教程区块链区块链技术第17章 Solana 投票 DApp

本页目录

完整实战: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
前端React 18 + Vite现代组件化开发
链交互Solana Web3.js钱包连接 + 交易发送
钱包Phantom测试网 dApp 标准入口

架构图

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 项目初始化

bash

## 安装依赖

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 配置

toml

[features]

seeds = true



[programs.devnet]

vote = "Fg6...<program_id>"



[provider]

cluster = "devnet"

wallet = "/home/user/.config/solana/id.json"

17.1.3 项目目录结构

text

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 创建钱包

bash

## 生成新密钥对(本地存储)

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

typescript

// 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 };

};

组件

tsx

// 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(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 数据结构

rust

// 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 测试/部署

bash

## 编译

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 配置

typescript

// 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

typescript

// 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

typescript

// 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 完整投票表单组件

tsx

// 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 实时数据获取

typescript

// 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 结果卡片组件

tsx

// 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=vijvjp_i = \frac{v_i}{\sum_{j} v_j}
typescript

// 计算置信区间(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 动画方案

tsx

// 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 替代轮询)

typescript

// 使用 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 原则

动画时长缓动
------------------
票数变化300msease-out
条形增长1000mscubic-bezier(0.4, 0, 0.2, 1)
新投票出现500msslide-down + fade
投票成功800msscale(1.05) → 1.0

, 前往 → 17.8 测试 |*


17.8 安全与测试

投票系统的核心安全需求:一人一票、结果不可篡改、计票透明。


17.8.1 防重复投票

rust

// 已在 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 测试用例

typescript

// 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 部署流程

bash

## 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

## .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
2前端创建投票devnet 上产生新投票账户
3另一设备/浏览器投票票数实时递增
4验证链上数据explorer.solana.com 显示交易
5关闭投票状态变为 inactive,禁止新投票

, 前往 → 17.10 选做扩展 |*


17.10 选做扩展

基础投票 DApp 完成后,以下扩展将带你从"教学项目"进入"工程实践"。


选做 1:多签管理员

rust

// 修改 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:投票委托

rust

// 用户A可将投票权委托给B

#[account]

pub struct VoteAccount {

    pub delegations: Vec<(Pubkey, Pubkey)>, // (委托人, 被委托人)

}



// B 投票时,检查 B 是否有委托授权

选做 3:隐私投票

方案技术难度
------------------
盲签名Chaum 盲签名⭐⭐
零知识承诺Pedersen 承诺 + 范围证明⭐⭐⭐⭐
同态加密Paillier 加密累加⭐⭐⭐⭐

选做 4:Gas 优化

优化方法节省
------------------
账户复用批量创建投票,共享存储~30%
压缩投票位图代替数组~50%
程序拆分投票逻辑与计数分离~20%

, 前往 → 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

评论加载中…

发表评论

0/2000