forest/tool/subcommands/
api_cmd.rs

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