learn.sol
Solana Kit Clients • Send Confirm Retry And Lifetime Management
Lesson 6 of 11

Send, Confirm, Retry and Lifetime Management

Send a signed transaction the right way, separate sending from confirmation, and retry only when rebuilding is actually safe.

The biggest transaction UX bug is simple.

Apps act like submitted means done.

It does not.

A transaction can be signed, have a valid signature, and still fail to confirm.

It can also expire before the network accepts it.

That is why the send flow has to separate three different ideas:

  • the transaction already has a signature
  • the transaction has been sent to the network
  • the transaction has been confirmed at the commitment you care about

If you collapse those into one step, your retry logic will be wrong.

SubmissionTap to reveal
The point where the signed transaction has been sent to the network, even though the final outcome is not known yet.
Submission
ConfirmationTap to reveal
The later network result that tells you the submitted signature actually landed at the commitment you care about.
Confirmation
Retry PolicyTap to reveal
The explicit rule for which failures justify rebuilding and resending a transaction and which ones do not.
Retry Policy
Lifetime RebuildTap to reveal
Creating a fresh message with a new blockhash and new signature instead of resending stale signed bytes.
Lifetime Rebuild

The Signature Exists Before Sending

This matters more than most beginners expect.

With Kit, the transaction signature is available as soon as the transaction is signed by the fee payer.

You do not need to wait for the RPC node to return it.

That means:

  • you can display the signature immediately
  • you can log it before send
  • you can still fail later during send or confirmation

That is the right model.

Step 1: Fund The Sender Before You Test Retries

The old version of this lesson had a real bug.

It generated a fresh sender and tried to transfer lamports immediately.

That script could not succeed.

This version fixes that first.

Create scripts/send-confirm.ts:

import {
  airdropFactory,
  appendTransactionMessageInstructions,
  createSolanaRpc,
  createSolanaRpcSubscriptions,
  createTransactionMessage,
  generateKeyPairSigner,
  getSignatureFromTransaction,
  lamports,
  pipe,
  sendAndConfirmTransactionFactory,
  setTransactionMessageFeePayerSigner,
  setTransactionMessageLifetimeUsingBlockhash,
  signTransactionMessageWithSigners,
} from "@solana/kit";
import { getTransferSolInstruction } from "@solana-program/system";

const RPC_URL = "http://127.0.0.1:8899";
const RPC_WS_URL = "ws://127.0.0.1:8900";
const LAMPORTS_PER_SOL = 1_000_000_000n;

type GeneratedSigner = Awaited<ReturnType<typeof generateKeyPairSigner>>;

const rpc = createSolanaRpc(RPC_URL);
const rpcSubscriptions = createSolanaRpcSubscriptions(RPC_WS_URL);

async function prepareActors(): Promise<{
  sender: GeneratedSigner;
  recipient: GeneratedSigner;
}> {
  const sender = await generateKeyPairSigner();
  const recipient = await generateKeyPairSigner();

  await airdropFactory({ rpc, rpcSubscriptions })({
    recipientAddress: sender.address,
    lamports: lamports(LAMPORTS_PER_SOL),
    commitment: "confirmed",
  });

  return { sender, recipient };
}

async function buildAndSign(sender: GeneratedSigner, recipient: GeneratedSigner) {
  const { value: latestBlockhash } = await rpc.getLatestBlockhash().send();

  const transferInstruction = getTransferSolInstruction({
    source: sender,
    destination: recipient.address,
    amount: lamports(LAMPORTS_PER_SOL / 100n),
  });

  const transactionMessage = pipe(
    createTransactionMessage({ version: 0 }),
    (tx) => setTransactionMessageFeePayerSigner(sender, tx),
    (tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx),
    (tx) => appendTransactionMessageInstructions([transferInstruction], tx),
  );

  return signTransactionMessageWithSigners(transactionMessage);
}

async function main() {
  const { sender, recipient } = await prepareActors();
  const sendAndConfirmTransaction = sendAndConfirmTransactionFactory({
    rpc,
    rpcSubscriptions,
  });

  for (let attempt = 1; attempt <= 2; attempt++) {
    try {
      const signedTransaction = await buildAndSign(sender, recipient);
      const signature = getSignatureFromTransaction(signedTransaction);

      console.log("submitted", signature);
      await sendAndConfirmTransaction(signedTransaction, {
        commitment: "confirmed",
      });
      console.log("confirmed", signature);
      return;
    } catch (e: any) {
      const message = String(e?.message ?? e);
      const retryable =
        message.includes("Blockhash") ||
        message.includes("timeout") ||
        message.includes("expired");

      if (!retryable || attempt === 2) {
        throw e;
      }

      console.log("retrying with a freshly rebuilt transaction...");
    }
  }
}

main().catch((e) => {
  console.error("send/confirm failed", e);
  process.exit(1);
});

Run it with:

pnpm tsx scripts/send-confirm.ts

prepareActors() funds the sender before any transfer is built, so the retry loop is testing send and confirmation behavior instead of failing early for insufficient funds.

That distinction matters.

If the example is going to teach retry policy, it has to survive the first send.

Read The Flow In The Right Order

First, rebuild before every retry

This is the real rule.

A retry is not send the same bytes again forever.

If the blockhash lifetime is stale, the correct retry path is:

  1. fetch a fresh blockhash
  2. rebuild the message
  3. sign again
  4. send again

That is why the helper above calls buildAndSign(sender, recipient) again inside the retry loop.

Then separate submission from confirmation

const signature = getSignatureFromTransaction(signedTransaction);
console.log("submitted", signature);
await sendAndConfirmTransaction(signedTransaction, {
  commitment: "confirmed",
});
console.log("confirmed", signature);

That is the correct distinction.

The signature exists before confirmation.

Confirmation is about network outcome, not identity.

Then keep retry policy explicit

This line matters:

const retryable =
  message.includes("Blockhash") ||
  message.includes("timeout") ||
  message.includes("expired");

The point is not perfect string parsing.

The point is policy.

Some failures deserve rebuild-and-retry.

Some failures do not.

User rejection is not retryable.

Program logic errors are not retryable until inputs or state change.

The Failure Modes To Avoid

Retrying stale signed bytes

That is one of the most common client mistakes.

If the lifetime expired, you need a new message.

Reporting confirmed too early

Users need honest state.

If the app only knows the transaction was submitted, say that.

Do not claim success before the confirmation path finishes.

Treating every error as retryable

That turns real bugs into noisy loops.

Retry only when the failure mode actually supports retry.

What You Can Do After This

You can separate signature, submission, and confirmation correctly, and you can retry only by rebuilding the transaction when the lifetime requires it.

That is the baseline for reliable client-side transaction delivery.

The next lessons build on that for token flows and broader client testing.

Quick Check

Quick Check
Single answer

Why can the client show a transaction signature before the transaction is confirmed?

Quick Check
Single answer

Why is rebuilding the transaction the correct retry path after a blockhash lifetime issue?

Sources

Solana Assistant

AI-powered documentation helper

Welcome to Solana Assistant

Ask specific questions about Solana development:

Ask specific questions for better results400px