Tablou de bord QA pentru monitorizarea stării contractului inteligent Postarea anterioară a parcurs o implementare end-to-end: un contract token minimal, reconstrucția stării off-chainTablou de bord QA pentru monitorizarea stării contractului inteligent Postarea anterioară a parcurs o implementare end-to-end: un contract token minimal, reconstrucția stării off-chain

Starea Contului Ethereum: Pipeline QA pentru un Token Minimal

2026/04/09 13:48
8 min de lectură
Pentru opinii sau preocupări cu privire la acest conținut, contactează-ne la crypto.news@mexc.com
Tablou de bord QA pentru monitorizarea stării contractului inteligent

Postarea anterioară a prezentat o implementare end-to-end: un contract token minimal, reconstrucția stării off-chain și un frontend React — de la `mint()` până la MetaMask. Această postare continuă de unde s-a oprit aceea: cum testezi QA așa ceva?

Nu sunt (încă) inginer blockchain, dar tiparele QA se portează bine între domenii, iar împrumutarea a ceea ce funcționează deja în altă parte este modul în care învăț cel mai rapid.

Contractul face doar trei lucruri: `mint`, `transfer` și `burn`, dar chiar și asta este suficient pentru a practica întregul lanț de instrumente QA: analiză statică, testare prin mutații, profilare gaz, verificare formală.

Codul se află în `egpivo/ethereum-account-state`.

Piramida QA Blockchain: de la analiza statică la bază până la verificarea formală în vârf

Cu ce am început

Înainte de a adăuga ceva nou, proiectul avea deja:

  • 21 de teste unitare Foundry acoperind fiecare tranziție de stare (succes, revert la input ilegal, emisie de evenimente)
  • 3 teste de invarianți prin intermediul unui `TokenHandler` care rulează secvențe aleatoare de `mint`/`transfer`/`burn` pe 10 actori (câte 128k apeluri fiecare)
  • Teste fuzz verificând `sum(balances) == totalSupply` pentru sume aleatoare
  • Teste de domeniu TypeScript (Vitest) care oglindesc mașina de stare on-chain
  • CI: compilare, testare, lint (Prettiersolhint)

Toate testele au trecut. Acoperirea arăta bine. Deci de ce să te mai deranjezi?

Pentru că "toate testele trec" nu înseamnă "toate bug-urile sunt prinse." Acoperirea de 100% a liniilor poate încă rata un bug real dacă nicio aserțiune nu verifică lucrul corect.

Faza 1: Analiza statică a contractului inteligent și acoperirea

Slither

Slither(Trail of Bits) prinde probleme care sunt invizibile pentru teste: reentrancy, valori returnate neverificate, nepotriviri de interfață.

./scripts/run-qa.sh slither

Rezultat: 1 descoperire Medium: `erc20-interface`: `transfer()` nu returnează `bool`.

Era de așteptat. Contractul nu este intenționat un ERC20 complet: este o mașină de stare educațională. Dar descoperirea nu este academică:

Dacă cineva importă mai târziu acest token într-un protocol care așteaptă ERC20, nepotrivirea de interfață ar eșua silent. Slither îl semnalează acum, astfel încât decizia să fie conștientă.

Acoperire

./scripts/run-qa.sh coverageRezultat acoperire.

O funcție neacoperită: `BalanceLib.gt()`. Ne vom întoarce la aceasta.

ieșire forge coverage: 24 de teste trecute, tabel de acoperire Token.sol

Snapshot-uri gaz

./scripts/run-qa.sh gas

Costurile de bază de gaz pentru cele trei operații:

Gaz în termeni de operații

La rulările ulterioare, `forge snapshot — diff` compară cu baseline-ul. O regresie de 20% a gazului în `transfer()` este un cost real pentru fiecare utilizator — a o prinde înainte de merge este ieftin.

Faza 2: Testare prin mutații și verificare formală

Testare prin mutații (Gambit)

Aici lucrurile au devenit interesante. Gambit(Certora) generează mutanți: copii ale `Token.sol` cu bug-uri mici deliberate (`+=` în `-=`, `>=` în `>`, condiții negate). Pipeline-ul rulează suita completă de teste împotriva fiecărui mutant. Dacă un mutant supraviețuiește (toate testele încă trec), aceasta este o lacună concretă de testare.

./scripts/run-qa.sh mutation

Rezultat: scor mutație 97,0% — 32 uciși, 1 supraviețuit din 33 de mutanți.

Jurnalul de ieșire al Gambit arată fiecare mutant și ce a schimbat. Câteva exemple:

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 ← no test caught thisTestare prin mutații Gambit: 32 uciși, 1 supraviețuit, scor mutație 97,0%

Mutantul supraviețuitor a schimbat `a > b` în `b > a` în `BalanceLib.gt()`. Niciun test nu l-a prins pentru că `gt()` este cod mort. Nu este apelat niciodată nicăieri în `Token.sol`.

Acoperirea a semnalat 91,67% funcții, dar nu a putut explica decalajul. Testarea prin mutații a reușit: `gt()` este cod mort, nimic nu îl apelează și nimeni nu ar observa dacă ar fi greșit.

Codul mort sau neprotejat în contractele inteligente are un precedent real.

Funcția nu era destinată să fie apelabilă, dar nimeni nu a testat această presupunere. `gt()` nostru este inofensiv prin comparație, dar modelul este același: codul care există dar nu este niciodată exercitat este cod pe care nimeni nu îl supraveghează.

Verificare formală (Halmos)

Halmos(a16z) raționează despre toate intrările posibile simbolic. Unde testele fuzz eșantionează valori aleatoare și speră să lovească cazuri limită, Halmos dovedește proprietăți în mod exhaustiv.

./scripts/run-qa.sh halmos

Rezultat: 9/9 teste simbolice trecute — toate proprietățile dovedite pentru toate intrările.

Proprietăți verificate:

Proprietăți verificate

O notă practică: Halmos 0.3.3 nu suportă `vm.expectRevert()`, așa că nu am putut scrie teste de revert în modul normal Foundry. Soluția alternativă este un model try/catch — dacă apelul reușește când ar trebui să revert, `assert(false)` eșuează dovada:

function check_mint_reverts_on_zero_address(uint256 amount) public {
vm.assume(amount > 0);
try token.mint(address(0), amount) {
assert(false); // should not reach here
} catch {
// expected revert - Halmos proves this path is always taken
}
}

Nu este cel mai frumos lucru, dar funcționează — Halmos încă dovedește proprietatea pentru toate intrările. Acesta este genul de lucru pe care îl descoperi doar rulând efectiv instrumentul.

Pentru context despre de ce contează verificarea formală:

Vulnerabilitatea era în cod, revizuibilă de oricine, dar niciun instrument sau test nu a prins-o înainte de implementare. Dovezitorii simbolici precum Halmos există exact pentru a închide acel decalaj — nu eșantionează; epuizează spațiul de intrare.

ieșire Halmos: 9 teste trecute, 0 eșuate, rezultate teste simbolice

Fișierul de test este `contracts/test/Token.halmos.t.sol`.

Faza 3: Testare de proprietăți între straturi

Arhitectura primei postări are un strat de domeniu TypeScript care oglindește mașina de stare on-chain. Această fază testează dacă cele două sunt de fapt de acord.

Testare bazată pe proprietăți cu fast-check

Am adăugat teste de proprietăți fast-check pentru stratul de domeniu TypeScript, oglindind ceea ce face fuzzer-ul Foundry pentru Solidity:

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

Rezultat: 9/9 teste de proprietăți trecute după remedierea unui bug real.

Proprietăți testate:

  • `Balance`: comutativitate, asociativitate, identitate, inversă, consistență comparații
  • `Token`: invariant `sum(balances) == totalSupply` sub secvențe de operații aleatoare (200 de rulări, câte 50 de operații)
  • `Token`: `totalSupply` non-negativ după secvențe aleatoare
  • `mint` reușește întotdeauna pentru intrări valide
  • `transfer` păstrează `totalSupply`

Bug-ul găsit de fast-check

fast-check a găsit un bug real de consistență între straturi în `Token.ts` `transfer()`. Contraexemplul micșorat a fost imediat clar:

Property failed after 3 tests
Shrunk 2 time(s)
Counterexample: transfer(from=0xaaa…, to=0xaaa…, amount=1n)
→ from == to (self-transfer)
→ verifyInvariant() returned false

Auto-transferul (`from == to`) a rupt invariantul `sum(balances) == totalSupply`. `toBalance` a fost citit înainte ca `fromBalance` să fie actualizat, așa că când `from == to`, valoarea învechită a suprascris deducția:

// Before (buggy)
const fromBalance = this.getBalance(from);
const toBalance = this.getBalance(to); // ← stale when from == to
this.accounts.set(from.getValue(), fromBalance.subtract(amount));
this.accounts.set(to.getValue(), toBalance.add(amount)); // ← overwrites the subtraction

Remediere: citește `toBalance` după scrierea `fromBalance`, potrivit semanticii de stocare Solidity:

// After (fixed)
const fromBalance = this.getBalance(from);
this.accounts.set(from.getValue(), fromBalance.subtract(amount));
const toBalance = this.getBalance(to); // ← now reads updated value
this.accounts.set(to.getValue(), toBalance.add(amount));

Contractul Solidity nu a fost afectat: recitește storage-ul după fiecare scriere. Dar oglinda TypeScript avea o dependență subtilă de ordonare pe care niciun test unitar existent nu o acoperea.

Nepotrivirile între straturi la scară mai mare au fost catastrofale.

Bug-ul nostru de auto-transfer nu ar fi făcut pe nimeni să piardă bani, dar modul de eșec este structural același: două straturi care ar trebui să fie de acord, nu sunt.

Capcane întâlnite pe parcurs

Rularea instrumentelor QA pe un proiect existent nu este niciodată doar "instalează și rulează." Câteva lucruri s-au stricat înainte de a funcționa:

  • 0% acoperire pentru că `foundry.toml` nu avea cale de test: Prima rulare `forge coverage` a returnat 0% peste tot. Se pare că `foundry.toml` nu specifica `test = "contracts/test"` sau `script = "contracts/script"`, astfel încât Forge nu descoperea niciun test. Comanda de acoperire a reușit în tăcere — doar că nu avea nimic de acoperit. Aceasta a fost cea mai înșelătoare eșuare: o rulare verde fără nicio ieșire utilă.
  • Import `InvariantTest` dispărut în forge-std v1.14.0: `Invariant.t.sol` importa `InvariantTest` din `forge-std`, care a fost eliminat într-o versiune recentă. Compilarea a eșuat cu o eroare opacă "symbol not found". Remedierea a fost eliminarea import-ului — `Test` singur este suficient pentru testarea de invarianți Foundry acum.
  • `uint256(token.totalSupply())` vs `Balance.unwrap()`: Testele foloseau un cast explicit pentru a extrage `uint256` de bază din tipul definit de utilizator `Balance`. Se compila, dar este idiomul greșit — `Balance.unwrap(token.totalSupply())` este pentru ce este proiectat sistemul UDVT. Aplicat în `Token.t.sol`, `Invariant.t.sol` și `DeploySepolia.s.sol`.

Design pipeline

Totul rulează prin două scripturi:

  • scripts/setup-qa-tools.sh`: instalează Slither, Halmos, Gambit (idempotent)
  • `scripts/run-qa.sh`: rulează verificări, salvează rezultate cu timestamp în `qa-results/`

./scripts/run-qa.sh slither gas # just static analysis + gas
./scripts/run-qa.sh mutation # just mutation testing
./scripts/run-qa.sh all # everything

Nu fiecare verificare este rapidă. Slither și acoperirea rulează la fiecare commit. Testarea prin mutații și Halmos sunt mai lente — mai potrivite pentru rulări săptămânale sau pre-release.

Rezumat

Lanț de instrumente QA Blockchain: ce prinde fiecare strat — de la analiza statică la testarea de proprietăți între straturi

Cinci straturi QA, fiecare prinde o clasă diferită de problemă.

Explicație straturi

Gambit și fast-check au dat cele mai acționabile rezultate în această rundă.

Pipeline CI

Verificările QA sunt acum conectate în GitHub Actions ca un pipeline în șase etape:

Pipeline CI: Build & Lint se ramifică în Test, Coverage, Gas, Slither și etapele Audit

Pipeline GitHub Actions: Build & Lint controlează toate etapele downstream.

Explicație etape

Referințe

  • Sursă Ethereum Account State: [github.com/egpivo/ethereum-account-state](https://github.com/egpivo/ethereum-account-state)
  • Postare anterioară: 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

Note

  • Această postare este adaptată din postarea mea originală de blog.

Ethereum Account State: QA Pipeline for a Minimal Token a fost publicat inițial în Coinmonks pe Medium, unde oamenii continuă conversația evidențiind și răspunzând la această poveste.

Declinarea responsabilității: Articolele publicate pe această platformă provin de pe platforme publice și sunt furnizate doar în scop informativ. Acestea nu reflectă în mod necesar punctele de vedere ale MEXC. Toate drepturile rămân la autorii originali. Dacă consideri că orice conținut încalcă drepturile terților, contactează crypto.news@mexc.com pentru eliminare. MEXC nu oferă nicio garanție cu privire la acuratețea, exhaustivitatea sau actualitatea conținutului și nu răspunde pentru nicio acțiune întreprinsă pe baza informațiilor furnizate. Conținutul nu constituie consiliere financiară, juridică sau profesională și nici nu trebuie considerat o recomandare sau o aprobare din partea 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!