🔝Proxy Upgrade Pattern in Ethereum
Last updated
Last updated
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".
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:
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:
Transfer of the calldata
to memory.
Forwarding of the call to the logic contract.
Fetching the return data post-call.
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.
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".
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
.
The randomization of storage is implemented in alignment with EIP 1967:
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.
Optimal Storage Mapping:
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.
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:
Notice the contract's extension of Initializable and its implementation.
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
):
If the caller is the proxy's admin (with upgrade rights), the proxy doesn't delegate calls and only responds to understood messages.
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:
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.
Proxy | Implementation |
---|---|
Proxy | Implementation |
---|---|
Implementation_v0 | Implementation_v1 |
---|---|
Implementation_v0 | Implementation_v1 |
---|---|
address _implementation
address _owner
...
mapping _balances
...
uint256 _supply
...
...
...
address _owner
...
mapping _balances
...
uint256 _supply
...
...
...
...
address _implementation
...
...
...
address _owner
address _lastContributor
mapping _balances
address _owner
uint256 _supply
mapping _balances
...
uint256 _supply
...
...
address _owner
address _owner
mapping _balances
mapping _balances
uint256 _supply
uint256 _supply
...
address _lastContributor
...
...
msg.sender
owner()
upgradeTo()
transfer()
Owner
returns proxy.owner()
returns proxy.upgradeTo()
fails
Other
returns erc20.owner()
fails
returns erc20.transfer()