MyCut

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

closePot is not idempotent - calling it more than once re-distributes the same remainingRewards and drains the pot repeatedly

Description

Pot.closePot distributes the manager's cut and the claimants' cut out of remainingRewards, but never resets remainingRewards (or any other state) once it's done, and has no "already closed" guard:

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
}

Once the 90-day window has passed, closePot() can be called again — and again — and each call re-distributes the exact same remainingRewards figure, transferring the manager cut and every claimant's cut out of the pot's actual token balance a second, third, etc. time, until the pot is out of funds.

Risk

Likelihood: High

  • No special conditions: any call to closePot() after the first one reproduces the bug. ContestManager.closeContest is a thin, unguarded onlyOwner wrapper around it.

Impact: High

  • Real funds leave the pot on every repeat call — draining the pot's actual token balance beyond what remainingRewards was ever supposed to distribute, until the underlying ERC20 balance is exhausted.

Proof of Concept

Calling closeContest twice pays the manager's cut twice, from the same remainingRewards figure that was never reset:

function test_H1_closePotIsNotIdempotent_doublePayout() public {
// pot funded with 900, nobody claims
vm.warp(91 days);
ContestManager(conMan).closeContest(contest); // pays 90 (900/10)
assertEq(Pot(contest).getRemainingRewards(), 900); // never reset!
ContestManager(conMan).closeContest(contest); // pays 90 AGAIN
// total paid out: 180, double the intended 90
}

Recommended Mitigation

Track closed state and reset the distributable amount:

+ bool private closed;
function closePot() external onlyOwner {
+ if (closed) revert Pot__AlreadyClosed();
+ closed = true;
if (block.timestamp - i_deployedAt < 90 days) revert Pot__StillOpenForClaim();
if (remainingRewards > 0) {
uint256 managerCut = remainingRewards / managerCutPercent;
+ uint256 distributable = remainingRewards;
+ remainingRewards = 0;
i_token.transfer(msg.sender, managerCut);
- uint256 claimantCut = (remainingRewards - managerCut) / i_players.length;
+ uint256 claimantCut = (distributable - managerCut) / i_players.length;
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!