MyCut

AI First Flight #8
Beginner FriendlyFoundry
EXP
View results
Submission Details
Impact: medium
Likelihood: high
Invalid

No lifecycle state — `closePot` never closes anything: players can claim after the 90-day window and the pot can be "closed" repeatedly

Root + Impact

Description

Normal behavior: The claim period is 90 days. After it elapses the owner closes the pot: the manager takes the cut, the remainder goes to timely claimants, and the pot is done — late players forfeit, and no further claims or closures can occur.

The issue: The program has no closed/terminal state at all:

  • claimCut only checks that the caller has a reward — never that the claim period is still open. A player who never claimed within 90 days can call claimCut after closePot and still withdraw their full reward whenever enough tokens remain in the pot.

  • closePot updates no state: it does not set a flag, does not zero remainingRewards, and does not clear the payout. It can be called any number of times; each call with a sufficient balance moves another remainingRewards / 10 to the manager, so the "manager cut" is not a one-time 10% but a repeatable drain (worst case with zero claimants: repeated closes move 10% of the original remainder each time until the pot is nearly empty).

Root cause in src/Pot.sol:

function claimCut() public {
address player = msg.sender;
uint256 reward = playersToRewards[player];
if (reward <= 0) {
revert Pot__RewardNotFound();
}
// @> no check that the pot is still open / within 90 days — claimable after closePot
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();
}
if (remainingRewards > 0) {
uint256 managerCut = remainingRewards / managerCutPercent;
i_token.transfer(msg.sender, managerCut);
// @> no state is written anywhere in closePot:
// @> - no closed flag -> claimCut stays callable
// @> - remainingRewards untouched -> the cut can be taken again and again
...
}
}

Risk

Likelihood:

  • Reason 1 — The 90-day "claim or forfeit" rule is enforced nowhere; a late player who notices the pot still holds funds can claim at any later time with a single transaction.

  • Reason 2 — closePot is callable by the owner repeatedly whenever the balance covers the cut, with no terminal state to stop it.

Impact:

  • Impact 1 — The forfeiture mechanic (the entire point of the 90-day window) is bypassable: late claims succeed after closure, so the remaining-pool math can be upset after the split already ran.

  • Impact 2 — No state is ever final: repeated closePot calls keep paying the manager beyond the intended single 10% cut, and the pot never transitions to "closed".

Proof of Concept

Foundry test (kept in poc/MyCutPoC.t.sol) — passes, showing a late claim succeeding after the pot was closed and that closePot left the pot state open:

function testPoC_ClaimAfterCloseBypassesDeadline() public {
// 3 players x 100; only player1 claims within 90 days
...
vm.prank(owner);
address contest = manager.createContest(players, rewards, IERC20(address(token)), 300);
vm.prank(owner);
manager.fundContest(0);
vm.prank(player1);
Pot(contest).claimCut(); // only player1 claims in time (100)
vm.warp(91 days);
vm.prank(owner);
manager.closeContest(contest); // should end the claim period
// closePot changes NO state: no closed flag, remainingRewards still 200
assertEq(Pot(contest).getRemainingRewards(), 200, "closePot left the pot open");
// player2 never claimed within the 90 days, yet can still claim the FULL 100 now:
uint256 before = token.balanceOf(player2);
vm.prank(player2);
Pot(contest).claimCut(); // succeeds — no "closed" state is ever set
assertEq(token.balanceOf(player2) - before, 100, "late claim succeeded after close");
}

Console output of the run:

$ forge test --match-contract MyCutPoC --match-test testPoC_ClaimAfterCloseBypassesDeadline -vv
[PASS] testPoC_ClaimAfterCloseBypassesDeadline() (gas: 1152366)
Suite result: ok. 1 passed; 0 failed; 0 skipped

Recommended Mitigation

Add a terminal state and gate both functions on it:

+ bool private s_closed;
function claimCut() public {
+ require(!s_closed, Pot__PotClosed());
...
}
function closePot() external onlyOwner {
if (block.timestamp - i_deployedAt < 90 days) {
revert Pot__StillOpenForClaim();
}
if (remainingRewards > 0) {
...
}
+ // terminal: no further claims, no repeatable manager cuts
+ s_closed = true;
+ remainingRewards = 0;
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 2 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!