Root + Impact
closePot reads remainingRewards, pays the manager cut and each claimant, but never sets remainingRewards = 0 and has no closed flag. Every subsequent call re-derives the same cut from the same remainingRewards and pays claimants again until the pot balance is exhausted.
Description
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);
}
}
}
Risk
Likelihood: Trivially reached by calling closeContest twice.
Impact: Repeated manager cuts and repeated claimant payouts drain the pot and over-distribute; stale remainingRewards misreports balances.
Proof of Concept
function testCloseRepeatableDoubleChargesManagerCut() public {
address[] memory p = new address[](1);
p[0] = alice;
uint256[] memory r = new uint256[](1);
r[0] = 100e18;
vm.prank(owner);
Pot pot = Pot(manager.createContest(p, r, IERC20(address(token)), 100e18));
vm.prank(owner);
manager.fundContest(0);
vm.warp(block.timestamp + 91 days);
vm.prank(owner);
manager.closeContest(address(pot));
uint256 afterFirst = token.balanceOf(address(manager));
vm.prank(owner);
manager.closeContest(address(pot));
uint256 afterSecond = token.balanceOf(address(manager));
assertEq(afterFirst, 10e18);
assertEq(afterSecond, 20e18, "manager cut charged twice from same remainingRewards");
assertEq(pot.getRemainingRewards(), 100e18, "remainingRewards never reconciled");
}
Recommended Mitigation
+ bool private i_closed;
function closePot() external onlyOwner {
+ if (i_closed) revert Pot__AlreadyClosed();
if (block.timestamp - i_deployedAt < 90 days) {
revert Pot__StillOpenForClaim();
}
+ i_closed = true;
if (remainingRewards > 0) {
...
}
+ remainingRewards = 0;
}