Панель мониторинга QA для отслеживания состояния смарт-контракта  Предыдущий пост описывал сквозную реализацию: минимальный токен-контракт, восстановление состояния вне цепочкиПанель мониторинга QA для отслеживания состояния смарт-контракта  Предыдущий пост описывал сквозную реализацию: минимальный токен-контракт, восстановление состояния вне цепочки

Состояние аккаунта Ethereum: конвейер QA для минимального токена

2026/04/09 13:48
7м. чтение
Для обратной связи или замечаний по поводу данного контента, свяжитесь с нами по адресу crypto.news@mexc.com
Панель мониторинга QA состояния смарт-контракта

Предыдущий пост описывал сквозную реализацию: минимальный контракт токена, восстановление оффчейн-состояния и React-фронтенд — от `mint()` до MetaMask. Этот пост продолжает с того места: как провести QA чего-то подобного?

Я (пока) не блокчейн-инженер, но паттерны QA хорошо переносятся между доменами, и заимствование того, что уже работает, — самый быстрый способ обучения для меня.

Контракт выполняет только три операции: `mint`, `transfer` и `burn`, но даже этого достаточно, чтобы практиковать полную цепочку инструментов QA: статический анализ, мутационное тестирование, профилирование газа, формальную верификацию.

Код находится в `egpivo/ethereum-account-state`.

Пирамида QA блокчейна: от статического анализа в основании до формальной верификации наверху

С чего мы начали

Прежде чем добавлять что-либо новое, проект уже имел:

  • 21 модульный тест Foundry, охватывающий каждый переход состояния (успех, откат при неверном вводе, эмиссия событий)
  • 3 инвариантных теста через `TokenHandler`, запускающий случайные последовательности `mint`/`transfer`/`burn` для 10 участников (по 128 тыс. вызовов каждый)
  • Фаззинг-тесты, проверяющие `sum(balances) == totalSupply` для случайных сумм
  • Доменные тесты TypeScript (Vitest), зеркалирующие он-чейн машину состояний
  • CI: компиляция, тестирование, линтинг (Prettier + solhint)

Все тесты прошли. Покрытие выглядело нормально. Так зачем беспокоиться о большем?

Потому что "все тесты прошли" не означает "все баги пойманы". 100% покрытие строк всё равно может пропустить реальный баг, если ни одно утверждение не проверяет нужное.

Этап 1: Статический анализ смарт-контракта и покрытие

Slither

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()` — реальные затраты для каждого пользователя — поймать это до слияния дёшево.

Этап 2: Мутационное тестирование и формальная верификация

Мутационное тестирование (Gambit)

Здесь всё стало интересно. 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)

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`.

Этап 3: Межслойное тестирование свойств

Архитектура первого поста имеет доменный слой TypeScript, зеркалирующий он-чейн машину состояний. Этот этап тестирует, действительно ли оба совпадают.

Тестирование на основе свойств с fast-check

Я добавил тесты свойств fast-check для доменного слоя TypeScript, зеркалируя то, что делает фаззер Foundry для Solidity:

npm test - tests/unit/property.test.ts

Результат: 9/9 тестов свойств прошли после исправления реального бага.

Протестированные свойства:

  • `Balance`: коммутативность, ассоциативность, идентичность, обратность, согласованность сравнения
  • `Token`: инвариант `sum(balances) == totalSupply` при случайных последовательностях операций (200 запусков, 50 операций каждый)
  • `Token`: `totalSupply` неотрицателен после случайных последовательностей
  • `mint` всегда успешен для валидных входов
  • `transfer` сохраняет `totalSupply`

Баг, найденный fast-check

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 на существующем проекте — это никогда не просто "установить и запустить". Несколько вещей сломались, прежде чем заработали:

  • 0% покрытие, потому что `foundry.toml` не имел пути тестов: Первый запуск `forge coverage` вернул 0% по всем показателям. Оказалось, `foundry.toml` не указывал `test = "contracts/test"` или `script = "contracts/script"`, поэтому Forge не обнаруживал никаких тестов. Команда покрытия успешно завершилась молча — просто нечего было покрывать. Это был самый вводящий в заблуждение провал: зелёный запуск без полезного вывода.
  • Импорт `InvariantTest` исчез в forge-std v1.14.0: `Invariant.t.sol` импортировал `InvariantTest` из `forge-std`, который был удалён в недавнем релизе. Компиляция провалилась с непрозрачной ошибкой "символ не найден". Исправление — убрать импорт — одного `Test` достаточно для инвариантного тестирования Foundry теперь.
  • `uint256(token.totalSupply())` против `Balance.unwrap()`: Тесты использовали явное приведение для извлечения базового `uint256` из пользовательского типа `Balance`. Оно компилировалось, но это неправильная идиома — `Balance.unwrap(token.totalSupply())` — то, для чего разработана система UDVT. Применено к `Token.t.sol`, `Invariant.t.sol` и `DeploySepolia.s.sol`.

Дизайн пайплайна

Всё запускается через два скрипта:

  • scripts/setup-qa-tools.sh`: устанавливает Slither, Halmos, Gambit (идемпотентно)
  • `scripts/run-qa.sh`: запускает проверки, сохраняет результаты с временными метками в `qa-results/`

./scripts/run-qa.sh slither gas # только статический анализ + газ
./scripts/run-qa.sh mutation # только мутационное тестирование
./scripts/run-qa.sh all # всё

Не каждая проверка быстрая. Slither и покрытие запускаются при каждом коммите. Мутационное тестирование и Halmos медленнее — лучше подходят для еженедельных или предрелизных запусков.

Итоги

Цепочка инструментов QA блокчейна: что ловит каждый слой — от статического анализа до межслойного тестирования свойств

Пять слоёв QA, каждый ловит свой класс проблем.

Объяснение слоёв

Gambit и fast-check дали наиболее действенные результаты в этом раунде.

CI-пайплайн

Проверки QA теперь встроены в GitHub Actions как шестиэтапный пайплайн:

CI-пайплайн: Build & Lint разветвляется на этапы Test, Coverage, Gas, Slither и Audit

Пайплайн GitHub Actions: Build & Lint контролирует все последующие этапы.

Объяснение этапов

Ссылки

  • Исходники Ethereum Account State: [github.com/egpivo/ethereum-account-state](https://github.com/egpivo/ethereum-account-state)
  • Предыдущий пост: Ethereum Account State
  • Slither: github.com/crytic/slither
  • Gambit: github.com/Certora/gambit
  • Halmos: github.com/a16z/halmos
  • fast-check: github.com/dubzzz/fast-check
  • Foundry: getfoundry.sh

Примечания

  • Этот пост адаптирован из моего оригинального блог-поста.

Ethereum Account State: QA Pipeline for a Minimal Token был первоначально опубликован в Coinmonks на Medium, где люди продолжают обсуждение, выделяя и отвечая на эту историю.

Отказ от ответственности: Статьи, размещенные на этом веб-сайте, взяты из общедоступных источников и предоставляются исключительно в информационных целях. Они не обязательно отражают точку зрения MEXC. Все права принадлежат первоисточникам. Если вы считаете, что какой-либо контент нарушает права третьих лиц, пожалуйста, обратитесь по адресу crypto.news@mexc.com для его удаления. MEXC не дает никаких гарантий в отношении точности, полноты или своевременности контента и не несет ответственности за любые действия, предпринятые на основе предоставленной информации. Контент не является финансовой, юридической или иной профессиональной консультацией и не должен рассматриваться как рекомендация или одобрение со стороны MEXC.

30 000$ в PRL + 15 000 USDT

30 000$ в PRL + 15 000 USDT30 000$ в PRL + 15 000 USDT

Вносите депозит и торгуйте PRL для роста наград!