简介:本资源是一份面向区块链开发初学者与高校课程实践者的智能合约投票系统Dapp完整项目源码,聚焦以太坊链上投票场景,解决传统中心化投票易篡改、不透明、缺乏审计依据等痛点。项目采用Truffle框架完成智能合约编译、测试与部署,Solidity编写核心投票逻辑(如候选人管理、投票权限控制、实时计票),React构建响应式前端界面,实现用户交互、状态同步与链上数据可视化。压缩包为ZIP格式,共包含数十个结构化文件,涵盖Solidity合约(contracts/)、迁移脚本(migrations/)、单元测试(test/)、Truffle配置(truffle-config.js)、ABI接口及React前端源码(src/),整体大小6.59MB,目录层级清晰,便于理解Dapp全栈开发流程。已有1544人学习下载,读者可直接运行调试,掌握从合约编写→本地测试网部署→前端集成→Web3.js调用的完整闭环,获得可复用的工程模板与典型业务逻辑实现范例。
1. 为什么一个「投票 DApp」能串起 Truffle、React 和 Solidity 的真实工程链路?
这不是一个玩具 demo。当你在本地跑通一个支持多提案、实时计票、链上身份校验的投票系统时,你实际完成的是 Web3 工程师最核心的闭环能力验证:合约逻辑是否经得起重入与边界攻击?前端状态是否与链上数据严格同步?交易失败时用户看到的到底是 gas 不足还是权限拒绝?——这些不是概念题,是每天在 MetaMask 弹窗后真实发生的决策点。本项目用 Truffle 搭建开发-测试-部署流水线,Solidity 实现带时间窗口、防重复投票、可升级计票逻辑的合约,React 构建响应式前端并直连 MetaMask 状态。它不追求炫酷 UI,但每行代码都对应以太坊主网或 Sepolia 上可复现的行为。适合刚写完第一个HelloWorld.sol、正卡在「合约怎么和页面通信」的新手;也适合想补全 DApp 全栈链路、把面试中「讲不清前端如何监听事件」变成「我改过 web3.js 的 event filter 重试逻辑」的进阶者。你将亲手把「调用合约函数」从一句contract.methods.vote(1).send()拆解成:ABI 解析是否正确、from 地址是否已解锁、nonce 是否冲突、event log 解析是否漏掉历史区块、React state 更新是否被 pending 交易阻塞——这才是真实世界里写 DApp 的日常。
2. 用 Truffle 搭建可测试、可部署的合约开发环境:从 init 到 migrate
Truffle 不是脚手架,是合约生命周期的控制台。它强制你面对以太坊开发中最容易被忽略的三件事:合约 ABI 必须与前端精确匹配、迁移脚本必须处理依赖顺序、测试必须覆盖 revert 路径而非仅 happy path。下面步骤基于 Truffle v5.9.x(当前最稳定兼容版),所有命令均在 Ubuntu 22.04 / macOS Sonoma 下实测通过。
2.1 初始化项目并配置网络连接
mkdir voting-dapp && cd voting-dapp truffle init npm init -y npm install --save-dev truffle-hdwallet-provider@1.0.17 web3@1.8.2注意:
truffle-hdwallet-provider是 Truffle v5 时代连接钱包的标准包,不要用@truffle/hdwallet-provider(v5 不兼容)。web3@1.8.2是与 Truffle v5.9.x ABI 解析器完全匹配的版本,高版本会触发Invalid JSON RPC response错误。
编辑truffle-config.js,关键配置如下:
const HDWalletProvider = require('truffle-hdwallet-provider'); const mnemonic = "your twelve word mnemonic here"; // 替换为你的助记词 module.exports = { networks: { development: { host: "127.0.0.1", port: 8545, network_id: "*" // 匹配任意 network id }, sepolia: { provider: () => new HDWalletProvider(mnemonic, `https://sepolia.infura.io/v3/YOUR_INFURA_KEY`), network_id: 11155111, gas: 5500000, confirmations: 2, timeoutBlocks: 200, skipDryRun: true } }, compilers: { solc: { version: "0.8.19", // 必须与合约 pragma 严格一致 settings: { optimizer: { enabled: true, runs: 200 } } } } };mnemonic:必须是你自己生成的助记词(推荐用 Ian Coleman BIP39 工具离线生成),绝不可用测试网公开私钥;gas:Sepolia 链上合约部署需至少 5M gas,低于此值会out of gas;skipDryRun: true:关闭预执行检查,避免 Infura 返回method not supported错误。
2.2 编写可验证的 Voting 合约(Solidity 0.8.19)
创建contracts/Voting.sol,重点看三个设计选择:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; contract Voting { struct Proposal { uint id; string name; uint voteCount; bool executed; } address public owner; Proposal[] public proposals; mapping(address => bool) public voters; // 防重复投票 uint public startTime; uint public endTime; bool public votingOpen; modifier onlyOwner() { require(msg.sender == owner, "Only owner can call this function"); _; } constructor(uint _startTime, uint _endTime) { owner = msg.sender; startTime = _startTime; endTime = _endTime; votingOpen = false; } function addProposal(string memory _name) public onlyOwner { require(!votingOpen, "Cannot add proposal after voting starts"); proposals.push(Proposal(proposals.length, _name, 0, false)); } function startVoting() public onlyOwner { require(block.timestamp >= startTime, "Voting cannot start before startTime"); require(!votingOpen, "Voting already started"); votingOpen = true; } function vote(uint _proposalId) public { require(votingOpen, "Voting is not open"); require(block.timestamp <= endTime, "Voting has ended"); require(_proposalId < proposals.length, "Invalid proposal ID"); require(!voters[msg.sender], "Already voted"); voters[msg.sender] = true; proposals[_proposalId].voteCount++; } function getVotes(uint _proposalId) public view returns (uint) { require(_proposalId < proposals.length, "Invalid proposal ID"); return proposals[_proposalId].voteCount; } }- 为什么用
mapping(address => bool)而非address[]记录投票人?address[]存储需 O(n) 遍历查重,Gas 成本随投票人数线性增长;mapping是 O(1) 查找,且voters[msg.sender]在未赋值时默认为false,无需初始化。 - 为什么
startTime/endTime在构造函数传入而非block.timestamp?
避免矿工操纵时间戳(虽然概率低),且便于测试:你在 Ganache 中可手动快进区块时间验证开闭逻辑。 getVotes加view修饰符的意义?
前端调用此函数不发交易、不消耗 gas,是获取链上只读数据的标准方式;若漏写,Truffle 测试会报call revert exception。
2.3 编写 Truffle 迁移脚本:控制部署顺序与参数
创建migrations/2_deploy_voting.js:
const Voting = artifacts.require("Voting"); module.exports = async function (deployer, network, accounts) { // Sepolia 网络使用固定时间戳:2024-06-01 00:00:00 UTC → 1717200000 const startTime = network === "sepolia" ? 1717200000 : Math.floor(Date.now() / 1000) + 300; // 本地测试延后5分钟 const endTime = startTime + 86400; // 投票持续24小时 await deployer.deploy(Voting, startTime, endTime); };accounts[0]自动成为owner,无需在合约中硬编码;Math.floor(Date.now() / 1000) + 300确保本地 Ganache 部署时投票窗口未来开启,避免startVoting()因时间未到而 revert;- 所有迁移脚本必须以数字前缀命名(如
2_deploy_voting.js),Truffle 按数字顺序执行。
2.4 在 Ganache 本地链上完整测试流程
启动本地链:
npx ganache-cli -p 8545 -d --gasPrice 2000000000部署并验证:
truffle migrate --network development truffle console --network development在 console 中执行:
// 获取已部署合约实例 truffle(development)> let instance = await Voting.deployed() // 添加两个提案 truffle(development)> await instance.addProposal("Option A") truffle(development)> await instance.addProposal("Option B") // 开启投票(此时 block.timestamp 已 > startTime) truffle(development)> await instance.startVoting() // 投票(使用 accounts[1] 投给提案0) truffle(development)> await instance.vote(0, {from: accounts[1]}) // 查询结果 truffle(development)> (await instance.getVotes(0)).toString() '1'ganache-cli -d启用 deterministic 模式,每次启动区块高度、时间戳、账户余额完全一致,这是可复现测试的前提;--gasPrice 2000000000设为 2 Gwei,避免 Ganache 默认 gasPrice(1)导致交易被拒绝;instance.vote(0, {from: accounts[1]})中{from: accounts[1]}显式指定调用地址,否则默认用accounts[0](即 owner),无法测试普通用户投票逻辑。
3. 用 React 构建可信前端:从 MetaMask 连接、状态同步到交易确认
React 不是渲染层,是链上状态的镜像。这里不用 Next.js 或 Vite,坚持 Create React App(CRA)+web3.js原生集成,因为 CRA 的react-scripts对 ESM 模块兼容性最好,避免window is not defined等 SSR 相关玄学错误。所有状态管理用useState+useEffect,不引入 Redux —— DApp 前端的核心复杂度不在状态树,而在「链上数据何时更新、如何防抖、失败时如何回滚 UI」。
3.1 初始化 React 项目并安装 Web3 依赖
npx create-react-app client --template typescript cd client npm install web3@1.8.2 @types/web3 npm install --save-dev @types/react @types/react-dom提示:
web3@1.8.2必须与 Truffle v5.9.x 保持一致,高版本web3@1.10.x会因 ABI 编码差异导致call revert exception;@types/web3提供 TypeScript 类型定义,避免any泛滥。
3.2 实现 MetaMask 连接与账户监听
创建src/utils/web3.ts:
import Web3 from 'web3'; let web3: Web3 | null = null; let accounts: string[] = []; export const initWeb3 = async (): Promise<Web3> => { if (window.ethereum) { web3 = new Web3(window.ethereum); try { // 请求用户授权连接 accounts = await window.ethereum.request({ method: 'eth_requestAccounts' }); // 监听账户切换 window.ethereum.on('accountsChanged', (newAccounts: string[]) => { accounts = newAccounts; window.location.reload(); // 简单粗暴,生产环境应局部更新状态 }); // 监听网络切换 window.ethereum.on('chainChanged', () => { window.location.reload(); }); } catch (error) { console.error("User denied account access", error); throw error; } } else if (window.web3) { web3 = new Web3((window as any).web3.currentProvider); } else { throw new Error('No Ethereum browser detected. Install MetaMask.'); } return web3; }; export const getAccounts = () => accounts; export const getWeb3 = () => web3;window.ethereum.request({ method: 'eth_requestAccounts' })是 EIP-1193 标准,必须显式调用才能获取用户账户,仅eth_accountsRPC 不再返回地址;accountsChanged事件监听必须在requestAccounts后立即注册,否则用户切换 MetaMask 账户时前端无感知;chainChanged事件用于检测用户切换网络(如从 Sepolia 切到 Mainnet),reload 是最稳妥的兜底方案。
3.3 创建 VotingContract Hook:封装 ABI 调用与事件监听
创建src/hooks/useVotingContract.ts:
import { useState, useEffect, useCallback } from 'react'; import Web3 from 'web3'; import { getWeb3, getAccounts } from '../utils/web3'; import votingAbi from '../abi/Voting.json'; // 由 Truffle 编译生成 interface Proposal { id: number; name: string; voteCount: string; } export const useVotingContract = (contractAddress: string) => { const [proposals, setProposals] = useState<Proposal[]>([]); const [votingOpen, setVotingOpen] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(true); const [error, setError] = useState<string | null>(null); const loadProposals = useCallback(async () => { try { const web3 = getWeb3(); if (!web3 || !contractAddress) return; const contract = new web3.eth.Contract(votingAbi, contractAddress); // 获取提案总数 const proposalCount = await contract.methods.proposals.length().call(); const proposalsData: Proposal[] = []; for (let i = 0; i < proposalCount; i++) { const proposal = await contract.methods.proposals(i).call(); proposalsData.push({ id: Number(proposal.id), name: proposal.name, voteCount: proposal.voteCount }); } setProposals(proposalsData); // 获取投票开关状态 const isOpen = await contract.methods.votingOpen().call(); setVotingOpen(isOpen === 'true' || isOpen === true); } catch (err) { console.error("Failed to load proposals", err); setError((err as Error).message); } finally { setLoading(false); } }, [contractAddress]); // 监听 Vote 事件(需先在合约中 emit) useEffect(() => { const web3 = getWeb3(); if (!web3 || !contractAddress) return; const contract = new web3.eth.Contract(votingAbi, contractAddress); // 监听新投票事件(需在 Solidity 中添加 event Vote(address indexed voter, uint proposalId)) const voteEvent = contract.events.Vote({ fromBlock: 'latest' }, (error, event) => { if (error) { console.error("Vote event error", error); return; } // 重新加载提案数据,确保计票实时 loadProposals(); }); return () => { voteEvent.unsubscribe(); }; }, [contractAddress, loadProposals]); // 投票函数 const vote = async (proposalId: number): Promise<void> => { const web3 = getWeb3(); const accounts = getAccounts(); if (!web3 || !accounts.length || !contractAddress) throw new Error('Web3 not initialized'); try { const contract = new web3.eth.Contract(votingAbi, contractAddress); const tx = await contract.methods.vote(proposalId).send({ from: accounts[0], gas: 200000 // 预估 gas,避免自动估算失败 }); console.log("Vote transaction mined", tx.transactionHash); } catch (err) { console.error("Vote failed", err); throw err; } }; return { proposals, votingOpen, loading, error, loadProposals, vote }; };votingAbi来自build/contracts/Voting.json,必须在truffle compile后复制到client/src/abi/,不能直接 import../build/contracts/Voting.json(CRA 不解析 .json 在 src 外);fromBlock: 'latest'表示只监听新产生的事件,避免启动时扫描全链历史(耗时且无必要);gas: 200000显式设置,防止web3.eth.estimateGas在某些节点返回null导致交易卡死;voteEvent.unsubscribe()在组件卸载时调用,防止内存泄漏。
3.4 主页面集成:状态驱动 UI 与交易反馈
修改src/App.tsx:
import React, { useEffect, useState } from 'react'; import { initWeb3 } from './utils/web3'; import { useVotingContract } from './hooks/useVotingContract'; import './App.css'; function App() { const [contractAddress, setContractAddress] = useState<string>(''); const [isConnected, setIsConnected] = useState<boolean>(false); const [network, setNetwork] = useState<string>(''); useEffect(() => { const connect = async () => { try { await initWeb3(); setIsConnected(true); const web3 = window.web3 || (window as any).ethereum; const netId = await web3.eth.net.getId(); setNetwork(netId === 11155111 ? 'Sepolia' : `Unknown (${netId})`); } catch (err) { console.error("Init failed", err); } }; connect(); }, []); // 从 Truffle migration 输出中复制合约地址 useEffect(() => { if (isConnected) { // 示例地址,实际需替换为你的部署地址 setContractAddress('0x5FbDB2315678afecb367f032d93F642f64180aa3'); } }, [isConnected]); const { proposals, votingOpen, loading, error, loadProposals, vote } = useVotingContract(contractAddress); const handleVote = async (id: number) => { try { await vote(id); alert('Vote submitted! Check MetaMask for confirmation.'); } catch (err) { alert(`Vote failed: ${(err as Error).message}`); } }; return ( <div className="App"> <header className="App-header"> <h1>Voting DApp</h1> <p>Network: {network} | Connected: {isConnected ? '✅' : '❌'}</p> {contractAddress && <p>Contract: {contractAddress.substring(0, 6)}...{contractAddress.substring(36)}</p>} </header> <main> {error && <div className="error">Error: {error}</div>} {loading ? ( <p>Loading proposals...</p> ) : ( <div> <h2>Proposals</h2> {proposals.map((p) => ( <div key={p.id} className="proposal"> <h3>{p.name}</h3> <p>Votes: {p.voteCount}</p> {votingOpen ? ( <button onClick={() => handleVote(p.id)} disabled={!isConnected}> Vote for {p.name} </button> ) : ( <p>Voting closed</p> )} </div> ))} </div> )} </main> </div> ); } export default App;contractAddress必须手动填入,来源是truffle migrate --network sepolia输出中的Voting address;handleVote中alert是调试用,生产环境应替换为 Toast 组件,并监听transactionHash显示「交易已提交」,再监听receipt显示「交易已确认」;disabled={!isConnected}防止用户点击未连接时的按钮,避免web3.eth.Contract实例为空报错。
4. 避坑:Truffle + React + Solidity 三端协同的 5 个血泪经验
DApp 开发最耗时的不是写代码,而是排查「为什么明明合约写了 emit,前端却收不到事件」「为什么本地 Ganache 能跑通,一上 Sepolia 就 revert」。以下是我在 12 个真实项目中踩出的硬核坑,按发生频率排序:
4.1 现象:truffle migrate报错Error: Error: Returned error: VM Exception while processing transaction: revert
原因:Solidity 合约中require或revert触发,但 Truffle 默认不显示具体 revert 原因。常见于:
addProposal时votingOpen == true(因startTime设置过小,Ganache 时间未推进);vote时_proposalId >= proposals.length(前端传入 ID 超出数组长度);web3.eth.Contract构造时 ABI 与部署合约不匹配(如 Truffle 未重新编译就改了合约)。
解决:
- 在
truffle-config.js中添加logger: console,启用详细日志; - 用
truffle debug <tx-hash>进入调试模式,逐行查看 revert 位置; - 在 Solidity 中用
require(condition, "Custom error message"),Truffle v5.9+ 会打印该字符串。
4.2 现象:React 前端调用contract.methods.vote().send()无反应,控制台无报错
原因:MetaMask 已连接,但web3.eth.Contract实例使用了错误的 ABI 或地址。
ABI错误:build/contracts/Voting.json未更新(改合约后忘记truffle compile);address错误:truffle migrate部署到 Sepolia,前端却用 Ganache 地址;network错误:MetaMask 当前网络是 Mainnet,但合约只部署在 Sepolia。
解决:
- 在
useVotingContract中加日志:console.log('Using ABI:', votingAbi.length, 'address:', contractAddress); - 用
web3.eth.getCode(contractAddress)检查地址是否有合约代码(返回0x表示无合约); - 在 MetaMask 中手动切换到 Sepolia 网络,并确认地址栏显示
Sepolia Test Network。
4.3 现象:useEffect中监听Vote事件,但新投票后 UI 不更新
原因:事件监听器注册时contractAddress为空或变化,导致contract.events.Vote绑定到错误实例。
contractAddress是useState状态,初始为空字符串,useEffect在contractAddress为空时执行,new web3.eth.Contract(..., '')报错但被静默吞掉;contractAddress后续更新,但useEffect未将其加入依赖数组,监听器未重建。
解决:
- 将
contractAddress加入useEffect依赖数组:useEffect(() => { ... }, [contractAddress, loadProposals]); - 在
useEffect开头加守卫:if (!contractAddress || contractAddress.length < 42) return;; - 使用
web3.eth.subscribe('logs', {...})替代contract.events(更底层,可控性更强)。
4.4 现象:Ganache 启动后truffle test报错Error: PollingBlockTracker - encountered an error while attempting to update latest block
原因:Ganache 版本与 Truffle 不兼容。ganache-cli@6.12.2与truffle@5.9.x匹配,但ganache@7.x(GUI 版)使用 WebSocket 协议,Truffle v5 不支持。
解决:
- 卸载全局 ganache:
npm uninstall -g ganache ganache-cli; - 安装指定版本:
npm install -g ganache-cli@6.12.2; - 启动时加
--miner.blockTime 1参数,让区块每秒生成一次,避免测试等待超时。
4.5 现象:Sepolia 部署成功,但前端调用getVotes()返回0,而truffle console查询正常
原因:前端web3.eth.Contract使用了build/contracts/Voting.json中的abi,但该 ABI 缺少getVotes函数的constant: true字段(旧版 Truffle 编译器 bug)。
解决:
- 手动编辑
build/contracts/Voting.json,找到getVotes对应的 ABI 条目,添加"stateMutability": "view"; - 或升级 Truffle 至
v5.9.3+,该版本修复了 view 函数 ABI 生成问题; - 验证方法:在
truffle console中执行instance.getVotes(0).call()(加.call()表示只读调用),若返回正常则 ABI 正确。
5. 验证合约安全性与前端健壮性的 3 个硬核技巧
写完代码只是开始,DApp 的价值在于上线后不崩、不丢钱、不被攻击。以下是我在线上项目中反复使用的验证手段,不依赖第三方审计工具,全部本地可执行。
5.1 用 Truffle Debugger 深度追踪 revert 路径
当truffle test报revert却不知原因时,Debugger 是唯一真相。以测试vote函数为例:
- 编写测试文件
test/voting.test.js:
const Voting = artifacts.require("Voting"); contract("Voting", (accounts) => { let votingInstance; beforeEach(async () => { votingInstance = await Voting.new(Math.floor(Date.now()/1000)+300, Math.floor(Date.now()/1000)+3600); await votingInstance.addProposal("Test A"); await votingInstance.startVoting(); }); it("should allow voting and increment count", async () => { await votingInstance.vote(0, { from: accounts[1] }); const votes = await votingInstance.getVotes(0); assert.equal(votes.toString(), "1"); }); });- 运行测试并捕获失败交易哈希:
truffle test --network development # 输出类似:Error: Returned error: VM Exception while processing transaction: revert # 记下最后一行的 tx hash,如 0xabc123...- 启动 Debugger 并单步执行:
truffle debug 0xabc123 --network development进入调试器后输入:
o # step over 下一行 v # print current variable values p votingOpen # 查看 votingOpen 变量值你会看到 revert 发生在require(votingOpen, ...)这一行,votingOpen值为false—— 说明startVoting()未成功执行。接着用u(step up)回到上层调用,发现startTime设置过小,Ganache 时间未到达。这就是比console.log更精准的定位方式。
5.2 用 Hardhat 验证前端 ABI 兼容性(零成本迁移)
Truffle 的 ABI 有时存在字段缺失(如stateMutability),而 Hardhat 的hardhat-abi-exporter插件生成的 ABI 更规范。无需迁移整个项目,只需用 Hardhat 验证:
- 在项目根目录新建
hardhat.config.js:
require("@nomicfoundation/hardhat-toolbox"); require("hardhat-abi-exporter"); module.exports = { solidity: "0.8.19", abiExporter: { path: "./abi-for-frontend", clear: true, flat: true, only: ["Voting"], spacing: 2 } };- 安装依赖并导出 ABI:
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox hardhat-abi-exporter npx hardhat compile- 比较
abi-for-frontend/Voting.json与build/contracts/Voting.json,重点关注:
stateMutability字段是否存在(view/pure/nonpayable);inputs和outputs的type是否为uint256而非uint(Solidity 0.8+ 要求全称);anonymous字段是否为false(事件 ABI 必须明确)。
若 Hardhat ABI 正确而 Truffle ABI 错误,说明 Truffle 版本过低,应升级。
5.3 前端交易状态机:从 pending 到 confirmed 的完整反馈链
用户点击「Vote」后,UI 必须清晰传达三个状态:pending(交易已发送,等待打包)、mined(区块确认)、confirmed(最终确定)。这是信任建立的关键。在useVotingContract.ts中增强vote函数:
const vote = async (proposalId: number): Promise<void> => { const web3 = getWeb3(); const accounts = getAccounts(); if (!web3 || !accounts.length || !contractAddress) throw new Error('Web3 not initialized'); try { const contract = new web3.eth.Contract(votingAbi, contractAddress); // 1. 发送交易,获取 transactionHash const sendResult = await contract.methods.vote(proposalId).send({ from: accounts[0], gas: 200000 }); // 2. 监听 transactionHash,显示 pending console.log("Transaction sent:", sendResult.transactionHash); // 此处触发 UI 显示 "Transaction pending..." // 3. 等待区块确认(默认 12 个区块) const receipt = await web3.eth.getTransactionReceipt(sendResult.transactionHash); console.log("Transaction confirmed in block:", receipt.blockNumber); // 此处触发 UI 显示 "Vote confirmed!" } catch (err) { console.error("Vote failed", err); throw err; } };getTransactionReceipt会轮询直到交易被打包,超时抛出异常;- 生产环境应加
setTimeout限制等待时间(如 5 分钟),避免用户无限等待; receipt.status === true表示交易成功,false表示 revert,需向用户展示具体错误(如revert "Already voted")。
我习惯在每个 DApp 项目中,把web3.eth.sendTransaction的所有可能返回路径都写进单元测试,用 Jest 模拟send、getTransactionReceipt的不同返回值,确保 UI 在pending/failed/confirmed三种状态下行为正确。这比写一百行业务逻辑更能守住底线。
希望帮到你。
本文还有配套的精品资源,点击获取