Miguel Piedrafita

ENS

miguel.build

Gas Optimizations for the Rest of Us

Miguel Piedrafita

0xE340…DCb39D

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.

Bump your Solidity version

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;

Drop Counters.sol

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++;
	}
}

Mark immutable variables

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;
	}
}

unchecked {}

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; }
		}
	}
}

Avoid copying arguments to memory

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).

Use custom errors

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.

Honorable mentions

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.

Closing

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 😁