Snowman Merkle Airdrop

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

MESSAGE_TYPEHASH spells addres instead of address, so every EIP-712 signature from standard wallets is rejected

Root + Impact

Description

The EIP-712 type hash for the claim struct contains a typo: addres instead of address.

@> bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
function getMessageHash(address receiver) public view returns (bytes32) {
...
return _hashTypedDataV4(
@> keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: amount})))
);
}

EIP-712 requires the type hash to be the keccak256 of the canonical type string, where field types and names are exactly the ones declared in the struct. The correct string is SnowmanClaim(address receiver,uint256 amount).

Because the constant is used on both the signing and the verifying side, the contract stays internally consistent, but it no longer agrees with the standard. Any signer that derives the digest per EIP-712 - MetaMask, ethers, viem, any typed-data wallet - produces a different digest, and its signature is rejected with SA__InvalidSignature.

The practical consequence is that the intended UX ("claim directly or via someone else using cryptographic signatures") only works with a custom signer that deliberately reproduces the typo. Standard tooling cannot produce an accepted signature.

Risk

Likelihood: High. Every signature produced by a standards-compliant wallet fails. It is the default outcome, not an edge case.

Impact: Low. No funds are at risk and nothing can be forged; the claim flow simply cannot be used with standard tooling, which breaks the intended delegated-claim feature and forces users to rely on bespoke, unaudited signing code.

Proof of Concept

The test builds the digest exactly as EIP-712 specifies, signs it with the claimer's key, and shows that the contract rejects the signature.

// Actors:
// - Alice: the claimer, signing with a standards-compliant wallet
// - Relayer: any third party submitting the claim
​
function test_poc_typehash_typo() public {
// the two type strings do not hash to the same value
bytes32 correct = keccak256("SnowmanClaim(address receiver,uint256 amount)");
bytes32 used = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
assertTrue(correct != used);
​
vm.prank(alice);
snow.earnSnow();
vm.prank(alice);
snow.approve(address(airdrop), 1);
​
// digest built per the EIP-712 spec
bytes32 domainSeparator = keccak256(
abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256("Snowman Airdrop"),
keccak256("1"),
block.chainid,
address(airdrop)
)
);
bytes32 structHash = keccak256(abi.encode(correct, alice, uint256(1)));
bytes32 specDigest = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash));
​
(, uint256 alKey) = makeAddrAndKey("alice");
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alKey, specDigest);
​
// the contract rejects a spec-compliant signature
vm.prank(attacker);
vm.expectRevert(SnowmanAirdrop.SA__InvalidSignature.selector);
airdrop.claimSnowman(alice, proof, v, r, s);
}

Recommended Mitigation

Use the canonical type string, with the field name spelled correctly and no space after the comma.

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

To avoid this class of error entirely, derive the type hash from a shared constant and add a test that reconstructs the digest with an independent EIP-712 implementation.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 20 minutes 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!