Skip to content

wuqin76/smart-contract-lottery

Repository files navigation

smart-contract-lottery - 编译与编辑器导入问题排查记录

概要

本仓库包含一个简单的 Chainlink VRF 抽奖合约(src/Rafflemoney.sol),在使用 Foundry (forge) 编译和在 VS Code 编辑器中使用 Solidity 语言服务器 (LSP) 时,曾出现导入路径无法解析的报错(Source "@chainlink/..." not found)。本文档总结了问题根因、调查步骤、已执行的修复,以及给开发者的建议与操作步骤。


我对 VRF 的理解(整理)

可以把一次随机数请求理解为“填写需求表 + 邮递员递送 + 商店验货并回信”的完整异步流程:

  1. 我们在链上“填写需求表”(构造 VRFV2PlusClient.RandomWordsRequest 结构体),把这次请求需要的参数全部写好:

    • 需要几个随机数(numWords
    • 回调最多给多少 gas(callbackGasLimit
    • 需要等待多少个区块确认(requestConfirmations
    • 选择哪条 gas lane/密钥(keyHash
    • 用哪个订阅(subId
    • 以及是否使用原生代币支付(extraArgs.nativePayment
  2. 把需求表交给“邮递员”(VRF_COORDINATOR.requestRandomWords(request))。该交易同步只会返回一个 requestId(唯一标识),不返回随机数。

  3. 经过你指定的确认数后,Chainlink 的 VRF 节点在链下生成随机数与密码学证明,并把证明提交到链上;协调器(Coordinator)在链上验证证明的正确性。

  4. 验证通过后,协调器“回信”(异步回调你的合约),调用你重写的 fulfillRandomWords(uint256 requestId, uint256[] calldata randomWords),把随机数数组 randomWords 传给你。你就可以用这些随机数完成业务逻辑(比如抽签、选赢家)。

要点:

  • VRF 是异步模型:请求交易只拿到 requestId;随机数稍后通过回调函数送达。
  • randomWords 是数组,因为你可以一次请求多个随机数;长度等于你在请求里设置的 numWords
  • requestId 便于在并发场景下把“回调”关联到“原始请求”。

代码中的关键片段

请求随机数(原生 ETH 支付):

VRFV2PlusClient.RandomWordsRequest memory request = VRFV2PlusClient.RandomWordsRequest({
    keyHash: KEY_HASH,
    subId: uint256(SUBSCRIPTION_ID),
    requestConfirmations: REQUEST_CONFIRMATIONS,
    callbackGasLimit: CALLBACK_GAS_LIMIT,
    numWords: NUM_WORDS,
    extraArgs: VRFV2PlusClient._argsToBytes(
        VRFV2PlusClient.ExtraArgsV1({ nativePayment: true }) // 原生代币支付
    )
});

uint256 requestId = VRF_COORDINATOR.requestRandomWords(request);
emit RequestedRaffleWinner(requestId);

接收随机数(回调):

function fulfillRandomWords(
    uint256 /* requestId */,
    uint256[] calldata randomWords
) internal override {
    // 本项目 numWords = 1,因此使用 randomWords[0]
    uint256 indexOfWinner = randomWords[0] % players.length;
    // ...后续选赢家与发奖逻辑
}

为什么 randomWords[0]

  • 因为本项目 NUM_WORDS = 1,数组里只有一个元素。若你把 NUM_WORDS 设为 3,则可以访问 randomWords[0]randomWords[1]randomWords[2]

VRF v2(LINK 支付)与 v2.5/Plus(原生支付)的差异

  • 入参组织方式:
    • v2:位置参数方法 requestRandomWords(keyHash, subId, ...)
    • v2.5/Plus:用结构体 RandomWordsRequest + extraArgs(可扩展、更灵活)。
  • 支付方式:
    • v2:从订阅的 LINK 余额扣费。
    • v2.5/Plus:可从订阅的"原生代币余额"扣费(nativePayment: true)或 LINK 余额(nativePayment: false)。
  • 回调形式与返回值:
    • 都是异步回调 fulfillRandomWords,请求调用本身只返回 requestIduint256)。

新项目建议优先使用 v2.5/Plus 原生支付,减少维护 LINK 余额的运维成本。

⚠️ 重要:原生代币支付方式的影响

nativePayment 参数的作用

在 VRF v2.5/Plus 中,nativePayment 参数决定了 VRF 服务费用的支付方式,这是一个关键配置,必须与订阅的充值方式匹配:

VRFV2PlusClient.ExtraArgsV1({nativePayment: true})  // 使用原生代币(ETH/MATIC 等)
VRFV2PlusClient.ExtraArgsV1({nativePayment: false}) // 使用 LINK 代币

不同环境的支付方式选择

本项目根据链 ID 动态选择支付方式(见 src/Raffle.sol):

// 本地链使用原生支付(与 fundSubscriptionWithNative 匹配)
// 测试网使用 LINK 支付(与 LinkToken.transferAndCall 匹配)
bool useNativePayment = (block.chainid == 31337); // 本地链 ID

为什么要这样设计?

环境 nativePayment 充值方式 扣费来源 原因
本地测试 (Anvil) true fundSubscriptionWithNative subscription.nativeBalance Mock 环境方便测试,不需要 LINK 代币
Sepolia/主网 false LinkToken.transferAndCall subscription.balance 测试网/主网使用标准 LINK 支付

常见错误:支付方式不匹配

错误现象:

[FAIL: InsufficientBalance()] testFulfillRandomWordsPicksAWinnerResetsAndSendsMoney()

原因分析:

  • 如果 nativePayment: false(使用 LINK),但订阅用 fundSubscriptionWithNative 充值(充的是 ETH)
  • VRF 尝试从 subscription.balance(LINK 余额)扣费,但余额实际在 subscription.nativeBalance(ETH 余额)
  • 结果:余额不足,交易回退

解决方案: 确保支付方式与充值方式一致:

// 方案1:本地使用原生支付
nativePayment: true
充值方式:fundSubscriptionWithNative{value: amount}(subId)

// 方案2:测试网使用 LINK 支付
nativePayment: false
充值方式:LinkToken.transferAndCall(coordinator, amount, abi.encode(subId))

订阅余额的两个独立账户

VRF v2.5 订阅内部维护两个独立的余额账户:

  1. subscription.balance - LINK 代币余额

    • 通过 LinkToken.transferAndCall 充值
    • nativePayment: false 时使用
  2. subscription.nativeBalance - 原生代币余额(ETH/MATIC 等)

    • 通过 fundSubscriptionWithNative 充值
    • nativePayment: true 时使用

这两个账户互不相通! 如果充值到错误的账户,即使订阅有余额,VRF 也会因为"从正确账户看余额不足"而失败。

本地测试配置示例

script/interaction.s.sol 中:

if (block.chainid == CodeConstants.LOCAL_CHAIN_ID) {
    // 本地测试:使用原生 ETH 充值
    vm.startBroadcast();
    VRFCoordinatorV2_5Mock(vrfCoordinator).fundSubscriptionWithNative{
        value: FUND_AMOUNT  // 10 ether
    }(subscriptionId);
    vm.stopBroadcast();
} else {
    // 测试网/主网:使用 LINK 充值
    vm.startBroadcast(account);
    LinkToken(linkToken).transferAndCall(
        vrfCoordinator,
        FUND_AMOUNT,
        abi.encode(subscriptionId)
    );
    vm.stopBroadcast();
}

最佳实践建议

  1. 明确支付方式:在部署前确认使用原生支付还是 LINK 支付
  2. 保持一致性:合约的 nativePayment 设置必须与订阅充值方式匹配
  3. 环境隔离:本地测试和测试网可以使用不同的支付方式,通过 chainid 判断
  4. 充足余额:确保订阅在对应账户中有足够余额支付 VRF 费用
  5. 费用估算:VRF 费用 ≈ baseFee + (gasUsed * gasPrice),本地 Mock 费用约 0.1-0.25 ETH/次

调试技巧

如果遇到 InsufficientBalance 错误:

  1. 检查合约中 nativePayment 的值
  2. 检查订阅充值使用的方法(fundSubscriptionWithNative 还是 transferAndCall
  3. 在 Chainlink VRF UI 上查看订阅的两个余额是否充足
  4. 运行测试时添加 -vvvv 查看详细的余额扣费信息

Smart Contract Lottery (Chainlink VRF v2.5)

一场漫长的旅行:从本地单测到测试网部署与 VRF 上链回调的完整踩坑与实践总结。

概览

这是一个使用 Chainlink VRF v2.5 的去中心化抽奖合约项目,基于 Foundry 开发测试,支持本地(Anvil + VRF Mock)与 Sepolia。项目包含:

  • 合约:src/Raffle.sol(抽奖逻辑 + VRF 回调)
  • 部署与交互脚本:script/DeployRaffle.s.solscript/interaction.s.solscript/CallPerformUpkeep.s.sol
  • 单元测试:test/uint/testuint.sol(12 个测试全部通过,覆盖率 81%+)
  • 常用 Make 命令与配置:Makefilefoundry.tomlremappings.txt

本 README 汇总项目使用方法、关键配置、常见问题,并保留了完整的学习笔记。


主要特性

  • ✅ Chainlink VRF v2.5(Plus)集成:支持原生代币(ETH)和 LINK 两种支付方式
  • ✅ 智能支付方式切换:本地测试用 ETH,测试网用 LINK,自动匹配充值方式
  • ✅ 自动化抽奖流程:通过 Automation/脚本执行 checkUpkeep -> performUpkeep -> fulfillRandomWords
  • ✅ 完整脚本链路:创建/充值订阅、添加 Consumer、部署、执行 Upkeep
  • ✅ 全面的单元测试:12 个测试覆盖关键边界条件,使用 forge-std cheatcodes

我对 VRF 的理解(整理)

可以把一次随机数请求理解为"填写需求表 + 邮递员递送 + 商店验货并回信"的完整异步流程:

  1. 填写需求表:我们在链上构造 VRFV2PlusClient.RandomWordsRequest 结构体,把这次请求需要的参数全部写好:

    • 需要几个随机数(numWords
    • 回调最多给多少 gas(callbackGasLimit
    • 需要等待多少个区块确认(requestConfirmations
    • 选择哪条 gas lane/密钥(keyHash
    • 用哪个订阅(subId
    • ⚠️ 是否使用原生代币支付extraArgs.nativePayment)- 关键配置!
  2. 递交请求:把需求表交给"邮递员"(VRF_COORDINATOR.requestRandomWords(request))。该交易同步只会返回一个 requestId(唯一标识),不返回随机数。

  3. 链下生成:经过你指定的确认数后,Chainlink 的 VRF 节点在链下生成随机数与密码学证明,并把证明提交到链上;协调器(Coordinator)在链上验证证明的正确性。

  4. 异步回调:验证通过后,协调器"回信"(异步回调你的合约),调用你重写的 fulfillRandomWords(uint256 requestId, uint256[] calldata randomWords),把随机数数组 randomWords 传给你。你就可以用这些随机数完成业务逻辑(比如抽签、选赢家)。

要点:

  • VRF 是异步模型:请求交易只拿到 requestId;随机数稍后通过回调函数送达。
  • randomWords 是数组,因为你可以一次请求多个随机数;长度等于你在请求里设置的 numWords
  • requestId 便于在并发场景下把"回调"关联到"原始请求"。

快速开始

环境准备

# 克隆项目
git clone <your-repo-url>
cd smart-contract-lottery

# 安装依赖(Foundry 会自动管理)
forge install

# 配置环境变量
cp .env.example .env
# 编辑 .env 填入你的私钥和 RPC URL

本地测试

# 启动本地链
make anvil

# 在另一个终端部署(本地会自动创建订阅并充值)
make deploy

# 运行所有测试
forge test

# 查看详细测试输出
forge test -vvv

# 查看覆盖率
forge coverage

部署到 Sepolia

  1. Chainlink VRF 创建订阅
  2. 用 LINK 充值订阅
  3. 更新 script/HelperConfig.s.sol 中的 subscriptionId
  4. 部署并验证:
make deploy-sepolia

关键参数说明(本项目)

  • KEY_HASH:选择 VRF 的 gas lane/密钥,影响费用上限与安全参数。
  • SUBSCRIPTION_ID:订阅 ID;需要把本合约地址加入该订阅的消费者列表。
  • REQUEST_CONFIRMATIONS:等待的链上确认数(常用 3),越大越抗重组但回调更慢。
  • CALLBACK_GAS_LIMIT:回调 fulfillRandomWords 的 gas 上限;不够会导致回调失败/重试。
  • NUM_WORDS:请求的随机词个数(本项目为 1)。
  • extraArgs.nativePayment⚠️ 关键! 设为 true 表示原生代币支付(ETH),设为 false 使用 LINK 支付。**必须与订阅充值方式匹配!**详见"原生代币支付方式的影响"章节。

Chainlink Automation(Keeper)如何配合

关键参数说明(本项目)

  • KEY_HASH:选择 VRF 的 gas lane/密钥,影响费用上限与安全参数。
  • SUBSCRIPTION_ID:订阅 ID;需要把本合约地址加入该订阅的消费者列表。
  • REQUEST_CONFIRMATIONS:等待的链上确认数(常用 3),越大越抗重组但回调更慢。
  • CALLBACK_GAS_LIMIT:回调 fulfillRandomWords 的 gas 上限;不够会导致回调失败/重试。
  • NUM_WORDS:请求的随机词个数(本项目为 1)。
  • extraArgs.nativePayment:设为 true 表示原生代币支付(例如在 Sepolia 用 ETH),订阅需有足够原生代币余额。

Chainlink Automation(Keeper)如何配合

  • checkUpkeep:判断是否满足抽奖触发条件(时间间隔、状态为 OPEN、存在玩家且合约有余额)。
  • performUpkeep:当 checkUpkeep 为真时被执行,切换状态为 CALCULATING,然后发起 VRF 请求。
  • 随后等待 VRF 回调 fulfillRandomWords,在回调中完成抽奖与转账,并把状态重置为 OPEN

部署与配置清单(以 Sepolia 为例)

  1. 获取 VRF v2.5/Plus 的网络参数(Coordinator 地址、keyHash)。
  2. 创建/使用已有 SUBSCRIPTION_ID,将本合约地址添加为消费者。
  3. 给订阅充值“原生代币”余额(因为我们使用 nativePayment: true)。
  4. 部署合约,构造参数传入:
    • vrfCoordinator(网络对应的 v2.5 Coordinator 地址)
    • keyHash
    • subscriptionId
    • callbackGasLimit(根据回调逻辑预估)
    • entranceFeeinterval 等业务参数
  5. 配置 Chainlink Automation,指向合约的 checkUpkeep/performUpkeep

注意:不同网络的 Coordinator 地址、keyHash 会不同,请参考 Chainlink 官方文档。

常见问题与排查

  • VS Code 报 “Source not found” 但 forge 能编译:
    • 多半是 Solidity 语言服务器(LSP)没有读取到 remappings 或缓存未刷新。确保打开的根目录是项目根,重载窗口(Ctrl+Shift+P → Developer: Reload Window),或执行扩展的清缓存命令。
    • 检查订阅是否已将合约加入消费者、订阅余额是否充足(原生代币余额)、callbackGasLimit 是否足够、requestConfirmations 是否过大。
  • randomWords 越界:

项目结构与主要合约

  • 可变:players, lastTimestamp, raffleState, recentWinner

  • Chainlink VRF v2.5/Plus 文档

  • Foundry Book(lint 规范、remappings 等)

发现的问题

  • 命令行使用 forge build 时,合约可以成功编译(有 lint 提示但无致命错误)。
  • 在 VS Code 编辑器中,Solidity 语言服务器(LSP)曾报 Source "@chainlink/..." not found 错误,导致编辑器显示导入错误,即使命令行编译成功。
  • 问题在两份目录之间复现不一致:smart-contract-lottery(原项目)与 smart-contract-lottery-new(临时测试项目)。原项目配置完备能通过编译;-new 项目初期缺少某些文件造成 LSP 或编译报错。

根因分析

  1. VS Code 的 Solidity LSP 在启动时读取工作区与配置(例如 .vscode/settings.jsonsolidity.remappings),并在其进程内缓存路径映射。若 LSP 启动时没有读取到正确的 remappings、或工作区根不正确、或缓存未刷新,就会出现“Source not found”的诊断错误。

我们做了什么(操作记录)

  1. smart-contract-lottery 中运行:

    • forge remappings > remappings.txt 结果:命令行编译成功,只报告 lint notes(风格提示)。
  2. 在临时目录 smart-contract-lottery-new 中复现问题,通过 forge install smartcontractkit/chainlink-brownie-contracts 安装依赖并同步 src/, lib/, foundry.toml, remappings.txt,最终使 -new 目录也能成功编译。

"solidity.remappings": [

"@chainlink/contracts/=lib/chainlink-brownie-contracts/contracts/",
"chainlink-brownie-contracts/=lib/chainlink-brownie-contracts/",
"forge-std/=lib/forge-std/src/"

}

  1. 重载 VS Code 窗口(Developer: Reload Window)。重载后,Solidity LSP 重新读取 remappings 与工作区设置,编辑器中 Source not found 的错误消失。

  2. 为方便触发 LSP 重新加载,我们还写入了 .vscode/solidity-reload.txt 作为提醒文件(不是必需,仅作为标记)。


建议与最佳实践

  • 在 VS Code 中打开项目根(包含 foundry.tomlremappings.txt),不要只打开上层目录或子目录。确保 VS Code 的 Workspace 根是项目目录。
  • 把 remappings 明确写入项目的 .vscode/settings.json,并在修改 remappings 或 lib 后重载窗口(Developer: Reload Window)。
  • 在 WSL/Remote 环境下,确保扩展安装在远端/WSL 上,路径解析使用远端路径。
  • 遇到编辑器显示找不到导入但 forge build 能成功时,先重载窗口、清除缓存,再检查 .vscode/settings.json

总结与收获

这次漫长旅程的关键学习点

  1. 原生代币支付的坑nativePayment 必须与订阅充值方式匹配

    • 本地测试:nativePayment: true + fundSubscriptionWithNative
    • 测试网:nativePayment: false + LinkToken.transferAndCall
    • 不匹配会导致 InsufficientBalance 错误
  2. VRF 订阅的双账户系统

    • subscription.balance (LINK 余额)
    • subscription.nativeBalance (原生代币余额)
    • 两个账户互不相通,扣费时只会从对应账户扣
  3. 测试覆盖率的重要性

    • 12 个单元测试覆盖了所有关键流程
    • 边界条件测试帮助发现了支付方式不匹配问题
    • 使用 Mock 进行本地测试,避免浪费测试网 gas
  4. VS Code 开发环境配置

    • Solidity LSP 需要正确的 remappings 配置
    • 遇到"Source not found"时记得重载窗口
    • .vscode/settings.json 是关键配置文件
  5. Gas 优化与估算

    • Etherscan UI 的 gas 限制问题
    • 使用 cast/forge script 自动估算 gas
    • 本地测量 performUpkeep ~128k gas

项目成果

✅ 完整的 Raffle 智能合约(支持双支付方式)
✅ 12/12 测试全部通过,覆盖率 81%+
✅ 本地与 Sepolia 部署脚本
✅ 完整的文档与踩坑记录
✅ GitHub 版本控制与代码管理

后续改进方向

  • 添加前端界面(React + ethers.js/viem)
  • 集成 Chainlink Automation(自动触发抽奖)
  • 支持多轮抽奖和奖金池累积
  • 添加更多网络支持(Polygon, Arbitrum 等)
  • 实现管理员功能(暂停/恢复合约)
  • 优化 gas 消耗(批量操作、存储优化)

许可与致谢

依赖

许可证

MIT License - 详见源文件头

致谢

感谢这一路的耐心调试与记录。每一个错误都是宝贵的学习经验。这不仅仅是一个抽奖合约,更是对 Chainlink VRF、Foundry 测试框架、智能合约开发最佳实践的深入理解。

记住:支付方式必须匹配! 🎯


最后更新:2025 年 10 月


About

A decentralized lottery smart contract using Chainlink VRF v2.5 and Chainlink Automation

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors