Normal behavior: Claims are authorized by an EIP-712 typed-data signature. Standard tooling (MetaMask signTypedData_v4, ethers/viem TypedDataEncoder, hardware wallets) derives the digest from the canonical struct type SnowmanClaim(address receiver,uint256 amount) — exact spelling, no spaces after commas.
Specific issue: The contract's type string is keccak256("SnowmanClaim(addres receiver, uint256 amount)") — it misspells address as addres and adds a space after the comma (non-canonical EIP-712 encoding). Any digest produced by a standards-compliant signer therefore differs from the contract's digest and is rejected with SA__InvalidSignature. The system only works when callers bypass typed-data tooling and manually hash via the contract's getMessageHash() view — so wallet UIs cannot display or sign the claim message, and third-party claim services/SDK integrations fail deterministically.
Root cause:
Because getMessageHash() and _isValidSignature() share the constant, on-chain checks pass with the typo'd hash — the divergence only shows against the standard, which is exactly what every external signer produces.
Likelihood:
The mismatch is deterministic and permanent: the moment anyone signs with standard EIP-712 tooling (any wallet, any SDK, the schema implied by the contract's own SnowmanClaim(address receiver, uint256 amount) struct declaration), the digest differs and the claim reverts SA__InvalidSignature — the PoC reproduces this with zero environmental assumptions.
The contract's public struct field names advertise the correct schema, so integrators will naturally construct the canonical type and hit the failure on first use.
Impact:
EIP-712 compatibility is broken: wallets cannot sign (or properly display) the claim message; recipients must rely solely on the custom getMessageHash() relay flow, defeating the purpose of typed-data signing (phishing-resistant display of what is being signed).
Off-chain signed messages produced by any standard integration are permanently invalid — no on-chain fund loss, but the signature feature is non-functional for its standard use case.
The PoC computes the digest two ways against the deployed contract and proves which one is accepted:
Standard path — build the canonical EIP-712 digest exactly as signTypedData would: type SnowmanClaim(address receiver,uint256 amount), domain (name "Snowman Airdrop", version "1", chainid, contract). Sign it → claimSnowman reverts SA__InvalidSignature.
Root-cause pinpoint — build a second digest using the typo'd type string → it equals getMessageHash(alice) byte-for-byte, proving the contract uses the misspelled type.
# 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.