在上一阶段,我们已用 Solidity 完成了投票合约的编码与 Hardhat 工程化配置。本章将完成合约的后端测试与优化,并打通面向用户的前端全栈交互链路。
17.4 合约单元测试与Gas优化
17.4.1 单元测试覆盖场景
智能合约一经部署便不可修改,因此单元测试是 DApp 开发的"最后一道防线"。对于投票系统,必须验证以下核心场景:
- 成功填充选举信息
- 授权选民(选民白名单维护)
- 合法投票且正确计票
- 非法场景回滚:重复投票、非授权选民投票、非候选人投票
Hardhat 提供了与 Mocha / Chai 无缝集成的测试框架,并针对智能合约扩展了语义化的 matchers。我们的测试套件按如下流程执行:
flowchart TD
A[hardhat test] --> B[是否要顺序执行?]
B -->|是| C[beforeEach 重置快照]
C --> D[部署 Voting 合约]
D --> E[授权测试账户为选民]
E --> F[获取合约实例与 Signer]
F --> G[执行测试用例]
G --> H[expect 结果与预期
致?]
H -->|是| I["✅测试通过,下一用例"]
I -->|否| J[回滚至快照,清理状态]
J --> K[执行下一用例]
每一条测试路径都保证独立、可重复运行,通过 beforeEach 钩子每次重新部署合约,避免测试间状态污染。
17.4.2 测试核心代码
// test/Voting.test.js
const { expect } = require("chai");
describe("Voting 合约测试", function () {
let voting;
let owner, voterA, voterB, attacker;
// 每用例重置,避免状态污染
beforeEach(async () => {
[owner, voterA, voterB, attacker] = await ethers.getSigners();
const VotingFactory = await ethers.getContractFactory("Voting");
voting = await VotingFactory.deploy(["Alice", "Bob"]);
await voting.setVoterStatus(voterA.address, true);
});
it("应创建候选人", async () => {
const candidates = await voting.getCandidates();
expect(candidates).to.deep.equal(["Alice", "Bob"]);
});
it("授权选民可投票并能正确增量", async () => {
await voting.connect(voterA).vote(0);
const count = await voting.votes(0);
expect(count).to.equal(1);
});
it("非法选民尝试投票应回滚", async () => {
await expect(
voting.connect(attacker).vote(0)
).to.be.revertedWith("Not authorized voter");
});
it("重复投票应回滚", async () => {
await voting.connect(voterA).vote(0);
await expect(
voting.connect(voterA).vote(0)
).to.be.revertedWith("Already voted");
});
});关键认知:
hardhat-chai-matchers插件提供的revertedWith可精确校验回滚消息,让测试可读性大幅提升。to.equal对应基础断言,to.deep.equal用于数组/结构化数据比对。
17.4.3 Gas优化的权衡
以太坊主网的存储操作(SSTORE)极为昂贵,单次写入动辄花掉 20,000 gas。针对投票系统的优化空间:
- 状态变量打包:将独立但相关的变量尽量定义为同一结构体内部字段,Solidity 编译器会尝试紧凑排列。例如
uint8 id+uint8 voteCount可被打包进 1 个 32 字节槽位,而不是各占 1 槽。 - 事件替代存储:如果仅需事后审计、不需要合约逻辑读取,可用事件(
event Voted(address, uint8, uint))替代链上状态更新。事件写入成本约为存储操作的 1/5 至 1/8。 - 减少变量类型级别的"过度分配":使用
uint8替代uint256可节省空间,但如果该变量会频繁参与运算导致额外的类型扩展开销,反而得不偿失。权衡原则是:"仅在冷存储(写入为主、运算少)场景使用小类型"。
下表列出未优化与优化后的单笔 vote() 函数 Gas 消耗对比(基于 Hardhat Network 快照):
| 操作 | 未优化(uint256/映射) | 优化(uint8/结构体打包) | 仅事件记录 |
|---|---|---|---|
| 部署合约 | 424,801 | 398,213 | 412,005 |
| 单次投票 | 66,432 | 59,821 | 12,340 |
注意:上述对比数据在本地 Hardhat 网络测得,仅用于趋势教学,不反映主网实际价格波动。
17.4.4 要点总结
- 用
beforeEach钩子确保测试独立,避免跨测试用例状态污染。 revertedWith配合清晰错误信息,可同时验证业务逻辑与错误处理链路。- 事件是降低链上写入成本的首选方案,适用于审计类需求;若需合约内部直接读取,则仍需存储状态。
- 小类型(
uint8)的 Gas 收益仅在"冷存储"场景显著,频繁参与运算时可能得不偿失。
17.5 使用 ethers.js 与 React 构建前端
17.5.1 项目架构与数据流
前端作为用户与智能合约交互的入口,需要清晰的分层数据流。我们从组件架构开始,逐步向下连接合约层:
graph TD
A[App.jsx] --> B[WalletConnect]
A --> C[ElectionInfo]
A --> D[CandidateList]
A --> E[VoteButton]
A --> F[ResultsChart]
B -->|账号+签名者状态| A
D -->|候选人列表| E
E -->|投票请求| A
A -->|展示票数| F
核心设计原则:只读(Read)与写入(Write)分离。读取选举信息、候选人列表等操作无需钱包授权,仅需调用 provider 上的只读方法;投票等写入操作则需要用户签名,需获得 signer。通过自定义 useContract Hook,我们可以在全局高效切换这两种模式。
17.5.2 自定义 Hook:useContract
// src/hooks/useContract.js
import { useMemo } from "react";
import { Contract, JsonRpcProvider, BrowserProvider } from "ethers";
import abi from "../artifacts/contracts/Voting.sol/Voting.json";
const CONTRACT_ADDRESS = "0x..."; // 部署后替换
export function useContract(signer = null) {
return useMemo(() => {
if (!signer) return null;
// signer 模式下以写入
const contract = new Contract(CONTRACT_ADDRESS, abi.abi, signer);
return contract;
}, [signer]);
}
export function useReadContract() {
return useMemo(() => {
const provider = new JsonRpcProvider("https://rpc.sepolia.org");
return new Contract(CONTRACT_ADDRESS, abi.abi, provider);
}, []);
}Hook 职责分离:
useReadContract以无签名的 RPC 调用,不需要 MetaMask 授权即可展示基础数据;useContract将用户签名者(Signer)传递给合约实例,实现写入权限管理。
17.5.3 候选人列表组件(只读模式)
// src/components/CandidateList.jsx
import { useEffect, useState } from "react";
import { useReadContract } from "../hooks/useContract";
export default function CandidateList() {
const [candidates, setCandidates] = useState([]);
const contract = useReadContract();
useEffect(() => {
if (!contract) return;
const fetch = async () => {
const list = await contract.getCandidates();
setCandidates(list);
};
fetch();
}, [contract]);
return (
<ul className="candidate-list">
{candidates.map((name, idx) => (
<li key={idx}>
<span className="id">{idx}</span> {name}
</li>
))}
</ul>
);
}17.5.4 投票按钮组件(写入模式 + 异步状态管理)
// src/components/VoteButton.jsx
import { useState } from "react";
export default function VoteButton({ contract }) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [tx, setTx] = useState(null);
const vote = async (candidateId) => {
if (!contract) return;
setError(null);
setTx(null);
setLoading(true);
try {
const txReq = await contract.vote(candidateId);
setTx(`广播中... Hash: ${txReq.hash.slice(0, 12)}...`);
const receipt = await txReq.wait();
setTx(`确认成功!区块号: ${receipt.blockNumber}`);
} catch (err) {
setError(err.code === 4001 ? "用户取消了交易" : err.message);
} finally {
setLoading(false);
}
};
return (
<div>
<button onClick={() => vote(0)} disabled={!contract || loading}>
{loading ? "投票中..." : "投票给 Alice"}
</button>
{tx && <p className="success">✅ {tx}</p>}
{error && <p className="error">❌ {error}</p>}
</div>
);
}前端交互时序如下:
sequenceDiagram
actor User
participant Button as VoteButton.jsx
participant Hook as useContract
participant MetaMask as 钱包扩展
participant EVM as 以太坊节点
User->>Button: 点击"投票"
Button->>Hook: 调用 contract.vote(id)
Hook->>MetaMask: 发起 eth_sendTransaction
MetaMask->>User: 弹出签名窗口
User->>MetaMask: 确认交易
MetaMask->>EVM: 提交已签交易
EVM-->>MetaMask: 返回 TX Hash
MetaMask-->>Hook: 返回 Transaction 对象
Hook-->>Button: 设置 tx Hash 状态
Button-->>User: 显示 "广播中..."
EVM->>EVM: 挖矿确认(等待...)
EVM-->>MetaMask: 确认回调
MetaMask-->>Hook: 交易回执(receipt)
Hook-->>Button: 设置 confirmed 状态
Button-->>User: 显示 "✅ 确认成功"
异步三态原则:所有涉及交易的 UI 必须显式管理
loading / error / success三种状态。没有反馈的按钮会让用户误以为操作成功或失败,导致重复触发交易(重复投票)。
17.5.5 要点总结
- 前端与合约交互应按 只读 / 写入 拆分为两类 Hook,避免在不需要时弹出钱包授权。
ethers.Contract创建开销极小,但仍应在useMemo中缓存,避免依赖项不变时重复实例化。- 所有与交易相关的按钮必须显示三个状态:加载中、成功反馈、错误提示。
- Mermaid 组件树图可将复杂的前端层级以可视化的方式呈现,便于团队协作与架构评审。
17.6 钱包连接与签名交互
17.6.1 检测与连接 MetaMask
钱包插件通过浏览器注入的 window.ethereum 对象提供接口。主流前端第一步:检测对象是否存在,若缺失则给出明确的安装引导。
// src/components/WalletConnect.jsx
import { useState } from "react";
import { BrowserProvider } from "ethers";
import { useContract } from "../hooks/useContract";
export default function WalletConnect({ onConnect }) {
const [status, setStatus] = useState("未连接");
const [address, setAddress] = useState(null);
const connectWallet = async () => {
if (typeof window.ethereum === "undefined") {
setStatus("未检测到兼容钱包,请安装 MetaMask");
return;
}
try {
const provider = new BrowserProvider(window.ethereum);
const accounts = await provider.send("eth_requestAccounts", []);
const signer = await provider.getSigner();
setAddress(accounts[0]);
setStatus("已连接");
onConnect(signer);
} catch (err) {
if (err.code === 4001) {
setStatus("用户拒绝了账户授权");
} else {
setStatus("连接失败: " + err.message);
}
}
};
return (
<div className="wallet-connect">
<p>状态: <b>{status}</b></p>
{address && <p>地址: {address.slice(0, 10)}...{address.slice(-6)}</p>}
<button onClick={connectWallet}>连接钱包</button>
</div>
);
}17.6.2 网络切换与链 ID 校验
测试网(Sepolia)与主网的链 ID 不同,前端需确保用户连接到了目标网络。不匹配时主动调用 wallet_switchEthereumChain 切换。
// 网络配置映射
const SUPPORTED_CHAIN = 0xaa36a7; // Sepolia 测试网
const SEPOLIA_RPC = "https://rpc.sepolia.org";
async function checkAndSwitchNetwork() {
const chainId = await window.ethereum.request({ method: "eth_chainId" });
if (Number(chainId) !== SUPPORTED_CHAIN) {
try {
await window.ethereum.request({
method: "wallet_switchEthereumChain",
params: [{ chainId: `0x${SUPPORTED_CHAIN.toString(16)}` }],
});
} catch (err) {
if (err.code === 4902) {
// 用户未添加目标网络,请求添加
await window.ethereum.request({
method: "wallet_addEthereumChain",
params: [{
chainId: `0x${SUPPORTED_CHAIN.toString(16)}`,
chainName: "Sepolia 测试网",
rpcUrls: [SEPOLIA_RPC],
}],
});
} else {
throw err;
}
}
}
}钱包连接的状态机可用以下状态图描述:
stateDiagram
[*] --> 未连接
未连接 --> 已连接: 检测到钱包
已连接 --> 网络错误: 链 ID 不匹配
网络错误 --> 已连接: 切换网络成功
已连接 --> 交易进行中: 发送交易
交易进行中 --> 已连接: 交易结果返回
已连接 --> 未连接: 钱包断开/锁定
未连接 --> [*]
17.6.3 交易发送与 Gas 估算
// 带 Gas Limit 估算的投票交易发送
const sendVoteTransaction = async (contract, candidateId) => {
const txReq = await contract.vote.populateTransaction(candidateId);
const gasEstimate = await contract.runner.estimateGas(txReq);
const gasLimit = (gasEstimate * 120n) / 100n; // 增加 20% 安全余量
const tx = await contract.vote(candidateId, { gasLimit });
return tx.wait();
};Gas 安全策略:直接预估值发送存在竞争条件下高失败风险。增加 10%–20% 余量可显著降低交易被打回(revert)概率,同时避免过度浪费。若设置过大,多余 gas 不会被消耗,仅影响最大上限。
17.6.4 离线签名(高阶选学)
去中心化应用的某些场景(如链下数据登记、轻量级身份认证)不需要交易上链,只需证明某地址拥有对应私钥即可。
// 投票前的链下身份验证(可选)
async function signAuthorization(signer, account, candidateId) {
const message = `I authorize my address {account} to vote for candidate #{candidateId}`;
const signature = await signer.signMessage(message);
return { account, message, signature };
// 后端验证:ethers.utils.verifyMessage(message, signature) === account
}链下签名零 Gas、几乎即时生效,适用于需要高速确认的业务。投票 DApp 主流程不在链下完成,但可以引入"链下预授权 + 可信后端代投"模式降低前端摩擦。
17.6.5 要点总结
window.ethereum的检测是必做步骤,未安装钱包时应提供清晰的安装引导而非报错。eth_requestAccounts弹出授权窗口时,务必捕获 4001 错误码,区分"用户拒绝"与"系统错误",提升交互体验。- 链 ID 校验 +
wallet_switchEthereumChain切换是用户 onboarding 的"防坑"关键,避免用户误在主网支付真实 Gas。 - 带
estimateGas+ 20% 安全余量的发送模式,能显著降低交易失败率。 - 离线签名是补全前端工具箱的高阶技能,在认证与快速确认场景下尤其实用。
总结:三阶段模型的闭环
本章将投票 DApp 从合约"黑盒"延伸为可交互的完整应用:
- 17.4 单元测试 建立了后端安全底线,Gas 优化让我们理解链上资源的真实成本。
- 17.5 前端架构 通过 React 组件树与自定义 Hook,将复杂合约接口转化为清晰、可复用的 UI 层。
- 17.6 钱包交互 打通了用户与区块链之间的"最后一公里",从验权、网络切换到安全交易发送,确保每一次投票都可靠可追溯。
这三个环节共同构成了一条从代码到用户的完整链路。下一章,我们将进一步拓展:监听合约事件以实时更新投票结果图表,并最终将 DApp 部署至公共测试网。
评论
0评论加载中…