MESSAGE_TYPEHASH is not a valid EIP-712 encodeType string, so the digest the contract verifies against is unreachable through any standards-compliant signerSnowmanAirdrop inherits OpenZeppelin's EIP712 so that claimants sign structured, human-readable typed data. The point of EIP-712 is that the wallet derives the typehash from the struct definition and arrives at the same digest the contract computes, letting the user see what they are approving.
The hardcoded MESSAGE_TYPEHASH string is not a valid encodeType: the type is misspelled addres and there is a stray space after the comma. Every compliant library derives a different hash, so a signature produced through normal wallet flows can never satisfy _isValidSignature.
EIP-712 defines encodeType as name ‖ "(" ‖ member₁ ‖ "," ‖ member₂ ‖ … ‖ ")", with each member written as type ‖ " " ‖ name and no other whitespace, and restricts atomic types to bytes1..bytes32, uint8..uint256, int8..int256, bool and address. The string above breaks the rule twice, independently.
The canonical form and the two resulting hashes:
OpenZeppelin's own ERC20Permit, vendored in this repository, shows the correct form for comparison:
The rest of the implementation is conformant — rebuilding the digest with the malformed typehash and a flat abi.encode(typehash, receiver, amount) reproduces getMessageHash() byte for byte, which isolates the encodeType string as the sole deviation.
Verified against the actual libraries rather than assumed:
ethers v6 TypedDataEncoder throws unknown type "addres".
viem hashTypedData throws Type "addres" is not a valid encoding type.
Even a library that tolerated the typo would emit no space after the comma, so it would still produce a different hash.
Likelihood:
Every attempt to sign this struct through eth_signTypedData_v4 — the standard path used by MetaMask, ethers and viem — fails outright, because the libraries reject the type string before a signature is ever produced. The failure is deterministic, not probabilistic.
Every claim goes through this check. claimSnowman demands a valid signature unconditionally at src/SnowmanAirdrop.sol:80, including when the receiver claims for themselves, so there is no path around it.
Falling back to personal_sign also fails, because it applies the EIP-191 prefix and yields a different digest again. eth_sign, which would work, is disabled by default in MetaMask and deprecated.
Impact:
The "have someone claim on your behalf" flow described in the README is unusable through normal wallet tooling, and a default-configuration MetaMask user cannot complete a claim at all.
The only remaining workaround is to have the user sign the raw hash returned by getMessageHash() — precisely the blind-signing pattern users are trained to refuse, since nothing on screen describes what is being authorised.
Because addres is not a type, wallets cannot render the struct meaningfully even in principle, so the human-readable guarantee EIP-712 exists to provide is lost.
Stated so the severity is not overread: no assets are at risk and no unauthorised action is enabled by this. getMessageHash() is public view (src/SnowmanAirdrop.sol:112), so the correct digest is obtainable on-chain and can be signed by a script or a hardware-wallet tool. That bounded impact is why I have set Impact to Low; the deterministic failure across every standard client is why Likelihood is High. A judge who considers a broken primary user flow to be Medium impact would land this one tier higher, and I would not argue against that.
Save as test/PoCM02.t.sol and run forge test --match-contract PoCM02 -vv. The test builds the digest exactly as a compliant client would, using the contract's own domain from ERC-5267, and shows it is rejected.
Result:
TestSnowmanAirdrop::testClaimSnowman passes only because it calls airdrop.getMessageHash(alice) and signs the contract's own (non-conformant) digest. It never validates against the EIP-712 standard, so the suite cannot detect this class of defect.
Correct the type string. Optionally also skip the signature requirement for self-claims, since msg.sender == receiver already proves authorisation.
# 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.