Snowman Merkle Airdrop

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

`SnowmanAirdrop::claimSnowman` sets `s_hasClaimedSnowman` mapping but never reads it allowing unlimited repeated claims

Root + Impact

Description

The s_hasClaimedSnowman mapping is intended to enforce a one-claim-per-address rule — once a user claims, the flag is set to true and all further claims are blocked.

The claimSnowman function writes s_hasClaimedSnowman[receiver] = true after a successful claim but never reads or checks this value at any point before executing, making the guard completely non-functional. Any user who re-acquires Snow tokens can claim again indefinitely.

// src/SnowmanAirdrop.sol
function claimSnowman(address receiver, ...) external nonReentrant {
if (receiver == address(0)) { revert SA__ZeroAddress(); }
@> // s_hasClaimedSnowman[receiver] is NEVER checked here
if (i_snow.balanceOf(receiver) == 0) { revert SA__ZeroAmount(); }
...
@> s_hasClaimedSnowman[receiver] = true; // Written but the value was never read before this
...
i_snowman.mintSnowman(receiver, amount);
}

Risk

Likelihood:

  • Users re-acquire Snow tokens via earnSnow() after the global timer resets, or by calling buySnow() at any time during the farming period, then claim again.

  • The same Merkle proof remains valid across all claims because the tree never changes, and if the user re-acquires the same token amount the same signature is also valid

Impact:

  • Users claim Snowman NFTs an unlimited number of times beyond their single allocated entitlement.

  • The protocol's core one-claim-per-address invariant is entirely unenforceable on-chain.

Proof of Concept

The test simulates the full double-claim sequence end to end. Alice earns 1 Snow, approves the airdrop contract with an unlimited allowance, and completes a first successful claim — confirmed by assertEq(nft.balanceOf(alice), 1) and assertTrue(airdrop.getClaimStatus(alice)). The test then warps two weeks forward, which satisfies the global earn timer, and Alice calls earnSnow again to restore her balance to 1. Because there is no nonce in the signed message, getMessageHash produces an identical digest to the first claim, and the same Merkle proof remains valid for the same (alice, 1) leaf. A second claimSnowman call with a freshly signed message — identical digest to the first — succeeds. The final assertion assertEq(nft.balanceOf(alice), 2) confirms that s_hasClaimedSnowman[alice] = true had absolutely no effect on the second execution because it is never read at function entry.

To run: forge test --match-test test_DoubleClaim -vvvv

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {SnowmanAirdrop} from "src/SnowmanAirdrop.sol";
import {Snow} from "src/Snow.sol";
import {Snowman} from "src/Snowman.sol";
contract PoC_DoubleClaim is Test {
SnowmanAirdrop airdrop;
Snow snow;
Snowman nft;
address alice;
uint256 alicePrivKey;
bytes32[] aliceProof;
function setUp() public {
// deploy via helper, set alice, aliceProof from output.json
alicePrivKey = uint256(keccak256("alice"));
alice = vm.addr(alicePrivKey);
aliceProof = new bytes32[](3);
aliceProof[0] = 0xf99782cec890699d4947528f9884acaca174602bb028a66d0870534acf241c52;
aliceProof[1] = 0xbc5a8a0aad4a65155abf53bb707aa6d66b11b220ecb672f7832c05613dba82af;
aliceProof[2] = 0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1;
}
function test_DoubleClaim() public {
vm.prank(alice);
snow.approve(address(airdrop), type(uint256).max);
// ── First claim ──
bytes32 digest1 = airdrop.getMessageHash(alice);
(uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(alicePrivKey, digest1);
vm.prank(alice);
airdrop.claimSnowman(alice, aliceProof, v1, r1, s1);
assertEq(nft.balanceOf(alice), 1);
assertTrue(airdrop.getClaimStatus(alice)); // true — but NEVER checked on entry
// ── Alice re-acquires Snow ──
vm.warp(block.timestamp + 2 weeks);
vm.prank(alice);
snow.earnSnow(); // balance = 1 again
// ── Second claim — s_hasClaimedSnowman[alice] == true is never enforced ──
bytes32 digest2 = airdrop.getMessageHash(alice);
(uint8 v2, bytes32 r2, bytes32 s2) = vm.sign(alicePrivKey, digest2);
vm.prank(alice);
airdrop.claimSnowman(alice, aliceProof, v2, r2, s2); // succeeds
assertEq(nft.balanceOf(alice), 2); // Two NFTs — protocol broken
}
}

Recommended Mitigation

The fix is a single missing if check at the very top of claimSnowman — the mapping is already correctly declared and correctly written to after a claim, so only the read-and-revert guard is absent.

Add a new custom error SA__AlreadyClaimed to give callers a clear revert reason rather than a silent balance-check failure.

The check must be placed before any other logic so that previously claimed addresses are rejected at the earliest possible point, before any balance reads, signature verification, or Merkle proof computation wastes gas.

+ 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(); }
if (i_snow.balanceOf(receiver) == 0) { revert SA__ZeroAmount(); }
Updates

Lead Judging Commences

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