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:
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:
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:
Recommended Mitigation
Use the canonical type string (no space after the comma):
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:
Finding 2 — Critical: mintSnowman has no access control — anyone can mint NFTs for free
Description
Snowman.mintSnowman is external with no permission check:
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
Recommended Mitigation
Restrict minting to the airdrop contract, e.g. with an immutable i_airdrop address set in the constructor:
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:
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
Recommended Mitigation
Require msg.value == 0 on the WETH path and refund any surplus (or revert) on the ETH path:
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:
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
Recommended Mitigation
Track the timer per user:
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:
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
Recommended Mitigation
Enforce the flag and add a dedicated error:
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
# Root + Impact ## Description * The Snowman NFT contract is designed to mint NFTs through a controlled airdrop mechanism where only authorized entities should be able to create new tokens for eligible recipients. * The `mintSnowman()` function lacks any access control mechanisms, allowing any external address to call the function and mint unlimited NFTs to any recipient without authorization, completely bypassing the intended airdrop distribution model. ```Solidity // Root cause in the codebase function mintSnowman(address receiver, uint256 amount) external { @> // NO ACCESS CONTROL - Any address can call this function for (uint256 i = 0; i < amount; i++) { _safeMint(receiver, s_TokenCounter); emit SnowmanMinted(receiver, s_TokenCounter); s_TokenCounter++; } @> // NO VALIDATION - No checks on amount or caller authorization } ``` ## Risk **Likelihood**: * The vulnerability will be exploited as soon as any malicious actor discovers the contract address, since the function is publicly accessible with no restrictions * Automated scanning tools and MEV bots continuously monitor new contract deployments for exploitable functions, making discovery inevitable **Impact**: * Complete destruction of tokenomics through unlimited supply inflation, rendering all legitimate NFTs worthless * Total compromise of the airdrop mechanism, allowing attackers to mint millions of tokens and undermine the project's credibility and economic model ## Proof of Concept ```Solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; import {Test, console2} from "forge-std/Test.sol"; import {Snowman} from "../src/Snowman.sol"; contract SnowmanExploitPoC is Test { Snowman public snowman; address public attacker = makeAddr("attacker"); string constant SVG_URI = "data:image/svg+xml;base64,PHN2Zy4uLi4+"; function setUp() public { snowman = new Snowman(SVG_URI); } function testExploit_UnrestrictedMinting() public { console2.log("=== UNRESTRICTED MINTING EXPLOIT ==="); console2.log("Initial token counter:", snowman.getTokenCounter()); console2.log("Attacker balance before:", snowman.balanceOf(attacker)); // EXPLOIT: Anyone can mint unlimited NFTs vm.prank(attacker); snowman.mintSnowman(attacker, 1000); // Mint 1K NFTs console2.log("Final token counter:", snowman.getTokenCounter()); console2.log("Attacker balance after:", snowman.balanceOf(attacker)); // Verify exploit success assertEq(snowman.balanceOf(attacker), 1000); assertEq(snowman.getTokenCounter(), 1000); console2.log(" EXPLOIT SUCCESSFUL - Minted 1K NFTs without authorization"); } } ``` <br /> PoC Results: ```Solidity forge test --match-test testExploit_UnrestrictedMinting -vv [⠑] Compiling... [⠢] Compiling 1 files with Solc 0.8.29 [⠰] Solc 0.8.29 finished in 1.45s Compiler run successful! Ran 1 test for test/SnowmanExploitPoC.t.sol:SnowmanExploitPoC [PASS] testExploit_UnrestrictedMinting() (gas: 26868041) Logs: === UNRESTRICTED MINTING EXPLOIT === Initial token counter: 0 Attacker balance before: 0 Final token counter: 1000 Attacker balance after: 1000 EXPLOIT SUCCESSFUL - Minted 1K NFTs without authorization Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 4.28ms (3.58ms CPU time) Ran 1 test suite in 10.15ms (4.28ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests) ``` ## Recommended Mitigation Adding the `onlyOwner` modifier restricts the `mintSnowman()` function to only be callable by the contract owner, preventing unauthorized addresses from minting NFTs. ```diff - function mintSnowman(address receiver, uint256 amount) external { + function mintSnowman(address receiver, uint256 amount) external onlyOwner { for (uint256 i = 0; i < amount; i++) { _safeMint(receiver, s_TokenCounter); emit SnowmanMinted(receiver, s_TokenCounter); s_TokenCounter++; } } ```
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.