MyCut

AI First Flight #8
Beginner FriendlyFoundry
EXP
View results
Submission Details
Impact: medium
Likelihood: medium
Invalid

fundContest has no funded-guard, so the owner can (and a retried transaction will) fund the same pot multiple times, permanently locking every excess deposit inside the Pot

Root + Impact

Description

  • Normally, a contest is created once and funded once with exactly totalRewards; players then claim against that balance.

  • The balance check token.balanceOf(msg.sender) < totalRewards guards the payer's wallet, not the pot's funding state. Calling fundContest twice (a retried script, a double click, or any repeated keeper invocation) deposits 2x totalRewards. Since claims pay out at most sum(rewards) and close only distributes remainingRewards (computed from totalRewards, not from the actual balance), the excess has no exit.

// src/ContestManager.sol
function fundContest(uint256 index) public onlyOwner {
Pot pot = Pot(contests[index]);
IERC20 token = pot.getToken();
uint256 totalRewards = contestToTotalRewards[address(pot)];
​
if (token.balanceOf(msg.sender) < totalRewards) { // payer balance, not pot funding state
revert ContestManager__InsufficientFunds();
}
@> token.transferFrom(msg.sender, address(pot), totalRewards); // repeatable: no funded flag
}

Risk

Likelihood:

  • Any retried/duplicated funding transaction (keeper scripts, wallet retries, manual double-entry) silently succeeds — nothing in the flow rejects a second call.

  • The surplus also acts as fuel for the post-close claim (V5B) and repeated-close extraction (V4), widening those attack windows.

Impact:

  • Verified: total 4, funded twice -> pot holds 8; players claim 3+1=4; the leftover 4 is permanently locked — a 50% funding loss.

  • The over-balance desynchronizes the pot from its ledger, enabling the successful post-close claim demonstrated in V5B.

Proof of Concept

PoC explanation: The test simulates a retried/duplicated funding transaction. Setup: the admin creates a pot with rewards [3, 1] and totalRewards = 4, then calls fundContest(0) TWICE — the second call sails through because the only guard checks the payer's wallet balance, not the pot's funding state. Observation: the pot now holds 8 WETH = 2 × totalRewards. Step: both players claim their full 3 + 1 = 4; at close, remainingRewards is already 0 so closeContest is a no-op. Final assertion: 4 WETH remain in the pot with no exit path — a 50% funding loss from a single duplicated call. This over-balance is also exactly the fuel that turns V5's post-close claim from a revert into a successful payout (test_POC5B). Run with: forge test --match-test test_POC6_DoubleFunding_LocksExcess -vv (PASS; asserts pot balance 8 then residual 4).

// forge test --match-test test_POC6_DoubleFunding_LocksExcess -> PASS
function test_POC6_DoubleFunding_LocksExcess() public {
address[] memory players = new address[](2); players[0] = p1; players[1] = p2;
uint256[] memory rewards = new uint256[](2); rewards[0] = 3; rewards[1] = 1;
vm.startPrank(admin);
address pot = conMan.createContest(players, rewards, IERC20(address(weth)), 4);
conMan.fundContest(0);
conMan.fundContest(0); // second funding succeeds
vm.stopPrank();
​
assertEq(weth.balanceOf(pot), 8, "Pot holds 2x totalRewards");
​
vm.prank(p1); Pot(pot).claimCut();
vm.prank(p2); Pot(pot).claimCut(); // 3+1=4 fully claimed
​
vm.warp(91 days);
vm.prank(admin);
conMan.closeContest(pot); // remainingRewards == 0, no-op
​
assertEq(weth.balanceOf(pot), 4, "4 tokens permanently locked");
}

Recommended Mitigation

// src/ContestManager.sol
+ mapping(address pot => bool s_funded) private s_funded;
function fundContest(uint256 index) public onlyOwner {
Pot pot = Pot(contests[index]);
...
+ if (s_funded[address(pot)]) revert ContestManager__AlreadyFunded();
token.transferFrom(msg.sender, address(pot), totalRewards);
+ s_funded[address(pot)] = true;
}

The stronger variant is funding atomically inside createContest (single transferFrom), which additionally eliminates the "created but never funded" claim-DoS state.

⚠ Combination note: fixing V6 does NOT eliminate V4 — the repeated-close PoC (V4) runs on a single normal funding. Fixing V6 only removes the fuel for the V5B post-close-claim success path.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 33 minutes ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!