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 actions | User-signed actions | |
|---|---|---|
| Examples | Trading and position management | Fund movements and account security |
| EIP-712 domain | Exchange, chain ID 1337 | HyperliquidSignTransaction, user's chain ID |
| What gets signed | Action hash as connectionId | Action fields directly |
L1 action
The action is never signed directly. Instead, a phantom agent is constructed:
- Msgpack-encode the action object (field order matters — the expected order varies by action type)
- Append the nonce as uint64 big-endian (8 bytes)
- Append a vault marker:
0x01+ 20-byte vault address, or0x00if none - If
expiresAfteris set, append0x00+ the timestamp as uint64 big-endian - Keccak-256 hash the concatenated bytes. This is the
connectionId - 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 }whereconnectionIdis the hash from step 5
- Domain:
- 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:
- Each action type defines its own
typed data structure (e.g.,
HyperliquidTransaction:ApproveAgent) - Sign an EIP-712 message with:
- Domain:
{ name: "HyperliquidSignTransaction", version: "1", chainId: <signatureChainId>, verifyingContract: 0x0...0 } - Type and message: defined per action
- Domain:
- 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
nonceManagerbacked 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 ofDate.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.,
CancelRequestforcancel), or hand the action tocanonicalize.
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:
signTypedData | Address | Chain ID | |
|---|---|---|---|
| viem Local Account | single params object | address property | fallback 0x1 |
| viem JSON-RPC Account | single params object | getAddresses() | 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-secp256k1is 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. signTypedDatais 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 requirevieminstalled. 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.
ExchangeClientstarts 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 whenhash-wasmis 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-wasmis 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:
createFastLocalWallet— halves ECDSA (~55 µs vs ~85 µs). ECDSA is ~90% of a single-ordersignL1Action.hash-wasm— ambient keccak speedup on every L1 hash and Agent digest (install the optional dep; no code change).skipValidation: true— skip the valibot parse + key canonicalization on trusted, already-canonical wire input (~3× less non-ECDSA CPU). See ExchangeClient for the contract.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 (viemprivateKeyToAccount/mnemonicToAccount/hdKeyToAccount,createFastLocalWallet, or aWalletClientwrapping one) — never with a JSON-RPC wallet or a custom HSM / MPC / remote signer.warmupSigning(wallet)from@bloxwap/hyperliquid/signingdoes 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 lowercasegetWalletChainId— 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"