Root + Impact
Description
-
closePot should always be able to distribute the leftover to the manager and the claimants.
-
closePot sends tokens to every claimant in one loop. If any one transfer reverts (for example, the token issuer blacklisted a claimant's address after they claimed), the whole transaction reverts. There is no way to skip that claimant or pay out in parts, so the pot can never be closed.
for (uint256 i = 0; i < claimants.length; i++) {
_transferReward(claimants[i], claimantCut);
}
function _transferReward(address player, uint256 reward) internal {
i_token.transfer(player, reward);
}
Risk
Likelihood:
-
When the pot uses a popular token with a blacklist (USDC, USDT) and any claimant's address is blacklisted during the 90-day period.
-
When a claimant deliberately gets their own address blacklisted (e.g. by interacting with sanctioned contracts) to block the close.
Impact:
Proof of Concept
Paste this contract above contract TestMyCut (it only needs ERC20 imported from the same OpenZeppelin file):
import {ERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
contract BlacklistToken is ERC20 {
mapping(address => bool) public blacklisted;
constructor() ERC20("USDC", "USDC") {}
function mint(address a, uint256 v) external { _mint(a, v); }
function setBlacklist(address a, bool b) external { blacklisted[a] = b; }
function _update(address from, address to, uint256 v) internal override {
require(!blacklisted[from] && !blacklisted[to], "blacklisted");
super._update(from, to, v);
}
}
function testBlacklistedClaimantBlocksClosePot() public {
BlacklistToken usdc = new BlacklistToken();
usdc.mint(user, 200e18);
address[] memory ps = new address[](3);
ps[0] = player1; ps[1] = player2; ps[2] = makeAddr("player3");
uint256[] memory rs = new uint256[](3);
rs[0] = 50e18; rs[1] = 50e18; rs[2] = 100e18;
vm.startPrank(user);
usdc.approve(conMan, type(uint256).max);
address pot = ContestManager(conMan).createContest(ps, rs, IERC20(address(usdc)), 200e18);
ContestManager(conMan).fundContest(0);
vm.stopPrank();
vm.prank(player1); Pot(pot).claimCut();
vm.prank(player2); Pot(pot).claimCut();
usdc.setBlacklist(player1, true);
vm.warp(block.timestamp + 91 days);
vm.prank(user);
vm.expectRevert(bytes("blacklisted"));
ContestManager(conMan).closeContest(pot);
assertEq(usdc.balanceOf(pot), 100e18);
}
Recommended Mitigation
Record each claimant's share and let them withdraw it (pull payments), so one failed transfer only affects that claimant. Or skip failed transfers:
for (uint256 i = 0; i < claimants.length; i++) {
- _transferReward(claimants[i], claimantCut);
+ try i_token.transfer(claimants[i], claimantCut) returns (bool ok) {
+ if (!ok) unpaid[claimants[i]] += claimantCut;
+ } catch {
+ unpaid[claimants[i]] += claimantCut;
+ }
}