MyCut

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

No Atomicity Between `createContest` and `fundContest` Allows State Wipe on Unfunded

[H-02] — No Atomicity Between createContest and fundContest Allows State Wipe on Unfunded Pot

Severity: High
File: src/ContestManager.sol + src/Pot.sol
Functions: createContest(), fundContest(), claimCut()
Lines: ContestManager.sol:L14–L34 · Pot.sol:L38–L47

src/ContestManager.sol
└── function createContest() L14deploys Pot with 0 balance
└── function fundContest() L25funds Pot in a separate TX (gap window)
src/Pot.sol
└── function claimCut()
L40: playersToRewards[player] = 0; ← state wiped BEFORE transfer
L41: remainingRewards -= reward; ← decremented BEFORE transfer
L42: claimants.push(player);
L43: _transferReward(player, reward); ← reverts on strict ERC20, silent on non-standard

Summary

createContest and fundContest are two separate transactions. Between them, the Pot is live with 0 balance. On non-standard ERC20 tokens (returning false instead of reverting), a player can call claimCut() on the empty Pot — their reward is wiped to 0 and remainingRewards is decremented, but they receive nothing. After the Pot is later funded, that player can never claim.


Vulnerability Details

// src/ContestManager.sol — createContest() — Line 14
Pot pot = new Pot(players, rewards, token, totalRewards); // Pot deployed with 0 balance
// src/ContestManager.sol — fundContest() — Line 25 (separate TX)
token.transferFrom(msg.sender, address(pot), totalRewards);
// src/Pot.sol — claimCut() — Lines 40–43 (callable between the two TXs above)
playersToRewards[player] = 0; // state wiped before transfer ← VULNERABLE
remainingRewards -= reward;
_transferReward(player, reward); // silent fail on non-standard ERC20

PoC

function testH02_ClaimOnUnfundedPotWipesState() public {
// Deploy SilentFailERC20 (returns false instead of reverting)
SilentFailERC20 silentToken = new SilentFailERC20();
vm.startPrank(user);
ContestManager cm = new ContestManager();
// Create but do NOT fund
address contest = cm.createContest(players, rewards, IERC20(address(silentToken)), 1000);
vm.stopPrank();
// player1 claims on empty pot — silent transfer, state wiped
vm.prank(player1);
Pot(contest).claimCut();
assertEq(Pot(contest).checkCut(player1), 0); // reward wiped
assertEq(silentToken.balanceOf(player1), 0); // no tokens received
vm.prank(user);
cm.fundContest(0); // pot now funded
// player1 can never claim — permanently locked out
vm.expectRevert(Pot.Pot__RewardNotFound.selector);
vm.prank(player1);
Pot(contest).claimCut();
}

Impact

  • Players permanently lose their claim on non-standard ERC20 tokens.

  • Severity: High — irreversible loss of player funds.


Tools Used

  • Manual analysis

  • ERC20 edge case analysis


Recommendations

Merge creation and funding into a single atomic call:

// src/ContestManager.sol — createContest() — Line 14
function createContest(...) public onlyOwner returns (address) {
+ if (token.balanceOf(msg.sender) < totalRewards) revert ContestManager__InsufficientFunds();
Pot pot = new Pot(players, rewards, token, totalRewards);
+ token.transferFrom(msg.sender, address(pot), totalRewards);
contests.push(address(pot));
contestToTotalRewards[address(pot)] = totalRewards;
return address(pot);
}
Updates

Lead Judging Commences

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