Making locks upgradeable
What are contract upgrades on Ethereum, and how we use, test and deploy them at Unlock
Blockchains like Ethereum exist to prevent recorded data from being altered. Once published, the content of a contract can not be changed. Only values stored by the contract itself can be updated (i.e. balance, owner, etc) but the code itself is immutable. However, the same address can still lead to different contracts. At Unlock, we are about to deploy the 10th version of our main contract but the address never changed since day one. How are these upgrades made possible? black magic? blocks reorg?
Proxies on Ethereum
A proxy is a piece of code that acts as an intermediary between the entity doing a request and the provider answering it. Instead of directly calling a program, you call the proxy that will take care of passing your demand and brings you back what you asked. As a provider, the proxy will take charge of handling all the incoming, forwarding only what asked.
On Ethereum, proxy contracts are used to forward call to an end contract that does the heavy lifting - often called implementation. If you decide to change the way the contract behaves, you just need to point the proxy to a new implementation, and tell him to forward things there. Requests can still be sent to the same address (the address of the proxy) but the behaviour may have changed.
This pattern turns out very useful when developing a product like Unlock, because it allows us to upgrade contracts to provide new features, fix known issues, etc.
Potential storage conflicts
Upgrading a contract is not like updating a web page though. Everything on-chain is unreversible and mistakes are very costly, destroying entire projects - and accessorily costing tons of gas for a simple upgrade.
One important risk is storage conflicts. On Ethereum, contracts have their own storage for persitent information. A simple token contract keeps addresses of all the holders in its storage. The storage lives in the almighty Ethereum Virtual Machine (EVM) and takes the form of a very long list, initially full of zeros. When a contract is compiled, each variable is assigned a slot in the storage following their order in the contract. When data is stored, the EVM picks some of the zeros in the corresponding slots and replace them with the new value.
Anatomy of a storage slot, from a good write-up.
When using a proxy, the calls are forwarded to the end/implementation contract, but the data is stored within the proxy itself. To know how to store it, the proxy reads variable from the end contract to define the slots. That means data will be kept when the end contract changes. That also means that if variables changes in the end contract, the storage layout ends up being modified. In certain cases, conflicts in slots can occur, making it impossible for the proxy to find existing slots - or even leading it to erase them.
To prevent this type of conflict in storage from happening, we at Unlock rely on the Open Zeppelin upgrades library to ensure that storage layout is compatible before proceding to any upgrade.
Testing upgrades
Despite avoiding technical failures, we have to make sure to preserve existing features from our contracts during upgrades - and add some new ones! We have an extensive test suite to check that each function does what it is supposed to. However, many features of Unlock relies on external resources, tokens and oracle contracts which is harder to enforce. The complex set of dependencies is hard to recreate locally, so we came up with solutions to tackle it.
First, we developed a set of scripts that are used both for local development and multi-chains deployment. Powered by hardhat, we now have tools to deploy and configure our contracts, submit DAO proposals, upgrade contracts, etc. Before sending an upgade, we run relevant tests on a local fork of Ethereum mainnet, then proceed to an upgrade and finally run the test again against the upgraded version. That way we can check that our contracts behaviours are preserved after an upgrade.
$ yarn hardhat
AVAILABLE TASKS:
...
deploy Deploy the entire Unlock protocol
deploy:governor Deploy Governor Alpha contracts
deploy:oracle Deploy UDT <> WETH oracle contract
deploy:template Deploy PublicLock contract
deploy:udt Deploy Unlock Discount Token proxy
deploy:uniswap Deploy Uniswap V2 Factory and Router
deploy:unlock Deploy Unlock proxy
deploy:weth Deploy WETH contract
...
gov:delegate Delagate voting power
gov:submit Submit a proposal to UDT Governor contract
gov:vote Vote for a proposal on UDT Governor contract
...
impl Get the contract implementation address
...
upgrade Upgrade an existing contract with a new implementation
upgrade:prepare Deploy the implementation of an upgreadable contract
upgrade:propose Send an upgrade implementation proposal to multisig
...
verify Verifies contract on Etherscan
...
Upgrading contract from another contract
Besides making our main contracts upgradeable, we started to add the upgrade logic inside our product itself. When you deploy a lock with Unlock, a new instance of the PublicLock
contract (ERC-721) is created with the parameters you defined (name, token, etc). In Solidity, several patterns exist to deploy a contract from another contract. The previous version of Unlock was using a Minimal Proxy (EIP-1167). Instead of deploying the entire contract for each new lock created, a small proxy is deployed just to hold the data and forward calls to a main instance that deals with all the incoming. That allowed to save a non-negligeable amount of gas when deploying a new lock instance.
However, the minimal proxy approach did not allow for upgrades. We wanted all locks to benefit from the latest features available so we started to investigate how we could deploy a full proxy from a contract and allow users to upgrade their locks when new features were released. The problem was twofold: 1) allow all managers of a lock to upgrade themselves (keep things decentralized), 2) make sure upgrades are safe.
We used Open Zeppelin TransparentProxy pattern, and added the following logic to our contract :
-
when a lock is created:
- deploy a proxy
- point it to the latest implemtation
-
when a lock is upgraded:
- check if the caller is authorized as a lock manager
- point to the new implementation
We decided to store a single ProxyAdmin
instance in our main contract that will take care of all the upgrade process. When upgradeLock
is called, our main contract will check into the deployed lock if the caller is a lock manager. If yes, our proxy admin will proceed to upgrade the lock. If not, the upgrade will be rejected.
/**
* @dev Upgrade a Lock template implementation
* @param lockAddress the address of the lock to be upgraded
* @param version the version number of the template
*/
function upgradeLock(
address payable lockAddress,
uint16 version
) public returns (address) {
require(proxyAdminAddress != address(0), 'proxyAdmin is not set');
// check perms
require(
_isLockManager(lockAddress, msg.sender) == true,
'caller is not a manager of this lock'
);
// check version
IPublicLock lock = IPublicLock(lockAddress);
uint16 currentVersion = lock.publicLockVersion();
require(
version == currentVersion + 1,
'version error: only +1 increments are allowed'
);
// make our upgrade
address impl = _publicLockImpls[version];
TransparentUpgradeableProxy proxy = TransparentUpgradeableProxy(lockAddress);
proxyAdmin.upgrade(proxy, impl);
emit LockUpgraded(lockAddress, version);
return lockAddress;
}
The various versions are stored in the contract with a version number and its corresponding implementation. For instance, lock managers can launch an upgrade by calling upgradeLock(0x...lockAddress, 10)
to upgrade their lock to version 10 - or through the dashboard. For better compatibility, we forbid bumps of more than one version number (so version 9 to 10 works, but 8 to 10 will fail).
Coming release
We are now finalising a new version of the lock template with more features and a few fixes that should come with the upgradeable version! Stay tuned :)