The README says that after the 90-day claim period "the manager takes a cut of the remaining pool". The manager is the Owner/Admin who creates, funds and closes pots, so the 10% cut should reach that admin.
Pot is Ownable(msg.sender) and ContestManager::createContest deploys it with new Pot(...), so the owner of every Pot is the ContestManager contract. closePot is onlyOwner, so it can only be reached through ContestManager::closeContest, and it transfers managerCut to msg.sender. That is the ContestManager contract, not the admin.
ContestManager's only token-moving call is transferFrom(msg.sender, pot, totalRewards) in fundContest, which spends the admin's own balance. It has no withdraw or sweep function and no fallback, and it never calls Pot.transferOwnership. The pinned OpenZeppelin 5.0.2 Ownable adds only owner, renounceOwnership and transferOwnership.
So the fee can never leave ContestManager. Even if "manager" is read as the ContestManager contract itself, the cut is unusable, because that contract has no way to spend it.
Likelihood:
This happens on every closeContest call where remainingRewards > 0, which means whenever at least one player did not claim. That is the normal case the close step exists for.
No attacker and no admin mistake is involved. The trusted admin following the intended flow triggers it on every pot, for the lifetime of the deployment.
Impact:
All of the protocol's fee revenue, 10% of every unclaimed pool across all pots, is frozen in ContestManager, and the admin receives none of it. In the PoC the admin's balance stays at 0 while ContestManager gains 50 per close, and three pots lock 150.
The loss is permanent. No code path, inherited or otherwise, moves ContestManager's token balance. Player funds are not affected.
The test deploys ContestManager and the repo's test/ERC20Mock.sol and uses the real createContest, fundContest and closeContest flow. All three tests pass:
test_managerCutLockedInContestManager:
It asserts pot.owner() == address(conMan), and that a direct pot.closePot() call from the admin reverts (onlyOwner).
p1 claims 500 and the admin closes. ContestManager's balance goes from 0 to 50, and the admin's balance is unchanged at 0.
The helper _assertNoExit then shows the fee cannot leave. Calls to withdraw(), withdraw(address), withdraw(address,uint256), sweep(address), transfer(address,uint256) and rescueTokens(address,address,uint256) on ContestManager all fail. ContestManager has granted no allowance. fundContest cannot recycle the fee, because it spends only the admin's balance.
test_feesAccumulateAcrossPots: three closed pots lock 150 in ContestManager, and the admin receives none of it.
test_repeatedCloseFeedsTheSinkUntilPotIsDrained: with zero claimants, ten repeated closes move the whole 1000 into ContestManager, and the next close reverts. This test depends on the separate non-terminal closePot issue and is included only to bound the amount.
Save the file as test/PoC_MC-02.t.sol and run from the repository root:
Forward the amount that closePot delivered on to the admin in the same call. An alternative is an onlyOwner withdraw(IERC20 token) on ContestManager.
## Description When `closeContest` function in the `ContestManager` contract is called, `pot` sends the owner's cut to the `ContestManager` itself, with no mechanism to withdraw these funds. ## Vulnerability Details: Relevant code - [Pot](https://github.com/Cyfrin/2024-08-MyCut/blob/main/src/Pot.sol#L7) [ContestManager](https://github.com/Cyfrin/2024-08-MyCut/blob/main/src/ContestManager.sol#L16-L26) The vulnerability stems from current ownership implementation between the `Pot` and `ContestManager` contracts, leading to funds being irretrievably locked in the `ContestManager` contract. 1. **Ownership Assignment**: When a `Pot` contract is created, it assigns `msg.sender` as its owner: ```solidity contract Pot is Ownable(msg.sender) { ... } ``` 2. **Contract Creation Context**: The `ContestManager` contract creates new `Pot` instances through its `createContest` function: ```solidity function createContest(...) public onlyOwner returns (address) { Pot pot = new Pot(players, rewards, token, totalRewards); ... } ``` In this context, `msg.sender` for the new `Pot` is the `ContestManager` contract itself, not the external owner who called `createContest`. 3. **Unintended Ownership**: As a result, the `ContestManager` becomes the owner of each `Pot` contract it creates, rather than the intended external owner. 4. **Fund Lock-up**: When `closeContest` is called (after the 90-day contest period), it triggers the `closePot` function: ```solidity function closeContest(address contest) public onlyOwner { Pot(contest).closePot(); } ``` The `closePot` function sends the owner's cut to its caller. Since the caller is `ContestManager`, these funds are sent to and locked within the `ContestManager` contract. 5. **Lack of Withdrawal Mechanism**: The `ContestManager` contract does not include any functionality to withdraw or redistribute these locked funds, rendering them permanently inaccessible. This ownership misalignment and the absence of a fund recovery mechanism result in a critical vulnerability where contest rewards become permanently trapped in the `ContestManager` contract. ## POC In existing test suite, add following test ```solidity function testOwnerCutStuckInContestManager() public mintAndApproveTokens { vm.startPrank(user); contest = ContestManager(conMan).createContest( players, rewards, IERC20(ERC20Mock(weth)), 100 ); ContestManager(conMan).fundContest(0); vm.stopPrank(); // Fast forward 91 days vm.warp(block.timestamp + 91 days); uint256 conManBalanceBefore = ERC20Mock(weth).balanceOf(conMan); console.log("contest manager balance before:", conManBalanceBefore); vm.prank(user); ContestManager(conMan).closeContest(contest); uint256 conManBalanceAfter = ERC20Mock(weth).balanceOf(conMan); // Assert that the ContestManager balance has increased (owner cut is stuck) assertGt(conManBalanceAfter, conManBalanceBefore); console.log("contest manager balance after:", conManBalanceAfter); } ``` run `forge test --mt testOwnerCutStuckInContestManager -vv` in the terminal and it will return following output: ```js [⠊] Compiling... [⠑] Compiling 1 files with Solc 0.8.20 [⠘] Solc 0.8.20 finished in 1.66s Compiler run successful! Ran 1 test for test/TestMyCut.t.sol:TestMyCut [PASS] testOwnerCutStuckInContestManager() (gas: 810988) Logs: User Address: 0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D Contest Manager Address 1: 0x7BD1119CEC127eeCDBa5DCA7d1Bd59986f6d7353 Minting tokens to: 0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D Approved tokens to: 0x7BD1119CEC127eeCDBa5DCA7d1Bd59986f6d7353 contest manager balance before: 0 contest manager balance after: 10 Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 10.51ms (1.31ms CPU time) ``` ## Impact Loss of funds for the protocol / owner ## Recommendations Add a claimERC20 function `ContestManager` to solve this issue. ```solidity function claimStuckedERC20(address tkn, address to, uint256 amount) external onlyOwner { // bytes4(keccak256(bytes('transfer(address,uint256)'))); (bool success, bytes memory data) = tkn.call(abi.encodeWithSelector(0xa9059cbb, to, amount)); require( success && (data.length == 0 || abi.decode(data, (bool))), 'ContestManager::safeTransfer: transfer failed' ); ```
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.