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,
};
const CHUNK_SIZE: u32 = 1_000;
const MAX_TWEAKS_PER_REQUEST: u32 = 5_000;
#[derive(Debug, Clone)]
pub struct SilentTweakClientConfig {
pub server_url: String,
pub timeout: Duration,
pub verify_commitments: bool,
pub use_filters: bool,
pub max_concurrent: usize,
}
impl SilentTweakClientConfig {
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,
}
}
}
#[derive(Debug, Clone)]
pub struct DetectedPayment {
pub block_height: u32,
pub block_hash: String,
pub txid: String,
pub output: SilentPaymentOutput,
}
pub struct SilentTweakClient {
keys: ScanKeys,
cfg: SilentTweakClientConfig,
http: reqwest::Client,
}
impl SilentTweakClient {
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 })
}
#[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)
}
#[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
}
}
#[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)
}
async fn scan_with_filters(
&self,
from_height: u32,
to_height: u32,
) -> Result<Vec<DetectedPayment>> {
let candidate_script = self.spend_key_p2tr_script();
let mut candidate_blocks: Vec<(u32, String)> = Vec::new();
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"
);
if candidate_blocks.is_empty() {
return Ok(vec![]);
}
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)
}
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)
}
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)
}
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);
let proof_map: HashMap<&str, &crate::proto::MerkleProof> = resp
.proofs
.iter()
.map(|p| (p.block_hash.as_str(), p))
.collect();
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(())
}
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)
}
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)
}
fn spend_key_p2tr_script(&self) -> Vec<u8> {
let (xonly, _) = self.keys.spend_pubkey().x_only_public_key();
let mut script = vec![0x51, 0x20]; script.extend_from_slice(&xonly.serialize());
script
}
}
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);
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));
}
}