The SnowmanAirdrop contract defines an incorrect EIP-712 MESSAGE_TYPEHASH due to a misspelled Solidity type (addres instead of address) and non-canonical formatting. Because EIP-712 hashes are derived directly from the literal type string, this discrepancy causes the contract to compute a message digest that does not match the digest produced by standard EIP-712 signing tools.
As a result, signatures generated via conventional wallet and frontend flows (e.g. eth_signTypedData_v4) will never verify, preventing legitimate users from claiming Snowman NFTs through normal means.
The issue originates from the following definition:
This string is incorrect for two reasons:
The Solidity type address is misspelled as addres
The string uses non-canonical spacing, which also affects the resulting hash
Under EIP-712, the type hash must be computed as:
The hashing process is extremely sensitive—any deviation in spelling or whitespace produces a different hash. Consequently, the type hash used by the contract does not match the one generated by standard EIP-712 libraries.
The contract verifies signatures against a digest computed as:
However, standard EIP-712 tooling signs a digest derived from the canonical schema:
Because the MESSAGE_TYPEHASH values differ, the final EIP-712 digests differ as well. As a result, ECDSA.recover does not recover the expected signer, and the following check always fails for standard signatures:
This creates the following risks:
All standard typed-data signatures are rejected
Legitimate users cannot claim Snowman NFTs using normal wallet flows
The airdrop is effectively unusable in practice
While it is theoretically possible to claim by signing the exact digest returned by getMessageHash, this requires non-standard raw digest signing and is unsupported by most wallets and UIs. In realistic usage, the claim mechanism is therefore functionally blocked.
Update the EIP-712 type hash to exactly match the canonical struct definition, ensuring consistency with standard signing tools:
Additional recommendations:
Ensure frontend and backend signing logic uses the exact same struct definition and field ordering
Avoid introducing whitespace or formatting differences in EIP-712 type strings
Consider documenting the expected typed-data schema or enforcing it via tests to prevent regressions
Note: Fixing this issue invalidates any signatures produced under the incorrect type hash. All signatures must be regenerated after the fix, or the contract must be redeployed.
This PoC demonstrates that the SnowmanAirdrop contract’s EIP-712 signature verification is incompatible with standard typed-data signing. A signature generated using the correct EIP-712 schema (SnowmanClaim(address receiver,uint256 amount)) consistently fails verification due to an incorrect MESSAGE_TYPEHASH in the contract. The PoC also shows that only signatures produced over the contract’s internally computed digest succeed, confirming that normal wallet signing flows are effectively blocked.
# 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)"); ```
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.