Upgradable Smart Contracts
What are Upgradable Smart Contracts? Upgrading smart contracts refers to the ability to modify or extend the functionality of a deployed smart contract without disrupting the existing system or requiring users to interact with a new contract. This is particularly challenging due to the immutable nature of blockchain, where deployed contracts cannot be changed. Upgradable smart contracts solve this by allowing changes while maintaining the same contract address, thus preserving state and user interactions. Why Do We Need Upgradable Smart Contracts? Bug Fixes and Security Patches: Smart contracts, like any software, can have bugs or vulnerabilities discovered post-deployment. Upgrades allow these issues to be addressed without requiring users to switch to a new contract. Feature Enhancements: As dApps evolve, new features or improvements may be needed. Upgradable contracts enable adding these enhancements seamlessly. Compliance and Regulations: Regulatory envi
What are Upgradable Smart Contracts?
Upgrading smart contracts refers to the ability to modify or extend the functionality of a deployed smart contract without disrupting the existing system or requiring users to interact with a new contract. This is particularly challenging due to the immutable nature of blockchain, where deployed contracts cannot be changed. Upgradable smart contracts solve this by allowing changes while maintaining the same contract address, thus preserving state and user interactions.
Why Do We Need Upgradable Smart Contracts?
- Bug Fixes and Security Patches: Smart contracts, like any software, can have bugs or vulnerabilities discovered post-deployment. Upgrades allow these issues to be addressed without requiring users to switch to a new contract.
- Feature Enhancements: As dApps evolve, new features or improvements may be needed. Upgradable contracts enable adding these enhancements seamlessly.
- Compliance and Regulations: Regulatory environments can change, necessitating updates to contract logic to ensure ongoing compliance.
- User Experience: Users can continue interacting with the same contract address, avoiding the confusion and potential loss of funds associated with contract migrations.
Types of Upgradable Smart Contracts
1. Not Really Upgrading (Parametrizing Everything)
This approach involves designing contracts with parameters that can be adjusted without changing the contract code.
Pros:
- Simple to implement.
- No complex upgrade mechanisms are required.
Cons:
- Limited flexibility as future changes must be anticipated at the time of initial deployment.
- Can lead to complex and inefficient contract design due to excessive parametrization.
2. Social Migration
Social migration involves deploying a new contract version and encouraging users to migrate their interactions to the new contract voluntarily.
Pros:
- Fully decentralized and transparent as users choose to migrate.
- No central authority is required to manage the upgrade.
Cons:
- Risk of user fragmentation, where some users do not migrate, leading to divided user bases.
- Coordination challenges and potential loss of user funds during the migration process.
3. Proxies
Proxies involve a proxy contract that delegates calls to an implementation contract, allowing the implementation to be swapped out as needed.
Pros:
- Flexible and powerful, enabling comprehensive upgrades without redeploying the contract.
- Users continue interacting with the original contract address.
Cons:
- Complex to implement and maintain.
- Security risks such as storage clashes and function selector conflicts.
Problems with Proxies
Storage Clashes
Storage clashes occur when the storage layout of the proxy contract conflicts with that of the implementation contract. Each slot in a contract’s storage is assigned a unique index, and if both the proxy and the implementation contracts use the same storage slots for different variables, it can result in corrupted data.
Example:
contract Proxy { address implementation; uint256 proxyData; // Proxy-specific data function upgradeTo(address _implementation) external { implementation = _implementation; } fallback() external payable { (bool success, ) = implementation.delegatecall(msg.data); require(success); } } contract ImplementationV1 { uint256 data; function setData(uint256 _data) external { data = _data; } function getData() external view returns (uint256) { return data; } }
If ImplementationV1 is replaced with another implementation that uses the same storage slots differently, data can be overwritten, leading to storage clashes.
Function Selector Conflicts
Function selector conflicts occur when different functions in the proxy and implementation contracts have the same signature, which is the first four bytes of the Keccak-256 hash of the function’s prototype. In Solidity, each function is identified by a unique selector, but if two functions in different contracts have the same selector, it can lead to conflicts when delegatecall is used.
Let’s delve into a detailed example to understand this issue.
Consider the following proxy contract and two implementation contracts:
Proxy Contract:
contract Proxy { address public implementation; function upgradeTo(address _implementation) external { implementation = _implementation; } fallback() external payable { (bool success, ) = implementation.delegatecall(msg.data); require(success); } }
Implementation Contract V1:
contract ImplementationV1 { uint256 public data; function setData(uint256 _data) external { data = _data; } }
Implementation Contract V2:
contract ImplementationV2 { uint256 public data; function setData(uint256 _data) external { data = _data; } function additionalFunction() external view returns (string memory) { return "This is V2"; } }
In this scenario, both ImplementationV1 and ImplementationV2 have a function named setData, which generates the same function selector. If the proxy is initially using ImplementationV1 and then upgraded to ImplementationV2, calls to setData will correctly delegate to the new implementation.
However, if the proxy itself had a function with the same selector as setData, it would cause a conflict.
Proxy Contract with Conflicting Function:
contract Proxy { address public implementation; function upgradeTo(address _implementation) external { implementation = _implementation; } function setData(uint256 _data) external { // This function would conflict with Implementation contracts' setData } fallback() external payable { (bool success, ) = implementation.delegatecall(msg.data); require(success); } }
What Happens During a Conflict?
When setData is called on the proxy contract, Solidity will check the function selectors to determine which function to execute. Since the proxy contract itself has a function setData with the same selector, it will execute the proxy’s setData function instead of delegating the call to the implementation contract.
This means that the intended call to ImplementationV1 or ImplementationV2’s setData function will never occur, leading to unexpected behavior and potential bugs.
Technical Explanation:
- Function Selector Generation: The function selector is generated as the first four bytes of the Keccak-256 hash of the function prototype. For example, setData(uint256) generates a unique selector.
- Proxy Fallback Mechanism: When a call is made to the proxy, the fallback function uses delegatecall to forward the call to the implementation contract.
- Conflict Resolution: If the proxy contract has a function with the same selector, Solidity’s function dispatch mechanism will prioritize the function in the proxy contract over the implementation contract.
To avoid such conflicts, it is crucial to ensure that the proxy contract does not have any functions that could conflict with those in the implementation contracts. Proper naming conventions and careful contract design can help mitigate these issues.
What is DELEGATECALL?
DELEGATECALL is a low-level function in Solidity that allows a contract to execute code from another contract while preserving the original context (e.g., msg.sender and msg.value). This is essential for proxy patterns, where the proxy contract delegates function calls to the implementation contract.
Delegate Call vs Call Function
Similar to a call function, delegatecall is a fundamental feature of Ethereum. However, they work a bit differently. Think of delegatecall as a call option that allows one contract to borrow a function from another contract.
To illustrate this, let’s look at an example using Solidity - an object-oriented programming language for writing smart contracts.
contract B { // NOTE: storage layout must be the same as contract A uint256 public num; address public sender; uint256 public value; function setVars(uint256 _num) public payable { num = _num; sender = msg.sender; value = msg.value; } }
Contract B has three storage variables (num, sender, and value), and one function setVars that updates our num value. In Ethereum, contract storage variables are stored in a specific storage data structure that’s indexed starting from zero. This means that num is at index zero, sender at index one, and value at index two.
Now, let’s deploy another contract - Contract A. This one also has a setVars function. However, it makes a delegatecall to Contract B.
contract A { uint256 public num; address public sender; uint256 public value; function setVars(address _contract, uint256 _num) public payable { // A's storage is set, B is not modified. // (bool success, bytes memory data) = _contract.delegatecall( (bool success, ) = _contract.delegatecall( abi.encodeWithSignature("setVars(uint256)", _num) ); if (!success) { revert("delegatecall failed"); } } }
Normally, if Contract A called setVars on Contract B, it would only update Contract B’s num storage. However, by using delegatecall, it says “call setVars function and then pass _num as an input parameter but call it in our contract (A).” In essence, it ‘borrows’ the setVars function and uses it in its own context.
Understanding Storage in DELEGATECALL
It’s interesting to see how delegatecall works with storage on a deeper level. The borrowed function (setVars of Contract B) doesn’t look at the names of the storage variables of the calling contract (Contract A) but instead, at their storage slots.
If we used the setVars function from Contract B using delegatecall, the first storage slot (which is num in Contract A) will be updated instead of num in Contract B, and so on.
One other important aspect to remember is that the data type of the storage slots in Contract A does not have to match that of Contract B. Even if they are different, delegatecall works by just updating the storage slot of the contract making the call.
In this way, delegatecall enables Contract A to effectively utilize the logic of Contract B while operating within its own storage context.
What is EIP1967?
EIP1967 is an Ethereum Improvement Proposal that standardizes the storage slots used by proxy contracts to avoid storage clashes. It defines specific storage slots for implementation addresses, ensuring compatibility and stability across different implementations.
Example of OpenZeppelin Minimalistic Proxy
To build a minimalistic proxy using EIP1967, let’s follow these steps:
Step 1 - Building the Implementation Contract
We’ll start by creating a dummy contract ImplementationA. This contract will have a uint256 public value and a function to set the value.
contract ImplementationA { uint256 public value; function setValue(uint256 newValue) public { value = newValue; } }
Step 2 - Creating a Helper Function
To easily encode the function call data, we’ll create a helper function named getDataToTransact.
function getDataToTransact(uint256 numberToUpdate) public pure returns (bytes memory) { return abi.encodeWithSignature("setValue(uint256)", numberToUpdate); }
Step 3 - Reading the Proxy
Next, we create a function in Solidity named readStorage to read our storage in the proxy.
function readStorage() public view returns (uint256 valueAtStorageSlotZero) { assembly { valueAtStorageSlotZero := sload(0) } }
Step 4 - Deployment and Upgrading
Deploy our proxy and ImplementationA. Let’s grab ImplementationA’s address and set it in the proxy.
Step 5 - The Core Logic
When we call the proxy with data, it delegates the call to ImplementationA and saves the storage in the proxy address.
contract EIP1967Proxy { bytes32 private constant _IMPLEMENTATION_SLOT = keccak256("eip1967.proxy.implementation"); constructor(address _logic) { bytes32 slot = _IMPLEMENTATION_SLOT; assembly { sstore(slot, _logic) } } fallback() external payable { assembly { let impl := sload(_IMPLEMENTATION_SLOT) calldatacopy(0, 0, calldatasize()) let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0) returndatacopy(0, 0, returndatasize()) switch result case 0 { revert(0, returndatasize()) } default { return(0, returndatasize()) } } } function setImplementation(address newImplementation) public { bytes32 slot = _IMPLEMENTATION_SLOT; assembly { sstore(slot, newImplementation) } } }
Step 6 - Isometrics
To ensure that our logic works correctly, we’ll read the output from the readStorage function. We’ll then create a new implementation contract ImplementationB.
contract ImplementationB { uint256 public value; function setValue(uint256 newValue) public { value = newValue + 2; } }
After deploying ImplementationB and updating the proxy, calling the proxy should now delegate calls to ImplementationB, reflecting the new logic.
Types of Proxies and Their Pros and Cons
Transparent Proxy
This contract implements a proxy that is upgradeable by an admin.
To avoid proxy selector clashing, which can potentially be used in an attack, this contract uses the transparent proxy pattern. This pattern implies two things that go hand in hand:
- If any account other than the admin calls the proxy, the call will be forwarded to the implementation, even if that call matches one of the admin functions exposed by the proxy itself.
- If the admin calls the proxy, it can access the admin functions, but its calls will never be forwarded to the implementation. If the admin tries to call a function on the implementation, it will fail with an error that says “admin cannot fallback to proxy target”.
These properties mean that the admin account can only be used for admin actions like upgrading the proxy or changing the admin, so it’s best if it’s a dedicated account that is not used for anything else. This will avoid headaches due to sudden errors when trying to call a function from the proxy implementation.
UUPS (Universal Upgradeable Proxy Standard)
UUPS works similarly to the Transparent Proxy Pattern. We use msg.sender as a key in the same way as in the previously explained pattern. The only difference is where we put the function to upgrade the logic’s contract: in the proxy or in the logic. In the Transparent Proxy Pattern, the function to upgrade is in the proxy’s contract, and the way to change the logic looks the same for all logic contracts.
It is changed in UUPS. The function to upgrade to a new version is implemented in the logic’s contract, so the mechanism of upgrading could change over time. Moreover, if the new version of the logic doesn’t have the upgrading mechanism, the whole project will be immutable and won’t be able to change. Therefore, if you would like to use this pattern, you should be very careful not to accidentally take from yourself the option to upgrade out.
Read more on Transparent vs UUPS Proxies.
Example of UUPS Proxy Implementation Using EIP1967Proxy
BoxV1.sol:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; contract BoxV1 is Initializable, OwnableUpgradeable, UUPSUpgradeable { uint256 internal value; /// @custom:oz-upgrades-unsafe-allow constructor constructor() { _disableInitializers(); } function initialize() public initializer { __Ownable_init(); __UUPSUpgradeable_init(); } function getValue() public view returns (uint256) { return value; } function version() public pure returns (uint256) { return 1; } function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} }
BoxV2.sol:
/// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; contract BoxV2 is Initializable, OwnableUpgradeable, UUPSUpgradeable { uint256 internal value; /// @custom:oz-upgrades-unsafe-allow constructor constructor() { _disableInitializers(); } function initialize() public initializer { __Ownable_init(); __UUPSUpgradeable_init(); } function setValue(uint256 newValue) public { value = newValue; } function getValue() public view returns (uint256) { return value; } function version() public pure returns (uint256) { return 2; } function _authorizeUpgrade( address newImplementation ) internal override onlyOwner {} }
EIP1967Proxy.sol:
// SPDX-License-Identifier: MIT // OpenZeppelin Contracts (last updated v5.0.0) (proxy/ERC1967/ERC1967Proxy.sol) pragma solidity ^0.8.20; import {Proxy} from "../Proxy.sol"; import {ERC1967Utils} from "./ERC1967Utils.sol"; /** * @dev This contract implements an upgradeable proxy. It is upgradeable because calls are delegated to an * implementation address that can be changed. This address is stored in storage in the location specified by * https://eips.ethereum.org/EIPS/eip-1967[ERC-1967], so that it doesn't conflict with the storage layout of the * implementation behind the proxy. */ contract ERC1967Proxy is Proxy { constructor(address implementation, bytes memory _data) payable { ERC1967Utils.upgradeToAndCall(implementation, _data); } function _implementation() internal view virtual override returns (address) { return ERC1967Utils.getImplementation(); } }
Deploy and Upgrade Process:
- Deploy BoxV1.
- Deploy EIP1967Proxy with the address of BoxV1.
- Interact with BoxV1 through the proxy.
- Deploy BoxV2.
- Upgrade the proxy to use BoxV2.
BoxV1 box = new BoxV1(); ERC1967Proxy proxy = new ERC1967Proxy(address(box), ""); BoxV2 newBox = new BoxV2(); BoxV1 proxy = BoxV1(payable(proxyAddress)); proxy.upgradeTo(address(newBox));
Why Should We Avoid Upgradable Smart Contracts?
- Complexity: The added complexity in development, testing, and auditing can introduce new vulnerabilities.
- Gas Costs: Proxy mechanisms can increase gas costs, impacting the efficiency of the contract.
- Security Risks: Improperly managed upgrades can lead to security breaches and loss of funds.
- Centralization: Upgrade mechanisms often introduce a central point of control, which can be at odds with the decentralized ethos of blockchain.
Upgradable smart contracts offer a powerful tool for maintaining and improving blockchain applications. However, they come with their own set of challenges and trade-offs. Developers must carefully consider the necessity of upgradability, weigh the pros and cons of different approaches, and implement robust testing and security measures to ensure the integrity of their systems. While upgradability provides flexibility, it must be balanced with the foundational principles of security and decentralization.
Web3 Labs has expertise in smart contract development. Feel free to reach out for any support regarding smart contract development, gas optimizations, auditing or security of smart contracts, or any consultations.
What's Your Reaction?