Hello! Welcome back to our course on Solana development.
In our last lesson, we designed a Finite State Machine (FSM) to model the lifecycle of a transaction. We defined states like idle, signing, confirming, and success, creating a robust structure for managing complex asynchronous flows. We also included an error state, which served as a catch-all for when things go wrong.
Today, we're going to dive deep into that error state. As a seasoned front-end developer, you know that handling errors gracefully is what separates a prototype from a professional, production-ready application. Our goal for this lesson is to learn how to handle transaction submission errors from the wallet or RPC and display user-friendly feedback. We'll explore the different types of errors you'll encounter and implement specific strategies for catching, interpreting, and presenting them in a way that helps, rather than confuses, the user.
1. The Two Main Categories of Transaction Errors
When a user interacts with your dApp, errors can occur at two primary stages:
- Wallet-Side Errors: These happen on the user's machine, within their wallet software (e.g., Phantom, Solflare), before the transaction is ever broadcast to the network. This includes the user manually rejecting a signature request, the wallet being locked, or a new transaction being requested while another is already pending approval.
- Network-Side Errors: These occur after a signed transaction has been sent to an RPC node. They can be further broken down:
- Simulation Failure: The transaction is deemed invalid during a pre-flight check.
- Execution Failure: The transaction is processed by the network but fails due to a program logic error (e.g., incorrect authority, insufficient funds for the operation). The user still pays the transaction fee.
- Dropped Transaction: The transaction is never included in a block and processed by a validator. This often happens due to network congestion or an expired blockhash.
Let's equip our state machine to handle these scenarios.
2. Handling Wallet-Side Errors
Wallet-side errors are the most straightforward to handle because they are typically well-defined and returned synchronously or within a rejected promise from the wallet adapter's functions.
The Phantom wallet, a popular choice in the Solana ecosystem, provides a standardized set of error codes inspired by Ethereum's EIP-1193. Understanding these codes is key to providing precise feedback.
The official Phantom documentation lists all possible error codes and their meanings. We'll use this as our guide for interpreting wallet errors.
Please read the introduction and review the table of error codes. Pay special attention to codes 4001 (User Rejected Request) and -32002 (Requested resource not available). Then, look at the try-catch example to see the structure of the error object.
As the documentation shows, when a wallet function like signTransaction fails, it throws an error object that contains a code and a message. The most common code you'll encounter is 4001, which means the user clicked "Reject" in the wallet prompt.
Let's update the catch block from our previous lesson's handleTransaction function to specifically handle this case.
// Inside your component's handleTransaction function...
// ... try block
} catch (error: any) {
// Check for a user rejection from the wallet
if (error.code === 4001 || (error.name === 'WalletSignTransactionError' && error.message.includes('User rejected the request'))) {
// The WalletSignTransactionError is a wrapper from @solana/wallet-adapter-base
// The specific 'code' property might not always be present, so checking the name and message is a robust fallback.
send({ type: 'REJECT' });
} else {
// For all other errors, transition to the generic error state
send({ type: 'FAIL', error: error as Error });
}
}
In our FSM from the last lesson, we already had a distinct rejected state. By inspecting the error code, we can now confidently dispatch the correct event (REJECT instead of a generic FAIL), allowing the UI to display a specific message like "Transaction cancelled." instead of a vague "An error occurred."
3. Handling Network-Side Errors
Network-side errors are more complex because they involve the asynchronous and distributed nature of the blockchain. A transaction isn't just "sent"; it's a journey with multiple potential points of failure.
Dropped Transactions and Blockhash Expiration
One of the most common and confusing UX issues on Solana is a dropped transaction. The user signs, the UI shows a loader, but... nothing happens. The transaction never appears on-chain and the user is left wondering if it worked.
This often happens because a transaction's recentBlockhash has a limited lifetime (about 1-2 minutes). If the network is congested or the validator is slow, the transaction might not get processed before its blockhash expires, causing it to be dropped.
The naive approach of await connection.confirmTransaction(signature) can hang indefinitely. A professional dApp must handle this possibility gracefully. The strategy is to poll for the transaction's status while simultaneously checking if its blockhash has become invalid.
Solana Transaction Propagation - Handling Dropped Transactions
The article 'Solana Transaction Propagation - Handling Dropped Transactions' from QuickNode provides an excellent, practical guide on this exact problem.
First, read the section 'Check if a Blockhash is Expired' to understand the isBlockhashExpired function. Then, focus on the code in the section 'Check if the Transaction Succeeded or if Blockhash has Expired'. This demonstrates a while loop that polls for the transaction status and checks for expiration.
Let's integrate this robust confirmation logic into our flow.
First, we need the helper functions described in the article. You can add these to a utility file in your project.
// utils.ts
import { Connection } from '@solana/web3.js';
// Helper to check if a blockhash has expired
export async function isBlockhashExpired(connection: Connection, lastValidBlockHeight: number) {
// The Solana network considers a blockhash expired after 151 blocks.
// See: https://docs.solana.com/proposals/blockhash-expiration
let currentBlockHeight = await connection.getBlockHeight('finalized');
return currentBlockHeight > lastValidBlockHeight;
}
// Helper to introduce a delay
export const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
Now, we can create a more robust confirmation function that replaces the simple connection.confirmTransaction. This function will be responsible for transitioning our state machine.
// In your component or a separate service file
async function confirmTransactionWithPolling(
connection: Connection,
signature: string,
lastValidBlockHeight: number,
sendEvent: (event: TxEvent) => void // Function to send events to our Zustand store
) {
sendEvent({ type: 'CONFIRMATION_UPDATE', confirmation: 'processed' });
let hashExpired = false;
let txSuccess = false;
while (!hashExpired && !txSuccess) {
const { value: statuses } = await connection.getSignatureStatuses([signature]);
const status = statuses?.[0];
if (status) {
if (status.err) {
// On-chain execution error!
console.error('Transaction failed on-chain:', status.err);
sendEvent({ type: 'FAIL', error: new Error('Transaction failed on-chain.') });
return;
}
if (status.confirmationStatus === 'finalized') {
txSuccess = true;
sendEvent({ type: 'FINALIZED' });
console.log('Transaction finalized!');
break;
} else if (status.confirmationStatus === 'confirmed') {
// Still waiting for finalization, but we can update the UI
sendEvent({ type: 'CONFIRMATION_UPDATE', confirmation: 'confirmed' });
}
}
// Check if the blockhash has expired
hashExpired = await isBlockhashExpired(connection, lastValidBlockHeight);
if (hashExpired) {
console.log('Blockhash has expired.');
sendEvent({ type: 'FAIL', error: new Error('Transaction timed out and may have been dropped.') });
break;
}
// Wait before polling again
await sleep(2500);
}
}
We can now call this function from our main handleTransaction logic right after sending the transaction.
On-Chain Execution Errors
What if the transaction is processed but fails? For example, a program constraint is violated, or a required account has insufficient funds. In this case, the status.err property in the polling loop above will be populated.
The status.err object often contains a program-specific error. For an Anchor program, it might look like this: { "InstructionError": [0, { "Custom": 6000 }] }. The number 6000 would correspond to a custom error defined in your smart contract's #[error_code] enum.
On the client side, you would create a mapping to turn this code into a human-readable message.
// Example of an error mapping for your dApp
const ANCHOR_ERROR_MAP: { [key: number]: string } = {
6000: 'Invalid authority provided.',
6001: 'Cannot initialize an account that is already initialized.',
// ... and so on for all your custom program errors
};
function getErrorMessage(error: any): string {
// Wallet errors
if (error.code === 4001) {
return 'Transaction rejected by user.';
}
// Dropped transaction
if (error.message.includes('Transaction timed out')) {
return 'Transaction timed out. Please check a block explorer to confirm its status and try again if necessary.';
}
// On-chain Anchor errors
const anchorError = error.toString().match(/custom program error: 0x([0-9a-f]+)/);
if (anchorError) {
const errorCode = parseInt(anchorError[1], 16);
return ANCHOR_ERROR_MAP[errorCode + 6000] || 'An unknown program error occurred.';
}
return 'An unexpected error occurred. Please try again.';
}
This function acts as a central interpreter, turning cryptic errors into clear UI feedback. You would call this function when transitioning to the error state.
Test your understanding!
A user submits a transaction. Your UI shows a loading spinner. After about a minute, it displays the message: "Transaction timed out."
In another scenario, the user submits a transaction, and the UI immediately shows an error: "Invalid authority provided."
Based on what we've learned, what part of our error-handling logic was likely triggered in each case?
Show answer
-
"Transaction timed out": This error was triggered inside the
confirmTransactionWithPollingfunction. Thewhileloop continued until theisBlockhashExpiredfunction returnedtrue. This indicates a dropped transaction. -
"Invalid authority provided": This error was also triggered inside the
confirmTransactionWithPollingfunction. ThegetSignatureStatusescall returned a status object wherestatus.errwas populated with a custom program error. This indicates an on-chain execution failure. ThegetErrorMessagefunction then mapped the custom error code to the user-friendly string.
4. Updating the UI with User-Friendly Feedback
The final step is to use the rich information in our FSM's error state to render useful UI. Instead of a single, generic error message, your component can now be much more intelligent.
Let's refine the TxState from our last lesson to hold a more descriptive error.
// In your Zustand store definition
type TxState =
// ... other states
| { status: 'error'; message: string }; // Store the user-friendly message directly
And update the send event for FAIL:
// Inside your reducer
if (event.type === 'FAIL') return { state: { status: 'error', message: getErrorMessage(event.error) } };
Now, your React component's render logic becomes very clean:
// Inside your React component
function MyTransactionComponent() {
const { state, send } = useTransactionStore();
// ... handleTransaction logic ...
if (state.status === 'error') {
return (
<div className="error-box">
<h4>Transaction Failed</h4>
<p>{state.message}</p>
<button onClick={() => send({ type: 'RESET' })}>Try Again</button>
</div>
);
}
if (state.status === 'rejected') {
return (
<div className="info-box">
<p>You cancelled the transaction.</p>
<button onClick={() => send({ type: 'RESET' })}>OK</button>
</div>
);
}
// ... render other states (idle, loading, success, etc.)
}
By centralizing error interpretation and storing a clean message in your state, you keep the UI components simple and focused on presentation.
Conclusion
In this lesson, we transformed our basic error state into a powerful tool for creating a professional and user-friendly dApp. You learned to distinguish between different classes of errors and implement specific strategies for each.
Your key takeaways are:
- Categorize Errors: Differentiate between wallet-side errors (like user rejection) and network-side errors (like execution failures or dropped transactions).
- Parse Wallet Errors: Inspect the
errorobject from wallet functions for specific codes (e.g.,4001) to provide precise feedback. - Handle Dropped Transactions: Implement a robust polling mechanism that checks for transaction confirmation while also monitoring for blockhash expiration to avoid an indefinite loading state.
- Interpret On-Chain Errors: Parse the
errobject from a failed transaction status to map program-specific error codes to human-readable messages. - Provide Actionable Feedback: Always tell the user what happened and what they can do next, whether it's trying again, checking a block explorer, or simply acknowledging a cancellation.
You now have a complete, production-grade pattern for managing the entire transaction lifecycle, from submission to finality, including all the messy parts in between.
In our next and final lesson of this module, we'll look at improving the user experience during the confirming phase. We'll learn how to implement optimistic UI updates for a smoother user experience while waiting for transaction finalization, making your dApp feel faster and more responsive.
Can't find a good explanation? Sign up and we'll make it for you
Sign up