SnowmanAirdrop inherits OpenZeppelin's EIP712 and uses _hashTypedDataV4, which signals that claim authorisations are meant to be produced by standard typed-data signing, the flow a wallet exposes as eth_signTypedData_v4. That flow lets a wallet display the claim's fields to the user before they sign. Its entire security value depends on the wallet and the contract deriving the same digest, which EIP-712 guarantees by fixing an exact canonical form for the type string.
The declared type string deviates from that canonical form in two independent ways: the type name is spelt addres rather than address, and there is a space after the comma separating the members. EIP-712's encodeType permits neither. A wallet following the standard therefore computes a different type hash, a different struct hash and a different digest, and the signature it produces fails _isValidSignature.
The rest of the implementation is correct, which is what isolates the defect. getMessageHash encodes the struct in the standard way, and the domain separator is standard:
Because SnowmanClaim has only static members, abi.encode of the struct lays its fields out inline, so this expression is byte-identical to the standard hashStruct of keccak256(abi.encode(typeHash, receiver, amount)). The proof of concept below verifies this directly rather than assuming it: it reconstructs the contract's own digest from the malformed type string plus the standard encoding and asserts the result equals getMessageHash. That equality establishes that the type string is the only deviation from the standard, and therefore the only cause of the rejection.
The reason the project's own tests do not catch this is that they never perform typed-data signing. test/TestSnowmanAirdrop.t.sol asks the contract for the digest and signs that value directly:
A signer that derives the digest from the contract will always agree with the contract, whatever the type string says. The mismatch only appears against a signer that derives the digest from the standard, which is every real wallet.
Likelihood:
This occurs on every attempt to claim using a standards-compliant signature. There is no configuration, timing or balance under which the canonical digest matches, because the type string is a compile-time constant.
The contract's use of EIP712 and _hashTypedDataV4 advertises typed-data signing, so an integrator building the claim flow the intended way reaches this on their first attempt.
The airdrop's documented feature of claiming on someone else's behalf depends entirely on this signature path, so the affected flow is not an edge case but the reason the signature exists.
Impact:
Signatures from any standards-compliant wallet are rejected with SA__InvalidSignature, so the claim-on-behalf feature is unusable through ordinary tooling.
The only workaround is for users to sign the raw 32-byte digest returned by getMessageHash. A wallet cannot decode that into readable fields, so it presents an opaque hash and the user signs blind. This removes exactly the protection typed-data signing exists to provide, and blind signing of an arbitrary hash supplied by a third party is a well-known phishing vector, which is a security regression rather than merely an inconvenience.
Any external indexer, relayer or front end that constructs the digest from the published struct definition will produce signatures the contract rejects, so integrations break silently.
The domain separator is rebuilt through ERC-5267's eip712Domain(), exactly as a wallet would, so nothing about the digest is taken from the contract except the values the standard says to take.
Result:
The claimant is genuinely eligible, holds the correct balance, presents a valid Merkle proof, and signs with her own key. The claim still reverts with SA__InvalidSignature, solely because she signed the message the standard describes.
Correct the type string to its canonical form. This is a one-line change and requires no structural modification, since the encoding around it is already standard.
EIP-712 defines encodeType as the struct name followed by its members in parentheses, each written as the type, a single space, and the member name, separated by commas with no additional whitespace. The corrected string above is that form for this struct.
Worth adding a regression test that derives the digest independently of the contract, since a test that asks the contract for its own digest cannot detect this class of defect. Note that OpenZeppelin's EIP712 keeps _domainSeparatorV4 internal, so the separator has to be rebuilt through the public ERC-5267 eip712Domain() accessor rather than read directly:
This concerns the derivation of the signed digest and is independent of the previously reported issues. It is separate from the report on the unread claim flag, which also touches the signature: that report concerns the absence of a nonce and of a replay guard, and its fix adds a claim check and a nonce without altering the type string; this report's fix corrects the type string and adds no guard. A claim can fail here for a claimant who has never claimed before, and can succeed repeatedly there for a claimant signing the contract's own digest, so the two are independent failures of the same authorisation step.
# 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.