Snowman Merkle Airdrop

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

Misspelled EIP-712 type signature in MESSAGE_TYPEHASH breaks compatibility with spec-compliant off-chain signing tools

Misspelled EIP-712 type signature in MESSAGE_TYPEHASH breaks compatibility with spec-compliant off-chain signing tools

Description

`SnowmanAirdrop::MESSAGE_TYPEHASH` constant contains a typo, `addres` instead of `address`, in the EIP-712 type string used to compute the struct type hash. EIP-712 typehashes are strict, byte-exact commitments to a type signature: `keccak256` of the exact string produces a completely different hash if even a single character differs. Any wallet, SDK, or frontend library that constructs the typehash correctly per the EIP-712 specification will compute a different typehash than the one hardcoded in this contract, and the resulting signature will fail verification in `_isValidSignature()`.

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

Compare this to the `struct`:

struct SnowmanClaim {
address receiver;
uint256 amount;
}

Risk

Likelihood:

  • Medium. This isn't attacker-triggered, but it's highly likely to surface in practice. Any frontend built using standard tooling (most EIP-712 signing library codegen, or a developer manually writing the type string correctly per spec) will produce non-matching signatures, and the bug would only be caught through the exact kind of cross-check performed here rather than through normal manual testing directly rather than independently deriving the digest, meaning the existing tests can't catch this bug, since they trust the contract's own broken constant.

Impact:

Wallets, SDKs, or any tooling that independently derives the EIP-712 typehash per specification (rather than hardcoding this contract's exact non-standard string) will generate signatures that fail on-chain verification. This directly undermines the usability and interoperability of the claim flow: a user signing via a standards-compliant wallet integration would have their otherwise-valid claim rejected with `SA__InvalidSignature()`, not due to any fault of their own, but because the contract's typehash deviates from spec. Any off-chain integration must be specifically hand-coded to replicate this typo to work at all, which is fragile and easy to break during future maintenance or if the frontend is rebuilt using a standard EIP-712 library/codegen tool.

Proof of Concept

Add the following test to the `TestSnowmanAirdrop.t.sol` test suite:

function test_SpecCompliantTypehashDoesNotMatchContract() public view {
// What the contract actually uses (with the typo):
bytes32 contractTypehash = keccak256("SnowmanClaim(addres receiver, uint256 amount)");
// What any spec-compliant EIP-712 tool would independently compute
// for the same SnowmanClaim struct, per the EIP-712 canonical
// type-string rules (correct spelling, no space after comma):
bytes32 specCompliantTypehash = keccak256("SnowmanClaim(address receiver,uint256 amount)");
assertTrue(
contractTypehash != specCompliantTypehash,
"Contract typehash unexpectedly matches spec-compliant typehash"
);
}

Run the test in the terminal:

forge test --mt test_SpecCompliantTypehashDoesNotMatchContract -vvvv

If the test passes the two hashes are not the same.

Recommended Mitigation

Fix the spelling to match the EIP-712 canonical type string exactly, mirroring the actual `SnowmanClaim` `struct` definition (correct type name, no space after the comma, per EIP-712 convention):

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

Lead Judging Commences

ai-first-flight-judge Lead Judge 1 day 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!