3MIKAN
仮想通貨直コン

Foundry fork test入門:block固定・state変更・simulationの境界を理解する

Foundryのfork testをblock number/hash、RPC、archive、fork ID、prank・deal・store・etch・warp・rollの変更履歴まで固定し、eth_callや実transactionとの境界を検証します。

3MIKANのブランドキャラクターがblockchainの街から固定blockを透明なローカル模型へコピーし、orange色のtest変更札を置いているFoundry fork testの記事画像

Foundryのfork testは、既存chainのstateを参照しながらローカルでcontractを動かすための仕組みです。protocol連携、incident再現、upgrade前後の比較には便利ですが、「mainnetを完全に複製した環境」でも「本番transactionが成功する証明」でもありません。

再現性を決めるのはRPC URLだけではありません。chain ID、endpointのidentity、block number、block hash、archive state、fork ID、ローカルで変更したaccount・slot・code・timestampを一緒に残します。latestで作ったforkへprankdealを重ね、成功結果だけを共有すると、読み手はchain由来の事実とtest用の仮定を分けられません。

この記事ではFoundry 1.8.0、Solidity 0.8.36、二つのローカルAnvil providerを使い、同じchain ID・同じcontract address・同じblock numberでも、block hashとstateが異なる境界を再現しました。さらにarchive不足のRPC、複数fork、prankdealstoreetchwarprolleth_call、ローカルtransactionを分けて検証しています。public RPC、API key、wallet、署名済みtransaction、public network、実資産は使っていません。

結論を先に置くと、forkの再現単位は、chain IDではなく、RPC identity、block number、block hash、fork ID、local mutationを一つのmanifestへ固定した組み合わせです。

local contractだけのtestとfork testを使い分ける

外部protocolのstateを必要としないbusiness ruleは、ローカルにdeployした小さなcontractで先に検証します。速く、RPC停止やprovider差の影響を受けず、失敗条件を小さくできます。

fork testが必要になるのは、次のような境界です。

  • 特定block時点の既存contract code・storage・balanceへ依存する
  • proxy implementation、pool reserve、oracle observationなど複数contractの組み合わせを再現する
  • 実際のaddress・ABI・storage layoutに対するintegrationを確認する
  • 過去のtransaction直前または直後のstateを比較する
  • upgrade候補を既存stateへ重ねたときの挙動を見る

一方、access control、計算式、revert条件、state machine、invariantはローカルtestでも確認できます。最初から大きなforkへ寄せると、RPC failureとcontract bugが同じ失敗に見えます。unit・fuzz・invariantの分担はFoundry invariant testの記事で詳しく整理しています。

blue-grayのchain stateをblock snapshotとして固定し、orange色でcaller・balance・storage・codeをlocal変更してからassertionへ進むprovenance図
青灰色はprovider由来、orange色はローカル変更を表す概念図です。正確なblock identity、値、cheatcode、判定は下の表と公開JSONを正本にします。

createForkとcreateSelectForkでblockを固定する

vm.createFork(urlOrAlias, blockNumber)はforkを作るだけで、active forkは切り替えません。vm.createSelectFork(urlOrAlias, blockNumber)は作成と選択を一度に行い、どちらもfork IDを返します。vm.selectFork(forkId)で切り替え、vm.activeFork()で現在のIDをassertできます。

uint256 primary = vm.createFork(vm.envString("PRIMARY_RPC"), 2);
uint256 secondary = vm.createFork(vm.envString("SECONDARY_RPC"), 2);

vm.selectFork(primary);
assertEq(vm.activeFork(), primary);

vm.selectFork(secondary);
assertEq(vm.activeFork(), secondary);

blockを省略するとfork作成時のlatestが選ばれます。今回のprimary providerでは、固定block 2の値が41、作成時のlatest block 3の値が42でした。1 block進んだだけでstateが変わるため、latest forkは同じcodeを後日実行しても同じ開始点になりません。

fork条件 block number block hash 読み取った値
primary固定 2 0x627ada…05ba 41
primary latest 3 0xdff0cf…6513 42
secondary固定 2 0x6f95ad…122 91
secondary latest 3 0x329732…0cf 92

Foundryのblock number指定だけでは、同じ高さが同じblockだったことまでは表せません。fork作成前にeth_getBlockByNumberでhashを取得し、numberとhashを一組で保存します。Ethereum RPC snapshotの記事と同様に、再実行時はhashも照合します。

今回の二つのproviderはchain ID 31337、contract address、固定block numberが同じですが、固定block hashとstorage valueが異なりました。これはprovider優劣ではなく、別のlocal historyでreorg相当の不一致を作ったものです。chain IDと高さだけで同一stateと判断できないことを示します。

fork manifestでchain由来とlocal変更を分ける

実行前にmanifestを作り、実行後に変更履歴と期待結果を追記します。endpointはsecretを含まないaliasまたは識別名を保存し、生URLやAPI keyは残しません。

{
  "chainId": 31337,
  "endpoint": "local-anvil-primary",
  "blockNumber": "2",
  "blockHash": "0x627ada8d...05ba",
  "forkId": "runtime value; do not compare across runs",
  "archiveRequired": true,
  "target": "0x5FbDB2...80aa3",
  "mutations": [
    { "kind": "prank", "caller": "0x...bEEF", "signature": false },
    { "kind": "store", "slot": "0x0", "before": "41", "after": "777" }
  ],
  "expected": { "valueBefore": "41", "valueAfter": "777" }
}

fork IDはFoundry process内のhandleで、chain IDやnetwork IDではありません。別の実行で同じ数字になる保証を期待せず、その実行内でactiveFork()と対応させるために使います。

項目 chain由来 local変更 証拠として言える範囲
code・storage・balance 固定blockをRPCから取得 etchstoredeal後は変更済み mutation前だけが取得時点のprovider state
caller transaction/call context prankで上書き signatureや秘密鍵の所持は示さない
block timestamp block header warpで上書き 実chainでその時刻へ進むことは示さない
block number fork開始block rollまたはrollFork header・state・blockhashの連続性を別確認する
expected result 仕様・観測から定義 assertionで照合 本番実行の成功やgas価格は保証しない

変更前・変更後を同じrecordへ入れると、777を「block 2に実在したstorage」と誤読する余地が減ります。公開した再現結果JSONには、二つのprovider、fork assertion、六つのmutation、archive failure、eth_callとlocal transactionの差をまとめています。

RPC・archive・cache・reorg相当の失敗を分ける

forkは必要なstateをRPCから取得します。過去blockのheaderが読めても、その時点のaccount stateやstorageをnodeが保持しているとは限りません。historical stateを必要とする場合は、対象blockを提供できるarchive対応endpointが必要です。

今回のarchive制限proxyはlatestへのreadを通す一方、block 2eth_getStorageAtをJSON-RPC code -32000historical state unavailable: archive data requiredで拒否しました。その結果、固定block forkの作成は失敗し、latest stateのreadは成功しています。

この失敗はSolidityのrevertではありません。次の順に層を分けます。

  1. endpointへ接続できるか
  2. chain IDと想定networkが一致するか
  3. block numberとhashを取得できるか
  4. 対象blockのcode・balance・storageが読めるか
  5. fork作成後、対象addressのcode hashと主要slotが期待どおりか
  6. test logicがassertionまで進んだか

providerを切り替えたときは、同じchain IDや同じblock numberだけでcacheを信用しません。block hash、target code hash、主要slotを再照合します。verified sourceからbytecodeと設定を照合する手順はverified contractの再構築記事も参照してください。

provider hash差、archive不足、moving latest、timeとoracleのlocal仮定という四つのFoundry fork test failure境界を3MIKANのキャラクターが照合する図
同じ高さに見えるblockでもidentityは別になり得ます。図は境界の補助表現で、実際のhash、RPC error、timestamp、stateは本文と公開JSONで確認します。

prankとbroadcastは目的が違う

vm.prank(address)は次のcallでmsg.senderを指定addressへ変えます。private keyを読み込まず、signatureを作らず、そのaddressの権限を現実に取得するものでもありません。

今回の検証では0x…bEEFをcallerにして、targetが観測したmsg.senderだけを確認しました。結果に残すのは「caller override」かつ「signatureなし」です。「whaleとして署名した」「admin accountを操作できた」とは書きません。

broadcastはscriptでtransactionを組み立て、指定senderの送信処理へつなぐための別機能です。fork上でprankが通ったことから、real networkで必要になるsignature、nonce、fee、balance、mempool、block inclusion、直前stateを省略できるわけではありません。

access controlのtestでは、少なくとも次を対にします。

  • 許可callerで成功する
  • 非許可callerで期待errorへ戻る
  • prankの適用範囲が一callか複数callかを明示する
  • tx.originまで変える必要が本当にあるか確認する
  • signature validationが要件なら、prankではなくsignatureそのものをtestする

deal・store・etchはchain stateを作り替える

cheatcodeは準備を短くしますが、変更後のstateはproviderから取得した事実ではありません。

dealはtransferではない

vm.deal(account, amount)はnative balanceを直接設定します。今回の0x…bEEFは0 weiから5 ETH相当へ変わりましたが、送金transaction、sender、receiptは存在しません。

ERC-20向けのForge Std deal(token, account, amount)は別のhelperで、storage slotを操作します。non-standard layout、rebasing、balance hookを持つtokenでは、実際のmint・transferと同じ副作用にならない場合があります。tokenの会計を確認するなら、deal後のbalanceだけでなくtotal supply、hook、event、downstream accountingも分けます。

storeはstorage layoutが前提

vm.store(account, slot, value)は指定slotへraw valueを書きます。今回のslot 0x041から777へ変わりました。mapping、dynamic array、packed fields、proxy storage、namespaced storageはslot計算が異なるため、sourceとcompiler設定を確認せず推測で書きません。

vm.loadでbefore/afterを読み、slotの意味、値のencoding、変更理由をmanifestへ残します。upgrade検証では、別fieldまで壊していないか複数slotを比較します。

etchはruntime codeだけを置く

vm.etch(target, code)はaddressのruntime bytecodeを直接設定します。今回の0x…cAFEはcodeなしから、marker 909を返すruntime codeへ変わりました。deployment transactionはなく、constructorも実行されません。

したがってconstructorで設定するowner、immutable、storage、event、factory recordは自動では再現されません。initializerまたは明示的なslot設定が必要なら、その変更も別々に記録します。custom precompileのmockにも使えますが、chain固有precompileのgas・state transition・system contract挙動を完全に再現する保証はありません。

warp・roll・rollForkで「進んだ」の意味を混ぜない

vm.warp(timestamp)はtest環境のblock.timestampを、vm.roll(blockNumber)block.numberを設定します。今回の固定forkではtimestampを1788200002から1788203602へ、block numberを2から14へ変えました。

この操作でprovider側に12個のblockが生成されたわけではありません。各blockのhash、transaction、base fee、randomness、L1/L2 data、oracle observationが自動的に埋まるわけでもありません。

vm.rollForkはactive forkまたは指定forkを別blockへ進める機能で、block numberを受け取る形とtransaction hashまで進めて手前のtransactionをreplayする形があります。単に環境変数を変えるrollとは分けて使います。

time依存testでは、次を個別に扱います。

  • deadline・vesting・cooldownはtimestamp境界の直前、同値、直後をtestする
  • TWAPは時間だけでなくobservation更新とliquidity条件を確認する
  • oracleはround timestamp、freshness、answer範囲、decimalsを確認する
  • L2はsequencer uptime、L1 data、system contract、precompileを確認する
  • blockhash依存は取得可能範囲と実際のheader連続性を確認する

oracleの安全境界は価格feedの検証記事にまとめています。warpして価格が変わらない環境を、本番の将来価格として扱ってはいけません。

multiple forksは独立stateとして扱う

Foundryは複数forkを作り、fork IDで切り替えられます。今回、primary固定forkのslotを777へ変えた後にsecondary固定forkへ切り替えると値は91で、primaryへ戻すと777が残りました。変更はそのforkにだけ保存されています。

公式referenceでは、forkごとにstorageは独立し、fork切替時に置き換わります。初期状態ではtest contractとcallerがfork間でpersistentです。ほかのaccountをvm.makePersistentで共有すると、別chain由来のstateへlocal contract stateが持ち越されます。

cross-chain testでpersistent objectが便利な場合もありますが、次をmanifestへ書きます。

  • 各fork IDに対応するchain・endpoint・block number・hash
  • forkごとに変更したaccount・slot・code
  • persistentにしたaddressと理由
  • switch前後の期待state
  • bridge messageやproofをmockしたか、実際のsource dataを使ったか

「mainnet forkからL2 forkへ切り替わった」だけでは、bridgeのfinalityやmessage relayまで再現したことにはなりません。

fork test・eth_call・シミュレーション・transactionの境界

同じcalldataでも、実行方法で残るものが違います。

実行方法 stateを変更するか receipt signature・送信 主に確認できること
eth_call provider stateへ保存しない なし なし 指定block・call contextでのreturn/revert
client固有シミュレーション 通常は保存しない 実装依存 通常は送信なし trace、複数call、state overrideを含む仮想実行
fork上のlocal transaction active fork内へ保存 local receipt local key/context 複数call後のstate、event、gasの比較
public transaction canonical chainへ採用されれば保存 inclusion後に取得 有効signatureと送信が必要 実networkで採用された結果

今回、valueを99へ書くcalldataをeth_callで実行するとreturnは得られましたが、stateは42のまま、receiptもありませんでした。同じcallをlocal Anvil transactionとして実行するとblock 4でsuccess receiptができ、stateは99へ変わりました。

それでもlocal receiptはpublic transactionの証拠ではありません。real networkではnonce、fee、balance、signature、mempool policy、access list、base fee、直前のcompeting transaction、block producerのorderingが加わります。fork test成功は「固定した開始stateとローカル仮定の下で期待どおり動いた」という範囲に限定します。

state overrideを受け付けるシミュレーションAPIも便利ですが、method、field、複数blockの扱いはclientやproviderにより異なります。request payload、endpoint identity、block identity、override内容、responseを保存し、通常のEthereum JSON-RPCと同じ仕様だと決めつけません。

secretを残さない実行checklist

fork testにwrite権限のあるRPC keyやwallet secretは不要です。read-only endpointを使い、失敗logやscreenshotへcredentialを残さない設計にします。

  • RPC URLは環境変数またはsecret storeから読み、manifestにはaliasだけを保存する
  • command lineへtoken付きURLを直接書かない
  • error object、request header、process環境を丸ごと出力しない
  • browser wallet、seed phrase、private key、raw signed transactionを使わない
  • public transaction送信methodをtest processから分離する
  • endpointのrate limit、archive範囲、region、取得日時を記録する
  • block numberとhash、target code hash、主要slotを実行前に照合する
  • mutationごとにbefore、after、provenance、戻し方を記録する
  • exact Foundry versionとcommit、solc version、EVM targetを固定する
  • 成功だけでなくarchive不足、provider hash差、非許可caller、stale oracleもtestする

最小のreview手順

  1. local contractだけで確認できるlogicを先に分離する
  2. read-only endpointと対象blockを選び、numberとhashを保存する
  3. code hash・主要slot・balanceを取得し、開始stateをassertする
  4. createForkまたはcreateSelectForkの返すfork IDを記録する
  5. prankdealstoreetchwarprollを一つずつ適用し、before/afterを残す
  6. 複数forkならswitchごとのactive forkとstateをassertする
  7. eth_call、local transaction、public transactionで残る証拠を分ける
  8. provider差、archive不足、latest drift、oracle/time/L2境界をnegative caseに入れる
  9. secret、signed payload、個人dataがoutputへ出ていないことを確認する

fork testの価値は、mainnetらしく見える大きな環境を作ることではありません。どのstateをどこから読み、どこをローカルで変え、何をassertし、何をまだ証明していないかを説明できることです。その境界がmanifestに残っていれば、成功結果だけでなく、provider変更や将来の再実行で起きた差も調べられます。

本記事は開発・検証方法の解説であり、特定protocol、asset、RPC providerの安全性、transaction成功、監査完了を保証するものではありません。

確認した一次情報