Η προηγούμενη ανάρτηση περιέγραψε μια ολοκληρωμένη υλοποίηση: ένα ελάχιστο συμβόλαιο token, ανακατασκευή κατάστασης εκτός αλυσίδας και ένα React frontend — από το `mint()` μέχρι το MetaMask. Αυτή η ανάρτηση συνεχίζει από εκεί που σταμάτησε: πώς κάνετε QA σε κάτι τέτοιο;
Δεν είμαι μηχανικός blockchain (ακόμα), αλλά τα μοτίβα QA λειτουργούν καλά σε όλους τους τομείς, και το δανεισμό αυτού που ήδη λειτουργεί αλλού είναι ο ταχύτερος τρόπος μάθησης.
Το συμβόλαιο κάνει μόνο τρία πράγματα: `mint`, `transfer` και `burn`, αλλά ακόμη και αυτό είναι αρκετό για να εξασκηθεί η πλήρης αλυσίδα εργαλείων QA: στατική ανάλυση, δοκιμές μετάλλαξης, προφίλ gas, επίσημη επαλήθευση.
Ο κώδικας βρίσκεται στο `egpivo/ethereum-account-state`.
Πυραμίδα QA Blockchain: από τη στατική ανάλυση στη βάση έως την επίσημη επαλήθευση στην κορυφήΠριν προσθέσουμε κάτι νέο, το έργο είχε ήδη:
Όλες οι δοκιμές πέρασαν. Η κάλυψη φαινόταν καλή. Γιατί λοιπόν να ενδιαφερθούμε για περισσότερα;
Επειδή το "όλες οι δοκιμές περνάνε" δεν σημαίνει "όλα τα σφάλματα εντοπίζονται". Η κάλυψη γραμμής 100% μπορεί ακόμα να χάσει ένα πραγματικό σφάλμα αν καμία διαβεβαίωση δεν ελέγχει το σωστό πράγμα.
Το Slither(Trail of Bits) εντοπίζει ζητήματα που είναι αόρατα στις δοκιμές: reentrancy, μη ελεγμένες τιμές επιστροφής, αναντιστοιχίες διεπαφής.
./scripts/run-qa.sh slither
Αποτέλεσμα: 1 Μεσαίο εύρημα: `erc20-interface`: το `transfer()` δεν επιστρέφει `bool`.
Αυτό ήταν αναμενόμενο. Το συμβόλαιο σκόπιμα δεν είναι πλήρες ERC20: είναι μια εκπαιδευτική μηχανή κατάστασης. Αλλά το εύρημα δεν είναι ακαδημαϊκό:
Εάν κάποιος αργότερα εισαγάγει αυτό το token σε ένα πρωτόκολλο που αναμένει ERC20, η αναντιστοιχία διεπαφής θα αποτύχει σιωπηλά. Το Slither το επισημαίνει τώρα, ώστε η απόφαση να είναι συνειδητή.
./scripts/run-qa.sh coverageΑποτέλεσμα κάλυψης.
Μία συνάρτηση χωρίς κάλυψη: `BalanceLib.gt()`. Θα επιστρέψουμε σε αυτό.
Έξοδος forge coverage: 24 δοκιμές πέρασαν, πίνακας κάλυψης Token.sol./scripts/run-qa.sh gas
Βασικό κόστος gas για τις τρεις λειτουργίες:
Gas σε όρους λειτουργιώνΣε επόμενες εκτελέσεις, το `forge snapshot — diff` συγκρίνει με τη βάση. Μια υποχώρηση gas 20% στο `transfer()` είναι πραγματικό κόστος για κάθε χρήστη — το να το εντοπίσεις πριν από τη συγχώνευση είναι φθηνό.
Εδώ τα πράγματα έγιναν ενδιαφέροντα. Το Gambit(Certora) δημιουργεί μεταλλάγματα: αντίγραφα του `Token.sol` με μικρά σκόπιμα σφάλματα (`+=` σε `-=`, `>=` σε `>`, συνθήκες αντεστραμμένες). Η διαδικασία εκτελεί την πλήρη σουίτα δοκιμών κατά κάθε μετάλλαγμα. Εάν ένα μετάλλαγμα επιβιώσει (όλες οι δοκιμές εξακολουθούν να περνάνε), αυτό είναι ένα συγκεκριμένο κενό δοκιμής.
./scripts/run-qa.sh mutation
Αποτέλεσμα: 97.0% σκορ μετάλλαξης — 32 εξοντώθηκαν, 1 επέζησε από 33 μεταλλάγματα.
Το αρχείο εξόδου του Gambit δείχνει κάθε μετάλλαγμα και τι άλλαξε. Μερικά παραδείγματα:
Δημιουργήθηκε μετάλλαγμα #7: BinaryOpMutation — Token.sol:168
totalSupply = totalSupply.add(amountBalance) → totalSupply = totalSupply.sub(amountBalance)
KILLED από test_Mint_Success
Δημιουργήθηκε μετάλλαγμα #19: RelationalOpMutation — Token.sol:196
if (!fromBalance.gte(amountBalance)) → if (fromBalance.gte(amountBalance))
KILLED από test_Transfer_Success
Δημιουργήθηκε μετάλλαγμα #28: SwapArgumentsMutation — Token.sol:81
return Balance.unwrap(a) > Balance.unwrap(b) → return Balance.unwrap(b) > Balance.unwrap(a)
SURVIVED ← καμία δοκιμή δεν το έπιασεΔοκιμές μετάλλαξης Gambit: 32 εξοντώθηκαν, 1 επέζησε, σκορ μετάλλαξης 97.0%
Το μετάλλαγμα που επέζησε άλλαξε το `a > b` σε `b > a` στο `BalanceLib.gt()`. Καμία δοκιμή δεν το έπιασε επειδή το `gt()` είναι νεκρός κώδικας. Δεν καλείται ποτέ πουθενά στο `Token.sol`.
Η κάλυψη επισήμανε 91.67% συναρτήσεις αλλά δεν μπορούσε να εξηγήσει το κενό. Οι δοκιμές μετάλλαξης το έκαναν: το `gt()` είναι νεκρός κώδικας, τίποτα δεν το καλεί και κανείς δεν θα παρατηρούσε αν ήταν λάθος.
Νεκρός ή απροστάτευτος κώδικας σε έξυπνα συμβόλαια έχει πραγματικό προηγούμενο.
Η συνάρτηση δεν προοριζόταν να καλείται, αλλά κανείς δεν δοκίμασε αυτή την υπόθεση. Το `gt()` μας είναι αβλαβές συγκριτικά, αλλά το μοτίβο είναι το ίδιο: κώδικας που υπάρχει αλλά δεν εκτελείται ποτέ είναι κώδικας που κανείς δεν παρακολουθεί.
Το Halmos(a16z) συλλογίζεται για όλες τις πιθανές εισόδους συμβολικά. Ενώ οι δοκιμές fuzz δειγματίζουν τυχαίες τιμές και ελπίζουν να χτυπήσουν ακραίες περιπτώσεις, το Halmos αποδεικνύει ιδιότητες εξαντλητικά.
./scripts/run-qa.sh halmos
Αποτέλεσμα: 9/9 συμβολικές δοκιμές περνούν — όλες οι ιδιότητες αποδεικνύονται για όλες τις εισόδους.
Επαληθευμένες ιδιότητες:
Επαληθευμένες ιδιότητεςΜια πρακτική σημείωση: Το Halmos 0.3.3 δεν υποστηρίζει `vm.expectRevert()`, οπότε δεν μπορούσα να γράψω δοκιμές επαναφοράς με τον κανονικό τρόπο του Foundry. Η λύση είναι ένα μοτίβο try/catch — εάν η κλήση επιτύχει όταν θα έπρεπε να επαναφερθεί, το `assert(false)` αποτυγχάνει την απόδειξη:
function check_mint_reverts_on_zero_address(uint256 amount) public {
vm.assume(amount > 0);
try token.mint(address(0), amount) {
assert(false); // δεν πρέπει να φτάσει εδώ
} catch {
// αναμενόμενη επαναφορά - το Halmos αποδεικνύει ότι αυτή η διαδρομή λαμβάνεται πάντα
}
}
Όχι το πιο όμορφο, αλλά λειτουργεί — το Halmos εξακολουθεί να αποδεικνύει την ιδιότητα για όλες τις εισόδους. Αυτό είναι το είδος του πράγματος που ανακαλύπτεις μόνο εκτελώντας πραγματικά το εργαλείο.
Για πλαίσιο σχετικά με το γιατί έχει σημασία η επίσημη επαλήθευση:
Η ευπάθεια ήταν στον κώδικα, αναθεωρήσιμη από οποιονδήποτε, αλλά κανένα εργαλείο ή δοκιμή δεν το έπιασε πριν από την ανάπτυξη. Οι συμβολικοί αποδεικτές όπως το Halmos υπάρχουν ακριβώς για να κλείσουν αυτό το κενό — δεν δειγματίζουν· εξαντλούν τον χώρο εισόδου.
Έξοδος Halmos: 9 δοκιμές πέρασαν, 0 απέτυχαν, αποτελέσματα συμβολικών δοκιμώνΤο αρχείο δοκιμής είναι `contracts/test/Token.halmos.t.sol`.
Η αρχιτεκτονική της πρώτης ανάρτησης έχει ένα επίπεδο τομέα TypeScript που αντικατοπτρίζει τη μηχανή κατάστασης on-chain. Αυτή η φάση δοκιμάζει αν τα δύο πράγματι συμφωνούν.
Πρόσθεσα δοκιμές ιδιοτήτων fast-check για το επίπεδο τομέα TypeScript, αντικατοπτρίζοντας αυτό που κάνει το fuzzer του Foundry για τη Solidity:
npm test - tests/unit/property.test.ts
Αποτέλεσμα: 9/9 δοκιμές ιδιοτήτων περνούν μετά τη διόρθωση ενός πραγματικού σφάλματος.
Δοκιμασμένες ιδιότητες:
Το fast-check βρήκε ένα πραγματικό σφάλμα συνέπειας διασταυρούμενου επιπέδου στο `Token.ts` `transfer()`. Το συρρικνωμένο αντιπαράδειγμα ήταν αμέσως σαφές:
Η ιδιότητα απέτυχε μετά από 3 δοκιμές
Συρρίκνωση 2 φορά/φορές
Αντιπαράδειγμα: transfer(from=0xaaa…, to=0xaaa…, amount=1n)
→ from == to (αυτομεταφορά)
→ verifyInvariant() επέστρεψε false
Η αυτομεταφορά (`from == to`) έσπασε το αναλλοίωτο `sum(balances) == totalSupply`. Το `toBalance` διαβάστηκε πριν ενημερωθεί το `fromBalance`, οπότε όταν `from == to`, η παρωχημένη τιμή αντικατέστησε την αφαίρεση:
// Πριν (με σφάλμα)
const fromBalance = this.getBalance(from);
const toBalance = this.getBalance(to); // ← παρωχημένο όταν from == to
this.accounts.set(from.getValue(), fromBalance.subtract(amount));
this.accounts.set(to.getValue(), toBalance.add(amount)); // ← αντικαθιστά την αφαίρεση
Διόρθωση: διάβασε το `toBalance` μετά τη γραφή του `fromBalance`, ταιριάζοντας τη σημασιολογία αποθήκευσης της Solidity:
// Μετά (διορθωμένο)
const fromBalance = this.getBalance(from);
this.accounts.set(from.getValue(), fromBalance.subtract(amount));
const toBalance = this.getBalance(to); // ← τώρα διαβάζει την ενημερωμένη τιμή
this.accounts.set(to.getValue(), toBalance.add(amount));
Το συμβόλαιο Solidity δεν επηρεάστηκε: επαναδιαβάζει την αποθήκευση μετά από κάθε εγγραφή. Αλλά ο καθρέφτης TypeScript είχε μια λεπτή εξάρτηση σειράς που καμία υπάρχουσα δοκιμή μονάδας δεν κάλυπτε.
Αναντιστοιχίες διασταυρούμενων επιπέδων σε μεγαλύτερη κλίμακα έχουν υπάρξει καταστροφικές.
Το σφάλμα αυτομεταφοράς μας δεν θα είχε χάσει χρήματα σε κανέναν, αλλά ο τρόπος αποτυχίας είναι δομικά ο ίδιος: δύο επίπεδα που υποτίθεται ότι συμφωνούν, δεν συμφωνούν.
Η εκτέλεση εργαλείων QA σε ένα υπάρχον έργο δεν είναι ποτέ απλά "εγκατάσταση και εκτέλεση". Μερικά πράγματα έσπασαν πριν λειτουργήσουν:
Όλα εκτελούνται μέσω δύο σεναρίων:
./scripts/run-qa.sh slither gas # μόνο στατική ανάλυση + gas
./scripts/run-qa.sh mutation # μόνο δοκιμές μετάλλαξης
./scripts/run-qa.sh all # όλα
Όχι κάθε έλεγχος είναι γρήγορος. Το Slither και η κάλυψη εκτελούνται σε κάθε commit. Οι δοκιμές μετάλλαξης και το Halmos είναι πιο αργά — πιο κατάλληλα για εβδομαδιαίες ή εκτελέσεις πριν την κυκλοφορία.
Πέντε επίπεδα QA, κάθε ένα πιάνει μια διαφορετική κατηγορία προβλήματος.
Επεξήγηση επιπέδουΤο Gambit και το fast-check έδωσαν τα πιο εφαρμόσιμα αποτελέσματα σε αυτό τον γύρο.
Οι έλεγχοι QA είναι τώρα συνδεδεμένοι στο GitHub Actions ως διοχέτευση έξι σταδίων:
Διοχέτευση CI: Build & Lint διακλαδώνεται σε στάδια Test, Coverage, Gas, Slither και AuditΔιοχέτευση GitHub Actions: Build & Lint περιορίζει όλα τα downstream στάδια.
Επεξήγηση σταδίουΤο Ethereum Account State: QA Pipeline for a Minimal Token δημοσιεύτηκε αρχικά στο Coinmonks στο Medium, όπου οι άνθρωποι συνεχίζουν τη συζήτηση επισημαίνοντας και απαντώντας σε αυτή την ιστορία.


