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
10 changes: 10 additions & 0 deletions apps/docs/content/docs/clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -799,12 +799,22 @@ wallet or transport calls. The input remains owned by the caller. Reuse a built
resolve coin symbols to asset IDs before building. Time-dependent constraints such as scheduled cancellation are
checked when building, so rebuild those actions before reuse.
An explicit `buildNoop({ nonce })` retains that nonce rather than allocating a fresh one on reuse.
Reuse is where the speedup comes from: a reused L1 action skips validation, copying, and MessagePack encoding on every
signature. Building a fresh action for each call costs slightly more than the raw method, because the builder also
copies and freezes its input, so keep using raw methods for one-off actions.

Signing consumes one nonce and produces an immutable signed request. Submission preserves the operation's response
type and does not sign again. Submit promptly: expiration, the protocol timestamp range, and the signer's 100-highest
nonce window still apply. Signed ownership and network checks are in-process; serialize for storage only if you intend
to use the legacy `submitPrepared` wire-payload API. Reconstructed canonical actions must be rebuilt through a builder.

Each stage accepts a `signal`. Aborting before signing starts consumes no nonce. Aborting while the wallet signs
rejects the call without posting anything; the allocated nonce is skipped, which the exchange tolerates as a gap. A
signed request does not hold on to the signal it was signed with: to cancel it, drop it and let its nonce go stale.
Aborting `submit` (or `execute` after signing) stops waiting for the response, but it cannot recall a request that has
already been sent, so the exchange may still apply it. Resubmitting the same signed request is safe, because the
exchange rejects a nonce it has already seen and the action cannot be applied twice.

The standalone `signAction`, `submitAction`, and `executeAction` functions in
`@bloxwap/hyperliquid/actions/execution` accept the same exchange config. Existing client methods and
`prepareRequest` / `submitPrepared` continue to work.
120 changes: 120 additions & 0 deletions packages/hyperliquid/.dev/perf/canonical_reuse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* Paired offline comparison of fresh versus reused canonical actions.
* <bun|node> .dev/perf/canonical_reuse.ts [--filter real_viem] [--out /tmp/canonical-reuse.json]
*
* Each group times one complete exchange call per iteration. The `memory` wire returns a fixed response
* without serializing; the `http` wire is the real `HttpTransport` over a mocked `fetch`, so the SDK's
* wire serialization stays in the measurement. Variants:
* - `direct`: the existing raw `order` function (validate → sign → submit).
* - `fresh`: `buildOrder` on every call, then `executeAction`.
* - `reused`: one `buildOrder` reused by `executeAction`.
* - `staged`: one `buildOrder` reused by `signAction` then `submitAction`.
*
* Wallets: `stub` returns a constant signature (isolates SDK overhead), `real_viem` is viem's local
* account and `real_fast` is the SDK's WASM-accelerated local wallet. Cases rotate inside every
* round so machine drift hits each one equally. Numbers are CPU time with mocks, not network latency.
* @module
*/
import { writeFile } from "node:fs/promises";
import { cpus } from "node:os";
import { privateKeyToAccount } from "viem/accounts";
import type { ExchangeConfig } from "../../src/api/exchange/_methods/_base/_config.ts";
import { buildOrder, order, type OrderParameters } from "../../src/api/exchange/_methods/order.ts";
import { executeAction, signAction, submitAction } from "../../src/actions/execution.ts";
import { createFastLocalWallet } from "../../src/signing/mod.ts";
import { HttpTransport } from "../../src/transport/http/mod.ts";
import type { AbstractViemLocalAccount } from "../../src/signing/mod.ts";

const filterIndex = process.argv.indexOf("--filter");
const filter = filterIndex >= 0 ? process.argv[filterIndex + 1] : undefined;
const outIndex = process.argv.indexOf("--out");
const sampleCount = 11;
const privateKey = `0x${"11".repeat(32)}` as const;
const signature = `0x${"11".repeat(64)}1b` as const;
const input = { a: 0, b: true, p: "30000", s: "0.1", r: false, t: { limit: { tif: "Gtc" as const } } };

let sink = 0;
const wallets: Record<string, () => Promise<AbstractViemLocalAccount>> = {
stub: async () => ({ address: privateKeyToAccount(privateKey).address, signTypedData: async () => signature }),
real_viem: async () => privateKeyToAccount(privateKey),
real_fast: () => createFastLocalWallet(privateKey),
};

function config(wallet: AbstractViemLocalAccount, wire: string, count: number): ExchangeConfig {
let nonce = 1_700_000_000_000;
const statuses = Array.from({ length: count }, (_, i) => ({ resting: { oid: i + 1 } }));
const response = { status: "ok", response: { type: "order", data: { statuses } } };
if (wire === "http") {
const body = JSON.stringify(response);
globalThis.fetch = (async (_url: unknown, init: RequestInit) => {
sink ^= (init.body as string).length;
return new Response(body, { headers: { "Content-Type": "application/json" } });
}) as typeof fetch;
}
return {
wallet,
nonceManager: () => ++nonce,
transport:
wire === "http"
? new HttpTransport({ isTestnet: true, timeout: null })
: {
isTestnet: true,
request: async <T>(): Promise<T> => response as T,
},
};
}

function median(values: number[]): number {
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.floor(sorted.length / 2)];
}

const originalFetch = globalThis.fetch;
const results: { scenario: string; variant: string; medianUs: number; vsDirect: number; roundsUs: number[] }[] = [];
for (const [walletName, createWallet] of Object.entries(wallets))
for (const wire of ["memory", "http"])
for (const count of [1, 100]) {
const scenario = `canonical_${walletName}_${wire}_${count}`;
if (filter !== undefined && !scenario.includes(filter)) continue;
const params: OrderParameters = { orders: Array.from({ length: count }, () => input), grouping: "na" };
const exchange = config(await createWallet(), wire, count);
const reused = buildOrder(params);
const cases: Record<string, () => Promise<unknown>> = {
direct: () => order(exchange, params),
fresh: () => executeAction(exchange, buildOrder(params)),
reused: () => executeAction(exchange, reused),
staged: async () => submitAction(exchange, await signAction(exchange, reused)),
};
const entries = Object.entries(cases);
const rounds = entries.map(() => [] as number[]);
const iterations = Math.max(20, Math.floor((walletName === "stub" ? 4000 : 400) / Math.sqrt(count)));
// Three discarded warmup rounds, then rotate the starting case each round.
for (let round = -3; round < sampleCount; round++) {
for (let index = 0; index < entries.length; index++) {
const selected = (index + round + entries.length * 3) % entries.length;
const start = performance.now();
for (let i = 0; i < iterations; i++) await entries[selected][1]();
if (round >= 0) rounds[selected].push(((performance.now() - start) * 1000) / iterations);
}
}
const direct = median(rounds[0]);
entries.forEach(([variant], i) => {
const medianUs = median(rounds[i]);
results.push({ scenario, variant, medianUs, vsDirect: medianUs / direct, roundsUs: rounds[i] });
});
}
globalThis.fetch = originalFetch;

console.table(
results.map(({ scenario, variant, medianUs, vsDirect }) => ({
scenario,
variant,
medianUs: Number(medianUs.toFixed(2)),
vsDirect: Number(vsDirect.toFixed(3)),
})),
);
if (outIndex >= 0) {
const runtime = typeof Bun === "undefined" ? `node ${process.version}` : `bun ${Bun.version}`;
const meta = { runtime, cpu: cpus()[0]?.model, sampleCount, sink };
await writeFile(process.argv[outIndex + 1], `${JSON.stringify({ meta, results }, null, 2)}\n`);
}
4 changes: 3 additions & 1 deletion packages/hyperliquid/src/actions/_canonical.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ export function immutableCopy<T>(value: T): T {
if (value !== null && typeof value === "object") {
const copy: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value)) {
Object.defineProperty(copy, key, { value: immutableCopy(child), enumerable: true });
// Assignment is cheaper than defining each key; only `__proto__` needs an explicit own property.
if (key === "__proto__") Object.defineProperty(copy, key, { value: immutableCopy(child), enumerable: true });
else copy[key] = immutableCopy(child);
}
return Object.freeze(copy) as T;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { race } from "../../../../transport/_abort.ts";
import { registerExchangeWireRequest } from "../../../../transport/_wire.ts";
/**
* Common execution shell shared by L1 and user-signed Exchange API actions.
* @module
*/

import { getWalletAddress, type Signature } from "../../../../signing/mod.ts";
import { race } from "../../../../transport/_abort.ts";
import { registerExchangeWireRequest } from "../../../../transport/_wire.ts";
import type { ExchangeConfig } from "./_config.ts";
import { assertSuccessResponse } from "./errors.ts";
import { globalNonceManager } from "./_nonce.ts";
Expand Down Expand Up @@ -142,7 +142,9 @@ const dispatchChains = new Map<string, Promise<void>>();
* @param config Exchange API configuration.
* @param build Callback that, given the nonce, returns the action, signature, and any extras.
* @param signal Optional {@link AbortSignal} to cancel the request.
* @return The validated API response.
* @param prepareOnly Return the signed request after nonce allocation and signing, without claiming a
* dispatch slot or calling the transport. Used by `signAction`; the nonce is consumed either way.
* @return The validated API response, or the signed request when `prepareOnly` is set.
*
* @throws {ApiRequestError} If the API returns an error response.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,16 @@ export function activateOutcomeDeployer(
return executeL1Action(config, action, opts);
}

/** Validate and own a canonical activateOutcomeDeployer action without allocating a nonce or calling a wallet/transport. */
/**
* Build a canonical {@linkcode activateOutcomeDeployer} action: validate, normalize, fill defaults, and copy and freeze
* the result. Allocates no nonce and makes no wallet or transport call, so the action can be signed
* and submitted later, or reused while its fields stay valid.
*
* @param params Parameters specific to the API request.
* @return Immutable action typed with {@link ActivateOutcomeDeployerSuccessResponse}.
*
* @throws {ValidationError} When the request parameters fail validation.
*/
export function buildActivateOutcomeDeployer(
params: ActivateOutcomeDeployerParameters,
): CanonicalAction<ActivateOutcomeDeployerSuccessResponse> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,13 @@ export function agentEnableDexAbstraction(
return executeL1Action(config, action, opts);
}

/** Validate and own a canonical agentEnableDexAbstraction action without allocating a nonce or calling a wallet/transport. */
/**
* Build a canonical {@linkcode agentEnableDexAbstraction} action: validate, normalize, fill defaults, and copy and freeze
* the result. Allocates no nonce and makes no wallet or transport call, so the action can be signed
* and submitted later, or reused while its fields stay valid.
*
* @return Immutable action typed with {@link AgentEnableDexAbstractionSuccessResponse}.
*/
export function buildAgentEnableDexAbstraction(): CanonicalAction<AgentEnableDexAbstractionSuccessResponse> {
const action = buildAction(AgentEnableDexAbstractionActionSchema, { type: "agentEnableDexAbstraction" });
return canonicalAction(action, { kind: "l1" });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,16 @@ export function agentSendAsset(
);
}

/** Validate and own a canonical agentSendAsset action without allocating a nonce or calling a wallet/transport. */
/**
* Build a canonical {@linkcode agentSendAsset} action: validate, normalize, fill defaults, and copy and freeze
* the result. Allocates no nonce and makes no wallet or transport call, so the action can be signed
* and submitted later, or reused while its fields stay valid.
*
* @param params Parameters specific to the API request.
* @return Immutable action typed with {@link AgentSendAssetSuccessResponse}.
*
* @throws {ValidationError} When the request parameters fail validation.
*/
export function buildAgentSendAsset(params: AgentSendAssetParameters): CanonicalAction<AgentSendAssetSuccessResponse> {
const action = buildAction(AgentSendAssetActionSchema, { type: "agentSendAsset", ...params, nonce: 0 });
return canonicalAction(action, { kind: "l1" });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,16 @@ export function agentSetAbstraction(
return executeL1Action(config, action, opts);
}

/** Validate and own a canonical agentSetAbstraction action without allocating a nonce or calling a wallet/transport. */
/**
* Build a canonical {@linkcode agentSetAbstraction} action: validate, normalize, fill defaults, and copy and freeze
* the result. Allocates no nonce and makes no wallet or transport call, so the action can be signed
* and submitted later, or reused while its fields stay valid.
*
* @param params Parameters specific to the API request.
* @return Immutable action typed with {@link AgentSetAbstractionSuccessResponse}.
*
* @throws {ValidationError} When the request parameters fail validation.
*/
export function buildAgentSetAbstraction(
params: AgentSetAbstractionParameters,
): CanonicalAction<AgentSetAbstractionSuccessResponse> {
Expand Down
11 changes: 10 additions & 1 deletion packages/hyperliquid/src/api/exchange/_methods/approveAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,16 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

/** Validate and own a canonical approveAgent action without allocating a nonce or calling a wallet/transport. */
/**
* Build a canonical {@linkcode approveAgent} action: validate, normalize, fill defaults, and copy and freeze
* the result. Allocates no nonce and makes no wallet or transport call, so the action can be signed
* and submitted later, or reused while its fields stay valid.
*
* @param params Parameters specific to the API request.
* @return Immutable action typed with {@link ApproveAgentSuccessResponse}.
*
* @throws {ValidationError} When the request parameters fail validation.
*/
export function buildApproveAgent(params: ApproveAgentParameters): CanonicalAction<ApproveAgentSuccessResponse> {
// Docs: an agent's `valid_until` expiration can be at most 180 days in the future. Guarded only
// when a timestamp is present — a name without `valid_until` (or an unnamed agent) carries no
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,16 @@ export function approveBuilderFee(
return executeUserSignedAction(config, action, ApproveBuilderFeeTypes, opts);
}

/** Validate and own a canonical approveBuilderFee action without allocating a nonce or calling a wallet/transport. */
/**
* Build a canonical {@linkcode approveBuilderFee} action: validate, normalize, fill defaults, and copy and freeze
* the result. Allocates no nonce and makes no wallet or transport call, so the action can be signed
* and submitted later, or reused while its fields stay valid.
*
* @param params Parameters specific to the API request.
* @return Immutable action typed with {@link ApproveBuilderFeeSuccessResponse}.
*
* @throws {ValidationError} When the request parameters fail validation.
*/
export function buildApproveBuilderFee(
params: ApproveBuilderFeeParameters,
): CanonicalAction<ApproveBuilderFeeSuccessResponse> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,16 @@ export function authorizeAqav2Role(
return executeL1Action(config, action, opts);
}

/** Validate and own a canonical authorizeAqav2Role action without allocating a nonce or calling a wallet/transport. */
/**
* Build a canonical {@linkcode authorizeAqav2Role} action: validate, normalize, fill defaults, and copy and freeze
* the result. Allocates no nonce and makes no wallet or transport call, so the action can be signed
* and submitted later, or reused while its fields stay valid.
*
* @param params Parameters specific to the API request.
* @return Immutable action typed with {@link AuthorizeAqav2RoleSuccessResponse}.
*
* @throws {ValidationError} When the request parameters fail validation.
*/
export function buildAuthorizeAqav2Role(
params: AuthorizeAqav2RoleParameters,
): CanonicalAction<AuthorizeAqav2RoleSuccessResponse> {
Expand Down
11 changes: 10 additions & 1 deletion packages/hyperliquid/src/api/exchange/_methods/batchModify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,16 @@ export function batchModify(
return executeL1Action(config, action, opts);
}

/** Validate and own a canonical batchModify action without allocating a nonce or calling a wallet/transport. */
/**
* Build a canonical {@linkcode batchModify} action: validate, normalize, fill defaults, and copy and freeze
* the result. Allocates no nonce and makes no wallet or transport call, so the action can be signed
* and submitted later, or reused while its fields stay valid.
*
* @param params Parameters specific to the API request.
* @return Immutable action typed with {@link BatchModifySuccessResponse}.
*
* @throws {ValidationError} When the request parameters fail validation.
*/
export function buildBatchModify(params: BatchModifyParameters): CanonicalAction<BatchModifySuccessResponse> {
const action = buildAction(BatchModifyActionSchema, { type: "batchModify", ...params });
return canonicalAction(action, { kind: "l1" });
Expand Down
11 changes: 10 additions & 1 deletion packages/hyperliquid/src/api/exchange/_methods/borrowLend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,16 @@ export function borrowLend(
return executeL1Action(config, action, opts);
}

/** Validate and own a canonical borrowLend action without allocating a nonce or calling a wallet/transport. */
/**
* Build a canonical {@linkcode borrowLend} action: validate, normalize, fill defaults, and copy and freeze
* the result. Allocates no nonce and makes no wallet or transport call, so the action can be signed
* and submitted later, or reused while its fields stay valid.
*
* @param params Parameters specific to the API request.
* @return Immutable action typed with {@link BorrowLendSuccessResponse}.
*
* @throws {ValidationError} When the request parameters fail validation.
*/
export function buildBorrowLend(params: BorrowLendParameters): CanonicalAction<BorrowLendSuccessResponse> {
const action = buildAction(BorrowLendActionSchema, { type: "borrowLend", ...params });
return canonicalAction(action, { kind: "l1" });
Expand Down
11 changes: 10 additions & 1 deletion packages/hyperliquid/src/api/exchange/_methods/cDeposit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,16 @@ export function cDeposit(
return executeUserSignedAction(config, action, CDepositTypes, opts);
}

/** Validate and own a canonical cDeposit action without allocating a nonce or calling a wallet/transport. */
/**
* Build a canonical {@linkcode cDeposit} action: validate, normalize, fill defaults, and copy and freeze
* the result. Allocates no nonce and makes no wallet or transport call, so the action can be signed
* and submitted later, or reused while its fields stay valid.
*
* @param params Parameters specific to the API request.
* @return Immutable action typed with {@link CDepositSuccessResponse}.
*
* @throws {ValidationError} When the request parameters fail validation.
*/
export function buildCDeposit(params: CDepositParameters): CanonicalAction<CDepositSuccessResponse> {
const action = buildAction(CDepositActionSchema, { type: "cDeposit", ...params });
return canonicalAction(action, { kind: "user", types: CDepositTypes });
Expand Down
Loading
Loading