Snowman Merkle Airdrop

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

`Snowman::mintSnowman` performs a costly storage write to `s_TokenCounter` on every loop iteration making batch NFT minting unnecessarily expensive for users

Root + Impact

Description

mintSnowman reads from and writes to the s_TokenCounter storage slot on every loop iteration. Each s_TokenCounter++ executes an SSTORE opcode — the first write in a transaction costs 2,900 gas, and each subsequent dirty-slot write costs 100 gas. Because s_TokenCounter only needs its final post-loop value to be persisted, all intermediate writes are avoidable.

The same result can be achieved by reading s_TokenCounter once before the loop into a local variable, iterating with that local variable, and writing the accumulated final value back to storage exactly once after the loop completes.

// src/Snowman.sol
function mintSnowman(address receiver, uint256 amount) external {
for (uint256 i = 0; i < amount; i++) {
_safeMint(receiver, s_TokenCounter);
emit SnowmanMinted(receiver, s_TokenCounter);
@> s_TokenCounter++; // SSTORE on every iteration
}
}

Risk

Likelihood:

  • Every call to mintSnowman with amount > 1 pays this unnecessary cost — the SnowmanAirdrop contract calls mintSnowman(receiver, amount) where amount equals the receiver's Snow token balance, meaning any user with more than 1 Snow token triggers the expensive path.

  • Batch minting is a core protocol operation because Snowman NFTs are distributed according to a user's Snow token balance. Therefore the issue is guaranteed to occur whenever multiple NFTs are minted in a single transaction.

Impact:

  • Users pay more gas than necessary when claiming multiple Snowman NFTs.

  • Large batch mints become increasingly expensive due to repeated storage writes.


Proof of Concept

The test calls mintSnowman for a batch of 10 tokens and measures the gas consumed via gasleft() deltas before and after the call. The inline annotations document the per-iteration opcode breakdown: two warm SLOADs at 100 gas each for the _safeMint and emit arguments, one first-write SSTORE at 2,900 gas on iteration zero, and one dirty-slot SSTORE at 100 gas on each of the nine subsequent iterations — totalling approximately 3,800 gas in avoidable storage writes and 2,000 gas in redundant reads for a 10-token batch. The second test function documents the estimated saving of approximately 1,800 gas for 10 tokens that the single-read-single-write optimised version would achieve, with the saving growing linearly with batch size.

To run: forge test --match-test test_BatchMintGasCost -vvvv

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {Snowman} from "src/Snowman.sol";
contract PoC_CostlyLoop is Test {
Snowman snowman;
Snowman snowmanOptimized;
address receiver = makeAddr("receiver");
function setUp() public {
snowman = new Snowman("data:image/svg+xml;base64,dummyUri");
}
function test_BatchMintGasCost() public {
uint256 amount = 10;
uint256 gasBefore = gasleft();
snowman.mintSnowman(receiver, amount);
uint256 gasUsed = gasBefore - gasleft();
// Log the gas cost of the current implementation
// Each iteration performs:
// - 2 SLOADs on s_TokenCounter (for _safeMint arg + emit arg): 2 × 100 gas
// - 1 SSTORE on s_TokenCounter (s_TokenCounter++):
// First iteration: 2,900 gas
// Iterations 2-10: 100 gas each
// Total SSTORE overhead: 2,900 + (9 × 100) = 3,800 gas
// Total SLOAD overhead: 10 × 200 = 2,000 gas (vs 1 × 100 with cache = 100 gas)
// Unnecessary overhead vs optimised version: ~5,700 gas for 10 tokens
emit log_named_uint("Gas used (current, 10 tokens)", gasUsed);
}
function test_GasComparisonCurrentVsOptimised() public {
// Current implementation — 10 tokens
uint256 gasBefore = gasleft();
snowman.mintSnowman(makeAddr("receiver1"), 10);
uint256 currentGas = gasBefore - gasleft();
// The optimised version would look like:
// uint256 tokenId = s_TokenCounter; // 1 SLOAD
// for loop { _safeMint(receiver, tokenId); emit...; tokenId++; } // MLOAD/MSTORE only
// s_TokenCounter = tokenId; // 1 SSTORE
//
// This replaces:
// 10 SLOADs (100 gas each) = 1,000 gas → 1 SLOAD = 100 gas
// 10 SSTOREs (2900+9×100) = 3,800 gas → 1 SSTORE = 2,900 gas
// Saving: ~(900 + 900) = ~1,800 gas for 10 tokens
emit log_named_uint("Current gas for 10 tokens", currentGas);
emit log_named_uint("Estimated saving with cache", 1800);
}
}

Recommended Mitigation

A more gas-efficient approach is to cache s_TokenCounter into a local uint256 tokenId variable before the loop begins. Use and increment tokenId (a memory variable) inside each loop iteration. Write the final value back to s_TokenCounter storage exactly once after the loop completes.

function mintSnowman(address receiver, uint256 amount) external {
+ uint256 tokenId = s_TokenCounter; // Single SLOAD — read once into memory
for (uint256 i = 0; i < amount; i++) {
- _safeMint(receiver, s_TokenCounter);
- emit SnowmanMinted(receiver, s_TokenCounter);
- s_TokenCounter++;
+ _safeMint(receiver, tokenId);
+ emit SnowmanMinted(receiver, tokenId);
+ tokenId++;
}
+ s_TokenCounter = tokenId; // Single SSTORE — write once after all mints
}
Updates

Lead Judging Commences

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