MyCut

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

When no player claims within the 90-day window, closePot distributes nothing to claimants and leaves 90% of the pot permanently frozen with no recovery path

Root + Impact

Description

  • Normally, when a contest ends with unclaimed rewards, the leftover should be recoverable — the manager takes the cut and the rest goes to the claimant pool (or back to the funder when the pool is empty).

  • When claimants is empty, the distribution loop executes zero iterations and no fallback branch exists, so only managerCut leaves the pot — and that cut itself goes to the non-withdrawable ContestManager (V1). The rest of the principal is trapped.

// src/Pot.sol
function closePot() external onlyOwner {
...
if (remainingRewards > 0) {
uint256 managerCut = remainingRewards / managerCutPercent;
i_token.transfer(msg.sender, managerCut);
uint256 claimantCut = (remainingRewards - managerCut) / i_players.length;
@> for (uint256 i = 0; i < claimants.length; i++) { // zero iterations when nobody claimed
_transferReward(claimants[i], claimantCut);
}
}
@> // no sweep, remainingRewards never reset, 90% of the pot is stranded
}

Risk

Likelihood:

  • Contests with zero participation (nobody wins / nobody bothers to claim a bounty) are a normal operating outcome, not an adversarial state — any such pot freezes 90% of its funding at close time.

  • Once 90 days elapse there is no alternative path for the trapped tokens: claimCut keeps reverting Pot__RewardNotFound for everyone, and the close branch shown above is the only outflow.

Impact:

  • With total funding of 1000 and zero claimants: 100 is frozen in the ContestManager (V1) and 900 in the Pot — 100% of the funding becomes unrecoverable (verified below).

  • Even for partially claimed pots, every non-divisible remainder left after the claimant loop strands permanently in the same way (see V10).

Proof of Concept

PoC explanation: The test reproduces the zero-participation outcome, which is a normal business case (a contest nobody bothered to claim). Setup: a 2-player pot ([500, 500]) funded with 1000 WETH; nobody calls claimCut. Step: after vm.warp(91 days) the admin closes the pot. Inside closePot, managerCut = 1000 / 10 = 100 is transferred (to the ContestManager, per V1), then the distribution loop runs zero iterations because claimants is empty, and no fallback branch exists. Assertions: the ContestManager holds 100, the pot still holds 900, and the admin's balance is unchanged at (10_000_000 - 1000) — i.e. every single token of the 1000 WETH funding is unrecoverable across the two contracts (100 frozen in CM per V1 + 900 stranded in the pot). Run with: forge test --match-test test_POC3_NoClaimants_AllFundsFrozen -vv (PASS; asserts 100 / 900 / unchanged admin balance).

// forge test --match-test test_POC3_NoClaimants_AllFundsFrozen -> PASS
function test_POC3_NoClaimants_AllFundsFrozen() public {
(address[] memory players, uint256[] memory rewards) = _twoPlayers();
address pot = _createAndFund(players, rewards, 1000);
​
vm.warp(91 days);
vm.prank(admin);
conMan.closeContest(pot);
​
assertEq(weth.balanceOf(address(conMan)), 100, "managerCut=100 frozen in CM");
assertEq(weth.balanceOf(pot), 900, "90% frozen in Pot");
assertEq(weth.balanceOf(admin), 10_000_000 - 1000, "admin gets nothing back");
}

Recommended Mitigation

// src/Pot.sol
function closePot(address payoutRecipient) external onlyOwner {
...
if (remainingRewards > 0) {
+ if (claimants.length == 0) {
+ i_token.transfer(payoutRecipient, remainingRewards);
+ remainingRewards = 0;
+ s_closed = true;
+ return;
+ }
uint256 managerCut = remainingRewards / managerCutPercent;
...

⚠ Combination note: this fix depends on V1 being fixed first — a fallback that still pays "the manager" via msg.sender would merely move the frozen 900 from the Pot into the equally non-withdrawable ContestManager. The fallback must also be guarded by the V4 closed flag, otherwise the owner can trigger the full-remainder payout repeatedly.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 33 minutes 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!