Snowman Merkle Airdrop

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

Unbounded Loop in mintSnowman Can Revert Claims Due to Out-of-Gas


Root + Impact

Description

Under normal operation, mintSnowman(receiver, amount) should mint a number of Snowman NFTs proportional to a user’s entitlement (e.g., derived from staked or held Snow tokens) and complete successfully in a single claim transaction.

The issue is that mintSnowman performs an unbounded linear loop over amount, and each iteration calls _safeMint (storage writes + optional ERC721Receiver callback). As amount grows, gas usage grows linearly until the transaction exceeds the block gas limit and reverts, making large mints practically unclaimable.

function mintSnowman(address receiver, uint256 amount) external {
for (uint256 i = 0; i < amount; i++) { // @> unbounded loop controlled by `amount`
_safeMint(receiver, s_TokenCounter); // @> expensive per-iteration mint (storage writes + callback)
emit SnowmanMinted(receiver, s_TokenCounter); // @> additional per-iteration log cost
s_TokenCounter++; // @> storage write every iteration
}
}

Risk

Likelihood:

  • amount scales directly with entitlement (e.g., token balance) and grows without a protocol-enforced ceiling, so large holders naturally produce large amount values.

  • Claims that mint to contract receivers (or receivers that implement onERC721Received) incur additional per-mint gas overhead, making the revert threshold easier to hit even at smaller amount values.

Impact:

  • Eligible users with large amount values are unable to claim because the mint transaction repeatedly reverts due to out-of-gas / block gas limit constraints.

  • Claiming becomes unreliable and can be used to create operational/UX DoS conditions for “whales” or any user whose entitlement grows over time.

Proof of Concept

  • Shows a small mint succeeds under a fixed gas stipend

  • Shows a larger mint fails under the same gas stipend

  • Optionally shows the failure happens sooner when minting to a contract (because _safeMint triggers onERC721Received)

// SP
DX-License-Identifier: MIT
pragma solidity ^0.8.24;
/*
forge test -vv
This PoC makes the issue deterministic by:
- calling mintSnowman using a FIXED gas stipend via low-level call
- proving small `amount` succeeds under that gas
- proving larger `amount` fails under the SAME gas
- demonstrating that minting to a contract receiver consumes more gas (safeMint callback),
so it fails at smaller amounts.
NOTE: This PoC focuses ONLY on the "unbounded loop -> out of gas" vector.
Access control is intentionally ignored.
*/
import "forge-std/Test.sol";
import {Snowman} from "../src/Snowman.sol"; // adjust import path to your repo
contract ERC721ReceiverMock {
// Standard ERC721Receiver selector
function onERC721Received(address, address, uint256, bytes calldata)
external
pure
returns (bytes4)
{
return 0x150b7a02;
}
}
contract Snowman_OOG_PoC is Test {
Snowman snowman;
address eoaReceiver;
ERC721ReceiverMock contractReceiver;
function setUp() external {
snowman = new Snowman("data:image/svg+xml;base64,AAA=");
eoaReceiver = makeAddr("eoaReceiver");
contractReceiver = new ERC721ReceiverMock();
}
function test_SmallAmount_Succeeds_UnderFixedGas() external {
// Fixed gas stipend
uint256 gasStipend = 6_000_000;
// Small amount should fit under the stipend
uint256 smallAmount = 10;
(bool ok, bytes memory ret) = address(snowman).call{gas: gasStipend}(
abi.encodeWithSelector(Snowman.mintSnowman.selector, eoaReceiver, smallAmount)
);
// Must succeed
assertTrue(ok, _revertMsg(ret));
assertEq(snowman.getTokenCounter(), smallAmount);
}
function test_LargeAmount_Fails_UnderSameFixedGas() external {
// Same fixed gas stipend as the success case
uint256 gasStipend = 6_000_000;
// Larger amount pushes gas usage past stipend (linear scaling)
uint256 largeAmount = 5_000;
(bool ok, bytes memory ret) = address(snowman).call{gas: gasStipend}(
abi.encodeWithSelector(Snowman.mintSnowman.selector, eoaReceiver, largeAmount)
);
// Must fail due to running out of gas under the stipend
assertFalse(ok, "expected failure (gas exhaustion), but call succeeded unexpectedly");
// Optional: if you want, you can log the revert data
emit log_string(_revertMsg(ret));
}
function test_ContractReceiver_FailsEarlier_DueToSafeMintCallback() external {
uint256 gasStipend = 6_000_000;
// Two calls under the same stipend:
// - EOA receiver mints more cheaply (no ERC721Receiver callback)
// - Contract receiver costs more per mint due to onERC721Received
uint256 amount = 800; // tune if needed for your environment
// EOA receiver attempt
(bool okEoa,) = address(snowman).call{gas: gasStipend}(
abi.encodeWithSelector(Snowman.mintSnowman.selector, eoaReceiver, amount)
);
// Reset state by redeploying (so tokenCounter doesn't affect the second run)
snowman = new Snowman("data:image/svg+xml;base64,AAA=");
// Contract receiver attempt
(bool okContract,) = address(snowman).call{gas: gasStipend}(
abi.encodeWithSelector(Snowman.mintSnowman.selector, address(contractReceiver), amount)
);
// It’s common for okEoa to be true while okContract is false for the same `amount`
// because _safeMint triggers the callback.
// If both pass or both fail, increase/decrease `amount` until divergence is observed.
assertTrue(okEoa || okContract, "both failed; lower amount to observe divergence");
assertTrue(okEoa != okContract, "expected divergence; tune amount to show contract receiver fails earlier");
}
function _revertMsg(bytes memory ret) internal pure returns (string memory) {
if (ret.length < 68) return "call reverted (possibly out-of-gas)";
assembly {
ret := add(ret, 0x04)
}
return abi.decode(ret, (string));
}
}

Recommended Mitigation

+ uint256 public constant MAX_MINT_PER_TX = 50;
function mintSnowman(address receiver, uint256 amount) external {
+ // Prevent out-of-gas reverts by bounding work per transaction
+ require(amount != 0, "zero amount");
+ require(amount <= MAX_MINT_PER_TX, "amount too large");
+
for (uint256 i = 0; i < amount; i++) {
_safeMint(receiver, s_TokenCounter);
emit SnowmanMinted(receiver, s_TokenCounter);
s_TokenCounter++;
}
}

Note: If the protocol must support large entitlements, replace the hard cap with batched minting (e.g., mintSnowman(receiver, count) callable multiple times) while still enforcing a safe per-call maximum.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 12 days 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!