MyCut

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

claimCut has no 90-day deadline and no awareness of pot closure, so claims remain (nominally) possible after close — reverting with a confusing ERC20 error for late players, or fully succeeding when the pot is over-funded

Root + Impact

Description

  • Normally, players claim within 90 days; after closePot finalizes the distribution, no further claims should be honored.

  • claimCut only checks playersToRewards[msg.sender] > 0 — a value that persists after close (close never clears the mapping). A late claimer therefore passes the entitlement check, but the pot's real balance was already distributed at close: the transfer reverts with a raw ERC20InsufficientBalance error. With an over-funded pot (V6) the same late claim SUCCEEDS, transferring the full original reward after closure and further desynchronizing ledger from balance.

// src/Pot.sol
function claimCut() public {
address player = msg.sender;
uint256 reward = playersToRewards[player];
if (reward <= 0) {
revert Pot__RewardNotFound();
}
@> // no deadline check, no closed check — 90-day window is unenforceable here
playersToRewards[player] = 0;
remainingRewards -= reward;
claimants.push(player);
_transferReward(player, reward);
}

Risk

Likelihood:

  • Any player who attempts a legitimate-looking claim after the admin closes the pot hits this path — checkCut() still reports their full reward as available, actively signaling they can claim.

  • Over-funding (V6) converts the revert into a successful post-close payout; combining double funding + close + late claims drains the excess.

Impact:

  • Late players are permanently locked out with a misleading error (ledger says 500 available, transfer fails), with no recovery path anywhere in the protocol.

  • In the over-funded case, post-close claims succeed and the pot balance diverges further from remainingRewards, compounding the accounting corruption of V4.

Proof of Concept

PoC explanation: Two complementary tests pin down both failure modes of the unenforced window. Part A (stranded late claimer): a 2-player pot ([500, 500], 1000 WETH) where player1 claims and the admin closes after 91 days (paying out 50 managerCut + 225 claimantCut, see V2). Observation: checkCut(p2) still reports 500 — the entitlement mapping was never cleared at close, actively inviting p2 to claim — but when p2 does, the transfer reverts with a raw ERC20InsufficientBalance because the real balance was already distributed. p2 is permanently locked out by a misleading error. Part B (successful post-close claim under over-funding): the same pot is funded twice (2000 WETH, per V6) before close; after close, p2's claimCut SUCCEEDS and pays the full 500 — proof that claimCut neither checks a closed flag nor the elapsed time, and that the ledger/balance desync of V4 deepens with each late claim. Run with: forge test --match-test test_POC5A_ClaimAfterClose_Reverts -vv and forge test --match-test test_POC5B_ClaimAfterClose_SucceedsWithOverfunding -vv (both PASS).

// forge test --match-test test_POC5A_ClaimAfterClose_Reverts -> PASS
function test_POC5A_ClaimAfterClose_Reverts() public {
(address[] memory players, uint256[] memory rewards) = _twoPlayers();
address pot = _createAndFund(players, rewards, 1000);
​
vm.prank(p1);
Pot(pot).claimCut();
​
vm.warp(91 days);
vm.prank(admin);
conMan.closeContest(pot); // pays out 50 + 225
​
assertEq(Pot(pot).checkCut(p2), 500, "ledger still says p2 can claim 500");
vm.prank(p2);
vm.expectRevert();
Pot(pot).claimCut(); // reverts: ERC20InsufficientBalance, p2 stranded forever
}
​
// forge test --match-test test_POC5B_ClaimAfterClose_SucceedsWithOverfunding -> PASS
function test_POC5B_ClaimAfterClose_SucceedsWithOverfunding() public {
(address[] memory players, uint256[] memory rewards) = _twoPlayers();
address pot = _createAndFund(players, rewards, 1000);
vm.prank(admin);
conMan.fundContest(0); // over-fund: pot = 2000 (see V6)
​
vm.prank(p1);
Pot(pot).claimCut();
​
vm.warp(91 days);
vm.prank(admin);
conMan.closeContest(pot);
​
vm.prank(p2);
Pot(pot).claimCut(); // claim AFTER close succeeds
assertEq(weth.balanceOf(p2), 500, "p2 claims full reward AFTER close");
}

Recommended Mitigation

// src/Pot.sol
function claimCut() public {
+ if (s_closed) revert Pot__AlreadyClosed();
+ if (block.timestamp - i_deployedAt >= 90 days) revert Pot__ClaimWindowClosed();
address player = msg.sender;
...

⚠ Combination note: a hard 90-day revert in claimCut creates a new griefing surface — an admin who never calls closeContest strands every late claimer. Ship this together with V4's s_closed and consider allowing ANYONE to trigger close after 90 days (payout recipient pinned to the admin), so the window is bounded even with an inactive admin.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 33 minutes 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!