SPB Git

Repository Public

router.ts 8.8 KB 118 lines typescript

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

Theme