完整实战:Solana + Rust/Anchor 从零构建投票 DApp。项目架构、钱包连接、投票合约、前端调用、实时更新、安全部署——体验公链产品化全流程。
本章目录:
- 17.1 项目架构与开发环境
- 17.2 Solana 钱包连接
- 17.3 投票智能合约(Rust/Anchor)
- 17.4 前端合约调用
- 17.5 投票表单 UI 设计
- 17.6 投票结果展示
- 17.7 动画与实时更新
- 17.8 安全与测试
- 17.9 部署与联调
- 17.10 选做扩展
17.1 项目架构与开发环境
构建一个去中心化投票 DApp:React 前端 + Solana 链上投票程序。在本章中,你既是智能合约开发者,又是前端工程师。
17.1.1 技术选型
| 链 | Solana devnet | 快速出块(~400ms)、低交易成本 |
| 合约 | Rust + Anchor 框架 | 类型安全、自动生成 IDL |
| 链交互 | Solana Web3.js | 钱包连接 + 交易发送 |
架构图
graph TD
User["用户浏览器"] --> React[React + Vite]
React --> Wallet["Phantom 钱包"]
Wallet --> Solana[Solana devnet]
Solana --> VoteProgram["Rust 投票程序"]
VoteProgram --> State["投票状态账户"]
style VoteProgram fill:#c8e6c9
style State fill:#e3f2fd
17.1.2 Anchor 项目初始化
## 安装依赖
npm install -g @coral-xyz/anchor-cli
solana-cli --version # 确认已安装
anchor init vote-dapp --template react
## 生成 Anchor 工作区:
## ├── programs/vote/ ← 投票智能合约(Rust)
## ├── app/ ← React 前端
## ├── tests/ ← 链上测试
## └── Anchor.toml ← 配置 devnet/cluster
Anchor.toml 配置
[features]
seeds = true
[programs.devnet]
vote = "Fg6...<program_id>"
[provider]
cluster = "devnet"
wallet = "/home/user/.config/solana/id.json"
17.1.3 项目目录结构
vote-dapp/
├── programs/
│ └── vote/
│ ├── src/
│ │ └── lib.rs # 投票合约(Rust / Anchor)
│ ├── Cargo.toml
│ └── Xargo.toml
├── app/
│ ├── src/
│ │ ├── App.tsx # 主入口
│ │ ├── components/
│ │ │ ├── WalletButton.tsx # 连接钱包
│ │ │ ├── VoteCard.tsx # 投票卡片
│ │ │ ├── CreatePollForm.tsx # 创建投票
│ │ │ └── PollList.tsx # 投票列表
│ │ └── hooks/
│ │ └── useVoteProgram.ts # Anchor 交互
│ └── package.json
├── tests/
│ └── vote.ts # 链上测试脚本
└── Anchor.toml
17.1.4 核心流程
sequenceDiagram
participant U as 用户
participant Ph as Phantom
participant React as DApp前端
participant Sol as Solana devnet
participant Prog as 投票程序
U->>Ph: 连接钱包
Ph->>React: 返回公钥
U->>React: 创建投票(标题,选项)
React->>Sol: 发送: 初始化投票账户
Sol->>Prog: 执行: create_poll
Prog->>Sol: 创建投票状态账户(PDA)
U->>React: 选择选项 → 投票
React->>Sol: 发送: cast_vote
Sol->>Prog: 验证: 未重复投票
Prog->>Sol: 更新: 选项计数+1
U->>React: 查看结果
Sol->>React: 返回: 投票统计
, 前往 → 17.2 连接钱包 |*
17.2 Solana 钱包连接
Solana 的 devnet 水龙头每次可获取 5 SOL(测试用),足够开发全过程。
17.2.1 创建钱包
## 生成新密钥对(本地存储)
solana-keygen new --outfile ~/.config/solana/id.json
## 保存助记词!这是恢复唯一路径
## 查看地址
solana address --keypair ~/.config/solna/id.json
## → Fg6...xyz (Base58 编码的公钥)
## 请求测试 SOL
solana airdrop 5 <address> --url devnet
## → 5 SOL 已发送到 devnet 地址
## 查看余额
solana balance <address> --url devnet
## → 5 SOL
17.2.2 前端连接 Phantom
// hooks/useWallet.ts
import { useEffect, useState } from 'react';
interface PhantomWindow extends Window {
phantom?: { solana?: { isPhantom: boolean; connect: () => Promise<{ publicKey: { toString: () => string } }>; disconnect: () => Promise<void> } };
}
export const usePhantom = () => {
const [wallet, setWallet] = useState<string | null>(null);
const [connected, setConnected] = useState(false);
const connect = async () => {
const w = (window as PhantomWindow).phantom?.solana;
if (!w?.isPhantom) {
alert("请安装 Phantom 钱包扩展");
return;
}
const res = await w.connect();
setWallet(res.publicKey.toString());
setConnected(true);
};
const disconnect = async () => {
await (window as PhantomWindow).phantom?.solana?.disconnect();
setWallet(null);
setConnected(false);
};
return { wallet, connected, connect, disconnect };
};
组件
// WalletButton.tsx
export const WalletButton = () => {
const { wallet, connected, connect, disconnect } = usePhantom();
return (
<button
className={connected ? "btn-disconnect" : "btn-connect"}
onClick={connected ? disconnect : connect}
>
{connected ? `断开: wallet?.slice(0,6)...{wallet?.slice(-4)}` : "连接 Phantom"}
</button>
);
};
17.2.3 多签基础知识
graph TD
Admin["管理员A"] --> Multisig["多签程序"]
Admin2["管理员B"] --> Multisig
Admin3["管理员C"] --> Multisig
Multisig --> |需 2/3 签名| Vote["投票操作"]
style Multisig fill:#fff3e0
生产级投票系统中,投票结果可能由 2/3 多签管理员确认后执行。
, 前往 → 17.3 投票合约逻辑 |*
17.3 投票智能合约(Rust/Anchor)
graph TD
User["用户 via 钱包"] --> F["前端 React<br/>Anchor Provider"]
F --> C1["指令交易 create_poll"]
F --> C2["指令交易 cast_vote"]
F --> C3["指令交易 get_results"]
C1 --> P["Solana 程序<br/>Rust/Anchor"]
C2 --> P
C3 --> P
P --> Q["账户数据<br/>投票状态/选项计数"]
Q --> A["后端 RPC/历史索引"]
A --> F
style P fill:#c8e6c9
style User fill:#e3f2fd
Anchor 框架自动生成 TypeScript IDL——前端可直接调用合约指令,无需手写原始交易。
17.3.1 数据结构
// programs/vote/src/lib.rs
use anchor_lang::prelude::*;
// 声明 program_id
#[program]
pub mod vote {
use super::*;
/// 创建新投票
pub fn create_poll(ctx: Context<CreatePoll>, question: String, options: Vec<String>, end_time: i64) -> Result<()> {
let vote_account = &mut ctx.accounts.vote_account;
let clock = Clock::get()?;
vote_account.creator = *ctx.accounts.creator.key;
vote_account.question = question;
vote_account.options = options;
vote_account.votes = vec![0u32; vote_account.options.len()];
vote_account.end_time = end_time;
vote_account.is_active = true;
vote_account.created_at = clock.unix_timestamp;
Ok(())
}
/// 投一票
pub fn cast_vote(ctx: Context<CastVote>, option_index: u8) -> Result<()> {
let vote_account = &mut ctx.accounts.vote_account;
let clock = Clock::get()?;
let voter = *ctx.accounts.voter.key;
// 检查投票是否过期
require!(clock.unix_timestamp < vote_account.end_time, VoteError::PollExpired);
require!(vote_account.is_active, VoteError::PollInactive);
// 检查选项有效
require!((option_index as usize) < vote_account.options.len(), VoteError::InvalidOption);
// 防重复投票: 每个地址只能投一次
if vote_account.has_voted.contains(&voter) {
return Err(VoteError::AlreadyVoted.into());
}
// 计票
vote_account.votes[option_index as usize] += 1;
vote_account.has_voted.push(voter);
// 触发事件(前端可监听)
emit!(VoteCast {
poll: vote_account.key(),
voter,
option: option_index,
timestamp: clock.unix_timestamp,
});
Ok(())
}
/// 结束投票
pub fn close_poll(ctx: Context<ClosePoll>) -> Result<()> {
let vote_account = &mut ctx.accounts.vote_account;
// 仅创建者可关闭
require!(vote_account.creator == *ctx.accounts.creator.key, VoteError::Unauthorized);
vote_account.is_active = false;
Ok(())
}
}
/// 数据账户(每个投票一个账户)
#[account]
pub struct VoteAccount {
pub creator: Pubkey,
pub question: String,
pub options: Vec<String>,
pub votes: Vec<u32>,
pub end_time: i64,
pub is_active: bool,
pub created_at: i64,
pub has_voted: Vec<Pubkey>, // 已投票地址
}
/// 创建投票的指令上下文
#[derive(Accounts)]
#[instruction(question: String, options: Vec<String>)]
pub struct CreatePoll<'info> {
#[account(init, payer = creator, space = 8 + VoteAccount::MAX_SIZE)]
pub vote_account: Account<'info, VoteAccount>,
#[account(mut)]
pub creator: Signer<'info>,
pub system_program: Program<'info, System>,
}
/// 投票的指令上下文
#[derive(Accounts)]
pub struct CastVote<'info> {
#[account(mut)]
pub vote_account: Account<'info, VoteAccount>,
pub voter: Signer<'info>,
pub system_program: Program<'info, System>,
}
/// 关闭投票的指令上下文
#[derive(Accounts)]
pub struct ClosePoll<'info> {
#[account(mut, has_one = creator)]
pub vote_account: Account<'info, VoteAccount>,
pub creator: Signer<'info>,
}
/// 事件
#[event]
pub struct VoteCast {
pub poll: Pubkey,
pub voter: Pubkey,
pub option: u8,
pub timestamp: i64,
}
/// 错误
#[error_code]
pub enum VoteError {
#[msg("Poll has expired")]
PollExpired,
#[msg("Poll is not active")]
PollInactive,
#[msg("Invalid option index")]
InvalidOption,
#[msg("You have already voted")]
AlreadyVoted,
#[msg("Unauthorized")]
Unauthorized,
}
// MAX_SIZE 简化计算(实际应精确计算)
impl VoteAccount {
const MAX_SIZE: usize = 32 + 4 + 256 + 4 + 200 + 8 + 1 + 8 + 5000; // 预留空间
}
17.3.2 关键设计决策
| has_voted 数组 | 简单追踪重复投票 | Merkle 树(大型投票场景) |
| 单个 VoteAccount | 每个投票一个账户,SOL 存款支付 | 多投票聚合(复杂) |
| PDA (未使用) | 本例用随机 keypair 生成账户 | PDA 适合确定性地址 |
17.3.3 测试/部署
## 编译
anchor build
## 测试(本地验证器自动启动)
anchor test
## 部署到 devnet
anchor deploy --provider.cluster devnet
## 记录 program_id,更新 Anchor.toml
, 前往 → 17.4 前端交互 |*
17.4 前端合约调用
sequenceDiagram
用户 ->> 前端: 点击创建投票/投票/查看结果
前端 ->> AnchorProvider: 请求签名交易
AnchorProvider ->> 钱包: 弹出签名确认
钱包 -->> 用户: 用户确认
钱包 -->> AnchorProvider: 签名完成
AnchorProvider ->> 程序: 发送指令至 Solana devnet
程序 -->> 程序: 更新合约状态/投票数据
程序 -->> AnchorProvider: 返回确认交易ID
前端 ->> 区块浏览器: 轮询 / 订阅确认状态
前端 ->> 前端: 更新 UI
17.4.1 Anchor Provider 配置
// hooks/useVoteProgram.ts
import { useEffect, useState } from 'react';
import { Connection, PublicKey, clusterApiUrl } from '@solana/web3.js';
import { Program, AnchorProvider, web3 } from '@coral-xyz/anchor';
import { Vote, IDL } from '../types/vote'; // 自动生成的 IDL
const PROGRAM_ID = new PublicKey("Fg6...your_program_id...");
const connection = new Connection(clusterApiUrl('devnet'));
export const useVoteProgram = (wallet: any) => {
const [program, setProgram] = useState<Program<Vote>>();
useEffect(() => {
if (!wallet) return;
const provider = new AnchorProvider(
connection,
wallet, // 需适配以 Phantom 签名器
{ commitment: 'confirmed' }
);
const program = new Program<Vote>(IDL, PROGRAM_ID, provider);
setProgram(program);
}, [wallet]);
return program;
};
// 适配 Phantom 为 Anchor 钱包适配器
const getPhantomWallet = () => {
const phantom = (window as any).phantom?.solana;
return {
publicKey: phantom?.publicKey ? new PublicKey(phantom.publicKey.toString()) : null,
signTransaction: async (tx: any) => {
const signed = await phantom.signTransaction(tx);
return signed;
},
signAllTransactions: async (txs: any[]) => {
return await phantom.signAllTransactions(txs);
},
};
};
17.4.2 调用 create_poll
// components/CreatePollForm.tsx
import { useState } from 'react';
import { PublicKey, Keypair } from '@solana/web3.js';
export const CreatePollForm = ({ program, wallet }: { program: any; wallet: any }) => {
const [question, setQuestion] = useState('');
const [options, setOptions] = useState(['Option A', 'Option B']);
const [txId, setTxId] = useState('');
const createPoll = async () => {
// 生成新投票账户
const voteAccount = Keypair.generate();
const tx = await program.methods
.createPoll(
question,
options,
Math.floor(Date.now() / 1000) + 86400 // 24h 过期
)
.accounts({
voteAccount: voteAccount.publicKey,
creator: wallet.publicKey,
systemProgram: web3.SystemProgram.programId,
})
.signers([voteAccount])
.rpc(); // 自动发送并确认
setTxId(tx);
console.log("投票已创建:", tx);
};
return (
<form onSubmit={(e) => { e.preventDefault(); createPoll(); }}>
<h3>创建新投票</h3>
<input value={question} onChange={e => setQuestion(e.target.value)} placeholder="问题" />
{options.map((o, i) => (
<input key={i} value={o} onChange={e => {
const newOpts = [...options]; newOpts[i] = e.target.value; setOptions(newOpts);
}} />
))}
<button type="submit">创建投票</button>
{txId && <div>交易: <a href={`https://explorer.solana.com/tx/${txId}?cluster=devnet`} target="_blank">{txId.slice(0, 16)}...</a></div>}
</form>
);
};
17.4.3 调用 cast_vote
// components/VoteCard.tsx
export const VoteCard = ({ program, wallet, voteAccount }: any) => {
const [voteData, setVoteData] = useState<any>(null);
// 加载投票数据
const loadPoll = async () => {
const data = await program.account.voteAccount.fetch(voteAccount);
setVoteData(data);
};
const castVote = async (optionIndex: number) => {
const tx = await program.methods
.castVote(optionIndex)
.accounts({
voteAccount,
voter: wallet.publicKey,
systemProgram: web3.SystemProgram.programId,
})
.rpc();
await loadPoll(); // 刷新数据
};
if (!voteData) { loadPoll(); return <div>加载中...</div>; }
return (
<div className="vote-card">
<h4>{voteData.question}</h4>
<p>状态: {voteData.isActive ? "进行中" : "已结束"}</p>
{voteData.options.map((opt: string, i: number) => (
<div key={i}>
<span>{opt}: {voteData.votes[i]} 票</span>
{voteData.isActive && !voteData.hasVoted.includes(wallet?.publicKey?.toString()) && (
<button onClick={() => castVote(i)}>投票</button>
)}
</div>
))}
</div>
);
};
, 前往 → 17.5 完整 UI |*
17.5 投票表单 UI 设计
好的投票 UI 必须有状态反馈:投票中 → 已提交 → 已确认 → 结果更新。
17.5.1 表单状态机
graph LR
Idle["空闲"] --> Creating["正在创建"]
Creating --> Created["创建成功/显示TX链接"]
Created --> Idle
Vote["投票中"] --> Signing["钱包签名中"]
Signing --> Submitting["提交交易"]
Submitting --> Confirming["等待确认"]
Confirming --> Voted["投票成功"]
Voted --> Results["结果更新"]
17.5.2 完整投票表单组件
// components/VoteForm.tsx
import { useState } from 'react';
export const VoteForm = ({ program, wallet, poll, onVote }: any) => {
const [status, setStatus] = useState<'idle' | 'signing' | 'submitting' | 'done'>('idle');
const [selected, setSelected] = useState<number | null>(null);
const [error, setError] = useState('');
const handleVote = async () => {
if (selected === null) return;
setStatus('signing');
setError('');
try {
setStatus('submitting');
const tx = await program.methods
.castVote(selected)
.accounts({
voteAccount: poll.publicKey,
voter: wallet.publicKey,
systemProgram: web3.SystemProgram.programId,
})
.rpc({
commitment: 'confirmed',
preflightCommitment: 'confirmed',
});
setStatus('done');
onVote(); // 回调刷新列表
} catch (e: any) {
setError(e.message || '投票失败');
setStatus('idle');
}
};
return (
<div className="vote-form">
<h3>{poll.question}</h3>
{poll.options.map((opt: string, i: number) => (
<label key={i} className={`option ${selected === i ? 'selected' : ''}`}>
<input
type="radio"
name="vote-option"
checked={selected === i}
onChange={() => setSelected(i)}
/>
{opt}
</label>
))}
<button
onClick={handleVote}
disabled={status !== 'idle' || selected === null}
>
{status === 'idle' && '投票'}
{status === 'signing' && '钱包签名中...'}
{status === 'submitting' && '提交中...'}
{status === 'done' && '✅ 已投票'}
</button>
{error && <div className="error">{error}</div>}
</div>
);
};
// CSS(Tailwind 风格)
// .vote-form { @apply p-4 border rounded-lg bg-gray-50; }
// .option { @apply block p-2 cursor-pointer hover:bg-gray-100; }
// .option.selected { @apply bg-blue-100 border-blue-500; }
// button:disabled { @apply opacity-50 cursor-not-allowed; }
17.5.3 UX 最佳实践
| 用户重复投票 | 禁用投票按钮,显示"已投票" | 查询 has_voted 数组 |
| 投票过期 | 禁用整个组件,显示"已结束" | 比较当前时间 > end_time |
| 交易失败 | 显示错误信息,允许重试 | catch / setError |
, 前往 → 17.6 结果展示 |*
17.6 投票结果展示
flowchart TD
User["用户"] --> F["前端 React"]
F --> Q["查询 RPC get_results"]
Q --> P["Vote Program 账户"]
P --> Data["反序列化投票数据<br/>投票选项/各选项票数"]
Data --> Chart["可视化渲染
条形图/饼图/表格"]
Chart --> Live["WebSocket 实时更新<br/>solana_account_sub"]
Live --> Q
style P fill:#c8e6c9
style Chart fill:#e3f2fd
结果展示需要实时性。Solana 的 400ms 出块时间让"实时更新"成为可能。
17.6.1 实时数据获取
// hooks/usePolls.ts — 获取所有投票
import { useEffect, useState } from 'react';
import { PublicKey } from '@solana/web3.js';
export const usePolls = (program: any) => {
const [polls, setPolls] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const fetchPolls = async () => {
if (!program) return;
// Anchor 自动索引所有 VoteAccount
const accounts = await program.account.voteAccount.all();
// 按创建时间排序
const sorted = accounts.sort((a: any, b: any) =>
(b.account.createdAt - a.account.createdAt)
);
setPolls(sorted);
setLoading(false);
};
useEffect(() => { fetchPolls(); }, [program]);
// 每 5 秒自动刷新
useEffect(() => {
const interval = setInterval(fetchPolls, 5000);
return () => clearInterval(interval);
}, [program]);
return { polls, loading, refetch: fetchPolls };
};
17.6.2 结果卡片组件
// components/ResultsCard.tsx
export const ResultsCard = ({ poll }: { poll: any }) => {
const { question, options, votes, isActive, endTime } = poll.account;
const totalVotes = votes.reduce((a: number, b: number) => a + b, 0);
return (
<div className="results-card">
<h3>{question}</h3>
<div className="status-badge">
{isActive ? <span className="active">● 进行中</span> : <span className="closed">✓ 已结束</span>}
{isActive && <span>剩余 {Math.max(0, Math.floor((endTime - Date.now()/1000)/60))} 分钟</span>}
</div>
{options.map((opt: string, i: number) => {
const pct = totalVotes ? (votes[i] / totalVotes * 100) : 0;
return (
<div key={i} className="result-bar">
<div className="label">{opt}</div>
<div className="bar-container">
<div className="bar" style={{width: `${pct}%`}} />
</div>
<div className="count">{votes[i]} 票 ({pct.toFixed(1)}%)</div>
</div>
);
})}
<div className="total">总计: {totalVotes} 票</div>
</div>
);
};
17.6.3 概率统计(可选高级)
pi=∑jvjvi
// 计算置信区间(Wilson 得分)
function wilsonScore(votes: number, total: number, z: number = 1.96) {
const p = votes / total;
const n = total;
const phat = (p + z*z/(2*n)) / (1 + z*z/n);
const error = z * Math.sqrt((p*(1-p) + z*z/(4*n)) / n) / (1 + z*z/n);
return { lower: phat - error, upper: phat + error };
}
当票数较小时,Wilson 区间给出更保守的估计。教学版可省略。
, 前往 → 17.7 动画与实时更新 |*
17.7 动画与实时更新
flowchart TD
Sub["WebSocket 订阅<br/>solana_account_sub"] --> Event["Account/Slot 变动事件"]
Event --> Act["查询 RPC 获取最新数据"]
Act --> Opt{数据有变更?}
Opt -->|是| Anim["React 动画过渡<br/>react-spring / GSAP"]
Anim --> UI["更新展示组件<br/>Chart/表格"]
Opt -->|否| Idle["丢弃更新"]
UI --> Sub
style Anim fill:#ffe0b2
style UI fill:#c8e6c9
数据变化需要视觉反馈。投票后条形图应平滑增长,票数应闪烁更新。
17.7.1 React 动画方案
// CSS 过渡动画
// components/VoteBar.tsx
import { useEffect, useState } from 'react';
export const VoteBar = ({ current, previous, label }: { current: number; previous: number; label: string; }) => {
const [display, setDisplay] = useState(previous);
// 数字递增动画
useEffect(() => {
if (current <= display) { setDisplay(current); return; }
const step = Math.max(1, Math.ceil((current - display) / 20));
const timer = setInterval(() => {
setDisplay(d => {
if (d >= current) { clearInterval(timer); return d; }
return Math.min(d + step, current);
});
}, 50);
return () => clearInterval(timer);
}, [current]);
return (
<div className="vote-bar">
<div className="label">{label}</div>
<div className="bar-container">
<div className="bar-fill" style={{
width: `${display}%`,
transition: 'width 1s ease-out',
}} />
</div>
<div className="count">{display} 票</div>
</div>
);
};
17.7.2 事件监听(WebSocket 替代轮询)
// 使用 Solana 的 WebSocket 订阅
import { Connection, clusterApiUrl, PublicKey } from '@solana/web3.js';
const wsConnection = new Connection(clusterApiUrl('devnet'), 'confirmed');
export function subscribeToPoll(pollAddress: string, onUpdate: (data: any) => void) {
const pubKey = new PublicKey(pollAddress);
// 订阅账户变化
const subId = wsConnection.onAccountChange(pubKey, (accountInfo) => {
// 反序列化数据
onUpdate(accountInfo.data);
});
return () => wsConnection.removeAccountChangeListener(subId);
}
17.7.3 动画 UX 原则
| 条形增长 | 1000ms | cubic-bezier(0.4, 0, 0.2, 1) |
| 新投票出现 | 500ms | slide-down + fade |
| 投票成功 | 800ms | scale(1.05) → 1.0 |
, 前往 → 17.8 测试 |*
17.8 安全与测试
投票系统的核心安全需求:一人一票、结果不可篡改、计票透明。
17.8.1 防重复投票
// 已在 17.3 中实现
// has_voted: Vec<Pubkey> — 每个地址只能出现一次
pub fn cast_vote(ctx: Context<CastVote>, option_index: u8) -> Result<()> {
let vote_account = &mut ctx.accounts.vote_account;
let voter = *ctx.accounts.voter.key;
// 防重复投票检查
if vote_account.has_voted.contains(&voter) {
return Err(VoteError::AlreadyVoted.into());
}
vote_account.votes[option_index as usize] += 1;
vote_account.has_voted.push(voter);
Ok(())
}
| 重复投票 | has_voted 数组检查 | 单元测试:同一地址两次投票返回错误 |
| 过期后投票 | end_time 检查 | 时间旅行测试:模拟过期后调用 |
| 无效选项 | 索引范围检查 | 边界测试:option_index 超范围 |
| 非创建者关闭 | 签名地址 == creator 检查 | 权限测试:其他地址关闭失败 |
17.8.2 测试用例
// tests/vote.ts
import * as anchor from '@coral-xyz/anchor';
import { expect } from 'chai';
import { Vote } from '../target/types/vote';
describe('vote', () => {
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
const program = anchor.workspace.Vote as anchor.Program<Vote>;
let voteAccount: anchor.web3.Keypair;
// 测试用例
it('可创建投票', async () => {
voteAccount = anchor.web3.Keypair.generate();
await program.methods
.createPoll('最喜欢的编程语言?', ['Rust', 'Go', 'TypeScript'], ...)
.accounts({ ... })
.signers([voteAccount])
.rpc();
const account = await program.account.voteAccount.fetch(voteAccount.publicKey);
expect(account.question).to.equal('最喜欢的编程语言?');
expect(account.options).to.deep.equal(['Rust', 'Go', 'TypeScript']);
});
it('可投票', async () => {
await program.methods.castVote(0)
.accounts({ voteAccount: voteAccount.publicKey, voter: provider.wallet.publicKey })
.rpc();
const account = await program.account.voteAccount.fetch(voteAccount.publicKey);
expect(account.votes[0]).to.equal(1);
});
it('不能重复投票', async () => {
try {
await program.methods.castVote(1)
.accounts({ voteAccount: voteAccount.publicKey })
.rpc();
expect.fail('应抛出错误');
} catch (e: any) {
expect(e.toString()).to.include('AlreadyVoted');
}
});
it('过期后不能投票', async () => { /* 时间旅行测试 */ });
});
17.8.3 测试金字塔
graph TD
Unit["单元测试: Anchor test 本地验证器"] --> Integration["集成: 前后端联调"]
Integration --> Devnet["devnet 部署测试"]
Devnet --> Mainnet["主网观察模式"]
, 前往 → 17.9 部署 |*
17.9 部署与联调
17.9.1 devnet 部署流程
## 1. 构建
anchor build
## 2. 部署(使用已配置在 Anchor.toml 中的 devnet provider)
anchor deploy --provider.cluster devnet
## 输出:
## Program Id: Fg6...xyz ← 复制此 ID
## 3. 更新配置
## Anchor.toml 中的 program_id
## 前端 .env 中的 VITE_PROGRAM_ID
## 4. 验证
solana program show Fg6...xyz --url devnet
## → Program Data: <Data Account> | Size: <X> bytes | Balance: <Y> SOL
graph LR
Build[anchor build] --> Deploy[anchor deploy --cluster devnet]
Deploy --> Update["更新 Program ID"]
Update --> Test["前端联调"]
Test --> Verify["explorer.solana.com 验证"]
17.9.2 前端环境配置
## .env
VITE_SOLANA_CLUSTER=devnet
VITE_PROGRAM_ID=Fg6...your_deployed_id...
VITE_RPC_URL=https://api.devnet.solana.com
17.9.3 端到端联调清单
| 1 | 连接 Phantom,获取 devnet SOL | 余额 > 0 |
| 4 | 验证链上数据 | explorer.solana.com 显示交易 |
, 前往 → 17.10 选做扩展 |*
17.10 选做扩展
基础投票 DApp 完成后,以下扩展将带你从"教学项目"进入"工程实践"。
选做 1:多签管理员
// 修改 close_poll 为 2/3 多签
#[account]
pub struct VoteAccount {
// ...原有字段
pub admins: Vec<Pubkey>, // 3 个管理员
pub admin_threshold: u8, // 2
pub close_signatures: Vec<Pubkey>, // 已签名关闭的管理员
}
pub fn close_poll_multisig(ctx: Context<ClosePollMultisig>) -> Result<()> {
let vote = &mut ctx.accounts.vote_account;
let signer = ctx.accounts.creator.key();
require!(vote.admins.contains(&signer), VoteError::NotAdmin);
if !vote.close_signatures.contains(&signer) {
vote.close_signatures.push(signer);
}
if vote.close_signatures.len() >= vote.admin_threshold as usize {
vote.is_active = false;
}
Ok(())
}
选做 2:投票委托
// 用户A可将投票权委托给B
#[account]
pub struct VoteAccount {
pub delegations: Vec<(Pubkey, Pubkey)>, // (委托人, 被委托人)
}
// B 投票时,检查 B 是否有委托授权
选做 3:隐私投票
| 零知识承诺 | Pedersen 承诺 + 范围证明 | ⭐⭐⭐⭐ |
选做 4:Gas 优化
, 前往 → ch17-summary(本章总结) |*
第17章 总结:从代码到产品
三个核心结论
- Anchor 框架将 Solana 开发的复杂度降低了 10 倍。自动 IDL 生成、类型安全、内置测试——这是 2024 年最高效的智能合约开发范式之一。
- 前端开发是 DApp 的"最后一公里"。状态机、动画、错误处理、实时更新——这些 UX 细节决定用户是否愿意使用你的产品。
- 安全不是事后补丁。防重复投票、权限控制、过期验证——这些逻辑在合约层面就需完整实现,前端不可信任。
技术栈速查
| 合约 | Rust + Anchor | 类型安全、自动 IDL |
| 测试 | anchor test | 本地验证器 + TypeScript 断言 |
| 部署 | anchor deploy --devnet | 一键 devnet 部署 |
| 前端 | React + Vite + TypeScript | 现代组件化 |
| 链交互 | @solana/web3.js + Anchor | 钱包连接 + 交易发送 |
| 实时更新 | 5s 轮询 / WebSocket | 数据同步 |
下一步
你已经完成了一个完整的链上投票系统。第 18 章将带你进入更复杂的 NFT 铸造与交易——从技术实现到市场运营的全链路。
, 前往 → 18.1 NFT 架构 |*
评论
0评论加载中…