SPB Git

Repository Public

sol.ts 11.3 KB 152 lines typescript

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
Files main
Menu

Theme