Repository Public
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