Repository Public
1// Full-stack swap test on a LOCAL FORK of Base: real API (HTTP layer, guards, KyberSwap, simulation), real signer process2// boundary (HMAC over HTTP), passkey approval, worker reconciliation — everything except the real network.3import { spawn } from 'node:child_process';4import { createHash } from 'node:crypto';5import { mkdtempSync } from 'node:fs';6import type { Server } from 'node:http';7import { tmpdir } from 'node:os';8import { join } from 'node:path';9import { generateMnemonic } from '@scure/bip39';10import { wordlist } from '@scure/bip39/wordlists/english.js';11import { encodeFunctionData, erc20Abi, formatUnits, hexToBigInt, numberToHex, parseUnits } from 'viem';12import { KYBER_ROUTER, NETWORKS } from '@spw/core';13import { TestPasskey } from '../tests/helpers/passkey.ts';14import { deriveKeys } from '../services/isolated-signer/src/keys.ts';15import { createSignerServer } from '../services/isolated-signer/src/server.ts';16import { Signer } from '../services/isolated-signer/src/signer.ts';17import { Store } from '../services/isolated-signer/src/store.ts';1819const FORK = 'http://127.0.0.1:8548', ORIGIN = 'https://wallet.test', RP = 'wallet.test', SECRET = 'f'.repeat(48);20const keys = deriveKeys(generateMnemonic(wordlist, 128)), ME = keys.addresses.evm;21const USDC = NETWORKS.base.tokens.find((t) => t.symbol === 'USDC')!.id, WHALE = process.env.USDC_WHALE ?? '0xF977814e90dA44bFA03b6295A0616a897441aceC';22let rid = 0;23const rpc = async <T>(method: string, params: unknown[] = []): Promise<T> => { const r = await fetch(FORK, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: ++rid, method, params }) }); const j = await r.json() as any; if (j.error) throw new Error(`${method}: ${j.error.message}`); return j.result; };24const bal = async (token: string) => token === 'native' ? hexToBigInt(await rpc<`0x${string}`>('eth_getBalance', [ME, 'latest'])) : hexToBigInt(await rpc<`0x${string}`>('eth_call', [{ to: token, data: encodeFunctionData({ abi: erc20Abi, functionName: 'balanceOf', args: [ME as `0x${string}`] }) }, 'latest']));25const anvil = spawn('anvil', ['--fork-url', process.env.BASE_RPC ?? 'https://mainnet.base.org', '--port', '8548', '--chain-id', '8453', '--silent'], { stdio: 'ignore' });26let signerSrv: Server | undefined, app: any, sql: any;27const cleanup = async () => { anvil.kill(); signerSrv?.close(); await app?.close().catch(() => {}); await sql?.end().catch(() => {}); };28try {29 for (let k = 0; k < 80; k++) { try { await rpc('eth_chainId'); break; } catch { await new Promise((r) => setTimeout(r, 250)); } }30 await rpc('anvil_setBalance', [ME, numberToHex(parseUnits('1', 18))]);31 await rpc('anvil_impersonateAccount', [WHALE]); await rpc('anvil_setBalance', [WHALE, numberToHex(parseUnits('1', 18))]);32 await rpc('eth_sendTransaction', [{ from: WHALE, to: USDC, data: encodeFunctionData({ abi: erc20Abi, functionName: 'transfer', args: [ME as `0x${string}`, 100_000000n] }) }]);33 await rpc('anvil_stopImpersonatingAccount', [WHALE]);3435 const dir = mkdtempSync(join(tmpdir(), 'spw-forkapi-'));36 const store = new Store(join(dir, 'state.json'), join(dir, 'policy.json'));37 store.state.setupTokenHash = createHash('sha256').update('fork-setup').digest('hex');38 // anvil reports a 1 gwei priority fee (about a thousand times Base's real one): raise the fee ceiling for this fork only39 store.policy = { ...store.policy, maxFee: { ...store.policy.maxFee, base: '5000000000000000' } };40 signerSrv = createSignerServer(new Signer(keys, store, { rpId: RP, origin: ORIGIN }), SECRET).listen(0, '127.0.0.1');41 await new Promise((r) => signerSrv!.once('listening', r));42 Object.assign(process.env, { SPW_ORIGIN: ORIGIN, SPW_RP_ID: RP, DATABASE_URL: 'postgres:///spw_fork', SPW_SIGNER_SECRET: SECRET, SPW_SIGNER_URL: `http://127.0.0.1:${(signerSrv.address() as { port: number }).port}`,43 SPW_BROADCAST: 'on', SPW_WORKERS: 'off', SPW_RPC_OVERRIDE: JSON.stringify({ base: [FORK, FORK] }) });44 const db = await import('../services/api/src/db.ts'); sql = db.sql;45 await sql.unsafe('drop schema public cascade; create schema public;'); await db.migrate();46 app = await (await import('../services/api/src/server.ts')).buildServer({ swapProviders: [] });47 const worker = await import('../services/api/src/worker.ts');48 await worker.refreshHealth(); // verifies chain id and token metadata on the fork, as at production start-up4950 const pk = new TestPasskey(RP, ORIGIN); let cookie = '';51 const api = async (method: string, url: string, body?: unknown, idem?: string) => { const r = await app.inject({ method, url, headers: { ...(cookie ? { cookie } : {}), ...(method !== 'GET' ? { origin: ORIGIN, 'x-spw-csrf': '1', 'content-type': 'application/json' } : {}), ...(idem ? { 'idempotency-key': idem } : {}) }, payload: body === undefined ? undefined : JSON.stringify(body) });52 return { status: r.statusCode, json: (() => { try { return r.json(); } catch { return null; } })(), setCookie: r.headers['set-cookie'] as string | undefined }; };53 const o = (await api('POST', '/api/v1/auth/setup/options', {})).json;54 const s = await api('POST', '/api/v1/auth/setup/verify', { challengeId: o.challengeId, response: pk.register(o.options.challenge), setupToken: 'fork-setup' });55 if (s.status !== 200) throw new Error(`setup failed: ${JSON.stringify(s.json)}`);56 cookie = String(s.setCookie).split(';')[0]!;57 console.log(`fork of Base, wallet ${ME}: ${formatUnits(await bal('native'), 18)} ETH, ${formatUnits(await bal(USDC), 6)} USDC`);5859 async function settle(id: string): Promise<any> {60 for (let k = 0; k < 120; k++) {61 await worker.reconcile(); await rpc('anvil_mine', [numberToHex(3)]);62 const p = (await api('GET', `/api/v1/transfers/proposals/${id}`)).json;63 if (['finalized', 'failed', 'replaced', 'expired'].includes(p.state)) return p;64 await new Promise((r) => setTimeout(r, 400));65 }66 throw new Error('swap did not settle');67 }68 async function swap(label: string, fromAsset: string, toAsset: string, amount: string) {69 const before = { src: await bal(fromAsset), dst: await bal(toAsset) };70 const prep = await api('POST', '/api/v1/swap/proposals', { network: 'base', fromAsset, toAsset, amount, slippageBps: 50 }, crypto.randomUUID());71 if (prep.status !== 200) throw new Error(`${label}: prepare failed ${prep.status} ${JSON.stringify(prep.json)}`);72 const p0 = prep.json.proposal;73 const ap = await api('POST', `/api/v1/transfers/proposals/${p0.id}/approve`, { assertion: pk.assert(Buffer.from(prep.json.options.challenge, 'base64url')) });74 if (ap.status !== 200) throw new Error(`${label}: approve failed ${ap.status} ${JSON.stringify(ap.json)}`);75 const p = await settle(p0.id);76 const after = { src: await bal(fromAsset), dst: await bal(toAsset) };77 console.log(`${label}: ${p.state}; quoted ${p.swap.expectedOutDisplay}, approved minimum ${p.swap.minOutDisplay}, received per router event ${p.swap.receivedDisplay} ${p.swap.toSymbol}; approval ${p.swap.approvalState ?? 'not needed'}; simulated before approval: ${p.swap.simulated}`);78 if (p.state !== 'finalized') throw new Error(`${label}: ended ${p.state}: ${p.error}`);79 if (p.swap.received === null || BigInt(p.swap.received) < BigInt(p.swap.minOut)) throw new Error(`${label}: received below the approved minimum`);80 if (toAsset !== 'native' && after.dst - before.dst !== BigInt(p.swap.received)) throw new Error(`${label}: balance change ${after.dst - before.dst} differs from the recorded outcome`);81 if (fromAsset !== 'native' && before.src - after.src !== BigInt(p.amount)) throw new Error(`${label}: spent ${before.src - after.src}, approved ${p.amount}`);82 return p;83 }84 await swap('ETH -> USDC', 'native', USDC, '0.01');85 await swap('USDC -> ETH ', USDC, 'native', '25'); // token input: exact approval mined first, swap simulated again, then sent86 const allowance = hexToBigInt(await rpc<`0x${string}`>('eth_call', [{ to: USDC, data: encodeFunctionData({ abi: erc20Abi, functionName: 'allowance', args: [ME as `0x${string}`, KYBER_ROUTER as `0x${string}`] }) }, 'latest']));87 if (allowance !== 0n) throw new Error('an allowance was left behind'); console.log('leftover allowance: 0');8889 // a compromised API/database swaps the calldata after the owner approved: the signer must refuse90 const prep = await api('POST', '/api/v1/swap/proposals', { network: 'base', fromAsset: 'native', toAsset: USDC, amount: '0.005', slippageBps: 50 }, crypto.randomUUID());91 const evil = (await api('POST', '/api/v1/swap/proposals', { network: 'base', fromAsset: 'native', toAsset: USDC, amount: '0.005', slippageBps: 50 }, crypto.randomUUID()));92 console.log('second swap while one is pending approval:', evil.status, evil.json.error ?? 'prepared');93 await sql`update proposals set aux = jsonb_set(aux, '{swapData}', to_jsonb(overlay(aux->>'swapData' placing 'ff' from length(aux->>'swapData') - 1))) where id = ${prep.json.proposal.id}`;94 const bad = await api('POST', `/api/v1/transfers/proposals/${prep.json.proposal.id}/approve`, { assertion: pk.assert(Buffer.from(prep.json.options.challenge, 'base64url')) });95 console.log('tampered calldata after approval:', bad.status, bad.json.message);96 if (bad.status === 200) throw new Error('signer accepted tampered calldata');97 const audit = (await api('GET', '/api/v1/security/audit?limit=200')).json;98 console.log(`audit chain intact: ${audit.intact}; swap events: ${audit.events.filter((e: any) => e.type.startsWith('swap.')).map((e: any) => e.type).join(', ')}`);99 console.log('\nFULL-STACK FORK TEST OK — nothing was sent to the real network');100} finally { await cleanup(); }101process.exit(0);102