MyCut

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

`Pot::closePot` sends the manager cut to the `ContestManager` contract, which has no token-withdrawal function, permanently freezing every manager cut

Severity: M · Impact: M · Likelihood: H

Description

  • closePot is meant to pay the manager their cut of the leftover pool. It sends that cut to msg.sender.

  • closePot is onlyOwner, and the Pot's owner is the ContestManager contract (it deployed the Pot). The only way to reach closePot is ContestManager::closeContest_closeContestpot.closePot(), so msg.sender is always the ContestManager contract, never a human/EOA. The cut therefore lands in ContestManager, which exposes no function that can transfer ERC20 out — so the manager cut is permanently frozen and unrecoverable.

// src/Pot.sol:L54-L55 (msg.sender here is the ContestManager, the Pot's owner)
uint256 managerCut = remainingRewards / managerCutPercent;
@> i_token.transfer(msg.sender, managerCut); // -> ContestManager, which cannot move tokens out
// src/ContestManager.sol — no withdraw/sweep function exists; tokens sent here are stuck
// (functions: createContest, fundContest, getters, closeContest, _closeContest)

Risk

Likelihood

  • Occurs on every closeContest where the pool has unclaimed rewards (remainingRewards > 0) — i.e. the normal close path whenever any player did not claim.

Impact

  • The manager never receives their cut; it is permanently frozen in the ContestManager with no recovery path. Value is bounded to 10% of the unclaimed pool per contest, and accrues across every contest closed.

  • Escalation note: by the permanent-freeze rubric (funds locked with no recovery path → top-tier) this is arguably High; submitted as Medium because the frozen value is the manager's own bounded cut rather than user principal.

Proof of Concept

Save as test/PoC_M01.t.sol and run forge test --mt test_managerCutFrozenInContestManager -vv. Two players are allocated 500 each; one claims; at close the 50-token manager cut is transferred into the ContestManager, where nothing can retrieve it.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test, console} from "lib/forge-std/src/Test.sol";
import {ERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
import {IERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol";
import {ContestManager} from "../src/ContestManager.sol";
import {Pot} from "../src/Pot.sol";
contract MockERC20 is ERC20 {
constructor() ERC20("Mock", "MCK") {}
function mint(address to, uint256 amt) external { _mint(to, amt); }
}
contract PoC_M01 is Test {
function test_managerCutFrozenInContestManager() public {
ContestManager cm = new ContestManager(); // owner = address(this)
MockERC20 token = new MockERC20();
address[] memory players = new address[](2);
uint256[] memory rewards = new uint256[](2);
players[0] = address(0x1); players[1] = address(0x2);
rewards[0] = 500; rewards[1] = 500;
token.mint(address(this), 1000);
token.approve(address(cm), 1000);
address pot = cm.createContest(players, rewards, IERC20(address(token)), 1000);
cm.fundContest(0);
vm.prank(players[0]); Pot(pot).claimCut(); // one claimant; remaining = 500 at close
vm.warp(block.timestamp + 91 days);
uint256 cmBefore = token.balanceOf(address(cm));
cm.closeContest(pot); // src/Pot.sol:L55 sends managerCut (50) to the ContestManager
uint256 frozen = token.balanceOf(address(cm)) - cmBefore;
console.log("frozen manager cut in ContestManager:", frozen);
assertEq(frozen, 50);
// ContestManager has no ERC20-transfer/withdraw function -> these 50 tokens are stuck forever.
}
}

Recommended Mitigation

Pay the cut to a real recipient (the ContestManager's owner), or add a withdrawal function to ContestManager. Passing the intended recipient into closePot is cleanest:

// src/Pot.sol:L49 and L55
- function closePot() external onlyOwner {
+ function closePot(address managerRecipient) external onlyOwner {
...
- i_token.transfer(msg.sender, managerCut);
+ i_token.transfer(managerRecipient, managerCut);
// src/ContestManager.sol:L57-L59 — forward the real manager (the ContestManager owner)
function _closeContest(address contest) internal {
Pot pot = Pot(contest);
- pot.closePot();
+ pot.closePot(owner());
}

Why: routing the cut to the ContestManager owner (an EOA/multisig that can actually spend it) removes the dead-end where funds are sent to a contract with no way to move them.

Updates

Lead Judging Commences

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