From d8e7eb489010c132739a1bd8b0807b7a78d4d5e1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 13 Nov 2025 20:07:53 +0700 Subject: [PATCH 01/14] feat: expose filters --- dash-spv-ffi/include/dash_spv_ffi.h | 183 +++++++++++++++++++++++ dash-spv-ffi/src/client.rs | 211 +++++++++++++++++++++++++++ dash-spv-ffi/src/types.rs | 149 +++++++++++++++++++ dash-spv/src/error.rs | 4 + dash-spv/src/storage/disk/filters.rs | 30 +++- dash-spv/src/storage/disk/state.rs | 5 + dash-spv/src/storage/memory.rs | 20 +++ dash-spv/src/storage/mod.rs | 8 + dash-spv/src/storage/sync_state.rs | 10 +- dash-spv/src/types.rs | 151 ++++++++++++++++++- dash-spv/tests/storage_test.rs | 66 +++++++++ 11 files changed, 826 insertions(+), 11 deletions(-) diff --git a/dash-spv-ffi/include/dash_spv_ffi.h b/dash-spv-ffi/include/dash_spv_ffi.h index 2e0308c9a..1acd10ecf 100644 --- a/dash-spv-ffi/include/dash_spv_ffi.h +++ b/dash-spv-ffi/include/dash_spv_ffi.h @@ -109,6 +109,48 @@ typedef struct FFISpvStats { uint64_t uptime; } FFISpvStats; +/** + * A single compact block filter with its height. + * + * # Memory Management + * + * The `data` field is heap-allocated and must be freed using + * `dash_spv_ffi_compact_filter_destroy` when no longer needed. + */ +typedef struct FFICompactFilter { + /** + * Block height for this filter + */ + uint32_t height; + /** + * Filter data bytes + */ + uint8_t *data; + /** + * Length of filter data + */ + uintptr_t data_len; +} FFICompactFilter; + +/** + * Array of compact block filters. + * + * # Memory Management + * + * Both the array itself and each filter's data must be freed using + * `dash_spv_ffi_compact_filters_destroy` when no longer needed. + */ +typedef struct FFICompactFilters { + /** + * Pointer to array of filters + */ + struct FFICompactFilter *filters; + /** + * Number of filters in the array + */ + uintptr_t count; +} FFICompactFilters; + typedef void (*BlockCallback)(uint32_t height, const uint8_t (*hash)[32], void *user_data); typedef void (*TransactionCallback)(const uint8_t (*txid)[32], @@ -171,6 +213,43 @@ typedef struct FFIWalletManager { uint8_t _private[0]; } FFIWalletManager; +/** + * A single filter match entry with height and wallet IDs. + */ +typedef struct FFIFilterMatchEntry { + /** + * Block height where filter matched + */ + uint32_t height; + /** + * Array of wallet IDs (32 bytes each) that matched at this height + */ + uint8_t (*wallet_ids)[32]; + /** + * Number of wallet IDs + */ + uintptr_t wallet_ids_count; +} FFIFilterMatchEntry; + +/** + * Array of filter match entries. + * + * # Memory Management + * + * Both the array itself and each entry's wallet_ids must be freed using + * `dash_spv_ffi_filter_matches_destroy` when no longer needed. + */ +typedef struct FFIFilterMatches { + /** + * Pointer to array of match entries + */ + struct FFIFilterMatchEntry *entries; + /** + * Number of entries in the array + */ + uintptr_t count; +} FFIFilterMatches; + /** * Handle for Core SDK that can be passed to Platform SDK */ @@ -473,6 +552,36 @@ int32_t dash_spv_ffi_client_sync_to_tip_with_progress(struct FFIDashSpvClient *c */ bool dash_spv_ffi_client_is_filter_sync_available(struct FFIDashSpvClient *client) ; +/** + * Load compact block filters in a given height range. + * + * Returns an `FFICompactFilters` struct containing all filters that exist in the range. + * Missing filters are skipped. The caller must free the result using + * `dash_spv_ffi_compact_filters_destroy`. + * + * # Parameters + * - `client`: Valid pointer to an FFIDashSpvClient + * - `start_height`: Starting block height (inclusive) + * - `end_height`: Ending block height (exclusive) + * + * # Limits + * - Maximum range size: 10,000 blocks + * - If `end_height - start_height > 10000`, an error is returned + * + * # Returns + * - Non-null pointer to FFICompactFilters on success + * - Null pointer on error (check `dash_spv_ffi_get_last_error`) + * + * # Safety + * - `client` must be a valid, non-null pointer + * - Caller must call `dash_spv_ffi_compact_filters_destroy` on the returned pointer + */ + +struct FFICompactFilters *dash_spv_ffi_client_load_filters(struct FFIDashSpvClient *client, + uint32_t start_height, + uint32_t end_height) +; + /** * Set event callbacks for the client. * @@ -571,6 +680,36 @@ int32_t dash_spv_ffi_client_enable_mempool_tracking(struct FFIDashSpvClient *cli */ void dash_spv_ffi_wallet_manager_free(struct FFIWalletManager *manager) ; +/** + * Get filter matched heights with wallet IDs in a given range. + * + * Returns an `FFIFilterMatches` struct containing all heights where filters matched + * and the wallet IDs that matched at each height. The caller must free the result using + * `dash_spv_ffi_filter_matches_destroy`. + * + * # Parameters + * - `client`: Valid pointer to an FFIDashSpvClient + * - `start_height`: Starting block height (inclusive) + * - `end_height`: Ending block height (exclusive) + * + * # Limits + * - Maximum range size: 10,000 blocks + * - If `end_height - start_height > 10000`, an error is returned + * + * # Returns + * - Non-null pointer to FFIFilterMatches on success + * - Null pointer on error (check `dash_spv_ffi_get_last_error`) + * + * # Safety + * - `client` must be a valid, non-null pointer + * - Caller must call `dash_spv_ffi_filter_matches_destroy` on the returned pointer + */ + +struct FFIFilterMatches *dash_spv_ffi_client_get_filter_matched_heights(struct FFIDashSpvClient *client, + uint32_t start_height, + uint32_t end_height) +; + struct FFIClientConfig *dash_spv_ffi_config_new(FFINetwork network) ; struct FFIClientConfig *dash_spv_ffi_config_mainnet(void) ; @@ -976,6 +1115,50 @@ void dash_spv_ffi_unconfirmed_transaction_destroy_addresses(struct FFIString *ad */ void dash_spv_ffi_unconfirmed_transaction_destroy(struct FFIUnconfirmedTransaction *tx) ; +/** + * Destroys a single compact filter. + * + * # Safety + * + * - `filter` must be a valid pointer to an FFICompactFilter + * - The pointer must not be used after this function is called + * - This function should only be called once per allocation + */ + void dash_spv_ffi_compact_filter_destroy(struct FFICompactFilter *filter) ; + +/** + * Destroys an array of compact filters. + * + * # Safety + * + * - `filters` must be a valid pointer to an FFICompactFilters struct + * - The pointer must not be used after this function is called + * - This function should only be called once per allocation + */ + void dash_spv_ffi_compact_filters_destroy(struct FFICompactFilters *filters) ; + +/** + * Destroys a single filter match entry. + * + * # Safety + * + * - `entry` must be a valid pointer to an FFIFilterMatchEntry + * - The pointer must not be used after this function is called + * - This function should only be called once per allocation + */ + void dash_spv_ffi_filter_match_entry_destroy(struct FFIFilterMatchEntry *entry) ; + +/** + * Destroys an array of filter match entries. + * + * # Safety + * + * - `matches` must be a valid pointer to an FFIFilterMatches struct + * - The pointer must not be used after this function is called + * - This function should only be called once per allocation + */ + void dash_spv_ffi_filter_matches_destroy(struct FFIFilterMatches *matches) ; + /** * Initialize logging for the SPV library. * diff --git a/dash-spv-ffi/src/client.rs b/dash-spv-ffi/src/client.rs index e39fd9c91..e65b8716d 100644 --- a/dash-spv-ffi/src/client.rs +++ b/dash-spv-ffi/src/client.rs @@ -1284,6 +1284,111 @@ pub unsafe extern "C" fn dash_spv_ffi_client_is_filter_sync_available( }) } +/// Load compact block filters in a given height range. +/// +/// Returns an `FFICompactFilters` struct containing all filters that exist in the range. +/// Missing filters are skipped. The caller must free the result using +/// `dash_spv_ffi_compact_filters_destroy`. +/// +/// # Parameters +/// - `client`: Valid pointer to an FFIDashSpvClient +/// - `start_height`: Starting block height (inclusive) +/// - `end_height`: Ending block height (exclusive) +/// +/// # Limits +/// - Maximum range size: 10,000 blocks +/// - If `end_height - start_height > 10000`, an error is returned +/// +/// # Returns +/// - Non-null pointer to FFICompactFilters on success +/// - Null pointer on error (check `dash_spv_ffi_get_last_error`) +/// +/// # Safety +/// - `client` must be a valid, non-null pointer +/// - Caller must call `dash_spv_ffi_compact_filters_destroy` on the returned pointer +#[no_mangle] +pub unsafe extern "C" fn dash_spv_ffi_client_load_filters( + client: *mut FFIDashSpvClient, + start_height: u32, + end_height: u32, +) -> *mut crate::types::FFICompactFilters { + use crate::types::{FFICompactFilter, FFICompactFilters}; + + null_check!(client, std::ptr::null_mut()); + + // Validate range size + const MAX_RANGE: u32 = 10_000; + let range_size = end_height.saturating_sub(start_height); + if range_size > MAX_RANGE { + set_last_error(&format!( + "Range size {} exceeds maximum of {} blocks", + range_size, MAX_RANGE + )); + return std::ptr::null_mut(); + } + + let client = &(*client); + let inner = client.inner.clone(); + + let result = client.runtime.block_on(async { + let spv_client = { + let mut guard = inner.lock().unwrap(); + match guard.take() { + Some(client) => client, + None => { + set_last_error("Client not initialized"); + return None; + } + } + }; + + // Get the storage + let storage = spv_client.storage(); + let storage_guard = storage.lock().await; + + // Load filters in range + let filters_result = storage_guard.load_filters(start_height..end_height).await; + + // Put the client back + let mut guard = inner.lock().unwrap(); + *guard = Some(spv_client); + + match filters_result { + Ok(filters) => { + // Convert to FFI format + let mut ffi_filters = Vec::with_capacity(filters.len()); + for (height, data) in filters { + let mut data_vec = data; + let data_ptr = data_vec.as_mut_ptr(); + let data_len = data_vec.len(); + std::mem::forget(data_vec); // Transfer ownership to FFI + + ffi_filters.push(FFICompactFilter { + height, + data: data_ptr, + data_len, + }); + } + + let filters_ptr = ffi_filters.as_mut_ptr(); + let count = ffi_filters.len(); + std::mem::forget(ffi_filters); // Transfer ownership to FFI + + Some(Box::into_raw(Box::new(FFICompactFilters { + filters: filters_ptr, + count, + }))) + } + Err(e) => { + set_last_error(&format!("Failed to load filters: {}", e)); + None + } + } + }); + + result.unwrap_or(std::ptr::null_mut()) +} + /// Set event callbacks for the client. /// /// # Safety @@ -1570,3 +1675,109 @@ pub unsafe extern "C" fn dash_spv_ffi_wallet_manager_free(manager: *mut FFIWalle key_wallet_ffi::wallet_manager::wallet_manager_free(manager as *mut KeyWalletFFIWalletManager); } + +/// Get filter matched heights with wallet IDs in a given range. +/// +/// Returns an `FFIFilterMatches` struct containing all heights where filters matched +/// and the wallet IDs that matched at each height. The caller must free the result using +/// `dash_spv_ffi_filter_matches_destroy`. +/// +/// # Parameters +/// - `client`: Valid pointer to an FFIDashSpvClient +/// - `start_height`: Starting block height (inclusive) +/// - `end_height`: Ending block height (exclusive) +/// +/// # Limits +/// - Maximum range size: 10,000 blocks +/// - If `end_height - start_height > 10000`, an error is returned +/// +/// # Returns +/// - Non-null pointer to FFIFilterMatches on success +/// - Null pointer on error (check `dash_spv_ffi_get_last_error`) +/// +/// # Safety +/// - `client` must be a valid, non-null pointer +/// - Caller must call `dash_spv_ffi_filter_matches_destroy` on the returned pointer +#[no_mangle] +pub unsafe extern "C" fn dash_spv_ffi_client_get_filter_matched_heights( + client: *mut FFIDashSpvClient, + start_height: u32, + end_height: u32, +) -> *mut crate::types::FFIFilterMatches { + use crate::types::{FFIFilterMatchEntry, FFIFilterMatches}; + + null_check!(client, std::ptr::null_mut()); + + // Validate range size + const MAX_RANGE: u32 = 10_000; + let range_size = end_height.saturating_sub(start_height); + if range_size > MAX_RANGE { + set_last_error(&format!( + "Range size {} exceeds maximum of {} blocks", + range_size, MAX_RANGE + )); + return std::ptr::null_mut(); + } + + let client = &(*client); + let inner = client.inner.clone(); + + let result = client.runtime.block_on(async { + let spv_client = { + let mut guard = inner.lock().unwrap(); + match guard.take() { + Some(client) => client, + None => { + set_last_error("Client not initialized"); + return None; + } + } + }; + + // Get the chain state + let chain_state = spv_client.chain_state().await; + + // Get filter matches in range + let matches_result = chain_state.get_filter_matched_heights(start_height..end_height); + + // Put the client back + let mut guard = inner.lock().unwrap(); + *guard = Some(spv_client); + + match matches_result { + Ok(matches) => { + // Convert BTreeMap to FFI format + let mut ffi_entries = Vec::with_capacity(matches.len()); + + for (height, wallet_ids) in matches { + // Convert Vec<[u8; 32]> to FFI format + let mut wallet_ids_vec = wallet_ids; + let wallet_ids_ptr = wallet_ids_vec.as_mut_ptr(); + let wallet_ids_count = wallet_ids_vec.len(); + std::mem::forget(wallet_ids_vec); // Transfer ownership to FFI + + ffi_entries.push(FFIFilterMatchEntry { + height, + wallet_ids: wallet_ids_ptr, + wallet_ids_count, + }); + } + + let entries_ptr = ffi_entries.as_mut_ptr(); + let count = ffi_entries.len(); + std::mem::forget(ffi_entries); // Transfer ownership to FFI + + Some(Box::into_raw(Box::new(FFIFilterMatches { + entries: entries_ptr, + count, + }))) + } + Err(e) => { + set_last_error(&format!("Failed to get filter matched heights: {}", e)); + None + } + } + }); + + result.unwrap_or(std::ptr::null_mut()) +} diff --git a/dash-spv-ffi/src/types.rs b/dash-spv-ffi/src/types.rs index c644c52da..dfaac11d2 100644 --- a/dash-spv-ffi/src/types.rs +++ b/dash-spv-ffi/src/types.rs @@ -550,3 +550,152 @@ pub unsafe extern "C" fn dash_spv_ffi_unconfirmed_transaction_destroy( // The Box will be dropped here, freeing the FFIUnconfirmedTransaction itself } } + +/// A single compact block filter with its height. +/// +/// # Memory Management +/// +/// The `data` field is heap-allocated and must be freed using +/// `dash_spv_ffi_compact_filter_destroy` when no longer needed. +#[repr(C)] +pub struct FFICompactFilter { + /// Block height for this filter + pub height: u32, + /// Filter data bytes + pub data: *mut u8, + /// Length of filter data + pub data_len: usize, +} + +/// Array of compact block filters. +/// +/// # Memory Management +/// +/// Both the array itself and each filter's data must be freed using +/// `dash_spv_ffi_compact_filters_destroy` when no longer needed. +#[repr(C)] +pub struct FFICompactFilters { + /// Pointer to array of filters + pub filters: *mut FFICompactFilter, + /// Number of filters in the array + pub count: usize, +} + +/// Destroys a single compact filter. +/// +/// # Safety +/// +/// - `filter` must be a valid pointer to an FFICompactFilter +/// - The pointer must not be used after this function is called +/// - This function should only be called once per allocation +#[no_mangle] +pub unsafe extern "C" fn dash_spv_ffi_compact_filter_destroy(filter: *mut FFICompactFilter) { + if !filter.is_null() { + let filter = Box::from_raw(filter); + if !filter.data.is_null() && filter.data_len > 0 { + drop(Vec::from_raw_parts(filter.data, filter.data_len, filter.data_len)); + } + } +} + +/// Destroys an array of compact filters. +/// +/// # Safety +/// +/// - `filters` must be a valid pointer to an FFICompactFilters struct +/// - The pointer must not be used after this function is called +/// - This function should only be called once per allocation +#[no_mangle] +pub unsafe extern "C" fn dash_spv_ffi_compact_filters_destroy(filters: *mut FFICompactFilters) { + if !filters.is_null() { + let filters = Box::from_raw(filters); + if !filters.filters.is_null() && filters.count > 0 { + // Free each filter's data + for i in 0..filters.count { + let filter = filters.filters.add(i); + if !(*filter).data.is_null() && (*filter).data_len > 0 { + drop(Vec::from_raw_parts( + (*filter).data, + (*filter).data_len, + (*filter).data_len, + )); + } + } + // Free the filters array + drop(Vec::from_raw_parts(filters.filters, filters.count, filters.count)); + } + } +} + +/// A single filter match entry with height and wallet IDs. +#[repr(C)] +pub struct FFIFilterMatchEntry { + /// Block height where filter matched + pub height: u32, + /// Array of wallet IDs (32 bytes each) that matched at this height + pub wallet_ids: *mut [u8; 32], + /// Number of wallet IDs + pub wallet_ids_count: usize, +} + +/// Array of filter match entries. +/// +/// # Memory Management +/// +/// Both the array itself and each entry's wallet_ids must be freed using +/// `dash_spv_ffi_filter_matches_destroy` when no longer needed. +#[repr(C)] +pub struct FFIFilterMatches { + /// Pointer to array of match entries + pub entries: *mut FFIFilterMatchEntry, + /// Number of entries in the array + pub count: usize, +} + +/// Destroys a single filter match entry. +/// +/// # Safety +/// +/// - `entry` must be a valid pointer to an FFIFilterMatchEntry +/// - The pointer must not be used after this function is called +/// - This function should only be called once per allocation +#[no_mangle] +pub unsafe extern "C" fn dash_spv_ffi_filter_match_entry_destroy(entry: *mut FFIFilterMatchEntry) { + if !entry.is_null() { + let entry = Box::from_raw(entry); + if !entry.wallet_ids.is_null() && entry.wallet_ids_count > 0 { + drop(Vec::from_raw_parts( + entry.wallet_ids, + entry.wallet_ids_count, + entry.wallet_ids_count, + )); + } + } +} + +/// Destroys an array of filter match entries. +/// +/// # Safety +/// +/// - `matches` must be a valid pointer to an FFIFilterMatches struct +/// - The pointer must not be used after this function is called +/// - This function should only be called once per allocation +#[no_mangle] +pub unsafe extern "C" fn dash_spv_ffi_filter_matches_destroy(matches: *mut FFIFilterMatches) { + if !matches.is_null() { + let matches = Box::from_raw(matches); + if !matches.entries.is_null() && matches.count > 0 { + for i in 0..matches.count { + let entry = matches.entries.add(i); + if !(*entry).wallet_ids.is_null() && (*entry).wallet_ids_count > 0 { + drop(Vec::from_raw_parts( + (*entry).wallet_ids, + (*entry).wallet_ids_count, + (*entry).wallet_ids_count, + )); + } + } + drop(Vec::from_raw_parts(matches.entries, matches.count, matches.count)); + } + } +} diff --git a/dash-spv/src/error.rs b/dash-spv/src/error.rs index cd8ca0879..ae56ac06f 100644 --- a/dash-spv/src/error.rs +++ b/dash-spv/src/error.rs @@ -110,6 +110,9 @@ pub enum StorageError { #[error("Lock poisoned: {0}")] LockPoisoned(String), + + #[error("Invalid input: {0}")] + InvalidInput(String), } impl Clone for StorageError { @@ -123,6 +126,7 @@ impl Clone for StorageError { StorageError::Serialization(s) => StorageError::Serialization(s.clone()), StorageError::InconsistentState(s) => StorageError::InconsistentState(s.clone()), StorageError::LockPoisoned(s) => StorageError::LockPoisoned(s.clone()), + StorageError::InvalidInput(s) => StorageError::InvalidInput(s.clone()), } } } diff --git a/dash-spv/src/storage/disk/filters.rs b/dash-spv/src/storage/disk/filters.rs index 0d6bcf457..d850e85d8 100644 --- a/dash-spv/src/storage/disk/filters.rs +++ b/dash-spv/src/storage/disk/filters.rs @@ -5,7 +5,7 @@ use std::ops::Range; use dashcore::hash_types::FilterHeader; use dashcore_hashes::Hash; -use crate::error::StorageResult; +use crate::error::{StorageError, StorageResult}; use super::manager::DiskStorageManager; use super::segments::SegmentState; @@ -200,6 +200,34 @@ impl DiskStorageManager { Ok(Some(data)) } + /// Load compact filters in a range. + /// Returns a vector of tuples (height, filter_data) for filters that exist in the range. + /// Missing filters are skipped (not included in the result). + /// + /// # Limits + /// The range must not exceed 10,000 blocks. Larger ranges will return an error. + pub async fn load_filters(&self, range: Range) -> StorageResult)>> { + const MAX_RANGE: u32 = 10_000; + + let range_size = range.end.saturating_sub(range.start); + if range_size > MAX_RANGE { + return Err(StorageError::InvalidInput(format!( + "Range size {} exceeds maximum of {} blocks", + range_size, MAX_RANGE + ))); + } + + let mut result = Vec::new(); + + for height in range { + if let Some(filter) = self.load_filter(height).await? { + result.push((height, filter)); + } + } + + Ok(result) + } + /// Clear all filter data. pub async fn clear_filters(&mut self) -> StorageResult<()> { // Stop worker to prevent concurrent writes to filter directories diff --git a/dash-spv/src/storage/disk/state.rs b/dash-spv/src/storage/disk/state.rs index 49a49ad4b..322573c13 100644 --- a/dash-spv/src/storage/disk/state.rs +++ b/dash-spv/src/storage/disk/state.rs @@ -2,6 +2,7 @@ use async_trait::async_trait; use std::collections::HashMap; +use std::ops::Range; use dashcore::{block::Header as BlockHeader, BlockHash, Txid}; #[cfg(test)] @@ -570,6 +571,10 @@ impl StorageManager for DiskStorageManager { Self::load_filter(self, height).await } + async fn load_filters(&self, range: Range) -> StorageResult)>> { + Self::load_filters(self, range).await + } + async fn store_metadata(&mut self, key: &str, value: &[u8]) -> StorageResult<()> { Self::store_metadata(self, key, value).await } diff --git a/dash-spv/src/storage/memory.rs b/dash-spv/src/storage/memory.rs index 839baf5ba..f6ce93a91 100644 --- a/dash-spv/src/storage/memory.rs +++ b/dash-spv/src/storage/memory.rs @@ -285,6 +285,26 @@ impl StorageManager for MemoryStorageManager { Ok(self.filters.get(&height).cloned()) } + async fn load_filters(&self, range: Range) -> StorageResult)>> { + const MAX_RANGE: u32 = 10_000; + + let range_size = range.end.saturating_sub(range.start); + if range_size > MAX_RANGE { + return Err(StorageError::InvalidInput(format!( + "Range size {} exceeds maximum of {} blocks", + range_size, MAX_RANGE + ))); + } + + let mut result = Vec::new(); + for height in range { + if let Some(filter) = self.filters.get(&height) { + result.push((height, filter.clone())); + } + } + Ok(result) + } + async fn store_metadata(&mut self, key: &str, value: &[u8]) -> StorageResult<()> { self.metadata.insert(key.to_string(), value.to_vec()); Ok(()) diff --git a/dash-spv/src/storage/mod.rs b/dash-spv/src/storage/mod.rs index f8d7d795b..46f7c17db 100644 --- a/dash-spv/src/storage/mod.rs +++ b/dash-spv/src/storage/mod.rs @@ -145,6 +145,14 @@ pub trait StorageManager: Send + Sync { /// Load a compact filter by blockchain height. async fn load_filter(&self, height: u32) -> StorageResult>>; + /// Load compact filters in the given blockchain height range. + /// Returns a vector of tuples (height, filter_data) for filters that exist in the range. + /// Missing filters are skipped (not included in the result). + /// + /// # Limits + /// The range must not exceed 10,000 blocks. Larger ranges will return an error. + async fn load_filters(&self, range: Range) -> StorageResult)>>; + /// Store metadata. async fn store_metadata(&mut self, key: &str, value: &[u8]) -> StorageResult<()>; diff --git a/dash-spv/src/storage/sync_state.rs b/dash-spv/src/storage/sync_state.rs index 27318a44b..422fcf903 100644 --- a/dash-spv/src/storage/sync_state.rs +++ b/dash-spv/src/storage/sync_state.rs @@ -2,6 +2,7 @@ use dashcore::{BlockHash, Network}; use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; use std::time::SystemTime; use crate::types::{ChainState, SyncProgress}; @@ -110,8 +111,9 @@ pub struct FilterSyncState { /// Number of filters downloaded. pub filters_downloaded: u64, - /// Heights where filters matched (for recovery). - pub matched_heights: Vec, + /// Filter matches: height -> Vec of wallet IDs (32-byte arrays) that matched. + /// This tracks which wallet IDs had transactions in blocks with matching compact filters. + pub filter_matches: BTreeMap>, /// Whether filter sync is available from peers. pub filter_sync_available: bool, @@ -186,7 +188,7 @@ impl PersistentSyncState { filter_header_height: sync_progress.filter_header_height, filter_height: sync_progress.last_synced_filter_height.unwrap_or(0), filters_downloaded: sync_progress.filters_downloaded, - matched_heights: chain_state.get_filter_matched_heights().unwrap_or_default(), + filter_matches: chain_state.filter_matches.clone(), filter_sync_available: sync_progress.filter_sync_available, }, saved_at: SystemTime::now(), @@ -372,7 +374,7 @@ mod tests { filter_header_height: 0, filter_height: 0, filters_downloaded: 0, - matched_heights: vec![], + filter_matches: BTreeMap::new(), filter_sync_available: false, }, saved_at: SystemTime::now(), diff --git a/dash-spv/src/types.rs b/dash-spv/src/types.rs index a4d1e70ed..96a363d7f 100644 --- a/dash-spv/src/types.rs +++ b/dash-spv/src/types.rs @@ -15,6 +15,7 @@ //! 2. stats (SpvStats) //! 3. mempool_state (MempoolState) +use std::collections::BTreeMap; use std::time::{Duration, Instant, SystemTime}; use dashcore::{ @@ -280,6 +281,10 @@ pub struct ChainState { /// Whether the chain was synced from a checkpoint rather than genesis. pub synced_from_checkpoint: bool, + + /// Filter matches: height -> Vec of wallet IDs (32-byte arrays) that matched + /// This tracks which wallet IDs had transactions in blocks with matching compact filters. + pub filter_matches: BTreeMap>, } impl ChainState { @@ -420,12 +425,58 @@ impl ChainState { self.last_chainlock_height } - /// Get filter matched heights (placeholder for now) - /// In a real implementation, this would track heights where filters matched wallet transactions - pub fn get_filter_matched_heights(&self) -> Option> { - // For now, return an empty vector as we don't track this yet - // This would typically be populated during filter sync when matches are found - Some(Vec::new()) + /// Record a filter match at a specific height for a wallet ID. + /// + /// # Parameters + /// - `height`: The blockchain height where the filter matched + /// - `wallet_id`: The 32-byte wallet identifier that matched + pub fn record_filter_match(&mut self, height: u32, wallet_id: [u8; 32]) { + self.filter_matches.entry(height).or_insert_with(Vec::new).push(wallet_id); + } + + /// Record multiple filter matches at a specific height. + /// + /// # Parameters + /// - `height`: The blockchain height where filters matched + /// - `wallet_ids`: Vec of wallet identifiers that matched + pub fn record_filter_matches(&mut self, height: u32, wallet_ids: Vec<[u8; 32]>) { + if wallet_ids.is_empty() { + return; + } + + self.filter_matches.entry(height).or_insert_with(Vec::new).extend(wallet_ids); + } + + /// Get filter matched heights with wallet IDs in a given range. + /// + /// Returns a BTreeMap of height -> Vec for all matched filters in the range. + /// The range must not exceed 10,000 blocks. + /// + /// # Parameters + /// - `range`: The height range to query (start inclusive, end exclusive) + /// + /// # Returns + /// - `Ok(BTreeMap)`: Map of heights to wallet IDs that matched + /// - `Err(String)`: Error if range exceeds 10,000 blocks + pub fn get_filter_matched_heights( + &self, + range: std::ops::Range, + ) -> Result>, String> { + const MAX_RANGE: u32 = 10_000; + + let range_size = range.end.saturating_sub(range.start); + if range_size > MAX_RANGE { + return Err(format!( + "Range size {} exceeds maximum of {} blocks", + range_size, MAX_RANGE + )); + } + + // Extract matches in the requested range + let matches: BTreeMap> = + self.filter_matches.range(range).map(|(k, v)| (*k, v.clone())).collect(); + + Ok(matches) } /// Calculate the total chain work up to the tip @@ -1104,3 +1155,91 @@ impl MempoolState { self.pending_balance + self.pending_instant_balance } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_filter_match_recording() { + let mut chain_state = ChainState::default(); + + // Record some filter matches + let wallet_id_1 = [1u8; 32]; + let wallet_id_2 = [2u8; 32]; + let wallet_id_3 = [3u8; 32]; + + chain_state.record_filter_match(100, wallet_id_1); + chain_state.record_filter_match(100, wallet_id_2); + chain_state.record_filter_match(200, wallet_id_3); + + // Verify matches were recorded + assert_eq!(chain_state.filter_matches.len(), 2); + assert_eq!(chain_state.filter_matches.get(&100).unwrap().len(), 2); + assert_eq!(chain_state.filter_matches.get(&200).unwrap().len(), 1); + } + + #[test] + fn test_filter_match_bulk_recording() { + let mut chain_state = ChainState::default(); + + let wallet_ids = vec![[1u8; 32], [2u8; 32], [3u8; 32]]; + chain_state.record_filter_matches(100, wallet_ids); + + assert_eq!(chain_state.filter_matches.get(&100).unwrap().len(), 3); + } + + #[test] + fn test_get_filter_matched_heights_in_range() { + let mut chain_state = ChainState::default(); + + // Record matches at various heights + for height in 100..150 { + chain_state.record_filter_match(height, [height as u8; 32]); + } + + // Query a range + let matches = chain_state.get_filter_matched_heights(110..120).unwrap(); + assert_eq!(matches.len(), 10); + assert!(matches.contains_key(&110)); + assert!(matches.contains_key(&119)); + assert!(!matches.contains_key(&109)); + assert!(!matches.contains_key(&120)); + } + + #[test] + fn test_get_filter_matched_heights_range_limit() { + let chain_state = ChainState::default(); + + // Test exactly 10,000 blocks (should succeed) + let result = chain_state.get_filter_matched_heights(0..10_000); + assert!(result.is_ok()); + + // Test 10,001 blocks (should fail) + let result = chain_state.get_filter_matched_heights(0..10_001); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("exceeds maximum")); + + // Test very large range (should fail) + let result = chain_state.get_filter_matched_heights(0..100_000); + assert!(result.is_err()); + } + + #[test] + fn test_get_filter_matched_heights_empty_range() { + let mut chain_state = ChainState::default(); + + // Record some matches + chain_state.record_filter_match(100, [1u8; 32]); + chain_state.record_filter_match(200, [2u8; 32]); + + // Query range with no matches + let matches = chain_state.get_filter_matched_heights(300..400).unwrap(); + assert!(matches.is_empty()); + + // Query range that partially overlaps + let matches = chain_state.get_filter_matched_heights(150..250).unwrap(); + assert_eq!(matches.len(), 1); + assert!(matches.contains_key(&200)); + } +} diff --git a/dash-spv/tests/storage_test.rs b/dash-spv/tests/storage_test.rs index 9bc45bd41..b8d19e7e2 100644 --- a/dash-spv/tests/storage_test.rs +++ b/dash-spv/tests/storage_test.rs @@ -284,3 +284,69 @@ fn create_test_filter_headers(count: usize) -> Vec Date: Fri, 14 Nov 2025 14:35:06 +0700 Subject: [PATCH 02/14] feat: expose filters --- dash-spv-ffi/src/client.rs | 37 +++++++------------ .../src/sync/sequential/message_handlers.rs | 6 +++ 2 files changed, 19 insertions(+), 24 deletions(-) diff --git a/dash-spv-ffi/src/client.rs b/dash-spv-ffi/src/client.rs index e65b8716d..cd55b65fe 100644 --- a/dash-spv-ffi/src/client.rs +++ b/dash-spv-ffi/src/client.rs @@ -1331,10 +1331,11 @@ pub unsafe extern "C" fn dash_spv_ffi_client_load_filters( let inner = client.inner.clone(); let result = client.runtime.block_on(async { - let spv_client = { - let mut guard = inner.lock().unwrap(); - match guard.take() { - Some(client) => client, + // Get storage reference without taking the client + let storage = { + let guard = inner.lock().unwrap(); + match guard.as_ref() { + Some(client) => client.storage(), None => { set_last_error("Client not initialized"); return None; @@ -1342,16 +1343,10 @@ pub unsafe extern "C" fn dash_spv_ffi_client_load_filters( } }; - // Get the storage - let storage = spv_client.storage(); + // Access storage directly - works even during sync let storage_guard = storage.lock().await; - - // Load filters in range let filters_result = storage_guard.load_filters(start_height..end_height).await; - - // Put the client back - let mut guard = inner.lock().unwrap(); - *guard = Some(spv_client); + drop(storage_guard); match filters_result { Ok(filters) => { @@ -1723,10 +1718,11 @@ pub unsafe extern "C" fn dash_spv_ffi_client_get_filter_matched_heights( let inner = client.inner.clone(); let result = client.runtime.block_on(async { - let spv_client = { - let mut guard = inner.lock().unwrap(); - match guard.take() { - Some(client) => client, + // Get chain state without taking the client + let chain_state = { + let guard = inner.lock().unwrap(); + match guard.as_ref() { + Some(client) => client.chain_state().await, None => { set_last_error("Client not initialized"); return None; @@ -1734,16 +1730,9 @@ pub unsafe extern "C" fn dash_spv_ffi_client_get_filter_matched_heights( } }; - // Get the chain state - let chain_state = spv_client.chain_state().await; - - // Get filter matches in range + // Get filter matches in range - works even during sync let matches_result = chain_state.get_filter_matched_heights(start_height..end_height); - // Put the client back - let mut guard = inner.lock().unwrap(); - *guard = Some(spv_client); - match matches_result { Ok(matches) => { // Convert BTreeMap to FFI format diff --git a/dash-spv/src/sync/sequential/message_handlers.rs b/dash-spv/src/sync/sequential/message_handlers.rs index 14ce6e63b..bf372aa64 100644 --- a/dash-spv/src/sync/sequential/message_handlers.rs +++ b/dash-spv/src/sync/sequential/message_handlers.rs @@ -604,6 +604,12 @@ impl< return Ok(()); } + // Store the filter to disk for persistence + storage + .store_filter(height, &cfilter.filter) + .await + .map_err(|e| SyncError::Storage(format!("Failed to store filter: {}", e)))?; + let matches = self .filter_sync .check_filter_for_matches( From c0fc4ca554a73be0464312c1bc82d65cd2c1f7c9 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 17 Nov 2025 15:11:35 +0700 Subject: [PATCH 03/14] more work --- dash-spv-ffi/include/dash_spv_ffi.h | 38 +++++++ dash-spv-ffi/src/client.rs | 101 ++++++++++++++++++ .../tests/unit/test_client_lifecycle.rs | 57 ++++++++++ .../tests/unit/test_type_conversions.rs | 1 + dash-spv/src/client/block_processor.rs | 14 ++- dash-spv/src/client/block_processor_test.rs | 12 +-- dash-spv/src/storage/disk/filters.rs | 60 +++++++++++ dash-spv/src/storage/disk/state.rs | 4 + dash-spv/src/storage/memory.rs | 9 ++ dash-spv/src/storage/mod.rs | 4 + dash-spv/src/sync/filters/matching.rs | 20 ++-- .../src/sync/sequential/message_handlers.rs | 35 +++++- .../src/sync/sequential/phase_execution.rs | 50 ++++++++- key-wallet-manager/src/wallet_interface.rs | 10 +- .../src/wallet_manager/process_block.rs | 51 +++++---- 15 files changed, 412 insertions(+), 54 deletions(-) diff --git a/dash-spv-ffi/include/dash_spv_ffi.h b/dash-spv-ffi/include/dash_spv_ffi.h index 1acd10ecf..920755ace 100644 --- a/dash-spv-ffi/include/dash_spv_ffi.h +++ b/dash-spv-ffi/include/dash_spv_ffi.h @@ -710,6 +710,44 @@ struct FFIFilterMatches *dash_spv_ffi_client_get_filter_matched_heights(struct F uint32_t end_height) ; +/** + * Get the total count of transactions across all wallets. + * + * This returns the persisted transaction count from the wallet, + * not the ephemeral sync statistics. Use this to show how many + * blocks contained relevant transactions for the user's wallets. + * + * # Parameters + * - `client`: Valid pointer to an FFIDashSpvClient + * + * # Returns + * - Transaction count (0 or higher) + * - Returns 0 if client not initialized or wallet not available + * + * # Safety + * - `client` must be a valid, non-null pointer + */ + uintptr_t dash_spv_ffi_client_get_transaction_count(struct FFIDashSpvClient *client) ; + +/** + * Get the count of blocks that contained relevant transactions. + * + * This counts unique block heights from the wallet's transaction history, + * representing how many blocks actually had transactions for the user's wallets. + * This is a persistent metric that survives app restarts. + * + * # Parameters + * - `client`: Valid pointer to an FFIDashSpvClient + * + * # Returns + * - Count of blocks with transactions (0 or higher) + * - Returns 0 if client not initialized or wallet not available + * + * # Safety + * - `client` must be a valid, non-null pointer + */ + uintptr_t dash_spv_ffi_client_get_blocks_with_transactions_count(struct FFIDashSpvClient *client) ; + struct FFIClientConfig *dash_spv_ffi_config_new(FFINetwork network) ; struct FFIClientConfig *dash_spv_ffi_config_mainnet(void) ; diff --git a/dash-spv-ffi/src/client.rs b/dash-spv-ffi/src/client.rs index cd55b65fe..7021c9d50 100644 --- a/dash-spv-ffi/src/client.rs +++ b/dash-spv-ffi/src/client.rs @@ -1770,3 +1770,104 @@ pub unsafe extern "C" fn dash_spv_ffi_client_get_filter_matched_heights( result.unwrap_or(std::ptr::null_mut()) } + +/// Get the total count of transactions across all wallets. +/// +/// This returns the persisted transaction count from the wallet, +/// not the ephemeral sync statistics. Use this to show how many +/// blocks contained relevant transactions for the user's wallets. +/// +/// # Parameters +/// - `client`: Valid pointer to an FFIDashSpvClient +/// +/// # Returns +/// - Transaction count (0 or higher) +/// - Returns 0 if client not initialized or wallet not available +/// +/// # Safety +/// - `client` must be a valid, non-null pointer +#[no_mangle] +pub unsafe extern "C" fn dash_spv_ffi_client_get_transaction_count( + client: *mut FFIDashSpvClient, +) -> usize { + null_check!(client, 0); + + let client = &(*client); + let inner = client.inner.clone(); + + let result = client.runtime.block_on(async { + // Get wallet without taking the client + let guard = inner.lock().unwrap(); + match guard.as_ref() { + Some(spv_client) => { + // Access wallet and get transaction count + let wallet = spv_client.wallet(); + let wallet_guard = wallet.read().await; + let tx_history = wallet_guard.transaction_history(); + tx_history.len() + } + None => { + tracing::warn!("Client not initialized when querying transaction count"); + 0 + } + } + }); + + result +} + +/// Get the count of blocks that contained relevant transactions. +/// +/// This counts unique block heights from the wallet's transaction history, +/// representing how many blocks actually had transactions for the user's wallets. +/// This is a persistent metric that survives app restarts. +/// +/// # Parameters +/// - `client`: Valid pointer to an FFIDashSpvClient +/// +/// # Returns +/// - Count of blocks with transactions (0 or higher) +/// - Returns 0 if client not initialized or wallet not available +/// +/// # Safety +/// - `client` must be a valid, non-null pointer +#[no_mangle] +pub unsafe extern "C" fn dash_spv_ffi_client_get_blocks_with_transactions_count( + client: *mut FFIDashSpvClient, +) -> usize { + null_check!(client, 0); + + let client = &(*client); + let inner = client.inner.clone(); + + let result = client.runtime.block_on(async { + // Get wallet without taking the client + let guard = inner.lock().unwrap(); + match guard.as_ref() { + Some(spv_client) => { + // Access wallet and get unique block heights + let wallet = spv_client.wallet(); + let wallet_guard = wallet.read().await; + let tx_history = wallet_guard.transaction_history(); + + // Count unique block heights (confirmed transactions only) + let mut unique_heights = std::collections::HashSet::new(); + for tx in tx_history { + if let Some(height) = tx.height { + unique_heights.insert(height); + } + } + + unique_heights.len() + } + None => { + tracing::warn!( + "Client not initialized when querying blocks with transactions count" + ); + 0 + } + } + }); + + result +} diff --git a/dash-spv-ffi/tests/unit/test_client_lifecycle.rs b/dash-spv-ffi/tests/unit/test_client_lifecycle.rs index 067c1ba6d..6b98a0bd0 100644 --- a/dash-spv-ffi/tests/unit/test_client_lifecycle.rs +++ b/dash-spv-ffi/tests/unit/test_client_lifecycle.rs @@ -241,4 +241,61 @@ mod tests { } } } + + #[test] + #[serial] + fn test_transaction_count_with_empty_wallet() { + unsafe { + let (config, _temp_dir) = create_test_config_with_dir(); + let client = dash_spv_ffi_client_new(config); + assert!(!client.is_null(), "Client creation failed"); + + // Should return 0 for a new wallet with no transactions + let tx_count = dash_spv_ffi_client_get_transaction_count(client); + assert_eq!(tx_count, 0, "Expected 0 transactions for new wallet"); + + // Cleanup + dash_spv_ffi_client_destroy(client); + dash_spv_ffi_config_destroy(config); + } + } + + #[test] + #[serial] + fn test_blocks_with_transactions_count_with_empty_wallet() { + unsafe { + let (config, _temp_dir) = create_test_config_with_dir(); + let client = dash_spv_ffi_client_new(config); + assert!(!client.is_null(), "Client creation failed"); + + // Should return 0 for a new wallet with no transactions + let block_count = dash_spv_ffi_client_get_blocks_with_transactions_count(client); + assert_eq!(block_count, 0, "Expected 0 blocks for new wallet"); + + // Cleanup + dash_spv_ffi_client_destroy(client); + dash_spv_ffi_config_destroy(config); + } + } + + #[test] + #[serial] + fn test_transaction_count_with_null_client() { + unsafe { + // Should handle null client gracefully + let tx_count = dash_spv_ffi_client_get_transaction_count(std::ptr::null_mut()); + assert_eq!(tx_count, 0, "Expected 0 for null client"); + } + } + + #[test] + #[serial] + fn test_blocks_count_with_null_client() { + unsafe { + // Should handle null client gracefully + let block_count = + dash_spv_ffi_client_get_blocks_with_transactions_count(std::ptr::null_mut()); + assert_eq!(block_count, 0, "Expected 0 for null client"); + } + } } diff --git a/dash-spv-ffi/tests/unit/test_type_conversions.rs b/dash-spv-ffi/tests/unit/test_type_conversions.rs index ee6586caa..37b1e0772 100644 --- a/dash-spv-ffi/tests/unit/test_type_conversions.rs +++ b/dash-spv-ffi/tests/unit/test_type_conversions.rs @@ -172,6 +172,7 @@ mod tests { last_masternode_diff_height: None, sync_base_height: 0, synced_from_checkpoint: false, + filter_matches: std::collections::BTreeMap::new(), }; let ffi_state = FFIChainState::from(state); diff --git a/dash-spv/src/client/block_processor.rs b/dash-spv/src/client/block_processor.rs index c582932f5..e902318de 100644 --- a/dash-spv/src/client/block_processor.rs +++ b/dash-spv/src/client/block_processor.rs @@ -177,11 +177,17 @@ impl { // Check compact filter with wallet let mut wallet = self.wallet.write().await; - let matches = + let matched_wallet_ids = wallet.check_compact_filter(&filter, &block_hash, self.network).await; - if matches { - tracing::info!("🎯 Compact filter matched for block {}", block_hash); + let has_matches = !matched_wallet_ids.is_empty(); + + if has_matches { + tracing::info!( + "🎯 Compact filter matched for block {} ({} wallet(s))", + block_hash, + matched_wallet_ids.len() + ); drop(wallet); // Emit event if filter matched let _ = self.event_tx.send(SpvEvent::CompactFilterMatched { @@ -196,7 +202,7 @@ impl bool { - // Return true for all filters in test - true + ) -> Vec<[u8; 32]> { + // Return a test wallet ID for all filters in test + vec![[1u8; 32]] } async fn describe(&self, _network: Network) -> String { @@ -291,9 +291,9 @@ mod tests { _filter: &dashcore::bip158::BlockFilter, _block_hash: &dashcore::BlockHash, _network: Network, - ) -> bool { - // Always return false - filter doesn't match - false + ) -> Vec<[u8; 32]> { + // Return empty vector - filter doesn't match + Vec::new() } async fn describe(&self, _network: Network) -> String { diff --git a/dash-spv/src/storage/disk/filters.rs b/dash-spv/src/storage/disk/filters.rs index d850e85d8..284b55212 100644 --- a/dash-spv/src/storage/disk/filters.rs +++ b/dash-spv/src/storage/disk/filters.rs @@ -182,6 +182,66 @@ impl DiskStorageManager { Ok(*self.cached_filter_tip_height.read().await) } + /// Get the highest stored compact filter height by scanning the filters directory. + /// This checks which filters are actually persisted on disk, not just filter headers. + /// + /// Returns None if no filters are stored, otherwise returns the highest height found. + /// + /// Note: This only counts individual filter files ({height}.dat), not segment files. + pub async fn get_stored_filter_height(&self) -> StorageResult> { + let filters_dir = self.base_path.join("filters"); + + // If filters directory doesn't exist, no filters are stored + if !filters_dir.exists() { + return Ok(None); + } + + let mut max_height: Option = None; + + // Read directory entries + let mut entries = tokio::fs::read_dir(&filters_dir).await?; + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + + // Skip if not a file + if !path.is_file() { + continue; + } + + // Check if it's a .dat file + if let Some(extension) = path.extension() { + if extension == "dat" { + // Extract height from filename (format: "{height}.dat") + if let Some(filename) = path.file_stem() { + if let Some(filename_str) = filename.to_str() { + // Only parse if filename is PURELY numeric (not "filter_segment_0001") + // This ensures we only count individual filter files, not segments + if filename_str.chars().all(|c| c.is_ascii_digit()) { + if let Ok(height) = filename_str.parse::() { + if height > 2_000_000 { + // Sanity check - testnet/mainnet should never exceed 2M blocks + tracing::warn!( + "Found suspiciously high filter file: {}.dat (height {}), ignoring", + filename_str, + height + ); + continue; + } + max_height = Some( + max_height.map_or(height, |current| current.max(height)), + ); + } + } + } + } + } + } + } + + Ok(max_height) + } + /// Store a compact filter. pub async fn store_filter(&mut self, height: u32, filter: &[u8]) -> StorageResult<()> { let path = self.base_path.join(format!("filters/{}.dat", height)); diff --git a/dash-spv/src/storage/disk/state.rs b/dash-spv/src/storage/disk/state.rs index 322573c13..6c1d341bb 100644 --- a/dash-spv/src/storage/disk/state.rs +++ b/dash-spv/src/storage/disk/state.rs @@ -547,6 +547,10 @@ impl StorageManager for DiskStorageManager { Self::get_filter_tip_height(self).await } + async fn get_stored_filter_height(&self) -> StorageResult> { + Self::get_stored_filter_height(self).await + } + async fn store_masternode_state(&mut self, state: &MasternodeState) -> StorageResult<()> { Self::store_masternode_state(self, state).await } diff --git a/dash-spv/src/storage/memory.rs b/dash-spv/src/storage/memory.rs index f6ce93a91..0fa67ebec 100644 --- a/dash-spv/src/storage/memory.rs +++ b/dash-spv/src/storage/memory.rs @@ -258,6 +258,15 @@ impl StorageManager for MemoryStorageManager { } } + async fn get_stored_filter_height(&self) -> StorageResult> { + // For memory storage, find the highest filter in the HashMap + if self.filters.is_empty() { + Ok(None) + } else { + Ok(self.filters.keys().max().copied()) + } + } + async fn store_masternode_state(&mut self, state: &MasternodeState) -> StorageResult<()> { self.masternode_state = Some(state.clone()); Ok(()) diff --git a/dash-spv/src/storage/mod.rs b/dash-spv/src/storage/mod.rs index 46f7c17db..476eadabe 100644 --- a/dash-spv/src/storage/mod.rs +++ b/dash-spv/src/storage/mod.rs @@ -127,6 +127,10 @@ pub trait StorageManager: Send + Sync { /// Get the current filter tip blockchain height. async fn get_filter_tip_height(&self) -> StorageResult>; + /// Get the highest stored compact filter height by checking which filters are persisted. + /// This is distinct from filter header tip - it shows which filters are actually downloaded. + async fn get_stored_filter_height(&self) -> StorageResult>; + /// Store masternode state. async fn store_masternode_state(&mut self, state: &MasternodeState) -> StorageResult<()>; diff --git a/dash-spv/src/sync/filters/matching.rs b/dash-spv/src/sync/filters/matching.rs index b1c39fc35..f627e7f46 100644 --- a/dash-spv/src/sync/filters/matching.rs +++ b/dash-spv/src/sync/filters/matching.rs @@ -51,18 +51,22 @@ impl SyncResult { + ) -> SyncResult> { // Create the BlockFilter from the raw data let filter = dashcore::bip158::BlockFilter::new(filter_data); - // Use wallet's check_compact_filter method - let matches = wallet.check_compact_filter(&filter, block_hash, network).await; - if matches { - tracing::info!("🎯 Filter match found for block {}", block_hash); - Ok(true) - } else { - Ok(false) + // Use wallet's check_compact_filter method to get matching wallet IDs + let matched_wallet_ids = wallet.check_compact_filter(&filter, block_hash, network).await; + + if !matched_wallet_ids.is_empty() { + tracing::info!( + "🎯 Filter match found for block {} ({} wallet(s) matched)", + block_hash, + matched_wallet_ids.len() + ); } + + Ok(matched_wallet_ids) } /// Check if filter matches any of the provided scripts using BIP158 GCS filter. diff --git a/dash-spv/src/sync/sequential/message_handlers.rs b/dash-spv/src/sync/sequential/message_handlers.rs index bf372aa64..ca11785a5 100644 --- a/dash-spv/src/sync/sequential/message_handlers.rs +++ b/dash-spv/src/sync/sequential/message_handlers.rs @@ -610,7 +610,7 @@ impl< .await .map_err(|e| SyncError::Storage(format!("Failed to store filter: {}", e)))?; - let matches = self + let matched_wallet_ids = self .filter_sync .check_filter_for_matches( &cfilter.filter, @@ -622,14 +622,43 @@ impl< drop(wallet); - if matches { + if !matched_wallet_ids.is_empty() { // Update filter match statistics { let mut stats = self.stats.write().await; stats.filters_matched += 1; } - tracing::info!("🎯 Filter match found! Requesting block {}", cfilter.block_hash); + // Record the filter matches in ChainState for persistence + { + // Load current chain state from storage + let mut chain_state = storage + .load_chain_state() + .await + .map_err(|e| SyncError::Storage(format!("Failed to load chain state: {}", e)))? + .unwrap_or_else(|| crate::types::ChainState::new()); + + // Record the filter matches + chain_state.record_filter_matches(height, matched_wallet_ids.clone()); + + // Save ChainState to persist the filter matches + storage.store_chain_state(&chain_state).await.map_err(|e| { + SyncError::Storage(format!("Failed to store chain state: {}", e)) + })?; + + tracing::debug!( + "✅ Recorded {} wallet ID(s) matching at height {} to ChainState", + matched_wallet_ids.len(), + height + ); + } + + tracing::info!( + "🎯 Filter match found! Requesting block {} (matched {} wallet(s))", + cfilter.block_hash, + matched_wallet_ids.len() + ); + // Request the full block let inv = Inventory::Block(cfilter.block_hash); network diff --git a/dash-spv/src/sync/sequential/phase_execution.rs b/dash-spv/src/sync/sequential/phase_execution.rs index 5150845f5..d1499c1ba 100644 --- a/dash-spv/src/sync/sequential/phase_execution.rs +++ b/dash-spv/src/sync/sequential/phase_execution.rs @@ -150,9 +150,53 @@ impl< .unwrap_or(0); if filter_header_tip > 0 { - // Download all filters for complete blockchain history - // This ensures the wallet can find transactions from any point in history - let start_height = self.header_sync.get_sync_base_height().max(1); + tracing::info!( + "🔍 Filter download check: filter_header_tip={}, sync_base_height={}", + filter_header_tip, + self.header_sync.get_sync_base_height() + ); + + // Check what filters are already stored to resume download + let stored_filter_height = + storage.get_stored_filter_height().await.map_err(|e| { + SyncError::Storage(format!("Failed to get stored filter height: {}", e)) + })?; + + tracing::info!( + "🔍 Stored filter height from disk scan: {:?}", + stored_filter_height + ); + + // Resume from the next height after the last stored filter + // If no filters are stored, start from sync_base_height or 1 + let start_height = if let Some(stored_height) = stored_filter_height { + tracing::info!( + "Found stored filters up to height {}, resuming from height {}", + stored_height, + stored_height + 1 + ); + stored_height + 1 + } else { + let base_height = self.header_sync.get_sync_base_height().max(1); + tracing::info!( + "No stored filters found, starting from height {}", + base_height + ); + base_height + }; + + // If we've already downloaded all filters, skip to next phase + if start_height > filter_header_tip { + tracing::info!( + "All filters already downloaded (stored up to {}, tip is {}), skipping to next phase", + start_height - 1, + filter_header_tip + ); + self.transition_to_next_phase(storage, network, "Filters already synced") + .await?; + return Ok(()); + } + let count = filter_header_tip - start_height + 1; tracing::info!( diff --git a/key-wallet-manager/src/wallet_interface.rs b/key-wallet-manager/src/wallet_interface.rs index 14fa2936f..94e49f992 100644 --- a/key-wallet-manager/src/wallet_interface.rs +++ b/key-wallet-manager/src/wallet_interface.rs @@ -32,14 +32,18 @@ pub trait WalletInterface: Send + Sync { network: Network, ); - /// Check if a compact filter matches any watched items - /// Returns true if the block should be downloaded + /// Check if a compact filter matches any watched items. + /// + /// Returns a vector of 32-byte wallet IDs that matched the filter. + /// An empty vector means no matches (block should not be downloaded). + /// A non-empty vector means the block should be downloaded and indicates + /// which wallet(s) had matching addresses. async fn check_compact_filter( &mut self, filter: &BlockFilter, block_hash: &dashcore::BlockHash, network: Network, - ) -> bool; + ) -> alloc::vec::Vec<[u8; 32]>; /// Return the wallet's per-transaction net change and involved addresses if known. /// Returns (net_amount, addresses) where net_amount is received - sent in satoshis. diff --git a/key-wallet-manager/src/wallet_manager/process_block.rs b/key-wallet-manager/src/wallet_manager/process_block.rs index 3151173f8..1bb417981 100644 --- a/key-wallet-manager/src/wallet_manager/process_block.rs +++ b/key-wallet-manager/src/wallet_manager/process_block.rs @@ -89,40 +89,37 @@ impl WalletInterface for WalletM filter: &BlockFilter, block_hash: &BlockHash, network: Network, - ) -> bool { - // Check if we've already evaluated this filter - if let Some(network_cache) = self.filter_matches.get(&network) { - if let Some(&matched) = network_cache.get(block_hash) { - return matched; - } - } - - // Collect all scripts we're watching - let mut script_bytes = Vec::new(); + ) -> Vec<[u8; 32]> { + let mut matched_wallet_ids = Vec::new(); - // Get all wallet addresses for this network - for info in self.wallet_infos.values() { + // Check each wallet individually to track which ones match + for (wallet_id, info) in &self.wallet_infos { let monitored = info.monitored_addresses(network); - for address in monitored { - script_bytes.push(address.script_pubkey().as_bytes().to_vec()); + + // Skip wallets with no monitored addresses for this network + if monitored.is_empty() { + continue; } - } - // If we don't watch any scripts for this network, there can be no match. - // Note: BlockFilterReader::match_any returns true for an empty query set, - // so we must guard this case explicitly to avoid false positives. - let hit = if script_bytes.is_empty() { - false - } else { - filter + // Collect script pubkeys for this specific wallet + let script_bytes: Vec> = + monitored.iter().map(|addr| addr.script_pubkey().as_bytes().to_vec()).collect(); + + // Check if this wallet's addresses match the filter + let hit = filter .match_any(block_hash, &mut script_bytes.iter().map(|s| s.as_slice())) - .unwrap_or(false) - }; + .unwrap_or(false); + + if hit { + matched_wallet_ids.push(*wallet_id); + } + } - // Cache the result - self.filter_matches.entry(network).or_default().insert(*block_hash, hit); + // Cache the result (true if any wallet matched) + let any_match = !matched_wallet_ids.is_empty(); + self.filter_matches.entry(network).or_default().insert(*block_hash, any_match); - hit + matched_wallet_ids } async fn transaction_effect( From ec771fdcfb6c985b80a1ab1902052c5c25a788d5 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 26 Nov 2025 03:24:21 +0700 Subject: [PATCH 04/14] work on filters --- dash-spv-ffi/include/dash_spv_ffi.h | 12 +-- key-wallet-ffi/include/key_wallet_ffi.h | 118 ++++++++++++------------ 2 files changed, 65 insertions(+), 65 deletions(-) diff --git a/dash-spv-ffi/include/dash_spv_ffi.h b/dash-spv-ffi/include/dash_spv_ffi.h index 920755ace..a90fbac58 100644 --- a/dash-spv-ffi/include/dash_spv_ffi.h +++ b/dash-spv-ffi/include/dash_spv_ffi.h @@ -3,7 +3,7 @@ #ifndef DASH_SPV_FFI_H #define DASH_SPV_FFI_H -/* Generated with cbindgen:0.29.0 */ +/* Generated with cbindgen:0.29.2 */ /* Warning: This file is auto-generated by cbindgen. Do not modify manually. */ @@ -16,11 +16,6 @@ namespace dash_spv_ffi { #endif // __cplusplus -typedef enum FFIMempoolStrategy { - FetchAll = 0, - BloomFilter = 1, -} FFIMempoolStrategy; - typedef enum FFISyncStage { Connecting = 0, QueryingHeight = 1, @@ -34,6 +29,11 @@ typedef enum FFISyncStage { Failed = 9, } FFISyncStage; +typedef enum FFIMempoolStrategy { + FetchAll = 0, + BloomFilter = 1, +} FFIMempoolStrategy; + typedef enum DashSpvValidationMode { None = 0, Basic = 1, diff --git a/key-wallet-ffi/include/key_wallet_ffi.h b/key-wallet-ffi/include/key_wallet_ffi.h index 8dfba760e..f46cf8e61 100644 --- a/key-wallet-ffi/include/key_wallet_ffi.h +++ b/key-wallet-ffi/include/key_wallet_ffi.h @@ -11,7 +11,7 @@ #ifndef KEY_WALLET_FFI_H #define KEY_WALLET_FFI_H -/* Generated with cbindgen:0.29.0 */ +/* Generated with cbindgen:0.29.2 */ /* Warning: This file is auto-generated by cbindgen. Do not modify manually. */ @@ -25,30 +25,14 @@ #include /* - FFI Account Creation Option Type + FFI Network type (single network) */ typedef enum { - /* - Create default accounts (BIP44 account 0, CoinJoin account 0, and special accounts) - */ - DEFAULT = 0, - /* - Create all specified accounts plus all special purpose accounts - */ - ALL_ACCOUNTS = 1, - /* - Create only BIP44 accounts (no CoinJoin or special accounts) - */ - BIP44_ACCOUNTS_ONLY = 2, - /* - Create specific accounts with full control - */ - SPECIFIC_ACCOUNTS = 3, - /* - Create no accounts at all - */ - NO_ACCOUNTS = 4, -} FFIAccountCreationOptionType; + DASH = 0, + TESTNET = 1, + REGTEST = 2, + DEVNET = 3, +} FFINetwork; /* Account type enumeration matching all key_wallet AccountType variants @@ -111,22 +95,16 @@ typedef enum { } FFIAccountType; /* - Address pool type + FFI Network type (bit flags for multiple networks) */ typedef enum { - /* - External (receive) addresses - */ - EXTERNAL = 0, - /* - Internal (change) addresses - */ - INTERNAL = 1, - /* - Single pool (for non-standard accounts) - */ - SINGLE = 2, -} FFIAddressPoolType; + NO_NETWORKS = 0, + DASH_FLAG = 1, + TESTNET_FLAG = 2, + REGTEST_FLAG = 4, + DEVNET_FLAG = 8, + ALL_NETWORKS = 15, +} FFINetworks; /* FFI Error code @@ -147,6 +125,24 @@ typedef enum { INTERNAL_ERROR = 12, } FFIErrorCode; +/* + Address pool type + */ +typedef enum { + /* + External (receive) addresses + */ + EXTERNAL = 0, + /* + Internal (change) addresses + */ + INTERNAL = 1, + /* + Single pool (for non-standard accounts) + */ + SINGLE = 2, +} FFIAddressPoolType; + /* Language enumeration for mnemonic generation @@ -167,28 +163,6 @@ typedef enum { SPANISH = 9, } FFILanguage; -/* - FFI Network type (single network) - */ -typedef enum { - DASH = 0, - TESTNET = 1, - REGTEST = 2, - DEVNET = 3, -} FFINetwork; - -/* - FFI Network type (bit flags for multiple networks) - */ -typedef enum { - NO_NETWORKS = 0, - DASH_FLAG = 1, - TESTNET_FLAG = 2, - REGTEST_FLAG = 4, - DEVNET_FLAG = 8, - ALL_NETWORKS = 15, -} FFINetworks; - /* FFI-compatible transaction context */ @@ -207,6 +181,32 @@ typedef enum { IN_CHAIN_LOCKED_BLOCK = 2, } FFITransactionContext; +/* + FFI Account Creation Option Type + */ +typedef enum { + /* + Create default accounts (BIP44 account 0, CoinJoin account 0, and special accounts) + */ + DEFAULT = 0, + /* + Create all specified accounts plus all special purpose accounts + */ + ALL_ACCOUNTS = 1, + /* + Create only BIP44 accounts (no CoinJoin or special accounts) + */ + BIP44_ACCOUNTS_ONLY = 2, + /* + Create specific accounts with full control + */ + SPECIFIC_ACCOUNTS = 3, + /* + Create no accounts at all + */ + NO_ACCOUNTS = 4, +} FFIAccountCreationOptionType; + /* Opaque account handle */ From a973986ddd1085529950a95a9643b28fdbe3b9d4 Mon Sep 17 00:00:00 2001 From: Borja Castellano Date: Wed, 3 Dec 2025 18:36:12 +0000 Subject: [PATCH 05/14] removed FilterSyncState.filter_matches field --- dash-spv/src/storage/sync_state.rs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/dash-spv/src/storage/sync_state.rs b/dash-spv/src/storage/sync_state.rs index 422fcf903..6f5aa143c 100644 --- a/dash-spv/src/storage/sync_state.rs +++ b/dash-spv/src/storage/sync_state.rs @@ -2,7 +2,6 @@ use dashcore::{BlockHash, Network}; use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; use std::time::SystemTime; use crate::types::{ChainState, SyncProgress}; @@ -111,10 +110,6 @@ pub struct FilterSyncState { /// Number of filters downloaded. pub filters_downloaded: u64, - /// Filter matches: height -> Vec of wallet IDs (32-byte arrays) that matched. - /// This tracks which wallet IDs had transactions in blocks with matching compact filters. - pub filter_matches: BTreeMap>, - /// Whether filter sync is available from peers. pub filter_sync_available: bool, } @@ -188,7 +183,6 @@ impl PersistentSyncState { filter_header_height: sync_progress.filter_header_height, filter_height: sync_progress.last_synced_filter_height.unwrap_or(0), filters_downloaded: sync_progress.filters_downloaded, - filter_matches: chain_state.filter_matches.clone(), filter_sync_available: sync_progress.filter_sync_available, }, saved_at: SystemTime::now(), @@ -374,7 +368,6 @@ mod tests { filter_header_height: 0, filter_height: 0, filters_downloaded: 0, - filter_matches: BTreeMap::new(), filter_sync_available: false, }, saved_at: SystemTime::now(), From 18174e887752618ea5e299155ab2644b11786c8d Mon Sep 17 00:00:00 2001 From: Borja Castellano Date: Wed, 3 Dec 2025 18:49:59 +0000 Subject: [PATCH 06/14] removed StorageManager::get_stored_filter_height method and rolled back SyncPhase::DownloadingFilters phase execution logic --- dash-spv/src/storage/disk/state.rs | 4 -- dash-spv/src/storage/memory.rs | 9 ---- dash-spv/src/storage/mod.rs | 4 -- .../src/sync/sequential/phase_execution.rs | 49 ++----------------- 4 files changed, 3 insertions(+), 63 deletions(-) diff --git a/dash-spv/src/storage/disk/state.rs b/dash-spv/src/storage/disk/state.rs index 6c1d341bb..322573c13 100644 --- a/dash-spv/src/storage/disk/state.rs +++ b/dash-spv/src/storage/disk/state.rs @@ -547,10 +547,6 @@ impl StorageManager for DiskStorageManager { Self::get_filter_tip_height(self).await } - async fn get_stored_filter_height(&self) -> StorageResult> { - Self::get_stored_filter_height(self).await - } - async fn store_masternode_state(&mut self, state: &MasternodeState) -> StorageResult<()> { Self::store_masternode_state(self, state).await } diff --git a/dash-spv/src/storage/memory.rs b/dash-spv/src/storage/memory.rs index 0fa67ebec..f6ce93a91 100644 --- a/dash-spv/src/storage/memory.rs +++ b/dash-spv/src/storage/memory.rs @@ -258,15 +258,6 @@ impl StorageManager for MemoryStorageManager { } } - async fn get_stored_filter_height(&self) -> StorageResult> { - // For memory storage, find the highest filter in the HashMap - if self.filters.is_empty() { - Ok(None) - } else { - Ok(self.filters.keys().max().copied()) - } - } - async fn store_masternode_state(&mut self, state: &MasternodeState) -> StorageResult<()> { self.masternode_state = Some(state.clone()); Ok(()) diff --git a/dash-spv/src/storage/mod.rs b/dash-spv/src/storage/mod.rs index 476eadabe..46f7c17db 100644 --- a/dash-spv/src/storage/mod.rs +++ b/dash-spv/src/storage/mod.rs @@ -127,10 +127,6 @@ pub trait StorageManager: Send + Sync { /// Get the current filter tip blockchain height. async fn get_filter_tip_height(&self) -> StorageResult>; - /// Get the highest stored compact filter height by checking which filters are persisted. - /// This is distinct from filter header tip - it shows which filters are actually downloaded. - async fn get_stored_filter_height(&self) -> StorageResult>; - /// Store masternode state. async fn store_masternode_state(&mut self, state: &MasternodeState) -> StorageResult<()>; diff --git a/dash-spv/src/sync/sequential/phase_execution.rs b/dash-spv/src/sync/sequential/phase_execution.rs index d1499c1ba..f397d60aa 100644 --- a/dash-spv/src/sync/sequential/phase_execution.rs +++ b/dash-spv/src/sync/sequential/phase_execution.rs @@ -150,52 +150,9 @@ impl< .unwrap_or(0); if filter_header_tip > 0 { - tracing::info!( - "🔍 Filter download check: filter_header_tip={}, sync_base_height={}", - filter_header_tip, - self.header_sync.get_sync_base_height() - ); - - // Check what filters are already stored to resume download - let stored_filter_height = - storage.get_stored_filter_height().await.map_err(|e| { - SyncError::Storage(format!("Failed to get stored filter height: {}", e)) - })?; - - tracing::info!( - "🔍 Stored filter height from disk scan: {:?}", - stored_filter_height - ); - - // Resume from the next height after the last stored filter - // If no filters are stored, start from sync_base_height or 1 - let start_height = if let Some(stored_height) = stored_filter_height { - tracing::info!( - "Found stored filters up to height {}, resuming from height {}", - stored_height, - stored_height + 1 - ); - stored_height + 1 - } else { - let base_height = self.header_sync.get_sync_base_height().max(1); - tracing::info!( - "No stored filters found, starting from height {}", - base_height - ); - base_height - }; - - // If we've already downloaded all filters, skip to next phase - if start_height > filter_header_tip { - tracing::info!( - "All filters already downloaded (stored up to {}, tip is {}), skipping to next phase", - start_height - 1, - filter_header_tip - ); - self.transition_to_next_phase(storage, network, "Filters already synced") - .await?; - return Ok(()); - } + // Download all filters for complete blockchain history + // This ensures the wallet can find transactions from any point in history + let start_height = self.header_sync.get_sync_base_height().max(1); let count = filter_header_tip - start_height + 1; From 7f9124422e4913456f7e895a84883b6257164cb8 Mon Sep 17 00:00:00 2001 From: Borja Castellano Date: Wed, 3 Dec 2025 19:11:12 +0000 Subject: [PATCH 07/14] ChainState::record_filter_match removed and ChainState::record_filter_matches optimized --- dash-spv/src/types.rs | 42 ++++++++++-------------------------------- 1 file changed, 10 insertions(+), 32 deletions(-) diff --git a/dash-spv/src/types.rs b/dash-spv/src/types.rs index 96a363d7f..fdf8069b4 100644 --- a/dash-spv/src/types.rs +++ b/dash-spv/src/types.rs @@ -425,15 +425,6 @@ impl ChainState { self.last_chainlock_height } - /// Record a filter match at a specific height for a wallet ID. - /// - /// # Parameters - /// - `height`: The blockchain height where the filter matched - /// - `wallet_id`: The 32-byte wallet identifier that matched - pub fn record_filter_match(&mut self, height: u32, wallet_id: [u8; 32]) { - self.filter_matches.entry(height).or_insert_with(Vec::new).push(wallet_id); - } - /// Record multiple filter matches at a specific height. /// /// # Parameters @@ -444,7 +435,11 @@ impl ChainState { return; } - self.filter_matches.entry(height).or_insert_with(Vec::new).extend(wallet_ids); + if let Some(ids) = self.filter_matches.get_mut(&height) { + ids.extend(wallet_ids); + } else { + self.filter_matches.insert(height, wallet_ids); + } } /// Get filter matched heights with wallet IDs in a given range. @@ -1158,26 +1153,9 @@ impl MempoolState { #[cfg(test)] mod tests { - use super::*; + use std::vec; - #[test] - fn test_filter_match_recording() { - let mut chain_state = ChainState::default(); - - // Record some filter matches - let wallet_id_1 = [1u8; 32]; - let wallet_id_2 = [2u8; 32]; - let wallet_id_3 = [3u8; 32]; - - chain_state.record_filter_match(100, wallet_id_1); - chain_state.record_filter_match(100, wallet_id_2); - chain_state.record_filter_match(200, wallet_id_3); - - // Verify matches were recorded - assert_eq!(chain_state.filter_matches.len(), 2); - assert_eq!(chain_state.filter_matches.get(&100).unwrap().len(), 2); - assert_eq!(chain_state.filter_matches.get(&200).unwrap().len(), 1); - } + use super::*; #[test] fn test_filter_match_bulk_recording() { @@ -1195,7 +1173,7 @@ mod tests { // Record matches at various heights for height in 100..150 { - chain_state.record_filter_match(height, [height as u8; 32]); + chain_state.record_filter_matches(height, vec![[height as u8; 32]]); } // Query a range @@ -1230,8 +1208,8 @@ mod tests { let mut chain_state = ChainState::default(); // Record some matches - chain_state.record_filter_match(100, [1u8; 32]); - chain_state.record_filter_match(200, [2u8; 32]); + chain_state.record_filter_matches(100, vec![[1u8; 32]]); + chain_state.record_filter_matches(200, vec![[2u8; 32]]); // Query range with no matches let matches = chain_state.get_filter_matched_heights(300..400).unwrap(); From 0c985986117fb62174e261cee3c8223dea37d995 Mon Sep 17 00:00:00 2001 From: Borja Castellano Date: Wed, 3 Dec 2025 19:19:05 +0000 Subject: [PATCH 08/14] removed unnecesary wallet ids vector clon in SequentialSyncManager::handle_cfilter_message --- dash-spv/src/sync/sequential/message_handlers.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/dash-spv/src/sync/sequential/message_handlers.rs b/dash-spv/src/sync/sequential/message_handlers.rs index ca11785a5..ddf130dba 100644 --- a/dash-spv/src/sync/sequential/message_handlers.rs +++ b/dash-spv/src/sync/sequential/message_handlers.rs @@ -622,7 +622,9 @@ impl< drop(wallet); - if !matched_wallet_ids.is_empty() { + let matched_wallet_ids_len = matched_wallet_ids.len(); + + if matched_wallet_ids_len > 0 { // Update filter match statistics { let mut stats = self.stats.write().await; @@ -639,7 +641,7 @@ impl< .unwrap_or_else(|| crate::types::ChainState::new()); // Record the filter matches - chain_state.record_filter_matches(height, matched_wallet_ids.clone()); + chain_state.record_filter_matches(height, matched_wallet_ids); // Save ChainState to persist the filter matches storage.store_chain_state(&chain_state).await.map_err(|e| { @@ -648,7 +650,7 @@ impl< tracing::debug!( "✅ Recorded {} wallet ID(s) matching at height {} to ChainState", - matched_wallet_ids.len(), + matched_wallet_ids_len, height ); } @@ -656,7 +658,7 @@ impl< tracing::info!( "🎯 Filter match found! Requesting block {} (matched {} wallet(s))", cfilter.block_hash, - matched_wallet_ids.len() + matched_wallet_ids_len ); // Request the full block From e14011a1a238731a232eb48300324759f26b0515 Mon Sep 17 00:00:00 2001 From: Borja Castellano Date: Wed, 3 Dec 2025 20:01:14 +0000 Subject: [PATCH 09/14] updated WalletManager::filter_matches to cache matched_wallet_ids --- key-wallet-manager/src/wallet_manager/mod.rs | 2 +- .../src/wallet_manager/process_block.rs | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/key-wallet-manager/src/wallet_manager/mod.rs b/key-wallet-manager/src/wallet_manager/mod.rs index d0d00a157..fed21de56 100644 --- a/key-wallet-manager/src/wallet_manager/mod.rs +++ b/key-wallet-manager/src/wallet_manager/mod.rs @@ -90,7 +90,7 @@ pub struct WalletManager { network_states: BTreeMap, /// Filter match cache (per network) - caches whether a filter matched /// This is used for SPV operations to avoid rechecking filters - filter_matches: BTreeMap>, + filter_matches: BTreeMap>>, } impl Default for WalletManager diff --git a/key-wallet-manager/src/wallet_manager/process_block.rs b/key-wallet-manager/src/wallet_manager/process_block.rs index 1bb417981..ccc579bef 100644 --- a/key-wallet-manager/src/wallet_manager/process_block.rs +++ b/key-wallet-manager/src/wallet_manager/process_block.rs @@ -90,6 +90,13 @@ impl WalletInterface for WalletM block_hash: &BlockHash, network: Network, ) -> Vec<[u8; 32]> { + // Check if we've already evaluated this filter + if let Some(network_cache) = self.filter_matches.get(&network) { + if let Some(matched) = network_cache.get(block_hash) { + return matched.clone(); + } + } + let mut matched_wallet_ids = Vec::new(); // Check each wallet individually to track which ones match @@ -115,9 +122,11 @@ impl WalletInterface for WalletM } } - // Cache the result (true if any wallet matched) - let any_match = !matched_wallet_ids.is_empty(); - self.filter_matches.entry(network).or_default().insert(*block_hash, any_match); + // Cache the result + self.filter_matches + .entry(network) + .or_default() + .insert(*block_hash, matched_wallet_ids.clone()); matched_wallet_ids } From 14633558a7cd3e724d92beb533b43953cc1f072e Mon Sep 17 00:00:00 2001 From: Borja Castellano Date: Wed, 3 Dec 2025 20:42:53 +0000 Subject: [PATCH 10/14] updated dash-spv-ffi/FFI_API.md docs --- dash-spv-ffi/FFI_API.md | 142 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 139 insertions(+), 3 deletions(-) diff --git a/dash-spv-ffi/FFI_API.md b/dash-spv-ffi/FFI_API.md index fc76d4e22..2f363969e 100644 --- a/dash-spv-ffi/FFI_API.md +++ b/dash-spv-ffi/FFI_API.md @@ -4,7 +4,7 @@ This document provides a comprehensive reference for all FFI (Foreign Function I **Auto-generated**: This documentation is automatically generated from the source code. Do not edit manually. -**Total Functions**: 71 +**Total Functions**: 79 ## Table of Contents @@ -91,11 +91,13 @@ Functions: 1 ### Transaction Management -Functions: 3 +Functions: 5 | Function | Description | Module | |----------|-------------|--------| | `dash_spv_ffi_client_broadcast_transaction` | No description | broadcast | +| `dash_spv_ffi_client_get_blocks_with_transactions_count` | Get the count of blocks that contained relevant transactions | client | +| `dash_spv_ffi_client_get_transaction_count` | Get the total count of transactions across all wallets | client | | `dash_spv_ffi_unconfirmed_transaction_destroy` | Destroys an FFIUnconfirmedTransaction and all its associated resources # Saf... | types | | `dash_spv_ffi_unconfirmed_transaction_destroy_raw_tx` | Destroys the raw transaction bytes allocated for an FFIUnconfirmedTransaction... | types | @@ -138,7 +140,7 @@ Functions: 2 ### Utility Functions -Functions: 19 +Functions: 25 | Function | Description | Module | |----------|-------------|--------| @@ -148,13 +150,19 @@ Functions: 19 | `dash_spv_ffi_checkpoint_latest` | Get the latest checkpoint for the given network | checkpoints | | `dash_spv_ffi_checkpoints_between_heights` | Get all checkpoints between two heights (inclusive) | checkpoints | | `dash_spv_ffi_client_clear_storage` | Clear all persisted SPV storage (headers, filters, metadata, sync state) | client | +| `dash_spv_ffi_client_get_filter_matched_heights` | Get filter matched heights with wallet IDs in a given range | client | | `dash_spv_ffi_client_get_stats` | Get current runtime statistics for the SPV client | client | | `dash_spv_ffi_client_get_tip_hash` | Get the current chain tip hash (32 bytes) if available | client | | `dash_spv_ffi_client_get_tip_height` | Get the current chain tip height (absolute) | client | | `dash_spv_ffi_client_get_wallet_manager` | Get the wallet manager from the SPV client Returns a pointer to an `FFIWalle... | client | +| `dash_spv_ffi_client_load_filters` | Load compact block filters in a given height range | client | | `dash_spv_ffi_client_record_send` | Record that we attempted to send a transaction by its txid | client | | `dash_spv_ffi_client_rescan_blockchain` | Request a rescan of the blockchain from a given height (not yet implemented) | client | +| `dash_spv_ffi_compact_filter_destroy` | Destroys a single compact filter | types | +| `dash_spv_ffi_compact_filters_destroy` | Destroys an array of compact filters | types | | `dash_spv_ffi_enable_test_mode` | No description | utils | +| `dash_spv_ffi_filter_match_entry_destroy` | Destroys a single filter match entry | types | +| `dash_spv_ffi_filter_matches_destroy` | Destroys an array of filter match entries | types | | `dash_spv_ffi_init_logging` | Initialize logging for the SPV library | utils | | `dash_spv_ffi_spv_stats_destroy` | Destroy an `FFISpvStats` object returned by this crate | client | | `dash_spv_ffi_string_array_destroy` | Destroy an array of FFIString pointers (Vec<*mut FFIString>) and their contents | types | @@ -806,6 +814,38 @@ dash_spv_ffi_client_broadcast_transaction(client: *mut FFIDashSpvClient, tx_hex: --- +#### `dash_spv_ffi_client_get_blocks_with_transactions_count` + +```c +dash_spv_ffi_client_get_blocks_with_transactions_count(client: *mut FFIDashSpvClient,) -> usize +``` + +**Description:** +Get the count of blocks that contained relevant transactions. This counts unique block heights from the wallet's transaction history, representing how many blocks actually had transactions for the user's wallets. This is a persistent metric that survives app restarts. # Parameters - `client`: Valid pointer to an FFIDashSpvClient # Returns - Count of blocks with transactions (0 or higher) - Returns 0 if client not initialized or wallet not available # Safety - `client` must be a valid, non-null pointer + +**Safety:** +- `client` must be a valid, non-null pointer + +**Module:** `client` + +--- + +#### `dash_spv_ffi_client_get_transaction_count` + +```c +dash_spv_ffi_client_get_transaction_count(client: *mut FFIDashSpvClient,) -> usize +``` + +**Description:** +Get the total count of transactions across all wallets. This returns the persisted transaction count from the wallet, not the ephemeral sync statistics. Use this to show how many blocks contained relevant transactions for the user's wallets. # Parameters - `client`: Valid pointer to an FFIDashSpvClient # Returns - Transaction count (0 or higher) - Returns 0 if client not initialized or wallet not available # Safety - `client` must be a valid, non-null pointer + +**Safety:** +- `client` must be a valid, non-null pointer + +**Module:** `client` + +--- + #### `dash_spv_ffi_unconfirmed_transaction_destroy` ```c @@ -1067,6 +1107,22 @@ Clear all persisted SPV storage (headers, filters, metadata, sync state). # Saf --- +#### `dash_spv_ffi_client_get_filter_matched_heights` + +```c +dash_spv_ffi_client_get_filter_matched_heights(client: *mut FFIDashSpvClient, start_height: u32, end_height: u32,) -> *mut crate::types::FFIFilterMatches +``` + +**Description:** +Get filter matched heights with wallet IDs in a given range. Returns an `FFIFilterMatches` struct containing all heights where filters matched and the wallet IDs that matched at each height. The caller must free the result using `dash_spv_ffi_filter_matches_destroy`. # Parameters - `client`: Valid pointer to an FFIDashSpvClient - `start_height`: Starting block height (inclusive) - `end_height`: Ending block height (exclusive) # Limits - Maximum range size: 10,000 blocks - If `end_height - start_height > 10000`, an error is returned # Returns - Non-null pointer to FFIFilterMatches on success - Null pointer on error (check `dash_spv_ffi_get_last_error`) # Safety - `client` must be a valid, non-null pointer - Caller must call `dash_spv_ffi_filter_matches_destroy` on the returned pointer + +**Safety:** +- `client` must be a valid, non-null pointer - Caller must call `dash_spv_ffi_filter_matches_destroy` on the returned pointer + +**Module:** `client` + +--- + #### `dash_spv_ffi_client_get_stats` ```c @@ -1131,6 +1187,22 @@ The caller must ensure that: - The client pointer is valid - The returned pointe --- +#### `dash_spv_ffi_client_load_filters` + +```c +dash_spv_ffi_client_load_filters(client: *mut FFIDashSpvClient, start_height: u32, end_height: u32,) -> *mut crate::types::FFICompactFilters +``` + +**Description:** +Load compact block filters in a given height range. Returns an `FFICompactFilters` struct containing all filters that exist in the range. Missing filters are skipped. The caller must free the result using `dash_spv_ffi_compact_filters_destroy`. # Parameters - `client`: Valid pointer to an FFIDashSpvClient - `start_height`: Starting block height (inclusive) - `end_height`: Ending block height (exclusive) # Limits - Maximum range size: 10,000 blocks - If `end_height - start_height > 10000`, an error is returned # Returns - Non-null pointer to FFICompactFilters on success - Null pointer on error (check `dash_spv_ffi_get_last_error`) # Safety - `client` must be a valid, non-null pointer - Caller must call `dash_spv_ffi_compact_filters_destroy` on the returned pointer + +**Safety:** +- `client` must be a valid, non-null pointer - Caller must call `dash_spv_ffi_compact_filters_destroy` on the returned pointer + +**Module:** `client` + +--- + #### `dash_spv_ffi_client_record_send` ```c @@ -1163,6 +1235,38 @@ Request a rescan of the blockchain from a given height (not yet implemented). # --- +#### `dash_spv_ffi_compact_filter_destroy` + +```c +dash_spv_ffi_compact_filter_destroy(filter: *mut FFICompactFilter) -> () +``` + +**Description:** +Destroys a single compact filter. # Safety - `filter` must be a valid pointer to an FFICompactFilter - The pointer must not be used after this function is called - This function should only be called once per allocation + +**Safety:** +- `filter` must be a valid pointer to an FFICompactFilter - The pointer must not be used after this function is called - This function should only be called once per allocation + +**Module:** `types` + +--- + +#### `dash_spv_ffi_compact_filters_destroy` + +```c +dash_spv_ffi_compact_filters_destroy(filters: *mut FFICompactFilters) -> () +``` + +**Description:** +Destroys an array of compact filters. # Safety - `filters` must be a valid pointer to an FFICompactFilters struct - The pointer must not be used after this function is called - This function should only be called once per allocation + +**Safety:** +- `filters` must be a valid pointer to an FFICompactFilters struct - The pointer must not be used after this function is called - This function should only be called once per allocation + +**Module:** `types` + +--- + #### `dash_spv_ffi_enable_test_mode` ```c @@ -1173,6 +1277,38 @@ dash_spv_ffi_enable_test_mode() -> () --- +#### `dash_spv_ffi_filter_match_entry_destroy` + +```c +dash_spv_ffi_filter_match_entry_destroy(entry: *mut FFIFilterMatchEntry) -> () +``` + +**Description:** +Destroys a single filter match entry. # Safety - `entry` must be a valid pointer to an FFIFilterMatchEntry - The pointer must not be used after this function is called - This function should only be called once per allocation + +**Safety:** +- `entry` must be a valid pointer to an FFIFilterMatchEntry - The pointer must not be used after this function is called - This function should only be called once per allocation + +**Module:** `types` + +--- + +#### `dash_spv_ffi_filter_matches_destroy` + +```c +dash_spv_ffi_filter_matches_destroy(matches: *mut FFIFilterMatches) -> () +``` + +**Description:** +Destroys an array of filter match entries. # Safety - `matches` must be a valid pointer to an FFIFilterMatches struct - The pointer must not be used after this function is called - This function should only be called once per allocation + +**Safety:** +- `matches` must be a valid pointer to an FFIFilterMatches struct - The pointer must not be used after this function is called - This function should only be called once per allocation + +**Module:** `types` + +--- + #### `dash_spv_ffi_init_logging` ```c From 26d28dda0462d4c9af2814b55f5e3fde20e49652 Mon Sep 17 00:00:00 2001 From: Borja Castellano Date: Wed, 3 Dec 2025 21:09:32 +0000 Subject: [PATCH 11/14] updated dash-spv-ffi/FFI_API.md docs --- dash-spv-ffi/FFI_API.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dash-spv-ffi/FFI_API.md b/dash-spv-ffi/FFI_API.md index 7d6b40d73..74b7b6db2 100644 --- a/dash-spv-ffi/FFI_API.md +++ b/dash-spv-ffi/FFI_API.md @@ -98,7 +98,7 @@ Functions: 5 | `dash_spv_ffi_client_broadcast_transaction` | No description | broadcast | | `dash_spv_ffi_client_get_blocks_with_transactions_count` | Get the count of blocks that contained relevant transactions | client | | `dash_spv_ffi_client_get_transaction_count` | Get the total count of transactions across all wallets | client | -| `dash_spv_ffi_unconfirmed_transaction_destroy` | Destroys an FFIUnconfirmedTransaction and all its associated resources # Saf... | types | +| `dash_spv_ffi_unconfirmed_transaction_destroy` | Destroys an FFIUnconfirmedTransaction and all its associated resources #... | types | | `dash_spv_ffi_unconfirmed_transaction_destroy_raw_tx` | Destroys the raw transaction bytes allocated for an FFIUnconfirmedTransaction... | types | ### Mempool Operations @@ -154,7 +154,7 @@ Functions: 25 | `dash_spv_ffi_client_get_stats` | Get current runtime statistics for the SPV client | client | | `dash_spv_ffi_client_get_tip_hash` | Get the current chain tip hash (32 bytes) if available | client | | `dash_spv_ffi_client_get_tip_height` | Get the current chain tip height (absolute) | client | -| `dash_spv_ffi_client_get_wallet_manager` | Get the wallet manager from the SPV client Returns a pointer to an `FFIWalle... | client | +| `dash_spv_ffi_client_get_wallet_manager` | Get the wallet manager from the SPV client Returns a pointer to an... | client | | `dash_spv_ffi_client_load_filters` | Load compact block filters in a given height range | client | | `dash_spv_ffi_client_record_send` | Record that we attempted to send a transaction by its txid | client | | `dash_spv_ffi_client_rescan_blockchain` | Request a rescan of the blockchain from a given height (not yet implemented) | client | From 108d09285091c592477d310854e5ed5dda596a6a Mon Sep 17 00:00:00 2001 From: Borja Castellano Date: Wed, 3 Dec 2025 21:32:05 +0000 Subject: [PATCH 12/14] fixed clippy error --- dash-spv/src/sync/sequential/message_handlers.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dash-spv/src/sync/sequential/message_handlers.rs b/dash-spv/src/sync/sequential/message_handlers.rs index 89b62558e..ccc48a55e 100644 --- a/dash-spv/src/sync/sequential/message_handlers.rs +++ b/dash-spv/src/sync/sequential/message_handlers.rs @@ -645,7 +645,7 @@ impl< .load_chain_state() .await .map_err(|e| SyncError::Storage(format!("Failed to load chain state: {}", e)))? - .unwrap_or_else(|| crate::types::ChainState::new()); + .unwrap_or_else(crate::types::ChainState::new); // Record the filter matches chain_state.record_filter_matches(height, matched_wallet_ids); From 638dd2010f28b6b3642b30fe0fb6bbb271e72eb1 Mon Sep 17 00:00:00 2001 From: Borja Castellano Date: Thu, 4 Dec 2025 22:59:34 +0000 Subject: [PATCH 13/14] this doesn't belong to this PR --- dash-spv/src/storage/disk/filters.rs | 60 ---------------------------- 1 file changed, 60 deletions(-) diff --git a/dash-spv/src/storage/disk/filters.rs b/dash-spv/src/storage/disk/filters.rs index 284b55212..d850e85d8 100644 --- a/dash-spv/src/storage/disk/filters.rs +++ b/dash-spv/src/storage/disk/filters.rs @@ -182,66 +182,6 @@ impl DiskStorageManager { Ok(*self.cached_filter_tip_height.read().await) } - /// Get the highest stored compact filter height by scanning the filters directory. - /// This checks which filters are actually persisted on disk, not just filter headers. - /// - /// Returns None if no filters are stored, otherwise returns the highest height found. - /// - /// Note: This only counts individual filter files ({height}.dat), not segment files. - pub async fn get_stored_filter_height(&self) -> StorageResult> { - let filters_dir = self.base_path.join("filters"); - - // If filters directory doesn't exist, no filters are stored - if !filters_dir.exists() { - return Ok(None); - } - - let mut max_height: Option = None; - - // Read directory entries - let mut entries = tokio::fs::read_dir(&filters_dir).await?; - - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - - // Skip if not a file - if !path.is_file() { - continue; - } - - // Check if it's a .dat file - if let Some(extension) = path.extension() { - if extension == "dat" { - // Extract height from filename (format: "{height}.dat") - if let Some(filename) = path.file_stem() { - if let Some(filename_str) = filename.to_str() { - // Only parse if filename is PURELY numeric (not "filter_segment_0001") - // This ensures we only count individual filter files, not segments - if filename_str.chars().all(|c| c.is_ascii_digit()) { - if let Ok(height) = filename_str.parse::() { - if height > 2_000_000 { - // Sanity check - testnet/mainnet should never exceed 2M blocks - tracing::warn!( - "Found suspiciously high filter file: {}.dat (height {}), ignoring", - filename_str, - height - ); - continue; - } - max_height = Some( - max_height.map_or(height, |current| current.max(height)), - ); - } - } - } - } - } - } - } - - Ok(max_height) - } - /// Store a compact filter. pub async fn store_filter(&mut self, height: u32, filter: &[u8]) -> StorageResult<()> { let path = self.base_path.join(format!("filters/{}.dat", height)); From dcbe06a59eaa265880c4c4580e2e8be443ac375e Mon Sep 17 00:00:00 2001 From: Borja Castellano Date: Tue, 23 Dec 2025 00:44:24 +0000 Subject: [PATCH 14/14] fixed clippt warnings by taking the spv client --- dash-spv-ffi/src/client.rs | 113 ++++++++++++++++++++++++------------- 1 file changed, 73 insertions(+), 40 deletions(-) diff --git a/dash-spv-ffi/src/client.rs b/dash-spv-ffi/src/client.rs index 07e8f8f9a..f7e2f18f4 100644 --- a/dash-spv-ffi/src/client.rs +++ b/dash-spv-ffi/src/client.rs @@ -1687,11 +1687,11 @@ pub unsafe extern "C" fn dash_spv_ffi_client_get_filter_matched_heights( let inner = client.inner.clone(); let result = client.runtime.block_on(async { - // Get chain state without taking the client - let chain_state = { - let guard = inner.lock().unwrap(); - match guard.as_ref() { - Some(client) => client.chain_state().await, + // Take client out of the mutex so no std::sync::MutexGuard is held across .await + let spv_client = { + let mut guard = inner.lock().unwrap(); + match guard.take() { + Some(client) => client, None => { set_last_error("Client not initialized"); return None; @@ -1699,6 +1699,15 @@ pub unsafe extern "C" fn dash_spv_ffi_client_get_filter_matched_heights( } }; + // Query chain state without holding the mutex + let chain_state = spv_client.chain_state().await; + + // Put client back + { + let mut guard = inner.lock().unwrap(); + *guard = Some(spv_client); + } + // Get filter matches in range - works even during sync let matches_result = chain_state.get_filter_matched_heights(start_height..end_height); @@ -1765,21 +1774,33 @@ pub unsafe extern "C" fn dash_spv_ffi_client_get_transaction_count( let inner = client.inner.clone(); let result = client.runtime.block_on(async { - // Get wallet without taking the client - let guard = inner.lock().unwrap(); - match guard.as_ref() { - Some(spv_client) => { - // Access wallet and get transaction count - let wallet = spv_client.wallet(); - let wallet_guard = wallet.read().await; - let tx_history = wallet_guard.transaction_history(); - tx_history.len() - } - None => { - tracing::warn!("Client not initialized when querying transaction count"); - 0 + // Take client out of the mutex so no std::sync::MutexGuard is held across .await + let spv_client = { + let mut guard = inner.lock().unwrap(); + match guard.take() { + Some(client) => client, + None => { + tracing::warn!("Client not initialized when querying transaction count"); + return 0; + } } + }; + + // Access wallet and get transaction count + let tx_len = { + let wallet = spv_client.wallet(); + let wallet_guard = wallet.read().await; + let tx_history = wallet_guard.transaction_history(); + tx_history.len() + }; + + // Put client back + { + let mut guard = inner.lock().unwrap(); + *guard = Some(spv_client); } + + tx_len }); result @@ -1810,32 +1831,44 @@ pub unsafe extern "C" fn dash_spv_ffi_client_get_blocks_with_transactions_count( let inner = client.inner.clone(); let result = client.runtime.block_on(async { - // Get wallet without taking the client - let guard = inner.lock().unwrap(); - match guard.as_ref() { - Some(spv_client) => { - // Access wallet and get unique block heights - let wallet = spv_client.wallet(); - let wallet_guard = wallet.read().await; - let tx_history = wallet_guard.transaction_history(); - - // Count unique block heights (confirmed transactions only) - let mut unique_heights = std::collections::HashSet::new(); - for tx in tx_history { - if let Some(height) = tx.height { - unique_heights.insert(height); - } + // Take client out of the mutex so no std::sync::MutexGuard is held across .await + let spv_client = { + let mut guard = inner.lock().unwrap(); + match guard.take() { + Some(client) => client, + None => { + tracing::warn!( + "Client not initialized when querying blocks with transactions count" + ); + return 0; } - - unique_heights.len() } - None => { - tracing::warn!( - "Client not initialized when querying blocks with transactions count" - ); - 0 + }; + + let unique_heights_len = { + // Access wallet and get unique block heights + let wallet = spv_client.wallet(); + let wallet_guard = wallet.read().await; + let tx_history = wallet_guard.transaction_history(); + + // Count unique block heights (confirmed transactions only) + let mut unique_heights = std::collections::HashSet::new(); + for tx in tx_history { + if let Some(height) = tx.height { + unique_heights.insert(height); + } } + + unique_heights.len() + }; + + // Put client back + { + let mut guard = inner.lock().unwrap(); + *guard = Some(spv_client); } + + unique_heights_len }); result