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 = {