Snowman Merkle Airdrop

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

EIP-712 Type Hash Breaks Signature Verification

Description

The SnowmanAirdrop contract defines an incorrect EIP-712 MESSAGE_TYPEHASH due to a misspelled Solidity type (addres instead of address) and non-canonical formatting. Because EIP-712 hashes are derived directly from the literal type string, this discrepancy causes the contract to compute a message digest that does not match the digest produced by standard EIP-712 signing tools.

As a result, signatures generated via conventional wallet and frontend flows (e.g. eth_signTypedData_v4) will never verify, preventing legitimate users from claiming Snowman NFTs through normal means.

The issue originates from the following definition:

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

This string is incorrect for two reasons:

  1. The Solidity type address is misspelled as addres

  2. The string uses non-canonical spacing, which also affects the resulting hash

Under EIP-712, the type hash must be computed as:

keccak256("StructName(type1 field1,type2 field2,...)")

The hashing process is extremely sensitive—any deviation in spelling or whitespace produces a different hash. Consequently, the type hash used by the contract does not match the one generated by standard EIP-712 libraries.


Risk

The contract verifies signatures against a digest computed as:

_hashTypedDataV4(
keccak256(abi.encode(
MESSAGE_TYPEHASH,
receiver,
amount
))
);

However, standard EIP-712 tooling signs a digest derived from the canonical schema:

keccak256("SnowmanClaim(address receiver,uint256 amount)")

Because the MESSAGE_TYPEHASH values differ, the final EIP-712 digests differ as well. As a result, ECDSA.recover does not recover the expected signer, and the following check always fails for standard signatures:

if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) {
revert SA__InvalidSignature();
}

This creates the following risks:

  • All standard typed-data signatures are rejected

  • Legitimate users cannot claim Snowman NFTs using normal wallet flows

  • The airdrop is effectively unusable in practice

While it is theoretically possible to claim by signing the exact digest returned by getMessageHash, this requires non-standard raw digest signing and is unsupported by most wallets and UIs. In realistic usage, the claim mechanism is therefore functionally blocked.


Recommended Mitigation

Update the EIP-712 type hash to exactly match the canonical struct definition, ensuring consistency with standard signing tools:

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

Additional recommendations:

  • Ensure frontend and backend signing logic uses the exact same struct definition and field ordering

  • Avoid introducing whitespace or formatting differences in EIP-712 type strings

  • Consider documenting the expected typed-data schema or enforcing it via tests to prevent regressions

Note: Fixing this issue invalidates any signatures produced under the incorrect type hash. All signatures must be regenerated after the fix, or the contract must be redeployed.


Proof of Concept

This PoC demonstrates that the SnowmanAirdrop contract’s EIP-712 signature verification is incompatible with standard typed-data signing. A signature generated using the correct EIP-712 schema (SnowmanClaim(address receiver,uint256 amount)) consistently fails verification due to an incorrect MESSAGE_TYPEHASH in the contract. The PoC also shows that only signatures produced over the contract’s internally computed digest succeed, confirming that normal wallet signing flows are effectively blocked.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/*
POC (Foundry) — demonstrates:
1) A standard / “correct” EIP-712 digest (with the correct type string)
produces a signature that FAILS in claimSnowman (reverts SA__InvalidSignature).
2) A signature over the *contracts* digest (returned by getMessageHash),
succeeds (showing it’s not cryptographically impossible, but standard EIP-712
typed-data signing is effectively broken).
Run:
forge test -vvv
*/
import "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
// -------------------- Minimal mocks --------------------
contract MockSnow is ERC20 {
constructor() ERC20("Snow", "SNOW") {}
function mint(address to, uint256 amt) external {
_mint(to, amt);
}
}
contract MockSnowman {
event Minted(address indexed to, uint256 amount);
function mintSnowman(address to, uint256 amount) external {
emit Minted(to, amount);
}
}
// -------------------- Vulnerable contract (as given) --------------------
import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SnowmanAirdrop is EIP712, ReentrancyGuard {
using SafeERC20 for MockSnow;
error SA__InvalidProof();
error SA__InvalidSignature();
error SA__ZeroAddress();
error SA__ZeroAmount();
struct SnowmanClaim {
address receiver;
uint256 amount;
}
bytes32 private immutable i_merkleRoot;
MockSnow private immutable i_snow;
MockSnowman private immutable i_snowman;
mapping(address => bool) private s_hasClaimedSnowman;
// BUG: "addres" + whitespace => wrong TYPEHASH
bytes32 private constant MESSAGE_TYPEHASH =
keccak256("SnowmanClaim(addres receiver, uint256 amount)");
event SnowmanClaimedSuccessfully(address receiver, uint256 amount);
constructor(bytes32 _merkleRoot, address _snow, address _snowman)
EIP712("Snowman Airdrop", "1")
{
if (_snow == address(0) || _snowman == address(0)) revert SA__ZeroAddress();
i_merkleRoot = _merkleRoot;
i_snow = MockSnow(_snow);
i_snowman = MockSnowman(_snowman);
}
function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
if (receiver == address(0)) revert SA__ZeroAddress();
if (i_snow.balanceOf(receiver) == 0) revert SA__ZeroAmount();
if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) {
revert SA__InvalidSignature();
}
uint256 amount = i_snow.balanceOf(receiver);
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) revert SA__InvalidProof();
i_snow.safeTransferFrom(receiver, address(this), amount);
s_hasClaimedSnowman[receiver] = true;
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}
function _isValidSignature(address receiver, bytes32 digest, uint8 v, bytes32 r, bytes32 s)
internal
pure
returns (bool)
{
(address actualSigner,,) = ECDSA.tryRecover(digest, v, r, s);
return actualSigner == receiver;
}
function getMessageHash(address receiver) public view returns (bytes32) {
if (i_snow.balanceOf(receiver) == 0) revert SA__ZeroAmount();
uint256 amount = i_snow.balanceOf(receiver);
return _hashTypedDataV4(
keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: amount})))
);
}
function getClaimStatus(address claimant) external view returns (bool) {
return s_hasClaimedSnowman[claimant];
}
}
// -------------------- Test --------------------
contract SnowmanAirdrop_TypehashPoC is Test {
MockSnow snow;
MockSnowman snowman;
SnowmanAirdrop airdrop;
uint256 receiverPk;
address receiver;
function setUp() external {
snow = new MockSnow();
snowman = new MockSnowman();
receiverPk = 0xA11CE;
receiver = vm.addr(receiverPk);
// Give receiver SNOW balance used as "amount"
snow.mint(receiver, 100e18);
// Merkle tree for a single leaf => root == leaf, proof empty
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, 100e18))));
bytes32 root = leaf;
airdrop = new SnowmanAirdrop(root, address(snow), address(snowman));
// Receiver approves airdrop to pull SNOW
vm.prank(receiver);
snow.approve(address(airdrop), type(uint256).max);
}
function test_POC_CorrectEIP712SignatureFails_InvalidSignature() external {
uint256 amount = snow.balanceOf(receiver);
// Compute what *standard tooling* would sign (correct type string, canonical spacing)
bytes32 correctTypehash = keccak256("SnowmanClaim(address receiver,uint256 amount)");
bytes32 structHashCorrect = keccak256(abi.encode(correctTypehash, receiver, amount));
// Recreate OZ EIP712 domain separator for ("Snowman Airdrop","1")
bytes32 domainSeparator = _domainSeparatorV4("Snowman Airdrop", "1", block.chainid, address(airdrop));
bytes32 digestCorrect = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHashCorrect));
// Sign the *correct* digest
(uint8 v, bytes32 r, bytes32 s) = vm.sign(receiverPk, digestCorrect);
bytes32;
// Expect revert because contract verifies against its WRONG MESSAGE_TYPEHASH digest
vm.expectRevert(SnowmanAirdrop.SA__InvalidSignature.selector);
airdrop.claimSnowman(receiver, proof, v, r, s);
}
function test_POC_SigningContractDigestSucceeds() external {
// This uses the digest the contract expects (even though it's based on the wrong type string)
bytes32 digestWrong = airdrop.getMessageHash(receiver);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(receiverPk, digestWrong);
bytes32;
airdrop.claimSnowman(receiver, proof, v, r, s);
assertTrue(airdrop.getClaimStatus(receiver));
}
function _domainSeparatorV4(
string memory name,
string memory version,
uint256 chainId,
address verifyingContract
) internal pure returns (bytes32) {
// OZ: keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")
bytes32 EIP712_DOMAIN_TYPEHASH =
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");
return keccak256(
abi.encode(
EIP712_DOMAIN_TYPEHASH,
keccak256(bytes(name)),
keccak256(bytes(version)),
chainId,
verifyingContract
)
);
}
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 12 days 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!