Client: MyCut Protocol
Audit Date: [Date]
Report Version: 1.0
MyCut is a contest-rewards distribution protocol where owners create reward pools for contests, players claim their shares within 90 days, and unclaimed funds are redistributed with a 10% management fee.
The audit identified 4 critical High-severity vulnerabilities that result in permanent fund locks and protocol revenue loss, 4 Medium-severity issues affecting functionality and fund safety, and 3 Low-severity concerns for operational robustness.
Highest Priority: The combination of H-1, H-2, and H-3 creates a scenario where:
Player funds are permanently locked in the pot
Protocol fees are permanently locked in the manager
Late claimants can double-spend the pot
| Contract | nSLOC | Description |
|---|---|---|
src/Pot.sol |
~78 | Individual contest reward pool management |
src/ContestManager.sol |
~61 | Contest creation and lifecycle management |
| ID | Severity | Title |
|---|---|---|
| [H-1] | High | Forfeited rewards are never fully redistributed — wrong denominator locks funds permanently |
| [H-2] | High | Manager cut is sent to ContestManager which has no withdrawal function — fees permanently locked |
| [H-3] | High | No claim deadline — post-close claims double-spend the pot and permanently lock late claimants out |
| [H-4] | High | closePot is re-callable and drains the entire pot |
| [M-1] | Medium | No validation that totalRewards >= sum(rewards) — claimCut underflows, locking the last claimants |
| [M-2] | Medium | Division by zero when a pot is created with zero players — pot can never be closed |
| [M-3] | Medium | No "already funded" guard — double-funding permanently locks tokens |
| [M-4] | Medium | No input validation in createContest (array-length mismatch, duplicates) |
| [L-1] | Low | Integer-division dust is permanently locked (no rescue function) |
| [L-2] | Low | ERC20 transfer/transferFrom return values are unchecked |
| [L-3] | Low | No emergency-withdraw/rescue path for any error mode |
Severity: High
Category: Loss of Funds (Permanent Lock)
Affected Code: src/Pot.sol:57
The post-close redistribution divides the forfeited pool by the total number of players instead of the number of claimants:
The Issue: Per the protocol specification ("the remainder is distributed equally to those who claimed in time"), every token a non-claimant forfeits should go to the claimants. Dividing by i_players.length reserves a share for players who never claimed and can never claim, leaving that share permanently locked in the pot.
| Scenario | Tokens Locked |
|---|---|
| 3 players, 1 doesn't claim | ~33% of remaining pool locked |
| 10 players, 3 don't claim | ~30% of remaining pool locked |
| n players, m claimants | (remaining - managerCut) * (players - claimants) / players locked |
Mathematical Example:
3 players with 1000 tokens each (total 3000)
Alice and Bob claim; Carol does not → remainingRewards = 1000
Manager cut (10%): 100 tokens
Current implementation: ClaimantCut = 900/3 = 300 each
Result: 300 tokens permanently locked in pot
Expected: ClaimantCut = 900/2 = 450 each, pot empty
Severity: High
Category: Loss of Protocol Revenue
Affected Code: src/Pot.sol:55, src/ContestManager.sol:53-60
Pot is declared as Ownable(msg.sender). When deployed from ContestManager.createContest(), msg.sender — and therefore the Pot's owner — is the ContestManager contract itself.
When the owner calls ContestManager.closeContest(), it calls pot.closePot(). Inside closePot():
The Issue: msg.sender is the ContestManager contract (not the owner EOA). ContestManager has no withdrawal/sweep/rescue function, so the 10% fee is permanently stuck in the ContestManager contract.
| Scenario | Impact |
|---|---|
| Any contest with unclaimed funds | Protocol revenue permanently lost |
| Every closed contest | Manager cut sent to dead-end contract |
| Protocol economics | Fee collection mechanism completely broken |
Send the cut to the actual owner instead of msg.sender:
Severity: High
Category: Loss of Funds, Double-Spend Vulnerability
Affected Code: src/Pot.sol:37-47, src/Pot.sol:49-62
claimCut() never checks the 90-day deadline:
Critical Issues:
No deadline check - Players can claim anytime, even after closePot
Post-close claims double-spend - closePot distributes forfeited shares, then late players claim their full original reward
Players not marked settled - closePot doesn't zero playersToRewards for unclaimed players
First-come-first-served race - Once pot balance is exhausted, legitimate claimants are permanently locked out
Setup: 3 players, 1000 each, total 3000. Only Alice claims before deadline.
| Step | Operation | Pot Balance | Issues |
|---|---|---|---|
| 1 | Alice claims 1000 | 2000 | Legitimate claim |
| 2 | closePot called | 1200 | Manager: 200, Alice gets 600 |
| 3 | Bob claims 1000 | 200 | Invalid claim (should have forfeited) |
| 4 | Carol claims 1000 | Reverts | Permanently locked out of 1000 |
Double Payment: Forfeited shares paid to claimants at close, then again to late claimants
Unfair Outcomes: Payment results become order-dependent
Permanent Lock: Last legitimate claimants can never receive their rewards
No Safe Terminal State: Pot never reaches zero balance
Severity: High
Category: Loss of Funds
Affected Code: src/Pot.sol:49-62, src/ContestManager.sol:53-60
closePot() performs no state transition:
Never sets a closed flag
Never updates remainingRewards
No validation that pot hasn't been closed before
Attack Vector: The owner (or anyone able to reach closePot) can call it repeatedly. Each call:
Takes another 10% of unchanged remainingRewards
Pays out claimant cuts again
Does not decrease the pool proportionally (wrong denominator issue compounds)
Repeated calls converge to ~100% of the pot drained to ContestManager:
| # Calls | Manager Cut (cumulative) | Remaining in Pot |
|---|---|---|
| 1 | 100 | 900 |
| 2 | 190 | 810 |
| 3 | 271 | 729 |
| ... | ... | ... |
| 10 | ~651 | ~0 |
Complete Pot Drain: Manager can extract nearly all tokens
Dead-End Funds: Every drained token goes to ContestManager (no withdrawal)
Compound Issue: Combined with H-2, fees are permanently lost
Repeated Claimant Payouts: Claimants receive their (incorrectly computed) cut each call
Make closing idempotent and distribute actual balance:
Severity: Medium
Category: Permanent Lock, Panic Revert
Affected Code: src/Pot.sol:22-35, src/Pot.sol:44
createContest accepts rewards and totalRewards independently. The constructor stores remainingRewards = totalRewards with no check that sum(rewards) <= totalRewards.
claimCut() does remainingRewards -= reward, which underflows (Solidity Panic 0x11) as soon as the running total of claims exceeds the funded amount.
| Funding Error | Result |
|---|---|
| totalRewards < sum(rewards) | First claimants succeed, remaining revert |
| Underfunding by 10% | Last 10% of claimants permanently locked out |
| No recovery mechanism | Trapped rewards never recoverable |
Severity: Medium
Category: Permanent Lock
Affected Code: src/Pot.sol:57
createContest allows an empty players array. At close, claimantCut = (remainingRewards - managerCut) / i_players.length divides by 0 → Panic(0x12), reverting the entire transaction.
Complete Fund Lock: Entire pot becomes unrecoverable
All-or-Nothing Revert: Even manager's 10% cut cannot be withdrawn
No Recovery: Tokens permanently locked in pot
Severity: Medium
Category: Loss of Funds
Affected Code: src/ContestManager.sol:28-38
fundContest() transfers totalRewards from the owner to the pot every time it's called. Nothing records that a contest has already been funded.
| Scenario | Impact |
|---|---|
| Owner funds twice | Excess tokens permanently locked |
| After closePot exhausted | Re-funding sends tokens to dead pot |
| No recovery mechanism | Surplus tokens unrecoverable |
Severity: Medium
Category: Configuration Error, Silent Misbehavior
Affected Code: src/ContestManager.sol:16-26, src/Pot.sol:32-34
createContest does not validate:
players.length == rewards.length → Opaque Panic(0x32) or silent errors
Non-zero addresses → Zero address allocations
Duplicate players → Earlier allocations overwritten
sum(rewards) == totalRewards → See M-1
| Validation Missing | Result |
|---|---|
| Array length mismatch | Opaque revert or ignored tail entries |
| Duplicate addresses | Earlier rewards vanish |
| Zero addresses | Funds sent to address(0) |
| Sum mismatch | Underflow and permanent lock |
Severity: Low
Category: Minor Fund Lock
Affected Code: src/Pot.sol:57
(remainingRewards - managerCut) / claimants.length truncates. Any remainder (< claimants.length) stays in the pot with no way out.
Severity: Low
Category: Accounting Drift
Affected Code: src/Pot.sol:55, src/Pot.sol:64-66, src/ContestManager.sol:37
transfer/transferFrom return values are ignored. With non-reverting "false-returning" ERC20 tokens, transfers silently no-op while bookkeeping still decrements.
Accounting drift between internal state and actual token balances
Potential fund loss with non-standard ERC20 implementations
Severity: Low
Category: Protocol Robustness
Affected Code: src/Pot.sol, src/ContestManager.sol
Neither contract has emergency withdrawal or token recovery functions. All vulnerabilities above result in "tokens permanently locked" because no rescue mechanism exists.
9 of 10 PoC tests pass
H-2 test requires assertion correction for proper verification
All tests reproduce the described vulnerabilities
The core high-severity issues all stem from closePot()/claimCut() being stateless:
No deadline on claims → H-3: Post-close double-spend
No closed/terminal state → H-4: Re-callable drain
Wrong redistribution denominator → H-1: Permanent fund lock
Fee routed to msg.sender → H-2: Protocol revenue locked
Immediate: Implement H-1 through H-4 fixes (critical fund loss)
High Priority: Implement M-1 through M-4 (config errors and edge cases)
Standard: Implement L-1 through L-3 (operational robustness)
As written, every contest with an unclaimed player permanently locks user funds, and the protocol can never collect its fee. The fixes in [H-1]–[H-4] plus the input validation in [M-1]/[M-2]/[M-4] address the full set of vulnerabilities. No contract should be deployed without these fixes implemented.
## 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.