Skip to content

Commit 412afb7

Browse files
guibvieiraclaude
andcommitted
fix: trim leading zeros from inner sig r/s before hashing outer envelope
The server strips leading zeros from signature r/s components before recomputing the multiSig action hash to verify the outer signer. Submitting raw (untrimmed) signatures causes a hash mismatch, making the outer sig recover to the wrong address → "Invalid multi-sig outer signer". Fix mirrors nktkas/hyperliquid SDK's trimSignature() behavior. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3938683 commit 412afb7

2 files changed

Lines changed: 22 additions & 1 deletion

File tree

src/lib/eip712.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,8 @@ export const ACTION_REGISTRY: Record<ActionType, ActionDef> = {
256256
// ============================================================================
257257

258258
function buildMultisigTypes(actionDef: ActionDef) {
259+
// SDK inserts payloadMultiSigUser + outerSigner immediately after hyperliquidChain
260+
// via add_multi_sig_types(). Order must match exactly.
259261
const baseTypes: Array<{ name: string; type: string }> = [
260262
{ name: 'hyperliquidChain', type: 'string' },
261263
{ name: 'payloadMultiSigUser', type: 'address' },

src/lib/execute.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ type Provider = {
1616
request: (args: { method: string; params?: unknown[] }) => Promise<unknown>
1717
}
1818

19+
type Signature = { r: string; s: string; v: number }
20+
1921
function hexToBytes(hex: string): Uint8Array {
2022
const bytes = new Uint8Array(hex.length / 2)
2123
for (let i = 0; i < hex.length; i += 2) {
@@ -24,6 +26,19 @@ function hexToBytes(hex: string): Uint8Array {
2426
return bytes
2527
}
2628

29+
// nktkas/hyperliquid SDK trims leading zeros from r and s before including
30+
// signatures in the multiSig action that gets msgpacked for the outer hash.
31+
// The server does the same before recomputing the hash to verify the outer sig.
32+
// If we send raw (untrimmed) sigs, the server's hash diverges from ours and
33+
// the outer sig recovery fails → "Invalid multi-sig outer signer".
34+
function trimSignature(sig: Signature): Signature {
35+
const trimHex = (hex: string) => {
36+
const stripped = hex.slice(2).replace(/^0+/, '')
37+
return '0x' + (stripped || '0')
38+
}
39+
return { r: trimHex(sig.r), s: trimHex(sig.s), v: sig.v }
40+
}
41+
2742
/**
2843
* Compute action hash for the multiSig envelope.
2944
* Matches Python SDK: action_hash(action_without_tag, vault_address, nonce)
@@ -167,10 +182,14 @@ export async function executeBundle(
167182
const isMainnet = bundle.network === 'Mainnet'
168183
const exchangeUrl = isMainnet ? MAINNET_EXCHANGE : TESTNET_EXCHANGE
169184

185+
// Trim leading zeros from r/s — the server trims before recomputing
186+
// the outer action hash, so we must hash the same trimmed form.
187+
const trimmedSignatures = bundle.signatures.map(trimSignature)
188+
170189
const multiSigAction = {
171190
type: 'multiSig',
172191
signatureChainId: '0x66eee',
173-
signatures: bundle.signatures,
192+
signatures: trimmedSignatures,
174193
payload: {
175194
multiSigUser: bundle.multisig_user.toLowerCase(),
176195
outerSigner: outerSigner.toLowerCase(),

0 commit comments

Comments
 (0)