This post mentions ZeppelinOS, which has since been deprecated. If you would like to get started with upgradeable smart contracts check out the OpenZeppelin Upgrades Plugins for Hardhat and Truffle.
Much has been discussed around proxy patterns and how to best achieve upgradeability in Ethereum smart contracts. The underlying idea is quite simple: instead of interacting with your smart contract directly, your users interact with a proxy that only holds the state and delegates execution to a logic contract that holds the code. Upgradeability is then achieved by simply changing the reference to the logic contract in the proxy contract, so new code is used for executing all calls.
In a previous blog post, we presented different patterns for how to best manage the application storage and the references to the logic contracts in the proxies. However, all these patterns, as well as other patterns devised by the Ethereum development community, are subject to an issue known as proxy selector clashing.
As explained, proxies work by delegating all calls to a logic contract that holds the actual code to be executed. Nevertheless, upgradeable proxies require certain functions for management of the proxy itself. At the very least, an
upgradeTo(address newImplementation) function is needed in order to be able to upgrade the proxy to a new logic contract.
This raises the question of how to proceed if the logic contract also has a function with the same name and signature. Upon a call to
upgradeTo, did the caller intend to call the proxy management function or the logic contract? This ambiguity can lead to unintended errors, or even malicious exploits.
The clashing can also happen among functions with different names. Every function that is part of a contract’s public interface is identified at the bytecode level by a short 4-byte identifier. This identifier depends on the name and arity of the function, but since it’s only 4 bytes, there’s a possibility that two different functions with different names may end up actually having the same identifier. The Solidity compiler tracks when this happens within the same contract, but not when the collision happens across different ones, such as between a proxy and its logic contract. Nomic Labs has an excellent analysis of this matter, and of how it can be exploited by an attacker.
The way we deal with this problem is via the transparent proxy pattern. The goal of a transparent proxy is to be indistinguishable by a user from the actual logic contract. This means that a user calling
upgradeTo on a proxy should always end up executing the function in the logic contract, not the proxy management function.
How do we allow proxy management, then? The answer is based on the message sender. A transparent proxy will decide which calls are delegated to the underlying logic contract based on the caller address:
- If the caller is the admin of the proxy, the proxy will not delegate any calls, and will only answer management messages it understands.
- If the caller is any other address, the proxy will always delegate the call, no matter if it matches one of the proxy’s own functions.
Let’s see how this works in an example. Assume a proxy with an
owner() getter and an
upgradeTo() function that delegates calls to an ERC20 contract that has an
owner() getter and a
transfer() function. The following table covers all resulting scenarios:
While this is the safest approach, it may lead to confusing scenarios. For instance, if a user creates a proxy to a logic contract and then immediately tries to interact with it (following the example above, by calling
transfer()), they’ll get a revert error. This is because any calls from the proxy admin will not be delegated to the logic contract.
A way around this problem is to move the upgradeability ownership of the proxy to a dedicated account, which could even be a smart contract that handles
upgradeTo calls on behalf of the owner. If you’re using ZeppelinOS to build upgradeable smart contracts, this is managed by an App contract, which is created whenever you publish your project to a network.
Then, whenever you want to upgrade a proxy to a new version, you only need to send an upgrade request to the App, which will forward it to the proxy to be upgraded.
One caveat is that ZeppelinOS spins up an App contract only if you explicitly choose to do so by running
zos publish. By default, the OS will simply create your proxies from your user account, and if you try to interact with them from the same account, you’ll run into the issues described above. Because of this, we are analyzing the possibility of always deploying a simple App contract, even if it means a small extra gas cost for managing your project from ZeppelinOS. Feel free to share your thoughts about this!
While proxies have become the de facto way to manage upgradeability in Ethereum, they still present some challenges in terms of their management. In ZeppelinOS, these problems are solved via the implementation of a transparent proxy pattern that disambiguates calls by relying on the caller before the function selector. This makes interacting with a proxy indistinguishable from interacting with the actual logic contract, for any user except its admin.
Thanks to Francisco Giordano for the idea and design of the transparent proxy pattern and to Patricio Palladino of Nomic Labs for his analysis of the function selector clashing exploit as part of the ZeppelinOS v1.0 audit.