Upgradable smart contracts. 5 ways to update smart contract code for all occasions

Smart contracts on the Ethereum network are immutable by default. However, for some scenarios it is desirable to be able to modify them.

Smart contract update is a change in the business logic of the contract while maintaining the state of the contract. Involves updating the code base, but the address, state and balances of the contract must remain unchanged.

What is it for?

Firstly, There may be errors or potential vulnerabilities in the smart contract that will need to be fixed. Here we need to make a reservation, if we have time to do this, that is, the consequences of the hack have not yet occurred, otherwise it no longer makes sense.

Secondly, There may be a scenario where you need to make improvements or new functionality to a smart contracts project. Typically this may be required at the start, when the project is changing quickly and dynamically.

Important ! Ideally, control over updates should be decentralized to avoid malicious activity. That is, not to be under the control of one address. To do this, you can use multisig or build a full-fledged DAO that will manage the code update process.

There are several ways to change smart contract code:

  1. Creation several versions smart contracts and migration of state from the old contract to the new contract.

  2. Creation of several smart contracts for separate storage states and business logic.

  3. Usage Proxy patterns to delegate function calls from an immutable proxy contract to a mutable logical contract.

  4. Usage Strategy pattern. Create an immutable main contract that interacts with and relies on flexible subcontracts to perform specific functions.

  5. Usage Diamond pattern to delegate function calls from the proxy contract to logical contracts.

Below we will talk in detail about each of the methods. The most popular is proxy pattern. If you need an example of smart contract code, then go there right away.

Versioning

The basic idea is to create a new contract and transfer state from the old contract to it. Initially, a new deployed contract must have an empty store.

The process for upgrading to a new version may look like this:

  1. Creating a new contract instance.

  2. State transfer or data migration. This can be implemented in two ways:

    • On-chain. Migration using smart contracts.

    • Off-chain. Data collection from the old contract occurs outside the blockchain. At the last stage, the collected data is recorded at the address of the new contract.

  3. Update the new contract address for all contracts, services and client applications. That is, replace the old address with a new one.

  4. Convince users and other projects to switch to the new contract.

Transferring data is a relatively simple and straightforward operation, but it can take a significant amount of time and incur significant gas costs. It's also worth remembering that not all users will want to switch to a new version, which means it is necessary to think through support measures for such users and older versions of contracts.

On-chain migration

Migration using smart contracts. This migration can be implemented in two ways:

  • At the user's expense. When we offer the user to pay for gas. We implement a migration smart contract, which, when called, identifies the user and transfers the functionality to the new contract.

  • Due to the protocol. We can do this through the protocol. We will implement a migration smart contract that will accept a list of addresses and handle the transfer of state to a new contract. In this case, gas costs are covered by the project.

Off-chain migration

We read all the data from the blockchain. If there was a hack or failure, then you need to read until the problematic block. In this case, it is better to suspend the operation of the current smart contract (if possible).

All public primitives are easy to read. For private variables it's a little more complicated, but you can rely on events or use the method getStorageAt() to read such variables from storage.

To recover data from events, you need to understand how events are stored, indexed, and filtered outside of the blockchain. This is well described here.

One of the options for collecting data is to use the service Google BigQuery API. I have prepared several examples and a small guide for Google BigQuery API.

After the collection of all data from the old contract is completed, it is necessary to write it to the new contract.

Separate data storage and business logic

This approach can be called a template data separation. It says that users interact with a logical contract, and the data is stored in a separate storage contract.

A logical contract contains code that runs when users interact with an application. It also contains the address of the storage contract and communicates with it to receive and write data.

The storage contract can store balances, user addresses, and so on.

_Important!_ The storage contract should only write data to a specific logic contract and no one else. Otherwise, anyone can overwrite the data.

Let's look at the example of a smart contract TokenLogic. This is a token contract that has no state variables.

Example of a smart contract TokenLogic.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

interface IBalanceStorage {
    function balanceOf(address _account) external view returns (uint256);
    function setBalance(address _account, uint256 _newBalance) external;
}

interface ITotalSupplyStorage {
    function getTotalSupply() external view returns (uint256);
    function setTotalSupply(uint256 _newTotalSupply) external;
}

contract TokenLogic {
    IBalanceStorage public balanceStorage;
    ITotalSupplyStorage public totalSupplyStorage;

    event Transfer(address from, address to, uint256 amount);
    error AddressZero();

    constructor(address _balanceStorage, address _totalSupplyStorage) {
        balanceStorage = IBalanceStorage(_balanceStorage);
        totalSupplyStorage = ITotalSupplyStorage(_totalSupplyStorage);
    }

    function totalSupply() public view returns (uint256) {
        // Возвращаем значение из контракта хранилища TotalSupply
        return totalSupplyStorage.getTotalSupply();
    }

    function _mint(address _account, uint256 _amount) internal virtual {
        if (_account == address(0)) {
            revert AddressZero();
        }

        // Записываем новое значение TotalSupply
        uint256 prevTotalSupply = totalSupplyStorage.getTotalSupply();
        totalSupplyStorage.setTotalSupply(prevTotalSupply + _amount);

        // Записываем новое значение balance
        uint256 prevBalance = balanceStorage.balanceOf(_account);
        balanceStorage.setBalance(_account, prevBalance + _amount);

        emit Transfer(address(0), _account, _amount);
    }

All state variables are included in contracts BalanceStorage And TotalSupplyStorage. These two contracts have public methods for managing states. These public methods can only be called by the logic contract.

Example of a smart contract BalanceStorage.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

import "@openzeppelin/contracts/access/Ownable.sol";

contract BalanceStorage is Ownable {
    address private _logic;
    mapping (address => uint256) private _balances;

    modifier onlyLogic() {
        if (_msgSender() != _logic) {
            revert OnlyLogic(_msgSender());
        }

        _;
    }

    event BalanceSet(address account, uint256 newBalance);
    event LogicSet(address newLogic);

    error OnlyLogic(address sender);

    constructor(address logic) {
        _logic = logic;
    }

    function balanceOf(address _account) external view returns (uint256) {
        return _balances[_account];
    }

    function setBalance(address _account, uint256 _newBalance) onlyLogic() external {
        _balances[_account] = _newBalance;

        emit BalanceSet(_account, _newBalance);
    }

    function setLogic(address _newLogic) external onlyOwner() {
        _logic = _newLogic;

        emit LogicSet(_newLogic);
    }
}
Example of a smart contract SupplyStorage.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

import "@openzeppelin/contracts/access/Ownable.sol";

contract SupplyStorage is Ownable {
    address private _logic;
    uint256 private _totalSupply;

    modifier onlyLogic() {
        if (_msgSender() != _logic) {
            revert OnlyLogic(_msgSender());
        }

        _;
    }

    event TotalSupplySet(uint256 newTotalSupply);
    event LogicSet(address newLogic);

    error OnlyLogic(address sender);

    constructor(address logic) {
        _logic = logic;
    }

    function getTotalSupply() external view returns (uint256) {
        return _totalSupply;
    }

    function setTotalSupply(uint256 _newTotalSupply) onlyLogic() external {
        _totalSupply = _newTotalSupply;

        emit TotalSupplySet(_newTotalSupply);
    }

    function setLogic(address _newLogic) external onlyOwner() {
        _logic = _newLogic;

        emit LogicSet(_newLogic);
    }
}

Proxy pattern

The proxy pattern uses data separation to store business logic and state, similar to the “data separation”. However, it has a difference. We can say that this is the reverse method of data partitioning, when the storage contract makes calls to the logical contract.

_Important!_ From now on we will simply call the “state storage” contract “proxy”.

Schematically, user interaction with the implementation contract and proxy looks like this.

Proxy concept

The proxy template works like this:

  1. The user interacts with the proxy contract. For example, calls a certain function. User interaction occurs only with the proxy contract.

  2. The proxy contract does not have an implemented function that is called and therefore the contract calls the built-in function fallback().

  3. The proxy contract stores the address of the implementation contract and delegates the function call to the implementation contract (which contains the business logic) using a low-level functiondelegatecall().

  4. After the call is redirected, code execution occurs on the implementation contract, but data is written on the proxy contract.

To understand how the proxy template works, you need to understand the function  delegatecall(). Essentially, it is an opcode that allows a contract to call another contract, while the actual execution of the code occurs in the context of the calling contract.

Meaning of use delegatecall() in proxy patterns is that the proxy contract reads and writes to its own storage, and executes logic stored in another contract.

Smart contract example with a challenge delegateсall().

This may sound complicated, in fact there is no magic here, from the outside it looks like this – you take the ABI of the smart contract implementation and call any function of this implementation on the proxy smart contract.

This can be done even in Remix:

To get the proxy template to work, you need to write a custom fallback function fallback(), which specifies how the proxy contract should handle calls to functions that it does not support. And already inside make a call to the logical contract via delegateсall().

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;

contract Proxy {
    // Любые вызовы функций через прокси будут делегироваться 
    fallback() external {
        _delegate(_getImplementation());
    }
}

In the proxy contract, you can change the logic contract address at any time. This allows us to update the contract logic without forcing users to switch to using a new contract.

Function selector conflicts

Proxy templates are quite difficult to use and can lead to critical errors if used incorrectly, such as function selector conflicts.

In order for a call from a proxy contract to always be delegated, it is necessary that a call to any function always falls into fallback() and delegated to the logic contract. Therefore, the proxy contract should not contain functions of the same name as the logic contract. If this happens, the call will not be delegated. This means that you must always be aware of function selector conflicts.

Below is an invalid option when calling a function setBalance() on the contract, the proxy will not be delegated to the implementation contract.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;

contract Implementation {
  function setBalance(uint256 balance) external {
    ...
  }
}

contract Proxy {
    function setBalance(uint256 balance) external {
      ...
    }
      
    // Любые вызовы функций через прокси будут делегироваться 
    fallback() external {
        _delegate(_getImplementation());
    }
}

You can read more about this here.

Simple proxy

All the above experiences were described in the standard eip-1967. The standard describes a mechanism for secure call delegation and several nuances related to data storage.

A simple example of a proxy contract:

contract Proxy {
    struct AddressSlot {
        address value;
    }

    /**
     * @notice Внутренняя переменная для определения места записи информации об адресе контракта логики
     * @dev Согласно EIP-1967 слот можно рассчитать как bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1));
     * Выбираем псевдослучайный слот и записывает адрес контракта логики в этот слот. Эта позиция слота должна быть достаточно случайной,
     * чтобы переменная в контракте логики никогда не занимала этот слот.
     */
    bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

    constructor(address logic) {
        _setImplementation(logic);
    }

    /// @notice Возвращает адрес установленного контракта логики для контракта прокси
    function getImplementation() external view returns (address) {
        return _getImplementation();
    }

    /// @notice Устанавливает адрес контракта логики для контракта прокси
    function setImplementation(address _newLogic) external {
        _setImplementation(_newLogic);
    }

    function _delegate(address _implementation) internal {
        // Необходима assembly вставка, потому что невозможно получить доступ к слоту для возврата значения в обычном solidity
        assembly {
            // Копируем msg.data и получаем полный контроль над памятью для этого вызова.
            calldatacopy(0, 0, calldatasize())

            // Вызываем контракт реализации
            let result := delegatecall(gas(), _implementation, 0, calldatasize(), 0, 0)

            // Копируем возвращаемые данные
            returndatacopy(0, 0, returndatasize())

            switch result
            // Делаем revert, если возвращенные данные равны нулю.
            case 0 {
                revert(0, returndatasize())
            }
            default {
                return(0, returndatasize())
            }
        }
    }

    /**
     * @notice Возращает адрес установленного контракта логики для контракта прокси
     * @dev Адрес логики хранится в специально отведенном слоте, для того, чтобы невозможно было случайно затереть значение
     */
    function _getImplementation() internal view returns (address) {
        return getAddressSlot(_IMPLEMENTATION_SLOT).value;
    }

    /**
     * @notice Устанавливает адрес контракта логики для котракта прокси
     * @dev Адрес логики хранится в специально отведенном слоте, для того, чтобы невозможно было случайно затереть значение
     */
    function _setImplementation(address newImplementation) private {
        getAddressSlot(_IMPLEMENTATION_SLOT).value = newImplementation;
    }

    /**
     * @notice Возвращает произвольный слот памяти типа storage
     * @param slot Указатель на слот памяти storage
     */
    function getAddressSlot(bytes32 slot) internal pure returns (AddressSlot storage r) {
        assembly {
            r.slot := slot
        }
    }

    /// @dev Любые вызовы функций контракта логики через прокси будут делегироваться благодаря обработке внутри fallback
    fallback() external {
        _delegate(_getImplementation());
    }
}

The main things to learn:

  1. All calls go through the proxy contract, ending up in fallback() followed by a call delegateсall().

  2. The proxy contract stores the address of the implementation contract as a state variable. Since the implementation contract variables will overwrite the values ​​in slot zero, all of the proxy contract's own variables must be stored in random and inaccessible slots for the logic contract. The essence of this problem and its solution are described in eip-1967.

  3. When updating a contract, you need to preserve the previous variable storage scheme. Otherwise, old data will be overwritten.

  4. Because constructor() is not part of the bytecode and runs only once during deployment, another way to set initialization values ​​is needed. It is generally accepted to use the function initialize(). You can read more about this at OpenZeppelin.

Proxy requires its own functions. For example upgradeTo(address newLogic) to change the address of the implementation contract. How to solve the problem of function selector conflict?

OpenZeppelin was the first to come up with a solution to this problem. They added the concept of a proxy administrator. Then, if the administrator (i.e. msg.sender == admin), the proxy will not delegate the call, but will make the call if it exists or does revert(). This solution is called Transparent Proxy.

Important! To allow the admin address to be a regular user and have its calls delegated to the implementation contract, OpenZeppelin suggests using an additional contract ProxyAdmin. Calls made as ProxyAdmin will not be delegated.

Transparent vs UUPS

Transparent And UUPS (Universal Upgradeable Proxy Standard) are different implementations of the proxy template for the smart contract update mechanism from OpenZeppelin. There isn't really much difference between the two implementations in the sense that they use the same interface for updates and delegating calls from the proxy to the implementation.

The difference is where the update logic is located, in the proxy contract or the implementation contract.

In Transparent proxy, the update logic is in the proxy contract. This means that the proxy contract has a method upgradeToAndCall(address newLogic, bytes memory data)

Example TransparentProxy.sol
contract Logic {
    uint256 private _value;

    function store(uint256 value) public { /*..*/ }
    function retrieve() public view returns (uint256) { /*..*/ }
}

contract TransparentProxy {
    function _delegate(address implementation) internal virtual { /*..*/ }
    function getImplementationAddress() public view returns (address) { /*..*/ }

    /// @notice Обновить адрес контракта логики для прокси
    upgradeToAndCall(address newlogic, bytes memory data) external {
        // Меняем адрес логики в специальном слоте памяти прокси контракта
    }

    fallback() external { /*..*/ }
}

In UUPS, the update logic is handled by the implementation contract itself. This means that the functionupgradeToAndCall(address newLogic, bytes memory data) is not in the proxy, but in the implementation.

Important! Before version 5, the OpenZeppelin library also had a function upgradeTo()in the 5th only the function remains upgradeToAndCall(). The latter allows you to update the implementation both with and without calling any function.

Example UUPSProxy.sol
contract Logic {
    uint256 private _value;

    function store(uint256 value) public { /*..*/ }
    function retrieve() public view returns (uint256) { /*..*/ }

    /// @notice Обновить адрес контракта логики для прокси
    upgradeToAndCall(address newlogic, bytes memory data) external {
        // Меняем адрес логики в специальном слоте памяти прокси контракта
    }
}

contract UUPSProxy {
    function _delegate(address implementation) internal virtual { /*..*/ }
    function getImplementationAddress() public view returns (address) { /*..*/ }
    fallback() external { /*..*/ }
}

Updating via UUPS can be cheaper in gas and easier than updating via Transparent Proxy, because… no need to use the additional ProxyAdmin smart contract. On the other hand, ProxyAdmin provides a greater level of security and allows you to separate the update logic from the main business logic.

Another important point is that TransparentProxy checks with each call whether the call is from the ProxyAdmin smart contract or from a regular user. This is necessary to determine whether you need to delegate execution or perform your own proxy administration methods. Due to the additional code for this check, all TransparentProxy function calls are slightly more expensive than UUPS.

However, in the case of UUPS, the logic contract stores additional update code, which means that deploying such a contract is more expensive than deploying logic alone. Also in the case of UUPS, it is necessary to correctly implement methods for proxy management in the logic contract. Otherwise, there is a threat of never renewing the contract.

Sandbox code TransparentProxy.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;

import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
import {ERC1967Utils} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Utils.sol";
import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol";

/**
 * Чтобы понять контракты. Лучше всего задеплоить их при помощи Remix.
 * Порядок деплоя:
 *      1. Задеплоить контракт Logic
 *      2. Задеплоить контракт LogicProxy(address Logic, address InitialOwner, 0x)
 *      3. Связать ABI контракта Logic с LogicProxy при помощи встроенного в Remix функционала "Deploy at address".
 *         Чтобы сделать это необходимо выбрать в поле CONTRACT - Logic, а в "At Address" установить адрес LogicProxy. Нажать на кнопку "At address"
 *          Это позволит вызывать методы контракта Logic для контракта LogicProxy
 *      4. Задеплоить контракт Logic2. Этот контракт обновит логику контракта Logic. Будет добавлена новая функция increment()
        5. Вызвать на контракте LogicProxy функцию "getAdmin()" чтобы получить адрес контракта администратора, затем связать ABI ProxyAdmin
            с этим адресом, как это было проделано в пункте 3
 *      6. На контракте ProxyAdmin вызвать upgradeAndCall(address LogicProxy, address Logic2, 0x) и передать туда адреса LogicProxy, Logic2 и data (можно нулевую 0x)
 *      7. Повторить пункт 3 но уже для контракта Logic2. Теперь у нас появился дополнительный метод increment().
 *         При этом состояние прокси не изменилось, там хранятся те же значения что были до обновления имплементации.
 */

/// Контракт логики
contract Logic {
    uint256 private _value;

    function store(uint256 _newValue) public {
        _value = _newValue;
    }

    function retrieve() public view returns (uint256) {
        return _value;
    }
}

/// Контракт логики для обновления
contract Logic2 {
    uint256 private _value;

    function store(uint256 _newValue) public {
        _value = _newValue;
    }

    function increment() public {
        _value += 1;
    }

    function retrieve() public view returns (uint256) {
        return _value;
    }
}

/// Контракт прокси
contract LogicProxy is TransparentUpgradeableProxy {
    constructor(address _logic, address _initialOwner, bytes memory _data)
        TransparentUpgradeableProxy(_logic, _initialOwner, _data)
    {}

    function getAdmin() external view returns (address) {
        return ERC1967Utils.getAdmin();
    }

    function getImplementation() external view returns (address) {
        return ERC1967Utils.getImplementation();
    }

    receive() external payable {}
}
Sandbox code UUPSProxy.sol

Beacon Proxy

This is a proxy template where multiple proxy contracts reference a single smart contract Beacon. This smart contract provides everyone with a proxy address of the implementation contract.

Important! This approach makes sense when you have several proxies and only one implementation contract. In the case of TransparentProxy and UUPS, it will be necessary to update each proxy. Beacon proxy will update the implementation for all proxies at once.

Example of a simple Beacon proxy implementation here.

Sandbox code BeaconProxy.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;

import "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol";
import "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol";

/**
 * Чтобы понять контракты. Лучше всего задеплоить их при помощи Remix.
 * Порядок деплоя для тестирования в remix:
 *      1. Деплой контракта Logic
 *      2. Деплой контракта Beacon(address Logic, address Owner)
 *      3. Деплой контракта LogicProxy(address Beacon, 0x)
 *      4. Деплой контракта LogicProxy2(address Beacon, 0x)
 *      5. Деплой нового контракта Logic2
 *      6. Вызов upgradeTo(address Logic2) на контракте Beacon
 *      7. Вызов функции getImplementation() на каждом контракте LogicProxy для проверки смены контракта логики
 */

/// Контракт логики
contract Logic {
    uint256 private _value;

    function store(uint256 _newValue) public {
        _value = _newValue;
    }

    function retrieve() public view returns (uint256) {
        return _value;
    }
}

/// Контракт логики для обновления
contract Logic2 {
    uint256 private _value;

    function store(uint256 _newValue) public {
        _value = _newValue;
    }

    function increment() public {
        _value += 1;
    }

    function retrieve() public view returns (uint256) {
        return _value;
    }
}

// Контракт Beacon
contract Beacon is UpgradeableBeacon {
    // Для обновления логики для всех контрактов прокси нужно вызывать функцию upgradeTo() на контракте Beacon
    constructor(address _implementation, address _owner) UpgradeableBeacon(_implementation, _owner) {}
}

/// Контракт First прокси
contract LogicProxy is BeaconProxy {
    constructor(address _beacon, bytes memory _data) BeaconProxy(_beacon, _data) {}

    /// @notice Возвращает адрес Beacon контракта
    function getBeacon() public view returns (address) {
        return _getBeacon();
    }

    /// @notice Возвращает адрес установленного контракта логики для прокси
    function getImplementation() public view returns (address) {
        return _implementation();
    }

    /// @notice Возвращает описание прокси
    function getProxyDescription() external pure returns (string memory) {
        return "First proxy";
    }

    receive() external payable {}
}

/// Контракт Second прокси
contract LogicProxy2 is BeaconProxy {
    constructor(address _beacon, bytes memory _data) BeaconProxy(_beacon, _data) {}

    /// @notice Возвращает адрес Beacon контракта
    function getBeacon() public view returns (address) {
        return _getBeacon();
    }

    /// @notice Возвращает адрес установленного контракта логики для прокси
    function getImplementation() public view returns (address) {
        return _implementation();
    }

    /// @notice Возвращает описание прокси
    function getProxyDescription() external pure returns (string memory) {
        return "Second proxy";
    }

    receive() external payable {}
}

Minimal Clones

This is a standard based eip-1167 to deploy minimal proxy contracts, called clones. OpenZeppelin offers its own library implementation of the standard.

This approach should be used when you need to create a new contract instance on-chain and this action is repeated over time. A kind of contract factory. Due to low-level calls and folding the library code into bytecode, such cloning is relatively inexpensive.

Important! The library supports functions for creating contracts create() And create2(). Also supports functions for predicting addresses of cloned contracts.

Very important! This approach does not serve to update the logic of contracts deployed using eip-1167. It's simply a cheap, controlled way to create a clone of an existing contract.

Below, the example shows the creation of a pair contract inside a factory contract. Inspired by the Uniswap concept.

Example of using Minimal Clones

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;

import "@openzeppelin/contracts/proxy/Clones.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

/**
 * Чтобы понять контракты. Лучше всего задеплоить их при помощи Remix.
 * Порядок деплоя:
 * 1. Деплой контракта Pair
 * 2. Деплой контракта Factory(address Pair)
 * 3. Вызываем метод createPair на контракте Factory. Адреса токенов можно отправить любые
 * 4. Убедиться, что новый инстанс(клон) контракта Pair успешно создан
 */

interface IPair {
    function initialize(address _tokenA, address _tokenB) external;
}

contract Pair {
    address public factory;
    IERC20 public token0;
    IERC20 public token1;

    function initialize(address _tokenA, address _tokenB) external {
        require(factory == address(0), "UniswapV2: FORBIDDEN");

        factory = msg.sender;
        token0 = IERC20(_tokenA);
        token1 = IERC20(_tokenB);
    }

    function getReserves() public view returns (uint112 reserve0, uint112 reserve1) {/** */}
    function mint(address to) external returns (uint256 liquidity) {/** */}
    function burn(address to) external returns (uint256 amount0, uint256 amount1) {/** */}
    function swap(uint256 amount0Out, uint256 amount1Out, address to) external {/** */}
}

contract Factory {
    address public pairImplementation;
    mapping(address => mapping(address => address)) private _pairs;

    event PairCreated(address tokenA, address tokenB, address pair);

    constructor(address _pairImplementation) {
        pairImplementation = _pairImplementation;
    }

    function createPair(address _tokenA, address _tokenB) external returns (address pair) {
        require(getPair(_tokenA, _tokenB) == address(0), "Pair has been created already");

        // При помощи библиотеки clones развертываем контракт pair на основе задеплоенного контракта Pair
        bytes32 salt = keccak256(abi.encodePacked(_tokenA, _tokenB));
        pair = Clones.cloneDeterministic(pairImplementation, salt);

        // Инициализируем контракт пары. Передаем токены и дополнительно установится адрес factory для Pair
        IPair(pair).initialize(_tokenA, _tokenB);

        _pairs[_tokenA][_tokenB] = pair;

        emit PairCreated(_tokenA, _tokenB, pair);
    }

    function getPair(address tokenA, address tokenB) public view returns (address) {
        return _pairs[tokenA][tokenB] != address(0) ? _pairs[tokenA][tokenB] : _pairs[tokenB][tokenA];
    }
}

More use cases here.

OpenZeppelin utilities

As we said above, upgradeable contracts do not have constructor(). Instead, the common function is used initialize(). It is used for initial data initialization when deploying an updated smart contract.

OpenZeppelin offers its own utility Initializable for secure control of initialization. Essentially, this is a basic contract that offers help in writing an updatable contract with the ability to protect a function initialize() from calling again.

Important! To avoid leaving the proxy contract uninitialized, you need to call the function initialize()as soon as possible. This is usually done using the argument data at the time of proxy deployment.

Important! In addition to the fact that you cannot leave the proxy contract uninitialized, it is also not recommended to leave the ability to call the function initialize() on the logic contract.

To prohibit calling a function initialize() on the logic contract, the utility implements the function _disableInitializers();.

Usage example:

/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
    _disableInitializers();
}
Example of using Initializable.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;

import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
import {ERC1967Utils} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Utils.sol";
import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol";
import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol";

/**
 * Чтобы понять контракты. Лучше всего задеплоить их при помощи Remix.
 * Порядок деплоя:
 *      1. Задеплоить контракт Logic. Попробовать вызвать initialize() на задеплоенном контракте.
 *         Наша защита не позволит этого сделать
 *      2. Задеплоить контракт LogicProxy(address Logic, address InitialOwner, 0x)
 *      3. Связать ABI контракта Logic с LogicProxy при помощи встроенного в Remix функционала "Deploy at address".
 *         Чтобы сделать это необходимо выбрать в поле CONTRACT - Logic, а в "At Address" установить адрес LogicProxy. Нажать на кнопку "At address"
 *          Это позволит вызывать методы контракта Logic для контракта LogicProxy
 *      4. Вызвать функцию initialize() на контракте Logic (из пункта 3, этот контракт позволяет прокси вызывать методы Logic)
 *         Убедиться, что транзакция прошла успешно. Вызвать функцию initialize() повторно. Убедиться что транзакция вернулась с ошибкой
 */

/// Контракт логики
contract Logic is Initializable {
    uint256 private _defaultValue;
    uint256 private _value;

    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        /// Это не позволит инициализировать контракт логики миную прокси
        _disableInitializers();
    }

    /**
     * @notice Функция инициализации.
     * @param defaultValue Дефолтное значение
     * @dev Используется модификатор из контракта Initializable.sol от OpenZeppelin
     */
    function initialize(uint256 defaultValue) external initializer {
        _defaultValue = defaultValue;
    }

    function store(uint256 _newValue) public {
        _value = _newValue;
    }

    function retrieve() public view returns (uint256) {
        if (_value != 0) {
            return _value;
        }

        return _defaultValue;
    }
}

/// Контракт прокси
contract LogicProxy is TransparentUpgradeableProxy {
    constructor(address _logic, address _initialOwner, bytes memory _data)
        TransparentUpgradeableProxy(_logic, _initialOwner, _data)
    {}

    function getAdmin() external view returns (address) {
        return ERC1967Utils.getAdmin();
    }

    function getImplementation() external view returns (address) {
        return ERC1967Utils.getImplementation();
    }

    receive() external payable {}
}

Strategy pattern

This approach is directly influenced classic strategy pattern. The main idea of ​​which is to select a behavior or action algorithm depending on conditions at runtime.

A simple example would be a class that performs input validation. To validate different types of data, a strategy template is used, which applies different algorithms for validating input data.

Applying the strategy pattern to smart contract development would mean creating a contract that calls functions from other contracts. The main contract in this case interacts with other smart contracts (auxiliary contracts) to perform certain functions. This main contract also stores the address for each auxiliary contract and can switch between different implementations.

You can always create a new auxiliary contract and configure the main contract to a new address. This allows you to change strategies (implement new logic or, in other words, update the code) for the smart contract.

Important! The main drawback is that this pattern is mainly useful for deploying minor updates. In addition, if the main contract is compromised (has been hacked), then this update method will no longer work.

Examples of strategy patterns

  1. A good example of a simple strategy pattern is Compound, which has different implementations RateModel to calculate the interest rate, and its CToken contract can switch between them.

  2. A slightly more complex implementation of the strategy pattern is “Pluggable Modules” or plug-ins. In this approach, the main contract provides a set of core immutable functions and allows new modules to be registered. These modules add new functions to call in the main contract. This pattern is found in the wallet Gnosis Safe. Users can add new modules to their own wallets, and then each wallet contract call will request a specific function from a specific module to be executed.

Important ! One thing to keep in mind is that Pluggable Modules also require that the underlying contract be error-free. Any errors in the module management itself cannot be corrected by adding new modules to this scheme.

Diamond pattern

This approach can be considered an improvement on the proxy template. The main difference is that a diamond proxy can delegate calls to more than one logical contract.

The Diamond update pattern has some advantages over regular proxy patterns:

  1. It is possible to update only a small part of the contract without changing the entire code.

  2. The Diamond pattern makes it easy to split functions into multiple logical contracts. This way you can easily bypass the 24 KB contract size limit.

  3. The Diamond template provides a modular approach to managing update permissions; you can restrict updates to certain functions within a smart contract.

From the outside, the Diamond pattern appears to be a single smart contract and has one address. Internally it uses a set of smart contracts called facets.

When a function is called externally on the Diamond proxy contract, the proxy checks whether it has facet with this function, and calls it if it exists. In this case, all states are stored on the main Diamond contract.

Important! Diamond, like regular proxies, has a backup function fallback() within which delegation of a call to facets.

EIP-2535 Diamonds was originally created to address the 24K contract limitation, but has proven to be useful beyond that. It provides a framework for creating larger smart contract systems that can be expanded during development. One example of such a system is blockchain smart contracts zkSync era, which are deployed on Ethereum. You can read more about them in documentation protocol.

Inherited storage

Since many facets use the same address storage space within the Diamond proxy contract, it is necessary to correctly implement the creation and update process state contract.

The simplest strategy is to create a separate Storage contract. Here we recall our second method of updating smart contracts (data separation). It is important to strictly define any state variables only in this contract. This strategy works and is successfully used in the implementation of the pattern.

However, with a large number of facets, it is quite easy to start confusing variables declared in Storage and locally. Therefore, there is another approach to organizing storage.

Diamond Storage

For each facet, you can specify different places to start storing data, thereby preventing different facets from conflicting with different state variables in storage locations.

We can hash the unique string to get a random storage position and store the structure there. The structure can contain all the state variables we need. The unique string can act as a namespace for certain functions.

App storage

Another option is to create one AppStorage structure for all facets at once. And store all variables in this structure. This can be much more convenient, because you won’t need to think about delimiting state variables.

Examples of diamond patterns

  1. Simple implementation

  2. Gas-optimized

  3. Simple loop functions

Pros and cons of updating smart contracts

pros

  1. Allows you to fix a vulnerability after deployment. You could even say (but this is debatable) that this improves security since the vulnerability can be fixed.

  2. Allows you to add functionality to the contract logic after deployment.

  3. Opens up new possibilities for designing and building a decentralized system with isolation of individual parts of the application and differentiation of access and control.

Minuses

  1. Abolishes the idea of ​​blockchain that code is immutable. So from a security point of view, this is bad. Users must trust developers not to change smart contracts arbitrarily.

  2. To gain the trust of users, additional layers of protection are needed, such as a DAO, which will protect against unauthorized changes.

  3. Building in the ability to update a contract can greatly increase its complexity.

  4. Insecure access control or centralization in smart contracts can make it easier for attackers to perform unauthorized updates.

Conclusion

The concept of “updatable” smart contracts contradicts the immutability property of smart contracts. However, this can simplify and speed up the development of the project at the start or help correct vulnerabilities.

Potentially, the trust in a project that uses upgradeable contracts in its projects (with the exception of versioning) may be lower on the part of users. Moreover, auditors often note in their reports the possibility of changing the logic of the application.

I believe that the choice is always up to the project: to use the upgrade feature or not to use it. Sometimes this is convenient for development, sometimes it is unsafe for the user.

Links

  1. Upgrading smart contracts

  2. Upgradable Smart Contracts: What They Are and How To Deploy Your Own

  3. Upgrading smart contracts from OpenZeppelin

  4. yAcademy Proxies Research

  5. How contract migration works

  6. Proxy patterns

  7. Proxy Patterns For Upgradability Of Solidity Contracts: Transparent vs UUPS Proxies

  8. ERC-1822: Universal Upgradeable Proxy Standard (UUPS)

  9. ERC-1167: Minimal Proxy Contract

  10. Proxies deep dive

  11. Strategy pattern

  12. Introduction to EIP-2535 Diamonds

  13. ERC-2535: Diamonds, Multi-Facet Proxy

  14. Smart Contract Security Audits for EIP-2535 Diamonds Implementations

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *