FABRKNT
Foundry を極める — Rust 規律を Solidity へ移す
Test discipline
レッスン 4 / 7·CONTENT40 分80 XP
コース
Foundry を極める — Rust 規律を Solidity へ移す
レッスンの役割
CONTENT
順序
4 / 7

レッスン3 — forge invariant — Handler パターンによる multi-call invariant testing

問い

forge fuzz は 1 つの呼び出しに対する property を証明する。forge invariant は呼び出しの に対する property を証明するHandler contract がランダムな call sequence を生成、各 step で保存則を検査。Liquidation L13 scanner proptest(per-scan 保存則)と同パターン。Handler + targetContract + invariant 関数の 3 要素はどう連携するか?

原理(最小モデル)

  • forge invariant の構造.Handler contract が public な mutation 関数(fuzz-tested op)を expose、② setUp()targetContract(handler) 登録、③ invariant_* 関数が各 step 後の保存則を assert、④ runner がランダムな call sequence を runs * depth 回回す。
  • Fuzz と Invariant の違い. Fuzz = 1 call の property(state → state')/ Invariant = call 列の property(state → state' → state'' → ...、各 step で invariant 成立)。Liquidation L9 withdraw_amount_plus_unfilled_equals_shortfall は fuzz、L13 scanner per-scan 保存則は invariant。
  • Handler パターンの役割. Contract への entry point を制御、vm.assume で前提条件 filter、bookkeeping(ghost 変数)で sequence 全体の不変量を track、無効な call を生成しないように guard。
  • targetContract(addr) / targetSelector / targetSender. ファザーが呼ぶ surface を制御、デフォルトは contract 全体、絞ると意味ある call のみ生成。excludeSender / excludeContract も併用。
  • invariant.runsinvariant.depth. runs = call sequence 数、depth = 1 sequence あたりの call 数。Default runs = 256, depth = 15、production CI 1K runs × 50 depth、nightly 10K × 100。
  • Ghost 変数 (uint256 totalDeposited). Handler 内で各 mutation の累積値を track、invariant 関数で contract.balanceOf() == totalDeposited のような保存則を assert。Rust の proptest! 内ローカル counter と同パターン。
  • Bound input. Handler 内で amount = bound(amount, 1, 1e18) で fuzz input を 意味ある range に clamp、vm.assume より bound のほうが「reject せず変換」で生産的。

具体例 + ステップで組み立てる

レッスン 3 — forge invariant — Handler パターンによる multi-call invariant testing

ゴール

このレッスンで掴む概念:

  • forge invariant は、fuzz testing を 1 call から call の 系列 へと格上げする。 forge fuzz (レッスン2) は各 iteration で 1 つの関数をランダム parameter で呼び、property を assert する。forge invariant は method call の ランダム系列increment, increment, setNumber(0), increment, increment — を生成し、系列の各 step 後に すべての invariant_* 関数を再 check する。これが、特定の 順序 で初めて顔を出すバグを catch する。token-balance reentrancy、withdraw-during-deposit のレース、1 call は survive するが 2 call 後に壊れる ghost-state divergence。Single-call の系譜ではこれらは見えない。レッスン 2 は単一入力で存在するバグを見つけた。レッスン 3 は履歴を必要とするバグを見つける。
  • Handler は、target contract に対する「test 制御の API surface area」ラッパーだ。 forge invariant を target contract に直接向けることは普通しない。代わりに Handler contract に向ける。Handler は target の method を wrap し、入力を bound し(例: bound(amount, 1, target.balance()))、ghost variable(invariant が比較するための mirror state)を track する public method を一握り公開する。Foundry はその Handler の method をランダムに呼ぶのであって、target の method を直接呼ぶのではない。一見、冗長な手続き(Ceremony)に思えるが、これが load-bearing な問題を解く。target contract の大半は「ランダム parameter だと precondition を即座に violate する」method を持つ(withdraw(uint256)uint256 > balance を渡すなど)。直接 fuzz すると iteration の大半が vm.assume rejection で無駄になる。Handler は入力を意味ある range に clip するので、iteration の 100% が target を exercise する。Handler なしの forge invariant は iteration の大半をナンセンス拒否で消費する。Handler ありなら、各 iteration が真の adversary move になる。
  • invariant_* 関数は、系列の各 call 後に 成立すべき保存則(Conservation law)を名指す。 test_ / testFuzz_ と同じ prefix 規律で invariant_ を使う。Body は何が起きようと成立するべき equality か bound を assert する。古典的な例は balance + sum_of_withdrawals == sum_of_deposits。どんな deposit/withdraw 系列でも成立する保存則だ。これは openhl-liquidation レッスン13の before + deposits - withdrawals == after per-scan proptest と まったく同じ 形状。invariant_* は Rust で使った保存則規律の Solidity bindings。構文は assertEq(handler.ghostSum(), target.actualBalance())
  • Invariant が失敗すると、反例(Counterexample)は単一入力ではなく call 系列全体になる。 forge fuzz の counterexample は args=[5]forge invariantdeposit(100), withdraw(50), increment(), withdraw(75) という trace を報告し、どの call がどの invariant を壊したかを教える。Shrinker は 系列を reduce する。load-bearing でない call を drop し、残る引数値を半分にし、invariant をまだ違反する最小長・最小値の call 系列に到達するまで続ける。200-call 反例が 3 calls まで縮む。それなら debug できる。Sequence shrinking がなければ、invariant testing は読めない失敗を吐き出すだけだ。

確認:

forge test --match-test invariant

…で新しい invariant suite が走り、(runs: <N>, calls: <M>, reverts: <R>) を報告する。本レッスン完走後はこれらを手にする。Counter を wrap する Handler、何千ものランダム call 系列にわたって成立する invariant_NumberEqualsIncrementCount、そして意図的に壊して call-sequence 反例を観察した経験。

具体的な変更:

  • foundry.toml[invariant] profile セクションを追加し、runsdepth(run あたりの call 数)、fail_on_revert を設定する。
  • test/CounterHandler.sol — 新規ファイル。wrappedIncrement() と(後で)wrappedSetNumber(uint256) を ghost-variable tracking 付きで公開する Handler contract。
  • test/Counter.invariant.t.sol — 新規ファイル。Handler を targetContract(...) に wire し、invariant_* 関数を宣言する invariant test contract。

合計で約 50 行の新規コードを 2 つの新規 test ファイルにまたがって書く。レッスン3の主題は Handler パターンを理解すること であって、賢い invariant 算術ではない。

おさらい

レッスン2の後はこうなっている。

  • forge fuzz が 1 つの test 関数を 256+ iteration、ランダム parameter で走らせる。
  • vm.assume は precondition を filter し、vm.expectRevert は negative-path test のためのもので、目的は真逆。
  • Shrinker が 32-byte の失敗入力を minimal counterexample まで reduce する。cache/fuzz/ がそれらを persist する。
  • testFuzz_IncrementPreservesPlusOne を書いた。1 call の保存則 property だ。

レッスン 3 はその保存則 property を call の 系列 で走らせる。同じ定理、より深い adversary だ。

計画

編集は 5 つ、2 つの新規ファイルにまたがる。

  1. foundry.toml[invariant] config を追加runs = 256depth = 50 (run あたり 50 ランダム call)、fail_on_revert = false。Invariant testing における「run」が何を意味するかを定義する。
  2. test/CounterHandler.sol を作るCounter instance を保持し、ghostIncrementCount 変数を lockstep で bump する wrappedIncrement() を公開する。後で reset を track する wrappedSetNumber(uint256) も追加する。
  3. test/Counter.invariant.t.sol を作るTest を継承し、CounterHandler をインスタンス化し、targetContract(...) で登録、counter.number() == handler.ghostIncrementCount() を assert する invariant_NumberEqualsIncrementCount を宣言する。
  4. forge test --match-contract CounterInvariantTest -vvv を走らせる(runs: 256, calls: 12800, reverts: 0) を観察し、何千ものランダム系列を超えて invariant が成立し続けるのを見る。
  5. setNumber を ghost 更新なしで公開して意図的に壊すwrappedIncrement(), wrappedIncrement(), badSetNumber(0), wrappedIncrement() のような multi-call counterexample を Foundry が生成するのを観察する。

(答え: Handler は ghostSumDepositsghostSumWithdrawals の両方を track する必要がある。 どちらも wrappedDeposit(uint256)wrappedWithdraw(uint256) の内部で増分する。構築時に 1 回 capture される ghostInitialBalance も必要。Invariant は target.balance() == handler.ghostInitialBalance() + handler.ghostSumDeposits() - handler.ghostSumWithdrawals() を assert する。レッスン13 の proptest と まったく同じ 算術形状だ。同じ定理、2 言語。本コースの レッスン6 capstone がまさに openhl-liquidation Step 5(Liquidation)中の InsuranceFund についてこの port を行う。)

forge invariantforge fuzz とどう違うか

flowchart TD
    A[1. ランダムな Handler メソッドを選択] --> B[2. 宣言済み bound 内でランダム引数を選択]
    B --> C[3. Handler メソッドを呼び出し<br/>target を駆動しゴースト変数を更新]
    C --> D{4. 呼び出しがリバートした?}
    D -->|yes, かつ fail_on_revert=true| F[FAIL: 系列全体を反例として登録]
    D -->|yes, かつ fail_on_revert=false| E[5. すべての invariant_* 関数を評価]
    D -->|no| E
    E -->|インバリアント違反発生| F
    E -->|すべての保存則が成立| G{6. Depth 上限に到達?}
    G -->|no| A
    G -->|yes| H[Run 完了: setUp をリセットし次の Run へ]
    F -.->|系列シュリンカーがトレースを圧縮| I[最小化されたコール系列を報告]

Loop で押さえる点が 5 つ。

  1. 2 つのネストしたランダム軸がある。method 選択と parameter 選択だ。 レッスン2の forge fuzz は軸が 1 つ。固定された test 関数があり、parameter を選ぶだけ。レッスン3の forge invariant は軸が 2 つ。各 step で どの Handler method を呼ぶかと、その parameter を選ぶ。探索空間は (num_methods × param_space)^depth。Depth 50 で 3 つの method、32-byte parameter なら (3 × 2^256)^50。総当たり(Exhaustive)など到底不可能であり、biased random + shrinking だけが頼みの綱。この組み合わせ爆発こそが Handler-bounded inputs が重要な理由だ。Precondition 違反に費やす iteration は、真の adversary move に費やさない iteration だ。
  2. fail_on_revert が test の strict 度合いを制御する dial。 fail_on_revert = true のとき、Handler call からの 任意の revert が run を失敗させる。Handler は target を panic させてはならない、という strict mode で、Handler が無効入力を素通りさせるバグを catch する。fail_on_revert = false のとき、revert は許容され、invariant 違反だけが run を失敗させる。Handler を iterate している間の緩い default だ。まず fail_on_revert = false で始める。Handler が tight になったら true に flip して、Handler が許した入力で target が panic するバグを catch する。
  3. Invariant は終わりだけでなく 毎 call 後に check される。 これが レッスン2の per-iteration assertion の multi-call 等価物だ。total >= 0 invariant が call 1 と call 3 の後では成立するが call 2 の後に壊れる場合、失敗は call 2 で検出される。「いつか気づく」ではない。これが invariant testing を「self-heal する一過性の inconsistency」を catch するのに有用にする所以だ。一貫した 2 つの状態の間で 1 call の間だけ存在するバグは、single-call fuzzing には絶対に見えない種類だ。
  4. depth parameter が coverage と実行時間を trade off する。 depth = 50 は各 run が 50 ランダム call を行う。runs = 256 はその run が 256 回起きる。1 回の forge test invocation あたりの total call は runs × depth = 12,800。各 call が setUp、method 選択、parameter 選択、Handler 呼び出し、invariant check を走らせる。Depth 50 で typical run は ~100ms、depth 500 で ~1s。Depth を増やす = 順序バグを catch しやすい。Runs を増やす = 初期状態への sensitivity を catch しやすい。fuzz.runs と同じく、環境ごとに両方を tune する。
  5. Sequence shrinking が killer feature だ。 Invariant が 50-call 系列の後で失敗するとき、生の失敗は読めない。Shrinker は個々の call を drop してみる。call #23 なしでも invariant はまだ失敗するか。call #7 なしでも。そうやって系列を、失敗をまだ trigger する最小 subset まで reduce する。報告される counterexample はしばしば 2–5 calls。失敗が call 47 で発見されたとしてもだ。Sequence shrinking なしでは、invariant testing は debug できない失敗を吐く。

Handler パターンを 1 段落で

Handler とは、target の test 制御の API surface となることを仕事にした contract だ。target への参照を保持し、target の method を wrap する public method を一握り公開する。それらの method の入力を bound し(例: bound(amount, 1, target.balance()))、invariant が期待する conceptual state を mirror する ゴースト変数(Ghost variables) を更新する。Foundry の invariant runner はランダムな Handler method をランダム parameter で呼ぶ。Handler は 3 つを決める。どの parameter 値が sensible か(balance を超える withdraw はしない)、何が起きたかをどう数えるか(ghost-variable accumulator)、何を無視するか(fuzz したくない method はそもそも公開しない)。invariant_* 関数は Handler の ghost state を target の actual state と比較する。一致しなければバグだ。Handler はあなたの「シャドウ仕様(Shadow Specification)」だ。Solidity で書かれ、test 対象の contract と並走して実行される。

手を動かす walk-through

Step 1: foundry.toml[invariant] を設定する

foundry.toml に追記:

[invariant]
runs = 256
depth = 50
fail_on_revert = false
call_override = false

押さえる点が 4 つ。

  1. runs = 256fuzz.runs default と同じ — 同じ「試行回数」概念だ。各 run は fresh な setUp() の後に depth 回のランダム call。Production CI はこれを 1000 以上まで bump する。
  2. depth = 50 は run あたり 50 ランダム Handler call を意味する。 これが各 run が call-history 空間にどれだけ深く分け入るかだ。Newer Foundry の default は 500。50 は学習中の小さく速い値だ。Handler が正しくなったら 500 まで bump して real な adversary coverage を得る。
  3. fail_on_revert = false は Handler method が revert しても run を失敗させない。Iterate 中は便利だ。Handler 内部で try/catch を使って expected revert を飲み込める。Production codebase は Handler が tight になったら true に flip する。その時点で revert があれば Handler が入力 bounding に失敗した signal だからだ。開発中は false、証明には true
  4. call_override = false は Foundry が call ごとに msg.sender を override できるかを制御する。レッスン3では false のまま。msg.sender 操作は レッスン4で vm.prank 経由で見る。

Step 2: test/CounterHandler.sol を書く

test/CounterHandler.sol を作る:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.35;

import {Counter} from "../src/Counter.sol";

contract CounterHandler {
    Counter public counter;
    uint256 public ghostIncrementCount;

    constructor(Counter _counter) {
        counter = _counter;
    }

    function wrappedIncrement() public {
        counter.increment();
        ghostIncrementCount++;
    }
}

押さえる点が 4 つ。

  1. Handler は普通の Solidity contract で、test contract ではない。 何も継承しない。Testforge-std もなし。State (counter, ghostIncrementCount) を保持し、method を公開するだけだ。Foundry の invariant runner は targetContract(...) 経由でそれを発見する(次の step)。Handler は plain Solidity。Invariant runner は discovery layer だ。
  2. ghostIncrementCount は ghost 変数だ。 これまでの call から target の state がこうあるべきだと予想する値を mirror する。Invariant test は counter.number() == handler.ghostIncrementCount() を assert する。将来 Counter.increment() の code 変更で誤って double-increment するようになれば、この Handler はそれを catch する。ghostIncrementCountcounter.number() が乖離するからだ。Ghost 変数は test の「shadow specification」だ。Contract が何をするかとは別に、我々が何を期待しているか。
  3. wrappedIncrement() は lockstep で 2 つのことをする。target を呼び、ghost を更新する。 これが load-bearing 規律だ。Ghost を更新せずに target を呼ぶと、次の invariant check で失敗する(actual が expected と乖離するから)。Target を呼ばずに ghost を更新しても失敗する。Wrapper が「target は X をした」と「ghost は X を track した」の 1:1 binding を強制する。Handler method こそが、target action と ghost update が atomic に起きる場所だ。
  4. Handler は setNumber を公開していない — まだ、だ。我々は invariant を簡単に表現できる 1 つの operation だけを公開する Handler から始める (number == count)。Handler が method を公開しなければ、invariant runner はそれを呼べない。Invariant を壊す method は単に省くという選択をしているのだ。Handler が公開する surface ≠ target の full surface。Invariant を書ける範囲だけを公開する。

Step 3: test/Counter.invariant.t.sol を書く

test/Counter.invariant.t.sol を作る:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.35;

import {Test} from "forge-std/Test.sol";
import {Counter} from "../src/Counter.sol";
import {CounterHandler} from "./CounterHandler.sol";

contract CounterInvariantTest is Test {
    Counter public counter;
    CounterHandler public handler;

    function setUp() public {
        counter = new Counter();
        handler = new CounterHandler(counter);

        // Tell Foundry: when generating random call sequences, only
        // call methods on `handler`. Without this, Foundry would also
        // try to fuzz Counter directly, and uncontrolled setNumber(x)
        // calls would immediately break our invariant.
        targetContract(address(handler));
    }

    function invariant_NumberEqualsIncrementCount() public view {
        // The conservation law: every wrappedIncrement() bumps both
        // counter.number() and handler.ghostIncrementCount() by 1.
        // No matter what random sequence of Handler calls Foundry has
        // generated, these two values must remain equal.
        assertEq(counter.number(), handler.ghostIncrementCount());
    }
}

押さえる点が 5 つ。

  1. setUp() は call ごとではなく run ごと に走る。 各 run の内部では、同じ counterhandler instance が 50 call すべてにわたって再利用される。それが state が系列にわたって蓄積する仕組みだ。Run 間では fresh instance。レッスン2の per-iteration isolation と同じ規律だが、外側のループで起きる。
  2. targetContract(address(handler)) が Foundry にどこを fuzz するか教える。 これがないと、Foundry は到達できる すべての contract の method を呼ぼうとする。Counter も含めて直接、だ。Uncontrolled な counter.setNumber(x) call は ghost を bypass するから invariant を即座に壊す。targetContract 登録は探索を Handler の public method のみに scope する。targetContract は invariant runner の discovery scope。何を登録するかで何が fuzz されるかをコントロールする。
  3. invariant_NumberEqualsIncrementCountview でマークされている。 State を変えず、ただ読んで assert するだけだ。Foundry は系列の各 Handler call 後にこれを呼ぶ。view を忘れても runner はそれでも呼ぶが gas コストが高くなる。view ならその call は事実上 free だ。Performance のために invariant は view であるべき。Assertion の意味はどちらでも同じ。
  4. 関数名が invariant_ で始まる。 test_testFuzz_ と同じ naming-convention discovery だ。Foundry の runner は invariant_* 関数を scan し、Handler call ごとに各 invariant を呼ぶ。1 つの test contract に複数の invariant を持てる。それらすべてが各 call 後に check される。Test contract あたり複数 invariant = 複数の保存則を同時に check する。レッスン13の 4 つの独立した proptest と同じ構造だ。
  5. Assertion は レッスン1と同じ assertEq だ。 異質なものはない。invariant は単に常に成立するべき assertion。新規性は いつ check されるか(ランダム call ごと)であって、何を check するか(plain Solidity の equality)ではない。forge invariant は新しい assertion vocabulary ではなく、discovery loop が違う forge fuzz だ。

Step 4: Invariant suite を走らせる

forge test --match-contract CounterInvariantTest -vvv

期待される出力:

[PASS] invariant_NumberEqualsIncrementCount() (runs: 256, calls: 12800, reverts: 0)

この行を注意深く読む。

  • runs: 256 — 別々の run の数 ([invariant] runs と一致)
  • calls: 12800 — 全 run にわたる total Handler call 数 (256 × 50 = 12800)
  • reverts: 0 — revert した call の数 (wrappedIncrement() は決して revert しないからゼロ)

12,800 ランダム Handler call、invariant は毎回成立した。 保存則 number == ghostIncrementCount が膨大なバリエーションの call 系列にわたって証明された。

Step 5: 意図的に invariant を壊す

Sequence-counterexample ワークフローを見るため、ghost を bypass する Handler method を公開する。CounterHandler.sol に追記:

    function badSetNumber(uint256 x) public {
        // Intentionally wrong: updates the target without updating the ghost.
        // This breaks the invariant on purpose to demonstrate Foundry's
        // sequence-counterexample reporting.
        counter.setNumber(x);
    }

再実行:

forge test --match-contract CounterInvariantTest -vvv

期待される出力:

[FAIL: invariant_NumberEqualsIncrementCount persisted failure]
    Counter: 0x...
    Sequence (length: 2):
        sender=0x... addr=[CounterHandler]0x... calldata=badSetNumber(uint256), args=[42]
        sender=0x... addr=[CounterHandler]0x... calldata=wrappedIncrement(), args=[]
    Last invariant: invariant_NumberEqualsIncrementCount

報告された反例は、わずか 2 コールの系列にまで圧縮されている。 Foundry は最初におそらく ~30 ランダム call 後に失敗を見つけ、shrinker が reduce した。大半の call を drop し、badSetNumber(0xa3b8...)badSetNumber(42) まで半分にし、最小失敗が ちょうど badSetNumber(42) の後に wrappedIncrement() を必要とすることを発見した。

ここには、インバリアント・テストにおける極めて重要な概念である**「因果的タイムラグ(Causal Time Lag)」**が体現されている。

インバリアント検証における「因果的タイムラグ」とは?

インバリアントは各ハンドラーコールのに評価される。つまり、状態を壊したコール失敗が観測されるコールは一致しないことがある。

このテストの因果連鎖は次のとおり。

  1. badSetNumber(42) は成功する(fail_on_revert = false)。
  2. counter.number()42 になるが、ghostIncrementCount0 のまま。
  3. 次に wrappedIncrement() が成功し、counter.number()43 になる。
  4. その直後の invariant 評価で 43 != 1 が露呈して失敗する。

forge のシーケンスシュリンカーは、無関係なコールを削ってこの最小系列に収束させる。長大なトレースを読む代わりに、原因と結果が最短で見える形を直接受け取れる。

続行する前に CounterHandler.sol から badSetNumber を削除する。 保存則規律は、すべての Handler method が target と ghost を lockstep で更新する場合だけ成立する。

Step 6: 適切に handle された wrappedSetNumber を追加する

今度は setNumber正しく 公開する。ghost を一致させて更新することで、だ。CounterHandler.sol に追記:

    function wrappedSetNumber(uint256 newNumber) public {
        counter.setNumber(newNumber);
        // setNumber breaks the simple "number == incrementCount" relationship,
        // so we reset the ghost to match the new target value. The invariant
        // is now: "number equals the number we asked for, plus increments since."
        ghostIncrementCount = newNumber;
    }

再実行:

forge test --match-contract CounterInvariantTest -vvv

期待される出力:

[PASS] invariant_NumberEqualsIncrementCount() (runs: 256, calls: 12800, reverts: 0)

Invariant は再び成立する。 Foundry の runner は今、wrappedIncrement()wrappedSetNumber(uint256) の call からランダムに選び、両 Handler method が ghost を lockstep で維持する。Invariant は同じ 1 行の assertEq だが、test surface area は広い。それでも invariant は 2 つの operation を混ぜた 12,800 のランダム系列にわたって成立する。

これが レッスン3の punchline だ。 Invariant は Handler 媒介の mutation と保存則の間の 契約 だ。Handler method を ghost 更新なしで追加 → invariant 失敗。Ghost を正しく更新 → invariant がどんな unit test もカバーできない指数的に大きな系列空間にわたって成立する。

よくある失敗パターン

  • fail_on_revert = true で Handler が revert する — Handler method が target が扱えない入力を渡したことを意味する。Handler method 内部に入力 bounding (amount = bound(amount, 1, target.balance())) を追加する。
  • runs: 256, calls: 12800, reverts: 12000 — Handler call の大半が revert している。Handler の入力 bound が緩すぎるか、target の precondition がきつすぎるかのどちらかだ。Handler の bound(...) をきつくするか、fail_on_revert を緩めて iteration を生産的に保つ。
  • Invariant が run で即座に失敗する — invariant が間違っている。contract ではない。Assertion の算術を check する。単一 call の manual test を走らせて期待通りに成立するか確認する。
  • Invariant が時々、長い系列の後でだけ失敗する — これが good な種類の失敗だ。特定の call 順序が本物のバグを暴いている。Shrink された counterexample を使って、それを deterministic に再現する unit test を書く。

設計の振り返り

forge invariant の設計に焼き込んだ load-bearing な決定が 3 つ。

  1. Handler パターンは syntax ではなく convention だ。 Foundry は Handler を書くことを 要求 しない。targetContract(target) を直接呼んで target の method を raw に fuzz できる。だが コミュニティが Handler に標準化した のは、それが「すべての iteration が vm.assume rejection」問題を解くからだ。Convention は集合的実践によって enforce され、ツールによってではない。Foundry が multi-call sequencing primitive を与え、Handler パターンはエコシステムがその上に重ねた規律だ。

  2. Ghost 変数は target ではなく Handler に住む。 これは意図的だ。target は clean Solidity のまま、test infrastructure は test ディレクトリに住む。Target に ghost 変数があれば production bytecode を汚染し、gas コストを上げる。Ghost を Handler に置くことで、保存則規律は deploy 時に zero gas コストだ。Test は production contract を testable にするために変更してはならない。Handler が test-only state を target state から isolate する。

  3. Sequence shrinking は per-byte ではなく per-call だ。 Invariant が失敗すると、shrinker は どの call を残すか引数値を何にするか を別の pass で reduce する。Call graph をランダムに mutate しようとはしない。系列を walk して「この call を drop できるか」、次に「この引数を shrink できるか」と問う。Foundry はこれを proptest の state-machine shrinking strategy から継承している。結果として、最小 counterexample は usually 2–5 calls、決して original の 30+ ではない。Per-call shrinking が invariant testing を debuggable にする。なしでは誰も parse できない 50-call trace を吐く。

答え合わせ

レッスン3の後:

   my-foundry-lab/
   ├── foundry.toml                      (+ [invariant] セクション)
   ├── src/Counter.sol                    (レッスン1から変更なし)
   ├── test/Counter.t.sol                 (レッスン2から変更なし)
   ├── test/CounterHandler.sol            (新規 — wrappedIncrement + wrappedSetNumber を持つ Handler)
   ├── test/Counter.invariant.t.sol       (新規 — targetContract を持つ invariant test)
   └── lib/forge-std/                     (変更なし)

レッスン3の後:

  • forge test --match-contract CounterInvariantTest(runs: 256, calls: 12800, reverts: 0) で pass する
  • Multi-call counterexample 形式を見た (単一引数ではなく call の系列)
  • Shrinker が 30+ call の失敗を 2-call minimal example まで reduce するのを見た
  • Handler がなぜ存在するかを理解した。入力を bound して iteration を生産的にするためだ

よくある質問

Q1: なぜ target を直接 targetContract(address(counter)) で呼ばないのか?

呼べる。Trivial な contract には動く。だが precondition を持つ contract (withdraw(amount)amount <= balance を要求するなど) にとって、ランダム uint256 parameter は事実上すべての call で precondition を violate する。fail_on_revert = true なら test は即座に失敗する。fail_on_revert = false なら reverts: 12800 と生産的な iteration ゼロを得る。Handler はその間に挟まる layer だ。ランダム入力を、target が実際に exercise できる bound されて意味ある 入力に変換する。直接 fuzz は stateless または precondition-free な target に動く。Handler 媒介の fuzz はそれ以外のすべてに動く。

Q2: 1 つの test contract に複数の invariant_* 関数を持てる?

Yes、持つべきだ。openhl-liquidation レッスン 13 capstone は 4 つの独立した invariant proptest を持ち、それぞれが異なる保存則を assert する。ここでも同じだ。各 invariant_* が 1 つの法則を check する。Foundry は各 call 後にそれらすべてを走らせる。3 つが pass して 1 つが失敗するなら、どの法則が壊れたかが分かる。これは bundle された 1 つの invariant よりずっと debug しやすい。保存則 1 つにつき 1 つの invariant。Handler あたり複数の invariant が norm だ。

Q3: targetContracttargetSelector の違いは?

targetContract(address) は Foundry に「この contract のすべての public/external method を fuzz しろ」と告げる。targetSelector(FuzzSelector({addr: address, selectors: [bytes4[]]})) はより細かい。「この contract のこれら特定の method だけ を fuzz しろ」だ。Fuzz したくない method を持つ Handler に使う (view-only ヘルパーなど、簡単に private にできない場合)。ほとんどの Handler には targetContract と慎重な public/internal 規律で十分。targetContract から始める。Surgical scoping が必要になったら targetSelector を出す。

Q4: これは openhl-liquidation レッスン13の proptest とどう違う?

レッスン 13 は Rust の proptest! macro と手書きの test を使い、insurance fund method を系列で呼んで保存則を assert する。Pattern は forge invariant がやることと同一だ。ランダム operation 系列、call ごとに assert する保存則。鍵となる違いはこれだ。forge invariant は sequencing + shrinking machinery を built-in として提供する (Handler と invariant だけ書く)。Rust では sequencing は通常自分で書くか proptest-state-machine を使う。Foundry の tooling は stateful testing でより turnkey、Rust の tooling はより細かい制御を与える。同じ定理、Foundry の tooling が ceremony をより多く持ち上げる。

Q5: fail_on_revert = false のとき、Handler が正しいかどうやって分かる?

reverts: カウンタを見る。12800 call のうち reverts: 12800 なら、すべての Handler call が revert した。入力 bounding が壊れているサインだ。reverts: 30 なら occasional な revert がある。これは usually 大丈夫 (一部の operation は特定の prior state を与えられたら自然に失敗する)。reverts: 0 なら Handler は fail_on_revert = true に flip して strict な証明にできるくらい tight だ。reverts: が Handler-quality dashboard。低い 1 桁台かゼロを目指す。

Q6: invariant_* 関数は setup 用に state を変更できる?

No。viewpure でなければならない。Foundry は Handler call の間に呼ぶからだ。Invariant 内部の state mutation は test 系列を破壊する。Check 前に work が必要なら、Handler 内部か setUp() で行う。Invariant は state の純粋な観察。決して mutate しない。

次のレッスン(レッスン4) — cast — Solidity CLI の swiss army knife

レッスン 4 が testing primitive を後にして、Foundry に同梱される CLI ツール cast を導入する。forge がビルドと test を行うのに対し、cast は chain と対話し、data を decode し、ABI encoding を terminal から計算する。HTTP の curl と同じワークフロー ergonomics だ。alloy (Reth と同様、cast の構築基盤) への cross-reference が、このレッスンを Rust エンジニアにとって「alloy::Provider を grok しているなら、cast の mental model はすでに知っている」という payoff にする。

学ぶこと:

  • cast call で read-only contract query (view 関数の RPC 等価物)
  • cast send で state-changing transaction (--rpc-url で mainnet/testnet/anvil を指す)
  • script で calldata を扱うための cast abi-encode / cast abi-decode
  • Chain introspection 用の cast block / cast tx / cast logs
  • Full read-eval パターン: contract を書く → forge test → forked anvil に対して cast call を打って real state で挙動を検証する

レッスン 4 完走後は、Solidity script を書かずに shell loop からデプロイされた contract と対話できるようになる。EVM 用の curl+jq の CLI 等価物だ。

Expert への接続

Invariant testing は contract 層で multi-call bug を catch する。同じ規律を EVM 実装層に持ち込んだのが Differential fuzzing と execution-spec-tests — ランダム transaction sequence を複数 EVM 実装に対して走らせ、各 step で一致を assert する。

合格基準

  • forge fuzzforge invariant の違い(1 call vs call 列)を即答できる。
  • Handler パターンの 4 役割(entry point 制御 / 前提 filter / bookkeeping / guard)を即答できる。
  • targetContract / targetSelector / targetSender で ファザー surface を絞る理由を即答できる。
  • invariant.runs × invariant.depth の意味と default 256 × 15 を即答できる。
  • Ghost 変数(totalDeposited 等)で sequence 全体の保存則を track する手順を即答できる。
  • bound(x, min, max)vm.assume より生産的な理由(reject せず変換)を即答できる。
  • Liquidation L13 scanner per-scan 保存則と forge invariant の対応を即答できる。

まとめ(3行)

  • forge invariant = call 列に対する property、Handler contract が ランダム sequence を生成 + ghost 変数で track、invariant_* 関数が各 step で保存則 assert、runner が runs × depth 回回す。
  • Handler パターン = entry point 制御 + vm.assume 前提 filter + ghost bookkeeping + 無効 call の guard、targetContract / targetSelector でファザー surface を絞り意味ある call のみ生成、bound で fuzz input を意味ある range に clamp。
  • Liquidation L13 scanner per-scan 保存則と同パターン、production CI 1K × 50、nightly 10K × 100、次は CLI(cast)+ state-aware testing(anvil + cheatcodes)。