Snowman Merkle Airdrop

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

Malformed EIP-712 Type Hash Prevents Standard Wallet Signatures From Claiming Snowman NFTs

Root + Impact

Description

  • Under normal behavior, SnowmanAirdrop should allow an eligible user to authorize a delegated claim using a standard EIP-712 signature. A frontend or wallet should be able to construct the typed data for SnowmanClaim(address receiver,uint256 amount), have the eligible user sign it, and submit that signature to claimSnowman().

  • The issue is that the contract’s EIP-712 type hash contains a typo: it uses addres receiver instead of address receiver. As a result, standard off-chain EIP-712 tooling computes a different digest than the contract expects, so signatures created through normal wallet typed-data signing are rejected as invalid.

Root Cause

In SnowmanAirdrop.sol, the EIP-712 type hash for SnowmanClaim is malformed. The SnowmanClaim struct defines receiver as an address, but the MESSAGE_TYPEHASH string uses addres receiver instead of address receiver.

struct SnowmanClaim {
address receiver;
uint256 amount;
}
@> bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(addres receiver, uint256 amount)"); // keccak256 hash of the SnowmanClaim struct's type signature, used for EIP-712 compliant message signing

The malformed MESSAGE_TYPEHASH is then used in getMessageHash() to build the digest that the contract expects users to sign:

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})))
);
}

Because the contract hashes SnowmanClaim(addres receiver, uint256 amount) instead of the standard typed-data string SnowmanClaim(address receiver,uint256 amount), standard off-chain EIP-712 tooling computes a different digest than the contract expects.

Risk

Likelihood:

  • This occurs whenever a frontend, wallet, script, or relayer generates a standard EIP-712 signature for SnowmanClaim(address receiver,uint256 amount) and submits that signature to SnowmanAirdrop::claimSnowman().

  • This is practical because standard EIP-712 tooling uses the correctly spelled Solidity type address, while the contract expects a digest built from the malformed type string addres receiver.

Impact:

  • Eligible users who sign the standard EIP-712 typed data cannot successfully claim through the delegated claim flow because their otherwise valid signatures are rejected as invalid.

  • The protocol’s EIP-712 integration becomes incompatible with normal wallet and frontend typed-data signing, requiring custom tooling that reproduces the contract’s typo in order for signatures to validate.

Proof of Concept

The following test demonstrates that a standard EIP-712 signature for SnowmanClaim(address receiver,uint256 amount) is rejected by SnowmanAirdrop::claimSnowman().

Alice is an eligible claimant and approves the airdrop contract to transfer her Snow. The test then manually computes the standard EIP-712 digest using the correctly spelled type string SnowmanClaim(address receiver,uint256 amount) and signs it with Alice's key.

That standard signature should be accepted by a contract implementing the same EIP-712 typed data. Instead, the claim reverts with SA__InvalidSignature because the contract expects a digest built from the malformed type string SnowmanClaim(addres receiver, uint256 amount).

The test then signs the contract's malformed digest returned by getMessageHash(alice). That second signature succeeds, proving that the failure is caused by the type hash typo rather than Alice being ineligible or the Merkle proof being invalid.

Place this test in test/TestSnowmanAirdrop.t.sol.

Run with:

forge test --match-test testStandardEip712SignatureFailsDueToTypeHashTypo -vvvv
function testStandardEip712SignatureFailsDueToTypeHashTypo() public {
// Alice is an eligible claimant and approves the airdrop contract
// to transfer the 1 Snow required for her Snowman claim.
vm.prank(alice);
snow.approve(address(airdrop), 1);
// Build the standard EIP-712 domain separator.
// These values match the expected Snowman Airdrop EIP-712 domain:
// name: "Snowman Airdrop"
// version: "1"
// chainId: block.chainid
// verifyingContract: address(airdrop)
bytes32 domainSeparator = keccak256(
abi.encode(
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
keccak256(bytes("Snowman Airdrop")),
keccak256(bytes("1")),
block.chainid,
address(airdrop)
)
);
// Build the standard EIP-712 struct hash using the correctly spelled
// Solidity type: address receiver.
bytes32 structHash = keccak256(
abi.encode(keccak256("SnowmanClaim(address receiver,uint256 amount)"), alice, uint256(1))
);
// Build the standard EIP-712 digest that normal off-chain tooling
// would ask Alice to sign.
bytes32 standardDigest = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash));
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alKey, standardDigest);
// Satoshi submits Alice's standard EIP-712 signature.
// This should succeed for a correct EIP-712 implementation.
// It reverts because the contract uses the malformed type string
// "SnowmanClaim(addres receiver, uint256 amount)" instead.
vm.prank(satoshi);
vm.expectRevert(SnowmanAirdrop.SA__InvalidSignature.selector);
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
// Now sign the malformed digest that the contract actually expects.
// This digest is returned by getMessageHash(alice), which uses the
// misspelled MESSAGE_TYPEHASH.
bytes32 malformedDigest = airdrop.getMessageHash(alice);
(v, r, s) = vm.sign(alKey, malformedDigest);
// The same claim succeeds when Alice signs the malformed contract digest.
// This proves that the Merkle proof and eligibility are valid, and that
// the earlier failure was caused by the EIP-712 type hash typo.
vm.prank(satoshi);
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
// Alice receives the Snowman NFT only when the malformed digest is signed.
assert(nft.balanceOf(alice) == 1);
}

Recommended Mitigation

Correct the EIP-712 type hash so that it uses the valid Solidity type address instead of the misspelled addres.

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

The corrected type string should exactly match the struct fields and the off-chain typed data that users are expected to sign:

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

After this change, standard EIP-712 tooling can generate the same digest that SnowmanAirdrop verifies on-chain, allowing normal wallet and frontend signatures to work with claimSnowman().

Updates

Lead Judging Commences

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