Snowman Merkle Airdrop

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

Incorrect Deployment Timing and Balance Verification

Root + Impact

Description

The SnowmanAirdrop contract uses current token balances (i_snow.balanceOf(receiver)) to verify airdrop eligibility, which violates the intended staking period requirement.

This allows users to claim NFTs before the staking period ends and makes the airdrop vulnerable to balance manipulation through token transfers.

function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s) external nonReentrant {
// ... other checks ...
// Uses current balance instead of staking end balance
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();
}
// ... minting logic ...
}

Risk

Likelihood:

  • Medium as user could have new deposit after the cutoff date.

Impact:

  • High as it may cause user not able to claim the airdrop successfully because of any new deposit.


Proof of Concept

  1. User A stakes Snow tokens

  2. Before staking period ends, User A transfers tokens to User B

  3. User B can now claim the NFT even though they didn't stake

  4. User A can also claim the NFT if they receive tokens back

  5. This allows multiple claims and violates the staking requirement


Recommended Mitigation

Add staking period end time and snapshot balances:

contract SnowmanAirdrop is EIP712, ReentrancyGuard {
uint256 public immutable i_stakingEndTime;
mapping(address => uint256) private s_stakingEndBalance;
constructor(
bytes32 _merkleRoot,
address _snow,
address _snowman,
uint256 _stakingEndTime
) EIP712("Snowman Airdrop", "1") {
i_stakingEndTime = _stakingEndTime;
// ... other initialization
}
function setStakingEndBalance(address[] calldata users, uint256[] calldata balances) external onlyOwner {
require(block.timestamp >= i_stakingEndTime, "Staking not ended");
require(users.length == balances.length, "Length mismatch");
for (uint256 i = 0; i < users.length; i++) {
s_stakingEndBalance[users[i]] = balances[i];
}
}
function claimSnowman(
address receiver,
bytes32[] calldata merkleProof,
uint8 v,
bytes32 r,
bytes32 s
) external nonReentrant {
require(block.timestamp >= i_stakingEndTime, "Staking not ended");
uint256 stakingEndBalance = s_stakingEndBalance[receiver];
require(stakingEndBalance > 0, "No staking balance");
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, stakingEndBalance))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert SA__InvalidProof();
}
// ... minting logic ...
}
}
Updates

Lead Judging Commences

yeahchibyke Lead Judge 5 months 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.