From bbfc7ec618f6105a9916df1c7da5be7cb5ae222a Mon Sep 17 00:00:00 2001 From: Joe Blau Date: Mon, 27 Jul 2026 22:12:40 -0700 Subject: [PATCH] perf: fast digests for user-signed actions, leaner ws/http per-request paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-signed actions (approveAgent, usdSend, and the other 15) no longer pay viem's generic hashTypedData. The EIP-712 domain is fixed and every shipped types object is a module constant, so typehash plans compile once per types identity (WeakMap) and domain separators once per chainId, following the _fastDigest pattern used for Agent/SendMultiSig. Signing routes through the raw-digest capability (WASM when present); remote and ledger wallets fall back to signTypedData unchanged. Digests are byte-identical to viem's hashTypedData across all 17 types x 5 chain IDs (userSignedDigest.test.ts). Measured: 2.5 us vs 56 us per digest; multisig_user_signed_3_signers -22%. Multi-sig computes the shared digest once per call and only after a capability check, so stub/remote signers never pay for a digest they cannot use. Websocket transport: - The dispatcher schedules request timeouts on the shared abort.TimeoutWheel (as HttpTransport already did) instead of a native setTimeout per request: ws_request_round_trip -10%. - Routing stops allocating per frame: .toLowerCase() is gated behind an uppercase check (the server sends lowercase hex), and routed event-type strings are interned per channel+key instead of re-concatenated: webData3_frame_dispatch_e2e -38%, l2book_dispatch_50_coins -28%. - WebSocketTransport.request drops a pointless async/return await. Subscriptions and utils: - fastAssetCtxs decodes the node:zlib Buffer via Buffer.toString("utf8") instead of a shared TextDecoder on the hottest decode path; the DecompressionStream fallback fuses its two .then hops into one. - floatToWire renders the double once (toFixed(9)) and derives the 8-decimal wire string by digit-9 rounding with carry: -20%, fuzz-verified byte-identical over 1.8M values. Http transport: - Error paths skip the redaction walk for signature-free payloads (all info requests), gated on a wire-string check. - No AbortController or TimeoutWheel entry is allocated when timeout is null and no signal exists; the wheel hands out a shared frozen null-handle. - Explorer requests skip the pre-send JSON.parse (flat weight needs no parsed form); billing snapshots materialize lazily for surcharge/error paths only. Info/exchange billing still parses the wire form — a pinned test contract requires billing to derive from the serialized payload. Exchange shell: - Signing moves outside the per-wallet nonce lock; wire order is preserved by a per-(wallet x network) dispatch chain instead, so concurrent callers on one wallet (where signing is a network round trip for any remote wallet) sign in parallel while the server still sees strictly increasing nonces. Covered by the new _dispatchOrder tests. - extractNonceFieldName and static signatureChainId validation are memoized per types/config identity; the multi-sig inner hash no longer round-trips through hex; msgpack uint64 writes integers without BigInt boxing. Perf harness: - subscribe_user_trio stabilised (from the abandoned perf/stabilize-subscribe-user-trio branch): 20 iterations per sample instead of 1, and MockWebSocket keeps only the latest instance instead of retaining every socket. Run-to-run spread falls from 33% to ~3%, stopping the scenario flipping the gate on unrelated PRs. - New scenarios: eip712_user_signed_digest (+viem oracle pair), approve_agent_e2e_no_ecdsa. Baseline re-recorded (the committed one was stale since 0.1.4 and failed the local gate closed). Full suite vs old baseline: 11 faster, 0 regressed. perf:gate PASS. Co-Authored-By: Claude Fable 5 --- src/api/exchange/_methods/_base/_shell.ts | 78 +- src/api/exchange/_methods/_base/execute.ts | 42 +- .../subscription/_methods/fastAssetCtxs.ts | 20 +- src/signing/_abstractWallet.ts | 17 + src/signing/_fastDigest.ts | 260 ++++- src/signing/_l1.ts | 32 +- src/signing/_msgpack.ts | 12 +- src/signing/_multiSig.ts | 20 +- src/signing/_userSigned.ts | 123 +- src/transport/_abort.ts | 29 +- src/transport/http/mod.ts | 89 +- src/transport/websocket/_dispatcher.ts | 5 +- src/transport/websocket/_routing.ts | 46 +- src/transport/websocket/mod.ts | 4 +- src/utils/_format.ts | 80 +- tests/api/exchange/_dispatchOrder.test.ts | 149 +++ tests/perf/_helpers.ts | 18 +- tests/perf/results/baseline.json | 1031 +++++++++-------- tests/perf/scenarios/signing.ts | 104 ++ tests/perf/scenarios/user_account_channels.ts | 2 +- tests/signing/fastWallet.test.ts | 23 +- tests/signing/multiSigDigest.test.ts | 44 +- tests/signing/userSignedDigest.test.ts | 540 +++++++++ 23 files changed, 2154 insertions(+), 614 deletions(-) create mode 100644 tests/api/exchange/_dispatchOrder.test.ts create mode 100644 tests/signing/userSignedDigest.test.ts diff --git a/src/api/exchange/_methods/_base/_shell.ts b/src/api/exchange/_methods/_base/_shell.ts index c1a7a6f2..0a665a08 100644 --- a/src/api/exchange/_methods/_base/_shell.ts +++ b/src/api/exchange/_methods/_base/_shell.ts @@ -97,15 +97,33 @@ const nonceKeyCache = new WeakMap< WeakMap >(); +/** + * Per-`(walletAddress × isTestnet)` dispatch chain: resolves once the request holding the previous + * nonce has been handed to the transport. + * + * The nonce lock guarantees the order nonces are ISSUED in; this guarantees the order they reach + * the WIRE in, which is what the server actually requires. Keeping the two separate is what lets + * signing — a network round trip for any remote wallet — run outside the lock and overlap across + * callers, while a later nonce still cannot overtake an earlier one. + * + * Entries are dropped as soon as the chain goes idle, so a long-lived process that touches many + * wallets does not accumulate one per key forever. + */ +const dispatchChains = new Map>(); + /** * Common shell for executing an Exchange API request: * acquires per-`(walletAddress × isTestnet)` lock, generates nonce, calls `build` to construct * the signed payload, sends to the Exchange endpoint, and validates the response. * - * The lock covers only nonce issuance and signing, plus request INITIATION (`transport.request` - * runs synchronously up to its first `await`), so requests are dispatched to the server in - * strictly increasing nonce order. The lock is released as soon as the request is in flight — - * network responses resolve concurrently, unblocking order throughput beyond 1/RTT per wallet. + * The lock covers only nonce issuance and claiming a slot in the per-wallet dispatch chain — both + * synchronous. Signing happens outside it, so concurrent callers on one wallet sign at the same + * time; for a remote wallet, where signing is an `eth_signTypedData_v4` round trip, that is the + * difference between one order in flight per wallet and all of them. + * + * Wire order is preserved by {@linkcode dispatchChains} rather than by the lock: a request waits + * for its predecessor to reach `transport.request` before making its own call, so the server still + * sees strictly increasing nonces per wallet. Network responses resolve concurrently. * * @param config Exchange API configuration. * @param build Callback that, given the nonce, returns the action, signature, and any extras. @@ -146,24 +164,44 @@ export async function executeWithShell( const nonceOrPromise = config.nonceManager?.(walletAddress) ?? globalNonceManager.getNonce(key); const nonce = typeof nonceOrPromise === "number" ? nonceOrPromise : await nonceOrPromise; - // --- Build signed payload -------------------------------- - const { action, signature, extras } = await build(nonce); + // --- Claim this nonce's slot in the dispatch order -------- + // Taken under the lock, so slots are claimed in the same order nonces are issued. + const predecessor = dispatchChains.get(key); + let openGate!: () => void; + const dispatched = new Promise((resolve) => { + openGate = resolve; + }); + dispatchChains.set(key, dispatched); + + // --- Sign and dispatch, outside the lock ------------------ + // Signing is a network round trip for a remote wallet; running it here rather than inside the + // lock lets concurrent callers on one wallet sign at the same time. The `predecessor` await + // then restores order at the only point it matters — handing the request to the transport. + const pending = (async (): Promise => { + let response: Promise | undefined; + try { + const { action, signature, extras } = await build(nonce); + if (predecessor !== undefined) await predecessor; + // `transport.request` runs synchronously up to its first await, so wire order is fixed + // here. It is assigned rather than awaited so the gate below opens on dispatch, not on + // the response. + response = config.transport.request("exchange", { action, signature, nonce, ...extras }, signal); + } finally { + // Wait for our turn even when this request never reached the wire. A rejected signature + // or an abort burns its nonce, which the server tolerates as a gap — but opening the gate + // early would let a later nonce overtake an earlier one that is still being signed. + if (predecessor !== undefined) await predecessor; + openGate(); + // Idle chain: drop the entry so the map does not grow one slot per key forever. Compared + // by identity, so a successor that has already claimed the slot is left alone. + if (dispatchChains.get(key) === dispatched) dispatchChains.delete(key); + } + return await response; + })(); - // --- Initiate the request -------------------------------- // Hand the pending promise out in a plain (non-thenable) box, so the lock releases - // without awaiting the network response. - return { - pending: config.transport.request( - "exchange", - { - action, - signature, - nonce, - ...extras, - }, - signal, - ), - }; + // without awaiting either the signature or the network response. + return { pending }; }); // --- Await response (concurrently across calls) and validate diff --git a/src/api/exchange/_methods/_base/execute.ts b/src/api/exchange/_methods/_base/execute.ts index 52ccfe77..6b0d3880 100644 --- a/src/api/exchange/_methods/_base/execute.ts +++ b/src/api/exchange/_methods/_base/execute.ts @@ -204,16 +204,32 @@ export function executeUserSignedAction( // Helpers // ============================================================ -/** Extracts the nonce field name ("nonce" or "time") from EIP-712 type definitions. */ +/** Cache of the nonce field name per `types` object (keyed by object identity). */ +const nonceFieldNameCache = new WeakMap, "nonce" | "time">(); + +/** Extracts the nonce field name ("nonce" or "time") from EIP-712 type definitions (memoized per `types` object). */ function extractNonceFieldName(types: Record): "nonce" | "time" { - const primaryType = Object.keys(types)[0]; - const field = types[primaryType].find((f) => f.name === "nonce" || f.name === "time"); - if (!field) { - throw new HyperliquidError(`EIP-712 types must contain a "nonce" or "time" field in "${primaryType}"`); + let name = nonceFieldNameCache.get(types); + if (name === undefined) { + const primaryType = Object.keys(types)[0]; + const field = types[primaryType].find((f) => f.name === "nonce" || f.name === "time"); + if (!field) { + throw new HyperliquidError(`EIP-712 types must contain a "nonce" or "time" field in "${primaryType}"`); + } + name = field.name as "nonce" | "time"; + nonceFieldNameCache.set(types, name); } - return field.name as "nonce" | "time"; + return name; } +/** + * Cache of the parsed static `signatureChainId` per config object (keyed by object identity). + * A function-valued `signatureChainId` is re-resolved on every request instead — it may return a + * different value each time, which is precisely why the config accepts a function. The raw string + * is kept alongside the parsed value so a mutated config re-parses instead of signing stale. + */ +const staticSignatureChainIdCache = new WeakMap(); + /** * Resolves signature chain ID from config, or falls back to the leader wallet's chain ID. * @@ -225,9 +241,17 @@ function extractNonceFieldName(types: Record { if (config.signatureChainId) { - const id = - typeof config.signatureChainId === "function" ? await config.signatureChainId() : config.signatureChainId; - return parse(Hex, id); + if (typeof config.signatureChainId === "function") { + return parse(Hex, await config.signatureChainId()); + } + // Static string: the valibot parse result is a pure function of the string, so cache it. + const raw = config.signatureChainId; + let entry = staticSignatureChainIdCache.get(config); + if (entry === undefined || entry.raw !== raw) { + entry = { raw, parsed: parse(Hex, raw) }; + staticSignatureChainIdCache.set(config, entry); + } + return entry.parsed; } const leader = "wallet" in config ? config.wallet : config.signers[0]; return await getWalletChainId(leader); diff --git a/src/api/subscription/_methods/fastAssetCtxs.ts b/src/api/subscription/_methods/fastAssetCtxs.ts index 0544b569..e4bbe2fe 100644 --- a/src/api/subscription/_methods/fastAssetCtxs.ts +++ b/src/api/subscription/_methods/fastAssetCtxs.ts @@ -83,9 +83,6 @@ export function fastAssetCtxs( let queue = Promise.resolve(); /** Frames still waiting on the async queue; the synchronous path may only run at zero. */ let queued = 0; - const released = (): void => { - queued--; - }; return config.transport.subscribe( payload.type, payload, @@ -110,7 +107,15 @@ export function fastAssetCtxs( // promise would skip every subsequent `.then` callback, silently dropping all later updates // for the life of the subscription. queued++; - queue = queue.then(() => deliver(data, listener)).then(released); + // One chained step per frame: the release rides the same continuation via try/finally, so + // `queued` still drains even if `deliver` ever does reject. + queue = queue.then(async () => { + try { + await deliver(data, listener); + } finally { + queued--; + } + }); }, options, ); @@ -285,7 +290,12 @@ function decompressSync(data: string): FastAssetCtxsEvent { if (data.length === 0 || data.length % 4 !== 0) throw new Error("Invalid base64"); const pooled = Buf.from(data, "base64"); if (pooled.byteLength !== expectedBase64Bytes(data)) throw new Error("Invalid base64"); - return JSON.parse(TEXT_DECODER.decode(inflate(pooled))); + // `inflate` returns a `Buffer` here (node:zlib), and `Buffer.toString("utf8")` decodes the + // ASCII-heavy JSON payload measurably faster than the shared TextDecoder — no decoder machinery. + // The cast is structural (same style as the node:zlib import above): the declared return type + // stays `Uint8Array` so browser/RN type-check stays clean. + const inflated = inflate(pooled) as Uint8Array & { toString(encoding: "utf8"): string }; + return JSON.parse(inflated.toString("utf8")); } /** Decode a base64 + raw DEFLATE (RFC 1951) payload into a {@linkcode FastAssetCtxsEvent}. */ diff --git a/src/signing/_abstractWallet.ts b/src/signing/_abstractWallet.ts index 4aaef3a3..684745e5 100644 --- a/src/signing/_abstractWallet.ts +++ b/src/signing/_abstractWallet.ts @@ -490,6 +490,23 @@ export async function signRawDigestBytes(args: { } } +/** + * Whether the wallet can sign a raw 32-byte digest locally (memoized per wallet object by the + * adapter cache). Package-internal — not re-exported from `mod.ts`: callers use it to skip + * COMPUTING a digest the wallet would only discard, when the digest is data-dependent enough + * that the lazy-thunk form of {@linkcode signRawDigestBytes} cannot express the fallback + * (the user-signed path, where an unencodable action must still reach `signTypedData`). + * Signing itself still goes through {@linkcode signRawDigestBytes}. + * + * @param wallet The wallet to inspect. + * @return `true` when {@linkcode signRawDigestBytes} would sign for this wallet. + * + * @throws {AbstractWalletError} If the wallet type is unknown. + */ +export function canSignRawDigest(wallet: AbstractWallet): boolean { + return adapt(wallet).signDigest !== undefined; +} + /** * Gets the lowercase wallet address from various wallet types. * diff --git a/src/signing/_fastDigest.ts b/src/signing/_fastDigest.ts index 2909b7d5..65c0b21e 100644 --- a/src/signing/_fastDigest.ts +++ b/src/signing/_fastDigest.ts @@ -13,9 +13,18 @@ * or `"Testnet"`. Same treatment, except the chain ID is caller-chosen, so the domain separator * is computed once per `signatureChainId` and cached instead of being a module constant. * + * User-signed actions (`HyperliquidTransaction:ApproveAgent`, `:UsdSend`, …) share the multi-sig + * domain but vary in shape, so they get a generic treatment: the typehash and field descriptors + * are compiled once per `types` object identity (WeakMap), the domain separator is the same + * per-chain-ID cache, and a digest then costs one keccak per string/bytes field plus two for the + * struct and envelope. Shapes the encoder cannot reproduce byte-identically (nested structs, + * arrays, fixed `bytesN`, checksummed mixed-case addresses) compile to no plan at all, and the + * caller falls back to viem's generic encoding. + * * The outputs are byte-identical to viem's `hashTypedData` for these shapes; - * `tests/signing/fastDigest.test.ts` and `tests/signing/multiSigDigest.test.ts` pin the constants - * to literals and diff the digests and the resulting signatures against viem. + * `tests/signing/fastDigest.test.ts`, `tests/signing/multiSigDigest.test.ts`, and + * `tests/signing/userSignedDigest.test.ts` pin the constants to literals and diff the digests and + * the resulting signatures against viem. * @module */ @@ -131,15 +140,16 @@ const HYPERLIQUID_CHAIN_HASH_MAINNET = keccak_256(ENCODER.encode("Mainnet")); const HYPERLIQUID_CHAIN_HASH_TESTNET = keccak_256(ENCODER.encode("Testnet")); /** - * Cache of multi-sig domain separators keyed by numeric chain ID. Unlike the L1 domain, the chain - * ID is caller-chosen (`signatureChainId`), so the separator is computed lazily on first use of a + * Cache of `HyperliquidSignTransaction` domain separators keyed by numeric chain ID. The chain ID + * is caller-chosen (`signatureChainId`), so the separator is computed lazily on first use of a * chain — through the `keccak256` dispatch, WASM acceleration included — and reused thereafter. + * Shared by the multi-sig outer digest and the user-signed digests: both sign under this domain. */ -const MULTI_SIG_DOMAIN_SEPARATORS = new Map(); +const HYPERLIQUID_SIGN_DOMAIN_SEPARATORS = new Map(); -/** Domain separator of the multi-sig domain for `chainId`; `verifyingContract` is the zero address. */ -function multiSigDomainSeparator(chainId: number): Uint8Array { - let separator = MULTI_SIG_DOMAIN_SEPARATORS.get(chainId); +/** Domain separator of the `HyperliquidSignTransaction` domain for `chainId`; `verifyingContract` is the zero address. */ +function hyperliquidSignDomainSeparator(chainId: number): Uint8Array { + let separator = HYPERLIQUID_SIGN_DOMAIN_SEPARATORS.get(chainId); if (separator === undefined) { const preimage = new Uint8Array(32 * 5); preimage.set(EIP712_DOMAIN_TYPEHASH, 0); @@ -151,7 +161,7 @@ function multiSigDomainSeparator(chainId: number): Uint8Array { } // `verifyingContract` is the zero address, so the last word stays zeroed separator = keccak256(preimage); - MULTI_SIG_DOMAIN_SEPARATORS.set(chainId, separator); + HYPERLIQUID_SIGN_DOMAIN_SEPARATORS.set(chainId, separator); } return separator; } @@ -225,10 +235,240 @@ export function createMultiSigDigestBytes( digest[1] = 0x01; // `signatureChainId` is a `0x`-prefixed hex string, so radix 16 is the only correct base here: // radix 10 would parse it as `0` and silently sign under the wrong EIP-712 domain. - digest.set(multiSigDomainSeparator(parseInt(signatureChainId, 16)), 2); + digest.set(hyperliquidSignDomainSeparator(parseInt(signatureChainId, 16)), 2); digest.set(structHash, 34); return keccak256(digest); } finally { MULTI_SIG_SCRATCH_BUSY = nested; } } + +// --- User-signed action digests ---------------------------------------------- + +/** EIP-712 type definitions; the hash depends on key and field order. */ +type TypedDataTypes = Record; + +/** Primitive field kinds the hand-rolled user-signed encoder reproduces byte-identically. */ +type UserSignedFieldKind = "string" | "bytes" | "address" | "bool" | "uint"; + +/** + * Precomputed encoding plan for a `types` object: the primary type's typehash plus its field + * descriptors. Everything else a digest needs — the domain separator and the `0x1901` envelope — + * is shared with the multi-sig path. + */ +interface UserSignedPlan { + typehash: Uint8Array; + fields: readonly { name: string; kind: UserSignedFieldKind }[]; +} + +/** + * Cache of encoding plans keyed by `types` object identity (`null` = unsupported shape, fall back + * to the typed-data path). The `types` objects on the hot path are module constants + * (`ApproveAgentTypes` and friends), so one compile serves every request. + */ +const USER_SIGNED_PLANS = new WeakMap(); + +/** Maps an EIP-712 field type to a supported kind, or `undefined` for shapes left to viem. */ +function userSignedFieldKind(type: string): UserSignedFieldKind | undefined { + if (type === "string" || type === "bytes" || type === "address" || type === "bool") return type; + // `uintN` with N a multiple of 8 up to 256 (bare `uint` is `uint256`). Anything else — fixed + // `bytesN`, arrays, nested structs — falls back to the typed-data path. + if (type === "uint") return "uint"; + const match = /^uint(\d+)$/.exec(type); + if (match !== null) { + const bits = Number(match[1]); + if (bits >= 8 && bits <= 256 && bits % 8 === 0) return "uint"; + } + return undefined; +} + +/** Builds the encoding plan for `types`, or `null` when any field type is unsupported. */ +function compileUserSignedPlan(types: TypedDataTypes): UserSignedPlan | null { + const primaryType = Object.keys(types)[0]; + const typeFields = types[primaryType]; + if (typeFields === undefined) return null; + const fields: { name: string; kind: UserSignedFieldKind }[] = []; + for (const { name, type } of typeFields) { + const kind = userSignedFieldKind(type); + if (kind === undefined) return null; + fields.push({ name, kind }); + } + // No field references another struct (checked just above), so `encodeType` is the primary + // type alone — there are no dependencies to append. + const encodeType = `${primaryType}(${typeFields.map((f) => `${f.type} ${f.name}`).join(",")})`; + return { typehash: keccak256(ENCODER.encode(encodeType)), fields }; +} + +/** Returns the cached plan for `types`, compiling (and caching `null`) on first sight. */ +function userSignedPlan(types: TypedDataTypes): UserSignedPlan | null { + let plan = USER_SIGNED_PLANS.get(types); + if (plan === undefined) { + plan = compileUserSignedPlan(types); + USER_SIGNED_PLANS.set(types, plan); + } + return plan; +} + +/** Decode one ASCII hex nibble; the caller already validated the character. */ +function hexNibble(code: number): number { + // '0'-'9' → 0-9; 'a'-'f' / 'A'-'F' → 10-15 + return code < 58 ? code - 48 : (code | 32) - 87; +} + +/** Whether `code` is an ASCII hex digit of either case. */ +function isHexCode(code: number): boolean { + return (code >= 48 && code <= 57) || (code >= 97 && code <= 102) || (code >= 65 && code <= 70); +} + +/** Whether `code` is a lowercase ASCII hex digit. */ +function isLowerHexCode(code: number): boolean { + return (code >= 48 && code <= 57) || (code >= 97 && code <= 102); +} + +/** + * Decodes a dynamic `bytes` value (`0x` hex string or `Uint8Array`), or returns `undefined` to + * fall back. Hex of either case is fine here — unlike addresses, `bytes` carries no checksum. + */ +function decodeDynamicBytes(value: unknown): Uint8Array | undefined { + if (value instanceof Uint8Array) return value; + if ( + typeof value !== "string" || + value.length % 2 !== 0 || + value.charCodeAt(0) !== 48 || + (value.charCodeAt(1) | 32) !== 120 + ) { + return undefined; + } + const bytes = new Uint8Array((value.length - 2) / 2); + for (let i = 0; i < bytes.length; i++) { + const hiCode = value.charCodeAt(2 + i * 2); + const loCode = value.charCodeAt(3 + i * 2); + if (!isHexCode(hiCode) || !isHexCode(loCode)) return undefined; + bytes[i] = (hexNibble(hiCode) << 4) | hexNibble(loCode); + } + return bytes; +} + +/** + * Writes a 20-byte address into the low bytes of the zeroed word at `offset`, or returns `false` + * to fall back. Mixed-case input falls back deliberately: viem checksum-validates it (and throws + * on a bad checksum), so the typed-data path must keep that behavior and its error surface. + */ +function decodeAddressWord(word: Uint8Array, offset: number, value: unknown): boolean { + if ( + typeof value !== "string" || + value.length !== 42 || + value.charCodeAt(0) !== 48 || + (value.charCodeAt(1) | 32) !== 120 + ) { + return false; + } + for (let i = 0; i < 20; i++) { + const hiCode = value.charCodeAt(2 + i * 2); + const loCode = value.charCodeAt(3 + i * 2); + if (!isLowerHexCode(hiCode) || !isLowerHexCode(loCode)) return false; + word[offset + 12 + i] = (hexNibble(hiCode) << 4) | hexNibble(loCode); + } + return true; +} + +/** Writes an unsigned integer into the zeroed 32-byte word at `offset`, or returns `false` to fall back. */ +function encodeUintWord(word: Uint8Array, offset: number, value: unknown): boolean { + if (typeof value === "number") { + if (!Number.isSafeInteger(value) || value < 0) return false; + // Big-endian over the low 7 bytes: `value / 256` and `value % 256` stay exact for every safe + // integer, so the bytes match `BigInt(value)` bit for bit. + for (let i = offset + 31, remaining = value; remaining > 0; i--, remaining = Math.floor(remaining / 256)) { + word[i] = remaining % 256; + } + return true; + } + if (typeof value === "bigint") { + if (value < 0n || value >= 1n << 256n) return false; + for (let i = offset + 31, remaining = value; remaining > 0n; i--, remaining /= 256n) { + word[i] = Number(remaining % 256n); + } + return true; + } + return false; +} + +/** + * Encodes one declared field into its 32-byte word. Returns `false` when the value is missing or + * shaped in a way the encoder does not reproduce exactly (viem would throw for some of these; the + * fallback preserves that error surface). + */ +function encodeUserSignedField(word: Uint8Array, offset: number, kind: UserSignedFieldKind, value: unknown): boolean { + switch (kind) { + case "string": { + if (typeof value !== "string") return false; + word.set(keccak256(ENCODER.encode(value)), offset); + return true; + } + case "bytes": { + const bytes = decodeDynamicBytes(value); + if (bytes === undefined) return false; + word.set(keccak256(bytes), offset); + return true; + } + case "address": + return decodeAddressWord(word, offset, value); + case "bool": { + if (value === true) word[offset + 31] = 1; + else if (value !== false) return false; + return true; + } + case "uint": + return encodeUintWord(word, offset, value); + } +} + +/** + * Computes the EIP-712 digest of a user-signed action in the `HyperliquidSignTransaction` domain, + * or `undefined` when the shape is unsupported — the caller then falls back to the generic + * typed-data path, so behavior (including errors) is unchanged for anything this encoder does not + * cover. + * + * `digest = keccak256(0x1901 ‖ domainSeparator(signatureChainId) ‖ keccak256(typehash ‖ fields…))` + * + * Fields not declared in `types` are ignored, exactly like the message filtering the typed-data + * path applies before encoding. Package-internal — not re-exported from `mod.ts`. + * + * @param action The action to hash; declared fields are read by name. + * @param types The EIP-712 types of the action (hash depends on key and field order). + * @param signatureChainId Chain ID of the EIP-712 domain, as a `0x`-prefixed hex string. + * @return The 32-byte digest, byte-identical to viem's `hashTypedData`, or `undefined`. + */ +export function createUserSignedDigestBytes( + action: Record, + types: TypedDataTypes, + signatureChainId: `0x${string}`, +): Uint8Array | undefined { + const plan = userSignedPlan(types); + if (plan === null) return undefined; + + // `signatureChainId` is a `0x`-prefixed hex string, so radix 16 is the only correct base here: + // radix 10 would parse it as `0` and silently sign under the wrong EIP-712 domain. A value that + // does not parse to a safe non-negative integer falls back to the typed-data path: viem rejects + // it there, while the hand-rolled domain separator would silently sign under the wrong domain. + const chainId = parseInt(signatureChainId, 16); + if (!Number.isSafeInteger(chainId) || chainId < 0) return undefined; + + // Every field encodes to exactly one 32-byte word (dynamic values contribute their keccak + // hash), so the struct preimage size is fixed by the plan. + const struct = new Uint8Array(32 * (1 + plan.fields.length)); + struct.set(plan.typehash, 0); + let offset = 32; + for (const field of plan.fields) { + if (!encodeUserSignedField(struct, offset, field.kind, action[field.name])) return undefined; + offset += 32; + } + const structHash = keccak256(struct); + + const digest = new Uint8Array(2 + 32 + 32); + digest[0] = 0x19; + digest[1] = 0x01; + digest.set(hyperliquidSignDomainSeparator(chainId), 2); + digest.set(structHash, 34); + return keccak256(digest); +} diff --git a/src/signing/_l1.ts b/src/signing/_l1.ts index 66719b6f..a595a672 100644 --- a/src/signing/_l1.ts +++ b/src/signing/_l1.ts @@ -392,10 +392,40 @@ export async function signL1Inner(args: { * Default: `false` */ isTestnet?: boolean; +}): Promise { + return signL1InnerBytes({ + signer: args.signer, + actionHashBytes: hexToBytes(args.actionHash.slice(2)), + isTestnet: args.isTestnet, + }); +} + +/** + * Bytes-level variant of {@linkcode signL1Inner}: takes the precomputed hash as `Uint8Array`, so + * the multi-sig orchestrator passes its `createL1ActionHashBytes` output straight through instead + * of round-tripping the 32-byte hash through hex per signer. Package-internal — not re-exported + * from `mod.ts`. + * + * @param args The signer and signing parameters. + * @return The trimmed ECDSA signature. + * + * @throws {AbstractWalletError} If signing fails. + */ +export async function signL1InnerBytes(args: { + /** Inner signer (one of the multi-sig authorized users). */ + signer: AbstractWallet; + /** Precomputed 32-byte hash of `[multiSigUser, outerSigner, action]` (addresses lowercased) with the request nonce. */ + actionHashBytes: Uint8Array; + /** + * Indicates if the action is for the testnet. + * + * Default: `false` + */ + isTestnet?: boolean; }): Promise { const signature = await signL1ActionHash({ wallet: args.signer, - actionHashBytes: hexToBytes(args.actionHash.slice(2)), + actionHashBytes: args.actionHashBytes, isTestnet: args.isTestnet, }); return trimSignature(signature); diff --git a/src/signing/_msgpack.ts b/src/signing/_msgpack.ts index 42f96661..7c81e065 100644 --- a/src/signing/_msgpack.ts +++ b/src/signing/_msgpack.ts @@ -254,7 +254,17 @@ export class MsgpackWriter { */ uint64(value: number | bigint): void { this.ensure(8); - this.dataView.setBigUint64(this.offset, BigInt(value)); + if (typeof value === "number" && Number.isInteger(value) && value >= 0 && value < 2 ** 64) { + // Fast arm: two 32-bit writes skip the BigInt allocation/conversion on the nonce hot path. + // Exact for every integer double in range: scaling by 2**32 is an exponent shift and `%` + // by a power of two is exact, so the bytes match `BigInt(value)` bit for bit. + this.dataView.setUint32(this.offset, Math.floor(value / 2 ** 32)); + this.dataView.setUint32(this.offset + 4, value % 2 ** 32); + } else { + // Preserves the original error surface: `BigInt()` rejects non-integers and + // `setBigUint64` rejects out-of-range values with the same `RangeError`s as before. + this.dataView.setBigUint64(this.offset, BigInt(value)); + } this.offset += 8; } diff --git a/src/signing/_multiSig.ts b/src/signing/_multiSig.ts index 399ea071..b41ecd7c 100644 --- a/src/signing/_multiSig.ts +++ b/src/signing/_multiSig.ts @@ -12,8 +12,8 @@ import { signTypedData, } from "./_abstractWallet.ts"; import { createMultiSigDigestBytes } from "./_fastDigest.ts"; -import { createL1ActionHash, createL1ActionHashBytes, preadjustL1Action, signL1Inner } from "./_l1.ts"; -import { signUserSignedInner } from "./_userSigned.ts"; +import { createL1ActionHashBytes, preadjustL1Action, signL1InnerBytes } from "./_l1.ts"; +import { createUserSignedInnerDigestThunk, signUserSignedInner } from "./_userSigned.ts"; /** EIP-712 types for the multi-sig outer wrapper. */ const MULTI_SIG_TYPES = { @@ -234,7 +234,8 @@ export async function signMultiSigL1(args: { const adjustedAction = preadjustL1Action(args.action); // --- Hash the inner payload (identical for every signer) - - const innerActionHash = createL1ActionHash({ + // Bytes end-to-end: the hash feeds straight into the per-signer digests without a hex round trip. + const innerActionHashBytes = createL1ActionHashBytes({ action: [args.multiSigUser.toLowerCase(), outerSigner.toLowerCase(), adjustedAction], nonce: args.nonce, vaultAddress: args.vaultAddress, @@ -244,9 +245,9 @@ export async function signMultiSigL1(args: { // --- Collect inner signatures from all signers ----------- const innerSignatures = await Promise.all( args.signers.map((signer) => - signL1Inner({ + signL1InnerBytes({ signer, - actionHash: innerActionHash, + actionHashBytes: innerActionHashBytes, isTestnet: args.isTestnet, }), ), @@ -381,6 +382,14 @@ export async function signMultiSigUserSigned(args: { const outerSigner = await getWalletAddress(args.signers[0]); // --- Collect inner signatures from all signers ----------- + // Every inner signer commits to the SAME digest, so share one memoized thunk: it computes at + // most once per call, and only if some signer can actually sign a raw digest. + const innerDigestThunk = createUserSignedInnerDigestThunk({ + action: args.action, + types: args.types, + multiSigUser: args.multiSigUser, + outerSigner, + }); const innerSignatures = await Promise.all( args.signers.map((signer) => signUserSignedInner({ @@ -389,6 +398,7 @@ export async function signMultiSigUserSigned(args: { types: args.types, multiSigUser: args.multiSigUser, outerSigner, + digestThunk: innerDigestThunk, }), ), ); diff --git a/src/signing/_userSigned.ts b/src/signing/_userSigned.ts index 51f5994d..5618745b 100644 --- a/src/signing/_userSigned.ts +++ b/src/signing/_userSigned.ts @@ -3,7 +3,14 @@ * @module */ -import { type AbstractWallet, type Signature, signTypedData } from "./_abstractWallet.ts"; +import { + type AbstractWallet, + canSignRawDigest, + type Signature, + signRawDigestBytes, + signTypedData, +} from "./_abstractWallet.ts"; +import { createUserSignedDigestBytes } from "./_fastDigest.ts"; import { trimSignature } from "./_multiSig.ts"; /** EIP-712 type definitions; the hash depends on key and field order. */ @@ -109,8 +116,45 @@ export async function signUserSignedAction< action: TAction; /** The types of the action (hash depends on key order). */ types: Record; +}): Promise { + return signUserSignedActionDigest(args); +} + +/** + * Shared by {@linkcode signUserSignedAction} and {@linkcode signUserSignedInner}. + * + * Fast path: a wallet that can sign a raw digest (viem local accounts expose `sign`) signs the + * hand-rolled digest directly and skips viem's whole EIP-712 encoding (~43 µs → a few µs). The + * digest is byte-identical — `tests/signing/userSignedDigest.test.ts` pins it against viem's + * `hashTypedData`. Two kinds of miss fall through to the unchanged typed-data path: a wallet + * without the raw-digest capability (ledger/remote signers), and a `types` shape or field value + * the hand-rolled encoder does not reproduce exactly (nested structs, arrays, checksummed + * mixed-case addresses, …) yields no digest. + * + * The capability check comes FIRST: the digest is data-dependent and costs several keccak calls, + * so computing it for a wallet that would discard it (every remote/ledger wallet) is pure waste — + * and the lazy-thunk form of `signRawDigestBytes` cannot express "no digest → fall back", so + * gating on `canSignRawDigest` is how the waste is avoided. `digestThunk`, when given, produces + * the digest at most once across all signers of one multi-sig call (they all sign the same one). + */ +async function signUserSignedActionDigest(args: { + wallet: AbstractWallet; + action: { signatureChainId: `0x${string}`; [key: string]: unknown }; + types: Record; + digestThunk?: () => Uint8Array | undefined; }): Promise { const { wallet, action, types } = args; + + if (canSignRawDigest(wallet)) { + const digest = args.digestThunk + ? args.digestThunk() + : createUserSignedDigestBytes(action, types, action.signatureChainId); + if (digest !== undefined) { + const fast = await signRawDigestBytes({ wallet, digest: () => digest }); + if (fast !== undefined) return fast; + } + } + return await signTypedData({ wallet, domain: { @@ -127,6 +171,62 @@ export async function signUserSignedAction< }); } +/** + * Builds the multi-sig-extended types and the action with the multi-sig fields injected — the + * exact pair the inner per-signer signatures commit to. Shared by {@linkcode signUserSignedInner} + * and {@linkcode createUserSignedInnerDigestThunk} so the digest and any typed-data fallback can + * never diverge. + */ +function buildMultiSigInner(args: { + action: { signatureChainId: `0x${string}`; [key: string]: unknown }; + types: Record; + multiSigUser: `0x${string}`; + outerSigner: `0x${string}`; +}): { + action: { signatureChainId: `0x${string}`; [key: string]: unknown }; + types: Record; +} { + return { + // Inject fields for multi-sig; shared across signers of one action, see the memo above. + types: getMultiSigExtendedTypes(args.types), + action: { + payloadMultiSigUser: args.multiSigUser.toLowerCase(), + outerSigner: args.outerSigner.toLowerCase(), + ...args.action, + }, + }; +} + +/** + * Returns a thunk producing the digest every inner signer of one multi-sig user-signed action + * commits to — they all sign the SAME digest, so the thunk computes it on first invocation and + * memoizes. Invoked only for signers that can actually sign a raw digest (see + * {@linkcode signUserSignedActionDigest}): when no signer can, the digest is never computed. + * Package-internal — not re-exported from `mod.ts`. + * + * @param args The action, types, and multi-sig parameters (as passed to {@linkcode signUserSignedInner}). + * @return A memoized thunk producing the 32-byte digest, or `undefined` when the shape is unsupported. + */ +export function createUserSignedInnerDigestThunk(args: { + /** The action to be authorized (must include `signatureChainId`). */ + action: { signatureChainId: `0x${string}`; [key: string]: unknown }; + /** The types of the action. */ + types: Record; + /** The multi-sig account address. */ + multiSigUser: `0x${string}`; + /** The leader address (address of the wallet that signs the outer wrapper). */ + outerSigner: `0x${string}`; +}): () => Uint8Array | undefined { + let digest: Uint8Array | null | undefined; + return () => { + if (digest === undefined) { + const inner = buildMultiSigInner(args); + digest = createUserSignedDigestBytes(inner.action, inner.types, inner.action.signatureChainId) ?? null; + } + return digest ?? undefined; + }; +} + /** * Signs an inner per-signer contribution to a multi-sig user-signed action. * @@ -150,18 +250,19 @@ export async function signUserSignedInner(args: { multiSigUser: `0x${string}`; /** The leader address (address of the wallet that signs the outer wrapper). */ outerSigner: `0x${string}`; + /** + * Shared digest thunk from {@linkcode createUserSignedInnerDigestThunk}: every signer of one + * multi-sig call signs the same digest, so the caller computes it at most once. When omitted, + * the digest is computed per call. + */ + digestThunk?: () => Uint8Array | undefined; }): Promise { - // Inject fields for multi-sig; shared across signers of one action, see the memo above. - const extendedTypes = getMultiSigExtendedTypes(args.types); - - const signature = await signUserSignedAction({ + const inner = buildMultiSigInner(args); + const signature = await signUserSignedActionDigest({ wallet: args.signer, - action: { - payloadMultiSigUser: args.multiSigUser.toLowerCase(), - outerSigner: args.outerSigner.toLowerCase(), - ...args.action, - }, - types: extendedTypes, + action: inner.action, + types: inner.types, + digestThunk: args.digestThunk, }); return trimSignature(signature); } diff --git a/src/transport/_abort.ts b/src/transport/_abort.ts index 561a74f9..4b0c1d11 100644 --- a/src/transport/_abort.ts +++ b/src/transport/_abort.ts @@ -32,6 +32,22 @@ export function scheduleTimeout(target: AbortController, ms: number | null): { r /** Longest delay `setTimeout` accepts without clamping to ~1 ms: 2^31-1, the same bound the rate limiter slices at. */ const MAX_TIMEOUT_DELAY_MS = 2_147_483_647; +/** Lazily-created reason behind {@linkcode DISABLED_TIMEOUT}; memoized so reference classification holds. */ +let disabledReason: Error | undefined; + +/** + * The handle every disabled timeout shares: no queue entry and no timer exist for it, so one + * frozen object serves all callers instead of a fresh allocation per schedule. `reason` stays + * lazy and memoized like a live handle's — callers classify timeouts by reference — and + * `cancel` is a no-op. + */ +const DISABLED_TIMEOUT: { reason: Error; cancel: () => void } = Object.freeze({ + get reason(): Error { + return (disabledReason ??= new DOMException_("Signal timed out.", "TimeoutError")); + }, + cancel: noop, +}); + /** A pending timeout: one node of the {@linkcode TimeoutWheel}'s deadline-sorted queue. */ interface TimeoutEntry { /** @@ -86,18 +102,11 @@ export class TimeoutWheel { * `reason` is the lazily-created, memoized `TimeoutError` the abort fires with, and `cancel` * withdraws the entry (clear-on-settle). * - * `null` — and any non-finite value — disables the timeout: no queue entry, no timer. + * `null` — and any non-finite value — disables the timeout: no queue entry, no timer, just + * the shared frozen DISABLED_TIMEOUT handle. */ schedule(target: AbortController, ms: number | null): { reason: Error; cancel: () => void } { - if (ms === null || !Number.isFinite(ms)) { - let reason: Error | undefined; - return { - get reason(): Error { - return (reason ??= new DOMException_("Signal timed out.", "TimeoutError")); - }, - cancel: noop, - }; - } + if (ms === null || !Number.isFinite(ms)) return DISABLED_TIMEOUT; const entry: TimeoutEntry = { deadline: Date.now() + (ms > MAX_TIMEOUT_DELAY_MS ? 1 : ms), target, diff --git a/src/transport/http/mod.ts b/src/transport/http/mod.ts index cb0f2a49..10ef8f8e 100644 --- a/src/transport/http/mod.ts +++ b/src/transport/http/mod.ts @@ -8,7 +8,7 @@ * ```text * HttpTransport.request(): * rateLimit? ◄─ token bucket wait for the request's weight (opt-in; abort-aware; disabled by default) - * controller ◄─ timeout / user signal / fetchOptions.signal + * controller ◄─ timeout / user signal / fetchOptions.signal (none allocated when all are absent) * └─► fetch ┬─► non-OK or non-JSON body ─► HttpRequestError; 429 ─► HttpRateLimitError * └─► parse JSON ─► T * catch: classify by reference ─► finally: cancel timer, detach @@ -160,9 +160,16 @@ export class HttpRequestError extends TransportError { * * The message is the response status line, extended with `detail` when given; * without a response, `detail` alone or a description of `cause` is used. + * + * `signatureFree` asserts that `request` carries no `signature`/`signatures` value anywhere, + * letting the constructor skip the redaction walk. The transports set it only after scanning + * the serialized wire form for a `"signature"` key; leave it unset for a payload of unknown + * provenance, so every `request` is walked and redacted. */ - constructor(options?: ErrorOptions & { detail?: string; response?: Response; request?: unknown }) { - const { detail, response, request, ...errorOptions } = options ?? {}; + constructor( + options?: ErrorOptions & { detail?: string; response?: Response; request?: unknown; signatureFree?: boolean }, + ) { + const { detail, response, request, signatureFree, ...errorOptions } = options ?? {}; let message: string; if (response) { @@ -182,7 +189,7 @@ export class HttpRequestError extends TransportError { this.name = "HttpRequestError"; this.response = response; this.status = response?.status; - this.request = redactSignature(request); + this.request = signatureFree === true ? request : redactSignature(request); } } @@ -224,7 +231,9 @@ export class HttpRateLimitError extends HttpRequestError { * * Accepts the same options as {@linkcode HttpRequestError}. */ - constructor(options?: ErrorOptions & { detail?: string; response?: Response; request?: unknown }) { + constructor( + options?: ErrorOptions & { detail?: string; response?: Response; request?: unknown; signatureFree?: boolean }, + ) { super(options); this.name = "HttpRateLimitError"; this.retryAfter = parseRetryAfter(options?.response?.headers.get("Retry-After") ?? null); @@ -306,25 +315,29 @@ export class HttpTransport implements IRequestTransport<"info" | "exchange" | "e * ``` */ async request(endpoint: "info" | "exchange" | "explorer", payload: unknown, signal?: AbortSignal): Promise { - // One controller per request: the caller's signals relay into it FIRST, so they also cancel - // a rate-limit wait; the timeout timer is armed only after the wait, so deliberate pacing + // One controller per request — but only when something can actually abort it: a caller or + // fetchOptions signal, or a finite timeout. With none of those there is nothing to relay or + // arm, so no controller, relay, or wheel entry is allocated and fetch runs unsignaled. When + // the controller exists, the caller's signals relay into it FIRST, so they also cancel a + // rate-limit wait; the timeout timer is armed only after the wait, so deliberate pacing // never trips it; and `finally` detaches everything, so no listener or timer outlives the // request. - const controller = new AbortController(); const fetchSignal = this.fetchOptions.signal; - const detachRelay = - signal !== undefined || (fetchSignal !== undefined && fetchSignal !== null) - ? abort.relay([signal, fetchSignal], controller) - : noop; // no signals to relay, so nothing to wire up + const hasSignal = signal !== undefined || (fetchSignal !== undefined && fetchSignal !== null); // Captured now, so the error message reports the value the timer was armed with even // if the field is reassigned mid-flight. The exchange endpoint honors its own override. const timeoutMs = endpoint === "exchange" && this.exchangeTimeout !== undefined ? this.exchangeTimeout : this.timeout; + // Mirrors the wheel's own disabling rule, so a null/non-finite timeout costs no entry either. + const hasTimeout = timeoutMs !== null && Number.isFinite(timeoutMs); + const controller = hasSignal || hasTimeout ? new AbortController() : undefined; + const detachRelay = controller !== undefined && hasSignal ? abort.relay([signal, fetchSignal], controller) : noop; let timeout: ReturnType | undefined; // The one serialization of the payload — wire form, weight source, and error snapshot all // derive from it, so getters/proxies/toJSON run exactly once per request. let body: string | undefined; - // The parsed form of `body`, computed when the limiter needs the weight; `undefined` until then. + // The parsed form of `body`, materialized lazily: up front when the limiter needs an + // info/exchange weight, otherwise only if a response surcharge or an error requires it. let snapshot: unknown; try { @@ -339,11 +352,14 @@ export class HttpTransport implements IRequestTransport<"info" | "exchange" | "e // debit is the conservative reading. const rateLimit = this._rateLimit; if (rateLimit !== null) { - snapshot = JSON.parse(body); // plain data: billing is immune to getters/proxies/toJSON - await rateLimit.acquire(requestWeight(endpoint, snapshot), controller.signal); + // The parsed wire form — never the live payload — is the billing source, so + // getters/proxies/toJSON cannot move the weight off what was actually sent. Explorer + // requests are a flat 40 whatever the payload, so they skip the parse entirely. + const weight = endpoint === "explorer" ? 40 : requestWeight(endpoint, (snapshot = JSON.parse(body))); + await rateLimit.acquire(weight, controller?.signal); } - timeout = this._timeouts.schedule(controller, timeoutMs); + if (controller !== undefined && hasTimeout) timeout = this._timeouts.schedule(controller, timeoutMs); // --- Request init ------------------------------------------------------ const url = this._endpointUrl(endpoint === "explorer" ? this.rpcUrl : this.apiUrl, endpoint); @@ -354,7 +370,7 @@ export class HttpTransport implements IRequestTransport<"info" | "exchange" | "e body, headers: { "Content-Type": "application/json" }, method: "POST", - signal: controller.signal, + signal: controller?.signal, } : mergeRequestInit( { @@ -365,7 +381,7 @@ export class HttpTransport implements IRequestTransport<"info" | "exchange" | "e method: "POST", }, this.fetchOptions, - { signal: controller.signal }, + { signal: controller?.signal }, ); // --- Send and validate ------------------------------------------------- @@ -378,7 +394,7 @@ export class HttpTransport implements IRequestTransport<"info" | "exchange" | "e throw new ErrorClass({ response: clone, detail: text ? truncate(text) : undefined, - request: requestSnapshot(body, snapshot), + ...errorRequest(body, snapshot), }); } @@ -388,7 +404,8 @@ export class HttpTransport implements IRequestTransport<"info" | "exchange" | "e const parsed = JSON.parse(text); // Response-size surcharges can only be billed after the fact: debit the bucket so // later requests wait off the real cost instead of the pre-request estimate. - if (rateLimit !== null) { + if (rateLimit !== null && Array.isArray(parsed) && parsed.length > 0) { + snapshot ??= JSON.parse(body); // explorer skipped the pre-send parse (flat weight 40) const surcharge = responseSurcharge(endpoint, snapshot, parsed); if (surcharge > 0) rateLimit.charge(surcharge); } @@ -398,7 +415,7 @@ export class HttpTransport implements IRequestTransport<"info" | "exchange" | "e response: recreateResponse(response, text), detail: "Invalid JSON response body", cause: error, - request: requestSnapshot(body, snapshot), + ...errorRequest(body, snapshot), }); } } catch (error) { @@ -407,17 +424,17 @@ export class HttpTransport implements IRequestTransport<"info" | "exchange" | "e throw new HttpRequestError({ detail: `Request timed out after ${timeoutMs} ms`, cause: error, - request: requestSnapshot(body, snapshot), + ...errorRequest(body, snapshot), }); } - if (controller.signal.aborted && error === controller.signal.reason) { + if (controller?.signal.aborted && error === controller.signal.reason) { throw new HttpRequestError({ detail: "Request aborted", cause: error, - request: requestSnapshot(body, snapshot), + ...errorRequest(body, snapshot), }); } - throw new HttpRequestError({ cause: error, request: requestSnapshot(body, snapshot) }); + throw new HttpRequestError({ cause: error, ...errorRequest(body, snapshot) }); } finally { timeout?.cancel(); detachRelay(); @@ -451,14 +468,22 @@ function truncate(text: string, limit = 1024): string { // https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/rate-limits-and-user-limits /** - * The `request` carried by an error: the parsed form of the one wire serialization, so it is - * always a plain-data snapshot — never the live payload (getters, proxies, and stateful `toJSON` - * run exactly once, inside the serialization itself). When serialization failed there is no - * snapshot, and the original is never traversed as a fallback: a safe constant remains. + * The error options carrying the request: the parsed form of the one wire serialization, so it + * is always a plain-data snapshot — never the live payload (getters, proxies, and stateful + * `toJSON` run exactly once, inside the serialization itself). When serialization failed there + * is no snapshot, and the original is never traversed as a fallback: a safe constant remains. + * + * `signatureFree` comes from scanning the wire string for a `"signature` key (a prefix of + * `"signatures"` too): `JSON.stringify` always emits keys quoted, so a signature-bearing payload + * cannot slip past, and the signature-free majority — every info request — skips the error + * constructor's redaction walk. */ -function requestSnapshot(body: string | undefined, snapshot: unknown): unknown { - if (body === undefined) return UNSERIALIZABLE_REQUEST; - return snapshot !== undefined ? snapshot : JSON.parse(body); +function errorRequest(body: string | undefined, snapshot: unknown): { request: unknown; signatureFree: boolean } { + if (body === undefined) return { request: UNSERIALIZABLE_REQUEST, signatureFree: true }; + return { + request: snapshot !== undefined ? snapshot : JSON.parse(body), + signatureFree: !body.includes('"signature'), + }; } /** Info requests with weight 2. */ diff --git a/src/transport/websocket/_dispatcher.ts b/src/transport/websocket/_dispatcher.ts index e1a56403..249e44e5 100644 --- a/src/transport/websocket/_dispatcher.ts +++ b/src/transport/websocket/_dispatcher.ts @@ -163,10 +163,13 @@ export class WebSocketDispatcher { * re-subscription echoes cost N² subset checks. */ private readonly _byEchoId: Map = new Map(); + /** Shared request-timeout scheduler: at most one armed native timer, however many requests are in flight. */ + private readonly _timeouts: abort.TimeoutWheel; constructor(socket: ReconnectingWebSocket, hlEvents: HyperliquidEventTarget, timeout: number | null) { this.timeout = timeout; this._socket = socket; + this._timeouts = new abort.TimeoutWheel(); // --- Hyperliquid event handlers ------------------------------------------ hlEvents.addEventListener("subscriptionResponse", (event) => this._handleSubscriptionResponse(event.detail)); @@ -223,7 +226,7 @@ export class WebSocketDispatcher { // no listener or timer outlives the request. const controller = new AbortController(); const timeoutMs = this.timeout; // for correct error message after user changes - const timeout = abort.scheduleTimeout(controller, timeoutMs); + const timeout = this._timeouts.schedule(controller, timeoutMs); const detachRelay = abort.relay([signal, this._socket.terminationSignal], controller); let entry: PendingRequest | undefined; diff --git a/src/transport/websocket/_routing.ts b/src/transport/websocket/_routing.ts index 3348fd5a..9445e88d 100644 --- a/src/transport/websocket/_routing.ts +++ b/src/transport/websocket/_routing.ts @@ -48,11 +48,26 @@ function stringProp(value: unknown, key: string): string | undefined { return typeof read === "string" ? read : undefined; } +/** + * Matches any uppercase ASCII letter: the gate that lets a case-folding reader skip its + * `toLowerCase` — and the fresh string it allocates — when the value is already lowercase, which + * server-sent hex addresses virtually always are. + */ +const HAS_UPPERCASE = /[A-Z]/; + +/** Case-folds a user-reading key reader, allocating only when an uppercase char actually occurs. */ +function caseFolded(reader: KeyReader): KeyReader { + return (value: unknown): string | undefined => { + const key = reader(value); + return key === undefined || !HAS_UPPERCASE.test(key) ? key : key.toLowerCase(); + }; +} + /** Route key of a payload's `coin`. */ const payloadCoin: KeyReader = (payload: unknown): string | undefined => stringProp(payload, "coin"); /** Route key of a payload's `user`, case-folded because addresses are case-insensitive. */ -const payloadUser: KeyReader = (payload: unknown): string | undefined => stringProp(payload, "user")?.toLowerCase(); +const payloadUser: KeyReader = caseFolded((payload: unknown): string | undefined => stringProp(payload, "user")); /** `payload.coin` against `data.coin`: the shape most asset channels use. */ const BY_COIN: ChannelRoute = { @@ -63,7 +78,7 @@ const BY_COIN: ChannelRoute = { /** `payload.user` against `data.user`. */ const BY_USER: ChannelRoute = { fromPayload: payloadUser, - fromEvent: (data: unknown): string | undefined => stringProp(data, "user")?.toLowerCase(), + fromEvent: caseFolded((data: unknown): string | undefined => stringProp(data, "user")), }; /** @@ -84,7 +99,7 @@ const BY_TRADES_COIN: ChannelRoute = { /** `webData3.ts`: `e.detail.userState.user === payload.user`. */ const BY_USER_STATE: ChannelRoute = { fromPayload: payloadUser, - fromEvent: (data: unknown): string | undefined => stringProp(prop(data, "userState"), "user")?.toLowerCase(), + fromEvent: caseFolded((data: unknown): string | undefined => stringProp(prop(data, "userState"), "user")), }; /** @@ -162,6 +177,29 @@ export function payloadEventType(channel: string, payload: unknown): string { return key === undefined ? channel : channel + KEY_SEPARATOR + key; } +/** + * Interned routed types, `channel → key → routedType`: repeat frames on one route reuse a single + * string instead of allocating `channel + KEY_SEPARATOR + key` per frame, and the shared identity + * also speeds the listener-map hashing the routed type feeds into. Keys are bounded by the + * coins/users actually seen on the wire, so no eviction is needed. + */ +const ROUTED_TYPES: Map> = new Map(); + +/** The interned routed type of `channel` + `key`. */ +function internRoutedType(channel: string, key: string): string { + let byKey = ROUTED_TYPES.get(channel); + if (byKey === undefined) { + byKey = new Map(); + ROUTED_TYPES.set(channel, byKey); + } + let routed = byKey.get(key); + if (routed === undefined) { + routed = channel + KEY_SEPARATOR + key; + byKey.set(key, routed); + } + return routed; +} + /** * Routed event type of an incoming frame, or `undefined` when the frame carries no route key and * must be broadcast on its channel instead. @@ -176,5 +214,5 @@ export function frameEventType(channel: string, data: unknown): string | undefin const route = ROUTES.get(channel); if (route === undefined) return undefined; const key = route.fromEvent(data); - return key === undefined ? undefined : channel + KEY_SEPARATOR + key; + return key === undefined ? undefined : internRoutedType(channel, key); } diff --git a/src/transport/websocket/mod.ts b/src/transport/websocket/mod.ts index c1de0c64..0f6e7d18 100644 --- a/src/transport/websocket/mod.ts +++ b/src/transport/websocket/mod.ts @@ -163,9 +163,9 @@ export class WebSocketTransport implements IRequestTransport<"info" | "exchange" * const mids = await transport.request("info", { type: "allMids" }); * ``` */ - async request(endpoint: "info" | "exchange", payload: unknown, signal?: AbortSignal): Promise { + request(endpoint: "info" | "exchange", payload: unknown, signal?: AbortSignal): Promise { const wrapped = { type: endpoint === "exchange" ? "action" : endpoint, payload }; - return await this._dispatcher.request("post", wrapped, signal); + return this._dispatcher.request("post", wrapped, signal); } /** diff --git a/src/utils/_format.ts b/src/utils/_format.ts index 343a2877..1214cab2 100644 --- a/src/utils/_format.ts +++ b/src/utils/_format.ts @@ -191,33 +191,61 @@ export function floatToWire(x: number): string { throw new FormatError(`floatToWire: ${String(x)} is not finite`); } - // Fast path: for |x| < 1e21, native `toFixed(8)` renders the EXACT stored double (V8/JSC exact-mode - // dtoa — `1e18 + 128` comes out as "1000000000000000128.00000000"), byte-identical to CPython's - // `f"{x:.8f}"` on every input EXCEPT an exact 8-decimal tie, where it rounds half-up while Python - // rounds half-even (repro: -233095212199.9004 → toFixed gives …063, Python gives …062). The 1e-12 - // guard does NOT catch that — both candidates parse back to x — so ties must not take this path. - // A tie means the exact expansion terminates in digit 5 at the 9th decimal, so `toFixed(9)` ending - // in "5" detects every potential tie (no false negatives; false positives only cost the slow path). - // |x| >= 1e21 also takes the slow path: toFixed degenerates to `String()` (exponent form) there. - // This path never exceeds 28 significant digits (≤ 20 integer digits + 8 decimals below 1e20; - // doubles in [1e20, 1e21) are integers), so the context-28 normalize below can only strip here. - if (Math.abs(x) < 1e21 && !x.toFixed(9).endsWith("5")) { - let wire = x.toFixed(8); - // toFixed pads to exactly 8 decimals; strip the padding (Python's `Decimal(rounded).normalize()`): - // trailing zeros, then a bare decimal point. "-0.00000000" collapses to "0" — the documented -0 - // mapping. A manual strip rather than the scanDecimal/toFixed round-trip: the string shape is - // fixed, so no parse is needed, and it is ~90 ns/call cheaper (see tests/perf float_to_wire). - let end = wire.length; - while (wire.charCodeAt(end - 1) === 48) end--; // "0" - if (wire.charCodeAt(end - 1) === 46) end--; // "." - wire = wire.slice(0, end); - if (wire === "-0") wire = "0"; - // Python: `if abs(float(rounded) - x) >= 1e-12: raise ValueError("float_to_wire causes rounding")`. - // Python's `float()` is the nearest double to the decimal string — exactly what `Number()` parses. - if (Math.abs(Number(wire) - x) >= 1e-12) { - throw new FormatError(`floatToWire causes rounding: ${x}`); + // Fast path: for |x| < 1e21, native `toFixed` renders the EXACT stored double (V8/JSC exact-mode + // dtoa — `1e18 + 128` comes out as "1000000000000000128.000000000"), byte-identical to CPython's + // fixed-point render on every input EXCEPT an exact 8-decimal tie, where it rounds half-up while + // Python rounds half-even (repro: -233095212199.9004 → toFixed gives …063, Python gives …062). + // The 1e-12 guard does NOT catch that — both candidates parse back to x — so ties must not take + // this path. A tie means the exact expansion terminates in digit 5 at the 9th decimal, so + // `toFixed(9)` ending in "5" detects every potential tie (no false negatives; false positives + // only cost the slow path). The 8-decimal wire string is then derived from the SAME 9-decimal + // render — digit 9 (never 5 here) is the round digit: 0-4 truncate, 6-9 round digit 8 up with + // carry. Byte-identical to `x.toFixed(8)`: the 9-decimal render lies within 0.5e-9 of the exact + // double, and only a 5e-9 tie (already excluded) sits close enough to the 8-decimal rounding + // boundary for that gap to flip the result. |x| >= 1e21 takes the slow path: toFixed degenerates + // to `String()` (exponent form) there. This path never exceeds 28 significant digits (≤ 20 + // integer digits + 8 decimals below 1e20; doubles in [1e20, 1e21) are integers), so the + // context-28 normalize below can only strip here. + if (Math.abs(x) < 1e21) { + const round9 = x.toFixed(9); + if (!round9.endsWith("5")) { + let wire: string; + if (round9.charCodeAt(round9.length - 1) < 53 /* "5" */) { + // Digit 9 of 0-4: the 8-decimal render truncates. + wire = round9.slice(0, -1); + } else { + // Digit 9 of 6-9: round digit 8 up, carrying left through any run of 9s (hopping the + // decimal point); a carry that reaches the sign or the first digit grows the integer + // part ("9.999999999" → "10.00000000", "-9.…" → "-10.…"). + let i = round9.length - 2; + while (round9.charCodeAt(i) === 57 /* "9" */) { + i -= round9.charCodeAt(i - 1) === 46 /* "." */ ? 2 : 1; + } + // Zero the carried 9-run; the decimal point inside it (if any) keeps its place. + const dot = round9.length - 10; // index of "." in a 9-decimal render + const zeros = i < dot ? `${"0".repeat(dot - i - 1)}.00000000` : "0".repeat(round9.length - 2 - i); + wire = + i >= 0 && round9.charCodeAt(i) !== 45 /* "-" */ + ? round9.slice(0, i) + String.fromCharCode(round9.charCodeAt(i) + 1) + zeros + : `${round9.slice(0, i + 1)}1${zeros}`; + } + // The render pads to exactly 8 decimals; strip the padding (Python's + // `Decimal(rounded).normalize()`): trailing zeros, then a bare decimal point. "-0.00000000" + // collapses to "0" — the documented -0 mapping. A manual strip rather than the + // scanDecimal/toFixed round-trip: the string shape is fixed, so no parse is needed, and it + // is ~90 ns/call cheaper (see tests/perf float_to_wire). + let end = wire.length; + while (wire.charCodeAt(end - 1) === 48) end--; // "0" + if (wire.charCodeAt(end - 1) === 46) end--; // "." + wire = wire.slice(0, end); + if (wire === "-0") wire = "0"; + // Python: `if abs(float(rounded) - x) >= 1e-12: raise ValueError("float_to_wire causes rounding")`. + // Python's `float()` is the nearest double to the decimal string — exactly what `Number()` parses. + if (Math.abs(Number(wire) - x) >= 1e-12) { + throw new FormatError(`floatToWire causes rounding: ${x}`); + } + return wire; } - return wire; } // Exact path: render the double's exact binary value via {@linkcode exactDecimalParts} — CPython's diff --git a/tests/api/exchange/_dispatchOrder.test.ts b/tests/api/exchange/_dispatchOrder.test.ts new file mode 100644 index 00000000..c3c6a80e --- /dev/null +++ b/tests/api/exchange/_dispatchOrder.test.ts @@ -0,0 +1,149 @@ +/** + * Tests for the per-wallet dispatch order guaranteed by `executeWithShell`. + * + * The server requires a wallet's exchange requests to reach it in strictly increasing nonce order. + * The nonce lock only fixes the order nonces are ISSUED in; signing happens outside it so that + * concurrent callers on one wallet — where signing is a network round trip for any remote wallet — + * can sign at the same time. A per-wallet dispatch chain is what restores wire order afterwards. + * + * That makes the interesting cases the ones where signing finishes out of order, and the ones where + * a request never reaches the wire at all: a burned nonce must leave a gap (which the server + * tolerates) without either stalling later requests or letting them overtake an earlier nonce that + * is still being signed. + * @module + */ + +import { describe, test } from "bun:test"; +import { assert, assertEquals } from "@jsr/std__assert"; + +import { executeWithShell } from "../../../src/api/exchange/_methods/_base/_shell.ts"; + +// ============================================================ +// Helpers +// ============================================================ + +/** Records the nonce of every request that reaches the transport, in wire order. */ +interface Harness { + config: never; + dispatched: number[]; +} + +function createHarness(): Harness { + const dispatched: number[] = []; + const config = { + transport: { + isTestnet: false, + request: async (_endpoint: string, body: { nonce: number }) => { + dispatched.push(body.nonce); + // A response that settles later than the next caller's dispatch, so a serialized + // implementation would be visible as reordering rather than hidden by timing. + await new Promise((resolve) => setTimeout(resolve, 5)); + return { status: "ok", response: { type: "default" } }; + }, + }, + // Minimal viem local-account shape: `signTypedData` arity 1 plus an address. + wallet: { + address: "0x1111111111111111111111111111111111111111" as const, + signTypedData: async (_params: unknown): Promise<`0x${string}`> => `0x${"11".repeat(65)}` as `0x${string}`, + }, + } as never; + return { config, dispatched }; +} + +/** + * Fires `count` concurrent requests whose signing takes `delayFor(i)` ms, optionally failing the + * one at `failAt`. `i` is assigned in the order signing STARTS, which is the order nonces were + * issued in. + */ +async function runConcurrent( + harness: Harness, + count: number, + delayFor: (index: number) => number, + failAt?: number, +): Promise[]> { + let index = 0; + return await Promise.allSettled( + Array.from({ length: count }, () => + executeWithShell(harness.config, async (_nonce) => { + const mine = index++; + await new Promise((resolve) => setTimeout(resolve, delayFor(mine))); + if (mine === failAt) throw new Error("signing failed"); + return { action: { type: "test" }, signature: { r: "0x0", s: "0x0", v: 27 }, extras: {} }; + }), + ), + ); +} + +/** Asserts the recorded wire order is strictly increasing. */ +function assertStrictlyIncreasing(dispatched: number[], context: string): void { + for (let i = 1; i < dispatched.length; i++) { + assert( + dispatched[i]! > dispatched[i - 1]!, + `${context}: nonce ${dispatched[i]} reached the wire after ${dispatched[i - 1]} (order: ${dispatched.join(", ")})`, + ); + } +} + +// ============================================================ +// Tests +// ============================================================ + +describe("executeWithShell dispatch order", () => { + test("holds when signatures complete in reverse order", async () => { + const harness = createHarness(); + // The first nonce signs slowest and the last signs fastest, so completion order is the exact + // reverse of issue order — the case a naive "dispatch when signed" implementation gets wrong. + await runConcurrent(harness, 10, (i) => (10 - i) * 10); + + assertEquals(harness.dispatched.length, 10); + assertStrictlyIncreasing(harness.dispatched, "reversed signing order"); + }); + + test("holds under jittered signing latency", async () => { + const harness = createHarness(); + const jitter = [37, 3, 21, 8, 44, 12, 29, 1, 17, 33]; + await runConcurrent(harness, 10, (i) => jitter[i]!); + + assertEquals(harness.dispatched.length, 10); + assertStrictlyIncreasing(harness.dispatched, "jittered signing order"); + }); + + test("a failed signature leaves a nonce gap without stalling or reordering the rest", async () => { + // Each position matters: failing first, in the middle, and last exercise different sides of + // the chain — in particular that a burned nonce still waits its turn before releasing, so a + // later request cannot overtake an earlier one that is still signing. + for (const failAt of [0, 3, 7]) { + const harness = createHarness(); + const results = await runConcurrent(harness, 8, (i) => (8 - i) * 8, failAt); + + assertEquals(results.filter((r) => r.status === "rejected").length, 1, `failAt=${failAt}: one rejection`); + assertEquals(harness.dispatched.length, 7, `failAt=${failAt}: the other seven still dispatch`); + assertStrictlyIncreasing(harness.dispatched, `failAt=${failAt}`); + } + }); + + test("signing runs concurrently across callers on one wallet", async () => { + const harness = createHarness(); + const started = performance.now(); + await runConcurrent(harness, 8, () => 40); + const elapsed = performance.now() - started; + + assertEquals(harness.dispatched.length, 8); + assertStrictlyIncreasing(harness.dispatched, "concurrent signing"); + // Serialized signing would cost at least 8 x 40 ms; overlapped it is bounded by one signature + // plus dispatch. The bound is loose enough to survive a loaded CI runner. + assert(elapsed < 240, `signing did not overlap: 8 x 40 ms took ${elapsed.toFixed(0)} ms`); + }); + + test("sequential callers still dispatch in order", async () => { + const harness = createHarness(); + for (let i = 0; i < 5; i++) { + await executeWithShell(harness.config, async (_nonce) => { + return { action: { type: "test" }, signature: { r: "0x0", s: "0x0", v: 27 }, extras: {} }; + }); + } + + assertEquals(harness.dispatched.length, 5); + assertStrictlyIncreasing(harness.dispatched, "sequential callers"); + }); +}); diff --git a/tests/perf/_helpers.ts b/tests/perf/_helpers.ts index ce1be098..8a619511 100644 --- a/tests/perf/_helpers.ts +++ b/tests/perf/_helpers.ts @@ -102,7 +102,17 @@ export class MockInfoTransport implements IRequestTransport { * Server-to-client frames are injected with {@linkcode MockWebSocket.serverSend}. */ export class MockWebSocket extends EventTarget { - static instances: MockWebSocket[] = []; + /** + * The most recently constructed instance, which is all {@linkcode lastMockWebSocket} ever needs. + * + * Deliberately a single reference rather than a list: scenarios that build a transport per + * sample would otherwise pile every socket — and, through the listeners registered on it, every + * transport and its keep-alive timers — into a static array that nothing releases until the + * scenario ends. That turns a per-sample measurement into a function of how many samples have + * already run, and leaves enough retained garbage that whether a major GC lands inside the + * measured window changes the result. + */ + static last: MockWebSocket | undefined; readonly url: string; binaryType: BinaryType = "blob"; @@ -114,7 +124,7 @@ export class MockWebSocket extends EventTarget { constructor(url: string | URL, _protocols?: string | string[]) { super(); this.url = String(url); - MockWebSocket.instances.push(this); + MockWebSocket.last = this; queueMicrotask(() => { if (this.readyState !== 0) return; this.readyState = 1; // OPEN @@ -170,7 +180,7 @@ const OriginalWebSocket: typeof globalThis.WebSocket = globalThis.WebSocket; /** Replaces `globalThis.WebSocket` with {@linkcode MockWebSocket} (picked up by `ReconnectingWebSocket`). */ export function installMockWebSocket(): void { - MockWebSocket.instances = []; + MockWebSocket.last = undefined; // The mock implements only what the transport touches, so it is not structurally a // `WebSocket`; the double assertion is the whole point of installing a stand-in. globalThis.WebSocket = MockWebSocket as unknown as typeof globalThis.WebSocket; @@ -183,7 +193,7 @@ export function restoreWebSocket(): void { /** Returns the most recently created {@linkcode MockWebSocket} (i.e. the one backing the transport). */ export function lastMockWebSocket(): MockWebSocket { - const socket = MockWebSocket.instances.at(-1); + const socket = MockWebSocket.last; if (!socket) throw new Error("No MockWebSocket instance was created"); return socket; } diff --git a/tests/perf/results/baseline.json b/tests/perf/results/baseline.json index ff05fc7e..19f41056 100644 --- a/tests/perf/results/baseline.json +++ b/tests/perf/results/baseline.json @@ -1,13 +1,13 @@ { "schema": 1, "meta": { - "commit": "35cddacecd6c52a351ec841fc5692969823b4245", + "commit": "cfeeddb31e0d22daed54d2aa9842c7b11b69851f", "dirty": true, "runtime": "Bun 1.4.0 (webkit 5491700)", "cpu": "Apple M3 Max", "os": "darwin arm64", - "date": "2026-07-27T23:52:21.623Z", - "suiteFingerprint": "f259cdaa8dc3c4e0", + "date": "2026-07-28T05:07:53.997Z", + "suiteFingerprint": "c58f2d1bfb838bdd", "label": "baseline" }, "scenarios": [ @@ -19,15 +19,15 @@ "samples": 15, "iterations": 200, "unitsPerIteration": 40, - "nsPerUnit": 138.8542499999996, - "unitsPerSec": 7201796.127954332, - "min": 132.12500000000026, - "p50": 138.8542499999996, - "p75": 146.22400000000013, - "p99": 155.5156249999996, - "max": 155.5156249999996, - "stddev": 7.752872548959874, - "rme": 3.052692712194986, + "nsPerUnit": 150.06249999999886, + "unitsPerSec": 6663890.045814294, + "min": 135.2499999999992, + "p50": 150.06249999999886, + "p75": 153.59887500000013, + "p99": 160.84374999999974, + "max": 160.84374999999974, + "stddev": 6.212556280010046, + "rme": 2.2996565599508783, "fingerprint": "a0e52b09c67a0898" }, { @@ -38,15 +38,15 @@ "samples": 15, "iterations": 100, "unitsPerIteration": 350, - "nsPerUnit": 86.80237142857145, - "unitsPerSec": 11520422.582266511, - "min": 77.70239999999994, - "p50": 86.80237142857145, - "p75": 93.05831428571392, - "p99": 107.14168571428624, - "max": 107.14168571428624, - "stddev": 7.908304813195068, - "rme": 4.912208950537846, + "nsPerUnit": 88.34165714285729, + "unitsPerSec": 11319688.042334322, + "min": 82.44759999999961, + "p50": 88.34165714285729, + "p75": 93.47499999999918, + "p99": 103.43571428571424, + "max": 103.43571428571424, + "stddev": 5.551589268179441, + "rme": 3.4144491664088847, "fingerprint": "421d1b5d06bde6d5" }, { @@ -57,15 +57,15 @@ "samples": 15, "iterations": 100, "unitsPerIteration": 20, - "nsPerUnit": 767.9164999999983, - "unitsPerSec": 1302224.9163808853, - "min": 737.5419999999906, - "p50": 767.9164999999983, - "p75": 853.5830000000004, - "p99": 1239.4795000000017, - "max": 1239.4795000000017, - "stddev": 126.02188584676095, - "rme": 8.538767806589727, + "nsPerUnit": 786.3334999999978, + "unitsPerSec": 1271725.0377861338, + "min": 747.9584999999959, + "p50": 786.3334999999978, + "p75": 815.5414999999948, + "p99": 1145.5625000000111, + "max": 1145.5625000000111, + "stddev": 95.30229782694546, + "rme": 6.473946497359206, "fingerprint": "f5c147d5054be4b9" }, { @@ -76,17 +76,61 @@ "samples": 15, "iterations": 50, "unitsPerIteration": 200, - "nsPerUnit": 787.0083000000022, - "unitsPerSec": 1270634.6299016124, - "min": 734.6582999999953, - "p50": 787.0083000000022, - "p75": 808.0917, - "p99": 888.5457999999973, - "max": 888.5457999999973, - "stddev": 38.5029145321159, - "rme": 2.67822113897681, + "nsPerUnit": 762.5042000000008, + "unitsPerSec": 1311468.186011302, + "min": 735.3375, + "p50": 762.5042000000008, + "p75": 788.1708000000003, + "p99": 871.8084000000033, + "max": 871.8084000000033, + "stddev": 38.48527771330306, + "rme": 2.7513431159695583, "fingerprint": "cff19bf00ec1c5ac" }, + { + "name": "fast_asset_ctxs_snapshot_decompress", + "group": "subscription", + "description": "End-to-end fastAssetCtxs delivery of a full 320-coin snapshot frame: base64 decode, raw inflate and JSON parse", + "unit": "frame", + "samples": 15, + "iterations": 1, + "unitsPerIteration": 100, + "nsPerUnit": 82579.16999999963, + "unitsPerSec": 12109.591317035573, + "min": 69564.16999999988, + "p50": 82579.16999999963, + "p75": 86225.00000000001, + "p99": 93934.58000000009, + "max": 93934.58000000009, + "stddev": 7277.452415491381, + "rme": 4.956833396776632, + "fingerprint": "2faabb2b0ef265aa", + "extra": { + "deliveredPerTick": 1 + } + }, + { + "name": "fast_asset_ctxs_delta_decompress", + "group": "subscription", + "description": "End-to-end fastAssetCtxs delivery of a 6-coin delta frame: dominated by fixed per-frame decode overhead", + "unit": "frame", + "samples": 15, + "iterations": 1, + "unitsPerIteration": 2000, + "nsPerUnit": 5428.583500000002, + "unitsPerSec": 184210.11669066153, + "min": 5120.666999999969, + "p50": 5428.583500000002, + "p75": 5523.81250000002, + "p99": 5726.875000000006, + "max": 5726.875000000006, + "stddev": 193.18298361468572, + "rme": 1.9833146671947743, + "fingerprint": "0f8092278cf119ea", + "extra": { + "deliveredPerTick": 1 + } + }, { "name": "signing/canonicalize_order_1", "group": "signing", @@ -95,15 +139,15 @@ "samples": 15, "iterations": 2000, "unitsPerIteration": 1, - "nsPerUnit": 212.41650000001755, - "unitsPerSec": 4707732.21477577, - "min": 198.332999999991, - "p50": 212.41650000001755, - "p75": 240.08349999999723, - "p99": 357.3959999999943, - "max": 357.3959999999943, - "stddev": 42.1546216771678, - "rme": 10.23688239133553, + "nsPerUnit": 217.6875000000109, + "unitsPerSec": 4593741.027849325, + "min": 198.0415000000448, + "p50": 217.6875000000109, + "p75": 225.52100000001474, + "p99": 359.77050000002464, + "max": 359.77050000002464, + "stddev": 41.48220618765582, + "rme": 10.033935717507266, "fingerprint": "28054c7c52395052" }, { @@ -114,15 +158,15 @@ "samples": 15, "iterations": 100, "unitsPerIteration": 100, - "nsPerUnit": 124.87499999999727, - "unitsPerSec": 8008008.008008183, - "min": 120.17500000000042, - "p50": 124.87499999999727, - "p75": 128.4084000000007, - "p99": 136.78339999999594, - "max": 136.78339999999594, - "stddev": 5.100836547245626, - "rme": 2.2569091561684855, + "nsPerUnit": 128.52499999999054, + "unitsPerSec": 7780587.434351866, + "min": 124.88749999999982, + "p50": 128.52499999999054, + "p75": 130.12090000000853, + "p99": 132.95830000000706, + "max": 132.95830000000706, + "stddev": 2.343844591958002, + "rme": 1.0121648050465828, "fingerprint": "ccec3754d0b8a0a9" }, { @@ -133,15 +177,15 @@ "samples": 15, "iterations": 50, "unitsPerIteration": 100, - "nsPerUnit": 711.7165999999997, - "unitsPerSec": 1405053.6407328427, - "min": 627.0832000000041, - "p50": 711.7165999999997, - "p75": 758.2666000000017, - "p99": 888.0417999999963, - "max": 888.0417999999963, - "stddev": 70.48179298026636, - "rme": 5.411745089086885, + "nsPerUnit": 672.266599999989, + "unitsPerSec": 1487505.1058613001, + "min": 641.2665999999945, + "p50": 672.266599999989, + "p75": 752.150000000006, + "p99": 1148.4916000000112, + "max": 1148.4916000000112, + "stddev": 126.94083747171983, + "rme": 9.782673042435418, "fingerprint": "b22d0ad022636746" }, { @@ -152,15 +196,15 @@ "samples": 15, "iterations": 2000, "unitsPerIteration": 1, - "nsPerUnit": 2458.520999999962, - "unitsPerSec": 406748.6102416922, - "min": 2303.625000000011, - "p50": 2458.520999999962, - "p75": 2581.3545000000317, - "p99": 4070.021000000054, - "max": 4070.021000000054, - "stddev": 571.3766660932387, - "rme": 11.844043374453088, + "nsPerUnit": 2422.2500000000196, + "unitsPerSec": 412839.3023015758, + "min": 2323.020999999983, + "p50": 2422.2500000000196, + "p75": 2459.4999999999914, + "p99": 3453.4790000000157, + "max": 3453.4790000000157, + "stddev": 273.8968207937284, + "rme": 6.110352802440258, "fingerprint": "d54cbf619371389c" }, { @@ -171,15 +215,15 @@ "samples": 15, "iterations": 100, "unitsPerIteration": 100, - "nsPerUnit": 722.5624999999923, - "unitsPerSec": 1383963.324971903, - "min": 689.8000000000025, - "p50": 722.5624999999923, - "p75": 739.6833000000015, - "p99": 759.7707999999898, - "max": 759.7707999999898, - "stddev": 22.419421008126946, - "rme": 1.711407198710142, + "nsPerUnit": 739.7292000000107, + "unitsPerSec": 1351846.0539343122, + "min": 721.4375000000018, + "p50": 739.7292000000107, + "p75": 747.4749999999972, + "p99": 913.6875000000032, + "max": 913.6875000000032, + "stddev": 45.994845529853556, + "rme": 3.391780723151235, "fingerprint": "e04111996808eebf" }, { @@ -190,15 +234,15 @@ "samples": 15, "iterations": 2000, "unitsPerIteration": 1, - "nsPerUnit": 2609.0414999999894, - "unitsPerSec": 383282.51965329185, - "min": 2328.395999999998, - "p50": 2609.0414999999894, - "p75": 2765.3329999999983, - "p99": 3831.0204999999655, - "max": 3831.0204999999655, - "stddev": 449.8488129994487, - "rme": 9.095355373090547, + "nsPerUnit": 2477.1460000000616, + "unitsPerSec": 403690.37594068947, + "min": 2402.6460000000043, + "p50": 2477.1460000000616, + "p75": 2596.458499999926, + "p99": 3857.3749999999336, + "max": 3857.3749999999336, + "stddev": 432.48760123736565, + "rme": 9.065974962451829, "fingerprint": "7f66885275690ea4" }, { @@ -209,15 +253,15 @@ "samples": 10, "iterations": 50, "unitsPerIteration": 1, - "nsPerUnit": 106529.16000000006, - "unitsPerSec": 9387.101146765819, - "min": 92471.6599999988, - "p50": 106529.16000000006, - "p75": 122576.65999999973, - "p99": 128264.99999999895, - "max": 128264.99999999895, - "stddev": 13389.850052433298, - "rme": 8.67967606899351, + "nsPerUnit": 103134.99999999748, + "unitsPerSec": 9696.029475929843, + "min": 93430.00000000301, + "p50": 103134.99999999748, + "p75": 116183.33999999777, + "p99": 123287.50000000127, + "max": 123287.50000000127, + "stddev": 10417.823990255349, + "rme": 6.903629469070541, "fingerprint": "b7da711e90fee6ec" }, { @@ -228,15 +272,15 @@ "samples": 10, "iterations": 10, "unitsPerIteration": 100, - "nsPerUnit": 1603.1670000000986, - "unitsPerSec": 623765.3344910034, - "min": 1517.9170000000113, - "p50": 1603.1670000000986, - "p75": 1687.6250000000255, - "p99": 2035.7910000000174, - "max": 2035.7910000000174, - "stddev": 164.78414658629362, - "rme": 7.09284708044353, + "nsPerUnit": 1752.5840000000699, + "unitsPerSec": 570586.0603542883, + "min": 1547.6249999999254, + "p50": 1752.5840000000699, + "p75": 1820.791999999983, + "p99": 2990.1250000000346, + "max": 2990.1250000000346, + "stddev": 425.1946757025447, + "rme": 16.180999591214235, "fingerprint": "28281ef0ef0057b9" }, { @@ -247,15 +291,15 @@ "samples": 10, "iterations": 10, "unitsPerIteration": 1, - "nsPerUnit": 419912.499999998, - "unitsPerSec": 2381.4485160599047, - "min": 351150.00000000687, - "p50": 419912.499999998, - "p75": 469358.3999999987, - "p99": 544350.0000000086, - "max": 544350.0000000086, - "stddev": 59263.76035732599, - "rme": 9.517468154768379, + "nsPerUnit": 412500, + "unitsPerSec": 2424.242424242424, + "min": 354983.3999999919, + "p50": 412500, + "p75": 431087.49999998964, + "p99": 560916.5999999959, + "max": 560916.5999999959, + "stddev": 58242.09033361916, + "rme": 9.983073256951505, "fingerprint": "5121c76f766827ff" }, { @@ -266,15 +310,15 @@ "samples": 10, "iterations": 10, "unitsPerIteration": 1, - "nsPerUnit": 602795.8000000012, - "unitsPerSec": 1658.9365752050662, - "min": 511504.1999999903, - "p50": 602795.8000000012, - "p75": 635091.6999999981, - "p99": 648454.0999999922, - "max": 648454.0999999922, - "stddev": 55491.61507738265, - "rme": 6.75637817243165, + "nsPerUnit": 387654.1999999972, + "unitsPerSec": 2579.6186394988295, + "min": 375583.2999999939, + "p50": 387654.1999999972, + "p75": 426379.19999999664, + "p99": 490099.999999984, + "max": 490099.999999984, + "stddev": 42549.94531514849, + "rme": 7.422412363049224, "fingerprint": "f7e1c759be5541c9" }, { @@ -285,15 +329,15 @@ "samples": 15, "iterations": 200, "unitsPerIteration": 1, - "nsPerUnit": 17373.959999999897, - "unitsPerSec": 57557.40199701196, - "min": 15505.624999999556, - "p50": 17373.959999999897, - "p75": 17883.124999999607, - "p99": 25027.499999999916, - "max": 25027.499999999916, - "stddev": 2246.1375673395637, - "rme": 7.100277110169037, + "nsPerUnit": 17472.92000000016, + "unitsPerSec": 57231.41867529817, + "min": 16030.204999999569, + "p50": 17472.92000000016, + "p75": 17864.790000000994, + "p99": 24189.99999999983, + "max": 24189.99999999983, + "stddev": 1833.9723552327137, + "rme": 5.703827750434393, "fingerprint": "326bc35b91b0fb53" }, { @@ -304,15 +348,15 @@ "samples": 15, "iterations": 200, "unitsPerIteration": 1, - "nsPerUnit": 67884.58500000047, - "unitsPerSec": 14730.884780395918, - "min": 64966.040000000474, - "p50": 67884.58500000047, - "p75": 69300.21000000011, - "p99": 98957.71000000082, - "max": 98957.71000000082, - "stddev": 8957.796668131, - "rme": 7.027967977606934, + "nsPerUnit": 67110.62499999911, + "unitsPerSec": 14900.77018355906, + "min": 65197.28999999984, + "p50": 67110.62499999911, + "p75": 68490.83500000006, + "p99": 78376.6700000001, + "max": 78376.6700000001, + "stddev": 4475.270397690786, + "rme": 3.603956626175367, "fingerprint": "f79908712fee1795" }, { @@ -323,15 +367,15 @@ "samples": 15, "iterations": 5000, "unitsPerIteration": 1, - "nsPerUnit": 4534.0416000000005, - "unitsPerSec": 220553.77700989772, - "min": 4234.516600000006, - "p50": 4534.0416000000005, - "p75": 4883.783200000016, - "p99": 5362.558200000012, - "max": 5362.558200000012, - "stddev": 363.43167863921394, - "rme": 4.30177805537626, + "nsPerUnit": 4510.558399999991, + "unitsPerSec": 221702.0402618004, + "min": 4273.983200000021, + "p50": 4510.558399999991, + "p75": 4876.041600000008, + "p99": 5252.82500000003, + "max": 5252.82500000003, + "stddev": 312.13144337912007, + "rme": 3.730396422644074, "fingerprint": "b3be40641fa0fa67" }, { @@ -342,15 +386,15 @@ "samples": 15, "iterations": 2000, "unitsPerIteration": 1, - "nsPerUnit": 43523.56250000002, - "unitsPerSec": 22976.06038108667, - "min": 41827.43750000009, - "p50": 43523.56250000002, - "p75": 43912.27099999992, - "p99": 46297.08349999988, - "max": 46297.08349999988, - "stddev": 1210.8764962472496, - "rme": 1.545222099107133, + "nsPerUnit": 43124.95799999988, + "unitsPerSec": 23188.42838061437, + "min": 41183.91650000012, + "p50": 43124.95799999988, + "p75": 44028.29150000002, + "p99": 45639.12499999993, + "max": 45639.12499999993, + "stddev": 1392.417209289892, + "rme": 1.7884050646860865, "fingerprint": "e93558ba4ce1c999" }, { @@ -361,15 +405,15 @@ "samples": 15, "iterations": 5000, "unitsPerIteration": 1, - "nsPerUnit": 4523.858400000063, - "unitsPerSec": 221050.24330557874, - "min": 4355.7081999999355, - "p50": 4523.858400000063, - "p75": 4927.275000000009, - "p99": 5648.599999999988, - "max": 5648.599999999988, - "stddev": 383.6289887383652, - "rme": 4.476030826617185, + "nsPerUnit": 4871.64159999993, + "unitsPerSec": 205269.61589292905, + "min": 4238.100000000031, + "p50": 4871.64159999993, + "p75": 5022.249999999985, + "p99": 5941.716600000018, + "max": 5941.716600000018, + "stddev": 469.7630280770069, + "rme": 5.390520367445378, "fingerprint": "664b3dfce511ba41" }, { @@ -380,15 +424,15 @@ "samples": 15, "iterations": 2000, "unitsPerIteration": 1, - "nsPerUnit": 45724.91649999984, - "unitsPerSec": 21869.91418562795, - "min": 43947.354000000356, - "p50": 45724.91649999984, - "p75": 46698.35400000011, - "p99": 47641.37500000016, - "max": 47641.37500000016, - "stddev": 1031.3073991383114, - "rme": 1.2442569810434325, + "nsPerUnit": 46230.60399999986, + "unitsPerSec": 21630.69294963144, + "min": 44080.895500000224, + "p50": 46230.60399999986, + "p75": 46971.72950000004, + "p99": 47454.125000000204, + "max": 47454.125000000204, + "stddev": 1039.8021251118892, + "rme": 1.2475942457678064, "fingerprint": "94bccde4ebba62f9" }, { @@ -399,15 +443,15 @@ "samples": 15, "iterations": 200, "unitsPerIteration": 1, - "nsPerUnit": 9136.250000001382, - "unitsPerSec": 109454.09768776562, - "min": 8702.079999998205, - "p50": 9136.250000001382, - "p75": 10607.915000000503, - "p99": 19309.579999999187, - "max": 19309.579999999187, - "stddev": 2670.3056540954876, - "rme": 14.695184225706, + "nsPerUnit": 10285.625000001346, + "unitsPerSec": 97223.0661724367, + "min": 9471.454999998059, + "p50": 10285.625000001346, + "p75": 10476.039999998648, + "p99": 18813.545000002705, + "max": 18813.545000002705, + "stddev": 2245.976902891809, + "rme": 11.551695570668803, "fingerprint": "35f3d1be84f65d55" }, { @@ -418,15 +462,15 @@ "samples": 10, "iterations": 1, "unitsPerIteration": 500, - "nsPerUnit": 1338.6659999996482, - "unitsPerSec": 747012.3242095212, - "min": 1265.49999999952, - "p50": 1338.6659999996482, - "p75": 1418.6659999995752, - "p99": 1808.9999999992872, - "max": 1808.9999999992872, - "stddev": 158.9621435643438, - "rme": 8.165657681878976, + "nsPerUnit": 975.5000000004657, + "unitsPerSec": 1025115.3254736265, + "min": 879.4999999990978, + "p50": 975.5000000004657, + "p75": 1000.3340000002936, + "p99": 5721.750000000611, + "max": 5721.750000000611, + "stddev": 1506.948097245212, + "rme": 75.10840092786468, "fingerprint": "da414bed7beb2751", "extra": { "invocationsPerTick": 1, @@ -441,15 +485,15 @@ "samples": 25, "iterations": 1, "unitsPerIteration": 200, - "nsPerUnit": 6200.210000001789, - "unitsPerSec": 161284.85970631824, - "min": 5013.32999999704, - "p50": 6200.210000001789, - "p75": 6837.709999999788, - "p99": 14782.289999998284, - "max": 14782.289999998284, - "stddev": 1897.3336533691825, - "rme": 11.7426381325029, + "nsPerUnit": 5729.375000000801, + "unitsPerSec": 174539.10766878762, + "min": 4822.289999997338, + "p50": 5729.375000000801, + "p75": 6307.709999996405, + "p99": 13747.49999999949, + "max": 13747.49999999949, + "stddev": 1887.2873699268164, + "rme": 12.433723565597846, "fingerprint": "492e45c50f28b829" }, { @@ -460,18 +504,18 @@ "samples": 10, "iterations": 1, "unitsPerIteration": 500, - "nsPerUnit": 7109.50000000048, - "unitsPerSec": 140656.8675715497, - "min": 6388.417999998637, - "p50": 7109.50000000048, - "p75": 8348.7499999992, - "p99": 13643.500000000131, - "max": 13643.500000000131, - "stddev": 2694.254498966069, - "rme": 22.66786178912699, + "nsPerUnit": 5863.333999999668, + "unitsPerSec": 170551.43029546956, + "min": 5251.749999999447, + "p50": 5863.333999999668, + "p75": 6258.584000001065, + "p99": 11791.416000000027, + "max": 11791.416000000027, + "stddev": 1902.3337222231555, + "rme": 20.866310891224202, "fingerprint": "a55a49f02f489524", "extra": { - "echoNsPerFrame": 2702.3339999996097, + "echoNsPerFrame": 1715.5840000013995, "echoes": 500 } }, @@ -483,15 +527,15 @@ "samples": 10, "iterations": 1, "unitsPerIteration": 500, - "nsPerUnit": 5777.831999999761, - "unitsPerSec": 173075.29883181813, - "min": 5095.416000000114, - "p50": 5777.831999999761, - "p75": 6101.915999999619, - "p99": 9648.334000001341, - "max": 9648.334000001341, - "stddev": 1305.8687059115032, - "rme": 15.283778194640554, + "nsPerUnit": 5277.915999999094, + "unitsPerSec": 189468.72212444677, + "min": 4852.831999998671, + "p50": 5277.915999999094, + "p75": 5351.167999999234, + "p99": 7672.00000000048, + "max": 7672.00000000048, + "stddev": 814.205240344307, + "rme": 10.733945820379597, "fingerprint": "bfc7cdd6eb4a4307", "extra": { "deliveredPerTick": 1 @@ -505,15 +549,15 @@ "samples": 15, "iterations": 500, "unitsPerIteration": 202, - "nsPerUnit": 9.364693069307965, - "unitsPerSec": 106784065.70284939, - "min": 9.061881188118956, - "p50": 9.364693069307965, - "p75": 9.462455445539643, - "p99": 9.862623762372923, - "max": 9.862623762372923, - "stddev": 0.23546123904046737, - "rme": 1.39809192303038, + "nsPerUnit": 9.618396039603804, + "unitsPerSec": 103967438.63347839, + "min": 9.40388118812221, + "p50": 9.618396039603804, + "p75": 9.738029702975387, + "p99": 10.214108910887344, + "max": 10.214108910887344, + "stddev": 0.18542946673529087, + "rme": 1.0619241054530255, "fingerprint": "7c9a76dd107ba450" }, { @@ -524,15 +568,15 @@ "samples": 15, "iterations": 500, "unitsPerIteration": 202, - "nsPerUnit": 9.249584158412587, - "unitsPerSec": 108112968.41820616, - "min": 9.10272277228119, - "p50": 9.249584158412587, - "p75": 9.49174257425286, - "p99": 9.951732673273161, - "max": 9.951732673273161, - "stddev": 0.23106639953695585, - "rme": 1.3681762933832087, + "nsPerUnit": 9.184000000000243, + "unitsPerSec": 108885017.42159991, + "min": 8.872940594058193, + "p50": 9.184000000000243, + "p75": 9.39644554455299, + "p99": 9.654287128715525, + "max": 9.654287128715525, + "stddev": 0.22374579100176678, + "rme": 1.3418751804331506, "fingerprint": "280bace6f155be2c" }, { @@ -543,15 +587,15 @@ "samples": 15, "iterations": 500, "unitsPerIteration": 100, - "nsPerUnit": 9.776660000006814, - "unitsPerSec": 102284420.24160634, - "min": 9.6349999999984, - "p50": 9.776660000006814, - "p75": 10.004160000007687, - "p99": 14.390000000003054, - "max": 14.390000000003054, - "stddev": 1.3108665204968772, - "rme": 7.059490221681017, + "nsPerUnit": 9.963320000006206, + "unitsPerSec": 100368150.3755151, + "min": 9.517500000001746, + "p50": 9.963320000006206, + "p75": 10.328339999996388, + "p99": 13.86583999999857, + "max": 13.86583999999857, + "stddev": 1.043505869484499, + "rme": 5.640895315279739, "fingerprint": "b69071333d297bb1" }, { @@ -562,15 +606,15 @@ "samples": 15, "iterations": 5000, "unitsPerIteration": 100, - "nsPerUnit": 140.67883400000028, - "unitsPerSec": 7108389.880456345, - "min": 135.55808399999842, - "p50": 140.67883400000028, - "p75": 143.3590839999997, - "p99": 152.5570000000007, - "max": 152.5570000000007, - "stddev": 4.454989542911766, - "rme": 1.7438898403047238, + "nsPerUnit": 139.9572500000013, + "unitsPerSec": 7145038.931530812, + "min": 129.47833399999945, + "p50": 139.9572500000013, + "p75": 143.1670840000006, + "p99": 147.52016600000024, + "max": 147.52016600000024, + "stddev": 5.146012967446897, + "rme": 2.0459295325530418, "fingerprint": "676d88d0bfb4ad41" }, { @@ -581,15 +625,15 @@ "samples": 15, "iterations": 5000, "unitsPerIteration": 100, - "nsPerUnit": 120.1755000000012, - "unitsPerSec": 8321163.63152215, - "min": 118.58216600000014, - "p50": 120.1755000000012, - "p75": 122.0997499999994, - "p99": 123.5669999999991, - "max": 123.5669999999991, - "stddev": 1.652281308012248, - "rme": 0.7583380445626672, + "nsPerUnit": 123.95249999999942, + "unitsPerSec": 8067606.5428289445, + "min": 115.47616600000038, + "p50": 123.95249999999942, + "p75": 125.75475000000006, + "p99": 128.25191599999926, + "max": 128.25191599999926, + "stddev": 3.834652173251868, + "rme": 1.7263323514357036, "fingerprint": "b337cd1a8dc7167c" }, { @@ -600,15 +644,15 @@ "samples": 15, "iterations": 5000, "unitsPerIteration": 100, - "nsPerUnit": 149.39058400000067, - "unitsPerSec": 6693862.311964692, - "min": 147.78999999999724, - "p50": 149.39058400000067, - "p75": 150.11708400000134, - "p99": 151.05033399999957, - "max": 151.05033399999957, - "stddev": 1.0079738821222621, - "rme": 0.3738669307485422, + "nsPerUnit": 116.57066599999962, + "unitsPerSec": 8578487.490154712, + "min": 115.17083400000047, + "p50": 116.57066599999962, + "p75": 118.41291600000113, + "p99": 121.72541799999817, + "max": 121.72541799999817, + "stddev": 2.1596116663171596, + "rme": 1.0196535490689265, "fingerprint": "20f135a5bea53308" }, { @@ -619,15 +663,15 @@ "samples": 10, "iterations": 50, "unitsPerIteration": 1, - "nsPerUnit": 108374.99999997817, - "unitsPerSec": 9227.220299886518, - "min": 95580.00000000902, - "p50": 108374.99999997817, - "p75": 152255.00000000466, - "p99": 154151.6799999954, - "max": 154151.6799999954, - "stddev": 24932.519962439186, - "rme": 14.751150268799465, + "nsPerUnit": 106483.34000001341, + "unitsPerSec": 9391.140435676361, + "min": 89777.49999998196, + "p50": 106483.34000001341, + "p75": 123148.33999997063, + "p99": 145069.17999999132, + "max": 145069.17999999132, + "stddev": 18135.825686013, + "rme": 11.733218689255352, "fingerprint": "a3c403e8cf3d2362" }, { @@ -638,15 +682,15 @@ "samples": 10, "iterations": 10, "unitsPerIteration": 100, - "nsPerUnit": 2744.6250000011787, - "unitsPerSec": 364348.49933946185, - "min": 2374.1659999996045, - "p50": 2744.6250000011787, - "p75": 2849.1249999988213, - "p99": 5954.04199999939, - "max": 5954.04199999939, - "stddev": 1178.4850727028777, - "rme": 26.639875240350523, + "nsPerUnit": 2706.3749999997526, + "unitsPerSec": 369497.94466771657, + "min": 2576.457999999547, + "p50": 2706.3749999997526, + "p75": 2830.9579999986454, + "p99": 4041.3750000006985, + "max": 4041.3750000006985, + "stddev": 425.43298316733257, + "rme": 10.642984033783282, "fingerprint": "cdbb3097d2620387" }, { @@ -657,15 +701,15 @@ "samples": 5, "iterations": 1, "unitsPerIteration": 100, - "nsPerUnit": 309440.0000000132, - "unitsPerSec": 3231.6442605996554, - "min": 296660.40999998583, - "p50": 309440.0000000132, - "p75": 310119.5899999948, - "p99": 316198.7500000032, - "max": 316198.7500000032, - "stddev": 8424.92974826301, - "rme": 3.417108902473781, + "nsPerUnit": 313062.0799999997, + "unitsPerSec": 3194.2546347357074, + "min": 309208.3400000047, + "p50": 313062.0799999997, + "p75": 313930.41999999696, + "p99": 319054.16999999946, + "max": 319054.16999999946, + "stddev": 3554.5408101242842, + "rme": 1.4073347759198678, "fingerprint": "b5ea99d41379703a", "extra": { "maxInFlight": 100, @@ -680,15 +724,15 @@ "samples": 10, "iterations": 500, "unitsPerIteration": 1, - "nsPerUnit": 53.832000001420965, - "unitsPerSec": 18576311.48710068, - "min": 46.16799999712384, - "p50": 53.832000001420965, - "p75": 77.75000000037835, - "p99": 191.8340000011085, - "max": 191.8340000011085, - "stddev": 54.687356864877586, - "rme": 46.904389067766864, + "nsPerUnit": 51.33399999976973, + "unitsPerSec": 19480266.490132965, + "min": 47.50000000058208, + "p50": 51.33399999976973, + "p75": 127.16599999839673, + "p99": 169.0840000010212, + "max": 169.0840000010212, + "stddev": 49.16755168306848, + "rme": 41.617051152670776, "fingerprint": "9ff5c5438ad91c58" }, { @@ -699,15 +743,15 @@ "samples": 10, "iterations": 50, "unitsPerIteration": 1, - "nsPerUnit": 108015.00000001397, - "unitsPerSec": 9257.97342961506, - "min": 84652.50000001106, - "p50": 108015.00000001397, - "p75": 118660.00000001804, - "p99": 129098.32000001188, - "max": 129098.32000001188, - "stddev": 15121.620477012259, - "rme": 10.147003353355622, + "nsPerUnit": 99522.50000002095, + "unitsPerSec": 10047.979100201355, + "min": 86630.00000000466, + "p50": 99522.50000002095, + "p75": 118291.6600000317, + "p99": 128847.49999997439, + "max": 128847.49999997439, + "stddev": 13275.861529836855, + "rme": 9.029838340372294, "fingerprint": "7f94b9715b2feae5" }, { @@ -718,15 +762,15 @@ "samples": 15, "iterations": 200, "unitsPerIteration": 1, - "nsPerUnit": 4823.544999999285, - "unitsPerSec": 207316.40318482532, - "min": 4450.834999997824, - "p50": 4823.544999999285, - "p75": 5703.334999998333, - "p99": 11530.624999995782, - "max": 11530.624999995782, - "stddev": 1762.887503943029, - "rme": 17.79264409146672, + "nsPerUnit": 4753.330000003189, + "unitsPerSec": 210378.82915752308, + "min": 4251.665000001594, + "p50": 4753.330000003189, + "p75": 4894.3750000034925, + "p99": 10858.749999997599, + "max": 10858.749999997599, + "stddev": 1618.4005028657339, + "rme": 17.49713261618444, "fingerprint": "b9c386a54222793e" }, { @@ -737,15 +781,15 @@ "samples": 25, "iterations": 200, "unitsPerIteration": 1, - "nsPerUnit": 960.8349999962229, - "unitsPerSec": 1040761.4210597356, - "min": 865.0000000034197, - "p50": 960.8349999962229, - "p75": 1110.2050000044983, - "p99": 1717.7100000026257, - "max": 1717.7100000026257, - "stddev": 245.79811265733707, - "rme": 9.480316825580308, + "nsPerUnit": 1174.1650000021764, + "unitsPerSec": 851669.0584356938, + "min": 943.1250000034197, + "p50": 1174.1650000021764, + "p75": 1323.5400000030495, + "p99": 1951.8750000042928, + "max": 1951.8750000042928, + "stddev": 232.50735041477947, + "rme": 7.916179227398426, "fingerprint": "933240fc2231b44f" }, { @@ -756,15 +800,15 @@ "samples": 25, "iterations": 200, "unitsPerIteration": 1, - "nsPerUnit": 1354.17000000416, - "unitsPerSec": 738459.720712265, - "min": 1198.1250000008004, - "p50": 1354.17000000416, - "p75": 1654.1649999999208, - "p99": 2711.039999994682, - "max": 2711.039999994682, - "stddev": 363.2171470033063, - "rme": 9.88845146101705, + "nsPerUnit": 1591.0449999955745, + "unitsPerSec": 628517.7352009412, + "min": 1241.4600000010978, + "p50": 1591.0449999955745, + "p75": 1665.419999999358, + "p99": 9175.20500000137, + "max": 9175.20500000137, + "stddev": 1537.6460887179815, + "rme": 34.43280428096326, "fingerprint": "801510758c8d4211" }, { @@ -775,15 +819,15 @@ "samples": 25, "iterations": 200, "unitsPerIteration": 1, - "nsPerUnit": 2708.7500000016007, - "unitsPerSec": 369173.9732346688, - "min": 2428.954999995767, - "p50": 2708.7500000016007, - "p75": 3280.2100000026257, - "p99": 11275.210000003428, - "max": 11275.210000003428, - "stddev": 1742.5865610982764, - "rme": 22.182617288967, + "nsPerUnit": 2345.6249999981083, + "unitsPerSec": 426325.6061820651, + "min": 2117.500000003929, + "p50": 2345.6249999981083, + "p75": 2397.915000001376, + "p99": 2831.6699999959383, + "max": 2831.6699999959383, + "stddev": 154.1771954318898, + "rme": 2.687806354762073, "fingerprint": "b6263c6c4e3cf548" }, { @@ -794,15 +838,15 @@ "samples": 10, "iterations": 1, "unitsPerIteration": 500, - "nsPerUnit": 7438.249999999244, - "unitsPerSec": 134440.2245151886, - "min": 6718.250000001717, - "p50": 7438.249999999244, - "p75": 7671.99999999866, - "p99": 11050.834000001487, - "max": 11050.834000001487, - "stddev": 1192.2875398545914, - "rme": 10.97995931369781, + "nsPerUnit": 7130.916000001888, + "unitsPerSec": 140234.43832457642, + "min": 6821.41799999954, + "p50": 7130.916000001888, + "p75": 7364.08400000073, + "p99": 8793.000000001484, + "max": 8793.000000001484, + "stddev": 580.4994065682826, + "rme": 5.687785880640085, "fingerprint": "d20b08c4484d84d6", "extra": { "deliveredPerTick": 1 @@ -816,15 +860,15 @@ "samples": 10, "iterations": 1, "unitsPerIteration": 500, - "nsPerUnit": 4878.500000002532, - "unitsPerSec": 204981.03925376263, - "min": 4541.08400000041, - "p50": 4878.500000002532, - "p75": 5122.667999999976, - "p99": 7619.750000001659, - "max": 7619.750000001659, - "stddev": 912.253158591081, - "rme": 12.70833503648206, + "nsPerUnit": 4110.499999998865, + "unitsPerSec": 243279.40639831556, + "min": 4068.9999999995057, + "p50": 4110.499999998865, + "p75": 4236.500000002707, + "p99": 4266.415999998571, + "max": 4266.415999998571, + "stddev": 79.04000018462692, + "rme": 1.363626407549816, "fingerprint": "50d26b6e8352c5ca", "extra": { "deliveredPerTick": 1 @@ -838,15 +882,15 @@ "samples": 10, "iterations": 1, "unitsPerIteration": 500, - "nsPerUnit": 1301.4159999984258, - "unitsPerSec": 768393.8110498177, - "min": 1232.7499999992142, - "p50": 1301.4159999984258, - "p75": 1361.165999998775, - "p99": 1372.166000001016, - "max": 1372.166000001016, - "stddev": 47.072295463296435, - "rme": 2.5594251411808657, + "nsPerUnit": 762.166000000434, + "unitsPerSec": 1312050.130810651, + "min": 751.749999999447, + "p50": 762.166000000434, + "p75": 801.500000001397, + "p99": 852.3339999992459, + "max": 852.3339999992459, + "stddev": 31.847483023635085, + "rme": 2.9112446745645304, "fingerprint": "9ec3492b4bbaf905", "extra": { "deliveredPerTick": 1 @@ -860,15 +904,15 @@ "samples": 10, "iterations": 1, "unitsPerIteration": 500, - "nsPerUnit": 7516.4999999979045, - "unitsPerSec": 133040.64391675364, - "min": 6748.834000001807, - "p50": 7516.4999999979045, - "p75": 7665.750000000116, - "p99": 10238.084000000526, - "max": 10238.084000000526, - "stddev": 981.1233506382131, - "rme": 9.14454005744354, + "nsPerUnit": 6685.834000003524, + "unitsPerSec": 149569.97137522005, + "min": 6572.749999999359, + "p50": 6685.834000003524, + "p75": 6845.58399999878, + "p99": 7150.250000002416, + "max": 7150.250000002416, + "stddev": 180.46928317765796, + "rme": 1.9074947685091475, "fingerprint": "14c24cd88274c2dd", "extra": { "deliveredPerTick": 1 @@ -880,18 +924,18 @@ "description": "Cost of establishing the three account subscriptions (clearinghouseState + spotState + webData3) for one user — the calls a feed makes at session start and on every reconnect", "unit": "subscription", "samples": 25, - "iterations": 1, + "iterations": 20, "unitsPerIteration": 3, - "nsPerUnit": 14458.333333095652, - "unitsPerSec": 69164.26513081999, - "min": 11235.999999674581, - "p50": 14458.333333095652, - "p75": 16416.666666676367, - "p99": 33930.33333365262, - "max": 33930.33333365262, - "stddev": 4974.277832924477, - "rme": 12.878367672687286, - "fingerprint": "32cc935cab71f6c2" + "nsPerUnit": 8647.2166666681, + "unitsPerSec": 115644.14753878425, + "min": 7531.250000010914, + "p50": 8647.2166666681, + "p75": 10462.500000024496, + "p99": 35936.79999997524, + "max": 35936.79999997524, + "stddev": 5703.23732591587, + "rme": 22.822777251541595, + "fingerprint": "616c19e978774eec" }, { "name": "signing/sign_l1_action_order_1_wasm", @@ -901,15 +945,15 @@ "samples": 10, "iterations": 50, "unitsPerIteration": 1, - "nsPerUnit": 60912.5000000131, - "unitsPerSec": 16416.99158628828, - "min": 57422.499999993306, - "p50": 60912.5000000131, - "p75": 62724.99999999126, - "p99": 63354.160000017146, - "max": 63354.160000017146, - "stddev": 1969.7820877379763, - "rme": 2.309674035901903, + "nsPerUnit": 59738.31999999675, + "unitsPerSec": 16739.673964719037, + "min": 58986.66000000957, + "p50": 59738.31999999675, + "p75": 60158.340000016324, + "p99": 61902.49999999651, + "max": 61902.49999999651, + "stddev": 786.5883351624835, + "rme": 0.9388426172356723, "fingerprint": "8fb87a5afd711771" }, { @@ -920,15 +964,15 @@ "samples": 15, "iterations": 200, "unitsPerIteration": 1, - "nsPerUnit": 2910.0000000016735, - "unitsPerSec": 343642.61168365116, - "min": 2813.125000002401, - "p50": 2910.0000000016735, - "p75": 2994.584999996732, - "p99": 3281.25, - "max": 3281.25, - "stddev": 129.98729388610943, - "rme": 2.4508746813869045, + "nsPerUnit": 3031.875000006039, + "unitsPerSec": 329828.9012568157, + "min": 2921.249999999418, + "p50": 3031.875000006039, + "p75": 3119.3749999965803, + "p99": 13826.460000000223, + "max": 13826.460000000223, + "stddev": 2784.5595084421916, + "rme": 40.95279006616879, "fingerprint": "d6f28468a96f8253" }, { @@ -939,15 +983,15 @@ "samples": 15, "iterations": 2000, "unitsPerIteration": 1, - "nsPerUnit": 764.4169999994118, - "unitsPerSec": 1308186.5003012354, - "min": 714.125000000422, - "p50": 764.4169999994118, - "p75": 773.50000000024, - "p99": 1848.9584999997533, - "max": 1848.9584999997533, - "stddev": 283.4271115341867, - "rme": 18.959535354559005, + "nsPerUnit": 744.2499999997381, + "unitsPerSec": 1343634.53140793, + "min": 689.8545000003651, + "p50": 744.2499999997381, + "p75": 761.6459999999278, + "p99": 829.2915000001813, + "max": 829.2915000001813, + "stddev": 32.427532091491955, + "rme": 2.3915250984663343, "fingerprint": "6f8c9d3fb30f33bb" }, { @@ -958,15 +1002,15 @@ "samples": 15, "iterations": 100, "unitsPerIteration": 100, - "nsPerUnit": 249.1250000000946, - "unitsPerSec": 4014049.172100834, - "min": 232.61250000014115, - "p50": 249.1250000000946, - "p75": 252.0915999999488, - "p99": 286.9957999999315, - "max": 286.9957999999315, - "stddev": 12.823740296962635, - "rme": 2.8572783425183594, + "nsPerUnit": 246.84579999993733, + "unitsPerSec": 4051112.070775577, + "min": 241.52499999981956, + "p50": 246.84579999993733, + "p75": 251.95000000003347, + "p99": 258.5750000000189, + "max": 258.5750000000189, + "stddev": 4.933888910249693, + "rme": 1.1034513655677065, "fingerprint": "00eb2dd7c8a228b1" }, { @@ -977,15 +1021,15 @@ "samples": 15, "iterations": 5000, "unitsPerIteration": 1, - "nsPerUnit": 913.0582000001597, - "unitsPerSec": 1095220.4361122053, - "min": 847.399999999834, - "p50": 913.0582000001597, - "p75": 932.9583999999159, - "p99": 1285.533199999918, - "max": 1285.533199999918, - "stddev": 100.98934347170832, - "rme": 5.971451159971633, + "nsPerUnit": 913.4084000001166, + "unitsPerSec": 1094800.5295329804, + "min": 867.7833999998256, + "p50": 913.4084000001166, + "p75": 958.6667999999918, + "p99": 1296.5583999997762, + "max": 1296.5583999997762, + "stddev": 102.719910193556, + "rme": 6.02767993437828, "fingerprint": "d2b32bb77a5788e5" }, { @@ -996,16 +1040,73 @@ "samples": 15, "iterations": 5000, "unitsPerIteration": 1, - "nsPerUnit": 1032.574999999997, - "unitsPerSec": 968452.6547708426, - "min": 979.0918000002422, - "p50": 1032.574999999997, - "p75": 1063.1915999998455, - "p99": 1372.0915999998397, - "max": 1372.0915999998397, - "stddev": 91.91567553022611, - "rme": 4.814397855156273, + "nsPerUnit": 996.8000000000757, + "unitsPerSec": 1003210.272873118, + "min": 969.9250000001484, + "p50": 996.8000000000757, + "p75": 1035.358400000041, + "p99": 1402.2499999999127, + "max": 1402.2499999999127, + "stddev": 106.78060420738282, + "rme": 5.718594849467097, "fingerprint": "afdcbc4159893d3c" + }, + { + "name": "signing/eip712_user_signed_digest", + "group": "signing", + "description": "createUserSignedDigestBytes(): hand-rolled EIP-712 digest of an approveAgent action", + "unit": "digest", + "samples": 15, + "iterations": 5000, + "unitsPerIteration": 1, + "nsPerUnit": 2534.4584000002214, + "unitsPerSec": 394561.6152152715, + "min": 2417.7834000001894, + "p50": 2534.4584000002214, + "p75": 3045.208199999979, + "p99": 4165.166600000157, + "max": 4165.166600000157, + "stddev": 474.900124970329, + "rme": 9.466611660713596, + "fingerprint": "813d5b6bb6b19c5a" + }, + { + "name": "signing/eip712_user_signed_digest_viem", + "group": "signing", + "description": "viem hashTypedData() over the same approveAgent action (the oracle the fast digest replaces)", + "unit": "digest", + "samples": 15, + "iterations": 2000, + "unitsPerIteration": 1, + "nsPerUnit": 55085.68749999995, + "unitsPerSec": 18153.535798205314, + "min": 52839.58299999995, + "p50": 55085.68749999995, + "p75": 56095.062499999585, + "p99": 58988.16650000026, + "max": 58988.16650000026, + "stddev": 1502.316332482461, + "rme": 1.4974101820852574, + "fingerprint": "cd1e25d188f68065" + }, + { + "name": "signing/approve_agent_e2e_no_ecdsa", + "group": "signing", + "description": "ExchangeClient.approveAgent() with a stub wallet (fixed signature): SDK shell overhead without secp256k1", + "unit": "request", + "samples": 15, + "iterations": 200, + "unitsPerIteration": 1, + "nsPerUnit": 5223.539999997229, + "unitsPerSec": 191441.0533853537, + "min": 4994.584999994913, + "p50": 5223.539999997229, + "p75": 5776.459999997314, + "p99": 14973.544999993464, + "max": 14973.544999993464, + "stddev": 2499.642244388354, + "rme": 22.978807378802923, + "fingerprint": "eeb4aaae24fa3d9d" } ] } diff --git a/tests/perf/scenarios/signing.ts b/tests/perf/scenarios/signing.ts index 5039f56b..d6545ef9 100644 --- a/tests/perf/scenarios/signing.ts +++ b/tests/perf/scenarios/signing.ts @@ -591,3 +591,107 @@ if (hashWasmAvailable) { }, }); } + +// --- EIP-712 user-signed digest ------------------------------------------------- +// User-signed actions (approveAgent, usdSend, …) share the fixed `HyperliquidSignTransaction` +// domain with the multi-sig outer but vary in shape, so their digests are hand-rolled from a +// plan compiled once per `types` object (`createUserSignedDigestBytes`, domain separator shared +// with the multi-sig cache) instead of viem's generic `hashTypedData`. The pair is measured side +// by side so the saving stays visible in the report; byte-equality is pinned by +// `tests/signing/userSignedDigest.test.ts`, not here. (The import declaration is hoisted; keeping +// it next to the scenarios keeps this file append-only.) + +import { createUserSignedDigestBytes } from "../../../src/signing/_fastDigest.ts"; + +/** An approveAgent action exactly as `executeUserSignedAction` signs it (testnet). */ +const APPROVE_AGENT_ACTION = { + type: "approveAgent", + signatureChainId: "0x66eee", + hyperliquidChain: "Testnet", + agentAddress: "0x0000000000000000000000000000000000000001", + agentName: "Agent", + nonce: NONCE, +} as const; + +/** The typed-data envelope viem's `hashTypedData` is benchmarked on (same action). */ +const APPROVE_AGENT_TYPED_DATA = { + domain: { + name: "HyperliquidSignTransaction", + version: "1", + chainId: 0x66eee, + verifyingContract: "0x0000000000000000000000000000000000000000", + }, + types: { + EIP712Domain: [ + { name: "name", type: "string" }, + { name: "version", type: "string" }, + { name: "chainId", type: "uint256" }, + { name: "verifyingContract", type: "address" }, + ], + "HyperliquidTransaction:ApproveAgent": [ + { name: "hyperliquidChain", type: "string" }, + { name: "agentAddress", type: "address" }, + { name: "agentName", type: "string" }, + { name: "nonce", type: "uint64" }, + ], + }, + primaryType: "HyperliquidTransaction:ApproveAgent", + message: { + hyperliquidChain: "Testnet", + agentAddress: "0x0000000000000000000000000000000000000001", + agentName: "Agent", + nonce: NONCE, + }, +} as const; + +scenario({ + name: "signing/eip712_user_signed_digest", + group: "signing", + description: "createUserSignedDigestBytes(): hand-rolled EIP-712 digest of an approveAgent action", + unit: "digest", + iterations: 5000, + run: () => { + createUserSignedDigestBytes(APPROVE_AGENT_ACTION, ApproveAgentTypes, "0x66eee"); + }, +}); + +scenario({ + name: "signing/eip712_user_signed_digest_viem", + group: "signing", + description: "viem hashTypedData() over the same approveAgent action (the oracle the fast digest replaces)", + unit: "digest", + iterations: 2000, + run: () => { + hashTypedData(APPROVE_AGENT_TYPED_DATA as never); + }, +}); + +// --- End-to-end user-signed action without ECDSA -------------------------------- +// Counterpart of `signing/order_e2e_no_ecdsa` for the user-signed path: the stub wallet's +// raw-digest `sign` takes the fast digest path, so validation, nonce issuance, the hand-rolled +// digest, and dispatch are what remains on the clock. Grounds the user-signed digest work end to +// end through `executeUserSignedAction`; the per-digest view is the pair above. + +scenario({ + name: "signing/approve_agent_e2e_no_ecdsa", + group: "signing", + description: + "ExchangeClient.approveAgent() with a stub wallet (fixed signature): SDK shell overhead without secp256k1", + unit: "request", + iterations: 200, + samples: 15, + setup: () => { + const transport = new MockExchangeTransport(0); + const client = new ExchangeClient({ + transport, + wallet: digestStubWallet("0x1111111111111111111111111111111111111111"), + }); + return { client }; + }, + run: async ({ client }: { client: ExchangeClient }) => { + await client.approveAgent({ + agentAddress: "0x0000000000000000000000000000000000000001", + agentName: "Agent", + }); + }, +}); diff --git a/tests/perf/scenarios/user_account_channels.ts b/tests/perf/scenarios/user_account_channels.ts index 904a8600..1ff9a4e2 100644 --- a/tests/perf/scenarios/user_account_channels.ts +++ b/tests/perf/scenarios/user_account_channels.ts @@ -225,7 +225,7 @@ scenario({ "for one user — the calls a feed makes at session start and on every reconnect", unit: "subscription", unitsPerIteration: 3, - iterations: 1, + iterations: 20, samples: 25, warmupSamples: 3, setup: () => { diff --git a/tests/signing/fastWallet.test.ts b/tests/signing/fastWallet.test.ts index 5fc9c185..1d3960c3 100644 --- a/tests/signing/fastWallet.test.ts +++ b/tests/signing/fastWallet.test.ts @@ -300,10 +300,29 @@ describe("createFastLocalWallet() fallback", () => { const wrongKeyFactory = (): ReturnType => privateKeyToAccount(PRIVATE_KEYS[1]); const fast = await createFastLocalWallet(PRIVATE_KEYS[0], { privateKeyToAccount: wrongKeyFactory }); - // The wallet itself is fine; only the typed-data delegate is wrong, so it fails there. + // The wallet itself is fine; only the typed-data delegate is wrong, so it fails there. The + // action must reach `signTypedData` to trigger that: approveAgent takes the raw-digest fast + // path now, so this uses types with an array field — a shape the hand-rolled digest does not + // cover, forcing the typed-data fallback where the delegate is resolved and validated. expect(fast.address).toBe(privateKeyToAccount(PRIVATE_KEYS[0]).address); await expect( - signUserSignedAction({ wallet: fast, action: { ...APPROVE_AGENT }, types: ApproveAgentTypes }), + signUserSignedAction({ + wallet: fast, + action: { + type: "custom", + signatureChainId: "0x66eee", + hyperliquidChain: "Testnet", + payloads: ["0x1111111111111111111111111111111111111111111111111111111111111111"], + nonce: NONCE, + }, + types: { + "HyperliquidTransaction:Custom": [ + { name: "hyperliquidChain", type: "string" }, + { name: "payloads", type: "bytes32[]" }, + { name: "nonce", type: "uint64" }, + ], + }, + }), ).rejects.toThrow("must derive the account from the private key it is given"); }); diff --git a/tests/signing/multiSigDigest.test.ts b/tests/signing/multiSigDigest.test.ts index 377c63ba..e2b18f57 100644 --- a/tests/signing/multiSigDigest.test.ts +++ b/tests/signing/multiSigDigest.test.ts @@ -397,7 +397,7 @@ describe("signMultiSigUserSigned() fast path", () => { } }); - test("routes only the inner signature through signTypedData; the outer uses the digest", async () => { + test("routes both the inner and outer signatures through raw digests, never signTypedData", async () => { const account = privateKeyToAccount(PRIVATE_KEYS[0]); const signedDigests: `0x${string}`[] = []; let typedDataCalls = 0; @@ -421,14 +421,48 @@ describe("signMultiSigUserSigned() fast path", () => { types: APPROVE_AGENT_TYPES, }); - // Inner: one typed-data signature (user-signed actions have no digest fast path). + // Inner: one raw-digest signature over exactly viem's ApproveAgent digest with the multi-sig + // fields injected after the first field (see `getMultiSigExtendedTypes` in `_userSigned.ts`). + const innerOracle = hashTypedData({ + domain: { + name: "HyperliquidSignTransaction", + version: "1", + chainId: parseInt(signatureChainId, 16), + verifyingContract: "0x0000000000000000000000000000000000000000", + }, + types: { + EIP712Domain: [ + { name: "name", type: "string" }, + { name: "version", type: "string" }, + { name: "chainId", type: "uint256" }, + { name: "verifyingContract", type: "address" }, + ], + "HyperliquidTransaction:ApproveAgent": [ + { name: "hyperliquidChain", type: "string" }, + { name: "payloadMultiSigUser", type: "address" }, + { name: "outerSigner", type: "address" }, + { name: "agentAddress", type: "address" }, + { name: "agentName", type: "string" }, + { name: "nonce", type: "uint64" }, + ], + }, + primaryType: "HyperliquidTransaction:ApproveAgent", + message: { + hyperliquidChain: "Testnet", + payloadMultiSigUser: MULTI_SIG_USERS[0], + outerSigner: account.address.toLowerCase(), + agentAddress: "0x0000000000000000000000000000000000000001", + agentName: "Agent", + nonce: NONCE, + }, + } as never); // Outer: one raw-digest signature over exactly viem's SendMultiSig digest. const { type: _, ...wrapperWithoutType } = wrapper; const multiSigActionHash = createL1ActionHash({ action: wrapperWithoutType, nonce: NONCE }); - const oracle = hashTypedData( + const outerOracle = hashTypedData( oracleTypedData({ multiSigActionHash, nonce: NONCE, signatureChainId, isTestnet: true }) as never, ); - expect(typedDataCalls).toBe(1); - expect(signedDigests).toEqual([oracle]); + expect(typedDataCalls).toBe(0); + expect(signedDigests).toEqual([innerOracle, outerOracle]); }); }); diff --git a/tests/signing/userSignedDigest.test.ts b/tests/signing/userSignedDigest.test.ts new file mode 100644 index 00000000..2e1ab032 --- /dev/null +++ b/tests/signing/userSignedDigest.test.ts @@ -0,0 +1,540 @@ +/** + * Differential conformance tests for the hand-rolled user-signed digest fast path. + * + * `src/signing/_userSigned.ts` signs user-signed actions through the hand-rolled digest in + * `src/signing/_fastDigest.ts` (`createUserSignedDigestBytes`) whenever the wallet can sign a raw + * digest, and the digest it produces is signed — a single differing byte would authorize a + * payload the user never approved. So viem is kept as the oracle here, in the style of + * `multiSigDigest.test.ts`: the digest must be byte-identical to viem's `hashTypedData` across + * EVERY user-signed action type the package ships, chain IDs, and networks, and the full + * `signUserSignedAction` output must equal the same flow run with the raw-digest capability + * stripped (forcing the signature through viem's `signTypedData`). Shapes the encoder does not + * cover (arrays, nested structs, fixed `bytesN`, checksummed mixed-case addresses, out-of-range + * integers) must yield `undefined`, so the caller falls back to the typed-data path. + * @module + */ + +import { describe, expect, test } from "bun:test"; +import { bytesToHex } from "@noble/hashes/utils.js"; +import { hashTypedData } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; + +import { signUserSignedAction } from "@bloxwap/hyperliquid/signing"; +import { + ApproveAgentTypes, + ApproveBuilderFeeTypes, + CDepositTypes, + CWithdrawTypes, + ConvertToMultiSigUserTypes, + LinkStakingUserTypes, + SendAssetTypes, + SendToEvmWithDataTypes, + SpotSendTypes, + StakingLinkDisableTradingUserTypes, + TokenDelegateTypes, + UsdClassTransferTypes, + UsdSendTypes, + UserDexAbstractionTypes, + UserPortfolioMarginTypes, + UserSetAbstractionTypes, + Withdraw3Types, +} from "@bloxwap/hyperliquid/api/exchange"; +import { SIGN_DIGEST_BYTES } from "../../src/signing/_abstractWallet.ts"; +import { createUserSignedDigestBytes } from "../../src/signing/_fastDigest.ts"; + +// --- Fixtures -------------------------------------------------- + +const PRIVATE_KEY = "0x822e9959e022b78423eb653a62ea0020cd283e71a2a8133a6ff2aeffaf373cff"; + +const NONCE = 1700000000000; + +/** `0x66eee` is the Hyperliquid mainnet default; the rest exercise the per-chain domain separator cache. */ +const SIGNATURE_CHAIN_IDS = ["0x66eee", "0x1", "0xa4b1", "0x539", "0xaa36a7"] as const; + +const USER = "0x1234567890123456789012345678901234567890"; +const DESTINATION = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"; + +type Types = Record; + +/** Every user-signed action type the package ships, with a fully-populated message. */ +const CASES: readonly { label: string; types: Types; message: Record }[] = [ + { + label: "approveAgent", + types: ApproveAgentTypes, + message: { hyperliquidChain: "Mainnet", agentAddress: USER, agentName: "Agent", nonce: NONCE }, + }, + { + label: "approveAgent (unnamed, empty agentName)", + types: ApproveAgentTypes, + message: { hyperliquidChain: "Testnet", agentAddress: USER, agentName: "", nonce: NONCE }, + }, + { + label: "approveBuilderFee", + types: ApproveBuilderFeeTypes, + message: { hyperliquidChain: "Mainnet", maxFeeRate: "0.001%", builder: USER, nonce: NONCE }, + }, + { + label: "cDeposit", + types: CDepositTypes, + message: { hyperliquidChain: "Mainnet", wei: 123456789, nonce: NONCE }, + }, + { + label: "cWithdraw", + types: CWithdrawTypes, + message: { hyperliquidChain: "Testnet", wei: 1, nonce: NONCE }, + }, + { + label: "convertToMultiSigUser", + types: ConvertToMultiSigUserTypes, + message: { hyperliquidChain: "Mainnet", signers: JSON.stringify([USER, DESTINATION]), nonce: NONCE }, + }, + { + label: "linkStakingUser", + types: LinkStakingUserTypes, + message: { hyperliquidChain: "Mainnet", user: USER, isFinalize: true, nonce: NONCE }, + }, + { + label: "linkStakingUser (false bool)", + types: LinkStakingUserTypes, + message: { hyperliquidChain: "Testnet", user: USER, isFinalize: false, nonce: NONCE }, + }, + { + label: "sendAsset", + types: SendAssetTypes, + message: { + hyperliquidChain: "Mainnet", + destination: DESTINATION, + sourceDex: "spot", + destinationDex: "", + token: "PURR:0xeb62eee3685fc4c43992febcd9e75443", + amount: "1.5", + fromSubAccount: "", + nonce: NONCE, + }, + }, + { + label: "sendToEvmWithData (bytes + uint32 fields)", + types: SendToEvmWithDataTypes, + message: { + hyperliquidChain: "Mainnet", + token: "USDC:0xeb62eee3685fc4c43992febcd9e75443", + amount: "10.0", + sourceDex: "", + destinationRecipient: DESTINATION, + addressEncoding: "hex", + destinationChainId: 42161, + gasLimit: 250000, + data: "0x095ea7b30000000000000000000000001234567890123456789012345678901234567890", + nonce: NONCE, + }, + }, + { + label: "sendToEvmWithData (empty bytes)", + types: SendToEvmWithDataTypes, + message: { + hyperliquidChain: "Testnet", + token: "USDC:0xeb62eee3685fc4c43992febcd9e75443", + amount: "0.1", + sourceDex: "spot", + destinationRecipient: DESTINATION, + addressEncoding: "hex", + destinationChainId: 0, + gasLimit: 0, + data: "0x", + nonce: 0, + }, + }, + { + label: "spotSend", + types: SpotSendTypes, + message: { + hyperliquidChain: "Mainnet", + destination: DESTINATION, + token: "PURR:0xeb62eee3685fc4c43992febcd9e75443", + amount: "2.25", + time: NONCE, + }, + }, + { + label: "stakingLinkDisableTradingUser", + types: StakingLinkDisableTradingUserTypes, + message: { hyperliquidChain: "Mainnet", tradingUser: USER, nonce: NONCE }, + }, + { + label: "tokenDelegate", + types: TokenDelegateTypes, + message: { hyperliquidChain: "Mainnet", validator: USER, wei: 9007199254740991, isUndelegate: false, nonce: NONCE }, + }, + { + label: "usdClassTransfer", + types: UsdClassTransferTypes, + message: { hyperliquidChain: "Mainnet", amount: "100.5", toPerp: true, nonce: NONCE }, + }, + { + label: "usdSend", + types: UsdSendTypes, + message: { hyperliquidChain: "Mainnet", destination: DESTINATION, amount: "3.14", time: NONCE }, + }, + { + label: "userDexAbstraction", + types: UserDexAbstractionTypes, + message: { hyperliquidChain: "Mainnet", user: USER, enabled: true, nonce: NONCE }, + }, + { + label: "userPortfolioMargin", + types: UserPortfolioMarginTypes, + message: { hyperliquidChain: "Testnet", user: USER, enabled: false, nonce: NONCE }, + }, + { + label: "userSetAbstraction", + types: UserSetAbstractionTypes, + message: { hyperliquidChain: "Mainnet", user: USER, abstraction: "unifiedAccount", nonce: NONCE }, + }, + { + label: "withdraw3", + types: Withdraw3Types, + message: { hyperliquidChain: "Mainnet", destination: DESTINATION, amount: "42.0", time: NONCE }, + }, + { + label: "bigint and non-ASCII string values", + types: ApproveAgentTypes, + message: { hyperliquidChain: "Mainnet", agentAddress: USER, agentName: "Agent ünïcode ✓", nonce: BigInt(NONCE) }, + }, +]; + +/** The viem typed-data envelope for a case under `signatureChainId`. */ +function oracleTypedData(types: Types, message: Record, signatureChainId: `0x${string}`) { + return { + domain: { + name: "HyperliquidSignTransaction", + version: "1", + chainId: parseInt(signatureChainId, 16), + verifyingContract: "0x0000000000000000000000000000000000000000", + }, + types: { + EIP712Domain: [ + { name: "name", type: "string" }, + { name: "version", type: "string" }, + { name: "chainId", type: "uint256" }, + { name: "verifyingContract", type: "address" }, + ], + ...types, + }, + primaryType: Object.keys(types)[0], + message, + } as const; +} + +// --- Digest conformance ---------------------------------------- + +describe("createUserSignedDigestBytes()", () => { + test("matches viem hashTypedData across every shipped action type x chain IDs", () => { + for (const { label, types, message } of CASES) { + for (const signatureChainId of SIGNATURE_CHAIN_IDS) { + // Extra undeclared keys (as the real action carries: `type`, `signatureChainId`) must be + // ignored, exactly like the message filtering the typed-data path applies. + const action = { type: label, signatureChainId, ...message }; + const oracle = hashTypedData(oracleTypedData(types, message, signatureChainId) as never); + const digest = createUserSignedDigestBytes(action, types, signatureChainId); + expect(digest, `${label} / ${signatureChainId}`).toBeDefined(); + expect(`0x${bytesToHex(digest!)}`, `${label} / ${signatureChainId}`).toBe(oracle); + } + } + }); + + test("keeps the known-good digest (pins typehash, domain separator, and field encoding)", () => { + // Captured from viem `hashTypedData`; a wrong typehash, domain separator, or field word breaks this. + const action = { + type: "approveAgent", + signatureChainId: "0x66eee", + hyperliquidChain: "Mainnet", + agentAddress: USER, + agentName: "Agent", + nonce: NONCE, + }; + const digest = createUserSignedDigestBytes(action, ApproveAgentTypes, "0x66eee"); + expect(digest).toBeDefined(); + expect(`0x${bytesToHex(digest!)}`).toBe("0x3df7ffbed96976200c31604dd7626e6a6da2733a6dad32681027e908d7294117"); + }); + + test("matches viem for the multi-sig-extended types (payloadMultiSigUser/outerSigner injected)", () => { + // The shape `getMultiSigExtendedTypes` in `_userSigned.ts` builds: two address fields + // injected after the primary type's first field. + const extendedTypes = { + "HyperliquidTransaction:ApproveAgent": [ + { name: "hyperliquidChain", type: "string" }, + { name: "payloadMultiSigUser", type: "address" }, + { name: "outerSigner", type: "address" }, + { name: "agentAddress", type: "address" }, + { name: "agentName", type: "string" }, + { name: "nonce", type: "uint64" }, + ], + } as const; + const message = { + hyperliquidChain: "Testnet", + payloadMultiSigUser: USER, + outerSigner: DESTINATION, + agentAddress: USER, + agentName: "Agent", + nonce: NONCE, + }; + const oracle = hashTypedData(oracleTypedData(extendedTypes, message, "0x66eee") as never); + const digest = createUserSignedDigestBytes({ ...message, type: "approveAgent" }, extendedTypes, "0x66eee"); + expect(digest).toBeDefined(); + expect(`0x${bytesToHex(digest!)}`).toBe(oracle); + }); + + test("accepts a Uint8Array for a dynamic bytes field, like viem", () => { + const types = SendToEvmWithDataTypes; + const dataBytes = new Uint8Array([1, 2, 3, 250]); + const base = CASES.find((c) => c.label === "sendToEvmWithData (bytes + uint32 fields)")!.message; + const message = { ...base, data: dataBytes }; + const oracle = hashTypedData(oracleTypedData(types, message, "0x66eee") as never); + const digest = createUserSignedDigestBytes(message, types, "0x66eee"); + expect(digest).toBeDefined(); + expect(`0x${bytesToHex(digest!)}`).toBe(oracle); + }); +}); + +// --- Fallback (unsupported shapes yield no digest) -------------- + +describe("createUserSignedDigestBytes() fallback", () => { + const ACTION = { + type: "approveAgent", + signatureChainId: "0x66eee" as const, + hyperliquidChain: "Mainnet", + agentAddress: USER, + agentName: "Agent", + nonce: NONCE, + }; + + test("returns undefined for an array field type", () => { + const types = { + "HyperliquidTransaction:Custom": [ + { name: "hyperliquidChain", type: "string" }, + { name: "payloads", type: "bytes32[]" }, + { name: "nonce", type: "uint64" }, + ], + } as const; + expect(createUserSignedDigestBytes(ACTION, types, "0x66eee")).toBeUndefined(); + }); + + test("returns undefined for a nested struct field type", () => { + const types = { + "HyperliquidTransaction:Custom": [ + { name: "hyperliquidChain", type: "string" }, + { name: "inner", type: "Inner" }, + { name: "nonce", type: "uint64" }, + ], + Inner: [{ name: "value", type: "uint64" }], + } as const; + expect(createUserSignedDigestBytes(ACTION, types, "0x66eee")).toBeUndefined(); + }); + + test("returns undefined for a fixed bytesN field type", () => { + const types = { + "HyperliquidTransaction:Custom": [ + { name: "hyperliquidChain", type: "string" }, + { name: "connectionId", type: "bytes32" }, + { name: "nonce", type: "uint64" }, + ], + } as const; + expect(createUserSignedDigestBytes(ACTION, types, "0x66eee")).toBeUndefined(); + }); + + test("returns undefined for a mixed-case (checksummed) address — viem must validate it", () => { + const action = { ...ACTION, agentAddress: "0xaBcdEf1234567890aBcDeF1234567890aBcDeF12" }; + expect(createUserSignedDigestBytes(action, ApproveAgentTypes, "0x66eee")).toBeUndefined(); + }); + + test("returns undefined for a missing declared field", () => { + const { agentName: _, ...action } = ACTION; + expect(createUserSignedDigestBytes(action, ApproveAgentTypes, "0x66eee")).toBeUndefined(); + }); + + test("returns undefined for out-of-domain integer values", () => { + expect(createUserSignedDigestBytes({ ...ACTION, nonce: -1 }, ApproveAgentTypes, "0x66eee")).toBeUndefined(); + expect(createUserSignedDigestBytes({ ...ACTION, nonce: 1.5 }, ApproveAgentTypes, "0x66eee")).toBeUndefined(); + expect( + createUserSignedDigestBytes({ ...ACTION, nonce: "1700000000000" }, ApproveAgentTypes, "0x66eee"), + ).toBeUndefined(); + }); + + test("returns undefined for an unparsable signatureChainId", () => { + expect(createUserSignedDigestBytes(ACTION, ApproveAgentTypes, "0xZZ")).toBeUndefined(); + }); +}); + +// --- Signing flow conformance ------------------------------------ + +type ViemAccount = ReturnType; + +/** The same account with the raw-digest capability removed: the signature goes through viem's `signTypedData`. */ +function stripped(account: ViemAccount) { + return { + address: account.address, + signTypedData: (params: never) => account.signTypedData(params), + }; +} + +describe("signUserSignedAction() fast path", () => { + test("is byte-identical to the typed-data path across action types x chain IDs", async () => { + const account = privateKeyToAccount(PRIVATE_KEY); + const oracle = stripped(account); + for (const { label, types, message } of CASES) { + for (const signatureChainId of ["0x66eee", "0x1"] as const) { + const action = { type: label, signatureChainId, ...message }; + const fast = await signUserSignedAction({ wallet: account, action, types }); + const reference = await signUserSignedAction({ wallet: oracle, action, types }); + expect(fast, `${label} / ${signatureChainId}`).toEqual(reference); + } + } + }); + + test("signs exactly viem's digest through the raw-digest path, never signTypedData", async () => { + const account = privateKeyToAccount(PRIVATE_KEY); + const signedDigests: `0x${string}`[] = []; + let typedDataCalls = 0; + const wallet = { + address: account.address, + sign: (args: { hash: `0x${string}` }) => { + signedDigests.push(args.hash); + return account.sign(args); + }, + signTypedData: (params: never) => { + typedDataCalls++; + return account.signTypedData(params); + }, + }; + const { label, types, message } = CASES[0]; + const signatureChainId = "0x66eee"; + const action: { signatureChainId: `0x${string}`; [key: string]: unknown } = { + type: label, + signatureChainId, + ...message, + }; + + const signature = await signUserSignedAction({ wallet, action, types }); + + const oracle = hashTypedData(oracleTypedData(types, message, signatureChainId) as never); + expect(typedDataCalls).toBe(0); + expect(signedDigests).toEqual([oracle]); + // …and the signature equals what signing that digest with viem produces. + const oracleHex = await account.sign({ hash: oracle }); + expect(`${signature.r.slice(2)}${signature.s.slice(2)}${signature.v.toString(16)}`).toBe(oracleHex.slice(2)); + }); + + test("falls back to signTypedData for a wallet without raw-digest signing", async () => { + const account = privateKeyToAccount(PRIVATE_KEY); + let typedDataCalls = 0; + const wallet = { + address: account.address, + signTypedData: (params: never) => { + typedDataCalls++; + return account.signTypedData(params); + }, + }; + const { label, types, message } = CASES[0]; + const action = { type: label, signatureChainId: "0x66eee" as const, ...message }; + + const fallback = await signUserSignedAction({ wallet, action, types }); + const fast = await signUserSignedAction({ wallet: account, action, types }); + + expect(typedDataCalls).toBe(1); + expect(fallback).toEqual(fast); + }); + + test("falls back to signTypedData for an unsupported types shape", async () => { + const account = privateKeyToAccount(PRIVATE_KEY); + let signCalls = 0; + let typedDataCalls = 0; + const wallet = { + address: account.address, + sign: (args: { hash: `0x${string}` }) => { + signCalls++; + return account.sign(args); + }, + signTypedData: (params: never) => { + typedDataCalls++; + return account.signTypedData(params); + }, + }; + // An array field is not covered by the hand-rolled encoder, so the typed-data path must sign. + const types = { + "HyperliquidTransaction:Custom": [ + { name: "hyperliquidChain", type: "string" }, + { name: "payloads", type: "bytes32[]" }, + { name: "nonce", type: "uint64" }, + ], + } as const; + const action = { + type: "custom", + signatureChainId: "0x66eee" as const, + hyperliquidChain: "Testnet", + payloads: ["0x1111111111111111111111111111111111111111111111111111111111111111"], + nonce: NONCE, + }; + + const fallback = await signUserSignedAction({ wallet, action, types }); + const reference = await signUserSignedAction({ wallet: stripped(account), action, types }); + + expect(signCalls).toBe(0); + expect(typedDataCalls).toBe(1); + expect(fallback).toEqual(reference); + }); + + test("never uses the fast path for a JSON-RPC wallet", async () => { + const account = privateKeyToAccount(PRIVATE_KEY); + let typedDataCalls = 0; + // JSON-RPC shape: signTypedData + getAddresses + getChainId, and NO address field. Even though the + // underlying signer could sign a digest locally, the adapter must route through real signTypedData. + const wallet = { + signTypedData: (params: never) => { + typedDataCalls++; + return account.signTypedData(params); + }, + getAddresses: () => Promise.resolve([account.address]), + getChainId: () => Promise.resolve(0x66eee), + }; + const { label, types, message } = CASES[0]; + const action = { type: label, signatureChainId: "0x66eee" as const, ...message }; + + const jsonRpc = await signUserSignedAction({ wallet, action, types }); + const fast = await signUserSignedAction({ wallet: account, action, types }); + + expect(typedDataCalls).toBe(1); + expect(jsonRpc).toEqual(fast); + }); + + test("prefers the SIGN_DIGEST_BYTES capability and hands it the exact digest bytes", async () => { + const account = privateKeyToAccount(PRIVATE_KEY); + const digests: Uint8Array[] = []; + let hexSignCalls = 0; + const wallet = { + address: account.address, + sign: (args: { hash: `0x${string}` }) => { + hexSignCalls++; + return account.sign(args); + }, + [SIGN_DIGEST_BYTES]: (digest: Uint8Array) => { + digests.push(digest); + return account.sign({ hash: `0x${bytesToHex(digest)}` }); + }, + signTypedData: (params: never) => account.signTypedData(params), + }; + const { label, types, message } = CASES[0]; + const signatureChainId = "0x66eee"; + const action: { signatureChainId: `0x${string}`; [key: string]: unknown } = { + type: label, + signatureChainId, + ...message, + }; + + await signUserSignedAction({ wallet, action, types }); + + const oracle = hashTypedData(oracleTypedData(types, message, signatureChainId) as never); + expect(hexSignCalls).toBe(0); + expect(digests.length).toBe(1); + expect(`0x${bytesToHex(digests[0])}`).toBe(oracle); + }); +});