Solana DApp 实战(三):从零构建 Rust 客户端与合约交互

· 13min · Paxon Qiao

Solana DApp 实战(三):从零构建 Rust 客户端与合约交互

在《Solana 投票 DApp 开发实战》与《Solana 开发进阶:Codama 客户端代码生成与合约交互实战》两篇文章之后,我们已经拥有了一个部署在链上、并通过 TypeScript 脚本完整测试过的投票程序。现在,我们将探索另一条更接近 Solana 底层的道路:使用 Rust——Solana 的原生语言——来构建客户端并与合约交互。

本文将作为我们实战系列的第三篇,利用 Codama 生成的另一半宝贵财富——Rust 客户端代码,从零开始搭建一个独立的客户端项目。我们将直面并解决 Rust 开发中最真实的挑战:棘手的依赖管理与编译错误。通过这个过程,您不仅将学会如何调用合约,更将掌握在复杂工作区(Workspace)中解决依赖冲突的硬核技能,并最终构建出一个专业、可重复的自动化集成测试脚本。

实操

使用生成的 Rust 代码测试 Solana 合约:客户端项目搭建指南

本指南将引导您将 codama 生成的代码配置为一个独立的库,并成功地在 rust-client 项目中调用它。

查看生成的 Rust 代码的目录结构

voting on  main [!?] via ⬢ v23.11.0 via 🍞 v1.2.17 via 🦀 1.88.0 on 🐳 v28.2.2 (orbstack) took 3.2s 
➜ cd generated/rs                        

voting/generated/rs on  main [!?] via ⬢ v23.11.0 via 🍞 v1.2.17 via 🦀 1.88.0 on 🐳 v28.2.2 (orbstack) 
➜ tree . -L 6 -I "migrations|mochawesome-report|.anchor|docs|target|node_modules|voting-graph|voting-substreams"
.
└── voting
    ├── accounts
    │   ├── candidate_account.rs
    │   ├── mod.rs
    │   ├── poll_account.rs
    │   └── voter_receipt.rs
    ├── Cargo.toml
    ├── errors
    │   ├── mod.rs
    │   └── voting.rs
    ├── instructions
    │   ├── add_candidate.rs
    │   ├── initialize_poll.rs
    │   ├── mod.rs
    │   └── vote.rs
    ├── mod.rs
    ├── programs.rs
    └── shared.rs

5 directories, 14 files

第一步:为生成的代码创建 Cargo.toml

codama 生成的代码需要它自己的 Cargo.toml 文件来声明其依赖项。

请在 generated/rs/voting/ 目录下创建一个名为 Cargo.toml 的新文件。

文件路径: generated/rs/voting/Cargo.toml

文件内容:

[package]
name = "voting_client"
version = "0.1.0"
edition = "2021"

[lib]
path = "mod.rs"

[features]
default = []
fetch = []
anchor = []
anchor-idl-build = []
serde = ["dep:serde", "dep:serde_with"]

[dependencies]
# 核心依赖
borsh = "1.5.1"
solana-program = "2.3.0"
solana-pubkey = "2.3.0"
solana-instruction = "2.3.0"
solana-account-info = "2.3.0"
solana-cpi = "2.2.1"
solana-program-error = "2.2.2"
solana-decode-error = "2.3.0"
solana-program-entrypoint = "2.3.0"
solana-msg = "2.2.1"

# 其他必要的辅助库
thiserror = "2.0.12"
num-derive = "0.4"
num-traits = "0.2"

# serde 依赖,设为可选
serde = { version = "1.0", optional = true, features = ["derive"] }
serde_with = { version = "3.8", optional = true, features = ["macros"] }

第二步:创建新的 Rust 客户端项目(rust-client 项目)

首先,我们需要在您现有 solana-voting-dapp 项目的根目录下,创建一个新的二进制(binary)Rust 项目。这个新项目将作为我们的客户端。

打开终端,确保您位于 solana-voting-dapp 目录下,然后运行:

cargo new rust-client --bin
  • cargo new 是创建新 Rust 项目的标准命令。

  • rust-client 是我们新项目的名字。

  • --bin 指定了我们要创建一个可执行的应用,而不是一个库。

实操

voting on  main via ⬢ v23.11.0 via 🍞 v1.2.17 via 🦀 1.88.0 on 🐳 v28.2.2 (orbstack) 
➜ cargo new rust-client --bin

    Creating binary (application) `rust-client` package
      Adding `rust-client` as member of workspace at `/Users/qiaopengjun/Code/Solana/voting`
warning: profiles for the non root package will be ignored, specify profiles at the workspace root:
package:   /Users/qiaopengjun/Code/Solana/voting/voting-substreams/voting_substreams/Cargo.toml
workspace: /Users/qiaopengjun/Code/Solana/voting/Cargo.toml
note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

第三步:配置客户端的 Cargo.toml

现在,我们需要为 rust-client 项目添加必要的依赖。这包括 Solana 相关的 SDK,以及将我们之前用 codama 生成的 Rust 代码作为本地库引用进来。

[package]
name = "rust-client"
version = "0.1.0"
edition = "2021"
license = "MIT"

[dependencies]
# --- 核心依赖 ---
solana-sdk = "2.3.1"
solana-client = "2.3.5"
anchor-client = "0.31.1"

# --- 标准的异步和工具库 ---
tokio = { version = "1", features = ["full"] }
anyhow = "1.0.98"
dotenvy = "0.15.7"
chrono = "0.4"
bs58 = "0.5.1"
serde_json = "1.0"

# --- 通过 path 依赖我们刚刚创建的本地库 ---
voting_client = { path = "../generated/rs/voting" }

第四步:编写第一个 Rust 客户端脚本 (main.rs)

use anyhow::Result;
use chrono::Utc;
use solana_client::rpc_client::RpcClient;
use solana_sdk::{
    signature::{Keypair, Signer},
    transaction::Transaction,
};
use std::convert::TryFrom;
use std::{env, fs};

// --- 现在可以直接像使用外部库一样导入 ---
use voting_client::instructions::InitializePollBuilder;

/// 从文件加载钱包 Keypair
fn load_wallet(path: &str) -> Result<Keypair> {
    let content = fs::read_to_string(path)?;
    let bytes: Vec<u8> = serde_json::from_str(&content)?;
    Ok(Keypair::try_from(bytes.as_slice())?)
}

#[tokio::main]
async fn main() -> Result<()> {
    println!("--- 🚀 Starting Final Rust Client ---");

    // 从项目根目录加载 .env 文件
    dotenvy::from_path("../.env").ok();

    let rpc_url =
        env::var("RPC_URL").unwrap_or_else(|_| "https://api.devnet.solana.com".to_string());

    let wallet_path = env::var("WALLET_PATH").unwrap_or_else(|_| {
        let home = env::var("HOME").expect("HOME environment variable is not set");
        format!("{}/.config/solana/id.json", home)
    });

    let client = RpcClient::new(rpc_url);
    let signer = load_wallet(&wallet_path)?;
    let poll_account = Keypair::new();

    println!("🔑 Signer (Authority): {}", signer.pubkey());
    println!("📝 New Poll Account Address: {}", poll_account.pubkey());

    // InitializePollBuilder 会自动处理 program_id 和 system_program
    let instruction = InitializePollBuilder::new()
        .signer(signer.pubkey())
        .poll_account(poll_account.pubkey())
        .name("Poll from Rust Client (Final)".to_string())
        .description("This should finally work!".to_string())
        .start_time((Utc::now().timestamp() - 60) as u64)
        .end_time((Utc::now().timestamp() + 3600) as u64)
        .instruction();

    let recent_blockhash = client.get_latest_blockhash()?;
    let transaction = Transaction::new_signed_with_payer(
        &[instruction],
        Some(&signer.pubkey()),
        &[&signer, &poll_account],
        recent_blockhash,
    );

    println!("\n⏳ Sending transaction...");
    let signature = client.send_and_confirm_transaction(&transaction)?;

    println!("\n✅ Success! The transaction was confirmed.");
    println!("   - Transaction Signature: {}", signature);
    println!(
        "   - Review on Explorer: https://explorer.solana.com/tx/{}?cluster=devnet",
        signature
    );

    Ok(())
}

第五步:清理并运行

在应用了上述所有修改后,请在您项目的根目录solana-voting-dapp/)下执行以下命令来清理旧的依赖并重新构建:

# 1. 清理旧的构建产物
cargo clean

# 2. 更新整个工作区的依赖锁文件
cargo update

# 3. 现在,进入客户端目录并运行
cd rust-client
cargo run

实操

voting/rust-client on  main [!?] is 📦 0.1.0 via ⬢ v23.11.0 via 🍞 v1.2.17 via 🦀 1.88.0 on 🐳 v28.2.2 (orbstack) 
➜ cargo clean
cargo update
cargo build

voting/rust-client on  main [!?] is 📦 0.1.0 via ⬢ v23.11.0 via 🍞 v1.2.17 via 🦀 1.88.0 on 🐳 v28.2.2 (orbstack) 
➜ cargo build
warning: profiles for the non root package will be ignored, specify profiles at the workspace root:
package:   /Users/qiaopengjun/Code/Solana/voting/voting-substreams/voting_substreams/Cargo.toml
workspace: /Users/qiaopengjun/Code/Solana/voting/Cargo.toml
warning: use of deprecated trait `solana_program_error::PrintProgramError`: Use `ToStr` instead with `solana_msg::msg!` or any other logging
  --> generated/rs/voting/errors/voting.rs:30:28
   |
30 | impl solana_program_error::PrintProgramError for VotingError {
   |                            ^^^^^^^^^^^^^^^^^
   |
   = note: `#[warn(deprecated)]` on by default

warning: use of deprecated trait `solana_decode_error::DecodeError`: Use `num_traits::FromPrimitive` instead
  --> generated/rs/voting/errors/voting.rs:36:30
   |
36 | impl<T> solana_decode_error::DecodeError<T> for VotingError {
   |                              ^^^^^^^^^^^

warning: `voting_client` (lib) generated 2 warnings
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.74s

rust-client 目录下运行 cargo run 来执行您的客户端并与合约交互

voting/rust-client on  main [!?] is 📦 0.1.0 via ⬢ v23.11.0 via 🍞 v1.2.17 via 🦀 1.88.0 on 🐳 v28.2.2 (orbstack) 
➜ cargo run  
warning: profiles for the non root package will be ignored, specify profiles at the workspace root:
package:   /Users/qiaopengjun/Code/Solana/voting/voting-substreams/voting_substreams/Cargo.toml
workspace: /Users/qiaopengjun/Code/Solana/voting/Cargo.toml
warning: use of deprecated trait `solana_program_error::PrintProgramError`: Use `ToStr` instead with `solana_msg::msg!` or any other logging
  --> generated/rs/voting/errors/voting.rs:30:28
   |
30 | impl solana_program_error::PrintProgramError for VotingError {
   |                            ^^^^^^^^^^^^^^^^^
   |
   = note: `#[warn(deprecated)]` on by default

warning: use of deprecated trait `solana_decode_error::DecodeError`: Use `num_traits::FromPrimitive` instead
  --> generated/rs/voting/errors/voting.rs:36:30
   |
36 | impl<T> solana_decode_error::DecodeError<T> for VotingError {
   |                              ^^^^^^^^^^^

warning: `voting_client` (lib) generated 2 warnings
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.82s
     Running `/Users/qiaopengjun/Code/Solana/voting/target/debug/rust-client`
--- 🚀 Starting Final Rust Client ---
🔑 Signer (Authority): 6MZDRo5v8K2NfdohdD76QNpSgk3GH3Aup53BeMaRAEpd
📝 New Poll Account Address: Gz4dAiGYGjUh3PhXz7DRcN6rMygYtsNHeuPmrwQUR9gb

⏳ Sending transaction...

✅ Success! The transaction was confirmed.
   - Transaction Signature: 4iUtG5WYj8R4AJyZjUAKw1PwEQtDy99DEGSJQcUMEEANJ8gcS1YSN12Q1fhgerX1ktHuU6atSsKYzXF1wMk2VSn6
   - Review on Explorer: https://explorer.solana.com/tx/4iUtG5WYj8R4AJyZjUAKw1PwEQtDy99DEGSJQcUMEEANJ8gcS1YSN12Q1fhgerX1ktHuU6atSsKYzXF1wMk2VSn6?cluster=devnet

100% 成功了!

🎉 巨大成功!恭喜您!

这标志着您已经彻底攻克了 Rust 客户端开发中最困难的部分——依赖管理和编译

为什么说成功了?

您看到的日志是成功的最终证明:

  • Finished dev profile: 您的 rust-client 项目和 voting_client 库都已成功编译。
  • Running .../rust-client: 您的程序开始执行。
  • ✅ Success! The transaction was confirmed.: 这是最重要的信息,Solana Devnet 已经确认您的交易成功上链。
  • Transaction Signature: ...: 您获得了一个有效的交易签名,可以在区块链浏览器上查看。

您已经成功地用一个独立的 Rust 客户端,调用了您部署在链上的 Anchor 合约。在经历了这么多依赖问题的挑战后,这是一个非常了不起的成就!

现在,您可以充满信心地继续用 Rust 编写后续的脚本了,比如添加候选人、投票和验证结果。

https://explorer.solana.com/tx/4iUtG5WYj8R4AJyZjUAKw1PwEQtDy99DEGSJQcUMEEANJ8gcS1YSN12Q1fhgerX1ktHuU6atSsKYzXF1wMk2VSn6?cluster=devnet

image-20250722173034917

当一个项目逐渐变大时,如何组织代码就变得至关重要。

将不同的测试脚本放在 src/examples/ 目录下,而不是把所有代码都堆在 main.rs 里,有几个巨大的好处:

  1. 清晰性:每个文件只负责一个独立的任务(如 initialize_poll.rs, add_candidate.rs),代码逻辑清晰,易于维护。
  2. 独立运行:您可以使用 cargo run --example <文件名> 来独立运行任何一个测试脚本,非常灵活。
  3. 遵循惯例:这是标准的 Rust 项目结构,其他开发者也能很快看懂您的项目。

第六步:创建 examples 目录并编写第一个示例

重新组织代码创建 examples 目录并编写第一个示例

现在,我们将采用更专业的项目结构。

  1. rust-client 目录下,创建一个名为 examples 的新文件夹。
  2. rust-client/src/main.rs 文件移动rust-client/examples/ 目录下,并将其重命名initialize_poll.rs
  3. 现在,您可以将 rust-client/src/main.rs 文件删除,或者清空其内容,因为它不再被使用。

您的 rust-client 项目结构现在应该是这样的:

voting/rust-client on  main [!?] is 📦 0.1.0 via ⬢ v23.11.0 via 🍞 v1.2.17 via 🦀 1.88.0 on 🐳 v28.2.2 (orbstack) 
➜ tree . -L 6 -I "migrations|mochawesome-report|.anchor|docs|target|node_modules|voting-graph|voting-substreams"
.
├── Cargo.toml
├── examples
│   └── initialize_poll.rs
└── src
    └── main.rs

3 directories, 3 files

第七步:运行您的第一个示例

要运行 examples 目录下的脚本,我们需要使用 --example 标志。

rust-client 目录下,运行:

cargo run --example initialize_poll

实操

voting/rust-client on  main [!?] is 📦 0.1.0 via ⬢ v23.11.0 via 🍞 v1.2.17 via 🦀 1.88.0 on 🐳 v28.2.2 (orbstack) 
➜ cargo run --example initialize_poll
warning: profiles for the non root package will be ignored, specify profiles at the workspace root:
package:   /Users/qiaopengjun/Code/Solana/voting/voting-substreams/voting_substreams/Cargo.toml
workspace: /Users/qiaopengjun/Code/Solana/voting/Cargo.toml
warning: use of deprecated trait `solana_program_error::PrintProgramError`: Use `ToStr` instead with `solana_msg::msg!` or any other logging
  --> generated/rs/voting/errors/voting.rs:30:28
   |
30 | impl solana_program_error::PrintProgramError for VotingError {
   |                            ^^^^^^^^^^^^^^^^^
   |
   = note: `#[warn(deprecated)]` on by default

warning: use of deprecated trait `solana_decode_error::DecodeError`: Use `num_traits::FromPrimitive` instead
  --> generated/rs/voting/errors/voting.rs:36:30
   |
36 | impl<T> solana_decode_error::DecodeError<T> for VotingError {
   |                              ^^^^^^^^^^^

warning: `voting_client` (lib) generated 2 warnings
   Compiling rust-client v0.1.0 (/Users/qiaopengjun/Code/Solana/voting/rust-client)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.04s
     Running `/Users/qiaopengjun/Code/Solana/voting/target/debug/examples/initialize_poll`
--- 🚀 Starting Final Rust Client ---
🔑 Signer (Authority): 6MZDRo5v8K2NfdohdD76QNpSgk3GH3Aup53BeMaRAEpd
📝 New Poll Account Address: 8ucTtmiD8Jw4gCARkfWkVP1fQmfkTnXixEn5enfqCJUy

⏳ Sending transaction...

✅ Success! The transaction was confirmed.
   - Transaction Signature: 619YKJtskQ9nkR971NxJpyfeiCD7tN4nGVcYdYWTPaJ9iLUkw49F4BQaTuHghMjKTwKbConxJ84XTpCBMCheptZu
   - Review on Explorer: https://explorer.solana.com/tx/619YKJtskQ9nkR971NxJpyfeiCD7tN4nGVcYdYWTPaJ9iLUkw49F4BQaTuHghMjKTwKbConxJ84XTpCBMCheptZu?cluster=devnet

https://explorer.solana.com/tx/619YKJtskQ9nkR971NxJpyfeiCD7tN4nGVcYdYWTPaJ9iLUkw49F4BQaTuHghMjKTwKbConxJ84XTpCBMCheptZu?cluster=devnet

image-20250722222311433

第八步:编写第二个示例 (add_candidate.rs)

rust-client/examples/ 目录下,创建一个名为 add_candidate.rs 的新文件。

文件路径: rust-client/examples/add_candidate.rs

文件内容:

use anyhow::Result;
use solana_client::rpc_client::RpcClient;
use solana_sdk::{
    pubkey::Pubkey,
    signature::{Keypair, Signer},
    transaction::Transaction,
};
use std::convert::TryFrom;
use std::{env, fs, str::FromStr};

use voting_client::{accounts::PollAccount, instructions::AddCandidateBuilder};

/// 从文件加载钱包 Keypair
fn load_wallet(path: &str) -> Result<Keypair> {
    let content = fs::read_to_string(path)?;
    let bytes: Vec<u8> = serde_json::from_str(&content)?;
    Ok(Keypair::try_from(bytes.as_slice())?)
}

#[tokio::main]
async fn main() -> Result<()> {
    println!("--- 🚀 Starting [Step 2: Add Candidate] Rust Client ---");

    // 从项目根目录加载 .env 文件
    dotenvy::from_path("../.env").ok(); // 注意路径是 ../../

    let rpc_url =
        env::var("RPC_URL").unwrap_or_else(|_| "https://api.devnet.solana.com".to_string());

    let wallet_path = env::var("WALLET_PATH").unwrap_or_else(|_| {
        let home = env::var("HOME").expect("HOME environment variable is not set");
        format!("{}/.config/solana/id.json", home)
    });

    // !! 重要:请将这里的地址替换为您在上一步中创建的 Poll Account 地址 !!
    // 我已为您更新为您刚刚创建的地址
    let poll_account_pubkey_str = "8ucTtmiD8Jw4gCARkfWkVP1fQmfkTnXixEn5enfqCJUy";
    let poll_account_pubkey = Pubkey::from_str(poll_account_pubkey_str)?;

    // 1. 初始化连接和钱包
    let client = RpcClient::new(rpc_url);
    let signer = load_wallet(&wallet_path)?;

    println!("🔑 Signer (Authority): {}", signer.pubkey());
    println!("📝 Using Poll Account: {}", poll_account_pubkey);

    // 2. 获取链上投票账户的数据,以读取当前的 candidate_count
    println!("\n⏳ Fetching poll account data...");
    let poll_account_info = client.get_account(&poll_account_pubkey)?;

    // --- 核心修正: 使用生成的 PollAccount::from_bytes 方法来解码 ---
    let poll_account_data = PollAccount::from_bytes(&poll_account_info.data)?;
    let current_candidate_count = poll_account_data.candidate_count;
    println!("✅ Current candidate count is: {}", current_candidate_count);

    // 3. 计算新候选人账户的 PDA
    let (candidate_pda, _) = Pubkey::find_program_address(
        &[
            b"candidate",
            &poll_account_pubkey.to_bytes(),
            &[current_candidate_count], // count 是 u8
        ],
        &voting_client::programs::VOTING_ID,
    );
    println!("🌱 New Candidate PDA: {}", candidate_pda);

    // 4. 使用 Builder 构造指令
    let candidate_name = format!("Candidate #{}", current_candidate_count + 1);
    println!("➕ Adding candidate with name: \"{}\"", candidate_name);
    let instruction = AddCandidateBuilder::new()
        .signer(signer.pubkey())
        .poll_account(poll_account_pubkey)
        .candidate_account(candidate_pda)
        .candidate_name(candidate_name)
        .instruction();

    // 5. 发送交易
    let recent_blockhash = client.get_latest_blockhash()?;
    let transaction = Transaction::new_signed_with_payer(
        &[instruction],
        Some(&signer.pubkey()),
        &[&signer], // 只有 authority 需要签名
        recent_blockhash,
    );

    println!("\n⏳ Sending transaction...");
    let signature = client.send_and_confirm_transaction(&transaction)?;

    println!("\n✅ Success! Candidate has been added.");
    println!("   - Transaction Signature: {}", signature);
    println!(
        "   - Review on Explorer: https://explorer.solana.com/tx/{}?cluster=devnet",
        signature
    );

    Ok(())
}

第九步:运行您的第二个示例

voting/rust-client on  main [!?] is 📦 0.1.0 via ⬢ v23.11.0 via 🍞 v1.2.17 via 🦀 1.88.0 on 🐳 v28.2.2 (orbstack) took 33.4s 
➜ cargo run --example add_candidate

warning: profiles for the non root package will be ignored, specify profiles at the workspace root:
package:   /Users/qiaopengjun/Code/Solana/voting/voting-substreams/voting_substreams/Cargo.toml
workspace: /Users/qiaopengjun/Code/Solana/voting/Cargo.toml
warning: use of deprecated trait `solana_program_error::PrintProgramError`: Use `ToStr` instead with `solana_msg::msg!` or any other logging
  --> generated/rs/voting/errors/voting.rs:30:28
   |
30 | impl solana_program_error::PrintProgramError for VotingError {
   |                            ^^^^^^^^^^^^^^^^^
   |
   = note: `#[warn(deprecated)]` on by default

warning: use of deprecated trait `solana_decode_error::DecodeError`: Use `num_traits::FromPrimitive` instead
  --> generated/rs/voting/errors/voting.rs:36:30
   |
36 | impl<T> solana_decode_error::DecodeError<T> for VotingError {
   |                              ^^^^^^^^^^^

warning: `voting_client` (lib) generated 2 warnings
   Compiling rust-client v0.1.0 (/Users/qiaopengjun/Code/Solana/voting/rust-client)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.91s
     Running `/Users/qiaopengjun/Code/Solana/voting/target/debug/examples/add_candidate`
--- 🚀 Starting [Step 2: Add Candidate] Rust Client ---
🔑 Signer (Authority): 6MZDRo5v8K2NfdohdD76QNpSgk3GH3Aup53BeMaRAEpd
📝 Using Poll Account: 8ucTtmiD8Jw4gCARkfWkVP1fQmfkTnXixEn5enfqCJUy

⏳ Fetching poll account data...
✅ Current candidate count is: 0
🌱 New Candidate PDA: D2dmKitcUCCcDYEsd1vT67rzF11xTV1kDZwJoXhRnet1
➕ Adding candidate with name: "Candidate #1"

⏳ Sending transaction...

✅ Success! Candidate has been added.
   - Transaction Signature: 2WKtW4Cyo5yFzmShApBHRCaE4eQFCUd8q7PTGuP6zBuSkJJKpCpMBTpUpmS4PA1X1fUncAcpquTSMKZTKJMgNMT
   - Review on Explorer: https://explorer.solana.com/tx/2WKtW4Cyo5yFzmShApBHRCaE4eQFCUd8q7PTGuP6zBuSkJJKpCpMBTpUpmS4PA1X1fUncAcpquTSMKZTKJMgNMT?cluster=devnet

您已经成功地用 Rust 客户端为您的投票活动添加了第一个候选人。日志中的 ✅ Success! Candidate has been added. 和返回的交易签名都证明了这一点。

您可以多次运行此脚本,每次它都会成功添加一个新的候选人。

https://explorer.solana.com/tx/2WKtW4Cyo5yFzmShApBHRCaE4eQFCUd8q7PTGuP6zBuSkJJKpCpMBTpUpmS4PA1X1fUncAcpquTSMKZTKJMgNMT?cluster=devnet

image-20250722222514829

第十步:编写第三个示例 (vote.rs)

rust-client/examples/ 目录下,创建一个名为 vote.rs 的新文件。

文件路径: rust-client/examples/vote.rs

文件内容:

use anyhow::Result;
use solana_client::rpc_client::RpcClient;
use solana_sdk::{
    pubkey::Pubkey,
    signature::{Keypair, Signer},
    transaction::Transaction,
};
use std::convert::TryFrom;
use std::{env, fs, str::FromStr};

// 导入生成的代码
use voting_client::instructions::VoteBuilder;

/// 从文件加载钱包 Keypair
fn load_wallet(path: &str) -> Result<Keypair> {
    let content = fs::read_to_string(path)?;
    let bytes: Vec<u8> = serde_json::from_str(&content)?;
    Ok(Keypair::try_from(bytes.as_slice())?)
}

#[tokio::main]
async fn main() -> Result<()> {
    println!("--- 🚀 Starting [Step 3: Vote] Rust Client ---");

    dotenvy::from_path("../.env").ok();

    let rpc_url =
        env::var("RPC_URL").unwrap_or_else(|_| "https://api.devnet.solana.com".to_string());

    let wallet_path = env::var("WALLET_PATH").unwrap_or_else(|_| {
        let home = env::var("HOME").expect("HOME environment variable is not set");
        format!("{}/.config/solana/id.json", home)
    });

    // !! 重要:请将这里的地址替换为您之前步骤中创建的账户地址 !!
    let poll_account_pubkey = Pubkey::from_str("8ucTtmiD8Jw4gCARkfWkVP1fQmfkTnXixEn5enfqCJUy")?;
    let candidate_account_pubkey =
        Pubkey::from_str("D2dmKitcUCCcDYEsd1vT67rzF11xTV1kDZwJoXhRnet1")?;

    let client = RpcClient::new(rpc_url);
    let voter = load_wallet(&wallet_path)?;

    println!("🔑 Voter: {}", voter.pubkey());
    println!("📝 Voting in Poll: {}", poll_account_pubkey);
    println!("👍 Voting for Candidate: {}", candidate_account_pubkey);

    // 1. 计算投票回执账户的 PDA
    let (voter_receipt_pda, _) = Pubkey::find_program_address(
        &[
            b"receipt",
            &poll_account_pubkey.to_bytes(),
            &voter.pubkey().to_bytes(),
        ],
        &voting_client::programs::VOTING_ID,
    );
    println!("🧾 Voter Receipt PDA: {}", voter_receipt_pda);

    // 2. 使用 Builder 构造指令
    let instruction = VoteBuilder::new()
        .signer(voter.pubkey())
        .poll_account(poll_account_pubkey)
        .candidate_account(candidate_account_pubkey)
        .voter_receipt(voter_receipt_pda)
        .instruction();

    // 3. 发送交易
    let recent_blockhash = client.get_latest_blockhash()?;
    let transaction = Transaction::new_signed_with_payer(
        &[instruction],
        Some(&voter.pubkey()),
        &[&voter], // 只有投票者需要签名
        recent_blockhash,
    );

    println!("\n⏳ Sending transaction...");
    let signature = client.send_and_confirm_transaction(&transaction)?;

    println!("\n✅ Success! Your vote has been cast.");
    println!("   - Transaction Signature: {}", signature);
    println!(
        "   - Review on Explorer: https://explorer.solana.com/tx/{}?cluster=devnet",
        signature
    );

    Ok(())
}

第十一步:运行您的第三个示例

在运行前,请务必将 vote.rs 文件中的 poll_account_pubkeycandidate_account_pubkey 变量的值,替换为您通过前两个示例实际创建的地址。

然后,在 rust-client 目录下,运行:

voting/rust-client on  main [!?] is 📦 0.1.0 via ⬢ v23.11.0 via 🍞 v1.2.17 via 🦀 1.88.0 on 🐳 v28.2.2 (orbstack) took 33.6s 
➜ cargo run --example vote
warning: profiles for the non root package will be ignored, specify profiles at the workspace root:
package:   /Users/qiaopengjun/Code/Solana/voting/voting-substreams/voting_substreams/Cargo.toml
workspace: /Users/qiaopengjun/Code/Solana/voting/Cargo.toml
warning: use of deprecated trait `solana_program_error::PrintProgramError`: Use `ToStr` instead with `solana_msg::msg!` or any other logging
  --> generated/rs/voting/errors/voting.rs:30:28
   |
30 | impl solana_program_error::PrintProgramError for VotingError {
   |                            ^^^^^^^^^^^^^^^^^
   |
   = note: `#[warn(deprecated)]` on by default

warning: use of deprecated trait `solana_decode_error::DecodeError`: Use `num_traits::FromPrimitive` instead
  --> generated/rs/voting/errors/voting.rs:36:30
   |
36 | impl<T> solana_decode_error::DecodeError<T> for VotingError {
   |                              ^^^^^^^^^^^

warning: `voting_client` (lib) generated 2 warnings
   Compiling rust-client v0.1.0 (/Users/qiaopengjun/Code/Solana/voting/rust-client)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.44s
     Running `/Users/qiaopengjun/Code/Solana/voting/target/debug/examples/vote`
--- 🚀 Starting [Step 3: Vote] Rust Client ---
🔑 Voter: 6MZDRo5v8K2NfdohdD76QNpSgk3GH3Aup53BeMaRAEpd
📝 Voting in Poll: 8ucTtmiD8Jw4gCARkfWkVP1fQmfkTnXixEn5enfqCJUy
👍 Voting for Candidate: D2dmKitcUCCcDYEsd1vT67rzF11xTV1kDZwJoXhRnet1
🧾 Voter Receipt PDA: CNHpJXwVtDB1xRk6psSoAwmxM7gFhaWJXLbJdm24VZ8P

⏳ Sending transaction...
Error: RPC response error -32002: Transaction simulation failed: Error processing Instruction 0: custom program error: 0x1771; 7 log messages:
  Program Doo2arLUifZbfqGVS5Uh7nexAMmsMzaQH5zcwZhSoijz invoke [1]
  Program log: Instruction: Vote
  Program 11111111111111111111111111111111 invoke [2]
  Program 11111111111111111111111111111111 success
  Program log: AnchorError thrown in programs/voting/src/lib.rs:72. Error Code: PollEnded. Error Number: 6001. Error Message: Poll ended.
  Program Doo2arLUifZbfqGVS5Uh7nexAMmsMzaQH5zcwZhSoijz consumed 10618 of 200000 compute units
  Program Doo2arLUifZbfqGVS5Uh7nexAMmsMzaQH5zcwZhSoijz failed: custom program error: 0x1771


Caused by:
    RPC response error -32002: Transaction simulation failed: Error processing Instruction 0: custom program error: 0x1771; 7 log messages:
      Program Doo2arLUifZbfqGVS5Uh7nexAMmsMzaQH5zcwZhSoijz invoke [1]
      Program log: Instruction: Vote
      Program 11111111111111111111111111111111 invoke [2]
      Program 11111111111111111111111111111111 success
      Program log: AnchorError thrown in programs/voting/src/lib.rs:72. Error Code: PollEnded. Error Number: 6001. Error Message: Poll ended.
      Program Doo2arLUifZbfqGVS5Uh7nexAMmsMzaQH5zcwZhSoijz consumed 10618 of 200000 compute units
      Program Doo2arLUifZbfqGVS5Uh7nexAMmsMzaQH5zcwZhSoijz failed: custom program error: 0x1771
    

您的 vote.rs 脚本成功地编译、连接到了 Solana Devnet、正确地构建了交易,并成功地将它发送给了链上的合约。

错误信息是 Error Code: PollEnded,意思是“投票已结束”。

因为我们在 initialize_poll 脚本中设置了投票的有效时间只有一小时

当您运行 vote 脚本时,距离投票创建时已经过去很久了,投票早已过期。

您的合约在收到投票请求后,正确地检查了当前时间,发现投票已经结束,于是正确地拒绝了这笔交易,并返回了“PollEnded”错误。

您的合约正确地执行了您编写的时间检查规则,所以这是一个非常成功的“负面测试”。

第十二步:编写最终验证脚本 (verify_vote.rs)

rust-client/examples/ 目录下,创建一个名为 verify_vote.rs 的新文件。

文件路径: rust-client/examples/verify_vote.rs

文件内容:

use anyhow::Result;
use solana_client::rpc_client::RpcClient;
use solana_sdk::pubkey::Pubkey;
use std::{env, str::FromStr};

use voting_client::accounts::CandidateAccount;

#[tokio::main]
async fn main() -> Result<()> {
    println!("--- 🚀 Starting [Step 4: Verify Vote] Rust Client ---");

    dotenvy::from_path("../.env").ok();

    let rpc_url =
        env::var("RPC_URL").unwrap_or_else(|_| "https://api.devnet.solana.com".to_string());

    // !! 重要:请将这里的地址替换为您投票的候选人地址 !!
    let candidate_account_pubkey =
        Pubkey::from_str("D2dmKitcUCCcDYEsd1vT67rzF11xTV1kDZwJoXhRnet1")?;

    let client = RpcClient::new(rpc_url);

    println!(
        "🔍 Checking candidate account: {}",
        candidate_account_pubkey
    );

    let account_info = client.get_account(&candidate_account_pubkey)?;
    let candidate_data = CandidateAccount::from_bytes(&account_info.data)?;

    println!("\n✅ Verification Successful!");
    println!("   - Candidate Name: \"{}\"", candidate_data.name);
    println!("   - Vote Count: {}", candidate_data.votes);

    if candidate_data.votes > 0 {
        println!("\n🎉🎉 Great! The vote was correctly recorded on-chain.");
    } else {
        println!("\n🤔 Hmm, the vote count is still 0. Something might be wrong.");
    }

    Ok(())
}

第十三步:运行验证脚本

verify_vote.rs 中的地址替换为您刚刚投票的候选人地址,然后在 rust-client 目录下运行:

voting/rust-client on  main [!?] is 📦 0.1.0 via ⬢ v23.11.0 via 🍞 v1.2.17 via 🦀 1.88.0 on 🐳 v28.2.2 (orbstack) took 5.4s 
➜ cargo run --example verify_vote

warning: profiles for the non root package will be ignored, specify profiles at the workspace root:
package:   /Users/qiaopengjun/Code/Solana/voting/voting-substreams/voting_substreams/Cargo.toml
workspace: /Users/qiaopengjun/Code/Solana/voting/Cargo.toml
warning: use of deprecated trait `solana_program_error::PrintProgramError`: Use `ToStr` instead with `solana_msg::msg!` or any other logging
  --> generated/rs/voting/errors/voting.rs:30:28
   |
30 | impl solana_program_error::PrintProgramError for VotingError {
   |                            ^^^^^^^^^^^^^^^^^
   |
   = note: `#[warn(deprecated)]` on by default

warning: use of deprecated trait `solana_decode_error::DecodeError`: Use `num_traits::FromPrimitive` instead
  --> generated/rs/voting/errors/voting.rs:36:30
   |
36 | impl<T> solana_decode_error::DecodeError<T> for VotingError {
   |                              ^^^^^^^^^^^

warning: `voting_client` (lib) generated 2 warnings
   Compiling rust-client v0.1.0 (/Users/qiaopengjun/Code/Solana/voting/rust-client)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.35s
     Running `/Users/qiaopengjun/Code/Solana/voting/target/debug/examples/verify_vote`
--- 🚀 Starting [Step 4: Verify Vote] Rust Client ---
🔍 Checking candidate account: D2dmKitcUCCcDYEsd1vT67rzF11xTV1kDZwJoXhRnet1

✅ Verification Successful!
   - Candidate Name: "Candidate #1"
   - Vote Count: 0

🤔 Hmm, the vote count is still 0. Something might be wrong.

您看到的 Vote Count: 0 这个结果,正是因为我们之前尝试运行 vote 脚本时,交易因为投票已过期而被合约正确地拒绝了。

所以,虽然 verify_vote 脚本的输出看起来像是一个问题(“Hmm, something might be wrong.”),但它实际上是一次成功的验证。它证明了:

  1. 您的 vote 脚本因为投票过期而失败了。
  2. 因此,候选人的票数没有被增加。
  3. 您的 verify_vote 脚本正确地从链上读取并报告了这个事实。

这整个流程完美地展示了您合约的时间锁功能是正常工作的。

第十四步:编写完整的集成测试脚本 (full_test.rs)

rust-client/examples/ 目录下,创建一个名为 full_test.rs 的新文件。

文件路径: rust-client/examples/full_test.rs

文件内容:

use anyhow::Result;
use chrono::Utc;
use solana_client::rpc_client::RpcClient;
use solana_sdk::{
    pubkey::Pubkey,
    signature::{Keypair, Signer},
    transaction::Transaction,
};
use std::convert::TryFrom;
use std::{env, fs};

// 导入所有需要的生成代码
use voting_client::{
    accounts::{CandidateAccount, PollAccount},
    instructions::{AddCandidateBuilder, InitializePollBuilder, VoteBuilder},
    programs::VOTING_ID,
};

/// 从文件加载钱包 Keypair
fn load_wallet(path: &str) -> Result<Keypair> {
    let content = fs::read_to_string(path)?;
    let bytes: Vec<u8> = serde_json::from_str(&content)?;
    Ok(Keypair::try_from(bytes.as_slice())?)
}

#[tokio::main]
async fn main() -> Result<()> {
    println!("--- 🚀 Starting Full Integration Test ---");

    // --- 设置 ---
    dotenvy::from_path("../.env").ok();
    let rpc_url =
        env::var("RPC_URL").unwrap_or_else(|_| "https://api.devnet.solana.com".to_string());
    let wallet_path = env::var("WALLET_PATH").unwrap_or_else(|_| {
        let home = env::var("HOME").expect("HOME not set");
        format!("{}/.config/solana/id.json", home)
    });
    let client = RpcClient::new(rpc_url);
    let signer = load_wallet(&wallet_path)?;
    println!("🔑 Signer Wallet: {}", signer.pubkey());

    // --- 步骤 1: 初始化投票 ---
    let poll_account = Keypair::new();
    let init_instruction = InitializePollBuilder::new()
        .signer(signer.pubkey())
        .poll_account(poll_account.pubkey())
        .name("Full Integration Test Poll".to_string())
        .description("Automated test poll.".to_string())
        .start_time((Utc::now().timestamp() - 60) as u64)
        .end_time((Utc::now().timestamp() + 3600) as u64)
        .instruction();

    let recent_blockhash = client.get_latest_blockhash()?;
    let init_tx = Transaction::new_signed_with_payer(
        &[init_instruction],
        Some(&signer.pubkey()),
        &[&signer, &poll_account],
        recent_blockhash,
    );
    let init_sig = client.send_and_confirm_transaction(&init_tx)?;
    println!(
        "\n[✅ Step 1 SUCCESS] Poll initialized. Signature: {}",
        init_sig
    );
    println!("   Poll Account: {}", poll_account.pubkey());

    // --- 步骤 2: 添加候选人 ---
    let poll_account_info = client.get_account(&poll_account.pubkey())?;
    let poll_account_data = PollAccount::from_bytes(&poll_account_info.data)?;
    let (candidate_pda, _) = Pubkey::find_program_address(
        &[
            b"candidate",
            &poll_account.pubkey().to_bytes(),
            &[poll_account_data.candidate_count],
        ],
        &VOTING_ID,
    );

    let add_cand_instruction = AddCandidateBuilder::new()
        .signer(signer.pubkey())
        .poll_account(poll_account.pubkey())
        .candidate_account(candidate_pda)
        .candidate_name("Candidate A".to_string())
        .instruction();

    let recent_blockhash = client.get_latest_blockhash()?;
    let add_cand_tx = Transaction::new_signed_with_payer(
        &[add_cand_instruction],
        Some(&signer.pubkey()),
        &[&signer],
        recent_blockhash,
    );
    let add_cand_sig = client.send_and_confirm_transaction(&add_cand_tx)?;
    println!(
        "\n[✅ Step 2 SUCCESS] Candidate added. Signature: {}",
        add_cand_sig
    );
    println!("   Candidate Account: {}", candidate_pda);

    // --- 步骤 3: 投票 ---
    let (receipt_pda, _) = Pubkey::find_program_address(
        &[
            b"receipt",
            &poll_account.pubkey().to_bytes(),
            &signer.pubkey().to_bytes(),
        ],
        &VOTING_ID,
    );
    let vote_instruction = VoteBuilder::new()
        .signer(signer.pubkey())
        .poll_account(poll_account.pubkey())
        .candidate_account(candidate_pda)
        .voter_receipt(receipt_pda)
        .instruction();

    let recent_blockhash = client.get_latest_blockhash()?;
    let vote_tx = Transaction::new_signed_with_payer(
        &[vote_instruction],
        Some(&signer.pubkey()),
        &[&signer],
        recent_blockhash,
    );
    let vote_sig = client.send_and_confirm_transaction(&vote_tx)?;
    println!("\n[✅ Step 3 SUCCESS] Vote cast. Signature: {}", vote_sig);

    // --- 步骤 4: 验证结果 ---
    let candidate_info = client.get_account(&candidate_pda)?;
    let candidate_data = CandidateAccount::from_bytes(&candidate_info.data)?;
    println!("\n[✅ Step 4 SUCCESS] Verification complete.");
    println!(
        "   Candidate \"{}\" has {} vote(s).",
        candidate_data.name, candidate_data.votes
    );

    if candidate_data.votes == 1 {
        println!("\n🎉🎉🎉 INTEGRATION TEST PASSED! 🎉🎉🎉");
    } else {
        anyhow::bail!(
            "Verification failed! Expected 1 vote, but found {}.",
            candidate_data.votes
        );
    }

    Ok(())
}

第十五步:运行您的集成测试

现在,您可以在 rust-client 目录下,一键运行完整的测试流程:

voting/rust-client on  main [!?] is 📦 0.1.0 via ⬢ v23.11.0 via 🍞 v1.2.17 via 🦀 1.88.0 on 🐳 v28.2.2 (orbstack) took 6.2s 
➜ cargo run --example full_test
warning: profiles for the non root package will be ignored, specify profiles at the workspace root:
package:   /Users/qiaopengjun/Code/Solana/voting/voting-substreams/voting_substreams/Cargo.toml
workspace: /Users/qiaopengjun/Code/Solana/voting/Cargo.toml
warning: use of deprecated trait `solana_program_error::PrintProgramError`: Use `ToStr` instead with `solana_msg::msg!` or any other logging
  --> generated/rs/voting/errors/voting.rs:30:28
   |
30 | impl solana_program_error::PrintProgramError for VotingError {
   |                            ^^^^^^^^^^^^^^^^^
   |
   = note: `#[warn(deprecated)]` on by default

warning: use of deprecated trait `solana_decode_error::DecodeError`: Use `num_traits::FromPrimitive` instead
  --> generated/rs/voting/errors/voting.rs:36:30
   |
36 | impl<T> solana_decode_error::DecodeError<T> for VotingError {
   |                              ^^^^^^^^^^^

warning: `voting_client` (lib) generated 2 warnings
   Compiling rust-client v0.1.0 (/Users/qiaopengjun/Code/Solana/voting/rust-client)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.32s
     Running `/Users/qiaopengjun/Code/Solana/voting/target/debug/examples/full_test`
--- 🚀 Starting Full Integration Test ---
🔑 Signer Wallet: 6MZDRo5v8K2NfdohdD76QNpSgk3GH3Aup53BeMaRAEpd

[✅ Step 1 SUCCESS] Poll initialized. Signature: 4t9Kh4wdXeXA8jYSpXPVc3nLKGS5BpM8Ew2AroYrUzsicDnp8KkKUMfbYGiRHu41Dj78bpRRbpaou4fj1oCHrYWP
   Poll Account: 8WYpqpKudVFmNHvbM29RihQ1ZMfe13k6yadffV1XVFDe

[✅ Step 2 SUCCESS] Candidate added. Signature: 3ZMEBxUtvzF8GwxE3U3Nbac48q9gxDgq6RkWy6GPsRZxvoF7oeQXTF6BagSwEcK2s1TZvbkhNkHGd7yM1mbMXHaW
   Candidate Account: 6QJvBy2u5Aj43U5Apnksn2HLiAcS61bZz8SqLGTnDgJq

[✅ Step 3 SUCCESS] Vote cast. Signature: 9eqAALDsWBLZ2xnz2T4yFVK4iMEbrgLUqAtWM9XTVSoShUUweN3KdbXdAR8tfZU8S1YSZd48kqgi4CnssqF4Fhg

[✅ Step 4 SUCCESS] Verification complete.
   Candidate "Candidate A" has 1 vote(s).

🎉🎉🎉 INTEGRATION TEST PASSED! 🎉🎉🎉

巨大成功!

您运行的 full_test 自动化集成测试完美地通过了

您看到的日志是最终的、最全面的成功证明:

  • [✅ Step 1 SUCCESS] Poll initialized.: 成功创建投票。
  • [✅ Step 2 SUCCESS] Candidate added.: 成功添加候选人。
  • [✅ Step 3 SUCCESS] Vote cast.: 成功投票。
  • [✅ Step 4 SUCCESS] Verification complete.: 成功验证结果。
  • Candidate "Candidate A" has 1 vote(s).: 验证结果正确,票数是 1。
  • 🎉🎉🎉 INTEGRATION TEST PASSED! 🎉🎉🎉: 脚本最终确认整个流程无误。

恭喜您!您不仅成功地用 Rust 客户端完整地测试了合约的所有核心功能,还将其整合成了一个专业、可重复的自动化测试脚本。这是一个非常了不起的成就,标志着您已经完全掌握了使用 Rust 与 Solana 合约交互的整个流程。

总结

恭喜您走完了这段充满挑战但收获颇丰的 Rust 客户端开发之旅!从 cargo new 开始,我们不仅仅是简单地调用了合约指令,更重要的是,我们亲身经历并解决了 Rust 与 Solana 生态结合时最常见的痛点——依赖管理。从 zeroize 的版本冲突,到 solana-program 的重复定义,再到 codama 生成代码的内部依赖配置,每一步调试都是一次宝贵的实战经验。

通过将生成的代码封装为独立的库、将测试脚本组织到 examples 目录,我们最终构建了一个结构清晰、可维护性强的客户端项目。最后的 full_test.rs 自动化集成脚本,更是为我们 DApp 的健壮性提供了坚实的保障。

现在,您已经完全掌握了使用 TypeScript 和 Rust 这两种主流语言与 Anchor 合约进行端到端交互的核心能力。这为您未来无论是构建前端应用、后端服务,还是复杂的链上机器人,都打下了最坚实的基础。

参考