Zero to Hero in Foundry - Part 5: Gas Optimization


Recap
We’re on a roll! Let’s keep going!
Part 4 we dug deep into writing fuzz and invariant tests. We wrote our token mint contracts and the 1:1 swap contract which let users swap our tokens. Then we fuzz tested our swap by using Foundry’s in-built fuzzer and made sure our contracts fundamental unbreakable rules remained intact in different scenarios using invariant testing.
Please read it if you haven't already before continuing
Foundry: Zero to Hero - Part 4
Wanna learn Web3 through live and interactive challenges? You’ll love and find that Web3Compass is the best!!
Today’s Outcome
Topic of focus: Gas optimization
Code base - BatchTransfer ↗️
What is Gas and why is it required?
Create an ether batch transferring contract that’s intentionally gas intensive
Write tests for getting gas reports and taking gas snapshots
Optimize our batch transfer contract
Compare gas consumed before and after
So, What's the Deal with Gas? ⛽
Think of gas as the small fee we pay to get anything done on the Ethereum network. It’s the fuel that makes the whole system run. Every single transaction—from sending tokens to minting an NFT—requires this fuel.
We need gas for two simple reasons:
It stops the network from breaking. Without a cost, a single bad piece of code (like an infinite loop) could get stuck and freeze the entire network. Gas acts like a safety switch that cuts the power if a transaction uses too much fuel.
It pays the people who run the network. The fees go to validators who use their computers to process our transactions and keep the blockchain secure.
The Biggest Gas Guzzlers to Avoid 💸
Some actions in Solidity barely sip gas, while others chug it like there's no tomorrow. Here are the main ones to watch out for:
1. Writing to Storage (SSTORE
)
This is Public Enemy #1 for gas fees. Writing or changing data in storage is like carving something into the blockchain's permanent public record—it's a big deal and very expensive.
- Our goal: Write to storage as little as possible. If we need to use the same piece of stored data multiple times in a function, we should copy it to a memory variable first.
2. Loops (Especially Unpredictable Ones)
A loop that runs over an array in storage can be a ticking time bomb. If that array gets too big, the gas cost to loop through it can become so massive that the function is impossible to call.
- Our goal: Avoid, whenever possible, loops that run based on a storage array that can grow indefinitely.
3. Creating New Contracts (CREATE
)
Deploying a new smart contract is a heavyweight operation. It's like adding a new building to the city—a complex and costly process that consumes a lot of gas.
- Our goal: Be mindful of designs where our system needs to create tons of new contracts.
4. External Calls to Other Contracts
When our contract calls another contract, we have to pay the gas for everything that happens inside that other contract, too. It’s like picking up the tab for a friend at a very expensive restaurant.
- Our goal: Be aware of what the contracts we're calling are doing, as their gas costs become our gas costs.
The Solidity Gas Optimization Adventure 🚀
Alright, so we've covered the "what" and "why" of gas. We know it's the fuel and we know that wasting it is like setting a pile of money on fire. 🔥 Now, let's get our hands dirty and dive into the fun part: taking a horribly inefficient smart contract and turning it into a lean, mean, gas-sipping machine.
We'll be using the awesome power of Foundry to not only test our contracts but also to get concrete proof of our heroic optimization efforts. Let's begin!
Meet Our Villain: GassyBatchTransfer.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
// Importing Ownable to add an owner and increase storage reads.
import "@openzeppelin/contracts/access/Ownable.sol";
contract GassyBatchTransfer is Ownable {
constructor() Ownable(msg.sender) {}
uint256 public totalTransfersCompleted = 0;
uint256 public totalEtherSent = 0;
// A mapping to store data for each recipient.
mapping(address => uint256) public recipientTransferCounts;
// A dynamic array in storage. Pushing to this array in a loop is extremely inefficient
address[] public transferHistory;
event EtherTransferred(address indexed recipient, uint256 amount);
// Receives Ether to fund the contract for batch transfers.
receive() external payable {}
// Batch transfers Ether to multiple recipients in a highly inefficient way.
function batchTransfer(address[] memory _recipients, uint256[] memory _amounts) public payable onlyOwner {
require(_recipients.length == _amounts.length, "Recipients and amounts arrays must have the same length.");
require(_recipients.length > 0, "No recipients provided.");
for (uint256 i = 0; i < _recipients.length; i++) {
address recipient = _recipients[i];
uint256 amount = _amounts[i];
// --- Inefficient Check inside the loop ---
require(recipient != address(0), "Cannot send to the zero address.");
// Reading from storage (SLOAD) in a loop costs gas every time.
// We read the owner address here even though it doesn't change and isn't used.
address contractOwner = owner();
require(contractOwner != address(0)); // Dummy check to prevent compiler optimization
payable(recipient).transfer(amount);
totalTransfersCompleted += 1;
totalEtherSent += amount;
// Update the mapping for the recipient.
recipientTransferCounts[recipient] += 1;
// Push the recipient to the storage array. This is one of the most
// gas-intensive operations you can put in a loop.
transferHistory.push(recipient);
emit EtherTransferred(recipient, amount);
}
}
// A helper function to check the contract's current balance.
function getBalance() public view returns (uint256) {
return address(this).balance;
}
}
We built this contract with one purpose in mind: to be an absolute gas guzzler. It performs a simple task—sending Ether to a bunch of addresses at once—but it does so in the most spectacularly inefficient way imaginable.
Let's look at its main crimes against gas efficiency:
The Storage Spree: The biggest issue is inside its
for
loop. On every single pass, it writes to four different state variables (totalTransfersCompleted
,totalEtherSent
,recipientTransferCounts
, andtransferHistory
). Think of writing to storage (SSTORE
) as carving something into a giant stone tablet. It's permanent, and it takes a ton of energy. Our contract is basically chiseling a new line on that tablet for every single recipient. Ouch.Pointless Peeking: Just for good measure, we added
address contractOwner = owner();
inside the loop. This reads from storage (SLOAD
) every time. It's like re-reading the same sentence over and over again while the meter is running.The Ever-Expanding Backpack: The line
transferHistory.push(recipient)
is a classic gas-trap. Pushing to a dynamic array in storage is like stuffing another item into a backpack that might need to magically grow larger. It's a slow and costly process.Data Detour: The function takes its inputs as
address[] memory
. This tells Solidity to make a copy of all the data in memory. For data we're just reading, this is an unnecessary detour.
The Hero Arrives: OptimizedBatchTransfer.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
contract OptimizedBatchTransfer {
uint256 public totalTransfersCompleted = 0;
uint256 public totalEtherSent = 0;
address public immutable owner;
constructor() {
owner = msg.sender;
}
// Event to log each transfer. This is an efficient way to log activity.
event EtherTransferred(address indexed recipient, uint256 amount);
// Custom error definitions for better error handling and gas efficiency
error NotOwner();
error RecipientAndAmountMismatch();
error NoRecipients();
error ZeroAddress();
error InsufficientBalance();
// Receives Ether to fund the contract for batch transfers.
receive() external payable {}
// Batch transfers Ether to multiple recipients in a gas-efficient way.
function batchTransfer(address[] calldata _recipients, uint256[] calldata _amounts) onlyOwner public payable {
if (_recipients.length != _amounts.length) revert RecipientAndAmountMismatch();
if (_recipients.length == 0) revert NoRecipients();
// We use a local variable to sum up the total amount to be sent.
// Operations in memory are much cheaper than operations in storage (i.e., updating state variables).
uint256 totalAmountToSend = 0;
uint256 recipientsCount = _recipients.length; // Caching array length in a local variable saves gas on each loop access.
unchecked {
for (uint256 i = 0; i < recipientsCount; i++) {
// This check remains inside the loop for security, ensuring no funds are sent to an invalid address.
if (_recipients[i] == address(0)) revert ZeroAddress();
// Add the amount to our local sum variable.
totalAmountToSend += _amounts[i];
}
}
if (address(this).balance < totalAmountToSend) revert InsufficientBalance();
// We loop again to perform the actual transfers
for (uint256 i = 0; i < recipientsCount;) {
payable(_recipients[i]).transfer(_amounts[i]);
emit EtherTransferred(_recipients[i], _amounts[i]);
unchecked {
i++;
}
}
// This is the most critical optimization. We update the state variables only ONCE,
// after the loop has completed. This saves an enormous amount of gas compared to updating in every iteration.
unchecked {
totalTransfersCompleted += recipientsCount;
totalEtherSent += totalAmountToSend;
}
}
// A helper function to check the contract's current balance.
function getBalance() onlyOwner public view returns (uint256) {
return address(this).balance;
}
modifier onlyOwner() {
if (msg.sender != owner) revert NotOwner();
_;
}
}
Fear not! We can fix this mess. Our hero contract, OptimizedBatchTransfer.sol
, swoops in to save the day (and our Ether). It does the exact same job, but with grace and efficiency.
Here’s how we transformed our villain into a hero:
Thinking in Memory: Instead of carving into that stone tablet over and over, we do all our math on a temporary notepad (memory). We create a local variable
totalAmountToSend
and loop once to add everything up. Memory operations are thousands of times cheaper than storage operations!One Write to Rule Them All: After the loop is done, we update our state variables
totalTransfersCompleted
andtotalEtherSent
just once. We went from dozens of expensive storage writes down to two. That's a huge win!Ditching the Baggage: We completely removed the
recipientTransferCounts
mapping and thetransferHistory
array. Why? Because we can get the same information almost for free using events. Events are like shouting out a log of what happened for the off-chain world to hear, without needing to permanently store it on that expensive stone tablet.The
calldata
Express Lane: We changed the function arguments frommemory
tocalldata
. Think ofcalldata
as a direct, read-only lane for data coming into our function. It's the most efficient way to handle external inputs that we don't need to change.Speaking in Code: We swapped our
require
statements for custom errors. Not only does this make our code cleaner, but it also saves a surprising amount of gas compared to using error strings.
The Moment of Truth
Talk is cheap, but gas isn't. Let's get some hard numbers to prove our optimizations worked. This is where Foundry shines.
First let’s make sure we add the following lines to our foundry.toml
gas_reports = ["*"] # Enable gas reports for all contracts
gas_reports_ignore = [] # Don't ignore any contracts
Gas Reports
let’s writw a simple test in BatchTransfer.t.sol
that calls the function for both contracts.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
/// @title Batch Transfer Test
/// @notice This contract tests the gas usage of different batch transfer implementations.
/// Comment the GassyBatchTransfer references and uncomment the OptimizedBatchTransfer references
/// for getting gas reports of OptimizedBatchTransfer contract and vice versa.
import {Test, console} from "forge-std/Test.sol";
import {GassyBatchTransfer} from "../src/GassyBatchTransfer.sol";
//import {OptimizedBatchTransfer} from "../src/OptimizedBatchTransfer.sol";
contract BatchTransferTest is Test {
GassyBatchTransfer public gassyBatchTransfer;
//OptimizedBatchTransfer public optimizedBatchTransfer;
address[] public recipients;
uint256[] public amounts;
address public owner;
uint256 public constant NUM_RECIPIENTS = 10;
uint256 public constant AMOUNT_PER_RECIPIENT = 10 ether;
function setUp() public {
gassyBatchTransfer = new GassyBatchTransfer();
//optimizedBatchTransfer = new OptimizedBatchTransfer();
owner = address(this);
for (uint256 i = 0; i < NUM_RECIPIENTS; i++) {
// Create new, unique addresses for recipients using Foundry's `makeAddr` cheatcode.
address recipient = makeAddr(string(abi.encodePacked("recipient", vm.toString(i + 1))));
recipients.push(recipient);
amounts.push(AMOUNT_PER_RECIPIENT);
}
uint256 totalAmount = AMOUNT_PER_RECIPIENT * NUM_RECIPIENTS;
vm.deal(address(gassyBatchTransfer), totalAmount);
//vm.deal(address(optimizedBatchTransfer), totalAmount);
assertEq(address(gassyBatchTransfer).balance, totalAmount, "Initial balance not correct");
//assertEq(address(optimizedBatchTransfer).balance, totalAmount, "Initial balance not correct");
}
// Add your test functions here
function test_gas_batchTransfer() public {
// Test the gas consumption of the batch transfer function
uint256 gasUsed = gasleft();
gassyBatchTransfer.batchTransfer(recipients, amounts);
gasUsed -= gasleft();
console.log("Gas used for gassy batch transfer:", gasUsed);
emit log_named_uint("Gas used for gassy batch transfer", gasUsed);
// optimizedBatchTransfer.batchTransfer(recipients, amounts);
// gasUsed -= gasleft();
// console.log("Gas used for optimized batch transfer:", gasUsed);
// emit log_named_uint("Gas used for optimized batch transfer", gasUsed);
}
}
First, let’s see the report of out inefficient contract. To see that, we just need to run one command:
Bash
forge test --gas-report
Now we’ll un-comment our optimized contract references, comment our inefficient contract ones and run the same command as above. Foundry will run our tests and spit out a beautiful table. It's like a report card for our functions, showing the average gas cost for each.
When we look at the results, the difference is staggering. The gas cost for GassyBatchTransfer
will be ridiculously high, while OptimizedBatchTransfer
will be a fraction of the cost. This is the "Aha!" moment where we see just how much Ether we saved.
Gas Snapshots
For an even more granular view, we can use Foundry's snapshot testing. It's like taking a "before" and "after" photo of our gas costs.
Step 1: The "Before" Photo
First, we'll run a snapshot test on our gas-guzzling villain using the BatchTransferSnapshot.t.sol
file.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
/// @title Batch Transfer Snapshot Test
/// @notice This contract save the gas snapshots of different batch transfer implementations.
/// Comment the GassyBatchTransfer references and uncomment the OptimizedBatchTransfer references
/// for getting gas snapshots of OptimizedBatchTransfer contract and vice versa.
import {Test, console} from "forge-std/Test.sol";
import {GassyBatchTransfer} from "../src/GassyBatchTransfer.sol";
//import {OptimizedBatchTransfer} from "../src/OptimizedBatchTransfer.sol";
contract BatchTransferSnapshotTest is Test {
GassyBatchTransfer public gassyBatchTransfer;
//OptimizedBatchTransfer public optimizedBatchTransfer;
address[] public recipients;
uint256[] public amounts;
address public owner;
uint256 public constant NUM_RECIPIENTS = 10;
uint256 public constant AMOUNT_PER_RECIPIENT = 10 ether;
function setUp() public {
gassyBatchTransfer = new GassyBatchTransfer();
//optimizedBatchTransfer = new OptimizedBatchTransfer();
for (uint256 i = 0; i < NUM_RECIPIENTS; i++) {
// Create new, unique addresses for recipients using Foundry's `makeAddr` cheatcode.
address recipient = makeAddr(string(abi.encodePacked("recipient", vm.toString(i + 1))));
recipients.push(recipient);
amounts.push(AMOUNT_PER_RECIPIENT);
}
uint256 totalAmount = AMOUNT_PER_RECIPIENT * NUM_RECIPIENTS;
vm.deal(address(gassyBatchTransfer), totalAmount);
//vm.deal(address(optimizedBatchTransfer), totalAmount);
}
// Add your test functions here
function test_snapshot_batchTransfer() public {
// Perform the batch transfer
gassyBatchTransfer.batchTransfer(recipients, amounts);
//optimizedBatchTransfer.batchTransfer(recipients, amounts);
}
}
Bash
forge snapshot --match-path test/BatchTransferSnapshot.t.sol
This command runs the test and saves a .gas-snapshot
file, which is our "before" picture, detailing the gas usage.
Step 2: The "After" Photo and Comparison
Next, we'll quickly edit our test file to call our optimized hero contract instead, commenting out all our inefficient contract references. Then, we run the snapshot command again, but with a magic flag:
Bash
forge snapshot --diff --match-path test/BatchTransferSnapshot.t.sol
This command runs the test on our optimized code and compares it directly to the "before" photo. The output is pure gold—it will show us the exact percentage of gas we saved. Seeing a number like -49.882% isn't just satisfying; it's proof that we’ve successfully transformed our contract from a gas-guzzling monster into a lean, efficient hero. 🦸♂️
And that's a Wrap! (For now 😬)
By now we should have
Understood what gas is and why it needs to be optimized
Our gassy and optimized batch transfer contracts
Tests for getting gas reports for both contracts
Tests for getting gas snapshots of both contracts
Understood the optimization we did to achieve the -49.882% gas reduction
What's Next?
Part 6, we'll be
Writing a simple auction contract where users bid and settle based on time
Focusing majorly on more advanced tests which helps us simulate time and events
Don't miss it! I'll see you soon! 😎
Subscribe to my newsletter
Read articles from Abhiram A directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
