From 1e2b196a20dab30d4d3ad07c13474a784783922e Mon Sep 17 00:00:00 2001 From: Joe Blau Date: Wed, 7 Oct 2026 08:21:29 +0800 Subject: [PATCH 1/2] feat(sdk): add an opt-in order batcher with bounded queues and per-order results createOrderBatcher (actions/orderBatcher) collects independent single-order callers into shared order actions: one signature, one nonce, and one request per batch, with each caller settling exactly once with its own status. - Size, time, and explicit flush thresholds; bounded queue with immediate rejection when full; queued and post-dispatch cancellation; expiry; close with or without drain. Never retries an ambiguous batch. - Partitions by grouping, builder, vault, expiresAfter, and order class (ALO apart from IOC/GTC; split by tif and reduce-only under priority grouping). - Mixed responses keep successful items; whole-batch rejection, cardinality mismatch, and transport failure reject every caller in the batch. - Orders are validated per caller at enqueue and the batch is executed through the shared executeAction core without validating each order again. - Tests on FakeRuntime, including wire parity with order(), immediate dispatch of ExchangeClient.order next to a batcher, and HTTP weight charged once per batch. Benchmark in .dev/perf/order_batching.ts; docs in clients.md. Co-Authored-By: Claude Opus 5.5 --- apps/docs/content/docs/clients.md | 77 +++++ packages/hyperliquid/.dev/build/consumer.ts | 4 +- .../hyperliquid/.dev/perf/order_batching.ts | 160 +++++++++ packages/hyperliquid/package.json | 1 + packages/hyperliquid/src/actions/mod.ts | 1 + .../hyperliquid/src/actions/orderBatcher.ts | 295 +++++++++++++++++ .../tests/api/exchange/_batcherCounts.test.ts | 59 ++++ .../tests/api/exchange/_orderBatcher.test.ts | 310 ++++++++++++++++++ 8 files changed, 906 insertions(+), 1 deletion(-) create mode 100644 packages/hyperliquid/.dev/perf/order_batching.ts create mode 100644 packages/hyperliquid/src/actions/orderBatcher.ts create mode 100644 packages/hyperliquid/tests/api/exchange/_batcherCounts.test.ts create mode 100644 packages/hyperliquid/tests/api/exchange/_orderBatcher.test.ts diff --git a/apps/docs/content/docs/clients.md b/apps/docs/content/docs/clients.md index 7afbb446..32cfc1bc 100644 --- a/apps/docs/content/docs/clients.md +++ b/apps/docs/content/docs/clients.md @@ -473,6 +473,9 @@ of any CPU saving. Separate actions are genuinely required only when the orders differ in a field the action carries once rather than per order — `vaultAddress`, `expiresAfter`, `builder`, or `grouping` itself. +When the orders come from independent callers rather than one place in your code, the +[order batcher](#optional-order-batching) collects them into shared actions for you. + ### Orders over WebSocket (low latency) Every `ExchangeClient` method also works over [`WebSocketTransport`](transports.md#websocket) — the server accepts @@ -819,6 +822,80 @@ 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. +## Optional order batching + +Independent callers that each place one order can share signatures and requests through an opt-in batcher. Each +caller still gets its own result: + +```ts +import { createOrderBatcher } from "@bloxwap/hyperliquid/actions/orderBatcher"; + +const batcher = createOrderBatcher(exchange.config, { + maxQueueSize: 1000, // queued plus in-flight orders + maxBatchSize: 100, // orders per request + flushIntervalMs: 1, // longest wait for a partial batch +}); +const outcome = await batcher.enqueue({ + a: 0, b: true, p: "30000", s: "0.01", r: false, t: { limit: { tif: "Gtc" } }, +}); +if (typeof outcome === "object" && "error" in outcome) console.error(outcome.error); +await batcher.close(); +``` + +Queued orders are sent when a batch reaches `maxBatchSize` (which flushes the whole queue), when `flushIntervalMs` +elapses, or when you call `flush()`. Each request carries one action, one signature, and one nonce, through the same +signing path as `exchange.execute`, so nonce management, the dispatch policy, and the transport's rate limiting +apply unchanged. `exchange.order()` and the other existing methods still dispatch immediately and keep their error +behavior. + +Only orders that agree on everything the action or request carries once share a batch: `grouping`, `builder`, +`vaultAddress`, and `expiresAfter`. ALO orders are also kept apart from IOC and GTC orders so they keep their +prioritization, and under priority grouping (`{ p }`), where the exchange requires every order in the action to be IOC +or every order to be a non-reduce-only ALO, orders are further split by time-in-force and reduce-only flag. The batcher +is bound to one config, so signer and network never mix. + +Results: + +- `enqueue` resolves with the server's status for that order, in the order it was submitted: `resting`, `filled`, + `waitingForFill`, `waitingForTrigger`, or `{ error }`. An error status for one order does not affect the others in + its batch; unlike `exchange.order()`, mixed responses are never turned into a rejection. +- A whole-batch failure rejects every caller in that batch: a top-level `status: "err"`, a response whose statuses do + not match the submitted orders one to one, or a transport failure. +- An invalid order rejects only its own `enqueue`, before it is queued. +- A full queue rejects `enqueue` immediately with `Order batcher queue is full`. +- An order whose `expiresAfter` has passed before its batch is sent rejects without being sent. + +Cancellation and shutdown: + +- Aborting an `enqueue` signal before its batch is sent removes the order; nothing is sent for it. +- Aborting after the batch is sent only stops that caller waiting. It does not cancel the order, which the exchange may + already have accepted, and its slot counts against `maxQueueSize` until the batch settles. +- `flush()` sends everything queued and waits for every batch in flight. +- `close()` stops new enqueues and drains: queued orders are sent and their callers settle normally. +- `close({ drain: false })` rejects every waiting caller and stops waiting for in-flight batches. Orders already sent + may still be accepted; the rejection does not mean they were cancelled. +- The batcher never retries a batch. A failure after sending is ambiguous (the exchange may have applied the action), + so reconcile with open orders or fills before placing the orders again. + +Rate limits: each request costs `1 + floor(orders / 40)` of the per-IP REST weight, so 100 orders in one batch cost 3 +instead of 100. Address-based limits still count every order individually; batching does not raise them. + +Batching trades latency for throughput. It helps when many orders arrive together, and costs a timer hop when they +arrive one at a time. Measured with 300 single-order callers, real secp256k1 signing, and a 5 ms mock transport +(`bun .dev/perf/order_batching.ts`, Bun 1.4.0, Apple M3 Max, median of 5 rounds): + +| Callers arrive | Mode | Orders/s | p50 / p99 latency | Signatures and requests | Weight | +| ------------------- | --------------------- | -------- | ----------------- | ----------------------- | ------ | +| All at once | `order()` per caller | ~3,800 | 74 / 76 ms | 300 | 300 | +| All at once | batch 5, flush 1 ms | ~16,000 | 13 / 18 ms | 60 | 60 | +| All at once | batch 100, flush 0 ms | ~43,000 | 6 / 7 ms | 3 | 9 | +| One per millisecond | `order()` per caller | ~900 | 5.1 / 6.5 ms | 300 | 300 | +| One per millisecond | batch 100, flush 1 ms | ~680 | 6.5 / 9.2 ms | 300 | 300 | +| One per millisecond | batch 100, flush 5 ms | ~680 | 9.1 / 12.3 ms | 75 | 75 | + +With steady arrivals, a flush interval shorter than the gap between orders sends batches of one and only adds delay; +a longer one batches more but makes every caller wait for it. Measure with your own traffic before choosing values. + ## 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 diff --git a/packages/hyperliquid/.dev/build/consumer.ts b/packages/hyperliquid/.dev/build/consumer.ts index d9393bf5..81149763 100644 --- a/packages/hyperliquid/.dev/build/consumer.ts +++ b/packages/hyperliquid/.dev/build/consumer.ts @@ -3,6 +3,7 @@ import { ExchangeClient, HttpTransport } from "@bloxwap/hyperliquid"; import { allMids } from "@bloxwap/hyperliquid/api/info/allMids"; import { buildOrder } from "@bloxwap/hyperliquid/actions/order"; import { executeAction, signAction, submitAction } from "@bloxwap/hyperliquid/actions/execution"; +import { createOrderBatcher, type OrderOutcome } from "@bloxwap/hyperliquid/actions/orderBatcher"; import type { OrderSuccessResponse } from "@bloxwap/hyperliquid/api/exchange/order"; import type { ExchangeConfig } from "@bloxwap/hyperliquid/api/exchange"; // @ts-expect-error Private declaration paths remain closed by the published export map. @@ -21,7 +22,8 @@ const submitted: OrderSuccessResponse = await client.submit(signed); const executed: OrderSuccessResponse = await client.execute(action); const staged: OrderSuccessResponse = await submitAction(config, await signAction(config, action)); const direct: OrderSuccessResponse = await executeAction(config, action); -void [submitted, executed, staged, direct]; +const outcome: OrderOutcome = await createOrderBatcher(config).enqueue(input); +void [submitted, executed, staged, direct, outcome]; // @ts-expect-error The published type exposes an immutable signature. signed.signature.v = 28; // @ts-expect-error Signed action fields are immutable as well. diff --git a/packages/hyperliquid/.dev/perf/order_batching.ts b/packages/hyperliquid/.dev/perf/order_batching.ts new file mode 100644 index 00000000..3230df6a --- /dev/null +++ b/packages/hyperliquid/.dev/perf/order_batching.ts @@ -0,0 +1,160 @@ +/** + * Order batching: throughput, latency, queue depth, and signature/request counts across batch sizes + * and flush intervals, against per-caller `order()` dispatch. + * bun .dev/perf/order_batching.ts [--filter burst] [--out /tmp/order-batching.json] + * + * Independent callers each place one order, either as a single burst or open-loop at one order per + * millisecond. Every request is signed with a real secp256k1 key and spends a fixed time on a mock + * transport. Reported per configuration: + * - caller latency p50/p99, from enqueue to settled outcome; + * - throughput, orders over the time from the first arrival to the last outcome; + * - peak queue depth (`batcher.pending`: queued plus in-flight orders); + * - signatures, requests, and REST weight (`1 + floor(orders / 40)` per request). + * + * Configurations 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 { privateKeyToAccount } from "viem/accounts"; +import type { ExchangeConfig } from "../../src/api/exchange/_methods/_base/_config.ts"; +import { order } from "../../src/api/exchange/_methods/order.ts"; +import { createOrderBatcher } from "../../src/actions/orderBatcher.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 = [0, 1]; +const transportMs = 5; +const batchSizes = [1, 5, 25, 100]; +const flushIntervals = [0, 1, 5]; +const key = privateKeyToAccount(`0x${"22".repeat(32)}`); +const input = { a: 0, b: true, p: "30000", s: "0.1", r: false, t: { limit: { tif: "Gtc" as const } } }; + +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); + +interface Sample { + ordersPerSecond: number; + p50Ms: number; + p99Ms: number; + peakQueue: number; + signatures: number; + requests: number; + weight: number; +} + +/** `undefined` batching options measure one `order()` call per caller. */ +async function run(batching: { maxBatchSize: number; flushIntervalMs: number } | undefined, arrivalEveryMs: number) { + let signatures = 0; + let requests = 0; + let weight = 0; + let lastNonce = 0; + const config: ExchangeConfig = { + wallet: { + address: key.address, + sign: (args) => { + signatures++; + return key.sign(args); + }, + signTypedData: (typedData) => { + signatures++; + return key.signTypedData(typedData); + }, + }, + nonceManager: () => (lastNonce = Math.max(Date.now(), lastNonce + 1)), + transport: { + isTestnet: true, + async request(_endpoint: "info" | "exchange", payload: unknown): Promise { + const count = (payload as { action: { orders: unknown[] } }).action.orders.length; + requests++; + weight += 1 + Math.floor(count / 40); + await delay(transportMs); + const statuses = Array.from({ length: count }, (_, i) => ({ resting: { oid: i + 1 } })); + return { status: "ok", response: { type: "order", data: { statuses } } } as T; + }, + }, + }; + const batcher = batching && createOrderBatcher(config, batching); + let outstanding = 0; + let peakQueue = 0; + const latencies: number[] = []; + const calls: Promise[] = []; + const start = performance.now(); + for (let i = 0; i < orders; i++) { + const arrived = performance.now(); + outstanding++; + calls.push( + (batcher ? batcher.enqueue(input) : order(config, { orders: [input] })).then(() => { + latencies.push(performance.now() - arrived); + outstanding--; + }), + ); + peakQueue = Math.max(peakQueue, batcher ? batcher.pending : outstanding); + if (arrivalEveryMs > 0) await delay(arrivalEveryMs); + } + await Promise.all(calls); + const elapsed = performance.now() - start; + await batcher?.close(); + return { + ordersPerSecond: (orders * 1000) / elapsed, + p50Ms: percentile(latencies, 0.5), + p99Ms: percentile(latencies, 0.99), + peakQueue, + signatures, + requests, + weight, + } satisfies Sample; +} + +const configs: [string, { maxBatchSize: number; flushIntervalMs: number } | undefined][] = [ + ["direct", undefined], + ...batchSizes.flatMap((maxBatchSize) => + flushIntervals.map((flushIntervalMs): [string, { maxBatchSize: number; flushIntervalMs: number }] => [ + `batch ${maxBatchSize}/${flushIntervalMs}ms`, + { maxBatchSize, flushIntervalMs }, + ]), + ), +]; + +const report: Record[] = []; +for (const arrivalEveryMs of arrivals) { + const arrival = arrivalEveryMs ? "1/ms" : "burst"; + if (filter !== undefined && !arrival.includes(filter)) continue; + const samples = new Map(configs.map(([name]) => [name, []])); + for (let round = 0; round < rounds; round++) { + const sequence = round % 2 === 0 ? configs : [...configs].reverse(); + for (const [name, batching] of sequence) samples.get(name)!.push(await run(batching, arrivalEveryMs)); + } + for (const [name, batching] of configs) { + const rows = samples.get(name)!; + const summary: Record = { + arrival, + mode: batching ? "batcher" : "direct", + maxBatchSize: batching?.maxBatchSize ?? "-", + flushIntervalMs: batching?.flushIntervalMs ?? "-", + }; + for (const metric of Object.keys(rows[0]) as (keyof Sample)[]) { + summary[metric] = Number(median(rows.map((row) => row[metric])).toFixed(2)); + } + report.push(summary); + } +} + +console.log(`${orders} orders per run, ${transportMs} ms transport, real signing, 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), + ); +} diff --git a/packages/hyperliquid/package.json b/packages/hyperliquid/package.json index c8535b6f..f3e02025 100644 --- a/packages/hyperliquid/package.json +++ b/packages/hyperliquid/package.json @@ -304,6 +304,7 @@ "./actions/vaultModify": "./src/actions/vaultModify.ts", "./actions/vaultTransfer": "./src/actions/vaultTransfer.ts", "./actions/withdraw3": "./src/actions/withdraw3.ts", + "./actions/orderBatcher": "./src/actions/orderBatcher.ts", "./actions/stakingDeposit": "./src/actions/stakingDeposit.ts", "./actions/stakingWithdraw": "./src/actions/stakingWithdraw.ts", "./actions/validatorAction": "./src/actions/validatorAction.ts", diff --git a/packages/hyperliquid/src/actions/mod.ts b/packages/hyperliquid/src/actions/mod.ts index 99d8a828..0b484380 100644 --- a/packages/hyperliquid/src/actions/mod.ts +++ b/packages/hyperliquid/src/actions/mod.ts @@ -61,6 +61,7 @@ export * from "./vaultModify.ts"; export * from "./vaultTransfer.ts"; export * from "./withdraw3.ts"; +export * from "./orderBatcher.ts"; export * from "./stakingDeposit.ts"; export * from "./stakingWithdraw.ts"; export * from "./validatorAction.ts"; diff --git a/packages/hyperliquid/src/actions/orderBatcher.ts b/packages/hyperliquid/src/actions/orderBatcher.ts new file mode 100644 index 00000000..59e54ec4 --- /dev/null +++ b/packages/hyperliquid/src/actions/orderBatcher.ts @@ -0,0 +1,295 @@ +/** Optional bounded microbatching for independent order callers. @module */ +import { HyperliquidError } from "../_base.ts"; +import { ApiRequestError } from "../api/_errors.ts"; +import type { ExchangeConfig } from "../api/exchange/_methods/_base/_config.ts"; +import { + buildOrder, + type OrderOptions, + type OrderParameters, + type OrderResponse, + type OrderSuccessResponse, +} from "../api/exchange/_methods/order.ts"; +import { resolveRuntime, type Runtime, type TimerHandle } from "../transport/runtime.ts"; +import { canonicalAction, immutableCopy } from "./_canonical.ts"; +import { type ActionOptions, executeAction } from "./execution.ts"; + +/** Per-item server outcome. Transport/top-level failures reject the enqueue promise. */ +export type OrderOutcome = OrderResponse["response"]["data"]["statuses"][number]; + +function isOrderOutcome(value: unknown): value is OrderOutcome { + if (value === "waitingForFill" || value === "waitingForTrigger") return true; + if (!value || typeof value !== "object") return false; + if ("error" in value) return typeof value.error === "string"; + if ("resting" in value) { + const resting = value.resting; + return !!resting && typeof resting === "object" && "oid" in resting && Number.isSafeInteger(resting.oid); + } + if ("filled" in value) { + const filled = value.filled; + return ( + !!filled && + typeof filled === "object" && + "oid" in filled && + Number.isSafeInteger(filled.oid) && + "totalSz" in filled && + typeof filled.totalSz === "string" && + "avgPx" in filled && + typeof filled.avgPx === "string" + ); + } + return false; +} + +/** + * Order class for partitioning. ALO stays apart from IOC/GTC to keep its prioritization; under + * priority grouping the exchange requires every order to be IOC or every order a non-reduce-only + * ALO, so the class also carries the time-in-force and reduce-only flag. + */ +function orderClass(order: OrderParameters["orders"][number], priority: boolean): string { + const tif = "limit" in order.t ? order.t.limit.tif : "trigger"; + if (priority) return `${tif}:${order.r}`; + return tif === "Alo" ? "alo" : "other"; +} + +/** Queue limits and scheduling. */ +export interface OrderBatcherOptions { + /** Maximum queued plus in-flight orders. Default: 1000. */ + maxQueueSize?: number; + /** Maximum orders per request. Default: 100. */ + maxBatchSize?: number; + /** Delay before automatically flushing queued orders. Default: 1 ms. */ + flushIntervalMs?: number; + /** Isolated scheduler and wall clock. */ + runtime?: Partial; +} +/** Grouping and signing options must match for orders to share a batch. */ +export type EnqueueOrderOptions = Pick & Omit; +interface Entry { + key: string; + params: OrderParameters; + options: Omit; + resolve: (outcome: OrderOutcome) => void; + reject: (reason: unknown) => void; + detach: () => void; + dispatched: boolean; + settled: boolean; +} + +/** Explicit lifecycle for batching; callers may flush immediately or close with drain/abort. */ +export class OrderBatcher { + private readonly _runtime: Runtime; + private readonly _maxQueue: number; + private readonly _maxBatch: number; + private readonly _interval: number; + private readonly _config: ExchangeConfig; + private readonly _queue: Entry[] = []; + /** Compatible queued counts avoid a full backlog scan on every enqueue. */ + private readonly _queuedByKey = new Map(); + private readonly _active = new Set(); + private readonly _requests = new Set>(); + private readonly _abort = new AbortController(); + private _timer?: TimerHandle; + private _closed = false; + private _draining?: Promise; + + constructor(config: ExchangeConfig, options?: OrderBatcherOptions) { + this._config = { ...config }; + this._runtime = resolveRuntime(options?.runtime); + this._maxQueue = options?.maxQueueSize ?? 1000; + this._maxBatch = options?.maxBatchSize ?? 100; + this._interval = options?.flushIntervalMs ?? 1; + if ( + !Number.isSafeInteger(this._maxQueue) || + this._maxQueue < 1 || + !Number.isSafeInteger(this._maxBatch) || + this._maxBatch < 1 || + !Number.isFinite(this._interval) || + this._interval < 0 || + this._interval > 2_147_483_647 + ) { + throw new RangeError("Batch limits must be positive integers and flushIntervalMs a finite timer delay"); + } + } + + /** Outstanding queued and dispatched orders, including callers awaiting a response. */ + get pending(): number { + return this._active.size; + } + + /** Queue one raw-asset order. Cancelling after dispatch only stops this caller waiting. */ + enqueue(input: OrderParameters["orders"][number], options?: EnqueueOrderOptions): Promise { + if (this._closed) return Promise.reject(new HyperliquidError("Order batcher is closed")); + if (options?.signal?.aborted) return Promise.reject(options.signal.reason); + if (this.pending >= this._maxQueue) return Promise.reject(new HyperliquidError("Order batcher queue is full")); + try { + // Validated per caller, so one malformed order cannot fail its batch. + const params = buildOrder({ orders: [input], grouping: options?.grouping, builder: options?.builder }) + .payload as OrderParameters; + const vaultAddress = options?.vaultAddress ?? this._config.defaultVaultAddress; + const expiresAfter = options?.expiresAfter ?? this._config.defaultExpiresAfter; + // Dynamic defaults are resolved per actual batch by the execution core. + const signingOptions = immutableCopy({ + vaultAddress, + expiresAfter: typeof expiresAfter === "function" ? undefined : expiresAfter, + }); + const key = JSON.stringify([ + params.grouping, + params.builder, + signingOptions, + orderClass(params.orders[0], typeof params.grouping === "object"), + ]); + return new Promise((resolve, reject) => { + const entry: Entry = { + key, + params, + options: signingOptions, + resolve, + reject, + detach: () => {}, + dispatched: false, + settled: false, + }; + if (options?.signal) { + const signal = options.signal; + const onAbort = (): void => { + if (entry.dispatched) { + // Keep capacity occupied until the actual batch settles: server acceptance is unknown. + this._settle(entry, undefined, signal.reason, true); + } else { + const index = this._queue.indexOf(entry); + if (index >= 0) { + this._queue.splice(index, 1); + this._decrementQueued(entry.key); + } + this._settle(entry, undefined, signal.reason); + if (this._queue.length === 0) this._clearTimer(); + } + }; + signal.addEventListener("abort", onAbort, { once: true }); + entry.detach = (): void => signal.removeEventListener("abort", onAbort); + } + this._active.add(entry); + this._queue.push(entry); + const queued = (this._queuedByKey.get(key) ?? 0) + 1; + this._queuedByKey.set(key, queued); + if (queued >= this._maxBatch) this._dispatch(); + else this._armTimer(); + }); + } catch (error) { + return Promise.reject(error); + } + } + + /** Immediately dispatch queued orders and wait for all batches outstanding at invocation. */ + async flush(): Promise { + this._clearTimer(); + this._dispatch(); + await Promise.all([...this._requests]); + } + + /** Stop accepting orders; drain by default, or abort waiting callers without implying server cancellation. */ + close(options?: { drain?: boolean }): Promise { + this._closed = true; + this._clearTimer(); + if (options?.drain === false) { + const error = new HyperliquidError("Order batcher closed; dispatched orders may already be accepted"); + this._abort.abort(error); + this._queue.length = 0; + this._queuedByKey.clear(); + for (const entry of this._active) this._settle(entry, undefined, error); + return Promise.resolve(); + } + return (this._draining ??= this.flush()); + } + + private _clearTimer(): void { + this._runtime.clearTimeout(this._timer); + this._timer = undefined; + } + private _decrementQueued(key: string): void { + const remaining = this._queuedByKey.get(key)! - 1; + if (remaining === 0) this._queuedByKey.delete(key); + else this._queuedByKey.set(key, remaining); + } + private _armTimer(): void { + if (this._timer === undefined && this._queue.length > 0) + this._timer = this._runtime.setTimeout(() => { + this._timer = undefined; + this._dispatch(); + }, this._interval); + } + private _settle(entry: Entry, outcome?: OrderOutcome, error?: unknown, retainCapacity = false): void { + if (!retainCapacity) this._active.delete(entry); + if (entry.settled) return; + entry.settled = true; + entry.detach(); + if (outcome !== undefined) entry.resolve(outcome); + else entry.reject(error); + } + private _dispatch(): void { + this._clearTimer(); + this._queuedByKey.clear(); + const groups = new Map(); + for (const entry of this._queue.splice(0)) { + let group = groups.get(entry.key); + if (!group || group.length === this._maxBatch) { + if (group) this._post(group); + group = []; + groups.set(entry.key, group); + } + group.push(entry); + } + for (const group of groups.values()) this._post(group); + } + private _post(entries: Entry[]): void { + for (const entry of entries) entry.dispatched = true; + const task = this._send(entries).finally(() => this._requests.delete(task)); + this._requests.add(task); + } + private async _send(entries: Entry[]): Promise { + try { + const first = entries[0]; + const expires = first.options.expiresAfter; + if (expires !== undefined && Number(expires) <= this._runtime.now()) + throw new HyperliquidError("Queued order expired before dispatch"); + // Every order was validated and canonicalized at enqueue and the partition key fixes the + // shared fields, so the batch is assembled without validating each order a second time. + const action = canonicalAction( + { + type: "order", + orders: entries.map((entry) => entry.params.orders[0]), + grouping: first.params.grouping, + ...(first.params.builder ? { builder: first.params.builder } : {}), + }, + { kind: "l1" }, + ); + let response: unknown; + try { + response = await executeAction(this._config, action, { ...first.options, signal: this._abort.signal }); + } catch (error) { + // ApiRequestError retains the full raw response, including successful batch items. + if (!(error instanceof ApiRequestError)) throw error; + response = error.response; + } + const envelope = response as OrderResponse; + const statuses = envelope?.response?.data?.statuses; + if ( + envelope?.status !== "ok" || + envelope.response.type !== "order" || + !Array.isArray(statuses) || + statuses.length !== entries.length || + !statuses.every(isOrderOutcome) + ) { + throw new ApiRequestError(response, "Order batch rejected or response cardinality mismatch"); + } + for (let i = 0; i < entries.length; i++) this._settle(entries[i], statuses[i]); + } catch (error) { + for (const entry of entries) this._settle(entry, undefined, error); + } + } +} + +/** Construct a batcher without changing the immediate-dispatch behavior of ExchangeClient.order. */ +export function createOrderBatcher(config: ExchangeConfig, options?: OrderBatcherOptions): OrderBatcher { + return new OrderBatcher(config, options); +} diff --git a/packages/hyperliquid/tests/api/exchange/_batcherCounts.test.ts b/packages/hyperliquid/tests/api/exchange/_batcherCounts.test.ts new file mode 100644 index 00000000..dcf71c6b --- /dev/null +++ b/packages/hyperliquid/tests/api/exchange/_batcherCounts.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from "bun:test"; +import { createOrderBatcher } from "../../../src/actions/orderBatcher.ts"; +import { FakeRuntime, drain } from "../../_fakeRuntime.ts"; + +test("cancelled groups and flushed generations cannot inflate subsequent batch thresholds", async () => { + const clock = new FakeRuntime(); + const sizes: number[] = []; + const input = { a: 0, b: true, p: "1", s: "1", r: false, t: { limit: { tif: "Gtc" as const } } }; + let nonce = clock.now(); + const batcher = createOrderBatcher( + { + wallet: { + address: "0x7777777777777777777777777777777777777777", + signTypedData: async () => `0x${"11".repeat(64)}1b`, + }, + nonceManager: () => nonce++, + transport: { + isTestnet: true, + async request(_endpoint: "info" | "exchange", payload: unknown): Promise { + const count = (payload as { action: { orders: unknown[] } }).action.orders.length; + sizes.push(count); + return { + status: "ok", + response: { + type: "order", + data: { statuses: Array.from({ length: count }, () => ({ resting: { oid: 1 } })) }, + }, + } as T; + }, + }, + }, + { runtime: clock, maxBatchSize: 2, flushIntervalMs: 10 }, + ); + + const abort = new AbortController(); + const cancelled = batcher.enqueue(input, { signal: abort.signal }).catch((reason: unknown) => reason); + const sibling = batcher.enqueue(input, { grouping: "normalTpsl" }); + abort.abort(new Error("cancel queued")); + expect(await cancelled).toBe(abort.signal.reason); + const first = batcher.enqueue(input); + await drain(); + expect(sizes).toEqual([]); + const second = batcher.enqueue(input); + await drain(); + await Promise.all([first, second, sibling]); + expect([...sizes].sort()).toEqual([1, 2]); + const flushedSizes = [...sizes]; + + const afterFlush = batcher.enqueue(input); + await drain(); + expect(sizes).toEqual(flushedSizes); + clock.advance(10); + await drain(); + await afterFlush; + expect(sizes).toEqual([...flushedSizes, 1]); + expect(batcher.pending).toBe(0); + await batcher.close(); + expect(clock.pendingTimers).toBe(0); +}); diff --git a/packages/hyperliquid/tests/api/exchange/_orderBatcher.test.ts b/packages/hyperliquid/tests/api/exchange/_orderBatcher.test.ts new file mode 100644 index 00000000..5ab437ea --- /dev/null +++ b/packages/hyperliquid/tests/api/exchange/_orderBatcher.test.ts @@ -0,0 +1,310 @@ +import { expect, test } from "bun:test"; +import { createOrderBatcher, type OrderOutcome } from "@bloxwap/hyperliquid/actions/orderBatcher"; +import { ApiRequestError, ExchangeClient, HttpTransport } from "@bloxwap/hyperliquid"; +import type { ExchangeConfig } from "../../../src/api/exchange/_methods/_base/_config.ts"; +import { buildOrder } from "../../../src/api/exchange/_methods/order.ts"; +import { FakeRuntime, drain } from "../../_fakeRuntime.ts"; + +let walletId = 200; +const input = { a: 0, b: true, p: "1", s: "1", r: false, t: { limit: { tif: "Gtc" as const } } }; +function harness(handler?: (body: { action: { orders: unknown[] } }) => unknown) { + const clock = new FakeRuntime(); + const calls: { + action: { orders: unknown[]; grouping: unknown }; + nonce: number; + vaultAddress?: string; + expiresAfter?: number; + }[] = []; + let nonce = clock.now(); + let signs = 0; + const config: ExchangeConfig = { + wallet: { + address: `0x${(++walletId).toString(16).padStart(40, "0")}`, + signTypedData: async () => { + signs++; + return `0x${"11".repeat(64)}1b`; + }, + }, + nonceManager: () => nonce++, + transport: { + isTestnet: true, + request: async (_endpoint: "info" | "exchange", payload: unknown): Promise => { + const body = payload as (typeof calls)[number]; + calls.push(body); + return ( + handler + ? await handler(body) + : { + status: "ok", + response: { + type: "order", + data: { statuses: body.action.orders.map((_, i) => ({ resting: { oid: i + 1 } })) }, + }, + } + ) as T; + }, + }, + }; + return { config, clock, calls, signs: () => signs }; +} + +test("flush batches compatible callers with one signature and one nonce", async () => { + const { config, clock, calls, signs } = harness(); + const batcher = createOrderBatcher(config, { runtime: clock, maxBatchSize: 100 }); + const results = Array.from({ length: 5 }, () => batcher.enqueue(input)); + expect(calls).toHaveLength(0); + expect(batcher.pending).toBe(5); + await batcher.flush(); + expect(await Promise.all(results)).toEqual(Array.from({ length: 5 }, (_, i) => ({ resting: { oid: i + 1 } }))); + expect(calls).toHaveLength(1); + expect(signs()).toBe(1); + expect(batcher.pending).toBe(0); + expect(clock.pendingTimers).toBe(0); + await batcher.close(); +}); + +test("virtual flush deadline, size threshold, and compatible option partitioning", async () => { + const { config, clock, calls } = harness(); + const batcher = createOrderBatcher(config, { runtime: clock, flushIntervalMs: 5, maxBatchSize: 2 }); + const first = batcher.enqueue(input); + clock.advance(4); + await drain(); + expect(calls).toHaveLength(0); + clock.advance(1); + await drain(); + await first; + expect(calls).toHaveLength(1); + const second = batcher.enqueue(input); + const third = batcher.enqueue(input); + await drain(); + await Promise.all([second, third]); + expect(calls).toHaveLength(2); + const grouped = batcher.enqueue(input, { grouping: "normalTpsl" }); + const alo = batcher.enqueue({ ...input, t: { limit: { tif: "Alo" } } }); + const normal = batcher.enqueue(input); + await batcher.flush(); + await Promise.all([grouped, alo, normal]); + expect(calls).toHaveLength(5); +}); + +test("mixed batch errors preserve each success and return typed error outcomes", async () => { + const { config, clock } = harness(() => ({ + status: "ok", + response: { type: "order", data: { statuses: [{ resting: { oid: 1 } }, { error: "insufficient margin" }] } }, + })); + const batcher = createOrderBatcher(config, { runtime: clock }); + const first = batcher.enqueue(input); + const second = batcher.enqueue(input); + await batcher.flush(); + expect(await first).toEqual({ resting: { oid: 1 } }); + const error: OrderOutcome = await second; + expect(error).toEqual({ error: "insufficient margin" }); +}); + +for (const failure of [ + { status: "err", response: "batch rejected" }, + { status: "ok", response: { type: "order", data: { statuses: [] } } }, + { status: "ok", response: { type: "order", data: { statuses: [null, {}] } } }, +]) + test("whole batch rejection and cardinality mismatch reject every waiting caller", async () => { + const { config, clock } = harness(() => failure); + const batcher = createOrderBatcher(config, { runtime: clock }); + const first = batcher.enqueue(input).catch((e: unknown) => e); + const second = batcher.enqueue(input).catch((e: unknown) => e); + await batcher.flush(); + expect(await first).toBeInstanceOf(Error); + expect(await second).toBeInstanceOf(Error); + expect(batcher.pending).toBe(0); + }); + +test("queue overflow, queued cancellation, and expiry do not strand callers or timers", async () => { + const { config, clock, calls } = harness(); + const batcher = createOrderBatcher(config, { runtime: clock, maxQueueSize: 1 }); + const abort = new AbortController(); + const queued = batcher.enqueue(input, { signal: abort.signal }).catch((e: unknown) => e); + await expect(batcher.enqueue(input)).rejects.toThrow("full"); + abort.abort(new Error("withdraw queued")); + expect(await queued).toBe(abort.signal.reason); + expect(batcher.pending).toBe(0); + expect(clock.pendingTimers).toBe(0); + const expired = batcher.enqueue(input, { expiresAfter: clock.now() + 1 }).catch((e: unknown) => e); + clock.advance(2); + await drain(); + expect(await expired).toBeInstanceOf(Error); + expect(calls).toHaveLength(0); +}); + +test("post-dispatch cancellation retains capacity and leaves sibling results intact", async () => { + let respond!: (value: unknown) => void; + const { config, clock, calls } = harness( + () => + new Promise((resolve) => { + respond = resolve; + }), + ); + const batcher = createOrderBatcher(config, { runtime: clock, maxQueueSize: 2 }); + const abort = new AbortController(); + const first = batcher.enqueue(input, { signal: abort.signal }).catch((e: unknown) => e); + const second = batcher.enqueue(input); + const flushed = batcher.flush(); + await drain(); + expect(calls).toHaveLength(1); + abort.abort(new Error("stop waiting")); + expect(await first).toBe(abort.signal.reason); + expect(batcher.pending).toBe(2); + await expect(batcher.enqueue(input)).rejects.toThrow("full"); + respond({ + status: "ok", + response: { type: "order", data: { statuses: [{ resting: { oid: 1 } }, { resting: { oid: 2 } }] } }, + }); + await flushed; + expect(await second).toEqual({ resting: { oid: 2 } }); + expect(batcher.pending).toBe(0); +}); + +test("close drains by default; abort close settles queued callers and rejects later enqueues", async () => { + const { config, clock } = harness(); + const batcher = createOrderBatcher(config, { runtime: clock }); + const queued = batcher.enqueue(input); + await batcher.close(); + expect(await queued).toEqual({ resting: { oid: 1 } }); + await expect(batcher.enqueue(input)).rejects.toThrow("closed"); + const aborted = createOrderBatcher(config, { runtime: clock }); + const pending = aborted.enqueue(input).catch((e: unknown) => e); + await aborted.close({ drain: false }); + expect(await pending).toBeInstanceOf(Error); + expect(clock.pendingTimers).toBe(0); + expect(aborted.pending).toBe(0); +}); + +test("incompatible options and order classes are partitioned into separate batches", async () => { + const { config, clock, calls } = harness(); + const batcher = createOrderBatcher(config, { runtime: clock, flushIntervalMs: 10 }); + const builder = { b: "0x1111111111111111111111111111111111111111" as const, f: 1 }; + const vault = "0x2222222222222222222222222222222222222222" as const; + const expiresAfter = clock.now() + 60_000; + const ioc = { ...input, t: { limit: { tif: "Ioc" as const } } }; + const results = [ + batcher.enqueue(input), + batcher.enqueue(ioc), // GTC and IOC share a batch + batcher.enqueue({ ...input, t: { limit: { tif: "Alo" } } }), + batcher.enqueue(input, { builder }), + batcher.enqueue(input, { vaultAddress: vault }), + batcher.enqueue(input, { expiresAfter }), + // Priority grouping requires a homogeneous class: IOC and reduce-only ALO stay apart. + batcher.enqueue(ioc, { grouping: { p: 1 } }), + batcher.enqueue(ioc, { grouping: { p: 1 } }), + batcher.enqueue({ ...input, r: true, t: { limit: { tif: "Alo" } } }, { grouping: { p: 1 } }), + ]; + await batcher.flush(); + await Promise.all(results); + expect(calls.map((call) => call.action.orders.length).sort()).toEqual([1, 1, 1, 1, 1, 2, 2]); + expect(calls.filter((call) => "builder" in call.action)).toHaveLength(1); + expect(calls.filter((call) => call.vaultAddress === vault)).toHaveLength(1); + expect(calls.filter((call) => call.expiresAfter === expiresAfter)).toHaveLength(1); + const priority = calls.filter((call) => typeof call.action.grouping === "object"); + expect(priority.map((call) => call.action.orders.length).sort()).toEqual([1, 2]); + // One nonce per submitted batch, never per order. + expect(new Set(calls.map((call) => call.nonce)).size).toBe(calls.length); +}); + +test("a batch carries the same canonical wire action as one order() call for its orders", async () => { + const { config, clock, calls } = harness(); + const batcher = createOrderBatcher(config, { runtime: clock }); + const builder = { b: "0xABCDEF0000000000000000000000000000000000" as const, f: 5 }; + const orders = [ + { ...input, p: "30000.50", s: "0.10" }, + { ...input, b: false, c: "0x0123456789abcdef0123456789abcdef" as const }, + { + a: 1, + b: true, + p: "2", + s: "1", + r: true, + t: { trigger: { isMarket: true, triggerPx: "2.0", tpsl: "sl" as const } }, + }, + ]; + const results = orders.map((order) => batcher.enqueue(order, { grouping: "normalTpsl", builder })); + await batcher.flush(); + await Promise.all(results); + expect(calls).toHaveLength(1); + expect(JSON.stringify(calls[0].action)).toBe( + JSON.stringify(buildOrder({ orders, grouping: "normalTpsl", builder }).payload), + ); +}); + +test("ExchangeClient.order keeps immediate dispatch and whole-call errors next to an active batcher", async () => { + const { config, clock, calls } = harness((body) => ({ + status: "ok", + response: { + type: "order", + data: { + statuses: body.action.orders.map((_, i) => (i === 0 ? { resting: { oid: 1 } } : { error: "rejected" })), + }, + }, + })); + const client = new ExchangeClient(config); + const batcher = createOrderBatcher(config, { runtime: clock, flushIntervalMs: 50 }); + const queued = [batcher.enqueue(input), batcher.enqueue(input)]; + await drain(); + expect(calls).toHaveLength(0); + + expect(await client.order({ orders: [input] })).toMatchObject({ status: "ok" }); + expect(calls).toHaveLength(1); + expect(batcher.pending).toBe(2); + const error = await client.order({ orders: [input, input] }).catch((e: unknown) => e); + expect(error).toBeInstanceOf(ApiRequestError); + expect(calls).toHaveLength(2); + expect(clock.pendingTimers).toBe(1); + + clock.advance(50); + expect(await Promise.all(queued)).toEqual([{ resting: { oid: 1 } }, { error: "rejected" }]); + expect(calls).toHaveLength(3); + await batcher.close(); +}); + +test("HTTP rate limiting charges one batch weight of 1 + floor(n / 40), not one per order", async () => { + const clock = new FakeRuntime(); + const sizes: number[] = []; + const transport = new HttpTransport({ + isTestnet: true, + runtime: clock, + timeout: null, + // Three weight, refilled at one per minute. + rateLimit: { capacity: 3, refillPerMinute: 1 }, + fetch: async (_input, init) => { + const count = JSON.parse(String(init?.body)).action.orders.length; + sizes.push(count); + const statuses = Array.from({ length: count }, (_, i) => ({ resting: { oid: i + 1 } })); + return new Response(JSON.stringify({ status: "ok", response: { type: "order", data: { statuses } } }), { + headers: { "Content-Type": "application/json" }, + }); + }, + }); + let nonce = clock.now(); + const config: ExchangeConfig = { + transport, + wallet: { + address: "0x8888888888888888888888888888888888888888", + signTypedData: async () => `0x${"11".repeat(64)}1b`, + }, + nonceManager: () => nonce++, + }; + const batcher = createOrderBatcher(config, { runtime: clock, maxBatchSize: 80 }); + const results = Array.from({ length: 80 }, () => batcher.enqueue(input)); + await Promise.all(results); + expect(sizes).toEqual([80]); + + // The 80-order batch spent exactly 3 weight: a 1-weight follow-up waits for one refill. + const followUp = batcher.enqueue(input); + clock.advance(1); + await drain(); + expect(sizes).toEqual([80]); + clock.advance(59_998); + await drain(); + expect(sizes).toEqual([80]); + clock.advance(1); + expect(await followUp).toEqual({ resting: { oid: 1 } }); + expect(sizes).toEqual([80, 1]); + await batcher.close(); +}); From 782fdc90d027af489efd1ff18c02efa3f758f598 Mon Sep 17 00:00:00 2001 From: Joe Blau Date: Wed, 7 Oct 2026 08:30:05 +0800 Subject: [PATCH 2/2] fix(sdk): reject TP/SL groupings in the order batcher TP/SL groupings link the orders of one action, so batching independent callers under them could attach one caller's stop-loss to another caller's entry order. enqueue now accepts only "na" and { p }. Co-Authored-By: Claude Opus 5.5 --- apps/docs/content/docs/clients.md | 4 +++ .../hyperliquid/src/actions/orderBatcher.ts | 16 ++++++++++-- .../tests/api/exchange/_batcherCounts.test.ts | 2 +- .../tests/api/exchange/_orderBatcher.test.ts | 26 +++++++++++++++---- 4 files changed, 40 insertions(+), 8 deletions(-) diff --git a/apps/docs/content/docs/clients.md b/apps/docs/content/docs/clients.md index 32cfc1bc..9170db95 100644 --- a/apps/docs/content/docs/clients.md +++ b/apps/docs/content/docs/clients.md @@ -854,6 +854,10 @@ prioritization, and under priority grouping (`{ p }`), where the exchange requir or every order to be a non-reduce-only ALO, orders are further split by time-in-force and reduce-only flag. The batcher is bound to one config, so signer and network never mix. +`grouping` accepts `"na"` (the default) or `{ p }`. The TP/SL groupings (`normalTpsl`, `positionTpsl`) link the orders +of one action, so a stop-loss from one caller could become the child of another caller's entry order. `enqueue` +rejects them; send a TP/SL group as one `exchange.order()` call instead. + Results: - `enqueue` resolves with the server's status for that order, in the order it was submitted: `resting`, `filled`, diff --git a/packages/hyperliquid/src/actions/orderBatcher.ts b/packages/hyperliquid/src/actions/orderBatcher.ts index 59e54ec4..5fef2d1d 100644 --- a/packages/hyperliquid/src/actions/orderBatcher.ts +++ b/packages/hyperliquid/src/actions/orderBatcher.ts @@ -62,8 +62,15 @@ export interface OrderBatcherOptions { /** Isolated scheduler and wall clock. */ runtime?: Partial; } -/** Grouping and signing options must match for orders to share a batch. */ -export type EnqueueOrderOptions = Pick & Omit; +/** + * Grouping and signing options must match for orders to share a batch. TP/SL groupings link the + * orders of one action, so independent callers cannot share them: use `exchange.order` with the full group. + */ +export type EnqueueOrderOptions = Pick & + Omit & { + /** `"na"` (default) or priority grouping `{ p }`. */ + grouping?: Exclude; + }; interface Entry { key: string; params: OrderParameters; @@ -125,6 +132,11 @@ export class OrderBatcher { // Validated per caller, so one malformed order cannot fail its batch. const params = buildOrder({ orders: [input], grouping: options?.grouping, builder: options?.builder }) .payload as OrderParameters; + if (params.grouping === "normalTpsl" || params.grouping === "positionTpsl") { + throw new HyperliquidError( + "TP/SL groupings link orders within one action; use exchange.order with the full group", + ); + } const vaultAddress = options?.vaultAddress ?? this._config.defaultVaultAddress; const expiresAfter = options?.expiresAfter ?? this._config.defaultExpiresAfter; // Dynamic defaults are resolved per actual batch by the execution core. diff --git a/packages/hyperliquid/tests/api/exchange/_batcherCounts.test.ts b/packages/hyperliquid/tests/api/exchange/_batcherCounts.test.ts index dcf71c6b..dcb1d883 100644 --- a/packages/hyperliquid/tests/api/exchange/_batcherCounts.test.ts +++ b/packages/hyperliquid/tests/api/exchange/_batcherCounts.test.ts @@ -34,7 +34,7 @@ test("cancelled groups and flushed generations cannot inflate subsequent batch t const abort = new AbortController(); const cancelled = batcher.enqueue(input, { signal: abort.signal }).catch((reason: unknown) => reason); - const sibling = batcher.enqueue(input, { grouping: "normalTpsl" }); + const sibling = batcher.enqueue(input, { grouping: { p: 1 } }); abort.abort(new Error("cancel queued")); expect(await cancelled).toBe(abort.signal.reason); const first = batcher.enqueue(input); diff --git a/packages/hyperliquid/tests/api/exchange/_orderBatcher.test.ts b/packages/hyperliquid/tests/api/exchange/_orderBatcher.test.ts index 5ab437ea..3b442bc3 100644 --- a/packages/hyperliquid/tests/api/exchange/_orderBatcher.test.ts +++ b/packages/hyperliquid/tests/api/exchange/_orderBatcher.test.ts @@ -79,7 +79,7 @@ test("virtual flush deadline, size threshold, and compatible option partitioning await drain(); await Promise.all([second, third]); expect(calls).toHaveLength(2); - const grouped = batcher.enqueue(input, { grouping: "normalTpsl" }); + const grouped = batcher.enqueue(input, { grouping: { p: 1 } }); const alo = batcher.enqueue({ ...input, t: { limit: { tif: "Alo" } } }); const normal = batcher.enqueue(input); await batcher.flush(); @@ -224,13 +224,29 @@ test("a batch carries the same canonical wire action as one order() call for its t: { trigger: { isMarket: true, triggerPx: "2.0", tpsl: "sl" as const } }, }, ]; - const results = orders.map((order) => batcher.enqueue(order, { grouping: "normalTpsl", builder })); + const results = orders.map((order) => batcher.enqueue(order, { grouping: "na", builder })); await batcher.flush(); await Promise.all(results); expect(calls).toHaveLength(1); - expect(JSON.stringify(calls[0].action)).toBe( - JSON.stringify(buildOrder({ orders, grouping: "normalTpsl", builder }).payload), - ); + expect(JSON.stringify(calls[0].action)).toBe(JSON.stringify(buildOrder({ orders, grouping: "na", builder }).payload)); +}); + +test("TP/SL groupings are rejected so independent orders are never linked", async () => { + const { config, clock, calls } = harness(); + const batcher = createOrderBatcher(config, { runtime: clock }); + for (const grouping of ["normalTpsl", "positionTpsl"] as const) { + const results = [ + // @ts-expect-error TP/SL groupings are excluded from the batcher's options. + batcher.enqueue(input, { grouping }), + // @ts-expect-error TP/SL groupings are excluded from the batcher's options. + batcher.enqueue(input, { grouping }), + ]; + for (const result of results) await expect(result).rejects.toThrow("TP/SL groupings link orders"); + } + await batcher.flush(); + expect(calls).toHaveLength(0); + expect(batcher.pending).toBe(0); + await batcher.close(); }); test("ExchangeClient.order keeps immediate dispatch and whole-call errors next to an active batcher", async () => {