MyCut

AI First Flight #8
Beginner FriendlyFoundry
EXP
View results
Submission Details
Severity: medium
Valid

90-Day Claim Timer Starts at Deployment, Not Funding → Shortened Claim Window and Failed Claims

Description

  • The Pot constructor records i_deployedAt = block.timestamp and the 90-day claim window begins immediately. fundContest() in ContestManager is a completely
    separate transaction that transfers tokens into the Pot — it can be called any time after deployment, or potentially never. The two operations are decoupled with
    no on-chain enforcement linking them.

  • Players attempting to call claimCut() before fundContest is called will receive an ERC20 revert (zero token balance in the Pot) even though their
    playersToRewards entry is valid. More critically, every day the funding is delayed is a day removed from the legitimate claim window. A pot funded 30 days late
    gives players only 60 days to claim, not the intended 90 — with no way for players to know the effective deadline has shifted.

// Pot.sol
constructor(address[] memory players, uint256[] memory rewards, IERC20 token, uint256 totalRewards) {
// @> 90-day clock starts here at deployment
i_deployedAt = block.timestamp;

  // @> Self-funding is commented out — Pot starts with 0 token balance
  // i_token.transfer(address(this), i_totalRewards);
  // ...

}

// ContestManager.sol
function createContest(...) public onlyOwner returns (address) {
Pot pot = new Pot(players, rewards, token, totalRewards); // @> timer starts NOW
// @> No fundContest call here — funding is a separate optional step
contests.push(address(pot));
return address(pot);
}

function fundContest(uint256 index) public onlyOwner {
// @> Can be called any time after createContest, or never
token.transferFrom(msg.sender, address(pot), totalRewards);
}

Risk

Likelihood:

  • Any deployment workflow that separates createContest and fundContest by even one block starts eroding the claim window

  • Players who monitor createContest events and call claimCut() immediately will receive confusing ERC20 reverts with no clear error message

Impact:

  • Delayed funding silently reduces the effective claim window — players lose claimable time they are not aware of

  • Players calling claimCut() on an unfunded pot receive an opaque ERC20 error, not a protocol-level message, creating bad UX and potential trust issues

Proof of Concept

function testClaimCutBeforeFundingReverts() public {
vm.prank(owner);
address potAddr = contestManager.createContest(players, rewards, token, 1000);
// fundContest NOT called yet — pot has 0 balance

  // Player tries to claim immediately after contest creation
  vm.prank(players[0]);
  vm.expectRevert(); // ERC20InsufficientBalance — no helpful error from Pot
  Pot(potAddr).claimCut();

}

function testDelayedFundingShrinsClaimWindow() public {
vm.prank(owner);
address potAddr = contestManager.createContest(players, rewards, token, 1000);

  // Owner funds 30 days late
  vm.warp(block.timestamp + 30 days);
  vm.prank(owner);
  contestManager.fundContest(0);

  // Players now have only 60 days to claim instead of 90
  // After 61 more days the pot can be closed, players had no warning
  vm.warp(block.timestamp + 61 days);
  vm.prank(owner);
  contestManager.closeContest(potAddr); // succeeds — 90 days elapsed since deployment

}

Recommended Mitigation

// Option A — Fund atomically at creation
function createContest(address[] memory players, uint256[] memory rewards, IERC20 token, uint256 totalRewards)
public onlyOwner returns (address)
{
Pot pot = new Pot(players, rewards, token, totalRewards);
contests.push(address(pot));
contestToTotalRewards[address(pot)] = totalRewards;

  • token.transferFrom(msg.sender, address(pot), totalRewards);
    return address(pot);
    

    }

// Option B — Gate claimCut on a funded flag

  • bool private s_isFunded;

  • function markFunded() external onlyOwner {

  • s_isFunded = true;
    
  • }

    function claimCut() public {

  • if (!s_isFunded) revert Pot__InsufficientFunds();
    address player = msg.sender;
    // ...
    

    }

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour ago
Submission Judgement Published
Validated
Assigned finding tags:

[M-02] **[L-1] users can invoke `claimCut` prior to the contest being funded**

````markdown **Description:** It is possible that once the contest has been created, it is not necessarily funded at the same time, these are separate operations, which may result in users attempting to invoke `claimCut`, however there would be no funds and we would most likely get a `ERC20InsufficientBalance` error. Users have most probably assumed that at the time of claiming their cut that the contest is funded. The more insidious issue lies in the fact that the timer of 90 days begins when the Pot contract is constructed not when it's funded, hence if the contract is not funded at the time of creation, users will not be entitled to the whole 90 day duration claim period. **Impact:** Bad UX, as users would be able to attempt claim their cut but this would result in a reversion. **Proof of Concept:** The below test can be added to `TestMyCut.t.sol:TestMyCut` contracts test suite. **Recommended Mitigation:** We must ensure the contest is funded at the time it is created. Otherwise we should state a clearer error message. In the event where we want to give the users a more gracious error message, we could add the following changes which leverages a boolean to track if the Pot has been funded: ```diff contract Pot is Ownable(msg.sender) { /** Existing Code... */ + boolean private s_isFunded; // Ensure this is updated correctly when the contract is funded. function claimCut() public { + if (!s_isFunded) { + revert Pot__InsufficientFunds(); + } 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); } } ``` In the scenario where we want to ensure the contest is funded at the time of being created employ the following code. ```diff function createContest(address[] memory players, uint256[] memory rewards, IERC20 token, uint256 totalRewards) public onlyOwner returns (address) { // Create a new Pot contract Pot pot = new Pot(players, rewards, token, totalRewards); contests.push(address(pot)); contestToTotalRewards[address(pot)] = totalRewards; + fundContest(contests.length - 1); return address(pot); } - function fundContest(uint256 index) public onlyOwner { + function fundContest(uint256 index) internal onlyOwner { Pot pot = Pot(contests[index]); IERC20 token = pot.getToken(); uint256 totalRewards = contestToTotalRewards[address(pot)]; if (token.balanceOf(msg.sender) < totalRewards) { revert ContestManager__InsufficientFunds(); } token.transferFrom(msg.sender, address(pot), totalRewards); } ``` ````

Support

FAQs

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

Give us feedback!