Проходя данный цикл статей, мы овладели инструментарием и возможностями Foundry на уровне, достаточном для поддержания и создания проектов простого и среднего уровней. Предлагаю сегодня изучить очень важные и полезные возможности Foundry углублённого уровня, а именно - команды cast для ручной отправки RPC-запросов и fuzz-тестирование! Сегодня будет много теории, часть из которой была заимствована у основоположника учебных материалов по Solidity - Патрика Коллинза.
Исходный код к данной части курса
Ссылка на мой аккаунт в Хабр Карьере (буду рад знакомству)
Cast
Данный инструмент позволяет нам в ручном режиме отлаживать определённые команды и вызовы в блокчейн.
call
Метод call
отправляет запрос в блокчейн без отправки транзакции, то есть для чтения данных.
На примере кода из прошлой части попробуем вызвать функцию через прокси-контракт.
Для этого в папке broadcast/UpgradeCounter.s.sol/11155111 (может различаться в зависимости от вашего тестнета и названия скрипта) находим последний файл .json
В нём будет храниться информация о всех вызовах, где мы можем найти адрес нашего прокси. После этого вызовем у него функцию getValue()
(перед запуском не забываем сделать source .env
)
$ cast call 0x96e6F26eA9FAC191a6E126C6220358bf1829BD52 ()(uint256)" --rpc-url $SEPOLIA_RPC
0x96e6F26eA9FAC191a6E126C6220358bf1829BD52
- адрес, на которой мы отправляем запросgetValue()(uint256)
- это так называемый селектор функции, которой мы хотим вызвать - комбинация имени функции и принимаемых (возвращаемых) типов её параметров, объединённых в строку без пробелов--rpc-url $SEPOLIA_RPC
- указываем адрес, на который нужно посылать запрос
send
Метод send
позволяет подписывать и отправлять транзакции в блокчейн. Теперь вызовем функцию increment()
через наш прокси-контракт:
$ cast send 0x96e6F26eA9FAC191a6E126C6220358bf1829BD52 "increment()" --private-key $PRIVATE_KEY --rpc-url $SEPOLIA_RPC
После отработки транзакции мы можем ещё раз выполнить getValue()
и убедиться в корректности работы.
Скорее всего, помимо двух этих команд в cast вы больше не с чем не столкнётесь, однако этот инструмент имеет много разных возможностей, подробнее - сюда
Fuzz-тестирование
Фаззинг — это техника тестирования программного обеспечения, часто автоматическая или полуавтоматическая, заключающаяся в передаче приложению на вход неправильных, неожиданных или случайных данных.
Фаззинг приходит на помощь в тот момент, когда нужно удостовериться в детерминированности объекта тестирования.
Stateless fuzz-тестирование - это вид фаззинга, при котором начальное состояние каждого запуска одинаково. В документации Foundry - fuzz-testing
Stateful fuzz-тестирование - это вид фаззинга, при котором конечное состояние предыдущего запуска является начальным состоянием следующего запуска. В документации Foundry - invariant-testing (Инвариантный тест = stateful тест)
Представим, что шарик является объектом нашего тестирования. При stateless-тестировании для каждого нового теста мы будем брать новый шарик, в то время как при stateful-тестировании на протяжении всех тестов мы используем один и тот же.
Рассмотрим пример контракта, где можно использовать различные виды фаззинга (Counter.sol):
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
contract Counter {
uint256 testVar;
/**
* Данная функция возвращает различные значения в
* зависимости от значений аргумента
* По названию можно чётко понять, что по бизнес-логике
* наша функция не должна возвращать ноль
*
* Здесь довольно очевидно (в целях обучения)
* спрятан баг, который нарушает бизнес-логику
* этой функции
*
* При помощи state-less fuzz-тестирования мы скорее всего
* очень легко найдём этот баг
*
* @param x Обрабатываемое число
*/
function neverReturnZero(uint256 x) public pure returns (uint256) {
if (x == 1) {
return 123 - x;
} else if (x % 10 == 0 && x != 0) {
//Здесь всегда будет возвращаться ноль
return (x % (78123 - 78113));
} else if (x % 123 == 1) {
return 321;
} else {
return 0;
}
}
/**
* Данную функцию уже не получится протестировать с помощью
* state-less fuzz-тестирования
*
* В данном случае баг вызовется только в сценарии,
* когда состояние предыдущей итерации будет влиять на
* состояние следующей
*
* Здесь на помощь может прийти только stateful
* fuzz-тестирование
*
* @param x Обрабатываемое число
*/
function neverReturnZero2(uint256 x) public returns (uint256) {
if (x < 10) {
revert();
}
if (x % 5 == 0) {
testVar += 100;
}
if (testVar == 200) {
//Может вызваться, если до этого два раза
//вызвать функцию с аргументом, кратным 5
return 0;
} else {
return 1;
}
}
}
Теперь напишем state-less тест для функции neverReturnZero()
(CounterStateLessTest.t.sol):
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Test} from "forge-std/Test.sol";
import {Counter} from "../src/Counter.sol";
contract CounterTest is Test {
Counter public counter;
function setUp() public {
counter = new Counter();
}
/**
* В параметры функции тестирования добавляем аргумент,
* который должен генерироваться случайным образом
*
* В итоге у нас получается простой пример фаззинга
*/
function testNeverReturnZero(uint256 x) public view {
assert(counter.neverReturnZero(x) != 0);
}
}
Запускаем тест и смотрим результат:
Failing tests:
Encountered 1 failing test in test/CounterStateLessTest.t.sol:CounterTest
[FAIL. Reason: Assertion violated CouFnterexample:
calldata=...,
args=[263566295740049255046696346078832174726508000 [2.635e44]]]
testNeverReturnZero(uint256)
(runs: 132, μ: 5748, ~: 5749)
Мы видим, как наш тест упал, но описание ошибки немного отличается от других тестов:
args - это значения аргументов, при которых упал тест;
runs - количество прогонов, которые успешно прошли тест;
μ и ~ - средние значения газа, потраченного на фаззинг.
Мы можем исправить баг в контракте и посмотреть, что тест проходит успешно и по умолчанию запускает 256 итераций. Данное значение можно поменять, к примеру на 512, добавив в foundry.toml
:
[fuzz]
runs = 512
Теперь напишем тест для инвариантного тестирования функции neverReturnZero2()
(CounterStateFulTest.t.sol):
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Test} from "forge-std/Test.sol";
//Обязательный контракт для написания инвариантных тестов
//Наследуем его от нашего тест-контракта
import {StdInvariant} from "forge-std/StdInvariant.sol";
import {Counter} from "../src/Counter.sol";
contract CounterTest is StdInvariant, Test {
Counter public counter;
function setUp() public {
counter = new Counter();
//Команда targetContract() указывает foundry на то,
//какой контракт должен подвергаться изменению состояния
//для инвариантного тестирования
targetContract(address(counter));
}
/**
* Для инициализации инвариантного теста
* добавляем соответствующий префикс в название
* функции
*/
function invariant_testNeverReturnZero2() public {
assert(counter.neverReturnZero2(10) != 0);
}
}
Запустим тест с расширенным логгированием (-vvv
) и разберёмся, что происходит:
invariant_testNeverReturnZero2() (runs: 1, calls: 3, reverts: 1)
Traces:
[2489] Counter::neverReturnZero2(62)
└─ ← 1
[224] Counter::neverReturnZero2(0)
└─ ← "EvmError: Revert"
[22689] Counter::neverReturnZero2(115....35 [1.157e77])
└─ ← 1
[10810] CounterTest::invariant_testNeverReturnZero2()
├─ [5588] Counter::neverReturnZero2(10)
│ └─ ← 0
└─ ← "Assertion violated"
Из теста видно, что ошибка вызвана сценарием, при котором вызывается функция neverReturnZero2()
c аргументом, кратным 5.
revert()
, вызванный функцией neverReturnZero2(0)
Foundry при инвариантном тестировании игнорируется (по-умолчанию значением fail_on_revert
). Также помимо регулируемого параметра runs, в инвариантном тестировании есть параметр depth
- это количество вызовов различных функций в одном запуске (run
). Все эти параметры можно изменить, настроив конфигурацию проекта:
[invariant]
runs = 128
depth = 128
fail_on_revert = true
Нюанс работы с инвариантным тестированием
Я не случайно в функции neverReturnZero2() добавил такое условие:
if (x < 10) {
revert();
}
Так как все вызовы и аргументы при stateful-тестировании случайны, часть тестов будет просто заканчиваться ошибками, связанными с ограничением бизнес-логики функции. Вместо этого условия может стоять модификатор onlyOwner, проверка на msg.value и т.д.
Чтобы инвариантный тест сделать более полезным и работоспособным, можно написать отдельный тестовый контракт (Handler.sol), который будет иметь все те же функции, что и Counter и при их вызове будет настраивать входные данные таким образом, чтобы и сохранялась инвариантность (случайный вызов со случайным значением), и соблюдалась бизнес-логика.
Можете попрактиковаться и сделать к данному проекту Handler.sol самостоятельно, а в исходном коде можете посмотреть, как это реализовал я.
Заключение
Фаззинг - это очень интересный метод тестирования, который используется далеко не только в смарт-контрактах. Его использование в ваших проектах заметно повысит качество защиты от случайных сценариев.
Скорее всего, на ближайшее время это будет последняя часть данного курса. Был рад написать данный цикл статей очень надеюсь на вашу обратную связь, по активности буду решать, делать ли продолжение. Всем большое спасибо!