MyCut

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

closePot() sends the manager cut to the ContestManager contract (msg.sender), which has no withdraw — the cut is permanently locked

closePot() pays the manager cut to msg.sender — the ContestManager contract, which has no withdraw — so the protocol fee is permanently locked on every close.

Description

Normally, the contest's 10% manager fee skimmed in closePot() should reach the human admin/owner who operates the protocol so they can collect revenue.

The issue: closePot() sends the cut to msg.sender. closePot() is onlyOwner, and the Pot's owner is the ContestManager contract, because the ContestManager deploys the Pot (new Pot(...)) and Pot is Ownable(msg.sender). So inside closePot() msg.sender is always the ContestManager contract — an address that exposes no function able to move ERC20 out. The fee is therefore sent somewhere it can never be released.

// src/Pot.sol
contract Pot is Ownable(msg.sender) { ... } // owner = deployer = ContestManager
function closePot() external onlyOwner {
...
@> i_token.transfer(msg.sender, managerCut); // msg.sender == ContestManager (no withdraw)
}
// src/ContestManager.sol : createContest()
Pot pot = new Pot(...); // ContestManager is the deployer == Pot owner

Risk

Likelihood:

closePot() is reachable only through ContestManager.closeContest() -> _closeContest() -> pot.closePot(), so the caller inside closePot() is always the ContestManager contract.

The cut is therefore mis-sent on every single contest close, deterministically, with no special condition or attacker required.

Impact:

The 10% manager fee is permanently locked in the ContestManager contract on every close; the admin can never collect protocol revenue.

The trapped tokens are removed from circulation with no recovery path — neither Pot nor ContestManager exposes a withdraw/sweep.

Proof of Concept

This Foundry test passes against the in-scope code. A 400 Pot over [p1..p4]; only p1 claims (remaining 300); the owner closes; the 30-token manager cut lands in the ContestManager contract:

function test_managerCut_locked_in_ContestManager() public {
// owner creates + funds a 400 Pot over [p1, p2, p3, p4]; only p1 claims -> remaining 300
vm.prank(p1); Pot(pot).claimCut();
vm.warp(block.timestamp + 90 days + 1);
vm.prank(owner); cm.closeContest(pot); // closePot's msg.sender == the ContestManager
// managerCut = 300/10 = 30, transferred to the ContestManager contract (no recovery fn exists)
assertEq(weth.balanceOf(address(cm)), 30); // stuck: permanently locked
}

Recommended Mitigation

Pay the cut to an explicit recipient set at construction (the human admin), not msg.sender:

- i_token.transfer(msg.sender, managerCut);
+ i_token.transfer(i_admin, managerCut); // i_admin set at construction to the human owner

Alternatively, add an onlyOwner withdraw/sweep to ContestManager so trapped tokens can be recovered.

Updates

Lead Judging Commences

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