Skip to content

Lowercase authorized users before sorting in convert_to_multi_sig_user - #316

Open
BhariGowda wants to merge 1 commit into
hyperliquid-dex:masterfrom
BhariGowda:fix/convert-to-multi-sig-user-sorting
Open

Lowercase authorized users before sorting in convert_to_multi_sig_user#316
BhariGowda wants to merge 1 commit into
hyperliquid-dex:masterfrom
BhariGowda:fix/convert-to-multi-sig-user-sorting

Conversation

@BhariGowda

@BhariGowda BhariGowda commented Aug 22, 2026

Copy link
Copy Markdown

convert_to_multi_sig_user sorts authorized_users, serialises the result to JSON, and puts that string in the action. signers is an EIP-712 string, so those exact bytes are what gets signed and what the server parses.

The sort runs on the strings as the caller passed them. Two things follow from that, one unconditional and one narrower.

The unconditional one is the casing. If the caller passes checksummed addresses, which is what eth_account gives you from LocalAccount.address, the addresses go into the signed string checksummed. A caller that lowercases first produces a different signers string for the same set of signers. That happens on every call, not on some of them.

The narrower one is the ordering, and it is the sharper consequence. ASCII sorts every uppercase letter before every lowercase one, and EIP-55 casing is effectively random per character, so sorting the strings as given does not order the addresses. Measured over 40,000 randomly generated address sets per row, the order produced today differs from the order of the same addresses normalised:

signers order differs
2 3.77%
3 9.82%
4 17.41%
5 25.50%
10 62.73%

A randomly generated pair that shows it:

users = ["0xCF5936c3DDD8BE043260d7aA459FE8c2aD7D1195",
         "0xc484BE44A66DFbe527896D3f652Bd6C26119692E"]

sorted(users)                     # ['0xCF5936c3DD…', '0xc484BE44A6…']
sorted(u.lower() for u in users)  # ['0xc484be44a6…', '0xcf5936c3dd…']

Here the comparison is decided at index 2, on 'C' against 'c', which should have been a tie and left the decision to index 3, where 'F' and '4' compare as 'f' against '4'. The other way it happens is at the first genuinely differing position, for example 'B' against 'a', where the ASCII order is the reverse of the real one. Both mechanisms occur, so this is not a single narrow case.

Every other address the SDK puts into an action is lowercased first: multi_sig_user, the builder address in bulk_orders, the userGenesis and freezeUser users in the spot deploy actions, oracleUpdater in perp_deploy_register_asset, and both multi-sig payload fields. Lowercasing before the sort makes this consistent with those, and makes both the casing and the ordering depend only on the set of signers.

I have not tested what the server does with either form, so this is about the SDK producing one representation for a given set of signers rather than a claim that the current output is rejected.

The test asserts the emitted signers list is identical for checksummed input, lowercased input and reversed input. It fails on master on the first of those.

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

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.
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