Snowman Merkle Airdrop

AI First Flight #10
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Severity: high
Valid

SnowmanAirdrop::claimSnowman - Typo in MESSAGE_TYPEHASH ("addres" vs "address") permanently invalidates all delegated claim signatures

Root + Impact

Description

  • SnowmanAirdrop supports delegated claiming: a recipient can sign a message authorizing a third party to call claimSnowman() on their behalf. This relies on EIP-712 signature verification through _isValidSignature().

  • The MESSAGE_TYPEHASH constant contains a typo — "addres" instead of "address" — causing the on-chain digest to permanently diverge from any correctly generated off-chain signature. ECDSA.tryRecover will always return an address that does not match receiver, causing every delegated claim to revert with SA__InvalidSignature.

// @> "addres" is misspelled — should be "address"
bytes32 private constant MESSAGE_TYPEHASH =
keccak256("SnowmanClaim(addres receiver, uint256 amount)");

Risk

Likelihood:

  • Every user who attempts to use the delegated claim path will encounter this failure

  • The bug is deterministic: the incorrect hash is hardcoded as an immutable constant and cannot be corrected post-deployment

Impact:

  • The delegated claim feature is completely non-functional from the moment of deployment

  • Recipients who cannot submit transactions themselves (e.g., due to gas constraints) are permanently locked out of the airdrop

Proof of Concept

The following test shows that the hash produced by the contract's misspelled type
string and the hash produced by the correct EIP-712 type string are always unequal.
Because ECDSA.tryRecover uses the contract's digest, any signature generated by a
standards-compliant client will resolve to the wrong signer address, causing
_isValidSignature() to permanently return false.

function test_typoBreaksAllDelegatedClaims() public {
bytes32 correctHash = keccak256("SnowmanClaim(address receiver, uint256 amount)");
bytes32 contractHash = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
// The two hashes are always different
assertNotEq(correctHash, contractHash);
// Any signature produced by a correct EIP-712 client will always fail:
// ECDSA.tryRecover returns the wrong signer → actualSigner != receiver → SA__InvalidSignature
}

Recommended Mitigation

The fix is a one-character correction to the type string constant. Because
MESSAGE_TYPEHASH is an immutable constant, no storage changes or architectural
modifications are required — correcting the spelling makes the on-chain digest match
the output of any EIP-712 compliant signing client.

bytes32 private constant MESSAGE_TYPEHASH =
- keccak256("SnowmanClaim(addres receiver, uint256 amount)");
+ keccak256("SnowmanClaim(address receiver, uint256 amount)");
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 20 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[H-02] Unconsistent `MESSAGE_TYPEHASH` with standart EIP-712 declaration on contract `SnowmanAirdrop`

# 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)"); ```

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!