โพสต์ก่อนหน้านี้ได้อธิบายการทำงานแบบ end-to-end: สัญญาโทเค็นขั้นต่ำ, การสร้างสถานะ off-chain ใหม่, และ frontend React — ตั้งแต่ `mint()` ไปจนถึง MetaMask โพสต์นี้จะต่อยอดจากที่ทิ้งไว้: คุณจะทำ QA สิ่งแบบนี้ได้อย่างไร?
ผมไม่ใช่วิศวกรบล็อกเชน (ยัง) แต่รูปแบบ QA สามารถนำไปใช้ได้ดีในหลายๆ โดเมน และการยืมสิ่งที่ใช้ได้ผลจากที่อื่นคือวิธีที่ผมเรียนรู้เร็วที่สุด
สัญญาทำเพียงสามอย่าง: `mint`, `transfer`, และ `burn` แต่นั่นก็เพียงพอที่จะฝึกใช้เครื่องมือ QA ทั้งหมด: static analysis, mutation testing, gas profiling, formal verification
โค้ดอยู่ใน `egpivo/ethereum-account-state`
พีระมิด QA ของบล็อกเชน: จาก static analysis ที่ฐานไปจนถึง formal verification ที่ด้านบนก่อนที่จะเพิ่มสิ่งใหม่ๆ โปรเจกต์มีอยู่แล้ว:
ทุกการทดสอบผ่าน Coverage ดูดี แล้วทำไมต้องทำมากกว่านี้?
เพราะ "การทดสอบทั้งหมดผ่าน" ไม่ได้หมายความว่า "จับบั๊กทั้งหมดได้" 100% line coverage ยังคงพลาดบั๊กจริงได้หากไม่มี assertion ตรวจสอบสิ่งที่ถูกต้อง
Slither(Trail of Bits) จับปัญหาที่มองไม่เห็นในการทดสอบ: reentrancy, unchecked return values, interface mismatches
./scripts/run-qa.sh slither
ผลลัพธ์: 1 Medium finding: `erc20-interface`: `transfer()` ไม่ return `bool`
นี่เป็นสิ่งที่คาดหวัง สัญญาตั้งใจไม่ให้เป็น ERC20 แบบเต็มรูปแบบ: มันเป็น state machine เพื่อการศึกษา แต่การค้นพบนี้ไม่ใช่เพียงทางวิชาการ:
หากมีคนนำโทเค็นนี้ไปใช้ในโปรโตคอลที่คาดหวัง ERC20 ในภายหลัง interface mismatch จะล้มเหลวอย่างเงียบๆ Slither ทำเครื่องหมายไว้ตอนนี้เพื่อให้การตัดสินใจเป็นไปอย่างรู้ตัว
./scripts/run-qa.sh coverageผลลัพธ์ Coverage
ฟังก์ชันหนึ่งที่ไม่ได้ครอบคลุม: `BalanceLib.gt()` เราจะกลับมาที่นี่
ผลลัพธ์ forge coverage: ผ่านการทดสอบ 24 ครั้ง, ตาราง coverage ของ Token.sol./scripts/run-qa.sh gas
ต้นทุน gas พื้นฐานสำหรับการดำเนินการทั้งสาม:
Gas ในแง่ของการดำเนินการในการรันครั้งต่อไป `forge snapshot — diff` เปรียบเทียบกับพื้นฐาน การถดถอย gas 20% ใน `transfer()` เป็นต้นทุนจริงสำหรับผู้ใช้ทุกคน — การจับก่อน merge นั้นถูก
นี่คือจุดที่สิ่งต่างๆ เริ่มน่าสนใจ Gambit(Certora) สร้าง mutants: สำเนาของ `Token.sol` ที่มีบั๊กเล็กๆ ที่ตั้งใจ (`+=` เป็น `-=`, `>=` เป็น `>`, เงื่อนไขถูกกลับ) pipeline รันชุดทดสอบทั้งหมดกับ mutant แต่ละตัว หาก mutant รอด (การทดสอบทั้งหมดยังผ่าน) นั่นคือช่องว่างการทดสอบที่เป็นรูปธรรม
./scripts/run-qa.sh mutation
ผลลัพธ์: 97.0% mutation score — ฆ่า 32, รอด 1 จาก 33 mutants
log ผลลัพธ์ของ Gambit แสดง mutant แต่ละตัวและสิ่งที่เปลี่ยนแปลง ตัวอย่างบางส่วน:
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 ← ไม่มีการทดสอบจับได้Gambit mutation testing: ฆ่า 32, รอด 1, mutation score 97.0%
mutant ที่รอดสลับ `a > b` เป็น `b > a` ใน `BalanceLib.gt()` ไม่มีการทดสอบจับได้เพราะ `gt()` เป็น dead code ไม่มีการเรียกใช้ที่ไหนเลยใน `Token.sol`
Coverage ทำเครื่องหมาย 91.67% functions แต่ไม่สามารถอธิบายช่องว่างได้ Mutation testing ทำได้: `gt()` เป็น dead code ไม่มีอะไรเรียกมัน และไม่มีใครสังเกตเห็นหากมันผิด
โค้ดที่ตายหรือไม่มีการปกป้องในสมาร์ทคอนแทรกต์มีตัวอย่างจริง
ฟังก์ชันนั้นไม่ได้ตั้งใจให้เรียกใช้ได้ แต่ไม่มีใครทดสอบข้อสมมติฐานนั้น `gt()` ของเราไม่เป็นอันตรายเมื่อเทียบ แต่รูปแบบเหมือนกัน: โค้ดที่มีอยู่แต่ไม่เคยถูกใช้งานคือโค้ดที่ไม่มีใครเฝ้าดู
Halmos(a16z) ใช้เหตุผลเกี่ยวกับ input ที่เป็นไปได้ทั้งหมด แบบ symbolic ในขณะที่ fuzz tests สุ่มค่าและหวังว่าจะตี edge cases Halmos พิสูจน์คุณสมบัติอย่างละเอียดถี่ถ้วน
./scripts/run-qa.sh halmos
ผลลัพธ์: 9/9 symbolic tests ผ่าน — พิสูจน์คุณสมบัติทั้งหมดสำหรับ input ทั้งหมด
คุณสมบัติที่ตรวจสอบแล้ว:
คุณสมบัติที่ได้รับการตรวจสอบหมายเหตุปฏิบัติหนึ่ง: Halmos 0.3.3 ไม่รองรับ `vm.expectRevert()` ดังนั้นผมจึงไม่สามารถเขียน revert tests แบบ Foundry ปกติได้ วิธีแก้คือรูปแบบ try/catch — หากการเรียกสำเร็จเมื่อควร revert `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 {
// คาดว่าจะ revert - Halmos พิสูจน์ว่าเส้นทางนี้ถูกเลือกเสมอ
}
}
ไม่สวยงามที่สุด แต่มันใช้งานได้ — Halmos ยังคงพิสูจน์คุณสมบัติสำหรับ input ทั้งหมด นี่คือสิ่งที่คุณจะพบได้ก็ต่อเมื่อรันเครื่องมือจริงๆ
สำหรับบริบทว่าทำไม formal verification ถึงสำคัญ:
ช่องโหว่อยู่ในโค้ด ทุกคนสามารถตรวจสอบได้ แต่ไม่มีเครื่องมือหรือการทดสอบจับได้ก่อนการติดตั้งใช้งาน Symbolic provers เช่น Halmos มีอยู่เพื่อปิดช่องว่างนั้น — พวกมันไม่สุ่มตัวอย่าง พวกมันตรวจสอบ input space อย่างละเอียด
ผลลัพธ์ Halmos: ผ่านการทดสอบ 9 ครั้ง, ล้มเหลว 0, ผลลัพธ์การทดสอบ symbolicไฟล์ทดสอบคือ `contracts/test/Token.halmos.t.sol`
สถาปัตยกรรมของโพสต์แรกมี TypeScript domain layer ที่สะท้อน state machine บน on-chain เฟสนี้ทดสอบว่าทั้งสองตกลงกันจริงหรือไม่
ผมเพิ่ม fast-check property tests สำหรับ TypeScript domain layer สะท้อนสิ่งที่ fuzzer ของ Foundry ทำสำหรับ Solidity:
npm test - tests/unit/property.test.ts
ผลลัพธ์: 9/9 property tests ผ่าน หลังจากแก้ไขบั๊กจริง
คุณสมบัติที่ทดสอบ:
fast-check พบบั๊ก cross-layer consistency จริงใน `Token.ts` `transfer()` ตัวอย่างตรงกันข้ามที่ย่อมาชัดเจนทันที:
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`) ทำลาย invariant `sum(balances) == totalSupply` `toBalance` ถูกอ่านก่อน`fromBalance` ถูกอัปเดต ดังนั้นเมื่อ `from == to` ค่าเก่าเขียนทับการหัก:
// Before (buggy)
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` ให้ตรงกับ storage semantics ของ Solidity:
// After (fixed)
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 ไม่ได้รับผลกระทบ: มันอ่าน storage ใหม่หลังจากแต่ละการเขียน แต่ TypeScript mirror มีการพึ่งพาลำดับที่ละเอียดอ่อนที่ไม่มี unit test ที่มีอยู่ครอบคลุม
Cross-layer mismatches ในขนาดใหญ่กว่าเคยเป็นหายนะ
บั๊ก self-transfer ของเราจะไม่ทำให้ใครเสียเงิน แต่โหมดความล้มเหลวมีโครงสร้างเหมือนกัน: สองชั้นที่ควรตกลงกัน ไม่ตกลงกัน
การรันเครื่องมือ QA บนโปรเจกต์ที่มีอยู่ไม่เคยเป็นแค่ "ติดตั้งและรัน" สิ่งบางอย่างพังก่อนที่จะใช้งานได้:
ทุกอย่างรันผ่านสองสคริปต์:
./scripts/run-qa.sh slither gas # เฉพาะ static analysis + gas
./scripts/run-qa.sh mutation # เฉพาะ mutation testing
./scripts/run-qa.sh all # ทุกอย่าง
ไม่ใช่ทุกการตรวจสอบจะเร็ว Slither และ coverage รันทุกการ commit Mutation testing และ Halmos ช้ากว่า — เหมาะสำหรับการรันรายสัปดาห์หรือก่อนการเผยแพร่มากกว่า
ห้าชั้น QA แต่ละชั้นจับปัญหาประเภทที่ต่างกัน
คำอธิบายชั้นGambit และ fast-check ให้ผลลัพธ์ที่นำไปใช้ได้มากที่สุดในรอบนี้
การตรวจสอบ QA ตอนนี้เชื่อมต่อเข้ากับ GitHub Actions เป็น pipeline หกขั้นตอน:
CI Pipeline: Build & Lint กระจายไปที่ Test, Coverage, Gas, Slither, และ Audit stagesGitHub Actions pipeline: Build & Lint กั้นขั้นตอนทั้งหมดที่อยู่ด้านล่าง
คำอธิบาย StageEthereum Account State: QA Pipeline for a Minimal Token เผยแพร่ครั้งแรกใน Coinmonks บน Medium ซึ่งผู้คนกำลังดำเนินการสนทนาต่อโดยการเน้นและตอบกลับเรื่องราวนี้


