用 Rust 编写的 Solana 程序对结构的要求非常少,因此代码组织方式非常灵活。唯一的要求是,程序必须有一个
entrypoint,它定义了程序执行的起点。
程序结构
虽然没有严格的文件结构规定,但 Solana 程序通常遵循一个通用模式:
entrypoint.rs:定义路由传入指令的入口点。state.rs:定义程序状态(账户数据)。instructions.rs:定义程序可执行的指令。processor.rs:定义实现每个指令业务逻辑的指令处理函数。error.rs:定义程序可返回的自定义错误。
例如,请参见 Token Program。
示例程序
为了演示如何构建包含多个指令的原生 Rust 程序,我们将以一个简单的计数器程序为例,介绍实现两个指令的过程:
InitializeCounter:创建并初始化一个带有初始值的新账户。IncrementCounter:对现有账户中存储的值进行递增。
为简化起见,程序将全部实现于一个 lib.rs
文件中,但在实际开发中,你可能会将较大的程序拆分为多个文件。
第 1 部分:编写程序
我们先从构建计数器程序开始。我们将创建一个可以用初始值初始化计数器并递增计数的程序。
创建新程序
首先,让我们为 Solana 程序创建一个新的 Rust 项目。
$cargo new counter_program --lib$cd counter_program
你应该会看到默认的 src/lib.rs 和 Cargo.toml 文件。
请将 edition 字段在 Cargo.toml 文件中更新为
2021。否则,在构建程序时可能会遇到错误。
添加依赖项
现在让我们添加构建 Solana 程序所需的依赖项。我们需要用于核心 SDK 的
solana-program,以及用于序列化的 borsh。
$cargo add solana-program@2.2.0$cargo add borsh
并没有强制要求使用 Borsh。不过,它是 Solana 程序中常用的序列化库。
配置 crate-type
Solana 程序必须编译为动态库。请添加 [lib] 部分,以配置 Cargo 如何构建该程序。
[lib]crate-type = ["cdylib", "lib"]
如果没有包含此配置,构建程序时将不会生成 target/deploy 目录。
设置程序入口
每个 Solana 程序都有一个入口点,即在程序被调用时执行的函数。我们先添加程序所需的 import,并设置入口点。
将以下代码添加到 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(())}
entrypoint
宏会将 input 数据反序列化为 process_instruction 函数的参数。
Solana 程序的 entrypoint 具有如下函数签名。开发者可以自由实现自己的
entrypoint 函数。
#[no_mangle]pub unsafe extern "C" fn entrypoint(input: *mut u8) -> u64;
定义程序状态
现在我们来定义将存储在计数器账户中的数据结构。这些数据将被存储在账户的 data
字段中。
将以下代码添加到 lib.rs:
#[derive(BorshSerialize, BorshDeserialize, Debug)]pub struct CounterAccount {pub count: u64,}
定义指令枚举
现在我们来定义程序可以执行的指令。我们将使用一个枚举,其中每个变体代表一个不同的指令。
将以下代码添加到 lib.rs:
#[derive(BorshSerialize, BorshDeserialize, Debug)]pub enum CounterInstruction {InitializeCounter { initial_value: u64 },IncrementCounter,}
实现指令反序列化
现在我们需要将 instruction_data(原始字节)反序列化为我们的
CounterInstruction 枚举的某个变体。Borsh 的 try_from_slice
方法会自动处理这个转换。
更新 process_instruction 函数,使用 Borsh 反序列化:
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(())}
路由指令到处理器
现在我们来更新主 process_instruction 函数,将指令路由到对应的处理函数。
这种路由模式在 Solana 程序中很常见。instruction_data
会被反序列化为表示指令的枚举变体,然后调用相应的处理函数。每个处理函数都包含该指令的具体实现。
将以下代码添加到 lib.rs,更新 process_instruction 函数,并添加
InitializeCounter 和 IncrementCounter 指令的处理器:
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(())}
实现初始化处理器
现在我们来实现用于创建和初始化新计数器账户的处理器。由于只有 System Program 可以在 Solana 上创建账户,我们将使用跨程序调用(CPI),也就是从我们的程序中调用另一个程序。
我们的程序通过 CPI 调用 System Program 的 create_account
指令。新账户由我们的程序作为 owner 创建,这样我们的程序就可以写入该账户并初始化数据。
将以下代码添加到 lib.rs 中,更新 process_initialize_counter 函数:
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(())}
此指令仅用于演示目的。它未包含生产环境程序所需的安全性和校验。
实现增量处理器
现在让我们实现一个处理器,用于递增已有计数器。此指令:
- 读取账户
data字段中的counter_account - 将其反序列化为
CounterAccount结构体 - 将
count字段加 1 - 将
CounterAccount结构体重新序列化回账户的data字段
将以下代码添加到 lib.rs 中,更新 process_increment_counter 函数:
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(())}
此指令仅用于演示目的。它未包含生产环境程序所需的安全性和校验。
程序已完成
恭喜!你已经构建了一个完整的 Solana 程序,演示了所有 Solana 程序共有的基本结构:
- Entrypoint:定义程序执行的起点,并将所有传入请求路由到相应的指令处理器
- Instruction Handling:定义指令及其对应的处理函数
- State Management:定义账户数据结构并管理其在程序拥有账户中的状态
- Cross Program Invocation (CPI):调用 System Program 创建新的程序拥有账户
下一步是测试程序,以确保一切正常运行。
第 2 部分:测试程序
现在让我们测试计数器程序。我们将使用 LiteSVM,这是一个测试框架,可以让我们在不部署到集群的情况下测试程序。
添加测试依赖项
首先,让我们添加测试所需的依赖项。我们将使用 litesvm 进行测试,还会用到
solana-sdk。
$cargo add litesvm@0.6.1 --dev$cargo add solana-sdk@2.2.0 --dev
创建测试模块
现在让我们为程序添加一个测试模块。我们将从基础结构和导入开始。
将以下代码添加到 lib.rs,直接放在程序代码下方:
#[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}}
#[cfg(test)] 属性确保这段代码只会在运行测试时编译。
初始化测试环境
让我们用 LiteSVM 搭建测试环境,并为付款账户充值。
LiteSVM 可以模拟 Solana 运行环境,让我们无需部署到真实集群即可测试程序。
将以下代码添加到 lib.rs,并更新 test_counter_program 函数:
let mut svm = LiteSVM::new();let payer = Keypair::new();svm.airdrop(&payer.pubkey(), 1_000_000_000).expect("Failed to airdrop");
加载程序
现在我们需要将程序构建并加载到测试环境中。运行 cargo build-sbf
命令来构建程序。这会在 target/deploy 目录下生成 counter_program.so 文件。
$cargo build-sbf
请确保 Cargo.toml 中的 edition 已设置为 2021。
构建完成后,我们可以加载该程序。
更新 test_counter_program 函数,将程序加载到测试环境中。
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");
在运行测试前,必须先运行 cargo build-sbf,以生成 .so
文件。测试会加载已编译的程序。
测试初始化指令
我们通过创建一个具有初始值的新计数器账户来测试初始化指令。
将以下代码添加到 lib.rs 中,更新 test_counter_program 函数:
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);
验证初始化
初始化后,我们来验证计数器账户是否已按预期值正确创建。
将以下代码添加到 lib.rs 中,更新 test_counter_program 函数:
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);
测试自增指令
现在我们来测试自增指令,确保它能正确更新计数器的值。
将以下代码添加到 lib.rs 中,更新 test_counter_program 函数:
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);
验证最终结果
最后,我们通过检查更新后的计数器值,验证自增操作是否正确。
将以下代码添加到 lib.rs 中,更新 test_counter_program 函数:
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);
使用以下命令运行测试。--nocapture 标志会打印测试输出。
$cargo test -- --nocapture
预期输出:
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
第 3 部分:调用程序
现在让我们添加一个客户端脚本来调用该程序。
创建客户端示例
让我们创建一个 Rust 客户端与已部署的程序进行交互。
$mkdir examples$touch examples/client.rs
将以下配置添加到 Cargo.toml:
[[example]]name = "client"path = "examples/client.rs"
安装客户端依赖:
$cargo add solana-client@2.2.0 --dev$cargo add tokio --dev
实现客户端代码
现在让我们实现客户端,用于调用已部署的程序。
运行以下命令,从 keypair 文件中获取你的程序 ID:
$solana address -k ./target/deploy/counter_program-keypair.json
将客户端代码添加到 examples/client.rs,并用上一步命令的输出替换 program_id:
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);}}}
第 4 部分:部署程序
现在我们的程序和客户端都已准备好,接下来让我们构建、部署并调用该程序。
构建程序
首先,让我们构建我们的程序。
$cargo build-sbf
此命令会编译你的程序,并在 target/deploy/ 目录下生成两个重要文件:
counter_program.so # The compiled programcounter_program-keypair.json # Keypair for the program ID
你可以通过运行以下命令查看你的程序 ID:
$solana address -k ./target/deploy/counter_program-keypair.json
示例输出:
HQ5Q2XXqbTKKQsWPtLzMn7rDhM8v9UPYPe7DfSoFQqJF
启动本地 validator
在开发过程中,我们将使用本地测试 validator。
首先,将 Solana CLI 配置为使用 localhost:
$solana config set -ul
示例输出:
Config File: ~/.config/solana/cli/config.ymlRPC URL: http://localhost:8899WebSocket URL: ws://localhost:8900/ (computed)Keypair Path: ~/.config/solana/id.jsonCommitment: confirmed
现在,在另一个终端中启动测试 validator:
$solana-test-validator
部署程序
在 validator 正在运行时,将你的程序部署到本地集群:
$solana program deploy ./target/deploy/counter_program.so
示例输出:
Program Id: HQ5Q2XXqbTKKQsWPtLzMn7rDhM8v9UPYPe7DfSoFQqJFSignature: 5xKdnh3dDFnZXB5UevYYkFBpCVcuqo5SaUPLnryFWY7eQD2CJxaeVDKjQ4ezQVJfkGNqZGYqMZBNqymPKwCQQx5h
你可以使用 solana program show 命令和你的程序 ID 验证部署情况:
$solana program show HQ5Q2XXqbTKKQsWPtLzMn7rDhM8v9UPYPe7DfSoFQqJF
示例输出:
Program Id: HQ5Q2XXqbTKKQsWPtLzMn7rDhM8v9UPYPe7DfSoFQqJFOwner: BPFLoaderUpgradeab1e11111111111111111111111ProgramData Address: 47MVf5tRZ4zWXQMX7ydrkgcFQr8XTk1QBjohwsUzaiuMAuthority: 4kh6HxYZiAebF8HWLsUWod2EaQQ6iWHpHYCz8UcmFbM1Last Deployed In Slot: 16Data Length: 82696 (0x14308) bytesBalance: 0.57676824 SOL
运行客户端
在本地 validator 仍然运行时,执行客户端:
$cargo run --example client
预期输出:
Requesting airdrop...Airdrop confirmedInitializing counter...Counter initialized!Transaction: 2uenChtqNeLC1fitqoVE2LBeygSBTDchMZ4gGqs7AiDvZZVJguLDE5PfxsfkgY7xs6zFWnYsbEtb82dWv9tDT14kCounter address: EppPAmwqD42u4SCPWpPT7wmWKdFad5VnM9J4R9ZfofcyIncrementing counter...Counter incremented!Transaction: 4qv1Rx6FHu1M3woVgDQ6KtYUaJgBzGcHnhej76ZpaKGCgsTorbcHnPKxoH916UENw7X5ppnQ8PkPnhXxEwrYuUxS
在本地 validator 运行时,你可以通过
Solana Explorer
查看交易,使用输出的交易签名。请注意,Solana Explorer 上的集群必须设置为“Custom
RPC URL”,默认是 http://localhost:8899,即 solana-test-validator
正在运行的地址。
Is this page helpful?