
{"id":148718,"date":"2026-04-09T05:48:44","date_gmt":"2026-04-09T05:48:44","guid":{"rendered":"https:\/\/mycryptomania.com\/?p=148718"},"modified":"2026-04-09T05:48:44","modified_gmt":"2026-04-09T05:48:44","slug":"ethereum-account-state-qa-pipeline-for-a-minimal-token","status":"publish","type":"post","link":"https:\/\/mycryptomania.com\/?p=148718","title":{"rendered":"Ethereum Account State: QA Pipeline for a Minimal Token"},"content":{"rendered":"<p>QA dashboard monitoring smart contract\u00a0state<\/p>\n<p>The <a href=\"https:\/\/medium.com\/block-magnates\/ethereum-account-state-a-minimal-token-with-reconstructible-state-bc3e3c738a08\">previous post<\/a> walked through an end-to-end implementation: a minimal token contract, off-chain state reconstruction, and a React frontend\u200a\u2014\u200aall the way from `<em>mint()<\/em>` to MetaMask. This post picks up where that left off: how do you QA something like\u00a0this?<\/p>\n<p>I\u2019m not a blockchain engineer (yet), but QA patterns port well across domains, and borrowing what already works elsewhere is how I learn\u00a0fastest.<\/p>\n<p>The contract only does three things: `<em>mint<\/em>`, `<em>transfer<\/em>`, and `<em>burn<\/em>`, but even that is enough to practice the full QA toolchain: static analysis, mutation testing, gas profiling, formal verification.<\/p>\n<p>The code is in `<a href=\"https:\/\/github.com\/egpivo\/ethereum-account-state\">egpivo\/ethereum-account-state<\/a>`.<\/p>\n<p>Blockchain QA Pyramid: from static analysis at the base to formal verification at the\u00a0top<\/p>\n<h3><strong>What we started\u00a0with<\/strong><\/h3>\n<p>Before adding anything new, the project already\u00a0had:<\/p>\n<p><strong>21 Foundry unit tests<\/strong> covering each state transition (success, revert on illegal input, event emission)<strong>3 invariant tests<\/strong> via a `<em>TokenHandler<\/em>` that runs random sequences of `mint`\/`transfer`\/`burn` on 10 actors (128k calls\u00a0each)<strong>Fuzz tests<\/strong> checking `<em>sum(balances) == totalSupply<\/em>` for random\u00a0amounts<strong>TypeScript domain tests<\/strong> (<em>Vitest<\/em>) mirroring the on-chain state\u00a0machine<strong>CI<\/strong>: compile, test, lint (<em>Prettier<\/em> +\u00a0<em>solhint<\/em>)<\/p>\n<p>All tests passed. Coverage looked fine. So why bother with\u00a0more?<\/p>\n<p>Because \u201call tests pass\u201d does not mean \u201call bugs are caught.\u201d 100% line coverage can still miss a real bug if no assertion checks the right\u00a0thing.<\/p>\n<h3><strong>Phase 1: Smart contract static analysis and\u00a0coverage<\/strong><\/h3>\n<h4><strong>Slither<\/strong><\/h4>\n<p><a href=\"https:\/\/github.com\/crytic\/slither\">Slither<\/a>(Trail of Bits) catches issues that are invisible to tests: reentrancy, unchecked return values, interface mismatches.<\/p>\n<p>.\/scripts\/run-qa.sh slither<\/p>\n<p>Result: <strong>1 Medium finding: <\/strong>`<em>erc20-interface<\/em>`: `<em>transfer()<\/em>` doesn\u2019t return\u00a0`<em>bool<\/em>`.<\/p>\n<p>This is expected. The contract is intentionally not a full ERC20: it is an educational state machine. But the finding is not academic:<\/p>\n<p>USDT\u2019s `<em>transfer()<\/em>` famously does not return `bool` either, and that non-compliance has caused <a href=\"https:\/\/bugblow.com\/blog\/erc20-integration-hell\">real integration failures<\/a> in DeFi protocols that assumed standard ERC20 behavior.<\/p>\n<p>If someone later imports this token into a protocol expecting ERC20, the interface mismatch would silently fail. Slither flags it now so the decision is conscious.<\/p>\n<h4><strong>Coverage<\/strong><\/h4>\n<p>.\/scripts\/run-qa.sh coverageCoverage result.<\/p>\n<p>One uncovered function: `<em>BalanceLib.gt()<\/em>`. We will come back to\u00a0this.<\/p>\n<p>forge coverage output: 24 tests passed, Token.sol coverage\u00a0table<\/p>\n<h4><strong>Gas snapshots<\/strong><\/h4>\n<p>.\/scripts\/run-qa.sh gas<\/p>\n<p>Baseline gas costs for the three operations:<\/p>\n<p>Gas in terms of operations<\/p>\n<p>On subsequent runs, `<em>forge snapshot\u200a\u2014\u200adiff<\/em>` compares against the baseline. A 20% gas regression in `<em>transfer()<\/em>` is a real cost to every user\u200a\u2014\u200acatching it before merge is\u00a0cheap.<\/p>\n<h3><strong>Phase 2: Mutation testing and formal verification<\/strong><\/h3>\n<h4><strong>Mutation testing\u00a0(Gambit)<\/strong><\/h4>\n<p>This is where things got interesting. <a href=\"https:\/\/github.com\/Certora\/gambit\">Gambit<\/a>(Certora) generates <strong><em>mutants<\/em><\/strong><em>: <\/em>copies of `<em>Token.sol<\/em>` with small deliberate bugs (`<em>+=<\/em>` to <em>`-=<\/em>`, `<em>&gt;=<\/em>` to `<em>&gt;<\/em>`, conditions negated). The pipeline runs the full test suite against each mutant. If a mutant survives (all tests still pass), that is a concrete test\u00a0gap.<\/p>\n<p>.\/scripts\/run-qa.sh mutation<\/p>\n<p>Result: <strong>97.0% mutation score\u200a\u2014\u200a<\/strong>32 killed, 1 survived out of 33\u00a0mutants.<\/p>\n<p>Gambit\u2019s output log shows each mutant and what it changed. A few examples:<\/p>\n<p>Generated mutant #7: BinaryOpMutation \u2014 Token.sol:168<br \/>  totalSupply = totalSupply.add(amountBalance)  \u2192  totalSupply = totalSupply.sub(amountBalance)<br \/>  KILLED by test_Mint_Success<\/p>\n<p>Generated mutant #19: RelationalOpMutation \u2014 Token.sol:196<br \/>  if (!fromBalance.gte(amountBalance))  \u2192  if (fromBalance.gte(amountBalance))<br \/>  KILLED by test_Transfer_Success<\/p>\n<p>Generated mutant #28: SwapArgumentsMutation \u2014 Token.sol:81<br \/>  return Balance.unwrap(a) &gt; Balance.unwrap(b)  \u2192  return Balance.unwrap(b) &gt; Balance.unwrap(a)<br \/>  SURVIVED \u2190 no test caught thisGambit mutation testing: 32 killed, 1 survived, mutation score\u00a097.0%<\/p>\n<p>The surviving mutant swapped `<em>a &gt; b<\/em>` to `<em>b &gt; a<\/em>` in `<em>BalanceLib.gt()<\/em>`. No test caught it because `<em>gt()<\/em>` is <strong>dead code<\/strong>. It is never called anywhere in `<em>Token.sol<\/em>`.<\/p>\n<p>Coverage flagged 91.67% functions but could not explain the gap. Mutation testing did: `<em>gt()<\/em>` is dead code, nothing calls it, and nobody would notice if it were\u00a0wrong.<\/p>\n<p>Dead or unprotected code in smart contracts has real precedent.<\/p>\n<p>In 2017, an unprotected `<em>initWallet()<\/em>` function in the Parity multisig library was called by an outside user, who then triggered `<em>kill()<\/em>`\u200a\u2014\u200a<a href=\"https:\/\/threatpost.com\/hundreds-of-millions-in-digital-currency-remains-frozen\/128821\/\">permanently freezing over $150M<\/a> across 500+\u00a0wallets.<\/p>\n<p>The function was not intended to be callable, but nobody tested that assumption. Our `<em>gt()<\/em>` is harmless by comparison, but the pattern is the same: code that exists but is never exercised is code that nobody is watching.<\/p>\n<h4><strong>Formal verification (Halmos)<\/strong><\/h4>\n<p><a href=\"https:\/\/github.com\/a16z\/halmos\">Halmos<\/a>(a16z) reasons about <em>all possible inputs<\/em> symbolically. Where fuzz tests sample random values and hope to hit edge cases, Halmos proves properties exhaustively.<\/p>\n<p>.\/scripts\/run-qa.sh halmos<\/p>\n<p>Result: <strong>9\/9 symbolic tests pass<\/strong>\u200a\u2014\u200aall properties proven for all\u00a0inputs.<\/p>\n<p>Properties verified:<\/p>\n<p>Verified properties<\/p>\n<p>One practical note: Halmos 0.3.3 does not support `<em>vm.expectRevert()<\/em>`, so I could not write revert tests the normal Foundry way. The workaround is a try\/catch pattern\u200a\u2014\u200aif the call succeeds when it should revert, `<em>assert(false)<\/em>` fails the\u00a0proof:<\/p>\n<p>function check_mint_reverts_on_zero_address(uint256 amount) public {<br \/>    vm.assume(amount &gt; 0);<br \/>    try token.mint(address(0), amount) {<br \/>        assert(false); \/\/ should not reach here<br \/>    } catch {<br \/>        \/\/ expected revert &#8211; Halmos proves this path is always taken<br \/>    }<br \/>}<\/p>\n<p>Not the prettiest, but it works\u200a\u2014\u200aHalmos still proves the property for all inputs. This is the kind of thing you only find out by actually running the\u00a0tool.<\/p>\n<p>For context on why formal verification matters:<\/p>\n<p>the 2016 DAO hack exploited a reentrancy pattern that <a href=\"https:\/\/www.nadcab.com\/blog\/famous-smart-contract-hacks-complete-guide)\">drained ~$60M and led to Ethereum\u2019s hard\u00a0fork<\/a>.<\/p>\n<p>The vulnerability was in the code, reviewable by anyone, but no tool or test caught it before deployment. Symbolic provers like Halmos exist precisely to close that gap\u200a\u2014\u200athey do not sample; they exhaust the input\u00a0space.<\/p>\n<p>Halmos output: 9 tests passed, 0 failed, symbolic test\u00a0results<\/p>\n<p>The test file is `<a href=\"https:\/\/github.com\/egpivo\/ethereum-account-state\/blob\/main\/contracts\/test\/Token.halmos.t.sol\">contracts\/test\/Token.halmos.t.sol<\/a>`.<\/p>\n<h3><strong>Phase 3: Cross-layer property\u00a0testing<\/strong><\/h3>\n<p>The first post\u2019s architecture has a TypeScript domain layer that mirrors the on-chain state machine. This phase tests whether the two actually\u00a0agree.<\/p>\n<h4><strong>Property-based testing with fast-check<\/strong><\/h4>\n<p>I added <a href=\"https:\/\/github.com\/dubzzz\/fast-check\">fast-check<\/a> property tests for the TypeScript domain layer, mirroring what Foundry\u2019s fuzzer does for Solidity:<\/p>\n<p>npm test &#8211; tests\/unit\/property.test.ts<\/p>\n<p>Result: <strong>9\/9 property tests pass<\/strong> after fixing a real\u00a0bug.<\/p>\n<p>Properties tested:<\/p>\n<p>`<em>Balance<\/em>`: commutativity, associativity, identity, inverse, comparison consistency`<em>Token<\/em>`: invariant `<em>sum(balances) == totalSupply<\/em>` under random operation sequences (200 runs, 50 ops\u00a0each)`<em>Token<\/em>`: `<em>totalSupply<\/em>` non-negative after random sequences`<em>mint<\/em>` always succeeds for valid\u00a0inputs`<em>transfer<\/em>` preserves `<em>totalSupply<\/em>`<\/p>\n<h4><strong>The bug fast-check found<\/strong><\/h4>\n<p>fast-check found a real cross-layer consistency bug in `<em>Token.ts<\/em>` `<em>transfer()<\/em>`. The shrunk counterexample was immediately clear:<\/p>\n<p>Property failed after 3 tests<br \/>Shrunk 2 time(s)<br \/>Counterexample: transfer(from=0xaaa\u2026, to=0xaaa\u2026, amount=1n)<br \/>    \u2192 from == to (self-transfer)<br \/>    \u2192 verifyInvariant() returned false<\/p>\n<p>Self-transfer (`<em>from == to<\/em>`) broke the `<em>sum(balances) == totalSupply<\/em>` invariant. `<em>toBalance<\/em>` was read <em>before<\/em> `<em>fromBalance<\/em>` was updated, so when `<em>from == to<\/em>`, the stale value overwrote the deduction:<\/p>\n<p>\/\/ Before (buggy)<br \/>const fromBalance = this.getBalance(from);<br \/>const toBalance = this.getBalance(to); \/\/ \u2190 stale when from == to<br \/>this.accounts.set(from.getValue(), fromBalance.subtract(amount));<br \/>this.accounts.set(to.getValue(), toBalance.add(amount)); \/\/ \u2190 overwrites the subtraction<\/p>\n<p>Fix: read `<em>toBalance<\/em>` after writing `<em>fromBalance<\/em>`, matching Solidity\u2019s storage semantics:<\/p>\n<p>\/\/ After (fixed)<br \/>const fromBalance = this.getBalance(from);<br \/>this.accounts.set(from.getValue(), fromBalance.subtract(amount));<br \/>const toBalance = this.getBalance(to); \/\/ \u2190 now reads updated value<br \/>this.accounts.set(to.getValue(), toBalance.add(amount));<\/p>\n<p>The Solidity contract was <strong>not<\/strong> affected: it re-reads storage after each write. But the TypeScript mirror had a subtle ordering dependency that no existing unit test\u00a0covered.<\/p>\n<p>Cross-layer mismatches at larger scale have been catastrophic.<\/p>\n<p>The 2022 <a href=\"https:\/\/techcrunch.com\/2023\/07\/27\/wormhole-new-security-320m-hack\/\">Wormhole bridge hack ($320M)<\/a> exploited a gap between off-chain guardian validation and on-chain verification\u200a\u2014\u200athe two layers disagreed on what constituted a valid signature, and the attacker walked through the\u00a0gap.<\/p>\n<p>Our self-transfer bug would not have lost anyone money, but the failure mode is structurally the same: two layers that are supposed to agree,\u00a0don\u2019t.<\/p>\n<h3><strong>Pitfalls hit along the\u00a0way<\/strong><\/h3>\n<p>Running QA tools on an existing project is never just \u201cinstall and run.\u201d A few things broke before they\u00a0worked:<\/p>\n<p><strong>0% coverage because `<em>foundry.toml<\/em>` had no test path<\/strong>: The first `<em>forge coverage<\/em>` run returned 0% across the board. Turns out `<em>foundry.toml<\/em>` did not specify `<em>test = \u201ccontracts\/test<\/em>\u201d` or `<em>script = \u201ccontracts\/script<\/em>\u201d`, so Forge was not discovering any tests. The coverage command succeeded silently\u200a\u2014\u200ait just had nothing to cover. This was the most misleading failure: a green run with no useful\u00a0output.<strong>`<em>InvariantTest<\/em>` import gone in forge-std v1.14.0<\/strong>: `<em>Invariant.t.sol<\/em>` imported `<em>InvariantTest<\/em>` from `<em>forge-std<\/em>`, which was removed in a recent release. Compilation failed with an opaque \u201csymbol not found\u201d error. The fix was to drop the import\u200a\u2014\u200a`Test` alone is sufficient for Foundry\u2019s invariant testing\u00a0now.<strong>`<em>uint256(token.totalSupply())<\/em>` vs `<em>Balance.unwrap()<\/em>`<\/strong>: Tests were using an explicit cast to extract the underlying `<em>uint256<\/em>` from the user-defined `<em>Balance<\/em>` type. It compiled, but it is the wrong idiom\u200a\u2014\u200a`<em>Balance.unwrap(token.totalSupply())<\/em>` is what the UDVT system is designed for. Applied across `<em>Token.t.sol<\/em>`, `<em>Invariant.t.sol<\/em>`, and `<em>DeploySepolia.s.sol<\/em>`.<\/p>\n<h3><strong>Pipeline design<\/strong><\/h3>\n<p>Everything runs through two\u00a0scripts:<\/p>\n<p><a href=\"https:\/\/github.com\/egpivo\/ethereum-account-state\/blob\/main\/scripts\/setup-qa-tools.sh\">scripts\/setup-qa-tools.sh<\/a>`: installs Slither, Halmos, Gambit (idempotent)`<a href=\"https:\/\/github.com\/egpivo\/ethereum-account-state\/blob\/main\/scripts\/run-qa.sh\">scripts\/run-qa.sh<\/a>`: runs checks, saves timestamped results to `<em>qa-results\/<\/em>`.\/scripts\/run-qa.sh slither gas # just static analysis + gas<br \/>.\/scripts\/run-qa.sh mutation # just mutation testing<br \/>.\/scripts\/run-qa.sh all # everything<\/p>\n<p>Not every check is fast. Slither and coverage run on every commit. Mutation testing and Halmos are slower\u200a\u2014\u200abetter suited for weekly or pre-release runs.<\/p>\n<h3><strong>Summary<\/strong><\/h3>\n<p>Blockchain QA Toolchain: what each layer catches\u200a\u2014\u200afrom static analysis to cross-layer property\u00a0testing<\/p>\n<p>Five QA layers, each catching a different class of\u00a0problem.<\/p>\n<p>Layer explanation<\/p>\n<p>Gambit and fast-check gave the most actionable results in this\u00a0round.<\/p>\n<h3><strong>CI pipeline<\/strong><\/h3>\n<p>The QA checks are now wired into GitHub Actions as a six-stage pipeline:<\/p>\n<p>CI Pipeline: Build &amp; Lint fans out to Test, Coverage, Gas, Slither, and Audit\u00a0stages<\/p>\n<p>GitHub Actions pipeline: Build &amp; Lint gates all downstream stages.<\/p>\n<p>Stage explanation<\/p>\n<h3><strong>References<\/strong><\/h3>\n<p>Ethereum Account State source: [github.com\/egpivo\/ethereum-account-state](https:\/\/github.com\/egpivo\/ethereum-account-state)<a href=\"https:\/\/medium.com\/block-magnates\/ethereum-account-state-a-minimal-token-with-reconstructible-state-bc3e3c738a08\">Previous post: Ethereum Account\u00a0State<\/a>Slither: <a href=\"https:\/\/github.com\/crytic\/slither\">github.com\/crytic\/slither<\/a>Gambit: <a href=\"https:\/\/github.com\/Certora\/gambit\">github.com\/Certora\/gambit<\/a>Halmos: <a href=\"https:\/\/github.com\/a16z\/halmos\">github.com\/a16z\/halmos<\/a>fast-check: <a href=\"https:\/\/github.com\/dubzzz\/fast-check\">github.com\/dubzzz\/fast-check<\/a>Foundry: <a href=\"https:\/\/getfoundry.sh\/\">getfoundry.sh<\/a><\/p>\n<h3>Notes<\/h3>\n<p>This post is adapted from my original blog\u00a0<a href=\"https:\/\/egpivo.github.io\/2026\/04\/08\/ethereum-account-state-qa.html\">post<\/a>.<\/p>\n<p><a href=\"https:\/\/medium.com\/coinmonks\/ethereum-account-state-qa-pipeline-for-a-minimal-token-2df60517e707\">Ethereum Account State: QA Pipeline for a Minimal Token<\/a> was originally published in <a href=\"https:\/\/medium.com\/coinmonks\">Coinmonks<\/a> on Medium, where people are continuing the conversation by highlighting and responding to this story.<\/p>","protected":false},"excerpt":{"rendered":"<p>QA dashboard monitoring smart contract\u00a0state The previous post walked through an end-to-end implementation: a minimal token contract, off-chain state reconstruction, and a React frontend\u200a\u2014\u200aall the way from `mint()` to MetaMask. This post picks up where that left off: how do you QA something like\u00a0this? I\u2019m not a blockchain engineer (yet), but QA patterns port well [&hellip;]<\/p>\n","protected":false},"author":0,"featured_media":148719,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[2],"tags":[],"class_list":["post-148718","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-interesting"],"_links":{"self":[{"href":"https:\/\/mycryptomania.com\/index.php?rest_route=\/wp\/v2\/posts\/148718"}],"collection":[{"href":"https:\/\/mycryptomania.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/mycryptomania.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"replies":[{"embeddable":true,"href":"https:\/\/mycryptomania.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=148718"}],"version-history":[{"count":0,"href":"https:\/\/mycryptomania.com\/index.php?rest_route=\/wp\/v2\/posts\/148718\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/mycryptomania.com\/index.php?rest_route=\/wp\/v2\/media\/148719"}],"wp:attachment":[{"href":"https:\/\/mycryptomania.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=148718"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/mycryptomania.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=148718"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/mycryptomania.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=148718"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}