Logic and Storage Separation#
We all know that once a smart contract is deployed on the blockchain, the contract's code cannot be tampered with. If a contract has a bug, the project team often has no solution.
Contracts usually consist of stored variables and logical functions. Since migrating variables to a new contract is costly and the main upgrade is usually the logical functions, it is possible to isolate the stored variables and logical functions at the contract level.
Therefore, the complete call chain is shown in the following diagram:
- User interacts with contract-A, where the logical functions are not recorded, only the variables are stored.
- Contract-A calls contract-B through delegatecall, applying the function logic from B to contract-A.
- After the call ends, from the user's perspective, only contract-A is visible, and the existence of contract-B is not perceived.
Basic Upgradable Contract Implementation#
Referring to the above call chain, if contract-B is replaced with another contract, the replacement of the logical contract can be completed without affecting the original contract storage.
Proxy Contract#
- implementation: Records the address of the logical contract.
- Use the fallback function to delegate all function calls to the logical contract through delegatecall.
- Provide the upgrade function to change the address of the logical contract.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
// Simple upgradable contract, the admin can change the address of the logical contract through the upgrade function to change the contract's logic.
contract SimpleUpgrade {
address public implementation; // Address of the logical contract
address public admin; // Admin address
string public words; // String that can be changed through the logical contract's function
// Constructor, initializes the admin and logical contract address
constructor(address _implementation){
admin = msg.sender;
implementation = _implementation;
}
// Fallback function, delegates the call to the logical contract
fallback() external payable {
(bool success, bytes memory data) = implementation.delegatecall(msg.data);
}
// Upgrade function, changes the address of the logical contract, can only be called by the admin
function upgrade(address newImplementation) external {
require(msg.sender == admin);
implementation = newImplementation;
}
}
Logical Contract#
- Need to maintain the same storage layout (variable types and variable order) as the proxy contract.
- The functions in the logical contract will modify the variables in the proxy contract, so calling the
foo()
function will modify thewords
variable in the proxy contract to "old".
contract Logic1 {
// Consistent state variables with the proxy contract to prevent slot conflicts
address public implementation;
address public admin;
string public words; // String that can be changed through the logical contract's function
// Changes the state variable in the proxy contract, selector: 0xc2985578
function foo() public{
words = "old";
}
}
New logical contract:
- Maintain the same storage layout.
- Adjust the function logic to complete the logic update. Calling the
foo()
function will modify thewords
variable in the proxy contract to "new".
contract Logic2 {
// Consistent state variables with the proxy contract to prevent slot conflicts
address public implementation;
address public admin;
string public words; // String that can be changed through the logical contract's function
// Changes the state variable in the proxy contract, selector: 0xc2985578
function foo() public{
words = "new";
}
}
Contract Upgrade#
The admin account calls the upgrade
function in the proxy contract and passes in the address of the new logical contract to complete the upgrade.
Existing Issues#
There may be selector conflicts when calling delegatecall in the fallback function.
The selector of a function is the first four bytes of the hash of the function signature. Different functions may have the possibility of conflicts. If there are two conflicting functions in a contract, the contract cannot be compiled. However, there may be conflicts between the proxy contract and the logical contract, which cannot be detected during compilation.
Transparent Proxy#
The transparent proxy is designed to solve the upgrade problem caused by selector conflicts. The idea of the transparent proxy is to separate the permissions of the upgrade function and the delegatecall execution in the fallback function.
- The fallback function requires that it is not called by the admin.
- The upgrade function requires that it is only called by the admin.
This avoids upgrade problems caused by selector conflicts.
fallback() external payable {
require(msg.sender != admin);
(bool success, bytes memory data) = implementation.delegatecall(msg.data);
}
// Upgrade function, changes the address of the logical contract, can only be called by the admin
function upgrade(address newImplementation) external {
if (msg.sender != admin) revert();
implementation = newImplementation;
}
Universal Upgradeable Proxy System (UUPS)#
The transparent proxy pattern requires gas to verify the admin address every time. The UUPS pattern moves the upgrade function into the logical contract, which can avoid the selector conflict problem of the upgrade function at compile time in a single contract.
The original upgrade function was in the proxy contract and was restricted to admin permissions. Now it is moved to the logic contract. Since the msg.sender remains unchanged during the delegatecall process, and the stored admin is still recorded in the proxy contract, this verification logic can be placed in the logic contract.
The UUPS upgradeable contract example is as follows: Record the implementation address in the logic contract and apply it to the proxy contract.
Proxy Contract#
Only includes stored variables, constructor, and fallback function. The upgrade function is removed.
contract UUPSProxy {
address public implementation; // Address of the logical contract
address public admin; // Admin address
string public words; // String that can be changed through the logical contract's function
// Constructor, initializes the admin and logical contract address
constructor(address _implementation){
admin = msg.sender;
implementation = _implementation;
}
// Fallback function, delegates the call to the logical contract
fallback() external payable {
(bool success, bytes memory data) = implementation.delegatecall(msg.data);
}
}
Logical Contract#
The upgrade function is added.
// UUPS logical contract (upgrade function is written in the logical contract)
contract UUPS1{
// Consistent state variables with the proxy contract to prevent slot conflicts
address public implementation;
address public admin;
string public words; // String that can be changed through the logical contract's function
// Changes the state variable in the proxy contract, selector: 0xc2985578
function foo() public{
words = "old";
}
// Upgrade function, changes the address of the logical contract, can only be called by the admin. Selector: 0x0900f010
// In UUPS, the logical function must include the upgrade function, otherwise it cannot be upgraded.
function upgrade(address newImplementation) external {
require(msg.sender == admin);
implementation = newImplementation;
}
}
// New UUPS logical contract
contract UUPS2{
// Consistent state variables with the proxy contract to prevent slot conflicts
address public implementation;
address public admin;
string public words; // String that can be changed through the logical contract's function
// Changes the state variable in the proxy contract, selector: 0xc2985578
function foo() public{
words = "new";
}
// Upgrade function, changes the address of the logical contract, can only be called by the admin. Selector: 0x0900f010
// In UUPS, the logical function must include the upgrade function, otherwise it cannot be upgraded.
function upgrade(address newImplementation) external {
require(msg.sender == admin);
implementation = newImplementation;
}
}