MyCut

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

closePot() can be called repeatedly, re-paying the manager cut and claimant shares each time

Root + Impact

Description

Closing a pot is meant to be a one-time finalization: take the manager cut and distribute the leftover, once. After that the pot should be closed. The problem is that closePot() never marks the pot as closed and never resets remainingRewards. Since remainingRewards keeps its value, the owner can call closePot (via ContestManager.closeContest) multiple times. Each call re-computes and re-transfers the manager cut and the claimant shares, draining the contract's balance far beyond the intended single distribution.

function closePot() external onlyOwner {
if (block.timestamp - i_deployedAt < 90 days) revert Pot__StillOpenForClaim();
if (remainingRewards > 0) { // @> gate never changes
uint256 managerCut = remainingRewards / managerCutPercent;
i_token.transfer(msg.sender, managerCut); // @> paid again each call
uint256 claimantCut = (remainingRewards - managerCut) / i_players.length;
for (uint256 i = 0; i < claimants.length; i++) {
_transferReward(claimants[i], claimantCut); // @> paid again each call
}
}
// @> remainingRewards never reset; no "closed" flag set
}

Risk

Likelihood:

  • The owner (ContestManager) can call closeContest -> closePot any number of times after the 90-day period; nothing prevents repeated calls.

Impact:

  • Each repeated call transfers the manager cut and claimant shares again, based on the stale remainingRewards, draining the pot's token balance.

  • Funds intended for a single distribution are paid out multiple times until the contract is emptied (later calls revert once balance is insufficient), corrupting all accounting.

Proof of Concept

Note: demonstrates that closePot has no "closed" flag and never resets remainingRewards, so closeContest -> closePot can be called repeatedly, re-paying the manager cut (and claimant shares) each time until the pot is drained. The missing state reset is evident from the source.

function test_PoC_ClosePotCallableMultipleTimes() public {
vm.warp(block.timestamp + 90 days + 1);
uint256 managerBalanceStart = token.balanceOf(owner);
// First close: pays manager cut + claimant shares
vm.prank(owner);
contestManager.closeContest(address(pot));
uint256 managerAfterFirst = token.balanceOf(owner);
// remainingRewards was NOT reset -> closePot can run again
// Second close: pays the manager cut AGAIN on the stale remainingRewards
vm.prank(owner);
contestManager.closeContest(address(pot));
uint256 managerAfterSecond = token.balanceOf(owner);
// Manager received a cut on BOTH calls (double payout)
assertGt(managerAfterFirst, managerBalanceStart);
assertGt(managerAfterSecond, managerAfterFirst); // paid a second time
// The pot is drained beyond the intended single distribution
}

Recommended Mitigation

Reset remainingRewards to 0 after distribution (and/or add a closed boolean set once). This makes closePot idempotent and prevents repeated payouts.

function closePot() external onlyOwner {
if (block.timestamp - i_deployedAt < 90 days) revert Pot__StillOpenForClaim();
+ if (remainingRewards == 0) revert Pot__AlreadyClosed();
if (remainingRewards > 0) {
uint256 managerCut = remainingRewards / managerCutPercent;
i_token.transfer(msg.sender, managerCut);
uint256 claimantCut = (remainingRewards - managerCut) / claimants.length;
for (uint256 i = 0; i < claimants.length; i++) {
_transferReward(claimants[i], claimantCut);
}
+ remainingRewards = 0; // finalize: prevent re-distribution
}
}
Updates

Lead Judging Commences

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