Skip to content

Commit fe6ebed

Browse files
committed
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.
1 parent 2fdb18f commit fe6ebed

2 files changed

Lines changed: 37 additions & 2 deletions

File tree

‎hyperliquid/exchange.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -665,7 +665,7 @@ def approve_builder_fee(self, builder: str, max_fee_rate: str) -> Any:
665665

666666
def convert_to_multi_sig_user(self, authorized_users: List[str], threshold: int) -> Any:
667667
timestamp = get_timestamp_ms()
668-
authorized_users = sorted(authorized_users)
668+
authorized_users = sorted(authorized_user.lower() for authorized_user in authorized_users)
669669
signers = {
670670
"authorizedUsers": authorized_users,
671671
"threshold": threshold,

‎tests/signing_test.py‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import json
2+
13
import eth_account
24
import pytest
35
from eth_utils import to_hex
46

5-
from hyperliquid.exchange import _multi_sig_payload_action
7+
from hyperliquid.exchange import Exchange, _multi_sig_payload_action
68
from hyperliquid.utils.signing import (
79
OrderRequest,
810
ScheduleCancelAction,
@@ -228,6 +230,39 @@ def test_multi_sig_user_set_abstraction_payload_uses_wire_enum():
228230
assert action["abstraction"] == "disabled"
229231

230232

233+
def test_convert_to_multi_sig_user_signers_do_not_depend_on_input_casing():
234+
# The sort exists to give the signers list a canonical order, but sorting mixed-case
235+
# addresses orders on ASCII, so the result depends on how the caller cased its input.
236+
wallet = eth_account.Account.from_key("0x0123456789012345678901234567890123456789012345678901234567890123")
237+
checksummed = [
238+
"0xB1C4b0f9E7fD0B9d2F0c0F6C0B0D0E0F0A0b0C0D",
239+
"0xa2c4B0F9e7Fd0b9D2f0C0f6c0b0d0e0f0A0B0c0D",
240+
]
241+
expected = sorted(authorized_user.lower() for authorized_user in checksummed)
242+
243+
posted = {}
244+
245+
class CapturingExchange(Exchange):
246+
def post(self, url_path, payload=None):
247+
posted.update(payload)
248+
return {}
249+
250+
def signers_for(authorized_users):
251+
posted.clear()
252+
exchange = CapturingExchange(
253+
wallet,
254+
"https://api.hyperliquid-testnet.xyz",
255+
meta={"universe": []},
256+
spot_meta={"universe": [], "tokens": []},
257+
)
258+
exchange.convert_to_multi_sig_user(authorized_users, 2)
259+
return json.loads(posted["action"]["signers"])["authorizedUsers"]
260+
261+
assert signers_for(checksummed) == expected
262+
assert signers_for(expected) == expected
263+
assert signers_for(list(reversed(checksummed))) == expected
264+
265+
231266
def test_create_sub_account_action():
232267
wallet = eth_account.Account.from_key("0x0123456789012345678901234567890123456789012345678901234567890123")
233268
action = {

0 commit comments

Comments
 (0)