Snowman Merkle Airdrop

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

[M-06] Contract beneficiaries (multisigs, smart wallets) can never claim the airdrop, via two paths

Description

  • Contracts (Safe multisigs, ERC-4337 accounts) can hold tokens and be listed in the Merkle tree, so they should be able to claim like any beneficiary.

  • Two independent blocks exclude them: _isValidSignature is pure and only uses ECDSA.tryRecover, ignoring EIP-1271 (how contracts sign), so a smart wallet's valid signature is rejected; and mintSnowman uses _safeMint, which reverts for a contract without onERC721Received.

@> function _isValidSignature(...) internal pure returns (bool) {
@> (address actualSigner,,) = ECDSA.tryRecover(digest, v, r, s); // ECDSA only, no EIP-1271
return actualSigner == receiver;
}
// and in Snowman.sol:
@> _safeMint(receiver, s_TokenCounter); // requires onERC721Received

Risk

Likelihood:

  • The beneficiary is a contract (multisig / smart wallet), which is common among holders with meaningful balances.

Impact:

  • An entire class of legitimate beneficiaries is permanently excluded from the airdrop.

  • There is no user-side workaround - an account cannot become an EOA.

Proof of Concept

The test deploys an EIP-1271 smart wallet that validly approves its own signature per the standard, yet claimSnowman rejects it; a second test shows a plain contract without onERC721Received cannot receive the NFT even via the direct mint path.

Verified with Foundry (test_unaSmartWalletEip1271NoPuedeReclamar, test_unContratoSinOnErc721ReceivedNoPuedeRecibirLosNfts), forge test passing:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test, console2} 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 {Helper} from "../script/Helper.s.sol";
import {IERC1271} from "@openzeppelin/contracts/interfaces/IERC1271.sol";
/// Una smart wallet correcta: implementa EIP-1271, que es el estándar con el que los
/// contratos (multisigs, cuentas abstractas) firman mensajes.
contract SmartWallet is IERC1271 {
function isValidSignature(bytes32, bytes memory) external pure returns (bytes4) {
return IERC1271.isValidSignature.selector; // 0x1626ba7e: firma válida
}
function aprobar(Snow snow, address a, uint256 v) external {
snow.approve(a, v);
}
}
/// Un contrato normal que no espera recibir NFTs (una tesorería, por ejemplo).
contract ContratoSinReceiver {}
/// @notice Los beneficiarios que sean contratos no pueden cobrar el airdrop **por dos vías
/// independientes**, y basta con una para dejarlos fuera:
/// 1. `_isValidSignature` solo usa `ECDSA.tryRecover`: no contempla EIP-1271, así que una
/// smart wallet no puede autorizar su propio reclamo.
/// 2. `mintSnowman` usa `_safeMint`, que exige `onERC721Received`: un contrato que no lo
/// implemente hace revertir la entrega.
contract BeneficiariosContratoTest is Test {
Snow snow;
Snowman nft;
SnowmanAirdrop airdrop;
MockWETH weth;
Helper deployer;
bytes32[] AL_PROOF = [
bytes32(0xf99782cec890699d4947528f9884acaca174602bb028a66d0870534acf241c52),
bytes32(0xbc5a8a0aad4a65155abf53bb707aa6d66b11b220ecb672f7832c05613dba82af),
bytes32(0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1)
];
function setUp() public {
deployer = new Helper();
(airdrop, snow, nft, weth) = deployer.run();
}
/// [M] Sin soporte EIP-1271, una smart wallet no puede reclamar aunque firme correctamente
/// según el estándar: el contrato solo sabe recuperar firmas de EOAs.
function test_unaSmartWalletEip1271NoPuedeReclamar() public {
SmartWallet wallet = new SmartWallet();
// le damos SNOW (via earnSnow, que es gratis) para superar el check de balance y
// llegar hasta la verificación de firma, que es lo que queremos probar.
// El warp hace falta por el [M-03]: el cooldown de earnSnow es GLOBAL, y el propio
// Helper del setUp ya lo dejó en marcha. El bug se nota hasta escribiendo tests.
vm.warp(block.timestamp + 1 weeks + 1);
vm.prank(address(wallet));
snow.earnSnow();
assertEq(snow.balanceOf(address(wallet)), 1, "la wallet es beneficiaria");
wallet.aprobar(snow, address(airdrop), 1);
// la wallet valida su firma segun EIP-1271...
assertEq(
wallet.isValidSignature(bytes32(0), ""),
IERC1271.isValidSignature.selector,
"la wallet SI aprueba la firma segun el estandar"
);
// ...pero el airdrop solo mira ECDSA.tryRecover, asi que la rechaza sin remedio
vm.expectRevert(); // SA__InvalidSignature
airdrop.claimSnowman(address(wallet), AL_PROOF, 27, bytes32(uint256(1)), bytes32(uint256(2)));
assertEq(nft.balanceOf(address(wallet)), 0, "una smart wallet no puede cobrar jamas");
}
/// [M] Y aunque resolvieran lo de la firma, la entrega tambien falla: `_safeMint` exige
/// `onERC721Received`. Se prueba por la via directa (mintSnowman no tiene control de
/// acceso, ver H-02) para aislar este segundo bloqueo del anterior.
function test_unContratoSinOnErc721ReceivedNoPuedeRecibirLosNfts() public {
ContratoSinReceiver destino = new ContratoSinReceiver();
vm.expectRevert(); // ERC721InvalidReceiver
nft.mintSnowman(address(destino), 1);
assertEq(nft.balanceOf(address(destino)), 0, "no hay forma de entregarle el NFT");
}
}

Recommended Mitigation


Validate signatures with OpenZeppelin's SignatureChecker, which falls back to EIP-1271 for contract signers (this requires dropping pure):

+import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol";
-function _isValidSignature(...) internal pure returns (bool) {
- (address actualSigner,,) = ECDSA.tryRecover(digest, v, r, s);
- return actualSigner == receiver;
+function _isValidSignature(address receiver, bytes32 digest, uint8 v, bytes32 r, bytes32 s) internal view returns (bool) {
+ return SignatureChecker.isValidSignatureNow(receiver, digest, abi.encodePacked(r, s, v));
}

Decide consciously whether to support contract receivers; if so keep _safeMint and document the onERC721Received requirement, otherwise use _mint.

Updates

Lead Judging Commences

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