Snowman Merkle Airdrop

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

[H-02] Decimals scaling not applied in `Snow` mint paths — protocol economically non-functional

[H-02] Decimals scaling not applied in Snow mint paths — protocol economically non-functional

Description

Snow inherits OpenZeppelin's ERC20 with the default decimals() = 18. To mint "1 whole Snow" the code must call _mint(account, 1 * 10**decimals()) = _mint(account, 1e18). The contract does NOT apply this scaling in either of its mint paths:

// src/Snow.sol:73 — fee IS scaled to wei units
s_buyFee = _buyFee * PRECISION; // PRECISION = 1e18; if _buyFee=1 → s_buyFee = 1 ETH
// src/Snow.sol:79-90 — but mint amount is NOT scaled
function buySnow(uint256 amount) external payable canFarmSnow {
if (msg.value == (s_buyFee * amount)) {
_mint(msg.sender, amount); // ← mints 'amount' wei, NOT 'amount' tokens
} else {
i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
_mint(msg.sender, amount);
}
...
}
// src/Snow.sol:96 — earnSnow hardcodes 1 (= 1 wei)
_mint(msg.sender, 1);

The fundamental mismatch: s_buyFee is in wei units (scaled), but _mint's value argument receives the unscaled amount. With _buyFee = 1 at deployment, paying 1 ETH yields 1 wei of Snow. To buy 1 whole Snow (1e18 raw units), a user would have to pay s_buyFee * 1e18 = 10^36 wei = 10^18 ETH — economically impossible.

Downstream, SnowmanAirdrop.claimSnowman reads Snow.balanceOf(receiver) to determine NFT amount. With balances stuck at single-digit wei, claimers receive 0–1 NFTs each, and the airdrop is non-functional for the intended user base.

Risk

  • Likelihood: High — the bug fires on every call to buySnow or earnSnow; no special preconditions required.

  • Impact: High — the protocol's free-earn pathway is useless (1 wei per week), the paid pathway is unaffordable (10^18 ETH per Snow), and the entire downstream airdrop is non-functional because Snow balances never reach usable amounts.

  • Risk = Likelihood × Impact = High

Impact

The "earn for free once a week" feature mints 0.000000000000000001 S per call — economically zero. After 100 weeks of earning, a user holds 100 wei = 0.0000000000000001 S. The "buy Snow with ETH/WETH" feature charges 1 ETH per 1 wei of Snow — anyone who pays receives a balance that, displayed in standard 18-decimal wallet UI, rounds to zero. Because claimSnowman derives NFT count from Snow.balanceOf, no eligible user can receive a meaningful number of Snowman NFTs through legitimate means. The protocol cannot operate as documented.

Proof of Concept

Full PoC at .audit/poc/PoC_H-02.t.sol. The key test function:

function test_BuySnow_PrecisionMismatch() public {
address buyer = makeAddr("buyer");
vm.deal(buyer, 10 ether);
// s_buyFee = _buyFee * PRECISION = 1 * 1e18 = 1 ether
assertEq(snow.s_buyFee(), 1 ether, "s_buyFee should be 1 ether (1 * PRECISION)");
// ACT: buyer pays exactly 1 ETH for amount=1
vm.prank(buyer);
snow.buySnow{value: 1 ether}(1);
// BUG: received 1 wei, not 1e18 (= 1 whole Snow token)
assertEq(snow.balanceOf(buyer), 1, "BUG: received exactly 1 wei of Snow, not 1e18");
uint256 intendedAmount = 1 * (10 ** snow.decimals()); // 1e18
assertGt(intendedAmount, snow.balanceOf(buyer), "buyer should have received 1e18 but got 1 wei");
}

Test passes inside hardened audit container (forge exit 0). Buyer pays 1 ETH and receives exactly 1 wei of Snow — a 10^18-fold shortfall vs. the intended whole-token mint.

Recommended Mitigation

Apply decimals scaling at mint time in both paths — keep the amount parameter user-friendly (whole tokens) and multiply by PRECISION when minting:

function buySnow(uint256 amount) external payable canFarmSnow {
if (msg.value == (s_buyFee * amount)) {
_mint(msg.sender, amount * PRECISION); // ← mint 'amount' WHOLE tokens
} else {
i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
_mint(msg.sender, amount * PRECISION);
}
s_earnTimer = block.timestamp;
emit SnowBought(msg.sender, amount);
}
function earnSnow() external canFarmSnow {
if (s_earnTimer != 0 && block.timestamp < (s_earnTimer + 1 weeks)) revert S__Timer();
_mint(msg.sender, 1 * PRECISION); // ← mint 1 whole Snow per week
s_earnTimer = block.timestamp;
}

After this fix, buySnow(1) {value: 1 ether} mints 1 whole Snow (1e18 raw units), and earnSnow() mints 1 whole Snow per week — restoring the documented economic model.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 6 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!