SPB Git

Repository Public

derivatives-collateral.test.ts 39.8 KB 484 lines typescript

1// Collateral of a venue account, end to end inside the process: the REAL collateral service and its routes, the REAL isolated2// signer class (public test mnemonic, real WebAuthn assertions from a software passkey), PostgreSQL (database3// spw_deriv_collateral_test, created here if missing), the scripted fake of the Hyperliquid endpoints extended with the venue's4// ledger and the `withdraw3` action (it recovers the signer from the signature, like the venue), and a fake of the wallet's5// chain side. No network: a request to any real host fails the test; nothing is broadcast (SPW_BROADCAST=off).6import { createHash } from 'node:crypto';7import { mkdtempSync } from 'node:fs';8import { tmpdir } from 'node:os';9import { join } from 'node:path';10import fastify from 'fastify';11import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';12import { decodeFunctionData, erc20Abi, parseTransaction } from 'viem';13import { intentHash, type Intent } from '@spw/core';14import { HL_BRIDGE_PINS, recoverTypedDataSigner, venueWithdrawalHash, type HlBridgePin, type VenueWithdrawal } from '@spw/derivatives/live';15import { buildWithdraw3TypedDataOnly } from '../../../packages/derivatives/src/venues/hyperliquid/signing.ts';16import { TestPasskey } from '../../../tests/helpers/passkey.ts';17import { deriveKeys } from '../../isolated-signer/src/keys.ts';18import { Signer, SignerError } from '../../isolated-signer/src/signer.ts';19import { Store } from '../../isolated-signer/src/store.ts';20import { Clock, useDatabase } from './derivatives-helpers.ts';21import { FakeHl } from './hl-fake.ts';2223const RP = 'wallet.test', ORIGIN = 'https://wallet.test';24const keys = deriveKeys('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about');25const ME = keys.addresses.evm, ACCOUNT = ME.toLowerCase(), PIN = HL_BRIDGE_PINS.mainnet!, OTHER = '0x70997970c51812dc3a010c7d01b50e0d17dc79c8';2627let sql: any, lv: typeof import('../src/derivatives/live.ts'), co: typeof import('../src/derivatives/collateral.ts'), cr: typeof import('../src/derivatives/collateral-routes.ts'), UserError: typeof import('../src/config.ts').UserError;28type Svc = import('../src/derivatives/collateral.ts').CollateralService;29type Chain = import('../src/derivatives/collateral.ts').CollateralChain;3031beforeAll(async () => {32  await useDatabase('deriv_collateral');33  const db = await import('../src/db.ts');34  sql = db.sql;35  await db.migrate();36  await db.migrate();37  lv = await import('../src/derivatives/live.ts'); co = await import('../src/derivatives/collateral.ts'); cr = await import('../src/derivatives/collateral-routes.ts');38  UserError = (await import('../src/config.ts')).UserError;39});40beforeEach(async () => { await sql`truncate deriv_accounts, deriv_collateral, proposals, deposits`; });41afterAll(async () => { await sql?.end(); });4243type Fault = null | 'lost_before' | 'lost_after' | 'garbage_after';44/** the venue's ledger, account mode and withdraw3 on top of the scripted fake (shapes read live on mainnet, 2026-11-11) */45class VenueWithLedger {46  readonly fake: FakeHl;47  ledger = new Map<string, { time: number; hash: string; delta: Record<string, unknown> }[]>();48  abstraction = 'default';49  fault: Fault = null; faultAlways = false;50  usedNonces = new Set<number>();51  pending: { user: string; destination: string; net: string; time: number }[] = [];52  exchanges: any[] = [];53  constructor(fake: FakeHl) { this.fake = fake; }54  private entries(user: string) { const k = user.toLowerCase(); if (!this.ledger.has(k)) this.ledger.set(k, []); return this.ledger.get(k)!; }55  /** the venue credits a bridge deposit: ledger entry whose hash is the Arbitrum transaction, balance up */56  credit(user: string, txid: string, usdc: string): void {57    const u = this.fake.user(user); u.funded = true; u.usdc = String(Number(u.usdc) + Number(usdc));58    this.entries(user).push({ time: this.fake.now(), hash: txid, delta: { type: 'deposit', usdc: Number(usdc).toFixed(1).replace(/\.0$/, '.0') } });59  }60  /** the validators paid out: ledger entry `withdraw` (usdc = received, fee, nonce in µs); returns what arrived on the wallet */61  payout(k = 0): { txid: string; amount: string; seenAt: number } {62    const p = this.pending.splice(k, 1)[0]!, txid = `0x${createHash('sha256').update(`payout-${p.time}`).digest('hex')}`;63    this.entries(p.user).push({ time: this.fake.now(), hash: `0x${'ab'.repeat(32)}`, delta: { type: 'withdraw', usdc: p.net, nonce: p.time * 1000, fee: '1.0' } });64    return { txid, amount: String(Math.round(Number(p.net) * 1e6)), seenAt: this.fake.now() };65  }66  fetch = (async (url: string, init?: RequestInit): Promise<Response> => {67    const u = new URL(String(url)), body = JSON.parse(String(init?.body ?? '{}'));68    const json = (x: unknown) => new Response(JSON.stringify(x), { status: 200, headers: { 'content-type': 'application/json' } });69    if (u.host !== this.fake.host) throw new Error(`no fake venue for ${u.host}: the tests must never reach a real endpoint`);70    if (u.pathname === '/info' && body.type === 'userNonFundingLedgerUpdates') return json(this.entries(body.user).filter((e) => e.time >= body.startTime));71    if (u.pathname === '/info' && body.type === 'userAbstraction') return json(this.abstraction);72    if (u.pathname === '/info' && body.type === 'spotClearinghouseState') return json({ balances: this.abstraction === 'default' ? [] : [{ coin: 'USDC', token: 0, total: this.fake.user(body.user).usdc, hold: '0.0' }] });73    if (u.pathname === '/exchange' && body.action?.type === 'withdraw3') {74      this.exchanges.push(body);75      const fault = this.fault; if (!this.faultAlways) this.fault = null;76      const timeout = () => { const e = new Error('The operation was aborted due to timeout'); e.name = 'TimeoutError'; return e; };77      if (fault === 'lost_before') throw timeout();78      const answer = await this.withdraw(body);79      if (fault === 'lost_after') throw timeout();80      if (fault === 'garbage_after') return new Response('<html>upstream error</html>', { status: 200 });81      return json(answer);82    }83    return this.fake.fetch(url as never, init);84  }) as unknown as typeof fetch;85  private async withdraw(b: any): Promise<unknown> {86    const a = b.action, err = (response: string) => ({ status: 'err', response });87    if (Object.keys(a).join() !== 'type,hyperliquidChain,signatureChainId,destination,amount,time' || b.nonce !== a.time || b.vaultAddress !== null) return err(`fake: malformed withdraw3 ${JSON.stringify(b).slice(0, 400)}`);88    if (a.hyperliquidChain !== (this.fake.net === 'mainnet' ? 'Mainnet' : 'Testnet')) return err('fake: wrong chain');89    const td = buildWithdraw3TypedDataOnly({ network: this.fake.net, destination: a.destination, amount: a.amount, time: a.time, signatureChainId: a.signatureChainId }).typedData;90    const signer = await recoverTypedDataSigner(td, b.signature), u = this.fake.users.get(signer);91    if (!u?.funded) return err(`Must deposit before performing actions. User: ${signer}`);92    if (this.usedNonces.has(a.time)) return err('Invalid nonce: duplicate nonce');93    if (Number(u.usdc) < Number(a.amount)) return err('Insufficient balance for withdrawal');94    this.usedNonces.add(a.time);95    u.usdc = String(Math.round((Number(u.usdc) - Number(a.amount)) * 1e6) / 1e6);96    this.pending.push({ user: signer, destination: a.destination, net: String(Math.round((Number(a.amount) - 1) * 1e6) / 1e6), time: a.time });97    return { status: 'ok', response: { type: 'default' } };98  }99}100101function world() {102  const clock = new Clock(Date.parse('2026-11-11T12:00:00Z'));103  const t = new VenueWithLedger(new FakeHl('testnet', clock.now)), m = new VenueWithLedger(new FakeHl('mainnet', clock.now));104  const dir = mkdtempSync(join(tmpdir(), 'spw-coll-'));105  const store = new Store(join(dir, 'state.json'), join(dir, 'policy.json'));106  store.state.setupTokenHash = createHash('sha256').update('tok').digest('hex');107  const signer = new Signer(keys, store, { rpId: RP, origin: ORIGIN }, () => Math.floor(clock.now() / 1000), { get: async (s) => Object.fromEntries(s.map((k) => [k, 70_000])) });108  const pk = new TestPasskey(RP, ORIGIN);109  const call = async <T>(fn: () => T | Promise<T>): Promise<T> => { try { return await fn(); } catch (e) { if (e instanceof SignerError) throw new UserError(`signer_${e.code}`, e.message, 422); throw e; } };110  const seen = { sign: [] as unknown[], withdraw: [] as unknown[] };111  const port: import('../src/derivatives/collateral.ts').CollateralSignerPort = {112    status: () => call(() => signer.status()),113    sign: (i, a) => { seen.sign.push(i); return call(() => signer.sign(i, a as never)); },114    venueWithdraw: (x, a) => { seen.withdraw.push(x); return call(() => signer.venueWithdraw(x, a as never)); },115  };116  const flags = { mainnet: true };117  const chain = { usdc: 40_000_000n, native: 10n ** 15n, elsewhere: [] as { network: string; name: string; usdc: string }[], guard: null as null | (() => never), live: true, arrivals: [] as { txid: string; amount: string; seenAt: number }[], prepared: 0, guards: 0 };118  const fakeChain: Chain = {119    guards: async (pin: HlBridgePin) => { chain.guards++; expect(pin).toBe(PIN); if (chain.guard) chain.guard(); },120    liveness: async () => ({ ok: chain.live, checkedAt: clock.now(), checked: 3, credited: chain.live ? 3 : 0, samples: [], detail: chain.live ? '3 of the 3 most recent deposits into the bridge are in the venue\'s ledger' : 'none of the 3 most recent deposits into the bridge is in the venue\'s ledger' }),121    balances: async () => ({ usdc: chain.usdc, native: chain.native }),122    elsewhere: async () => chain.elsewhere,123    prepare: async (_pin, from, amount) => { chain.prepared++; expect(from).toBe(ME); expect(amount > 0n).toBe(true);124      return { section: { evm: { nonce: 7, gasLimit: '90000', maxFeePerGas: '100000000', maxPriorityFeePerGas: '1000000' } }, maxFee: 9_000_000_000_000n, feeEstimate: 4_500_000_000_000n, warnings: [], ttlSec: 180 }; },125    arrivals: async (_pin, _me, since) => chain.arrivals.filter((x) => x.seenAt >= since),126  };127  const routed = (async (url: string, init?: RequestInit) => { const host = new URL(String(url)).host; const f = [t, m].find((x) => x.fake.host === host); if (!f) throw new Error(`no fake venue for ${host}`); return f.fetch(url as never, init); }) as unknown as typeof fetch;128  const build = (): Svc => new co.CollateralService({ live: lv.createLive({ fetch: routed, signer: { ...port, venueKey: (v: string, n: string) => call(() => signer.venueKey(v, n)) } as never, now: clock.now, rpId: RP, mainnetEnabled: () => flags.mainnet,129    credentials: async () => [{ id: pk.id, transports: ['internal'] }], confirmDelayMs: 0 }), signer: port, chain: fakeChain });130  const w = { clock, t, m, signer, store, pk, port, seen, flags, chain, svc: build(), restart: () => { w.svc = build(); return w.svc; }, next: (ms = 61_000) => clock.advance(ms) };131  return w;132}133type World = ReturnType<typeof world>;134const refusal = async (p: Promise<unknown>) => { const e = await p.then(() => null, (x) => x); return e ? { code: e.code as string, status: e.status as number, message: e.message as string } : null; };135const audits = async (id: string) => (await sql`select type from audit_events where data->>'account' = ${id} and type like 'deriv.collateral.%' order by seq`).map((r: { type: string }) => r.type.replace('deriv.collateral.', '')) as string[];136137async function account(w: World, network: 'mainnet' | 'testnet' = 'mainnet', fund: string | null = null) {138  if (w.signer.status().credentials.length === 0) await w.signer.enroll({ credential: w.pk.credential(), setupToken: 'tok' });139  if (fund !== null) (network === 'mainnet' ? w.m : w.t).fake.fund(ACCOUNT, fund);140  const a = await w.svc.deps.live.createAccount({ venue: 'hyperliquid', network, label: 'test' });141  return a.id as string;142}143const proposal = async (opId: string) => (await sql`select p.* from proposals p join deriv_collateral c on c.proposal_id = p.id where c.id = ${opId}`)[0];144/** what the wallet's own worker does when the chain confirms / reverts the transfer (the worker is off in tests) */145const chainSays = async (opId: string, state: string, error: string | null = null) => { await sql`update proposals set state = ${state}, confirmations = 3, error = ${error} where id = (select proposal_id from deriv_collateral where id = ${opId})`; };146async function deposited(w: World, id: string, amount = '10') {147  const prep = await w.svc.prepareDeposit(id, { amount });148  const done = await w.svc.approveDeposit(id, prep.operation.id, w.pk.assert(intentHash(prep.intent)));149  return { prep, op: done.operation };150}151152describe('status: what the two sheets show', () => {153  it('names the pinned route, the minimum, who is credited, the wallet side and its limits', async () => {154    const w = world(), id = await account(w, 'mainnet', '25');155    const s = await w.svc.status(id);156    expect(s).toMatchObject({ account: ACCOUNT, network: 'mainnet', mainnetEnabled: true,157      route: { id: 'arbitrum-bridge2', network: 'arbitrum', networkName: 'Arbitrum One', bridge: PIN.bridge, asset: 'USDC', token: PIN.usdc, venueMinimum: '5', minimum: '6', expectedSeconds: 60, credits: 'sender', venueFee: '0', legacy: true, codeSha256: PIN.codeSha256 },158      withdrawal: { fee: '1', expectedSeconds: 240, destination: ACCOUNT, payoutNetworkName: 'Arbitrum One', arrivalTracked: true, available: '25' },159      wallet: { address: ME, network: 'arbitrum', usdc: '40', gasSymbol: 'ETH', limits: { perTransfer: '50', perDay: '100', spentToday: '0', maxNow: '40' }, elsewhere: [] },160      deposit: { available: true, reason: null }, withdraw: { available: true, reason: null }, operations: [] });161    expect(s.venueBalance).toMatchObject({ abstraction: 'default', equity: '25', withdrawable: '25', total: '25', error: null });162  });163164  it('says why nothing can be deposited: server setting, testnet, no USDC here but elsewhere, no gas', async () => {165    const w = world(), id = await account(w), tid = await account(w, 'testnet');166    w.flags.mainnet = false;167    expect((await w.svc.status(id))).toMatchObject({ deposit: { available: false, reason: 'mainnet_disabled' }, withdraw: { available: false, reason: 'mainnet_disabled' } });168    expect((await w.svc.status(tid))).toMatchObject({ route: null, wallet: null, deposit: { available: false, reason: 'no_route' }, withdraw: { available: true }, withdrawal: { payoutNetworkName: 'Arbitrum Sepolia', arrivalTracked: false } });169    w.flags.mainnet = true;170    w.chain.usdc = 2_000_000n; w.chain.elsewhere = [{ network: 'base', name: 'Base', usdc: '31.5' }];171    expect((await w.svc.status(id)).deposit).toEqual({ available: false, reason: 'usdc_elsewhere', bridgeFrom: [{ network: 'base', name: 'Base', usdc: '31.5' }] });172    w.chain.elsewhere = [];173    expect((await w.svc.status(id)).deposit).toMatchObject({ available: false, reason: 'no_usdc' });174    w.chain.usdc = 40_000_000n; w.chain.native = 0n;175    expect((await w.svc.status(id)).deposit).toMatchObject({ available: false, reason: 'no_gas' });176  });177178  it('reads the collateral where a "unified account" keeps it', async () => {179    const w = world(), id = await account(w, 'mainnet', '18.5');180    w.m.abstraction = 'unifiedAccount';181    const s = await w.svc.status(id);182    expect(s.venueBalance).toMatchObject({ abstraction: 'unifiedAccount', spotUsdc: '18.5', total: '18.5' });183    expect(s.withdrawal.available).toBe('18.5');184  });185});186187describe('deposit', () => {188  it('prepare → passkey → signed transfer to the pinned bridge → on-chain → waits for the venue → credited, never before', async () => {189    const w = world(), id = await account(w);190    const prep = await w.svc.prepareDeposit(id, { amount: '10' });191    expect(prep.intent).toMatchObject({ network: 'arbitrum', chainRef: '42161', asset: PIN.usdc, symbol: 'USDC', decimals: 6, amount: '10000000', from: ME, to: PIN.bridge,192      venueDeposit: { venue: 'hyperliquid', venueNetwork: 'mainnet', route: 'arbitrum-bridge2', bridge: PIN.bridge, minAmount: '6000000' } });193    expect(prep.options.challenge).toBe(Buffer.from(intentHash(prep.intent as Intent)).toString('base64url'));194    expect(prep.operation).toMatchObject({ kind: 'deposit', state: 'pending_approval', final: false, amount: '10', chainNetwork: 'arbitrum', bridge: PIN.bridge, balanceBefore: { total: '0' }, overdue: false });195    expect(prep.liveness.ok).toBe(true);196    expect(w.seen.sign).toHaveLength(0);197198    const done = await w.svc.approveDeposit(id, prep.operation.id, w.pk.assert(intentHash(prep.intent)));199    expect(done.operation).toMatchObject({ state: 'sent', final: false, proposalState: 'broadcast' });200    const p = await proposal(prep.operation.id);201    const tx = parseTransaction(p.raw), callData = decodeFunctionData({ abi: erc20Abi, data: tx.data! });202    expect([tx.chainId, tx.to?.toLowerCase(), callData.functionName, callData.args]).toEqual([42161, PIN.usdc.toLowerCase(), 'transfer', [PIN.bridge, 10_000_000n]]);203    expect(done.operation.txid).toBe(p.txid);204    expect(w.chain.guards).toBe(2); // at prepare and again at the moment of signing205206    // the chain confirms; the venue has not credited yet: the screen must wait207    await chainSays(prep.operation.id, 'confirming'); w.next();208    let op = (await w.svc.operation(id, prep.operation.id)).operation;209    expect(op).toMatchObject({ state: 'awaiting_credit', final: false, overdue: false, check: [], ledger: null, balanceAfter: null });210    // a deposit of the same amount by the same address in ANOTHER transaction is not ours211    w.m.ledger.set(ACCOUNT, [{ time: w.clock.now(), hash: `0x${'77'.repeat(32)}`, delta: { type: 'deposit', usdc: '10.0' } }]); w.next();212    expect((await w.svc.operation(id, prep.operation.id)).operation.state).toBe('awaiting_credit');213    // ten minutes later still nothing: it says what to check, and keeps polling214    w.next(co.DEPOSIT_OVERDUE_MS);215    op = (await w.svc.operation(id, prep.operation.id)).operation;216    expect(op).toMatchObject({ state: 'awaiting_credit', final: false, overdue: true, check: ['tx_success', 'tx_to_bridge', 'amount_min', 'venue_ledger', 'venue_status'] });217    // the venue credits: its ledger names our transaction218    w.m.credit(ACCOUNT, p.txid.toUpperCase().replace('0X', '0x'), '10'); w.next();219    const after = w.restart(); // a new process: the state is in the database220    expect((await after.step()).errors).toEqual([]);221    op = (await after.operation(id, prep.operation.id)).operation;222    expect(op).toMatchObject({ state: 'credited', final: true, overdue: false, error: null, ledger: { type: 'deposit', usdc: '10.0' }, balanceBefore: { total: '0' }, balanceAfter: { total: '10' } });223    expect(await audits(id)).toEqual(['deposit_prepared', 'deposit_signed', 'deposit_confirmed_onchain', 'deposit_credited']);224    expect((await after.status(id)).operations).toHaveLength(1);225  });226227  it('refuses before anything is signed: minimum, testnet, server setting, changed contract, unproven route, balance, limits, halt', async () => {228    const w = world(), id = await account(w), tid = await account(w, 'testnet');229    for (const amount of ['5.999999', '5', '1']) expect(await refusal(w.svc.prepareDeposit(id, { amount }))).toMatchObject({ code: 'below_minimum', status: 422, message: expect.stringMatching(/smallest deposit is 6 USDC.*credits nothing under 5 USDC/) });230    expect(await refusal(w.svc.prepareDeposit(tid, { amount: '10' }))).toMatchObject({ code: 'no_route', status: 422 });231    w.flags.mainnet = false;232    expect(await refusal(w.svc.prepareDeposit(id, { amount: '10' }))).toMatchObject({ code: 'mainnet_disabled', status: 403 });233    w.flags.mainnet = true;234    w.chain.guard = () => { throw new UserError('contract_changed', 'the bytecode of the venue bridge on Arbitrum One is not what was reviewed; deposits are refused until it is reviewed again', 503); };235    expect(await refusal(w.svc.prepareDeposit(id, { amount: '10' }))).toMatchObject({ code: 'contract_changed', status: 503 });236    w.chain.guard = () => { throw new UserError('bridge_paused', 'the venue bridge is locked', 503); };237    expect(await refusal(w.svc.prepareDeposit(id, { amount: '10' }))).toMatchObject({ code: 'bridge_paused' });238    w.chain.guard = null; w.chain.live = false;239    expect(await refusal(w.svc.prepareDeposit(id, { amount: '10' }))).toMatchObject({ code: 'route_not_proven', status: 503, message: expect.stringMatching(/none of the 3 most recent deposits/) });240    w.chain.live = true; w.chain.usdc = 8_000_000n; w.chain.elsewhere = [{ network: 'base', name: 'Base', usdc: '31.5' }];241    expect(await refusal(w.svc.prepareDeposit(id, { amount: '10' }))).toMatchObject({ code: 'insufficient_usdc', message: expect.stringMatching(/holds 8 USDC on Arbitrum One; it holds USDC on Base \(31\.5\): move it to Arbitrum One first with the cross-network transfer/) });242    w.chain.usdc = 400_000_000n;243    expect(await refusal(w.svc.prepareDeposit(id, { amount: '50.01' }))).toMatchObject({ code: 'policy', message: expect.stringMatching(/per-transaction limit/) });244    expect(w.chain.prepared).toBe(1);245    w.signer.halt();246    expect(await refusal(w.svc.prepareDeposit(id, { amount: '10' }))).toMatchObject({ code: 'halted', status: 423 });247    expect(w.seen.sign).toHaveLength(0);248    expect(await sql`select count(*)::int as n from deriv_collateral`).toEqual([{ n: 0 }]);249    expect(await sql`select count(*)::int as n from proposals`).toEqual([{ n: 0 }]);250  });251252  it('a wrong passkey, an expired window, a tampered intent: nothing is sent and the operation says so', async () => {253    const w = world(), id = await account(w);254    const a = await w.svc.prepareDeposit(id, { amount: '10' });255    expect(await refusal(w.svc.approveDeposit(id, a.operation.id, w.pk.assert(intentHash({ ...a.intent, amount: '11000000' }))))).toMatchObject({ code: 'signer_bad_approval', status: 422 });256    expect((await w.svc.operation(id, a.operation.id)).operation).toMatchObject({ state: 'failed', final: true, txid: null, error: expect.stringMatching(/the signer refused.*Nothing was signed or sent/) });257    expect((await proposal(a.operation.id)).state).toBe('failed');258    // the API's database is tampered with between review and approval: the signer still decides on what it is handed259    const b = await w.svc.prepareDeposit(id, { amount: '10' });260    const evil = { ...b.intent, to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' };261    await sql`update proposals set intent = ${sql.json(evil)} where id = ${b.operation.proposalId}`;262    expect(await refusal(w.svc.approveDeposit(id, b.operation.id, w.pk.assert(intentHash(evil as Intent))))).toMatchObject({ code: 'signer_venue_deposit', message: expect.stringMatching(/not the pinned bridge/) });263    const lowered = { ...b.intent, proposalId: b.intent.proposalId, amount: '5000000' };264    const c = await w.svc.prepareDeposit(id, { amount: '10' });265    await sql`update proposals set intent = ${sql.json({ ...lowered, proposalId: c.intent.proposalId })} where id = ${c.operation.proposalId}`;266    expect(await refusal(w.svc.approveDeposit(id, c.operation.id, w.pk.assert(intentHash({ ...lowered, proposalId: c.intent.proposalId } as Intent))))).toMatchObject({ code: 'signer_venue_deposit', message: expect.stringMatching(/below 6 USDC is refused/) });267    // expired268    const d = await w.svc.prepareDeposit(id, { amount: '10' });269    w.next(181_000);270    expect(await refusal(w.svc.approveDeposit(id, d.operation.id, w.pk.assert(intentHash(d.intent))))).toMatchObject({ code: 'expired', status: 410 });271    expect((await w.svc.operation(id, d.operation.id)).operation).toMatchObject({ state: 'expired', final: true, error: expect.stringMatching(/nothing left the wallet/) });272    expect(w.store.state.journal.filter((j) => j.type === 'sign.ok')).toHaveLength(0);273    // an approved operation cannot be approved twice, a cancelled one not at all274    const e = await w.svc.prepareDeposit(id, { amount: '10' });275    const f = await w.svc.prepareDeposit(id, { amount: '12' }); // replaces the one under review276    expect((await w.svc.operation(id, e.operation.id)).operation).toMatchObject({ state: 'cancelled', final: true });277    expect(await refusal(w.svc.approveDeposit(id, e.operation.id, w.pk.assert(intentHash(e.intent))))).toMatchObject({ code: 'bad_state', status: 409 });278    await w.svc.approveDeposit(id, f.operation.id, w.pk.assert(intentHash(f.intent)));279    expect(await refusal(w.svc.approveDeposit(id, f.operation.id, w.pk.assert(intentHash(f.intent))))).toMatchObject({ code: 'bad_state' });280    expect(await refusal(w.svc.prepareDeposit(id, { amount: '10' }))).toMatchObject({ code: 'deposit_in_progress', status: 409 });281    expect(await refusal(w.svc.cancel(id, f.operation.id))).toMatchObject({ code: 'bad_state' });282  });283284  it('a transfer that reverts or is replaced on-chain ends as failed: nothing reached the venue', async () => {285    const w = world(), id = await account(w);286    const { op } = await deposited(w, id);287    await chainSays(op.id, 'failed', 'transaction reverted on-chain'); w.next();288    expect((await w.svc.step()).errors).toEqual([]);289    expect((await w.svc.operation(id, op.id)).operation).toMatchObject({ state: 'failed', final: true, error: 'transaction reverted on-chain; the transfer did not happen on-chain: nothing reached the venue' });290    // and the next deposit is possible again291    await sql`update proposals set state = 'failed'`;292    expect((await w.svc.prepareDeposit(id, { amount: '10' })).operation.state).toBe('pending_approval');293  });294295  it('a credit that never appears stays "awaiting", across restarts, and is never reported as done', async () => {296    const w = world(), id = await account(w);297    const { op } = await deposited(w, id);298    await chainSays(op.id, 'finalized');299    for (let k = 0; k < 4; k++) { w.next(3_600_000); const s = w.restart(); expect((await s.step()).errors).toEqual([]); }300    const now = (await w.svc.operation(id, op.id)).operation;301    expect(now).toMatchObject({ state: 'awaiting_credit', final: false, overdue: true, balanceAfter: null, doneAt: null });302    expect((await w.svc.status(id)).operations[0]).toMatchObject({ state: 'awaiting_credit', overdue: true });303    expect(await audits(id)).toEqual(['deposit_prepared', 'deposit_signed', 'deposit_confirmed_onchain']);304  });305});306307describe('withdrawal', () => {308  const prepared = async (w: World, id: string, amount = '12.3') => w.svc.prepareWithdraw(id, { amount });309  const approve = (w: World, id: string, p: Awaited<ReturnType<typeof prepared>>) => w.svc.approveWithdraw(id, p.operation.id, w.pk.assert(venueWithdrawalHash(p.withdrawal)));310311  it('prepare → passkey → withdraw3 to the wallet\'s own address → venue → ledger and arrival on the wallet → completed', async () => {312    const w = world(), id = await account(w, 'mainnet', '30');313    const p = await prepared(w, id);314    expect(p.withdrawal).toMatchObject({ venue: 'hyperliquid', network: 'mainnet', account: ACCOUNT, destination: ACCOUNT, amount: '12.3', fee: '1' });315    expect(p.options.challenge).toBe(Buffer.from(venueWithdrawalHash(p.withdrawal)).toString('base64url'));316    expect(p).toMatchObject({ receive: '11.3', operation: { kind: 'withdraw', state: 'pending_approval', amount: '12.3', fee: '1', expectedReceive: '11.3', balanceBefore: { withdrawable: '30' } },317      whatIsSigned: { action: { type: 'withdraw3', hyperliquidChain: 'Mainnet', signatureChainId: '0x66eee', destination: ACCOUNT, amount: '12.3', time: p.withdrawal.time } } });318    expect(w.m.exchanges).toHaveLength(0);319320    const done = await approve(w, id, p);321    expect(done.operation).toMatchObject({ state: 'submitted', final: false, attempts: 1, error: null });322    expect(w.m.exchanges).toHaveLength(1);323    const sent = w.m.exchanges[0];324    expect(sent).toMatchObject({ action: { type: 'withdraw3', hyperliquidChain: 'Mainnet', signatureChainId: '0x66eee', destination: ACCOUNT, amount: '12.3', time: p.withdrawal.time }, nonce: p.withdrawal.time, vaultAddress: null, expiresAfter: null });325    expect(await recoverTypedDataSigner(buildWithdraw3TypedDataOnly({ network: 'mainnet', destination: ACCOUNT, amount: '12.3', time: p.withdrawal.time }).typedData, sent.signature)).toBe(ACCOUNT);326    expect(w.t.exchanges).toHaveLength(0);327328    // accepted by the venue is not "arrived": four minutes of validators, then the wallet's own scan must see the USDC329    w.next();330    expect((await w.svc.operation(id, p.operation.id)).operation).toMatchObject({ state: 'submitted', final: false, overdue: false, arrival: null, ledger: null });331    w.next(co.WITHDRAW_OVERDUE_MS);332    expect((await w.svc.operation(id, p.operation.id)).operation).toMatchObject({ state: 'submitted', overdue: true, check: ['venue_ledger', 'bridge_paused', 'wallet_scan', 'venue_status'] });333    // a transfer from the bridge of another amount is not this withdrawal334    w.chain.arrivals.push({ txid: `0x${'11'.repeat(32)}`, amount: '5000000', seenAt: w.clock.now() }); w.next();335    expect((await w.svc.operation(id, p.operation.id)).operation.state).toBe('submitted');336    const arrived = w.m.payout(); w.chain.arrivals.push(arrived); w.next();337    const after = w.restart();338    expect((await after.step()).errors).toEqual([]);339    expect((await after.operation(id, p.operation.id)).operation).toMatchObject({ state: 'completed', final: true, error: null, txid: arrived.txid, arrival: { txid: arrived.txid, amount: '11300000' },340      ledger: { type: 'withdraw', usdc: '11.3', fee: '1.0', nonce: p.withdrawal.time * 1000 }, balanceBefore: { withdrawable: '30' }, balanceAfter: { withdrawable: '17.7' } });341    expect(await audits(id)).toEqual(['withdraw_prepared', 'withdraw_signed', 'withdraw_submitted', 'withdraw_in_venue_ledger', 'withdraw_arrived', 'withdraw_completed']);342    expect(w.m.exchanges).toHaveLength(1);343  });344345  it('refuses before the passkey: fee, balance, server setting, halt; and the venue\'s own refusal is final and shown', async () => {346    const w = world(), id = await account(w, 'mainnet', '30');347    for (const amount of ['1', '0.5']) expect(await refusal(prepared(w, id, amount))).toMatchObject({ code: 'below_fee', status: 422 });348    expect(await refusal(prepared(w, id, '30.01'))).toMatchObject({ code: 'insufficient_collateral', message: expect.stringMatching(/reports 30 USDC withdrawable/) });349    w.flags.mainnet = false;350    expect(await refusal(prepared(w, id))).toMatchObject({ code: 'mainnet_disabled', status: 403 });351    w.flags.mainnet = true;352    const p = await prepared(w, id);353    w.flags.mainnet = false; // switched off between review and approval354    expect(await refusal(approve(w, id, p))).toMatchObject({ code: 'mainnet_disabled' });355    w.flags.mainnet = true;356    expect(w.seen.withdraw).toHaveLength(0);357    // the venue refuses (the balance went away meanwhile): final, with the venue's words, and nothing is retried358    w.m.fake.fund(ACCOUNT, '3');359    expect((await approve(w, id, p)).operation).toMatchObject({ state: 'rejected', final: true, error: 'the venue refused the withdrawal: Insufficient balance for withdrawal' });360    w.next(); await w.svc.step(); w.next(); await w.svc.step();361    expect(w.m.exchanges).toHaveLength(1);362    // an account the venue has never seen363    const w2 = world(), id2 = await account(w2, 'mainnet', null);364    expect(await refusal(prepared(w2, id2, '5'))).toMatchObject({ code: 'insufficient_collateral' });365    w2.signer.halt();366    expect(await refusal(prepared(w2, id2, '5'))).toMatchObject({ code: 'halted' });367  });368369  it('a wrong passkey, a tampered destination, an expired window: nothing reaches the venue', async () => {370    const w = world(), id = await account(w, 'mainnet', '30');371    const a = await prepared(w, id);372    expect(await refusal(w.svc.approveWithdraw(id, a.operation.id, w.pk.assert(venueWithdrawalHash({ ...a.withdrawal, amount: '13' }))))).toMatchObject({ code: 'signer_bad_approval' });373    expect((await w.svc.operation(id, a.operation.id)).operation).toMatchObject({ state: 'failed', final: true, error: expect.stringMatching(/the signer refused.*Nothing was sent to the venue/) });374    // the stored withdrawal is rewritten to pay someone else, and the passkey is even asked over the rewritten object375    const b = await prepared(w, id);376    const evil: VenueWithdrawal = { ...b.withdrawal, destination: OTHER };377    await sql`update deriv_collateral set request = ${sql.json({ withdrawal: evil })} where id = ${b.operation.id}`;378    expect(await refusal(w.svc.approveWithdraw(id, b.operation.id, w.pk.assert(venueWithdrawalHash(evil))))).toMatchObject({ code: 'signer_wrong_destination', message: expect.stringMatching(/own address only/) });379    const c = await prepared(w, id);380    w.next(181_000);381    expect(await refusal(approve(w, id, c))).toMatchObject({ code: 'expired', status: 410 });382    expect((await w.svc.operation(id, c.operation.id)).operation).toMatchObject({ state: 'expired', final: true });383    expect(w.m.exchanges).toHaveLength(0);384    expect(w.store.state.journal.filter((j) => j.type === 'venue.withdraw.ok')).toHaveLength(0);385    expect(w.store.state.journal.filter((j) => j.type === 'venue.withdraw.denied').length).toBeGreaterThanOrEqual(2);386  });387388  it('answer lost AFTER the venue accepted: unknown, the same bytes again, then the ledger and the wallet decide', async () => {389    const w = world(), id = await account(w, 'mainnet', '30');390    const p = await prepared(w, id);391    w.m.fault = 'lost_after';392    expect((await approve(w, id, p)).operation).toMatchObject({ state: 'unknown', final: false, attempts: 1, error: expect.stringMatching(/outcome is unknown/) });393    w.next(); await w.svc.step(); // re-sends the identical signed action: the venue says the nonce was used394    expect(w.m.exchanges).toHaveLength(2);395    expect(JSON.stringify(w.m.exchanges[1])).toBe(JSON.stringify(w.m.exchanges[0]));396    expect((await w.svc.operation(id, p.operation.id)).operation).toMatchObject({ state: 'unknown', final: false, attempts: 2, error: expect.stringMatching(/Invalid nonce: duplicate nonce.*first one may have been accepted/) });397    expect(w.m.pending).toHaveLength(1); // one withdrawal at the venue, not two398    const arrived = w.m.payout(); w.chain.arrivals.push(arrived); w.next();399    await w.restart().step();400    expect((await w.svc.operation(id, p.operation.id)).operation).toMatchObject({ state: 'completed', final: true, arrival: { amount: '11300000' }, ledger: { fee: '1.0' } });401  });402403  it('request lost BEFORE the venue: unknown, then the repeated request is accepted; unreadable answers three times and no trace: given up, never assumed', async () => {404    const w = world(), id = await account(w, 'mainnet', '30');405    const p = await prepared(w, id);406    w.m.fault = 'lost_before';407    expect((await approve(w, id, p)).operation.state).toBe('unknown');408    expect(w.m.pending).toHaveLength(0);409    w.next(); await w.svc.step();410    expect((await w.svc.operation(id, p.operation.id)).operation).toMatchObject({ state: 'submitted', attempts: 2, error: null });411    expect(w.m.pending).toHaveLength(1);412413    await sql`update deriv_collateral set final = true`; // the first part is out of the way (same account row: one wallet address)414    const w2 = world(), id2 = await account(w2, 'mainnet', '30');415    const q = await prepared(w2, id2);416    w2.m.fault = 'lost_before'; w2.m.faultAlways = true;417    expect((await approve(w2, id2, q)).operation.state).toBe('unknown');418    for (let k = 0; k < 6; k++) { w2.next(); await w2.restart().step(); }419    expect(w2.m.exchanges).toHaveLength(3); // bounded420    expect((await w2.svc.operation(id2, q.operation.id)).operation).toMatchObject({ state: 'unknown', final: false });421    w2.next(co.WITHDRAW_UNKNOWN_GIVE_UP_MS);422    await w2.svc.step();423    expect((await w2.svc.operation(id2, q.operation.id)).operation).toMatchObject({ state: 'rejected', final: true, error: expect.stringMatching(/30 minutes without any trace.*taken as not made/) });424    expect(w2.m.exchanges).toHaveLength(3);425  });426427  it('a process that died between the signature and the request sends the stored bytes after the restart; one that died while asking the signer sends nothing', async () => {428    const w = world(), id = await account(w, 'mainnet', '30');429    const p = await prepared(w, id);430    const s = await w.port.venueWithdraw(p.withdrawal, w.pk.assert(venueWithdrawalHash(p.withdrawal)));431    await sql`update deriv_collateral set state = 'signed', action = ${s.action as never}::json, signature = ${sql.json(s.signature)} where id = ${p.operation.id}`;432    await w.restart().step();433    expect((await w.svc.operation(id, p.operation.id)).operation).toMatchObject({ state: 'submitted', attempts: 1 });434    expect(w.m.exchanges).toHaveLength(1);435    await sql`update deriv_collateral set final = true where id = ${p.operation.id}`; // out of the way436    const q = await prepared(w, id, '5');437    await sql`update deriv_collateral set state = 'approved', updated_at = ${new Date(w.clock.now())} where id = ${q.operation.id}`;438    w.next(); await w.restart().step();439    expect((await w.svc.operation(id, q.operation.id)).operation).toMatchObject({ state: 'failed', final: true, error: expect.stringMatching(/interrupted while asking the signer; nothing was sent/) });440    expect(w.m.exchanges).toHaveLength(1);441  });442443  it('testnet: signed for the Testnet chain, sent to the testnet endpoint only, done when the venue\'s ledger shows it (no chain to watch)', async () => {444    const w = world(), id = await account(w, 'testnet', '100');445    w.flags.mainnet = false;446    const p = await prepared(w, id, '20');447    expect(p.whatIsSigned.action).toMatchObject({ hyperliquidChain: 'Testnet', destination: ACCOUNT });448    expect((await approve(w, id, p)).operation).toMatchObject({ state: 'submitted', arrivalTracked: false });449    expect([w.t.exchanges.length, w.m.exchanges.length]).toEqual([1, 0]);450    w.t.payout(); w.next(); await w.svc.step();451    expect((await w.svc.operation(id, p.operation.id)).operation).toMatchObject({ state: 'completed', final: true, arrival: null, txid: null, ledger: { usdc: '19', fee: '1.0' } });452  });453});454455describe('routes', () => {456  it('are under …/accounts/:id/collateral/, validate their input, and map refusals to their status', async () => {457    const w = world(), id = await account(w, 'mainnet', '30');458    const app = fastify();459    app.setErrorHandler((err, _req, reply) => { const e = err as Error & { code?: string; status?: number }; return e instanceof UserError ? reply.status(e.status).send({ error: e.code, message: e.message }) : reply.status(e.name === 'ZodError' ? 400 : 500).send({ error: e.name }); });460    cr.collateralRoutes(app, w.svc);461    const P = `/api/v1/derivatives/live/accounts/${id}/collateral`;462    const post = (path: string, payload?: unknown) => app.inject({ method: 'POST', url: P + path, payload: payload as never });463    const st = await app.inject({ method: 'GET', url: `${P}/status` });464    expect(st.statusCode).toBe(200);465    expect(st.json()).toMatchObject({ account: ACCOUNT, route: { bridge: PIN.bridge }, deposit: { available: true } });466    for (const bad of [{ amount: '1e3' }, { amount: '-5' }, { amount: '5.1234567' }, { amount: '10', to: OTHER }, { amount: '10', destination: OTHER }, {}]) {467      expect((await post('/deposit/prepare', bad)).statusCode, JSON.stringify(bad)).toBe(400);468      expect((await post('/withdraw/prepare', bad)).statusCode, JSON.stringify(bad)).toBe(400);469    }470    expect((await post('/deposit/prepare', { amount: '5' })).json()).toMatchObject({ error: 'below_minimum' });471    const d = await post('/deposit/prepare', { amount: '10' });472    expect(d.statusCode).toBe(200);473    const op = d.json().operation.id as string;474    expect((await post(`/deposit/${op}/approve`, {})).statusCode).toBe(400);475    expect((await post(`/withdraw/${op}/approve`, { assertion: {} })).statusCode).toBe(404); // a deposit is not a withdrawal476    expect((await app.inject({ method: 'GET', url: `${P}/operations/${op}` })).json().operation).toMatchObject({ id: op, state: 'pending_approval' });477    expect((await post(`/operations/${op}/cancel`)).json().operation).toMatchObject({ state: 'cancelled', final: true });478    expect((await app.inject({ method: 'GET', url: `/api/v1/derivatives/live/accounts/${'0'.repeat(8)}-0000-4000-8000-${'0'.repeat(12)}/collateral/status` })).statusCode).toBe(404);479    const routes = app.printRoutes({ commonPrefix: false });480    for (const forbidden of ['send', 'transfer', 'usd', 'destination']) expect(routes.includes(forbidden), forbidden).toBe(false);481    await app.close();482  });483});484
Files main
Menu

Theme