Web applications today are built iteratively. With continuous delivery, developers release hotfixes and new features hundreds of times a day. We upgrade our software all the time with little ceremony.
Upgradability is something that you don’t truly appreciate - until you’ve written smart contracts. Why? Because smart contracts are immutable - it’s not possible to upgrade the source code of an already deployed contract. In this aspect, developing smart contracts is closer to hardware programming than web development.
At the same time, decentralized applications and smart contracts are a new and highly experimental space. There are constant changes in the security landscape and the cost of failure are in the high tens or hundreds of millions of dollars. Code will need to be changed if errors are discovered or if improvements need to be made. It is no good to discover a bug, but have no way to deal with it.
As software engineers, we seek to build software systems that is modular and supports upgradable components. To handle the large space of smart contract attack vectors, we need a mechanism through which we can safely and securely upgrade our smart contracts. This is especially true when you build complex, perpetual contract systems instead of one-time token sale contracts.
📬Get updates straight to your inbox.
Subscribe to my newsletter so you don't miss new content.
In this article, we’ll explore how you can upgrade smart contracts by:
Pausing contracts when things are going wrong, to halt critical operations in case of an attack.
Having an effective upgrade path for bugfixes and improvements.
Having a secure way for parties to perform an upgrade.
Holding the Door with Circuit Breakers
Upgrading a smart contract is a non-trivial, slow process. A developer needs to first analyze the issues present in the current version before working on a new implementation. When rapid response is required, it’s sometimes useful to pause our contracts and enter some kind of ‘maintenance mode’. That’s where circuit breakers come in.
Circuit breakers stop execution if certain conditions are met, and can be useful when new errors are discovered. For example, some actions can be suspended in a contract if a bug is discovered. During this maintenance period, developers can write new contracts containing the fix, deploy them, and replace the old contracts while the exploit is stopped in its tracks.
To add circuit breakers, use a Pausable contract. This contract provides whenPaused and whenNotPaused function modifiers that you can apply to certain functions.
To add circuit breaking mechanisms to your contract, simply inherit Pausable and apply the modifiers to any relevant functions. For example:
In the above example, deposit is only callable when the contract is not paused whereas withdraw is only callable when the contract is.
You can also give certain parties roles that allow them to trigger the circuit breaker. We don’t want any stranger to stop our contracts from operating!
Alternatively, you can also have programmatic rules that automatically trigger the certain breaker when certain conditions are met, such as after a certain block number is reached.
Below is a PauserRole contract that adds a role-based authorization system for our circuit breaker:
Depending on your use case, code changes may need to be approved by a single trusted party, a group of members, or a vote of the full set of stakeholders. You can use the above roles contract as the foundation for more complex upgrade authorization schemes.
Smart Contract Upgrade Mechanisms
Designing an effective upgrade system for smart contracts is an area of active research. Here are three approaches that are most commonly used.
The simplest approach is to store a mutable reference to an external subcomponent in your main contract.
Another simple approach is to have a registry contract that holds the address of the latest version of the contract.
A more seamless approach for contract users is to have a contract that forwards calls and data onto the latest version of the contract.
In general, it’s important to have modularization and good separation between components, so that code changes do not break functionality, orphan data, or require substantial costs to port.
Let’s have a look at each approach in detail.
1. Upgradability with Hub-and-Spoke
You can decompose your contract system into a hub-and-spoke model consisting of several external subcomponent contracts. The main contract knows the addresses and interfaces of these subcomponents and is able to make external calls to them. For example:
The primary Hub contract contains a reference to an external Spoke contract in the state variable spoke. It makes an external contract call spoke.run() in the definition of doSomething().
We can upgrade the spoke contract by first deploying a new fresh contract that implements the Spoke interface. Then, we call Hub.setSpoke() and supply the address of the newly deployed version. The Hub contract will now make external calls to the Spoke contract deployed at the new address.
In production use, you’ll want to restrict who can call setSpoke() to a set of trusted users.
The drawback with this approach is any state variables within the old Spoke contract is unavailable to the newly deployed version. The old and new versions of Spoke are distinct contracts with distinct storage and addresses. When the new version is deployed, it will have its storage initialized to empty.
The only way for the new version to access the old state is to have the new version make external getter calls to the old version, which makes the overall system more brittle and cost additional gas.
Once you’ve upgraded to the new implementation, you can remove the deprecated contract with selfdestruct when it is no longer used.
2. Upgradability with Registries
A variation of the hub-and-spoke model is to have a dedicated Registry contract to store a mutable directory of subcomponent contract addresses.
In this approach, our main contract will make an external call to the registry contract to find the address of a component’s current implementation. The registry address is fixed and only set during the initial contract creation step.
All address state changes and role-based authorization checks are isolated within the registry contract.
3. Upgradability with Proxies
A major drawback of the upgrade mechanisms we’ve seen so far is the fact that state is not preserved during the move from an old to new implementation.
An alternate state-preserving upgrade mechanism is to use a low-level delegatecall to forward functionality and data to another contract whilst operating in the state of the current contract.
Although it is not possible to upgrade the code of your already deployed smart contract, it is possible to set-up a proxy contract architecture that will allow you to use new deployed contracts as if your main logic had been upgraded.
From here on out, we will examine in detail ZeppelinOS’ unstructured storage smart contract upgrade mechanism.
Pre-requisites
Before we go into delegatecall, there are a few concepts that you need to understand:
When a function call to a contract is made that it does not support, the fallback function will be called. You can write a custom fallback function to handle such scenarios. The proxy contract uses a custom fallback function to redirect calls to other contract implementations.
There exists a special variant of a Solidity message call, named delegatecall which is identical to a call apart from the fact that the code at the target address is executed in the context of the calling contract and msg.sender and msg.value do not change their values. This means that a contract can dynamically load code from a different address at runtime. Storage, current address and balance still refer to the calling contract, only the code is taken from the called address.
In other words, whenever a contract A delegates a call to another contract B, it executes the code of contract B in the state and context of contract A. This means that msg.value and msg.sender values will be kept and every storage modification will impact the storage of contract A instead of B.
There is a tradeoff between upgradability and security. Upgradability adds complexity and new attack vectors. Foregoing upgradability gives you no tools to tackle vulnerabilities.
Proxy
This diagram illustrates our final proxy contract structure:
Here is our Proxy contract with a fallback function:
Within the contract’s fallback function, the assembly block contains logic that passes received input data to another implementation address.
Here is the key delegatecall:
The parameters are as follows:
gas : we pass in the gas needed to execute the function
_impl : the address of the logic contract we’re calling
0 : the memory pointer for where data starts
calldatasize : the size of the data we’re passing.
0 : for data out representing the returned value from calling the logic contract. This is unused because we do not yet know the size of data out and therefore cannot assign it to a variable. We can still access this information using returndata opcode later
0 : for size out. This is unused because we didn’t get a chance to create a temp variable to store data out, since we didn’t know the size of it prior to calling the other contract. We can get this value using an alternative way by calling the returndatasize opcode later.
UpgradeabilityProxy
We can extend the Proxy contract into an UpgradeabilityProxy contract with state variables and functions to point to a new implementation address:
Key to this ‘unstructured storage’ approach is this line:
To store data related to upgradability, we use an unstructured storage slot in the proxy contract. In the proxy contract we define a constant variable that, when hashed, should give a random enough storage position to store the address of the implementation contract that the proxy should call to.
In short, this line frees us from having to think about Solidity storage memory layouts when preserving state variables across contract versions.
Since constant state variables do not occupy storage slots, there’s no concern of the implementationPosition being accidentally overwritten by the implementation contract. Due to how Solidity lays out its state variables in storage there is extremely little chance of collision of this storage slot being used by something else defined in the implementation contract.
By using this pattern, none of the implementation contract versions have to know about the storage structure of the proxy, however all future implementation contracts must inherit the storage variables declared by their ancestor versions. Future upgraded token implementation contracts can override existing functions as well as introduce new functions and new storage variables.
AdminUpgradeabilityProxy
Going further, we can define AdminUpgradeabilityProxy with an upgradeTo() function as well as proxy owners and metadata. A proxy owner is the only address that can upgrade a proxy to point to a new logic contract, and the only address that can transfer ownership.
Initializer Functions vs. Constructors
To deploy a contract we wish to upgrade, we need to deploy both the implementation contract and the proxy contract:
However, in order to initialize your contract’s state you will need to change one more thing: to use an initializer function instead of your contract constructor.
Let’s have a look. Deploying contracts with Truffle normally looks like this:
Deploying a contract will automatically execute any constructors logic defined in the contract as well as the contracts it inherits from. However, due to the nature of the proxy pattern the constructor logic is executed in the wrong context.
A contract’s constructor logic is executed within its own context. when it is deployed.
The proxy has its own storage and its own address. We need to find a way to initialize that instead of the logic contract. Because the state lives in the proxy contract instead of the implementation, the initialization needs to be executed at the proxy context and not depend on the constructor.
If your logic contract relies on its constructor to set up some initial state, this has to be initialized in a separate initialize() function after the proxy upgrades to your logic contract. For example:
The initializer function should mimic everything you would traditionally put in a constructor. Care must be taken to protect the function so that it can only run once for a given instance—otherwise our contract runs the risk of being initialized twice, potentially by an attacker.
ZeppelinOS’ Initializable contract helps you write initializer functions:
Your contract needs to inherit from the Initializable contract and annotate its initializer function with the initializer modifier:
During a deployment, right after your proxy is deployed, you will need to call the initialize() to perform any constructor logic:
Your upgradable contracts will then be properly initialized.
Note that openzeppelin-solidity will not work with initializer functions! You will need to use openzeppelin-eth together with zos. openzeppelin-eth is an official fork of OpenZeppelin, which has been modified to use initializers instead of constructors.
Upgradable Helper
To simplify the process of deploying an upgradable contract, I’ve written an upgradable helper function:
Re-initialization of new contract versions
Sometimes, you want to call a new initialization function alongside a contract upgrade. AdminUpgradeabilityProxy offers the upgradeToAndCall function:
To use it, you need to pass in bytes data that include the signature and the parameters of the function to be called, encoded in Ethereum’s encoding format. We can define a helper function to help us perform the encode step:
The encodeCall transforms a function name, args, and rawValues into the right format. We can then call upgradeToAndCall by passing in the encoded initializer function signature and arguments:
Writing New Contract Versions
In order for new versions of your contract to be able to access old state, you will need to inherit the state variables of the old contract - this can be done via simple inheritance:
Then, deploy the new version and set it as the proxy’s current implementation:
And that’s it! You’ve deployed a new version of your contract which is able to access state variables used in previous versions.
With this upgrade mechanism, minimal modifications are required to make your contracts upgradeable! Your smart contracts don’t even know that they are part of a proxy system. This is ZeppelinOS’ currently favoured approach to smart contract upgradability, and it’s easy to see why.
Summary
Software has an inherent need for evolvability in response to changing requirements, and smart contract systems are no different.
In this article, we examined three different upgradability approaches available to smart contract systems: the hub-and-spoke model, the registry model, and the proxy model.
A Message From the Author 👋
You reached the end of the article! You should follow me on
LinkedIn
and X.
📣Enjoyed this article? Share it.
📬Get updates straight to your inbox.
Subscribe to my newsletter so you don't miss new content.