一个基于 Chainlink VRF v2.5 与 Automation 的自动化彩票合约

· 10 分钟 · web3 soliditychainlinkvrffoundryhardhatblockchain

你能在链上运行一个没人能操纵的彩票吗——玩家不能、矿工不能,连部署它 的人也不能?这就是我写 Raffle.sol 时想回答的问题,也是这个项目要 用区块链预言机(oracle)的根本原因。

简短的回答是可以,但带着两个诚实的条件。随机数必须来自合约本身无法 预测、也无法重掷的地方;开奖必须没有人类按下按钮——因为一个能决定 「何时」开奖的人,「何时」本身就已经是一种攻击。这篇文章会讲合约 本身、让开奖挂掉的 bug、我是怎么测试它的,以及重做时我会改什么。 这是一个学习项目,不是处理真钱的线上代码——我开门见山说出来, 因为这篇文章的可信度就靠它。

为什么这是预言机少数真正有用的场景之一

合约无法自己产生随机数。当前区块的 blockhash 可以预测, block.timestamp 由挖出区块的人决定,任何纯 Solidity 的「随机」函数 都是确定性的——每个玩家都能重算出来。链上彩票因此需要一个预言机, 而这是少数几个预言机不是弱点、反而是全部意义所在的场景。

Chainlink VRF(可验证随机函数)返回一个随机数,并附带一份合约在链上 验证的证明。合约在数字到达之前无法预测它,到达之后也无法重掷—— 数字在开奖之前就已承诺,而计算出它的模块看不到谁参加了。这正是彩票 需要的性质,也是任何区块哈希都给不了的性质。

另一半是 Chainlink Automation。节点按定时器观察合约,调用 checkUpkeep;当它说「对,现在开奖」,节点就调用 performUpkeep。 合约自己的文档注释把目标说得很清楚:

// Enter the lottery (paying some amount)
// Pick a random winner (verifiably random)
// Winner to be selected every X minutes -> completely automated
// Chainlink Oracle -> Randomness, Automated Execution (Chainlink Keeper)

没有 keeper 节点,没有人类——合约回答「现在该抽出赢家吗?」这个问题, 预言机按答案行动。

我试了什么,以及那个让开奖挂掉的 bug

项目叫 hardhat-smartcontract-lottery:一个 Raffle 合约, 用 Hardhat 和 Foundry 两套工具测试,部署并验证在 Sepolia 上。 搭建过程是常规的 VRF v2.5 流程:在 vrf.chain.link 创建订阅、充值、部署 consumer、把它 加进订阅,然后在 30 秒间隔上注册一个 Automation upkeep(间隔、0.01 ETH 入场费、50 万 gas 的回调上限,都放在 HelperConfig 里)。

开奖挂了,而且在合约内部完全看不见。在本地,VRF mock 毫无怨言地接受 了请求——然后 fulfill 回滚了。仓库自己的历史记录了这次失败——修复落 在一个标题为 “Fixed InsufficientBalance VRF Mock” 的提交里——痕迹至今 留在测试文件里。Foundry 测试里 fulfillRandomWords 调用旁的注释只写 着 // InsuficientBalance(),hardhat 测试里有一条更长、更惨的: // Here cannot run, always InsufficientBalance()。

我当初没明白的是:mock 并不比主网宽松——它执行着同样的不变量。在它 愿意服务请求之前,订阅必须存在、必须有余额、必须把彩票合约注册为 consumer,而且 LINK 必须真的流动。mock 和 coordinator 一样记录订阅 余额——JS 测试会读 getSubscription(...).balance 回来确认——而在 真实链上,你的钱包用 transferAndCall 把 LINK 发给 coordinator, 订阅才会入账。我的订阅要么是空的,要么 consumer 还没挂上,于是 fulfill 被合理地弹了回来。

修复在测试的 setUp 里:Foundry 的 setUp() 在任何测试运行之前铸造 100 个 LINK 并为订阅充值(LINK_BALANCE = 100 ether):

vm.startPrank(msg.sender);
if (block.chainid == LOCAL_CHAIN_ID) {
    link.mint(msg.sender, LINK_BALANCE);
    VRFCoordinatorV2_5Mock(vrfCoordinatorV2_5).fundSubscription(
        subscriptionId,
        LINK_BALANCE
    );
}
link.approve(vrfCoordinatorV2_5, LINK_BALANCE);
vm.stopPrank();

部署脚本在真实链上做同样的事——FundSubscription 用 transferAndCall 送出 FUND_AMOUNT(3 个 LINK):

LinkToken(linkToken).transferAndCall(vrfCoordinatorV2_5, FUND_AMOUNT, abi.encode(subId));

hardhat 部署脚本会给刚创建的订阅充值。这次教训花了我一个晚上: VRF mock 要求订阅先有资金才愿意服务请求,而 checkUpkeep 自己的 文档注释也隐晦地这么写——“Implicity, your subscription is funded with LINK.” 随机数不是免费的,连在 mock 里也不是。

修复:逐段讲解合约

Raffle 继承自两个 Chainlink 合约,都来自 v2.5 线:

import {VRFConsumerBaseV2Plus} from "@chainlink/contracts/src/v0.8/vrf/dev/VRFConsumerBaseV2Plus.sol";
import {VRFV2PlusClient} from "@chainlink/contracts/src/v0.8/vrf/dev/libraries/VRFV2PlusClient.sol";
import {AutomationCompatibleInterface} from "@chainlink/contracts/src/v0.8/automation/interfaces/AutomationCompatibleInterface.sol";

contract Raffle is VRFConsumerBaseV2Plus, AutomationCompatibleInterface {

构造函数接收 coordinator 地址、订阅 ID、gas lane(key hash)、间隔、 入场费和回调 gas 上限,除了必须变化的状态外全部 immutable。两个 常量很关键:REQUEST_CONFIRMATIONS = 3 和 NUM_WORDS = 1—— 开奖只需要一个随机词,且要等三个区块确认。

入场。 enterRaffle 刻意保持无聊:要么付足够的钱,否则回滚 Raffle__NotEnoughETHEntered;要么处于 OPEN 状态,否则回滚 Raffle__RaffleNotOpen。然后把发送者压栈,发出事件:

if (msgValue < i_entranceFee) {
    revert Raffle__NotEnoughETHEntered();
}
if (s_raffleState != RaffleState.OPEN) {
    revert Raffle__RaffleNotOpen();
}
s_players.push(payable(msg.sender));
emit RaffleEnter(msg.sender);

决定是否开奖。 checkUpkeep 用一行写完了整个 Automation 合约:

bool isOpen = RaffleState.OPEN == s_raffleState;
bool timePassed = ((block.timestamp - s_lastTimeStamp) > i_interval);
bool hasPlayers = s_players.length > 0;
bool hasBalance = address(this).balance > 0;
upkeepNeeded = (timePassed && isOpen && hasBalance && hasPlayers);

开奖。 performUpkeep 只能被网络调用,但它仍然重新检查 checkUpkeep——upkeep 可能拿陈旧数据被触发,纵深防御不花什么成本。 检查失败时它回滚一个自定错误,错误里自带证据:

revert Raffle__UpkeepNotNeeded(
    address(this).balance,
    s_players.length,
    uint256(s_raffleState)
);

错误数据本身就会告诉你哪个条件失败了。然后状态翻转,VRF 请求发出:

s_raffleState = RaffleState.CALCULATING;

VRFV2PlusClient.RandomWordsRequest memory req = VRFV2PlusClient.RandomWordsRequest({
    keyHash: i_gasLane,
    subId: i_subscriptionId,
    requestConfirmations: REQUEST_CONFIRMATIONS,
    callbackGasLimit: i_callbackGasLimit,
    numWords: NUM_WORDS,
    extraArgs: VRFV2PlusClient._argsToBytes(
        VRFV2PlusClient.ExtraArgsV1({nativePayment: false})
    )
});

uint256 requestId = s_vrfCoordinator.requestRandomWords(req);
emit RequestedRaffleWinner(requestId);

nativePayment: false 表示这次请求用订阅里的 LINK 付费——这正是资金 bug 之所以要紧的原因。

锁。 RaffleState 是一个枚举,OPEN 和 CALCULATING。从 performUpkeep 翻转它那一刻起,到 fulfillRandomWords 把它重置为止, 彩票处于 CALCULATING,enterRaffle 一律回滚。没人能在请求与回调 之间溜进来,所以被随机数索引的数组,恰好就是随机数抽取所面对的玩家 集合。防操纵的全部故事,就藏在这一个枚举里。

抽赢家。 VRF coordinator 会回调 fulfillRandomWords, 合约覆写它:

uint256 indexOfWinner = randomWords[0] % s_players.length;
address payable recentWinner = s_players[indexOfWinner];
s_recentWinner = recentWinner;
s_players = new address payable[](0);
s_lastTimeStamp = block.timestamp;
s_raffleState = RaffleState.OPEN;
emit WinnerPicked(recentWinner);

(bool success, ) = recentWinner.call{value: address(this).balance}("");
if (!success) {
    revert Raffle__TransferFailed();
}

这个顺序是故意的,它就是 Checks-Effects-Interactions 模式(合约自己的 注释里点了名)。赢家、玩家数组、时间戳、彩票状态全部在 ETH 转账之前 重置。如果赢家恰好是个合约,它的 fallback 想重新入场:没什么可重入的 对象了——状态已经全新,转账失败则由自定错误处理,而不是一句无声的 require 字符串。

它是怎么被测试的——一套故意保留的混合测试套件

仓库为同一个合约保留了两套单元测试,因为它们抓的是不同的东西。 Hardhat/JS 套件(Raffle.test.js)用 hardhat-deploy fixture、命名账户和 ethers 事件断言——它甚至在完整的端到端测试里监听 WinnerPicked。 Foundry 套件(RaffleTest.t.sol)是纯 Solidity:它 prank coordinator mock,用 vm.warp(block.timestamp + interval + 1) 拨 时间、roll 区块,然后用同一种语言断言一切。两套测试独立地断言 相同的行为——hardhat 测试甚至检查 consumers.includes(raffle.address) 来证明订阅确实 连到了合约。

完整的开奖流程是端到端覆盖的:四个玩家入场、执行 performUpkeep、 从发出的日志里抓 requestId、经由 mock 完成 fulfill,然后断言赢家拿到 整个奖池、彩票回到 OPEN、时间戳前进了。

单元层周围是让「基于 mock 的测试」保持诚实的设施:一个 LinkToken mock(ERC-677,带 transferAndCall)、Chainlink VRFCoordinatorV2_5Mock,以及 HelperConfig,它按链构建 NetworkConfig。在 LOCAL_CHAIN_ID (31337) 上它会自己部署 mock 并创建订阅;在 Sepolia 和主网上它持有 真实的 coordinator、gas lane 和 LINK 地址。两套工具链各有一份部署脚本: deploy/01-deploy-raffle.js (hardhat-deploy),以及 script/DeployRaffle.s.sol (forge,配套 Interactions.s.sol 里的 CreateSubscription、 FundSubscription、AddConsumer)。

单元测试覆盖不了的东西,文件夹布局自己就承认了:integration 测试目录 是一份注释骨架,列着完整的金字塔——unit、integration、fork、staging、 fuzzing、formal verification。Mock 控制恰恰是你在真实网络上会失去的 东西,所以单元测试带着 skipFork modifier(“Testnet cannot test with Mock, don’t have Mock control”)——staging 阶段对着真实 Sepolia VRF 跑,练的是真正的订阅、资金和回调延迟,这些是 mock 只能 假装的东西。

最后,.gas-snapshot 把每个测试的成本钉死,回归被当作一个数字抓 出来,而不是一种感觉。完整的抽奖测试——从入场到付款的整条开奖流程 ——是 335,011 gas:

RaffleTest:testFulfillRandomWordsPicksAWinnerResetsAndSendsMoney() (gas: 335011)
RaffleTest:testPerformUpkeepUpdatesRaffleStateAndEmitsRequestId() (gas: 222486)
RaffleTest:testCheckUpkeepReturnsTrueWhenParametersGood() (gas: 74771)

前端

仓库还带了一个纯 HTML 前端 pages/1/,它是笔记里列出的七种与合约 交互方式的第一种(HTML/JS,然后是 Next.js + 原生 ethers、web3-react、 react-moralis、web3Modal、useDapp、wagmi)。浏览器不能 require("ethers"),所以页面被 browserify 打包成 bundle:

yarn browserify pages/1/indexProperCatch.js --standalone bundle -o pages/1/dist/bundle.js

ProperCatch 这个文件名本身就是重点:要修的就是 错误处理。每一个异步交互——connect、store、retrieve——都包在 try/catch 里,把错误 log 出来而不是让 promise 无处理地 reject;每条 路径都先检查 typeof window.ethereum !== "undefined",钱包缺失时把 按钮文字换成 “Please install MetaMask”。旁边的 index.js 旧版本里一个 try/catch 都没有:交易被拒绝时 promise 只会无处理地在控制台里 reject, 而且它直接 new ethers.providers.Web3Provider(window.ethereum),前面没有 任何判断。它是个小页面,但它决定了 demo 在交易被拒绝时是无声死掉,还是告诉你发生了什么。

重做时我会改什么

这是一个测试网学习项目,我想把边界写清楚:没有审计、没有经济攻击 建模、没有主网的钱。README 管它叫「学习项目笔记」,它就是如此。

运营上,一个真彩票有个学习项目没有的持续成本:VRF 订阅每次请求都会 消耗 LINK,Automation upkeep 也要自己的 LINK 余额。没人会自动充值—— 我会写一个小监控脚本,给订阅补仓、余额太低时报警。

合约内部我会改四件事。第一,用 randomWords[0] % s_players.length 选 赢家在数组长度不能整除 2^256 时有模偏差——玩家少时可忽略,但真彩票 应该用拒绝采样,或者多要几个词。第二,回调忽略了 requestId—— CALCULATING 锁让重放不现实,但我会存下未完成的请求,并在 fulfillRandomWords 里校验。第三,赢家拿走全部余额,运营者一分不赚; 真彩票需要内置手续费或所有者分成。第四,没有暂停或紧急停止——一旦 出 bug,资金要卡到下一次开奖。

结果

一个数字,来自 gas snapshot:完整的开奖——四人入场、upkeep 触发、 VRF mock fulfill、赢家收款——在 Foundry 套件里跑 335,011 gas, hardhat 套件又把同一个端到端行为断言了一遍。合约已部署并验证在 Sepolia 上(JS 部署的 0xc2022b56eBC140B5FebCf9FBaB14c17db4C315C4, hardhat 部署的 0x3a827C119e1D746bb3C7bcbbf95c55246C8CcBdd)。我开头的 问题有了一个能指给人看的答案:随机数来自合约无法预测或重掷的模块, 状态机在开奖进行时锁住彩票,而自动化决定何时开奖。

我是全栈 Web 开发者和 DevOps 工程师,我构建完整的应用—— 前端、后端、部署,以及那些必须持续跑下去的部分。如果你需要 一个全栈 Web 项目,或想让现有项目被可靠地部署,来找我:WhatsApp · [email protected] · hoelee.com。

Lee Teong Hoe

Lee Teong Hoe · Mr Hoelee

Full-stack developer & DevOps engineer based in Malaysia. I run my own production infrastructure and build web systems for small businesses — this blog is the living record.

Hiring? Profile & stack · Malaysian SME? Website design & development ↗ · All posts