The development of the Ethereum contracts requires a focus not only on the aspects of its functionality but also on efficiency and cost-effectiveness. Often, an essential missing element is an optimization in the use of storage and memory, which directly influences gas costs. This article delves into six Solidity design patterns that would reduce your smart contract’s storage costs significantly.
1. Uint* vs Uint256:
The use of unsigned integers
is a common area of optimization. The Ethereum Virtual Machine (EVM) operates on 256-bit strings, which means that any operation, including those on smaller unsigned integer values (such as uint8
, uint16
, etc.), will eventually be converted to 256 bits during compile-time even though it looks like a minor task because each conversion requires computational resources, such an operation leads to unnecessary gas expenditures.
To mitigate over-expenditure, we recommend developers use unsigned integers that are smaller or equal to 256 bits (e.g., uint128
or uint64
) when they aim to pack multiple variables into a single 256-bit storage slot, leveraging the Variable Packing pattern to optimize the contract’s storage. This approach allows various minor variables to coexist within a single storage/memory slot. It helps reduce storage requirements significantly and results in more efficient contracts. Nevertheless, when variable packing is not applicable, sticking to uint256
is more gas-efficient due to the native 256-bit processing capability of the EVM, avoiding the overhead of downsizing and conversion operations.
Example Scenario
For example, consider a smart contract to store user details where each user has an ID, age, and score. A less efficient implementation might use separate uint256 variables for each attribute, even though age
and score
could be represented with smaller integers.
// Less Efficient Version
contract UserDetails {
struct User {
uint256 id; // User ID
uint256 age; // Age, unnecessarily using uint256
uint256 score; // Score, unnecessarily using uint256
}
mapping(uint256 => User) public users;
}
A more efficient approach would utilize uint128
or smaller types for age
and score
, and pack these variables together with the id
into a single storage slot when possible.
// More Efficient Version
contract UserDetails {
struct User {
uint256 id; // User ID remains uint256
uint128 age; // Age, now efficiently using uint128
uint128 score; // Score, now efficiently using uint128
}
mapping(uint256 => User) public users;
}
In the above examples, the optimized version reduces the storage footprint and gas costs associated with adding or updating a user in the contract, demonstrating the practical benefits of understanding and applying the uint vs uint256 pattern in Solidity development.
2. Mapping vs Array:
In Solidity smart contracts, developers often encounter a dilemma between using arrays and mappings to store data. This decision is crucial as it directly impacts the contract’s gas consumption and, by extension, its operational costs on the Ethereum network.
Arrays offer the flexibility of data packing and the convenience of iteration, making them an intuitive choice for ordered data. However, this convenience comes at a cost, as arrays, especially dynamic ones, consume more gas during operations like adding or removing elements.
Mappings, on the other hand, present a more gas-efficient alternative for storing key-value pairs. While mappings cannot iterate over their elements, which arrays facilitate, they compensate for this with significantly lower gas costs for operations like retrieval and updates. Therefore, the choice between arrays and mappings hinges on the specific requirements of the smart contract. So, if iteration or data packing isn’t vital, mappings
are the go-to for their efficiency.
Example Scenario
For instance, we developed a smart contract to manage a list of registered users. A less efficient approach might use an array
to store user addresses, allowing for easy iteration but at higher gas costs.
// Less Efficient Version
contract UserRegistry {
address[] public users; // Dynamic array of user addresses
function addUser(address _user) public {
users.push(_user); // Adds a new user to the array
}
}
A better implementation would use a mapping to store user data, significantly reducing gas costs, especially as the number of users grows. An auxiliary counter could maintain order if necessary.
// More Efficient Version
contract UserRegistry {
mapping(uint => address) public users; // Mapping of user IDs to addresses
uint public userCount = 0; // Counter to keep track of the number of users
function addUser(address _user) public {
users[userCount] = _user; // Adds a new user to the mapping
userCount++; // Increments the user count
}
}
This example illustrates the trade-offs between arrays and mappings in Solidity, emphasizing the importance of choosing the most appropriate data structure.It’s noteworthy to consider the used cases of both data structures here while choosing the appropriate approach based on your smart contract’s specific needs and the gas optimizations required.
3. Fixed Size:
The choice between fixed-size variables and dynamic ones can significantly impact the gas efficiency in Solidity. As the Fixed-size variables
name suggests, these variables are predefined in size and will have no change throughout the lifetime of a smart contract. This size predictability leads to more efficient use of the Ethereum Virtual Machine (EVM) resources, translating into lower gas costs for operations involving these variables.
Contrarily, variable-size variables, such as dynamic arrays, offer flexibility by allowing the array’s size to change during runtime, but the flexibility comes at a cost. Compared to similar operations on fixed-size arrays, operations that alter the size of the array, like adding or removing elements, consume more gas. The reason is resizing requires additional computation and storage re-allocation. These operations ultimately increase the computational burden on the EVM.
Given this trade-off, the solution for optimizing gas costs without sacrificing functionality is to use fixed-size arrays whenever the maximum number of elements can be predetermined. The aforementioned is particularly useful where the data set size is known and unlikely to change. Some examples include a list of predetermined roles in a contract or a capped collection of items.
In the following scenario, a smart contract manages a small, fixed number of items in an inventory. A less efficient approach would use a dynamic array to store these items, allowing the inventory size to change but at higher operational costs.
// Less Efficient Version
contract DynamicInventory {
address[] public items; // Dynamic array of item addresses
function addItem(address _item) public {
items.push(_item); // Adds a new item, increasing array size
}
}
A better implementation would define a fixed-size array, assuming the maximum inventory size is known and consistent, thus saving on gas costs for adding or removing items.
// More Efficient Version
contract FixedInventory {
address[10] public items; // Fixed-size array with a maximum of 10 items
uint public itemCount = 0; // Counter to track the number of added items
function addItem(address _item) public {
require(itemCount < items.length, "Inventory is full.");
items[itemCount] = _item; // Adds a new item within the fixed size
itemCount++;
}
}
4. Default Value
One of the pivotal roles in gas optimization is the practice of initializing variables. While it’s a standard software engineering practice to initialize all variables to prevent undefined behaviors, Ethereum’s Solidity programming language has a unique characteristic that influences this practice. Having all variables initialized to zero by default means explicitly setting a variable to its default zero value, which is redundant and leads to unnecessary gas consumption. The table here shows you the 0
default value translated for various data types:
Data Type | Initial Value Description |
---|---|
boolean |
false |
string |
Empty string ""
|
int |
0 |
uint |
0 |
fixed |
0.0 (Note: This type is not fully supported in Solidity) |
enum |
The first element of the enum |
address |
0x0000000000000000000000000000000000000000 or address(0)
|
function internal |
An empty function. For functions returning values, it returns initial values. |
function external |
A function that throws an exception when called. |
mapping |
An empty mapping. Note: Using delete on a mapping has no effect. |
struct |
A struct where all members are set to their respective initial values. |
array dynamically-sized |
An empty array []
|
array fixed-sized |
An array of the specified fixed size where all elements are set to their initial values. |
Using the delete
keyword on a variable resets it to its initial value, except for mappings where delete has no effect. For structs, the delete
keyword recursively assigns initial values to all members except for mappings within the struct.
Example Scenario
For example, consider a smart contract designed to track the balance of accounts. A less efficient approach might explicitly initialize each account balance to zero:
// Less Efficient Version
contract AccountTracker {
mapping(address => uint256) public balances;
function addAccount(address _account) public {
balances[_account] = 0; // Explicitly setting to default value
}
}
However, the explicit initialization is redundant because Solidity automatically initializes mapping values to zero. A more efficient approach would omit this step:
// More Efficient Version
contract AccountTracker {
mapping(address => uint256) public balances;
// No need to explicitly initialize balances to zero
function addAccount(address _account) public {
// Simply adding the account without setting its balance
}
}
5. Minimize On-Chain Data
In developing Ethereum smart contracts, efficiently managing on-chain data is crucial due to the inherent cost structure of blockchain storage. The Ethereum network charges gas for transactions and contract executions, with storage operations being particularly expensive due to on-chain data being replicated across the network, contributing to its immutability and decentralization. However, this replication also means adding or modifying on-chain data incurs significant gas costs.
So, to mitigate such costs, a key strategy is to minimize the amount of data stored on the blockchain. Only essential data that needs to benefit from blockchain’s security and immutability should be stored on-chain. Non-critical data, or data that doesn’t require decentralization, can be stored off-chain using other storage solutions like IPFS (InterPlanetary File System) or centralized databases, with references to this data (like hashes) held on-chain if necessary.
Optimizing data storage in Ethereum smart contracts is crucial due to the significant cost disparity between on-chain storage and the storage space that data consumes. On-chain storage, being permanent and immutable, incurs a higher gas cost, reflecting the resources required to maintain this data across the network.
A best practice is to minimize the amount of data stored on-chain, which involves carefully determining what information truly needs the security and permanence of the blockchain. i.e., developers can store non-essential data off-chain using other data storage solutions like IPFS or traditional databases. As a conventional rule, only key information, such as references to off-chain data or essential transactional data, should be stored on-chain to ensure contract functionality while optimizing gas costs.
Additionally, event logs offer a powerful mechanism for enhancing off-chain data accessibility without incurring the storage costs associated with on-chain data. Events emit logs during transaction execution that are stored on the blockchain but are not accessible from within smart contracts. These logs can be indexed and accessed off-chain, providing a cost-effective way to record and retrieve information related to smart contract transactions.
Example Scenario
Consider a content management system (CMS) smart contract that tracks the ownership and metadata of digital articles. A less efficient approach would store all article content directly on the blockchain:
// Less Efficient Version
contract ArticleRegistry {
struct Article {
address owner;
string content; // Large content stored on-chain
}
mapping(uint => Article) public articles;
function registerArticle(uint _id, string memory _content) public {
articles[_id] = Article(msg.sender, _content);
}
}
Here’s a more effective implementation that would store only the article’s ownership and a hash of the content on-chain, with the actual content stored off-chain. It could also emit an event for each new article registration, allowing off-chain applications to access article metadata easily:
// More Efficient Version
contract ArticleRegistry {
struct Article {
address owner;
string contentHash; // Hash of the off-chain stored content
}
mapping(uint => Article) public articles;
// Event for new article registration
event ArticleRegistered(uint indexed _id, address indexed _owner, string _contentHash);
function registerArticle(uint _id, string memory _contentHash) public {
articles[_id] = Article(msg.sender, _contentHash);
emit ArticleRegistered(_id, msg.sender, _contentHash);
}
}
The latter version significantly reduces on-chain storage requirements and associated costs, leveraging off-chain storage for bulky data and utilizing events to facilitate off-chain data access.
6. Explicitly Mark External Functions
In Solidity, the distinction between `public` and `external` function visibility is crucial in optimizing gas consumption in smart contracts. The key difference lies in how Ethereum Virtual Machine (EVM) handles input parameters. For public functions, input parameters are automatically copied to memory, which consumes gas. Memory in Solidity is a temporary place to store data. It is more expensive than `calldata`, a non-modifiable, non-persistent area where function arguments are stored. Calldata
is cheaper to use because it’s read-only and exists only for the duration of the function call.
In contrast, `external` functions are designed to be called from outside the contract, and their input parameters are read directly from `calldata`, bypassing the need for memory allocation and thereby reducing gas costs, which makes external functions more applicable for operations that users/developers can perform from outside the owner’s contract.
Example Scenario
Imagine a smart contract function that accepts an array of integers as input and processes them. A less efficient implementation might define this function as public
, leading to higher gas costs due to memory usage:
// Less Efficient Version
contract DataProcessor {
function processIntegers(uint[] memory _integers) public {
// Function logic
}
}
Now, the more efficient approach would be to use external visibility for the function, ensuring that the EVM reads the input parameters from calldata
:
// More Efficient Version
contract DataProcessor {
function processIntegers(uint[] calldata _integers) external {
// Function logic
}
}
Conclusion
Incorporating these design patterns into your Solidity smart contracts can yield substantial gas savings and improved efficiency. Remember, It’s not just about writing functional code but also about understanding and leveraging the nuances of the Ethereum platform to optimize your contracts’ performance and cost.