[ad_1]
“Gas” is the unit of measurement through which the Ethereum network — and other blockchains — express the amount of computational effort required for a specific action. Each operation is considered as a transaction which needs computational resources to be executed, therefore each of them has a gas fee. Basically, when a Smart Contract (SC) is compiled, it is converted into a series of operation codes
having a specific gas cost for execution. For example, additions cost 3 gas
, multiplications cost 5 gas
, storage operations cost anywhere from 200
to 20,000 gas
. Inevitably, SC developers need to pay close attention to this aspect, because they could end up developing SCs with unnecessary high gas fees. There are several factors that can increase gas cost of SCs, and it is essential to be aware of them to avoid unnecessary gas expenditures.
Variable Packing:
The storage of a SC is divided in contiguous 32-byte slots. In addition, each variable declared in storage has a different size; therefore, a single storage slot can hold variable until it reaches 32 bytes. With the exception of dynamic arrays and mappings, all state variables of a SC are organized into these slots starting from slot 0.
State variables can be declared to fit a single slot, so having one slot with two or three state variables in it will result in less gas spent at the deployment to a network.
contract BadVariablePacking {address public ownerAddress; //20byte - slot 0
bytes32 public hashedValue; //32 bytes - slot 1
uint256 public counter; //32 bytes - slot2
bool public randBool; //1 byte - slot 3
}
contract BetterVariablePacking {address public ownerAddress; //20byte - slot 0
bool public randBool; //1 byte - slot 0
uint32 public counter; //4 byte - slot 0
bytes32 public hashedValue; //32 byte - slot 1
}
In the first example, it is evident how each variable occupies a different storage slot, thus resulting in three storage slots needed; while, in the second example, the same variables are re-arranged in a way that optimizes the use of the storage slots, hence only two of them are needed. Items smaller than 32bytes are packed into a single slot whenever possible, following these rules:
· Value types only uses the necessary bytes to store them;
· If a value does not fit in the remaining space of a slot it is stored in the next one;
· Structs and arrays by default are stored in brand new slot, but they are internally packed according to these rules;
· Items following struct / array start from the following slot.
Long story short, the smaller number of slots needed, the more gas efficiency will be provided.
Write to storage last:
In general developers should avoid writing to the storage of SCs multiple times within functions. The reason is simple: this operation is one of the most expensive. In these cases, it is common practice to create a local variable, use it throughout the function, and update the storage variable with the value of the temporary one at the end of the function.
contract badStorageVariableAccessing {
uint256 public count;function incrementCount(uint256 times) external {
for(uint i = 0; i < times; ++i){
++count;
}
}
}
contract betterStorageVariableAccessing{
uint256 public count;function incrementCount(uint256 times) external {
uint256 tempCount = 0;
for(uint i = 0; i < times; ++i){
++tempCount;
}
count += tempCount;
}
}
In these examples, we basically have a state variable that is updated after the incrementCount
function is invoked. The two examples have the same outcome; but, the first implementation is more expensive than the second one. As a proof of this, lets check the following Remix’s receipt:
This is the treansaction receipt after calling incrementCount
from badStorageAccessing
SC with 100
as argument.
This is the receipt of betterStorageAccessing
SC after calling the incrementCoun
t function with 100
as argument. It is obvious how the gas cost in this second case is dramatically reduced — and that was just a silly example -, so imagine how much gas can be saved in more complex functions.
Read from storage only one time:
Within a function, it may be necessary to access a state variable more than once. Like writing to the storage, also reading from storage is an expensive action. In these cases, it is convention to create a local variable initiated to the value of the storage one. Usually this kind of operation is related to loops, for example when a SC has to repeat a block of code until a certain value is reached.
contract badReadingFromStorage {
uint256 public winners = 10;
uint256 public winnerNum = 0;function sendPrizes() public {
for(uint i = 0; i < winners; ++i){
++winnerNum;
}
}
}
contract betterStorageReading {
uint256 public winners = 10;
uint256 public winnerNum = 0;function sendPrized() public {
uint256 tempIWinners = winners;
uint256 tempNum;
for(uint i = 0; i < tempWinners; ++i){
++tempNum;
}
winnerNum += tempNum;
}
}
The same idea can be applied when different external calls are made without expecting the returned value to change.
//before
require(anyContract.owner() == msg.sender);
//do something
require(anyContract.owner() == msg.sender);
//do other things
require(anyContract.owner() == msg.sender);//after
address ownerAddress = anyContract.owner();
require(ownerAddress == msg.sender);
//do something
require(ownerAddress == msg.sender);
//do other things
require(ownerAddress == msg.sender);
Free up unused storage:
A variable freed up through the delete
keywork grants a gas refund of 4,800
gas up to 20% the transaction’s cost in which it was used. Deleting a variable basically means reinitializing it to its default value (address(0)
for addresses and 0
for numeric values).
contract deleteStateVariable {
mapping(address => uint) balances;function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
payable(msg.sender).call{value: balances[msg.sender]}("");
delete balances[msg.sender];
//yep, I knoe it's vulnerable to reentrancy ^.^
}
}
Use constant
and immutable
:constant
and immutable
are variable annotations which mean that the variable can only be accessed in read-only mode.
contract constantAndImmutable {bytes32 public constant VALUE = keccak256("constant value");
address public immutable owner;
constructor() public {
owner = msg.sender;
}
}
constant
variables must be initialized at the same moment of their declaration, while immutable
variables can be initialized at a second moment, but only once; both are used to store values that will not change during the life cycle of the SC.
!= 0
is cheaper than > 0
for unsigned integers:
Checking whether the value of a uint variable is different from 0
, rather than greater than it, costs less when the optimizer is enabled.
contract expensiveRequire{uint public value = 5;
function checkValue() external {
require(value > 0);
}
}
contract cheaperRequire{uint public value = 5;
function checkValue() external {
require(value != 0);
}
}
Splitting require() statement with &&
operator:
contract badAndRequire{
int public constant MINIMUM = 1;
int public constant MAXIMUM = 100;function limitedAddition(int a, int b) external {
int result = a + b;
require(result < MINIMUM && result > MAXIMUM, "the result exceeds the boundaries");
}
}
When it is needed to check different conditions within a single require using the &&
operator it is better to split it into different requires, as this will save gas. Also, if possible, try to order the conditions from least likely to happen to the most likely, because if the first is not met there is no need to check the others (since they have an AND relation).
contract betterAndRequire{
int public constant MINIMUM = 1;
int public constant MAXIMUM = 100;function limitedAddition(int a, int b) external {
int result = a + b;
require(result > MINIMUM, "Result too low");
require(result < MAXIMUM, "Result too high")
}
}
Shorten require message to less than 32 characters:
As mentioned earlier, Solidity storage is organized on 32 bytes slots. The same thing happens when a SC needs to store a require message, consequently, if it is longer than 32 characters, it will need another storage slot to be stored. One way to reduce the number of characters used would be to use error codes in the SC and associate them to a reason in the docs
contract betterAndRequire{
int public constant MINIMUM = 1;
int public constant MAXIMUM = 100;function limitedAddition(int a, int b) external {
int result = a + b;
require(result > MINIMUM, "ME-01");
require(result < MAXIMUM, "ME-02")
}
}
/////////
ME: Mathematic Errors
ME-01: result too low
ME-02: result too high
Custom errors instead of Revert String:
From Solidity 0.8.4
custom errors have been introduced. These are cheaper than revert strings and accept arguments like events. However, they are not yet supported by all blackchains (like BSC).
contract customErrors{
int public constant MINIMUM = 1;
int public constant MAXIMUM = 100;error tooLow(uint firstValue, uint secondValue, uint result);
error tooHigh(uint firstValue, uint secondValue, uint result);
function limitedAddition(int a, int b) external {
int result = a + b;
if(result < MINIMUM) {
revert tooLow(a, b, result);
}
if(result > MAXIMUM) {
revert tooHigh(a, b, result);
}
}
}
Pre-increment is cheaper than post-increment:
Pre-increment (++i
) costs less than post-increment(i++
). The reason is simple: post-increment basically increments the value of a variable, but the operation returns its initial value. Consequently, the compiler has to create a temporary variable to store the initial value; while pre-increment, in the other hand, returns the current incremented value.
//from
uint counter = 1;
counter++;//to
uint counter = 1;
++counter;
Strict inequalities are more expensive than non-strict ones:
The EVM does not have an opcode
for non-strict inequalities (such as, >=
, <=
), so two different operations are performed each time one is analyzed. Therefore, consider using strict inequalities when developing SC.
//from
require(value >= 2);
//to
require(value > 1);//from
require(value <= 2);
//to
require(value < 3);
Use external
instead of public
when possible:external
and public
are two modifiers for function visibility. Both prepare the function to be called externally, but they differ slightly. On the one hand, external
allows a function to only be called only externally, on the other hand, public
allows the function to be called even from the SC that exposes it. If a function should only be called by EoA / other SCs, developers can declare this function as `external.
contract publicAndExternal {
string message = "Hello World";function publicFunction() public view returns(string memory){
return message;
}
function externalFunction() external view returns(string memory){
return message;
}
}
Let’s check the gas price:
Use internal instead of public when possible:
At the opposite, if a function is to be called only internally, developers can declare it as internal.
Enable the Solidity gas optimizer:
In the file hardhat.config
the Solidity optimizer can be enabled.
module.exports = {
solidity: {
compilers: [
{
version: "0.8.15",
settings: {
optimizer: {
enabled: true,
runs: 200,
},
},
},
]
}
}
Set the runs value to a low number to optimize deployment costs, or set it to a high number to optimize function call costs.
Don’t initialize to default values:
In Solidity, each variable is automatically initialized to its default value. Thus, if a state variable does not need to be initialized to a value other than its default value, developers can avoid initialization to save gas.
Organize Logical Expression:
When dealing with logical expressions, such as in an if statement, organize the operator so that the more expensive operation is examined only after the cheaper one has been considered.
k(x) //expensive
j(y) //not expensivej(y) && k(x)
j(y) || k(x)
In both cases, the expensive operation is called only when is necessary to compute it.
Use mapping instead of arrays:
Solidity provides two ways to organize data lists: mappings and arrays. For example, if I need an array representing the Pokémon I have caught, I could declare it as follow:
string public caughtPokemon[];
caughtPokemon = ['Charizard', 'Alakazam', 'Mewtwo'];
However, Pokémon are like 900, so if I catch a lot of them (and I am a Pokémon trainer, so…), after a certain point I will not longer be able to iterate trough that array because the operation will run out of gas. To avoid it, a mapping representing the same thing could be declared:
mapping(uint => string) public betterCoughtPokemon;betterCaughtPokemon[6] = 'Charizard';
betterCaughtPokemon[65] = 'Alakazam';
betterCaughtPokemon[150] = 'Mewtwo';
Mappings work with key-value pairs. Accordingly, I can store the Pokémon I have caught and organize them by their id, which is different for each Pokémon. Also, mappings are cheaper than arrays so consider converting arrays in mappings when possible.
Store data in calldata
instead of memory
in function args:memory
and calldata
are two storage location definition that must be declared along with specific function arguments, such as arrays and strings. When using memory
and a user calls a function on a SC the arguments are copied from the caller’s memory to calldata
and then to the SC’s memory
; while, when using calldata
and a user calls a function on a SC the arguments are copied from his memory to calldata
, without copying them to the SC’s memory. So, the reason why calldata
is cheaper is that it performs one less step than memory
, but this missing step is also the cause of a small difference between the two. Basically, when an argument is declared in calldata
storage location means that its value cannot be changed throughout the function, on the other hand, arguments passed in memory
location can be updated during the execution of the function.
contract goodUseOfCallData {string public name;
constructor(string calldata ownerName) external {
name = ownerName;
}
}
contract badUseOfCalldata {
uint256[] public favoriteNumbers;
constructor(uint256[] calldata favNum) external {
favoriteNumbers = new uint256[](favNum.length);
for(uint256 i = 0; i < favNum.length; ++i){
favoriteNumbers[i] = favNum[i] + 1;
}
}
}
contract badUseOfMemory {string public name;
constructor(string memory ownerName) external {
name = ownerName;
}
}
contract goodUseOfMemory {
uint256[] public favoriteNumbers;
constructor(uint256[] calldata favNum) external {
favoriteNumbers = new uint256[](favNum.length);
for(uint256 i = 0; i < favNum.length; ++i){
favoriteNumbers[i] = favNum[i] + 1;
}
}
}
Using unchecked:
As of Solidity version 0.8
, math over/underflow is handled automatically. However, if the developer is sure that a particular arithmetic operation will not trigger such a problem, he can use the keyword unchecked
to create block of code in which the compiler does not need to perform these controls, thus saving the gas used for them.
function forLoop(uint256 whenToStop) public {
for(uint256 i = 0; i < whenToStop;){
//do something
unchecked {
++i;
}
}
}
Use indexed
events:
Each event can count up to three indexed
values. Using this keyword with value types such as uint
, bool
, and address
saves gas; however, this is only true for these value types, since indexing bytes
and strings
is more costly than not indexing them.
contract notIndexed {event Log(address guy, uint256 favNumber);
function favoriteNumbers(uint256 number) external {
emit Log(msg.sender, number)
}
}
contract Indexed {event Log(address indexed guy, uint256 indexed favNumber);
function favoriteNumbers(uint256 number) external {
emit Log(msg.sender, number)
}
}
Provide functions for batch operation:
Should it be possible, provide users with functions for batch operation, thus reducing the gas cost.
function someOperation(uint256 x, uint256 y, uint256 z) external {
require(registered[msg.sender], "Not registered");
_someOperations(x, y, z);
}function _someOperations(uint256 x, uint256 y, uint256 z) internal {
//do something
}
In this example, if the user had to call the someOperation
function more than once with different values, he would have to pay the gas fees for the require
each time he called the function.
function batchSomeOperations(uint256[] calldata x, uint256[] calldata y, uint256[] calldata z) external {
require(registered[msg.sender], "Not registered");
require(x.length == z.length == y.length, "Wrong input");for(uint i = 0; i < x.length;) {
_someOperations(x, y, z);
unchecked{
++i;
}
}
}
function someOperation(uint256 x, uint256 y, uint256 z) external {
require(registered[msg.sender], "Not registered");
_someOperations(x, y, z);
}
function _someOperations(uint256 x, uint256 y, uint256 z) internal {
//do something
}
Consider providing a function that handles multiple calls to the same function, accordingly the gas needed for the requires will only be spent once.
Refractor a modifier to have its implementation in an internal function rather than in the modifier itself:
A modifier’s code is copied each time it is used, increasing the size of the bytecode. By moving its implementation to an internal function, the size of the bytecode can be significantly reduced.
modifier onlyOwner(){
require(owner() == msg.sender, "Ownable: caller is not the owner");
_;
}
modifier onlyOwner(){
_checkOwner();
_;
}function _checkOwner() internal view virtual {
require(owner() == msg.sender, "Ownable: caller is not the owner");
}
In this article we have explored various ways to reduce the gas costs of SCs. This is necessary, because high gas fees kill the usability of a SC and make it less appetible for users. As seen earlier, there are several ways to reduce transaction costs, and certainly there are other ways that I do not know yet to decrease gas fees, and I look forward to get learning about and understand them!
- Yos Riady. (2021). Gas-Efficient Solidity. Retrieved from https://yos.io/2021/05/17/gas-efficient-solidity/
- Celo. (n.d.). Gas Optimization Techniques in Solidity. Retrieved from https://docs.celo.org/blog/tutorials/gas-optimization-techniques-in-solidity#:~:text=One%20way%20to%20reduce%20gas,not%20modify%20the%20contract%20state.&text=In%20this%20contract%2C%20the%20constantValue,initialized%20with%20the%20value%2042.
- 0xmacro. (n.d.). Solidity Gas Optimizations Cheat Sheet. Retrieved from https://0xmacro.com/blog/solidity-gas-optimizations-cheat-sheet/
[ad_2]
Source link