简介:这套基于 Truffle 框架的区块链投票系统源码,是面向区块链初学者的毕业设计项目,内置两个递进式子项目:简单投票 DApp 与基于 Token 的投票 DApp。项目以 Ganache 作为本地私有链,配合 MetaMask 钱包完成交互,完整覆盖了 Solidity 合约编写、Truffle 迁移部署、Web 前端接入和自动化测试等核心环节,适合计算机、通信、人工智能、自动化等相关专业学生作为课程设计或毕业设计参考。压缩包共 34 个文件,大小约 353KB。其中 Solidity 合约文件定义投票与代币逻辑,JS 脚本负责部署与测试,JSON 配置用于管理依赖和编译参数,HTML 页面提供前端交互界面,另有 Git 忽略规则与说明文档,目录结构清晰、模块划分明确,便于按照项目说明逐步理解和二次开发。目前已有 252 人学习下载。所有代码均经过调试测试,可直接运行,能帮助读者快速跑通去中心化投票全流程,深入理解智能合约部署、账户授权及 Token 激励等实现细节;基础较强的学习者也能在此基础上扩展功能,提升项目完成度。
1. 从 Truffle 模板长出来的一套投票系统,真正的差别在授权边界
把 zip 解压后,你会在根目录看到两个几乎同构的子工程,分别叫1_simple_voting_by_truffle_dapp和2_token_based_voting。前者是注册即投票的单票制,后者在投票入口前加了一层代币授权和权重计算。两个工程共享同一套 Truffle 工程骨架:contracts/、migrations/、test/、app/src/,用 Ganache 起本地链,MetaMask 做签名端,浏览器里完成投票交易。典型的使用链路是:合约部署到 Ganache,前端通过 web3.js 注入账户,MetaMask 弹窗确认,交易写入链上后事件驱动页面刷新。对毕业设计来说,这套工程的完整度恰好在“能讲清合约状态变化”和“能看到完整 DApp 交互闭环”之间。对想快速复现一条链上交互流程的人,它也是先看整体再抠细节的好样本。
2. 双工程结构拆解:Truffle 骨架、迁移脚本和前端入口的职责边界
2.1 两个子工程的文件差异,真正要改的只有 contracts 与 migrations
把两个子工程并排看,目录几乎是从同一套官方 metacoin 模板派生出来的。1_simple_voting_by_truffle_dapp的test/下还保留着TestMetacoin.sol和metacoin.js,这是模板复刻时留下的痕迹,不影响运行,但能让你看清哪些文件是模板自带、哪些是实际业务代码。
| 区域 | 1_simple_voting_by_truffle_dapp | 2_token_based_voting |
|---|---|---|
contracts/ | Migrations.sol、Voting.sol | Migrations.sol、Voting.sol |
migrations/ | 1_initial_migration.js、2_deploy_contracts.js | 同名脚本 |
test/ | TestMetacoin.sol、metacoin.js模板残留 | TestVoting.sol、Voting.js |
app/src/ | 含index.html与前端逻辑 | 前端逻辑,接 token 权重 |
模板残留并非坏事,它反而说明一件事:Truffle 工程的核心可替换边界就在contracts/与migrations/。前端只要 web3 调用方式不变,合约换一套逻辑后仍然能跑通。这也是为什么毕业设计答辩时,提问点通常集中在合约内部状态和部署脚本,而不是页面样式。
2.2 truffle-config.js 网络配置与 Ganache 端口的对应关系
truffle-config.js是整条链上交互的起点。Ganache 默认监听127.0.0.1:7545,如果这里写成 8545,MetaMask 连的就是另一条链,前端页面拿到的账户和合约地址全部对不上。
module.exports = { networks: { development: { host: "127.0.0.1", port: 7545, network_id: "*" } }, compilers: { solc: { version: "0.5.0" } } };network_id: "*"表示接受任意网络 id,本地开发这样写最省事。port必须跟 Ganache 的监听端口完全一致,Ganache 新版桌面版默认 7545,命令行版 Ganache CLI 默认 8545,很多迁移报错都来自这里错位。solc.version需要跟合约里的pragma兼容,Truffle 会自动下载对应版本编译器,不必手动装 solc。
2.3 迁移脚本按序号执行,Migrations 是部署记录器
migrations/目录下的脚本按文件名前缀数字顺序执行。1_initial_migration.js部署Migrations.sol,这个合约的作用是记录链上已经执行到哪一步迁移;2_deploy_contracts.js才真正部署业务合约:
const Voting = artifacts.require("Voting"); module.exports = function (deployer) { deployer.deploy(Voting); };artifacts.require("Voting")会读取build/contracts/Voting.json,这个 JSON 包含 ABI 和部署字节码,是编译阶段生成的产物。deployer.deploy(Voting)在没有构造参数时不需要额外传参;如果后续改成带tokenAddress的构造器,这里就要写成deployer.deploy(Voting, token.address)。整条部署链路是:编译产出 artifact,迁移脚本读取 artifact,部署器按顺序执行交易,MetaMask 在部署时也会弹窗要求确认 gas。
提示:每次改完合约后执行
truffle migrate --reset,否则 Truffle 判断迁移记录未变会跳过部署,前端拿到的是旧地址。
3. Voting.sol 状态设计:候选人表、选民状态和投票动作的控制流
3.1 候选人数据结构和候选人数量的作用
Voting.sol是这套系统的核心。常见写法是用一个struct描述候选人,用mapping存候选人详情,用mapping记录选民状态:
pragma solidity ^0.5.0; contract Voting { struct Candidate { uint id; string name; uint voteCount; } mapping(address => bool) public voters; mapping(uint => Candidate) public candidates; uint public candidatesCount; event VoteCast(address indexed voter, uint indexed candidateId); function addCandidate(string memory _name) public { candidatesCount++; candidates[candidatesCount] = Candidate(candidatesCount, _name, 0); } function vote(uint _candidateId) public { require(!voters[msg.sender], "already voted"); require(_candidateId > 0 && _candidateId <= candidatesCount, "invalid candidate"); voters[msg.sender] = true; candidates[_candidateId].voteCount++; emit VoteCast(msg.sender, _candidateId); } }candidatesCount看起来只是一个数字,实际承担了两个职能:一是为候选人分配自增 id,二是给vote()提供边界校验依据。Solidity 的mapping不能直接遍历长度,如果不维护这个计数器,前端就无法知道链上到底有几个候选人。voters映射用地址做 key,value 是 bool,这里记录的是“是否投过票”,而不是“投给谁”。
3.2 投票函数的检查顺序与状态变更次序
vote()里的两个require顺序是有讲究的。先查!voters[msg.sender],再查_candidateId是否越界,这是把“调用者是否合法”放在“操作对象是否合法”之前。如果反过来,攻击者可以先探测候选人范围,再针对未投票地址做定向操作。两个检查都通过后,执行顺序是先置位voters[msg.sender] = true,再累加票数。
先改状态再累加票数不是为了性能,而是为了天然防止重入。Solidity 0.5.x 里没有内置的重入保护,如果把票数累加放在状态置位之前,合约内部调用或恶意合约回调就可能在同一笔交易里重复投票。先置位后累加,第二次进入时第一个require直接拦下。
emit VoteCast放在函数末尾,事件本身不影响状态,但对前端非常重要。前端监听VoteCast事件后可以实时刷新候选人票数,而不必每次轮询全量合约状态。
3.3 用户身份从布尔到权重的升级点
2_token_based_voting与 simple 版的核心差异就在voters这个映射上。布尔值只能表达“投过/没投过”,token 版需要表达“你有多少投票权重”。常见做法是把 bool 换成uint,记录投票时对应账户持有的代币余额快照,票数累加改为按权重累加:
mapping(address => uint) public voteWeight; function vote(uint _candidateId) public { uint weight = token.balanceOf(msg.sender); require(weight > 0, "no token"); require(voteWeight[msg.sender] == 0, "already voted"); voteWeight[msg.sender] = weight; candidates[_candidateId].voteCount += weight; }这里有个容易忽略的细节:voteWeight[msg.sender] == 0既可以表示“没投过票”,也可能和“持有 0 权重”混淆。更严谨的做法是在合约里维护一个单独的hasVoted映射,或者在voteWeight里用 0 作为未投票标记时,明确禁止 0 权重账户参与。工程代码里简短处理没问题,但答辩时被问到“0 余额账户能否投票”时,要能说清楚这个边界。
4. Ganache + MetaMask 联调:编译、迁移、前端三层的连接顺序
4.1 Ganache 的启动参数要和 truffle-config.js 对齐
Ganache 可以开桌面版,也可以直接用命令行版本。命令行方式的优势是可以精确控制端口和网络 id,方便多项目并行调试。
ganache-cli -p 7545 --networkId 5777-p 7545与truffle-config.js里的port保持一致,--networkId 5777是 Ganache 的典型默认网络 id。MetaMask 添加网络时填入的 Chain ID 要和这里一致,否则 MetaMask 会认为连接的是未知网络,拒绝展示账户余额。启动后 Ganache 会打印 10 个带私钥的账户,私钥列表用于后续导入 MetaMask。
注意:MetaMask 里添加本地网络时,Chain ID 填 1337 还是 5777,取决于 Ganache 启动参数。两边不一致时会直接报
eth_chainId错误,页面里表现为 MetaMask 一直转圈。
4.2 编译、迁移、重置的完整命令序列
合约写完后的命令序列固定为三步:
npm install truffle compile truffle migrate --resetnpm install安装 Truffle 工程依赖,包括web3、truffle-hdwallet-provider等前端所需包。truffle compile只做编译,产出build/contracts/Voting.json。truffle migrate --reset是强制重新执行所有迁移脚本,从1_开始重新部署。
--reset的关键作用在于:Truffle 会在链上通过Migrations.sol记录已执行到的脚本序号。没有--reset时,第二次执行truffle migrate会发现链上已有部署记录而跳过,结果是合约地址没变或者指向旧的字节码。调试阶段前端的Voting.json是本地文件,但链上合约是旧版本时,ABI 与字节码错位会导致调用方法返回 undefined 或 revert。
4.3 前端通过 artifact 找地址,MetaMask 通过私钥签交易
前端入口在app/src/里,webpack 配置已经就绪。接入合约的标准做法是读取编译产物里的networks字段,按网络 id 找对应地址:
import Web3 from "web3"; import VotingArtifact from "../../build/contracts/Voting.json"; const web3 = new Web3(Web3.givenProvider || "http://127.0.0.1:7545"); const networkId = await web3.eth.net.getId(); const deployedNetwork = VotingArtifact.networks[networkId]; const voting = new web3.eth.Contract( VotingArtifact.abi, deployedNetwork.address );Web3.givenProvider在有 MetaMask 注入时优先使用window.ethereum,pass 的才是本地节点。用getNetworkId()拿到的值必须与Voting.json中networks[5777].address匹配,不匹配通常是 Ganache 网络 id 改过而忘记重新迁移。拿到deployedNetwork.address后,后续调用voting.methods.vote(id).send({ from: accounts[0] })时,MetaMask 会弹出授权确认。
前端发起交易时注意send()是异步的,需要传入from地址。accounts[0]要等 MetaMask 的eth_requestAccounts授权完成后才能拿到,页面初始化阶段直接取会得到空数组。常见做法是点击投票按钮时再请求账户,而不是页面加载时就请求权限。
5. 测试与排错:把重复投票、越界候选人和 gas 问题锁在测试层
5.1 TestVoting.sol 适合验证合约内状态,Voting.js 适合验证交互
Truffle 支持两种测试风格。TestVoting.sol在 EVM 里直接运行合约间调用,适合验证状态逻辑;Voting.js走完整 web3 交易流程,适合验证 ABI 编码和事件。两个测试文件互补,缺一不可。
Voting.js测试重复投票场景的写法:
const Voting = artifacts.require("Voting"); contract("Voting", (accounts) => { it("should reject double voting", async () => { const voting = await Voting.new(); await voting.addCandidate("Alice", { from: accounts[0] }); await voting.vote(1, { from: accounts[1] }); let reverted = false; try { await voting.vote(1, { from: accounts[1] }); } catch (error) { reverted = true; } assert.ok(reverted, "second vote must be reverted"); }); });artifacts.require("Voting")拿到的是合约抽象,.new()会部署一个全新的合约实例,每个测试用例互不干扰,这是测试合约与迁移脚本部署的合约彼此独立的好处。try/catch捕获的正是vote()中require(!voters[msg.sender], "already voted")抛出的 revert。断言reverted为 true,是锁住业务边界的最低成本方式。
TestVoting.sol更接近底层,需要导入truffle/Assert.sol和DeployedAddresses.sol。它适合做数值断言,比如投票后候选人票数是否精确加一,这类状态验证用 JS 测试反而要多一轮await调用,可读性不如 Solidity 里直接断言。
5.2 package.json 脚本让测试与迁移结果可回归
package.json里的 scripts 字段是日常开发最常碰的地方。合理配置后,一条命令完成编译、迁移和回归测试:
{ "scripts": { "compile": "truffle compile", "migrate": "truffle migrate --reset", "test": "truffle test", "dev": "webpack-dev-server --mode development" } }npm test会先编译再运行全部测试,测试失败时进程退出码非 0,CI 环境下可以直接拦截。npm run dev启动的是 webpack-dev-server,默认端口 8080,前端页面通过这个服务访问。如果页面里拿不到合约地址,先回到终端跑npm run migrate,再刷新页面。前端不会主动感知重新部署,必须重新加载页面并重新读取VotingArtifact.networks[networkId].address。
5.3 三种常见报错的定位方式与修复路径
| 报错现象 | 直接原因 | 定位方式 |
|---|---|---|
Error: exceeds block gas limit | 合约构造函数或迁移脚本里某个操作 gas 消耗过大 | 在migrate命令后加--verbose-rpc观察交易 gas 估算 |
VM Exception while processing transaction: revert | 合约内require条件不满足 | 用 Remix 或 Truffle Debugger 单步执行,定位到具体require |
Returned values aren't valid, did it run Out of Gas? | 调用只读函数时传入的地址不是合约地址 | 确认deployedNetwork.address存在,且与当前网络 id 匹配 |
第三类报错最容易误导人。它看起来像 gas 不足,实际上往往是VotingArtifact.networks[networkId]返回 undefined,导致new web3.eth.Contract(abi, undefined)。address 为空时,web3 依然能构造出合约对象,但调用任何只读方法都会返回上述错误。这时候要去查 artifact 文件里的网络 id,而不是调 gas。
6. 进阶一步:在毕设之上把投票状态从 bool 升级为状态机
6.1 用枚举替代布尔值,获得可取消与可转移的治理空间
原始合约用bool voters[address]表示投票状态,这只够表达“投了/没投”。要做一个真正有治理语义的系统,建议立即升级为枚举状态机:
enum VoterStatus { Unregistered, Registered, Voted, Revoked } mapping(address => VoterStatus) public voterStatus; function register() public { require(voterStatus[msg.sender] == VoterStatus.Unregistered, "already registered"); voterStatus[msg.sender] = VoterStatus.Registered; } function vote(uint _candidateId) public { require(voterStatus[msg.sender] == VoterStatus.Registered, "not registered"); voterStatus[msg.sender] = VoterStatus.Voted; candidates[_candidateId].voteCount++; }枚举的收益是一眼可读,并且让非法状态迁移在类型层面就能拦截。比如Revoked状态的账户不可能直接跳到Voted,必须先由管理员或合约逻辑置回Registered,这种显式状态迁移比 bool 位运算容易审计。毕设答辩时,面试官如果问“如何让选票作废”,枚举方案可以直接说:把状态置为Revoked,并保证voteCount不被回滚,原始交易记录保持一致。
6.2 函数选择器白名单:给前端可调用边界做一次显式声明
合约暴露的每个 public 函数都有对应的函数选择器,即keccak256(函数签名)的前 4 字节。投票类合约最怕的是前端 ABI 版本错位后,用户调用了旧版函数签名,交易成功但状态没改变。
低层调用时,建议在前端维护一份选择器白名单:
const allowedSelectors = new Set([ voting.methods.vote(1).encodeABI().slice(0, 10), voting.methods.addCandidate("").encodeABI().slice(0, 10), voting.methods.voters("0x0000000000000000000000000000000000000000").encodeABI().slice(0, 10), ]); const selector = txData.slice(0, 10); if (!allowedSelectors.has(selector)) { throw new Error("selector not allowed"); }encodeABI()会输出完整的 calldata,前 10 位十六进制字符就是函数选择器。把vote、addCandidate、voters这几个预期内的方法做成集合,任何不在集合内的调用直接拦截。这样即使合约升级后冒出新的 public 函数,前端也不会因为 ABI 列表更新不及时而误调用到未审计的方法。这套校验和链上require互为镜像:链上管状态合法性,前端管调用面收窄,两边同时出错的可能性极低。
本文还有配套的精品资源,点击获取