MyCut

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

Zero claimants in closePot causes 90% of all pot funds to be permanently locked with no recovery path

Root + Impact

Description

  • closePot() distributes rewards in two steps: it sends 10% of remainingRewards to the manager, then iterates over the claimants array to distribute the remaining 90% in equal shares.

  • When claimants is empty (no player called claimCut() before closure), the loop body never executes. The manager receives 10%, claimantCut is computed to a non-zero value but never transferred, and 90% of all contributed funds are trapped in the contract with no sweep function, no emergency withdrawal, and no second-call mechanism that could recover them.

uint256 managerCut = remainingRewards / managerCutPercent; // 10% sent to manager
i_token.transfer(msg.sender, managerCut);
// claimantCut is non-zero but loop never executes when claimants is empty
uint256 claimantCut = (remainingRewards - managerCut) / i_players.length;
for (uint256 i = 0; i < claimants.length; i++) {
_transferReward(claimants[i], claimantCut); // unreachable
}

Risk

Likelihood:

  • A pot where all players lose interest, miss the deadline due to a front-end outage, or encounter technical issues is a realistic scenario — especially for low-participation contests or test deployments.

  • A pot with a single player who is blacklisted from claiming (USDC blocklist) also results in zero successful claims, triggering the full lock.

Impact:

  • 90% of the entire pot value is permanently locked. On a 1,000,000 token pot, 900,000 tokens are unrecoverable.

  • The contract has no emergencyWithdraw, no owner sweep, and no mechanism to re-call closePot in a way that would release funds — the 90-day gate is already satisfied after the first call.

Proof of Concept

Place this test in test/ and run forge test --match-test testZeroClaimantsLocksNinetyPercent. The test demonstrates that when no player calls claimCut(), closePot() permanently locks 90% of the reward pool.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {Pot} from "src/Pot.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
contract H2Test is Test {
MockERC20 token;
Pot pot;
address manager = address(0x1);
address player1 = address(0x2);
function setUp() public {
token = new MockERC20();
address[] memory players = new address[](1);
players[0] = player1;
uint256[] memory rewards = new uint256[](1);
rewards[0] = 1_000_000e18;
token.mint(manager, 1_000_000e18);
vm.prank(manager);
pot = new Pot(players, rewards, token, 1_000_000e18);
vm.prank(manager);
token.transfer(address(pot), 1_000_000e18);
}
function testZeroClaimantsLocksNinetyPercent() public {
// No one calls claimCut()
uint256 managerBalanceBefore = token.balanceOf(manager);
uint256 expectedManagerCut = 1_000_000e18 / 10; // 10% of pot
vm.warp(block.timestamp + 91 days);
vm.prank(manager);
pot.closePot();
// Manager received 10%
assertEq(token.balanceOf(manager), managerBalanceBefore + expectedManagerCut);
// 90% permanently locked
assertEq(token.balanceOf(address(pot)), 900_000e18);
// No recovery mechanism exists
// A second closePot call would attempt to distribute the remaining 90%
// but claimants is still empty — same outcome
}
}

Recommended Mitigation

Add a zero-claimants guard before the division: if claimants.length == 0, transfer the entire distributable amount to the owner rather than attempting division, preventing both the revert and the fund lock.

+ if (claimants.length == 0) {
+ i_token.transfer(owner(), remainingRewards - managerCut);
+ } else {
uint256 claimantCut = (remainingRewards - managerCut) / claimants.length;
for (uint256 i = 0; i < claimants.length; i++) {
_transferReward(claimants[i], claimantCut);
}
+ }
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 2 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[M-01] Incorrect Handling of Zero Claimants in `closePot()` Function

## Description In the \`closePot\` function, if the number of claimants is zero, the remaining rewards intended for distribution among claimants may not be properly reclaimed by the Contest Manager. The \`claimantCut\` is calculated using the length of the \`i_players\` array instead of the \`claimants\` array, which could lead to incorrect distribution. Additionally, the function does not have a mechanism to handle the scenario where there are zero claimants, resulting in the potential loss of rewards. ## Vulnerability Details Specifically, when there are no claimants: - The manager's cut is calculated but only a portion or none of the remaining rewards is transferred back to the Contest Manager. - The rewards intended for claimants (\`claimantCut\`) are not distributed because the loop iterating over \`claimants\` does not execute, but there's also no fallback to reclaim these rewards. ## Proof of Concept Add this test in the TestMyCut.t.sol: ```markdown function testClosePotWithZeroClaimants() public mintAndApproveTokens { vm.startPrank(user); // Step 1: Create a new contest contest = ContestManager(conMan).createContest(players, rewards, IERC20(weth), totalRewards); // Step 2: Fund the pot ContestManager(conMan).fundContest(0); // Step 3: Move forward in time by 90 days so the pot can be closed vm.warp(block.timestamp + 90 days); // Step 4: Close the pot with 0 claimants uint256 managerBalanceBefore = weth.balanceOf(user); ContestManager(conMan).closeContest(contest); uint256 managerBalanceAfter = weth.balanceOf(user); vm.stopPrank(); // Step 5: Assert that the Contest Manager received all the remaining rewards // Since there are no claimants, the manager should receive all remaining rewards assertEq(managerBalanceAfter, managerBalanceBefore + totalRewards, "Manager did not reclaim all rewards after closing pot with zero claimants."); ``` In the test `testClosePotWithZeroClaimants`, after closing a pot with zero claimants, the Contest Manager is unable to reclaim all the remaining rewards: ```markdown ├─ [9811] ContestManager::closeContest(Pot: [0x43e82d2718cA9eEF545A591dfbfD2035CD3eF9c0]) │ ├─ [8956] Pot::closePot() │ │ ├─ [5288] 0x5929B14F2984bBE5309c2eC9E7819060C31c970f::transfer(ContestManager: [0x7BD1119CEC127eeCDBa5DCA7d1Bd59986f6d7353], 0) │ │ │ ├─ emit Transfer(from: Pot: [0x43e82d2718cA9eEF545A591dfbfD2035CD3eF9c0], to: ContestManager: [0x7BD1119CEC127eeCDBa5DCA7d1Bd59986f6d7353], value: 0) ``` ## Impact - This bug can lead to incomplete recovery of rewards by the Contest Manager. If no participants claim their rewards, a significant portion of the remaining tokens could remain locked in the contract indefinitely, leading to financial loss and inefficient fund management. - And All the reward is lost except from the little 10 % the manager gets because there was no mechanism to claim the remainingReward ## Recommendations - Adjust Calculation Logic: Modify the \`claimantCut\` calculation to divide by \`claimants.length\` instead of \`i_players.length\`. This ensures that only the claimants are considered when distributing the remaining rewards. - Handle Zero Claimants: Implement a check to determine if there are zero claimants. If true, all remaining rewards should be transferred back to the Contest Manager to ensure no tokens are left stranded in the contract. Example ```markdown if (claimants.length == 0) { i_token.transfer(msg.sender, remainingRewards); } else { for (uint256 i = 0; i < claimants.length; i++) { \_transferReward(claimants[i], claimantCut); } } ``` This approach ensures that in the event of zero claimants, all remaining rewards are securely returned to the Contest Manager.

Support

FAQs

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

Give us feedback!