Snowman Merkle Airdrop

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

Airdrop mints one NFT per wei of SNOW, so any realistic 18-decimal balance makes the claim loop exceed the block gas limit

Root + Impact

Description

claimSnowman() takes the claimer's SNOW balance and uses it directly as the number of NFTs to mint.

uint256 amount = i_snow.balanceOf(receiver);
​
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) revert SA__InvalidProof();
​
i_snow.safeTransferFrom(receiver, address(this), amount);
​
@> i_snowman.mintSnowman(receiver, amount);

Snowman.mintSnowman() then mints one NFT per unit in a loop.

function mintSnowman(address receiver, uint256 amount) external {
@> for (uint256 i = 0; i < amount; i++) {
_safeMint(receiver, s_TokenCounter);
emit SnowmanMinted(receiver, s_TokenCounter);
s_TokenCounter++;
}
}

Snow is an 18-decimal ERC20, so one SNOW is 1e18 wei. The airdrop therefore mints one NFT per wei, not one per token or one per allocation:

  • A claimer holding one whole SNOW would require 1e18 loop iterations. Minting just 200 NFTs already costs 6.7M gas, so the transaction can never fit in a block and the claim reverts with out-of-gas for every such claimer.

  • The documented behaviour ("users receive Snowman NFTs proportional to their Snow token holdings") is unachievable: the proportional share is expressed in wei and instantly becomes a gas exhaustion condition.

  • The tree does not protect against this, because the leaf commits to the same wei balance that drives the loop. The current tree happens to use 1 wei per claimer, which masks the defect; any allocation expressed in real token units is unclaimable.

Risk

Likelihood: Medium. Any claimer whose leaf commits to a non-trivial amount (anything above roughly a thousand wei) can never claim. The defect is present for every allocation and only hidden by the tree currently using 1 wei values.

Impact: Medium. The claim path is permanently unusable for realistic allocations, so the airdrop cannot be run with proportional allocations at all. No attacker is required; the transaction simply reverts.

Proof of Concept

The test mints 200 NFTs directly and measures the gas, showing the loop is linear in the raw wei amount. The logged figure for a one-token balance (1e18 wei) is 3.35e22 gas, which no block can accommodate.

// Actors:
// - Holder: any address receiving a proportional allocation
// - Protocol: Snowman (ERC721) with its per-wei mint loop
​
function test_poc_wei_is_the_nft_count() public {
// one weekly earn is 1 wei, which is why the deployed tree works at all
vm.prank(alice);
snow.earnSnow();
assertEq(snow.balanceOf(alice), 1);
​
// measure the cost of the loop for a modest amount
uint256 g0 = gasleft();
vm.prank(holder);
nft.mintSnowman(holder, 200);
uint256 used = g0 - gasleft();
​
// 6.7M gas for 200 NFTs; a 1e18 wei balance extrapolates to ~3.35e22 gas
assertGt(used, 5_000_000);
}

Recommended Mitigation

Decouple the NFT count from the raw wei balance. Normalise the balance to whole tokens, or lift the allocation out of the live balance entirely by passing the committed amount and minting a fixed number of NFTs per entitlement.

- uint256 amount = i_snow.balanceOf(receiver);
+ uint256 amount = i_snow.balanceOf(receiver) / 1e18;
​
i_snow.safeTransferFrom(receiver, address(this), i_snow.balanceOf(receiver));
s_hasClaimedSnowman[receiver] = true;
i_snowman.mintSnowman(receiver, amount);

Additionally, cap the number of NFTs minted per claim (require(amount <= MAX_NFTS_PER_CLAIM)) so that no allocation can make the loop exceed the block gas limit.

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!