Snowman Merkle Airdrop

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

Root cause: MESSAGE_TYPEHASH misspells the EIP-712 type string as addres and adds spaces. Impact: signatures produced by standard wallets never validate, so eligible users cannot claim their Snowman

Root cause: MESSAGE_TYPEHASH misspells the EIP-712 type string as addres and adds spaces. Impact: signatures produced by standard wallets never validate, so eligible users cannot claim their Snowman

Description

SnowmanAirdrop inherits OpenZeppelin's EIP712 and requires an ECDSA signature on every claim. The comment on the constant states it is "used for EIP-712 compliant message signing", so a signature produced by any EIP-712 wallet call should validate.

The type string it hashes is not valid EIP-712. The word address is misspelled as addres, and EIP-712 encodeType forbids the whitespace that has been added inside the argument list. The canonical form is SnowmanClaim(address receiver,uint256 amount). Because the type hash differs, the digest a wallet computes never equals the digest the contract computes, and every such signature is rejected.

// src/SnowmanAirdrop.sol
@> bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(addres receiver, uint256 amount)");

The two sides disagree permanently:

type hash used by the contract: 0xff59e96f4a12fdaf4e417a1440b578f822f6cb542be1a2a7c196280bec54f9ab
type hash any wallet computes: 0x8cc878fcc8a56748c223ca472f543dc59c856aca7a2e59b34d1c2b4ef288044d

The rest of the EIP-712 setup is fine, so the type string is the only fault: rebuilding the domain separator by hand reproduces getMessageHash exactly, and swapping only the type hash back to the broken one makes the same claim succeed.

Risk

Likelihood:

  • Every user who signs through a wallet hits this, on every claim attempt, from deployment onward. eth_signTypedData_v4 produces the canonical type hash by definition, and personal_sign prefixes the payload, so both standard paths are rejected.

  • A valid signature is demanded on every claim, including when the receiver is the transaction sender, so there is no path around it for a normal user.

Impact:

  • Eligible recipients who farmed Snow across the 12 week window cannot exchange it for the NFT through any ordinary interface, and the delegated claim the README advertises, having someone claim on their behalf with v, r, s, does not work for wallet users.

  • Nothing can be repaired after deployment: MESSAGE_TYPEHASH is a compile time constant, the contract has no owner, no setter and no upgrade path, and i_merkleRoot is immutable, so the only remedy is redeploying with a new root.

Proof of Concept

The affected party is any eligible recipient, alice here, one of the five in script/flakes/input.json. The protocol side is SnowmanAirdrop, validating against a type hash no standard tool produces. There is no attacker here, the protocol simply rejects its own users. Deployed via their own Helper.s.sol.

function test_M1_CanonicalEip712SignatureIsRejected() public {
// the type hash the contract uses versus the one EIP-712 mandates
bytes32 used = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
bytes32 canonical = keccak256("SnowmanClaim(address receiver,uint256 amount)");
assertTrue(used != canonical);
// rebuild the digest exactly the way eth_signTypedData_v4 does
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)
)
);
bytes32 structHash = keccak256(abi.encode(canonical, alice, snow.balanceOf(alice)));
bytes32 walletDigest = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash));
// alice signs it with her own key, exactly as a wallet would
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alKey, walletDigest);
vm.prank(alice);
snow.approve(address(airdrop), 1);
// and the contract refuses the correctly formed signature
vm.prank(satoshi);
vm.expectRevert(SnowmanAirdrop.SA__InvalidSignature.selector);
airdrop.claimSnowman(alice, AL_PROOF, v, r, s);
}

Output:

[PASS] test_M1_CanonicalEip712SignatureIsRejected() (gas: 101629)

Three further checks close the obvious objections. A personal_sign signature over the contract's own digest is rejected too, so both standard wallet methods fail. Alice cannot claim even as the sender herself, so this is not limited to the delegated flow. And deploying the same airdrop with only the type string corrected makes the canonical signature validate, pinning the typo as the sole cause.

To be straight about the one remaining route: a user could call getMessageHash and sign that raw bytes32 directly, which is what their own test suite does with vm.sign. That is why I file this as Medium rather than higher. It is not a route ordinary users have, since MetaMask removed plain eth_sign and hardware wallets refuse to sign an opaque hash by default.

Recommended Mitigation

Correct the type string to the canonical EIP-712 form:

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

While there, getMessageHash passes a struct into abi.encode, which encodes identically here but is fragile if the struct changes. Encode the fields explicitly:

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

Lead Judging Commences

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