教程区块链区块链基础知识chunk_48_ch17_voting_pt1第17章 投票合约设计与 Hardhat 工程化

本页目录

在完成了对智能合约安全基础(重入、溢出、访问控制)的理解之后,我们终于要将这些知识融入一个完整的应用级场景——去中心化投票系统。投票是区块链最早被看好的落地场景之一:它天然需要不可篡改的记票本透明可查的计票规则,而这正是智能合约的核心优势。

本章将带领你经历一次从业务需求拆解合约代码落地再到Hardhat 工程化部署的完整闭环。你将亲手编写一个支持管理员注册候选人、授权选民身份、防重入投票、实时计票的 Vote.sol 合约,并借助 Hardhat 工具链在本地网络完成部署与交互验证。后续章节会在此基础上搭建 React 前端,形成完整的 DApp。

17.1 需求分析与智能合约设计

17.1.1 投票场景的业务需求拆解

一个可信的链上投票系统,至少需要满足以下五条业务规则:

  1. 管理员创建选举:由可信的管理员初始化选举主题,并注册候选人名单。
  2. 选民身份注册:投票前,管理员或授权机构将合格选民的地址录入白名单,防止无关地址参与。
  3. 一人一票制:每个被授权的选民在整个选举周期中只能投出一张选票,且不可撤回或修改。
  4. 结果实时可见:每位候选人的得票数对全网络公开透明,任何人都可以验证计票结果。
  5. 生命周期管理:选举应具有明确的开始与结束状态(本章先做简化版,后续扩展为时间锁控制)。

这些需求看似朴素,但在去信任环境中,每一条都需要用代码强制约束,因为链上不存在现实中的「检票员」。

17.1.2 威胁模型与安全假设

在设计合约之前,我们先建立威胁模型(Threat Model),明确「敌人会怎么做」。

威胁一:重复投票

  • 场景:选民 A 在投完票后再次调用 vote() 函数。
  • 危害:攻击者或恶意节点可通过大量重复投票人为放大某候选人的得票数,导致结果失真。
  • 防御:在合约中维护 mapping(address => bool) voted,在投票函数入口处通过 require(!voted[msg.sender]) 强制回滚。

威胁二:非授权投票

  • 场景:地址 B 从未被管理员授权,却直接调用 vote()
  • 危害:破坏身份验证机制,使投票结果失去代表性。
  • 防御:维护 mapping(address => bool) authorizedVoters 白名单,投票前双重校验——既查身份又查是否已投票。

威胁三:管理员作恶

  • 场景:管理员在选举进行中擅自修改候选人名单、取消已投选票,甚至自己伪造投票。
  • 危害:中心化风险违背区块链「去信任」的设计初衷。如果管理员是一把没有制约的私钥,整个系统就变成「链上 Excel 表格」。
  • 缓解思路:生产环境应引入多签钱包(如 Gnosis Safe)管理 owner 权限,或将关键操作交由 DAO 治理合约投票决定。本章为教学清晰,先以 OpenZeppelin 的 Ownable 做简化实现,后续章节再做扩展。

17.1.3 合约架构设计

基于以上需求与威胁分析,合约由三个核心模块构成:

  • Election 主合约:管理选举元数据(名称、状态、候选人列表、选民注册表),作为整个系统的调度中心。
  • Candidate 数据结构struct Candidate { uint id; string name; uint voteCount; }。使用 id 作为键值索引,既方便前端快速查询,也节省遍历候选人的 Gas。
  • VoterRegistry 身份映射mapping(address => bool) authorizedVoters 记录授权白名单,mapping(address => bool) voted 记录已投票状态。

下图展示了投票 DApp 的权限模型与核心数据流:

flowchart LR
    A[管理员] -->|创建选举 / 注册候选| C[智能合约 Vote.sol]
    A -->|授权选民地址| C
    B[授权选民] -->|vote(candidateId)| C
    C -->|记录投票状态| D[链上存储]
    D -->|实时查询得票| E[任何人]
    C -->|emit Voted| F[前端 / 监听器]

如图,管理员负责初始化与授权,选民只能执行一次受限的投票操作,而合约状态对所有参与者公开透明。这样的设计将「信任」从某个中心化机构转移到了可审计的代码逻辑上。

本节要点

  • 投票合约的核心需求是「身份认证、一人一票、公开计票」。
  • 威胁模型告诉我们:防御重复投票、非授权投票与中心化管理员作恶是设计重点。
  • 合约架构围绕 Election(调度)、Candidate(数据)与 VoterRegistry(身份)三层展开。

17.2 编写投票合约:候选人注册、投票与防重入

17.2.1 核心状态变量与数据结构

合约需要以下关键状态:

变量类型用途
candidatesCountuint候选人总数,作为自增 ID 分配器
candidates[id]mapping(uint => Candidate)按 ID 索引候选人信息
voted[addr]mapping(address => bool)记录某地址是否已投票
authorizedVoters[addr]mapping(address => bool)授权白名单
owneraddress管理员地址,继承自 OpenZeppelin 的 Ownable

其中 Candidate 结构体定义为:

solidity
struct Candidate {
    uint id;
    string name;
    uint voteCount;
}

17.2.2 访问控制:onlyOwner 与 OpenZeppelin 继承

许多初学者会手写一个 modifier onlyOwner,但更好的方案是直接继承 OpenZeppelin 的 Ownable.sol

solidity
import "@openzeppelin/contracts/access/Ownable.sol";
contract Vote is Ownable {
    // 自动生成 owner 与 onlyOwner 修饰符
}

这样做的好处有三重:经过社区审计的代码更安全;未来迁移到 AccessControl 做细粒度角色管理时路径清晰;代码语义对阅读者更友好。

17.2.3 投票函数完整流程设计

投票函数 vote(uint _candidateId) 必须按如下严格顺序执行——这一顺序是防御重入攻击(Reentrancy)的关键:

  1. 资格审查require(authorizedVoters[msg.sender], "Not authorized");
  2. 防重复投票require(!voted[msg.sender], "Already voted");
  3. 标记已投票voted[msg.sender] = true; ——先更新状态
  4. 计票增加candidates[_candidateId].voteCount++;
  5. 触发事件emit Voted(msg.sender, _candidateId);

⚠️ 为什么是「先改状态、再发事件、最后外部调用」?

虽然本章的投票函数不涉及向用户转账,但如果未来扩展为「投票即获得代币奖励」,函数末尾的 transfercall 就可能被攻击者利用重入漏洞回调 vote()。若状态更新放在外部调用之后,攻击者在回调时仍满足 !voted[msg.sender],从而可以无限循环投票。将状态更新置于最前面,是 Solidity 安全编程的黄金铁律——Checks-Effects-Interactions 模式

17.2.4 管理员函数与查询接口

  • 构造函数constructor(string memory _electionName) Ownable(msg.sender),初始化选举名称并设定部署者为管理员。
  • 注册候选人addCandidate(string memory _name),仅 onlyOwner 可调用,内部将 candidatesCount 自增并写入新候选信息。
  • 授权选民authorizeVoter(address _voter),将地址加入白名单。
  • 查询候选人getCandidate(uint _id) public view returns (Candidate memory),供前端读取数据。

17.2.5 完整投票合约代码(完整可运行)

以下是一段基于 Solidity 0.8.19、完整继承 OpenZeppelin Ownable 的投票合约,附带 natspec 风格注释,可直接放入 contracts/Vote.sol

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "@openzeppelin/contracts/access/Ownable.sol";

/// @title 去中心化投票合约
/// @author 教程作者
/// @notice 支持管理员注册候选人、授权选民,并确保每人只能投一票
/// @dev 基于 OpenZeppelin Ownable 做访问控制
contract Vote is Ownable {

    /// @notice 候选人数据结构
    struct Candidate {
        uint id;
        string name;
        uint voteCount;
    }

    /// @notice 选举名称
    string public electionName;

    /// @notice 候选人总数,自动递增作为 ID 分配器
    uint public candidatesCount;

    /// @notice 候选人索引映射,id => Candidate
    mapping(uint => Candidate) public candidates;

    /// @notice 记录某地址是否已投票
    mapping(address => bool) public voted;

    /// @notice 授权选民白名单
    mapping(address => bool) public authorizedVoters;

    /// @notice 投票事件,用于前端实时监听
    /// @param voter 投票者地址
    /// @param candidateId 所投候选人 ID
    event Voted(address indexed voter, uint indexed candidateId);

    /// @notice 注册候选人事件
    /// @param candidateId 候选人 ID
    /// @param name 候选人名称
    event CandidateAdded(uint indexed candidateId, string name);

    /// @notice 选民授权事件
    /// @param voter 被授权地址
    event VoterAuthorized(address indexed voter);

    /// @param _electionName 选举主题名称
    constructor(string memory _electionName) Ownable(msg.sender) {
        electionName = _electionName;
    }

    /// @notice 管理员注册新候选人
    /// @param _name 候选人名称
    function addCandidate(string memory _name) public onlyOwner {
        candidatesCount++;
        candidates[candidatesCount] = Candidate(candidatesCount, _name, 0);
        emit CandidateAdded(candidatesCount, _name);
    }

    /// @notice 管理员将地址加入授权选民白名单
    /// @param _voter 待授权的以太坊地址
    function authorizeVoter(address _voter) public onlyOwner {
        authorizedVoters[_voter] = true;
        emit VoterAuthorized(_voter);
    }

    /// @notice 授权选民为指定候选人投票,每人只能投一次
    /// @param _candidateId 候选人 ID(1-based)
    function vote(uint _candidateId) public {
        // Step 1: 身份校验
        require(authorizedVoters[msg.sender], "Vote: caller is not an authorized voter");

        // Step 2: 防重复投票
        require(!voted[msg.sender], "Vote: already voted");

        // Step 3: 候选人存在性校验
        require(_candidateId > 0 && _candidateId <= candidatesCount, "Vote: invalid candidate ID");

        // Step 4: 先更新状态(Checks-Effects)
        voted[msg.sender] = true;

        // Step 5: 计票增加
        candidates[_candidateId].voteCount++;

        // Step 6: 触发事件(Interactions 的最后一步)
        emit Voted(msg.sender, _candidateId);
    }

    /// @notice 按 ID 查询候选人信息
    /// @param _id 候选人 ID
    /// @return 候选人结构体
    function getCandidate(uint _id) public view returns (Candidate memory) {
        require(_id > 0 && _id <= candidatesCount, "Vote: invalid candidate ID");
        return candidates[_id];
    }
}

这段代码遵循了安全编码的三大原则:

  1. 单一入口约束:所有状态变更只能由 vote() 函数一次触发,不存在旁路修改票数的后门。
  2. 先校验后修改require 语句在前,确保非法请求在 Gas 消耗最小的阶段被回退。
  3. 事件驱动透明:每一次投票都会触发 Voted 事件,前端可以通过 ethers.jscontract.on("Voted", ...) 实时更新计票面板。

本节要点

  • 核心状态由 candidatesvotedauthorizedVoters 三组映射共同维护。
  • 投票函数必须遵循 Checks → Effects → Interactions 顺序,这是防重入的黄金铁律。
  • OpenZeppelin Ownable 让访问控制更简洁、更安全、更可维护。
  • natspec 注释不仅是文档,还能被 Etherscan 与开发者工具自动解析。

17.3 Hardhat 工程化:编译、部署与脚本编写

17.3.1 项目初始化与依赖安装

将合约代码落地为可运行、可测试、可部署的工程,需要一套完整的开发工具链。本章选用 Hardhat——它内置了以太网模拟节点、任务系统与插件生态,是目前以太坊开发者最主流的工程化框架。

创建并初始化项目的命令如下:

bash
mkdir voting-dapp && cd voting-dapp
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat init   # 选择「Create a TypeScript project」或 JavaScript 项目
npm install @openzeppelin/contracts

初始化后的项目目录结构如下:

text
voting-dapp/
├── contracts/
│   └── Vote.sol          # 将 17.2.5 的合约代码放入此处
├── scripts/
│   └── deploy.js         # 部署脚本
├── test/
│   └── Vote.test.js      # 单元测试(后续章节展开)
├── hardhat.config.js     # 编译器与网络配置
├── package.json
└── node_modules/

下图展示了 Hardhat 项目内各目录的职责与工作流程:

flowchart TD
    A[contracts/ 合约源码] -->|hardhat compile| B[编译产物 artifacts/]
    B --> C[scripts/ 部署脚本]
    C -->|npx hardhat run| D[Hardhat Network 本地节点]
    E[test/ 单元测试] -->|npx hardhat test| D
    D -->|实时反馈| F[开发者控制台]

contracts/ 保存 .sol 源码,scripts/ 负责部署与初始化,test/ 做断言验证,而 Hardhat Network 为这一切提供极速的本地执行环境,不需要等待真实区块确认。

17.3.2 配置 hardhat.config.js

为了让 Hardhat 正确编译 0.8.19 版本的 Solidity 代码,我们需要在配置文件中指定编译器版本,并开启优化器以降低部署 Gas:

javascript
// hardhat.config.js
require("@nomicfoundation/hardhat-toolbox");

/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
  solidity: {
    version: "0.8.19",
    settings: {
      optimizer: {
        enabled: true,
        runs: 200,
      },
    },
  },
  // 默认网络为内置的 Hardhat Network,无需额外配置即可测试
  // 后续部署到 Sepolia 等测试网时,可在此添加 network 配置
};

optimizer 的作用是压缩合约字节码体积并减少运行时 Gas。runs: 200 表示优化器重排代码以假设函数会被调用约 200 次,这是兼顾部署成本与执行成本的常用参数。

17.3.3 编写部署脚本

部署脚本 scripts/deploy.js 的职责不仅是「部署一份合约」,更包括预填充初始化数据——注册候选人与授权测试选民,让后续手动交互时可以直接投票,而不必重复打字。

javascript
// scripts/deploy.js
const hre = require("hardhat");

async function main() {
  // 获取部署者钱包地址
  const [deployer, voter1, voter2] = await hre.ethers.getSigners();
  console.log("部署者地址:", deployer.address);

  // 1. 编译并获取合约工厂
  const VoteFactory = await hre.ethers.getContractFactory("Vote");

  // 2. 部署合约,传入构造函数参数
  const voteContract = await VoteFactory.deploy("2024 社区治理选举");

  // 3. 等待部署上链
  await voteContract.waitForDeployment();
  console.log("投票合约已部署至:", await voteContract.getAddress());

  // 4. 管理员操作:注册候选人
  await (await voteContract.addCandidate("Alice - 建设更多公共节点")).wait();
  await (await voteContract.addCandidate("Bob - 优化 Gas 费分配")).wait();
  await (await voteContract.addCandidate("Charlie - 引入 DAO 治理")).wait();
  console.log("已注册 3 名候选人");

  // 5. 管理员操作:授权测试选民
  await (await voteContract.authorizeVoter(voter1.address)).wait();
  await (await voteContract.authorizeVoter(voter2.address)).wait();
  console.log("已授权测试选民:", voter1.address, voter2.address);

  // 6. 初始状态查询
  const c1 = await voteContract.getCandidate(1);
  console.log(`候选人 #1: c1.name,当前票数:{c1.name}, 当前票数:{c1.voteCount}`);
}

// 优雅处理异步异常
main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

执行部署:

bash
npx hardhat run scripts/deploy.js --network hardhat

控制台应依次输出部署地址、候选人注册信息与测试选民地址。

17.3.4 使用 Hardhat Console 本地交互验证

脚本部署后,接下来进入交互式验证阶段。Hardhat 提供了 console 子命令,允许开发者在已部署合约上逐条执行函数调用,非常适合教学与调试。

首先,在另一个终端启动一个长期运行的本地节点(模拟真实区块链,MetaMask 也能够连接):

bash
npx hardhat node

然后打开 Hardhat Console 并连接到该本地节点,加载已部署的合约实例:

bash
npx hardhat console --network localhost

> 提示符下,你可以执行以下操作来验证业务逻辑:

javascript
// 加载已部署的合约(替换为 deploy.js 输出的实际地址)
const Vote = await ethers.getContractFactory("Vote");
const vote = await Vote.attach("0x5FbDB2315678afecb367f032d93F642f64180aa3");

// 获取可用签名者
const [owner, voter1, voter2, stranger] = await ethers.getSigners();

// 查询候选人信息
await vote.getCandidate(1);
// 返回: [ 1n, 'Alice - 建设更多公共节点', 0n ]

// 以 voter1 身份投票
await vote.connect(voter1).vote(1);
// 交易确认后,voter1 成功投票给候选人 1

// 再次查询票数
await vote.getCandidate(1);
// 返回: [ 1n, 'Alice - 建设更多公共节点', 1n ]

// 尝试重复投票(应被回滚)
await vote.connect(voter1).vote(2);
// 抛出: Error: VM Exception while processing transaction: reverted with reason string 'Vote: already voted'

// 以未授权地址投票(应被回滚)
await vote.connect(stranger).vote(1);
// 抛出: Error: VM Exception while processing transaction: reverted with reason string 'Vote: caller is not an authorized voter'

// 以 voter2 投票给不同候选人
await vote.connect(voter2).vote(3);
await vote.getCandidate(3);
// 返回: [ 3n, 'Charlie - 引入 DAO 治理', 1n ]

下图以时序图的形式展示了上述交互的完整流程:

sequenceDiagram
    participant U as 开发者
    participant C as hardhat console
    participant N as Hardhat Network
    participant S as Vote.sol 合约
    participant E as 链上存储

    U->>C: npx hardhat console --network localhost
    C->>N: 请求合约实例
    N->>C: 返回 Vote 合约句柄
    U->>C: vote.connect(voter1).vote(1)
    C->>N: 发送交易
    N->>S: 执行 vote(1)
    S->>S: require(authorized)
    S->>S: require(!voted)
    S->>E: voted[voter1] = true
    S->>E: candidates[1].voteCount++
    S->>N: emit Voted
    N->>C: 交易回执
    C->>U: 返回成功
    U->>C: vote.connect(voter1).vote(2)
    C->>N: 发送交易
    N->>S: 执行 vote(2)
    S->>S: require(!voted) ❌ 失败回滚
    N->>C: 抛出 revert
    C->>U: 显示 'Vote: already voted'

这个交互过程充分验证了合约的两大核心安全属性:

  1. 已授权选民可以成功投票,且票数正确累加。
  2. 重复投票与未授权投票均被合约级 revert 拦截,不会污染状态。

Hardhat Console 是这一验证过程的利器——它比写前端更快,比直接写测试用例更灵活,让开发者可以在任何阶段停下来检查合约内部状态。

本节要点

  • 工程化四步法:初始化npm install(Hardhat + OpenZeppelin) → 编译hardhat compile) → 部署脚本hardhat run) → 本地交互/测试
  • hardhat.config.js 通过 optimizer 配置降低部署与执行成本。
  • 部署脚本不仅是「启动合约」,还应包含「预填充数据」,降低后续交互的重复工作。
  • npx hardhat console 提供了与已部署合约实时交互的 REPL 环境,是调试与验证的最佳入口。

章末小结:3 个关键认知

  1. 安全不是附加功能,而是设计前提。从威胁模型出发,我们在编码之前就已经明确了「防重投、防非授权投票、防管理员作恶」三大防御目标,这些目标直接映射为 voted 映射、authorizedVoters 白名单与 Ownable 修饰符。先想「坏人怎么做」,再写「代码怎么防」,是合约开发的正确顺序。
  1. Checks-Effects-Interactions 是防重入的黄金铁律vote() 函数将 voted[msg.sender] = true 这一状态更新放在任何可能被攻击的外部调用(如未来扩展的代币奖励)之前。这一顺序原则不仅适用于投票,也适用于所有涉及状态变更与外部调用的智能合约函数。
  1. Hardhat 工程化让「编译-部署-交互-测试」形成高速闭环。从 hardhat compilehardhat run scripts/deploy.js 再到 hardhat console,整个流程在本地几秒内就能完成,无需等待测试网区块确认。这种快速反馈是 DApp 开发效率的核心保障。

附录:项目目录树

将本章所有代码整理后,完整的文件结构如下:

text
voting-dapp/
├── contracts/
│   └── Vote.sol              # 投票主合约(Solidity 0.8.19)
├── scripts/
│   └── deploy.js             # 部署与初始化脚本
├── test/
│   └── Vote.test.js          # 单元测试(将在后续章节展开)
├── node_modules/             # 依赖包(Hardhat、OpenZeppelin 等)
├── hardhat.config.js         # 编译器与网络配置
├── package.json
└── package-lock.json

下一步预告:第 18 章将基于本章已部署的合约,使用 Chai 与 Hardhat 编写完整的单元测试,覆盖边界条件、异常回滚与事件断言,让你掌握合约测试的完整方法论。

评论

0

评论加载中…

发表评论

0/2000