MyCut

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

Players can call `claimCut` before the contest is funded, and the 90-day claim window starts at deployment, not at funding

Root + Impact

Description

  • Creating and funding a contest are two separate owner transactions (`createContest` then `fundContest`). Nothing links them or gates claiming on funding state.

  • A player who calls `claimCut` before the `Pot` is funded hits a raw `ERC20InsufficientBalance` revert (poor UX / looks like a broken contract). More importantly, the 90-day claim window is measured from `i_deployedAt` (set in the constructor), **not** from when the `Pot` is funded. If funding is delayed, the real usable claim window is shorter than the intended 90 days.

// src/Pot.sol :: constructor
@> i_deployedAt = block.timestamp; // clock starts at creation, before funding
function claimCut() public {
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); // reverts if the pot holds no tokens yet
}

Risk

Likelihood:

  • Any window between `createContest` and `fundContest` exposes the issue; the two are separate manual admin steps.

Impact:

  • Bad UX: early claims revert with an opaque token error.

  • Reduced claim window: because the timer starts at deployment, late funding silently eats into the players' 90 days.

Proof of Concept

Create a contest but do not fund it; a player's `claimCut` reverts because the `Pot` holds no tokens.

function test_M02_claimBeforeFunding() public mintAndApproveTokens {
vm.prank(user);
contest = ContestManager(conMan).createContest(players, rewards, IERC20(ERC20Mock(weth)), 4);
// NOT funded
assertEq(ERC20Mock(weth).balanceOf(contest), 0);
vm.prank(player1);
vm.expectRevert(); // ERC20InsufficientBalance from the reward transfer
Pot(contest).claimCut();
}

Recommended Mitigation

Fund atomically at creation (and/or start the clock at funding). Example — fund inside `createContest` and make `fundContest` internal:

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;
+ fundContest(contests.length - 1); // fund in the same tx as creation
return address(pot);
}
- function fundContest(uint256 index) public onlyOwner {
+ function fundContest(uint256 index) internal onlyOwner {
...
}
Updates

Lead Judging Commences

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