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.
/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:
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.
contract GasComparisonTest is Test {
GasComparisonSnowman token;
address receiver = makeAddr("receiver");
function setUp() public {
token = new GasComparisonSnowman();
}
function test_GasComparison_SmallBatch_10() public {
_compare(10);
}
function test_GasComparison_MediumBatch_50() public {
_compare(50);
}
function test_GasComparison_LargeBatch_100() public {
_compare(100);
}
function _compare(uint256 amount) internal {
uint256 gasBeforeOriginal = gasleft();
token.mintSnowmanOriginal(receiver, amount);
uint256 gasUsedOriginal = gasBeforeOriginal - gasleft();
address receiver2 = makeAddr(string.concat("receiver2-", vm.toString(amount)));
uint256 gasBeforeOptimized = gasleft();
token.mintSnowmanOptimized(receiver2, amount);
uint256 gasUsedOptimized = gasBeforeOptimized - gasleft();
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("--------------------------------------");
assertEq(token.balanceOf(receiver), amount);
assertEq(token.balanceOf(receiver2), amount);
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
}