Root + Impact
Description
The `claimSnowman()` function uses a strict equality check `if (i_snow.balanceOf(receiver) == 0)` to validate that the `receiver` has `Snow` tokens. This creates a vulnerability where users who have spent or transferred even a small portion of their Snow tokens after the Merkle tree snapshot was created will be unable to claim their Snowman NFT, even though they legitimately should be able to claim based on their balance at snapshot time.
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();
}
uint256 amount = i_snow.balanceOf(receiver);
i_snow.safeTransferFrom(receiver, address(this), amount);
}
Risk
Likelihood:
Impact:
- Denial of service: Users who have spent any Snow tokens after snapshot cannot claim their rightful Snowman NFTs
- Unfair exclusion: Legitimate users lose access to rewards they earned
- Race condition: Users must avoid spending tokens between snapshot and claim, creating artificial constraints
- Logic inconsistency: The function tries to transfer the current balance but validates against a snapshot-based Merkle proof
Proof of Concept
Run the test ` forge test --match-test testStrictEqualityPreventsValidClaim -vvv` in `TestSnowmanAirdrop.t.sol`
This is the expected test output:
```ngnix
forge test --match-test testStrictEqualityPreventsValidClaim -vvv
[⠒] Compiling...
No files changed, compilation skipped
Ran 1 test for test/TestSnowmanAirdrop.t.sol:TestSnowmanAirdrop
[PASS] testStrictEqualityPreventsValidClaim() (gas: 386778)
Logs:
Alice blocked from claiming due to spending 1 token
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 37.68ms (5.36ms CPU time)
Ran 1 test suite in 48.19ms (37.68ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
```
function testStrictEqualityPreventsValidClaim() public {
uint256 snapshotBalance = 1000 ether;
deal(address(snow),alice, snapshotBalance);
vm.prank(alice);
snow.transfer(bob, 1 ether);
assertEq(snow.balanceOf(alice), 999 ether);
bytes32 messageHash = airdrop.getMessageHash(alice);
(uint8 v , bytes32 r, bytes32 s) = vm.sign(alKey,messageHash);
vm.prank(alice);
vm.expectRevert();
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
console2.log("Alice blocked from claiming due to spending 1 token");
}
Recommended Mitigation
1. Store snapshot balances: Instead of using current balances, store the snapshot balances that the Merkle tree was built with
2. Use minimum balance check: Change to a minimum balance requirement:
// Include snapshot amount as a parameter
function claimSnowman(address receiver, bytes32
+ uint256 snapshotAmount
[] calldata merkleProof,
uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
if (receiver == address(0)) {
revert SA__ZeroAddress();
}
//p use of strict equality
- if (i_snow.balanceOf(receiver) == 0) {revert SA__ZeroAmount();}
+ if (i_snow.balanceOf(receiver) < snapshotAmount) {revert SA__InsufficientBalanc();}
// ..rest of function logic
}