本文为 Solana 2024 训练营 project 2 anchor 构建 voting 合约我自己的一些见解,以及 solidity 生态入门 Solana 生态的一些思考角度
对于 Solana 合约的编写,个人认为包括如下流程:
首先新建 Solana 合约项目,此时,会自动为我们派生 program id。接着为我们的 Solana program 定义 instruction,而一个 instruction 的执行,需要对应的上下文即 Context,比如,我们的 instruction 是来 init 一个 poll,那么我们就可以声明一个 Context Account。
对于 context 的生成,我们依赖 anchor 的宏。
先回归到基础的 rust:
#[account]
这样的类似 #[...]
是 rust 中的属性宏#[derive(Account)
这样的,类似 [derive(...)]
是 rust 中的派生宏回到 Solana program 中,在 anchor 框架中声明一个 context 就很简单了,使用 anchor 已经实现的派生宏 #[derive(Account)]
即可。当然,我们在执行 instruction 的时候,需要一些参数,这些参数的声明,可以使用 anchor 框架的属性宏 #[instruction(...)]
:
// 账户结构(Solana 上的一切都是 Account,同时 Account 是无状态的)
#[derive(Accounts)] // Anchor 宏,派生 Accounts
#[instruction(poll_id: u64)] // 说明此账户结构依赖于调用者提供的参数 poll_id
pub struct InitializePoll<'info> {
#[account(mut)]
pub signer: Signer<'info>, // 用于支付的 Signer,mut 表示此账户可以在指令中被修改
#[account(
init, // 代表这是一个新账户的初始化
payer=signer, // 新账户的租金由 `signer` 支付
space = 8 + Poll::INIT_SPACE, // 定义 account 所需的空间
// 8 为 Solana Account数据头长度
// Poll::INIT_SPACE 是通过宏得出的 Poll 所需的空间
// seeds 和 bump 用于 PDA,`poll_id` 作为种子,`bump` 用于确保地址的唯一性
seeds= [poll_id.to_le_bytes().as_ref()],
bump,
)]
pub poll: Account<'info, Poll>,
// 指向 Solana system program,用于创建账户和管理基础功能
pub system_program: Program<'info, System>,
}
上述代码 context 例子,我们来 init 一个 poll 投票,这里实际是新建了一个 data account。Solana program 是无状态的。Solana 存储数据都是通过 Data account 来存储的。 我们 InitalizePoll 这个 context 对应的 instruction 就是来创建一个 poll 的。在创建之前,我们先需要对其进行声明。
data account 的声明,我们可以使用 anchor 的 #[account]
属性宏。由于区块链的分布式特点,Solana 和 solidity 一样,链上存储数据肯定是需要 gas 的。对于 gas 的衡量,肯定是依赖于数据占据的空间。所以,Solana 的 data account 的大小一定是明确的。 对于动态类型,我们要限制他们的最大长度。动态类型的 gas 费用就按照定义的最大长度收取。这里提前说一点,在我们 context 中声明新建 data account 时,是需要明确指出其大小的。我们可以使用 anchor 的 #[derive(InitSpace)]
派生宏自动计算其空间。动态数据类型(例如 String)我们需要 #[max_len()]
属性宏限制大小:
#[account] // 标记此结构用于账户数据存储。
#[derive(InitSpace)] // 自动为此结构生成存储所需的初始空间大小。
pub struct Poll {
pub poll_id: u64,
#[max_len(280)]
pub description: String,
pub poll_start: u64,
pub poll_end: u64,
pub candidate_amount: u64,
}
接着回到我们的 context 中。当我们新建一个 data account 的时候,我们需要在对应的 context 中添加如下属性宏:
#[account(mut)]
pub signer: Signer<'info>, // 用于支付的 Signer,mut 表示此账户可以在指令中被修改
#[account(
init, // 代表这是一个新账户的初始化
payer=signer, // 新账户的租金由 `signer` 支付
space = 8 + Poll::INIT_SPACE, // 定义 account 所需的空间
// 8 为 Solana Account数据头长度
// Poll::INIT_SPACE 是通过宏得出的 Poll 所需的空间
// seeds 和 bump 用于 PDA,`poll_id` 作为种子,`bump` 用于确保地址的唯一性
seeds= [poll_id.to_le_bytes().as_ref()],
bump,
)]
pub poll: Account<'info, Poll>,
第一行标记 signer 为可变账户的原因是因为当我们通过 instruction 来新建 data account 的时候,signer 需要支付一定的费用(租金 rent)修改了 signer 的余额,所以需要可变(注意,由于 gas 导致的 signer 余额发生变化,不需要标记 signer 为 mut,因为这一部分是由 Solana 的 system program 来执行的)
这里基本是固定的了。对于 13 行 pub poll: Account<'info, Poll>
,'info
是生命周期,Poll 指定对应的 data account。
当我们新建一个 Data account 的时候,实际上使用的是 solana 的 PDA(program derived address 有点像 Solidity 的 create2)。
// seeds 和 bump 用于 PDA,`poll_id` 作为种子,`bump` 用于确保地址的唯一性
seeds= [poll_id.to_le_bytes().as_ref()],
bump,
这两部分就是用来计算生成的 poll data account 的 program id(地址)。
现在。context 完成后,我们就可以在 program 中声明我们的 instruction 了:
#![allow(clippy::result_large_err)]
use anchor_lang::prelude::*;
declare_id!("4vGpJ1U7dC49BVp7jLYm7mvqidCqKkcx8bGDo9buoMnG");
// program 声明(合约声明)
#[program]
pub mod voting {
use super::*;
pub fn initialize_poll(
ctx: Context<InitializePoll>,
poll_id: u64,
description: String,
poll_start: u64,
poll_end: u64,
) -> Result<()> {
let poll = &mut ctx.accounts.poll; // 这样我们可以更新投票账户
poll.poll_id = poll_id;
poll.description = description;
poll.poll_start = poll_start;
poll.poll_end = poll_end;
poll.candidate_amount = 0;
Ok(())
}
}
#[program] 属性宏,使用 anchor 宏,声明 program。可以看到,我们指定了执行的上下文:
ctx: Context<InitializePoll>
,这里的 Context 是 anchor 封装的泛型,传递我们刚刚声明的 anchor context 类型即可。而 instruction 里面的,便是对上下文 ctx 中,accounts poll 的赋值了。
接下来是初始化候选者:
首先还是,先把 program 中的 instruction 先声明:
pub fn initialize_candidate(
ctx: Context<InitalizeCandidate>,
) -> Result<()> {
Ok(())
}
后续需要什么,我们再补充即可。接下来是 initialize_candidate 这个 instruction 的 context。由于我们是 init 一个候选者,肯定需要候选者的 data account。声明 data account:
#[account]
#[derive(InitSpace)]
pub struct Candidate {
#[max_len(32)]
pub candidate_name: String,
pub candidate_votes: u64,
}
完成 InitalizeCandidate Context:
#[derive(Accounts)]
#[instruction(candidate_name: String, poll_id:u64)]
pub struct InitalizeCandidate<'info> {
// 我们要进行投票,那么就需要 signer 以及 投票的 poll
#[account(mut)]
pub signer: Signer<'info>,
// 但是实际上我们不需要创建 poll,需要的是引用,所以前三项可以删除
#[account(
mut,
seeds=[poll_id.to_le_bytes().as_ref()],
bump
)]
pub poll: Account<'info, Poll>,
// 最后我们要新建一个 account,这个 account 是一个候选人 candidate
#[account(
init,
payer = signer,
space = 0+ Candidate::INIT_SPACE,
seeds=[candidate_name.as_bytes(), poll_id.to_le_bytes().as_ref()],
bump
)]
pub candidate: Account<'info, Candidate>,
pub system_program: Program<'info, System>,
}
需要注意的是,我们需要引用 poll data account(因为我们刚刚已经新建了),而引用它,我们需要知道它的地址。由于 poll 是通过 program 的 instruction 通过 PDA 出来的,我们使用:
#[account(
mut,
seeds=[poll_id.to_le_bytes().as_ref()],
bump
)]
方可计算出来。这里由于我们最终的 instruction 修改了 poll,我们需要标记其为 mut 可变的。
完成 instruction:
pub fn initialize_candidate(
ctx: Context<InitializeCandidate>,
candidate_name: String,
_poll_id: u64,
) -> Result<()> {
let candidate = &mut ctx.accounts.candidate;
let poll = &mut ctx.accounts.poll;
poll.candidate_amount += 1;
candidate.candidate_name = candidate_name;
candidate.candidate_votes = 0;
Ok(())
}
最终关于 vote 部分的代码,逻辑相同,不多赘述。完整代码:
use anchor_lang::prelude::*;
declare_id!("coUnmi3oBUtwtd9fjeAvSsJssXh5A5xyPbhpewyzRVF");
// program 声明(合约声明)
#[program]
pub mod votingdapp {
use super::*;
pub fn initialize_poll(
ctx: Context<InitalizePoll>,
poll_id: u64,
description: String,
poll_start: u64,
poll_end: u64,
) -> Result<()> {
let poll = &mut ctx.accounts.poll; // 这样我们可以更新投票账户
poll.poll_id = poll_id;
poll.description = description;
poll.poll_start = poll_start;
poll.poll_end = poll_end;
poll.candidate_amount = 0;
Ok(())
}
pub fn initialize_candidate(
ctx: Context<InitalizeCandidate>,
candidate_name: String,
_poll_id: u64,
) -> Result<()> {
let candidate = &mut ctx.accounts.candidate;
let poll = &mut ctx.accounts.poll;
poll.candidate_amount += 1;
candidate.candidate_name = candidate_name;
candidate.candidate_votes = 0;
Ok(())
}
pub fn vote(ctx: Context<Vote>, _candidate_name: String, _poll_id: u64) -> Result<()> {
let candidate = &mut ctx.accounts.candidate;
candidate.candidate_votes += 1;
msg!("Voted for candidate: {}", candidate.candidate_name);
msg!("Votes: {}", candidate.candidate_votes);
Ok(())
}
}
#[derive(Accounts)]
#[instruction(candidate_name: String, poll_id:u64)]
pub struct Vote<'info> {
// 我们要进行投票,那么就需要 signer 以及 投票的 poll
pub signer: Signer<'info>,
// 但是实际上我们不需要创建 poll,需要的是引用,所以前三项可以删除
#[account(
seeds=[poll_id.to_le_bytes().as_ref()],
bump
)]
pub poll: Account<'info, Poll>,
// 最后我们要新建一个 account,这个 account 是一个候选人 candidate
#[account(
mut,
seeds=[candidate_name.as_bytes(), poll_id.to_le_bytes().as_ref()],
bump
)]
pub candidate: Account<'info, Candidate>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
#[instruction(candidate_name: String, poll_id:u64)]
pub struct InitalizeCandidate<'info> {
// 我们要进行投票,那么就需要 signer 以及 投票的 poll
#[account(mut)]
pub signer: Signer<'info>,
// 但是实际上我们不需要创建 poll,需要的是引用,所以前三项可以删除
#[account(
mut,
seeds=[poll_id.to_le_bytes().as_ref()],
bump
)]
pub poll: Account<'info, Poll>,
// 最后我们要新建一个 account,这个 account 是一个候选人 candidate
#[account(
init,
payer = signer,
space = 0+ Candidate::INIT_SPACE,
seeds=[candidate_name.as_bytes(), poll_id.to_le_bytes().as_ref()],
bump
)]
pub candidate: Account<'info, Candidate>,
pub system_program: Program<'info, System>,
}
#[account]
#[derive(InitSpace)]
pub struct Candidate {
#[max_len(32)]
pub candidate_name: String,
pub candidate_votes: u64,
}
// 账户结构(Solana 上的一切都是 Account,同时 Account 是无状态的)
#[derive(Accounts)] // Anchor 宏,派生 Accounts
#[instruction(poll_id: u64)] // 说明此账户结构依赖于调用者提供的参数 poll_id
pub struct InitalizePoll<'info> {
#[account(mut)]
pub signer: Signer<'info>, // 用于支付的 Signer,mut 表示此账户可以在指令中被修改
#[account(
init, // 代表这是一个新账户的初始化
payer=signer, // 新账户的租金由 `signer` 支付
space = 8 + Poll::INIT_SPACE, // 定义 account 所需的空间
// 8 为 Solana Account数据头长度
// Poll::INIT_SPACE 是通过宏得出的 Poll 所需的空间
// seeds 和 bump 用于 PDA,`poll_id` 作为种子,`bump` 用于确保地址的唯一性
seeds= [poll_id.to_le_bytes().as_ref()],
bump,
)]
pub poll: Account<'info, Poll>,
// 指向 Solana system program,用于创建账户和管理基础功能
pub system_program: Program<'info, System>,
}
#[account] // 标记此结构用于账户数据存储。
#[derive(InitSpace)] // 自动为此结构生成存储所需的初始空间大小。
pub struct Poll {
pub poll_id: u64,
#[max_len(280)]
pub description: String,
pub poll_start: u64,
pub poll_end: u64,
pub candidate_amount: u64,
}
注意,Context:Vote 的 signer 为不可变的,因为 Vote 的上下文中,没有通过 PDA 生成新的 account。
#[program]
:Anchor 的宏,用于标记程序逻辑。模块中的所有函数会被导出,供客户端调用。
Context<T>
时 Anchor 中的上下文类型,用于验证账户的合法性
在 Solana Anchor 框架中,账户的初始化和验证通常需要动态计算的参数(#[instruction(poll_id: u64)]
)
poll_id
是由调用方传递的唯一标识符,Anchor 使用它来验证账户的合法性和确定性注明声明周期 <'info>
的原因:
signer
、poll
和 system_program
的生命周期与 InitalizePoll
的生命周期绑定。initialize_poll
指令执行期间有效。InitializePoll
是用来创建一个 PDA 并为 Poll
账户分配空间的。
在 Solana 的账户模型中,账户的大小在初始化时必须明确指定,因为 Solana 的账户存储是固定长度的。
因此,在初始化账户时需要:
system_program::create_account
为账户分配指定大小的存储。使用宏#[derive(InitSpace)]
可以自动计算 Account 需要的大小
动态数据需要我们使用#[max_len(280)]
宏指定最大长度
对于动态结构的租金:是根据其静态确定的最大空间来收取的(即:按照其填装满的大小收取)
为什么使用 bump
?
PDA 的生成规则要求结果地址是:
可预测的(基于 Program ID
和种子计算)。
不可控的(因为使用了 bump
来避免重复)。
当使用指定的种子无法生成有效的 PDA 时,Anchor 会尝试通过增加 bump
值,直到找到一个有效的 PDA。
bump
的核心在于它是一个 1 字节的值(0 到 255),用于调整生成地址的哈希值,使其符合 PDA 的规则(地址必须不落在 Ed25519 椭圆曲线的可签名范围内)。
bump
基本上是每个 PDA (Program Derived Address) 中的必备组成部分
#[account]
宏用于定义数据账户(Data Account)的结构。
特性,带有 #[account]
的结构:
AnchorSerialize
和 AnchorDeserialize
的实例(Anchor 自动派生)。而#[derive(Accounts)]
宏用于定义账户上下文(Account Context)的结构。
上下文描述了程序调用时,必须传递的所有账户及其访问权限。
每个上下文结构表示一次 Solana 程序调用中涉及的账户集合。
特性,带有 #[derive(Accounts)]
标记的程序:
每个字段是一个账户类型(如 Signer
、Account
、Program
)。
Anchor 自动为这些账户类型生成验证逻辑(如访问权限检查、种子匹配验证等)。
#[derive(Accounts)]
的账户不完全是 program:
#[derive(Accounts)]
定义的是账户上下文(Account Context),用于描述调用程序时涉及的账户集合。
如果上下文中包括 Program<'info, YourProgram>
,那么它可以理解为与特定程序相关的账户。
可以把 #[derive(Accounts)]
理解为:
定义调用程序时所需的账户集合和规则的一个“模板”。
类似于 Solidity 函数调用中的 msg.sender
、目标地址(合约)以及相关参数。
同时,它额外负责自动验证账户的状态、权限、租金支付等功能,从而大大简化了程序开发的复杂性。
其中的 init
代表了这会新建一个 Data Account
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!