Description
-
The Snow contract imports SafeERC20 and declares using SafeERC20 for IERC20. The buySnow() function correctly uses safeTransferFrom for WETH payments. The collectFee() function is intended to collect all accumulated WETH and ETH fees to the collector address.
-
collectFee() calls i_weth.transfer() directly, bypassing the SafeERC20 wrapper. For ERC20 tokens that return false on failure instead of reverting (non-compliant but common in the wild), this transfer silently fails and the collector loses all accumulated WETH fees.
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
using SafeERC20 for IERC20;
function collectFee() external onlyCollector {
uint256 collection = i_weth.balanceOf(address(this));
i_weth.transfer(s_collector, collection);
(bool collected,) = payable(s_collector).call{value: address(this).balance}("");
require(collected, "Fee collection failed!!!");
}
Risk
Likelihood:
-
The protocol uses WETH which is OZ-compliant, making silent failure unlikely with canonical WETH
-
Non-standard ERC20 tokens that return false instead of reverting are common across DeFi — deploying with such a token triggers the bug
Impact:
-
All accumulated WETH fees are permanently lost — transfer() returns false but execution continues
-
The ETH portion is sent successfully, masking the WETH failure from the collector
-
No event or revert indicates the WETH transfer failed
Proof of Concept
pragma solidity ^0.8.24;
import {Test, console2} from "forge-std/Test.sol";
import {Snow} from "../src/Snow.sol";
import {MockWETH} from "../src/mock/MockWETH.sol";
contract PoC_M01 is Test {
Snow snow;
MockWETH weth;
address buyer;
address collector;
function setUp() public {
weth = new MockWETH();
collector = makeAddr("collector");
snow = new Snow(address(weth), 5, collector);
buyer = makeAddr("buyer");
}
function test_M01_UncheckedTransfer() public {
uint256 fee = snow.s_buyFee();
weth.mint(buyer, fee);
vm.startPrank(buyer);
weth.approve(address(snow), fee);
snow.buySnow(1);
vm.stopPrank();
console2.log("WETH in Snow contract:", weth.balanceOf(address(snow)));
vm.prank(collector);
snow.collectFee();
console2.log("Collector WETH balance:", weth.balanceOf(collector));
assertEq(weth.balanceOf(collector), fee);
assertEq(weth.balanceOf(address(snow)), 0);
console2.log("Works with OZ ERC20, but would silently fail with non-compliant tokens");
}
}
Output:
[PASS] test_M01_UncheckedTransfer() (gas: 214015)
Logs:
WETH in Snow contract: 5000000000000000000
Collector WETH balance: 5000000000000000000
Works with OZ ERC20, but would silently fail with non-compliant tokens
Recommended Mitigation
Replace the direct i_weth.transfer() call with i_weth.safeTransfer() from the SafeERC20 library that is already imported and declared in the contract. safeTransfer wraps the low-level call and reverts on failure or a false return value, ensuring fee collection either succeeds completely or reverts the entire transaction.
function collectFee() external onlyCollector {
uint256 collection = i_weth.balanceOf(address(this));
- i_weth.transfer(s_collector, collection);
+ i_weth.safeTransfer(s_collector, collection);
(bool collected,) = payable(s_collector).call{value: address(this).balance}("");
require(collected, "Fee collection failed!!!");
}