MyCut

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

`closePot()` Can Be Executed Repeatedly, Re-Distributing the Same Remaining Rewards

closePot() Can Be Executed Repeatedly, Re-Distributing the Same Remaining Rewards

Description

Pot.closePot() only checks whether 90 days have passed since deployment. After that moment, there is no lifecycle state indicating that the pot has already been closed.

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);
}
}
}

Because remainingRewards is not finalized after close and no isClosed guard exists, subsequent calls execute payout logic again from the same nominal remainingRewards base. In effect, the pot does not have single-use finalization semantics.


Risk

Likelihood: Medium

Only the authorized contest owner path can trigger closure, so this is not a public exploit. However, once the claim period has elapsed, repeated execution is trivial and can happen via script retries, operational mistakes, or flawed automation.

Impact: High

Repeated closure causes duplicate manager and claimant distributions, violating final payout invariants and draining contest funds beyond intended one-time settlement.


Proof of Concept

The behavior is demonstrated by testClosePot_CanBeCalledMultipleTimes_AndPaysManagerAgain().

function testClosePot_CanBeCalledMultipleTimes_AndPaysManagerAgain() public {
address[] memory players = new address[](3);
players[0] = p1;
players[1] = p2;
players[2] = p3;
uint256[] memory rewards = new uint256[](3);
rewards[0] = 34;
rewards[1] = 33;
rewards[2] = 33;
(address contest, Pot pot) = _createAndFund(players, rewards, 100);
vm.warp(block.timestamp + 91 days);
uint256 managerBefore = token.balanceOf(address(manager));
vm.prank(owner);
manager.closeContest(contest);
vm.prank(owner);
manager.closeContest(contest);
uint256 managerAfter = token.balanceOf(address(manager));
// Each close uses the same remainingRewards=100:
// managerCut each call = 100 / 10 = 10
assertEq(managerAfter - managerBefore, 20);
assertEq(token.balanceOf(contest), 80);
assertEq(pot.getRemainingRewards(), 100);
}

Run:

forge test --match-test testClosePot_CanBeCalledMultipleTimes_AndPaysManagerAgain -vv

Output:

Ran 1 test for test/ClosePotMathEdgeCases.t.sol:ClosePotMathEdgeCases
[PASS] testClosePot_CanBeCalledMultipleTimes_AndPaysManagerAgain() (gas: 1070912)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 9.34ms (1.08ms CPU time)
Ran 1 test suite in 86.17ms (9.34ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

This confirms that calling close twice after the deadline pays out manager allocations again instead of enforcing one-time closure.


Recommended Mitigation

Add explicit finalization state and make closure idempotent. The state update should occur before transfers to preserve one-way lifecycle behavior.

+bool private s_closed;
function closePot() external onlyOwner {
+ require(!s_closed, "Pot already closed");
if (block.timestamp - i_deployedAt < 90 days) {
revert Pot__StillOpenForClaim();
}
+ s_closed = true;
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);
}
}

This preserves the intended one-time settlement model and prevents duplicate post-deadline distributions.

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!