[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() L14 — deploys Pot with 0 balance
└── function fundContest() L25 — funds 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
Pot pot = new Pot(players, rewards, token, totalRewards);
token.transferFrom(msg.sender, address(pot), totalRewards);
playersToRewards[player] = 0;
remainingRewards -= reward;
_transferReward(player, reward);
PoC
function testH02_ClaimOnUnfundedPotWipesState() public {
SilentFailERC20 silentToken = new SilentFailERC20();
vm.startPrank(user);
ContestManager cm = new ContestManager();
address contest = cm.createContest(players, rewards, IERC20(address(silentToken)), 1000);
vm.stopPrank();
vm.prank(player1);
Pot(contest).claimCut();
assertEq(Pot(contest).checkCut(player1), 0);
assertEq(silentToken.balanceOf(player1), 0);
vm.prank(user);
cm.fundContest(0);
vm.expectRevert(Pot.Pot__RewardNotFound.selector);
vm.prank(player1);
Pot(contest).claimCut();
}
Impact
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);
}