SPB Git

Repository Public

kyberswap.test.ts 15.9 KB 223 lines typescript

1// KyberSwap adapter against real answers recorded from the live API on 2026-11-10 (scripts/record-swap-kyberswap-fixtures.mts).2import { readFileSync } from 'node:fs';3import { describe, expect, it } from 'vitest';4import { NETWORKS } from '@spw/core';5import { checkQuote, httpClient, ProviderError, ProviderRuntime, DEFAULT_POLICY, type AssetRef, type HttpClient, type ProviderContext, type SwapQuoteRequest } from '../src/index.ts';6import { createProviders } from '../src/providers/index.ts';7import { createKyberswapProvider, KYBERSWAP_NATIVE, KYBERSWAP_ROUTER } from '../src/providers/kyberswap.ts';89interface Fixture { recordedAt: string; url: string; status: number; body: any }10const fx = (name: string): Fixture => JSON.parse(readFileSync(new URL(`./fixtures/kyberswap/${name}.json`, import.meta.url), 'utf8'));11const tok = (network: string, symbol: string): AssetRef => ({ network, asset: NETWORKS[network]!.tokens.find((t) => t.symbol === symbol)!.id });12const native = (network: string): AssetRef => ({ network, asset: 'native' });13const SENDER = '0x1111111111111111111111111111111111111111', RECIPIENT = SENDER;14const req = (from: AssetRef, to: AssetRef, amountIn: bigint, slippageBps = 50): SwapQuoteRequest => ({ from, to, amountIn, sender: SENDER, recipient: RECIPIENT, slippageBps });1516/** Replays a recorded HTTP exchange through the real http client (so status handling is the production path). */17function replay(f: Fixture, patch?: (body: any) => unknown) {18  const calls: { url: string; headers: Record<string, string> }[] = [];19  const fetchImpl = (async (url: string, init: RequestInit) => {20    calls.push({ url, headers: init.headers as Record<string, string> });21    const body = patch ? patch(structuredClone(f.body)) : f.body;22    return new Response(typeof body === 'string' ? body : JSON.stringify(body), { status: f.status, headers: { 'content-type': 'application/json' } });23  }) as unknown as typeof fetch;24  const now = Date.parse(f.recordedAt);25  const ctx: ProviderContext = { signal: new AbortController().signal, http: httpClient('kyberswap', fetchImpl), now: () => now };26  return { ctx, calls, now };27}28const p = createKyberswapProvider();29const CASES: [fixture: string, from: AssetRef, to: AssetRef, amountIn: bigint][] = [30  ['ethereum-eth-usdc', native('ethereum'), tok('ethereum', 'USDC'), 10n ** 16n],31  ['ethereum-usdc-wbtc', tok('ethereum', 'USDC'), tok('ethereum', 'WBTC'), 50_000_000n],32  ['base-usdc-eth', tok('base', 'USDC'), native('base'), 25_000_000n],33  ['arbitrum-usdc-link', tok('arbitrum', 'USDC'), tok('arbitrum', 'LINK'), 30_000_000n],34  ['polygon-pol-usdc', native('polygon'), tok('polygon', 'USDC'), 100n * 10n ** 18n],35  ['optimism-eth-usdc', native('optimism'), tok('optimism', 'USDC'), 10n ** 16n],36  ['bsc-bnb-fdusd', native('bsc'), tok('bsc', 'FDUSD'), 4n * 10n ** 16n],37  ['avalanche-usdc-usdt', tok('avalanche', 'USDC'), tok('avalanche', 'USDT'), 20_000_000n],38  ['hyperevm-hype-usdc', native('hyperevm'), tok('hyperevm', 'USDC'), 10n ** 18n],39];4041describe('kyberswap: capabilities and pair filter', () => {42  it('is registered, keyless, same-chain only, on the eight EVM networks', () => {43    expect(createProviders().map((x) => x.id)).toContain('kyberswap');44    const c = p.capabilities();45    expect(c).toMatchObject({ crossChain: false, needsKey: false, configured: true, kinds: ['swap'] });46    expect(c.networks.sort()).toEqual(['arbitrum', 'avalanche', 'base', 'bsc', 'ethereum', 'hyperevm', 'optimism', 'polygon']);47    for (const n of c.networks) expect(NETWORKS[n]!.family).toBe('evm');48  });49  it('supportsPair is local and strict', () => {50    expect(p.supportsPair(native('base'), tok('base', 'USDC'))).toBe(true);51    expect(p.supportsPair(native('base'), native('base'))).toBe(false);52    expect(p.supportsPair(native('base'), tok('ethereum', 'USDC'))).toBe(false); // cross-chain53    expect(p.supportsPair(native('solana'), tok('solana', 'USDC'))).toBe(false);54    expect(p.supportsPair(native('base'), { network: 'base', asset: '0x000000000000000000000000000000000000dEaD' })).toBe(false); // not in the registry55    expect(p.supportsPair({ network: 'base', asset: tok('base', 'USDC').asset.toLowerCase() }, native('base'))).toBe(true);56  });57  it('rejects a client id that could break out of a header', () => {58    expect(() => createKyberswapProvider({ KYBERSWAP_CLIENT_ID: 'a\r\nx: y' })).toThrow();59  });60});6162describe('kyberswap: field mapping from recorded answers', () => {63  it.each(CASES)('%s', async (name, from, to, amountIn) => {64    const f = fx(name), { ctx, calls, now } = replay(f), r = req(from, to, amountIn);65    const quotes = await p.quote(r, ctx);66    expect(quotes).toHaveLength(1);67    const q = quotes[0]!, s = f.body.data.routeSummary;68    // the request the adapter sent is the one that was recorded69    expect(calls).toHaveLength(1);70    expect(calls[0]!.url).toBe(f.url);71    expect(calls[0]!.headers['x-client-id']).toBe('swap-router');72    expect(calls[0]!.url).not.toContain(SENDER.slice(2)); // the wallet address is not disclosed for price discovery73    // amounts: exact strings -> bigint74    expect(q.amountOut.toString()).toBe(s.amountOut);75    expect(q.minAmountOut).toBe((BigInt(s.amountOut) * 9950n) / 10_000n);76    expect(q.fees).toEqual([{ kind: 'network', asset: native(from.network), amount: BigInt(s.gas) * BigInt(s.gasPrice), includedInOutput: false, estimated: true }]);77    expect(q.estimatedSeconds).toBeNull();78    expect(q.expiresAt).toBe(now + 10_000);79    expect(q.priceImpactBps).toBe(Math.round(((Number(s.amountInUsd) - Number(s.amountOutUsd)) / Number(s.amountInUsd)) * 10_000));80    expect(Math.abs(q.priceImpactBps!)).toBeLessThan(100); // small liquid trades81    expect(q).toMatchObject({ provider: 'kyberswap', id: `kyberswap-${s.routeID}`, firmness: 'indicative', atomic: true, refundable: true, request: r });82    expect(q.executability.class).toBe('opaque');83    expect(q.steps).toHaveLength(1);84    expect(q.steps[0]).toMatchObject({ kind: 'swap', provider: 'kyberswap', from, to });85    expect(q.steps[0]!.via).toEqual([...new Set((s.route as any[][]).flat().map((h) => h.exchange))]);86    const roles = q.steps[0]!.contracts.map((c) => c.role);87    expect(roles).toEqual(from.asset === 'native' ? ['router'] : ['router', 'spender']); // an approval is only needed for a token input88    for (const c of q.steps[0]!.contracts) expect(c).toMatchObject({ network: from.network, address: KYBERSWAP_ROUTER });89    expect(q.raw).toEqual(f.body.data); // untouched payload, as /route/build needs it90    expect(checkQuote(q, r, now)).toBeNull();91  });9293  it('decimals: 0.01 ETH -> about 20 USDC (6 decimals), 50 USDC -> WBTC (8 decimals)', async () => {94    const a = (await p.quote(req(native('ethereum'), tok('ethereum', 'USDC'), 10n ** 16n), replay(fx('ethereum-eth-usdc')).ctx))[0]!;95    expect(a.amountOut).toBeGreaterThan(15_000_000n); expect(a.amountOut).toBeLessThan(30_000_000n);96    const b = (await p.quote(req(tok('ethereum', 'USDC'), tok('ethereum', 'WBTC'), 50_000_000n), replay(fx('ethereum-usdc-wbtc')).ctx))[0]!;97    expect(b.amountOut).toBeGreaterThan(20_000n); expect(b.amountOut).toBeLessThan(200_000n); // 0.0002–0.002 BTC98  });99  it('native coin is sent as 0xEeee…, tokens by their registry address', () => {100    expect(fx('base-usdc-eth').url).toContain(`tokenOut=${KYBERSWAP_NATIVE}`);101    expect(fx('base-usdc-eth').url).toContain(`tokenIn=${tok('base', 'USDC').asset}`);102  });103});104105describe('kyberswap: slippage', () => {106  const f = fx('arbitrum-usdc-link'), out = BigInt(f.body.data.routeSummary.amountOut);107  const run = (bps: number) => p.quote(req(tok('arbitrum', 'USDC'), tok('arbitrum', 'LINK'), 30_000_000n, bps), replay(f).ctx);108  it.each([[0, out], [1, (out * 9999n) / 10_000n], [50, (out * 9950n) / 10_000n], [300, (out * 9700n) / 10_000n], [2000, (out * 8000n) / 10_000n]])('%i bps', async (bps, min) => {109    const q = (await run(bps))[0]!;110    expect(q.minAmountOut).toBe(min); expect(q.minAmountOut <= q.amountOut).toBe(true);111  });112  it('rounds the minimum down, never up', async () => {113    const g = fx('avalanche-usdc-usdt'), o = BigInt(g.body.data.routeSummary.amountOut); // 20021713 * 0.9999 is not an integer114    const q = (await p.quote(req(tok('avalanche', 'USDC'), tok('avalanche', 'USDT'), 20_000_000n, 1), replay(g).ctx))[0]!;115    expect((o * 9999n) % 10_000n).not.toBe(0n);116    expect(q.minAmountOut).toBe(o - o / 10_000n - 1n);117  });118  it('above the 2000 bps the provider accepts: no quote, and no request', async () => {119    const r = replay(f); expect(await p.quote(req(tok('arbitrum', 'USDC'), tok('arbitrum', 'LINK'), 30_000_000n, 2001), r.ctx)).toEqual([]); expect(r.calls).toHaveLength(0);120  });121  it('fractional or negative slippage is refused', async () => {122    await expect(run(0.5)).rejects.toBeInstanceOf(ProviderError);123    await expect(run(-1)).rejects.toBeInstanceOf(ProviderError);124  });125});126127describe('kyberswap: no route and provider errors (recorded)', () => {128  const eth = native('ethereum'), usdc = tok('ethereum', 'USDC');129  it('4008 route not found -> empty', async () => { expect(await p.quote(req(eth, usdc, 1n), replay(fx('no-route-1-wei')).ctx)).toEqual([]); });130  it('4011 token not found -> empty', async () => { expect(await p.quote(req(eth, usdc, 10n ** 16n), replay(fx('error-unknown-token')).ctx)).toEqual([]); });131  it('4009 amount above the maximum -> empty', async () => { expect(await p.quote(req(eth, usdc, 10n ** 33n), replay(fx('error-amount-too-large')).ctx)).toEqual([]); });132  it('4000 bad request -> throws http', async () => { await expect(p.quote(req(eth, usdc, 10n ** 16n), replay(fx('error-malformed-token')).ctx)).rejects.toMatchObject({ code: 'http', provider: 'kyberswap' }); });133  it('404 unknown chain -> throws http', async () => { await expect(p.quote(req(eth, usdc, 10n ** 16n), replay(fx('error-unknown-chain')).ctx)).rejects.toMatchObject({ code: 'http' }); });134  it('429 -> rate_limited; 500 -> http; HTML -> bad_response', async () => {135    const f = fx('ethereum-eth-usdc'), r = req(eth, usdc, 10n ** 16n);136    await expect(p.quote(r, replay({ ...f, status: 429 }).ctx)).rejects.toMatchObject({ code: 'rate_limited' });137    await expect(p.quote(r, replay({ ...f, status: 500, body: { code: 5000, message: 'internal' } }).ctx)).rejects.toMatchObject({ code: 'http' });138    await expect(p.quote(r, replay({ ...f, body: '<html>blocked</html>' }).ctx)).rejects.toMatchObject({ code: 'bad_response' });139  });140  it('unsupported pair or zero amount: empty without any request', async () => {141    const r = replay(fx('ethereum-eth-usdc'));142    expect(await p.quote(req(eth, tok('base', 'USDC'), 10n ** 16n), r.ctx)).toEqual([]);143    expect(await p.quote(req(eth, usdc, 0n), r.ctx)).toEqual([]);144    expect(r.calls).toHaveLength(0);145  });146});147148describe('kyberswap: malformed and hostile answers are rejected', () => {149  const f = fx('ethereum-eth-usdc'), r = req(native('ethereum'), tok('ethereum', 'USDC'), 10n ** 16n);150  const hostile: [string, (b: any) => unknown][] = [151    ['another output token', (b) => { b.data.routeSummary.tokenOut = tok('ethereum', 'USDT').asset; return b; }],152    ['another input token', (b) => { b.data.routeSummary.tokenIn = tok('ethereum', 'WETH').asset; return b; }],153    ['another input amount', (b) => { b.data.routeSummary.amountIn = '1000000000000000'; return b; }],154    ['float output', (b) => { b.data.routeSummary.amountOut = '20480330.5'; return b; }],155    ['exponent output', (b) => { b.data.routeSummary.amountOut = '2e7'; return b; }],156    ['negative output', (b) => { b.data.routeSummary.amountOut = '-20480330'; return b; }],157    ['numeric (non-string) output', (b) => { b.data.routeSummary.amountOut = 20480330; return b; }],158    ['hex output', (b) => { b.data.routeSummary.amountOut = '0x1388'; return b; }],159    ['absurdly long output', (b) => { b.data.routeSummary.amountOut = '9'.repeat(90); return b; }],160    ['negative gas', (b) => { b.data.routeSummary.gas = '-1'; return b; }],161    ['missing amountOut', (b) => { delete b.data.routeSummary.amountOut; return b; }],162    ['missing routeSummary', (b) => { delete b.data.routeSummary; return b; }],163    ['missing data', (b) => ({ code: 0, message: 'successfully' })],164    ['missing route', (b) => { delete b.data.routeSummary.route; return b; }],165    ['empty route', (b) => { b.data.routeSummary.route = []; return b; }],166    ['missing checksum', (b) => { delete b.data.routeSummary.checksum; return b; }],167    ['non-zero code with data', (b) => { b.code = 1; return b; }],168    ['another router', (b) => { b.data.routerAddress = '0x000000000000000000000000000000000000bEEF'; return b; }],169    ['another router inside the summary', (b) => { b.data.routeSummary.routerAddress = '0x000000000000000000000000000000000000bEEF'; return b; }],170    ['malformed router', (b) => { b.data.routerAddress = '0x6131'; return b; }],171    ['a fee nobody asked for', (b) => { b.data.routeSummary.extraFee = { feeAmount: '100', chargeFeeBy: 'currency_out', isInBps: true, feeReceiver: '0x000000000000000000000000000000000000bEEF' }; return b; }],172    ['hop with a malformed token', (b) => { b.data.routeSummary.route[0][0].tokenOut = 'usdc'; return b; }],173    ['null body', () => null],174    ['array body', () => []],175  ];176  it.each(hostile)('%s', async (_n, patch) => {177    await expect(p.quote(r, replay(f, patch).ctx)).rejects.toMatchObject({ code: 'schema', provider: 'kyberswap' });178  });179  it('a stale (replayed) route is refused', async () => {180    const { ctx, now } = replay(f);181    await expect(p.quote(r, { ...ctx, now: () => now + 10 * 60_000 })).rejects.toMatchObject({ code: 'bad_response' });182  });183  it('zero output is "no route", never a quote', async () => {184    expect(await p.quote(r, replay(f, (b) => { b.data.routeSummary.amountOut = '0'; return b; }).ctx)).toEqual([]);185    // 1 base unit out with slippage rounds the minimum to zero: nothing can be guaranteed186    expect(await p.quote(r, replay(f, (b) => { b.data.routeSummary.amountOut = '1'; return b; }).ctx)).toEqual([]);187  });188  it('nonsensical USD estimates give no price impact rather than an invented one', async () => {189    const q = (await p.quote(r, replay(f, (b) => { b.data.routeSummary.amountInUsd = '0'; return b; }).ctx))[0]!;190    expect(q.priceImpactBps).toBeNull();191    const q2 = (await p.quote(r, replay(f, (b) => { b.data.routeSummary.amountOutUsd = '900000'; return b; }).ctx))[0]!;192    expect(q2.priceImpactBps).toBeNull();193  });194  it('unknown extra fields are tolerated and preserved in raw', async () => {195    const q = (await p.quote(r, replay(f, (b) => { b.data.routeSummary.newField = { a: 1 }; return b; }).ctx))[0]!;196    expect((q.raw as any).routeSummary.newField).toEqual({ a: 1 });197  });198});199200describe('kyberswap: timeout and abort', () => {201  const r = req(native('ethereum'), tok('ethereum', 'USDC'), 10n ** 16n);202  /** a server that never answers; honours the abort signal like fetch does */203  const hanging = (async (_u: string, init: RequestInit) => new Promise((_res, rej) => {204    const s = init.signal!; const fail = () => rej(Object.assign(new Error('This operation was aborted'), { name: 'AbortError' }));205    if (s.aborted) fail(); else s.addEventListener('abort', fail, { once: true });206  })) as unknown as typeof fetch;207  it('passes ctx.signal to the HTTP layer: an abort surfaces as a timeout', async () => {208    const ac = new AbortController();209    const pending = p.quote(r, { signal: ac.signal, http: httpClient('kyberswap', hanging), now: Date.now });210    setTimeout(() => ac.abort(), 20);211    await expect(pending).rejects.toMatchObject({ code: 'timeout', provider: 'kyberswap' });212  });213  it('the runtime deadline cuts a hanging provider and counts a timeout', async () => {214    const rt = new ProviderRuntime(p, { ...DEFAULT_POLICY, timeoutMs: 40 }, httpClient('kyberswap', hanging));215    await expect(rt.quote(r)).rejects.toMatchObject({ code: 'timeout' });216    expect(rt.stats().timeouts).toBe(1);217  });218  it('a connection failure is "unavailable"', async () => {219    const down: HttpClient = httpClient('kyberswap', (async () => { throw new TypeError('fetch failed'); }) as unknown as typeof fetch);220    await expect(p.quote(r, { signal: new AbortController().signal, http: down, now: Date.now })).rejects.toMatchObject({ code: 'unavailable' });221  });222});223
Files main
Menu

Theme