MyCut

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

Constructor never validates totalRewards against sum(rewards) - an underfunded pot lets early claimants drain it and permanently bricks later claimants

Description

Pot's constructor stores totalRewards as the pot's distributable balance tracker, and separately stores a per-player rewards array that claimCut pays out directly. Nothing ties these two together:

constructor(address[] memory players, uint256[] memory rewards, IERC20 token, uint256 totalRewards) {
i_players = players;
i_rewards = rewards;
@> i_totalRewards = totalRewards;
@> remainingRewards = totalRewards;
...
for (uint256 i = 0; i < i_players.length; i++) {
playersToRewards[i_players[i]] = i_rewards[i];
}
}

ContestManager.fundContest transfers exactly totalRewards tokens into the pot — not sum(rewards). If an admin passes a totalRewards smaller than the sum of the individual rewards entries (a simple input mistake, or a deliberate but uncaught inconsistency), the pot is funded with fewer tokens than it has promised to pay out. claimCut doesn't check the pot's balance or remainingRewards before paying — it just calls _transferReward(player, i_rewards[player]) for whichever fixed amount that player was assigned:

function claimCut() public {
...
playersToRewards[player] = 0;
remainingRewards -= reward;
claimants.push(player);
@> _transferReward(player, reward); // no balance/remainingRewards check before paying
}

Whichever players happen to claim first get paid in full. Once the pot's actual token balance is exhausted, every subsequent claimant's claimCut() call reverts (the underlying ERC20 transfer reverts on insufficient balance) — permanently, since nothing ever adds more funds to the pot. checkCut() still reports these players as owed their full reward; they simply can never receive it.

Risk

Likelihood: Medium

  • Requires an admin to pass a totalRewards that doesn't match sum(rewards) — a straightforward, uncaught input-validation gap, not requiring any circumvention. The constructor and createContest have no check against it.

Impact: High

  • Players promised a specific reward and shown as owed it via checkCut() can be permanently and silently unable to claim, purely based on the arbitrary order people happen to claim in.

Proof of Concept

3 players promised 300 each (900 total), pot funded with only 600. The first two claimants succeed and drain the pot; the third is permanently bricked despite checkCut still showing they're owed 300:

function test_H4_underfundedPot_bricksLaterClaimants() public {
address contest = ContestManager(conMan).createContest(players, rewards, IERC20(weth), 600);
ContestManager(conMan).fundContest(0);
assertEq(Pot(contest).checkCut(player3), 300); // owed 300...
vm.prank(player1); Pot(contest).claimCut(); // pot: 600 -> 300
vm.prank(player2); Pot(contest).claimCut(); // pot: 300 -> 0
assertEq(Pot(contest).checkCut(player3), 300); // still shows owed 300
vm.prank(player3);
vm.expectRevert();
Pot(contest).claimCut(); // reverts forever: pot is empty
}

Recommended Mitigation

Validate the invariant at construction, where the mismatch actually originates:

constructor(address[] memory players, uint256[] memory rewards, IERC20 token, uint256 totalRewards) {
+ if (players.length != rewards.length) revert Pot__LengthMismatch();
+ uint256 sum;
+ for (uint256 i = 0; i < rewards.length; i++) sum += rewards[i];
+ if (sum != totalRewards) revert Pot__RewardsMismatch();
i_players = players;
Updates

Lead Judging Commences

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

[L-01] The logic for ContestManager::createContest is NOT efficient

## Description there are two major problems that comes with the way contests are created using the `ContestManager::createContest`. - using dynamic arrays for `players` and `rewards` leads to potential DoS for the `Pot::constructor`, this is possible if the arrays are too large therefore requiring too much gas - it is not safe to trust that `totalRewards` value supplied by the `manager` is accurate and that could lead to some players not being able to `claimCut` ## Vulnerability Details - If the array of `players` is very large, the `Pot::constructor` will revert because of too much `gas` required to run the for loop in the constructor. ```Solidity constructor(address[] memory players, uint256[] memory rewards, IERC20 token, uint256 totalRewards) { i_players = players; i_rewards = rewards; i_token = token; i_totalRewards = totalRewards; remainingRewards = totalRewards; i_deployedAt = block.timestamp; // i_token.transfer(address(this), i_totalRewards); @> for (uint256 i = 0; i < i_players.length; i++) { @> playersToRewards[i_players[i]] = i_rewards[i]; @> } } ``` - Another issue is that, if a `Pot` is created with a wrong `totalRewards` that for instance is less than the sum of the reward in the `rewards` array, then some players may never get to `claim` their rewards because the `Pot` will be underfunded by the `ContestManager::fundContest` function. ## PoC Here is a test for wrong `totalRewards` ```solidity function testSomePlayersCannotClaimCut() public mintAndApproveTokens { vm.startPrank(user); // manager creates pot with a wrong(smaller) totalRewards value- contest = ContestManager(conMan).createContest(players, rewards, IERC20(ERC20Mock(weth)), 6); ContestManager(conMan).fundContest(0); vm.stopPrank(); vm.startPrank(player1); Pot(contest).claimCut(); vm.stopPrank(); vm.startPrank(player2); // player 2 cannot claim cut because the pot is underfunded due to the wrong totalScore vm.expectRevert(); Pot(contest).claimCut(); vm.stopPrank(); } ``` ## Impact - Pot not created if large dynamic array of players and rewards is used - wrong totlRewards value leads to players inability to claim their cut ## Recommendations review the pot-creation design by, either using merkle tree to store the players and their rewards OR another solution is to use mapping to clearly map players to their reward and a special function to calculate the `totalRewards` each time a player is mapped to her reward. this `totalRewards` will be used later when claiming of rewards starts.

Support

FAQs

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

Give us feedback!