The following test demonstrates the vulnerability with non-standard ERC20 tokens. When a token returns false instead of reverting on failed transfer, the withdrawTokens function continues execution and emits a success event, even though no tokens were actually transferred.
pragma solidity ^0.8.26;
import {Test, console} from "forge-std/Test.sol";
import {ReFiSwapRebateHook} from "../src/RebateFiHook.sol";
import {Deployers} from "@uniswap/v4-core/test/utils/Deployers.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract NonStandardToken is IERC20 {
mapping(address => uint256) public balances;
function mint(address to, uint256 amount) external {
balances[to] += amount;
}
function transfer(address to, uint256 amount) external returns (bool) {
if (balances[msg.sender] < amount) {
return false;
}
balances[msg.sender] -= amount;
balances[to] += amount;
return true;
}
function balanceOf(address account) external view returns (uint256) {
return balances[account];
}
function totalSupply() external pure returns (uint256) { return 0; }
function allowance(address, address) external pure returns (uint256) { return 0; }
function approve(address, uint256) external pure returns (bool) { return true; }
function transferFrom(address, address, uint256) external pure returns (bool) { return true; }
}
contract UnsafeTransferPoCTest is Test, Deployers {
ReFiSwapRebateHook public hook;
NonStandardToken public badToken;
address owner;
address recipient = address(0xBEEF);
event TokensWithdrawn(address indexed token, address indexed to, uint256 amount);
function setUp() public {
deployFreshManagerAndRouters();
hook = new ReFiSwapRebateHook(manager, address(0x1234));
owner = hook.owner();
badToken = new NonStandardToken();
badToken.mint(address(hook), 50 ether);
}
function test_PoC_SilentFailureWithNonStandardToken() public {
uint256 hookBalanceBefore = badToken.balanceOf(address(hook));
uint256 recipientBalanceBefore = badToken.balanceOf(recipient);
console.log("=== Before Withdrawal ===");
console.log("Hook balance:", hookBalanceBefore / 1e18, "tokens");
console.log("Recipient balance:", recipientBalanceBefore / 1e18, "tokens");
uint256 withdrawAmount = 100 ether;
vm.prank(owner);
vm.expectEmit(true, true, false, true);
emit TokensWithdrawn(recipient, address(badToken), withdrawAmount);
hook.withdrawTokens(address(badToken), recipient, withdrawAmount);
uint256 hookBalanceAfter = badToken.balanceOf(address(hook));
uint256 recipientBalanceAfter = badToken.balanceOf(recipient);
console.log("");
console.log("=== After 'Withdrawal' ===");
console.log("Hook balance:", hookBalanceAfter / 1e18, "tokens");
console.log("Recipient balance:", recipientBalanceAfter / 1e18, "tokens");
console.log("");
console.log("BUG: Event emitted but NO tokens moved!");
console.log("Owner thinks withdrawal succeeded, but it silently failed.");
assertEq(hookBalanceAfter, hookBalanceBefore, "Hook balance unchanged");
assertEq(recipientBalanceAfter, recipientBalanceBefore, "Recipient got nothing");
}
function test_PoC_USDTLikeBehavior() public {
console.log("=== USDT-like Token Behavior ===");
console.log("1. Hook has 50 tokens");
console.log("2. Owner calls withdrawTokens(badToken, recipient, 100)");
console.log("3. badToken.transfer() returns false (insufficient balance)");
console.log("4. withdrawTokens() ignores return value");
console.log("5. TokensWithdrawn event emitted");
console.log("6. Owner sees 'success' but recipient has 0 tokens");
console.log("");
console.log("With SafeERC20.safeTransfer(), this would revert.");
}
}