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