Skip to main content

forest/dev/subcommands/
update_checkpoints_cmd.rs

1// Copyright 2019-2026 ChainSafe Systems
2// SPDX-License-Identifier: Apache-2.0, MIT
3
4use anyhow::Context as _;
5use cid::Cid;
6use clap::Parser;
7use indexmap::IndexMap;
8use serde::{Deserialize, Serialize};
9use std::path::PathBuf;
10use url::Url;
11
12use crate::rpc::Client;
13use crate::rpc::prelude::*;
14use crate::rpc::types::ApiTipsetKey;
15use crate::shim::clock::ChainEpoch;
16
17/// The interval between checkpoints (86400 epochs = 30 days)
18const CHECKPOINT_INTERVAL: ChainEpoch = 86400;
19
20/// YAML structure for `known_blocks.yaml`
21/// Using `IndexMap` to preserve insertion order
22#[derive(Debug, Clone, Serialize, Deserialize)]
23struct KnownBlocks {
24    #[serde(with = "cid_string_map")]
25    calibnet: IndexMap<ChainEpoch, Cid>,
26    #[serde(with = "cid_string_map")]
27    mainnet: IndexMap<ChainEpoch, Cid>,
28}
29
30/// Network selection for checkpoint updates
31#[derive(Debug, Clone, clap::ValueEnum)]
32pub enum Network {
33    /// Update both calibnet and mainnet
34    All,
35    /// Update calibnet only
36    Calibnet,
37    /// Update mainnet only
38    Mainnet,
39}
40
41/// Update known blocks in `build/known_blocks.yaml` by querying RPC endpoints
42///
43/// This command finds and adds missing checkpoint entries at constant intervals
44/// by querying Filfox or other full-archive RPC nodes that support historical queries.
45#[derive(Debug, Parser)]
46pub struct UpdateCheckpointsCommand {
47    /// Path to `known_blocks.yaml` file
48    #[arg(long, default_value = "build/known_blocks.yaml")]
49    known_blocks_file: PathBuf,
50
51    /// Mainnet RPC endpoint (Filfox recommended for full historical data)
52    #[arg(long, default_value = "https://filfox.info")]
53    mainnet_rpc: Url,
54
55    /// Calibnet RPC endpoint (Filfox recommended for full historical data)
56    #[arg(long, default_value = "https://calibration.filfox.info")]
57    calibnet_rpc: Url,
58
59    /// Which network(s) to update
60    #[arg(long, default_value = "all")]
61    network: Network,
62
63    /// Dry run - don't write changes to file
64    #[arg(long)]
65    dry_run: bool,
66}
67
68impl UpdateCheckpointsCommand {
69    pub async fn run(self) -> anyhow::Result<()> {
70        let Self {
71            known_blocks_file,
72            mainnet_rpc,
73            calibnet_rpc,
74            network,
75            dry_run,
76        } = self;
77
78        println!("Reading known blocks from: {}", known_blocks_file.display());
79        let yaml_content = std::fs::read_to_string(&known_blocks_file)
80            .context("Failed to read known_blocks.yaml")?;
81        let mut known_blocks: KnownBlocks =
82            serde_yaml::from_str(&yaml_content).context("Failed to parse known_blocks.yaml")?;
83
84        if matches!(network, Network::All | Network::Calibnet) {
85            println!("\n=== Updating Calibnet Checkpoints ===");
86            let calibnet_client = Client::from_url(calibnet_rpc);
87            update_chain_checkpoints(&calibnet_client, &mut known_blocks.calibnet, "calibnet")
88                .await?;
89        }
90
91        if matches!(network, Network::All | Network::Mainnet) {
92            println!("\n=== Updating Mainnet Checkpoints ===");
93            let mainnet_client = Client::from_url(mainnet_rpc);
94            update_chain_checkpoints(&mainnet_client, &mut known_blocks.mainnet, "mainnet").await?;
95        }
96
97        if dry_run {
98            println!("\n=== Dry Run - Changes Not Written ===");
99            println!("Would write to: {}", known_blocks_file.display());
100        } else {
101            println!("\n=== Writing Updated Checkpoints ===");
102            write_known_blocks(&known_blocks_file, &known_blocks)?;
103            println!("Successfully updated: {}", known_blocks_file.display());
104        }
105
106        Ok(())
107    }
108}
109
110async fn update_chain_checkpoints(
111    client: &Client,
112    checkpoints: &mut IndexMap<ChainEpoch, Cid>,
113    chain_name: &str,
114) -> anyhow::Result<()> {
115    println!("Fetching chain head for {chain_name}...");
116    let head = ChainHead::call(client, ())
117        .await
118        .context("Failed to get chain head")?;
119
120    let current_epoch = head.epoch();
121    println!("Current epoch: {}", current_epoch);
122
123    let latest_checkpoint_epoch = (current_epoch / CHECKPOINT_INTERVAL) * CHECKPOINT_INTERVAL;
124
125    let existing_max_epoch = checkpoints.keys().max().copied().unwrap_or(0);
126    println!("Existing max checkpoint epoch: {existing_max_epoch}");
127    println!("Latest checkpoint epoch should be: {latest_checkpoint_epoch}");
128
129    if latest_checkpoint_epoch <= existing_max_epoch {
130        println!("No new checkpoints needed (already up to date)");
131        return Ok(());
132    }
133
134    let mut needed_epochs = Vec::new();
135    let mut epoch = existing_max_epoch + CHECKPOINT_INTERVAL;
136    while epoch <= latest_checkpoint_epoch {
137        if !checkpoints.contains_key(&epoch) {
138            needed_epochs.push(epoch);
139        }
140        epoch += CHECKPOINT_INTERVAL;
141    }
142
143    if needed_epochs.is_empty() {
144        println!("No missing checkpoints to add");
145        return Ok(());
146    }
147
148    println!("Need to add {} checkpoint(s)", needed_epochs.len());
149
150    println!("Fetching checkpoints via RPC...");
151    let mut found_checkpoints: IndexMap<ChainEpoch, Cid> = IndexMap::new();
152
153    for &requested_epoch in &needed_epochs {
154        match fetch_checkpoint_at_height(client, requested_epoch).await {
155            Ok((actual_epoch, cid)) => {
156                found_checkpoints.insert(actual_epoch, cid);
157
158                if actual_epoch != requested_epoch {
159                    println!(
160                        "  ✓ Epoch {actual_epoch} (requested {requested_epoch}, no blocks at exact height): {cid}"
161                    );
162                } else {
163                    println!("  ✓ Epoch {}: {}", actual_epoch, cid);
164                }
165
166                // Map chain name for Beryx URL (calibnet -> calibration)
167                let beryx_network = if chain_name == "calibnet" {
168                    "calibration"
169                } else {
170                    chain_name
171                };
172                println!("    Verify at: https://beryx.io/fil/{beryx_network}/block-cid/{cid}",);
173            }
174            Err(e) => {
175                println!("  ✗ Epoch {requested_epoch}: {e}");
176            }
177        }
178    }
179
180    let num_found = found_checkpoints.len();
181    println!("\nAdding {num_found} new checkpoint(s) to the file...");
182
183    let mut sorted_checkpoints: Vec<_> = found_checkpoints.into_iter().collect();
184    sorted_checkpoints.sort_by_key(|(epoch, _)| std::cmp::Reverse(*epoch));
185
186    let mut new_map = IndexMap::new();
187    for (epoch, cid) in sorted_checkpoints {
188        new_map.insert(epoch, cid);
189    }
190    new_map.extend(checkpoints.drain(..));
191    *checkpoints = new_map;
192
193    if num_found < needed_epochs.len() {
194        anyhow::bail!(
195            "Only found {num_found} out of {} needed checkpoints. Consider using an RPC provider with full historical data (e.g., Filfox).",
196            needed_epochs.len()
197        );
198    }
199
200    Ok(())
201}
202
203/// Fetch a checkpoint at a specific height via RPC.
204///
205/// Returns `(actual_epoch, cid)` where `actual_epoch` might be slightly earlier than requested
206/// if there were no blocks at the exact requested height.
207async fn fetch_checkpoint_at_height(
208    client: &Client,
209    epoch: ChainEpoch,
210) -> anyhow::Result<(ChainEpoch, Cid)> {
211    let tipset = ChainGetTipSetByHeight::call(client, (epoch, ApiTipsetKey(None)))
212        .await
213        .context("ChainGetTipSetByHeight RPC call failed")?;
214
215    let actual_epoch = tipset.epoch();
216    let first_block_cid = tipset.block_headers().first().cid();
217    Ok((actual_epoch, *first_block_cid))
218}
219
220fn write_known_blocks(path: &PathBuf, known_blocks: &KnownBlocks) -> anyhow::Result<()> {
221    let mut output = String::new();
222
223    output.push_str("# This file is auto-generated by `forest-dev update-checkpoints` command.\n");
224    output.push_str("# Do not edit manually. Run the command to update checkpoints.\n\n");
225
226    output.push_str("calibnet:\n");
227    for (epoch, cid) in &known_blocks.calibnet {
228        output.push_str(&format!("  {epoch}: {cid}\n"));
229    }
230
231    output.push_str("mainnet:\n");
232    for (epoch, cid) in &known_blocks.mainnet {
233        output.push_str(&format!("  {epoch}: {cid}\n"));
234    }
235
236    std::fs::write(path, output).context(format!(
237        "Failed to write updated known blocks to {}",
238        path.display()
239    ))?;
240
241    Ok(())
242}
243
244// Custom serde module for serializing/deserializing IndexMap<ChainEpoch, Cid> as strings
245mod cid_string_map {
246    use super::*;
247    use serde::de::{Deserialize, Deserializer};
248    use serde::ser::Serializer;
249    use std::str::FromStr;
250
251    pub fn serialize<S>(map: &IndexMap<ChainEpoch, Cid>, serializer: S) -> Result<S::Ok, S::Error>
252    where
253        S: Serializer,
254    {
255        use serde::ser::SerializeMap;
256        let mut ser_map = serializer.serialize_map(Some(map.len()))?;
257        for (k, v) in map {
258            ser_map.serialize_entry(k, &v.to_string())?;
259        }
260        ser_map.end()
261    }
262
263    pub fn deserialize<'de, D>(deserializer: D) -> Result<IndexMap<ChainEpoch, Cid>, D::Error>
264    where
265        D: Deserializer<'de>,
266    {
267        let string_map: IndexMap<ChainEpoch, String> = IndexMap::deserialize(deserializer)?;
268        string_map
269            .into_iter()
270            .map(|(k, v)| {
271                Cid::from_str(&v)
272                    .map(|cid| (k, cid))
273                    .map_err(serde::de::Error::custom)
274            })
275            .collect()
276    }
277}