用 Solana 实现一个投票合约

本文为 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 使用它来验证账户的合法性和确定性
    • 它用于生成账户的 PDA(Program Derived Address)。
    • 它在创建账户时会作为种子的一部分,确保生成的账户与参数匹配。
    • 由于 Solana 是无状态的链,程序无法直接存储数据,只能通过账户读取和写入
  • 注明声明周期 <'info>的原因:

    • 告诉编译器:这些引用只能在当前指令的生命周期内使用,超出范围后它们就无效。
    • 它标注了 signerpollsystem_program 的生命周期与 InitalizePoll 的生命周期绑定。
    • 表示这些账户数据的引用仅在 initialize_poll 指令执行期间有效。
  • InitializePoll 是用来创建一个 PDA 并为 Poll 账户分配空间的。

    • 在 Solana 的账户模型中,账户的大小在初始化时必须明确指定,因为 Solana 的账户存储是固定长度的。

    • 因此,在初始化账户时需要:

      • 明确账户所需的总字节数(空间大小)。
      • 使用 system_program::create_account 为账户分配指定大小的存储。
    • 使用宏#[derive(InitSpace)]可以自动计算 Account 需要的大小

    • 动态数据需要我们使用#[max_len(280)]宏指定最大长度

      • 对于 String:字符串最长长度
      • 对于 Vec:数组的最大元素数量
    • 对于动态结构的租金:是根据其静态确定的最大空间来收取的(即:按照其填装满的大小收取)

  • 为什么使用 bump

    • PDA 的生成规则要求结果地址是:

    • 可预测的(基于 Program ID 和种子计算)。

    • 不可控的(因为使用了 bump 来避免重复)。

    • 当使用指定的种子无法生成有效的 PDA 时,Anchor 会尝试通过增加 bump 值,直到找到一个有效的 PDA。

    • bump 的核心在于它是一个 1 字节的值(0 到 255),用于调整生成地址的哈希值,使其符合 PDA 的规则(地址必须不落在 Ed25519 椭圆曲线的可签名范围内)。

    • bump 基本上是每个 PDA (Program Derived Address) 中的必备组成部分

  • #[account]宏用于定义数据账户(Data Account)的结构

    • 特性,带有 #[account]的结构:

      • 必须是存储在 Solana 区块链上的账户。
      • 必须是 AnchorSerializeAnchorDeserialize 的实例(Anchor 自动派生)。
  • #[derive(Accounts)] 宏用于定义账户上下文(Account Context)的结构

    • 上下文描述了程序调用时,必须传递的所有账户及其访问权限。

    • 每个上下文结构表示一次 Solana 程序调用中涉及的账户集合。

    • 特性,带有 #[derive(Accounts)]标记的程序:

    • 每个字段是一个账户类型(如 SignerAccountProgram)。

    • Anchor 自动为这些账户类型生成验证逻辑(如访问权限检查、种子匹配验证等)。

    • #[derive(Accounts)]的账户不完全是 program:

    • #[derive(Accounts)] 定义的是账户上下文(Account Context),用于描述调用程序时涉及的账户集合。

    • 如果上下文中包括 Program<'info, YourProgram>,那么它可以理解为与特定程序相关的账户。

    • 可以把 #[derive(Accounts)] 理解为:

    • 定义调用程序时所需的账户集合和规则的一个“模板”。

    • 类似于 Solidity 函数调用中的 msg.sender、目标地址(合约)以及相关参数。

    • 同时,它额外负责自动验证账户的状态、权限、租金支付等功能,从而大大简化了程序开发的复杂性。

    • 其中的 init代表了这会新建一个 Data Account

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

0 条评论

请先 登录 后评论
Q1ngying
Q1ngying
0x468F...68bf
本科在读,合约安全学习中......