Snowman Merkle Airdrop

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

Typo in `MESSAGE_TYPEHASH` ("addres" vs "address") breaks all EIP-712 signatures with standard tooling

Root + Impact

Description

  • claimSnowman() uses EIP-712 typed data to verify that the receiver authorized their claim via a signature over (address receiver, uint256 amount).

  • The EIP-712 type string for SnowmanClaim contains a typo — addres instead of address. Any frontend, wallet, or off-chain tool that constructs the message using the correct EIP-712 type string produces a signature that fails verification, making the airdrop completely non-functional with conforming EIP-712 tools. The bug is masked in the internal test suite because both signer and verifier call getMessageHash() (which uses the misspelled hash), so tests pass but it breaks on first contact with any conforming external signer (ethers.js, viem, MetaMask).

@> bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
// ^^^^^^ should be "address"

Risk

Likelihood:

  • Any user attempting to claim via a standard EIP-712 wallet (MetaMask, Ledger, etc.) or a frontend using ethers.js/viem produces a valid-format but hash-mismatched signature that is rejected every time.

  • The bug is masked by the protocol's own test suite because both the signer and verifier call getMessageHash(), which uses the misspelled hash internally. Tests pass typo-to-typo, so the bug ships silently and only surfaces when an external integrator touches it.

Impact:

  • Creates a permanent EIP-712 incompatibility masked by the internal test suite. The airdrop appears functional in testing but breaks on first contact with any conforming external signer.

  • Any future upgrade that corrects the typehash invalidates all previously generated signatures permanently, requiring all users to re-sign.

Proof of Concept

Place this test in test/ and run forge test --match-test testTypehashMismatch. The test demonstrates that the deployed typehash contains a typo (addres instead of address) causing every EIP-712 signature to be computed against a non-standard hash, breaking signature verification.

contract TypehashPoC is Test {
function testTypehashMismatch() public view {
bytes32 correct = keccak256("SnowmanClaim(address receiver, uint256 amount)");
bytes32 deployed = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
// These differ — any signature using the correct string will fail
assert(correct != deployed);
}
}

Recommended Mitigation

Correct the typehash string from "SnowmanClaim(addres receiver, uint256 amount)" to "SnowmanClaim(address receiver, uint256 amount)" so the on-chain hash matches what signers produce off-chain.

- 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 10 days 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!