MyCut

AI First Flight #8
Beginner FriendlyFoundry
EXP
View results
Submission Details
Severity: high
Valid

closePot() iterates an unbounded claimants array, enabling permanent gas-limit DoS on large contests

Root + Impact

Description

  • Pot::closePot() is supposed to distribute the post-deadline remainder to all players who claimed before the window closed. Each claimant receives one ERC20 transfer.

  • The claimants array grows by one for every claimCut() call and has no upper bound. closePot loops over the entire array executing one ERC20 transfer per iteration. When the array is large enough to push the loop past the block gas limit, closePot reverts on every attempt and can never complete, permanently trapping the manager cut and any undistributed balance.

// Pot.sol closePot()
uint256 claimantCut = (remainingRewards - managerCut) / i_players.length;
// @> unbounded loop — one ERC20 transfer (~21 000 gas) per claimant
for (uint256 i = 0; i < claimants.length; i++) {
_transferReward(claimants[i], claimantCut);
}

Risk

Likelihood:

  • A legitimate large-scale competition — hackathon, on-chain raffle, airdrop — with more than ~1 400 participants who all call claimCut() before the deadline hits the Ethereum block gas limit (~30 M gas at ~21 000 gas per ERC20 transfer) without any malicious action.

  • ContestManager::createContest imposes no cap on players.length, so there is no protocol-level guard preventing this scenario.

Impact:

  • closePot is permanently uncallable once the claimant count exceeds the gas limit. The manager's 10% cut and the remaining distributable balance are locked in the Pot forever with no recovery path.

  • Players who already called claimCut() received their individual shares, but the bonus distribution from closePot is unrecoverable.

Proof of Concept

Create a pot with 1 500 players, have all of them call claimCut(), advance 90 days, then attempt to close with the full block gas budget. The transaction reverts.

function testGasLimitDoS() public mintAndApproveTokens {
uint256 N = 1500;
address[] memory largePlayers = new address[](N);
uint256[] memory largeRewards = new uint256[](N);
uint256 perPlayer = 1e15;
for (uint256 i = 0; i < N; i++) {
largePlayers[i] = address(uint160(i + 1));
largeRewards[i] = perPlayer;
}
vm.startPrank(user);
weth.mint(user, N * perPlayer);
weth.approve(address(conMan), N * perPlayer);
address pot = ContestManager(conMan).createContest(
largePlayers, largeRewards, IERC20(weth), N * perPlayer
);
ContestManager(conMan).fundContest(
ContestManager(conMan).getContests().length - 1
);
vm.stopPrank();
// All 1 500 players claim — claimants.length = 1 500
for (uint256 i = 0; i < N; i++) {
vm.prank(largePlayers[i]);
Pot(pot).claimCut();
}
vm.warp(block.timestamp + 90 days + 1);
// closePot exceeds block gas limit and reverts
vm.prank(user);
vm.expectRevert();
ContestManager(conMan).closeContest{gas: 30_000_000}(pot);
}

The expectRevert passes, confirming that closePot cannot complete at the block gas limit when claimants.length is 1 500.

Recommended Mitigation

Replace the push-based loop with a pull-based pattern. Store the per-claimant close amount at close time and let each claimant withdraw it individually in a separate transaction, eliminating the unbounded loop from closePot.

+ mapping(address => bool) private hasReceivedCloseCut;
+ uint256 private closeCutPerClaimant;
function closePot() external onlyOwner {
if (block.timestamp - i_deployedAt < 90 days) revert Pot__StillOpenForClaim();
if (remainingRewards > 0) {
uint256 managerCut = remainingRewards / managerCutPercent;
- i_token.transfer(msg.sender, managerCut);
+ i_token.transfer(i_manager, managerCut);
+ if (claimants.length > 0) {
+ closeCutPerClaimant = (remainingRewards - managerCut) / claimants.length;
+ }
- uint256 claimantCut = (remainingRewards - managerCut) / i_players.length;
- for (uint256 i = 0; i < claimants.length; i++) {
- _transferReward(claimants[i], claimantCut);
- }
}
}
+ function claimCloseCut() external {
+ require(closeCutPerClaimant > 0, "not closed or no cut");
+ require(!hasReceivedCloseCut[msg.sender], "already claimed close cut");
+ require(playersToRewards[msg.sender] == 0, "claim main cut first");
+ hasReceivedCloseCut[msg.sender] = true;
+ _transferReward(msg.sender, closeCutPerClaimant);
+ }
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 15 days ago
Submission Judgement Published
Validated
Assigned finding tags:

[H-04] Gas Limit DoS via large amount of claimants

## Description The `Pot.sol` contract contains a vulnerability that can lead to a Denial of Service (DoS) attack. This issue arises from the inefficient handling of claimants in the `closePot` function, where iterating over a large number of claimants can cause the transaction to run out of gas, thereby preventing the contract from executing as intended. ## Vulnerability Details Affected code - <https://github.com/Cyfrin/2024-08-MyCut/blob/946231db0fe717039429a11706717be568d03b54/src/Pot.sol#L58> The vulnerability is located in the `closePot` function of the Pot contract, specifically at the loop iterating over the claimants array: ```javascript function closePot() external onlyOwner { ... if (remainingRewards > 0) { ... @> for (uint256 i = 0; i < claimants.length; i++) { _transferReward(claimants[i], claimantCut); } } } ``` The `closePot` function is designed to distribute remaining rewards to claimants after a contest ends. However, if the number of claimants is extremly large, the loop iterating over the claimants array can consume a significant amount of gas. This can lead to a situation where the transaction exceeds the gas limit and fails, effectively making it impossible to close the pot and distribute the rewards. ## Exploit 1. Attacker initiates a big contest with a lot of players 2. People claim the cut 3. Owner closes the large pot that will be very costly ```javascript function testGasCostForClosingPotWithManyClaimants() public mintAndApproveTokens { // Generate 2000 players address[] memory players2000 = new address[](2000); uint256[] memory rewards2000 = new uint256[](2000); for (uint256 i = 0; i < 2000; i++) { players2000[i] = address(uint160(i + 1)); rewards2000[i] = 1 ether; } // Create a contest with 2000 players vm.startPrank(user); contest = ContestManager(conMan).createContest(players2000, rewards2000, IERC20(ERC20Mock(weth)), 2000 ether); ContestManager(conMan).fundContest(0); vm.stopPrank(); // Allow 1500 players to claim their cut for (uint256 i = 0; i < 1500; i++) { vm.startPrank(players2000[i]); Pot(contest).claimCut(); vm.stopPrank(); } // Fast forward time to allow closing the pot vm.warp(91 days); // Record gas usage for closing the pot vm.startPrank(user); uint256 gasBeforeClose = gasleft(); ContestManager(conMan).closeContest(contest); uint256 gasUsedClose = gasBeforeClose - gasleft(); vm.stopPrank(); console.log("Gas used for closing pot with 1500 claimants:", gasUsedClose); } ``` ```Solidity Gas used for closing pot with 1500 claimants: 6425853 ``` ## Impact The primary impact of this vulnerability is a Denial of Service (DoS) attack vector. An attacker (or even normal usage with a large number of claimants) can cause the `closePot` function to fail due to excessive gas consumption. This prevents the distribution of remaining rewards and the execution of any subsequent logic in the function, potentially locking funds in the contract indefinitely. In the case of smaller pots it would be a gas inefficency to itterate over the state variabel `claimants`. ## Recommendations Gas Optimization: Optimize the loop to reduce gas consumption by using a local variable to itterate over, like in the following example: ```diff - for (uint256 i = 0; i < claimants.length; i++) { - _transferReward(claimants[i], claimantCut); - } + uint256 claimants_length = claimants.length; + ... + for (uint256 i = 0; i < claimants_length; i++) { + _transferReward(claimants[i], claimantCut); + } ``` Batch Processing: Implement batch processing for distributing rewards. This will redesign the protocol functionallity but instead of processing all claimants in a single transaction, allow the function to process a subset of claimants per transaction. This can be achieved by introducing pagination or limiting the number of claimants processed in one call. This could also be fixed if the user would claim their reward after 90 days themselves

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!