QA 儀表板監控智能合約 狀態 上一篇文章逐步介紹了端到端的實作:一個最小化的代幣合約、鏈下狀態重建QA 儀表板監控智能合約 狀態 上一篇文章逐步介紹了端到端的實作:一個最小化的代幣合約、鏈下狀態重建

Ethereum 帳戶狀態:最小化代幣的 QA 流程

2026/04/09 13:48
閱讀時長 13 分鐘
如需對本內容提供反饋或相關疑問,請通過郵箱 crypto.news@mexc.com 聯絡我們。
QA 儀表板監控智能合約狀態

上一篇文章介紹了端到端的實作:一個最小的代幣合約、鏈下狀態重建以及 React 前端——從 `mint()` 到 MetaMask 的完整流程。本文接續上文:如何對這樣的項目進行品質保證(QA)?

我還不是區塊鏈工程師,但 QA 模式在不同領域間具有良好的可移植性,借鑑已在其他領域證明有效的方法是我最快的學習方式。

這個合約只做三件事:`mint`、`transfer` 和 `burn`,但這已足以實踐完整的 QA 工具鏈:靜態分析、突變測試、Gas 分析、形式化驗證。

程式碼位於 `egpivo/ethereum-account-state`。

區塊鏈 QA 金字塔:從底層的靜態分析到頂層的形式化驗證

初始狀態

在新增任何內容之前,專案已經具備:

  • 21 個 Foundry 單元測試涵蓋每個狀態轉換(成功、非法輸入時回退、事件發出)
  • 3 個不變量測試透過 `TokenHandler` 在 10 個參與者上執行 `mint`/`transfer`/`burn` 的隨機序列(每個 128k 次呼叫)
  • 模糊測試檢查隨機金額的 `sum(balances) == totalSupply`
  • TypeScript 領域測試(Vitest)鏡像鏈上狀態機
  • CI:編譯、測試、程式碼檢查(Prettier + solhint)

所有測試都通過了。覆蓋率看起來也不錯。那為什麼還要做更多?

因為「所有測試通過」並不意味著「捕獲了所有錯誤」。100% 的行覆蓋率仍然可能遺漏真正的錯誤,如果沒有斷言檢查正確的內容。

階段 1:智能合約靜態分析和覆蓋率

Slither

Slither(Trail of Bits)捕獲測試無法發現的問題:重入、未檢查的返回值、介面不匹配。

./scripts/run-qa.sh slither

結果:1 個中等級別發現:`erc20-interface`:`transfer()` 沒有返回 `bool`。

這是預期的。該合約刻意不是完整的 ERC20:它是一個教學用的狀態機。但這個發現並非學術性的:

如果之後有人將此代幣導入期望 ERC20 的協議,介面不匹配會靜默失敗。Slither 現在標記出來,使決策是有意識的。

覆蓋率

./scripts/run-qa.sh coverage覆蓋率結果。

一個未覆蓋的函數:`BalanceLib.gt()`。我們稍後會回到這一點。

forge coverage 輸出:24 個測試通過,Token.sol 覆蓋率表

Gas 快照

./scripts/run-qa.sh gas

三個操作的基準 Gas 成本:

操作的 Gas 成本

在後續運行中,`forge snapshot — diff` 與基準進行比較。`transfer()` 中 20% 的 Gas 回歸對每個用戶都是真實成本——在合併前捕獲它的成本很低。

階段 2:突變測試和形式化驗證

突變測試(Gambit)

這裡事情變得有趣了。Gambit(Certora)生成突變體:`Token.sol` 的副本,其中包含小的刻意錯誤(`+=` 改為 `-=`,`>=` 改為 `>`,條件取反)。管道對每個突變體運行完整的測試套件。如果突變體存活(所有測試仍然通過),那就是一個具體的測試缺口。

./scripts/run-qa.sh mutation

結果:97.0% 突變分數——33 個突變體中 32 個被殺死,1 個存活。

Gambit 的輸出日誌顯示每個突變體及其更改。幾個例子:

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 ← no test caught thisGambit 突變測試:32 個被殺死,1 個存活,突變分數 97.0%

存活的突變體將 `BalanceLib.gt()` 中的 `a > b` 交換為 `b > a`。沒有測試捕獲它,因為 `gt()` 是死碼。它在 `Token.sol` 中從未被呼叫。

覆蓋率標記出 91.67% 的函數,但無法解釋差距。突變測試做到了:`gt()` 是死碼,沒有任何東西呼叫它,即使它是錯的也不會有人注意到。

智能合約中的死碼或未受保護的程式碼有真實的先例。

該函數並非有意可呼叫,但沒有人測試這個假設。相比之下,我們的 `gt()` 是無害的,但模式是相同的:存在但從未執行的程式碼是沒有人監視的程式碼。

形式化驗證(Halmos)

Halmos(a16z)以符號方式推理所有可能的輸入。模糊測試採樣隨機值並希望命中邊界情況,而 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); // should not reach here
} catch {
// expected revert - Halmos proves this path is always taken
}
}

不是最優雅的,但它有效——Halmos 仍然為所有輸入證明該屬性。這是只有實際運行工具才能發現的事情。

關於形式化驗證為何重要的背景:

該漏洞在程式碼中,任何人都可以審查,但在部署前沒有工具或測試捕獲到它。像 Halmos 這樣的符號證明器正是為了彌補這一差距而存在的——它們不採樣;它們窮盡輸入空間。

Halmos 輸出:9 個測試通過,0 個失敗,符號測試結果

測試檔案是 `contracts/test/Token.halmos.t.sol`。

階段 3:跨層屬性測試

第一篇文章的架構有一個 TypeScript 領域層,鏡像鏈上狀態機。此階段測試兩者是否真正一致。

使用 fast-check 進行基於屬性的測試

我為 TypeScript 領域層添加了 fast-check 屬性測試,鏡像 Foundry 的模糊器對 Solidity 所做的:

npm test - tests/unit/property.test.ts

結果:修復一個真實錯誤後,9/9 屬性測試通過

測試的屬性:

  • `Balance`:交換律、結合律、單位元、逆元、比較一致性
  • `Token`:隨機操作序列下的不變量 `sum(balances) == totalSupply`(200 次運行,每次 50 個操作)
  • `Token`:隨機序列後 `totalSupply` 非負
  • `mint` 對有效輸入總是成功
  • `transfer` 保持 `totalSupply`

fast-check 發現的錯誤

fast-check 在 `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

自我轉帳(`from == to`)破壞了 `sum(balances) == totalSupply` 不變量。`toBalance` 在 `fromBalance` 更新之前被讀取,因此當 `from == to` 時,陳舊值覆蓋了扣除:

// Before (buggy)
const fromBalance = this.getBalance(from);
const toBalance = this.getBalance(to); // ← stale when from == to
this.accounts.set(from.getValue(), fromBalance.subtract(amount));
this.accounts.set(to.getValue(), toBalance.add(amount)); // ← overwrites the subtraction

修復:在寫入 `fromBalance` 後讀取 `toBalance`,匹配 Solidity 的儲存語義:

// After (fixed)
const fromBalance = this.getBalance(from);
this.accounts.set(from.getValue(), fromBalance.subtract(amount));
const toBalance = this.getBalance(to); // ← now reads updated value
this.accounts.set(to.getValue(), toBalance.add(amount));

Solidity 合約沒有受到影響:它在每次寫入後重新讀取儲存。但 TypeScript 鏡像有一個微妙的排序依賴,現有的單元測試都沒有覆蓋。

更大規模的跨層不匹配曾經是災難性的。

我們的自我轉帳錯誤不會讓任何人損失金錢,但失敗模式在結構上是相同的:兩個應該一致的層,卻不一致。

過程中遇到的陷阱

在現有專案上運行 QA 工具從來不只是「安裝並運行」。在它們工作之前,有幾件事出了問題:

  • 0% 覆蓋率,因為 `foundry.toml` 沒有測試路徑:第一次 `forge coverage` 運行返回全面 0%。結果發現 `foundry.toml` 沒有指定 `test = "contracts/test"` 或 `script = "contracts/script"`,所以 Forge 沒有發現任何測試。覆蓋率命令靜默成功——它只是沒有任何東西可覆蓋。這是最具誤導性的失敗:綠色運行卻沒有有用的輸出。
  • `InvariantTest` 導入在 forge-std v1.14.0 中消失:`Invariant.t.sol` 從 `forge-std` 導入 `InvariantTest`,該導入在最近版本中被移除。編譯失敗,出現晦澀的「找不到符號」錯誤。修復方法是刪除導入——現在單獨的 `Test` 就足以進行 Foundry 的不變量測試。
  • `uint256(token.totalSupply())` vs `Balance.unwrap()`:測試使用顯式轉換從使用者定義的 `Balance` 類型中提取底層的 `uint256`。它可以編譯,但這是錯誤的習慣——`Balance.unwrap(token.totalSupply())` 才是 UDVT 系統設計的方式。應用於 `Token.t.sol`、`Invariant.t.sol` 和 `DeploySepolia.s.sol`。

管道設計

所有操作都透過兩個腳本運行:

  • scripts/setup-qa-tools.sh`:安裝 Slither、Halmos、Gambit(冪等)
  • `scripts/run-qa.sh`:運行檢查,將帶時間戳的結果儲存到 `qa-results/`

./scripts/run-qa.sh slither gas # just static analysis + gas
./scripts/run-qa.sh mutation # just mutation testing
./scripts/run-qa.sh all # everything

並非每個檢查都很快。Slither 和覆蓋率在每次提交時運行。突變測試和 Halmos 較慢——更適合每週或預發布運行。

總結

區塊鏈 QA 工具鏈:每一層捕獲什麼——從靜態分析到跨層屬性測試

五個 QA 層,每個捕獲不同類別的問題。

層說明

Gambit 和 fast-check 在本輪中給出了最可行的結果。

CI 管道

QA 檢查現在作為六階段管道連接到 GitHub Actions:

CI 管道:Build & Lint 展開到 Test、Coverage、Gas、Slither 和 Audit 階段

GitHub Actions 管道:Build & Lint 控制所有下游階段。

階段說明

參考資料

  • Ethereum Account State 原始碼:[github.com/egpivo/ethereum-account-state](https://github.com/egpivo/ethereum-account-state)
  • 上一篇文章:Ethereum Account State
  • Slither:github.com/crytic/slither
  • Gambit:github.com/Certora/gambit
  • Halmos:github.com/a16z/halmos
  • fast-check:github.com/dubzzz/fast-check
  • Foundry:getfoundry.sh

備註

  • 本文改編自我的原始部落格文章。

Ethereum Account State: QA Pipeline for a Minimal Token 最初發表於 Medium 上的 Coinmonks,人們透過重點標示和回應這個故事來繼續對話。

免責聲明: 本網站轉載的文章均來源於公開平台,僅供參考。這些文章不代表 MEXC 的觀點或意見。所有版權歸原作者所有。如果您認為任何轉載文章侵犯了第三方權利,請聯絡 crypto.news@mexc.com 以便將其刪除。MEXC 不對轉載文章的及時性、準確性或完整性作出任何陳述或保證,並且不對基於此類內容所採取的任何行動或決定承擔責任。轉載材料僅供參考,不構成任何商業、金融、法律和/或稅務決策的建議、認可或依據。

$30,000 等值 PRL + 15,000 USDT

$30,000 等值 PRL + 15,000 USDT$30,000 等值 PRL + 15,000 USDT

充值並交易 PRL,即可提升您的獎勵!