The SnowmanAirdrop contract uses EIP-712 structured data hashing for signature verification in the claimSnowman() function. This allows a third party to claim Snowman NFTs on behalf of a recipient using the recipient's cryptographic signature. The EIP-712 standard requires that the type hash string exactly matches the struct definition, including all Solidity type names.
In SnowmanAirdrop.sol (line 49), the MESSAGE_TYPEHASH constant contains a critical typo — "addres" is used instead of "address" (missing one letter 's'):
The correct definition should be:
This typo causes the on-chain type hash to differ from what any standard EIP-712 signing library (ethers.js, viem, etc.) would produce when signing a SnowmanClaim struct with an address field. When a user signs a message off-chain using the correct "address" type, the resulting signature will not match the on-chain hash computed with the typo "addres", causing _isValidSignature() to return false and the claim to revert with SA__InvalidSignature().
This effectively breaks the delegated claiming mechanism. While a user can still potentially sign using the exact same typo to produce a matching signature, standard tooling and wallet interfaces (MetaMask, etc.) will use the correct EIP-712 type string, making the delegated claim feature unusable in practice.
Likelihood: High
Any off-chain signing tool that follows the EIP-712 specification will use the correct "address" type
Standard libraries like ethers.js, viem, and wallet providers like MetaMask all use correct type names
The mismatch between on-chain and off-chain type hashes is deterministic and affects every single delegated claim attempt
Impact: High
The delegated claiming mechanism (claiming on behalf of another user via signature) is completely broken
Users who cannot directly interact with the blockchain (e.g., using a cold wallet or through a relayer) cannot claim their Snowman NFTs
This defeats a core protocol feature described in the project specification: "have someone claim on their behalf using the recipient's v, r, s signatures"
The following demonstrates the mismatch between the contract's type hash and what a standard EIP-712 implementation would produce:
Attack scenario:
Alice earns Snow tokens and is included in the Merkle tree
Alice signs an EIP-712 message using MetaMask or ethers.js to authorize Bob to claim on her behalf
Bob calls claimSnowman() with Alice's signature
The contract computes the digest using the typo type hash, which differs from what Alice signed
_isValidSignature() returns false, and the transaction reverts with SA__InvalidSignature()
Alice's delegated claim is permanently blocked unless she uses a custom signing tool that replicates the typo
Fix the typo in the MESSAGE_TYPEHASH constant on line 49 of SnowmanAirdrop.sol:
This single-character fix restores compatibility with all EIP-712 compliant signing libraries and wallets.
# 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.