Upgradeable Ethereum Smart Contracts

Nearly every developer that writes Ethereum smart contracts has heard the question: “What do I do if I want to expand the functionality of my contracts? What if there is a bug in the contract that leads to a loss of funds? What if a vulnerability in the Solidity compiler is discovered (which has happened before)?” After all, the contracts cannot be changed once uploaded to the network. This seems strange at first; Why can’t the code be updated, you might ask. However, the impossibility of altering Ethereum smart contracts is in fact a strength, as users would likely have less faith in contracts that lack this feature. Here we will analyze several approaches that allow us to make some changes to smart contracts.

Divide smart contracts into several related contracts

Using this approach, you can save addresses of the active contracts in storage. Often, there is one contract responsible for storing and changing links for the entire system. For example, you can have a contract for the sale of tokens, in which the rules for calculating the number of tokens that need to be sent to the wallet the Ether came from are not clearly spelled out. The quantity calculations can be done in a separate contract, which can be replaced if necessary. This is a rather common approach that is also used outside of Solidity. One of the main disadvantages of this approach is that you cannot change the interface of contracts external to the entire system, and you cannot add or remove functions.

Use delegatecall to proxy a call to another contract

In EIP-7 delegatecall opcode was implemented. It allows to delegate execution to other contract, but execution context stays the same (storage of the caller is used, and msg.sender, msg.value don’t change). You can find several examples of this mechanism in practice, and they all include the use of a Solidity assembly. Without the assembly, it is impossible to return any value from delegatecall. The basic idea behind all methods that use delegatecall to proxy is the use of the fallback function. In it developers need to read the calldata and send it through delegatecall.

Here are some examples of this method in use:

Upgradeable stores the size of the return values in the mapping

This is the implementation of the fallback function from the above:

bytes4 sig; 
assembly {sig: = calldataload (0)}
var len = _sizes [sig];
var target = _dest;
assembly { 
// return _dest.delegatecall (msg.data)
calldatacopy (0x0, 0x0, calldatasize)
delegatecall (sub (gas, 10000), target, 0x0, calldatasize, 0, len)
return (0, len)
}

The size of the returned value (in bytes) is stored in mapping _sizes. This field must be filled in within Storage when updating the contract. The main drawback of this approach is that the return value is rigidly bound to the signature of the function being called up, meaning that returning a string of arbitrary size or byte array does not work. Additionally, Storage access is quite expensive, and for this example we already have two call-ups from storage: when we access the _dest field, and when we access the _sizes field.

EVM assembly tricks: always use a response size of 32 bytes

This code is very similar to the one we showed before, but the result size is always 32 bytes, which is a conscious decision. First, most Solidity types fit into 32 bytes, and secondly, by avoiding a second call-up from Storage, we save quite a bit gas. Later we will estimate how much gas is used for each option.

Using the new instructions resultdatasize and resultdatacopy

These instructions appeared on the Ethereum network after the last fork (Byzantium, October 17, 2017). The instructions allow developers to get the size of the response returned from the call / delegatecall, and copy the response into memory. That is, we were able to implement a full-fledged proxy for any returndata sizes. Here is the code:

assembly {
let _target := sload(0)
calldatacopy(0x0, 0x0, calldatasize)
let retval := delegatecall(gas, _target, 0x0, calldatasize, 0x0, 0)
let returnsize := returndatasize
returndatacopy(0x0, 0x0, returnsize)
switch retval case 0 {revert(0, 0)} default {return (0, returnsize)}
}

Gas usage

Here we analyze the gas usage for each approach. The test shows that all three of the above methods increase the use of gas by 1,000–1,500, which is approximately 2% of the average transaction cost to change Storage.

Difficulties in use

Unfortunately, the use of these techniques is limited. First, for the contract updates to work, the structure of data storage in the contract cannot be changed (you cannot rearrange fields, or delete fields). In new versions of the contract, fields can be added. It is also necessary to carefully delimit access to a function that changes the address of the active contract. It is important to note that contract users’ trust of these contracts will likely be lower than of unchangeable contracts. On the other hand, you can provide a test period, during which the new version of the contract can be rolled back, and after which the contract version will be fixed and can no longer change.

Examples of implementing updates

Here are few contracts that will help make the upgrade easier and more reliable: Upgradeable — this contract verifies that the target (address of the active contract version) is stored in the same slot as in the current version. Similarly, you can implement validations to other storage fields (for example, see Target.sol) If you plan to implement Upgradeable contracts, be sure to look at Tests for the Upgradeable contract.

Before these contracts are released into the network, it is necessary to test all options, or you could end up without a functioning contract after the next update, with no possibility of updating it.