Snowman Merkle Airdrop

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

Balance-Based Claim Breaks Merkle Validation

Root + Impact

Description

  • In a typical Merkle-based airdrop, off-chain data (such as token balances at a snapshot in time) is used to construct a Merkle tree. This tree is later verified on-chain using the user's claimed data and a Merkle proof.

  • In this contract, the claimSnowman() function derives the user's token amount on-chain using i_snow.balanceOf(receiver). This approach can introduce a mismatch between the on-chain calculation and the off-chain snapshot used to generate the Merkle root.

// Root cause in the codebase with @> marks to highlight the relevant section
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();
}
...
}

Risk

Likelihood:

  • his issue will occur when a user's current token balance differs from their balance at the Merkle snapshot (e.g., they transferred tokens in or out after the snapshot).

  • Users who are eligible based on the snapshot but later modify their balance (even temporarily) will fail both Merkle and EIP-712 signature checks.

Impact:

  • Eligible users may be permanently unable to claim their airdrop allocation.

  • Airdrop campaign may lose trust or fail to meet its distribution goals due to false negatives in verification.

Proof of Concept

Scenario:
The user was eligible to claim with a balance of 1000 tokens at the time of the snapshot. After the snapshot:

The user transfers tokens and their balance becomes 1500.

The contract calls balanceOf(user) during claimSnowman(), which returns 1500.

The on-chain code generates a new leaf:

bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(user, 1500))));

This new leaf does not match the one used to build the original Merkle tree (based on 1000).

As a result, MerkleProof.verify(...) fails and the claim is rejected, even though the user was legitimately included in the snapshot.

// Off-chain snapshot included:
address user = 0x123...abc;
uint256 snapshotBalance = 1000;
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(user, snapshotBalance))));
// Merkle tree built using `snapshotBalance`
// Later, on-chain:
uint256 currentBalance = 1500;
uint256 amount = i_snow.balanceOf(user); // returns 1500
// Contract calculates:
// leaf = keccak256(bytes.concat(keccak256(abi.encode(user, 1500))))
// which != Merkle tree leaf for 1000, so proof verification fails

Recommended Mitigation

These changes ensure that the amount used for Merkle and signature validation matches the original off-chain data used to construct the Merkle root.

- uint256 amount = i_snow.balanceOf(receiver);
+ require(i_snow.balanceOf(receiver) >= amount, "Insufficient balance");
- 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)
- if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) {
+ if (!_isValidSignature(receiver, getMessageHash(receiver, amount), v, r, s)) {
- function getMessageHash(address receiver) public view returns (bytes32) {
- uint256 amount = i_snow.balanceOf(receiver);
+ function getMessageHash(address receiver, uint256 amount) public pure returns (bytes32) {
Updates

Lead Judging Commences

yeahchibyke Lead Judge
3 months ago
yeahchibyke Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

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