Snowman Merkle Airdrop

AI First Flight #10
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Severity: high
Valid

`MESSAGE_TYPEHASH` has typo "addres" - EIP-712 signature verification is broken

Root + Impact

Description

  • The SnowmanAirdrop contract uses EIP-712 typed data signing for claim authorization. The MESSAGE_TYPEHASH constant defines the struct type used for hashing.

  • The typehash string contains a typo: "addres" instead of "address". This causes the contract to compute a different typehash than what any standard EIP-712 signer (wallet, frontend, etc.) would produce using the correct Solidity type address.

// src/SnowmanAirdrop.sol
bytes32 private constant MESSAGE_TYPEHASH =
keccak256("SnowmanClaim(addres receiver, uint256 amount)");
// ^^^^^^ @> typo: missing 's', should be "address"
  • Any wallet or signing library that correctly encodes the struct as "SnowmanClaim(address receiver, uint256 amount)" will produce a different hash, and the signature will be rejected by the contract.

Risk

Likelihood:

  • Every single claim attempt using a correctly generated EIP-712 signature will fail.

  • The only way to succeed would be to intentionally sign with the typo, which no standard tool does.

Impact:

  • The claimSnowman function becomes permanently unusable through the intended signature flow.

  • No user can claim their Snowman NFT airdrop via the designed mechanism.

  • The entire airdrop distribution system is non-functional.


Proof of Concept

The EIP-712 standard requires that the typehash exactly matches the struct definition used for signing. Wallets like MetaMask and libraries like ethers.js generate the typehash from the correct Solidity types. Since the contract uses "addres" (missing one 's'), the keccak256 hash computed on-chain will differ from the hash any standard signer computes. This means every signature verification will fail, making the claimSnowman function permanently unusable.

Step-by-step scenario:

  1. A user wants to claim their Snowman NFT. A frontend or off-chain system generates the EIP-712 typed data using the correct struct definition: "SnowmanClaim(address receiver, uint256 amount)".

  2. The user signs this message with their wallet, producing a valid (v, r, s) signature.

  3. Someone calls claimSnowman() with this signature. The contract computes getMessageHash() using MESSAGE_TYPEHASH which contains the typo "addres".

  4. The on-chain digest differs from what the user signed, so ECDSA.tryRecover returns a different address.

  5. _isValidSignature returns false and the transaction reverts with SA__InvalidSignature().

function testTypehashMismatchBreaksAllClaims() public {
// Step 1: Demonstrate the two typehashes produce different values
bytes32 correctTypehash = keccak256("SnowmanClaim(address receiver, uint256 amount)");
bytes32 brokenTypehash = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
// These are fundamentally different hashes
assert(correctTypehash != brokenTypehash);
// Step 2: Any signature generated with the correct typehash (as all wallets would)
// will NEVER match the digest the contract computes with the broken typehash.
// This makes claimSnowman() permanently unusable for all users.
// Step 3: Even a legitimate user in the Merkle tree with valid Snow tokens
// cannot claim because their correctly-signed message will always be rejected.
// The only workaround would be to sign with the typo, which no standard
// wallet or library supports.
}

Recommended Mitigation

Fix the typo in the MESSAGE_TYPEHASH constant so that the type string matches the actual Solidity struct definition and conforms to the EIP-712 specification. The word "addres" must be corrected to "address". This ensures that the on-chain digest matches what standard wallets and signing libraries compute, restoring the signature verification flow.

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

Lead Judging Commences

ai-first-flight-judge Lead Judge about 4 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[H-02] Unconsistent `MESSAGE_TYPEHASH` with standart EIP-712 declaration on contract `SnowmanAirdrop`

# Root + Impact ## Description * Little typo on `MESSAGE_TYPEHASH` Declaration on `SnowmanAirdrop` contract ```Solidity // src/SnowmanAirdrop.sol 49: bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(addres receiver, uint256 amount)"); ``` **Impact**: * `function claimSnowman` never be `TRUE` condition ## Proof of Concept Applying this function at the end of /test/TestSnowmanAirdrop.t.sol to know what the correct and wrong digest output HASH. Ran with command: `forge test --match-test testFrontendSignatureVerification -vvvv` ```Solidity function testFrontendSignatureVerification() public { // Setup Alice for the test vm.startPrank(alice); snow.approve(address(airdrop), 1); vm.stopPrank(); // Simulate frontend using the correct format bytes32 FRONTEND_MESSAGE_TYPEHASH = keccak256("SnowmanClaim(address receiver, uint256 amount)"); // Domain separator used by frontend (per EIP-712) bytes32 DOMAIN_SEPARATOR = keccak256( abi.encode( keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), keccak256("Snowman Airdrop"), keccak256("1"), block.chainid, address(airdrop) ) ); // Get Alice's token amount uint256 amount = snow.balanceOf(alice); // Frontend creates hash using the correct format bytes32 structHash = keccak256( abi.encode( FRONTEND_MESSAGE_TYPEHASH, alice, amount ) ); // Frontend creates the final digest (per EIP-712) bytes32 frontendDigest = keccak256( abi.encodePacked( "\x19\x01", DOMAIN_SEPARATOR, structHash ) ); // Alice signs the digest created by the frontend (uint8 v, bytes32 r, bytes32 s) = vm.sign(alKey, frontendDigest); // Digest created by the contract (with typo) bytes32 contractDigest = airdrop.getMessageHash(alice); // Display both digests for comparison console2.log("Frontend Digest (correct format):"); console2.logBytes32(frontendDigest); console2.log("Contract Digest (with typo):"); console2.logBytes32(contractDigest); // Compare the digests - they should differ due to the typo assertFalse( frontendDigest == contractDigest, "Digests should differ due to typo in MESSAGE_TYPEHASH" ); // Attempt to claim with the signature - should fail vm.prank(satoshi); vm.expectRevert(SnowmanAirdrop.SA__InvalidSignature.selector); airdrop.claimSnowman(alice, AL_PROOF, v, r, s); assertEq(nft.balanceOf(alice), 0); } ``` ## Recommended Mitigation on contract `SnowmanAirdrop` Line 49 applying this: ```diff - bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(addres receiver, uint256 amount)"); + bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(address receiver, uint256 amount)"); ```

Support

FAQs

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

Give us feedback!