
简介本资源是一份面向区块链开发初学者与高校课程实践者的智能合约投票系统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 到 migrateTruffle 不是脚手架是合约生命周期的控制台。它强制你面对以太坊开发中最容易被忽略的三件事合约 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-provider1.0.17 web31.8.2注意truffle-hdwallet-provider是 Truffle v5 时代连接钱包的标准包不要用truffle/hdwallet-providerv5 不兼容。web31.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 工具离线生成绝不可用测试网公开私钥gasSepolia 链上合约部署需至少 5M gas低于此值会out of gasskipDryRun: 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.jsconst 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.jsTruffle 按数字顺序执行。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() 1ganache-cli -d启用 deterministic 模式每次启动区块高度、时间戳、账户余额完全一致这是可复现测试的前提--gasPrice 2000000000设为 2 Gwei避免 Ganache 默认 gasPrice1导致交易被拒绝instance.vote(0, {from: accounts[1]})中{from: accounts[1]}显式指定调用地址否则默认用accounts[0]即 owner无法测试普通用户投票逻辑。3. 用 React 构建可信前端从 MetaMask 连接、状态同步到交易确认React 不是渲染层是链上状态的镜像。这里不用 Next.js 或 Vite坚持 Create React AppCRAweb3.js原生集成因为 CRA 的react-scripts对 ESM 模块兼容性最好避免window is not defined等 SSR 相关玄学错误。所有状态管理用useStateuseEffect不引入 Redux —— DApp 前端的核心复杂度不在状态树而在「链上数据何时更新、如何防抖、失败时如何回滚 UI」。3.1 初始化 React 项目并安装 Web3 依赖npx create-react-app client --template typescript cd client npm install web31.8.2 types/web3 npm install --save-dev types/react types/react-dom提示web31.8.2必须与 Truffle v5.9.x 保持一致高版本web31.10.x会因 ABI 编码差异导致call revert exceptiontypes/web3提供 TypeScript 类型定义避免any泛滥。3.2 实现 MetaMask 连接与账户监听创建src/utils/web3.tsimport Web3 from web3; let web3: Web3 | null null; let accounts: string[] []; export const initWeb3 async (): PromiseWeb3 { 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 切到 Mainnetreload 是最稳妥的兜底方案。3.3 创建 VotingContract Hook封装 ABI 调用与事件监听创建src/hooks/useVotingContract.tsimport { 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] useStateProposal[]([]); const [votingOpen, setVotingOpen] useStateboolean(false); const [loading, setLoading] useStateboolean(true); const [error, setError] useStatestring | 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): Promisevoid { 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.jsonCRA 不解析 .json 在 src 外fromBlock: latest表示只监听新产生的事件避免启动时扫描全链历史耗时且无必要gas: 200000显式设置防止web3.eth.estimateGas在某些节点返回null导致交易卡死voteEvent.unsubscribe()在组件卸载时调用防止内存泄漏。3.4 主页面集成状态驱动 UI 与交易反馈修改src/App.tsximport React, { useEffect, useState } from react; import { initWeb3 } from ./utils/web3; import { useVotingContract } from ./hooks/useVotingContract; import ./App.css; function App() { const [contractAddress, setContractAddress] useStatestring(); const [isConnected, setIsConnected] useStateboolean(false); const [network, setNetwork] useStatestring(); 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 classNameApp header classNameApp-header h1Voting DApp/h1 pNetwork: {network} | Connected: {isConnected ? ✅ : ❌}/p {contractAddress pContract: {contractAddress.substring(0, 6)}...{contractAddress.substring(36)}/p} /header main {error div classNameerrorError: {error}/div} {loading ? ( pLoading proposals.../p ) : ( div h2Proposals/h2 {proposals.map((p) ( div key{p.id} classNameproposal h3{p.name}/h3 pVotes: {p.voteCount}/p {votingOpen ? ( button onClick{() handleVote(p.id)} disabled{!isConnected} Vote for {p.name} /button ) : ( pVoting closed/p )} /div ))} /div )} /main /div ); } export default App;contractAddress必须手动填入来源是truffle migrate --network sepolia输出中的Voting addresshandleVote中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 compileaddress错误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-cli6.12.2与truffle5.9.x匹配但ganache7.xGUI 版使用 WebSocket 协议Truffle v5 不支持。解决卸载全局 ganachenpm uninstall -g ganache ganache-cli安装指定版本npm install -g ganache-cli6.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.jsconst 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()未成功执行。接着用ustep up回到上层调用发现startTime设置过小Ganache 时间未到达。这就是比console.log更精准的定位方式。5.2 用 Hardhat 验证前端 ABI 兼容性零成本迁移Truffle 的 ABI 有时存在字段缺失如stateMutability而 Hardhat 的hardhat-abi-exporter插件生成的 ABI 更规范。无需迁移整个项目只需用 Hardhat 验证在项目根目录新建hardhat.config.jsrequire(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 } };安装依赖并导出 ABInpm 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/nonpayableinputs和outputs的type是否为uint256而非uintSolidity 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): Promisevoid { 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三种状态下行为正确。这比写一百行业务逻辑更能守住底线。希望帮到你。本文还有配套的精品资源点击获取