From c3c81b681c82bba7696835b9e22d868989ce68b7 Mon Sep 17 00:00:00 2001 From: BhariGowda Date: Sat, 22 Aug 2026 16:57:52 +0530 Subject: [PATCH] Send the vault address that multi_sig actually signed 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. --- hyperliquid/exchange.py | 9 ++++-- tests/signing_test.py | 61 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/hyperliquid/exchange.py b/hyperliquid/exchange.py index 41a2f66b..36bcb4ff 100644 --- a/hyperliquid/exchange.py +++ b/hyperliquid/exchange.py @@ -98,12 +98,14 @@ def __init__( self.info = Info(base_url, True, meta, spot_meta, perp_dexs, timeout) self.expires_after: Optional[int] = None - def _post_action(self, action, signature, nonce): + def _post_action(self, action, signature, nonce, vault_address=None): + if vault_address is None: + vault_address = self.vault_address payload = { "action": action, "nonce": nonce, "signature": signature, - "vaultAddress": self.vault_address if action["type"] not in ["usdClassTransfer", "sendAsset"] else None, + "vaultAddress": vault_address if action["type"] not in ["usdClassTransfer", "sendAsset"] else None, "expiresAfter": self.expires_after, } logging.debug(payload) @@ -1098,6 +1100,8 @@ def c_validator_unregister(self) -> Any: def multi_sig(self, multi_sig_user, inner_action, signatures, nonce, vault_address=None): multi_sig_user = multi_sig_user.lower() + if vault_address is None: + vault_address = self.vault_address payload_action = _multi_sig_payload_action(inner_action) multi_sig_action = { "type": "multiSig", @@ -1122,6 +1126,7 @@ def multi_sig(self, multi_sig_user, inner_action, signatures, nonce, vault_addre multi_sig_action, signature, nonce, + vault_address, ) def use_big_blocks(self, enable: bool) -> Any: diff --git a/tests/signing_test.py b/tests/signing_test.py index b6e89d9f..a99753bc 100644 --- a/tests/signing_test.py +++ b/tests/signing_test.py @@ -2,8 +2,9 @@ import pytest from eth_utils import to_hex -from hyperliquid.exchange import _multi_sig_payload_action +from hyperliquid.exchange import Exchange, _multi_sig_payload_action from hyperliquid.utils.signing import ( + MULTI_SIG_ENVELOPE_SIGN_TYPES, OrderRequest, ScheduleCancelAction, action_hash, @@ -11,6 +12,7 @@ float_to_int_for_hashing, order_request_to_order_wire, order_wires_to_order_action, + recover_user_from_user_signed_action, sign_l1_action, sign_usd_transfer_action, sign_withdraw_from_bridge_action, @@ -228,6 +230,63 @@ def test_multi_sig_user_set_abstraction_payload_uses_wire_enum(): assert action["abstraction"] == "disabled" +def test_multi_sig_uses_the_same_vault_address_it_signs(): + # The outer multi-sig signature commits to the vault address via action_hash, so the + # vaultAddress that goes out in the payload has to be the one that was signed. + wallet = eth_account.Account.from_key("0x0123456789012345678901234567890123456789012345678901234567890123") + vault_address = "0x1719884eb866cb12b2686399699950d60822350c" + nonce = 1700000000000 + inner_action = {"type": "cancel", "cancels": [{"a": 4, "o": 1}]} + + posted = {} + + class CapturingExchange(Exchange): + def post(self, url_path, payload=None): + posted.update(payload) + return {} + + for constructor_vault, argument_vault in [ + (None, vault_address), + (vault_address, None), + (vault_address, vault_address), + (None, None), + ]: + posted.clear() + exchange = CapturingExchange( + wallet, + "https://api.hyperliquid-testnet.xyz", + meta={"universe": []}, + spot_meta={"universe": [], "tokens": []}, + vault_address=constructor_vault, + ) + exchange.multi_sig( + "0x0000000000000000000000000000000000000005", + inner_action, + [], + nonce, + vault_address=argument_vault, + ) + + # Recompute the outer signature the way a verifier would: from what was actually posted. + action_without_tag = posted["action"].copy() + del action_without_tag["type"] + envelope = { + "multiSigActionHash": action_hash( + action_without_tag, posted["vaultAddress"], posted["nonce"], posted["expiresAfter"] + ), + "nonce": posted["nonce"], + "signatureChainId": "0x66eee", + } + recovered = recover_user_from_user_signed_action( + envelope, + posted["signature"], + MULTI_SIG_ENVELOPE_SIGN_TYPES, + "HyperliquidTransaction:SendMultiSig", + False, + ) + assert recovered.lower() == wallet.address.lower() + + def test_create_sub_account_action(): wallet = eth_account.Account.from_key("0x0123456789012345678901234567890123456789012345678901234567890123") action = {