教程区块链区块链基础知识chunk_49_ch17_voting_pt2第17章 以太坊投票DApp全栈开发(续)

本页目录

在上一阶段,我们已用 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 测试核心代码

javascript
// 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,801398,213412,005
单次投票66,43259,82112,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

javascript
// 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 候选人列表组件(只读模式)

jsx
// 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 投票按钮组件(写入模式 + 异步状态管理)

jsx
// 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 对象提供接口。主流前端第一步:检测对象是否存在,若缺失则给出明确的安装引导。

jsx
// 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 切换。

javascript
// 网络配置映射
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 估算

javascript
// 带 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 离线签名(高阶选学)

去中心化应用的某些场景(如链下数据登记、轻量级身份认证)不需要交易上链,只需证明某地址拥有对应私钥即可。

javascript
// 投票前的链下身份验证(可选)
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 从合约"黑盒"延伸为可交互的完整应用:

  1. 17.4 单元测试 建立了后端安全底线,Gas 优化让我们理解链上资源的真实成本。
  2. 17.5 前端架构 通过 React 组件树与自定义 Hook,将复杂合约接口转化为清晰、可复用的 UI 层。
  3. 17.6 钱包交互 打通了用户与区块链之间的"最后一公里",从验权、网络切换到安全交易发送,确保每一次投票都可靠可追溯。

这三个环节共同构成了一条从代码到用户的完整链路。下一章,我们将进一步拓展:监听合约事件以实时更新投票结果图表,并最终将 DApp 部署至公共测试网。

评论

0

评论加载中…

发表评论

0/2000