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}