Multi-sig actions
A multi-sig action carries enough authorised users' signatures over one nonce and one named leader, in an envelope only the leader signs and submits.
HyperCore multi-sig is built in, not a smart contract. The concept is on multi-sig accounts; this wire format nests both schemes from signing.
Say an account has 2 authorised users and a threshold of 2. Both sign the same scheduleCancel with the same nonce and leader. The leader wraps both signatures, signs the wrapper and submits.
Every signer must sign exactly the same bytes; common mismatches surface as one error naming the leader, at fault or not.
Five steps from conversion to submission
Section titled “Five steps from conversion to submission”1. Convert the account
Section titled “1. Convert the account”convertToMultiSigUser is user-signed by the account’s own key:
{ "type": "convertToMultiSigUser", "signatureChainId": "0xa4b1", "hyperliquidChain": "Mainnet", "signers": "{\"authorizedUsers\":[\"0x0000000000000000000000000000000000000001\",\"0x0000000000000000000000000000000000000002\"],\"threshold\":2}", "nonce": 1717000000000}signersis a JSON string, signed as a string:HyperliquidTransaction:ConvertToMultiSigUser(string hyperliquidChain,string signers,uint64 nonce).- Authorised users must already exist on Hyperliquid. After conversion, every action must be a multi-sig action.
- To change signers or threshold, wrap a new
convertToMultiSigUserin a multi-sig action. Empty authorised users converts back to a normal user (one SDK sends the string"null"). - The HyperEVM side is not converted. The HyperEVM address stays under the original key and CoreWriter does not work for multi-sig users. The docs advise not touching the HyperEVM before or after conversion.
2. Fix the leader and the nonce
Section titled “2. Fix the leader and the nonce”The leader (outerSigner) is the authorised user who submits, never the multi-sig account itself. Take the nonce once and give it, with the leader’s address, to every signer. The leader collects all signatures before submitting.
A mismatched nonce or leader can’t be repaired: collect the whole set again.
3. Each authorised user signs the inner action
Section titled “3. Each authorised user signs the inner action”The inner signature depends on the wrapped action’s scheme:
| Wrapped action | What each authorised user signs |
|---|---|
L1 action, such as order or outcomeDeploy |
An ordinary L1 signature over the three-element array [multiSigUser, outerSigner, action], both addresses lowercase, with the shared nonce and no vault address |
User-signed action, such as usdSend or approveAgent |
The action’s own typed data with two fields inserted directly after hyperliquidChain: address payloadMultiSigUser and address outerSigner, values lowercase |
For a usdSend, the inner type becomes:
HyperliquidTransaction:UsdSend(string hyperliquidChain,address payloadMultiSigUser,address outerSigner,string destination,string amount,uint64 time)Position is part of the struct hash: anywhere else signs differently.
Browser wallets can’t sign L1 inner actions, which use the chain-1337 Exchange domain. User-signed inner actions are typed data on a real chain id, so a quorum of browser wallets or custodians can sign account-level actions; L1 actions need raw keys.
4. Assemble the wrapper
Section titled “4. Assemble the wrapper”{ "type": "multiSig", "signatureChainId": "0xa4b1", "signatures": [ { "r": "0x<r, leading zeros stripped>", "s": "0x<s, leading zeros stripped>", "v": 27 } ], "payload": { "multiSigUser": "0x<multi-sig account, lowercase>", "outerSigner": "0x<leader, lowercase>", "action": { "type": "scheduleCancel", "time": 1717000010000 } }}Keep the key order shown, inside payload too. Write r and s as minimal hex, with no leading zeros.
5. The leader signs the envelope
Section titled “5. The leader signs the envelope”multiSigActionHash hashes the wrapper minus type exactly like an L1 action (msgpack of {signatureChainId, signatures, payload}, then nonce, vault marker and any expiry). The leader signs it as user-signed typed data under HyperliquidSignTransaction, with chainId equal to signatureChainId:
HyperliquidTransaction:SendMultiSig(string hyperliquidChain,bytes32 multiSigActionHash,uint64 nonce)The body is {action: <wrapper>, nonce, signature: <leader's signature>}. Only the leader’s nonce set is checked and updated. A leader that is an authorised user’s API wallet uses that API wallet’s nonce.
The numbers
Section titled “The numbers”| Parameter | Value |
|---|---|
| Authorised users per multi-sig account | at most 10 |
| Threshold | set in signers; the minimum number of authorised signatures |
| Nonces per action | one, shared by every signer and the leader |
| Nonce set consumed | the leader’s only |
Inner v |
27 or 28 |
Submissions failing with zero-padded r, s and two inner signers |
about 1 in 4 |
| Accepted after stripping leading zeros, measured on testnet | 24 of 24 |
Signing a multi-sig action from code
Section titled “Signing a multi-sig action from code”A sketch for an L1 inner action, using l1ActionHash, signL1Action and signUserAction from signing. It follows the SDKs; test on testnet first.
// Illustrative. Hex comes from viem. Mainnet values; on testnet use "Testnet", source "b" and 0x66eee.const lower = (a: Hex) => a.toLowerCase() as Hex;const minimalHex = (h: Hex): Hex => `0x${h.slice(2).replace(/^0+/, "") || "0"}`;
export async function multiSigL1( signerKeys: Hex[], // at least `threshold` authorised users leaderKey: Hex, // the outerSigner, an authorised user multiSigUser: Hex, outerSigner: Hex, action: object, // an L1 action in canonical key order nonce: number, // one nonce for everyone) { const inner = [lower(multiSigUser), lower(outerSigner), action]; const sigs = await Promise.all(signerKeys.map((k) => signL1Action(k, inner, { nonce, mainnet: true }))); const wrapper = { type: "multiSig", signatureChainId: "0xa4b1" as Hex, signatures: sigs.map(({ r, s, v }) => ({ r: minimalHex(r), s: minimalHex(s), v })), payload: { multiSigUser: lower(multiSigUser), outerSigner: lower(outerSigner), action }, }; const { type: _type, ...withoutType } = wrapper; // the envelope hash excludes `type` const signature = await signUserAction( leaderKey, { signatureChainId: wrapper.signatureChainId, hyperliquidChain: "Mainnet", multiSigActionHash: l1ActionHash(withoutType, { nonce }), nonce, }, "HyperliquidTransaction:SendMultiSig", [ { name: "hyperliquidChain", type: "string" }, { name: "multiSigActionHash", type: "bytes32" }, { name: "nonce", type: "uint64" }, ], ); return { action: wrapper, nonce, signature }; // POST this to /exchange}For a user-signed inner action, payload.action carries the bare action, without the two extra fields. The SDKs verify by rebuilding the typed data from the payload’s addresses, so they must match what was signed, lowercase included:
// Illustrative. One authorised user's inner signature over a usdSend.const innerSig = await signUserAction( signerKey, { ...usdSend, payloadMultiSigUser: lower(multiSigUser), outerSigner: lower(outerSigner) }, "HyperliquidTransaction:UsdSend", [ { name: "hyperliquidChain", type: "string" }, { name: "payloadMultiSigUser", type: "address" }, { name: "outerSigner", type: "address" }, { name: "destination", type: "string" }, { name: "amount", type: "string" }, { name: "time", type: "uint64" }, ],);// The wrapper then carries payload: { multiSigUser, outerSigner, action: usdSend }.Two encodings that blame the leader
Section titled “Two encodings that blame the leader”Checksummed addresses anywhere in the payload recover the wrong signer: same error. Lowercase everything before signing.
For the neighbouring signer errors, see Debugging signatures. For API wallets as leaders, see Nonces and API wallets.