Snowman Merkle Airdrop

AI First Flight #10
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: medium
Likelihood: medium
Invalid

`Snow::collectFee` uses unsafe `transfer` instead of `safeTransfer` for WETH, ignoring the return value

Root + Impact

Description

In Snow.sol at line 103, the collectFee function calls i_weth.transfer(s_collector, collection) instead of using the safeTransfer wrapper from the SafeERC20 library. While the contract imports and declares using SafeERC20 for IERC20, the transfer call on line 103 uses the raw ERC20 transfer function. If the WETH token were to return false instead of reverting on failure (as some non-standard ERC20 tokens do), the transfer would silently fail and the function would continue executing.

// Snow.sol, line 101-107
function collectFee() external onlyCollector {
uint256 collection = i_weth.balanceOf(address(this));
i_weth.transfer(s_collector, collection); // @audit Unchecked return value
(bool collected,) = payable(s_collector).call{value: address(this).balance}("");
require(collected, "Fee collection failed!!!");
}

Risk

Likelihood:

  • Reason 1 // Describe WHEN this will occur (avoid using "if" statements)

  • Reason 2

Impact:

If the WETH transfer fails silently, the collector would believe fees were collected when they were not. The ETH portion would still be sent successfully, masking the WETH transfer failure.

Proof of Concept

This test demonstrates the vulnerability using a mock ERC20 that returns false instead of reverting on transfer failure (simulating a non-standard token). The collector
calls collectFee, the ETH is sent successfully, but the WETH silently fails — and no revert occurs.

// Mock token that returns false instead of reverting

contract MockReturnFalseToken is ERC20 {
constructor() ERC20("BadToken", "BAD") {}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
// Override transfer to return false instead of reverting
function transfer(address, uint256) public pure override returns (bool) {
return false;
}
// transferFrom works normally so buySnow can receive tokens
function transferFrom(address from, address to, uint256 amount) public override returns (bool) {
return super.transferFrom(from, to, amount);
}
}
function test_collectFeesilentlyFailsWethTransfer() public {
// Deploy Snow with a token that returns false on transfer
MockReturnFalseToken badToken = new MockReturnFalseToken();
address testCollector = makeAddr("testCollector");
Snow snowWithBadToken = new Snow(address(badToken), 5, testCollector);
// User buys Snow with the bad token — transferFrom works, tokens go to contract
address user = makeAddr("user");
badToken.mint(user, snowWithBadToken.s_buyFee());
vm.startPrank(user);
badToken.approve(address(snowWithBadToken), snowWithBadToken.s_buyFee());
snowWithBadToken.buySnow(1);
vm.stopPrank();
// Also send some ETH to the contract via a direct buy
address ethUser = makeAddr("ethUser");
deal(ethUser, snowWithBadToken.s_buyFee());
vm.prank(ethUser);
snowWithBadToken.buySnow{value: snowWithBadToken.s_buyFee()}(1);
// Verify contract holds both WETH and ETH fees
uint256 contractWethBefore = badToken.balanceOf(address(snowWithBadToken));
assert(contractWethBefore > 0); // Contract has WETH fees
assert(address(snowWithBadToken).balance > 0); // Contract has ETH fees
// Collector calls collectFee — expects to receive both WETH and ETH
vm.prank(testCollector);
snowWithBadToken.collectFee(); // Does NOT revert!
// ETH was sent successfully to collector
assert(address(snowWithBadToken).balance == 0);
assert(testCollector.balance > 0);
// BUT: WETH transfer silently failed — tokens still in contract!
assert(badToken.balanceOf(address(snowWithBadToken)) == contractWethBefore); // WETH never left
assert(badToken.balanceOf(testCollector) == 0); // Collector got nothing
}

Recommended Mitigation

Use safeTransfer from the SafeERC20 library:

- i_weth.transfer(s_collector, collection);
+ i_weth.safeTransfer(s_collector, collection);

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 8 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!