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

Исходный код к данной части курса
Ссылка на мой аккаунт в Хабр Карьере (буду рад знакомству)
Тестирование прокси-контрактов
На русском ютубе есть два замечательных ролика про прокси-контракты, если вдруг вы не знаете, что это (тык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=
Через Метамаск (или любой другой кошелёк) переключаемся на тестовую сеть Sepolia и достаём тестовые эфиры
Достаём приватный ключ от аккаунта, на котором тестовый эфир и добавляем его в .env
Достаём адрес RPC-ноды от нашего тестнета
Рекомендуемый способ: Регистрируемся в alchemy (c ВПН), попадаем на главный дэшборд и заходим на страницу с нашими приложениями, Нажимаем Create new app, выбираем нужную нам сеть (Ethereum, Sepolia) и заполняем остальные поля. После создания находим кнопку API Key и копируем в .env файл пункт HTTPSETHERSCAN_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.
Вы - большие молодцы, так держать!

