Перейти к основному содержимому
БлокчейнMar 28, 2026

Deep EVM #19: Property-based тестирование смарт-контрактов — фаззинг в Foundry

OS
Open Soft Team

Engineering Team

Почему юнит-тестов недостаточно

Юнит-тесты проверяют конкретные сценарии, которые вы предусмотрели. Но уязвимости смарт-контрактов чаще всего скрываются в сценариях, которые никто не предвидел. Property-based тестирование (PBT) переворачивает подход: вместо проверки конкретных входных данных вы формулируете свойства, которые должны выполняться для любых входных данных, и позволяете фаззеру искать контрпримеры.

В экосистеме Ethereum PBT уже предотвратил множество взломов. Фаззинг обнаружил уязвимости в Uniswap v3, Compound и десятках других протоколов на стадии аудита.

Фаззинг в Foundry: основы

Foundry поддерживает фаззинг «из коробки». Любой тест с параметрами автоматически становится фазз-тестом:

function testFuzz_transfer(address to, uint256 amount) public {
    vm.assume(to != address(0));
    vm.assume(amount <= token.balanceOf(alice));

    uint256 totalBefore = token.balanceOf(alice) + token.balanceOf(to);

    vm.prank(alice);
    token.transfer(to, amount);

    uint256 totalAfter = token.balanceOf(alice) + token.balanceOf(to);
    assertEq(totalBefore, totalAfter, "Total supply changed!");
}

Foundry генерирует случайные значения to и amount, прогоняя тест тысячи раз. vm.assume фильтрует невалидные комбинации.

Настройка фаззера

В foundry.toml:

[profile.default.fuzz]
runs = 10000          # Количество итераций
max_test_rejects = 100000  # Макс. отклонённых vm.assume
seed = 0x42           # Фиксированный seed для воспроизводимости

[profile.ci.fuzz]
runs = 50000          # Больше итераций в CI

Увеличение runs линейно повышает вероятность обнаружения бага. Для критичных контрактов рекомендуется 50 000+ итераций.

Инвариантное тестирование

Инвариантные тесты — это более мощная форма фаззинга. Вместо одного вызова функции фаззер выполняет последовательность случайных вызовов и после каждого проверяет инварианты:

contract InvariantTest is Test {
    ERC20Handler handler;

    function setUp() public {
        handler = new ERC20Handler(token);
        targetContract(address(handler));
    }

    function invariant_totalSupplyConstant() public view {
        assertEq(
            token.totalSupply(),
            INITIAL_SUPPLY,
            "Total supply must never change"
        );
    }

    function invariant_balanceSumEqualsTotalSupply() public view {
        uint256 sum = 0;
        for (uint i = 0; i < handler.actorsCount(); i++) {
            sum += token.balanceOf(handler.actors(i));
        }
        assertEq(sum, token.totalSupply());
    }
}

Handler-паттерн

Handler — это контракт-обёртка, направляющий фаззер на осмысленные действия:

contract ERC20Handler is Test {
    ERC20 token;
    address[] public actors;

    constructor(ERC20 _token) {
        token = _token;
        actors.push(makeAddr("actor1"));
        actors.push(makeAddr("actor2"));
        actors.push(makeAddr("actor3"));
    }

    function transfer(
        uint256 actorSeed, uint256 toSeed, uint256 amount
    ) external {
        address from = actors[actorSeed % actors.length];
        address to = actors[toSeed % actors.length];
        amount = bound(amount, 0, token.balanceOf(from));

        vm.prank(from);
        token.transfer(to, amount);
    }

    function actorsCount() external view returns (uint256) {
        return actors.length;
    }
}

Функция bound из forge-std ограничивает значение диапазоном, избегая vm.assume и связанного с ним отклонения тестов.

Стратегии формулировки свойств

Ключевые категории свойств для DeFi-контрактов:

  1. Сохранение инвариантов — totalSupply не меняется при transfer
  2. Монотонность — баланс стейкера растёт со временем
  3. Коммутативность — порядок операций не влияет на результат
  4. Roundtrip — deposit + withdraw возвращает исходное состояние
  5. Невозможность — нельзя вывести больше, чем внесли

Реальный пример: фаззинг AMM

Рассмотрим инвариантный тест для AMM (Automated Market Maker):

function invariant_constantProduct() public view {
    uint256 reserve0 = pool.reserve0();
    uint256 reserve1 = pool.reserve1();
    uint256 k = reserve0 * reserve1;
    assertGe(k, initialK, "Product must never decrease");
}

function invariant_noFreeTokens() public view {
    uint256 poolBalance0 = token0.balanceOf(address(pool));
    assertGe(poolBalance0, pool.reserve0());
}

Медиан-фаззинг и guided fuzzing

Foundry’s фаззер использует корпус-ориентированный подход. Он сохраняет входные данные, которые увеличивают покрытие кода, и мутирует их. Для улучшения эффективности:

  • Используйте словари (fuzz.dictionary_weight) для подстановки значимых констант
  • Настройте fuzz.include_storage для использования текущего состояния хранилища
  • Включите fuzz.include_push_bytes для извлечения констант из байткода

Интеграция с CI/CD

test-fuzz:
  script:
    - forge test --match-test "testFuzz" --fuzz-runs 50000
    - forge test --match-test "invariant" --fuzz-runs 1000
  timeout: 30m

Важно: инвариантные тесты с высокой глубиной могут занимать минуты. Установите разумные таймауты и не блокируйте быстрый фидбек-цикл.

Заключение

Property-based тестирование — это не замена юнит-тестам, а дополнение. Юнит-тесты подтверждают ожидаемое поведение, PBT ищет неожиданное. Для смарт-контрактов, управляющих реальными деньгами, это различие может стоить миллионы долларов.