MyCut

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

closePot() is not idempotent: remainingRewards is never updated, so the same settlement is paid out repeatedly

Root + Impact

Description

closePot() performs the final settlement but never updates the accounting it reads from.

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 decreased and no "closed" flag is set
}
}

remainingRewards is only ever reduced by claimCut(). The settlement pays the manager and every claimant, but leaves the variable untouched, so the guard if (remainingRewards > 0) still passes on the next call and the whole payout runs again against whatever balance is left.

The result is a settlement that is not idempotent. Each additional call pays the manager another remainingRewards / 10 and each claimant another (remainingRewards - managerCut) / i_players.length, draining the pot in repeated rounds long after the single settlement the protocol describes.

Risk

Likelihood: Medium. The function is restricted to the owner, but the payout repeats on every call while the balance covers it, so a retry after a failed batch, a duplicated operations script or a re-run for a second batch of claimants silently pays everyone a second time. Nothing in the contract prevents it.

Impact: High. Funds reserved for the contest are paid out more than once: the manager receives a repeated cut and each claimant receives repeated settlement shares, while the recorded remainingRewards still reports the original figure. The pot is drained below what the accounting says it should hold.

Proof of Concept

Four players, each entitled to 1000, pool of 4000. One player claims, the contest is closed twice, and the same claimant is paid twice while the accounting never moves.

// Actors:
// - Manager: owner of ContestManager, calls closeContest
// - p1: the only player who claimed before the deadline
// - Protocol: ContestManager + Pot
​
function test_poc_close_pot_not_idempotent() public {
vm.prank(p1);
Pot(contest).claimCut(); // p1 takes 1000, remainingRewards = 3000
assertEq(Pot(contest).getRemainingRewards(), 3000);
​
vm.warp(block.timestamp + 91 days);
​
uint256 before = token.balanceOf(p1);
​
ContestManager(mgr).closeContest(contest); // first settlement
uint256 afterFirst = token.balanceOf(p1);
uint256 potAfterFirst = token.balanceOf(contest);
​
ContestManager(mgr).closeContest(contest); // identical call, second settlement
uint256 afterSecond = token.balanceOf(p1);
uint256 potAfterSecond = token.balanceOf(contest);
​
assertGt(afterFirst, before); // +675
assertGt(afterSecond, afterFirst); // +675 again
assertLt(potAfterSecond, potAfterFirst); // 2025 -> 1050
assertEq(Pot(contest).getRemainingRewards(), 3000); // accounting unchanged
}

Logged values: first close pays 675, second close pays another 675, pot balance goes 4000 -> 2025 -> 1050, remainingRewards stays at 3000.

Recommended Mitigation

Make the settlement single-shot: zero the accounting before transferring and record that the pot is closed.

+ bool private s_closed;
+
function closePot() external onlyOwner {
if (block.timestamp - i_deployedAt < 90 days) {
revert Pot__StillOpenForClaim();
}
+ if (s_closed) revert Pot__AlreadyClosed();
+ s_closed = true;
+
if (remainingRewards > 0) {
uint256 managerCut = remainingRewards / managerCutPercent;
+ uint256 claimantCut = (remainingRewards - managerCut) / claimants.length;
+ remainingRewards = 0;
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);
}
}
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 10 minutes 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!