Snowman Merkle Airdrop

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

`buySnow` Traps User ETH When `msg.value` Doesn't Match Fee, Causing Double Payment

Root + Impact

Description

  • The buySnow() function accepts native ETH (msg.value) as payment. When msg.value exactly equals the required fee, ETH payment succeeds. Otherwise, the else branch charges WETH via safeTransferFrom.

  • When a user sends ETH that does not exactly match the fee, the else branch executes: WETH is transferred from the user and the sent ETH remains trapped in the contract. The user pays twice — once in ETH (trapped, no refund) and once in WETH.

// src/Snow.sol, line 79-85
function buySnow(uint256 amount) external payable canFarmSnow {
if (msg.value == (s_buyFee * amount)) {
_mint(msg.sender, amount);
} else {
// @> ETH sent via msg.value is NOT refunded here
// @> WETH is ALSO taken from the user
i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
_mint(msg.sender, amount);
}
s_earnTimer = block.timestamp;
emit SnowBought(msg.sender, amount);
}

Risk

Likelihood:

  • Any user who sends ETH that is off by even 1 wei from the exact fee will trigger the else branch, resulting in double payment.

  • UI rounding errors, gas estimation quirks, or dynamic fee changes can cause legitimate users to send mismatched ETH amounts.

Impact:

  • Users permanently lose the ETH sent with the transaction — there is no refund mechanism. The trapped ETH can only be recovered via collectFee() by the fee collector, not returned to the user.

  • Users are simultaneously charged the full WETH amount, resulting in payment of up to 2x the intended fee.

Proof of Concept

The test sends ETH that is 1 wei short of the required fee. The contract traps the ETH and also charges the full WETH amount — the user pays double.

function test_SAST003_EthTrapping() public {
uint256 buyFee = snow.s_buyFee();
uint256 amount = 1;
uint256 requiredFee = buyFee * amount;
uint256 wrongEth = requiredFee - 1;
// Read correct WETH from Snow's storage slot 9
address snowWeth = address(uint160(uint256(
vm.load(address(snow), bytes32(uint256(9)))
)));
MockWETH realWeth = MockWETH(snowWeth);
vm.deal(attacker, wrongEth);
realWeth.mint(attacker, requiredFee);
vm.prank(attacker);
realWeth.approve(address(snow), requiredFee);
vm.prank(attacker);
snow.buySnow{value: wrongEth}(amount);
assertEq(attacker.balance, 0); // ETH trapped
assertEq(realWeth.balanceOf(attacker), 0); // WETH also charged
}

Recommended Mitigation

Explicitly revert when msg.value > 0 but does not match the expected fee. Separate the payment paths cleanly.

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

Lead Judging Commences

ai-first-flight-judge Lead Judge 3 days 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!