SPB Git

Repository Public

plan.test.ts 18.4 KB 197 lines typescript

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
Files main
Menu

Theme