[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})))
);
}