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 SnowmanSnowmanAirdrop 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.
The two sides disagree permanently:
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.
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.
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.
Output:
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.
Correct the type string to the canonical EIP-712 form:
While there, getMessageHash passes a struct into abi.encode, which encodes identically here but is fragile if the struct changes. Encode the fields explicitly:
# 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)"); ```
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.