Deep EVM #19: Property-based тестирование смарт-контрактов — фаззинг в Foundry
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-контрактов:
- Сохранение инвариантов — totalSupply не меняется при transfer
- Монотонность — баланс стейкера растёт со временем
- Коммутативность — порядок операций не влияет на результат
- Roundtrip — deposit + withdraw возвращает исходное состояние
- Невозможность — нельзя вывести больше, чем внесли
Реальный пример: фаззинг 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 ищет неожиданное. Для смарт-контрактов, управляющих реальными деньгами, это различие может стоить миллионы долларов.