silent-tweak-sdk 0.1.0

Zero-trust BIP352 Silent Payment client SDK — local scan key, verifiable tweaks, delta sync.
Documentation
//! Scan-Memo — a compact bit-vector that records which blocks have been
//! fully scanned so that recovery restarts at the right position.
//!
//! ## Format (v1)
//!
//! ```text
//! [4 bytes] magic: 0x53_4D_45_4D  ("SMEM")
//! [1 byte]  version: 0x01
//! [4 bytes] start_height (little-endian u32)
//! [4 bytes] end_height   (little-endian u32)
//! [N bytes] RoaringBitmap serialisation (absolute block heights)
//! ```
//!
//! The bitmap stores the heights of **fully scanned** blocks.
//! Heights not in the bitmap are treated as unscanned.

use roaring::RoaringBitmap;

const MAGIC: [u8; 4] = [0x53, 0x4D, 0x45, 0x4D]; // "SMEM"
const VERSION: u8 = 0x01;

/// Persistent scan memo backed by a compressed bit-vector.
#[derive(Debug, Clone)]
pub struct ScanMemo {
    /// The first block height this memo tracks.
    pub start_height: u32,
    /// The last block height this memo currently covers.
    pub end_height: u32,
    /// Roaring bitmap of fully-scanned heights.
    bitmap: RoaringBitmap,
}

impl ScanMemo {
    /// Create a new empty memo starting at `start_height`.
    #[must_use]
    pub fn new(start_height: u32) -> Self {
        Self {
            start_height,
            end_height: start_height,
            bitmap: RoaringBitmap::new(),
        }
    }

    /// Deserialise from bytes produced by [`ScanMemo::to_bytes`].
    ///
    /// # Errors
    /// Returns an error if the bytes are too short, have an incorrect magic
    /// header, an unsupported version, or a malformed bitmap.
    ///
    /// # Panics
    /// Does not panic — all slice indexing is bounds-checked before use.
    pub fn from_bytes(bytes: &[u8]) -> crate::Result<Self> {
        if bytes.len() < 13 {
            return Err(crate::Error::InvalidResponse("ScanMemo too short".into()));
        }
        if bytes[..4] != MAGIC {
            return Err(crate::Error::InvalidResponse("ScanMemo bad magic".into()));
        }
        if bytes[4] != VERSION {
            return Err(crate::Error::InvalidResponse(format!(
                "ScanMemo unsupported version {}",
                bytes[4]
            )));
        }
        let start_height = u32::from_le_bytes(bytes[5..9].try_into().unwrap());
        let end_height = u32::from_le_bytes(bytes[9..13].try_into().unwrap());
        let bitmap = RoaringBitmap::deserialize_from(&bytes[13..])
            .map_err(|e| crate::Error::InvalidResponse(format!("ScanMemo bitmap: {e}")))?;
        Ok(Self {
            start_height,
            end_height,
            bitmap,
        })
    }

    /// Serialise to bytes.
    ///
    /// # Panics
    /// Panics only if the in-memory bitmap cannot be written — this should
    /// never happen in practice.
    #[must_use]
    pub fn to_bytes(&self) -> Vec<u8> {
        let mut out = Vec::new();
        out.extend_from_slice(&MAGIC);
        out.push(VERSION);
        out.extend_from_slice(&self.start_height.to_le_bytes());
        out.extend_from_slice(&self.end_height.to_le_bytes());
        self.bitmap
            .serialize_into(&mut out)
            .expect("in-memory write");
        out
    }

    /// Mark a block height as fully scanned.
    pub fn mark_scanned(&mut self, height: u32) {
        self.bitmap.insert(height);
        if height > self.end_height {
            self.end_height = height;
        }
    }

    /// Returns `true` if this block has already been scanned.
    #[must_use]
    pub fn is_scanned(&self, height: u32) -> bool {
        self.bitmap.contains(height)
    }

    /// Return the lowest unscanned height at or above `from`.
    ///
    /// Useful for resuming an interrupted scan.
    #[must_use]
    pub fn first_unscanned_from(&self, from: u32) -> u32 {
        let mut h = from;
        while self.bitmap.contains(h) {
            h += 1;
        }
        h
    }

    /// Number of scanned blocks.
    #[must_use]
    pub fn scanned_count(&self) -> u64 {
        self.bitmap.len()
    }

    /// Percentage of blocks scanned in the range [`start_height`, `end_height`].
    #[must_use]
    #[allow(clippy::cast_precision_loss)]
    pub fn progress_pct(&self) -> f64 {
        let total = f64::from(self.end_height - self.start_height + 1);
        if total == 0.0 {
            return 100.0;
        }
        (self.scanned_count() as f64 / total) * 100.0
    }

    /// Merge another memo into this one (union of scanned sets).
    pub fn merge(&mut self, other: &ScanMemo) {
        self.bitmap |= &other.bitmap;
        if other.start_height < self.start_height {
            self.start_height = other.start_height;
        }
        if other.end_height > self.end_height {
            self.end_height = other.end_height;
        }
    }

    /// Return a list of contiguous unscanned ranges in [from, to].
    #[must_use]
    pub fn unscanned_ranges(&self, from: u32, to: u32) -> Vec<(u32, u32)> {
        let mut ranges = Vec::new();
        let mut range_start: Option<u32> = None;
        for h in from..=to {
            if !self.bitmap.contains(h) {
                if range_start.is_none() {
                    range_start = Some(h);
                }
            } else if let Some(start) = range_start.take() {
                ranges.push((start, h - 1));
            }
        }
        if let Some(start) = range_start {
            ranges.push((start, to));
        }
        ranges
    }
}

impl Default for ScanMemo {
    fn default() -> Self {
        Self::new(0)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn roundtrip_empty() {
        let memo = ScanMemo::new(840_000);
        let bytes = memo.to_bytes();
        let back = ScanMemo::from_bytes(&bytes).unwrap();
        assert_eq!(back.start_height, 840_000);
        assert_eq!(back.scanned_count(), 0);
    }

    #[test]
    fn mark_and_check() {
        let mut memo = ScanMemo::new(100);
        memo.mark_scanned(100);
        memo.mark_scanned(102);
        assert!(memo.is_scanned(100));
        assert!(!memo.is_scanned(101));
        assert!(memo.is_scanned(102));
    }

    #[test]
    fn resume_logic() {
        let mut memo = ScanMemo::new(100);
        memo.mark_scanned(100);
        memo.mark_scanned(101);
        memo.mark_scanned(102);
        assert_eq!(memo.first_unscanned_from(100), 103);
    }

    #[test]
    fn unscanned_ranges() {
        let mut memo = ScanMemo::new(100);
        memo.mark_scanned(101);
        memo.mark_scanned(103);
        let ranges = memo.unscanned_ranges(100, 105);
        assert_eq!(ranges, vec![(100, 100), (102, 102), (104, 105)]);
    }

    #[test]
    fn roundtrip_with_data() {
        let mut memo = ScanMemo::new(840_000);
        for h in 840_000..840_100 {
            memo.mark_scanned(h);
        }
        let bytes = memo.to_bytes();
        let back = ScanMemo::from_bytes(&bytes).unwrap();
        assert_eq!(back.scanned_count(), 100);
        for h in 840_000..840_100 {
            assert!(back.is_scanned(h));
        }
    }

    #[test]
    fn merge_memos() {
        let mut m1 = ScanMemo::new(100);
        m1.mark_scanned(100);
        m1.mark_scanned(101);

        let mut m2 = ScanMemo::new(102);
        m2.mark_scanned(102);
        m2.mark_scanned(103);

        m1.merge(&m2);
        assert!(m1.is_scanned(101));
        assert!(m1.is_scanned(102));
    }
}