Repository Public
1// Smart route optimizer: direct quotes from every compatible provider, plus bounded two-leg routes through a hub asset,2// ranked on what the user is guaranteed to receive net of all known costs — not on path length.3import { QuoteAggregator, type QuoteEvent } from './aggregator.ts';4import { LiquidityGraph } from './graph.ts';5import { assessRisk, valueQuote, type Valuation, type Valuer } from './risk.ts';6import { assetKey, type AssetRef, type Executability, type RiskAssessment, type RoutingMode, type SwapQuote, type SwapQuoteRequest } from './types.ts';78export interface Route {9 id: string;10 /** one quote for a direct route, two for a route through an intermediate asset */11 legs: SwapQuote[];12 providers: string[];13 path: AssetRef[];14 amountIn: bigint; amountOut: bigint; minAmountOut: bigint;15 estimatedSeconds: number | null;16 expiresAt: number;17 executability: Executability;18 valuation: Valuation; risk: RiskAssessment;19 /** which optimisation modes pick this route */20 tags: RoutingMode[];21 /** on the price/time/risk Pareto frontier: no other route is at least as good on all three and better on one */22 pareto: boolean;23}24export interface RouteSearch { routes: Route[]; best: Partial<Record<RoutingMode, string>>; events: QuoteEvent[]; reachable: boolean; ms: number }25export interface RouterOptions { deadlineMs?: number; maxHops?: 1 | 2; signal?: AbortSignal; onEvent?: (e: QuoteEvent) => void; onRoute?: (r: Route) => void }2627const worst = (a: Executability, b: Executability): Executability => { const rank = { verified: 0, opaque: 1, quote_only: 2 } as const; return rank[a.class] >= rank[b.class] ? a : b; };28const riskRank = { low: 0, medium: 1, high: 2 } as const;2930export class SwapRouter {31 constructor(private agg: QuoteAggregator, private graph: LiquidityGraph, private value: Valuer, private now: () => number = Date.now) {}3233 private route(legs: SwapQuote[], peers: SwapQuote[]): Route {34 const first = legs[0]!, last = legs[legs.length - 1]!;35 // a composed route is valued on its real endpoints: input of the first leg, guaranteed output of the last36 const composite: SwapQuote = legs.length === 1 ? first : { ...last, id: legs.map((l) => l.id).join('+'), provider: legs.map((l) => l.provider).join('+'),37 request: { ...first.request, to: last.request.to, recipient: last.request.recipient }, steps: legs.flatMap((l) => l.steps), fees: legs.flatMap((l) => l.fees),38 estimatedSeconds: legs.every((l) => l.estimatedSeconds !== null) ? legs.reduce((s, l) => s + l.estimatedSeconds!, 0) : null,39 priceImpactBps: legs.every((l) => l.priceImpactBps !== null) ? legs.reduce((s, l) => s + l.priceImpactBps!, 0) : null,40 expiresAt: Math.min(...legs.map((l) => l.expiresAt)), executability: legs.map((l) => l.executability).reduce(worst), atomic: false,41 refundable: legs.some((l) => l.refundable === false) ? false : legs.some((l) => l.refundable === null) ? null : true };42 const valuation = valueQuote(composite, this.value);43 return { id: composite.id, legs, providers: legs.map((l) => l.provider), path: [first.request.from, ...legs.map((l) => l.request.to)], amountIn: first.request.amountIn,44 amountOut: composite.amountOut, minAmountOut: composite.minAmountOut, estimatedSeconds: composite.estimatedSeconds, expiresAt: composite.expiresAt,45 executability: composite.executability, valuation, risk: assessRisk(composite, valuation, legs.length === 1 ? peers : []), tags: [], pareto: false };46 }4748 async search(req: SwapQuoteRequest, opts: RouterOptions = {}): Promise<RouteSearch> {49 const t0 = this.now(), deadline = opts.deadlineMs ?? 4500;50 const events: QuoteEvent[] = [], direct: SwapQuote[] = [], routes: Route[] = [];51 const emit = (e: QuoteEvent) => { events.push(e); opts.onEvent?.(e); };52 const paths = this.graph.paths(req.from, req.to, opts.maxHops ?? 2);53 const reachable = paths.length > 0;54 const hubs = paths.filter((p) => p.length === 3).map((p) => this.graph.nodes.get(p[1]!)!).slice(0, 4);55 const remaining = () => Math.max(300, deadline - (this.now() - t0));5657 // direct quotes and the first legs of hub routes are requested at the same time58 const directJob = (async () => {59 if (!paths.some((p) => p.length === 2)) return;60 for await (const e of this.agg.stream(req, { deadlineMs: deadline, signal: opts.signal })) { emit(e);61 if (e.type === 'quote') { direct.push(e.quote); this.observe(e.quote, e.ms); const r = this.route([e.quote], direct); routes.push(r); opts.onRoute?.(r); } }62 })();63 const hubJobs = hubs.map(async (hub) => {64 // leg 1 delivers the intermediate asset to the wallet itself; leg 2 spends only what leg 1 guarantees65 const hubRecipient = hub.network === req.from.network ? req.sender : req.recipient;66 const leg1 = await this.agg.collect({ ...req, to: { network: hub.network, asset: hub.asset }, recipient: hubRecipient }, { deadlineMs: Math.floor(deadline * 0.5), signal: opts.signal, role: 'leg' });67 for (const q of leg1.quotes) this.observe(q, 0);68 const best1 = leg1.quotes.sort((a, b) => (a.minAmountOut > b.minAmountOut ? -1 : 1))[0];69 if (!best1) return;70 const leg2 = await this.agg.collect({ from: { network: hub.network, asset: hub.asset }, to: req.to, amountIn: best1.minAmountOut, sender: hubRecipient, recipient: req.recipient, slippageBps: req.slippageBps },71 { deadlineMs: remaining(), signal: opts.signal, role: 'leg' });72 for (const q of leg2.quotes) this.observe(q, 0);73 const best2 = leg2.quotes.sort((a, b) => (a.minAmountOut > b.minAmountOut ? -1 : 1))[0];74 if (!best2) return;75 const r = this.route([best1, best2], []); routes.push(r); opts.onRoute?.(r);76 });77 await Promise.allSettled([directJob, ...hubJobs]);7879 // re-assess direct routes now that every peer is known (outlier detection needs the full set)80 for (const r of routes) if (r.legs.length === 1) r.risk = assessRisk(r.legs[0]!, r.valuation, direct);81 // a route through an intermediate asset is only worth showing if it clearly beats the best direct route82 const bestDirect = routes.filter((r) => r.legs.length === 1).reduce((m, r) => (r.minAmountOut > m ? r.minAmountOut : m), 0n);83 const kept = routes.filter((r) => r.legs.length === 1 || bestDirect === 0n || r.minAmountOut * 1000n > bestDirect * 1003n);84 const best = rank(kept);85 return { routes: kept, best, events, reachable, ms: this.now() - t0 };86 }87 private observe(q: SwapQuote, ms: number): void {88 this.graph.observe({ provider: q.provider, from: assetKey(q.request.from), to: assetKey(q.request.to), amountIn: q.request.amountIn.toString(), amountOut: q.amountOut.toString(), minAmountOut: q.minAmountOut.toString(), ms, at: this.now(), ok: true });89 }90}9192/** Order routes and pick one per mode. Security policy first: a high-risk route is never selected while a lower-risk one exists. */93export function rank(routes: Route[]): Partial<Record<RoutingMode, string>> {94 for (const r of routes) { r.tags = []; r.pareto = false; }95 if (!routes.length) return {};96 // net guaranteed output: in fiat when priced (so fees paid in another asset count), otherwise raw minimum output97 const priced = routes.every((r) => r.valuation.netUsd !== null);98 const net = (r: Route) => (priced ? r.valuation.netUsd! : Number(r.minAmountOut));99 const time = (r: Route) => r.estimatedSeconds ?? Number.POSITIVE_INFINITY;100 const lowest = Math.min(...routes.map((r) => riskRank[r.risk.level]));101 const admissible = routes.filter((r) => riskRank[r.risk.level] === lowest || r.risk.level !== 'high');102 const pool = admissible.length ? admissible : routes;103 const byPrice = [...pool].sort((a, b) => net(b) - net(a) || time(a) - time(b));104 const bestNet = net(byPrice[0]!);105 // fastest: among routes that give up at most 2 % of the best net output106 const near = pool.filter((r) => net(r) >= bestNet - Math.abs(bestNet) * 0.02);107 const byTime = [...near].sort((a, b) => time(a) - time(b) || net(b) - net(a));108 // balanced: normalised net output, minus time and risk penalties (weights are explicit and fixed)109 const maxT = Math.max(1, ...pool.map((r) => (Number.isFinite(time(r)) ? time(r) : 0)));110 const score = (r: Route) => (bestNet !== 0 ? net(r) / Math.abs(bestNet) : 0) - 0.03 * (Number.isFinite(time(r)) ? time(r) / maxT : 1) - 0.004 * r.risk.score;111 const byScore = [...pool].sort((a, b) => score(b) - score(a));112 const pick: Partial<Record<RoutingMode, string>> = { best_price: byPrice[0]!.id, fastest: byTime[0]!.id, balanced: byScore[0]!.id };113 for (const [mode, id] of Object.entries(pick) as [RoutingMode, string][]) routes.find((r) => r.id === id)!.tags.push(mode);114 for (const r of routes) r.pareto = !routes.some((o) => o !== r && net(o) >= net(r) && time(o) <= time(r) && o.risk.score <= r.risk.score && (net(o) > net(r) || time(o) < time(r) || o.risk.score < r.risk.score));115 routes.sort((a, b) => net(b) - net(a));116 return pick;117}118