Skip to main content

tenzro_storage/
snapshot.rs

1//! State snapshot management for Tenzro Network
2//!
3//! This module provides functionality for creating, storing, and restoring
4//! state snapshots at specific block heights.
5//!
6//! # Concurrency Model
7//!
8//! ## For `SnapshotManager<RocksDbStore>`
9//!
10//! Prefer `create_checkpoint_snapshot()` which uses RocksDB's built-in atomic checkpoint
11//! mechanism. The checkpoint creates a point-in-time consistent view of the database
12//! without blocking writes.
13//!
14//! ## For other `KvStore` implementations
15//!
16//! Use the `state_lock` for coordination:
17//!
18//! 1. Writers must hold a write lock when modifying state that needs to be
19//!    consistent in snapshots.
20//! 2. Callers collecting state data for `create_snapshot()` must hold a read lock
21//!    while collecting state data to ensure consistency.
22//!
23//! ```ignore
24//! // Example usage:
25//! let state_lock = manager.state_lock();
26//! let state_data = {
27//!     let _guard = state_lock.read();
28//!     // Collect state data here while holding read lock
29//!     collect_state_data()
30//! };
31//! manager.create_snapshot(height, state_root, block_hash, state_data).await?;
32//! ```
33
34use crate::error::{Result, StorageError};
35use crate::kv::{KvStore, RocksDbStore, WriteOp, CF_SNAPSHOTS};
36use flate2::read::{GzDecoder, GzEncoder};
37use flate2::Compression;
38use parking_lot::RwLock;
39use rocksdb::checkpoint::Checkpoint;
40use serde::{Deserialize, Serialize};
41use std::io::Read;
42use std::path::Path;
43use std::sync::Arc;
44use tenzro_types::{BlockHeight, Hash, Timestamp};
45
46/// A state snapshot at a specific block height
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct Snapshot {
49    /// The block height at which this snapshot was taken
50    pub height: BlockHeight,
51    /// The state root hash at this height
52    pub state_root: Hash,
53    /// The block hash at this height
54    pub block_hash: Hash,
55    /// The timestamp when the snapshot was created
56    pub timestamp: Timestamp,
57    /// Snapshot metadata
58    pub metadata: SnapshotMetadata,
59    /// Compressed state data
60    pub state_data: Vec<u8>,
61}
62
63/// Metadata for a snapshot
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct SnapshotMetadata {
66    /// Total number of accounts in the snapshot
67    pub account_count: u64,
68    /// Total state size in bytes (before compression)
69    pub state_size: u64,
70    /// Compressed size in bytes
71    pub compressed_size: u64,
72    /// Compression algorithm used
73    pub compression: CompressionType,
74}
75
76/// Compression types for snapshots
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
78pub enum CompressionType {
79    /// No compression
80    None,
81    /// LZ4 compression
82    Lz4,
83    /// Zstandard compression
84    Zstd,
85    /// Gzip compression (flate2)
86    Gzip,
87}
88
89/// Compresses raw data using gzip.
90///
91/// Returns the compressed bytes. If compression fails (should not happen for
92/// valid input), returns a `StorageError`.
93fn compress_gzip(data: &[u8]) -> Result<Vec<u8>> {
94    let mut encoder = GzEncoder::new(data, Compression::default());
95    let mut compressed = Vec::new();
96    encoder.read_to_end(&mut compressed).map_err(|e| {
97        StorageError::InvalidSnapshot(format!("gzip compression failed: {}", e))
98    })?;
99    Ok(compressed)
100}
101
102/// Decompresses gzip-compressed data.
103///
104/// Returns the original uncompressed bytes. Returns a `StorageError` if the
105/// data is not valid gzip.
106fn decompress_gzip(data: &[u8]) -> Result<Vec<u8>> {
107    let mut decoder = GzDecoder::new(data);
108    let mut decompressed = Vec::new();
109    decoder.read_to_end(&mut decompressed).map_err(|e| {
110        StorageError::InvalidSnapshot(format!("gzip decompression failed: {}", e))
111    })?;
112    Ok(decompressed)
113}
114
115/// Snapshot manager for creating and managing state snapshots
116pub struct SnapshotManager<K: KvStore> {
117    kv_store: Arc<K>,
118    retention_count: Arc<RwLock<u64>>,
119    /// Lock to ensure consistent state reads during snapshot creation.
120    /// Callers must hold a read lock on this while collecting state data,
121    /// and writers must hold a write lock when modifying state.
122    state_lock: Arc<RwLock<()>>,
123}
124
125impl<K: KvStore> SnapshotManager<K> {
126    /// Creates a new snapshot manager
127    pub fn new(kv_store: Arc<K>, retention_count: u64) -> Self {
128        Self {
129            kv_store,
130            retention_count: Arc::new(RwLock::new(retention_count)),
131            state_lock: Arc::new(RwLock::new(())),
132        }
133    }
134
135    /// Returns the state lock for coordinating snapshot consistency.
136    ///
137    /// Hold a read lock while collecting state data for `create_snapshot()`.
138    /// Writers should hold a write lock when modifying state that needs
139    /// to be consistent in snapshots.
140    ///
141    /// # Example
142    ///
143    /// ```ignore
144    /// // Collect state data with read lock held
145    /// let state_lock = manager.state_lock();
146    /// let state_data = {
147    ///     let _guard = state_lock.read();
148    ///     collect_state_data()
149    /// };
150    /// manager.create_snapshot(height, state_root, block_hash, state_data).await?;
151    /// ```
152    pub fn state_lock(&self) -> Arc<RwLock<()>> {
153        self.state_lock.clone()
154    }
155
156    /// Creates a consistent snapshot by collecting state data under the lock.
157    ///
158    /// This method atomically:
159    /// 1. Acquires the state write lock (prevents all concurrent modifications)
160    /// 2. Calls the provided closure to collect state data
161    /// 3. Stores the snapshot while the lock is held
162    ///
163    /// This eliminates the race condition where state could change between
164    /// data collection and snapshot creation.
165    ///
166    /// # Example
167    ///
168    /// ```ignore
169    /// manager.create_consistent_snapshot(height, state_root, block_hash, || {
170    ///     collect_state_data_from_vm()
171    /// }).await?;
172    /// ```
173    // The parking_lot::RwLock guard is intentionally held across async calls here.
174    // The called methods (store_snapshot, prune_snapshots) perform only synchronous
175    // RocksDB I/O and never yield to the executor, so no deadlock is possible.
176    #[allow(clippy::await_holding_lock)]
177    pub async fn create_consistent_snapshot<F>(
178        &self,
179        height: BlockHeight,
180        state_root: Hash,
181        block_hash: Hash,
182        collect_state: F,
183    ) -> Result<Snapshot>
184    where
185        F: FnOnce() -> Vec<u8>,
186    {
187        // Acquire WRITE lock to prevent any concurrent state modifications
188        // during both state collection AND snapshot serialization
189        let _guard = self.state_lock.write();
190
191        // Collect state data while holding the lock — no torn reads possible
192        let state_data = collect_state();
193
194        let state_size = state_data.len() as u64;
195        let compressed_data = compress_gzip(&state_data)?;
196        let compressed_size = compressed_data.len() as u64;
197
198        let snapshot = Snapshot {
199            height,
200            state_root,
201            block_hash,
202            timestamp: Timestamp::now(),
203            metadata: SnapshotMetadata {
204                account_count: 0,
205                state_size,
206                compressed_size,
207                compression: CompressionType::Gzip,
208            },
209            state_data: compressed_data,
210        };
211
212        // Store the snapshot while still holding the lock
213        self.store_snapshot(&snapshot).await?;
214
215        // Prune old snapshots
216        self.prune_snapshots(height).await?;
217
218        Ok(snapshot)
219    }
220
221    /// Creates a snapshot at the given height
222    ///
223    /// # Consistency
224    ///
225    /// The caller is responsible for ensuring `state_data` is consistent by
226    /// holding a read lock on `state_lock()` while collecting the data.
227    /// Prefer `create_consistent_snapshot()` which handles locking automatically.
228    ///
229    /// # Example
230    ///
231    /// ```ignore
232    /// let state_lock = manager.state_lock();
233    /// let state_data = {
234    ///     let _guard = state_lock.read();
235    ///     // Collect state data here
236    ///     collect_state_data()
237    /// };
238    /// manager.create_snapshot(height, state_root, block_hash, state_data).await?;
239    /// ```
240    // See create_consistent_snapshot for rationale on #[allow(clippy::await_holding_lock)]
241    #[allow(clippy::await_holding_lock)]
242    pub async fn create_snapshot(
243        &self,
244        height: BlockHeight,
245        state_root: Hash,
246        block_hash: Hash,
247        state_data: Vec<u8>,
248    ) -> Result<Snapshot> {
249        // Acquire read lock to prevent state modifications during snapshot serialization
250        let _guard = self.state_lock.read();
251
252        let state_size = state_data.len() as u64;
253
254        // Compress with gzip
255        let compressed_data = compress_gzip(&state_data)?;
256        let compressed_size = compressed_data.len() as u64;
257
258        let snapshot = Snapshot {
259            height,
260            state_root,
261            block_hash,
262            timestamp: Timestamp::now(),
263            metadata: SnapshotMetadata {
264                account_count: 0, // Would be calculated from state_data
265                state_size,
266                compressed_size,
267                compression: CompressionType::Gzip,
268            },
269            state_data: compressed_data,
270        };
271
272        // Store the snapshot
273        self.store_snapshot(&snapshot).await?;
274
275        // Prune old snapshots
276        self.prune_snapshots(height).await?;
277
278        Ok(snapshot)
279    }
280
281    /// Stores a snapshot
282    async fn store_snapshot(&self, snapshot: &Snapshot) -> Result<()> {
283        let key = Self::snapshot_key(snapshot.height);
284        let data = bincode::serialize(snapshot)?;
285        self.kv_store.put(CF_SNAPSHOTS, &key, &data)?;
286
287        // Also store in the index
288        let index_key = Self::snapshot_index_key();
289        let mut index = self.load_snapshot_index().await?;
290        index.push(snapshot.height);
291        index.sort_by_key(|h| h.0);
292        let index_data = bincode::serialize(&index)?;
293        self.kv_store.put(CF_SNAPSHOTS, &index_key, &index_data)?;
294
295        Ok(())
296    }
297
298    /// Loads a snapshot at the given height
299    pub async fn load_snapshot(&self, height: BlockHeight) -> Result<Option<Snapshot>> {
300        let key = Self::snapshot_key(height);
301        if let Some(data) = self.kv_store.get(CF_SNAPSHOTS, &key)? {
302            let snapshot: Snapshot = bincode::deserialize(&data)?;
303            Ok(Some(snapshot))
304        } else {
305            Ok(None)
306        }
307    }
308
309    /// Deletes a snapshot at the given height
310    pub async fn delete_snapshot(&self, height: BlockHeight) -> Result<()> {
311        let key = Self::snapshot_key(height);
312        self.kv_store.delete(CF_SNAPSHOTS, &key)?;
313
314        // Remove from index
315        let index_key = Self::snapshot_index_key();
316        let mut index = self.load_snapshot_index().await?;
317        index.retain(|h| *h != height);
318        let index_data = bincode::serialize(&index)?;
319        self.kv_store.put(CF_SNAPSHOTS, &index_key, &index_data)?;
320
321        Ok(())
322    }
323
324    /// Lists all available snapshots
325    pub async fn list_snapshots(&self) -> Result<Vec<BlockHeight>> {
326        self.load_snapshot_index().await
327    }
328
329    /// Gets the latest snapshot
330    pub async fn latest_snapshot(&self) -> Result<Option<Snapshot>> {
331        let index = self.load_snapshot_index().await?;
332        if let Some(height) = index.last() {
333            self.load_snapshot(*height).await
334        } else {
335            Ok(None)
336        }
337    }
338
339    /// Prunes old snapshots based on retention policy
340    async fn prune_snapshots(&self, _current_height: BlockHeight) -> Result<()> {
341        let retention = *self.retention_count.read();
342        let index = self.load_snapshot_index().await?;
343
344        if index.len() as u64 <= retention {
345            return Ok(());
346        }
347
348        // Calculate how many to delete
349        let to_delete = index.len() as u64 - retention;
350        let mut ops = Vec::new();
351
352        for height in index.iter().take(to_delete as usize) {
353            let key = Self::snapshot_key(*height);
354            ops.push(WriteOp::Delete {
355                cf: CF_SNAPSHOTS.to_string(),
356                key,
357            });
358        }
359
360        // Update index
361        let new_index: Vec<BlockHeight> = index.into_iter().skip(to_delete as usize).collect();
362        let index_key = Self::snapshot_index_key();
363        let index_data = bincode::serialize(&new_index)?;
364        ops.push(WriteOp::Put {
365            cf: CF_SNAPSHOTS.to_string(),
366            key: index_key,
367            value: index_data,
368        });
369
370        self.kv_store.write_batch(ops)?;
371
372        Ok(())
373    }
374
375    /// Loads the snapshot index
376    async fn load_snapshot_index(&self) -> Result<Vec<BlockHeight>> {
377        let key = Self::snapshot_index_key();
378        if let Some(data) = self.kv_store.get(CF_SNAPSHOTS, &key)? {
379            let index: Vec<BlockHeight> = bincode::deserialize(&data)?;
380            Ok(index)
381        } else {
382            Ok(Vec::new())
383        }
384    }
385
386    /// Generates a key for storing a snapshot
387    fn snapshot_key(height: BlockHeight) -> Vec<u8> {
388        let mut key = b"snapshot:".to_vec();
389        key.extend_from_slice(&height.0.to_be_bytes());
390        key
391    }
392
393    /// Generates the key for the snapshot index
394    fn snapshot_index_key() -> Vec<u8> {
395        b"snapshot_index".to_vec()
396    }
397
398    /// Sets the retention count
399    pub fn set_retention_count(&self, count: u64) {
400        *self.retention_count.write() = count;
401    }
402
403    /// Gets the retention count
404    pub fn retention_count(&self) -> u64 {
405        *self.retention_count.read()
406    }
407}
408
409impl SnapshotManager<RocksDbStore> {
410    /// Creates a consistent snapshot using RocksDB checkpoint
411    /// This ensures atomicity and consistency by using RocksDB's built-in checkpoint mechanism.
412    /// The checkpoint creates a point-in-time consistent view of the database without blocking writes.
413    pub async fn create_checkpoint_snapshot(
414        &self,
415        height: BlockHeight,
416        state_root: Hash,
417        block_hash: Hash,
418        checkpoint_path: &Path,
419    ) -> Result<Snapshot> {
420        // Create a RocksDB checkpoint for consistent point-in-time snapshot
421        let db = self.kv_store.db();
422        let checkpoint = Checkpoint::new(db)
423            .map_err(|e| StorageError::InvalidSnapshot(format!("Failed to create checkpoint: {}", e)))?;
424
425        checkpoint.create_checkpoint(checkpoint_path)
426            .map_err(|e| StorageError::InvalidSnapshot(format!("Failed to create checkpoint at path: {}", e)))?;
427
428        // The checkpoint is now a consistent snapshot of the entire database
429        // For the metadata, we need to read the checkpoint to get the actual size
430        let checkpoint_size = calculate_directory_size(checkpoint_path)?;
431
432        let snapshot = Snapshot {
433            height,
434            state_root,
435            block_hash,
436            timestamp: Timestamp::now(),
437            metadata: SnapshotMetadata {
438                account_count: 0, // Would need to scan checkpoint to calculate
439                state_size: checkpoint_size,
440                compressed_size: checkpoint_size,
441                compression: CompressionType::None,
442            },
443            state_data: checkpoint_path.to_string_lossy().as_bytes().to_vec(),
444        };
445
446        // Store the snapshot using the base implementation
447        let key = Self::snapshot_key(snapshot.height);
448        let data = bincode::serialize(&snapshot)?;
449        self.kv_store.put(CF_SNAPSHOTS, &key, &data)?;
450
451        // Update the index
452        let index_key = Self::snapshot_index_key();
453        let index_data_opt = self.kv_store.get(CF_SNAPSHOTS, &index_key)?;
454        let mut index: Vec<BlockHeight> = if let Some(data) = index_data_opt {
455            bincode::deserialize(&data)?
456        } else {
457            Vec::new()
458        };
459        index.push(snapshot.height);
460        index.sort_by_key(|h| h.0);
461        let index_data = bincode::serialize(&index)?;
462        self.kv_store.put(CF_SNAPSHOTS, &index_key, &index_data)?;
463
464        // Prune old snapshots
465        let retention = *self.retention_count.read();
466        if index.len() as u64 > retention {
467            let to_delete = index.len() as u64 - retention;
468            let mut ops = Vec::new();
469
470            for height in index.iter().take(to_delete as usize) {
471                let key = Self::snapshot_key(*height);
472                ops.push(WriteOp::Delete {
473                    cf: CF_SNAPSHOTS.to_string(),
474                    key,
475                });
476            }
477
478            // Update index
479            let new_index: Vec<BlockHeight> = index.into_iter().skip(to_delete as usize).collect();
480            let index_data = bincode::serialize(&new_index)?;
481            ops.push(WriteOp::Put {
482                cf: CF_SNAPSHOTS.to_string(),
483                key: index_key,
484                value: index_data,
485            });
486
487            self.kv_store.write_batch(ops)?;
488        }
489
490        Ok(snapshot)
491    }
492}
493
494/// Helper function to calculate the total size of a directory recursively
495fn calculate_directory_size(path: &Path) -> Result<u64> {
496    let mut total_size = 0u64;
497
498    if path.is_file() {
499        return Ok(std::fs::metadata(path)?.len());
500    }
501
502    if path.is_dir() {
503        for entry in std::fs::read_dir(path)? {
504            let entry = entry?;
505            let metadata = entry.metadata()?;
506            if metadata.is_file() {
507                total_size += metadata.len();
508            } else if metadata.is_dir() {
509                total_size += calculate_directory_size(&entry.path())?;
510            }
511        }
512    }
513
514    Ok(total_size)
515}
516
517/// A single key-value entry within a snapshot's serialized state data.
518///
519/// `state_data` in a `Snapshot` is a `bincode`-serialized `Vec<SnapshotEntry>`.
520/// This allows `SnapshotRestorer::restore_from_snapshot` to write each entry
521/// back into the correct column family of any `KvStore` implementation.
522#[derive(Debug, Clone, Serialize, Deserialize)]
523pub struct SnapshotEntry {
524    /// Column family name (e.g. CF_STATE, CF_ACCOUNTS)
525    pub cf: String,
526    /// Key bytes
527    pub key: Vec<u8>,
528    /// Value bytes
529    pub value: Vec<u8>,
530}
531
532/// Serializes a collection of column-family key-value pairs into the wire
533/// format expected by `Snapshot::state_data`.
534///
535/// # Example
536///
537/// ```ignore
538/// let entries = vec![
539///     SnapshotEntry { cf: CF_STATE.to_string(), key: b"k1".to_vec(), value: b"v1".to_vec() },
540/// ];
541/// let bytes = serialize_snapshot_entries(&entries)?;
542/// manager.create_snapshot(height, state_root, block_hash, bytes).await?;
543/// ```
544pub fn serialize_snapshot_entries(entries: &[SnapshotEntry]) -> Result<Vec<u8>> {
545    bincode::serialize(entries).map_err(|e| {
546        StorageError::SerializationError(format!("Failed to serialize snapshot entries: {}", e))
547    })
548}
549
550/// Compute a deterministic state-root hash over a set of snapshot entries.
551///
552/// Replaces the long-standing `Hash::zero()` stub. The root is
553/// `SHA-256(domain || sorted_triples)` where each triple is
554/// `len_le(cf) || cf || len_le(key) || key || len_le(value) || value`
555/// and `domain = b"tenzro/snapshot/state-root/v1"`.
556///
557/// Determinism: entries are sorted by `(cf, key)` before hashing so any
558/// snapshot of the same logical state produces the same root regardless of
559/// the order entries were collected in. The length prefixes guarantee no
560/// preimage ambiguity across cf/key/value boundaries.
561///
562/// This is *not* a Merkle-Patricia-Trie root — that's a heavier construct
563/// that the consensus state layer maintains separately and threads in via
564/// `create_snapshot(state_root, ...)`. This helper exists so callers that
565/// don't have an MPT (offline tooling, lightweight snapshots, tests) can
566/// still produce and verify a load-bearing root without falling back to
567/// `Hash::zero()`.
568pub fn compute_state_root(entries: &[SnapshotEntry]) -> Hash {
569    use sha2::{Digest, Sha256};
570    const DOMAIN: &[u8] = b"tenzro/snapshot/state-root/v1";
571
572    let mut sorted: Vec<&SnapshotEntry> = entries.iter().collect();
573    sorted.sort_by(|a, b| {
574        a.cf.as_bytes()
575            .cmp(b.cf.as_bytes())
576            .then_with(|| a.key.cmp(&b.key))
577    });
578
579    let mut hasher = Sha256::new();
580    hasher.update(DOMAIN);
581    for e in sorted {
582        let cf = e.cf.as_bytes();
583        hasher.update((cf.len() as u32).to_le_bytes());
584        hasher.update(cf);
585        hasher.update((e.key.len() as u32).to_le_bytes());
586        hasher.update(&e.key);
587        hasher.update((e.value.len() as u32).to_le_bytes());
588        hasher.update(&e.value);
589    }
590    let digest = hasher.finalize();
591    let mut out = [0u8; 32];
592    out.copy_from_slice(&digest);
593    Hash::new(out)
594}
595
596/// Snapshot restoration helper
597pub struct SnapshotRestorer;
598
599impl SnapshotRestorer {
600    /// Restores state from a snapshot by writing all stored key-value entries
601    /// back into `store`.
602    ///
603    /// The `snapshot.state_data` field must contain a `bincode`-serialized
604    /// `Vec<SnapshotEntry>` (produced by `serialize_snapshot_entries`).
605    /// Each entry is written to the corresponding column family atomically via
606    /// a single `write_batch_sync` call so the restoration is crash-safe.
607    ///
608    /// For RocksDB checkpoint snapshots (created by
609    /// `create_checkpoint_snapshot`), `state_data` holds the checkpoint path
610    /// as UTF-8 bytes and cannot be replayed through this method — use a
611    /// filesystem-level restore for those.
612    ///
613    /// Returns the number of key-value entries restored.
614    pub async fn restore_from_snapshot(
615        snapshot: &Snapshot,
616        store: &dyn KvStore,
617    ) -> Result<RestoredState> {
618        if snapshot.state_data.is_empty() {
619            return Err(StorageError::InvalidSnapshot(
620                "snapshot contains no state data".to_string(),
621            ));
622        }
623
624        // Decompress state data if needed
625        let raw_data = match snapshot.metadata.compression {
626            CompressionType::Gzip => decompress_gzip(&snapshot.state_data)?,
627            CompressionType::None => snapshot.state_data.clone(),
628            other => {
629                return Err(StorageError::InvalidSnapshot(format!(
630                    "unsupported compression type: {:?}",
631                    other
632                )));
633            }
634        };
635
636        // Attempt to decode as a Vec<SnapshotEntry>. If the data is not in
637        // that format (e.g. it is a checkpoint path), return an error rather
638        // than silently ignoring the contents.
639        let entries: Vec<SnapshotEntry> =
640            bincode::deserialize(&raw_data).map_err(|e| {
641                StorageError::InvalidSnapshot(format!(
642                    "state_data is not a valid SnapshotEntry list \
643                     (is this a RocksDB checkpoint snapshot?): {}",
644                    e
645                ))
646            })?;
647
648        let entry_count = entries.len();
649        tracing::info!(
650            "Restoring snapshot at height {} with {} entries",
651            snapshot.height.0,
652            entry_count
653        );
654
655        // Convert to WriteOps and flush in one atomic fsync'd batch.
656        let ops: Vec<WriteOp> = entries
657            .iter()
658            .map(|e| WriteOp::Put {
659                cf: e.cf.clone(),
660                key: e.key.clone(),
661                value: e.value.clone(),
662            })
663            .collect();
664
665        if !ops.is_empty() {
666            store.write_batch_sync(ops)?;
667        }
668
669        tracing::info!(
670            "Snapshot at height {} restored successfully ({} entries)",
671            snapshot.height.0,
672            entry_count
673        );
674
675        Ok(RestoredState {
676            height: snapshot.height,
677            state_root: snapshot.state_root,
678            account_count: snapshot.metadata.account_count,
679            entries_restored: entry_count as u64,
680        })
681    }
682
683    /// Validates a snapshot.
684    ///
685    /// Checks the trivial invariants (state_data non-empty, compressed_size
686    /// matches), then — when the payload decodes as a `Vec<SnapshotEntry>` —
687    /// recomputes the deterministic state root via [`compute_state_root`] and
688    /// requires it to match the snapshot's recorded `state_root`. When the
689    /// recorded root is `Hash::zero()`, the recompute check is skipped (legacy
690    /// callers that didn't set a real root still validate as long as their
691    /// payload is well-formed). Returns `false` on root mismatch so the
692    /// snapshot can be rejected before any restore attempt.
693    pub fn validate_snapshot(snapshot: &Snapshot) -> Result<bool> {
694        if snapshot.state_data.is_empty() {
695            return Ok(false);
696        }
697        if snapshot.metadata.compressed_size != snapshot.state_data.len() as u64 {
698            return Ok(false);
699        }
700
701        // If the recorded root is non-zero AND the payload decodes as
702        // SnapshotEntry list, recompute and compare.
703        if snapshot.state_root != Hash::zero() {
704            let raw = match snapshot.metadata.compression {
705                CompressionType::Gzip => match decompress_gzip(&snapshot.state_data) {
706                    Ok(d) => d,
707                    Err(_) => return Ok(true), // can't decompress => accept basic checks only
708                },
709                CompressionType::None => snapshot.state_data.clone(),
710                _ => return Ok(true),
711            };
712            if let Ok(entries) = bincode::deserialize::<Vec<SnapshotEntry>>(&raw) {
713                let recomputed = compute_state_root(&entries);
714                if recomputed != snapshot.state_root {
715                    tracing::warn!(
716                        height = snapshot.height.0,
717                        recorded = %hex::encode(snapshot.state_root.as_bytes()),
718                        recomputed = %hex::encode(recomputed.as_bytes()),
719                        "snapshot state_root mismatch"
720                    );
721                    return Ok(false);
722                }
723            }
724        }
725        Ok(true)
726    }
727}
728
729/// Result of restoring from a snapshot
730#[derive(Debug, Clone)]
731pub struct RestoredState {
732    /// The block height of the restored state
733    pub height: BlockHeight,
734    /// The state root hash
735    pub state_root: Hash,
736    /// Number of accounts in the snapshot (from metadata)
737    pub account_count: u64,
738    /// Number of key-value entries actually written back to the store
739    pub entries_restored: u64,
740}
741
742#[cfg(test)]
743mod tests {
744    use super::*;
745    use crate::kv::MemoryStore;
746
747    #[tokio::test]
748    async fn test_snapshot_creation() {
749        let kv_store = Arc::new(MemoryStore::new());
750        let manager = SnapshotManager::new(kv_store, 5);
751
752        let state_data = vec![1, 2, 3, 4, 5];
753        let snapshot = manager
754            .create_snapshot(
755                BlockHeight::new(100),
756                Hash::zero(),
757                Hash::zero(),
758                state_data,
759            )
760            .await
761            .unwrap();
762
763        assert_eq!(snapshot.height, BlockHeight::new(100));
764        assert_eq!(snapshot.metadata.state_size, 5);
765    }
766
767    #[tokio::test]
768    async fn test_snapshot_load() {
769        let kv_store = Arc::new(MemoryStore::new());
770        let manager = SnapshotManager::new(kv_store, 5);
771
772        let state_data = vec![1, 2, 3, 4, 5];
773        manager
774            .create_snapshot(
775                BlockHeight::new(100),
776                Hash::zero(),
777                Hash::zero(),
778                state_data,
779            )
780            .await
781            .unwrap();
782
783        let loaded = manager
784            .load_snapshot(BlockHeight::new(100))
785            .await
786            .unwrap();
787        assert!(loaded.is_some());
788        assert_eq!(loaded.unwrap().height, BlockHeight::new(100));
789    }
790
791    #[tokio::test]
792    async fn test_snapshot_pruning() {
793        let kv_store = Arc::new(MemoryStore::new());
794        let manager = SnapshotManager::new(kv_store, 3);
795
796        // Create 5 snapshots
797        for i in 1..=5 {
798            manager
799                .create_snapshot(
800                    BlockHeight::new(i * 100),
801                    Hash::zero(),
802                    Hash::zero(),
803                    vec![i as u8],
804                )
805                .await
806                .unwrap();
807        }
808
809        // Should only have 3 snapshots (retention policy)
810        let snapshots = manager.list_snapshots().await.unwrap();
811        assert_eq!(snapshots.len(), 3);
812
813        // Should have the latest 3
814        assert_eq!(snapshots[0], BlockHeight::new(300));
815        assert_eq!(snapshots[1], BlockHeight::new(400));
816        assert_eq!(snapshots[2], BlockHeight::new(500));
817    }
818
819    #[tokio::test]
820    async fn test_latest_snapshot() {
821        let kv_store = Arc::new(MemoryStore::new());
822        let manager = SnapshotManager::new(kv_store, 5);
823
824        manager
825            .create_snapshot(
826                BlockHeight::new(100),
827                Hash::zero(),
828                Hash::zero(),
829                vec![1],
830            )
831            .await
832            .unwrap();
833
834        manager
835            .create_snapshot(
836                BlockHeight::new(200),
837                Hash::zero(),
838                Hash::zero(),
839                vec![2],
840            )
841            .await
842            .unwrap();
843
844        let latest = manager.latest_snapshot().await.unwrap();
845        assert!(latest.is_some());
846        assert_eq!(latest.unwrap().height, BlockHeight::new(200));
847    }
848
849    #[tokio::test]
850    async fn test_snapshot_deletion() {
851        let kv_store = Arc::new(MemoryStore::new());
852        let manager = SnapshotManager::new(kv_store, 5);
853
854        manager
855            .create_snapshot(
856                BlockHeight::new(100),
857                Hash::zero(),
858                Hash::zero(),
859                vec![1],
860            )
861            .await
862            .unwrap();
863
864        manager.delete_snapshot(BlockHeight::new(100)).await.unwrap();
865
866        let loaded = manager.load_snapshot(BlockHeight::new(100)).await.unwrap();
867        assert!(loaded.is_none());
868    }
869
870    #[tokio::test]
871    async fn test_restore_from_snapshot_writes_entries() {
872        use crate::kv::{MemoryStore, CF_STATE, CF_ACCOUNTS};
873
874        let entries = vec![
875            SnapshotEntry {
876                cf: CF_STATE.to_string(),
877                key: b"balance:0xabc".to_vec(),
878                value: 1234u128.to_le_bytes().to_vec(),
879            },
880            SnapshotEntry {
881                cf: CF_ACCOUNTS.to_string(),
882                key: b"account:0xabc".to_vec(),
883                value: b"account_data".to_vec(),
884            },
885        ];
886
887        let state_data = serialize_snapshot_entries(&entries).unwrap();
888        let restore_store = Arc::new(MemoryStore::new());
889        let manager = SnapshotManager::new(restore_store.clone(), 5);
890
891        let snapshot = manager
892            .create_snapshot(
893                BlockHeight::new(42),
894                Hash::zero(),
895                Hash::zero(),
896                state_data,
897            )
898            .await
899            .unwrap();
900
901        // Restore into a fresh store
902        let target_store = Arc::new(MemoryStore::new());
903        let restored = SnapshotRestorer::restore_from_snapshot(&snapshot, target_store.as_ref())
904            .await
905            .unwrap();
906
907        assert_eq!(restored.height, BlockHeight::new(42));
908        assert_eq!(restored.entries_restored, 2);
909
910        // Verify entries are actually in the target store
911        let balance_bytes = target_store
912            .get(CF_STATE, b"balance:0xabc")
913            .unwrap()
914            .expect("balance entry should be present");
915        assert_eq!(u128::from_le_bytes(balance_bytes.try_into().unwrap()), 1234u128);
916
917        let account_bytes = target_store
918            .get(CF_ACCOUNTS, b"account:0xabc")
919            .unwrap()
920            .expect("account entry should be present");
921        assert_eq!(account_bytes, b"account_data");
922    }
923
924    #[tokio::test]
925    async fn test_restore_from_snapshot_empty_data_returns_error() {
926        let kv_store = Arc::new(MemoryStore::new());
927        let target_store = Arc::new(MemoryStore::new());
928
929        // Build a snapshot with empty state_data
930        let snapshot = Snapshot {
931            height: BlockHeight::new(1),
932            state_root: Hash::zero(),
933            block_hash: Hash::zero(),
934            timestamp: Timestamp::now(),
935            metadata: SnapshotMetadata {
936                account_count: 0,
937                state_size: 0,
938                compressed_size: 0,
939                compression: CompressionType::None,
940            },
941            state_data: vec![],
942        };
943
944        let result = SnapshotRestorer::restore_from_snapshot(&snapshot, target_store.as_ref()).await;
945        assert!(result.is_err(), "empty state_data must return an error");
946
947        // Silence unused variable warning
948        let _ = kv_store;
949    }
950
951    #[tokio::test]
952    async fn test_restore_roundtrip_via_snapshot_manager() {
953        use crate::kv::{MemoryStore, CF_STATE};
954
955        // Populate source state
956        let source_entries = (0u8..5).map(|i| SnapshotEntry {
957            cf: CF_STATE.to_string(),
958            key: format!("key:{}", i).into_bytes(),
959            value: vec![i * 10],
960        }).collect::<Vec<_>>();
961
962        let state_data = serialize_snapshot_entries(&source_entries).unwrap();
963
964        let snap_store = Arc::new(MemoryStore::new());
965        let manager = SnapshotManager::new(snap_store, 10);
966
967        let snapshot = manager
968            .create_snapshot(BlockHeight::new(77), Hash::zero(), Hash::zero(), state_data)
969            .await
970            .unwrap();
971
972        // Validate the snapshot first
973        assert!(SnapshotRestorer::validate_snapshot(&snapshot).unwrap());
974
975        // Restore into a fresh target
976        let target = Arc::new(MemoryStore::new());
977        let result = SnapshotRestorer::restore_from_snapshot(&snapshot, target.as_ref())
978            .await
979            .unwrap();
980        assert_eq!(result.entries_restored, 5);
981
982        // Confirm all entries are present
983        for i in 0u8..5 {
984            let key = format!("key:{}", i).into_bytes();
985            let val = target.get(CF_STATE, &key).unwrap().expect("entry missing");
986            assert_eq!(val, vec![i * 10]);
987        }
988    }
989
990    #[test]
991    fn compute_state_root_is_deterministic() {
992        use crate::kv::CF_STATE;
993        let entries_a = vec![
994            SnapshotEntry {
995                cf: CF_STATE.to_string(),
996                key: b"k1".to_vec(),
997                value: b"v1".to_vec(),
998            },
999            SnapshotEntry {
1000                cf: CF_STATE.to_string(),
1001                key: b"k2".to_vec(),
1002                value: b"v2".to_vec(),
1003            },
1004        ];
1005        let entries_b = vec![
1006            SnapshotEntry {
1007                cf: CF_STATE.to_string(),
1008                key: b"k2".to_vec(),
1009                value: b"v2".to_vec(),
1010            },
1011            SnapshotEntry {
1012                cf: CF_STATE.to_string(),
1013                key: b"k1".to_vec(),
1014                value: b"v1".to_vec(),
1015            },
1016        ];
1017        let root_a = compute_state_root(&entries_a);
1018        let root_b = compute_state_root(&entries_b);
1019        assert_eq!(root_a, root_b, "root must be order-independent");
1020        assert_ne!(root_a, Hash::zero(), "root must be non-zero for non-empty payload");
1021    }
1022
1023    #[test]
1024    fn compute_state_root_changes_with_value() {
1025        use crate::kv::CF_STATE;
1026        let entries_a = vec![SnapshotEntry {
1027            cf: CF_STATE.to_string(),
1028            key: b"k".to_vec(),
1029            value: b"v1".to_vec(),
1030        }];
1031        let entries_b = vec![SnapshotEntry {
1032            cf: CF_STATE.to_string(),
1033            key: b"k".to_vec(),
1034            value: b"v2".to_vec(),
1035        }];
1036        assert_ne!(
1037            compute_state_root(&entries_a),
1038            compute_state_root(&entries_b)
1039        );
1040    }
1041
1042    #[tokio::test]
1043    async fn validate_snapshot_accepts_correct_root() {
1044        use crate::kv::CF_STATE;
1045        let kv_store = Arc::new(MemoryStore::new());
1046        let manager = SnapshotManager::new(kv_store, 5);
1047        let entries = vec![
1048            SnapshotEntry {
1049                cf: CF_STATE.to_string(),
1050                key: b"alpha".to_vec(),
1051                value: b"1".to_vec(),
1052            },
1053            SnapshotEntry {
1054                cf: CF_STATE.to_string(),
1055                key: b"beta".to_vec(),
1056                value: b"2".to_vec(),
1057            },
1058        ];
1059        let real_root = compute_state_root(&entries);
1060        let payload = serialize_snapshot_entries(&entries).unwrap();
1061        let snapshot = manager
1062            .create_snapshot(BlockHeight::new(42), real_root, Hash::zero(), payload)
1063            .await
1064            .unwrap();
1065        assert!(SnapshotRestorer::validate_snapshot(&snapshot).unwrap());
1066    }
1067
1068    #[tokio::test]
1069    async fn validate_snapshot_rejects_wrong_root() {
1070        use crate::kv::CF_STATE;
1071        let kv_store = Arc::new(MemoryStore::new());
1072        let manager = SnapshotManager::new(kv_store, 5);
1073        let entries = vec![SnapshotEntry {
1074            cf: CF_STATE.to_string(),
1075            key: b"alpha".to_vec(),
1076            value: b"1".to_vec(),
1077        }];
1078        let mut wrong_root = [0u8; 32];
1079        wrong_root[0] = 0x42; // arbitrary non-zero hash
1080        let payload = serialize_snapshot_entries(&entries).unwrap();
1081        let snapshot = manager
1082            .create_snapshot(
1083                BlockHeight::new(43),
1084                Hash::new(wrong_root),
1085                Hash::zero(),
1086                payload,
1087            )
1088            .await
1089            .unwrap();
1090        assert!(!SnapshotRestorer::validate_snapshot(&snapshot).unwrap());
1091    }
1092}