Snowman Merkle Airdrop

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

Root cause: five defects across Snow, Snowman and the in-scope scripts. Impact: buyers silently lose ETH to the collector, fees can be stranded permanently, large claims are unexecutable, and contract wallets can never claim

Root cause: five defects across Snow, Snowman and the in-scope scripts. Impact: buyers silently lose ETH to the collector, fees can be stranded permanently, large claims are unexecutable, and contract wallets can never claim

Description

Five low severity issues. None lets anyone steal staked funds, but in each case value is lost or a function becomes permanently unusable for someone.

L-1, buySnow keeps ETH it does not credit. The payment path is chosen by a strict equality on msg.value, and the WETH branch never refunds.

// src/Snow.sol
function buySnow(uint256 amount) external payable canFarmSnow {
@> if (msg.value == (s_buyFee * amount)) {
_mint(msg.sender, amount);
} else {
@> i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
_mint(msg.sender, amount); // the ETH that was sent is kept, never returned
}

Being payable, it accepts ETH it does not credit, takes the full fee in WETH too, and does not revert. collectFee then sweeps that ETH to the collector.

L-2, a collector that cannot receive ETH bricks fee collection. The WETH transfer and the ETH sweep share one function and the ETH leg runs even at a zero balance, so a collector with no payable fallback cannot withdraw the WETH either, and changeCollector being onlyCollector leaves it unable to pass the role on.

L-3, the mint loop is linear. mintSnowman loops once per token, and a claim mints the claimant's whole Snow balance, which buySnow lets anyone accumulate without limit.

L-4, Helper.s.sol returns a WETH the token never uses. Helper deploys its own MockWETH at line 31 and returns it, while DeploySnow.s.sol deploys another at line 18 and passes that to Snow, so anyone building on Helper as the README directs sees purchases revert for no visible reason.

L-5, contract wallets can never claim. _isValidSignature uses raw ECDSA.tryRecover with no EIP-1271 fallback, so a Safe or ERC-4337 account has no key to recover from and is permanently excluded.

Risk

Likelihood:

  • L-1 triggers on any purchase carrying ETH that is not exactly the fee, which the contract accepts silently, and L-4 hits anyone following the README.

  • L-2 depends on the collector set at deployment, L-3 on the claimant's balance, L-5 on whether an eligible address is a contract.

Impact:

  • A buyer loses real ETH on a transaction that reports success, and it ends up with the fee collector.

  • Fees can become unwithdrawable, a large claim can exceed any block gas limit, and a contract wallet holding Snow can never exchange it for the NFT.

  • No privileged role fixes any of this: there is no refund function and the owner cannot reassign the collector.

Proof of Concept

The affected parties are buyers, the fee collector, and holders on contract wallets. There is no attacker here.

function test_H1_SwallowedEthEndsUpWithTheCollector() public {
weth.mint(buyer, 1000 ether);
vm.prank(buyer);
weth.approve(address(snow), type(uint256).max);
vm.deal(buyer, 10 ether);
// the ETH attached does not exactly equal the fee, so the WETH branch runs
vm.prank(buyer);
snow.buySnow{value: 3 ether}(1);
assertEq(weth.balanceOf(address(snow)), fee, "he paid the full fee in WETH");
assertEq(buyer.balance, 7 ether, "and lost the 3 ETH on top");
// the collector sweeps that ETH out, it never returns to the buyer
vm.prank(collector);
snow.collectFee();
assertEq(collector.balance, 3 ether, "the ETH ends up with the collector");
}
[PASS] test_H1_SwallowedEthEndsUpWithTheCollector() (gas: 290897)

The rest are covered by passing tests too. A payable-less collector made collectFee revert with 5e18 WETH held and no ETH present. The mint loop measured 3,364,091 gas for 100 tokens and 6,648,566 for 200, so roughly 33.2M for 1000. Buying through the WETH Helper.run() returns reverts, and a contract wallet holding Snow still hits SA__InvalidSignature.

Recommended Mitigation

Pick the payment path explicitly instead of inferring it, and reject empty purchases:

function buySnow(uint256 amount) external payable canFarmSnow {
if (amount == 0) revert S__ZeroValue();
uint256 cost = s_buyFee * amount;
if (msg.value > 0) {
if (msg.value != cost) revert S__ZeroValue(); // no silent overpayment
} else {
i_weth.safeTransferFrom(msg.sender, address(this), cost);
}
_mint(msg.sender, amount);
}

For L-2 split collectFee into separate WETH and ETH withdrawals, skipping the ETH leg at a zero balance. For L-3 take a bounded quantity argument. For L-4 return DeploySnow's WETH from Helper. For L-5 use OpenZeppelin's SignatureChecker, which falls back to EIP-1271.

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!