From fe6ebed4eff64795de00d11aa70ace5923135744 Mon Sep 17 00:00:00 2001 From: BhariGowda Date: Sat, 22 Aug 2026 17:09:20 +0530 Subject: [PATCH] Lowercase authorized users before sorting in convert_to_multi_sig_user convert_to_multi_sig_user sorts authorized_users to give the signers list a canonical order, then serialises it into the signed action. The sort runs on the strings as given, so with checksummed addresses it orders on ASCII, where every uppercase letter sorts before every lowercase one. The result is that the same set of signers produces a different signers string depending only on how the caller happened to case its input: users = ["0xB1C4b0f9E7fD0B9d2F0c0F6C0B0D0E0F0A0b0C0D", "0xa2c4B0F9e7Fd0b9D2f0C0f6c0b0d0e0f0A0B0c0D"] sorted(users) -> ["0xB1C4...", "0xa2c4..."] sorted(u.lower() for u in users) -> ["0xa2c4...", "0xb1c4..."] so the canonical ordering is not canonical, and the addresses go out checksummed rather than lowercased. Every other address the SDK puts into an action is lowercased first (multi_sig_user, the builder address, the userGenesis and freezeUser users, oracleUpdater, and the multi-sig payload fields). Lowercasing before the sort makes this consistent with those and makes the ordering depend only on the set of signers. Added a test asserting the emitted signers list is the same for checksummed input, lowercased input, and reversed input. --- hyperliquid/exchange.py | 2 +- tests/signing_test.py | 37 ++++++++++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/hyperliquid/exchange.py b/hyperliquid/exchange.py index 41a2f66b..6ee6c81e 100644 --- a/hyperliquid/exchange.py +++ b/hyperliquid/exchange.py @@ -665,7 +665,7 @@ def approve_builder_fee(self, builder: str, max_fee_rate: str) -> Any: def convert_to_multi_sig_user(self, authorized_users: List[str], threshold: int) -> Any: timestamp = get_timestamp_ms() - authorized_users = sorted(authorized_users) + authorized_users = sorted(authorized_user.lower() for authorized_user in authorized_users) signers = { "authorizedUsers": authorized_users, "threshold": threshold, diff --git a/tests/signing_test.py b/tests/signing_test.py index b6e89d9f..5053f060 100644 --- a/tests/signing_test.py +++ b/tests/signing_test.py @@ -1,8 +1,10 @@ +import json + import eth_account 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 ( OrderRequest, ScheduleCancelAction, @@ -228,6 +230,39 @@ def test_multi_sig_user_set_abstraction_payload_uses_wire_enum(): assert action["abstraction"] == "disabled" +def test_convert_to_multi_sig_user_signers_do_not_depend_on_input_casing(): + # The sort exists to give the signers list a canonical order, but sorting mixed-case + # addresses orders on ASCII, so the result depends on how the caller cased its input. + wallet = eth_account.Account.from_key("0x0123456789012345678901234567890123456789012345678901234567890123") + checksummed = [ + "0xB1C4b0f9E7fD0B9d2F0c0F6C0B0D0E0F0A0b0C0D", + "0xa2c4B0F9e7Fd0b9D2f0C0f6c0b0d0e0f0A0B0c0D", + ] + expected = sorted(authorized_user.lower() for authorized_user in checksummed) + + posted = {} + + class CapturingExchange(Exchange): + def post(self, url_path, payload=None): + posted.update(payload) + return {} + + def signers_for(authorized_users): + posted.clear() + exchange = CapturingExchange( + wallet, + "https://api.hyperliquid-testnet.xyz", + meta={"universe": []}, + spot_meta={"universe": [], "tokens": []}, + ) + exchange.convert_to_multi_sig_user(authorized_users, 2) + return json.loads(posted["action"]["signers"])["authorizedUsers"] + + assert signers_for(checksummed) == expected + assert signers_for(expected) == expected + assert signers_for(list(reversed(checksummed))) == expected + + def test_create_sub_account_action(): wallet = eth_account.Account.from_key("0x0123456789012345678901234567890123456789012345678901234567890123") action = {