Snowman Merkle Airdrop

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

Merkle Proof Verification Uses Current Token Balance Instead of Snapshot Amount, Breaking Airdrop Eligibility

Description

The SnowmanAirdrop::claimSnowman() function contains a critical flaw in how it reconstructs the Merkle tree leaf during verification. It uses the user’s live Snow token balance at the time of claiming instead of a fixed amount that was originally committed in the Merkle tree:

function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s) external nonReentrant {
@> uint256 amount = i_snow.balanceOf(receiver);
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));

This introduces a major inconsistency. The Merkle tree was generated off-chain using a static snapshot of eligible addresses and their corresponding amounts. However, if a user’s balance has changed between that snapshot and the actual claim, even by 1 token, the computed leaf will not match the original Merkle proof, and the claim will fail.

Impact

  • Eligible users can be unfairly denied their airdrop claim due to minor balance changes (e.g. additional purchases, transfers, or earnings).

  • Delegated claim systems become unreliable, as users cannot be guaranteed to maintain the exact balance until a relayer submits the claim.

  • This breaks the determinism of Merkle-based verification and can severely degrade user experience and trust.

Proof of Concept

  1. Merkle tree was generated assigning each eligible user an amount = 1.

  2. Alice has 5 snow tokens during claiming period

  3. Alice calls the claimSnowman() function and it fails due to SA__InvalidProof.

Proof of Code

function testClaimingOfAirdropDeniedDueToSnowBalanceGreaterThanOne() public {
assert(nft.balanceOf(alice) == 0);
vm.startPrank(alice);
uint256 FEE = snow.s_buyFee();
snow.buySnow{value: FEE * 10}(10); // alice buys 10 snow tokens
snow.approve(address(airdrop), type(uint256).max);
bytes32 alDigest = airdrop.getMessageHash(alice);
(uint8 alV, bytes32 alR, bytes32 alS) = vm.sign(alKey, alDigest);
// Reverts because alice can only claim one snow NFt but the proof use dynamic token balance instead.
vm.expectRevert(SnowmanAirdrop.SA__InvalidProof.selector);
airdrop.claimSnowman(alice, AL_PROOF, alV, alR, alS);
}
Ran 1 test for test/TestSnowmanAirdrop.t.sol:TestSnowmanAirdrop
[PASS] testClaimingOfAirdropDeniedDueToSnowBalanceGreaterThanOne() (gas: 104546)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 8.49ms (714.92µs CPU time)
│ ├─ [573] Snow::balanceOf(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6]) [staticcall]
│ │ └─ ← [Return] 11
│ └─ ← [Revert] SA__InvalidProof()
└─ ← [Return]

Recommended Mitigation

Make the Amount Explicit in the Signature

Instead of dynamically computing the amount, we explicitly pass the amount to the getMessageHash function. This ensures that the signed message reflects the correct, fixed claim amount.

- function getMessageHash(address receiver) public view returns (bytes32) {
+ function getMessageHash(address receiver, uint256 amount) 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: amount})))
);
}

Update the Claim Function to Accept Amount

We also need to update the claimSnowman function to accept the amount parameter and pass it through to getMessageHash. This removes reliance on dynamic state within the verification logic.

- function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
+ function claimSnowman(address receiver, uint256 amount, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s) {
// rest of the logic...
- if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) {
+ if (!_isValidSignature(receiver, getMessageHash(receiver, amount), v, r, s)) {
revert SA__InvalidSignature();
}
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert SA__InvalidProof();
}

Alternative Solution: Enable Partial Claims

For a more flexible and user-friendly design, we can support partial claims by introducing a mapping that tracks how much each user has already claimed. This prevents users from claiming more than they’re entitled to while allowing them to claim in multiple transactions.

Add Tracking for Claimed Amounts

mapping(address => uint256) public s_claimedAmount;
  • Users can claim multiple times up to their maximum entitled amount.

  • The system validates that claimed + new amount ≤ entitlement.

Updates

Lead Judging Commences

yeahchibyke Lead Judge about 1 month ago
Submission Judgement Published
Validated
Assigned finding tags:

Invalid merkle-proof as a result of snow balance change before claim action

Claims use snow balance of receiver to compute the merkle leaf, making proofs invalid if the user’s balance changes (e.g., via transfers). Attackers can manipulate balances or frontrun claims to match eligible amounts, disrupting the airdrop.

Support

FAQs

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