Root + Impact
Description
-
Describe the normal behavior in one or more sentences
-
The normal behavior should be: if a token transfer fails, the transaction reverts.
-
Explain the specific issue or problem in one or more sentences
-
The Pot contract uses raw transfer() and transferFrom() calls without checking return values. Many ERC20 tokens (notably USDT, BNB) return false on failure instead of reverting.
function _transferReward(address player, uint256 reward) internal {
i_token.transfer(player, reward);
}
i_token.transfer(msg.sender, managerCut);
_transferReward(player, reward);
Risk
Likelihood:
Impact:
-
Impact 1
-
Individual claim failure
-
Impact 2
Proof of Concept
contract MockUSDT {
mapping(address => uint256) public balanceOf;
mapping(address => bool) public blacklist;
function transfer(address to, uint256 amount) external returns (bool) {
if (blacklist[to]) return false;
if (balanceOf[msg.sender] < amount) return false;
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
return true;
}
}
function testUncheckedTransferSilentFailure() public {
MockUSDT usdt = new MockUSDT();
address[] memory players = new address[](1);
players[0] = alice;
uint256[] memory rewards = new uint256[](1);
rewards[0] = 1000;
pot = new Pot(players, rewards, IERC20(address(usdt)), 1000);
usdt.mint(address(pot), 1000);
usdt.blacklist(alice, true);
uint256 aliceBalanceBefore = usdt.balanceOf(alice);
vm.prank(alice);
pot.claimCut();
assertEq(usdt.balanceOf(alice), aliceBalanceBefore);
assertEq(pot.checkCut(alice), 0);
assertEq(pot.getRemainingRewards(), 0);
assertEq(usdt.balanceOf(address(pot)), 1000);
}
function testClosePortLoopSilentFailures() public {
MockUSDT usdt = new MockUSDT();
address[] memory players = new address[](3);
players[0] = alice;
players[1] = bob;
players[2] = charlie;
usdt.blacklist(bob, true);
pot.closePot();
assertGt(usdt.balanceOf(alice), 0);
assertEq(usdt.balanceOf(bob), 0);
assertGt(usdt.balanceOf(charlie), 0);
}
Recommended Mitigation
- remove this code// Pot.sol
pragma solidity ^0.8.20;
import {IERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
import {Ownable} from "lib/openzeppelin-contracts/contracts/access/Ownable.sol";
+ import {SafeERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol";
contract Pot is Ownable(msg.sender) {
+ using SafeERC20 for IERC20;
function claimCut() public {
address player = msg.sender;
uint256 reward = playersToRewards[player];
if (reward <= 0) {
revert Pot__RewardNotFound();
}
+ // Validate balance before state changes
+ require(i_token.balanceOf(address(this)) >= reward, "Insufficient balance");
playersToRewards[player] = 0;
remainingRewards -= reward;
claimants.push(player);
- _transferReward(player, reward);
+ i_token.safeTransfer(player, reward); // Will revert on failure
}
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);
+ i_token.safeTransfer(msg.sender, managerCut); // Will revert on failure
uint256 claimantCut = (remainingRewards - managerCut) / claimants.length;
for (uint256 i = 0; i < claimants.length; i++) {
- _transferReward(claimants[i], claimantCut);
+ i_token.safeTransfer(claimants[i], claimantCut); // Will revert on failure
}
}
}
- function _transferReward(address player, uint256 reward) internal {
- i_token.transfer(player, reward);
- }
}
+ add this code