实现一个链上数据存储器项目 | Solana

搭建初始目录结构

我们来搭建一个干净的纯 rust 的 solana 程序项目目录结构, 作为我们的链上数据存储器项目的初始目录结构.

新建空白项目

我们先创建一个普通的 rust 库项目:

$ cargo new --lib pxsol-ss
$ cd pxsol-ss

这个项目会包含我们的主程序逻辑, 也就是将要部署到链上的合约. 项目名称 pxsol-sspxsol-simple-storage 的缩写.

配置编译目标

编辑 Cargo.toml, 添加如下设置:

[lib]
crate-type = ["cdylib", "lib"]

这个是告诉 cargo 我们要生成哪几种类型的 crate.

其中 cdylib 表示编译为一个 c 兼容的动态库. Solana 要求合约以 cdylib 形式编译为 .so 文件, 才能部署到链上. 这个 .so 文件会通过 cargo build-bpf 生成.

另外 lib 表示我们还希望编译成普通的 rust 库, 即 .rlib. 这有助于在本地测试时, 把合约逻辑当作普通 rust 模块来调用, 也方便写单元测试或集成测试.

总结的说: 您需要 cdylib 来部署, lib 来开发和测试.

添加依赖

我们需要引入 solana 的核心 sdk:

[dependencies]
solana-program = "1.18.0"

项目目录结构参考

最终的目录结构看起来可能像这样:

pxsol-ss/
├── Cargo.toml
└── src/
    └── lib.rs

其中 Cargo.toml 中的内容为

[package]
name = "pxsol-ss"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib", "lib"]

[dependencies]
solana-program = "1.18"

创建 lib.rs 骨架

src/lib.rs 中, 先写一个最简单的入口:

solana_program::entrypoint!(process_instruction);

pub fn process_instruction(
    program_id: &solana_program::pubkey::Pubkey,
    accounts: &[solana_program::account_info::AccountInfo],
    data: &[u8],
) -> solana_program::entrypoint::ProgramResult {
    solana_program::msg!("Hello Solana!");
    Ok(())
}

这个程序目前什么也不做, 只会在调用时打印一句话. 但它已经可以被编译成 solana 支持的 bpf 程序了.

尝试编译

运行下面命令, 进行交叉编译:

$ cargo build-sbf -- -Znext-lockfile-bump

如果一切正常, 你会在 target/deploy/ 目录下看到 pxsol_ss.so 文件, 这就是可以部署到 solana 的程序文件.

注意参数 -Znext-lockfile-bump 是一个临时参数, 因为 solana v1.18 依赖于 rustc 1.75, 如果您本地的 rust 版本大于 1.75, 存在一些兼容性问题, 因此需要传入该参数. 当您阅读本书时, 随着版本的更新, 此兼容问题很可能已经修复, 因此您也许可以尝试看看不加入该临时参数. 关于该问题的详细解释, 可以参考该 github 页面.

到这里, 我们已经完成了一个最基本的, 使用原生 rust 编写的 solana 程序框架搭建. 这是实现我们用户自托管链上数据存储器的第一步.

下一步我们将实现账户派生和数据存储的逻辑, 开始真正和 solana 的账户模型打交道.

入口函数解释

Solana 的每一笔交易中都包含一个或多个指令. 一个指令是对链上某个程序账户的调用, 包含三部分, 源码:

pub struct Instruction {
    /// Pubkey of the program that executes this instruction.
    pub program_id: Pubkey,
    /// Metadata describing accounts that should be passed to the program.
    pub accounts: Vec<AccountMeta>,
    /// Opaque data passed to the program for its own interpretation.
    pub data: Vec<u8>,
}

所以, 当一个指令被执行时, 它就会被送给您写的程序的入口函数 process_instruction().

程序入口函数签名

solana_program::entrypoint!(process_instruction);

pub fn process_instruction(
    program_id: &solana_program::pubkey::Pubkey,
    accounts: &[solana_program::account_info::AccountInfo],
    data: &[u8],
) -> solana_program::entrypoint::ProgramResult {
    solana_program::msg!("Hello Solana!");
    Ok(())
}

我们一一解释.

参数 program_id: &solana_program::pubkey::Pubkey 是当前这个合约(程序账户)本身的地址. 在链上, 每个部署的程序都有一个账户地址(公钥). 当交易指令调用这个程序时, solana 会把它写进 program_id 字段中. 您可以用它来做校验, 比如检查某个账户是否是由这个程序创建的 pda. 比如:

let expected_pda = solana_program::pubkey::Pubkey::create_program_address(&[seed], program_id)?;

参数 accounts: &[solana_program::account_info::AccountInfo] 是指令中涉及的账户列表, 对应 Instruction.accounts 里的 Vec<AccountMeta> 项. 这些账户由调用方指定, 并且程序不能随便添加账户, 只能用这些已传入的账户.

每个 AccountInfo 包含:

  1. 账户地址 (key)
  2. 是否是签名者 (is_signer)
  3. 是否是可写账户 (is_writable)
  4. Lamports 余额 (lamports)
  5. 数据 (data)
  6. 所属程序 (owner)
  7. ...

程序通常需要自己根据账户位置索引来解读这些账户. 比如:

let account_user = &accounts[0];
let account_user_pda = &accounts[1];

注意账户的顺序非常重要, 您必须和调用方传入的顺序一一对应.

参数 data: &[u8] 是调用方自定义的指令数据. 通常我们会自己设计一个结构, 然后用 borsh, serde 等序列化方案或手动解析来从字节数组中提取内容. 您可以把它理解为"程序要执行什么操作 + 参数", 类似函数调用时传参.

这个模式和其他链, 比如以太坊的函数调用, 很不一样, solana 追求高性能, 所以要求调用方把所有要用的账户和数据一次性传进来, 程序自己不会查链, 也不扫账户, 而是基于这些参数做纯函数式的运算.

创建数据账户并使其达成租赁豁免

我们开始实现链上数据存储器的第一个功能. 用户首次使用该存储器, 并尝试写入数据到自己的专属数据账户中时. 我们要完成以下几个功能:

  1. 用户第一次上传时, 程序会帮他创建一个 pda 数据账户.
  2. 上传的数据长度可以自定义.
  3. 创建的账户会自动达成租赁豁免, 避免日后被清理.

涉及到的账户

在编写具体功能之前, 我们先思考一下该功能会涉及到哪些账户.

  1. 用户的普通钱包账户. 创建 pda 数据账户需要使用到用户的普通钱包账户作为种子, 同时需要用户的普通钱包账户提供 lamport 以达成数据账户租赁豁免. 该账户的权限应当是可写, 需要签名.
  2. 用户新生成的数据账户. 我们会新建一个 pda 数据账户并在此账户中写入数据. 该账户的权限应当是可写, 无需签名.
  3. 系统账户. 只有系统账户才能创建新的账户. 该账户的权限应当是只读, 无需签名.
  4. Sysvar rent 账户. Solana 通过 sysvar 帐户向程序公开各种集群状态数据. 在本例子中, 我们需要知道需要多少 lamport 才能使数据账户达成租赁豁免, 而这个数字可能由集群动态改变. 因此需要访问 sysvar rent 账户. 该账户的权限应当是只读, 无需签名. 您可以访问此页面 了解更多关于 sysvar 账户的信息.

总结账户列表如下:

账户索引 地址 需要签名 可写 权限(0-3) 角色
0 ... 3 用户的普通钱包账户
1 ... 1 用户的数据账户
2 1111111111... 0 System
3 SysvarRent... 0 Sysvar rent

从入口函数 process_instruction 的 accounts 参数中获取各个账户信息代码如下:

let accounts_iter = &mut accounts.iter();
let account_user = solana_program::account_info::next_account_info(accounts_iter)?;
let account_data = solana_program::account_info::next_account_info(accounts_iter)?;
let _ = solana_program::account_info::next_account_info(accounts_iter)?; // Program system
let _ = solana_program::account_info::next_account_info(accounts_iter)?; // Program sysvar rent

计算租赁豁免

Solana 提供了一个函数可以查询系统规定的租赁豁免门槛:

let rent_exemption = solana_program::rent::Rent::get()?.minimum_balance(data.len());

参数 data.len() 是您准备在 pda 账户中存储的字节数, 返回值 rent_exemption 是为租赁豁免所需的 lamport 数量.

派生 PDA 数据账户地址

您需要使用 solana_program::pubkey::Pubkey::find_program_address 来获取 pda 账户地址以及其 bump 值. 在本示例中, 我们只需要使用到 bump 的值.

let bump_seed = solana_program::pubkey::Pubkey::find_program_address(&[&account_user.key.to_bytes()], program_id).1;

判断 PDA 是否已经存在

Solana 的 sdk 里并没有直接提供可供判断一个账户是否存在的函数, 因此我们使用以下方式来进行判断. 这行代码的依据是任何存在的账户都必须达成租赁豁免, 因此存在的账户的余额必不可能为零.

if **account_data.try_borrow_lamports().unwrap() == 0 {
    // Data account is not initialized.
}

创建 PDA 账户

您需要使用系统程序 solana_program::system_instruction::create_account 创建账户.

solana_program::system_instruction::create_account(
    account_user.key,
    account_data.key,
    rent_exemption,
    data.len() as u64,
    program_id,
)

由于 pda 没有私钥, 不能自己签名, 所以要用程序的签名种子进行签名.

solana_program::program::invoke_signed(
    &solana_program::system_instruction::create_account(
        account_user.key,
        account_data.key,
        rent_exemption,
        data.len() as u64,
        program_id,
    ),
    accounts,
    &[&[&account_user.key.to_bytes(), &[bump_seed]]],
)?;

Solana rust sdk 中有一个与 invoke_signed() 函数非常相似的 invoke() 函数, 它们的作用都是用于执行一个指令, 但是功能上存在细微的差异. 在这个例子中, 我们要操作的账户是 pda, 也就是说这个账户没有私钥, 不能真正签名, 但您(程序)作为它的所有者, 有权代表它执行操作. 这个时候就不能用普通的 invoke(), 而是要用 invoke_signed(), 让 solana 系统知道: "这个账户虽然没有签名, 但我是它的创建者, 我现在代表它签名了".

完成! 您现在拥有了一个租赁豁免的 pda 数据账户.

写入数据

最后, 我们向数据账户写入数据. 很简单, 对吧?

account_data.data.borrow_mut().copy_from_slice(data);

完整代码

#![allow(unexpected_cfgs)]

use solana_program::sysvar::Sysvar;

solana_program::entrypoint!(process_instruction);

pub fn process_instruction(
    program_id: &solana_program::pubkey::Pubkey,
    accounts: &[solana_program::account_info::AccountInfo],
    data: &[u8],
) -> solana_program::entrypoint::ProgramResult {
    let accounts_iter = &mut accounts.iter();
    let account_user = solana_program::account_info::next_account_info(accounts_iter)?;
    let account_data = solana_program::account_info::next_account_info(accounts_iter)?;
    let _ = solana_program::account_info::next_account_info(accounts_iter)?; // Program system
    let _ = solana_program::account_info::next_account_info(accounts_iter)?; // Program sysvar rent

    let rent_exemption = solana_program::rent::Rent::get()?.minimum_balance(data.len());
    let bump_seed = solana_program::pubkey::Pubkey::find_program_address(&[&account_user.key.to_bytes()], program_id).1;

    // Data account is not initialized. Create an account and write data into it.
    if **account_data.try_borrow_lamports().unwrap() == 0 {
        solana_program::program::invoke_signed(
            &solana_program::system_instruction::create_account(
                account_user.key,
                account_data.key,
                rent_exemption,
                data.len() as u64,
                program_id,
            ),
            accounts,
            &[&[&account_user.key.to_bytes(), &[bump_seed]]],
        )?;
        account_data.data.borrow_mut().copy_from_slice(data);
        return Ok(());
    }
    Ok(())
}

数据账户内容更新及动态租赁调节

Solana 的账户存储需要租金, 数据越长, 租金越贵. 如果数据更新后变长了, 数据账户租金不够, 账户就不再租赁豁免; 如果数据更新后变短了, 那用户其实多交了租金, 程序可以把多余的部分还给用户!

本篇文章就来教您如何实现动态租赁调节.

更新数据账户内容

链上账户可以使用 .data.borrow_mut() 来更新内容, 但大小不能变, 所以通常需要重新创建或使用 .realloc() 重分配数据账户空间.

// Realloc space.
account_data.realloc(data.len(), false)?;
// Overwrite old data with new data.
account_data.data.borrow_mut().copy_from_slice(data);

租金补足

如果新数据比老数据更长, 您需要使用系统程序 solana_program::system_instruction::transfer 为 pda 账户添加更多租金.

// Fund the data account to let it rent exemption.
if rent_exemption > account_data.lamports() {
    solana_program::program::invoke(
        &solana_program::system_instruction::transfer(
            account_user.key,
            account_data.key,
            rent_exemption - account_data.lamports(),
        ),
        accounts,
    )?;
}

租金退款

如果新数据比老数据更短, 则从 pda 账户中获取退款. 获取退款不需要执行系统程序, 您只需要修改两个账户中的余额即可.

// Withdraw excess funds and return them to users. Since the funds in the pda account belong to the program, we do
// not need to use instructions to transfer them here.
if rent_exemption < account_data.lamports() {
    **account_user.lamports.borrow_mut() = account_user.lamports() + account_data.lamports() - rent_exemption;
    **account_data.lamports.borrow_mut() = rent_exemption;
}

您可能好奇, 为什么这个时候不需要使用 solana_program::system_instruction::transfer? 问题的答案在于权限. 您是否还记得每个数据账户都拥有所有者程序? 所谓的所有者程序, 就是能自由操控数据账户而不需要额外的权限.

  • 租金补足过程中, 程序转移了您的钱包账户的资金, 因此必须得到您的授权.
  • 租金退款过程中, 程序转移了自己控制的数据账户的资金, 因此无需您授权.

完整链上代码

完整链上数据存储器的代码:

#![allow(unexpected_cfgs)]

use solana_program::sysvar::Sysvar;

solana_program::entrypoint!(process_instruction);

pub fn process_instruction(
    program_id: &solana_program::pubkey::Pubkey,
    accounts: &[solana_program::account_info::AccountInfo],
    data: &[u8],
) -> solana_program::entrypoint::ProgramResult {
    let accounts_iter = &mut accounts.iter();
    let account_user = solana_program::account_info::next_account_info(accounts_iter)?;
    let account_data = solana_program::account_info::next_account_info(accounts_iter)?;
    let _ = solana_program::account_info::next_account_info(accounts_iter)?; // Program system
    let _ = solana_program::account_info::next_account_info(accounts_iter)?; // Program sysvar rent

    let rent_exemption = solana_program::rent::Rent::get()?.minimum_balance(data.len());
    let bump_seed = solana_program::pubkey::Pubkey::find_program_address(&[&account_user.key.to_bytes()], program_id).1;

    // Data account is not initialized. Create an account and write data into it.
    if **account_data.try_borrow_lamports().unwrap() == 0 {
        solana_program::program::invoke_signed(
            &solana_program::system_instruction::create_account(
                account_user.key,
                account_data.key,
                rent_exemption,
                data.len() as u64,
                program_id,
            ),
            accounts,
            &[&[&account_user.key.to_bytes(), &[bump_seed]]],
        )?;
        account_data.data.borrow_mut().copy_from_slice(data);
        return Ok(());
    }

    // Fund the data account to let it rent exemption.
    if rent_exemption > account_data.lamports() {
        solana_program::program::invoke(
            &solana_program::system_instruction::transfer(
                account_user.key,
                account_data.key,
                rent_exemption - account_data.lamports(),
            ),
            accounts,
        )?;
    }
    // Withdraw excess funds and return them to users. Since the funds in the pda account belong to the program, we do
    // not need to use instructions to transfer them here.
    if rent_exemption < account_data.lamports() {
        **account_user.lamports.borrow_mut() = account_user.lamports() + account_data.lamports() - rent_exemption;
        **account_data.lamports.borrow_mut() = rent_exemption;
    }
    // Realloc space.
    account_data.realloc(data.len(), false)?;
    // Overwrite old data with new data.
    account_data.data.borrow_mut().copy_from_slice(data);

    Ok(())
}

demo

链上前端: https://pxsol-ss-pinocchio.vercel.app/

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论