// A small JSON-RPC client for the handful of Solana calls the card needs. Every call has a hard // timeout; only reads and the (idempotent) resend of an already-signed transaction are retried. // Platform-neutral. The RPC URL carries the Helius key, so it never leaves the server. import type { RawAccount } from './chain.ts' export class RpcError extends Error { /** JSON-RPC error code when the node answered with one (these are negative). */ readonly code: number | null /** Program logs from a failed simulation, for the server log only. */ readonly logs: readonly string[] /** HTTP status when the request itself failed (429, 5xx). */ readonly httpStatus: number | null constructor(message: string, code: number | null = null, logs: readonly string[] = [], httpStatus: number | null = null) { super(message) this.name = 'RpcError' this.code = code this.logs = logs this.httpStatus = httpStatus } } // JSON-RPC errors that mean "this node can't answer right now", not "your request is wrong": // node behind (-32005), min context slot not reached (-32016), block not available (-32004, // -32007, -32014), internal error (-32603). const TRANSIENT_CODES = new Set([-32004, -32005, -32007, -32014, -32016, -32603]) /** Not a final answer: no answer at all, a busy HTTP status, or a node-side JSON-RPC error. */ export function isTransient(err: unknown): boolean { if (!(err instanceof RpcError)) return true if (err.httpStatus !== null) return err.httpStatus === 429 || err.httpStatus >= 500 return err.code === null || TRANSIENT_CODES.has(err.code) } /** Worth retrying inside the same request. A 429 isn't: retrying only deepens the rate limit, so it * goes back to the app, which backs off. */ const retryable = (err: unknown) => isTransient(err) && !(err instanceof RpcError && err.httpStatus === 429) /** RPC URLs carry the Helius key in the query string; never let one reach a log line. */ export const scrub = (text: string) => text.replace(/api-key=[^&\s"']+/gi, 'api-key=***') /** Solana methods take positional params (an array); Helius DAS methods take one named object. */ export type RpcParams = unknown[] | Record export interface Rpc { call(method: string, params: RpcParams, opts?: { timeoutMs?: number; retries?: number }): Promise } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) export function createRpc(url: string, fetcher: typeof fetch = fetch): Rpc { let id = 0 async function once(method: string, params: RpcParams, timeoutMs: number): Promise { const res = await fetcher(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: ++id, method, params }), signal: AbortSignal.timeout(timeoutMs), }) if (!res.ok) throw new RpcError(`rpc http ${res.status}`, null, [], res.status) const body = (await res.json()) as { result?: T; error?: { code?: number; message?: string; data?: { logs?: unknown } } } if (body.error) { const logs = Array.isArray(body.error.data?.logs) ? body.error.data.logs.filter((l): l is string => typeof l === 'string') : [] throw new RpcError(body.error.message ?? 'rpc error', body.error.code ?? null, logs) } return body.result as T } return { async call(method: string, params: RpcParams, { timeoutMs = 8000, retries = 2 } = {}): Promise { for (let attempt = 0; ; attempt++) { try { return await once(method, params, timeoutMs) } catch (err) { // A real answer (bad transaction, simulation failure) is never retried. if (!retryable(err) || attempt >= retries) throw err await sleep(250 * 2 ** attempt + Math.random() * 250) } } }, } } interface WireAccount { owner: string lamports: number data: [string, 'base64'] } const fromBase64 = (s: string) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0)) /** Up to 100 accounts in one round trip; a missing account comes back as null. */ export async function getAccounts(rpc: Rpc, addresses: readonly string[]): Promise> { if (addresses.length === 0) return [] if (addresses.length > 100) throw new Error('getMultipleAccounts takes at most 100 addresses') const out = await rpc.call<{ value: Array }>('getMultipleAccounts', [addresses, { encoding: 'base64', commitment: 'confirmed' }]) return out.value.map((a) => (a ? { owner: a.owner, lamports: BigInt(a.lamports), data: fromBase64(a.data[0]) } : null)) } export async function getLatestBlockhash(rpc: Rpc): Promise<{ blockhash: string; lastValidBlockHeight: number }> { const out = await rpc.call<{ value: { blockhash: string; lastValidBlockHeight: number } }>('getLatestBlockhash', [{ commitment: 'confirmed' }]) return out.value }