How to optimize your gas consumption without getting REKT, Part 1
Developing a DeFi product is an engineering problem of deceptive depth — it is easy to write a smart contract in Solidity that (mostly) does what you want, but it is hard to do it well.
Author:
There is a growing consensus that one of the key hurdles the Decentralized Finance (DeFi) world has to overcome is the problem of smart contract security.
Indeed, when the news cycle is dominated by stories of fraud and loss, wider mainstream adoption remains a distant prospect, to say nothing of true competition with the current financial services and investment banking incumbents like Visa, J.P. Morgan, and Goldman-Sachs.
Contrary to what many people outside the Blockchain community say, this problem is not simply a product of over-hyped projects with insane deadlines or start-ups with a “cowboy coding” culture.
These well-publicized factors certainly exist in some cases, and they definitely don’t help, but laying the blame solely at their feet is utter nonsense, as anyone inside the Blockchain community can tell you.
It is actually much, much worse.
This is because developing a DeFi product is an engineering problem of deceptive depth — it is easy to write a smart contract in Solidity that (mostly) does what you want, but it is hard to do it well. Much like Vitalik Buterin’s famous Blockchain Trilemma, the process of Smart Contract development is also guided by three separate, and often conflicting, goals:
Helping developers reach a better equilibrium without compromising on one of these three requirements, Certora has been developing a new product called the Equivalence Checker. Unlike the Certora Prover which is focused on testing and security, this lightweight formal verification allows DeFi programmers to compare different versions of the same mathematical formula written in:
and show that they are equivalent (more on that below). It is meant to be used as a rapid development tool during the entire life-cycle of the project and help with both security, development time, and gas consumption.
The remainder of this post demonstrates the workflow of this new tool in a concrete case. We explain about the different modes of the Equivalence Checker and demonstrate how it has been able to both immediately detect a famous old bug as well as a new, previously undiscovered bug, in live code (remark: readers should note this is a low-severity bug chosen for didactic purposes. In the sequel to this blog we will reveal and analyze a more elaborate case where a high-severity bug was detected using the Equivalence Checker —so please stay tuned for more!)
But first,
A Solidity/Yul function is said to be pure if it does not read or modify the state (they can however revert potential state changes if an error occurs). It can have multiple input and output values. For example, the following function is pure:
We will say that two pure functions f(…) and g(…) are equivalent if:
(a) f reverts if and only if g reverts.
(b) [assuming f and g did not revert:] the return values of f and g are identical.
Of course, this definition can be extended to suitable fragments of EVM bytecode and made precise in the formalism of the Ethereum Yellow Paper. (the equivalence between a pure function and a logic formula is similar, but slightly more subtle and explained best by the second example below).
Now that we have properly defined equivalence, it is time to begin our discussion in earnest.
It is well-known that the Ethereum Virtual Machine (EVM) natively supports only two data types: the 256-bit word and 8-bit byte. Furthermore, most EVM opcodes deal with word-size chunks of data at a time, including all the mathematical ones. Thus, there are basically only two numerical data types in Ethereum:
which are represented in Solidity by the uint256 and int256 data types, respectively.
This is unfortunate, since virtually any type of financial application needs to implement some support for fractional arithmetic. There are a few different ways to go about this, but the most popular in DeFi applications (by far) is fixed-point numbers. These are basically simple fractions whose denominator is a predefined constant, usually a power of 2 (“binary”) or 10 (“decimal”). The standard choices of denominator in the decimal case are 10¹⁸ (known as a “wad”) or 10²⁷ (a “ray”). Thus, existing Solidity fixed-point libraries often represent a wad as a 256-bit integer. This is pretty straightforward for addition and subtraction, but when performing multiplication or division one must rescale the result to get the right answer.
For example,
Consider the following test case. We want to write a function wadDiv that divides two wads, rounding half wad and above up to the nearest wad. These types of arithmetic functions are very common in DeFi. They are necessary to take into account the difference between how integer arithmetic behaves normally, and how decimal arithmetic should actually work.
Step 1: Test the code
As a first step, we consider the following reference implementation for wadDiv written in Solidity:
In an attempt to optimize it, we played around with assembly a bit and created the following provisional Yul version:
and indeed we see a substantial reduction in gas costs: the Solidity version requires ~860 gas to perform the computation while the assembly one only spends ~140 gas.
Question. Does this work (i.e., are these two functions equivalent)?
Instead of racking our brains trying to find the answer, the Equivalence Checker generic rules template immediately produces the following two rules, written in CVL (Certora Verification Language), and submits them to the Prover for formal verification.
Almost immediately, the prover detects a discrepancy between the two revert conditions:
and yields the following counterexample:
(Why? Because division by zero in Yul returns zero instead of reverting! See the linked tweeter thread for a detailed discussion)
Step 2: Test the logic
Suppose we have now fixed the bug and written a new Yul version:
Question. Are we done?
Good news: Running the Equivalence Checker now shows that it is the same as the Solidity version!
Bad news: Not so fast… because our original Solidity version is WRONG.
To see that, we need to run the Equivalence Checker in a different configuration. In the previous paragraph we explained how we can use the Equivalence Checker to compare code (wadDivAsmOld) vs. code (wadDivSol/wadDivAsmNew).
Now we need to compare a piece of code (wadDivSol) which carries out a certain mathematical computation on the EVM) with two mathematical functions which describe its behavior:
Written in CVL, the specification for these two functions is -
In this case, equivalence testing consists of checking that the following two rules are true -
What do they mean?
Running the Prover results in a counterexample:
Indeed, after converting these values to decimal, we get:
which is still within range and can be represented by a uint256 type. Thus, we find that the Solidity version we wrote reverts “too eagerly,” i.e., the way we choose to implement the computation causes reverts to occur even when they shouldn’t.
Conclusion
The Equivalence Checker is a flexible, easy to use CLI tool which allows a user to compare two pieces of code, or an implementation vs a specification, for functional equivalence. Its purpose is to accelerate code development and improve gas efficiency without compromising security. The current prototype version supports Yul, Solidity, and CVL. It is expected to be released in 2023 Q2.
Future planned versions may allow for comparison between Solidity/Yul and Vyper (or even Cairo).
I hope you enjoyed this tutorial and exploring the tool’s capabilities. We would love to get feedback and improvement proposals from developers and users — please comment below if you have any questions or suggestions.
Finally, as we mentioned before this blog is only the first part of a series — stay tuned for more!