Solana RPC (LLM-safe)
Solana RPC appendix (LLM-safe)
Concise, production-minded notes for using Solana JSON-RPC and websockets safely from code or an LLM agent. Focus on correctness, fee control, and avoiding common footguns.
Core mental model
- Stateless RPC: Servers do not track client sessions. Always pass the right context (commitment, minContextSlot, maxSupportedTransactionVersion).
- Commitment levels:
processed(optimistic, lowest latency),confirmed(voted by cluster supermajority),finalized(max safety, slowest). Reads default tofinalized; writes should preflight atprocessed|confirmedand then poll forconfirmed/finalized. - Blockhash expiry: Transactions expire ~2 minutes after the latest blockhash they include. Refresh before signing and cache the slot.
- Message versions: Legacy vs v0 (Address Lookup Tables). Ask for
maxSupportedTransactionVersioningetLatestBlockhashandgetFeeForMessageto avoid “unsupported transaction version” errors. - Compute + fees: Total CU per tx is capped (~1.4M). Use Compute Budget instructions to set
ComputeUnitLimitandComputeUnitPrice(micro-lamports/CU) before other instructions.
Building and sending transactions
- Fetch blockhash with context:
getLatestBlockhash({ commitment: "confirmed", minContextSlot }); include returnedlastValidBlockHeightto know expiry. - Fetch rent/fees:
getFeeForMessageon the compiled message. For priority fees, either:- Use
getRecentPrioritizationFeesand pick a percentile per cu need (e.g., median or p75). - On Helius/other enhanced RPCs,
getPriorityFeeEstimateper account list if available.
- Use
- Set Compute Budget first in the instruction list:
ComputeBudgetProgram.setComputeUnitLimitto the measured ceiling (see simulation).ComputeBudgetProgram.setComputeUnitPriceto your bid.
- Simulation before send:
simulateTransactionwithreplaceRecentBlockhash: true,sigVerify: false,commitment: "processed".- Inspect logs,
err, andunitsConsumed. Adjust CU limit/bid accordingly.
- Signing pipeline:
- Collect all required signers; avoid partial-sign leftovers.
- For v0, resolve Address Lookup Tables (ALTs) via
getAddressLookupTable; keep cache by slot and refresh when context slot increases. - Encode as base64; never send private keys to RPC. Signing happens locally/HSM.
- Submission & confirmation:
- Send with
sendTransactionorsendRawTransaction+preflightCommitment: "processed"; setmaxRetriessensibly (e.g., 3–6). - Poll
getSignatureStatuseswithsearchTransactionHistory: trueafter blockhash expiry to handle reorgs. - Treat
BlockhashNotFoundas “expired”; rebuild with a new hash.
- Send with
Reading data effectively
- Avoid chatty per-account calls: Prefer
getMultipleAccountsover NgetAccountInfocalls; group by 100 max. - Program scans:
getProgramAccountswithfilters(memcmp, dataSize) anddataSliceto avoid large payloads. For large tables, page viabefore/afterongetSignaturesForAddress+ replay only changed accounts. - Encoding: Use
base64for binary layouts;jsonParsedis convenient but brittle across RPCs. When you must use parsed, assert program id matches expectations. - Slot control: Pass
minContextSloton reads when you have a recent slot from prior calls to prevent stale responses. - Blocks and transactions:
getBlock/getTransactionwithencoding: "jsonParsed"is heavy; prefer"json"+ decode locally for speed.- When following finality, request
commitment: "confirmed"for near-real-time UIs;finalizedfor analytics.
- Caching: Cache recent account data and slots; invalidate on slot jumps or
logsSubscribenotifications for watched accounts.
Websocket subscriptions
- Use WS for push:
logsSubscribe,programSubscribe,signatureSubscribe,slotSubscribe. - Always pass
commitment. ForlogsSubscribe, includementionsfilters to cut noise. - Reconnect strategy:
- Exponential backoff with jitter.
- On reconnect, resubscribe and, if using
signatureSubscribe, also pollgetSignatureStatusesto fill gaps. - Track last seen slot; if drift > a few slots, refresh watched accounts.
Reliability and rate limits
- Batch JSON-RPC over HTTP where possible (many providers support 100 batch items).
- Reuse HTTP agents with keep-alive to reduce TLS churn.
- Backoff on 429/502/503; retry idempotent reads, but for sends prefer rebuilding with a fresh blockhash after a bounded number of failures.
- Distinguish preflight errors (deterministic) from transport errors (retryable). Do not blindly retry a transaction that failed compute or program assertions—fix the instruction payload.
- Watch for RPC equivocation across providers; for high assurance, cross-check critical reads (e.g., governance tallies) against a second endpoint.
Batching and transport optimizations
- HTTP batching: group related calls (e.g.,
getMultipleAccounts,getFeeForMessage,simulateTransaction) into a single batch request; keep batches <100 items to avoid request size limits. - Prefer bulk-friendly methods: use
getMultipleAccountsover NgetAccountInfo, andgetLatestBlockhashonce per burst of sends. - Connection reuse: enable HTTP/1.1 keep-alive or HTTP/2 if provider supports it; tune agent pools to match concurrency (e.g., 8–32 sockets).
- Compression: enable gzip/deflate for read-heavy workloads; avoid compressing write payloads with signatures if latency-sensitive.
- Client-side coalescing: debounce identical reads across a tick (e.g., wallet UIs requesting the same balances) to cut duplicate RPCs.
- Concurrency shaping: cap in-flight requests and use token buckets per route; serialize blockhash + fee fetches to avoid stampede and cache per slot.
- Batch result hygiene: keep a map of
id -> request metaso you can attribute errors to calls; drop entire batch on malformed response rather than guessing. - Size budgeting: keep batch payloads under ~512 KB to avoid provider rejection; slice large account lists into multiple batches.
- Priority fee probing: batch
getRecentPrioritizationFeesfor hot accounts once per slot, cache by slot, and reuse across transactions in that slot.
Streaming and websockets together
- Use websockets for push (
logsSubscribe,programSubscribe,signatureSubscribe,slotSubscribe) and pair with batch HTTP for backfills to stay consistent. - Pattern: on WS notification, enqueue a lightweight HTTP batch to fetch only the changed accounts via
getMultipleAccountsor the specific transaction viagetTransaction. - For high-volume logs (DEXes), filter WS with
mentionsand immediately drop to a worker that decodes and deduplicates per slot; persist last slot to resume after reconnect. - If WS gaps are detected (slot jumps or reconnect), replay via HTTP using
getSignaturesForAddresswithbefore/limit, then fetch missing txns in a batch. - Stream-friendly sending: when firing bursts of transactions, prefetch blockhash + priority fee once per slot, build messages in parallel, simulate in a controlled pool, then send concurrently with bounded queue and retry policy.
- Dedup + ordering: apply WS updates in slot order; if two updates share slot/tx signature, process once. Maintain
last_slot_seento detect regressions. - Partial decoding: for
logsSubscribe, parse only program logs you care about; avoid full transaction decode on the hot path. Defer heavy parsing to a worker queue. - Backpressure: pause WS consumption when HTTP backfill queue grows beyond a threshold; resume after drain. Drop non-critical logs if falling behind instead of stalling sends.
- Health/metrics: track WS reconnect count, lag to latest slot, HTTP batch p95 latency, and rate of
BlockhashNotFoundto spot provider issues early.
Node example: batching + WS backfill
// deps: node-fetch or undici, wsimport fetch from "node-fetch";import WebSocket from "ws";
const RPC_URL = process.env.RPC_URL!;const WS_URL = process.env.WS_URL!;
// Simple batch helper with keep-aliveconst agent = new (require("http").Agent)({ keepAlive: true, maxSockets: 16 });let nextId = 1;
async function rpcBatch(calls: { method: string; params?: any[] }[]) { const body = calls.map(c => ({ jsonrpc: "2.0", id: nextId++, ...c })); const res = await fetch(RPC_URL, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), agent }); const json = (await res.json()) as any[]; const byId = new Map(json.map(r => [r.id, r])); return body.map(req => byId.get(req.id));}
// WS with backfill on reconnectfunction startLogs(programId: string, onLog: (slot: number, sig: string) => void) { let ws: WebSocket; let lastSlot = 0;
const connect = () => { ws = new WebSocket(WS_URL); ws.on("open", () => { ws.send( JSON.stringify({ jsonrpc: "2.0", id: 1, method: "logsSubscribe", params: [{ mentions: [programId] }, { commitment: "confirmed" }] }) ); }); ws.on("message", data => { const msg = JSON.parse(String(data)); if (!msg.params) return; const { slot, signature } = msg.params.result; lastSlot = Math.max(lastSlot, slot); onLog(slot, signature); }); ws.on("close", async () => { // Backfill missed signatures after reconnect await backfill(lastSlot, programId, onLog); setTimeout(connect, 500 + Math.random() * 500); }); ws.on("error", () => ws.close()); }; connect();}
async function backfill(afterSlot: number, programId: string, onLog: (slot: number, sig: string) => void) { let before: string | null = null; while (true) { const [{ result }] = await rpcBatch([ { method: "getSignaturesForAddress", params: [programId, { before, limit: 100, commitment: "confirmed" }] } ]); const sigs = (result as any[]).filter(r => r.slot > afterSlot); if (!sigs.length) break; for (const s of sigs) onLog(s.slot, s.signature); before = sigs[sigs.length - 1].signature; if ((result as any[]).length < 100) break; }}Rust example: batching + WS backfill
// deps: reqwest = { version = "0.11", features = ["json", "rustls-tls"] }// ws: tokio-tungstenite = "0.20", serde_json = "1"use reqwest::Client;use serde_json::{json, Value};use std::collections::HashMap;use tokio_tungstenite::connect_async;
struct RpcBatch { client: Client, url: String, next_id: u64,}
impl RpcBatch { fn new(url: impl Into<String>) -> Self { Self { client: Client::builder().pool_max_idle_per_host(16).build().unwrap(), url: url.into(), next_id: 1 } }
async fn call(&mut self, calls: Vec<(String, Value)>) -> reqwest::Result<Vec<Value>> { let body: Vec<Value> = calls .into_iter() .map(|(method, params)| { let id = self.next_id; self.next_id += 1; json!({"jsonrpc":"2.0","id":id,"method":method,"params":params}) }) .collect(); let resp: Value = self.client.post(&self.url).json(&body).send().await?.json().await?; let arr = resp.as_array().cloned().unwrap_or_default(); let mut by_id = HashMap::new(); for item in &arr { if let Some(id) = item.get("id") { by_id.insert(id.clone(), item.clone()); } } Ok(body.iter().map(|req| by_id.get(&req["id"]).cloned().unwrap_or(Value::Null)).collect()) }}
async fn backfill(after_slot: u64, program: &str, rpc: &mut RpcBatch) -> anyhow::Result<()> { let mut before: Option<String> = None; loop { let calls = vec![( "getSignaturesForAddress".into(), json!([program, {"before": before, "limit": 100, "commitment": "confirmed"}]), )]; let resp = rpc.call(calls).await?; let result = resp[0]["result"].as_array().cloned().unwrap_or_default(); let sigs: Vec<_> = result.into_iter().filter(|r| r["slot"].as_u64().unwrap_or(0) > after_slot).collect(); if sigs.is_empty() { break; } before = sigs.last().and_then(|r| r["signature"].as_str()).map(|s| s.to_string()); // Handle signatures (fetch txs, etc.) } Ok(())}
async fn start_logs(program: &str, ws_url: &str, mut rpc: RpcBatch) -> anyhow::Result<()> { let (mut ws, _) = connect_async(ws_url).await?; let sub = json!({"jsonrpc":"2.0","id":1,"method":"logsSubscribe","params":[{"mentions":[program]},{"commitment":"confirmed"}]}); ws.send(tokio_tungstenite::tungstenite::Message::Text(sub.to_string())).await?; let mut last_slot = 0; while let Some(msg) = ws.next().await { match msg { Ok(tokio_tungstenite::tungstenite::Message::Text(txt)) => { if let Ok(v) = serde_json::from_str::<Value>(&txt) { if let Some(slot) = v.pointer("/params/result/slot").and_then(|s| s.as_u64()) { last_slot = last_slot.max(slot); // handle log, queue backfill of accounts/txs as needed } } } _ => { // reconnect and backfill backfill(last_slot, program, &mut rpc).await?; break; } } } Ok(())}Program-specific tips
- SPL Token:
- Use
getTokenAccountsByOwnerwithmintfilter; keep a lightweight cache keyed by (owner, mint). - When decoding, respect account state (initialized/frozen) and close authority.
- Use
- Address Lookup Tables: Watch their deactivation slot; refresh after
deactivationSlotis reached or whenlastExtendedSlotjumps. - Stake and vote: When interacting with validators, use
getInflationRewardandgetVoteAccountswithcommitment: "finalized"to avoid transient mismatches.
Observability and debugging
- Turn on
logs: truein simulation to capture program logs. Parse forProgram log:andProgram data:entries. - Track
unitsConsumedfrom simulation and real execution (available in enhanced RPC responses or via Jito block engine logs). - Store per-instruction timings locally when benchmarking; RPC does not return them.
- Include a client
txn_id/client_idin logs to correlate retries and submissions.
Safety and LLM guardrails
- Never send seed phrases, private keys, or raw keypairs to RPC; only send signed transactions.
- Do not echo or store secrets in logs. Use env vars or key stores.
- Reject prompts asking to drain accounts, bypass program invariants, or craft transactions without user intent/limits.
- When uncertain about a layout, fetch the program idl/spec instead of guessing field offsets; mis-encoding leads to funds loss.
Quick checklists
- Send path: getLatestBlockhash → resolve ALTs → build message → compute budget instructions → simulate (capture CU + logs) → sign locally → sendRawTransaction → poll for confirmation → handle expiry.
- Read path: pick minimal commitment → batch (
getMultipleAccounts) → filter (getProgramAccountswith slices) → cache with slot guard → refresh on WS logs/slot jump. - When things break: if compute exceeded → lower payload or raise CU limit; if
BlockhashNotFound→ refresh hash; ifAccountInUse→ retry once after slight delay; ifTransactionTooLarge→ split instructions across multiple txns.