by Manuel Araoz
The foundational layer of ZeppelinOS is the Kernel: an on-chain set of libraries offering common functionality and services that developers can call from within their smart contracts. We designed the Kernel to be fully decentralized while serving both the needs of contributors and users.
Eventually, there will be a governance mechanism that allows developers to propose upgrades to the Kernel. While the Kernel is still some time away from being ready (see our development roadmap), we wanted to get the community involved in the development of ZeppelinOS as early as possible.
Today, we’re announcing ZeppelinOS labs, a space for the community to interact and exchange ideas. We want development for ZeppelinOS to happen transparently with a high level of community involvement, just as we’ve done for OpenZeppelin. We want to know what you think of the direction we’re going in and how it could be better.
Kicking off ZeppelinOS labs with a look at our upgrading mechanism
The first component that we’d like to share is our prototype implementation of the upgrading mechanism that will be a cornerstone of the Kernel. You can take a look at the code in the ZeppelinOS labs Github repository.
To implement the upgradeability functionality, we created a Proxy contract which delegates all of its logic to an Implementation contract via delegatecall. This allows a Proxy’s Implementation contract to be upgraded through a central registry of versions.
Here’s a look at why we chose this method.
Design Decision: Logic & Data Separation
Having a swappable implementation means that the contract’s data can’t be stored alongside the implementation. There are two common solutions to this problem:
- Having a Storage contract exposing getters and setters for state variables
- Directly using the storage of the Proxy contract, relying on state variables being in the same storage location across each version.
We’ve opted for the second option largely because it’s more ergonomic: coding against a Storage contract using getters and setters would be very uncomfortable, and by looking a lot different than Solidity, it wouldn’t be possible to use some of the patterns that the community has developed so far. Additionally, unless the Storage contract itself is upgradeable, you would be stuck with a fixed set of state variables. Also, each call to a getter or setter would incur a high cost.
By directly using the storage of the Proxy contract, we can code the implementation almost like we would a normal (non-upgradeable) contract. State variables are mapped by the Solidity compiler to storage locations, and these are read from and written to the Proxy contract instead of the Implementation contract due to the use of delegatecall. This results in simpler code because the complexity of reading and writing to storage is left to the compiler.
When a new version of the contract is implemented, we must make sure that the same state variables are mapped to the same locations. A clever use of inheritance allows this to be accomplished in a very simple way: if contract X inherits from contract Y (before any other contract), all of Y’s state variables will be the first state variables in X, and so they will share the same storage locations. Thus, a Proxy running Y’s logic can be upgraded to use X’s logic.
By letting the compiler handle this transparently, we’re relying on behavior that may change between compiler releases. We plan to provide tooling to ensure such changes don’t affect the correctness of an upgrade and to prevent other kinds of human error.
Remaining Challenges
- A side-effect of this design is that the Implementation’s constructor is run in the context of the Implementation and not the Proxy contract. If a state variable needs to be initialized (what would be usually done in the constructor), we would need an additional function to do it. Because of the low-level code being used, it’s not possible to ensure in the language that this is done in a type-safe manner. It should be solved as part of the tooling too.
- As part of an upgrade, it might be necessary to run a small “migration” analogous to a constructor. It would similarly serve the purpose of initializing any new variables and possibly make adjustments in the variables of the previous version.
We’d love to hear your thoughts, ideas, and proposals in the form of pull requests and Github issues.