SPB Git

Repository Public

swap-sol.test.ts 33.5 KB 368 lines typescript

1// Jupiter swap life cycle on Solana against a scripted cluster and a scripted Jupiter API (no network): the real API routes,2// worker steps and signer process boundary. The answers Jupiter gives here are real recorded ones (re-addressed to the test3// wallet); what the real program does with them is proven separately on mainnet by simulation (scripts/sim-jupiter.mts).4import { createHash, createPublicKey, verify } from 'node:crypto';5import { mkdtempSync } from 'node:fs';6import { createServer, type Server } from 'node:http';7import { tmpdir } from 'node:os';8import { join } from 'node:path';9import { createTestDb } from './test-db.ts';10import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';11import { base58 } from '@scure/base';12import { BPF_UPGRADEABLE_LOADER, decodeSolV1Message, JUPITER_PIN, JUPITER_PROGRAM, jupiterMinOut, NETWORKS, SYSTEM_PROGRAM, TOKEN_2022_PROGRAM, TOKEN_PROGRAM, verifyJupiterSwap, type Intent } from '@spw/core';13import { buildFor, jupFixture, solAta, solMint, solToken, type JupFixture } from '../../../tests/helpers/jupiter-fixtures.ts';14import { TestPasskey } from '../../../tests/helpers/passkey.ts';15import { deriveKeys } from '../../isolated-signer/src/keys.ts';16import { createSignerServer } from '../../isolated-signer/src/server.ts';17import { Signer } from '../../isolated-signer/src/signer.ts';18import { Store } from '../../isolated-signer/src/store.ts';1920const ORIGIN = 'https://wallet.test', RP = 'wallet.test', SECRET = 'j'.repeat(48), DB_LABEL = 'swap_sol';21const keys = deriveKeys('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'), ME = keys.addresses.solana!;22const owner = new TestPasskey(RP, ORIGIN);23const N = NETWORKS.solana!;24const spki = (k: string) => createPublicKey({ key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), Buffer.from(base58.decode(k))]), format: 'der', type: 'spki' });2526/** A scripted Solana cluster: the state the API reads, and what a simulation or a confirmed transaction reports. */27class Cluster {28  f!: JupFixture; height = 5000; lamports = 500_000_000n; tokens: Record<string, bigint> = {}; sent: string[] = []; status = new Map<string, any>(); sims = 0; calls: string[] = [];29  blocks = 0; simErr: unknown = null; down: string[] = []; shortfall = 0n; steal: string | null = null;30  pd = { slot: JUPITER_PIN.lastDeploySlot, authority: JUPITER_PIN.upgradeAuthority as string, executable: true }; walletOwner = SYSTEM_PROGRAM; ext: unknown[] = [];31  use(name: string) { this.f = jupFixture(name); this.tokens = { [solToken(this.f.from).symbol]: 10n ** 12n }; return this.f; }32  /** balance tables for a message: the recorded mainnet ones, with the fee of the bytes actually submitted */33  tables(msg: Uint8Array) {34    const m = decodeSolV1Message(msg), s = this.f.simulation, fee = 5000 + Number(m.priorityFeeLamports);35    const post = [...s.postBalances]; post[0]! += s.fee - fee;36    const ptb = structuredClone(s.postTokenBalances), dstIdx = m.keys.indexOf(solAta(ME, this.f.to));37    if (this.shortfall) { if (this.f.to === 'SOL') post[0]! -= Number(this.shortfall); else { const e = ptb.find((b: any) => b.accountIndex === dstIdx); e.uiTokenAmount.amount = (BigInt(e.uiTokenAmount.amount) - this.shortfall).toString(); } }38    const pre = structuredClone(s.preTokenBalances);39    for (const t of [pre, ptb]) for (const b of t) if (b.owner === this.f.wallet) b.owner = ME;40    if (this.steal) { const idx = m.keys.indexOf(this.steal); pre.push({ accountIndex: idx, mint: solMint('JUP'), owner: ME, uiTokenAmount: { amount: '500' } }); ptb.push({ accountIndex: idx, mint: solMint('JUP'), owner: ME, uiTokenAmount: { amount: '100' } }); }41    return { err: null, fee, unitsConsumed: s.unitsConsumed, logs: [], preBalances: s.preBalances, postBalances: post, preTokenBalances: pre, postTokenBalances: ptb };42  }43  /** distinct transactions relayed (the adapter relays to every endpoint, and both endpoints are this double) */44  get unique() { return new Set(this.sent).size; }45  rpc(method: string, p: any[]): unknown {46    this.calls.push(method);47    const err = (message: string, code = -32002) => { throw Object.assign(new Error(message), { rpc: code }); };48    const key = (k: string) => Buffer.from(base58.decode(k));49    switch (method) {50      case 'getGenesisHash': return N.chainRef;51      case 'getBlockHeight': return this.height;52      case 'getBalance': return { value: Number(this.lamports) };53      case 'getLatestBlockhash': return { value: { blockhash: base58.encode(createHash('sha256').update(`block ${++this.blocks}`).digest()), lastValidBlockHeight: this.height + 150 } }; // a new one each time, as on a live cluster54      case 'getTokenAccountsByOwner': return { value: Object.entries(this.tokens).filter(([sym]) => (solToken(sym).program === 'token2022' ? TOKEN_2022_PROGRAM : TOKEN_PROGRAM) === p[1].programId)55        .map(([sym, amount]) => ({ pubkey: solAta(ME, sym), account: { data: { parsed: { info: { mint: solToken(sym).id, tokenAmount: { amount: amount.toString(), decimals: solToken(sym).decimals } } } } } })) };56      case 'getMultipleAccounts': return { value: (p[0] as string[]).map((k) => {57        if (p[1].dataSlice) return { lamports: 1, owner: TOKEN_PROGRAM }; // the aggregator's shared accounts all exist58        const sym = Object.keys(this.tokens).find((s) => solAta(ME, s) === k);59        return sym ? { lamports: 2039280, data: { parsed: { info: { tokenAmount: { amount: this.tokens[sym]!.toString() } } } } } : null; }) };60      case 'getAccountInfo': {61        const k = p[0] as string;62        if (k === JUPITER_PROGRAM) return { value: { owner: BPF_UPGRADEABLE_LOADER, executable: this.pd.executable, lamports: 1, data: [Buffer.concat([Buffer.from([2, 0, 0, 0]), key(JUPITER_PIN.programData)]).toString('base64'), 'base64'] } };63        if (k === JUPITER_PIN.programData) { const h = Buffer.alloc(45); h.writeUInt32LE(3, 0); h.writeBigUInt64LE(BigInt(this.pd.slot), 4); h[12] = 1; key(this.pd.authority).copy(h, 13);64          return { value: { owner: BPF_UPGRADEABLE_LOADER, executable: false, lamports: 1, data: [h.toString('base64'), 'base64'] } }; }65        if (k === ME) return { value: { owner: this.walletOwner, executable: false, lamports: Number(this.lamports), data: ['', 'base64'] } };66        if (N.tokens.some((t) => t.id === k)) return { value: { owner: TOKEN_2022_PROGRAM, executable: false, lamports: 1, data: { parsed: { type: 'mint', info: { decimals: 6, extensions: this.ext } } } } };67        return { value: null }; }68      case 'simulateTransaction': {69        this.sims++;70        const wire = Buffer.from(p[0], 'base64'), msg = new Uint8Array(wire.subarray(0, wire.length - 64));71        if (p[1].sigVerify && !verify(null, msg, spki(decodeSolV1Message(msg).keys[0]!), wire.subarray(wire.length - 64))) return err('Transaction signature verification failure', -32003);72        if (this.simErr) return { value: { err: this.simErr, logs: ['Program JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4 failed: custom program error: 0x1771'], unitsConsumed: 1 } };73        return { value: this.tables(msg) }; }74      case 'sendTransaction': { const wire = Buffer.from(p[0], 'base64'); this.sent.push(p[0]); return base58.encode(wire.subarray(wire.length - 64)); }75      case 'getSignatureStatuses': return { value: [this.status.get(p[0][0]) ?? null] };76      case 'getTransaction': { const raw = this.sent.find((r) => base58.encode(Buffer.from(r, 'base64').subarray(-64)) === p[0]); if (!raw || p[1].maxSupportedTransactionVersion !== 1) return null;77        const wire = Buffer.from(raw, 'base64'); return { slot: 1, meta: this.tables(new Uint8Array(wire.subarray(0, wire.length - 64))) }; }78      default: return err(`unscripted ${method}`);79    }80  }81}82const chain = new Cluster();83const jup: { calls: URLSearchParams[]; answer: (q: URLSearchParams) => { status: number; body: unknown } } = { calls: [], answer: () => ({ status: 200, body: buildFor(chain.f, ME) }) };84let fake: Server, signerSrv: Server, app: any, sql: any, worker: any, exec: any, cookie = '', store: Store;8586async function api(method: string, path: string, body?: unknown, idem?: string) {87  const r = await app.inject({ method, url: path, 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) });88  return { status: r.statusCode, json: (() => { try { return r.json(); } catch { return null; } })(), setCookie: r.headers['set-cookie'] as string | undefined };89}90const row = async (id: string) => (await sql`select * from proposals where id = ${id}`)[0];91const getP = async (id: string) => (await api('GET', `/api/v1/transfers/proposals/${id}`)).json;92const amountOf = (f: JupFixture) => { const d = solToken(f.from).decimals, s = f.intent.amount.padStart(d + 1, '0'); return `${s.slice(0, -d)}.${s.slice(-d)}`.replace(/\.?0+$/, ''); };93const prepare = (f: JupFixture, o: Record<string, unknown> = {}) => api('POST', '/api/v1/swap/proposals', { network: 'solana', fromAsset: solToken(f.from).id, toAsset: solToken(f.to).id, amount: amountOf(f), slippageBps: 50, ...o }, crypto.randomUUID());94const approve = (prep: any) => api('POST', `/api/v1/transfers/proposals/${prep.json.proposal.id}/approve`, { assertion: owner.assert(Buffer.from(prep.json.options.challenge, 'base64url')) });95const confirm = (id: string, st: 'confirmed' | 'finalized', e: unknown = null) => chain.status.set(id, { confirmations: st === 'finalized' ? null : 3, err: e, confirmationStatus: st, slot: 777 });9697beforeAll(async () => {98  const DB = (await createTestDb(DB_LABEL)).name;99  fake = createServer((req, res) => {100    const url = new URL(req.url!, 'http://x'); let body = '';101    req.on('data', (c) => { body += c; }); req.on('end', () => {102      const send = (status: number, j: unknown) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(j)); };103      if (url.pathname === '/jup/build') { jup.calls.push(url.searchParams); const a = jup.answer(url.searchParams); return send(a.status, a.body); }104      const q = JSON.parse(body);105      if (chain.down.includes(q.method) || chain.down.includes('*')) return send(500, { error: 'down' });106      try { send(200, { jsonrpc: '2.0', id: q.id, result: chain.rpc(q.method, q.params) }); }107      catch (e) { send(200, { jsonrpc: '2.0', id: q.id, error: { code: (e as { rpc?: number }).rpc ?? -32000, message: (e as Error).message } }); }108    });109  }).listen(0, '127.0.0.1');110  await new Promise((r) => fake.once('listening', r));111  const base = `http://127.0.0.1:${(fake.address() as { port: number }).port}`;112  const dir = mkdtempSync(join(tmpdir(), 'spw-jup-api-'));113  store = new Store(join(dir, 'state.json'), join(dir, 'policy.json'));114  store.state.setupTokenHash = createHash('sha256').update('jup-setup-token').digest('hex');115  signerSrv = createSignerServer(new Signer(keys, store, { rpId: RP, origin: ORIGIN }), SECRET).listen(0, '127.0.0.1');116  await new Promise((r) => signerSrv.once('listening', r));117  Object.assign(process.env, { SPW_ORIGIN: ORIGIN, SPW_RP_ID: RP, DATABASE_URL: `postgres:///${DB}`, SPW_SIGNER_SECRET: SECRET, SPW_SIGNER_URL: `http://127.0.0.1:${(signerSrv.address() as { port: number }).port}`,118    SPW_BROADCAST: 'on', SPW_WORKERS: 'off', SPW_JUPITER_API: `${base}/jup`, SPW_RPC_OVERRIDE: JSON.stringify({ solana: [`${base}/rpc`, `${base}/rpc`] }) });119  const db = await import('../src/db.ts'); sql = db.sql;120  await sql.unsafe('drop schema public cascade; create schema public;'); await db.migrate();121  await import('../src/rpc-override.ts');122  app = await (await import('../src/server.ts')).buildServer({ swapProviders: [] });123  worker = await import('../src/worker.ts'); exec = await import('../src/swap-exec-sol.ts');124  const o = (await api('POST', '/api/v1/auth/setup/options', {})).json;125  const s = await api('POST', '/api/v1/auth/setup/verify', { challengeId: o.challengeId, response: owner.register(o.options.challenge), setupToken: 'jup-setup-token' });126  expect(s.status, JSON.stringify(s.json)).toBe(200); cookie = String(s.setCookie).split(';')[0]!;127});128afterAll(async () => { await app?.close(); signerSrv?.close(); fake?.close(); await sql?.end(); });129beforeEach(async () => {130  await sql`update proposals set state = 'failed' where state not in ('finalized', 'failed', 'expired', 'rejected', 'replaced')`;131  store.state.spent = {}; store.save();132  Object.assign(chain, { height: 5000, lamports: 500_000_000n, sent: [], sims: 0, calls: [], simErr: null, down: [], shortfall: 0n, steal: null, walletOwner: SYSTEM_PROGRAM, ext: [],133    pd: { slot: JUPITER_PIN.lastDeploySlot, authority: JUPITER_PIN.upgradeAuthority, executable: true } });134  chain.status.clear(); jup.calls = []; jup.answer = () => ({ status: 200, body: buildFor(chain.f, ME) });135  chain.use('usdc-bonk');136});137138describe('preparation', () => {139  it('prepares a swap from a real Jupiter answer: verified like the signer will, simulated, costed, labelled "Metis"', async () => {140    const f = chain.f, prep = await prepare(f);141    expect(prep.status, JSON.stringify(prep.json)).toBe(200);142    const p = prep.json.proposal, r = await row(p.id), intent = r.intent as Intent;143    expect(p).toMatchObject({ state: 'pending_approval', kind: 'swap', network: 'solana', symbol: 'USDC', from: ME, to: ME, feeSymbol: 'SOL' });144    expect(p.swap).toMatchObject({ provider: 'jupiter', toSymbol: 'BONK', expectedOut: f.build.outAmount, minOut: jupiterMinOut(BigInt(f.build.outAmount), 50).toString(), needsApproval: false, simulated: true });145    expect(p.swap.detail).toMatchObject({ label: 'Jupiter (Metis)', instruction: 'shared_accounts_route_v2', rewritten: false, createsAccount: false, rent: '0', simulatedOut: f.effects.received });146    expect(p.swap.detail.venues).toEqual([...new Set(f.build.routePlan.map((l: any) => l.swapInfo.label))]);147    expect(BigInt(p.swap.detail.networkFee)).toBe(5000n + BigInt(p.swap.detail.priorityFee)); expect(p.feeEstimate).toBe(p.swap.detail.networkFee);148    expect(BigInt(p.maxFee)).toBeLessThanOrEqual(BigInt(store.policy.maxFee.solana!)); // ceiling: fee + the bound of the (idempotent) account creation149    expect(intent.swap).toMatchObject({ provider: 'jupiter', router: JUPITER_PROGRAM, approveGas: '0' }); expect(intent.sol!.computeUnitLimit).toBeLessThanOrEqual(400_000);150    const facts = verifyJupiterSwap(intent, new Uint8Array(Buffer.from(r.aux.swapMessage, 'base64'))); // exactly what the signer will run151    expect(facts.message.payer).toBe(ME); expect(facts.sourceAccount).toBe(solAta(ME, 'USDC')); expect(facts.destinationAccount).toBe(solAta(ME, 'BONK'));152    expect(jup.calls).toHaveLength(1);153    expect(Object.fromEntries(jup.calls[0]!)).toEqual({ inputMint: solMint('USDC'), outputMint: solMint('BONK'), amount: f.intent.amount, taker: ME, slippageBps: '50', transactionVersion: '1' }); // no fee account, no referral, no tip154    expect(chain.sims).toBe(2); expect(chain.sent).toHaveLength(0);155    expect((await api('GET', '/api/v1/swap/execution')).json.solana).toMatchObject({ provider: 'jupiter', label: 'Jupiter (Metis)', program: JUPITER_PROGRAM, lastDeploySlot: JUPITER_PIN.lastDeploySlot });156    // one operation in flight per network is enforced at approval; a swap is prepared once157    expect((await api('POST', `/api/v1/transfers/proposals/${p.id}/request-approval`, {})).json.error).toBe('bad_state');158  });159160  it('rewrites a direct route (route_v2) to the shared-accounts form, shows the rent of a new token account', async () => {161    const f = chain.use('usdc-jup'); expect(f.converted).toBe(true);162    const prep = await prepare(f); expect(prep.status, JSON.stringify(prep.json)).toBe(200);163    const d = prep.json.proposal.swap.detail;164    expect(d).toMatchObject({ rewritten: true, instruction: 'shared_accounts_route_v2', createsAccount: true, rent: f.effects.rentKept });165    expect(BigInt(d.rent)).toBeGreaterThan(1_000_000n); expect(prep.json.proposal.warnings).toContain('creates_token_account');166    expect(BigInt(prep.json.proposal.feeEstimate)).toBe(BigInt(d.networkFee) + BigInt(d.rent));167    expect(BigInt(prep.json.proposal.maxFee)).toBeGreaterThanOrEqual(BigInt(d.networkFee) + BigInt(d.rent));168  });169170  it.each(['sol-usdc', 'usdc-sol', 'usdc-sol-nowsol', 'usdc-pyusd', 'pyusd-usdc'])('%s: prepared with the recorded mainnet balance changes', async (name) => {171    const f = chain.use(name); chain.lamports = 2_000_000_000n;172    const prep = await prepare(f); expect(prep.status, JSON.stringify(prep.json)).toBe(200);173    expect(prep.json.proposal.swap.detail.simulatedOut).toBe(f.effects.received);174    expect(BigInt(f.effects.received)).toBeGreaterThanOrEqual(BigInt(prep.json.proposal.swap.minOut));175  });176177  it('refuses when no verifiable route can be had: three attempts, each leaving out the venues already tried', async () => {178    chain.simErr = { InstructionError: [1, { Custom: 6001 }] };179    const r = await prepare(chain.f);180    expect([r.status, r.json.error]).toEqual([422, 'route_not_verifiable']); expect(r.json.message).toMatch(/nothing was signed/);181    expect(jup.calls).toHaveLength(3);182    const labels = [...new Set(chain.f.build.routePlan.map((l: any) => l.swapInfo.label))].join(',');183    expect(jup.calls[0]!.get('excludeDexes')).toBeNull(); expect(jup.calls[1]!.get('excludeDexes')).toBe(labels); expect(jup.calls[2]!.get('maxAccounts')).toBe('32');184    expect((await sql`select count(*)::int as n from proposals where state = 'pending_approval'`)[0].n).toBe(0);185    chain.simErr = null;186    jup.answer = () => ({ status: 400, body: { error: 'No routes found' } });187    expect((await prepare(chain.f)).json.error).toBe('no_route');188    jup.answer = () => ({ status: 429, body: { code: 429, message: 'Too many requests' } });189    expect((await prepare(chain.f)).json.error).toBe('provider_busy');190  });191192  it('refuses a simulation that pays less than the minimum, spends another amount or touches another wallet account', async () => {193    chain.shortfall = BigInt(chain.f.effects.received) - jupiterMinOut(BigInt(chain.f.build.outAmount), 50) + 1n; // one unit under the minimum194    let r = await prepare(chain.f); expect(r.json.error, JSON.stringify(r.json).slice(0, 400)).toBe('route_not_verifiable'); expect(r.json.message).toMatch(/below the guaranteed minimum/);195    chain.shortfall -= 1n; // exactly the minimum: accepted196    r = await prepare(chain.f); expect(r.status, JSON.stringify(r.json)).toBe(200);197    await sql`update proposals set state = 'failed' where id = ${r.json.proposal.id}`; chain.shortfall = 0n;198    const ks = decodeSolV1Message(new Uint8Array(Buffer.from((await row(r.json.proposal.id)).aux.swapMessage, 'base64'))).keys;199    chain.steal = ks.find((k, n) => n > 0 && !chain.f.simulation.preTokenBalances.some((b: any) => b.accountIndex === n))!; // an account of the route turns out to be a token account of the wallet, and loses value200    r = await prepare(chain.f); expect(r.json.error).toBe('route_not_verifiable'); expect(r.json.message).toMatch(/another account of the wallet losing value/);201  });202203  it('refuses answers that are not what was asked: a fee, a tip, lookup tables, another amount, another wallet', async () => {204    const good = () => buildFor(chain.f, ME);205    const tries: [string, (b: any) => void, string][] = [206      ['platform fee', (b) => { b.platformFee = { amount: '1', feeBps: 10 }; }, 'provider_bad_answer'],207      ['tip', (b) => { b.tipInstruction = b.cleanupInstruction ?? b.setupInstructions[0]; }, 'provider_bad_answer'],208      ['extra instruction', (b) => { b.otherInstructions = [b.setupInstructions[0]]; }, 'provider_bad_answer'],209      ['version 0', (b) => { b.transactionVersion = 0; }, 'provider_bad_answer'],210      ['lookup tables', (b) => { b.addressesByLookupTableAddress = { [solMint('USDC')]: [solMint('BONK')] }; }, 'provider_bad_answer'],211      ['compute budget instruction', (b) => { b.computeBudgetInstructions = [b.setupInstructions[0]]; }, 'provider_bad_answer'],212      ['another amount', (b) => { b.inAmount = '1'; }, 'provider_bad_answer'],213      ['another output', (b) => { b.outputMint = solMint('JUP'); }, 'provider_bad_answer'],214      ['exact out', (b) => { b.swapMode = 'ExactOut'; }, 'provider_bad_answer'],215    ];216    for (const [name, mut, code] of tries) { jup.answer = () => { const b = good(); mut(b); return { status: 200, body: b }; }; expect((await prepare(chain.f)).json.error, name).toBe(code); }217    jup.answer = () => ({ status: 200, body: chain.f.build }); // built for another wallet (the recorded public address)218    const r = await prepare(chain.f); expect(r.json.error).toBe('route_not_verifiable'); expect(jup.calls.length).toBeGreaterThanOrEqual(3);219    // a platform fee hidden in the instruction itself220    jup.answer = () => { const b = good(), d = Buffer.from(b.swapInstruction.data, 'base64'); d.writeUInt16LE(25, 27); b.swapInstruction.data = d.toString('base64'); return { status: 200, body: b }; };221    const h = await prepare(chain.f); expect(h.json.error).toBe('route_not_verifiable'); expect(h.json.message).toMatch(/platform fee/);222    expect(chain.sent).toHaveLength(0);223  });224225  it('guards: the pinned program deployment, a plain wallet account, Token-2022 fees and hooks, balances, policy', async () => {226    const f = chain.f;227    chain.pd.slot += 1; expect((await prepare(f)).json).toMatchObject({ error: 'router_changed' }); chain.pd.slot -= 1;228    chain.pd.authority = ME; expect((await prepare(f)).json.error).toBe('router_changed'); chain.pd.authority = JUPITER_PIN.upgradeAuthority;229    chain.pd.executable = false; expect((await prepare(f)).json.error).toBe('router_changed'); chain.pd.executable = true;230    chain.walletOwner = TOKEN_PROGRAM; expect((await prepare(f)).json.error).toBe('account_not_plain'); chain.walletOwner = SYSTEM_PROGRAM;231    chain.down = ['getGenesisHash']; expect((await prepare(f)).json.error).toBe('rpc_degraded'); chain.down = [];232    expect(jup.calls).toHaveLength(0); // nothing was even asked of Jupiter233    chain.tokens.USDC = 1n; expect((await prepare(f)).json.error).toBe('insufficient_funds'); chain.tokens.USDC = 10n ** 12n;234    chain.lamports = 100_000n; expect((await prepare(f)).json.error).toBe('insufficient_gas'); chain.lamports = 500_000_000n;235    expect((await prepare(f, { toAsset: solToken('USDC').id })).json.error).toBe('same_asset');236    expect((await prepare(f, { toAsset: 'So11111111111111111111111111111111111111112' })).json.error).toBe('unsupported_asset'); // wrapped SOL is not a registry asset: SOL is "native"237    const p = chain.use('usdc-pyusd');238    chain.ext = [{ extension: 'transferFeeConfig', state: { newerTransferFee: { transferFeeBasisPoints: 5 }, olderTransferFee: { transferFeeBasisPoints: 0 } } }];239    expect((await prepare(p)).json.error).toBe('token_has_fee');240    chain.ext = [{ extension: 'transferHook', state: { programId: ME } }];241    expect((await prepare(p)).json.error).toBe('token_has_hook');242    chain.ext = [{ extension: 'transferFeeConfig', state: { newerTransferFee: { transferFeeBasisPoints: 0 }, olderTransferFee: { transferFeeBasisPoints: 0 } } }, { extension: 'transferHook', state: { programId: null } }];243    expect((await prepare(p)).status).toBe(200);244    // policy as for a send: USDC is capped at 50 per operation245    chain.use('usdc-bonk'); await sql`update proposals set state = 'failed'`;246    jup.answer = () => { const b = buildFor(chain.f, ME), d = Buffer.from(b.swapInstruction.data, 'base64'); b.inAmount = '60000000'; d.writeBigUInt64LE(60_000000n, 9); b.swapInstruction.data = d.toString('base64'); return { status: 200, body: b }; };247    chain.rpc = ((orig) => function (this: Cluster, m: string, q: any[]) { const r: any = orig.call(this, m, q); if (m === 'simulateTransaction' && r.value.preTokenBalances) { const k = decodeSolV1Message(new Uint8Array(Buffer.from(q[0], 'base64').subarray(0, -64))).keys.indexOf(solAta(ME, 'USDC')); r.value.postTokenBalances.find((b: any) => b.accountIndex === k).uiTokenAmount.amount = (BigInt(r.value.preTokenBalances.find((b: any) => b.accountIndex === k).uiTokenAmount.amount) - 60_000000n).toString(); } return r; })(chain.rpc);248    const big = await prepare(chain.f, { amount: '60' });249    delete (chain as any).rpc;250    expect([big.status, big.json.error]).toEqual([422, 'policy']); expect(big.json.message).toMatch(/per-transaction limit for solana:USDC/);251  });252});253254describe('approval, sending and outcome', () => {255  it('approve → signed by the signer → simulated with its signature → sent once → confirmed: the outcome is the real balance change', async () => {256    const f = chain.f, prep = await prepare(f), id = prep.json.proposal.id;257    const done = await approve(prep); expect(done.status, JSON.stringify(done.json)).toBe(200);258    expect(done.json.state).toBe('broadcast'); expect(chain.unique).toBe(1); expect(chain.sent).toHaveLength(N.rpc.length);259    const wire = Buffer.from(chain.sent[0]!, 'base64'), msg = wire.subarray(0, wire.length - 64), sig = wire.subarray(wire.length - 64);260    expect(msg.toString('base64')).toBe((await row(id)).aux.swapMessage); expect(verify(null, msg, spki(ME), sig)).toBe(true);261    expect(done.json.txid).toBe(base58.encode(sig)); expect(done.json.explorer).toContain(done.json.txid);262    // a second swap or a send on Solana must wait263    expect((await prepare(f)).json.error).toBe('pending_tx');264    // confirmed with a little less than expected, still above the approved minimum265    chain.shortfall = BigInt(f.effects.received) - BigInt(f.build.outAmount) + 1234n; confirm(done.json.txid, 'confirmed'); // lands 1234 units under the quote266    await worker.reconcile();267    let p = await getP(id); expect(p.state).toBe('confirming');268    expect(p.swap.received).toBe((BigInt(f.build.outAmount) - 1234n).toString()); expect(BigInt(p.swap.received)).toBeGreaterThanOrEqual(BigInt(p.swap.minOut)); expect(BigInt(p.swap.received)).toBeLessThan(BigInt(p.swap.expectedOut));269    confirm(done.json.txid, 'finalized'); await worker.reconcile();270    p = await getP(id); expect(p.state).toBe('finalized'); expect(p.swap.receivedDisplay).toBeTruthy();271    expect(chain.unique).toBe(1); expect(chain.sent).toHaveLength(N.rpc.length); // relayed in one round only272    const audit = (await api('GET', '/api/v1/security/audit?limit=200')).json;273    expect(audit.intact).toBe(true);274    const mine = audit.events.filter((e: any) => e.data?.id === id).map((e: any) => e.type);275    expect(mine).toEqual(expect.arrayContaining(['swap.prepared', 'swap.settled', 'transfer.signed', 'transfer.broadcast', 'transfer.finalized']));276    expect(audit.events.find((e: any) => e.type === 'swap.settled' && e.data.id === id).data).toMatchObject({ received: p.swap.received, spent: f.intent.amount, minOut: p.swap.minOut });277    expect(Object.values(store.state.spent)).toEqual([f.intent.amount]); // counted once against the daily limit278  });279280  it.each(['usdc-sol', 'sol-usdc'])('%s: the outcome of a swap with SOL on one side comes from the lamport balances', async (name) => {281    const f = chain.use(name); chain.lamports = 2_000_000_000n;282    const prep = await prepare(f), done = await approve(prep); expect(done.json.state, JSON.stringify(done.json)).toBe('broadcast');283    confirm(done.json.txid, 'finalized'); await worker.reconcile();284    const p = await getP(prep.json.proposal.id); expect(p.state).toBe('finalized'); expect(p.swap.received).toBe(f.effects.received);285  });286287  it('blockhash expired before the transaction could be sent: failed, nothing sent, nothing moved', async () => {288    const prep = await prepare(chain.f);289    chain.height += 151; // past lastValidBlockHeight290    const r = await approve(prep);291    expect(r.json.state, JSON.stringify(r.json) + JSON.stringify(prep.json).slice(0, 300)).toBe('failed'); expect(r.json.error).toMatch(/expired before it could be sent.*nothing was swapped/); expect(chain.sent).toHaveLength(0);292    await worker.reconcile(); expect(chain.sent).toHaveLength(0);293    expect((await prepare(chain.f)).status).toBe(200); // the network is free again294  });295296  it('the swap would fail at the moment of sending: not sent', async () => {297    const prep = await prepare(chain.f);298    chain.simErr = { InstructionError: [1, { Custom: 6001 }] };299    const r = await approve(prep);300    expect(r.json.state).toBe('failed'); expect(r.json.error).toMatch(/would fail now.*6001.*not sent/); expect(chain.sent).toHaveLength(0);301    chain.simErr = null; chain.shortfall = BigInt(chain.f.effects.received) - jupiterMinOut(BigInt(chain.f.build.outAmount), 50) + 1n; // or it would now pay one unit less than the approved minimum302    const prep2 = await (async () => { const s = chain.shortfall; chain.shortfall = 0n; const x = await prepare(chain.f); chain.shortfall = s; return x; })();303    const r2 = await approve(prep2);304    expect(r2.json.state).toBe('failed'); expect(r2.json.error).toMatch(/no longer delivers the approved minimum/); expect(chain.sent).toHaveLength(0);305  });306307  it('RPC unreachable: retried, never duplicated', async () => {308    const prep = await prepare(chain.f), id = prep.json.proposal.id;309    chain.down = ['simulateTransaction', 'getBlockHeight'];310    const r = await approve(prep);311    expect(r.json.state).toBe('signed'); expect(chain.sent).toHaveLength(0); // not failed: nothing is known yet312    await worker.reconcile(); expect((await getP(id)).state).toBe('signed');313    chain.down = ['sendTransaction']; // the gate passes, the relay itself fails314    await worker.reconcile();315    let p = await getP(id); expect(p.state).toBe('signed'); expect(p.error).toMatch(/broadcast attempt failed/); expect(chain.sent).toHaveLength(0);316    chain.down = [];317    await worker.reconcile();318    p = await getP(id); expect(p.state).toBe('broadcast'); expect(chain.sent).toHaveLength(N.rpc.length);319    // not seen by the cluster yet: the same signed bytes are relayed again, never a new transaction320    await sql`update proposals set last_broadcast_at = now() - interval '2 minutes' where id = ${id}`;321    await worker.reconcile(); await worker.reconcile();322    expect(chain.sent.length).toBeGreaterThanOrEqual(2 * N.rpc.length); expect(chain.unique).toBe(1);323    expect((await row(id)).txid).toBe(p.txid);324    chain.down = ['*']; await worker.reconcile(); expect((await getP(id)).state).toBe('broadcast'); chain.down = []; // a dead node changes nothing325    confirm(p.txid, 'finalized'); await worker.reconcile();326    expect((await getP(id)).state).toBe('finalized'); expect(Object.values(store.state.spent)).toHaveLength(1);327  });328329  it('sent but never included: expires with the blockhash, nothing moved; an on-chain failure records no outcome', async () => {330    const prep = await prepare(chain.f), r = await approve(prep); expect(r.json.state).toBe('broadcast');331    chain.height += 200; await worker.reconcile();332    let p = await getP(prep.json.proposal.id); expect(p.state).toBe('failed'); expect(p.error).toMatch(/expired before inclusion.*no funds moved/); expect(p.swap.received).toBeNull();333    const prep2 = await prepare(chain.f), r2 = await approve(prep2); expect(r2.json.state).toBe('broadcast');334    confirm(r2.json.txid, 'confirmed', { InstructionError: [1, { Custom: 6001 }] }); await worker.reconcile();335    p = await getP(prep2.json.proposal.id); expect(p.state).toBe('failed'); expect(p.error).toMatch(/on-chain error.*6001/); expect(p.swap.received).toBeNull();336  });337338  it('a message swapped in the database after preparation is refused by the signer; an expired approval window too', async () => {339    const prep = await prepare(chain.f), id = prep.json.proposal.id;340    const other = chain.use('usdc-jup'); void other;341    const evil = await exec.prepareJupiterSwap({ n: N, me: ME, keyId: keys.keyId, fromAsset: solToken('USDC').id, toAsset: solToken('JUP').id, amount: 25_000000n, slippageBps: 50, policyVersion: store.policy.version, proposalId: crypto.randomUUID(), feeCeiling: 3_000_000n });342    await sql`update proposals set aux = ${sql.json({ swapMessage: evil.message })} where id = ${id}`;343    const r = await approve(prep);344    expect(r.status).toBe(422); expect(r.json.message).toMatch(/message does not match the approved hash/);345    expect((await getP(id)).state).toBe('failed'); expect(chain.sent).toHaveLength(0);346    chain.use('usdc-bonk');347    const late = await prepare(chain.f);348    await sql`update proposals set expires_at = now() - interval '1 second' where id = ${late.json.proposal.id}`;349    expect((await approve(late)).json.error).toBe('expired'); expect(chain.sent).toHaveLength(0);350  });351});352353describe('balance effects of a transaction (simulated or confirmed)', () => {354  it.each(['usdc-bonk', 'usdc-jup', 'jup-usdc', 'sol-usdc', 'sol-usdc-nowsol', 'usdc-sol', 'usdc-sol-nowsol', 'usdc-pyusd', 'pyusd-usdc', 'usdc-usdt'])('%s: recorded mainnet simulation → exact input, output, rent, nothing else touched', (name) => {355    const f = jupFixture(name), facts = verifyJupiterSwap(f.intent, new Uint8Array(Buffer.from(f.message, 'base64')));356    const fx = exec.swapEffects(facts, { err: null, ...f.simulation });357    expect(fx.spent.toString()).toBe(f.intent.amount); expect(fx.received.toString()).toBe(f.effects.received); expect(fx.rentKept.toString()).toBe(f.effects.rentKept);358    expect(fx.otherLosses).toEqual([]); expect(fx.received).toBeGreaterThanOrEqual(facts.minOut); expect(fx.fee).toBe(facts.networkFee); expect(fx.rentKept).toBeLessThanOrEqual(facts.rentMax);359    expect(() => exec.swapEffects(facts, { err: null, fee: 5000 })).toThrow(/balance tables missing/);360    expect(() => exec.swapEffects(facts, { err: null, ...f.simulation, preBalances: f.simulation.preBalances.slice(1) })).toThrow(/balance tables missing/);361  });362  it('lamports leaving the wallet beyond fee and rent are reported', () => {363    const f = jupFixture('usdc-bonk'), facts = verifyJupiterSwap(f.intent, new Uint8Array(Buffer.from(f.message, 'base64')));364    const post = [...f.simulation.postBalances]; post[0]! -= 1000;365    expect(exec.swapEffects(facts, { err: null, ...f.simulation, postBalances: post }).otherLosses).toEqual(['lamports -1000']);366  });367});368
Files main
Menu

Theme