Предыдущий пост описывал сквозную реализацию: минимальный контракт токена, восстановление оффчейн-состояния и React-фронтенд — от `mint()` до MetaMask. Этот пост продолжает с того места: как провести QA чего-то подобного?
Я (пока) не блокчейн-инженер, но паттерны QA хорошо переносятся между доменами, и заимствование того, что уже работает, — самый быстрый способ обучения для меня.
Контракт выполняет только три операции: `mint`, `transfer` и `burn`, но даже этого достаточно, чтобы практиковать полную цепочку инструментов QA: статический анализ, мутационное тестирование, профилирование газа, формальную верификацию.
Код находится в `egpivo/ethereum-account-state`.
Пирамида QA блокчейна: от статического анализа в основании до формальной верификации наверхуПрежде чем добавлять что-либо новое, проект уже имел:
Все тесты прошли. Покрытие выглядело нормально. Так зачем беспокоиться о большем?
Потому что "все тесты прошли" не означает "все баги пойманы". 100% покрытие строк всё равно может пропустить реальный баг, если ни одно утверждение не проверяет нужное.
Slither (Trail of Bits) выявляет проблемы, невидимые для тестов: реентерабельность, непроверенные возвращаемые значения, несоответствия интерфейсов.
./scripts/run-qa.sh slither
Результат: 1 средняя находка: `erc20-interface`: `transfer()` не возвращает `bool`.
Это ожидаемо. Контракт намеренно не является полным ERC20: это обучающая машина состояний. Но находка не академическая:
Если кто-то позже импортирует этот токен в протокол, ожидающий ERC20, несоответствие интерфейса молча провалится. Slither помечает это сейчас, чтобы решение было осознанным.
./scripts/run-qa.sh coverage Результат покрытия.
Одна непокрытая функция: `BalanceLib.gt()`. Мы вернёмся к этому.
Вывод forge coverage: 24 теста пройдено, таблица покрытия Token.sol./scripts/run-qa.sh gas
Базовые затраты газа для трёх операций:
Газ в терминах операцийПри последующих запусках `forge snapshot — diff` сравнивает с базовой линией. Регрессия газа на 20% в `transfer()` — реальные затраты для каждого пользователя — поймать это до слияния дёшево.
Здесь всё стало интересно. Gambit (Certora) генерирует мутанты: копии `Token.sol` с небольшими намеренными багами (`+=` на `-=`, `>=` на `>`, отрицание условий). Пайплайн запускает полный набор тестов против каждого мутанта. Если мутант выживает (все тесты всё ещё проходят), это конкретный пробел в тестировании.
./scripts/run-qa.sh mutation
Результат: 97,0% оценка мутации — 32 убито, 1 выжил из 33 мутантов.
Лог вывода Gambit показывает каждого мутанта и что изменилось. Несколько примеров:
Сгенерирован мутант #7: BinaryOpMutation — Token.sol:168
totalSupply = totalSupply.add(amountBalance) → totalSupply = totalSupply.sub(amountBalance)
УБИТ тестом test_Mint_Success
Сгенерирован мутант #19: RelationalOpMutation — Token.sol:196
if (!fromBalance.gte(amountBalance)) → if (fromBalance.gte(amountBalance))
УБИТ тестом test_Transfer_Success
Сгенерирован мутант #28: SwapArgumentsMutation — Token.sol:81
return Balance.unwrap(a) > Balance.unwrap(b) → return Balance.unwrap(b) > Balance.unwrap(a)
ВЫЖИЛ ← никакой тест это не поймал
Мутационное тестирование Gambit: 32 убито, 1 выжил, оценка мутации 97,0%
Выживший мутант поменял `a > b` на `b > a` в `BalanceLib.gt()`. Никакой тест не поймал это, потому что `gt()` — мёртвый код. Он никогда не вызывается нигде в `Token.sol`.
Покрытие отметило 91,67% функций, но не смогло объяснить пробел. Мутационное тестирование смогло: `gt()` — мёртвый код, ничто его не вызывает, и никто не заметил бы, если бы он был неправильным.
Мёртвый или незащищённый код в смарт-контрактах имеет реальные прецеденты.
Функция не предназначалась для вызова, но никто не проверял это предположение. Наш `gt()` безвреден по сравнению, но паттерн тот же: код, который существует, но никогда не выполняется, — это код, за которым никто не следит.
Halmos (a16z) рассуждает о всех возможных входах символически. Где фаззинг-тесты берут случайные значения и надеются попасть в граничные случаи, Halmos доказывает свойства исчерпывающе.
./scripts/run-qa.sh halmos
Результат: 9/9 символических тестов прошли — все свойства доказаны для всех входов.
Проверенные свойства:
Проверенные свойстваОдна практическая заметка: Halmos 0.3.3 не поддерживает `vm.expectRevert()`, поэтому я не мог написать тесты отката обычным способом Foundry. Обходной путь — паттерн try/catch — если вызов успешен, когда должен откатиться, `assert(false)` проваливает доказательство:
function check_mint_reverts_on_zero_address(uint256 amount) public {
vm.assume(amount > 0);
try token.mint(address(0), amount) {
assert(false); // не должны сюда попасть
} catch {
// ожидаемый откат - Halmos доказывает, что этот путь всегда выбирается
}
}
Не самое красивое, но работает — Halmos всё ещё доказывает свойство для всех входов. Это то, что узнаёшь, только реально запуская инструмент.
Для контекста, почему важна формальная верификация:
Уязвимость была в коде, доступна для просмотра кем угодно, но никакой инструмент или тест не поймал её до развёртывания. Символические доказыватели вроде Halmos существуют именно для закрытия этого пробела — они не сэмплируют; они исчерпывают пространство входов.
Вывод Halmos: 9 тестов прошло, 0 провалено, результаты символического тестаТестовый файл `contracts/test/Token.halmos.t.sol`.
Архитектура первого поста имеет доменный слой TypeScript, зеркалирующий он-чейн машину состояний. Этот этап тестирует, действительно ли оба совпадают.
Я добавил тесты свойств fast-check для доменного слоя TypeScript, зеркалируя то, что делает фаззер Foundry для Solidity:
npm test - tests/unit/property.test.ts
Результат: 9/9 тестов свойств прошли после исправления реального бага.
Протестированные свойства:
fast-check нашёл реальный баг межслойной согласованности в `Token.ts` `transfer()`. Сокращённый контрпример был сразу ясен:
Свойство провалено после 3 тестов
Сокращено 2 раз(а)
Контрпример: transfer(from=0xaaa…, to=0xaaa…, amount=1n)
→ from == to (самоперевод)
→ verifyInvariant() вернул false
Самоперевод (`from == to`) нарушил инвариант `sum(balances) == totalSupply`. `toBalance` был прочитан до обновления `fromBalance`, поэтому когда `from == to`, устаревшее значение перезаписало вычет:
// До (с багом)
const fromBalance = this.getBalance(from);
const toBalance = this.getBalance(to); // ← устарел, когда from == to
this.accounts.set(from.getValue(), fromBalance.subtract(amount));
this.accounts.set(to.getValue(), toBalance.add(amount)); // ← перезаписывает вычитание
Исправление: читать `toBalance` после записи `fromBalance`, соответствуя семантике хранилища Solidity:
// После (исправлено)
const fromBalance = this.getBalance(from);
this.accounts.set(from.getValue(), fromBalance.subtract(amount));
const toBalance = this.getBalance(to); // ← теперь читает обновлённое значение
this.accounts.set(to.getValue(), toBalance.add(amount));
Контракт Solidity не был затронут: он перечитывает хранилище после каждой записи. Но зеркало TypeScript имело тонкую зависимость от порядка, которую не покрыл ни один существующий модульный тест.
Межслойные несоответствия в большем масштабе были катастрофическими.
Наш баг самоперевода не привёл бы к потере чьих-либо денег, но режим отказа структурно тот же: два слоя, которые должны совпадать, не совпадают.
Запуск инструментов QA на существующем проекте — это никогда не просто "установить и запустить". Несколько вещей сломались, прежде чем заработали:
Всё запускается через два скрипта:
./scripts/run-qa.sh slither gas # только статический анализ + газ
./scripts/run-qa.sh mutation # только мутационное тестирование
./scripts/run-qa.sh all # всё
Не каждая проверка быстрая. Slither и покрытие запускаются при каждом коммите. Мутационное тестирование и Halmos медленнее — лучше подходят для еженедельных или предрелизных запусков.
Пять слоёв QA, каждый ловит свой класс проблем.
Объяснение слоёвGambit и fast-check дали наиболее действенные результаты в этом раунде.
Проверки QA теперь встроены в GitHub Actions как шестиэтапный пайплайн:
CI-пайплайн: Build & Lint разветвляется на этапы Test, Coverage, Gas, Slither и AuditПайплайн GitHub Actions: Build & Lint контролирует все последующие этапы.
Объяснение этаповEthereum Account State: QA Pipeline for a Minimal Token был первоначально опубликован в Coinmonks на Medium, где люди продолжают обсуждение, выделяя и отвечая на эту историю.


