MyCut

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

`Pot.closePot` never zeroes `remainingRewards` or sets a closed flag, allowing it to be invoked multiple times and repeatedly drain the manager cut from the Pot

Root + Impact

Description

  • Pot.closePot performs the final distribution but does not record that it has run — there is no closed boolean, and remainingRewards is never zeroed after the distribution. The stale remainingRewards value remains equal to whatever it was just before the call.

  • Because closePot re-reads remainingRewards on each invocation, calling it a second (or third, or Nth) time runs the same manager-cut transfer again, draining additional tokens from the Pot on every call until the Pot's balance is exhausted.

// src/Pot.sol :: closePot
function closePot() external onlyOwner {
if (block.timestamp - i_deployedAt < 90 days) revert Pot__StillOpenForClaim();
@> if (remainingRewards > 0) { // remainingRewards is NEVER zeroed below
@> 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);
}
@> // <no remainingRewards = 0, no closed = true>
}
}

Risk

Likelihood:

  • Any duplicate call by the admin — whether by mistake (frontend retry, double-click, transaction monitoring re-submission) or any automation re-invoking it — triggers another full distribution.

  • The README marks the admin as Trusted, but trust does not imply infallible operations; a contract relying on operator perfection for fund safety is a design smell.
    Impact:

  • Each repeated call transfers another remainingRewards / 10 to the manager-cut recipient and, when there are claimants, another (remainingRewards - managerCut) / i_players.length to each.

  • The Pot's actual token balance, after the first close, is less than remainingRewards; eventually _transferReward calls revert mid-loop due to insufficient balance — but not before extra tokens have already been diverted on each prior call.

  • Combined with finding #2 (manager cut sent to ContestManager), each repeat call drains another ~10% of the original remainingRewards into the unrecoverable ContestManager balance.

Proof of Concept

function test_closePotCallableMultipleTimes() public {
Pot pot = _setupPot5Players1000Each();
vm.warp(block.timestamp + 91 days);
vm.prank(owner);
manager.closeContest(address(pot));
uint256 mgrBalAfterFirst = token.balanceOf(address(manager));
// remainingRewards is NOT zeroed after closePot
assertEq(pot.getRemainingRewards(), 5000e18);
// Owner calls closeContest again — another 500 transferred
vm.prank(owner);
manager.closeContest(address(pot));
assertEq(token.balanceOf(address(manager)) - mgrBalAfterFirst, 500e18);
}

Run with forge test --match-test test_closePotCallableMultipleTimes -vvv. Test passes.

Recommended Mitigation

Make closePot idempotent by zeroing remainingRewards (or setting a dedicated closed flag) at the end of the distribution block.

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

A dedicated boolean is even clearer:

+bool private closed;
+
function closePot() external onlyOwner {
+ if (closed) revert Pot__AlreadyClosed();
if (block.timestamp - i_deployedAt < 90 days) revert Pot__StillOpenForClaim();
+ closed = true;
// ...
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 17 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!