Snowman Merkle Airdrop

First Flight #42
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Impact: high
Likelihood: medium
Invalid

Claims fail if users snow balance change after merkle tree creation

[M-01] Claims Fail if User Balance Changes After Merkle Tree Generation

Description

The Merkle tree used to verify airdrop eligibility encodes each user's current balanceOf() during claim(). This makes the system fragile: any transfer after the tree is generated causes legitimate claims to fail, as the on-chain balance no longer matches the off-chain snapshot used to generate the Merkle proof. This breaks the integrity of the airdrop mechanism and can block valid users from claiming.

function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
// Zero Address checks
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); // <@ here uses dynamic balance check
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount)))); // <@ and then includes it for the creation of the leaf

Risk

Likelihood:

  • This will occur whenever a user transfers or receives tokens after the snapshot but before claiming. Since user behavior is unpredictable, this is likely across even moderate airdrop userbases.

  • Users typically do not know they must maintain the same balance to claim, so they may act normally (e.g., selling tokens) and inadvertently disqualify themselves

Impact:

  • Eligible users are unable to claim.

  • No clear feedback about why claim failed.

Proof of Concept

In this test case, Alice receives extra $SNOW token right after snapshot and proceeds to claim NFT but cannot because she is no longer eligible

// This inadvertently failed due to the fact that the merkle leaf hashes amount by dynamic balanceOf() on claim and if the amount changes claim fails
function testClaimFailIfUserBalanceChanges() public {
uint256 amountRequired = 2; // <@ Here because alice balance was incremented by 1 she becomes inelligible for airdrop
deal(address(snow), alice, amountRequired);
// Setup
assert(nft.balanceOf(alice) == 0);
vm.prank(alice);
snow.approve(address(airdrop), 1);
// Alice signs the message
bytes32 digest = airdrop.getMessageHash(alice);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alKey, digest);
// Proceeds to claim her drop (but fails)
vm.expectRevert(SnowmanAirdrop.SA__InvalidProof.selector);
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
}

Recommended Mitigation

Snapshot user balances off-chain and use static snapshotAmount in both:

  • The Merkle leaf

  • The signature

function claimSnowman(
address receiver,
bytes32[] calldata proof,
+ uint256 snapshotAmount, // <@ inlcude ths snaphot amount
uint8 v, bytes32 r, bytes32 s
) external nonReentrant {
// Zero Address checks
// @> include previouss checks
+ require(s_hasClaimedSnowman[receiver], "Already claimed"); //<@ include this line to prevent multiple claims
- uint256 amount = i_snow.balanceOf(receiver);
+ bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, snapshotAmount))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert SA__InvalidProof();
}
s_hasClaimedSnowman[receiver] = true;
// and mint or transfer NFT here
...
}
// @> update `getMessageHash()`:
+ function getMessageHash(address receiver, uint256 snapshotAmount) public view returns (bytes32) {
if (i_snow.balanceOf(receiver) == 0) {
revert SA__ZeroAmount();
}
- uint256 amount = i_snow.balanceOf(receiver);
return _hashTypedDataV4(
+ keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: snapshotAmount})))
);
}
Updates

Lead Judging Commences

yeahchibyke Lead Judge
3 months ago
yeahchibyke Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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