Snowman Merkle Airdrop

First Flight #42
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Severity: low
Valid

The mapping `s_hasClaimedSnowman` is not used to prevent duplicate claims

The mapping s_hasClaimedSnowman is not used to prevent duplicate claims

Description

Normal Behavior
The s_hasClaimedSnowman mapping should be used to prevent a user from claiming the airdrop more than once. Before processing a claimSnowman(), it should be checked whether the user has already received their airdrop and revert if so.

Issue
Although the mapping s_hasClaimedSnowman[receiver] = true is updated after each claim, it is never checked before allowing a new call to claimSnowman().
This means the mapping has no real functional effect.
What prevents repeated claims is that the Snow token balance is zero after the first attempt, but this is an indirect consequence and not an explicit protection.

@> 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();
}
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); // send tokens to contract... akin to burning
s_hasClaimedSnowman[receiver] = true;
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}

Risk

Likelihood: LOW

Although there is no risk of loss of funds (the contract checks the balance before transferring), leaving an apparently protective but inactive mapping can lead to confusion or bad practices in the future. It is a missed opportunity to strengthen the security of the claim process.

Impact: LOW

It does not negatively impact funds or system stability, but it breaks the principle of code clarity and intent. It can give a false sense of security and make future developers assume that duplicate protection is already implemented.

Proof of Concept

function test_ClaimAirdrop() public {
address bob = helper.bob();
uint256 bobPrivateKey = helper.bobPrivateKey();
// ➤ Predefined Merkle proofs to show Bob is included in the tree
bytes32 proof1 = 0x51c4b9a3cc313d7d7325f2d5d9e782a5a484e56a38947ab7eea7297ec86ff138;
bytes32 proof2 = 0xbc5a8a0aad4a65155abf53bb707aa6d66b11b220ecb672f7832c05613dba82af;
bytes32 proof3 = 0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1;
// ➤ Grouped into an array to pass to the claim function
bytes32 memory PROOFS = new bytes32[](3);
PROOFS[0] = proof1;
PROOFS[1] = proof2;
PROOFS[2] = proof3;
// ➤ Generate the signed hash following the EIP-712 scheme
bytes32 digest = airdrop.getMessageHash(bob);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(bobPrivateKey, digest);
// ➤ Check that Bob has enough Snow tokens
uint256 bobSnowBalance = snow.balanceOf(bob);
vm.startPrank(bob);
// ➤ Bob approves the transfer of his tokens to the airdrop
snow.approve(address(airdrop), bobSnowBalance);
// ✅ First claim call: expected to work correctly
airdrop.claimSnowman(bob, PROOFS, v, r, s);
// Second claim also passes the mapping but reverts due to balance = 0
// This shows that the mapping does not protect anything
airdrop.claimSnowman(bob, PROOFS, v, r, s);
}
@> │ └─ ← [Revert] SA__ZeroAmount()

Recommended Mitigation

Add a conditional check at the beginning of the claimSnowman() function to ensure that a user cannot claim the airdrop more than once. While the contract currently sets a flag (s_hasClaimedSnowman[receiver] = true) after claiming, it does not check this flag before proceeding with another claim. Adding this guard clause will enforce idempotent airdrop logic, prevent unnecessary executions, and clarify the role of the mapping.

+ error SA__AirdropHasAlreadyClaimed();
function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
+ if (s_hasClaimedSnowman[receiver]) {
+ revert SA__AirdropHasAlreadyClaimed();
+ }
if (receiver == address(0)) {
revert SA__ZeroAddress();
}
if (i_snow.balanceOf(receiver) == 0) {
revert SA__ZeroAmount();
...
Updates

Lead Judging Commences

yeahchibyke Lead Judge 23 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Lack of claim check

The claim function of the Snowman Airdrop contract doesn't check that a recipient has already claimed a Snowman. This poses no significant risk as is as farming period must have been long concluded before snapshot, creation of merkle script, and finally claiming.

Support

FAQs

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