Snowman Merkle Airdrop

AI First Flight #10
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: medium
Likelihood: medium
Invalid

`SnowmanAirdrop::_isValidSignature` accepts only ECDSA signatures, permanently excluding contract-based Snow holders from claiming

Root + Impact

Description

The airdrop allows a third party to claim on a receiver's behalf using the receiver's ECDSA signature over an EIP-712 digest. However, ECDSA recovery can never produce a contract address, and no EIP-1271 fallback exists, so any whitelisted address that is a contract (Safe multisig, smart wallet) can never claim its NFT allocation.

// File: src/SnowmanAirdrop.sol — Lines 102–109
function _isValidSignature(address receiver, bytes32 digest, uint8 v, bytes32 r, bytes32 s)
internal
pure
returns (bool)
{
@> (address actualSigner,,) = ECDSA.tryRecover(digest, v, r, s);
@> return actualSigner == receiver; // impossible to satisfy for a contract receiver
}

ecrecover outputs EOA keys only — there is no (v, r, s) whose recovery equals a contract address, and the function is even pure, so it cannot call isValidSignature on the receiver. A whitelisted contract's claim therefore always reverts SA__InvalidSignature. Moving the Snow to an EOA does not rescue the allocation either: the Merkle leaf binds (contractAddress, amount), so the EOA's new balance matches no leaf. The NFT allocation for that address is permanently unreachable, while the Snow principal itself stays transferable.

Risk

Likelihood:

  • The operator includes any contract address (multisig, smart account, DAO treasury) in the Merkle tree — a realistic inclusion for a token-distribution snapshot.

Impact:

  • 100% of the NFT allocation of every whitelisted contract address is permanently frozen — an entire claimant class is excluded from the airdrop.

  • No on-chain recovery path exists; only regenerating the tree for different addresses could remediate.

Proof of Concept

contract DummyWallet {
function earn(Snow snow) external { snow.earnSnow(); }
}
function test_PoC_ContractHolderCannotClaim() public {
DummyWallet wallet = new DummyWallet();
vm.warp(block.timestamp + 1 weeks);
wallet.earn(snow); // the contract itself earned 1 Snow
assertEq(snow.balanceOf(address(wallet)), 1);
// any signature — even a perfectly valid one from a real EOA (alice) —
// recovers to that EOA, never to the contract, so verification always fails
bytes32 digest = airdrop.getMessageHash(address(wallet));
(uint8 v, bytes32 r, bytes32 s) = vm.sign(alKey, digest);
vm.expectRevert(SnowmanAirdrop.SA__InvalidSignature.selector);
airdrop.claimSnowman(address(wallet), AL_PROOF, v, r, s);
}

Run: add the wallet contract and the test to test/TestSnowmanAirdrop.t.sol, then forge test --match-test test_PoC_ContractHolderCannotClaim -vv

Output (actual run):

[PASS] test_PoC_ContractHolderCannotClaim() (gas: 256092)
Logs:
FC-0009: contract holder cannot claim - ecrecover can never equal the contract address

The signature check runs before the Merkle check, so the revert is attributable purely to the signature scheme: no (v, r, s) exists whose ecrecover output equals a contract address.

Recommended Mitigation

Support EIP-1271 contract signatures alongside ECDSA recovery.

function _isValidSignature(address receiver, bytes32 digest, uint8 v, bytes32 r, bytes32 s)
- internal pure returns (bool)
+ internal view returns (bool)
{
(address actualSigner,,) = ECDSA.tryRecover(digest, v, r, s);
+ if (actualSigner == receiver) return true;
+ if (receiver.code.length > 0) {
+ try IERC1271(receiver).isValidSignature(digest, abi.encodePacked(r, s, v)) returns (bytes4 magic) {
+ return magic == IERC1271.isValidSignature.selector;
+ } catch { return false; }
+ }
- return actualSigner == receiver;
+ return false;
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 19 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!