Snowman Merkle Airdrop

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

Transferable Snow Tokens Enable Inflated Snowman Minting

Root + Impact

Description

  • The Snowman NFT supply is expected to directly correspond to the amount of Snow tokens consumed during the airdrop process.

  • However, Snow is implemented as a fully transferable ERC20 token, and the airdrop contract determines the mint amount based on the receiver’s current balanceOf. This allows Snow tokens to be transferred between accounts and reused to mint Snowman NFTs multiple times.

uint256 amount = i_snow.balanceOf(receiver);
i_snow.safeTransferFrom(receiver, address(this), amount);
i_snowman.mintSnowman(receiver, amount);

Risk

Likelihood:

  • Snow transfers are unrestricted at all times

  • The airdrop logic relies on a mutable, on-chain balance

Impact:

  • Snowman NFTs can be minted in excess of the intended supply

  • The fairness and correctness of the airdrop distribution are undermined

Proof of Concept

  • Scenario

The Snowman airdrop is designed so that the number of Snowman NFTs minted by an address should exactly match the amount of Snow tokens that address is entitled to and consumes during the claim process.

However, Snow is implemented as a fully transferable ERC20 token, and the airdrop contract dynamically determines the mint amount using balanceOf(receiver). This allows the same Snow tokens to be transferred and reused by multiple addresses to mint Snowman NFTs, inflating the total NFT supply beyond the intended limits.

  • Step-by-Step Attack Flow

Address A legitimately acquires Snow tokens.

Address A claims Snowman NFTs using claimSnowman, minting NFTs equal to its Snow balance.

After the claim, Address A transfers Snow tokens to Address B.

Address B calls claimSnowman and mints additional Snowman NFTs using the same Snow tokens.

This process can be repeated across unlimited addresses, reusing the same Snow supply.

function test_TransferableSnow_InflatesSnowmanSupply() public {
// Setup contracts
Snow snow = new Snow(address(weth), buyFee, collector);
Snowman snowman = new Snowman("ipfs://snowman");
SnowmanAirdrop airdrop =
new SnowmanAirdrop(merkleRoot, address(snow), address(snowman));
// Step 1: Mint Snow to address A
address A = address(0xA);
address B = address(0xB);
snow.mint(A, 10);
vm.prank(A);
snow.approve(address(airdrop), 10);
// Step 2: A claims Snowman NFTs
vm.prank(A);
airdrop.claimSnowman(A, validProofFor10, v, r, s);
// A now owns 10 Snowman NFTs
assertEq(snowman.balanceOf(A), 10);
// Step 3: Snow is transferred to B
vm.prank(A);
snow.transfer(B, 10);
vm.prank(B);
snow.approve(address(airdrop), 10);
// Step 4: B claims Snowman NFTs using the same Snow
vm.prank(B);
airdrop.claimSnowman(B, validProofFor10, v2, r2, s2);
// B also owns 10 Snowman NFTs
assertEq(snowman.balanceOf(B), 10);
// Total NFTs minted = 20, backed by only 10 Snow tokens
}

Recommended Mitigation

  • Approach

Store each user’s eligible Snow amount off-chain.
Commit it on-chain via the Merkle tree.
Never rely on balanceOf during the claim.

- uint256 amount = i_snow.balanceOf(receiver);
+ uint256 amount = claimableAmount[receiver];
Updates

Lead Judging Commences

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