Repository Public
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