SPB Git

Repository Public

jupiter.test.ts 32.0 KB 387 lines typescript

1// Jupiter adapter against REAL answers recorded from https://api.jup.ag/swap/v2 (scripts/record-swap-jupiter-fixtures.mts).2// No network: the real httpClient runs over a fetch stub that replays the recorded status + body.3import { readFileSync } from 'node:fs';4import { describe, expect, it } from 'vitest';5import { NETWORKS } from '@spw/core';6import { checkQuote, DEFAULT_POLICY, httpClient, ProviderError, ProviderRuntime, QuoteAggregator, type AssetRef, type ProviderContext, type SwapQuote, type SwapQuoteRequest } from '../src/index.ts';7import { createProviders } from '../src/providers/index.ts';8import { createJupiterProvider, JUPITER_API_BASE, JUPITER_KEYLESS_POLICY, JUPITER_PROGRAM, jupiterBuildSchema, WSOL_MINT, type JupiterBuild } from '../src/providers/jupiter.ts';9import { JUPITER_DEX_PROGRAMS } from '../src/providers/jupiter-dexes.ts';1011interface Fixture { recordedAt: string; url: string; status: number; body: unknown }12const fixture = (name: string): Fixture => JSON.parse(readFileSync(new URL(`./fixtures/jupiter/${name}.json`, import.meta.url), 'utf8')) as Fixture;13const clone = <T,>(v: T): T => JSON.parse(JSON.stringify(v)) as T;1415const TOKEN_2022 = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', ATA_PROGRAM = 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL';16const OTHER_WALLET = '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM';17const token = (symbol: string) => NETWORKS.solana!.tokens.find((t) => t.symbol === symbol)!;18const ref = (symbol: string): AssetRef => ({ network: 'solana', asset: symbol === 'SOL' ? 'native' : token(symbol).id });19const SOL = ref('SOL'), USDC = ref('USDC'), PYUSD = ref('PYUSD'), USDG = ref('USDG');20const refOfMint = (mint: string): AssetRef => ({ network: 'solana', asset: mint === WSOL_MINT ? 'native' : mint });21const decimals = (a: AssetRef) => (a.asset === 'native' ? NETWORKS.solana!.native.decimals : NETWORKS.solana!.tokens.find((t) => t.id === a.asset)!.decimals);2223/** the request that produced a recorded answer, rebuilt from the recorded URL */24function requestOf(f: Fixture): SwapQuoteRequest {25  const q = new URL(f.url).searchParams, taker = q.get('taker')!;26  return { from: refOfMint(q.get('inputMint')!), to: refOfMint(q.get('outputMint')!), amountIn: BigInt(q.get('amount')!), sender: taker, recipient: taker, slippageBps: Number(q.get('slippageBps')) };27}28/** replay a status + body through the real HTTP client; records what the adapter asked for */29function replay(status: number, body: unknown, calls: { url: string; init?: RequestInit }[] = []) {30  const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => { calls.push({ url: String(url), init }); return new Response(typeof body === 'string' ? body : JSON.stringify(body), { status }); }) as typeof fetch;31  return { http: httpClient('jupiter', fetchImpl), calls };32}33const ctxFor = (f: Fixture, http: ProviderContext['http'], now = Date.parse(f.recordedAt) + 400): ProviderContext => ({ signal: new AbortController().signal, http, now: () => now });34const provider = createJupiterProvider();35async function quoteFixture(name: string, mutate?: (b: JupiterBuild) => unknown, req?: (r: SwapQuoteRequest) => SwapQuoteRequest): Promise<{ quotes: SwapQuote[]; f: Fixture; r: SwapQuoteRequest; calls: { url: string; init?: RequestInit }[] }> {36  const f = fixture(name); let body = clone(f.body);37  if (mutate) { const out = mutate(body as JupiterBuild); if (out !== undefined) body = out; }38  const r = req ? req(requestOf(f)) : requestOf(f), { http, calls } = replay(f.status, body);39  return { quotes: await provider.quote(r, ctxFor(f, http)), f, r, calls };40}41const SUCCESS = ['sol-usdc', 'usdc-sol', 'sol-pyusd', 'pyusd-sol', 'usdg-usdc', 'usdc-usdg', 'usdc-wbtc', 'usdc-bonk', 'wbtc-usdc-high-impact'] as const;4243describe('jupiter: capabilities and pair filter (local, no network)', () => {44  it('is registered, keyless, Solana only, same-chain only', () => {45    const all = createProviders({});46    expect(all.map((p) => p.id)).toContain('jupiter');47    const cap = provider.capabilities();48    expect(cap).toMatchObject({ networks: ['solana'], crossChain: false, needsKey: false, configured: true, kinds: ['swap'] });49    expect(cap.docs).toMatch(/^https:\/\/developers\.jup\.ag\//);50    expect(provider.name).toContain('Metis'); // labelling required by Jupiter's API licence51    expect(JUPITER_API_BASE).toBe('https://api.jup.ag/swap/v2');52    expect(JUPITER_KEYLESS_POLICY.ratePerSec).toBeLessThanOrEqual(0.5);53  });54  it('accepts SOL and every registry token on Solana, both ways, including the Token-2022 mints', () => {55    expect(token('PYUSD').program).toBe('token2022'); expect(token('USDG').program).toBe('token2022');56    for (const t of NETWORKS.solana!.tokens) { const a = { network: 'solana', asset: t.id }; expect(provider.supportsPair(SOL, a)).toBe(true); expect(provider.supportsPair(a, SOL)).toBe(true); }57    expect(provider.supportsPair(PYUSD, USDG)).toBe(true);58  });59  it('refuses other networks, cross-chain pairs, identical assets and mints outside the registry', () => {60    const usdcBase = { network: 'base', asset: NETWORKS.base!.tokens.find((t) => t.symbol === 'USDC')!.id };61    expect(provider.supportsPair(SOL, usdcBase)).toBe(false);62    expect(provider.supportsPair(usdcBase, { network: 'base', asset: 'native' })).toBe(false);63    expect(provider.supportsPair(SOL, SOL)).toBe(false);64    expect(provider.supportsPair(USDC, USDC)).toBe(false);65    expect(provider.supportsPair(SOL, { network: 'solana', asset: WSOL_MINT })).toBe(false); // wrapped SOL is not a wallet asset66    expect(provider.supportsPair(SOL, { network: 'solana', asset: 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB264' })).toBe(false);67    expect(provider.supportsPair({ network: 'nowhere', asset: 'native' }, SOL)).toBe(false);68  });69});7071describe('jupiter: exact mapping of recorded answers', () => {72  it.each(SUCCESS)('%s: one HTTP round, exact amounts, guaranteed minimum, steps, validity', async (name) => {73    const { quotes, f, r, calls } = await quoteFixture(name);74    const b = f.body as JupiterBuild;75    expect(calls).toHaveLength(1);76    expect(calls[0]!.url).toBe(f.url); // the adapter asks exactly what was recorded: mints, base-unit amount, taker, slippageBps77    expect(calls[0]!.init?.method).toBe('GET');78    expect(quotes).toHaveLength(1);79    const q = quotes[0]!;80    expect(q.provider).toBe('jupiter'); expect(q.request).toBe(r);81    expect(q.amountOut).toBe(BigInt(b.outAmount));82    expect(q.minAmountOut).toBe(BigInt(b.otherAmountThreshold));83    // slippage exactly as Jupiter defines it: outAmount − floor(outAmount × slippageBps / 10 000)84    expect(q.minAmountOut).toBe(q.amountOut - (q.amountOut * BigInt(r.slippageBps)) / 10_000n);85    expect(q.estimatedSeconds).toBeNull();86    expect(q.priceImpactBps).toBeCloseTo(Number(b.priceImpactPct) * 10_000, 2);87    expect(q.atomic).toBe(true); expect(q.refundable).toBe(true); expect(q.firmness).toBe('indicative');88    expect(q.executability.class).toBe('opaque');89    expect((q.executability as { reason: string }).reason).toMatch(/cannot rebuild/);90    // validity: 30 s from the moment Jupiter fetched the blockhash (never later than 30 s from now)91    const now = Date.parse(f.recordedAt) + 400;92    expect(q.expiresAt).toBe(Math.min(now, b.blockhashWithMetadata.fetchedAt.secs_since_epoch * 1000) + 30_000);93    expect(q.expiresAt).toBeGreaterThan(now); expect(q.expiresAt - now).toBeLessThanOrEqual(30_000);94    // one atomic step naming every venue, the aggregator program and every pool of the route plan95    expect(q.steps).toHaveLength(1);96    const s = q.steps[0]!;97    expect(s).toMatchObject({ kind: 'swap', provider: 'jupiter', from: r.from, to: r.to });98    expect(s.via).toEqual([...new Set(b.routePlan.map((l) => l.swapInfo.label))]);99    expect(s.contracts[0]).toEqual({ network: 'solana', address: JUPITER_PROGRAM, role: 'router' });100    for (const l of b.routePlan) expect(s.contracts).toContainEqual({ network: 'solana', address: l.swapInfo.ammKey, role: 'vault' });101    for (const c of s.contracts) expect(c.network).toBe('solana');102    // the raw payload is the validated answer, and the quote passes the aggregator's structural check103    expect(q.raw).toEqual(jupiterBuildSchema.parse(f.body));104    expect(checkQuote(q, r, now)).toBeNull();105    expect(checkQuote(q, r, q.expiresAt)).toBe('quote already expired');106  });107108  it('pins the recorded numbers (base units, registry decimals)', async () => {109    const out = async (n: string) => { const { quotes } = await quoteFixture(n); return quotes[0]!; };110    const solUsdc = await out('sol-usdc'), usdcSol = await out('usdc-sol'), wbtc = await out('usdc-wbtc'), bonk = await out('usdc-bonk'), usdg0 = await out('usdc-usdg'), pyusdSol = await out('pyusd-sol');111    expect([solUsdc.request.amountIn, solUsdc.amountOut, solUsdc.minAmountOut]).toEqual([300_000_000n, 24_898_345n, 24_773_854n]); // 0.3 SOL -> 24.898345 USDC112    expect([usdcSol.amountOut, usdcSol.minAmountOut]).toEqual([301_259_329n, 299_753_033n]); // 25 USDC -> 0.301259329 SOL113    expect([wbtc.amountOut, wbtc.minAmountOut]).toEqual([34_088n, 33_918n]); // 25 USDC -> 0.00034088 WBTC (8 decimals)114    expect([bonk.amountOut, bonk.minAmountOut]).toEqual([885_426_761_904n, 880_999_628_095n]); // 25 USDC -> 8 854 267.61904 BONK (5 decimals)115    expect([usdg0.request.slippageBps, usdg0.amountOut, usdg0.minAmountOut]).toEqual([0, 24_999_274n, 24_999_274n]); // zero slippage: minimum = expected116    expect([pyusdSol.request.slippageBps, pyusdSol.amountOut, pyusdSol.minAmountOut]).toEqual([100, 301_284_623n, 298_271_777n]); // 1 %117    // decimals: the two directions of SOL/USDC imply the same price once scaled by the registry decimals118    const human = (q: SwapQuote) => ({ i: Number(q.request.amountIn) / 10 ** decimals(q.request.from), o: Number(q.amountOut) / 10 ** decimals(q.request.to) });119    const a = human(solUsdc), b = human(usdcSol);120    expect(a.o / a.i).toBeGreaterThan(10); expect(a.o / a.i).toBeLessThan(10_000); // USD per SOL121    expect(Math.abs(a.o / a.i / (b.i / b.o) - 1)).toBeLessThan(0.005);122  });123124  it('reports price impact in basis points, including a near-total one', async () => {125    expect((await quoteFixture('wbtc-usdc-high-impact')).quotes[0]!.priceImpactBps).toBe(9949.8); // "0.99498…" = 99.498 %126    expect((await quoteFixture('sol-usdc')).quotes[0]!.priceImpactBps).toBe(3.19); // "0.000318522…"127    expect((await quoteFixture('pyusd-sol')).quotes[0]!.priceImpactBps).toBe(0); // "0"128  });129130  it('classifies fees: only the network fee, in SOL, estimated, not included in the output', async () => {131    for (const name of SUCCESS) {132      const { quotes, f } = await quoteFixture(name), b = f.body as JupiterBuild;133      expect((b as { platformFee?: unknown }).platformFee).toBeUndefined(); // /build charges no Jupiter fee and we ask for no integrator fee134      const price = new DataView(Uint8Array.from(Buffer.from(b.computeBudgetInstructions[0]!.data, 'base64')).buffer).getBigUint64(1, true);135      expect(quotes[0]!.fees).toEqual([{ kind: 'network', asset: SOL, amount: 5_000n + (price * 1_400_000n + 999_999n) / 1_000_000n, includedInOutput: false, estimated: true }]);136    }137    // sol-usdc: 3 331 micro-lamports per compute unit -> 5 000 + ceil(3 331 × 1.4) = 9 664 lamports at most138    expect((await quoteFixture('sol-usdc')).quotes[0]!.fees[0]!.amount).toBe(9_664n);139    // no priority-fee instruction: the signature fee alone140    expect((await quoteFixture('sol-usdc', (b) => { b.computeBudgetInstructions = []; })).quotes[0]!.fees[0]!.amount).toBe(5_000n);141  });142143  it('really routes the Token-2022 mints (PYUSD, USDG): token accounts are created under the Token-2022 program', async () => {144    for (const [name, sym] of [['sol-pyusd', 'PYUSD'], ['pyusd-sol', 'PYUSD'], ['usdg-usdc', 'USDG'], ['usdc-usdg', 'USDG']] as const) {145      const { quotes, f } = await quoteFixture(name), b = f.body as JupiterBuild, mint = token(sym).id;146      expect(quotes).toHaveLength(1);147      expect(b.routePlan.some((l) => l.swapInfo.inputMint === mint || l.swapInfo.outputMint === mint)).toBe(true);148      const keys = (ix: JupiterBuild['swapInstruction']) => ix.accounts.map((a) => a.pubkey);149      expect(keys(b.swapInstruction)).toContain(mint); expect(keys(b.swapInstruction)).toContain(TOKEN_2022);150      if (requestOf(f).to.asset === mint) expect(b.setupInstructions.some((ix) => ix.programId === ATA_PROGRAM && keys(ix).includes(mint) && keys(ix).includes(TOKEN_2022))).toBe(true);151    }152  });153154  it('native SOL is the wrapped-SOL mint: wrap on the way in, unwrap (close account) on the way out', async () => {155    const wrap = fixture('sol-usdc'), unwrap = fixture('usdc-sol');156    expect(new URL(wrap.url).searchParams.get('inputMint')).toBe(WSOL_MINT);157    expect((wrap.body as JupiterBuild).setupInstructions.map((i) => i.programId)).toContain('11111111111111111111111111111111'); // SOL transfer into the wSOL account158    expect((unwrap.body as JupiterBuild).cleanupInstruction).toMatchObject({ programId: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', data: 'CQ==' }); // CloseAccount159  });160161  it('the DEX table is the recorded program-id-to-label answer, and names a program that the swap instruction really uses', async () => {162    const live = fixture('program-id-to-label').body as Record<string, string>;163    const fromLive: Record<string, string[]> = {};164    for (const [pid, label] of Object.entries(live)) (fromLive[label] ??= []).push(pid);165    for (const v of Object.values(fromLive)) v.sort();166    expect(JUPITER_DEX_PROGRAMS).toEqual(fromLive);167    for (const name of SUCCESS) {168      const { quotes, f } = await quoteFixture(name), b = f.body as JupiterBuild;169      const used = new Set(b.swapInstruction.accounts.map((a) => a.pubkey));170      for (const l of b.routePlan) {171        const programs = JUPITER_DEX_PROGRAMS[l.swapInfo.label]!;172        expect(programs, l.swapInfo.label).toBeDefined();173        expect(programs.some((p) => used.has(p)), `${name}: ${l.swapInfo.label}`).toBe(true);174        for (const p of programs) expect(quotes[0]!.steps[0]!.contracts).toContainEqual({ network: 'solana', address: p, role: 'program' });175      }176    }177  });178179  it('a venue missing from the table is still quoted, without a program entry', async () => {180    const { quotes } = await quoteFixture('usdc-sol', (b) => { b.routePlan[0]!.swapInfo.label = 'Brand New DEX'; });181    expect(quotes[0]!.steps[0]!.via).toEqual(['Brand New DEX']);182    expect(quotes[0]!.steps[0]!.contracts.filter((c) => c.role === 'program')).toEqual([]);183  });184185  it('keeps only validated fields in raw (unknown keys are dropped)', async () => {186    const { quotes } = await quoteFixture('usdc-sol', (b) => { (b as Record<string, unknown>).surprise = { transaction: 'AAAA' }; });187    expect(quotes[0]!.raw).not.toHaveProperty('surprise');188  });189190  it('sends the optional API key as x-api-key, and nothing without one', async () => {191    const f = fixture('usdc-sol'), a = replay(200, f.body), b = replay(200, f.body);192    await createJupiterProvider({ JUPITER_API_KEY: ' jup_test ' }).quote(requestOf(f), ctxFor(f, a.http));193    await provider.quote(requestOf(f), ctxFor(f, b.http));194    expect(new Headers(a.calls[0]!.init?.headers).get('x-api-key')).toBe('jup_test');195    expect(new Headers(b.calls[0]!.init?.headers).has('x-api-key')).toBe(false);196  });197});198199describe('jupiter: no route is an empty array', () => {200  it.each(['no-route-dust', 'no-route-liquidity'])('%s (recorded HTTP 400)', async (name) => {201    const { quotes, f, calls } = await quoteFixture(name);202    expect(f.status).toBe(400); expect(calls).toHaveLength(1); expect(quotes).toEqual([]);203  });204  it('requests it cannot serve as asked are refused locally, without calling the API', async () => {205    const f = fixture('sol-usdc'), base = requestOf(f);206    const none = async (r: SwapQuoteRequest) => { const { http, calls } = replay(200, f.body); expect(await provider.quote(r, ctxFor(f, http))).toEqual([]); expect(calls).toHaveLength(0); };207    await none({ ...base, recipient: OTHER_WALLET }); // the output of a /build swap lands in the taker's own account208    await none({ ...base, sender: 'SENDER', recipient: 'SENDER' }); // not a Solana address209    await none({ ...base, sender: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', recipient: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' });210    await none({ ...base, amountIn: 0n }); await none({ ...base, amountIn: -5n }); await none({ ...base, amountIn: 1n << 64n });211    await none({ ...base, slippageBps: 12.5 }); await none({ ...base, slippageBps: -1 }); await none({ ...base, slippageBps: 10_001 });212    await none({ ...base, to: { network: 'base', asset: 'native' } }); await none({ ...base, to: SOL });213  });214  it('through the aggregator: a quote for a recorded answer, no_route for a recorded 400', async () => {215    const run = async (name: string) => {216      const f = fixture(name), { http } = replay(f.status, f.body), now = Date.parse(f.recordedAt) + 400;217      const rt = new ProviderRuntime(provider, { ...DEFAULT_POLICY, ...JUPITER_KEYLESS_POLICY }, http, () => now);218      return new QuoteAggregator([rt], () => now).collect(requestOf(f));219    };220    const ok = await run('sol-pyusd');221    expect(ok.quotes).toHaveLength(1); expect(ok.quotes[0]!.amountOut).toBe(24_892_199n);222    const none = await run('no-route-dust');223    expect(none.quotes).toEqual([]); expect(none.events.find((e) => e.type === 'provider')).toMatchObject({ provider: 'jupiter', status: 'no_route' });224  });225});226227describe('jupiter: provider failures throw', () => {228  const failing = async (status: number, body: unknown) => { const f = fixture('sol-usdc'); return provider.quote(requestOf(f), ctxFor(f, replay(status, body).http)); };229  it('recorded 400 that is not a no-route answer', async () => {230    const f = fixture('error-invalid-taker');231    await expect(failing(f.status, f.body)).rejects.toMatchObject({ provider: 'jupiter', code: 'http', status: 400 });232  });233  it('recorded transient upstream failure wrapped in a 400 (stale AMM oracle): a failure, not "no route"', async () => {234    const f = fixture('error-oracle-stale'), { http } = replay(f.status, f.body);235    await expect(provider.quote(requestOf(f), ctxFor(f, http))).rejects.toMatchObject({ provider: 'jupiter', code: 'http', status: 400 });236  });237  it('recorded 429 from the API gateway', async () => {238    const f = fixture('error-rate-limited');239    await expect(failing(f.status, f.body)).rejects.toMatchObject({ provider: 'jupiter', code: 'rate_limited' });240  });241  it('other 400 shapes, 5xx and non-JSON bodies', async () => {242    await expect(failing(400, { error: 'Invalid amount' })).rejects.toMatchObject({ code: 'http' });243    await expect(failing(400, { success: false, error: { issues: [], name: 'ZodError' } })).rejects.toMatchObject({ code: 'http' });244    await expect(failing(400, { error: 'No routes found, try later' })).rejects.toMatchObject({ code: 'http' }); // only the exact documented strings mean "no route"245    await expect(failing(500, { error: 'No routes found' })).rejects.toMatchObject({ code: 'http' });246    await expect(failing(502, '<html>Bad gateway</html>')).rejects.toMatchObject({ code: 'bad_response' });247    await expect(failing(200, '<html>maintenance</html>')).rejects.toMatchObject({ code: 'bad_response' });248  });249  it('timeout and abort: the signal reaches fetch and the failure is a timeout', async () => {250    const f = fixture('sol-usdc'); let seen: AbortSignal | undefined;251    const hang = (async (_u: string | URL | Request, init?: RequestInit) => { seen = init?.signal ?? undefined; return new Promise<Response>((_res, rej) => init!.signal!.addEventListener('abort', () => rej(new DOMException('aborted', 'AbortError')))); }) as typeof fetch;252    // the adapter alone, aborted by its context253    const ac = new AbortController(); setTimeout(() => ac.abort(), 20);254    await expect(provider.quote(requestOf(f), { signal: ac.signal, http: httpClient('jupiter', hang), now: Date.now })).rejects.toMatchObject({ provider: 'jupiter', code: 'timeout' });255    expect(seen).toBe(ac.signal);256    // under the runtime: the per-provider deadline fires and is counted257    const rt = new ProviderRuntime(provider, { ...DEFAULT_POLICY, timeoutMs: 30 }, httpClient('jupiter', hang));258    await expect(rt.quote(requestOf(f))).rejects.toMatchObject({ code: 'timeout' });259    expect(rt.stats().timeouts).toBe(1);260    // a caller that gives up does not count against the provider261    const rt2 = new ProviderRuntime(provider, DEFAULT_POLICY, httpClient('jupiter', hang)), outer = new AbortController(); setTimeout(() => outer.abort(), 20);262    await expect(rt2.quote(requestOf(f), outer.signal)).rejects.toMatchObject({ code: 'timeout' });263    expect(rt2.stats().failures).toBe(0);264  });265});266267describe('jupiter: malformed or hostile answers are rejected (schema), never turned into a quote', () => {268  const rejects = async (name: string, mutate: (b: JupiterBuild) => unknown, fx = 'sol-usdc') => {269    const err = await quoteFixture(fx, mutate).then(() => null, (e: unknown) => e);270    expect(err, name).toBeInstanceOf(ProviderError);271    expect(err, name).toMatchObject({ provider: 'jupiter', code: 'schema' });272  };273  const any = (b: JupiterBuild) => b as unknown as Record<string, unknown>;274  const BONK = token('BONK').id, WBTC = token('WBTC').id;275  it('wrong tokens, amounts or terms', async () => {276    await rejects('output is another registry token', (b) => { b.outputMint = BONK; });277    await rejects('output is a look-alike mint', (b) => { b.outputMint = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1w'; });278    await rejects('input is another token', (b) => { b.inputMint = WBTC; });279    await rejects('mints swapped', (b) => { [b.inputMint, b.outputMint] = [b.outputMint, b.inputMint]; });280    await rejects('another input amount', (b) => { b.inAmount = '300000001'; });281    await rejects('another slippage', (b) => { b.slippageBps = 500; });282    await rejects('exact-out answer', (b) => { any(b).swapMode = 'ExactOut'; });283    await rejects('zero output', (b) => { b.outAmount = '0'; b.otherAmountThreshold = '0'; });284    await rejects('legacy transaction version', (b) => { any(b).transactionVersion = 1; });285  });286  it('amounts that are not unsigned integer strings', async () => {287    for (const v of ['24898345.5', '-24898345', '2.4898345e7', '0x17BE9A9', ' 24898345', '024898345', '', '1e3', 'NaN', '18446744073709551616', '9'.repeat(40)]) await rejects(`outAmount ${JSON.stringify(v)}`, (b) => { b.outAmount = v; });288    await rejects('outAmount as a JSON number', (b) => { any(b).outAmount = 24898345; });289    await rejects('outAmount as a float', (b) => { any(b).outAmount = 24898345.5; });290    await rejects('threshold as a float string', (b) => { b.otherAmountThreshold = '24773854.9'; });291    await rejects('negative threshold', (b) => { b.otherAmountThreshold = '-1'; });292    await rejects('negative input', (b) => { b.inAmount = '-300000000'; });293    await rejects('leg amount as a float', (b) => { b.routePlan[0]!.swapInfo.outAmount = '1.5'; });294    await rejects('slippage as a string', (b) => { any(b).slippageBps = '50'; });295    await rejects('price impact not a number', (b) => { b.priceImpactPct = 'low'; });296  });297  it('a guaranteed minimum outside the requested slippage', async () => {298    await rejects('minimum of 1 unit', (b) => { b.otherAmountThreshold = '1'; });299    await rejects('one unit below the exact floor', (b) => { b.otherAmountThreshold = String((BigInt(b.outAmount) * 9_950n) / 10_000n - 1n); });300    await rejects('minimum above the expected output', (b) => { b.otherAmountThreshold = String(BigInt(b.outAmount) + 1n); });301    await rejects('zero minimum', (b) => { b.otherAmountThreshold = '0'; });302    // the exact floor itself is inside the band: accepted, and taken as the provider states it303    const { quotes } = await quoteFixture('sol-usdc', (b) => { b.otherAmountThreshold = String((BigInt(b.outAmount) * 9_950n) / 10_000n); });304    expect(quotes[0]!.minAmountOut).toBe(24_773_853n);305  });306  it('missing fields and wrong shapes', async () => {307    for (const k of ['inputMint', 'outputMint', 'inAmount', 'outAmount', 'otherAmountThreshold', 'swapMode', 'slippageBps', 'priceImpactPct', 'routePlan', 'computeBudgetInstructions', 'setupInstructions', 'swapInstruction', 'cleanupInstruction', 'otherInstructions', 'addressesByLookupTableAddress', 'blockhashWithMetadata', 'transactionVersion']) await rejects(`missing ${k}`, (b) => { delete any(b)[k]; });308    await rejects('empty route plan', (b) => { b.routePlan = []; });309    await rejects('leg without pool', (b) => { delete (b.routePlan[0]!.swapInfo as Record<string, unknown>).ammKey; });310    await rejects('leg without bps', (b) => { delete (b.routePlan[0] as Record<string, unknown>).bps; });311    await rejects('no blockhash time', (b) => { delete (b.blockhashWithMetadata as Record<string, unknown>).fetchedAt; });312    await rejects('short blockhash', (b) => { b.blockhashWithMetadata.blockhash = [1, 2, 3]; });313    await rejects('pool key not base58', (b) => { b.routePlan[0]!.swapInfo.ammKey = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; });314    await rejects('null body', () => null);315    await rejects('array body', () => []);316    await rejects('error object with HTTP 200', () => ({ error: 'No routes found' }));317    await rejects('V1 quote shape (no instructions)', (b) => ({ inputMint: b.inputMint, inAmount: b.inAmount, outputMint: b.outputMint, outAmount: b.outAmount, otherAmountThreshold: b.otherAmountThreshold, swapMode: 'ExactIn', slippageBps: 50, platformFee: null, priceImpactPct: '0', routePlan: b.routePlan }));318  });319  it('a route plan that does not add up', async () => {320    await rejects('legs deliver less than quoted', (b) => { b.outAmount = String(BigInt(b.outAmount) + 1_000_000n); b.otherAmountThreshold = b.outAmount; });321    await rejects('legs take another input', (b) => { const l = b.routePlan.find((x) => x.swapInfo.inputMint === WSOL_MINT)!; l.swapInfo.inAmount = String(BigInt(l.swapInfo.inAmount) - 1n); });322    await rejects('route ends in another mint', (b) => { for (const l of b.routePlan) if (l.swapInfo.outputMint === b.outputMint) l.swapInfo.outputMint = BONK; });323    await rejects('self-swap leg', (b) => { b.routePlan[0]!.swapInfo.outputMint = b.routePlan[0]!.swapInfo.inputMint; });324  });325  it('instructions for another wallet, another program, or with extras nobody asked for', async () => {326    const SYSTEM = '11111111111111111111111111111111';327    await rejects('output for another recipient: every occurrence of the taker replaced', (b) => JSON.parse(JSON.stringify(b).replaceAll(requestOf(fixture('sol-usdc')).sender, OTHER_WALLET)));328    await rejects('a second signer', (b) => { b.setupInstructions[0]!.accounts.push({ pubkey: OTHER_WALLET, isSigner: true, isWritable: true }); });329    await rejects('swap signed by another wallet', (b) => { for (const a of b.swapInstruction.accounts) if (a.isSigner) a.pubkey = OTHER_WALLET; });330    await rejects('swap not signed by the taker', (b) => { for (const a of b.swapInstruction.accounts) a.isSigner = false; });331    await rejects('swap sent to another program', (b) => { b.swapInstruction.programId = 'JUP4Fb2cqiRUcaTHdrPC8h2gNsA2ETXiPDD33WcGuJB'; });332    await rejects('unknown program in setup', (b) => { b.setupInstructions[0]!.programId = 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr'; });333    await rejects('unknown program in cleanup', (b) => { b.cleanupInstruction!.programId = OTHER_WALLET; });334    await rejects('extra instruction', (b) => { b.otherInstructions.push({ programId: SYSTEM, accounts: [{ pubkey: b.swapInstruction.accounts.find((a) => a.isSigner)!.pubkey, isSigner: true, isWritable: true }, { pubkey: OTHER_WALLET, isSigner: false, isWritable: true }], data: 'AgAAAADKmjsAAAAA' }); });335    await rejects('tip instruction', (b) => { any(b).tipInstruction = b.setupInstructions[0]; });336    await rejects('platform fee nobody asked for', (b) => { any(b).platformFee = { amount: '100000', feeBps: 40 }; });337    await rejects('compute budget instruction that is not a price', (b) => { b.computeBudgetInstructions[0]!.data = 'AkANAwA='; });338    await rejects('compute budget slot used by another program', (b) => { b.computeBudgetInstructions[0]!.programId = SYSTEM; });339    await rejects('instruction data not base64', (b) => { b.swapInstruction.data = '<script>'; });340  });341});342343// The execution dossier (docs/swap/providers/jupiter.md) states what an isolated signer could check by itself in a /build344// answer. These are those facts, pinned on every recorded answer (derivations with @solana/kit, a dev dependency).345describe('jupiter: structure of the recorded instructions (the facts the execution dossier relies on)', async () => {346  const { address, getAddressEncoder, getProgramDerivedAddress } = await import('@solana/kit');347  const TOKEN = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', SYSTEM = '11111111111111111111111111111111';348  const programOf = (mint: string) => (NETWORKS.solana!.tokens.find((t) => t.id === mint)?.program === 'token2022' ? TOKEN_2022 : TOKEN);349  const enc = getAddressEncoder();350  const ata = async (owner: string, mint: string) => (await getProgramDerivedAddress({ programAddress: address(ATA_PROGRAM), seeds: [enc.encode(address(owner)), enc.encode(address(programOf(mint))), enc.encode(address(mint))] }))[0] as string;351  const bytes = (b64: string) => Buffer.from(b64, 'base64');352353  it.each(SUCCESS)('%s', async (name) => {354    const f = fixture(name), b = f.body as JupiterBuild, q = new URL(f.url).searchParams;355    const taker = q.get('taker')!, inMint = q.get('inputMint')!, outMint = q.get('outputMint')!;356    const src = await ata(taker, inMint), dst = await ata(taker, outMint);357    // swap instruction: Anchor `route_v2` or `shared_accounts_route_v2` of the aggregator, carrying the exact input, the358    // quoted output and the slippage (the program enforces quoted_out × (1 − slippage)); no platform fee359    const data = bytes(b.swapInstruction.data), disc = data.subarray(0, 8).toString('hex');360    expect(['bb64facc31c4af14', 'd19853937cfed8e9']).toContain(disc); // sha256("global:route_v2")[..8], sha256("global:shared_accounts_route_v2")[..8]361    const shared = disc === 'd19853937cfed8e9', o = shared ? 9 : 8;362    expect(data.readBigUInt64LE(o)).toBe(BigInt(b.inAmount));363    expect(data.readBigUInt64LE(o + 8)).toBe(BigInt(b.outAmount));364    expect(data.readUInt16LE(o + 16)).toBe(b.slippageBps);365    expect(data.readUInt16LE(o + 18)).toBe(0); // platform fee bps366    expect(data.readUInt32LE(o + 22)).toBe(b.routePlan.length);367    // accounts at fixed positions: the taker (only signer), its own source and destination token accounts, both mints368    const keys = b.swapInstruction.accounts.map((a) => a.pubkey), at = shared ? { taker: 1, src: 2, dst: 5, inMint: 6, outMint: 7 } : { taker: 0, src: 1, dst: 2, inMint: 3, outMint: 4 };369    expect([keys[at.taker], keys[at.src], keys[at.dst], keys[at.inMint], keys[at.outMint]]).toEqual([taker, src, dst, inMint, outMint]);370    expect(b.swapInstruction.accounts.filter((a) => a.isSigner).map((a) => a.pubkey)).toEqual([taker]);371    // setup: only idempotent creations of the taker's OWN associated token accounts (possibly for intermediate mints),372    // and, when SOL is the input, a transfer of exactly the input amount into the taker's wrapped-SOL account + SyncNative373    for (const ix of b.setupInstructions) {374      const k = ix.accounts.map((a) => a.pubkey), d = bytes(ix.data);375      if (ix.programId === ATA_PROGRAM) { expect(d).toEqual(Buffer.from([1])); expect([k[0], k[2]]).toEqual([taker, taker]); expect([TOKEN, TOKEN_2022]).toContain(k[5]); }376      else if (ix.programId === SYSTEM) { expect(inMint).toBe(WSOL_MINT); expect(d.readUInt32LE(0)).toBe(2); expect([k[0], k[1]]).toEqual([taker, src]); expect(d.readBigUInt64LE(4)).toBe(BigInt(b.inAmount)); }377      else { expect(ix.programId).toBe(TOKEN); expect(d).toEqual(Buffer.from([17])); expect(k).toEqual([src]); }378    }379    expect(b.setupInstructions.some((ix) => ix.programId === ATA_PROGRAM && ix.accounts[1]!.pubkey === dst)).toBe(true);380    // cleanup: present exactly when SOL is on either side — CloseAccount of the taker's wrapped-SOL account, back to the taker381    expect(b.cleanupInstruction !== null).toBe(inMint === WSOL_MINT || outMint === WSOL_MINT);382    if (b.cleanupInstruction) expect(b.cleanupInstruction.accounts.map((a) => a.pubkey)).toEqual([inMint === WSOL_MINT ? src : dst, taker, taker]);383    // versioned (v0) transaction material: at least one address lookup table384    expect(Object.keys(b.addressesByLookupTableAddress ?? {}).length).toBeGreaterThan(0);385  });386});387
Files main
Menu

Theme