Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
0b9a934
block_on_any_runtime function made publicly accessible
zees-dev Oct 30, 2025
890835e
added fork_url cli param to fork node
zees-dev Oct 30, 2025
075d0e1
added fork_block_number param to forc node
zees-dev Oct 30, 2025
41cb973
forc node contract data and state forking support
zees-dev Oct 30, 2025
b6b177e
added state forking e2e tests
zees-dev Oct 31, 2025
c1b7a90
fuel-core branch patch
zees-dev Oct 31, 2025
440a91c
forc-node cargo.toml upd
zees-dev Oct 31, 2025
e85b5b5
cargo.lock upd
zees-dev Oct 31, 2025
34b4583
forc node forking integration test contracts
zees-dev Oct 31, 2025
86b1183
added comment for patch diff
zees-dev Oct 31, 2025
250b6ae
cargo-toml-lint fixed dependency ordering
zees-dev Oct 31, 2025
609d0fd
various improvements to align contract bytecode retrieval and state r…
zees-dev Oct 31, 2025
8a52f69
improve combined database construction readability
zees-dev Oct 31, 2025
df3ae87
non-interactive for tests
zees-dev Nov 3, 2025
07eecf9
introduced non-interactive cmd arg instead - defaults to false
zees-dev Nov 3, 2025
f1d9f38
updated forc compilation for test contracts
zees-dev Nov 3, 2025
7438872
docs for using forc-node with testnet as fork example
zees-dev Nov 4, 2025
494fa90
Merge branch 'master' into feat/forc-node-local-state-forking
zees-dev Nov 4, 2025
e266dd6
Update forc-plugins/forc-node/tests/fork/Forc.toml
zees-dev Nov 12, 2025
28964d8
Update forc-plugins/forc-node/tests/local.rs
zees-dev Nov 12, 2025
b810dcc
Update forc-plugins/forc-node/tests/local.rs
zees-dev Nov 12, 2025
01dde89
using cargo.toml and cargo.lock from master
zees-dev Nov 14, 2025
703c4e0
Merge branch 'master' into feat/forc-node-local-state-forking
zees-dev Nov 14, 2025
bbf192d
cargo.toml and cargo.lock upd
zees-dev Nov 18, 2025
f1bf8a5
fixed compilation errors in tests
zees-dev Nov 18, 2025
dce3d6b
fixed spellchack and markdown lint issues
zees-dev Nov 18, 2025
d6c6bd9
fixed spellcheck
zees-dev Nov 18, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,677 changes: 1,090 additions & 587 deletions Cargo.lock

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ rayon-cond = "0.3"
regex = "1.10"
reqwest = "0.12"
rexpect = "0.6"
revm = "14.0"
revm = { version = "33.1", default-features = false, features = ["std", "secp256k1", "portable", "blst"] }
rmcp = "0.2"
ropey = "1.5"
rpassword = "7.2"
Expand Down Expand Up @@ -259,3 +259,10 @@ vte = "0.13"
walkdir = "2.3"
whoami = "1.5"
wiremock = "0.6"

[patch.crates-io]
fuel-core = { git = "https://github.com/FuelLabs/fuel-core", branch = "master" }
fuel-core-chain-config = { git = "https://github.com/FuelLabs/fuel-core", branch = "master" }
fuel-core-client = { git = "https://github.com/FuelLabs/fuel-core", branch = "master" }
fuel-core-storage = { git = "https://github.com/FuelLabs/fuel-core", branch = "master" }
fuel-core-types = { git = "https://github.com/FuelLabs/fuel-core", branch = "master" }
2 changes: 2 additions & 0 deletions docs/book/spell-check-custom-words.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
ABI
ABIs
AMM
ASM
IDE
IDEs
Expand Down Expand Up @@ -110,6 +111,7 @@ relayer
relayers
repo
repos
RocksDB
runnable
stateful
struct
Expand Down
1 change: 1 addition & 0 deletions docs/book/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
- [Unit Testing](./testing/unit-testing.md)
- [Testing with Rust](./testing/testing-with-rust.md)
- [Testing with Forc Call](./testing/testing_with_forc_call.md)
- [Testing with Forc Node](./testing/testing_with_forc_node.md)
- [Debugging](./debugging/index.md)
- [Debugging with CLI](./debugging/debugging_with_cli.md)
- [Debugging with IDE](./debugging/debugging_with_ide.md)
Expand Down
88 changes: 88 additions & 0 deletions docs/book/src/testing/testing_with_forc_node.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Testing with `forc-node`

`forc-node` wraps the `fuel-core` library and provides a convenient CLI for starting a Fuel node.
Besides running an entirely local chain, `forc-node` can *fork* an existing node - or network (testnet or mainnet) so that you can exercise contracts against a near-real state without touching live infrastructure.

The workflow below demonstrates how to validate contract reads against public Fuel Testnet data and then repeat the exact same call against a forked local node.

## 1. Inspect a contract on testnet

Fuel ships a `forc-call` binary that can read contract state directly from public infrastructure.
The example below queries the owner of the [Mira AMM](https://github.com/mira-amm/mira-v1-periphery) contract that is already deployed on testnet:

```sh
forc-call \
--abi https://raw.githubusercontent.com/mira-amm/mira-v1-periphery/refs/heads/main/fixtures/mira-amm/mira_amm_contract-abi.json \
0xd5a716d967a9137222219657d7877bd8c79c64e1edb5de9f2901c98ebe74da80 \
owner \
--testnet
```

Sample output (truncated):

```text
result: Initialized(Address(std::address::Address { bits: Bits256([31, 131, 36, 111, 177, 67, 191, 23, 136, 60, 86, 168, 69, 88, 194, 77, 47, 157, 117, 51, 25, 181, 34, 234, 129, 216, 182, 250, 160, 158, 176, 83]) }))
```

Keep both the ABI URL and the contract ID handy—we will reuse them when pointing `forc-node` at the same network.

## 2. Start a forked node that mirrors testnet

Launch a local `forc-node` instance and instruct it to sync contract state from the public Testnet GraphQL endpoint.
The first time a contract or storage slot is requested, the forked node lazily retrieves the data from the remote network and caches it into the local database.

```sh
cargo run -p forc-node -- \
local \
--fork-url https://testnet.fuel.network/v1/graphql \
--db-type rocks-db \
--db-path /tmp/.db.fork \
--debug \
--historical-execution \
--poa-instant \
--port 4000
```

Key flags:

- `--fork-url` specifies the upstream GraphQL endpoint; for Testnet this is `https://testnet.fuel.network/v1/graphql`.
- `--db-type rocks-db` and `--db-path` enable persistence so the node survives restarts; this is required for `historical-execution` to work.
- `--historical-execution` allows dry-running transactions against previous blocks—handy when replaying test cases.
- This is required for state forking to work.
- `--poa-instant` auto-produces blocks so transactions submitted against the fork finalize immediately.

Once the node is running, its GraphQL endpoint is available at `http://127.0.0.1:4000/v1/graphql`.

## 3. Repeat the contract call against the fork

Now that the forked node is live, repeat the earlier `forc-call` but target the local endpoint instead of the public Testnet endpoint:

```sh
forc-call \
--abi https://raw.githubusercontent.com/mira-amm/mira-v1-periphery/refs/heads/main/fixtures/mira-amm/mira_amm_contract-abi.json \
0xd5a716d967a9137222219657d7877bd8c79c64e1edb5de9f2901c98ebe74da80 \
owner \
--node-url http://127.0.0.1:4000/v1/graphql
```

The first call hydrates the contract bytecode and storage into the RocksDB database defined earlier; subsequent reads are served locally.
You should see the same owner address as before:

```text
result: Initialized(Address(std::address::Address { bits: Bits256([31, 131, 36, 111, 177, 67, 191, 23, 136, 60, 86, 168, 69, 88, 194, 77, 47, 157, 117, 51, 25, 181, 34, 234, 129, 216, 182, 250, 160, 158, 176, 83]) }))
```

Because the fork persists data locally, re-running the command now serves the response immediately without contacting Testnet again.
You can safely mutate state or deploy additional tooling against the fork—the remote network remains untouched.

### Verifying fork behaviour

- **Lazy hydration:** the first query fetches bytecode and storage from Testnet; later calls are local.
- **State isolation:** writes against the fork do not propagate back to Testnet.
- **Continued discovery:** if you reference another contract that exists on Testnet, the fork loads it on demand, letting you blend public and local-only workflows.

## Troubleshooting and tips

The original feature proposal and design discussion is tracked in [FuelLabs/sway#7448](https://github.com/FuelLabs/sway/issues/7448) if you need more background.
2 changes: 1 addition & 1 deletion forc-pkg/src/source/reg/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -653,7 +653,7 @@ where
/// If we are already in a runtime, this will spawn a new OS thread to create a new runtime.
///
/// If we are not in a runtime, a new runtime is created and the future is blocked on.
pub(crate) fn block_on_any_runtime<F>(future: F) -> F::Output
pub fn block_on_any_runtime<F>(future: F) -> F::Output
where
F: std::future::Future + Send + 'static,
F::Output: Send + 'static,
Expand Down
6 changes: 6 additions & 0 deletions forc-plugins/forc-node/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,13 @@ path = "src/main.rs"
anyhow.workspace = true
clap = { workspace = true, features = ["derive", "string"] }
dialoguer.workspace = true
forc-pkg.workspace = true
forc-tracing.workspace = true
forc-util.workspace = true
fuel-core = { workspace = true, default-features = false, features = ["relayer", "rocksdb", "test-helpers"] }
fuel-core-chain-config.workspace = true
fuel-core-client.workspace = true
fuel-core-storage.workspace = true
fuel-core-types.workspace = true
fuel-crypto = { workspace = true, features = ["random"] }
libc.workspace = true
Expand All @@ -38,6 +41,9 @@ tracing.workspace = true
tracing-subscriber.workspace = true

[dev-dependencies]
forc-client.workspace = true
fuel-tx.workspace = true
fuels.workspace = true
portpicker.workspace = true
reqwest = { workspace = true, features = ["json"] }
serde_json.workspace = true
Expand Down
59 changes: 48 additions & 11 deletions forc-plugins/forc-node/src/chain_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,28 +70,35 @@ pub struct ConfigFetcher {
client: reqwest::Client,
base_url: String,
config_vault: PathBuf,
non_interactive: bool,
}

impl Default for ConfigFetcher {
/// Creates a new fetcher to interact with github.
/// By default user's chain configuration vault is at: `~/.forc/chainspecs`
fn default() -> Self {
Self::new(false)
}
}

impl ConfigFetcher {
pub fn new(non_interactive: bool) -> Self {
Self {
client: reqwest::Client::new(),
base_url: "https://api.github.com".to_string(),
config_vault: user_forc_directory().join(CONFIG_FOLDER),
non_interactive,
}
}
}

impl ConfigFetcher {
#[cfg(test)]
/// Override the base url, to be used in tests.
pub fn with_base_url(base_url: String) -> Self {
Self {
client: reqwest::Client::new(),
base_url,
config_vault: user_forc_directory().join(CONFIG_FOLDER),
non_interactive: false,
}
}

Expand All @@ -101,9 +108,14 @@ impl ConfigFetcher {
client: reqwest::Client::new(),
base_url,
config_vault,
non_interactive: false,
}
}

fn non_interactive(&self) -> bool {
self.non_interactive
}

fn get_base_url(&self) -> &str {
&self.base_url
}
Expand Down Expand Up @@ -293,9 +305,19 @@ async fn validate_local_chainconfig(fetcher: &ConfigFetcher) -> anyhow::Result<(
"Local node configuration files are missing at {}",
local_conf_dir.display()
));
// Ask user if they want to update the chain config.
let update = ask_user_yes_no_question("Would you like to download network configuration?")?;
if update {
let non_interactive = fetcher.non_interactive();
if non_interactive {
println_action_green(
"Downloading",
"local network configuration (non-interactive mode).",
);
}
let should_download = if non_interactive {
true
} else {
ask_user_yes_no_question("Would you like to download network configuration?")?
};
if should_download {
fetcher.download_config(&ChainConfig::Local).await?;
} else {
bail!(
Expand Down Expand Up @@ -324,10 +346,22 @@ async fn validate_remote_chainconfig(
println_warning(&format!(
"A network configuration update detected for {conf}, this might create problems while syncing with rest of the network"
));
// Ask user if they want to update the chain config.
let update = ask_user_yes_no_question("Would you like to update network configuration?")?;
if update {
println_action_green("Updating", &format!("configuration files for {conf}",));
let non_interactive = fetcher.non_interactive();
if non_interactive {
println_action_green(
"Updating",
&format!("configuration files for {conf} (non-interactive mode)",),
);
}
let should_update = if non_interactive {
true
} else {
ask_user_yes_no_question("Would you like to update network configuration?")?
};
if should_update {
if !non_interactive {
println_action_green("Updating", &format!("configuration files for {conf}",));
}
fetcher.download_config(conf).await?;
println_action_green(
"Finished",
Expand All @@ -343,8 +377,11 @@ async fn validate_remote_chainconfig(
/// Check local state of the configuration file in the vault (if they exists)
/// and compare them to the remote one in github. If a change is detected asks
/// user if they want to update, and does the update for them.
pub async fn check_and_update_chain_config(conf: ChainConfig) -> anyhow::Result<()> {
let fetcher = ConfigFetcher::default();
pub async fn check_and_update_chain_config(
conf: ChainConfig,
non_interactive: bool,
) -> anyhow::Result<()> {
let fetcher = ConfigFetcher::new(non_interactive);
match conf {
ChainConfig::Local => validate_local_chainconfig(&fetcher).await?,
remote_config => validate_remote_chainconfig(&fetcher, &remote_config).await?,
Expand Down
4 changes: 4 additions & 0 deletions forc-plugins/forc-node/src/ignition/cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ pub struct IgnitionCmd {
pub db_path: PathBuf,
#[clap(long, default_value_t = MAINNET_BOOTSTRAP_NODE.to_string())]
pub bootstrap_node: String,

/// Skip interactive prompts (intended for scripted/test environments).
#[clap(long, hide = true)]
pub non_interactive: bool,
}

fn default_ignition_db_path() -> PathBuf {
Expand Down
2 changes: 1 addition & 1 deletion forc-plugins/forc-node/src/ignition/op.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ use std::{
/// Configures the node with ignition configuration to connect the node to latest mainnet.
/// Returns `None` if this is a dry_run and no child process created for fuel-core.
pub async fn run(cmd: IgnitionCmd, dry_run: bool) -> anyhow::Result<Option<Child>> {
check_and_update_chain_config(ChainConfig::Ignition).await?;
check_and_update_chain_config(ChainConfig::Ignition, cmd.non_interactive).await?;
let keypair = if let (Some(peer_id), Some(secret)) = (
&cmd.connection_settings.peer_id,
&cmd.connection_settings.secret,
Expand Down
12 changes: 12 additions & 0 deletions forc-plugins/forc-node/src/local/cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ pub struct LocalCmd {

#[arg(long = "poa-instant", env)]
pub poa_instant: bool,

/// URL of the remote node to fork from (enables state forking)
#[clap(long, value_name = "URL")]
pub fork_url: Option<String>,

/// Block number to fork from (latest if not specified)
#[clap(long, value_name = "BLOCK")]
pub fork_block_number: Option<u32>,

/// Skip interactive prompts (intended for scripted/test environments).
#[clap(long, hide = true)]
pub non_interactive: bool,
}

fn get_coins_per_account(
Expand Down
Loading
Loading