MyCut

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

Integer division in closePot() permanently locks truncation remainder tokens with no recovery path

Root + Impact

Description

  • Pot::closePot() is supposed to distribute all remaining funds — 10% to the manager and the rest to claimants — leaving the Pot balance at zero after close.

  • Two sequential integer divisions truncate the fractional wei. The truncated amounts remain in the Pot after all transfers complete. Because Pot has no sweep or withdrawal function, these dust amounts are permanently locked.

// Pot.sol closePot()
// @> division 1 — remainder up to (managerCutPercent - 1) wei is truncated
uint256 managerCut = remainingRewards / managerCutPercent;
i_token.transfer(msg.sender, managerCut);
// @> division 2 — remainder up to (i_players.length - 1) wei is truncated
uint256 claimantCut = (remainingRewards - managerCut) / i_players.length;
for (uint256 i = 0; i < claimants.length; i++) {
_transferReward(claimants[i], claimantCut);
}
// remaining balance = (remainingRewards % 10) + ((remainingRewards - managerCut) % i_players.length)
// this amount has no recipient

Risk

Likelihood:

  • This activates on every closePot() call unless remainingRewards is an exact multiple of both managerCutPercent (10) and the player count — which almost never occurs in practice.

Impact:

  • Per-pot loss is bounded by (managerCutPercent - 1) + (i_players.length - 1) token units, which is dust for 18-decimal tokens but can be meaningful for low-decimal tokens (USDC, USDT at 6 decimals) or when player count is large.

  • The loss is guaranteed and cumulative across all pots — every close permanently discards some tokens with no recovery mechanism.

Proof of Concept

Create a pot with a reward amount that produces an obvious remainder under integer division. After all players claim and the pot is closed, verify the Pot contract still holds a non-zero balance.

function testPrecisionLossLocksTokens() public mintAndApproveTokens {
address[] memory threeP = new address[](3);
uint256[] memory threeR = new uint256[](3);
threeP[0] = makeAddr("p1");
threeP[1] = makeAddr("p2");
threeP[2] = makeAddr("p3");
// Use 10e18 + 1 wei — managerCut = 1e18, distributable = 9e18 + 1 wei
// 9e18 + 1 wei divided by 3 = 3e18 per player with 1 wei remainder locked
uint256 tinyTotal = 10e18 + 1;
threeR[0] = tinyTotal / 3;
threeR[1] = tinyTotal / 3;
threeR[2] = tinyTotal - (tinyTotal / 3) * 2;
vm.startPrank(user);
weth.mint(user, tinyTotal);
weth.approve(conMan, tinyTotal);
address pot = ContestManager(conMan).createContest(
threeP, threeR, IERC20(weth), tinyTotal
);
ContestManager(conMan).fundContest(
ContestManager(conMan).getContests().length - 1
);
vm.stopPrank();
// All 3 players claim
for (uint256 i = 0; i < 3; i++) {
vm.prank(threeP[i]);
Pot(pot).claimCut();
}
vm.warp(block.timestamp + 91 days);
vm.prank(user);
ContestManager(conMan).closeContest(pot);
// Pot still holds leftover wei — permanently locked
assertGt(weth.balanceOf(pot), 0, "dust permanently locked");
}

weth.balanceOf(pot) > 0 after close confirms that truncation remainder is irrecoverable.

Recommended Mitigation

Assign the truncation remainder to the last recipient in each division so the full balance is distributed and the Pot ends at zero.

function closePot() external onlyOwner {
if (block.timestamp - i_deployedAt < 90 days) revert Pot__StillOpenForClaim();
if (remainingRewards > 0) {
uint256 managerCut = remainingRewards / managerCutPercent;
+ uint256 managerRemainder = remainingRewards % managerCutPercent;
- i_token.transfer(msg.sender, managerCut);
+ i_token.transfer(i_manager, managerCut + managerRemainder);
+ uint256 distributable = remainingRewards - managerCut - managerRemainder;
if (claimants.length > 0) {
- uint256 claimantCut = (remainingRewards - managerCut) / claimants.length;
+ uint256 claimantCut = distributable / claimants.length;
+ uint256 claimantRemainder = distributable % claimants.length;
for (uint256 i = 0; i < claimants.length; i++) {
- _transferReward(claimants[i], claimantCut);
+ uint256 extra = (i == claimants.length - 1) ? claimantRemainder : 0;
+ _transferReward(claimants[i], claimantCut + extra);
}
}
}
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 13 days 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!