|
| 1 | +// Copyright 2019-2026 ChainSafe Systems |
| 2 | +// SPDX-License-Identifier: Apache-2.0, MIT |
| 3 | + |
| 4 | +use anyhow::Context as _; |
| 5 | +use cid::Cid; |
| 6 | +use clap::Parser; |
| 7 | +use indexmap::IndexMap; |
| 8 | +use serde::{Deserialize, Serialize}; |
| 9 | +use std::path::PathBuf; |
| 10 | +use url::Url; |
| 11 | + |
| 12 | +use crate::rpc::Client; |
| 13 | +use crate::rpc::prelude::*; |
| 14 | +use crate::rpc::types::ApiTipsetKey; |
| 15 | +use crate::shim::clock::ChainEpoch; |
| 16 | + |
| 17 | +/// The interval between checkpoints (86400 epochs = 30 days) |
| 18 | +const 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)] |
| 23 | +struct 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)] |
| 32 | +pub 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)] |
| 46 | +pub 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 | + |
| 68 | +impl 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 | + |
| 110 | +async 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. |
| 207 | +async 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 | + |
| 220 | +fn 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 |
| 245 | +mod 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 | +} |
0 commit comments