SPB Git

Repository Public

swap.ts 11.2 KB 125 lines typescript

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
Files main
Menu

Theme