Snowman Merkle Airdrop

AI First Flight #10
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: medium
Likelihood: medium
Invalid

claimSnowman never reads s_hasClaimedSnowman, so the claim-once invariant is unenforced

Description

The airdrop is meant to be claimable once per recipient. SnowmanAirdrop keeps a mapping for this:

mapping(address => bool) private s_hasClaimedSnowman;

and writes to it inside claimSnowman:

i_snow.safeTransferFrom(receiver, address(this), amount);
s_hasClaimedSnowman[receiver] = true; // @> written, but never read as a guard
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);

However, claimSnowman never reads s_hasClaimedSnowman. The flag is only read by the getClaimStatus view getter, so it enforces nothing. The only thing that incidentally prevents a second claim is that safeTransferFrom moves the recipient's Snow into the contract, after which i_snow.balanceOf(receiver) == 0 makes the next call revert on the zero-amount check.

That incidental guard is defeatable: the claim amount is the recipient's current Snow balance, and the Merkle leaf is keccak256(keccak256(abi.encode(receiver, amount))). If the recipient re-acquires exactly their leaf amount of Snow (via buySnow), the same Merkle proof and the same EIP-712 signature validate again, and they can claim a second set of Snowman NFTs.

Risk

Likelihood: Medium — requires the recipient to re-acquire exactly the entitled amount of Snow; this is cheap and fully under the recipient's control, but it is an extra deliberate step rather than a free repeat.

Impact: Medium — a recipient can mint more reward NFTs than their single entitlement, inflating the reward supply and breaking the one-claim-per-recipient invariant; no funds are stolen from others directly.

Impact

The intended claim-once guarantee is unenforced. Any entitled recipient can repeat their claim (once per re-acquisition of the leaf amount), minting duplicate Snowman NFTs and diluting the fairness of the distribution.

Proof of Concept

Attack sequence (same receiver, Merkle proof, and signature throughout):

  1. receiver holds exactly amount Snow (their Merkle-leaf amount).

  2. receiver calls claimSnowman(receiver, proof, v, r, s) — succeeds. Their Snow is transferred to the contract, s_hasClaimedSnowman[receiver] = true, and amount Snowman NFTs are minted.

  3. receiver calls buySnow(amount) to re-acquire exactly amount Snow.

  4. receiver calls claimSnowman(receiver, proof, v, r, s) again with the identical proof and signature. Because claimSnowman never checks s_hasClaimedSnowman, and balanceOf(receiver) == amount != 0, and the leaf keccak256(keccak256(abi.encode(receiver, amount))) is unchanged, every check passes and a second batch of NFTs is minted.

The guard that was supposed to stop step 4 (s_hasClaimedSnowman[receiver]) is dead-written and does nothing.

Recommended Mitigation

Read the flag at the start of claimSnowman and revert on a repeat claim:

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 2 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!