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}