Upgradability & Proxies

How does upgradability work?

  • A proxy contract exists between the main implementation contract and the user.

  • The proxy contract serves as the entrypoint of interaction for users; it forwards transactions to the current implementation contract that contains the logic.

  • Proxy contract remains unchanged and always has the same address, but the implementation contract that the Proxy contract refers to can be changed. In this way, it can be considered "Upgradeable".

  • Proxies forward users' transaction via delegatecall, so the state is preserved on the proxy contract and the logic executed is retreived from the implementation contract.

  • This way data is not lost, even if the referenced implementation contract is changed.

How proxies work

Proxy contract

  • contract that acts as a proxy, delegating all calls to the contract it is the proxy for

  • serves as the storage component

Implementation Contract

  • Contract that contains the execution logic (state var, functions, etc)

  • Contract that you want to upgrade

The proxy contract stores the address of the implementation contract as a state variable.

All user calls go through the proxy and this proxy delegates the calls to the implementation contract at the address that proxy has stored, returning any data it received from logic layer to the caller or reverting for errors.

Only borrow the logic from implementation contract and execute it in proxy's context affecting proxy's state variables in storage.

Storage collision between Proxy and Implementation contract

One cannot just go around and simply declare address implementation in a proxy contract because that would cause storage collision with the storage of implementation which may have multiple variables in it at overlapping storage slots.

Solution is to write the implementation address into a pseudo-random slot. That slot position should be sufficiently random so that having a variable in implementation contract at same slot is negligible.

According to EIP-1967 one such slot could be calculated as:

bytes32 private constant implementationPosition = bytes32(uint256(
  keccak256('eip1967.proxy.implementation')) - 1
));

Every time implementation address needs to be accessed/modified this slot is read/written.

EIP-1967: A consistent location where proxies store the address of the logic contract they delegate to, as well as other proxy-specific information.

Storage collision between different Implementation version contracts

When upgrading to a new implementation, if a new state variable is added to implementation contract, it MUST be appended in storage layout. The new contract MUST extend the storage layout and NOT modify it. Otherwise, collisions may occur.

  • this can be done via inheritance

Initializing constructor code

Any initialization logic should run inside the proxy, as it preserves state. So having a constructor on the implementation contract is nullified as the constructor would assign values to state variables that are held within the context of the implementation contract - not the proxy.

To that end, we have an initialize function on the implementation that achieves the same end result as the constructor would, but is delegateCalled by the proxy.

This is just like a normal function but MUST be ensured that it is called only once; hence the initializer modifier.

Function clashes between Proxy and Implementation

The proxy contract may have functions of its own - this depends on the proxy pattern employed. In the case of transparent proxies, they contain the function, for example upgradeTo(address impl).

What if the implementation contract has a function with the same name i.e. upgradeTo(address someAddr)?There must be a mechanism to determine whether to delegate the call to implementation or not.

One such way (OpenZeppelin way) is by having an admin or owner address of the Proxy contract.

  • If the admin (i.e. msg.sender == admin) is making the calls to Proxy, it will not delegate the call but instead execute the function in Proxy itself, if it exists or reverts.

  • For any other address it simply delegates the call to implementation.

  • Thus, only an admin address can call upgradeTo(address impl) of the Proxy to upgrade to new version of implementation contract.

Transparent Proxy (TPP)

Transparent proxy pattern includes the upgrade functionality within the proxy contract itself. An admin role is assigned with privilege to interact with the proxy contract directly to update the referenced logic implementation address. Callers that do not have the admin privilege will have their call delegated to the implementation contract.

  • Implementation address - Located in a unique storage slot in the proxy contract (EIP-1967).

  • Upgrade logic - Located in the proxy contract with use of a modifier to re-route non-admin callers.

  • If the caller is the admin of the proxy, the proxy will not delegate any calls,

  • If the caller is any other address, the proxy will always delegate the call, even if the func sig matches one of the proxy’s own functions.

This pattern is very widely used for its upgradeability and protections against certain function and storage collision vulnerabilities.

Pro vs Con

Pros

  • Eliminates possibility of function clashing for admins, since they are never redirected to the implementation contract.

  • Since the upgrade logic lives on the proxy, if a proxy is left in an uninitialized state or if the implementation contract is selfdestructed, then the implementation can still be set to a new address.

  • Reduces risk of storage collisions from use of EIP-1967 storage slots.

  • Block explorer compatibility.

Cons

  • Every call not only incurs runtime gas cost of delegatecall from the Proxy but also incurs cost of SLOAD for checking whether the caller is admin.

  • Because the upgrade logic lives on the proxy, there is more bytecode so the deploy costs are higher.

Known vulnerabilities

Implementation

Re-routing is often implemented with a modifier like this one from OpenZeppelin:

modifier ifAdmin() {
    if (msg.sender == _getAdmin()) {
        _;
    } else {
        _fallback(); // redirects call to proxy
    }
}

and a check in the fallback():

require(msg.sender != _getAdmin(), "TransparentUpgradeableProxy: admin cannot fallback to proxy target");

Note: The proxy admin should NOT be any important role or even a regular user for the logic implementation contract because the proxy administrator cannot interact with the implementation contract.

UUPS (Universal Upgradeable Proxy Standard)

The UUPS pattern was first documented in EIP1822, which describes a standard for an upgradeable proxy pattern where the upgrade logic is stored in the implementation contract.

  • This way, there is no need to check if the caller is admin in the proxy at the proxy level, saving gas.

  • It also eliminates the possibility of a function on the implementation contract colliding with the upgrade logic in the proxy.

  • Because the upgrade mechanism is in the implementation, later versions can remove related logic to disable future upgrades.

  • Implementation address - Located in a unique storage slot in the proxy contract (EIP-1967).

The upgrade mechanism contains an additional check when upgrading that ensures the new implementation contract is upgradeable. The UUPS proxy contract usually incorporates EIP-1967.

Pros vs Cons

Pros

  • Eliminates risk of functions on the implementation contract colliding with the proxy contract since the upgrade logic lives on the implementation contract and there is no logic on the proxy besides the fallback() which delegatecalls to the impl contract.

  • Reduced runtime gas over TPP because the proxy does not need to check if the caller is admin.

  • Reduced cost of deploying a new proxy because the proxy only contains no logic besides the fallback().

  • Reduces risk of storage collisions from use of EIP-1967 storage slots.

  • Block explorer compatibility.

Cons

  • Because the upgrade logic lives on the implementation contract, extra care must be taken to ensure the implementation contract cannot selfdestruct or get left in a bad state due to an improper initialization. If the impl contract gets borked then the proxy cannot be saved.

  • Still incurs cost of delegatecall from the Proxy.

Known vulnerabilities

Implementation

You can make the any implementation contract UUPS compliant by making it inherit a common standard interface that requires one to include the upgrade logic, like inheriting OpenZeppelin's UUPSUpgradeable interface.

There is little difference between Transparent and UUPS patterns, in the sense that these share the same interface for upgrades and delegation to implementation contract.

The difference lies in where the upgrade logic resides - Proxy or the Implementation contract.

Example

UUPS implementation of upgradable staking pool: https://github.com/calnix/Upgradable-Staking-Pool

Transparent vs UUPS Proxies

Transparent proxies are implemented using TransparentUpgradeableProxy

Transparent proxies include the upgrade and admin logic in the proxy itself. This means TransparentUpgradeableProxy is more expensive to deploy than what is possible with UUPS proxies.

UUPS proxies are implemented using an ERC1967Proxy

  • This proxy is not by itself upgradeable.

  • It is the role of the implementation to include, alongside the contract’s logic, all the code necessary to update the implementation’s address that is stored at a specific slot in the proxy’s storage space.

  • This is where the UUPSUpgradeable contract comes in. Inheriting from it (and overriding the _authorizeUpgrade function with the relevant access control mechanism) will turn your implementation contract into a UUPS compliant implementation.

Note that since both proxies use the same storage slot for the implementation address, using a UUPS compliant implementation with a TransparentUpgradeableProxy might allow non-admins to perform upgrade operations.

Beacon

The Beacon proxy pattern allows multiple proxy contracts to share one logic implementation by referencing the beacon contract. The beacon contract provides the logic implementation contract address to calling proxies and only the beacon contract needs to be updated when upgrading with a new logic implementation address.

Diamond Proxy

Diamond Proxy allows us to delegate calls to more than one implementation contract, known as facets, similar to microservices. Function signatures are mapped to facets.

mapping(bytes4 => address) facets;

Code of call delegation is very similar to the ones that UUPS and Transparent Proxies using but before delegating the call we need to find the correct facet address:

// Find facet for function that is called and execute the
// function if a facet is found and return any value.
fallback() external payable {
  // get facet from function selector
  address facet = selectorTofacet[msg.sig];
  require(facet != address(0));
  // Execute external function from facet using delegatecall and return any value.
  assembly {
    // copy function selector and any arguments
    calldatacopy(0, 0, calldatasize())
    // execute function call using the facet
    let result := delegatecall(gas(), facet, 0, calldatasize(), 0, 0)
    // get any return value
    returndatacopy(0, 0, returndatasize())
    // return any return value or error back to the caller
    switch result
      case 0 {revert(0, returndatasize())}
      default {return (0, returndatasize())}
  }
}

Benefits of Diamond Proxy:

  • All smart contracts have a 24kb size limit. That might be a limitation for large contracts, we can solve that by splitting functions to multiple facets.

  • Allows us to upgrade small parts of the contracts (a facet) without having to upgrade the whole implementation.

  • Instead of redeploying contracts each time, splitted code logics can be reused across different Diamonds.

  • Acts as an API Gateway and allows us to use functionality from a single address.

Last updated