Solidity
April 7, 2023

Solidity | ERC1967 upgradeable Proxy 

Всем привет, сегодня пойдет речь о proxy, и стандарте ERC1967. В прошлой статье, а уже рассказывал про этот паттерн, но в этот раз мы подробно рассмотрим его и напишем сами с нуля.

Начну с того, что есть два типа proxy upgradeable smart-contract. Первый тип это Transparent, а второй UUPS.

Transparent более старый тип, он работает по принципу все обновление и администрирования происходит в самом proxy. Это более затратно по газу, но более понятно с точки зрения логики.

UUPS более новый тип и более актуальный на данный момент. Он работает по принципу обновление и администрирования происходит в самой реализации. То есть все функции для обновления и администрирования находятся в самом смарт контракте, а не в proxy. По факту мы увидим, что для нашего смарт-контракта, который мы хотим обновлять нам понадобиться просто унаследовать контракт UUPSUpgradeable, и реализовать все функции для обновления.

Как вы поняли мы идем в ногу со временем, и поэтому рассмотрим второй тип proxy.

Начнем по порядку. У нас будет много разных смарт-контрактов и давайте я вам объясню для чего каждый из них будет нужен, а потом мы их разберем.

Proxy.sol - этот смарт-контракт вы может помните из прошлой статьи. Он реализует наш вызов delegatecall, на СК

initializable.sol - этот смарт-контракт позволит нам создать псевдо конструктор, об этом по подробнее позже.

ERC1967Upgrade.sol - смарт-контракт, который реализует все наши функции установки и обновления Implementation и слотов для них.

ERC1967.sol - смарт-контракт, который реализует обновляемый proxy, то есть наследует все из ERC1967Upgrade.sol

Address.sol - смарт-контракт, который будет нам помогать проверять адрес СК и делегировать вызовы. В общем небольшая утилита.

StorageSlot.sol - смарт-контракт, который поможет с записью и чтением информации в слоты, чтоб избежать конфликтов с обновляемыми СК.

P.S если не понятно о каких слотах идет речь и об их проблеме поймете по позже

Так же понадобиться интерфейс IERC1822.sol, для проверки адреса Implementation

Разбор контрактов

Proxy

 // SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
abstract contract Proxy {
    function _delegate(address implementation) internal virtual {        
        assembly {
            calldatacopy(0, 0, calldatasize())
            let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())
            switch result            
            case 0 {                
                revert(0, returndatasize())            
            }            
            default {                
                return(0, returndatasize())            
            }        
        }    
    }    
    function _implementation() internal view virtual returns (address);
    
    function _fallback() internal virtual {        
        _beforeFallback();        
        _delegate(_implementation());    
    }
    fallback() external payable virtual {        
        _fallback();    
    }
    receive() external payable virtual {        
        _fallback();    
    }
    function _beforeFallback() internal virtual {}
}

Об этом СК я уже рассказывал, и поэтому не буду подробно останавливаться. Здесь есть функция _delegate, которая на низкоуровневом языке делает вызов delegatecall и таким образом вызывает функции у Implementation, то есть у нашего СК. Чтоб мы могли вызывать все функции СК, то мы используем fallback функцию, которая вызывается всегда, когда в смарт-контракт попадает функция, которой не существует. И таким не хитрым способом Proxy получает доступ ко всем функциям нашего СК.

Address

 // SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
library Address {        
    function isContract(address account) internal view returns (bool) {
        return account.code.length > 0;    
    }
    function functionDelegateCall(address target, bytes memory data) internal returns (bytes memory) {        
        return functionDelegateCall(target, data, "Address: low-level delegate call failed");    
    }
    function functionDelegateCall(        
        address target,        
        bytes memory data,        
        string memory errorMessage    
    ) internal returns (bytes memory) {        
        (bool success, bytes memory returndata) = target.delegatecall(data);        
        return verifyCallResultFromTarget(target, success, returndata, errorMessage);    
    }
    function verifyCallResultFromTarget(        
      address target,        
      bool success,       
      bytes memory returndata,        
      string memory errorMessage    
    ) internal view returns (bytes memory) {        
        if (success) {            
            if (returndata.length == 0) {                            
                require(isContract(target), "Address: call to non-contract");            
            }            
            return returndata;        
        } else {            
            _revert(returndata, errorMessage);        
        }    
    }
    function _revert(bytes memory returndata, string memory errorMessage) private pure {        
        if (returndata.length > 0) {            
            assembly {                
                let returndata_size := mload(returndata)                
                revert(add(32, returndata), returndata_size)            
            }        
        } else {            
            revert(errorMessage);        
        }    
    }
}

На самом деле в openzeppelin там еще есть функции в этом СК, но они нам не нужны, поэтому я оставил только те, которые мы будем использовать.

isContract - эта функция проверяет на то, что адрес является смарт-контрактом. account.code.length должен быть больше нуля если это СК, а если это просто адрес, то разумеется размер кода равен нулю.

functionDelegateCall - вызывает в себе functionDelegateCall, это своего рода перегрузка функции, но это не важно, важно то, что это обычный delegatecall, на адрес который мы передали. Дальше мы можем увидеть, что идет
return verifyCallResultFromTarget

verifyCallResultFromTarget - функция, которая будет проверять наш результат после delegatecall. Как она это делает: Если переменная success равна true, то идем дальше, если нет то откат через функцию revert (о ней позже), если все таки true, то мы дальше начинаем проверять наши данные, которые вернули после вызова, если они равны нулю, то может возникнуть вопрос, а это вообще адрес смарт-контракта? Поэтому через require мы проверяем что наш адрес это СК, через функцию isContract из Address.sol. Если же данные есть, то мы точно знаем, что это адрес СК и возвращаем их.

А если у нас не прошел delegatecall, то мы попадаем в функцию revert.

Тут мы смотрим, если у нас данные больше нуля, то мы делаем махинацию на assembly. Я сейчас постараюсь объяснить что происходит, но если вы на знакомы с тем как храниться динамические данные в солидити может показаться не понятно.

let returndata_size := mload(returndata) тут мы в переменную загружаем первые 32 байта из returndata, потому что функция mload считывает первые 32 байта. Так как returndata имеет тип bytes memory, то первые 32 байта, являются длиной returndata (длина 32 байта). Таким образом переменная returndata_size становиться указателем на длину returndata. Надеюсь понятно.

revert(add(32, returndata), returndata_size) функция revert работает по принципу вернуть данные начиная с такого то места и с такой то длиной данных, так как мы знаем что первые 32 байта указывают на длину, то мы их пропускаем с помощью add(32, returndata) (add это сумма) и возвращаем данные, которые лежат после 32 байт длины. Таким образом мы вернем причину отката и сделаем откат. Ну а если данных нет, то просто причину вернем errorMessage и все.

Теперь еще один вспомогательный контракт, это StorageSlot

 // SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
library StorageSlot {    
    struct AddressSlot {        
        address value;    
    }
    struct BooleanSlot {        
       bool value;    
    }        
    function getAddressSlot(bytes32 slot) internal pure returns (AddressSlot storage r) {                
        assembly {            
            r.slot := slot        
        }   
    }
    function getBooleanSlot(bytes32 slot) internal pure returns (BooleanSlot storage r) {               
        assembly {            
            r.slot := slot        
        }    
    }
}

Тут тоже на много больше функций чем я покажу, но я не вижу смысла нагружать бесполезной инфой. У нас есть 2 простые структуры. И есть 2 функции, которые возвращают через ассемблер данные находящиеся в том или ином слоте в памяти, но вернее сказать слоты в storage. Первый вернет адрес на том месте, второй bool переменную. Все просто.

Initializable

 // SPDX-License-Identifier: MIT
pragma solidity ^0.8.2;
import "../utils/Address.sol";
abstract contract Initializable {       
    uint8 private _initialized;
    bool private _initializing;
    
    event Initialized(uint8 version);
    
    modifier initializer() {        
        bool isTopLevelCall = !_initializing;        
        require(            
          (isTopLevelCall && _initialized < 1) || (!Address.isContract(address(this)) && _initialized == 1),
          "Initializable: contract is already initialized"        
        );        
        _initialized = 1;        
        if (isTopLevelCall) {            
            _initializing = true;        
        }        
        _;        
        if (isTopLevelCall) {           
             _initializing = false;            
             emit Initialized(1);        
        }    
    }
    modifier reinitializer(uint8 version) {        
        require(!_initializing && _initialized < version,
         "Initializable: contract is already initialized");
        _initialized = version;       
        _initializing = true;        
        _;        
        _initializing = false;        
        emit Initialized(version);    
    }
    modifier onlyInitializing() {        
        require(_initializing, "Initializable: contract is not initializing");        
        _;    
    }
    function _disableInitializers() internal virtual {        
        require(!_initializing, "Initializable: contract is initializing");        
        if (_initialized != type(uint8).max) {            
            _initialized = type(uint8).max;            
            emit Initialized(type(uint8).max);        
        }    
    }
    function _getInitializedVersion() internal view returns (uint8) { 
        return _initialized;    
    }
    function _isInitializing() internal view returns (bool) {        
        return _initializing;    
    }
}

Тут мы просто создаём модификаторы для того, чтоб функцию можно было вызвать только 1 раз. То есть псевдо конструктор. initializer это и делает. Как видно внутри модификатора, сначала идет проверка require, что наша функция еще не была вызвана ни разу. Для этого переменная _initialized должна быть меньше нуля и isTopLevelCall true. isTopLevelCall просто как флаг для того, чтоб не возможно было вызвать функцию саму в себе и перевызвать функцию(псевдо конструктор). Надеюсь понятно. Таким образом мы создали псевдо конструктор.

Модификатор reinitializer работает так же, но для новых версий вашего СК. Главное отличие в том, что мы смотрим чтоб новая версия _initialized была больше предыдущей.

onlyInitializing просто смотрит был вызван конструктор или нет. Если нет то откат.

_disableInitializers работает по принципу блокировки СК, если нам хочется предотвратить повторную инициализацию, поставив максимальное число uint8 = 255, то мы больше не сможем инициализировать функции. Что логично, ведь в reinitializer есть условие что версия должна быть больше предыдущей. И в initializer, что переменная _initialized меньше одного.

Ну и две функции. Одна возвращает версию, другая возвращает _initializing переменную(True или False), то есть инициализирован контракт или нет.

Интерфейс IERC1822

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IERC1822Proxiable {    
    function proxiableUUID() external view returns (bytes32);
}

Просто возвращает слот хранения адреса implementation для UUPS proxy

ERC1967Upgrade

 // SPDX-License-Identifier: MIT
pragma solidity ^0.8.2;
import "../utils/Address.sol";
import "../utils/StorageSlot.sol";
import "../interfaces/draft-IERC1822.sol";
abstract contract ERC1967Upgrade {     
    bytes32 private constant _ROLLBACK_SLOT = 0x4910fdfa16fed3260ed0e7147f7cc6da11a60208b5b9406d12a635614ffd9143;
    bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
    event Upgraded(address indexed implementation);
    function _getImplementation() internal view returns (address) {        
        return StorageSlot.getAddressSlot(_IMPLEMENTATION_SLOT).value;    
    }
    function _setImplementation(address newImplementation) private {        
        require(Address.isContract(newImplementation), 
        "ERC1967: new implementation is not a contract");       
        StorageSlot.getAddressSlot(_IMPLEMENTATION_SLOT).value = newImplementation;    
    }
    function _upgradeTo(address newImplementation) internal {        
        _setImplementation(newImplementation);        
        emit Upgraded(newImplementation);    
    }
    function _upgradeToAndCall(        
      address newImplementation,        
      bytes memory data,        
      bool forceCall    
    ) internal {        
        _upgradeTo(newImplementation);        
        if (data.length > 0 || forceCall) {              
            Address.functionDelegateCall(newImplementation, data);       
        }    
    }
    
    function _upgradeToAndCallUUPS(address newImplementation, bytes memory data, bool forceCall) internal {        
        if (StorageSlot.getBooleanSlot(_ROLLBACK_SLOT).value) {            
            _setImplementation(newImplementation);        
        } else {            
            try IERC1822Proxiable(newImplementation).proxiableUUID() returns (bytes32 slot) {                
                require(slot == _IMPLEMENTATION_SLOT, "ERC1967Upgrade: unsupported proxiableUUID");            
            } catch {                
                revert("ERC1967Upgrade: new implementation is not UUPS");            
            }           
            _upgradeToAndCall(newImplementation, data, forceCall);        
         }    
         
    }       
    bytes32 internal constant _ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;
    event AdminChanged(address previousAdmin, address newAdmin);
    function _getAdmin() internal view returns (address) {        
        return StorageSlot.getAddressSlot(_ADMIN_SLOT).value;    
    }
    function _setAdmin(address newAdmin) private {       
        require(newAdmin != address(0), "ERC1967: new admin is the zero address");        
        StorageSlot.getAddressSlot(_ADMIN_SLOT).value = newAdmin;   
    }
    function _changeAdmin(address newAdmin) internal {        
        emit AdminChanged(_getAdmin(), newAdmin);        
        _setAdmin(newAdmin);    
    }
   uint256[50] private __gap;
}

Тут у меня тоже не все написано, потому что в openzeppelin есть такая реализация, как beacon, которую я не использую.

Первая и вторая переменная constant, это номер слота переменных, которые специально спрятаны, чтоб избежать взломов и атак на наш proxy. Дальше мы увидим как они используются. То есть переменные под этими слотами мы не знаем где находятся и поэтому к ним сложно добраться.

Чтоб их получить мы делали что то на подобии этого :
bytes32 private constant implementationPosition = bytes32(uint256( keccak256('eip1967.proxy.implementation')) - 1 ));

Функции:

_getImplementation

function _getImplementation() internal view returns (address) {        
        return StorageSlot.getAddressSlot(_IMPLEMENTATION_SLOT).value;    
    }

При помощи нашего StorageSlot достает из нашего слота адрес implementation.

_setImplementation

function _setImplementation(address newImplementation) private {        
        require(Address.isContract(newImplementation), 
        "ERC1967: new implementation is not a contract");       
        StorageSlot.getAddressSlot(_IMPLEMENTATION_SLOT).value = newImplementation;    
    }

При помощи Address проверяем что наш новый адрес, это СК. И просто в наш слот _IMPLEMENTATION_SLOT устанавливаем новый адрес.

_upgradeTo

function _upgradeTo(address newImplementation) internal {        
        _setImplementation(newImplementation);        
        emit Upgraded(newImplementation);    
    }

Функция установки нового implementation. Просто вызываем _setImplementation так как она private.

_upgradeToAndCall

function _upgradeToAndCall(        
      address newImplementation,        
      bytes memory data,        
      bool forceCall    
    ) internal {        
        _upgradeTo(newImplementation);        
        if (data.length > 0 || forceCall) {              
            Address.functionDelegateCall(newImplementation, data);       
        }    
    }

В целом тоже самое что и _upgradeTo, но мы можем еще передать data, и благодаря этому сделать низкоуровневый вызов, обычно используется во время установки implemenation и вызывает функцию initialize, как конструктор. Если
data = 0, то мы пропускаем этот вызов. ForceCall работает так, что даже если data=0, все равно делать delegatecall. Зачем это я так и не понял, потому что почти всегда используют false у данной переменной.

_upgradeToAndCallUUPS

function _upgradeToAndCallUUPS(address newImplementation, bytes memory data, bool forceCall) internal {        
        if (StorageSlot.getBooleanSlot(_ROLLBACK_SLOT).value) {            
            _setImplementation(newImplementation);        
        } else {            
            try IERC1822Proxiable(newImplementation).proxiableUUID() returns (bytes32 slot) {                
                require(slot == _IMPLEMENTATION_SLOT, "ERC1967Upgrade: unsupported proxiableUUID");            
            } catch {                
                revert("ERC1967Upgrade: new implementation is not UUPS");            
            }           
            _upgradeToAndCall(newImplementation, data, forceCall);        
         }    
         
    }     

Функция, которая обновляет implementation для UUPS. Тут мы смотрим на _ROLLBACK_SLOT, если в нем находится true переменная, то устанавливаем implementation первый раз, если нет, то это означает, что это новая версия СК и нужно добавить пару проверок. При помощи try catch мы пытаемся, обращаясь через интерфейс от лица нового implementation к функции proxiableUUID (ее напишем позже), узнать слот хранения implementation. Это нужно делать чтоб у нас получилось вернуть значение. Если у нас не получиться это сделать, значит может быть такое, что кто то делегирует эту функцию (_upgradeToAndCallUUPS) саму в себя и тем самым блокирует контракт. И еще slot, он должен быть равен слоту _IMPLEMENTATION_SLOT. Если же не получилось, то просто откат. И только потом, когда все проверки прошли, мы уже обновляем наш implementation адрес.

Дальше слот хранения админа СК.

Админ функции

  function _getAdmin() internal view returns (address) {        
        return StorageSlot.getAddressSlot(_ADMIN_SLOT).value;    
    }
    function _setAdmin(address newAdmin) private {       
        require(newAdmin != address(0), "ERC1967: new admin is the zero address");        
        StorageSlot.getAddressSlot(_ADMIN_SLOT).value = newAdmin;   
    }
    function _changeAdmin(address newAdmin) internal {        
        emit AdminChanged(_getAdmin(), newAdmin);        
        _setAdmin(newAdmin);    
    }

Ну тут все просто. _getAdmin по слоту возвращает админа. _setAdmin проверяет, что новый адрес не нулевой и устанавливает в _ADMIN_SLOT нового овнера. _changeAdmin меняет админа, при помощи _setAdmin.

uint256[50] private __gap строчка, которая гарантирует, что не будет проблем со слотами, не будет наложений и смещений. Грубо говоря это резервация пространства для новых переменных в бедующих обновлениях. То есть в данном случает у нас есть целых 50 свободных слотов.

ERC1967

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "../Proxy.sol";
import "./ERC1967Upgrade.sol";

contract ERC1967Proxy is Proxy, ERC1967Upgrade {    
    uint8 private _initializd;    uint256 public x1;    
    constructor(address _logic, bytes memory _data) payable {        
        _upgradeToAndCall(_logic, _data, false);    
    }    
    function _implementation() internal view virtual override returns (address impl) {        
        return ERC1967Upgrade._getImplementation();    
    }    
}

Это наш Proxy СК. Тут мы реализуем конструктор, в котором вызываем _upgradeToAndCall и устанавливаем implementation.

Ну и _implementation функция, которая возвращает наш адрес СК.

По идее всю логику нужно реализовать тут, но это не советуют делать и для этого создают СК, который наследует этот и там уже все реализуют.

UUPSUpgradeable

 // SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./interfaces/draft-IERC1822.sol";
import "./ERC1967/ERC1967Upgrade.sol";
import "./utils/Initializable.sol";
abstract contract UUPSUpgradeable is IERC1822Proxiable, ERC1967Upgrade, Initializable {    
    function __UUPSUpgradeable_init() internal onlyInitializing {    
    }
    function __UUPSUpgradeable_init_unchained() internal onlyInitializing {    
    }    
    address private immutable __self = address(this);
    modifier onlyProxy() {        
        require(address(this) != __self, 
        "Function must be called through delegatecall");        
        require(_getImplementation() == __self, 
        "Function must be called through active proxy");        
        _;   
    }
    modifier notDelegated() {         
         require(address(this) == __self, 
         "UUPSUpgradeable: must not be called through delegatecall");        
         _;    
    }
    function proxiableUUID() external view virtual override notDelegated returns (bytes32) {        
        return _IMPLEMENTATION_SLOT;    
    }
    function upgradeTo(address newImplementation) public virtual onlyProxy {        
        _authorizeUpgrade(newImplementation);        
        _upgradeToAndCallUUPS(newImplementation, new bytes(0), false);    
    }
    function upgradeToAndCall(address newImplementation, bytes memory data) public payable virtual onlyProxy {        
         _authorizeUpgrade(newImplementation);        
         _upgradeToAndCallUUPS(newImplementation, data, true);    
    }
    function _authorizeUpgrade(address newImplementation) internal virtual;
    uint256[50] private __gap; 
}

__UUPSUpgradeable_init и __UUPSUpgradeable_init_unchained нужны по факту для инициализации каких то идей, но их нет. А так, если серьезно, то используется для проверки, что инициализация псевдо конструктора прошла.

onlyProxy модификатор, который говорит что только Proxy может вызывать функции.

notDelegated не дает делегировать функции.

Это все нужно если у нас есть функции с одинаковыми селекторами, и чтоб не произошел казус. И некоторые функции нельзя было делегировать для безопасности, как мы уже и заметили в _upgradeToAndCallUUPS.

proxiableUUID

Та самая функция которая возвращает слот implementstion, и мы ее вызывали в _upgradeToAndCallUUPS.

upgradeTo очевидно, что просто обновляет наш адрес СК (implementation), и upgradeToAndCall делает тоже самое. В целом понятно, что идет вызов функций из ERC1967Upgrade.

_authorizeUpgrade функция которая переопределяется в нашем СК, который мы хотим потом обновлять(implementation)

Пример СК, который мы хотим в будущем обновить:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./utils/Initializable.sol";
import "./UUPSUpgradeable.sol";
contract V1 is Initializable, UUPSUpgradeable{    
    uint256 public number;    
    address owner;    
    modifier onlyOwner(){        
        require(owner == msg.sender, "error");        
        _;    
    }    
    function initialize(uint _number) public payable initializer {        
        __UUPSUpgradeable_init();        
        number = _number;        
        owner = msg.sender;    
    }    
    function _authorizeUpgrade(address newImplementation) internal onlyOwner override{}
}

И обновлённая версия

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./utils/Initializable.sol";
import "./UUPSUpgradeable.sol";
contract V1 is Initializable, UUPSUpgradeable{    
    uint256 public number;    
    address owner;    
    uint256 public newNumber;
    modifier onlyOwner(){        
        require(owner == msg.sender, "error");        
        _;    
    }    
    function initialize(uint _number) public payable initializer {        
        __UUPSUpgradeable_init();        
        number = _number;
        newNumber = _newNumber;        
        owner = msg.sender;    
    }    
    function _authorizeUpgrade(address newImplementation) internal onlyOwner override{}
}

Нужно заметить что мы наследуем именно UUPSUpgradeable и Initializable.

Вот мы и реализовали свой ERC1967 proxy смарт-контракт. Сразу хочется сказать, что для использования этот код не рекомендуется. Потому что я убрал некоторые вещи, которые показались не очень нужными и поэтому не стоит рисковать и писать самим этот стандарт. Тот же самый beacon, который по хорошему тоже должен быть и случай Transparent как бы ни кто не исключал. Поэтому используйте версию из openzeppelin и не парьтесь. Главное что мы знаем как эта штука работает изнутри и понимаем что происходит и для чего это нужно. Это как минимум упростит написание своих upgradeable СК. А как максимум, вы будете знать то, что обычно другие считают не нужным знать.

Бонус

Если ты дожил до этого момента, то покажу скрип деплоя. На самом деле он есть в openzeppelin, но когда я писал его, то в некоторых моментах не мог сразу понять что от меня хотят.

Устанавливаем эту библиотеку для взаимодействия.

 npm install @openzeppelin/contracts-upgradeable

Деплой первой версии

 const { ethers, upgrades } = require("hardhat");
async function main() {  
  if (network.name === "hardhat") {    
  console.warn(      
  "You are trying to deploy a contract to the Hardhat Network, which" +   
   "gets automatically created and destroyed every time. Use the Hardhat" +        
   " option '--network localhost'"    
   );  
  }
  const [deployer] = await ethers.getSigners()
  console.log("Deploying with", await deployer.getAddress())
  const V1 = await ethers.getContractFactory("V1");  
  const v1 = await upgrades.deployProxy(V1.address, [53], {
    kind: 'uups'
  });  
  await v1.deployed();  
  console.log("deployed to:", v1.address);
}
main().catch((error) => { 
 console.error(error);  
 process.exitCode = 1;
});

Тут все как всегда, но есть изменение:

const v1 = await upgrades.deployProxy(V1.address, [0]);  

Мы деплоим не как обычно через deploy(), а через deployProxy. Передаем адрес СК и в массив, наши аргументы, которые будут переданы и вызваны в функции initialize, если такая имеется. И главное: если мы хотим делать деплой через UUPS, то это нужно явно указать через kind переменную. Так как по умолчанию будет использоваться transpenrent.

v1 переменная будет нашим Proxy смарт-контрактом, а не нашим смарт-контрактом V1.

Деплой новой версии

 const { ethers, upgrades } = require("hardhat");
async function main() {  
  if (network.name === "hardhat") {    
  console.warn(      
  "You are trying to deploy a contract to the Hardhat Network, which" +   
   "gets automatically created and destroyed every time. Use the Hardhat" +        
   " option '--network localhost'"    
   );  
  }
  addressProxy = 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
  const V2 = await ethers.getContractFactory("V2"); 
  const v2 = await upgrades.upgradeProxy(addressProxy, V2, {
    call:{
     fn:"initialize(uint256), 
     args:[35])
    }
    });
  
  console.log("deployed to:", v2.address);
}
main().catch((error) => { 
 console.error(error);  
 process.exitCode = 1;
});

Тут мы просто будем деплоить новую версию СК через upgradeProxy.

И необычная особенность upgradeProxy()

const v2 = await upgrades.upgradeProxy(addressProxy, V2, {
    call:{
     fn:"initialize(uint256), 
     args:[35]
    }
    });

Мы передаем адрес Proxy и новый адрес обновлённого СК. Последнюю переменную call нужно использовать в том случае, если нам снова нужно вызвать конструктор, и вот таким необычным способом нужно передавать туда функцию, которую мы хотим вызвать и аргументы в массиве. Тут не будет как в deployProxy, где достаточно передать аргументы и он сам найдет нашу функцию initialize и сделает все что нужно. Но и так нормально.

Ну на этом я пожалуй закончу свой долгий монолог про ERC1967. Сегодня мы изнутри посмотрели как работает Proxy и как мы можем обновлять свои СК. А главное узнали специфику и хитрости которые можно использовать для хранения информации, например через спрятанные слоты. Дальше больше. И теперь когда мы будем писать свои обновляемые СК мы будем знать что у этой машины под капотом.

Полезные ссылки:

Что такое proxy

Реализация Transparent и UUPS

Как написать скрипт деплой

API плагина upgrade

Реализация EIP-1967

tg: мой телеграмчик)

GitHub: Этот проект на гит хабе