Solidity Learnings: How To Save 50% on Gas Costs

blake west
Warbler Labs
Published in
6 min readMay 18, 2021

When we built the first version of Goldfinch, we did not really pay attention to gas costs. This resulted in even basic actions being insanely expensive on our protocol. Borrowing money cost $250+ at high gas times 😟. So the first improvement we made was to see how we could reduce gas costs. We spent hours profiling, researching, and going through our code, more or less line by line, to figure out how we could cut gas. Along the way we learned a lot of really useful rules of thumb. These are high level concepts you can always keep in the back of your mind while writing Solidity, both for what adds gas, and what doesn’t add gas, which is not always intuitive. Following these, we were able to cut costs of most functions by 30–50%, without sacrificing much in terms of readability.

1 Min Summary

  • It’s all about storage access. The vast majority of your gains will come from reducing storage reads and writes, and that’s it. This is really all you need to take away. Computation is comparatively irrelevant. So be ruthless! Do you really need to check that balance again? Can you re-use a read across numerous functions?
  • There’s no real overhead to calling functions or libraries — I thought maybe that wrapping certain functionality in functions or using libraries or other contracts would add some gas bloat. But it’s so minimal as to not be very relevant. Wrapping a storage read in a function costs only 50 extra gas. Making it ~2050 gas vs. ~2000 for a storage read alone. So unless you’re Uniswap, I would prioritize readability here.
  • Proxies can add a lot of overhead — Because storage is so expensive, think hard about using the Proxy Pattern, because you’ll be adding one storage read to every call. We ended up nixing the proxy on one of our contracts due to this, and that saved ~10% of all gas usage.
  • You don’t need advanced tooling — While seeing a flame graph (from Tenderly) was useful initially, really our basic test loop was just a few simple lines of javascript to compare the current gas consumption with the original.

The Profiling

Like any performance improvements, a cardinal rule is to always do profiling first. Which is still true with Solidity. Though I will say, compared to modern “web2” development — which involves databases, multi-threading, parallel I/O, complex frameworks, and hundreds of libraries — Solidity is a walk in the park. Once you understand the basic rules of gas consumption, you almost don’t need to do profiling anymore. Because with Solidity, there’s just not that much going on. It’s a single thread, gas costs are literally laid out in a table, and given a particular transaction, it is deterministic.

That said, especially as you’re doing your first gas optimizations, it can be helpful to see a flame graph. We used Tenderly (which has a free version), and they have a solid gas profiler. I looked around for open source alternatives, but couldn’t find any that were well maintained. In any case, I pulled up Tenderly’s profiler for a random real transaction on Goldfinch, and it looks like this:

The Tenderly gas profiler view

You can see that it breaks down gas usage by function call. You can click on each function and see a further breakdown by sub function calls. For example, our entire deposit function cost 129,422 gas in this transaction. Within that, ~39k of it was from doUSDCTransfer. And you can see that within doUSDCTransfer, there are 4 other functions, fallback, _delegate, transferFrom, and _transfer. Each has their own gas cost as well (not pictured, but clickable).

This profiler does not show you gas usage tied to line of code, or a break down by op code, which would be cool. But it certainly gets you started.

The Fixing

Armed with the knowledge of which functions were the most gas guzzling, we started trying to cut them down. You can find a full list of the gas prices in the ETH Yellow Paper (search “fee schedule”). I mentioned up top that it’s all about storage, and not really computation. Let’s put numbers on that.

The Yellowpaper mentions that storing a variable (the SSTORE opcode) will cost 20k gas the first time, and 5k gas for subsequent non-zero updates. Reading from storage is 800 gas (with some subtleties after the Berlin upgrade). But doing all sorts of normal computation and logic (ADD, MUL, LOG, AND, OR, NOT, JUMP, MOD), and even reading / writing from memory (MLOAD, MSTORE), will cost you no more than 8 gas. So storing a single variable can be worth 2500 other operations. This is why nothing else really matters except for removing storage reads and writes.

But it’s not always obvious where those are, and costs don’t always work out exactly like you expect. So we set up a test loop that was like this.

  1. Create a dummy test who’s only job was to run a single function.
  2. Record how much gas it uses with txn.receipt.gasUsed.
  3. Hardcode that number into our test.
  4. Make a change, run it again, and compare.

You’ll end up with something that looks like this…

it("should do stuff", async () => {
const originalGas = 2268374
const txn = await pool.deposit(new BN(1), usdcVal(5), {from: person2})
const newGas = txn.receipt.gasUsed
console.log("Original:", originalGas, "New:", newGas)
console.log("Savings:", (originalGas - newGas) / originalGas)
})

And that was it! We were then able to easily experiment with different things to see how much they really affected cost. Note, we also kept a little spreadsheet journal, to record costs at a higher level (ie. “USDC Transfer: 30k gas”), rather than at an operation level.

The Learnings

So it’s all about storage, but it’s not always obvious just where those storage reads/writes will be, especially if you call into contracts that you don’t control. Or sometimes you don’t even realize you’re doing the read. Some of our key takeaways and findings

  • Store your reads in a local variable for reuse — This ended up being one of the easiest and best optimizations we made. Initially, we might do something like creditLine.balance() multiple times within a function. Each time, though, was a fresh storage read. Instead, do uint256 balance = creditLine.balance(); and reuse the local var. That reuse costs basically nothing. Note, the latest Berlin upgrade will automatically give you some similar savings as storing it in a local var. But it's still cheaper to store it in a local var.
  • Pass down storage variables through functions — This one should be used sparingly, as it affects readability. But this is yet another way to avoid storage reads. Read it once into a local var, and keep passing that var to subsequent functions. Can be gross, but it works!
  • ERC20 balance checks and transfers are expensive! — They are ~5500 and 30k gas, respectively. After taking a hard look at our code, we realized some balance checks were not worth the cost. And we also realized we could consolidate a few transfers into one (ie. A→B→C became A→C)
  • Function overhead and contract-to-contract overhead is very low — I had initially thought that maybe wrapping things in a function, or calling out to another contract might entail a decent amount of overhead. But it’s pretty negligible (~50 gas). So if you think wrapping up some logic in a function is cleaner or easier to read, then do it!
  • Only do proxies for contracts that aren’t called much! — At our super early stage, we made all our contracts upgradeable by default. But anytime you call a proxied contract, it will add about 2k gas to the call (for the delegation + storage reads necessary to look up the implementation address). This is ok if it only happens once. But we added it even to our CreditLine contract, which was a big state container, and would get called a lot for reads and writes. Meaning we paid that overhead up to 10 times during one outer function call. Once we realized this, we nixed the proxy and saved about 10% of all our gas. 🙂

Summary

We saved 30–50% gas by using the following approach:

  1. Use Tenderly to get a high level of idea of what’s costing you gas
  2. Create a simple test loop, so that you can actually try different gas reduction strategies. Note: Have tests you can run before/after to ensure the functionality isn’t broken.
  3. Go through line by line, make a change, and check it. Keep it if gas goes down enough to justify the change in readability.

It’s all about reducing storage reads and writes! (in case I haven’t said it enough)

If you found this interesting, and want to contribute to the DeFi movement, we’re hiring!

--

--

blake west
Warbler Labs

Cofounder, CTO @goldfinch_fi. Formerly: Senior Engineer @Coinbase, 1st hire @HintHealth, Musician. Also ML enthusiast