MyCut

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

Unbound loop in closePot function

Description

  • Closing a pot after the 90‑day window should reliably finalize payouts in a single transaction: take the manager cut, then redistribute the remainder to eligible claimants without risking out‑of‑gas failures.

  • closePot() iterates over the dynamic claimants array and transfers tokens to each entry in a single call. This is an unbounded loop whose gas cost grows linearly with the number of claimants. With a sufficiently large set of claimants, the transaction will exceed the block gas limit or the sender’s gas budget and revert, preventing closure and leaving funds stuck.

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);
uint256 claimantCut = (remainingRewards - managerCut) / i_players.length;
// @> Unbounded loop: gas ~ O(claimants.length)
for (uint256 i = 0; i < claimants.length; i++) {
_transferReward(claimants[i], claimantCut);
}
}
}

Risk

Likelihood: High

  • Contests with many participants commonly produce long claimant lists; as the list grows, the close operation routinely approaches or exceeds practical gas limits.

  • Any scenario with high participation (e.g., community contests) will cause the loop to become prohibitively expensive and regularly revert at closure time.

Impact: High

  • Denial of service on pot closure: The manager cannot close the pot; the manager cut and redistribution cannot be executed, leaving funds in limbo.

  • Operational lockup and accounting drift: With closure blocked, remainingRewards and post‑close distribution logic never finalize, impairing financial correctness and follow‑on operations.

Proof of Concept

  • Copy the code below to TestMyCut.t.sol.

  • Run command forge test --mt testGasUsedWhenThereAreManyClaimants -vv.

modifier mintAndApproveTokens2() {
console.log("Minting tokens to: ", user);
vm.startPrank(user);
ERC20Mock(weth).mint(user, 4000 ether);
ERC20Mock(weth).approve(conMan, 4000 ether);
console.log("Approved tokens to: ", address(conMan));
vm.stopPrank();
_;
}
function testGasUsedWhenThereAreManyClaimants() public mintAndApproveTokens2 {
// Setup token balances for manager and fund the pot with 3000 tokens.
// Players: 3000, rewards: 1 ether each, totalRewards: 3000 ether.
players = new address[](3000);
rewards = new uint256[](3000);
for (uint256 i = 0; i < 3000; i++) {
players[i] = address(uint160(i + 1));
rewards[i] = 1 ether;
}
// Creante and fund the contest
vm.startPrank(user);
contest = ContestManager(conMan).createContest(players, rewards, IERC20(ERC20Mock(weth)), 3000 ether);
ContestManager(conMan).fundContest(0);
vm.stopPrank();
// Players claim their cuts
for (uint256 i = 0; i < 2999; i++) {
vm.prank(players[i]);
Pot(contest).claimCut();
}
// Fast-forward >90 days and close
// Also measure gas used
vm.warp(91 days);
vm.startPrank(user);
uint256 gasBefore = gasleft();
ContestManager(conMan).closeContest(contest);
uint256 gasUsed = gasBefore - gasleft();
vm.stopPrank();
emit log_named_uint("Gas used for 2999 claimants", gasUsed);
}

Output:

[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/TestMyCut.t.sol:TestMyCut
[PASS] testGasUsedWhenThereAreManyClaimants() (gas: 459763825)
Logs:
User Address: 0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D
Contest Manager Address 1: 0x7BD1119CEC127eeCDBa5DCA7d1Bd59986f6d7353
Minting tokens to: 0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D
Approved tokens to: 0x7BD1119CEC127eeCDBa5DCA7d1Bd59986f6d7353
Gas used for 2999 claimants: 15876669
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 198.20ms (196.20ms CPU time)
Ran 1 test suite in 199.97ms (198.20ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended Mitigation

  • Use a pull‑payment or batched payout model to avoid looping over all claimants in one transaction.

  • Record each claimant’s pro‑rata share at closure, set a closed flag, and let claimants individually withdraw their share. This transforms the O(N) loop into O(1) per claimant.

- for (uint256 i = 0; i < claimants.length; i++) {
- _transferReward(claimants[i], claimantCut);
- }
+ closed = true;
+ nClaimants = claimants.length;
+ require(nClaimants > 0, "no-claimants");
+ postCloseShare = (remainingRewards - managerCut) / nClaimants;
+ // Claimants withdraw individually:
+ // function claimPostClose() external {
+ // require(closed, "not-closed");
+ // require(wasClaimant[msg.sender], "not-claimant");
+ // require(!claimedPostClose[msg.sender], "already");
+ // claimedPostClose[msg.sender] = true;
+ // i_token.transfer(msg.sender, postCloseShare);
+ // remainingRewards -= postCloseShare;
+ // }
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 6 hours 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!