Snowman Merkle Airdrop

AI First Flight #10
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Severity: low
Valid

Missing claim check in claimSnowman lets recipients mint unlimited NFTs by topping up their Snow balance

Description

The SnowmanAirdrop contract is meant to allow each eligible recipient to claim their Snowman NFTs exactly once, enforced through the s_hasClaimedSnowman mapping.

The problem is that claimSnowman writes s_hasClaimedSnowman[receiver] = true at the end of the function but never reads it at the start. The mapping is dead code: nothing prevents a recipient from claiming again after re-acquiring Snow tokens.

Solidity
function claimSnowman(...) external nonReentrant {
if (receiver == address(0)) revert SA__ZeroAddress();
// @> no check: if (s_hasClaimedSnowman[receiver]) revert
if (i_snow.balanceOf(receiver) == 0) revert SA__ZeroAmount();
...
s_hasClaimedSnowman[receiver] = true; // @> written but never read
i_snowman.mintSnowman(receiver, amount);
}

Risk

  • A recipient claims their NFTs, then earns or buys 1 more Snow token to restore their balance to the airdropped amount.

  • The same Merkle proof and EIP-712 signature remain valid because both are bound to amount == balanceOf(receiver), and there is no nonce or replay protection.

Impact:

  • A recipient can mint an unbounded number of Snowman NFTs from a single airdrop allocation.

  • NFT supply is inflated arbitrarily, destroying the fairness and value of the distribution for all other recipients.

Proof of Concept

This Foundry test demonstrates the double-claim. It runs against the project's own Helper setup (Alice starts with 1 Snow token). Step 1 — Alice claims legitimately: she approves and `satoshi` submits her signed claim. After this, Alice holds 1 Snowman NFT and her Snow balance is 0. Step 2 — Alice tops up: after waiting 1 week (the earnSnow cooldown), she calls earnSnow() and her balance returns to exactly 1 — the same amount as her airdrop allocation. Step 3 — Alice claims AGAIN with the identical Merkle proof and a fresh signature over the same amount. Because `s_hasClaimedSnowman` is never checked, the claim succeeds and a second NFT is minted. The test asserts Alice ends with 2 NFTs. Critically, getClaimStatus(alice) returns true after the first claim, proving the contract recorded the claim but never enforced it.

This test passes, showing Alice ends with 2 NFTs from one allocation. The contract reports getClaimStatus(alice) == true yet still allows the second claim:
function test_PoC_DoubleClaim() public {
vm.prank(alice); snow.approve(address(airdrop), 1);
bytes32 d1 = airdrop.getMessageHash(alice);
(uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(alKey, d1);
vm.prank(satoshi); airdrop.claimSnowman(alice, AL_PROOF, v1, r1, s1);
assertEq(nft.balanceOf(alice), 1);
vm.warp(block.timestamp + 1 weeks);
vm.prank(alice); snow.earnSnow(); // balance back to 1
vm.prank(alice); snow.approve(address(airdrop), 1);
bytes32 d2 = airdrop.getMessageHash(alice);
(uint8 v2, bytes32 r2, bytes32 s2) = vm.sign(alKey, d2);
vm.prank(satoshi); airdrop.claimSnowman(alice, AL_PROOF, v2, r2, s2);
assertEq(nft.balanceOf(alice), 2); // double claim confirmed
}

Recommended Mitigation

  • Add the matching error declaration: error SA__AlreadyClaimed();

    This adds a check at the start of the function (Checks-Effects-Interactions), so a recipient who has already claimed is rejected before any minting, regardless of their current Snow balance.

function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
if (receiver == address(0)) revert SA__ZeroAddress();
+ if (s_hasClaimedSnowman[receiver]) revert SA__AlreadyClaimed();
if (i_snow.balanceOf(receiver) == 0) revert SA__ZeroAmount();
// ... rest of function unchanged
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 3 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[L-01] Missing Claim Status Check Allows Multiple Claims in SnowmanAirdrop.sol::claimSnowman

# Root + Impact   **Root:** The [`claimSnowman`](https://github.com/CodeHawks-Contests/2025-06-snowman-merkle-airdrop/blob/b63f391444e69240f176a14a577c78cb85e4cf71/src/SnowmanAirdrop.sol#L44) function updates `s_hasClaimedSnowman[receiver] = true` but never checks if the user has already claimed before processing the claim, allowing users to claim multiple times if they acquire more Snow tokens. **Impact:** Users can bypass the intended one-time airdrop limit by claiming, acquiring more Snow tokens, and claiming again, breaking the airdrop distribution model and allowing unlimited NFT minting for eligible users. ## Description * **Normal Behavior:** Airdrop mechanisms should enforce one claim per eligible user to ensure fair distribution and prevent abuse of the reward system. * **Specific Issue:** The function sets the claim status to true after processing but never validates if `s_hasClaimedSnowman[receiver]` is already true at the beginning, allowing users to claim multiple times as long as they have Snow tokens and valid proofs. ## Risk **Likelihood**: Medium * Users need to acquire additional Snow tokens between claims, which requires time and effort * Users must maintain their merkle proof validity across multiple claims * Attack requires understanding of the missing validation check **Impact**: High * **Airdrop Abuse**: Users can claim far more NFTs than intended by the distribution mechanism * **Unfair Distribution**: Some users receive multiple rewards while others may receive none * **Economic Manipulation**: Breaks the intended scarcity and distribution model of the NFT collection ## Proof of Concept Add the following test to TestSnowMan.t.sol  ```Solidity function testMultipleClaimsAllowed() public { // Alice claims her first NFT vm.prank(alice); snow.approve(address(airdrop), 1); bytes32 aliceDigest = airdrop.getMessageHash(alice); (uint8 v, bytes32 r, bytes32 s) = vm.sign(alKey, aliceDigest); vm.prank(alice); airdrop.claimSnowman(alice, AL_PROOF, v, r, s); assert(nft.balanceOf(alice) == 1); assert(airdrop.getClaimStatus(alice) == true); // Alice acquires more Snow tokens (wait for timer and earn again) vm.warp(block.timestamp + 1 weeks); vm.prank(alice); snow.earnSnow(); // Alice can claim AGAIN with new Snow tokens! vm.prank(alice); snow.approve(address(airdrop), 1); bytes32 aliceDigest2 = airdrop.getMessageHash(alice); (uint8 v2, bytes32 r2, bytes32 s2) = vm.sign(alKey, aliceDigest2); vm.prank(alice); airdrop.claimSnowman(alice, AL_PROOF, v2, r2, s2); // Second claim succeeds! assert(nft.balanceOf(alice) == 2); // Alice now has 2 NFTs } ``` ## Recommended Mitigation **Add a claim status check at the beginning of the function** to prevent users from claiming multiple times. ```diff // Add new error + error SA__AlreadyClaimed(); function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s) external nonReentrant { + if (s_hasClaimedSnowman[receiver]) { + revert SA__AlreadyClaimed(); + } + if (receiver == address(0)) { revert SA__ZeroAddress(); } // Rest of function logic... s_hasClaimedSnowman[receiver] = true; } ```

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!