Snowman Merkle Airdrop

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

MESSAGE_TYPEHASH Typo Breaks EIP-712 Compatibility

Root + Impact

Description

SnowmanAirdrop implements EIP-712 structured-data signing so users can authorize claims off-chain. A relayer (or
anyone) calls claimSnowman() with the receiver's signature, which the contract recovers and validates against the
expected signer. Every standard signing library computes the type hash by hashing the canonical type string
exactly as written in the ABI.

The MESSAGE_TYPEHASH constant contains a typo — "addres" instead of "address" — producing a hash that no
conformant EIP-712 implementation will ever produce. MetaMask, ethers.js, viem, and every other standard
wallet/library hash the correct type string "SnowmanClaim(address receiver, uint256 amount)". The hashes differ,
so the recovered signer never matches receiver, and every claim attempt reverts with SA__InvalidSignature. The
airdrop is permanently broken for all recipients.

// SnowmanAirdrop.sol:49
@> bytes32 private constant MESSAGE\_TYPEHASH =
@> keccak256("SnowmanClaim(addres receiver, uint256 amount)");
// ^^^^^^ missing 'd' — type string is not valid EIP-712

Risk

Likelihood:

  • When any user attempts to claim using MetaMask, ethers.js, viem, or any EIP-712-compliant signing library

  • When a relayer calls claimSnowman() on behalf of a user with a legitimately produced signature

  • When the project deploys to mainnet in its current state — 100% of claims fail from block 1

Impact:

  • All eligible recipients are permanently unable to claim their Snowman NFT through the intended off-chain
    signing flow

  • Airdrop contract is completely non-functional for its primary use case

  • The only path to recovery is redeployment; all pre-generated signatures and Merkle trees must be regenerated

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test, console2} from "forge-std/Test.sol";
import {SnowmanAirdrop} from "../../src/SnowmanAirdrop.sol";
import {Snow} from "../../src/Snow.sol";
import {Snowman} from "../../src/Snowman.sol";
import {MockWETH} from "../../src/mock/MockWETH.sol";
import {Helper} from "../../script/Helper.s.sol";
contract PoC_SA04 is Test {
Snow snow;
Snowman nft;
SnowmanAirdrop airdrop;
MockWETH weth;
bytes32[] AL_PROOF;
address alice;
uint256 aliceKey;
function setUp() public {
Helper deployer = new Helper();
(airdrop, snow, nft, weth) = deployer.run();
AL_PROOF = new bytes32[](3);
AL_PROOF[0] = 0xf99782cec890699d4947528f9884acaca174602bb028a66d0870534acf241c52;
AL_PROOF[1] = 0xbc5a8a0aad4a65155abf53bb707aa6d66b11b220ecb672f7832c05613dba82af;
AL_PROOF[2] = 0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1;
(alice, aliceKey) = makeAddrAndKey("alice");
}
function test_exploit_SA04_TypehashTypoRejectsStandardSignatures() public {
// Compute what a correct EIP-712 typehash SHOULD be
bytes32 CORRECT_TYPEHASH = keccak256(
"SnowmanClaim(address receiver, uint256 amount)"
);
// Compute what the contract actually uses
bytes32 BROKEN_TYPEHASH = keccak256(
"SnowmanClaim(addres receiver, uint256 amount)"
);
assertTrue(CORRECT_TYPEHASH != BROKEN_TYPEHASH, "hashes differ");
console2.log("Correct typehash:", uint256(CORRECT_TYPEHASH));
console2.log("Broken typehash: ", uint256(BROKEN_TYPEHASH));
// Alice approves and signs using the CORRECT EIP-712 type string
// (as any standard wallet would)
vm.prank(alice);
snow.approve(address(airdrop), 1);
bytes32 domainSeparator = airdrop.getDomainSeparator();
bytes32 structHash = keccak256(abi.encode(CORRECT_TYPEHASH, alice, uint256(1)));
bytes32 digest = keccak256(
abi.encodePacked("\x19\x01", domainSeparator, structHash)
);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(aliceKey, digest);
// Contract rejects a valid EIP-712 standard signature
vm.expectRevert(SnowmanAirdrop.SA__InvalidSignature.selector);
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
console2.log("[CONFIRMED] SA-04: standard EIP-712 signature rejected due to typehash typo");
console2.log("Alice NFT balance (should be 0):", nft.balanceOf(alice));
}
}

Recommended Mitigation


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