在完成了对智能合约安全基础(重入、溢出、访问控制)的理解之后,我们终于要将这些知识融入一个完整的应用级场景——去中心化投票系统。投票是区块链最早被看好的落地场景之一:它天然需要不可篡改的记票本与透明可查的计票规则,而这正是智能合约的核心优势。
本章将带领你经历一次从业务需求拆解到合约代码落地再到Hardhat 工程化部署的完整闭环。你将亲手编写一个支持管理员注册候选人、授权选民身份、防重入投票、实时计票的 Vote.sol 合约,并借助 Hardhat 工具链在本地网络完成部署与交互验证。后续章节会在此基础上搭建 React 前端,形成完整的 DApp。
17.1 需求分析与智能合约设计
17.1.1 投票场景的业务需求拆解
一个可信的链上投票系统,至少需要满足以下五条业务规则:
- 管理员创建选举:由可信的管理员初始化选举主题,并注册候选人名单。
- 选民身份注册:投票前,管理员或授权机构将合格选民的地址录入白名单,防止无关地址参与。
- 一人一票制:每个被授权的选民在整个选举周期中只能投出一张选票,且不可撤回或修改。
- 结果实时可见:每位候选人的得票数对全网络公开透明,任何人都可以验证计票结果。
- 生命周期管理:选举应具有明确的开始与结束状态(本章先做简化版,后续扩展为时间锁控制)。
这些需求看似朴素,但在去信任环境中,每一条都需要用代码强制约束,因为链上不存在现实中的「检票员」。
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 核心状态变量与数据结构
合约需要以下关键状态:
| 变量 | 类型 | 用途 |
|---|---|---|
candidatesCount | uint | 候选人总数,作为自增 ID 分配器 |
candidates[id] | mapping(uint => Candidate) | 按 ID 索引候选人信息 |
voted[addr] | mapping(address => bool) | 记录某地址是否已投票 |
authorizedVoters[addr] | mapping(address => bool) | 授权白名单 |
owner | address | 管理员地址,继承自 OpenZeppelin 的 Ownable |
其中 Candidate 结构体定义为:
struct Candidate {
uint id;
string name;
uint voteCount;
}17.2.2 访问控制:onlyOwner 与 OpenZeppelin 继承
许多初学者会手写一个 modifier onlyOwner,但更好的方案是直接继承 OpenZeppelin 的 Ownable.sol:
import "@openzeppelin/contracts/access/Ownable.sol";
contract Vote is Ownable {
// 自动生成 owner 与 onlyOwner 修饰符
}这样做的好处有三重:经过社区审计的代码更安全;未来迁移到 AccessControl 做细粒度角色管理时路径清晰;代码语义对阅读者更友好。
17.2.3 投票函数完整流程设计
投票函数 vote(uint _candidateId) 必须按如下严格顺序执行——这一顺序是防御重入攻击(Reentrancy)的关键:
- 资格审查:
require(authorizedVoters[msg.sender], "Not authorized"); - 防重复投票:
require(!voted[msg.sender], "Already voted"); - 标记已投票:
voted[msg.sender] = true;——先更新状态 - 计票增加:
candidates[_candidateId].voteCount++; - 触发事件:
emit Voted(msg.sender, _candidateId);
⚠️ 为什么是「先改状态、再发事件、最后外部调用」?
虽然本章的投票函数不涉及向用户转账,但如果未来扩展为「投票即获得代币奖励」,函数末尾的
transfer或call就可能被攻击者利用重入漏洞回调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:
// 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];
}
}这段代码遵循了安全编码的三大原则:
- 单一入口约束:所有状态变更只能由
vote()函数一次触发,不存在旁路修改票数的后门。 - 先校验后修改:
require语句在前,确保非法请求在 Gas 消耗最小的阶段被回退。 - 事件驱动透明:每一次投票都会触发
Voted事件,前端可以通过ethers.js的contract.on("Voted", ...)实时更新计票面板。
本节要点
- 核心状态由
candidates、voted、authorizedVoters三组映射共同维护。- 投票函数必须遵循 Checks → Effects → Interactions 顺序,这是防重入的黄金铁律。
- OpenZeppelin
Ownable让访问控制更简洁、更安全、更可维护。natspec注释不仅是文档,还能被 Etherscan 与开发者工具自动解析。
17.3 Hardhat 工程化:编译、部署与脚本编写
17.3.1 项目初始化与依赖安装
将合约代码落地为可运行、可测试、可部署的工程,需要一套完整的开发工具链。本章选用 Hardhat——它内置了以太网模拟节点、任务系统与插件生态,是目前以太坊开发者最主流的工程化框架。
创建并初始化项目的命令如下:
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初始化后的项目目录结构如下:
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:
// 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 的职责不仅是「部署一份合约」,更包括预填充初始化数据——注册候选人与授权测试选民,让后续手动交互时可以直接投票,而不必重复打字。
// 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.voteCount}`);
}
// 优雅处理异步异常
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});执行部署:
npx hardhat run scripts/deploy.js --network hardhat控制台应依次输出部署地址、候选人注册信息与测试选民地址。
17.3.4 使用 Hardhat Console 本地交互验证
脚本部署后,接下来进入交互式验证阶段。Hardhat 提供了 console 子命令,允许开发者在已部署合约上逐条执行函数调用,非常适合教学与调试。
首先,在另一个终端启动一个长期运行的本地节点(模拟真实区块链,MetaMask 也能够连接):
npx hardhat node然后打开 Hardhat Console 并连接到该本地节点,加载已部署的合约实例:
npx hardhat console --network localhost在 > 提示符下,你可以执行以下操作来验证业务逻辑:
// 加载已部署的合约(替换为 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'
这个交互过程充分验证了合约的两大核心安全属性:
- 已授权选民可以成功投票,且票数正确累加。
- 重复投票与未授权投票均被合约级
revert拦截,不会污染状态。
Hardhat Console 是这一验证过程的利器——它比写前端更快,比直接写测试用例更灵活,让开发者可以在任何阶段停下来检查合约内部状态。
本节要点
- 工程化四步法:初始化 →
npm install(Hardhat + OpenZeppelin) → 编译(hardhat compile) → 部署脚本(hardhat run) → 本地交互/测试。hardhat.config.js通过optimizer配置降低部署与执行成本。- 部署脚本不仅是「启动合约」,还应包含「预填充数据」,降低后续交互的重复工作。
npx hardhat console提供了与已部署合约实时交互的 REPL 环境,是调试与验证的最佳入口。
章末小结:3 个关键认知
- 安全不是附加功能,而是设计前提。从威胁模型出发,我们在编码之前就已经明确了「防重投、防非授权投票、防管理员作恶」三大防御目标,这些目标直接映射为
voted映射、authorizedVoters白名单与Ownable修饰符。先想「坏人怎么做」,再写「代码怎么防」,是合约开发的正确顺序。
- Checks-Effects-Interactions 是防重入的黄金铁律。
vote()函数将voted[msg.sender] = true这一状态更新放在任何可能被攻击的外部调用(如未来扩展的代币奖励)之前。这一顺序原则不仅适用于投票,也适用于所有涉及状态变更与外部调用的智能合约函数。
- Hardhat 工程化让「编译-部署-交互-测试」形成高速闭环。从
hardhat compile到hardhat run scripts/deploy.js再到hardhat console,整个流程在本地几秒内就能完成,无需等待测试网区块确认。这种快速反馈是 DApp 开发效率的核心保障。
附录:项目目录树
将本章所有代码整理后,完整的文件结构如下:
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评论加载中…