MyCut

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

closePot's floor divisions strand the non-divisible remainder, and with no sweep function the dust from every non-even pot is permanently locked

Root + Impact

Description

  • Normally, after close, the pot should be emptied: cut to the manager, shares to claimants, remainder swept.

  • The two divisions independently drop remainders and no terminal sweep exists, so every pot whose numbers do not divide evenly strands (rem mod 10) + (rem - mc) mod players.length tokens forever.

// src/Pot.sol
function closePot() external onlyOwner {
...
if (remainingRewards > 0) {
@> uint256 managerCut = remainingRewards / managerCutPercent; // floors rem%10
i_token.transfer(msg.sender, managerCut);
@> uint256 claimantCut = (remainingRewards - managerCut) / i_players.length; // floors the rest
for (uint256 i = 0; i < claimants.length; i++) {
_transferReward(claimants[i], claimantCut);
}
}
@> // leftover dust never swept, no exit anywhere in the protocol
}

Risk

Likelihood:

  • Almost every realistic reward pool contains numbers that do not divide evenly by 10 and by the player count — the dust accrues on each such contest as a matter of course.

Impact:

  • Verified: total 505 with three players of 100 leaves 2 tokens stranded per pot; across many pots this is a slow permanent leak of protocol funds.

  • Individually small, the stranded dust compounds with every other freeze issue in this report (all of which also rely on the missing sweep), so the same one-line mitigation increases every fix's effectiveness.

Proof of Concept

PoC explanation: The test demonstrates the floor-division dust on a realistic non-even pot. Setup: a 3-player pot, each with reward 100, funded with totalRewards = 505. Step: all three players claim their 300, leaving remainingRewards = 205; after 91 days the admin closes the pot. Arithmetic: managerCut = 205 / 10 = 20 (flooring 205 % 10 = 5), then claimantCut = (205 - 20) / 3 = 61 (flooring the remaining 185 % 3 = 2), paying out 20 + 3 × 61 = 203. Final assertion: the pot retains exactly 2 WETH — the two independent floor remainders — with no sweep function anywhere in the protocol to extract them. Every pot whose numbers do not divide evenly by 10 and by the player count strands the same kind of dust, making this a slow but permanent leak across all contests. Run with: forge test --match-test test_POC10_DustLocked -vv (PASS; asserts the residual 2).

// forge test --match-test test_POC10_DustLocked -> PASS
function test_POC10_DustLocked() public {
address[] memory players = new address[](3);
players[0] = p1; players[1] = p2; players[2] = p3;
uint256[] memory rewards = new uint256[](3);
rewards[0] = 100; rewards[1] = 100; rewards[2] = 100;
address pot = _createAndFund(players, rewards, 505);
​
vm.prank(p1); Pot(pot).claimCut();
vm.prank(p2); Pot(pot).claimCut();
vm.prank(p3); Pot(pot).claimCut(); // all claimed 300, rem = 205
​
vm.warp(91 days);
vm.prank(admin);
conMan.closeContest(pot); // mc=20 (to CM, frozen per V1), cc=61 x3
​
assertEq(weth.balanceOf(pot), 2, "dust=2 permanently locked");
}

Recommended Mitigation

// src/Pot.sol — end of closePot
for (uint256 i = 0; i < claimants.length; i++) {
_transferReward(claimants[i], claimantCut);
}
+ // sweep the non-divisible leftovers to the payout recipient
+ uint256 leftover = i_token.balanceOf(address(this));
+ i_token.transfer(payoutRecipient, leftover);
+ remainingRewards = 0;

⚠ Combination note: the sweep is only effective after V1's recipient fix — sweeping to msg.sender inside closePot would send the dust to the non-withdrawable ContestManager; and the sweep should run AFTER the idempotency flag (V4) so it cannot be repeated.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 34 minutes ago
Submission Judgement Published
Validated
Assigned finding tags:

[L-03] [H-03] Precision loss can lead to rewards getting stuck in the pot forever

### \[H-03] Precision loss can lead to rewards getting stuck in the pot forever **Description:** When contest manager closes the pot by calling `Pot::closePot`, 10 percent of the remaining rewards are transferred to the contest manager and the rest are distributed equally among the claimants. It does this by dividing the rewards by the manager's cut percentage which is 10. Then the remaining rewards are divided by the number of players to distribute equally among claimants. Since solidity allows only integer division this will lead to precision loss which will cause a portion of funds to be left in the pot forever. Each pot follows the same method, so as number of pots grow, the loss of funds is very significant. **Impact:** Reward tokens get stuck in the pot forever which causes loss of funds. **Proof of code:** Add the below test to `test/TestMyCut.t.sol` ```javascript function testPrecisionLoss() public mintAndApproveTokens { ContestManager cm = ContestManager(conMan); uint playersLength = 3; address[] memory p = new address[](playersLength); uint256[] memory r = new uint256[](playersLength); uint tr = 86; p[0] = makeAddr("_player1"); p[1] = makeAddr("_player2"); p[2] = makeAddr("_player3"); r[0] = 20; r[1] = 23; r[2] = 43; vm.startPrank(user); address pot = cm.createContest(p, r, weth, tr); cm.fundContest(0); vm.stopPrank(); console.log("\n\ntoken balance in pot before: ", weth.balanceOf(pot)); vm.prank(p[1]); // player 2 Pot(pot).claimCut(); vm.prank(p[0]); // player 1 Pot(pot).claimCut(); vm.prank(user); vm.warp(block.timestamp + 90 days + 1); cm.closeContest(pot); console.log( "\n\ntoken balance in pot after closing pot: ", weth.balanceOf(pot) ); assert(weth.balanceOf(pot) != 0); } ``` Run the below test command in terminal ```Solidity forge test --mt testPrecisionLoss -vv ``` Which results in the below output ```Solidity [⠒] Compiling... [⠆] Compiling 1 files with 0.8.20 [⠰] Solc 0.8.20 finished in 2.57s Compiler run successful! Ran 1 test for test/TestMyCut.t.sol:TestMyCut [PASS] testPrecisionLoss() (gas: 936926) Logs: token balance in pot before: 86 token balance in pot after closing pot: 1 Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.75ms (654.60µs CPU time) Ran 1 test suite in 261.16ms (1.75ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests) ``` If you observe the output you can see the pot still has rewards despite distributing them to claimants. **Recommended Mitigations:** Fixed-Point Arithmetic: Utilize a fixed-point arithmetic library or implement a custom solution to handle fee calculations with greater precision.

Support

FAQs

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

Give us feedback!