EIP-712 requires the on-chain typeHash to equal keccak256 of the canonical struct type string the wallet hashes off-chain. For struct SnowmanClaim { address receiver; uint256 amount; } the canonical string is exactly "SnowmanClaim(address receiver,uint256 amount)".
The on-chain constant misspells address as addres and adds an EIP-712-illegal space after the comma, so MetaMask/viem/ethers produce digests against a different typehash. The on-chain recovery never matches the wallet-signed digest.
Likelihood:
Reason 1: Every standards-compliant off-chain signer (MetaMask eth_signTypedData_v4, viem, ethers, ledger) hashes the struct definition SnowmanClaim(address receiver,uint256 amount) — the contract hashes a different string.
Reason 2: Wallets reject signing if you ask them to sign the typo'd type, because addres is not a valid Solidity type — so the protocol cannot even instruct users to "sign this broken type."
Impact:
Impact 1: 100% of standard claim attempts revert with SA__InvalidSignature — full DoS of the airdrop.
Impact 2: If the team works around it with a custom raw-keccak signer, users must blind-sign opaque digests outside the wallet's typed-data UX — phishing surface.
The PoC builds two parallel digests: one with the canonical EIP-712 typehash (which every conformant wallet produces) and one with the on-chain typo'd typehash. We sign Alice's claim with the canonical typehash — i.e., we simulate exactly what eth_signTypedData_v4 would emit. When that signature is submitted to claimSnowman, the contract recomputes the digest using its broken typehash, recovers a different signer, and reverts. The expected revert proves the two paths can never agree, so no real wallet user can ever pass the signature gate.
The fix is purely the string contents — restoring the canonical EIP-712 type encoding: the type name address spelled correctly and no whitespace inside the parameter list (per EIP-712 §"Definition of encodeType"). This change must ship before any signatures are distributed to users, because old signatures and new signatures hash to different digests and existing test-suite signatures (if any) will all need regeneration.
Follow-up: regenerate every test signature, document that claimants should use standard eth_signTypedData_v4, and add a CI check that the type string round-trips through keccak256 to a known-correct constant.
# 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.