За прошедшие две части мы освоили почти всю базу, которая нужна нам для тестирования и отладки контрактов в 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 часть >>>

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


  1. pnaydanovgoo
    09.10.2023 08:41

    Я еще стараюсь деплоить через remix IDE, чтобы не светить приватник кошелька в env. Там можно и сам проект локально подцепить и metamask.


    1. andreysWeb Автор
      09.10.2023 08:41

      На самом деле я с вами согласен, так намного безопаснее. Можно конечно шифровать приватник с паролем, но с ремиксом это ни к чему :)