Snowman Merkle Airdrop

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

buySnow charges the caller twice when msg.value is not exact: the ETH is retained and the full WETH price is pulled as well

Root + Impact

Description

buySnow() decides how the caller pays by comparing msg.value to the expected price, and silently falls back to WETH when the comparison fails.

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);
}
​
s_earnTimer = block.timestamp;
​
emit SnowBought(msg.sender, amount);
}

The else branch runs whenever msg.value is not exactly the required amount, including when the caller did send ETH but sent slightly too little. In that case the caller is charged twice:

  • The ETH they sent stays in the Snow contract, where collectFee() later forwards the whole balance to the collector.

  • The full WETH price is pulled on top of it.

There is no refund and no revert, so the caller cannot tell from the return value that anything went wrong. A user paying with ETH who miscomputes the price by one wei loses both the ETH and the WETH.

Risk

Likelihood: Medium. The branch triggers on any inexact msg.value, which is the normal outcome of a UI computing the price from a stale or rounded quote. No attacker is needed.

Impact: Medium. Direct, silent loss of the caller's ETH. The amount lost is the full purchase price, and the WETH is charged as well, so the user pays twice for one purchase and loses the ETH to the collector.

Proof of Concept

The test funds an account with both ETH and WETH, sends one wei less than the required ETH price, and shows that the ETH is retained by the contract while the full WETH amount is also transferred.

// Actors:
// - Buyer: an account holding ETH and WETH, approving the Snow contract
// - Protocol: Snow (ERC20) with its dual payment branch
​
function test_poc_buy_snow_double_charge() public {
weth.mint(attacker, 100 ether);
vm.deal(attacker, 100 ether);
​
uint256 fee = snow.s_buyFee(); // 5e18 in the default deployment
uint256 amount = 1;
uint256 required = fee * amount; // 5 ETH
​
vm.startPrank(attacker);
weth.approve(address(snow), type(uint256).max);
uint256 ethBefore = attacker.balance;
​
// pay one wei short: the ETH branch fails, the WETH branch still executes
snow.buySnow{value: required - 1}(amount);
vm.stopPrank();
​
assertEq(attacker.balance, ethBefore - (required - 1)); // ETH kept by the contract
assertEq(weth.balanceOf(address(snow)), required); // WETH pulled as well
assertEq(snow.balanceOf(attacker), 1);
}

Recommended Mitigation

Make the two payment paths explicit. Revert when a caller sends ETH that does not exactly cover the price, and only use the WETH path when no ETH was sent.

function buySnow(uint256 amount) external payable canFarmSnow {
- if (msg.value == (s_buyFee * amount)) {
+ uint256 cost = s_buyFee * amount;
+ if (msg.value > 0) {
+ if (msg.value != cost) revert S__WrongValue();
_mint(msg.sender, amount);
} else {
- i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
+ i_weth.safeTransferFrom(msg.sender, address(this), cost);
_mint(msg.sender, amount);
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 20 minutes 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!