[M-01] Claims Fail if User Balance Changes After Merkle Tree Generation
Description
The Merkle tree used to verify airdrop eligibility encodes each user's current balanceOf() during claim(). This makes the system fragile: any transfer after the tree is generated causes legitimate claims to fail, as the on-chain balance no longer matches the off-chain snapshot used to generate the Merkle proof. This breaks the integrity of the airdrop mechanism and can block valid users from claiming.
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))));
Risk
Likelihood:
Impact:
Proof of Concept
In this test case, Alice receives extra $SNOW token right after snapshot and proceeds to claim NFT but cannot because she is no longer eligible
function testClaimFailIfUserBalanceChanges() public {
uint256 amountRequired = 2;
deal(address(snow), alice, amountRequired);
assert(nft.balanceOf(alice) == 0);
vm.prank(alice);
snow.approve(address(airdrop), 1);
bytes32 digest = airdrop.getMessageHash(alice);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alKey, digest);
vm.expectRevert(SnowmanAirdrop.SA__InvalidProof.selector);
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
}
Recommended Mitigation
Snapshot user balances off-chain and use static snapshotAmount in both:
-
The Merkle leaf
-
The signature
function claimSnowman(
address receiver,
bytes32[] calldata proof,
+ uint256 snapshotAmount, // <@ inlcude ths snaphot amount
uint8 v, bytes32 r, bytes32 s
) external nonReentrant {
// Zero Address checks
// @> include previouss checks
+ require(s_hasClaimedSnowman[receiver], "Already claimed"); //<@ include this line to prevent multiple claims
- uint256 amount = i_snow.balanceOf(receiver);
+ bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, snapshotAmount))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert SA__InvalidProof();
}
s_hasClaimedSnowman[receiver] = true;
// and mint or transfer NFT here
...
}
// @> update `getMessageHash()`:
+ function getMessageHash(address receiver, uint256 snapshotAmount) 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: snapshotAmount})))
);
}