Published on

Understanding How Solidity Upgradeable Unstructured Proxies Work

Authors

One of the challenges with smart contracts is that they are immutable. This means once you deploy them it is difficult and in some cases impossible to correct mistakes or add functionality to contracts.

Luckily there are approaches to upgrade the logic and (to some extent) the data used by your contract. How this works is not immediately intuitive. This post attempts to help clarify this.

If you just want to walk through some example code see the repo accompanying this article here. To get a good feel, have a look at:

  • The contracts in the contracts folder to see a proxy contract, a logic contract and a logic contract upgrade
  • The tests to see how the proxy is used.
  • The migrations folder to see how to deploy a proxy.

What is a Proxy Exactly?

The contract that helps facilitate upgrades and relay calls to logic contracts is known as the proxy contract. The word proxy has the following definition in the Cambridge Dictionary:

authority given to a person to act for someone else,

This is exactly what proxy contracts do: they are given the authority to relay calls to the newest version of a logic contract. How this is done will be discussed in more detail throughout this post.

Why Use Unstructured Proxies to Enable Upgradeability?

Open Zeppelin has really solid opensource contracts for Ethereum standards like ERC20 and ERC721. They have plenty written about this contract upgradeability issue.

OpenZeppelin seems to have settled on the unstructured proxy approach as that is what they use in Zeppelin OS/SDK. As a result I will discuss specifically the unstructured proxy approach.

Dissecting an Unstructured Proxy - Understanding how they Enable Upgradeability

An unstructured proxy has 2 pieces:

  1. The proxy contract
    1. The address of this contract does not change
    2. The storage of this contract does not change
    3. The logic contract is told by the proxy where its storage starts
  2. The logic contract
    1. The address of this will change when you make upgrades
    2. This contract is instructed by the proxy contract where its storage starts and ends
    3. Logic contracts can only see the current contract state if they are called via the proxy contract
      1. If you look directly at the deployed contract's data (bypassing the proxy) it will not be the proxy's view of the data

Proxy Contract

The high-level principle behind this is that any dApps referring to your contract:

  • Will refer to the proxy contract's address
  • The proxy contract internally will redirect calls to the most recent upgraded version of the logic contract
  • The proxy contract points to the memory location where the storage of the newest version of the logic contract starts.

The core behind a proxy contract is the following parts:

  1. The Solidity fallback method
  2. Solidity's delegatecall
  3. The implementationPosition and proxyOwnerPosition variables

1 - Solidity's Fallback Method

To help in understanding this part let's look at some code:

    function() external payable {
       // your fallback logic here
       // ...
    }

The official docs discuss these types of methods here. In summary, the docs say that:

  • The fallback function has to have the signature above.
  • Solidity looks for this function when no other function signatures in the contract match the call to the contract.
  • The original contract parameters will be passed into the fallback function if it is defined.

For example, say we want to call a function with the signature function bar(uint age) on contract Foo. But the contract does not have any function with this signature:

  • If you have no fallback method Solidity will simply give you an error.
  • If you do have a fallback method as defined above you can handle these unknown function calls however you want where you have access to the unknown function parameters.

Proxy contracts take advantage of this by capturing a call to another smart contract's function which can have any valid function signature. It then relays this call using this fallback method to the appropriate version of the logic contract. If there are any return types or errors then these are returned to the original function caller. How this is done exactly will be discussed further down.

2 - Solidity's delegatecall

The official documentation describes this here. In summary this:

  • Allows contract A to be called from contract B
  • msg.sender and msg.value are still passed to contract A
    • i.e. msg.sender will not change to contract B's address - it will be the original sender's address.
  • Contract A is called in the context of contract B meaning A's storage will start where B says it does
    • But only when called via a delegatecall
    • If you call contract A directly its storage will start in the context of contract A so it cannot see the storage that was proxied.

The delegatecall code sample below is what all proxy contracts look like in the context of a fallback function:

    function() external payable {
        // the implementation method simply returns an address as defined by EIP897
	address implementation = implementation();
        assembly {
            calldatacopy(0, 0, calldatasize)

            let result := delegatecall(
                gas,
                implementation,
                0,
                calldatasize,
                0,
                0
            )

            returndatacopy(0, 0, returndatasize)

            switch result
                case 0 {
                    revert(0, returndatasize)
                }
                default {
                    return(0, returndatasize)
                }
        }
    }

This OpenZeppelin article does an excellent job describing what each part in the above does. Specifically, look from the first code block to after the Parameters section.

3 - The implementationPosition and proxyOwnerPosition Variables

In Solidity the location used by a contracts variables is fixed once the contract is deployed. Special care needs to be taken to avoid having variables colliding at the same memory location/s.

The unstructured proxy uses a hash to get a random enough pointer to the memory location that a logic contract's implementation will use for its storage. This same approach is used to store the address of the proxy owner for the same reason - to avoid logic contracts overlapping the owner's memory location.

OpenZeppelin's article on proxies, specifically the unstructured proxy section describes this well. This can be found here.

In proxy implementations, these variables are defined and used as below (the string values being hashed are arbitrary, you can use your own):

    bytes32 private constant implementationPosition = keccak256(
        "com.example.implementation.address"
    );

    bytes32 private constant proxyOwnerPosition = keccak256(
        "com.example.proxy.owner"
    );

//...

    // setter to set the position of an implementation from the implementation position onwards
    function _setImplementation(address _newImplementation) internal {
        bytes32 position = implementationPosition;
        assembly {
            sstore(position, _newImplementation)
        }
    }

    // retrieving the address at the implementation position
    function implementation() public view returns (address impl) {
        bytes32 position = implementationPosition;
        assembly {
            impl := sload(position)
        }
    }

//...

    function proxyOwner() public view returns (address owner) {
        bytes32 position = proxyOwnerPosition;
        assembly {
            owner := sload(position)
        }
    }

    function _setUpgradeabilityOwner(address _newProxyOwner) internal {
        bytes32 position = proxyOwnerPosition;
        assembly {
            sstore(position, _newProxyOwner)
        }
    }

Logic Contract

This is mostly coded the way you would usually code your logic contract. The key difference is that you would not initialize initial variables using a constructor. Instead, you have to make a factory method known as an initializer.

OpenZeppelin provides a parent contract (as linked above) called Initializable.sol. This is needed as the factory method you use to set the initial variables for a method is just a standard method, nothing special. This means without some sort of controls put in place this method can be hit to reset the contracts state.

The Initializable contract provides the initializer modifier which prevents this by keeping track of whether or not this method has been hit before.

Other than this small change the rest of the contract is coded in a standard way meaning you can still write your unit tests against it as you normally would.

When upgrading a logic contract it is recommended you do this by creating a new contract and extend the contract being upgraded. This is needed as it helps ensure that the storage of the contract we are upgrading is being honoured and not overridden. The child contract can see internal and public variables of the parent and therefore do not need to respecify them. The child can then freely define new variables without having them collide with the parent.

How to Use a Proxied Contract

The basic usage pattern for unstructured proxies is:

  1. Once off only:
    1. Deploy the proxy contract.
  2. When Upgrading:
    1. Deploy the new version of a logic contract.
    2. Tell the deployed proxy contract to relay calls to the newly deployed logic contract's address.
  3. Using it in code:
    1. Use the ABI/wrapper of the logic contract generated by truffle or whatever tool you use to generate contract wrappers.
    2. Point this wrapper to the proxy contract address.

Points 1 and 2 above can be seen with the following truffle migration script:

const PersonRegistry = artifacts.require('./registry/PersonRegistry.sol')
const ERC20 = artifacts.require('./token/ERC20.sol')
const Proxy = artifacts.require('./Proxy.sol')

async function deployPersonRegistryProxy(deployer, accounts) {
  await deployer.deploy(PersonRegistry)

  const personRegistryInstance = await PersonRegistry.deployed()
  const personRegistryProxy = await Proxy.new()
  await personRegistryProxy.upgradeTo(personRegistryInstance.address)

  const proxiedPersonRegistry = await PersonRegistry.at(personRegistryProxy.address)
  await proxiedPersonRegistry.initialize(accounts[0])

  await proxiedPersonRegistry.register('John', 'Smith')
  await proxiedPersonRegistry.register('Jane', 'Doe')

  return personRegistryProxy
}

async function deployERC20Proxy(bicRegistryProxyAddress, deployer, accounts) {
  await deployer.deploy(ERC20)
  const erc20Instance = await ERC20.deployed()
  const erc20Proxy = await Proxy.new()
  await erc20Proxy.upgradeTo(erc20Instance.address)

  const proxiedErc20 = await ERC20.at(erc20Proxy.address)
  await proxiedErc20.initialize('XYZ Coin', 'XYZ', 10)

  return erc20Proxy
}

module.exports = async (deployer, _, accounts) => {
  await deployer.deploy(Proxy)
  const personRegistryProxy = await deployPersonRegistryProxy(deployer, accounts)
  const erc20Proxy = await deployERC20Proxy(personRegistryProxy.address, deployer, accounts)

  console.log(`personRegistryProxy.address: [${personRegistryProxy.address}]`)
  console.log(`erc20Proxy.address: [${erc20Proxy.address}]`)
}

Some points about the above:

  • We deploy the proxy code once. But to create a new instance to proxy a new type of logic contract we call: const personRegistryProxy = await Proxy.new();
  • We deploy the logic contract as normal
  • We point the proxy to the correct logic contract address by calling: await erc20Proxy.upgradeTo(erc20Instance.address);
  • We use the logic contracts ABI but the proxy contract's address to delegatecall the logic contract via the proxy contract: const proxiedErc20 = await ERC20.at(erc20Proxy.address);
    • The code generated by truffle are wrappers that know how to call the solidity contract methods.
    • By pointing this to the proxy address we are saying call the logic contract's methods but do it via the proxy contract.
  • We output the proxy's address so that we can use this in our dApps (we do not call the contract's deployed address directly)
  • We call any methods we need on the logic contract via its proxy to ensure the proxy's storage is used:
    const proxiedPersonRegistry = await PersonRegistry.at(personRegistryProxy.address);
    await proxiedPersonRegistry.initialize(accounts[0]);

    await proxiedPersonRegistry.register('John', 'Smith');

As mentioned in the beginning of this article you can refer to the accompanying repo here to get a feel for how the solidity code looks (look at the contracts folder), how this code is deployed (look at the migrations folder) and how you would use a deployed proxy contract (have a look at the tests for this).