Thank you for your interest in this post! We’re undergoing a rebranding process, so please excuse us if some names are out of date. Also have in mind that this post might not reference the latest version of our products. For up-to-date guides, please check our documentation site.
ZeppelinOS is all about making the technology of upgradeability into an accessible and frictionless tool for developers. Ideally, we want to enable a developer to create upgradeable instances of an existing contract source without any special work. One of the biggest obstacles in the way of this goal is that of constructors. In this post, I will explain this obstacle and provide some background, and explore the space of possible solutions as we see it.
Constructors in Solidity and the EVM
Constructors in Solidity are pieces of code that are similar to functions and that describe how to initialize a contract when a new instance of it is created. Initialization may be parameterized by constructor arguments or transaction metadata. Depending on these parameters, the code can perform validations, set the initial value of state variables, and interact with or even create other contracts. As an example, the constructor of a multisig wallet may receive an array of accounts, validate that they’re all different, and store them as the set of signers.
contract MultiSigWallet { address[] _signers; constructor(address[] _signers) public { require(noDuplicates(_signers)); signers = _signers; }... }
Constructors look similar to functions, but they are actually quite different when we look at the low-level details. The key is in understanding how a contract is created in the EVM. A contract creation transaction has bytecode attached to it; this bytecode will execute, and then end with a return statement that includes some more bytecode. The part that executes is the constructor, and the part that is returned is stored by the EVM as the runtime code (i.e., the code that will run for every call to the new contract).
The Solidity compiler takes care of putting this together as the result of compiling a contract. If you’re interested in reading about this down to the gory detail, be sure to check out our recent series on Deconstructing a Solidity Contract.
So how does this affect upgradeability?
An upgradeable contract is implemented with a proxy that delegates all calls to another contract, which we’ll call the logic contract. Clients then interact with the proxy. (Check out Proxy Patterns to read more about this!) We can take a regular contract, deploy it, and use it as the logic contract for a proxy. However, we have to consider the possibility that it has a constructor. As we’ve seen, the constructor runs during contract creation. In this case, it will run during creation of the logic contract, and it’s the logic contract that will thus be initialized. The proxy has its own storage and its own address. We need to find a way to initialize that instead of the logic contract.
After contract creation, the constructor is normally discarded from the EVM, so there’s no way to reuse it in the proxy. Thus, it won’t be enough with the regular contract creation code that we obtain from compiling a contract: we’ll need to do something special to keep the constructor on-chain for reuse. Let’s explore the different approaches we’re considering for this purpose.
Initializer functions
The simplest way that we can take this code and keep it on-chain for reuse is to make it an actual function: we call these “initializer functions”. This has been the workaround traditionally used (e.g., in Aragon and Gnosis Safe) for upgradeable contracts. 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. This is the approach that is enabled by ZeppelinOS’s Initializable base contract, and it is the way that we currently recommend users build their upgradeable contracts.
contract MultiSigWallet is Initializable { address[] _signers; function initialize(address[] _signers) public initializer { require(noDuplicates(_signers)); signers = _signers; }... }
Aside from making sure it runs only once, there are other considerations to keep in mind. Solidity has inheritance, so a constructor will include calls to base constructors. Those too must be made into initializer functions and manually invoked in the body of the initializer for the derived contract. Solidity has multiple inheritance, in fact, which makes this job even more complicated and delicate. While the compiler normally linearizes the inheritance graph and invokes constructors in that order, manually written initializer functions are ordered by the developer and can be accidentally invoked twice in this process.
These issues are what first drove us to explore an alternative approach.
Initializer contracts
Normally, the compiler takes care of linearizing the inheritance graph to ensure each constructor is invoked exactly once. After that, the semantics of the EVM guarantee by design that the constructor is run only at contract creation. Is there any way we can utilize these existing mechanisms but instead obtain a reusable initializer for proxies? As a matter of fact, yes, there is, and it’s quite simple (but there’s a catch).
We can take contract creation bytecode (that is, the result of compilation) and put it as-is inside a contract. What we mean by this is not that we execute the bytecode, but rather that we place it in a contract on-chain, which is simple to do by prepending a small sequence of operations. Thus, we obtain what we call an “initializer contract”. At the moment of creation of a proxy, we can delegate to the initializer contract, and the constructor will execute.
What’s nice about this approach is that the constructor logic is kept out of the logic contract and is more in line with how the EVM normally works. By design, the constructor runs exactly once.
As for the catch, well, for one thing what we just described is terribly inefficient. As we mentioned before, contract creation code has a return statement with all of the runtime bytecode, all of which will be in the initializer contract as well—thus, paid for but ignored. Additionally, the simple implementation described above does not work at all for constructors with arguments. The bytecode is prepared to grab its arguments from a specific place where, in this different context, there’s no way for us to place them.
All is not lost, though. There’s a relatively simple transformation of the bytecode that we can carry out to work around this. We can identify all argument reads and transform them such that they read from a place we have access to, namely calldata. We can also remove the unused return statement and make that more efficient.
What’s great about this is that it can be applied to any contract for the EVM, regardless of the source code that was used to write it. Note, though, that we’re talking about changes to the bytecode itself. We can’t expect developers to do this themselves.
Automation
The bytecode transformations necessary for initializer contracts have to be automated. Although relatively simple on paper, a naive implementation would likely be incorrect; a complete implementation would need full knowledge of the EVM semantics. By being conservative and targeting the common case of Solidity-generated bytecode, however, we could achieve a useful implementation with a reasonable amount of effort—one that could be made more sophisticated with time. We refer to this approach as bytecode preprocessing.
As an alternative, we’re considering automating the generation of initializer functions by source code preprocessing, that is by transforming the AST of the source code. The shortcomings of manually written initializer functions are overcome when a program can take care of doing things correctly, but there are other downsides that can crop up. Such an AST transformation would be Solidity-specific and would likely have to be written from scratch for all new smart contract languages. On the other hand, the bytecode transformation will have to be rewritten for eWASM in the future.
Another concern is that of contract verification, as the currently available source code verifiers only work with Solidity. Sticking to initializer functions would enable users to verify the intermediate Solidity code, and anyone would be able to audit its correctness. Both of these things are harder when it is the bytecode that’s processed. On the other hand, as new languages show up, we’re likely to see verifiers include support for them and possibly for other code generation tools.
Wrapping up
We’ve explained the problem of constructors in proxies and how this affects upgradeability solutions, along with two different approaches to work around this. With the goal of removing developer friction, we also discussed their potential for automation by bytecode preprocessing or source code preprocessing.
If you’re interested in seeing some of the prototypes we wrote and some more technical detail, check out these experiments in our zeppelinos/labs repo: the initial prototype, an inefficient workaround for arguments, and source code transformation.
We’re interested in your thoughts on this issue. Do you have any preference for any of the approaches laid down in this post? Do you have any concerns that we haven’t addressed? Join the discussion at zeppelinos/zos#57.
Thanks to Santiago Palladino and Nicolas Venturo for reviewing early drafts of this post.