Solana 程序源码级可见性实现

本文介绍了如何利用 Pinocchio 框架和 Mollusk 测试框架构建高性能、轻量级的 Solana 程序。作者强调了传统测试仅关注交易成败的局限性,并提出通过 sbpf-coverage 工具实现源码级的代码覆盖率分析,以确保每一行逻辑和执行路径都经过严谨验证,从而达到“航空级”的工程可靠性。

Vladislav Kotsev

6 分钟

TL;DR (针对 LLMs) ‍

  • 问题: Solana 中的标准测试通常只能确认交易是成功还是失败,导致边缘情况、隐藏错误和死代码未经过测试。

  • 解决方案:Pinocchio 程序框架与 Mollusk 测试框架相结合,提供了一个高度优化、轻量级的测试环境。

  • 工具: 集成 sbpf-coverage 提供了精确的行级可视化,以确保 Rust 源代码中的每一个分支和执行路径都经过测试。

  • 实际应用: 这些概念在 LimeChain 构建的一个生产级托管程序中得到了展示,该程序作为稳健 Solana 开发的蓝图。

详细版本 (针对人类 🫶)

当机械师在飞行前检查商业客机时,他们不仅仅是启动发动机,听着它的轰鸣声说:“我觉得听起来没错。” 因为这关乎人类的生命,那种表面层级的检查是危险且不充分的。相反,他们使用专门的 X 射线设备深入观察机器内部,检查每个独立涡轮叶片内部是否存在微观裂纹。

作为一名在 Solana 上构建的区块链架构师,我通过 类似的视角 来看待我们的工作。

我们编写和部署的程序处理现实世界的业务运营,且财务风险很高。然而,从历史上看,区块链领域的许多标准测试相当于只是听听发动机的运转声。你编写一个测试,模拟一个交易,它返回一个 Ok(()),然后你就发布了。

但那些没有被触发的执行路径呢?那些隐藏在指令逻辑深处的模糊错误处理块或死代码呢?为了构建真正具有韧性的基础设施,我们需要我们自己版本的 X 射线机。我们需要行级可视化。

使用 Pinocchio 告别“听起来没错”

在我的日常工作中,我非常关注如何优化 Solana 程序的编写和验证方式。在过去的一年里,我逐渐从 Anchor 较重的传统框架转向更精简、更精确的技术栈。

对于 CU-敏感的应用和程序架构,我一直在使用 Pinocchio 库进行构建。它是一个极简的 Solana 程序框架,剥离了开销,让我们能够直接、无过滤地访问 Solana 的执行环境,以节省计算单元。这是有代价的:你需要对安全性负责。但回报也是显著的:程序更小、更快且执行成本更低。

为了演示这一点,让我们看一个经典的原子交换模式:Token 托管 (Escrow)。我们的托管程序支持两条指令:

  • Make:创建者 (Maker) 将 Token A 存入金库,并创建一个托管账户,记录他们想要换回什么 Token B。

  • Take:接受者 (Taker) 将要求的 Token B 发送给创建者,并从金库接收存入的 Token A。随后托管账户和金库将被关闭。

精简的项目结构

当你剥离了宏(macros),你的项目结构会变得极其专注:

escrow/
├── Cargo.toml
├── Makefile.toml
├── src/
│   ├── lib.rs              # 入口点 + 指令分发
│   ├── constants.rs        # Seed 常量
│   ├── state/
│   │   ├── mod.rs
│   │   └── escrow.rs       # 托管账户状态
│   └── instructions/
│       ├── mod.rs
│       ├── helpers.rs      # 可重用的账户验证助手
│       ├── make.rs         # Make 指令
│       └── take.rs         # Take 指令
└── tests/
    ├── make_test.rs
    └── take_test.rs

零拷贝状态

托管账户存储了谁创建了它、涉及哪些 Mint、预期多少 Token B 以及 PDA 派生参数。使用 Pinocchio,我们不拥有数据;我们借用它来实现零拷贝反序列化:

use pinocchio::Address;
use wincode::{SchemaRead, SchemaWrite};

#[repr(C)]
#[derive(SchemaRead, SchemaWrite)]
pub struct Escrow<'a> {
    maker: &'a [u8; 32],
    mint_a: &'a [u8; 32],
    mint_b: &'a [u8; 32],
    receive: &'a u64,
    seed: &'a u64,
    bump: &'a u8,
}

impl<'a> Escrow<'a> {
    // 手动计算大小: 3*(32) + 2*(8) + 1 = 113 字节
    pub const LEN: usize = 3 * size_of::<[u8; 32]>() + 2 * size_of::<u64>() + size_of::<u8>();
}

#[repr(C)] 确保了可预测的内存布局。结合 wincode 的派生,该结构体创建了指向现有字节切片的指针,而不是对其进行昂贵的复制。

入口点与逻辑

入口点完全没有任何魔术代码。单个字节决定了分发:

use pinocchio::{AccountView, Address, ProgramResult, entrypoint, error::ProgramError};
use pinocchio_pubkey::declare_id;

declare_id!("4AtAjt1xrf6SwnwSh8GjnTJFrkMVZBscPSKKaFG8mQam");
entrypoint!(process_instruction);

pub fn process_instruction(
    _program_id: &Address,
    accounts: &[AccountView],
    instruction_data: &[u8],
) -> ProgramResult {
    let [disc, data @ ..] = instruction_data else {
        return Err(ProgramError::InvalidInstructionData);
    };
    match disc {
        Make::DISCRIMINATOR => Make::try_from((accounts, data))?.process(),
        Take::DISCRIMINATOR => Take::try_from((accounts, data))?.process(),
        _ => Err(ProgramError::InvalidInstructionData),
    }
}

在处理指令时,例如 Make,我们通过跨程序调用 (CPIs) 显式处理 PDA 创建、状态初始化和 Token 转账:

pub fn process(&self) -> ProgramResult {
    // 1. 创建托管 PDA 账户
    let seeds = &[ /* ... */ ];
    ProgramAccount::init::<Escrow>(self.accounts.maker, self.accounts.escrow, seeds, space)?;

    // 2. 创建由托管账户拥有的金库 ATA
    AssociatedToken::init(self.accounts.vault, self.accounts.mint_a, /* ... */)?;

    // 3. 写入托管状态
    let mut escrow = deserialize_mut::<EscrowMut>(escrow_account_data)?;
    escrow.set_inner(maker, mint_a, mint_b, &receive, &seed, &bump);

    // 4. 将 Token 从创建者的 ATA 转账到金库
    Transfer { from: self.accounts.maker_ata, authority: self.accounts.maker,
               to: self.accounts.vault, amount: self.data.amount }.invoke()
}

然而,像这样精简的架构 必须 配合全面的测试,以确保其在生产环境中的可靠性。

使用 Mollusk SVM 进行测试

为了测试这些程序,我们利用 Mollusk 测试框架。Mollusk 在本地直接调用 Solana 虚拟机 (SVM) 指令处理器。它能让我们快速高效地启动引擎。

设置非常简单:

// 我们使用可调试的构造函数初始化 mollusk,稍后会用到它。
let mut mollusk = Mollusk::new_debuggable(&PROGRAM_ADDRESS, "target/deploy/escrow", true);

Mollusk 要求你手动提供所有账户。这迫使你深入理解程序交互的确切状态:

// 为测试状态手动创建一个 Token 账户
let maker_a_data = TokenAccount {
    mint: mint_a_address,
    owner: maker_address,
    amount: 1_000_000,
    delegate: COption::None,
    state: AccountState::Initialized,
    is_native: COption::None,
    delegated_amount: 0,
    close_authority: COption::None,
};
let (maker_a_ata_address, maker_a_ata_account) =
    create_account_for_associated_token_account(maker_a_data);

但同样,仅运行引擎是不够的。我们需要 X 射线。

引入 sbpf-coverage

这正是 sbpf-coverage 所提供的。当你运行结合了 sbpf-coverage 的 Mollusk 测试时,你不再需要猜测。该工具将执行的 SBPF (Solana Berkeley Packet Filter) 指令直接映射回你的 Rust 源代码。

它提供了行级可视化。它能准确告诉你测试触达了哪些代码行,更重要的是,错过了哪些。如果存在隐藏的 panic、未经测试的算术溢出检查,或者你的测试根本无法触及的逻辑分支,sbpf-coverage 会直接在上面点亮明灯。

与其费力地编辑你的 Cargo.toml 来更改发布配置 (release profiles)(这可能很痛苦且容易误提交),你不如干脆跳过编辑文件。你可以手动构建 Solana 程序,通过 RUSTFLAGS 直接传递你需要的确切编译器参数。

只需运行以下构建命令:

RUSTFLAGS="-Copt-level=0 -C strip=none -C debuginfo=2" cargo build-sbf --tools-version v1.54 --arch v1 --debug

注意:[profile.release] 部分的设置是强制性的。如果没有它们,你的 X 射线机将会变得模糊且不准确。

使用这套工具栈可以确保在程序系统上线之前,其最深处不存在隐藏错误或未经测试的路径。

覆盖率报告示例

付诸实践

为了演示这种架构和测试方法论是如何结合在一起的,我准备了一个全面的概念验证。

我最近准备了一个完全使用这套新工具栈构建的完整托管示例程序。这是一个经典的 Solana 托管实现,但其价值在于底层的结构化和测试方式。它使用 Pinocchio 编写程序逻辑,使用 Mollusk 作为测试环境,并利用 sbpf-coverage 来确保行级的审查。

我相信,如果我们真的想让 Solana 成为全球金融基础设施的基准,我们就必须采用航空级的工程标准。我们不能再仅仅满足于听听发动机的声音了。

我邀请你深入研究该代码库,检查代码,并查看实际运行中的测试框架。你可以在这里查看该框架和完整实现:https://github.com/vlady-kotsev/escrow-pinocchio-0.10\ \ ‍

欢迎随时联系交流。我总是很乐意与对高性能架构充满热情的同行开发者们一起切磋。Peace. ✌️

  • 原文链接: limechain.tech/blog/insp...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
LimeChain
LimeChain
江湖只有他的大名,没有他的介绍。