SPB Git

Repository Public

venue-collateral.test.ts 36.5 KB 421 lines typescript

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
Files main
Menu

Theme