Snowman Merkle Airdrop

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

Incorrect EIP-712 typehash string breaks signature verification for standard wallets

Root + Impact

Description

  • MESSAGE_TYPEHASH is meant to match the EIP-712 type string that standards-compliant wallets (MetaMask, ethers, viem) derive when signing SnowmanClaim data, so that _isValidSignature can correctly recover the signer.

    The type string contains a typo (addres instead of address), producing a completely different typehash than any standard signer will compute.

bytes32 private constant MESSAGE_TYPEHASH =
@> keccak256("SnowmanClaim(addres receiver, uint256 amount)"); // "addres" — missing "s"

Risk

Likelihood:

  • This affects every single signature produced by a standard wallet or signing library — it is not conditional on attacker behavior, it is a deterministic mismatch that occurs on 100% of legitimate signing attempts using correct tooling.


Impact:

  • Any receiver signing through standard wallet tooling produces a signature _isValidSignature cannot validate, causing claimSnowman to revert with SA__InvalidSignature.

Breaks the delegated-claim feature (a third party submitting a claim using the receiver's signature) for all realistic users.

Proof of Concept

function test_typehashMismatch() public {
bytes32 correctTypehash = keccak256("SnowmanClaim(address receiver,uint256 amount)");
bytes32 contractTypehash = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
assertTrue(correctTypehash != contractTypehash);
}

This test doesn't need to interact with the deployed contract at all — it directly compares the hash of the correctly-spelled EIP-712 type string (what any standard wallet would sign against) with the hash of the contract's misspelled constant. Since keccak256 output diverges completely for any change in input, the two hashes being unequal is sufficient to prove that a standard signer's digest will never match what the contract expects.


Recommended Mitigation

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 6 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!