Snowman Merkle Airdrop

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

EIP-712 TypeHash typo "addres" prevents standard signature verification

Root + Impact

The SnowmanAirdrop contract implements EIP-712 for off-chain message signing. The MESSAGE_TYPEHASH is a keccak256 hash of the struct's name and its field types. The contract uses "SnowmanClaim(addres receiver, uint256 amount)", misspelling address as addres.

Description

Because the hash is calculated with a typo, standard crypto libraries (like Ethers.js, Viem) and hardware wallets (like Ledger/Trezor) that expect a valid Solidity type string will generate a different hash for the same user data. This creates a disconnect:

  1. A standard-compliant frontend will generate a signature for address.

  2. The smart contract will try to verify it against addres.

  3. Verification will fail because the domain separator and typehash do not match.

To make the system work, a developer would have to hardcode the typo into their frontend, which is a major security risk as it prevents users from verifying what they are signing on their wallet screens.

// src/SnowmanAirdrop.sol
@> bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(addres receiver, uint256 amount)");

Risk

Likelihood: High

  • The typo is permanent and exists in the contract's bytecode.

  • It affects every single signature generated for the airdrop.

Impact: Medium

  • Breaks integration with the wider EIP-712 ecosystem.

  • Users might be tricked into signing malicious messages because the wallet "Typed Data" display might fail or look corrupted due to the invalid type string.

Proof of Concept

The PoC compares the MESSAGE_TYPEHASH generated by the contract (using the typo) against what a standard EIP-712 generator would expect. It confirms that they are not equal, proving that normal tools cannot interact with this contract without modification.

function test_poc_TypeHashTypo() public {
// 1. What a standard tool expects:
bytes32 expected = keccak256("SnowmanClaim(address receiver, uint256 amount)");
// 2. What the contract uses:
bytes32 actual = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
// 3. Assert they are different
assertTrue(expected != actual);
console2.log("Standard Hash: ", vm.toString(expected));
console2.log("Contract Hash: ", vm.toString(actual));
}

Recommended Mitigation

Correct the spelling of address in the MESSAGE_TYPEHASH definition.

- 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 24 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!