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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 58 additions & 20 deletions src/api/exchange/_methods/_base/_shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,33 @@ const nonceKeyCache = new WeakMap<
WeakMap<object, { walletAddress: string; isTestnet: boolean; key: string }>
>();

/**
* 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<string, Promise<void>>();

/**
* 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.
Expand Down Expand Up @@ -146,24 +164,44 @@ export async function executeWithShell<T>(
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<void>((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<T> => {
let response: Promise<T> | 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<T>("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<T>(
"exchange",
{
action,
signature,
nonce,
...extras,
},
signal,
),
};
// without awaiting either the signature or the network response.
return { pending };
});

// --- Await response (concurrently across calls) and validate
Expand Down
42 changes: 33 additions & 9 deletions src/api/exchange/_methods/_base/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,16 +204,32 @@ export function executeUserSignedAction<T>(
// 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<Record<string, readonly { name: string; type: string }[]>, "nonce" | "time">();

/** Extracts the nonce field name ("nonce" or "time") from EIP-712 type definitions (memoized per `types` object). */
function extractNonceFieldName(types: Record<string, readonly { name: string; type: string }[]>): "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<ExchangeConfig, { raw: string; parsed: `0x${string}` }>();

/**
* Resolves signature chain ID from config, or falls back to the leader wallet's chain ID.
*
Expand All @@ -225,9 +241,17 @@ function extractNonceFieldName(types: Record<string, readonly { name: string; ty
*/
async function resolveSignatureChainId(config: ExchangeConfig): Promise<`0x${string}`> {
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);
Expand Down
20 changes: 15 additions & 5 deletions src/api/subscription/_methods/fastAssetCtxs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(
payload.type,
payload,
Expand All @@ -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,
);
Expand Down Expand Up @@ -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}. */
Expand Down
17 changes: 17 additions & 0 deletions src/signing/_abstractWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Loading
Loading