Snowman Merkle Airdrop

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

`SnowmanAirdrop::MESSAGE_TYPEHASH` contains typo `"addres"` instead of `"address"` breaking all standard EIP-712 signature verification

Root + Impact

Description

The EIP-712 MESSAGE_TYPEHASH should be computed from the exact canonical type string of the SnowmanClaim struct, enabling any standard EIP-712 compliant wallet or library (MetaMask, ethers.js, viem, Ledger) to generate valid signatures.

The type string contains a typo — "addres" instead of "address" — producing a different bytes32 hash than every EIP-712 compliant tool will compute. Any signature built using the correct type string will always fail _isValidSignature and revert with SA__InvalidSignature, making the entire claim flow inaccessible through standard tooling.

// SnowmanAirdrop.sol
@> bytes32 private constant MESSAGE_TYPEHASH =
@> keccak256("SnowmanClaim(addres receiver, uint256 amount)");
// ^^^^^^ — missing trailing 's'
// Correct string: "SnowmanClaim(address receiver, uint256 amount)"

Risk

Likelihood:

  • Guaranteed to affect every user who generates a signature with any standard EIP-712 signing tool — the typehash mismatch is deterministic and permanent.

  • Occurs on the very first claim attempt by any user.

Impact:

  • The entire airdrop is permanently broken. No legitimate user can ever successfully call claimSnowman because their valid signatures will always be rejected.

  • Users who discover the typo and intentionally sign the malformed string bypass EIP-712 protections, undermining the security model.

Proof of Concept

The test first asserts that the contract's typo'd typehash and the correctly spelled typehash produce different bytes32 values, establishing that the mismatch is deterministic. It then simulates the exact failure every real user will encounter: Alice signs a claim message using the canonical EIP-712 type string "SnowmanClaim(address receiver, uint256 amount)" — the string every wallet, hardware device, and signing library uses. The contract internally hashes the message using its typo'd string, producing a different digest. When ECDSA recovery is applied to Alice's valid signature against the wrong digest, the recovered address does not match Alice's address and SA__InvalidSignature is thrown. The vm.expectRevert assertion confirms the revert, proving no legitimately produced off-chain signature can ever pass verification.

To run: forge test --match-test test_TypoMakesStandardSignatureAlwaysFail -vvvv

function test_TypoMakesStandardSignatureAlwaysFail() public {
// The contract's actual (typo'd) typehash
bytes32 contractHash = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
// What every EIP-712 compliant tool computes from the struct definition
bytes32 correctHash = keccak256("SnowmanClaim(address receiver, uint256 amount)");
// They are different — standard signatures will always fail
assertTrue(contractHash != correctHash);
// Simulate: Alice signs with the correct EIP-712 type string (as MetaMask would)
// This produces a digest the contract cannot verify
bytes32 correctDigest = _hashTypedDataV4(
keccak256(abi.encode(correctHash, alice, uint256(1)))
);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alicePrivKey, correctDigest);
// Contract rejects the correctly-formed signature — always
vm.expectRevert(SnowmanAirdrop.SA__InvalidSignature.selector);
vm.prank(alice);
airdrop.claimSnowman(alice, aliceProof, v, r, s);
}

Recommended Mitigation

Fix the typo in the type string by replacing the "addres" with "address"

- bytes32 private constant MESSAGE_TYPEHASH =
- keccak256("SnowmanClaim(addres receiver, uint256 amount)");
+ bytes32 private constant MESSAGE_TYPEHASH =
+ keccak256("SnowmanClaim(address receiver, uint256 amount)");
// ^^^^^^^ Correct spelling
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 6 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!