What Are Upgradable Smart Contracts?
Upgradable smart contracts provide the capacity to alter or augment the functions of a deployed smart contract without interrupting the existing framework or compelling users to engage with a new contract. This poses a unique challenge due to the unchangeable aspect of blockchain, where once contracts are deployed, they cannot be altered. Upgradable smart contracts address this issue by allowing modifications while retaining the same contract address, thereby preserving the contract’s state and user interactions.
Why Are Upgradable Smart Contracts Essential?
- Bug Fixes and Security Updates: Like any software, smart contracts can have bugs or vulnerabilities identified after deployment. Upgrades help remedy these issues without necessitating a switch to a new contract for users.
- Feature Improvements: As decentralized applications (dApps) advance, new features or enhancements may be required. Upgradable contracts facilitate the addition of these improvements effortlessly.
- Regulatory Compliance: Changing regulatory frameworks may require modifications to contract logic to ensure ongoing adherence to the law.
- User Convenience: Users can interact with the same contract address, reducing confusion and avoiding the risk of losing funds during contract migrations.
Categories of Upgradable Smart Contracts
1. Parameterization (Not Truly Upgrading)
This method entails structuring contracts with parameters that can be changed without altering the contract’s code.
Advantages:
- Easy to implement.
- No intricate upgrade systems are necessary.
Disadvantages:
- Limited adaptability as future modifications must be predicted during the initial deployment.
- Can result in convoluted and inefficient contract architecture due to excessive parameterization.
2. Social Migration
Social migration includes launching a new version of a contract and encouraging users to voluntarily transfer their interactions to the new contract.
Advantages:
- Completely decentralized and transparent since users decide to migrate.
- No central authority is needed for managing upgrades.
Disadvantages:
- Possibility of user fragmentation, with some users not migrating, resulting in a split user base.
- Coordination difficulties and potential risks of user funds being lost during migration.
3. Proxies
Proxies feature a proxy contract that channels calls to an implementation contract, allowing the implementation to be replaced as required.
Advantages:
- Versatile and effective, allowing for comprehensive upgrades without redeploying the contract.
- Users continue to interact with the original contract address.
Disadvantages:
- Complex to set up and manage.
- Security issues such as storage conflicts and function selector mismatches.
Challenges with Proxies
Storage Conflicts
Storage conflicts arise when the storage layout of the proxy contract interferes with that of the implementation contract. Each position in a contract’s storage has a unique index, and if both contracts utilize the same storage slots for different variables, it could lead to data corruption.
Example:
contract Proxy {
address implementation;
uint256 proxyData; // Data specific to the proxy
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 swapped with another implementation that utilizes the same storage slots differently, it results in data being overwritten, causing storage conflicts.
Function Selector Conflicts
Function selector conflicts happen when different functions in proxy and implementation contracts hold the same signature, represented by the first four bytes of the Keccak-256 hash of the function’s prototype. Each function in Solidity has a unique selector, but if two functions from varying contracts share the same selector, it leads to conflicts during the delegatecall execution.
Let’s analyze this issue with a comprehensive example.
Consider the following proxy contract alongside 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 setup, both ImplementationV1 and ImplementationV2 contain a function labeled setData, resulting in identical function selectors. If the proxy initially employs ImplementationV1 and is then updated to ImplementationV2, calls to setData are correctly delegated to the new implementation.
However, if the proxy includes a function sharing the same selector as setData, it would result in 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 Occurs During a Conflict?
When setData is called from the proxy contract, Solidity will verify the function selectors to determine which function to execute. Since the proxy contract contains its own version of setData with an identical selector, it will prioritize the proxy’s setData function, preventing the call to ImplementationV1 or ImplementationV2’s setData function from executing.
This scenario leads to unintended behavior and potential bugs.
Technical Breakdown:
- Function Selector Creation: The function selector is derived from the first four bytes of the Keccak-256 hash of the function prototype. For instance, setData(uint256) results in a distinct selector.
- Proxy Fallback Mechanism: When an interaction is initiated with the proxy, the fallback function employs delegatecall to redirect the request to the implementation contract.
- Conflict Resolution: Should the proxy contract have a function that shares a selector, Solidity’s dispatch system will prioritize the function within the proxy over its implementation contract.
To mitigate such issues, it’s essential to ensure that the proxy contract does not hold any functions that could conflict with those in the implementation contracts. Employing proper naming conventions and attentive contract design can help alleviate these challenges.
What Is DELEGATECALL?
DELEGATECALL is a low-level function in Solidity that permits a contract to execute code from another contract while maintaining the original context (such as msg.sender and msg.value). It is fundamental for proxy patterns where the proxy contract delegates function calls to the implementation contract.
Delegate Call vs Call Function
Like a call function, delegatecall is a core feature of Ethereum, but they function quite differently. You can think of delegatecall as a call that allows one contract to utilize a function from another contract.
To illustrate this concept, consider an example in Solidity, an object-oriented programming language suited for writing smart contracts.
contract B {
// NOTE: storage layout must match with 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 contains three storage variables (num, sender, and value), along with a setVars function to update the num value. In Ethereum, contract storage variables are held within a structured storage data organization indexed from zero. Thus, num occupies index zero, sender is at index one, and value is at index two.
Next, we will deploy another contract—Contract A—which will also feature a setVars function. However, in this case, it will make 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 remains unchanged.
// (bool success, bytes memory data) = _contract.delegatecall(
(bool success, ) = _contract.delegatecall(
abi.encodeWithSignature("setVars(uint256)", _num)
);
if (!success) {
revert("delegatecall failed");
}
}
}
Typically, if Contract A called setVars on Contract B, it would solely update Contract B’s num storage. However, by implementing delegatecall, it stipulates “invoke the setVars function and pass _num as an input parameter while executing it in our contract (A).” Essentially, it ‘borrows’ the setVars function and operates in its own context.
Understanding Storage Within DELEGATECALL
A deeper exploration of how delegatecall interacts with storage reveals interesting dynamics. The borrowed function (setVars of Contract B) looks at the storage slots instead of the names of the storage variables from the invoking contract (Contract A).
If we invoked the setVars function from Contract B via delegatecall, the first storage slot (which corresponds to num in Contract A) will be modified instead of the num in Contract B, and so forth.
Moreover, it’s important to remember that the data type of the storage slots in Contract A need not match those of Contract B. Regardless of differences, delegatecall works by simply updating the storage slot of the originating contract.
In this manner, delegatecall allows Contract A to effectively employ the logic of Contract B while functioning within its own storage context.
What Is EIP1967?
EIP1967 is an Ethereum Improvement Proposal that standardizes the storage slots utilized by proxy contracts to prevent storage conflicts. It specifies particular storage slots for implementation addresses, ensuring compatibility and stability across various implementations.
Example of OpenZeppelin Minimalistic Proxy
To create a minimalistic proxy using EIP1967, follow these steps:
Step 1 – Constructing the Implementation Contract
We’ll initiate by developing a mock contract, ImplementationA. This contract contains 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
In order to encode the function call data easily, we’ll add a helper function named getDataToTransact.
function getDataToTransact(uint256 numberToUpdate) public pure returns (bytes memory) {
return abi.encodeWithSignature("setValue(uint256)", numberToUpdate);
}
Step 3 – Accessing the Proxy
Next, we create a Solidity function called readStorage to access our storage in the proxy.
function readStorage() public view returns (uint256 valueAtStorageSlotZero) {
assembly {
valueAtStorageSlotZero := sload(0)
}
}
Step 4 – Deployment and Upgrading
Deploy our proxy along with ImplementationA. Retrieve ImplementationA’s address and set it in the proxy.
Step 5 – Core Functionality
When we invoke the proxy with data, it delegates the call to ImplementationA and retains the state 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 – Isometric Testing
To verify that our logic operates appropriately, we’ll read the output from the readStorage function and subsequently 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, invoking the proxy should now delegate calls to ImplementationB, incorporating the new logic.
Types of Proxies with Their Advantages and Disadvantages
Transparent Proxy
This contract incorporates a proxy that can be upgraded by an administrator.
To prevent proxy selector clashes, which could potentially be exploited in an attack, this contract utilizes the transparent proxy model. This model implies two interrelated principles:
- If any account other than the admin engages the proxy, their call will be forwarded to the implementation, even if it matches one of the admin functions exposed by the proxy itself.
- If the admin calls the proxy, they can access admin functions, but their calls will never redirect to the implementation. If the admin attempts to engage a function on the implementation, it will yield an error stating “admin cannot fallback to proxy target”.
These attributes indicate that the admin account should solely handle administrative actions like upgrading the proxy or altering the admin, ideally maintained as a dedicated account utilized exclusively for this purpose. This will prevent complications arising from unexpected errors when invoking functions from the proxy implementation.
UUPS (Universal Upgradeable Proxy Standard)
UUPS operates similarly to the Transparent Proxy Pattern, using msg.sender as a key, akin to the previously described model. The primary distinction lies in where the function for upgrading the logic contract resides: within the proxy or the logic contract. In the Transparent Proxy Pattern, the upgrade function exists within the proxy’s contract, maintaining uniformity across all logic contracts.
In contrast, UUPS places the function for upgrading in the logic contract, making the upgrade mechanism adaptable over time. Furthermore, if the new logic version lacks an upgrading mechanism, the entire project becomes immutable and cannot be modified. Therefore, if considering this pattern, it’s paramount to avoid inadvertently removing your ability to upgrade.
Learn more about Transparent vs UUPS Proxies.
Example of UUPS Proxy Implementation Utilizing 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();
}
}
Deployment and Upgrade Steps:
- Deploy BoxV1.
- Deploy EIP1967Proxy using the address of BoxV1.
- Interact with BoxV1 via the proxy.
- Deploy BoxV2.
- Upgrade the proxy to point to 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 We Might Avoid Upgradable Smart Contracts?
- Complex Development: The increased complexity in development, testing, and auditing may introduce new vulnerabilities.
- Increased Gas Costs: Proxy mechanisms can elevate gas expenses, affecting contract efficiency.
- Security Vulnerabilities: Mishandled upgrades can lead to security breaches and financial losses.
- Centralization Risks: Upgrade mechanisms often introduce a central point of control, potentially conflicting with blockchain’s decentralized philosophy.
While upgradable smart contracts present a powerful means to manage and enhance blockchain applications, they also come with unique challenges and trade-offs. Developers must meticulously evaluate the need for upgradability, balance the advantages and drawbacks of various methods, and enforce rigorous testing and security protocols to safeguard their systems. Achieving flexibility in upgradability must be harmonized with the core principles of security and decentralization.
Web3 Labs possesses proficiency in smart contract development. Please do not hesitate to contact us for assistance concerning smart contract development, gas optimizations, audits, security of smart contracts, or any consultations.