Snowman Merkle Airdrop

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

Typo in EIP‑712 Type String (`"addres"`) Breaks Compatibility with Standard Signing Libraries

Medium Severity: Typo in EIP‑712 Type String ("addres") Breaks Compatibility with Standard Signing Libraries

Severity: Medium (functional flaw – signatures generated by standard tools will be invalid)
Impact: The MESSAGE_TYPEHASH constant contains a misspelled Solidity type name ("addres" instead of "address"). This causes any signature created with a correct, standards‑compliant EIP‑712 implementation to be rejected by the contract. Users relying on common wallets or libraries (e.g., ethers.js, viem) may be unable to claim their airdrop.
Affected Contract: SnowmanAirdrop.sol


Summary

EIP‑712 defines a precise method for encoding structured data for signing. The type string used in the MESSAGE_TYPEHASH must match the Solidity parameter types exactly. The contract declares:

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

The typo "addres" (missing the second s) deviates from the correct "address". Consequently, the hash of the type definition differs from the one used by any standard EIP‑712 implementation. Signatures produced by widely‑used tooling (Metamask, ethers _signTypedData, viem signTypedData) will be built using the correct type string and will fail the _isValidSignature check on‑chain.


Vulnerability Details

The contract verifies a signature using:

function getMessageHash(address receiver) public view returns (bytes32) {
uint256 amount = i_snow.balanceOf(receiver);
return _hashTypedDataV4(
keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: amount})))
);
}

MESSAGE_TYPEHASH is the keccak256 hash of the string "SnowmanClaim(addres receiver, uint256 amount)". A correct implementation would use "SnowmanClaim(address receiver,uint256 amount)" (note the space differences also matter, but the critical error is the misspelling of address). Any external signing library that builds the type hash from the struct’s ABI definition will generate a different value, and the final digest will not match the one computed by the contract. Therefore, all signatures created off‑chain using proper EIP‑712 libraries will be rejected.


Proof of Concept

  1. Deploy the contract as is.

  2. Use a standard EIP‑712 signing library (e.g., ethers v6) to sign a SnowmanClaim(address receiver,uint256 amount) message for a valid receiver.

  3. Call claimSnowman with the resulting signature. The transaction will revert with SA__InvalidSignature().

  4. Modify the constant to keccak256("SnowmanClaim(address receiver,uint256 amount)") and repeat – the signature now passes.

A Foundry test can demonstrate the mismatch:

function test_TypoPreventsStandardSignature() public {
address receiver = address(0x1234);
// Generate signature using correct type string (simulated with a known private key)
bytes32 correctTypeHash = keccak256("SnowmanClaim(address receiver,uint256 amount)");
bytes32 digest = airdrop._hashTypedDataV4(keccak256(abi.encode(correctTypeHash, SnowmanClaim(receiver, 100))));
(uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, digest); // sign with receiver's key
vm.prank(receiver);
vm.expectRevert(SnowmanAirdrop.SA__InvalidSignature.selector);
airdrop.claimSnowman(receiver, merkleProof, v, r, s);
}

The test will confirm that the signature is invalid despite being correctly formed for the expected struct.


Impact

  • Failed claims: Users who generate signatures using standard wallets or libraries (the most common approach) will be unable to claim their airdrop, causing frustration and potential loss of opportunity.

  • Requires custom, non‑standard signing code: The protocol would need to instruct users to manually construct a malformed type string, which is highly error‑prone and non‑trivial for non‑technical participants.

  • Degraded user experience: Breaks the seamless interaction expected from a properly implemented EIP‑712 scheme.


Recommended Mitigation

Correct the MESSAGE_TYPEHASH constant to the proper type string:

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

Note: The exact spacing inside the parentheses may also need to match the standard (commonly no space after the comma), but the critical fix is changing "addres" to "address". After this change, any compliant EIP‑712 library will produce signatures that pass verification.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 3 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!