Skip to content

Commit ed1ae81

Browse files
authored
[codex] fix createVault L1 signing (#22)
* fix createVault L1 signing * docs clarify L1 vault and subaccount actions
1 parent fe93715 commit ed1ae81

5 files changed

Lines changed: 146 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
- Fix the `createVault` signing mode back to Hyperliquid L1 and document the failed user-signed experiment. Evidence: collected user-signed `HyperliquidTransaction:CreateVault` signatures recovered to their listed signers locally, but recovered to different addresses under the SDK L1 `Exchange`/`Agent` digest and Hyperliquid returned `Invalid multi-sig inner signer`; nktkas/hyperliquid marks `createVault` as `Signing: L1 Action` and executes it through `executeL1Action`, while its signing docs expose separate `signMultiSigL1` and `signMultiSigUserSigned` paths. Resources: https://github.com/nktkas/hyperliquid/blob/main/src/api/exchange/_methods/createVault.ts, https://jsr.io/%40nktkas/hyperliquid/doc/signing, https://github.com/nktkas/hyperliquid/blob/main/docs/signing.md (2026-06-21).
6+
- Record the failed browser-wallet test for SDK-correct multisig `createVault`: after switching local `createVault` signing back to the SDK L1 `Exchange`/`Agent` payload, Rabby rejected `eth_signTypedData_v4` with `chainId should be same as current chainId | -32602`. Do not retry fake chain/network switching or ask users to add chain `1337`; that was already tested and is not acceptable. The confirmed blocker is Rabby's chain-id validation for the documented Hyperliquid L1 typed-data domain, not JSON body shape. Zero-cost API probe with SDK-shaped dummy signatures returned HTTP 200 `Unable to recover signer`, proving the request shape reaches signature recovery (2026-06-21).
7+
- Record the validated agent-wallet route for `createVault`: a zero-cost probe signed SDK-correct single-agent L1 `createVault` with a throwaway local key and Hyperliquid returned HTTP 200 `User or API Wallet ... does not exist`, proving the server recovered the agent signer and accepted the L1 createVault request shape up to API-wallet authorization. Next viable path is to approve a generated agent wallet through the existing multisig `approveAgent` user-signed flow, verify it with a no-cost L1 action such as `noop`, then submit `createVault` signed by that approved agent. This does not expose multisig signer keys, but the agent key is sensitive and revocable (2026-06-21).
8+
- Record the single-EOA `createVault` signature validation: using a disposable funded-under-minimum account, SDK-correct L1 `createVault` signing returned HTTP 200 `Insufficient balance to create vault`, proving the L1 createVault signature/body construction is accepted by Hyperliquid up to the expected funding check. This confirms remaining failures are multisig browser-signing/authorization path issues, not the base createVault action schema (2026-06-21).
59
- Add vault leader L1 actions `createVault`, `vaultModify`, `vaultDistribute` so a multisig can create and operate a Hyperliquid native vault end-to-end (2026-06-17).
610
- Add WalletConnect support so signers can pair mobile wallets via QR (2026-04-27).
711
- Add gitleaks-based secret scanning: server-side via GitHub Action and local via husky pre-commit hook (2026-04-27).

CLAUDE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,14 @@ Reusing or replaying a nonce lower than the last accepted one returns `"Invalid
135135

136136
Rabby (and MetaMask internally via `@metamask/eth-sig-util`) computes a different domain separator when `EIP712Domain` is omitted from the `types` object. Always include it explicitly in both inner and outer `eth_signTypedData_v4` calls.
137137

138+
**createVault is L1, not user-signed**
139+
140+
`createVault` must stay in the Hyperliquid L1 `Exchange` / `Agent` signing family. The nktkas SDK marks it as `Signing: L1 Action` and calls `executeL1Action`. A user-signed `HyperliquidTransaction:CreateVault` experiment was tested: browser wallets signed it and local recovery matched, but Hyperliquid rejected the final submit with `"Invalid multi-sig inner signer"` because the signatures covered the wrong digest. See `docs/hyperliquid-signing.md` before changing any action's `signingMode`.
141+
142+
**Do not add fake chain 1337 to wallets**
143+
144+
The L1 signing domain uses EIP-712 `chainId: 1337` as part of Hyperliquid's exchange-action signing scheme. This is not an RPC chain this app should add to Rabby or MetaMask. Rabby was tested against the SDK-correct `Exchange` / `Agent` payload and rejected it with `"chainId should be same as current chainId"`. Do not repeat fake-network switching. For wallets that cannot sign the direct L1 multisig payload, use an approved API wallet / agent path or another provider that signs the SDK-correct payload.
145+
138146
## Pull requests
139147

140148
- Pull request description must have sections Why (the rational of change), Lessons learnt (memory for future agents) and Summary (what was changed). No test plan or verification section.

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,16 @@ Open Hyperliquid Multisigner supports both sides of this flow:
6565
- `Withdraw`, `USD Send`, `Spot Send`, `USD Class Transfer`, `Token Delegate`, `Convert to MultiSig` — the fund-moving actions that always require the full multisig quorum
6666
- L1 actions (`Place Order`, `Cancel Order`, `Vault Transfer`, `Sub-Account Transfer`, etc.) — signable either by the multisig quorum or, once delegation is set up, by the approved agent directly
6767

68+
`Create Vault` is also an L1 action in the Hyperliquid protocol. It must use
69+
the same `Exchange` / `Agent` signing family as orders and vault transfers, not
70+
the user-signed `HyperliquidTransaction:*` family used by `Approve Agent`.
71+
Browser wallets may still sign an invented user-signed `Create Vault` payload,
72+
but Hyperliquid rejects those signatures as `Invalid multi-sig inner signer`.
73+
Vault deposits/withdrawals (`Vault Transfer`) and sub-account transfers are in
74+
that same L1 family.
75+
See [docs/hyperliquid-signing.md](docs/hyperliquid-signing.md) before changing
76+
any action's signing mode.
77+
6878
For the canonical protocol reference, see the Hyperliquid documentation on [nonces and API wallets](https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/nonces-and-api-wallets).
6979

7080
# How to create Hyperliquid native multisignature wallet

docs/hyperliquid-signing.md

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# Hyperliquid signing modes
2+
3+
This document records the signing-mode decisions for this app. Do not change
4+
an action from L1 to user-signed, or the reverse, without checking the SDK source
5+
and repeating the relevant recovery/API probe.
6+
7+
## Two signing modes
8+
9+
Hyperliquid has two distinct signing families:
10+
11+
- **User-signed EIP-712**: actions such as `approveAgent`, `usdSend`,
12+
`spotSend`, `withdraw`, `usdClassTransfer`, `approveBuilderFee`,
13+
`tokenDelegate`, `convertToMultiSigUser`, and `sendAsset`. These use the
14+
`HyperliquidSignTransaction` domain and an action-specific
15+
`HyperliquidTransaction:*` primary type.
16+
- **L1 actions**: actions such as `order`, `cancel`, `vaultTransfer`,
17+
`createVault`, `vaultModify`, `vaultDistribute`, `subAccountTransfer`,
18+
`subAccountSpotTransfer`, and `createSubAccount`. These are hashed with the
19+
Hyperliquid action hash and signed as `Exchange` / `Agent` typed data.
20+
21+
`L1` here means Hyperliquid's exchange action signing scheme, not an Ethereum
22+
L1 RPC transaction. Do not add a fake chain 1337 network to a browser wallet.
23+
24+
## Vault and sub-account operations
25+
26+
Keep these actions in the L1 signing family:
27+
28+
- `vaultTransfer`: deposit USDC into a vault or withdraw USDC from a vault.
29+
- `vaultModify`: change vault settings.
30+
- `vaultDistribute`: distribute vault funds back to depositors.
31+
- `subAccountTransfer`: move USDC to or from a sub-account.
32+
- `subAccountSpotTransfer`: move spot tokens to or from a sub-account.
33+
- `createSubAccount`: create a new named sub-account.
34+
35+
These actions are operational Hyperliquid exchange actions. They are not the
36+
same user-signed family as `usdSend`, `spotSend`, `withdraw`, or
37+
`approveAgent`. Browser-wallet success against a locally invented
38+
`HyperliquidTransaction:*` schema is not evidence that Hyperliquid will accept
39+
the submit.
40+
41+
## createVault decision
42+
43+
`createVault` is an L1 action.
44+
45+
References:
46+
47+
- nktkas TypeScript SDK `createVault.ts` marks `createVault` as
48+
`Signing: L1 Action` and calls `executeL1Action`.
49+
https://github.com/nktkas/hyperliquid/blob/main/src/api/exchange/_methods/createVault.ts
50+
- The same SDK marks `approveAgent` as `Signing: User-Signed EIP-712` and calls
51+
`executeUserSignedAction`.
52+
https://github.com/nktkas/hyperliquid/blob/main/src/api/exchange/_methods/approveAgent.ts
53+
- The same SDK marks `vaultTransfer` as `Signing: L1 Action`.
54+
https://github.com/nktkas/hyperliquid/blob/main/src/api/exchange/_methods/vaultTransfer.ts
55+
- Hyperliquid documents API wallets/agents as keys approved to sign on behalf
56+
of the master account.
57+
https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/nonces-and-api-wallets
58+
59+
## createVault test history
60+
61+
The user-signed `createVault` experiment was wrong:
62+
63+
- Browser wallets signed the local user-signed payload successfully.
64+
- Local recovery matched the listed signers.
65+
- Hyperliquid rejected the final multisig submit with
66+
`Invalid multi-sig inner signer`.
67+
- Recovering those signatures against the SDK L1 digest returned different
68+
addresses. They were valid signatures over the wrong digest.
69+
70+
The SDK-correct direct multisig L1 browser path was also tested:
71+
72+
- The exact SDK inner payload was captured:
73+
`domain.name = Exchange`, `domain.chainId = 1337`,
74+
`primaryType = Agent`.
75+
- Rabby rejected it before showing a useful signing path with:
76+
`chainId should be same as current chainId | -32602`.
77+
- Do not retry fake chain switching or ask users to add chain 1337.
78+
79+
The base single-EOA L1 createVault path was validated:
80+
81+
- A disposable underfunded EOA signed SDK-correct L1 `createVault`.
82+
- Hyperliquid returned `Insufficient balance to create vault`.
83+
- Mutating the action after signing, or mutating the signature, returned
84+
recovered-signer errors instead of the balance error. This proves the
85+
insufficient-balance response happens after valid signer recovery.
86+
87+
The agent-wallet path was partially validated:
88+
89+
- A throwaway unapproved agent key signed SDK-correct single-agent L1
90+
`createVault`.
91+
- Hyperliquid returned `User or API Wallet ... does not exist`, proving the
92+
server recovered the signer and reached API-wallet authorisation.
93+
- The viable next path for browser multisigs that cannot sign `Exchange` /
94+
`Agent` directly is:
95+
1. Approve a generated agent wallet through multisig `approveAgent`.
96+
2. Verify the agent with a no-cost L1 action such as `noop`.
97+
3. Submit `createVault` signed by that approved agent.
98+
99+
The agent wallet is a separate revocable API key. It is not a multisig signer
100+
private key, and it should be stored and rotated like any other trading agent
101+
key.
102+
103+
## Action classification checklist
104+
105+
Before adding a new action:
106+
107+
1. Check the nktkas SDK method source for `Signing: L1 Action` or
108+
`Signing: User-Signed EIP-712`.
109+
2. Check whether the SDK method calls `executeL1Action` or
110+
`executeUserSignedAction`.
111+
3. If it is L1, ensure the action object is canonical and does not include
112+
user-signed fields such as `signatureChainId` or `hyperliquidChain`.
113+
4. If it is user-signed, ensure the action includes the SDK's user-signed
114+
fields and the correct action-specific EIP-712 primary type.
115+
5. For multisig submit, keep the outer `SendMultiSig` signature path unchanged:
116+
trim inner signature `r`/`s`, use the same nonce as the inner action, and
117+
commit to the same `outerSigner` in every inner signature.

src/lib/eip712.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,9 @@ export const ACTION_REGISTRY: Record<ActionType, ActionDef> = {
145145
},
146146

147147
// ==== L1 ACTIONS (phantom agent pattern) ====
148+
// These are Hyperliquid exchange actions, not user-signed wallet-transfer
149+
// actions. Keep vault funding/operations, sub-account operations, and orders
150+
// in this section unless SDK source says the specific method is user-signed.
148151

149152
vaultTransfer: {
150153
type: 'vaultTransfer',
@@ -171,8 +174,10 @@ export const ACTION_REGISTRY: Record<ActionType, ActionDef> = {
171174
label: 'Create Vault',
172175
description:
173176
'Create a new Hyperliquid vault. The signing multisig becomes the vault leader. Minimum 100 USDC initial deposit.',
174-
signingMode: 'user-signed',
175-
primaryType: 'HyperliquidTransaction:CreateVault',
177+
// Hyperliquid SDK marks createVault as "Signing: L1 Action".
178+
// Do not move this to user-signed: browser wallets can sign that invented schema,
179+
// but Hyperliquid rejects the submit with "Invalid multi-sig inner signer".
180+
signingMode: 'l1',
176181
nonceField: 'nonce',
177182
fields: [
178183
{

0 commit comments

Comments
 (0)