SPB Git

Repository Public

plan-state.test.ts 11.5 KB 121 lines typescript

1// The plan saga as a pure state machine (services/api/src/plan-state.ts): a leg is prepared only when the previous one has2// settled, on what was actually received; a failed or refused leg pauses the plan; nothing is ever skipped or auto-approved.3import { describe, expect, it } from 'vitest';4import type { PlanLegSpec } from '@spw/swap';5import { floorHolds, legAmount, legOutcome, planAdvance, planAttach, planCreate, planPause, planReplan, planRetry, planStop, type PlanDoc, type ProposalFacts } from '../src/plan-state.ts';67const A = { network: 'arbitrum', asset: '0xlink' }, UA = { network: 'arbitrum', asset: '0xusdcA' }, UB = { network: 'base', asset: '0xusdcB' }, C = { network: 'base', asset: '0xcbbtc' };8const specs: PlanLegSpec[] = [{ kind: 'swap', via: 'kyberswap', from: A, to: UA }, { kind: 'bridge', via: 'cctp', from: UA, to: UB, speed: 'standard' }, { kind: 'swap', via: 'kyberswap', from: UB, to: C }];9const planned = (i: string, o: string) => ({ expectedIn: i, worstIn: i, expectedOut: o, worstOut: o, seconds: 30 });10const fresh = (floor: bigint | null = null): PlanDoc => planCreate({ id: 'p1', from: A, to: C, amountIn: 4000n, slippageBps: 50, floor, now: 1000,11  legs: [{ spec: specs[0]!, planned: planned('4000', '40') }, { spec: specs[1]!, planned: planned('40', '40') }, { spec: specs[2]!, planned: planned('40', '5') }] });12const types = (s: { events: [string, unknown][] }) => s.events.map((e) => e[0]);13const facts = (o: Partial<ProposalFacts>): ProposalFacts => ({ state: 'finalized', kind: 'swap', confirmations: 20, minConf: 12, error: null, out_amount: null, ...o });1415describe('reading a proposal as a leg outcome', () => {16  it('a swap is settled only when mined deep enough AND its real output was read from the chain', () => {17    expect(legOutcome(facts({ state: 'pending_approval' }))).toEqual({ kind: 'waiting_approval' });18    for (const state of ['approved', 'signed', 'broadcast']) expect(legOutcome(facts({ state, out_amount: '39' }))).toEqual({ kind: 'in_flight' });19    expect(legOutcome(facts({ state: 'confirming', confirmations: 3, out_amount: '39' }))).toEqual({ kind: 'in_flight' });20    expect(legOutcome(facts({ state: 'confirming', confirmations: 12, out_amount: '39' }))).toEqual({ kind: 'settled', received: 39n });21    expect(legOutcome(facts({ state: 'finalized', out_amount: null }))).toEqual({ kind: 'in_flight' });       // outcome not read yet: never assumed22    expect(legOutcome(facts({ state: 'finalized', out_amount: '39' }))).toEqual({ kind: 'settled', received: 39n });23    expect(legOutcome(facts({ state: 'reorged', out_amount: '39' }))).toEqual({ kind: 'in_flight' });24  });25  it('a CCTP transfer is settled when the destination minted, not when the burn is final', () => {26    expect(legOutcome(facts({ kind: 'bridge', state: 'finalized', bridge: { phase: 'attesting' } }))).toEqual({ kind: 'in_flight' });27    expect(legOutcome(facts({ kind: 'bridge', state: 'finalized', bridge: { phase: 'mint_failed' } }))).toEqual({ kind: 'in_flight' });   // the USDC stays mintable: the bridge flow asks for the passkey28    expect(legOutcome(facts({ kind: 'bridge', state: 'finalized', bridge: { phase: 'done' }, out_amount: '39994801' }))).toEqual({ kind: 'settled', received: 39994801n });29  });30  it('a THORChain swap is settled when the wallet saw the outbound itself; a refund is a failure that brings the source asset back', () => {31    expect(legOutcome(facts({ kind: 'xswap', state: 'finalized', xswap: { phase: 'outbound' } }))).toEqual({ kind: 'in_flight' });32    expect(legOutcome(facts({ kind: 'xswap', state: 'finalized', xswap: { phase: 'received', received: '777' } }))).toEqual({ kind: 'settled', received: 777n });33    expect(legOutcome(facts({ kind: 'xswap', state: 'finalized', xswap: { phase: 'refunded', refunded: '3990' } }))).toMatchObject({ kind: 'failed', outcome: 'refunded', returned: 3990n });34  });35  it('rejected, expired and failed are told apart', () => {36    expect(legOutcome(facts({ state: 'rejected', error: 'rejected by owner' }))).toMatchObject({ kind: 'failed', outcome: 'rejected' });37    expect(legOutcome(facts({ state: 'expired' }))).toMatchObject({ kind: 'failed', outcome: 'expired' });38    expect(legOutcome(facts({ state: 'failed', error: 'the swap would revert now' }))).toEqual({ kind: 'failed', outcome: 'failed', reason: 'the swap would revert now' });39  });40});4142describe('the saga', () => {43  it('walks three legs, each sized on what the previous one ACTUALLY delivered', () => {44    let p = fresh();45    expect(p.legs.map((l) => l.state)).toEqual(['ready', 'pending', 'pending']); expect(p.holding).toEqual({ ...A, amount: '4000' });46    expect(legAmount(p, 10_000n)).toBe(4000n);47    let s = planAttach(p, 'prop-1', 4000n, 1100); p = s.plan;48    expect(p.legs[0]).toMatchObject({ state: 'proposed', proposalId: 'prop-1', actualIn: '4000' });49    expect(planAdvance(p, { kind: 'waiting_approval' }, 1200).plan).toBe(p);                                       // nothing happens without the owner50    s = planAdvance(p, { kind: 'in_flight' }, 1300); p = s.plan; expect(types(s)).toEqual(['plan.step_sent']);51    expect(planAdvance(p, { kind: 'in_flight' }, 1400).events).toEqual([]);52    s = planAdvance(p, { kind: 'settled', received: 39n }, 1500); p = s.plan;                                      // 39 received where 40 was expected53    expect(types(s)).toEqual(['plan.step_settled', 'plan.step_ready']);54    expect(s.events[1]![1]).toEqual({ id: 'p1', step: 2, steps: 3 });55    expect(p).toMatchObject({ state: 'active', current: 1, holding: { ...UA, amount: '39' } });56    expect(p.legs.map((l) => l.state)).toEqual(['settled', 'ready', 'pending']);57    expect(p.legs[0]).toMatchObject({ actualIn: '4000', actualOut: '39', planned: { expectedOut: '40' } });        // planned and actual side by side58    // the second leg is sized on 39 (what the plan holds), never on the 40 of the estimate — and never on more than the wallet has59    expect(legAmount(p, 1_000n)).toBe(39n); expect(legAmount(p, 30n)).toBe(30n); expect(legAmount(p, null)).toBe(39n);60    // a leg is never prepared ahead of its turn61    expect(() => planAttach({ ...p, current: 2 }, 'x', 1n, 0)).toThrow(/no leg ready/);62    p = planAttach(p, 'prop-2', 39n, 1600).plan; p = planAdvance(p, { kind: 'settled', received: 39n }, 1700).plan;63    p = planAttach(p, 'prop-3', 39n, 1800).plan;64    s = planAdvance(p, { kind: 'settled', received: 5n }, 1900); p = s.plan;65    expect(types(s)).toEqual(['plan.step_settled', 'plan.done']);66    expect(p).toMatchObject({ state: 'done', holding: { ...C, amount: '5' }, doneAt: 1900 });67    expect(planAdvance(p, { kind: 'settled', received: 9n }, 2000).plan).toBe(p);                                  // a finished plan does not move68    expect(() => planStop(p, 0)).toThrow(/already finished/);69  });70  it('a failed leg pauses the plan; the funds are where they were; the owner chooses', () => {71    let p = planAttach(fresh(), 'prop-1', 4000n, 1).plan; p = planAdvance(p, { kind: 'settled', received: 39n }, 2).plan; p = planAttach(p, 'prop-2', 39n, 3).plan;72    const s = planAdvance(p, { kind: 'failed', outcome: 'failed', reason: 'the burn would revert now' }, 4); p = s.plan;73    expect(types(s)).toEqual(['plan.paused']);74    expect(p).toMatchObject({ state: 'paused', current: 1, holding: { ...UA, amount: '39' }, pause: { code: 'leg_failed', message: 'the burn would revert now', leg: 1 } });75    expect(p.legs[1]).toMatchObject({ state: 'ready', proposalId: null, attempts: [{ proposalId: 'prop-2', outcome: 'failed', error: 'the burn would revert now' }] });76    expect(() => planAttach(p, 'x', 1n, 5)).toThrow();                                                             // nothing is prepared while paused77    // réessayer78    const again = planRetry(p, 6).plan; expect(again).toMatchObject({ state: 'active', pause: null });79    expect(planAttach(again, 'prop-2b', 39n, 7).plan.legs[1]!.attempts.map((a) => a.proposalId)).toEqual(['prop-2', 'prop-2b']);80    // arrêter ici: final, funds stay as an ordinary balance81    const stopped = planStop(p, 8); expect(stopped.plan).toMatchObject({ state: 'stopped', holding: { ...UA, amount: '39' } }); expect(stopped.events[0]![1]).toMatchObject({ holding: { ...UA, amount: '39' } });82    expect(() => planRetry(stopped.plan, 9)).toThrow();83    // re-planifier depuis l'actif actuel: the settled leg stays, the rest is replaced by a route that starts from USDC on Arbitrum84    const other: PlanLegSpec[] = [{ kind: 'bridge', via: 'cctp', from: UA, to: UB, speed: 'fast' }, specs[2]!];85    const re = planReplan(p, other.map((spec) => ({ spec, planned: planned('39', '39') })), 10).plan;86    expect(re).toMatchObject({ state: 'active', current: 1, replans: 1, pause: null });87    expect(re.legs.map((l) => [l.state, l.spec.kind, l.spec.speed ?? null])).toEqual([['settled', 'swap', null], ['ready', 'bridge', 'fast'], ['pending', 'swap', null]]);88    expect(() => planReplan(p, [{ spec: specs[2]!, planned: planned('1', '1') }], 11)).toThrow(/does not start from the asset held/);89    expect(() => planReplan(p, [{ spec: specs[1]!, planned: planned('1', '1') }], 11)).toThrow(/does not end at the plan's destination/);90    expect(() => planReplan(p, [], 11)).toThrow(/no route/);91  });92  it('a refused approval pauses; an approval window that ran out does not; a THORChain refund pauses with what came back', () => {93    const p = planAttach(fresh(), 'prop-1', 4000n, 1).plan;94    expect(planAdvance(p, { kind: 'failed', outcome: 'rejected', reason: 'rejected by owner' }, 2).plan).toMatchObject({ state: 'paused', pause: { code: 'refused' } });95    const exp = planAdvance(p, { kind: 'failed', outcome: 'expired', reason: 'expired before approval' }, 2);96    expect(exp.plan).toMatchObject({ state: 'active', pause: null }); expect(exp.plan.legs[0]).toMatchObject({ state: 'ready', attempts: [{ outcome: 'expired' }] }); expect(types(exp)).toEqual(['plan.step_expired']);97    const ref = planAdvance(planAdvance(p, { kind: 'in_flight' }, 2).plan, { kind: 'failed', outcome: 'refunded', reason: 'refunded', returned: 3990n }, 3).plan;98    expect(ref).toMatchObject({ state: 'paused', pause: { code: 'refunded' }, holding: { ...A, amount: '3990' } });99  });100  it('cannot be stopped or re-planned while a step is in progress', () => {101    const p = planAdvance(planAttach(fresh(), 'prop-1', 4000n, 1).plan, { kind: 'in_flight' }, 2).plan;102    expect(() => planStop(p, 3)).toThrow(/in progress/);103    expect(() => planReplan(p, [{ spec: specs[0]!, planned: planned('1', '1') }], 3)).toThrow(/in progress/);104    expect(() => planRetry(p, 3)).toThrow();105    expect(planPause(planStop(fresh(), 1).plan, 'x', 'y', 2).events).toEqual([]);106  });107  it('the overall floor is checked against the estimate made before each leg', () => {108    expect(floorHolds(fresh(), 1n)).toBe(true);                       // no floor109    const p = fresh(5n);110    expect(floorHolds(p, 5n)).toBe(true); expect(floorHolds(p, 4n)).toBe(false);111    const s = planPause(p, 'floor', 'the final result estimated now is below the floor', 9);112    expect(s.plan).toMatchObject({ state: 'paused', pause: { code: 'floor', leg: 0 }, holding: { ...A, amount: '4000' } }); expect(types(s)).toEqual(['plan.paused']);113  });114  it('survives a restart: the document is plain JSON and the next step depends only on it', () => {115    let p = planAttach(fresh(4n), 'prop-1', 4000n, 1).plan;116    p = JSON.parse(JSON.stringify(p)) as PlanDoc;                     // what the database gives back after a restart, days later117    const s = planAdvance(p, { kind: 'settled', received: 41n }, 9_999_999);118    expect(s.plan).toMatchObject({ current: 1, holding: { ...UA, amount: '41' }, floor: '4' }); expect(types(s)).toEqual(['plan.step_settled', 'plan.step_ready']);119  });120});121
Files main
Menu

Theme