MyCut

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

Owner's cut is stuck in ContestManager contract

Description

  • When a pot is closed after the claim window, the protocol should send the manager/owner cut to the human admin (EOA) who operates the system, allowing them to realize their share of the remaining rewards.

  • ContestManager.closeContest() calls Pot.closePot(). Inside Pot.closePot(), the manager cut is transferred to msg.sender. Because msg.sender in Pot is the ContestManager contract address (not the admin EOA), the manager cut is sent to ContestManager. Since ContestManager lacks any withdrawal function, those tokens become stuck in the ContestManager contract with no path for the owner to retrieve them.

// ContestManager.sol
function _closeContest(address contest) internal {
Pot pot = Pot(contest);
pot.closePot(); // @> msg.sender inside Pot == ContestManager (this contract)
}
// Pot.sol
function closePot() external onlyOwner {
if (block.timestamp - i_deployedAt < 90 days) {
revert Pot__StillOpenForClaim();
}
if (remainingRewards > 0) {
uint256 managerCut = remainingRewards / managerCutPercent;
// @> BUG: Sends to msg.sender, which is ContestManager contract
i_token.transfer(msg.sender, managerCut);
// ...
}
}

Risk

Likelihood: High

  • In normal operations, contests are closed via ContestManager.closeContest(...), so the cut routinely goes to the ContestManager contract address.

  • The ContestManager contract has no withdraw/sweep method for ERC‑20 tokens, making the stuck state persistent every time a pot is closed.

Impact: High

  • Permanent loss of owner funds: The owner cannot access the manager cut, breaking the economic assumptions of the protocol.

  • Operational friction: Attempts to reconcile balances or migrate funds are blocked; tokens accumulate inside ContestManager, impairing accounting and future upgrades.

Proof of Concept

  • Copy the code below to TestMyCut.t.sol.

  • Run command forge test --mt testOwnerCutStuckedInContestManager -vv.

function testOwnerCutStuckedInContestManager() public mintAndApproveTokens {
vm.startPrank(user);
rewards = [500, 500];
totalRewards = 1000;
contest = ContestManager(conMan).createContest(players, rewards, IERC20(ERC20Mock(weth)), totalRewards);
ContestManager(conMan).fundContest(0);
vm.stopPrank();
// Player 1 claims 500, remainingRewards = 500
vm.prank(player1);
Pot(contest).claimCut();
// Check balances before closing
uint256 ownerBalance = ERC20Mock(weth).balanceOf(user);
emit log_named_uint("Owner balance before closing contest: ", ownerBalance);
uint256 contestManagerBalance = ERC20Mock(weth).balanceOf(conMan);
emit log_named_uint("Contest Manager balance before closing contest: ", contestManagerBalance);
// Fast-forward >90 days and close
vm.warp(91 days);
vm.prank(user);
// Owner's cut should be 10% of 500 = 50
ContestManager(conMan).closeContest(contest);
// Owner balance after closing remains the same, which is incorrect
ownerBalance = ERC20Mock(weth).balanceOf(user);
emit log_named_uint("Owner balance after closing contest: ", ownerBalance);
// Funds meant for owner cut are stuck in ContestManager
contestManagerBalance = ERC20Mock(weth).balanceOf(conMan);
emit log_named_uint("Contest Manager balance after closing contest: ", contestManagerBalance);
}

Output:

[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/TestMyCut.t.sol:TestMyCut
[PASS] testOwnerCutStuckedInContestManager() (gas: 1155334)
Logs:
User Address: 0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D
Contest Manager Address 1: 0x7BD1119CEC127eeCDBa5DCA7d1Bd59986f6d7353
Minting tokens to: 0x6CA6d1e2D5347Bfab1d91e883F1915560e09129D
Approved tokens to: 0x7BD1119CEC127eeCDBa5DCA7d1Bd59986f6d7353
Owner balance before closing contest: : 999999999999999999000
Contest Manager balance before closing contest: : 0
Owner balance after closing contest: : 999999999999999999000
Contest Manager balance after closing contest: : 50
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 4.07ms (1.03ms CPU time)
Ran 1 test suite in 14.33ms (4.07ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended Mitigation

Pass an explicit manager recipient into Pot at construction, and send the cut there (e.g., ContestManager.owner()).

- contract Pot is Ownable(msg.sender) {
+ contract Pot is Ownable {
+ address public immutable i_manager;
- constructor(address[] memory players, uint256[] memory rewards, IERC20 token, uint256 totalRewards) {
+ constructor(address[] memory players, uint256[] memory rewards, IERC20 token, uint256 totalRewards, address manager)
+ Ownable(msg.sender)
+ {
+ i_manager = manager; // set the EOA or designated recipient
// ...
}
function closePot() external onlyOwner {
// ...
if (remainingRewards > 0) {
uint256 managerCut = remainingRewards / managerCutPercent;
- i_token.transfer(msg.sender, managerCut);
+ i_token.transfer(i_manager, managerCut); // send directly to the intended recipient
// ...
}
}
- Pot pot = new Pot(players, rewards, token, totalRewards);
+ Pot pot = new Pot(players, rewards, token, totalRewards, owner()); // EOA owner receives the cut
Updates

Lead Judging Commences

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