简介:这是一份面向计算机相关专业毕业生的区块链方向完整毕业设计项目,主题为基于区块链的去中心化抽奖平台。项目源码已经过本地编译并正常运行,评审得分在95分以上,难度适中,兼顾智能合约编写、去中心化应用交互与区块链底层原理学习。压缩包共2000个文件、约4.64MB,以1700个JavaScript文件和120个Markdown文档为主,另有94个C头文件、19个C源文件及JSON、Java、Shell、CSS等配置与辅助资源,覆盖从底层密码学算法到上层应用展示的完整链路。已有96人学习,适合作为毕业设计参考、区块链技术课程设计或入门实践的完整素材。源码、文档、测试与配置齐全,目录结构清晰,便于按模块阅读、修改和二次开发。
1. 去中心化抽奖的第一性问题:随机数不能被信任
中心化抽奖平台的运营者手里握着两把作弊钥匙:随机数生成器是黑盒,用户余额存在自建数据库。想让自己人中奖,只是一个 if 语句的事。基于区块链的去中心化抽奖平台要拆掉这两把钥匙,思路是把规则写进智能合约,随机数在链上生成,奖池由合约托管,任何人都能重放整个流程验证公平性。解包这套毕业设计源码时,根目录下同时出现 secp256k1.c、KeccakP-1600-inplace32BI.c、org_bitcoin_NativeSecp256k1.c,说明它不是套个 Web 壳子,而是把椭圆曲线签名和 Keccak 哈希真正落到了 Java 后端与合约层。对准备答辩或想跑通一个完整 DApp 工程的人来说,这套代码展示了抽奖、加密、链上交互三部分是怎么拼接的。
2. 随机数生成方案选型与 Commit-Reveal 合约实现
2.1 链上随机数的难点不在随机,而在可验证
很多区块链抽奖 demo 直接用block.timestamp、block.difficulty或blockhash当随机种子,这类写法在演示环境跑得通,但拿上台面就是作弊口子。block.timestamp由矿工或验证者写入,出块时间允许小幅偏移;blockhash虽然看起来不可预测,但它对矿工来说是已知的,矿工可以尝试构造多个候选块,挑一个对自己最有利的结果广播。抽奖是单次开奖、单次定输赢的场景,这种可预测性已经足够威胁公平性。
要解决的不是“能不能随机”,而是“随机结果出来后,参与者能不能验证这个结果没有被任何一方操纵”。去中心化抽奖对随机数的要求有三个层次:不可预测、不可操纵、可验证。前两者靠密码学,第三个要靠合约把整个生成过程公开在链上。
2.2 常用方案对比与选型理由
| 随机数方案 | 信任假设 | 链上成本 | 抗偏置能力 | 适用场景 |
|---|---|---|---|---|
| block.timestamp / blockhash | 信任验证者不操纵 | 最低 | 弱 | 演示项目 |
| RANDAO | 信任最后一个参与者诚实 | 中 | 中 | 早期 PoS 项目 |
| Commit-Reveal + 链上混合 | 信任多数参与者及时揭示 | 中 | 中 | 中小规模抽奖、教学项目 |
| Chainlink VRF | 信任预言机节点 | 高 | 强 | 生产级抽奖 |
这套源码包里既然带了完整的 ECDSA 和 Keccak 实现,设计者选择 Commit-Reveal 是合理的:不需要引入预言机服务,不需要为随机性单独付 gas,随机种子由所有参与者的秘密共同贡献。代价是每个参与者必须在揭示窗口内行动,有人不揭示整局就会卡住,所以合约里必须有时间锁和惩罚设计。
2.3 合约中的提交与揭示实现
Commit-Reveal 的核心流程分两段。参与者在提交阶段发送承诺值commitment = keccak256(secret, nonce),合约只存哈希;等所有人提交完毕,参与者再公开secret和nonce,合约重新计算哈希并与承诺比对。这样任何人在提交阶段都看不到其他人的秘密,也就没法针对性地改变自己的选择。
// SPDX-License-Identifier: MIT pragma solidity ^0.8.17; contract Lottery { enum Phase { Open, Commit, Reveal, Closed } Phase public phase; mapping(address => bytes32) public commitments; address[] public players; uint256 public seedAccumulator; modifier onlyPhase(Phase p) { require(phase == p, "wrong phase"); _; } function commit(bytes32 h) external onlyPhase(Phase.Commit) { require(h != bytes32(0), "empty commitment"); if (commitments[msg.sender] == bytes32(0)) { players.push(msg.sender); } commitments[msg.sender] = h; } function reveal(string calldata secret, uint256 nonce) external onlyPhase(Phase.Reveal) { bytes32 h = keccak256(abi.encodePacked(secret, nonce)); require(h == commitments[msg.sender], "reveal mismatch"); seedAccumulator ^= uint256(h); } function finalize() external onlyPhase(Phase.Closed) { require(players.length > 0, "no players"); uint256 winnerIndex = seedAccumulator % players.length; address winner = players[winnerIndex]; // 教学演示:把奖池余额转给 winner (bool sent, ) = winner.call{value: address(this).balance}(""); require(sent, "transfer failed"); } }代码里commit用bytes32存承诺,reveal时把secret和nonce一起编码后取keccak256。重点在seedAccumulator ^= uint256(h):用异或混合所有揭示值,只要有一个参与者的秘密是不可预测的,最终种子对其他人就不可预测。nonce的作用是防止字典攻击,secret建议用openssl rand -hex 32这类方式生成,长度至少 32 字节,否则短字符串很容易被暴力枚举出来。
2.4 时间锁与防卡死设计
Commit-Reveal 的一个现实问题是“有人提交了但就是不揭示”,整局游戏会卡在 Reveal 阶段。常见做法是给每个阶段设置 deadline,超过时间后容许调用finalize按已揭示的参与者开奖,或者没收未揭示者的押金分给诚实参与者。源码包里的实现也遵循了这个模式:合约记录commitDeadline和revealDeadline,每个函数入口用block.timestamp与 deadline 比较,超时即拒绝操作。
提示:Commit-Reveal 只能让偏置成本变大,不能绝对杜绝偏置。生产环境建议直接接 Chainlink VRF;教学项目里保留 Commit-Reveal 是为了把密码学过程透明地展示在链上。
3. secp256k1 签名与 Keccak 哈希:底层加密库的编译与调用
3.1 这两个库在抽奖平台里承担什么
源码包里的secp256k1.c、org_bitcoin_NativeSecp256k1.c是 libsecp256k1 的 C 实现和 JNI 桥接层,KeccakP-1600-inplace32BI.c、KeccakP-1600-opt64.c是 Keccak 置换内核的两种平台优化实现。它们不是无关紧要的第三方代码,而是平台身份验证和哈希承诺的基石。
在 EVM 生态里,交易签名本身就走 secp256k1 曲线;合约里ecrecover能从签名中恢复出签名者地址。链下如果要生成钱包、离线签名、或者校验某个地址是否真的提交过某个承诺,就必须使用同一套加密原语,否则签名格式对不上。而 Keccak-256 承担的是承诺计算和地址派生:以太坊地址就是keccak256(公钥)[12..32]这 20 个字节。只要项目还在用 Solidity 合约,链下计算就必须用与链上一致的 Keccak,而不是标准 SHA-256。
3.2 编译 native 库与模块参数
这套源码的 Java 后端通过 JNI 调用 C 层,本地编译时推荐按下面的参数配置:
cd src/main/native/libsecp256k1 ./autogen.sh ./configure --enable-jni --enable-module-recovery --enable-experimental make -j4--enable-jni会生成org_bitcoin_NativeSecp256k1对应的 JNI 接口,没有这个参数,Java 侧只能拿到 C API,无法直接调用。--enable-module-recovery打开公钥恢复模块,这是交易签名和ecrecover的基础,关闭后签名恢复函数不可用。--enable-experimental用于放宽编译告警,遇到个别宏未定义时可以打开。编译完成后把生成的.so或.dylib放到java.library.path指向的目录,或者直接用System.load("/绝对路径/libsecp256k1.so")加载。
3.3 Java 侧的签名与验签
import org.bitcoin.NativeSecp256k1; import org.bouncycastle.util.encoders.Hex; NativeSecp256k1.init(); byte[] privateKey = Hex.decode("你的私钥hex字符串"); byte[] messageHash = keccak256(sealedData); // 32 字节 byte[] signature = NativeSecp256k1.secp256k1_ecdsa_sign(messageHash, privateKey); boolean ok = NativeSecp256k1.secp256k1_ecdsa_verify(messageHash, signature, publicKey);secp256k1_ecdsa_sign接收 32 字节的消息哈希和 32 字节的私钥,返回的签名是 64 字节的r || s。这里有个容易踩的坑:链上ecrecover需要 65 字节签名,前 64 字节是r和s,最后 1 字节是 recovery id。如果 Java 侧只拿到 64 字节,必须额外做一次签名恢复来补出 recovery id,或者调用secp256k1_ecdsa_sign_recoverable这一个支持可恢复签名的接口。验签时公钥可以是压缩格式(33 字节)或未压缩格式(65 字节),地址派生必须用未压缩格式再取哈希,否则算出的地址和前端钱包地址对不上。
3.4 Keccak-256 与 SHA3-256 的差异
很多人在这个点上吃过亏:Java 的MessageDigest.getInstance("SHA3-256")算出来和 Solidity 的keccak256对不上。原因是 Keccak 原始算法和最后被 NIST 标准化的 SHA3 使用了不同的填充字节,前者是0x01,后者是0x06,输出完全不同。
| 算法 | 填充规则 | 链上对应函数 |
|---|---|---|
| Keccak-256 | 0x01 | Soliditykeccak256、以太坊地址 |
| SHA3-256 | 0x06 | NIST 标准 SHA3 |
| SHA-256 | 另起填充 | Bitcoin 相关计算 |
Java 侧要和 Solidity 结果对齐,得用 BouncyCastle 的Keccak.Digest256:
import org.bouncycastle.jcajce.provider.digest.Keccak; byte[] data = "abc".getBytes(StandardCharsets.UTF_8); byte[] hash = new Keccak.Digest256().digest(data);后端在做承诺校验、地址派生、事件摘要时都要检查本地用的是不是 Keccak-256。源码包里同时出现KeccakP-1600-inplace32BI.c和KeccakP-1600-opt64.c,分别针对 32 位和 64 位平台优化,编译时按目标平台选择其一即可,不要在 Java 侧混用两种实现。
4. 抽奖平台从部署到联调:状态机与完整流程
4.1 本地链环境与 Hardhat 配置
联调阶段我建议用 Ganache 做本地链、Hardhat 做编译部署工具。Ganache 即时出块,且支持evm_increaseTime手动推进时间,正好用来测 deadline 相关的分支。Hardhat 配置文件里只需要一个网络指向本地节点:
module.exports = { solidity: "0.8.17", networks: { ganache: { url: "http://127.0.0.1:7545", accounts: [process.env.PRIVATE_KEY], }, }, };PRIVATE_KEY用环境变量注入,别写死在配置文件里。Ganache 启动时默认会创建 10 个带测试 ETH 的账户,参与抽奖时可以用不同账户模拟多个用户。这样做的好处是每一笔交易都能在区块浏览器里对应到独立地址,评审时更容易解释“谁提交了承诺、谁揭示了结果”。
4.2 抽奖合约状态机与部署脚本
整套抽奖逻辑是一个有限状态机:Open 等待启动,Commit 收集承诺,Reveal 校验并混合种子,Closed 开奖转账。每个状态之间的转换必须由明确的函数调用触发,并且受 deadline 限制。
| 阶段 | 入口函数 | 前置条件 | 结束后进入 |
|---|---|---|---|
| Open | start(config) | 合约刚部署 | Commit |
| Commit | commit(h) | 启动后、TTL 未到 | Reveal |
| Reveal | reveal(secret, nonce) | 已存在有效承诺 | Closed |
| Closed | finalize() | 奖池有余量 | 奖池转移,流程结束 |
部署脚本用 Hardhat 自带的 ethers 接口:
// scripts/deploy.js const hre = require("hardhat"); async function main() { const Lottery = await hre.ethers.getContractFactory("Lottery"); const lottery = await Lottery.deploy(); await lottery.deployed(); console.log("Lottery address:", lottery.address); } main().then(() => process.exit(0));部署后把合约地址记录下来,前端 Web3 连接和事件重放脚本都要用。不要直接用 Remix 部署线上节点再手动复制地址,自动化脚本能保证每次联调用的都是最新编译产物,避免“改了一行代码但链上跑的还是旧合约”这种低级问题。
4.3 端到端联调与常见坑
完整流程可以拆成四条命令:
# 1. 启动本地链 npx ganache -p 7545 --chain.chainId 1337 # 2. 部署合约 npx hardhat run scripts/deploy.js --network ganache # 3. 参与抽奖:提交承诺、揭示、开奖 node scripts/play.js --action commit --contract 0x... node scripts/play.js --action reveal --contract 0x... # 4. 验证 winner 地址 node scripts/play.js --action winner --contract 0x...联调中常见的问题有这么几个。第一是 nonce 冲突,用同一个账户连续发起多笔交易时,客户端如果没有自动管理 nonce,会出现交易被替换或卡在 pending;解决办法是在交易参数里显式设置nonce: provider.getTransactionCount(address)。第二是 deadline 判断,合约里如果用了绝对时间戳,而本地链的时间没有同步,会导致合约一启动就进入 Closed 状态;排查时先看commitDeadline - block.timestamp的值。第三是reveal时传入的secret如果是中文,编码后字节流和提交时不一致,建议统一用十六进制字符串。这几个问题在源码包的排错文档里都有对应说明,评审时也是高频提问点。
5. 验证抽奖公平性:链上事件重放与结果自校验脚本
5.1 事件日志是链上唯一可信的真相
finalize()执行之后,链上只会留下一个 winner 地址,但这不足以证明开奖过程诚实。要证明“winner 不是合约 owner 暗箱指定”,必须回到事件日志里,把每个参与者的Committed和Revealed重新拉出来校验一遍。事件日志由交易收据持久化,任何节点都无法篡改,这是合约层给出的最直接的验证凭证。
5.2 事件重放与哈希一致性校验脚本
下面这个脚本会拉取合约的全部Committed和Revealed事件,对每个揭示数据重新计算 Keccak-256,与提交事件里的承诺值比对:
// verify.js const { ethers } = require("ethers"); async function verify(contractAddress, abi, rpcUrl) { const provider = new ethers.providers.JsonRpcProvider(rpcUrl); const lottery = new ethers.Contract(contractAddress, abi, provider); const [commits, reveals] = await Promise.all([ lottery.queryFilter("Committed"), lottery.queryFilter("Revealed"), ]); for (const e of reveals) { const { participant, secret, nonce } = e.args; const expected = ethers.utils.keccak256( ethers.utils.defaultAbiCoder.encode( ["string", "uint256"], [secret, nonce] ) ); const chainCommit = commits.find( (c) => c.args.who === participant )?.args.commitment; if (expected !== chainCommit) { throw new Error(`address ${participant} 的揭示与承诺不一致`); } } console.log(`校验通过,共 ${reveals.length} 个有效揭示`); } verify(process.argv[2], ABI, process.argv[3]).catch((e) => console.error(e));abi从artifacts/contracts/Lottery.sol/Lottery.json里读取,rpcUrl指向本地节点或已部署链的 RPC 地址。脚本返回值非零说明有参与者揭示造假,此时整个开奖结果不可信,需要回滚或重新开奖。生产环境里可以把这个脚本接进 CI,每次合约地址更新后自动跑一遍校验,归档验证报告。
5.3 把校验脚本做成链下监控任务
更进一步的用法是把校验从“手动执行”变成“定时巡检”。开奖结束后的一个小时内是最容易出现质疑的时间窗口,挂一个 cron 任务每小时检查一次承诺与揭示的一致性,异常时写日志告警:
0 * * * * node /opt/lottery-ops/verify.js 0x合约地址 http://127.0.0.1:8545 > /var/log/lottery-verify.log 2>&1脚本返回非零码时告警系统会收到通知,运营人员再结合区块浏览器确认数据是否被构造或覆盖。这套机制把“去中心化”从口号变成可操作的技术流程:合约负责执行,链下脚本负责监督,互相制衡。
本文还有配套的精品资源,点击获取