SPB Git

Repository Public

kyberswap.ts 8.9 KB 116 lines typescript

1// KyberSwap Aggregator — same-chain EVM swaps, price discovery only (see docs/swap/providers/kyberswap.md).2// One keyless round: GET {BASE}/{chain}/api/v1/routes. The route is never encoded here (POST /route/build is the3// execution phase); slippage, recipient and deadline only exist in that second call, so the guaranteed minimum is4// derived from the quoted output exactly as KyberSwap defines it: output reduced by `slippageTolerance` basis points.5import { z } from 'zod';6import { getNetwork } from '@spw/core';7import { ProviderError, resolveAsset, sameAsset, type AssetRef, type FeeItem, type ProviderContext, type SwapProvider, type SwapQuote, type SwapQuoteRequest } from '../types.ts';8import type { ProviderEnv } from './index.ts';910const ID = 'kyberswap';11/** Pinned. Keyless base (public limit 3 requests/s); the keyed gateway https://api.kyberswap.com/swap/ is not used. */12export const KYBERSWAP_API_BASE = 'https://aggregator-api.kyberswap.com';13export const KYBERSWAP_API_VERSION = 'v1';14const DOCS = 'https://docs.kyberswap.com/developer-guide/aggregator-api/aggregator-api-specification/evm-swaps';15/** MetaAggregationRouterV2, same address on every supported chain — https://docs.kyberswap.com/developer-guide/aggregator-api/contracts */16export const KYBERSWAP_ROUTER = '0x6131B5fae19EA4f9D964eAc0408E4408b66337b5';17/** How KyberSwap designates a chain's native coin. */18export const KYBERSWAP_NATIVE = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE';19/** wallet network id -> KyberSwap chain path segment. Each one is listed in the API specification and answered live on 2026-11-10. */20export const KYBERSWAP_CHAINS: Record<string, string> = {21  ethereum: 'ethereum', base: 'base', arbitrum: 'arbitrum', polygon: 'polygon', optimism: 'optimism', bsc: 'bsc', avalanche: 'avalanche', hyperevm: 'hyperevm',22};23/** `slippageTolerance` is accepted in [0, 2000] bps unless `ignoreCappedSlippage` is set, which this wallet never does. */24export const KYBERSWAP_MAX_SLIPPAGE_BPS = 2000;25/** "it is recommended to not cache routes from client side for more than 5-10 seconds" */26const VALIDITY_MS = 10_000;27/** a route stamped this far from our clock is a cached or replayed answer */28const MAX_CLOCK_SKEW_MS = 120_000;29/** error codes that mean "nothing to trade", not "provider failure": route not found, no eligible pool, token unknown, amount above the maximum */30const NO_ROUTE_CODES = new Set([4008, 4009, 4010, 4011]);3132const uint = z.string().regex(/^(0|[1-9]\d{0,77})$/);33const address = z.string().regex(/^0x[0-9a-fA-F]{40}$/);34const usd = z.string().regex(/^\d+(\.\d+)?([eE][-+]?\d+)?$/);35// loose objects: unknown keys are kept, because `routeSummary` must later be sent back to /route/build exactly as received36const hop = z.looseObject({ pool: z.string().max(200), tokenIn: address, tokenOut: address, swapAmount: uint, amountOut: uint, exchange: z.string().min(1).max(64), poolType: z.string().max(64) });37const routeSummary = z.looseObject({38  tokenIn: address, amountIn: uint, amountInUsd: usd, tokenOut: address, amountOut: uint, amountOutUsd: usd,39  gas: uint, gasPrice: uint, gasUsd: usd, l1FeeUsd: usd.optional(),40  extraFee: z.looseObject({ feeAmount: z.string(), chargeFeeBy: z.string(), isInBps: z.boolean(), feeReceiver: z.string() }),41  route: z.array(z.array(hop).min(1).max(12)).min(1).max(32),42  routerAddress: address.optional(), routeID: z.string().min(1).max(128), checksum: z.string().min(1).max(128), timestamp: z.number().int().positive(),43});44export const kyberswapRoutesSchema = z.looseObject({ code: z.literal(0), data: z.looseObject({ routeSummary, routerAddress: address }) });45export type KyberswapRoutes = z.infer<typeof kyberswapRoutesSchema>;4647const eq = (a: string, b: string) => a.toLowerCase() === b.toLowerCase();48const wire = (a: AssetRef) => (a.asset === 'native' ? KYBERSWAP_NATIVE : a.asset);49const bad = (msg: string) => new ProviderError(ID, 'schema', msg);5051export function createKyberswapProvider(env: ProviderEnv = {}): SwapProvider {52  // sent as x-client-id ("your app or company name"); requests without it are rate limited more strictly53  const clientId = env.KYBERSWAP_CLIENT_ID ?? 'swap-router';54  if (!/^[\w.-]{1,64}$/.test(clientId)) throw new Error('KYBERSWAP_CLIENT_ID: letters, digits, dot, dash, underscore (1–64)');55  const networks = Object.keys(KYBERSWAP_CHAINS);56  const known = (a: AssetRef) => { try { resolveAsset(a); return true; } catch { return false; } };57  const supportsPair = (from: AssetRef, to: AssetRef) =>58    from.network === to.network && from.network in KYBERSWAP_CHAINS && known(from) && known(to) && getNetwork(from.network).family === 'evm' && !sameAsset(from, to);5960  return {61    id: ID, name: 'KyberSwap',62    capabilities: () => ({ networks, crossChain: false, needsKey: false, configured: true, kinds: ['swap'], docs: DOCS }),63    supportsPair,64    async quote(req: SwapQuoteRequest, ctx: ProviderContext): Promise<SwapQuote[]> {65      if (!supportsPair(req.from, req.to)) return [];66      if (req.amountIn <= 0n) return [];67      if (!Number.isInteger(req.slippageBps) || req.slippageBps < 0) throw new ProviderError(ID, 'bad_response', 'slippage must be a whole number of basis points');68      if (req.slippageBps > KYBERSWAP_MAX_SLIPPAGE_BPS) return []; // the provider would refuse to encode it69      const net = getNetwork(req.from.network), tokenIn = wire(req.from), tokenOut = wire(req.to);70      // `origin` (the user's address) is deliberately not sent: it is optional and price discovery should not disclose the wallet71      const url = `${KYBERSWAP_API_BASE}/${KYBERSWAP_CHAINS[net.id]}/api/${KYBERSWAP_API_VERSION}/routes?tokenIn=${tokenIn}&tokenOut=${tokenOut}&amountIn=${req.amountIn.toString()}`;72      let body: unknown;73      try { body = await ctx.http.getJson(url, { headers: { 'x-client-id': clientId }, signal: ctx.signal }); }74      catch (e) {75        const code = (e as { body?: { code?: unknown } }).body?.code;76        if (e instanceof ProviderError && e.code === 'http' && typeof code === 'number' && NO_ROUTE_CODES.has(code)) return [];77        throw e;78      }79      const parsed = kyberswapRoutesSchema.safeParse(body);80      if (!parsed.success) throw bad(`unexpected answer: ${parsed.error.issues.slice(0, 3).map((i) => `${i.path.join('.')} ${i.message}`).join('; ')}`.slice(0, 200));81      const { routeSummary: s, routerAddress } = parsed.data.data;82      if (!eq(s.tokenIn, tokenIn) || !eq(s.tokenOut, tokenOut)) throw bad('route is for another token pair');83      if (BigInt(s.amountIn) !== req.amountIn) throw bad('route is for another input amount');84      if (!eq(routerAddress, KYBERSWAP_ROUTER) || (s.routerAddress !== undefined && !eq(s.routerAddress, KYBERSWAP_ROUTER))) throw bad('router is not the pinned MetaAggregationRouterV2');85      if (s.extraFee.chargeFeeBy !== '' || !['', '0'].includes(s.extraFee.feeAmount)) throw bad('route carries a fee that was not requested');86      const now = ctx.now();87      if (Math.abs(now - s.timestamp * 1000) > MAX_CLOCK_SKEW_MS) throw new ProviderError(ID, 'bad_response', 'route timestamp is stale');88      const amountOut = BigInt(s.amountOut);89      if (amountOut <= 0n) return [];90      // KyberSwap: "the estimated output adjusted by the selected max slippage", slippageTolerance in bps. Rounded down.91      const minAmountOut = (amountOut * BigInt(10_000 - req.slippageBps)) / 10_000n;92      if (minAmountOut <= 0n) return [];93      const fees: FeeItem[] = [{ kind: 'network', asset: { network: net.id, asset: 'native' }, amount: BigInt(s.gas) * BigInt(s.gasPrice), includedInOutput: false, estimated: true }];94      // the provider's own USD estimates of both sides; the same ratio its interface shows as price impact. Display only.95      const inUsd = Number(s.amountInUsd), outUsd = Number(s.amountOutUsd);96      const impact = inUsd > 0 && outUsd > 0 ? Math.round(((inUsd - outUsd) / inUsd) * 10_000) : null;97      const via = [...new Set(s.route.flat().map((h) => h.exchange))].slice(0, 12);98      const contracts: SwapQuote['steps'][number]['contracts'] = [{ network: net.id, address: KYBERSWAP_ROUTER, role: 'router' }];99      if (req.from.asset !== 'native') contracts.push({ network: net.id, address: KYBERSWAP_ROUTER, role: 'spender' });100      return [{101        id: `${ID}-${s.routeID}`, provider: ID, request: req,102        steps: [{ kind: 'swap', provider: ID, from: req.from, to: req.to, via, contracts }],103        amountOut, minAmountOut, fees,104        estimatedSeconds: null,105        priceImpactBps: impact !== null && impact >= -10_000 && impact <= 10_000 ? impact : null,106        expiresAt: now + VALIDITY_MS,107        firmness: 'indicative',108        executability: { class: 'opaque', reason: 'KyberSwap returns router calldata (MetaAggregationRouterV2) built by its API; the signer can pin the router, the allowance, the recipient and the minimum output but cannot rebuild the route' },109        refundable: true, // one transaction: it settles in full or reverts, the input never leaves the sender otherwise110        atomic: true,111        raw: parsed.data.data,112      }];113    },114  };115}116
Files main
Menu

Theme