Snowman Merkle Airdrop

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

SnowmanAirdrop::MESSAGE_TYPEHASH contains a typo addres receiver instead of address receiver, making the EIP-712 digest incompatible with every standard wallet and signing library

Description

EIP-712 requires the typeHash to be keccak256(encodeType(typeOf(message))) where encodeType exactly matches the struct's Solidity definition. Wallets such as MetaMask, Frame, and Rabby, and
libraries like ethers.js, viem, and web3.js all compute the digest using the correct field type address.

This contract's MESSAGE_TYPEHASH constant has the field name spelled as addres receiver (one s), producing a different typeHash than any standard tool will produce. A legitimate user signing a
SnowmanClaim struct with their wallet produces a signature over digest D_correct, but _hashTypedDataV4 on-chain computes D_typo, and ECDSA.tryRecover(D_typo, v, r, s) recovers an address that
does not equal receiver, so claimSnowman reverts with SA__InvalidSignature — even for fully eligible airdrop recipients.

// src/SnowmanAirdrop.sol:49
bytes32 private constant MESSAGE_TYPEHASH =
@> keccak256("SnowmanClaim(addres receiver, uint256 amount)");
// ^^^^^^ TYPO — should be "address"

Risk

Likelihood:

  • Every signature produced by a standard EIP-712 wallet or library fails verification on-chain.

  • The bug occurs every time a user attempts to claim via standard tooling — no edge case required.

Impact:

  • Legitimate airdrop recipients cannot claim their NFTs through any common wallet UX.

  • The "claim on behalf of receiver via signature" feature documented in the README is effectively non-functional in production.

  • Only users sophisticated enough to manually sign with the typo'd type string can claim, contradicting the contract's purpose.

Proof of Concept

The following test reproduces the digest mismatch end-to-end. We compute the typehash both ways — once with the correct address field name (what every standard wallet uses) and once with the on-chain
typo (what _hashTypedDataV4 actually uses). We then have Alice sign the wallet-correct digest and submit it to claimSnowman. The contract recomputes the digest using its typo'd typehash, recovers a
different address, and reverts.

function test_standard_eip712_signature_fails() public {
bytes32 correctTypehash = keccak256("SnowmanClaim(address receiver, uint256 amount)");
bytes32 onchainTypehash = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
assertTrue(correctTypehash != onchainTypehash);
bytes32 structHash = keccak256(abi.encode(correctTypehash, alice, uint256(10)));
bytes32 domainSeparator = airdrop.DOMAIN_SEPARATOR();
bytes32 walletDigest = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash));
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alicePk, walletDigest);
vm.expectRevert(SnowmanAirdrop.SA__InvalidSignature.selector);
airdrop.claimSnowman(alice, proof, v, r, s);
}

The test confirms that legitimate users using mainstream wallets are locked out of the airdrop entirely.

Recommended Mitigation

Fix the typo in MESSAGE_TYPEHASH so the on-chain typeHash matches what every standard EIP-712 tool produces. This is a one-character change with no other code impact.

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

After the fix, signatures produced by MetaMask, ethers.js, viem, etc. for the SnowmanClaim struct verify correctly and legitimate recipients can claim through their wallet UX as intended.

Updates

Lead Judging Commences

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