Skip to main content

forest/rpc/methods/
chain.rs

1// Copyright 2019-2026 ChainSafe Systems
2// SPDX-License-Identifier: Apache-2.0, MIT
3
4pub mod types;
5use types::*;
6
7#[cfg(test)]
8use crate::blocks::RawBlockHeader;
9use crate::blocks::{Block, CachingBlockHeader, Tipset, TipsetKey};
10use crate::chain::index::ResolveNullTipset;
11use crate::chain::{ChainStore, ExportOptions, FilecoinSnapshotVersion, HeadChange};
12use crate::chain_sync::{get_full_tipset, load_full_tipset};
13use crate::cid_collections::{CidHashSet, FileBackedCidHashSet};
14use crate::ipld::DfsIter;
15use crate::ipld::{CHAIN_EXPORT_STATUS, cancel_export, end_export, start_export};
16use crate::lotus_json::{HasLotusJson, LotusJson, lotus_json_with_self};
17#[cfg(test)]
18use crate::lotus_json::{assert_all_snapshots, assert_unchanged_via_json};
19use crate::message::{ChainMessage, SignedMessage};
20use crate::rpc::eth::Block as EthBlock;
21use crate::rpc::eth::{
22    EthLog, TxInfo, eth_logs_with_filter, types::ApiHeaders, types::EthFilterSpec,
23};
24use crate::rpc::f3::F3ExportLatestSnapshot;
25use crate::rpc::types::*;
26use crate::rpc::{ApiPaths, Ctx, EthEventHandler, Permission, RpcMethod, ServerError};
27use crate::shim::clock::ChainEpoch;
28use crate::shim::error::ExitCode;
29use crate::shim::executor::Receipt;
30use crate::shim::message::Message;
31use crate::utils::ShallowClone;
32use crate::utils::db::CborStoreExt as _;
33use crate::utils::io::VoidAsyncWriter;
34use crate::utils::misc::env::is_env_truthy;
35use anyhow::{Context as _, Result};
36use cid::Cid;
37use enumflags2::{BitFlags, make_bitflags};
38use fvm_ipld_blockstore::Blockstore;
39use fvm_ipld_encoding::{CborStore, RawBytes};
40use hex::ToHex;
41use ipld_core::ipld::Ipld;
42use itertools::Itertools as _;
43use jsonrpsee::types::Params;
44use jsonrpsee::types::error::ErrorObjectOwned;
45use num::BigInt;
46use schemars::JsonSchema;
47use serde::{Deserialize, Serialize};
48use sha2::Sha256;
49use std::fs::File;
50use std::sync::Arc;
51use std::{collections::VecDeque, path::PathBuf, sync::LazyLock};
52use tokio::sync::{
53    Mutex,
54    broadcast::{self, Receiver as Subscriber},
55};
56use tokio::task::JoinHandle;
57use tokio_util::sync::CancellationToken;
58
59const HEAD_CHANNEL_CAPACITY: usize = 10;
60
61/// [`SAFE_HEIGHT_DISTANCE`] is the distance from the latest tipset, i.e. "heaviest", that
62/// is considered to be safe from re-orgs at an increasingly diminishing
63/// probability.
64///
65/// This is used to determine the safe tipset when using the "safe" tag in
66/// [`TipsetSelector`] or via Eth JSON-RPC APIs. Note that "safe" doesn't guarantee
67/// finality, but rather a high probability of not being reverted. For guaranteed
68/// finality, use the "finalized" tag.
69///
70/// This constant is experimental and may change in the future.
71/// Discussion on this current value and a tracking item to document the
72/// probabilistic impact of various values is in
73/// https://github.com/filecoin-project/go-f3/issues/944
74pub const SAFE_HEIGHT_DISTANCE: ChainEpoch = 200;
75
76static CHAIN_EXPORT_LOCK: LazyLock<Mutex<Option<CancellationToken>>> =
77    LazyLock::new(|| Mutex::new(None));
78
79/// Subscribes to head changes from the chain store and broadcasts new blocks.
80///
81/// # Notes
82///
83/// Spawns an internal `tokio` task that can be aborted anytime via the returned `JoinHandle`,
84/// allowing manual cleanup if needed.
85pub(crate) fn new_heads<DB: Blockstore + Send + Sync + 'static>(
86    data: Ctx<DB>,
87) -> (Subscriber<ApiHeaders>, JoinHandle<()>) {
88    let (sender, receiver) = broadcast::channel(HEAD_CHANNEL_CAPACITY);
89
90    let mut head_changes_rx = data.chain_store().subscribe_head_changes();
91
92    let handle = tokio::spawn(async move {
93        while let Ok(changes) = head_changes_rx.recv().await {
94            for ts in changes.applies {
95                // Convert the tipset to an Ethereum block with full transaction info
96                // Note: In Filecoin's Eth RPC, a tipset maps to a single Ethereum block
97                match EthBlock::from_filecoin_tipset(data.clone(), ts, TxInfo::Full).await {
98                    Ok(block) => {
99                        if let Err(e) = sender.send(ApiHeaders(block)) {
100                            tracing::error!("Failed to send headers: {}", e);
101                            return;
102                        }
103                    }
104                    Err(e) => {
105                        tracing::error!("Failed to convert tipset to eth block: {}", e);
106                    }
107                }
108            }
109        }
110    });
111
112    (receiver, handle)
113}
114
115/// Subscribes to head changes from the chain store and broadcasts new `Ethereum` logs.
116///
117/// # Notes
118///
119/// Spawns an internal `tokio` task that can be aborted anytime via the returned `JoinHandle`,
120/// allowing manual cleanup if needed.
121pub(crate) fn logs<DB: Blockstore + Sync + Send + 'static>(
122    ctx: &Ctx<DB>,
123    filter: Option<EthFilterSpec>,
124) -> (Subscriber<Vec<EthLog>>, JoinHandle<()>) {
125    let (sender, receiver) = broadcast::channel(HEAD_CHANNEL_CAPACITY);
126
127    let mut head_changes_rx = ctx.chain_store().subscribe_head_changes();
128
129    let ctx = ctx.clone();
130
131    let handle = tokio::spawn(async move {
132        while let Ok(changes) = head_changes_rx.recv().await {
133            for ts in changes.applies {
134                match eth_logs_with_filter(&ctx, &ts, filter.clone(), None).await {
135                    Ok(logs) => {
136                        if !logs.is_empty()
137                            && let Err(e) = sender.send(logs)
138                        {
139                            tracing::error!("Failed to send logs for tipset {}: {}", ts.key(), e);
140                            break;
141                        }
142                    }
143                    Err(e) => {
144                        tracing::error!("Failed to fetch logs for tipset {}: {}", ts.key(), e);
145                    }
146                }
147            }
148        }
149    });
150
151    (receiver, handle)
152}
153
154pub enum ChainGetFinalizedTipset {}
155impl RpcMethod<0> for ChainGetFinalizedTipset {
156    const NAME: &'static str = "Filecoin.ChainGetFinalizedTipSet";
157    const PARAM_NAMES: [&'static str; 0] = [];
158    const API_PATHS: BitFlags<ApiPaths> = make_bitflags!(ApiPaths::V1);
159    const PERMISSION: Permission = Permission::Read;
160    const DESCRIPTION: Option<&'static str> = Some(
161        "Returns the latest F3 finalized tipset, or falls back to EC finality if F3 is not operational on the node or if the F3 finalized tipset is further back than EC finalized tipset.",
162    );
163
164    type Params = ();
165    type Ok = Tipset;
166
167    async fn handle(
168        ctx: Ctx<impl Blockstore + Send + Sync + 'static>,
169        (): Self::Params,
170        _: &http::Extensions,
171    ) -> Result<Self::Ok, ServerError> {
172        Ok(ChainGetTipSetV2::get_latest_finalized_tipset(&ctx).await?)
173    }
174}
175
176pub enum ChainGetMessage {}
177impl RpcMethod<1> for ChainGetMessage {
178    const NAME: &'static str = "Filecoin.ChainGetMessage";
179    const PARAM_NAMES: [&'static str; 1] = ["messageCid"];
180    const API_PATHS: BitFlags<ApiPaths> = ApiPaths::all();
181    const PERMISSION: Permission = Permission::Read;
182    const DESCRIPTION: Option<&'static str> = Some("Returns the message with the specified CID.");
183
184    type Params = (Cid,);
185    type Ok = FlattenedApiMessage;
186
187    async fn handle(
188        ctx: Ctx<impl Blockstore>,
189        (message_cid,): Self::Params,
190        _: &http::Extensions,
191    ) -> Result<Self::Ok, ServerError> {
192        let chain_message: ChainMessage = ctx
193            .store()
194            .get_cbor(&message_cid)?
195            .with_context(|| format!("can't find message with cid {message_cid}"))?;
196        let message = match chain_message {
197            ChainMessage::Signed(m) => Arc::unwrap_or_clone(m).into_message(),
198            ChainMessage::Unsigned(m) => Arc::unwrap_or_clone(m),
199        };
200
201        let cid = message.cid();
202        Ok(FlattenedApiMessage { message, cid })
203    }
204}
205
206/// Returns the events stored under the given event AMT root CID.
207/// Errors if the root CID cannot be found in the blockstore.
208pub enum ChainGetEvents {}
209impl RpcMethod<1> for ChainGetEvents {
210    const NAME: &'static str = "Filecoin.ChainGetEvents";
211    const PARAM_NAMES: [&'static str; 1] = ["rootCid"];
212    const API_PATHS: BitFlags<ApiPaths> = ApiPaths::all();
213    const PERMISSION: Permission = Permission::Read;
214    const DESCRIPTION: Option<&'static str> =
215        Some("Returns the events under the given event AMT root CID.");
216
217    type Params = (Cid,);
218    type Ok = Vec<Event>;
219    async fn handle(
220        ctx: Ctx<impl Blockstore + Send + Sync + 'static>,
221        (root_cid,): Self::Params,
222        _: &http::Extensions,
223    ) -> Result<Self::Ok, ServerError> {
224        let events = EthEventHandler::get_events_by_event_root(&ctx, &root_cid)?;
225        Ok(events)
226    }
227}
228
229pub enum ChainGetParentMessages {}
230impl RpcMethod<1> for ChainGetParentMessages {
231    const NAME: &'static str = "Filecoin.ChainGetParentMessages";
232    const PARAM_NAMES: [&'static str; 1] = ["blockCid"];
233    const API_PATHS: BitFlags<ApiPaths> = ApiPaths::all();
234    const PERMISSION: Permission = Permission::Read;
235    const DESCRIPTION: Option<&'static str> =
236        Some("Returns the messages included in the blocks of the parent tipset.");
237
238    type Params = (Cid,);
239    type Ok = Vec<ApiMessage>;
240
241    async fn handle(
242        ctx: Ctx<impl Blockstore + Send + Sync + 'static>,
243        (block_cid,): Self::Params,
244        _: &http::Extensions,
245    ) -> Result<Self::Ok, ServerError> {
246        let store = ctx.store();
247        let block_header: CachingBlockHeader = store
248            .get_cbor(&block_cid)?
249            .with_context(|| format!("can't find block header with cid {block_cid}"))?;
250        if block_header.epoch == 0 {
251            Ok(vec![])
252        } else {
253            let parent_tipset = ctx
254                .chain_index()
255                .load_required_tipset(&block_header.parents)?;
256            load_api_messages_from_tipset(&ctx, parent_tipset.key()).await
257        }
258    }
259}
260
261pub enum ChainGetParentReceipts {}
262impl RpcMethod<1> for ChainGetParentReceipts {
263    const NAME: &'static str = "Filecoin.ChainGetParentReceipts";
264    const PARAM_NAMES: [&'static str; 1] = ["blockCid"];
265    const API_PATHS: BitFlags<ApiPaths> = ApiPaths::all();
266    const PERMISSION: Permission = Permission::Read;
267    const DESCRIPTION: Option<&'static str> =
268        Some("Returns the message receipts included in the blocks of the parent tipset.");
269
270    type Params = (Cid,);
271    type Ok = Vec<ApiReceipt>;
272
273    async fn handle(
274        ctx: Ctx<impl Blockstore>,
275        (block_cid,): Self::Params,
276        _: &http::Extensions,
277    ) -> Result<Self::Ok, ServerError> {
278        let store = ctx.store();
279        let block_header: CachingBlockHeader = store
280            .get_cbor(&block_cid)?
281            .with_context(|| format!("can't find block header with cid {block_cid}"))?;
282        if block_header.epoch == 0 {
283            return Ok(vec![]);
284        }
285        let receipts = Receipt::get_receipts(store, block_header.message_receipts)
286            .map_err(|_| {
287                ErrorObjectOwned::owned::<()>(
288                    1,
289                    format!(
290                        "failed to root: ipld: could not find {}",
291                        block_header.message_receipts
292                    ),
293                    None,
294                )
295            })?
296            .iter()
297            .map(|r| ApiReceipt {
298                exit_code: r.exit_code().into(),
299                return_data: r.return_data(),
300                gas_used: r.gas_used(),
301                events_root: r.events_root(),
302            })
303            .collect_vec();
304
305        Ok(receipts)
306    }
307}
308
309pub enum ChainGetMessagesInTipset {}
310impl RpcMethod<1> for ChainGetMessagesInTipset {
311    const NAME: &'static str = "Filecoin.ChainGetMessagesInTipset";
312    const PARAM_NAMES: [&'static str; 1] = ["tipsetKey"];
313    const API_PATHS: BitFlags<ApiPaths> = ApiPaths::all();
314    const PERMISSION: Permission = Permission::Read;
315
316    type Params = (ApiTipsetKey,);
317    type Ok = Vec<ApiMessage>;
318
319    async fn handle(
320        ctx: Ctx<impl Blockstore + Send + Sync + 'static>,
321        (ApiTipsetKey(tipset_key),): Self::Params,
322        _: &http::Extensions,
323    ) -> Result<Self::Ok, ServerError> {
324        let tipset = ctx
325            .chain_store()
326            .load_required_tipset_or_heaviest(&tipset_key)?;
327        load_api_messages_from_tipset(&ctx, tipset.key()).await
328    }
329}
330
331pub enum ChainPruneSnapshot {}
332impl RpcMethod<1> for ChainPruneSnapshot {
333    const NAME: &'static str = "Forest.SnapshotGC";
334    const PARAM_NAMES: [&'static str; 1] = ["blocking"];
335    const API_PATHS: BitFlags<ApiPaths> = ApiPaths::all();
336    const PERMISSION: Permission = Permission::Admin;
337
338    type Params = (bool,);
339    type Ok = ();
340
341    async fn handle(
342        _ctx: Ctx<impl Blockstore + Send + Sync + 'static>,
343        (blocking,): Self::Params,
344        _: &http::Extensions,
345    ) -> Result<Self::Ok, ServerError> {
346        if let Some(gc) = crate::daemon::GLOBAL_SNAPSHOT_GC.get() {
347            let progress_rx = gc.trigger()?;
348            while blocking && progress_rx.recv_async().await.is_ok() {}
349            Ok(())
350        } else {
351            Err(anyhow::anyhow!("snapshot gc is not enabled").into())
352        }
353    }
354}
355
356pub enum ForestChainExport {}
357impl RpcMethod<1> for ForestChainExport {
358    const NAME: &'static str = "Forest.ChainExport";
359    const PARAM_NAMES: [&'static str; 1] = ["params"];
360    const API_PATHS: BitFlags<ApiPaths> = ApiPaths::all_with_v2();
361    const PERMISSION: Permission = Permission::Read;
362
363    type Params = (ForestChainExportParams,);
364    type Ok = ApiExportResult;
365
366    async fn handle(
367        ctx: Ctx<impl Blockstore + Send + Sync + 'static>,
368        (params,): Self::Params,
369        _: &http::Extensions,
370    ) -> Result<Self::Ok, ServerError> {
371        let ForestChainExportParams {
372            version,
373            epoch,
374            recent_roots,
375            output_path,
376            tipset_keys: ApiTipsetKey(tsk),
377            include_receipts,
378            include_events,
379            include_tipset_keys,
380            skip_checksum,
381            dry_run,
382        } = params;
383
384        let token = CancellationToken::new();
385        {
386            let mut guard = CHAIN_EXPORT_LOCK.lock().await;
387            if guard.is_some() {
388                return Err(
389                    anyhow::anyhow!("A chain export is still in progress. Cancel it with the export-cancel subcommand if needed.").into(),
390                );
391            }
392            *guard = Some(token.clone());
393        }
394        start_export();
395
396        let head = ctx.chain_store().load_required_tipset_or_heaviest(&tsk)?;
397        let start_ts =
398            ctx.chain_index()
399                .tipset_by_height(epoch, head, ResolveNullTipset::TakeOlder)?;
400
401        let options = ExportOptions {
402            skip_checksum,
403            include_receipts,
404            include_events,
405            include_tipset_keys,
406            seen: FileBackedCidHashSet::new(ctx.temp_dir.as_path())?,
407        };
408        let writer = if dry_run {
409            tokio_util::either::Either::Left(VoidAsyncWriter)
410        } else {
411            tokio_util::either::Either::Right(tokio::fs::File::create(&output_path).await?)
412        };
413        let result = match version {
414            FilecoinSnapshotVersion::V1 => {
415                let db = ctx.store_owned();
416
417                let chain_export = crate::chain::export::<Sha256, _>(
418                    &db,
419                    &start_ts,
420                    recent_roots,
421                    writer,
422                    options,
423                );
424
425                tokio::select! {
426                    result = chain_export => {
427                        result.map(|checksum_opt| ApiExportResult::Done(checksum_opt.map(|hash| hash.encode_hex())))
428                    },
429                    _ = token.cancelled() => {
430                        cancel_export();
431                        tracing::warn!("Snapshot export was cancelled");
432                        Ok(ApiExportResult::Cancelled)
433                    },
434                }
435            }
436            FilecoinSnapshotVersion::V2 => {
437                let db = ctx.store_owned();
438
439                let f3_snap_tmp_path = {
440                    let mut f3_snap_dir = output_path.clone();
441                    let mut builder = tempfile::Builder::new();
442                    let with_suffix = builder.suffix(".f3snap.bin");
443                    if f3_snap_dir.pop() {
444                        with_suffix.tempfile_in(&f3_snap_dir)
445                    } else {
446                        with_suffix.tempfile_in(".")
447                    }?
448                    .into_temp_path()
449                };
450                let f3_snap = {
451                    match F3ExportLatestSnapshot::run(f3_snap_tmp_path.display().to_string()).await
452                    {
453                        Ok(cid) => Some((cid, File::open(&f3_snap_tmp_path)?)),
454                        Err(e) => {
455                            tracing::error!("Failed to export F3 snapshot: {e:#}");
456                            None
457                        }
458                    }
459                };
460
461                let chain_export = crate::chain::export_v2::<Sha256, _, _>(
462                    &db,
463                    f3_snap,
464                    &start_ts,
465                    recent_roots,
466                    writer,
467                    options,
468                );
469
470                tokio::select! {
471                    result = chain_export => {
472                        result.map(|checksum_opt| ApiExportResult::Done(checksum_opt.map(|hash| hash.encode_hex())))
473                    },
474                    _ = token.cancelled() => {
475                        cancel_export();
476                        tracing::warn!("Snapshot export was cancelled");
477                        Ok(ApiExportResult::Cancelled)
478                    },
479                }
480            }
481        };
482        end_export();
483        // Clean up token
484        let mut guard = CHAIN_EXPORT_LOCK.lock().await;
485        *guard = None;
486        match result {
487            Ok(export_result) => Ok(export_result),
488            Err(e) => Err(anyhow::anyhow!(e).into()),
489        }
490    }
491}
492
493pub enum ForestChainExportStatus {}
494impl RpcMethod<0> for ForestChainExportStatus {
495    const NAME: &'static str = "Forest.ChainExportStatus";
496    const PARAM_NAMES: [&'static str; 0] = [];
497    const API_PATHS: BitFlags<ApiPaths> = ApiPaths::all_with_v2();
498    const PERMISSION: Permission = Permission::Read;
499
500    type Params = ();
501    type Ok = ApiExportStatus;
502
503    async fn handle(
504        _ctx: Ctx<impl Blockstore>,
505        (): Self::Params,
506        _: &http::Extensions,
507    ) -> Result<Self::Ok, ServerError> {
508        let mutex = CHAIN_EXPORT_STATUS.lock();
509
510        let progress = if mutex.initial_epoch == 0 {
511            0.0
512        } else {
513            let p = 1.0 - ((mutex.epoch as f64) / (mutex.initial_epoch as f64));
514            if p.is_finite() {
515                p.clamp(0.0, 1.0)
516            } else {
517                0.0
518            }
519        };
520        // only two decimal places
521        let progress = (progress * 100.0).round() / 100.0;
522
523        let status = ApiExportStatus {
524            progress,
525            exporting: mutex.exporting,
526            cancelled: mutex.cancelled,
527            start_time: mutex.start_time,
528        };
529
530        Ok(status)
531    }
532}
533
534pub enum ForestChainExportCancel {}
535impl RpcMethod<0> for ForestChainExportCancel {
536    const NAME: &'static str = "Forest.ChainExportCancel";
537    const PARAM_NAMES: [&'static str; 0] = [];
538    const API_PATHS: BitFlags<ApiPaths> = ApiPaths::all_with_v2();
539    const PERMISSION: Permission = Permission::Read;
540
541    type Params = ();
542    type Ok = bool;
543
544    async fn handle(
545        _ctx: Ctx<impl Blockstore>,
546        (): Self::Params,
547        _: &http::Extensions,
548    ) -> Result<Self::Ok, ServerError> {
549        if let Some(token) = CHAIN_EXPORT_LOCK.lock().await.as_ref() {
550            token.cancel();
551            return Ok(true);
552        }
553
554        Ok(false)
555    }
556}
557
558pub enum ForestChainExportDiff {}
559impl RpcMethod<1> for ForestChainExportDiff {
560    const NAME: &'static str = "Forest.ChainExportDiff";
561    const PARAM_NAMES: [&'static str; 1] = ["params"];
562    const API_PATHS: BitFlags<ApiPaths> = ApiPaths::all_with_v2();
563    const PERMISSION: Permission = Permission::Read;
564
565    type Params = (ForestChainExportDiffParams,);
566    type Ok = ();
567
568    async fn handle(
569        ctx: Ctx<impl Blockstore + Send + Sync + 'static>,
570        (params,): Self::Params,
571        _: &http::Extensions,
572    ) -> Result<Self::Ok, ServerError> {
573        let ForestChainExportDiffParams {
574            from,
575            to,
576            depth,
577            output_path,
578        } = params;
579
580        let _locked = CHAIN_EXPORT_LOCK.try_lock();
581        if _locked.is_err() {
582            return Err(
583                anyhow::anyhow!("Another chain export diff job is still in progress").into(),
584            );
585        }
586
587        let chain_finality = ctx.chain_config().policy.chain_finality;
588        if depth < chain_finality {
589            return Err(
590                anyhow::anyhow!(format!("depth must be greater than {chain_finality}")).into(),
591            );
592        }
593
594        let head = ctx.chain_store().heaviest_tipset();
595        let start_ts =
596            ctx.chain_index()
597                .tipset_by_height(from, head, ResolveNullTipset::TakeOlder)?;
598
599        crate::tool::subcommands::archive_cmd::do_export(
600            &ctx.store_owned(),
601            start_ts,
602            output_path,
603            None,
604            depth,
605            Some(to),
606            Some(chain_finality),
607            true,
608        )
609        .await?;
610
611        Ok(())
612    }
613}
614
615pub enum ChainExport {}
616impl RpcMethod<1> for ChainExport {
617    const NAME: &'static str = "Filecoin.ChainExport";
618    const PARAM_NAMES: [&'static str; 1] = ["params"];
619    const API_PATHS: BitFlags<ApiPaths> = ApiPaths::all();
620    const PERMISSION: Permission = Permission::Read;
621
622    type Params = (ChainExportParams,);
623    type Ok = ApiExportResult;
624
625    async fn handle(
626        ctx: Ctx<impl Blockstore + Send + Sync + 'static>,
627        (ChainExportParams {
628            epoch,
629            recent_roots,
630            output_path,
631            tipset_keys,
632            skip_checksum,
633            dry_run,
634        },): Self::Params,
635        ext: &http::Extensions,
636    ) -> Result<Self::Ok, ServerError> {
637        ForestChainExport::handle(
638            ctx,
639            (ForestChainExportParams {
640                version: FilecoinSnapshotVersion::V1,
641                epoch,
642                recent_roots,
643                output_path,
644                tipset_keys,
645                include_receipts: false,
646                include_events: false,
647                include_tipset_keys: false,
648                skip_checksum,
649                dry_run,
650            },),
651            ext,
652        )
653        .await
654    }
655}
656
657pub enum ChainReadObj {}
658impl RpcMethod<1> for ChainReadObj {
659    const NAME: &'static str = "Filecoin.ChainReadObj";
660    const PARAM_NAMES: [&'static str; 1] = ["cid"];
661    const API_PATHS: BitFlags<ApiPaths> = ApiPaths::all();
662    const PERMISSION: Permission = Permission::Read;
663    const DESCRIPTION: Option<&'static str> = Some(
664        "Reads IPLD nodes referenced by the specified CID from the chain blockstore and returns raw bytes.",
665    );
666
667    type Params = (Cid,);
668    type Ok = Vec<u8>;
669
670    async fn handle(
671        ctx: Ctx<impl Blockstore>,
672        (cid,): Self::Params,
673        _: &http::Extensions,
674    ) -> Result<Self::Ok, ServerError> {
675        let bytes = ctx
676            .store()
677            .get(&cid)?
678            .with_context(|| format!("can't find object with cid={cid}"))?;
679        Ok(bytes)
680    }
681}
682
683pub enum ChainHasObj {}
684impl RpcMethod<1> for ChainHasObj {
685    const NAME: &'static str = "Filecoin.ChainHasObj";
686    const PARAM_NAMES: [&'static str; 1] = ["cid"];
687    const API_PATHS: BitFlags<ApiPaths> = ApiPaths::all();
688    const PERMISSION: Permission = Permission::Read;
689    const DESCRIPTION: Option<&'static str> =
690        Some("Checks if a given CID exists in the chain blockstore.");
691
692    type Params = (Cid,);
693    type Ok = bool;
694
695    async fn handle(
696        ctx: Ctx<impl Blockstore>,
697        (cid,): Self::Params,
698        _: &http::Extensions,
699    ) -> Result<Self::Ok, ServerError> {
700        Ok(ctx.store().get(&cid)?.is_some())
701    }
702}
703
704/// Returns statistics about the graph referenced by 'obj'.
705/// If 'base' is also specified, then the returned stat will be a diff between the two objects.
706pub enum ChainStatObj {}
707impl RpcMethod<2> for ChainStatObj {
708    const NAME: &'static str = "Filecoin.ChainStatObj";
709    const PARAM_NAMES: [&'static str; 2] = ["obj_cid", "base_cid"];
710    const API_PATHS: BitFlags<ApiPaths> = ApiPaths::all();
711    const PERMISSION: Permission = Permission::Read;
712
713    type Params = (Cid, Option<Cid>);
714    type Ok = ObjStat;
715
716    async fn handle(
717        ctx: Ctx<impl Blockstore>,
718        (obj_cid, base_cid): Self::Params,
719        _: &http::Extensions,
720    ) -> Result<Self::Ok, ServerError> {
721        let mut stats = ObjStat::default();
722        let mut seen = CidHashSet::default();
723        let mut walk = |cid, collect| {
724            let mut queue = VecDeque::new();
725            queue.push_back(cid);
726            while let Some(link_cid) = queue.pop_front() {
727                if !seen.insert(link_cid) {
728                    continue;
729                }
730                let data = ctx.store().get(&link_cid)?;
731                if let Some(data) = data {
732                    if collect {
733                        stats.links += 1;
734                        stats.size += data.len();
735                    }
736                    if matches!(link_cid.codec(), fvm_ipld_encoding::DAG_CBOR)
737                        && let Ok(ipld) =
738                            crate::utils::encoding::from_slice_with_fallback::<Ipld>(&data)
739                    {
740                        for ipld in DfsIter::new(ipld) {
741                            if let Ipld::Link(cid) = ipld {
742                                queue.push_back(cid);
743                            }
744                        }
745                    }
746                }
747            }
748            anyhow::Ok(())
749        };
750        if let Some(base_cid) = base_cid {
751            walk(base_cid, false)?;
752        }
753        walk(obj_cid, true)?;
754        Ok(stats)
755    }
756}
757
758pub enum ChainGetBlockMessages {}
759impl RpcMethod<1> for ChainGetBlockMessages {
760    const NAME: &'static str = "Filecoin.ChainGetBlockMessages";
761    const PARAM_NAMES: [&'static str; 1] = ["blockCid"];
762    const API_PATHS: BitFlags<ApiPaths> = ApiPaths::all();
763    const PERMISSION: Permission = Permission::Read;
764    const DESCRIPTION: Option<&'static str> =
765        Some("Returns all messages from the specified block.");
766
767    type Params = (Cid,);
768    type Ok = BlockMessages;
769
770    async fn handle(
771        ctx: Ctx<impl Blockstore>,
772        (block_cid,): Self::Params,
773        _: &http::Extensions,
774    ) -> Result<Self::Ok, ServerError> {
775        let blk: CachingBlockHeader = ctx.store().get_cbor_required(&block_cid)?;
776        let (unsigned_cids, signed_cids) = crate::chain::read_msg_cids(ctx.store(), &blk)?;
777        let (bls_msg, secp_msg) =
778            crate::chain::block_messages_from_cids(ctx.store(), &unsigned_cids, &signed_cids)?;
779        let cids = unsigned_cids.into_iter().chain(signed_cids).collect();
780
781        let ret = BlockMessages {
782            bls_msg,
783            secp_msg,
784            cids,
785        };
786        Ok(ret)
787    }
788}
789
790pub enum ChainGetPath {}
791impl RpcMethod<2> for ChainGetPath {
792    const NAME: &'static str = "Filecoin.ChainGetPath";
793    const PARAM_NAMES: [&'static str; 2] = ["from", "to"];
794    const API_PATHS: BitFlags<ApiPaths> = ApiPaths::all();
795    const PERMISSION: Permission = Permission::Read;
796    const DESCRIPTION: Option<&'static str> =
797        Some("Returns the path between the two specified tipsets.");
798
799    type Params = (TipsetKey, TipsetKey);
800    type Ok = Vec<PathChange>;
801
802    async fn handle(
803        ctx: Ctx<impl Blockstore>,
804        (from, to): Self::Params,
805        _: &http::Extensions,
806    ) -> Result<Self::Ok, ServerError> {
807        Ok(chain_get_path(ctx.chain_store(), &from, &to)?.into_change_vec())
808    }
809}
810
811/// Find the path between two tipsets, as [`PathChanges`].
812///
813/// ```text
814/// 0 - A - B - C - D
815///     ^~~~~~~~> apply B, C
816///
817/// 0 - A - B - C - D
818///     <~~~~~~~^ revert C, B
819///
820///     <~~~~~~~~ revert C, B
821/// 0 - A - B  - C
822///     |
823///      -- B' - C'
824///      ~~~~~~~~> then apply B', C'
825/// ```
826///
827/// Exposes errors from the [`Blockstore`], and returns an error if there is no common ancestor.
828pub fn chain_get_path(
829    chain_store: &ChainStore<impl Blockstore>,
830    from: &TipsetKey,
831    to: &TipsetKey,
832) -> anyhow::Result<PathChanges> {
833    let finality = chain_store.chain_config().policy.chain_finality;
834    let mut to_revert = chain_store
835        .load_required_tipset_or_heaviest(from)
836        .context("couldn't load `from`")?;
837    let mut to_apply = chain_store
838        .load_required_tipset_or_heaviest(to)
839        .context("couldn't load `to`")?;
840
841    anyhow::ensure!(
842        (to_apply.epoch() - to_revert.epoch()).abs() <= finality,
843        "the gap between the new head ({}) and the old head ({}) is larger than chain finality ({finality})",
844        to_apply.epoch(),
845        to_revert.epoch()
846    );
847
848    let mut reverts = vec![];
849    let mut applies = vec![];
850
851    // This loop is guaranteed to terminate if the blockstore contain no cycles.
852    // This is currently computationally infeasible.
853    while to_revert != to_apply {
854        if to_revert.epoch() > to_apply.epoch() {
855            let next = chain_store
856                .load_required_tipset_or_heaviest(to_revert.parents())
857                .context("couldn't load ancestor of `from`")?;
858            reverts.push(to_revert);
859            to_revert = next;
860        } else {
861            let next = chain_store
862                .load_required_tipset_or_heaviest(to_apply.parents())
863                .context("couldn't load ancestor of `to`")?;
864            applies.push(to_apply);
865            to_apply = next;
866        }
867    }
868    applies.reverse();
869    Ok(PathChanges { reverts, applies })
870}
871
872/// Get tipset at epoch. Pick younger tipset if epoch points to a
873/// null-tipset. Only tipsets below the given `head` are searched. If `head`
874/// is null, the node will use the heaviest tipset.
875pub enum ChainGetTipSetByHeight {}
876impl RpcMethod<2> for ChainGetTipSetByHeight {
877    const NAME: &'static str = "Filecoin.ChainGetTipSetByHeight";
878    const PARAM_NAMES: [&'static str; 2] = ["height", "tipsetKey"];
879    const API_PATHS: BitFlags<ApiPaths> = ApiPaths::all();
880    const PERMISSION: Permission = Permission::Read;
881    const DESCRIPTION: Option<&'static str> = Some("Returns the tipset at the specified height.");
882
883    type Params = (ChainEpoch, ApiTipsetKey);
884    type Ok = Tipset;
885
886    async fn handle(
887        ctx: Ctx<impl Blockstore>,
888        (height, ApiTipsetKey(tipset_key)): Self::Params,
889        _: &http::Extensions,
890    ) -> Result<Self::Ok, ServerError> {
891        let ts = ctx
892            .chain_store()
893            .load_required_tipset_or_heaviest(&tipset_key)?;
894        let tss = ctx
895            .chain_index()
896            .tipset_by_height(height, ts, ResolveNullTipset::TakeOlder)?;
897        Ok(tss)
898    }
899}
900
901pub enum ChainGetTipSetAfterHeight {}
902impl RpcMethod<2> for ChainGetTipSetAfterHeight {
903    const NAME: &'static str = "Filecoin.ChainGetTipSetAfterHeight";
904    const PARAM_NAMES: [&'static str; 2] = ["height", "tipsetKey"];
905    const API_PATHS: BitFlags<ApiPaths> = ApiPaths::all();
906    const PERMISSION: Permission = Permission::Read;
907    const DESCRIPTION: Option<&'static str> = Some(
908        "Looks back and returns the tipset at the specified epoch.
909    If there are no blocks at the given epoch,
910    returns the first non-nil tipset at a later epoch.",
911    );
912
913    type Params = (ChainEpoch, ApiTipsetKey);
914    type Ok = Tipset;
915
916    async fn handle(
917        ctx: Ctx<impl Blockstore>,
918        (height, ApiTipsetKey(tipset_key)): Self::Params,
919        _: &http::Extensions,
920    ) -> Result<Self::Ok, ServerError> {
921        let ts = ctx
922            .chain_store()
923            .load_required_tipset_or_heaviest(&tipset_key)?;
924        let tss = ctx
925            .chain_index()
926            .tipset_by_height(height, ts, ResolveNullTipset::TakeNewer)?;
927        Ok(tss)
928    }
929}
930
931pub enum ChainGetGenesis {}
932impl RpcMethod<0> for ChainGetGenesis {
933    const NAME: &'static str = "Filecoin.ChainGetGenesis";
934    const PARAM_NAMES: [&'static str; 0] = [];
935    const API_PATHS: BitFlags<ApiPaths> = ApiPaths::all();
936    const PERMISSION: Permission = Permission::Read;
937
938    type Params = ();
939    type Ok = Option<Tipset>;
940
941    async fn handle(
942        ctx: Ctx<impl Blockstore>,
943        (): Self::Params,
944        _: &http::Extensions,
945    ) -> Result<Self::Ok, ServerError> {
946        let genesis = ctx.chain_store().genesis_block_header();
947        Ok(Some(Tipset::from(genesis)))
948    }
949}
950
951pub enum ChainHead {}
952impl RpcMethod<0> for ChainHead {
953    const NAME: &'static str = "Filecoin.ChainHead";
954    const PARAM_NAMES: [&'static str; 0] = [];
955    const API_PATHS: BitFlags<ApiPaths> = ApiPaths::all();
956    const PERMISSION: Permission = Permission::Read;
957    const DESCRIPTION: Option<&'static str> = Some("Returns the chain head (heaviest tipset).");
958
959    type Params = ();
960    type Ok = Tipset;
961
962    async fn handle(
963        ctx: Ctx<impl Blockstore>,
964        (): Self::Params,
965        _: &http::Extensions,
966    ) -> Result<Self::Ok, ServerError> {
967        let heaviest = ctx.chain_store().heaviest_tipset();
968        Ok(heaviest)
969    }
970}
971
972pub enum ChainGetBlock {}
973impl RpcMethod<1> for ChainGetBlock {
974    const NAME: &'static str = "Filecoin.ChainGetBlock";
975    const PARAM_NAMES: [&'static str; 1] = ["blockCid"];
976    const API_PATHS: BitFlags<ApiPaths> = ApiPaths::all();
977    const PERMISSION: Permission = Permission::Read;
978    const DESCRIPTION: Option<&'static str> = Some("Returns the block with the specified CID.");
979
980    type Params = (Cid,);
981    type Ok = CachingBlockHeader;
982
983    async fn handle(
984        ctx: Ctx<impl Blockstore>,
985        (block_cid,): Self::Params,
986        _: &http::Extensions,
987    ) -> Result<Self::Ok, ServerError> {
988        let blk: CachingBlockHeader = ctx.store().get_cbor_required(&block_cid)?;
989        Ok(blk)
990    }
991}
992
993pub enum ChainGetTipSet {}
994
995impl RpcMethod<1> for ChainGetTipSet {
996    const NAME: &'static str = "Filecoin.ChainGetTipSet";
997    const PARAM_NAMES: [&'static str; 1] = ["tipsetKey"];
998    const API_PATHS: BitFlags<ApiPaths> = make_bitflags!(ApiPaths::{ V0 | V1 });
999    const PERMISSION: Permission = Permission::Read;
1000    const DESCRIPTION: Option<&'static str> = Some("Returns the tipset with the specified CID.");
1001
1002    type Params = (ApiTipsetKey,);
1003    type Ok = Tipset;
1004
1005    async fn handle(
1006        ctx: Ctx<impl Blockstore>,
1007        (ApiTipsetKey(tsk),): Self::Params,
1008        _: &http::Extensions,
1009    ) -> Result<Self::Ok, ServerError> {
1010        if let Some(tsk) = &tsk {
1011            let ts = ctx.chain_index().load_required_tipset(tsk)?;
1012            Ok(ts)
1013        } else {
1014            // It contains Lotus error message `NewTipSet called with zero length array of blocks` for parity tests
1015            Err(anyhow::anyhow!(
1016                "TipsetKey cannot be empty (NewTipSet called with zero length array of blocks)"
1017            )
1018            .into())
1019        }
1020    }
1021}
1022
1023pub enum ChainGetTipSetV2 {}
1024
1025impl ChainGetTipSetV2 {
1026    pub async fn get_tipset_by_anchor(
1027        ctx: &Ctx<impl Blockstore + Send + Sync + 'static>,
1028        anchor: Option<&TipsetAnchor>,
1029    ) -> anyhow::Result<Tipset> {
1030        if let Some(anchor) = anchor {
1031            match (&anchor.key.0, &anchor.tag) {
1032                // Anchor is zero-valued. Fall back to heaviest tipset.
1033                (None, None) => Ok(ctx.state_manager.heaviest_tipset()),
1034                // Get tipset at the specified key.
1035                (Some(tsk), None) => Ok(ctx.chain_index().load_required_tipset(tsk)?),
1036                (None, Some(tag)) => Self::get_tipset_by_tag(ctx, *tag).await,
1037                _ => {
1038                    anyhow::bail!("invalid anchor")
1039                }
1040            }
1041        } else {
1042            // No anchor specified. Fall back to finalized tipset.
1043            Self::get_tipset_by_tag(ctx, TipsetTag::Finalized).await
1044        }
1045    }
1046
1047    pub async fn get_tipset_by_tag(
1048        ctx: &Ctx<impl Blockstore + Send + Sync + 'static>,
1049        tag: TipsetTag,
1050    ) -> anyhow::Result<Tipset> {
1051        match tag {
1052            TipsetTag::Latest => Ok(ctx.state_manager.heaviest_tipset()),
1053            TipsetTag::Finalized => Self::get_latest_finalized_tipset(ctx).await,
1054            TipsetTag::Safe => Self::get_latest_safe_tipset(ctx).await,
1055        }
1056    }
1057
1058    pub async fn get_latest_safe_tipset(
1059        ctx: &Ctx<impl Blockstore + Send + Sync + 'static>,
1060    ) -> anyhow::Result<Tipset> {
1061        let finalized = Self::get_latest_finalized_tipset(ctx).await?;
1062        let head = ctx.chain_store().heaviest_tipset();
1063        let safe_height = (head.epoch() - SAFE_HEIGHT_DISTANCE).max(0);
1064        if finalized.epoch() >= safe_height {
1065            Ok(finalized)
1066        } else {
1067            Ok(ctx.chain_index().tipset_by_height(
1068                safe_height,
1069                head,
1070                ResolveNullTipset::TakeOlder,
1071            )?)
1072        }
1073    }
1074
1075    pub async fn get_latest_finalized_tipset(
1076        ctx: &Ctx<impl Blockstore + Send + Sync + 'static>,
1077    ) -> anyhow::Result<Tipset> {
1078        ChainGetTipSetFinalityStatus::get_finality_status(ctx)
1079            .finalized_tip_set
1080            .context("failed to resolve finalized tipset")
1081    }
1082
1083    pub async fn get_tipset(
1084        ctx: &Ctx<impl Blockstore + Send + Sync + 'static>,
1085        selector: &TipsetSelector,
1086    ) -> anyhow::Result<Tipset> {
1087        selector.validate()?;
1088        // Get tipset by key.
1089        if let ApiTipsetKey(Some(tsk)) = &selector.key {
1090            let ts = ctx.chain_index().load_required_tipset(tsk)?;
1091            return Ok(ts);
1092        }
1093        // Get tipset by height.
1094        if let Some(height) = &selector.height {
1095            let anchor = Self::get_tipset_by_anchor(ctx, height.anchor.as_ref()).await?;
1096            let ts = ctx.chain_index().tipset_by_height(
1097                height.at,
1098                anchor,
1099                height.resolve_null_tipset_policy(),
1100            )?;
1101            return Ok(ts);
1102        }
1103        // Get tipset by tag, either latest or finalized.
1104        if let Some(tag) = &selector.tag {
1105            let ts = Self::get_tipset_by_tag(ctx, *tag).await?;
1106            return Ok(ts);
1107        }
1108        anyhow::bail!("no tipset found for selector")
1109    }
1110}
1111
1112impl RpcMethod<1> for ChainGetTipSetV2 {
1113    const NAME: &'static str = "Filecoin.ChainGetTipSet";
1114    const PARAM_NAMES: [&'static str; 1] = ["tipsetSelector"];
1115    const API_PATHS: BitFlags<ApiPaths> = make_bitflags!(ApiPaths::{ V2 });
1116    const PERMISSION: Permission = Permission::Read;
1117    const DESCRIPTION: Option<&'static str> = Some("Returns the tipset with the specified CID.");
1118
1119    type Params = (TipsetSelector,);
1120    type Ok = Tipset;
1121
1122    async fn handle(
1123        ctx: Ctx<impl Blockstore + Send + Sync + 'static>,
1124        (selector,): Self::Params,
1125        _: &http::Extensions,
1126    ) -> Result<Self::Ok, ServerError> {
1127        Ok(Self::get_tipset(&ctx, &selector).await?)
1128    }
1129}
1130
1131pub enum ChainGetTipSetFinalityStatus {}
1132
1133impl ChainGetTipSetFinalityStatus {
1134    pub fn get_finality_status(ctx: &Ctx<impl Blockstore>) -> ChainFinalityStatus {
1135        let head = ctx.chain_store().heaviest_tipset();
1136        let (ec_finality_threshold_depth, ec_finalized_tip_set) =
1137            Self::get_ec_finality_threshold_depth_and_tipset_with_cache(ctx, head.shallow_clone());
1138        let f3_finalized_tip_set = ctx.chain_store().f3_finalized_tipset();
1139        let finalized_tip_set = match (&ec_finalized_tip_set, &f3_finalized_tip_set) {
1140            (Some(ec), Some(f3)) => {
1141                if ec.epoch() >= f3.epoch() {
1142                    Some(ec.shallow_clone())
1143                } else {
1144                    Some(f3.shallow_clone())
1145                }
1146            }
1147            (Some(ec), None) => Some(ec.shallow_clone()),
1148            (None, Some(f3)) => Some(f3.shallow_clone()),
1149            (None, None) => None,
1150        };
1151        ChainFinalityStatus {
1152            ec_finality_threshold_depth,
1153            ec_finalized_tip_set,
1154            f3_finalized_tip_set,
1155            finalized_tip_set,
1156            head,
1157        }
1158    }
1159
1160    pub fn get_ec_finality_threshold_depth_and_tipset_with_cache(
1161        ctx: &Ctx<impl Blockstore>,
1162        head: Tipset,
1163    ) -> (i64, Option<Tipset>) {
1164        static CACHE: parking_lot::Mutex<Option<(Tipset, i64, Option<Tipset>)>> =
1165            parking_lot::Mutex::new(None);
1166        let mut cache = CACHE.lock();
1167        if let Some((cached_head, cached_threshold, cached_tipset)) = &*cache
1168            && cached_head == &head
1169        {
1170            (*cached_threshold, cached_tipset.shallow_clone())
1171        } else {
1172            let (threshold, tipset) =
1173                Self::get_ec_finality_threshold_depth_and_tipset(ctx, head.shallow_clone());
1174            *cache = Some((head, threshold, tipset.shallow_clone()));
1175            (threshold, tipset)
1176        }
1177    }
1178
1179    fn get_ec_finality_threshold_depth_and_tipset(
1180        ctx: &Ctx<impl Blockstore>,
1181        head: Tipset,
1182    ) -> (i64, Option<Tipset>) {
1183        use crate::chain::ec_finality::calculator::{
1184            DEFAULT_BLOCKS_PER_EPOCH, DEFAULT_BYZANTINE_FRACTION, DEFAULT_GUARANTEE,
1185            find_threshold_depth,
1186        };
1187
1188        /// Number of extra epochs to fetch beyond [`chain_finality`] when
1189        /// building the chain sample for [`find_threshold_depth`].
1190        ///
1191        /// The extra 5 epochs act as a tail buffer to prevent out-of-bounds access,
1192        /// particularly when null rounds (epochs with zero blocks) are present, since
1193        /// they consume array slots without advancing the meaningful epoch count.
1194        const FINALITY_CHAIN_EXTRA_EPOCHS: usize = 5;
1195
1196        let finality = ctx.chain_config().policy.chain_finality;
1197        let chain_len = finality as usize + FINALITY_CHAIN_EXTRA_EPOCHS;
1198        let mut chain = Vec::with_capacity(chain_len);
1199        let mut ts = head.shallow_clone();
1200        while chain.len() < chain_len {
1201            chain.push(ts.len() as i64);
1202            if let Ok(parent) = ctx.chain_index().load_required_tipset(ts.parents()) {
1203                // insert 0 for null rounds
1204                if let Ok(n_null_tipsets_to_pad) = usize::try_from(ts.epoch() - parent.epoch() - 1)
1205                    && n_null_tipsets_to_pad > 0
1206                {
1207                    let target_len =
1208                        (chain.len().saturating_add(n_null_tipsets_to_pad)).min(chain_len);
1209                    chain.resize(target_len, 0);
1210                }
1211                ts = parent;
1212            } else {
1213                break;
1214            }
1215        }
1216        // Reverse to chronological order (oldest first).
1217        chain.reverse();
1218        let depth = match find_threshold_depth(
1219            &chain,
1220            finality,
1221            DEFAULT_BLOCKS_PER_EPOCH,
1222            DEFAULT_BYZANTINE_FRACTION,
1223            *DEFAULT_GUARANTEE,
1224        ) {
1225            Ok(threshold) => threshold,
1226            Err(e) => {
1227                tracing::error!(
1228                    "Failed to calculate EC finality threshold depth: {e:#}, chain: {chain:?}"
1229                );
1230                -1
1231            }
1232        };
1233        let finalized = if depth >= 0
1234            && let Ok(ts) = ctx.chain_index().tipset_by_height(
1235                (head.epoch() - depth).max(0),
1236                head.shallow_clone(),
1237                ResolveNullTipset::TakeOlder,
1238            ) {
1239            Some(ts)
1240        } else {
1241            let ec_finality_epoch =
1242                (head.epoch() - ctx.chain_config().policy.chain_finality).max(0);
1243            ctx.chain_index()
1244                .tipset_by_height(ec_finality_epoch, head, ResolveNullTipset::TakeOlder)
1245                .ok()
1246        };
1247        (depth, finalized)
1248    }
1249}
1250
1251impl RpcMethod<0> for ChainGetTipSetFinalityStatus {
1252    const NAME: &'static str = "Filecoin.ChainGetTipSetFinalityStatus";
1253    const PARAM_NAMES: [&'static str; 0] = [];
1254    const API_PATHS: BitFlags<ApiPaths> = make_bitflags!(ApiPaths::{ V2 });
1255    const PERMISSION: Permission = Permission::Read;
1256    const DESCRIPTION: Option<&'static str> =
1257        Some("Returns a breakdown of how the node is currently determining finality.");
1258
1259    type Params = ();
1260    type Ok = ChainFinalityStatus;
1261
1262    async fn handle(
1263        ctx: Ctx<impl Blockstore + Send + Sync + 'static>,
1264        (): Self::Params,
1265        _: &http::Extensions,
1266    ) -> Result<Self::Ok, ServerError> {
1267        Ok(Self::get_finality_status(&ctx))
1268    }
1269}
1270
1271pub enum ChainSetHead {}
1272impl RpcMethod<1> for ChainSetHead {
1273    const NAME: &'static str = "Filecoin.ChainSetHead";
1274    const PARAM_NAMES: [&'static str; 1] = ["tsk"];
1275    const API_PATHS: BitFlags<ApiPaths> = ApiPaths::all();
1276    const PERMISSION: Permission = Permission::Admin;
1277
1278    type Params = (TipsetKey,);
1279    type Ok = ();
1280
1281    async fn handle(
1282        ctx: Ctx<impl Blockstore>,
1283        (tsk,): Self::Params,
1284        _: &http::Extensions,
1285    ) -> Result<Self::Ok, ServerError> {
1286        // This is basically a port of the reference implementation at
1287        // https://github.com/filecoin-project/lotus/blob/v1.23.0/node/impl/full/chain.go#L321
1288
1289        let new_head = ctx.chain_index().load_required_tipset(&tsk)?;
1290        let mut current = ctx.chain_store().heaviest_tipset();
1291        while current.epoch() >= new_head.epoch() {
1292            for cid in current.key().to_cids() {
1293                ctx.chain_store().unmark_block_as_validated(&cid);
1294            }
1295            let parents = &current.block_headers().first().parents;
1296            current = ctx.chain_index().load_required_tipset(parents)?;
1297        }
1298        ctx.chain_store()
1299            .set_heaviest_tipset(new_head)
1300            .map_err(Into::into)
1301    }
1302}
1303
1304pub enum ChainGetMinBaseFee {}
1305impl RpcMethod<1> for ChainGetMinBaseFee {
1306    const NAME: &'static str = "Forest.ChainGetMinBaseFee";
1307    const PARAM_NAMES: [&'static str; 1] = ["lookback"];
1308    const API_PATHS: BitFlags<ApiPaths> = ApiPaths::all();
1309    const PERMISSION: Permission = Permission::Read;
1310
1311    type Params = (u32,);
1312    type Ok = String;
1313
1314    async fn handle(
1315        ctx: Ctx<impl Blockstore>,
1316        (lookback,): Self::Params,
1317        _: &http::Extensions,
1318    ) -> Result<Self::Ok, ServerError> {
1319        let mut current = ctx.chain_store().heaviest_tipset();
1320        let mut min_base_fee = current.block_headers().first().parent_base_fee.clone();
1321
1322        for _ in 0..lookback {
1323            let parents = &current.block_headers().first().parents;
1324            current = ctx.chain_index().load_required_tipset(parents)?;
1325
1326            min_base_fee =
1327                min_base_fee.min(current.block_headers().first().parent_base_fee.to_owned());
1328        }
1329
1330        Ok(min_base_fee.atto().to_string())
1331    }
1332}
1333
1334pub enum ChainTipSetWeight {}
1335impl RpcMethod<1> for ChainTipSetWeight {
1336    const NAME: &'static str = "Filecoin.ChainTipSetWeight";
1337    const PARAM_NAMES: [&'static str; 1] = ["tipsetKey"];
1338    const API_PATHS: BitFlags<ApiPaths> = ApiPaths::all();
1339    const PERMISSION: Permission = Permission::Read;
1340    const DESCRIPTION: Option<&'static str> = Some("Returns the weight of the specified tipset.");
1341
1342    type Params = (ApiTipsetKey,);
1343    type Ok = BigInt;
1344
1345    async fn handle(
1346        ctx: Ctx<impl Blockstore>,
1347        (ApiTipsetKey(tipset_key),): Self::Params,
1348        _: &http::Extensions,
1349    ) -> Result<Self::Ok, ServerError> {
1350        let ts = ctx
1351            .chain_store()
1352            .load_required_tipset_or_heaviest(&tipset_key)?;
1353        let weight = crate::fil_cns::weight(ctx.store(), &ts)?;
1354        Ok(weight)
1355    }
1356}
1357
1358pub enum ChainGetTipsetByParentState {}
1359impl RpcMethod<1> for ChainGetTipsetByParentState {
1360    const NAME: &'static str = "Forest.ChainGetTipsetByParentState";
1361    const PARAM_NAMES: [&'static str; 1] = ["parentState"];
1362    const API_PATHS: BitFlags<ApiPaths> = ApiPaths::all();
1363    const PERMISSION: Permission = Permission::Read;
1364
1365    type Params = (Cid,);
1366    type Ok = Option<Tipset>;
1367
1368    async fn handle(
1369        ctx: Ctx<impl Blockstore>,
1370        (parent_state,): Self::Params,
1371        _: &http::Extensions,
1372    ) -> Result<Self::Ok, ServerError> {
1373        Ok(ctx
1374            .chain_store()
1375            .heaviest_tipset()
1376            .chain(ctx.store())
1377            .find(|ts| ts.parent_state() == &parent_state)
1378            .shallow_clone())
1379    }
1380}
1381
1382pub const CHAIN_NOTIFY: &str = "Filecoin.ChainNotify";
1383pub(crate) fn chain_notify<DB: Blockstore>(
1384    _params: Params<'_>,
1385    data: &crate::rpc::RPCState<DB>,
1386) -> Subscriber<Vec<ApiHeadChange>> {
1387    let (sender, receiver) = broadcast::channel(HEAD_CHANNEL_CAPACITY);
1388
1389    // As soon as the channel is created, send the current tipset
1390    let current = data.chain_store().heaviest_tipset();
1391    let (change, tipset) = ("current".into(), current);
1392    sender
1393        .send(vec![ApiHeadChange { change, tipset }])
1394        .expect("receiver is not dropped");
1395
1396    let mut head_changes_rx = data.chain_store().subscribe_head_changes();
1397
1398    tokio::spawn(async move {
1399        // Skip first message
1400        let _ = head_changes_rx.recv().await;
1401        while let Ok(changes) = head_changes_rx.recv().await {
1402            let api_changes = changes
1403                .into_change_vec()
1404                .into_iter()
1405                .map(From::from)
1406                .collect();
1407            if sender.send(api_changes).is_err() {
1408                break;
1409            }
1410        }
1411    });
1412    receiver
1413}
1414
1415async fn load_api_messages_from_tipset<DB: Blockstore + Send + Sync + 'static>(
1416    ctx: &crate::rpc::RPCState<DB>,
1417    tipset_keys: &TipsetKey,
1418) -> Result<Vec<ApiMessage>, ServerError> {
1419    static SHOULD_BACKFILL: LazyLock<bool> = LazyLock::new(|| {
1420        let enabled = is_env_truthy("FOREST_RPC_BACKFILL_FULL_TIPSET_FROM_NETWORK");
1421        if enabled {
1422            tracing::warn!(
1423                "Full tipset backfilling from network is enabled via FOREST_RPC_BACKFILL_FULL_TIPSET_FROM_NETWORK, excessive disk and bandwidth usage is expected."
1424            );
1425        }
1426        enabled
1427    });
1428    let full_tipset = if *SHOULD_BACKFILL {
1429        get_full_tipset(
1430            &ctx.sync_network_context,
1431            ctx.chain_store(),
1432            None,
1433            tipset_keys,
1434        )
1435        .await?
1436    } else {
1437        load_full_tipset(ctx.chain_store(), tipset_keys)?
1438    };
1439    let blocks = full_tipset.into_blocks();
1440    let mut messages = vec![];
1441    let mut seen = CidHashSet::default();
1442    for Block {
1443        bls_messages,
1444        secp_messages,
1445        ..
1446    } in blocks
1447    {
1448        for message in bls_messages {
1449            let cid = message.cid();
1450            if seen.insert(cid) {
1451                messages.push(ApiMessage { cid, message });
1452            }
1453        }
1454
1455        for msg in secp_messages {
1456            let cid = msg.cid();
1457            if seen.insert(cid) {
1458                messages.push(ApiMessage {
1459                    cid,
1460                    message: msg.message,
1461                });
1462            }
1463        }
1464    }
1465
1466    Ok(messages)
1467}
1468
1469#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
1470pub struct BlockMessages {
1471    #[serde(rename = "BlsMessages", with = "crate::lotus_json")]
1472    #[schemars(with = "LotusJson<Vec<Message>>")]
1473    pub bls_msg: Vec<Message>,
1474    #[serde(rename = "SecpkMessages", with = "crate::lotus_json")]
1475    #[schemars(with = "LotusJson<Vec<SignedMessage>>")]
1476    pub secp_msg: Vec<SignedMessage>,
1477    #[serde(rename = "Cids", with = "crate::lotus_json")]
1478    #[schemars(with = "LotusJson<Vec<Cid>>")]
1479    pub cids: Vec<Cid>,
1480}
1481lotus_json_with_self!(BlockMessages);
1482
1483#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, JsonSchema)]
1484#[serde(rename_all = "PascalCase")]
1485pub struct ApiReceipt {
1486    // Exit status of message execution
1487    pub exit_code: ExitCode,
1488    // `Return` value if the exit code is zero
1489    #[serde(rename = "Return", with = "crate::lotus_json")]
1490    #[schemars(with = "LotusJson<RawBytes>")]
1491    pub return_data: RawBytes,
1492    // Non-negative value of GasUsed
1493    pub gas_used: u64,
1494    #[serde(with = "crate::lotus_json")]
1495    #[schemars(with = "LotusJson<Option<Cid>>")]
1496    pub events_root: Option<Cid>,
1497}
1498
1499lotus_json_with_self!(ApiReceipt);
1500
1501#[derive(Serialize, Deserialize, JsonSchema, Clone, Debug, Eq, PartialEq)]
1502#[serde(rename_all = "PascalCase")]
1503pub struct ApiMessage {
1504    #[serde(with = "crate::lotus_json")]
1505    #[schemars(with = "LotusJson<Cid>")]
1506    pub cid: Cid,
1507    #[serde(with = "crate::lotus_json")]
1508    #[schemars(with = "LotusJson<Message>")]
1509    pub message: Message,
1510}
1511
1512lotus_json_with_self!(ApiMessage);
1513
1514#[derive(Serialize, Deserialize, JsonSchema, Clone, Debug, Eq, PartialEq)]
1515pub struct FlattenedApiMessage {
1516    #[serde(flatten, with = "crate::lotus_json")]
1517    #[schemars(with = "LotusJson<Message>")]
1518    pub message: Message,
1519    #[serde(rename = "CID", with = "crate::lotus_json")]
1520    #[schemars(with = "LotusJson<Cid>")]
1521    pub cid: Cid,
1522}
1523
1524lotus_json_with_self!(FlattenedApiMessage);
1525
1526#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1527pub struct ForestChainExportParams {
1528    pub version: FilecoinSnapshotVersion,
1529    pub epoch: ChainEpoch,
1530    pub recent_roots: i64,
1531    pub output_path: PathBuf,
1532    #[schemars(with = "LotusJson<ApiTipsetKey>")]
1533    #[serde(with = "crate::lotus_json")]
1534    pub tipset_keys: ApiTipsetKey,
1535    #[serde(default)]
1536    pub include_receipts: bool,
1537    #[serde(default)]
1538    pub include_events: bool,
1539    #[serde(default)]
1540    pub include_tipset_keys: bool,
1541    pub skip_checksum: bool,
1542    pub dry_run: bool,
1543}
1544lotus_json_with_self!(ForestChainExportParams);
1545
1546#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1547pub struct ForestChainExportDiffParams {
1548    pub from: ChainEpoch,
1549    pub to: ChainEpoch,
1550    pub depth: i64,
1551    pub output_path: PathBuf,
1552}
1553lotus_json_with_self!(ForestChainExportDiffParams);
1554
1555#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1556pub struct ChainExportParams {
1557    pub epoch: ChainEpoch,
1558    pub recent_roots: i64,
1559    pub output_path: PathBuf,
1560    #[schemars(with = "LotusJson<ApiTipsetKey>")]
1561    #[serde(with = "crate::lotus_json")]
1562    pub tipset_keys: ApiTipsetKey,
1563    pub skip_checksum: bool,
1564    pub dry_run: bool,
1565}
1566lotus_json_with_self!(ChainExportParams);
1567
1568#[derive(PartialEq, Debug, Serialize, Deserialize, Clone, JsonSchema)]
1569#[serde(rename_all = "PascalCase")]
1570pub struct ApiHeadChange {
1571    #[serde(rename = "Type")]
1572    pub change: String,
1573    #[serde(rename = "Val", with = "crate::lotus_json")]
1574    #[schemars(with = "LotusJson<Tipset>")]
1575    pub tipset: Tipset,
1576}
1577lotus_json_with_self!(ApiHeadChange);
1578
1579impl From<HeadChange> for ApiHeadChange {
1580    fn from(change: HeadChange) -> Self {
1581        match change {
1582            HeadChange::Apply(tipset) => Self {
1583                change: "apply".into(),
1584                tipset,
1585            },
1586            HeadChange::Revert(tipset) => Self {
1587                change: "revert".into(),
1588                tipset,
1589            },
1590        }
1591    }
1592}
1593
1594#[derive(PartialEq, Debug, Serialize, Deserialize, JsonSchema)]
1595#[serde(tag = "Type", content = "Val", rename_all = "snake_case")]
1596pub enum PathChange<T = Tipset> {
1597    Revert(T),
1598    Apply(T),
1599}
1600
1601impl<T: Clone> Clone for PathChange<T> {
1602    fn clone(&self) -> Self {
1603        match self {
1604            Self::Revert(i) => Self::Revert(i.clone()),
1605            Self::Apply(i) => Self::Apply(i.clone()),
1606        }
1607    }
1608}
1609
1610impl HasLotusJson for PathChange {
1611    type LotusJson = PathChange<<Tipset as HasLotusJson>::LotusJson>;
1612
1613    #[cfg(test)]
1614    fn snapshots() -> Vec<(serde_json::Value, Self)> {
1615        use serde_json::json;
1616        vec![(
1617            json!({
1618                "Type": "revert",
1619                "Val": {
1620                    "Blocks": [
1621                        {
1622                            "BeaconEntries": null,
1623                            "ForkSignaling": 0,
1624                            "Height": 0,
1625                            "Messages": { "/": "baeaaaaa" },
1626                            "Miner": "f00",
1627                            "ParentBaseFee": "0",
1628                            "ParentMessageReceipts": { "/": "baeaaaaa" },
1629                            "ParentStateRoot": { "/":"baeaaaaa" },
1630                            "ParentWeight": "0",
1631                            "Parents": [{"/":"bafyreiaqpwbbyjo4a42saasj36kkrpv4tsherf2e7bvezkert2a7dhonoi"}],
1632                            "Timestamp": 0,
1633                            "WinPoStProof": null
1634                        }
1635                    ],
1636                    "Cids": [
1637                        { "/": "bafy2bzaceag62hjj3o43lf6oyeox3fvg5aqkgl5zagbwpjje3ajwg6yw4iixk" }
1638                    ],
1639                    "Height": 0
1640                }
1641            }),
1642            Self::Revert(RawBlockHeader::default().into()),
1643        )]
1644    }
1645
1646    fn into_lotus_json(self) -> Self::LotusJson {
1647        match self {
1648            PathChange::Revert(it) => PathChange::Revert(it.into_lotus_json()),
1649            PathChange::Apply(it) => PathChange::Apply(it.into_lotus_json()),
1650        }
1651    }
1652
1653    fn from_lotus_json(lotus_json: Self::LotusJson) -> Self {
1654        match lotus_json {
1655            PathChange::Revert(it) => PathChange::Revert(Tipset::from_lotus_json(it)),
1656            PathChange::Apply(it) => PathChange::Apply(Tipset::from_lotus_json(it)),
1657        }
1658    }
1659}
1660
1661#[derive(Debug)]
1662pub struct PathChanges<T = Tipset> {
1663    pub reverts: Vec<T>,
1664    pub applies: Vec<T>,
1665}
1666
1667impl<T: Clone> Clone for PathChanges<T> {
1668    fn clone(&self) -> Self {
1669        let Self { reverts, applies } = self;
1670        Self {
1671            reverts: reverts.clone(),
1672            applies: applies.clone(),
1673        }
1674    }
1675}
1676
1677impl<T> PathChanges<T> {
1678    pub fn into_change_vec(self) -> Vec<PathChange<T>> {
1679        let Self { reverts, applies } = self;
1680        reverts
1681            .into_iter()
1682            .map(PathChange::Revert)
1683            .chain(applies.into_iter().map(PathChange::Apply))
1684            .collect_vec()
1685    }
1686}
1687
1688#[cfg(test)]
1689impl<T> quickcheck::Arbitrary for PathChange<T>
1690where
1691    T: quickcheck::Arbitrary + ShallowClone,
1692{
1693    fn arbitrary(g: &mut quickcheck::Gen) -> Self {
1694        let inner = T::arbitrary(g);
1695        g.choose(&[PathChange::Apply(inner.clone()), PathChange::Revert(inner)])
1696            .unwrap()
1697            .clone()
1698    }
1699}
1700
1701#[test]
1702fn snapshots() {
1703    assert_all_snapshots::<PathChange>()
1704}
1705
1706#[cfg(test)]
1707#[quickcheck_macros::quickcheck]
1708fn quickcheck(val: PathChange) {
1709    assert_unchanged_via_json(val)
1710}
1711
1712#[cfg(test)]
1713mod tests {
1714    use super::*;
1715    use crate::{
1716        blocks::{Chain4U, RawBlockHeader, chain4u},
1717        db::{
1718            MemoryDB,
1719            car::{AnyCar, ManyCar},
1720        },
1721        networks::{self, ChainConfig},
1722    };
1723    use PathChange::{Apply, Revert};
1724    use std::sync::Arc;
1725
1726    #[test]
1727    fn revert_to_ancestor_linear() {
1728        let store = ChainStore::calibnet();
1729        chain4u! {
1730            in store.blockstore();
1731            [_genesis = store.genesis_block_header()]
1732            -> [a] -> [b] -> [c, d] -> [e]
1733        };
1734
1735        // simple
1736        assert_path_change(&store, b, a, [Revert(&[b])]);
1737
1738        // from multi-member tipset
1739        assert_path_change(&store, [c, d], a, [Revert(&[c, d][..]), Revert(&[b])]);
1740
1741        // to multi-member tipset
1742        assert_path_change(&store, e, [c, d], [Revert(e)]);
1743
1744        // over multi-member tipset
1745        assert_path_change(&store, e, b, [Revert(&[e][..]), Revert(&[c, d])]);
1746    }
1747
1748    /// Mirror how lotus handles passing an incomplete `TipsetKey`s.
1749    /// Tested on lotus `1.23.2`
1750    #[test]
1751    fn incomplete_tipsets() {
1752        let store = ChainStore::calibnet();
1753        chain4u! {
1754            in store.blockstore();
1755            [_genesis = store.genesis_block_header()]
1756            -> [a, b] -> [c] -> [d, _e] // this pattern 2 -> 1 -> 2 can be found at calibnet epoch 1369126
1757        };
1758
1759        // apply to descendant with incomplete `from`
1760        assert_path_change(
1761            &store,
1762            a,
1763            c,
1764            [
1765                Revert(&[a][..]), // revert the incomplete tipset
1766                Apply(&[a, b]),   // apply the complete one
1767                Apply(&[c]),      // apply the destination
1768            ],
1769        );
1770
1771        // apply to descendant with incomplete `to`
1772        assert_path_change(&store, c, d, [Apply(d)]);
1773
1774        // revert to ancestor with incomplete `from`
1775        assert_path_change(&store, d, c, [Revert(d)]);
1776
1777        // revert to ancestor with incomplete `to`
1778        assert_path_change(
1779            &store,
1780            c,
1781            a,
1782            [
1783                Revert(&[c][..]),
1784                Revert(&[a, b]), // revert the complete tipset
1785                Apply(&[a]),     // apply the incomplete one
1786            ],
1787        );
1788    }
1789
1790    #[test]
1791    fn apply_to_descendant_linear() {
1792        let store = ChainStore::calibnet();
1793        chain4u! {
1794            in store.blockstore();
1795            [_genesis = store.genesis_block_header()]
1796            -> [a] -> [b] -> [c, d] -> [e]
1797        };
1798
1799        // simple
1800        assert_path_change(&store, a, b, [Apply(&[b])]);
1801
1802        // from multi-member tipset
1803        assert_path_change(&store, [c, d], e, [Apply(e)]);
1804
1805        // to multi-member tipset
1806        assert_path_change(&store, b, [c, d], [Apply([c, d])]);
1807
1808        // over multi-member tipset
1809        assert_path_change(&store, b, e, [Apply(&[c, d][..]), Apply(&[e])]);
1810    }
1811
1812    #[test]
1813    fn cross_fork_simple() {
1814        let store = ChainStore::calibnet();
1815        chain4u! {
1816            in store.blockstore();
1817            [_genesis = store.genesis_block_header()]
1818            -> [a] -> [b1] -> [c1]
1819        };
1820        chain4u! {
1821            from [a] in store.blockstore();
1822            [b2] -> [c2]
1823        };
1824
1825        // same height
1826        assert_path_change(&store, b1, b2, [Revert(b1), Apply(b2)]);
1827
1828        // different height
1829        assert_path_change(&store, b1, c2, [Revert(b1), Apply(b2), Apply(c2)]);
1830
1831        let _ = (a, c1);
1832    }
1833
1834    impl ChainStore<Chain4U<ManyCar>> {
1835        fn _load(genesis_car: &'static [u8], genesis_cid: Cid) -> Self {
1836            let db = Arc::new(Chain4U::with_blockstore(
1837                ManyCar::new(MemoryDB::default())
1838                    .with_read_only(AnyCar::new(genesis_car).unwrap())
1839                    .unwrap(),
1840            ));
1841            let genesis_block_header = db.get_cbor(&genesis_cid).unwrap().unwrap();
1842            ChainStore::new(
1843                db,
1844                Arc::new(MemoryDB::default()),
1845                Arc::new(MemoryDB::default()),
1846                Arc::new(ChainConfig::calibnet()),
1847                genesis_block_header,
1848            )
1849            .unwrap()
1850        }
1851        pub fn calibnet() -> Self {
1852            Self::_load(
1853                networks::calibnet::DEFAULT_GENESIS,
1854                *networks::calibnet::GENESIS_CID,
1855            )
1856        }
1857    }
1858
1859    /// Utility for writing ergonomic tests
1860    trait MakeTipset {
1861        fn make_tipset(self) -> Tipset;
1862    }
1863
1864    impl MakeTipset for &RawBlockHeader {
1865        fn make_tipset(self) -> Tipset {
1866            Tipset::from(CachingBlockHeader::new(self.clone()))
1867        }
1868    }
1869
1870    impl<const N: usize> MakeTipset for [&RawBlockHeader; N] {
1871        fn make_tipset(self) -> Tipset {
1872            self.as_slice().make_tipset()
1873        }
1874    }
1875
1876    impl<const N: usize> MakeTipset for &[&RawBlockHeader; N] {
1877        fn make_tipset(self) -> Tipset {
1878            self.as_slice().make_tipset()
1879        }
1880    }
1881
1882    impl MakeTipset for &[&RawBlockHeader] {
1883        fn make_tipset(self) -> Tipset {
1884            Tipset::new(self.iter().cloned().cloned()).unwrap()
1885        }
1886    }
1887
1888    #[track_caller]
1889    fn assert_path_change<T: MakeTipset>(
1890        store: &ChainStore<impl Blockstore>,
1891        from: impl MakeTipset,
1892        to: impl MakeTipset,
1893        expected: impl IntoIterator<Item = PathChange<T>>,
1894    ) {
1895        fn print(path_change: &PathChange) {
1896            let it = match path_change {
1897                Revert(it) => {
1898                    print!("Revert(");
1899                    it
1900                }
1901                Apply(it) => {
1902                    print!(" Apply(");
1903                    it
1904                }
1905            };
1906            println!(
1907                "epoch = {}, key.cid = {})",
1908                it.epoch(),
1909                it.key().cid().unwrap()
1910            )
1911        }
1912
1913        let actual = chain_get_path(store, from.make_tipset().key(), to.make_tipset().key())
1914            .unwrap()
1915            .into_change_vec();
1916        let expected = expected
1917            .into_iter()
1918            .map(|change| match change {
1919                PathChange::Revert(it) => PathChange::Revert(it.make_tipset()),
1920                PathChange::Apply(it) => PathChange::Apply(it.make_tipset()),
1921            })
1922            .collect_vec();
1923        if expected != actual {
1924            println!("SUMMARY");
1925            println!("=======");
1926            println!("expected:");
1927            for it in &expected {
1928                print(it)
1929            }
1930            println!();
1931            println!("actual:");
1932            for it in &actual {
1933                print(it)
1934            }
1935            println!("=======\n")
1936        }
1937        assert_eq!(
1938            expected, actual,
1939            "expected change (left) does not match actual change (right)"
1940        )
1941    }
1942}