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}