资讯详情

资讯详情

智能合约投票Dapp开发全流程:Solidity+Truffle+React实战

简介智能合约投票系统Dapp是一份面向区块链初学者与以太坊开发者的编程作业资源基于Truffle、React与Solidity构建完整的去中心化投票应用适合用于课程设计或学习Dapp开发流程。项目中Solidity负责编写投票逻辑与权限控制Truffle提供编译、部署及自动化测试环境React搭建用户界面并借助Web3.js完成前端与链上合约的数据交互覆盖了从后端合约到前端展示的主要环节。压缩包为zip格式大小约6.59MB文件总数未在下载页标注内含Solidity合约源码、React组件、Truffle配置与迁移脚本、测试用例等目录结构清晰便于按模块研读。目前已有1544人学习下载。通过研读这份代码可以掌握以太坊合约开发的基本范式理解Dapp工程组织方式并可直接作为作业或项目的参考框架节省从零搭建环境的时间。1. 把“智能合约投票系统 Dapp”作业拆成一条可复现的工程链路作业标题里写着 truffle react solidity 三个词意味着你要把「智能合约投票系统 Dapp」这条路完整走一遍用 Solidity 写投票合约用 Truffle 编译、部署、测试再用 React 搭界面最后让浏览器里的钱包把交易发到本地链上。这个组合是区块链课程作业和入门项目最常用的一套原因很实际——三层职责分得清楚合约层管规则工具链管构建部署前端层管交互任何一层出问题都能单独排查。这篇笔记就是按这个顺序把每一步拆开数据结构怎么定迁移脚本怎么写前端怎么接钱包以及几个能让你卡一整晚的坑。适合两类人正在为这个作业发愁的学生和想把 Dapp 开发流程快速跑通一遍的开发者。2. 写投票合约数据结构、权限控制与防重复投票2.1 候选人与选民的链上表示struct 与 mapping 的选型投票合约要回答三个问题投给谁、谁在投、每人能投几次。对应到 Solidity 里就是候选人的名单、选民的登记表以及投票状态的记录。候选人天然是一个有序列表前端要按顺序展示也可能需要遍历求出胜者所以用动态数组Candidate[]合适选民则相反投票时只需要根据地址快速判断“这个人有没有资格投、投过没有”用mapping(address Voter)最直接。mapping 的查询复杂度是 O(1)而且地址天然唯一不需要额外做去重逻辑。// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract Voting { struct Candidate { uint256 id; string name; uint256 voteCount; } struct Voter { bool registered; bool voted; uint256 votedCandidateId; } address public owner; mapping(address Voter) private voters; Candidate[] private candidates; uint256 public votingEnd; event Voted(address indexed voter, uint256 indexed candidateId, uint256 voteCount); constructor(string[] memory candidateNames, uint256 durationSeconds) { owner msg.sender; votingEnd block.timestamp durationSeconds; for (uint256 i 0; i candidateNames.length; i) { candidates.push(Candidate(i, candidateNames[i], 0)); } } }这里用private修饰voters和candidates不是不让外部读而是不暴露 Solidity 自动生成的 getter——后面我会补公开的视图函数前端通过视图函数读取数据可控性更好。构造函数接收两个参数候选人名字数组和投票持续秒数。votingEnd存的是区块时间戳加秒数也就是投票截止的绝对时间。把候选人写死在构造函数里是作业里最常见的做法部署时一次性传进去省掉单独维护候选人管理合约的复杂度。2.2 投票函数的权限控制为什么重复投票要用状态来挡投票函数的核心不是“加一票”这个动作而是先验证这一票该不该被记上。Solidity 里没有现成的权限框架所有规则都靠require和状态字段组合出来。registered用来挡未登记地址voted用来挡重复投票candidateId candidates.length用来挡越界候选人编号block.timestamp votingEnd用来挡过期投票。这四条校验缺一条投票系统就是有洞的。modifier onlyOwner() { require(msg.sender owner, only owner); _; } function registerVoter(address voter) external onlyOwner { require(!voters[voter].registered, already registered); voters[voter].registered true; } function vote(uint256 candidateId) external { Voter storage voter voters[msg.sender]; require(voter.registered, not registered); require(!voter.voted, already voted); require(candidateId candidates.length, invalid candidate); require(block.timestamp votingEnd, voting ended); voter.voted true; voter.votedCandidateId candidateId; candidates[candidateId].voteCount 1; emit Voted(msg.sender, candidateId, candidates[candidateId].voteCount); } function getVoterInfo(address voter) external view returns (bool registered, bool voted) { return (voters[voter].registered, voters[voter].voted); } function getCandidate(uint256 id) external view returns (uint256, string memory, uint256) { Candidate storage c candidates[id]; return (c.id, c.name, c.voteCount); } function getCandidateCount() external view returns (uint256) { return candidates.length; } function getWinner() external view returns (uint256, string memory, uint256) { uint256 winnerId 0; for (uint256 i 1; i candidates.length; i) { if (candidates[i].voteCount candidates[winnerId].voteCount) { winnerId i; } } return (candidates[winnerId].id, candidates[winnerId].name, candidates[winnerId].voteCount); }Voter storage voter voters[msg.sender]这一行是把 storage 引用拿过来后面所有读写都走同一份链上存储不改别的地址。有人会把storage误写成memory那样voter.voted true只改了内存副本链上状态没变投票记录会全部丢失——这是一类很隐蔽的翻车现场。onlyOwner修饰符用在registerVoter上只有构造时记录的owner能登记选民。注意这里用的是msg.sender绝不能用tx.origin后者会把外部调用者当成操作者合约被中间合约调用时权限会直接被绕过。事件Voted里我额外带上了voteCount也就是“这一票之后该候选人的累计票数”。这个设计是给前端用的订阅事件后前端不用再调一次getCandidate去查票数直接从事件返回值里更新界面。投票时四个require的顺序也有讲究先查登记状态再查重复投票越界和过期放后面这样被挡住的交易能给出更明确的报错文本测试和前端排查起来不用猜。3. 用 Truffle 编译部署到本地链配置文件与迁移脚本3.1 初始化项目与 Truffle 网络配置合约写完后下一步是让 Truffle 接管项目的编译、部署和测试。常见做法是用truffle init生成空白模板也可以直接用truffle unbox react初始化一个带 React 前端的模板作业场景下我更推荐后者因为省去手动接 webpack 和合约 JSON 路径的时间。初始化完成后的目录里contracts/放 Solidity 源码migrations/放部署脚本test/放自动化测试truffle-config.js是核心配置文件。module.exports { networks: { development: { host: 127.0.0.1, port: 7545, network_id: *, }, }, compilers: { solc: { version: 0.8.20, }, }, };host和port指向本地链的 RPC 地址。这里有个非常常见的分叉点桌面版本地链默认端口是 7545命令行版默认是 8545。作业里如果开着图形界面配置就写 7545如果用的是命令行的开发链就要改成 8545不然truffle migrate会一直报连接超时。network_id: *表示接受任意网络 ID这个配置只适合本地开发不要带到测试网或正式网。solc.version建议写死一个 0.8.x 版本避免 Truffle 自动抓取最新编译器导致编译行为漂移。3.2 迁移脚本与部署参数Truffle 通过迁移脚本把合约部署到链上脚本文件名带数字前缀Truffle 按顺序执行并且会记录已跑过的迁移。第一个迁移脚本通常由模板自动生成部署你自己的合约从第二个编号开始。const Voting artifacts.require(Voting); module.exports async function (deployer) { const candidateNames [Alice, Bob, Carol]; const durationSeconds 30 * 60; await deployer.deploy(Voting, candidateNames, durationSeconds); };artifacts.require(Voting)告诉 Truffle 去build/contracts/Voting.json里找编译产物。deployer.deploy的第一个参数是合约对象后面的参数会原样传给构造函数——候选人和投票时长就是在这一步确定的。部署完成后终端会打印合约地址这个地址后面要填给前端别只看一眼就关掉窗口。每次改了合约代码都要先truffle compile再truffle migrate --network developmentbuild/contracts/下的 JSON 文件才会更新如果只改了 Solidity 不重新编译前端拿到的还是旧 ABI调用新函数会直接报错。3.3 用 Truffle 自动测试投票流程作业里最容易被扣分的点是合约能部署但逻辑经不起边界测试。Truffle 的测试跑在本地链上推荐用Voting.new(...)在每个用例前部署一个全新合约而不是用Voting.deployed()。区别在于deployed()依赖上次 migrate 的记录多个用例共享同一份状态后一个用例会被前一个的数据污染new()每次都在测试环境里重新部署用例之间互不干扰。const Voting artifacts.require(Voting); contract(Voting, (accounts) { let voting; beforeEach(async () { voting await Voting.new([Alice, Bob, Carol], 3600); }); it(拒绝未注册地址投票, async () { try { await voting.vote(0, { from: accounts[4] }); assert.fail(应该抛出异常); } catch (error) { assert(error.message.includes(not registered), error.message); } }); it(允许注册用户投票且只能投一次, async () { await voting.registerVoter(accounts[1], { from: accounts[0] }); await voting.vote(0, { from: accounts[1] }); const info await voting.getVoterInfo(accounts[1]); assert.equal(info.registered, true); assert.equal(info.voted, true); try { await voting.vote(1, { from: accounts[1] }); assert.fail(应该抛出异常); } catch (error) { assert(error.message.includes(already voted), error.message); } }); it(统计获胜者, async () { await voting.registerVoter(accounts[1], { from: accounts[0] }); await voting.registerVoter(accounts[2], { from: accounts[0] }); await voting.registerVoter(accounts[3], { from: accounts[0] }); await voting.vote(0, { from: accounts[1] }); await voting.vote(0, { from: accounts[2] }); await voting.vote(1, { from: accounts[3] }); const winner await voting.getWinner(); assert.equal(winner[0], 0); assert.equal(winner[1], Alice); }); });注意getWinner()返回的是(uint256, string memory, uint256)元组在 JS 里拿到的是数组结构winner[0]是候选人 idwinner[1]是名字。断言winner[0]时我转成了字符串再比因为 Truffle 返回的大数字在部分断言库里有精度风险用字符串最稳。测试里故意让第三个候选人 Bob 只拿到一票Alice 拿两票验证遍历逻辑会选出票数最多的候选人。作业验收时把这三个用例补成五个未注册投票、重复投票、越界候选人、过期投票、并列票数基本能把大部分实现问题兜住。4. React 前端对接合约读候选人、发起投票、监听事件4.1 初始化前端并注入 Web3 实例前端这一层要解决的是“浏览器怎么跟链上打招呼”。现代 Dapp 的标准路径是用户安装浏览器钱包插件插件往页面注入window.ethereum对象前端拿这个对象创建 Web3 实例再拿实例去实例化合约。整个过程的核心就三步请求账户授权、创建合约实例、把账户列表存进 React 状态。import { useEffect, useState } from react; import Web3 from web3; import VotingArtifact from ./contracts/Voting.json; const CONTRACT_ADDRESS process.env.REACT_APP_CONTRACT_ADDRESS; export default function App() { const [web3, setWeb3] useState(null); const [contract, setContract] useState(null); const [accounts, setAccounts] useState([]); const connectWallet async () { if (!window.ethereum) { alert(请先安装浏览器钱包插件); return; } const accs await window.ethereum.request({ method: eth_requestAccounts, }); const web3 new Web3(window.ethereum); const contract new web3.eth.Contract( VotingArtifact.abi, CONTRACT_ADDRESS ); setWeb3(web3); setContract(contract); setAccounts(accs); }; }VotingArtifact.abi来自 Truffle 编译产物build/contracts/Voting.json直接用import引进来webpack 会把 JSON 打包进前端。合约地址我放在process.env.REACT_APP_CONTRACT_ADDRESS里用create-react-app时只有以REACT_APP_开头的环境变量会注入到前端构建产物写成别的名字会在构建后变成undefined——这是很多作业卡在前端加载不出候选人的主要原因。eth_requestAccounts这步必须显式调用它负责拉起钱包的授权弹窗。作业里经常出现的坑是页面加载完直接调web3.eth.getAccounts()但用户还没授权返回空数组点击投票时from传了undefined交易直接报错。所以我把授权动作放在“连接钱包”按钮的回调里用户主动点击后才请求权限逻辑更符合浏览器的安全模型。4.2 加载候选人列表与发起投票call 与 send 的区别候选人列表的读取要用call()发起投票要用send()。这两个方法的区别是整个前端最容易绕晕的点call()只是模拟执行合约的 view 函数不产生交易、不消耗 Gas结果直接从本地节点返回send()才会把交易广播出去需要从钱包确认、需要 Gas返回的是交易收据。凡是合约里没写view/pure的函数前端必须用send()否则会报错“contains unresolved method”或者“invalid opcode”。const [candidates, setCandidates] useState([]); const loadCandidates async () { const count await contract.methods.getCandidateCount().call(); const list []; for (let i 0; i count; i) { const c await contract.methods.getCandidate(i).call(); list.push({ id: Number(c[0]), name: c[1], voteCount: Number(c[2]), }); } setCandidates(list); }; const handleVote async (candidateId) { if (!accounts.length) { alert(请先连接钱包); return; } await contract.methods.vote(candidateId).send({ from: accounts[0], gas: 150000, }); await loadCandidates(); };loadCandidates里用Number(c[0])把合约返回的大数字转成 JS 数字方便渲染。候选人的voteCount也用Number转一次因为 Solidity 的uint256通过 web3 返回时是字符串或 BigNumber 对象直接丢给 React 渲染会显示成异常格式。handleVote里给send()显式传了gas: 150000这是因为本地开发链对 Gas 上限的估算偶尔会失败写死一个够用的值能减少报错。作业场景合约很小15 万 Gas 足够如果以后合约函数多了再用estimateGas()动态估算更合理。4.3 订阅链上事件实时刷新票数投票之后前端要看到票数变化最简单的方案是像上面那样在handleVote里重新loadCandidates()。但这是个一次性刷新别人投的票你的界面不会动。要做得像真正的 Dapp应该订阅合约事件链上每次emit Voted都把新票数推到前端。const subscribeToVoted (contract) { contract.events.Voted({ fromBlock: latest }) .on(data, (event) { const { voter, candidateId, voteCount } event.returnValues; setCandidates((prev) prev.map((c) c.id Number(candidateId) ? { ...c, voteCount: Number(voteCount) } : c ) ); }) .on(error, (err) console.error(event error, err)); };event.returnValues里能拿到voter、candidateId、voteCount名字对应 Solidity 事件里定义的参数名。前端收到事件后按candidateId找到候选人用事件里带的voteCount直接覆盖旧票数。这样就不需要额外调getCandidate也不需要轮询本地链上的每一次投票都会实时反映到界面。注意fromBlock: latest表示只监听新的事件块页面刷新之前的历史投票不会补回来。所以组件挂载时正确顺序是先getPastEvents(Voted, { fromBlock: 0 })拉一次全量历史把票数恢复到当前链上状态然后再订阅增量事件。这个顺序我放在最后一章展开因为它是事件驱动模式里最容易遗漏的一环。5. 投票 Dapp 的常见问题排查从连不上链到事件不触发5.1 交易一直 pending 或超时端口与网络配置不一致现象truffle migrate卡住几分钟后报超时或者钱包里的交易一直处于 pending 状态链上查询永远没有这笔交易。原因本地链的 RPC 端口没对上。图形界面的本地链默认 7545命令行版本默认 8545钱包插件的自定义网络里填的端口号与 Truffle 配置不一致也会导致交易发不出去。还有一个隐蔽原因钱包插件开了“eth 节点直连”或者代理模式跳过本地 RPC。解决统一三个地方的端口——Truffle 的truffle-config.js、本地链的启动端口、钱包插件自定义网络里的 RPC URL。我一般固定用图形界面的本地链truffle-config.js写 7545钱包网络填http://127.0.0.1:7545网络 ID 填 1337。排查时用一句truffle networks看已部署的合约落在哪个端口再用浏览器访问 RPC 地址确认节点活着。5.2 前端调不到合约方法ABI 与合约地址对不上现象前端页面能加载但点击投票后控制台报Returned error: The method ... does not exist或者候选人列表一直是空的。原因合约改过代码并重新 migrate 后build/contracts/Voting.json里的 ABI 变了但前端还在用旧的编译产物或者.env里的合约地址还是旧合约的地址而旧合约早已不在新链上。作业里还有一种情况项目启动时没有重新执行truffle compile前端 import 的 JSON 是上次编译留下的。解决每次改完合约先truffle compile --all强制重新编译再truffle migrate --reset重新部署然后把终端打印的最新合约地址同步到REACT_APP_CONTRACT_ADDRESS。前端 dev server 也要重启因为 webpack 对 JSON 的缓存不总是即时失效。如果用了模板自带的前端脚手架确认它的构建配置是把contracts/目录放进编译搜索路径的否则 import 会指向不存在的文件。5.3 钱包不弹确认框或报用户拒绝账户与网络没对齐现象点击投票后没有任何弹窗或者提示User denied transaction signature偶尔还会报Invalid address。原因钱包里导入的账户不是本地链上的账户。本地链每次启动都会生成一组助记词和对应账户钱包插件里如果没导入这组账户eth_requestAccounts返回的地址在链上并不存在签名后节点找不到发送方。另一个原因是当前选中的网络不是本地链钱包试图把交易发到其他网络本地链自然收不到。解决把本地链启动时显示的助记词导入钱包插件而不是自己随便创建一个账户。确认钱包当前网络是自定义的本地 RPC且网络 ID 与本地链一致。前端代码里在handleVote前先检查accounts[0]是否存在不存在就重新走一遍eth_requestAccounts避免空地址直接发交易。5.4 事件订阅不触发fromBlock 与历史数据回补现象投票成功后界面票数没变化控制台没有任何事件输出但重新刷新页面后票数是对的。原因contract.events.Voted({ fromBlock: latest })只在页面打开之后开始监听页面打开之前的投票事件全部错过了。如果投票发生在订阅建立之前前端永远不会收到那笔事件数据。另外合约重新部署后事件日志在新地址上从头累计如果 Web3 实例还拿着旧地址订阅自然也收不到。解决组件挂载时先await contract.getPastEvents(Voted, { fromBlock: 0 })把全量历史事件拿回来按candidateId逐条回放票数回放完成后再建立events.Voted订阅处理增量。这样页面刷新、合约重部署、断线重连三种情况都能自愈。事件回放的顺序按区块高度升序执行票数是累加的不能用后一条覆盖前一条的方式简单赋值需要prev.voteCount 1地叠加上去。6. 把作业做成工程时间窗口、事件驱动刷新与结果验证投票系统如果只做到“能投、能数票”那只是个 Demo。要让验收的人觉得你真理解了 Dapp 的工程结构我会再加三件事。第一件是给投票加明确的时间窗口。合约里已经有votingEnd前端在加载页面时读出这个时间戳用setInterval每秒检查一次当前区块时间投票按钮在窗口外直接禁用并显示剩余时间。这样用户不会被“交易回滚”这种模糊的报错搞懵。前端判断时间不能只靠Date.now()最好从合约再读一次votingEnd或依赖最近一个块的timestamp因为本地链的时间可能和你电脑时钟不一致。第二件是把事件驱动刷新做成完整闭环。我习惯在subscribeToVoted之前先跑一段回放逻辑拿getPastEvents从fromBlock: 0读出所有Voted事件按序号累加到对应候选人票数上然后再订阅增量。这个模式解决的不只是刷新丢状态的问题——它在页面刷新、浏览器断网重连、钱包切换账户之后都能自动对账比每次手动调loadCandidates可靠得多。第三件是结果验证。getWinner()返回的是一个元组前端展示胜者时我会同时显示每个候选人的最终票数并且加一个“从事件回放中验证”的小逻辑把getPastEvents拿到的Voted事件按candidateId分组计数和合约getWinner()的结果对比不一致就高亮提示。这个验证逻辑放在测试里也可以写成前端辅助函数也够用。我最初做这类作业时只写了 happy path 的测试后来联调中一个未注册用户投票被require拦住、前端又只在控制台报错页面没有任何提示我花了一晚上才发现是 revert 信息没有被翻译成用户可读的文案。从此我养成一个习惯每个require的失败分支都在测试里显式断言一遍前端对每种失败类型给一条对应提示。做 Dapp链上的正确性和用户的可见反馈是同等重要的。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →