一张彩票真正难的不是「开奖」——而是凭什么相信开奖结果。
我们把这件事做了三种实现:后端开奖、VRF、全链上;另有一个 ticketRoot 草案。
看完这一页,你会知道它们各自怎么跑、各自适合什么。
下面这张表不是技术细节,而是先帮你建立方向感:现在默认用什么、票号谁来分、钱和配置放在哪里、FULL_CHAIN 为什么会更重。
rewardConfigId下面的内容会反复用到这几个词。如果你已经熟,跳过;如果你不熟,花 30 秒看完——后面所有"链上"、"VRF"、"gas" 出现的地方就不会再卡你。
BACKEND 模式。夺宝、彩票、抽奖——所有这类产品的核心矛盾都是同一句话:用户付了钱,怎么相信开出来的号是公平的?
这件事拆开就是三个问题。三种主方案和一个草案的差别,本质是它们对这三个问题的回答不一样。
是服务器自己生成?还是预言机给的?还是参与者一起算的?源头决定了信任根。
开奖之后能被回滚或调整吗?运营方、合约部署者、节点运营商——谁都能动,结果就不算公平。
用户能不能在结果公布后,自己拿到证据证明开奖是真的随机、真的没被改?还是只能选择相信?
重写之前那一版已经在主网跑了一段时间——它的开奖用的就是方案二(VRF)。 把完整玩法拆三块来看:怎么参与、怎么开奖、奖池怎么分。 这里先只看旧版主网本身:用户链上买票,合约维护票号,售完后合约请求 VRF,VRF 回调后合约开奖并分账。
每场("一期")有一个固定的总票数 ticketAll。每张票绑定 1 到 N 之间的一个唯一号码。买票时可以"随机抓号",也可以"手选号码"。
最后一张票卖出的瞬间,合约自动向 Chainlink VRF v2.5 请求一个 256-bit 随机数。VRF 回调后,合约先把随机数对总票数取模,得到 0 ~ ticketAll - 1 的下标;真正查中奖票时再用 winningIndex + 1,所以业务票号仍然是 1 ~ ticketAll,不会出现 0 号票。
block.prevrandaouint256,看起来像 0x9f3a...0011 这种 32-byte 大数winningIndex = randomWord % ticketAll,查票:numberOwner[winningIndex + 1]LOTTERY_EVENT 同步
一期所有买票钱进入同一个池子。开奖后合约按预设的 5 个比例分给 5 个角色——比例用 basis points 制,5 份必须加起来等于 100%。
DENOM = 100000(万分位精度)setRewardConfig() 可调append 倍率玩法叠加放大ⓘ Fisher-Yates 只是“怎么从剩余号里公平拿一个号”的通用算法,不是合约专属技术。旧版看起来像“Fisher-Yates 是合约技术”,只是因为旧版完整业务闭环都在智能合约里完成。
旧版本完整走智能合约:用户买票、票号分配、售完触发、请求 VRF、VRF 回调开奖、奖池分账、下一期启动,都由链上合约状态驱动。
假设一期只有 5 张票。第 5 张票卖出后,合约请求 VRF。真实 VRF 回来的是一个 256-bit 的 uint256 randomWords[0],通常写成很长的十进制整数,或者 32-byte 十六进制,例如 0x9f3a...0011。
为了让取模过程看得懂,下面把这个大数简化成演示值 17。合约算 17 % 5 = 2,这是从 0 开始的下标;再用 2 + 1 = 3 查票号,所以 3 号票中奖。
requestRandomWords(numWords: 1) 请求 1 个随机数;回调里存 randomWords[0];开奖时算 randomWord % ticketAll;最后用 numberOwner[winningIndex + 1] 找到中奖地址。
v1.0 的核心目标是:尽可能简单、安全、可验证、公平。用户买票不被链上操作打断,开奖随机数也要能被链上验证。
平台自己用电脑生成一个数当中奖号——但开奖之前就先把这个数的"指纹"挂出来;开奖之后再亮出原始的数。任何人都能算一下指纹对不对得上,平台想偷换数就会穿帮。
像店主自己摇号,但摇之前先把奖号封在透明信封里挂墙上、开奖时当众拆封——号没法换,但你还是得相信"摇号那一下没人帮店主作弊"。
最简单、最快、最便宜。
技术上:服务器调用 SecureRandom.nextLong(),把结果写进数据库,公布。
类比:你去街角小店参加店主办的抽奖。店主在柜台后摇了个奖,把中奖号写在小黑板上。整个流程——抽奖、记录、公布——都在店主的柜台里完成。你信任店主,所以你信任这个结果。
技术现实里,「店主」是后端服务,「柜台」是数据库。生成、存储、公布全部在公司内部系统完成。用户必须信任运营方不会作弊——这是这种方式最大的特点,也是最大的局限。
SecureRandom.nextLong() 生成一个 64-bit signed long,随机值由后端独占randomValue——但 commit-reveal 兜底设计限制了"开奖后改"(见下方)randomValue,本地算 sha256(randomValue + roundId) 看是否等于开奖前公布的 commitment
新版的 BACKEND 不是完全黑盒——开奖之前后端就先公布 commitment = sha256(randomValue + roundId) 并由 KMS 签名;开奖之后再 reveal 真正的 randomValue。任何人都能本地复算 hash 验证 reveal 的随机值是否就是当初承诺的那个——给"信任运营方"加了一道事后可审计的兜底。
兜底的边界:能防"开奖后篡改随机值",但不能防"开奖前选有利随机值"——后端理论上可以一直生成 randomValue 直到选到对己有利的那个,再公布 commitment。这点要靠场景信任弥补。
积分游戏、营销抽奖、内部测试、品牌可信度高且金额小的场景。Gas 为零,UX 最丝滑。
真金白银、跨地域用户、用户互不认识的 PvP。一旦出现争议,运营方虽有 commit-reveal 自证空间,但仍弱于 VRF。
SecureRandom.nextLong() 返回的是一个 Java long:64-bit、有正有负,真实值可能长得像 -5128840312744306913L,不是只会返回 17 这种小数字。
假设一期有 5 张票,服务器拿这个 long 做取模。因为 long 可能是负数,代码里要用 Math.floorMod(randomValue, ticketAll),不要直接用 randomValue % ticketAll 当业务票号。
Math.floorMod(-5128840312744306913L, 5) = 2,这是 0 基下标;业务票号仍然是 2 + 1 = 3。
import java.security.SecureRandom; public final class BackendDrawEngine { private static final SecureRandom RNG = new SecureRandom(); public DrawResult draw(long roundId, int ticketAll) { if (ticketAll <= 0) { throw new IllegalArgumentException("ticketAll must be positive"); } long randomValue = RNG.nextLong(); // 64-bit signed long, may be negative int winningIndex = Math.floorMod(randomValue, ticketAll); // 0..ticketAll-1 int ticketNo = winningIndex + 1; // business ticket: 1..ticketAll return new DrawResult(roundId, randomValue, ticketAll, winningIndex, ticketNo); } } record DrawResult( long roundId, long randomValue, int ticketAll, int winningIndex, int ticketNo ) {}
nextLong() 可能返回负数,所以这里用 Math.floorMod。不要写 Math.abs(randomValue) % ticketAll,因为 Long.MIN_VALUE 取绝对值仍然会溢出。
Chainlink VRF 是区块链常用的"可验证随机数服务"。平台不是自己在服务器里偷偷摇号,而是让链上的合约去请求 Chainlink VRF;它返回随机数时,会同时带一份数学证明,证明这个数确实是它按这次请求生成的。
小白可以这样理解:普通后端开奖像"店主自己摇号";Chainlink VRF 像"摇号机自带防伪码"。数字出来后,链上合约会先验防伪码,验过才接受这个随机数;验不过就拒绝。
它解决的是随机数来源可信:平台不能提前知道随机数,也不能在随机数出来后偷偷改。但在 VRF_ONLY 里,奖金到账仍然走平台余额和提现流程,不是直接打进链上钱包。
VRF 全称是 Verifiable Random Function,意思是"可验证随机函数"。它不是人工审核,也不是平台自己生成随机数;它是一套链上可验证的随机数机制。
这件事分三步:合约发起请求,Chainlink VRF 返回 randomWord 和 proof,合约验证 proof 通过后才把这个随机数认作有效。VRF_ONLY 的关键设计是:只把"出随机数"这一步上链——票务管理、派奖结算都在后端 game-service / wallet-service 完成。用户买票零 gas,开奖结果链上可查。
类比:抽奖时不再让店主自己摇。合约把"我要给这一期开奖"这个请求发给 Chainlink VRF。Chainlink VRF 返回一个数字,同时附上防伪证明。任何人拿到结果,都能确认这个数字是不是这次请求对应出来的。
技术上,Chainlink VRF 是去中心化预言机网络提供的随机数服务。防伪证明更准确说是 ECVRF proof。链上合约只接受能通过 VRF 验证的随机数——不通过验证的,合约自己就拒绝,没有人工干预空间。
requestId / randomWord / 回调交易,再用 randomWord % soldCount 复算中奖票号
VRF_ONLY 不是“没有智能合约”。Chainlink VRF 必须由链上的 consumer contract 发起请求并接收回调。后端不能直接找 Chainlink 要随机数;后端只能调用或协调 DrawCoordinator,再由合约请求 VRF、接收 randomWord、emit event,最后由 chain-service 监听事件同步给 game-service。
主网生产、有真实金额、要可验证又不想 gas 太高。Chainlink VRF 在 EVM 链上是事实标准。
仍然要信任 VRF 网络的诚实性(节点 collusion)。响应有 1–3 分钟延迟,开奖瞬间感不如方案一即时。
小白不用理解 ECVRF 公式,按下面流程核对即可。核心思路是:先确认随机数真来自 Chainlink VRF,再用同一个公式复算中奖票号。
roundId、chainId、DrawCoordinator 合约地址、requestTxHash、fulfillTxHash、requestId、randomWord、soldCount、平台公布的中奖票号。
roundId、requestId、randomWord、soldCount 和两个交易 hash。requestTxHash 打开区块浏览器,确认交易调用的是项目公布的 DrawCoordinator,事件里能看到同一个 roundId / requestId。fulfillTxHash 打开区块浏览器,确认回调来自 Chainlink VRFCoordinator,并且写入或 emit 的 requestId / randomWord 与开奖结果页一致。winningIndex = randomWord % soldCount,再算 ticketNo = winningIndex + 1。这里 +1 是因为业务票号从 1 开始。ticketNo 必须等于平台公布的中奖票号;如果不一致,说明平台公布结果有问题。ticketNo。如果持有,就应该中奖;如果没持有,就不是本期中奖人。边界要讲清楚:VRF_ONLY 能验证"随机数没被平台编造或偷换";但票表归属和平台内账派奖仍主要依赖后端记录。要连票表和领奖都链上验证,需要方案三 FULL_CHAIN 或方案四草案这类更重的设计。
ⓘ 一句话总结:随机数还是用 Chainlink VRF,但其他全部移到链下。换来的好处是用户买票零 gas、UX 更顺;代价是派奖那一段失去了"无需信任运营方"的强保障。
// ── 售完触发 / on sold-out (后端) ── chainService.requestVrf(roundId) └→ DrawCoordinator.requestRandom(roundId) // 链上调 Chainlink VRF └→ VRFCoordinatorV2Plus.requestRandomWords(...) // ── 1-3 分钟后 VRF 链上回调 (合约) ── DrawCoordinator.fulfillRandomWords(reqId, [word]) └→ emit RandomWordsFulfilled(roundId, word) // 链上事件 // ── chain-service 监听到事件 → Kafka → game-service ── gameService.onVrfFulfilled(roundId, word): winNo = (BigInteger(word) % soldCount) + 1 winner = ticket[winNo].uid walletService.credit(winner, prize) // 后端钱包派奖 publish(game.draw.completed{roundId, winner})
方案三改成和旧版一致:用户直接在链上买票,合约自己维护票号、奖池、开奖和分账。售完后合约请求 Chainlink VRF,VRF 回调后合约算中奖票号并直接分账。
小白可以理解成:整个抽奖摊位都搬到区块链上。买票、票号、开奖、奖池怎么分,全都按合约规则走;平台不能在开奖后改票,也不能跳过合约私下分账。
方案三就是旧版主网那种纯链上玩法:链上买票、合约分号、售完即开、VRF 回调、合约分账、下一期自动启动。
这个模式不追求买票零 gas,而是追求"全流程都在合约里"。票号归属在合约状态里,随机数来自 Chainlink VRF,中奖号和分账都由合约执行。
类比:不是平台先收钱、记票、开奖、再发奖;而是每个用户都直接把买票交易发到合约。合约像一个公开自动运行的抽奖机,谁买了哪张票、什么时候售完、随机数是什么、奖金怎么分,都能在链上查。
技术上:合约用 Fisher-Yates 维护剩余票号;最后一张票卖出时,合约请求 Chainlink VRF;VRF 回调后,合约用 randomWord % ticketAll 得到中奖下标,再用 +1 查业务票号和中奖地址,最后按合约配置分账。
取舍:这是最接近旧版的 FULL_CHAIN,但用户每次买票都要付 gas,也要理解钱包、授权和链上交易确认。
randomWord % ticketAll 结果和链上分账事件高 TVL 场、Web3 原生玩家、愿意为强可验证性接受 gas 成本和链上操作复杂度的玩法。
买票体验最重:每次买票都要链上交易、授权和 gas;合约逻辑复杂,升级和安全审计成本高。
ⓘ 一句话总结:方案三回到旧版的完整链上闭环;代价是用户买票必须发链上交易。
// ── 用户链上买票 / every purchase is on-chain ── TreasureContract.buy(ticketCount, optionalNumber) └→ transfer USDT into contract └→ assign ticketNo by Fisher-Yates └→ store numberOwner[ticketNo] = buyer // ── 售完即开 / sold out triggers VRF ── if remainingTicket == 0: requestId = VRFCoordinatorV2Plus.requestRandomWords(...) // ── VRF 回调后合约开奖和分账 / fulfill on-chain ── rawFulfillRandomWords(requestId, randomWords): randomWord = randomWords[0] winningIndex = randomWord % ticketAll ticketNo = winningIndex + 1 winner = numberOwner[ticketNo] settleDraw(winner, rewardConfig) // 中奖人、推荐人、平台、社区、滚动奖池 launchNextRoundIfNeeded()
方案四是折中方案:用户买票仍然走后端,所以买票可以零 gas;但售完后,后端必须先把整场票表做成一个不可篡改的指纹 ticketRoot 放到链上,然后才能请求 Chainlink VRF。
小白可以理解成:开奖前先把所有票根拍成一张"防伪名单指纹"交给链上保管。随机数出来后,中奖人拿自己的票根和证明去合约验,验得过,合约金库才付款。
方案四不是替代方案三,而是另一个未来草案:后端售票负责体验,链上 ticketRoot 负责防篡改,Vault 负责奖金可达。
关键规则只有一条:合约必须强制 ticketRoot 在请求 VRF 之前上链。如果随机数出来后才允许上传 ticketRoot,后端仍然可以换票表,那就不可信。
票表怎么变成 root:每张票做成一个 leaf,例如 hash(roundId, ticketNo, userId, wallet);同一期内 ticketNo 必须唯一。所有 leaf 组成 Merkle tree,树根就是 ticketRoot。只要任何一张票被改,root 就会变。
中奖怎么验:VRF 回来后得到中奖票号。中奖者提交自己的票据 leaf 和 merkleProof,合约验证这个 leaf 确实属于链上锁定的 ticketRoot,并且票号等于中奖号,才从 Vault 付款到 leaf 里的 wallet。
ticketRoot 是否早于 VRF 请求上链,再用 ticketLeaf + merkleProof 验自己的票是否在锁定票表里// ── 售完触发 / on sold-out (后端) ── tickets = loadTickets(roundId) ticketRoot = merkleRoot(tickets) // leaf = hash(roundId, ticketNo, userId, wallet) DrawCoordinator.commitTickets(roundId, ticketRoot, soldCount) // ── root 上链后才能请求 VRF ── DrawCoordinator.requestRandom(roundId) require(ticketRoot[roundId] != 0) require(vrfRequestId[roundId] == 0) // ── VRF 回调锁定中奖票号 ── fulfillRandomWords(reqId, [word]): winNo = (word % soldCount) + 1 store(roundId, winNo) // ── 中奖者或代领服务提交 proof,合约验票后付款 ── claim(roundId, ticketNo, userId, wallet, proof): leaf = hash(roundId, ticketNo, userId, wallet) require(ticketNo == winNo) require(MerkleProof.verify(proof, ticketRoot, leaf)) require(!claimed[roundId]) claimed[roundId] = true Vault.payout(wallet, prize)
没有「最好」的答案——只有「适合什么场景」。下表是同一把尺子下的相对比较(条越长越强)。
"哪个最安全"不是一个有意义的问题——安全是一束维度。一种方案可能在「随机不可预测」上 10 分满分、但在「奖金可达性」上是 3 分。把每个维度拆开看,才能挑对场景。
下表比较的是旧版主网 + 三种主方案;方案四仍是草案,暂不纳入安全评级。● = 强保障,● = 有兜底但需信任某方,● = 这个维度是这个方案的弱点。
ⓘ 「奖金可达性」是 BACKEND 和 VRF_ONLY 最大的弱点——这两种方案下,奖金以平台余额形式存在,平台跑路或被冻 = 用户拿不回。FULL_CHAIN 走旧版全链闭环,能解决兑付信任问题,但买票体验和合约复杂度都会明显上升。
看完安全对比,你大概会问:FULL_CHAIN 在「票号链上记录」「奖金可达」「合约分账」几个维度都更强——为什么不所有场都用它?
这里只看技术安全:FULL_CHAIN 的信任边界更硬,但它把风险集中到智能合约和链上依赖里。一旦合约、VRF、代币转账或升级权限出问题,修复成本比后端方案高很多。
FULL_CHAIN 里买票、票号、开奖、分账都在合约里。只要有重入、权限判断、取模边界、数组/映射状态、转账失败处理这类 bug,影响的不是页面显示错误,而是链上资金和开奖结果。
如果合约可升级,owner / multisig / timelock 就是高价值攻击面:私钥泄露或治理失误可能改掉核心逻辑。如果合约完全不可升级,发现漏洞后又很难修。两边都不是免费午餐。
FULL_CHAIN 仍依赖 Chainlink VRF 回调、USDT 合约行为、目标链稳定性和 gas 市场。如果 VRF 长时间不回调、USDT transfer 行为异常、链拥堵,合约必须有明确的 timeout、retry、refund 或 pause 策略。
FULL_CHAIN 至少有售票中、售完待请求、等待 VRF、开奖中、已分账、退款、重试、暂停等状态。每个状态都要限制谁能调用什么函数,重复调用、乱序调用、跨期调用都要防住。
FULL_CHAIN 下奖池资金锁在合约里,只能按合约规则流动。如果分账配置写错、黑名单逻辑误伤、收款地址不可用、某个 token 行为不兼容,资金可能卡在合约状态里。
所以技术策略不是"永远不用 FULL_CHAIN",而是:低风险场景先用 BACKEND / VRF_ONLY 跑通;FULL_CHAIN 只在合约审计、权限治理、VRF 超时、退款、分账和资金救援方案都完整后,再用于高价值场。
引擎层我们抽象了一个统一的 DrawEngine 接口,三种实现作为可插拔的 strategy。运行时通过 Nacos 配置项 one.draw.mode 切换——不改代码、不重启上下文,就能把同一个游戏在不同环境用不同方式开奖。
env × chain 正交:环境(dev / staging / prod)和链类型(Sepolia / BSC / Base / Solana)是两个独立维度,开奖模式跟着场景需要选,不绑死。