# Permanent Fund Lockup Due to Wrong Divisor in closePot() Distribution
## Summary
The incorrect divisor in `Pot.sol:57` will cause permanent loss of 63-100% of remaining funds for claimants as the `closePot()` function distributes bonus rewards by dividing by `i_players.length` (total players) but only iterates over `claimants.length` (actual claimants), leaving the majority of tokens locked forever in the Pot contract.
## Root Cause
In `Pot.sol:57` the claimant bonus distribution uses the wrong denominator:
```solidity
uint256 claimantCut = (remainingRewards - managerCut) / i_players.length; // ✗ BUG: Should be claimants.length
for (uint256 i = 0; i < claimants.length; i++) {
_transferReward(claimants[i], claimantCut);
}
```
The code divides by `i_players.length` (total number of registered players) but only distributes to `claimants.length` recipients. This mathematical error causes:
1. Each claimant receives a fraction of their fair share
2. The difference is permanently locked in the Pot contract with no recovery mechanism
**Additional Bug:** The manager cut in `Pot.sol:55` is sent to `msg.sender` which is the `ContestManager` contract (not the admin), and `ContestManager` has no withdrawal function, causing those funds to also be stuck.
## Internal Pre-conditions
1. At least one player needs to NOT claim their reward before the 90-day period expires
2. This is expected behavior - the protocol explicitly allows 90 days for claims
## External Pre-conditions
None - this is a pure protocol logic bug requiring no external conditions.
## Attack Path
This is not an active attack but a vulnerability path that occurs during normal protocol operation:
1. Admin creates a contest with 10 players, each entitled to 100 tokens (1000 total)
2. Some players claim their rewards (e.g., only player1 claims 100 tokens)
3. After 90 days, admin calls `ContestManager.closeContest()` to distribute remaining funds
4. Buggy calculation: `claimantCut = (900 - 90) / 10 = 81` per claimant
5. But only 1 claimant exists, so only `1 * 81 = 81` tokens are distributed
6. Result: `900 - 90 (manager) - 81 (claimant bonus) = 729 tokens` locked forever
## Impact
The claimants suffer permanent loss of 63-100% of remaining rewards depending on the claim ratio:
| Scenario | Players | Claimants | Remaining | Locked Forever | Loss % |
|----------|---------|-----------|-----------|----------------|--------|
| Worst Case | 10 | 0 | 1000 | 900 (+ 100 in ContestManager) | **100%** |
| Typical | 10 | 1 | 900 | 729 | **81%** |
| Moderate | 10 | 3 | 700 | 441 | **63%** |
**Quantified Impact:**
- For a 1000 ETH prize pool with 10 players where only 1 claims:
- Expected claimant bonus: 810 ETH
- Actual claimant bonus: 81 ETH
- **Permanent loss: 729 ETH (72.9% of remaining funds)**
## PoC
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test, console} from "forge-std/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";
/**
* @title ERC20Mock - Simple mock ERC20 for testing
*/
contract ERC20Mock is IERC20 {
string public name;
string public symbol;
uint8 public decimals = 18;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
constructor(string memory _name, string memory _symbol) {
name = _name;
symbol = _symbol;
}
function mint(address to, uint256 amount) external {
balanceOf[to] += amount;
totalSupply += amount;
}
function transfer(address to, uint256 amount) external returns (bool) {
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
emit Transfer(msg.sender, to, amount);
return true;
}
function approve(address spender, uint256 amount) external returns (bool) {
allowance[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
function transferFrom(address from, address to, uint256 amount) external returns (bool) {
allowance[from][msg.sender] -= amount;
balanceOf[from] -= amount;
balanceOf[to] += amount;
emit Transfer(from, to, amount);
return true;
}
}
/**
* @title ClaimantCutWrongDivisorPoC
* @notice PoC demonstrating that closePot() uses wrong divisor causing permanent fund lockup
*
* ## Vulnerability Summary
* In Pot.sol:closePot(), the claimant bonus distribution uses i_players.length as divisor
* but only iterates over claimants.length recipients. This causes:
* 1. Each claimant receives LESS than their fair share
* 2. Remaining tokens are PERMANENTLY LOCKED in the Pot contract
*
* ## Attack Scenario
* - 10 players, 1000 tokens total distributed equally (100 each)
* - Only 1 player claims their 100 tokens
* - After 90 days, closePot() is called with 900 remaining
* - Manager gets: 900 / 10 = 90 tokens (10%)
* - Claimant bonus: (900 - 90) / 10 = 81 tokens per claimant
* - But only 1 claimant exists! They get: 1 * 81 = 81 tokens
* - Locked forever: 900 - 90 - 81 = 729 tokens (72.9% of remaining!)
*/
contract ClaimantCutWrongDivisorPoC is Test {
ContestManager public contestManager;
ERC20Mock public token;
address public admin = makeAddr("admin");
// 10 players for clear demonstration
address public player1 = makeAddr("player1");
address public player2 = makeAddr("player2");
address public player3 = makeAddr("player3");
address public player4 = makeAddr("player4");
address public player5 = makeAddr("player5");
address public player6 = makeAddr("player6");
address public player7 = makeAddr("player7");
address public player8 = makeAddr("player8");
address public player9 = makeAddr("player9");
address public player10 = makeAddr("player10");
address[] public players;
uint256[] public rewards;
uint256 public constant TOTAL_REWARDS = 1000 ether;
function setUp() public {
// Setup players array
players = new address[](10);
players[0] = player1;
players[1] = player2;
players[2] = player3;
players[3] = player4;
players[4] = player5;
players[5] = player6;
players[6] = player7;
players[7] = player8;
players[8] = player9;
players[9] = player10;
// Each player gets 100 ether (fair distribution)
rewards = new uint256[](10);
for (uint i = 0; i < 10; i++) {
rewards[i] = 100 ether;
}
// Deploy contracts
vm.startPrank(admin);
contestManager = new ContestManager();
token = new ERC20Mock("Test Token", "TEST");
token.mint(admin, TOTAL_REWARDS);
token.approve(address(contestManager), TOTAL_REWARDS);
vm.stopPrank();
}
/**
* @notice Test demonstrating the vulnerability with quantified fund lockup
*/
function test_PermanentFundLockup_WrongDivisor() public {
console.log("=== ClaimantCut Wrong Divisor Vulnerability PoC ===");
console.log("");
// Step 1: Admin creates and funds the contest
vm.startPrank(admin);
address potAddress = contestManager.createContest(
players,
rewards,
IERC20(address(token)),
TOTAL_REWARDS
);
contestManager.fundContest(0);
vm.stopPrank();
Pot pot = Pot(potAddress);
console.log("[SETUP] Contest Created and Funded");
console.log(" Total Players: 10");
console.log(" Total Rewards: %s tokens", TOTAL_REWARDS / 1e18);
console.log(" Pot Balance: %s tokens", token.balanceOf(potAddress) / 1e18);
console.log("");
// Step 2: Only player1 claims their reward (simulating partial claims)
vm.prank(player1);
pot.claimCut();
uint256 player1Balance = token.balanceOf(player1);
console.log("[CLAIM] Player1 claims their cut");
console.log(" Player1 received: %s tokens", player1Balance / 1e18);
console.log(" Remaining in Pot: %s tokens", pot.getRemainingRewards() / 1e18);
console.log("");
// Step 3: Fast forward 91 days
vm.warp(block.timestamp + 91 days);
console.log("[TIME] 91 days passed, pot ready to close");
console.log("");
// Step 4: Record balances before close
uint256 adminBalBefore = token.balanceOf(admin);
uint256 player1BalBefore = token.balanceOf(player1);
uint256 potBalBefore = token.balanceOf(potAddress);
uint256 remainingBefore = pot.getRemainingRewards();
console.log("[BEFORE CLOSE]");
console.log(" Admin balance: %s tokens", adminBalBefore / 1e18);
console.log(" Player1 balance: %s tokens", player1BalBefore / 1e18);
console.log(" Pot balance: %s tokens", potBalBefore / 1e18);
console.log(" Remaining rewards: %s tokens", remainingBefore / 1e18);
console.log("");
// Step 5: Close the pot - THIS IS WHERE THE BUG MANIFESTS
vm.prank(admin);
contestManager.closeContest(potAddress);
// Step 6: Analyze the damage
uint256 adminBalAfter = token.balanceOf(admin);
uint256 player1BalAfter = token.balanceOf(player1);
uint256 potBalAfter = token.balanceOf(potAddress);
uint256 managerReceived = adminBalAfter - adminBalBefore;
uint256 player1Bonus = player1BalAfter - player1BalBefore;
uint256 lockedForever = potBalAfter;
console.log("[AFTER CLOSE - BUG DEMONSTRATED]");
console.log(" Manager received (10%): %s tokens", managerReceived / 1e18);
console.log(" Player1 bonus: %s tokens", player1Bonus / 1e18);
console.log(" ");
console.log(" >>> TOKENS LOCKED FOREVER IN POT: %s tokens <<<", lockedForever / 1e18);
console.log("");
// Step 7: Calculate what SHOULD have happened
// Expected: claimantCut = (remaining - managerCut) / claimants.length = (900 - 90) / 1 = 810
// Actual: claimantCut = (remaining - managerCut) / i_players.length = (900 - 90) / 10 = 81
// Locked: 810 - 81 = 729 tokens
uint256 expectedClaimantBonus = (remainingBefore - managerReceived); // 810 ether
uint256 actualClaimantBonus = player1Bonus; // 81 ether
uint256 stolenFromClaimant = expectedClaimantBonus - actualClaimantBonus;
console.log("[IMPACT ANALYSIS]");
console.log(" Expected claimant bonus: %s tokens", expectedClaimantBonus / 1e18);
console.log(" Actual claimant bonus: %s tokens", actualClaimantBonus / 1e18);
console.log(" Claimant LOSS: %s tokens", stolenFromClaimant / 1e18);
console.log("");
console.log(" Loss percentage: %s%%", (lockedForever * 100) / TOTAL_REWARDS);
console.log("");
// Assertions proving the vulnerability
assertGt(lockedForever, 0, "Tokens should be locked in pot");
assertEq(lockedForever, 729 ether, "Exactly 729 tokens should be locked");
assertEq(player1Bonus, 81 ether, "Claimant only received 81 tokens instead of 810");
console.log("=== PoC SUCCESSFUL: %s tokens (72.9%%) permanently locked ===", lockedForever / 1e18);
}
/**
* @notice Worst case: No one claims, ALL tokens locked forever
* @dev ADDITIONAL BUG DISCOVERED: Manager cut goes to ContestManager contract, NOT the admin!
*/
function test_WorstCase_NoOneClaims_AllTokensLocked() public {
console.log("=== WORST CASE: No Claims = Total Fund Lockup ===");
console.log("");
vm.startPrank(admin);
address potAddress = contestManager.createContest(
players,
rewards,
IERC20(address(token)),
TOTAL_REWARDS
);
contestManager.fundContest(0);
vm.stopPrank();
Pot pot = Pot(potAddress);
console.log("[SETUP] Contest Created, NO ONE CLAIMS");
console.log(" Total Rewards: %s tokens", TOTAL_REWARDS / 1e18);
console.log("");
// Fast forward and close
vm.warp(block.timestamp + 91 days);
uint256 adminBalBefore = token.balanceOf(admin);
uint256 contestManagerBalBefore = token.balanceOf(address(contestManager));
vm.prank(admin);
contestManager.closeContest(potAddress);
uint256 adminBalAfter = token.balanceOf(admin);
uint256 contestManagerBalAfter = token.balanceOf(address(contestManager));
uint256 managerReceivedByAdmin = adminBalAfter - adminBalBefore;
uint256 managerReceivedByContract = contestManagerBalAfter - contestManagerBalBefore;
uint256 lockedInPot = token.balanceOf(potAddress);
console.log("[RESULT - MULTIPLE BUGS REVEALED]");
console.log(" Admin received: %s tokens (SHOULD be 100)", managerReceivedByAdmin / 1e18);
console.log(" ContestManager contract received: %s tokens (BUG #2!)", managerReceivedByContract / 1e18);
console.log(" >>> LOCKED IN POT FOREVER: %s tokens <<<", lockedInPot / 1e18);
console.log("");
// BUG #1: Claimant loop runs 0 times, so 90% stays in pot
// BUG #2: Manager cut of 10% goes to ContestManager contract, NOT admin
// ContestManager has no withdraw function = funds stuck there too!
console.log("[BUG #1] Wrong divisor: 90%% locked in Pot contract");
console.log("[BUG #2] Manager cut goes to ContestManager (no withdraw!) = stuck");
console.log("");
console.log("Total unrecoverable: %s tokens (100%%!)", (lockedInPot + managerReceivedByContract) / 1e18);
// Verify the bugs
assertEq(managerReceivedByAdmin, 0, "Admin gets NOTHING - bug #2");
assertEq(managerReceivedByContract, 100 ether, "ContestManager gets the 10% cut");
assertEq(lockedInPot, 900 ether, "90% locked in Pot - bug #1");
console.log("");
console.log("=== CRITICAL: 100%% of protocol funds UNRECOVERABLE ===");
}
/**
* @notice Demonstrate the difference between actual vs expected distribution
*/
function test_CompareActualVsExpectedDistribution() public {
console.log("=== Actual vs Expected Distribution Analysis ===");
console.log("");
vm.startPrank(admin);
address potAddress = contestManager.createContest(
players,
rewards,
IERC20(address(token)),
TOTAL_REWARDS
);
contestManager.fundContest(0);
vm.stopPrank();
Pot pot = Pot(potAddress);
// 3 out of 10 players claim
vm.prank(player1);
pot.claimCut();
vm.prank(player2);
pot.claimCut();
vm.prank(player3);
pot.claimCut();
uint256 remaining = pot.getRemainingRewards(); // 700 ether
console.log("[SCENARIO] 3 out of 10 players claimed");
console.log(" Players claimed: 300 tokens");
console.log(" Remaining: %s tokens", remaining / 1e18);
console.log("");
vm.warp(block.timestamp + 91 days);
uint256 p1Before = token.balanceOf(player1);
uint256 p2Before = token.balanceOf(player2);
uint256 p3Before = token.balanceOf(player3);
vm.prank(admin);
contestManager.closeContest(potAddress);
uint256 p1Bonus = token.balanceOf(player1) - p1Before;
uint256 p2Bonus = token.balanceOf(player2) - p2Before;
uint256 p3Bonus = token.balanceOf(player3) - p3Before;
uint256 locked = token.balanceOf(potAddress);
// Buggy calculation:
// managerCut = 700 / 10 = 70
// claimantCut = (700 - 70) / 10 = 63 per claimant
// Total distributed to claimants = 3 * 63 = 189
// Locked = 700 - 70 - 189 = 441
// CORRECT calculation should be:
// managerCut = 700 / 10 = 70
// claimantCut = (700 - 70) / 3 = 210 per claimant
// Total distributed = 3 * 210 = 630
// Locked = 0
console.log("[ACTUAL DISTRIBUTION (BUGGY)]");
console.log(" Each claimant bonus: %s tokens", p1Bonus / 1e18);
console.log(" Total to claimants: %s tokens", (p1Bonus + p2Bonus + p3Bonus) / 1e18);
console.log(" Locked: %s tokens", locked / 1e18);
console.log("");
uint256 expectedPerClaimant = (remaining - (remaining / 10)) / 3; // 210 ether
console.log("[EXPECTED DISTRIBUTION (CORRECT)]");
console.log(" Each claimant should get: %s tokens", expectedPerClaimant / 1e18);
console.log(" Locked should be: 0 tokens");
console.log("");
console.log("[LOSS]");
console.log(" Each claimant loses: %s tokens", (expectedPerClaimant - p1Bonus) / 1e18);
console.log(" Total locked forever: %s tokens (%s%%)", locked / 1e18, (locked * 100) / remaining);
assertEq(p1Bonus, 63 ether, "Bug: claimant gets 63 instead of 210");
assertEq(locked, 441 ether, "63% of remaining funds locked");
}
}
```
### Files Included
- `ClaimantCutWrongDivisorPoC.t.sol` - Main PoC test file
### Environment Setup
1. Install dependencies:
```bash
forge install foundry-rs/forge-std OpenZeppelin/openzeppelin-contracts
```
2. Modify `foundry.toml` to include PoC test path:
```toml
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
test = "poc"
```
### Run Command
```bash
forge test --match-contract ClaimantCutWrongDivisorPoC -vvv
```
### Expected Output
```
[PASS] test_CompareActualVsExpectedDistribution() (gas: 1732754)
Logs:
=== Actual vs Expected Distribution Analysis ===
[SCENARIO] 3 out of 10 players claimed
Players claimed: 300 tokens
Remaining: 700 tokens
[ACTUAL DISTRIBUTION (BUGGY)]
Each claimant bonus: 63 tokens
Total to claimants: 189 tokens
Locked: 441 tokens
[EXPECTED DISTRIBUTION (CORRECT)]
Each claimant should get: 210 tokens
Locked should be: 0 tokens
[LOSS]
Each claimant loses: 147 tokens
Total locked forever: 441 tokens (63%)
[PASS] test_PermanentFundLockup_WrongDivisor() (gas: 1672704)
Logs:
=== ClaimantCut Wrong Divisor Vulnerability PoC ===
[SETUP] Contest Created and Funded
Total Players: 10
Total Rewards: 1000 tokens
Pot Balance: 1000 tokens
[CLAIM] Player1 claims their cut
Player1 received: 100 tokens
Remaining in Pot: 900 tokens
[TIME] 91 days passed, pot ready to close
[AFTER CLOSE - BUG DEMONSTRATED]
Manager received (10%): 0 tokens
Player1 bonus: 81 tokens
>>> TOKENS LOCKED FOREVER IN POT: 729 tokens <<<
[IMPACT ANALYSIS]
Expected claimant bonus: 900 tokens
Actual claimant bonus: 81 tokens
Claimant LOSS: 819 tokens
Loss percentage: 72%
=== PoC SUCCESSFUL: 729 tokens (72.9%) permanently locked ===
[PASS] test_WorstCase_NoOneClaims_AllTokensLocked() (gas: 1589723)
Logs:
=== WORST CASE: No Claims = Total Fund Lockup ===
[SETUP] Contest Created, NO ONE CLAIMS
Total Rewards: 1000 tokens
[RESULT - MULTIPLE BUGS REVEALED]
Admin received: 0 tokens (SHOULD be 100)
ContestManager contract received: 100 tokens (BUG #2!)
>>> LOCKED IN POT FOREVER: 900 tokens <<<
[BUG #1] Wrong divisor: 90% locked in Pot contract
[BUG #2] Manager cut goes to ContestManager (no withdraw!) = stuck
Total unrecoverable: 1000 tokens (100%!)
=== CRITICAL: 100% of protocol funds UNRECOVERABLE ===
Suite result: ok. 3 passed; 0 failed; 0 skipped
```
## Mitigation
Replace `i_players.length` with `claimants.length` in the divisor:
```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);
+ i_token.transfer(owner(), managerCut); // Fix: Send to owner, not msg.sender
- uint256 claimantCut = (remainingRewards - managerCut) / i_players.length;
+ uint256 claimantCut = claimants.length > 0
+ ? (remainingRewards - managerCut) / claimants.length
+ : 0;
for (uint256 i = 0; i < claimants.length; i++) {
_transferReward(claimants[i], claimantCut);
}
+
+ // Transfer any remaining dust to owner
+ uint256 remaining = i_token.balanceOf(address(this));
+ if (remaining > 0) {
+ i_token.transfer(owner(), remaining);
+ }
}
}
```
**Key fixes:**
1. Divide by `claimants.length` instead of `i_players.length`
2. Add zero-check to prevent division by zero when no one claims
3. Send manager cut to `owner()` instead of `msg.sender`
4. Add sweep mechanism to recover any rounding dust