ARTICLE DETAIL

资讯详情

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

基于Truffle的区块链投票系统:合约开发与DApp实践

基于Truffle的区块链投票系统:合约开发与DApp实践 简介面向区块链初学者的 Truffle 框架投票系统项目资源定位明确适合计算机、通信、人工智能、自动化等相关专业学生用于课程设计、大作业或毕业设计参考也可帮助想快速掌握去中心化应用开发链路的开发者入门。项目基于 Ganache 搭建本地私有链配合 MetaMask 实现链上账户交互从简单投票到代币化投票两个递进示例逐步展示合约编写、编译部署、前端调用和投票计票的完整流程代码均经过调试验证作为个人毕设取得 95 分答辩成绩可自由修改扩展。资源包共 34 个文件以 15 个 JS 脚本部署与交互逻辑、6 个 Solidity 合约文件和 JSON 配置为主辅以 HTML 页面与说明文档整体仅 353KB目录结构紧凑清晰便于按模块阅读和二次开发。已有 252 人学习使用适合从零上手夯实区块链 DApp 基础也能作为课程设计与毕业设计的直接蓝本。1. 区块链项目基于 truffle 的投票系统毕业设计最稳的链上应用选题一份名为《区块链项目基于 truffle 的投票系统源码》的毕业设计压缩包解压后通常是 truffle 工程的标准四件套contracts 合约目录、migrations 迁移脚本、test 测试用例外加一个用 web3.js 写的投票页面。区块链项目最容易卡死在「合约写完了却跑不起来」这一环而 truffle 的价值恰好是把编译、部署、测试、控制台调试收拢到一套命令里让投票这种状态简单但逻辑完整的合约半天之内跑通全链路。它适合 Solidity 刚入门、手里要交出可演示成果的本科毕设也适合想快速验证去中心化应用落地姿势的从业者当原型。判断这份源码是否靠谱先看它有没有踩中下面几个经典大坑。2. 初始化 truffle 工程与投票合约的数据结构设计2.1 用 truffle init 生成工程骨架先分清目录职责先装 truffle再生成工程npm install -g truffle mkdir truffle-voting cd truffle-voting truffle inittruffle init会生成一套目录结构真正重要的是背后的约定contracts/放 Solidity 源码migrations/放部署脚本test/放基于 Mocha 的测试build/contracts/是编译产物属于生成文件不手动改。很多 zip 源码换台机器就编译不过原因是把build/一起打进包又在没有重新truffle compile的情况下直接执行migrate拿到的全是旧 ABI 和旧字节码。目录职责常见误用contracts/Solidity 源码把编译产物 json 塞进来migrations/链上迁移脚本脚本编号重复或漏编号test/Mocha 测试用例用例之间不重置合约状态build/contracts/编译器生成的 ABI 与字节码手动改 json 里的字段truffle init生成的Migrations.sol不要删truffle 靠它记录链上迁移进度删掉后migrate会报找不到迁移记录。投票合约直接在contracts/下新建文件即可文件名建议Voting.sol合约名与文件名必须一致这是 Solidity 的硬性约定。2.2 投票合约建模候选人用 struct 数组选民用 mapping链上投票的状态就三类候选人列表、谁投过票、当前票数。一个最小可编译版本// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; contract Voting { struct Candidate { uint256 id; string name; uint256 voteCount; } Candidate[] private candidates; mapping(address bool) private voters; address public owner; event VoteCast(address indexed voter, uint256 candidateId); constructor(string[] memory names) { owner msg.sender; for (uint256 i 0; i names.length; i) { candidates.push(Candidate(i, names[i], 0)); } } function vote(uint256 candidateId) external { require(!voters[msg.sender], already voted); require(candidateId candidates.length, candidate not exist); voters[msg.sender] true; candidates[candidateId].voteCount; emit VoteCast(msg.sender, candidateId); } function getCandidates() external view returns (Candidate[] memory) { return candidates; } function getCandidateCount() external view returns (uint256) { return candidates.length; } }选型理由candidates用数组而不是 mapping因为前端要按顺序渲染候选人列表数组天然保留插入顺序mapping 既拿不到长度也不能遍历。voters用 mapping 而不是数组因为重复投票检查要求 O(1) 完成。每投一票都遍历一遍已投票地址gas 会随人数线性上涨答辩时这个点几乎必问。两条require都在状态变更之前任何一条失败整个交易回滚不会留下「人已标记却没计票」的中间态。VoteCast事件不是装饰。前端监听它实现票数实时刷新比轮询getCandidates更贴近真实 dApp 的数据流。2.2.1 构造函数传参与 memory 声明的两个坑构造函数参数来自迁移脚本truffle migrate时由deployer.deploy(Voting, [数组])提供具体写在第 3 章。这里先提醒一个高频编译错误Solidity 0.8.x 里函数参数如果是数组必须显式写string[] memory names写成string[] names直接编译失败。网上老教程大量使用 0.4/0.5 语法把旧合约搬到新版 truffle 时这个报错出现频率最高。2.3 剔除源码里常见的重复投票漏洞网上流传的投票 demo 有一类删掉了require(!voters[msg.sender])只保留票数累加一个地址反复调vote就能刷票答辩老师一句话就能问倒「这不就是数据库里的 UPDATE 吗」去中心化投票的意义在于验票公平、记录可审计链上存的不该只是最终数字而是「哪个地址投过、投给谁」的完整痕迹。另外函数可见性要收紧纯读取方法标view对外不可见的逻辑标private事件参数里能indexed的地址字段都标上方便前端按地址过滤日志。提示部署前建议把vote里的第二条require(candidateId candidates.length)保留它专门挡越界调用批处理脚本最容易在这里出事。3. 用 ganache 和 truffle migrate 把投票合约部署到本地链3.1 启动 ganache 后写 truffle-config.js端口和网络 ID 必须对上本地链用 ganache 即可v7 之后的 CLI 安装与启动方式npm install -g ganache ganache --server.port 7545 --chain.networkId 5777 --wallet.totalAccounts 10老教程里的ganache-cli -p 7545 --networkId 5777 --accounts 10已废弃但参数含义一一对应搜到旧写法也能照着翻译。启动后记住两个值端口 7545、网络 ID 5777。配置文件truffle-config.jsmodule.exports { networks: { development: { host: 127.0.0.1, port: 7545, network_id: 5777, gas: 6721975, }, }, compilers: { solc: { version: 0.8.19, }, }, };参数说明network_id必须和 ganache 界面显示的一致不一致时migrate直接报Could not connect to your Ethereum client。gas写到接近主网区块上限是为了给复杂合约余量投票合约实际消耗只有几十万写不写都能跑通但「先看 gas 再看网络」的排查顺序值得养成。compilers.solc.version不写时 truffle 用最新编译器会和源码里的 pragma 产生警告写成精确版本号换机器复现时行为才一致。3.2 迁移脚本构造函数参数在这里传入新建migrations/2_deploy_voting.jsconst Voting artifacts.require(Voting); module.exports function (deployer) { deployer.deploy(Voting, [张三, 李四, 王五]); };artifacts.require(Voting)读的是build/contracts/Voting.json所以必须先编译再迁移。deployer.deploy第一个参数是合约抽象后面所有参数按顺序传给 Solidity 构造函数在这里就是把三个名字传入constructor(string[] memory names)。文件名数字前缀决定执行顺序1_initial_migration.js之后新文件从 2 开始编号重复或跳跃都可能让迁移记录对不上。执行truffle compile truffle migrate --network development --reset--reset的含义是忽略链上已有迁移记录从 1 号脚本重新跑。开发期每次改完合约必须带它否则 truffle 检测到链上已有迁移记录会直接跳过前端连的还是旧地址。成功后终端会打印合约地址待会填进前端。3.3 开发期 truffle 命令速查命令作用开发期必调参数truffle compile编译 contracts/ 下全部 Solidity--all强制重编truffle migrate按编号执行部署脚本--reset、--networktruffle test跑 test/ 下全部用例--network、--compile-alltruffle console进入带合约抽象的交互控制台--network指定链truffle create contract X生成新合约骨架无truffle console是写前端之前最常用的验证手段进去之后Voting.deployed()直接拿已部署实例调函数比开页面快得多具体命令在第 6 章。4. 用 truffle test 覆盖投票的三种核心异常分支4.1 每个 it 对应一个链上场景用例之间隔离状态truffle test 底层是 Mocha Chai测试文件放test/目录即可。下面这段覆盖正常投票、重复投票、非法候选人、事件日志四个场景const Voting artifacts.require(Voting); contract(Voting, (accounts) { const [alice, bob] accounts; let voting; beforeEach(async () { voting await Voting.new([Alice, Bob]); }); it(部署后候选人数量为 2初始票数为 0, async () { assert.strictEqual((await voting.getCandidateCount()).toString(), 2); const rows await voting.getCandidates(); assert.strictEqual(rows[0].voteCount.toString(), 0); }); it(同一地址重复投票被拒绝并回滚, async () { await voting.vote(0, { from: alice }); try { await voting.vote(1, { from: alice }); assert.fail(should revert); } catch (e) { assert(e.message.includes(already voted)); } }); it(投票给不存在的候选人会回滚, async () { try { await voting.vote(99, { from: bob }); assert.fail(should revert); } catch (e) { assert(e.message.includes(candidate not exist)); } }); it(投票事件日志包含投票人和候选人编号, async () { const receipt await voting.vote(0, { from: alice }); const log receipt.logs.find((l) l.event VoteCast); assert.strictEqual(log.args.voter, alice); assert.strictEqual(log.args.candidateId.toString(), 0); }); });几个容易写错的地方Voting.new([Alice, Bob])跳过迁移脚本直接部署新实例保证每个用例从干净状态开始。用例之间共享同一个合约实例会出现「上一个用例投过票、下一个用例初始票数断言失败」的脏数据问题。uint256返回值在 web3 层可能是字符串或 BigNumber统一.toString()再比较避免类型误判导致的假失败。捕获回滚用try/catch而不是.catch错误对象里的message能同时验证「确实回滚了」和「回滚原因正确」两层信息。4.2 测试用例与预期行为对照用例输入预期行为正常投票alice 投候选人 0该候选人票数 1产生 VoteCast 日志重复投票alice 再投候选人 1交易回滚错误含 already voted非法候选人bob 投编号 99交易回滚错误含 candidate not exist多地址投票alice、bob 各投一票两个候选人的票数分别 1运行命令truffle test --network developmentcontract(Voting, (accounts) {...})里的accounts对应 ganache 生成的前 10 个地址顺序固定其中第 0 个是默认发送者。如果assert.fail没被触发先回去看合约里require是否真的存在——测试报错翻车往往是测试写错测试不报错才是合约逻辑漏了这是两件完全不同的事。5. 前端用 web3.js 对接合约 ABI本地跑通一票一投5.1 从 build/contracts 读取 ABI 和部署地址前端用 web3.js工程里装web3包或直接引 CDN 都行关键是 ABI 和合约地址必须来自同一份编译产物import Web3 from web3; import VotingArtifact from ./build/contracts/Voting.json; let web3, voting; async function init() { if (window.ethereum) { web3 new Web3(window.ethereum); await window.ethereum.request({ method: eth_requestAccounts }); } else { web3 new Web3(new Web3.providers.HttpProvider(http://127.0.0.1:7545)); } const networkId await web3.eth.net.getId(); const address VotingArtifact.networks[networkId].address; voting new web3.eth.Contract(VotingArtifact.abi, address); }VotingArtifact.networks[networkId]记录的是「最后一次 migrate 所在链的地址」。zip 里这段代码换到另一台机器跑经常报地址为空就是因为新机器没重新 migratenetworks里查不到当前链 ID这是毕设源码跨机器复现的头号原因。window.ethereum分支对应 MetaMask 注入的 provider。用 MetaMask 演示时要在插件里手动添加 ganache 链RPC 填http://127.0.0.1:7545Chain ID 填 5777。注意这个 Chain ID 和truffle-config.js里的network_id是两套配置填错会出现 MetaMask 报网络错误而 truffle 一切正常的怪异现象。提示没有 MetaMask 也能完整演示走else分支直连 ganache投票和查票都正常只是演示不了「钱包签名确认」那一步。5.2 用 .call() 读状态、.send() 写状态async function loadResult() { const rows await voting.methods.getCandidates().call(); rows.forEach((c) { console.log(${c.name}: ${c.voteCount}); }); } async function castVote(candidateId) { const accounts await web3.eth.getAccounts(); await voting.methods.vote(candidateId).send({ from: accounts[0], gas: 100000, }); loadResult(); }方法类型行为getCandidates().call()读本地质押执行不产生交易、不花 gasvote().send()写广播交易从from地址扣 gas两个函数之间必须等.send()返回再刷新页面。ganache 本地挖矿很快但不加await的话loadResult()可能读到未打包的旧状态界面出现「投完票数字没变」实际是时序问题而不是合约问题。gas: 100000对单次投票足够写死是为了在网络不稳时减少估算失败的几率。6. 答辩验收前的三个验证技巧与高频报错对照6.1 用 truffle console 核对链上真实状态页面显示和链上状态不一致时进控制台人工复核truffle(development) const v await Voting.deployed() truffle(development) (await v.getCandidates()).map(c [c.name, c.voteCount])第二行把每个候选人的名字和票数映射成数组打印如果页面和这里的数字对不上问题在前端读取逻辑对得上问题在事件刷新逻辑。这是定位「页面是假的还是链是真的」最快的方式。6.2 高频报错对照表报错根因处理Could not connect to your Ethereum client端口或 network_id 与 ganache 不一致重启 ganache 后核对配置Error: No network specifiedmigrate 没指定网络加--network developmentInvalid address / 地址为空networks[networkId]不存在重新 migrate重建 build 产物Non-Error Promise rejectionMetaMask 网络与 ganache 不一致确认 Chain ID 5777、RPC 指向 127.0.0.1:75456.3 变更合约后的全量重验一条命令改完合约后按依赖顺序执行清掉旧产物避免 stale ABI强制重编强制重迁移再跑全部测试。rm -rf build truffle compile --all truffle migrate --reset --network development truffle test这条命令把「手滑用了旧 ABI、忘了重置迁移记录、测试跑的是旧合约」三类问题一次性挡掉。答辩前一晚把它跑通把输出贴进演示文档环境类翻车基本可以提前排除剩下的就只是前端页面和 MetaMask 的手动流程多演练两遍即可。本文还有配套的精品资源点击获取
返回列表