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Înainte de a adăuga ceva nou, proiectul avea deja:
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.
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ă.
./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./scripts/run-qa.sh gas
Costurile de bază de gaz pentru cele trei operații:
Gaz în termeni de operațiiLa 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.
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ă.
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 verificateO 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 simboliceFișierul de test este `contracts/test/Token.halmos.t.sol`.
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.
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:
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.
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:
Totul rulează prin două scripturi:
./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.
Cinci straturi QA, fiecare prinde o clasă diferită de problemă.
Explicație straturiGambit și fast-check au dat cele mai acționabile rezultate în această rundă.
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 AuditPipeline GitHub Actions: Build & Lint controlează toate etapele downstream.
Explicație etapeEthereum 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.


