Skip to main content

forest/tool/subcommands/
api_cmd.rs

1// Copyright 2019-2026 ChainSafe Systems
2// SPDX-License-Identifier: Apache-2.0, MIT
3
4mod api_compare_tests;
5pub(crate) mod generate_test_snapshot;
6mod report;
7mod state_decode_params_tests;
8mod stateful_tests;
9mod test_snapshot;
10
11use crate::cli_shared::{chain_path, read_config};
12use crate::db::car::ManyCar;
13use crate::db::db_engine::db_root;
14use crate::eth::EthChainId as EthChainIdType;
15use crate::lotus_json::HasLotusJson;
16use crate::networks::NetworkChain;
17use crate::rpc::{self, ApiPaths, eth::types::*, prelude::*};
18use crate::shim::address::Address;
19use crate::tool::offline_server::start_offline_server;
20use crate::tool::subcommands::api_cmd::stateful_tests::TestTransaction;
21use crate::tool::subcommands::api_cmd::test_snapshot::{Index, Payload};
22use crate::utils::UrlFromMultiAddr;
23use anyhow::{Context as _, bail};
24use cid::Cid;
25use clap::{Subcommand, ValueEnum};
26use fvm_ipld_blockstore::Blockstore;
27use serde::{Deserialize, Serialize};
28use serde_json::Value;
29use std::{
30    io,
31    path::{Path, PathBuf},
32    sync::Arc,
33    time::Instant,
34};
35use test_snapshot::RpcTestSnapshot;
36
37#[derive(Debug, Copy, Clone, PartialEq, ValueEnum)]
38pub enum NodeType {
39    Forest,
40    Lotus,
41}
42
43/// Report mode for the API compare tests.
44#[derive(Debug, Clone, Copy, ValueEnum)]
45pub enum ReportMode {
46    /// Show everything
47    Full,
48    /// Show summary and failures only
49    FailureOnly,
50    /// Show summary only
51    Summary,
52}
53
54#[derive(Debug, Subcommand)]
55#[allow(clippy::large_enum_variant)]
56pub enum ApiCommands {
57    /// Starts an offline RPC server using provided snapshot files.
58    ///
59    /// This command launches a local RPC server for development and testing purposes.
60    /// Additionally, it can be used to serve data from archival snapshots.
61    Serve {
62        /// Snapshot input paths. Supports `.car`, `.car.zst`, and `.forest.car.zst`.
63        snapshot_files: Vec<PathBuf>,
64        /// Filecoin network chain
65        #[arg(long)]
66        chain: Option<NetworkChain>,
67        // RPC port
68        #[arg(long, default_value_t = crate::rpc::DEFAULT_PORT)]
69        port: u16,
70        // Allow downloading snapshot automatically
71        #[arg(long)]
72        auto_download_snapshot: bool,
73        /// Validate snapshot at given EPOCH, use a negative value -N to validate
74        /// the last N EPOCH(s) starting at HEAD.
75        #[arg(long, default_value_t = -50)]
76        height: i64,
77        /// Backfill index for the given EPOCH(s)
78        #[arg(long, default_value_t = 0)]
79        index_backfill_epochs: usize,
80        /// Genesis file path, only applicable for devnet
81        #[arg(long)]
82        genesis: Option<PathBuf>,
83        /// If provided, indicates the file to which to save the admin token.
84        #[arg(long)]
85        save_token: Option<PathBuf>,
86    },
87    /// Compare two RPC providers.
88    ///
89    /// The providers are labeled `forest` and `lotus`,
90    /// but other nodes may be used (such as `venus`).
91    ///
92    /// The `lotus` node is assumed to be correct and the `forest` node will be
93    /// marked as incorrect if it deviates.
94    ///
95    /// If snapshot files are provided,
96    /// these files will be used to generate additional tests.
97    ///
98    /// Example output:
99    /// ```markdown
100    /// | RPC Method                        | Forest              | Lotus         |
101    /// |-----------------------------------|---------------------|---------------|
102    /// | Filecoin.ChainGetBlock            | Valid               | Valid         |
103    /// | Filecoin.ChainGetGenesis          | Valid               | Valid         |
104    /// | Filecoin.ChainGetMessage (67)     | InternalServerError | Valid         |
105    /// ```
106    /// The number after a method name indicates how many times an RPC call was tested.
107    Compare {
108        /// Forest address
109        #[clap(long, default_value = "/ip4/127.0.0.1/tcp/2345/http")]
110        forest: UrlFromMultiAddr,
111        /// Lotus address
112        #[clap(long, default_value = "/ip4/127.0.0.1/tcp/1234/http")]
113        lotus: UrlFromMultiAddr,
114        /// Filter which tests to run according to method name. Case sensitive.
115        #[arg(long, default_value = "")]
116        filter: String,
117        /// Filter file which tests to run according to method name. Case sensitive.
118        /// The file should contain one entry per line. Lines starting with `!`
119        /// are considered as rejected methods, while the others are allowed.
120        /// Empty lines and lines starting with `#` are ignored.
121        #[arg(long)]
122        filter_file: Option<PathBuf>,
123        /// Filter methods for the specific API version.
124        #[arg(long)]
125        filter_version: Option<ApiPaths>,
126        /// Cancel test run on the first failure
127        #[arg(long)]
128        fail_fast: bool,
129
130        #[arg(long, value_enum, default_value_t = RunIgnored::Default)]
131        /// Behavior for tests marked as `ignored`.
132        run_ignored: RunIgnored,
133        /// Maximum number of concurrent requests
134        #[arg(long, default_value = "8")]
135        max_concurrent_requests: usize,
136
137        #[command(flatten)]
138        create_tests_args: CreateTestsArgs,
139
140        /// Specify a directory to which the RPC tests are dumped
141        #[arg(long)]
142        dump_dir: Option<PathBuf>,
143
144        /// Additional overrides to modify success criteria for tests
145        #[arg(long, value_enum, num_args = 0.., use_value_delimiter = true, value_delimiter = ',', default_values_t = [TestCriteriaOverride::TimeoutAndTimeout])]
146        test_criteria_overrides: Vec<TestCriteriaOverride>,
147
148        /// Specify a directory to dump the test report
149        #[arg(long)]
150        report_dir: Option<PathBuf>,
151
152        /// Report detail level: full (default), failure-only, or summary
153        #[arg(long, value_enum, default_value = "full")]
154        report_mode: ReportMode,
155    },
156    /// Generates RPC test snapshots from test dump files and a Forest database.
157    ///
158    /// This command processes test dump files and creates RPC snapshots for use in automated testing.
159    /// You can specify the database folder, network chain, and output directory. Optionally, you can allow
160    /// generating snapshots even if Lotus and Forest responses differ, which is useful for non-deterministic tests.
161    ///
162    /// See additional documentation in the <https://docs.forest.chainsafe.io/developers/guides/rpc_test_snapshot/>.
163    GenerateTestSnapshot {
164        /// Path to test dumps that are generated by `forest-tool api dump-tests` command
165        #[arg(num_args = 1.., required = true)]
166        test_dump_files: Vec<PathBuf>,
167        /// Path to the database folder that powers a Forest node
168        #[arg(long)]
169        db: Option<PathBuf>,
170        /// Filecoin network chain
171        #[arg(long, required = true)]
172        chain: NetworkChain,
173        #[arg(long, required = true)]
174        /// Folder into which test snapshots are dumped
175        out_dir: PathBuf,
176        /// Allow generating snapshot even if Lotus generated a different response. This is useful
177        /// when the response is not deterministic or a failing test is expected.
178        /// If generating a failing test, use `Lotus` as the argument to ensure the test passes
179        /// only when the response from Forest is fixed and matches the response from Lotus.
180        #[arg(long)]
181        use_response_from: Option<NodeType>,
182        /// Allow generating snapshot even if the test fails.
183        #[arg(long, default_value_t = false)]
184        allow_failure: bool,
185    },
186    /// Dumps RPC test cases for a specified API path.
187    ///
188    /// This command generates and outputs RPC test cases for a given API path, optionally including ignored tests.
189    /// Useful for inspecting or exporting test cases for further analysis or manual review.
190    ///
191    /// See additional documentation in the <https://docs.forest.chainsafe.io/developers/guides/rpc_test_snapshot/>.
192    DumpTests {
193        #[command(flatten)]
194        create_tests_args: CreateTestsArgs,
195        /// Which API path to dump.
196        #[arg(long)]
197        path: rpc::ApiPaths,
198        #[arg(long)]
199        include_ignored: bool,
200    },
201    /// Runs RPC tests using provided test snapshot files.
202    ///
203    /// This command executes RPC tests based on previously generated test snapshots, reporting success or failure for each test.
204    /// Useful for validating node behavior against expected responses.
205    ///
206    /// See additional documentation in the <https://docs.forest.chainsafe.io/developers/guides/rpc_test_snapshot/>.
207    Test {
208        /// Path to test snapshots that are generated by `forest-tool api generate-test-snapshot` command
209        #[arg(num_args = 1.., required = true)]
210        files: Vec<PathBuf>,
211    },
212    /// Run multiple stateful JSON-RPC API tests against a Filecoin node.
213    ///
214    /// Connection: uses `FULLNODE_API_INFO` from the environment.
215    ///
216    /// Some tests require sending a transaction to trigger events; the provided
217    /// `from`, `to`, `payload`, and `topic` inputs are used for those cases.
218    ///
219    /// Useful for verifying methods like `eth_newFilter`, `eth_getFilterLogs`, and others
220    /// that rely on internal state.
221    ///
222    /// Inputs:
223    /// - `--to`, `--from`: delegated Filecoin (f4) addresses
224    /// - `--payload`: calldata in hex (accepts optional `0x` prefix)
225    /// - `--topic`: `32‑byte` event topic in hex
226    /// - `--filter`: run only tests that interact with a specific RPC method
227    ///
228    /// Example output:
229    /// ```text
230    /// running 7 tests
231    /// test eth_newFilter install/uninstall ... ok
232    /// test eth_newFilter under limit ... ok
233    /// test eth_newFilter just under limit ... ok
234    /// test eth_newFilter over limit ... ok
235    /// test eth_newBlockFilter works ... ok
236    /// test eth_newPendingTransactionFilter works ... ok
237    /// test eth_getFilterLogs works ... ok
238    /// test result: ok. 7 passed; 0 failed; 0 ignored; 0 filtered out
239    /// ```
240    TestStateful {
241        /// Test Transaction `to` address (delegated f4)
242        #[arg(long)]
243        to: Address,
244        /// Test Transaction `from` address (delegated f4)
245        #[arg(long)]
246        from: Address,
247        /// Test Transaction hex `payload`
248        #[arg(long)]
249        payload: String,
250        /// Log `topic` to search for
251        #[arg(long)]
252        topic: EthHash,
253        /// Filter which tests to run according to method name. Case sensitive.
254        #[arg(long, default_value = "")]
255        filter: String,
256    },
257}
258
259impl ApiCommands {
260    pub async fn run(self) -> anyhow::Result<()> {
261        match self {
262            Self::Serve {
263                snapshot_files,
264                chain,
265                port,
266                auto_download_snapshot,
267                height,
268                index_backfill_epochs,
269                genesis,
270                save_token,
271            } => {
272                start_offline_server(
273                    snapshot_files,
274                    chain,
275                    port,
276                    auto_download_snapshot,
277                    height,
278                    index_backfill_epochs,
279                    genesis,
280                    save_token,
281                )
282                .await?;
283            }
284            Self::Compare {
285                forest: UrlFromMultiAddr(forest),
286                lotus: UrlFromMultiAddr(lotus),
287                filter,
288                filter_file,
289                filter_version,
290                fail_fast,
291                run_ignored,
292                max_concurrent_requests,
293                create_tests_args,
294                dump_dir,
295                test_criteria_overrides,
296                report_dir,
297                report_mode,
298            } => {
299                let forest = Arc::new(rpc::Client::from_url(forest));
300                let lotus = Arc::new(rpc::Client::from_url(lotus));
301                let tests = api_compare_tests::create_tests(create_tests_args.clone()).await?;
302
303                api_compare_tests::run_tests(
304                    tests,
305                    forest,
306                    lotus,
307                    max_concurrent_requests,
308                    filter_file,
309                    filter,
310                    filter_version,
311                    run_ignored,
312                    fail_fast,
313                    dump_dir,
314                    &test_criteria_overrides,
315                    report_dir,
316                    report_mode,
317                )
318                .await?;
319            }
320            Self::GenerateTestSnapshot {
321                test_dump_files,
322                db,
323                chain,
324                out_dir,
325                use_response_from,
326                allow_failure,
327            } => {
328                unsafe { std::env::set_var("FOREST_TIPSET_CACHE_DISABLED", "1") };
329                if !out_dir.is_dir() {
330                    std::fs::create_dir_all(&out_dir)?;
331                }
332                let db = if let Some(db) = db {
333                    db
334                } else {
335                    let (_, config) = read_config(None, Some(chain.clone()))?;
336                    db_root(&chain_path(&config))?
337                };
338                let tracking_db = generate_test_snapshot::load_db(&db, None).await?;
339                for test_dump_file in test_dump_files {
340                    let out_path = out_dir
341                        .join(test_dump_file.file_name().context("Infallible")?)
342                        .with_extension("rpcsnap.json");
343                    let test_dump = serde_json::from_reader(std::fs::File::open(&test_dump_file)?)?;
344                    print!("Generating RPC snapshot at {} ...", out_path.display());
345                    let allow_response_mismatch = use_response_from.is_some();
346                    match generate_test_snapshot::run_test_with_dump(
347                        &test_dump,
348                        tracking_db.clone(),
349                        &chain,
350                        allow_response_mismatch,
351                        allow_failure,
352                    )
353                    .await
354                    {
355                        Ok(_) => {
356                            let snapshot = {
357                                tracking_db.ensure_chain_head_is_tracked()?;
358                                let mut db = vec![];
359                                tracking_db.export_forest_car(&mut db).await?;
360                                let index =
361                                    generate_test_snapshot::build_index(tracking_db.clone());
362                                RpcTestSnapshot {
363                                    chain: chain.clone(),
364                                    name: test_dump.request.method_name.to_string(),
365                                    params: test_dump.request.params,
366                                    response: match use_response_from {
367                                        Some(NodeType::Forest) | None => test_dump.forest_response,
368                                        Some(NodeType::Lotus) => test_dump.lotus_response,
369                                    },
370                                    index,
371                                    db,
372                                    api_path: Some(test_dump.path),
373                                }
374                            };
375
376                            std::fs::write(&out_path, serde_json::to_string_pretty(&snapshot)?)?;
377                            println!(" Succeeded");
378                        }
379                        Err(e) => {
380                            println!(" Failed: {e}");
381                        }
382                    };
383                }
384            }
385            Self::Test { files } => {
386                for path in files {
387                    print!("Running RPC test with snapshot {} ...", path.display());
388                    let start = Instant::now();
389                    match test_snapshot::run_test_from_snapshot(&path).await {
390                        Ok(_) => {
391                            println!(
392                                "  succeeded, took {}.",
393                                humantime::format_duration(start.elapsed())
394                            );
395                        }
396                        Err(e) => {
397                            println!(" Failed: {e}");
398                        }
399                    };
400                }
401            }
402            Self::TestStateful {
403                to,
404                from,
405                payload,
406                topic,
407                filter,
408            } => {
409                let client = Arc::new(rpc::Client::default_or_from_env(None)?);
410
411                let payload = {
412                    let clean = payload.strip_prefix("0x").unwrap_or(&payload);
413                    hex::decode(clean)
414                        .with_context(|| format!("invalid --payload hex: {payload}"))?
415                };
416                let tx = TestTransaction {
417                    to,
418                    from,
419                    payload,
420                    topic,
421                };
422
423                let tests = stateful_tests::create_tests(tx).await;
424                stateful_tests::run_tests(tests, client, filter).await?;
425            }
426            Self::DumpTests {
427                create_tests_args,
428                path,
429                include_ignored,
430            } => {
431                for api_compare_tests::RpcTest {
432                    request:
433                        rpc::Request {
434                            method_name,
435                            params,
436                            api_paths,
437                            ..
438                        },
439                    ignore,
440                    ..
441                } in api_compare_tests::create_tests(create_tests_args).await?
442                {
443                    if !api_paths.contains(path) {
444                        continue;
445                    }
446                    if ignore.is_some() && !include_ignored {
447                        continue;
448                    }
449
450                    let dialogue = Dialogue {
451                        method: method_name.into(),
452                        params: match params {
453                            Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {
454                                bail!("params may not be a primitive")
455                            }
456                            Value::Array(v) => {
457                                Some(ez_jsonrpc_types::RequestParameters::ByPosition(v))
458                            }
459                            Value::Object(it) => Some(ez_jsonrpc_types::RequestParameters::ByName(
460                                it.into_iter().collect(),
461                            )),
462                        },
463                        response: None,
464                    };
465                    serde_json::to_writer(io::stdout(), &dialogue)?;
466                    println!();
467                }
468            }
469        }
470        Ok(())
471    }
472}
473
474#[derive(clap::Args, Debug, Clone)]
475pub struct CreateTestsArgs {
476    /// The nodes to test against is offline, the chain is out of sync.
477    #[arg(long, default_value_t = false)]
478    offline: bool,
479    /// The number of tipsets to use to generate test cases.
480    #[arg(short, long, default_value = "10")]
481    n_tipsets: usize,
482    /// Miner address to use for miner tests. Miner worker key must be in the key-store.
483    #[arg(long)]
484    miner_address: Option<Address>,
485    /// Worker address to use where key is applicable. Worker key must be in the key-store.
486    #[arg(long)]
487    worker_address: Option<Address>,
488    /// Ethereum chain ID. Default to the calibnet chain ID.
489    #[arg(long, default_value_t = crate::networks::calibnet::ETH_CHAIN_ID)]
490    eth_chain_id: EthChainIdType,
491    /// Snapshot input paths. Supports `.car`, `.car.zst`, and `.forest.car.zst`.
492    snapshot_files: Vec<PathBuf>,
493}
494
495#[derive(Debug, Copy, Clone, PartialEq, ValueEnum)]
496pub enum TestCriteriaOverride {
497    /// Test pass when first endpoint returns a valid result and the second one timeout
498    ValidAndTimeout,
499    /// Test pass when both endpoints timeout
500    TimeoutAndTimeout,
501}
502
503#[derive(Debug, Serialize, Deserialize)]
504pub struct Dialogue {
505    method: String,
506    #[serde(skip_serializing_if = "Option::is_none")]
507    params: Option<ez_jsonrpc_types::RequestParameters>,
508    #[serde(skip_serializing_if = "Option::is_none")]
509    response: Option<DialogueResponse>,
510}
511
512#[derive(Debug, Serialize, Deserialize)]
513#[serde(rename_all = "lowercase")]
514enum DialogueResponse {
515    Result(Value),
516    Error(ez_jsonrpc_types::Error),
517}
518
519#[derive(ValueEnum, Debug, Clone, Copy)]
520#[clap(rename_all = "kebab_case")]
521pub enum RunIgnored {
522    Default,
523    IgnoredOnly,
524    All,
525}