Snowman Merkle Airdrop

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

MESSAGE_TYPEHASH contains typo 'addres' instead of 'address' EIP-712 signatures permanently broken

Root + Impact

Description

  • Normal behavior: EIP-712 requires the type hash to exactly match the struct definition. MESSAGE_TYPEHASH should encode SnowmanClaim(address receiver, uint256 amount) to produce signatures that match the on-chain verification.

  • The issue: Line 49 of SnowmanAirdrop.sol contains a critical typo:

  • addres instead of address. This produces an incorrect MESSAGE_TYPEHASH. Every signature generated off-chain using the correct type string address will hash to a different digest than what the contract computes, causing _isValidSignature() to always return false. The airdrop is completely non-functional — no legitimate user can ever successfully claim a Snowman NFT through the signature path.

    ```
    keccak256("SnowmanClaim(addres receiver, uint256 amount)")
    ```
```solidity
// src/SnowmanAirdrop.sol#49
// @> TYPO: 'addres' instead of 'address'
bytes32 private constant MESSAGE_TYPEHASH =
keccak256("SnowmanClaim(addres receiver, uint256 amount)");
// ^^^^^^ missing 's'
```

Risk

Likelihood:

  • This is a compile-time constant, the typo is baked into the contract permanently at deployment

  • Every single signature verification call will fail 100% failure rate

Impact:

  • The entire airdrop signature mechanism is broken, no user can claim via signature

  • The contract cannot be fixed without redeployment and all deployed instances are permanently broken

Proof of Concept

The following Foundry test proves that off-chain and on-chain type hashes
diverge due to the typo. The test computes both hashes and asserts they
are not equal, confirming that any signature generated by standard EIP-712
tooling will never match what the contract verifies. The result is that
claimSnowman() will always revert with SA__InvalidSignature for every user.

function testTypehashMismatch() public {
// Correct type string — what off-chain signers use
bytes32 correctHash = keccak256(
"SnowmanClaim(address receiver, uint256 amount)"
);
// Buggy type string — what the contract uses
bytes32 buggyHash = keccak256(
"SnowmanClaim(addres receiver, uint256 amount)"
);
// They are not equal — signatures will never verify
assertNotEq(correctHash, buggyHash);
// Confirm claimSnowman always reverts
vm.expectRevert(SnowmanAirdrop.SA__InvalidSignature.selector);
airdrop.claimSnowman(receiver, merkleProof, v, r, s);
}

Recommended Mitigation

The fix is a single character correction to the type string constant.
Because MESSAGE_TYPEHASH is immutable at deployment, the contract must be
redeployed with the corrected string. No other changes are required. The
corrected hash will automatically align with signatures generated by any
standard EIP-712 compliant wallet or library.

- bytes32 private constant MESSAGE_TYPEHASH =
- keccak256("SnowmanClaim(addres receiver, uint256 amount)");
+ bytes32 private constant MESSAGE_TYPEHASH =
+ keccak256("SnowmanClaim(address receiver, uint256 amount)");
+ // After fix, verify the hash matches off-chain tooling:
+ // Expected: keccak256("SnowmanClaim(address receiver, uint256 amount)")
+ // Must equal what EIP-712 signers compute off-chain
+ // Redeploy required — compile-time constant cannot be patched on existing deployment
+ // Additionally consider adding a test to prevent regression:
+ function testTypehashCorrect() public {
+ bytes32 expected = keccak256("SnowmanClaim(address receiver, uint256 amount)");
+ assertEq(airdrop.MESSAGE_TYPEHASH(), expected);
+ }
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!