区块链抽奖平台开发实战:智能合约、随机数与DApp完整设计
2026/9/14 15:42:43 网站建设 项目流程

简介:这套基于区块链的去中心化抽奖平台毕业设计资源,面向计算机相关专业学生及区块链爱好者,可作为毕业设计选题、课程项目或入门实战的完整参考。压缩包共2000个文件,大小约4.64MB,其中1700个JavaScript文件用于前端交互与合约调用,120个Markdown文档承载说明与笔记,另有JSON配置、C/C++底层实现及少量Java辅助代码,覆盖系统配置、加解密与业务逻辑等多个层面。所有源码均经过本地编译验证,评审分达95分以上,且经助教审定难度适中,配套详细开发文档与全部工程资料,方便读者从需求分析、架构设计一直跟进到部署运行。目录结构清晰,既可直接运行体验,也便于定位模块进行二次开发。目前已有96人学习下载,适合希望快速上手区块链去中心化应用、理解链上抽奖全流程的读者。

1. 为什么去中心化抽奖平台的毕业设计要从“信任”开始写

我审过不少区块链课设,第一版普遍是把一个普通网页抽奖后面加了个数据库表存 hash,界面写个 “区块链驱动” 就交差。这种项目经不起一句追问:中奖结果为什么一定是公平的?后台管理员能不能换掉随机数?参与者的 ticket 会不会重复写入?区块链抽奖平台要解决的是这些问题,而不是“把上链当装饰”。

用区块链做抽奖,核心是把抽奖规则、参与者状态、随机数来源、开奖结果全部放到链上可验证。评审老师想看到的也是这点:你不光会写合约,还得说清楚去中心化到底改掉了哪个信任缺口。下面从合约设计、事件监听与前端接入、测试和论文结构、验收检查点四个方向展开,每一步都给出可直接抄的代码和参数,照着改就能跑通一个高分项目。

2. 抽奖合约的第一层:随机数、奖池与开奖状态机

2.1 随机数来源选型:区块哈希、commit-reveal 还是预言机

抽奖合约里最容易出问题的就是随机数。用block.timestampblockhash做开奖种子,矿工或出块者能看到其他参与者的交易,再决定要不要在你之后打包开奖交易,这叫偏序攻击;用keccak256(abi.encodePacked(block.timestamp, block.difficulty, msg.sender))这种组合,可读性差,审查时也说不清安全性。

毕业设计里我一般推荐 commit-reveal 机制:参与者先提交一个随机数的哈希,所有人提交完后再打开自己的随机数,开奖时把这些随机数累加进种子。只要有一个参与者不打开,就无法预知最终种子;合约还能对不 reveal 的人做惩罚。这个方法不依赖外部预言机,在本地链和测试网上都能完整演示,论文里也很好解释。

方案公平性实现成本演示难度适合场景
区块哈希弱,有偏序攻击风险演示型 demo
commit-reveal强,参与者共同参与熵源中等独立毕业设计
Chainlink VRF强,依赖去中心化预言机高,需要拿到测试币有网络环境加分项

选定 commit-reveal 后,合约随机数部分就变成了一个状态机约束问题:只允许在「提交哈希」阶段 commit,只允许在「打开」阶段 reveal,开奖前还要检查所有人都参与过 commit。

2.2 合约状态机:从 Open 到 Drawn 的四段流转

我把合约状态设计成四个阶段:Open(报名)、Commit(提交随机数哈希)、Reveal(打开随机数)、Drawn(开奖结束)。另外加一个Cancelled管理员取消状态,避免因为参与人数不足导致资金锁死。

状态只能单向流转,且每个阶段有独立时间窗口:Open结束后进入Commit,所有参与者提交哈希后进入RevealReveal窗口结束且所有人 reveal 后才能开奖。这样做的好处是,开奖时随机数种子完整的由参与者贡献,合约无法单方面控制结果。

enum Phase { Open, Commit, Reveal, Drawn, Cancelled } Phase public phase; uint256 public openEndsAt; uint256 public commitEndsAt; uint256 public revealEndsAt;

时间窗口在创建抽奖时写入,比如openEndsAt = block.timestamp + 30 minutes。这里注意不能用block.timestamp直接当截止时间,必须给足窗口,否则用户会在一个区块内同时经历多个状态。

2.3 最小可运行的 Lottery 合约源码

下面是一个能跑通核心流程的简化合约,省略了 ownerFee 分配和部分修饰器,但完整保留了 commit-reveal 开奖逻辑。代码兼容 Solidity 0.8.x。

// SPDX-License-Identifier: MIT pragma solidity ^0.8.18; contract Lottery { enum Phase { Open, Commit, Reveal, Drawn, Cancelled } Phase public phase; address public owner; uint256 public ticketPrice; uint256 public playerLimit; uint256 public seed; address[] public players; mapping(address => bytes32) public commitHashes; mapping(address => bool) public hasRevealed; uint256 public openEndsAt; uint256 public commitEndsAt; uint256 public revealEndsAt; address public winner; event TicketPurchased(address indexed player, uint256 indexed ticketId); event Committed(address indexed player, bytes32 hash); event Revealed(address indexed player, bytes32 secret); event WinnerSelected(address indexed player, uint256 randomNumber); constructor( uint256 _ticketPrice, uint256 _playerLimit, uint256 _openDuration, uint256 _commitDuration, uint256 _revealDuration ) { owner = msg.sender; ticketPrice = _ticketPrice; playerLimit = _playerLimit; openEndsAt = block.timestamp + _openDuration; commitEndsAt = openEndsAt + _commitDuration; revealEndsAt = commitEndsAt + _revealDuration; phase = Phase.Open; } function participate() external payable { require(phase == Phase.Open, "not open"); require(block.timestamp < openEndsAt, "open ended"); require(msg.value == ticketPrice, "wrong price"); require(players.length < playerLimit, "limit reached"); players.push(msg.sender); emit TicketPurchased(msg.sender, players.length - 1); } function startCommit() external { require(phase == Phase.Open, "invalid phase"); require(block.timestamp >= openEndsAt, "still open"); phase = Phase.Commit; } function commit(bytes32 hash) external { require(phase == Phase.Commit, "not commit phase"); require(commitHashes[msg.sender] == 0, "already committed"); require(isPlayer(msg.sender), "not player"); commitHashes[msg.sender] = hash; emit Committed(msg.sender, hash); } function reveal(bytes32 secret) external { require(phase == Phase.Reveal, "not reveal phase"); require(!hasRevealed[msg.sender], "already revealed"); bytes32 expected = keccak256(abi.encodePacked(msg.sender, secret)); require(commitHashes[msg.sender] == expected, "hash mismatch"); hasRevealed[msg.sender] = true; seed = uint256(keccak256(abi.encodePacked(seed, secret))); emit Revealed(msg.sender, secret); } function draw() external { require(phase == Phase.Reveal, "not reveal phase"); require(block.timestamp >= revealEndsAt, "reveal not ended"); require(allRevealed(), "some players not revealed"); uint256 random = uint256( keccak256(abi.encodePacked(seed, blockhash(block.number - 1))) ) % players.length; winner = players[random]; phase = Phase.Drawn; emit WinnerSelected(winner, random); } function isPlayer(address user) public view returns (bool) { for (uint256 i = 0; i < players.length; i++) { if (players[i] == user) return true; } return false; } function allRevealed() public view returns (bool) { for (uint256 i = 0; i < players.length; i++) { if (!hasRevealed[players[i]]) return false; } return true; } }

逻辑说明:participate把参与者写入players数组,commit保存keccak256(player, secret)的哈希,reveal用同一个哈希校验,然后把 secret 混入seeddraw开奖时再引入上一个区块的blockhash做最终熵源,即使 seed 被猜出,也还需要同时预测区块哈希才能操纵结果。

参数说明:构造函数里ticketPrice用 wei 传,比如0.01 etheropenDurationcommitDurationrevealDuration用秒,30 分钟写1800。实际部署时三个时间窗口不能都设成很短,否则测试还没操作完状态就过期了,我一般部署时 open 给 1 小时,commit 和 reveal 各给 15 分钟。

2.4 奖金领取与取消机制

draw之后合约里已有奖池,直接支持 winner 调一个claim函数领取。但为了展示安全性,建议用withdraw+nonReentrant锁,而不是用transfer硬推。领奖代码里还要处理 winner 长时间不领的情况,可以加一个claimDeadline,超过后由 owner 收回进入下次抽奖。

取消机制在成品里是必须的:如果Open结束后没有人参与,owner 可以调用cancel把奖池返还给所有参与者。这个场景虽然无聊,但论文的边界情况讨论里一定会写,少了它评审会问“资金锁死怎么办”。

// 新增状态 uint256 public claimDeadline; function cancel() external onlyOwner { require(phase == Phase.Open || phase == Phase.Commit, "too late"); phase = Phase.Cancelled; for (uint256 i = 0; i < players.length; i++) { payable(players[i]).transfer(ticketPrice); } }

注意cancel里循环返还 gas 会很高,如果参与者超过几百人就不现实。毕业论文里建议把返还改成每个参与者独立claimRefund,也就是 pull payment 模式,这才是生产项目常见的做法。

3. 事件监听与前端接入:用 ethers.js 把链上数据变成界面

3.1 为什么不直接读合约里的 public 变量

合约里playerswinner都是 public 的,前端当然可以直接调用视图函数。但抽奖平台有大量历史查询,比如“我过去参加了哪些场次”“某场开奖的 entropy 是多少”,每次都遍历players数组非常笨重,而且合约无法按某个用户筛选。

所以前后端之间要用事件日志做查询层:合约每次状态变更都emit一个事件,前端用 ethers.js 的日志过滤器按player地址或ticketId过滤。事件日志落链后不可篡改,天生就是审计线索,答辩时可以说这是“链上行为数据和链下展示层分离”。

3.2 用 ethers.js 监听抽奖事件的完整代码

前端我用ethersv6,写法比 v5 干净一点。先拿到合约实例,再queryFilter拿到历史事件,新事件用contract.on监听。

import { ethers } from "ethers"; import lotteryAbi from "./Lottery.json" assert { type: "json" }; const provider = new ethers.BrowserProvider(window.ethereum); const signer = await provider.getSigner(); const contract = new ethers.Contract( "0xYourLotteryAddress", lotteryAbi.abi, signer ); // 拉取某个玩家参与过的所有抽奖记录 const filter = contract.filters.TicketPurchased(account); const logs = await contract.queryFilter(filter, 0, "latest"); const tickets = logs.map((log) => { const parsed = contract.interface.parseLog({ topics: log.topics, data: log.data, }); return { player: parsed.args.player, ticketId: parsed.args.ticketId.toString(), txHash: log.transactionHash, blockNumber: log.blockNumber, }; });

逻辑说明:filters.TicketPurchased(account)中的account会作为 indexed address 字段的过滤条件,queryFilter数字参数是起始区块和结束区块。parseLog用于把原始日志还原成结构化参数,避免自己拼 hex。

参数说明:queryFilter(0, "latest")如果链上历史事件特别多,会非常慢,前端建议加一个起始区块缓存,第一次拉全量,之后只拉新高度。另外ticketIduint256,JS number 会丢失精度,所以必须用.toString(),这是新手最容易踩的坑。

3.3 抽奖元数据放 IPFS,前端如何拼 CID

合约里只存价格、时间、winner 等核心数据,抽奖名称、奖品图片、参与说明这类非结构化信息适合放 IPFS。做法是把 JSON 文件上传到 IPFS,得到 CID,存进合约的string metadataURI

{ "name": "Lucky Airdrop #1", "description": "Off-chain metadata for lottery round 1", "image": "ipfs://QmXeYcZ4x8KQr9aB3dGv2jH1mR5sLkNpA6BzpJtFcWUq", "attributes": [ { "trait_type": "Ticket Price", "value": "0.01 ETH" }, { "trait_type": "Player Limit", "value": "50" } ] }

前端拼 CID 时,不能直接把metadataURIhttps://用,要判断前缀。现在主流网关是https://ipfs.io/ipfs/https://gateway.pinata.cloud/ipfs/,但国内访问稳定性差异大,所以我通常会封装一个resolveIpfsUrl

function resolveIpfsUrl(cidOrUrl) { if (cidOrUrl.startsWith("ipfs://")) { const cid = cidOrUrl.slice("ipfs://".length); return `https://ipfs.io/ipfs/${cid}`; } return cidOrUrl; }

注意:IPFS 是内容寻址的,元数据一旦上传就不能改,所以写文档时要把「上链信息不可变」和「metadata 也应该不可变」关联起来。如果你为了让图片能换而把 URL 直接写进合约,论文里就没有这个论点了。

3.4 前端连接钱包与购买门票的交互代码

购买门票需要用户切换网络、确认两笔交易:第一笔是授权合约转 ERC20(如果用代币支付),第二笔是调用participate。如果是原生币支付,只调participate即可。

async function purchaseTicket() { if (!window.ethereum) throw new Error("no wallet found"); const network = await provider.getNetwork(); const expectedChainId = 80001; // teleport 测试网 if (network.chainId !== BigInt(expectedChainId)) { await window.ethereum.request({ method: "wallet_switchEthereumChain", params: [{ chainId: "0x13881" }], }); } const options = { value: ethers.parseEther("0.01"), gasLimit: 250000n, }; const tx = await contract.participate(options); const receipt = await tx.wait(); console.log("purchased with tx", receipt.hash); }

逻辑说明:BigInt(network.chainId)是为了和wallet_switchEthereumChain的十六进制链 ID 对照;parseEther("0.01")转成 wei;gasLimit给一个固定值可以避免 MetaMask 估错的某些边界情况,但生产项目应该动态调。

参数说明:0x13881是 Polygon Mumbai 测试网的链 ID 十六进制,如果你跑本地链会用31337十六进制0x89。这里要特别注意,wallet_switchEthereumChain不能用于尚未添加到钱包的自定义网络,需要先wallet_addEthereumChain,毕业设计直播演示前最好把网络预先切好,否则会卡在审批环节。

4. 从源码到高分论文:测试覆盖、文档结构与演示脚本

4.1 用 Hardhat 写断言型测试,覆盖正常流程和边界

链上代码只是项目的一半,另外一半是测试能不能证明合约行为正确。我见过很多项目拿hardhat console.log跑一遍就算测了,这在答辩时站不住。必须有断言、异常路径和时间推进,下面是一个测试骨架。

const { expect } = require("chai"); const { ethers } = require("hardhat"); describe("Lottery", function () { let lottery, owner, player1, player2; beforeEach(async function () { [owner, player1, player2] = await ethers.getSigners(); const Lottery = await ethers.getContractFactory("Lottery"); lottery = await Lottery.deploy( ethers.parseEther("0.01"), // ticketPrice 50, // playerLimit 60, // openDuration 15, // commitDuration 15 // revealDuration ); }); it("should reject wrong ticket price", async function () { await expect( lottery.connect(player1).participate({ value: ethers.parseEther("0.02") }) ).to.be.revertedWith("wrong price"); }); it("should open after openDuration and pick winner", async function () { await lottery.connect(player1).participate({ value: ethers.parseEther("0.01") }); await lottery.connect(player2).participate({ value: ethers.parseEther("0.01") }); await ethers.provider.send("evm_increaseTime", [61]); await ethers.provider.send("evm_mine"); await lottery.startCommit(); const secret1 = ethers.id("player1-secret"); const secret2 = ethers.id("player2-secret"); const hash1 = ethers.solidityPackedKeccak256( ["address", "bytes32"], [player1.address, secret1] ); const hash2 = ethers.solidityPackedKeccak256( ["address", "bytes32"], [player2.address, secret2] ); await lottery.connect(player1).commit(hash1); await lottery.connect(player2).commit(hash2); await ethers.provider.send("evm_increaseTime", [16]); await ethers.provider.send("evm_mine"); await lottery.connect(player1).reveal(secret1); await lottery.connect(player2).reveal(secret2); await ethers.provider.send("evm_increaseTime", [16]); await ethers.provider.send("evm_mine"); await lottery.draw(); expect(await lottery.winner()).to.be.oneOf([player1.address, player2.address]); }); });

逻辑说明:ethers.id生成的是任意字节的 keccak256,不是真正给用户看的明文 secret,但测试里简化用它。时间推进用evm_increaseTimeevm_mine组合,让 Hardhat 网络在一个模拟区块里跳过时间,这一步在 UI 手点测试时很难做,所以必须写脚本验证。

覆盖边界建议:重复 commit、提前 reveal、非参与者 commit、票数超上限、开奖时有人未 reveal、open 阶段直接 draw 等,这些行为都要有断言,测试文件最后注释里写清楚每个用例对应论文第几章。

4.2 论文文档结构:设计、实现和测试如何互相印证

源码压缩包里的「详细文档」如果只是把README复制粘贴,肯定拿不了高分。我一般建议用 5 章结构组织论文,其中第二章和第三章必须对应代码目录:

论文章节对应代码/文档必写内容
绪论与研究现状参考文献部分传统抽奖的信任问题、区块链存证能力
需求分析docs/requirements.md角色、流程、用例图、非功能需求
系统设计contracts/+docs/architecture.md状态机、随机数协议、接口设计
实现与测试test/+ 前端src/关键代码截图、部署脚本、覆盖率报告
总结与展望评审提问清单局限性、可扩展方向

文档里需要有一张「组件部署图」,画清楚 MetaMask 用户、Lottery 合约、IPFS、前端静态页之间的关系。这几部分必须在代码里有真实对应物,评审老师最喜欢指着架构图问“这个盒子在你的项目里是哪个文件”。

4.3 一键演示脚本:本地链、部署、跑通开奖

答辩演示最怕临时敲命令翻车,平时hardhat node起来后频繁切换网络也很浪费时间。我写了一个 bash 脚本,一条命令从头跑到尾:

#!/usr/bin/env bash set -e # 1. 启动本地链 npx hardhat node > /tmp/hardhat-node.log 2>&1 & NODE_PID=$! sleep 3 # 2. 部署合约 DEPLOY_INFO=$(npx hardhat run scripts/deploy.js --network localhost) echo "$DEPLOY_INFO" CONTRACT_ADDRESS=$(echo "$DEPLOY_INFO" | grep -oP 'Lottery deployed to: \K[0-9a-fA-F]+') # 3. 跑完整流程脚本 npx hardhat run scripts/demo.js --network localhost # 4. 输出前端需要配置的地址 echo "CONTRACT_ADDRESS=$CONTRACT_ADDRESS" echo "NEXT_PUBLIC_RPC_URL=http://127.0.0.1:8545" kill $NODE_PID

逻辑说明:set -e让任何一步失败立刻退出,不会带着半启动的服务继续答辩;grep -oP从部署输出里提取合约地址,这样前端环境和演示脚本不用手填地址。

参数说明:--network localhost对应hardhat.config.js里的localhost网络定义,默认 RPC 是http://127.0.0.1:8545。如果演示目标链是测试网,需要把脚本里的deploydemo改为测试网网络名,并在demo.js里用setTimeout等待交易确认,因为测试网出块时间不稳定。

5. 验收前的 5 个检查点:安全、Gas 与可复现性

答辩前最后半天应该做这 5 件事,而不是继续加功能。第一,检查源码里有没有把私钥或助记词写死在配置里。打开hardhat.config.js,把accounts那个数组改成.envPRIVATE_KEY读取,再在.env.example里留一个空白模板,这比任何架构设计都直接影响评审对“工程规范性”的印象。

第二,手工攻击路径复测:试着在 commit 阶段用一个没有参与的地址去 commit,在 reveal 阶段提交错误 secret,在 draw 前故意只 reveal 一半。修复逻辑是require加状态检查,但更重要的是一一记录到测试文件里,让这些路径自动化。我一般会回滚到代码被 review 的版本,跑一遍npx hardhat test,把输出贴进论文附录。

第三,Gas 优化检查。players数组循环遍历是O(n),参与者多到 100 时会有问题。简单优化是把循环里多次读取的变量缓存到局部变量,或者用树状结构生成种子,但毕业设计不追求极致,把participate的 gas 消耗截图放进文档,证明你意识到了 gas 成本问题就足够。

第四,前端链错误处理。切换到 mainnet 导致合约地址不存在、用户拒绝交易、本地链重启后 nonce 错乱,这三类错误在代码里要有try/catch和提示。准备好一个“错误注入”清单:断网、切链、拒绝签名,逐个演示界面不会白屏。

最后一个检查点是可复现性。删掉本地 node_modules 后,别人能不能根据你的 README 从头跑起来?我会在全新目录执行npm ci && npx hardhat node && npx hardhat deploy,同时把 Node 和 npm 版本记录在package.jsonengines字段里。如果出现版本跑不过,就补充一个nvm use命令。全部通过后再重跑一次hardhat coverage,把行号截图放进论文附录,然后回到scripts/demo.js里手动确认一遍开奖的随机数结果,确保演示时不会开出一个空地址。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询