SPB Git

Repository Public

swap-exec-sol.ts 30.2 KB 320 lines typescript

1// Swap execution on Solana through Jupiter (Swap API V2 "Router", GET /build — to be labelled "Metis" towards the owner).2// The API asks for the instructions of a v1 transaction (no address lookup tables), arranges them in the only shape the3// signer accepts (packages/core/src/swap-sol.ts), verifies them exactly as the signer will, and simulates the exact bytes.4// Nothing here can make the signer accept a transaction it has not decoded and checked itself.5import { createHash, randomUUID } from 'node:crypto';6import type { FastifyInstance } from 'fastify';7import { generateAuthenticationOptions } from '@simplewebauthn/server';8import { z } from 'zod';9import {10  arrangeJupiterInstructions, associatedTokenAddress, ATA_PROGRAM, BPF_UPGRADEABLE_LOADER, checkPolicy, compileSolV1Message, decodeSolV1Message, formatUnits, getAsset, getNetwork, IntentSchema, intentHash,11  JUPITER_PIN, JUPITER_PROGRAM, parseUnits, RENT_TOKEN_ACCOUNT, solAddress, SOL_SIGNATURE_FEE, solMessageHash, solV1Wire, SYSTEM_PROGRAM, toB64u, toHex, TOKEN_PROGRAM, tokenProgramOf,12  verifyJupiterSwap, WSOL_MINT, type AssetDef, type Intent, type JupiterSwapFacts, type NetworkDef, type SolIxInput,13} from '@spw/core';14import { listCreds } from './auth.ts';15import { adapterFor } from './chains/index.ts';16import { http, rpcAny, rpcQuorum } from './chains/rpc.ts';17import { config, UserError } from './config.ts';18import { audit, sql } from './db.ts';19import { rpcOverridden } from './rpc-override.ts';20import { addressFor, signer } from './signer-client.ts';21import { broadcastNow, move, view, type ProposalRow } from './transfers.ts';2223/** The label Jupiter's licence requires for any use of the API other than Ultra (SDK & API License Agreement §2.3). */24export const JUPITER_LABEL = 'Jupiter (Metis)';25const JUP_API = () => process.env.SPW_JUPITER_API ?? 'https://api.jup.ag/swap/v2';26const RENT_SYSTEM = 890_880n, MAX_CU = 400_000, MAX_PRICE = 2_000_000n, LOADED_DATA_LIMIT = 64 * 1024 * 1024;27const pubkey = z.string().regex(/^[1-9A-HJ-NP-Za-km-z]{32,44}$/);28const u64 = z.string().regex(/^(0|[1-9]\d{0,19})$/);29const Ix = z.object({ programId: pubkey, accounts: z.array(z.object({ pubkey, isSigner: z.boolean(), isWritable: z.boolean() })).max(255), data: z.string().max(4096).regex(/^[A-Za-z0-9+/]*={0,2}$/) });30/** GET /build with transactionVersion=1: no compute-budget instructions, no lookup tables, the price as a field. */31const BuildV1 = z.object({32  inputMint: pubkey, outputMint: pubkey, inAmount: u64, outAmount: u64, swapMode: z.literal('ExactIn'), slippageBps: z.number().int().min(0).max(10_000),33  routePlan: z.array(z.object({ swapInfo: z.object({ label: z.string().min(1).max(64) }) })).min(1).max(32),34  computeBudgetInstructions: z.array(Ix).max(0), setupInstructions: z.array(Ix).max(16), swapInstruction: Ix, cleanupInstruction: Ix.nullable(),35  otherInstructions: z.array(Ix).max(0), tipInstruction: z.null().optional(), platformFee: z.null().optional(),36  addressesByLookupTableAddress: z.record(z.string(), z.unknown()).nullable().refine((r) => !r || Object.keys(r).length === 0, 'lookup tables in a v1 answer'),37  transactionVersion: z.literal(1), computeUnitPrice: u64,38});39type Build = z.infer<typeof BuildV1>;40const toIx = (x: z.infer<typeof Ix>): SolIxInput => ({ program: x.programId, accounts: x.accounts.map((a) => ({ pubkey: a.pubkey, signer: a.isSigner, writable: a.isWritable })), data: new Uint8Array(Buffer.from(x.data, 'base64')) });41const mintOf = (a: AssetDef) => (a.id === 'native' ? WSOL_MINT : a.id);42const programOf = (a: AssetDef) => (a.id === 'native' ? TOKEN_PROGRAM : tokenProgramOf(a));4344// Keyless limit: 0.5 request/s shared with the quote path (measured: 5 requests per 10 s). One call every 2.1 s from here.45let nextCall = 0;46export async function jupiterBuild(q: Record<string, string>): Promise<Build | { noRoute: true }> {47  const wait = nextCall - Date.now(); nextCall = Math.max(Date.now(), nextCall) + (rpcOverridden ? 0 : 2100); // no pacing against a test double48  if (wait > 0) await new Promise((r) => setTimeout(r, wait));49  const key = process.env.JUPITER_API_KEY?.trim();50  let r: Response;51  try { r = await http(`${JUP_API()}/build?${new URLSearchParams(q)}`, key ? { headers: { 'x-api-key': key } } : {}); }52  catch { throw new UserError('provider_unavailable', 'the swap provider did not answer; nothing was signed', 503); }53  const j = await r.json().catch(() => null) as { error?: unknown } | null;54  if (r.status === 429) throw new UserError('provider_busy', 'the swap provider is rate-limiting requests; try again in a few seconds', 503);55  if (r.status === 400 && typeof j?.error === 'string' && ['No routes found', 'Insufficient liquidity'].includes(j.error)) return { noRoute: true };56  const p = BuildV1.safeParse(j);57  if (!r.ok || !p.success) throw new UserError('provider_bad_answer', `the swap provider returned an unexpected answer${typeof j?.error === 'string' ? `: ${j.error.slice(0, 80)}` : ''}`, 502);58  return p.data;59}6061type Acct = { owner: string; executable: boolean; lamports: number; data: [string, string] | { parsed?: { type?: string; info?: Record<string, any> } } } | null;62let codeCheckedAt = 0;63/**64 * The pinned aggregator program must still be the reviewed deployment on every source: an upgrade rewrites the ProgramData65 * header (slot of last deployment), and the executable itself is re-hashed every six hours. The wallet address must be a66 * plain system account, and a Token-2022 mint must carry neither a transfer fee nor a transfer hook.67 */68export async function jupiterGuards(n: NetworkDef, me: string, assets: AssetDef[]): Promise<void> {69  if ((await rpcQuorum<string>(n.rpc, 'getGenesisHash', [])) !== n.chainRef) throw new UserError('rpc_degraded', 'cluster identity mismatch; swap refused', 503);70  const changed = () => new UserError('router_changed', 'the Jupiter program is not the reviewed deployment (it was upgraded or replaced); swaps are refused', 503);71  const prog = (await rpcQuorum<{ value: Acct }>(n.rpc, 'getAccountInfo', [JUPITER_PROGRAM, { encoding: 'base64', commitment: 'finalized' }], (a, b) => JSON.stringify(a.value) === JSON.stringify(b.value))).value;72  const pd = prog && Array.isArray(prog.data) ? Buffer.from(prog.data[0], 'base64') : null;73  if (!prog || !pd || prog.owner !== BPF_UPGRADEABLE_LOADER || !prog.executable || pd.length !== 36 || pd.readUInt32LE(0) !== 2 || JUPITER_PIN.programData !== solAddress(pd.subarray(4, 36))) throw changed();74  const head = (await rpcQuorum<{ value: Acct }>(n.rpc, 'getAccountInfo', [JUPITER_PIN.programData, { encoding: 'base64', commitment: 'finalized', dataSlice: { offset: 0, length: 45 } }], (a, b) => JSON.stringify(a.value?.data) === JSON.stringify(b.value?.data) && a.value?.owner === b.value?.owner)).value;75  const h = head && Array.isArray(head.data) ? Buffer.from(head.data[0], 'base64') : null;76  if (!head || !h || head.owner !== BPF_UPGRADEABLE_LOADER || h.length !== 45 || h.readUInt32LE(0) !== 3 || Number(h.readBigUInt64LE(4)) !== JUPITER_PIN.lastDeploySlot || h[12] !== 1 || solAddress(h.subarray(13, 45)) !== JUPITER_PIN.upgradeAuthority) throw changed();77  if (!rpcOverridden && Date.now() - codeCheckedAt > 6 * 3600_000) { // 2.9 MB per source: not on every swap78    for (const u of n.rpc) {79      const r = await fetch(u, { method: 'POST', headers: { 'content-type': 'application/json' }, signal: AbortSignal.timeout(40_000), body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'getAccountInfo', params: [JUPITER_PIN.programData, { encoding: 'base64', commitment: 'finalized' }] }) })80        .then((x) => x.json() as Promise<{ result?: { value: Acct } }>).catch(() => null);81      const d = r?.result?.value && Array.isArray(r.result.value.data) ? Buffer.from(r.result.value.data[0], 'base64') : null;82      if (!d) throw new UserError('rpc_degraded', 'could not read the Jupiter program to check it; swap refused', 503);83      if (createHash('sha256').update(d.subarray(45)).digest('hex') !== JUPITER_PIN.codeSha256) throw changed();84    }85    codeCheckedAt = Date.now();86  }87  const w = (await rpcQuorum<{ value: Acct }>(n.rpc, 'getAccountInfo', [me, { encoding: 'base64', commitment: 'confirmed', dataSlice: { offset: 0, length: 0 } }], (a, b) => a.value?.owner === b.value?.owner && a.value?.executable === b.value?.executable)).value;88  // an address that has never received anything has no account at all: that is an empty wallet, not a suspicious one89  if (!w) throw new UserError('insufficient_funds', 'this wallet holds no SOL yet: receive some SOL first (it also pays the network fee)', 422);90  if (w.owner !== SYSTEM_PROGRAM || w.executable) throw new UserError('account_not_plain', 'this wallet address is not a plain system account; swaps are refused', 503);91  for (const a of assets) {92    if (a.program !== 'token2022') continue;93    const m = (await rpcQuorum<{ value: Acct }>(n.rpc, 'getAccountInfo', [a.id, { encoding: 'jsonParsed', commitment: 'finalized' }], (x, y) => JSON.stringify(x.value?.data) === JSON.stringify(y.value?.data))).value;94    const info = m && !Array.isArray(m.data) ? m.data.parsed?.info : undefined;95    if (!info) throw new UserError('rpc_degraded', `could not read the ${a.symbol} mint; swap refused`, 503);96    for (const e of (info.extensions ?? []) as { extension: string; state?: Record<string, any> }[]) {97      if (e.extension === 'transferFeeConfig' && (Number(e.state?.newerTransferFee?.transferFeeBasisPoints ?? 1) !== 0 || Number(e.state?.olderTransferFee?.transferFeeBasisPoints ?? 1) !== 0))98        throw new UserError('token_has_fee', `${a.symbol} currently charges a transfer fee; swaps of it are refused`, 422);99      if (e.extension === 'transferHook' && e.state?.programId) throw new UserError('token_has_hook', `${a.symbol} runs a transfer hook program; swaps of it are refused`, 422);100    }101  }102}103104export interface SolMeta { err: unknown; fee?: number; preBalances?: number[]; postBalances?: number[]; preTokenBalances?: TokBal[]; postTokenBalances?: TokBal[]; unitsConsumed?: number; logs?: string[] | null }105type TokBal = { accountIndex: number; mint: string; owner?: string; uiTokenAmount: { amount: string } };106export interface SwapEffects { spent: bigint; received: bigint; lamportsDelta: bigint; fee: bigint; rentKept: bigint; otherLosses: string[] }107/**108 * What a transaction (simulated or confirmed) did to the wallet, from its balance tables. `keys` is the message's own109 * account list (v1: nothing is loaded from tables). Throws when the tables are missing.110 */111export function swapEffects(f: Pick<JupiterSwapFacts, 'message' | 'sourceAccount' | 'destinationAccount' | 'inMint' | 'outMint' | 'creates'>, meta: SolMeta): SwapEffects {112  const keys = f.message.keys, me = keys[0]!;113  if (!meta.preBalances || !meta.postBalances || !meta.preTokenBalances || !meta.postTokenBalances || meta.preBalances.length !== keys.length || meta.fee === undefined) throw new Error('balance tables missing');114  const lam = (k: string, t: number[]) => BigInt(t[keys.indexOf(k)] ?? 0);115  const tok = (k: string, t: TokBal[]) => BigInt(t.find((b) => b.accountIndex === keys.indexOf(k))?.uiTokenAmount.amount ?? '0');116  const fee = BigInt(meta.fee), lamportsDelta = lam(me, meta.postBalances) - lam(me, meta.preBalances);117  const rentKept = f.creates.reduce((s, c) => s + (lam(c, meta.preBalances!) === 0n ? lam(c, meta.postBalances!) : 0n), 0n);118  const solIn = f.inMint === WSOL_MINT, solOut = f.outMint === WSOL_MINT;119  // the wrapped-SOL account is closed at the end: whatever it held before comes back to the wallet as well120  const wsolBefore = solIn ? lam(f.sourceAccount, meta.preBalances) : solOut ? lam(f.destinationAccount, meta.preBalances) : 0n;121  const net = lamportsDelta + fee + rentKept - wsolBefore; // lamports moved by the swap itself122  const spent = solIn ? -net : tok(f.sourceAccount, meta.preTokenBalances) - tok(f.sourceAccount, meta.postTokenBalances);123  const received = solOut ? net : tok(f.destinationAccount, meta.postTokenBalances) - tok(f.destinationAccount, meta.preTokenBalances);124  const otherLosses: string[] = [];125  if (!solIn && !solOut && net !== 0n) otherLosses.push(`lamports ${net}`);126  for (const b of meta.preTokenBalances) {127    const k = keys[b.accountIndex];128    if (b.owner !== me || !k || k === f.sourceAccount || k === f.destinationAccount) continue;129    if (tok(k, meta.postTokenBalances) < BigInt(b.uiTokenAmount.amount)) otherLosses.push(k);130  }131  return { spent, received, lamportsDelta, fee, rentKept, otherLosses };132}133134async function simulate(n: NetworkDef, wire: string, o: { sigVerify: boolean; replaceRecentBlockhash: boolean }): Promise<SolMeta> {135  return (await rpcAny<{ value: SolMeta }>(n.rpc, 'simulateTransaction', [wire, { encoding: 'base64', commitment: 'confirmed', sigVerify: o.sigVerify, replaceRecentBlockhash: o.replaceRecentBlockhash }])).value;136}137const simError = (s: SolMeta) => `${JSON.stringify(s.err).slice(0, 90)} ${(s.logs ?? []).filter((l) => /error|failed|insufficient|exceeded/i.test(l)).slice(-2).join(' | ').slice(0, 180)}`.trim();138139export interface PreparedSolSwap {140  intent: Intent; message: string; expected: bigint; minOut: bigint; simulatedOut: bigint; facts: JupiterSwapFacts; effects: SwapEffects;141  labels: string[]; converted: boolean; attempts: number; unitsConsumed: number; networkFee: bigint; rent: bigint; maxFee: bigint; build: unknown;142}143/**144 * Prepare a firm Jupiter swap for an address: guards, balances, a fresh route (up to three, leaving out the venues of a145 * route that could not be put in the verifiable form), the signer's own verification, and a simulation of the exact bytes146 * that proves the swap succeeds now, delivers at least the minimum and touches nothing else of the wallet. Signs nothing.147 */148export async function prepareJupiterSwap(o: { n: NetworkDef; me: string; keyId: string; fromAsset: string; toAsset: string; amount: bigint; slippageBps: number; policyVersion: number; proposalId: string; feeCeiling: bigint; ttlSec?: number }): Promise<PreparedSolSwap> {149  const { n, me, amount } = o;150  let src: AssetDef, dst: AssetDef;151  try { src = getAsset(n.id, o.fromAsset); dst = getAsset(n.id, o.toAsset); } catch { throw new UserError('unsupported_asset', 'asset is not in the wallet registry'); }152  if (src.id === dst.id) throw new UserError('same_asset', 'choose two different assets');153  if (amount <= 0n || amount >= 1n << 64n) throw new UserError('bad_amount', 'amount must be greater than zero');154  await jupiterGuards(n, me, [src, dst]);155  const a = adapterFor(n.id);156  const bal = await a.balances(me);157  if (a.disabledAssets?.().some((d) => d === src.id || d === dst.id)) throw new UserError('unsupported_asset', 'this token failed its on-chain identity check and is disabled');158  const srcAta = associatedTokenAddress(me, mintOf(src), programOf(src)), dstAta = associatedTokenAddress(me, mintOf(dst), programOf(dst));159  type Parsed = { value: ({ lamports: number; data: { parsed?: { info?: { tokenAmount?: { amount: string } } } } } | null)[] };160  const ataInfo = await rpcAny<Parsed>(n.rpc, 'getMultipleAccounts', [[srcAta, dstAta], { encoding: 'jsonParsed', commitment: 'confirmed' }]);161  if (src.id === 'native') { if ((bal.native ?? 0n) < amount) throw new UserError('insufficient_funds', 'insufficient SOL balance'); }162  else if (BigInt(ataInfo.value[0]?.data.parsed?.info?.tokenAmount?.amount ?? '0') < amount) throw new UserError('insufficient_funds', `insufficient ${src.symbol} in the wallet's associated token account`);163164  let exclude: string[] = [], lastWhy = 'no route for this pair and amount right now', attempts = 0;165  for (; attempts < 3;) {166    attempts++;167    const b = await jupiterBuild({ inputMint: mintOf(src), outputMint: mintOf(dst), amount: amount.toString(), taker: me, slippageBps: String(o.slippageBps), transactionVersion: '1',168      ...(exclude.length ? { excludeDexes: exclude.join(',') } : {}), ...(attempts === 3 ? { maxAccounts: '32' } : {}) });169    if ('noRoute' in b) break;170    const labels = [...new Set(b.routePlan.map((l) => l.swapInfo.label))];171    exclude = [...new Set([...exclude, ...labels])];172    if (b.inputMint !== mintOf(src) || b.outputMint !== mintOf(dst) || BigInt(b.inAmount) !== amount || b.slippageBps !== o.slippageBps) throw new UserError('provider_bad_answer', 'the provider answered for another swap', 502);173    try {174      const expected = BigInt(b.outAmount);175      // program token accounts the shared-accounts form relies on: take the first authority (0..7) that has them all176      let arranged: ReturnType<typeof arrangeJupiterInstructions> | null = null;177      for (let id = 0; id < 8 && !arranged; id++) {178        const c = arrangeJupiterInstructions({ setup: b.setupInstructions.map(toIx), swap: toIx(b.swapInstruction), cleanup: b.cleanupInstruction ? toIx(b.cleanupInstruction) : null, authorityId: id });179        if (!c.converted) { arranged = c; break; }180        const have = await rpcAny<{ value: (object | null)[] }>(n.rpc, 'getMultipleAccounts', [c.programAccounts, { encoding: 'base64', commitment: 'confirmed', dataSlice: { offset: 0, length: 0 } }]);181        if (have.value.every((x) => x !== null)) arranged = c;182      }183      if (!arranged) throw new Error('the aggregator has no shared account for one of the route\'s tokens');184      const bh = await rpcAny<{ value: { blockhash: string; lastValidBlockHeight: number } }>(n.rpc, 'getLatestBlockhash', [{ commitment: 'confirmed' }]);185      const compile = (limit: number, price: bigint) => compileSolV1Message({ payer: me, blockhash: bh.value.blockhash, instructions: arranged!.instructions, computeUnitLimit: limit, priorityFeeLamports: (BigInt(limit) * price + 999_999n) / 1_000_000n, loadedAccountsDataSizeLimit: LOADED_DATA_LIMIT });186      const now = Math.floor(Date.now() / 1000);187      const draft = (limit: number, price: bigint, minOut: bigint, maxFee: bigint, createAta: boolean, msg: Uint8Array): Intent => IntentSchema.parse({188        v: 1, proposalId: o.proposalId, keyId: o.keyId, network: n.id, chainRef: n.chainRef, asset: src.id, symbol: src.symbol, decimals: src.decimals, amount: amount.toString(),189        from: me, to: me, maxFee: maxFee.toString(), createdAt: now, expiresAt: now + (o.ttlSec ?? 50), policyVersion: o.policyVersion,190        swap: { provider: 'jupiter', toAsset: dst.id, minOut: minOut.toString(), router: JUPITER_PROGRAM, dataHash: solMessageHash(msg), approveGas: '0' },191        sol: { recentBlockhash: bh.value.blockhash, lastValidBlockHeight: bh.value.lastValidBlockHeight, createAta, computeUnitLimit: limit, computeUnitPrice: price.toString() },192      });193      // first pass at the largest limit an intent can carry: structure check, then units actually used194      const probeMsg = compile(MAX_CU, 0n);195      const probe = verifyJupiterSwap(draft(MAX_CU, 0n, 1n, 10n ** 12n, decodeKept(probeMsg, me, src, dst), probeMsg), probeMsg);196      const s1 = await simulate(n, Buffer.from(solV1Wire(new Uint8Array(64), probeMsg)).toString('base64'), { sigVerify: false, replaceRecentBlockhash: false });197      if (s1.err) throw new Error(`the route fails in simulation (${simError(s1)})`);198      const units = s1.unitsConsumed ?? MAX_CU;199      const limit = Math.ceil(units * 1.25) + 15_000;200      if (limit > MAX_CU) throw new Error('the route needs more compute than a swap may use here');201      // priority fee: Jupiter's suggestion, capped, and lowered if the policy fee ceiling would otherwise be exceeded202      const room = o.feeCeiling - SOL_SIGNATURE_FEE - probe.rentMax;203      if (room < 0n) throw new UserError('fee_ceiling', 'creating the token account for the received asset costs more than the wallet\'s fee ceiling allows', 422);204      let price = BigInt(b.computeUnitPrice); if (price > MAX_PRICE) price = MAX_PRICE;205      if ((BigInt(limit) * price + 999_999n) / 1_000_000n > room) price = (room * 1_000_000n) / BigInt(limit);206      const msg = compile(limit, price);207      const networkFee = SOL_SIGNATURE_FEE + (BigInt(limit) * price + 999_999n) / 1_000_000n, maxFee = networkFee + probe.rentMax;208      const need = maxFee + (src.id === 'native' ? amount : 0n) + (probe.wrapsSol || probe.unwrapsSol ? (ataInfo.value[src.id === 'native' ? 0 : 1] ? 0n : RENT_TOKEN_ACCOUNT) : 0n);209      if ((bal.native ?? 0n) < need + RENT_SYSTEM) throw new UserError('insufficient_gas', `not enough SOL to pay the network fee${probe.creates.length ? ' and the rent of the new token account' : ''}${src.id === 'native' ? ' on top of the amount swapped' : ''} (0.00089088 SOL must remain in the wallet)`);210      const minOut = verifyJupiterSwap(draft(limit, price, 1n, maxFee, probe.creates.length > 0, msg), msg).minOut;211      const floor = (expected * BigInt(10_000 - o.slippageBps)) / 10_000n;212      if (minOut * 1000n < floor * 999n) throw new UserError('provider_bad_answer', 'the instruction guarantees less than the requested tolerance allows', 502);213      const intent = draft(limit, price, minOut, maxFee, probe.creates.length > 0, msg);214      const facts = verifyJupiterSwap(intent, msg); // exactly what the signer will run215      // the exact bytes, against the current state: must succeed, pay at least the minimum and cost nothing else216      const s2 = await simulate(n, Buffer.from(solV1Wire(new Uint8Array(64), msg)).toString('base64'), { sigVerify: false, replaceRecentBlockhash: false });217      if (s2.err) throw new Error(`the swap would fail right now (${simError(s2)})`);218      const fx = swapEffects(facts, s2);219      if (fx.spent !== amount) throw new Error(`simulation spends ${fx.spent} instead of ${amount}`);220      if (fx.received < minOut) throw new Error('simulated output is below the guaranteed minimum');221      if (fx.otherLosses.length) throw new Error('simulation shows another account of the wallet losing value');222      if (fx.fee !== networkFee || fx.rentKept > facts.rentMax) throw new Error('simulated cost differs from the computed cost');223      return { intent, message: Buffer.from(msg).toString('base64'), expected, minOut, simulatedOut: fx.received, facts, effects: fx, labels, converted: arranged.converted, attempts, unitsConsumed: units, networkFee, rent: fx.rentKept, maxFee, build: b };224    } catch (e) {225      if (e instanceof UserError) throw e;226      lastWhy = (e as Error).message; // this route cannot be put in the verifiable form: ask again without its venues227    }228  }229  throw new UserError(attempts === 0 || lastWhy.startsWith('no route') ? 'no_route' : 'route_not_verifiable', attempts > 0 && !lastWhy.startsWith('no route')230    ? `Jupiter offered no route that the signer can verify for this pair right now (${lastWhy.slice(0, 160)}); nothing was signed` : lastWhy, 422);231}232/** does the arranged message create (and keep) a token account for the received or sold asset? (mirrors the signer's rule) */233function decodeKept(msg: Uint8Array, me: string, src: AssetDef, dst: AssetDef): boolean {234  const wsol = src.id === 'native' ? associatedTokenAddress(me, WSOL_MINT, TOKEN_PROGRAM) : dst.id === 'native' ? associatedTokenAddress(me, WSOL_MINT, TOKEN_PROGRAM) : null;235  return decodeSolV1Message(msg).instructions.some((x) => x.program === ATA_PROGRAM && x.accounts[1] !== wsol);236}237238const U = z.string().min(1).max(60);239/** POST /api/v1/swap/proposals for a Solana network: prepare, then offer the swap for passkey approval. */240export async function createSolSwapProposal(b: { network: string; fromAsset: string; toAsset: string; amount: string; slippageBps: number }, idem: string) {241  const n = getNetwork(b.network);242  let src, dst;243  try { src = getAsset(n.id, b.fromAsset); dst = getAsset(n.id, b.toAsset); } catch { throw new UserError('unsupported_asset', 'asset is not in the wallet registry'); }244  if (src.id === dst.id) throw new UserError('same_asset', 'choose two different assets');245  let amount: bigint;246  try { amount = parseUnits(U.parse(b.amount), src.decimals); } catch (e) { throw new UserError('bad_amount', (e as Error).message); }247  if (amount <= 0n) throw new UserError('bad_amount', 'amount must be greater than zero');248  const st = await signer.status(true);249  if (st.halted) throw new UserError('halted', 'emergency halt is active', 423);250  const me = addressFor(st, n.id);251  const [busy] = await sql<{ id: string }[]>`select id from proposals where network = ${n.id} and state in ('approved', 'signed', 'broadcast') limit 1`;252  if (busy) throw new UserError('pending_tx', 'a previous transfer or swap on this network is not confirmed yet; wait for it before starting another', 409);253  const ceiling = BigInt(st.policy.maxFee[n.id] ?? '0');254  const id = randomUUID();255  const ps = await prepareJupiterSwap({ n, me, keyId: st.keyId, fromAsset: src.id, toAsset: dst.id, amount, slippageBps: b.slippageBps, policyVersion: st.policy.version, proposalId: id, feeCeiling: ceiling });256  const { intent } = ps, now = intent.createdAt;257  const verdict = checkPolicy(st.policy, intent, BigInt(st.spentToday[`${n.id}:${src.symbol}`] ?? '0'), now, st.halted);258  if (!verdict.ok) throw new UserError('policy', verdict.reason, 422);259  const hash = intentHash(intent);260  const swap = { provider: 'jupiter', toAsset: dst.id, toSymbol: dst.symbol, toDecimals: dst.decimals, expectedOut: ps.expected.toString(), minOut: ps.minOut.toString(), slippageBps: b.slippageBps, needsApproval: false, simulated: true,261    detail: { label: JUPITER_LABEL, venues: ps.labels, instruction: ps.facts.kind, rewritten: ps.converted, simulatedOut: ps.simulatedOut.toString(), networkFee: ps.networkFee.toString(), priorityFee: (ps.networkFee - SOL_SIGNATURE_FEE).toString(),262      rent: ps.rent.toString(), createsAccount: ps.rent > 0n,263      simulatedOutDisplay: formatUnits(ps.simulatedOut, dst.decimals), networkFeeDisplay: formatUnits(ps.networkFee, 9), priorityFeeDisplay: formatUnits(ps.networkFee - SOL_SIGNATURE_FEE, 9), rentDisplay: formatUnits(ps.rent, 9), computeUnits: intent.sol!.computeUnitLimit, lastValidBlockHeight: intent.sol!.lastValidBlockHeight } };264  const [p] = await sql<ProposalRow[]>`insert into proposals (id, idempotency_key, state, kind, network, asset, symbol, decimals, amount, from_addr, to_addr, note, fee_estimate, max_fee, warnings, intent, intent_hash, aux, swap, expires_at)265    values (${id}, ${idem}, 'pending_approval', 'swap', ${n.id}, ${src.id}, ${src.symbol}, ${src.decimals}, ${amount.toString()}, ${me}, ${me}, ${`swap → ${dst.symbol}`}, ${(ps.networkFee + ps.rent).toString()}, ${ps.maxFee.toString()},266      ${sql.json(ps.rent > 0n ? ['creates_token_account'] : [])}, ${sql.json(intent as never)}, ${toHex(hash)}, ${sql.json({ swapMessage: ps.message })}, ${sql.json(swap)}, ${new Date(intent.expiresAt * 1000)})267    on conflict (idempotency_key) do nothing returning *`;268  if (!p) throw new UserError('conflict', 'duplicate request', 409);269  await audit('swap.prepared', 'owner', { id, network: n.id, provider: 'jupiter', from: src.symbol, to: dst.symbol, amount: amount.toString(), minOut: swap.minOut, venues: ps.labels, rewritten: ps.converted, networkFee: swap.detail.networkFee, rent: swap.detail.rent, hash: toHex(hash) });270  const creds = await listCreds();271  const options = await generateAuthenticationOptions({ rpID: config.rpId, userVerification: 'required', challenge: new Uint8Array(hash), timeout: 45_000, allowCredentials: creds.map((c) => ({ id: c.id, transports: c.transports as never })) });272  if (options.challenge !== toB64u(hash)) throw new Error('challenge binding mismatch');273  return { proposal: view(p), options };274}275276const factsOf = (p: ProposalRow): JupiterSwapFacts | null => {277  const m = (p.aux as { swapMessage?: string } | null)?.swapMessage;278  try { return m && p.intent ? verifyJupiterSwap(p.intent, new Uint8Array(Buffer.from(m, 'base64'))) : null; } catch { return null; }279};280281/**282 * After signing: last gate before the transaction leaves. A blockhash that is no longer valid means the transaction can283 * never be included: it is failed without being sent (nothing moved). Otherwise the signed bytes are simulated with their284 * signature checked; an unreachable node means "try again on the next tick", never a second transaction.285 */286export async function advanceSolSwap(p: ProposalRow): Promise<ProposalRow> {287  if (p.state !== 'signed' || !p.raw || !p.intent?.sol) return p;288  const n = getNetwork(p.network);289  const fail = async (error: string) => { const r = await move(p, 'failed', { error }); await audit('swap.not_sent', 'system', { id: p.id, reason: error.slice(0, 160) }); return r; };290  if (!config.broadcast) return broadcastNow(p);291  const facts = factsOf(p);292  if (!facts) return fail('the stored swap no longer verifies; it was not sent');293  let height: number, sim: SolMeta;294  try {295    height = await rpcAny<number>(n.rpc, 'getBlockHeight', [{ commitment: 'confirmed' }]);296    if (height > p.intent.sol.lastValidBlockHeight) return await fail('the swap expired before it could be sent (blockhash too old); nothing was swapped');297    sim = await simulate(n, p.raw, { sigVerify: true, replaceRecentBlockhash: false });298  } catch (e) { if ((e as { rpcCode?: number }).rpcCode === undefined) return p; return fail(`the node refused the signed swap (${(e as Error).message.slice(0, 100)}); it was not sent`); }299  if (sim.err) return fail(`the swap would fail now (${simError(sim)}); it was not sent`);300  try {301    const fx = swapEffects(facts, sim);302    if (fx.received < BigInt(p.swap!.minOut) || fx.otherLosses.length || fx.spent !== BigInt(p.amount)) return await fail('the swap no longer delivers the approved minimum in simulation; it was not sent');303  } catch { return fail('the node did not report the simulated balances; the swap was not sent'); }304  return broadcastNow(p);305}306307/** Once a Solana swap is confirmed, read what was actually received from the transaction's own balance tables. */308export async function recordSolSwapOutcome(p: ProposalRow): Promise<void> {309  if (p.kind !== 'swap' || !p.txid || p.out_amount !== null) return;310  const n = getNetwork(p.network), facts = factsOf(p);311  if (!facts) return;312  const tx = await rpcAny<{ meta: SolMeta | null } | null>(n.rpc, 'getTransaction', [p.txid, { encoding: 'json', commitment: 'confirmed', maxSupportedTransactionVersion: 1 }]).catch(() => null);313  if (!tx?.meta || tx.meta.err) return;314  let fx: SwapEffects; try { fx = swapEffects(facts, tx.meta); } catch { return; }315  await sql`update proposals set out_amount = ${fx.received.toString()} where id = ${p.id} and out_amount is null`;316  await audit('swap.settled', 'chain', { id: p.id, txid: p.txid, received: fx.received.toString(), spent: fx.spent.toString(), minOut: p.swap?.minOut, fee: fx.fee.toString(), rent: fx.rentKept.toString() });317}318319export function solSwapExecInfo() { return { provider: 'jupiter', label: JUPITER_LABEL, networks: ['solana'], program: JUPITER_PROGRAM, programData: JUPITER_PIN.programData, lastDeploySlot: JUPITER_PIN.lastDeploySlot }; }320
Files main
Menu

Theme