Upgradable Contracts in Solidity

Solving the riddle of how to improve immutable Ethereum code

At Cardstack we are building an application framework that will enable developers to create and host dApps that put user experience first. As part of this effort we’re creating an extensible and future-proof token to power our framework. Enter: Upgradable Contracts.

So why are upgradable contracts important? Requirements change, the market changes, the world becomes more complex, entropy increases — software, much like biology, is in a constant state of flux. The most successful software is the software that adapts best to a changing world. A smart contract isn’t so smart if it can’t evolve over time.

We’ve found a way to solve this crucial problem.

The core of the approach that we have taken at Cardstack was greatly influenced by Elena Dimitrova’s blog post on how Colony builds upgradable contracts. Specifically, the most important thing to consider when upgrading contracts is how to preserve the state of the original contract in the upgraded contract. In a classic software engineering approach, Elena describes how the state of the contract can be separated from the functionality of the contract. This approach allows multiple contracts to share the same state.

When we built upon this approach, we added an additional component to the solution: a “registry contract”. The registry contract performs the bookkeeping necessary to keep track of the shared contract state, keep track of the different versions of each contract, and to bind and unbind the shared state to contracts as new versions of the contracts are added to the network. Furthermore, the registry contract acts as a bridge between the Ethereum Name Service (ENS) and the latest version of our contracts.

Storage

First, let’s talk a little more about how we are handling the contract state. As in the approach described in Elena’s post, we have created a “storage contract” that basically acts a bucket, carrying the state from one version of our contract to the next. In this storage contract, we can accommodate many different types of data in a name-value store. We have also created a “ledger contract”, as in the Colony approach, a specialized form of a storage contract designed specifically for ledgers. However, in our storage contract we’ve created a map of ledgers you can use in place of the “ledger contract”, which we’ll most likely end up doing in the future.

ExternalStorage.sol

The ExternalStorage.sol is our “storage contract”. In it you can see we provide slots for storing all types of data, including more complex structures like actual ledgers, and even “multi-ledgers” (a word I just invented), which is a map of ledgers. This is what we use to store the allowances for the transferFrom() ERC20 function of our contract. For each type, we have a record parameter which is the “key” in our storage contract’s key value store. In our token’s library contract you can see how we set and get these values from our storage:

snippet from CstLibrary.sol

Another interesting thing to note: we’ve done some extra work in our storage contract to allow our ledgers (and multi-ledgers) to be iterate-able so that our storage is more easily introspected. It does cost additional gas to maintain such structures; but for our specific use-cases, we felt the additional gas fees were worth the trade-off.

For completeness, the specialized form of our ledger storage appears below:

CstLedger.sol

You can see how we use our ledger storage in our main ERC20 token contract. Below are an example of a few of the ERC20 functions that leverage the ledger-specific storage:

Snippet from CardstackToken.sol

Registry

The next component of our upgradable approach is the registry. The registry creates a nice abstraction over the actual Ethereum addresses for the contracts that participate in our ecosystem. The idea is that we only need to know the “name” of a contract, and query the registry for the most recent version of the contract that has a particular name. In addition, contracts that need to use the shared storage do not need to know the address of the shared storage; rather, they can declare to the registry their interest in using a particular bucket of storage (which also has a name), and the registry will take care of binding the storage to the contract that declared interest in using it. So at a real high-level, the registry is doing the work of keeping track of the addresses of all the contracts in our system and mapping the names of contracts to the addresses of contracts.

registry.addStorage()

After the storage contract is deployed we add it to the registry and give the storage contract a name by calling registry.addStorage(). From this point forward, when contracts are registered with the registry they can declare their intent to use the storage by name. The registry is then responsible for resolving the storage name to a specific address, as well as granting the contract “permissions” to use the storage (more on this later).

registry.registerContract()

For contracts that participate in our ecosystem that are not storage contracts, e.g. our token contract, we use registry.registerContract() to add them to our registry. Our contracts implement a storable.sol interface, which has the following declaration:

storable.sol

As you may see, the ledger name and storage name used by the token contract are specified in the constructor of our token contract and then just returned in the storable.sol functions. Additionally, our token contract implements a configurable.sol interface, which declares a configureFromStorage() function. In this function we the registry to resolve the storage names to actual addresses:

CardstackToken.sol (snippet)

And at this point we have a token contract that is bound to the storage contract(s) for which it is has declared an interest.

So a quick recap:

  1. We add storage to the registry and assign the storage a name.
  2. We create contracts (in this case a token contract) that declare an interest in using storage with a specific name.
  3. We register a contract with the registry, which then resolves the names of the storage to specific Ethereum addresses within the contract being registered.

upgradeContract()

The ability to upgrade a contract is exposed in the upgradeContract() function. This function’s parameters are the name of the contract and the address of the “successor” contract (where the current contract becomes the predecessor contract). The registry will use the same mechanism that was leveraged in the registration of the predecessor contract to discover the storage that the successor contract has declared it is interested in using. The registry resolves the address for the storage in the successor contract and binds the storage within the successor contract using configureFromStorage().

During the upgrade operation, the permissions of the predecessor contract upon the storage is revoked, so that the predecessor contract can no longer manipulate the storage. Likewise, the permissions of the successor contract upon the storage is granted, so that the successor contract can manipulate the storage. Additionally, the modifier unlessUpgraded will prevent the predecessor contract’s functions from executing, and instead cause them to revert. The predecessor contract also acquires a property that points to the successor contract as result of the registry upgrading the contract, so the clients of the contract can discover the new address of the upgraded contract (the successor contract). Finally a ContractUpgraded event is emitted when the contract upgrades (as well as an AddrChanged event for EIP-137 ENS resolver support — more on that later).

But what about “permissions”?

So I mentioned the word “permissions” a few times in this post — what do I mean by that? The Cardstack contracts have the ability to designate addresses from which msg.send calls can invoke privileged functions. In addition, we have devised two different levels (as of the time of this writing) of privileged access (most likely there will be many more, and we’ll probably want to further generalize this solution as time goes on). The lower level role of privileged access is what I’m calling an “admin”, and the higher level role of privileged access is what I’m calling a “super admin”. Generally admins have the ability to modify storage, while super admins have the ability to create admins, register contracts, and upgrade contracts.

We use a administratable.sol base contract that supplies the ability to add and remove admins and super admins as well as modifiers to designate that functions are only able to be invoked by admins and super admins, or more restrictively, just super admins. Additionally, we use iterate-able maps so that we can easily introspect our administrative addresses.

administrable.sol

For reference the full Registry.sol appears below which shows how storage is added, how contracts are registered, and how contracts are upgraded:

Registry.sol

Ethereum Name Service (ENS)

The capstone to this whole process is the Ethereum Naming Service (ENS). ENS allows contracts to be addressed using a human friendly name instead of a non-human friendly string of hexadecimal characters https://ens.domains/. This allows users to interact with a token contract at the address cardstack.eth instead of the address 0x66406f50624a645f36faa517c39049200d55c56e; much like being able to use the a domain name like https://google.com instead of https://172.217.12.206. The Cardstack registry adheres to EIP-137, which is the standard governing ENS, such that it can act as a “namehash” resolver (which is how ENS internally represents contract names). Because the registry is already keeping track of the address of the latest version of any contract, using it as a resolver for ENS is a very natural fit.

As part of the registration of a contract with the the Cardstack registry, the “namehash” of the contract name is supplied to the register() function. The namehash can be derived off-chain using a script (check out https://github.com/danfinlay/eth-ens-namehash) that converts the human-friendly contract name into a namehash. The registry contract’s address can then be used as the resolver ENS, such that it resolves the address of the latest version of a token contract for the namehashes that are presented to it.


Putting It All Together

The approach that Cardstack is taking to realize upgradable contracts in Solidity includes these 3 important features:

  1. Sequestering state into its own contract(s)
  2. Using a registry contract to manage contract addresses and to bind contracts to their state
  3. Resolving ENS namehash lookups to contract addresses

By embracing these approaches for contract development, Cardstack can create contracts that can evolve over time to satisfy the ever-changing needs of the people that use our applications.

This is the first of hopefully many updates we’ll be sharing on our experience in Solidity at Cardstack — stay tuned.


Read More

Join our community

To learn more about Cardstack, visit https://cardstack.com.

Join the Cardstack community channel on Telegram at https://t.me/cardstack


Hassan (@habdelra) has been designing and building a system for digital scarcity and digital rights on the bitcoin blockchain since 2014. He has been working on open-source Cardstack codebase since 2015 focusing on bringing external data sources into the Cardstack framework. Hassan is leading the design and architecture efforts in Solidity for Cardstack, and is an expert on the trade-offs of on-chain and off-chain computation and storage. Hassan has a Bachelor’s degree in Computer Science and Mathematics from the Colorado School of Mines.