Snowman Merkle Airdrop

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

`Snow.collectFee` ignores WETH `transfer` return value and forwards arbitrary force-pushed ETH

Root + Impact

Description

  • The contract imports SafeERC20 and uses safeTransferFrom in buySnow, but collectFee uses raw i_weth.transfer and discards the return value.

  • The function also forwards the entire contract ETH balance, which can include ETH force-pushed via selfdestruct or trapped overpay from H-03 — bypassing any intended user refund.

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

Risk

Likelihood:

  • Reason 1: When the fee token is swapped to a non-canonical WETH-like token that returns false on failure, funds silently stay in the Snow contract.

  • Reason 2: With H-03 unfixed, every overpaid user's ETH is harvested by the collector rather than refunded.

Impact:

  • Impact 1: Silent loss of WETH if a non-standard token is used.

  • Impact 2: User overpay funds are absorbed by the protocol with no refund path.

Proof of Concept

The PoC demonstrates the force-push variant, which is the simplest to reproduce. A throwaway contract SelfDestructor is funded with 1 ETH and then self-destructs, sending its balance to the Snow contract — selfdestruct bypasses any receive() / fallback() checks, so this is unstoppable. When the legitimate collector later calls collectFee, the function forwards address(this).balance (which includes the force-pushed wei) to the collector. The assert that the collector received exactly 1 ETH proves the sweep semantics: the protocol cannot distinguish between fee revenue and arbitrarily injected ETH, so any future refund logic for the H-03 overpay case has nothing left to refund from.

function test_collectFeeAbsorbsForcePushedETH() public {
(new SelfDestructor){value: 1 ether}(payable(address(snow))); // force-push 1 ETH
vm.prank(collector);
snow.collectFee();
assertEq(collector.balance, 1 ether); // collector sweeps it
}

Recommended Mitigation

The fix is twofold: switch to safeTransfer so any non-canonical WETH variant surfaces failures instead of swallowing them, and track ETH revenue in an explicit state variable so collectFee only forwards what the protocol actually accumulated through buySnow's exact-match path. The tracked variable is incremented only on the legitimate ETH-payment path, so force-pushed ETH and any H-03 overpay remain in the contract and can be refunded under a separate flow (or returned to users explicitly).

+ uint256 private s_collectedEthFees; // incremented only on the exact-ETH-match path in buySnow
+
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}("");
+ uint256 ethToSend = s_collectedEthFees;
+ s_collectedEthFees = 0;
+ (bool collected,) = payable(s_collector).call{value: ethToSend}("");
require(collected, "Fee collection failed!!!");
}
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!