MyCut

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

closePot() transfers tokens inside a loop — single failed transfer permanently bricks pot closure

Root + Impact

Description

  • Normal behavior: closePot() distributes remaining rewards to all claimants after the 90-day window expires.

  • The issue: _transferReward() is called inside a loop in closePot(). If any single transfer fails — due to a blacklisted address, gas stipend exhaustion, or token-level restrictions — the entire closePot() transaction reverts. The pot can never be closed, manager cut is never collected, and remaining funds are permanently locked.

```solidity
// src/Pot.sol#58-61
for (uint256 i = 0; i < claimants.length; i++) {
// @> single failure reverts entire closePot()
_transferReward(claimants[i], claimantCut);
}
// src/Pot.sol#64-66
function _transferReward(address player, uint256 reward) internal {
i_token.transfer(player, reward); // @> unchecked, reverts whole loop
}
```

Risk

Likelihood:

  • USDT and similar tokens implement address blacklisting — a blacklisted claimant permanently bricks closePot().

  • Any claimant address that becomes a contract with a reverting fallback between claim time and pot closure triggers the DoS.

Impact:

  • Manager can never collect their cut — closePot() is the only withdrawal path for manager funds.

  • All remaining rewards are permanently locked — no alternative distribution or emergency withdrawal exists.

Proof of Concept

The following test demonstrates that a single blacklisted claimant prevents closePot() from ever completing. A mock token with blacklist functionality blacklists one claimant after they claim but before pot closure. Every subsequent closePot() call reverts permanently.

```solidity
contract BlacklistToken is IERC20 {
mapping(address => uint256) public balanceOf;
mapping(address => bool) public blacklisted;
function blacklist(address addr) external { blacklisted[addr] = true; }
function transfer(address to, uint256 amount) external returns (bool) {
require(!blacklisted[to], 'blacklisted');
balanceOf[to] += amount;
balanceOf[msg.sender] -= amount;
return true;
}
function transferFrom(address from, address to, uint256 amount) external returns (bool) {
balanceOf[from] -= amount;
balanceOf[to] += amount;
return true;
}
function approve(address, uint256) external pure returns (bool) { return true; }
function allowance(address, address) external pure returns (uint256) { return type(uint256).max; }
function totalSupply() external pure returns (uint256) { return 1000e18; }
}
function testDoSBlacklistedClaimant() public {
BlacklistToken token = new BlacklistToken();
address[] memory players = new address[](2);
uint256[] memory rewards = new uint256[](2);
players[0] = alice; players[1] = bob;
rewards[0] = rewards[1] = 500e18;
Pot pot = new Pot(players, rewards, IERC20(address(token)), 1000e18);
token.balanceOf[address(pot)] = 1000e18;
vm.prank(alice); pot.claimCut();
vm.prank(bob); pot.claimCut();
// Bob gets blacklisted after claiming
token.blacklist(bob);
vm.warp(block.timestamp + 91 days);
// closePot permanently reverts
vm.expectRevert();
pot.closePot();
}
```
---

Recommended Mitigation

Replace the push-based distribution with a pull-based pattern. Instead of sending tokens to claimants in closePot(), record each claimant's entitled cut in a mapping and let them withdraw individually. This isolates individual transfer failures and ensures closePot() always completes regardless of any single claimant's token receivability.

```diff
+ mapping(address => uint256) private claimantPendingCuts;
function closePot() external onlyOwner {
if (block.timestamp - i_deployedAt < 90 days) revert Pot__StillOpenForClaim();
if (remainingRewards > 0) {
uint256 managerCut = remainingRewards / managerCutPercent;
i_token.safeTransfer(msg.sender, managerCut);
if (claimants.length > 0) {
uint256 claimantCut = (remainingRewards - managerCut) / claimants.length;
- for (uint256 i = 0; i < claimants.length; i++) {
- _transferReward(claimants[i], claimantCut);
- }
+ for (uint256 i = 0; i < claimants.length; i++) {
+ claimantPendingCuts[claimants[i]] += claimantCut;
+ }
}
}
}
+ function withdrawCut() external {
+ uint256 cut = claimantPendingCuts[msg.sender];
+ if (cut == 0) revert Pot__RewardNotFound();
+ claimantPendingCuts[msg.sender] = 0;
+ i_token.safeTransfer(msg.sender, cut);
+ }
```
Updates

Lead Judging Commences

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