Snowman Merkle Airdrop

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

Low-severity bundle: malformed EIP-712 typehash breaks standard signing tools; buySnow can silently double-charge ETH+WETH

Root + Impact

Description

Low-1: MESSAGE_TYPEHASH's inline comment says it's "used for EIP-712 compliant message signing," and getMessageHash is public specifically to support that. The type string doesn't match the EIP-712 spec for the real struct, so standards-compliant signing tools derive a different digest than this contract expects.

// src/SnowmanAirdrop.sol
@> bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
// missing "s" in "address"; stray space after the comma (spec requires none)

Low-2: buySnow is a designed dual-payment function (ETH or WETH). The else branch never checks msg.value == 0 before pulling the full WETH fee, so a nonzero-but-wrong msg.value gets silently charged on top, with no refund.

// src/Snow.sol
} else {
@> i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount)); // no msg.value==0 check
_mint(msg.sender, amount);
}

Risk

Likelihood:

  • Low-1 only manifests once a spec-compliant EIP-712 client is used against the contract — not exploitable in isolation today.

  • Low-2 requires a specific caller mistake (stale wallet value, miscalculated front-end), not ordinary correct usage.

Impact:

  • Low-1: no funds at risk; the contract stays usable via its own self-referential getMessageHash, but silently rejects any standards-compliant signature and contradicts its own "EIP-712 compliant" comment.

  • Low-2: no protocol funds at risk and not exploitable against a third party — the stray ETH stays in the contract (later swept by the collector) but the payer has no way to recover their own overpayment.

Proof of Concept

$ cast keccak "SnowmanClaim(addres receiver, uint256 amount)" # contract's actual string
0xff59e96f4a12fdaf4e417a1440b578f822f6cb542be1a2a7c196280bec54f9ab
$ cast keccak "SnowmanClaim(address receiver,uint256 amount)" # spec-correct for this struct
0x8cc878fcc8a56748c223ca472f543dc59c856aca7a2e59b34d1c2b4ef288044d

The two hashes differ, so any client deriving the typehash from the real struct (not this contract's own getter) produces a signature _isValidSignature rejects. Low-2 is a direct read of the else branch above — no msg.value == 0 guard precedes the WETH pull, confirmed by manual trace of buySnow's branch logic (also exercised incidentally by the separately-submitted global-earn-timer PoC, which calls buySnow(0) and observes it execute with msg.value == 0 and no revert).

Recommended Mitigation

Low-1: fix the type string to exactly match the struct's spec-correct encodeType:

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

Low-2: reject any stray msg.value before pulling the WETH fee, instead of silently accepting both:

} else {
+ require(msg.value == 0, "Snow: send ETH xor WETH, not both");
i_weth.safeTransferFrom(msg.sender, address(this), (s_buyFee * amount));
_mint(msg.sender, amount);
}
Updates

Lead Judging Commences

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