← Back to all posts Case Study

Designing and developing upgradable contracts

Posted by Reilabs on Oct 15, 2023

One of the most foundational concepts on the blockchain is immutability. While this is one of its greatest strengths, it is also a weakness—being able to fix bugs is crucial to building reliable software, but being able to do this is a challenge on chain.

While working with Tools for Humanity, a contributor to the Worldcoin project, Reilabs helped build the core contracts used by World ID, one such large system. We needed to ensure that we could fix problems and add features over time, which led us to investigate mechanisms for upgrading contracts.

World ID

World ID is a large-scale open protocol that provides proof of unique personhood for the internet using a custom iris imaging device. A core component of the Worldcoin project, World ID allows users to privately verify their uniqueness and humanness on web, mobile and decentralized applications.

The Identity Manager sits at the heart of World ID, taking user identities and adding them to the on-chain record. These identities are batched in the Signup Sequencer in conjunction with the prover service.

Core to the World ID protocol is the identity manager functionality. This is a smart contract that handles adding and removing users from the tree (a Merkle Tree) of identities. In doing so, it constitutes a public record of the identities included in the World ID system.

Thinking about upgrades

For a contract that is so crucial to the operation of World ID, we needed to make sure that it was possible for us to fix bugs without feeling the ripple effects in all of the services that depend on it. These services were both internal to Worldcoin project contributors, and also external to the project.

So what did this mean? We had to find a way to make changes while also accomplishing the following two major conditions:

  1. Stable Address: The address at which the contract was deployed needed to be stable, so that changing the contract did not require every downstream service to be reconfigured.
  2. No Data Migration: With the complexity of the data model, migrating data between two contracts would be so costly as to be infeasible.

An overview of upgradability

Fundamentally, an upgradability mechanism boils down to some way of changing the code that is executed on chain, while preserving data and not altering already deployed code. The Ethereum community has settled on five main classes of approach.

  1. Contract Migration
  2. Data Separation
  3. Proxy Pattern
  4. Diamond Pattern
  5. Strategy Pattern

Contract migration

The simplest approach to contract upgrades, is to just deploy a new contract with new code. This new contract is deployed with empty state, and then state from the old contract has to be migrated.

When migrating contracts, you deploy a new contract with the necessary code changes and then migrate the data. This changes the address you have to work with.
When migrating contracts, you deploy a new contract with the necessary code changes and then migrate the data. This changes the address you have to work with.

Pros

  1. This approach is very simple to understand and implement.
  2. You can change the interface to the new contract and the data representation during migration.

Cons

  1. Migration can be very expensive with lots of data.
  2. Services external to the chain have to change the addresses they depend on.
  3. Data can be corrupted during migration as it is an active process.

While the sheer simplicity and clarity of this approach is beneficial, the cost of migrating data would be prohibitive for World ID. Furthermore, the need to swap all system components at once when making the change is very difficult to coordinate in real life. For World ID it would need to migrate components such as the signup sequencer, router, and both the iOS and Android apps, not to mention external clients.

Data separation

This approach uses separate contracts to store the business logic and data. The logic contract owns the storage contract, and the storage contract guards all operations as coming from that address.

When using the data separation approach, you deploy a new contract and then tell that contract where to look for data. You also have to tell the data contract the address of the new implementation so it can enforce access control.
When using the data separation approach, you deploy a new contract and then tell that contract where to look for data. You also have to tell the data contract the address of the new implementation so it can enforce access control.

To perform an upgrade, you deploy a new logic contract (much like the Contract Migration approach) but only need to update the storage contract with the new address.

Pros

  1. Very clear, and the data obviously remains in one place.
  2. You can update the functional interface when changing the new logic contract.

Cons

  1. You have to be careful to register the new implementation contract with the storage at the same time as it taking ownership of the storage. Not doing so can break the system or leave you open to malicious upgrades.
  2. The data interface is fixed and cannot be changed.
  3. Addresses still need to be changed for off-chain services.

While this would avoid the need to migrate data, this shares the major downside with the contract migration approach. As an upgrade here would change the address, all of the services depending on that address would beed to be updated. In a system as complex as World ID, that is prohibitive.

Furthermore, not being able to change the data interface locks World ID into having to determine this up-front. We know that we cannot anticipate all uses ahead of time, so this is another reason that this is a poor choice.

Proxy pattern

In order to solve some of the deficiencies of the previously mentioned mechanisms, the community came up with the "Proxy Pattern". To best understand how this works, a quick look at DELEGATECALL can help.

DELEGATECALL is a mechanism by which one contract can execute code stored in another contract while using its own storage and address.

The Proxy Pattern consists of two contracts. One—called the "Proxy"—has a stable address and provides storage, while another—called the "Implementation"—actually contains the code that gets run.

The Proxy contract uses DELEGATECALL in conjunction with a "fallback function" that is executed whenever a function call does not match. In this fallback it calls into the implementation contract's code, which then performs operations using the storage of the proxy contract.

For the proxy pattern, an upgrade involves first deploying a new implementation, and then upgrading the proxy to use that new implementation. The data never moves from being stored in the proxy itself.
For the proxy pattern, an upgrade involves first deploying a new implementation, and then upgrading the proxy to use that new implementation. The data never moves from being stored in the proxy itself.

This makes it possible to upgrade just the implementation contract, while keeping the storage and address of the proxy contract the same. This is the pattern implemented by the OpenZeppelin Proxy library.

Pros

  1. The address stays the same after an upgrade, and the data does not move.
  2. You can change the interface of the logic contract.
  3. You can also change how data is represented if need be.
  4. There is wide library and ecosystem support for this pattern.

Cons

  1. Without utilizing one of the libraries out there, implementing all of the required features (upgrades, passthrough, authentication, and so on) can be complex and difficult to get right.
  2. Normal constructors do not work for implementation contracts. You have to use an initializer (or chain thereof) instead. The issue with this is that it is possible to leave a contract in an uninitialized state in which its access control does not work and data is inherently corrupt.
  3. Deployment requires careful orchestration, as you need to deploy the implementation first and subsequently deploy the proxy and initialize the implementation. Getting this wrong can leave the contract open to compromise.
  4. You cannot remove storage members in the implementation, only add them. If you could remove them, it is possible that a new implementation would refer to a different type at a given slot, and hence access corrupt data.

This approach satisfies the two major requirements of an upgrade for World ID in that the address stays the same and the data does not move. That makes it an excellent fit for the system, even with the complexities it introduces, as it provides the necessary flexibility to change the interface, fix bugs, and even change data storage if needed.

Diamond pattern

The Diamond Pattern improves on the Proxy Pattern by allowing delegation of calls from the proxy contract to more than one implementation contract.

With the diamond pattern you can upgrade facets of the implementation individually without upgrading others.
With the diamond pattern you can upgrade facets of the implementation individually without upgrading others.

Where the Proxy Pattern just blindly DELEGATECALLs to the implementation contract, here the proxy contract has to look up which contract to delegate to for a given function selector. This allows both more fine-grained access control and upgrades.

Pros

  1. The address stays the same after an upgrade, and the data does not move.
  2. The logic contracts can be upgraded independently, making it a more modular approach.
  3. It allows you to exceed the 24KB size limit for smart contracts by sharding the implementations appropriately.
  4. There is library support to help you do it right.

Cons

  1. As an extension of the proxy pattern described above, it shares the same downsides.
  2. As there are many independent implementation contracts, access control can become complicated as there is not necessarily any enforcement as to using the same access mechanisms.
  3. Significant developer care must be taken to ensure that all of the facets have the same view of the contract’s storage. If this is not done, storage corruption easily occurs.

While it might be nice to be able to upgrade individual portions of a contract separately, World ID’s implementation inherently is quite coupled. The opportunities for splitting it into additional facets are small, meaning that the diamond pattern does not bring any significant benefits to the system’s implementation.

Strategy pattern

Otherwise known as "Satellite Contracts", this is an implementation of a classic programming pattern. Functions are implemented in independent contracts that can be swapped out, but business logic also resides in the main contract.

With the strategy pattern, you can deploy a new version of a function and then tell the main contract to use it.
With the strategy pattern, you can deploy a new version of a function and then tell the main contract to use it.

Pros

  1. A highly modular solution that allows fine-grained upgrades.
  2. These fine-grained satellites do not need to worry about storage as that still resides in the main contract.

Cons

  1. The main contract still contains some functionality. If a bug is found in this functionality the only recourse is to deploy a new version of the main contract (and hence suffer an address change).
  2. There is no means by which the storage layout can be changed, meaning it needs to be correct from the start.

All told, the fact that this approach can potentially occur an address change in rare circumstances may seem relatively innocuous. At the scale of World ID, however, things that are comparatively rare have to be planned for, and hence this approach is not a good fit for the system.

The choice for World ID

So, to recap, what properties did we need for World ID?

  • Data migrations needed to be avoided as the large amount of data and its storage patterns were not amenable to migration.
  • The address had to stay stable, as many external services would be configured to work with a given identity manager deployment.

Furthermore, it was a stretch goal to make it possible for all contract logic to be able to be upgraded, including the upgrade logic itself.

If you have been paying attention, you can instantly see that this rules out contract migration, data separation, and the strategy pattern. Each of those makes it impossible to meet one of the above requirements.

What we ended up settling on was the proxy pattern.

You might ask why not go for the diamond pattern given it is even more granular, but the proxy pattern seemed a better choice to us for the following reasons.

  • The proxy pattern is supported by multiple libraries, the best known of which is OpenZeppelin Contracts. The library as a whole provides audited contracts for a wide variety of on-chain standards, but we cared about the battle-tested proxy implementation. They implement both UUPSProxy and TransparentProxy allowing us to fine-tune the exact approach we wanted.
  • It also comes with tooling that can help ensure that you deploy the paired contracts correctly, and avoid storage clashes when upgrading.
  • All told, this makes the proxy pattern far easier to get right, which is important when it comes to such a large and important system.
  • The functionality of the identity manager does not lend itself to being split up into facets to suit the diamond pattern, making it an unnecessary amount of extra complexity.

So that is what we did, implementing the proxy on top of the aforementioned OpenZeppelin Contracts library, and then building the first implementation contract.

We consider this to be a well-documented example of how to use the proxy pattern in practice, and it has already been used to deploy new features to production that allow user identities to be removed or changed in the identity tree. Without the ability to perform these upgrades, World ID would have needed to deploy new contracts, migrate lots of data, and change myriad addresses external to the chain.

Instead, they could just create a new implementation on top of the old one, and upgrade on chain in a near-seamless fashion.


Hope you have enjoyed this post. If you'd like to stay up to date and receive emails when new content is published, subscribe here!

Continue Reading

Introducing Hints in Cairo programming language

Reilabs introduces Cairo Hints, an extension to Cairo language that makes STARK programs easier to implement and cheaper to execute. Hints enable developers to supplement their programs with data that is difficult to obtain in ZK circuits, allowing them to solve new classes of problems with Cairo.

Zero Knowledge Systems You Can Trust

The EVM’s ability to run computations on-chain has a major weakness: the cost of running code is often too great. Given a lot of the use-cases for this kind of public computation involve more interesting operations, we’ve seen a rapid rise in the use of systems that aim to alleviate those costs.