The normal behavior should verify that token transfers succeed, as some ERC20 tokens return false on failure instead of reverting. The withdrawTokens() function should check the return value or use SafeERC20.
pragma solidity ^0.8.26;
import "forge-std/Test.sol";
import "forge-std/console.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract FalseReturningToken {
mapping(address => uint256) public balances;
bool public shouldFail;
constructor() {
balances[address(this)] = 1000 ether;
}
function setShouldFail(bool _fail) external {
shouldFail = _fail;
}
function transfer(address to, uint256 amount) external returns (bool) {
if (shouldFail) {
return false;
}
balances[msg.sender] -= amount;
balances[to] += amount;
return true;
}
function balanceOf(address account) external view returns (uint256) {
return balances[account];
}
}
contract VulnerableWithdraw {
address public owner;
constructor() {
owner = msg.sender;
}
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
function withdrawTokens_BUGGY(address token, address to, uint256 amount)
external
onlyOwner
returns (bool)
{
IERC20(token).transfer(to, amount);
return true;
}
function withdrawTokens_SAFE_V1(address token, address to, uint256 amount)
external
onlyOwner
returns (bool)
{
require(IERC20(token).transfer(to, amount), "Transfer failed");
return true;
}
function withdrawTokens_SAFE_V2(address token, address to, uint256 amount)
external
onlyOwner
returns (bool)
{
bool success = IERC20(token).transfer(to, amount);
require(success, "Transfer failed");
return true;
}
}
contract UncheckedTransferTest is Test {
FalseReturningToken token;
VulnerableWithdraw hook;
address owner = address(this);
address recipient = address(0xBEEF);
function setUp() public {
token = new FalseReturningToken();
hook = new VulnerableWithdraw();
token.transfer(address(hook), 100 ether);
}
function test_BuggyWithdraw_SilentFailure() public {
token.setShouldFail(true);
uint256 hookBalanceBefore = token.balanceOf(address(hook));
uint256 recipientBalanceBefore = token.balanceOf(recipient);
console.log("\n=== Before Withdrawal ===");
console.log("Hook balance:", hookBalanceBefore);
console.log("Recipient balance:", recipientBalanceBefore);
bool result = hook.withdrawTokens_BUGGY(address(token), recipient, 50 ether);
uint256 hookBalanceAfter = token.balanceOf(address(hook));
uint256 recipientBalanceAfter = token.balanceOf(recipient);
console.log("\n=== After Buggy Withdrawal ===");
console.log("Withdrawal returned:", result);
console.log("Hook balance:", hookBalanceAfter);
console.log("Recipient balance:", recipientBalanceAfter);
console.log("Tokens actually transferred:", recipientBalanceAfter - recipientBalanceBefore);
assertEq(result, true, "Function returns success");
assertEq(hookBalanceAfter, hookBalanceBefore, "Hook balance unchanged");
assertEq(recipientBalanceAfter, recipientBalanceBefore, "Recipient received nothing");
console.log("\n❌ BUG CONFIRMED: Function returned true but no tokens transferred!");
}
function test_SafeWithdraw_V1_CatchesFailure() public {
token.setShouldFail(true);
console.log("\n=== Testing Safe V1 (require check) ===");
vm.expectRevert("Transfer failed");
hook.withdrawTokens_SAFE_V1(address(token), recipient, 50 ether);
console.log("✅ Safe V1 correctly reverted on failed transfer");
}
function test_SafeWithdraw_V2_CatchesFailure() public {
token.setShouldFail(true);
console.log("\n=== Testing Safe V2 (return value check) ===");
vm.expectRevert("Transfer failed");
hook.withdrawTokens_SAFE_V2(address(token), recipient, 50 ether);
console.log("✅ Safe V2 correctly reverted on failed transfer");
}
function test_SafeWithdraw_SucceedsWhenTransferWorks() public {
token.setShouldFail(false);
uint256 hookBalanceBefore = token.balanceOf(address(hook));
uint256 recipientBalanceBefore = token.balanceOf(recipient);
console.log("\n=== Testing Safe Withdrawal With Working Token ===");
console.log("Hook balance before:", hookBalanceBefore);
console.log("Recipient balance before:", recipientBalanceBefore);
bool result = hook.withdrawTokens_SAFE_V1(address(token), recipient, 50 ether);
uint256 hookBalanceAfter = token.balanceOf(address(hook));
uint256 recipientBalanceAfter = token.balanceOf(recipient);
console.log("Hook balance after:", hookBalanceAfter);
console.log("Recipient balance after:", recipientBalanceAfter);
assertEq(result, true, "Function returns success");
assertEq(hookBalanceAfter, hookBalanceBefore - 50 ether, "Hook balance decreased");
assertEq(recipientBalanceAfter, recipientBalanceBefore + 50 ether, "Recipient received tokens");
console.log("✅ Safe withdrawal succeeded with working token");
}
function test_RealWorld_USDT_Scenario() public {
console.log("\n=== Real-World USDT Scenario ===");
console.log("USDT on Ethereum has no return value (returns void)");
console.log("Standard IERC20 interface expects bool return");
console.log("Calling transfer on USDT will cause issues with strict interfaces");
console.log("");
console.log("Vulnerable code: IERC20(token).transfer(to, amount);");
console.log("Problem: If token is USDT, this may fail silently or revert unexpectedly");
console.log("");
console.log("Solution: Use OpenZeppelin's SafeERC20 library");
console.log("SafeERC20.safeTransfer handles tokens with and without return values");
}
}