Snowman Merkle Airdrop

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

EIP-712 `MESSAGE_TYPEHASH` Contains a Typo (`addres` vs `address`) — Standard Wallet Signing Flows Fail, Forcing Users to Sign Raw Hashes

Description

The intended behavior of an EIP-712 typed-data signature is that the user's wallet renders a human-readable structured payload (e.g., "Claim 1 Snowman NFT for receiver 0xabc…") before the user approves. The wallet derives the type hash from the canonical type string "SnowmanClaim(address receiver, uint256 amount)" per the EIP-712 spec. The user signs the human-readable structure, and the contract reconstructs the same digest from the same type hash.

The specific issue is that the on-chain MESSAGE_TYPEHASH is computed from a misspelled type string: "SnowmanClaim(addres receiver, uint256 amount)" — the field type is addres, not address. The keccak256 of these two strings differs, so the digest the wallet produces (from the standard type string) is not the digest the contract expects. Every standard wallet signing flow produces a signature that fails _isValidSignature with SA__InvalidSignature. The only way to make the signature work is to bypass the wallet's structured-data display and ask the user to sign the raw digest returned by getMessageHash — which defeats the entire purpose of EIP-712.

// SnowmanAirdrop.sol — MESSAGE_TYPEHASH definition
- bytes32 private constant MESSAGE_TYPEHASH =
- keccak256("SnowmanClaim(addres receiver, uint256 amount)"); // @> typo: "addres" (missing one 's')
+ bytes32 private constant MESSAGE_TYPEHASH =
+ keccak256("SnowmanClaim(address receiver, uint256 amount)");

Risk

Likelihood:

Reason 1: The mismatch fires on every standard EIP-712 wallet signing attempt. There is no configuration, no parameter tuning, and no edge case that makes the typo'd type hash match the standard one — the two keccak256 outputs are different by construction.

Reason 2: EIP-712 structured signing is the default and recommended flow in modern wallets (MetaMask, Rainbow, Rabby, WalletConnect v2). Any dApp that follows the standard — including community-built front-ends the project does not control — will produce the standard type hash and fail validation.

Impact:

Impact 1: Standard wallet signing fails. The dApp must instead call getMessageHash on-chain and ask the user to personal_sign the raw 32-byte digest. The user loses the human-readable verification that EIP-712 provides — they see an opaque hex string instead of "Claim 1 Snowman NFT for receiver 0xabc…".

Impact 2: Signing raw hashes is a well-documented phishing vector. A malicious dApp can present a fake claim page and ask the user to sign a raw digest that actually authorizes a different action (e.g., a transfer of all their Snow tokens). Without the structured EIP-712 display, the user has no on-wallet signal to distinguish a legitimate claim signing from a malicious one. The typo therefore degrades the project's overall security posture even though it does not directly enable fund theft on its own.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
contract M06_TypeHashTypo is Test {
function test_typeHashesDiffer() public pure {
bytes32 onChain = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
bytes32 standard = keccak256("SnowmanClaim(address receiver, uint256 amount)");
assertNotEq(onChain, standard); // contract type hash != wallet type hash
}
function test_digestsDiffer() public pure {
bytes32 onChainTypehash = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
bytes32 standardTypehash = keccak256("SnowmanClaim(address receiver, uint256 amount)");
address receiver = 0x0000000000000000000000000000000000001234;
uint256 amount = 1;
bytes32 onChainDigest = keccak256(abi.encode(onChainTypehash, receiver, amount));
bytes32 standardDigest = keccak256(abi.encode(standardTypehash, receiver, amount));
assertNotEq(onChainDigest, standardDigest); // wallet-produced digest != contract-expected digest
}
// Implication: a wallet that follows EIP-712 (canonical "address" spelling) will produce
// `standardDigest`. The contract computes `onChainDigest`. _isValidSignature will fail with
// SA__InvalidSignature on every standard wallet signature. The dApp must fall back to
// `personal_sign(getMessageHash(...))` — i.e., signing a raw 32-byte hash with no
// human-readable EIP-712 display, which is the documented phishing vector.
}

Recommended Mitigation

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

The fix is a one-character change: add the missing s to addres. After the fix, the contract's type hash matches the canonical EIP-712 type string, and standard wallet signing flows produce signatures that validate against the contract's expected digest. No other code needs to change — the SnowmanClaim struct, getMessageHash, and _isValidSignature all derive from MESSAGE_TYPEHASH and will automatically use the corrected hash.

If the contract has already been deployed with the typo and the project cannot redeploy, the dApp must continue to call getMessageHash on-chain and ask users to personal_sign the raw digest. This workaround should be clearly documented as a degraded security mode, and users should be warned to verify the source of every signing request.

Updates

Lead Judging Commences

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