19 KiB
Error Handling in Solana Programs
This reference provides comprehensive coverage of error handling patterns for native Rust Solana program development, including custom error types, error propagation, and best practices.
Table of Contents
- Error Handling Fundamentals
- ProgramError
- Custom Error Types
- Error Propagation
- Error Context and Logging
- Client-Side Error Handling
- Best Practices
Error Handling Fundamentals
Why Error Handling Matters
In Solana programs, errors serve multiple purposes:
- Security: Prevent invalid state transitions
- User Experience: Provide meaningful feedback
- Debugging: Identify issues quickly
- Transaction Validation: Fail fast when invariants are violated
Key Principle: Errors should cause the entire transaction to fail and rollback, maintaining atomicity.
The Result Type
All Solana program instructions return ProgramResult:
use solana_program::{
entrypoint::ProgramResult,
program_error::ProgramError,
};
pub type ProgramResult = Result<(), ProgramError>;
// Success
pub fn successful_operation() -> ProgramResult {
Ok(())
}
// Failure
pub fn failed_operation() -> ProgramResult {
Err(ProgramError::Custom(42))
}
When an instruction returns Err:
- Transaction fails immediately
- All state changes rollback
- Error code returned to client
- Transaction fee still charged (for processing cost)
ProgramError
The Built-in Error Type
Solana provides ProgramError enum with common error variants:
use solana_program::program_error::ProgramError;
pub enum ProgramError {
// Common errors
Custom(u32), // Custom error code
InvalidArgument, // Invalid instruction argument
InvalidInstructionData, // Failed to deserialize instruction data
InvalidAccountData, // Invalid account data
AccountDataTooSmall, // Account data too small
InsufficientFunds, // Not enough lamports
IncorrectProgramId, // Wrong program ID
MissingRequiredSignature, // Required signer missing
AccountAlreadyInitialized, // Account already initialized
UninitializedAccount, // Account not initialized
NotEnoughAccountKeys, // Not enough accounts provided
AccountBorrowFailed, // Failed to borrow account data
MaxSeedLengthExceeded, // PDA seed too long
InvalidSeeds, // Invalid PDA derivation
BorshIoError(String), // Borsh serialization error
AccountNotRentExempt, // Account not rent-exempt
IllegalOwner, // Wrong account owner
ArithmeticOverflow, // Arithmetic overflow
// ... and more
}
Common ProgramError Usage
use solana_program::program_error::ProgramError;
pub fn validate_inputs(
amount: u64,
max_amount: u64,
) -> ProgramResult {
// InvalidArgument: Input doesn't meet requirements
if amount == 0 {
return Err(ProgramError::InvalidArgument);
}
// InsufficientFunds: Not enough balance
if amount > max_amount {
return Err(ProgramError::InsufficientFunds);
}
// ArithmeticOverflow: Math operation failed
let _result = amount.checked_mul(2)
.ok_or(ProgramError::ArithmeticOverflow)?;
Ok(())
}
Custom Error Types
Why Custom Errors?
Built-in ProgramError is generic. Custom errors provide:
- Specific error codes for different failure modes
- Better debugging with descriptive messages
- Client clarity - clients know exactly what went wrong
- Documentation - errors serve as API documentation
Defining Custom Errors
Use the thiserror crate to define custom error enums:
use solana_program::program_error::ProgramError;
use thiserror::Error;
#[derive(Error, Debug, Copy, Clone)]
pub enum NoteError {
#[error("You do not own this note")]
Forbidden,
#[error("Note text is too long")]
InvalidLength,
#[error("Rating must be between 1 and 5")]
InvalidRating,
#[error("Note title cannot be empty")]
EmptyTitle,
#[error("Maximum notes limit reached")]
MaxNotesExceeded,
}
Attributes explained:
#[derive(Error)]- Implementsstd::error::Errortrait#[derive(Debug)]- Allows{:?}formatting#[derive(Copy, Clone)]- Makes errors copyable (recommended)#[error("...")]- Error message string
Converting to ProgramError
Implement From<CustomError> for ProgramError:
impl From<NoteError> for ProgramError {
fn from(e: NoteError) -> Self {
ProgramError::Custom(e as u32)
}
}
How it works:
- Custom error is converted to
u32(usingas u32cast) - Wrapped in
ProgramError::Custom(u32) - Returned to client as error code
Error code mapping:
NoteError::Forbidden → ProgramError::Custom(0)
NoteError::InvalidLength → ProgramError::Custom(1)
NoteError::InvalidRating → ProgramError::Custom(2)
NoteError::EmptyTitle → ProgramError::Custom(3)
NoteError::MaxNotesExceeded → ProgramError::Custom(4)
Using Custom Errors
pub fn create_note(
program_id: &Pubkey,
accounts: &[AccountInfo],
title: String,
content: String,
rating: u8,
) -> ProgramResult {
// Validation with custom errors
if title.is_empty() {
return Err(NoteError::EmptyTitle.into());
}
if content.len() > 1000 {
return Err(NoteError::InvalidLength.into());
}
if rating < 1 || rating > 5 {
return Err(NoteError::InvalidRating.into());
}
// Continue processing...
Ok(())
}
The .into() method automatically converts NoteError to ProgramError.
Advanced Custom Error Types
With additional context:
#[derive(Error, Debug)]
pub enum GameError {
#[error("Insufficient mana: have {current}, need {required}")]
InsufficientMana { current: u32, required: u32 },
#[error("Invalid move: {0}")]
InvalidMove(String),
#[error("Player not found: {0}")]
PlayerNotFound(String),
}
Note: Errors with fields cannot derive Copy, only Clone.
Error Propagation
The ? Operator
The ? operator is Rust's error propagation mechanism:
pub fn complex_operation(
accounts: &[AccountInfo],
) -> ProgramResult {
// If validation fails, error is returned immediately
validate_accounts(accounts)?;
// If deserialization fails, error is propagated
let data = AccountData::try_from_slice(&accounts[0].data.borrow())?;
// If checked math fails, ArithmeticOverflow is returned
let result = data.value.checked_add(100)
.ok_or(ProgramError::ArithmeticOverflow)?;
Ok(())
}
What ? does:
- If
ResultisOk(value), unwraps tovalue - If
ResultisErr(e), convertseand returns early - Conversion happens via
Fromtrait
Error Conversion Chain
// Step 1: Borsh deserialization fails
let data = MyData::try_from_slice(bytes)?;
// Returns: Err(std::io::Error)
// Step 2: ? operator converts via From trait
// std::io::Error → ProgramError::BorshIoError
// Step 3: Custom error conversion
return Err(MyError::InvalidData.into());
// MyError → ProgramError::Custom(n)
Manual Error Handling
// Without ?
pub fn manual_error_handling(
account: &AccountInfo,
) -> ProgramResult {
match validate_account(account) {
Ok(()) => {
// Continue processing
}
Err(e) => {
msg!("Validation failed: {:?}", e);
return Err(e);
}
}
Ok(())
}
// With ? (equivalent)
pub fn automatic_error_handling(
account: &AccountInfo,
) -> ProgramResult {
validate_account(account)?;
Ok(())
}
Mapping Errors
Transform one error type to another:
pub fn map_errors(
account: &AccountInfo,
) -> ProgramResult {
// Map generic error to custom error
let data = AccountData::try_from_slice(&account.data.borrow())
.map_err(|_| NoteError::InvalidLength)?;
// Map to different ProgramError variant
let value = data.amount.checked_add(100)
.ok_or(ProgramError::ArithmeticOverflow)?;
Ok(())
}
Combining Multiple Operations
pub fn chain_operations(
accounts: &[AccountInfo],
) -> ProgramResult {
// All operations must succeed or transaction fails
let account1 = validate_and_load_account(&accounts[0])?;
let account2 = validate_and_load_account(&accounts[1])?;
let combined = account1.value
.checked_add(account2.value)
.ok_or(ProgramError::ArithmeticOverflow)?;
update_account(&accounts[2], combined)?;
Ok(())
}
Error Context and Logging
Adding Context with msg!
Use msg! macro to log context before returning errors:
use solana_program::msg;
pub fn transfer_tokens(
from: &AccountInfo,
to: &AccountInfo,
amount: u64,
) -> ProgramResult {
if amount == 0 {
msg!("Transfer amount cannot be zero");
return Err(ProgramError::InvalidArgument);
}
let from_balance = get_balance(from)?;
if from_balance < amount {
msg!("Insufficient balance: have {}, need {}", from_balance, amount);
return Err(ProgramError::InsufficientFunds);
}
// Perform transfer...
Ok(())
}
Logging Best Practices
✅ Good logging:
msg!("Invalid rating: got {}, expected 1-5", rating);
msg!("PDA derivation failed: expected {}, got {}", expected, actual);
msg!("Account {} not owned by program {}", account.key, program_id);
❌ Poor logging:
msg!("Error"); // Not helpful
msg!("Failed"); // What failed?
// (no logging) // Can't debug issues
Conditional Logging
pub fn debug_operation(
account: &AccountInfo,
debug_mode: bool,
) -> ProgramResult {
if debug_mode {
msg!("Processing account: {}", account.key);
msg!("Owner: {}", account.owner);
msg!("Lamports: {}", account.lamports());
}
// Process...
Ok(())
}
Error with Recovery
pub fn try_with_fallback(
accounts: &[AccountInfo],
) -> ProgramResult {
// Try primary method
match process_primary(accounts) {
Ok(()) => {
msg!("Primary method succeeded");
Ok(())
}
Err(e) => {
msg!("Primary method failed: {:?}, trying fallback", e);
// Try fallback
process_fallback(accounts).map_err(|fallback_err| {
msg!("Fallback also failed: {:?}", fallback_err);
fallback_err
})
}
}
}
Client-Side Error Handling
Error Code Interpretation
Client receives:
{
"error": {
"InstructionError": [
0,
{
"Custom": 2
}
]
}
}
Decoding:
- Instruction index:
0(first instruction) - Error type:
Custom - Error code:
2
TypeScript Error Mapping
// Define error codes matching Rust enum
enum NoteError {
Forbidden = 0,
InvalidLength = 1,
InvalidRating = 2,
EmptyTitle = 3,
MaxNotesExceeded = 4,
}
// Error messages
const NOTE_ERROR_MESSAGES = {
[NoteError.Forbidden]: "You do not own this note",
[NoteError.InvalidLength]: "Note text is too long",
[NoteError.InvalidRating]: "Rating must be between 1 and 5",
[NoteError.EmptyTitle]: "Note title cannot be empty",
[NoteError.MaxNotesExceeded]: "Maximum notes limit reached",
};
// Parse error
function parseNoteError(error: any): string {
if (error?.InstructionError) {
const [_, instructionError] = error.InstructionError;
if (instructionError?.Custom !== undefined) {
const errorCode = instructionError.Custom;
return NOTE_ERROR_MESSAGES[errorCode] || `Unknown error: ${errorCode}`;
}
}
return "Transaction failed";
}
// Usage
try {
await program.methods.createNote(title, content, rating).rpc();
} catch (error) {
const message = parseNoteError(error);
console.error(message);
}
Anchor Error Handling
With Anchor framework:
import { AnchorError } from "@coral-xyz/anchor";
try {
await program.methods.createNote(title, content, rating).rpc();
} catch (error) {
if (error instanceof AnchorError) {
console.error("Error code:", error.error.errorCode.code);
console.error("Error message:", error.error.errorMessage);
console.error("Error number:", error.error.errorCode.number);
}
}
Best Practices
1. Fail Fast
Return errors immediately when validation fails:
// ✅ Good - fails fast
pub fn validate_input(rating: u8) -> ProgramResult {
if rating < 1 || rating > 5 {
return Err(NoteError::InvalidRating.into());
}
// Continue only if valid
Ok(())
}
// ❌ Bad - continues with invalid state
pub fn validate_input_bad(rating: u8) -> ProgramResult {
if rating >= 1 && rating <= 5 {
// Valid branch
}
// Continues regardless!
Ok(())
}
2. Meaningful Error Messages
// ✅ Good - specific and actionable
#[error("Username must be 3-20 characters, got {0}")]
InvalidUsernameLength(usize),
#[error("Insufficient mana: need {required}, have {current}")]
InsufficientMana { required: u32, current: u32 },
// ❌ Bad - vague
#[error("Invalid input")]
InvalidInput,
#[error("Error")]
GenericError,
3. Organize Errors by Category
#[derive(Error, Debug, Copy, Clone)]
pub enum GameError {
// Validation errors (0-99)
#[error("Invalid player name")]
InvalidPlayerName,
#[error("Invalid move")]
InvalidMove,
// State errors (100-199)
#[error("Game not started")]
GameNotStarted,
#[error("Game already finished")]
GameFinished,
// Resource errors (200-299)
#[error("Insufficient gold")]
InsufficientGold,
#[error("Inventory full")]
InventoryFull,
}
4. Consistent Error Handling Pattern
pub fn standard_operation_pattern(
program_id: &Pubkey,
accounts: &[AccountInfo],
params: Params,
) -> ProgramResult {
// 1. Parse accounts
let account_info_iter = &mut accounts.iter();
let user = next_account_info(account_info_iter)?;
let data_account = next_account_info(account_info_iter)?;
// 2. Validate signers
if !user.is_signer {
msg!("User must sign the transaction");
return Err(ProgramError::MissingRequiredSignature);
}
// 3. Validate ownership
if data_account.owner != program_id {
msg!("Data account not owned by program");
return Err(ProgramError::IllegalOwner);
}
// 4. Validate input parameters
if params.amount == 0 {
msg!("Amount cannot be zero");
return Err(ProgramError::InvalidArgument);
}
// 5. Load and validate account data
let mut data = AccountData::try_from_slice(&data_account.data.borrow())?;
if !data.is_initialized {
msg!("Account not initialized");
return Err(ProgramError::UninitializedAccount);
}
// 6. Perform operation
// ...
Ok(())
}
5. Document Error Codes
/// Error codes for the Note program.
///
/// | Code | Error | Description |
/// |------|-------|-------------|
/// | 0 | Forbidden | Caller does not own the note |
/// | 1 | InvalidLength | Note text exceeds maximum length |
/// | 2 | InvalidRating | Rating not in range 1-5 |
/// | 3 | EmptyTitle | Note title is empty |
/// | 4 | MaxNotesExceeded | User has reached note limit |
#[derive(Error, Debug, Copy, Clone)]
#[repr(u32)]
pub enum NoteError {
#[error("You do not own this note")]
Forbidden = 0,
#[error("Note text is too long")]
InvalidLength = 1,
#[error("Rating must be between 1 and 5")]
InvalidRating = 2,
#[error("Note title cannot be empty")]
EmptyTitle = 3,
#[error("Maximum notes limit reached")]
MaxNotesExceeded = 4,
}
6. Error Testing
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_invalid_rating() {
let result = validate_rating(0);
assert_eq!(
result.unwrap_err(),
NoteError::InvalidRating.into()
);
let result = validate_rating(6);
assert_eq!(
result.unwrap_err(),
NoteError::InvalidRating.into()
);
}
#[test]
fn test_valid_rating() {
for rating in 1..=5 {
assert!(validate_rating(rating).is_ok());
}
}
}
7. Avoid Silent Failures
// ❌ Bad - errors ignored
pub fn bad_error_handling(accounts: &[AccountInfo]) -> ProgramResult {
let _ = validate_accounts(accounts); // Ignores error!
if let Ok(data) = load_data(accounts) {
process(data); // What if load_data failed?
}
Ok(()) // Returns success even if operations failed!
}
// ✅ Good - errors propagated
pub fn good_error_handling(accounts: &[AccountInfo]) -> ProgramResult {
validate_accounts(accounts)?;
let data = load_data(accounts)?;
process(data)?;
Ok(())
}
Summary
Key Takeaways:
- Always return
ProgramResultfrom instruction handlers - Use custom errors for specific failure modes
- Implement
Fromtrait to convert custom errors toProgramError - Use
?operator for clean error propagation - Add context with
msg!for better debugging - Fail fast - return errors immediately
- Document error codes for client developers
- Test error cases as thoroughly as success cases
Error Handling Pattern:
use solana_program::{
entrypoint::ProgramResult,
program_error::ProgramError,
msg,
};
use thiserror::Error;
// 1. Define custom errors
#[derive(Error, Debug, Copy, Clone)]
pub enum MyError {
#[error("Descriptive error message")]
SpecificError,
}
// 2. Implement From conversion
impl From<MyError> for ProgramError {
fn from(e: MyError) -> Self {
ProgramError::Custom(e as u32)
}
}
// 3. Use in program
pub fn my_instruction(accounts: &[AccountInfo]) -> ProgramResult {
// Validate
if invalid_condition {
msg!("Detailed error context");
return Err(MyError::SpecificError.into());
}
// Propagate errors with ?
let data = load_data(accounts)?;
Ok(())
}
Remember: Good error handling is not optional—it's essential for security, debugging, and user experience.