MyCut

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

closePot never resets remainingRewards, allowing repeated manager and claimant payouts that drain the Pot

Root + Impact

closePot is intended to close a funded Pot once, 90 days after deployment, paying the 10% manager cut and distributing the remaining rewards to claimants. The function never resets remainingRewards, so every call re-executes the full distribution: the manager cut is paid again and every claimant receives the claimant cut again. The Pot can be drained to dust while remainingRewards still reports the original balance, and the manager cut is transferred to the ContestManager contract, which has no withdrawal path.

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 set to zero, so closePot can run again

Risk

Likelihood: Medium. A funded Pot with unclaimed rewards reaches the 90-day mark and the owner calls closeContest; every subsequent call repeats the full payout for as long as the token balance covers it. Overfunding via repeated fundContest calls (reported separately) extends the drain.

Impact: High. Claimants receive the full remaining pool on top of their own claims, unclaimed players lose their share, the manager cut is paid repeatedly, and the Pot's token balance is exhausted. The repeated manager cuts are transferred to ContestManager, where they remain permanently locked.

Proof of Concept

Verified locally with forge 1.7.1 / solc 0.8.28 (one setup-only time-warp cheatcode):

function test_closePotRepeatedDistributionDrains() public {
cm.fundContest(0);
cm.fundContest(0); // overfunding: pot balance = 120, recorded total = 60
Pot(pot).claimCut(); // test contract claims its 30
_advance90Days();
for (uint256 i = 0; i < 5; i++) {
cm.closeContest(pot);
}
assertEq(token.balanceOf(pot), 10); // pot drained to dust
assertGt(Pot(pot).getRemainingRewards(), 0); // root cause: never reset
assertEq(token.balanceOf(address(this)), 10_000 ether - 120 + 30 + 65); // claimant overpaid
assertEq(token.balanceOf(address(cm)), 15); // manager cuts stuck in ContestManager
}

Each of the five calls pays the claimant 13 from the same remainingRewards = 30 (30-claim scenario), i.e. 65 extra to a player entitled to 30, plus 15 in manager cuts locked in ContestManager.

Recommended Mitigation

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 = 0;
}
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!