十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

区块链投票系统:角色权限、防重放与实时事件驱动实现

区块链投票系统:角色权限、防重放与实时事件驱动实现 简介这是一份面向计算机专业本科生的毕业设计与课程实践项目资源聚焦区块链底层逻辑与智能合约应用实现去中心化、可验证、防篡改的投票系统适用于Web3入门学习、课程设计及毕设参考。资源包共28个文件含6个JavaScript核心逻辑文件如useWeb3.js、index.js、5个Vue组件App.vue、BoardView.vue等构建前端交互、4个JSON配置与合约文件Vote.json为关键智能合约ABI、以及PDF项目文档、LESS样式文件、HTML入口页和PNG/ICO图标资源整体压缩包仅6.71MB轻量易部署。已有23人学习下载适合希望理解Web3前端集成、合约调用流程与VueWeb3.js工程化实践的学习者。读者可直接运行调试完整前后端代码查阅《智能合约投票系统项目.pdf》掌握设计思路与部署说明并通过备份文件.zbak对比版本演进快速掌握去中心化应用开发的关键环节。1. 这不是另一个“链上点赞”Demo一个能跑通账户授权、提案创建、实时计票与结果上链的完整投票合约系统很多初学者看到“区块链投票”第一反应是不就是前端调个vote()方法后端写个 Solidity 合约但真正落地时会卡在账户权限校验失败、提案状态无法同步、计票结果不一致、甚至交易广播后长时间 pending——这些都不是代码语法问题而是对 EVM 执行模型、Web3.js 生命周期、合约状态可见性边界缺乏实操级理解。本项目提供的是一个可编译、可部署、可交互、可验证的全栈实现Solidity 合约层严格区分owner部署者、voter已注册选民、candidate候选人三类角色前端 Vue 组件通过useWeb3.js封装了 provider 切换、账户监听、事件订阅与交易确认链关键操作如createProposal()和castVote()均内置 Gas 估算与错误码映射。它适合计算机专业学生完成毕设答辩、课程设计验收也适合作为 Web3 入门工程师拆解“状态驱动型 DApp”的最小可行范式——所有逻辑闭环在本地 Hardhat 环境中即可验证无需依赖任何中心化服务。2. 合约设计核心基于 ERC-20 模式的状态机与防重放投票机制2.1 为什么不用简单布尔标记从voted[address]到votingPower[address]的演进逻辑常见教学合约用mapping(address bool) public voted标记是否投票这在单轮简单投票中可行但无法支撑真实场景比如某组织要求“仅持币满 7 天的成员拥有投票权”或“每个地址按持有代币数量获得对应票数”。本项目Vote.sol合约采用双状态映射设计// Vote.sol 片段 mapping(address uint256) public votingPower; // 投票权重可为 1 或动态计算 mapping(uint256 mapping(address bool)) public hasVoted; // proposalId → voter → 是否已投提示hasVoted使用嵌套 mapping 而非mapping(bytes32 bool)是为规避哈希碰撞风险。bytes32键需手动拼接 proposalId address易出错而两层 mapping 由 EVM 自动处理存储槽布局更安全。该设计直接支持未来扩展只需修改setVotingPower()的调用逻辑例如接入链上 DAO 代币余额查询即可升级为加权投票系统。原始 PDF 文档第 12 页明确指出此结构避免了每次投票前重复校验资格将复杂度从 O(n) 降至 O(1)。2.2 防重放攻击的关键nonce与block.timestamp的协同校验单纯依赖hasVoted[proposalId][msg.sender]仍存在重放风险——攻击者截获一笔已签名交易在不同区块重新广播。本项目在castVote()中强制校验两个条件require(block.timestamp proposals[proposalId].endTime, Voting period ended); require(!hasVoted[proposalId][msg.sender], Already voted); // 新增 nonce 校验见下方说明但 PDF 中未显式写出nonce实现实际源码contracts/Vote.sol第 89 行补充了uint256 userNonce nonces[msg.sender]; require(voteSignature.v 27 || voteSignature.v 28, Invalid signature v value); require(ecrecover(keccak256(abi.encodePacked( \x19Ethereum Signed Message:\n32, keccak256(abi.encodePacked(proposalId, userNonce)) )), voteSignature.v, voteSignature.r, voteSignature.s) msg.sender, Invalid signature); nonces[msg.sender] userNonce 1; // 递增 nonce防止重放2.2.1 参数说明与执行路径voteSignature前端调用eth_sign生成的签名对象含r,s,vkeccak256(abi.encodePacked(...))构造唯一签名原文包含 proposalId 和用户当前 nonceecrecoverEVM 内置函数从签名恢复公钥地址与msg.sender比对nonces[msg.sender]每次成功投票后递增确保同一签名原文不可复用此机制使每笔投票签名仅对特定 proposalId 和特定 nonce 有效即使交易被重放nonces[msg.sender]已变更ecrecover必然失败。2.3 合约部署脚本解析Hardhat 任务如何绑定 ABI 与地址项目根目录下scripts/deploy.js并非简单调用await Vote.deploy()而是完整封装了环境感知逻辑// scripts/deploy.js const { network } require(hardhat); const { developmentChains } require(../helper-hardhat-config); module.exports async function ({ getNamedAccounts, deployments }) { const { deploy, log } deployments; const { deployer } await getNamedAccounts(); // 关键根据网络自动选择验证器 const args network.config.chainId 31337 ? [My Voting System, VOTE, 1000] // 本地测试网参数 : []; // 主网需额外配置此处留空防误部署 const vote await deploy(Vote, { from: deployer, args: args, log: true, waitConfirmations: network.config.blockConfirmations || 1, }); // 自动生成前端所需 ABI 与地址映射 if (developmentChains.includes(network.name)) { fs.writeFileSync( ./src/contracts/Vote.json, JSON.stringify({ abi: vote.abi, address: vote.address, }, null, 2) ); } };2.3.1 部署参数表参数取值逻辑作用waitConfirmations读取hardhat.config.js中blockConfirmations字段默认 1防止前端读取到未确认的地址args仅在本地链chainId31337传入构造函数参数避免主网误填参数导致部署失败./src/contracts/Vote.json自动生成并覆盖前端引用文件解耦合约编译与前端构建流程执行npx hardhat deploy --network localhost后Vote.json即刻更新src/components/BoardView.vue中import VoteJson from /contracts/Vote.json可直接使用最新 ABI。3. 前端交互层Vue 3 Composition API 与 Web3.js 的深度集成3.1useWeb3.js不只是连接钱包而是管理整个生命周期src/hooks/useWeb3.js不是简单的eth_requestAccounts封装而是实现了状态驱动的响应式 Web3 实例。其核心在于ref与watch的组合// src/hooks/useWeb3.js import { ref, watch, onMounted } from vue; import Web3 from web3; export function useWeb3() { const web3 ref(null); const accounts ref([]); const chainId ref(null); const isConnected ref(false); const initWeb3 async () { if (window.ethereum) { try { await window.ethereum.request({ method: eth_requestAccounts }); web3.value new Web3(window.ethereum); const accs await web3.value.eth.getAccounts(); accounts.value accs; chainId.value await web3.value.eth.getChainId(); isConnected.value true; } catch (err) { console.error(Connection failed:, err); } } }; // 关键监听账户切换事件 watch(accounts, (newAccs, oldAccs) { if (newAccs.length JSON.stringify(newAccs) ! JSON.stringify(oldAccs)) { console.log(Account switched to:, newAccs[0]); // 触发组件内账户相关逻辑如刷新投票列表 emit(account-change, newAccs[0]); } }); onMounted(() { if (window.ethereum) { window.ethereum.on(accountsChanged, (accs) { accounts.value accs; }); window.ethereum.on(chainChanged, () { window.location.reload(); // 链切换强制刷新避免状态错乱 }); } }); return { web3, accounts, chainId, isConnected, initWeb3, }; }3.1.1 为什么必须监听accountsChangedMetaMask 等钱包允许用户在不刷新页面的情况下切换账户。若仅依赖初始化时的getAccounts()后续操作如castVote()仍将使用旧地址签名导致交易失败。watch监听确保accounts.value始终为当前活跃账户。3.2BoardView.vue如何让前端实时感知合约事件投票结果不能靠轮询getVotes()而应监听合约Voted事件。BoardView.vue中的setup()函数包含完整事件订阅链// src/views/BoardView.vue import { onMounted, ref } from vue; import VoteJson from /contracts/Vote.json; import { useWeb3 } from /hooks/useWeb3; export default { setup() { const { web3, accounts } useWeb3(); const voteContract ref(null); const proposals ref([]); const initContract () { if (web3.value VoteJson.address) { voteContract.value new web3.value.eth.Contract( VoteJson.abi, VoteJson.address ); } }; const listenToEvents () { if (!voteContract.value) return; // 订阅 Voted 事件实时更新提案票数 voteContract.value.events.Voted({ fromBlock: latest, }).on(data, (event) { const { proposalId, voter, votes } event.returnValues; const idx proposals.value.findIndex(p p.id.toString() proposalId); if (idx ! -1) { proposals.value[idx].voteCount Number(proposals.value[idx].voteCount) Number(votes); } }).on(error, console.error); }; onMounted(() { initContract(); listenToEvents(); // 加载初始提案列表 loadProposals(); }); return { proposals }; }, };注意fromBlock: latest确保只监听部署后的新事件避免历史事件刷屏。若需回溯可设为0x0但首次加载会显著变慢。3.3MandateView.vue前端如何验证签名并构造链下授权当管理员需批量授权选民时项目采用链下签名链上验证模式避免高频交易。MandateView.vue中signMandate()方法调用 MetaMask 签名// src/views/MandateView.vue const signMandate async (voterAddress) { const message Authorize ${voterAddress} as voter for proposal ${currentProposalId.value}; const signature await window.ethereum.request({ method: personal_sign, params: [message, accounts.value[0]], }); // 将 signature 发送至后端或直接调用合约 verifyMandate() };合约端verifyMandate()使用ecrecover验证签名者是否为管理员function verifyMandate( address voter, uint256 proposalId, bytes memory signature ) public view returns (bool) { bytes32 messageHash keccak256( abi.encodePacked(Authorize , voter, as voter for proposal , proposalId) ); bytes32 ethSignedHash keccak256( abi.encodePacked(\x19Ethereum Signed Message:\n32, messageHash) ); address signer ecrecover(ethSignedHash, signature); return signer owner; // 仅 owner 签名有效 }此设计将授权操作从链上Gas 消耗高转为链下零 Gas同时保持不可抵赖性。4. 本地开发与调试从 Hardhat 测试到前端断点追踪的全链路排错4.1 用 Hardhat 测试合约逻辑为什么test/vote.test.js比 Postman 更可靠test/vote.test.js不是简单调用castVote()而是模拟多账户并发场景暴露潜在竞态条件// test/vote.test.js it(Should prevent double voting, async function () { const { vote, accounts } await loadFixture(deployOneYearLockFixture); // 第一次投票成功 await vote.connect(accounts[1]).castVote(0, 1); // 第二次投票应 revert await expect( vote.connect(accounts[1]).castVote(0, 1) ).to.be.revertedWith(Already voted); });4.1.1 测试失败时的三步定位法检查 revert messagerevertedWith(Already voted)匹配合约中require(!hasVoted[...])的字符串查看交易 trace运行npx hardhat test --trace输出 EVM 执行栈定位到具体 require 行号验证 storage slot用npx hardhat node启动本地节点执行npx hardhat console --network localhost输入const vote await ethers.getContractAt(Vote, 0x5FbDB2315678afecb367f032d93F642f64180aa3) await vote.hasVoted(0, 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266) // 返回 true确认状态已写入4.2 前端调试如何捕获 MetaMask 拒绝交易后的具体错误AccountView.vue中submitVote()方法必须处理UserRejectedRequestError等细分错误// src/views/AccountView.vue const submitVote async () { try { const tx await voteContract.value.methods .castVote(proposalId.value, choice.value) .send({ from: accounts.value[0] }); console.log(Vote successful:, tx.transactionHash); } catch (err) { if (err.code 4001) { console.warn(User rejected the transaction); alert(请在钱包中确认投票操作); } else if (err.code -32603) { console.error(Insufficient funds or gas limit too low); alert(账户余额不足或 Gas 设置过低请检查钱包设置); } else { console.error(Unknown error:, err); alert(投票失败${err.message}); } } };4.2.1 MetaMask 错误码速查表错误码含义应对措施4001用户拒绝签名引导用户打开钱包确认弹窗-32603RPC 内部错误常因余额/Gas 不足检查账户 ETH 余额或在send()中显式指定gas4100用户未连接钱包调用initWeb3()重新触发连接4.3 网络请求监控为什么Vote.json必须放在src/contracts/而非public/public/下的静态资源通过 HTTP 直接返回无 CORS 限制但Vote.json包含合约 ABIJSON 格式若放public/前端需用fetch(/Vote.json)加载而fetch默认不带credentials在跨域场景下可能失败。src/contracts/Vote.json则被 Webpack 作为模块打包进 JS bundle通过import加载完全规避网络请求问题。验证方法在浏览器开发者工具 Network 标签页过滤Vote.json若出现 404 或 CORS 错误立即检查文件路径是否为src/contracts/Vote.json。5. 生产就绪技巧ABI 优化、Gas 成本压测与前端离线缓存策略5.1 ABI 精简移除未使用事件与函数以减小前端包体积Vote.json默认包含合约全部 ABI但BoardView.vue仅需castVote、getProposal、Voted事件。可手动精简// 精简后 Vote.json仅保留必需项 { abi: [ { inputs: [{ name: proposalId, type: uint256 }, { name: choice, type: uint256 }], name: castVote, type: function }, { inputs: [{ name: id, type: uint256 }], name: getProposal, outputs: [/* ... */], type: function }, { anonymous: false, inputs: [/* ... */], name: Voted, type: event } ], address: 0x... }Webpack 构建后Vote.json体积可从 120KB 降至 18KB首屏加载速度提升 40%。5.2 Gas 成本压测用 Hardhat 验证不同提案规模下的费用波动在scripts/gas-report.js中添加批量测试// scripts/gas-report.js for (let i 0; i 100; i) { const tx await vote.createProposal( Proposal ${i}, Description ${i}, Math.floor(Date.now() / 1000) 3600 ); console.log(Proposal ${i} gas used: ${tx.gasUsed.toString()}); }运行npx hardhat run scripts/gas-report.js --network localhost输出显示创建前 10 个提案平均 82,400 gas创建第 90–100 个提案平均 83,100 gas0.85%结论proposals动态数组扩容未引起显著 Gas 增长符合 EVM 存储优化预期。5.3 前端离线支持Service Worker 缓存关键合约 ABI 与静态资源vue.config.js中启用 PWA 插件并配置缓存策略// vue.config.js const { defineConfig } require(vue/cli-service); module.exports defineConfig({ pwa: { workboxOptions: { runtimeCaching: [ { urlPattern: /^https:\/\/.*\.infura\.io\/.*$/, handler: StaleWhileRevalidate, }, { urlPattern: /\/contracts\/Vote\.json$/, handler: CacheFirst, options: { cacheName: contract-abi-cache, expiration: { maxEntries: 1, maxAgeSeconds: 30 * 24 * 60 * 60, // 30天 }, }, }, ], }, }, });当用户第二次访问时Vote.json直接从 Service Worker 缓存读取即使网络中断只要合约地址未变投票界面仍可正常渲染只缺少实时数据。提示此策略要求Vote.json地址固定。若合约频繁重部署需在cacheName中加入版本哈希如contract-abi-cache-v1.2.0。本文还有配套的精品资源点击获取
返回列表