Debugging Failed Transactions
Failed Solana transactions leave clues in simulation logs, RPC error codes, and explorer program output. A disciplined decode workflow separates user errors, client bugs, RPC issues, and on-chain program defects before you retry or page on-call.
Recipe
Quick-reference recipe card - copy-paste ready.
import { createSolanaRpc } from "@solana/kit";
const rpc = createSolanaRpc(process.env.RPC_URL!);
const sim = await rpc
.simulateTransaction(base64Tx, {
sigVerify: false,
replaceRecentBlockhash: true,
commitment: "confirmed",
})
.send();
if (sim.value.err) {
console.error("err:", sim.value.err);
console.error("logs:", sim.value.logs?.join("\n"));
console.error("units:", sim.value.unitsConsumed);
}When to reach for this:
- A user reports "transaction failed" with only a signature
- Preflight rejects before the tx hits the network
- Program returns
Custom: Nand you need the enum mapping - Compute or account size errors spike after a deploy
- Incident triage needs error class before paging program owners
Working Example
import {
createSolanaRpc,
createTransactionMessage,
setTransactionMessageFeePayer,
setTransactionMessageLifetimeUsingBlockhash,
appendTransactionMessageInstruction,
signTransactionMessageWithSigners,
getBase64EncodedWireTransaction,
pipe,
} from "@solana/kit";
import { getTransferSolInstruction } from "@solana-program/system";
const rpc = createSolanaRpc("https://api.devnet.solana.com");
async function debugTransfer(signer: Awaited<ReturnType<typeof import("@solana/kit").createKeyPairSignerFromBytes>>) {
const { value: blockhash } = await rpc.getLatestBlockhash().send();
const message = pipe(
createTransactionMessage({ version: 0 }),
(m) => setTransactionMessageFeePayer(signer.address, m),
(m) => setTransactionMessageLifetimeUsingBlockhash(blockhash, m),
(m) =>
appendTransactionMessageInstruction(
getTransferSolInstruction({
source: signer,
destination: signer.address,
amount: 1n,
}),
m,
),
);
const signed = await signTransactionMessageWithSigners(message);
const wire = getBase64EncodedWireTransaction(signed);
const sim = await rpc
.simulateTransaction(wire, {
sigVerify: true,
replaceRecentBlockhash: false,
commitment: "confirmed",
})
.send();
return {
err: sim.value.err,
logs: sim.value.logs ?? [],
unitsConsumed: sim.value.unitsConsumed,
accounts: sim.value.accounts,
};
}What this demonstrates:
- Building a real transaction and simulating with
replaceRecentBlockhashoff when testing signature validity - Reading
err,logs, andunitsConsumedas the primary debug triple - Using
@solana/kit7.0.0 RPC types for typed simulation responses - Capturing account post-simulation state when debugging rent or owner mismatches
Deep Dive
How It Works
- Preflight simulation runs the transaction against current ledger state before broadcast (when enabled on
sendTransaction). - Program logs emit via
sol_log/msg!/anchor_lang::prelude::*and appear in simulation and confirmed transaction metadata. - Custom program errors encode as
InstructionError::Custom(u32)- map to your IDL or#[error_code]enum. - Compute budget defaults to 200,000 CUs per instruction unless you prepend
ComputeBudgetinstructions. - Account meta mismatches fail before execution: wrong signer flag, wrong writable flag, or missing account.
Error Classes at a Glance
| Symptom | Typical cause | First check |
|---|---|---|
BlockhashNotFound | Expired or wrong cluster blockhash | getLatestBlockhash, cluster URL |
AccountNotFound | Missing ATA, closed account, wrong PDA | Explorer account tab, seed derivation |
InsufficientFundsForFee | Fee payer underfunded | Lamport balance vs fee + rent |
ComputationalBudgetExceeded | Hot path or unbounded loop | CU profile, ComputeBudget ix |
Custom: N | Program require! / ErrorCode | IDL error table, anchor error CLI |
TypeScript Notes
function parseCustomError(logs: string[]): number | null {
const line = logs.find((l) => l.includes("custom program error:"));
if (!line) return null;
const m = line.match(/custom program error: (0x[0-9a-f]+|\d+)/i);
if (!m) return null;
return m[1].startsWith("0x") ? parseInt(m[1], 16) : Number(m[1]);
}Rust / Anchor Notes
// Anchor 0.32.1 - errors surface as Custom codes in logs
#[error_code]
pub enum MyError {
#[msg("Vault is paused")]
VaultPaused,
}
// Map: anchor error --program-id <PID> shows code -> nameGotchas
- Retrying without reading logs - Users hammer resubmit; you burn priority fees on the same deterministic failure. Fix: Simulate once, classify error, fix root cause.
- Wrong cluster RPC - Devnet tx simulated against mainnet RPC returns
AccountNotFound. Fix: PinRPC_URL, wallet network, and explorer?cluster=together. - Assuming preflight is always on - Some server paths disable preflight for latency. Fix: Explicit
simulateTransactionin support tooling. - Decimal vs lamport amounts - Client sends
1.5SOL as float; program expects integer lamports. Fix:bigintend-to-end; convert at UI boundary only. - Stale IDL error map - Deployed program v2, client still decodes v1 codes. Fix: Version IDL in CI; regenerate clients on every program release.
- Ignoring
unitsConsumed- Near-limit programs fail only under load. Fix: Track CU in metrics; alert when p95 exceeds 80% of budget.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Explorer-only triage | Quick one-off user support | You need reproducible automation |
solana confirm -v CLI | SSH bastion with CLI 3.0.10 | Production app servers without keys |
| Surfpool / LiteSVM local replay | Reproducing mainnet account sets | You need real network congestion behavior |
| Geyser / indexer logs | High-volume batch failure analysis | Single-tx user report |
FAQs
Should I disable preflight in production?
No for user-facing sends. Keep preflight on unless you have a measured latency reason and server-side simulation as a backstop.
Where do Anchor `msg!` logs appear?
In simulation logs and in the confirmed transaction metadata via RPC getTransaction.
How do I decode `Custom: 6003`?
Run anchor error 6003 against your program IDL, or read the #[error_code] enum order (codes start at 6000 in Anchor).
What if simulation succeeds but send fails?
Likely blockhash expiry, network drop, or leader skip. Refresh blockhash, check signature status, and inspect whether the tx landed in a later slot.
Does `sigVerify: false` hide signature bugs?
Yes. Use sigVerify: true when debugging signer and fee-payer issues; use false only when testing program logic with unsigned fixtures.
How do I capture logs in a Next.js API route?
Simulate server-side with @solana/kit, return structured { err, logs, unitsConsumed } to the client - never expose signing keys.
What is the default compute unit limit?
200,000 CUs per instruction unless you raise it with ComputeBudgetInstruction::set_compute_unit_limit.
Can one failing instruction roll back the whole transaction?
Yes. Solana transactions are atomic unless your program catches CPI errors internally.
How do I correlate errors across RPC providers?
Log signature, slot, block time, provider name, and error JSON. Compare getTransaction across a secondary provider.
When should I page the program team vs client team?
Custom: N from your program ID -> program. AccountNotFound on user ATA -> client. RPC 429/503 -> infra.
Related
- Transactions Not Landing - when simulation passes but inclusion fails
- RPC & Infrastructure Outages - provider-side failures
- Prevention Checklists - monitors and alerts after incidents
Stack versions: This page was written for Agave 4.1.1, Solana CLI 3.0.10, Anchor 0.32.1, anchor-lang 0.32.1, Rust 1.91.1, @solana/kit 7.0.0, Surfpool 0.12.0, and LiteSVM 0.6.x.