Repository Public
1// The any-to-any planner (packages/swap/src/plan.ts): enumeration over executable legs only, amounts chained leg after leg2// (expected and worst case), limits of each leg's own asset, missing gas named per network, ranking, comparison-only quotes.3import { describe, expect, it } from 'vitest';4import { NETWORKS } from '@spw/core';5import {6 checkPlan, comparisonQuotes, enumeratePlans, planId, PlanQuoteError, pricePlan, rankPlans, whyNotExecutable,7 type AssetRef, type LegQuote, type PlanCaps, type PlanContext, type PlanLegSpec, type PricedPlan, type QuoteLeg,8} from '../src/index.ts';910const tok = (n: string, s: string): AssetRef => ({ network: n, asset: NETWORKS[n]!.tokens.find((t) => t.symbol === s)!.id });11const nat = (n: string): AssetRef => ({ network: n, asset: 'native' });12const USDC = (n: string) => tok(n, 'USDC');13const LINK_ARB = tok('arbitrum', 'LINK'), CBBTC_BASE = tok('base', 'cbBTC'), BTC = nat('bitcoin'), LTC = nat('litecoin'), SOL = nat('solana');14const CAPS: PlanCaps = {15 swap: { ethereum: 'kyberswap', base: 'kyberswap', arbitrum: 'kyberswap', optimism: 'kyberswap', polygon: 'kyberswap', bsc: 'kyberswap', avalanche: 'kyberswap', hyperevm: 'kyberswap', solana: 'jupiter' },16 cctp: Object.fromEntries(['ethereum', 'base', 'arbitrum', 'optimism', 'polygon', 'avalanche', 'hyperevm'].map((n) => [n, { usdc: USDC(n).asset, fast: ['ethereum', 'base', 'arbitrum', 'optimism'].includes(n) }])),17 thor: [{ network: 'bitcoin', asset: 'native', source: true }, { network: 'litecoin', asset: 'native', source: true }, { network: 'dogecoin', asset: 'native', source: false },18 { network: 'ethereum', asset: 'native', source: true }, { network: 'ethereum', asset: USDC('ethereum').asset, source: true }, { network: 'ethereum', asset: tok('ethereum', 'WBTC').asset, source: true }],19};20const shape = (legs: PlanLegSpec[]) => legs.map((l) => `${l.kind}${l.speed ? `(${l.speed})` : ''}@${l.from.network}${l.from.network === l.to.network ? '' : `>${l.to.network}`}`).join(' → ');21const shapes = (from: AssetRef, to: AssetRef) => enumeratePlans(from, to, CAPS).map(shape);2223describe('enumeration: only legs the wallet executes, hub = native USDC', () => {24 it('same network: one direct swap', () => {25 expect(shapes(LINK_ARB, nat('arbitrum'))).toEqual(['swap@arbitrum']);26 expect(shapes(SOL, tok('solana', 'USDC'))).toEqual(['swap@solana']);27 });28 it('USDC on A → token on B is "bridge then swap"; token → USDC is "swap then bridge"; USDC → USDC is the bridge alone', () => {29 expect(shapes(USDC('arbitrum'), CBBTC_BASE)).toEqual(['bridge(fast)@arbitrum>base → swap@base', 'bridge(standard)@arbitrum>base → swap@base']);30 expect(shapes(LINK_ARB, USDC('base'))).toEqual(['swap@arbitrum → bridge(fast)@arbitrum>base', 'swap@arbitrum → bridge(standard)@arbitrum>base']);31 expect(shapes(USDC('arbitrum'), USDC('base'))).toEqual(['bridge(fast)@arbitrum>base', 'bridge(standard)@arbitrum>base']);32 // Polygon is not a Fast source33 expect(shapes(USDC('polygon'), USDC('base'))).toEqual(['bridge(standard)@polygon>base']);34 });35 it('token on A → token on B: swap, bridge, swap — the three legs chain through USDC on both networks', () => {36 const plans = enumeratePlans(LINK_ARB, CBBTC_BASE, CAPS);37 expect(plans.map(shape)).toEqual(['swap@arbitrum → bridge(fast)@arbitrum>base → swap@base', 'swap@arbitrum → bridge(standard)@arbitrum>base → swap@base']);38 for (const p of plans) {39 expect(p[0]!.to).toEqual(USDC('arbitrum')); expect(p[1]!.from).toEqual(USDC('arbitrum')); expect(p[1]!.to).toEqual(USDC('base')); expect(p[2]!.from).toEqual(USDC('base')); expect(p[2]!.to).toEqual(CBBTC_BASE);40 for (let k = 1; k < p.length; k++) expect(p[k]!.from).toEqual(p[k - 1]!.to); // contiguous41 }42 expect(new Set(plans.map(planId)).size).toBe(plans.length);43 });44 it('THORChain variants: direct pinned pair, and through USDC on Ethereum to continue by CCTP', () => {45 expect(shapes(BTC, nat('ethereum'))).toEqual(['xswap@bitcoin>ethereum', 'xswap@bitcoin>ethereum → swap@ethereum']);46 expect(shapes(BTC, LTC)).toEqual(['xswap@bitcoin>litecoin']);47 expect(shapes(BTC, USDC('base'))).toEqual(['xswap@bitcoin>ethereum → bridge(fast)@ethereum>base', 'xswap@bitcoin>ethereum → bridge(standard)@ethereum>base']);48 expect(shapes(BTC, CBBTC_BASE)).toEqual(['xswap@bitcoin>ethereum → bridge(fast)@ethereum>base → swap@base', 'xswap@bitcoin>ethereum → bridge(standard)@ethereum>base → swap@base']);49 // towards Bitcoin from another EVM network: swap to USDC, CCTP to Ethereum, THORChain out50 expect(shapes(LINK_ARB, BTC)).toEqual(['swap@arbitrum → bridge(fast)@arbitrum>ethereum → xswap@ethereum>bitcoin', 'swap@arbitrum → bridge(standard)@arbitrum>ethereum → xswap@ethereum>bitcoin']);51 // Dogecoin is a destination only52 expect(shapes(nat('dogecoin'), BTC)).toEqual([]);53 expect(shapes(nat('ethereum'), nat('dogecoin'))).toContain('xswap@ethereum>dogecoin');54 });55 it('never more than three legs, never the same network twice, never two legs of the same kind in a row', () => {56 for (const [a, b] of [[LINK_ARB, CBBTC_BASE], [BTC, CBBTC_BASE], [LINK_ARB, BTC], [tok('polygon', 'LINK'), tok('avalanche', 'USDT')], [nat('ethereum'), LTC]] as const) for (const p of enumeratePlans(a, b, CAPS)) {57 expect(p.length).toBeLessThanOrEqual(3);58 for (let k = 1; k < p.length; k++) expect(p[k]!.kind === p[k - 1]!.kind, shape(p)).toBe(false);59 const visits = p.flatMap((l) => (l.from.network === l.to.network ? [] : [l.from.network])); expect(new Set(visits).size, shape(p)).toBe(visits.length);60 }61 });62 it('Solana ↔ EVM has NO plan, and the reason is said — not faked', () => {63 expect(shapes(SOL, USDC('base'))).toEqual([]);64 expect(shapes(nat('ethereum'), SOL)).toEqual([]);65 expect(shapes(tok('solana', 'USDC'), USDC('base'))).toEqual([]); // CCTP supports Solana; this wallet's signer does not, yet66 expect(whyNotExecutable(SOL, USDC('base'), CAPS)).toEqual({ code: 'solana_evm_bridge' });67 expect(whyNotExecutable(nat('ethereum'), SOL, CAPS)).toEqual({ code: 'solana_evm_bridge' });68 expect(whyNotExecutable(nat('tron'), USDC('base'), CAPS)).toEqual({ code: 'network_without_execution', network: 'tron' });69 expect(whyNotExecutable(USDC('base'), nat('xrpl'), CAPS)).toEqual({ code: 'network_without_execution', network: 'xrpl' });70 expect(whyNotExecutable(nat('bsc'), USDC('base'), CAPS)).toEqual({ code: 'no_executable_path' }); // swaps on BNB Chain, but no CCTP there71 expect(whyNotExecutable(SOL, SOL, CAPS)).toEqual({ code: 'same_asset' });72 });73});7475// a scripted market: 1 LINK = 10 USDC, 1 cbBTC = 80 000 USDC, 0.5 % slippage on swaps; CCTP Standard free, Fast 1.3 bps76const gasFee = (network: string, amount: bigint) => ({ kind: 'network' as const, asset: nat(network), amount, includedInOutput: false, estimated: true });77const calls: string[] = [];78const market: QuoteLeg = async (leg, amountIn): Promise<LegQuote | null> => {79 calls.push(`${leg.kind}:${amountIn}`);80 if (leg.kind === 'bridge') { const fee = leg.speed === 'fast' ? (amountIn * 13n + 99_999n) / 100_000n : 0n;81 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 }, gasFee(leg.from.network, 30_000_000_000_000n), gasFee(leg.to.network, 20_000_000_000_000n)] }; }82 if (leg.kind === 'xswap') return { amountOut: amountIn * 700n, minAmountOut: amountIn * 693n, seconds: 1200, fees: [{ kind: 'network', asset: leg.to, amount: 1_000_000n, includedInOutput: true, estimated: false }], minIn: 20_000n };83 const out = leg.to.asset === USDC(leg.to.network).asset ? amountIn / 10n ** 11n : amountIn / 800n; // LINK(18) → USDC(6); USDC(6) → cbBTC(8)84 return { amountOut: out, minAmountOut: (out * 995n) / 1000n, seconds: 45, fees: [gasFee(leg.from.network, 40_000_000_000_000n)] };85};86const usd = (a: AssetRef, amt: bigint) => (a.asset === 'native' && a.network !== 'bitcoin' ? Number(amt) / 1e18 * 2000 : null);87const ctx = (o: Partial<PlanContext> & { bal?: Record<string, bigint | null> } = {}): PlanContext => ({88 asset: (a) => { const n = NETWORKS[a.network]!, d = a.asset === 'native' ? n.native : n.tokens.find((t) => t.id.toLowerCase() === a.asset.toLowerCase())!; return { symbol: d.symbol, decimals: d.decimals }; },89 perTx: { 'arbitrum:LINK': '5000000000000000000', 'arbitrum:USDC': '50000000', 'base:USDC': '50000000', 'ethereum:USDC': '50000000', 'bitcoin:BTC': '70000' },90 daily: { 'arbitrum:LINK': '10000000000000000000', 'arbitrum:USDC': '100000000', 'base:USDC': '100000000', 'ethereum:USDC': '100000000', 'bitcoin:BTC': '140000' }, spentToday: {},91 balance: (a) => { const k = `${a.network}:${a.asset === 'native' ? 'native' : 'token'}`; return o.bal && k in o.bal ? o.bal[k]! : 10n ** 20n; }, ...o });92const three = () => enumeratePlans(LINK_ARB, CBBTC_BASE, CAPS);93const LINK4 = 4n * 10n ** 18n;9495describe('pricing: real quotes leg after leg, on the expected AND on the guaranteed amount', () => {96 it('chains amounts: leg 2 is quoted on leg 1 expected output and on its minimum; the plan shows both ends', async () => {97 calls.length = 0;98 const std = await pricePlan(three().find((p) => p[1]!.speed === 'standard')!, LINK4, market, usd);99 expect(std.legs.map((l) => [l.expectedIn, l.worstIn, l.expectedOut, l.worstOut])).toEqual([100 [LINK4, LINK4, 40_000000n, 39_800000n], // 4 LINK → 40 USDC expected, 39.8 guaranteed101 [40_000000n, 39_800000n, 40_000000n, 39_800000n], // Standard: no fee102 [40_000000n, 39_800000n, 50000n, 49501n], // 40 USDC → 0.0005 cbBTC expected; worst: 39.8 USDC → 0.0004975, less 0.5 %103 ]);104 expect([std.expectedOut, std.worstOut]).toEqual([50000n, 49501n]);105 expect(calls).toEqual([`swap:${LINK4}`, 'bridge:40000000', 'bridge:39800000', 'swap:40000000', 'swap:39800000']); // the first leg is asked once106 expect(std.seconds).toBe(45 + 1140 + 45); expect(std.approvals).toBe(3);107 // fees not deducted from an output, valued: gas on Arbitrum (swap + burn) and on Base (mint + swap)108 expect(std.extraFeesUsd).toBeCloseTo((40e12 + 30e12 + 20e12 + 40e12) / 1e18 * 2000, 6);109 const fast = await pricePlan(three().find((p) => p[1]!.speed === 'fast')!, LINK4, market, usd);110 expect(fast.legs[1]!.expectedOut).toBe(40_000000n - 5200n); // 1.3 bps of 40 USDC111 expect(fast.expectedOut).toBeLessThan(std.expectedOut); expect(fast.seconds).toBe(98);112 });113 it('a leg without a quote names itself; nothing is invented', async () => {114 const none: QuoteLeg = async (leg, a) => (leg.kind === 'bridge' ? null : market(leg, a));115 await expect(pricePlan(three()[0]!, LINK4, none)).rejects.toMatchObject({ leg: 1, code: 'no_route' });116 const down: QuoteLeg = async (leg, a) => { if (leg.from.network === 'base') throw new Error('HTTP 500'); return market(leg, a); };117 const e = await pricePlan(three()[0]!, LINK4, down).catch((x) => x);118 expect(e).toBeInstanceOf(PlanQuoteError); expect(e).toMatchObject({ leg: 2, code: 'provider', message: 'HTTP 500' });119 const liar: QuoteLeg = async (leg, a) => ({ ...(await market(leg, a))!, minAmountOut: 10n ** 30n });120 await expect(pricePlan(three()[0]!, LINK4, liar)).rejects.toMatchObject({ leg: 0, code: 'provider' });121 });122});123124describe('constraints: each leg within the limits of ITS asset, minimums, gas on every network', () => {125 const priced = (amount = LINK4) => pricePlan(three().find((p) => p[1]!.speed === 'standard')!, amount, market, usd);126 it('a clean plan is startable', async () => {127 const p = checkPlan(await priced(), ctx());128 expect(p.executable).toBe(true); expect(p.issues).toEqual([]);129 });130 it('a three-leg plan consumes limits on three assets: the one that is exceeded is named', async () => {131 // 5 LINK is inside the LINK limit, but worth 50 USDC … and 6 LINK is not132 const six = checkPlan(await priced(6n * 10n ** 18n), ctx());133 expect(six.executable).toBe(false);134 expect(six.issues.filter((i) => i.blocking).map((i) => [i.code, i.leg, `${i.network}:${i.symbol}`])).toEqual([['limit_per_transfer', 0, 'arbitrum:LINK'], ['limit_per_transfer', 1, 'arbitrum:USDC'], ['limit_per_transfer', 2, 'base:USDC']]);135 // LINK fits its own limit; the USDC it becomes does not fit the USDC limit of the second and third legs136 const pricey: QuoteLeg = async (leg, a) => { const q = (await market(leg, a))!; return leg.kind === 'swap' && leg.from.network === 'arbitrum' ? { ...q, amountOut: q.amountOut * 13n / 10n, minAmountOut: q.minAmountOut * 13n / 10n } : q; };137 const p = checkPlan(await pricePlan(three()[1]!, LINK4, pricey), ctx());138 expect(p.issues.map((i) => [i.code, i.leg, i.symbol, i.need, i.have])).toEqual([['limit_per_transfer', 1, 'USDC', '52000000', '50000000'], ['limit_per_transfer', 2, 'USDC', '52000000', '50000000']]);139 // the daily limit counts what was already spent today on that asset140 const day = checkPlan(await priced(), ctx({ spentToday: { 'base:USDC': '70000000' } }));141 expect(day.issues.map((i) => [i.code, i.leg, i.have])).toEqual([['limit_daily', 2, '30000000']]);142 // an asset without a limit cannot be moved at all143 const none = checkPlan(await priced(), ctx({ perTx: { 'arbitrum:LINK': '5000000000000000000', 'base:USDC': '50000000' } }));144 expect(none.issues.find((i) => i.blocking)).toMatchObject({ code: 'limit_missing', leg: 1, network: 'arbitrum' });145 // close to a limit: a warning, not a refusal — the leg's own check decides when the real amount is known146 const near = checkPlan(await priced(49n * 10n ** 17n), ctx());147 expect(near.executable).toBe(true); expect(near.issues.map((i) => [i.code, i.blocking, i.leg])).toEqual([['limit_margin', false, 1], ['limit_margin', false, 2]]);148 });149 it('says exactly which coin is missing on which network', async () => {150 const p = checkPlan(await priced(), ctx({ bal: { 'base:native': 0n } }));151 expect(p.executable).toBe(false);152 expect(p.issues).toEqual([{ code: 'gas_missing', blocking: true, leg: null, network: 'base', symbol: 'ETH', need: String((20e12 + 40e12) * 2), have: '0', decimals: 18 }]);153 const both = checkPlan(await priced(), ctx({ bal: { 'base:native': 1n, 'arbitrum:native': 139_999_999_999_999n } }));154 expect(both.issues.map((i) => [i.code, i.network, i.need])).toEqual([['gas_missing', 'arbitrum', '140000000000000'], ['gas_missing', 'base', '120000000000000']]);155 // a balance that could not be read is said, and does not block: the leg's own preparation checks it again156 const unk = checkPlan(await priced(), ctx({ bal: { 'base:native': null } }));157 expect(unk.executable).toBe(true); expect(unk.issues.map((i) => i.code)).toEqual(['gas_unknown']);158 });159 it('the source amount is not available for fees when it is the network coin itself; the source balance must cover the amount', async () => {160 const ethToUsdc = enumeratePlans(nat('arbitrum'), USDC('base'), CAPS).find((p) => p[1]!.speed === 'standard')!;161 const swapEth: QuoteLeg = async (leg, a) => (leg.kind === 'swap' ? { amountOut: a / 500_000_000n, minAmountOut: a / 500_000_000n - 1n, seconds: 45, fees: [gasFee('arbitrum', 40_000_000_000_000n)] } : market(leg, a)); // 0.01 ETH → 20 USDC162 const c = ctx({ perTx: { 'arbitrum:ETH': '25000000000000000', 'arbitrum:USDC': '50000000' }, daily: { 'arbitrum:ETH': '50000000000000000', 'arbitrum:USDC': '100000000' }, bal: { 'arbitrum:native': 10n ** 16n + 100n } });163 const p = checkPlan(await pricePlan(ethToUsdc, 10n ** 16n, swapEth), c);164 expect(p.issues.filter((i) => i.blocking).map((i) => [i.code, i.network, i.have])).toEqual([['gas_missing', 'arbitrum', '100']]);165 const poor = checkPlan(await priced(), ctx({ bal: { 'arbitrum:token': 10n ** 18n } }));166 expect(poor.issues[0]).toMatchObject({ code: 'insufficient_balance', blocking: true, leg: 0, symbol: 'LINK', need: LINK4.toString(), have: (10n ** 18n).toString() });167 });168 it('THORChain minimum: a leg below the provider minimum is refused at planning', async () => {169 const legs = enumeratePlans(BTC, USDC('base'), CAPS).find((p) => p[1]!.speed === 'standard')!;170 const c = ctx({ bal: { 'bitcoin:native': 10n ** 8n } });171 expect(checkPlan(await pricePlan(legs, 15_000n, market), c).issues.find((i) => i.blocking)).toMatchObject({ code: 'below_minimum', leg: 0, symbol: 'BTC', need: '20000', have: '15000' });172 expect(checkPlan(await pricePlan(legs, 60_000n, market), c).executable).toBe(true);173 // the THORChain outbound fee is deducted from the output: it is a cost shown, not gas the wallet must hold174 expect((await pricePlan(legs, 60_000n, market)).legs[0]!.fees).toEqual([expect.objectContaining({ kind: 'network', includedInOutput: true, leg: 0 })]);175 });176});177178describe('ranking and comparison-only quotes', () => {179 const mk = (id: string, worst: bigint, expected: bigint, o: Partial<PricedPlan> = {}): PricedPlan => ({ id, legs: [], amountIn: 1n, expectedOut: expected, worstOut: worst, seconds: 100, extraFeesUsd: 0, approvals: 1, issues: [], executable: true, ...o });180 it('ranks on the guaranteed worst case, expected output alongside; a startable plan always comes before one that is not', () => {181 const r = rankPlans([mk('a', 90n, 120n), mk('b', 95n, 100n), mk('c', 99n, 130n, { executable: false }), mk('d', 95n, 101n), mk('e', 95n, 101n, { seconds: 10 })]);182 expect(r.map((p) => p.id)).toEqual(['e', 'd', 'b', 'a', 'c']);183 });184 it('the real three-leg candidates: Standard guarantees more than Fast (no fee), Fast is faster', async () => {185 const plans = rankPlans(await Promise.all(three().map(async (legs) => checkPlan(await pricePlan(legs, LINK4, market), ctx()))));186 expect(plans.map((p) => p.legs[1]!.spec.speed)).toEqual(['standard', 'fast']);187 expect(plans[0]!.worstOut).toBeGreaterThan(plans[1]!.worstOut);188 });189 it('a cheaper quote the wallet cannot execute is never hidden: it is labelled comparison only', () => {190 const plans = [mk('plan', 95n, 100n)];191 const c = comparisonQuotes(plans, [{ id: 'lifi-1', providers: ['lifi'], amountOut: 104n, minAmountOut: 103n, executable: false }, { id: 'debridge-1', providers: ['debridge'], amountOut: 96n, minAmountOut: 94n, executable: false },192 { id: 'cctp-1', providers: ['cctp'], amountOut: 100n, minAmountOut: 100n, executable: true }]);193 expect(c.map((q) => [q.id, q.comparisonOnly, q.aboveBestPlan])).toEqual([['lifi-1', true, true], ['debridge-1', true, false]]);194 expect(comparisonQuotes([], [{ id: 'x', providers: ['lifi'], amountOut: 5n, minAmountOut: 4n, executable: false }])).toEqual([expect.objectContaining({ comparisonOnly: true, aboveBestPlan: true })]);195 });196});197