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