Snowman Merkle Airdrop

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

Unit Confusion in `claimSnowman()` Causes Permanent Gas DoS on All Airdrop Claims

Unit Confusion in claimSnowman() Causes Permanent Gas DoS on All Airdrop Claims

Description

  • claimSnowman() reads the caller's Snow token balance in wei (18 decimals) and passes it to Snowman.mintSnowman(), which loops that many times to mint NFTs. The Ethereum block gas limit is 30 million.

  • For any user holding 1 Snow token, this attempts 1e18 loop iterations, requiring roughly 3.3e22 gas. The function reverts for every user with a realistic balance, permanently breaking the airdrop claim mechanism.

// SnowmanAirdrop.sol, line 84
uint256 amount = i_snow.balanceOf(receiver); // @> returns wei — 1e18 for 1 Snow token
// SnowmanAirdrop.sol, line 98
i_snowman.mintSnowman(receiver, amount); // @> passes wei value directly
// Snowman.sol, lines 36-43
function mintSnowman(address receiver, uint256 amount) external {
for (uint256 i = 0; i < amount; i++) { // @> loops 1e18 times for 1 token
_safeMint(receiver, s_TokenCounter);

Risk

Likelihood:

  • 100% of eligible claimants are affected — this is an inherent logic bug, not an edge case or timing issue

  • No attacker is required; the DoS triggers on every legitimate claimSnowman() call when the receiver holds any realistic Snow balance

  • The measured DoS threshold is approximately 891 wei (~8.9e-16 Snow) — any balance above this makes claiming impossible

Impact:

  • The airdrop claim mechanism is completely non-functional — every eligible claimant who holds Snow tokens will have their transaction revert due to out-of-gas

  • The DoS cannot be worked around without redeploying the contract

  • amount is also used in the Merkle leaf hash (line 86), meaning the Merkle tree itself encodes wei values — the unit confusion is systemic

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test, console} from "forge-std/Test.sol";
import {Snowman} from "../src/Snowman.sol";
contract PocCR007_Final is Test {
Snowman snowman;
function setUp() public {
snowman = new Snowman("svg");
}
function test_Baseline_FINAL() public {
address user = makeAddr("user");
snowman.mintSnowman(user, 5);
assertEq(snowman.balanceOf(user), 5);
}
function test_VulnerabilityDemo_FINAL() public {
address user = makeAddr("user");
uint256 oneToken = 1e18;
vm.expectRevert();
snowman.mintSnowman{gas: 30_000_000}(user, oneToken);
}
function test_EconomicAnalysis_FINAL() public {
address user = makeAddr("user");
uint256 gasBefore = gasleft();
snowman.mintSnowman(user, 1000);
uint256 gasUsed = gasBefore - gasleft();
uint256 gasPerMint = gasUsed / 1000;
uint256 blockGasLimit = 30_000_000;
uint256 maxMintsPerBlock = blockGasLimit / gasPerMint;
assertTrue(maxMintsPerBlock < 1_000_000);
assertTrue(1e18 / maxMintsPerBlock > 1e12);
}
function test_Variant1_SubTokenBalance_FINAL() public {
address user = makeAddr("user");
uint256 microToken = 1e12;
vm.expectRevert();
snowman.mintSnowman{gas: 30_000_000}(user, microToken);
}
function test_Variant2_MinimumDosThreshold_FINAL() public {
address user = makeAddr("user");
uint256 gasBefore = gasleft();
snowman.mintSnowman(user, 100);
uint256 gasUsed = gasBefore - gasleft();
uint256 gasPerMint = gasUsed / 100;
uint256 blockGasLimit = 30_000_000;
uint256 maxMints = blockGasLimit / gasPerMint;
assertTrue(maxMints < 1e6);
}
function test_EdgeCase_UpstreamGasOverhead_FINAL() public {
address user = makeAddr("user");
uint256 reducedGas = 30_000_000 - 500_000;
vm.expectRevert();
snowman.mintSnowman{gas: reducedGas}(user, 1e18);
}
}

Recommended Mitigation

- uint256 amount = i_snow.balanceOf(receiver);
+ uint256 amount = i_snow.balanceOf(receiver) / 1e18;

Alternatively, use a fixed mint count per claimant (e.g., 1 NFT per claim) or include the intended mint count in the Merkle leaf data.


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!