MyCut

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

MyCut — closePot Divides Residual by i_players.length but Pays Only Claimants, Permanently Locking Funds

Description

After the 90-day claim window, Pot.closePot() is supposed to take a 10% manager cut of the unclaimed remainder and split the rest equally among players who claimed on time. The README states this explicitly:

authorized claimants 90 days to claim before the manager takes a cut of the remaining pool and the remainder is distributed equally to those who claimed in time

The implementation does the opposite of that last clause. It computes each claimant's share by dividing the residual by the full player list, then iterates only the claimants array. Tokens allocated to non-claimants are never transferred, never written back, and never rescueable. They sit in the Pot forever.

This is not dust. For a contest with N players and C timely claimants (C < N), the locked amount is (N - C) * claimantCut — a non-dust multiple of the per-claimant share. If nobody claims, ~90% of the pot is stranded after the manager cut. No attacker is required: any mapped player who skips claimCut, plus the owner invoking the designed closeContest after 90 days, is enough.

Deep Dive

The root cause is a single divisor mismatch in src/Pot.sol.

function closePot() external onlyOwner {
if (block.timestamp - i_deployedAt < 90 days) {
revert Pot__StillOpenForClaim();
}
if (remainingRewards > 0) {
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);
}
}
}

Line 57 divides by i_players.length. Lines 58–59 pay only claimants. Those two sets are not the same after a partial claim.

Supporting state that makes the lock permanent:

  • claimCut (src/Pot.sol:37-46) zeros the caller in playersToRewards, subtracts their original reward from remainingRewards, pushes them onto claimants, and transfers their original cut. It does not, and cannot, redistribute anyone else's unclaimed tokens.

  • remainingRewards is never written in closePot. After close it still equals the pre-close residual, even though tokens have left the contract.

  • ContestManager.closeContest (src/ContestManager.sol:53-60) is a thin onlyOwner wrapper around pot.closePot(). There is no second collection, sweep, or rescue on either contract.

  • The 90-day gate (src/Pot.sol:50-51) and onlyOwner are the designed close path. They pass on a normal close. remainingRewards > 0 at line 53 is true after any incomplete claim.

Worked numbers matching the intended README semantics vs. what the code does:

| | Intended | Actual |
|---|---|---|
| Players / rewards | A, B, C × 100e18 | same |
| Funded pot | 300e18 | 300e18 |
| A claims | A gets 100e18 | A gets 100e18 |
| Residual at close | 200e18 | 200e18 |
| Manager cut (10%) | 20e18 to ContestManager | 20e18 to ContestManager |
| Remainder to timely claimants | 180e18 to A | 180e18 / 3 = 60e18 to A |
| Left in Pot | 0 (or dust from %) | 120e18 = 2 * 60e18 |

remainingRewards after close is still 200e18. The 120e18 on the Pot is not residual % 3 dust — it is exactly the two unclaimed claimantCuts.

If claimants is empty, the loop is a no-op: manager takes ~10% and ~90% of the residual is locked. If every player claims, the residual is 0 and the branch is skipped — the bug only fires on the common incomplete-claim case.

Note: managerCut = remainingRewards / managerCutPercent with managerCutPercent = 10 is remainingRewards / 10 (10%), which happens to match the README. That is not the issue. The issue is exclusively the divisor on line 57.

Exploitation

No malicious actor is required. The production close path locks funds whenever at least one mapped player does not claim.

  1. Owner calls createContest([A, B, C], [100e18, 100e18, 100e18], token, 300e18).

  • i_players.length = 3, remainingRewards = 300e18, claimants = [].

  1. Owner approves the ContestManager and calls fundContest(0).

  • Pot balance = 300e18.

  1. A calls claimCut(). B and C never claim.

  • A receives 100e18.

  • remainingRewards = 200e18, claimants = [A], Pot balance = 200e18.

  1. Warp until block.timestamp >= i_deployedAt + 90 days.

  2. Owner calls closeContest(pot)closePot():

  • managerCut = 200e18 / 10 = 20e18 transferred to ContestManager.

  • claimantCut = (200e18 - 20e18) / 3 = 60e18 transferred only to A.

  • Pot balance left = 120e18.

  • remainingRewards still 200e18.

  • No further function can move those 120e18.

Confirmed by an executed Foundry test of this exact trace: assertEq(A's close payout, residual=180e18) failed (60e18 != 180e18). Post-close Pot balance is 120e18, not residual-mod-3 dust.

Reachable with a standard 18-decimal ERC20. No hooks, no unusual token behavior, no privileged attacker.

Impact

High — permanent loss of contest residual on the designed close path.

  • Every funded contest with incomplete claims permanently strands (i_players.length - claimants.length) * claimantCut.

  • Empty claimants strands ~90% of the residual after the manager cut.

  • Timely claimants are underpaid relative to the README (A is entitled to the full 180e18 remainder, receives 60e18).

  • Neither Pot nor ContestManager exposes a second collection or rescue. Once closePot runs, the leftover tokens are unrecoverable.

  • This is the common case, not an edge case: contests routinely have no-shows. The owner calling closeContest after 90 days is the documented residual-release path.

Severity High. Confidence 95.

Recommendation

Divide the residual by the number of actual claimants, not the original player list. Guard the empty-claimants case so the remainder is not stranded.

function closePot() external onlyOwner {
if (block.timestamp - i_deployedAt < 90 days) {
revert Pot__StillOpenForClaim();
}
if (remainingRewards > 0) {
uint256 managerCut = remainingRewards / managerCutPercent;
i_token.transfer(msg.sender, managerCut);
uint256 leftover = remainingRewards - managerCut;
if (claimants.length == 0) {
// send leftover to manager (or a designated sink); do not leave it in the Pot
i_token.transfer(msg.sender, leftover);
} else {
uint256 claimantCut = leftover / claimants.length;
for (uint256 i = 0; i < claimants.length; i++) {
_transferReward(claimants[i], claimantCut);
}
}
remainingRewards = 0;
}
}

Also consider sweeping any % claimants.length dust to the manager so the Pot balance is exactly zero after close.

→ skipped: dust-to-manager sweep, add when you want a strictly empty Pot after close.


Proof of Concept

diff --git a/test/ClosePotResidualLock.t.sol b/test/ClosePotResidualLock.t.sol
new file mode 100644
index 0000000..6a2a5b6
--- /dev/null
+++ b/test/ClosePotResidualLock.t.sol
@@ -0,0 +1,88 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.20;
+
+// Attacker: none required. Any mapped player who skips claimCut, plus the owner
+// calling the designed closeContest after 90 days, locks funds forever.
+// Impact: closePot divides the residual by i_players.length but pays only
+// claimants, permanently locking (players - claimants) * claimantCut in the Pot.
+// Run: forge test --match-contract ClosePotResidualLock -vv
+
+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";
+
+contract ClosePotResidualLock is Test {

  • ContestManager manager;

  • ERC20Mock token;

  • address owner = makeAddr("owner");

  • address playerA = makeAddr("playerA");

  • address playerB = makeAddr("playerB");

  • address playerC = makeAddr("playerC");

+

  • uint256 constant CUT = 100e18;

  • uint256 constant TOTAL = 300e18;

+

  • function setUp() public {

  • vm.startPrank(owner);

  • manager = new ContestManager();

  • token = new ERC20Mock("WETH", "WETH", owner, TOTAL);

  • token.approve(address(manager), TOTAL);

  • vm.stopPrank();

  • }

+

  • function test_closePotLocksUnclaimedShare() public {

  • address[] memory players = new address[](3);

  • players[0] = playerA;

  • players[1] = playerB;

  • players[2] = playerC;

  • uint256[] memory rewards = new uint256[](3);

  • rewards[0] = CUT;

  • rewards[1] = CUT;

  • rewards[2] = CUT;

+

  • vm.startPrank(owner);

  • address potAddr = manager.createContest(players, rewards, IERC20(token), TOTAL);

  • manager.fundContest(0);

  • vm.stopPrank();

+

  • Pot pot = Pot(potAddr);

  • assertEq(token.balanceOf(potAddr), TOTAL);

+

  • // Only A claims in time. B and C never call claimCut.

  • vm.prank(playerA);

  • pot.claimCut();

  • assertEq(token.balanceOf(playerA), CUT);

  • assertEq(token.balanceOf(potAddr), 200e18);

  • assertEq(pot.getRemainingRewards(), 200e18);

+

  • vm.warp(block.timestamp + 90 days);

+

  • uint256 aBeforeClose = token.balanceOf(playerA);

  • uint256 managerBefore = token.balanceOf(address(manager));

+

  • vm.prank(owner);

  • manager.closeContest(potAddr);

+

  • uint256 managerCut = token.balanceOf(address(manager)) - managerBefore;

  • uint256 aClosePayout = token.balanceOf(playerA) - aBeforeClose;

  • uint256 locked = token.balanceOf(potAddr);

+

  • // Designed: residual 200e18 → 10% manager (20e18), rest 180e18 to timely claimants.

  • // Actual: claimantCut = 180e18 / i_players.length (3) = 60e18, paid only to A.

  • assertEq(managerCut, 20e18);

  • assertEq(aClosePayout, 60e18);

  • // README: remainder goes equally to timely claimants → A is entitled to 180e18.

  • assertTrue(aClosePayout < 180e18, "A underpaid vs remaining residual");

  • // Locked = (players - claimants) * claimantCut = 2 * 60e18, not dust.

  • assertEq(locked, 120e18);

  • // remainingRewards is never written down; no rescue path exists.

  • assertEq(pot.getRemainingRewards(), 200e18);

+

  • console.log("A close payout (actual):", aClosePayout);

  • console.log("A close payout (intended, sole claimant):", uint256(180e18));

  • console.log("Permanently locked in Pot:", locked);

  • console.log("remainingRewards still:", pot.getRemainingRewards());

  • }

+}

Updates

Lead Judging Commences

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

[H-02] Incorrect logic in `Pot::closePot` leads to unfair distribution to `claimants`, potentially locking the funds with no way to take that out

## Description in `closePot` function while calclulating the shares for claimaint cut, `i_players.length` is used, instead of `claimants.length`, causing low amount being distributed to claimants. ## Vulnerability Details [2024-08-MyCut/src/Pot.sol at main · Cyfrin/2024-08-MyCut (github.com)](https://github.com/Cyfrin/2024-08-MyCut/blob/main/src/Pot.sol#L57) `Pot::closePot` function is meant to be called once contest passed 90 days, it sends the owner cut to owner and rest is splitted among the users who claimed b/w 90 days period. However, current implementation is wrong.&#x20; It uses total users (i_players.length) instead of the users (claimants.length) who claimed during the duration. This creates an unfair distribution to the participants and some of the funds could be locked in the contract. In worst case scenerio, it could be 90% if nobody has claimed from the protocol during the 90 days duration. ## POC In existing test suite, add following test: ```solidity function testUnfairDistributionInClosePot() public mintAndApproveTokens { // Setup address[] memory testPlayers = new address[](3); testPlayers[0] = makeAddr("player1"); testPlayers[1] = makeAddr("player2"); testPlayers[2] = makeAddr("player3"); uint256[] memory testRewards = new uint256[](3); testRewards[0] = 400; testRewards[1] = 300; testRewards[2] = 300; uint256 testTotalRewards = 1000; // Create and fund the contest vm.startPrank(user); address testContest = ContestManager(conMan).createContest( testPlayers, testRewards, IERC20(ERC20Mock(weth)), testTotalRewards ); ContestManager(conMan).fundContest(0); vm.stopPrank(); // Only player1 claims their reward vm.prank(testPlayers[0]); Pot(testContest).claimCut(); // Fast forward 91 days vm.warp(block.timestamp + 91 days); // Record balances before closing the pot uint256 player1BalanceBefore = ERC20Mock(weth).balanceOf( testPlayers[0] ); // Close the contest vm.prank(user); ContestManager(conMan).closeContest(testContest); // Check balances after closing the pot uint256 player1BalanceAfter = ERC20Mock(weth).balanceOf(testPlayers[0]); // Calculate expected distributions uint256 remainingRewards = 600; // 300 + 300 unclaimed rewards uint256 ownerCut = remainingRewards / 10; // 10% of remaining rewards uint256 distributionPerPlayer = (remainingRewards - ownerCut) / 1; // as only 1 user claimed uint256 fundStucked = ERC20Mock(weth).balanceOf(address(testContest)); // actual results console.log("expected reward:", distributionPerPlayer); console.log( "actual reward:", player1BalanceAfter - player1BalanceBefore ); console.log("Fund stucked:", fundStucked); } ``` then run `forge test --mt testUnfairDistributionInClosePot -vv` in the terminal and it will show following output: ```js [⠊] Compiling... [⠒] Compiling 1 files with Solc 0.8.20 [⠘] Solc 0.8.20 finished in 1.63s Compiler run successful! Ran 1 test for test/TestMyCut.t.sol:TestMyCut [PASS] testUnfairDistributionInClosePot() (gas: 905951) Logs: User Address: 0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D Contest Manager Address 1: 0x7BD1119CEC127eeCDBa5DCA7d1Bd59986f6d7353 Minting tokens to: 0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D Approved tokens to: 0x7BD1119CEC127eeCDBa5DCA7d1Bd59986f6d7353 expected reward: 540 actual reward: 180 Fund stucked: 360 Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.58ms (506.33µs CPU time) ``` ## Impact Loss of funds, Unfair distribution b/w users ## Recommendations Fix the functions as shown below: ```diff function closePot() external onlyOwner { if (block.timestamp - i_deployedAt < 90 days) { revert Pot__StillOpenForClaim(); } if (remainingRewards > 0) { uint256 managerCut = remainingRewards / managerCutPercent; i_token.transfer(msg.sender, managerCut); - uint256 claimantCut = (remainingRewards - managerCut) / i_players.length; + uint256 totalClaimants = claimants.length; + if(totalClaimant == 0){ + _transferReward(msg.sender, remainingRewards - managerCut); + } else { + uint256 claimantCut = (remainingRewards - managerCut) / claimants.length; for (uint256 i = 0; i < claimants.length; i++) { _transferReward(claimants[i], claimantCut); } } + } } ```

Support

FAQs

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

Give us feedback!