Snowman Merkle Airdrop

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

DoS on Fee Collection Traps Funds if Collector Rejects ETH

Summary

The collectFee function in Snow.sol uses a low-level call to transfer native ETH to the s_collector address. If the collector address is a smart contract that rejects ETH (e.g., lacks a receive() function), the transfer fails and reverts the entire transaction. This causes a Denial of Service (DoS) on fee collection, trapping all accumulated fees (both WETH and native ETH) in the contract permanently.

Description

The collectFee function is designed to allow the collector to withdraw accumulated WETH and native ETH fees. However, the native ETH transfer relies on a low-level call without checking if the recipient is a contract that can accept ETH. If the collector address is changed to a contract that rejects ETH, or if the original collector is a contract without a receive fallback, the fee collection will permanently fail.

Root Cause

File: src/Snow.sol (lines 83-90)

function collectFee() external onlyCollector {
uint256 collection = i_weth.balanceOf(address(this));
i_weth.transfer(s_collector, collection);
// ❌ Low-level call fails if s_collector is a contract that rejects ETH
(bool collected,) = payable(s_collector).call{value: address(this).balance}("");
require(collected, "Fee collection failed!!!");
}

Risk

Severity: Low
Likelihood: Low
Impact: Medium

  • ❌ Fee collection can be permanently blocked (DoS)

  • ❌ Accumulated WETH and ETH fees become trapped in the contract

  • ❌ Project loses access to its revenue

  • ✅ Requires the collector address to be a contract that rejects ETH

Proof of Concept

Scenario: The owner changes the collector to a smart contract that does not accept ETH, or the collector is inherently a contract without a receive() function.

Expected Behavior: The contract should handle ETH transfers safely or reject the collector address if it cannot receive ETH.

Actual Behavior: The collectFee transaction reverts, and all fees remain trapped in the Snow contract forever.

// SPDX-License-Identifier: MIT
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";
// A dummy contract that explicitly rejects ETH
contract RejectingCollector {
// No receive() or fallback() function
}
contract CollectFeeDoSTest is Test {
Snow public snow;
MockWETH public weth;
address initialCollector = makeAddr("collector");
uint256 FEE = 5;
RejectingCollector badCollector;
function setUp() public {
weth = new MockWETH();
snow = new Snow(address(weth), FEE, initialCollector);
badCollector = new RejectingCollector();
// Simulate some fees accumulating in the Snow contract
vm.deal(address(snow), 10 ether);
weth.mint(address(snow), 100 ether);
}
function test_CollectFeeFailsIfCollectorRejectsETH() public {
// 1. Owner changes collector to a contract that rejects ETH
vm.prank(initialCollector);
snow.changeCollector(address(badCollector));
// 2. Attempt to collect fees
vm.prank(address(badCollector));
vm.expectRevert("Fee collection failed!!!");
snow.collectFee(); // ❌ REVERTS
console2.log("VULNERABILITY: Fee collection is permanently blocked");
// 3. Verify funds are trapped
assertEq(address(snow).balance, 10 ether);
assertEq(weth.balanceOf(address(snow)), 100 ether);
console2.log("Fees trapped: 10 ETH and 100 WETH");
}
}

Test Output:

Transaction reverted: Fee collection failed!!!
VULNERABILITY: Fee collection is permanently blocked
Fees trapped: 10 ETH and 100 WETH

What This Proves:

  1. ✅ Low-level call fails if recipient rejects ETH

  2. ✅ Transaction reverts, rolling back WETH transfer too

  3. ✅ All accumulated fees become permanently trapped

Recommended Mitigation

Use OpenZeppelin's Address.sendValue which safely handles ETH transfers to contracts, or validate that the collector can receive ETH before setting it.

import {Address} from "@openzeppelin/contracts/utils/Address.sol";
function collectFee() external onlyCollector {
uint256 wethCollection = i_weth.balanceOf(address(this));
if (wethCollection > 0) {
i_weth.safeTransfer(s_collector, wethCollection);
}
uint256 ethCollection = address(this).balance;
if (ethCollection > 0) {
// ✅ Safely sends ETH, reverts with a clear error if the target rejects it
Address.sendValue(payable(s_collector), ethCollection);
}
emit FeeCollected();
}

Why This Fixes It:

  1. Address.sendValue checks if the transfer succeeded

  2. ✅ Provides a clear, standardized revert message if it fails

  3. ✅ Prevents silent failures or ambiguous reverts

Updates

Lead Judging Commences

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