Repository Public
1// Collateral of a venue account, signer side: the `venueDeposit` intent (pinned bridge, native USDC, floor above the venue2// minimum, wallet limits) and the `withdraw3` signature of the main key (passkey, own address only). No network; nothing is sent.3// Mnemonic: the public BIP-39 test vector. SDK key: the published test key of the official SDK's tests.4import { createHash } from 'node:crypto';5import { mkdtempSync, readdirSync, readFileSync } from 'node:fs';6import { tmpdir } from 'node:os';7import { join } from 'node:path';8import { describe, expect, it } from 'vitest';9import { decodeFunctionData, erc20Abi, parseTransaction, recoverTransactionAddress } from 'viem';10import { privateKeyToAccount } from 'viem/accounts';11import { cctpUsdc, intentHash, NETWORKS, verifyAuditChain, type Intent } from '@spw/core';12import {13 buildVenueWithdraw, checkVenueDepositIntent, createHlTrading, hex, HL_BRIDGE_ADDRESSES, HL_BRIDGE_PINS, HL_DEPOSIT_FLOOR_USDC, HL_DEPOSIT_MIN_USDC, HL_WITHDRAW_FEE_USDC, HL_WITHDRAWALS_PER_DAY, hlPinnedAssets,14 jurisdictionAck, recoverTypedDataSigner, usdcDecimal, usdcUnits, venueAuthorizationHash, venueDepositSection, venueWithdrawalBytes, venueWithdrawalHash,15 type HlTypedData, type SignRequest, type VenueAuthorization, type VenueWithdrawal,16} from '@spw/derivatives/live';17import { buildApproveAgent, buildUsdClassTransferTypedDataOnly, buildWithdraw3TypedDataOnly, HL_PRIMARY, HL_USD_SEND_TYPES, userSignedTypedData } from '../../../packages/derivatives/src/venues/hyperliquid/signing.ts';18import { raw as fixture, sameSig, SDK_TEST_KEY, signWith } from '../../../packages/derivatives/test/hyperliquid/helpers.ts';19import { TestPasskey } from '../../../tests/helpers/passkey.ts';20import { deriveKeys, type Keys } from '../src/keys.ts';21import { authHeader, createSignerServer } from '../src/server.ts';22import { Signer } from '../src/signer.ts';23import { Store } from '../src/store.ts';2425const RP = 'wallet.test', ORIGIN = 'https://wallet.test';26const keys = deriveKeys('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about');27const ME = keys.addresses.evm, ACCOUNT = ME.toLowerCase();28const PIN = HL_BRIDGE_PINS.mainnet!, USDC = cctpUsdc('arbitrum').id, OTHER = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8';29let n = 0;30const uuid = () => `8b2c1f0e-8f5f-4b1e-9c56-${String(++n).padStart(12, '0')}`;3132async function setup(k: Keys = keys) {33 const t = { now: Math.floor(Date.now() / 1000) };34 const dir = mkdtempSync(join(tmpdir(), 'spw-collateral-'));35 const paths = [join(dir, 'state.json'), join(dir, 'policy.json')] as const;36 const store = new Store(...paths);37 store.state.setupTokenHash = createHash('sha256').update('tok').digest('hex');38 const prices = { get: async (syms: string[]) => Object.fromEntries(syms.map((s) => [s, 70_000])) };39 const signer = new Signer(k, store, { rpId: RP, origin: ORIGIN }, () => t.now, prices);40 const pk = new TestPasskey(RP, ORIGIN);41 await signer.enroll({ credential: pk.credential(), setupToken: 'tok' });42 const restart = () => new Signer(k, new Store(...paths), { rpId: RP, origin: ORIGIN }, () => t.now, prices);43 return { signer, store, pk, t, restart };44}45type World = Awaited<ReturnType<typeof setup>>;46const code = async (p: Promise<unknown>) => { const e = await p.then(() => null, (x) => x); return e ? `${e.code}: ${e.message}` : 'ok'; };47const journal = (w: World, type: string) => w.store.state.journal.filter((j) => j.type === type);4849/** a venue deposit exactly as the API prepares it: 10 USDC on Arbitrum to the pinned bridge */50function deposit(now: number, over: Partial<Intent> = {}): Intent {51 return { v: 1, proposalId: uuid(), keyId: keys.keyId, network: 'arbitrum', chainRef: '42161', asset: USDC, symbol: 'USDC', decimals: 6, amount: '10000000', from: ME, to: PIN.bridge,52 maxFee: '100000000000000', createdAt: now, expiresAt: now + 120, policyVersion: 4, venueDeposit: venueDepositSection('mainnet'),53 evm: { nonce: 4, gasLimit: '90000', maxFeePerGas: '1000000000', maxPriorityFeePerGas: '1000000' }, ...over };54}55const sign = (w: World, i: Intent, assertion = w.pk.assert(intentHash(i))) => w.signer.sign(i, assertion);5657describe('pinned route', () => {58 it('pins the documented mainnet bridge, the registry\'s native USDC on Arbitrum, and no testnet route', () => {59 expect(PIN).toEqual({ network: 'arbitrum', chainId: 42161, bridge: '0x2Df1c51E09aECF9cacB7bc98cB1742757f163dF7', usdc: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831',60 codeSha256: '37ff6a58c3c80d99fd85ba0898ba3a5ae8d55b82c4a4d758dd258af3b05fc3c7', codeBytes: 19394 });61 expect(USDC).toBe(PIN.usdc);62 expect(NETWORKS.arbitrum!.chainRef).toBe(String(PIN.chainId));63 expect(HL_BRIDGE_PINS.testnet).toBeUndefined();64 expect(() => venueDepositSection('testnet')).toThrow(/no deposit route/);65 expect(HL_BRIDGE_ADDRESSES).toContain(PIN.bridge.toLowerCase());66 expect([HL_DEPOSIT_MIN_USDC, HL_DEPOSIT_FLOOR_USDC, HL_WITHDRAW_FEE_USDC]).toEqual(['5', '6', '1']);67 expect(usdcUnits(HL_DEPOSIT_FLOOR_USDC)).toBeGreaterThan(usdcUnits(HL_DEPOSIT_MIN_USDC));68 expect([usdcUnits('12.3'), usdcDecimal(12_300_000n), usdcDecimal(5_000_001n), usdcDecimal(7_000_000n)]).toEqual([12_300_000n, '12.3', '5.000001', '7']);69 expect(() => usdcUnits('1.0000001')).toThrow();70 });71});7273describe('venue deposit: canonical intent', () => {74 const fixed = (): Intent => ({ ...deposit(1794370000), proposalId: '8b2c1f0e-8f5f-4b1e-9c56-0000000000aa', policyVersion: 3 }); // golden hash recorded under policy version 3: unchanged, the canonical serialization did not move75 it('the section is part of the hash the passkey signs', () => {76 const i = fixed(), { venueDeposit: _s, ...plain } = i;77 expect(hex(intentHash(i))).toBe('e127c8313791b42c4dd7260521c52a95b75e7b349c1cd8770c910431b2c02bd6');78 expect(hex(intentHash(i))).not.toBe(hex(intentHash(plain as Intent)));79 for (const patch of [{ venueNetwork: 'testnet' as const }, { route: 'other-route' }, { bridge: OTHER }, { minAmount: '5000000' }])80 expect(hex(intentHash({ ...i, venueDeposit: { ...i.venueDeposit!, ...patch } }))).not.toBe(hex(intentHash(i)));81 });82 it('excludes a swap, a bridge and a memo in the same intent', () => {83 const i = fixed();84 expect(() => intentHash({ ...i, memo: 'x' })).toThrow(/venue deposit/);85 expect(() => intentHash({ ...i, bridge: { provider: 'cctp', toNetwork: 'base', toDomain: 6, maxFee: '0', minFinalityThreshold: 2000, messenger: OTHER, transmitter: OTHER, approveGas: '70000' } })).toThrow();86 expect(() => intentHash({ ...i, network: 'solana' })).toThrow();87 });88});8990describe('venue deposit: what the signer signs', () => {91 it('signs a transfer of native USDC on Arbitrum from the wallet to the pinned bridge, and records it', async () => {92 const w = await setup(), i = deposit(w.t.now);93 const r = await sign(w, i);94 const tx = parseTransaction(r.raw as `0x${string}`);95 expect(tx.chainId).toBe(42161);96 expect(tx.to?.toLowerCase()).toBe(USDC.toLowerCase());97 expect(tx.value ?? 0n).toBe(0n);98 const call = decodeFunctionData({ abi: erc20Abi, data: tx.data! });99 expect(call.functionName).toBe('transfer');100 expect(call.args).toEqual([PIN.bridge, 10_000_000n]);101 expect(await recoverTransactionAddress({ serializedTransaction: r.raw as `0x02${string}` })).toBe(ME); // the venue credits the sender: the wallet's own address102 expect(r.hash).toBe(hex(intentHash(i)));103 expect(journal(w, 'sign.ok').at(-1)!.data).toMatchObject({ network: 'arbitrum', symbol: 'USDC', amount: '10000000', to: PIN.bridge, venueDeposit: 'hyperliquid', venueNetwork: 'mainnet', route: 'arbitrum-bridge2' });104 expect(w.signer.status().spentToday['arbitrum:USDC']).toBe('10000000');105 expect(verifyAuditChain(w.store.state.journal)).toBeNull();106 });107108 it('accepts exactly the floor and refuses anything below it (the venue would credit nothing)', async () => {109 const w = await setup();110 expect(await code(sign(w, deposit(w.t.now, { amount: '6000000' })))).toBe('ok');111 for (const amount of ['5999999', '5000000', '4000000', '1'])112 expect(await code(sign(w, deposit(w.t.now, { amount }))), amount).toMatch(/^venue_deposit: a deposit below 6 USDC is refused: the venue credits nothing under 5 USDC/);113 // a lower minimum written in the section does not lower the signer's floor114 expect(await code(sign(w, deposit(w.t.now, { amount: '5000000', venueDeposit: { ...venueDepositSection('mainnet'), minAmount: '5000000' } })))).toMatch(/^venue_deposit: the minimum shown for approval is not the one this signer enforces/);115 expect(journal(w, 'sign.ok')).toHaveLength(1);116 });117118 it('refuses another token, another network, another bridge, another source', async () => {119 const w = await setup(), t = w.t.now, bridged = NETWORKS.arbitrum!.tokens.find((x) => x.symbol !== 'USDC' && x.decimals === 6)!;120 const cases: [string, Partial<Intent>, RegExp][] = [121 ['bridged USDC.e', { asset: bridged.id, symbol: bridged.symbol }, /^venue_deposit: only native USDC can be deposited/],122 ['native ETH', { asset: 'native', symbol: 'ETH', decimals: 18, amount: '10000000000000000', evm: { nonce: 4, gasLimit: '21000', maxFeePerGas: '1000000000', maxPriorityFeePerGas: '1000000' } }, /^venue_deposit: only native USDC/],123 ['USDC on Base', { network: 'base', chainRef: '8453', asset: cctpUsdc('base').id }, /^venue_deposit: a Hyperliquid mainnet deposit is made on arbitrum only/],124 ['Arbitrum with another chain id', { chainRef: '8453' }, /^wrong_chain/],125 ['bridge of the section is not the pinned one', { to: OTHER, venueDeposit: { ...venueDepositSection('mainnet'), bridge: OTHER } }, /^venue_deposit: the destination is not the pinned bridge/],126 ['destination differs from the section', { to: OTHER }, /^venue_deposit: the destination is not the pinned bridge/],127 ['testnet venue network (no pinned route)', { venueDeposit: { ...venueDepositSection('mainnet'), venueNetwork: 'testnet' } }, /^venue_deposit: no deposit route is pinned for Hyperliquid testnet/],128 ['unknown route', { venueDeposit: { ...venueDepositSection('mainnet'), route: 'cctp-forwarder' } }, /^venue_deposit: unknown deposit route/],129 ['another source address', { from: OTHER }, /^wrong_source/],130 ['another key', { keyId: 'spw-0000000000000000' }, /^wrong_key/],131 ];132 for (const [label, over, re] of cases) expect(await code(sign(w, deposit(t, over))), label).toMatch(re);133 expect(journal(w, 'sign.ok')).toHaveLength(0);134 expect(journal(w, 'sign.denied')).toHaveLength(cases.length);135 });136137 it('refuses a plain transfer to a venue bridge (any network, any amount): the minimum cannot be bypassed by an ordinary send', async () => {138 const w = await setup(), t = w.t.now;139 const { venueDeposit: _s, ...plain } = deposit(t);140 expect(await code(sign(w, plain as Intent))).toMatch(/^venue_deposit: this destination is a venue bridge/);141 expect(await code(sign(w, { ...plain, amount: '1000000' } as Intent))).toMatch(/^venue_deposit: this destination is a venue bridge/);142 expect(await code(sign(w, { ...plain, to: '0x08cfc1B6b2dCF36A1480b99353A354AA8AC56f89' } as Intent))).toMatch(/^venue_deposit: this destination is a venue bridge/); // testnet bridge address143 expect(await code(sign(w, { ...plain, network: 'base', chainRef: '8453', asset: cctpUsdc('base').id } as Intent))).toMatch(/^venue_deposit: this destination is a venue bridge/); // same address on another chain144 expect(await code(sign(w, { ...plain, asset: 'native', symbol: 'ETH', decimals: 18, amount: '1000000000000000', evm: { ...plain.evm!, gasLimit: '21000' } } as Intent))).toMatch(/^venue_deposit: this destination is a venue bridge/);145 // an ordinary transfer elsewhere is untouched146 expect(await code(sign(w, { ...plain, to: OTHER } as Intent))).toBe('ok');147 });148149 it('applies the wallet limits of a send: per transfer, per day, fee ceiling, approval window, halt, allowlist', async () => {150 const w = await setup(), t = w.t.now;151 expect(w.store.policy.perTx['arbitrum:USDC']).toBe('50000000');152 expect(w.store.policy.daily['arbitrum:USDC']).toBe('100000000');153 expect(await code(sign(w, deposit(t, { amount: '50000001' })))).toMatch(/^policy: amount above per-transaction limit for arbitrum:USDC/);154 expect(await code(sign(w, deposit(t, { maxFee: '300000000000001' })))).toMatch(/^policy: fee ceiling above policy/);155 expect(await code(sign(w, deposit(t, { expiresAt: t + 301 })))).toMatch(/^policy: approval window too long/);156 expect(await code(sign(w, deposit(t, { createdAt: t - 300, expiresAt: t - 1 })))).toMatch(/^policy: approval window expired/);157 expect(await code(sign(w, deposit(t, { policyVersion: 2 })))).toMatch(/^policy: policy version changed/);158 expect(await code(sign(w, deposit(t, { amount: '50000000' })))).toBe('ok');159 expect(await code(sign(w, deposit(t, { amount: '44000000' })))).toBe('ok');160 expect(await code(sign(w, deposit(t, { amount: '6000001' })))).toMatch(/^policy: daily limit exceeded for arbitrum:USDC/); // 94 + 6.000001 > 100161 expect(await code(sign(w, deposit(t, { amount: '6000000' })))).toBe('ok');162 // a non-empty allowlist stays binding: the bridge must be listed like any destination163 w.store.policy = { ...w.store.policy, daily: { ...w.store.policy.daily, 'arbitrum:USDC': '500000000' }, allowlist: [`arbitrum:${OTHER}`] };164 expect(await code(sign(w, deposit(t)))).toMatch(/^policy: destination not in signer allowlist/);165 w.store.policy = { ...w.store.policy, allowlist: [`arbitrum:${OTHER}`, `arbitrum:${PIN.bridge}`] };166 expect(await code(sign(w, deposit(t)))).toBe('ok');167 w.signer.halt();168 expect(await code(sign(w, deposit(t)))).toMatch(/^policy: emergency halt is active/);169 });170171 it('needs the owner\'s passkey over this exact intent, once', async () => {172 const w = await setup(), t = w.t.now, i = deposit(t);173 expect(await code(w.signer.sign(i, undefined as never))).toMatch(/^bad_approval/);174 expect(await code(sign(w, i, w.pk.assert(intentHash(deposit(t, { amount: '11000000' })))))).toMatch(/^bad_approval/); // approval of another amount175 expect(await code(sign(w, i, w.pk.assert(intentHash({ ...i, venueDeposit: undefined, to: OTHER }))))).toMatch(/^bad_approval/); // approval of a plain send176 expect(await code(sign(w, i, new TestPasskey(RP, ORIGIN).assert(intentHash(i))))).toMatch(/^unknown_credential/);177 expect(await code(sign(w, i, w.pk.assert(intentHash(i), { origin: 'https://evil.test' })))).toMatch(/^bad_approval/);178 expect(await code(sign(w, i))).toBe('ok');179 expect(await code(sign(w, i))).toMatch(/^replay: proposal already signed/);180 expect(await code(w.restart().sign(i, w.pk.assert(intentHash(i))))).toMatch(/^replay/);181 expect(journal(w, 'sign.ok')).toHaveLength(1);182 });183184 it('checkVenueDepositIntent is the same rule for the API (pure)', () => {185 const i = deposit(1794370000);186 expect(checkVenueDepositIntent(i, ME)).toBe(PIN);187 expect(checkVenueDepositIntent({ ...i, venueDeposit: undefined, to: OTHER }, ME)).toBeNull();188 expect(() => checkVenueDepositIntent({ ...i, swap: {} }, ME)).toThrow(/neither a swap/);189 expect(() => checkVenueDepositIntent(i, OTHER)).toThrow(/wallet's own address/);190 });191});192193// ---------------------------------------------------------------------------------------------------------------------------194195const withdrawal = (now: number, o: Partial<VenueWithdrawal> = {}): VenueWithdrawal => ({196 v: 1, withdrawalId: uuid(), keyId: keys.keyId, venue: 'hyperliquid', network: 'mainnet', account: ACCOUNT, destination: ACCOUNT, amount: '12.3', fee: '1', time: now * 1000 + ++n, createdAt: now, expiresAt: now + 180, ...o });197const withdraw = (w: World, x: VenueWithdrawal, assertion = w.pk.assert(venueWithdrawalHash(x))) => w.signer.venueWithdraw(x, assertion);198199describe('venue withdrawal: canonical object', () => {200 const fixed: VenueWithdrawal = { v: 1, withdrawalId: '8b2c1f0e-8f5f-4b1e-9c56-0000000000bb', keyId: 'spw-0123456789abcdef', venue: 'hyperliquid', network: 'mainnet', account: ACCOUNT, destination: ACCOUNT,201 amount: '12.3', fee: '1', time: 1794370000123, createdAt: 1794370000, expiresAt: 1794370180 };202 it('has a pinned hash that every field changes, and a normal form for decimals', () => {203 expect(hex(venueWithdrawalHash(fixed))).toBe('e13a5ae262e2d6cbf292e782678453ce6b95994d2c91352741c50110da3cd4a5');204 expect(new TextDecoder().decode(venueWithdrawalBytes(fixed)).startsWith('SPW-VENUE-WITHDRAW-v1\0')).toBe(true);205 const patches: Partial<VenueWithdrawal>[] = [{ withdrawalId: '8b2c1f0e-8f5f-4b1e-9c56-0000000000bc' }, { keyId: 'spw-0123456789abcdee' }, { network: 'testnet' }, { account: OTHER.toLowerCase() }, { destination: OTHER.toLowerCase() },206 { amount: '12.31' }, { fee: '2' }, { time: 1794370000124 }, { createdAt: 1794370001 }, { expiresAt: 1794370181 }];207 const seen = new Set([hex(venueWithdrawalHash(fixed))]);208 for (const p of patches) seen.add(hex(venueWithdrawalHash({ ...fixed, ...p })));209 expect(seen.size).toBe(patches.length + 1);210 expect(() => venueWithdrawalHash({ ...fixed, destination: OTHER } as VenueWithdrawal)).toThrow(); // upper case: not canonical211 expect(() => venueWithdrawalHash({ ...fixed, amount: '1.0000001' })).toThrow();212 expect(() => venueWithdrawalHash({ ...fixed, extra: 1 } as never)).toThrow();213 });214215 it('builds exactly the SDK\'s withdraw3 typed data: official vectors, mainnet and testnet', async () => {216 // tests/signing_test.py::test_sign_withdraw_from_bridge_action (Testnet) and the cross-vectors made with the SDK itself (fixtures/hyperliquid/sdk-vectors.json)217 const dest = '0x5e9ee1089755c3435139848e47e6635505d5a13a';218 const a = buildVenueWithdraw({ ...fixed, network: 'testnet', account: dest, destination: dest, amount: '1', time: 1687816341423 }, dest);219 expect(a.action).toEqual({ type: 'withdraw3', hyperliquidChain: 'Testnet', signatureChainId: '0x66eee', destination: dest, amount: '1', time: 1687816341423 });220 expect(sameSig(await signWith(SDK_TEST_KEY, a.typedData), { r: '0x8363524c799e90ce9bc41022f7c39b4e9bdba786e5f9c72b20e43e1462c37cf9', s: '0x58b1411a775938b83e29182e8ef74975f9054c8e97ebf5ec2dc8d51bfc893881', v: 28 })).toBe(true);221 const vectors = (fixture('sdk-vectors.json') as { user: { kind: string; network: 'mainnet' | 'testnet'; action: { destination: string; amount: string; time: number; hyperliquidChain: string; signatureChainId: string }; signature: { r: string; s: string; v: number } }[] }).user.filter((v) => v.kind === 'withdraw3');222 expect(vectors.map((v) => v.network).sort()).toEqual(['mainnet', 'testnet']);223 for (const v of vectors) {224 const b = buildVenueWithdraw({ ...fixed, network: v.network, account: v.action.destination, destination: v.action.destination, amount: v.action.amount, time: v.action.time }, v.action.destination);225 expect(b.action).toEqual({ type: 'withdraw3', hyperliquidChain: v.action.hyperliquidChain, signatureChainId: v.action.signatureChainId, destination: v.action.destination, amount: v.action.amount, time: v.action.time });226 expect(b.nonce).toBe(v.action.time);227 expect(b.typedData.domain).toEqual({ name: 'HyperliquidSignTransaction', version: '1', chainId: 0x66eee, verifyingContract: '0x0000000000000000000000000000000000000000' });228 expect(sameSig(await signWith(SDK_TEST_KEY, b.typedData), v.signature), v.network).toBe(true);229 }230 expect(() => buildVenueWithdraw({ ...fixed, destination: OTHER.toLowerCase() }, ACCOUNT)).toThrow(/own address only/);231 expect(() => buildVenueWithdraw(fixed, OTHER)).toThrow(/own address only/);232 });233});234235describe('venue withdrawal: what the main key signs', () => {236 it('signs withdraw3 to the wallet\'s own address after the passkey approved the canonical withdrawal — recovered from the typed data', async () => {237 const w = await setup();238 for (const network of ['mainnet', 'testnet'] as const) {239 const x = withdrawal(w.t.now, { network, amount: '25.5' });240 const r = await withdraw(w, x);241 expect(r.action).toEqual({ type: 'withdraw3', hyperliquidChain: network === 'mainnet' ? 'Mainnet' : 'Testnet', signatureChainId: '0x66eee', destination: ACCOUNT, amount: '25.5', time: x.time });242 expect(r.nonce).toBe(x.time);243 expect(r.signer).toBe(ACCOUNT);244 expect(r.hash).toBe(hex(venueWithdrawalHash(x)));245 // an independent rebuild of the typed data from the returned action alone recovers the wallet address — what the venue does246 const td = buildWithdraw3TypedDataOnly({ network, destination: r.action.destination, amount: r.action.amount, time: r.action.time }).typedData;247 expect(await recoverTypedDataSigner(td, r.signature)).toBe(ACCOUNT);248 // and it is not valid for the other network's chain name249 const other = buildWithdraw3TypedDataOnly({ network: network === 'mainnet' ? 'testnet' : 'mainnet', destination: r.action.destination, amount: r.action.amount, time: r.action.time }).typedData;250 expect(await recoverTypedDataSigner(other, r.signature)).not.toBe(ACCOUNT);251 }252 expect(journal(w, 'venue.withdraw.ok').map((j) => j.data)).toMatchObject([{ network: 'mainnet', destination: ACCOUNT, amount: '25.5', fee: '1' }, { network: 'testnet', destination: ACCOUNT }]);253 expect(verifyAuditChain(w.store.state.journal)).toBeNull();254 });255256 it('with the SDK\'s test key as wallet key, the signature is the one the SDK computes', async () => {257 const sdk = privateKeyToAccount(SDK_TEST_KEY), own = sdk.address.toLowerCase();258 const k: Keys = { ...keys, evmPriv: Uint8Array.from(Buffer.from(SDK_TEST_KEY.slice(2), 'hex')), addresses: { ...keys.addresses, evm: sdk.address, arbitrum: sdk.address } };259 const w = await setup(k), x = withdrawal(w.t.now, { account: own, destination: own, amount: '12.3' });260 const r = await withdraw(w, x);261 const expected = await signWith(SDK_TEST_KEY, buildWithdraw3TypedDataOnly({ network: 'mainnet', destination: own, amount: '12.3', time: x.time }).typedData);262 expect(sameSig(r.signature, expected)).toBe(true);263 expect(r.signer).toBe('0x14791697260e4c9a71f18484c9f997b308e59325');264 });265266 it('refuses any destination but the wallet\'s own address, even approved by the passkey', async () => {267 const w = await setup(), t = w.t.now, other = OTHER.toLowerCase();268 expect(await code(withdraw(w, withdrawal(t, { destination: other })))).toMatch(/^wrong_destination: a withdrawal is paid to the wallet's own address only/);269 expect(await code(withdraw(w, withdrawal(t, { account: other, destination: other })))).toMatch(/^wrong_source/);270 expect(await code(withdraw(w, withdrawal(t, { account: other })))).toMatch(/^wrong_source/);271 expect(await code(withdraw(w, withdrawal(t, { keyId: 'spw-0000000000000000' })))).toMatch(/^wrong_key/);272 expect(await code(withdraw(w, { ...withdrawal(t), destination: ME } as VenueWithdrawal, w.pk.assert(new Uint8Array(32))))).toMatch(/^bad_request/); // not lower case: not canonical273 expect(journal(w, 'venue.withdraw.ok')).toHaveLength(0);274 expect(journal(w, 'venue.withdraw.denied').length).toBe(4);275 });276277 it('refuses an amount that is not above the fee, a wrong fee, a stale or future time, a long or expired window', async () => {278 const w = await setup(), t = w.t.now;279 const cases: [Partial<VenueWithdrawal>, RegExp][] = [280 [{ amount: '1' }, /^unsupported: the amount must be greater than the venue's withdrawal fee \(1 USDC\)/],281 [{ amount: '0.5' }, /^unsupported: the amount must be greater/],282 [{ fee: '0.5' }, /^unsupported: the fee shown is not the venue's documented withdrawal fee/],283 [{ fee: '2' }, /^unsupported: the fee shown/],284 [{ time: (t + 61) * 1000 }, /^policy: withdrawal time is dated in the future/],285 [{ time: (t - 301) * 1000 }, /^policy: withdrawal time is stale/],286 [{ createdAt: t - 400, expiresAt: t - 100 }, /^policy: approval window expired/],287 [{ expiresAt: t + 301 }, /^policy: approval window too long/],288 [{ createdAt: t + 61, expiresAt: t + 120 }, /^policy: withdrawal dated in the future/],289 ];290 for (const [o, re] of cases) expect(await code(withdraw(w, withdrawal(t, o))), JSON.stringify(o)).toMatch(re);291 for (const bad of [{ amount: '-5' }, { amount: '1e3' }, { amount: '0' }, { amount: '5.1234567' }, { network: 'Mainnet' }, { venue: 'dydx' }, { time: 1.5 }, { v: 2 }])292 expect(await code(withdraw(w, { ...withdrawal(t), ...bad } as never, w.pk.assert(new Uint8Array(32)))), JSON.stringify(bad)).toMatch(/^bad_request/);293 expect(await code(withdraw(w, withdrawal(t, { amount: '1.000001' })))).toBe('ok');294 expect(journal(w, 'venue.withdraw.ok')).toHaveLength(1);295 });296297 it('needs the owner\'s passkey over this exact withdrawal', async () => {298 const w = await setup(), t = w.t.now, x = withdrawal(t);299 expect(await code(w.signer.venueWithdraw(x, undefined))).toMatch(/^bad_approval: the owner's passkey approval is required/);300 expect(await code(withdraw(w, x, w.pk.assert(venueWithdrawalHash({ ...x, amount: '12.4' }))))).toMatch(/^bad_approval/);301 expect(await code(withdraw(w, x, w.pk.assert(venueWithdrawalHash({ ...x, network: 'testnet' }))))).toMatch(/^bad_approval/); // an approval for testnet does not sign for mainnet302 expect(await code(withdraw(w, x, w.pk.assert(venueWithdrawalHash({ ...x, time: x.time + 1 }))))).toMatch(/^bad_approval/);303 expect(await code(withdraw(w, x, new TestPasskey(RP, ORIGIN).assert(venueWithdrawalHash(x))))).toMatch(/^unknown_credential/);304 expect(await code(withdraw(w, x, w.pk.assert(venueWithdrawalHash(x), { uv: false })))).toMatch(/^bad_approval/);305 // an approval of a DEPOSIT intent or of a venue authorization is not an approval of a withdrawal306 expect(await code(withdraw(w, x, w.pk.assert(intentHash(deposit(t)))))).toMatch(/^bad_approval/);307 expect(journal(w, 'venue.withdraw.ok')).toHaveLength(0);308 expect(await code(withdraw(w, x))).toBe('ok');309 });310311 it('never signs the same withdrawal twice, nor an older time, across a restart; bounded per day; nothing under the halt', async () => {312 const w = await setup(), t = w.t.now, x = withdrawal(t);313 expect(await code(withdraw(w, x))).toBe('ok');314 expect(await code(withdraw(w, x))).toMatch(/^replay: withdrawal already signed/);315 const again = w.restart();316 expect(await code(again.venueWithdraw(x, w.pk.assert(venueWithdrawalHash(x))))).toMatch(/^replay/);317 const older = withdrawal(t, { time: x.time - 1 }), same = withdrawal(t, { time: x.time });318 expect(await code(again.venueWithdraw(older, w.pk.assert(venueWithdrawalHash(older))))).toMatch(/^stale_nonce/);319 expect(await code(again.venueWithdraw(same, w.pk.assert(venueWithdrawalHash(same))))).toMatch(/^stale_nonce/);320 // the other network has its own venue nonces321 const test = withdrawal(t, { network: 'testnet', time: x.time - 1 });322 expect(await code(again.venueWithdraw(test, w.pk.assert(venueWithdrawalHash(test))))).toBe('ok');323 const w2 = await setup();324 for (let k = 0; k < HL_WITHDRAWALS_PER_DAY; k++) expect(await code(withdraw(w2, withdrawal(w2.t.now)))).toBe('ok');325 expect(await code(withdraw(w2, withdrawal(w2.t.now)))).toMatch(/^policy: daily withdrawal count reached/);326 w2.t.now += 86400;327 expect(await code(withdraw(w2, withdrawal(w2.t.now)))).toBe('ok');328 w2.signer.halt();329 expect(await code(withdraw(w2, withdrawal(w2.t.now)))).toMatch(/^policy: emergency halt is active/);330 });331});332333describe('nothing else is reachable with the main key on the venue', () => {334 const ACK = jurisdictionAck('hyperliquid', 'fr').sha256;335 const user = (network: 'mainnet' | 'testnet', action: unknown, typedData: HlTypedData): SignRequest => ({ venue: 'hyperliquid', network, kind: 'hl_user', payload: { scheme: 'user', action, typedData } });336337 it('/venue/withdraw signs nothing it is handed: a smuggled action, typed data or destination is refused or ignored', async () => {338 const w = await setup(), t = w.t.now, x = withdrawal(t), other = OTHER.toLowerCase();339 const usdSend = { type: 'usdSend', hyperliquidChain: 'Mainnet', signatureChainId: '0x66eee', destination: other, amount: '12.3', time: x.time };340 const td = userSignedTypedData(HL_PRIMARY.usdSend, HL_USD_SEND_TYPES, { hyperliquidChain: 'Mainnet', destination: other, amount: '12.3', time: x.time }, '0x66eee');341 for (const extra of [{ action: usdSend }, { typedData: td }, { sign: user('mainnet', usdSend, td) }, { type: 'usdSend' }, { toPerp: true }, { agentAddress: other }, { hyperliquidChain: 'Mainnet' }, { signatureChainId: '0xa4b1' }, { vaultAddress: other }, { subaccount: other }])342 expect(await code(withdraw(w, { ...x, ...extra } as never, w.pk.assert(venueWithdrawalHash(x)))), JSON.stringify(extra).slice(0, 40)).toMatch(/^bad_request: malformed venue withdrawal/);343 expect(journal(w, 'venue.withdraw.ok')).toHaveLength(0);344 // what does get signed is withdraw3 and only withdraw3: the signature verifies for NO other user-signed action with the same fields345 const r = await withdraw(w, x);346 const asUsdSend = userSignedTypedData(HL_PRIMARY.usdSend, HL_USD_SEND_TYPES, { hyperliquidChain: 'Mainnet', destination: ACCOUNT, amount: '12.3', time: x.time }, '0x66eee');347 expect(await recoverTypedDataSigner(asUsdSend, r.signature)).not.toBe(ACCOUNT);348 expect(await recoverTypedDataSigner(buildWithdraw3TypedDataOnly({ network: 'mainnet', destination: ACCOUNT, amount: '12.3', time: x.time }).typedData, r.signature)).toBe(ACCOUNT);349 });350351 it('/venue/sign (trading key) still refuses withdraw3, usdSend, spotSend, usdClassTransfer and an approveAgent', async () => {352 const w = await setup(), t = w.t.now, other = OTHER.toLowerCase();353 const a: VenueAuthorization = { v: 1, authorizationId: uuid(), keyId: keys.keyId, venue: 'hyperliquid', network: 'mainnet', account: ACCOUNT, agentAddress: keys.venueKey('hyperliquid', 'mainnet').address, agentName: 'spw-live', mode: 'MANUAL_LIVE',354 policy: { allowedInstruments: ['hyperliquid:BTC'], maxLeverage: 3, maxOrderNotional: '200', maxPositionNotional: '400', maxGrossExposure: '600', maxOrdersPerDay: 5, maxPriceDeviationBps: 300 },355 createdAt: t, expiresAt: t + 30 * 86400, approveNonce: t * 1000, ackSha256: ACK };356 await w.signer.venueAuthorize(a, w.pk.assert(venueAuthorizationHash(a)));357 const nonce = t * 1000 + 7;358 const wd = buildWithdraw3TypedDataOnly({ network: 'mainnet', destination: other, amount: '5', time: nonce });359 const own = buildWithdraw3TypedDataOnly({ network: 'mainnet', destination: ACCOUNT, amount: '5', time: nonce });360 const xfer = buildUsdClassTransferTypedDataOnly({ network: 'mainnet', amount: '5', toPerp: false, nonce });361 const send = { type: 'usdSend', hyperliquidChain: 'Mainnet', signatureChainId: '0x66eee', destination: other, amount: '5', time: nonce };362 const sendTd = userSignedTypedData(HL_PRIMARY.usdSend, HL_USD_SEND_TYPES, { hyperliquidChain: 'Mainnet', destination: other, amount: '5', time: nonce }, '0x66eee');363 const spot = { type: 'spotSend', hyperliquidChain: 'Mainnet', signatureChainId: '0x66eee', destination: other, token: 'USDC:0x6d1e7cde53ba9467b783cb7c530ce054', amount: '5', time: nonce };364 const spotTd = userSignedTypedData('HyperliquidTransaction:SpotSend', [{ name: 'hyperliquidChain', type: 'string' }, { name: 'destination', type: 'string' }, { name: 'token', type: 'string' }, { name: 'amount', type: 'string' }, { name: 'time', type: 'uint64' }],365 { hyperliquidChain: 'Mainnet', destination: other, token: spot.token, amount: '5', time: nonce }, '0x66eee');366 const agent = buildApproveAgent({ network: 'mainnet', agentAddress: other, agentName: 'evil', nonce });367 const tryIt = (sign: SignRequest) => code(w.signer.venueSign({ authorizationId: a.authorizationId, sign, assertion: w.pk.assert(new Uint8Array(32)) }));368 expect(await tryIt(user('mainnet', wd.action, wd.typedData))).toMatch(/^unsupported: this payload is not an order.*withdraw3/);369 expect(await tryIt(user('mainnet', own.action, own.typedData))).toMatch(/^unsupported: this payload is not an order.*withdraw3/); // even to the own address: only /venue/withdraw, with its own passkey object370 expect(await tryIt(user('mainnet', xfer.action, xfer.typedData))).toMatch(/^unsupported: this payload is not an order.*usdClassTransfer/);371 expect(await tryIt(user('mainnet', send, sendTd))).toMatch(/^unsupported: this payload is not an order.*usdSend/);372 expect(await tryIt(user('mainnet', spot, spotTd))).toMatch(/^unsupported: this payload is not an order.*spotSend/);373 expect(await tryIt(user('mainnet', agent.action, agent.typedData))).toMatch(/^unsupported: an agent approval can only be signed through \/venue\/authorize/);374 // and a describe() of the pinned trading object calls every one of them `other`375 const trading = createHlTrading({ network: 'mainnet', assets: hlPinnedAssets('mainnet') });376 for (const [act, td] of [[wd.action, wd.typedData], [send, sendTd], [spot, spotTd], [xfer.action, xfer.typedData]] as const) expect(trading.describe(user('mainnet', act, td)).kind).toBe('other');377 expect(journal(w, 'venue.sign.ok')).toHaveLength(0);378 });379380 it('an approveAgent for another agent cannot pass through the withdrawal path, and the authorization path still names only the signer\'s own trading key', async () => {381 const w = await setup(), t = w.t.now;382 const a = { v: 1, authorizationId: uuid(), keyId: keys.keyId, venue: 'hyperliquid', network: 'mainnet', account: ACCOUNT, agentAddress: OTHER.toLowerCase(), agentName: 'spw-live', mode: 'MANUAL_LIVE',383 policy: { allowedInstruments: ['hyperliquid:BTC'], maxLeverage: 3, maxOrderNotional: '200', maxPositionNotional: '400', maxGrossExposure: '600', maxOrdersPerDay: 5, maxPriceDeviationBps: 300 },384 createdAt: t, expiresAt: t + 86400, approveNonce: t * 1000, ackSha256: ACK } as VenueAuthorization;385 expect(await code(w.signer.venueWithdraw(a, w.pk.assert(venueAuthorizationHash(a))))).toMatch(/^bad_request: malformed venue withdrawal/);386 expect(await code(w.signer.venueAuthorize(a, w.pk.assert(venueAuthorizationHash(a))))).toMatch(/^wrong_agent/);387 });388389 it('the HTTP surface has one collateral route; no transfer, send or generic typed-data route exists', async () => {390 const w = await setup(), secret = 's'.repeat(48);391 const srv = createSignerServer(w.signer, secret).listen(0, '127.0.0.1');392 await new Promise((r) => srv.once('listening', r));393 const url = `http://127.0.0.1:${(srv.address() as { port: number }).port}`;394 const post = async (path: string, body: unknown) => { const b = JSON.stringify(body); const r = await fetch(url + path, { method: 'POST', headers: { 'x-spw-auth': authHeader(secret, 'POST', path, b) }, body: b }); return { status: r.status, json: await r.json() as { error?: string; action?: { type: string; destination: string } } }; };395 for (const path of ['/venue/usdSend', '/venue/send', '/venue/transfer', '/venue/spotSend', '/venue/usdClassTransfer', '/venue/deposit', '/venue/sign-typed-data', '/venue/withdraw/other', '/sign-typed-data'])396 expect((await post(path, { withdrawal: withdrawal(w.t.now) })).status, path).toBe(404);397 const x = withdrawal(w.t.now);398 expect((await post('/venue/withdraw', { withdrawal: x })).json.error).toBe('bad_approval');399 expect((await post('/venue/withdraw', { withdrawal: { ...x, destination: OTHER.toLowerCase() }, assertion: w.pk.assert(venueWithdrawalHash({ ...x, destination: OTHER.toLowerCase() })) })).json.error).toBe('wrong_destination');400 const ok = await post('/venue/withdraw', { withdrawal: x, assertion: w.pk.assert(venueWithdrawalHash(x)) });401 expect(ok.status).toBe(200);402 expect(ok.json.action).toMatchObject({ type: 'withdraw3', destination: ACCOUNT });403 srv.close();404 });405406 it('source scan: the wallet key is used for the venue in exactly two places, and no other user-signed builder is imported by the signer', () => {407 const dir = new URL('../src/', import.meta.url);408 const src = Object.fromEntries(readdirSync(dir).filter((f) => f.endsWith('.ts')).map((f) => [f, readFileSync(new URL(f, dir), 'utf8')]));409 const uses = (f: string) => (src[f]!.match(/keys\.evmPriv/g) ?? []).length;410 expect(uses('venue.ts')).toBe(1); // approveAgent, inside venueAuthorize411 expect(uses('venue-collateral.ts')).toBe(1); // withdraw3, inside venueWithdraw412 expect(Object.keys(src).filter((f) => /evmPriv/.test(src[f]!)).sort()).toEqual(['keys.ts', 'signer.ts', 'venue-collateral.ts', 'venue.ts']);413 const code = Object.values(src).join('\n').replace(/\/\/[^\n]*|\/\*[\s\S]*?\*\//g, '');414 for (const forbidden of ['usdSend', 'spotSend', 'sendAsset', 'UsdClassTransfer', 'userSignedTypedData', 'buildWithdraw3TypedDataOnly', 'signTypedData', 'signMessage('])415 expect(code.includes(forbidden), forbidden).toBe(false);416 // the only two typed-data signatures of the main key: over what buildApproveAgent and buildVenueWithdraw produced417 expect(src['venue-collateral.ts']!).toMatch(/typedDataDigest\(built\.typedData\)/);418 expect(src['venue.ts']!).toMatch(/typedDataDigest\(\(built\.sign\.payload as HlUserSignPayload\)\.typedData\)/);419 });420});421