Snowman Merkle Airdrop

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

Malformed EIP-712 type string in `SnowmanAirdrop::MESSAGE_TYPEHASH` makes every wallet-produced signature invalid, breaking claims

Root + Impact

Description

  • SnowmanAirdrop inherits EIP712 and exposes eip712Domain(), so recipients sign a SnowmanClaim struct in their wallet and a third party can submit the claim on their behalf.

  • However, MESSAGE_TYPEHASH hashes "SnowmanClaim(addres receiver, uint256 amount)" — a misspelled type name and a space after the comma — which is not the canonical EIP-712 encodeType string, so the digest the contract verifies can never match the one a compliant signer produces.

@> bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
// ^^^^^^ ^ space
// EIP-712 requires: "SnowmanClaim(address receiver,uint256 amount)"

Risk

Likelihood:

  • Every signature produced through eth_signTypedData_v4, ethers.js, viem or any wallet UI, since all of them hash the canonical string and reach a different digest.

  • No standard library can be made to produce the contract's digest either, as addres is rejected as an unknown type during typed-data validation. Only raw-hash signing via eth_sign works, which modern wallets block or bury behind warnings.

Impact:

  • The delegated-claim feature is unusable for every standard wallet user, and since claimSnowman requires a signature on every path including a self-claim, claiming breaks outright for anyone who cannot sign a raw 32-byte hash. All attempts revert with SA__InvalidSignature.

  • MESSAGE_TYPEHASH is constant, so there is no post-deployment fix short of redeploying and re-running the airdrop.

Proof of Concept

The test builds the digest the way a compliant wallet does — canonical type string, domain separator read from the contract's own eip712Domain() — signs it with Alice's key, and submits it. Everything except the type string matches the contract exactly, isolating the typo as the sole cause.

bytes32 correctTypehash = keccak256("SnowmanClaim(address receiver,uint256 amount)");
bytes32 structHash = keccak256(abi.encode(correctTypehash, alice, snow.balanceOf(alice)));
bytes32 correctDigest = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash));
assertTrue(correctDigest != airdrop.getMessageHash(alice)); // digests diverge
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alKey, correctDigest);
vm.expectRevert(SnowmanAirdrop.SA__InvalidSignature.selector);
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);

A correctly signed, genuinely authorised claim is rejected. The existing test suite misses this because it signs airdrop.getMessageHash() directly, reproducing the contract's own non-standard digest instead of a wallet's.

Recommended Mitigation

Correct the type name and remove the whitespace so the string matches the canonical encodeType form.

The contract then derives the same digest as any wallet, and the delegated-claim flow works as documented. Add a test that builds the digest from the canonical type string rather than from getMessageHash(), so the contract and standard tooling cannot silently diverge again.

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