Signing from first principles
L1 actions sign a keccak hash of their msgpack bytes, user-signed actions sign readable EIP-712 data, and one wrong byte in either recovers a stranger's address.
HyperCore has no logins: your action is authorised when its signature recovers the account, or an API wallet it approved. The server rebuilds the bytes it expects from your action. Sign a price as "0.650" where the server hashes "0.65", and recovery returns a different, valid-looking address; the action is refused, usually as coming from an account that does not exist.
| Scheme | For | Form |
|---|---|---|
| L1 action | Trading, in many shapes | One 32-byte hash in a fixed two-field struct, so one type definition covers every action |
| User-signed action | Transfers, withdrawals and approvals, which move funds or grant power | Typed data the wallet can display (“human readable”, in the docs’ words) |
The docs advise using an existing SDK. This page is for writing, auditing or debugging one.
L1 actions
Section titled “L1 actions”The bytes, in order:
| Step | Bytes | Notes |
|---|---|---|
| 1 | msgpack(action) |
The action object, keys in canonical order (below) |
| 2 | nonce, 8 bytes, big-endian | The same value as nonce in the request body |
| 3 | 0x00, or 0x01 followed by the 20-byte vault address |
0x01 form when vaultAddress is set |
| 4 | only if expiresAfter is set: 0x00 followed by expiresAfter, 8 bytes, big-endian |
The marker is 0x00, not 0x01; with no expiry, nothing is appended |
connectionId = keccak256(bytes), signed as EIP-712 typed data:
domain = { name: "Exchange", version: "1", chainId: 1337, verifyingContract: 0x0000000000000000000000000000000000000000 }type = Agent(string source, bytes32 connectionId)message = { source: "a" on mainnet, "b" on testnet, connectionId }The domain is the same on both networks: source alone picks the network.
What the server hashes
Section titled “What the server hashes”The server hashes its own msgpack re-encoding of your parsed action, with its field order and types, not your JSON. Your bytes must match:
| # | Rule | Detail |
|---|---|---|
| 1 | Key order is hashed | msgpack keeps insertion order. Build objects in the server’s order, never from an unordered map (that broke every signature in one codebase). |
| 2 | Decimals are strings, trailing zeros stripped | "10.0" is "10", "0.650" is "0.65". The docs call trailing zeros a common cause of bad signatures, yet their splitOutcome example writes "123.0": sign "123". Guard against "1e-7". |
| 3 | Integers take the smallest msgpack width | Some encoders write a JavaScript number of 2^32 or more as float64 unless it is a BigInt. Order ids pass that size: the docs show oid 287286372177. |
| 4 | Omit or null exactly as the server does | Table below. Undefined keys are removed, not encoded as nil. |
| 5 | Addresses are lowercase | A checksummed builder address recovers the wrong signer. The docs recommend lowercasing every address before signing and sending. |
| 6 | cloid is a 128-bit hex string |
For example 0x1234567890abcdef1234567890abcdef, not raw bytes. |
| 7 | Deployer actions sort lists of tuples | In HIP-3 and HIP-4. A HIP-4 keywordToValue is signed as [key, value] pairs sorted by key; signing the object recovers someone else’s address. |
Canonical key orders:
| Object | Key order |
|---|---|
order action |
type, orders, grouping, builder (builder only if present) |
| each order | a, b, p, s, r, t, c (c only if set) |
t for a limit |
{"limit": {"tif": ...}} |
t for a trigger |
{"trigger": {"isMarket", "triggerPx", "tpsl"}}, in that order |
builder |
b, f |
cancel action |
type, cancels; each cancel a, o |
modify action |
type, oid, order |
batchModify action |
type, modifies; each entry oid, order |
Omit or null:
| Field | Rule |
|---|---|
c on an order |
Omit when there is no cloid |
builder on an order action |
Omit when there is no builder |
f on cancel and cancelByCloid |
Omit when false; an action hashed with f: false is rejected |
a on modify and batchModify |
Omit when false; an action hashed with a: false is rejected |
destination on reserveRequestWeight |
Omit when unset; the documentation says it is skipped in hashing |
time on scheduleCancel |
Omit to clear a scheduled cancel |
amount on mergeOutcome and mergeQuestion |
Send null to merge the maximum |
amount on borrowLend |
Send null when not given |
User-signed actions
Section titled “User-signed actions”These are ordinary EIP-712 typed data, field by field:
domain = { name: "HyperliquidSignTransaction", version: "1", chainId: <signatureChainId as an integer>, verifyingContract: 0x0000000000000000000000000000000000000000 }primaryType = "HyperliquidTransaction:<Name>"message = the action's fields named in the type, in the type's order| Field | Rule |
|---|---|
signatureChainId |
Only has to equal the domain’s chain id. Convention: 0xa4b1 (42161, Arbitrum) on mainnet, 0x66eee (421614, Arbitrum Sepolia) on testnet; the docs’ example signs with 42161. |
hyperliquidChain |
"Mainnet" or "Testnet", inside the signed message: binds the network. |
time |
The in-action nonce of usdSend, spotSend and withdraw3; equals the outer nonce. |
nonce |
The in-action nonce of sendAsset, sendToEvmWithData, usdClassTransfer, cDeposit, cWithdraw, tokenDelegate, approveAgent, approveBuilderFee, userSetAbstraction and userDexAbstraction; equals the outer nonce. |
destination |
Typed string, so its case is hashed: send it lowercase. |
agentName |
approveAgent without a name signs the empty string. |
expiresAfter, vaultAddress |
Not taken. |
The docs publish two type tables: spotSend, and withdraw3, typed Withdraw, not Withdraw3. The rest match in at least two independent SDKs:
HyperliquidTransaction:UsdSend(string hyperliquidChain,string destination,string amount,uint64 time)HyperliquidTransaction:SpotSend(string hyperliquidChain,string destination,string token,string amount,uint64 time)HyperliquidTransaction:Withdraw(string hyperliquidChain,string destination,string amount,uint64 time)HyperliquidTransaction:SendAsset(string hyperliquidChain,string destination,string sourceDex,string destinationDex,string token,string amount,string fromSubAccount,uint64 nonce)HyperliquidTransaction:UsdClassTransfer(string hyperliquidChain,string amount,bool toPerp,uint64 nonce)HyperliquidTransaction:ApproveAgent(string hyperliquidChain,address agentAddress,string agentName,uint64 nonce)HyperliquidTransaction:ApproveBuilderFee(string hyperliquidChain,string maxFeeRate,address builder,uint64 nonce)HyperliquidTransaction:UserSetAbstraction(string hyperliquidChain,address user,string abstraction,uint64 nonce)HyperliquidTransaction:ConvertToMultiSigUser(string hyperliquidChain,string signers,uint64 nonce)Field order is hashed: one swapped field signs a different message and fails like a bad msgpack encoding.
Which scheme an action uses
Section titled “Which scheme an action uses”A documented action carrying hyperliquidChain and signatureChainId is user-signed; every other is L1, except the multiSig wrapper (multi-sig actions). That rule and the two published type tables settle four SDK disagreements:
| Action | Disagreement | Per the documentation |
|---|---|---|
withdraw3 |
One SDK names the type Withdraw3 |
The documentation’s own type is HyperliquidTransaction:Withdraw |
cDeposit, cWithdraw |
One SDK signs them as L1 actions | They carry hyperliquidChain and signatureChainId: user-signed, by the account’s own key |
tokenDelegate |
One SDK omits the nonce field | The action carries nonce, matching the outer nonce |
scheduleCancel clear |
One SDK sends time: null |
Leave time out to remove the schedule; see orders |
The numbers
Section titled “The numbers”| Constant | Value |
|---|---|
| L1 domain | name Exchange, version 1, chainId 1337, verifyingContract zero, on both networks |
| L1 struct | Agent(string source,bytes32 connectionId) |
source |
"a" mainnet, "b" testnet |
| Nonce in the hash | 8 bytes, big-endian |
| Vault marker | 0x00 without a vault; 0x01 plus 20 bytes with one |
| Expiry suffix | 0x00 plus 8 bytes big-endian, only when expiresAfter is set |
| User-signed domain | name HyperliquidSignTransaction, version 1, chainId from signatureChainId, verifyingContract zero |
signatureChainId convention |
0xa4b1 mainnet, 0x66eee testnet |
hyperliquidChain |
"Mainnet" or "Testnet" |
| Signature on the wire | { "r": "0x...", "s": "0x...", "v": 27 or 28 } |
Signing in TypeScript with viem
Section titled “Signing in TypeScript with viem”An illustrative implementation with viem and @msgpack/msgpack. It reproduces the order-hash and user-signed golden vectors; test any port against both.
// Illustrative. Build the action with keys in canonical order and decimals stripped first.import { encode } from "@msgpack/msgpack";import { concat, hexToBytes, keccak256, numberToBytes, parseSignature, type Hex } from "viem";import { privateKeyToAccount } from "viem/accounts";
const ZERO = "0x0000000000000000000000000000000000000000";
type L1Opts = { nonce: number; vaultAddress?: Hex; expiresAfter?: number };
// connectionId = keccak256(msgpack(action) | nonce | vault marker | expiry suffix)export function l1ActionHash(action: object, { nonce, vaultAddress, expiresAfter }: L1Opts): Hex { const parts: Uint8Array[] = [ encode(action, { ignoreUndefined: true }), // keys hashed in insertion order numberToBytes(BigInt(nonce), { size: 8 }), // u64 big-endian vaultAddress ? concat([new Uint8Array([1]), hexToBytes(vaultAddress)]) : new Uint8Array([0]), ]; if (expiresAfter !== undefined) { parts.push(new Uint8Array([0]), numberToBytes(BigInt(expiresAfter), { size: 8 })); } return keccak256(concat(parts));}
export async function signL1Action(key: Hex, action: object, opts: L1Opts & { mainnet: boolean }) { const sig = await privateKeyToAccount(key).signTypedData({ domain: { name: "Exchange", version: "1", chainId: 1337, verifyingContract: ZERO }, types: { Agent: [{ name: "source", type: "string" }, { name: "connectionId", type: "bytes32" }] }, primaryType: "Agent", message: { source: opts.mainnet ? "a" : "b", connectionId: l1ActionHash(action, opts) }, }); const { r, s, v } = parseSignature(sig); return { r, s, v: Number(v) }; // the exchange takes { r, s, v } with v 27 or 28}
export async function signUserAction( key: Hex, action: { signatureChainId: Hex } & Record<string, unknown>, primaryType: string, // "HyperliquidTransaction:UsdSend" and so on fields: { name: string; type: string }[], // order is part of the hash) { const sig = await privateKeyToAccount(key).signTypedData({ domain: { name: "HyperliquidSignTransaction", version: "1", chainId: Number(BigInt(action.signatureChainId)), // must equal signatureChainId verifyingContract: ZERO, }, types: { [primaryType]: fields }, primaryType, message: Object.fromEntries(fields.map((f) => [f.name, action[f.name]])), }); const { r, s, v } = parseSignature(sig); return { r, s, v: Number(v) };}Using it:
const action = { type: "order", orders: [{ a: 0, b: true, p: "50000", s: "0.001", r: false, t: { limit: { tif: "Gtc" } } }], grouping: "na",};const nonce = 1700000000000; // from a nonce generator; see the nonces pageconst signature = await signL1Action(KEY, action, { nonce, mainnet: true });const body = JSON.stringify({ action, nonce, signature }); // POST to /exchange
const usdSend = { type: "usdSend", signatureChainId: "0xa4b1", hyperliquidChain: "Mainnet", destination: "0x0d1d9635d0640821d15e323ac8adadfa9c111414", // lowercase: typed string amount: "1", time: 1690393044548, // equals the outer nonce} as const;const userSig = await signUserAction(KEY, usdSend, "HyperliquidTransaction:UsdSend", [ { name: "hyperliquidChain", type: "string" }, { name: "destination", type: "string" }, { name: "amount", type: "string" }, { name: "time", type: "uint64" },]);const userBody = JSON.stringify({ action: usdSend, nonce: usdSend.time, signature: userSig });@msgpack/msgpack encodes safe integers of 2^32 and above as uint64 by default; with useBigInt64 on, they become float64 unless passed as BigInt (rule 3).
Where signing goes wrong in practice
Section titled “Where signing goes wrong in practice”- Browser wallets refuse chain id 1337, so they cannot sign L1 actions. Use an approved API wallet (a raw key) or a placeholder chain-1337 network in the wallet;
activateOutcomeDeployerrejects agent signatures, so a master key in a browser wallet needs the placeholder network. - The L1 domain is always 1337. Signing under 42161 recovers the wrong address.
- Local recovery proves only self-consistency. The server rebuilds from the parsed action, where bytes diverge.
- Mixing the schemes (a user-signed action through the L1 path, or the reverse) is the first common error the docs list.
- Wrong network, right encoding.
source: "b"on mainnet, orhyperliquidChain: "Testnet"in a mainnet action, is valid for the other network and recovers a different address here.
For the checklist, the probe and golden vectors, see Debugging signatures and wire shapes. For nonces and who may sign, see Nonces and API wallets.