MyCut

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

External calls inside loop in closePot enables DoS

Root + Impact

Description

  • closePot iterates over claimants and makes an external ERC20 transfer call for each one. If any single transfer reverts due to a blacklisted address (USDC/USDT), a token with transfer hooks, or a contract recipient that reverts, the entire closePot call reverts.

  • Since there is no partial-close mechanism, the Pot becomes permanently uncloseable, and all remaining funds are locked.

// Pot.sol#58-60
for (uint256 i = 0; i < claimants.length; i++) {
_transferReward(claimants[i], claimantCut); // one revert blocks everything
}

Risk

Likelihood:

  • This requires a specific confluence: a claimant's address must be blacklisted on the prize token (USDC/USDT have on-chain blacklists) after they successfully called claimCut but before closePot is called.

  • Alternatively, a claimant could be a smart contract whose ERC777/transfer-hook callback reverts. While both scenarios are plausible in real deployments (especially for USDC-denominated contests), they require an external action (token-level blacklisting) outside of direct attacker control. Low likelihood, but once triggered, the impact is permanent and irreversible.

Impact:

  • A single malicious or blacklisted claimant can permanently prevent closePot from completing, locking all remaining funds in the contract.

Proof of Concept

  • The following test deploys a USDC-like token with an address blacklist. After two players claim (entering claimants[]), player1's address is blacklisted. closePot then fails permanently because the transfer to player1 reverts, trapping all remaining funds.

  • Add it to test/PocTests.t.sol alongside the BlacklistableERC20 helper contract and run forge test --match-test testM2 -vvv:

/// @dev ERC20 that reverts transfers to blacklisted addresses, mirroring USDC behaviour.
contract BlacklistableERC20 is ERC20 {
mapping(address => bool) private _blacklisted;
address private _admin;
constructor() ERC20("BLCK", "BLCK") { _admin = msg.sender; }
function mint(address to, uint256 amount) external { _mint(to, amount); }
function blacklist(address account) external {
require(msg.sender == _admin, "not admin");
_blacklisted[account] = true;
}
function transfer(address to, uint256 amount) public override returns (bool) {
require(!_blacklisted[to], "recipient blacklisted");
return super.transfer(to, amount);
}
function transferFrom(address from, address to, uint256 amount) public override returns (bool) {
require(!_blacklisted[to], "recipient blacklisted");
return super.transferFrom(from, to, amount);
}
}
function testM2_BlacklistedClaimantDosClosePot() public {
BlacklistableERC20 blToken = new BlacklistableERC20();
blToken.mint(owner, TOTAL_REWARDS);
address[] memory players = new address[](3);
players[0] = player1;
players[1] = player2;
players[2] = player3;
uint256[] memory rewards = new uint256[](3);
rewards[0] = 300 ether;
rewards[1] = 300 ether;
rewards[2] = 400 ether;
vm.startPrank(owner);
blToken.approve(address(conMan), TOTAL_REWARDS);
address contestAddr = conMan.createContest(
players, rewards, IERC20(address(blToken)), TOTAL_REWARDS
);
conMan.fundContest(0);
vm.stopPrank();
// player1 and player2 claim their individual cuts — they enter claimants[]
vm.prank(player1);
Pot(contestAddr).claimCut();
vm.prank(player2);
Pot(contestAddr).claimCut();
// player3 does not claim → remainingRewards = 400 ether at window close
// Simulate: player1's address is blacklisted on the token after they already claimed
blToken.blacklist(player1);
vm.warp(block.timestamp + 91 days);
// closePot tries to send player3's 400 ether bonus to each claimant.
// When it reaches player1 (blacklisted) the transfer reverts, rolling back the entire tx.
vm.prank(owner);
vm.expectRevert("recipient blacklisted");
conMan.closeContest(contestAddr); // permanently bricked
// All remaining rewards (400 ether) are locked forever — closePot can never complete
assertEq(blToken.balanceOf(contestAddr), 400 ether, "400 ether locked forever");
console.log("--- M2 Results ---");
console.log("Pot balance after failed close (ether):", blToken.balanceOf(contestAddr) / 1 ether);
console.log("closePot is now permanently bricked — all remaining funds are locked.");
}

Recommended Mitigation

Replace the push-loop with a pull-payment pattern: record pending amounts in a mapping during closePot and let each recipient withdraw individually. This isolates any single transfer failure and prevents full DoS.

+ mapping(address => uint256) public pendingWithdrawals;
function closePot() external onlyOwner {
if (block.timestamp - i_deployedAt < 90 days) revert Pot__StillOpenForClaim();
if (remainingRewards > 0) {
uint256 managerCut = remainingRewards / managerCutPercent;
- i_token.transfer(msg.sender, managerCut);
+ pendingWithdrawals[msg.sender] += managerCut;
if (claimants.length > 0) {
- uint256 claimantCut = (remainingRewards - managerCut) / i_players.length;
- for (uint256 i = 0; i < claimants.length; i++) {
- _transferReward(claimants[i], claimantCut);
- }
+ uint256 claimantCut = (remainingRewards - managerCut) / claimants.length;
+ for (uint256 i = 0; i < claimants.length; i++) {
+ pendingWithdrawals[claimants[i]] += claimantCut;
+ }
} else {
+ pendingWithdrawals[msg.sender] += remainingRewards - managerCut;
}
+ remainingRewards = 0;
}
}
+ function withdraw() external {
+ uint256 amount = pendingWithdrawals[msg.sender];
+ if (amount == 0) revert Pot__RewardNotFound();
+ pendingWithdrawals[msg.sender] = 0;
+ i_token.safeTransfer(msg.sender, amount);
+ }
Updates

Lead Judging Commences

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