跟我一起从0开始学习Solana合约开发,一起实操,一起做项目。这是一个系列文章,系统地记录了我的学习笔记。
在上一篇文章中,我们已经学习了账户的基础概念。今天,我们将通过代码操作账户,进一步理解 Solana 的账户模型。
以下内容是对上一讲的知识总结。
在 Solana 上,每个账户都有一个所有者,所有者通常是一个程序。需要注意的是,程序本身也是一个账户,而程序账户的所有者通常是系统原生程序(如 BPF Loader)。为了帮助理解账户之间的关系,我绘制了一张简图如下:
通过上图可以看到,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>
<'info>
是 Rust 中的生命周期标注,用于确保账户引用在整个调用期间有效。Anchor 会自动处理生命周期管理,因此开发者无需深入理解这一部分,简单地将其视为必要的语法即可。
账户初始化和空间分配
以下代码指定了账户初始化规则:
#[account(init, payer = user, space = 8 + 1024)]
pub my_account: Account<'info, MyAccount>,
具体参数解析:
init
: 表示这是一个新账户,需初始化。
payer = user
: 指定由 user 签名者支付账户租金。(对租金机制不熟悉,可以看我上一篇文章)。
space = 8 + 1024
: 指定账户所需存储空间(单位:字节)。其中 8
字节是 Anchor 自动添加的账户数据头(用于存储账户类型信息),1024
字节是 MyAccount 的数据大小。
当程序被调用时,Anchor 会检查传入的账户是否符合这些定义的规则(例如是否已经初始化、是否有足够的空间等)。
预定义类型
在 Anchor 中,Account
、Signer
和 Program
是 Anchor 提供的预定义类型,专门用于定义账户、签名者和程序。这些类型通过 use anchor_lang::prelude::*;
引入。
其中,system_program 字段通常使用 Program<'info, System>
类型,用于指向系统程序(System Program)。
可变账户
#[account(mut)]
标记账户为可变。只有被标记为 mut 的账户才允许修改,例如为账户充值或更新数据。
这里表示 user 账户的 Lamport(Solana 的原生代币)余额可能会减少(支付租金)。
接下来,我们向账户中添加一些数据。修改 initialize 函数的代码如下:
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
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<Initialize>) -> Result<()> {
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<'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>, // 系统程序(必须包含)
}
#[account]
pub struct MyAccount {
pub data: Vec<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]
以下问题可帮助你更深入地理解账户的使用:
修改 MyAccount
,为其添加新字段并在 initialize 函数中赋值。测试程序,观察结果。
尝试调整账户初始化的 space
,看看是否会报错。
移除 #[account(init)]
或 #[account(mut)]
,观察是否导致账户验证错误。
通过这些练习,你将对 Solana 和 Anchor 的账户管理机制有更深的理解。
到这里,本文就讲完了,下一篇文章我将带你做一个案例。如果你想提前看到我的更新,可以关注我的公众号:认知那些事
。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!