MyCut

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

[M-1] `closePot` can be called repeatedly, draining the Pot

Root + Impact

Description

  • closePot never updates remainingRewards and has no guard preventing a second invocation. Because the eligibility check is only remainingRewards > 0 (and the 90-day time check, which stays satisfied), the owner can call closeContest repeatedly and each call re-runs the full distribution — paying the manager cut and claimant cuts again — draining the Pot. getRemainingRewards() also reports a permanently stale value after the first close.

function closePot() external onlyOwner {
if (block.timestamp - i_deployedAt < 90 days) revert Pot__StillOpenForClaim();
if (remainingRewards > 0) { // stays > 0 forever
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);
}
}
// remainingRewards is NEVER set to 0, and no `closed` flag is set
}

Risk

Likelihood:

  • Medium (owner-gated, requires repeated/accidental calls).

Impact:

  • High (fund loss / broken accounting).


Proof of Concept

// SPDX-License-Identifier: MIT
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";
// H-3: Pot.closePot never zeroes remainingRewards and has no "closed" flag,
// so it can be called any number of times. Each call re-runs the full
// distribution (manager cut + claimant cuts), draining the Pot.
contract PoC_RecallableClose is Test {
ContestManager conMan;
ERC20Mock weth;
address user = makeAddr("user");
address player1 = makeAddr("player1");
address player2 = makeAddr("player2");
address[] players = [player1, player2];
uint256[] rewards = [500, 500];
uint256 totalRewards = 1000;
address contest;
function setUp() public {
vm.startPrank(user);
conMan = new ContestManager();
weth = new ERC20Mock("WETH", "WETH", user, 0);
weth.mint(user, 1000 ether);
weth.approve(address(conMan), type(uint256).max);
contest = conMan.createContest(players, rewards, IERC20(weth), totalRewards);
conMan.fundContest(0);
vm.stopPrank();
}
function test_closePot_can_be_called_repeatedly() public {
vm.warp(91 days); // nobody claims -> remaining stays 1000
uint256 potBefore = weth.balanceOf(contest); // 1000
vm.startPrank(user);
conMan.closeContest(contest); // distribution #1: managerCut = 1000/10 = 100
conMan.closeContest(contest); // distribution #2: 100 again (remaining never reset)
conMan.closeContest(contest); // distribution #3: 100 again
vm.stopPrank();
uint256 drained = potBefore - weth.balanceOf(contest);
// A single close should move 100. Three closes moved 300 out of the Pot.
assertEq(drained, 300, "Pot drained multiple times by repeated closePot");
// getRemainingRewards is permanently stale.
assertEq(Pot(contest).getRemainingRewards(), 1000, "remainingRewards never updated");
console.log("Drained over 3 closes:", drained, "(a single close should move 100)");
}
}

Recommended Mitigation

+ bool private closed;
+ error Pot__AlreadyClosed();
+
function closePot() external onlyOwner {
if (block.timestamp - i_deployedAt < 90 days) revert Pot__StillOpenForClaim();
+ if (closed) revert Pot__AlreadyClosed();
+ closed = true;
if (remainingRewards > 0) {
uint256 managerCut = remainingRewards / managerCutPercent;
i_token.transfer(msg.sender, managerCut);
uint256 claimantCut = (remainingRewards - managerCut) / claimants.length;
for (uint256 i = 0; i < claimants.length; i++) {
_transferReward(claimants[i], claimantCut);
}
+ remainingRewards = 0;
}
}
Updates

Lead Judging Commences

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