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.

ThingCan you reuse it after blockhash expiry?Recovery action
Business intent, such as "swap SOL to USDC"YesRe-evaluate the quote and risk controls
Quote parametersUsuallyRe-check price, slippage, and route freshness
Old signed transaction bytesNoDiscard and rebuild
Old signatureOnly for status lookupCheck status, then stop using it for execution
Wallet approvalNo, if the message changesAsk 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_account attached to the fresh quote request when the flow expects txn

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:

  1. check whether the blockhash is still valid if you retained it
  2. if validity is uncertain, prefer rebuilding over submitting a stale payload
  3. 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:

SignalLikely meaningSafer action
BlockhashNotFoundTransaction's recent blockhash is no longer validRebuild and re-sign
Client-side timeout before RPC responseUnknown whether RPC accepted itCheck local logs and avoid blind parallel sends
429 or throttlingRequest pressure, not a blockhash fixBack off; do not multiply send loops
Auth failureBad key or endpoint shapeFix 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:

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.