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        /// Number of retries for each test
157        #[arg(long, default_value = "2")]
158        n_retries: usize,
159    },
160    /// Generates RPC test snapshots from test dump files and a Forest database.
161    ///
162    /// This command processes test dump files and creates RPC snapshots for use in automated testing.
163    /// You can specify the database folder, network chain, and output directory. Optionally, you can allow
164    /// generating snapshots even if Lotus and Forest responses differ, which is useful for non-deterministic tests.
165    ///
166    /// See additional documentation in the <https://docs.forest.chainsafe.io/developers/guides/rpc_test_snapshot/>.
167    GenerateTestSnapshot {
168        /// Path to test dumps that are generated by `forest-tool api dump-tests` command
169        #[arg(num_args = 1.., required = true)]
170        test_dump_files: Vec<PathBuf>,
171        /// Path to the database folder that powers a Forest node
172        #[arg(long)]
173        db: Option<PathBuf>,
174        /// Filecoin network chain
175        #[arg(long, required = true)]
176        chain: NetworkChain,
177        #[arg(long, required = true)]
178        /// Folder into which test snapshots are dumped
179        out_dir: PathBuf,
180        /// Allow generating snapshot even if Lotus generated a different response. This is useful
181        /// when the response is not deterministic or a failing test is expected.
182        /// If generating a failing test, use `Lotus` as the argument to ensure the test passes
183        /// only when the response from Forest is fixed and matches the response from Lotus.
184        #[arg(long)]
185        use_response_from: Option<NodeType>,
186        /// Allow generating snapshot even if the test fails.
187        #[arg(long, default_value_t = false)]
188        allow_failure: bool,
189    },
190    /// Dumps RPC test cases for a specified API path.
191    ///
192    /// This command generates and outputs RPC test cases for a given API path, optionally including ignored tests.
193    /// Useful for inspecting or exporting test cases for further analysis or manual review.
194    ///
195    /// See additional documentation in the <https://docs.forest.chainsafe.io/developers/guides/rpc_test_snapshot/>.
196    DumpTests {
197        #[command(flatten)]
198        create_tests_args: CreateTestsArgs,
199        /// Which API path to dump.
200        #[arg(long)]
201        path: rpc::ApiPaths,
202        #[arg(long)]
203        include_ignored: bool,
204    },
205    /// Runs RPC tests using provided test snapshot files.
206    ///
207    /// This command executes RPC tests based on previously generated test snapshots, reporting success or failure for each test.
208    /// Useful for validating node behavior against expected responses.
209    ///
210    /// See additional documentation in the <https://docs.forest.chainsafe.io/developers/guides/rpc_test_snapshot/>.
211    Test {
212        /// Path to test snapshots that are generated by `forest-tool api generate-test-snapshot` command
213        #[arg(num_args = 1.., required = true)]
214        files: Vec<PathBuf>,
215    },
216    /// Run multiple stateful JSON-RPC API tests against a Filecoin node.
217    ///
218    /// Connection: uses `FULLNODE_API_INFO` from the environment.
219    ///
220    /// Some tests require sending a transaction to trigger events; the provided
221    /// `from`, `to`, `payload`, and `topic` inputs are used for those cases.
222    ///
223    /// Useful for verifying methods like `eth_newFilter`, `eth_getFilterLogs`, and others
224    /// that rely on internal state.
225    ///
226    /// Inputs:
227    /// - `--to`, `--from`: delegated Filecoin (f4) addresses
228    /// - `--payload`: calldata in hex (accepts optional `0x` prefix)
229    /// - `--topic`: `32‑byte` event topic in hex
230    /// - `--filter`: run only tests that interact with a specific RPC method
231    ///
232    /// Example output:
233    /// ```text
234    /// running 7 tests
235    /// test eth_newFilter install/uninstall ... ok
236    /// test eth_newFilter under limit ... ok
237    /// test eth_newFilter just under limit ... ok
238    /// test eth_newFilter over limit ... ok
239    /// test eth_newBlockFilter works ... ok
240    /// test eth_newPendingTransactionFilter works ... ok
241    /// test eth_getFilterLogs works ... ok
242    /// test result: ok. 7 passed; 0 failed; 0 ignored; 0 filtered out
243    /// ```
244    TestStateful {
245        /// Test Transaction `to` address (delegated f4)
246        #[arg(long)]
247        to: Address,
248        /// Test Transaction `from` address (delegated f4)
249        #[arg(long)]
250        from: Address,
251        /// Test Transaction hex `payload`
252        #[arg(long)]
253        payload: String,
254        /// Log `topic` to search for
255        #[arg(long)]
256        topic: EthHash,
257        /// Filter which tests to run according to method name. Case sensitive.
258        #[arg(long, default_value = "")]
259        filter: String,
260    },
261}
262
263impl ApiCommands {
264    pub async fn run(self) -> anyhow::Result<()> {
265        match self {
266            Self::Serve {
267                snapshot_files,
268                chain,
269                port,
270                auto_download_snapshot,
271                height,
272                index_backfill_epochs,
273                genesis,
274                save_token,
275            } => {
276                start_offline_server(
277                    snapshot_files,
278                    chain,
279                    port,
280                    auto_download_snapshot,
281                    height,
282                    index_backfill_epochs,
283                    genesis,
284                    save_token,
285                )
286                .await?;
287            }
288            Self::Compare {
289                forest: UrlFromMultiAddr(forest),
290                lotus: UrlFromMultiAddr(lotus),
291                filter,
292                filter_file,
293                filter_version,
294                fail_fast,
295                run_ignored,
296                max_concurrent_requests,
297                create_tests_args,
298                dump_dir,
299                test_criteria_overrides,
300                report_dir,
301                report_mode,
302                n_retries,
303            } => {
304                let forest = Arc::new(rpc::Client::from_url(forest));
305                let lotus = Arc::new(rpc::Client::from_url(lotus));
306                let tests = api_compare_tests::create_tests(create_tests_args.clone()).await?;
307
308                api_compare_tests::run_tests(
309                    tests,
310                    forest,
311                    lotus,
312                    max_concurrent_requests,
313                    filter_file,
314                    filter,
315                    filter_version,
316                    run_ignored,
317                    fail_fast,
318                    dump_dir,
319                    &test_criteria_overrides,
320                    report_dir,
321                    report_mode,
322                    n_retries,
323                )
324                .await?;
325            }
326            Self::GenerateTestSnapshot {
327                test_dump_files,
328                db,
329                chain,
330                out_dir,
331                use_response_from,
332                allow_failure,
333            } => {
334                unsafe { std::env::set_var("FOREST_TIPSET_CACHE_DISABLED", "1") };
335                if !out_dir.is_dir() {
336                    std::fs::create_dir_all(&out_dir)?;
337                }
338                let db = if let Some(db) = db {
339                    db
340                } else {
341                    let (_, config) = read_config(None, Some(chain.clone()))?;
342                    db_root(&chain_path(&config))?
343                };
344                let tracking_db = generate_test_snapshot::load_db(&db, None).await?;
345                for test_dump_file in test_dump_files {
346                    let out_path = out_dir
347                        .join(test_dump_file.file_name().context("Infallible")?)
348                        .with_extension("rpcsnap.json");
349                    let test_dump = serde_json::from_reader(std::fs::File::open(&test_dump_file)?)?;
350                    print!("Generating RPC snapshot at {} ...", out_path.display());
351                    let allow_response_mismatch = use_response_from.is_some();
352                    match generate_test_snapshot::run_test_with_dump(
353                        &test_dump,
354                        tracking_db.clone(),
355                        &chain,
356                        allow_response_mismatch,
357                        allow_failure,
358                    )
359                    .await
360                    {
361                        Ok(_) => {
362                            let snapshot = {
363                                tracking_db.ensure_chain_head_is_tracked()?;
364                                let mut db = vec![];
365                                tracking_db.export_forest_car(&mut db).await?;
366                                let index =
367                                    generate_test_snapshot::build_index(tracking_db.clone());
368                                RpcTestSnapshot {
369                                    chain: chain.clone(),
370                                    name: test_dump.request.method_name.to_string(),
371                                    params: test_dump.request.params,
372                                    response: match use_response_from {
373                                        Some(NodeType::Forest) | None => test_dump.forest_response,
374                                        Some(NodeType::Lotus) => test_dump.lotus_response,
375                                    },
376                                    index,
377                                    db,
378                                    api_path: Some(test_dump.path),
379                                }
380                            };
381
382                            std::fs::write(&out_path, serde_json::to_string_pretty(&snapshot)?)?;
383                            println!(" Succeeded");
384                        }
385                        Err(e) => {
386                            println!(" Failed: {e}");
387                        }
388                    };
389                }
390            }
391            Self::Test { files } => {
392                for path in files {
393                    print!("Running RPC test with snapshot {} ...", path.display());
394                    let start = Instant::now();
395                    match test_snapshot::run_test_from_snapshot(&path).await {
396                        Ok(_) => {
397                            println!(
398                                "  succeeded, took {}.",
399                                humantime::format_duration(start.elapsed())
400                            );
401                        }
402                        Err(e) => {
403                            println!(" Failed: {e}");
404                        }
405                    };
406                }
407            }
408            Self::TestStateful {
409                to,
410                from,
411                payload,
412                topic,
413                filter,
414            } => {
415                let client = Arc::new(rpc::Client::default_or_from_env(None)?);
416
417                let payload = {
418                    let clean = payload.strip_prefix("0x").unwrap_or(&payload);
419                    hex::decode(clean)
420                        .with_context(|| format!("invalid --payload hex: {payload}"))?
421                };
422                let tx = TestTransaction {
423                    to,
424                    from,
425                    payload,
426                    topic,
427                };
428
429                let tests = stateful_tests::create_tests(tx).await;
430                stateful_tests::run_tests(tests, client, filter).await?;
431            }
432            Self::DumpTests {
433                create_tests_args,
434                path,
435                include_ignored,
436            } => {
437                for api_compare_tests::RpcTest {
438                    request:
439                        rpc::Request {
440                            method_name,
441                            params,
442                            api_paths,
443                            ..
444                        },
445                    ignore,
446                    ..
447                } in api_compare_tests::create_tests(create_tests_args).await?
448                {
449                    if !api_paths.contains(path) {
450                        continue;
451                    }
452                    if ignore.is_some() && !include_ignored {
453                        continue;
454                    }
455
456                    let dialogue = Dialogue {
457                        method: method_name.into(),
458                        params: match params {
459                            Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {
460                                bail!("params may not be a primitive")
461                            }
462                            Value::Array(v) => {
463                                Some(ez_jsonrpc_types::RequestParameters::ByPosition(v))
464                            }
465                            Value::Object(it) => Some(ez_jsonrpc_types::RequestParameters::ByName(
466                                it.into_iter().collect(),
467                            )),
468                        },
469                        response: None,
470                    };
471                    serde_json::to_writer(io::stdout(), &dialogue)?;
472                    println!();
473                }
474            }
475        }
476        Ok(())
477    }
478}
479
480#[derive(clap::Args, Debug, Clone)]
481pub struct CreateTestsArgs {
482    /// The nodes to test against is offline, the chain is out of sync.
483    #[arg(long, default_value_t = false)]
484    offline: bool,
485    /// The number of tipsets to use to generate test cases.
486    #[arg(short, long, default_value = "10")]
487    n_tipsets: usize,
488    /// Miner address to use for miner tests. Miner worker key must be in the key-store.
489    #[arg(long)]
490    miner_address: Option<Address>,
491    /// Worker address to use where key is applicable. Worker key must be in the key-store.
492    #[arg(long)]
493    worker_address: Option<Address>,
494    /// Ethereum chain ID. Default to the calibnet chain ID.
495    #[arg(long, default_value_t = crate::networks::calibnet::ETH_CHAIN_ID)]
496    eth_chain_id: EthChainIdType,
497    /// Snapshot input paths. Supports `.car`, `.car.zst`, and `.forest.car.zst`.
498    snapshot_files: Vec<PathBuf>,
499}
500
501#[derive(Debug, Copy, Clone, PartialEq, ValueEnum)]
502pub enum TestCriteriaOverride {
503    /// Test pass when first endpoint returns a valid result and the second one timeout
504    ValidAndTimeout,
505    /// Test pass when both endpoints timeout
506    TimeoutAndTimeout,
507}
508
509#[derive(Debug, Serialize, Deserialize)]
510pub struct Dialogue {
511    method: String,
512    #[serde(skip_serializing_if = "Option::is_none")]
513    params: Option<ez_jsonrpc_types::RequestParameters>,
514    #[serde(skip_serializing_if = "Option::is_none")]
515    response: Option<DialogueResponse>,
516}
517
518#[derive(Debug, Serialize, Deserialize)]
519#[serde(rename_all = "lowercase")]
520enum DialogueResponse {
521    Result(Value),
522    Error(ez_jsonrpc_types::Error),
523}
524
525#[derive(ValueEnum, Debug, Clone, Copy)]
526#[clap(rename_all = "kebab_case")]
527pub enum RunIgnored {
528    Default,
529    IgnoredOnly,
530    All,
531}