Upgradeable Smart Contracts – Proxy Pattern

Upgradeable Smart Contracts? Does that really exist?

The biggest disadvantage of Ethereum is that every transaction and every contract deployed is immutable. That means that one cannot change the source code of a smart contract after deployment.

TL;DR

Smart contract developers are under constant pressure to write flawless code because it’s not possible to change a single line of smart contract source code after it has been deployed to a blockchain. 

It basically means that there’s no room for error. Smart contracts are vulnerable to zero-day exploits because of their immutable nature. Also, the source code of every smart contract is live 24/7 from day one, giving the attackers unfair advantage. 

And, of course, there’s no such thing as constant updates with new features from iteration to iteration as we are used to in regular software development.

Although it is not possible to upgrade the code you already deployed, it’s possible to set-up a proxy contract architecture that will allow you to use new deployed contracts if there is a need for some sort of upgrade.

Proxy contract stores an address of the latest deployed contract and redirect calls to that, currently valid, logic. If one upgrades contract logic, hence deploying a new smart contract, one just needs to update the reference variable in a Proxy contract with that new contract address.

User —- tx —> Proxy ———-> Implementation_v0
|
————> Implementation_v1
|
————> Implementation_v2

Proxy pattern architecture relies on low-level delegatecalls. Although Solidity provides a delegatecall function, it only returns true/false whether the call succeeded and doesn’t allow you to manage the returned data.

Proxy pattern architecture

Whenever contract A delegates a call to another contract B, it executes the code of contract B in the context of contract A. Every transaction has values:

  • msg.sender (from which address the function call came from)
  • msg.value (the amount of wei sent with a message to a contract; usually zero)
  • msg.data (the complete calldata which is a non-modifiable, non-persistent area where function arguments are stored)

This means that msg.value and msg.sender values will be kept and every storage modification will impact the storage of contract A. In order to delegate a call to another solidity contract function (from A: Proxy to B: Logic) we have to pass it the msg.data the Proxy received.

As an example, we are going to take a look at Open Zeppelin’s Proxy contract implementation of delegatecall function.

assembly {
let ptr := mload(0x40)

// (1) copy incoming call data
calldatacopy(ptr, 0, calldatasize)

// (2) forward call to logic contract
let result := delegatecall(gas, _impl, ptr, calldatasize, 0, 0)
let size := returndatasize

// (3) retrieve return data
returndatacopy(ptr, 0, size)

// (4) forward return data back to caller
switch result
case 0 { revert(ptr, size) }
default { return(ptr, size) }
}

Note: In Solidity, the memory slot at position 0x40 is special as it contains the value for the next available free memory pointer.

Parameters:

  • gas we pass in the gas needed to execute the function
  • _impl the address of the logic contract we’re calling
  • ptr the memory pointer for where data starts
  • calldatasize the size of the data we’re passing.
  • 0 for data out representing the returned value from calling the logic contract. This is unused because we do not yet know the size of data out and therefore cannot assign it to a variable. We can still access this information using returndata opcode later
  • 0 for size out. This is unused because we didn’t get a chance to create a temp variable to store data out, since we didn’t know the size of it prior to calling the other contract. We can get this value using an alternative way by calling the returndatasize opcode later

Problem: Shared Storage

Storage and Memory keywords in Solidity are analogous to Computer’s hard drive and Computer’s RAM. Much like RAM, Memory in Solidity is a temporary place to store data whereas Storage holds data between function calls. The Solidity Smart Contract can use any amount of memory during the execution but once the execution stops, the Memory is completely wiped off for the next execution. Whereas Storage on the other hand is persistent, each execution of the Smart contract has access to the data previously stored on the storage area.

The main thing to understand is that storage layout begins at position 0 and increments for each new state variable.

Here’s a simplified explanation of how it works:

  • Storage layout for state variables begin at position 0 and increments for each new state variable. So the first state variable is stored at position 0, the second state variable is stored at position 1, the third stored at position 2, etc.
  • Each struct or array element uses the next storage position, as if each one was defined separately on its own.

⚠️  A PROXY CONTRACT AND ITS DELEGATE/LOGIC CONTRACTS SHARE THE SAME STORAGE LAYOUT

Let’s see how one can overcome this limitation.

Solution: Three Main Proxy Patterns

There are three main proxy patterns and each of them tries to answer only one question: how to ensure that the logic contract does not overwrite state variables that are used in the proxy for upgradeability.

  1. Inherited Storage
  2. Eternal Storage
  3. Unstructured Storage

Bear in mind that if the Proxy contract has a state variable to keep track of the latest logic contract address at some storage slot and the logic contract doesn’t know about it, then the logic contract could store some other data in the same slot thus overwriting the proxy’s critical information.

Storage Collision

Suppose that the proxy stores the logic contract’s address in its only variable address public _implementation;.
Now, suppose that the logic contract is a basic ERC-20 token whose first variable is address public _owner.
Both variables are 32 bytes in size, and as far as the EVM knows, occupy the first slot of the resulting execution flow of a proxied call. When the logic contract writes to _owner, it does so in the scope of the proxy’s state, and in reality, writes to _implementation. This problem can be referred to as a “storage collision”.

Storage Collision

Let’s now see what are the key differences between these three patterns in terms of handling storage collision.

1) Inherited Storage

Idea of this approach is to start with one storage structure and then each version will follow the storage structure of the previous one.

Each new version of upgrade cannot change the storage structure of the previous implementations, but can add new state variables on top of that storage, ergo inheriting it.

Both the proxy and the logic contract inherit the same storage structure to ensure that both adhere to storing the necessary proxy state variables.

Downsides

  1. New versions need to inherit storage contracts that may contain many state variables that they don’t use.
  2. New versions become tightly coupled to specific proxy contracts and cannot be used by other proxy contracts that declare different state variables.

2) Eternal Storage

Idea of this approach is to have the same generic, immutable storage structure for any contract. This is a set of Solidity mappings for each type variable and one cannot change this storage structure of mappings.

We can imagine this like some sort of API for smart contracts. A Proxy contract and Logic contracts use the same Eternal Storage API to create and use storage variables  without conflict.

All versions of the logic contract must always use the eternal storage structure defined in the beginning.

Downsides:

  1. Clumsy syntax for state variables.
  2. It works directly for simple values and arrays but it does not work in a simple, generic way for mapping and struct values.
  3. Not easy to see at a glance what state variables exist because they are not declared together anywhere.

3) Unstructured Storage

The Unstructured Storage pattern is similar to Inherited Storage but doesn’t require the logic contract to inherit any state variables associated with upgradeability. This pattern uses an unstructured storage slot defined in the proxy contract to save the data required for upgradeability.

Let’s take back to the Storage collision problem.

Instead of storing the _implementation address at the proxy’s first storage slot, Unstructured Storage pattern chooses a pseudo random slot instead. This principle is the same and for any other variable that a Proxy contract may have.

Unstructured Storage

Random slot one can generate like this

bytes32 private constant implementationPosition = bytes32(uint256(
keccak256(‘eip1967.proxy.implementation’)) – 1
));

Downsides:

  1. A getter and setter function needs to be defined and used for each storage variable.
  2. This works for simple values. It does not work for structs or mappings.

What about constructor?

In Solidity, a code inside the constructor function is executed only once, when the contract instance is deployed. Unfortunately, the constructor of Logic smart contract won’t be called in the context of Proxy’s smart contract state.

Because of that, one needs to have a special ‘initializer’ function which will have all the source code of the constructor’s implementation. Also, one should take care that this function can be called only once (from the Proxy contract).

Final thoughts: Which proxy pattern is the right one for me?

Keep in mind that, in order to have an ability to upgrade, your smart contracts need to be preconfigured to inherit all necessary contracts and libraries from day one.

I am personally a fan of the third, Unstructured storage, approach. It gives you the most freedom for rearranging storage. I imagine it kinda like NoSql Databases. However, developers must be really careful and skillful in order to implement it right. Also, “Open Zeppelin” team is currently moving forward with the Unstructured Storage approach, so that’s a plus, too.

Inherited storage is quite similar to Unstructured storage, but much more easy to implement. So, if you don’t have some complex logic in your code and you are not planning some major upgrades (or not planning it at all) then go with the Inherited Storage approach.

Eternal storage “ties our hands” when upgrading storage comes in. But in some cases, that’s more an advantage than a disadvantage. For example, if you are developing ERC 20 tokens, there are only three mandatory metadata needed. So in this situation, the Eternal Storage approach is a good choice when it comes to deciding between Proxy Patterns.

LinkedIn Twitter Facebook

Comments (0)

Your thoughts
Name
*Your email address will not be published.

six + twelve =

Interested in working with us?

Let’s find the right solution for your blockchain ideas.