Snowman Merkle Airdrop

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

`Snow::collectFee` uses unchecked `transfer` instead of `safeTransfer` for WETH

Root + Impact

Description

  • The Snow contract imports SafeERC20 and applies using SafeERC20 for IERC20, and uses safeTransferFrom in buySnow.

  • However, in collectFee(), the WETH transfer uses the raw i_weth.transfer() instead of i_weth.safeTransfer(). The return value of transfer is not checked.

  • If the WETH token returns false on failure (as per ERC20 spec), the function will silently continue and proceed to send native ETH, giving the collector a false impression that all fees were collected.

// src/Snow.sol
function collectFee() external onlyCollector {
uint256 collection = i_weth.balanceOf(address(this));
i_weth.transfer(s_collector, collection); // @> unchecked return value, should use safeTransfer
(bool collected,) = payable(s_collector).call{value: address(this).balance}("");
require(collected, "Fee collection failed!!!");
}

Risk

Likelihood:

  • Standard WETH (Wrapped Ether) reverts on failure rather than returning false, so this is less likely with canonical WETH.

  • However, the contract is designed to work with any IERC20 WETH-like token, and non-standard tokens may return false.

Impact:

  • WETH fees accumulated in the contract could be permanently locked if transfer silently fails.

  • The collector believes fees are collected (no revert), but WETH remains stuck in the contract.


Proof of Concept

The Snow contract already recognizes the importance of safe ERC20 interactions — it imports SafeERC20, applies using SafeERC20 for IERC20, and uses safeTransferFrom in the buySnow function. However, in collectFee, the raw transfer is used instead of safeTransfer. According to the ERC20 standard, transfer returns a bool indicating success or failure. Tokens that return false on failure (instead of reverting) will cause a silent failure where no WETH is actually sent but execution continues.

Step-by-step scenario:

  1. Users buy Snow tokens using WETH. The WETH accumulates in the Snow contract.

  2. Users also buy Snow tokens using native ETH. The ETH accumulates in the Snow contract.

  3. The collector calls collectFee() to withdraw accumulated fees.

  4. i_weth.transfer(s_collector, collection) is called. If the WETH token returns false (e.g., due to a non-standard ERC20 implementation or a token pause), the return value is not checked.

  5. Execution continues to the ETH transfer via .call{value: ...}(""), which succeeds.

  6. The collector receives the ETH but not the WETH. The function does not revert, so the collector believes all fees were collected.

  7. The WETH remains stuck in the contract with no mechanism to retry the failed transfer.

function testUncheckedTransferSilentFailure() public {
// Deploy a mock ERC20 that returns false instead of reverting on failed transfer
MockFailingERC20 failingWeth = new MockFailingERC20();
// Snow contract holds WETH fees from buySnow calls
// Collector calls collectFee()
// failingWeth.transfer() returns false — not checked
// Execution proceeds to ETH collection — succeeds
// Collector gets ETH but not WETH
// No revert, no error — silent loss of WETH fees
// Compare with buySnow which correctly uses safeTransferFrom:
// i_weth.safeTransferFrom(...) — would revert on false return
}

Recommended Mitigation

Replace the raw i_weth.transfer() call with i_weth.safeTransfer() from OpenZeppelin's SafeERC20 library, which is already imported and applied in this contract via using SafeERC20 for IERC20. The safeTransfer wrapper checks the return value and reverts if the transfer returns false or doesn't return data, ensuring that fee collection either fully succeeds or fully reverts.

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!!!");
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 4 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!