MyCut

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

`Pot::closePot` never updates `remainingRewards` or sets a closed flag — can be called repeatedly to drain stuck tokens

Root + Impact

Description

After closePot distributes the manager cut and claimant bonuses, it doesn't zero out remainingRewards or mark the pot as closed. Since the 90-day check still passes on subsequent calls, the owner can call closePot over and over. Each call sends another round of manager cut and claimant payouts from whatever tokens are still in the pot.

This is especially impactful combined with H-01: because H-01 leaves tokens stuck in the pot after the first close, those stuck tokens fund additional rounds of payouts that were never meant to happen.

// src/Pot.sol — closePot()
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);
}
}
// @> missing: remainingRewards = 0;
// @> missing: closed flag to prevent re-entry

Risk

Likelihood: The owner (trusted) has to call it multiple times. But since there's no guard at all, any compromised admin key or governance mistake triggers this. Also, if closeContest is accidentally called twice, claimants silently get double-paid.

Impact: Claimants receive extra payouts they shouldn't get. The accounting breaks — remainingRewards never reflects reality. Combined with H-01's stuck tokens, the pot can be drained across multiple close calls.


Proof of Concept

10 players, only player[0] claims. After the first close, 729 tokens are stuck (H-01). The second close succeeds and pays player[0] again.

function testH03_ClosePotCallableMultipleTimes() public {
address[] memory players = new address[](10);
uint256[] memory rewards = new uint256[](10);
for (uint256 i = 0; i < 10; i++) {
players[i] = makeAddr(string(abi.encodePacked("p", i)));
rewards[i] = 100e18;
}
vm.startPrank(owner);
address contest = conMan.createContest(players, rewards, IERC20(weth), 1000e18);
conMan.fundContest(0);
vm.stopPrank();
vm.prank(players[0]);
Pot(contest).claimCut(); // remainingRewards = 900
vm.warp(91 days);
uint256 bal0 = weth.balanceOf(players[0]);
vm.prank(owner);
conMan.closeContest(contest); // first close: pays 81 to player[0]
uint256 bal1 = weth.balanceOf(players[0]);
vm.prank(owner);
conMan.closeContest(contest); // second close: pays 81 AGAIN
uint256 bal2 = weth.balanceOf(players[0]);
assertTrue(bal2 - bal1 > 0); // double payment confirmed
}

Recommended Mitigation

Zero out remainingRewards after distributing so the if (remainingRewards > 0) guard prevents re-entry.

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 4 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!