MyCut

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

`Pot::closePot` can be called multiple times, draining funds repeatedly due to missing closed-state guard

Root + Impact

Description

Normal behavior: After the 90-day claim window has passed, the Owner calls closePot() once to finalize the pot. The function should take the manager's cut from the remaining unclaimed rewards, distribute the rest equally among the addresses that claimed on time, and leave the pot fully settled with no further payouts possible.

The issue: closePot() never resets remainingRewards to zero after distribution, and there is no boolean flag or check to mark the pot as already closed. This means the function can be invoked an unlimited number of times after the 90-day mark, and each call recomputes managerCut and claimantCut from the same unchanged remainingRewards value, transferring tokens out again on every call.

Risk

Likelihood:

closePot() is onlyOwner-gated, so exploitation requires either the owner calling it again by mistake (e.g. re-triggered by automation/a script/a bot), or the owner key being compromised. Given the function has no safeguard at all, even a single accidental re-call is enough to trigger the bug — this is not a hard-to-reach edge case.

Impact:

Each repeated call drains additional tokens from the Pot contract: the manager receives remainingRewards / 10 again, and each address in claimants receives another claimantCut payout, even though they already received their share in the first call.

This continues until the contract's token balance can no longer cover a transfer, at which point transfer() either reverts or (for non-reverting ERC20s like USDT) silently fails.

Net effect: the pot pays out far more than the totalRewards it was funded with, directly at the expense of the protocol/manager's own funds, and breaks the core invariant that the total distributed never exceeds totalRewards.


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);
}
}
@> // <-- missing: remainingRewards = 0;
@> // <-- missing: closed = true; (no such state variable exists)
}

Risk

Likelihood:

closePot() becomes callable as soon as the 90-day claim window elapses, and it has no restriction preventing a second call — the owner (or any automation/script acting on the owner's behalf) calling it more than once is a normal, easily-triggered action, not an edge case.

Protocols that finalize pots via off-chain schedulers, cron jobs, or keeper bots commonly retry transactions on failure or re-trigger the same call for redundancy — this pattern alone is enough to invoke closePot() twice.

Impact:

Every additional call to closePot() pays out the manager cut and the full claimant redistribution again from the same unreduced remainingRewards, directly draining tokens that do not belong to any of these recipients a second time.

The pot's balance can be repeatedly drained until it can no longer cover a transfer, breaking the core invariant that total payouts never exceed totalRewards, and permanently depleting funds meant for other contests or protocol operations.Risk

Likelihood:

  • Reason 1 // Describe WHEN this will occur (avoid using "if" statements)

  • Reason 2

Impact:

  • Impact 1

  • Impact 2

Proof of Concept

function testClosePotCanBeCalledMultipleTimes() public {
// Setup: 90 days have passed, some players have claimed
vm.warp(block.timestamp + 90 days + 1);
uint256 managerBalanceBefore = token.balanceOf(owner);
vm.prank(owner);
pot.closePot(); // First call: distributes managerCut + claimantCut correctly
uint256 managerBalanceAfterFirstClose = token.balanceOf(owner);
assertGt(managerBalanceAfterFirstClose, managerBalanceBefore);
vm.prank(owner);
pot.closePot(); // Second call: remainingRewards was never reset,
// so the same managerCut and claimantCut are paid out AGAIN
uint256 managerBalanceAfterSecondClose = token.balanceOf(owner);
// Manager is paid a second time despite the pot already being "closed"
assertGt(managerBalanceAfterSecondClose, managerBalanceAfterFirstClose);
}

Recommended Mitigation

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