Snowman Merkle Airdrop

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

`SnowmanAirdrop::claimSnowman` has no one-time-claim enforcement (dead `s_hasClaimedSnowman`) and no signature nonce, allowing repeat claims

Description

Normal behavior: each eligible receiver should be able to convert their Snow into Snowman NFTs exactly once. The contract declares a s_hasClaimedSnowman mapping and an EIP-712 signature for exactly this purpose.

Specific issue: claimSnowman writes s_hasClaimedSnowman[receiver] = true but never reads it, so the intended one-time-claim guard is dead code. In addition, the signed EIP-712 message (getMessageHash) contains only (receiver, amount) with no nonce and no deadline, so a produced signature is replayable forever. The only thing currently preventing a second claim is the receiver's balance dropping to 0 after safeTransferFrom; because Snow can be re-earned for free (earnSnow) up to the same snapshot amount, the same signature and proof can be reused to claim again.

function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s) external nonReentrant {
// @> no check of s_hasClaimedSnowman[receiver]
...
i_snow.safeTransferFrom(receiver, address(this), amount);
@> s_hasClaimedSnowman[receiver] = true; // written, never read
i_snowman.mintSnowman(receiver, amount);
}

Risk

Likelihood:

  • The one-time guard is entirely absent; re-claim is blocked only by balance reaching 0, which is reversible via the free earnSnow.

  • The signature has no nonce/deadline, so it stays valid across the whole farming period.

Impact:

  • A receiver can mint more Snowman NFTs than their single entitlement, breaking the airdrop's one-claim invariant.

  • A third party holding an old signature can re-trigger a claim for the receiver without fresh consent.

Proof of Concept

The test performs a full claim for bob, then shows that the claim flag it just set is never consulted. Because getClaimStatus(bob) is true yet the guard is unread, once bob's balance returns to the same snapshot amount (trivially reachable through the free earnSnow), the identical proof and signature are accepted a second time. This demonstrates the one-claim invariant is unenforced and the signature is replayable.

function test_ClaimGuardIsDead() public {
// bob has snapshot balance `amount`, valid proof + signature (v,r,s)
vm.prank(bob); snow.approve(address(airdrop), type(uint256).max);
airdrop.claimSnowman(bob, proof, v, r, s);
assertTrue(airdrop.getClaimStatus(bob)); // flag set...
// ...but never enforced: after bob re-earns Snow back to `amount`,
// the SAME proof + SAME signature pass again (no nonce, no hasClaimed check):
// <re-earn bob's balance to `amount`>
vm.prank(bob); snow.approve(address(airdrop), type(uint256).max);
airdrop.claimSnowman(bob, proof, v, r, s); // second mint, guard never blocks it
}

The second claimSnowman call does not revert, confirming there is no effective one-time-claim protection.

Recommended Mitigation

Read the guard that is already being written, so a second claim by the same receiver reverts, and make the signature single-use by binding a per-receiver nonce (and optionally a deadline) into the EIP-712 typehash. Together these enforce "one claim per receiver" and prevent any signature replay.

function claimSnowman(...) external nonReentrant {
+ if (s_hasClaimedSnowman[receiver]) revert SA__AlreadyClaimed();
...
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 &#x20; **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!