Snowman Merkle Airdrop

First Flight #42
Beginner FriendlyFoundrySolidityNFT
100 EXP
Submission Details
Impact: medium
Likelihood: medium

Signature verification uses dynamic balance instead of static signed amount, breaking EIP-712 logic in `claimSnowman()`

Author Revealed upon completion

Root + Impact

Description

Normally, users are expected to sign an EIP-712 message containing their address and the amount of Snow tokens they hold, then call claimSnowman() to verify their signature and claim their Snowman NFT. The contract uses the getMessageHash() function to compute the digest for signature validation using the caller's current token balance.

Normally, users are expected to sign an EIP-712 message containing their address and the amount of Snow tokens they hold, then call claimSnowman() to verify their signature and claim their Snowman NFT. The contract uses the getMessageHash() function to compute the digest for signature validation using the caller's current token balance.


function getMessageHash(address receiver) public view returns (bytes32) {
if (i_snow.balanceOf(receiver) == 0) {
revert SA__ZeroAmount();
}
uint256 amount = i_snow.balanceOf(receiver); // @> Dynamically fetched at claim time instead of being fixed at sign time
return _hashTypedDataV4(
keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: amount})))
);
}

Risk

Likelihood: Medium

  • Very likely to occur in production where user balances fluctuate due to regular transfers or protocol activity.

  • Especially problematic if users pre-sign messages and claim later via scripts, UIs, or relayers.

Impact: Medium

  • Users will be unable to claim their airdrop despite having a valid Merkle proof and valid signature at time of signing.

  • Results in failed claims, poor UX, and loss of trust in the airdrop process.


Proof of Concept

This PoC simulates a real-world failure scenario. Alice signs a message when she has 1,000 SNOW tokens. After signing, she transfers 500 tokens to another user. When she later attempts to claim using her signature, the contract recalculates her current balance (now 500) and uses it in getMessageHash(), producing a different digest than what she signed. As a result, the ECDSA verification fails and the claim is rejected

// Assume the user "Alice" has 1,000 SNOW tokens at the time of signing
address alice = 0x123...abc;
uint256 initialBalance = 1000;
// Alice signs the message off-chain using this balance
bytes32 messageHash = getMessageHash(alice);
(bytes32 r, bytes32 s, uint8 v) = sign(alicePrivateKey, messageHash);
// Later, Alice transfers 500 SNOW tokens before calling `claimSnowman`
snowToken.transfer(bob, 500);
// Now her balance is 500, but `getMessageHash()` computes the hash using this new balance
// This will result in a different message hash than the one she signed
contract.claimSnowman(alice, proof, v, r, s);
// ⛔ Reverts with SA__InvalidSignature()

Recommended Mitigation

The root cause of the issue is that getMessageHash(address) uses the user's current SNOW balance to compute the EIP-712 digest. However, signatures are typically created off-chain using static values. If a user's balance changes between signing and claiming, the digest will mismatch, causing signature verification to fail.

To fix this, make the amount part of the input to claimSnowman() and use this static value when computing the message hash. This ensures the digest used for verification matches what the user actually signed.

@@ function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s) external
- 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)
@@ inside claimSnowman
- if (i_snow.balanceOf(receiver) == 0) {
+ if (amount == 0) {
revert SA__ZeroAmount();
}
- if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) {
+ if (!_isValidSignature(receiver, getMessageHash(receiver, amount), v, r, s)) {
revert SA__InvalidSignature();
}
- uint256 amount = i_snow.balanceOf(receiver);
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
@@ function getMessageHash
- function getMessageHash(address receiver) public view returns (bytes32) {
- if (i_snow.balanceOf(receiver) == 0) {
- revert SA__ZeroAmount();
- }
- uint256 amount = i_snow.balanceOf(receiver);
+ function getMessageHash(address receiver, uint256 amount) public pure returns (bytes32) {
return _hashTypedDataV4(
keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: amount})))
);
}

Support

FAQs

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