
Why Gas Optimization Matters
Ever found yourself scratching your head over exorbitant gas fees while working with Solidity smart contracts? You're not alone. In this bustling era of blockchain technology, optimizing gas costs is not just a smart move; it's a necessity for the longevity and success of your protocol. Why? Because it can slash your transaction costs by a whopping 90%, making your project more scalable, cheaper, and user-friendly.
In this guide, we're diving deep into 11 advanced, tried-and-tested strategies to shave off those pesky gas costs. We've scoured the wisdom of top-notch Web3 devs and tested dozens of tricks to bring you this ultimate checklist. So, buckle up and get ready to make your Solidity smart contracts leaner, meaner, and more efficient!
Remember, while our examples are simple for demonstration purposes, real-world contracts require thorough auditing.
The Importance of Solidity Gas Optimization
Your protocol is not just a run-of-the-mill project but a well-oiled machine that's cost-effective, efficient, and user-friendly. This isn't just a pipe dream—it's the direct result of meticulous gas optimization. You'll enjoy faster transactions, even under heavy network traffic, giving your project a competitive edge. Plus, a deeper dive into smart contract code can reveal hidden vulnerabilities, boosting security for you and your users.
Minimize On-Chain Data
One of the smartest moves in the playbook is to store less data on the blockchain. Using events to store data off-chain instead of on-chain can significantly lower your gas bills. A classic example: voting smart contracts. An inefficient contract storing each vote on-chain can eat up gas like nobody's business. Switching to an event-driven model can save an astonishing 90% in gas costs! You can take advantage of Solidity Events and once the vote is submitted and use chainlink functions to store the state off-chain.
Traditional On-Chain Voting Contract:
Here's a simplified version of a typical on-chain voting contract:
pragma solidity ^0.8.22;
contract InefficientVotingContract {
struct Vote {
address voter;
bool choice;
}
Vote[] public votes;
function castVote(bool _choice) public {
votes.push(Vote(msg.sender, _choice));
}
// Additional functions to count votes, etc.
}
In this contract each vote is stored as a Vote struct in an array called votes. The castVote function records each vote on-chain. This approach, while straightforward, consumes a lot of gas because each vote transaction modifies the state by adding a new element to the votes array.
Optimized Event-Driven Voting Contract:
Optimized Voting Contract using solidity events combined with chainlink functions: To optimize, we can emit an event for each vote and potentially use Chainlink to interact with off-chain data. Events in Ethereum are stored in transaction logs, which are significantly cheaper than storing data on-chain.
Here's an example of an optimized contract:
pragma solidity ^0.8.22;
import "@chainlink/contracts/src/v0.8/ChainlinkClient.sol";
contract OptimizedVoting is ChainlinkClient {
using Chainlink for Chainlink.Request;
event VoteCast(address indexed voter, bool choice);
// Chainlink Variables
address private oracle;
bytes32 private jobId;
uint256 private fee;
constructor() {
setPublicChainlinkToken();
oracle = 0x123...; // Oracle address
jobId = "abc123..."; // Job ID
fee = 0.1 * 10 ** 18; // Chainlink fee
}
function castVote(bool _choice) public {
emit VoteCast(msg.sender, _choice);
// Additional logic here
}
// Function to interact with Chainlink Oracle
function requestOffChainData() public returns (bytes32 requestId) {
Chainlink.Request memory request = buildChainlinkRequest(jobId, address(this), this.fulfill.selector);
// Add request parameters here
return sendChainlinkRequestTo(oracle, request, fee);
}
function fulfill(bytes32 _requestId, uint256 _data) public recordChainlinkFulfillment(_requestId) {
// Handle off-chain data here
}
}
Use Mappings Over Arrays
Mappings are your best friend when it comes to lists of data. They allow for direct access to data via unique keys, unlike arrays that require looping through elements. This not only saves gas but also streamlines your code.
Let's consider an example where using a mapping is more advantageous than an array. One common scenario is a token ledger in a cryptocurrency application, where you want to keep track of the balance of each account.
Cryptocurrency Token Ledger Scenario:
In a cryptocurrency token ledger, you need to manage the balance of each account efficiently. Using an array for this purpose would be inefficient and costly because you would have to iterate through the entire array to find or update the balance of a specific account. A mapping, on the other hand, provides direct access to the balance of any account using the account's address as a key.
Example Contract Using Mapping:
pragma solidity ^0.8.22;
contract Token {
// Mapping from account address to current balance
mapping(address => uint256) private balances;
// Function to transfer tokens from one account to another
function transfer(address _to, uint256 _amount) public {
require(balances[msg.sender] >= _amount, "Insufficient balance");
balances[msg.sender] -= _amount;
balances[_to] += _amount;
}
// Function to check the balance of an account
function balanceOf(address _account) public view returns (uint256) {
return balances[_account];
}
// Function to mint new tokens to an account
function mint(address _account, uint256 _amount) public {
// Additional checks like onlyOwner or similar could be added here
balances[_account] += _amount;
}
}
In this contract balances is a mapping that links each address to its token balance.
The transfer function allows token transfers between accounts. It directly accesses and updates the sender's and recipient's balances in the mapping without any iteration.
ThebalanceOffunction provides the balance of a specific account, again using direct access.
Themint function increases the token balance of a specific account.
Constant and Immutable Variables
Constants and immutable variables are like the secret sauce of gas optimization. They get compiled directly into the contract bytecode, which means no storage costs and lower gas fees. It's a no-brainer for values that don't change.
Optimize Unused Variables
cleaning Unused Variables
It's time for some spring cleaning! Get rid of variables that just sit there doing nothing. Removing unnecessary variables can lead to a surprising reduction in gas costs.
Gas Refund for Deleting Unused Variables
In the quirky world of Solidity, "deleting" a variable actually means resetting it to its default value. And guess what? This little trick can get you a gas refund!
Cleaning Up an Unused Storage Array Scenario:
Suppose you have a contract with a dynamically-sized array, and at some point, the data in this array is no longer needed.
Example Contract with Gas Refund:
pragma solidity ^0.8.22;
contract GasRefundExample {
// A dynamically-sized array storing user data
uint[] public userData;
// Function to add data to the array
function addData(uint data) public {
userData.push(data);
}
// Function to clear the array when the data is no longer needed
function clearData() public {
// Delete each element in the array
for (uint i = 0; i < userData.length; i++) {
delete userData[i];
}
// Reset the array length to 0
delete userData;
}
}
In this contract userData is an array that stores user data.
addData allows adding new data to userData.
`clearData`` is used to delete the data when it's no longer needed.
Gas Refund Mechanics:
When clearData is called, it iterates over the array, deleting each element. This action frees up space and generates a gas refund for each deletion.
Deleting userData itself (resetting the array length to 0) also contributes to the gas refund.
Key Points: Gas refunds are capped at half of the total gas used for a transaction. So, the maximum benefit from gas refunds is limited. Gas refunds are realized at the end of the transaction, reducing the overall gas cost. This mechanism encourages efficient use of storage on the blockchain.
Fixed-size Arrays Over Dynamic Ones
If arrays are a must, opt for fixed-size ones. Dynamic arrays, with their indefinite growth potential, can be gas guzzlers. Statically sized arrays, on the other hand, are more predictable and hence more efficient.
Example Contract Using Fixed-Size Array:
pragma solidity ^0.8.22;
contract TokenDistribution {
address[5] public recipientAddresses; // Fixed-size array of 5 addresses
uint256 public constant TOKEN_AMOUNT = 100;
// Function to set recipient addresses
function setRecipientAddress(uint index, address recipient) public {
require(index < recipientAddresses.length, "Index out of bounds");
recipientAddresses[index] = recipient;
}
// Function to distribute tokens
function distributeTokens() public {
for (uint i = 0; i < recipientAddresses.length; i++) {
// Logic to distribute TOKEN_AMOUNT tokens to recipientAddresses[i]
// For example: transfer tokens to each address
}
}
// Additional functions as needed
}
In this contract recipientAddresses is a fixed-size array of 5 elements, intended to store the addresses of the token recipients.
setRecipientAddress allows setting each recipient's address at a specific index in the array.
distributeTokens iterates over recipientAddresses to distribute a fixed amount of tokens to each address.
Advantages of Using Fixed-Size Arrays in this Scenario:
Gas Efficiency: Fixed-size arrays tend to use less gas because their size is known at compile time, leading to more optimized bytecode.
Predictability: The size of the array is constant and known, eliminating the need for dynamic memory allocation or resizing operations, which can be costly in terms of gas.
Simplicity: Fixed-size arrays are straightforward to use when the number of elements is known and fixed, making the contract code simpler and less prone to errors related to array resizing.
Remember, fixed-size arrays are ideal when the size is known and unlikely to change. If you need a collection that can change in size, a dynamic array is the appropriate choice, despite the potential for higher gas costs.
Avoid Using Lower than 256-bit Variables
The Ethereum Virtual Machine loves 256-bit integers. Using smaller types like uint8 might seem efficient, but it often leads to more complex operations, increasing gas costs.
Storing User Ages Scenario:
Let's consider a simple example where we store the ages of users. You might think using uint8 (which can store values from 0 to 255) is sufficient and more efficient for storing ages, but in terms of gas cost, uint256 could be more efficient.
Example Using uint8 (Less Efficient):
pragma solidity ^0.8.22;
contract LessEfficientAgeStorage {
mapping(address => uint8) public userAges;
function setUserAge(uint8 _age) public {
userAges[msg.sender] = _age;
}
}
In this contract, userAges is a mapping from addresses to uint8, representing the age of users. While uint8 is sufficient to store an age, the EVM operations to handle uint8 are less efficient.
Example Using uint256 (More Efficient):
pragma solidity ^0.8.22;
contract MoreEfficientAgeStorage {
mapping(address => uint256) public userAges;
function setUserAge(uint256 _age) public {
userAges[msg.sender] = _age;
}
}
In this version, userAges uses uint256. Despite uint256 being "larger" than necessary for storing ages, it's more aligned with the EVM's native word size, leading to more efficient operations and potentially lower gas costs.
Key Points:
EVM Optimization: The EVM is optimized for 256-bit operations. Even though uint8 uses less storage, operations on uint8 variables can be more gas-costly than uint256.
Type Conversion Costs: When you use types smaller than 256 bits, the EVM often has to perform implicit type conversions, which can add overhead.
Batching and Packing: Solidity tries to pack smaller types into a single 256-bit word where possible, but this can lead to complex and gas-inefficient storage layouts in certain cases.
Pack Smaller Variables Together
While it might seem counterintuitive to the earlier advice of favoring 256-bit variables, there are scenarios where using smaller variable types is advantageous. The key is to strike a balance. When you do opt for smaller types, it's crucial to group them together in storage. This method of strategic packing can lead to efficient use of storage slots and, as a result, offer significant savings in gas costs. It's about smartly managing your contract's storage footprint to achieve optimal gas efficiency.
Example:
pragma solidity ^0.8.0;
contract EfficientUserData {
struct User {
uint8 age; // 8 bits
bool isActive; // 1 bit
uint16 score; // 16 bits
uint32 balance; // 32 bits
// Total of 57 bits used so far, all can fit in a single 256-bit slot
uint256 id; // 256 bits, occupies the next full storage slot
}
mapping(address => User) public users;
// ... other functions ...
}
uint8 age, bool isActive, uint16 score, and uint32 balance are declared adjacent to each other, so they are packed into a single 256-bit storage slot. These smaller types together use only 57 bits, which fit well within a single slot.
uint256 id is placed after the smaller types and occupies a separate full storage slot.
Key Points:
Efficient Packing: By placing smaller-sized variables (uint8, uint16, uint32, bool) together at the beginning of the struct, Solidity can pack these variables into a single storage slot.
Variable Ordering: The order in which variables are declared in the struct is crucial for efficient packing. Smaller types should be grouped together for optimal packing.
Storage Slot Utilization: After the first 256 bits are used, the next variables will start occupying a new storage slot.
External Visibility Modifier
Choosing the right visibility for your functions can be a game-changer. External functions are more gas-efficient as they can read from calldata, which is cheaper than memory.
Let's consider our first example of a voting contract where users can vote for candidates. The function to cast a vote is a perfect candidate to be marked as external since it will be called from outside the contract.
pragma solidity ^0.8.0;
contract Voting {
mapping(uint256 => uint256) public votes; // CandidateID => votes
// Function for external call to cast a vote
function castVote(uint256 candidateId) external {
votes[candidateId] += 1;
}
// Other contract functions...
}
external: Most gas-efficient for functions called externally, especially with large data types.public: Versatile but less gas-efficient for external calls compared to external.internal & private: Efficient for internal calls; no calldata or memory overhead. The distinction between them is more about access control than gas efficiency.
Enable Solidity Compiler Optimization
The Solidity compiler is like a magic wand. Use it wisely! Tweak the settings, like the number of optimization runs, to find the sweet spot between deployment and runtime costs.
With hardhat Ethereum development environment you need to add optimizer entry for more details refer to Available config options
With remix editor Solidity compiler -> Advanced Configurations -> Enable optimization
Use Assembly (with Caution)
For the brave souls out there, writing code in assembly can give you more control over EVM opcodes, leading to potential gas savings. However, tread carefully as it can make your code more complex and harder to audit.
Leveraging the Latest Solidity Updates for Gas Efficiency
Staying updated with the latest Solidity versions, notably from 0.8.20 to 0.8.24, is a strategic move for gas optimization. These versions bring a host of new features and improvements, such as support for transient storage, shard blob transactions, better handling of on-chain data, and a more efficient Solidity compiler. These enhancements are focused on reducing storage and computation costs, key factors in minimizing gas fees. For developers looking to maximize these benefits, exploring the detailed documentation and changelogs of these versions is highly recommended.
Conclusion: The Road to Efficient Smart Contracts
As we pull down the curtains on our journey through Solidity gas optimization, let's take a moment to reflect. This isn't just about cutting costs – it's about sculpting a more robust, efficient, and user-friendly ecosystem in the blockchain universe. By weaving together the wisdom of seasoned Web3 devs and the latest advancements in Solidity, we've uncovered strategies that can pivot your project from just another blip on the blockchain to a powerhouse of efficiency and scalability.
Embrace these strategies, from being judicious with on-chain data to leveraging the latest Solidity features like transient storage and shard blob transactions. Remember, it's not just about the here and now; gas optimization is a commitment to the future-proofing of your smart contracts.
So, as you step back into the world of coding and development, carry these insights with you. Let them be your guiding stars in the vast cosmos of blockchain development. Here's to building a more efficient, cost-effective, and successful Web3 realm!
At Bi·Catalyst, we specialize in engineering and developing custom software tailored to your unique needs. If you have an idea you want to bring to life, don't hesitate to get in touch. with us, and let's transform your vision into reality. Your journey to bespoke software solutions begins here with Bi·Catalyst.💡



