#![allow(clippy::print_stdout, clippy::print_stderr, clippy::unwrap_in_result)]
use std::{fs, io::Write, path::Path};
use color_eyre::eyre::{ensure, eyre, Context, Result};
use zakura_chain::{
block::{self, Height, MAX_BLOCK_BYTES},
parameters::Network,
};
use zakura_node_services::constants::{MAX_CHECKPOINT_BYTE_COUNT, MAX_CHECKPOINT_HEIGHT_GAP};
use crate::args::Args;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct BlockRow {
pub height: Height,
pub hash: block::Hash,
pub size: u32,
}
pub fn select_checkpoints(
base_height: Height,
rows: impl IntoIterator<Item = BlockRow>,
max_height_gap: u32,
max_byte_count: u64,
) -> Result<Vec<(Height, block::Hash)>> {
let mut selected = Vec::new();
let mut cumulative_bytes: u64 = 0;
let mut last_height = base_height;
let mut next_height = base_height
.0
.checked_add(1)
.ok_or_else(|| eyre!("base height overflows the block height range"))?;
for row in rows {
ensure!(
row.height.0 == next_height,
"block rows must be contiguous: expected height {next_height}, got {}",
row.height.0
);
next_height = row
.height
.0
.checked_add(1)
.ok_or_else(|| eyre!("block height overflows the block height range"))?;
cumulative_bytes = cumulative_bytes
.checked_add(u64::from(row.size))
.ok_or_else(|| eyre!("cumulative checkpoint byte count overflowed"))?;
let height_gap = row.height.0 - last_height.0;
if cumulative_bytes >= max_byte_count || height_gap >= max_height_gap {
selected.push((row.height, row.hash));
cumulative_bytes = 0;
last_height = row.height;
}
}
Ok(selected)
}
fn validate_header_link(
height: Height,
actual_parent: block::Hash,
expected_parent: block::Hash,
) -> Result<()> {
ensure!(
actual_parent == expected_parent,
"retained block header at height {} links to {actual_parent}, but the preceding canonical \
hash is {expected_parent}",
height.0
);
Ok(())
}
pub fn run_offline(args: &Args) -> Result<()> {
let state_cache_dir = args
.state_cache_dir
.clone()
.expect("offline mode is only entered with --state-cache-dir");
let network = Network::Mainnet;
let embedded_max_height = network.checkpoint_list().max_height();
let base_height = args.last_checkpoint.unwrap_or(embedded_max_height);
let state_config = zakura_state::Config {
cache_dir: state_cache_dir,
delete_old_database: false,
storage_mode: zakura_state::StorageMode::Pruned(zakura_state::PruningConfig::default()),
..zakura_state::Config::default()
};
let (_read_state, db, _non_finalized_sender) =
zakura_state::init_read_only(state_config, &network)
.wrap_err("opening the Mainnet state database read-only")?;
let (tip_height, tip_hash) = db
.tip()
.ok_or_else(|| eyre!("Mainnet state database has no finalized tip"))?;
ensure!(
tip_height > base_height,
"state tip {} is not above the last checkpoint {}; sync further before exporting",
tip_height.0,
base_height.0
);
eprintln!(
"exporting checkpoints above {} from finalized tip {} ({tip_hash})",
base_height.0, tip_height.0
);
let mut previous_hash = db.hash(base_height).ok_or_else(|| {
eyre!(
"state database has no block at the base checkpoint {}",
base_height.0
)
})?;
if let Some(embedded_hash) = network.checkpoint_list().hash(base_height) {
ensure!(
previous_hash == embedded_hash,
"state database hash at base checkpoint {} is {previous_hash}, but the embedded \
checkpoint list has {embedded_hash}; refusing to export from a mismatched chain",
base_height.0
);
}
let rows = ((base_height.0 + 1)..=tip_height.0).map(|raw_height| {
let height = Height(raw_height);
let hash = db
.hash(height)
.ok_or_else(|| eyre!("missing retained finalized hash at height {raw_height}"))?;
ensure!(
db.height(hash) == Some(height),
"finalized hash indexes disagree at height {raw_height}"
);
let header = db
.block_header(height.into())
.ok_or_else(|| eyre!("missing retained block header at height {raw_height}"))?;
ensure!(
block::Hash::from(header.as_ref()) == hash,
"hash index disagrees with the retained block header at height {raw_height}"
);
validate_header_link(height, header.previous_block_hash, previous_hash)?;
previous_hash = hash;
let info = db
.block_info(height.into())
.ok_or_else(|| eyre!("missing retained BlockInfo at height {raw_height}"))?;
ensure!(
u64::from(info.size()) <= MAX_BLOCK_BYTES && info.size() > 0,
"invalid retained block size {} at height {raw_height}",
info.size()
);
Ok(BlockRow {
height,
hash,
size: info.size(),
})
});
let rows: Vec<BlockRow> = rows.collect::<Result<_>>()?;
let max_height_gap =
u32::try_from(MAX_CHECKPOINT_HEIGHT_GAP).expect("checkpoint height gap fits in u32");
let selected =
select_checkpoints(base_height, rows, max_height_gap, MAX_CHECKPOINT_BYTE_COUNT)?;
ensure!(
!selected.is_empty(),
"not enough finalized blocks above checkpoint {} to emit a new checkpoint",
base_height.0
);
let &(last_height, last_hash) = selected
.last()
.expect("selection was checked to be non-empty");
if let Some(frontier_path) = &args.mainnet_frontier_output {
write_frontier(&db, last_height, frontier_path)?;
}
let stdout = std::io::stdout();
let mut stdout = stdout.lock();
if args.full_list {
for (height, hash) in network.checkpoint_list().iter_cloned() {
writeln!(stdout, "{} {hash}", height.0)?;
}
}
for (height, hash) in &selected {
writeln!(stdout, "{} {hash}", height.0)?;
}
stdout.flush()?;
eprintln!(
"emitted {} checkpoints; last checkpoint {} ({last_hash})",
selected.len(),
last_height.0
);
Ok(())
}
fn write_frontier(db: &zakura_state::ZakuraDb, height: Height, path: &Path) -> Result<()> {
let bytes = zakura_state::produce_settled_final_frontiers_bytes(db, height)
.wrap_err("producing the Mainnet final frontiers")?;
zakura_state::validate_final_frontiers_bytes(&bytes, height)
.wrap_err("validating the produced frontier bytes")?;
let temporary_path = path.with_extension("tmp");
fs::write(&temporary_path, &bytes)
.wrap_err_with(|| format!("writing frontier artifact to {}", temporary_path.display()))?;
fs::rename(&temporary_path, path)
.wrap_err_with(|| format!("renaming frontier artifact to {}", path.display()))?;
eprintln!(
"wrote {}-byte frontier artifact for checkpoint {} to {}",
bytes.len(),
height.0,
path.display()
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn test_hash(height: u32) -> block::Hash {
let mut bytes = [0; 32];
bytes[..4].copy_from_slice(&height.to_le_bytes());
block::Hash(bytes)
}
fn rows(base: u32, count: u32, size: u32) -> Vec<BlockRow> {
(base + 1..=base + count)
.map(|height| BlockRow {
height: Height(height),
hash: test_hash(height),
size,
})
.collect()
}
#[test]
fn selects_on_height_gap() {
let selected = select_checkpoints(Height(100), rows(100, 10, 1), 4, u64::MAX)
.expect("contiguous rows select");
assert_eq!(
selected
.iter()
.map(|(height, _)| height.0)
.collect::<Vec<_>>(),
vec![104, 108],
"a checkpoint is emitted at every full height gap, and the short tail is dropped"
);
assert_eq!(selected[0].1, test_hash(104), "hashes follow their rows");
}
#[test]
fn selects_on_byte_count() {
let selected = select_checkpoints(Height(0), rows(0, 7, 40), 1000, 100)
.expect("contiguous rows select");
assert_eq!(
selected
.iter()
.map(|(height, _)| height.0)
.collect::<Vec<_>>(),
vec![3, 6],
"cumulative bytes reset at every selected checkpoint"
);
}
#[test]
fn selection_is_prefix_compatible_across_tips() {
let long = rows(500, 100, 7);
let full = select_checkpoints(Height(500), long.clone(), 10, 64).expect("select");
for shorter_len in [10, 35, 61, 99] {
let partial = select_checkpoints(Height(500), long[..shorter_len].to_vec(), 10, 64)
.expect("select");
assert_eq!(
partial,
full[..partial.len()],
"selection from a shorter tip is a prefix of the longer selection"
);
}
let (rebase_height, _) = full[1];
let rebase_rows: Vec<BlockRow> = long
.iter()
.copied()
.filter(|row| row.height > rebase_height)
.collect();
let rebased = select_checkpoints(rebase_height, rebase_rows, 10, 64).expect("select");
assert_eq!(
rebased,
full[2..],
"selection re-based at a selected checkpoint continues the sequence"
);
}
#[test]
fn short_chains_select_nothing() {
let selected = select_checkpoints(Height(9), rows(9, 3, 1), 4, u64::MAX)
.expect("contiguous rows select");
assert!(
selected.is_empty(),
"chains shorter than the first trigger emit no checkpoints"
);
}
#[test]
fn non_contiguous_rows_are_rejected() {
let mut gapped = rows(10, 5, 1);
gapped.remove(2);
let result = select_checkpoints(Height(10), gapped, 4, u64::MAX);
assert!(result.is_err(), "a height gap in the rows is an error");
let offset = rows(11, 3, 1);
let result = select_checkpoints(Height(10), offset, 4, u64::MAX);
assert!(
result.is_err(),
"rows must start immediately above the base height"
);
}
#[test]
fn header_links_must_extend_the_preceding_canonical_hash() {
let expected_parent = test_hash(10);
assert!(
validate_header_link(Height(11), expected_parent, expected_parent).is_ok(),
"a header linked to the preceding canonical hash is accepted"
);
assert!(
validate_header_link(Height(11), test_hash(9), expected_parent).is_err(),
"a disconnected retained header is rejected"
);
}
}