The window to exchange $HAI for Hacken Equity Shares ($HES) is now open > Claim your spot today

  • Hacken
  • Blog
  • Discover
  • Gas Optimization In Solidity: Strategies For Cost-Effective Smart Contracts

Gas Optimization In Solidity: Strategies For Cost-Effective Smart Contracts

By Tiutiun RomanandMalanii Oleh

Share via:

Ethereum gas fees have been a significant concern for years. While the transition to Proof-of-Stake has made the network more energy-efficient, it hasn’t substantially impacted gas fees. This change, along with other ongoing updates like EIP-1559, sharding, and various Layer 2 scaling solutions, are all steps towards a more efficient Ethereum ecosystem. However, for developers aiming to create cost-effective, secure smart contracts, mastering Solidity gas optimization techniques remains crucial.

This article will provide you with practical strategies for Solidity gas optimization, highlighting the importance of balancing cost reduction with security to avoid introducing vulnerabilities in contracts.

Gas Mechanism In Ethereum

Gas in Ethereum is the “fuel” that powers smart contract execution and transactions. It’s a unit that measures the amount of computational effort required to perform operations. Every action on the Ethereum network, from simple transfers to complex contract interactions, requires gas. Hence, gas is a mechanism that prevents computations from running forever and spamming the network.

It is also a mechanism that rewards fuel-efficient development. Two smart contracts might achieve the same goal, but the one with lower execution complexity is rewarded with lower gas fees. This economic model incentivizes developers to hone their Solidity skills, crafting code that is not only functional but also frugal. In this blockchain ecosystem, gas optimization is the key to success, ensuring that your smart contracts are not only effective but also economically viable for both developers and users.

Solidity Gas Optimization Techniques

Storage Optimization

In Solidity, storage optimization is pivotal in managing and reducing gas costs. The basic costs associated with storage operations include 20,000 gas for storing a new variable, 5,000 gas for rewriting an existing variable, and a relatively nominal 200 gas for reading from a storage slot. Interestingly, simply declaring a storage variable without initializing it incurs no gas cost, providing an opportunity for gas savings.

contract GasOptimization {

    // Storage variable declaration (no initialization cost)

    uint256 public storedData;

Minimize on-chain data: Reduce the amount of data stored in contract variables. Less on-chain data translates to lower gas consumption. Where possible, keep data off-chain and only store essential information on the blockchain. This approach not only saves gas but also enables the creation of more complex applications like prediction markets and stablecoins by integrating off-chain data.

Efficient data management: Permanently save a storage variable in memory in a function. Strategically use memory within functions for temporary storage of variables.

Updating storage variables: If you want to update a storage variable, first calculate everything in the memory variables. This minimizes the number of write operations to the blockchain, effectively reducing gas costs.

Variable packing: Consolidate multiple variables into one storage slot wherever feasible. Solidity allows for efficient storage handling, especially when smaller data types are grouped together. Also, while using structs, try to pack them.

struct PackedData {

        uint8 data1; // Smaller data types can be packed together

        uint8 data2;

    }

    PackedData public packedData;

Don’t initialize zero values: When writing a for loop, refrain from initializing variables to zero (uint256 index = 0;). Instead, use the uint256 index; As the default value of uint256 is zero. This practice lets you save some gas by avoiding initialization.

Make Solidity values constant where possible: For static values that do not change, use constant. If the value is assigned at construction and remains immutable, use immutable. These practices reduce the gas cost associated with accessing these variables.

Event-based data storage caution: While storing data in events is cheaper than variables, it’s important to note that this data is inaccessible to other smart contracts on-chain. This limitation should be carefully considered when using events for data storage.

Refunds

Understanding and leveraging the gas refund mechanism in Solidity is a crucial aspect of optimizing gas usage in smart contracts.

Freeing Storage Slots: When a storage slot is no longer needed, setting its value to zero (essentially “zeroing out” the variable) can lead to a significant gas refund. Specifically, this action will refund 15,000 gas. It’s important to strategically identify the points in your contract where storage variables can be safely zeroed out. Doing this as soon as the variable is no longer needed not only cleans up your contract’s state but also recovers part of the gas spent in storing values.

Using Self Destruct: The selfdestruct opcode in Solidity can be used to remove a contract from the blockchain. When a contract is destroyed using this opcode, it refunds 24,000 gas. However, there is an important limitation to consider. The refund obtained from selfdestruct cannot exceed half of the gas used by the ongoing contract call. This limitation is in place to prevent abuse of the refund mechanism.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

contract GasRefundExample {
   // Example storage variables
   uint256 public value1;
   uint256 public value2;

   // Function to update values and free storage slot
   function updateValuesAndFreeStorageSlot(uint256 _newValue1, uint256 _newValue2) external {
       // Perform some operations with the values
       value1 = _newValue1;
       value2 = _newValue2;

       // Clear the storage slot by zeroing the variables
       // This refunds 15,000 gas
       assembly {
           sstore(value1.slot, 0)
           sstore(value2.slot, 0)
       }
   }

   // Function to selfdestruct and refund gas
   function destroyContract() external {
       // Ensure the refund doesn't surpass half the gas used
       require(gasleft() > gasleft() / 2, "Refund cannot surpass half the gas used");

       // Selfdestruct and refund 24,000 gas
       selfdestruct(payable(msg.sender));
   }
}

In this example, the updateValuesAndFreeStorageSlot function updates two storage variables (value1 and value2) and then clears their corresponding storage slots using assembly to refund 15,000 gas.

The destroyContract function uses the selfdestruct opcode to destroy the contract and send any remaining funds to the contract owner (msg.sender). This operation refunds 24,000 gas, but the require statement ensures that the refund does not surpass half the gas used to prevent potential abuse.

Data Types And Packing

In Solidity, the selection and packing of data types are crucial for optimizing storage and reducing gas costs. 

Use bytes32 whenever possible because it is the most optimized storage type. bytes32 is a 32-byte data type, and it’s the most gas-efficient storage type in Solidity. When the data you are working with fits within 32 bytes, using bytes32 is recommended for optimal gas usage.

If the length of bytes can be limited, use the lowest amount possible from bytes1 to bytes32. If you are dealing with variable-length byte arrays, using the smallest size to accommodate your data is advisable. For example, if your data fits within 5 bytes, using bytes5 instead of bytes32 can save gas.

Using bytes32 is cheaper than using string. Storing and manipulating data in bytes32 is more gas-efficient than using string, which involves dynamic storage allocation. bytes32 is a fixed-size type, whereas string can have variable length, resulting in higher gas costs.

Variable packing occurs only in storage — memory and call data are not packed. Solidity packs variables in storage to minimize gas costs. However, this packing doesn’t occur in memory or during function calls. When optimizing for storage, be mindful of how variables are organized in storage to minimize the overall storage costs of your contract.

You will not save space trying to pack function arguments or local variables. Packing variables in function arguments or local variables doesn’t result in gas savings. The EVM (Ethereum Virtual Machine) operates on 32-byte words, so even if you use smaller types, they are often padded to fill a 32-byte word.

Storing small numbers in uint8 may not be cheaper. Storing a small number in a uint8 variable is not cheaper than storing it in uint256 because the number in uint8 is padded with numbers to fill 32 bytes. Additionally, operations on smaller types may sometimes be more gas-consuming due to the need for conversions.

Inheritance

Consider how the use of inheritance over composition can save additional gas.

Use inheritance: In Solidity, using inheritance is often simpler and more gas-efficient than composition. When extending contracts through inheritance, child contracts can efficiently pack their variables alongside those of the parent contract.

Mind the order: The order of variables is determined by C3 linearization. All you need to know is that child variables come after parent variables. This allows for more efficient storage packing, which is key to optimizing gas usage.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

contract Parent {
   uint256 public parentVar;

   constructor(uint256 _parentVar) {
       parentVar = _parentVar;
   }
}

// Child contract inherits from Parent
contract Child is Parent {
   uint256 public childVar;

   constructor(uint256 _parentVar, uint256 _childVar) Parent(_parentVar) {
       childVar = _childVar;
   }
}

// Example of using the Child contract
contract Example {
   Child public myChild;

   constructor(uint256 _parentVar, uint256 _childVar) {
       // Creating an instance of Child initializes both parentVar and childVar
       myChild = new Child(_parentVar, _childVar);
   }

   function getParentVar() external view returns (uint256) {
       // Accessing parentVar from the Child contract
       return myChild.parentVar();
   }

   function getChildVar() external view returns (uint256) {
       // Accessing childVar from the Child contract
       return myChild.childVar();
   }
}

Memory vs Storage

In Solidity, smart management of memory and storage is vital for gas optimization, involving the following practices.

Use storage pointer: Copying between the memory and storage will cost some gas, so don’t copy arrays from storage to memory; use a storage pointer.

Understand the complicated nature of memory: The cost of memory is complicated. You “buy” it in chunks, the cost of which will go up quadratically after a while

Try your luck: Try adjusting the location of your variables by playing with the keywords “storage” and “memory”. Depending on the size and number of copying operations between Storage and memory, switching to memory may or may not give improvements. All this is because of varying memory costs. So optimizing here is not that obvious, and every case has to be considered individually.

Mapping vs Array

Choosing between mappings and arrays for data storage is crucial for gas optimization. 

Rule of thumb: Opt for mappings over arrays for better gas savings, especially in cases where data sets are large or require direct access. However, for smaller data sets or when iteration is key, arrays can be a practical choice.

When to use mappings: Mappings, organized as key-value pairs, offer a more gas-efficient solution when you need direct access to specific elements without iterating over the entire set. They are particularly useful in scenarios where element retrieval is more frequent than iteration.

When to use arrays: Arrays, which are sequentially ordered collections, fit best in situations where you need to iterate through elements or work with small, easily packable data types. Also, when dealing with arrays, use memory arrays effectively to manage data in functions.

// Use memory arrays efficiently

   function sumValues(uint256[] memory values) external pure returns (uint256) {
       uint256 sum;
       for (uint256 i = 0; i < values.length; i++) {
           sum += values[i];
       }
       return sum;
   }

Optimizing Variables

Optimizing variables in Solidity is key to efficient smart contract design.

Optimize variable visibility: Avoid public variables; instead, use private visibility to save gas.

contract EfficientVariables {

   // Avoid public variables, use private visibility

   uint256 private myPrivateVar;

Efficient use of global variables: When used efficiently and set to private, global variables can also contribute to gas savings. 

Events for data logging: Another strategy is to use events for data logging rather than storing data directly in the contract.

// Use events rather than storing data

   event ValueUpdated(uint256 newValue);

Streamline return values: A simple yet effective optimization technique is to name the return value in a function, eliminating the need for a separate local variable. For instance, in a function that calculates a product, you can directly name the return value, streamlining the process.

// Use return values efficiently

   function calculateProduct(uint256 a, uint256 b) external pure returns (uint256 product) {

       // Naming the return value directly

       product = a * b;

}

Direct updates to private variables: Also, when updating private variables, do so directly and use events to log changes, avoiding unnecessary local variables. This approach not only simplifies your code but also contributes to overall gas efficiency in your smart contracts.

// Update private variable efficiently and emit an event

   function updatePrivateVar(uint256 newValue) external {

       // Assigning the value directly, no need for a local variable

       myPrivateVar = newValue;

       // Emit an event to log the update

       emit ValueUpdated(newValue);

   }

   // Retrieve the private variable

   function getPrivateVar() external view returns (uint256) {

       // Access the private variable directly

       return myPrivateVar;

   }

Fixed vs dynamic variables: Fixed size variables are always cheaper than dynamic ones. It’s good to use memory arrays if the size of the array is known, fixed-size memory arrays can be used to save gas. If we know how long an array should be, we specify a fixed size.

Fixed vs dynamic strings: This same rule applies to strings. A string or bytes variable is dynamically sized; we should use byte32 if our string is short enough to fit. If we absolutely need a dynamic array, it is best to structure our functions to be additive instead of subtractive. Extending an array costs constant gas, whereas truncating an array costs linear gas.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract VariableOptimization {
   // Fixed size variable (cheaper than dynamic)
   uint256 public fixedVar;

   // Fixed-size memory array (saves gas)
   uint256[5] public fixedSizeArray;

   // Additive operation for dynamic array (saves gas)
   uint256[] public dynamicArray;

   // Additive function for dynamic array
   function addToDynamicArray(uint256 newValue) external {
       dynamicArray.push(newValue);
   }

   // Truncating function for dynamic array (more gas)
   function removeFromDynamicArray() external {
       require(dynamicArray.length > 0, "Array is empty");
       dynamicArray.pop();
   }

   // Fixed-size string using bytes32 (more gas-efficient for short strings)
   bytes32 public fixedSizeString;

   // Dynamic string (higher gas cost)
   string public dynamicString;

   // Set fixed-size string
   function setFixedSizeString(bytes32 _value) external {
       fixedSizeString = _value;
   }

   // Set dynamic string
   function setDynamicString(string memory _value) external {
       dynamicString = _value;
   }
}

Function Optimization

Function optimization in Solidity is a key aspect of reducing gas costs and enhancing contract performance. Here are some best practices.

Use external functions: Functions should be marked as external whenever possible, as external functions are more gas-efficient than public ones. This is because external functions expect arguments to be passed from the external call, which can save gas.

Optimize public variables: Each position will have an extra 22 gas, so minimizing public variables can reduce gas costs. Public variables implicitly create a getter function, which can add to the contract’s size and gas usage.

Order of functions: Place often-called functions earlier in your contract. This can potentially optimize the contract’s execution, as frequently accessed code paths may benefit from such ordering.

Parameter optimization: Reducing the number of parameters in a function can save gas, as larger input data increases the gas cost due to more data being stored in memory.

Use of payable functions: Payable functions can be slightly more gas-efficient than non-payable ones. This is because the compiler doesn’t need to check for the transfer of Ether in payable functions.

Replacing Modifiers with Functions: Solidity modifiers can increase the code size. Sometimes, implementing the logic of a modifier as a function can reduce the overall contract size and save gas.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;


contract FunctionOptimization {
   address public owner; // Reduce public variables

   constructor() {
       owner = msg.sender;
   }

   // Use external most of the time whenever possible
   function getDataExternal() external view returns (uint256) {
       return 42;
   }

   // Often-called function placed earlier
   function frequentlyCalledFunction() external pure returns (string memory) {
       return "Hello, frequently called!";
   }

   // Reduce parameters if possible
   function calculateSum(uint256 a, uint256 b) external pure returns (uint256 sum) {
       sum = a + b;
   }

   // Payable function saves some gas compared to non-payable functions
   function receiveEther() external payable {
       // Additional logic can be added
   }

   // Modifier implemented as a function to reduce code size
   function onlyOwner() internal view {
       require(msg.sender == owner, "Not the owner");
   }

   // Example function using the modifier
   function updateOwner() external {
       onlyOwner();
       // Additional logic for owner-only functionality
   }
}

Wrapping Up

By incorporating these strategies, developers can significantly optimize their smart contracts for gas efficiency. The key lies in minimizing on-chain data storage, optimizing data processing, and being mindful of the limitations of certain gas-saving techniques like event-based data storage.

Follow @hackenclub on 𝕏 (Twitter)

To Be Continued 

Stay tuned for cutting-edge techniques in gas optimization, including advanced fallback and view functions, strategic operations ordering, and the power of ERC-1167 and Merkle trees. Dive deep into Yul tricks and learn how to write more efficient, cost-effective Solidity code. Whether you’re refining loops, optimizing hash functions, or exploring trustless calls on Layer 2, our upcoming insights will supercharge your smart contract development. Get ready to unlock the full potential of your Ethereum projects!

Subscribe to our blog, and don’t miss the next update on gas optimization techniques.

subscribe image
promotion image
IMPORTANT

Subscribe to our newsletter

Enter your email address to subscribe to Hacken Reseach and receive notifications of new posts by email.

Read next:

More related
  • Blog image
    DISCOVER
    Smart Contract Security: Ensuring Safety At Each Development Stage Palamarchuk R.
  • Blog image
  • Blog image

Get our latest updates and expert insights on Web3 security