MyCut

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

closePot is not idempotent — no closed flag exists and remainingRewards is never reset, so the owner can repeatedly close the same pot and drain it over multiple transactions at the expense of non-claiming players

Root + Impact

Description

  • Normally, closing a pot is a terminal operation: the manager takes the cut, claimants get their shares, and the pot is finalized.

  • closePot writes no state: there is no closed flag, and remainingRewards is not zeroed, so the 90-day guard is the only check — which remains satisfied forever after the first close. Each subsequent call repeats the exact same transfers while the actual pot balance keeps shrinking (ledger/balance desync).

// src/Pot.sol
function closePot() external onlyOwner {
if (block.timestamp - i_deployedAt < 90 days) {
revert Pot__StillOpenForClaim(); // still satisfied on 2nd..Nth call
}
if (remainingRewards > 0) { // never reset -> always true
uint256 managerCut = remainingRewards / managerCutPercent;
i_token.transfer(msg.sender, managerCut);
uint256 claimantCut = (remainingRewards - managerCut) / i_players.length;
for (uint256 i = 0; i < claimants.length; i++) {
_transferReward(claimants[i], claimantCut);
}
}
@> // no closed flag, remainingRewards not zeroed -> close is repeatable forever
}

Risk

Likelihood:

  • The owner simply calls closeContest repeatedly in separate transactions — nothing in the code prevents the 2nd..Nth call after the 90-day mark.

  • A single close only drains managerCut + claimants*claimantCut; in the verified low-claim-rate setup (10 players, 1 claimant) each round drains ~11% of remainingRewards, so five consecutive rounds succeed before the 6th reverts on insufficient balance.

Impact:

  • Verified: with 10 players x 100 (total 1000) and a single claimant, 5 close rounds transfer 450 to the ContestManager (frozen per V1), 905 to the claimant (905 - 500 entitlement = 405 taken from the 9 non-claiming players' shares), leaving 45 stranded — while remainingRewards still reads 900 on-chain.

  • The pot's accounting desynchronizes from its balance, breaking every downstream expectation and making audits/monitoring meaningless.

Proof of Concept

PoC explanation: The test drives closePot through five successful rounds on a single, normally-funded pot. Setup: 10 players at 100 each (total 1000); only player 0x1000 claims, leaving remainingRewards = 900. Step: the admin calls closeContest five times in separate calls — nothing stops calls 2..5 because no closed flag exists and remainingRewards was never zeroed. Each round re-pays managerCut = 90 (to the frozen CM, per V1) and claimantCut = (900 - 90) / 10 = 81 to the single claimant, so five rounds extract 5 × 90 = 450 for the CM and 5 × 81 = 405 extra for the claimant (405 taken directly from the 9 non-claiming players' shares). Assertions after 5 rounds: CM balance = 450, claimant balance = 100 + 81×5 = 505, the pot retains 45 of real tokens, yet getRemainingRewards() STILL reads 900 — the ledger never moved while the balance drained (full ledger/balance desync). The 6th call finally reverts — with a raw ERC20InsufficientBalance, not a domain error, confirming no idempotency guard of any kind exists. Run with: forge test --match-test test_POC4_ClosePotRepeatable -vv (PASS; all five rounds and all balance assertions hold).

// forge test --match-test test_POC4_ClosePotRepeatable -> PASS
function test_POC4_ClosePotRepeatable() public {
address[] memory players = new address[](10);
uint256[] memory rewards = new uint256[](10);
for (uint256 i = 0; i < 10; i++) { players[i] = address(uint160(0x1000 + i)); rewards[i] = 100; }
address pot = _createAndFund(players, rewards, 1000);
​
vm.prank(address(uint160(0x1000)));
Pot(pot).claimCut(); // rem = 900
​
vm.warp(91 days);
vm.startPrank(admin);
conMan.closeContest(pot);
conMan.closeContest(pot);
conMan.closeContest(pot);
conMan.closeContest(pot);
conMan.closeContest(pot); // 5 rounds all succeed
vm.stopPrank();
​
assertEq(weth.balanceOf(address(conMan)), 450, "CM drained 5x managerCut");
assertEq(weth.balanceOf(address(uint160(0x1000))), 100 + 81 * 5, "claimant over-paid 5x");
assertEq(Pot(pot).getRemainingRewards(), 900, "ledger never updated");
vm.prank(admin);
vm.expectRevert();
conMan.closeContest(pot); // 6th reverts only on insufficient balance
}

Recommended Mitigation

// src/Pot.sol
+ bool private s_closed;
function closePot(address payoutRecipient) external onlyOwner {
+ if (s_closed) revert Pot__AlreadyClosed();
if (block.timestamp - i_deployedAt < 90 days) {
revert Pot__StillOpenForClaim();
}
...
+ remainingRewards = 0;
+ s_closed = true;
}

⚠ Combination note: the s_closed flag must be shared with claimCut (V5). Closing out the ledger while leaving playersToRewards untouched lets post-close claims succeed whenever the pot holds an excess balance (e.g. double funding, V6). Also note: fixing V6 (double funding) does NOT fix this issue — the PoC above uses a single normal funding.

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!