SPB Git

Repository Public

router.test.ts 17.0 KB 217 lines typescript

1import { describe, expect, it } from 'vitest';2import { NETWORKS } from '@spw/core';3import {4  assessRisk, checkQuote, DEFAULT_POLICY, LiquidityGraph, ProviderError, ProviderRuntime, QuoteAggregator, rank, SwapRouter, valueQuote,5  type AssetRef, type HttpClient, type SwapProvider, type SwapQuote, type SwapQuoteRequest, type Valuer,6} from '../src/index.ts';78const USDC_BASE: AssetRef = { network: 'base', asset: NETWORKS.base.tokens.find((t) => t.symbol === 'USDC')!.id };9const ETH_BASE: AssetRef = { network: 'base', asset: 'native' };10const SOL: AssetRef = { network: 'solana', asset: 'native' };11const USDC_SOL: AssetRef = { network: 'solana', asset: NETWORKS.solana.tokens.find((t) => t.symbol === 'USDC')!.id };12const BTC: AssetRef = { network: 'bitcoin', asset: 'native' };13const noHttp: HttpClient = { getJson: async () => { throw new Error('no network in tests'); }, postJson: async () => { throw new Error('no network in tests'); } };14const PRICE: Record<string, number> = { ETH: 2000, USDC: 1, SOL: 80, BTC: 70000 };15const value: Valuer = (a, amt) => { const n = NETWORKS[a.network]!, d = a.asset === 'native' ? n.native : n.tokens.find((t) => t.id.toLowerCase() === a.asset.toLowerCase())!; const p = PRICE[d.symbol]; return p === undefined ? null : (Number(amt) / 10 ** d.decimals) * p; };16const req = (from: AssetRef, to: AssetRef, amountIn: bigint): SwapQuoteRequest => ({ from, to, amountIn, sender: 'SENDER', recipient: 'RECIPIENT', slippageBps: 50 });1718let qn = 0;19function quoteOf(provider: string, r: SwapQuoteRequest, out: bigint, o: Partial<SwapQuote> = {}): SwapQuote {20  return { id: `${provider}-${++qn}`, provider, request: r, steps: [{ kind: r.from.network === r.to.network ? 'swap' : 'bridge', provider, from: r.from, to: r.to, via: ['pool'], contracts: [] }],21    amountOut: out, minAmountOut: (out * 995n) / 1000n, fees: [], estimatedSeconds: 20, priceImpactBps: 5, expiresAt: Date.now() + 30_000, firmness: 'indicative',22    executability: { class: 'opaque', reason: 'test' }, refundable: true, atomic: true, ...o };23}24/** A scripted provider: `rate(from,to)` gives output per unit input (in base units ratio) or null for "no route". */25function fake(id: string, opts: { pairs?: (a: AssetRef, b: AssetRef) => boolean; out: (r: SwapQuoteRequest) => bigint | null; delayMs?: number; fail?: () => boolean; patch?: Partial<SwapQuote>; needsKey?: boolean }): SwapProvider & { calls: number } {26  const p = { id, name: id, calls: 0,27    capabilities: () => ({ networks: ['base', 'solana', 'bitcoin'], crossChain: true, needsKey: !!opts.needsKey, configured: !opts.needsKey, kinds: ['swap' as const, 'bridge' as const], docs: '' }),28    supportsPair: (a: AssetRef, b: AssetRef) => (opts.pairs ? opts.pairs(a, b) : true),29    async quote(r: SwapQuoteRequest, ctx: { signal: AbortSignal }) {30      p.calls += 1;31      if (opts.delayMs) await new Promise<void>((res, rej) => { const t = setTimeout(res, opts.delayMs); ctx.signal.addEventListener('abort', () => { clearTimeout(t); rej(new Error('aborted')); }); });32      if (opts.fail?.()) throw new ProviderError(id, 'http', 'HTTP 500');33      const out = opts.out(r);34      return out === null ? [] : [quoteOf(id, r, out, opts.patch)];35    } };36  return p;37}38const setup = (providers: SwapProvider[], policy: Partial<typeof DEFAULT_POLICY> = {}) => {39  policy = { ...DEFAULT_POLICY, ratePerSec: 1000, burst: 1000, ...policy };40  const rts = providers.map((p) => new ProviderRuntime(p, policy as typeof DEFAULT_POLICY, noHttp));41  const agg = new QuoteAggregator(rts), graph = new LiquidityGraph(providers);42  return { rts, agg, graph, router: new SwapRouter(agg, graph, value) };43};44// 1 ETH = 2000 USDC, at a provider-specific discount in basis points45const ethToUsdc = (bps: number) => (r: SwapQuoteRequest) => (r.amountIn * 2000n * (10_000n - BigInt(bps))) / 10_000n / 10n ** 12n;46const sameChain = (a: AssetRef, b: AssetRef) => a.network === b.network;4748describe('provider runtime', () => {49  it('enforces the per-provider deadline and reports a timeout', async () => {50    const rt = new ProviderRuntime(fake('slow', { out: () => 1n, delayMs: 300 }), { ...DEFAULT_POLICY, timeoutMs: 40 }, noHttp);51    await expect(rt.quote(req(ETH_BASE, USDC_BASE, 10n ** 18n))).rejects.toMatchObject({ code: 'timeout' });52    expect(rt.stats().timeouts).toBe(1);53  });54  it('opens the circuit after repeated failures, then lets one probe through after the cooldown', async () => {55    let t = 1_000_000, failing = true;56    const p = fake('flaky', { out: ethToUsdc(0), fail: () => failing });57    const rt = new ProviderRuntime(p, { ...DEFAULT_POLICY, breakerFailures: 3, breakerCooldownMs: 10_000, ratePerSec: 1000, burst: 1000 }, noHttp, () => t);58    for (let k = 0; k < 3; k++) await expect(rt.quote(req(ETH_BASE, USDC_BASE, 1n))).rejects.toMatchObject({ code: 'http' });59    expect(rt.stats().state).toBe('open');60    await expect(rt.quote(req(ETH_BASE, USDC_BASE, 1n))).rejects.toMatchObject({ code: 'unavailable' });61    expect(p.calls).toBe(3); // the open circuit did not call the provider62    t += 10_001; expect(rt.stats().state).toBe('half_open');63    await expect(rt.quote(req(ETH_BASE, USDC_BASE, 1n))).rejects.toBeTruthy(); // probe fails: open again64    expect(rt.stats().state).toBe('open');65    t += 10_001; failing = false;66    expect((await rt.quote(req(ETH_BASE, USDC_BASE, 10n ** 18n))).length).toBe(1);67    expect(rt.stats().state).toBe('closed');68  });69  it('rate-limits locally and refills over time', async () => {70    let t = 0;71    const rt = new ProviderRuntime(fake('rl', { out: ethToUsdc(0) }), { ...DEFAULT_POLICY, ratePerSec: 1, burst: 2 }, noHttp, () => t);72    await rt.quote(req(ETH_BASE, USDC_BASE, 1n)); await rt.quote(req(ETH_BASE, USDC_BASE, 1n));73    await expect(rt.quote(req(ETH_BASE, USDC_BASE, 1n))).rejects.toMatchObject({ code: 'rate_limited' });74    t += 1000; await rt.quote(req(ETH_BASE, USDC_BASE, 1n));75  });76  it('a caller that cancels does not count against the provider', async () => {77    const rt = new ProviderRuntime(fake('slow', { out: () => 1n, delayMs: 200 }), DEFAULT_POLICY, noHttp);78    const ac = new AbortController(); setTimeout(() => ac.abort(), 20);79    await expect(rt.quote(req(ETH_BASE, USDC_BASE, 1n), ac.signal)).rejects.toMatchObject({ code: 'timeout' });80    expect(rt.stats().failures).toBe(0);81  });82});8384describe('quote validation (no provider answer is trusted)', () => {85  const r = req(ETH_BASE, USDC_BASE, 10n ** 18n), now = Date.now();86  const good = quoteOf('p', r, 2000_000000n);87  it('accepts a coherent quote', () => expect(checkQuote(good, r, now)).toBeNull());88  it.each([89    ['another pair', { ...good, request: { ...r, to: SOL } }], ['another amount', { ...good, request: { ...r, amountIn: 5n } }],90    ['another recipient', { ...good, request: { ...r, recipient: 'ATTACKER' } }], ['zero output', { ...good, amountOut: 0n, minAmountOut: 0n }],91    ['minimum above expected', { ...good, minAmountOut: good.amountOut + 1n }], ['expired', { ...good, expiresAt: now - 1 }],92    ['float amount', { ...good, amountOut: 1.5 as unknown as bigint }], ['negative fee', { ...good, fees: [{ kind: 'network', asset: ETH_BASE, amount: -1n, includedInOutput: false, estimated: true }] }],93    ['steps end elsewhere', { ...good, steps: [{ ...good.steps[0]!, to: SOL }] }], ['no steps', { ...good, steps: [] }],94    ['broken chain of steps', { ...good, steps: [{ ...good.steps[0]!, to: SOL }, { ...good.steps[0]!, from: BTC }] }],95  ] as [string, SwapQuote][])('rejects: %s', (_n, q) => expect(checkQuote(q, r, now)).not.toBeNull());96});9798describe('aggregator', () => {99  it('queries providers concurrently, streams fastest first, and isolates failures', async () => {100    const { agg } = setup([fake('a', { out: ethToUsdc(10), delayMs: 60 }), fake('b', { out: ethToUsdc(30), delayMs: 5 }), fake('down', { out: () => 1n, fail: () => true }),101      fake('none', { out: () => null }), fake('keyed', { out: () => 1n, needsKey: true }), fake('other-chain', { out: () => 1n, pairs: () => false })]);102    const t0 = Date.now(); const seen: string[] = [];103    for await (const e of agg.stream(req(ETH_BASE, USDC_BASE, 10n ** 18n))) seen.push(e.type === 'quote' ? `q:${e.quote.provider}` : e.type === 'provider' ? `${e.status}:${e.provider}` : 'done');104    expect(Date.now() - t0).toBeLessThan(200); // not 60 + 5 + … sequentially105    expect(seen.indexOf('q:b')).toBeLessThan(seen.indexOf('q:a'));106    expect(seen).toContain('error:down'); expect(seen).toContain('no_route:none'); expect(seen).toContain('skipped:keyed');107    expect(seen.some((s) => s.includes('other-chain'))).toBe(false);108    expect(seen[seen.length - 1]).toBe('done');109  });110  it('drops a provider answer that is for another recipient', async () => {111    const { agg } = setup([fake('evil', { out: ethToUsdc(0), patch: { request: { ...req(ETH_BASE, USDC_BASE, 10n ** 18n), recipient: 'ATTACKER' } } })]);112    const { quotes, events } = await agg.collect(req(ETH_BASE, USDC_BASE, 10n ** 18n));113    expect(quotes.length).toBe(0);114    expect(events.some((e) => e.type === 'provider' && e.code === 'invalid_quote')).toBe(true);115  });116  it('respects the global deadline', async () => {117    const { agg } = setup([fake('slow', { out: () => 1n, delayMs: 2000 }), fake('fast', { out: ethToUsdc(0), delayMs: 5 })], { timeoutMs: 5000 });118    const t0 = Date.now();119    const { quotes } = await agg.collect(req(ETH_BASE, USDC_BASE, 10n ** 18n), { deadlineMs: 120 });120    expect(Date.now() - t0).toBeLessThan(400);121    expect(quotes.map((q) => q.provider)).toEqual(['fast']);122  });123});124125describe('risk engine', () => {126  const r = req(ETH_BASE, USDC_BASE, 10n ** 18n);127  const flags = (q: SwapQuote, peers: SwapQuote[] = []) => assessRisk(q, valueQuote(q, value), peers).flags.map((f) => f.code);128  it('flags a quote that is better than the market as high risk', () => {129    const a = assessRisk(quoteOf('p', r, 2200_000000n), valueQuote(quoteOf('p', r, 2200_000000n), value));130    expect(a.level).toBe('high'); expect(a.flags.map((f) => f.code)).toContain('better_than_market');131  });132  it('flags bad execution, price impact, opaque and cross-chain properties', () => {133    expect(flags(quoteOf('p', r, 1700_000000n))).toContain('far_below_market');134    expect(flags(quoteOf('p', r, 1990_000000n, { priceImpactBps: 350 }))).toContain('high_price_impact');135    expect(flags(quoteOf('p', r, 1990_000000n))).toContain('opaque_transaction');136    const x = req(USDC_BASE, USDC_SOL, 100_000000n);137    expect(flags(quoteOf('p', x, 99_900000n, { refundable: false }))).toEqual(expect.arrayContaining(['cross_chain', 'no_refund_path']));138  });139  it('detects an outlier against peers and unpriced fees', () => {140    const peers = [quoteOf('a', r, 1995_000000n), quoteOf('b', r, 1996_000000n)], out = quoteOf('c', r, 2110_000000n);141    expect(flags(out, [...peers, out])).toContain('outlier_vs_peers');142    const f = quoteOf('d', r, 1995_000000n, { fees: [{ kind: 'network', asset: { network: 'hyperevm', asset: 'native' }, amount: 5n, includedInOutput: false, estimated: true }] });143    expect(flags(f)).toContain('fee_unpriced');144  });145  it('subtracts only the fees that are not already in the output', () => {146    const q = quoteOf('p', r, 2000_000000n, { fees: [{ kind: 'network', asset: ETH_BASE, amount: 10n ** 15n, includedInOutput: false, estimated: true }, { kind: 'protocol', asset: USDC_BASE, amount: 5_000000n, includedInOutput: true, estimated: false }] });147    const v = valueQuote(q, value);148    expect(v.extraFeesUsd).toBeCloseTo(2, 6);149    expect(v.netUsd).toBeCloseTo(1990 - 2, 6);150  });151});152153describe('router', () => {154  it('ranks on guaranteed net output, not on the headline number', async () => {155    const gas = (eth: bigint) => [{ kind: 'network' as const, asset: ETH_BASE, amount: eth, includedInOutput: false, estimated: true }];156    const { router } = setup([fake('headline', { out: ethToUsdc(0), pairs: sameChain, patch: { fees: gas(5n * 10n ** 15n) } }), fake('net', { out: ethToUsdc(20), pairs: sameChain, patch: { fees: gas(10n ** 14n) } })]);157    const s = await router.search(req(ETH_BASE, USDC_BASE, 10n ** 18n));158    expect(s.routes.length).toBe(2);159    expect(s.routes.find((x) => x.id === s.best.best_price)!.providers).toEqual(['net']); // 4 USD better output loses to 9.8 USD more gas160  });161  it('fastest never gives up more than 2 % of the best net output; balanced is explicit', async () => {162    const { router } = setup([fake('cheap-slow', { out: ethToUsdc(0), pairs: sameChain, patch: { estimatedSeconds: 600 } }), fake('fast-ok', { out: ethToUsdc(100), pairs: sameChain, patch: { estimatedSeconds: 15 } }),163      fake('fast-bad', { out: ethToUsdc(260), pairs: sameChain, patch: { estimatedSeconds: 2 } })]);164    const s = await router.search(req(ETH_BASE, USDC_BASE, 10n ** 18n));165    const by = (m: 'best_price' | 'fastest' | 'balanced') => s.routes.find((x) => x.id === s.best[m])!.providers[0];166    expect(by('best_price')).toBe('cheap-slow'); expect(by('fastest')).toBe('fast-ok'); expect(['cheap-slow', 'fast-ok']).toContain(by('balanced'));167    expect(s.routes.filter((x) => x.pareto).map((x) => x.providers[0]).sort()).toEqual(['cheap-slow', 'fast-bad', 'fast-ok']);168  });169  it('never selects a high-risk route while a lower-risk one exists, in any mode', async () => {170    const { router } = setup([fake('honest', { out: ethToUsdc(30), pairs: sameChain }), fake('too-good', { out: (r) => ethToUsdc(0)(r) * 115n / 100n, pairs: sameChain, patch: { estimatedSeconds: 1 } })]);171    const s = await router.search(req(ETH_BASE, USDC_BASE, 10n ** 18n));172    expect(s.routes.find((x) => x.providers[0] === 'too-good')!.risk.level).toBe('high');173    for (const id of Object.values(s.best)) expect(s.routes.find((x) => x.id === id)!.providers).toEqual(['honest']);174  });175  it('builds a route through an intermediate asset when no direct route exists, spending only what leg 1 guarantees', async () => {176    const dex = fake('dex', { pairs: sameChain, out: (r) => (r.from.asset === 'native' ? ethToUsdc(10)(r) : null) });177    const bridge = fake('bridge', { pairs: (a, b) => a.network !== b.network && a.asset !== 'native' && b.asset !== 'native', out: (r) => r.amountIn - 100_000n, patch: { estimatedSeconds: 900 } });178    const { router } = setup([dex, bridge]);179    const s = await router.search(req(ETH_BASE, USDC_SOL, 10n ** 18n));180    expect(s.reachable).toBe(true); expect(s.routes.length).toBe(2); // one per stablecoin hub on Base: USDC, and bridged USDT (registry v5)181    const r = s.routes.find((x) => x.path[1]!.asset === USDC_BASE.asset)!;182    expect(r.providers).toEqual(['dex', 'bridge']);183    expect(r.path.map((p) => `${p.network}:${p.asset === 'native' ? 'native' : 'token'}`)).toEqual(['base:native', 'base:token', 'solana:token']);184    expect(r.legs[1]!.request.amountIn).toBe(r.legs[0]!.minAmountOut);185    expect(r.legs[0]!.request.recipient).toBe('SENDER'); // the intermediate asset stays in the wallet186    expect(r.estimatedSeconds).toBe(920);187    expect(r.risk.flags.map((f) => f.code)).toContain('multi_step');188  });189  it('hides a two-leg route that does not clearly beat the direct one', async () => {190    const direct = fake('direct', { pairs: (a, b) => a.network !== b.network, out: (r) => (r.from.asset === 'native' && r.to.asset !== 'native' ? ethToUsdc(5)(r) : r.from.asset !== 'native' ? r.amountIn - 1000n : null) });191    const dex = fake('dex', { pairs: sameChain, out: (r) => (r.from.asset === 'native' ? ethToUsdc(5)(r) : null) });192    const s = await setup([direct, dex]).router.search(req(ETH_BASE, USDC_SOL, 10n ** 18n));193    expect(s.routes.every((r) => r.legs.length === 1)).toBe(true);194  });195  it('says so when nothing connects the pair, and when providers cover it but none answers', async () => {196    const none = await setup([fake('dex', { pairs: sameChain, out: ethToUsdc(0) })]).router.search(req(BTC, SOL, 100_000n));197    expect(none.reachable).toBe(false); expect(none.routes).toEqual([]); expect(none.best).toEqual({});198    const dry = await setup([fake('x', { out: () => null })]).router.search(req(BTC, SOL, 100_000n));199    expect(dry.reachable).toBe(true); expect(dry.routes).toEqual([]);200  });201  it('rank() is stable on an empty set and with unpriced assets falls back to raw minimum output', () => {202    expect(rank([])).toEqual({});203  });204  it('handles thousands of concurrent route searches', async () => {205    const { router, rts } = setup([fake('a', { out: ethToUsdc(10), pairs: sameChain }), fake('b', { out: ethToUsdc(12), pairs: sameChain }), fake('c', { out: ethToUsdc(8), pairs: sameChain, fail: () => Math.random() < 0.2 })],206      { breakerFailures: 1_000_000, ratePerSec: 1e9, burst: 1e9 });207    const t0 = Date.now();208    const res = await Promise.all(Array.from({ length: 3000 }, (_, k) => router.search(req(ETH_BASE, USDC_BASE, BigInt(k + 1) * 10n ** 15n))));209    const ms = Date.now() - t0;210    expect(res.every((s) => s.routes.length >= 2)).toBe(true);211    expect(res.every((s) => s.routes.find((r) => r.id === s.best.best_price)!.minAmountOut >= s.routes[s.routes.length - 1]!.minAmountOut)).toBe(true);212    expect(rts.reduce((n, r) => n + r.stats().calls, 0)).toBe(27000); // per search and provider: the direct pair, plus both legs through the other stablecoin hub on Base (USDT, registry v5)213    expect(ms).toBeLessThan(8000);214    console.log(`3000 concurrent searches over 3 providers: ${ms} ms (${(ms / 3000).toFixed(2)} ms each)`);215  });216});217
Files main
Menu

Theme