Snowman Merkle Airdrop

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

SnowmanAirdrop::claimSnowman sets s_hasClaimedSnowman but never checks it, missing double-claim prevention

Root + Impact

Description

The SnowmanAirdrop::claimSnowman() function includes a mapping s_hasClaimedSnowman that is intended to track whether an address has already claimed their Snowman NFTs. On line 94, the function sets s_hasClaimedSnowman[receiver] = true after a successful claim. However, the function never checks this mapping before processing the claim.

The expected behavior is that the function should reject claims from addresses that have already claimed, preventing double-claiming. The standard pattern for this is to add a check at the beginning of the function:

// @> This check is MISSING from the function
if (s_hasClaimedSnowman[receiver]) {
revert AlreadyClaimed();
}

Instead, the current code only sets the flag without ever reading it:

function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external nonReentrant
{
// ... validation checks ...
// @> No check for s_hasClaimedSnowman[receiver] here!
i_snow.safeTransferFrom(receiver, address(this), amount);
// @> Flag is SET but never CHECKED
s_hasClaimedSnowman[receiver] = true;
i_snowman.mintSnowman(receiver, amount);
}

While the current implementation has an indirect protection (after claiming, the user's Snow balance becomes 0, causing the balanceOf check on line 76 to revert on subsequent calls), this is an accidental safeguard rather than an intentional design pattern. The s_hasClaimedSnowman mapping and getClaimStatus() getter function (line 137) clearly indicate that double-claim prevention was intended but incompletely implemented.

This becomes exploitable if combined with other bugs or if the protocol is modified. For example, if a user receives additional Snow tokens after claiming (through earnSnow() or buySnow()), they could potentially claim again since only the balance check would gate access, and the balance would no longer be zero.

Risk

Likelihood: Medium

  • The indirect balance-based protection currently prevents straightforward double-claims

  • However, users can acquire new Snow tokens after claiming, which would bypass the balance check

  • The Merkle proof verification adds another layer of protection, but if the user's new balance happens to match a Merkle leaf, the claim would succeed again

Impact: Medium

  • A user who acquires new Snow tokens after claiming could potentially claim additional Snowman NFTs

  • The getClaimStatus() function returns stale/incorrect data since s_hasClaimedSnowman is set but never enforced, which could mislead front-end applications and integrators relying on this flag

  • The protocol's intended security invariant (each address claims exactly once) is not properly enforced

Proof of Concept

function testDoubleClaimNotPrevented() public {
// Setup: user claims successfully
// ... (setup with merkle proof, signature, etc.)
airdrop.claimSnowman(user, merkleProof, v, r, s);
// Verify claim status is set
assertTrue(airdrop.getClaimStatus(user));
// User acquires more Snow tokens
vm.prank(user);
snow.earnSnow();
// The s_hasClaimedSnowman flag is TRUE, but the function never checks it
// If the new balance matches a Merkle leaf, user could claim again
// The only protection is the Merkle proof check, not the claim status flag
}

The core issue is defense-in-depth: the contract stores claim status data but fails to use it, creating a false sense of security. External contracts or front-ends calling getClaimStatus() would incorrectly assume that the claim status is being enforced on-chain.

Recommended Mitigation

Add a check for s_hasClaimedSnowman at the beginning of the claimSnowman() function:

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

Also add the corresponding error:

error SA__AlreadyClaimed();
Updates

Lead Judging Commences

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