Solana笔记 07.实操:账户模型(Accounts)

跟我一起从0开始学习Solana合约开发,一起实操,一起做项目。这是一个系列文章,系统地记录了我的学习笔记。

在上一篇文章中,我们已经学习了账户的基础概念。今天,我们将通过代码操作账户,进一步理解 Solana 的账户模型。

从网络初始化到程序部署

以下内容是对上一讲的知识总结。

在 Solana 上,每个账户都有一个所有者,所有者通常是一个程序。需要注意的是,程序本身也是一个账户,而程序账户的所有者通常是系统原生程序(如 BPF Loader)。为了帮助理解账户之间的关系,我绘制了一张简图如下:

solana_account_owner.png

通过上图可以看到,Solana 的账户模型清晰地定义了所有权关系,下面展开讲讲各个步骤和角色:

Solana 网络初始化

在网络启动时,Solana 的 NativeLoader 是一个核心原生程序,专门用于加载和管理其他原生程序。比如:

  • 系统程序(System Program)负责账户生成和基本功能。

  • BPF Loader 管理可执行程序账户。

钱包地址的生成

通过 solana-keygen 生成的钱包地址,其实是由 系统程序 创建的账户。这些账户的所有权归属于系统程序,并设计为通用账户,既可以用来存储 SOL,也可以用作交易手续费的支付账户。

程序部署

当用户将智能合约部署到 Solana 网络时,实际会经过以下步骤:

  • 系统程序创建一个 程序账户,并将其所有权转移给 BPF Loader

  • 这个程序账户存储用户上传的可执行代码,供 Solana 网络调用。

数据账户的创建

如果用户的程序需要持久化存储数据,在部署时会创建一个 数据账户,流程如下:

  • 系统程序为程序生成数据账户。

  • 数据账户的所有权被转移到用户的自定义程序中。

  • 这使得自定义程序可以读写和管理其对应的数据账户。

通过以上流程,可以更清晰地理解 Solana 的账户模型。初学者在学习这些知识时,无需深入研究 SPL 标准,只需掌握账户创建和程序部署的基本逻辑即可

动动手,验证一下

为了更好地理解账户的概念和账户之间的关系,可以尝试以下操作。

运行以下命令:

solana account 11111111111111111111111111111111

输出结果示例:

Public Key: 11111111111111111111111111111111
Balance: 0.000000001 SOL
Owner: NativeLoader1111111111111111111111111111111
Executable: true
Rent Epoch: 0
Length: 14 (0xe) bytes
0000:   73 79 73 74  65 6d 5f 70  72 6f 67 72  61 6d       system_program

在这段输出中:

  • Public Key: 11111111111111111111111111111111 是系统程序的公钥(地址)。

  • Owner: NativeLoader1111111111111111111111111111111 是系统程序的所有者。这表明,系统程序的执行和管理权限归属于 NativeLoader。

  • Executable: 标记为 true,表示这是一个可执行程序。

  • Length: 数据字段的大小为 14 字节,对应存储了 system_program 的标识符。

你可以将其他账户的公钥(例如你的钱包地址程序账户数据账户)替换到命令中,观察输出内容,查看每个账户的 Owner 字段。通过这种方式,你可以验证账户之间的所有权关系。

自定义数据账户

在编写 Solana 程序并将其部署到 Solana 网络时,系统程序会自动为程序创建对应的程序账户。但我们有时需要显式创建特定账户来持久化一些状态数据,这就是数据账户

那么,如何自定义数据账户呢?接下来,让我们一步步实现。

空账户

首先,我们从 Anchor 框架生成的初始化代码开始。在 src/lib.rs 文件中,通常会看到类似下面的代码:

#[program]
pub mod guide_1 {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        // 省略...
    }
}

#[derive(Accounts)]
pub struct Initialize {}

在这段代码中,最后一行定义了一个 Initialize 结构体。它与 initialize 函数的第一个参数 Context<Initialize> 是一一对应的。通过将这个结构体传入 Context 中,我们就定义了一个账户上下文

提示: Initialize 是一个自定义名称,可以任意命名,只需与 Context 中的泛型保持一致。

这个 Initialize 结构体用来定义程序调用时需要的账户集合,这些账户通常用于验证输入。

空账户上下文

注意,这里 Initialize 是一个空结构体,这意味着当前程序没有显式需要的数据账户。换句话说,这段代码表明,程序逻辑不依赖任何额外的数据账户,因此在部署至 Solana 网络时,不会创建额外的数据账户

#[derive(Accounts)] 的作用

代码中的 #[derive(Accounts)] 宏 是由 Anchor 框架提供的。Anchor 会根据结构体中定义的字段,决定需要哪些账户、如何验证这些账户,以及是否需要初始化新的账户。

虽然 Initialize 是一个空结构体,但它依然必须存在。为什么呢?

空结构体的必要性

我们来思考一个问题:能否移除 Initialize 结构体并省略 #[derive(Accounts)] 宏?比如直接删掉以下代码:

#[derive(Accounts)]
pub struct Initialize {}

答案是不可以!原因如下:

  • 所有程序的入口函数都要求第一个参数是 Context<T> 类型,T 是账户上下文。

  • 即使是空的账户上下文,Anchor 仍然需要它来传递基本信息,比如 program_id 字段,它表示当前程序的 Program ID

因此,当不需要显式定义数据账户时,我们仍需创建一个空的账户结构体作为上下文。

空账户结构体的实际用途

那么问题来了:这种没有字段的空账户结构体有实际用途吗?答案是。在 Solana 的真实业务场景中,以下情况可能用到空账户上下文:

  • 验证逻辑:某些程序仅用于验证条件,比如检查签名是否有效、某些账户的状态是否符合要求等。

  • 无需持久化状态:当程序的逻辑只依赖现有账户的数据,而不需要新增或修改状态时,空账户结构体是合适的选择。

数据账户

接下来,我们将尝试创建一个数据账户!

定义账户数据结构

把以下代码追加到src/lib.rs文件的末尾:

#[account]
pub struct MyAccount {
    pub data: Vec<u8>, // 存储动态数组的数据字段
}

代码解析

#[account]: 标记和初始化账户数据结构

当数据需要持久化保存在 Solana 区块链时,它会以序列化后的字节形式存储。因此,我们需要对账户数据结构进行序列化和反序列化。如果你使用原生 Rust 开发,这些步骤需要手动处理。

然而,使用 Anchor 框架时, #[account] 宏 会自动为该结构体生成所需的序列化和反序列化逻辑。这样,开发者可以专注于业务逻辑,而无需手动编写低级的序列化代码。

pub data: Vec<u8>: 定义账户中的字段

Vec<u8> 是一个动态数组类型。这里表示账户中存储的数据是一个可以动态调整大小的 u8 类型数组。如果不清楚动态数组的概念,可以参考我之前的文章。

总结:上述代码定义了一个 MyAccount 结构体,表示链上一个数据账户,用于存储一个动态数组。

声明账户上下文

将以下代码添加进src/lib.rs文件中:

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(init, payer = user, space = 8 + 1024)] 
    pub my_account: Account<'info, MyAccount>,
    #[account(mut)]
    pub user: Signer<'info>,
    pub system_program: Program<'info, System>,
}

代码解析

#[derive(Accounts)]: 自动生成账户验证逻辑

#[derive(Accounts)] 是 Anchor 框架提供的,用于自动生成账户验证逻辑。它会根据定义的结构体字段,生成账户的验证、序列化等相关代码。

例如,这里通过 #[derive(Accounts)] 自动为 Initialize 结构体实现了 Accounts trait,从而处理账户的验证和初始化逻辑。

trait 是 Rust 的一个特性,你可以类比为其他编程语言的接口,实现了 trait(接口) 就具备了相关的能力。

生命周期标注 <'info>

&lt;'info> 是 Rust 中的生命周期标注,用于确保账户引用在整个调用期间有效。Anchor 会自动处理生命周期管理,因此开发者无需深入理解这一部分,简单地将其视为必要的语法即可。

账户初始化和空间分配

以下代码指定了账户初始化规则:

#[account(init, payer = user, space = 8 + 1024)] 
pub my_account: Account&lt;'info, MyAccount>,

具体参数解析:

  • init: 表示这是一个新账户,需初始化。

  • payer = user: 指定由 user 签名者支付账户租金。(对租金机制不熟悉,可以看我上一篇文章)。

  • space = 8 + 1024: 指定账户所需存储空间(单位:字节)。其中 8 字节是 Anchor 自动添加的账户数据头(用于存储账户类型信息),1024 字节是 MyAccount 的数据大小。

当程序被调用时,Anchor 会检查传入的账户是否符合这些定义的规则(例如是否已经初始化、是否有足够的空间等)。

预定义类型

在 Anchor 中,AccountSignerProgram 是 Anchor 提供的预定义类型,专门用于定义账户、签名者和程序。这些类型通过 use anchor_lang::prelude::*; 引入。

其中,system_program 字段通常使用 Program&lt;'info, System> 类型,用于指向系统程序(System Program)。

可变账户

#[account(mut)] 标记账户为可变。只有被标记为 mut 的账户才允许修改,例如为账户充值或更新数据。

这里表示 user 账户的 Lamport(Solana 的原生代币)余额可能会减少(支付租金)。

编写程序逻辑

接下来,我们向账户中添加一些数据。修改 initialize 函数的代码如下:

pub fn initialize(ctx: Context&lt;Initialize>) -> Result&lt;()> {
    msg!("Greetings from: {:?}", ctx.program_id);

    let my_account = &mut ctx.accounts.my_account;
    my_account.data.push(42);
    my_account.data.push(7);
    msg!("data: {:?}", my_account.data);

    Ok(())
}

代码解析

数据操作

  • &mut ctx.accounts.my_account 获取一个可变引用,允许更新账户数据。

  • my_account.data.push(42) 向账户的动态数组中添加值。

Ok(()) 的作用?

Ok(()) 是函数的返回值,表示函数执行成功且不返回任何数据(相当于其他语言中的 void)。

动手试试吧

到这里,代码都讲完了,src/lib.rs文件的完整代码如下:

use anchor_lang::prelude::*;

declare_id!("7pR9Lstthnphgx2bLFo6WW4gbRJmGiJDujL4wxYRFRWg");

#[program]
pub mod guide_1 {
    use super::*;

    pub fn initialize(ctx: Context&lt;Initialize>) -> Result&lt;()> {
        msg!("Greetings from: {:?}", ctx.program_id);

        let my_account = &mut ctx.accounts.my_account;
        my_account.data.push(42);
        my_account.data.push(7);
        msg!("data: {:?}", my_account.data);

        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize&lt;'info> {
    #[account(init, payer = user, space = 8 + 1024)] 
    pub my_account: Account&lt;'info, MyAccount>,
    #[account(mut)]
    pub user: Signer&lt;'info>,
    pub system_program: Program&lt;'info, System>, // 系统程序(必须包含)
}

#[account]
pub struct MyAccount {
    pub data: Vec&lt;u8>, // 存储动态数组的数据字段
}

tests/guide_1.ts 文件中,初始测试代码如下:

it("Is initialized!", async () => {
    const tx = await program.methods.initialize().rpc();
    console.log("Your transaction signature", tx);
});

运行测试时,可能会看到如下错误:

Error Code: TryingToInitPayerAsProgramAccount. Error Number: 4101. Error Message: You cannot/should not initialize the payer account as a program account.

原因:默认情况下,Anchor 会自动将签名者 user 设置为新账户的支付者(payer),但签名者账户不能作为数据账户初始化。

解决:显式指定一个新账户作为数据账户。

解决报错问题

修改测试代码,明确创建并使用一个新账户:

it("Is initialized!", async () => {
    const provider = anchor.getProvider();

    // 创建新账户
    const newAccount = Keypair.generate();
    const lamports = await provider.connection.getMinimumBalanceForRentExemption(8 + 1024);

    // 请求空投
    const tx = await provider.connection.requestAirdrop(newAccount.publicKey, lamports);
    await provider.connection.confirmTransaction(tx);

    // 调用程序并初始化账户
    const txSignature = await program.methods
      .initialize()
      .accounts({
        myAccount: newAccount.publicKey,
        user: provider.publicKey,
      })
      .signers([newAccount])
      .rpc();

    console.log("Transaction Signature:", txSignature);
});

运行修改后的代码,测试应成功通过,并在日志中看到如下输出:

Program log: Greetings from: 7pR9Lstthnphgx2bLFo6WW4gbRJmGiJDujL4wxYRFRWg
Program log: data: [42, 7]

思考题

以下问题可帮助你更深入地理解账户的使用:

  1. 修改 MyAccount,为其添加新字段并在 initialize 函数中赋值。测试程序,观察结果。

  2. 尝试调整账户初始化的 space,看看是否会报错。

  3. 移除 #[account(init)]#[account(mut)],观察是否导致账户验证错误。

通过这些练习,你将对 Solana 和 Anchor 的账户管理机制有更深的理解。

到这里,本文就讲完了,下一篇文章我将带你做一个案例。如果你想提前看到我的更新,可以关注我的公众号:认知那些事

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

0 条评论

请先 登录 后评论
认知那些事
认知那些事
0x2b62...95a0
人立于天地之间,必然有我们的出路。