Description
The intended behavior of the airdrop is that any address — EOA or contract — included in the Merkle tree with a valid (address, amount) leaf can claim their NFT by presenting a valid Merkle proof and a valid signature from that address. The Merkle proof validation is address-agnostic: any 20-byte value can be a leaf, including contract addresses, and MerkleProof.verify will accept it.
The specific issue is that the signature layer uses ECDSA.tryRecover, which by construction can only ever return an EOA address derived from a secp256k1 public key. It is mathematically impossible for ECDSA.tryRecover to return a contract address, because contract addresses are derived from keccak256(rlp([deployer, nonce])) (CREATE) or keccak256(0xff ++ deployer ++ salt ++ keccak256(init_code)) (CREATE2), never from a secp256k1 public key. The _isValidSignature check therefore always fails for contract wallets, regardless of what (v, r, s) is supplied. The Merkle proof layer says "this contract is whitelisted" while the signature layer says "this contract can never authenticate." The two layers disagree, and the contract wallet is permanently excluded — with no error message indicating why, since the revert is the generic SA__InvalidSignature.
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);
}
if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s))
revert SA__InvalidSignature();
Risk
Likelihood:
Reason 1: The airdrop's Merkle tree is built from off-chain data (see GenerateInput.s.sol, SnowMerkle.s.sol). The deployer scripts do not filter contract addresses — they accept whatever input addresses are provided. If a multisig (Gnosis Safe), a treasury wallet, or any smart-contract wallet is included in the input list, its leaf is included in the Merkle root and a valid proof can be produced for it.
Reason 2: This condition occurs whenever any contract address is whitelisted in the Merkle tree — there is no special configuration required beyond including a contract in the input list, and whitelisting contract wallets (e.g., project treasury multisigs) is a common real-world practice.
Impact:
Impact 1: Contract wallets whitelisted in the Merkle tree are permanently unable to claim. They cannot produce a valid ECDSA signature, so the claim reverts on every attempt. The tokens they hold and the approval they granted to the airdrop contract are stranded, and the NFT allocation assigned to them in the Merkle tree is effectively burned.
Impact 2: The error message SA__InvalidSignature is misleading — it implies the signature parameters are wrong, when in fact no valid signature can ever exist for a contract address. This leads to wasted debugging time, support burden, and a degraded trust relationship with whitelisted project partners who hold funds in multisigs.
Proof of Concept
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {Snow} from "../src/Snow.sol";
import {Snowman} from "../src/Snowman.sol";
import {SnowmanAirdrop} from "../src/SnowmanAirdrop.sol";
import {MockWETH} from "../src/mock/MockWETH.sol";
import {Merkle} from "murky/src/Merkle.sol";
import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
interface IERC20 {
function approve(address, uint256) external returns (bool);
}
contract SimpleWallet {
function doApprove(address token, address spender, uint256 amount) external {
IERC20(token).approve(spender, amount);
}
}
contract H04_ContractWallet is Test {
Snow public snow;
Snowman public snowman;
MockWETH public weth;
Merkle public merkleLib;
address public alice;
uint256 public aliceKey;
address public bob;
uint256 public bobKey;
function setUp() public {
merkleLib = new Merkle();
(alice, aliceKey) = makeAddrAndKey("alice");
(bob, bobKey) = makeAddrAndKey("bob");
weth = new MockWETH();
snow = new Snow(address(weth), 5, makeAddr("collector"));
snowman = new Snowman("data:image/svg+xml;base64,test");
vm.prank(alice); snow.earnSnow();
vm.warp(block.timestamp + 1 weeks);
vm.prank(bob); snow.earnSnow();
}
function _leaf(address a, uint256 amt) internal pure returns (bytes32) {
return keccak256(bytes.concat(keccak256(abi.encode(a, amt))));
}
function test_contractWalletCannotClaimDespiteValidProof() public {
SimpleWallet wallet = new SimpleWallet();
vm.prank(alice);
snow.transfer(address(wallet), 1);
bytes32[] memory leaves = new bytes32[](2);
leaves[0] = _leaf(address(wallet), 1);
leaves[1] = _leaf(bob, 1);
bytes32 root = merkleLib.getRoot(leaves);
bytes32[] memory proof = merkleLib.getProof(leaves, 0);
assertTrue(MerkleProof.verify(proof, root, leaves[0]));
SnowmanAirdrop a = new SnowmanAirdrop(root, address(snow), address(snowman));
wallet.doApprove(address(snow), address(a), 1);
vm.prank(address(wallet));
vm.expectRevert(SnowmanAirdrop.SA__InvalidSignature.selector);
a.claimSnowman(address(wallet), proof, 27, bytes32(uint256(1)), bytes32(uint256(1)));
assertEq(snowman.balanceOf(address(wallet)), 0);
}
function test_eoaInSameTreeCanClaim() public {
bytes32[] memory leaves = new bytes32[](2);
leaves[0] = _leaf(alice, 1);
leaves[1] = _leaf(bob, 1);
bytes32 root = merkleLib.getRoot(leaves);
bytes32[] memory proofBob = merkleLib.getProof(leaves, 1);
SnowmanAirdrop a = new SnowmanAirdrop(root, address(snow), address(snowman));
vm.prank(bob); snow.approve(address(a), type(uint256).max);
bytes32 digest = a.getMessageHash(bob);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(bobKey, digest);
a.claimSnowman(bob, proofBob, v, r, s);
assertEq(snowman.balanceOf(bob), 1);
}
}
Recommended Mitigation
// SnowmanAirdrop.sol — _isValidSignature()
- 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);
- }
+ import {IERC1271} from "@openzeppelin/contracts/interfaces/IERC1271.sol";
+
+ function _isValidSignature(address receiver, bytes32 digest, bytes memory signature)
+ internal
+ view
+ returns (bool)
+ {
+ if (receiver.code.length > 0) {
+ // Contract wallet: follow EIP-1271.
+ return IERC1271(receiver).isValidSignature(digest, signature) ==
+ IERC1271.isValidSignature.selector;
+ }
+ // EOA: legacy ECDSA path.
+ if (signature.length != 65) return false;
+ bytes32 r;
+ bytes32 s;
+ uint8 v;
+ assembly {
+ r := mload(add(signature, 0x20))
+ s := mload(add(signature, 0x40))
+ v := byte(0, mload(add(signature, 0x60)))
+ }
+ (address actualSigner, , ) = ECDSA.tryRecover(digest, v, r, s);
+ return (actualSigner == receiver);
+ }
// SnowmanAirdrop.sol — claimSnowman() signature changes:
- function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
+ function claimSnowman(address receiver, uint256 amount, bytes32[] calldata merkleProof, bytes calldata signature)
external
nonReentrant
{
if (receiver == address(0)) revert SA__ZeroAddress();
if (amount == 0) revert SA__ZeroAmount();
- if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s))
+ if (!_isValidSignature(receiver, getMessageHash(receiver, amount), signature))
revert SA__InvalidSignature();
// ... remainder unchanged
}
The fix replaces the ECDSA-only path with an EIP-1271-aware verifier that delegates to the receiver contract's isValidSignature for contract wallets, while preserving the legacy ECDSA path for EOAs. This unifies the assumptions of the Merkle layer (any address) and the signature layer (any address that can authenticate).