Snowman Merkle Airdrop

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

Malformed EIP-712 typehash in SnowmanAirdrop.MESSAGE_TYPEHASH makes every standard typed-data wallet signature fail, so holders can't claim through the standard signing flow

MESSAGE_TYPEHASH hashes a misspelled, non-standard type string, so no EIP-712 wallet signature can ever verify and standard-flow claims always revert

Description

SnowmanAirdrop inherits OpenZeppelin's EIP712 and builds the claim digest with _hashTypedDataV4 (src/SnowmanAirdrop.sol:26, :119-121). The comment on its typehash says it is "used for EIP-712 compliant message signing". But the string it hashes isn't the struct's EIP-712 type string. It misspells address as addres and adds a space after the comma:

Type string keccak256
The contract (:49) SnowmanClaim(addres receiver, uint256 amount) 0xff59e96f…54f9ab
EIP-712, as forge eip712 derives it from the struct at :36-39 SnowmanClaim(address receiver,uint256 amount) 0x8cc878fc…88044d

A wallet answering an eth_signTypedData_v4 request derives the type string from the struct definition, so it signs a different digest from the one getMessageHash returns. claimSnowman always checks the signature against getMessageHash (:80-82), and _isValidSignature has no other digest to try (:102-109), so a valid signature from the receiver is rejected with SA__InvalidSignature.

No typed-data request can be shaped to produce the contract's digest, under EIP-712's encodeType rule. A type string is the struct name, then type name for each member joined by ,, then every other struct it references, appended. The literal has one ( and ends at the first ), so nothing can be appended to it. The only struct a member's type could then be is SnowmanClaim itself, and no way of splitting the literal gives a member that type. However the literal is split into members, the first member's type is addres or a longer string containing a space or comma, and none of those is a valid EIP-712 type. A second constraint holds even for a lenient encoder: the contract hashes three words (typehash, receiver, amount), and each EIP-712 member encodes to one word, so a matching type needs exactly two members.

// src/SnowmanAirdrop.sol
@> contract SnowmanAirdrop is EIP712, ReentrancyGuard { // :26, claims EIP-712
...
struct SnowmanClaim { // :36-39, the struct being signed
address receiver;
uint256 amount;
}
...
@> bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(addres receiver, uint256 amount)"); // :49
...
@> if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) { // :80, every claim is checked against it
revert SA__InvalidSignature();
}
...
function getMessageHash(address receiver) public view returns (bytes32) {
...
return _hashTypedDataV4(
@> keccak256(abi.encode(MESSAGE_TYPEHASH, SnowmanClaim({receiver: receiver, amount: amount}))) // :120
);
}

The domain is correct: eip712Domain() reports it, and the digest uses it. Only the type string is wrong, and as a compiled-in constant it can't be changed after deployment.

On the code as written, every claim needs an accepted signature from the receiver, because claimSnowman has no route that skips the check. So there's no other claim route for a holder whose wallet signs the standard way. The only signature that works is one over the raw output of getMessageHash, which is what the repo's own test does with vm.sign. The README doesn't tell users that's required: it only says claims use "the recipient's v, r, s signatures" (README.md:33).

Risk

Likelihood: High. It happens on every claim made through a wallet's standard typed-data flow, for every listed account, from deployment on. No attacker is needed. On the committed allowlist, all five accounts get 0 NFTs through typed data, and 5 once they sign the raw digest instead (test_F002_everyAccountOnTheCommittedListIsRejected below).

Impact: Medium. Holders on the standard path can't claim, which disrupts the protocol's main function. No funds are lost: a rejected claim moves nothing, and the Snow stays with the holder. The one working route needs a signer that will sign a raw 32-byte hash:

  • Context, not reproduced in the PoC below: cast wallet sign --no-hash with a software key signs the raw digest, and in the audit's own tests its output claimed, while cast wallet sign --data over the typed data was rejected. The PoC's raw-digest control signs the same digest with vm.sign.

  • MetaMask's improvement proposal MIP-3 says eth_sign "was disabled by default, but could be re-enabled in MetaMask's settings", and proposes removing it entirely. It's listed as Accepted, which doesn't show the setting has been removed, and it also names older MetaMask versions and alternative wallets as remaining options.

  • MetaMask's signing docs say Ledger and Trezor sign data only with personal_sign. By reading, that signs a prefixed hash ("\x19Ethereum Signed Message:\n32" plus the digest), not the digest the contract checks, so it can't produce an accepted signature. So those users have no raw route either.

So in practice the users who can claim are those with a command-line signer and a software key, or a wallet that still allows raw signing. The project ships no frontend, but the harm doesn't depend on one: a valid EIP-712 signature from the receiver is rejected, and that's the adverse effect of the non-compliance.

These two axes give the High/Medium band in the CodeHawks matrix, and this report claims Medium.

Proof of Concept

Save the file below as test/F002MalformedTypehash.t.sol in the contest repository and run, from the repository root (no fork needed):

forge test --match-path test/F002MalformedTypehash.t.sol -vvv

It imports only the repository's own src/ and script/ files, forge-std, and OpenZeppelin from lib/. It deploys the system in its own setUp with the repo's DeploySnowmanAirdrop script, so all the contracts are the real ones, with the committed allowlist, and nothing is mocked or stubbed. The wallet's digest comes from Foundry's EIP-712 encoder (vm.eip712HashTypedData), given the eth_signTypedData_v4 JSON a frontend would send: the struct exactly as SnowmanAirdrop declares it, and the domain read from the airdrop's own eip712Domain(). No wallet application was run; the encoder stands in for one. The defect is a string literal, so it doesn't depend on the OpenZeppelin version. Actors:

  • Attacker: none. The harm is a rejected claim.

  • Victim: alice, the committed list's first account, signing the standard way.

  • Protocol: SnowmanAirdrop, with Snow and Snowman. relayer is a neutral third party who submits claims.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
​
// Save as test/F002MalformedTypehash.t.sol in the contest repository, then run:
// forge test --match-path test/F002MalformedTypehash.t.sol -vvv
// It imports only the repository's own src/ and script/ files, forge-std and OpenZeppelin from lib/.
​
import {Test, console2} from "forge-std/Test.sol";
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {Snow} from "../src/Snow.sol";
import {Snowman} from "../src/Snowman.sol";
import {SnowmanAirdrop} from "../src/SnowmanAirdrop.sol";
import {DeploySnowmanAirdrop} from "../script/DeploySnowmanAirdrop.s.sol";
​
/// @notice The committed allowlist, copied from script/flakes/output.json: the five fixture accounts, their leaf amount
/// and their proofs against the root DeploySnowmanAirdrop uses.
library Allowlist {
bytes32 internal constant ROOT = 0xc0b6787abae0a5066bc2d09eaec944c58119dc18be796e93de5b2bf9f80ea79a;
uint256 internal constant LEAF_AMOUNT = 1;
uint256 internal constant SIZE = 5;
​
function accountName(uint256 i) internal pure returns (string memory) {
if (i == 0) return "alice";
if (i == 1) return "bob";
if (i == 2) return "clara";
if (i == 3) return "dan";
return "eli";
}
​
function proof(uint256 i) internal pure returns (bytes32[] memory p) {
p = new bytes32[](3);
if (i == 0) {
p[0] = 0xf99782cec890699d4947528f9884acaca174602bb028a66d0870534acf241c52;
p[1] = 0xbc5a8a0aad4a65155abf53bb707aa6d66b11b220ecb672f7832c05613dba82af;
p[2] = 0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1;
} else if (i == 1) {
p[0] = 0x51c4b9a3cc313d7d7325f2d5d9e782a5a484e56a38947ab7eea7297ec86ff138;
p[1] = 0xbc5a8a0aad4a65155abf53bb707aa6d66b11b220ecb672f7832c05613dba82af;
p[2] = 0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1;
} else if (i == 2) {
p[0] = 0x0065f7c9c934093ee1c4d51b77e77ad69d1c21351298d21cc720df18a39412f5;
p[1] = 0xe4f70a2d0da3e6c29810b3eb84deeae82d06479d602b0e64225458c968f98cc1;
p[2] = 0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1;
} else if (i == 3) {
p[0] = 0xc7c84a70b50ff4103e9a8b3a716b446a138a507fc1b65ebdfae38439e52b2612;
p[1] = 0xe4f70a2d0da3e6c29810b3eb84deeae82d06479d602b0e64225458c968f98cc1;
p[2] = 0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1;
} else {
p[2] = 0xd7ed3892547c15a926b49d400e13fefe2c9f08de658f08b09925d5790383e978;
}
}
}
​
/// @title F-002 proof: a holder's standard typed-data signature can't claim, because MESSAGE_TYPEHASH is malformed.
/// @notice The contracts come from the repo's own wiring, DeploySnowmanAirdrop. Nothing is mocked or stubbed.
/// Reachability:
/// - vm.deal gives a buyer the listed fee in ETH, the ordinary way to hold Snow.
/// - vm.prank(holder) is a holder's own buySnow or approve, both open to anyone.
/// - vm.prank(relayer) is a third party calling claimSnowman, which has no caller check.
/// - vm.sign is the ECDSA step inside a wallet, using a public test key.
/// - vm.eip712HashTypedData is the encoding step inside a wallet, given the eth_signTypedData_v4 JSON a frontend
/// sends: the struct exactly as SnowmanAirdrop declares it, and the domain read from the airdrop's eip712Domain().
/// Actors:
/// - Attacker: none. The harm is a rejected claim, not a theft.
/// - Victim: alice, the committed allowlist's first account, signing the standard way.
/// - Protocol: SnowmanAirdrop (with the malformed MESSAGE_TYPEHASH), with Snow and Snowman. relayer is a neutral
/// third party who submits claims.
contract F002MalformedTypehashTest is Test {
/// @dev Everything the oracle compares before and after the trigger.
struct State {
uint256 receiverSnow; // receiver's Snow
uint256 receiverNfts; // receiver's NFTs
uint256 allowance; // receiver's allowance to the airdrop
uint256 lockedSnow; // Snow held by the airdrop
uint256 nftSupply; // Snowman.getTokenCounter()
bool claimed; // getClaimStatus(receiver)
}
​
struct Sig {
uint8 v;
bytes32 r;
bytes32 s;
}
​
Snow private snow; // Protocol
Snowman private nft; // Protocol
SnowmanAirdrop private airdrop; // Protocol
​
address private alice; // Victim
uint256 private aliceKey; // Victim's signing key
address private relayer; // a neutral submitter
bytes32[] private aliceProof; // Victim's proof on the committed list
​
function setUp() public {
(airdrop, snow, nft) = new DeploySnowmanAirdrop().deploySnowmanAirdrop(); // Protocol: the repo's own deployment
// Public test key keccak256("alice"), whose address is entry 0 of script/flakes/output.json.
(alice, aliceKey) = makeAddrAndKey("alice"); // Victim: a listed holder
assertEq(alice, 0x328809Bc894f92807417D2dAD6b7C998c1aFdac6, "alice is the committed list's first entry"); // she is on the committed list
relayer = makeAddr("relayer"); // a neutral submitter
aliceProof = new bytes32[](3); // entry 0's proof in script/flakes/output.json
aliceProof[0] = 0xf99782cec890699d4947528f9884acaca174602bb028a66d0870534acf241c52; // step 1
aliceProof[1] = 0xbc5a8a0aad4a65155abf53bb707aa6d66b11b220ecb672f7832c05613dba82af; // step 2
aliceProof[2] = 0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1; // step 3
}
​
/// @dev The eth_signTypedData_v4 JSON a frontend sends a wallet for a claim of `amount` by `receiver`.
function _typedDataRequest(address target, address receiver, uint256 amount) private view returns (string memory) {
(, string memory name, string memory version, uint256 chainId, address verifyingContract,,) =
SnowmanAirdrop(target).eip712Domain(); // the domain, read from the airdrop itself (ERC-5267)
return string.concat( // the request, piece by piece
'{"types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},',
'{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],',
'"SnowmanClaim":[{"name":"receiver","type":"address"},{"name":"amount","type":"uint256"}]},', // the struct as declared
'"primaryType":"SnowmanClaim","domain":{"name":"', name, '","version":"', version,
'","chainId":', vm.toString(chainId), ',"verifyingContract":"', vm.toString(verifyingContract),
'"},"message":{"receiver":"', vm.toString(receiver), '","amount":"', vm.toString(amount), '"}}'
);
}
​
/// @dev What a wallet returns for the frontend's eth_signTypedData_v4 request.
function _walletSignature(address target, address who, uint256 key)
private
view
returns (bytes32 walletDigest, Sig memory sig)
{
walletDigest = vm.eip712HashTypedData(_typedDataRequest(target, who, 1)); // a wallet's eth_signTypedData_v4 digest
sig = _sign(key, walletDigest); // and its signature over it
}
​
function _buySnow(Snow token, address who) private {
uint256 fee = token.s_buyFee(); // the listed price
vm.deal(who, fee); // the buyer holds the fee
vm.prank(who); // the buyer calls
token.buySnow{value: fee}(1); // buys one unit, the leaf amount
}
​
/// @dev Every gate except the signature is open afterwards: leaf balance held, airdrop approved.
function _holdAndApprove(Snow token, address who, address spender) private {
_buySnow(token, who); // hold the leaf amount
vm.prank(who); // the holder calls
token.approve(spender, 1); // approves the airdrop
}
​
function _sign(uint256 key, bytes32 digest) private pure returns (Sig memory sig) {
(sig.v, sig.r, sig.s) = vm.sign(key, digest); // sign the digest as given
}
​
/// @dev The revert has to be SA__InvalidSignature: this fails on any other revert, or on none.
function _expectRejected(SnowmanAirdrop target, address caller, address receiver, bytes32[] memory proof, Sig memory sig)
private
{
vm.prank(caller); // the caller submits
vm.expectRevert(SnowmanAirdrop.SA__InvalidSignature.selector); // only this revert passes
target.claimSnowman(receiver, proof, sig.v, sig.r, sig.s); // the claim
}
​
function _state(address who) private view returns (State memory s) {
s.receiverSnow = snow.balanceOf(who);
s.receiverNfts = nft.balanceOf(who);
s.allowance = snow.allowance(who, address(airdrop));
s.lockedSnow = snow.balanceOf(address(airdrop));
s.nftSupply = nft.getTokenCounter();
s.claimed = airdrop.getClaimStatus(who);
}
​
function _assertUnchanged(State memory before, State memory current, string memory label) private pure {
assertEq(current.receiverSnow, before.receiverSnow, string.concat(label, ": receiver's Snow moved"));
assertEq(current.receiverNfts, before.receiverNfts, string.concat(label, ": receiver got an NFT"));
assertEq(current.allowance, before.allowance, string.concat(label, ": allowance was spent"));
assertEq(current.lockedSnow, before.lockedSnow, string.concat(label, ": Snow was locked"));
assertEq(current.nftSupply, before.nftSupply, string.concat(label, ": NFT supply moved"));
assertEq(current.claimed, before.claimed, string.concat(label, ": claim flag moved"));
}
​
function _log(string memory label, State memory s) private pure {
console2.log(label);
console2.log(" receiver Snow:", s.receiverSnow);
console2.log(" receiver NFTs:", s.receiverNfts);
console2.log(" allowance to the airdrop:", s.allowance);
console2.log(" Snow locked in the airdrop:", s.lockedSnow);
console2.log(" NFT supply:", s.nftSupply);
console2.log(" getClaimStatus:", s.claimed);
}
​
/// @dev The finding's oracle, on the committed allowlist. The two attempts differ only in the digest signed:
/// same holder, proof, balance and allowance.
function test_F002_oracle_walletSignatureRevertsAndTheSameClaimOverTheRawDigestSucceeds() public {
_holdAndApprove(snow, alice, address(airdrop)); // Victim: holds her leaf amount and approves
​
(bytes32 walletDigest, Sig memory walletSig) = _walletSignature(address(airdrop), alice, aliceKey); // Victim signs the standard way
bytes32 contractDigest = airdrop.getMessageHash(alice); // the digest the Protocol checks
console2.log("digest the wallet signs: ", vm.toString(walletDigest)); // log: the standard digest
console2.log("digest the contract checks:", vm.toString(contractDigest)); // log: the contract's digest
​
// The signature is valid: made by the receiver, over the digest the standard defines. The contract's own
// library raises no complaint about it either, so a low-s or v problem can't explain the rejection.
assertEq(ecrecover(walletDigest, walletSig.v, walletSig.r, walletSig.s), alice, "signed by the receiver"); // the signature is hers
(address signer, ECDSA.RecoverError err,) = ECDSA.tryRecover(walletDigest, walletSig.v, walletSig.r, walletSig.s); // the contract's own library
assertEq(signer, alice, "ECDSA.tryRecover agrees on the signer"); // agrees on the signer
assertEq(uint8(err), uint8(ECDSA.RecoverError.NoError), "ECDSA.tryRecover reports no error"); // and finds nothing wrong
assertNotEq(contractDigest, walletDigest, "A6 is false: the contract's digest isn't the standard one"); // the digests differ
​
State memory before = _state(alice); // snapshot
_log("BEFORE", before); // log it
​
// Both ways a claim can arrive: through a third party, and from the holder.
_expectRejected(airdrop, relayer, alice, aliceProof, walletSig); // relayed: rejected
_expectRejected(airdrop, alice, alice, aliceProof, walletSig); // self-submitted: rejected
​
State memory rejected = _state(alice); // snapshot after both
_log("AFTER the wallet signature, relayed and self-submitted", rejected); // log it
_assertUnchanged(before, rejected, "rejected claim"); // nothing moved
assertEq(rejected.receiverSnow, 1, "the holder keeps the Snow"); // Snow not taken
assertEq(rejected.receiverNfts, 0, "and has no NFT"); // but no NFT
​
// Control: only the signed digest changes. This is what `cast wallet sign --no-hash` produces
// (measured separately); eth_sign would sign the same digest, but that was not measured.
Sig memory rawSig = _sign(aliceKey, contractDigest); // Victim signs the contract's raw digest instead
vm.prank(relayer); // the relayer submits
airdrop.claimSnowman(alice, aliceProof, rawSig.v, rawSig.r, rawSig.s); // the same claim
​
State memory claimed = _state(alice); // snapshot after the claim
_log("AFTER a signature over the raw getMessageHash output", claimed); // log it
assertEq(claimed.receiverSnow, 0, "the Snow left the holder"); // Snow staked
assertEq(claimed.lockedSnow, 1, "and is locked in the airdrop"); // and locked
assertEq(claimed.receiverNfts, 1, "the NFT arrived"); // the NFT arrived
assertTrue(claimed.claimed, "the claim was recorded"); // and the claim was recorded
}
​
/// @dev "Any listed receiver": all five accounts on the committed list hit this, then all five clear it by
/// signing the raw digest, so nothing about their accounts or proofs was in the way.
function test_F002_everyAccountOnTheCommittedListIsRejected() public {
for (uint256 i = 0; i < Allowlist.SIZE; i++) { // each of the five listed accounts
(address who, uint256 key) = makeAddrAndKey(Allowlist.accountName(i)); // Victim and key
_holdAndApprove(snow, who, address(airdrop)); // holds its leaf amount and approves
(, Sig memory sig) = _walletSignature(address(airdrop), who, key); // signs the standard way
_expectRejected(airdrop, relayer, who, Allowlist.proof(i), sig); // rejected
}
assertEq(nft.getTokenCounter(), 0, "no listed account got an NFT through its wallet"); // no NFTs
assertEq(snow.balanceOf(address(airdrop)), 0, "and no Snow moved"); // nothing locked
console2.log("listed accounts claiming through typed data, NFTs minted:", nft.getTokenCounter()); // log: 0
​
for (uint256 i = 0; i < Allowlist.SIZE; i++) { // the same five accounts
(address who, uint256 key) = makeAddrAndKey(Allowlist.accountName(i)); // Victim and key
Sig memory sig = _sign(key, airdrop.getMessageHash(who)); // signs the raw digest instead
vm.prank(relayer); // the relayer submits
airdrop.claimSnowman(who, Allowlist.proof(i), sig.v, sig.r, sig.s); // the claim
assertEq(nft.balanceOf(who), 1, "the raw digest clears the same account"); // it claims
}
assertEq(nft.getTokenCounter(), Allowlist.SIZE, "all five claim once the signed digest changes"); // five NFTs
console2.log("the same accounts signing the raw digest, NFTs minted:", nft.getTokenCounter()); // log: 5
}
}

Output, from a clean copy of the repository at commit 6efcb0f with its own foundry.toml:

[PASS] test_F002_oracle_walletSignatureRevertsAndTheSameClaimOverTheRawDigestSucceeds() (gas: 898171)
Logs:
digest the wallet signs: 0x7d7285c4a39f25824c37e05030ddf21d4c5e4b8af022b2f691369004091d00d8
digest the contract checks: 0xee53487b488818391abb2d3f3df391d44c1ff19f977e0287f4cbbd9a66c284c9
BEFORE
receiver Snow: 1
receiver NFTs: 0
allowance to the airdrop: 1
Snow locked in the airdrop: 0
NFT supply: 0
getClaimStatus: false
AFTER the wallet signature, relayed and self-submitted
receiver Snow: 1
receiver NFTs: 0
allowance to the airdrop: 1
Snow locked in the airdrop: 0
NFT supply: 0
getClaimStatus: false
AFTER a signature over the raw getMessageHash output
receiver Snow: 0
receiver NFTs: 1
allowance to the airdrop: 0
Snow locked in the airdrop: 1
NFT supply: 1
getClaimStatus: true
[PASS] test_F002_everyAccountOnTheCommittedListIsRejected() (gas: 2384845)
Logs:
listed accounts claiming through typed data, NFTs minted: 0
the same accounts signing the raw digest, NFTs minted: 5

The wallet signs 0x7d7285c4…091d00d8, while the contract checks 0xee53487b…a66c284c9. Alice's typed-data signature is hers, and OpenZeppelin's ECDSA.tryRecover finds no fault with it. Yet the claim reverts whether a relayer submits it or she does, and nothing moves. The same claim signed over the raw getMessageHash output succeeds. Changing only line 49 to the EIP-712 type string makes the two digests equal.

test_F002_everyAccountOnTheCommittedListIsRejected repeats this for all five accounts on the committed allowlist: 0 NFTs through typed data, then 5 once each signs the raw digest instead.

Recommended Mitigation

Use the struct's EIP-712 type string:

- bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(addres receiver, uint256 amount)"); // keccak256 hash of the SnowmanClaim struct's type signature, used for EIP-712 compliant message signing
+ bytes32 private constant MESSAGE_TYPEHASH = keccak256("SnowmanClaim(address receiver,uint256 amount)"); // keccak256 hash of the SnowmanClaim struct's type signature, used for EIP-712 compliant message signing

Notes:

  • Tested: this change was checked on a patched copy of SnowmanAirdrop. The same wallet signature now claims, relayed or self-submitted, and so does cast wallet sign --data output. All five listed accounts claim, and signing getMessageHash output directly still works.

  • Tests: stop signing getMessageHash output directly in the repo's tests. Add one that compares MESSAGE_TYPEHASH with the value forge eip712 derives, and one that signs real encoder output.

  • Deployment: the constant is compiled in and nothing is upgradeable, so fixing a deployed airdrop means deploying a new one.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 18 minutes 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!