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