К основному содержимому

Введите слово из вопроса или названия статьи.

3 · Под капотом

Foundry для начинающих: компиляция, тесты, фаззинг и локальная сеть

Практическое введение в Foundry: установка, структура проекта, unit-тесты, читкоды vm, фаззинг и инварианты, forge, cast и anvil. С рабочим примером.

6 мин чтенияОбновлено:
В этой статье

Foundry - набор инструментов для разработки смарт-контрактов, где тесты пишутся на самом Solidity. Он быстрый и удобный, поэтому стал стандартом во многих командах. Эта статья - практическое введение: от установки до фаззинг-теста. Теория тестирования - в статье тестирование смарт-контрактов, язык - в обзоре Solidity.

Что входит в Foundry

ИнструментДля чего
forgeсборка, тесты, развёртывание, отчёты о газе
castкоманды для общения с сетью из терминала
anvilлокальный блокчейн для разработки
chiselинтерактивная консоль Solidity

Установка и проект

bash
curl -L https://foundry.paradigm.xyz | bash
foundryup
forge init escrow-demo
cd escrow-demo

Структура проекта:

text
src/        исходный код контрактов
test/       тесты (файлы *.t.sol)
script/     скрипты развёртывания
lib/        зависимости (forge-std и др.)
foundry.toml  настройки: версия компилятора, оптимизатор

Пример настроек, близких к тем, что использует наш контракт:

toml
[profile.default]
solc = "0.8.24"
evm_version = "paris"
optimizer = true
optimizer_runs = 200
bytecode_hash = "none"

Эти параметры делают сборку воспроизводимой - это важно для проверки контракта.

Контракт для примера

Возьмём упрощённую версию расчёта долей из эскроу (разбор математики):

solidity
// src/Splitter.sol
library Splitter {
    uint256 internal constant BPS = 10_000;
    uint256 internal constant PLATFORM_FEE_BPS = 100; // 1%

    function split(uint256 amount, uint256 arbiterFeeBps, uint256 buyerShareBps)
        internal
        pure
        returns (uint256 buyer, uint256 seller, uint256 arbiter, uint256 platform)
    {
        platform = (amount * PLATFORM_FEE_BPS) / BPS;
        uint256 net = amount - platform;
        arbiter = (net * arbiterFeeBps) / BPS;
        uint256 pot = net - arbiter;
        buyer = (pot * buyerShareBps) / BPS;
        seller = pot - buyer;
    }
}

Первый тест

solidity
// test/Splitter.t.sol
import {Test} from "forge-std/Test.sol";
import {Splitter} from "../src/Splitter.sol";

contract SplitterTest is Test {
    function test_completedDeal() public {
        // 1000 USDC (6 знаков), гарант 2%, всё продавцу
        (uint256 b, uint256 s, uint256 a, uint256 p) = Splitter.split(1_000_000_000, 200, 0);
        assertEq(p, 10_000_000);     // 10 USDC платформе
        assertEq(a, 19_800_000);     // 19,80 гаранту
        assertEq(b, 0);
        assertEq(s, 970_200_000);    // 970,20 продавцу
    }
}

Запуск:

bash
forge test -vvv

Флаги -v показывают подробности: чем больше v, тем больше деталей (логи, трассировки).

Фаззинг: тысячи случайных входов

Вместо проверки нескольких чисел сформулируем свойство: сумма частей всегда равна целому.

solidity
function testFuzz_partsEqualWhole(uint96 amount, uint16 arbBps, uint16 buyerBps) public {
    arbBps = uint16(bound(arbBps, 0, 3000));       // ограничим разумными значениями
    buyerBps = uint16(bound(buyerBps, 0, 10_000));
    (uint256 b, uint256 s, uint256 a, uint256 p) = Splitter.split(amount, arbBps, buyerBps);
    assertEq(b + s + a + p, amount);
}

Foundry подставит сотни случайных значений (по умолчанию 256) и при ошибке покажет минимальный контрпример. bound аккуратно приводит значение в нужный диапазон, а vm.assume(cond) отбрасывает неподходящие.

Такие тесты ловят то, о чём вы не подумали: сумма 0, максимум uint96, граничные доли.

Читкоды vm

Тестовый контракт наследует Test, а значит получает объект vm с «читами», управляющими окружением.

ЧитЧто делает
vm.prank(addr)следующий вызов выполнится от addr
vm.startPrank(addr) / vm.stopPrank()то же для серии вызовов
vm.expectRevert(селектор)следующий вызов обязан откатиться с этой ошибкой
vm.warp(время)устанавливает block.timestamp
vm.roll(блок)устанавливает номер блока
deal(addr, сумма)выдаёт ETH на адрес (есть вариант для токенов)
vm.expectEmit(...)проверяет событие
makeAddr("alice")создаёт тестовый адрес

Примеры проверок для эскроу

solidity
function test_strangerCannotConfirm() public {
    uint256 id = _createActiveDeal();
    vm.prank(makeAddr("stranger"));
    vm.expectRevert(Escrow.NotAuthorized.selector);
    escrow.confirmDelivery(id);
}

function test_arbiterTimeoutSplitsEvenly() public {
    uint256 id = _createDisputedDeal();
    vm.warp(block.timestamp + 30 days + 1);
    vm.prank(buyer);
    escrow.claimArbiterTimeout(id);
    // проверяем: гарант 0, покупатель и продавец поровну
}

Так проверяются правила из контроля доступа и жизненного цикла сделки: кто и когда может действовать, и что происходит по таймауту.

Инварианты

Инвариантный тест проверяет свойство, которое должно быть верно после любой последовательности вызовов. Foundry случайно вызывает функции контракта и после каждого шага проверяет условие:

solidity
function invariant_solvency() public view {
    assertGe(token.balanceOf(address(escrow)), escrow.reserved(address(token)));
}

Это тот самый инвариант платёжеспособности: баланс контракта не меньше обязательств (пояснение).

Газ и покрытие

bash
forge test --gas-report     # таблица расхода газа по функциям
forge snapshot              # сохраняет эталонные значения газа
forge coverage              # какие строки покрыты тестами

Результаты помогают оптимизировать газ и видеть непроверенные ветки кода.

Локальная сеть и cast

bash
anvil                         # блокчейн на localhost:8545, chain id 31337
cast sig "transfer(address,uint256)"   # 0xa9059cbb - селектор
cast keccak "hello"                    # хеш keccak256
cast call <адрес> "balanceOf(address)(uint256)" <кошелёк> --rpc-url http://127.0.0.1:8545

anvil создаёт тестовые аккаунты с ETH, на них удобно отрабатывать весь поток сделки локально, не тратя реальных денег. Селекторы и хеши - тема статей про ABI и хеш-функции.

Форк реальной сети

Тесты можно запускать на копии реальной сети (только чтение состояния, ничего не отправляется):

bash
forge test --fork-url $RPC_URL

Так проверяют взаимодействие с настоящими токенами вроде USDC - например, как они ведут себя при блокировке адреса.

Советы

  1. Тестируйте не только «счастливый путь», но и отказы, и граничные значения.
  2. Пишите по фаззинг-тесту на каждую арифметическую функцию.
  3. Один тест - одна идея, понятные имена: test_..._reverts_when_....
  4. Запускайте тесты в CI при каждом изменении.
  5. Тесты не заменяют аудит, а дополняют его.

Что почитать дальше

Узнайте, как проверяют контракты независимые эксперты: аудиты и баг-баунти. Или как сократить расход газа: оптимизация газа.

Упоминается в статьях

Готовы попробовать?

Создайте первую сделку за пару минут. Деньги блокируются в смарт-контракте и выплачиваются по правилам, которые видны заранее.