diff --git a/dash-spv-ffi/FFI_API.md b/dash-spv-ffi/FFI_API.md index 2f156c0a..205b3b9a 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**: 70 +**Total Functions**: 78 ## Table of Contents @@ -90,11 +90,13 @@ Functions: 1 ### Transaction Management -Functions: 3 +Functions: 5 | Function | Description | Module | |----------|-------------|--------| | `dash_spv_ffi_client_broadcast_transaction` | Broadcasts a transaction to the Dash network via connected peers | 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 #... | types | | `dash_spv_ffi_unconfirmed_transaction_destroy_raw_tx` | Destroys the raw transaction bytes allocated for an FFIUnconfirmedTransaction... | types | @@ -137,7 +139,7 @@ Functions: 2 ### Utility Functions -Functions: 19 +Functions: 25 | Function | Description | Module | |----------|-------------|--------| @@ -147,13 +149,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... | 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 | @@ -795,6 +803,38 @@ Broadcasts a transaction to the Dash network via connected peers. # Safety - ` --- +#### `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 @@ -1056,6 +1096,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 @@ -1120,6 +1176,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 @@ -1152,6 +1224,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 @@ -1162,6 +1266,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 diff --git a/dash-spv-ffi/include/dash_spv_ffi.h b/dash-spv-ffi/include/dash_spv_ffi.h index cf4b2661..91e07bb6 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 */ @@ -465,6 +544,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. * @@ -563,6 +672,74 @@ 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) +; + +/** + * 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) ; @@ -968,6 +1145,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 ba0ac676..f7e2f18f 100644 --- a/dash-spv-ffi/src/client.rs +++ b/dash-spv-ffi/src/client.rs @@ -2,6 +2,7 @@ use crate::{ null_check, set_last_error, FFIClientConfig, FFIDetailedSyncProgress, FFIErrorCode, FFIEventCallbacks, FFIMempoolStrategy, FFISpvStats, FFISyncProgress, FFIWalletManager, }; +use dash_spv::storage::StorageManager; // Import wallet types from key-wallet-ffi use key_wallet_ffi::FFIWalletManager as KeyWalletFFIWalletManager; @@ -1250,6 +1251,107 @@ 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 { + // 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; + } + } + }; + + // Access storage directly - works even during sync + let storage_guard = storage.lock().await; + let filters_result = storage_guard.load_filters(start_height..end_height).await; + drop(storage_guard); + + match filters_result { + Ok(filters) => { + // Convert to FFI format + let mut ffi_filters = Vec::with_capacity(filters.len()); + for (rel_height, data) in filters.into_iter().enumerate() { + let height = start_height + rel_height as u32; + 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 @@ -1537,3 +1639,237 @@ 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 { + // 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; + } + } + }; + + // 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); + + 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()) +} + +/// 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 { + // 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 +} + +/// 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 { + // 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; + } + } + }; + + 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 +} diff --git a/dash-spv-ffi/src/types.rs b/dash-spv-ffi/src/types.rs index c644c52d..dfaac11d 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-ffi/tests/unit/test_client_lifecycle.rs b/dash-spv-ffi/tests/unit/test_client_lifecycle.rs index 067c1ba6..6b98a0bd 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 58e29ce5..c338ea54 100644 --- a/dash-spv-ffi/tests/unit/test_type_conversions.rs +++ b/dash-spv-ffi/tests/unit/test_type_conversions.rs @@ -171,6 +171,7 @@ mod tests { masternode_engine: None, last_masternode_diff_height: None, sync_base_height: 0, + 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 c582932f..e902318d 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 { @@ -292,9 +292,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/error.rs b/dash-spv/src/error.rs index 5e411449..05267d43 100644 --- a/dash-spv/src/error.rs +++ b/dash-spv/src/error.rs @@ -133,6 +133,9 @@ pub enum StorageError { #[error("Lock poisoned: {0}")] LockPoisoned(String), + #[error("Invalid input: {0}")] + InvalidInput(String), + #[error("Data directory locked: {0}")] DirectoryLocked(String), } @@ -148,6 +151,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()), StorageError::DirectoryLocked(s) => StorageError::DirectoryLocked(s.clone()), } } diff --git a/dash-spv/src/sync/filters/matching.rs b/dash-spv/src/sync/filters/matching.rs index 55acfd04..09e5484e 100644 --- a/dash-spv/src/sync/filters/matching.rs +++ b/dash-spv/src/sync/filters/matching.rs @@ -30,18 +30,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/message_handlers.rs b/dash-spv/src/sync/message_handlers.rs index 027317c5..e82e7c7c 100644 --- a/dash-spv/src/sync/message_handlers.rs +++ b/dash-spv/src/sync/message_handlers.rs @@ -604,7 +604,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, @@ -616,20 +616,51 @@ impl< drop(wallet); + let matched_wallet_ids_len = matched_wallet_ids.len(); + { let mut stats_lock = self.stats.write().await; stats_lock.filters_received += 1; stats_lock.last_filter_received_time = Some(std::time::Instant::now()); } - if matches { + if matched_wallet_ids_len > 0 { // 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); + + // 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/phase_execution.rs b/dash-spv/src/sync/phase_execution.rs index 77758d83..32170d40 100644 --- a/dash-spv/src/sync/phase_execution.rs +++ b/dash-spv/src/sync/phase_execution.rs @@ -147,6 +147,7 @@ impl< // 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; tracing::info!( diff --git a/dash-spv/src/types.rs b/dash-spv/src/types.rs index ec217b42..167fb67f 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::{ @@ -277,6 +278,10 @@ pub struct ChainState { /// Base height when syncing from a checkpoint (0 if syncing from genesis). pub sync_base_height: u32, + + /// 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 { @@ -421,12 +426,53 @@ 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 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; + } + + 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. + /// + /// 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 @@ -1103,3 +1149,74 @@ impl MempoolState { self.pending_balance + self.pending_instant_balance } } + +#[cfg(test)] +mod tests { + use std::vec; + + use super::*; + + #[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_matches(height, vec![[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_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(); + 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 254a5162..ff484a4b 100644 --- a/dash-spv/tests/storage_test.rs +++ b/dash-spv/tests/storage_test.rs @@ -104,3 +104,24 @@ async fn test_disk_storage_lock_file_lifecycle() { let storage2 = DiskStorageManager::new(path.clone()).await; assert!(storage2.is_ok(), "Should reopen after previous storage dropped"); } + +#[tokio::test] +async fn test_load_filters_range() { + let temp_dir = TempDir::new().unwrap(); + let mut storage = DiskStorageManager::new(temp_dir.path().to_path_buf()) + .await + .expect("Failed to create Disk storage"); + + storage.store_filter(100, &[1, 2, 3]).await.expect("Failed to store filter at 100"); + storage.store_filter(101, &[4, 5, 6]).await.expect("Failed to store filter at 101"); + storage.store_filter(102, &[7, 8, 9]).await.expect("Failed to store filter at 102"); + storage.store_filter(103, &[10, 11, 12]).await.expect("Failed to store filter at 103"); + + let filters = storage.load_filters(100..104).await.expect("Failed to load filters"); + + assert_eq!(filters.len(), 4, "Should load 4 filters"); + assert_eq!(filters[0], vec![1, 2, 3], "Filter at 100 mismatch"); + assert_eq!(filters[1], vec![4, 5, 6], "Filter at 101 mismatch"); + assert_eq!(filters[2], vec![7, 8, 9], "Filter at 102 mismatch"); + assert_eq!(filters[3], vec![10, 11, 12], "Filter at 103 mismatch"); +} diff --git a/key-wallet-manager/src/wallet_interface.rs b/key-wallet-manager/src/wallet_interface.rs index 5970b5ce..efec3ca0 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/mod.rs b/key-wallet-manager/src/wallet_manager/mod.rs index 26902a54..2dca9237 100644 --- a/key-wallet-manager/src/wallet_manager/mod.rs +++ b/key-wallet-manager/src/wallet_manager/mod.rs @@ -92,7 +92,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 8226ff73..67990354 100644 --- a/key-wallet-manager/src/wallet_manager/process_block.rs +++ b/key-wallet-manager/src/wallet_manager/process_block.rs @@ -89,42 +89,46 @@ impl WalletInterface for WalletM filter: &BlockFilter, block_hash: &BlockHash, network: Network, - ) -> bool { + ) -> 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; + if let Some(matched) = network_cache.get(block_hash) { + return matched.clone(); } } - // Collect all scripts we're watching - let mut script_bytes = Vec::new(); + let mut matched_wallet_ids = Vec::new(); - // Get all wallet addresses for this network - for info in self.wallet_infos.values() { - if info.network() == network { - let monitored = info.monitored_addresses(); - for address in monitored { - script_bytes.push(address.script_pubkey().as_bytes().to_vec()); - } + // Check each wallet individually to track which ones match + for (wallet_id, info) in &self.wallet_infos { + let monitored = info.monitored_addresses(); + + // 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); + self.filter_matches + .entry(network) + .or_default() + .insert(*block_hash, matched_wallet_ids.clone()); - hit + matched_wallet_ids } async fn transaction_effect(