Repository Public
1// Any-to-any planner (V7). A plan is a SEQUENCE OF LEGS THE WALLET CAN ALREADY EXECUTE — a same-network swap (KyberSwap,2// Jupiter), a USDC transfer between two EVM networks (Circle CCTP), a native swap through THORChain — never a new trust path.3// This file is pure: no network, no database. The caller supplies what is executable (`PlanCaps`), a function that returns a4// REAL quote for one leg, balances and limits. Docs: docs/v7/ANY-TO-ANY.md.5//6// Amounts chain: leg k+1 is quoted twice, on what leg k is EXPECTED to deliver and on the MINIMUM it guarantees. The first7// chain gives the expected result, the second the worst case. Neither is a promise for the whole plan: each leg is approved8// (and guaranteed) on its own when its turn comes, sized on what was actually received.9import { getNetwork } from '@spw/core';10import { assetKey, sameAsset, type AssetRef, type FeeItem } from './types.ts';1112export type PlanLegKind = 'swap' | 'bridge' | 'xswap';13export type PlanVia = 'kyberswap' | 'jupiter' | 'cctp' | 'thorchain';14export interface PlanLegSpec { kind: PlanLegKind; via: PlanVia; from: AssetRef; to: AssetRef; /** CCTP only */ speed?: 'standard' | 'fast' }1516/** What the wallet can execute today, as data (built by the API from the pinned tables of @spw/core). */17export interface PlanCaps {18 /** network -> provider of the same-network swap the signer verifies there */19 swap: Record<string, 'kyberswap' | 'jupiter'>;20 /** network -> the native USDC that CCTP burns and mints there, and whether Circle offers Fast from it */21 cctp: Record<string, { usdc: string; fast: boolean }>;22 /** assets THORChain swaps; `source` = may be the input */23 thor: { network: string; asset: string; source: boolean }[];24}2526export const MAX_PLAN_LEGS = 3;27const key = (a: AssetRef) => assetKey(a);28export const legId = (l: PlanLegSpec) => `${l.via}${l.speed ? `.${l.speed}` : ''}:${key(l.from)}>${key(l.to)}`;29/** Stable identity of a plan shape: the same legs in the same order. */30export const planId = (legs: PlanLegSpec[]) => legs.map(legId).join('|');3132const thorOf = (c: PlanCaps, a: AssetRef) => c.thor.find((t) => t.network === a.network && key({ network: t.network, asset: t.asset }) === key(a));33const usdcOf = (c: PlanCaps, network: string): AssetRef | null => (c.cctp[network] ? { network, asset: c.cctp[network]!.usdc } : null);34const isUsdc = (c: PlanCaps, a: AssetRef) => { const u = usdcOf(c, a.network); return !!u && sameAsset(u, a); };35/** The only intermediate assets: native USDC of a CCTP network. Funds resting between two legs are an ordinary USDC balance. */36const isHub = (c: PlanCaps, a: AssetRef) => isUsdc(c, a);3738/**39 * Every plan of at most three legs from `from` to `to` over the executable legs. Rules: an intermediate asset is a hub40 * (native USDC), a network is never visited twice, no two legs of the same kind in a row.41 */42export function enumeratePlans(from: AssetRef, to: AssetRef, caps: PlanCaps, maxLegs = MAX_PLAN_LEGS): PlanLegSpec[][] {43 const out: PlanLegSpec[][] = [], dst = key(to);44 if (key(from) === dst) return out;45 const walk = (at: AssetRef, legs: PlanLegSpec[], left: Set<string>) => {46 if (legs.length >= maxLegs) return;47 const prev = legs[legs.length - 1], next: PlanLegSpec[] = [];48 // same-network swap: to the destination when it lives here, otherwise to this network's hub49 const via = caps.swap[at.network];50 if (via && prev?.kind !== 'swap') {51 if (to.network === at.network) next.push({ kind: 'swap', via, from: at, to });52 const hub = usdcOf(caps, at.network);53 if (hub && !sameAsset(hub, at) && !sameAsset(hub, to)) next.push({ kind: 'swap', via, from: at, to: hub });54 }55 // CCTP: native USDC to native USDC of another network56 if (isUsdc(caps, at) && prev?.kind !== 'bridge') for (const m of Object.keys(caps.cctp)) {57 if (m === at.network || left.has(m)) continue;58 const usdc = usdcOf(caps, m)!;59 next.push({ kind: 'bridge', via: 'cctp', from: at, to: usdc, speed: 'standard' });60 if (caps.cctp[at.network]!.fast) next.push({ kind: 'bridge', via: 'cctp', from: at, to: usdc, speed: 'fast' });61 }62 // THORChain: a pinned source asset to a pinned asset on another network63 if (thorOf(caps, at)?.source && prev?.kind !== 'xswap') for (const t of caps.thor) {64 if (t.network === at.network || left.has(t.network)) continue;65 next.push({ kind: 'xswap', via: 'thorchain', from: at, to: { network: t.network, asset: t.asset } });66 }67 for (const l of next) {68 const path = [...legs, l];69 if (key(l.to) === dst) { out.push(path); continue; }70 if (!isHub(caps, l.to)) continue;71 walk(l.to, path, l.to.network === at.network ? left : new Set([...left, at.network]));72 }73 };74 walk(from, [], new Set());75 // shortest first, then by identity: a deterministic order before pricing76 const seen = new Set<string>();77 return out.filter((p) => { const id = planId(p); if (seen.has(id)) return false; seen.add(id); return true; }).sort((a, b) => a.length - b.length || planId(a).localeCompare(planId(b)));78}7980export type NotExecutable = { code: 'same_asset' | 'solana_evm_bridge' | 'network_without_execution' | 'no_executable_path'; network?: string };81/** Why no plan exists for a pair, said precisely. Called only when `enumeratePlans` returned nothing. */82export function whyNotExecutable(from: AssetRef, to: AssetRef, caps: PlanCaps): NotExecutable {83 if (key(from) === key(to)) return { code: 'same_asset' };84 const fam = (a: AssetRef) => getNetwork(a.network).family;85 // CCTP supports Solana, but the wallet's signer only builds and verifies the EVM burn and mint (docs/v7/ANY-TO-ANY.md §Solana)86 if (from.network !== to.network && (fam(from) === 'sol') !== (fam(to) === 'sol')) return { code: 'solana_evm_bridge' };87 const reach = (a: AssetRef) => !!caps.swap[a.network] || !!thorOf(caps, a) || isUsdc(caps, a);88 if (!reach(from)) return { code: 'network_without_execution', network: from.network };89 if (!reach(to)) return { code: 'network_without_execution', network: to.network };90 return { code: 'no_executable_path' };91}9293// ---------------------------------------------------------------------------------------------------------------------94// Pricing9596export interface LegQuote {97 amountOut: bigint; minAmountOut: bigint; fees: FeeItem[]; seconds: number | null;98 /** the provider's own minimum input for this leg, when it has one (THORChain) */99 minIn?: bigint | null;100 /** venues, for display */101 via?: string[];102}103/** A real quote for one leg and one exact input. `null` = no route right now. Throws on provider failure. */104export type QuoteLeg = (leg: PlanLegSpec, amountIn: bigint) => Promise<LegQuote | null>;105export type PlanValuer = (asset: AssetRef, amount: bigint) => number | null;106107export interface PlanFee extends FeeItem { usd: number | null; /** fee of the expected chain (the worst-case chain pays about the same) */ leg: number }108export interface PricedLeg {109 spec: PlanLegSpec;110 expectedIn: bigint; worstIn: bigint; expectedOut: bigint; worstOut: bigint;111 fees: PlanFee[]; seconds: number | null; minIn: bigint | null; via: string[];112}113export interface PricedPlan {114 id: string; legs: PricedLeg[]; amountIn: bigint;115 /** "attendu": every leg at its quoted output. "garanti au pire": every leg at its guaranteed minimum. */116 expectedOut: bigint; worstOut: bigint;117 seconds: number | null;118 /** fees not already deducted from an output, in USD (null when one of them has no price) */119 extraFeesUsd: number | null;120 /** one passkey approval per leg */121 approvals: number;122 issues: PlanIssue[]; executable: boolean;123}124export class PlanQuoteError extends Error { leg: number; code: 'no_route' | 'provider'; constructor(leg: number, code: 'no_route' | 'provider', message: string) { super(message); this.leg = leg; this.code = code; } }125126/** Price one plan with real quotes, leg after leg. Throws PlanQuoteError naming the leg that has no quote. */127export async function pricePlan(specs: PlanLegSpec[], amountIn: bigint, quote: QuoteLeg, value: PlanValuer = () => null): Promise<PricedPlan> {128 const legs: PricedLeg[] = [];129 let expected = amountIn, worst = amountIn;130 for (const [k, spec] of specs.entries()) {131 let qe: LegQuote | null, qw: LegQuote | null;132 try { qe = await quote(spec, expected); qw = worst === expected ? qe : await quote(spec, worst); }133 catch (e) { throw new PlanQuoteError(k, 'provider', (e as Error).message); }134 if (!qe || !qw) throw new PlanQuoteError(k, 'no_route', 'no route for this leg and amount right now');135 if (qe.amountOut <= 0n || qw.minAmountOut <= 0n || qw.minAmountOut > qe.amountOut) throw new PlanQuoteError(k, 'provider', 'inconsistent quote for this leg');136 legs.push({ spec, expectedIn: expected, worstIn: worst, expectedOut: qe.amountOut, worstOut: qw.minAmountOut, seconds: qe.seconds, minIn: qe.minIn ?? null, via: qe.via ?? [],137 fees: qe.fees.map((f) => ({ ...f, usd: value(f.asset, f.amount), leg: k })) });138 expected = qe.amountOut; worst = qw.minAmountOut;139 }140 const extra = legs.flatMap((l) => l.fees).filter((f) => !f.includedInOutput);141 return { id: planId(specs), legs, amountIn, expectedOut: expected, worstOut: worst,142 seconds: legs.every((l) => l.seconds !== null) ? legs.reduce((s, l) => s + l.seconds!, 0) : null,143 extraFeesUsd: extra.every((f) => f.usd !== null) ? extra.reduce((s, f) => s + f.usd!, 0) : null,144 approvals: legs.length, issues: [], executable: true };145}146147// ---------------------------------------------------------------------------------------------------------------------148// Constraints: limits of EACH leg's own asset, minimums, balance, gas on every network involved149150export interface PlanIssue {151 code: 'insufficient_balance' | 'limit_missing' | 'limit_per_transfer' | 'limit_daily' | 'limit_margin' | 'below_minimum' | 'gas_missing' | 'gas_unknown';152 /** a blocking issue makes the plan non-startable; a warning is shown and the leg's own check decides when its turn comes */153 blocking: boolean;154 leg: number | null; network: string; symbol: string;155 /** base units, as decimal strings (limits, amounts) — the UI formats them */156 need: string; have: string; decimals: number;157}158export interface PlanContext {159 /** symbol and decimals of an asset (registry) */160 asset: (a: AssetRef) => { symbol: string; decimals: number };161 /** signer policy, keyed "<network>:<symbol>", base units; a missing key means the asset cannot be moved at all */162 perTx: Record<string, string>; daily: Record<string, string>; spentToday: Record<string, string>;163 /** the wallet's balance of an asset, or null when it could not be read */164 balance: (a: AssetRef) => bigint | null;165}166/** Network fees are estimates and the signer signs a ceiling of about twice the current price: require that much. */167export const GAS_MARGIN = 2n;168/** A leg whose expected input is within this of its per-transfer limit may be refused when the real amount arrives. */169export const LIMIT_MARGIN_BPS = 300n;170171export function checkPlan(p: PricedPlan, c: PlanContext): PricedPlan {172 const issues: PlanIssue[] = [], first = p.legs[0]!.spec.from;173 const src = c.asset(first), have = c.balance(first);174 if (have !== null && have < p.amountIn) issues.push({ code: 'insufficient_balance', blocking: true, leg: 0, network: first.network, symbol: src.symbol, need: p.amountIn.toString(), have: have.toString(), decimals: src.decimals });175 const day = new Map<string, bigint>();176 for (const [k, l] of p.legs.entries()) {177 const a = c.asset(l.spec.from), id = `${l.spec.from.network}:${a.symbol}`, base = { leg: k, network: l.spec.from.network, symbol: a.symbol, decimals: a.decimals };178 const perTx = BigInt(c.perTx[id] ?? '0'), daily = BigInt(c.daily[id] ?? '0'), spent = BigInt(c.spentToday[id] ?? '0') + (day.get(id) ?? 0n);179 if (perTx === 0n) issues.push({ ...base, code: 'limit_missing', blocking: true, need: l.expectedIn.toString(), have: '0' });180 else if (l.expectedIn > perTx) issues.push({ ...base, code: 'limit_per_transfer', blocking: true, need: l.expectedIn.toString(), have: perTx.toString() });181 else if (spent + l.expectedIn > daily) issues.push({ ...base, code: 'limit_daily', blocking: true, need: l.expectedIn.toString(), have: (daily > spent ? daily - spent : 0n).toString() });182 else if (k > 0 && l.expectedIn * 10_000n > perTx * (10_000n - LIMIT_MARGIN_BPS)) issues.push({ ...base, code: 'limit_margin', blocking: false, need: l.expectedIn.toString(), have: perTx.toString() });183 day.set(id, (day.get(id) ?? 0n) + l.expectedIn);184 if (l.minIn !== null && l.worstIn < l.minIn) issues.push({ ...base, code: 'below_minimum', blocking: true, need: l.minIn.toString(), have: l.worstIn.toString() });185 }186 // gas: every network fee the wallet pays itself, per network, in that network's coin187 const gas = new Map<string, bigint>();188 for (const f of p.legs.flatMap((l) => l.fees)) if (f.kind === 'network' && !f.includedInOutput && f.asset.asset === 'native') gas.set(f.asset.network, (gas.get(f.asset.network) ?? 0n) + f.amount * GAS_MARGIN);189 for (const [network, need] of gas) {190 const ref = { network, asset: 'native' }, a = c.asset(ref), bal = c.balance(ref);191 if (bal === null) { issues.push({ code: 'gas_unknown', blocking: false, leg: null, network, symbol: a.symbol, need: need.toString(), have: '0', decimals: a.decimals }); continue; }192 // the source amount itself may be this network's coin: it is not available for fees193 const free = sameAsset(first, ref) ? (bal > p.amountIn ? bal - p.amountIn : 0n) : bal;194 if (free < need) issues.push({ code: 'gas_missing', blocking: true, leg: null, network, symbol: a.symbol, need: need.toString(), have: free.toString(), decimals: a.decimals });195 }196 return { ...p, issues, executable: !issues.some((i) => i.blocking) };197}198199/** Startable plans first; then the highest guaranteed worst case, then the highest expected result, then the fastest. */200export function rankPlans(plans: PricedPlan[]): PricedPlan[] {201 const t = (p: PricedPlan) => p.seconds ?? Number.POSITIVE_INFINITY;202 return [...plans].sort((a, b) => Number(b.executable) - Number(a.executable) || (a.worstOut === b.worstOut ? 0 : a.worstOut > b.worstOut ? -1 : 1)203 || (a.expectedOut === b.expectedOut ? 0 : a.expectedOut > b.expectedOut ? -1 : 1) || t(a) - t(b) || a.id.localeCompare(b.id));204}205206export interface ComparisonQuote { id: string; providers: string[]; amountOut: bigint; minAmountOut: bigint; executable: boolean }207export interface Comparison extends ComparisonQuote { /** always: the wallet cannot execute this quote */ comparisonOnly: true; /** its guaranteed minimum is above the best plan's worst case */ aboveBestPlan: boolean }208/** Quotes of providers the wallet cannot execute are never hidden, even when they are better: they are labelled. */209export function comparisonQuotes(plans: PricedPlan[], quotes: ComparisonQuote[]): Comparison[] {210 const best = rankPlans(plans).find((p) => p.executable)?.worstOut ?? rankPlans(plans)[0]?.worstOut ?? null;211 return quotes.filter((q) => !q.executable).map((q) => ({ ...q, comparisonOnly: true as const, aboveBestPlan: best === null || q.minAmountOut > best }))212 .sort((a, b) => (a.minAmountOut === b.minAmountOut ? 0 : a.minAmountOut > b.minAmountOut ? -1 : 1));213}214