Summary
The claimSnowman()
function lacks a crucial validation to check whether an address has already claimed tokens, allowing users to claim multiple times.
Description
The contract uses a mapping s_hasClaimedSnowman
to track whether an address has already claimed Snowman NFTs. However, the claimSnowman()
function does not check this mapping before processing a new claim. It only updates the mapping after a successful claim.
This vulnerability allows users to claim multiple times using the same valid signature and Merkle proof. If a user has a significant Snow token balance, they could drain their entire balance across multiple transactions, claiming far more Snowman NFTs than intended by the airdrop design.
Step-by-step Analysis
A user with a valid Merkle proof and signature calls claimSnowman()
and successfully claims tokens
The contract updates s_hasClaimedSnowman[receiver] = true
after the claim
The same user calls claimSnowman()
again with the same parameters
The contract does not check if s_hasClaimedSnowman[receiver] == true
The claim is processed a second time, transferring more Snow tokens and minting more Snowman NFTs
This can be repeated until the user's Snow token balance is exhausted
Severity Classification
Impact: High - Users can claim multiple times, receiving significantly more NFTs than intended
Likelihood: High - Any user with a valid signature and proof can easily exploit this issue
File Name
src/SnowmanAirdrop.sol
Code with Issue
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);
s_hasClaimedSnowman[receiver] = true;
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}
Recommendation
Add a check at the beginning of the function to verify if the address has already claimed:
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();
}
}
You will also need to define the new error:
error SA__AlreadyClaimed();
This check ensures each eligible address can only claim once, maintaining the intended token distribution.
Proof of Concept
* @notice POC demonstrating multiple claims by the same user
* @dev Shows how missing already-claimed check allows repeated claims
*/
function test_POC_MissingAlreadyClaimedCheck() public {
console2.log("=== POC 2: Missing Already Claimed Check ===");
console2.log("Simulating scenario where signature verification would pass...");
uint256 aliceInitialBalance = snow.balanceOf(alice);
uint256 aliceInitialNFTs = snowman.balanceOf(alice);
console2.log("Alice's initial Snow tokens:", aliceInitialBalance);
console2.log("Alice's initial Snowman NFTs:", aliceInitialNFTs);
bool alreadyClaimed = airdrop.getClaimStatus(alice);
console2.log("Alice already claimed:", alreadyClaimed);
console2.log("\nBUG ANALYSIS:");
console2.log("claimSnowman() function checks:");
console2.log("[OK] Zero address check");
console2.log("[OK] Zero amount check");
console2.log("[OK] Signature validation");
console2.log("[OK] Merkle proof validation");
console2.log("[FAIL] Already claimed check - MISSING!");
console2.log("[OK] Sets claimed status (but too late)");
console2.log("\nIMPACT:");
console2.log("- User can claim multiple times with same signature/proof");
console2.log("- Will drain entire token balance across multiple transactions");
console2.log("- Bypasses intended single-claim limitation");
console2.log("- s_hasClaimedSnowman mapping becomes meaningless");
}