Repository Public
1// Universal Swap Router — API surface (V2). Price discovery only: there is deliberately no execution endpoint yet.2import { randomUUID } from 'node:crypto';3import type { FastifyInstance } from 'fastify';4import { z } from 'zod';5import { CCTP_ENABLED, formatUnits, getAsset, getNetwork, KYBER_ENABLED, parseUnits, thorPairEnabled } from '@spw/core';6import {7 assetKey, createEnabledProviders, DEFAULT_POLICY, httpClient, LiquidityGraph, ProviderRuntime, QuoteAggregator, SwapRouter,8 type AssetRef, type QuoteEvent, type Route, type RouteSearch, type SwapProvider, type SwapQuoteRequest, type Valuer,9} from '@spw/swap';10import { UserError } from './config.ts';11import { sql } from './db.ts';12import { addressFor, signer } from './signer-client.ts';1314export interface SwapDeps { providers?: SwapProvider[]; prices: () => Promise<Record<string, { usd: number }> | null> }1516const Query = z.object({17 from: z.string().min(3).max(140), to: z.string().min(3).max(140),18 amount: z.string().min(1).max(60),19 slippageBps: z.coerce.number().int().min(1).max(500).default(50),20});21const refOf = (s: string): AssetRef => {22 const k = s.indexOf(':');23 if (k < 1) throw new UserError('bad_asset', 'asset must be "<network>:<asset>"');24 const ref = { network: s.slice(0, k), asset: s.slice(k + 1) };25 try { getAsset(ref.network, ref.asset); } catch { throw new UserError('unsupported_asset', 'asset is not in the wallet registry'); }26 return ref;27};28const big = (_k: string, v: unknown) => (typeof v === 'bigint' ? v.toString() : v);2930export function swapRoutes(app: FastifyInstance, deps: SwapDeps): void {31 const providers = deps.providers ?? createEnabledProviders(process.env);32 const runtimes = providers.map((p) => new ProviderRuntime(p, { ...DEFAULT_POLICY, ...p.policy }, httpClient(p.id)));33 const agg = new QuoteAggregator(runtimes), graph = new LiquidityGraph(providers);34 // prices are refreshed outside the hot path; the valuer itself is synchronous35 let priceMap: Record<string, { usd: number }> = {};36 const refresh = async () => { const p = await deps.prices().catch(() => null); if (p) priceMap = p; };37 const valuer: Valuer = (a, amount) => { try { const d = getAsset(a.network, a.asset), p = priceMap[d.symbol]?.usd; return p === undefined ? null : Number(formatUnits(amount, d.decimals)) * p; } catch { return null; } };38 const router = new SwapRouter(agg, graph, valuer);3940 const viewAsset = (a: AssetRef) => { const d = getAsset(a.network, a.asset), n = getNetwork(a.network); return { key: assetKey(a), network: a.network, networkName: n.name, asset: a.asset, symbol: d.symbol, name: d.name, decimals: d.decimals }; };41 const viewRoute = (r: Route) => {42 const out = getAsset(r.path[r.path.length - 1]!.network, r.path[r.path.length - 1]!.asset);43 const first = r.path[0]!, last = r.path[r.path.length - 1]!;44 // USDC -> USDC between two EVM networks through CCTP: the signer builds both transactions itself (services/api/src/bridge.ts)45 const usdcOf = (a: AssetRef) => (a.network === 'solana' || (getNetwork(a.network).family === 'evm' && !!CCTP_ENABLED[a.network])) && getAsset(a.network, a.asset).symbol === 'USDC'; // v8: Solana too (services/api/src/bridge-sol.ts)46 const cctp = r.legs.length === 1 && r.providers[0] === 'cctp' && first.network !== last.network && usdcOf(first) && usdcOf(last);47 const cctpSpeed = cctp ? ((r.legs[0]!.raw as { transfer?: string } | undefined)?.transfer === 'fast' ? 'fast' : 'standard') : null;48 // Jupiter on Solana: the signer decodes the v1 transaction itself (services/api/src/swap-exec-sol.ts); whether a given route can be put in the verifiable form is decided when the swap is prepared49 const jupiter = r.legs.length === 1 && r.providers[0] === 'jupiter' && first.network === last.network && getNetwork(first.network).family === 'sol';50 // THORChain native swap on a pinned pair: memo and transaction built by the signer, vault confirmed by its own THORNode operators (services/api/src/xswap.ts)51 const thor = r.legs.length === 1 && r.providers[0] === 'thorchain' && thorPairEnabled(first, last);52 const executable = cctp || jupiter || thor || (r.legs.length === 1 && r.providers[0] === 'kyberswap' && !!KYBER_ENABLED[first.network] && first.network === last.network);53 // on a reviewed router the signer decodes and enforces recipient and minimum output itself: not an opaque transaction54 const flags = executable ? r.risk.flags.filter((f) => f.code !== 'opaque_transaction' && !((cctp || thor) && f.code === 'not_executable')) : r.risk.flags;55 const level = flags.some((f) => f.level === 'high') ? 'high' : flags.some((f) => f.level === 'medium') ? 'medium' : 'low';56 const risk = executable ? { level, score: Math.max(0, r.risk.score - 12), flags } : r.risk;57 return {58 id: r.id, providers: r.providers, path: r.path.map(viewAsset), tags: r.tags, pareto: r.pareto,59 // the wallet can execute this route itself today (signer-verified calldata on a reviewed router)60 executable, exec: executable ? (thor ? { via: 'thorchain' as const } : cctp ? { via: 'cctp' as const, speed: cctpSpeed } : jupiter ? { via: 'jupiter' as const } : { via: 'kyberswap' as const }) : null,61 amountOut: r.amountOut, minAmountOut: r.minAmountOut, amountOutDisplay: formatUnits(r.amountOut, out.decimals), minAmountOutDisplay: formatUnits(r.minAmountOut, out.decimals),62 estimatedSeconds: r.estimatedSeconds, expiresAt: r.expiresAt, executability: thor ? { class: 'verified' as const, reason: 'memo and transaction are built by the signer; the vault address is confirmed by at least three independent THORNode operators asked by the signer itself' } : executable ? { class: 'verified' as const } : r.executability, valuation: r.valuation, risk,63 atomic: r.legs.length === 1 && r.legs[0]!.atomic, refundable: r.legs.some((l) => l.refundable === false) ? false : r.legs.some((l) => l.refundable === null) ? null : true,64 priceImpactBps: r.legs.every((l) => l.priceImpactBps !== null) ? r.legs.reduce((s, l) => s + l.priceImpactBps!, 0) : null,65 steps: r.legs.flatMap((l) => l.steps.map((s) => ({ kind: s.kind, provider: s.provider, from: viewAsset(s.from), to: viewAsset(s.to), via: s.via, contracts: s.contracts }))),66 fees: r.legs.flatMap((l) => l.fees.map((f) => { const d = getAsset(f.asset.network, f.asset.asset); return { kind: f.kind, symbol: d.symbol, network: f.asset.network, amount: f.amount, display: formatUnits(f.amount, d.decimals), includedInOutput: f.includedInOutput, estimated: f.estimated, usd: valuer(f.asset, f.amount) }; })),67 };68 };69 async function request(q: z.infer<typeof Query>): Promise<SwapQuoteRequest> {70 const from = refOf(q.from), to = refOf(q.to);71 if (assetKey(from) === assetKey(to)) throw new UserError('same_asset', 'choose two different assets');72 let amountIn: bigint;73 try { amountIn = parseUnits(q.amount, getAsset(from.network, from.asset).decimals); } catch (e) { throw new UserError('bad_amount', (e as Error).message); }74 if (amountIn <= 0n) throw new UserError('bad_amount', 'amount must be greater than zero');75 const st = await signer.status();76 // both ends are always the wallet's own addresses, taken from the signer — never from the client77 return { from, to, amountIn, sender: addressFor(st, from.network), recipient: addressFor(st, to.network), slippageBps: q.slippageBps };78 }79 type Row = { search_id: string; provider: string; from_key: string; to_key: string; amount_in: string; amount_out: string | null; min_out: string | null; ms: number; ok: boolean; code: string | null };80 const record = (searchId: string, req: SwapQuoteRequest, events: QuoteEvent[]) => {81 const rows: Row[] = events.flatMap((e): Row[] => e.type === 'quote' ? [{ search_id: searchId, provider: e.quote.provider, from_key: assetKey(e.quote.request.from), to_key: assetKey(e.quote.request.to), amount_in: e.quote.request.amountIn.toString(), amount_out: e.quote.amountOut.toString(), min_out: e.quote.minAmountOut.toString(), ms: e.ms, ok: true, code: null }]82 : e.type === 'provider' && e.status !== 'skipped' ? [{ search_id: searchId, provider: e.provider, from_key: assetKey(req.from), to_key: assetKey(req.to), amount_in: req.amountIn.toString(), amount_out: null, min_out: null, ms: e.ms, ok: e.status === 'no_route', code: e.status === 'no_route' ? 'no_route' : e.code ?? 'error' }] : []);83 if (rows.length) void sql`insert into swap_quotes ${sql(rows as never)}`.catch(() => {});84 };85 const summary = (s: RouteSearch) => ({ best: s.best, reachable: s.reachable, ms: s.ms, routes: s.routes.map(viewRoute),86 providers: s.events.flatMap((e) => (e.type === 'provider' ? [{ provider: e.provider, status: e.status, code: e.code ?? null, ms: e.ms }] : [])) });8788 app.get('/api/v1/swap/assets', async () => ({ assets: [...graph.nodes.values()].map((n) => ({ key: n.key, network: n.network, networkName: n.networkName, asset: n.asset, symbol: n.symbol, name: n.name, decimals: n.decimals, native: n.native })) }));89 app.get('/api/v1/swap/providers', async () => ({90 executionEnabled: true,91 providers: runtimes.map((r) => ({ ...r.stats(), name: r.provider.name, capabilities: r.provider.capabilities() })),92 }));93 app.get('/api/v1/swap/stats', async () => {94 const rows = await sql`select provider, count(*)::int as calls, count(*) filter (where ok)::int as ok, count(*) filter (where amount_out is not null)::int as quotes,95 percentile_cont(0.5) within group (order by ms)::int as p50, percentile_cont(0.95) within group (order by ms)::int as p95, percentile_cont(0.99) within group (order by ms)::int as p9996 from swap_quotes where at > now() - interval '7 days' group by provider order by provider`;97 return { window: '7d', providers: rows };98 });99100 app.post('/api/v1/swap/quote', async (req) => {101 const r = await request(Query.parse(req.body)); await refresh();102 const s = await router.search(r); record(randomUUID(), r, s.events);103 return JSON.parse(JSON.stringify(summary(s), big)); // amounts leave the API as decimal strings, never as JS numbers104 });105106 // Server-sent events: routes are pushed as each provider answers, then one "done" event with the final ranking.107 app.get('/api/v1/swap/quote/stream', async (req, reply) => {108 const r = await request(Query.parse(req.query)); await refresh();109 const ac = new AbortController(); req.raw.on('close', () => ac.abort());110 reply.hijack();111 reply.raw.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-store', connection: 'keep-alive', 'x-accel-buffering': 'no' });112 const send = (event: string, data: unknown) => { if (!reply.raw.writableEnded) reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data, big)}\n\n`); };113 send('start', { from: viewAsset(r.from), to: viewAsset(r.to), amountIn: r.amountIn, slippageBps: r.slippageBps, reachable: graph.reachable(r.from, r.to) });114 try {115 const s = await router.search(r, { signal: ac.signal,116 onRoute: (route) => send('route', viewRoute(route)),117 onEvent: (e) => { if (e.type === 'provider') send('provider', { provider: e.provider, status: e.status, code: e.code ?? null, ms: e.ms }); } });118 record(randomUUID(), r, s.events);119 send('done', summary(s));120 } catch (e) { send('error', { message: (e as Error).message.slice(0, 200) }); }121 reply.raw.end();122 });123124}125