MyCut

AI First Flight #8
Beginner FriendlyFoundry
EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

closePot() can be called repeatedly, repaying the manager and claimants multiple times since `remainingRewards` is never decremented

closePot() can be called repeatedly, repaying the manager and claimants multiple times since remainingRewards is never decremented

Description

  • Normally, closePot() should be a one-time action that distributes whatever rewards were left unclaimed after 90 days — a manager cut and a split among claimants — and finalize the pot so no further payouts can occur.

  • The function never decrements remainingRewards (or sets any "closed" flag) after paying out, so its if (remainingRewards > 0) guard remains true after a successful close. Calling closePot() again re-executes the entire payout logic, sending the manager and every claimant another round of tokens, limited only by whatever balance the pot happens to still hold.

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;
for (uint256 i = 0; i < claimants.length; i++) {
_transferReward(claimants[i], claimantCut);
}
// @> remainingRewards is never updated here, so this branch stays
// @> reachable on every subsequent call to closePot()
}
}

Risk

Likelihood:

  • The owner (or ContestManager, which holds onlyOwner access) can call closePot() any number of times after the 90-day window opens, since nothing in the contract prevents a second call.

  • claimantCut is calculated by dividing by i_players.length while only iterating over claimants (a strict subset of players who actually claimed), so remainingRewards is virtually always left above zero after the first close — keeping the vulnerable branch open for repeat calls.

Impact:

  • Every repeat call re-pays the manager cut and the full claimant list again, extracting tokens beyond what totalRewards was ever meant to distribute.

  • The only limit on how much can be drained is the pot's actual token balance — repeated calls will keep paying out until a transfer reverts from insufficient balance, at which point the entire pot is gone.

Proof of Concept

function testCanReclaimRewardsAfterClosingContest() public mintAndApproveTokens {
vm.startPrank(user);
rewards = [500, 500];
totalRewards = 1000;
contest = ContestManager(conMan).createContest(players, rewards, IERC20(ERC20Mock(weth)), totalRewards);
ContestManager(conMan).fundContest(0);
vm.stopPrank();
vm.startPrank(player1);
Pot(contest).claimCut();
vm.stopPrank();
vm.warp(91 days);
uint256 managerBalanceBefore = ERC20Mock(weth).balanceOf(conMan);
// First close: pays out managerCut (50) + player1's claimantCut (225) = 275
vm.startPrank(conMan);
Pot(contest).closePot();
vm.stopPrank();
assertEq(ERC20Mock(weth).balanceOf(conMan), managerBalanceBefore + 50);
// Bug: remainingRewards is still 500 even though rewards were already paid out
assertEq(Pot(contest).getRemainingRewards(), 500);
// Second close: guard passes again. Manager is paid ANOTHER 50 before the
// loop reverts trying to overpay player1 with tokens the pot no longer holds.
vm.startPrank(conMan);
vm.expectRevert(); // only reverts because the pot happens to run out of balance
Pot(contest).closePot();
vm.stopPrank();
}

Trace confirms the second call's manager transfer (50 tokens) executes successfully before the loop reverts on player1's transfer with ERC20InsufficientBalance(175, 225) — proving the payout logic re-runs with no guard, and is only ever halted by accidentally running out of tokens rather than by design.

Recommended Mitigation

Track whether the pot has already been closed, and zero out remainingRewards after distribution so the branch can't be re-entered.

+ bool private potClosed;
+ error Pot__AlreadyClosed();
function closePot() external onlyOwner {
if (block.timestamp - i_deployedAt < 90 days) {
revert Pot__StillOpenForClaim();
}
+ if (potClosed) {
+ revert Pot__AlreadyClosed();
+ }
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++) {
_transferReward(claimants[i], claimantCut);
}
}
+ potClosed = true;
+ remainingRewards = 0;
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!