Abstract
Wrapped ETH, or WETH↗, is one of Ethereum’s most popular smart contracts. While WETH serves a simple purpose, a significant portion of all Ethereum transactions depend on it. WETH now underpins large parts of DeFi. In this blog post, we prove critical safety guarantees and invariants within the WETH contract. We do so by leveraging Z3, a battle-tested SMT solver.
We prove two critical invariants: (1) correctness of accounting, and (2) solvency. For accounting, we prove that the total supply of WETH is always equal to its balance of native Ether. For solvency, we prove that, regardless of other users’ actions, a user is always able to unstake Wrapped ETH. In the process, we also identified a minor, harmless bug in the contract. Our work enables the community to continue using WETH with increased confidence in its correctness.
Background
WETH is a straightforward token that wraps native Ether into the ERC20-compatible WETH token. The contract allows anyone to deposit native Ether for WETH tokens 1:1, or conversely, redeem WETH for native Ether. The contract also supports the standard ERC20 functionality, like approvals, transfers, totalSupply, and transferFrom.
WETH is useful because native Ether is inconvenient for use in smart contracts. First, native Ether is not ERC20 compatible. This necessitates additional code paths, increasing complexity. Not only does this add developmental burdens, it increases the likelihood for security vulnerabilities. Native Ether interactions also introduce the risk of re-entrancy vulnerabilities, which are absent when dealing with ERC20 tokens. Hence, WETH has seen widespread adoption in DeFi applications, like Uniswap, Compound, etc.
As of the time of writing, WETH has been involved in over 125 million transactions1. On the entire Ethereum blockchain, there have been 1.7 billion transactions so far. In other words, over 7% of all Ethereum transactions involve Wrapped ETH. Furthermore, Wrapped ETH has become more pervasive over time. In the past year, 48M of 420M Ethereum transactions called WETH. This represents 11.5% of all Ethereum transactions this year. Since WETH is heavily used in DeFi applications, and DeFi has become a centerpiece of the Web3 ecosystem, this is a natural outcome.
A smart contract bug in WETH would be deadly. Countless DeFi protocols use WETH as as a compatibility layer for native ETH. For instance, Uniswap relies on WETH↗ internally when dealing with native ETH. Should WETH have a solvency or accounting error, the downstream effects on DeFi would be massive. Not only would WETH holders be affected, so would all of the DeFi protocols which are hardcoded to use WETH internally.
The WETH contract itself is relatively simple. Excluding blank lines and comments, WETH9 is comprised of only 51 lines of code in total. Excluding getters, there are only 7 functions, and each of these functions are relatively simple. That being said, simplicity is no guarantee of correctness. In fact, the first ever known instance of a reentrancy attack was performed on a WETH-style wrapper contract↗. Incidents like this reveal the importance of formal verification of critical smart contracts.
That being said, let’s quickly review the most important functions in WETH:
balanceOf: A mapping from addresses to uint256 that tracks how much WETH a given address “owns”.deposit: Payable function. Accepts native ETH inmsg.valueand mints new WETH tomsg.senderby adding to their account entry inbalanceOf.withdraw: Function. Burns WETH by subtracting from the sender’s entry inbalanceOf, and disburses an equal amount of native ETH.totalSupply: View function. Computes the total amount of outstanding WETH issued by taking the native ETH balance of the WETH contract itself.transferFrom: Function. Transfers WETH tokens among different accounts. It also enforces ERC20 token allowance rules, blocking unauthorized transfers.
As we verify the correctness of WETH, we will need to keep each of these functions in mind.
Methods
Using Z3Py, we reimplemented the WETH contract behavior in SMT equations. We then used the Z3 theorem prover to prove certain facts about the symbolized WETH contract. First, we proved a key invariant concerning the correctness of WETH’s internal accounting. Second, we proved an invariant guaranteeing that WETH holders can always withdraw their locked ETH.
Invariant 1: Internal accounting correctness
A key invariant of WETH is that totalSupply must equal the sum of all entries in balanceOf. In other words, WETH must correctly track its total supply. This is reinforced in the ERC-20 standard. According to EIP-20, a token’s totalSupply function must “return the total token supply”. It seems important for a widely-used contract like WETH to obey the ERC20 token standard.
The WETH contract computes totalSupply by simply using address(this).balance, which returns the native ETH stored in the contract total. This amount should match up with the total supply of WETH ERC20 token, since balanceOf and native ETH are always added and subtracted commensurately, simultaneously (in the functions deposit and withdraw, respectively).
Interestingly, while I was formally verifying this property, I discovered that this invariant doesn’t actually hold↗. It’s possible to cause WETH to return a totalSupply that’s unequal to the total number of WETH tokens. So technically, WETH’s totalSupply function is wrong! But we’ll see later why that doesn’t really matter.
Let’s first begin by formalizing the various properties of WETH that have to do with this invariant. First, the invariant only depends on totalSupply, balanceOf, and the contract’s native ETH balance. For the purposes of this invariant, we can summarize the relevant global state in a 2-tuple: . Here, is a mapping from address to uint256, and is a uint256.
Next, because the WETH contract may not operate properly under conditions where there is an infinite supply of native ETH in circulation, we augment our state tuple with a third variable, : uint256 which represents (overapproximates) the total supply of native ETH outside of our WETH contract (in wei). The new variable, will constrain any native ETH values in transactions. Thus, our state tuple is .
Since the invariant is fully parameterized by our state tuple, we need only consider the ways the components of the state tuple can be affected. Enumerating them, we have:
deposit,fallback, andwithdraw: updates and the native ETH balancetransferFrom: updatesSELFDESTRUCTor block reward coinbase↗: updates the native ETH balance (applies to any address, not just WETH)
We can formalize each of these state transitions as a guarded transition rule. Each rule takes the form , where and are state tuples, is a tuple of arguments for the transition, and is a predicate required for the transition to be valid. We have the following rules:
-
deposit:
-
withdraw:
-
transferFrom:
-
selfdestruct: