В первой теоретической части мы поговорили про то:

  1. Что такое обновляемые смарт-контракты?

  2. Как работает обновление?

  3. Про модель прозрачного прокси

  4. Про шаблон UUPS

В этой части статьи мы перейдём от теории к практике и будем вести разработку обновляемого смарт-контракта.

Начинаем разработку

Для начала мы применим паттерн Transparent Proxy, используя инструментарий для обновления OpenZeppelin, который работает с обычными рабочими процессами разработки Web3, использующими JavaScript и Hardhat. OpenZeppelin предлагает плагины, которые интегрируются с Hardat и Truffle. Мы будем использовать Hardhat.

Плагин Hardhat предоставляет нам такие функции, как deployProxy, которые отслеживают для нас логический контракт и дают нам легкий доступ к функциям обновления. По умолчанию, адрес, который развернул контракт, является тем, который имеет права администратора для его обновления.

Давайте создадим проект Hardhat - вы можете назвать его как угодно - и, находясь в этом каталоге, начнем настраивать инструментарий проекта, выполнив следующие шаги.

Настройка проекта

Установите инструменты разработчика Hardhat, библиотеки Web3 и плагин обновлений, предоставляемый OpenZeppelin. Команда ниже создаст ваш файл package.json.

yarn add -D hardhat @openzeppelin/hardhat-upgrades @nomiclabs/hardhat-ethers ethers

Установите пакеты контрактов из NPM, которые содержат интерфейсы контрактов Chainlink и обновляемые библиотеки контрактов OpenZeppelin, которые мы хотим использовать:

yarn add @chainlink/contracts  @openzeppelin/contracts-upgradeable

Затем запустите yarn hardhat в корневом каталоге проекта, чтобы создать пустой файл hardhat.config.js в корне. Внутри этого конфигурационного файла вставьте следующее, чтобы сообщить Hardhat, какую версию компилятора будет использовать проект при импорте нужных зависимостей:

require("@nomiclabs/hardhat-ethers");

require("@openzeppelin/hardhat-upgrades");

const GOERLI_RPC_URL = process.env.GOERLI_RPC_URL_HTTP
const PRIVATE_KEY = process.env.WALLET_PRIVATE_KEY_DEV1;

const ETHERSCAN_KEY = process.env.ETHERSCAN_API_KEY;

/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
 solidity: "0.8.17",
 defaultNetwork: "hardhat",
 networks: {
   localhost: {
     chainId: 31337,
   },
   goerli: {
     url: GOERLI_RPC_URL,
     accounts: PRIVATE_KEY ? [PRIVATE_KEY] : [],
     chainId: 5,
   },
 },
 etherscan: {
   apiKey: ETHERSCAN_KEY,
 },
};

Код смарт-контракта

В корне вашего проекта создайте файл /contracts/PriceFeedTrackerV1.sol Solidity и вставьте в него следующий смарт-контракт:

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

import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract PriceFeedTracker is Initializable {
   address private admin;

   function initialize(address _admin) public initializer {
       admin = _admin;
   }

   function getAdmin() public view returns (address) {
       return admin;
   }

   /**
    * Network: Goerli
    * Aggregator: ETH/USD
    * Address: 0xD4a33860578De61DBAbDc8BFdb98FD742fA7028e
    */
   function retrievePrice() public view returns (int) {

       AggregatorV3Interface aggregator = AggregatorV3Interface(

           0xD4a33860578De61DBAbDc8BFdb98FD742fA7028e
       );
       (
           ,
           /*uint80 roundID*/
           int price, /*uint startedAt*/ /*uint timeStamp*/ /*uint80 answeredInRound*/
           ,
           ,

       ) = aggregator.latestRoundData();

       return price;
   }
} 

Если теперь вы запустите yarn hardhat compile, вы увидите, что код Solidity успешно скомпилировался, а в каталоге проекта появились две новые папки "Artifacts" и "cache".

Вы заметите, что этот смарт-контракт V1 получает данные о цене ETH/USD из Chainlink Price Feeds в сети Goerli. В настоящее время адрес Price Feed'а жестко закодирован, что означает, что он может возвращать только цену ETH/USD. В будущем мы обновим его, чтобы он мог обрабатывать адрес Price Feed любой пары активов в сети Goerli.

Пока же давайте рассмотрим, что происходит с Initializable и функцией initialize(). Из-за некоторых особенностей Solidity, которые выходят за рамки этой статьи, мы не можем включить конструктор в наши смарт-контракты, когда используем обновляемые контракты Open Zeppelin. Вместо этого мы создаем собственную функциональность, подобную конструктору, расширяя базовый контракт Initializable, который помогает нам применить модификатор инициализатора к функции initialize(). Мы можем назвать функцию initialize как угодно, но при использовании initialize плагин Hardhat распознает ее и будет вызывать эту функцию по умолчанию. Если у нас есть функция инициализатора с другим именем, нам нужно будет указать имя нашего инициализатора.

Этот шаблон "Initialize" с модификатором имитирует функцию конструктора, гарантируя, что initialize() будет запущена только один раз. Здесь мы можем явно задать адрес нашего администратора, если захотим - по умолчанию это будет адрес развертывателя. Функция retrievePrice() вызывает смарт-контракт ETH/USD Price Feed и возвращает биржевую цену.

Скрипт развертывания

Давайте развернем этот контракт V1 с помощью следующего скрипта в scripts/deploy_upgradeable_pricefeedtracker.js .

// The Open Zeppelin upgrades plugin adds the `upgrades` property
// to the Hardhat Runtime Environment.
const { ethers, network, upgrades } = require("hardhat");

async function main() {
 // Obtain reference to contract and ABI.
 const PriceFeedTracker = await ethers.getContractFactory("PriceFeedTracker");
 console.log("Deploying PriceFeedTracker to ", network.name);

 // Get the first account from the list of 20 created for you by Hardhat
 const [account1] = await ethers.getSigners();

 //  Deploy logic contract using the proxy pattern.
 const pricefeedTracker = await upgrades.deployProxy(
   PriceFeedTracker,

   //Since the logic contract has an initialize() function
   // we need to pass in the arguments to the initialize()
   // function here.
   [account1.address],

   // We don't need to expressly specify this
   // as the Hardhat runtime will default to the name 'initialize'
   { initializer: "initialize" }
 );
 await pricefeedTracker.deployed();

 console.log("PriceFeedTracker deployed to:", pricefeedTracker.address);
}

main();

Используя deployProxy() в плагинах OpenZeppelin, этот развернутый экземпляр контракта может быть обновлен позже. По умолчанию только адрес, который первоначально развернул контракт, имеет права на его обновление.

deployProxy создаст следующие транзакции:

  • Развертывание логического контракта (наш контракт PriceFeedTracker).

  • Развертывание прокси-контракта и запуск любой функции инициализатора.

  • Развертывание контракта ProxyAdmin (администратор для нашего прокси).

    Перед запуском сценария развертывания убедитесь, что у вас достаточно Goerli ETH. Вы можете получить Goerli ETH из фасета Chainlink. Убедитесь, что вы также задали URL узла RPC и закрытый ключ в переменных окружения, чтобы файл hardhat.config.js мог их прочитать!

Мы можем запустить наш сценарий развертывания с помощью следующей команды, чтобы развернуть контракт в тестовой сети Ethereum Goerli.

yarn hardhat run --network goerli scripts/deploy_upgradeable_pricefeedtracker.js

В результате в вашем терминале должно появиться подтверждение, похожее на приведенное ниже, но с другим адресом контракта. Запишите этот адрес контракта. Обратите внимание, что это адрес вашего прокси-контракта, а не логического контракта. Нам нужен адрес прокси-контракта, потому что это стабильный (неизменный) адрес, который мы будем использовать для взаимодействия с нашими логическими контрактами.

Deploying PriceFeedTracker...
PriceFeedTracker deployed to: 0x5FC8d32690cc91D4c39d9d3abcBD16989F875707

Вы можете изучить документацию по deployProxy() и ее опциям конфигурации здесь. Обратите внимание, что по умолчанию используется шаблон "прозрачный", но вы можете указать, что хотите, чтобы ваш прокси следовал шаблону UUPS, явно задав этот параметр конфигурации.

Консоль Hardhat

Прежде чем мы обновим наш контракт, давайте взаимодействовать с ним с помощью консоли Hardhat, которая позволяет нам писать JavaScript для взаимодействия с нашим логическим контрактом, развернутым через прокси-контракт.

В новом (третьем!) окне терминала выполните следующую команду, чтобы присоединить консоль к блокчейну Goerli:

yarn hardhat console --network goerli

Откроется приглашение консоли, в котором вы можете выполнить следующие команды по отдельности:

> const PriceFeedTracker = await ethers.getContractFactory("PriceFeedTracker");
undefined
> const priceFeedTracker = await PriceFeedTracker.attach('<<<< YOUR CONTRACT ADDRESS  >>>>') 
undefined

Затем вызовите функцию getAdmin(), которая должна вывести адрес вашего кошелька развертывателя - адрес, который вы передали в качестве аргумента функции initialize в сценарии развертывания.

> (await priceFeedTracker.getAdmin())
'0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'

Затем попробуйте получить цену ETH/USD. Это вызов только для представления, поскольку он не изменяет состояние и не вызывает никаких событий, поэтому вам не нужно платить за газ.

> (await v1.retrievePrice())
BigNumber { value: "150701000000" }

Ок! Если вы получаете эти результаты, значит прокси-контракт правильно взаимодействует с развернутым прокси-контрактом.

Вот полезный совет: перейдите по адресу goerli.etherscan.io/address/YOUR_CONTRACT_ADDRESS и перейдите на вкладку Events. Вы должны увидеть несколько событий, которые выглядят как на рисунке ниже. Найдите событие под названием "Upgrade" и нажмите на маленькую стрелку рядом с ним. Это покажет вам адрес контракта на внедрение. Именно этот адрес будет меняться каждый раз, когда вы будете обновлять свой смарт-контракт.

Модернизированный логический контракт

Теперь давайте соберем обновленный логический контракт, который имеет некоторые дополнительные функции. Посмотрите на PriceFeedTrackerV2 ниже. Вы заметите следующие изменения:

  1. Существует новая публичная переменная хранения под названием price, которая имеет тип int и будет хранить полученную цену.

  2. Есть новое событие, которое выдает две части данных при обновлении Price Feed.

  3. Функция retrievePrice() больше не кодирует адрес ETH/USD, а получает адрес Price Feed от вызывающей стороны. Она также проверяет, что передан ненулевой адрес. Как только цена получена, она издает событие, а также сохраняет цену в переменной состояния price (обе операции изменяют состояние блокчейна, так что это больше не функция просмотра).

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

import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract PriceFeedTrackerV2 is Initializable {
   address private admin;
   int public price; // NOTE: new storage slot

   // Emitted when the price is retrieved changes
   event PriceRetrievedFrom(address feed, int price);

   function initialize(address _admin) public initializer {
       admin = _admin;
   }

   function getAdmin() public view returns (address) {
       return admin;
   }

   // Fetches the price from the feed.
   // Note that the function is no longer a view function as it emits an event.
   function retrievePrice(address feed) public returns (int) {
       require(
           feed != address(0x0),
           "PriceFeedTrackerV2: Pricefeed address must not be zero address."
       );

       AggregatorV3Interface aggregator = AggregatorV3Interface(feed);
       (
           ,
           /*uint80 roundID*/
           int _price, /*uint startedAt*/ /*uint timeStamp*/ /*uint80 answeredInRound*/
           ,
           ,

       ) = aggregator.latestRoundData();

       price = _price;

       emit PriceRetrievedFrom(feed, _price);

       return price;
   }
}
  1. Существует важный технический момент, касающийся переменных хранения, который мы должны отметить на этом этапе. Вы увидите, что переменная состояния admin осталась на прежней "позиции", а переменная цены объявлена после нее. Это связано с тем, что при обновлении логических контрактов они не должны менять порядок объявления переменных состояния, так как это может привести к столкновению хранилищ (иначе называемому коллизией хранилищ), поскольку контекст хранилища находится в прокси-контракте (как обсуждалось ранее в этом блоге). Это связано с тем, что переменным состояния обычно назначаются "слоты" расположения хранения в контексте прокси-контракта, и эти слоты должны оставаться неизменными во всех обновлениях логического контракта. Поэтому мы не можем заменять слоты хранения или вставлять новые между обновлениями. Все новые переменные состояния должны быть добавлены в конец, в слот, который ранее не был занят. OpenZeppellin использует слоты хранения EIP1967, чтобы избежать столкновений хранения в логических контрактах. Подробнее о более глубоких деталях паттернов прокси OpenZeppelin и хранения данных вы можете прочитать здесь.

Сценарий развертывания для контракта на обновление

Контракт логики обновления имеет другое название и новую функциональность. Мы можем обновить экземпляр V1, вызвав функцию upgradeProxy, которая создает следующие транзакции:

  1. Развернуть обновленный логический контракт (наш контракт PriceFeedTrackerV2).

  2. Вызов контракта ProxyAdmin (администратора нашего прокси) для обновления контракта прокси, чтобы он указывал на новый логический контракт.

Наш обновленный сценарий будет находиться в scripts/upgrade_pricefeedtracker.js и будет выглядеть следующим образом (обратите внимание, что мы используем upgradeProxy, а не deployProxy). Очень важно отметить, что вы должны добавить адрес развернутого контракта до запуска скрипта - забыв об этом, легко допустить ошибку, которая может запутать вас на несколько часов!

const { ethers, upgrades } = require("hardhat");

async function main() {
  // TODO Check this address is right before deploying.
  const deployedProxyAddress = "<<< YOUR PROXY CONTRACT ADDRESS HERE >>>";

  const PriceFeedTrackerV2 = await ethers.getContractFactory(
    "PriceFeedTrackerV2"
  );
  console.log("Upgrading PriceFeedTracker...");

  await upgrades.upgradeProxy(deployedProxyAddress, PriceFeedTrackerV2);
  console.log("PriceFeedTracker upgraded");
}

main(); 

Затем мы можем запустить сценарий с помощью

yarn hardhat run --network goerli scripts/upgrade_pricefeedtracker.js

И мы должны увидеть результат, который выглядит следующим образом

Compiled 2 Solidity files successfully
Upgrading PriceFeedTracker...
PriceFeedTracker upgraded

Обратите внимание, что адрес прокси не изменился. Но если вы вернетесь в Etherscan и посмотрите на испускаемые события вашего прокси-контракта, вы должны увидеть новое событие "Upgraded" и новый адрес контракта реализации.

Если развертывание вашего контракта занимает много времени, пожалуйста, прокрутите вниз до раздела этого блога, посвященного устранению неполадок.

Теперь давайте воспользуемся консолью Hardhat для взаимодействия с обновленным контрактом.

Выполните следующие команды, по одной за раз. Я рекомендую оставить 60-90 секунд после запроса цены, прежде чем проверять переменную состояния цены.

> var V2 = await ethers.getContractFactory("PriceFeedTrackerV2")
undefined

> var v2 = await V2.attach(///// INSERT PROXY CONTRACT ADDRESS /////)
undefined

// ETH/USD
> var ethusdTx = await v2.retrievePrice('0xD4a33860578De61DBAbDc8BFdb98FD742fA7028e') 
undefined

// Wait about 60-90 seconds then read the updated state variable.
> (await v2.price())
BigNumber { value: "150701000000" }

// Change to LINK/ETH
> var linkEthTx = await v2.retrievePrice('0xb4c4a493AB6356497713A78FFA6c60FB53517c63')

// Wait about 60-90 seconds then read the updated state variable.
> (await v2.price())
BigNumber { value: "4659009800000000" }

Вы заметите, что мы обновили переменную price, чтобы сохранить полученную цену из контракта агрегатора ETH/USD Price Feed, а затем снова из LINK/ETH Price Feed.

Вот и все! Вы только что обновили свой логический контракт, а контракт, с которым вы взаимодействуете (прокси-контракт), не изменился! Прокси-контракт делегирует вызовы логических функций логическому контракту, который зарегистрирован в прокси-контракте как последний логический контракт!

Устранение неполадок

Существует несколько проблем, которые могут возникнуть при выполнении транзакций с обновляемыми контрактами в сети Goerli. Одна из проблем, с которой я столкнулся во время написания этого блога, заключалась в том, что мои транзакции застревали в mempool. Это происходило потому, что количество газа, отправляемого из моего кошелька, было меньше, чем требовалось сети в то время - на момент написания статьи в Goerli наблюдались скачки газа. Не было никаких ошибок, указывающих на то, что это произошло, поэтому мне потребовалось время, чтобы разобраться! Я использую Alchemy в качестве RPC-провайдера для подключения к Goerli, поэтому я нашел это видео, которое помогло мне разобраться. Я создал этот сценарий для запуска в качестве сценария Hardhat, который помог мне очистить транзакции mempool.

Также обратите внимание, что лучше всего подождать около 60 секунд после любой транзакции, которая изменяет состояние блокчейна. Например, чтение из переменной хранения цены слишком быстро после выполнения retrievePrice() вернет более старые данные из блокчейна, поскольку транзакции записи, изменяющие состояние, возможно, еще не были подтверждены блоками.

Заключение

Мы рассказали о том, как обновлять смарт-контракты, зачем это нужно и какова новая практика обновления смарт-контрактов. Мы изучили несколько паттернов проектирования, некоторые проблемы, которые могут поставить вас в тупик, а также запустили код для развертывания и обновления смарт-контракта, который потребляет данные из Price Feeds.


Телеграм канал про web3 разработку, смарт-контракты и оракулы.


Комментарии (1)


  1. crypter
    12.12.2022 13:15

    Смарт контракт обычно выполняет функцию неизменяемого прозрачного закона. Для чего может быть нужен смарт-контракт, если автор сможет его изменять? Не теряется ли от этого сама суть смарт-контрактов?