Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions apps/docs/content/docs/clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -818,3 +818,56 @@ exchange rejects a nonce it has already seen and the action cannot be applied tw
The standalone `signAction`, `submitAction`, and `executeAction` functions in
`@bloxwap/hyperliquid/actions/execution` accept the same exchange config. Existing client methods and
`prepareRequest` / `submitPrepared` continue to work.

## Bounded dispatch for remote signing

By default the SDK signs concurrently but sends a signer's requests in nonce-issuance order, so one slow remote
signature delays every later request for that signer and network. The exchange does not require that order: it accepts
any unused nonce above the smallest of the signer's 100 highest nonces. A dedicated signer can opt into bounded
dispatch, which sends ready signatures ahead of a slower earlier one:

```ts
const exchange = new ExchangeClient({
transport,
wallet,
dispatchPolicy: { mode: "bounded", maxOvertakes: 99, maxPending: 1000 },
});
```

- `maxOvertakes` (0–99, default 99) caps how many newer requests may be sent while an earlier one is still
outstanding. The count is cumulative until the earlier request's response settles, so newer requests that finish
quickly cannot keep overtaking it and push its nonce out of the exchange's window, even if requests arrive in a
different order than they were sent. Among signed requests waiting for the window, the oldest goes first. `0`
reproduces ordered dispatch.
- `maxPending` (default 1000) caps signing, waiting, and in-flight requests. A call beyond it rejects with
`Bounded dispatch queue is full` without consuming a nonce.
- Before sending, a request that waited is checked again against the protocol timestamp window (its nonce must be
within two days before and one day after the current time) and its `expiresAfter`. A failed check rejects the call
without sending it.
- A wallet rejection, an abort, an expiry, or a transport failure (including a closed WebSocket) settles the call and
frees its slot; the skipped nonce is a gap the exchange tolerates. A signature that completes after its call was
aborted is never sent. A signature that never completes holds back further overtaking until it is aborted, so pass a
`signal` with a deadline when the signer can stall.
- WebSocket and HTTP transport limits are unchanged and apply separately from the nonce bound: `post` frames are
still charged to the shared WebSocket message budget without waiting for it.

Bounded dispatch pays off when requests arrive steadily and some signatures are much slower than others: ready
requests no longer queue behind a slow one. It is not a throughput setting. A burst much larger than `maxOvertakes`
can finish later than under ordered dispatch, because ordered dispatch assumes requests arrive in the order they were
sent and keeps all of them in flight, while bounded dispatch counts each in-flight request against the window until
its response returns. Measure with your own signer and traffic before switching.

The coordinator is shared per signer address and network across every `ExchangeClient` and standalone function in
the process, HTTP and WebSocket alike, and so is the default nonce source. Multi-sig requests are coordinated by the
leader signer, the same key their nonces are issued under; vault and sub-account requests share their signer's lane.

Bounded mode supports managed calls only: ordinary exchange methods, `exchange.execute`, and `executeAction`.
`exchange.sign` / `exchange.submit`, `signAction` / `submitAction`, `prepareRequest`, and `submitPrepared` reject in
bounded mode, and also reject while bounded calls for that signer and network are outstanding, because a detached
submission's nonce would escape the bound. Ordered and bounded calls, or bounded calls with different limits, cannot be
active for the same signer and network at the same time.

The coordinator only sees requests made through this process. Other processes, other SDK instances bundled separately,
direct `transport.request` calls, and anything that submits for the same signer elsewhere also consume nonces in its
100-highest window, and the bound cannot account for them. Use bounded dispatch only with a signer (typically an API
wallet) dedicated to this process.
8 changes: 5 additions & 3 deletions apps/docs/content/docs/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,9 +365,11 @@ Outbound messages are **paced by default**: the shared quota runs a token bucket
re-subscribes every held subscription at once, so 1000 subscriptions spend half the minute's budget instantly, and a
flapping socket repeats the burst until the server refuses.

Pacing only ever delays `subscribe` and `unsubscribe` frames. **`post` requests and keep-alive pings never wait**: an
exchange action's wire order — and therefore per-wallet nonce ordering — depends on reaching the socket synchronously,
and delaying the keep-alive watchdog is how a half-open connection goes unnoticed. Both still *debit* the budget, so a
Pacing only ever delays `subscribe` and `unsubscribe` frames. **`post` requests and keep-alive pings never wait**: the
SDK's default ordered dispatch policy fixes an exchange action's wire order by reaching the socket synchronously (the
exchange itself only requires each nonce to be unused and within the signer's 100-highest window; see
[bounded dispatch](clients.md#bounded-dispatch-for-remote-signing)), and delaying the keep-alive watchdog is how a
half-open connection goes unnoticed. Both still *debit* the budget, so a
burst of orders correctly slows subscription traffic rather than silently overrunning the shared limit.

To opt out of pacing — or to resize the bucket — pass your own `quota`; constructed without `rateLimit`, it keeps the
Expand Down
174 changes: 174 additions & 0 deletions packages/hyperliquid/.dev/perf/remote_signing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/**
* Ordered versus bounded dispatch under variable remote-signing latency.
* bun .dev/perf/remote_signing.ts [--filter stall] [--out /tmp/remote-signing.json]
*
* Orders for one signer arrive either open-loop, one per millisecond, or as a single burst. Each signature takes a seeded random
* latency from the scenario's profile, standing in for an `eth_signTypedData_v4` round trip to a
* remote wallet, and each request spends a fixed time on a mock transport. Reported per mode:
* - caller latency p50/p99, from arrival to settled response;
* - throughput, orders over the time from the first arrival to the last response;
* - outstanding work: peak signatures in progress, peak signed requests waiting to dispatch, and
* peak requests in flight on the transport.
*
* Both modes see the same arrival and latency sequence; modes alternate inside every round so
* machine drift hits each one equally, and each metric is the median over rounds. Numbers are
* wall-clock timings with real timers and mocks, not production latency.
* @module
*/
import { writeFile } from "node:fs/promises";
import type { DispatchPolicy } from "../../src/api/exchange/_methods/_base/_dispatch.ts";
import type { ExchangeConfig } from "../../src/api/exchange/_methods/_base/_config.ts";
import { buildOrder } from "../../src/api/exchange/_methods/order.ts";
import { executeAction } from "../../src/actions/execution.ts";
import { delay } from "../../src/transport/runtime.ts";

const filterIndex = process.argv.indexOf("--filter");
const filter = filterIndex >= 0 ? process.argv[filterIndex + 1] : undefined;
const outIndex = process.argv.indexOf("--out");
const rounds = 5;
const orders = 300;
/** Gap between arrivals: `1` is open-loop at 1000 orders/s, `0` submits every order at once. */
const arrivals = [1, 0];
const transportMs = 5;
const signature = `0x${"11".repeat(64)}1b` as const;
const action = buildOrder({
orders: [{ a: 0, b: true, p: "30000", s: "0.1", r: false, t: { limit: { tif: "Gtc" } } }],
});
const response = { status: "ok", response: { type: "order", data: { statuses: [{ resting: { oid: 1 } }] } } };

/** Signing-latency profiles, as a function of a uniform random value and the order index. */
const scenarios: Record<string, (u: number, i: number) => number> = {
// One slow signature at the start, then 2-10 ms with an occasional 50 ms.
stall: (u, i) => (i === 0 ? 200 : i % 25 === 0 ? 50 : 2 + u * 8),
// Uniform 1-30 ms.
jitter: (u) => 1 + u * 29,
// 90% at 2 ms, 10% at 100 ms.
heavy_tail: (u) => (u < 0.9 ? 2 : 100),
};

const policies: Record<string, DispatchPolicy> = {
ordered: "ordered",
bounded: { mode: "bounded", maxOvertakes: 99 },
};

/** Deterministic PRNG (mulberry32), so both modes see the same latency sequence. */
function random(seed: number): () => number {
return () => {
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}

function percentile(values: number[], p: number): number {
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * p) - 1)];
}

const median = (values: number[]): number => percentile(values, 0.5);

let walletId = 0x5000;

interface Sample {
p50Ms: number;
p99Ms: number;
ordersPerSecond: number;
peakSigning: number;
peakWaiting: number;
peakInFlight: number;
}

async function run(
profile: (u: number, i: number) => number,
policy: DispatchPolicy,
arrivalEveryMs: number,
seed: number,
): Promise<Sample> {
const next = random(seed);
const latencies = Array.from({ length: orders }, (_, i) => profile(next(), i));
let signing = 0;
let waiting = 0;
let inFlight = 0;
let peakSigning = 0;
let peakWaiting = 0;
let peakInFlight = 0;
let signed = 0;
let lastNonce = 0;
const config: ExchangeConfig = {
wallet: {
address: `0x${(++walletId).toString(16).padStart(40, "0")}`,
async signTypedData(): Promise<`0x${string}`> {
const i = signed++;
peakSigning = Math.max(peakSigning, ++signing);
await delay(latencies[i]);
signing--;
peakWaiting = Math.max(peakWaiting, ++waiting);
return signature;
},
},
// Wall-clock nonces: bounded dispatch checks them against the protocol timestamp window.
nonceManager: () => (lastNonce = Math.max(Date.now(), lastNonce + 1)),
dispatchPolicy: policy,
transport: {
isTestnet: true,
async request<T>(): Promise<T> {
waiting--;
peakInFlight = Math.max(peakInFlight, ++inFlight);
await delay(transportMs);
inFlight--;
return response as T;
},
},
};
const callerLatencies: number[] = [];
const start = performance.now();
const calls: Promise<void>[] = [];
for (let i = 0; i < orders; i++) {
const arrived = performance.now();
calls.push(
executeAction(config, action).then(() => {
callerLatencies.push(performance.now() - arrived);
}),
);
if (arrivalEveryMs > 0) await delay(arrivalEveryMs);
}
await Promise.all(calls);
const elapsed = performance.now() - start;
return {
p50Ms: percentile(callerLatencies, 0.5),
p99Ms: percentile(callerLatencies, 0.99),
ordersPerSecond: (orders * 1000) / elapsed,
peakSigning,
peakWaiting,
peakInFlight,
};
}

const report: Record<string, unknown>[] = [];
for (const [scenario, profile] of Object.entries(scenarios)) {
if (filter !== undefined && !scenario.includes(filter)) continue;
for (const arrivalEveryMs of arrivals) {
const samples: Record<string, Sample[]> = { ordered: [], bounded: [] };
for (let round = 0; round < rounds; round++) {
const modes = round % 2 === 0 ? ["ordered", "bounded"] : ["bounded", "ordered"];
for (const mode of modes) samples[mode].push(await run(profile, policies[mode], arrivalEveryMs, round + 1));
}
for (const [mode, rows] of Object.entries(samples)) {
const summary: Record<string, unknown> = { scenario, arrival: arrivalEveryMs ? "1/ms" : "burst", mode };
for (const key of Object.keys(rows[0]) as (keyof Sample)[]) {
summary[key] = Number(median(rows.map((row) => row[key])).toFixed(2));
}
report.push(summary);
}
}
}

console.log(`${orders} orders per run, ${transportMs} ms transport, median of ${rounds} rounds`);
console.table(report);
if (outIndex >= 0) {
await writeFile(
process.argv[outIndex + 1],
JSON.stringify({ runtime: process.versions, orders, transportMs, rounds, report }, null, 2),
);
}
9 changes: 6 additions & 3 deletions packages/hyperliquid/src/actions/execution.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/** Explicit signing and execution of canonical actions. @module */
import { HyperliquidError } from "../_base.ts";
import type { ExchangeConfig } from "../api/exchange/_methods/_base/_config.ts";
import { assertDetachedAllowed } from "../api/exchange/_methods/_base/_dispatch.ts";
import { executeL1Action, executeUserSignedAction } from "../api/exchange/_methods/_base/execute.ts";
import {
getSigningContext,
Expand Down Expand Up @@ -56,7 +57,7 @@ function run<T>(
* @param action Action produced by an SDK builder.
* @param options Signing options and cancellation signal.
* @return An immutable signed request with the action's response type.
* @throws {HyperliquidError} When action ownership is invalid.
* @throws {HyperliquidError} When action ownership or dispatch policy is invalid.
*/
export async function signAction<T>(
config: ExchangeConfig,
Expand Down Expand Up @@ -84,7 +85,7 @@ export async function signAction<T>(
* @param signed Signed request produced by signAction.
* @param options Cancellation signal for submission.
* @return The action's response.
* @throws {HyperliquidError} When request ownership, network, is invalid.
* @throws {HyperliquidError} When request ownership, network, or dispatch policy is invalid.
*/
export async function submitAction<T>(
config: ExchangeConfig,
Expand All @@ -95,6 +96,7 @@ export async function submitAction<T>(
if (!owner || owner.isTestnet !== config.transport.isTestnet) {
throw new HyperliquidError("Signed request ownership or network mismatch; rebuild and sign for this network");
}
assertDetachedAllowed(owner.key, config.dispatchPolicy);
const response = await config.transport.request<T>("exchange", signed, options?.signal);
assertSuccessResponse(response);
return response;
Expand All @@ -106,7 +108,7 @@ export async function submitAction<T>(
* @param action Action produced by an SDK builder.
* @param options Signing options and cancellation signal.
* @return The action's response.
* @throws {HyperliquidError} When action ownership is invalid.
* @throws {HyperliquidError} When action ownership or dispatch policy is invalid.
*/
export function executeAction<T>(
config: ExchangeConfig,
Expand All @@ -121,3 +123,4 @@ export function executeAction<T>(
}

export { createNonceManager, type NonceManager } from "../api/exchange/_methods/_base/_nonce.ts";
export type { DispatchPolicy } from "../api/exchange/_methods/_base/_dispatch.ts";
16 changes: 16 additions & 0 deletions packages/hyperliquid/src/api/exchange/_methods/_base/_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@

import type { AbstractWallet } from "../../../../signing/mod.ts";
import type { IRequestTransport } from "../../../../transport/mod.ts";
import type { Runtime } from "../../../../transport/runtime.ts";
import type { SymbolConverter } from "../../../../utils/mod.ts";
import type { DispatchPolicy } from "./_dispatch.ts";

// ============================================================
// Type Utilities
Expand All @@ -27,6 +29,20 @@ interface BaseConfig<T extends IRequestTransport = IRequestTransport> {
/** The transport used to connect to the Hyperliquid Exchange API. */
transport: T;

/**
* How signed requests for this signer/network reach the transport. Default: `"ordered"`, which
* dispatches in nonce issuance order. `{ mode: "bounded" }` lets a ready signature overtake a
* slower earlier one within the exchange's nonce window; it supports managed execution only and
* assumes this process is the signer's only nonce producer.
*/
dispatchPolicy?: DispatchPolicy;

/**
* Wall clock used by bounded dispatch to re-check the nonce timestamp window and `expiresAfter`
* immediately before sending. Only `now` is read. Default: the platform clock.
*/
runtime?: Partial<Runtime>;

/** Signature chain ID for EIP-712 signing, defaults to wallet's chain ID. */
signatureChainId?: `0x${string}` | (() => MaybePromise<`0x${string}`>);

Expand Down
Loading
Loading