use crate::config::{PruningConfig, PruningMode};
use crate::network::filter_service::BlockFilterService;
use crate::storage::blockstore::BlockStore;
#[cfg(feature = "utxo-commitments")]
use crate::storage::commitment_store::CommitmentStore;
use crate::storage::txindex::TxIndex;
#[cfg(feature = "utxo-commitments")]
use crate::storage::utxostore::UtxoStore;
use anyhow::{Result, anyhow};
#[cfg(feature = "utxo-commitments")]
use blvm_protocol::Hash;
#[cfg(feature = "utxo-commitments")]
use hex;
use std::sync::Arc;
use tracing::{debug, info, warn};
#[derive(Debug, Clone, Default)]
pub struct PruningStats {
pub blocks_pruned: u64,
pub headers_kept: u64,
pub blocks_kept: u64,
pub storage_freed: u64,
pub last_prune_height: Option<u64>,
}
pub(crate) fn incremental_prune_target(current_height: u64, window: u64, floor: u64) -> u64 {
current_height.saturating_sub(window.max(floor))
}
pub struct PruningManager {
pub config: PruningConfig,
pub(crate) network: blvm_protocol::types::Network,
blockstore: Arc<BlockStore>,
#[cfg(feature = "utxo-commitments")]
commitment_store: Option<Arc<CommitmentStore>>,
#[cfg(feature = "utxo-commitments")]
utxostore: Option<Arc<UtxoStore>>,
filter_service: Option<Arc<BlockFilterService>>,
tx_index: Option<Arc<TxIndex>>,
stats: std::sync::Mutex<PruningStats>,
}
pub(crate) fn body_is_prunable(has_body: bool, has_undo: bool) -> bool {
has_body && has_undo
}
impl PruningManager {
pub fn new(config: PruningConfig, blockstore: Arc<BlockStore>) -> Self {
Self {
config,
network: blvm_protocol::types::Network::Mainnet,
blockstore,
#[cfg(feature = "utxo-commitments")]
commitment_store: None,
#[cfg(feature = "utxo-commitments")]
utxostore: None,
filter_service: None,
tx_index: None,
stats: std::sync::Mutex::new(PruningStats::default()),
}
}
pub fn with_tx_index(mut self, tx_index: Arc<TxIndex>) -> Self {
self.tx_index = Some(tx_index);
self
}
pub fn with_network(mut self, network: blvm_protocol::types::Network) -> Self {
self.network = network;
self
}
#[cfg(feature = "utxo-commitments")]
pub fn with_utxo_commitments(
config: PruningConfig,
blockstore: Arc<BlockStore>,
commitment_store: Arc<CommitmentStore>,
utxostore: Arc<UtxoStore>,
) -> Self {
Self {
config,
network: blvm_protocol::types::Network::Mainnet,
blockstore,
commitment_store: Some(commitment_store),
utxostore: Some(utxostore),
filter_service: None,
tx_index: None,
stats: std::sync::Mutex::new(PruningStats::default()),
}
}
pub fn with_features(
config: PruningConfig,
blockstore: Arc<BlockStore>,
#[cfg(feature = "utxo-commitments")] commitment_store: Option<Arc<CommitmentStore>>,
#[cfg(feature = "utxo-commitments")] utxostore: Option<Arc<UtxoStore>>,
filter_service: Option<Arc<BlockFilterService>>,
) -> Self {
Self {
config,
network: blvm_protocol::types::Network::Mainnet,
blockstore,
#[cfg(feature = "utxo-commitments")]
commitment_store,
#[cfg(feature = "utxo-commitments")]
utxostore,
filter_service,
tx_index: None,
stats: std::sync::Mutex::new(PruningStats::default()),
}
}
pub fn get_stats(&self) -> PruningStats {
self.stats.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
#[cfg(feature = "utxo-commitments")]
pub fn commitment_store(&self) -> Option<Arc<CommitmentStore>> {
self.commitment_store.as_ref().map(Arc::clone)
}
#[cfg(feature = "utxo-commitments")]
pub fn utxostore(&self) -> Option<Arc<UtxoStore>> {
self.utxostore.as_ref().map(Arc::clone)
}
pub fn is_enabled(&self) -> bool {
!matches!(self.config.mode, PruningMode::Disabled)
}
pub fn should_auto_prune(&self, current_height: u64, last_prune_height: Option<u64>) -> bool {
if !self.config.auto_prune {
return false;
}
if let Some(last_height) = last_prune_height {
current_height >= last_height + self.config.auto_prune_interval
} else {
current_height >= self.config.auto_prune_interval
}
}
fn can_incremental_prune_during_ibd(&self) -> bool {
#[cfg(feature = "utxo-commitments")]
{
let has_commitments = self.commitment_store.is_some() && self.utxostore.is_some();
let mode_supports = matches!(
&self.config.mode,
PruningMode::Aggressive { .. }
| PruningMode::Custom {
keep_commitments: true,
..
}
);
has_commitments && mode_supports
}
#[cfg(not(feature = "utxo-commitments"))]
{
false
}
}
pub fn incremental_prune_during_ibd(
&self,
current_height: u64,
is_ibd: bool,
) -> Result<Option<PruningStats>> {
if !is_ibd || !self.config.incremental_prune_during_ibd {
return Ok(None);
}
if !self.can_incremental_prune_during_ibd() {
return Ok(None);
}
if current_height < self.config.min_blocks_for_incremental_prune {
return Ok(None);
}
let prune_to_height = incremental_prune_target(
current_height,
self.config.prune_window_size,
self.config.min_blocks_to_keep,
);
if prune_to_height == 0 || prune_to_height >= current_height {
return Ok(None);
}
info!(
"Incremental pruning during IBD: current_height={}, prune_to_height={}, window_size={}",
current_height, prune_to_height, self.config.prune_window_size
);
let stats = self.prune_to_height(prune_to_height, current_height, is_ibd)?;
Ok(Some(stats))
}
pub fn prune_to_height(
&self,
prune_to_height: u64,
current_height: u64,
is_ibd: bool,
) -> Result<PruningStats> {
if !self.is_enabled() {
return Err(anyhow!("Pruning is disabled"));
}
if is_ibd {
let can_incremental_prune =
self.config.incremental_prune_during_ibd && self.can_incremental_prune_during_ibd();
if !can_incremental_prune {
return Err(anyhow!(
"Cannot prune during initial block download. Wait for IBD to complete, or enable incremental_prune_during_ibd with UTXO commitments."
));
}
if current_height < self.config.min_blocks_for_incremental_prune {
return Err(anyhow!(
"Cannot prune during IBD until at least {} blocks are synced (currently at {})",
self.config.min_blocks_for_incremental_prune,
current_height
));
}
}
if prune_to_height >= current_height {
return Err(anyhow!(
"Cannot prune to height >= current height ({prune_to_height} >= {current_height})"
));
}
let blocks_to_keep = current_height.saturating_sub(prune_to_height);
if blocks_to_keep < self.config.min_blocks_to_keep {
return Err(anyhow!(
"Pruning would leave {} blocks, but minimum is {}",
blocks_to_keep,
self.config.min_blocks_to_keep
));
}
info!(
"Starting pruning: prune_to_height={}, current_height={}, mode={:?}",
prune_to_height, current_height, self.config.mode
);
let stats = match &self.config.mode {
PruningMode::Disabled => {
return Err(anyhow!("Pruning is disabled"));
}
PruningMode::Normal {
keep_from_height,
min_recent_blocks,
} => self.prune_normal(
prune_to_height,
current_height,
*keep_from_height,
*min_recent_blocks,
)?,
PruningMode::Aggressive {
keep_from_height,
keep_commitments,
keep_filtered_blocks,
min_blocks,
} => {
#[cfg(feature = "utxo-commitments")]
{
self.prune_aggressive(
prune_to_height,
current_height,
*keep_from_height,
*keep_commitments,
*keep_filtered_blocks,
*min_blocks,
)?
}
#[cfg(not(feature = "utxo-commitments"))]
{
let _ = (
keep_from_height,
keep_commitments,
keep_filtered_blocks,
min_blocks,
);
return Err(anyhow!(
"Aggressive pruning requires utxo-commitments feature"
));
}
}
PruningMode::Custom {
keep_headers,
keep_bodies_from_height,
keep_commitments,
keep_filters,
keep_filtered_blocks,
keep_witnesses,
keep_tx_index,
} => self.prune_custom(
prune_to_height,
current_height,
*keep_headers,
*keep_bodies_from_height,
*keep_commitments,
*keep_filters,
*keep_filtered_blocks,
*keep_witnesses,
*keep_tx_index,
)?,
};
{
let mut stats_guard = self.stats.lock().unwrap_or_else(|e| e.into_inner());
stats_guard.blocks_pruned += stats.blocks_pruned;
stats_guard.headers_kept += stats.headers_kept;
stats_guard.blocks_kept += stats.blocks_kept;
stats_guard.storage_freed += stats.storage_freed;
stats_guard.last_prune_height = Some(prune_to_height);
}
info!(
"Pruning complete: pruned {} blocks, kept {} blocks, freed ~{} bytes",
stats.blocks_pruned, stats.blocks_kept, stats.storage_freed
);
Ok(stats)
}
fn remove_body_if_undo_is_durable(
&self,
hash: &blvm_protocol::Hash,
stats: &mut PruningStats,
) -> Result<()> {
let has_body = self.blockstore.get_block(hash)?.is_some();
let has_undo = self.blockstore.get_undo_log(hash)?.is_some();
if !body_is_prunable(has_body, has_undo) {
return Ok(());
}
self.blockstore.remove_block_body(hash)?;
stats.blocks_pruned += 1;
stats.storage_freed += 1024;
Ok(())
}
fn drop_tx_index_for_block(&self, hash: &blvm_protocol::Hash) -> Result<()> {
let Some(index) = &self.tx_index else {
return Ok(());
};
let Some(block) = self.blockstore.get_block(hash)? else {
return Ok(());
};
for tx in &block.transactions {
let txid = blvm_protocol::block::calculate_tx_id(tx);
index.remove_transaction(&txid)?;
}
Ok(())
}
fn prune_normal(
&self,
prune_to_height: u64,
current_height: u64,
keep_from_height: u64,
min_recent_blocks: u64,
) -> Result<PruningStats> {
let mut stats = PruningStats::default();
let effective_keep_height =
keep_from_height.max(current_height.saturating_sub(min_recent_blocks));
let actual_prune_height = prune_to_height.min(effective_keep_height);
debug!(
"Normal pruning: prune_to={}, keep_from={}, min_recent={}, effective_keep={}, actual_prune={}",
prune_to_height,
keep_from_height,
min_recent_blocks,
effective_keep_height,
actual_prune_height
);
for height in 0..actual_prune_height {
if let Some(hash) = self.blockstore.get_hash_by_height(height)? {
self.remove_body_if_undo_is_durable(&hash, &mut stats)?;
}
}
stats.blocks_kept = current_height.saturating_sub(actual_prune_height);
stats.headers_kept = current_height;
Ok(stats)
}
#[cfg(feature = "utxo-commitments")]
fn prune_aggressive(
&self,
prune_to_height: u64,
current_height: u64,
keep_from_height: u64,
keep_commitments: bool,
keep_filtered_blocks: bool,
min_blocks: u64,
) -> Result<PruningStats> {
let mut stats = PruningStats::default();
let effective_keep_height = keep_from_height.max(current_height.saturating_sub(min_blocks));
let actual_prune_height = prune_to_height.min(effective_keep_height);
debug!(
"Aggressive pruning: prune_to={}, keep_from={}, min_blocks={}, effective_keep={}, actual_prune={}",
prune_to_height,
keep_from_height,
min_blocks,
effective_keep_height,
actual_prune_height
);
if keep_commitments {
if let (Some(commitment_store), Some(utxostore)) =
(self.commitment_store.as_ref(), self.utxostore.as_ref())
{
info!("Generating UTXO commitments for blocks to be pruned...");
self.generate_commitments_before_prune(
actual_prune_height,
current_height,
commitment_store,
utxostore,
)?;
} else {
warn!(
"UTXO commitments requested but commitment store or UTXO store not available"
);
}
}
for height in 0..actual_prune_height {
if let Some(hash) = self.blockstore.get_hash_by_height(height)? {
self.remove_body_if_undo_is_durable(&hash, &mut stats)?;
if !keep_filtered_blocks {
self.blockstore.remove_witness(&hash)?;
}
if let Some(ref filter_service) = self.filter_service {
if filter_service.has_filter(&hash) {
filter_service.remove_filter_for_pruned_block(&hash)?;
debug!(
"Removed BIP158 filter for pruned block at height {} (header kept)",
height
);
}
}
}
}
stats.blocks_kept = current_height.saturating_sub(actual_prune_height);
stats.headers_kept = current_height;
Ok(stats)
}
fn prune_custom(
&self,
prune_to_height: u64,
current_height: u64,
keep_headers: bool,
keep_bodies_from_height: u64,
keep_commitments: bool,
keep_filters: bool,
keep_filtered_blocks: bool,
keep_witnesses: bool,
keep_tx_index: bool,
) -> Result<PruningStats> {
let mut stats = PruningStats::default();
if !keep_headers {
warn!("Custom pruning with keep_headers=false is not recommended (required for PoW)");
}
let actual_prune_height = prune_to_height.min(keep_bodies_from_height);
debug!(
"Custom pruning: prune_to={}, keep_bodies_from={}, actual_prune={}",
prune_to_height, keep_bodies_from_height, actual_prune_height
);
for height in 0..actual_prune_height {
if let Some(hash) = self.blockstore.get_hash_by_height(height)? {
if !keep_tx_index {
self.drop_tx_index_for_block(&hash)?;
}
if height < keep_bodies_from_height {
self.remove_body_if_undo_is_durable(&hash, &mut stats)?;
}
if !keep_witnesses {
self.blockstore.remove_witness(&hash)?;
}
if keep_commitments {
#[cfg(feature = "utxo-commitments")]
{
if let (Some(commitment_store), Some(utxostore)) =
(self.commitment_store.as_ref(), self.utxostore.as_ref())
{
if !commitment_store.has_commitment(&hash)? {
self.generate_commitment_for_block(
&hash,
height,
commitment_store,
utxostore,
)?;
}
}
}
}
if keep_filters {
if let Some(ref filter_service) = self.filter_service {
if filter_service.has_filter(&hash) {
filter_service.remove_filter_for_pruned_block(&hash)?;
debug!(
"Removed BIP158 filter for pruned block at height {} (header kept)",
height
);
}
}
}
if !keep_filtered_blocks {
}
}
}
stats.blocks_kept = current_height.saturating_sub(actual_prune_height);
stats.headers_kept = if keep_headers { current_height } else { 0 };
Ok(stats)
}
#[cfg(feature = "utxo-commitments")]
fn generate_commitments_before_prune(
&self,
prune_to_height: u64,
_current_height: u64,
commitment_store: &CommitmentStore,
utxostore: &UtxoStore,
) -> Result<()> {
info!(
"Generating UTXO commitments for heights 0..{}",
prune_to_height
);
let checkpoint_interval = 144;
for height in (0..prune_to_height).step_by(checkpoint_interval as usize) {
if let Some(hash) = self.blockstore.get_hash_by_height(height)? {
if commitment_store.has_commitment(&hash)? {
debug!("Commitment already exists for height {}", height);
continue;
}
self.generate_commitment_for_block(&hash, height, commitment_store, utxostore)?;
}
}
info!("Finished generating UTXO commitments");
Ok(())
}
#[cfg(feature = "utxo-commitments")]
fn generate_commitment_for_block(
&self,
block_hash: &Hash,
height: u64,
commitment_store: &CommitmentStore,
utxostore: &UtxoStore,
) -> Result<()> {
#[cfg(feature = "utxo-commitments")]
use blvm_protocol::utxo_commitments::merkle_tree::UtxoMerkleTree;
let utxo_set = self.reconstruct_utxo_set_at_height(height, utxostore)?;
let mut utxo_tree = UtxoMerkleTree::new()
.map_err(|e| anyhow::anyhow!("Failed to create UTXO Merkle tree: {e:?}"))?;
for (outpoint, utxo) in &utxo_set {
utxo_tree
.insert(*outpoint, utxo.as_ref().clone())
.map_err(|e| anyhow::anyhow!("Failed to insert UTXO: {e:?}"))?;
}
let commitment = utxo_tree.generate_commitment(*block_hash, height);
commitment_store.store_commitment(block_hash, height, &commitment)?;
debug!(
"Generated and stored commitment for height {} (hash: {})",
height,
hex::encode(block_hash)
);
Ok(())
}
#[cfg(feature = "utxo-commitments")]
pub fn generate_commitment_from_current_state(
&self,
block_hash: &Hash,
height: u64,
utxo_set: &blvm_protocol::UtxoSet,
commitment_store: &CommitmentStore,
) -> Result<()> {
use blvm_protocol::utxo_commitments::merkle_tree::UtxoMerkleTree;
let mut utxo_tree = UtxoMerkleTree::new()
.map_err(|e| anyhow::anyhow!("Failed to create UTXO Merkle tree: {e:?}"))?;
for (outpoint, utxo) in utxo_set {
utxo_tree
.insert(*outpoint, utxo.as_ref().clone())
.map_err(|e| anyhow::anyhow!("Failed to insert UTXO: {e:?}"))?;
}
let commitment = utxo_tree.generate_commitment(*block_hash, height);
commitment_store.store_commitment(block_hash, height, &commitment)?;
debug!(
"Generated commitment from current state for height {} (hash: {})",
height,
hex::encode(block_hash)
);
Ok(())
}
#[cfg(feature = "utxo-commitments")]
fn reconstruct_utxo_set_at_height(
&self,
target_height: u64,
utxostore: &UtxoStore,
) -> Result<blvm_protocol::UtxoSet> {
use blvm_protocol::UtxoSet;
use blvm_protocol::block::connect_block;
let mut utxo_set = UtxoSet::default();
if target_height == 0 {
return Ok(utxo_set);
}
for height in 0..target_height {
if let Some(block_hash) = self.blockstore.get_hash_by_height(height)? {
if let Some(block) = self.blockstore.get_block(&block_hash)? {
let witnesses = crate::node::block_processor::load_witnesses_for_block_network(
&self.blockstore,
&block,
height,
self.network,
)?;
let mut ctx =
blvm_protocol::block::BlockValidationContext::for_network(self.network);
ctx.sequence_prev_mtp = Some(self.blockstore.sequence_prev_mtp_lookup());
ctx.difficulty_ancestor = Some(self.blockstore.difficulty_ancestor_lookup());
let (validation_result, new_utxo_set, _undo_log) =
connect_block(&block, &witnesses, utxo_set, height, &ctx)?;
if !matches!(validation_result, blvm_protocol::ValidationResult::Valid) {
return Err(anyhow!(
"block at height {height} failed validation during UTXO reconstruction"
));
}
utxo_set = new_utxo_set;
} else {
return Err(anyhow!(
"block at height {height} not found during UTXO reconstruction"
));
}
} else {
return Err(anyhow!(
"height {height} not found in chain during UTXO reconstruction"
));
}
}
debug!(
"Reconstructed UTXO set at height {} with {} UTXOs",
target_height,
utxo_set.len()
);
Ok(utxo_set)
}
}
#[cfg(all(test, feature = "utxo-commitments"))]
mod reconstruct_tests {
use super::*;
use crate::config::{PruningConfig, PruningMode};
use crate::storage::blockstore::BlockStore;
use crate::storage::database::{create_database, default_backend};
use crate::storage::utxostore::UtxoStore;
use blvm_protocol::types::Network;
use std::sync::Arc;
use tempfile::TempDir;
#[test]
fn reconstruct_missing_chain_height_errors() {
let temp_dir = TempDir::new().unwrap();
let db = Arc::from(create_database(temp_dir.path(), default_backend(), None).unwrap());
let blockstore = Arc::new(BlockStore::new(Arc::clone(&db)).unwrap());
let utxostore = UtxoStore::new(Arc::clone(&db)).unwrap();
let config = PruningConfig {
mode: PruningMode::Disabled,
..Default::default()
};
let manager = PruningManager::new(config, blockstore).with_network(Network::Regtest);
let err = manager
.reconstruct_utxo_set_at_height(1, &utxostore)
.unwrap_err();
assert!(
err.to_string().contains("not found"),
"unexpected error: {err}"
);
}
}
#[cfg(test)]
mod prune_undo_tests {
use super::{PruningManager, body_is_prunable, incremental_prune_target};
use crate::config::{PruningConfig, PruningMode};
use crate::storage::blockstore::BlockStore;
use crate::storage::database::{create_database, default_backend};
use std::sync::Arc;
use tempfile::TempDir;
#[test]
fn default_window_keeps_the_floor() {
assert_eq!(incremental_prune_target(1000, 144, 288), 712);
let temp_dir = TempDir::new().unwrap();
let db = Arc::from(create_database(temp_dir.path(), default_backend(), None).unwrap());
let blockstore = Arc::new(BlockStore::new(Arc::clone(&db)).unwrap());
let config = PruningConfig {
mode: PruningMode::Normal {
keep_from_height: 0,
min_recent_blocks: 0,
},
min_blocks_to_keep: 288,
prune_window_size: 144,
..PruningConfig::default()
};
let manager = PruningManager::new(config, blockstore);
let short = manager
.prune_to_height(1000 - 144, 1000, false)
.unwrap_err();
assert!(short.to_string().contains("would leave 144"), "{short}");
manager.prune_to_height(712, 1000, false).unwrap();
}
#[test]
fn a_body_without_undo_is_kept() {
assert!(!body_is_prunable(true, false));
assert!(body_is_prunable(true, true));
assert!(!body_is_prunable(false, true));
}
}