Snowman Merkle Airdrop

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

Missing hasClaimed check in SnowmanAirdrop.claimSnowman allows unlimited NFT re-claims by any Merkle-tree address that restores its balance

Description

SnowmanAirdrop.claimSnowman sets s_hasClaimedSnowman[receiver] = true at the end of the flow, but this value is never read anywhere in the function. There is no guard checking if the receiver has already claimed. As long as the receiver can replenish their Snow balance to match their Merkle leaf amount, they can replay the entire claim flow indefinitely.

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 `if (s_hasClaimedSnowman[receiver]) revert ...` guard
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; // @> write but never read — guard is missing
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}

Replenishing the balance is trivial: Snow.earnSnow() mints 1 wei per week and Snow.buySnow() is open to anyone before FARMING_DURATION expires.

Risk

Any address in the Merkle tree can mint Snowman NFTs repeatedly. The intended 1-NFT-per-claimer cap is fully defeated. Combined with the missing access control on mintSnowman, the impact is total — but even with that issue fixed, this bug alone breaks supply for all tree-listed addresses.

Proof of Concept

function test_DoubleClaim_HasClaimedNeverChecked() public {
// Alice has 1 wei Snow from helper setup. Tree leaf = (alice, 1).
assertEq(snow.balanceOf(alice), 1);
// First legit claim
vm.prank(alice);
snow.approve(address(airdrop), 1);
bytes32 d1 = airdrop.getMessageHash(alice);
(uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(alKey, d1);
airdrop.claimSnowman(alice, AL_PROOF, v1, r1, s1);
assertEq(nft.balanceOf(alice), 1);
assertTrue(airdrop.getClaimStatus(alice)); // flag is set but unused
// Re-earn 1 wei, re-claim — no guard
vm.warp(block.timestamp + 1 weeks + 1);
vm.prank(alice);
snow.earnSnow();
vm.prank(alice);
snow.approve(address(airdrop), 1);
bytes32 d2 = airdrop.getMessageHash(alice);
(uint8 v2, bytes32 r2, bytes32 s2) = vm.sign(alKey, d2);
airdrop.claimSnowman(alice, AL_PROOF, v2, r2, s2);
assertEq(nft.balanceOf(alice), 2); // BUG: second mint went through
}

Result: [PASS] — alice mints unbounded Snowman NFTs by repeating earnSnow → approve → claim.

Recommended Mitigation

Add the missing guard at the top of claimSnowman:

+ 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();
...
}

This makes s_hasClaimedSnowman actually enforce the one-claim-per-receiver invariant it was clearly intended to provide.

Updates

Lead Judging Commences

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