SPB Git

Repository Public

plan-exec.test.ts 27.2 KB 303 lines typescript

1// The plan saga through the REAL API routes, worker steps and signer process boundary, against scripted chains, a scripted2// attestation service and scripted leg quotes (no network). A plan "USDC on Base → LINK on Arbitrum" is "bridge then swap":3// its first leg is a real CCTP proposal walked to the mint; its second leg is refused by the swap route's own guard (the4// scripted chain carries no reviewed router), which is exactly what must pause a plan. What the real contracts and the real5// KyberSwap do is proven on forks (scripts/fork-plan.mts). Database: spw_test_plan, created here.6import { createHash } from 'node:crypto';7import { mkdtempSync, readFileSync } from 'node:fs';8import { createServer, type Server } from 'node:http';9import { tmpdir } from 'node:os';10import { join } from 'node:path';11import { createTestDb } from './test-db.ts';12import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';13import { decodeFunctionData, encodeAbiParameters, keccak256, numberToHex, pad, parseTransaction, toFunctionSelector, type Hex } from 'viem';14import { CCTP_ABI, CCTP_ENABLED, CCTP_MESSAGE_TRANSMITTER, CCTP_TOKEN_MESSENGER, CCTP_TOKEN_MINTER, cctpBytes32, EIP1967_IMPLEMENTATION_SLOT, encodeCctpMessage, NETWORKS, type Intent } from '@spw/core';15import { assetKey, type AssetRef, type LegQuote, type PlanLegSpec } from '@spw/swap';16import { TestPasskey } from '../../../tests/helpers/passkey.ts';17import { deriveKeys } from '../../isolated-signer/src/keys.ts';18import { createSignerServer } from '../../isolated-signer/src/server.ts';19import { Signer } from '../../isolated-signer/src/signer.ts';20import { Store } from '../../isolated-signer/src/store.ts';2122const ORIGIN = 'https://wallet.test', RP = 'wallet.test', SECRET = 'p'.repeat(48), DB_LABEL = 'plan_exec';23const keys = deriveKeys('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'), ME = keys.addresses.evm;24const owner = new TestPasskey(RP, ORIGIN);25const CODE = JSON.parse(readFileSync(new URL('./fixtures/cctp-code.json', import.meta.url), 'utf8')) as Record<string, string>; // real mainnet bytecode, so the pinned hashes are exercised26const tok = (n: string, s: string) => NETWORKS[n]!.tokens.find((t) => t.symbol === s)!.id;27const USDC_B: AssetRef = { network: 'base', asset: tok('base', 'USDC') }, USDC_A: AssetRef = { network: 'arbitrum', asset: tok('arbitrum', 'USDC') }, LINK_A: AssetRef = { network: 'arbitrum', asset: tok('arbitrum', 'LINK') };28const K = (a: AssetRef) => `${a.network}:${a.asset}`;29const SEL = { allowance: '0xdd62ed3e', used: toFunctionSelector('usedNonces(bytes32)'), minFee: toFunctionSelector('getMinFeeAmount(uint256)'), burn: '0x8e0250ee', receive: '0x57ecfd28' };30const TRANSFER = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef';3132/** One scripted EVM chain behind a JSON-RPC endpoint (the CCTP part of services/api/test/bridge.test.ts). */33class Chain {34  head = 1000; nonce = 0; native = 10n ** 17n; allowance = 0n; pending: { hash: string; raw: string }[] = []; receipts = new Map<string, any>(); used = new Set<string>(); implSlot: Record<string, string>;35  constructor(public id: string) { const p = CCTP_ENABLED[id]!; this.implSlot = { [CCTP_TOKEN_MESSENGER.toLowerCase()]: p.messengerImpl.address, [CCTP_MESSAGE_TRANSMITTER.toLowerCase()]: p.transmitterImpl.address }; }36  mine(blocks = 20) {37    for (const t of this.pending) {38      const tx = parseTransaction(t.raw as Hex); this.head += 1; this.nonce = Math.max(this.nonce, tx.nonce! + 1);39      const logs: any[] = [];40      if (tx.to?.toLowerCase() === CCTP_MESSAGE_TRANSMITTER.toLowerCase()) {41        const [message] = decodeFunctionData({ abi: CCTP_ABI, data: tx.data! }).args as [Hex, Hex];42        const nonce = message.slice(2 + 24, 2 + 24 + 64), amount = BigInt('0x' + message.slice(2 + (148 + 68) * 2, 2 + (148 + 100) * 2)), fee = BigInt('0x' + message.slice(2 + (148 + 164) * 2, 2 + (148 + 196) * 2));43        this.used.add(nonce);44        logs.push({ address: tok(this.id, 'USDC'), topics: [TRANSFER, pad('0x0', { size: 32 }), pad(ME as Hex, { size: 32 }).toLowerCase()], data: numberToHex(amount - fee, { size: 32 }) });45      }46      this.receipts.set(t.hash, { status: '0x1', blockNumber: numberToHex(this.head), blockHash: keccak256(t.hash as Hex), transactionHash: t.hash, logs });47    }48    this.pending = []; this.head += blocks;49  }50  rpc(method: string, p: any[]): unknown {51    const err = (message: string) => { throw new Error(message); };52    switch (method) {53      case 'eth_chainId': return numberToHex(Number(NETWORKS[this.id]!.chainRef));54      case 'eth_blockNumber': return numberToHex(this.head);55      case 'eth_getBlockByNumber': return { number: numberToHex(this.head), baseFeePerGas: '0x989680' };56      case 'eth_maxPriorityFeePerGas': return '0xf4240';57      case 'eth_getBalance': return numberToHex(this.native);58      case 'eth_getTransactionCount': return numberToHex(p[1] === 'pending' ? this.nonce + this.pending.length : this.nonce);59      case 'eth_getCode': { const a = String(p[0]).toLowerCase();60        if (a === CCTP_TOKEN_MESSENGER.toLowerCase() || a === CCTP_MESSAGE_TRANSMITTER.toLowerCase()) return CODE.proxy;61        if (a === CCTP_TOKEN_MINTER.toLowerCase()) return CODE.minter;62        return CODE[a] ?? '0x'; }63      case 'eth_getStorageAt': return p[1] === EIP1967_IMPLEMENTATION_SLOT ? pad((this.implSlot[String(p[0]).toLowerCase()] ?? '0x0') as Hex, { size: 32 }).toLowerCase() : pad('0x0', { size: 32 });64      case 'eth_estimateGas': case 'eth_call': { const d = String(p[0].data), sel = d.slice(0, 10);65        if (sel === SEL.allowance) return numberToHex(this.allowance, { size: 32 });66        if (sel === SEL.used) return numberToHex(this.used.has(d.slice(10, 74)) ? 1 : 0, { size: 32 });67        if (sel === SEL.burn) return this.allowance < 1n ? err('execution reverted: ERC20: transfer amount exceeds allowance') : method === 'eth_call' ? '0x' : '0x1a000';68        if (sel === SEL.receive) return method === 'eth_call' ? encodeAbiParameters([{ type: 'bool' }], [true]) : '0x2a000';69        return err('execution reverted'); }70      case 'eth_sendRawTransaction': { const hash = keccak256(p[0] as Hex); if (!this.pending.some((x) => x.hash === hash) && !this.receipts.has(hash)) this.pending.push({ hash, raw: p[0] });71        const tx = parseTransaction(p[0] as Hex); if (tx.to?.toLowerCase() === tok(this.id, 'USDC').toLowerCase()) this.allowance = 10n ** 30n;72        return hash; }73      case 'eth_getTransactionReceipt': return this.receipts.get(String(p[0])) ?? null;74      case 'eth_getLogs': return [];75      default: return err(`unscripted ${method}`);76    }77  }78}79const chains: Record<string, Chain> = {};80const iris = { messages: new Map<string, unknown>() };81// the scripted market of the plan quotes, and the scripted balances the planner reads82const market = { linkPerUsdc: 100_000_000_000n /* 1 USDC (6 dec) → 0.1 LINK (18 dec) */, quotes: 0 };83const balances = new Map<string, bigint>();84const quoteLeg = () => async (leg: PlanLegSpec, amountIn: bigint): Promise<LegQuote | null> => {85  market.quotes += 1;86  const gas = (network: string) => ({ kind: 'network' as const, asset: { network, asset: 'native' }, amount: 20_000_000_000_000n, includedInOutput: false, estimated: true });87  if (leg.kind === 'bridge') { const fee = leg.speed === 'fast' ? (amountIn * 13n + 99_999n) / 100_000n : 0n; return { amountOut: amountIn - fee, minAmountOut: amountIn - fee, seconds: leg.speed === 'fast' ? 8 : 1140, fees: [{ kind: 'protocol', asset: leg.to, amount: fee, includedInOutput: true, estimated: false }, gas(leg.from.network), gas(leg.to.network)] }; }88  const out = amountIn * market.linkPerUsdc;89  return { amountOut: out, minAmountOut: (out * 995n) / 1000n, seconds: 45, fees: [gas(leg.from.network)] };90};91let fake: Server, signerSrv: Server, app: any, sql: any, worker: any, bridge: any, plans: any, cookie = '', store: Store;9293async function api(method: string, path: string, body?: unknown, idem?: string) {94  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) });95  return { status: r.statusCode, json: (() => { try { return r.json(); } catch { return null; } })(), setCookie: r.headers['set-cookie'] as string | undefined };96}97const row = async (id: string) => (await sql`select * from proposals where id = ${id}`)[0];98const getPlan = async (id: string) => (await api('GET', `/api/v1/plans/${id}`)).json;99const quote = async (from: AssetRef, to: AssetRef, amount: string) => (await api('POST', '/api/v1/plans/quote', { from: K(from), to: K(to), amount, slippageBps: 50 })).json;100async function create(amount = '10', o: { floor?: string; speed?: 'standard' | 'fast' } = {}) {101  const q = await quote(USDC_B, LINK_A, amount), p = q.plans.find((x: any) => x.legs[0].speed === (o.speed ?? 'standard'));102  const r = await api('POST', '/api/v1/plans', { from: K(USDC_B), to: K(LINK_A), amount, slippageBps: 50, plan: p.id, ...(o.floor ? { floor: o.floor } : {}) });103  return { r, quoted: p };104}105const approve = (prep: any) => api('POST', `/api/v1/transfers/proposals/${prep.proposal.id}/approve`, { assertion: owner.assert(Buffer.from(prep.options.challenge, 'base64url')) });106/** what Circle would attest for this burn */107function attested(i: Intent) {108  const b = i.bridge!, m = cctpBytes32(CCTP_TOKEN_MESSENGER);109  const message = encodeCctpMessage({ version: 1, sourceDomain: CCTP_ENABLED[i.network]!.domain, destinationDomain: b.toDomain, nonce: createHash('sha256').update(i.proposalId).digest('hex'), sender: m, recipient: m, destinationCaller: '0'.repeat(64), minFinalityThreshold: b.minFinalityThreshold,110    finalityThresholdExecuted: b.minFinalityThreshold, body: { version: 1, burnToken: cctpBytes32(i.asset), mintRecipient: cctpBytes32(i.from), amount: BigInt(i.amount), messageSender: cctpBytes32(i.from), maxFee: BigInt(b.maxFee), feeExecuted: 0n, expirationBlock: 0n } });111  return { messages: [{ status: 'complete', message, attestation: `0x${'33'.repeat(130)}`, cctpVersion: 2, delayReason: null }] };112}113const tick = async () => { await worker.reconcile(); await sql`update proposals set bridge = jsonb_set(bridge, '{nextAt}', '0') where kind = 'bridge'`; await bridge.advanceBridges(); await plans.advancePlans(); };114115beforeAll(async () => {116  const DB = (await createTestDb(DB_LABEL)).name;117  for (const id of ['base', 'arbitrum']) chains[id] = new Chain(id);118  fake = createServer((req, res) => {119    let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => {120      const send = (code: number, o: unknown) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };121      const url = new URL(req.url!, 'http://x');122      if (url.pathname.startsWith('/rpc/')) { const j = JSON.parse(body); try { send(200, { jsonrpc: '2.0', id: j.id, result: chains[url.pathname.slice(5)]!.rpc(j.method, j.params) }); } catch (e) { send(200, { jsonrpc: '2.0', id: j.id, error: { code: 3, message: (e as Error).message } }); } return; }123      if (url.pathname.startsWith('/v2/burn/USDC/fees/')) return send(200, [{ finalityThreshold: 1000, minimumFee: 1.3 }, { finalityThreshold: 2000, minimumFee: 0 }]);124      if (url.pathname === '/v2/fastBurn/USDC/allowance') return send(200, { allowance: 5e7 });125      if (url.pathname.startsWith('/v2/messages/')) { const m = iris.messages.get(url.searchParams.get('transactionHash') ?? ''); return m ? send(200, m) : send(404, { error: 'Message not found for provided parameters' }); }126      send(404, {});127    });128  }).listen(0, '127.0.0.1');129  await new Promise((r) => fake.once('listening', r));130  const base = `http://127.0.0.1:${(fake.address() as { port: number }).port}`;131  const dir = mkdtempSync(join(tmpdir(), 'spw-plan-api-'));132  store = new Store(join(dir, 'state.json'), join(dir, 'policy.json'));133  store.state.setupTokenHash = createHash('sha256').update('plan-setup-token').digest('hex');134  signerSrv = createSignerServer(new Signer(keys, store, { rpId: RP, origin: ORIGIN }), SECRET).listen(0, '127.0.0.1');135  await new Promise((r) => signerSrv.once('listening', r));136  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}`,137    SPW_BROADCAST: 'on', SPW_WORKERS: 'off', SPW_IRIS_OVERRIDE: base, SPW_RPC_OVERRIDE: JSON.stringify({ base: [`${base}/rpc/base`, `${base}/rpc/base`], arbitrum: [`${base}/rpc/arbitrum`, `${base}/rpc/arbitrum`] }) });138  const warn = console.warn; console.warn = () => {};139  const db = await import('../src/db.ts'); sql = db.sql;140  await sql.unsafe('drop schema public cascade; create schema public;'); await db.migrate();141  await import('../src/rpc-override.ts');142  const adapters = (await import('../src/chains/index.ts')).adapters;143  adapters.base!.balances = async () => ({ native: chains.base!.native, [USDC_B.asset]: balances.get(assetKey(USDC_B)) ?? 0n });144  app = await (await import('../src/server.ts')).buildServer({ swapProviders: [], plan: { providers: [], quoteLeg, balance: async (a: AssetRef) => balances.get(assetKey(a)) ?? null } });145  console.warn = warn;146  worker = await import('../src/worker.ts'); bridge = await import('../src/bridge.ts'); plans = await import('../src/plan-exec.ts');147  const o = (await api('POST', '/api/v1/auth/setup/options', {})).json;148  const s = await api('POST', '/api/v1/auth/setup/verify', { challengeId: o.challengeId, response: owner.register(o.options.challenge), setupToken: 'plan-setup-token' });149  expect(s.status, JSON.stringify(s.json)).toBe(200); cookie = String(s.setCookie).split(';')[0]!;150});151afterAll(async () => { await app?.close(); signerSrv?.close(); fake?.close(); await sql?.end(); });152beforeEach(async () => {153  await sql`update plans set state = 'stopped' where state in ('active', 'paused')`;154  await sql`update proposals set state = 'failed' where state not in ('finalized', 'failed', 'expired', 'rejected', 'replaced')`;155  await sql`update proposals set bridge = jsonb_set(bridge, '{phase}', '"done"') where kind = 'bridge'`;156  store.state.spent = {}; store.save();157  for (const c of Object.values(chains)) { c.mine(); c.native = 10n ** 17n; }158  chains.base!.allowance = 0n; market.linkPerUsdc = 100_000_000_000n;159  balances.clear(); for (const [a, v] of [[USDC_B, 100_000000n], [USDC_A, 0n], [{ network: 'base', asset: 'native' }, 10n ** 17n], [{ network: 'arbitrum', asset: 'native' }, 10n ** 17n]] as const) balances.set(assetKey(a), v);160});161162describe('planning through the API', () => {163  it('is closed without a session; prices candidate plans with a quote for every leg; says why a pair has no plan', async () => {164    const saved = cookie; cookie = '';165    expect((await api('POST', '/api/v1/plans/quote', { from: K(USDC_B), to: K(LINK_A), amount: '10' })).status).toBe(401);166    expect((await api('GET', '/api/v1/plans')).status).toBe(401);167    cookie = saved;168    const q = await quote(USDC_B, LINK_A, '10');169    expect(q.notExecutable).toBeNull();170    expect(q.plans.map((p: any) => [p.legs.map((l: any) => `${l.kind}${l.speed ? `:${l.speed}` : ''}`).join('>'), p.executable, p.expectedOutDisplay, p.worstOutDisplay, p.approvals])).toEqual([171      ['bridge:standard>swap', true, '1', '0.995', 2], ['bridge:fast>swap', true, '0.99987', '0.99487065', 2]]);    // ranked on the guaranteed worst case172    expect(q.plans[0].legs.map((l: any) => l.label)).toEqual(['Circle CCTP', 'KyberSwap']);173    expect(q.plans[0].requirements.gas.map((g: any) => [g.networkName, g.symbol, g.needDisplay, g.ok])).toEqual([['Base', 'ETH', '0.00004', true], ['Arbitrum One', 'ETH', '0.00008', true]]);174    expect(q.plans[0].requirements.limits.map((l: any) => [l.leg, l.symbol, l.networkName, l.needDisplay, l.perTransferDisplay, l.ok])).toEqual([[0, 'USDC', 'Base', '10', '50', true], [1, 'USDC', 'Arbitrum One', '10', '50', true]]);175    // v8: Solana ↔ EVM is a pair like the others — Jupiter, then CCTP from Solana (the leg itself is exercised in bridge-sol.test.ts)176    const sol = await quote({ network: 'solana', asset: 'native' }, USDC_B, '1');177    expect(sol.notExecutable).toBeNull();178    expect(sol.plans.map((p: any) => p.legs.map((l: any) => `${l.label}${l.speed ? `:${l.speed}` : ''}`).join('>')).sort()).toEqual(['Jupiter (Metis)>Circle CCTP:fast', 'Jupiter (Metis)>Circle CCTP:standard']);179    expect((await quote({ network: 'tron', asset: 'native' }, USDC_B, '1'))).toMatchObject({ plans: [], notExecutable: { code: 'network_without_execution', network: 'tron' } });180    expect((await api('POST', '/api/v1/plans/quote', { from: K(USDC_B), to: K(USDC_B), amount: '1' })).json.error).toBe('same_asset');181    expect((await api('POST', '/api/v1/plans/quote', { from: 'base:0x0000000000000000000000000000000000000001', to: K(USDC_B), amount: '1' })).json.error).toBe('unsupported_asset');182    const caps = (await api('GET', '/api/v1/plans/capabilities')).json;183    expect(caps.swap).toMatchObject({ base: 'kyberswap', solana: 'jupiter' }); expect(caps.cctp).toContain('hyperevm'); expect(caps.cctp).toContain('solana'); expect(caps.hub).toBe('USDC');184  });185  it('refuses to START a plan whose leg exceeds the limit of its asset, or that lacks gas somewhere — with the reason, storing nothing', async () => {186    const { r } = await create('60');187    expect(r.status).toBe(422); expect(r.json.error).toBe('plan_not_startable');188    expect(r.json.message).toMatch(/step 1: 60 USDC on Base is above the per-transfer limit of 50 USDC; step 2: 60 USDC on Arbitrum One is above the per-transfer limit of 50 USDC/);189    balances.set('arbitrum:native', 0n);190    const g = await create('10');191    expect(g.r.json.error).toBe('plan_not_startable'); expect(g.r.json.message).toMatch(/about 0\.00008 ETH is needed on Arbitrum One for network fees; the wallet has 0 available there/);192    expect(g.quoted.issues).toEqual([expect.objectContaining({ code: 'gas_missing', blocking: true, network: 'arbitrum', symbol: 'ETH' })]);193    expect(await sql`select 1 from plans where state in ('active', 'paused')`).toHaveLength(0);194    expect(await sql`select 1 from proposals where state = 'pending_approval'`).toHaveLength(0);195    expect((await api('POST', '/api/v1/plans', { from: K(USDC_B), to: K(LINK_A), amount: '10', slippageBps: 50, plan: 'cctp:nonsense' })).json.error).toBe('no_route');196  });197});198199describe('execution as a saga', () => {200  it('bridge then swap: leg 1 is a real CCTP proposal walked to the mint; leg 2 is prepared only afterwards, on the amount received; a refused leg pauses the plan', async () => {201    const { r, quoted } = await create('10');202    expect(r.status, JSON.stringify(r.json)).toBe(200);203    const id = r.json.id as string;204    expect(r.json).toMatchObject({ state: 'active', next: 'prepare', current: 0, steps: 2, holding: { symbol: 'USDC', network: 'base', amountDisplay: '10' }, plannedExpectedDisplay: quoted.expectedOutDisplay, plannedWorstDisplay: quoted.worstOutDisplay });205    expect(r.json.legs.map((l: any) => l.state)).toEqual(['ready', 'pending']);206    expect(await sql`select 1 from proposals where state not in ('failed', 'expired', 'rejected', 'finalized')`).toHaveLength(0);   // creating a plan prepares nothing207    expect((await create('5')).r.json.error).toBe('plan_open');                                                                 // one plan at a time208209    const prep = (await api('POST', `/api/v1/plans/${id}/prepare`, {})).json;210    expect(prep.proposal).toMatchObject({ kind: 'bridge', state: 'pending_approval', amount: '10000000', bridge: { toNetwork: 'arbitrum', speed: 'standard' } });211    expect(prep.plan).toMatchObject({ next: 'approve', estimate: { expectedOutDisplay: '1', worstOutDisplay: '0.995' } });212    expect((await row(prep.proposal.id)).idempotency_key).toMatch(/^pl-[0-9a-f]{24}-0-0-0$/);213    // the plan does not approve: without the owner's passkey the worker changes nothing214    await tick(); expect((await getPlan(id)).legs[0].state).toBe('proposed');215    expect((await approve(prep)).status).toBe(200);216    for (let k = 0; k < 4 && (await row(prep.proposal.id)).state !== 'finalized'; k++) { chains.base!.mine(); await tick(); }217    let p = await getPlan(id);218    expect(p).toMatchObject({ state: 'active', next: 'wait', current: 0 }); expect(p.legs[0].state).toBe('in_flight');     // burned, not minted: the plan waits219    expect(p.proposal.bridge.stage).toBe('attesting');220    iris.messages.set(p.proposal.txid, attested((await row(prep.proposal.id)).intent as Intent));221    await tick(); chains.arbitrum!.mine(); await tick();222    p = await getPlan(id);223    expect(p).toMatchObject({ state: 'active', next: 'prepare', current: 1, holding: { symbol: 'USDC', network: 'arbitrum', amount: '10000000' } });224    expect(p.legs.map((l: any) => [l.state, l.actual.inDisplay, l.actual.outDisplay])).toEqual([['settled', '10', '10'], ['ready', null, null]]);225    const audit = (await api('GET', '/api/v1/security/audit?limit=200')).json;226    expect(audit.intact).toBe(true);227    expect(audit.events.find((e: any) => e.type === 'plan.step_ready').data).toMatchObject({ id, step: 2, steps: 2, what: 'USDC → LINK', network: 'arbitrum' });228    for (const t of ['plan.created', 'plan.step_prepared', 'plan.step_sent', 'plan.step_settled']) expect(audit.events.map((e: any) => e.type)).toContain(t);229230    // leg 2 goes through the ordinary swap route, with ITS guards: this scripted chain has no reviewed router → refused → paused231    balances.set(assetKey(USDC_A), 10_000000n);232    const l2 = await api('POST', `/api/v1/plans/${id}/prepare`, {});233    expect(l2.status).toBe(200); expect(l2.json.proposal).toBeNull();234    expect(l2.json.plan).toMatchObject({ state: 'paused', next: 'paused', pause: { code: 'router_changed', leg: 1 }, holding: { symbol: 'USDC', network: 'arbitrum', amountDisplay: '10' } });235    expect((await api('POST', `/api/v1/plans/${id}/prepare`, {})).status).toBe(409);236    // réessayer → active again; re-planifier depuis l'actif actuel → a fresh route from USDC on Arbitrum; arrêter ici → final237    expect((await api('POST', `/api/v1/plans/${id}/retry`, {})).json).toMatchObject({ state: 'active', next: 'prepare', pause: null });238    market.linkPerUsdc = 90_000_000_000n;239    const re = (await api('POST', `/api/v1/plans/${id}/replan`, {})).json;240    expect(re).toMatchObject({ state: 'active', replans: 1, steps: 2, current: 1 });241    expect(re.legs.map((l: any) => [l.state, l.kind, l.planned.expectedOutDisplay])).toEqual([['settled', 'bridge', '10'], ['ready', 'swap', '0.9']]);   // re-quoted now242    const st = (await api('POST', `/api/v1/plans/${id}/stop`, {})).json;243    expect(st).toMatchObject({ state: 'stopped', next: 'stopped', holding: { symbol: 'USDC', network: 'arbitrum', amountDisplay: '10' } });244    expect((await api('POST', `/api/v1/plans/${id}/prepare`, {})).status).toBe(409);245    expect((await api('POST', `/api/v1/plans/${id}/stop`, {})).status).toBe(409);246    expect((await api('GET', '/api/v1/plans?open=1')).json.plans).toHaveLength(0);247    expect((await api('GET', '/api/v1/plans')).json.plans[0]).toMatchObject({ id, state: 'stopped' });248  });249250  it('the overall floor stops the plan BEFORE a leg is prepared; nothing is signed; the owner may lower it', async () => {251    const hi = await create('10', { floor: '2' });252    expect(hi.r.json.error).toBe('floor_above_estimate');253    const { r } = await create('10', { floor: '0.99' });254    expect(r.status, JSON.stringify(r.json)).toBe(200); expect(r.json.floorDisplay).toBe('0.99');255    const id = r.json.id as string;256    market.linkPerUsdc = 98_000_000_000n;                                // the price moves: the same route now gives 0.98 LINK257    const f = await api('POST', `/api/v1/plans/${id}/prepare`, {});258    expect(f.status).toBe(200); expect(f.json.proposal).toBeNull();259    expect(f.json.plan).toMatchObject({ state: 'paused', pause: { code: 'floor', leg: 0 }, estimate: { expectedOutDisplay: '0.98' }, holding: { symbol: 'USDC', network: 'base', amountDisplay: '10' } });260    expect(f.json.plan.pause.message).toMatch(/estimated now is 0\.98 LINK, below the floor of 0\.99/);261    expect(await sql`select 1 from proposals where state = 'pending_approval'`).toHaveLength(0);262    // retry with the same floor: stopped again; with a lower floor: the leg is prepared263    expect((await api('POST', `/api/v1/plans/${id}/retry`, {})).json.state).toBe('active');264    expect((await api('POST', `/api/v1/plans/${id}/prepare`, {})).json.plan.pause.code).toBe('floor');265    expect((await api('POST', `/api/v1/plans/${id}/retry`, { floor: '0.97' })).json).toMatchObject({ state: 'active', floorDisplay: '0.97' });266    const ok = (await api('POST', `/api/v1/plans/${id}/prepare`, {})).json;267    expect(ok.proposal).toMatchObject({ kind: 'bridge', state: 'pending_approval' });268    // stopping withdraws the unused approval request first269    expect((await api('POST', `/api/v1/plans/${id}/stop`, {})).json.state).toBe('stopped');270    expect((await row(ok.proposal.id)).state).toBe('rejected');271  });272273  it('a refused approval pauses the plan; an expired one does not; a second preparation replaces the first; amounts never exceed what the wallet holds', async () => {274    const { r } = await create('10');275    const id = r.json.id as string;276    const a = (await api('POST', `/api/v1/plans/${id}/prepare`, {})).json;277    await api('POST', `/api/v1/transfers/proposals/${a.proposal.id}/reject`, {});278    let p = await getPlan(id);279    expect(p).toMatchObject({ state: 'paused', pause: { code: 'refused', leg: 0 } }); expect(p.legs[0]).toMatchObject({ state: 'ready', attempts: [{ outcome: 'rejected' }] });280    await api('POST', `/api/v1/plans/${id}/retry`, {});281    const b = (await api('POST', `/api/v1/plans/${id}/prepare`, {})).json;282    expect(b.proposal.id).not.toBe(a.proposal.id);283    await sql`update proposals set expires_at = now() - interval '1 minute' where id = ${b.proposal.id}`; await worker.expireStale(); await plans.advancePlans();284    p = await getPlan(id);285    expect(p).toMatchObject({ state: 'active', next: 'prepare', pause: null }); expect(p.legs[0].attempts.map((x: any) => x.outcome)).toEqual(['rejected', 'expired']);286    const c = (await api('POST', `/api/v1/plans/${id}/prepare`, {})).json, d = (await api('POST', `/api/v1/plans/${id}/prepare`, {})).json;   // the page was closed and reopened287    expect(d.proposal.id).not.toBe(c.proposal.id); expect((await row(c.proposal.id)).state).toBe('rejected'); expect((await row(d.proposal.id)).state).toBe('pending_approval');288    expect(d.plan.legs[0].attempts).toHaveLength(4);289    // an approval of the withdrawn request is refused by the ordinary route290    expect((await approve(c)).status).toBe(409);291    await api('POST', `/api/v1/plans/${id}/stop`, {});292    // part of the funds left the wallet between the start and the first leg: the leg is sized on what is really there293    const two = (await create('10')).r.json;294    balances.set(assetKey(USDC_B), 7_000000n);295    const e = (await api('POST', `/api/v1/plans/${two.id}/prepare`, {})).json;296    expect(e.proposal).toMatchObject({ amount: '7000000', kind: 'bridge' }); expect(e.plan.estimate.expectedOutDisplay).toBe('0.7');297    await api('POST', `/api/v1/plans/${two.id}/stop`, {});298    balances.set(assetKey(USDC_B), 0n);299    const three = (await sql`update plans set state = 'active' where id = ${two.id} returning id`)[0];300    expect((await api('POST', `/api/v1/plans/${three.id}/prepare`, {})).json.plan).toMatchObject({ state: 'paused', pause: { code: 'no_funds' } });301  });302});303
Files main
Menu

Theme