Blockhash Expiry Recovery Playbook
Recover safely from Solana blockhash expiry by deciding when to refresh, rebuild, re-sign, check signature status, and avoid duplicate submissions.
Blockhash Expiry Recovery Playbook
BlockhashNotFound, "blockhash not found", and block-height-expiry errors usually mean the signed transaction became stale before the network could process it.
Do not treat that as a normal retry. A Solana transaction commits to a recent blockhash inside the signed message. Once that blockhash is no longer valid, the old signed payload cannot be made fresh by sending it again. You need a recovery policy that knows when to check status, when to rebuild, and when to ask for a new signature.
This page owns one operational question: what should a Carbium-backed app do after blockhash expiry or an unclear send result? For the lower-level architecture, use Transaction Lifecycle. For the relay boundary, use Sending Transactions through Carbium RPC.
Part of the Carbium Solana infrastructure stack.
The decision tree
When a submitted transaction times out, returns a blockhash error, or leaves your app unsure whether it landed, use this order:
flowchart TD
A["Send result is unclear<br/>or blockhash error appears"] --> B{"Did RPC return a signature?"}
B -->|Yes| C["Store signature<br/>check signature status"]
C --> D{"Landed or failed on-chain?"}
D -->|Landed| E["Stop retrying<br/>advance app state"]
D -->|Failed| F["Handle program or route failure"]
D -->|Still unknown| G{"Blockhash still valid?"}
B -->|No| G
G -->|Valid| H["Wait or resend carefully<br/>within retry budget"]
G -->|Expired| I["Rebuild with fresh blockhash<br/>re-sign before submitting"]
The dangerous shortcut is to keep replaying the same serialized transaction after expiry. If the blockhash is stale, the payload is stale.
What actually expires
Solana transactions include a recent_blockhash in the signed message. Solana's current documentation states that a blockhash is valid for 150 slots, and the payments production-readiness docs describe the practical window as roughly 60-90 seconds under normal conditions.
That design gives Solana replay protection and deterministic expiry. It also means your app has a clock running from the moment it fetches or receives the transaction payload.
| Thing | Can you reuse it after blockhash expiry? | Recovery action |
|---|---|---|
| Business intent, such as "swap SOL to USDC" | Yes | Re-evaluate the quote and risk controls |
| Quote parameters | Usually | Re-check price, slippage, and route freshness |
| Old signed transaction bytes | No | Discard and rebuild |
| Old signature | Only for status lookup | Check status, then stop using it for execution |
| Wallet approval | No, if the message changes | Ask the signer to sign the rebuilt transaction |
If the transaction message changes, the signature must change too. A fresh blockhash means a fresh signed message.
Recovery by stage
Before signing
This is the cheapest place to avoid expiry.
If your backend receives an executable transaction from Carbium's quote flow, do not let it sit in a queue while the user experience or bot worker does unrelated work. Move quickly from quote to sign, or request a fresh quote when the user returns after a long pause.
Good guardrails:
- keep quote-to-sign latency visible in logs
- expire unsigned transaction payloads in your own app before Solana does
- refresh the quote when a user reopens a modal, changes wallet state, or waits too long
- keep
user_accountattached to the fresh quote request when the flow expectstxn
After signing, before send
Once the transaction is signed, the message is fixed. Do not change the blockhash, compute budget, route, or accounts without rebuilding and re-signing.
If the signed payload waits too long before submission:
- check whether the blockhash is still valid if you retained it
- if validity is uncertain, prefer rebuilding over submitting a stale payload
- require a new signature for the rebuilt transaction
This is especially important for wallet flows where the user may leave the approval prompt open longer than the app expects.
After send returns a signature
If Carbium RPC returns a signature, store it before doing anything else. The next move is a status check, not an immediate rebuild.
Use getSignatureStatuses or your Solana SDK's confirmation helper to determine whether the original submission already landed, failed, or is still unknown. Rebuilding too early can turn one user intent into two execution attempts.
After send fails without a signature
If the request fails before a signature is accepted, you have less certainty. First classify the failure:
| Signal | Likely meaning | Safer action |
|---|---|---|
BlockhashNotFound | Transaction's recent blockhash is no longer valid | Rebuild and re-sign |
| Client-side timeout before RPC response | Unknown whether RPC accepted it | Check local logs and avoid blind parallel sends |
429 or throttling | Request pressure, not a blockhash fix | Back off; do not multiply send loops |
| Auth failure | Bad key or endpoint shape | Fix auth before retrying |
When in doubt, keep the app-level intent id separate from the transaction signature. That lets you reconcile "same user action" without assuming every retry is the same transaction.
Minimal TypeScript pattern
This example shows the shape of a safer recovery loop. It intentionally keeps quote construction abstract because the owning pages for Carbium quote shape are Q1 and Quote to Swap Integration Guide.
import {
Connection,
VersionedTransaction,
} from "@solana/web3.js";
const connection = new Connection(
`https://rpc.carbium.io/?apiKey=${process.env.CARBIUM_RPC_KEY}`,
"confirmed"
);
async function submitWithExpiryPolicy(buildFreshTxn: () => Promise<string>) {
const unsignedBase64 = await buildFreshTxn();
const tx = VersionedTransaction.deserialize(
Buffer.from(unsignedBase64, "base64")
);
const signedTx = await signWithYourApprovedBoundary(tx);
const raw = signedTx.serialize();
let signature: string | undefined;
try {
signature = await connection.sendRawTransaction(raw, {
skipPreflight: false,
maxRetries: 3,
});
await storeSubmittedSignature(signature);
const status = await connection.confirmTransaction(
signature,
"confirmed"
);
return { signature, status };
} catch (error) {
if (signature) {
const status = await connection.getSignatureStatus(signature);
if (status.value) return { signature, status };
}
if (isBlockhashExpiryError(error)) {
return { expired: true, next: "rebuild-and-resign" };
}
throw error;
}
}
function isBlockhashExpiryError(error: unknown) {
const message = String(error);
return (
message.includes("BlockhashNotFound") ||
message.toLowerCase().includes("blockhash not found") ||
message.toLowerCase().includes("block height exceeded")
);
}The policy is more important than the helper names:
- store the signature once you have it
- check status before rebuilding
- rebuild and re-sign after expiry
- keep retry budgets small enough that errors do not become traffic spikes
Practical recovery checklist
Use this when reviewing a production wallet, bot, or swap backend:
- The app records quote creation time, signing time, send time, and confirmation time.
- Unsigned transaction payloads are not reused after long user pauses.
- Signed transactions are submitted immediately or discarded and rebuilt.
- Returned signatures are persisted before retry logic runs.
- Signature status is checked before any rebuild or replay decision.
- Expired blockhash recovery rebuilds the transaction and requests a new signature.
-
429, auth, quote, and program failures are not mislabeled as blockhash expiry. - Support logs can connect one app-level intent to any signatures created for it.
Source checks
The recovery rules above come from Solana's published transaction model and RPC surface:
- Solana's transaction structure docs describe
recent_blockhash, expiry after 150 slots, andBlockhashNotFoundrejection for stale blockhashes. getLatestBlockhashreturns both the current blockhash andlastValidBlockHeight.isBlockhashValidlets a client ask whether a blockhash is still valid at a requested commitment.- Solana's production-readiness docs describe the practical blockhash window as roughly 60-90 seconds.
For Carbium-specific execution boundaries, pair this page with Sending Transactions through Carbium RPC and API Key Security Best Practices.
Blockhash expiry is not a reason to send harder. It is a reason to decide whether the original transaction already landed, then rebuild and re-sign only when the old payload is truly stale.
Building a production swap, wallet, or bot flow on Carbium? Keep the recovery loop backend-visible, signature-aware, and connected to carbium.io product access.
Updated 10 days ago
