Snowman Merkle Airdrop

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

SnowmanAirdrop EIP-712 MESSAGE_TYPEHASH typo "addres receiver" breaks signatures from standard signers

Root + Impact

Description

  • claimSnowman supports claim-on-behalf: a third party submits the recipient's EIP-712 signature over the struct SnowmanClaim(address receiver, uint256 amount), and the contract recovers the signer to authorize the claim.

  • The on-chain MESSAGE_TYPEHASH is built from a non-canonical type string: "addres receiver" is missing the second s in address, and there is a stray space after the comma. The canonical EIP-712 encodeType string uses correctly-spelled ABI type tokens and contains no stray whitespace, so the contract's type hash — and therefore its final digest — differs from the digest any standard signer computes. Signatures produced with standard tooling never verify.

```solidity
// src/SnowmanAirdrop.sol
@> bytes32 private constant MESSAGE_TYPEHASH =
@> keccak256("SnowmanClaim(addres receiver, uint256 amount)"); // typo "addres" + stray space
```

The malformed type string makes the contract's domain-separated digest diverge from the canonical EIP-712 digest, so ecrecover returns an address that does not match the intended signer.

Risk

Likelihood:

  • Occurs every time a recipient signs with standard EIP-712 tooling such as eth_signTypedData_v4, ethers, or viem, because that tooling encodes the canonical type string SnowmanClaim(address receiver,uint256 amount), which never matches the contract's malformed type hash.

  • The claim-on-behalf flow therefore fails deterministically for any signature created the standard way.

Impact:

  • The intended claim-on-behalf functionality is broken: recipients cannot have a third party claim for them using a normal wallet-produced signature.

  • The contract is EIP-712 non-compliant, so any off-chain integration that follows the standard is incompatible with it.

Proof of Concept

A signature is produced over the canonical struct (the way every standard wallet/library does), then passed to claimSnowman. The contract rejects it because it hashed a different, malformed type string:

```solidity
function test_canonical_signature_is_rejected() public {
// canonical, correctly-spelled type hash (what ethers/viem/MetaMask use)
bytes32 canonicalTypehash = keccak256("SnowmanClaim(address receiver,uint256 amount)");
bytes32 structHash = keccak256(abi.encode(canonicalTypehash, receiver, amount));
bytes32 digest = _toTypedDataHash(airdrop.DOMAIN_SEPARATOR(), structHash);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(receiverPk, digest);

vm.expectRevert(); // SA__InvalidSignature — contract used the malformed "addres" typehash
airdrop.claimSnowman(receiver, proof, v, r, s);

}
```

Because the contract's type hash is computed from "addres receiver, uint256 amount", no signature built from the canonical type string can ever pass verification.

Recommended Mitigation

Fix the type string to the canonical EIP-712 form — correct spelling and no stray whitespace:

```diff

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