Eelmine postitus läbis lõpuni implementatsiooni: minimaalse tokeni lepingu, väljastpoolset oleku taastamist ja Reacti esiletasandit – kogu tee `mint()`-ist MetaMaskini. See postitus jätkab eelmise punktist: kuidas seda sellist asja QA-testida?
Ma ei ole veel blockchain-insener, kuid QA-mustrid on ülevalt domäänide vahel hästi ülekantavad ja ma õpin kõige kiiremini, kasutades neid, mis juba mujal toimivad.
Leping teeb vaid kolm asja: `mint`, `transfer` ja `burn`, kuid isegi see on piisav, et harjutada täielikku QA-tööriistariba: staatiline analüüs, mutatsioonitestid, gaasiprofiilimine, formaalne verifitseerimine.
Kood asub kaustas `egpivo/ethereum-account-state`.
Blockchaini QA-püramiid: alusel staatiline analüüs, tippu formaalne verifitseerimineEnne uute asjade lisamist oli projektil juba:
Kõik testid läbisid. Katvus näis korras. Miks siis veel rohkem teha?
Sellepärast, et „kõik testid läbisid“ ei tähenda „kõiki vigu on avastatud“. 100% reakatvus võib ikka jätta tegeliku vea silmata, kui ükski väide ei kontrolli õiget asja.
Slither (Trail of Bits) tuvastab probleeme, mida testid ei näe: reentrancy, kontrollimata tagastusväärtused, liidese sobimatuse.
./scripts/run-qa.sh slither
Tulemus: 1 keskmise tõsiduse leidmine: `erc20-interface`: `transfer()` ei tagasta `bool`-väärtust.
See on oodatud. Leping pole intensionaalselt täielik ERC20: see on õppetöö eesmärgil loodud olekumasin. Kuid leidmine ei ole akadeemiline:
Kui keegi hiljem impordib selle tokeni protokolli, mis ootab ERC20-d, siis liidese sobimatuse tõttu toimub vaikne nurjumine. Slither tähistab selle juba nüüd, et otsus oleks teadlik.
./scripts/run-qa.sh coverageKatvuse tulemus.
Üks katmata funktsioon: `BalanceLib.gt()`. Me pöördume selle juurde tagasi.
forge coverage väljund: 24 testi läbisid, Token.sol katvustabel./scripts/run-qa.sh gas
Kolme operatsiooni baasgaaskulud:
Gaas operatsioonite järgiJärgmistel käsklustel võrdleb `forge snapshot — diff` baaspilti. 20% gaasiregressioon `transfer()`-is on igale kasutajale reaalne kulutus – selle avastamine enne ühendamist on odav.
Siin sai asi huvitavaks. Gambit (Certora) genereerib mutandid: `Token.sol` koopiad, milles on väikesed teadlikud vead (`+=` → `-=`, `>=` → `>`, tingimuste eitamine). Toru käivitab täieliku testikomplekti iga mutandi vastu. Kui mutand ellu jääb (kõik testid läbivad ikka), siis on see konkreetne testilüng.
./scripts/run-qa.sh mutation
Tulemus: 97,0% mutatsiooniskoor — 32 tapetud, 1 ellu jäänud 33-st mutandist.
Gambiti väljundlogis on iga mutand ja see, mida see muutis. Mõned näited:
Genereeritud mutand nr 7: BinaryOpMutation — Token.sol:168
totalSupply = totalSupply.add(amountBalance) → totalSupply = totalSupply.sub(amountBalance)
TAPETUD test_Mint_Success-i poolt
Genereeritud mutand nr 19: RelationalOpMutation — Token.sol:196
if (!fromBalance.gte(amountBalance)) → if (fromBalance.gte(amountBalance))
TAPETUD test_Transfer_Success-i poolt
Genereeritud mutand nr 28: SwapArgumentsMutation — Token.sol:81
return Balance.unwrap(a) > Balance.unwrap(b) → return Balance.unwrap(b) > Balance.unwrap(a)
ELLU JÄÄNUD ← ükski test seda ei avastanudGambiti mutatsioonitestid: 32 tapetud, 1 ellu jäänud, mutatsiooniskoor 97,0%
Ellu jäänud mutand vahetas `a > b` `b > a`-ks `BalanceLib.gt()`-is. Ükski test seda ei avastanud, sest `gt()` on surnud kood. See ei ole kusagil `Token.sol`-is kunagi välja kutsutud.
Katvus näitas 91,67% funktsioone, kuid ei suutnud selgitada lünga. Mutatsioonitestid seda tegid: `gt()` on surnud kood, seda ei kutsuta kuskil välja ning kui see vale oleks, ei märkaks seda keegi.
Surnud või kaitsemata kood nutikates lepingutes on juba tõesti esinenud.
Funktsioon polnud mõeldud väljakutsumiseks, kuid keegi ei testinud seda eeldust. Meie `gt()` on sellest võrreldes harmooniline, kuid muster on sama: kood, mis eksisteerib, kuid mida ei kasutata, on kood, millele keegi ei pööra tähelepanu.
Halmos (a16z) analüüsib kõiki võimalikke sisendeid sümboliliselt. Kus fuzz-testid proovivad juhuslikke väärtusi ja lootavad äärmuslike olukordade tabamist, tõestab Halmos omadusi põhjalikult.
./scripts/run-qa.sh halmos
Tulemus: 9/9 sümbolilist testi läbisid — kõik omadused tõestatud kõigi sisendite jaoks.
Tõestatud omadused:
Tõestatud omadusedÜks praktiline märkus: Halmos 0.3.3 ei toeta `vm.expectRevert()`-i, seega ei saanud ma revert-teste kirjutada tavapärase Foundry viisi järgi. Alternatiiviks on try/catch-muster — kui kõne õnnestub siis, kui peaks tagasipöörduma, siis `assert(false)` teeb tõestuse ebaõnnestunuks:
function check_mint_reverts_on_zero_address(uint256 amount) public {
vm.assume(amount > 0);
try token.mint(address(0), amount) {
assert(false); // ei tohiks siia jõuda
} catch {
// oodatud tagasipöördumine – Halmos tõestab, et see tee on alati valitud
}
}
Ei ole ilusaim, kuid see töötab — Halmos tõestab ikka omaduse kõigi sisendite jaoks. Sellised asjad selguvad ainult tööriista tegelikku käivitamist proovides.
Selle kontekstis, miks formaalne verifitseerimine on oluline:
Vulnerability oli koodis, mida igaüks saab üle vaadata, kuid ükski tööriist ega test ei avastanud seda enne paigaldamist. Sümbolilised tõestajad nagu Halmos on just selle lünka kinnitamiseks loodud — nad ei proovi, nad läbivad kogu sisendruumi.
Halmosi väljund: 9 testi läbisid, 0 nurjusid, sümboliliste testide tulemusedTestifail on `contracts/test/Token.halmos.t.sol`.
Eelneva postituse arhitektuuril on TypeScripti domeenikiht, mis peegeldab ahelas olevat olekumasinat. See faas testib, kas need kaks tegelikult kokku sobivad.
Ma lisasin TypeScripti domeenikihi jaoks fast-check’i omadusteste, mis peegeldavad seda, mida Foundry fuzzer teeb Solidity jaoks:
npm test - tests/unit/property.test.ts
Tulemus: 9/9 omadustesti läbisid pärast tegeliku vea parandamist.
Testitud omadused:
fast-check avastas tegeliku ristkihilise kooskõlavuse vea `Token.ts` `transfer()`-is. Kokkupressitud kontranaide oli kohe selge:
Omadus ebaõnnestus pärast 3 testi
Kokkupressitud 2 korda
Kontranaide: transfer(from=0xaaa…, to=0xaaa…, amount=1n)
→ from == to (iseleülekanne)
→ verifyInvariant() tagastas false
Iseleülekanne (`from == to`) rikkus `sum(balances) == totalSupply` invarianti. `toBalance` loeti enne, kui `fromBalance` värskendati, seega kui `from == to`, siis vananenud väärtus kirjutas lahutamise üle:
// Enne (vigane)
const fromBalance = this.getBalance(from);
const toBalance = this.getBalance(to); // ← vananenud, kui from == to
this.accounts.set(from.getValue(), fromBalance.subtract(amount));
this.accounts.set(to.getValue(), toBalance.add(amount)); // ← kirjutab lahutamise üle
Parandus: loe `toBalance` pärast `fromBalance` kirjutamist, vastavalt Solidity salvestussemantikale:
// Pärast (parandatud)
const fromBalance = this.getBalance(from);
this.accounts.set(from.getValue(), fromBalance.subtract(amount));
const toBalance = this.getBalance(to); // ← loeb nüüd värskendatud väärtust
this.accounts.set(to.getValue(), toBalance.add(amount));
Solidity lepingut ei mõjutanud: see loeb salvestust iga kirjutamise järel uuesti. Kuid TypeScripti peegel oli subtiilselt sõltuv järjestusest, mida ükski olemasolev ühiktest ei kattanud.
Suuremas mahus esinenud ristkihilised mittekooskõlad on olnud katastroofilised.
Meie iseülekande viga ei oleks kellegi rahalisi kaotusi põhjustanud, kuid vea tüüp on struktuuris sama: kaks kihti, mis peaksid kokku sobima, ei sobi.
QA-tööriistade käivitamine olemasolevas projektsis ei ole kunagi lihtsalt „paigalda ja käivita“. Mõned asjad läksid enne tööle minekut katki:
Kõik käivitub kahe skripti kaudu:
./scripts/run-qa.sh slither gas # ainult staatiline analüüs + gaas
./scripts/run-qa.sh mutation # ainult mutatsioonitestid
./scripts/run-qa.sh all # kõik
Ei iga kontroll pole kiire. Slither ja katvus käivitatakse iga commiti järel. Mutatsioonitestid ja Halmos on aeglasemad — paremini sobivad nädalaselt või enne versiooni avaldamist.
Viis QA-kihti, millest igaüks avastab teistsuguse probleemiklassi.
Kihi seletusGambit ja fast-check andsid selles voorus kõige tegutsemad tulemused.
QA-kontrollid on nüüd ühendatud GitHub Actionsi kui kuuefaasilise toru:
CI-toru: Build & Lint jaguneb Test, Coverage, Gas, Slither ja Audit faasidesseGitHub Actionsi toru: Build & Lint blokeerib kõiki järgnevaid faase.
Faasi seletusEthereumi konto olek: Minimaalse tokeni QA-toru avaldati esmakordselt Coinmonksis Mediumis, kus inimesed jätkavad vestlust, esiletõstes ja reageerides sellele loomule.


