Тестирование смарт-контрактов в Foundry (часть 3)

от автора

За прошедшие две части мы освоили почти всю базу, которая нужна нам для тестирования и отладки контрактов в Foundry. Пришло время закрепить успех! В этой части мы рассмотрим тестирование простых прокси-контрактов (Proxy Upgradable Contracts, UUPS) и на их примере создадим скрипт для деплоя и вызова функции, поработаем с переменными среды (env), частично автоматизируем работу с запуском скриптов (Makefile), разберёмся в форк-тестировании и запустим наш проект в тестнете!

Поехали!

Поехали!

1 часть

Исходный код к данной части курса

Ссылка на мой аккаунт в Хабр Карьере (буду рад знакомству)

Тестирование прокси-контрактов

На русском ютубе есть два замечательных ролика про прокси-контракты, если вдруг вы не знаете, что это (тык1 и тык2). Я предполагаю, что вы понимаете основную теорию и на ней можно сильно не останавливаться. Давайте создадим простой пример прокси-контракта (CounterV1.sol):

// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.19;  import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";  /**  * Создаём абсолютно тривиальный контракт, который может  * доставать одну переменную из хранилища  * и возвращать одно константное значение  */ contract CounterV1 is Initializable, OwnableUpgradeable, UUPSUpgradeable {     uint256 internal number;      /// @custom:oz-upgrades-unsafe-allow constructor     constructor() {         _disableInitializers();     }      function initialize() public initializer {         __Ownable_init(); //делаем transferOwnership на msg.sender         __UUPSUpgradeable_init(); // Ничего не делаем :)     }      function getNumber() external view returns (uint256) {         return number;     }      function version() external pure returns (uint256) {         return 1;     }      /**      * Данная функция является обязательной, так как она объявлена в      * абстрактном классе UUPSUpgradeable, но не определена      * Здесь нам нужно лишь указать, какие ограничения мы ставим на      * возможность обновлять наш контракт      * В нашем случае используем onlyOwner      * @param newImplementation адрес нового проксируемого адреса      */     function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} } 

CounterV2.sol:

// SPDX-License-Identifier: MIT pragma solidity ^0.8.19;  import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";  contract CounterV2 is Initializable, OwnableUpgradeable, UUPSUpgradeable {     uint256 internal value;      /// @custom:oz-upgrades-unsafe-allow constructor     constructor() {         _disableInitializers();     }      function initialize() public initializer {         __Ownable_init();         __UUPSUpgradeable_init();     }      /**      * Добавляем новую функцию, чтобы проверять корректность      * работы апргрейда      */     function increment() public {         value++;     }      function getValue() public view returns (uint256) {         return value;     }      /**      * Меняем версию на актуальную      */     function version() public pure returns (uint256) {         return 2;     }      function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} } 

Итак, у нас есть два практически одинаковых контракта, которые отличаются двумя функциями и названием. Оба они подключены к обновляемым прокси-контрактам.
Наша задача:

  • Написать скрипт для деплоя контракта и подключения прокси

  • Написать скрипт для апгрейда контракта на V2

  • Протестировать работу данных скриптов и контрактов в целом

Итак, в папке script создаём наш первый скрипт — DeployCounter.s.sol:

// SPDX-License-Identifier: MIT pragma solidity ^0.8.19;  //Данный контракт отвечает за имплементацию скриптов, он обязательно должен наследоваться // в любом скрипте import {Script} from "forge-std/Script.sol"; import {CounterV1} from "../src/CounterV1.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";  /**  * Контракты по структур практически ничем не отличаются от тестов  */ contract DeployCounter is Script {     /**      * Задача функции run() максимально проста:      * Задеплоить наш контракт      * Она будет запускаться автоматически при вызове      * скрипта, что-то вроде конструктора      */     function run() external returns (address) {         address proxy = deployCounter();         return proxy;     }      /**      * Здесь мы встречаем интересный метод: vm.startBroadcast()      * Данный метод позволяет контракту создать      * настоящую транзакцию он-чейн      * Здесь не будут работать чит-коды и транзакция будет "как настоящая"      * В данном случае мы хотим, чтобы настоящей транзакцией у нас задеплоился      * контракт и прокси к нему      */     function deployCounter() public returns (address) {         vm.startBroadcast();         //Деплоим контракт первой версии         CounterV1 counter = new CounterV1();         //Определяем селектор функции инициализации с нужными аргументами         //В нашем случае аргументов в функции нет, поэтому ()         bytes memory data = abi.encodeCall(CounterV1.initialize, ());         //Деплоим прокси-контракт указывая адрес имплементации и данные         //об инициализирующей функции         ERC1967Proxy proxy = new ERC1967Proxy(address(counter), data);         vm.stopBroadcast();         return address(proxy);     } } 

Ура, скрипт для деплоя готов! Осталось написать скрипт для апгрейда. Однако этот скрипт уже должен быть в курсе, какой контракт ему обновлять. С одной стороны, можно хардкодить адрес задеплоенного прокси в скрипте апгрейда, но с другой стороны, в больших проектах это будет занимать очень много времени и совсем не по-нашему 🙂

Foundry-devops

Когда мы запускаем какой-то скрипт, Foundry сохраняет всю информацию о деплоях в соответствующей папке, разделяя вызовы от каждой сети. Представим инструмент, который парсит эту инфу (находит адрес последнего задеплоенного прокси) и выводит её в скрипт апгрейда, таким образом мы избавимся от хардкода и добьёмся максимальной автоматизации. Именно так поступила команда Cyfrin и сделала очень удобный инструмент для Foundry — foundry-devops. Оценим его работу:

Устанавливаем foundry-devops:

$ forge install Cyfrin/foundry-devops@0.0.11 --no-commit

Используя его, пишем скрипт для обновления прокси(UpgradeCounter.s.sol):

// SPDX-License-Identifier: MIT pragma solidity ^0.8.19;  import {Script, console} from "forge-std/Script.sol"; import {CounterV1} from "../src/CounterV1.sol"; import {CounterV2} from "../src/CounterV2.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import {Helper} from "./Helper.s.sol";  //Импортируем данный контракт, он поможет нам в работе с недавно задеплоенными контрактами import {DevOpsTools} from "lib/foundry-devops/src/DevOpsTools.sol";  /**  * Мы создаём контракт, у которого будет лишь одна функция:  * Обновлять наш проксируемый контракт  */ contract UpgradeCounter is Helper {     function run() external returns (address) {         // Метод get_most_recent_deployment()         // позволяет достать адрес последнего задеплоенного контракта         // по заданным требованиям: Название контракта и id цепи,         // где данный контракт деплоился         address mostRecentlyDeployedProxy = DevOpsTools.get_most_recent_deployment("ERC1967Proxy", block.chainid);          //Мы хотим по-настоящему задеплоить новую версию контракта,         //поэтому startBroadcast()         vm.startBroadcast();         CounterV2 newCounter = new CounterV2();         vm.stopBroadcast();         //Обновляем контракт         address proxy = upgradeCounter(mostRecentlyDeployedProxy, address(newCounter));          return proxy;     }      function upgradeCounter(address proxyAddress, address newCounter) public returns (address) {         vm.startBroadcast();         //payable - это важный параметр, не забываем про него         //(нюанс проки-контрактов)         CounterV1 proxy = CounterV1(payable(proxyAddress));         proxy.upgradeTo(address(newCounter));         vm.stopBroadcast();         return address(proxy);     } } 

Ура! Теперь мы можем запускать тест, но перед этим стоит его протестировать. Тесты мы писать уже умеем, ничего нового здесь мы не найдём за исключением небольших хитростей, связанных с прокси контрактами (CounterTest.t.sol):

// SPDX-License-Identifier: MIT  pragma solidity ^0.8.19;  import {DeployCounter} from "../script/DeployCounter.s.sol"; import {UpgradeCounter} from "../script/UpgradeCounter.s.sol"; import {Test, console} from "forge-std/Test.sol"; import {CounterV1} from "../src/CounterV1.sol"; import {CounterV2} from "../src/CounterV2.sol"; import {Helper} from "../script/Helper.s.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";  /**  * Очень важно тестировать работу не только основных контрактов,  * но и контрактов-скриптов, ведь они тоже будут принимать участие  * в создании реальных транзакций  * В рамках этого теста мы проверим работу скриптов на деплой и апгрейд  * контрактов  */ contract DeployAndUpgradeTest is Test {     DeployCounter public deployCounter;     UpgradeCounter public upgradeCounter;      function setUp() public {         deployCounter = new DeployCounter();         upgradeCounter = new UpgradeCounter();     }      /**      * В этом тесте мы деплоим первую версию нашего контракта      * и проверяем корректность работы через просмотр параметра      * version      */     function testCounterWorks() public {         address proxyAddress = deployCounter.deployCounter();         uint256 expectedValue = 1;         assertEq(expectedValue, CounterV1(proxyAddress).version());     }      /**      * В этом тесте идёт дополнительная проверка засчёт      * попытки вызова функции из другой версии      * В Foundry работа с UUPS максимально тривиальна:      * Для обращения к прокси мы просто оборачиваем его адрес      * в интересующий нас контракт, т.е.      * вся ответсвенность за корректность лежит на нас      */     function testDeploymentIsV1() public {         address proxyAddress = deployCounter.deployCounter();         vm.expectRevert();         CounterV2(proxyAddress).increment();     }      /**      * Аналогично здесь мы вызываем функцию upgradeCounter и после этого      * обращаемся к адресу уже как ко второй версии и убеждаемся, что всё работает      */     function testUpgradeWorks() public {         address proxyAddress = deployCounter.deployCounter();          CounterV2 Counter2 = new CounterV2();          address proxy = upgradeCounter.upgradeCounter(proxyAddress, address(Counter2));          uint256 expectedValue = 2;         assertEq(expectedValue, CounterV2(proxy).version());          CounterV2(proxy).increment();         assertEq(1, CounterV2(proxy).getValue());     } } 

Запускаем тесты и убеждаемся в корректности работы.

Деплой в тестнет

Пора разворачивать всё в реальный блокчейн! Перед этим проделаем немного подготовительных шагов:

  • Создайте файл .env, в котором будут хранится переменные среды:
    (ВНИМАНИЕ: Добавьте этот файл в .gitignore, не допускайте того, чтобы этот файл ушёл куда-то за пределы вашего компьютера)

SEPOLIA_RPC= PRIVATE_KEY= ETHERSCAN_API_KEY=

Отлично, теперь у нас есть приватный ключ, RPC и даже API для автоматизированной верификации!

Пришла пора деплоить наш прокси.

Для начала подключим переменные среды, которые мы создали:

$ source .env

После этого запускаем наш скрипт:

$ forge script script/DeployCounter.s.sol:DeployCounter --rpc-url $SEPOLIA_RPC --private-key $PRIVATE_KEY --broadcast --verify --etherscan-api-key $ETHERSCAN_API_KEY -vvvv
  • forge script script/DeployCounter.s.sol:DeployCounter — здесь мы указываем, какой контракт запускать как скрипт, ведь в одном файле может быть несколько контрактов

  • --rpc-url $SEPOLIA_RPC — указываем Foundry, куда обращаться со всеми он-чейн командами

  • --private-key $PRIVATE_KEY — указываем приватный ключ, которым нужно подписывать все транзакции

  • --verify --etherscan-api-key $ETHERSCAN_API_KEY — указываем Foundry, что все контракты при деплое нужно верифицировать и по какому API ключу

После запуска команды, если всё прошло успешно, вы увидите много различных логов, два задеплоенных контракта и статус их верификации. Теперь вы можете зайти на etherscan тестнета и посмотреть на ваши контракты.

После того, как вы потыкали контракт и удостоверились, что это точно первая версия, давайте её обновим!

$ forge script script/UpgradeCounter.s.sol:UpgradeCounter --rpc-url $SEPOLIA_RPC --private-key $PRIVATE_KEY --broadcast --verify --etherscan-api-key $ETHERSCAN_API_KEY --ffi -vvvv
  • --ffi флаг разрешает Foundry использовать external вызовы к любым внешним контрактам. Придуман для обеспечения безопасности.

    После успешного прохождения скрипта удостоверяемся в корректности обновления!

Работа с ошибками с Foundry-Devops

В моём опыте работы я встречал две ошибки при работе с методом get_most_recent_deployment():

  • Первая ошибка (Error: No contract deployed) была связана с отсутствием пакета jq (описание и решение)

  • Вторая ошибка (‘\r’: command not found) связана с нюансами WSL (решение — сделать doc2unix на исполняемый файл (…lib/foundry-devops/src/get_recent_deployment.sh))

Makefile

Команды для запуска скриптов немного громоздкие и было бы неплохо их сократить. Для этого существуют специальные Makefile’ы. Для любопытных сюда, для остальных — в данном примере мы будем использовать make как инструмент, позволяющий запускать длинные команды командами покороче.
Создаём в папке проекта файл (Makefile):

-include .env  build:; forge build  deploy-sepolia: forge script script/DeployCounter.s.sol:DeployCounter --rpc-url $(SEPOLIA_RPC) --private-key $(PRIVATE_KEY) --broadcast --verify --etherscan-api-key $(ETHERSCAN_API_KEY) -vvvv  upgrade-sepolia: forge script script/UpgradeCounter.s.sol:UpgradeCounter --rpc-url $(SEPOLIA_RPC) --private-key $(PRIVATE_KEY) --broadcast --verify --etherscan-api-key $(ETHERSCAN_API_KEY) --ffi -vvvv 

Мне кажется, можно понять, что здесь происходит:

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

$ make deploy-sepolia

Удобно!

Fork tests (—fork-url)

Иногда нам для тестирования нужны актуальные данные с реальных сетей (мейннета, тестнета), но при этом не хочется тратить газ, деплоить контракты и всё отдельно проверять.
В команде запусков тестов есть интересный ключ — (—fork-url), после которого нужно указать RPC-адрес нужной сети
Если его указать, при тестировании Foundry сможет обращаться к внешним контрактам из другой сети для получения актуальных данных. Давайте разберём небольшой пример и дополним наш тест:

...     /**      * Данная тестовая функция не относится к тестированию прокси-контрактов,      * однако она очень наглядно демонстрирует работу форков      * В данном случае мы будем рабоатать с форком тестнета Sepolia      * Суть работы проста:      * При работе с форком наши обращения будут идти к RPC-ноде нашего тестнета      * При этом реально мы ничего не деплоим, а просто читаем информацию      * Это позволяет тестировать сценарии с продакшна без надобности что-то      * деплоить или вызывать в реальных транзакциях      */      function testForkTotalSupply() public {         //За пример возьмём такой параметр токенов, как decimals         //Это может быть абсолютно любой другой параметр         //Главное, чтобы он хранился в какой-то сети.         //Нам его нужно прочитать         uint256 decimals;         //chainId - id цепи, в которой мы сечас работаем         // 11155111 - это id от Sepolia         // различные chainId можно без проблем найти в интернетах         if (block.chainid == 11155111) {             console.log("Fork testing!");             //Если мы работаем с форком сеполии, то мы обращаемся к реально             //существующему контракту токена ERC20 на тестнете и читаем параметр             //decimals()             decimals = ERC20(0x94a9D9AC8a22534E3FaCa9F4e7F2E2cf85d5E4C8).decimals();             assertEq(decimals, 6);         } else {             console.log("Standart testing!");             //В противном случае нам нужно убедиться, что такого контракта вообще             //не существует - он даже не задеплоен             //Хитрость: у адреса есть поле code, где хранится код контракта             //(в случае, если этот адрес относится к смарт-контракту)             //В нашем случае он должен быть пустым             assertEq(address(0x94a9D9AC8a22534E3FaCa9F4e7F2E2cf85d5E4C8).code.length, 0);         }     } ...   

Теперь обновим наш MakeFile:

... test-sepolia: forge test --fork-url $(SEPOLIA_RPC) -vvv

Запустите тест с форком и без и убедитесь в корректности работы программы!

Заключение

Дорогие друзья! Теперь вы обладаете всеми базовыми навыками работы с Foundry. Вы можете создавать проекты, устанавливать зависимости, настраивать конфигурации, писать тесты для проверки функций, событий и ошибок. Вы также без проблем настраиваете среду разработки для комфортной работы. Вы умеете управлять балансом и менять актуальное время блока. Вы можете подготовить проект к релизу контрактов, написать специальные скрипты и протестировать их работу не только на встроенной ноде, но и на тестнете (например)! С помощью форков вы можете получать актуальную информацию с другой сети для тестирования. А с помощью Makefile вы можете очень удобно компоновать большие команды от forge.

Вы — большие молодцы, так держать!

4 часть >>>


ссылка на оригинал статьи https://habr.com/ru/articles/765266/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *