SPB Git

Repository Public

lifi.ts 17.0 KB 220 lines typescript

1// LI.FI — cross-chain and same-chain routing aggregator (bridges + DEX aggregators behind one "Diamond" contract).2// Price discovery only: this adapter asks POST /v1/advanced/routes, which returns priced routes WITHOUT transaction data3// (calldata is only produced later by /v1/advanced/stepTransaction, which this adapter never calls). Nothing is built,4// signed or sent. Execution dossier: docs/swap/providers/lifi.md.5//6// Verified live on 2026-11-10 (GET /v1/chains?chainTypes=SVM,UTXO,MVM,TVM,STL and /v1/chains): chain ids and Diamond7// addresses below. Stellar is listed by LI.FI (id 1201081091099710) but no route delivers native XLM today, and Litecoin,8// Dogecoin, Bitcoin Cash, XRPL, Aptos, Cosmos, NEAR and TON are not LI.FI chains: none of them is declared here.9import { z } from 'zod';10import { isValidAddress } from '@spw/core';11import {12  ProviderError, resolveAsset, sameAsset,13  type AssetRef, type FeeItem, type FeeKind, type ProviderContext, type RouteStep, type SwapProvider, type SwapQuote, type SwapQuoteRequest,14} from '../types.ts';15import type { ProviderEnv } from './index.ts';1617const ID = 'lifi';18/** pinned API base and version */19export const LIFI_API = 'https://li.quest/v1';20export const LIFI_DOCS = 'https://docs.li.fi/api-reference/advanced/get-a-set-of-routes-for-a-request-that-describes-a-transfer-of-tokens';21/** LI.FI gives no validity for a route: conservative default from the provider contract */22const VALIDITY_MS = 30_000;23const MAX_ROUTES = 12;2425/** wallet network id -> LI.FI chain id, the id LI.FI uses for the chain coin, and the LiFiDiamond where one exists (GET /v1/chains, 2026-11-10) */26export const LIFI_CHAINS: Record<string, { chainId: number; native: string; diamond?: string }> = {27  ethereum: { chainId: 1, native: '0x0000000000000000000000000000000000000000', diamond: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE' },28  base: { chainId: 8453, native: '0x0000000000000000000000000000000000000000', diamond: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE' },29  arbitrum: { chainId: 42161, native: '0x0000000000000000000000000000000000000000', diamond: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE' },30  polygon: { chainId: 137, native: '0x0000000000000000000000000000000000000000', diamond: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE' },31  optimism: { chainId: 10, native: '0x0000000000000000000000000000000000000000', diamond: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE' },32  bsc: { chainId: 56, native: '0x0000000000000000000000000000000000000000', diamond: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE' },33  avalanche: { chainId: 43114, native: '0x0000000000000000000000000000000000000000', diamond: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE' },34  hyperevm: { chainId: 999, native: '0x0000000000000000000000000000000000000000', diamond: '0x0a0758d937d1059c356D4714e57F5df0239bce1A' },35  tron: { chainId: 728126428, native: 'T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb', diamond: 'TU3ymitEKCWQFtASkEeHaPb8NfZcJtCHLt' },36  solana: { chainId: 1151111081099710, native: '11111111111111111111111111111111' },37  bitcoin: { chainId: 20000000000001, native: 'bitcoin' },38  sui: { chainId: 9270000000000000, native: '0x0000000000000000000000000000000000000000000000000000000000000002::sui::SUI' },39};40const NETWORK_OF = new Map(Object.entries(LIFI_CHAINS).map(([net, c]) => [c.chainId, net]));4142// ---- response schema (unknown keys are stripped; `raw` is the validated route) ----43/** base units as a decimal string: no sign, no fraction, no exponent */44const Amount = z.string().regex(/^\d{1,78}$/);45/** LI.FI's ids for non-EVM chains are large (Sui: 9270000000000000, above 2^53 but an exact double): integer-valued, not "safe" */46const ChainId = z.number().positive().max(1e17).refine(Number.isInteger, 'not an integer');47const Token = z.object({ address: z.string().min(1).max(128), chainId: ChainId, symbol: z.string().max(64), decimals: z.number().int().min(0).max(36) });48const FeeCost = z.object({49  name: z.string().max(128), token: Token, amount: Amount, included: z.boolean(),50  feeSplit: z.object({ lifiFee: Amount, integratorFee: Amount }).optional(),51});52const GasCost = z.object({ type: z.string().max(32), amount: Amount, token: Token });53const Duration = z.number().finite().min(0).max(7 * 86400);54const IncludedStep = z.object({55  id: z.string().max(128), type: z.enum(['swap', 'cross', 'protocol']), tool: z.string().max(64),56  toolDetails: z.object({ key: z.string().max(64), name: z.string().min(1).max(64) }),57  action: z.object({ fromChainId: ChainId, toChainId: ChainId, fromToken: Token, toToken: Token, fromAmount: Amount, toAddress: z.string().max(128).optional() }),58  estimate: z.object({ fromAmount: Amount, toAmount: Amount, toAmountMin: Amount, approvalAddress: z.string().max(128).optional(), feeCosts: z.array(FeeCost).max(32).optional() }),59});60const Step = z.object({61  id: z.string().max(128), type: z.enum(['lifi', 'swap', 'cross']), tool: z.string().max(64),62  toolDetails: z.object({ key: z.string().max(64), name: z.string().min(1).max(64) }),63  action: z.object({64    fromChainId: ChainId, toChainId: ChainId, fromToken: Token, toToken: Token, fromAmount: Amount,65    slippage: z.number().finite().min(0).max(1), fromAddress: z.string().max(128), toAddress: z.string().max(128),66  }),67  estimate: z.object({68    fromAmount: Amount, toAmount: Amount, toAmountMin: Amount, approvalAddress: z.string().max(128).optional(),69    feeCosts: z.array(FeeCost).max(32).optional(), gasCosts: z.array(GasCost).max(16).optional(), executionDuration: Duration,70  }),71  includedSteps: z.array(IncludedStep).min(1).max(12),72});73const Route = z.object({74  id: z.string().min(1).max(128),75  fromChainId: ChainId, toChainId: ChainId, fromToken: Token, toToken: Token,76  fromAmount: Amount, toAmount: Amount, toAmountMin: Amount, fromAddress: z.string().max(128), toAddress: z.string().max(128),77  containsSwitchChain: z.boolean().optional(), tags: z.array(z.string().max(32)).max(8).optional(),78  steps: z.array(Step).min(1).max(8),79});80const RoutesResponse = z.object({ routes: z.array(Route).max(64) });81type LifiToken = z.infer<typeof Token>;82type LifiRoute = z.infer<typeof Route>;8384const bad = (msg: string): never => { throw new ProviderError(ID, 'schema', msg); };85const isEvm = (network: string) => resolveAsset({ network, asset: 'native' }).net.family === 'evm';86const sameAddr = (network: string, a: string, b: string) => (isEvm(network) ? a.toLowerCase() === b.toLowerCase() : a === b);8788/** the identifier LI.FI uses for a registry asset */89function lifiToken(a: AssetRef): string { return a.asset === 'native' ? LIFI_CHAINS[a.network]!.native : a.asset; }90/** a LI.FI token -> registry asset, or null when the chain or the token is outside the wallet's registry. Wrong decimals are a schema error. */91function refOf(t: LifiToken): AssetRef | null {92  const network = NETWORK_OF.get(t.chainId);93  if (!network) return null;94  const ref: AssetRef = sameAddr(network, t.address, LIFI_CHAINS[network]!.native) ? { network, asset: 'native' } : { network, asset: t.address };95  let def;96  try { def = resolveAsset(ref).def; } catch { return null; }97  if (def.decimals !== t.decimals) bad(`decimals mismatch for ${t.symbol} on ${network}: provider ${t.decimals}, registry ${def.decimals}`);98  return ref.asset === 'native' ? ref : { network, asset: def.id };99}100const sameToken = (a: LifiToken, b: LifiToken) => a.chainId === b.chainId && (NETWORK_OF.has(a.chainId) ? sameAddr(NETWORK_OF.get(a.chainId)!, a.address, b.address) : a.address === b.address);101102/**103 * One LI.FI route -> SwapQuote, or null when the wallet cannot represent it (several user transactions, a fee the user would104 * have to pay in an asset outside the registry). Anything that contradicts the request is a schema error.105 */106function toQuote(route: LifiRoute, req: SwapQuoteRequest, now: number): SwapQuote | null {107  const src = LIFI_CHAINS[req.from.network]!, dst = LIFI_CHAINS[req.to.network]!;108  const from = refOf(route.fromToken), to = refOf(route.toToken);109  if (route.fromChainId !== src.chainId || route.toChainId !== dst.chainId || !from || !to || !sameAsset(from, req.from) || !sameAsset(to, req.to)) bad('route is for another pair');110  if (BigInt(route.fromAmount) !== req.amountIn) bad('route is for another amount');111  if (!sameAddr(req.from.network, route.fromAddress, req.sender)) bad('route is for another sender');112  if (!sameAddr(req.to.network, route.toAddress, req.recipient)) bad('route pays another recipient');113  const amountOut = BigInt(route.toAmount), minAmountOut = BigInt(route.toAmountMin);114  if (amountOut <= 0n || minAmountOut <= 0n || minAmountOut > amountOut) bad('inconsistent output amounts');115  // one top-level step = one transaction signed by the user; more than one needs a second signature (often on the destination chain)116  if (route.containsSwitchChain || route.steps.length !== 1) return null;117  const step = route.steps[0]!;118  if (Math.abs(step.action.slippage - req.slippageBps / 10_000) > 1e-9) bad('route was priced with another slippage');119  if (BigInt(step.action.fromAmount) !== req.amountIn || BigInt(step.estimate.toAmount) !== amountOut || BigInt(step.estimate.toAmountMin) !== minAmountOut) bad('step amounts differ from the route');120  if (!sameAddr(req.to.network, step.action.toAddress, req.recipient) || !sameAddr(req.from.network, step.action.fromAddress, req.sender)) bad('step is for other addresses');121  // the contract the user approves / calls must be the pinned Diamond wherever LI.FI has one (EVM, TRON)122  if (src.diamond && (!step.estimate.approvalAddress || !sameAddr(req.from.network, step.estimate.approvalAddress, src.diamond))) bad('approval address is not the pinned LI.FI Diamond');123124  // ---- steps: included steps in order; legs through an asset outside the registry are merged into the next one ----125  const inc = step.includedSteps;126  if (!sameToken(inc[0]!.action.fromToken, route.fromToken) || !sameToken(inc[inc.length - 1]!.action.toToken, route.toToken)) bad('included steps do not connect the requested assets');127  const steps: RouteStep[] = [];128  const feeKindByName = new Map<string, FeeKind>();129  let open: { from: AssetRef; via: string[]; contracts: RouteStep['contracts']; cross: boolean } | null = null, cursor: AssetRef = req.from;130  // the Diamond is the contract called, and the spender to approve when the input is a token131  const carried: RouteStep['contracts'] = !src.diamond ? [] : [{ network: req.from.network, address: src.diamond, role: 'router' }];132  if (src.diamond && req.from.asset !== 'native') carried.push({ network: req.from.network, address: src.diamond, role: 'spender' });133  for (let k = 0; k < inc.length; k++) {134    const s = inc[k]!;135    if (k > 0 && !sameToken(inc[k - 1]!.action.toToken, s.action.fromToken)) bad('included steps are not contiguous');136    if (s.action.fromToken.chainId !== s.action.fromChainId || s.action.toToken.chainId !== s.action.toChainId) bad('included step token is on another chain');137    for (const f of s.estimate.feeCosts ?? []) feeKindByName.set(f.name, s.type === 'cross' ? 'bridge' : s.type === 'swap' ? 'protocol' : 'provider');138    const net = NETWORK_OF.get(s.action.fromChainId), addr = s.estimate.approvalAddress;139    const usable = !!net && !!addr && isValidAddress(net, addr) && !sameAddr(net, addr, LIFI_CHAINS[net]!.diamond ?? '') && addr !== req.sender && addr !== req.recipient;140    if (s.type === 'protocol' && sameToken(s.action.fromToken, s.action.toToken)) {141      // LI.FI's fee step ("FeeForwarder" contract): no asset changes; its contract is reported with the next leg, its fee in `fees`142      if (usable) carried.push({ network: net!, address: addr!, role: 'router' });143      continue;144    }145    if ((s.type === 'cross') !== (s.action.fromChainId !== s.action.toChainId)) bad('step type contradicts its chains');146    open ??= { from: cursor, via: [], contracts: carried.splice(0), cross: false };147    open.via.push(s.toolDetails.name); open.cross ||= s.type === 'cross';148    if (usable) open.contracts.push({ network: net!, address: addr!, role: s.type === 'cross' ? 'bridge' : 'router' });149    const reached = refOf(s.action.toToken);150    if (reached) { steps.push({ kind: open.cross ? 'bridge' : 'swap', provider: ID, from: open.from, to: reached, via: open.via, contracts: open.contracts }); cursor = reached; open = null; }151  }152  if (open || steps.length === 0) bad('included steps do not end on the requested asset');153  if (steps.length > 6) return null;154155  // ---- fees ----156  const fees: FeeItem[] = [];157  for (const g of step.estimate.gasCosts ?? []) {158    const asset = refOf(g.token);159    if (!asset || asset.network !== req.from.network) return bad('gas is quoted in an asset the wallet does not hold on the source network');160    fees.push({ kind: 'network', asset, amount: BigInt(g.amount), includedInOutput: false, estimated: true });161  }162  for (const f of step.estimate.feeCosts ?? []) {163    const asset = refOf(f.token);164    if (!asset) { if (f.included) continue; return null; } // already inside the output (kept in `raw`) / would have to be paid in an unknown asset165    const amount = BigInt(f.amount), kind = feeKindByName.get(f.name) ?? (req.from.network === req.to.network ? 'protocol' : 'bridge');166    const lifi = f.feeSplit ? BigInt(f.feeSplit.lifiFee) : 0n, integrator = f.feeSplit ? BigInt(f.feeSplit.integratorFee) : 0n;167    if (f.feeSplit && lifi + integrator === amount) {168      if (lifi > 0n) fees.push({ kind: 'provider', asset, amount: lifi, includedInOutput: f.included, estimated: false });169      if (integrator > 0n) fees.push({ kind: 'affiliate', asset, amount: integrator, includedInOutput: f.included, estimated: false });170    } else fees.push({ kind, asset, amount, includedInOutput: f.included, estimated: false });171  }172173  const sameChain = req.from.network === req.to.network && steps.every((s) => s.kind === 'swap');174  return {175    id: `${ID}:${route.id}`, provider: ID, request: req, steps, amountOut, minAmountOut, fees,176    estimatedSeconds: Math.ceil(step.estimate.executionDuration), priceImpactBps: null, expiresAt: now + VALIDITY_MS,177    firmness: 'indicative',178    executability: { class: 'opaque', reason: 'LI.FI builds the calldata for its Diamond contract (bridge and DEX facets, per-bridge data); the signer can pin the target contract and the exact allowance but cannot recompute the recipient, the minimum output or the bridge payload by itself' },179    // same chain: one transaction that reverts as a whole. Cross-chain: refund depends on the bridge LI.FI picked (status REFUNDED / PARTIAL), not stated per route180    refundable: sameChain ? true : null, atomic: sameChain,181    raw: route,182  };183}184185export function createLifiProvider(env: ProviderEnv = {}): SwapProvider {186  const key = env.LIFI_API_KEY?.trim() || undefined, integrator = env.LIFI_INTEGRATOR?.trim() || 'sp-wallet';187  const networks = Object.keys(LIFI_CHAINS);188  const known = (a: AssetRef) => { if (!LIFI_CHAINS[a.network]) return false; try { resolveAsset(a); return true; } catch { return false; } };189  return {190    id: ID, name: 'LI.FI',191    // the public API works without a key (75 route requests per 2 h per IP); a key only raises the limit192    capabilities: () => ({ networks, crossChain: true, needsKey: false, configured: true, kinds: ['swap', 'bridge'], docs: LIFI_DOCS }),193    // LI.FI on TRON is cross-chain only ("quotes with Tron as the source chain are always cross-chain", docs, 2026-11-10)194    supportsPair: (from, to) => known(from) && known(to) && !sameAsset(from, to) && !(from.network === to.network && from.network === 'tron'),195    async quote(req: SwapQuoteRequest, ctx: ProviderContext): Promise<SwapQuote[]> {196      if (!this.supportsPair(req.from, req.to)) return [];197      if (req.amountIn <= 0n || !Number.isInteger(req.slippageBps) || req.slippageBps < 0 || req.slippageBps > 5000) throw new ProviderError(ID, 'bad_response', 'invalid amount or slippage');198      const body = {199        fromChainId: LIFI_CHAINS[req.from.network]!.chainId, toChainId: LIFI_CHAINS[req.to.network]!.chainId,200        fromTokenAddress: lifiToken(req.from), toTokenAddress: lifiToken(req.to),201        fromAmount: req.amountIn.toString(), fromAddress: req.sender, toAddress: req.recipient,202        // slippage is a fraction (0.005 = 0.5 %), applied by LI.FI per step; allowSwitchChain:false keeps routes to one user transaction203        options: { slippage: req.slippageBps / 10_000, integrator, allowSwitchChain: false },204      };205      let payload: unknown;206      try { payload = await ctx.http.postJson(`${LIFI_API}/advanced/routes`, body, { signal: ctx.signal, headers: key ? { 'x-lifi-api-key': key } : undefined }); }207      catch (e) {208        // 404 = LI.FI does not list one of the tokens (code 1003): nothing to route, not a failure209        if (e instanceof ProviderError && e.code === 'http' && (e as { status?: number }).status === 404) return [];210        throw e;211      }212      const parsed = RoutesResponse.safeParse(payload);213      if (!parsed.success) throw new ProviderError(ID, 'schema', parsed.error.issues.slice(0, 3).map((i) => `${i.path.join('.')}: ${i.message}`).join('; '));214      const now = ctx.now(), quotes: SwapQuote[] = [];215      for (const r of parsed.data.routes) { const q = toQuote(r, req, now); if (q && quotes.length < MAX_ROUTES) quotes.push(q); }216      return quotes;217    },218  };219}220
Files main
Menu

Theme