Snowman Merkle Airdrop

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

Balance Drift

Root + Impact

Description

  • User cannot claimSnowman if the balance of SNOW is not EXACTLY as in the merkle tree

  • The Merkle leaves are constructed with the current balance of the user. Which means that the user needs to have that exact balance at the time of calling claimSnowman

    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();
    }
    i_snow.safeTransferFrom(receiver, address(this), amount); // send tokens to contract... akin to burning
    s_hasClaimedSnowman[receiver] = true;
    emit SnowmanClaimedSuccessfully(receiver, amount);
    i_snowman.mintSnowman(receiver, amount);
    }
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();
}
i_snow.safeTransferFrom(receiver, address(this), amount); // send tokens to contract... akin to burning
s_hasClaimedSnowman[receiver] = true;
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}
function getMessageHash(address receiver) 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})))
);
}

Risk

Likelihood: High

  • User balances can change due to claim/buy (depending on timelock expiry date difference with the merkle tree generation) or transfer/trades

Impact: Medium

  • Users will be unable to claim their airdrop unless their balance exactly matches the snapshot value.

  • Any deviation (increase or decrease) in balance will cause the Merkle proof to fail, resulting in a poor user experience and potentially many failed claims.

  • The airdrop becomes inflexible and error-prone, especially if there is a time gap between snapshot and claim period.

Proof of Concept

1: User has 10 SNOW at snapshot time
2: User receives 5 more SNOW before claiming
3: User attempts to claim, resulting in a fail because the balance is > 10
i.e. the tree leaf has mismatch between:
- keccak256(abi.encode(user, 15))
- keccak256(abi.encode(user, 10))

Recommended Mitigation

1. Specify the claim amount in the Merkle tree at deployment.
2. Accept an explicit amount parameter in the claimSnowman function.
3. Verify the Merkle proof and signature against the supplied amount, not the current balance.
// If partial claims are desired
// track the amount already claimed per user:
// mapping(address => uint256) claimedSnowman;
Updates

Lead Judging Commences

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