Repository Public
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