Snowman Merkle Airdrop

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

Missing claim check in claimSnowman allows infinite double-claiming of Snowman NFTs

Description

The SnowmanAirdrop contract is meant to let each eligible address claim their Snowman NFT exactly once. It declares an s_hasClaimedSnowman mapping specifically to track whether an address has already claimed, implying a one-claim-per-address invariant is intended. However, claimSnowman writes to this mapping but never reads it as a guard. It sets s_hasClaimedSnowman[receiver] = true at the end, yet never checks it at the start to block repeat claims. The flag is written but never enforced, so the intended one-claim-per-address rule does not hold. Since the only balance gate is i_snow.balanceOf(receiver) == 0, a user who already claimed only needs a non-zero Snow balance again to re-enter, which is trivial because the protocol lets users farm free Snow weekly.

Risk

Likelihood: HIGH

A user who already claimed earns new Snow tokens via the normal weekly farming mechanism, restoring a non-zero balance so the balanceOf(receiver) == 0 check passes again.
Any eligible address can trigger this repeatedly, with no special privileges or timing required.

Impact: HIGH

A single eligible address can mint unlimited Snowman NFTs instead of exactly one.
This drains the entire NFT airdrop supply and breaks the fairness of the distribution.
Legitimate claimants may be left unable to receive their NFTs because supply is exhausted by repeat claimers.
Proof of Concept

The root cause is that the s_hasClaimedSnowman flag is set but never checked in claimSnowman:

solidity
function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
if (receiver == address(0)) revert SA__ZeroAddress();
if (i_snow.balanceOf(receiver) == 0) revert SA__ZeroAmount();
// @> No check on s_hasClaimedSnowman[receiver], so a repeat claim is not blocked

if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) revert SA__InvalidSignature();

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);
s_hasClaimedSnowman[receiver] = true; // @> flag written but never read as a guard
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);

}

Attack sequence:

An eligible receiver with a non-zero Snow balance calls claimSnowman. All checks pass, tokens are transferred, s_hasClaimedSnowman[receiver] is set to true, and NFTs are minted.
The same receiver earns new Snow tokens through weekly farming, so balanceOf(receiver) is non-zero again.
The receiver calls claimSnowman again. The balanceOf == 0 check passes, the signature and Merkle proof still validate, and because s_hasClaimedSnowman is never checked, the claim succeeds again and mints more NFTs.
Steps 2 and 3 can be repeated indefinitely, minting unlimited NFTs from one address.
Recommended Mitigation

Add a guard at the start of claimSnowman and define a matching error:

solidity error SA__AlreadyClaimed();

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(); // @> enforce one claim per address
if (i_snow.balanceOf(receiver) == 0) revert SA__ZeroAmount();

// ... rest unchanged

}

This makes the s_hasClaimedSnowman mapping actually enforce the one-claim-per-address invariant, so repeat claims revert regardless of the caller's current Snow balance.

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!