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:
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: