Root + Impact
Description
-
A pot should be closed only once. The leftover is distributed a single time and the pot's accounting should reflect that.
-
closePot only checks that 90 days have passed. It never records that the pot was closed and never lowers remainingRewards, so every call pays the same managerCut and claimantCut again from the Pot's balance. Those tokens belong to players who haven't claimed yet: their rewards are still recorded, but the Pot can no longer pay them.
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:
-
When the owner's close transaction is sent again, e.g. a retry after a stuck/underpriced transaction, a keeper/automation script, or a UI double submit.
-
When closeContest is called on a pot that is already closed, since nothing on-chain shows the pot is closed.
Impact:
-
Each extra call pays out funds owed to players who haven't claimed yet; their claimCut then reverts and they lose their reward.
-
Early claimants get paid several times, and more manager cut gets locked in ContestManager. getRemainingRewards keeps reporting a balance that no longer exists.
Proof of Concept
3 calls to closeContest pay player1 its 67.5 bonus 3 times and leave only 7.5 in the Pot, while remainingRewards still reports 300.
function testClosePotCanBeCalledRepeatedly() public mintAndApproveTokens {
address player3 = makeAddr("player3");
address player4 = makeAddr("player4");
address[] memory ps = new address[](4);
ps[0] = player1; ps[1] = player2; ps[2] = player3; ps[3] = player4;
uint256[] memory rs = new uint256[](4);
for (uint256 i; i < 4; i++) rs[i] = 100e18;
vm.startPrank(user);
address pot = ContestManager(conMan).createContest(ps, rs, IERC20(address(weth)), 400e18);
ContestManager(conMan).fundContest(0);
vm.stopPrank();
vm.prank(player1);
Pot(pot).claimCut();
vm.warp(block.timestamp + 91 days);
vm.startPrank(user);
ContestManager(conMan).closeContest(pot);
ContestManager(conMan).closeContest(pot);
ContestManager(conMan).closeContest(pot);
vm.stopPrank();
assertEq(weth.balanceOf(pot), 7.5e18);
assertEq(weth.balanceOf(player1), 100e18 + 3 * 67.5e18);
assertEq(Pot(pot).getRemainingRewards(), 300e18);
}
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) {
...
+ remainingRewards = 0;
}
}