Repository Public
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