Snowman Merkle Airdrop

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

The EIP-712 typehash is misspelled, so signatures from real wallets never verify

Description

SnowmanAirdrop uses EIP-712 typed-data signatures so that a third party can submit a claim on a recipient's behalf using that recipient's (v, r, s). The signed struct is SnowmanClaim(address receiver, uint256 amount), and the contract commits to it through a type hash constant:

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

The encoded type string is wrong in two ways. It says addres (missing a letter), and it contains spaces. EIP-712 requires the exact, space-free encoding SnowmanClaim(address receiver,uint256 amount). Every compliant signer — MetaMask's eth_signTypedData_v4, ethers, viem — hashes the correct string when producing a signature, so the digest they sign is different from the one getMessageHash / _isValidSignature reconstructs on-chain. The recovered address therefore never matches receiver, and the claim reverts with SA__InvalidSignature.

Risk

Impact: Medium. The delegated-claim path — the whole reason the v, r, s arguments exist — cannot be used by anyone signing with standard tooling. A legitimately-signed claim is always rejected, so a core, in-scope feature is broken (a denial of service on that flow).

Likelihood: High. This happens for every real user, unconditionally. Any wallet or library that follows EIP-712 (i.e. all of them) produces a signature the contract will reject.

Proof of Concept

A correctly-formed EIP-712 signature (built with the standard type string, exactly what a wallet emits) is rejected by the contract:

function test_standardEip712SignatureIsRejected() public {
uint256 amount = i_snow.balanceOf(alice);
// The CORRECT EIP-712 type string — what MetaMask / ethers / viem hash:
bytes32 STANDARD_TYPEHASH = keccak256("SnowmanClaim(address receiver,uint256 amount)");
bytes32 structHash = keccak256(abi.encode(STANDARD_TYPEHASH, alice, amount));
bytes32 digest = MessageHashUtils.toTypedDataHash(domainSeparator, structHash);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alicePk, digest);
// Alice signed correctly, but the contract's typehash is "addres", so recovery mismatches:
vm.expectRevert(SnowmanAirdrop.SA__InvalidSignature.selector);
airdrop.claimSnowman(alice, aliceProof, v, r, s);
}

Because the digest the wallet signs uses the correct type string while the contract rebuilds it from the malformed one, the recovered signer never equals receiver, so the claim always reverts. The only signature that passes is one hand-crafted to reproduce the contract's typo, which no standard wallet will ever produce.

Recommended Mitigation

Use the exact EIP-712 encoding:

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!