Making Ethereum Smart Contracts Upgradable
With ZeppelinOS 2.2.3 and Truffle 5
With ZeppelinOS, it’s easy to add upgradability to your smart contracts. In this post, we’ll walk you through how to take an existing smart contract and make it upgradable. We’ll then deploy the upgradable contract to the Kovan testnet and the main Ethereum network. Finally, we’ll take advantage of the contract’s upgradability to push some code changes.
Under the hood, ZeppelinOS uses what they call an “unstructured storage” proxy pattern to implement upgradability. We won’t go into details on the inner workings of upgradability since this is well-covered in ZeppelinOS’s technical blog posts and documentation.
Why use upgradable smart contracts?
A defining feature of blockchain smart contracts is their immutability. Decentralized applications (“DApps”) make use of smart contract code that is stored on a blockchain and cannot be changed. Because of this, users of smart contracts trust them to manage their funds — often thousands or millions of dollars worth.
A tradeoff of immutability is that it hampers iterative development. Updating code requires deploying a new smart contract and asking users of the old contract to switch over. Data stored on the old contract cannot be used with the new contract unless it is migrated, which can be expensive.
The primary goal of upgradability is to allow smart contract logic to be modified while preserving stored data, without the need for a data migration. Another goal is to preserve discoverability by users and client applications. Upgradable design patterns work around the immutability of smart contract code and provide mechanisms for administrators to update code “in-place.”
A note on versions
The libraries used in this tutorial are rapidly evolving, and it is common to run into breakages and incompatibilities with even minor changes in version number. We have included precise version numbers throughout and we recommend sticking to these if possible. Also note that ZeppelinOS started using web3 1.0 (specifically 1.0.0-beta.37) in v2.2.0, and if your project uses web3 you may want to use the same version.
Optional: Set up an example project
We’re going to use a simple smart contract based on Truffle’s MetaCoin example project. If you already have a Truffle project you want to work with, you can skip ahead. You can find the full source code for our project here.
Initialize a new Truffle project:
mkdir zos-demo
cd zos-demo
npm init --yes
npm install -D truffle@5.0.8
npm install --save openzeppelin-solidity@2.1.1
npx truffle init --force
Add the code below to a new file in contracts/MetaCoin.sol
. The code is based on Truffle’s MetaCoin.sol, but I’ve made a couple changes for the sake of this tutorial.
Add a migration migrations/2_deploy_contracts.js
to deploy the contract:
Now copy the MetaCoin tests to test/metacoin.js
:
Run the tests:
npx truffle test
You should see all three tests pass.
Make the smart contract upgradable
We will now modify our contract to be upgradable with ZeppelinOS. The steps are as follows:
- Install and set up ZeppelinOS.
- Modify the contract and its base contracts to use initializers instead of constructors.
- Update the migrations.
Before we begin, consider the following caveats which apply to upgradable contracts:
- Using
selfdestruct
ordelegatecall
from within an upgradable contract poses a security risk. - Contracts created from within another smart contract using the Solidity
new
operator will not be upgradable.
If either of these applies to your contract, see the ZeppelinOS documentation for more details.
Step 1: Install and set up ZeppelinOS.
Make sure you are in the project root directory, and then run the commands below. The zos
command-line tool will create a config file named zos.json
, configured for our contract. Future zos
commands should be run from the same directory, or else you may run into errors.
npm install -D zos@2.2.3
npx zos init zos-demo
npx zos add MetaCoin
Step 2: Modify the contract and its base contracts to use initializers instead of constructors.
In general, there are four changes you must make to prepare your contract for use with ZeppelinOS:
- Contracts should inherit from
Initializable
. - Constructors should become initializers.
- Initializers should call the initializers of parent contracts.
- Initial values cannot be used in non-constant field declarations. They can be set in the initializer instead.
It is important to remember that these changes must be applied both to your smart contract and all base contracts that your contract inherits from, including contracts imported from libraries. If your contract uses base contracts from the openzeppelin-solidity
library, you can use openzeppelin-eth
as a ZeppelinOS-compatible drop-in replacement.
Let’s apply the four changes outlined above to the MetaCoin
contract. In our case, the only base contract is Ownable
from openzeppelin-solidity
. We’ll swap this out for the corresponding upgradable contract from openzeppelin-eth
.
Install openzeppelin-eth
for the upgradable version of Ownable.sol
. We’ll also install zos-lib
which provides the Initializable.sol
base contract.
npm install --save openzeppelin-eth@2.1.3 zos-lib@2.2.3
In MetaCoin.sol
we’ll update the imports and contract declaration to use openzeppelin-eth
and inherit from Initializable
:
import "openzeppelin-eth/contracts/ownership/Ownable.sol";
import "zos-lib/contracts/Initializable.sol";contract MetaCoin is Initializable, Ownable { ...}
Next, we’ll change the constructor into an “initializer.” A ZeppelinOS initializer is simply a public function that zos
will call immediately after our contract is deployed. The Initializable
base contract includes a function modifier named initialize
. Applying this modifier ensures that the function can only be called once in the contract’s lifetime.
By convention, we’ll name the initializer function initialize
. Delete the constructor()
function and put this in its place:
function initialize() public initializer {
balances[owner()] = 10000;
}
Our initializer must call the initializers of parent contracts. If the old parent constructor required arguments, these arguments should be passed via the initializer instead. In MetaCoin.sol
, update the initializer function signature to take the transaction sender as an argument, and then pass it to the Ownable
base contract as follows:
function initialize(address sender) public initializer {
Ownable.initialize(sender);
balances[owner()] = 10000;
}
Finally, the upgradable contract cannot set initial values in any storage field declaration that isn’t constant
. In MetaCoin.sol
, this is a problem for the conversionRate
field. We have two options:
- Apply the
constant
keyword to make it a constant; OR - Set the initial value in the initializer function.
In our case, let’s take the latter approach in case we want conversionRate
rate to be mutable later on. First, remove the initial value from the field declaration:
uint public conversionRate;
Then, set the initial value in the initializer:
function initialize(address sender) public initializer {
Ownable.initialize(sender);
balances[owner()] = 10000;
conversionRate = 2;
}
And we’re done! The new contract looks like this:
If you are working with a smart contract that has more base contracts, repeat the steps above for each one so that they all follow the Initializable
pattern.
Step 3: Update the migrations.
Run the unit tests again. They will fail this time.
npx truffle test
The tests fail because the contract initializer is never called. Let’s update the migrations to fix this. Modify migrations/2_deploy_contracts.js
so that it deploys and initializes MetaCoin
when the current network is either test
or development
:
Note that the MetaCoin
contract deployed by this migration is not upgradable. That is why we only allow the migrations to deploy MetaCoin
on a test or development network. On a public Ethereum network, we should use the zos
command-line tool to deploy and upgrade the upgradable contract.
Run the tests again with npx truffle test
. They should pass!
Note: This is a straightforward method for testing upgradable contracts that does not require the tests to be modified. ZeppelinOS provides additional documentation on testing describing how to test the upgradable system as a whole, including the proxy contract.
Step 4 (optional): Add a warning comment about the storage layout.
Due to how ZeppelinOS’s proxy-based upgradability works, we can only perform certain kinds of upgrades. We must keep these limitations in mind when making any code changes that we want to deploy with ZeppelinOS. These constraints are described in the ZeppelinOS documentation and have to do with how Solidity lays out state variables in storage.
You may want to remind yourself and others working your smart contract of these limitations with a warning like the following:
Deploy the upgradable contract
Now that we’ve written and tested our upgradable contract, let’s see how ZeppelinOS handles contract deployment. There are two steps. First, we deploy the logic contract, which is the contract we implemented above. Second, we deploy a proxy contract, which is a special contract implemented by ZeppelinOS. The proxy contract will serve as an entry point, delegating calls to the logic contract.
Under the hood, ZeppelinOS deploys a third, small contract called the proxy admin, which is used to handle upgrades. See here for more info about why this is required.
Optional: Configure Truffle to use a public network
For this tutorial, we’ll start by deploying to the public Kovan test network and then deploy to the main Ethereum network. Here we describe how to create a Truffle config for interacting with Kovan. If you want to use your own network configuration you can skip this section.
Install dependencies:
npm install -D truffle-hdwallet-provider@1.0.5
Then replace the contents of truffle-config.js
with the following:
This config specifies a single network named kovan
and connects to it using Infura. We use truffle-hdwallet-provider
to unlock the account that will be used to sign Ethereum transactions when deploying our contracts. Here, we provide wallet access using a mnemonic secret phrase, but you can find more configuration options in the documentation for truffle-hdwallet-provider
.
The Truffle config requires the following environment variables:
MNEMONIC
: Twelve-word secret phrase, which you can get from a wallet provider like MetaMask.INFURA_KEY
: Infura API key, which you can get from https://infura.io/register.
export MNEMONIC=...
export INFURA_KEY=...
The unlocked account must have some Kovan Ether to fund the gas costs of contract deployment. You can get this Ether for free from the Kovan faucet.
Deployment
Note: Most zos
commands take the -v
option for verbose output, which is helpful for debugging.
These deployment steps should work on any network. We’ll start with the Kovan network using the sample Truffle config from the previous section.
Let’s start by defining some environment variables:
NETWORK
: One of the network names from your Truffle config, e.g.kovan
.UPGRADABILITY_OWNER
: An Ethereum address which will have the ability to push upgrades to the upgradable contract.
The UPGRADABILITY_OWNER
address should correspond to an account unlocked by your Truffle config. You can see which accounts are unlocked by running npx truffle console — network $NETWORK
and running web3.eth.getAccounts()
in the Truffle console.
export NETWORK=... # E.g. kovan, main...
export UPGRADABILITY_OWNER=...
Start a ZeppelinOS session and verify that we’re able to talk to the network:
npx zos session \
--network $NETWORK \
--from $UPGRADABILITY_OWNER \
--expires 3600
npx zos status
You should see a message like MetaCoin is not deployed
. If you see no errors, then we’re ready to deploy. Deploy the logic contract:
npx zos push
When finished, information about the deployed contract will be saved to zos.$NETWORK.json
.
We will now deploy the proxy contract which will delegate calls to the logic contract. When creating the proxy, we need to pass in the arguments for the initializer function. Our MetaCoin
example requires one argument in the initializer, which is the contract owner defined in Ownable.sol
. For simplicity we’ll have the upgradability owner be the contract owner as well.
Deploy the proxy contract:
npx zos create MetaCoin \
--init initialize \
--args $UPGRADABILITY_OWNER
Note: If your initializer takes multiple arguments, they can be passed in as a comma-separated list, with no spaces in between.
Take note of the proxy address in the output, where it says Instance created at…
. The address will also be saved to zos.$NETWORK.json
and can be found using npx zos status
:
export PROXY_ADDRESS=$( \
npx zos status 2>&1 \
| grep Deployed -A 1 \
| grep MetaCoin \
| awk '{ print $4; }' \
)
echo $PROXY_ADDRESS
The upgradable contract is now ready to use! Let’s try interacting with it. Open a console with npx truffle console --network $NETWORK
and run the following commands, one at a time:
truffle(kovan)> instance = await MetaCoin.deployed()
truffle(kovan)> rate = await instance.conversionRate()
truffle(kovan)> rate.toNumber() === 2
The last statement should return true
.
Upgrade the contract
Let’s take advantage of our smart contract’s upgradability to add a new function that sets the conversion rate. Add the following to MetaCoin.sol
:
function setConversionRate(uint _conversionRate) public onlyOwner {
conversionRate = _conversionRate;
}
The function is marked with theonlyOwner
modifier from Ownable.sol
. This means it can only be called from the “contract owner” address passed into the initializer.
Run npx truffle test
to make sure we didn’t break anything.
Now, refresh the zos session (if necessary) and push a new logic contract:
npx zos session \
--network $NETWORK \
--from $UPGRADABILITY_OWNER \
--expires 3600
npx zos push
Update the proxy to point to the new logic contract:
npx zos update MetaCoin
Open the Truffle console again with npx truffle console --network $NETWORK
, and run the following commands to try out the new function:
truffle(kovan)> instance = await MetaCoin.deployed()
truffle(kovan)> await instance.setConversionRate(7)
truffle(kovan)> rate = await instance.conversionRate()
truffle(kovan)> rate.toNumber() === 7
The last statement should return true
.
Note: When deploying contract upgrades, it is crucial that you check in the updated zos.*.json
files created by zos. These files keep track of the deployed proxy and logic contracts, and allow zos to detect when you try to deploy changes that violate the upgradability constraints described above.
Deploy to the Ethereum mainnet
We can update our earlier Truffle config in truffle-config.js
so that it can connect to the mainnet in addition to Kovan:
We can now follow the instructions in the “Deploy the upgradable contract” section above to deploy our upgradable contract to the main Ethereum network. Again, make sure that the upgradability owner has some Ether to fund the deployment. During testing, it cost us about 0.005 ETH (~2.47M gas) to deploy the MetaCoin
upgradable contract with zos push
and zos create
.
Closing remarks
Many Ethereum-based projects make use of upgradable contracts in some form. At Blend, we’ve found ZeppelinOS to be a powerful aid for developing smart contracts iteratively in the early phases of development.
It is important to consider, however, that “there is a fundamental tradeoff between malleability and security.” The upgradability model demonstrated here may not be appropriate for smart contracts that handle significant amounts of user funds, since the upgradability owner has full control to modify and manipulate the smart contract. Projects using upgradable contracts in production should use a multisig contract as the upgradability owner, and should have monitoring in place to detect smart contract upgrades.
Future work on upgradability may allow the upgradability of a contract to be governed by a DAO (decentralized autonomous organization). This is something ZeppelinOS has started discussing, but the use case has not yet been proven. Decentralizing the right to upgrade could expand the kinds of use cases where upgradability is appropriate.
Upgradable smart contracts should be complemented by a good migration plan. Even with an upgradable contract, you may need to perform a migration if your data model changes considerably, or if you need to make a change that is not supported by Zeppelin’s proxy model. In particular, you may plan to eventually migrate back to a non-upgradable contract.
Here are some starting points for further reading on the background behind ZeppelinOS and the tradeoffs of upgradability:
Ken is a software engineer at Blend focused on financial applications of blockchain.
Interested in working at the cutting edge of financial technology on projects like smart contracts at Blend? We’re hiring across all teams in our San Francisco and New York offices and would love to talk with you.