silent-tweak-sdk 0.1.0

Zero-trust BIP352 Silent Payment client SDK — local scan key, verifiable tweaks, delta sync.
Documentation
//! High-level zero-trust client API.
//!
//! The client:
//! 1. Downloads compact block filters to identify candidate blocks.
//! 2. Fetches delta-encoded tweaks for those blocks only.
//! 3. Locally derives Silent Payment output pubkeys (`b_scan` never leaves).
//! 4. Verifies every response against the server's Merkle commitment.

use std::collections::HashMap;
use std::time::Duration;

use tracing::{debug, info, instrument, warn};

use crate::{
    crypto::{verify_merkle_proof, SilentPaymentOutput},
    filters::BlockFilter,
    memo::ScanMemo,
    proto::{FiltersRangeResponse, ServerInfo, TweaksRangeResponse},
    Error, Result, ScanKeys,
};

/// Chunk size used for batch HTTP requests.
const CHUNK_SIZE: u32 = 1_000;
/// Maximum tweaks fetched in one request (server may enforce lower).
const MAX_TWEAKS_PER_REQUEST: u32 = 5_000;

/// Configuration for the `SilentTweakClient`.
#[derive(Debug, Clone)]
pub struct SilentTweakClientConfig {
    /// Base URL of the tweak server (e.g., `http://localhost:8080`).
    pub server_url: String,
    /// Request timeout.
    pub timeout: Duration,
    /// Whether to verify Merkle commitments. **Do not disable in production.**
    pub verify_commitments: bool,
    /// Whether to use compact filters for pre-filtering.
    pub use_filters: bool,
    /// Maximum concurrent HTTP requests.
    pub max_concurrent: usize,
}

impl SilentTweakClientConfig {
    /// Construct with defaults.
    pub fn new(server_url: impl Into<String>) -> Self {
        Self {
            server_url: server_url.into(),
            timeout: Duration::from_secs(30),
            verify_commitments: true,
            use_filters: true,
            max_concurrent: 4,
        }
    }
}

/// A payment detected by the scan.
#[derive(Debug, Clone)]
pub struct DetectedPayment {
    /// Block height.
    pub block_height: u32,
    /// Block hash (hex).
    pub block_hash: String,
    /// Transaction ID (hex).
    pub txid: String,
    /// The derived output pubkey.
    pub output: SilentPaymentOutput,
}

/// Zero-trust Silent Payment scanner.
///
/// Keeps `b_scan` entirely local.  All network communication is
/// authenticated via Merkle commitments before use.
pub struct SilentTweakClient {
    keys: ScanKeys,
    cfg: SilentTweakClientConfig,
    http: reqwest::Client,
}

impl SilentTweakClient {
    /// Create a new client.
    ///
    /// # Errors
    /// Returns an error if the underlying HTTP client cannot be built.
    pub fn new(keys: ScanKeys, cfg: SilentTweakClientConfig) -> Result<Self> {
        let http = reqwest::Client::builder()
            .timeout(cfg.timeout)
            .gzip(true)
            .user_agent("silent-tweak-sdk/0.1")
            .build()
            .map_err(Error::Network)?;
        Ok(Self { keys, cfg, http })
    }

    /// Query the server's `/info` endpoint.
    ///
    /// # Errors
    /// Returns an error on network failure or invalid JSON response.
    #[instrument(skip(self))]
    pub async fn server_info(&self) -> Result<ServerInfo> {
        let url = format!("{}/info", self.cfg.server_url);
        let resp = self.http.get(&url).send().await?;
        let info: ServerInfo = resp.json().await?;
        Ok(info)
    }

    /// Scan a block range for Silent Payment outputs.
    ///
    /// This is the primary entry-point.  Results are fully verified.
    ///
    /// # Errors
    /// Returns an error on network failure or commitment verification failure.
    #[instrument(skip(self))]
    pub async fn scan_range(
        &self,
        from_height: u32,
        to_height: u32,
    ) -> Result<Vec<DetectedPayment>> {
        info!(from_height, to_height, "starting scan");

        if self.cfg.use_filters {
            self.scan_with_filters(from_height, to_height).await
        } else {
            self.scan_all_tweaks(from_height, to_height).await
        }
    }

    /// Resume a scan from a `ScanMemo`, skipping already-processed blocks.
    ///
    /// # Errors
    /// Returns an error on network failure or commitment verification failure.
    #[instrument(skip(self, memo))]
    pub async fn resume_from_memo(
        &self,
        memo: &ScanMemo,
        to_height: u32,
    ) -> Result<Vec<DetectedPayment>> {
        let start = memo.first_unscanned_from(memo.start_height);
        info!(start, to_height, "resuming scan from memo");
        let ranges = memo.unscanned_ranges(start, to_height);
        let mut all = Vec::new();
        for (from, to) in ranges {
            let mut found = self.scan_range(from, to).await?;
            all.append(&mut found);
        }
        Ok(all)
    }

    // ── Private helpers ──────────────────────────────────────────────────

    /// Scan with compact block filter pre-filtering.
    async fn scan_with_filters(
        &self,
        from_height: u32,
        to_height: u32,
    ) -> Result<Vec<DetectedPayment>> {
        // Derive the P2TR scriptPubKey prefix for our spend key.
        let candidate_script = self.spend_key_p2tr_script();

        let mut candidate_blocks: Vec<(u32, String)> = Vec::new();

        // Fetch filters in chunks.
        let mut h = from_height;
        while h <= to_height {
            let chunk_end = (h + CHUNK_SIZE - 1).min(to_height);
            let filters = self.fetch_filters(h, chunk_end).await?;
            for f in &filters {
                if f.match_script(&candidate_script) {
                    candidate_blocks.push((f.height, hex::encode(f.block_hash)));
                    debug!(height = f.height, "filter match — candidate block");
                }
            }
            h = chunk_end + 1;
        }

        info!(
            candidates = candidate_blocks.len(),
            total_blocks = to_height - from_height + 1,
            "filter pass complete"
        );

        // Fetch tweaks only for candidate blocks.
        if candidate_blocks.is_empty() {
            return Ok(vec![]);
        }

        // Group candidates into contiguous ranges for efficient fetching.
        let ranges = contiguous_ranges(&candidate_blocks);
        let mut results = Vec::new();
        for (range_from, range_to) in ranges {
            let mut found = self.process_tweak_range(range_from, range_to).await?;
            results.append(&mut found);
        }
        Ok(results)
    }

    /// Scan all blocks without filter pre-pass.
    async fn scan_all_tweaks(
        &self,
        from_height: u32,
        to_height: u32,
    ) -> Result<Vec<DetectedPayment>> {
        let mut results = Vec::new();
        let mut h = from_height;
        while h <= to_height {
            let chunk_end = (h + MAX_TWEAKS_PER_REQUEST - 1).min(to_height);
            let mut found = self.process_tweak_range(h, chunk_end).await?;
            results.append(&mut found);
            h = chunk_end + 1;
        }
        Ok(results)
    }

    /// Fetch tweaks for a range, verify commitments, derive outputs.
    async fn process_tweak_range(&self, from: u32, to: u32) -> Result<Vec<DetectedPayment>> {
        let resp = self.fetch_tweaks(from, to).await?;

        if self.cfg.verify_commitments {
            Self::verify_tweak_response(&resp)?;
        }

        let mut found = Vec::new();
        for entry in &resp.tweaks {
            let tweak_bytes = hex::decode(&entry.tweak)
                .map_err(|_| Error::InvalidResponse("tweak not hex".into()))?;
            if tweak_bytes.len() != 32 {
                return Err(Error::InvalidResponse("tweak must be 32 bytes".into()));
            }
            let mut tweak_arr = [0u8; 32];
            tweak_arr.copy_from_slice(&tweak_bytes);

            match self.keys.derive_output(&tweak_arr) {
                Ok(output) => {
                    debug!(
                        height = entry.block_height,
                        txid = %entry.txid,
                        pubkey = %output.pubkey_hex(),
                        "derived output"
                    );
                    found.push(DetectedPayment {
                        block_height: entry.block_height,
                        block_hash: entry.block_hash.clone(),
                        txid: entry.txid.clone(),
                        output,
                    });
                }
                Err(e) => {
                    warn!("output derivation error: {e}");
                }
            }
        }
        Ok(found)
    }

    /// Verify Merkle commitments on a tweak response.
    fn verify_tweak_response(resp: &TweaksRangeResponse) -> Result<()> {
        let root_bytes = hex::decode(&resp.merkle_root)
            .map_err(|_| Error::InvalidResponse("merkle_root not hex".into()))?;
        if root_bytes.len() != 32 {
            return Err(Error::InvalidResponse(
                "merkle_root must be 32 bytes".into(),
            ));
        }
        let mut root_arr = [0u8; 32];
        root_arr.copy_from_slice(&root_bytes);

        // Build a map from block_hash → proof for quick lookup.
        let proof_map: HashMap<&str, &crate::proto::MerkleProof> = resp
            .proofs
            .iter()
            .map(|p| (p.block_hash.as_str(), p))
            .collect();

        // Each tweak entry must have a valid Merkle proof.
        for entry in &resp.tweaks {
            let proof = proof_map.get(entry.block_hash.as_str()).ok_or_else(|| {
                Error::CommitmentMismatch {
                    block_hash: entry.block_hash.clone(),
                }
            })?;

            let siblings: Result<Vec<[u8; 32]>> = proof
                .siblings
                .iter()
                .map(|s| {
                    let b = hex::decode(s)
                        .map_err(|_| Error::InvalidResponse("sibling not hex".into()))?;
                    b.as_slice()
                        .try_into()
                        .map_err(|_| Error::InvalidResponse("sibling must be 32 bytes".into()))
                })
                .collect();

            let siblings = siblings?;
            let leaf_data = format!("{}:{}", entry.block_hash, entry.tweak);
            if !verify_merkle_proof(leaf_data.as_bytes(), proof.leaf_index, &siblings, &root_arr) {
                return Err(Error::CommitmentMismatch {
                    block_hash: entry.block_hash.clone(),
                });
            }
        }
        Ok(())
    }

    /// Fetch compact filters for a block range.
    async fn fetch_filters(&self, from: u32, to: u32) -> Result<Vec<BlockFilter>> {
        let url = format!("{}/filters/range?from={from}&to={to}", self.cfg.server_url);
        let resp: FiltersRangeResponse = self.http.get(&url).send().await?.json().await?;
        let mut filters = Vec::with_capacity(resp.filters.len());
        for f in resp.filters {
            let raw = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &f.filter)
                .map_err(|_| Error::InvalidResponse("filter not valid base64".into()))?;
            let hash = hex::decode(&f.block_hash)
                .map_err(|_| Error::InvalidResponse("block_hash not hex".into()))?;
            let mut hash_arr = [0u8; 32];
            if hash.len() != 32 {
                return Err(Error::InvalidResponse("block_hash must be 32 bytes".into()));
            }
            hash_arr.copy_from_slice(&hash);
            let filter = BlockFilter::from_bytes(f.block_height, hash_arr, &raw)?;
            filters.push(filter);
        }
        Ok(filters)
    }

    /// Fetch tweaks for a block range.
    async fn fetch_tweaks(&self, from: u32, to: u32) -> Result<TweaksRangeResponse> {
        let url = format!("{}/tweaks/range?from={from}&to={to}", self.cfg.server_url);
        let resp: TweaksRangeResponse = self.http.get(&url).send().await?.json().await?;
        Ok(resp)
    }

    /// Derive the P2TR scriptPubKey for the spend key (used for filter matching).
    fn spend_key_p2tr_script(&self) -> Vec<u8> {
        // P2TR scriptPubKey: OP_1 <32-byte x-only key>
        let (xonly, _) = self.keys.spend_pubkey().x_only_public_key();
        let mut script = vec![0x51, 0x20]; // OP_1 PUSH32
        script.extend_from_slice(&xonly.serialize());
        script
    }
}

/// Group a sorted list of (height, hash) into contiguous height ranges.
fn contiguous_ranges(blocks: &[(u32, String)]) -> Vec<(u32, u32)> {
    if blocks.is_empty() {
        return vec![];
    }
    let mut ranges = Vec::new();
    let mut range_start = blocks[0].0;
    let mut prev = blocks[0].0;
    for (h, _) in &blocks[1..] {
        if *h > prev + CHUNK_SIZE {
            ranges.push((range_start, prev));
            range_start = *h;
        }
        prev = *h;
    }
    ranges.push((range_start, prev));
    ranges
}

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

    #[test]
    fn contiguous_ranges_basic() {
        let blocks = vec![
            (100u32, "a".into()),
            (101u32, "b".into()),
            (200u32, "c".into()),
        ];
        let ranges = contiguous_ranges(&blocks);
        // 100..=101 and 200..=200 should not be merged (gap > CHUNK_SIZE only matters if > 1000)
        // They're within 1000 so they merge into one range.
        assert_eq!(ranges, vec![(100, 200)]);
    }

    #[test]
    fn contiguous_ranges_split() {
        let blocks: Vec<(u32, String)> = (100u32..102)
            .chain(2000..2002)
            .map(|h| (h, h.to_string()))
            .collect();
        let ranges = contiguous_ranges(&blocks);
        assert_eq!(ranges.len(), 2);
        assert_eq!(ranges[0], (100, 101));
        assert_eq!(ranges[1], (2000, 2001));
    }
}