Snowman Merkle Airdrop

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

[H-02] `MESSAGE_TYPEHASH` typo ("addres" instead of "address") breaks all EIP-712 signature verification

Description

The MESSAGE_TYPEHASH constant in SnowmanAirdrop contains a typo: "addres" (6 characters) instead of "address" (7 characters). The keccak256 of this misspelled type string produces a completely different hash than what any standard EIP-712 signer (MetaMask, ethers.js, viem) would compute. All legitimate delegated claim attempts fail with SA__InvalidSignature.

Vulnerability Details

// src/SnowmanAirdrop.sol, line 49
bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
// ^^^^^^^ TYPO: "addres" not "address"

EIP-712 requires the typehash to exactly match the struct type definition. The SnowmanClaim struct uses address receiver (line 37), but the typehash string says addres receiver. Any wallet that constructs the correct type string "SnowmanClaim(address receiver, uint256 amount)" will compute a different keccak256, producing a different digest. The signature will not recover to the expected address.

The claimSnowman function supports delegated claims where someone else submits the transaction using the receiver's v, r, s signature (line 69-82). This path is completely broken because the contract verifies against a digest derived from the wrong typehash.

Risk

Likelihood:

  • Every delegated claim attempt using a standard EIP-712 signing library will fail. The only way to produce a valid signature is to manually use the misspelled type string, which no standard tool supports.

Impact:

  • Complete DoS of the delegated claim path. Users who cannot submit transactions themselves (e.g., hardware wallet users, gasless claim flows) have no way to claim their Snowman NFTs through a third party.

Proof of Concept

function testExploit_TypehashTypoBreaksSignatures() public {
// Contract's typehash has "addres" (TYPO)
bytes32 contractTypehash = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
// Correct EIP-712 typehash
bytes32 correctTypehash = keccak256("SnowmanClaim(address receiver, uint256 amount)");
// Prove they are different
assertTrue(contractTypehash != correctTypehash);
// Build correct EIP-712 digest (what MetaMask/ethers.js would produce)
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 correctStructHash = keccak256(abi.encode(correctTypehash, claimer, amount));
bytes32 correctDigest = MessageHashUtils.toTypedDataHash(domainSeparator, correctStructHash);
// User signs with correct typehash (standard EIP-712)
(uint8 v, bytes32 r, bytes32 s) = vm.sign(claimerPrivateKey, correctDigest);
// Standard EIP-712 signature FAILS
vm.expectRevert(SnowmanAirdrop.SA__InvalidSignature.selector);
airdrop.claimSnowman(claimer, proof, v, r, s);
}

Output:

Contract typehash: 0x4a72b... (keccak of "addres")
Correct typehash: 0xe18d3... (keccak of "address")
Standard EIP-712 signature: REJECTED

Recommendations

Fix the typo:

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