Skip to content

Fix multi_sig sending a different vault address than it signs - #314

Open
BhariGowda wants to merge 1 commit into
hyperliquid-dex:masterfrom
BhariGowda:fix/multi-sig-vault-address
Open

Fix multi_sig sending a different vault address than it signs#314
BhariGowda wants to merge 1 commit into
hyperliquid-dex:masterfrom
BhariGowda:fix/multi-sig-vault-address

Conversation

@BhariGowda

@BhariGowda BhariGowda commented Aug 22, 2026

Copy link
Copy Markdown

Exchange.multi_sig takes a vault_address argument and passes it to sign_multi_sig_action, which feeds it into action_hash, so the outer signature commits to it. _post_action then ignored that argument and wrote self.vault_address into the payload instead.

Whenever the two differ, the server recomputes the multi-sig action hash over a different vault address, recovers a different outer signer, and rejects the action. Two configurations are affected:

Exchange(vault_address=) multi_sig(vault_address=) signed posted
None V V null
V None null V
V V V V
None None null null

Only the last two work. The existing examples pass None on both sides, which is why this has not shown up there.

Recovering the outer signer from the payload that actually goes out, on master:

ctor=None  arg=VAULT -> posted vaultAddress=None    recovered 0x6CcA9Da6...  MISMATCH
ctor=VAULT arg=None  -> posted vaultAddress=0x1719… recovered 0xC3bf9FFb...  MISMATCH
ctor=VAULT arg=VAULT -> posted vaultAddress=0x1719… recovered 0x1a642f0E...  matches wallet

The recovery above does not go through the SDK's own action_hash or recover_user_from_user_signed_action. It is reimplemented directly from the wire format, msgpack of the action with its type tag removed, then the nonce, vault marker and expiry bytes, then keccak, then the HyperliquidTransaction:SendMultiSig EIP-712 struct, then ecrecover. That was deliberate: the conclusion holds even if every recovery and verification helper elsewhere in this repo is wrong, so a bug in the SDK's own verification path cannot be what produces the apparent mismatch. Both implementations agree on the two rows that already work, which is what makes the two mismatching rows meaningful.

The same resolution order shows up in the third-party TypeScript SDK nktkas/hyperliquid. Its executeL1Action resolves one value, options?.vaultAddress ?? config.defaultVaultAddress, and uses that single value both for the signature and for the vaultAddress field merged into the request body. That is the behaviour this change gives multi_sig.

The fix resolves the vault address once at the top of multi_sig, falling back to self.vault_address when the argument is omitted, and threads it through to _post_action so the payload states what was signed.

_post_action is unchanged for its other callers: the new parameter defaults to self.vault_address, and the usdClassTransfer/sendAsset exception is untouched. To check that rather than assert it, I captured the outgoing payload for 35 methods against both a plain Exchange and one constructed with a vault address, with the nonce clock frozen so the runs are comparable. All 70 payloads are byte-identical before and after this change.

The test recovers the outer signer from the posted payload the way a verifier would, across all four combinations above. It fails on master for the two mismatched rows.

Ran pytest tests/signing_test.py (14 passed) plus black, isort and flake8 with the repo's configured settings.

Exchange.multi_sig takes a vault_address argument and passes it to
sign_multi_sig_action, which feeds it into action_hash, so the outer
signature commits to it. _post_action then ignored it and put
self.vault_address in the payload instead. Whenever the two differ the
server recomputes the multi-sig action hash over a different vault
address, recovers a different outer signer, and rejects the action.

Two configurations are affected:

  Exchange(vault_address=None) + multi_sig(vault_address=V)
      signs V, posts null

  Exchange(vault_address=V) + multi_sig()
      signs null, posts V

Only the case where both happen to be the same value works today, which
is why the existing examples (both None) are unaffected.

Resolved the vault address once at the top of multi_sig, falling back to
self.vault_address when the argument is omitted, and threaded it through
to _post_action so the payload states what was signed. _post_action
keeps its previous behaviour for every other caller: the new parameter
defaults to self.vault_address, and the usdClassTransfer/sendAsset
exception is unchanged.

Added a test that recovers the outer signer from the posted payload the
way a verifier would, across all four combinations of constructor and
argument vault address. It fails on master for the two mismatched cases.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant