Snowman Merkle Airdrop

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

claimSnowman() reads live balance twice — TOCTOU race allows balance manipulation between check and use

Root + Impact

Description

  • Normal behavior: claimSnowman() checks that the receiver has a non-zero Snow balance before proceeding, then uses that balance to determine the NFT mint amount.

  • The issue: The function checks i_snow.balanceOf(receiver) == 0 at line 76, then re-reads i_snow.balanceOf(receiver) at line 84 for the transfer amount and Merkle leaf computation. Between these two reads the receiver's balance can change. The Merkle tree encodes a committed balance at snapshot time — if the live balance differs, the computed leaf won't match the proof, permanently locking out legitimate users.

```solidity
// src/SnowmanAirdrop.sol#76-92
// @> Read 1: balance checked here
if (i_snow.balanceOf(receiver) == 0) revert SA__ZeroAmount();
// @> Read 2: balance re-read — can differ from Read 1
uint256 amount = i_snow.balanceOf(receiver);
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
// @> Merkle proof was generated for snapshot amount, not current amount
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) revert SA__InvalidProof();
```

Risk

Likelihood:

  • A receiver whose Snow balance changes between Merkle snapshot generation and claim time is permanently locked out with no recovery path.

  • Front-running bots monitor the mempool and transfer Snow tokens out before the claim transaction executes.

Impact:

  • Legitimate users are permanently blocked from claiming with no admin override.

  • An attacker manipulates their own claim amount by transferring tokens between the two balance reads in the same block.

Proof of Concept

The following test demonstrates that a receiver whose Snow balance changes between Merkle tree generation and claim time is permanently locked out. The Merkle tree encodes the balance at snapshot time, but claimSnowman() recomputes the leaf using the live balance. Any discrepancy causes SA__InvalidProof to revert permanently.

```solidity
function testTOCTOUBalanceManipulation() public {
assertEq(snow.balanceOf(receiver), 100);
vm.prank(receiver);
snow.transfer(otherAddress, 50);
assertEq(snow.balanceOf(receiver), 50);
// Proof generated for amount=100, live balance=50 — mismatch
vm.prank(receiver);
vm.expectRevert(SnowmanAirdrop.SA__InvalidProof.selector);
airdrop.claimSnowman(receiver, merkleProof, v, r, s);
}
```

Recommended Mitigation

The fix passes amount as an explicit parameter instead of reading the live balance. The Merkle proof verifies the receiver is entitled to exactly that amount, the live balance check is removed since the Merkle proof provides the source of truth. This eliminates both the TOCTOU race and the balance manipulation vector.

```diff
- 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 {
- if (i_snow.balanceOf(receiver) == 0) revert SA__ZeroAmount();
- uint256 amount = i_snow.balanceOf(receiver);
+ if (amount == 0) revert SA__ZeroAmount();
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) revert SA__InvalidProof();
```
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 7 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!