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との境界を検証します。

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へprankやdealを重ね、成功結果だけを共有すると、読み手はchain由来の事実とtest用の仮定を分けられません。
この記事ではFoundry 1.8.0、Solidity 0.8.36、二つのローカルAnvil providerを使い、同じchain ID・同じcontract address・同じblock numberでも、block hashとstateが異なる境界を再現しました。さらにarchive不足のRPC、複数fork、prank、deal、store、etch、warp、roll、eth_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の記事で詳しく整理しています。
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から取得 | etch・store・deal後は変更済み |
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 2のeth_getStorageAtをJSON-RPC code -32000、historical state unavailable: archive data requiredで拒否しました。その結果、固定block forkの作成は失敗し、latest stateのreadは成功しています。
この失敗はSolidityのrevertではありません。次の順に層を分けます。
- endpointへ接続できるか
- chain IDと想定networkが一致するか
- block numberとhashを取得できるか
- 対象blockのcode・balance・storageが読めるか
- fork作成後、対象addressのcode hashと主要slotが期待どおりか
- test logicがassertionまで進んだか
providerを切り替えたときは、同じchain IDや同じblock numberだけでcacheを信用しません。block hash、target code hash、主要slotを再照合します。verified sourceからbytecodeと設定を照合する手順はverified contractの再構築記事も参照してください。
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 0x0は41から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手順
- local contractだけで確認できるlogicを先に分離する
- read-only endpointと対象blockを選び、numberとhashを保存する
- code hash・主要slot・balanceを取得し、開始stateをassertする
createForkまたはcreateSelectForkの返すfork IDを記録するprank、deal、store、etch、warp、rollを一つずつ適用し、before/afterを残す- 複数forkならswitchごとのactive forkとstateをassertする
eth_call、local transaction、public transactionで残る証拠を分ける- provider差、archive不足、latest drift、oracle/time/L2境界をnegative caseに入れる
- secret、signed payload、個人dataがoutputへ出ていないことを確認する
fork testの価値は、mainnetらしく見える大きな環境を作ることではありません。どのstateをどこから読み、どこをローカルで変え、何をassertし、何をまだ証明していないかを説明できることです。その境界がmanifestに残っていれば、成功結果だけでなく、provider変更や将来の再実行で起きた差も調べられます。
本記事は開発・検証方法の解説であり、特定protocol、asset、RPC providerの安全性、transaction成功、監査完了を保証するものではありません。
確認した一次情報
- Foundry Fork Testing guide確認日: 2026/09/01
- Foundry createSelectFork reference確認日: 2026/09/01
- Foundry selectFork reference確認日: 2026/09/01
- Foundry makePersistent reference確認日: 2026/09/01
- Foundry prank reference確認日: 2026/09/01
- Foundry deal reference確認日: 2026/09/01
- Foundry store reference確認日: 2026/09/01
- Foundry etch reference確認日: 2026/09/01
- Foundry rollFork reference確認日: 2026/09/01
- Ethereum JSON-RPC API確認日: 2026/09/01
- Geth archive mode確認日: 2026/09/01
- Foundry v1.8.0確認日: 2026/09/01



