Skip to content

${redev}

Published 12/4/2025 · 7 min read

Tags: solana , javascript , web3 , errors , transactions

Solana transactions fail. Networks are unreliable. Users reject signatures. Let’s build robust error handling.

Common Error Types

1. User Rejection

User clicked “Reject” in their wallet:

try {
  const signedTx = await wallet.signTransaction(tx);
} catch (err) {
  if (err.message.includes("User rejected")) {
    // Don't retry - user said no
    return { error: "Transaction cancelled" };
  }
  throw err;
}

2. Insufficient Funds

Not enough SOL for fees or not enough tokens:

try {
  await sendTransaction(tx);
} catch (err) {
  if (
    err.message.includes("insufficient funds") ||
    err.message.includes("Insufficient")
  ) {
    return { error: "Insufficient balance" };
  }
  throw err;
}

3. Blockhash Expired

Transaction took too long:

try {
  await sendTransaction(tx);
} catch (err) {
  if (
    err.message.includes("block height exceeded") ||
    err.message.includes("Blockhash not found")
  ) {
    // Rebuild with fresh blockhash and retry
    return await retryWithFreshBlockhash();
  }
  throw err;
}

4. Network Errors

RPC node unreachable:

try {
  await connection.getLatestBlockhash();
} catch (err) {
  if (
    err.message.includes("fetch failed") ||
    err.message.includes("NetworkError")
  ) {
    return { error: "Network unavailable. Please try again." };
  }
  throw err;
}

A Robust Transaction Function

Here’s a pattern for reliable transaction sending:

// src/lib/utils/transactions.ts
import {
  Transaction,
  Connection,
  TransactionSignature,
  Commitment,
} from "@solana/web3.js";

interface TransactionResult {
  success: boolean;
  signature?: TransactionSignature;
  error?: string;
}

export async function sendAndConfirmWithRetry(
  connection: Connection,
  transaction: Transaction,
  signTransaction: (tx: Transaction) => Promise<Transaction>,
  options: {
    maxRetries?: number;
    commitment?: Commitment;
  } = {}
): Promise<TransactionResult> {
  const { maxRetries = 3, commitment = "confirmed" } = options;

  let lastError: Error | null = null;

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      // Always get fresh blockhash
      const { blockhash, lastValidBlockHeight } =
        await connection.getLatestBlockhash(commitment);

      transaction.recentBlockhash = blockhash;

      // Sign
      const signedTx = await signTransaction(transaction);

      // Send
      const signature = await connection.sendRawTransaction(
        signedTx.serialize(),
        {
          skipPreflight: false,
          preflightCommitment: commitment,
        }
      );

      // Confirm with timeout
      const confirmation = await connection.confirmTransaction(
        {
          signature,
          blockhash,
          lastValidBlockHeight,
        },
        commitment
      );

      if (confirmation.value.err) {
        throw new Error(
          `Transaction failed: ${JSON.stringify(confirmation.value.err)}`
        );
      }

      return { success: true, signature };
    } catch (err) {
      lastError = err as Error;

      // Don't retry user rejections
      if (lastError.message.includes("User rejected")) {
        return { success: false, error: "Transaction cancelled" };
      }

      // Don't retry insufficient funds
      if (lastError.message.includes("insufficient")) {
        return { success: false, error: "Insufficient balance" };
      }

      // Retry on blockhash/network errors
      if (attempt < maxRetries - 1) {
        console.log(`Attempt ${attempt + 1} failed, retrying...`);
        await sleep(1000 * (attempt + 1)); // Exponential backoff
        continue;
      }
    }
  }

  return {
    success: false,
    error: lastError?.message || "Transaction failed",
  };
}

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

Simulating Before Sending

Always simulate to catch errors before bothering the user:

async function simulateTransaction(
  connection: Connection,
  transaction: Transaction
): Promise<{ success: boolean; error?: string; logs?: string[] }> {
  try {
    const simulation = await connection.simulateTransaction(transaction);

    if (simulation.value.err) {
      // Parse the error
      const errorMessage = parseSimulationError(simulation.value.err);
      return {
        success: false,
        error: errorMessage,
        logs: simulation.value.logs ?? undefined,
      };
    }

    return {
      success: true,
      logs: simulation.value.logs ?? undefined,
    };
  } catch (err) {
    return {
      success: false,
      error: (err as Error).message,
    };
  }
}

function parseSimulationError(err: any): string {
  if (typeof err === "string") return err;

  // Common Solana program errors
  if (err.InstructionError) {
    const [index, instructionError] = err.InstructionError;

    if (typeof instructionError === "object") {
      if (instructionError.Custom !== undefined) {
        return `Program error ${instructionError.Custom} at instruction ${index}`;
      }
    }

    return `Instruction ${index} failed: ${JSON.stringify(instructionError)}`;
  }

  return JSON.stringify(err);
}

Error Display Component

A Svelte component for user-friendly error display:

<!-- src/lib/components/TransactionStatus.svelte -->
<script lang="ts">
  export let status: 'idle' | 'signing' | 'sending' | 'confirming' | 'success' | 'error';
  export let signature: string | null = null;
  export let error: string | null = null;

  const messages = {
    idle: '',
    signing: 'Please approve in your wallet...',
    sending: 'Sending transaction...',
    confirming: 'Confirming on chain...',
    success: 'Transaction confirmed!',
    error: 'Transaction failed'
  };
</script>

{#if status !== 'idle'}
  <div class="status" class:success={status === 'success'} class:error={status === 'error'}>
    <div class="message">
      {#if status === 'signing' || status === 'sending' || status === 'confirming'}
        <span class="spinner"></span>
      {/if}

      {#if status === 'error' && error}
        {error}
      {:else}
        {messages[status]}
      {/if}
    </div>

    {#if status === 'success' && signature}
      <a
        href={`https://explorer.solana.com/tx/${signature}?cluster=devnet`}
        target="_blank"
        class="link"
      >
        View on Explorer →
      </a>
    {/if}
  </div>
{/if}

<style>
  .status {
    padding: 1rem;
    border-radius: 0.5rem;
    background: #1a1a2e;
    border: 1px solid #333;
  }

  .status.success {
    border-color: #14F195;
    background: rgba(20, 241, 149, 0.1);
  }

  .status.error {
    border-color: #f87171;
    background: rgba(248, 113, 113, 0.1);
  }

  .message {
    display: flex;
    align-items: center;
    gap: 0.75rem;
  }

  .spinner {
    width: 1rem;
    height: 1rem;
    border: 2px solid #333;
    border-top-color: #14F195;
    border-radius: 50%;
    animation: spin 1s linear infinite;
  }

  @keyframes spin {
    to { transform: rotate(360deg); }
  }

  .link {
    display: block;
    margin-top: 0.75rem;
    color: #9945FF;
    text-decoration: none;
  }

  .link:hover {
    text-decoration: underline;
  }
</style>

Transaction Hook Pattern

Combine everything into a reusable pattern:

// src/lib/stores/transaction.ts
import { writable, get } from "svelte/store";
import { connection, wallet } from "./wallet";
import type { Transaction } from "@solana/web3.js";

type TxStatus =
  | "idle"
  | "signing"
  | "sending"
  | "confirming"
  | "success"
  | "error";

interface TransactionState {
  status: TxStatus;
  signature: string | null;
  error: string | null;
}

export function createTransactionStore() {
  const { subscribe, set, update } = writable<TransactionState>({
    status: "idle",
    signature: null,
    error: null,
  });

  return {
    subscribe,

    reset() {
      set({ status: "idle", signature: null, error: null });
    },

    async send(transaction: Transaction): Promise<boolean> {
      const $wallet = get(wallet);
      const $connection = get(connection);

      if (!$wallet.wallet || !$wallet.publicKey) {
        set({
          status: "error",
          signature: null,
          error: "Wallet not connected",
        });
        return false;
      }

      try {
        // Get blockhash
        const { blockhash, lastValidBlockHeight } =
          await $connection.getLatestBlockhash("confirmed");

        transaction.recentBlockhash = blockhash;
        transaction.feePayer = $wallet.publicKey;

        // Simulate first
        const simulation = await $connection.simulateTransaction(transaction);
        if (simulation.value.err) {
          throw new Error(
            `Simulation failed: ${JSON.stringify(simulation.value.err)}`
          );
        }

        // Sign
        set({ status: "signing", signature: null, error: null });
        const signedTx = await $wallet.wallet.signTransaction(transaction);

        // Send
        set({ status: "sending", signature: null, error: null });
        const signature = await $connection.sendRawTransaction(
          signedTx.serialize(),
          { skipPreflight: true }
        );

        // Confirm
        set({ status: "confirming", signature, error: null });
        const confirmation = await $connection.confirmTransaction(
          { signature, blockhash, lastValidBlockHeight },
          "confirmed"
        );

        if (confirmation.value.err) {
          throw new Error("Transaction failed on chain");
        }

        set({ status: "success", signature, error: null });
        return true;
      } catch (err) {
        const error = (err as Error).message;

        // User-friendly error messages
        let friendlyError = error;
        if (error.includes("User rejected")) {
          friendlyError = "Transaction cancelled";
        } else if (error.includes("insufficient")) {
          friendlyError = "Insufficient balance";
        } else if (error.includes("block height")) {
          friendlyError = "Transaction expired. Please try again.";
        }

        set({ status: "error", signature: null, error: friendlyError });
        return false;
      }
    },
  };
}

export const transaction = createTransactionStore();

Using the Transaction Store

<script lang="ts">
  import { transaction } from '$lib/stores/transaction';
  import TransactionStatus from '$lib/components/TransactionStatus.svelte';
  import { Transaction, SystemProgram, LAMPORTS_PER_SOL } from '@solana/web3.js';
  import { wallet } from '$lib/stores/wallet';

  async function sendSol() {
    transaction.reset();

    const tx = new Transaction().add(
      SystemProgram.transfer({
        fromPubkey: $wallet.publicKey!,
        toPubkey: recipientPubkey,
        lamports: 0.1 * LAMPORTS_PER_SOL
      })
    );

    const success = await transaction.send(tx);

    if (success) {
      // Refresh balances, show success, etc.
    }
  }
</script>

<button on:click={sendSol} disabled={$transaction.status !== 'idle'}>
  Send 0.1 SOL
</button>

<TransactionStatus
  status={$transaction.status}
  signature={$transaction.signature}
  error={$transaction.error}
/>

What You Learned

  • Common Solana error types and how to handle them
  • Simulating transactions before sending
  • Retry patterns with exponential backoff
  • User-friendly error messages
  • Status tracking through the transaction lifecycle
  • Building reusable transaction utilities

Next Up

We’ve covered the foundations. Now we bring it all together with x402 - building pay-per-request APIs and clients.

Related Articles

  • Understanding requestAnimationFrame

    A practical guide to browser animation timing. Learn what requestAnimationFrame actually does, why it beats setInterval, and how to use it properly.

  • Lambda Expressions vs Anonymous Functions

    When learning a functional programming style you will often come across the term Lambda Expressions or Lambda Functions. In simple terms they are just functions that can be used as data and therefore declared as a value. Let's explore a few examples.

  • JavaScript typeof Number

    Often you will need to check that you have a number before using it in your JavaScript, here's how.