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