Repository Public
1// Swap execution (V2): KyberSwap same-chain swaps on the networks listed in KYBER_ENABLED.2// The API prepares and simulates; the isolated signer independently decodes the calldata and enforces recipient, amounts and3// minimum output (packages/core/src/swap-evm.ts). Nothing here can make the signer accept calldata it has not verified.4import { createHash, randomUUID } from 'node:crypto';5import type { FastifyInstance } from 'fastify';6import { generateAuthenticationOptions } from '@simplewebauthn/server';7import { decodeEventLog, encodeFunctionData, erc20Abi, hexToBigInt, hexToNumber, numberToHex } from 'viem';8import { z } from 'zod';9import {10 buildEvmSwapTxs, checkPolicy, formatUnits, getAsset, getNetwork, IntentSchema, intentHash, KYBER_ENABLED, KYBER_NATIVE, KYBER_ROUTER, KYBER_ROUTER_ABI,11 parseUnits, swapDataHash, toB64u, toHex, verifyKyberSwap, type Intent, type NetworkDef,12} from '@spw/core';13import { listCreds } from './auth.ts';14import { adapterFor } from './chains/index.ts';15import { http, rpcAny, rpcQuorum } from './chains/rpc.ts';16import { config, UserError } from './config.ts';17import { audit, sql } from './db.ts';18import { addressFor, signer } from './signer-client.ts';19import { broadcastNow, move, view, type ProposalRow } from './transfers.ts';20import { advanceSolSwap, createSolSwapProposal, recordSolSwapOutcome, solSwapExecInfo } from './swap-exec-sol.ts';2122const KYBER_API = 'https://aggregator-api.kyberswap.com';23const CLIENT_ID = process.env.KYBERSWAP_CLIENT_ID ?? 'swap-router';24const U = z.string().regex(/^(0|[1-9]\d{0,77})$/);25const RouteResp = z.object({ code: z.literal(0), data: z.object({ routeSummary: z.object({ tokenIn: z.string(), tokenOut: z.string(), amountIn: U, amountOut: U, gas: U }).passthrough(), routerAddress: z.string() }) });26const BuildResp = z.object({ code: z.literal(0), data: z.object({ amountIn: U, amountOut: U, gas: U, data: z.string().regex(/^0x([0-9a-fA-F]{2})+$/), routerAddress: z.string(), transactionValue: U }) });2728async function kyber<T>(net: string, path: string, schema: z.ZodType<T>, init?: RequestInit): Promise<T> {29 let r: Response;30 try { r = await http(`${KYBER_API}/${net}/api/v1${path}`, { ...init, headers: { 'x-client-id': CLIENT_ID, 'content-type': 'application/json' } }); }31 catch { throw new UserError('provider_unavailable', 'the swap provider did not answer; nothing was signed', 503); }32 const j = await r.json().catch(() => null) as { code?: number; message?: string } | null;33 if (j && typeof j.code === 'number' && [4008, 4009, 4010, 4011].includes(j.code)) throw new UserError('no_route', 'no route for this pair and amount right now', 422);34 const p = schema.safeParse(j);35 if (!p.success) throw new UserError('provider_bad_answer', `the swap provider returned an unexpected answer${j?.message ? `: ${String(j.message).slice(0, 80)}` : ''}`, 502);36 return p.data;37}38const tokenOf = (id: string) => (id === 'native' ? KYBER_NATIVE : id);39const sha = (hex: string) => createHash('sha256').update(Buffer.from(hex.slice(2), 'hex')).digest('hex');4041/** The pinned router must still hold the reviewed bytecode on every source, and the wallet address must be a plain account. */42async function chainGuards(n: NetworkDef, me: string): Promise<void> {43 const pin = KYBER_ENABLED[n.id];44 if (!pin) throw new UserError('swap_not_enabled', `swap execution is not enabled on ${n.name} yet (route comparison only)`, 422);45 const code = await rpcQuorum<string>(n.rpc, 'eth_getCode', [KYBER_ROUTER, 'latest']);46 if (sha(code) !== pin.codeSha256) throw new UserError('router_changed', 'the swap router bytecode is not the reviewed one; swaps are refused', 503);47 // an EIP-7702 delegation on the wallet address would run someone's code on every coin received48 if ((await rpcQuorum<string>(n.rpc, 'eth_getCode', [me, 'latest'])) !== '0x') throw new UserError('account_has_code', 'this wallet address carries code (delegation); swaps are refused', 503);49}5051/** Expected output right now for an exact input, from the same on-chain-liquidity routes used for execution. Signs nothing. */52export async function kyberQuote(n: NetworkDef, fromAsset: string, toAsset: string, amount: bigint): Promise<bigint> {53 const r = await kyber(n.id, `/routes?tokenIn=${tokenOf(fromAsset)}&tokenOut=${tokenOf(toAsset)}&amountIn=${amount}&excludeRFQSources=true&excludedSources=kyberswap-limit-order-v2,kyberswap-limit-order`, RouteResp);54 return BigInt(r.data.routeSummary.amountOut);55}5657export interface PreparedSwap { intent: Intent; data: string; expected: bigint; minOut: bigint; needApprove: boolean; simulated: boolean; feeEstimate: bigint; maxFee: bigint; fn: string }58/**59 * Prepare a firm KyberSwap swap for an address the signer controls (the owner's wallet or an agent vault): guards, balance60 * and allowance, fresh on-chain-liquidity route, calldata verified exactly as the signer will, nonce agreement, gas from the61 * node's own estimate (which is also the simulation) when possible. Throws UserError; signs nothing.62 */63export async function prepareKyberSwap(o: { n: NetworkDef; me: string; keyId: string; fromAsset: string; toAsset: string; amount: bigint; slippageBps: number; policyVersion: number; proposalId: string; ttlSec?: number }): Promise<PreparedSwap> {64 const { n, me } = o;65 let src, dst;66 try { src = getAsset(n.id, o.fromAsset); dst = getAsset(n.id, o.toAsset); } catch { throw new UserError('unsupported_asset', 'asset is not in the wallet registry'); }67 if (src.id === dst.id) throw new UserError('same_asset', 'choose two different assets');68 const amount = o.amount;69 if (amount <= 0n) throw new UserError('bad_amount', 'amount must be greater than zero');70 await chainGuards(n, me);71 const bal = await adapterFor(n.id).balances(me);72 if ((bal[src.id] ?? 0n) < amount) throw new UserError('insufficient_funds', `insufficient ${src.symbol} balance`);73 const allowance = src.id === 'native' ? 0n : hexToBigInt(await rpcAny<`0x${string}`>(n.rpc, 'eth_call', [{ to: src.id, data: encodeFunctionData({ abi: erc20Abi, functionName: 'allowance', args: [me as `0x${string}`, KYBER_ROUTER as `0x${string}`] }) }, 'latest']));74 const needApprove = src.id !== 'native' && allowance < amount;75 // firm route from on-chain liquidity only: off-chain signed orders can expire or be taken before inclusion76 const route = await kyber(n.id, `/routes?tokenIn=${tokenOf(src.id)}&tokenOut=${tokenOf(dst.id)}&amountIn=${amount}&excludeRFQSources=true&excludedSources=kyberswap-limit-order-v2,kyberswap-limit-order`, RouteResp);77 const now = Math.floor(Date.now() / 1000);78 const built = await kyber(n.id, '/route/build', BuildResp, { method: 'POST', body: JSON.stringify({ routeSummary: route.data.routeSummary, sender: me, recipient: me, slippageTolerance: o.slippageBps, deadline: now + 600, source: CLIENT_ID }) });79 if (built.data.routerAddress.toLowerCase() !== KYBER_ROUTER.toLowerCase()) throw new UserError('router_changed', 'the provider answered with another router; refused', 502);80 if (BigInt(built.data.amountIn) !== amount) throw new UserError('provider_bad_answer', 'the provider built another amount', 502);81 const data = built.data.data, expected = BigInt(built.data.amountOut);82 const nonce = hexToNumber(await rpcQuorum<`0x${string}`>(n.rpc, 'eth_getTransactionCount', [me, 'pending']));83 if (nonce !== hexToNumber(await rpcAny<`0x${string}`>(n.rpc, 'eth_getTransactionCount', [me, 'latest']))) throw new UserError('pending_tx', 'a previous transaction from this address is still pending on this network', 409);84 const block = await rpcAny<{ baseFeePerGas?: `0x${string}` }>(n.rpc, 'eth_getBlockByNumber', ['latest', false]);85 if (!block.baseFeePerGas) throw new UserError('rpc_degraded', 'network did not report a base fee', 503);86 const baseFee = hexToBigInt(block.baseFeePerGas);87 let tip = 1_000_000n; try { tip = hexToBigInt(await rpcAny<`0x${string}`>(n.rpc, 'eth_maxPriorityFeePerGas', [])); } catch { /* floor */ }88 const maxFeePerGas = baseFee * 2n + tip;89 const call = { from: me, to: KYBER_ROUTER, data, value: numberToHex(src.id === 'native' ? amount : 0n) };90 let gas: bigint, simulated = false;91 if (!needApprove) {92 const est = await rpcAny<`0x${string}`>(n.rpc, 'eth_estimateGas', [call]).then(hexToBigInt, (e: Error) => { throw new UserError('simulation_failed', `the swap would fail right now: ${e.message.slice(0, 120)}`, 422); });93 gas = (est * 13n) / 10n + 30_000n; simulated = true;94 } else gas = BigInt(built.data.gas) * 2n + 100_000n;95 if (gas > 2_000_000n) gas = 2_000_000n;96 const approveGas = needApprove ? 70_000n : 0n, maxFee = (gas + approveGas) * maxFeePerGas;97 if ((bal.native ?? 0n) < maxFee + (src.id === 'native' ? amount : 0n)) throw new UserError('insufficient_gas', `not enough ${n.native.symbol} on ${n.name} to pay the network fee`);98 const draft = (minOut: bigint): Intent => IntentSchema.parse({99 v: 1, proposalId: o.proposalId, keyId: o.keyId, network: n.id, chainRef: n.chainRef, asset: src.id, symbol: src.symbol, decimals: src.decimals, amount: amount.toString(),100 from: me, to: me, maxFee: maxFee.toString(), createdAt: now, expiresAt: now + (o.ttlSec ?? 75), policyVersion: o.policyVersion,101 swap: { provider: 'kyberswap', toAsset: dst.id, minOut: minOut.toString(), router: KYBER_ROUTER, dataHash: swapDataHash(data), approveGas: approveGas.toString() },102 evm: { nonce, gasLimit: gas.toString(), maxFeePerGas: maxFeePerGas.toString(), maxPriorityFeePerGas: tip.toString() },103 });104 let desc;105 try { desc = verifyKyberSwap(draft(1n), data); } catch (e) { throw new UserError('provider_bad_answer', `calldata refused: ${(e as Error).message}`, 502); }106 const floor = (expected * BigInt(10_000 - o.slippageBps)) / 10_000n;107 if (desc.minReturnAmount * 1000n < floor * 999n) throw new UserError('provider_bad_answer', 'the calldata guarantees less than the requested tolerance allows', 502);108 const intent = draft(desc.minReturnAmount);109 buildEvmSwapTxs(intent, data); // exactly what the signer will rebuild110 return { intent, data, expected, minOut: desc.minReturnAmount, needApprove, simulated, feeEstimate: (gas + approveGas) * (baseFee + tip), maxFee, fn: desc.functionName };111}112113const Create = z.strictObject({ network: z.string().max(24), fromAsset: z.string().max(80), toAsset: z.string().max(80), amount: z.string().min(1).max(60), slippageBps: z.number().int().min(1).max(300).default(50) });114115export function swapExecRoutes(app: FastifyInstance): void {116 app.get('/api/v1/swap/execution', async () => ({ enabled: true, provider: 'kyberswap', networks: Object.keys(KYBER_ENABLED), router: KYBER_ROUTER, solana: solSwapExecInfo() }));117118 // Prepare a firm swap for the owner's wallet, then offer it for passkey approval.119 app.post('/api/v1/swap/proposals', async (req) => {120 const idem = String(req.headers['idempotency-key'] ?? '');121 if (!/^[A-Za-z0-9-]{16,64}$/.test(idem)) throw new UserError('idempotency', 'Idempotency-Key header required');122 const b = Create.parse(req.body);123 const n = getNetwork(b.network);124 if (n.family === 'sol') return createSolSwapProposal(b, idem); // Jupiter: services/api/src/swap-exec-sol.ts125 if (n.family !== 'evm') throw new UserError('swap_not_enabled', 'swap execution is only available on EVM networks and Solana for now', 422);126 let src, dst;127 try { src = getAsset(n.id, b.fromAsset); dst = getAsset(n.id, b.toAsset); } catch { throw new UserError('unsupported_asset', 'asset is not in the wallet registry'); }128 if (src.id === dst.id) throw new UserError('same_asset', 'choose two different assets');129 let amount: bigint;130 try { amount = parseUnits(b.amount, src.decimals); } catch (e) { throw new UserError('bad_amount', (e as Error).message); }131 if (amount <= 0n) throw new UserError('bad_amount', 'amount must be greater than zero');132 const st = await signer.status(true);133 if (st.halted) throw new UserError('halted', 'emergency halt is active', 423);134 const me = addressFor(st, n.id);135 const [busy] = await sql<{ id: string }[]>`select id from proposals where network = ${n.id} and state in ('approved', 'signed', 'broadcast') limit 1`;136 if (busy) throw new UserError('pending_tx', 'a previous transfer or swap on this network is not confirmed yet; wait for it before starting another', 409);137 const id = randomUUID();138 const ps = await prepareKyberSwap({ n, me, keyId: st.keyId, fromAsset: src.id, toAsset: dst.id, amount, slippageBps: b.slippageBps, policyVersion: st.policy.version, proposalId: id });139 const { intent, data } = ps, now = intent.createdAt;140 const verdict = checkPolicy(st.policy, intent, BigInt(st.spentToday[`${n.id}:${src.symbol}`] ?? '0'), now, st.halted);141 if (!verdict.ok) throw new UserError('policy', verdict.reason, 422);142 const hash = intentHash(intent);143 const swap = { provider: 'kyberswap', toAsset: dst.id, toSymbol: dst.symbol, toDecimals: dst.decimals, expectedOut: ps.expected.toString(), minOut: ps.minOut.toString(), slippageBps: b.slippageBps,144 needsApproval: ps.needApprove, simulated: ps.simulated, fn: ps.fn };145 const [p] = await sql<ProposalRow[]>`insert into proposals (id, idempotency_key, state, kind, network, asset, symbol, decimals, amount, from_addr, to_addr, note, fee_estimate, max_fee, warnings, intent, intent_hash, aux, swap, expires_at)146 values (${id}, ${idem}, 'pending_approval', 'swap', ${n.id}, ${src.id}, ${src.symbol}, ${src.decimals}, ${amount.toString()}, ${me}, ${me}, ${`swap → ${dst.symbol}`}, ${ps.feeEstimate.toString()}, ${ps.maxFee.toString()},147 ${sql.json(ps.needApprove ? ['swap_needs_approval'] : [])}, ${sql.json(intent as never)}, ${toHex(hash)}, ${sql.json({ swapData: data })}, ${sql.json(swap)}, ${new Date(intent.expiresAt * 1000)})148 on conflict (idempotency_key) do nothing returning *`;149 if (!p) throw new UserError('conflict', 'duplicate request', 409);150 await audit('swap.prepared', 'owner', { id, network: n.id, from: src.symbol, to: dst.symbol, amount: amount.toString(), minOut: swap.minOut, hash: toHex(hash) });151 const creds = await listCreds();152 const options = await generateAuthenticationOptions({ rpID: config.rpId, userVerification: 'required', challenge: new Uint8Array(hash), timeout: 70_000, allowCredentials: creds.map((c) => ({ id: c.id, transports: c.transports as never })) });153 if (options.challenge !== toB64u(hash)) throw new Error('challenge binding mismatch');154 return { proposal: view(p), options };155 });156}157158/** After signing: mine the exact-amount approval if there is one, simulate the swap against the real state, then relay it. */159export async function advanceSwap(p: ProposalRow): Promise<ProposalRow> {160 if (p.state !== 'signed' || !p.raw || !p.intent) return p;161 if (getNetwork(p.network).family === 'sol') return advanceSolSwap(p);162 const n = getNetwork(p.network), a = adapterFor(p.network);163 const fail = (error: string) => move(p, 'failed', { error });164 if (!config.broadcast) return broadcastNow(p);165 if (p.expires_at && Date.now() - p.expires_at.getTime() > 8 * 60_000) return fail('the swap could not be sent in time; nothing was swapped');166 if (p.pre_raw && p.pre_state !== 'confirmed') {167 await a.broadcast(p.pre_raw).catch(() => {});168 const rc = await rpcAny<{ status: `0x${string}` } | null>(n.rpc, 'eth_getTransactionReceipt', [p.pre_txid]).catch(() => null);169 if (!rc) { if (p.pre_state !== 'sent') await sql`update proposals set pre_state = 'sent', updated_at = now() where id = ${p.id}`; return p; }170 if (hexToNumber(rc.status) !== 1) return fail('the token approval reverted; nothing was swapped');171 await sql`update proposals set pre_state = 'confirmed', updated_at = now() where id = ${p.id}`;172 await audit('swap.approval_confirmed', 'chain', { id: p.id, txid: p.pre_txid });173 }174 // last gate before the swap leaves: it must succeed against the current chain state175 const data = (p.aux as { swapData?: string } | null)?.swapData;176 const sim = await rpcAny<string>(n.rpc, 'eth_call', [{ from: p.from_addr, to: KYBER_ROUTER, data, value: numberToHex(p.asset === 'native' ? BigInt(p.amount) : 0n) }, 'latest']).then(() => null, (e: Error & { rpcCode?: number }) => (e.rpcCode !== undefined ? e.message : 'unreachable'));177 if (sim === 'unreachable') return p; // try again on the next tick178 if (sim !== null) return fail(`the swap would revert now (${sim.slice(0, 100)}); it was not sent${p.pre_raw ? '. The exact-amount approval stays in place for the next attempt' : ''}`);179 return broadcastNow(p);180}181182/** Once a swap is final, read what was actually received from the router's own event. */183export async function recordSwapOutcome(p: ProposalRow): Promise<void> {184 if (p.kind !== 'swap' || !p.txid || p.out_amount !== null) return;185 if (getNetwork(p.network).family === 'sol') return recordSolSwapOutcome(p);186 const n = getNetwork(p.network);187 const rc = await rpcAny<{ logs: { address: string; topics: [`0x${string}`, ...`0x${string}`[]]; data: `0x${string}` }[] } | null>(n.rpc, 'eth_getTransactionReceipt', [p.txid]).catch(() => null);188 for (const l of rc?.logs ?? []) {189 if (l.address.toLowerCase() !== KYBER_ROUTER.toLowerCase()) continue;190 try { const ev = decodeEventLog({ abi: KYBER_ROUTER_ABI, data: l.data, topics: l.topics }) as unknown as { eventName: string; args: { dstReceiver: string; returnAmount: bigint } };191 if (ev.eventName === 'Swapped' && ev.args.dstReceiver.toLowerCase() === p.from_addr.toLowerCase()) {192 await sql`update proposals set out_amount = ${ev.args.returnAmount.toString()} where id = ${p.id}`;193 await audit('swap.settled', 'chain', { id: p.id, txid: p.txid, received: ev.args.returnAmount.toString(), minOut: (p.swap as { minOut?: string } | null)?.minOut });194 return; } } catch { /* another event */ }195 }196}197export { formatUnits };198