Snowman Merkle Airdrop

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

Redundant Storage Reads/Writes in mintSnowman Loop Inflate Gas Cost

Root + Impact

Description

  • This is not a security vulnerability — no funds, access control, or correctness are at risk. The impact is purely economic: every caller of mintSnowman pays significantly more gas than necessary, scaling linearly with amount.

  • For a protocol expecting frequent or bulk minting, this translates into avoidable cost passed on to users (or to the protocol, if it subsidizes minting), and larger batch mints are more likely to approach block gas limits than they need to be.

// Root cause: s_TokenCounter, a storage variable, is both read and written
//inside the loop body on every iteration (once via _safeMint(receiver, s_TokenCounter)
/the event, once via s_TokenCounter++), instead of being cached in memory and written
back once after the loop completes. Additionally, the loop's arithmetic
(i++, s_TokenCounter++) runs through Solidity 0.8+'s default overflow-checked
arithmetic path even though both counters are provably bounded.
@> marks to highlight the relevant section

Risk

Likelihood:

  • High


Impact:

  • Gas Optimization

  • wasting a large gas

Proof of Concept

What the PoC test does: GasComparisonSnowman exposes both the original loop and the optimized loop side by side. GasComparisonTest mints the same batch size (10 / 50 / 100) through each, measures gas via gasleft() before and after each call, logs the delta, and asserts the optimized path is always cheaper (assertLt). Run with -vvv to see the actual gas numbers per batch size in the console output — the saving scales roughly linearly with amount, since it's driven by the number of storage writes eliminated.

// @title Gas Comparison PoC: Original vs Optimized mintSnowman
contract GasComparisonTest is Test {
GasComparisonSnowman token;
// Fixed receiver used for every "original" mint call across all three test functions.
address receiver = makeAddr("receiver");
function setUp() public {
// Fresh contract deployed before every test function
token = new GasComparisonSnowman();
}
// Three batch sizes to show the gas saving isn't a fixed constant
function test_GasComparison_SmallBatch_10() public {
_compare(10);
}
function test_GasComparison_MediumBatch_50() public {
_compare(50);
}
function test_GasComparison_LargeBatch_100() public {
_compare(100);
}
/// @dev Core comparison logic, shared by all three batch-size tests.
function _compare(uint256 amount) internal {
// --- Original ---
// gasleft() before and after gives the exact gas consumed by this
// specific external call (Foundry accounts for this accurately
// inside test execution).
uint256 gasBeforeOriginal = gasleft();
token.mintSnowmanOriginal(receiver, amount);
uint256 gasUsedOriginal = gasBeforeOriginal - gasleft();
// --- Optimized ---
// A *different* receiver is used here on purpose. Both mint
// functions share the same s_TokenCounter in the contract.
address receiver2 = makeAddr(string.concat("receiver2-", vm.toString(amount)));
uint256 gasBeforeOptimized = gasleft();
token.mintSnowmanOptimized(receiver2, amount);
uint256 gasUsedOptimized = gasBeforeOptimized - gasleft();
// Log results so `-vvv` output shows the actual gas numbers per
// batch size, not just pass/fail.
console.log("=== Batch size:", amount, "===");
console.log("Original gas used:", gasUsedOriginal);
console.log("Optimized gas used:", gasUsedOptimized);
console.log("Gas saved:", gasUsedOriginal - gasUsedOptimized);
console.log("--------------------------------------");
// Sanity check: both versions must still be functionally correct
assertEq(token.balanceOf(receiver), amount);
assertEq(token.balanceOf(receiver2), amount);
// The actual claim under test: optimized must strictly cost less
// gas than original for the same batch size.
assertLt(gasUsedOptimized, gasUsedOriginal);
}
}

Recommended Mitigation

Cache s_TokenCounter into a local variable before the loop, increment the local copy on each iteration, and write it back to storage exactly once after the loop finishes. Wrap the loop counter and token ID increments in an unchecked block, since both are bounded by amount and cannot overflow in practice. This reduces the loop's storage footprint from 2 × amount operations to a constant 2 (one SLOAD, one SSTORE) regardless of batch size, as implemented and benchmarked in the corrected mintSnowman above.

- remove this code
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++;
}
}
+ add this code
function mintSnowman(address receiver, uint256 amount) external {
uint256 tokenId = s_TokenCounter; // single SLOAD, cached in memory
for (uint256 i = 0; i < amount; ) {
_safeMint(receiver, tokenId);
emit SnowmanMinted(receiver, tokenId);
unchecked {
++tokenId;
++i;
}
}
s_TokenCounter = tokenId; // single SSTORE
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 1 day 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!