Snowman Merkle Airdrop

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

SnowmanAirdrop::claimSnowman has no replay protection: s_hasClaimedSnowman is set but never checked and the signed message has no nonce

Root + Impact

Description

A successful claim sets s_hasClaimedSnowman[receiver] = true, but that mapping is never read inside claimSnowman to block a repeat claim; it is only exposed through the getClaimStatus getter. Separately, the signed EIP-712 message commits only to (receiver, amount) with no nonce and no deadline, and the signature is never consumed on use. So a single signature stays valid and can be replayed to mint again whenever the receiver holds the committed amount.

// src/SnowmanAirdrop.sol
@> mapping(address => bool) private s_hasClaimedSnowman; // declared as a guard
function claimSnowman(...) external nonReentrant {
@> // no check: if (s_hasClaimedSnowman[receiver]) revert SA__AlreadyClaimed();
...
@> s_hasClaimedSnowman[receiver] = true; // written, but never read as a guard
}

Risk

Today a same-state double claim is blocked only incidentally, because the full balance is transferred out. Once M-01 is fixed to transfer a fixed amount, that side effect disappears and any receiver who re-acquires the committed amount can replay the same signature; the message has no nonce or deadline, so a captured signature never expires.

Impact: The one-claim-per-eligible-entry invariant is not enforced, allowing repeated mints and Snowman supply inflation. A leaked or replayed signature drives additional mints without fresh consent from the receiver.

Proof of Concept

Self-contained Foundry test. A single-leaf tree commits alice to a snapshot amount of 1. Alice signs one claim; it is then replayed after she re-acquires the committed amount, minting a second NFT from the same signature. This proves both defects at once: the claim guard is never checked, and the signature has no nonce so its digest is unchanged.

function setUp() public {
(alice, aliceKey) = makeAddrAndKey("alice");
snow = new Snow(makeAddr("weth"), 5, makeAddr("collector"));
nft = new Snowman("uri");
bytes32 root = keccak256(bytes.concat(keccak256(abi.encode(alice, uint256(1)))));
airdrop = new SnowmanAirdrop(root, address(snow), address(nft)); // commits alice to amount 1
}
function testOneSignatureMintsTwice() public {
bytes32[] memory emptyProof = new bytes32[](0);
// Alice signs ONE claim authorization while holding the committed amount (1).
deal(address(snow), alice, 1);
vm.prank(alice);
snow.approve(address(airdrop), 1);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(aliceKey, airdrop.getMessageHash(alice));
airdrop.claimSnowman(alice, emptyProof, v, r, s); // claim #1
assertEq(nft.balanceOf(alice), 1);
assertTrue(airdrop.getClaimStatus(alice)); // marked claimed...
// Alice re-acquires the same amount; the SAME signature is replayed.
deal(address(snow), alice, 1);
vm.prank(alice);
snow.approve(address(airdrop), 1);
// ...but the guard is never checked and the digest is unchanged (no nonce), so it mints again.
airdrop.claimSnowman(alice, emptyProof, v, r, s);
assertEq(nft.balanceOf(alice), 2); // two NFTs from ONE signature
}

Result: [PASS] testOneSignatureMintsTwice().

Recommended Mitigation

Enforce the existing claim guard at the start of claimSnowman, and add a per-account nonce and a deadline to the signed message so each authorization can be used only once and only before it expires. Extend SnowmanClaim and MESSAGE_TYPEHASH with uint256 nonce and uint256 deadline, include them in the digest, and increment the nonce on each successful claim.

+ error SA__AlreadyClaimed();
function claimSnowman(address receiver, ...) external nonReentrant {
+ if (s_hasClaimedSnowman[receiver]) revert SA__AlreadyClaimed();
...
}
Updates

Lead Judging Commences

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