Root + Impact
Description
Normal behavior: After the 90-day claim window has passed, the Owner calls closePot() once to finalize the pot. The function should take the manager's cut from the remaining unclaimed rewards, distribute the rest equally among the addresses that claimed on time, and leave the pot fully settled with no further payouts possible.
The issue: closePot() never resets remainingRewards to zero after distribution, and there is no boolean flag or check to mark the pot as already closed. This means the function can be invoked an unlimited number of times after the 90-day mark, and each call recomputes managerCut and claimantCut from the same unchanged remainingRewards value, transferring tokens out again on every call.
Risk
Likelihood:
closePot() is onlyOwner-gated, so exploitation requires either the owner calling it again by mistake (e.g. re-triggered by automation/a script/a bot), or the owner key being compromised. Given the function has no safeguard at all, even a single accidental re-call is enough to trigger the bug — this is not a hard-to-reach edge case.
Impact:
Each repeated call drains additional tokens from the Pot contract: the manager receives remainingRewards / 10 again, and each address in claimants receives another claimantCut payout, even though they already received their share in the first call.
This continues until the contract's token balance can no longer cover a transfer, at which point transfer() either reverts or (for non-reverting ERC20s like USDT) silently fails.
Net effect: the pot pays out far more than the totalRewards it was funded with, directly at the expense of the protocol/manager's own funds, and breaks the core invariant that the total distributed never exceeds totalRewards.
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:
closePot() becomes callable as soon as the 90-day claim window elapses, and it has no restriction preventing a second call — the owner (or any automation/script acting on the owner's behalf) calling it more than once is a normal, easily-triggered action, not an edge case.
Protocols that finalize pots via off-chain schedulers, cron jobs, or keeper bots commonly retry transactions on failure or re-trigger the same call for redundancy — this pattern alone is enough to invoke closePot() twice.
Impact:
Every additional call to closePot() pays out the manager cut and the full claimant redistribution again from the same unreduced remainingRewards, directly draining tokens that do not belong to any of these recipients a second time.
The pot's balance can be repeatedly drained until it can no longer cover a transfer, breaking the core invariant that total payouts never exceed totalRewards, and permanently depleting funds meant for other contests or protocol operations.Risk
Likelihood:
Impact:
Proof of Concept
function testClosePotCanBeCalledMultipleTimes() public {
vm.warp(block.timestamp + 90 days + 1);
uint256 managerBalanceBefore = token.balanceOf(owner);
vm.prank(owner);
pot.closePot();
uint256 managerBalanceAfterFirstClose = token.balanceOf(owner);
assertGt(managerBalanceAfterFirstClose, managerBalanceBefore);
vm.prank(owner);
pot.closePot();
uint256 managerBalanceAfterSecondClose = token.balanceOf(owner);
assertGt(managerBalanceAfterSecondClose, managerBalanceAfterFirstClose);
}
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) {
- uint256 managerCut = remainingRewards / managerCutPercent;
+ uint256 payout = remainingRewards;
+ remainingRewards = 0;
+ uint256 managerCut = payout / managerCutPercent;
i_token.transfer(msg.sender, managerCut);
- uint256 claimantCut = (remainingRewards - managerCut) / i_players.length;
+ uint256 claimantCut = (payout - managerCut) / claimants.length;
for (uint256 i = 0; i < claimants.length; i++) {
_transferReward(claimants[i], claimantCut);
}
}
}