Інформаційна панель QA для моніторингу стану смартконтракту  Попередній пост продемонстрував наскрізну реалізацію: мінімальний токен-контракт, позаланцюгове відновлення стануІнформаційна панель QA для моніторингу стану смартконтракту  Попередній пост продемонстрував наскрізну реалізацію: мінімальний токен-контракт, позаланцюгове відновлення стану

Стан облікового запису Ethereum: конвеєр QA для мінімального токена

2026/04/09 13:48
7 хв читання
Якщо у вас є відгуки або зауваження щодо цього контенту, будь ласка, зв’яжіться з нами за адресою [email protected]
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 показує кожного мутанта та що він змінив. Кілька прикладів:

Generated mutant #7: BinaryOpMutation — Token.sol:168
totalSupply = totalSupply.add(amountBalance) → totalSupply = totalSupply.sub(amountBalance)
KILLED by test_Mint_Success
Generated mutant #19: RelationalOpMutation — Token.sol:196
if (!fromBalance.gte(amountBalance)) → if (fromBalance.gte(amountBalance))
KILLED by test_Transfer_Success
Generated mutant #28: SwapArgumentsMutation — Token.sol:81
return Balance.unwrap(a) > Balance.unwrap(b) → return Balance.unwrap(b) > Balance.unwrap(a)
SURVIVED ← жоден тест це не виявив Мутаційне тестування 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()`. Скорочений контрприклад був одразу зрозумілий:

Property failed after 3 tests
Shrunk 2 time(s)
Counterexample: 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. Всі права залишаються за авторами оригінальних статей. Якщо ви вважаєте, що будь-який контент порушує права третіх осіб, будь ласка, зверніться за адресою [email protected] для його видалення. MEXC не дає жодних гарантій щодо точності, повноти або своєчасності вмісту і не несе відповідальності за будь-які дії, вчинені на основі наданої інформації. Вміст не є фінансовою, юридичною або іншою професійною порадою і не повинен розглядатися як рекомендація або схвалення з боку MEXC.

Вам також може сподобатися

Іран нібито попросив танкери сплачувати збори за прохід через Ормузьку протоку в Bitcoin

Іран нібито попросив танкери сплачувати збори за прохід через Ормузьку протоку в Bitcoin

Повідомлялося, що директива, пов'язана з Ормузькою протокою, передбачає, що нафтові танкери повинні будуть сплачувати збір у розмірі 1 долар за барель у bitcoin. Структура платежу, схоже, була розроблена для
Поділитись
Crypto News Flash2026/04/09 15:08
Якби запит Ірану щодо Bitcoin через Ормузьку протоку став реальністю, скільки BTC отримав би Іран? Ось нетрадиційний розрахунок

Якби запит Ірану щодо Bitcoin через Ормузьку протоку став реальністю, скільки BTC отримав би Іран? Ось нетрадиційний розрахунок

Які були б наслідки, якби Іран вимагав Bitcoin на суму $1 за барель з кожного нафтового танкера, що проходить через Ормузьку протоку? Читати далі: Якщо
Поділитись
Bitcoinsistemi2026/04/09 15:08
EUR/USD коливається на рівні 1,1660 на тлі невизначеності щодо перемир'я з Іраном

EUR/USD коливається на рівні 1,1660 на тлі невизначеності щодо перемир'я з Іраном

Допис EUR/USD коливається на рівні 1,1660 на тлі напруженої ситуації з перемир'ям Ірану з'явився на BitcoinEthereumNews.com. (EUR) торгується практично без змін, трохи вище
Поділитись
BitcoinEthereumNews2026/04/09 15:23

30 000 $ в PRL + 15 000 USDT

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

Депонуйте та торгуйте PRL, щоб збільшити винагороди!