Snowman Merkle Airdrop

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

Snow::collectFee() uses raw unsafe transfer and sweeps the entire ETH/WETH balance, including non-fee funds

Root + Impact

Description

  • collectFee() is meant to send the collector only the WETH fees the contract has genuinely accrued from buySnow() purchases.

  • The WETH leg uses the raw IERC20.transfer instead of SafeERC20.safeTransfer, even though the contract already declares using SafeERC20 for IERC20. Separately, the ETH leg forwards the contract's entire address(this).balance, not a tracked "fees owed" amount — so any ETH sitting in the contract for other reasons (e.g. the overpaid ETH trapped by the issue in buySnow) gets swept to the collector as if it were fee revenue.

function collectFee() external onlyCollector {
uint256 collection = i_weth.balanceOf(address(this));
@> i_weth.transfer(s_collector, collection); // raw transfer, return value ignored
@> (bool collected,) = payable(s_collector).call{value: address(this).balance}(""); // sweeps ALL ETH
require(collected, "Fee collection failed!!!");
}

Risk

Likelihood:

  • Occurs on every real-world call to collectFee() once any non-fee ETH/WETH has ever landed in the contract (e.g. an overpaid buySnow call, or WETH sent to the contract by mistake) — no special attacker action is needed, only a normal collector call.

Impact:

  • A non-standard ERC20 that returns false instead of reverting on failure would let collectFee() mark itself successful (via the unchecked .transfer) while actually transferring nothing — an accounting desync between "fee collected" and "fee actually received."

  • Any ETH that reached the contract for reasons other than a completed fee (most notably the trapped overpayment from the buySnow equality bug) is swept to the collector with no way for the original payer to recover it.

Proof of Concept

The test below first reproduces the overpayment from buySnow (6 ETH trapped, per the corrected fork trace) and mints 5 extra WETH directly to the Snow contract to simulate stray tokens. It then shows a single collectFee() call sweeps both — the trapped user ETH and the unrelated stray WETH — entirely to the collector, with no distinction between real fees and incidental balance.

function test_H6_collectFee_sweeps_trapped_eth() public {
// H3 precondition: attacker overpays ETH in buySnow -> trapped
snow2.buySnow{value: overpay}(1); // overpay = 6 ether, trapped in contract
w2.mint(address(snow2), 5 ether); // simulate stray WETH sent to the token contract
vm.prank(collector2);
snow2.collectFee();
assertEq(address(snow2).balance, 0, "ALL contract ETH swept, including the trapped overpayment");
assertEq(w2.balanceOf(collector2), 10 ether, "ALL WETH swept: buy-fee 5e18 + stray 5e18");
}
Run: forge test --match-test test_H6_collectFee_sweeps_trapped_eth -vv → PASS, confirming both the trapped ETH and the unrelated stray WETH leave the contract in a single collectFee() call.

Recommended Mitigation

Use the already-imported SafeERC20 wrapper for the WETH leg so a non-standard token's failed transfer reverts instead of silently "succeeding":

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!!!");
}
This alone fixes the unchecked-return risk. The broader "sweeps non-fee ETH" issue is resolved once the buySnow overpayment bug (separate finding) is fixed so no non-fee ETH ever reaches the contract in the first place — at that point, sweeping address(this).balance is safe because it only ever holds genuine fees.
Updates

Lead Judging Commences

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