Rust Program Structure
Solana programs written in Rust have minimal structural requirements, allowing
for flexibility in how code is organized. The only requirement is that a program
must have an entrypoint
, which defines where the execution of a program
begins.
Program Structure
While there are no strict rules for file structure, Solana programs typically follow a common pattern:
entrypoint.rs
: Defines the entrypoint that routes incoming instructions.state.rs
: Defines program state (account data).instructions.rs
: Defines the instructions that the program can execute.processor.rs
: Defines the instruction handlers (functions) that implement the business logic for each instruction.error.rs
: Defines custom errors that the program can return.
For example, see the Token Program.
Example Program
To demonstrate how to build a native Rust program with multiple instructions, we'll walk through a simple counter program that implements two instructions:
InitializeCounter
: Creates and initializes a new account with an initial value.IncrementCounter
: Increments the value stored in an existing account.
For simplicity, the program will be implemented in a single lib.rs
file,
though in practice you may want to split larger programs into multiple files.
Part 1: Writing the Program
Let's start by building the counter program. We'll create a program that can initialize a counter with a starting value and increment it.
Create a new program
First, let's create a new Rust project for our Solana program.
$cargo new counter_program --lib$cd counter_program
You should see the default src/lib.rs
and Cargo.toml
files.
Update the edition
field in Cargo.toml
to 2021. Otherwise, you might
encounter an error when building the program.
Add dependencies
Now let's add the necessary dependencies for building a Solana program. We need
solana-program
for the core SDK and borsh
for serialization.
$cargo add solana-program@2.2.0$cargo add borsh
There is no requirement to use Borsh. However, it is a commonly used serialization library for Solana programs.
Configure crate-type
Solana programs must be compiled as dynamic libraries. Add the [lib]
section
to configure how Cargo builds the program.
[lib]crate-type = ["cdylib", "lib"]
If you don't include this config, the target/deploy directory will not be generated when you build the program.
Setup program entrypoint
Every Solana program has an entrypoint, which is the function that gets called when the program is invoked. Let's start with adding the imports we'll need for the program and setting up the entrypoint.
Add the following code to lib.rs
:
use borsh::{BorshDeserialize, BorshSerialize};use solana_program::{account_info::{next_account_info, AccountInfo},entrypoint,entrypoint::ProgramResult,msg,program::invoke,program_error::ProgramError,pubkey::Pubkey,system_instruction,sysvar::{rent::Rent, Sysvar},};entrypoint!(process_instruction);pub fn process_instruction(program_id: &Pubkey,accounts: &[AccountInfo],instruction_data: &[u8],) -> ProgramResult {Ok(())}
The
entrypoint
macro handles the deserialization of the input
data into the parameters of the
process_instruction
function.
A Solana program entrypoint
has the following function signature. Developers
are free to create their own implementation of the entrypoint
function.
#[no_mangle]pub unsafe extern "C" fn entrypoint(input: *mut u8) -> u64;
Define program state
Now let's define the data structure that will be stored in our counter accounts.
This is the data that will be stored in the data
field of the account.
Add the following code to lib.rs
:
#[derive(BorshSerialize, BorshDeserialize, Debug)]pub struct CounterAccount {pub count: u64,}
Define instruction enum
Let's define the instructions our program can execute. We'll use an enum where each variant represents a different instruction.
Add the following code to lib.rs
:
#[derive(BorshSerialize, BorshDeserialize, Debug)]pub enum CounterInstruction {InitializeCounter { initial_value: u64 },IncrementCounter,}
Implement instruction deserialization
Now we need to deserialize the instruction_data
(raw bytes) into one of our
CounterInstruction
enum variants. The Borsh try_from_slice
method handles
this conversion automatically.
Update the process_instruction
function to use Borsh deserialization:
pub fn process_instruction(program_id: &Pubkey,accounts: &[AccountInfo],instruction_data: &[u8],) -> ProgramResult {let instruction = CounterInstruction::try_from_slice(instruction_data).map_err(|_| ProgramError::InvalidInstructionData)?;Ok(())}
Route instructions to handlers
Now let's update the main process_instruction
function to route instructions
to their appropriate handler functions.
This routing pattern is common in Solana programs. The instruction_data
is
deserialized into a variant of an enum representing the instruction, then the
appropriate handler function is called. Each handler function includes the
implementation for that instruction.
Add the following code to lib.rs
updating the process_instruction
function
and adding the handlers for the InitializeCounter
and IncrementCounter
instructions:
pub fn process_instruction(program_id: &Pubkey,accounts: &[AccountInfo],instruction_data: &[u8],) -> ProgramResult {let instruction = CounterInstruction::try_from_slice(instruction_data).map_err(|_| ProgramError::InvalidInstructionData)?;match instruction {CounterInstruction::InitializeCounter { initial_value } => {process_initialize_counter(program_id, accounts, initial_value)?}CounterInstruction::IncrementCounter => {process_increment_counter(program_id, accounts)?}};Ok(())}fn process_initialize_counter(program_id: &Pubkey,accounts: &[AccountInfo],initial_value: u64,) -> ProgramResult {Ok(())}fn process_increment_counter(program_id: &Pubkey,accounts: &[AccountInfo],) -> ProgramResult {Ok(())}
Implement initialize handler
Let's implement the handler to create and initialize a new counter account. Since only the System Program can create accounts on Solana, we'll use a Cross Program Invocation (CPI), essentially calling another program from our program.
Our program makes a CPI to call the System Program's create_account
instruction. The new account is created with our program as the owner, giving
our program the ability to write to the account and initialize the data.
Add the following code to lib.rs
updating the process_initialize_counter
function:
fn process_initialize_counter(program_id: &Pubkey,accounts: &[AccountInfo],initial_value: u64,) -> ProgramResult {let accounts_iter = &mut accounts.iter();let counter_account = next_account_info(accounts_iter)?;let payer_account = next_account_info(accounts_iter)?;let system_program = next_account_info(accounts_iter)?;let account_space = 8;let rent = Rent::get()?;let required_lamports = rent.minimum_balance(account_space);invoke(&system_instruction::create_account(payer_account.key,counter_account.key,required_lamports,account_space as u64,program_id,),&[payer_account.clone(),counter_account.clone(),system_program.clone(),],)?;let counter_data = CounterAccount {count: initial_value,};let mut account_data = &mut counter_account.data.borrow_mut()[..];counter_data.serialize(&mut account_data)?;msg!("Counter initialized with value: {}", initial_value);Ok(())}
This instruction is for demonstration purposes only. It does not include security and validation checks that are required for production programs.
Implement increment handler
Now let's implement the handler that increments an existing counter. This instruction:
- Reads the account
data
field for thecounter_account
- Deserializes it into a
CounterAccount
struct - Increments the
count
field by 1 - Serializes the
CounterAccount
struct back into the account'sdata
field
Add the following code to lib.rs
updating the process_increment_counter
function:
fn process_increment_counter(program_id: &Pubkey,accounts: &[AccountInfo],) -> ProgramResult {let accounts_iter = &mut accounts.iter();let counter_account = next_account_info(accounts_iter)?;if counter_account.owner != program_id {return Err(ProgramError::IncorrectProgramId);}let mut data = counter_account.data.borrow_mut();let mut counter_data: CounterAccount = CounterAccount::try_from_slice(&data)?;counter_data.count = counter_data.count.checked_add(1).ok_or(ProgramError::InvalidAccountData)?;counter_data.serialize(&mut &mut data[..])?;msg!("Counter incremented to: {}", counter_data.count);Ok(())}
This instruction is for demonstration purposes only. It does not include security and validation checks that are required for production programs.
Completed Program
Congratulations! You've built a complete Solana program that demonstrates the basic structure shared by all Solana programs:
- Entrypoint: Defines where program execution begins and routes all incoming requests to appropriate instruction handlers
- Instruction Handling: Defines instructions and their associated handlers functions
- State Management: Defines account data structures and manages their state in program owned accounts
- Cross Program Invocation (CPI): Calls the System Program to create new program owned accounts
The next step is to test the program to ensure everything works correctly.
Part 2: Testing the Program
Now let's test our counter program. We'll use LiteSVM, a testing framework that lets us test programs without deploying to a cluster.
Add test dependencies
First, let's add the dependencies needed for testing. We'll use litesvm
for
testing and solana-sdk
.
$cargo add litesvm@0.6.1 --dev$cargo add solana-sdk@2.2.0 --dev
Create test module
Now let's add a test module to our program. We'll start with the basic scaffold and imports.
Add the following code to lib.rs
, directly below the program code:
#[cfg(test)]mod test {use super::*;use litesvm::LiteSVM;use solana_sdk::{account::ReadableAccount,instruction::{AccountMeta, Instruction},message::Message,signature::{Keypair, Signer},system_program,transaction::Transaction,};#[test]fn test_counter_program() {// Test implementation will go here}}
The #[cfg(test)]
attribute ensures this code is only compiled when running
tests.
Initialize test environment
Let's set up the test environment with LiteSVM and fund a payer account.
LiteSVM simulates the Solana runtime environment, allowing us to test our program without deploying to a real cluster.
Add the following code to lib.rs
updating the test_counter_program
function:
let mut svm = LiteSVM::new();let payer = Keypair::new();svm.airdrop(&payer.pubkey(), 1_000_000_000).expect("Failed to airdrop");
Load the program
Now we need to build and load our program into the test environment. Run the
cargo build-sbf
command to build the program. This will generate the
counter_program.so
file in the target/deploy
directory.
$cargo build-sbf
Ensure the edition
in Cargo.toml
is set to 2021
.
After building, we can load the program.
Update the test_counter_program
function to load the program into the test
environment.
let program_keypair = Keypair::new();let program_id = program_keypair.pubkey();svm.add_program_from_file(program_id,"target/deploy/counter_program.so").expect("Failed to load program");
You must run cargo build-sbf
before running tests to generate the .so
file. The test loads the compiled program.
Test initialization instruction
Let's test the initialization instruction by creating a new counter account with a starting value.
Add the following code to lib.rs
updating the test_counter_program
function:
let counter_keypair = Keypair::new();let initial_value: u64 = 42;println!("Testing counter initialization...");let init_instruction_data =borsh::to_vec(&CounterInstruction::InitializeCounter { initial_value }).expect("Failed to serialize instruction");let initialize_instruction = Instruction::new_with_bytes(program_id,&init_instruction_data,vec![AccountMeta::new(counter_keypair.pubkey(), true),AccountMeta::new(payer.pubkey(), true),AccountMeta::new_readonly(system_program::id(), false),],);let message = Message::new(&[initialize_instruction], Some(&payer.pubkey()));let transaction = Transaction::new(&[&payer, &counter_keypair],message,svm.latest_blockhash());let result = svm.send_transaction(transaction);assert!(result.is_ok(), "Initialize transaction should succeed");let logs = result.unwrap().logs;println!("Transaction logs:\n{:#?}", logs);
Verify initialization
After initialization, let's verify the counter account was created correctly with the expected value.
Add the following code to lib.rs
updating the test_counter_program
function:
let account = svm.get_account(&counter_keypair.pubkey()).expect("Failed to get counter account");let counter: CounterAccount = CounterAccount::try_from_slice(account.data()).expect("Failed to deserialize counter data");assert_eq!(counter.count, 42);println!("Counter initialized successfully with value: {}", counter.count);
Test increment instruction
Now let's test the increment instruction to ensure it properly updates the counter value.
Add the following code to lib.rs
updating the test_counter_program
function:
println!("Testing counter increment...");let increment_instruction_data =borsh::to_vec(&CounterInstruction::IncrementCounter).expect("Failed to serialize instruction");let increment_instruction = Instruction::new_with_bytes(program_id,&increment_instruction_data,vec![AccountMeta::new(counter_keypair.pubkey(), true)],);let message = Message::new(&[increment_instruction], Some(&payer.pubkey()));let transaction = Transaction::new(&[&payer, &counter_keypair],message,svm.latest_blockhash());let result = svm.send_transaction(transaction);assert!(result.is_ok(), "Increment transaction should succeed");let logs = result.unwrap().logs;println!("Transaction logs:\n{:#?}", logs);
Verify final results
Finally, let's verify that the increment worked correctly by checking the updated counter value.
Add the following code to lib.rs
updating the test_counter_program
function:
let account = svm.get_account(&counter_keypair.pubkey()).expect("Failed to get counter account");let counter: CounterAccount = CounterAccount::try_from_slice(account.data()).expect("Failed to deserialize counter data");assert_eq!(counter.count, 43);println!("Counter incremented successfully to: {}", counter.count);
Run the tests with the following command. The --nocapture
flag prints the
output of the test.
$cargo test -- --nocapture
Expected output:
Testing counter initialization...Transaction logs:["Program 3QpyHXhFtYY32iY7foF3EjkVdCDrUppADk9aDwSWn6Sq invoke [1]","Program 11111111111111111111111111111111 invoke [2]","Program 11111111111111111111111111111111 success","Program log: Counter initialized with value: 42","Program 3QpyHXhFtYY32iY7foF3EjkVdCDrUppADk9aDwSWn6Sq consumed 3803 of 200000 compute units","Program 3QpyHXhFtYY32iY7foF3EjkVdCDrUppADk9aDwSWn6Sq success",]Counter initialized successfully with value: 42Testing counter increment...Transaction logs:["Program 3QpyHXhFtYY32iY7foF3EjkVdCDrUppADk9aDwSWn6Sq invoke [1]","Program log: Counter incremented to: 43","Program 3QpyHXhFtYY32iY7foF3EjkVdCDrUppADk9aDwSWn6Sq consumed 762 of 200000 compute units","Program 3QpyHXhFtYY32iY7foF3EjkVdCDrUppADk9aDwSWn6Sq success",]Counter incremented successfully to: 43
Part 3: Invoking the Program
Now let's add a client script to invoke the program.
Create client example
Let's create a Rust client to interact with our deployed program.
$mkdir examples$touch examples/client.rs
Add the following configuration to Cargo.toml
:
[[example]]name = "client"path = "examples/client.rs"
Install the client dependencies:
$cargo add solana-client@2.2.0 --dev$cargo add tokio --dev
Implement client code
Now let's implement the client that will invoke our deployed program.
Run the following command to get your program ID from the keypair file:
$solana address -k ./target/deploy/counter_program-keypair.json
Add the client code to examples/client.rs
and replace the program_id
with
the output of the previous command:
let program_id = Pubkey::from_str("BDLLezrtFEXVGYqG3aS7eAC7GVeojJ4JHhKJM6pAFCDH").expect("Invalid program ID");
use solana_client::rpc_client::RpcClient;use solana_sdk::{commitment_config::CommitmentConfig,instruction::{AccountMeta, Instruction},pubkey::Pubkey,signature::{Keypair, Signer},system_program,transaction::Transaction,};use std::str::FromStr;use counter_program::CounterInstruction;#[tokio::main]async fn main() {// Replace with your actual program ID from deploymentlet program_id = Pubkey::from_str("BDLLezrtFEXVGYqG3aS7eAC7GVeojJ4JHhKJM6pAFCDH").expect("Invalid program ID");// Connect to local clusterlet rpc_url = String::from("http://localhost:8899");let client = RpcClient::new_with_commitment(rpc_url, CommitmentConfig::confirmed());// Generate a new keypair for paying feeslet payer = Keypair::new();// Request airdrop of 1 SOL for transaction feesprintln!("Requesting airdrop...");let airdrop_signature = client.request_airdrop(&payer.pubkey(), 1_000_000_000).expect("Failed to request airdrop");// Wait for airdrop confirmationloop {if client.confirm_transaction(&airdrop_signature).unwrap_or(false){break;}std::thread::sleep(std::time::Duration::from_millis(500));}println!("Airdrop confirmed");println!("\nInitializing counter...");let counter_keypair = Keypair::new();let initial_value = 100u64;// Serialize the initialize instruction datalet instruction_data = borsh::to_vec(&CounterInstruction::InitializeCounter { initial_value }).expect("Failed to serialize instruction");let initialize_instruction = Instruction::new_with_bytes(program_id,&instruction_data,vec![AccountMeta::new(counter_keypair.pubkey(), true),AccountMeta::new(payer.pubkey(), true),AccountMeta::new_readonly(system_program::id(), false),],);let mut transaction =Transaction::new_with_payer(&[initialize_instruction], Some(&payer.pubkey()));let blockhash = client.get_latest_blockhash().expect("Failed to get blockhash");transaction.sign(&[&payer, &counter_keypair], blockhash);match client.send_and_confirm_transaction(&transaction) {Ok(signature) => {println!("Counter initialized!");println!("Transaction: {}", signature);println!("Counter address: {}", counter_keypair.pubkey());}Err(err) => {eprintln!("Failed to initialize counter: {}", err);return;}}println!("\nIncrementing counter...");// Serialize the increment instruction datalet increment_data = borsh::to_vec(&CounterInstruction::IncrementCounter).expect("Failed to serialize instruction");let increment_instruction = Instruction::new_with_bytes(program_id,&increment_data,vec![AccountMeta::new(counter_keypair.pubkey(), true)],);let mut transaction =Transaction::new_with_payer(&[increment_instruction], Some(&payer.pubkey()));transaction.sign(&[&payer, &counter_keypair], blockhash);match client.send_and_confirm_transaction(&transaction) {Ok(signature) => {println!("Counter incremented!");println!("Transaction: {}", signature);}Err(err) => {eprintln!("Failed to increment counter: {}", err);}}}
Part 4: Deploying the Program
Now that we have our program and client ready, let's build, deploy, and invoke the program.
Build the program
First, let's build our program.
$cargo build-sbf
This command compiles your program and generates two important files in
target/deploy/
:
counter_program.so # The compiled programcounter_program-keypair.json # Keypair for the program ID
You can view your program's ID by running the following command:
$solana address -k ./target/deploy/counter_program-keypair.json
Example output:
HQ5Q2XXqbTKKQsWPtLzMn7rDhM8v9UPYPe7DfSoFQqJF
Start local validator
For development, we'll use a local test validator.
First, configure the Solana CLI to use localhost:
$solana config set -ul
Example output:
Config File: ~/.config/solana/cli/config.ymlRPC URL: http://localhost:8899WebSocket URL: ws://localhost:8900/ (computed)Keypair Path: ~/.config/solana/id.jsonCommitment: confirmed
Now start the test validator in a separate terminal:
$solana-test-validator
Deploy the program
With the validator running, deploy your program to the local cluster:
$solana program deploy ./target/deploy/counter_program.so
Example output:
Program Id: HQ5Q2XXqbTKKQsWPtLzMn7rDhM8v9UPYPe7DfSoFQqJFSignature: 5xKdnh3dDFnZXB5UevYYkFBpCVcuqo5SaUPLnryFWY7eQD2CJxaeVDKjQ4ezQVJfkGNqZGYqMZBNqymPKwCQQx5h
You can verify the deployment using the solana program show
command with your
program ID:
$solana program show HQ5Q2XXqbTKKQsWPtLzMn7rDhM8v9UPYPe7DfSoFQqJF
Example output:
Program Id: HQ5Q2XXqbTKKQsWPtLzMn7rDhM8v9UPYPe7DfSoFQqJFOwner: BPFLoaderUpgradeab1e11111111111111111111111ProgramData Address: 47MVf5tRZ4zWXQMX7ydrkgcFQr8XTk1QBjohwsUzaiuMAuthority: 4kh6HxYZiAebF8HWLsUWod2EaQQ6iWHpHYCz8UcmFbM1Last Deployed In Slot: 16Data Length: 82696 (0x14308) bytesBalance: 0.57676824 SOL
Run the client
With the local validator still running, execute the client:
$cargo run --example client
Expected output:
Requesting airdrop...Airdrop confirmedInitializing counter...Counter initialized!Transaction: 2uenChtqNeLC1fitqoVE2LBeygSBTDchMZ4gGqs7AiDvZZVJguLDE5PfxsfkgY7xs6zFWnYsbEtb82dWv9tDT14kCounter address: EppPAmwqD42u4SCPWpPT7wmWKdFad5VnM9J4R9ZfofcyIncrementing counter...Counter incremented!Transaction: 4qv1Rx6FHu1M3woVgDQ6KtYUaJgBzGcHnhej76ZpaKGCgsTorbcHnPKxoH916UENw7X5ppnQ8PkPnhXxEwrYuUxS
With the local validator running, you can view the transactions on
Solana Explorer using the output
transaction signatures. Note the cluster on Solana Explorer must be set to
"Custom RPC URL", which defaults to http://localhost:8899
that the
solana-test-validator
is running on.
Is this page helpful?