Snowman Merkle Airdrop

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

Buy fee is scaled by 1e18 in the constructor but never divided back out in `buySnow`, so one whole Snow costs at least 1e36 wei and buying is unusable

Root + Impact

s_buyFee is multiplied by PRECISION (1e18) in the constructor, and buySnow multiplies it by an amount that is already in 18-decimal base units without dividing the precision back out, so the price is scaled twice.

Description

  • Snow can be purchased with ETH or WETH. A buyer pays the buy fee for the amount of Snow they want.

  • The constructor stores s_buyFee = _buyFee * PRECISION, and buySnow charges s_buyFee * amount. Because Snow is a standard 18-decimal ERC20, one whole Snow is 1e18 base units, so amount already carries a factor of 1e18. Multiplying the two scaled values gives a cost with the 1e18 factor counted twice. Since the constructor rejects _buyFee == 0, the cheapest possible configuration is _buyFee = 1, which gives s_buyFee = 1e18 wei per base unit (1 ETH for 0.000000000000000001 Snow) and 1e36 wei for one whole Snow.

// Snow.sol, lines 65-67 and 73
if (_buyFee == 0) { revert S__ZeroValue(); } // the fee input can never be below 1
// @> the fee is scaled by 1e18 here
s_buyFee = _buyFee * PRECISION;
​
// Snow.sol, lines 79-85
function buySnow(uint256 amount) external payable canFarmSnow {
// @> amount is in 18-decimal base units, so this multiplies two 1e18-scaled numbers
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);
}
...
}

Risk

Likelihood: High

  • Every deployment is affected. The constructor blocks a zero fee, so no valid _buyFee can make the price reasonable, and any wallet or frontend that sends one whole token (1e18) triggers it.

Impact: Medium

  • Buying whole tokens is impossible: at the minimum fee, one whole Snow costs 1e36 wei (1e18 ETH), far more than all ETH in existence.

  • The only purchases that can succeed are tiny base-unit amounts at a price of at least 1 ETH per 1e-18 Snow, so the paid path of the protocol, and the fees the collector is meant to gather, is effectively unusable.

Proof of Concept

Add SnowFeePoC.t.sol to your test/ folder and run:

forge test --match-path test/SnowFeePoC.t.sol -vv
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
​
import {Test} from "forge-std/Test.sol";
import {Snow} from "../src/Snow.sol";
​
contract SnowFeePoC is Test {
Snow snow;
address weth = makeAddr("weth");
address collector = makeAddr("collector");
address buyer = makeAddr("buyer");
​
// 1 is the smallest fee the constructor accepts (0 reverts with S__ZeroValue)
uint256 constant MIN_BUY_FEE = 1;
​
function setUp() public {
snow = new Snow(weth, MIN_BUY_FEE, collector);
}
​
// Even at the minimum fee, 1 base unit (1e-18 Snow) costs a full ETH
function test_oneBaseUnitCostsOneEth() public {
assertEq(snow.s_buyFee(), 1e18);
​
vm.deal(buyer, 1 ether);
vm.prank(buyer);
snow.buySnow{value: 1 ether}(1);
​
assertEq(snow.balanceOf(buyer), 1); // 0.000000000000000001 Snow
assertEq(address(snow).balance, 1 ether); // paid 1 ETH for it
}
​
// One whole Snow (1e18 base units) would cost 1e36 wei = 1e18 ETH,
// far more than all the ETH in existence
function test_oneWholeSnowIsUnaffordable() public {
uint256 oneWholeSnow = 10 ** snow.decimals(); // 1e18 base units
uint256 requiredWei = snow.s_buyFee() * oneWholeSnow;
uint256 totalEthSupplyUpperBound = 130_000_000 ether;
​
assertEq(requiredWei, 1e36);
assertGt(requiredWei, totalEthSupplyUpperBound);
​
// A buyer sending a very large 1,000 ETH still cannot buy one whole Snow
vm.deal(buyer, 1_000 ether);
vm.prank(buyer);
vm.expectRevert();
snow.buySnow{value: 1_000 ether}(oneWholeSnow);
assertEq(snow.balanceOf(buyer), 0);
}
}

Output:

Ran 2 tests for test/SnowFeePoC.t.sol:SnowFeePoC
[PASS] test_oneBaseUnitCostsOneEth() (gas: 103125)
[PASS] test_oneWholeSnowIsUnaffordable() (gas: 40514)
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 1.46ms (522.10µs CPU time)

The first test shows a buyer paying 1 ETH for a single base unit (1e-18 Snow) at the lowest possible fee. The second shows one whole Snow requires 1e36 wei, more than the ETH supply, so even a 1,000 ETH payment reverts.

Recommended Mitigation

Divide the precision back out so the fee means "price of one whole Snow in wei", and use one cost value in both branches:

function buySnow(uint256 amount) external payable canFarmSnow {
- if (msg.value == (s_buyFee * amount)) {
+ uint256 cost = (s_buyFee * amount) / PRECISION;
+ if (msg.value == cost) {
_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);
}

If the intended design was instead to price each base unit directly, remove the * PRECISION from the constructor (s_buyFee = _buyFee;).

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!