MyCut

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

Manager cut sent to ContestManager — permanently locked with no withdraw

Title: Manager cut sent to ContestManager — permanently locked with no withdraw
Impact: High. Every closePot execution sends the manager's 10% cut to an irretrievable contract.
Likelihood: High. Deterministic — affects every contest closed via the ContestManager.
Reference Files: repos/src/Pot.sol:7,49-62, repos/src/ContestManager.sol:53-61

Description

When ContestManager creates a Pot via new Pot(...), the Pot constructor calls Ownable(msg.sender) — setting the Pot's owner to the ContestManager contract address, not the EOA admin. When closeContest triggers pot.closePot(), the onlyOwner check passes because the caller is ContestManager. Inside closePot(), the manager's 10% cut is sent via i_token.transfer(msg.sender, managerCut) — but msg.sender is ContestManager, not the EOA owner. ContestManager has no withdraw(), sweep(), or transfer() function to recover these tokens.

// Pot deployment: owner = ContestManager, not EOA admin
contract Pot is Ownable(msg.sender) { // msg.sender = ContestManager
// closePot sends manager cut to msg.sender
function closePot() external onlyOwner { // onlyOwner → caller must be ContestManager
uint256 managerCut = remainingRewards / managerCutPercent;
i_token.transfer(msg.sender, managerCut); // msg.sender = ContestManager → LOCKED!
}

The manager's intended 10% cut on every contest closure is permanently trapped in the ContestManager contract.

Risk

Impact: High. On every contest closure, 10% of the remaining reward pool is sent to ContestManager and becomes permanently inaccessible. Over 100 contests with an average remaining pool of 1,000 tokens, approximately 10,000 tokens are locked — with zero recovery mechanism.
Likelihood: High. The ownership misalignment is baked into the constructor and triggers on every single closeContest call. No transaction error or revert occurs — the transfer succeeds silently, moving tokens to an irretrievable destination.
With a single contest holding 1,000 tokens where 500 remain unclaimed, the manager's 50-token cut is permanently lost to the ContestManager. Across the protocol's lifetime, this compounds into total loss of the manager's share.

Proof of Concept

function testManagerCutStuckInContestManager() public {
vm.startPrank(owner);
address pot = contestManager.createContest(players, rewards, token, 100);
contestManager.fundContest(0);
vm.stopPrank();
vm.warp(91 days);
uint256 cmBalanceBefore = token.balanceOf(address(contestManager));
vm.prank(owner);
contestManager.closeContest(pot);
uint256 cmBalanceAfter = token.balanceOf(address(contestManager));
assertGt(cmBalanceAfter, cmBalanceBefore);
// Manager's 10% cut is now locked in ContestManager — no withdraw function exists
}

This PoC proves the manager's cut is transferred to the ContestManager contract where it becomes permanently inaccessible.

Recommended Mitigation

function closePot() external onlyOwner {
// ...
i_token.transfer(ContestManager(msg.sender).owner(), managerCut);
// ...
}

Route the manager cut to the ContestManager's owner (the EOA admin) instead of the ContestManager contract itself. Alternatively, add a claimERC20 function to ContestManager allowing the owner to sweep trapped tokens.

Updates

Lead Judging Commences

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

[H-01] Owner Cut Stuck in `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' ); ```

Support

FAQs

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

Give us feedback!