MyCut

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

Integer-division truncation in closePot() leaves dust permanently locked in the Pot with no recovery path

Root + Impact

Description

In closePot(), both the manager cut and the per-claimant cut use integer (truncating) division:

uint256 managerCut = remainingRewards / managerCutPercent;
@> uint256 claimantCut = (remainingRewards - managerCut) / i_players.length; // truncates
for (uint256 i = 0; i < claimants.length; i++) {
_transferReward(claimants[i], claimantCut);
}

Solidity discards the remainder on each division. The truncated wei is never assigned to anyone and there is no sweep/withdraw function, so the dust stays locked in the Pot forever. This is independent of the wrong-divisor bug; it persists even with the correct divisor.

Risk

Likelihood: High - occurs on essentially every close where the pool is not perfectly divisible by the number of recipients.

Impact: Low - the stranded amount is bounded (a few wei) and economically negligible, but it is a permanent, unrecoverable loss that accrues across every Pot the protocol closes.

Proof of Concept

To isolate rounding from the divisor bug, all players claim in time (so claimants.length == i_players.length) and the pool is overfunded so a leftover remains at close. Runnable Foundry test (drop into test/TestMyCut.t.sol):

function test_PoC_roundingDustLocked() public mintAndApproveTokens {
// promise 1 token each but fund 1000 -> 998 leftover after both claim
vm.startPrank(user);
rewards = [1, 1];
totalRewards = 1000;
contest = ContestManager(conMan).createContest(players, rewards, IERC20(ERC20Mock(weth)), totalRewards);
ContestManager(conMan).fundContest(0);
vm.stopPrank();
vm.prank(player1);
Pot(contest).claimCut();
vm.prank(player2);
Pot(contest).claimCut();
// remainingRewards = 1000 - 1 - 1 = 998
assertEq(Pot(contest).getRemainingRewards(), 998);
vm.warp(91 days);
vm.prank(user);
ContestManager(conMan).closeContest(contest);
// managerCut = 998/10 = 99 ; claimantCut = (998-99)/2 = 449 each
// distributed = 99 + 449 + 449 = 997 ; 998 - 997 = 1 wei dust stranded
uint256 dust = ERC20Mock(weth).balanceOf(contest);
assertEq(dust, 1); // rounding remainder permanently locked
}

Run forge test --mt test_PoC_roundingDustLocked -vv; it passes, proving 1 wei is permanently locked in the Pot.

Recommended Mitigation

Sweep the rounding remainder after finalization (or fold it into the manager cut / last claimant):

uint256 claimantCut = (remainingRewards - managerCut) / claimants.length;
for (uint256 i = 0; i < claimants.length; i++) {
_transferReward(claimants[i], claimantCut);
}
+ uint256 dust = i_token.balanceOf(address(this));
+ if (dust > 0) {
+ i_token.transfer(msg.sender, dust);
+ }
Updates

Lead Judging Commences

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