Pot.closePot performs the final distribution but does not record that it has run — there is no closed boolean, and remainingRewards is never zeroed after the distribution. The stale remainingRewards value remains equal to whatever it was just before the call.
Because closePot re-reads remainingRewards on each invocation, calling it a second (or third, or Nth) time runs the same manager-cut transfer again, draining additional tokens from the Pot on every call until the Pot's balance is exhausted.
Likelihood:
Any duplicate call by the admin — whether by mistake (frontend retry, double-click, transaction monitoring re-submission) or any automation re-invoking it — triggers another full distribution.
The README marks the admin as Trusted, but trust does not imply infallible operations; a contract relying on operator perfection for fund safety is a design smell.
Impact:
Each repeated call transfers another remainingRewards / 10 to the manager-cut recipient and, when there are claimants, another (remainingRewards - managerCut) / i_players.length to each.
The Pot's actual token balance, after the first close, is less than remainingRewards; eventually _transferReward calls revert mid-loop due to insufficient balance — but not before extra tokens have already been diverted on each prior call.
Combined with finding #2 (manager cut sent to ContestManager), each repeat call drains another ~10% of the original remainingRewards into the unrecoverable ContestManager balance.
Run with forge test --match-test test_closePotCallableMultipleTimes -vvv. Test passes.
Make closePot idempotent by zeroing remainingRewards (or setting a dedicated closed flag) at the end of the distribution block.
A dedicated boolean is even clearer:
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.