Solana投票DApp开发实战:从合约到部署的完整指南Solana以其高性能和低成本的特点,正吸引着越来越多的开发者进入其生态。而Anchor框架的出现,更是极大地降低了Solana智能合约的开发门槛。但对于许多初学者来说,如何将零散的知识点串联起来,完成一个从无到有的完整项目,
Solana 以其高性能和低成本的特点,正吸引着越来越多的开发者进入其生态。而 Anchor 框架的出现,更是极大地降低了 Solana 智能合约的开发门槛。但对于许多初学者来说,如何将零散的知识点串联起来,完成一个从无到有的完整项目,仍然是一个挑战。
在本文中,我们将一起从零开始,以一个经典的“链上投票”应用为目标,使用强大的 Anchor 框架来完成这个任务。我们将依次经历以下几个阶段:
最终,你将拥有一个部署在公链上、可以通过区块浏览器验证的 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),对应用户可以执行的操作:
initialize_poll(..)
: 初始化投票。此函数用于创建一个新的投票活动。调用者(signer
)支付交易费用,并成为该投票的管理员(authority
)。函数会创建一个新的 PollAccount
账户,用来存储投票的名称、描述、起止时间以及管理员公钥等元数据。add_candidate(..)
: 添加候选人。只有投票的管理员才有权限调用此函数。它会为投票活动创建一个新的 CandidateAccount
账户来代表一位候选人。代码中有一个重要的安全设计:它使用一个独立的 candidate_count
字段来生成新候选人账户的地址(PDA),并限制最多只能添加 15 位候选人。vote(..)
: 投票。任何用户都可以调用此函数为特定候选人投票。合约会首先验证投票是否在有效时间范围内,然后为投票者创建一个 VoterReceipt
账户。这个回执账户的存在可以有效防止同一用户在同一次投票中重复投票。为了支持上述功能,合约定义了三种类型的账户(Account)来存储状态:
PollAccount
: 投票账户,存储单个投票活动的所有核心信息,是整个应用状态的中心。CandidateAccount
: 候选人账户,存储每个候选人的姓名和得票数,并关联到特定的 PollAccount
。VoterReceipt
: 投票回执账户,作为一个标记,记录一个用户(voter
)是否已参与了某次投票(poll
)。这段代码一个值得注意的实现细节是使用了 candidate_count
字段来辅助创建候选人账户。在 add_candidate
指令中,新的 CandidateAccount
是一个程序派生地址(PDA),其 seeds
包含了这个计数器。
这种设计模式比直接使用 poll_account.candidates.len()
(即候选人列表的长度)作为 seed
更加安全和健壮。因为账户数据(如 Vec
的长度)在交易处理前可能被外部操纵,而使用一个在逻辑中手动递增的独立计数器,可以确保 PDA 地址的生成是确定且无法被恶意利用的,这是 Solana 开发中一个重要的安全实践。
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
....
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!