SPB Git

Repository Public

jupiter.ts 14.0 KB 196 lines typescript

1// Jupiter (Solana) — price discovery through the Swap API V2 "Router" path: GET /swap/v2/build (Metis on-chain routing only,2// no Jupiter swap fee). One call returns the quote AND the raw instructions; nothing here assembles, signs or sends anything.3//4// Why /build and not the other endpoints (checked against https://developers.jup.ag/docs on 2026-11-10):5//  - Swap API V1 (/swap/v1/quote, /swap, /swap-instructions) still answers but is documented as "no longer actively6//    maintained and superseded by Swap V2"; lite-api.jup.ag is being phased out in favour of keyless api.jup.ag.7//  - /swap/v2/order is the Meta-Aggregator: its transaction cannot be modified and must be landed by Jupiter's /execute,8//    it charges a Jupiter platform fee, and without a taker it returns slippageBps 0 and no guaranteed minimum.9//  - /build states `otherAmountThreshold` for exactly the slippage we ask for and returns the instructions a self-custody10//    wallet would have to inspect. Execution dossier: docs/swap/providers/jupiter.md.11import { z } from 'zod';12import { NETWORKS } from '@spw/core';13import { ProviderError, resolveAsset, sameAsset, type AssetRef, type FeeItem, type ProviderContext, type RouteStep, type SwapProvider, type SwapQuote, type SwapQuoteRequest } from '../types.ts';14import { JUPITER_DEX_PROGRAMS } from './jupiter-dexes.ts';15import type { ProviderEnv } from './index.ts';1617export const JUPITER_ID = 'jupiter';18/** Pinned API: keyless on api.jup.ag (0.5 request/s; measured 5 requests per 10 s sliding window). */19export const JUPITER_API_BASE = 'https://api.jup.ag/swap/v2';20export const JUPITER_API_VERSION = 'Swap API V2 — Router (GET /build), instruction format V2';21export const JUPITER_DOCS = 'https://developers.jup.ag/docs/swap/build';22/** Jupiter v6 swap aggregator program (named in https://developers.jup.ag/docs/swap/routing/rfq/v2/streaming). */23export const JUPITER_PROGRAM = 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4';24/** Native SOL is quoted as the wrapped-SOL mint; /build wraps and unwraps by default (wrapAndUnwrapSol=true). */25export const WSOL_MINT = 'So11111111111111111111111111111111111111112';26/** What a keyless caller may send: use this for the provider runtime (the default policy of 2 requests/s would be throttled). */27export const JUPITER_KEYLESS_POLICY = { ratePerSec: 0.5, burst: 4 } as const;28const SOLANA = 'solana';29/** Jupiter states no price validity; a quote is treated as stale after 30 s (the blockhash it carries lives ~150 slots ≈ 60 s). */30const QUOTE_TTL_MS = 30_000;31/** Programs a /build answer may use outside the swap instruction itself (compute budget, wrap/unwrap, token accounts). */32const COMPUTE_BUDGET = 'ComputeBudget111111111111111111111111111111';33const AUX_PROGRAMS = new Set([34  COMPUTE_BUDGET,35  '11111111111111111111111111111111', // System (SOL transfer into the wrapped-SOL account)36  'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', // SPL Token (SyncNative, CloseAccount)37  'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', // Token-202238  'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL', // Associated Token Account (CreateIdempotent)39]);40/** Solana protocol constants used for the network-fee ceiling: 5 000 lamports per signature, at most 1.4 M compute units. */41const LAMPORTS_PER_SIGNATURE = 5_000n, MAX_COMPUTE_UNITS = 1_400_000n;42/** `400 {"error": …}` answers that mean "no route for this pair/size" rather than a failure (observed live 2026-11-10). */43const NO_ROUTE_ERRORS = new Set(['No routes found', 'Insufficient liquidity']);44const U64_MAX = (1n << 64n) - 1n;4546const pubkey = z.string().regex(/^[1-9A-HJ-NP-Za-km-z]{32,44}$/, 'not a base58 public key');47/** u64 as a decimal string: digits only, no sign, no fraction, no exponent, no leading zero. */48const u64 = z.string().refine((s) => /^(0|[1-9]\d{0,19})$/.test(s) && BigInt(s) <= U64_MAX, 'not an unsigned 64-bit integer string');49const instruction = z.object({50  programId: pubkey,51  accounts: z.array(z.object({ pubkey, isSigner: z.boolean(), isWritable: z.boolean() })).max(256),52  data: z.string().max(4096).regex(/^[A-Za-z0-9+/]*={0,2}$/, 'not base64'),53});54const leg = z.object({55  swapInfo: z.object({ ammKey: pubkey, label: z.string().min(1).max(64), inputMint: pubkey, outputMint: pubkey, inAmount: u64, outAmount: u64 }),56  /** share of the amount entering this leg's input mint, basis points (canonical in V2; `percent` is the legacy field) */57  bps: z.number().int().min(0).max(10_000),58  percent: z.number().min(0).max(100).nullish(),59});60/** GET /swap/v2/build, 200. Unknown extra keys are dropped: `raw` only ever carries what was validated here. */61export const jupiterBuildSchema = z.object({62  inputMint: pubkey, outputMint: pubkey,63  inAmount: u64, outAmount: u64, otherAmountThreshold: u64,64  swapMode: z.literal('ExactIn'),65  slippageBps: z.number().int().min(0).max(10_000),66  /** decimal ratio, e.g. "0.001" = 0.1 % */67  priceImpactPct: z.string().max(64).regex(/^-?\d+(\.\d+)?([eE][-+]?\d+)?$/, 'not a decimal'),68  routePlan: z.array(leg).min(1).max(32),69  computeBudgetInstructions: z.array(instruction).max(4),70  setupInstructions: z.array(instruction).max(16),71  swapInstruction: instruction,72  cleanupInstruction: instruction.nullable(),73  // nothing we ask for produces these: no tip, no integrator fee, no extra instruction74  otherInstructions: z.array(instruction).max(0),75  tipInstruction: z.null().optional(),76  platformFee: z.null().optional(),77  addressesByLookupTableAddress: z.record(pubkey, z.array(pubkey).max(256)).nullable(),78  blockhashWithMetadata: z.object({79    blockhash: z.array(z.number().int().min(0).max(255)).length(32),80    lastValidBlockHeight: z.number().int().positive(),81    fetchedAt: z.object({ secs_since_epoch: z.number().int().positive(), nanos_since_epoch: z.number().int().min(0) }),82  }),83  transactionVersion: z.literal(0),84});85export type JupiterBuild = z.infer<typeof jupiterBuildSchema>;8687const mintOf = (a: AssetRef): string => (a.asset === 'native' ? WSOL_MINT : a.asset);88const isRegistered = (a: AssetRef): boolean => { try { resolveAsset(a); return true; } catch { return false; } };89const bad = (msg: string): never => { throw new ProviderError(JUPITER_ID, 'schema', msg); };9091/** Priority-fee price (micro-lamports per compute unit) from a SetComputeUnitPrice instruction: tag 0x03 + u64 LE. */92function computeUnitPrice(b: JupiterBuild): bigint {93  let price = 0n;94  for (const ix of b.computeBudgetInstructions) {95    const raw = Uint8Array.from(atob(ix.data), (c) => c.charCodeAt(0));96    if (raw.length !== 9 || raw[0] !== 3) bad('unexpected compute budget instruction');97    price = new DataView(raw.buffer).getBigUint64(1, true);98  }99  return price;100}101102/** Everything the answer must agree with before it becomes a quote. Any mismatch is a schema error: the answer is not trusted. */103function crossCheck(b: JupiterBuild, req: SwapQuoteRequest, inMint: string, outMint: string): { amountOut: bigint; minAmountOut: bigint } {104  if (b.inputMint !== inMint || b.outputMint !== outMint) bad('answer is for other mints');105  if (BigInt(b.inAmount) !== req.amountIn) bad('answer is for another input amount');106  if (b.slippageBps !== req.slippageBps) bad('answer uses another slippage');107  const amountOut = BigInt(b.outAmount), minAmountOut = BigInt(b.otherAmountThreshold);108  if (amountOut <= 0n) bad('non-positive output');109  // Jupiter's guaranteed minimum is outAmount − floor(outAmount × slippageBps / 10 000). It is taken as given, but only110  // inside the band the requested slippage allows: never above the expected output, never below the exact floor.111  const floor = (amountOut * BigInt(10_000 - req.slippageBps)) / 10_000n;112  if (minAmountOut > amountOut || minAmountOut < floor || minAmountOut <= 0n) bad('guaranteed minimum outside the requested slippage');113  // route plan: what leaves the input mint and what reaches the output mint must be the quoted amounts114  let fromIn = 0n, toOut = 0n;115  for (const { swapInfo: s } of b.routePlan) {116    if (s.inputMint === s.outputMint) bad('route leg swaps a mint for itself');117    if (s.inputMint === inMint) fromIn += BigInt(s.inAmount);118    if (s.outputMint === outMint) toOut += BigInt(s.outAmount);119  }120  if (fromIn !== req.amountIn || toOut !== amountOut) bad('route plan does not add up to the quoted amounts');121  // instructions: the swap goes to the Jupiter aggregator, everything else to the few programs wrap/unwrap and token122  // accounts need, and the only signer anywhere is the wallet the quote was asked for123  if (b.swapInstruction.programId !== JUPITER_PROGRAM) bad('swap instruction is not for the Jupiter aggregator program');124  const aux = [...b.computeBudgetInstructions, ...b.setupInstructions, ...(b.cleanupInstruction ? [b.cleanupInstruction] : [])];125  for (const ix of b.computeBudgetInstructions) if (ix.programId !== COMPUTE_BUDGET) bad('unexpected compute budget program');126  for (const ix of aux) if (!AUX_PROGRAMS.has(ix.programId)) bad(`unexpected program ${ix.programId}`);127  let takerSigns = false;128  for (const ix of [...aux, b.swapInstruction]) for (const a of ix.accounts) if (a.isSigner) { if (a.pubkey !== req.sender) bad('answer is for another wallet'); if (ix === b.swapInstruction) takerSigns = true; }129  if (!takerSigns) bad('swap instruction is not signed by the requesting wallet');130  return { amountOut, minAmountOut };131}132133export function createJupiterProvider(env: ProviderEnv = {}): SwapProvider {134  const key = env.JUPITER_API_KEY?.trim() || undefined; // optional: only raises the rate limit135  let seq = 0;136  const supportsPair = (from: AssetRef, to: AssetRef): boolean => from.network === SOLANA && to.network === SOLANA && isRegistered(from) && isRegistered(to) && !sameAsset(from, to);137  return {138    policy: JUPITER_KEYLESS_POLICY,139    id: JUPITER_ID,140    // the licence requires any non-Ultra use of the API to be labelled "Metis" to end users (SDK & API License Agreement §2.3)141    name: 'Jupiter (Metis)',142    capabilities: () => ({ networks: SOLANA in NETWORKS ? [SOLANA] : [], crossChain: false, needsKey: false, configured: true, kinds: ['swap'], docs: JUPITER_DOCS }),143    supportsPair,144    async quote(req: SwapQuoteRequest, ctx: ProviderContext): Promise<SwapQuote[]> {145      if (!supportsPair(req.from, req.to)) return [];146      // What this adapter cannot serve as asked is "no route", decided locally: the output of a /build swap lands in the147      // taker's own token account (no third-party recipient), amounts are positive u64, slippage is whole basis points.148      if (req.recipient !== req.sender || !pubkey.safeParse(req.sender).success) return [];149      if (req.amountIn <= 0n || req.amountIn > U64_MAX || !Number.isInteger(req.slippageBps) || req.slippageBps < 0 || req.slippageBps > 10_000) return [];150      const inMint = mintOf(req.from), outMint = mintOf(req.to);151      const url = `${JUPITER_API_BASE}/build?${new URLSearchParams({ inputMint: inMint, outputMint: outMint, amount: req.amountIn.toString(), taker: req.sender, slippageBps: String(req.slippageBps) })}`;152      let body: unknown;153      try { body = await ctx.http.getJson(url, { signal: ctx.signal, ...(key ? { headers: { 'x-api-key': key } } : {}) }); }154      catch (e) {155        const err = e as ProviderError & { status?: number; body?: unknown };156        const msg = (err.body as { error?: unknown } | null | undefined)?.error;157        if (err instanceof ProviderError && err.code === 'http' && err.status === 400 && typeof msg === 'string' && NO_ROUTE_ERRORS.has(msg)) return [];158        throw e;159      }160      const parsed = jupiterBuildSchema.safeParse(body);161      if (!parsed.success) return bad(`unexpected /build answer: ${parsed.error.issues.slice(0, 3).map((i) => `${i.path.join('.')}: ${i.message}`).join('; ')}`);162      const b = parsed.data;163      const { amountOut, minAmountOut } = crossCheck(b, req, inMint, outMint);164165      const labels = [...new Set(b.routePlan.map((l) => l.swapInfo.label))];166      const contracts: RouteStep['contracts'] = [{ network: SOLANA, address: JUPITER_PROGRAM, role: 'router' }];167      for (const p of new Set(labels.flatMap((l) => JUPITER_DEX_PROGRAMS[l] ?? []))) contracts.push({ network: SOLANA, address: p, role: 'program' });168      for (const k of new Set(b.routePlan.map((l) => l.swapInfo.ammKey))) contracts.push({ network: SOLANA, address: k, role: 'vault' });169      // Network fee as a ceiling from the answer and protocol constants: one signature plus the returned compute-unit price170      // at the maximum compute budget (the real limit is only known after simulation). Rent for a token account the swap171      // may have to create (≈0.002 SOL, refundable) is not reported by Jupiter and not included.172      const networkFee = LAMPORTS_PER_SIGNATURE + (computeUnitPrice(b) * MAX_COMPUTE_UNITS + 999_999n) / 1_000_000n;173      const fees: FeeItem[] = [{ kind: 'network', asset: { network: SOLANA, asset: 'native' }, amount: networkFee, includedInOutput: false, estimated: true }];174      const impact = Number(b.priceImpactPct) * 10_000;175      const now = ctx.now();176      return [{177        id: `${JUPITER_ID}-${now.toString(36)}-${(seq++).toString(36)}`,178        provider: JUPITER_ID,179        request: req,180        // One step: the whole route (splits, intermediate mints, wrap/unwrap) is a single atomic Jupiter instruction, and its181        // intermediate mints are not wallet assets. The venues are named in `via`; the detail stays in `raw.routePlan`.182        steps: [{ kind: 'swap', provider: JUPITER_ID, from: req.from, to: req.to, via: labels, contracts }],183        amountOut, minAmountOut, fees,184        estimatedSeconds: null,185        priceImpactBps: Number.isFinite(impact) ? Math.round(impact * 100) / 100 : null,186        expiresAt: Math.min(now, b.blockhashWithMetadata.fetchedAt.secs_since_epoch * 1000) + QUOTE_TTL_MS,187        firmness: 'indicative',188        executability: { class: 'opaque', reason: 'Jupiter returns provider-built instructions (route data for many AMM programs, dozens of accounts, address lookup tables) that the isolated signer cannot rebuild; it could only constrain them structurally and by simulation' },189        refundable: true, // a single transaction: it either settles in full or reverts, funds never leave the wallet otherwise190        atomic: true,191        raw: b,192      }];193    },194  };195}196
Files main
Menu

Theme