All findings are reproduced by the PoC test suite in test/AuditPoC.t.sol (run with forge test --match-contract AuditPoC).
Summary
High EIP-712 typehash typo makes the airdrop permanently unclaimable SnowmanAirdrop.sol
High mintSnowman has no access control; anyone can mint NFTs for free Snowman.sol
High buySnow keeps mismatched msg.value without refunding (ETH loss) Snow.sol
High earnSnow uses a global timer instead of a per-user timer (one user blocks all) Snow.sol
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:
bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
```
Two defects:
addres is missing an s — the correct type is address.
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:
testPoC\_EIP712TypehashTypoBreaksOffchainSignatures\
bytes32 private constant CORRECT\_TYPEHASH =\
keccak256("SnowmanClaim(address receiver,uint256 amount)");
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:
airdrop.claimSnowman(alice, AL\_PROOF, v, r, s);\
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:
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
address mallory = makeAddr("mallory");
vm.prank(mallory);
nft.mintSnowman(mallory, 100);
assertEq(nft.balanceOf(mallory), 100);
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:
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
uint256 fee = snow\.s\_buyFee();
w\.mint(alice, fee);\
deal(alice, 1 ether);\
vm.startPrank(alice);\
w\.approve(address(snow), fee);\
uint256 ethBefore = alice.balance;
snow\.buySnow{value: 1}(1);\
vm.stopPrank();
assertEq(address(snow).balance, 1);
assertEq(alice.balance, ethBefore - 1);
assertEq(w\.balanceOf(address(snow)), 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:
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
vm.warp(block.timestamp + 1 weeks);
vm.prank(alice);\
snow\.earnSnow();
vm.prank(bob);\
vm.expectRevert(Snow\.S\_\_Timer.selector);\
snow\.earnSnow();
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:
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
(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);
snow\.buySnow(1);
(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);
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);\
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
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";
contract AuditPoC is Test {
Snow snow;
Snowman nft;
SnowmanAirdrop airdrop;
MockWETH weth;
Helper deployer;
bytes32 public ROOT = 0xc0b6787abae0a5066bc2d09eaec944c58119dc18be796e93de5b2bf9f80ea79a;
bytes32 alProofA = 0xf99782cec890699d4947528f9884acaca174602bb028a66d0870534acf241c52;
bytes32 alProofB = 0xbc5a8a0aad4a65155abf53bb707aa6d66b11b220ecb672f7832c05613dba82af;
bytes32 alProofC = 0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1;
bytes32[] AL_PROOF = [alProofA, alProofB, alProofC];
address alice;
uint256 alKey;
bytes32 private constant CORRECT_TYPEHASH =
keccak256("SnowmanClaim(address receiver,uint256 amount)");
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");
}
function testPoC_EIP712TypehashTypoBreaksOffchainSignatures() public {
vm.prank(alice);
snow.approve(address(airdrop), 1);
uint256 amount = snow.balanceOf(alice);
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);
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");
}
function testPoC_AnyoneCanMintSnowman() public {
address mallory = makeAddr("mallory");
vm.prank(mallory);
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");
}
function testPoC_BuySnowOverpaymentIsNotRefunded() public {
MockWETH w = snowWeth();
uint256 fee = snow.s_buyFee();
w.mint(alice, fee);
deal(alice, 1 ether);
vm.startPrank(alice);
w.approve(address(snow), fee);
uint256 ethBefore = alice.balance;
snow.buySnow{value: 1}(1);
vm.stopPrank();
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");
}
function testPoC_EarnSnowGlobalLockout() public {
address bob = makeAddr("bob");
vm.warp(block.timestamp + 1 weeks);
vm.prank(alice);
snow.earnSnow();
vm.prank(bob);
vm.expectRevert(Snow.S__Timer.selector);
snow.earnSnow();
console2.log("[BUG 4] After one user earns, ALL other users are locked out for a week");
}
function testPoC_CanClaimTwice() public {
vm.startPrank(alice);
snow.approve(address(airdrop), 1);
(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);
vm.stopPrank();
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);
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");
}
}