You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Solidity反向彩票合约drawLoser函数Gas估算超限报错求助

问题描述

我是Solidity新手,在当前项目中尝试实现反向抽奖逻辑:持续抽取落选者并追加记录,直到仅剩最终中奖者。

在Remix测试时持续遇到Gas估算报错,报错信息如下:

Gas estimation errored with the following message (see below). The transaction execution will likely fail. Do you want to force sending?
gas required exceeds allowance (29970705)

已尝试提高部署时的Gas上限,但该操作仅会提升运行成本,当前运行drawLoser()函数的估算成本甚至高达75000 ETH。

现有实现逻辑

  • 调用Chainlink VRF的fulfillRandomWords方法获取可验证随机种子,随后在drawLoser()函数中使用该种子;测试阶段current_supply硬编码为10000。
  • 基于随机种子生成随机数后,校验该数值是否已存在于losers数组中,若已存在则递增偏移量,基于同一随机种子生成新随机数,重复该过程直到找到未被记录的新数值。
  • 设计仅在losers数组中存储uint16类型的落选者条目,未额外存储其他数据,但Gas费用异常高昂,推测是实现逻辑存在疏漏,产生了非预期的链上存储或计算开销。

现有问题代码如下:

function fulfillRandomWords(
  uint256, /* requestId */
  uint256[] memory randomWords
) internal override {
  s_randomWords = randomWords;
}

function exists1(uint16 num) public view returns (bool) {
    for (uint i = 0; i < losers.length; i++) {
        if (losers[i] == num) {
            return true;
        }
    }
    return false;
}

// 逻辑说明:需要先完成随机数 fulfill 再调用drawLoser,drawLoser 本身不生成新的随机种子
function drawLoser() public {
  uint16 drawing;
  uint i = 0;
  uint j = 1;
  // 抽取总参与量10%的地址作为落选者
  while(i < 10*(current_supply-getCountLosers())/100) {  //current_supply 为彩票总参与量
    drawing = uint16(uint256(keccak256(abi.encode(s_randomWords[0], j)))%(current_supply-losers.length)+1);
    if (exists1(drawing) == true){
      j++;
    }
    if (exists1(drawing) == false){
      losers.push(drawing);
      i++;
    }
  }
}

问题根因

Gas异常爆炸完全来自实现逻辑的低效设计,和uint16存储类型无关,核心问题有三个:

  1. O(n²)复杂度的线性扫描开销:每次抽取一个落选者,都要遍历整个losers数组做存在性校验。按测试参数10000总供应量、单次抽10%计算,总遍历次数接近50万次,单次链上存储读取操作就要消耗2100左右Gas,仅扫描部分的Gas消耗就会突破10亿,远超出以太坊单区块Gas上限。
  2. 循环边界逻辑错误:循环终止条件写在循环判断中,每次往losers数组推入新值,getCountLosers()返回值就会+1,循环阈值会动态降低,实际抽取的落选者数量完全不符合预期,极端情况下甚至会触发无限循环。
  3. 重复计算冗余:同一个随机数的存在性校验会连续调用两次exists1,偏移量变化时重复做哈希计算,产生了大量无意义的Gas消耗。

修复方案

核心优化思路是用O(1)复杂度的存在性校验替代O(n)的线性扫描,同时修正循环逻辑:

  1. 新增mapping(uint16 => bool)类型状态变量标记落选者,存在性校验直接读映射,不需要遍历数组。
  2. 提前计算本次需要抽取的落选者总数,存为局部变量,不要在循环中动态计算阈值。
  3. 优化判断逻辑,避免重复调用校验函数、重复计算哈希。

修复后的参考代码:

// 新增状态变量
mapping(uint16 => bool) public isLoser;
uint16[] public losers;
uint256 public constant CURRENT_SUPPLY = 10000;
uint256[] public s_randomWords;

function fulfillRandomWords(
  uint256, /* requestId */
  uint256[] memory randomWords
) internal override {
  s_randomWords = randomWords;
}

// 移除原有的exists1函数,映射查询直接替代遍历逻辑

function drawLoser() public {
  require(s_randomWords.length > 0, "Random seed not generated");
  uint256 randomSeed = s_randomWords[0];
  uint256 currentLoserCount = losers.length;
  // 提前计算本次抽取总数,用局部变量缓存避免循环中重复计算
  uint256 drawTotal = 10 * (CURRENT_SUPPLY - currentLoserCount) / 100;
  uint256 offset = 1;

  for(uint256 i = 0; i < drawTotal; i++) {
    uint16 candidate;
    // 随机数碰撞概率极低,内层循环不会产生过多开销
    while(true) {
      candidate = uint16(uint256(keccak256(abi.encode(randomSeed, offset))) % CURRENT_SUPPLY + 1);
      offset++;
      if(!isLoser[candidate]) {
        break;
      }
    }
    isLoser[candidate] = true;
    losers.push(candidate);
  }
}

额外优化建议

  • 如果后续需要多轮次抽取,建议改用Fisher-Yates洗牌算法实现随机选择,完全避免随机数碰撞问题,Gas消耗会更稳定。
  • 单次交易不要抽取过多落选者,比如10000总供应量单次抽1000个的Gas消耗依然偏高,可以拆分为多次交易每次抽取100个,避免触碰单区块Gas上限。
  • 链上开发优先用局部变量缓存需要频繁读取的状态变量,尽可能减少存储读写操作,这是Solidity Gas优化的核心原则。

内容的提问来源于stack exchange,提问作者Drax

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.27 02:18:03