Building Ethereum’s public smart contract infrastructure (Part 1 of 2)
Part 1 provides some introductory information, covering today’s upgradability techniques, their relevance to security, and their shortcomings.
Smart contracts are developed and launched on the “world computer.” Although this is an analogy often used to describe Ethereum, today’s development practices fall short of their potential by failing to take advantage of the incredible power Ethereum’s shared infrastructure makes available. Applications developed on a “world computer” should utilize design patterns and standards that play into this: shared state and code, meaningful contract-to-contract communication, contracts as “first-class” citizens, and application address abstraction. Very few contracts interact or share information intelligently; that needs to change.
This article introduces a technique that can be leveraged today to create contracts that act as public utilities in every sense of the word through the use of gateway contracts. I view this discussion as a continuation of the ongoing work in smart contract upgradability, and so I will introduce the concept with some appropriate background information.
As a brief introduction to myself, I have been a smart contract auditor at Authio for well over a year now. My research into and development of smart contracts is focused on stretching utilization of the EVM to the furthest possible extent, using every available tool to create applications that are not just secure, but are truly viable for production and public use. The concept of gateway contracts and runtime utilities are a direct continuation of my work on authOS, which was an effort to build a large-scale, generalized PoC of gateway contracts. My thanks to POA Network for their support in the development of this idea.
Smart contract upgradability is incredibly important in this developing ecosystem. It’s easy to point to several examples of run-of-the-mill programming mistakes that have resulted in the loss of, cumulatively, hundreds of millions of USD worth of Ether and tokens. The problems that cause these catastrophic failures stem from development methodologies that take the phrase unstoppable applications far too literally; many smart contracts used in production lack the means to handle flaws discovered post-deployment. As smart contracts can currently only be developed and reviewed by imperfect, human developers, it is inevitable that issues like these will continue to be found in mission-critical code. Bug-free is an impossible promise; smart contracts should aim instead for resilience.
As such, mountains of work has gone into creating upgradable smart contracts. Over the past year, there have been several large projects centered around this, and the conversation around upgradability has been going on even longer. There are two primary methods to include upgradability mechanisms in contracts: storage migration, and delegate proxies.
Contracts implementing the storage migration pattern rely on the mass relocation of application data from one address to another. The obvious drawback is the sizeable associated cost: a complete migration requires every single storage slot in the old contract be freshly initialized in the new contract. Initializing a storage slot is a high-cost operation: storing a nonzero value in an empty storage slot costs 20,000 gas.
However, this cost is a bare minimum: in order to move data from one address to another on-chain, the new contract must make an external call to the deprecated contract (700 gas base), read the old value (200 gas per value read), and copy the returned result to a new location in its storage (20,000 gas per value written). This action also can be supplemented with several additional reads and writes, depending on the implementation. Because of the constraints imposed by Ethereum’s block gas limit, a typical migration cannot occur in a single transaction, and must spread this cost across several transactions over several blocks.
In the case where the deployer of the original contract did not include in the code a contingency for migration, the steps for migration require an off-chain recovery of the old state, the creation of the new contract, and the initialization of the new contract from the aforementioned recovered state. Trail of Bits details this process in a recent article; I would recommend reading it through, should you find yourself considering this option.
The proxy upgrade pattern is perhaps the most widely-used upgradability pattern for smart contracts. The reason for this mainly boils down to its cost and low-friction use: where a migration might require the re-initialization of thousands of storage slots, a properly-implemented proxy upgrade does not require this at all. Instead, a typical proxy upgrade only requires that a single storage slot is changed.
To accomplish this feat, a contract implementing the proxy pattern has a minimal implementation: it simply receives a transaction via its fallback function and delegates execution to some other address referenced in its state. This process uses the low-level
delegatecall, and is what gives the proxy pattern its power: a
delegatecall loads the code at the target address into the calling account, allowing the target’s code to interact with directly and modify the proxy’s state. What this means for the proxy pattern is that the storage and logic of an application are kept in separate addresses; thus, changing the logic for an application only requires that the proxy
delegatecall a different address.
This pattern can be incredibly powerful. Allowing a contract to be completely flexible at such a low cost is a big bonus for developers: with the Ethereum mainnet often clogged with transactions, a proxy pattern’s efficiency can be significantly more accessible for users.
However, the upgradability debate is not so cut-and-dry.
Muddying the Waters
While the proxy pattern may seem like a no-brainer,
delegatecall comes with a hidden cost: proper use requires a nuanced understanding of low-level EVM behavior, and it can be incredibly dangerous if misused.
For starters, a low-level
delegatecall does not provide a method to determine whether the called target is “valid,” or “safe.” For example, when pointed at a non-contract address, one might expect a
delegatecall to revert, given that there is no code to execute. Instead, execution returns with a “successful” status. This can make it difficult to tell if execution had any result at all: while a
delegatecall to a non-contract address will not change state, the contract that initiated the call will not be able to tell without additional checks.
Another marked difficulty is that the upgrade process needs to take into account the storage footprint of the application. Without going into too much detail, the storage footprint describes the layout (order and position) of the data that makes up a contract’s state. Solidity determines a contract’s storage footprint by the size and order of declaration of its state variables. Should a contract’s storage footprint be altered incorrectly or unintentionally, data can be corrupted or overwritten post-upgrade.
Most importantly, a proxy’s upgrade has the potential to completely destroy the application (or at the very least, alter it beyond recognition). One possibility is that the upgraded contract contains radically different code:
delegatecall makes it possible to change just about anything about an application. If not properly handled, the upgrade could result in a completely different application than before. In a worst-case scenario, the upgraded contract might even contain a
selfdestruct trigger where it did not before. Indeed, when a proxy performs a
delegatecall that triggers a
selfdestruct, the proxy’s state and code is removed from the chain entirely (as if the proxy had called
While mitigations exist for
delegatecall edge cases, oddities, and storage footprint changes, they often complicate the development and review process. The dangers stemming from possibly upgrading to a completely foreign application (or one that can be destroyed through
selfdestruct) are much more difficult to resolve. In an ideal scenario, a proxy is not upgraded by a single party, but by some measured consensus. In this case, it may well be possible to implement an upgradability protocol that sufficiently mitigates upgrade risk, possibly through public rounds of security review. At the end of the day, though, building a sufficiently secure consensus mechanism into an application introduces yet more questions and problems. As a result, many implementations of a delegate proxy upgrade rely on the
onlyOwner modifier for upgrade permissions, which often permits a single person to change absolutely anything about the contract.
That’s not to say, though, that storage migrations cannot be criticized under this lens as well. The unplanned migration discussed earlier will inevitably result in the initializer of the migration handling massive portions of a contract’s state off-chain. Free from the constraints of a smart contract and without any formal, defined migration protocol, this data is potentially subject to manipulation.
The outlook is similarly dubious for a planned migration. If the contract’s state is being migrated to a new address, this process cannot be considered secure without sufficient review and consensus around the destination of the migration.
Until sufficiently efficient and decentralized consensus mechanisms can be applied to these models (and easily implemented by any developer), contract upgradability remains an unsolved problem.
Unfortunately, this leaves a wide gap in smart contract security. High-profile vulnerabilities have made headlines across the Internet, and have shown time and time again that “getting it right the first time” just isn’t a reasonable expectation to make of any group of developers. Suffice to say, the discussion regarding upgradability is far from over.
In the next article, I introduce gateway contracts, in the hope that they are a step towards on-chain, public infrastructure, which might reduce some of the strain when implementing features shared across applications (for example, consensus protocols). One goal of gateway contracts is to provide a method by which users can interact with applications dynamically, post-launch, by utilizing public contract resources in the form of runtime utilities.