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.
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.
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
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.
# 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; } ```
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.