🔝Proxy Upgrade Pattern in Ethereum

Blockchain technology, particularly Ethereum, has carved a niche in the realm of decentralized applications, primarily because of its immutable nature. This very characteristic poses a challenging paradox: while developers laud the robustness and trustworthiness of an immutable system, the software development world thrives on the ability to upgrade, modify, and patch systems. In this article, we dive deep into how Ethereum smart contracts manage this apparent contradiction with the Proxy Upgrade Pattern.

This article is section is heavily influenced by open zeppelins documentation on this subject

refrenece:

The Rationale Behind Contract Upgrades

While smart contracts are immutable by design, the evolution of software quality necessitates the capability to amend and update source code for iterative advancements. Despite the profound benefits drawn from the immutable nature of blockchain-based software, there's an inherent need for a modicum of flexibility – primarily for bug resolutions and potential enhancements. Addressing this dichotomy, OpenZeppelin Upgrades offers a streamlined, robust, and optional upgrade mechanism for smart contracts. This mechanism seamlessly integrates with various governance types, from multi-sig wallets and singular addresses to intricate DAOs.

The Essence of Upgrading Through the Proxy Pattern

At its core, the proxy pattern centers on utilizing a proxy for upgrades. Users engage directly with the primary contract, the "proxy", which orchestrates transaction forwarding to the secondary, logic-bearing contract. Crucially, while the logic can evolve by swapping out the secondary contract, the primary interaction point remains constant. Although each contract retains its immutable nature, by redirecting the proxy to various logic implementations, the system effectively undergoes an "upgrade".

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

Dynamic Forwarding in Proxies

A critical challenge for proxies is to transparently expose the complete interface of a logic contract without necessitating an exact replication of its entire interface. A direct mapping not only makes maintenance arduous but also introduces vulnerability to errors and restricts the upgradability of the interface. The solution lies in a dynamic forwarding mechanism. Let's explore the core components of such a mechanism through the illustrative code below:

// NOTE: This code is illustrative. For actual production deployments, 
// employ the `Proxy` contract from the `@openzeppelin/contracts` library.
// Source: https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v4.8.2/contracts/proxy/Proxy.sol

assembly {
  // 1. Copy incoming call data
  calldatacopy(0, 0, calldatasize())

  // 2. Delegate call to logic contract
  let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)

  // 3. Extract return data
  returndatacopy(0, 0, returndatasize())

  // 4. Relay return data to the original caller
  switch result
  case 0 {
      revert(0, returndatasize())
  }
  default {
      return(0, returndatasize())
  }
}

By integrating this code within a proxy's fallback function, it seamlessly forwards any call—with varying function signatures and parameters—to the logic contract, irrespective of its knowledge of the latter's interface specifics. The sequence is as follows:

  1. Transfer of the calldata to memory.

  2. Forwarding of the call to the logic contract.

  3. Fetching the return data post-call.

  4. Channeling this returned data back to the initial caller.

An essential observation here is the use of the EVM's delegatecall opcode. It executes the callee’s code while preserving the context of the caller's state. Consequently, the logic contract wields control over the proxy's state, rendering its own state redundant. The proxy, therefore, serves a dual role: it mediates transactions to and from the logic contract and epitomizes the combined state of the duo. While the state resides in the proxy, the logic is housed in the designated implementation that the proxy references.

Managing Storage Collisions in Proxies: The Unstructured Storage Approach

In the realm of proxies, one prominent challenge revolves around the management of stored variables within the proxy contract. Here's an illustrative scenario:

  • Let's assume the proxy saves the logic contract’s address using address public _implementation;.

  • Conversely, the logic contract, imagined as a simple token, has its primary variable set as address public _owner.

Both these variables span 32 bytes. From the perspective of the Ethereum Virtual Machine (EVM), they both reside in the foremost slot during the proxied call's execution. This means that when the logic contract modifies _owner, it inadvertently alters _implementation within the proxy’s state—a phenomenon termed as a "storage collision".

Proxy
Implementation

address _implementation

address _owner

...

mapping _balances

...

uint256 _supply

...

...

OpenZeppelin Upgrades introduces an ingenious solution known as the "unstructured storage" approach. Rather than placing the _implementation address in the proxy's opening storage slot, it’s positioned in a pseudo-random slot. This slot is chosen such that it's highly unlikely for a logic contract to declare a variable in the same position. The principle extends to other potential variables within the proxy, like an administrative address that holds the rights to modify _implementation.

Proxy
Implementation

...

address _owner

...

mapping _balances

...

uint256 _supply

...

...

...

...

address _implementation

...

...

...

The randomization of storage is implemented in alignment with EIP 1967:

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

This strategic approach ensures that the logic contract remains unconcerned about inadvertently overwriting the proxy’s variables. Most other proxy designs either necessitate the proxy to recognize and conform to the logic contract’s storage blueprint or vice versa. The beauty of the "unstructured storage" model is its simplicity—neither contract has to adjust or be cognizant of the other's structure.

Addressing Storage Collisions Across Logic Contract Versions

While the unstructured storage approach adeptly sidesteps storage collisions between the proxy and the logic contract, it doesn't necessarily preclude storage overlaps between varied versions of the logic contract itself.

For context, consider an initial logic contract version (Implementation_v0) that saves address public _owner within the primary storage slot. If a subsequent version (Implementation_v1) places address public _lastContributor in the same spot, a collision arises. Now, when Implementation_v1 tries to update _lastContributor, it mistakenly overwrites the earlier _owner value.

Flawed Storage Mapping:

Implementation_v0
Implementation_v1

address _owner

address _lastContributor

mapping _balances

address _owner

uint256 _supply

mapping _balances

...

uint256 _supply

...

...

Optimal Storage Mapping:

Implementation_v0
Implementation_v1

address _owner

address _owner

mapping _balances

mapping _balances

uint256 _supply

uint256 _supply

...

address _lastContributor

...

...

While the unstructured storage method does not inherently prevent such overlaps, the responsibility falls on the developers. When creating new versions of a logic contract, they should either extend prior versions or ensure the storage structure is consistently enhanced without altering the existing layout. Fortunately, OpenZeppelin Upgrades offers a safety net by detecting and flagging these potential storage mismatches for the developer's attention.

Constructor Challenges in Solidity Proxies

In Solidity, code placed within a constructor or used for global variable declaration doesn't form part of a deployed contract’s runtime bytecode. This means it's executed solely during the deployment of the contract instance. As a result, any code within a logic contract’s constructor will never run within the proxy’s state context. To simplify, proxies do not recognize constructors – it's as if they were never present.

However, a straightforward solution exists. Logic contracts should transfer constructor code to an 'initializer' function. This function should then be invoked when the proxy connects to this logic contract. It's crucial that this initializer function is designed to be callable only once, mimicking a constructor's typical behavior in programming.

With OpenZeppelin Upgrades, you can specify the initializer function and pass parameters. A simple modifier ensures the function's one-time call. OpenZeppelin Upgrades offers this through an extendable contract:

solidityCopy code// contracts/MyContract.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract MyContract is Initializable {
    function initialize(
        address arg1,
        uint256 arg2,
        bytes memory arg3
    ) public payable initializer {
        // "constructor" code...
    }
}

Notice the contract's extension of Initializable and its implementation.

Transparent Proxies and Potential Function Overlaps

Upgradeable contract instances, or proxies, operate by delegating every call to a logic contract. But proxies need unique functions, like upgradeTo(address) for transitioning to new implementations. This raises a dilemma: What if the logic contract also features a function named upgradeTo(address)? Does the call intend to access the proxy or the logic contract?

This clash isn't restricted to functions with matching names. Every function in a contract’s public ABI is identified by a 4-byte identifier based on its name and arity. Given its brief size, different functions can potentially share this identifier. While the Solidity compiler identifies such overlaps within a single contract, it overlooks those between distinct contracts, like a proxy and its logic counterpart. This article provides deeper insights.

OpenZeppelin Upgrades navigates this through the transparent proxy pattern. The proxy determines call delegation to the underlying logic contract based on the caller's address (msg.sender):

  1. If the caller is the proxy's admin (with upgrade rights), the proxy doesn't delegate calls and only responds to understood messages.

  2. For other addresses, the proxy always delegates the call, even if it matches one of its functions.

Consider a proxy with owner() and upgradeTo() functions, which delegates calls to an ERC20 contract with owner() and transfer() functions. Here's an overview:

msg.sender

owner()

upgradeTo()

transfer()

Owner

returns proxy.owner()

returns proxy.upgradeTo()

fails

Other

returns erc20.owner()

fails

returns erc20.transfer()

OpenZeppelin Upgrades takes this into account, using an intermediary ProxyAdmin contract for proxies created via the Upgrades plugins. Though you may deploy using your node's default account, ProxyAdmin will be the true admin for all proxies. This setup lets you interact with proxies across node accounts, bypassing the transparent proxy pattern's intricacies. Only advanced users and smart contract auditors who create proxies in Solidity should be wary of this pattern.

In a Nutshell

Developers leveraging upgradeable contracts should comprehend proxies as detailed here. Fortunately, OpenZeppelin Upgrades simplifies proxy mechanics, ensuring developers focus on core project development. Key takeaways include:

  • Understand proxies at a basic level.

  • Prioritize extending storage rather than altering it.

  • Transition from constructors to initializer functions.

OpenZeppelin Upgrades will also signal if issues arise concerning the above pointers.

Last updated