BloxwapHyperliquid SDK

Signing

Low-level signing helpers from @bloxwap/hyperliquid/signing for building signed EIP-712 payloads outside ExchangeClient — for custom integrations or actions not yet covered by the high-level client.

How signing works

Hyperliquid has two signing flows depending on the action type:

L1 actionsUser-signed actions
ExamplesTrading and position managementFund movements and account security
EIP-712 domainExchange, chain ID 1337HyperliquidSignTransaction, user's chain ID
What gets signedAction hash as connectionIdAction fields directly

L1 action

The action is never signed directly. Instead, a phantom agent is constructed:

  1. Msgpack-encode the action object (field order matters — the expected order varies by action type)
  2. Append the nonce as uint64 big-endian (8 bytes)
  3. Append a vault marker: 0x01 + 20-byte vault address, or 0x00 if none
  4. If expiresAfter is set, append 0x00 + the timestamp as uint64 big-endian
  5. Keccak-256 hash the concatenated bytes. This is the connectionId
  6. Sign an EIP-712 message with:
    • Domain: { name: "Exchange", version: "1", chainId: 1337, verifyingContract: 0x0...0 }
    • Type: Agent { source: string, connectionId: bytes32 }
    • Message: { source: "a" (mainnet) or "b" (testnet), connectionId } where connectionId is the hash from step 5
  7. Send { action, signature: { r, s, v }, nonce } to the exchange endpoint

Chain ID 1337 is hardcoded and doesn't depend on the wallet's network. The phantom agent construct means the validator recovers the signer from the Agent message, then verifies that the connectionId matches the action hash.

User-signed action

The action fields are placed directly into the EIP-712 message, with no hashing or phantom agent:

  1. Each action type defines its own typed data structure (e.g., HyperliquidTransaction:ApproveAgent)
  2. Sign an EIP-712 message with:
    • Domain: { name: "HyperliquidSignTransaction", version: "1", chainId: <signatureChainId>, verifyingContract: 0x0...0 }
    • Type and message: defined per action
  3. Send { action, signature: { r, s, v }, nonce } to the exchange endpoint

The signatureChainId field in the action (hex, such as "0x66eee") sets the EIP-712 domain chain ID.

Shared rules

Both flows produce the same envelope: { action, signature, nonce }.

The signature is ECDSA { r, s, v } with v equal to 27 or 28 — wallets that return a raw recovery value of 0/1 are normalized by the SDK.

The nonce is a unix millisecond timestamp. Hyperliquid stores the 100 highest nonces per signer: a new one must be larger than the smallest stored, never repeat, and fall within (T - 2 days, T + 1 day) of the block timestamp. See Nonces.

Operational nonce rules

  • One signer = one process. A wallet must be driven by a single nonce source. Multi-process deployments (replicas, workers) need a shared nonceManager backed by external state (e.g. Redis), or the processes emit colliding nonces, or nonces that fall outside the 100-highest window the exchange tracks, and get rejected.
  • Restarts after running ahead of wall-clock. Under burst load the built-in manager issues last + 1, running ahead of Date.now(). A restarted process re-derives nonces from the wall clock, so until real time catches up with the previously issued nonces the exchange can reject fresh requests.

Hex is case-sensitive for signing. The SDK lowercases the values it generates (wallet addresses, multi-sig fields); any hex you place into an action yourself must already be lowercase, or the signature won't verify server-side.

L1 actions

The action is hashed, not signed directly — its key order is what the signature commits to, so build it in schema order (see L1 action), or let canonicalize build it for you.

Three optional parameters:

  • isTestnet — switch the EIP-712 source to the testnet ("b" instead of "a").
  • vaultAddress — sign through a vault; folded into the hash.
  • expiresAfter — reject the action after this timestamp; folded into the hash.
viem
import { signL1Action } from "@bloxwap/hyperliquid/signing";
import { privateKeyToAccount } from "viem/accounts";

const wallet = privateKeyToAccount("0x...");

const action = { type: "cancel", cancels: [{ a: 0, o: 12345 }] };
const nonce = Date.now();

const signature = await signL1Action({ wallet, action, nonce });

await fetch("https://api.hyperliquid.xyz/exchange", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ action, signature, nonce }),
});
Browser (viem)
import { signL1Action } from "@bloxwap/hyperliquid/signing";
import { createWalletClient, custom } from "viem";
import { arbitrum } from "viem/chains";

const [account] = await window.ethereum!.request({ method: "eth_requestAccounts" }) as `0x${string}`[];
const wallet = createWalletClient({ account, chain: arbitrum, transport: custom(window.ethereum!) });

const action = { type: "cancel", cancels: [{ a: 0, o: 12345 }] };
const nonce = Date.now();

const signature = await signL1Action({ wallet, action, nonce });

await fetch("https://api.hyperliquid.xyz/exchange", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ action, signature, nonce }),
});
Custom
import { signL1Action } from "@bloxwap/hyperliquid/signing";
import type { AbstractViemLocalAccount } from "@bloxwap/hyperliquid/signing";

const wallet: AbstractViemLocalAccount = {
  address: "0x...",
  async signTypedData({ domain, types, primaryType, message }) {
    // Your EIP-712 signing logic (HSM, MPC, remote signer, etc.)
    return "0x...";
  },
};

const action = { type: "cancel", cancels: [{ a: 0, o: 12345 }] };
const nonce = Date.now();

const signature = await signL1Action({ wallet, action, nonce });

await fetch("https://api.hyperliquid.xyz/exchange", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ action, signature, nonce }),
});

User-signed actions

The action fields are signed directly as EIP-712 typed data (see user-signed action) — no hashing, so you must hand it the types that match the action.

Each action type has its own types, exported from @bloxwap/hyperliquid/api/exchange under the convention PascalCase(actionType) + "Types" — ApproveAgentTypes for approveAgent, Withdraw3Types for withdraw3, and so on.

viem
import { signUserSignedAction } from "@bloxwap/hyperliquid/signing";
import { ApproveAgentTypes } from "@bloxwap/hyperliquid/api/exchange";
import { privateKeyToAccount } from "viem/accounts";

const wallet = privateKeyToAccount("0x...");

const action = {
  type: "approveAgent",
  signatureChainId: "0x66eee" as const,
  hyperliquidChain: "Mainnet", // or "Testnet"
  agentAddress: "0x...",
  agentName: "Agent",
  nonce: Date.now(),
};

const signature = await signUserSignedAction({ wallet, action, types: ApproveAgentTypes });

await fetch("https://api.hyperliquid.xyz/exchange", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ action, signature, nonce: action.nonce }),
});
Browser (viem)
import { signUserSignedAction } from "@bloxwap/hyperliquid/signing";
import { ApproveAgentTypes } from "@bloxwap/hyperliquid/api/exchange";
import { createWalletClient, custom } from "viem";
import { arbitrum } from "viem/chains";

const [account] = await window.ethereum!.request({ method: "eth_requestAccounts" }) as `0x${string}`[];
const wallet = createWalletClient({ account, chain: arbitrum, transport: custom(window.ethereum!) });

const action = {
  type: "approveAgent",
  signatureChainId: "0x66eee" as const,
  hyperliquidChain: "Mainnet", // or "Testnet"
  agentAddress: "0x...",
  agentName: "Agent",
  nonce: Date.now(),
};

const signature = await signUserSignedAction({ wallet, action, types: ApproveAgentTypes });

await fetch("https://api.hyperliquid.xyz/exchange", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ action, signature, nonce: action.nonce }),
});
Custom
import { signUserSignedAction } from "@bloxwap/hyperliquid/signing";
import { ApproveAgentTypes } from "@bloxwap/hyperliquid/api/exchange";
import type { AbstractViemLocalAccount } from "@bloxwap/hyperliquid/signing";

const wallet: AbstractViemLocalAccount = {
  address: "0x...",
  async signTypedData({ domain, types, primaryType, message }) {
    // Your EIP-712 signing logic (HSM, MPC, remote signer, etc.)
    return "0x...";
  },
};

const action = {
  type: "approveAgent",
  signatureChainId: "0x66eee" as const,
  hyperliquidChain: "Mainnet", // or "Testnet"
  agentAddress: "0x...",
  agentName: "Agent",
  nonce: Date.now(),
};

const signature = await signUserSignedAction({ wallet, action, types: ApproveAgentTypes });

await fetch("https://api.hyperliquid.xyz/exchange", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ action, signature, nonce: action.nonce }),
});

Action hashing

createL1ActionHash is the keccak256 connectionId that signL1Action signs internally — vaultAddress and expiresAfter feed into it exactly as they do when signing. Compute the hash yourself to verify that your action serialization matches the SDK's:

import { createL1ActionHash } from "@bloxwap/hyperliquid/signing";

const hash = createL1ActionHash({
  action: { type: "cancel", cancels: [{ a: 0, o: 12345 }] },
  nonce: Date.now(),
});

[!WARNING]

The hash depends on key order in the action object. The expected order varies by action type — look it up in that action's valibot schema (e.g., CancelRequest for cancel), or hand the action to canonicalize.

Canonicalize

canonicalize reorders an action's keys to match its valibot schema — the order an L1 signature commits to. The signing functions don't reorder for you, so an action you build by hand must already be in schema order; canonicalize guarantees it:

import { canonicalize } from "@bloxwap/hyperliquid/signing";
import { CancelRequest } from "@bloxwap/hyperliquid/api/exchange";

const action = canonicalize(CancelRequest.entries.action, {
  cancels: [{ o: 12345, a: 0 }],
  type: "cancel",
});
// → { type: "cancel", cancels: [{ a: 0, o: 12345 }] }

// `action` is now in schema order — pass it to `signL1Action` or `createL1ActionHash`

Each action's request schema is exported from @bloxwap/hyperliquid/api/exchange under the convention PascalCase(actionType) + "Request" — CancelRequest for cancel, OrderRequest for order, and so on; pass its .entries.action. It throws CanonicalizeError if the object has an unexpected key or is missing a required one.

Multi-sig actions

signMultiSigL1

One call runs the whole L1 multi-sig flow: it collects an inner signature from every signer, wraps them, and signs the wrapper with the leader (the first signer in the array).

It returns { action, signature }, where action is the multi-sig wrapper — send that, not your original action.

Optional isTestnet, vaultAddress, and expiresAfter behave as in signL1Action.

viem
import { signMultiSigL1 } from "@bloxwap/hyperliquid/signing";
import { privateKeyToAccount } from "viem/accounts";

const multiSigUser = "0x..."; // the multi-sig account address
const signers = [
  privateKeyToAccount("0x..."), // leader — signs the wrapper
  privateKeyToAccount("0x..."),
] as const;

const action = { type: "scheduleCancel", time: Date.now() + 10_000 };
const nonce = Date.now();

const { action: multiSigAction, signature } = await signMultiSigL1({
  signers,
  multiSigUser,
  signatureChainId: "0x66eee",
  action,
  nonce,
});

await fetch("https://api.hyperliquid.xyz/exchange", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ action: multiSigAction, signature, nonce }),
});
Browser (viem)
import { signMultiSigL1 } from "@bloxwap/hyperliquid/signing";
import { createWalletClient, custom } from "viem";
import { arbitrum } from "viem/chains";

const [account] = await window.ethereum!.request({ method: "eth_requestAccounts" }) as `0x${string}`[];
const leader = createWalletClient({
  account,
  chain: arbitrum,
  transport: custom(window.ethereum!),
});

const multiSigUser = "0x..."; // the multi-sig account address
const signers = [leader /* additional signers */] as const;

const action = { type: "scheduleCancel", time: Date.now() + 10_000 };
const nonce = Date.now();

const { action: multiSigAction, signature } = await signMultiSigL1({
  signers,
  multiSigUser,
  signatureChainId: "0x66eee",
  action,
  nonce,
});

await fetch("https://api.hyperliquid.xyz/exchange", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ action: multiSigAction, signature, nonce }),
});
Custom
import { signMultiSigL1 } from "@bloxwap/hyperliquid/signing";
import type { AbstractViemLocalAccount } from "@bloxwap/hyperliquid/signing";

const leader: AbstractViemLocalAccount = {
  address: "0x...",
  async signTypedData({ domain, types, primaryType, message }) {
    // Your EIP-712 signing logic (HSM, MPC, remote signer, etc.)
    return "0x...";
  },
};

const multiSigUser = "0x..."; // the multi-sig account address
const signers = [leader /* additional signers */] as const;

const action = { type: "scheduleCancel", time: Date.now() + 10_000 };
const nonce = Date.now();

const { action: multiSigAction, signature } = await signMultiSigL1({
  signers,
  multiSigUser,
  signatureChainId: "0x66eee",
  action,
  nonce,
});

await fetch("https://api.hyperliquid.xyz/exchange", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ action: multiSigAction, signature, nonce }),
});

signMultiSigUserSigned

The user-signed counterpart: one call collects each signer's contribution, wraps it, and signs the wrapper with the leader (the first signer). For the inner signatures it extends the action's types with the multi-sig fields, so you pass the same types as the single-signer call.

It returns { action, signature } — action is the wrapper to send, not your original action.

viem
import { signMultiSigUserSigned } from "@bloxwap/hyperliquid/signing";
import { UsdSendTypes } from "@bloxwap/hyperliquid/api/exchange";
import { privateKeyToAccount } from "viem/accounts";

const multiSigUser = "0x..."; // the multi-sig account address
const signers = [
  privateKeyToAccount("0x..."), // leader
  privateKeyToAccount("0x..."),
] as const;

const action = {
  type: "usdSend",
  signatureChainId: "0x66eee" as const,
  hyperliquidChain: "Mainnet" as const, // or "Testnet"
  destination: "0x...",
  amount: "100",
  time: Date.now(),
};

const { action: multiSigAction, signature } = await signMultiSigUserSigned({
  signers,
  multiSigUser,
  action,
  types: UsdSendTypes,
});

await fetch("https://api.hyperliquid.xyz/exchange", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ action: multiSigAction, signature, nonce: action.time }),
});
Browser (viem)
import { signMultiSigUserSigned } from "@bloxwap/hyperliquid/signing";
import { UsdSendTypes } from "@bloxwap/hyperliquid/api/exchange";
import { createWalletClient, custom } from "viem";
import { arbitrum } from "viem/chains";

const [account] = await window.ethereum!.request({ method: "eth_requestAccounts" }) as `0x${string}`[];

const leader = createWalletClient({
  account,
  chain: arbitrum,
  transport: custom(window.ethereum!),
});

const multiSigUser = "0x..."; // the multi-sig account address
const signers = [leader /* additional signers */] as const;

const action = {
  type: "usdSend",
  signatureChainId: "0x66eee" as const,
  hyperliquidChain: "Mainnet" as const, // or "Testnet"
  destination: "0x...",
  amount: "100",
  time: Date.now(),
};

const { action: multiSigAction, signature } = await signMultiSigUserSigned({
  signers,
  multiSigUser,
  action,
  types: UsdSendTypes,
});

await fetch("https://api.hyperliquid.xyz/exchange", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ action: multiSigAction, signature, nonce: action.time }),
});
Custom
import { signMultiSigUserSigned } from "@bloxwap/hyperliquid/signing";
import { UsdSendTypes } from "@bloxwap/hyperliquid/api/exchange";
import type { AbstractViemLocalAccount } from "@bloxwap/hyperliquid/signing";

const leader: AbstractViemLocalAccount = {
  address: "0x...",
  async signTypedData({ domain, types, primaryType, message }) {
    return "0x...";
  },
};

const multiSigUser = "0x..."; // the multi-sig account address
const signers = [leader /* additional signers */] as const;

const action = {
  type: "usdSend",
  signatureChainId: "0x66eee" as const,
  hyperliquidChain: "Mainnet" as const, // or "Testnet"
  destination: "0x...",
  amount: "100",
  time: Date.now(),
};

const { action: multiSigAction, signature } = await signMultiSigUserSigned({
  signers,
  multiSigUser,
  action,
  types: UsdSendTypes,
});

await fetch("https://api.hyperliquid.xyz/exchange", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ action: multiSigAction, signature, nonce: action.time }),
});

Wallet compatibility

All signing functions accept AbstractWallet — a union of supported wallet interfaces:

signTypedDataAddressChain ID
viem Local Accountsingle params objectaddress propertyfallback 0x1
viem JSON-RPC Accountsingle params objectgetAddresses()getChainId()

Any object matching one of these interfaces works. Detection is by member presence only — declared parameter counts are never inspected — so wrapped or adapted wallets qualify too, e.g. a hand-rolled adapter around an embedded-wallet provider (Privy-style) declaring signTypedData(...args) or default parameters. For a custom signer (HSM, MPC, remote service), implement the viem Local Account shape (address and signTypedData), as in the Custom tab.

Ethers-style wallets with positional signTypedData(domain, types, value) are not supported and are rejected with an explicit error — wrap them in an adapter whose signTypedData takes a single viem-style params object ({ domain, types, primaryType, message }).

Fast local wallet (WASM secp256k1)

createFastLocalWallet builds a local account whose raw-digest signing runs on tiny-secp256k1 (WASM libsecp256k1) instead of the pure-JS secp256k1 inside viem — roughly half the ECDSA cost per L1 action (~55 µs vs ~85 µs per signature). Both signers are RFC 6979 deterministic with low-S normalization, so the produced signature is byte-identical to the viem one; the SDK's differential tests pin that identity.

import { createFastLocalWallet, signL1Action } from "@bloxwap/hyperliquid/signing";

const wallet = await createFastLocalWallet("0x..."); // same shape as a viem local account

const action = { type: "cancel", cancels: [{ a: 0, o: 12345 }] };
const signature = await signL1Action({ wallet, action, nonce: Date.now() });

The acceleration is strictly opt-in:

  • Extra dependency. tiny-secp256k1 is an optional dependency, loaded through a guarded dynamic import inside the factory — wallets created any other way never touch the WASM module. It pulls in one small package of its own (uint8array-tools). If your package manager skips optional dependencies, install it explicitly: npm install tiny-secp256k1.
  • Graceful fallback. If the module is missing or fails to initialize, the factory warns once and returns the plain viem account (noble path) — signing keeps working, just slower. Pass { wasm: false } to skip the WASM path (and the warning) deliberately.
  • signTypedData is not accelerated. User-signed actions and multi-sig wrappers delegate to a viem local account created from the same key (imported lazily on first use), so those flows require viem installed. A wallet used only for L1 actions never loads viem.

Use it when signature latency is on the hot path — market making, high-frequency order management, bursts of cancels. For occasional actions the default viem account is fine.

WASM keccak

Every L1 action hashes its msgpack preimage with keccak256, and the EIP-712 Agent digest adds two more hashes per signature. When the optional hash-wasm dependency is installed, those hashes run on its WASM keccak instead of the pure-JS one in @noble/hashes — roughly 4-7× faster per hash (~0.5 µs vs ~2.2 µs for a single-order preimage, ~8.5 µs vs ~61 µs for a 100-order one), which takes the SDK's non-ECDSA overhead per order from ~11 µs down to ~5 µs.

The acceleration needs no code changes:

  • Automatic dispatch. ExchangeClient starts loading the WASM module in the background when it is constructed (any other signing entry point starts it on its first hash); until it is ready — and permanently when hash-wasm is not installed — hashing transparently stays on @noble/hashes. await preloadWasmKeccak() (from @bloxwap/hyperliquid/signing) waits until the load has settled. Both compute keccak-256, so the output is byte-identical either way; the SDK's differential tests pin that identity across input sizes, block boundaries, and real action preimages.
  • Optional dependency. hash-wasm is declared as an optional dependency and loaded through a guarded dynamic import — if your package manager skips optional dependencies, install it explicitly: npm install hash-wasm. A missing or broken module is never an error; the SDK just keeps the noble path.

Unlike createFastLocalWallet, the dispatch is ambient: every signing entry point benefits, including wallets you already create today.

Low-latency recipe (bots / HFT)

Stack the accelerators when signature latency is on the critical path:

  1. createFastLocalWallet — halves ECDSA (~55 µs vs ~85 µs). ECDSA is ~90% of a single-order signL1Action.
  2. hash-wasm — ambient keccak speedup on every L1 hash and Agent digest (install the optional dep; no code change).
  3. skipValidation: true — skip the valibot parse + key canonicalization on trusted, already-canonical wire input (~3× less non-ECDSA CPU). See ExchangeClient for the contract.
  4. await exchange.warmup() at start-up — the first order a cold process signs is several times slower than the steady state (measured ~5.5 ms vs ~0.1 ms with a viem local account): the WASM keccak is still loading, the curve's precomputed tables are not built, and nothing is compiled yet. warmup() settles all three (first order ~1.3 ms) without sending anything or consuming a nonce. It signs throwaway data only with keys held in the process (viem privateKeyToAccount / mnemonicToAccount / hdKeyToAccount, createFastLocalWallet, or a WalletClient wrapping one) — never with a JSON-RPC wallet or a custom HSM / MPC / remote signer. warmupSigning(wallet) from @bloxwap/hyperliquid/signing does the signing part without a client.
import { ExchangeClient, HttpTransport } from "@bloxwap/hyperliquid";
import { createFastLocalWallet } from "@bloxwap/hyperliquid/signing";

// npm i tiny-secp256k1 hash-wasm   # optional deps; install explicitly if your package manager skips them
const wallet = await createFastLocalWallet("0x...");
const exchange = new ExchangeClient({ transport: new HttpTransport(), wallet });
await exchange.warmup(); // before the first latency-sensitive order

// Action must already be in canonical wire form (schema key order, normalized decimals, lowercase hex, defaults filled).
await exchange.order(
  {
    orders: [{ a: 0, b: true, p: "95000", s: "0.01", r: false, t: { limit: { tif: "Gtc" } } }],
    grouping: "na",
  },
  { skipValidation: true },
);

Without step 3 the other steps still apply and are safe for any input. Step 3 is an escape hatch: invalid input is no longer a client-side ValidationError — the server rejects it instead.

Helpers

These functions work with any supported wallet type:

  • getWalletAddress — returns the wallet address, always lowercase
  • getWalletChainId — returns the wallet chain ID as hex, falls back to "0x1" for local wallets without a provider
import { getWalletAddress, getWalletChainId } from "@bloxwap/hyperliquid/signing";
import { privateKeyToAccount } from "viem/accounts";

const wallet = privateKeyToAccount("0x...");

const address = await getWalletAddress(wallet); // "0x..."
const chainId = await getWalletChainId(wallet); // "0xa4b1"
Edit this page on GitHub

On this page