Repository Public
1import { associatedTokenAddress, buildSolMessage, getAsset, solFeeLamports, solWireTransaction, SYSTEM_PROGRAM, TOKEN_2022_PROGRAM, TOKEN_PROGRAM, tokenProgramOf, type Intent, type NetworkDef } from '@spw/core';2import { UserError } from '../config.ts';3import { hostOf, rpcAny, rpcOne, rpcQuorum, type Adapter, type Deposit, type Health, type Prepared, type Tracked } from './rpc.ts';45const RENT_ATA = 2_039_280n; // rent-exempt minimum for a 165-byte token account6const RENT_SYSTEM = 890_880n; // rent-exempt minimum for a 0-byte system account78export function solAdapter(n: NetworkDef): Adapter {9 const call = <T>(m: string, p: unknown[]) => rpcAny<T>(n.rpc, m, p);10 let identity = false;11 const acct = (a: string) => call<{ value: { owner: string; lamports: number; data: unknown } | null }>('getAccountInfo', [a, { encoding: 'jsonParsed', commitment: 'confirmed' }]);12 type Parsed = { pubkey: string; account: { data: { parsed: { info: { mint: string; tokenAmount: { amount: string; decimals: number } } } } } };13 /** All token accounts of the owner, both token programs: two calls regardless of how many mints are allowlisted. */14 const tokenAccounts = async (owner: string): Promise<Parsed[]> => {15 const rs = await Promise.all([TOKEN_PROGRAM, TOKEN_2022_PROGRAM].map((programId) =>16 call<{ value: Parsed[] }>('getTokenAccountsByOwner', [owner, { programId }, { encoding: 'jsonParsed', commitment: 'confirmed' }])));17 return rs.flatMap((r) => r.value);18 };19 const disabled = new Set<string>();20 let tokensChecked = false;21 async function verifyTokens(): Promise<void> { // mint must exist, be owned by the expected token program and have the pinned decimals22 for (let k = 0; k < n.tokens.length; k += 50) {23 const chunk = n.tokens.slice(k, k + 50);24 const r = await call<{ value: ({ owner: string; data: { parsed?: { type: string; info: { decimals: number } } } } | null)[] }>('getMultipleAccounts', [chunk.map((t) => t.id), { encoding: 'jsonParsed', commitment: 'finalized' }]);25 chunk.forEach((t, j) => { const a = r.value[j];26 const good = !!a && a.owner === tokenProgramOf(t) && a.data.parsed?.type === 'mint' && a.data.parsed.info.decimals === t.decimals;27 if (good) disabled.delete(t.id); else { disabled.add(t.id); console.error(`[sol] token ${t.symbol} ${t.id} failed on-chain mint check — disabled`); } });28 }29 tokensChecked = true;30 }31 return {32 async health(): Promise<Health> {33 const endpoints = await Promise.all(n.rpc.map(async (url) => {34 const t = Date.now();35 try {36 const h = await rpcOne<number>(url, 'getBlockHeight', [{ commitment: 'confirmed' }]);37 if (!identity && (await rpcOne<string>(url, 'getGenesisHash', [])) !== n.chainRef) throw new Error('genesis');38 return { url: hostOf(url), ok: true, ms: Date.now() - t, h };39 } catch { return { url: hostOf(url), ok: false, ms: null, h: null as number | null }; }40 }));41 const ok = endpoints.every((e) => e.ok);42 if (ok) identity = true;43 if (identity && !tokensChecked) await verifyTokens().catch(() => {});44 return { network: n.id, ok: ok && identity, readable: endpoints.some((e) => e.ok), identityVerified: identity, height: Math.max(0, ...endpoints.map((e) => e.h ?? 0)) || null,45 endpoints: endpoints.map(({ h: _h, ...e }) => e), checkedAt: new Date().toISOString() };46 },47 async balances(addr) {48 const out: Record<string, bigint> = { native: BigInt((await call<{ value: number }>('getBalance', [addr, { commitment: 'confirmed' }])).value) };49 const accts = await tokenAccounts(addr);50 for (const t of n.tokens) if (!disabled.has(t.id)) out[t.id] = accts.filter((a) => a.account.data.parsed.info.mint === t.id).reduce((s, a) => s + BigInt(a.account.data.parsed.info.tokenAmount.amount), 0n);51 return out;52 },53 disabledAssets: () => [...disabled],54 async prepare(d): Promise<Prepared> {55 if ((await rpcQuorum<string>(n.rpc, 'getGenesisHash', [])) !== n.chainRef) throw new UserError('rpc_degraded', 'cluster identity mismatch; transfer refused', 503);56 const asset = getAsset(n.id, d.asset), warnings: string[] = [];57 if (!tokensChecked) await verifyTokens();58 if (disabled.has(asset.id)) throw new UserError('unsupported_asset', 'this token failed its on-chain identity check and is disabled');59 const tp = tokenProgramOf(asset);60 const dest = (await acct(d.to)).value;61 if (dest && dest.owner !== SYSTEM_PROGRAM) throw new UserError('bad_destination', 'destination is not a regular wallet address (it is a program-owned or token account)');62 const bal = await this.balances(d.from);63 const computeUnitLimit = asset.id === 'native' ? 5_000 : asset.program === 'token2022' ? 120_000 : 80_000;64 let price = 5_000n;65 try {66 const fees = await call<{ prioritizationFee: number }[]>('getRecentPrioritizationFees', [[d.from]]);67 const sorted = fees.map((f) => f.prioritizationFee).sort((a, b) => a - b);68 price = BigInt(Math.min(2_000_000, Math.max(5_000, sorted[Math.floor(sorted.length * 0.75)] ?? 0)));69 } catch { /* default */ }70 const fee = solFeeLamports(computeUnitLimit, price);71 let createAta = false, needLamports = fee;72 if (asset.id === 'native') {73 if (!dest && d.amount < RENT_SYSTEM) throw new UserError('below_rent', 'destination account does not exist yet: send at least 0.00089088 SOL');74 needLamports += d.amount;75 // keep the source account rent-exempt76 if ((bal.native ?? 0n) - needLamports < RENT_SYSTEM && (bal.native ?? 0n) !== needLamports) throw new UserError('insufficient_funds', 'insufficient SOL (0.00089088 SOL must remain in the wallet)');77 } else {78 if ((bal[asset.id] ?? 0n) < d.amount) throw new UserError('insufficient_funds', `insufficient ${asset.symbol} balance`);79 const srcAtaAddr = associatedTokenAddress(d.from, asset.id, tp);80 const srcAta = (await acct(srcAtaAddr)).value;81 // the builder spends from the associated token account only: its own balance must cover the amount82 const inAta = (await tokenAccounts(d.from)).find((a) => a.pubkey === srcAtaAddr);83 if (srcAta && BigInt(inAta?.account.data.parsed.info.tokenAmount.amount ?? '0') < d.amount) throw new UserError('insufficient_funds', `insufficient ${asset.symbol} in the associated token account`);84 if (!srcAta) throw new UserError('insufficient_funds', `no ${asset.symbol} token account in this wallet`);85 createAta = !(await acct(associatedTokenAddress(d.to, asset.id, tp))).value;86 if (createAta) { needLamports += RENT_ATA; warnings.push('creates_token_account'); }87 if ((bal.native ?? 0n) < needLamports) throw new UserError('insufficient_gas', 'not enough SOL to pay the network fee' + (createAta ? ' and the recipient token account rent (≈0.00204 SOL)' : ''));88 }89 const bh = await call<{ value: { blockhash: string; lastValidBlockHeight: number } }>('getLatestBlockhash', [{ commitment: 'confirmed' }]);90 return { section: { sol: { recentBlockhash: bh.value.blockhash, lastValidBlockHeight: bh.value.lastValidBlockHeight, createAta, computeUnitLimit, computeUnitPrice: price.toString() } },91 maxFee: fee, feeEstimate: fee, warnings, ttlSec: 50 };92 },93 async broadcast(raw) {94 const rs = await Promise.allSettled(n.rpc.map((u) => rpcOne<string>(u, 'sendTransaction', [raw, { encoding: 'base64', skipPreflight: false, preflightCommitment: 'confirmed', maxRetries: 5 }])));95 if (rs.some((r) => r.status === 'fulfilled')) return;96 const msg = String((rs[0] as PromiseRejectedResult).reason?.message);97 if (/already been processed/i.test(msg)) return;98 throw new Error(msg);99 },100 async track(p): Promise<Tracked> {101 const r = await call<{ value: ({ confirmations: number | null; err: unknown; confirmationStatus: string; slot: number } | null)[] }>('getSignatureStatuses', [[p.txid], { searchTransactionHistory: true }]);102 const s = r.value[0];103 if (!s) {104 const h = await call<number>('getBlockHeight', [{ commitment: 'finalized' }]);105 if (h > p.intent.sol.lastValidBlockHeight) return { state: 'failed', confirmations: 0, error: 'expired before inclusion (blockhash too old); no funds moved' };106 return { state: 'broadcast', confirmations: 0, rebroadcast: true };107 }108 if (s.err) return { state: 'failed', confirmations: 0, blockRef: String(s.slot), error: `on-chain error: ${JSON.stringify(s.err).slice(0, 120)}` };109 if (s.confirmationStatus === 'finalized') return { state: 'finalized', confirmations: 32, blockRef: String(s.slot) };110 return { state: 'confirming', confirmations: s.confirmations ?? 1, blockRef: String(s.slot) };111 },112 async deposits(addr, cursor) {113 const items: Deposit[] = [];114 // owner account + only the token accounts that actually exist115 const mine = new Set(n.tokens.filter((t) => !disabled.has(t.id)).map((t) => t.id));116 const targets = [addr, ...(await tokenAccounts(addr)).filter((a) => mine.has(a.account.data.parsed.info.mint)).map((a) => a.pubkey)].slice(0, 12);117 const sigs = new Map<string, number>();118 for (const t of targets) {119 const r = await call<{ signature: string; slot: number; err: unknown }[]>('getSignaturesForAddress', [t, { limit: 15, commitment: 'finalized' }]);120 for (const s of r) if (!s.err && s.slot > (cursor ?? 0)) sigs.set(s.signature, s.slot);121 }122 let maxSlot = cursor ?? 0;123 for (const [sig, slot] of [...sigs].slice(0, 15)) {124 const tx = await call<any>('getTransaction', [sig, { encoding: 'jsonParsed', commitment: 'finalized', maxSupportedTransactionVersion: 1 }]);125 if (!tx?.meta) continue;126 const keys: string[] = tx.transaction.message.accountKeys.map((k: any) => k.pubkey ?? k);127 const signer = keys[0] ?? '';128 const me = keys.indexOf(addr);129 if (me >= 0 && signer !== addr) {130 const delta = BigInt(tx.meta.postBalances[me]) - BigInt(tx.meta.preBalances[me]);131 if (delta > 0n) items.push({ txid: sig, idx: 0, asset: 'native', amount: delta, from: signer, block: slot, time: tx.blockTime ?? null });132 }133 for (const t of n.tokens) {134 if (disabled.has(t.id)) continue;135 const f = (arr: any[]) => BigInt(arr?.find((b: any) => b.owner === addr && b.mint === t.id)?.uiTokenAmount.amount ?? '0');136 const delta = f(tx.meta.postTokenBalances) - f(tx.meta.preTokenBalances);137 if (delta > 0n) items.push({ txid: sig, idx: 1 + n.tokens.indexOf(t), asset: t.id, amount: delta, from: signer === addr ? '' : signer, block: slot, time: tx.blockTime ?? null });138 }139 maxSlot = Math.max(maxSlot, slot);140 }141 return { items, cursor: maxSlot, finalizedHeight: Number.MAX_SAFE_INTEGER };142 },143 };144}145146/** Run the exact message through the cluster's simulator before asking for approval. */147export async function simulateSol(n: NetworkDef, intent: Intent): Promise<void> {148 const wire = Buffer.from(solWireTransaction(new Uint8Array(64), buildSolMessage(intent))).toString('base64');149 const r = await rpcAny<{ value: { err: unknown; logs: string[] | null } }>(n.rpc, 'simulateTransaction', [wire, { encoding: 'base64', sigVerify: false, commitment: 'confirmed' }]);150 if (r.value.err) throw new UserError('simulation_failed', `simulation failed: ${JSON.stringify(r.value.err).slice(0, 120)} ${(r.value.logs ?? []).slice(-2).join(' | ').slice(0, 200)}`);151}152