Repository Public
1// LI.FI adapter against real answers of POST https://li.quest/v1/advanced/routes recorded on 2026-11-10 (fixtures/lifi/*.json;2// the request bodies that produced them are in fixtures/lifi/_requests.json). The recordings are verbatim except for3// `unavailableRoutes`, a diagnostic list the adapter does not read, cut to its first entries (it weighs up to 700 kB per answer).4import { readFileSync } from 'node:fs';5import { describe, expect, it } from 'vitest';6import { NETWORKS } from '@spw/core';7import { checkQuote, DEFAULT_POLICY, httpClient, ProviderError, ProviderRuntime, type AssetRef, type ProviderContext, type SwapQuoteRequest } from '../src/index.ts';8import { createProviders } from '../src/providers/index.ts';9import { createLifiProvider, LIFI_API, LIFI_CHAINS } from '../src/providers/lifi.ts';1011const fx = (name: string): any => JSON.parse(readFileSync(new URL(`./fixtures/lifi/${name}.json`, import.meta.url), 'utf8'));12const REQUESTS = fx('_requests') as Record<string, any>;13const tok = (network: string, symbol: string): AssetRef => ({ network, asset: NETWORKS[network]!.tokens.find((t) => t.symbol === symbol)!.id });14const nat = (network: string): AssetRef => ({ network, asset: 'native' });15const EVM = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', SOL = '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM', TRON = 'TV6MuMXfmLbBqPZvBHdwFsDnQeVfnmiuSi';16const BTC = 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', SUI = '0x7d20dcdb2bca4f508ea9613994683eb4e76e9c4ed371169677c1be02aaf0b58e';17const DIAMOND = '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE', FEE_FORWARDER = '0xDe2b920506EE931a6A2ABDF566526220214FBbAE';18const NOW = 1_794_000_000_000;1920/** the requests that were recorded, as the wallet would express them */21const CASES: Record<string, SwapQuoteRequest> = {22 'base-usdc-sol-usdc': { from: tok('base', 'USDC'), to: tok('solana', 'USDC'), amountIn: 25_000_000n, sender: EVM, recipient: SOL, slippageBps: 50 },23 'eth-eth-sol-sol': { from: nat('ethereum'), to: nat('solana'), amountIn: 12_000_000_000_000_000n, sender: EVM, recipient: SOL, slippageBps: 50 },24 'sol-sol-arb-usdc': { from: nat('solana'), to: tok('arbitrum', 'USDC'), amountIn: 300_000_000n, sender: SOL, recipient: EVM, slippageBps: 50 },25 'arb-eth-arb-usdc': { from: nat('arbitrum'), to: tok('arbitrum', 'USDC'), amountIn: 12_000_000_000_000_000n, sender: EVM, recipient: EVM, slippageBps: 50 },26 'eth-link-sol-jup': { from: tok('ethereum', 'LINK'), to: tok('solana', 'JUP'), amountIn: 3_000_000_000_000_000_000n, sender: EVM, recipient: SOL, slippageBps: 50 },27 'btc-btc-eth-eth': { from: nat('bitcoin'), to: nat('ethereum'), amountIn: 40_000n, sender: BTC, recipient: EVM, slippageBps: 50 },28 'eth-usdc-btc-btc': { from: tok('ethereum', 'USDC'), to: nat('bitcoin'), amountIn: 40_000_000n, sender: EVM, recipient: BTC, slippageBps: 50 },29 'tron-usdt-eth-usdt': { from: tok('tron', 'USDT'), to: tok('ethereum', 'USDT'), amountIn: 30_000_000n, sender: TRON, recipient: EVM, slippageBps: 50 },30 'arb-usdc-tron-trx': { from: tok('arbitrum', 'USDC'), to: nat('tron'), amountIn: 30_000_000n, sender: EVM, recipient: TRON, slippageBps: 50 },31 'sui-sui-base-usdc': { from: nat('sui'), to: tok('base', 'USDC'), amountIn: 10_000_000_000n, sender: SUI, recipient: EVM, slippageBps: 50 },32 'hyper-hype-base-eth': { from: nat('hyperevm'), to: nat('base'), amountIn: 1_000_000_000_000_000_000n, sender: EVM, recipient: EVM, slippageBps: 50 },33 'no-route-dust': { from: tok('base', 'USDC'), to: tok('solana', 'USDC'), amountIn: 10n, sender: EVM, recipient: SOL, slippageBps: 50 },34};3536const lifi = createLifiProvider();37/** context whose HTTP layer answers with `payload` and records what was asked */38function ctxOf(payload: unknown, seen: { url?: string; body?: unknown; headers?: Record<string, string> } = {}): ProviderContext {39 return { signal: new AbortController().signal, now: () => NOW,40 http: { getJson: async () => { throw new Error('LI.FI routes are requested with POST'); }, postJson: async (url, body, init) => { seen.url = url; seen.body = JSON.parse(JSON.stringify(body)); seen.headers = init?.headers; return payload; } } };41}42const run = (name: string, payload: unknown = fx(name), req: SwapQuoteRequest = CASES[name]!) => lifi.quote(req, ctxOf(payload));43/** a deep copy of a recording, modified */44const tamper = (name: string, f: (d: any) => void) => { const d = fx(name); f(d); return d; };45const schemaError = (p: Promise<unknown>, re?: RegExp) => expect(p).rejects.toSatisfy((e: unknown) => e instanceof ProviderError && e.provider === 'lifi' && e.code === 'schema' && (!re || re.test(e.message)));4647describe('lifi: capabilities', () => {48 it('declares the wallet networks LI.FI serves, with its own chain ids, and is registered', () => {49 const c = lifi.capabilities();50 expect(c.networks.sort()).toEqual(['arbitrum', 'avalanche', 'base', 'bitcoin', 'bsc', 'ethereum', 'hyperevm', 'optimism', 'polygon', 'solana', 'sui', 'tron']);51 expect(c).toMatchObject({ crossChain: true, needsKey: false, configured: true, kinds: ['swap', 'bridge'] });52 expect(LIFI_CHAINS.solana!.chainId).toBe(1151111081099710); expect(LIFI_CHAINS.bitcoin!.chainId).toBe(20000000000001);53 expect(LIFI_CHAINS.sui!.chainId).toBe(9270000000000000); expect(LIFI_CHAINS.tron!.chainId).toBe(728126428); expect(LIFI_CHAINS.hyperevm!.chainId).toBe(999);54 for (const [net, c2] of Object.entries(LIFI_CHAINS)) if (NETWORKS[net]!.family === 'evm') expect(String(c2.chainId)).toBe(NETWORKS[net]!.chainRef);55 // Sui's id is above 2^53: it must survive JSON in both directions unchanged56 expect(JSON.stringify({ id: LIFI_CHAINS.sui!.chainId })).toBe('{"id":9270000000000000}'); expect(JSON.parse('{"id":9270000000000000}').id).toBe(LIFI_CHAINS.sui!.chainId);57 expect(createProviders().map((p) => p.id)).toContain('lifi');58 });59 it('filters pairs locally: registry assets on served networks, cross-chain and same-chain, never the same asset', () => {60 expect(lifi.supportsPair(tok('base', 'USDC'), tok('solana', 'USDC'))).toBe(true);61 expect(lifi.supportsPair(nat('arbitrum'), tok('arbitrum', 'USDC'))).toBe(true);62 expect(lifi.supportsPair(nat('bitcoin'), nat('sui'))).toBe(true);63 expect(lifi.supportsPair(nat('base'), nat('base'))).toBe(false);64 expect(lifi.supportsPair(nat('tron'), tok('tron', 'USDT'))).toBe(false); // LI.FI on TRON is cross-chain only65 expect(lifi.supportsPair(nat('solana'), tok('solana', 'USDC'))).toBe(true);66 expect(lifi.supportsPair(nat('base'), { network: 'base', asset: '0x0000000000000000000000000000000000000001' })).toBe(false);67 for (const n of ['litecoin', 'dogecoin', 'bitcoincash', 'stellar', 'xrpl', 'aptos', 'cosmos', 'near', 'ton']) expect(lifi.supportsPair(nat('base'), nat(n))).toBe(false);68 expect(lifi.supportsPair(nat('base'), nat('nowhere'))).toBe(false);69 });70 it('Stellar is left out on evidence: LI.FI lists the chain but answered no route for native XLM', () => {71 expect(fx('no-route-stellar').routes).toEqual([]);72 });73});7475describe('lifi: request', () => {76 it('sends exactly the recorded request: LI.FI chain ids and token ids, base-unit amount as a string, slippage as a fraction, one-transaction routes only', async () => {77 for (const name of Object.keys(CASES)) {78 const seen: { url?: string; body?: unknown; headers?: Record<string, string> } = {};79 await lifi.quote(CASES[name]!, ctxOf(fx(name), seen));80 expect(seen.url).toBe(`${LIFI_API}/advanced/routes`); expect(LIFI_API).toBe('https://li.quest/v1');81 expect(seen.body, name).toEqual(REQUESTS[name]);82 expect(seen.headers).toBeUndefined();83 }84 });85 it('native assets use the ids LI.FI expects on each chain family', async () => {86 const seen: { body?: any } = {};87 await lifi.quote(CASES['btc-btc-eth-eth']!, ctxOf(fx('btc-btc-eth-eth'), seen));88 expect(seen.body).toMatchObject({ fromChainId: 20000000000001, fromTokenAddress: 'bitcoin', toTokenAddress: '0x0000000000000000000000000000000000000000', fromAmount: '40000', fromAddress: BTC, toAddress: EVM });89 await lifi.quote(CASES['sui-sui-base-usdc']!, ctxOf(fx('sui-sui-base-usdc'), seen));90 expect(seen.body.fromTokenAddress).toBe('0x0000000000000000000000000000000000000000000000000000000000000002::sui::SUI');91 await lifi.quote(CASES['arb-usdc-tron-trx']!, ctxOf(fx('arb-usdc-tron-trx'), seen));92 expect(seen.body.toTokenAddress).toBe('T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb');93 await lifi.quote(CASES['sol-sol-arb-usdc']!, ctxOf(fx('sol-sol-arb-usdc'), seen));94 expect(seen.body.fromTokenAddress).toBe('11111111111111111111111111111111');95 });96 it('maps basis points to the fraction LI.FI uses and passes an API key and integrator only when configured', async () => {97 const seen: { body?: any; headers?: Record<string, string> } = {};98 const keyed = createLifiProvider({ LIFI_API_KEY: 'k-123', LIFI_INTEGRATOR: 'spw-test' });99 await keyed.quote({ ...CASES['no-route-dust']!, slippageBps: 125 }, ctxOf({ routes: [] }, seen));100 expect(seen.body.options).toEqual({ slippage: 0.0125, integrator: 'spw-test', allowSwitchChain: false });101 expect(seen.headers).toEqual({ 'x-lifi-api-key': 'k-123' });102 expect(keyed.capabilities()).toMatchObject({ needsKey: false, configured: true });103 await expect(lifi.quote({ ...CASES['no-route-dust']!, slippageBps: 0.5 }, ctxOf({ routes: [] }))).rejects.toBeInstanceOf(ProviderError);104 await expect(lifi.quote({ ...CASES['no-route-dust']!, amountIn: 0n }, ctxOf({ routes: [] }))).rejects.toBeInstanceOf(ProviderError);105 });106});107108describe('lifi: field mapping (EVM -> Solana, USDC Base -> USDC Solana, 25 USDC)', () => {109 it('returns every single-transaction route as a quote that passes the aggregator checks', async () => {110 const quotes = await run('base-usdc-sol-usdc');111 expect(quotes.map((q) => q.steps[0]!.via.join('+'))).toEqual(['Polymer (Standard)', 'NearIntents', 'Mayan (Swift)', 'Relay', 'LI.FI Intents', 'Layerswap', 'CCTPv2 + Mayan', 'CCTP + Mayan']);112 for (const q of quotes) { expect(checkQuote(q, CASES['base-usdc-sol-usdc']!, NOW)).toBeNull(); expect(q.provider).toBe('lifi'); }113 expect(new Set(quotes.map((q) => q.id)).size).toBe(quotes.length);114 });115 it('maps amounts, the provider minimum, duration, fees, contracts and flags of the cheapest route exactly', async () => {116 const q = (await run('base-usdc-sol-usdc'))[0]!, route = fx('base-usdc-sol-usdc').routes[0];117 expect(q.id).toBe(`lifi:${route.id}`);118 expect(q.amountOut).toBe(24_937_500n); expect(q.minAmountOut).toBe(24_937_500n); // CCTP-style transfer: the provider's minimum equals its estimate119 expect(q.estimatedSeconds).toBe(1080); expect(q.priceImpactBps).toBeNull(); expect(q.expiresAt).toBe(NOW + 30_000);120 expect(q.firmness).toBe('indicative'); expect(q.atomic).toBe(false); expect(q.refundable).toBeNull();121 expect(q.executability.class).toBe('opaque'); expect((q.executability as { reason: string }).reason).toMatch(/Diamond/);122 expect(q.steps).toEqual([{ kind: 'bridge', provider: 'lifi', from: CASES['base-usdc-sol-usdc']!.from, to: CASES['base-usdc-sol-usdc']!.to, via: ['Polymer (Standard)'],123 contracts: [{ network: 'base', address: DIAMOND, role: 'router' }, { network: 'base', address: DIAMOND, role: 'spender' }, { network: 'base', address: FEE_FORWARDER, role: 'router' }] }]);124 expect(q.fees).toEqual([125 { kind: 'network', asset: nat('base'), amount: 2_040_000_000_000n, includedInOutput: false, estimated: true }, // gas on Base, in wei126 { kind: 'provider', asset: tok('base', 'USDC'), amount: 62_500n, includedInOutput: true, estimated: false }, // LI.FI fixed fee, 0.25 % of 25 USDC127 ]);128 expect(25_000_000n - 62_500n).toBe(q.amountOut); // the fee is inside the output, as `included: true` says129 expect((q.raw as { id: string }).id).toBe(route.id);130 expect(JSON.stringify(q.raw)).not.toMatch(/transactionRequest|logoURI/); // validated payload only; advanced/routes carries no calldata131 });132 it('uses the minimum LI.FI guarantees when a route has slippage, and classifies bridge fees', async () => {133 const q = (await run('base-usdc-sol-usdc')).find((x) => x.steps[0]!.via[0] === 'Relay')!;134 expect(q.amountOut).toBe(24_909_845n); expect(q.minAmountOut).toBe(24_785_296n);135 // 0.5 % applied by LI.FI on the bridge leg: floor(out * 0.995) rounded in LI.FI's favour by at most one unit136 expect(q.minAmountOut - (q.amountOut * 9950n) / 10_000n).toBeLessThanOrEqual(1n); expect(q.minAmountOut).toBeLessThan(q.amountOut);137 expect(q.estimatedSeconds).toBe(1);138 expect(q.fees.map((f) => [f.kind, f.amount, f.includedInOutput, f.estimated])).toEqual([139 ['network', 1_296_000_000_000n, false, true], ['provider', 62_500n, true, false], ['bridge', 171n, true, false], ['bridge', 27_484n, true, false]]);140 expect(q.steps[0]!.contracts).toContainEqual({ network: 'base', address: '0xf70da97812CB96acDF810712Aa562db8dfA3dbEF', role: 'bridge' });141 });142});143144describe('lifi: multi-step routes', () => {145 it('represents swap + bridge as two steps in order when the intermediate asset is in the registry', async () => {146 const quotes = await run('eth-link-sol-jup'), req = CASES['eth-link-sol-jup']!;147 const viaEth = quotes.find((q) => q.steps.length === 2 && q.steps[1]!.via[0] === 'Mayan (Swift)')!;148 expect(viaEth.steps.map((s) => [s.kind, s.from, s.to, s.via])).toEqual([149 ['swap', req.from, nat('ethereum'), ['Nordstern Finance']],150 ['bridge', nat('ethereum'), req.to, ['Mayan (Swift)']]]);151 expect(viaEth.steps[0]!.contracts.map((c) => c.role)).toEqual(['router', 'spender', 'router', 'router']); // Diamond, Diamond as spender, FeeForwarder, the DEX152 expect(viaEth.atomic).toBe(false);153 const viaUsdc = quotes.find((q) => q.steps.length === 2 && q.steps[1]!.via[0] === 'CCTPv2 + Mayan')!;154 expect(viaUsdc.steps[0]!.to).toEqual(tok('ethereum', 'USDC')); expect(viaUsdc.steps[1]!.from).toEqual(tok('ethereum', 'USDC'));155 expect(viaUsdc.fees).toContainEqual({ kind: 'bridge', asset: tok('ethereum', 'USDC'), amount: 381_124n, includedInOutput: true, estimated: false });156 expect(viaUsdc.fees).toContainEqual({ kind: 'provider', asset: req.from, amount: 7_500_000_000_000_000n, includedInOutput: true, estimated: false });157 for (const q of quotes) expect(checkQuote(q, req, NOW)).toBeNull();158 });159 it('merges legs that go through an asset outside the registry, keeping the venues in order and never naming that asset', async () => {160 const q = (await run('eth-link-sol-jup'))[0]!, req = CASES['eth-link-sol-jup']!; // LINK -> cirBTC (Kyberswap) -> JUP (Relay)161 expect(q.steps).toHaveLength(1);162 expect(q.steps[0]).toMatchObject({ kind: 'bridge', from: req.from, to: req.to, via: ['Kyberswap', 'Relay'] });163 expect(q.amountOut).toBe(121_318_437n); expect(q.minAmountOut).toBe(120_109_147n);164 // the relayer fees are charged in cirBTC and already inside the output: they cannot be a FeeItem, they stay in raw165 expect(q.fees.map((f) => f.kind)).toEqual(['network', 'provider']);166 expect(JSON.stringify(q.raw)).toMatch(/cirBTC/);167 expect(JSON.stringify({ steps: q.steps, fees: q.fees }, (_k, v) => (typeof v === 'bigint' ? v.toString() : v))).not.toMatch(/0x72DFB2E44f59C5AD2bAFE84314E5b99a7cd5075E/i);168 });169 it('same-chain swaps are atomic single-step quotes', async () => {170 const quotes = await run('arb-eth-arb-usdc'), req = CASES['arb-eth-arb-usdc']!;171 expect(quotes).toHaveLength(7);172 for (const q of quotes) {173 expect(q.steps).toHaveLength(1); expect(q.steps[0]!.kind).toBe('swap'); expect(q.atomic).toBe(true); expect(q.refundable).toBe(true); expect(q.estimatedSeconds).toBe(0);174 expect(q.steps[0]!.contracts.filter((c) => c.role === 'spender')).toEqual([]); // native input: nothing to approve175 expect(checkQuote(q, req, NOW)).toBeNull();176 }177 expect(quotes[0]!.steps[0]!.via).toEqual(['SushiSwap Aggregator']); expect(quotes[0]!.amountOut).toBe(24_452_933n); expect(quotes[0]!.minAmountOut).toBe(24_330_668n);178 expect(quotes[0]!.fees[1]).toEqual({ kind: 'provider', asset: nat('arbitrum'), amount: 30_000_000_000_000n, includedInOutput: true, estimated: false });179 });180});181182describe('lifi: other chain families and decimals', () => {183 it('Solana source: lamports in, 6-decimal USDC out, no EVM contract attributed to Solana', async () => {184 const quotes = await run('sol-sol-arb-usdc'), req = CASES['sol-sol-arb-usdc']!;185 expect(quotes).toHaveLength(7);186 expect(quotes[0]!.amountOut).toBe(24_780_166n); expect(quotes[0]!.minAmountOut).toBe(24_656_265n);187 expect(quotes[0]!.fees[0]).toEqual({ kind: 'network', asset: nat('solana'), amount: 135_000n, includedInOutput: false, estimated: true });188 expect(quotes[0]!.fees[1]).toEqual({ kind: 'provider', asset: nat('solana'), amount: 750_000n, includedInOutput: true, estimated: false });189 for (const q of quotes) { for (const c of q.steps.flatMap((s) => s.contracts)) expect(c.address.startsWith('0x')).toBe(false); expect(checkQuote(q, req, NOW)).toBeNull(); }190 const relay = quotes.find((q) => q.steps[0]!.via[0] === 'Relay')!;191 expect(relay.steps[0]!.contracts).toEqual([{ network: 'solana', address: 'G9sGMKGAdDDqTwPDuqYnxBPSoQk6A2KPbcJGoHpq2HPV', role: 'bridge' }]);192 // Chainflip charges fees in three assets on three chains, all inside the output193 const chainflip = quotes.find((q) => q.steps[0]!.via[0] === 'Chainflip')!;194 expect(chainflip.estimatedSeconds).toBe(168);195 expect(chainflip.fees.filter((f) => f.kind === 'bridge').map((f) => [f.asset, f.amount])).toEqual([[nat('solana'), 395_000n], [tok('ethereum', 'USDC'), 500_000n], [tok('ethereum', 'USDC'), 24_306n], [tok('arbitrum', 'USDC'), 4_121n]]);196 });197 it('Bitcoin in both directions: satoshis and wei are never mixed up', async () => {198 const out = await run('btc-btc-eth-eth');199 expect(out[0]!.amountOut).toBe(14_275_195_224_876_807n); expect(out[0]!.minAmountOut).toBe(14_203_819_248_752_422n); expect(out[0]!.estimatedSeconds).toBe(1712);200 expect(out[0]!.fees[0]).toEqual({ kind: 'network', asset: nat('bitcoin'), amount: 915n, includedInOutput: false, estimated: true });201 expect(out.find((q) => q.steps[0]!.via[0] === 'Relay')!.steps[0]!.contracts).toEqual([{ network: 'bitcoin', address: 'bc1qz8p3w089t3tq3uvqmzhgdh5vuzm7gxr45xjppj', role: 'bridge' }]);202 const back = await run('eth-usdc-btc-btc');203 expect(back[0]!.amountOut).toBe(53_701n); expect(back[0]!.minAmountOut).toBe(53_433n); expect(back[0]!.steps[0]!.to).toEqual(nat('bitcoin'));204 for (const q of [...out, ...back]) expect(q.atomic).toBe(false);205 });206 it('TRON, Sui and HyperEVM sources use their own Diamond or none', async () => {207 const tron = await run('tron-usdt-eth-usdt');208 expect(tron[0]!.steps[0]!.contracts.slice(0, 2)).toEqual([{ network: 'tron', address: 'TU3ymitEKCWQFtASkEeHaPb8NfZcJtCHLt', role: 'router' }, { network: 'tron', address: 'TU3ymitEKCWQFtASkEeHaPb8NfZcJtCHLt', role: 'spender' }]);209 expect(tron[0]!.fees[0]).toEqual({ kind: 'network', asset: nat('tron'), amount: 66_000_000n, includedInOutput: false, estimated: true }); // 66 TRX of energy210 expect(tron[0]!.amountOut).toBe(29_530_218n);211 const toTron = await run('arb-usdc-tron-trx');212 expect(toTron[0]!.amountOut).toBe(93_798_547n); expect(toTron[0]!.steps[0]!.to).toEqual(nat('tron'));213 const sui = await run('sui-sui-base-usdc');214 expect(sui[0]!.steps[0]!.contracts).toEqual([]); expect(sui[0]!.amountOut).toBe(8_219_844n);215 expect(sui[0]!.fees[0]).toEqual({ kind: 'network', asset: nat('sui'), amount: 43_000_000n, includedInOutput: false, estimated: true });216 const hype = await run('hyper-hype-base-eth');217 expect(hype[0]!.steps[0]!.contracts[0]).toEqual({ network: 'hyperevm', address: '0x0a0758d937d1059c356D4714e57F5df0239bce1A', role: 'router' });218 expect(hype[0]!.amountOut).toBe(34_749_813_921_318_502n);219 });220});221222describe('lifi: no route and provider errors', () => {223 const viaFetch = (status: number, body: string) => ({ signal: new AbortController().signal, now: () => NOW, http: httpClient('lifi', (async () => new Response(body, { status })) as typeof fetch) });224 it('an answer without routes is an empty array', async () => {225 expect(await run('no-route-dust')).toEqual([]);226 });227 it('a token LI.FI does not list (HTTP 404, code 1003) is no route; other failures throw', async () => {228 const body = JSON.stringify(fx('error-unknown-token'));229 expect(JSON.parse(body).code).toBe(1003);230 expect(await lifi.quote(CASES['base-usdc-sol-usdc']!, viaFetch(404, body))).toEqual([]);231 await expect(lifi.quote(CASES['base-usdc-sol-usdc']!, viaFetch(400, '{"message":"Invalid request","code":1011}'))).rejects.toMatchObject({ code: 'http' });232 await expect(lifi.quote(CASES['base-usdc-sol-usdc']!, viaFetch(500, '{"message":"boom"}'))).rejects.toMatchObject({ code: 'http' });233 await expect(lifi.quote(CASES['base-usdc-sol-usdc']!, viaFetch(429, '{"message":"Rate limit exceeded"}'))).rejects.toMatchObject({ code: 'rate_limited' });234 await expect(lifi.quote(CASES['base-usdc-sol-usdc']!, viaFetch(200, '<html>'))).rejects.toMatchObject({ code: 'bad_response' });235 });236 it('does not call the API for a pair it cannot serve', async () => {237 const http = { getJson: async () => { throw new Error('called'); }, postJson: async () => { throw new Error('called'); } };238 expect(await lifi.quote({ ...CASES['base-usdc-sol-usdc']!, to: nat('litecoin') }, { signal: new AbortController().signal, now: () => NOW, http })).toEqual([]);239 });240 it('reports a timeout when the deadline aborts the request', async () => {241 const slow = (async (_u: unknown, init?: RequestInit) => new Promise<Response>((_res, rej) => init!.signal!.addEventListener('abort', () => rej(Object.assign(new Error('aborted'), { name: 'AbortError' }))))) as typeof fetch;242 const rt = new ProviderRuntime(lifi, { ...DEFAULT_POLICY, timeoutMs: 30 }, httpClient('lifi', slow));243 await expect(rt.quote(CASES['base-usdc-sol-usdc']!)).rejects.toMatchObject({ code: 'timeout' });244 expect(rt.stats().timeouts).toBe(1);245 const ac = new AbortController(); ac.abort();246 const aborted = (async (_u: unknown, init?: RequestInit) => { if (init?.signal?.aborted) throw Object.assign(new Error('aborted'), { name: 'AbortError' }); return new Response('{}'); }) as typeof fetch;247 await expect(lifi.quote(CASES['base-usdc-sol-usdc']!, { signal: ac.signal, now: () => NOW, http: httpClient('lifi', aborted) })).rejects.toMatchObject({ code: 'timeout' });248 });249});250251describe('lifi: malformed and hostile answers are rejected', () => {252 const name = 'base-usdc-sol-usdc';253 it('rejects answers that are not the documented shape', async () => {254 await schemaError(run(name, null)); await schemaError(run(name, [])); await schemaError(run(name, {})); await schemaError(run(name, { routes: 'none' }));255 await schemaError(run(name, tamper(name, (d) => { delete d.routes[0].toAmountMin; })), /toAmountMin/);256 await schemaError(run(name, tamper(name, (d) => { delete d.routes[2].steps[0].estimate.executionDuration; })));257 await schemaError(run(name, tamper(name, (d) => { d.routes[0].steps[0].includedSteps = []; })));258 await schemaError(run(name, tamper(name, (d) => { d.routes[0].steps[0].includedSteps[1].type = 'teleport'; })));259 });260 it('rejects amounts that are not plain base-unit integers', async () => {261 for (const v of ['24937500.5', '-24937500', '2.49375e7', '0x17c8a1c', '', ' 24937500', 24937500, null]) {262 await schemaError(run(name, tamper(name, (d) => { d.routes[0].toAmount = v; })), /toAmount/);263 await schemaError(run(name, tamper(name, (d) => { d.routes[0].steps[0].estimate.feeCosts[0].amount = v; })));264 await schemaError(run(name, tamper(name, (d) => { d.routes[0].steps[0].estimate.gasCosts[0].amount = v; })));265 }266 await schemaError(run(name, tamper(name, (d) => { d.routes[0].toAmount = '0'; d.routes[0].toAmountMin = '0'; })), /inconsistent/);267 await schemaError(run(name, tamper(name, (d) => { d.routes[0].toAmountMin = '24937501'; })), /inconsistent/); // minimum above the estimate268 await schemaError(run(name, tamper(name, (d) => { d.routes[0].steps[0].estimate.toAmount = '99937500'; })), /differ/);269 });270 it('rejects a route for another token, another chain, other decimals or another amount', async () => {271 await schemaError(run(name, tamper(name, (d) => { d.routes[0].toToken.address = 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB'; })), /another pair/); // USDT instead of USDC272 await schemaError(run(name, tamper(name, (d) => { d.routes[0].toToken.address = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1x'; })), /another pair/); // not in the registry273 await schemaError(run(name, tamper(name, (d) => { d.routes[0].toChainId = 42161; })), /another pair/);274 await schemaError(run(name, tamper(name, (d) => { d.routes[0].fromToken.address = '0x0000000000000000000000000000000000000000'; d.routes[0].fromToken.decimals = 18; })), /another pair/);275 await schemaError(run(name, tamper(name, (d) => { d.routes[0].toToken.decimals = 9; })), /decimals/);276 await schemaError(run(name, tamper(name, (d) => { d.routes[0].fromAmount = '2500000'; })), /another amount/);277 await schemaError(run(name, tamper(name, (d) => { d.routes[0].steps[0].includedSteps[1].action.toToken.address = 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB'; })), /connect/);278 await schemaError(run(name, tamper(name, (d) => { d.routes[0].steps[0].includedSteps[1].action.fromToken.address = '0x0000000000000000000000000000000000000000'; })), /contiguous/);279 });280 it('rejects output for another recipient, another sender, another slippage or another spender', async () => {281 const other = 'DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy';282 await schemaError(run(name, tamper(name, (d) => { d.routes[0].toAddress = other; })), /recipient/);283 await schemaError(run(name, tamper(name, (d) => { d.routes[0].steps[0].action.toAddress = other; })), /addresses/);284 await schemaError(run(name, tamper(name, (d) => { d.routes[0].fromAddress = '0x000000000000000000000000000000000000dEaD'; })), /sender/);285 await schemaError(run(name, fx(name), { ...CASES[name]!, recipient: other }), /recipient/);286 await schemaError(run(name, fx(name), { ...CASES[name]!, slippageBps: 100 }), /slippage/);287 await schemaError(run(name, tamper(name, (d) => { d.routes[0].steps[0].estimate.approvalAddress = '0x000000000000000000000000000000000000dEaD'; })), /Diamond/);288 await schemaError(run(name, tamper(name, (d) => { delete d.routes[0].steps[0].estimate.approvalAddress; })), /Diamond/);289 // checksum-case differences on EVM addresses are not differences290 expect(await run(name, fx(name), { ...CASES[name]!, sender: EVM.toLowerCase() })).toHaveLength(8);291 });292 it('rejects gas quoted in an asset the wallet does not hold, and drops (without failing) routes it cannot represent', async () => {293 await schemaError(run(name, tamper(name, (d) => { d.routes[0].steps[0].estimate.gasCosts[0].token.chainId = 324; })), /gas/);294 // a second user transaction (destination-chain signature) is not a route this wallet can run295 const two = await run(name, tamper(name, (d) => { d.routes[0].steps.push(d.routes[0].steps[0]); }));296 expect(two).toHaveLength(7);297 const sw = await run(name, tamper(name, (d) => { d.routes[1].containsSwitchChain = true; }));298 expect(sw).toHaveLength(7);299 // a fee to pay on top in an asset outside the registry300 const fee = await run(name, tamper(name, (d) => { const f = d.routes[0].steps[0].estimate.feeCosts[0]; f.included = false; f.token.address = '0x000000000000000000000000000000000000dEaD'; }));301 expect(fee).toHaveLength(7);302 // a fee to pay on top in a registry asset is reported as such303 const onTop = await run(name, tamper(name, (d) => { d.routes[0].steps[0].estimate.feeCosts[0].included = false; }));304 expect(onTop[0]!.fees[1]).toMatchObject({ kind: 'provider', includedInOutput: false });305 });306 it('never returns more quotes than a sane answer holds', async () => {307 const many = await run(name, tamper(name, (d) => { d.routes = Array.from({ length: 40 }, (_x, k) => ({ ...d.routes[0], id: `r${k}` })); }));308 expect(many).toHaveLength(12);309 await schemaError(run(name, tamper(name, (d) => { d.routes = Array.from({ length: 65 }, () => d.routes[0]); })));310 });311});312