Panel QA monitorujący stan inteligentnego kontraktu  Poprzedni post przeprowadził przez kompleksową implementację: minimalny kontrakt tokenów, odtwarzanie stanu off-chainPanel QA monitorujący stan inteligentnego kontraktu  Poprzedni post przeprowadził przez kompleksową implementację: minimalny kontrakt tokenów, odtwarzanie stanu off-chain

Stan konta Ethereum: Pipeline QA dla minimalnego tokena

2026/04/09 13:48
8 min. lektury
W przypadku uwag lub wątpliwości dotyczących niniejszej treści skontaktuj się z nami pod adresem [email protected]
Panel kontrolny QA monitorujący stan inteligentnego kontraktu

Poprzedni post przeprowadził przez implementację end-to-end: minimalny kontrakt tokenów, rekonstrukcję stanu off-chain i frontend React — od `mint()` do MetaMask. Ten post kontynuuje tam, gdzie tamten się skończył: jak przeprowadzić QA czegoś takiego?

Nie jestem (jeszcze) inżynierem blockchain, ale wzorce QA dobrze przenoszą się między domenami, a pożyczanie tego, co już działa gdzie indziej, to sposób, w jaki uczę się najszybciej.

Kontrakt robi tylko trzy rzeczy: `mint`, `transfer` i `burn`, ale nawet to wystarczy, by przećwiczyć pełny łańcuch narzędzi QA: analizę statyczną, testowanie mutacji, profilowanie gazu, weryfikację formalną.

Kod znajduje się w `egpivo/ethereum-account-state`.

Piramida QA Blockchain: od analizy statycznej u podstawy do weryfikacji formalnej na szczycie

Od czego zaczęliśmy

Zanim dodano cokolwiek nowego, projekt już miał:

  • 21 testów jednostkowych Foundry obejmujących każdą zmianę stanu (sukces, cofnięcie przy nielegalnym wejściu, emisja zdarzeń)
  • 3 testy niezmienników przez `TokenHandler`, który uruchamia losowe sekwencje `mint`/`transfer`/`burn` na 10 aktorach (po 128k wywołań każdy)
  • Testy fuzz sprawdzające `sum(balances) == totalSupply` dla losowych kwot
  • Testy domenowe TypeScript (Vitest) odzwierciedlające maszynę stanów on-chain
  • CI: kompilacja, test, lint (Prettier + solhint)

Wszystkie testy przeszły. Pokrycie wyglądało dobrze. Więc po co się więcej starać?

Ponieważ "wszystkie testy przeszły" nie oznacza "wszystkie błędy zostały wykryte". 100% pokrycia linii nadal może przeoczyć prawdziwy błąd, jeśli żadne asercje nie sprawdzają właściwej rzeczy.

Faza 1: Analiza statyczna inteligentnego kontraktu i pokrycie

Slither

Slither(Trail of Bits) wychwytuje problemy niewidoczne dla testów: reentrancy, niesprawdzone wartości zwracane, niedopasowania interfejsów.

./scripts/run-qa.sh slither

Wynik: 1 znalezisko średnie: `erc20-interface`: `transfer()` nie zwraca `bool`.

To jest oczekiwane. Kontrakt celowo nie jest pełnym ERC20: jest edukacyjną maszyną stanów. Ale znalezisko nie jest akademickie:

Jeśli ktoś później zaimportuje ten token do protokołu oczekującego ERC20, niedopasowanie interfejsu zawiodłoby po cichu. Slither flaguje to teraz, więc decyzja jest świadoma.

Pokrycie

./scripts/run-qa.sh coverageWynik pokrycia.

Jedna nieobsłużona funkcja: `BalanceLib.gt()`. Wrócimy do tego.

wynik forge coverage: 24 testy przeszły, tabela pokrycia Token.sol

Snapshoty gazu

./scripts/run-qa.sh gas

Bazowe koszty gazu dla trzech operacji:

Gaz w kontekście operacji

Przy kolejnych uruchomieniach `forge snapshot — diff` porównuje z bazą. 20% regresja gazu w `transfer()` to realny koszt dla każdego użytkownika — wykrycie tego przed merge jest tanie.

Faza 2: Testowanie mutacji i weryfikacja formalna

Testowanie mutacji (Gambit)

Tu sprawy stały się interesujące. Gambit(Certora) generuje mutanty: kopie `Token.sol` z małymi celowymi błędami (`+=` na `-=`, `>=` na `>`, negowane warunki). Pipeline uruchamia pełny zestaw testów przeciwko każdemu mutantowi. Jeśli mutant przeżyje (wszystkie testy nadal przechodzą), to jest konkretna luka testowa.

./scripts/run-qa.sh mutation

Wynik: 97,0% wynik mutacji — 32 zabite, 1 przeżył z 33 mutantów.

Log wyjściowy Gambit pokazuje każdego mutanta i co zmienił. Kilka przykładów:

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 ← żaden test tego nie wykryłTestowanie mutacji Gambit: 32 zabite, 1 przeżył, wynik mutacji 97,0%

Przeżywający mutant zamienił `a > b` na `b > a` w `BalanceLib.gt()`. Żaden test tego nie wykrył, ponieważ `gt()` jest martwym kodem. Nigdy nie jest wywoływany nigdzie w `Token.sol`.

Pokrycie zaflagowało 91,67% funkcji, ale nie mogło wyjaśnić luki. Testowanie mutacji tak: `gt()` jest martwym kodem, nic go nie wywołuje i nikt nie zauważyłby, gdyby był błędny.

Martwy lub niechroniony kod w inteligentnych kontraktach ma realne precedensy.

Funkcja nie była przeznaczona do wywołania, ale nikt nie przetestował tego założenia. Nasz `gt()` jest nieszkodliwy w porównaniu, ale wzorzec jest ten sam: kod, który istnieje, ale nigdy nie jest wykonywany, to kod, którego nikt nie obserwuje.

Weryfikacja formalna (Halmos)

Halmos(a16z) rozumuje o wszystkich możliwych wejściach symbolicznie. Tam gdzie testy fuzz próbkują losowe wartości i mają nadzieję trafić na przypadki brzegowe, Halmos dowodzi właściwości wyczerpująco.

./scripts/run-qa.sh halmos

Wynik: 9/9 testów symbolicznych przeszło — wszystkie właściwości udowodnione dla wszystkich wejść.

Zweryfikowane właściwości:

Zweryfikowane właściwości

Jedna praktyczna uwaga: Halmos 0.3.3 nie obsługuje `vm.expectRevert()`, więc nie mogłem napisać testów revert w normalny sposób Foundry. Obejście to wzorzec try/catch — jeśli wywołanie się powiedzie, gdy powinno się cofnąć, `assert(false)` zawodzi dowód:

function check_mint_reverts_on_zero_address(uint256 amount) public {
vm.assume(amount > 0);
try token.mint(address(0), amount) {
assert(false); // nie powinno tutaj dotrzeć
} catch {
// oczekiwane cofnięcie - Halmos dowodzi, że ta ścieżka jest zawsze brana
}
}

Nie najładniejsze, ale działa — Halmos nadal dowodzi właściwości dla wszystkich wejść. To jest rodzaj rzeczy, o której dowiadujesz się tylko faktycznie uruchamiając narzędzie.

Dla kontekstu, dlaczego weryfikacja formalna ma znaczenie:

Podatność była w kodzie, do przejrzenia przez każdego, ale żadne narzędzie ani test nie wykrył jej przed wdrożeniem. Symboliczne dowodziarze jak Halmos istnieją właśnie po to, by zamknąć tę lukę — nie próbkują; wyczerpują przestrzeń wejściową.

Wynik Halmos: 9 testów przeszło, 0 nieudanych, wyniki testów symbolicznych

Plik testowy to `contracts/test/Token.halmos.t.sol`.

Faza 3: Testowanie właściwości między warstwami

Architektura pierwszego posta ma warstwę domenową TypeScript, która odzwierciedla maszynę stanów on-chain. Ta faza testuje, czy obie faktycznie się zgadzają.

Testowanie oparte na właściwościach z fast-check

Dodałem testy właściwości fast-check dla warstwy domenowej TypeScript, odzwierciedlające to, co robi fuzzer Foundry dla Solidity:

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

Wynik: 9/9 testów właściwości przeszło po naprawieniu prawdziwego błędu.

Testowane właściwości:

  • `Balance`: przemienność, łączność, tożsamość, odwrotność, spójność porównań
  • `Token`: niezmiennik `sum(balances) == totalSupply` przy losowych sekwencjach operacji (200 uruchomień, po 50 operacji każde)
  • `Token`: `totalSupply` nieujemne po losowych sekwencjach
  • `mint` zawsze się udaje dla poprawnych wejść
  • `transfer` zachowuje `totalSupply`

Błąd znaleziony przez fast-check

fast-check znalazł prawdziwy błąd spójności między warstwami w `Token.ts` `transfer()`. Skurczony kontrprzykład był natychmiast jasny:

Property failed after 3 tests
Shrunk 2 time(s)
Counterexample: transfer(from=0xaaa…, to=0xaaa…, amount=1n)
→ from == to (transfer do siebie)
→ verifyInvariant() zwrócił false

Transfer do siebie (`from == to`) złamał niezmiennik `sum(balances) == totalSupply`. `toBalance` był odczytany przed aktualizacją `fromBalance`, więc gdy `from == to`, nieaktualna wartość nadpisała odliczenie:

// Przed (błędne)
const fromBalance = this.getBalance(from);
const toBalance = this.getBalance(to); // ← nieaktualne gdy from == to
this.accounts.set(from.getValue(), fromBalance.subtract(amount));
this.accounts.set(to.getValue(), toBalance.add(amount)); // ← nadpisuje odejmowanie

Poprawka: odczytaj `toBalance` po zapisaniu `fromBalance`, dopasowując semantykę pamięci Solidity:

// Po (naprawione)
const fromBalance = this.getBalance(from);
this.accounts.set(from.getValue(), fromBalance.subtract(amount));
const toBalance = this.getBalance(to); // ← teraz odczytuje zaktualizowaną wartość
this.accounts.set(to.getValue(), toBalance.add(amount));

Kontrakt Solidity nie był dotknięty: ponownie odczytuje pamięć po każdym zapisie. Ale lustro TypeScript miało subtelną zależność kolejności, której nie obejmował żaden istniejący test jednostkowy.

Niedopasowania między warstwami w większej skali były katastrofalne.

Nasz błąd transferu do siebie nie spowodowałby utraty niczyich pieniędzy, ale tryb awarii jest strukturalnie taki sam: dwie warstwy, które powinny się zgadzać, nie zgadzają się.

Pułapki napotkane po drodze

Uruchamianie narzędzi QA w istniejącym projekcie nigdy nie jest tylko "zainstaluj i uruchom". Kilka rzeczy pękło, zanim zadziałały:

  • 0% pokrycia, ponieważ `foundry.toml` nie miał ścieżki testowej: Pierwsze uruchomienie `forge coverage` zwróciło 0% na całej linii. Okazało się, że `foundry.toml` nie określił `test = "contracts/test"` ani `script = "contracts/script"`, więc Forge nie odkrywał żadnych testów. Polecenie pokrycia zakończyło się powodzeniem po cichu — po prostu nie miało czego pokryć. To była najbardziej myląca awaria: zielone uruchomienie bez użytecznego wyjścia.
  • Import `InvariantTest` zniknął w forge-std v1.14.0: `Invariant.t.sol` importował `InvariantTest` z `forge-std`, który został usunięty w najnowszym wydaniu. Kompilacja zawiodła z nieprzejrzystym błędem "symbol not found". Poprawka polegała na usunięciu importu — sam `Test` jest teraz wystarczający do testowania niezmienników Foundry.
  • `uint256(token.totalSupply())` vs `Balance.unwrap()`: Testy używały jawnego rzutowania do wydobycia podstawowego `uint256` z zdefiniowanego przez użytkownika typu `Balance`. Skompilowało się, ale to niewłaściwy idiom — `Balance.unwrap(token.totalSupply())` to właśnie to, do czego system UDVT jest zaprojektowany. Zastosowano w `Token.t.sol`, `Invariant.t.sol` i `DeploySepolia.s.sol`.

Projekt pipeline

Wszystko działa przez dwa skrypty:

  • scripts/setup-qa-tools.sh`: instaluje Slither, Halmos, Gambit (idempotentne)
  • `scripts/run-qa.sh`: uruchamia sprawdzenia, zapisuje wyniki z znacznikiem czasu do `qa-results/`

./scripts/run-qa.sh slither gas # tylko analiza statyczna + gaz
./scripts/run-qa.sh mutation # tylko testowanie mutacji
./scripts/run-qa.sh all # wszystko

Nie każde sprawdzenie jest szybkie. Slither i pokrycie działają przy każdym commit. Testowanie mutacji i Halmos są wolniejsze — lepiej nadają się do cotygodniowych uruchomień lub przed wydaniem.

Podsumowanie

Łańcuch narzędzi QA Blockchain: co wychwytuje każda warstwa — od analizy statycznej do testowania właściwości między warstwami

Pięć warstw QA, z których każda wychwytuje inną klasę problemów.

Wyjaśnienie warstw

Gambit i fast-check dały najbardziej przydatne wyniki w tej rundzie.

Pipeline CI

Sprawdzenia QA są teraz podłączone do GitHub Actions jako sześciostopniowy pipeline:

Pipeline CI: Build & Lint rozchodzi się do etapów Test, Coverage, Gas, Slither i Audit

Pipeline GitHub Actions: Build & Lint bramkuje wszystkie etapy niższego rzędu.

Wyjaśnienie etapów

Referencje

  • Źródło Ethereum Account State: [github.com/egpivo/ethereum-account-state](https://github.com/egpivo/ethereum-account-state)
  • Poprzedni post: 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

Notatki

  • Ten post jest adaptacją mojego oryginalnego posta na blogu.

Ethereum Account State: QA Pipeline for a Minimal Token zostało pierwotnie opublikowane w Coinmonks na Medium, gdzie ludzie kontynuują rozmowę, podkreślając i odpowiadając na tę historię.

Zastrzeżenie: Artykuły udostępnione na tej stronie pochodzą z platform publicznych i służą wyłącznie celom informacyjnym. Niekoniecznie odzwierciedlają poglądy MEXC. Wszystkie prawa pozostają przy pierwotnych autorach. Jeśli uważasz, że jakakolwiek treść narusza prawa stron trzecich, skontaktuj się z [email protected] w celu jej usunięcia. MEXC nie gwarantuje dokładności, kompletności ani aktualności treści i nie ponosi odpowiedzialności za jakiekolwiek działania podjęte na podstawie dostarczonych informacji. Treść nie stanowi porady finansowej, prawnej ani innej profesjonalnej porady, ani nie powinna być traktowana jako rekomendacja lub poparcie ze strony MEXC.

$30,000 in PRL + 15,000 USDT

$30,000 in PRL + 15,000 USDT$30,000 in PRL + 15,000 USDT

Deposit & trade PRL to boost your rewards!