Postingan sebelumnya membahas implementasi end-to-end: kontrak token minimal, rekonstruksi status off-chain, dan frontend React — dari `mint()` hingga MetaMask. Postingan ini melanjutkan dari situ: bagaimana melakukan QA terhadap sesuatu seperti ini?
Saya bukan insinyur blockchain (belum), tetapi pola QA dapat diterapkan dengan baik di berbagai domain, dan meminjam apa yang sudah berhasil di tempat lain adalah cara tercepat saya untuk belajar.
Kontrak hanya melakukan tiga hal: `mint`, `transfer`, dan `burn`, tetapi bahkan itu sudah cukup untuk mempraktikkan toolchain QA lengkap: analisis statis, mutation testing, profiling gas, verifikasi formal.
Kodenya ada di `egpivo/ethereum-account-state`.
Piramida QA Blockchain: dari analisis statis di dasar hingga verifikasi formal di puncakSebelum menambahkan hal baru, proyek sudah memiliki:
Semua test berhasil. Coverage terlihat baik. Jadi mengapa repot-repot menambahkan lebih banyak?
Karena "semua test berhasil" tidak berarti "semua bug tertangkap." 100% line coverage masih bisa melewatkan bug nyata jika tidak ada assertion yang memeriksa hal yang benar.
Slither(Trail of Bits) menangkap masalah yang tidak terlihat oleh test: reentrancy, nilai return yang tidak diperiksa, ketidakcocokan interface.
./scripts/run-qa.sh slither
Hasil: 1 temuan Medium: `erc20-interface`: `transfer()` tidak mengembalikan `bool`.
Ini sudah diharapkan. Kontrak memang sengaja bukan ERC20 lengkap: ini adalah state machine edukatif. Tetapi temuan ini bukan hanya akademis:
Jika seseorang nanti mengimpor token ini ke dalam protokol yang mengharapkan ERC20, ketidakcocokan interface akan gagal secara diam-diam. Slither menandainya sekarang agar keputusannya disadari.
./scripts/run-qa.sh coverageHasil coverage.
Satu fungsi yang tidak tercakup: `BalanceLib.gt()`. Kita akan kembali ke ini.
output forge coverage: 24 test berhasil, tabel coverage Token.sol./scripts/run-qa.sh gas
Biaya gas baseline untuk tiga operasi:
Gas dalam hal operasiPada eksekusi berikutnya, `forge snapshot — diff` membandingkan dengan baseline. Regresi gas 20% pada `transfer()` adalah biaya nyata untuk setiap pengguna — menangkapnya sebelum merge sangat murah.
Di sinilah hal menjadi menarik. Gambit(Certora) menghasilkan mutan: salinan `Token.sol` dengan bug kecil yang disengaja (`+=` menjadi `-=`, `>=` menjadi `>`, kondisi dinegasikan). Pipeline menjalankan suite test lengkap terhadap setiap mutan. Jika mutan bertahan (semua test masih berhasil), itu adalah celah test konkret.
./scripts/run-qa.sh mutation
Hasil: skor mutation 97,0% — 32 terbunuh, 1 bertahan dari 33 mutan.
Log output Gambit menunjukkan setiap mutan dan apa yang diubahnya. Beberapa contoh:
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 ← tidak ada test yang menangkap iniMutation testing Gambit: 32 terbunuh, 1 bertahan, skor mutation 97,0%
Mutan yang bertahan menukar `a > b` menjadi `b > a` di `BalanceLib.gt()`. Tidak ada test yang menangkapnya karena `gt()` adalah dead code. Tidak pernah dipanggil di mana pun dalam `Token.sol`.
Coverage menandai 91,67% fungsi tetapi tidak bisa menjelaskan celahnya. Mutation testing bisa: `gt()` adalah dead code, tidak ada yang memanggilnya, dan tidak ada yang akan menyadari jika salah.
Dead code atau kode yang tidak terlindungi dalam smart contract memiliki preseden nyata.
Fungsi itu tidak dimaksudkan untuk dapat dipanggil, tetapi tidak ada yang menguji asumsi itu. `gt()` kita tidak berbahaya dibandingkan, tetapi polanya sama: kode yang ada tetapi tidak pernah dijalankan adalah kode yang tidak diawasi siapa pun.
Halmos(a16z) bernalar tentang semua input yang mungkin secara simbolis. Di mana fuzz test mengambil sampel nilai acak dan berharap mencapai edge case, Halmos membuktikan properti secara menyeluruh.
./scripts/run-qa.sh halmos
Hasil: 9/9 symbolic test berhasil — semua properti terbukti untuk semua input.
Properti yang diverifikasi:
Properti yang diverifikasiSatu catatan praktis: Halmos 0.3.3 tidak mendukung `vm.expectRevert()`, jadi saya tidak bisa menulis revert test dengan cara Foundry normal. Solusinya adalah pola try/catch — jika panggilan berhasil padahal seharusnya revert, `assert(false)` gagalkan buktinya:
function check_mint_reverts_on_zero_address(uint256 amount) public {
vm.assume(amount > 0);
try token.mint(address(0), amount) {
assert(false); // seharusnya tidak mencapai di sini
} catch {
// expected revert - Halmos membuktikan jalur ini selalu diambil
}
}
Tidak terlalu cantik, tetapi berhasil — Halmos masih membuktikan properti untuk semua input. Ini jenis hal yang hanya Anda ketahui dengan benar-benar menjalankan tool-nya.
Untuk konteks mengapa verifikasi formal penting:
Kerentanan ada dalam kode, dapat ditinjau oleh siapa pun, tetapi tidak ada tool atau test yang menangkapnya sebelum deployment. Symbolic prover seperti Halmos ada justru untuk menutup celah itu — mereka tidak mengambil sampel; mereka menghabiskan ruang input.
Output Halmos: 9 test berhasil, 0 gagal, hasil symbolic testFile test ada di `contracts/test/Token.halmos.t.sol`.
Arsitektur postingan pertama memiliki layer domain TypeScript yang mencerminkan state machine on-chain. Fase ini menguji apakah keduanya benar-benar setuju.
Saya menambahkan property test fast-check untuk layer domain TypeScript, mencerminkan apa yang dilakukan fuzzer Foundry untuk Solidity:
npm test - tests/unit/property.test.ts
Hasil: 9/9 property test berhasil setelah memperbaiki bug nyata.
Properti yang diuji:
fast-check menemukan bug konsistensi lintas-layer nyata dalam `Token.ts` `transfer()`. Counterexample yang diperkecil langsung jelas:
Property failed after 3 tests
Shrunk 2 time(s)
Counterexample: transfer(from=0xaaa…, to=0xaaa…, amount=1n)
→ from == to (self-transfer)
→ verifyInvariant() returned false
Self-transfer (`from == to`) merusak invarian `sum(balances) == totalSupply`. `toBalance` dibaca sebelum `fromBalance` diperbarui, jadi ketika `from == to`, nilai lama menimpa pengurangan:
// Sebelum (buggy)
const fromBalance = this.getBalance(from);
const toBalance = this.getBalance(to); // ← lama ketika from == to
this.accounts.set(from.getValue(), fromBalance.subtract(amount));
this.accounts.set(to.getValue(), toBalance.add(amount)); // ← menimpa pengurangan
Perbaikan: baca `toBalance` setelah menulis `fromBalance`, sesuai dengan semantik storage Solidity:
// Setelah (diperbaiki)
const fromBalance = this.getBalance(from);
this.accounts.set(from.getValue(), fromBalance.subtract(amount));
const toBalance = this.getBalance(to); // ← sekarang membaca nilai yang diperbarui
this.accounts.set(to.getValue(), toBalance.add(amount));
Kontrak Solidity tidak terpengaruh: ia membaca ulang storage setelah setiap penulisan. Tetapi mirror TypeScript memiliki ketergantungan pengurutan halus yang tidak tercakup oleh unit test yang ada.
Ketidakcocokan lintas-layer pada skala yang lebih besar telah menjadi bencana.
Bug self-transfer kami tidak akan menyebabkan siapa pun kehilangan uang, tetapi mode kegagalannya secara struktural sama: dua layer yang seharusnya setuju, tidak setuju.
Menjalankan tool QA pada proyek yang ada tidak pernah hanya "instal dan jalankan." Beberapa hal rusak sebelum mereka berhasil:
Semuanya berjalan melalui dua script:
./scripts/run-qa.sh slither gas # hanya analisis statis + gas
./scripts/run-qa.sh mutation # hanya mutation testing
./scripts/run-qa.sh all # semuanya
Tidak setiap pemeriksaan cepat. Slither dan coverage berjalan pada setiap commit. Mutation testing dan Halmos lebih lambat — lebih cocok untuk run mingguan atau pra-rilis.
Lima layer QA, masing-masing menangkap kelas masalah yang berbeda.
Penjelasan layerGambit dan fast-check memberikan hasil paling dapat ditindaklanjuti dalam putaran ini.
Pemeriksaan QA sekarang terhubung ke GitHub Actions sebagai pipeline enam tahap:
CI Pipeline: Build & Lint bercabang ke tahap Test, Coverage, Gas, Slither, dan AuditPipeline GitHub Actions: Build & Lint mengontrol semua tahap downstream.
Penjelasan tahapEthereum Account State: QA Pipeline for a Minimal Token awalnya diterbitkan di Coinmonks di Medium, di mana orang-orang melanjutkan percakapan dengan menyoroti dan merespons cerita ini.

