Snowman Merkle Airdrop

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

EIP-712 typehash misspells 'address' as 'addres', so standard typed-data signatures fail verification

Root + Impact

Description

// src/SnowmanAirdrop.sol - the EIP-712 type string is misspelled
@> bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
@> function getMessageHash(address receiver) public view returns (bytes32) {
@> uint256 amount = i_snow.balanceOf(receiver);
@> ...
@> return _hashTypedDataV4(keccak256(abi.encode(MESSAGE_TYPEHASH, receiver, amount)));
@> }
​
MESSAGE_TYPEHASH hashes "SnowmanClaim(addres receiver, uint256 amount)" - "addres" is missing its final "s". The signed type therefore does not match the struct it claims to describe. Any signer that builds EIP-712 typed data the standard way, with the correct type "SnowmanClaim(address receiver, uint256 amount)" (for example a wallet using eth_signTypedData_v4), produces a different digest, and _isValidSignature() returns false, so claimSnowman() reverts with SA__InvalidSignature(). The defect is hidden by the project's own tests, which sign the digest returned by getMessageHash() directly with vm.sign (test/TestSnowmanAirdrop.t.sol, _signMessage), so they never exercise standard typed-data signing.
​
Impact: low. Delegated claims, claiming on someone else's behalf, which the README advertises, break for standard wallet signers. The only workaround is to sign the raw digest, which most wallets do not offer as a normal flow.

Risk

Likelihood:

Impact:

Proof of Concept

// Explanation: a standard EIP-712 signer and the contract hash different type strings, so the signature the contract expects can only be produced by copying the typo.
// 1. Alice holds 1 Snow and is a leaf (aliceSB, 1).
// 2. Alice's wallet builds EIP-712 typed data for SnowmanClaim with the
// correct type string "SnowmanClaim(address receiver, uint256 amount)"
// and signs it, producing (v, r, s).
// 3. Anyone calls claimSnowman(aliceSB, 1, proof, v, r, s).
// 4. The contract hashes "SnowmanClaim(addres receiver, uint256 amount)",
// so the recovered signer differs from Alice and the call reverts
// with SA__InvalidSignature().
// 5. The same signature made over the contract's misspelled type string
// would verify - the two digests differ only because of the typo.
// The project's tests sign getMessageHash() directly with vm.sign
// (test/TestSnowmanAirdrop.t.sol), so standard typed-data signing is
// never tested.

Recommended Mitigation

Explanation: spell the type correctly so the hashed type matches the struct.
- bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
+ bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(address receiver, uint256 amount)");
​
Note: this changes the digest, so any signatures created against the old (misspelled) type string become invalid. Apply the fix before any real claims are signed, or ask signers to re-sign.
Updates

Lead Judging Commences

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