Snowman Merkle Airdrop

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

Audit Report: Snowman Merkle Airdrop

All findings are reproduced by the PoC test suite in test/AuditPoC.t.sol (run with forge test --match-contract AuditPoC).

Summary

  1. High EIP-712 typehash typo makes the airdrop permanently unclaimable SnowmanAirdrop.sol

  2. High mintSnowman has no access control; anyone can mint NFTs for free Snowman.sol

  3. High buySnow keeps mismatched msg.value without refunding (ETH loss) Snow.sol

  4. High earnSnow uses a global timer instead of a per-user timer (one user blocks all) Snow.sol

  5. High s_hasClaimedSnowman is written but never enforced (unlimited re-claims) SnowmanAirdrop.sol

Finding 1 — High: EIP-712 typehash typo makes the airdrop permanently unclaimable

Description

The MESSAGE_TYPEHASH in SnowmanAirdrop.sol is not the canonical EIP-712 type string:

// SnowmanAirdrop.sol
bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
```

Two defects:

  1. addres is missing an s — the correct type is address.

  2. The comma is followed by a space, which is non-canonical for EIP-712.

The digest computed by the contract therefore differs from the digest every standard off-chain signer (MetaMask, ethers.js, viem) produces.

Verified:

keccak256("SnowmanClaim(addres receiver, uint256 amount)") = 0xff59e96f...
keccak256("SnowmanClaim(address receiver,uint256 amount)") = 0x8cc878fc... <- what off-chain signers compute

Risk

High: availability/DoL (loss of functionality). claimSnowman calls _isValidSignature, which compares the recovered signer against receiver. Because the on-chain digest never matches the digest a real user signs, ECDSA.tryRecover never returns the receiver and the function always reverts with SA__InvalidSignature. Zero users can ever claim their airdrop. The bundled test passes only because it signs the contract's own buggy digest (airdrop.getMessageHash(alice)), which masks the bug.

Proof of Concept

A signature produced by standard EIP-712 tooling (correct typehash) is rejected by the contract:

// test/AuditPoC.t.sol —
testPoC\_EIP712TypehashTypoBreaksOffchainSignatures\
bytes32 private constant CORRECT\_TYPEHASH =\
keccak256("SnowmanClaim(address receiver,uint256 amount)");
// Compute the digest exactly as MetaMask/ethers/viem would\
bytes32 structHash = keccak256(abi.encode(CORRECT\_TYPEHASH, alice, amount));\
bytes32 digest = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash));\
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alKey, digest);
vm.prank(alice);\
vm.expectRevert([SnowmanAirdrop.SA](https://snowmanairdrop.sa/)\_\_InvalidSignature.selector);\
airdrop.claimSnowman(alice, AL\_PROOF, v, r, s);\
// Reverts with SA\_\_InvalidSignature => legitimate user signatures never validate

Recommended Mitigation

Use the canonical type string (no space after the comma):

bytes32 private constant MESSAGE\_TYPEHASH =\
keccak256("SnowmanClaim(address receiver,uint256 amount)");

If the merkle tree has already been generated with the buggy digest, regenerate the root from the canonical digest, or derive the typehash from the struct:

bytes32 private constant MESSAGE_TYPEHASH =
keccak256("SnowmanClaim(address receiver,uint256 amount)");

Finding 2 — Critical: mintSnowman has no access control — anyone can mint NFTs for free

Description

Snowman.mintSnowman is external with no permission check:

// Snowman.sol\
function mintSnowman(address receiver, uint256 amount) external {\
for (uint256 i = 0; i < amount; i++) {\
\_safeMint(receiver, s\_TokenCounter);\
emit SnowmanMinted(receiver, s\_TokenCounter);\
s\_TokenCounter++;\
}\
}

The contract defines error SM__NotAllowed(); but never uses it. Any EOA can mint unlimited NFTs to any address at zero cost.

Risk

High: the NFT's entire purpose (earn Snow, pay fees, claim a Snowman) is bypassed. An attacker mints unlimited supply, destroying scarcity and any secondary-market value. The project's own test TestSnowman.t.sol::testMintSnowman calls it as a plain contract, confirming the absence of a guard.

Proof of Concept

// test/AuditPoC.t.sol — testPoC\_AnyoneCanMintSnowman\
address mallory = makeAddr("mallory");
vm.prank(mallory); // plain EOA, NOT the airdrop contract\
nft.mintSnowman(mallory, 100);
assertEq(nft.balanceOf(mallory), 100); // mallory now owns 100 Snowmen for free

Recommended Mitigation

Restrict minting to the airdrop contract, e.g. with an immutable i_airdrop address set in the constructor:

error SM\_\_NotAllowed();
address private immutable i\_airdrop;
constructor(string memory \_SnowmanSvgUri, address \_airdrop)\
ERC721("Snowman Airdrop", "SNOWMAN")\
Ownable(msg.sender)\
{\
if (\_airdrop == address(0)) revert SM\_\_NotAllowed();\
i\_airdrop = \_airdrop;\
}
function mintSnowman(address receiver, uint256 amount) external {\
if (msg.sender != i\_airdrop) revert SM\_\_NotAllowed();\
for (uint256 i = 0; i < amount; i++) {\
\_safeMint(receiver, s\_TokenCounter);\
emit SnowmanMinted(receiver, s\_TokenCounter);\
s\_TokenCounter++;\
}\
}

Finding 3 — High: buySnow keeps mismatched msg.value without refunding (ETH loss)

Description

Snow.buySnow accepts ETH and WETH interchangeably, but does not enforce an exact match:

// Snow\.sol\
function buySnow(uint256 amount) external payable canFarmSnow {\
if (msg.value == (s\_buyFee \* amount)) {\
\_mint(msg.sender, amount);\
} else {\
i\_weth.safeTransferFrom(msg.sender, address(this), (s\_buyFee \* amount));\
\_mint(msg.sender, amount);\
}\
s\_earnTimer = block.timestamp;\
emit SnowBought(msg.sender, amount);\
}

If msg.value is any nonzero value that is not exactly s_buyFee * amount, the ETH is not refunded and the full WETH fee is also pulled — the user pays twice.

Risk

High: direct loss of funds. A user who sends 1 wei of ETH by accident (or a rounding/miscalculation error) loses that ETH permanently; it accumulates in the contract and is swept to the collector by collectFee (address(this).balance). Because the ETH branch and WETH branch mint identically, mismatched ETH is pure profit for the collector at the user's expense.

Proof of Concept

// test/AuditPoC.t.sol — testPoC\_BuySnowOverpaymentIsNotRefunded\
uint256 fee = snow\.s\_buyFee(); // 5e18 for amount=1\
w\.mint(alice, fee);\
deal(alice, 1 ether);\
vm.startPrank(alice);\
w\.approve(address(snow), fee);\
uint256 ethBefore = alice.balance;
// Sends 1 wei of ETH on top of the full WETH payment\
snow\.buySnow{value: 1}(1);\
vm.stopPrank();
assertEq(address(snow).balance, 1); // ETH was NOT refunded\
assertEq(alice.balance, ethBefore - 1); // alice lost 1 wei\
assertEq(w\.balanceOf(address(snow)), fee); // ...and paid the full WETH fee

Recommended Mitigation

Require msg.value == 0 on the WETH path and refund any surplus (or revert) on the ETH path:

error S\_\_WrongPaymentAmount();
function buySnow(uint256 amount) external payable canFarmSnow {\
uint256 fee = s\_buyFee \* amount;\
if (msg.value > 0) {\
if (msg.value != fee) revert S\_\_WrongPaymentAmount();\
} else {\
i\_weth.safeTransferFrom(msg.sender, address(this), fee);\
}\
\_mint(msg.sender, amount);\
s\_earnTimer = block.timestamp;\
emit SnowBought(msg.sender, amount);\
}

Finding 4 — High: earnSnow uses a global timer, so one user blocks everyone

Description

s_earnTimer is a single contract-wide variable shared by all users:

// Snow\.sol\
uint256 private s\_earnTimer;
function earnSnow() external canFarmSnow {\
if (s\_earnTimer != 0 && block.timestamp < (s\_earnTimer + 1 weeks)) {\
revert S\_\_Timer();\
}\
\_mint(msg.sender, 1);\
s\_earnTimer = block.timestamp;\
}

The first caller each week mints 1 Snow and locks every other user out for a full week. It is also trivially front-runnable — whoever gets to the mempool first wins, and bots can grief the entire user base.

Risk

High: broken tokenomics / availability. Only one user per week can ever earn Snow for the entire 12-week farming window (max 12 total earners contract-wide). Legitimate users are permanently denied the earn mechanism. The deploy script Helper.s.sol has to vm.warp(+1 week) between each user, confirming one user blocks the rest.

Proof of Concept

// test/AuditPoC.t.sol — testPoC\_EarnSnowGlobalLockout\
vm.warp(block.timestamp + 1 weeks); // let the global timer expire
vm.prank(alice);\
snow\.earnSnow(); // alice earns first this week
vm.prank(bob);\
vm.expectRevert(Snow\.S\_\_Timer.selector);\
snow\.earnSnow(); // bob is locked out for a week by alice's action

Recommended Mitigation

Track the timer per user:

mapping(address => uint256) private s\_lastEarn;
function earnSnow() external canFarmSnow {\
if (s\_lastEarn\[msg.sender] != 0 && block.timestamp < s\_lastEarn\[msg.sender] + 1 weeks) {\
revert S\_\_Timer();\
}\
\_mint(msg.sender, 1);\
s\_lastEarn\[msg.sender] = block.timestamp;\
}

Finding 5 — High: s_hasClaimedSnowman is written but never enforced (re-claim)

Description

claimSnowman sets s_hasClaimedSnowman[receiver] = true, but no code path ever reads it:

// SnowmanAirdrop.sol\
s\_hasClaimedSnowman\[receiver] = true;\
emit SnowmanClaimedSuccessfully(receiver, amount);\
i\_snowman.mintSnowman(receiver, amount);

There is no if (s_hasClaimedSnowman[receiver]) revert check anywhere, and no partial-claim accounting. The claim only requires (a) a valid signature, (b) balanceOf(receiver) > 0, and (c) a valid merkle proof for the receiver's live balance. Because the merkle leaf is bound to the live balance, a user can claim, re-buy Snow to the snapshot amount, and claim again with a fresh signature and the same proof.

Risk

High: an attacker can claim unlimited Snowman NFTs while paying only the Snow buy fee per cycle (or zero, if they keep Snow staked elsewhere). Combined with Finding 2 this is even worse. Supply inflation destroys NFT value for all holders.

Proof of Concept

// test/AuditPoC.t.sol — testPoC\_CanClaimTwice\
// 1st claim (valid signature, valid proof)\
(uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(alKey, airdrop.getMessageHash(alice));\
airdrop.claimSnowman(alice, AL\_PROOF, v1, r1, s1);\
assertEq(nft.balanceOf(alice), 1);
// alice re-buys Snow back to the snapshot amount (1)\
snow\.buySnow(1);
// 2nd claim with a fresh signature + the SAME merkle proof -> succeeds\
(uint8 v2, bytes32 r2, bytes32 s2) = vm.sign(alKey, airdrop.getMessageHash(alice));\
airdrop.claimSnowman(alice, AL\_PROOF, v2, r2, s2);
assertEq(nft.balanceOf(alice), 2); // claimed twice

Recommended Mitigation

Enforce the flag and add a dedicated error:

error SA\_\_AlreadyClaimed();
function claimSnowman(address receiver, bytes32\[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)\
external\
nonReentrant\
{\
if (receiver == address(0)) revert SA\_\_ZeroAddress();\
if (s\_hasClaimedSnowman\[receiver]) revert SA\_\_AlreadyClaimed();\
if (i\_snow\.balanceOf(receiver) == 0) revert SA\_\_ZeroAmount();
uint256 amount = i\_snow\.balanceOf(receiver);\
// ... signature + merkle checks unchanged ...
s\_hasClaimedSnowman\[receiver] = true;\
emit SnowmanClaimedSuccessfully(receiver, amount);\
i\_snowman.mintSnowman(receiver, amount);\
}

Additional observations (informational)

  • Snow.collectFee ignores the bool return of i_weth.transfer(...) — a non-reverting token would silently skip the transfer. Use SafeERC20 or check the return value.

  • The claim digest and merkle leaf are bound to the receiver's live balance; any change after the snapshot (e.g., earning one more Snow) permanently locks the user out of claiming — there is no partial-claim path.

  • s_claimers array is dead code, and tokenURI's ownerOf(tokenId) == address(0) check is unreachable since ownerOf already reverts for nonexistent tokens.

Complete PoC:

// test/AuditPoC.t.sol
Run
forge test --match-contract AuditPoC

or...

forge test --match-contract AuditPoC -vvv


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test, console2} from "forge-std/Test.sol";
import {Snow} from "../src/Snow.sol";
import {Snowman} from "../src/Snowman.sol";
import {SnowmanAirdrop} from "../src/SnowmanAirdrop.sol";
import {MockWETH} from "../src/mock/MockWETH.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {Helper} from "../script/Helper.s.sol";
/// @notice Audit PoCs for the Snowman Merkle Airdrop
contract AuditPoC is Test {
Snow snow;
Snowman nft;
SnowmanAirdrop airdrop;
MockWETH weth;
Helper deployer;
bytes32 public ROOT = 0xc0b6787abae0a5066bc2d09eaec944c58119dc18be796e93de5b2bf9f80ea79a;
// Alice's valid merkle proof (leaf: (alice, 1))
bytes32 alProofA = 0xf99782cec890699d4947528f9884acaca174602bb028a66d0870534acf241c52;
bytes32 alProofB = 0xbc5a8a0aad4a65155abf53bb707aa6d66b11b220ecb672f7832c05613dba82af;
bytes32 alProofC = 0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1;
bytes32[] AL_PROOF = [alProofA, alProofB, alProofC];
address alice;
uint256 alKey;
// The typehash a standard EIP-712 signer (MetaMask, ethers.js, viem) would use
bytes32 private constant CORRECT_TYPEHASH =
keccak256("SnowmanClaim(address receiver,uint256 amount)");
// Helper returns a decoy MockWETH; Snow's real WETH sits at storage slot 9
function snowWeth() internal view returns (MockWETH w) {
w = MockWETH(address(uint160(uint256(vm.load(address(snow), bytes32(uint256(9)))))));
}
function setUp() public {
deployer = new Helper();
(airdrop, snow, nft, weth) = deployer.run();
(alice, alKey) = makeAddrAndKey("alice");
}
// BUG 1: EIP-712 typehash typo ("addres" + non-canonical spacing)
function testPoC_EIP712TypehashTypoBreaksOffchainSignatures() public {
vm.prank(alice);
snow.approve(address(airdrop), 1);
uint256 amount = snow.balanceOf(alice); // snapshot amount = 1
// Compute the digest exactly as standard tooling would (correct typehash):
bytes32 structHash = keccak256(abi.encode(CORRECT_TYPEHASH, alice, amount));
bytes32 domainSeparator = keccak256(
abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256("Snowman Airdrop"),
keccak256("1"),
block.chainid,
address(airdrop)
)
);
bytes32 digest = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash));
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alKey, digest);
// The on-chain contract uses "SnowmanClaim(addres receiver, uint256 amount)" -> different digest
vm.prank(alice);
vm.expectRevert(SnowmanAirdrop.SA__InvalidSignature.selector);
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
console2.log("[BUG 1] Legitimate user signature (correct EIP-712 typehash) is rejected:");
console2.log(" claimSnowman reverted with SA__InvalidSignature => airdrop is unclaimable");
}
// BUG 2: Snowman.mintSnowman has NO access control - anyone can mint
function testPoC_AnyoneCanMintSnowman() public {
address mallory = makeAddr("mallory");
vm.prank(mallory); // plain EOA, not the airdrop contract
nft.mintSnowman(mallory, 100);
assertEq(nft.balanceOf(mallory), 100);
console2.log("[BUG 2] EOA minted 100 Snowman NFTs for free - mintSnowman has no access control");
}
// BUG 3: buySnow does not refund mismatched msg.value (ETH loss)
function testPoC_BuySnowOverpaymentIsNotRefunded() public {
MockWETH w = snowWeth();
uint256 fee = snow.s_buyFee(); // 5e18 for amount=1
w.mint(alice, fee);
deal(alice, 1 ether); // alice needs ETH to send as msg.value
vm.startPrank(alice);
w.approve(address(snow), fee);
uint256 ethBefore = alice.balance;
// Sends 1 wei of ETH on top of the WETH payment - msg.value != s_buyFee * amount
snow.buySnow{value: 1}(1);
vm.stopPrank();
// ETH was NOT refunded: user lost 1 wei AND paid the full WETH fee
assertEq(address(snow).balance, 1);
assertEq(alice.balance, ethBefore - 1);
assertEq(w.balanceOf(address(snow)), fee);
console2.log("[BUG 3] buySnow with msg.value != fee keeps the ETH and still pulls WETH fee");
}
// BUG 4: earnSnow uses a GLOBAL timer - one user blocks everyone
function testPoC_EarnSnowGlobalLockout() public {
address bob = makeAddr("bob");
vm.warp(block.timestamp + 1 weeks); // let the global timer from setUp expire
vm.prank(alice);
snow.earnSnow(); // alice earns first this week
vm.prank(bob);
vm.expectRevert(Snow.S__Timer.selector);
snow.earnSnow(); // bob is locked out for a week by alice's action
console2.log("[BUG 4] After one user earns, ALL other users are locked out for a week");
}
// BUG 5: s_hasClaimedSnowman is set but never checked -> re-claim possible
function testPoC_CanClaimTwice() public {
vm.startPrank(alice);
snow.approve(address(airdrop), 1);
// First claim (valid signature from the contract's own digest)
(uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(alKey, airdrop.getMessageHash(alice));
airdrop.claimSnowman(alice, AL_PROOF, v1, r1, s1);
assertEq(nft.balanceOf(alice), 1);
assertEq(snow.balanceOf(alice), 0); // snow drained to airdrop
vm.stopPrank();
// Alice re-buys Snow back to the snapshot amount (1) - paying the fee again
MockWETH w = snowWeth();
w.mint(alice, snow.s_buyFee());
deal(alice, 1 ether);
vm.startPrank(alice);
w.approve(address(snow), snow.s_buyFee());
snow.buySnow(1);
assertEq(snow.balanceOf(alice), 1);
// Claim again with a fresh signature + the SAME merkle proof -> succeeds
snow.approve(address(airdrop), 1);
(uint8 v2, bytes32 r2, bytes32 s2) = vm.sign(alKey, airdrop.getMessageHash(alice));
airdrop.claimSnowman(alice, AL_PROOF, v2, r2, s2);
vm.stopPrank();
assertEq(nft.balanceOf(alice), 2);
console2.log("[BUG 5] Alice claimed twice: s_hasClaimedSnowman is written but never enforced");
}
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour 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!