Skip to content

Module 3: Rust for Solana Development

This module covers Rust fundamentals specifically tailored for Solana program development. We focus on patterns you'll use daily when building on-chain programs.

Learning Objectives

By the end of this module, you will understand:

  • Rust's ownership model and why it matters for Solana
  • Structs, enums, and traits for program state
  • Error handling patterns
  • Macros (derive and attribute)
  • Borsh serialization
  • Testing Rust code
  • Common Solana-specific patterns

1. Why Rust for Solana?

Rust's Guarantees

Feature Benefit for Solana
Memory safety No buffer overflows, null pointers
No garbage collector Predictable compute costs
Zero-cost abstractions High performance
Strong type system Catch bugs at compile time
Fearless concurrency Safe parallel execution

The BPF Target

Solana programs compile to BPF (Berkeley Packet Filter) bytecode:

# Anchor handles this, but under the hood:
cargo build-bpf --manifest-path=Cargo.toml --bpf-out-dir=target/deploy

BPF constraints: - No standard library networking - No filesystem access - Limited floating point (avoid in programs) - Stack size: 4KB - Heap: 32KB

2. Ownership and Borrowing

The Three Rules

// Rule 1: Each value has exactly one owner
let s1 = String::from("hello");  // s1 owns the String
let s2 = s1;                      // Ownership moves to s2
// println!("{}", s1);            // ERROR: s1 no longer valid

// Rule 2: References allow borrowing without ownership
let s1 = String::from("hello");
let len = calculate_length(&s1);  // Borrow s1
println!("{} has length {}", s1, len);  // s1 still valid

fn calculate_length(s: &String) -> usize {
    s.len()
}  // s goes out of scope, but doesn't drop the String

// Rule 3: At any time, you can have EITHER:
//   - One mutable reference, OR
//   - Any number of immutable references
let mut s = String::from("hello");
let r1 = &s;      // OK: immutable borrow
let r2 = &s;      // OK: another immutable borrow
// let r3 = &mut s;  // ERROR: can't borrow mutably while immutable borrows exist

Visual Model

Ownership:
┌─────────────┐
│ Variable s1 │──owns──► "hello" (heap)
└─────────────┘
      │ move
┌─────────────┐
│ Variable s2 │──owns──► "hello" (heap)
└─────────────┘
(s1 is now invalid)

Borrowing:
┌─────────────┐
│ Variable s1 │──owns──► "hello" (heap)
└─────────────┘              ▲
      │                      │
      │ &s1                  │ borrows
      ▼                      │
┌─────────────┐──────────────┘
│ Reference r │
└─────────────┘
(s1 still valid, r can read)

Why This Matters for Solana

In Solana programs, accounts are passed as references:

// Accounts come as references with specific mutability
pub fn process_instruction(
    program_id: &Pubkey,           // Immutable reference
    accounts: &[AccountInfo],      // Slice of account references
    instruction_data: &[u8],       // Immutable data reference
) -> ProgramResult {
    let account = &accounts[0];

    // Mutable borrow of account data
    let mut data = account.data.borrow_mut();
    data[0] = 42;

    Ok(())
}

3. Structs for Program State

Defining State

use borsh::{BorshDeserialize, BorshSerialize};

// Account state structure
#[derive(BorshSerialize, BorshDeserialize, Debug, Clone)]
pub struct UserAccount {
    pub authority: Pubkey,      // 32 bytes
    pub balance: u64,           // 8 bytes
    pub created_at: i64,        // 8 bytes
    pub is_active: bool,        // 1 byte
    pub name: String,           // 4 + len bytes
}

impl UserAccount {
    // Calculate space needed for account
    pub const LEN: usize = 32 + 8 + 8 + 1 + (4 + 32); // 85 bytes (32 char name max)

    pub fn new(authority: Pubkey, name: String) -> Self {
        Self {
            authority,
            balance: 0,
            created_at: Clock::get().unwrap().unix_timestamp,
            is_active: true,
            name,
        }
    }
}

Struct Memory Layout

UserAccount in memory:
┌────────────────────────────────────────────────────────┐
│ authority (32 bytes) │ balance │ created_at │ active │ │
│ [32 bytes Pubkey   ] │ [8 u64] │ [8 i64   ] │ [1]    │ │
├──────────────────────┼─────────┼────────────┼────────┼─┤
│ name_len (4 bytes)   │ name data (variable)          │ │
└────────────────────────────────────────────────────────┘

Associated Functions and Methods

impl UserAccount {
    // Associated function (no self) - like a static method
    pub fn seed() -> &'static [u8] {
        b"user-account"
    }

    // Method (takes &self) - immutable access
    pub fn is_valid(&self) -> bool {
        self.is_active && self.balance > 0
    }

    // Method (takes &mut self) - mutable access
    pub fn deposit(&mut self, amount: u64) -> Result<()> {
        self.balance = self.balance
            .checked_add(amount)
            .ok_or(ErrorCode::Overflow)?;
        Ok(())
    }

    // Method that consumes self
    pub fn close(self) -> u64 {
        self.balance  // Returns balance, self is dropped
    }
}

4. Enums for State Machines

Basic Enums

#[derive(BorshSerialize, BorshDeserialize, Debug, Clone, PartialEq)]
pub enum OrderStatus {
    Pending,
    Active,
    Filled,
    Cancelled,
}

impl Default for OrderStatus {
    fn default() -> Self {
        OrderStatus::Pending
    }
}

Enums with Data

#[derive(BorshSerialize, BorshDeserialize, Debug, Clone)]
pub enum EscrowState {
    // No additional data
    Uninitialized,

    // Holds deposit information
    Deposited {
        amount: u64,
        depositor: Pubkey,
        timestamp: i64,
    },

    // Holds release information
    Released {
        recipient: Pubkey,
        released_at: i64,
    },

    // Dispute with reason
    Disputed(String),
}

impl EscrowState {
    pub fn is_active(&self) -> bool {
        matches!(self, EscrowState::Deposited { .. })
    }
}

Pattern Matching

fn process_escrow(state: &EscrowState) -> Result<()> {
    match state {
        EscrowState::Uninitialized => {
            msg!("Escrow not initialized");
            Err(ErrorCode::NotInitialized.into())
        }
        EscrowState::Deposited { amount, depositor, timestamp } => {
            msg!("Deposit of {} from {} at {}", amount, depositor, timestamp);
            Ok(())
        }
        EscrowState::Released { recipient, released_at } => {
            msg!("Already released to {} at {}", recipient, released_at);
            Err(ErrorCode::AlreadyReleased.into())
        }
        EscrowState::Disputed(reason) => {
            msg!("Disputed: {}", reason);
            Err(ErrorCode::Disputed.into())
        }
    }
}

5. Traits

Defining Traits

// A trait defines shared behavior
pub trait Executable {
    fn execute(&self) -> Result<()>;
    fn validate(&self) -> bool;

    // Default implementation
    fn run(&self) -> Result<()> {
        if !self.validate() {
            return Err(ErrorCode::ValidationFailed.into());
        }
        self.execute()
    }
}

// Implement for your type
impl Executable for UserAccount {
    fn execute(&self) -> Result<()> {
        msg!("Executing for user: {}", self.authority);
        Ok(())
    }

    fn validate(&self) -> bool {
        self.is_active
    }
}

Common Traits You'll Use

#[derive(
    Clone,           // Can be cloned
    Copy,            // Can be copied (for small types)
    Debug,           // Can be printed with {:?}
    Default,         // Has a default value
    PartialEq,       // Can be compared with ==
    Eq,              // Full equality (if PartialEq)
    PartialOrd,      // Can be compared with <, >
    Ord,             // Full ordering
    Hash,            // Can be hashed
)]
pub struct TokenAmount(pub u64);

// Borsh traits for Solana
#[derive(BorshSerialize, BorshDeserialize)]
pub struct OnChainData {
    pub value: u64,
}

Trait Bounds

// Function that accepts any type implementing a trait
fn process<T: Executable>(item: &T) -> Result<()> {
    item.run()
}

// Multiple bounds
fn process_and_display<T: Executable + Debug>(item: &T) -> Result<()> {
    msg!("Processing: {:?}", item);
    item.run()
}

// Where clause (cleaner for complex bounds)
fn complex_process<T, U>(item: &T, other: &U) -> Result<()>
where
    T: Executable + Clone,
    U: Default + Debug,
{
    // ...
    Ok(())
}

6. Error Handling

Result and Option

// Result<T, E> - operation that can fail
fn divide(a: u64, b: u64) -> Result<u64, &'static str> {
    if b == 0 {
        Err("division by zero")
    } else {
        Ok(a / b)
    }
}

// Option<T> - value that might not exist
fn find_user(id: u64) -> Option<UserAccount> {
    if id == 0 {
        None
    } else {
        Some(UserAccount::default())
    }
}

// Using ? operator for propagation
fn process() -> Result<()> {
    let result = divide(10, 2)?;  // Returns early if Err
    let user = find_user(1).ok_or(ErrorCode::UserNotFound)?;
    Ok(())
}

Custom Errors with thiserror

use thiserror::Error;

#[derive(Error, Debug)]
pub enum ProgramError {
    #[error("Insufficient funds: need {required}, have {available}")]
    InsufficientFunds { required: u64, available: u64 },

    #[error("Invalid authority")]
    InvalidAuthority,

    #[error("Account already initialized")]
    AlreadyInitialized,

    #[error("Arithmetic overflow")]
    Overflow,

    #[error("Account not found: {0}")]
    AccountNotFound(String),
}

Anchor Error Handling

use anchor_lang::prelude::*;

#[error_code]
pub enum ErrorCode {
    #[msg("Insufficient funds for this operation")]
    InsufficientFunds,

    #[msg("Invalid authority provided")]
    InvalidAuthority,

    #[msg("Arithmetic overflow occurred")]
    Overflow,

    #[msg("Account is not initialized")]
    NotInitialized,
}

// Using in program
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
    let account = &mut ctx.accounts.user_account;

    require!(
        account.balance >= amount,
        ErrorCode::InsufficientFunds
    );

    account.balance = account.balance
        .checked_sub(amount)
        .ok_or(ErrorCode::Overflow)?;

    Ok(())
}

7. Macros

Derive Macros

// Derive macros generate code at compile time
#[derive(Debug, Clone, BorshSerialize, BorshDeserialize)]
pub struct MyStruct {
    pub value: u64,
}

// Expands roughly to:
impl std::fmt::Debug for MyStruct { /* ... */ }
impl Clone for MyStruct { /* ... */ }
impl BorshSerialize for MyStruct { /* ... */ }
impl BorshDeserialize for MyStruct { /* ... */ }

Attribute Macros (Anchor)

use anchor_lang::prelude::*;

// #[program] marks this module as a Solana program
#[program]
pub mod my_program {
    use super::*;

    // #[account] marks this as an on-chain account type
    #[account]
    pub struct Counter {
        pub count: u64,
    }

    // Function is an instruction handler
    pub fn increment(ctx: Context<Increment>) -> Result<()> {
        ctx.accounts.counter.count += 1;
        Ok(())
    }
}

// #[derive(Accounts)] generates account validation
#[derive(Accounts)]
pub struct Increment<'info> {
    #[account(mut)]
    pub counter: Account<'info, Counter>,
}

Common Anchor Macros

Macro Purpose
#[program] Marks the program module
#[account] Defines an account type
#[derive(Accounts)] Generates account validation
#[instruction(...)] Access instruction data in Accounts struct
#[error_code] Defines custom errors

8. Borsh Serialization

Why Borsh?

Feature Borsh JSON Bincode
Size Compact Large Compact
Speed Fast Slow Fast
Deterministic Yes No No
Schema Yes No No

Solana uses Borsh because it's deterministic - same data always produces same bytes.

Borsh in Practice

use borsh::{BorshDeserialize, BorshSerialize};

#[derive(BorshSerialize, BorshDeserialize)]
pub struct GameState {
    pub player: Pubkey,       // 32 bytes, fixed
    pub score: u64,           // 8 bytes, fixed
    pub level: u8,            // 1 byte, fixed
    pub achievements: Vec<u8>, // 4 + n bytes, variable
}

// Serialization
let state = GameState { /* ... */ };
let bytes: Vec<u8> = state.try_to_vec()?;

// Deserialization
let state: GameState = GameState::try_from_slice(&bytes)?;

// Calculate space
impl GameState {
    pub const BASE_LEN: usize = 32 + 8 + 1 + 4; // Fixed fields
    pub fn space(achievements_count: usize) -> usize {
        Self::BASE_LEN + achievements_count
    }
}

Borsh Encoding Rules

u8, i8:       1 byte
u16, i16:     2 bytes (little-endian)
u32, i32:     4 bytes (little-endian)
u64, i64:     8 bytes (little-endian)
u128, i128:   16 bytes (little-endian)
bool:         1 byte (0 or 1)
Pubkey:       32 bytes
String:       4 bytes length + UTF-8 bytes
Vec<T>:       4 bytes length + T * count
Option<T>:    1 byte tag + T (if Some)
Enum:         1 byte variant + variant data

9. Testing Rust Code

Unit Tests

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_user_account_creation() {
        let authority = Pubkey::new_unique();
        let account = UserAccount::new(authority, "Alice".to_string());

        assert_eq!(account.authority, authority);
        assert_eq!(account.balance, 0);
        assert!(account.is_active);
        assert_eq!(account.name, "Alice");
    }

    #[test]
    fn test_deposit() {
        let mut account = UserAccount::new(Pubkey::new_unique(), "Bob".to_string());

        account.deposit(100).unwrap();
        assert_eq!(account.balance, 100);

        account.deposit(50).unwrap();
        assert_eq!(account.balance, 150);
    }

    #[test]
    fn test_deposit_overflow() {
        let mut account = UserAccount::new(Pubkey::new_unique(), "Charlie".to_string());
        account.balance = u64::MAX;

        let result = account.deposit(1);
        assert!(result.is_err());
    }

    #[test]
    #[should_panic(expected = "assertion failed")]
    fn test_panic_case() {
        assert!(false);
    }
}

Running Tests

# Run all tests
cargo test

# Run specific test
cargo test test_deposit

# Run tests with output
cargo test -- --nocapture

# Run tests in specific module
cargo test user_account::tests

Integration Tests (For Anchor)

// tests/my_program.ts (TypeScript with Anchor)
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { MyProgram } from "../target/types/my_program";
import { expect } from "chai";

describe("my_program", () => {
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);

  const program = anchor.workspace.MyProgram as Program<MyProgram>;

  it("Initializes counter", async () => {
    const counter = anchor.web3.Keypair.generate();

    await program.methods
      .initialize()
      .accounts({
        counter: counter.publicKey,
        user: provider.wallet.publicKey,
        systemProgram: anchor.web3.SystemProgram.programId,
      })
      .signers([counter])
      .rpc();

    const account = await program.account.counter.fetch(counter.publicKey);
    expect(account.count.toNumber()).to.equal(0);
  });
});

10. Common Solana Patterns

Checked Math

// ALWAYS use checked math to prevent overflow/underflow
let a: u64 = 100;
let b: u64 = 50;

// Bad: can panic or wrap
let sum = a + b;

// Good: returns Option
let sum = a.checked_add(b).ok_or(ErrorCode::Overflow)?;
let diff = a.checked_sub(b).ok_or(ErrorCode::Underflow)?;
let product = a.checked_mul(b).ok_or(ErrorCode::Overflow)?;
let quotient = a.checked_div(b).ok_or(ErrorCode::DivisionByZero)?;

Account Validation

// Verify account ownership
require!(
    account.owner == expected_owner,
    ErrorCode::InvalidOwner
);

// Verify account is signer
require!(
    account.is_signer,
    ErrorCode::MissingSignature
);

// Verify account is writable
require!(
    account.is_writable,
    ErrorCode::AccountNotWritable
);

// Verify PDA
let (expected_pda, bump) = Pubkey::find_program_address(
    &[b"seed", user.key().as_ref()],
    program_id
);
require!(
    account.key() == expected_pda,
    ErrorCode::InvalidPDA
);

Safe Account Access

// Borrow account data safely
let data = account.try_borrow_data()?;
let mut data = account.try_borrow_mut_data()?;

// Deserialize account data
let state: MyState = MyState::try_from_slice(&data)?;

// Serialize back
state.serialize(&mut *data)?;

Invoke and Invoke Signed

use solana_program::program::invoke;
use solana_program::program::invoke_signed;

// Regular invoke (for user-signed transactions)
invoke(
    &transfer_instruction,
    &[from_account.clone(), to_account.clone()],
)?;

// Invoke signed (for PDA-signed transactions)
let seeds = &[b"vault", mint.key().as_ref(), &[bump]];
invoke_signed(
    &transfer_instruction,
    &[vault_account.clone(), to_account.clone()],
    &[seeds],
)?;

Interactive: Rust Playground

Exercise 1: Ownership Practice

Open Rust Playground and try:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;  // What happens here?
    // println!("{}", s1);  // Uncomment - what error?
    println!("{}", s2);

    let s3 = String::from("world");
    let s4 = &s3;  // Borrow instead
    println!("{} {}", s3, s4);  // Both work!
}

Exercise 2: Implement a Token Account

use std::fmt;

#[derive(Debug, Clone)]
struct TokenAccount {
    mint: [u8; 32],
    owner: [u8; 32],
    amount: u64,
}

impl TokenAccount {
    fn new(mint: [u8; 32], owner: [u8; 32]) -> Self {
        Self { mint, owner, amount: 0 }
    }

    fn deposit(&mut self, amount: u64) -> Result<(), &'static str> {
        self.amount = self.amount
            .checked_add(amount)
            .ok_or("overflow")?;
        Ok(())
    }

    fn withdraw(&mut self, amount: u64) -> Result<(), &'static str> {
        self.amount = self.amount
            .checked_sub(amount)
            .ok_or("insufficient funds")?;
        Ok(())
    }
}

fn main() {
    let mut account = TokenAccount::new([1; 32], [2; 32]);
    account.deposit(100).unwrap();
    account.withdraw(30).unwrap();
    println!("Balance: {}", account.amount);
}

Summary

Concept Key Pattern
Ownership Values have one owner; use references to borrow
Structs Define account state with #[derive(BorshSerialize, BorshDeserialize)]
Enums Model state machines with pattern matching
Traits Define shared behavior; implement for your types
Errors Use Result<T, E> and ? for propagation
Macros Derive traits; Anchor uses attribute macros
Borsh Deterministic serialization for on-chain data
Testing Unit tests with #[test], integration with Anchor
Patterns Checked math, account validation, invoke_signed

Self-Assessment

Why does Rust's ownership system matter for Solana programs?

It prevents common bugs like use-after-free, double-free, and data races at compile time. For Solana, this means programs can't accidentally corrupt account data or cause undefined behavior, which is critical for financial applications.

When would you use invoke_signed vs invoke?

Use invoke when calling another program with accounts that the user signed for. Use invoke_signed when your program needs to "sign" for a PDA it owns - you provide the seeds that derive the PDA, and the runtime verifies your program derived that address.

Why use Borsh instead of JSON for Solana accounts?

Borsh is deterministic (same data = same bytes), compact, and fast. JSON has variable formatting, is verbose, and slower to parse. Since every byte costs rent and every operation costs compute, Borsh is far more efficient for on-chain storage.

Next Steps

Now you understand Rust patterns for Solana. Let's see how Anchor makes development easier:

Module 4: Anchor Framework


Further Reading