Repository Public
1import { createHash } from 'node:crypto';2import { mkdtempSync } from 'node:fs';3import type { Server } from 'node:http';4import { tmpdir } from 'node:os';5import { join } from 'node:path';6import { createTestDb } from './test-db.ts';7import { afterAll, beforeAll, describe, expect, it } from 'vitest';8import { decodeFunctionData, erc20Abi, parseTransaction, recoverTransactionAddress } from 'viem';9import { NETWORKS } from '@spw/core';10import { TestPasskey } from '../../../tests/helpers/passkey.ts';11import { deriveKeys } from '../../isolated-signer/src/keys.ts';12import { createSignerServer } from '../../isolated-signer/src/server.ts';13import { Signer } from '../../isolated-signer/src/signer.ts';14import { Store } from '../../isolated-signer/src/store.ts';1516const ORIGIN = 'https://wallet.test', RP = 'wallet.test', SECRET = 'k'.repeat(48);17const keys = deriveKeys('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about');18const owner = new TestPasskey(RP, ORIGIN), attacker = new TestPasskey(RP, ORIGIN);19let signerSrv: Server, app: any, sql: any, adapters: any, cookie = '';20const swapSeen: any[] = [];21const USDC = NETWORKS.base.tokens[0]!.id, DEST = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8';2223async function api(method: string, path: string, body?: unknown, opts: { cookie?: string | null; origin?: string | null; csrf?: boolean; idem?: string } = {}) {24 const headers: Record<string, string> = {};25 const c = opts.cookie === undefined ? cookie : opts.cookie;26 if (c) headers.cookie = c;27 if (method !== 'GET') { if (opts.origin !== null) headers.origin = opts.origin ?? ORIGIN; if (opts.csrf !== false) headers['x-spw-csrf'] = '1'; headers['content-type'] = 'application/json'; }28 if (opts.idem) headers['idempotency-key'] = opts.idem;29 const r = await app.inject({ method, url: path, headers, payload: body === undefined ? undefined : JSON.stringify(body) });30 return { status: r.statusCode, json: (() => { try { return r.json(); } catch { return null; } })(), setCookie: r.headers['set-cookie'] as string | undefined, headers: r.headers };31}3233beforeAll(async () => {34 const dir = mkdtempSync(join(tmpdir(), 'spw-api-'));35 const store = new Store(join(dir, 'state.json'), join(dir, 'policy.json'));36 store.state.setupTokenHash = createHash('sha256').update('the-setup-token').digest('hex');37 signerSrv = createSignerServer(new Signer(keys, store, { rpId: RP, origin: ORIGIN }), SECRET).listen(0, '127.0.0.1');38 await new Promise((r) => signerSrv.once('listening', r));39 Object.assign(process.env, { SPW_ORIGIN: ORIGIN, SPW_RP_ID: RP, DATABASE_URL: (await createTestDb('api')).url, SPW_SIGNER_SECRET: SECRET,40 SPW_SIGNER_URL: `http://127.0.0.1:${(signerSrv.address() as { port: number }).port}`, SPW_BROADCAST: 'off', SPW_WORKERS: 'off' });41 const db = await import('../src/db.ts');42 sql = db.sql;43 await sql.unsafe('drop schema public cascade; create schema public;');44 await db.migrate();45 adapters = (await import('../src/chains/index.ts')).adapters;46 // Deterministic chain data: the network is never contacted in this suite.47 adapters.base.prepare = async () => ({ section: { evm: { nonce: 7, gasLimit: '90000', maxFeePerGas: '1000000000', maxPriorityFeePerGas: '1000000' } }, maxFee: 90_000_000_000_000n, feeEstimate: 50_000_000_000_000n, warnings: [], ttlSec: 120 });48 // two scripted swap providers; the second answers for an attacker's address and must be dropped49 const fake = (id: string, bps: bigint, recipient?: string) => ({ id, name: id,50 capabilities: () => ({ networks: ['base'], crossChain: false, needsKey: false, configured: true, kinds: ['swap' as const], docs: '' }),51 supportsPair: (a: any, b: any) => a.network === 'base' && b.network === 'base',52 quote: async (r: any) => { swapSeen.push(r); const out = (r.amountIn * 2000n * (10_000n - bps)) / 10_000n / 10n ** 12n;53 return [{ id: `${id}-${swapSeen.length}`, provider: id, request: recipient ? { ...r, recipient } : r, steps: [{ kind: 'swap' as const, provider: id, from: r.from, to: r.to, via: ['pool'], contracts: [] }],54 amountOut: out, minAmountOut: (out * 995n) / 1000n, fees: [], estimatedSeconds: 12, priceImpactBps: 4, expiresAt: Date.now() + 30_000, firmness: 'indicative' as const,55 executability: { class: 'opaque' as const, reason: 'test' }, refundable: true, atomic: true }]; } });56 app = await (await import('../src/server.ts')).buildServer({ swapProviders: [fake('alpha', 10n), fake('beta', 30n), fake('evil', 0n, '0x000000000000000000000000000000000000dEaD')] });57});58afterAll(async () => { await app?.close(); signerSrv?.close(); await sql?.end(); });5960describe('access control', () => {61 it('everything is closed without a session; CSRF is enforced', async () => {62 for (const p of ['/api/v1/portfolio', '/api/v1/accounts', '/api/v1/activity', '/api/v1/security/overview', '/api/v1/security/audit', '/api/v1/settings', '/api/v1/health/ready'])63 expect((await api('GET', p, undefined, { cookie: null })).status).toBe(401);64 expect((await api('POST', '/api/v1/transfers/proposals', {}, { cookie: null })).status).toBe(401);65 expect((await api('POST', '/api/v1/security/emergency-halt', {}, { cookie: null })).status).toBe(401);66 expect((await api('POST', '/api/v1/auth/login/options', {}, { cookie: null, origin: 'https://evil.example' })).status).toBe(403);67 expect((await api('POST', '/api/v1/auth/login/options', {}, { cookie: null, csrf: false })).status).toBe(403);68 const live = await api('GET', '/api/v1/health/live', undefined, { cookie: null });69 expect(live.status).toBe(200);70 expect(String(live.headers['content-security-policy'])).toContain("script-src 'self'");71 expect((await api('POST', '/api/v1/sign', {}, { cookie: null })).status).toBe(401);72 });7374 it('setup needs the signer token, works once, then closes forever', async () => {75 expect((await api('GET', '/api/v1/auth/state', undefined, { cookie: null })).json).toEqual({ setupRequired: true, authenticated: false });76 let o = (await api('POST', '/api/v1/auth/setup/options', {}, { cookie: null })).json;77 const bad = await api('POST', '/api/v1/auth/setup/verify', { challengeId: o.challengeId, response: attacker.register(o.options.challenge), setupToken: 'wrong-token' }, { cookie: null });78 expect(bad.status).toBe(422);79 o = (await api('POST', '/api/v1/auth/setup/options', {}, { cookie: null })).json;80 const ok = await api('POST', '/api/v1/auth/setup/verify', { challengeId: o.challengeId, response: owner.register(o.options.challenge), setupToken: 'the-setup-token' }, { cookie: null });81 expect(ok.status).toBe(200);82 expect(String(ok.setCookie)).toMatch(/__Host-spw=.*HttpOnly; Secure; SameSite=Strict/);83 expect((await api('POST', '/api/v1/auth/setup/options', {}, { cookie: null })).status).toBe(403);84 });8586 it('only the enrolled passkey can log in', async () => {87 let o = (await api('POST', '/api/v1/auth/login/options', {}, { cookie: null })).json;88 const no = await api('POST', '/api/v1/auth/login/verify', { challengeId: o.challengeId, response: attacker.assert(Buffer.from(o.options.challenge, 'base64url')) }, { cookie: null });89 expect(no.status).toBe(401);90 o = (await api('POST', '/api/v1/auth/login/options', {}, { cookie: null })).json;91 const stale = owner.assert(Buffer.from(o.options.challenge, 'base64url'));92 const yes = await api('POST', '/api/v1/auth/login/verify', { challengeId: o.challengeId, response: stale }, { cookie: null });93 expect(yes.status).toBe(200);94 cookie = String(yes.setCookie).split(';')[0]!;95 expect((await api('POST', '/api/v1/auth/login/verify', { challengeId: o.challengeId, response: stale }, { cookie: null })).status).toBe(401); // challenge is single-use96 const acc = await api('GET', '/api/v1/accounts');97 expect(acc.status).toBe(200);98 expect(acc.json.accounts.find((a: any) => a.network === 'base').address).toBe(keys.addresses.evm);99 expect(JSON.stringify(acc.json)).not.toMatch(/abandon|priv/i);100 });101});102103describe('transfer lifecycle', () => {104 const draft = (amount = '12.5', idem = crypto.randomUUID()) => api('POST', '/api/v1/transfers/proposals', { network: 'base', asset: USDC, to: DEST, amount }, { idem });105106 it('validates input and applies policy before anything is drafted', async () => {107 expect((await api('POST', '/api/v1/transfers/proposals', { network: 'base', asset: USDC, to: DEST, amount: '1' })).status).toBe(400); // no idempotency key108 expect((await draft('0')).status).toBe(400);109 expect((await draft('1.0000001')).status).toBe(400);110 expect((await draft('50.000001')).json.error).toBe('policy');111 const badAddr = await api('POST', '/api/v1/transfers/proposals', { network: 'base', asset: USDC, to: 'HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk', amount: '1' }, { idem: crypto.randomUUID() });112 expect(badAddr.json.error).toBe('bad_address');113 const badAsset = await api('POST', '/api/v1/transfers/proposals', { network: 'base', asset: '0xdAC17F958D2ee523a2206206994597C13D831ec7', to: DEST, amount: '1' }, { idem: crypto.randomUUID() });114 expect(badAsset.json.error).toBe('unsupported_asset');115 });116117 it('is idempotent', async () => {118 const k = crypto.randomUUID();119 const a = await draft('3', k), b = await draft('3', k);120 expect(a.status).toBe(200);121 expect(b.json.id).toBe(a.json.id);122 expect(a.json.warnings).toContain('new_destination');123 });124125 it('a session alone cannot move funds; approval for another intent is refused by the signer', async () => {126 const d = (await draft('5')).json;127 const ra = (await api('POST', `/api/v1/transfers/proposals/${d.id}/request-approval`, {})).json;128 expect(ra.proposal.state).toBe('pending_approval');129 expect(Buffer.from(ra.options.challenge, 'base64url').toString('hex')).toBe(ra.proposal.intentHash);130 const none = await api('POST', `/api/v1/transfers/proposals/${d.id}/approve`, {});131 expect(none.status).toBe(400);132 expect((await api('GET', `/api/v1/transfers/proposals/${d.id}`)).json.state).toBe('pending_approval');133 expect((await api('GET', `/api/v1/transfers/proposals/${d.id}`)).json.txid).toBeNull();134135 const d2 = (await draft('5')).json;136 await api('POST', `/api/v1/transfers/proposals/${d2.id}/request-approval`, {});137 const wrong = await api('POST', `/api/v1/transfers/proposals/${d2.id}/approve`, { assertion: owner.assert(Buffer.from(ra.options.challenge, 'base64url')) });138 expect(wrong.status).toBe(422);139 const d3 = (await draft('5')).json;140 const ra3 = (await api('POST', `/api/v1/transfers/proposals/${d3.id}/request-approval`, {})).json;141 const rogue = await api('POST', `/api/v1/transfers/proposals/${d3.id}/approve`, { assertion: attacker.assert(Buffer.from(ra3.options.challenge, 'base64url')) });142 expect(rogue.status).toBe(422);143 });144145 it('signs exactly what was approved, and only once', async () => {146 const d = (await draft('12.5')).json;147 const ra = (await api('POST', `/api/v1/transfers/proposals/${d.id}/request-approval`, {})).json;148 const assertion = owner.assert(Buffer.from(ra.options.challenge, 'base64url'));149 const ok = await api('POST', `/api/v1/transfers/proposals/${d.id}/approve`, { assertion });150 expect(ok.status).toBe(200);151 expect(ok.json.state).toBe('broadcast');152 const [row] = await sql`select raw, txid from proposals where id = ${d.id}`;153 const tx = parseTransaction(row.raw);154 expect(await recoverTransactionAddress({ serializedTransaction: row.raw })).toBe(keys.addresses.evm);155 expect(tx.chainId).toBe(8453);156 expect(tx.nonce).toBe(7);157 expect(tx.to!.toLowerCase()).toBe(USDC.toLowerCase());158 expect(decodeFunctionData({ abi: erc20Abi, data: tx.data! }).args).toEqual([DEST, 12_500_000n]);159 expect((await api('POST', `/api/v1/transfers/proposals/${d.id}/approve`, { assertion })).status).toBe(409);160 expect(JSON.stringify(ok.json)).not.toContain(row.raw);161 });162163 it('refuses a second transfer on a network while the first is still unconfirmed', async () => {164 // the previous test left a proposal in "broadcast" (test mode never confirms it)165 const d = (await draft('1')).json;166 const blocked = await api('POST', `/api/v1/transfers/proposals/${d.id}/request-approval`, {});167 expect(blocked.status).toBe(409);168 expect(blocked.json.error).toBe('pending_tx');169 await sql`update proposals set state = 'finalized' where state = 'broadcast'`; // the chain confirmed it170 expect((await api('POST', `/api/v1/transfers/proposals/${d.id}/request-approval`, {})).status).toBe(200);171 await api('POST', `/api/v1/transfers/proposals/${d.id}/reject`, {});172 });173174 it('reject, tampered stored intent, daily limit and emergency halt all fail closed', async () => {175 const r = (await draft('1')).json;176 expect((await api('POST', `/api/v1/transfers/proposals/${r.id}/reject`, {})).json.state).toBe('rejected');177 expect((await api('POST', `/api/v1/transfers/proposals/${r.id}/request-approval`, {})).status).toBe(409);178179 // a compromised API/database swaps the destination after the owner approved the original180 const t = (await draft('2')).json;181 const ra = (await api('POST', `/api/v1/transfers/proposals/${t.id}/request-approval`, {})).json;182 await sql`update proposals set intent = jsonb_set(intent, '{to}', '"0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"') where id = ${t.id}`;183 const tampered = await api('POST', `/api/v1/transfers/proposals/${t.id}/approve`, { assertion: owner.assert(Buffer.from(ra.options.challenge, 'base64url')) });184 expect(tampered.status).toBe(422);185186 for (const amt of ['50', '37.5']) { // 12.5 already signed today → reaches the 100 USDC daily limit187 const d = (await draft(amt)).json;188 const a = (await api('POST', `/api/v1/transfers/proposals/${d.id}/request-approval`, {})).json;189 expect((await api('POST', `/api/v1/transfers/proposals/${d.id}/approve`, { assertion: owner.assert(Buffer.from(a.options.challenge, 'base64url')) })).status).toBe(200);190 await sql`update proposals set state = 'finalized' where state = 'broadcast'`;191 }192 expect((await draft('0.01')).json.error).toBe('policy');193194 expect((await api('POST', '/api/v1/security/emergency-halt', {})).status).toBe(200);195 expect((await api('GET', '/api/v1/security/overview')).json.signer.halted).toBe(true);196 expect((await draft('0.01')).json.error).toBe('policy');197 });198199 it('audit trail is hash-chained, append-only, and free of secrets', async () => {200 const a = (await api('GET', '/api/v1/security/audit?limit=500')).json;201 expect(a.intact).toBe(true);202 expect(a.total).toBeGreaterThan(15);203 await expect(sql`update audit_events set type = 'x' where seq = 1`).rejects.toThrow(/append-only/);204 await expect(sql`delete from audit_events`).rejects.toThrow(/append-only/);205 const dump = JSON.stringify(a) + JSON.stringify(await sql`select * from proposals`) + JSON.stringify(await sql`select * from sessions`);206 expect(dump).not.toMatch(/abandon/);207 expect(dump).not.toContain(Buffer.from(keys.evmPriv).toString('hex'));208 expect(dump).not.toContain(cookie.split('=')[1]);209 });210211 it('swap: quotes are for the wallet\'s own addresses, hostile answers are dropped, execution is gated', async () => {212 expect((await api('POST', '/api/v1/swap/quote', { from: 'base:native', to: `base:${USDC}`, amount: '0.01' }, { cookie: null })).status).toBe(401);213 expect((await api('GET', '/api/v1/swap/assets', undefined, { cookie: null })).status).toBe(401);214 const q = await api('POST', '/api/v1/swap/quote', { from: 'base:native', to: `base:${USDC}`, amount: '0.01', recipient: '0x000000000000000000000000000000000000dEaD' });215 expect(q.status).toBe(200);216 expect(swapSeen.every((r) => r.sender === keys.addresses.evm && r.recipient === keys.addresses.evm)).toBe(true); // the client cannot choose the recipient217 expect(q.json.routes.map((r: any) => r.providers[0]).sort()).toEqual(['alpha', 'beta']);218 expect(q.json.providers.find((p: any) => p.provider === 'evil').code).toBe('invalid_quote');219 const best = q.json.routes.find((r: any) => r.id === q.json.best.best_price);220 expect(best.providers).toEqual(['alpha']);221 expect(best.minAmountOut).toBe('19880100'); // 0.01 ETH × 2000 × 0.999 × 0.995, in USDC base units, as a string222 expect(best.minAmountOutDisplay).toBe('19.8801');223 for (const bad of [{ from: 'base:native', to: 'base:native', amount: '1' }, { from: 'base:native', to: 'base:0xdAC17F958D2ee523a2206206994597C13D831ec7', amount: '1' },224 { from: 'base:native', to: `base:${USDC}`, amount: '0' }, { from: 'base:native', to: `base:${USDC}`, amount: '1e3' }, { from: 'nochain:native', to: `base:${USDC}`, amount: '1' }])225 expect((await api('POST', '/api/v1/swap/quote', bad)).status, JSON.stringify(bad)).toBe(400);226 const far = await api('POST', '/api/v1/swap/quote', { from: 'bitcoin:native', to: 'solana:native', amount: '0.001' });227 expect(far.json.reachable).toBe(false); expect(far.json.routes).toEqual([]);228 const sse = await app.inject({ method: 'GET', url: `/api/v1/swap/quote/stream?from=base:native&to=base:${USDC}&amount=0.01`, headers: { cookie } });229 expect(sse.headers['content-type']).toContain('text/event-stream');230 const events = sse.body.split('\n\n').filter(Boolean).map((b: string) => b.split('\n')[0]!.replace('event: ', ''));231 expect(events[0]).toBe('start'); expect(events.filter((e: string) => e === 'route').length).toBe(2); expect(events[events.length - 1]).toBe('done');232 // execution: authenticated, idempotent, only on networks whose router has been reviewed, and refused while halted233 expect((await api('POST', '/api/v1/swap/proposals', { network: 'base', fromAsset: 'native', toAsset: USDC, amount: '0.01' }, { cookie: null, idem: crypto.randomUUID() })).status).toBe(401);234 expect((await api('POST', '/api/v1/swap/proposals', { network: 'base', fromAsset: 'native', toAsset: USDC, amount: '0.01' })).json.error).toBe('idempotency');235 expect((await api('POST', '/api/v1/swap/proposals', { network: 'bitcoin', fromAsset: 'native', toAsset: 'x', amount: '1' }, { idem: crypto.randomUUID() })).json.error).toBe('swap_not_enabled');236 expect((await api('POST', '/api/v1/swap/proposals', { network: 'solana', fromAsset: 'native', toAsset: 'x', amount: '1' }, { idem: crypto.randomUUID() })).json.error).toBe('unsupported_asset'); // Solana is served by Jupiter since V5 (swap-sol.test.ts)237 expect((await api('POST', '/api/v1/swap/proposals', { network: 'base', fromAsset: 'native', toAsset: '0xdAC17F958D2ee523a2206206994597C13D831ec7', amount: '1' }, { idem: crypto.randomUUID() })).json.error).toBe('unsupported_asset');238 expect((await api('POST', '/api/v1/swap/proposals', { network: 'base', fromAsset: 'native', toAsset: 'native', amount: '1' }, { idem: crypto.randomUUID() })).json.error).toBe('same_asset');239 expect((await api('POST', '/api/v1/swap/proposals', { network: 'base', fromAsset: 'native', toAsset: USDC, amount: '0.01', recipient: '0x000000000000000000000000000000000000dEaD' }, { idem: crypto.randomUUID() })).status).toBe(400); // no way to name a recipient240 expect((await api('POST', '/api/v1/swap/proposals', { network: 'base', fromAsset: 'native', toAsset: USDC, amount: '0.01' }, { idem: crypto.randomUUID() })).json.error).toBe('halted'); // the suite halted the signer earlier241 const st = (await api('GET', '/api/v1/swap/providers')).json;242 expect(st.providers.length).toBe(3);243 expect((await api('GET', '/api/v1/swap/execution')).json.networks).toEqual(['base', 'ethereum', 'arbitrum', 'polygon', 'optimism', 'bsc', 'avalanche', 'hyperevm']);244 const rows = await sql`select provider, ok, code from swap_quotes`;245 expect(rows.length).toBeGreaterThanOrEqual(4);246 });247248 it('logout and revoke-all end access', async () => {249 expect((await api('POST', '/api/v1/security/sessions/revoke-all', {})).status).toBe(200);250 expect((await api('GET', '/api/v1/portfolio')).status).toBe(401);251 });252});253