Snowman Merkle Airdrop

AI First Flight #10
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

claimSnowman builds the Merkle leaf from live balanceOf instead of a committed amount, bricking any claim after a balance change

Reading live Snow balance to build the Merkle leaf bricks any claim after a post-snapshot balance change

Description

The Merkle tree is committed at a snapshot of each claimer's Snow balance. claimSnowman should verify against that snapshotted amount, but instead it reads the live balance and rebuilds the leaf from it, so any balance change after the snapshot makes the recomputed leaf mismatch the committed root and reverts.

uint256 amount = i_snow.balanceOf(receiver); // @> live balance, not snapshot
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert SA__InvalidProof();
}

getMessageHash (line 117) also reads the live balance, so the EIP-712 digest the user signs no longer matches either.

Risk

Likelihood:

A balance change is trivial to cause: the receiver earns 1 wei via earnSnow, buys via buySnow, transfers out, or a griefer sends a 1-wei dust transfer in. Any of these permanently invalidate the proof.

Impact:

Eligible users are permanently denied their airdrop (claim DoS), and a griefer can brick any specific victim's claim by dusting them with 1 wei of Snow.

Proof of Concept

A 1-wei dust transfer to the receiver after snapshot makes claimSnowman revert with SA__InvalidProof.

function test_dustBricksClaim() public {
vm.prank(griefer);
snow.transfer(receiver, 1); // balance now != snapshot
vm.expectRevert(SnowmanAirdrop.SA__InvalidProof.selector);
airdrop.claimSnowman(receiver, proof, v, r, s);
}

Recommended Mitigation

Take amount as a calldata parameter, compute the leaf and digest from it, and only require the live balance to cover it.

- 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)
external nonReentrant {
- uint256 amount = i_snow.balanceOf(receiver);
+ if (i_snow.balanceOf(receiver) < amount) revert SA__ZeroAmount();
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 5 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!