Snowman Merkle Airdrop

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

MESSAGE_TYPEHASH misspells the struct field type ("addres"), so standard EIP-712 signatures are rejected

Root + Impact

The EIP-712 typehash for the claim struct is computed from the string "SnowmanClaim(addres receiver, uint256 amount)". The field type is misspelled ("addres" instead of "address"), and the string also uses a comma-space separator where the canonical EIP-712 encoding uses a plain comma. As a result the contract's typehash differs from the typehash any standards-compliant EIP-712 signer computes from the actual struct definition. A receiver who signs their claim with a normal wallet or library, from the struct SnowmanClaim(address receiver, uint256 amount), produces a digest the contract rejects with SA__InvalidSignature. Claims only succeed when the signer copies the exact digest out of the contract's own getMessageHash helper, which defeats the interoperability EIP-712 exists to provide and will break any frontend or relayer that builds the typed data from the documented struct.

Description

// @> bytes32 private constant MESSAGE_TYPEHASH =
// @> keccak256("SnowmanClaim(addres receiver, uint256 amount)"); // "addres", and ", " instead of ","

Risk

Likelihood: High - deterministic for every signer that constructs the EIP-712 typed data from the struct definition instead of calling the on-chain helper; the first integration that signs off-chain the standard way fails.
Impact: Low - no funds can be stolen and the contract-internal flow still works via getMessageHash, but the signature scheme is non-compliant: standards-based claim flows break, and users can be locked out of claiming through interfaces that sign correctly per the spec.

Proof of Concept

Verified with Foundry (test passes). The same claim signed over the canonical EIP-712 digest (correct spelling, canonical type string, the contract's own domain) is rejected with SA__InvalidSignature, while the digest handed out by getMessageHash verifies.

bytes32 canonicalTypeHash = keccak256("SnowmanClaim(address receiver,uint256 amount)");
// build struct hash + domain separator + final digest, sign with the receiver's key:
vm.expectRevert(SnowmanAirdrop.SA__InvalidSignature.selector);
airdrop.claimSnowman(alice, emptyProof, v, r, s);

Recommended Mitigation

Use the canonical EIP-712 type string:

- bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
+ bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(address receiver,uint256 amount)");
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!