Snowman Merkle Airdrop

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

Claim Status Is Not Enforced, Allowing Eligible Users to Reuse Proofs and Signatures for Multiple Snowman Claims

Root + Impact

Description

  • Under normal behavior, SnowmanAirdrop::claimSnowman() should allow an eligible recipient to claim their Snowman NFT allocation only once. After a successful claim, the contract records s_hasClaimedSnowman[receiver] = true, which indicates that the recipient has already claimed.

  • The issue is that SnowmanAirdrop::claimSnowman() sets s_hasClaimedSnowman[receiver] to true, but never checks this value before processing future claims. As a result, an eligible user can claim once, reacquire the required Snow balance, and then reuse the same Merkle proof and signature to claim again.

  • This allows eligible users to mint more Snowman NFTs than their intended allocation. The bug causes unauthorized NFT inflation through the intended airdrop claim path, rather than through a direct call to the NFT contract.

## Root Cause
In `SnowmanAirdrop.sol`, the contract declares `s_hasClaimedSnowman` to track whether a receiver has already claimed a Snowman NFT allocation:
```solidity
@> mapping(address => bool) private s_hasClaimedSnowman; // mapping to verify if an address has claimed Snowman
```
However, `claimSnowman()` never checks this mapping before allowing a claim to proceed. The function validates the receiver, Snow balance, signature, and Merkle proof, then transfers Snow and sets the claim status to `true`.
```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();
}
@> // Missing check:
@> // if (s_hasClaimedSnowman[receiver]) { revert AlreadyClaimed(); }
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;
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}
```
The root cause is that `s_hasClaimedSnowman[receiver]` is written after a successful claim but is never enforced before future claims. This allows the same eligible receiver to reacquire the required Snow balance and reuse the same Merkle proof and signature to claim additional Snowman NFTs.

Risk

Likelihood:

  • This occurs whenever an eligible claimant successfully claims once, later reacquires the same Snow balance used in their Merkle leaf, and calls `claimSnowman()` again with the same Merkle proof and signature.

  • This is practical because `claimSnowman()` sets `s_hasClaimedSnowman[receiver] = true` after the first claim, but never checks that value before processing later claims.

Impact:

  • An eligible claimant can mint more Snowman NFTs than their intended allocation by repeatedly reusing the same proof and signature after reacquiring the required Snow balance.

  • Repeated claims inflate the Snowman NFT supply through the intended airdrop claim path, devaluing legitimate claims and breaking the protocol’s one-time claim accounting.

Proof of Concept

## Proof of Concept
The following test demonstrates that `SnowmanAirdrop::claimSnowman()` records Alice as having claimed, but does not enforce that recorded claim status on later calls.
Alice performs a valid first claim using her Snow balance, Merkle proof, and signature. After the first claim, `getClaimStatus(alice)` returns `true`, proving that the contract knows Alice has already claimed.
Alice then waits one week, earns Snow again, and approves the airdrop contract again. A third-party caller reuses the same Merkle proof and the same signature to call `claimSnowman()` for Alice a second time. The second claim succeeds, and Alice ends with two Snowman NFTs.
This proves that `s_hasClaimedSnowman` is set but not enforced.
Place this test in `test/TestSnowmanAirdrop.t.sol`.
Run with:
```bash
forge test --match-test testHasClaimedSnowmanIsSetButNotEnforced -vvvv
```
```solidity
function testHasClaimedSnowmanIsSetButNotEnforced() public {
// Alice is an eligible claimant in the Merkle tree.
// She approves the airdrop contract to transfer the 1 Snow required
// for her first Snowman claim.
vm.prank(alice);
snow.approve(address(airdrop), 1);
// Alice signs the airdrop claim message.
// This signature will be used for the first valid claim.
bytes32 alDigest = airdrop.getMessageHash(alice);
(uint8 alV, bytes32 alR, bytes32 alS) = vm.sign(alKey, alDigest);
// Satoshi acts as a relayer and submits Alice's valid proof and signature.
// The first claim succeeds.
vm.prank(satoshi);
airdrop.claimSnowman(alice, AL_PROOF, alV, alR, alS);
// The contract records Alice as having claimed.
assert(airdrop.getClaimStatus(alice) == true);
// Alice receives her first Snowman NFT.
assert(nft.balanceOf(alice) == 1);
// Time advances by one week so Alice can earn Snow again.
vm.warp(block.timestamp + 1 weeks);
// Alice reacquires the same Snow balance needed for the Merkle leaf
// and approves the airdrop contract again.
vm.startPrank(alice);
snow.earnSnow();
snow.approve(address(airdrop), 1);
vm.stopPrank();
// Satoshi reuses the same Merkle proof and the same signature.
// This should fail because Alice has already claimed.
// However, claimSnowman() never checks s_hasClaimedSnowman[alice],
// so the second claim succeeds.
vm.prank(satoshi);
airdrop.claimSnowman(alice, AL_PROOF, alV, alR, alS);
// Alice now has two Snowman NFTs from the same airdrop entitlement.
// This proves that the claim status is set but not enforced.
assert(nft.balanceOf(alice) == 2);
}
```

Recommended Mitigation

## Recommended Mitigation
Add an `SA__AlreadyClaimed` error, check `s_hasClaimedSnowman[receiver]` before allowing the claim to continue, and set the claimed flag before external calls.
```diff
+ 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();
}
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();
}
+ s_hasClaimedSnowman[receiver] = true;
i_snow.safeTransferFrom(receiver, address(this), amount);
- s_hasClaimedSnowman[receiver] = true;
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}
```
The added check prevents a receiver who has already claimed from calling `claimSnowman()` again. Moving `s_hasClaimedSnowman[receiver] = true` before the token transfer and NFT mint follows the checks-effects-interactions pattern and ensures the claim status is updated before external calls occur.
If repeat claims are intentionally allowed, then `s_hasClaimedSnowman` should be removed or renamed because it currently implies one-time claim protection. In that case, the protocol should instead track cumulative claimed entitlement per receiver and ensure that users cannot mint more Snowman NFTs than their intended allocation.
Updates

Lead Judging Commences

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