Description
-
Players should only be able to claim their per‑player reward during the 90‑day claim window. After 90 days have elapsed since the contest was opened (or funded, depending on policy), claiming should be blocked, and only the closing/redistribution logic should run.
-
Pot.claimCut() has no time gate and no closed flag. Even when more than 90 days have passed since deployment, players can still call claimCut() and receive their original allocation. This violates the intended claim window and also corrupts post‑close redistribution (since late claimants can join after the deadline).
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);
}
Risk
Likelihood: Medium
-
In routine operations where the manager delays funding or close actions, time will exceed 90 days since deployment while the pot remains open; players can and will continue to claim in that period.
-
The path is consistently reachable because there is no guard in claimCut() to prevent late claims.
Impact: Medium
-
Policy breach / fairness violation: Players who missed the 90‑day window can still collect, contrary to the contest rules.
-
Accounting corruption: Late claims reduce remainingRewards after the deadline, altering the base used for the manager cut and claimant redistribution, causing under/over‑payments and stranded balances.
Proof of Concept
function testPlayerCanClaimAfter90DaysWindow() public mintAndApproveTokens {
vm.prank(user);
contest = ContestManager(conMan).createContest(players, rewards, IERC20(ERC20Mock(weth)), 4);
uint256 i_deployedAt = block.timestamp;
vm.warp(100 days);
vm.prank(user);
ContestManager(conMan).fundContest(0);
assertTrue(block.timestamp - i_deployedAt > 90 days);
emit log_named_uint("Days since deployment", (block.timestamp - i_deployedAt) / 1 days);
uint256 player1BalanceBefore = ERC20Mock(weth).balanceOf(player1);
emit log_named_uint("Player 1 balance before claiming cut", player1BalanceBefore);
vm.prank(player1);
Pot(contest).claimCut();
uint256 player1BalanceAfter = ERC20Mock(weth).balanceOf(player1);
emit log_named_uint("Player 1 balance after claiming cut", player1BalanceAfter);
assert(player1BalanceAfter > player1BalanceBefore);
}
Output:
[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/TestMyCut.t.sol:TestMyCut
[PASS] testPlayerCanClaimAfter90DaysWindow() (gas: 1102317)
Logs:
User Address: 0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D
Contest Manager Address 1: 0x7BD1119CEC127eeCDBa5DCA7d1Bd59986f6d7353
Minting tokens to: 0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D
Approved tokens to: 0x7BD1119CEC127eeCDBa5DCA7d1Bd59986f6d7353
Days since deployment: 99
Player 1 balance before claiming cut: 0
Player 1 balance after claiming cut: 3
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.97ms (418.20µs CPU time)
Ran 1 test suite in 13.86ms (1.97ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Recommended Mitigation
- contract Pot is Ownable(msg.sender) {
+ contract Pot is Ownable {
+ bool public closed;
+ // If you’ve implemented funding tracking:
+ // bool public funded;
+ // uint256 public fundedAt;
function claimCut() public {
+ // Block claims after the 90-day window since deployment (or since funding).
+ require(block.timestamp - i_deployedAt < 90 days, "claim-window-closed");
+ require(!closed, "pot-closed");
+ // If using funded policy:
+ // require(funded && block.timestamp - fundedAt < 90 days, "claim-window-closed");
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);
}
function closePot() external onlyOwner {
if (block.timestamp - i_deployedAt < 90 days) {
revert Pot__StillOpenForClaim();
}
+ require(!closed, "already-closed");
if (remainingRewards > 0) {
uint256 managerCut = remainingRewards / managerCutPercent;
i_token.transfer(msg.sender, managerCut);
uint256 claimantCut = (remainingRewards - managerCut) / i_players.length; // fix separately
for (uint256 i = 0; i < claimants.length; i++) {
_transferReward(claimants[i], claimantCut);
}
}
+ closed = true;
}