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存储类型无关,核心问题有三个:
- O(n²)复杂度的线性扫描开销:每次抽取一个落选者,都要遍历整个
losers数组做存在性校验。按测试参数10000总供应量、单次抽10%计算,总遍历次数接近50万次,单次链上存储读取操作就要消耗2100左右Gas,仅扫描部分的Gas消耗就会突破10亿,远超出以太坊单区块Gas上限。 - 循环边界逻辑错误:循环终止条件写在循环判断中,每次往
losers数组推入新值,getCountLosers()返回值就会+1,循环阈值会动态降低,实际抽取的落选者数量完全不符合预期,极端情况下甚至会触发无限循环。 - 重复计算冗余:同一个随机数的存在性校验会连续调用两次
exists1,偏移量变化时重复做哈希计算,产生了大量无意义的Gas消耗。
修复方案
核心优化思路是用O(1)复杂度的存在性校验替代O(n)的线性扫描,同时修正循环逻辑:
- 新增
mapping(uint16 => bool)类型状态变量标记落选者,存在性校验直接读映射,不需要遍历数组。 - 提前计算本次需要抽取的落选者总数,存为局部变量,不要在循环中动态计算阈值。
- 优化判断逻辑,避免重复调用校验函数、重复计算哈希。
修复后的参考代码:
// 新增状态变量 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
相关产品推荐
相关产品推荐

