Solana

2025年07月21日更新 10 人订阅
原价: ¥ 8.6 限时优惠
专栏简介 Solana 开发学习之Solana 基础知识 Solana 开发学习之通过RPC与Solana交互 Web3与Solana实操指南:如何签名与发送交易 Web3 新玩法:Solana Nonce Account 让你交易无忧 Web3 快上手:Solana 造你的链上名片 Web3 开发实战:用 Anchor 打造 Solana 猜数游戏 @solana/web3.js 2.0:Solana 转账全流程解析 玩转 Web3 Solana:从零到代币开发 Web3 开发入门:Solana CLI 配置与本地验证器实战 Web3 Eclipse 开发环境搭建与资产跨链桥接实战指南 用 Gill 库解锁 Web3:Solana 代币转账实战 Web3开发:用Rust实现Solana SOL转账教程 用 Rust 开发 Solana:解锁 Web3 交易费用计算 Web3开发入门:Solana账户创建与Rust实践全攻略 Web3 实战:用 Anchor 打造 Solana 智能合约全流程 Solana Web3 快速入门:创建并获取钱包账户的完整指南 Web3 开发实操:用 Anchor 在 Solana 创建代币 Mint Account 从零到 Web3:使用 @solana/kit 快速查询 Solana 账户余额 快速上手 Web3:用 @solana/kit 在 Solana 上创建钱包并查询余额 Web3实战:使用Anchor与Rust开发和调用Solana智能合约 Web3实战:Solana CPI全解析,从Anchor封装到PDA转账 用 Rust 在 Solana 上打造你的专属代币:从零到一的 Web3 实践 探索Solana SDK实战:Web3开发的双路径与轻量模块化 手把手教你用 Solana Token-2022 创建支持元数据的区块链代币 Solana 开发实战:Rust 客户端调用链上程序全流程 Solana 开发进阶:在 Devnet 上实现链上程序部署、调用与更新 Solana 开发进阶:链上事件到链下解析全攻略 从零打造Solana空投工具库:Rust开发实战指南 从零开始:用 Rust 开发 Solana 链上 Token 元数据查询工具 Solana 智能合约终极部署指南:从入门到主网,定制你的专属靓号 Program ID 【Solana 开发实战】轻松搞定链上 IDL:从上传到获取全解析 Solana 投票 DApp 开发实战:从合约到部署的完整指南

Solana 投票 DApp 开发实战:从合约到部署的完整指南

Solana投票DApp开发实战:从合约到部署的完整指南Solana以其高性能和低成本的特点,正吸引着越来越多的开发者进入其生态。而Anchor框架的出现,更是极大地降低了Solana智能合约的开发门槛。但对于许多初学者来说,如何将零散的知识点串联起来,完成一个从无到有的完整项目,

Solana 投票 DApp 开发实战:从合约到部署的完整指南

Solana 以其高性能和低成本的特点,正吸引着越来越多的开发者进入其生态。而 Anchor 框架的出现,更是极大地降低了 Solana 智能合约的开发门槛。但对于许多初学者来说,如何将零散的知识点串联起来,完成一个从无到有的完整项目,仍然是一个挑战。

在本文中,我们将一起从零开始,以一个经典的“链上投票”应用为目标,使用强大的 Anchor 框架来完成这个任务。我们将依次经历以下几个阶段:

  1. 环境准备与项目初始化
  2. 智能合约(程序)的详细实现与讲解
  3. 编写并运行覆盖正反场景的全面测试脚本
  4. 编译、部署合约到 Solana 开发网络并进行链上验证

最终,你将拥有一个部署在公链上、可以通过区块浏览器验证的 DApp。无论你是希望转型的 Web2 开发者,还是对 Web3 充满好奇的学习者,相信这篇详尽的实战文章都能为你点亮 Solana 开发的技能树。

实操

创建项目

anchor init voting
yarn install v1.22.22
info No lockfile found.
[1/4] 🔍  Resolving packages...
info There appears to be trouble with your network connection. Retrying...
info There appears to be trouble with your network connection. Retrying...
warning mocha > glob@7.2.0: Glob versions prior to v9 are no longer supported
warning mocha > glob > inflight@1.0.6: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
[2/4] 🚚  Fetching packages...
[3/4] 🔗  Linking dependencies...
[4/4] 🔨  Building fresh packages...
success Saved lockfile.
✨  Done in 29.40s.
Failed to install node modules
提示: 使用 'master' 作为初始分支的名称。这个默认分支名称可能会更改。要在新仓库中
提示: 配置使用初始分支名,并消除这条警告,请执行:
提示:
提示:     git config --global init.defaultBranch <名称>
提示:
提示: 除了 'master' 之外,通常选定的名字有 'main'、'trunk' 和 'development'。
提示: 可以通过以下命令重命名刚创建的分支:
提示:
提示:     git branch -m <name>
提示:
提示: Disable this message with "git config set advice.defaultBranchName false"
已初始化空的 Git 仓库于 /Users/qiaopengjun/Code/Solana/voting/.git/
voting initialized

切换到项目目录并用cursor 打开项目

cd voting
cc # open -a cursor .

查看项目目录结构

voting on  master [!?] via ⬢ v23.11.0 via 🦀 1.88.0 
➜ tree . -L 6 -I "migrations|mochawesome-report|.anchor|docs|target|node_modules"
.
├── Anchor.toml
├── app
├── Cargo.lock
├── Cargo.toml
├── cliff.toml
├── idls
│   └── voting
│       └── voting-2025-07-18-093219.json
├── Makefile
├── package.json
├── pnpm-lock.yaml
├── programs
│   └── voting
│       ├── Cargo.toml
│       ├── src
│       │   └── lib.rs
│       └── Xargo.toml
├── tests
│   └── voting.ts
├── tsconfig.json

18 directories, 37 files

实现程序(合约) lib.rs

#![allow(unexpected_cfgs, deprecated)]

use anchor_lang::prelude::*;

declare_id!("Doo2arLUifZbfqGVS5Uh7nexAMmsMzaQH5zcwZhSoijz");

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

    // 初始化投票活动
    pub fn initialize_poll(
        ctx: Context<InitializePoll>,
        name: String,
        description: String,
        start_time: u64,
        end_time: u64,
    ) -> Result<()> {
        let poll_account = &mut ctx.accounts.poll_account;
        poll_account.name = name;
        poll_account.description = description;
        poll_account.start_time = start_time;
        poll_account.end_time = end_time;
        poll_account.authority = ctx.accounts.signer.key();
        poll_account.candidates = Vec::new();

        // 关键修复:使用专门的计数器,避免 Vec.len() 的解释错误
        poll_account.candidate_count = 0;

        Ok(())
    }

    // 添加候选人
    pub fn add_candidate(ctx: Context<AddCandidate>, candidate_name: String) -> Result<()> {
        require_keys_eq!(
            ctx.accounts.poll_account.authority,
            ctx.accounts.signer.key(),
            ErrorCode::Unauthorized
        );

        let poll_account = &mut ctx.accounts.poll_account;
        let candidate_account = &mut ctx.accounts.candidate_account;

        // 检查专门的计数器,而不是 Vec.len()
        require!(
            poll_account.candidate_count < 15,
            ErrorCode::MaxCandidatesReached
        );

        candidate_account.name = candidate_name;
        candidate_account.poll = poll_account.key();
        candidate_account.votes = 0;

        poll_account.candidates.push(candidate_account.key());
        // 在成功添加后,手动增加计数器
        poll_account.candidate_count += 1;

        Ok(())
    }

    // 投票
    pub fn vote(ctx: Context<Vote>) -> Result<()> {
        let clock = Clock::get()?;
        let poll_account = &ctx.accounts.poll_account;
        let candidate_account = &mut ctx.accounts.candidate_account;

        if clock.unix_timestamp < poll_account.start_time as i64 {
            return err!(ErrorCode::PollNotStarted);
        }

        if clock.unix_timestamp > poll_account.end_time as i64 {
            return err!(ErrorCode::PollEnded);
        }

        require_keys_eq!(
            candidate_account.poll,
            poll_account.key(),
            ErrorCode::InvalidCandidateForPoll
        );

        candidate_account.votes += 1;

        let receipt = &mut ctx.accounts.voter_receipt;
        receipt.voter = ctx.accounts.signer.key();
        receipt.poll = poll_account.key();

        Ok(())
    }
}

#[derive(Accounts)]
pub struct InitializePoll<'info> {
    #[account(mut)]
    pub signer: Signer<'info>,
    #[account(init, payer = signer, space = 8 + PollAccount::INIT_SPACE)]
    pub poll_account: Account<'info, PollAccount>,
    pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct AddCandidate<'info> {
    #[account(mut)]
    pub signer: Signer<'info>,
    #[account(mut)]
    pub poll_account: Account<'info, PollAccount>,
    #[account(
        init,
        payer = signer,
        space = 8 + CandidateAccount::INIT_SPACE,
        // seeds 现在使用更可靠的计数器
        seeds = [b"candidate", poll_account.key().as_ref(), poll_account.candidate_count.to_le_bytes().as_ref()],
        bump
    )]
    pub candidate_account: Account<'info, CandidateAccount>,
    pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct Vote<'info> {
    #[account(mut)]
    pub signer: Signer<'info>,
    #[account(mut)]
    pub poll_account: Account<'info, PollAccount>,
    #[account(
        mut,
        constraint = candidate_account.poll == poll_account.key() @ ErrorCode::InvalidCandidateForPoll
    )]
    pub candidate_account: Account<'info, CandidateAccount>,
    #[account(
        init,
        payer = signer,
        space = 8 + VoterReceipt::INIT_SPACE,
        seeds = [b"receipt", poll_account.key().as_ref(), signer.key().as_ref()],
        bump
    )]
    pub voter_receipt: Account<'info, VoterReceipt>,
    pub system_program: Program<'info, System>,
}

#[account]
#[derive(InitSpace)]
pub struct PollAccount {
    pub authority: Pubkey,
    #[max_len(32)]
    pub name: String,
    #[max_len(280)]
    pub description: String,
    pub start_time: u64,
    pub end_time: u64,
    // 增加专门的计数器
    pub candidate_count: u8,
    #[max_len(15, 32)]
    pub candidates: Vec<Pubkey>,
}

#[account]
#[derive(InitSpace)]
pub struct CandidateAccount {
    pub poll: Pubkey,
    #[max_len(32)]
    pub name: String,
    pub votes: u64,
}

#[account]
#[derive(InitSpace)]
pub struct VoterReceipt {
    pub voter: Pubkey,
    pub poll: Pubkey,
}

#[error_code]
pub enum ErrorCode {
    #[msg("Poll not started yet")]
    PollNotStarted,
    #[msg("Poll ended")]
    PollEnded,
    #[msg("Unauthorized: Only the poll authority can perform this action.")]
    Unauthorized,
    #[msg("Maximum number of candidates reached.")]
    MaxCandidatesReached,
    #[msg("This candidate is not valid for this poll.")]
    InvalidCandidateForPoll,
}

这段代码使用 Solana 的 Anchor 框架实现了一个功能完整的链上投票智能合约。它清晰地展示了如何定义程序逻辑、管理链上数据状态以及处理权限验证。合约的核心功能可以分解为以下几个部分:

核心功能 (Instructions)

合约定义了三个主要的公开指令(Instructions),对应用户可以执行的操作:

  1. initialize_poll(..): 初始化投票。此函数用于创建一个新的投票活动。调用者(signer)支付交易费用,并成为该投票的管理员(authority。函数会创建一个新的 PollAccount 账户,用来存储投票的名称、描述、起止时间以及管理员公钥等元数据。
  2. add_candidate(..): 添加候选人。只有投票的管理员才有权限调用此函数。它会为投票活动创建一个新的 CandidateAccount 账户来代表一位候选人。代码中有一个重要的安全设计:它使用一个独立的 candidate_count 字段来生成新候选人账户的地址(PDA),并限制最多只能添加 15 位候选人。
  3. vote(..): 投票。任何用户都可以调用此函数为特定候选人投票。合约会首先验证投票是否在有效时间范围内,然后为投票者创建一个 VoterReceipt 账户。这个回执账户的存在可以有效防止同一用户在同一次投票中重复投票

链上数据结构 (Accounts)

为了支持上述功能,合约定义了三种类型的账户(Account)来存储状态:

  • PollAccount: 投票账户,存储单个投票活动的所有核心信息,是整个应用状态的中心。
  • CandidateAccount: 候选人账户,存储每个候选人的姓名和得票数,并关联到特定的 PollAccount
  • VoterReceipt: 投票回执账户,作为一个标记,记录一个用户(voter)是否已参与了某次投票(poll)。

关键设计与安全亮点

这段代码一个值得注意的实现细节是使用了 candidate_count 字段来辅助创建候选人账户。在 add_candidate 指令中,新的 CandidateAccount 是一个程序派生地址(PDA),其 seeds 包含了这个计数器。

这种设计模式比直接使用 poll_account.candidates.len()(即候选人列表的长度)作为 seed 更加安全和健壮。因为账户数据(如 Vec 的长度)在交易处理前可能被外部操纵,而使用一个在逻辑中手动递增的独立计数器,可以确保 PDA 地址的生成是确定且无法被恶意利用的,这是 Solana 开发中一个重要的安全实践。

Build 编译程序(合约)

voting on  master [!?] via ⬢ v23.11.0 via 🦀 1.88.0 took 5.9s 
➜ make build-one PROGRAM=voting
Building single program: [voting]...
warning: profiles for the non root package will be ignored, specify profiles at the workspace root:
package:   /Users/qiaopengjun/Code/Solana/voting/voting-substreams/voting_substreams/Cargo.toml
workspace: /Users/qiaopengjun/Code/Solana/voting/Cargo.toml
    Finished `release` profile [optimized] target(s) in 0.38s
warning: profiles for the non root package will be ignored, specify profiles at the workspace root:
package:   /Users/qiaopengjun/Code/Solana/voting/voting-substreams/voting_substreams/Cargo.toml
workspace: /Users/qiaopengjun/Code/Solana/voting/Cargo.toml
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.38s
     Running unittests src/lib.rs (/Users/qiaopengjun/Code/Solana/voting/target/debug/deps/voting-8013a2dc526371b8)

编写测试 voting.ts


import * as anchor from "@coral-xyz/anchor";
import { Program, BN } from "@coral-xyz/anchor";
import { Voting } from "../target/types/voting";
import { assert } from "chai";
import { LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js";

describe("voting", () => {
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);
  const program = anchor.workspace.Voting as Program<Voting>;

  const pollAccount = anchor.web3.Keypair.generate();
  const authority = provider.wallet as anchor.Wallet;
  const voter1 = anchor.web3.Keypair.generate();
  const voter2 = anchor.web3.Keypair.generate();
  const unauthorizedUser = anchor.web3.Keypair.generate();

  const confirmTx = async (txSignature: string) => {
    const latestBlockhash = await provider.connection.getLatestBlockhash();
    await provider.connection.confirmTransaction(
      { signature: txSignature, ...latestBlockhash },
      "confirmed"
    );
  };

  const airdrop = async (account: anchor.web3.Keypair) => {
    const sig = await provider.connection.requestAirdrop(
      account.publicKey,
      2 * LAMPORTS_PER_SOL
    );
    await confirmTx(sig);
  };

  const getCandidatePda = (
    pollKey: PublicKey,
    index: number
  ): [PublicKey, number] => {
    return anchor.web3.PublicKey.findProgramAddressSync(
      [
        Buffer.from("candidate"),
        pollKey.toBuffer(),
        // 关键修复:合约中的 candidate_count 是 u8 (1字节),这里必须匹配
        new BN(index).toArrayLike(Buffer, "le", 1),
      ],
      program.programId
    );
  };

  const getReceiptPda = (
    pollKey: PublicKey,
    voterKey: PublicKey
  ): [PublicKey, number] => {
    return anchor.web3.PublicKey.findProgramAddressSync(
      [Buffer.from("receipt"), pollKey.toBuffer(), voterKey.toBuffer()],
      program.programId
    );
  };

  before(async () => {
    await airdrop(voter1);
    await airdrop(voter2);
    await airdrop(unauthorizedUser);
  });

  it("✅ Successfully initializes a poll", async () => {
    const name = "Favorite Framework";
    const description = "Which framework do you prefer?";
    const startTime = new BN(Math.floor(Date.now() / 1000));
    const endTime = new BN(startTime.toNumber() + 3600);

    const tx = await program.methods
      .initializePoll(name, description, startTime, endTime)
      .accounts({
        pollAccount: pollAccount.publicKey,
        signer: authority.publicKey,
        systemProgram: anchor.web3.SystemProgram.programId,
      })
      .signers([pollAccount])
      .rpc();
    await confirmTx(tx);

    const fetchedPoll = await program.account.pollAccount.fetch(
      pollAccount.publicKey
    );
    assert.strictEqual(fetchedPoll.name, name, "Poll name does not match");
    assert.strictEqual(
      fetchedPoll.authority.toBase58(),
      authority.publicKey.toBase58()
    );
    assert.ok(fetchedPoll.startTime.eq(startTime), "Start time does not match");
    assert.ok(fetchedPoll.endTime.eq(endTime), "End time does not match");
  });

  it("✅ Successfully adds two candidates", async () => {
    const [candidatePda1] = getCandidatePda(pollAccount.publicKey, 0);
    const tx1 = await program.methods
      ....

剩余50%的内容订阅专栏后可查看

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

0 条评论

请先 登录 后评论