Skip to content

feat(sdk): add an optional bounded dispatch policy for remote signing - #169

Merged
joeblau merged 1 commit into
mainfrom
feat/160-bounded-dispatch
Oct 7, 2026
Merged

joeblau merged 1 commit into
mainfrom
feat/160-bounded-dispatch

Conversation

@joeblau

@joeblau joeblau commented Oct 7, 2026

Copy link
Copy Markdown

Summary

Adds an opt-in bounded dispatch policy. With it, a ready signature for a signer can go out before a slower earlier one, while every outstanding nonce stays inside Hyperliquid's 100-highest window. Ordered dispatch remains the default, and when no policy is set the dispatchChains path does exactly what it did before.

new ExchangeClient({ transport, wallet, dispatchPolicy: { mode: "bounded", maxOvertakes: 99, maxPending: 1000 } });
  • Coordinator (_base/_dispatch.ts). There is one lane per (signer address × network) key. HTTP and WebSocket clients for the same key share it, as do the standalone functions. Each outstanding request keeps a cumulative count of newer requests dispatched ahead of it. That count lasts until the request's response settles, not just until it is sent, so the bound still holds when requests arrive out of order. Once any older request reaches maxOvertakes (≤ 99), newer requests wait. Among signed requests waiting for the window, the oldest goes first. Anything that blocks the older request also blocks the newer one, so this adds no stall, and it saves the older request an overtake. In the burst benchmark this raised throughput from about 970 to about 3,800 orders/s. maxOvertakes: 0 reproduces ordered dispatch.
  • Shell (_shell.ts). Policy checks now run under the nonce lock before a nonce is allocated: mixed ordered/bounded calls, mismatched limits, a full queue, invalid limits, and detached signing during an active lane. A rejected call no longer burns a nonce. The one exception is a custom nonceManager that returns a non-increasing nonce (documented on reserveDispatch). Just before dispatch, a bounded request re-checks the protocol timestamp window (+1 day / −2 days) and expiresAfter against config.runtime.now.
  • Settlement. A wallet rejection, an abort while signing or waiting, an expiry, a timestamp-window failure, a full queue, or a transport shutdown each settles the call and releases its slot. When a lane's last slot goes, the lane is dropped. A signature that completes after its call was aborted is never posted.
  • Prepared and detached submission. prepareRequest, submitPrepared, signAction/submitAction and exchange.sign/submit reject in bounded mode. They also reject while a bounded lane is active for the signer, including when a signed request is routed through another client's config. When no lane exists anywhere, the signer-address lookup is skipped, so the prepared path costs the same as before.
  • Quota. The transports are unchanged. WebSocket post frames are still charged to the shared message budget without waiting for it, separately from the nonce bound.
  • Corrected comments. _shell.ts, _nonce.ts, the _dispatchOrder.test.ts header and transports.md no longer describe strict wire ordering as a server requirement. The transports.md wording is fixed.
  • Docs. A new section in clients.md, "Bounded dispatch for remote signing", covers the limits, settlement, the timestamp re-check, the managed-only restriction, the burst trade-off, and coordination scope: separate processes, separately bundled SDK copies, direct transport calls and external submissions cannot be observed, so a dedicated signer is required.

Scope: nothing from #159 (order batcher) or #161 (per-operation entry points). tests/perf is untouched, so the CI perf-suite fingerprint is unchanged.

Acceptance criteria

  • Default dispatch stays ordered and compatible. dispatchPolicy is optional, and with no policy the dispatch-chain path is unchanged. The existing _dispatchOrder.test.ts cases pass with only comment changes. A new test checks that an omitted policy still dispatches in order behind a stalled signature.
  • A ready later signature dispatches before a slow earlier one when the bound permits. See ready signatures may overtake one stalled signature, but cumulative overtaking stops at 99.
  • Adversarial tests with more than 100 newer requests. The transports model the server's 100-highest set and assert, on arrival, that each nonce is unused and above min(set).
    • 120 newer requests overtake a stalled request. The stalled one dispatches at index 99.
    • 400 requests per seed, three seeds, with random signing latency (2% stall for 2 s) and random network delay, so arrival order differs from send order. A mutation that stops counting in-flight requests fails this test.
    • transport completion does not erase earlier pending nonce overtaking history.
  • Nonces stay unique across clients sharing a signer/network. _runtimeIntegration.test.ts runs a real HttpTransport (injected fetch) and a real WebSocketTransport (FakeSocket) for one multi-sig signer with a vault. All 6 requests have unique nonces, carry vaultAddress, and are multi-sig wrappers with 2 signatures.
  • Signature failure, cancellation, expiry and shutdown settle requests and release scheduling state. Separate tests cover a failed signature, an abort while signing (a late signature is never posted), an abort while waiting, expiry, the timestamp window in both directions, and a full queue. A new shutdown test closes the WebSocket transport while one request is signing, one is waiting and one is in flight. All three settle, and the same signer can then switch back to the ordered policy, which proves the lane was released.
  • An injected clock and a simulated server nonce set make the tests deterministic. They use config.runtime = FakeRuntime, counter nonces, a seeded PRNG and the ServerNonces model.
  • HTTP/WS quota, multi-sig, vault and prepared-submission interactions are covered.
    • Quota: with a one-message WS budget, 3 posts go out without waiting, the connection count is 1, and the next subscription frame now has to wait (acquireSend() returns a promise).
    • Multi-sig and vault: covered by the integration test above.
    • Prepared submission: prepareRequest, signAction, submitAction and submitPrepared reject in bounded mode or during an active lane, including through another client's config. They work again once the lane drains.
    • Policy rejections consume no nonce, which a test asserts.
  • Ordered vs bounded benchmark with variable remote-signing latency. It reports caller p50/p99, throughput and outstanding work (below).

Measurements

bun .dev/perf/remote_signing.ts on an Apple M3 Max with Bun 1.4.0. Each run sends 300 orders for one signer. Each signature takes a seeded random latency from the scenario's profile, and each request spends 5 ms on a mock transport. Orders arrive either open-loop at 1 per ms or as a single burst. Both modes see the same latency sequence, and modes alternate within each round. Every value is the median of 5 rounds. Timings use real timers.

  • stall: the first signature takes 200 ms; the rest take 2–10 ms, with 50 ms every 25th.
  • jitter: uniform 1–30 ms.
  • heavy_tail: 90% take 2 ms and 10% take 100 ms.

"Waiting" counts signed requests not yet dispatched. "In flight" counts requests on the transport.

scenario arrival mode p50 ms p99 ms orders/s peak signing peak waiting peak in flight
stall 1/ms ordered 55.1 202.1 903 11 191 175
stall 1/ms bounded 13.0 108.2 891 11 97 99
jitter 1/ms ordered 29.0 34.0 911 21 17 16
jitter 1/ms bounded 20.0 34.0 909 21 1 13
heavy_tail 1/ms ordered 99.0 105.0 751 16 90 34
heavy_tail 1/ms bounded 7.0 105.0 751 16 1 8
stall burst ordered 205.3 205.4 1,460 300 300 300
stall burst bounded 231.4 250.5 1,197 300 201 99
jitter burst ordered 34.3 34.5 8,691 300 273 296
jitter burst bounded 62.7 78.5 3,816 300 198 70
heavy_tail burst ordered 105.3 105.5 2,843 300 258 292
heavy_tail burst bounded 116.4 125.4 2,391 300 192 100

What the numbers show:

  • Open-loop traffic. Bounded dispatch cuts caller p50 by 1.5–14x. With a stalled signature it about halves p99, and it holds far less signed work waiting. Throughput matches the arrival rate in both modes.
  • Bursts well beyond the window. Ordered is faster here. It puts up to about 300 requests in flight at once and relies on them arriving in send order. Bounded counts each in-flight request against the 99-overtake window until its response returns, so it caps in-flight work at about 100. That is the price of the arrival-order-independent guarantee. The docs say so: bounded dispatch is for steady flow with variable signing latency, not a throughput setting. Before oldest-ready-first admission, the jitter burst ran at about 970 orders/s.

The default path shows no regression. Paired tests/perf --filter transaction runs against origin/main on the same machine fall within noise. order_sequential, order_sequential_unchecked, order_100_concurrent and prepare_request stay within the ±5–12% rme.

Test plan

  • bun run check (format, lint, docs, sdk incl. types / ts7 / jsdoc / export / imports)
  • bun run test:offline: 2057 pass, 0 fail
  • bun run build (published consumer checks pass)
  • bun .dev/perf/remote_signing.ts (table above)
  • Mutation checks:
    • Loosening the overtake cap fails 7 bounded tests.
    • No longer counting in-flight requests fails the randomized-arrival test.
    • Dropping the oldest-ready-first rule fails its test.

Closes #160

🤖 Generated with Claude Code

Ordered dispatch stays the default. `dispatchPolicy: { mode: "bounded" }` lets a ready signature
dispatch before a slower earlier one, while a per-(signer x network) lane keeps every outstanding
request within the exchange's 100-highest nonce window:

- Overtakes are counted cumulatively per outstanding request until its response settles, capped
  at maxOvertakes (<= 99); ready requests waiting for the window go oldest first.
- Policy checks run under the nonce lock before a nonce is allocated, so rejected calls (mixed
  policies, queue full, invalid limits, detached signing) consume no nonce.
- The protocol timestamp window and expiresAfter are re-checked at dispatch against config.runtime.
- Failures, aborts, expiry and transport shutdown settle the call and release the lane; a late
  signature after an abort is never posted.
- Detached signing/submission (sign/submit, prepareRequest/submitPrepared) rejects in bounded mode
  or while a bounded lane for the signer is active, including through another client's config.
- Comments and docs no longer describe strict wire ordering as a server requirement; clients.md
  documents the coordination scope.
- .dev/perf/remote_signing.ts compares ordered and bounded dispatch under variable signing latency.

Closes #160

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@joeblau
joeblau merged commit 3f414de into main Oct 7, 2026
6 checks passed
@joeblau
joeblau deleted the feat/160-bounded-dispatch branch October 7, 2026 00:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add an explicit bounded dispatch policy for out-of-order signing completion

1 participant