The basics of optimizing Solidity contracts, explained for regular coders.
Writing smart contracts is hard. Not only do you get a single chance to write bug-free code, but depending on exactly how you write, it’ll cost your users more or less to interact with it.
When you compile a smart contract, every line of Solidity gets converted into a series of operations (called opcodes), which have a set gas cost. Your goal is to write your program using as little opcodes as possible (or replace the most expensive with cheaper ones).
Of course, this is all very complex, so let’s take it slowly. Instead of going down the opcode rabbit hole, here are some simple optimizations you can apply to your contracts today.
The Solidity version your contracts’ use is defined at the top of the file, like so:
pragma solidity ^0.8.0;
In this context, ^0.8.0
means that the contract will use the newest version of Solidity available from the series 0.8.x
.
Newer versions of Solidity sometimes include gas optimizations along with bug fixes and security patches, so updating to the latest version will not only make your code safer but (often) cheaper as well.
To catch most of the recent optimizations, make sure you’re using at least 0.8.4
, like so:
pragma solidity ^0.8.4;
If you’re using any of the OpenZeppelin contracts for your NFT project or token, it’s likely you’re using OZ’s Counters.sol
library.
In newer versions of Solidity (0.8
onwards), this library isn’t super useful, and replacing it with a regular integer can save some gas. Here’s how:
contract TestCounters {
- using Counters for Counters.Counter;
- Counters.Counter private _tokenIds;
+ uint256 private _tokenId;
function mint() public {
- _tokenIds.increment();
- uint256 tokenId = _tokenIds.current();
+ uint256 tokenId = _tokenId++;
}
}
Wether it’s the amount of decimals for a token, USDC’s address, or a payout account, sometimes there are contract variables we don’t ever plan on changing. Marking these as either constants (if you write them in the code) or immutable (if you plan on giving them a value later, e.g. via the constructor) can reduce the cost of accessing those values. Here’s an example:
contract TestImmutable {
uint256 internal constant DECIMALS = 18;
address public immutable currencyToken;
constructor(address _currencyToken) {
currencyToken = _currencyToken;
}
}
Starting with Solidity 0.8
, all math operations include checks for overflows. This is great (and replaces the SafeMath library, so you can drop that if you’re using it!), but it costs extra gas, so we want to avoid it when not necessary.
Overflow checks basically make sense that you don’t subtract from zero, or add to 2^256 (the maximum number Solidity can handle). So, for example, if you’re just incrementing a token id or storing an ERC20 value, you should opt out of these checks using unchecked {}
:
contract TestUnchecked is ERC721 {
ERC20 internal immutable paymentToken = ERC20(address(0x1));
uint256 internal _tokenId;
mapping(address => uint256) _balances;
function mint(uint256 amount) public {
_mint(msg.sender, _tokenId);
unchecked {
_balances[msg.sender] += amount;
++tokenId;
}
paymentToken.transferFrom(msg.sender, address(this), amount);
}
}
This comes in especially handy with for loops, where the i
value you increment will never realistically overflow, so you save gas on every iteration:
contract TestUncheckedFor {
ERC20 internal immutable token = ERC20(address(0x1));
function refundAddresses(address[] calldata accounts) {
// 💡 pro tip: save the array length to a variable instead of
// inlining to save gas on every iteration.
uint256 len = accounts.length;
for (uint256 i = 0; i < len; ) {
token.transfer(accounts[i], 1 ether);
unchecked { ++i; }
}
}
}
For some types of arguments, like strings or arrays, solidity forces you to specify a location for storing them (either memory
or calldata
). Using calldata
here is much cheaper, so you’re gonna want to use that as much as possible, leaving memory
only for when you intend to modify the arguments (since specifying calldata
makes them read-only).
Solidity 0.8.4
introduced a new feature, allowing developers to specify custom errors, which are defined and behave similar to events:
contract TestErrors {
// first, define the error
error Unauthorized();
// errors can have parameters, like events
error AlreadyMinted(uint256 id);
// 💡 pro tip: this gets set to the deployer address
// sometimes, you don't need Ownable :)
address internal immutable owner = msg.sender;
mapping(uint256 => address) _ownerOf;
function ownerMint(uint256 tokenId) public {
if (msg.sender != owner) revert Unauthorized();
if (_ownerOf[tokenId] != address(0)) revert AlreadyMinted(tokenId);
_ownerOf[tokenId] = msg.sender;
}
}
You should try to use these custom errors instead of the old revert strings (require(true, "error message here")
), since those could cost extra gas depending on the message.
When using any kind of counters (like _tokenId
), starting it off at 1
instead of 0
will make the first mint slightly cheaper. In general, writing a value to a slot that doesn’t have one will be more expensive than writing to one that does, so keep that in mind.
Also, when incrementing an integer, ++i
(return old value, then add 1) is cheaper than i++
(add 1, then return new value). If you’re just incrementing a counter and ignoring the return value, you probably want the first one.
When dividing, Solidity inserts a check to make sure we’re not dividing by zero. If you know for sure the divisor isn’t zero, you can perform the operation using assembly and save some extra gas, like so:
contract TestDivision {
function divide_by_2(uint256 a) public pure returns (uint256 result) {
assembly {
result := div(a, 2)
}
}
}
Finally, functions marked as payable
will be cheaper to call than non-payable functions. Keep in mind marking everything as payable might impact user experience, since they’ll get an extra field when using Etherscan, and might accidentally send some ETH to the contract when you don’t expect them to. A relatively safe optimization is to mark the constructor as payable
, slightly reducing deployment cost.
While hard at times, the world of Solidity and the EVM is really interesting. Some devs can spend days and days making slight tweaks to their code, trying to shove a few extra gas units off their contracts.
For everyone else though, I hope the above list can serve as a good resource for making your contracts a bit cheaper 😁