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