Repository navigation
feat(sdk): add an optional bounded dispatch policy for remote signing - #169
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
dispatchChainspath does exactly what it did before._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 reachesmaxOvertakes(≤ 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: 0reproduces ordered dispatch._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 customnonceManagerthat returns a non-increasing nonce (documented onreserveDispatch). Just before dispatch, a bounded request re-checks the protocol timestamp window (+1 day / −2 days) andexpiresAfteragainstconfig.runtime.now.prepareRequest,submitPrepared,signAction/submitActionandexchange.sign/submitreject 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.postframes are still charged to the shared message budget without waiting for it, separately from the nonce bound._shell.ts,_nonce.ts, the_dispatchOrder.test.tsheader andtransports.mdno longer describe strict wire ordering as a server requirement. Thetransports.mdwording is fixed.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/perfis untouched, so the CI perf-suite fingerprint is unchanged.Acceptance criteria
dispatchPolicyis optional, and with no policy the dispatch-chain path is unchanged. The existing_dispatchOrder.test.tscases pass with only comment changes. A new test checks that an omitted policy still dispatches in order behind a stalled signature.ready signatures may overtake one stalled signature, but cumulative overtaking stops at 99.min(set).transport completion does not erase earlier pending nonce overtaking history._runtimeIntegration.test.tsruns a realHttpTransport(injectedfetch) and a realWebSocketTransport(FakeSocket) for one multi-sig signer with a vault. All 6 requests have unique nonces, carryvaultAddress, and are multi-sig wrappers with 2 signatures.config.runtime = FakeRuntime, counter nonces, a seeded PRNG and theServerNoncesmodel.acquireSend()returns a promise).prepareRequest,signAction,submitActionandsubmitPreparedreject in bounded mode or during an active lane, including through another client's config. They work again once the lane drains.Measurements
bun .dev/perf/remote_signing.tson 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.
What the numbers show:
The default path shows no regression. Paired
tests/perf --filter transactionruns againstorigin/mainon the same machine fall within noise.order_sequential,order_sequential_unchecked,order_100_concurrentandprepare_requeststay 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 failbun run build(published consumer checks pass)bun .dev/perf/remote_signing.ts(table above)Closes #160
🤖 Generated with Claude Code