Snowman Merkle Airdrop

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

Snow::buySnow strands ETH and double-charges when msg.value is not exactly equal to the fee

Root + Impact

Description

  • buySnow branches on an exact equality between msg.value and the fee. When msg.value does not match exactly, it pulls the full fee in WETH but never refunds the ETH the caller sent, and never requires msg.value to be zero on the WETH path. The stranded ETH is later swept to the collector.

// src/Snow.sol (buySnow)
if (msg.value == (s_buyFee * amount)) {
_mint(msg.sender, amount);
} else {
@> i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount)); // WETH pulled
@> _mint(msg.sender, amount); // ETH sent is NOT refunded
}

Risk

Likelihood:

  • Likelihood: s_buyFee is pre-multiplied by PRECISION in the constructor, so the exact ETH amount required is large and unintuitive, making the double-charge branch the common outcome for any caller who attaches ETH while intending to pay in WETH.

    Impact: A caller who sends a non-zero msg.value that is not exactly the fee pays twice (WETH fee taken plus ETH retained) and permanently loses that ETH to the collector, with no refund.

Proof of Concept

Self-contained Foundry test (inline WETH mock). The buyer funds and approves the full WETH fee, then attaches 1 ETH while calling buySnow. Because msg.value does not equal the fee, the else branch pulls the full WETH fee and keeps the ETH, so the buyer pays twice for one token.

function testStrandedEthAndDoubleCharge() public {
uint256 amount = 1;
uint256 fee = snow.s_buyFee() * amount; // WETH fee for 1 token
// Buyer intends to pay in WETH: fund and approve.
weth.mint(buyer, fee);
vm.prank(buyer);
weth.approve(address(snow), fee);
// Buyer also attaches 1 ETH (msg.value != fee), so the else branch runs.
vm.deal(buyer, 1 ether);
vm.prank(buyer);
snow.buySnow{value: 1 ether}(amount);
// Buyer got the tokens, but paid TWICE: full WETH fee taken AND the ETH kept by the contract.
assertEq(snow.balanceOf(buyer), amount);
assertEq(weth.balanceOf(address(snow)), fee); // full WETH fee pulled
assertEq(address(snow).balance, 1 ether); // ETH stranded in the contract
assertEq(buyer.balance, 0); // buyer's 1 ETH gone, no refund
}

Result: [PASS] testStrandedEthAndDoubleCharge().

Recommended Mitigation

Make the payment path explicit. On the WETH branch, require msg.value to be zero (or refund any ETH sent), so a caller can never be charged in both ETH and WETH for the same purchase.

if (msg.value == (s_buyFee * amount)) {
_mint(msg.sender, amount);
} else {
+ if (msg.value != 0) revert S__NotAllowed(); // or refund msg.value
i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
_mint(msg.sender, amount);
}
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!