За прошедшие две части мы освоили почти всю базу, которая нужна нам для тестирования и отладки контрактов в 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.
Вы - большие молодцы, так держать!
pnaydanovgoo
Я еще стараюсь деплоить через remix IDE, чтобы не светить приватник кошелька в env. Там можно и сам проект локально подцепить и metamask.
andreysWeb Автор
На самом деле я с вами согласен, так намного безопаснее. Можно конечно шифровать приватник с паролем, но с ремиксом это ни к чему :)