Solidity is a curly-bracket language influenced by C++, Python, & JavaScript; yet coding in solidity is different from any other language as every piece of code cost you gas. it is is designed to target the EVM, so before we dig into the tips & tricks of saving gas & reducing bytecode size, we will have a quick overview of six areas where data is stored in the EVM:
STORAGE
SSTORE
opcode will cost 20,000 gas units to store a value, which will vary based on the
opcode arguments.SLOAD
opcode will cost 800 gas units to read from storage, which will vary based on the
opcode arguments.MEMORY
MLOAD, MSTORE, MSTORE8
opcode have a base cost of 3 gas units.STACK
PUSH, POP, SWAP, DUP
opcode have a base cost of 3 gas units.
DUP
to clone from the last value to the last to the 16th last value on the stack.
SWAP
to swap from the last 2 values to the top of the stack within the 17th last element.
POP
removes a uint256 off the stack and discards it.
CALL DATA
CALLDATASIZE
opcode will cost 2 gas units, which will vary based on the data size
retrieved.CALLDATALOAD & CALLDATACOPY
opcode will cost 3 gas units to read from call data, which will
vary based on the opcode arguments.CODE
LOGS
1 - State Variables
// poor code example
address public sender; // slot 1: 20 bytes
uint256 private count; // slot 2: 32 bytes
uint96 private value; // slot 3: 12 bytes
bytes32 private name; // slot 4: 32 bytes
The code snippet above is a poor code because it didn't fully utilize each storage slot & used more than the number of slots needed for the state variables, instead you can pack your variables as follows:
// good code example
address public sender; // slot 1: 20 bytes
uint96 private value; // slot 1: 12 bytes
uint256 private count; // slot 2: 32 bytes
bytes32 private name; // slot 3: 32 bytes
// poor code example
uint256 public amount; // slot 1: 32 bytes
string private name; // slot variable: depends on the string length
bytes private identifier; // slot variable: depends size of bytes
The code snippet above can be considered poor code because:
- you can use uint96 instead of uint256: 2 ^96–1 = 79,228,162,514,264,337,593,543,950,335
- if you are certain that your data can fit into bytes 32, then it's way cheaper to use bytes 32 instead of strings & bytes.
- generally speaking, fixed size data is cheaper than dynamic data.
- to calculate the storage size of a string in bytes, you can use the following formula: Size in bytes = (string length * 32) + 32.
- let's say you have a string of 10 characters, then the storage size would be: (10 * 32) + 32 = 352.
/// good code example
uint96 public amount; // slot 1: 12 bytes
bytes20 private name; // slot 1: 20 bytes
bytes32 private identifier; // slot 2:32 bytes
The compiler does not reserve a storage slot for these variables, and every occurrence is replaced by its respective value.
For constant variables:
- The value has to be fixed at compile-time.
- Disallowed any expression that accesses storage, blockchain data (e.g., block.timestamp
,
address(this).balance
or block.number
),
or execution data
(msg.value
or gasleft()
), or makes calls to external contracts.
For immutable variables:
- It can still be assigned at construction time.
- The contract creation code generated by the compiler will modify the contract's runtime code before it is returned by replacing all references to immutable with the values assigned to them. This is important if you are comparing the runtime code generated by the compiler with the one actually stored in the blockchain.
In some cases, constant values can be cheaper than immutable values as the EVM reserve 32 bytes for immutable regardless if they can fit in fewer bytes.
If you're declaring a state variable with their prospective default value, you're wasting gas and will be considered a poor code, the default values for different types are as follows:
Boolean:
- The default value for a boolean variable is false
.
- If a boolean variable is declared without initialization, it will have the default value of
false
.
Unsigned Integer:
- The default value for is uint
is 0
.
- If a uint variable is declared without initialization, it will have the default value of
0
.
- The default value for other unsigned integer types, like uint8
, uint16
, etc.,
follows the same rule and is 0
.
Bytes:
- The default value for a bytes variable is an empty byte array, represented by 0x
(a
hexadecimal value indicating an empty byte array).
- If a bytes variable is declared without initialization, it will have the default value of an empty byte array.
Try to minimize reading from and writing to storage as much as you can, we all know the value of a state variable can change in the scoop of any function. If it is necessary to iterate over or modify a variable, consider the following example:
contract PoorExampleContract {
uint256[] public dataArray;
uint256 public index;
function updateDataArray(uint256 newValue) public {
// 1st storage touch
require(index < dataArray.length, "Index out of bounds");
// 2nd storage touch
if (index == 0) {
dataArray[0] = newValue;
} else {
for (uint256 i = 1; i < dataArray.length; i++) {
if (i == index) { // 3rd storage touch
dataArray[i] = newValue;
}
}
}
}
}
contract GoodExampleContract {
uint256[] public dataArray;
uint256 public index;
function updateDataArray(uint256 newValue) public {
// 1st storage touch
uint256 tempIndex = index;
require(tempIndex < dataArray.length, "Index out of bounds");
if (tempIndex == 0) {
dataArray[0] = newValue;
} else {
uint256 currentValue = dataArray[tempIndex];
if (currentValue != newValue) {
dataArray[tempIndex] = newValue;
}
}
// 2nd storage touch
index = tempIndex;
}
}
Remember, it's important to handle memory variables properly to avoid potential issues like exceeding the available memory or running out of gas due to excessive memory usage.
2- Memory
While memory considered cheaper compared to storage, it is expanded by a word (256-bit), when accessing (either reading or writing) a previously untouched memory word (i.e., any offset within a word). At the time of expansion, the cost in gas must be paid. Memory is more costly the larger it grows (it scales quadratically).
calldata
is an appealing and cheaper alternative to memory,
EIP-2028
reduced gas per non-zero byte from 68 gas per byte to 16 gas per byte. Depending on how you
want to handle and access the data within a function. Here's a guideline on when to use each type:
- Calldata is read-only, meaning you cannot modify its contents within the function.
- It is useful when you want to minimize gas costs by avoiding unnecessary data copying.
- Use calldata
for input parameters, especially when you want to access data sent to your contract from an external source, such as another contract or an external account.
- Memory is a temporary data storage area that is cleared between (external) function calls and is cheaper than storage.
- When you need to modify or store data within the function, especially for temporary variables or data manipulation.
- If you want to store a copy of calldata
for later use within the function, you can assign it to a memory
variable.
contract ExampleContract{
function processData(uint256[] calldata data) public {
// Read-only access to calldata
uint256 sum = 0;
for (uint256 i = 0; i < data.length; i++) {
sum += data[i];
}
// Modify data using memory
modifyDataInMemory(data);
}
function modifyDataInMemory(uint256[] memory data) internal {
// Modify data in memory
for (uint256 i = 0; i < data.length; i++) {
data[i] = data[i] * 2;
}
}
}
By choosing the appropriate type (
calldata
ormemory
), you can optimize gas costs and efficiently handle data based on your specific requirements within your Solidity functions.
3- Pack & Hash Struct
If you have a struct in your code that will eventually need to be set, modified, or read from in any function, you can hash and store the struct
contract PoorExampleContract {
struct Employee {
uint256 id; // slot 1 storage
string name; // complicated depending on string length
uint256 salary; // full slot N after string
}
mapping(uint256 => Employee) public employees;
function addEmployee(uint256 _id, string calldata _name, uint256 _salary) public {
employees[_id] = Employee(_id, _name, _salary);
}
function updateSalary(uint256 _id, uint256 _newSalary) public {
Employee storage emp = employees[_id];
require(emp.id != 0, "Employee does not exist");
emp.salary = _newSalary;
}
}
Each time an employee's data is modified, the entire struct is updated in storage, which can lead to higher gas costs if the struct size is large or if frequent modifications are made.
contract GoodExampleContract{
struct Employee {
uint15 id; // slot 1 storage
uint17 salary; // slot 1 storage
bytes32 firstName; // slot 2 storage
bytes32 lastName; // slot 3 storage
}
mapping(uint256 => bytes32) public hashedEmployees;
mapping(uint15 => Employee) public employees;
function addEmployee(Employee calldata _employee) public {
bytes32 hash = hashEmployee(_employee);
employees[_employee.id] = _employee;
hashedEmployees[_employee.id] = hash;
}
function updateSalary(uint15 _id, uint17 _newSalary) public {
Employee storage emp = employees[_id];
require(emp.id != 0, "Employee does not exist");
bytes32 oldHash = hashedEmployees[_id];
bytes32 newHash = updateHash(oldHash, emp.salary, _newSalary);
emp.salary = _newSalary;
hashedEmployees[_id] = newHash;
}
function hashEmployee(Employee memory _employee) internal pure returns (bytes32) {
return keccak256(abi.encode(_employee));
}
function updateHash(bytes32 _oldHash, uint17 _oldValue, uint17 _newValue) internal pure returns (bytes32) {
return keccak256(abi.encodePacked(_oldHash, _oldValue, _newValue));
}
}
— By accepting the struct as input in the addEmployee
and updateSalary
functions,
we improve gas efficiency by avoiding unnecessary data copying or parameter conversions. This can lead to
lower gas costs when interacting with the contract.
— Employing hashing techniques, can help reduce gas costs by storing compact representations of struct data and perform data integrity checks efficiently.
— Using calldata
option in the addEmployee
improves gas efficiency by avoiding unnecessary data copying.
— Using bytes32 for first & last name is more gas efficient than strings and bytes, but may have limitations in terms of length and flexibility.
— Using uint15 for employee id will provide you with a max value of 32,768 which depends on the company size.
— Using uint17 for salary will provide you with max value of 131,072 per month, which depends on the company payroll.
4- Use Assembly instead of Solidity
Assembly is a low-level programming language that is converted to machine code using an assembler. The EVM has its own instruction set which is abstracted by Solidity, its high level language for writing smart contract.
contract AssemblyContract {
address private _owner;
// 22710 gas
function transferOwnership(address owner) public {
_owner = owner;
}
// 2706 gas
function transferOwnershipAssembly(address owner) public {
assembly {
sstore(_owner.slot, owner)
}
}
// 405 gas
function balance() public view returns (uint256) {
return address(this).balance;
}
// 181 gas
function balanceAssembly() public view returns (uint256) {
assembly {
let c := selfbalance()
mstore(0x00, c)
return(0x00, 0x20)
}
}
receive() external payable {}
// 490 gas
function checkZeroAdd(address _address) public pure {
require(_address != address(0), 'ZeroAddress');
}
// 473 gas
function checkZeroAddAssembly(address _address) public pure {
assembly {
if iszero(_address) {
mstore(0x00, "zero address")
revert(0x00, 0x20)
}
}
}
// 1068 gas
function hashInput(uint256 amount, uint256 value) public pure {
keccak256(abi.encodePacked(amount, value));
}
// 691 gas
function hashInputAssembly(uint256 amount, uint256 value) public pure {
assembly {
mstore(0x00, amount)
mstore(0x20, value)
let hashedInputs := keccak256(0x00, 0x40)
}
}
}
The trade-off lies between complexity and gas efficiency. Inline assembly code can be more intricate to work with, but it offers improved gas efficiency. However, utilizing inline assembly requires knowledge of EVM opcodes.
5- Time of Deployment and init function
You can trade-off more cost during deployment than for execution of other functions, as shown below in the heatmap of gas prices at various of the day gas cost changed dramatically due to network congestion; hence you can deploy your contract for a gas price 5x or 10x less than other times.
Another great possibility is to do more in the init function to help reduce the gas needed at other times.
// source: NFTX Protocol
function __NFTXVault_init(
string memory _name,
string memory _symbol,
address _assetAddress,
bool _is1155,
bool _allowAllItems
) public override virtual initializer {
__Ownable_init();
__ERC20_init(_name, _symbol);
require(_assetAddress != address(0), "Asset != address(0)");
assetAddress = _assetAddress;
vaultFactory = INFTXVaultFactory(msg.sender);
vaultId = vaultFactory.numVaults();
is1155 = _is1155;
allowAllItems = _allowAllItems;
emit VaultInit(vaultId, _assetAddress, _is1155, _allowAllItems);
setVaultFeatures(true /*enableMint*/, true /*enableRandomRedeem*/, true /*enableTargetRedeem*/, true /*enableRandomSwap*/, true /*enableTargetSwap*/);
}
6- Choose Wisely the type of data to store on-chain
When deciding what data to store on-chain, you need to consider factors such as data size, security, and efficiency. Here's a comparison of storing data as bytes, strings, or hashes:
- Storing data as bytes allows you to handle arbitrary binary data.
- It is more efficient than strings for storing raw data, such as IPFS hashes or encrypted data.
- Use bytes when you need to handle raw binary information or when the data format is not known in advance.
- It provides flexibility but can result in higher storage costs and gas consumption, especially for large amounts of data.
- Strings are convenient for readability and handling textual data, but they come with higher storage and gas costs compared to bytes.
- If the data contains variable-length or frequently changing text, consider the gas cost implications.
- Storing the hash of information instead of the actual data can be beneficial for certain scenarios.
- Hashes are fixed-size, regardless of the original data size, resulting in lower storage costs and efficient comparison of data integrity.
7- Consider passing Admin functions cost to user functions
There a significant number of contracts that require access control mechanism in place to prevent unauthorized use of some of its' functions which will increase the total gas cost of deploying & running the contract. One get away is to pass this cost to the end user instead of paying it ourselves or the admin of the contract, let's take a look into some EIPs that increase gas cost:
First we have to differentiate between accounts haven't been accessed during a transaction; called Cold Accounts and accounts have been accessed; called Warn accounts.
// This example will cost us 22,100 gas each time our admin adds a user
contract AdminCostExample {
mapping(address => bool) private authorized;
function addUser(address user) public onlyAdmin {
authorized[user] = true;
}
}
Due to the high cost of adding access list, reading cost each time from storage, and the reduced refund EIPs a great alternative is authorizing users or whitelist them in our server, and pass the cost to the enduser as follows:
// here instead of pilling up gas each time we want to add user, it is passed to end user
contract UserAuthExample {
function mint(uint256 amount, uint8 v, bytes32 r, bytes32 s) public {
bytes32 hash = keccak256(abi.encodePacked(amount, msg.sender));
address signer = ecrecover(hash, v, r, s);
require(signer == user, 'unauthorized user');
}
}
8- Events are cheaper to store data but you should look out for cost
Use events to store data on-chain but keep in mind that you can't access or use these data on-chain. Events are great tool for:
- Decentralized Communication: Logs can act as a communication mechanism between smart contracts and external systems. By emitting events, smart contracts can notify other contracts or off-chain systems about specific occurrences or state changes.
- Audit Trail: Logs provide a historical record of important events or transactions within a smart contract. They can be used for accountability, transparency, and debugging purposes.
- Off-chain Analysis: Logs can be retrieved and analyzed off-chain by external systems or tools. This allows for data extraction, monitoring, and reporting without requiring direct access to the smart contract's state.
Logs do not directly affect the state of the blockchain or consume excessive gas. However, emitting logs incurs a certain gas cost, which is paid by the contract owner. The cost depends on the number of topics and the data size emitted in the log, here is a breakdown for cost incurred by emitting an event:
event Add(uint256 value);
function addValue(uint256 _value) external {
emit Add(_value);
}
- Log Operation Base Gas Cost : 375 gas
- Log Operation Data Byte Gas Cost : 8 gas/ byte x 32 bytes = 2256 gas
- Log Operation Topic Gas Cost : 375 gas/topic x 1 topic = 375 gas
- Memory Expansion Gas Cost : number of bytes x 3 gas/byte
- Worth noting in the example above that we only stored one topic which is value
, all costs are approximate and no indexed topic was stored.
9- Downsize Your Contract to Fight Size Limitation
EIP-170 forced a maximum limit of 24576 bytes for a contract, you may receive a warning indicating that it may not be deployable on the Mainnet. This size limit was introduced to mitigate the risk of denial-of-service attacks, here's how to compact the size limitation:
Public variables autogenerate getter functions, if you don't need these functions consider making your variables private.
Public functions when called it copied all its parameters again to memory automatically, while external function's parameters are not copied into memory but are read from calldata directly.
— If your functions expected to be called from outside the contract, consider making them external. - If your functions expected to be called within the contract only, consider making them private. - If your functions expected to be called by inherited contracts, consider making them internal.
Separating logic into smaller contracts is considered a good architectural practice. When designing your contracts, it's essential to analyze whether your functions belong together or if you can split the storage and functionality.
By separating logic into smaller contracts, you can achieve several benefits. First, it enhances code readability and maintainability, making it easier to understand and modify specific functionalities. It also promotes reusability, as smaller contracts with well-defined responsibilities can be reused in different contexts.
To determine if your functions belong together, you should consider their relatedness and the degree to which they share common state variables or dependencies. If multiple functions consistently operate on the same set of data or require shared resources, it might be appropriate to group them together in a single contract. On the other hand, if there are independent functionalities or functionalities that operate on separate data or dependencies, splitting them into separate contracts can improve modularity and flexibility.
is a powerful technique to achieve efficient and scalable mass deployment of contracts. Instead of deploying individual instances of the contract, you deploy a single implementation contract and multiple proxy contracts. The proxy contracts act as intermediaries that delegate their logic to the shared implementation contract. Each proxy contract can have its own set of data, allowing for differentiation while sharing the same underlying logic.
A straightforward approach to separate functionality code from storage is by utilizing libraries. You can keep the storage and functionality in separate contracts. It's important to note that if you declare library functions as internal, they will be directly added to the main contract during compilation. However, by declaring them as public functions, they will be implemented in a separate library contract.
To enhance the convenience of working with libraries, consider utilizing the "using for" keyword. This keyword allows you to extend the functionality of a specific data type by attaching a library to it. This way, you can conveniently access the library functions directly on the data type itself.
Removing unused internal or private functions will have no impact on the contract size, but removing unused public or external functions will result in a smaller contract size. Often times we add a lot of view functions for convenience reasons. That's perfectly fine until you hit the size limit. Then you might want to really think about removing all but absolutely essential ones.
There are four different ways to handle error messages displayed to end user, and here's the guide of how & when to utilize each:
Assert:
- Should only be used to test for internal errors and to check invariants.
- The Assert
function creates an error of type Panic(uint256)
- The error code supplied with the error data indicates the kind of panic:
assert
with an argument that evaluates to false.unchecked { ... }
block.5 / 0
or 23 % 0
)..pop()
on an empty array.bytesN
or an array slice at an out-of-bounds or negative index (i.e. x[i]
where i >= x.length
or i < 0
).
Require
- Creates an error without any data or an error of type Error(string)
.
- It should be used to ensure valid conditions that can't be detected until execution time.
- This includes conditions on inputs or return values from calls to external contracts.
- You should shorten your error message so it would fit into 32 bytes or less.
- You should consider using the same require message in multiple places.
contract AssertAndRequireExample{
function sendHalf(address payable addr) public payable returns (uint balance) {
require(msg.value % 2 == 0, "Even value required.");
uint balanceBeforeTransfer = address(this).balance;
addr.transfer(msg.value / 2);
// Since transfer throws an exception on failure and
// cannot call back here, there should be no way for us to
// still have half of the Ether.
assert(address(this).balance == balanceBeforeTransfer - msg.value / 2);
return address(this).balance;
}
}
// Example from OZ using the same require in multiple places
contract ERC721 {
function tokenURI(uint256 tokenId) public view virtual override returns (string memory) {
_requireMinted(tokenId);
string memory baseURI = _baseURI();
return bytes(baseURI).length > 0 ? string(abi.encodePacked(baseURI, tokenId.toString())) : "";
}
function getApproved(uint256 tokenId) public view virtual override returns (address) {
_requireMinted(tokenId);
return _tokenApprovals[tokenId];
}
function _requireMinted(uint256 tokenId) internal view virtual {
require(_exists(tokenId), "ERC721: invalid token ID");
}
}
Revert with Custom Error
- A direct revert can be triggered using the revert
statement.
- The revert statement uses a parentheses which can either be empty, or includes a description, or number of arguments.
- Empty revert and revert with arguments are more gas efficient if your contract have to check numerous conditions, as require & revert with the error message will cost more gas.
contract CustomErrorExample {
// revert with arguments example
error ExceedsBalance(uint256 balance, uint256 decrementAmount);
// empty revert example
error NonExistAddress();
function decrementAllowance(address minter, uint256 decrease) public {
uint256 currentAllowance = mintAllowances[minter];
if(currentAllowance >= decrease) {
uint256 allowance = currentAllowance - decrease;
mintAllowances[minter] = allowance;
emit AllowanceDecreased(_msgSender(), minter, decrease, allowance);
} else {
revert ExceedsBalance(currentAllowance, decrease);
}
}
function _notMinter(address minter) private view {
if(!isAssignee(minter)) {
revert NonExistAddress();
}
}
// revert with description message as same as require
function buy(uint amount) public payable {
if (amount > msg.value / 2 ether)
revert("Not enough Ether provided.");
// Alternative way to do it:
require(
amount <= msg.value / 2 ether,
"Not enough Ether provided."
);
// Perform the purchase.
}
}
Try and Catch
- The try
and catch
statements are used to handle exceptions that occur during execution.
- The try
statement is used to wrap the code that may throw an exception, while the catch
statement is used to handle the exception.
- The try
statement can be followed by one or more catch
statements to handle different types of exceptions.
- The catch
statement can include a variable to store the exception information, such as the error message or error code.
- The try
and catch
statements are useful for handling exceptions that may occur during contract execution, such as invalid inputs or unexpected conditions.
- The try
and catch
statements can help improve the robustness and reliability of your smart contract by providing a mechanism to handle errors gracefully.
- - A failure in an external call can be caught using a try/catch statement, as follows:
contract TryAndCatchExample {
DataFeed feed;
uint errorCount;
function rate(address token) public returns (uint value, bool success) {
// Permanently disable the mechanism if there are
// more than 10 errors.
require(errorCount < 10);
try feed.getData(token) returns (uint v) {
return (v, true);
} catch Error(string memory /*reason*/) {
// This is executed in case
// revert was called inside getData
// and a reason string was provided.
errorCount++;
return (0, false);
} catch Panic(uint /*errorCode*/) {
// This is executed in case of a panic,
// i.e. a serious error like division by zero
// or overflow. The error code can be used
// to determine the kind of error.
errorCount++;
return (0, false);
} catch (bytes memory /*lowLevelData*/) {
// This is executed in case revert() was used.
errorCount++;
return (0, false);
}
}
}
Modifier variables share the same restricted stack with the they are added to and they add to the bytecode size of the contract, so when modifiers are used intensely they could have a significant impact on the contract size.
Internal functions, on the other hand, are not inlined but called as separate functions. This means they are very slightly more expensive in run time but save a lot of redundant bytecode in deployment. Internal functions can also help avoid the dreaded "Stack too deep Error" as variables created in an internal function don't share the same restricted stack with the original function.
Mappings are generally more cost-effective than arrays in Solidity, but there are some important considerations to keep in mind.
Unlike arrays, mappings in Solidity are stored more efficiently in memory due to how the Ethereum Virtual Machine (EVM) works. Arrays are not stored sequentially, but mappings are, which leads to cost savings. However, it's worth noting that arrays can be packed, allowing for efficient storage of smaller elements like uint8 when they can be grouped together. In such cases, using arrays can be more economical.
Another aspect to consider is that mappings lack certain functionalities. For instance, you cannot obtain the length of a mapping or iterate through all its elements as you would with an array. Consequently, depending on your specific use case, you might be compelled to use an array despite the potentially higher gas cost.
In summary, while mappings are generally cheaper to use than arrays in terms of gas consumption, the decision between the two depends on factors such as the size of the elements and the desired functionality. Careful consideration of these factors will help you determine the most efficient choice for your Solidity code.
10- Utilize Optimizer
To maximize the efficiency of your Solidity smart contracts, it is crucial to leverage the optimizer provided by the solc compiler. The optimizer performs a range of optimizations on your code, such as removing redundant operations and minimizing storage usage.
By default, the optimizer is enabled, but you can further customize its behavior. One important parameter to consider is the number of optimizer runs. This value reflects the expected frequency of function calls within your contract. For contracts that are used infrequently or expect less function calls, setting the runs value to 1 can result in the smallest bytecode size, although function calls may incur slightly higher gas costs.
On the other hand, contracts that are heavily utilized, such as transfer in ERC20, can benefit from setting a higher runs value, resulting in slightly larger initial bytecode but reducing gas costs for frequent function calls.
It is essential to strike the right balance by tailoring the optimizer runs parameter based on your contract's usage patterns to achieve optimal gas efficiency and bytecode size.