Der vorherige Beitrag führte durch eine End-to-End-Implementierung: ein minimaler Token-Kontrakt, Off-Chain-Zustandsrekonstruktion und ein React-Frontend – den ganzen Weg von `mint()` bis MetaMask. Dieser Beitrag knüpft dort an: Wie führt man QA für so etwas durch?
Ich bin (noch) kein Blockchain-Ingenieur, aber QA-Muster lassen sich gut über Domänen hinweg übertragen, und das Ausleihen dessen, was anderswo bereits funktioniert, ist meine schnellste Lernmethode.
Der Kontrakt macht nur drei Dinge: `mint`, `transfer` und `burn`, aber selbst das reicht aus, um die vollständige QA-Toolchain zu praktizieren: statische Analyse, Mutationstests, Gas-Profiling, formale Verifizierung.
Der Code befindet sich in `egpivo/ethereum-account-state`.
Blockchain-QA-Pyramide: von der statischen Analyse an der Basis bis zur formalen Verifizierung an der SpitzeBevor etwas Neues hinzugefügt wurde, hatte das Projekt bereits:
Alle Tests bestanden. Die Abdeckung sah gut aus. Warum also mehr Aufwand?
Weil „alle Tests bestehen" nicht bedeutet „alle Fehler werden gefangen". 100% Zeilenabdeckung kann immer noch einen echten Fehler übersehen, wenn keine Assertion das Richtige überprüft.
Slither (Trail of Bits) erfasst Probleme, die für Tests unsichtbar sind: Reentrancy, ungeprüfte Rückgabewerte, Schnittstelleninkompatibilitäten.
./scripts/run-qa.sh slither
Ergebnis: 1 mittlerer Befund: `erc20-interface`: `transfer()` gibt kein `bool` zurück.
Das ist zu erwarten. Der Kontrakt ist absichtlich kein vollständiges ERC20: Er ist eine pädagogische Zustandsmaschine. Aber der Befund ist nicht akademisch:
Wenn jemand später diesen Token in ein Protokoll importiert, das ERC20 erwartet, würde die Schnittstelleninkompatibilität stillschweigend fehlschlagen. Slither markiert es jetzt, damit die Entscheidung bewusst ist.
./scripts/run-qa.sh coverage Abdeckungsergebnis.
Eine nicht abgedeckte Funktion: `BalanceLib.gt()`. Darauf kommen wir zurück.
forge coverage-Ausgabe: 24 Tests bestanden, Token.sol-Abdeckungstabelle./scripts/run-qa.sh gas
Basis-Gaskosten für die drei Operationen:
Gas in Bezug auf OperationenBei nachfolgenden Durchläufen vergleicht `forge snapshot — diff` mit der Basislinie. Eine 20%-Gas-Regression bei `transfer()` bedeutet reale Kosten für jeden Benutzer – sie vor dem Merge zu erfassen, ist günstig.
Hier wurde es interessant. Gambit (Certora) generiert Mutanten: Kopien von `Token.sol` mit kleinen absichtlichen Fehlern (`+=` zu `-=`, `>=` zu `>`, Bedingungen negiert). Die Pipeline führt die vollständige Testsuite gegen jeden Mutanten aus. Wenn ein Mutant überlebt (alle Tests bestehen weiterhin), ist das eine konkrete Testlücke.
./scripts/run-qa.sh mutation
Ergebnis: 97,0% Mutations-Score – 32 getötet, 1 überlebt von 33 Mutanten.
Gambits Ausgabeprotokoll zeigt jeden Mutanten und was geändert wurde. Ein paar Beispiele:
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 ← kein Test hat dies erfasst
Gambit-Mutationstests: 32 getötet, 1 überlebt, Mutations-Score 97,0%
Der überlebende Mutant tauschte `a > b` gegen `b > a` in `BalanceLib.gt()`. Kein Test erfasste es, weil `gt()` toter Code ist. Es wird nirgendwo in `Token.sol` aufgerufen.
Die Abdeckung markierte 91,67% der Funktionen, konnte aber die Lücke nicht erklären. Mutationstests taten es: `gt()` ist toter Code, nichts ruft ihn auf, und niemand würde es bemerken, wenn er falsch wäre.
Toter oder ungeschützter Code in Smart-Contracts hat einen realen Präzedenzfall.
Die Funktion sollte nicht aufrufbar sein, aber niemand testete diese Annahme. Unser `gt()` ist im Vergleich harmlos, aber das Muster ist dasselbe: Code, der existiert, aber nie ausgeführt wird, ist Code, den niemand beobachtet.
Halmos (a16z) argumentiert symbolisch über alle möglichen Eingaben. Wo Fuzz-Tests zufällige Werte sampeln und hoffen, Randfälle zu treffen, beweist Halmos Eigenschaften erschöpfend.
./scripts/run-qa.sh halmos
Ergebnis: 9/9 symbolische Tests bestehen – alle Eigenschaften für alle Eingaben bewiesen.
Verifizierte Eigenschaften:
Verifizierte EigenschaftenEine praktische Anmerkung: Halmos 0.3.3 unterstützt `vm.expectRevert()` nicht, daher konnte ich Revert-Tests nicht auf normale Foundry-Weise schreiben. Die Problemumgehung ist ein try/catch-Muster – wenn der Aufruf erfolgreich ist, obwohl er rückgängig gemacht werden sollte, schlägt `assert(false)` den Beweis fehl:
function check_mint_reverts_on_zero_address(uint256 amount) public {
vm.assume(amount > 0);
try token.mint(address(0), amount) {
assert(false); // sollte hier nicht ankommen
} catch {
// erwarteter Revert - Halmos beweist, dass dieser Pfad immer genommen wird
}
}
Nicht das Hübscheste, aber es funktioniert – Halmos beweist die Eigenschaft immer noch für alle Eingaben. Das ist die Art von Sache, die man nur herausfindet, indem man das Tool tatsächlich ausführt.
Zum Kontext, warum formale Verifizierung wichtig ist:
Die Schwachstelle war im Code, von jedem überprüfbar, aber kein Tool oder Test erfasste sie vor der Bereitstellung. Symbolische Beweiser wie Halmos existieren genau, um diese Lücke zu schließen – sie sampeln nicht; sie erschöpfen den Eingaberaum.
Halmos-Ausgabe: 9 Tests bestanden, 0 fehlgeschlagen, symbolische TestergebnisseDie Testdatei ist `contracts/test/Token.halmos.t.sol`.
Die Architektur des ersten Beitrags hat eine TypeScript-Domain-Schicht, die die On-Chain-Zustandsmaschine spiegelt. Diese Phase testet, ob die beiden tatsächlich übereinstimmen.
Ich habe fast-check Property-Tests für die TypeScript-Domain-Schicht hinzugefügt, die spiegeln, was Foundrys Fuzzer für Solidity tut:
npm test - tests/unit/property.test.ts
Ergebnis: 9/9 Property-Tests bestehen nach Behebung eines echten Fehlers.
Getestete Eigenschaften:
fast-check fand einen echten Cross-Layer-Konsistenzfehler in `Token.ts` `transfer()`. Das verkleinerte Gegenbeispiel war sofort klar:
Property failed after 3 tests
Shrunk 2 time(s)
Counterexample: transfer(from=0xaaa…, to=0xaaa…, amount=1n)
→ from == to (Selbstübertragung)
→ verifyInvariant() returned false
Selbstübertragung (`from == to`) brach die `sum(balances) == totalSupply`-Invariante. `toBalance` wurde gelesen, bevor `fromBalance` aktualisiert wurde, sodass bei `from == to` der veraltete Wert die Abzug überschrieb:
// Vorher (fehlerhaft)
const fromBalance = this.getBalance(from);
const toBalance = this.getBalance(to); // ← veraltet, wenn from == to
this.accounts.set(from.getValue(), fromBalance.subtract(amount));
this.accounts.set(to.getValue(), toBalance.add(amount)); // ← überschreibt die Subtraktion
Fix: `toBalance` nach dem Schreiben von `fromBalance` lesen, entsprechend Soliditys Storage-Semantik:
// Nachher (behoben)
const fromBalance = this.getBalance(from);
this.accounts.set(from.getValue(), fromBalance.subtract(amount));
const toBalance = this.getBalance(to); // ← liest jetzt aktualisierten Wert
this.accounts.set(to.getValue(), toBalance.add(amount));
Der Solidity-Kontrakt war nicht betroffen: Er liest Storage nach jedem Schreibvorgang neu. Aber der TypeScript-Spiegel hatte eine subtile Reihenfolgeabhängigkeit, die kein existierender Unit-Test abdeckte.
Cross-Layer-Inkompatibilitäten in größerem Maßstab waren katastrophal.
Unser Selbstübertragungs-Fehler hätte niemandem Geld gekostet, aber der Fehlermodus ist strukturell derselbe: zwei Schichten, die übereinstimmen sollen, tun es nicht.
QA-Tools auf einem bestehenden Projekt auszuführen ist nie nur „installieren und ausführen". Ein paar Dinge sind kaputt gegangen, bevor sie funktionierten:
Alles läuft über zwei Skripte:
./scripts/run-qa.sh slither gas # nur statische Analyse + Gas
./scripts/run-qa.sh mutation # nur Mutationstests
./scripts/run-qa.sh all # alles
Nicht jede Prüfung ist schnell. Slither und Coverage laufen bei jedem Commit. Mutationstests und Halmos sind langsamer – besser geeignet für wöchentliche oder Pre-Release-Durchläufe.
Fünf QA-Schichten, jede erfasst eine andere Problemklasse.
SchichterklärungGambit und fast-check lieferten in dieser Runde die umsetzbarsten Ergebnisse.
Die QA-Prüfungen sind jetzt als sechsstufige Pipeline in GitHub Actions integriert:
CI-Pipeline: Build & Lint verzweigt zu Test, Coverage, Gas, Slither und Audit-StufenGitHub Actions-Pipeline: Build & Lint steuert alle nachgelagerten Stufen.
StufenerklärungEthereum Account State: QA Pipeline for a Minimal Token wurde ursprünglich in Coinmonks auf Medium veröffentlicht, wo die Leute die Konversation fortsetzen, indem sie diese Geschichte hervorheben und darauf reagieren.


