From c5f4719056c43339aa3fa7b276245248819fd680 Mon Sep 17 00:00:00 2001 From: xdustinface Date: Mon, 16 Mar 2026 01:38:26 +0700 Subject: [PATCH 1/7] feat: add `TransactionStatusChanged` event and emit wallet events - Add `TransactionStatusChanged` variant to `WalletEvent` and a `status` field to `TransactionReceived`. - Wire up event emission in `check_transaction_in_all_wallets`, `process_instant_send_lock`, and `update_synced_height`. - Add FFI callback for status changes. --- dash-spv-ffi/include/dash_spv_ffi.h | 13 + dash-spv-ffi/src/bin/ffi_cli.rs | 17 +- dash-spv-ffi/src/callbacks.rs | 36 ++ dash-spv-ffi/tests/dashd_sync/callbacks.rs | 17 + .../tests/dashd_sync/tests_callback.rs | 12 +- key-wallet-manager/src/events.rs | 27 +- key-wallet-manager/src/test_utils/wallet.rs | 14 + key-wallet-manager/src/wallet_interface.rs | 4 + .../src/wallet_manager/event_tests.rs | 333 ++++++++++++++++++ key-wallet-manager/src/wallet_manager/mod.rs | 53 ++- .../src/wallet_manager/process_block.rs | 28 +- .../src/wallet_manager/test_helpers.rs | 129 +++++++ .../transaction_checking/account_checker.rs | 3 + .../transaction_checking/wallet_checker.rs | 6 +- 14 files changed, 665 insertions(+), 27 deletions(-) create mode 100644 key-wallet-manager/src/wallet_manager/event_tests.rs create mode 100644 key-wallet-manager/src/wallet_manager/test_helpers.rs diff --git a/dash-spv-ffi/include/dash_spv_ffi.h b/dash-spv-ffi/include/dash_spv_ffi.h index 05290f78d..c47776cdd 100644 --- a/dash-spv-ffi/include/dash_spv_ffi.h +++ b/dash-spv-ffi/include/dash_spv_ffi.h @@ -375,12 +375,24 @@ typedef struct FFINetworkEventCallbacks { * copy any data they need to retain after the callback returns. */ typedef void (*OnTransactionReceivedCallback)(const char *wallet_id, + FFITransactionContext status, uint32_t account_index, const uint8_t (*txid)[32], int64_t amount, const char *addresses, void *user_data); +/** + * Callback for WalletEvent::TransactionStatusChanged + * + * The `wallet_id` string pointer and `txid` hash pointer are borrowed and only + * valid for the duration of the callback. + */ +typedef void (*OnTransactionStatusChangedCallback)(const char *wallet_id, + const uint8_t (*txid)[32], + FFITransactionContext status, + void *user_data); + /** * Callback for WalletEvent::BalanceUpdated * @@ -406,6 +418,7 @@ typedef void (*OnBalanceUpdatedCallback)(const char *wallet_id, */ typedef struct FFIWalletEventCallbacks { OnTransactionReceivedCallback on_transaction_received; + OnTransactionStatusChangedCallback on_transaction_status_changed; OnBalanceUpdatedCallback on_balance_updated; void *user_data; } FFIWalletEventCallbacks; diff --git a/dash-spv-ffi/src/bin/ffi_cli.rs b/dash-spv-ffi/src/bin/ffi_cli.rs index b9c4a62d1..6d56ae4e5 100644 --- a/dash-spv-ffi/src/bin/ffi_cli.rs +++ b/dash-spv-ffi/src/bin/ffi_cli.rs @@ -5,6 +5,7 @@ use std::ptr; use clap::{Arg, ArgAction, Command}; use dash_spv_ffi::*; +use key_wallet_ffi::types::FFITransactionContext; use key_wallet_ffi::wallet_manager::wallet_manager_add_wallet_from_mnemonic; use key_wallet_ffi::{FFIError, FFINetwork}; @@ -150,6 +151,7 @@ extern "C" fn on_peers_updated(connected_count: u32, best_height: u32, _user_dat extern "C" fn on_transaction_received( wallet_id: *const c_char, + status: FFITransactionContext, account_index: u32, txid: *const [u8; 32], amount: i64, @@ -165,11 +167,21 @@ extern "C" fn on_transaction_received( }; let txid_hex = unsafe { hex::encode(*txid) }; println!( - "[Wallet] TX received: wallet={}..., txid={}, account={}, amount={} duffs, addresses={}", - wallet_short, txid_hex, account_index, amount, addr_str + "[Wallet] TX received: wallet={}..., txid={}, account={}, amount={} duffs, status={:?}, addresses={}", + wallet_short, txid_hex, account_index, amount, status, addr_str ); } +extern "C" fn on_transaction_status_changed( + _wallet_id: *const c_char, + txid: *const [u8; 32], + status: FFITransactionContext, + _user_data: *mut c_void, +) { + let txid_hex = unsafe { hex::encode(*txid) }; + println!("[Wallet] TX status changed: txid={}, status={:?}", txid_hex, status); +} + extern "C" fn on_balance_updated( wallet_id: *const c_char, spendable: u64, @@ -431,6 +443,7 @@ fn main() { let wallet_callbacks = FFIWalletEventCallbacks { on_transaction_received: Some(on_transaction_received), + on_transaction_status_changed: Some(on_transaction_status_changed), on_balance_updated: Some(on_balance_updated), user_data: ptr::null_mut(), }; diff --git a/dash-spv-ffi/src/callbacks.rs b/dash-spv-ffi/src/callbacks.rs index 97ce9028e..5ea4c2c6c 100644 --- a/dash-spv-ffi/src/callbacks.rs +++ b/dash-spv-ffi/src/callbacks.rs @@ -8,6 +8,7 @@ use crate::{dash_spv_ffi_sync_progress_destroy, FFISyncProgress}; use dashcore::hashes::Hash; +use key_wallet_ffi::types::FFITransactionContext; use std::ffi::CString; use std::os::raw::{c_char, c_void}; @@ -522,6 +523,7 @@ impl FFINetworkEventCallbacks { pub type OnTransactionReceivedCallback = Option< extern "C" fn( wallet_id: *const c_char, + status: FFITransactionContext, account_index: u32, txid: *const [u8; 32], amount: i64, @@ -530,6 +532,19 @@ pub type OnTransactionReceivedCallback = Option< ), >; +/// Callback for WalletEvent::TransactionStatusChanged +/// +/// The `wallet_id` string pointer and `txid` hash pointer are borrowed and only +/// valid for the duration of the callback. +pub type OnTransactionStatusChangedCallback = Option< + extern "C" fn( + wallet_id: *const c_char, + txid: *const [u8; 32], + status: FFITransactionContext, + user_data: *mut c_void, + ), +>; + /// Callback for WalletEvent::BalanceUpdated /// /// The `wallet_id` string pointer is borrowed and only valid for the duration @@ -557,6 +572,7 @@ pub type OnBalanceUpdatedCallback = Option< #[derive(Clone)] pub struct FFIWalletEventCallbacks { pub on_transaction_received: OnTransactionReceivedCallback, + pub on_transaction_status_changed: OnTransactionStatusChangedCallback, pub on_balance_updated: OnBalanceUpdatedCallback, pub user_data: *mut c_void, } @@ -569,6 +585,7 @@ impl Default for FFIWalletEventCallbacks { fn default() -> Self { Self { on_transaction_received: None, + on_transaction_status_changed: None, on_balance_updated: None, user_data: std::ptr::null_mut(), } @@ -625,6 +642,7 @@ impl FFIWalletEventCallbacks { match event { WalletEvent::TransactionReceived { wallet_id, + status, account_index, txid, amount, @@ -639,6 +657,7 @@ impl FFIWalletEventCallbacks { let c_addresses = CString::new(addresses_str.join(",")).unwrap_or_default(); cb( c_wallet_id.as_ptr(), + FFITransactionContext::from(*status), *account_index, txid_bytes as *const [u8; 32], *amount, @@ -647,6 +666,23 @@ impl FFIWalletEventCallbacks { ); } } + WalletEvent::TransactionStatusChanged { + wallet_id, + txid, + status, + } => { + if let Some(cb) = self.on_transaction_status_changed { + let wallet_id_hex = hex::encode(wallet_id); + let c_wallet_id = CString::new(wallet_id_hex).unwrap_or_default(); + let txid_bytes = txid.as_byte_array(); + cb( + c_wallet_id.as_ptr(), + txid_bytes as *const [u8; 32], + FFITransactionContext::from(*status), + self.user_data, + ); + } + } WalletEvent::BalanceUpdated { wallet_id, spendable, diff --git a/dash-spv-ffi/tests/dashd_sync/callbacks.rs b/dash-spv-ffi/tests/dashd_sync/callbacks.rs index 54ec511ee..20ca342ff 100644 --- a/dash-spv-ffi/tests/dashd_sync/callbacks.rs +++ b/dash-spv-ffi/tests/dashd_sync/callbacks.rs @@ -7,6 +7,7 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; use dash_spv_ffi::*; +use key_wallet_ffi::types::FFITransactionContext; /// Tracks callback invocations for verification. /// @@ -36,6 +37,7 @@ pub(super) struct CallbackTracker { // Wallet event tracking pub(super) transaction_received_count: AtomicU32, + pub(super) transaction_status_changed_count: AtomicU32, pub(super) balance_updated_count: AtomicU32, // Data from callbacks @@ -334,6 +336,7 @@ extern "C" fn on_peers_updated(connected_count: u32, best_height: u32, user_data extern "C" fn on_transaction_received( wallet_id: *const c_char, + _status: FFITransactionContext, account_index: u32, txid: *const [u8; 32], amount: i64, @@ -358,6 +361,19 @@ extern "C" fn on_transaction_received( ); } +extern "C" fn on_transaction_status_changed( + _wallet_id: *const c_char, + _txid: *const [u8; 32], + status: FFITransactionContext, + user_data: *mut c_void, +) { + let Some(tracker) = (unsafe { tracker_from(user_data) }) else { + return; + }; + tracker.transaction_status_changed_count.fetch_add(1, Ordering::SeqCst); + tracing::debug!("on_transaction_status_changed: status={:?}", status); +} + extern "C" fn on_balance_updated( wallet_id: *const c_char, spendable: u64, @@ -427,6 +443,7 @@ pub(super) fn create_network_callbacks(tracker: &Arc) -> FFINet pub(super) fn create_wallet_callbacks(tracker: &Arc) -> FFIWalletEventCallbacks { FFIWalletEventCallbacks { on_transaction_received: Some(on_transaction_received), + on_transaction_status_changed: Some(on_transaction_status_changed), on_balance_updated: Some(on_balance_updated), user_data: Arc::as_ptr(tracker) as *mut c_void, } diff --git a/dash-spv-ffi/tests/dashd_sync/tests_callback.rs b/dash-spv-ffi/tests/dashd_sync/tests_callback.rs index a7489fddd..dab7aa6a4 100644 --- a/dash-spv-ffi/tests/dashd_sync/tests_callback.rs +++ b/dash-spv-ffi/tests/dashd_sync/tests_callback.rs @@ -107,13 +107,23 @@ fn test_all_callbacks_during_sync() { // Validate wallet event callbacks (test wallet has transactions) let tx_received = tracker.transaction_received_count.load(Ordering::SeqCst); let balance_updated = tracker.balance_updated_count.load(Ordering::SeqCst); + let tx_status_changed = tracker.transaction_status_changed_count.load(Ordering::SeqCst); - tracing::info!("Wallet: tx_received={}, balance_updated={}", tx_received, balance_updated); + tracing::info!( + "Wallet: tx_received={}, tx_status_changed={}, balance_updated={}", + tx_received, + tx_status_changed, + balance_updated + ); assert!( tx_received > 0, "on_transaction_received should fire for wallet with transactions" ); + assert_eq!( + tx_status_changed, 0, + "on_transaction_status_changed should not fire here, all transactions are confirmed." + ); assert!(balance_updated > 0, "on_balance_updated should fire for wallet with transactions"); // Validate sync cycle (initial sync is cycle 0) diff --git a/key-wallet-manager/src/events.rs b/key-wallet-manager/src/events.rs index 56101d4c4..f2c99a556 100644 --- a/key-wallet-manager/src/events.rs +++ b/key-wallet-manager/src/events.rs @@ -7,6 +7,7 @@ use crate::wallet_manager::WalletId; use alloc::string::String; use alloc::vec::Vec; use dashcore::{Address, Amount, SignedAmount, Txid}; +use key_wallet::transaction_checking::TransactionContext; /// Events emitted by the wallet manager. /// @@ -14,10 +15,12 @@ use dashcore::{Address, Amount, SignedAmount, Txid}; /// may want to react to. #[derive(Debug, Clone)] pub enum WalletEvent { - /// A transaction relevant to the wallet was received. + /// A transaction relevant to the wallet was received for the first time. TransactionReceived { /// ID of the affected wallet. wallet_id: WalletId, + /// Context at the time the transaction was first seen. + status: TransactionContext, /// Account index within the wallet. account_index: u32, /// Transaction ID. @@ -27,6 +30,15 @@ pub enum WalletEvent { /// Addresses involved in the transaction. addresses: Vec
, }, + /// The confirmation status of a previously seen transaction has changed. + TransactionStatusChanged { + /// ID of the affected wallet. + wallet_id: WalletId, + /// Transaction ID. + txid: Txid, + /// New transaction context. + status: TransactionContext, + }, /// The wallet balance has changed. BalanceUpdated { /// ID of the affected wallet. @@ -49,14 +61,23 @@ impl WalletEvent { WalletEvent::TransactionReceived { txid, amount, + status, .. } => { format!( - "TransactionReceived(txid={}, amount={})", + "TransactionReceived(txid={}, amount={}, status={})", txid, - SignedAmount::from_sat(*amount) + SignedAmount::from_sat(*amount), + status ) } + WalletEvent::TransactionStatusChanged { + txid, + status, + .. + } => { + format!("TransactionStatusChanged(txid={}, status={})", txid, status) + } WalletEvent::BalanceUpdated { spendable, unconfirmed, diff --git a/key-wallet-manager/src/test_utils/wallet.rs b/key-wallet-manager/src/test_utils/wallet.rs index b7341fb29..6751c5432 100644 --- a/key-wallet-manager/src/test_utils/wallet.rs +++ b/key-wallet-manager/src/test_utils/wallet.rs @@ -1,6 +1,7 @@ use crate::{wallet_interface::WalletInterface, BlockProcessingResult, WalletEvent}; use dashcore::prelude::CoreBlockHeight; use dashcore::{Address, Block, Transaction, Txid}; +use key_wallet::transaction_checking::TransactionContext; use std::{collections::BTreeMap, sync::Arc}; use tokio::sync::{broadcast, Mutex}; @@ -14,6 +15,8 @@ pub struct MockWallet { effects: TransactionEffectsMap, synced_height: CoreBlockHeight, event_sender: broadcast::Sender, + /// Recorded status change notifications for test assertions. + status_changes: Arc>>, } impl Default for MockWallet { @@ -31,9 +34,14 @@ impl MockWallet { effects: Arc::new(Mutex::new(BTreeMap::new())), synced_height: 0, event_sender, + status_changes: Arc::new(Mutex::new(Vec::new())), } } + pub fn status_changes(&self) -> Arc>> { + self.status_changes.clone() + } + pub async fn set_effect(&self, txid: dashcore::Txid, net: i64, addresses: Vec) { let mut map = self.effects.lock().await; map.insert(txid, (net, addresses)); @@ -90,6 +98,12 @@ impl WalletInterface for MockWallet { fn subscribe_events(&self) -> broadcast::Receiver { self.event_sender.subscribe() } + + fn process_instant_send_lock(&mut self, txid: Txid) { + if let Ok(mut changes) = self.status_changes.try_lock() { + changes.push((txid, TransactionContext::InstantSend)); + } + } } /// Mock wallet that returns false for filter checks diff --git a/key-wallet-manager/src/wallet_interface.rs b/key-wallet-manager/src/wallet_interface.rs index 6e0321c61..8325bcf34 100644 --- a/key-wallet-manager/src/wallet_interface.rs +++ b/key-wallet-manager/src/wallet_interface.rs @@ -95,6 +95,10 @@ pub trait WalletInterface: Send + Sync + 'static { /// Subscribe to wallet events (e.g. transactions received, balance changes). fn subscribe_events(&self) -> broadcast::Receiver; + /// Process an InstantSend lock for a transaction already in the wallet. + /// Marks UTXOs as IS-locked, emits status change and balance update events. + fn process_instant_send_lock(&mut self, _txid: Txid) {} + /// Provide a human-readable description of the wallet implementation. /// /// Implementations are encouraged to include high-level state such as the diff --git a/key-wallet-manager/src/wallet_manager/event_tests.rs b/key-wallet-manager/src/wallet_manager/event_tests.rs new file mode 100644 index 000000000..876c61bee --- /dev/null +++ b/key-wallet-manager/src/wallet_manager/event_tests.rs @@ -0,0 +1,333 @@ +use super::test_helpers::*; +use super::*; +use crate::wallet_interface::WalletInterface; +use dashcore::hashes::Hash; +use dashcore::BlockHash; + +// --------------------------------------------------------------------------- +// Lifecycle flow tests +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_mempool_to_confirmed_event_flow() { + let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); + let mut rx = manager.subscribe_events(); + let tx = create_tx_paying_to(&addr, 0xaa); + + // First time in mempool — validate all event fields + manager.check_transaction_in_all_wallets(&tx, TransactionContext::Mempool, true, true).await; + let event = assert_single_event(&mut rx); + match event { + WalletEvent::TransactionReceived { + txid: ev_txid, + wallet_id: ev_wid, + status, + amount, + .. + } => { + assert_eq!(status, TransactionContext::Mempool); + assert_eq!(ev_txid, tx.txid()); + assert_eq!(ev_wid, wallet_id); + assert_eq!(amount, TX_AMOUNT as i64); + } + other => panic!("expected TransactionReceived, got {:?}", other), + } + + // Same tx now confirmed in a block + let block_ctx = TransactionContext::InBlock { + height: 100, + block_hash: Some(BlockHash::from_byte_array([0xaa; 32])), + timestamp: Some(1000), + }; + manager.check_transaction_in_all_wallets(&tx, block_ctx, true, true).await; + let event = assert_single_event(&mut rx); + match event { + WalletEvent::TransactionStatusChanged { + wallet_id: ev_wid, + txid: ev_txid, + status, + } => { + assert_eq!(ev_wid, wallet_id); + assert_eq!(ev_txid, tx.txid()); + assert!( + matches!( + status, + TransactionContext::InBlock { + height: 100, + .. + } + ), + "expected InBlock(100), got {:?}", + status + ); + } + other => panic!("expected TransactionStatusChanged, got {:?}", other), + } +} + +#[tokio::test] +async fn test_mempool_to_instantsend_to_confirmed_event_flow() { + assert_lifecycle_flow( + &[ + TransactionContext::Mempool, + TransactionContext::InstantSend, + TransactionContext::InBlock { + height: 200, + block_hash: Some(BlockHash::from_byte_array([0xbb; 32])), + timestamp: Some(2000), + }, + ], + 0xbb, + ) + .await; +} + +#[tokio::test] +async fn test_first_seen_in_block_event_flow() { + assert_lifecycle_flow( + &[TransactionContext::InBlock { + height: 1000, + block_hash: Some(BlockHash::from_byte_array([0xdd; 32])), + timestamp: Some(10000), + }], + 0xdd, + ) + .await; +} + +// --------------------------------------------------------------------------- +// Duplicate suppression tests +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_duplicate_mempool_emits_no_event() { + assert_context_suppressed( + &[TransactionContext::Mempool], + TransactionContext::Mempool, + None, + 0x11, + ) + .await; +} + +#[tokio::test] +async fn test_duplicate_instantsend_emits_no_event() { + assert_context_suppressed( + &[TransactionContext::Mempool, TransactionContext::InstantSend], + TransactionContext::InstantSend, + None, + 0x22, + ) + .await; +} + +#[tokio::test] +async fn test_duplicate_confirmed_emits_no_event() { + let block_ctx = TransactionContext::InBlock { + height: 300, + block_hash: Some(BlockHash::from_byte_array([0x33; 32])), + timestamp: Some(3000), + }; + assert_context_suppressed(&[block_ctx], block_ctx, Some(300), 0x33).await; +} + +// --------------------------------------------------------------------------- +// Edge case tests +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_first_seen_as_instantsend_then_duplicate() { + assert_context_suppressed( + &[TransactionContext::InstantSend], + TransactionContext::InstantSend, + None, + 0x55, + ) + .await; +} + +#[tokio::test] +async fn test_late_instantsend_after_confirmation_is_ignored() { + assert_context_suppressed( + &[ + TransactionContext::Mempool, + TransactionContext::InBlock { + height: 800, + block_hash: Some(BlockHash::from_byte_array([0x77; 32])), + timestamp: Some(8000), + }, + ], + TransactionContext::InstantSend, + Some(800), + 0x77, + ) + .await; +} + +#[tokio::test] +async fn test_mempool_after_instantsend_is_suppressed() { + assert_context_suppressed( + &[TransactionContext::Mempool, TransactionContext::InstantSend], + TransactionContext::Mempool, + None, + 0xab, + ) + .await; +} + +// --------------------------------------------------------------------------- +// Production API tests +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_process_instant_send_lock_for_unknown_txid() { + let (mut manager, wallet_id, _addr) = setup_manager_with_wallet(); + let mut rx = manager.subscribe_events(); + + let unknown_txid = dashcore::Txid::from_byte_array([0xee; 32]); + let balance_before = manager.wallet_infos.get(&wallet_id).unwrap().balance(); + + manager.process_instant_send_lock(unknown_txid); + + assert_no_events(&mut rx); + let balance_after = manager.wallet_infos.get(&wallet_id).unwrap().balance(); + assert_eq!(balance_before, balance_after); +} + +#[tokio::test] +async fn test_process_block_emits_events() { + use dashcore::blockdata::block::{Block, Header, Version}; + use dashcore::hashes::Hash; + use dashcore::{BlockHash, CompactTarget, TxMerkleNode}; + + let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); + let mut rx = manager.subscribe_events(); + let tx = create_tx_paying_to(&addr, 0xe3); + + let block = Block { + header: Header { + version: Version::default(), + prev_blockhash: BlockHash::all_zeros(), + merkle_root: TxMerkleNode::all_zeros(), + time: 12345, + bits: CompactTarget::from_consensus(0x1d00ffff), + nonce: 0, + }, + txdata: vec![tx], + }; + + let result = manager.process_block(&block, 1000).await; + assert_eq!(result.new_txids.len(), 1); + + let events = drain_events(&mut rx); + let event = events + .iter() + .find(|e| matches!(e, WalletEvent::TransactionReceived { .. })) + .unwrap_or_else(|| { + panic!("expected TransactionReceived from process_block, got {:?}", events) + }); + + match event { + WalletEvent::TransactionReceived { + status, + account_index, + addresses, + .. + } => { + assert!( + matches!( + status, + TransactionContext::InBlock { + height: 1000, + .. + } + ), + "expected InBlock at height 1000, got {:?}", + status + ); + assert_eq!(*account_index, 0); + assert!(!addresses.is_empty(), "expected non-empty addresses"); + } + _ => unreachable!(), + } + assert!( + events.iter().any( + |e| matches!(e, WalletEvent::BalanceUpdated { wallet_id: wid, .. } if *wid == wallet_id) + ), + "expected BalanceUpdated from process_block, got {:?}", + events + ); +} + +#[tokio::test] +async fn test_instantsend_to_chainlocked_event_flow() { + assert_lifecycle_flow( + &[ + TransactionContext::InstantSend, + TransactionContext::InChainLockedBlock { + height: 1600, + block_hash: Some(BlockHash::from_byte_array([0xc3; 32])), + timestamp: Some(16000), + }, + ], + 0xc3, + ) + .await; +} + +#[tokio::test] +async fn test_chainlocked_block_event_flow() { + let (mut manager, _wallet_id, addr) = setup_manager_with_wallet(); + let mut rx = manager.subscribe_events(); + let tx = create_tx_paying_to(&addr, 0xc1); + + let ctx = TransactionContext::InChainLockedBlock { + height: 2000, + block_hash: Some(BlockHash::from_byte_array([0xc1; 32])), + timestamp: Some(20000), + }; + manager.check_transaction_in_all_wallets(&tx, ctx, true, true).await; + let event = assert_single_event(&mut rx); + assert!( + matches!( + event, + WalletEvent::TransactionReceived { + status: TransactionContext::InChainLockedBlock { + height: 2000, + .. + }, + .. + } + ), + "expected TransactionReceived(InChainLockedBlock), got {:?}", + event + ); +} + +#[tokio::test] +async fn test_check_transaction_dry_run_does_not_persist_state() { + let (mut manager, _wallet_id, addr) = setup_manager_with_wallet(); + let mut rx = manager.subscribe_events(); + let tx = create_tx_paying_to(&addr, 0xd1); + + // Dry run: update_state_if_found = false + let result = manager + .check_transaction_in_all_wallets(&tx, TransactionContext::Mempool, false, false) + .await; + + assert!(!result.affected_wallets.is_empty()); + assert_no_events(&mut rx); + + // Call again — should still report as relevant (state not persisted) + let result2 = manager + .check_transaction_in_all_wallets(&tx, TransactionContext::Mempool, false, false) + .await; + assert!(!result2.affected_wallets.is_empty()); + assert_no_events(&mut rx); + + // Now persist — should still report as new since dry runs didn't record it + let result3 = manager + .check_transaction_in_all_wallets(&tx, TransactionContext::Mempool, true, true) + .await; + assert!(result3.is_new_transaction); +} diff --git a/key-wallet-manager/src/wallet_manager/mod.rs b/key-wallet-manager/src/wallet_manager/mod.rs index f2521da5c..79c55326c 100644 --- a/key-wallet-manager/src/wallet_manager/mod.rs +++ b/key-wallet-manager/src/wallet_manager/mod.rs @@ -531,27 +531,39 @@ impl WalletManager { result.is_new_transaction = true; } - // Emit TransactionReceived events for each affected account #[cfg(feature = "std")] - for account_match in &check_result.affected_accounts { - let Some(account_index) = account_match.account_type_match.account_index() - else { - continue; - }; - let amount = account_match.received as i64 - account_match.sent as i64; - let addresses: Vec
= account_match - .account_type_match - .all_involved_addresses() - .into_iter() - .map(|info| info.address) - .collect(); - - let event = WalletEvent::TransactionReceived { + if check_result.is_new_transaction { + // First time seeing this transaction — emit TransactionReceived + for account_match in &check_result.affected_accounts { + let Some(account_index) = + account_match.account_type_match.account_index() + else { + continue; + }; + let amount = account_match.received as i64 - account_match.sent as i64; + let addresses: Vec
= account_match + .account_type_match + .all_involved_addresses() + .into_iter() + .map(|info| info.address) + .collect(); + + let event = WalletEvent::TransactionReceived { + wallet_id, + status: context, + account_index, + txid: tx.txid(), + amount, + addresses, + }; + let _ = self.event_sender.send(event); + } + } else if check_result.state_modified { + // Known transaction whose state was modified (confirmation or IS-lock). + let event = WalletEvent::TransactionStatusChanged { wallet_id, - account_index, txid: tx.txid(), - amount, - addresses, + status: context, }; let _ = self.event_sender.send(event); } @@ -1114,6 +1126,11 @@ fn current_timestamp() -> u64 { #[cfg(feature = "std")] impl std::error::Error for WalletError {} +#[cfg(test)] +mod event_tests; +#[cfg(test)] +mod test_helpers; + /// Conversion from key_wallet::Error to WalletError impl From for WalletError { fn from(err: key_wallet::Error) -> Self { diff --git a/key-wallet-manager/src/wallet_manager/process_block.rs b/key-wallet-manager/src/wallet_manager/process_block.rs index c1c3faed0..052352665 100644 --- a/key-wallet-manager/src/wallet_manager/process_block.rs +++ b/key-wallet-manager/src/wallet_manager/process_block.rs @@ -6,7 +6,7 @@ use alloc::vec::Vec; use async_trait::async_trait; use core::fmt::Write as _; use dashcore::prelude::CoreBlockHeight; -use dashcore::{Address, Block, Transaction}; +use dashcore::{Address, Block, Transaction, Txid}; use key_wallet::transaction_checking::transaction_router::TransactionRouter; use key_wallet::transaction_checking::TransactionContext; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; @@ -136,6 +136,32 @@ impl WalletInterface for WalletM self.event_sender.subscribe() } + fn process_instant_send_lock(&mut self, txid: Txid) { + let snapshot = self.snapshot_balances(); + + let mut affected_wallets = Vec::new(); + for (wallet_id, info) in self.wallet_infos.iter_mut() { + if info.mark_instant_send_utxos(&txid) { + affected_wallets.push(*wallet_id); + } + } + + if affected_wallets.is_empty() { + return; + } + + for wallet_id in affected_wallets { + let event = WalletEvent::TransactionStatusChanged { + wallet_id, + txid, + status: TransactionContext::InstantSend, + }; + let _ = self.event_sender().send(event); + } + + self.emit_balance_changes(&snapshot); + } + async fn describe(&self) -> String { let wallet_count = self.wallet_infos.len(); if wallet_count == 0 { diff --git a/key-wallet-manager/src/wallet_manager/test_helpers.rs b/key-wallet-manager/src/wallet_manager/test_helpers.rs new file mode 100644 index 000000000..268bf6a47 --- /dev/null +++ b/key-wallet-manager/src/wallet_manager/test_helpers.rs @@ -0,0 +1,129 @@ +use super::*; +use dashcore::hashes::Hash; +use dashcore::{OutPoint, ScriptBuf, TxIn, TxOut, Txid, Witness}; +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; +use key_wallet::Network; +use tokio::sync::broadcast; + +pub(crate) const TEST_MNEMONIC: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + +pub(crate) const TX_AMOUNT: u64 = 100_000; + +pub(crate) fn setup_manager_with_wallet() -> (WalletManager, WalletId, Address) { + let mut manager = WalletManager::new(Network::Testnet); + let wallet_id = manager + .create_wallet_from_mnemonic(TEST_MNEMONIC, "", 0, WalletAccountCreationOptions::Default) + .unwrap(); + let addresses = manager.monitored_addresses(); + assert!(!addresses.is_empty(), "wallet should have monitored addresses"); + let addr = addresses[0].clone(); + (manager, wallet_id, addr) +} + +pub(crate) fn create_tx_paying_to(addr: &Address, input_seed: u8) -> Transaction { + Transaction { + version: 2, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint { + txid: Txid::from_byte_array([input_seed; 32]), + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: u32::MAX, + witness: Witness::default(), + }], + output: vec![TxOut { + value: TX_AMOUNT, + script_pubkey: addr.script_pubkey(), + }], + special_transaction_payload: None, + } +} + +pub(crate) fn drain_events(rx: &mut broadcast::Receiver) -> Vec { + let mut events = Vec::new(); + while let Ok(e) = rx.try_recv() { + events.push(e); + } + events +} + +/// Drain events from the receiver, assert exactly one was emitted, and return it. +pub(crate) fn assert_single_event(rx: &mut broadcast::Receiver) -> WalletEvent { + let events = drain_events(rx); + assert_eq!(events.len(), 1, "expected 1 event, got {}: {:?}", events.len(), events); + events.into_iter().next().unwrap() +} + +/// Drain events and assert none were emitted. +pub(crate) fn assert_no_events(rx: &mut broadcast::Receiver) { + let events = drain_events(rx); + assert!(events.is_empty(), "expected no events, got {}: {:?}", events.len(), events); +} + +/// Submit a transaction through a sequence of contexts and verify the event flow. +/// +/// The first context produces a `TransactionReceived` event; each subsequent +/// context produces a `TransactionStatusChanged` event. +pub(crate) async fn assert_lifecycle_flow(contexts: &[TransactionContext], input_seed: u8) { + assert!(!contexts.is_empty(), "at least one context required"); + + let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); + let mut rx = manager.subscribe_events(); + let tx = create_tx_paying_to(&addr, input_seed); + + for (i, ctx) in contexts.iter().enumerate() { + manager.check_transaction_in_all_wallets(&tx, *ctx, true, true).await; + let event = assert_single_event(&mut rx); + + if i == 0 { + assert!( + matches!(event, WalletEvent::TransactionReceived { wallet_id: wid, status, .. } if wid == wallet_id && status == *ctx), + "context[{}]: expected TransactionReceived with wallet_id and status {:?}, got {:?}", + i, + ctx, + event + ); + } else { + assert!( + matches!(event, WalletEvent::TransactionStatusChanged { wallet_id: wid, status, .. } if wid == wallet_id && status == *ctx), + "context[{}]: expected TransactionStatusChanged with wallet_id and status {:?}, got {:?}", + i, + ctx, + event + ); + } + } +} + +/// Submit a transaction through `setup_contexts`, drain events, then submit with +/// `suppressed_context` and assert no event is emitted. Optionally verify +/// the stored height. +pub(crate) async fn assert_context_suppressed( + setup_contexts: &[TransactionContext], + suppressed_context: TransactionContext, + expected_height: Option, + input_seed: u8, +) { + let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); + let mut rx = manager.subscribe_events(); + let tx = create_tx_paying_to(&addr, input_seed); + + for ctx in setup_contexts { + manager.check_transaction_in_all_wallets(&tx, *ctx, true, true).await; + drain_events(&mut rx); + } + + manager.check_transaction_in_all_wallets(&tx, suppressed_context, true, true).await; + assert_no_events(&mut rx); + + let history = manager.wallet_transaction_history(&wallet_id).unwrap(); + let records: Vec<_> = history.iter().filter(|r| r.txid == tx.txid()).collect(); + assert_eq!(records.len(), 1); + if let Some(height) = expected_height { + assert_eq!(records[0].height, Some(height)); + } +} diff --git a/key-wallet/src/transaction_checking/account_checker.rs b/key-wallet/src/transaction_checking/account_checker.rs index 15089cef9..77e47f226 100644 --- a/key-wallet/src/transaction_checking/account_checker.rs +++ b/key-wallet/src/transaction_checking/account_checker.rs @@ -32,6 +32,8 @@ pub struct TransactionCheckResult { pub is_relevant: bool, /// Set to false if the transaction was already stored and is being re-processed (e.g., during rescan) pub is_new_transaction: bool, + /// Whether any wallet state was modified during this check (new recording, confirmation, or IS-lock). + pub state_modified: bool, /// Accounts that the transaction affects pub affected_accounts: Vec, /// Total value received by our accounts @@ -290,6 +292,7 @@ impl ManagedAccountCollection { let mut result = TransactionCheckResult { is_relevant: false, is_new_transaction: false, + state_modified: false, affected_accounts: Vec::new(), total_received: 0, total_sent: 0, diff --git a/key-wallet/src/transaction_checking/wallet_checker.rs b/key-wallet/src/transaction_checking/wallet_checker.rs index b3124ef75..9ff7ecfad 100644 --- a/key-wallet/src/transaction_checking/wallet_checker.rs +++ b/key-wallet/src/transaction_checking/wallet_checker.rs @@ -199,6 +199,7 @@ impl WalletTransactionChecker for ManagedWalletInfo { if update_balance { self.update_balance(); } + result.state_modified = true; return result; } // Only proceed if the new context is a block confirmation @@ -217,8 +218,9 @@ impl WalletTransactionChecker for ManagedWalletInfo { if is_new { account.record_transaction(tx, &account_match, context); - } else { - account.confirm_transaction(tx, &account_match, context); + result.state_modified = true; + } else if account.confirm_transaction(tx, &account_match, context) { + result.state_modified = true; } for address_info in account_match.account_type_match.all_involved_addresses() { From afe707a8263dc1b80c31a9bdab03e5b8ace5e4dd Mon Sep 17 00:00:00 2001 From: xdustinface Date: Thu, 19 Mar 2026 01:02:45 +0700 Subject: [PATCH 2/7] fix: use `.expect()` on `try_lock()` in `MockWallet::process_instant_send_lock` Addresses CodeRabbit review comment on PR #552 File: key-wallet-manager/src/test_utils/wallet.rs --- key-wallet-manager/src/test_utils/wallet.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/key-wallet-manager/src/test_utils/wallet.rs b/key-wallet-manager/src/test_utils/wallet.rs index 6751c5432..d155bbf4e 100644 --- a/key-wallet-manager/src/test_utils/wallet.rs +++ b/key-wallet-manager/src/test_utils/wallet.rs @@ -100,9 +100,9 @@ impl WalletInterface for MockWallet { } fn process_instant_send_lock(&mut self, txid: Txid) { - if let Ok(mut changes) = self.status_changes.try_lock() { - changes.push((txid, TransactionContext::InstantSend)); - } + let mut changes = + self.status_changes.try_lock().expect("status_changes lock contention in test helper"); + changes.push((txid, TransactionContext::InstantSend)); } } From 39d06fb0fa354a85d0cecbe13cd85af64313fdb6 Mon Sep 17 00:00:00 2001 From: xdustinface Date: Thu, 19 Mar 2026 01:02:53 +0700 Subject: [PATCH 3/7] fix: deduplicate InstantSend events across mixed entry points `process_instant_send_lock` did not record txids in `instant_send_locks`, so a subsequent `check_transaction_in_all_wallets(..., InstantSend)` for the same txid would emit a duplicate `TransactionStatusChanged` event. Record the txid in `instant_send_locks` inside `mark_instant_send_utxos` so both paths share the same deduplication set. Add tests covering both orderings of the mixed entry points. Addresses CodeRabbit review comment on PR #552 Files: key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs, key-wallet-manager/src/wallet_manager/event_tests.rs --- .../src/wallet_manager/event_tests.rs | 67 +++++++++++++++++++ .../wallet_info_interface.rs | 3 + 2 files changed, 70 insertions(+) diff --git a/key-wallet-manager/src/wallet_manager/event_tests.rs b/key-wallet-manager/src/wallet_manager/event_tests.rs index 876c61bee..a1e3d762a 100644 --- a/key-wallet-manager/src/wallet_manager/event_tests.rs +++ b/key-wallet-manager/src/wallet_manager/event_tests.rs @@ -194,6 +194,73 @@ async fn test_process_instant_send_lock_for_unknown_txid() { assert_eq!(balance_before, balance_after); } +#[tokio::test] +async fn test_mixed_instantsend_paths_no_duplicate_events() { + let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); + let mut rx = manager.subscribe_events(); + let tx = create_tx_paying_to(&addr, 0xf0); + + // Mempool first + manager.check_transaction_in_all_wallets(&tx, TransactionContext::Mempool, true, true).await; + drain_events(&mut rx); + + // IS lock via process_instant_send_lock (network IS lock message) + manager.process_instant_send_lock(tx.txid()); + let events = drain_events(&mut rx); + assert!( + events.iter().any(|e| matches!( + e, + WalletEvent::TransactionStatusChanged { + wallet_id: wid, + status: TransactionContext::InstantSend, + .. + } if *wid == wallet_id + )), + "expected TransactionStatusChanged(InstantSend) with correct wallet_id, got {:?}", + events + ); + + // Same IS lock via check_transaction_in_all_wallets (block/tx processing path) + // should be suppressed — no duplicate event + manager + .check_transaction_in_all_wallets(&tx, TransactionContext::InstantSend, true, true) + .await; + assert_no_events(&mut rx); +} + +#[tokio::test] +async fn test_mixed_instantsend_paths_reverse_no_duplicate_events() { + let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); + let mut rx = manager.subscribe_events(); + let tx = create_tx_paying_to(&addr, 0xf1); + + // Mempool first + manager.check_transaction_in_all_wallets(&tx, TransactionContext::Mempool, true, true).await; + drain_events(&mut rx); + + // IS lock via check_transaction_in_all_wallets first + manager + .check_transaction_in_all_wallets(&tx, TransactionContext::InstantSend, true, true) + .await; + let events = drain_events(&mut rx); + assert!( + events.iter().any(|e| matches!( + e, + WalletEvent::TransactionStatusChanged { + wallet_id: wid, + status: TransactionContext::InstantSend, + .. + } if *wid == wallet_id + )), + "expected TransactionStatusChanged(InstantSend) with correct wallet_id, got {:?}", + events + ); + + // Same IS lock via process_instant_send_lock — should be suppressed + manager.process_instant_send_lock(tx.txid()); + assert_no_events(&mut rx); +} + #[tokio::test] async fn test_process_block_emits_events() { use dashcore::blockdata::block::{Block, Header, Version}; diff --git a/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs b/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs index 760573cca..627f3217d 100644 --- a/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs +++ b/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs @@ -234,6 +234,9 @@ impl WalletInfoInterface for ManagedWalletInfo { } fn mark_instant_send_utxos(&mut self, txid: &Txid) -> bool { + if !self.instant_send_locks.insert(*txid) { + return false; + } let mut any_changed = false; for account in self.accounts.all_accounts_mut() { if account.mark_utxos_instant_send(txid) { From 1af96dd02f12576ba0727476e39ff78aca0a1e13 Mon Sep 17 00:00:00 2001 From: xdustinface Date: Mon, 16 Mar 2026 02:21:28 +0700 Subject: [PATCH 4/7] feat: add mempool support See the changes in `ARCHITECTURE.md` for a detailed description. - Add `MempoolManager` that activates after initial sync to monitor unconfirmed transactions via BIP37 bloom filters or local address matching. Includes peer relay management, dedup tracking, IS lock handling, and auto-rebuilding filters on address pool changes. - Extend `WalletInterface` with `MempoolTransactionResult` return type and `watched_outpoints()` for bloom filter construction. Wire mempool manager into `SyncCoordinator` and propagate `confirmed_txids` through `BlockProcessed` events for mempool eviction. - Add FFI bindings, dashd integration tests, and wallet unit tests. --- dash-spv-ffi/FFI_API.md | 29 +- dash-spv-ffi/include/dash_spv_ffi.h | 24 + dash-spv-ffi/src/bin/ffi_cli.rs | 8 +- dash-spv-ffi/src/callbacks.rs | 9 + dash-spv-ffi/src/types.rs | 51 +- dash-spv-ffi/tests/dashd_sync/callbacks.rs | 9 +- dash-spv/ARCHITECTURE.md | 154 +- dash-spv/src/client/lifecycle.rs | 16 +- dash-spv/src/network/manager.rs | 3 + dash-spv/src/network/mod.rs | 16 + dash-spv/src/sync/blocks/manager.rs | 4 + dash-spv/src/sync/events.rs | 7 +- dash-spv/src/sync/identifier.rs | 3 + dash-spv/src/sync/mempool/filter.rs | 163 ++ dash-spv/src/sync/mempool/manager.rs | 1550 +++++++++++++++++ dash-spv/src/sync/mempool/mod.rs | 11 + dash-spv/src/sync/mempool/progress.rs | 171 ++ dash-spv/src/sync/mempool/sync_manager.rs | 616 +++++++ dash-spv/src/sync/mod.rs | 3 + dash-spv/src/sync/progress.rs | 21 +- dash-spv/src/sync/sync_coordinator.rs | 10 +- dash-spv/src/sync/sync_manager.rs | 6 +- dash-spv/src/test_utils/node.rs | 106 +- dash-spv/src/types.rs | 5 +- dash-spv/tests/dashd_sync/helpers.rs | 174 +- dash-spv/tests/dashd_sync/main.rs | 1 + dash-spv/tests/dashd_sync/setup.rs | 39 + dash-spv/tests/dashd_sync/tests_mempool.rs | 514 ++++++ key-wallet-manager/src/lib.rs | 2 +- key-wallet-manager/src/test_utils/wallet.rs | 105 +- key-wallet-manager/src/wallet_interface.rs | 31 +- .../src/wallet_manager/event_tests.rs | 255 +++ key-wallet-manager/src/wallet_manager/mod.rs | 26 + .../src/wallet_manager/process_block.rs | 173 +- 34 files changed, 4260 insertions(+), 55 deletions(-) create mode 100644 dash-spv/src/sync/mempool/filter.rs create mode 100644 dash-spv/src/sync/mempool/manager.rs create mode 100644 dash-spv/src/sync/mempool/mod.rs create mode 100644 dash-spv/src/sync/mempool/progress.rs create mode 100644 dash-spv/src/sync/mempool/sync_manager.rs create mode 100644 dash-spv/tests/dashd_sync/tests_mempool.rs diff --git a/dash-spv-ffi/FFI_API.md b/dash-spv-ffi/FFI_API.md index 8f4606063..4b1d4db22 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**: 49 +**Total Functions**: 50 ## Table of Contents @@ -13,6 +13,7 @@ This document provides a comprehensive reference for all FFI (Foreign Function I - [Synchronization](#synchronization) - [Wallet Operations](#wallet-operations) - [Transaction Management](#transaction-management) +- [Mempool Operations](#mempool-operations) - [Platform Integration](#platform-integration) - [Event Callbacks](#event-callbacks) - [Error Handling](#error-handling) @@ -82,6 +83,14 @@ Functions: 1 |----------|-------------|--------| | `dash_spv_ffi_client_broadcast_transaction` | Broadcasts a transaction to the Dash network via connected peers | client | +### Mempool Operations + +Functions: 1 + +| Function | Description | Module | +|----------|-------------|--------| +| `dash_spv_ffi_mempool_progress_destroy` | Destroy an `FFIMempoolProgress` object | types | + ### Platform Integration Functions: 2 @@ -558,6 +567,24 @@ Broadcasts a transaction to the Dash network via connected peers. # Safety - ` --- +### Mempool Operations - Detailed + +#### `dash_spv_ffi_mempool_progress_destroy` + +```c +dash_spv_ffi_mempool_progress_destroy(progress: *mut FFIMempoolProgress) -> () +``` + +**Description:** +Destroy an `FFIMempoolProgress` object. # Safety - `progress` must be a pointer returned from this crate, or null. + +**Safety:** +- `progress` must be a pointer returned from this crate, or null. + +**Module:** `types` + +--- + ### Platform Integration - Detailed #### `ffi_dash_spv_get_platform_activation_height` diff --git a/dash-spv-ffi/include/dash_spv_ffi.h b/dash-spv-ffi/include/dash_spv_ffi.h index c47776cdd..d032f829c 100644 --- a/dash-spv-ffi/include/dash_spv_ffi.h +++ b/dash-spv-ffi/include/dash_spv_ffi.h @@ -38,6 +38,7 @@ typedef enum FFIManagerId { Masternodes = 4, ChainLocks = 5, InstantSend = 6, + Mempool = 7, } FFIManagerId; typedef enum FFIMempoolStrategy { @@ -144,6 +145,18 @@ typedef struct FFIInstantSendProgress { uint64_t last_activity; } FFIInstantSendProgress; +/** + * Progress for mempool transaction monitoring. + */ +typedef struct FFIMempoolProgress { + enum FFISyncState state; + uint32_t received; + uint32_t relevant; + uint32_t tracked; + uint32_t removed; + uint64_t last_activity; +} FFIMempoolProgress; + /** * Aggregate progress for all sync managers. * Provides a complete view of the parallel sync system's state. @@ -162,6 +175,7 @@ typedef struct FFISyncProgress { struct FFIMasternodesProgress *masternodes; struct FFIChainLockProgress *chainlocks; struct FFIInstantSendProgress *instantsend; + struct FFIMempoolProgress *mempool; } FFISyncProgress; /** @@ -249,6 +263,8 @@ typedef void (*OnBlocksNeededCallback)(const struct FFIBlockNeeded *blocks, typedef void (*OnBlockProcessedCallback)(uint32_t height, const uint8_t (*hash)[32], uint32_t new_address_count, + const uint8_t (*confirmed_txids)[32], + uint32_t confirmed_txid_count, void *user_data); /** @@ -976,6 +992,14 @@ struct FFIResult ffi_dash_spv_get_platform_activation_height(struct FFIDashSpvCl */ void dash_spv_ffi_instantsend_progress_destroy(struct FFIInstantSendProgress *progress) ; +/** + * Destroy an `FFIMempoolProgress` object. + * + * # Safety + * - `progress` must be a pointer returned from this crate, or null. + */ + void dash_spv_ffi_mempool_progress_destroy(struct FFIMempoolProgress *progress) ; + /** * Destroy an `FFISyncProgress` object and all its nested pointers. * diff --git a/dash-spv-ffi/src/bin/ffi_cli.rs b/dash-spv-ffi/src/bin/ffi_cli.rs index 6d56ae4e5..d20eeeeb2 100644 --- a/dash-spv-ffi/src/bin/ffi_cli.rs +++ b/dash-spv-ffi/src/bin/ffi_cli.rs @@ -29,6 +29,7 @@ extern "C" fn on_sync_start(manager_id: FFIManagerId, _user_data: *mut c_void) { FFIManagerId::Masternodes => "Masternodes", FFIManagerId::ChainLocks => "ChainLocks", FFIManagerId::InstantSend => "InstantSend", + FFIManagerId::Mempool => "Mempool", }; println!("[Sync] Manager started: {}", manager_name); } @@ -76,9 +77,14 @@ extern "C" fn on_block_processed( height: u32, _hash: *const [u8; 32], new_address_count: u32, + _confirmed_txids: *const [u8; 32], + confirmed_txid_count: u32, _user_data: *mut c_void, ) { - println!("[Sync] Block processed: height={}, new_addresses={}", height, new_address_count); + println!( + "[Sync] Block processed: height={}, new_addresses={}, confirmed_txs={}", + height, new_address_count, confirmed_txid_count + ); } extern "C" fn on_masternode_state_updated(height: u32, _user_data: *mut c_void) { diff --git a/dash-spv-ffi/src/callbacks.rs b/dash-spv-ffi/src/callbacks.rs index 5ea4c2c6c..09076636d 100644 --- a/dash-spv-ffi/src/callbacks.rs +++ b/dash-spv-ffi/src/callbacks.rs @@ -27,6 +27,7 @@ pub enum FFIManagerId { Masternodes = 4, ChainLocks = 5, InstantSend = 6, + Mempool = 7, } impl From for FFIManagerId { @@ -39,6 +40,7 @@ impl From for FFIManagerId { dash_spv::sync::ManagerIdentifier::Masternode => FFIManagerId::Masternodes, dash_spv::sync::ManagerIdentifier::ChainLock => FFIManagerId::ChainLocks, dash_spv::sync::ManagerIdentifier::InstantSend => FFIManagerId::InstantSend, + dash_spv::sync::ManagerIdentifier::Mempool => FFIManagerId::Mempool, } } } @@ -162,6 +164,8 @@ pub type OnBlockProcessedCallback = Option< height: u32, hash: *const [u8; 32], new_address_count: u32, + confirmed_txids: *const [u8; 32], + confirmed_txid_count: u32, user_data: *mut c_void, ), >; @@ -350,13 +354,18 @@ impl FFISyncEventCallbacks { block_hash, height, new_addresses, + confirmed_txids, } => { if let Some(cb) = self.on_block_processed { let hash_bytes = block_hash.as_byte_array(); + let txid_bytes: Vec<[u8; 32]> = + confirmed_txids.iter().map(|txid| *txid.as_byte_array()).collect(); cb( *height, hash_bytes as *const [u8; 32], new_addresses.len() as u32, + txid_bytes.as_ptr(), + txid_bytes.len() as u32, self.user_data, ); } diff --git a/dash-spv-ffi/src/types.rs b/dash-spv-ffi/src/types.rs index e53a2a9b1..2fb791637 100644 --- a/dash-spv-ffi/src/types.rs +++ b/dash-spv-ffi/src/types.rs @@ -1,8 +1,8 @@ use dash_spv::client::config::MempoolStrategy; use dash_spv::sync::{ BlockHeadersProgress, BlocksProgress, ChainLockProgress, FilterHeadersProgress, - FiltersProgress, InstantSendProgress, MasternodesProgress, ProgressPercentage, SyncProgress, - SyncState, + FiltersProgress, InstantSendProgress, MasternodesProgress, MempoolProgress, ProgressPercentage, + SyncProgress, SyncState, }; use dash_spv::types::MempoolRemovalReason; use std::ffi::{CStr, CString}; @@ -259,6 +259,31 @@ impl From<&InstantSendProgress> for FFIInstantSendProgress { } } +/// Progress for mempool transaction monitoring. +#[repr(C)] +#[derive(Debug, Clone, Default)] +pub struct FFIMempoolProgress { + pub state: FFISyncState, + pub received: u32, + pub relevant: u32, + pub tracked: u32, + pub removed: u32, + pub last_activity: u64, +} + +impl From<&MempoolProgress> for FFIMempoolProgress { + fn from(progress: &MempoolProgress) -> Self { + FFIMempoolProgress { + state: progress.state().into(), + received: progress.received(), + relevant: progress.relevant(), + tracked: progress.tracked(), + removed: progress.removed(), + last_activity: progress.last_activity().elapsed().as_secs(), + } + } +} + /// Aggregate progress for all sync managers. /// Provides a complete view of the parallel sync system's state. #[repr(C)] @@ -274,6 +299,7 @@ pub struct FFISyncProgress { pub masternodes: *mut FFIMasternodesProgress, pub chainlocks: *mut FFIChainLockProgress, pub instantsend: *mut FFIInstantSendProgress, + pub mempool: *mut FFIMempoolProgress, } impl From for FFISyncProgress { @@ -320,6 +346,12 @@ impl From for FFISyncProgress { .map(|p| Box::into_raw(Box::new(FFIInstantSendProgress::from(p)))) .unwrap_or(std::ptr::null_mut()); + let mempool = progress + .mempool() + .ok() + .map(|p| Box::into_raw(Box::new(FFIMempoolProgress::from(p)))) + .unwrap_or(std::ptr::null_mut()); + Self { state: progress.state().into(), percentage: progress.percentage(), @@ -331,6 +363,7 @@ impl From for FFISyncProgress { masternodes, chainlocks, instantsend, + mempool, } } } @@ -486,6 +519,17 @@ pub unsafe extern "C" fn dash_spv_ffi_instantsend_progress_destroy( } } +/// Destroy an `FFIMempoolProgress` object. +/// +/// # Safety +/// - `progress` must be a pointer returned from this crate, or null. +#[no_mangle] +pub unsafe extern "C" fn dash_spv_ffi_mempool_progress_destroy(progress: *mut FFIMempoolProgress) { + if !progress.is_null() { + let _ = Box::from_raw(progress); + } +} + /// Destroy an `FFISyncProgress` object and all its nested pointers. /// /// # Safety @@ -517,5 +561,8 @@ pub unsafe extern "C" fn dash_spv_ffi_sync_progress_destroy(progress: *mut FFISy if !p.instantsend.is_null() { dash_spv_ffi_instantsend_progress_destroy(p.instantsend); } + if !p.mempool.is_null() { + dash_spv_ffi_mempool_progress_destroy(p.mempool); + } } } diff --git a/dash-spv-ffi/tests/dashd_sync/callbacks.rs b/dash-spv-ffi/tests/dashd_sync/callbacks.rs index 20ca342ff..56a8125b0 100644 --- a/dash-spv-ffi/tests/dashd_sync/callbacks.rs +++ b/dash-spv-ffi/tests/dashd_sync/callbacks.rs @@ -233,6 +233,8 @@ extern "C" fn on_block_processed( height: u32, _hash: *const [u8; 32], new_address_count: u32, + _confirmed_txids: *const [u8; 32], + confirmed_txid_count: u32, user_data: *mut c_void, ) { let Some(tracker) = (unsafe { tracker_from(user_data) }) else { @@ -240,7 +242,12 @@ extern "C" fn on_block_processed( }; tracker.processed_block_heights.lock().unwrap_or_else(|e| e.into_inner()).push(height); tracker.block_processed_count.fetch_add(1, Ordering::SeqCst); - tracing::debug!("on_block_processed: height={}, new_addresses={}", height, new_address_count); + tracing::debug!( + "on_block_processed: height={}, new_addresses={}, confirmed_txs={}", + height, + new_address_count, + confirmed_txid_count + ); } extern "C" fn on_masternode_state_updated(height: u32, user_data: *mut c_void) { diff --git a/dash-spv/ARCHITECTURE.md b/dash-spv/ARCHITECTURE.md index 6fbd17cd2..9ec363165 100644 --- a/dash-spv/ARCHITECTURE.md +++ b/dash-spv/ARCHITECTURE.md @@ -25,7 +25,7 @@ ### Current State: Production-Ready Structure ✅ **Code Organization: EXCELLENT (A+)** -- ✅ Parallel event-driven sync architecture with 7 independent managers +- ✅ Parallel event-driven sync architecture with 8 independent managers - ✅ SyncManager trait with standard event loop pattern - ✅ SyncEvent broadcast channel for inter-manager communication - ✅ client/: 8 modules (2,895 lines) @@ -59,7 +59,7 @@ |----------|-------|-------| | Total Files | 110+ | Well-organized module structure | | Total Lines | ~40,000 | All files appropriately sized | -| Sync Managers | 7 | Block headers, filter headers, filters, blocks, masternodes, chainlock, instantsend | +| Sync Managers | 8 | Block headers, filter headers, filters, blocks, masternodes, chainlock, instantsend, mempool | | Largest File | network/manager.rs | 1,322 lines - Acceptable complexity | | Module Count | 10+ | Well-separated concerns | @@ -91,13 +91,13 @@ │ ▼ ┌─────────────────────────────────────────────────────────┐ - │ Parallel Sync Managers (7) │ + │ Parallel Sync Managers (8) │ ├──────────────┬──────────────┬──────────────┬────────────┤ │ BlockHeaders │ FilterHeaders│ Filters │ Blocks │ │ Manager │ Manager │ Manager │ Manager │ ├──────────────┼──────────────┼──────────────┼────────────┤ - │ Masternodes │ ChainLock │ InstantSend │ │ - │ Manager │ Manager │ Manager │ │ + │ Masternodes │ ChainLock │ InstantSend │ Mempool │ + │ Manager │ Manager │ Manager │ Manager │ └──────────────┴──────────────┴──────────────┴────────────┘ │ ▼ @@ -141,8 +141,12 @@ │ FiltersManager ──BlocksNeeded──> BlocksManager │ │ │ │ BlocksManager ──BlockProcessed──> FiltersManager (for gap limit rescan) │ +│ ──BlockProcessed──> MempoolManager (confirmed tx removal)│ │ │ -│ SyncCoordinator ──SyncComplete──> External listeners │ +│ InstantSendManager ──InstantLockReceived──> MempoolManager │ +│ │ +│ SyncCoordinator ──SyncComplete──> MempoolManager (activation trigger) │ +│ ──SyncComplete──> External listeners │ └──────────────────────────────────────────────────────────────────────────┘ │ │ Progress (watch channels) @@ -976,7 +980,7 @@ storage/disk/ #### Overview -The sync module uses a parallel, event-driven architecture where 7 independent managers run concurrently in their own tokio tasks, communicating via a broadcast event channel. +The sync module uses a parallel, event-driven architecture where 8 independent managers run concurrently in their own tokio tasks, communicating via a broadcast event channel. #### Architecture Summary @@ -988,7 +992,8 @@ SyncCoordinator ├── BlocksManager - Downloads matched blocks, processes through wallet ├── MasternodesManager - Synchronizes masternode list via QRInfo/MnListDiff ├── ChainLockManager - Receives and validates ChainLocks -└── InstantSendManager - Receives and validates InstantLocks +├── InstantSendManager - Receives and validates InstantLocks +└── MempoolManager - Tracks unconfirmed wallet transactions via BIP37 or full-fetch ``` #### Core Components @@ -1041,10 +1046,10 @@ The trait provides a default `run()` implementation with the standard event loop | `FilterHeadersStored` | FilterHeadersManager | FiltersManager | | `FiltersSyncComplete` | FiltersManager | BlocksManager | | `BlocksNeeded` | FiltersManager | BlocksManager | -| `BlockProcessed` | BlocksManager | FiltersManager (gap limit rescan) | +| `BlockProcessed` | BlocksManager | FiltersManager (gap limit rescan), MempoolManager (confirmed tx removal) | | `ChainLockReceived` | ChainLockManager | External listeners | -| `InstantLockReceived` | InstantSendManager | External listeners | -| `SyncComplete` | Coordinator | External listeners | +| `InstantLockReceived` | InstantSendManager | MempoolManager (IS lock association) | +| `SyncComplete` | Coordinator | MempoolManager (activation trigger), External listeners | ##### `src/sync/progress.rs` - Aggregate Progress @@ -1111,6 +1116,132 @@ sync// - Validates signatures - Emits `InstantLockReceived` events +##### `src/sync/mempool/` - Mempool Transaction Tracking + +Tracks unconfirmed transactions relevant to the wallet in real time after chain sync completes. Unlike other managers that participate in the initial sync pipeline, the mempool manager is purely post-sync: it activates only after `SyncComplete` and runs continuously until shutdown. + +**Module structure:** +```text +sync/mempool/ +├── mod.rs - Module exports, bloom filter false-positive rate constant +├── manager.rs - Core state machine and transaction processing +├── sync_manager.rs - SyncManager trait implementation (event routing, tick logic) +├── bloom.rs - BIP37 bloom filter construction from wallet addresses/outpoints +└── progress.rs - Progress tracking (received, relevant, tracked, removed) +``` + +**Multi-peer activation:** + +The manager activates mempool relay on all connected peers simultaneously. When `SyncComplete` arrives, `activate_all_peers()` enables relay on every peer that has completed handshake. Peers connecting after activation are activated immediately if the manager is already in `Synced` state. + +Since the client connects with `relay=false`, peers won't send transaction INVs until explicitly enabled. Two strategies control how relay is enabled: + +- **BloomFilter**: Sends a BIP37 bloom filter containing wallet address hashes (P2PKH/P2SH hash160) and UTXO outpoints via `filterload` (which implicitly enables filtered relay), then `mempool`. The peer filters INV messages server-side, reducing bandwidth. The filter is rebuilt on all activated peers when new addresses are discovered during block processing. +- **FetchAll**: Sends `filterclear` (which enables unfiltered relay), then `mempool`. The manager checks wallet relevance locally. Higher bandwidth but no address leakage to peers. + +**Transaction processing pipeline:** + +```text +Peer INV(tx) + │ + ▼ +handle_inv() + ├─ Skip if: in seen_txids (180s dedup window), pending, queued, or in mempool state + ├─ Skip if: at capacity (max_transactions) + └─ Enqueue to announcing peer's queue + │ + ▼ +send_queued() (up to 100 in-flight getdata requests) + │ + ▼ +Peer TX + │ + ▼ +handle_tx() + ├─ Add txid to seen_txids (prevents re-download from other peers) + ├─ Check for pre-arrived InstantSend lock in pending_is_locks + ├─ wallet.process_mempool_transaction(tx, is_locked) + │ ├─ Not relevant → discard + │ └─ Relevant → store in MempoolState + │ ├─ Wallet emits BalanceUpdated event + │ └─ New addresses discovered → flag filter rebuild + └─ Return MempoolTransactionResult { is_relevant, net_amount, is_outgoing, addresses, new_addresses } +``` + +The `seen_txids` map provides a 180-second deduplication window to handle the case where multiple peers respond to the initial `mempool` request with overlapping INVs. + +**Events consumed:** + +| Event | Action | +|-------|--------| +| `SyncComplete` | Activate mempool relay on all connected peers (transitions to `Synced`) | +| `BlockProcessed` | Remove confirmed txids from mempool state; immediately rebuild bloom filter if new addresses | +| `InstantLockReceived` | Mark transaction as IS-locked, or store in pending_is_locks if TX not yet received | +| `PeerConnected` | Activate on new peer immediately if already synced | +| `PeerDisconnected` | Remove peer; redistribute its queued txids to a random activated peer | +| `PeersUpdated(0)` | All peers lost: call `stop_sync()`, transition to `WaitingForConnections` | + +**InstantSend lock handling:** + +IS locks can arrive before or after their corresponding transaction. Both orderings are handled: +- Lock after TX: set `is_instant_send` flag on stored transaction, notify wallet via `process_instant_send_lock` +- Lock before TX: store lock in `pending_is_locks` map; when the TX arrives via `handle_tx()`, it is processed with the IS flag already set + +Pending IS locks are pruned after 24 hours alongside expired transactions. + +**Bloom filter lifecycle:** + +Rebuilds happen immediately when the wallet state changes: +- On `handle_tx()` when a wallet-relevant transaction is received (new UTXOs, spent inputs, potentially new addresses from gap limit maintenance) +- On `BlockProcessed` with confirmed txids or new addresses, if the sync state is `Synced` (during initial sync, filter rebuilds are deferred until sync completes) + +The rebuild sequence on each activated peer is: `filterclear` → `filterload` (with updated wallet data) → `mempool` (re-request inventory with the new filter). + +**Periodic maintenance (tick):** + +| Action | Trigger | +|--------|---------| +| Prune expired transactions | Transactions older than 24 hours | +| Requeue timed-out requests | Getdata requests unanswered for 120s | +| Drain queued txids | Send getdata up to 100 in-flight limit | + +**Peer failover:** + +Each peer has its own txid queue (`None` = connected but inactive, `Some(VecDeque)` = activated). On disconnect: +- Peer with queued txids: redistribute to a random activated peer +- No activated peers remaining: queued items dropped with warning +- All peers lost (`PeersUpdated` with count 0): manager transitions to `WaitingForConnections`, then re-activates via `start_sync()` when peers return + +**Wallet integration:** + +The `WalletInterface` trait provides four methods for mempool support: + +| Method | Purpose | +|--------|---------| +| `process_mempool_transaction(tx, is_instant_send)` | Check relevance across all accounts, return net amount and new addresses | +| `monitored_addresses()` | All watched addresses for bloom filter construction | +| `watched_outpoints()` | All owned UTXOs for bloom filter spend detection | +| `process_instant_send_lock(txid)` | Mark UTXOs as IS-locked, transition balance to spendable | + +**Balance semantics:** + +`MempoolState` tracks two pending balance categories: +- `pending_balance`: regular unconfirmed transactions +- `pending_instant_balance`: IS-locked transactions (immediately spendable) + +The wallet emits `BalanceUpdated` events only when balance actually changes, with four categories: spendable, unconfirmed, immature, locked. + +**Capacity and limits:** + +| Parameter | Value | Purpose | +|-----------|-------|---------| +| `max_mempool_transactions` | configurable (default 1000) | Cap on tracked transactions | +| `MAX_IN_FLIGHT` | 100 | Max concurrent getdata requests | +| `MEMPOOL_TX_EXPIRY` | 24 hours | Auto-prune for unconfirmed transactions | +| `PENDING_REQUEST_TIMEOUT` | 120 seconds | Requeue unanswered getdata | +| `SEEN_TXID_EXPIRY` | 180 seconds | Dedup window for multi-peer INV overlap | +| `BLOOM_FALSE_POSITIVE_RATE` | 0.0005 (0.05%) | BIP37 filter false-positive rate | + #### Design Strengths - **True parallelism**: Headers, filters, and masternodes sync concurrently @@ -1134,3 +1265,4 @@ sync// | MasternodesManager | sync/masternodes/ | manager.rs, pipeline.rs, sync_manager.rs | Masternode list via QRInfo/MnListDiff | | ChainLockManager | sync/chainlock/ | manager.rs, sync_manager.rs | ChainLock message handling | | InstantSendManager | sync/instantsend/ | manager.rs, sync_manager.rs | InstantLock message handling | +| MempoolManager | sync/mempool/ | manager.rs, sync_manager.rs, bloom.rs, progress.rs | Post-sync mempool transaction tracking via BIP37 or full-fetch | diff --git a/dash-spv/src/client/lifecycle.rs b/dash-spv/src/client/lifecycle.rs index a8ceba656..210aece61 100644 --- a/dash-spv/src/client/lifecycle.rs +++ b/dash-spv/src/client/lifecycle.rs @@ -23,7 +23,7 @@ use crate::storage::{ }; use crate::sync::{ BlockHeadersManager, BlocksManager, ChainLockManager, FilterHeadersManager, FiltersManager, - InstantSendManager, Managers, MasternodesManager, SyncCoordinator, + InstantSendManager, Managers, MasternodesManager, MempoolManager, SyncCoordinator, }; use crate::types::MempoolState; use dashcore::sml::masternode_list_engine::MasternodeListEngine; @@ -122,10 +122,18 @@ impl DashSpvClient let storage = Arc::new(Mutex::new(storage)); diff --git a/dash-spv/src/network/manager.rs b/dash-spv/src/network/manager.rs index d4369efdc..01d7f4656 100644 --- a/dash-spv/src/network/manager.rs +++ b/dash-spv/src/network/manager.rs @@ -992,6 +992,9 @@ impl PeerNetworkManager { } let preferred_service = match &message { + NetworkMessage::FilterLoad(_) + | NetworkMessage::FilterClear + | NetworkMessage::MemPool => Some((ServiceFlags::BLOOM, true)), NetworkMessage::GetCFHeaders(_) | NetworkMessage::GetCFilters(_) => { Some((ServiceFlags::COMPACT_FILTERS, true)) } diff --git a/dash-spv/src/network/mod.rs b/dash-spv/src/network/mod.rs index 35695cdaa..78ced64dc 100644 --- a/dash-spv/src/network/mod.rs +++ b/dash-spv/src/network/mod.rs @@ -24,6 +24,7 @@ use crate::error::NetworkResult; use crate::NetworkError; use dashcore::network::message::NetworkMessage; use dashcore::network::message_blockdata::{GetHeadersMessage, Inventory}; +use dashcore::network::message_bloom::FilterLoad; use dashcore::network::message_filter::{GetCFHeaders, GetCFilters}; use dashcore::network::message_qrinfo::GetQRInfo; use dashcore::network::message_sml::GetMnListDiff; @@ -145,6 +146,21 @@ impl RequestSender { hashes.into_iter().map(Inventory::Block).collect(), )) } + + /// Send a filterload message to a specific peer. + pub fn send_filter_load(&self, filter_load: FilterLoad, peer: SocketAddr) -> NetworkResult<()> { + self.send_message_to_peer(NetworkMessage::FilterLoad(filter_load), peer) + } + + /// Send a filterclear message to a specific peer. + pub fn send_filter_clear(&self, peer: SocketAddr) -> NetworkResult<()> { + self.send_message_to_peer(NetworkMessage::FilterClear, peer) + } + + /// Send a mempool message to request inventory from a specific peer. + pub fn request_mempool(&self, peer: SocketAddr) -> NetworkResult<()> { + self.send_message_to_peer(NetworkMessage::MemPool, peer) + } } /// Network manager trait for abstracting network operations. diff --git a/dash-spv/src/sync/blocks/manager.rs b/dash-spv/src/sync/blocks/manager.rs index 34771d9e9..0b4ddfe27 100644 --- a/dash-spv/src/sync/blocks/manager.rs +++ b/dash-spv/src/sync/blocks/manager.rs @@ -100,6 +100,9 @@ impl BlocksManager = result.relevant_txids().cloned().collect(); + // Collect new addresses for gap limit rescanning let new_addresses: Vec<_> = result.new_addresses.into_iter().collect(); if !new_addresses.is_empty() { @@ -122,6 +125,7 @@ impl BlocksManager, + /// Transaction IDs confirmed in this block that are relevant to the wallet + confirmed_txids: Vec, }, /// Masternode state updated to a new height. diff --git a/dash-spv/src/sync/identifier.rs b/dash-spv/src/sync/identifier.rs index a09e3e8fd..048355073 100644 --- a/dash-spv/src/sync/identifier.rs +++ b/dash-spv/src/sync/identifier.rs @@ -10,6 +10,7 @@ pub enum ManagerIdentifier { Masternode, ChainLock, InstantSend, + Mempool, } impl Display for ManagerIdentifier { @@ -22,6 +23,7 @@ impl Display for ManagerIdentifier { ManagerIdentifier::Masternode => write!(f, "Masternode"), ManagerIdentifier::ChainLock => write!(f, "ChainLock"), ManagerIdentifier::InstantSend => write!(f, "InstantSend"), + ManagerIdentifier::Mempool => write!(f, "Mempool"), } } } @@ -39,5 +41,6 @@ mod tests { assert_eq!(ManagerIdentifier::Masternode.to_string(), "Masternode"); assert_eq!(ManagerIdentifier::ChainLock.to_string(), "ChainLock"); assert_eq!(ManagerIdentifier::InstantSend.to_string(), "InstantSend"); + assert_eq!(ManagerIdentifier::Mempool.to_string(), "Mempool"); } } diff --git a/dash-spv/src/sync/mempool/filter.rs b/dash-spv/src/sync/mempool/filter.rs new file mode 100644 index 000000000..c1b3e0229 --- /dev/null +++ b/dash-spv/src/sync/mempool/filter.rs @@ -0,0 +1,163 @@ +//! Bloom filter builder for wallet addresses and outpoints. +//! +//! Builds BIP37 bloom filters from wallet data for peer-side transaction filtering. + +use dashcore::address::Payload; +use dashcore::bloom::BloomFilter; +use dashcore::consensus::Encodable; +use dashcore::network::message_bloom::{BloomFlags, FilterLoad}; +use dashcore::{Address, OutPoint}; + +use crate::error::{SyncError, SyncResult}; + +/// Extract the raw hash payload bytes from an address for bloom filter insertion. +fn address_payload_bytes(addr: &Address) -> Option> { + match addr.payload() { + Payload::PubkeyHash(hash) => Some(<[u8; 20]>::from(*hash).to_vec()), + Payload::ScriptHash(hash) => Some(<[u8; 20]>::from(*hash).to_vec()), + _ => { + tracing::warn!("skipping unknown address type for bloom filter: {:?}", addr); + None + } + } +} + +/// Build a bloom filter from wallet addresses and outpoints. +/// +/// Addresses are inserted as their raw hash payload bytes (20-byte hash160 +/// for P2PKH/P2SH). This matches what Dash Core's `CheckScript` extracts as +/// data pushes from scriptPubKeys. +/// +/// Outpoints are inserted as consensus-serialized bytes (`txid || vout_le`) +/// to detect transactions spending our UTXOs. +pub(super) fn build_wallet_bloom_filter( + addresses: &[Address], + outpoints: &[OutPoint], + false_positive_rate: f64, + tweak: u32, +) -> SyncResult { + let element_count = addresses.len() + outpoints.len(); + if element_count == 0 { + let filter = BloomFilter::new(1, false_positive_rate, tweak, BloomFlags::All) + .map_err(|e| SyncError::Validation(e.to_string()))?; + return Ok(FilterLoad::from_bloom_filter(&filter)); + } + + let mut filter = + BloomFilter::new(element_count as u32, false_positive_rate, tweak, BloomFlags::All) + .map_err(|e| SyncError::Validation(e.to_string()))?; + + for addr in addresses { + if let Some(payload) = address_payload_bytes(addr) { + filter.insert(&payload); + } + } + + for outpoint in outpoints { + let mut buf = Vec::new(); + outpoint.consensus_encode(&mut buf).map_err(|e| SyncError::Validation(e.to_string()))?; + filter.insert(&buf); + } + + Ok(FilterLoad::from_bloom_filter(&filter)) +} + +#[cfg(test)] +mod tests { + use std::slice; + + use super::*; + use crate::sync::mempool::BLOOM_FALSE_POSITIVE_RATE; + use dashcore::hashes::Hash; + use dashcore::{Network, Txid}; + + fn test_addr(seed: usize) -> Address { + Address::dummy(Network::Testnet, seed) + } + + fn test_outpoint(seed: u8, vout: u32) -> OutPoint { + OutPoint { + txid: Txid::from_byte_array([seed; 32]), + vout, + } + } + + fn outpoint_bytes(outpoint: &OutPoint) -> Vec { + let mut buf = Vec::new(); + outpoint.consensus_encode(&mut buf).unwrap(); + buf + } + + fn build_filter(addrs: &[Address], outpoints: &[OutPoint]) -> FilterLoad { + build_wallet_bloom_filter(addrs, outpoints, BLOOM_FALSE_POSITIVE_RATE, 0).unwrap() + } + + #[test] + fn test_address_membership() { + let addr = test_addr(0); + let other = test_addr(1); + let filter = build_filter(slice::from_ref(&addr), &[]).to_bloom_filter().unwrap(); + + assert!(filter.contains(&address_payload_bytes(&addr).unwrap())); + assert!(!filter.contains(&address_payload_bytes(&other).unwrap())); + } + + #[test] + fn test_outpoint_membership() { + let outpoint = test_outpoint(1, 0); + let filter = build_filter(&[], &[outpoint]).to_bloom_filter().unwrap(); + + assert!(filter.contains(&outpoint_bytes(&outpoint))); + } + + #[test] + fn test_empty_inputs() { + let filter = build_filter(&[], &[]).to_bloom_filter().unwrap(); + assert!(!filter.contains(&[1, 2, 3])); + } + + fn test_p2sh_addr(seed: u8) -> Address { + // Build OP_HASH160 <20-byte-hash> OP_EQUAL script, then wrap as P2SH + let redeem_script = dashcore::ScriptBuf::from(vec![seed; 20]); + Address::p2sh(&redeem_script, Network::Testnet).unwrap() + } + + #[test] + fn test_p2sh_address_membership() { + let addr = test_p2sh_addr(0x42); + let other = test_p2sh_addr(0x43); + let filter = build_filter(slice::from_ref(&addr), &[]).to_bloom_filter().unwrap(); + + assert!(filter.contains(&address_payload_bytes(&addr).unwrap())); + assert!(!filter.contains(&address_payload_bytes(&other).unwrap())); + } + + #[test] + fn test_combined_addresses_and_outpoints() { + let addr1 = test_addr(0); + let addr2 = test_p2sh_addr(0x10); + let op1 = test_outpoint(1, 0); + let op2 = test_outpoint(2, 1); + + let filter = + build_filter(&[addr1.clone(), addr2.clone()], &[op1, op2]).to_bloom_filter().unwrap(); + + assert!(filter.contains(&address_payload_bytes(&addr1).unwrap())); + assert!(filter.contains(&address_payload_bytes(&addr2).unwrap())); + assert!(filter.contains(&outpoint_bytes(&op1))); + assert!(filter.contains(&outpoint_bytes(&op2))); + + // Random data should not match + assert!(!filter.contains(&[0xff; 20])); + } + + #[test] + fn test_rejects_invalid_fp_rates() { + let addr = test_addr(0); + let addrs = slice::from_ref(&addr); + + for rate in [0.0, -0.5, 1.0, 1.5] { + assert!(build_wallet_bloom_filter(addrs, &[], rate, 0).is_err()); + } + } +} diff --git a/dash-spv/src/sync/mempool/manager.rs b/dash-spv/src/sync/mempool/manager.rs new file mode 100644 index 000000000..63c0937e0 --- /dev/null +++ b/dash-spv/src/sync/mempool/manager.rs @@ -0,0 +1,1550 @@ +//! Mempool manager for monitoring unconfirmed transactions. +//! +//! Activates after initial sync is complete and uses either BIP37 bloom +//! filters or local address matching to identify wallet-relevant +//! transactions from the mempool. + +use std::collections::{HashMap, VecDeque}; +use std::fmt; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use dashcore::network::message_blockdata::Inventory; +use dashcore::{Amount, Transaction, Txid}; +use rand::seq::IteratorRandom; +use tokio::sync::RwLock; + +use super::filter::build_wallet_bloom_filter; +use super::BLOOM_FALSE_POSITIVE_RATE; +use crate::client::config::MempoolStrategy; +use crate::error::SyncResult; +use crate::network::RequestSender; +use crate::sync::mempool::MempoolProgress; +use crate::sync::SyncEvent; +use crate::types::{MempoolState, UnconfirmedTransaction}; +use key_wallet_manager::wallet_interface::WalletInterface; + +/// Timeout for pruning expired mempool transactions (24 hours). +pub(super) const MEMPOOL_TX_EXPIRY: Duration = Duration::from_secs(24 * 3600); + +/// Timeout for pending getdata requests that never received a response. +const PENDING_REQUEST_TIMEOUT: Duration = Duration::from_secs(120); + +/// Maximum number of in-flight getdata requests. +const MAX_IN_FLIGHT: usize = 100; + +/// Maximum number of pending IS locks awaiting their corresponding transaction. +const MAX_PENDING_IS_LOCKS: usize = 1000; + +/// How long a downloaded txid stays in the dedup map. +/// Covers the window where multiple peers respond to the initial `mempool` request. +const SEEN_TXID_EXPIRY: Duration = Duration::from_secs(180); + +/// Mempool manager that monitors unconfirmed transactions from the P2P network. +/// +/// Tracks connected peers via a unified map where: +/// - `None` = peer is connected but not yet activated (before sync completes) +/// - `Some(VecDeque)` = peer is activated (relay enabled), queue holds pending getdata txids +pub(crate) struct MempoolManager { + pub(super) progress: MempoolProgress, + pub(super) wallet: Arc>, + pub(super) mempool_state: Arc>, + strategy: MempoolStrategy, + max_transactions: usize, + /// Txids we have requested via getdata but not yet received, with request time. + pending_requests: HashMap, + /// Connected peers and their activation state. + pub(super) peers: HashMap>>, + /// IS lock txids that arrived before their corresponding transaction, with insertion time. + pending_is_locks: HashMap, + /// Txids already downloaded, with download timestamp. + /// Prevents duplicate downloads when multiple peers announce the same transactions. + /// Entries expire after `SEEN_TXID_EXPIRY`. + seen_txids: HashMap, +} + +impl MempoolManager { + /// Creates a new mempool manager with the given wallet, shared mempool state, + /// bloom filter strategy, and transaction capacity limit. + pub(crate) fn new( + wallet: Arc>, + mempool_state: Arc>, + strategy: MempoolStrategy, + max_transactions: usize, + ) -> Self { + Self { + progress: MempoolProgress::default(), + wallet, + mempool_state, + strategy, + max_transactions, + pending_requests: HashMap::new(), + peers: HashMap::new(), + pending_is_locks: HashMap::new(), + seen_txids: HashMap::new(), + } + } + + /// Activate mempool monitoring on a single peer. + /// + /// Since we connect with `relay=false`, peers won't send transaction INVs + /// until we explicitly enable relay: + /// - BloomFilter strategy: sends `filterload` (which enables filtered relay) + `mempool` + /// - FetchAll strategy: sends `filterclear` (which enables unfiltered relay) + `mempool` + pub(super) async fn activate_peer( + &mut self, + peer: SocketAddr, + requests: &RequestSender, + ) -> SyncResult<()> { + tracing::info!("Activating mempool on peer {} (strategy: {:?})", peer, self.strategy); + + match self.strategy { + MempoolStrategy::BloomFilter => { + self.load_bloom_filter(peer, requests).await?; + } + MempoolStrategy::FetchAll => { + requests.send_filter_clear(peer)?; + } + } + requests.request_mempool(peer)?; + + self.peers.insert(peer, Some(VecDeque::new())); + Ok(()) + } + + /// Activate mempool relay on all connected but not-yet-activated peers. + pub(super) async fn activate_all_peers(&mut self, requests: &RequestSender) -> SyncResult<()> { + let inactive: Vec = + self.peers.iter().filter(|(_, v)| v.is_none()).map(|(k, _)| *k).collect(); + for peer in inactive { + self.activate_peer(peer, requests).await?; + } + Ok(()) + } + + /// Build and send a bloom filter to the mempool peer. + async fn load_bloom_filter( + &mut self, + peer: SocketAddr, + requests: &RequestSender, + ) -> SyncResult<()> { + let wallet = self.wallet.read().await; + let addresses = wallet.monitored_addresses(); + let outpoints = wallet.watched_outpoints(); + drop(wallet); + + if addresses.is_empty() && outpoints.is_empty() { + tracing::debug!("No addresses or outpoints to build bloom filter from"); + return Ok(()); + } + + let filter_load = build_wallet_bloom_filter( + &addresses, + &outpoints, + BLOOM_FALSE_POSITIVE_RATE, + rand::random(), + )?; + + tracing::info!( + "Built bloom filter with {} addresses and {} outpoints (fp_rate={}, size={}B)", + addresses.len(), + outpoints.len(), + BLOOM_FALSE_POSITIVE_RATE, + filter_load.filter.len() + ); + + requests.send_filter_load(filter_load, peer)?; + + Ok(()) + } + + /// Rebuild the bloom filter on all activated peers. + pub(super) async fn rebuild_filter(&mut self, requests: &RequestSender) -> SyncResult<()> { + if self.strategy != MempoolStrategy::BloomFilter { + return Ok(()); + } + + let activated: Vec = + self.peers.iter().filter(|(_, v)| v.is_some()).map(|(k, _)| *k).collect(); + + if activated.is_empty() { + return Ok(()); + } + + for peer in activated { + requests.send_filter_clear(peer)?; + self.load_bloom_filter(peer, requests).await?; + requests.request_mempool(peer)?; + } + + Ok(()) + } + + /// Handle incoming inventory announcements. + /// + /// Filters for new transaction txids and enqueues them. The actual getdata + /// requests are sent by `send_queued()`, respecting the in-flight limit. + pub(super) async fn handle_inv( + &mut self, + inv: &[Inventory], + peer: SocketAddr, + requests: &RequestSender, + ) -> SyncResult> { + let mempool_full = + self.mempool_state.read().await.transactions.len() >= self.max_transactions; + if mempool_full { + return Ok(vec![]); + } + + let total_queued: usize = + self.peers.values().filter_map(|v| v.as_ref()).map(|q| q.len()).sum(); + let mut enqueued = 0; + for item in inv { + let Inventory::Transaction(txid) = item else { + continue; + }; + + if self.seen_txids.get(txid).is_some_and(|t| t.elapsed() < SEEN_TXID_EXPIRY) + || self.pending_requests.contains_key(txid) + || self.is_queued(txid) + || self.mempool_state.read().await.transactions.contains_key(txid) + { + continue; + } + if self.pending_requests.len() + total_queued + enqueued >= self.max_transactions { + break; + } + // Only queue on activated peers + if let Some(Some(queue)) = self.peers.get_mut(&peer) { + queue.push_back(*txid); + enqueued += 1; + } + } + + if enqueued > 0 { + tracing::debug!("Enqueued {} mempool txids for download", enqueued); + self.send_queued(requests).await?; + } + + Ok(vec![]) + } + + /// Drain per-peer queues and send getdata for up to `MAX_IN_FLIGHT` items. + /// + /// Deduplicates at send time against `pending_requests` and `mempool_state` + /// in case a transaction was received between enqueue and send. + pub(super) async fn send_queued(&mut self, requests: &RequestSender) -> SyncResult<()> { + let mut available = MAX_IN_FLIGHT.saturating_sub(self.pending_requests.len()); + let has_queued = self.peers.values().any(|v| v.as_ref().is_some_and(|q| !q.is_empty())); + if available == 0 || !has_queued { + return Ok(()); + } + + let now = Instant::now(); + let mut per_peer: HashMap> = HashMap::new(); + + let activated_peers: Vec = self + .peers + .iter() + .filter(|(_, v)| v.as_ref().is_some_and(|q| !q.is_empty())) + .map(|(k, _)| *k) + .collect(); + for peer in activated_peers { + if available == 0 { + break; + } + let Some(Some(queue)) = self.peers.get_mut(&peer) else { + continue; + }; + while available > 0 { + let Some(txid) = queue.pop_front() else { + break; + }; + if self.pending_requests.contains_key(&txid) + || self.mempool_state.read().await.transactions.contains_key(&txid) + { + continue; + } + self.pending_requests.insert(txid, now); + per_peer.entry(peer).or_default().push(Inventory::Transaction(txid)); + available -= 1; + } + } + + let total_queued: usize = + self.peers.values().filter_map(|v| v.as_ref()).map(|q| q.len()).sum(); + for (peer, inventory) in per_peer { + if inventory.is_empty() { + continue; + } + tracing::debug!( + "Requesting {} mempool transactions via getdata from {} ({} still queued)", + inventory.len(), + peer, + total_queued, + ); + requests.request_inventory(inventory, peer)?; + } + Ok(()) + } + + /// Handle a received transaction. + pub(super) async fn handle_tx( + &mut self, + tx: Transaction, + requests: &RequestSender, + ) -> SyncResult> { + let txid = tx.txid(); + self.pending_requests.remove(&txid); + self.seen_txids.insert(txid, Instant::now()); + self.progress.add_received(1); + + // Check for a pre-arrived IS lock before wallet processing consumes it + let is_locked = self.pending_is_locks.remove(&txid).is_some(); + + let result = { + let mut wallet = self.wallet.write().await; + wallet.process_mempool_transaction(&tx, is_locked).await + }; + + if !result.is_relevant { + return Ok(vec![]); + } + + self.progress.add_relevant(1); + tracing::info!("Wallet-relevant mempool transaction: {}", txid); + + // Build and store the unconfirmed transaction. + // The wallet already confirmed relevance, so we store unconditionally. + let unconfirmed_tx = UnconfirmedTransaction::new( + tx, + Amount::ZERO, + is_locked, + result.is_outgoing, + result.addresses, + result.net_amount, + ); + { + let mut state = self.mempool_state.write().await; + state.add_transaction(unconfirmed_tx); + self.progress.set_tracked(state.transactions.len() as u32); + } + + // Wallet-relevant transactions change the monitored set (new UTXOs, spent + // inputs, potentially new addresses from gap limit maintenance). + self.rebuild_filter(requests).await?; + + Ok(vec![]) + } + + /// Remove transactions from the mempool that have been confirmed in a block. + pub(super) async fn remove_confirmed(&mut self, txids: &[Txid]) { + self.seen_txids.retain(|_, t| t.elapsed() < SEEN_TXID_EXPIRY); + let mut removed = Vec::new(); + { + let mut state = self.mempool_state.write().await; + for txid in txids { + if state.remove_transaction(txid).is_some() { + removed.push(*txid); + } + } + if !removed.is_empty() { + self.progress.add_removed(removed.len() as u32); + self.progress.set_tracked(state.transactions.len() as u32); + tracing::debug!("Removed {} confirmed transactions from mempool", removed.len()); + } + } + } + + /// Mark a mempool transaction as InstantSend-locked and notify the wallet. + /// + /// If the transaction hasn't arrived yet, remembers the txid so the lock + /// can be applied when the transaction is later received via `handle_tx`. + pub(super) async fn mark_instant_send(&mut self, txid: &Txid) { + let mut state = self.mempool_state.write().await; + let marked = if let Some(tx) = state.transactions.get_mut(txid) { + tx.is_instant_send = true; + tracing::debug!("Marked mempool tx {} as InstantSend-locked", txid); + true + } else if self.pending_is_locks.len() < MAX_PENDING_IS_LOCKS { + self.pending_is_locks.insert(*txid, Instant::now()); + tracing::debug!("IS lock arrived before tx {}, remembering for later", txid); + false + } else { + tracing::warn!( + "Pending IS locks at capacity ({}), dropping IS lock for {}", + MAX_PENDING_IS_LOCKS, + txid + ); + false + }; + drop(state); + if marked { + let mut wallet = self.wallet.write().await; + wallet.process_instant_send_lock(*txid); + } + } + + /// Prune transactions and pending IS locks older than `timeout`. + pub(super) async fn prune_expired(&mut self, timeout: Duration) { + let mut state = self.mempool_state.write().await; + let pruned = state.prune_expired(timeout); + if !pruned.is_empty() { + self.progress.add_removed(pruned.len() as u32); + self.progress.set_tracked(state.transactions.len() as u32); + tracing::debug!("Pruned {} expired mempool transactions", pruned.len()); + for txid in &pruned { + self.pending_is_locks.remove(txid); + } + } + + // Prune pending IS locks whose transaction never arrived + let before = self.pending_is_locks.len(); + self.pending_is_locks.retain(|_, inserted_at| inserted_at.elapsed() < timeout); + let expired = before - self.pending_is_locks.len(); + if expired > 0 { + tracing::debug!("Pruned {} expired pending IS locks", expired); + } + } + + fn is_queued(&self, txid: &Txid) -> bool { + self.peers.values().filter_map(|v| v.as_ref()).any(|q| q.contains(txid)) + } + + /// Register a newly connected peer (not yet activated). + pub(super) fn handle_peer_connected(&mut self, peer: SocketAddr) { + self.peers.entry(peer).or_insert(None); + } + + /// Remove a disconnected peer, redistributing its queued txids to another activated peer. + pub(super) fn handle_peer_disconnected(&mut self, peer: SocketAddr) { + if let Some(Some(orphaned)) = self.peers.remove(&peer) { + if !orphaned.is_empty() { + let target = self + .peers + .iter_mut() + .filter(|(_, v)| v.is_some()) + .map(|(_, v)| v) + .choose(&mut rand::thread_rng()); + if let Some(Some(queue)) = target { + queue.extend(orphaned); + } else { + tracing::warn!( + "Dropped {} orphaned txids from disconnected peer {}: no activated peers available", + orphaned.len(), + peer + ); + } + } + } + } + + /// Clear all peer state, pending requests, and pending IS locks. + pub(super) fn clear_pending(&mut self) { + self.pending_requests.clear(); + self.peers.clear(); + self.pending_is_locks.clear(); + } + + /// Remove pending requests that have timed out without receiving a response. + /// Timed-out txids are re-queued to any connected peer for retry. + pub(super) fn prune_pending_requests(&mut self) { + let mut timed_out = Vec::new(); + self.pending_requests.retain(|txid, requested_at| { + if requested_at.elapsed() >= PENDING_REQUEST_TIMEOUT { + timed_out.push(*txid); + false + } else { + true + } + }); + if timed_out.is_empty() { + return; + } + tracing::debug!("Pruned {} timed-out pending requests, re-queuing", timed_out.len()); + let target = + self.peers.values_mut().filter_map(|v| v.as_mut()).choose(&mut rand::thread_rng()); + if let Some(queue) = target { + queue.extend(timed_out); + } else { + tracing::warn!( + "Dropped {} timed-out txids: no activated peers available for re-queue", + timed_out.len() + ); + } + } +} + +impl fmt::Debug for MempoolManager { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let activated = self.peers.values().filter(|v| v.is_some()).count(); + f.debug_struct("MempoolManager") + .field("progress", &self.progress) + .field("strategy", &self.strategy) + .field("pending_requests", &self.pending_requests.len()) + .field("peers", &self.peers.len()) + .field("activated_peers", &activated) + .field( + "queued", + &self.peers.values().filter_map(|v| v.as_ref()).map(|q| q.len()).sum::(), + ) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::network::NetworkRequest; + use dashcore::hashes::Hash; + use dashcore::network::message::NetworkMessage; + use dashcore::{Address, BlockHash, Network, ScriptBuf, Transaction}; + use key_wallet::transaction_checking::TransactionContext; + use key_wallet_manager::test_utils::MockWallet; + + use crate::sync::SyncState; + use crate::test_utils::test_socket_address; + use tokio::sync::mpsc; + + fn create_test_manager( + ) -> (MempoolManager, RequestSender, mpsc::UnboundedReceiver) { + let wallet = Arc::new(RwLock::new(MockWallet::new())); + let mempool_state = Arc::new(RwLock::new(MempoolState::default())); + let (tx, rx) = mpsc::unbounded_channel::(); + let requests = RequestSender::new(tx); + + let mut manager = + MempoolManager::new(wallet, mempool_state, MempoolStrategy::FetchAll, 1000); + manager.progress.set_state(SyncState::Synced); + + (manager, requests, rx) + } + + fn create_bloom_manager( + ) -> (MempoolManager, RequestSender, mpsc::UnboundedReceiver) { + let wallet = Arc::new(RwLock::new(MockWallet::new())); + let mempool_state = Arc::new(RwLock::new(MempoolState::default())); + let (tx, rx) = mpsc::unbounded_channel::(); + let requests = RequestSender::new(tx); + + let manager = + MempoolManager::new(wallet, mempool_state, MempoolStrategy::BloomFilter, 1000); + + (manager, requests, rx) + } + + #[tokio::test] + async fn test_activation_fetch_all() { + let peer = test_socket_address(1); + let (mut manager, requests, mut rx) = create_test_manager(); + manager.activate_peer(peer, &requests).await.unwrap(); + + // FetchAll activation sends filterclear then mempool to the chosen peer + let msg1 = rx.recv().await.unwrap(); + assert!( + matches!(msg1, NetworkRequest::SendMessageToPeer(NetworkMessage::FilterClear, p) if p == peer) + ); + let msg2 = rx.recv().await.unwrap(); + assert!( + matches!(msg2, NetworkRequest::SendMessageToPeer(NetworkMessage::MemPool, p) if p == peer) + ); + assert!(matches!(manager.peers.get(&peer), Some(Some(_)))); + } + + #[tokio::test] + async fn test_activation_bloom_filter_skips_empty_wallet() { + let (mut manager, requests, mut rx) = create_bloom_manager(); + manager.activate_peer(test_socket_address(1), &requests).await.unwrap(); + + // No addresses in mock wallet, so only MemPool should be sent (no FilterLoad) + let mut found_filter_load = false; + while let Ok(msg) = rx.try_recv() { + if matches!(msg, NetworkRequest::SendMessageToPeer(NetworkMessage::FilterLoad(_), _)) { + found_filter_load = true; + } + } + assert!(!found_filter_load, "should not send FilterLoad for empty wallet"); + } + + #[tokio::test] + async fn test_handle_inv_deduplication() { + let (mut manager, requests, _rx) = create_test_manager(); + let peer = test_socket_address(1); + manager.peers.insert(peer, Some(VecDeque::new())); + + let txid = Txid::from_byte_array([1u8; 32]); + let inv = vec![Inventory::Transaction(txid)]; + + // First call should add to pending + let events = manager.handle_inv(&inv, peer, &requests).await.unwrap(); + assert!(events.is_empty()); + assert!(manager.pending_requests.contains_key(&txid)); + + // Second call with same txid should be filtered out + let events = manager.handle_inv(&inv, peer, &requests).await.unwrap(); + assert!(events.is_empty()); + assert_eq!(manager.pending_requests.len(), 1); + } + + #[tokio::test] + async fn test_handle_inv_capacity_limit() { + let wallet = Arc::new(RwLock::new(MockWallet::new())); + let mempool_state = Arc::new(RwLock::new(MempoolState::default())); + let (tx, _rx) = mpsc::unbounded_channel::(); + let requests = RequestSender::new(tx); + + let mut manager = MempoolManager::new( + wallet, + mempool_state.clone(), + MempoolStrategy::FetchAll, + 2, // Very small capacity + ); + let peer = test_socket_address(1); + manager.peers.insert(peer, Some(VecDeque::new())); + + // Fill mempool to capacity + { + let mut state = mempool_state.write().await; + for i in 0..2u32 { + let tx = Transaction { + version: 1, + lock_time: i, + input: vec![], + output: vec![], + special_transaction_payload: None, + }; + state.add_transaction(UnconfirmedTransaction::new( + tx, + Amount::from_sat(0), + false, + false, + Vec::new(), + 0, + )); + } + } + + // New transactions should be filtered out + let new_txid = Txid::from_byte_array([99u8; 32]); + let inv = vec![Inventory::Transaction(new_txid)]; + let events = manager.handle_inv(&inv, peer, &requests).await.unwrap(); + assert!(events.is_empty()); + assert!(!manager.pending_requests.contains_key(&new_txid)); + } + + #[tokio::test] + async fn test_handle_inv_pending_requests_limit() { + let wallet = Arc::new(RwLock::new(MockWallet::new())); + let mempool_state = Arc::new(RwLock::new(MempoolState::default())); + let (tx, _rx) = mpsc::unbounded_channel::(); + let requests = RequestSender::new(tx); + + let mut manager = MempoolManager::new(wallet, mempool_state, MempoolStrategy::FetchAll, 2); + manager.progress.set_state(SyncState::Synced); + let peer = test_socket_address(1); + manager.peers.insert(peer, Some(VecDeque::new())); + + // Fill pending requests to capacity + let inv1: Vec = + (0..2).map(|i| Inventory::Transaction(Txid::from_byte_array([i; 32]))).collect(); + manager.handle_inv(&inv1, peer, &requests).await.unwrap(); + assert_eq!(manager.pending_requests.len(), 2); + + // Additional requests should be rejected when pending is at capacity + let extra_txid = Txid::from_byte_array([99; 32]); + let inv2 = vec![Inventory::Transaction(extra_txid)]; + manager.handle_inv(&inv2, peer, &requests).await.unwrap(); + assert!(!manager.pending_requests.contains_key(&extra_txid)); + } + + #[test] + fn test_prune_pending_requests_timeout() { + let wallet = Arc::new(RwLock::new(MockWallet::new())); + let mempool_state = Arc::new(RwLock::new(MempoolState::default())); + let (tx, _rx) = mpsc::unbounded_channel::(); + let _requests = RequestSender::new(tx); + + let mut manager = + MempoolManager::new(wallet, mempool_state, MempoolStrategy::FetchAll, 1000); + + let fresh_txid = Txid::from_byte_array([1; 32]); + let stale_txid = Txid::from_byte_array([2; 32]); + + manager.pending_requests.insert(fresh_txid, Instant::now()); + manager + .pending_requests + .insert(stale_txid, Instant::now() - PENDING_REQUEST_TIMEOUT - Duration::from_secs(1)); + + manager.prune_pending_requests(); + + assert!(manager.pending_requests.contains_key(&fresh_txid)); + assert!(!manager.pending_requests.contains_key(&stale_txid)); + } + + #[tokio::test] + async fn test_handle_tx_irrelevant() { + let (mut manager, requests, _rx) = create_test_manager(); + + let tx = Transaction { + version: 1, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }; + let txid = tx.txid(); + + let events = manager.handle_tx(tx, &requests).await.unwrap(); + // MockWallet returns is_relevant=false by default + assert!(events.is_empty()); + assert_eq!(manager.progress.received(), 1); + + // Irrelevant tx should not be stored in mempool state + let state = manager.mempool_state.read().await; + assert!(!state.transactions.contains_key(&txid)); + assert_eq!(manager.progress.relevant(), 0); + } + + #[tokio::test] + async fn test_handle_inv_non_transaction_filtered() { + let (mut manager, requests, _rx) = create_test_manager(); + let peer = test_socket_address(1); + manager.peers.insert(peer, Some(VecDeque::new())); + + let inv = vec![ + Inventory::Block(BlockHash::all_zeros()), + Inventory::Transaction(Txid::from_byte_array([1u8; 32])), + ]; + + let events = manager.handle_inv(&inv, peer, &requests).await.unwrap(); + assert!(events.is_empty()); + // Only the transaction should be tracked, not the block + assert_eq!(manager.pending_requests.len(), 1); + } + + #[tokio::test] + async fn test_prune_expired() { + let (mut manager, _requests, _rx) = create_test_manager(); + + let fresh_tx = Transaction { + version: 1, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }; + let fresh_txid = fresh_tx.txid(); + + let expired_tx = Transaction { + version: 1, + lock_time: 99, + input: vec![], + output: vec![], + special_transaction_payload: None, + }; + let expired_txid = expired_tx.txid(); + let test_timeout = Duration::from_secs(2); + + { + let mut state = manager.mempool_state.write().await; + state.add_transaction(UnconfirmedTransaction::new( + fresh_tx, + Amount::from_sat(0), + false, + false, + Vec::new(), + 0, + )); + let mut expired_utx = UnconfirmedTransaction::new( + expired_tx, + Amount::from_sat(0), + false, + false, + Vec::new(), + 0, + ); + expired_utx.first_seen = Instant::now() - test_timeout - Duration::from_secs(1); + state.add_transaction(expired_utx); + } + + manager.prune_expired(test_timeout).await; + + let state = manager.mempool_state.read().await; + assert_eq!(state.transactions.len(), 1); + assert!(state.transactions.contains_key(&fresh_txid)); + assert!(!state.transactions.contains_key(&expired_txid)); + drop(state); + assert_eq!(manager.progress.removed(), 1); + } + + /// Create a manager with BloomFilter strategy where the wallet reports + /// mempool transactions as relevant. BloomFilter strategy skips local + /// address pre-filtering, relying on the wallet for definitive checks. + fn create_relevant_manager( + ) -> (MempoolManager, RequestSender, Arc>) { + let mut mock = MockWallet::new(); + mock.set_mempool_relevant(true); + let wallet = Arc::new(RwLock::new(mock)); + let mempool_state = Arc::new(RwLock::new(MempoolState::default())); + let (tx, _rx) = mpsc::unbounded_channel::(); + let requests = RequestSender::new(tx); + + let manager = + MempoolManager::new(wallet.clone(), mempool_state, MempoolStrategy::BloomFilter, 1000); + + (manager, requests, wallet) + } + + #[tokio::test] + async fn test_handle_tx_relevant_stores_transaction() { + let (mut manager, requests, _wallet) = create_relevant_manager(); + + let tx = Transaction { + version: 1, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }; + let txid = tx.txid(); + + let events = manager.handle_tx(tx, &requests).await.unwrap(); + assert!(events.is_empty()); + + // Verify transaction was stored in mempool state + let state = manager.mempool_state.read().await; + assert!(state.transactions.contains_key(&txid)); + assert_eq!(manager.progress.received(), 1); + assert_eq!(manager.progress.relevant(), 1); + assert_eq!(manager.progress.tracked(), 1); + } + + #[tokio::test] + async fn test_handle_tx_clears_pending_request() { + let (mut manager, requests, _wallet) = create_relevant_manager(); + + let tx = Transaction { + version: 1, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }; + let txid = tx.txid(); + + // Simulate that we requested this transaction + manager.pending_requests.insert(txid, Instant::now()); + assert!(manager.pending_requests.contains_key(&txid)); + + manager.handle_tx(tx, &requests).await.unwrap(); + // Pending request should be cleared regardless of relevance + assert!(!manager.pending_requests.contains_key(&txid)); + + // Since the manager uses BloomFilter strategy (relevant mock), tx should be stored + let state = manager.mempool_state.read().await; + assert!(state.transactions.contains_key(&txid)); + } + + fn create_bloom_manager_with_addresses( + addresses: Vec
, + ) -> (MempoolManager, RequestSender, mpsc::UnboundedReceiver) { + let mut mock = MockWallet::new(); + mock.set_addresses(addresses); + let wallet = Arc::new(RwLock::new(mock)); + let mempool_state = Arc::new(RwLock::new(MempoolState::default())); + let (tx, rx) = mpsc::unbounded_channel::(); + let requests = RequestSender::new(tx); + + let manager = + MempoolManager::new(wallet, mempool_state, MempoolStrategy::BloomFilter, 1000); + + (manager, requests, rx) + } + + /// Create a test P2PKH address from a byte pattern. + fn test_address(byte: u8) -> Address { + // Build OP_DUP OP_HASH160 <20-byte-hash> OP_EQUALVERIFY OP_CHECKSIG + let mut script_bytes = vec![0x76, 0xa9, 0x14]; // OP_DUP OP_HASH160 PUSH20 + script_bytes.extend_from_slice(&[byte; 20]); + script_bytes.push(0x88); // OP_EQUALVERIFY + script_bytes.push(0xac); // OP_CHECKSIG + let script = ScriptBuf::from(script_bytes); + Address::from_script(&script, Network::Testnet).unwrap() + } + + #[tokio::test] + async fn test_bloom_filter_loaded_with_addresses() { + let addr = test_address(0xab); + + let (mut manager, requests, mut rx) = create_bloom_manager_with_addresses(vec![addr]); + manager.activate_peer(test_socket_address(1), &requests).await.unwrap(); + + let mut found_filter_load = false; + while let Ok(msg) = rx.try_recv() { + if matches!(msg, NetworkRequest::SendMessageToPeer(NetworkMessage::FilterLoad(_), _)) { + found_filter_load = true; + } + } + assert!(found_filter_load, "expected FilterLoad for wallet with addresses"); + } + + #[tokio::test] + async fn test_mark_instant_send_emits_status_change() { + let (mut manager, _requests, _rx) = create_test_manager(); + + let tx = Transaction { + version: 1, + lock_time: 42, + input: vec![], + output: vec![], + special_transaction_payload: None, + }; + let txid = tx.txid(); + { + let mut state = manager.mempool_state.write().await; + state.add_transaction(UnconfirmedTransaction::new( + tx, + Amount::from_sat(0), + false, + false, + Vec::new(), + 0, + )); + } + + manager.mark_instant_send(&txid).await; + + // Verify mempool state also reflects IS flag + let state = manager.mempool_state.read().await; + assert!(state.transactions.get(&txid).unwrap().is_instant_send); + drop(state); + + let wallet = manager.wallet.read().await; + let status_changes = wallet.status_changes(); + let changes = status_changes.lock().await; + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].0, txid); + assert_eq!(changes[0].1, TransactionContext::InstantSend); + } + + #[tokio::test] + async fn test_mark_instant_send_stores_pending_for_unknown() { + let (mut manager, _requests, _rx) = create_test_manager(); + + let unknown_txid = Txid::from_byte_array([0xbb; 32]); + manager.mark_instant_send(&unknown_txid).await; + + // No immediate wallet notification + let wallet = manager.wallet.read().await; + let status_changes = wallet.status_changes(); + let changes = status_changes.lock().await; + assert!(changes.is_empty()); + + // But the txid is remembered for when the transaction arrives + assert!(manager.pending_is_locks.contains_key(&unknown_txid)); + } + + #[tokio::test] + async fn test_in_flight_limit() { + let (mut manager, requests, _rx) = create_test_manager(); + let peer = test_socket_address(1); + manager.peers.insert(peer, Some(VecDeque::new())); + + // Send 200 INVs — only MAX_IN_FLIGHT should go to pending, rest queued + let inv: Vec = (0..200u16) + .map(|i| { + let mut bytes = [0u8; 32]; + bytes[0..2].copy_from_slice(&i.to_le_bytes()); + Inventory::Transaction(Txid::from_byte_array(bytes)) + }) + .collect(); + + manager.handle_inv(&inv, peer, &requests).await.unwrap(); + assert_eq!(manager.pending_requests.len(), MAX_IN_FLIGHT); + assert_eq!( + manager.peers.values().filter_map(|v| v.as_ref()).map(|q| q.len()).sum::(), + 100 + ); + } + + #[tokio::test] + async fn test_send_queued_drains_after_response() { + let (mut manager, requests, _rx) = create_test_manager(); + let peer = test_socket_address(1); + manager.peers.insert(peer, Some(VecDeque::new())); + + // Fill with 150 INVs + let inv: Vec = (0..150u16) + .map(|i| { + let mut bytes = [0u8; 32]; + bytes[0..2].copy_from_slice(&i.to_le_bytes()); + Inventory::Transaction(Txid::from_byte_array(bytes)) + }) + .collect(); + + manager.handle_inv(&inv, peer, &requests).await.unwrap(); + assert_eq!(manager.pending_requests.len(), MAX_IN_FLIGHT); + assert_eq!( + manager.peers.values().filter_map(|v| v.as_ref()).map(|q| q.len()).sum::(), + 50 + ); + + // Simulate receiving 10 responses (freeing 10 slots) + let pending_txids: Vec = manager.pending_requests.keys().take(10).copied().collect(); + for txid in &pending_txids { + manager.pending_requests.remove(txid); + } + assert_eq!(manager.pending_requests.len(), 90); + + // send_queued should fill the freed slots + manager.send_queued(&requests).await.unwrap(); + assert_eq!(manager.pending_requests.len(), MAX_IN_FLIGHT); + assert_eq!( + manager.peers.values().filter_map(|v| v.as_ref()).map(|q| q.len()).sum::(), + 40 + ); + } + + #[tokio::test] + async fn test_send_queued_skips_already_received() { + let (mut manager, requests, _rx) = create_test_manager(); + let peer = test_socket_address(1); + + // Create a real transaction and get its actual txid + let tx = Transaction { + version: 1, + lock_time: 0xaa, + input: vec![], + output: vec![], + special_transaction_payload: None, + }; + let txid = tx.txid(); + + // Enqueue the txid on an activated peer + manager.peers.insert(peer, Some(VecDeque::from([txid]))); + + // Simulate the transaction arriving in mempool_state before send + { + let mut state = manager.mempool_state.write().await; + state.add_transaction(UnconfirmedTransaction::new( + tx, + Amount::from_sat(0), + false, + false, + Vec::new(), + 0, + )); + } + + manager.send_queued(&requests).await.unwrap(); + // Txid should have been skipped, not added to pending + assert!(manager.pending_requests.is_empty()); + assert!(manager.peers.values().filter_map(|v| v.as_ref()).all(|q| q.is_empty())); + } + + #[test] + fn test_clear_pending_clears_queue() { + let (mut manager, _requests, _rx) = create_test_manager(); + + manager.pending_requests.insert(Txid::from_byte_array([1; 32]), Instant::now()); + manager + .peers + .insert(test_socket_address(1), Some(VecDeque::from([Txid::from_byte_array([2; 32])]))); + manager.pending_is_locks.insert(Txid::from_byte_array([3; 32]), Instant::now()); + + manager.clear_pending(); + + assert!(manager.pending_requests.is_empty()); + assert!(manager.peers.is_empty()); + assert!(manager.pending_is_locks.is_empty()); + } + + #[tokio::test] + async fn test_send_queued_noop_at_capacity() { + let (mut manager, requests, _rx) = create_test_manager(); + + // Fill pending to MAX_IN_FLIGHT + for i in 0..MAX_IN_FLIGHT as u16 { + let mut bytes = [0u8; 32]; + bytes[0..2].copy_from_slice(&i.to_le_bytes()); + manager.pending_requests.insert(Txid::from_byte_array(bytes), Instant::now()); + } + + // Add something to the queue on an activated peer + manager.peers.insert( + test_socket_address(1), + Some(VecDeque::from([Txid::from_byte_array([0xff; 32])])), + ); + + manager.send_queued(&requests).await.unwrap(); + // Queue should remain unchanged (one peer with one txid) + assert_eq!( + manager.peers.values().filter_map(|v| v.as_ref()).map(|q| q.len()).sum::(), + 1 + ); + assert_eq!(manager.pending_requests.len(), MAX_IN_FLIGHT); + } + + #[tokio::test] + async fn test_instant_send_before_transaction() { + let (mut manager, requests, _wallet) = create_relevant_manager(); + + let tx = Transaction { + version: 1, + lock_time: 77, + input: vec![], + output: vec![], + special_transaction_payload: None, + }; + let txid = tx.txid(); + + // IS lock arrives before the transaction + manager.mark_instant_send(&txid).await; + assert!(manager.pending_is_locks.contains_key(&txid)); + + // Transaction arrives + manager.handle_tx(tx, &requests).await.unwrap(); + + // Pending IS lock consumed + assert!(manager.pending_is_locks.is_empty()); + + // Transaction stored with IS flag set + let state = manager.mempool_state.read().await; + assert!(state.transactions.get(&txid).unwrap().is_instant_send); + } + + #[tokio::test] + async fn test_instant_send_before_irrelevant_transaction() { + let (mut manager, requests, _rx) = create_test_manager(); + + let tx = Transaction { + version: 1, + lock_time: 88, + input: vec![], + output: vec![], + special_transaction_payload: None, + }; + let txid = tx.txid(); + + // IS lock arrives before the transaction + manager.mark_instant_send(&txid).await; + assert!(manager.pending_is_locks.contains_key(&txid)); + + // Transaction arrives but wallet says it's not relevant + manager.handle_tx(tx, &requests).await.unwrap(); + + // Pending IS lock cleaned up (no leak) + assert!(manager.pending_is_locks.is_empty()); + + // Irrelevant tx should not be stored in mempool state + let state = manager.mempool_state.read().await; + assert!(!state.transactions.contains_key(&txid)); + } + + #[tokio::test] + async fn test_pending_is_locks_capacity_limit() { + let (mut manager, _requests, _rx) = create_test_manager(); + + // Fill pending IS locks to capacity + for i in 0..MAX_PENDING_IS_LOCKS { + let mut bytes = [0u8; 32]; + bytes[0..8].copy_from_slice(&(i as u64).to_le_bytes()); + manager.pending_is_locks.insert(Txid::from_byte_array(bytes), Instant::now()); + } + assert_eq!(manager.pending_is_locks.len(), MAX_PENDING_IS_LOCKS); + + // Next IS lock should be dropped + let overflow_txid = Txid::from_byte_array([0xff; 32]); + manager.mark_instant_send(&overflow_txid).await; + assert!(!manager.pending_is_locks.contains_key(&overflow_txid)); + assert_eq!(manager.pending_is_locks.len(), MAX_PENDING_IS_LOCKS); + } + + #[tokio::test] + async fn test_prune_expired_removes_is_lock_for_expired_tx() { + let (mut manager, _requests, _rx) = create_test_manager(); + + let tx = Transaction { + version: 1, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }; + let txid = tx.txid(); + + let test_timeout = Duration::from_secs(2); + + // Add the tx with a timestamp in the past so it expires + { + let mut state = manager.mempool_state.write().await; + let mut utx = + UnconfirmedTransaction::new(tx, Amount::from_sat(0), false, false, Vec::new(), 0); + utx.first_seen = Instant::now() - test_timeout - Duration::from_secs(1); + state.add_transaction(utx); + } + + // Also store a pending IS lock for this txid and an unrelated one + let unrelated_txid = Txid::from_byte_array([0xdd; 32]); + manager.pending_is_locks.insert(txid, Instant::now()); + manager.pending_is_locks.insert(unrelated_txid, Instant::now()); + + manager.prune_expired(test_timeout).await; + + // The expired tx's IS lock should be removed + assert!( + !manager.pending_is_locks.contains_key(&txid), + "IS lock for expired tx should be removed" + ); + // The unrelated IS lock should be preserved + assert!( + manager.pending_is_locks.contains_key(&unrelated_txid), + "IS lock for non-expired tx should be preserved" + ); + } + + #[tokio::test] + async fn test_prune_expired_removes_stale_pending_is_locks() { + let (mut manager, _requests, _rx) = create_test_manager(); + + let test_timeout = Duration::from_secs(2); + + // Insert a pending IS lock that is older than the test timeout + let stale_txid = Txid::from_byte_array([0xaa; 32]); + manager + .pending_is_locks + .insert(stale_txid, Instant::now() - test_timeout - Duration::from_secs(1)); + + // Insert a fresh pending IS lock + let fresh_txid = Txid::from_byte_array([0xbb; 32]); + manager.pending_is_locks.insert(fresh_txid, Instant::now()); + + manager.prune_expired(test_timeout).await; + + assert!( + !manager.pending_is_locks.contains_key(&stale_txid), + "stale pending IS lock should be pruned" + ); + assert!( + manager.pending_is_locks.contains_key(&fresh_txid), + "fresh pending IS lock should be preserved" + ); + } + + #[tokio::test] + async fn test_handle_inv_dedup_against_queue() { + let (mut manager, requests, _rx) = create_test_manager(); + let peer = test_socket_address(1); + manager.peers.insert(peer, Some(VecDeque::new())); + + // Fill pending to capacity so items go to queue + for i in 0..MAX_IN_FLIGHT as u16 { + let mut bytes = [0u8; 32]; + bytes[0..2].copy_from_slice(&i.to_le_bytes()); + manager.pending_requests.insert(Txid::from_byte_array(bytes), Instant::now()); + } + + let txid = Txid::from_byte_array([0xff; 32]); + let inv = vec![Inventory::Transaction(txid)]; + + // First call enqueues + manager.handle_inv(&inv, peer, &requests).await.unwrap(); + assert_eq!( + manager.peers.values().filter_map(|v| v.as_ref()).map(|q| q.len()).sum::(), + 1 + ); + + // Second call with same txid should be deduped + manager.handle_inv(&inv, peer, &requests).await.unwrap(); + assert_eq!( + manager.peers.values().filter_map(|v| v.as_ref()).map(|q| q.len()).sum::(), + 1 + ); + } + + #[tokio::test] + async fn test_bloom_filter_load_failure_propagates() { + let addr = test_address(0xab); + let mut mock = MockWallet::new(); + mock.set_addresses(vec![addr]); + let wallet = Arc::new(RwLock::new(mock)); + let mempool_state = Arc::new(RwLock::new(MempoolState::default())); + let (tx, rx) = mpsc::unbounded_channel::(); + let requests = RequestSender::new(tx); + + let mut manager = + MempoolManager::new(wallet, mempool_state, MempoolStrategy::BloomFilter, 1000); + + // Drop receiver so send_filter_load fails + drop(rx); + + // activate() should propagate the error + let result = manager.activate_peer(test_socket_address(1), &requests).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_handle_tx_relevant_populates_wallet_effect_fields() { + let (mut manager, requests, wallet) = create_relevant_manager(); + + let tx = Transaction { + version: 1, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }; + let txid = tx.txid(); + + // Set effect data on the mock wallet before handle_tx + { + let w = wallet.read().await; + w.set_effect(txid, 50000, vec!["yWdXnYxGbouNoo8yMvcbZmZ3Gdp6BpySxL".into()]).await; + } + + manager.handle_tx(tx, &requests).await.unwrap(); + + let state = manager.mempool_state.read().await; + let stored = state.transactions.get(&txid).unwrap(); + assert_eq!(stored.net_amount, 50000); + assert!(!stored.is_outgoing); + assert!(!stored.is_instant_send); + assert_eq!(stored.addresses.len(), 1); + assert_eq!(stored.addresses[0].to_string(), "yWdXnYxGbouNoo8yMvcbZmZ3Gdp6BpySxL"); + } + + #[tokio::test] + async fn test_handle_tx_outgoing_transaction() { + let (mut manager, requests, wallet) = create_relevant_manager(); + + let tx = Transaction { + version: 1, + lock_time: 123, + input: vec![], + output: vec![], + special_transaction_payload: None, + }; + let txid = tx.txid(); + + { + let w = wallet.read().await; + w.set_effect(txid, -30000, vec![]).await; + } + + manager.handle_tx(tx, &requests).await.unwrap(); + + let state = manager.mempool_state.read().await; + let stored = state.transactions.get(&txid).unwrap(); + assert_eq!(stored.net_amount, -30000); + assert!(stored.is_outgoing); + assert!(!stored.is_instant_send); + assert!(stored.addresses.is_empty()); + } + + #[test] + fn test_peer_connected_creates_entry() { + let (mut manager, _requests, _rx) = create_test_manager(); + let peer = test_socket_address(1); + + assert!(!manager.peers.contains_key(&peer)); + manager.handle_peer_connected(peer); + assert!(manager.peers.contains_key(&peer)); + assert!(manager.peers[&peer].is_none()); + } + + #[test] + fn test_peer_disconnected_redistributes_queue() { + let (mut manager, _requests, _rx) = create_test_manager(); + let peer1 = test_socket_address(1); + let peer2 = test_socket_address(2); + + // Both peers activated with queues + let txid1 = Txid::from_byte_array([1; 32]); + let txid2 = Txid::from_byte_array([2; 32]); + manager.peers.insert(peer1, Some(VecDeque::from([txid1, txid2]))); + manager.peers.insert(peer2, Some(VecDeque::new())); + + manager.handle_peer_disconnected(peer1); + + assert!(!manager.peers.contains_key(&peer1)); + // Txids should have moved to peer2 + let peer2_queue = manager.peers[&peer2].as_ref().unwrap(); + assert!(peer2_queue.contains(&txid1)); + assert!(peer2_queue.contains(&txid2)); + } + + #[test] + fn test_peer_disconnected_no_peers_drops_queue() { + let (mut manager, _requests, _rx) = create_test_manager(); + let peer = test_socket_address(1); + + manager.peers.insert(peer, Some(VecDeque::from([Txid::from_byte_array([1; 32])]))); + + manager.handle_peer_disconnected(peer); + + assert!(manager.peers.is_empty()); + } + + #[test] + fn test_prune_pending_requeues_to_activated_peer() { + let (mut manager, _requests, _rx) = create_test_manager(); + let peer = test_socket_address(1); + manager.peers.insert(peer, Some(VecDeque::new())); + + let txid = Txid::from_byte_array([1; 32]); + manager + .pending_requests + .insert(txid, Instant::now() - PENDING_REQUEST_TIMEOUT - Duration::from_secs(1)); + + manager.prune_pending_requests(); + + assert!(!manager.pending_requests.contains_key(&txid)); + assert!(manager.peers[&peer].as_ref().unwrap().contains(&txid)); + } + + #[test] + fn test_prune_pending_drops_when_no_peers() { + let (mut manager, _requests, _rx) = create_test_manager(); + + let txid = Txid::from_byte_array([1; 32]); + manager + .pending_requests + .insert(txid, Instant::now() - PENDING_REQUEST_TIMEOUT - Duration::from_secs(1)); + + manager.prune_pending_requests(); + + assert!(!manager.pending_requests.contains_key(&txid)); + assert!(manager.peers.is_empty()); + } + + #[tokio::test] + async fn test_remove_confirmed_removes_txids() { + let (mut manager, _requests, _rx) = create_test_manager(); + + let mut txids = Vec::new(); + { + let mut state = manager.mempool_state.write().await; + for i in 0..3u32 { + let tx = Transaction { + version: 1, + lock_time: i, + input: vec![], + output: vec![], + special_transaction_payload: None, + }; + let txid = tx.txid(); + txids.push(txid); + state.add_transaction(UnconfirmedTransaction::new( + tx, + Amount::from_sat(0), + false, + false, + Vec::new(), + 0, + )); + } + assert_eq!(state.transactions.len(), 3); + } + + // Remove 2 of the 3 transactions + manager.remove_confirmed(&txids[..2]).await; + + let state = manager.mempool_state.read().await; + assert_eq!(state.transactions.len(), 1); + assert!(state.transactions.contains_key(&txids[2])); + drop(state); + + assert_eq!(manager.progress.removed(), 2); + assert_eq!(manager.progress.tracked(), 1); + } + + #[tokio::test] + async fn test_remove_confirmed_unknown_txids_noop() { + let (mut manager, _requests, _rx) = create_test_manager(); + + let unknown = vec![Txid::from_byte_array([0xaa; 32]), Txid::from_byte_array([0xbb; 32])]; + + manager.remove_confirmed(&unknown).await; + + let state = manager.mempool_state.read().await; + assert!(state.transactions.is_empty()); + assert_eq!(manager.progress.removed(), 0); + } + + #[tokio::test] + async fn test_rebuild_filter_clears_and_reloads() { + let addr = test_address(0xab); + let (mut manager, requests, mut rx) = create_bloom_manager_with_addresses(vec![addr]); + let peer = test_socket_address(1); + + manager.activate_peer(peer, &requests).await.unwrap(); + + // Drain activation messages + while rx.try_recv().is_ok() {} + + manager.rebuild_filter(&requests).await.unwrap(); + + // Verify message sequence: FilterClear, FilterLoad, MemPool + let msg1 = rx.try_recv().unwrap(); + assert!(matches!(msg1, NetworkRequest::SendMessageToPeer(NetworkMessage::FilterClear, _))); + let msg2 = rx.try_recv().unwrap(); + assert!(matches!( + msg2, + NetworkRequest::SendMessageToPeer(NetworkMessage::FilterLoad(_), _) + )); + let msg3 = rx.try_recv().unwrap(); + assert!(matches!(msg3, NetworkRequest::SendMessageToPeer(NetworkMessage::MemPool, _))); + } + + #[tokio::test] + async fn test_rebuild_filter_no_activated_peers_noop() { + let (mut manager, requests, mut rx) = create_bloom_manager(); + // No activation, so no activated peers + assert!(manager.peers.values().all(|v| v.is_none())); + + manager.rebuild_filter(&requests).await.unwrap(); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn test_seen_txids_deduplication_window() { + let (mut manager, requests, _rx) = create_test_manager(); + let peer = test_socket_address(1); + manager.peers.insert(peer, Some(VecDeque::new())); + + let txid = Txid::from_byte_array([1u8; 32]); + let inv = vec![Inventory::Transaction(txid)]; + + // A fresh seen_txids entry should cause handle_inv to skip the txid + manager.seen_txids.insert(txid, Instant::now()); + manager.handle_inv(&inv, peer, &requests).await.unwrap(); + assert!(manager.pending_requests.is_empty(), "seen txid should be skipped"); + + // An expired entry should allow the txid to be accepted again + manager.seen_txids.insert(txid, Instant::now() - SEEN_TXID_EXPIRY - Duration::from_secs(1)); + manager.handle_inv(&inv, peer, &requests).await.unwrap(); + assert!( + manager.pending_requests.contains_key(&txid), + "expired seen txid should be accepted" + ); + } + + #[test] + fn test_peer_disconnect_keeps_other_peers_intact() { + let (mut manager, _requests, _rx) = create_test_manager(); + let peer1 = test_socket_address(1); + let peer2 = test_socket_address(2); + + // Both activated + manager.peers.insert(peer1, Some(VecDeque::new())); + manager.peers.insert(peer2, Some(VecDeque::from([Txid::from_byte_array([1; 32])]))); + + manager.handle_peer_disconnected(peer1); + + assert!(!manager.peers.contains_key(&peer1)); + // peer2 should still be present and activated + assert!(manager.peers.contains_key(&peer2)); + assert!(manager.peers[&peer2].is_some()); + } +} diff --git a/dash-spv/src/sync/mempool/mod.rs b/dash-spv/src/sync/mempool/mod.rs new file mode 100644 index 000000000..94f1a84e0 --- /dev/null +++ b/dash-spv/src/sync/mempool/mod.rs @@ -0,0 +1,11 @@ +mod filter; +mod manager; +mod progress; +mod sync_manager; + +pub(crate) use manager::MempoolManager; +pub use progress::MempoolProgress; + +/// Bloom filter false positive rate for BIP37 mempool filtering. +// TODO: probably expose via config, e.g. as a privacy level enum (low/medium/high) instead of a raw f64 +const BLOOM_FALSE_POSITIVE_RATE: f64 = 0.0005; diff --git a/dash-spv/src/sync/mempool/progress.rs b/dash-spv/src/sync/mempool/progress.rs new file mode 100644 index 000000000..4a7b34893 --- /dev/null +++ b/dash-spv/src/sync/mempool/progress.rs @@ -0,0 +1,171 @@ +use crate::sync::SyncState; +use std::fmt; +use std::time::Instant; + +/// Progress tracking for mempool transaction monitoring. +#[derive(Debug, Clone, PartialEq)] +pub struct MempoolProgress { + /// Current sync state. + state: SyncState, + /// Total transactions received from the network. + received: u32, + /// Transactions that matched wallet addresses. + relevant: u32, + /// Transactions currently tracked in mempool state (wallet-relevant). + tracked: u32, + /// Transactions removed (confirmed or expired). + removed: u32, + /// Time of last activity. + last_activity: Instant, +} + +impl Default for MempoolProgress { + fn default() -> Self { + Self { + state: Default::default(), + received: 0, + relevant: 0, + tracked: 0, + removed: 0, + last_activity: Instant::now(), + } + } +} + +impl MempoolProgress { + pub fn state(&self) -> SyncState { + self.state + } + + pub fn received(&self) -> u32 { + self.received + } + + pub fn relevant(&self) -> u32 { + self.relevant + } + + pub fn tracked(&self) -> u32 { + self.tracked + } + + pub fn removed(&self) -> u32 { + self.removed + } + + pub fn last_activity(&self) -> Instant { + self.last_activity + } + + pub(super) fn set_state(&mut self, state: SyncState) { + self.state = state; + self.bump_last_activity(); + } + + pub(super) fn add_received(&mut self, count: u32) { + self.received += count; + self.bump_last_activity(); + } + + pub(super) fn add_relevant(&mut self, count: u32) { + self.relevant += count; + self.bump_last_activity(); + } + + pub(super) fn set_tracked(&mut self, count: u32) { + self.tracked = count; + self.bump_last_activity(); + } + + pub(super) fn add_removed(&mut self, count: u32) { + self.removed += count; + self.bump_last_activity(); + } + + fn bump_last_activity(&mut self) { + self.last_activity = Instant::now(); + } +} + +impl fmt::Display for MempoolProgress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{:?} received: {}, relevant: {}, tracked: {}, removed: {}, last_activity: {}s", + self.state, + self.received, + self.relevant, + self.tracked, + self.removed, + self.last_activity.elapsed().as_secs() + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_values() { + let p = MempoolProgress::default(); + assert_eq!(p.state(), SyncState::WaitForEvents); + assert_eq!(p.received(), 0); + assert_eq!(p.relevant(), 0); + assert_eq!(p.tracked(), 0); + assert_eq!(p.removed(), 0); + } + + #[test] + fn test_mutators_update_correctly() { + let mut p = MempoolProgress::default(); + + p.add_received(5); + assert_eq!(p.received(), 5); + p.add_received(3); + assert_eq!(p.received(), 8); + + p.add_relevant(2); + assert_eq!(p.relevant(), 2); + + p.set_tracked(10); + assert_eq!(p.tracked(), 10); + // set_tracked replaces, not accumulates + p.set_tracked(7); + assert_eq!(p.tracked(), 7); + + p.add_removed(3); + assert_eq!(p.removed(), 3); + + p.set_state(SyncState::Synced); + assert_eq!(p.state(), SyncState::Synced); + } + + #[test] + fn test_last_activity_updated_on_mutation() { + let mut p = MempoolProgress::default(); + let before = p.last_activity(); + + // Small sleep to ensure time difference + std::thread::sleep(std::time::Duration::from_millis(1)); + p.add_received(1); + + assert!(p.last_activity() >= before); + } + + #[test] + fn test_display_format() { + let mut p = MempoolProgress::default(); + p.add_received(10); + p.add_relevant(3); + p.set_tracked(2); + p.add_removed(1); + + let display = format!("{}", p); + assert!(display.contains("received: 10")); + assert!(display.contains("relevant: 3")); + assert!(display.contains("tracked: 2")); + assert!(display.contains("removed: 1")); + assert!(display.contains("WaitForEvents")); + } +} diff --git a/dash-spv/src/sync/mempool/sync_manager.rs b/dash-spv/src/sync/mempool/sync_manager.rs new file mode 100644 index 000000000..f73611220 --- /dev/null +++ b/dash-spv/src/sync/mempool/sync_manager.rs @@ -0,0 +1,616 @@ +use super::manager::MEMPOOL_TX_EXPIRY; +use crate::error::SyncResult; +use crate::network::{Message, MessageType, NetworkEvent, RequestSender}; +use crate::sync::{ + ManagerIdentifier, MempoolManager, SyncEvent, SyncManager, SyncManagerProgress, SyncState, +}; +use async_trait::async_trait; +use dashcore::network::message::NetworkMessage; +use key_wallet_manager::wallet_interface::WalletInterface; + +#[async_trait] +impl SyncManager for MempoolManager { + fn identifier(&self) -> ManagerIdentifier { + ManagerIdentifier::Mempool + } + + fn state(&self) -> SyncState { + self.progress.state() + } + + fn set_state(&mut self, state: SyncState) { + self.progress.set_state(state); + } + + async fn start_sync(&mut self, requests: &RequestSender) -> SyncResult> { + // After a full disconnect, re-activate mempool on all connected peers + self.activate_all_peers(requests).await?; + let has_activated = self.peers.values().any(|v| v.is_some()); + if has_activated { + self.set_state(SyncState::Synced); + tracing::info!("Mempool manager re-activated after disconnect recovery"); + } + // If no peers could be activated, stay in WaitingForConnections so the + // next PeersUpdated event will retry activation. + Ok(vec![]) + } + + fn clear_in_flight_state(&mut self) { + self.clear_pending(); + } + + fn wanted_message_types(&self) -> &'static [MessageType] { + &[MessageType::Inv, MessageType::Tx] + } + + async fn handle_message( + &mut self, + msg: Message, + requests: &RequestSender, + ) -> SyncResult> { + match msg.inner() { + NetworkMessage::Inv(inv) => self.handle_inv(inv, msg.peer_address(), requests).await, + NetworkMessage::Tx(tx) => self.handle_tx(tx.clone(), requests).await, + _ => Ok(vec![]), + } + } + + async fn handle_sync_event( + &mut self, + event: &SyncEvent, + requests: &RequestSender, + ) -> SyncResult> { + match event { + SyncEvent::SyncComplete { + .. + } => { + if self.state() != SyncState::Synced { + self.activate_all_peers(requests).await?; + let has_activated = self.peers.values().any(|v| v.is_some()); + if has_activated { + self.set_state(SyncState::Synced); + tracing::info!("Mempool manager activated on all peers"); + return Ok(vec![]); + } else { + tracing::warn!( + "Sync complete but no peers available for mempool activation" + ); + } + } + Ok(vec![]) + } + SyncEvent::BlockProcessed { + new_addresses, + confirmed_txids, + .. + } => { + // Remove confirmed transactions from mempool + if !confirmed_txids.is_empty() { + self.remove_confirmed(confirmed_txids).await; + } + if self.state() == SyncState::Synced + && (!confirmed_txids.is_empty() || !new_addresses.is_empty()) + { + // Confirmed transactions change the wallet's UTXO set and + // new addresses expand the monitored set. Both make the + // bloom filter stale, so rebuild immediately. + self.rebuild_filter(requests).await?; + } + Ok(vec![]) + } + SyncEvent::InstantLockReceived { + instant_lock, + .. + } => { + self.mark_instant_send(&instant_lock.txid).await; + Ok(vec![]) + } + _ => Ok(vec![]), + } + } + + async fn handle_network_event( + &mut self, + event: &NetworkEvent, + requests: &RequestSender, + ) -> SyncResult> { + match event { + NetworkEvent::PeerConnected { + address, + } => { + self.handle_peer_connected(*address); + // If synced, activate the new peer immediately + if self.state() == SyncState::Synced + && self.peers.get(address).is_some_and(|v| v.is_none()) + { + tracing::info!("Activating mempool on newly connected peer {}", address); + self.activate_peer(*address, requests).await?; + } + } + NetworkEvent::PeerDisconnected { + address, + } => { + self.handle_peer_disconnected(*address); + } + NetworkEvent::PeersUpdated { + connected_count, + best_height, + .. + } => { + if let Some(best_height) = best_height { + self.update_target_height(*best_height); + } + if *connected_count == 0 { + self.stop_sync(); + } else if self.state() == SyncState::WaitingForConnections { + return self.start_sync(requests).await; + } + } + } + Ok(vec![]) + } + + async fn tick(&mut self, requests: &RequestSender) -> SyncResult> { + if self.state() != SyncState::Synced { + return Ok(vec![]); + } + + // Prune expired transactions periodically + self.prune_expired(MEMPOOL_TX_EXPIRY).await; + + // Prune pending requests that never received a response + self.prune_pending_requests(); + + // Send queued getdata requests now that slots may have freed up + self.send_queued(requests).await?; + + Ok(vec![]) + } + + fn progress(&self) -> SyncManagerProgress { + SyncManagerProgress::Mempool(self.progress.clone()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::client::config::MempoolStrategy; + use crate::network::NetworkRequest; + use crate::test_utils::test_socket_address; + use crate::types::MempoolState; + use dashcore::hashes::Hash; + use key_wallet_manager::test_utils::MockWallet; + use std::sync::Arc; + use tokio::sync::{mpsc, RwLock}; + + fn create_test_manager( + ) -> (MempoolManager, RequestSender, mpsc::UnboundedReceiver) { + let wallet = Arc::new(RwLock::new(MockWallet::new())); + let mempool_state = Arc::new(RwLock::new(MempoolState::default())); + let (tx, rx) = mpsc::unbounded_channel::(); + let requests = RequestSender::new(tx); + + let manager = MempoolManager::new(wallet, mempool_state, MempoolStrategy::FetchAll, 1000); + + (manager, requests, rx) + } + + #[test] + fn test_sync_manager_trait_basics() { + let (mut manager, _, _rx) = create_test_manager(); + + assert_eq!(manager.identifier(), ManagerIdentifier::Mempool); + assert_eq!(manager.state(), SyncState::WaitForEvents); + + let types = manager.wanted_message_types(); + assert!(types.contains(&MessageType::Inv)); + assert!(types.contains(&MessageType::Tx)); + assert_eq!(types.len(), 2); + + manager.set_state(SyncState::Synced); + assert_eq!(manager.state(), SyncState::Synced); + + assert!(matches!(manager.progress(), SyncManagerProgress::Mempool(_))); + } + + #[tokio::test] + async fn test_handle_sync_complete_activates() { + let (mut manager, requests, _rx) = create_test_manager(); + let peer = crate::test_utils::test_socket_address(1); + manager.handle_peer_connected(peer); + + let event = SyncEvent::SyncComplete { + header_tip: 1000, + cycle: 0, + }; + + let events = manager.handle_sync_event(&event, &requests).await.unwrap(); + assert!(events.is_empty()); + assert_eq!(manager.state(), SyncState::Synced); + assert!(matches!(manager.peers.get(&peer), Some(Some(_)))); + } + + #[tokio::test] + async fn test_handle_sync_complete_subsequent_cycles() { + let (mut manager, requests, _rx) = create_test_manager(); + manager.handle_peer_connected(crate::test_utils::test_socket_address(1)); + + // Activate first + let event0 = SyncEvent::SyncComplete { + header_tip: 1000, + cycle: 0, + }; + manager.handle_sync_event(&event0, &requests).await.unwrap(); + + // Subsequent cycles should not change state + let event1 = SyncEvent::SyncComplete { + header_tip: 1001, + cycle: 1, + }; + let events = manager.handle_sync_event(&event1, &requests).await.unwrap(); + assert!(events.is_empty()); + assert_eq!(manager.state(), SyncState::Synced); + } + + #[tokio::test] + async fn test_reactivation_after_disconnect() { + let (mut manager, requests, _rx) = create_test_manager(); + let peer = test_socket_address(1); + manager.handle_peer_connected(peer); + + // Initial activation + let event = SyncEvent::SyncComplete { + header_tip: 1000, + cycle: 0, + }; + let events = manager.handle_sync_event(&event, &requests).await.unwrap(); + assert!(events.is_empty()); + assert_eq!(manager.state(), SyncState::Synced); + + // Simulate disconnect by resetting state + manager.set_state(SyncState::WaitForEvents); + + // Re-sync should re-activate + let event = SyncEvent::SyncComplete { + header_tip: 1001, + cycle: 1, + }; + let events = manager.handle_sync_event(&event, &requests).await.unwrap(); + assert!(events.is_empty()); + assert_eq!(manager.state(), SyncState::Synced); + } + + #[tokio::test] + async fn test_peer_connect_activates_when_synced() { + let (mut manager, requests, _rx) = create_test_manager(); + let peer1 = test_socket_address(1); + manager.handle_peer_connected(peer1); + + // Activate via SyncComplete + let event = SyncEvent::SyncComplete { + header_tip: 1000, + cycle: 0, + }; + manager.handle_sync_event(&event, &requests).await.unwrap(); + assert!(matches!(manager.peers.get(&peer1), Some(Some(_)))); + + // New peer connects while synced => should activate immediately + let peer2 = test_socket_address(2); + let connect = NetworkEvent::PeerConnected { + address: peer2, + }; + let events = manager.handle_network_event(&connect, &requests).await.unwrap(); + assert!(events.is_empty()); + assert!(matches!(manager.peers.get(&peer2), Some(Some(_)))); + } + + #[tokio::test] + async fn test_network_event_peer_connect_disconnect() { + let (mut manager, requests, _rx) = create_test_manager(); + + let peer1 = test_socket_address(1); + let peer2 = test_socket_address(2); + + // Connecting peers should return empty events (not synced yet) + let connect1 = NetworkEvent::PeerConnected { + address: peer1, + }; + let events = manager.handle_network_event(&connect1, &requests).await.unwrap(); + assert!(events.is_empty()); + assert!(manager.peers.contains_key(&peer1)); + + let connect2 = NetworkEvent::PeerConnected { + address: peer2, + }; + let events = manager.handle_network_event(&connect2, &requests).await.unwrap(); + assert!(events.is_empty()); + assert_eq!(manager.peers.len(), 2); + + let disconnect1 = NetworkEvent::PeerDisconnected { + address: peer1, + }; + let events = manager.handle_network_event(&disconnect1, &requests).await.unwrap(); + assert!(events.is_empty()); + + // Still have peer2 available + assert!(manager.peers.contains_key(&peer2)); + assert_eq!(manager.peers.len(), 1); + + // Disconnecting an already-disconnected peer should not error + let events = manager.handle_network_event(&disconnect1, &requests).await.unwrap(); + assert!(events.is_empty()); + } + + #[tokio::test] + async fn test_block_processed_removes_confirmed_txids() { + let (mut manager, requests, _rx) = create_test_manager(); + let peer = test_socket_address(1); + manager.handle_peer_connected(peer); + + // Activate + let sync = SyncEvent::SyncComplete { + header_tip: 1000, + cycle: 0, + }; + manager.handle_sync_event(&sync, &requests).await.unwrap(); + + // Add transactions to mempool state + let mut txids = Vec::new(); + { + let mut state = manager.mempool_state.write().await; + for i in 0..2u32 { + let tx = dashcore::Transaction { + version: 1, + lock_time: i, + input: vec![], + output: vec![], + special_transaction_payload: None, + }; + let txid = tx.txid(); + txids.push(txid); + state.add_transaction(crate::types::UnconfirmedTransaction::new( + tx, + dashcore::Amount::from_sat(0), + false, + false, + Vec::new(), + 0, + )); + } + } + + let event = SyncEvent::BlockProcessed { + block_hash: dashcore::BlockHash::all_zeros(), + height: 1001, + new_addresses: vec![], + confirmed_txids: txids.clone(), + }; + let events = manager.handle_sync_event(&event, &requests).await.unwrap(); + assert!(events.is_empty()); + + let state = manager.mempool_state.read().await; + assert!(state.transactions.is_empty()); + } + + #[tokio::test] + async fn test_instant_lock_received_marks_transaction() { + let (mut manager, requests, _rx) = create_test_manager(); + let peer = test_socket_address(1); + manager.handle_peer_connected(peer); + + // Activate + let sync = SyncEvent::SyncComplete { + header_tip: 1000, + cycle: 0, + }; + manager.handle_sync_event(&sync, &requests).await.unwrap(); + + // Add a transaction to mempool + let tx = dashcore::Transaction { + version: 1, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }; + let txid = tx.txid(); + { + let mut state = manager.mempool_state.write().await; + state.add_transaction(crate::types::UnconfirmedTransaction::new( + tx, + dashcore::Amount::from_sat(0), + false, + false, + Vec::new(), + 0, + )); + } + + // Fire InstantLockReceived with a lock whose txid matches + let mut is_lock = dashcore::InstantLock::dummy(0..1); + is_lock.txid = txid; + + let event = SyncEvent::InstantLockReceived { + instant_lock: is_lock, + validated: true, + }; + let events = manager.handle_sync_event(&event, &requests).await.unwrap(); + assert!(events.is_empty()); + + let state = manager.mempool_state.read().await; + assert!(state.transactions.get(&txid).unwrap().is_instant_send); + } + + #[tokio::test] + async fn test_peer_disconnect_removes_from_peers() { + let (mut manager, requests, _rx) = create_test_manager(); + let peer = test_socket_address(1); + manager.handle_peer_connected(peer); + + // Activate + let sync = SyncEvent::SyncComplete { + header_tip: 1000, + cycle: 0, + }; + manager.handle_sync_event(&sync, &requests).await.unwrap(); + + // Disconnect the only peer + let disconnect = NetworkEvent::PeerDisconnected { + address: peer, + }; + let events = manager.handle_network_event(&disconnect, &requests).await.unwrap(); + assert!(events.is_empty()); + assert!(manager.peers.is_empty()); + } + + #[tokio::test] + async fn test_sync_complete_no_peers_stays_inactive() { + let (mut manager, requests, _rx) = create_test_manager(); + + let event = SyncEvent::SyncComplete { + header_tip: 1000, + cycle: 0, + }; + let events = manager.handle_sync_event(&event, &requests).await.unwrap(); + + assert!(events.is_empty()); + assert_eq!(manager.state(), SyncState::WaitForEvents); + assert!(manager.peers.is_empty()); + } + + #[tokio::test] + async fn test_start_sync_no_peers_stays_waiting() { + let (mut manager, requests, _rx) = create_test_manager(); + + // Simulate full disconnect setting state to WaitingForConnections + manager.set_state(SyncState::WaitingForConnections); + + // start_sync with no peers should stay in WaitingForConnections + let events = manager.start_sync(&requests).await.unwrap(); + assert!(events.is_empty()); + assert_eq!(manager.state(), SyncState::WaitingForConnections); + } + + #[tokio::test] + async fn test_disconnect_recovery_reactivates_on_reconnect() { + let (mut manager, requests, _rx) = create_test_manager(); + let peer = test_socket_address(1); + manager.handle_peer_connected(peer); + + // Activate via SyncComplete + let event = SyncEvent::SyncComplete { + header_tip: 1000, + cycle: 0, + }; + manager.handle_sync_event(&event, &requests).await.unwrap(); + assert_eq!(manager.state(), SyncState::Synced); + + // Disconnect peer + let disconnect = NetworkEvent::PeerDisconnected { + address: peer, + }; + manager.handle_network_event(&disconnect, &requests).await.unwrap(); + + // PeersUpdated with 0 triggers stop_sync + let update = NetworkEvent::PeersUpdated { + connected_count: 0, + addresses: vec![], + best_height: None, + }; + manager.handle_network_event(&update, &requests).await.unwrap(); + assert_eq!(manager.state(), SyncState::WaitingForConnections); + + // PeersUpdated with 1 but no peers tracked yet: stays WaitingForConnections + let update = NetworkEvent::PeersUpdated { + connected_count: 1, + addresses: vec![peer], + best_height: Some(1000), + }; + manager.handle_network_event(&update, &requests).await.unwrap(); + assert_eq!(manager.state(), SyncState::WaitingForConnections); + + // Peer reconnects and PeersUpdated fires again + manager.handle_peer_connected(peer); + let update = NetworkEvent::PeersUpdated { + connected_count: 1, + addresses: vec![peer], + best_height: Some(1000), + }; + manager.handle_network_event(&update, &requests).await.unwrap(); + assert_eq!(manager.state(), SyncState::Synced); + assert!(matches!(manager.peers.get(&peer), Some(Some(_)))); + } + + #[tokio::test] + async fn test_block_processed_confirmed_txids_rebuilds_filter() { + let mut mock = MockWallet::new(); + // Wallet needs at least one address for the bloom filter to be built + let script = dashcore::ScriptBuf::from_bytes(vec![ + 0x76, 0xa9, 0x14, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, + 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0x88, 0xac, + ]); + let addr = dashcore::Address::from_script(&script, dashcore::Network::Testnet).unwrap(); + mock.set_addresses(vec![addr]); + let wallet = Arc::new(RwLock::new(mock)); + let mempool_state = Arc::new(RwLock::new(MempoolState::default())); + let (tx, mut rx) = mpsc::unbounded_channel::(); + let requests = RequestSender::new(tx); + + let mut manager = + MempoolManager::new(wallet, mempool_state, MempoolStrategy::BloomFilter, 1000); + + let peer = test_socket_address(1); + manager.handle_peer_connected(peer); + + // Activate + let sync = SyncEvent::SyncComplete { + header_tip: 1000, + cycle: 0, + }; + manager.handle_sync_event(&sync, &requests).await.unwrap(); + + // Drain activation messages + while rx.try_recv().is_ok() {} + + // BlockProcessed with confirmed txids should rebuild immediately + let event = SyncEvent::BlockProcessed { + block_hash: dashcore::BlockHash::all_zeros(), + height: 1001, + new_addresses: vec![], + confirmed_txids: vec![dashcore::Txid::all_zeros()], + }; + manager.handle_sync_event(&event, &requests).await.unwrap(); + + // Verify a FilterLoad was sent + let mut found_filter_load = false; + while let Ok(req) = rx.try_recv() { + if matches!(req, NetworkRequest::SendMessageToPeer(NetworkMessage::FilterLoad(_), _)) { + found_filter_load = true; + } + } + assert!(found_filter_load, "expected FilterLoad after confirmed txids"); + } + + #[tokio::test] + async fn test_block_processed_no_changes_no_rebuild_flag() { + let (mut manager, requests, _rx) = create_test_manager(); + let peer = test_socket_address(1); + manager.handle_peer_connected(peer); + + let sync = SyncEvent::SyncComplete { + header_tip: 1000, + cycle: 0, + }; + manager.handle_sync_event(&sync, &requests).await.unwrap(); + + // BlockProcessed with no confirmed txids and no new addresses + let event = SyncEvent::BlockProcessed { + block_hash: dashcore::BlockHash::all_zeros(), + height: 1001, + new_addresses: vec![], + confirmed_txids: vec![], + }; + manager.handle_sync_event(&event, &requests).await.unwrap(); + } +} diff --git a/dash-spv/src/sync/mod.rs b/dash-spv/src/sync/mod.rs index bcffed2aa..8a6925ef7 100644 --- a/dash-spv/src/sync/mod.rs +++ b/dash-spv/src/sync/mod.rs @@ -10,6 +10,7 @@ mod filters; mod identifier; mod instantsend; mod masternodes; +mod mempool; mod progress; mod sync_coordinator; mod sync_manager; @@ -21,6 +22,8 @@ pub use filter_headers::{FilterHeadersManager, FilterHeadersProgress}; pub use filters::{FiltersManager, FiltersProgress}; pub use instantsend::{InstantSendManager, InstantSendProgress}; pub use masternodes::{MasternodesManager, MasternodesProgress}; +pub(crate) use mempool::MempoolManager; +pub use mempool::MempoolProgress; pub use events::SyncEvent; pub use identifier::ManagerIdentifier; diff --git a/dash-spv/src/sync/progress.rs b/dash-spv/src/sync/progress.rs index 85641647a..724dc993a 100644 --- a/dash-spv/src/sync/progress.rs +++ b/dash-spv/src/sync/progress.rs @@ -1,7 +1,7 @@ use crate::error::{SyncError, SyncResult}; use crate::sync::{ BlockHeadersProgress, BlocksProgress, ChainLockProgress, FilterHeadersProgress, - FiltersProgress, InstantSendProgress, MasternodesProgress, + FiltersProgress, InstantSendProgress, MasternodesProgress, MempoolProgress, }; use dashcore::prelude::CoreBlockHeight; use std::fmt; @@ -34,6 +34,8 @@ pub struct SyncProgress { chainlocks: Option, /// InstantSend synchronization progress. instantsend: Option, + /// Mempool monitoring progress. + mempool: Option, } impl SyncProgress { @@ -156,6 +158,12 @@ impl SyncProgress { .ok_or_else(|| SyncError::InvalidState("InstantSendManager not started".into())) } + pub fn mempool(&self) -> SyncResult<&MempoolProgress> { + self.mempool + .as_ref() + .ok_or_else(|| SyncError::InvalidState("MempoolManager not started".into())) + } + pub fn update_headers(&mut self, progress: BlockHeadersProgress) { let updated_headers = Some(progress); if self.headers != updated_headers { @@ -209,6 +217,14 @@ impl SyncProgress { self.instantsend = updated_instantsend; } } + + /// Update mempool progress. + pub fn update_mempool(&mut self, progress: MempoolProgress) { + let updated_mempool = Some(progress); + if self.mempool != updated_mempool { + self.mempool = updated_mempool; + } + } } impl fmt::Display for SyncProgress { @@ -235,6 +251,9 @@ impl fmt::Display for SyncProgress { if let Some(i) = &self.instantsend { writeln!(f, " InstantSend: {}", i)?; } + if let Some(m) = &self.mempool { + writeln!(f, " Mempool: {}", m)?; + } Ok(()) } } diff --git a/dash-spv/src/sync/sync_coordinator.rs b/dash-spv/src/sync/sync_coordinator.rs index 17a8ef9d3..3490ad5b4 100644 --- a/dash-spv/src/sync/sync_coordinator.rs +++ b/dash-spv/src/sync/sync_coordinator.rs @@ -20,8 +20,8 @@ use crate::storage::{ use crate::sync::progress::ProgressPercentage; use crate::sync::{ BlockHeadersManager, BlocksManager, ChainLockManager, FilterHeadersManager, FiltersManager, - InstantSendManager, ManagerIdentifier, MasternodesManager, SyncEvent, SyncManager, - SyncManagerProgress, SyncManagerTaskContext, SyncProgress, + InstantSendManager, ManagerIdentifier, MasternodesManager, MempoolManager, SyncEvent, + SyncManager, SyncManagerProgress, SyncManagerTaskContext, SyncProgress, }; use crate::SyncError; use key_wallet_manager::wallet_interface::WalletInterface; @@ -78,6 +78,7 @@ where pub masternode: Option>, pub chainlock: Option>, pub instantsend: Option, + pub(crate) mempool: Option>, } impl Default for Managers @@ -98,6 +99,7 @@ where masternode: None, chainlock: None, instantsend: None, + mempool: None, } } } @@ -156,6 +158,7 @@ where try_update_progress(managers.masternode.as_ref(), &mut initial_progress); try_update_progress(managers.chainlock.as_ref(), &mut initial_progress); try_update_progress(managers.instantsend.as_ref(), &mut initial_progress); + try_update_progress(managers.mempool.as_ref(), &mut initial_progress); tracing::info!("Initial sync progress {}", initial_progress.clone()); @@ -213,6 +216,7 @@ where let masternode = self.managers.masternode.take(); let chainlock = self.managers.chainlock.take(); let instantsend = self.managers.instantsend.take(); + let mempool = self.managers.mempool.take(); // Spawn each manager using the macro spawn_manager!(self, block_headers, network); @@ -222,6 +226,7 @@ where spawn_manager!(self, masternode, network); spawn_manager!(self, chainlock, network); spawn_manager!(self, instantsend, network); + spawn_manager!(self, mempool, network); // Clone receivers for progress task let receivers = self.progress_receivers.clone(); @@ -402,6 +407,7 @@ fn update_progress_from_manager( SyncManagerProgress::Masternodes(m) => progress.update_masternodes(m), SyncManagerProgress::ChainLock(c) => progress.update_chainlocks(c), SyncManagerProgress::InstantSend(i) => progress.update_instantsend(i), + SyncManagerProgress::Mempool(m) => progress.update_mempool(m), } } diff --git a/dash-spv/src/sync/sync_manager.rs b/dash-spv/src/sync/sync_manager.rs index 72f423e1b..8d03e24eb 100644 --- a/dash-spv/src/sync/sync_manager.rs +++ b/dash-spv/src/sync/sync_manager.rs @@ -2,8 +2,8 @@ use crate::error::SyncResult; use crate::network::{Message, MessageType, NetworkEvent, RequestSender}; use crate::sync::{ BlockHeadersProgress, BlocksProgress, ChainLockProgress, FilterHeadersProgress, - FiltersProgress, InstantSendProgress, ManagerIdentifier, MasternodesProgress, SyncEvent, - SyncState, + FiltersProgress, InstantSendProgress, ManagerIdentifier, MasternodesProgress, MempoolProgress, + SyncEvent, SyncState, }; use async_trait::async_trait; @@ -30,6 +30,7 @@ pub enum SyncManagerProgress { Masternodes(MasternodesProgress), ChainLock(ChainLockProgress), InstantSend(InstantSendProgress), + Mempool(MempoolProgress), } impl SyncManagerProgress { @@ -42,6 +43,7 @@ impl SyncManagerProgress { SyncManagerProgress::Masternodes(progress) => progress.state(), SyncManagerProgress::ChainLock(progress) => progress.state(), SyncManagerProgress::InstantSend(progress) => progress.state(), + SyncManagerProgress::Mempool(progress) => progress.state(), } } } diff --git a/dash-spv/src/test_utils/node.rs b/dash-spv/src/test_utils/node.rs index c2b3b4c3f..28a94b9b5 100644 --- a/dash-spv/src/test_utils/node.rs +++ b/dash-spv/src/test_utils/node.rs @@ -2,9 +2,11 @@ //! //! This provides utilities for managing a dashd instance and loading test wallet data. -use dashcore::{Address, Amount, BlockHash, Txid}; +use dashcore::{Address, Amount, BlockHash, Transaction, Txid}; +use dashcore_rpc::json as rpc_json; use dashcore_rpc::{Auth, Client, RpcApi}; use serde::Deserialize; +use std::collections::HashMap; use std::fs; use std::net::SocketAddr; use std::path::{Path, PathBuf}; @@ -142,6 +144,7 @@ impl DashCoreNode { "-timestampindex=0".to_string(), "-blockfilterindex=1".to_string(), "-peerblockfilters=1".to_string(), + "-peerbloomfilters=1".to_string(), "-debug=all".to_string(), format!("-wallet={}", self.config.wallet), ]; @@ -287,7 +290,7 @@ impl DashCoreNode { hashes } - /// Send DASH to an address. + /// Send DASH to an address from the primary wallet. pub fn send_to_address(&self, address: &Address, amount: Amount) -> Txid { let client = self.rpc_client(); let txid = client @@ -297,6 +300,105 @@ impl DashCoreNode { txid } + /// Send DASH to an address from a specific wallet. + pub fn send_to_address_from_wallet( + &self, + wallet_name: &str, + address: &Address, + amount: Amount, + ) -> Txid { + let client = self.rpc_client_for_wallet(wallet_name); + let txid = client + .send_to_address(address, amount, None, None, None, None, None, None, None, None) + .expect("failed to send to address"); + tracing::info!("Sent {} to {} (wallet: {}), txid: {}", amount, address, wallet_name, txid); + txid + } + + /// List unspent outputs for a specific wallet. + pub fn list_unspent_from_wallet( + &self, + wallet_name: &str, + ) -> Vec { + let client = self.rpc_client_for_wallet(wallet_name); + client.list_unspent(None, None, None, None, None).expect("failed to list unspent") + } + + /// Create, sign, and broadcast a raw transaction spending a single UTXO. + /// Sends the input amount minus fee to the destination address. + pub fn send_raw_from_wallet( + &self, + wallet_name: &str, + input_txid: Txid, + input_vout: u32, + input_amount: Amount, + destination: &Address, + fee: Amount, + ) -> Txid { + let client = self.rpc_client_for_wallet(wallet_name); + + let inputs = vec![rpc_json::CreateRawTransactionInput { + txid: input_txid, + vout: input_vout, + sequence: None, + }]; + let send_amount = input_amount.checked_sub(fee).expect("fee exceeds input amount"); + let mut outputs = HashMap::new(); + outputs.insert(destination.to_string(), send_amount); + + let raw_tx: Transaction = client + .create_raw_transaction(&inputs, &outputs, None) + .expect("failed to create raw tx"); + + let signed = client + .sign_raw_transaction_with_wallet(&raw_tx, None, None) + .expect("failed to sign raw tx"); + assert!(signed.complete, "raw transaction signing incomplete"); + + let txid = client + .send_raw_transaction(&signed.transaction().expect("invalid signed tx")) + .expect("failed to send raw tx"); + tracing::info!( + "Sent raw tx from wallet '{}': {} -> {}, txid: {}", + wallet_name, + input_amount, + destination, + txid + ); + txid + } + + /// Connect this dashd node to another dashd node via P2P and wait for the + /// connection to be established. + pub async fn connect_to_node(&self, addr: SocketAddr) { + let client = self.rpc_client(); + client.onetry_node(&addr.to_string()).expect("failed to connect to node"); + + for _ in 0..30 { + let peers = client.get_peer_info().expect("failed to get peer info"); + if peers.iter().any(|p| p.addr.to_string().starts_with(&addr.ip().to_string())) { + tracing::info!("Connected to node {}", addr); + return; + } + sleep(Duration::from_millis(500)).await; + } + panic!("Timed out waiting for connection to {}", addr); + } + + /// Disconnect a specific peer by address. + pub fn disconnect_peer(&self, addr: SocketAddr) { + let client = self.rpc_client(); + client.disconnect_node(&addr.to_string()).expect("failed to disconnect peer"); + tracing::info!("Disconnected peer {}", addr); + } + + /// Enable or disable all P2P network activity on this node. + pub fn set_network_active(&self, active: bool) { + let client = self.rpc_client(); + client.set_network_active(active).expect("failed to set network active"); + tracing::info!("Set network active={} on dashd", active); + } + /// Disconnect all currently connected peers. pub fn disconnect_all_peers(&self) { let client = self.rpc_client(); diff --git a/dash-spv/src/types.rs b/dash-spv/src/types.rs index 87b89dd1f..960a08a49 100644 --- a/dash-spv/src/types.rs +++ b/dash-spv/src/types.rs @@ -336,8 +336,9 @@ impl MempoolState { }); // Also prune old recent sends - let cutoff = Instant::now() - timeout; - self.recent_sends.retain(|_, &mut timestamp| timestamp > cutoff); + if let Some(cutoff) = Instant::now().checked_sub(timeout) { + self.recent_sends.retain(|_, &mut timestamp| timestamp > cutoff); + } expired } diff --git a/dash-spv/tests/dashd_sync/helpers.rs b/dash-spv/tests/dashd_sync/helpers.rs index 533984e77..87bfef5b1 100644 --- a/dash-spv/tests/dashd_sync/helpers.rs +++ b/dash-spv/tests/dashd_sync/helpers.rs @@ -1,9 +1,12 @@ use dash_spv::network::NetworkEvent; -use dash_spv::sync::{ProgressPercentage, SyncEvent, SyncProgress}; +use dash_spv::sync::{ProgressPercentage, SyncEvent, SyncProgress, SyncState}; use dash_spv::test_utils::DashCoreNode; +use dashcore::Txid; +use key_wallet::transaction_checking::TransactionContext; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet_manager::wallet_manager::{WalletId, WalletManager}; +use key_wallet_manager::WalletEvent; use std::collections::HashSet; use std::sync::Arc; use std::time::Duration; @@ -123,6 +126,66 @@ pub(super) async fn wait_for_network_event( } } +/// Wait for a wallet `TransactionReceived` event with mempool status within the given timeout. +/// Returns `Some(txid)` if received, `None` on timeout. +pub(super) async fn wait_for_mempool_tx( + receiver: &mut broadcast::Receiver, + max_wait: Duration, +) -> Option { + let timeout = tokio::time::sleep(max_wait); + tokio::pin!(timeout); + + loop { + tokio::select! { + _ = &mut timeout => return None, + result = receiver.recv() => { + match result { + Ok(WalletEvent::TransactionReceived { txid, status: TransactionContext::Mempool, .. }) => return Some(txid), + Ok(_) => continue, + Err(_) => return None, + } + } + } + } +} + +/// Wait for the mempool manager to reach `Synced` state via the progress watch channel. +/// Returns `true` if the state is reached within the timeout, `false` otherwise. +pub(super) async fn wait_for_mempool_synced( + progress_receiver: &mut watch::Receiver, +) -> bool { + let timeout = tokio::time::sleep(Duration::from_secs(30)); + tokio::pin!(timeout); + + loop { + { + let progress = progress_receiver.borrow_and_update(); + if progress.mempool().ok().is_some_and(|m| m.state() == SyncState::Synced) { + return true; + } + } + + tokio::select! { + _ = &mut timeout => return false, + result = progress_receiver.changed() => { + if result.is_err() { + return false; + } + } + } + } +} + +/// Assert that no mempool `TransactionReceived` event arrives within the given duration. +pub(super) async fn assert_no_mempool_tx( + receiver: &mut broadcast::Receiver, + wait: Duration, +) { + if let Some(txid) = wait_for_mempool_tx(receiver, wait).await { + panic!("Unexpected mempool TransactionReceived event with txid: {}", txid); + } +} + /// Run a disconnect-and-reconnect loop during sync, then verify final state. /// /// Waits for progress events, disconnects all peers after every 5th event, @@ -205,3 +268,112 @@ pub(super) async fn run_disconnect_loop( client_handle.stop().await; ctx.assert_synced(&client_handle.client.progress().await).await; } + +/// Wait for two clients to sync to the target height concurrently. +pub(super) async fn wait_for_sync_both( + a: &mut ClientHandle, + b: &mut ClientHandle, + target_height: u32, +) { + tokio::join!( + wait_for_sync(&mut a.progress_receiver, target_height), + wait_for_sync(&mut b.progress_receiver, target_height), + ); +} + +/// Wait for a mempool transaction event from two clients concurrently. +/// Asserts both detect the same txid. +pub(super) async fn wait_for_mempool_tx_both( + a: &mut ClientHandle, + b: &mut ClientHandle, + timeout: Duration, +) -> Option { + let (r_a, r_b) = tokio::join!( + wait_for_mempool_tx(&mut a.wallet_event_receiver, timeout), + wait_for_mempool_tx(&mut b.wallet_event_receiver, timeout), + ); + match (r_a, r_b) { + (Some(txid_a), Some(txid_b)) => { + assert_eq!(txid_a, txid_b, "Clients detected different txids"); + Some(txid_a) + } + (None, None) => None, + (a, b) => panic!("Strategy mismatch: client_a={:?}, client_b={:?}", a, b), + } +} + +/// Collect N mempool transaction events from two clients concurrently. +/// Asserts both detect the same set of txids. +pub(super) async fn wait_for_mempool_txs_both( + a: &mut ClientHandle, + b: &mut ClientHandle, + count: usize, + timeout: Duration, +) -> HashSet { + async fn collect_n( + receiver: &mut broadcast::Receiver, + count: usize, + timeout: Duration, + ) -> HashSet { + let mut txids = HashSet::new(); + for _ in 0..count { + let txid = wait_for_mempool_tx(receiver, timeout) + .await + .expect("Expected mempool TransactionReceived event"); + txids.insert(txid); + } + txids + } + + let (txids_a, txids_b) = tokio::join!( + collect_n(&mut a.wallet_event_receiver, count, timeout), + collect_n(&mut b.wallet_event_receiver, count, timeout), + ); + assert_eq!(txids_a, txids_b, "Clients detected different txid sets"); + txids_a +} + +/// Wait for both clients to reach mempool Synced state. +pub(super) async fn wait_for_mempool_synced_both(a: &mut ClientHandle, b: &mut ClientHandle) { + let (r_a, r_b) = tokio::join!( + wait_for_mempool_synced(&mut a.progress_receiver), + wait_for_mempool_synced(&mut b.progress_receiver), + ); + assert!(r_a, "Client A: expected mempool to reach Synced state"); + assert!(r_b, "Client B: expected mempool to reach Synced state"); +} + +/// Assert that neither client receives a mempool transaction event within the given duration. +pub(super) async fn assert_no_mempool_tx_both( + a: &mut ClientHandle, + b: &mut ClientHandle, + wait: Duration, +) { + tokio::join!( + assert_no_mempool_tx(&mut a.wallet_event_receiver, wait), + assert_no_mempool_tx(&mut b.wallet_event_receiver, wait), + ); +} + +/// Wait for a network event on both clients concurrently. +pub(super) async fn wait_for_network_event_both( + a: &mut ClientHandle, + b: &mut ClientHandle, + predicate: impl Fn(&NetworkEvent) -> bool + Clone, + max_wait: Duration, +) -> bool { + let pred_clone = predicate.clone(); + let (r_a, r_b) = tokio::join!( + wait_for_network_event(&mut a.network_event_receiver, predicate, max_wait), + wait_for_network_event(&mut b.network_event_receiver, pred_clone, max_wait), + ); + r_a && r_b +} + +/// Assert mempool transaction count on both clients. +pub(super) async fn assert_mempool_count_both(a: &ClientHandle, b: &ClientHandle, expected: usize) { + let count_a = a.client.get_mempool_transaction_count().await; + let count_b = b.client.get_mempool_transaction_count().await; + assert_eq!(count_a, expected, "Client A mempool count: expected {}, got {}", expected, count_a); + assert_eq!(count_b, expected, "Client B mempool count: expected {}, got {}", expected, count_b); +} diff --git a/dash-spv/tests/dashd_sync/main.rs b/dash-spv/tests/dashd_sync/main.rs index aefe73ac7..da6f3c6a1 100644 --- a/dash-spv/tests/dashd_sync/main.rs +++ b/dash-spv/tests/dashd_sync/main.rs @@ -6,5 +6,6 @@ mod helpers; mod setup; mod tests_basic; mod tests_disconnect; +mod tests_mempool; mod tests_restart; mod tests_transaction; diff --git a/dash-spv/tests/dashd_sync/setup.rs b/dash-spv/tests/dashd_sync/setup.rs index e2689bb78..aafa0549b 100644 --- a/dash-spv/tests/dashd_sync/setup.rs +++ b/dash-spv/tests/dashd_sync/setup.rs @@ -1,3 +1,4 @@ +use dash_spv::client::config::MempoolStrategy; use dash_spv::network::NetworkEvent; use dash_spv::storage::{PeerStorage, PersistentPeerStorage, PersistentStorage}; use dash_spv::test_utils::{retain_test_dir, DashdTestContext, TestChain}; @@ -10,11 +11,13 @@ use dash_spv::{ }; use dashcore::network::address::AddrV2Message; use dashcore::network::constants::ServiceFlags; +use dashcore::Txid; use key_wallet::managed_account::managed_account_type::ManagedAccountType; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet_manager::wallet_manager::{WalletId, WalletManager}; +use key_wallet_manager::WalletEvent; use std::collections::{BTreeSet, HashSet}; use std::path::PathBuf; use std::sync::Arc; @@ -100,6 +103,19 @@ impl TestContext { pub(super) async fn spawn_new_client(&self) -> ClientHandle { create_and_start_client(&self.client_config, Arc::clone(&self.wallet)).await } + + /// Spawns an independent client with the given mempool strategy. + /// + /// Each call creates a fresh wallet (same mnemonic) and a separate storage directory. + /// The caller must hold the returned `TempDir` alive for the duration of the test. + pub(super) async fn spawn_client(&self, strategy: MempoolStrategy) -> (ClientHandle, TempDir) { + let storage = TempDir::new().expect("Failed to create client temp dir"); + let mut config = create_test_config(storage.path().to_path_buf(), self.dashd.addr); + config.mempool_strategy = strategy; + let (wallet, _) = create_test_wallet(&self.dashd.wallet.mnemonic, Network::Regtest); + let handle = create_and_start_client(&config, wallet).await; + (handle, storage) + } /// Retrieves the total count of transactions across all accounts in the wallet. pub(super) async fn transaction_count(&self) -> usize { let wallet_read = self.wallet.read().await; @@ -258,6 +274,8 @@ pub(super) struct ClientHandle { pub(super) sync_event_receiver: broadcast::Receiver, /// A channel for receiving network events. pub(super) network_event_receiver: broadcast::Receiver, + /// A channel for receiving wallet events. + pub(super) wallet_event_receiver: broadcast::Receiver, /// A cancellation token for the client's run loop. pub(super) cancel_token: CancellationToken, } @@ -274,6 +292,22 @@ impl ClientHandle { } } +/// Check if a transaction exists in a client's wallet. +pub(super) async fn client_has_transaction( + client: &TestClient, + wallet_id: &WalletId, + txid: &Txid, +) -> bool { + let wallet_read = client.wallet().read().await; + let wallet_info = wallet_read.get_wallet_info(wallet_id).expect("Wallet info not found"); + wallet_info + .accounts() + .all_accounts() + .iter() + .any(|account| account.transactions.contains_key(txid)) + || wallet_info.immature_transactions().iter().any(|tx| &tx.txid() == txid) +} + /// Creates a new SPV client and starts it. pub(super) async fn create_and_start_client( config: &ClientConfig, @@ -291,6 +325,10 @@ pub(super) async fn create_and_start_client( let progress_receiver = client.subscribe_progress().await; let sync_event_receiver = client.subscribe_sync_events().await; let network_event_receiver = client.subscribe_network_events().await; + let wallet_event_receiver = { + let w = client.wallet().read().await; + w.subscribe_events() + }; let cancel_token = CancellationToken::new(); let run_token = cancel_token.clone(); @@ -303,6 +341,7 @@ pub(super) async fn create_and_start_client( progress_receiver, sync_event_receiver, network_event_receiver, + wallet_event_receiver, cancel_token, } } diff --git a/dash-spv/tests/dashd_sync/tests_mempool.rs b/dash-spv/tests/dashd_sync/tests_mempool.rs new file mode 100644 index 000000000..ec71bcc24 --- /dev/null +++ b/dash-spv/tests/dashd_sync/tests_mempool.rs @@ -0,0 +1,514 @@ +use std::collections::HashSet; +use std::time::Duration; + +use dash_spv::client::config::MempoolStrategy; +use dash_spv::network::NetworkEvent; +use dash_spv::test_utils::{DashdTestContext, TestChain}; +use dashcore::Amount; + +use super::helpers::{ + assert_mempool_count_both, assert_no_mempool_tx_both, wait_for_mempool_synced_both, + wait_for_mempool_tx_both, wait_for_mempool_txs_both, wait_for_network_event, + wait_for_network_event_both, wait_for_sync_both, +}; +use super::setup::{ + client_has_transaction, create_and_start_client, create_test_wallet, TestContext, +}; + +const MEMPOOL_TIMEOUT: Duration = Duration::from_secs(30); + +/// Verify mempool detects an incoming wallet transaction using both strategies. +#[tokio::test] +async fn test_mempool_detects_incoming_tx() { + let Some(ctx) = TestContext::new(TestChain::Minimal).await else { + return; + }; + if !ctx.dashd.supports_mining { + eprintln!("Skipping test (dashd RPC miner not available)"); + return; + } + + let (mut fa, _fa_dir) = ctx.spawn_client(MempoolStrategy::FetchAll).await; + let (mut bf, _bf_dir) = ctx.spawn_client(MempoolStrategy::BloomFilter).await; + wait_for_sync_both(&mut fa, &mut bf, ctx.dashd.initial_height).await; + + let receive_address = ctx.receive_address().await; + let txid = ctx.dashd.node.send_to_address(&receive_address, Amount::from_sat(100_000_000)); + tracing::info!("Sent tx to SPV wallet, txid: {}", txid); + + let mempool_txid = wait_for_mempool_tx_both(&mut fa, &mut bf, MEMPOOL_TIMEOUT) + .await + .expect("Expected mempool TransactionReceived event"); + assert_eq!(mempool_txid, txid, "Mempool event txid should match sent txid"); + + assert_mempool_count_both(&fa, &bf, 1).await; + + fa.stop().await; + bf.stop().await; + tracing::info!("test_mempool_detects_incoming_tx passed"); +} + +/// Verify mempool ignores transactions not relevant to the SPV wallet. +#[tokio::test] +async fn test_mempool_ignores_irrelevant_tx() { + let Some(ctx) = TestContext::new(TestChain::Minimal).await else { + return; + }; + if !ctx.dashd.supports_mining { + eprintln!("Skipping test (dashd RPC miner not available)"); + return; + } + + // Fund the "default" wallet with a regular (non-coinbase) output so it's + // immediately spendable. Send from the primary wallet and mine the tx. + let default_addr = ctx.dashd.node.get_new_address_from_wallet("default"); + ctx.dashd.node.send_to_address(&default_addr, Amount::from_sat(100_000_000)); + let miner_addr = ctx.dashd.node.get_new_address_from_wallet("default"); + ctx.dashd.node.generate_blocks(1, &miner_addr); + let funded_height = ctx.dashd.initial_height + 1; + + let (mut fa, _fa_dir) = ctx.spawn_client(MempoolStrategy::FetchAll).await; + let (mut bf, _bf_dir) = ctx.spawn_client(MempoolStrategy::BloomFilter).await; + wait_for_sync_both(&mut fa, &mut bf, funded_height).await; + + // Send from the "default" wallet to itself (no relation to SPV wallet) + let non_wallet_address = ctx.dashd.node.get_new_address_from_wallet("default"); + let txid = ctx.dashd.node.send_to_address_from_wallet( + "default", + &non_wallet_address, + Amount::from_sat(50_000_000), + ); + tracing::info!("Sent irrelevant tx (not to SPV wallet), txid: {}", txid); + + assert_no_mempool_tx_both(&mut fa, &mut bf, Duration::from_secs(3)).await; + assert_mempool_count_both(&fa, &bf, 0).await; + + fa.stop().await; + bf.stop().await; + tracing::info!("test_mempool_ignores_irrelevant_tx passed"); +} + +/// Verify a mempool transaction transitions to confirmed after mining. +#[tokio::test] +async fn test_mempool_to_confirmed_lifecycle() { + let Some(ctx) = TestContext::new(TestChain::Minimal).await else { + return; + }; + if !ctx.dashd.supports_mining { + eprintln!("Skipping test (dashd RPC miner not available)"); + return; + } + + let (mut fa, _fa_dir) = ctx.spawn_client(MempoolStrategy::FetchAll).await; + let (mut bf, _bf_dir) = ctx.spawn_client(MempoolStrategy::BloomFilter).await; + wait_for_sync_both(&mut fa, &mut bf, ctx.dashd.initial_height).await; + + let receive_address = ctx.receive_address().await; + let txid = ctx.dashd.node.send_to_address(&receive_address, Amount::from_sat(100_000_000)); + tracing::info!("Sent tx to SPV wallet (lifecycle test), txid: {}", txid); + + let mempool_txid = wait_for_mempool_tx_both(&mut fa, &mut bf, MEMPOOL_TIMEOUT) + .await + .expect("Expected mempool TransactionReceived event"); + assert_eq!(mempool_txid, txid); + + assert_mempool_count_both(&fa, &bf, 1).await; + + // Mine the transaction + let miner_address = ctx.dashd.node.get_new_address_from_wallet("default"); + ctx.dashd.node.generate_blocks(1, &miner_address); + let new_height = ctx.dashd.initial_height + 1; + wait_for_sync_both(&mut fa, &mut bf, new_height).await; + + assert_mempool_count_both(&fa, &bf, 0).await; + assert!( + client_has_transaction(&fa.client, &ctx.wallet_id, &txid).await, + "FetchAll: confirmed tx should be in wallet" + ); + assert!( + client_has_transaction(&bf.client, &ctx.wallet_id, &txid).await, + "BloomFilter: confirmed tx should be in wallet" + ); + + fa.stop().await; + bf.stop().await; + tracing::info!("test_mempool_to_confirmed_lifecycle passed"); +} + +/// Verify multiple mempool transactions are all detected. +#[tokio::test] +async fn test_mempool_multiple_txs() { + let Some(ctx) = TestContext::new(TestChain::Minimal).await else { + return; + }; + if !ctx.dashd.supports_mining { + eprintln!("Skipping test (dashd RPC miner not available)"); + return; + } + + let (mut fa, _fa_dir) = ctx.spawn_client(MempoolStrategy::FetchAll).await; + let (mut bf, _bf_dir) = ctx.spawn_client(MempoolStrategy::BloomFilter).await; + wait_for_sync_both(&mut fa, &mut bf, ctx.dashd.initial_height).await; + + let receive_address = ctx.receive_address().await; + let amounts = + [Amount::from_sat(50_000_000), Amount::from_sat(75_000_000), Amount::from_sat(120_000_000)]; + let mut expected_txids = HashSet::new(); + for amount in &amounts { + let txid = ctx.dashd.node.send_to_address(&receive_address, *amount); + tracing::info!("Sent {} to SPV wallet (multi-tx test), txid: {}", amount, txid); + expected_txids.insert(txid); + } + + let received_txids = wait_for_mempool_txs_both(&mut fa, &mut bf, 3, MEMPOOL_TIMEOUT).await; + assert_eq!(received_txids, expected_txids, "Received mempool txids should match sent txids"); + + assert_mempool_count_both(&fa, &bf, 3).await; + + fa.stop().await; + bf.stop().await; + tracing::info!("test_mempool_multiple_txs passed"); +} + +/// Verify mempool detects both incoming (address match) and outgoing (outpoint match) transactions. +/// +/// 1. Sync to tip +/// 2. Send from "default" wallet TO the SPV wallet receive address (incoming) +/// 3. Wait for mempool event (address match) +/// 4. Mine the tx so it becomes a confirmed UTXO in the SPV wallet +/// 5. Craft a raw tx that spends the wallet UTXO with all outputs going to an external +/// "default" address (no change back to the wallet) and broadcast it +/// 6. Wait for mempool event (outpoint match only, no address match) +/// 7. Assert both txids were detected +#[tokio::test] +async fn test_mempool_incoming_and_outgoing_tx() { + let Some(ctx) = TestContext::new(TestChain::Minimal).await else { + return; + }; + if !ctx.dashd.supports_mining { + eprintln!("Skipping test (dashd RPC miner not available)"); + return; + } + + let (mut fa, _fa_dir) = ctx.spawn_client(MempoolStrategy::FetchAll).await; + let (mut bf, _bf_dir) = ctx.spawn_client(MempoolStrategy::BloomFilter).await; + wait_for_sync_both(&mut fa, &mut bf, ctx.dashd.initial_height).await; + + // Step 1: Send an incoming transaction to the SPV wallet + let receive_address = ctx.receive_address().await; + let incoming_amount = Amount::from_sat(200_000_000); + let incoming_txid = ctx.dashd.node.send_to_address(&receive_address, incoming_amount); + tracing::info!("Sent incoming tx to SPV wallet, txid: {}", incoming_txid); + + let mempool_txid = wait_for_mempool_tx_both(&mut fa, &mut bf, MEMPOOL_TIMEOUT) + .await + .expect("Expected mempool event for incoming tx"); + assert_eq!(mempool_txid, incoming_txid); + + // Step 2: Mine the incoming tx so it becomes a confirmed UTXO + let miner_address = ctx.dashd.node.get_new_address_from_wallet("default"); + ctx.dashd.node.generate_blocks(1, &miner_address); + let mined_height = ctx.dashd.initial_height + 1; + wait_for_sync_both(&mut fa, &mut bf, mined_height).await; + + // Step 3: Craft a raw transaction that spends the wallet UTXO with all outputs + // going to an external address. This ensures the mempool detects it purely via + // the watched outpoint, not via any output address match. + let wallet_name = &ctx.dashd.wallet.wallet_name; + let utxos = ctx.dashd.node.list_unspent_from_wallet(wallet_name); + let utxo = utxos + .iter() + .find(|u| u.txid == incoming_txid) + .expect("Incoming tx UTXO not found in wallet"); + + let external_address = ctx.dashd.node.get_new_address_from_wallet("default"); + let fee = Amount::from_sat(10_000); + let outgoing_txid = ctx.dashd.node.send_raw_from_wallet( + wallet_name, + utxo.txid, + utxo.vout, + utxo.amount, + &external_address, + fee, + ); + tracing::info!("Sent raw outgoing tx (outpoint-only match), txid: {}", outgoing_txid); + + let mempool_txid = wait_for_mempool_tx_both(&mut fa, &mut bf, MEMPOOL_TIMEOUT) + .await + .expect("Expected mempool event for outgoing tx (outpoint match)"); + assert_eq!(mempool_txid, outgoing_txid); + + fa.stop().await; + bf.stop().await; + tracing::info!("test_mempool_incoming_and_outgoing_tx passed"); +} + +/// Verify full mempool lifecycle: detection, disconnect recovery, and confirmation. +/// +/// 1. Sync to tip with empty mempool +/// 2. Send 2 transactions, verify both arrive via mempool events +/// 3. Disconnect the SPV client from the peer (via dashd disconnectnode) +/// 4. Send 1 transaction while disconnected (it sits in dashd's mempool) +/// 5. Reconnect and wait for mempool reactivation +/// 6. Verify the tx sent while disconnected is detected (mempool dump on reconnect) +/// 7. Verify all 3 transactions are tracked +/// 8. Mine a block, verify all txs transition to confirmed, mempool count drops to 0 +#[tokio::test] +async fn test_mempool_lifecycle() { + let Some(ctx) = TestContext::new(TestChain::Minimal).await else { + return; + }; + if !ctx.dashd.supports_mining { + eprintln!("Skipping test (dashd RPC miner not available)"); + return; + } + + let (mut fa, _fa_dir) = ctx.spawn_client(MempoolStrategy::FetchAll).await; + let (mut bf, _bf_dir) = ctx.spawn_client(MempoolStrategy::BloomFilter).await; + wait_for_sync_both(&mut fa, &mut bf, ctx.dashd.initial_height).await; + + // Wait for mempool activation before sending transactions + wait_for_mempool_synced_both(&mut fa, &mut bf).await; + tokio::time::sleep(Duration::from_secs(1)).await; + tracing::info!("Mempool synced on both clients"); + + // Step 1: Send 2 transactions, verify both arrive + let receive_address = ctx.receive_address().await; + let txid1 = ctx.dashd.node.send_to_address(&receive_address, Amount::from_sat(50_000_000)); + let txid2 = ctx.dashd.node.send_to_address(&receive_address, Amount::from_sat(60_000_000)); + tracing::info!("Sent tx1={}, tx2={}", txid1, txid2); + + let received = wait_for_mempool_txs_both(&mut fa, &mut bf, 2, MEMPOOL_TIMEOUT).await; + assert!(received.contains(&txid1), "Should have received tx1"); + assert!(received.contains(&txid2), "Should have received tx2"); + assert_mempool_count_both(&fa, &bf, 2).await; + + // Step 2: Disconnect the peer + ctx.dashd.node.disconnect_all_peers(); + let saw_disconnect = wait_for_network_event_both( + &mut fa, + &mut bf, + |e| matches!(e, NetworkEvent::PeerDisconnected { .. }), + Duration::from_secs(10), + ) + .await; + assert!(saw_disconnect, "Both clients should observe PeerDisconnected"); + + // Step 3: Send a transaction while disconnected + let txid3 = ctx.dashd.node.send_to_address(&receive_address, Amount::from_sat(70_000_000)); + tracing::info!("Sent tx3={} while disconnected", txid3); + + // Step 4: Reconnect and wait for mempool reactivation + let saw_reconnect = wait_for_network_event_both( + &mut fa, + &mut bf, + |e| matches!(e, NetworkEvent::PeerConnected { .. }), + Duration::from_secs(30), + ) + .await; + assert!(saw_reconnect, "Both clients should reconnect to peer"); + + wait_for_mempool_synced_both(&mut fa, &mut bf).await; + tracing::info!("Mempool reactivated after reconnect on both clients"); + + // Step 5: Verify tx sent while disconnected is detected via mempool dump + let detected = wait_for_mempool_tx_both(&mut fa, &mut bf, MEMPOOL_TIMEOUT) + .await + .expect("Expected mempool event for tx sent while disconnected"); + assert_eq!(detected, txid3, "Should detect tx3 via mempool dump on reconnect"); + + // Step 6: Verify all 3 transactions tracked + assert_mempool_count_both(&fa, &bf, 3).await; + + // Step 7: Mine a block, verify all txs confirmed + let miner_address = ctx.dashd.node.get_new_address_from_wallet("default"); + ctx.dashd.node.generate_blocks(1, &miner_address); + let new_height = ctx.dashd.initial_height + 1; + wait_for_sync_both(&mut fa, &mut bf, new_height).await; + + assert_mempool_count_both(&fa, &bf, 0).await; + for (label, client) in [("FetchAll", &fa.client), ("BloomFilter", &bf.client)] { + assert!( + client_has_transaction(client, &ctx.wallet_id, &txid1).await, + "{}: tx1 should be confirmed", + label + ); + assert!( + client_has_transaction(client, &ctx.wallet_id, &txid2).await, + "{}: tx2 should be confirmed", + label + ); + assert!( + client_has_transaction(client, &ctx.wallet_id, &txid3).await, + "{}: tx3 should be confirmed", + label + ); + } + + fa.stop().await; + bf.stop().await; + tracing::info!("test_mempool_lifecycle passed"); +} + +/// Verify mempool handles peer disconnection with multi-peer activation. +/// +/// Uses two dashd nodes connected to each other. Both SPV clients connect to both peers and +/// exercise these scenarios in sequence: +/// 1. Both peers active after sync — send tx, verify detection +/// 2. Disconnect one peer — no event expected, send tx from remaining, verify detection +/// 3. Disconnect both, reconnect — wait for mempool Synced state, verify detection +#[tokio::test] +async fn test_mempool_peer_disconnect_reactivation() { + let Some(ctx) = TestContext::new(TestChain::Minimal).await else { + return; + }; + if !ctx.dashd.supports_mining { + eprintln!("Skipping test (dashd RPC miner not available)"); + return; + } + + let Some(dashd2) = DashdTestContext::new(TestChain::Minimal).await else { + eprintln!("Skipping test (could not create second dashd node)"); + return; + }; + + // Connect the two dashd nodes so mempool transactions propagate between them + ctx.dashd.node.connect_to_node(dashd2.addr).await; + + // Spawn both SPV clients with both peers configured + let mut fa_config = ctx.client_config.clone(); + fa_config.add_peer(dashd2.addr); + + let fa_storage = tempfile::TempDir::new().expect("Failed to create FetchAll temp dir"); + let bf_storage = tempfile::TempDir::new().expect("Failed to create BloomFilter temp dir"); + + let mut fa_cfg = fa_config.clone(); + fa_cfg.storage_path = fa_storage.path().to_path_buf(); + fa_cfg.mempool_strategy = MempoolStrategy::FetchAll; + + let mut bf_cfg = fa_config.clone(); + bf_cfg.storage_path = bf_storage.path().to_path_buf(); + bf_cfg.mempool_strategy = MempoolStrategy::BloomFilter; + + let (fa_wallet, _) = create_test_wallet(&ctx.dashd.wallet.mnemonic, dash_spv::Network::Regtest); + let (bf_wallet, _) = create_test_wallet(&ctx.dashd.wallet.mnemonic, dash_spv::Network::Regtest); + + let mut fa = create_and_start_client(&fa_cfg, fa_wallet).await; + let mut bf = create_and_start_client(&bf_cfg, bf_wallet).await; + + // Sync both clients + wait_for_sync_both(&mut fa, &mut bf, ctx.dashd.initial_height).await; + + // Both peers should be activated after sync + wait_for_mempool_synced_both(&mut fa, &mut bf).await; + tokio::time::sleep(Duration::from_secs(1)).await; + tracing::info!("Mempool synced on all peers for both clients"); + + // --- Scenario 1: baseline mempool detection with both peers --- + let receive_address = ctx.receive_address().await; + let txid1 = ctx.dashd.node.send_to_address(&receive_address, Amount::from_sat(50_000_000)); + tracing::info!("[scenario 1] sent tx {}", txid1); + + let detected = wait_for_mempool_tx_both(&mut fa, &mut bf, MEMPOOL_TIMEOUT) + .await + .expect("Scenario 1: expected mempool tx detection"); + assert_eq!(detected, txid1); + assert_mempool_count_both(&fa, &bf, 1).await; + + // --- Scenario 2: disconnect one peer, verify detection still works --- + // Resubscribe to get fresh receivers, avoiding stale events or lagged errors + // from earlier phases that could cause the wait to miss the disconnect event. + let mut fa_net_rx = fa.network_event_receiver.resubscribe(); + let mut bf_net_rx = bf.network_event_receiver.resubscribe(); + + ctx.dashd.node.disconnect_all_peers(); + + let (fa_disc, bf_disc) = tokio::join!( + wait_for_network_event( + &mut fa_net_rx, + |e| matches!(e, NetworkEvent::PeerDisconnected { address } if *address == ctx.dashd.addr), + Duration::from_secs(10), + ), + wait_for_network_event( + &mut bf_net_rx, + |e| matches!(e, NetworkEvent::PeerDisconnected { address } if *address == ctx.dashd.addr), + Duration::from_secs(10), + ), + ); + assert!(fa_disc, "FetchAll: should observe PeerDisconnected"); + assert!(bf_disc, "BloomFilter: should observe PeerDisconnected"); + tokio::time::sleep(Duration::from_secs(1)).await; + + let txid2 = dashd2.node.send_to_address(&receive_address, Amount::from_sat(60_000_000)); + tracing::info!("[scenario 2] sent tx {} from remaining peer", txid2); + + let detected = wait_for_mempool_tx_both(&mut fa, &mut bf, MEMPOOL_TIMEOUT) + .await + .expect("Scenario 2: expected mempool tx detection from remaining peer"); + assert_eq!(detected, txid2); + assert_mempool_count_both(&fa, &bf, 2).await; + + // --- Scenario 3: disconnect both peers, verify recovery --- + ctx.dashd.node.set_network_active(false); + dashd2.node.set_network_active(false); + + // Wait for both disconnect events on both clients + for (label, receiver) in [ + ("FetchAll", &mut fa.network_event_receiver), + ("BloomFilter", &mut bf.network_event_receiver), + ] { + let mut seen_dashd1 = false; + let mut seen_dashd2 = false; + let deadline = tokio::time::sleep(Duration::from_secs(10)); + tokio::pin!(deadline); + while !seen_dashd1 || !seen_dashd2 { + tokio::select! { + _ = &mut deadline => panic!("{}: timed out waiting for both peer disconnects", label), + result = receiver.recv() => { + match result { + Ok(NetworkEvent::PeerDisconnected { address }) if address == ctx.dashd.addr => { + seen_dashd1 = true; + } + Ok(NetworkEvent::PeerDisconnected { address }) if address == dashd2.addr => { + seen_dashd2 = true; + } + _ => {} + } + } + } + } + } + tracing::info!("[scenario 3] both peers disconnected from both clients"); + + // Re-enable networking so SPV can reconnect + ctx.dashd.node.set_network_active(true); + dashd2.node.set_network_active(true); + ctx.dashd.node.connect_to_node(dashd2.addr).await; + + // Wait for reconnection and mempool reactivation on both clients + let saw_reconnect = wait_for_network_event_both( + &mut fa, + &mut bf, + |e| matches!(e, NetworkEvent::PeerConnected { .. }), + Duration::from_secs(30), + ) + .await; + assert!(saw_reconnect, "Both clients should reconnect to a peer"); + + wait_for_mempool_synced_both(&mut fa, &mut bf).await; + tokio::time::sleep(Duration::from_secs(1)).await; + tracing::info!("[scenario 3] mempool recovered on both clients"); + + let txid3 = ctx.dashd.node.send_to_address(&receive_address, Amount::from_sat(70_000_000)); + tracing::info!("[scenario 3] sent tx {}", txid3); + + let detected = wait_for_mempool_tx_both(&mut fa, &mut bf, MEMPOOL_TIMEOUT) + .await + .expect("Scenario 3: expected mempool tx detection after recovery"); + assert_eq!(detected, txid3); + assert_mempool_count_both(&fa, &bf, 3).await; + + fa.stop().await; + bf.stop().await; + tracing::info!("test_mempool_peer_disconnect_reactivation passed"); +} diff --git a/key-wallet-manager/src/lib.rs b/key-wallet-manager/src/lib.rs index 53c608e9e..561b84622 100644 --- a/key-wallet-manager/src/lib.rs +++ b/key-wallet-manager/src/lib.rs @@ -45,5 +45,5 @@ pub use key_wallet::wallet::managed_wallet_info::coin_selection::{ }; pub use key_wallet::wallet::managed_wallet_info::fee::FeeRate; pub use key_wallet::wallet::managed_wallet_info::transaction_builder::TransactionBuilder; -pub use wallet_interface::BlockProcessingResult; +pub use wallet_interface::{BlockProcessingResult, MempoolTransactionResult}; pub use wallet_manager::{WalletError, WalletManager}; diff --git a/key-wallet-manager/src/test_utils/wallet.rs b/key-wallet-manager/src/test_utils/wallet.rs index d155bbf4e..68c81f221 100644 --- a/key-wallet-manager/src/test_utils/wallet.rs +++ b/key-wallet-manager/src/test_utils/wallet.rs @@ -1,7 +1,11 @@ -use crate::{wallet_interface::WalletInterface, BlockProcessingResult, WalletEvent}; +use crate::{ + wallet_interface::WalletInterface, BlockProcessingResult, MempoolTransactionResult, WalletEvent, +}; +use dashcore::address::NetworkUnchecked; use dashcore::prelude::CoreBlockHeight; -use dashcore::{Address, Block, Transaction, Txid}; +use dashcore::{Address, Block, OutPoint, Transaction, Txid}; use key_wallet::transaction_checking::TransactionContext; +use std::str::FromStr; use std::{collections::BTreeMap, sync::Arc}; use tokio::sync::{broadcast, Mutex}; @@ -15,16 +19,18 @@ pub struct MockWallet { effects: TransactionEffectsMap, synced_height: CoreBlockHeight, event_sender: broadcast::Sender, + /// When true, process_mempool_transaction returns is_relevant=true. + mempool_relevant: bool, + /// Addresses returned by monitored_addresses. + addresses: Vec
, + /// Outpoints returned by watched_outpoints. + outpoints: Vec, + /// New addresses returned by process_mempool_transaction. + mempool_new_addresses: Vec
, /// Recorded status change notifications for test assertions. status_changes: Arc>>, } -impl Default for MockWallet { - fn default() -> Self { - Self::new() - } -} - impl MockWallet { pub fn new() -> Self { let (event_sender, _) = broadcast::channel(16); @@ -34,12 +40,32 @@ impl MockWallet { effects: Arc::new(Mutex::new(BTreeMap::new())), synced_height: 0, event_sender, + mempool_relevant: false, + addresses: Vec::new(), + outpoints: Vec::new(), + mempool_new_addresses: Vec::new(), status_changes: Arc::new(Mutex::new(Vec::new())), } } - pub fn status_changes(&self) -> Arc>> { - self.status_changes.clone() + /// Configure whether mempool transactions are reported as relevant. + pub fn set_mempool_relevant(&mut self, relevant: bool) { + self.mempool_relevant = relevant; + } + + /// Set the addresses returned by monitored_addresses. + pub fn set_addresses(&mut self, addresses: Vec
) { + self.addresses = addresses; + } + + /// Set the outpoints returned by watched_outpoints. + pub fn set_outpoints(&mut self, outpoints: Vec) { + self.outpoints = outpoints; + } + + /// Set new addresses returned by process_mempool_transaction. + pub fn set_mempool_new_addresses(&mut self, addresses: Vec
) { + self.mempool_new_addresses = addresses; } pub async fn set_effect(&self, txid: dashcore::Txid, net: i64, addresses: Vec) { @@ -54,6 +80,16 @@ impl MockWallet { pub fn processed_transactions(&self) -> Arc>> { self.processed_transactions.clone() } + + pub fn status_changes(&self) -> Arc>> { + self.status_changes.clone() + } +} + +impl Default for MockWallet { + fn default() -> Self { + Self::new() + } } #[async_trait::async_trait] @@ -69,9 +105,38 @@ impl WalletInterface for MockWallet { } } - async fn process_mempool_transaction(&mut self, tx: &Transaction) { + async fn process_mempool_transaction( + &mut self, + tx: &Transaction, + _is_instant_send: bool, + ) -> MempoolTransactionResult { let mut processed = self.processed_transactions.lock().await; processed.push(tx.txid()); + + if !self.mempool_relevant { + return MempoolTransactionResult::default(); + } + + let effects = self.effects.lock().await; + let (net_amount, addresses) = if let Some((net, addr_strs)) = effects.get(&tx.txid()) { + let addrs = addr_strs + .iter() + .filter_map(|s| { + Address::::from_str(s).ok().map(|a| a.assume_checked()) + }) + .collect(); + (*net, addrs) + } else { + (0, Vec::new()) + }; + + MempoolTransactionResult { + is_relevant: true, + net_amount, + is_outgoing: net_amount < 0, + addresses, + new_addresses: self.mempool_new_addresses.clone(), + } } async fn describe(&self) -> String { @@ -84,7 +149,11 @@ impl WalletInterface for MockWallet { } fn monitored_addresses(&self) -> Vec
{ - Vec::new() + self.addresses.clone() + } + + fn watched_outpoints(&self) -> Vec { + self.outpoints.clone() } fn synced_height(&self) -> CoreBlockHeight { @@ -134,12 +203,22 @@ impl WalletInterface for NonMatchingMockWallet { BlockProcessingResult::default() } - async fn process_mempool_transaction(&mut self, _tx: &Transaction) {} + async fn process_mempool_transaction( + &mut self, + _tx: &Transaction, + _is_instant_send: bool, + ) -> MempoolTransactionResult { + MempoolTransactionResult::default() + } fn monitored_addresses(&self) -> Vec
{ Vec::new() } + fn watched_outpoints(&self) -> Vec { + Vec::new() + } + fn synced_height(&self) -> CoreBlockHeight { self.synced_height } diff --git a/key-wallet-manager/src/wallet_interface.rs b/key-wallet-manager/src/wallet_interface.rs index 8325bcf34..e3cbab4c9 100644 --- a/key-wallet-manager/src/wallet_interface.rs +++ b/key-wallet-manager/src/wallet_interface.rs @@ -7,7 +7,7 @@ use alloc::string::String; use alloc::vec::Vec; use async_trait::async_trait; use dashcore::prelude::CoreBlockHeight; -use dashcore::{Address, Block, Transaction, Txid}; +use dashcore::{Address, Block, OutPoint, Transaction, Txid}; use tokio::sync::broadcast; /// Result of processing a block through the wallet @@ -21,6 +21,21 @@ pub struct BlockProcessingResult { pub new_addresses: Vec
, } +/// Result of processing a mempool transaction through the wallet +#[derive(Debug, Default, Clone)] +pub struct MempoolTransactionResult { + /// Whether the transaction was relevant to any wallet. + pub is_relevant: bool, + /// Net amount change for the wallet (received - sent) in satoshis. + pub net_amount: i64, + /// Whether this is an outgoing transaction (net_amount < 0). + pub is_outgoing: bool, + /// Addresses involved in this transaction. + pub addresses: Vec
, + /// New addresses generated during gap limit maintenance. + pub new_addresses: Vec
, +} + impl BlockProcessingResult { /// Returns all relevant transaction IDs (new and existing) pub fn relevant_txids(&self) -> impl Iterator { @@ -45,12 +60,22 @@ pub trait WalletInterface: Send + Sync + 'static { height: CoreBlockHeight, ) -> BlockProcessingResult; - /// Called when a transaction is seen in the mempool - async fn process_mempool_transaction(&mut self, tx: &Transaction); + /// Called when a transaction is seen in the mempool. + /// Returns whether the transaction was relevant and any new addresses generated. + /// When `is_instant_send` is true, the transaction already has an IS lock. + async fn process_mempool_transaction( + &mut self, + tx: &Transaction, + is_instant_send: bool, + ) -> MempoolTransactionResult; /// Get all addresses the wallet is monitoring for incoming transactions fn monitored_addresses(&self) -> Vec
; + /// Get all outpoints the wallet is watching (unspent outputs). + /// Used for bloom filter construction to detect spends of our UTXOs. + fn watched_outpoints(&self) -> Vec; + /// 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. /// If the wallet has no record for the transaction, returns None. diff --git a/key-wallet-manager/src/wallet_manager/event_tests.rs b/key-wallet-manager/src/wallet_manager/event_tests.rs index a1e3d762a..e656332cd 100644 --- a/key-wallet-manager/src/wallet_manager/event_tests.rs +++ b/key-wallet-manager/src/wallet_manager/event_tests.rs @@ -175,6 +175,105 @@ async fn test_mempool_after_instantsend_is_suppressed() { .await; } +// --------------------------------------------------------------------------- +// BalanceUpdated event tests +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_mempool_tx_emits_balance_updated() { + let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); + let mut rx = manager.subscribe_events(); + let tx = create_tx_paying_to(&addr, 0xf1); + + manager.process_mempool_transaction(&tx, false).await; + + let events = drain_events(&mut rx); + let balance_events: Vec<_> = + events.iter().filter(|e| matches!(e, WalletEvent::BalanceUpdated { .. })).collect(); + assert_eq!(balance_events.len(), 1, "expected exactly 1 BalanceUpdated, got {:?}", events); + assert!( + matches!( + balance_events[0], + WalletEvent::BalanceUpdated { + wallet_id: wid, + unconfirmed, + spendable, + .. + } if *wid == wallet_id && *unconfirmed == TX_AMOUNT && *spendable == 0 + ), + "expected BalanceUpdated with unconfirmed={TX_AMOUNT}, spendable=0, got {:?}", + balance_events[0] + ); +} + +#[tokio::test] +async fn test_instantsend_tx_emits_balance_updated_spendable() { + let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); + let mut rx = manager.subscribe_events(); + let tx = create_tx_paying_to(&addr, 0xf2); + + manager.process_mempool_transaction(&tx, true).await; + + let events = drain_events(&mut rx); + let balance_events: Vec<_> = + events.iter().filter(|e| matches!(e, WalletEvent::BalanceUpdated { .. })).collect(); + assert_eq!(balance_events.len(), 1, "expected exactly 1 BalanceUpdated, got {:?}", events); + assert!( + matches!( + balance_events[0], + WalletEvent::BalanceUpdated { + wallet_id: wid, + spendable, + unconfirmed, + .. + } if *wid == wallet_id && *spendable == TX_AMOUNT && *unconfirmed == 0 + ), + "expected BalanceUpdated with spendable={TX_AMOUNT}, unconfirmed=0, got {:?}", + balance_events[0] + ); +} + +#[tokio::test] +async fn test_mempool_to_instantsend_transitions_balance() { + let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); + let mut rx = manager.subscribe_events(); + let tx = create_tx_paying_to(&addr, 0xf3); + + // Mempool tx: balance should be unconfirmed + manager.process_mempool_transaction(&tx, false).await; + let events = drain_events(&mut rx); + assert!( + events.iter().any(|e| matches!( + e, + WalletEvent::BalanceUpdated { + wallet_id: wid, + unconfirmed, + spendable, + .. + } if *wid == wallet_id && *unconfirmed == TX_AMOUNT && *spendable == 0 + )), + "expected unconfirmed balance after mempool, got {:?}", + events + ); + + // IS lock: balance should move from unconfirmed to spendable + manager.process_instant_send_lock(tx.txid()); + let events = drain_events(&mut rx); + assert!( + events.iter().any(|e| matches!( + e, + WalletEvent::BalanceUpdated { + wallet_id: wid, + spendable, + unconfirmed, + .. + } if *wid == wallet_id && *spendable == TX_AMOUNT && *unconfirmed == 0 + )), + "expected spendable balance after IS lock, got {:?}", + events + ); +} + // --------------------------------------------------------------------------- // Production API tests // --------------------------------------------------------------------------- @@ -194,6 +293,68 @@ async fn test_process_instant_send_lock_for_unknown_txid() { assert_eq!(balance_before, balance_after); } +#[tokio::test] +async fn test_process_instant_send_lock_dedup() { + let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); + let tx = create_tx_paying_to(&addr, 0xe1); + + manager.process_mempool_transaction(&tx, false).await; + let mut rx = manager.subscribe_events(); + + // First IS lock should emit events + manager.process_instant_send_lock(tx.txid()); + let events = drain_events(&mut rx); + assert!( + events.iter().any(|e| matches!( + e, + WalletEvent::TransactionStatusChanged { + wallet_id: wid, + status: TransactionContext::InstantSend, + .. + } if *wid == wallet_id + )), + "expected TransactionStatusChanged(InstantSend) with correct wallet_id, got {:?}", + events + ); + assert!( + events.iter().any( + |e| matches!(e, WalletEvent::BalanceUpdated { wallet_id: wid, .. } if *wid == wallet_id) + ), + "expected BalanceUpdated for wallet, got {:?}", + events + ); + + // Second IS lock should be a no-op + manager.process_instant_send_lock(tx.txid()); + assert_no_events(&mut rx); +} + +#[tokio::test] +async fn test_process_instant_send_lock_after_block_confirmation() { + let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); + let tx = create_tx_paying_to(&addr, 0xe2); + + // Process as IS mempool tx, then confirm in block + manager.process_mempool_transaction(&tx, true).await; + let block_ctx = TransactionContext::InBlock { + height: 500, + block_hash: Some(BlockHash::from_byte_array([0xe2; 32])), + timestamp: Some(5000), + }; + manager.check_transaction_in_all_wallets(&tx, block_ctx, true, true).await; + + // IS lock after block confirmation is a no-op (already tracked via mempool IS) + let mut rx = manager.subscribe_events(); + manager.process_instant_send_lock(tx.txid()); + assert_no_events(&mut rx); + + // Confirm height preserved + let history = manager.wallet_transaction_history(&wallet_id).unwrap(); + let records: Vec<_> = history.iter().filter(|r| r.txid == tx.txid()).collect(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].height, Some(500)); +} + #[tokio::test] async fn test_mixed_instantsend_paths_no_duplicate_events() { let (mut manager, wallet_id, addr) = setup_manager_with_wallet(); @@ -326,6 +487,46 @@ async fn test_process_block_emits_events() { ); } +#[tokio::test] +async fn test_irrelevant_mempool_tx_emits_no_events() { + use dashcore::{PublicKey, ScriptBuf}; + + let (mut manager, _wallet_id, _addr) = setup_manager_with_wallet(); + let mut rx = manager.subscribe_events(); + + // Create a tx paying to a random script that doesn't match any wallet address + let random_script = + ScriptBuf::new_p2pkh(&PublicKey::from_slice(&[2; 33]).unwrap().pubkey_hash()); + let tx = Transaction { + version: 2, + lock_time: 0, + input: vec![dashcore::TxIn { + previous_output: dashcore::OutPoint { + txid: dashcore::Txid::from_byte_array([0xe4; 32]), + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: u32::MAX, + witness: dashcore::Witness::default(), + }], + output: vec![dashcore::TxOut { + value: TX_AMOUNT, + script_pubkey: random_script, + }], + special_transaction_payload: None, + }; + + let result = manager.process_mempool_transaction(&tx, false).await; + + assert!(!result.is_relevant); + assert_eq!(result.net_amount, 0); + assert_no_events(&mut rx); +} + +// --------------------------------------------------------------------------- +// Edge case tests +// --------------------------------------------------------------------------- + #[tokio::test] async fn test_instantsend_to_chainlocked_event_flow() { assert_lifecycle_flow( @@ -342,6 +543,58 @@ async fn test_instantsend_to_chainlocked_event_flow() { .await; } +#[tokio::test] +async fn test_mempool_to_block_to_chainlocked_event_flow() { + let (mut manager, _wallet_id, addr) = setup_manager_with_wallet(); + let mut rx = manager.subscribe_events(); + let tx = create_tx_paying_to(&addr, 0xc4); + + // Step 1: mempool — emits TransactionReceived + manager.check_transaction_in_all_wallets(&tx, TransactionContext::Mempool, true, true).await; + let event = assert_single_event(&mut rx); + assert!( + matches!( + event, + WalletEvent::TransactionReceived { + status: TransactionContext::Mempool, + .. + } + ), + "expected TransactionReceived(Mempool), got {:?}", + event + ); + + // Step 2: block confirmation — emits TransactionStatusChanged + let block_ctx = TransactionContext::InBlock { + height: 1700, + block_hash: Some(BlockHash::from_byte_array([0xc4; 32])), + timestamp: Some(17000), + }; + manager.check_transaction_in_all_wallets(&tx, block_ctx, true, true).await; + let event = assert_single_event(&mut rx); + assert!( + matches!( + event, + WalletEvent::TransactionStatusChanged { + status: TransactionContext::InBlock { .. }, + .. + } + ), + "expected TransactionStatusChanged(InBlock), got {:?}", + event + ); + + // Step 3: chain lock on already-confirmed tx — no event (wallet doesn't + // track chain lock state separately from block confirmation) + let cl_ctx = TransactionContext::InChainLockedBlock { + height: 1700, + block_hash: Some(BlockHash::from_byte_array([0xc4; 32])), + timestamp: Some(17000), + }; + manager.check_transaction_in_all_wallets(&tx, cl_ctx, true, true).await; + assert_no_events(&mut rx); +} + #[tokio::test] async fn test_chainlocked_block_event_flow() { let (mut manager, _wallet_id, addr) = setup_manager_with_wallet(); @@ -383,6 +636,7 @@ async fn test_check_transaction_dry_run_does_not_persist_state() { .await; assert!(!result.affected_wallets.is_empty()); + assert_eq!(result.total_received, TX_AMOUNT); assert_no_events(&mut rx); // Call again — should still report as relevant (state not persisted) @@ -390,6 +644,7 @@ async fn test_check_transaction_dry_run_does_not_persist_state() { .check_transaction_in_all_wallets(&tx, TransactionContext::Mempool, false, false) .await; assert!(!result2.affected_wallets.is_empty()); + assert_eq!(result2.total_received, TX_AMOUNT); assert_no_events(&mut rx); // Now persist — should still report as new since dry runs didn't record it diff --git a/key-wallet-manager/src/wallet_manager/mod.rs b/key-wallet-manager/src/wallet_manager/mod.rs index 79c55326c..bb28ef69b 100644 --- a/key-wallet-manager/src/wallet_manager/mod.rs +++ b/key-wallet-manager/src/wallet_manager/mod.rs @@ -67,6 +67,12 @@ pub struct CheckTransactionsResult { pub is_new_transaction: bool, /// New addresses generated during gap limit maintenance pub new_addresses: Vec
, + /// Total value received across all wallets + pub total_received: u64, + /// Total value sent across all wallets + pub total_sent: u64, + /// Addresses involved across all wallets + pub involved_addresses: Vec
, } /// High-level wallet manager that manages multiple wallets @@ -531,6 +537,16 @@ impl WalletManager { result.is_new_transaction = true; } + // Aggregate totals and involved addresses across wallets + result.total_received = + result.total_received.saturating_add(check_result.total_received); + result.total_sent = result.total_sent.saturating_add(check_result.total_sent); + for account_match in &check_result.affected_accounts { + for addr_info in account_match.account_type_match.all_involved_addresses() { + result.involved_addresses.push(addr_info.address); + } + } + #[cfg(feature = "std")] if check_result.is_new_transaction { // First time seeing this transaction — emit TransactionReceived @@ -1049,6 +1065,16 @@ impl WalletManager { } } } + + /// Get all outpoints from wallet UTXOs across all managed wallets. + /// Used for bloom filter construction to detect spends of our UTXOs. + pub fn watched_outpoints(&self) -> Vec { + let mut outpoints = Vec::new(); + for info in self.wallet_infos.values() { + outpoints.extend(info.utxos().into_iter().map(|u| u.outpoint)); + } + outpoints + } } /// Wallet manager errors diff --git a/key-wallet-manager/src/wallet_manager/process_block.rs b/key-wallet-manager/src/wallet_manager/process_block.rs index 052352665..286995862 100644 --- a/key-wallet-manager/src/wallet_manager/process_block.rs +++ b/key-wallet-manager/src/wallet_manager/process_block.rs @@ -1,4 +1,4 @@ -use crate::wallet_interface::{BlockProcessingResult, WalletInterface}; +use crate::wallet_interface::{BlockProcessingResult, MempoolTransactionResult, WalletInterface}; use crate::WalletEvent; use crate::WalletManager; use alloc::string::String; @@ -50,17 +50,51 @@ impl WalletInterface for WalletM result } - async fn process_mempool_transaction(&mut self, tx: &Transaction) { - let context = TransactionContext::Mempool; + async fn process_mempool_transaction( + &mut self, + tx: &Transaction, + is_instant_send: bool, + ) -> MempoolTransactionResult { + let context = if is_instant_send { + TransactionContext::InstantSend + } else { + TransactionContext::Mempool + }; + let snapshot = self.snapshot_balances(); + let check_result = self.check_transaction_in_all_wallets(tx, context, true, false).await; + + let is_relevant = !check_result.affected_wallets.is_empty(); + let net_amount = if is_relevant { + check_result.total_received as i64 - check_result.total_sent as i64 + } else { + 0 + }; + + // Refresh cached balances only for affected wallets + for wallet_id in &check_result.affected_wallets { + if let Some(info) = self.wallet_infos.get_mut(wallet_id) { + info.update_balance(); + } + } + self.emit_balance_changes(&snapshot); - // Check transaction against all wallets - self.check_transaction_in_all_wallets(tx, context, true, true).await; + MempoolTransactionResult { + is_relevant, + net_amount, + is_outgoing: net_amount < 0, + addresses: check_result.involved_addresses, + new_addresses: check_result.new_addresses, + } } fn monitored_addresses(&self) -> Vec
{ self.monitored_addresses() } + fn watched_outpoints(&self) -> Vec { + self.watched_outpoints() + } + async fn transaction_effect(&self, tx: &Transaction) -> Option<(i64, Vec)> { // Aggregate across all managed wallets. If any wallet considers it relevant, // compute net = total_received - total_sent and collect involved addresses. @@ -195,9 +229,29 @@ impl WalletInterface for WalletM #[cfg(test)] mod tests { use super::*; - use dashcore::Network; + use crate::wallet_manager::test_helpers::*; + use dashcore::block::{Header, Version}; + use dashcore::hashes::Hash; + use dashcore::pow::CompactTarget; + use dashcore::{ + BlockHash, Network, OutPoint, ScriptBuf, TxIn, TxMerkleNode, TxOut, Txid, Witness, + }; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + fn make_block(txdata: Vec) -> Block { + Block { + header: Header { + version: Version::ONE, + prev_blockhash: BlockHash::from_byte_array([0; 32]), + merkle_root: TxMerkleNode::from_byte_array([0; 32]), + time: 1000, + bits: CompactTarget::from_consensus(0x1d00ffff), + nonce: 0, + }, + txdata, + } + } + #[tokio::test] async fn test_synced_height() { let mut manager: WalletManager = WalletManager::new(Network::Testnet); @@ -213,4 +267,111 @@ mod tests { manager.update_synced_height(10); assert_eq!(manager.synced_height(), 10); } + + #[tokio::test] + async fn test_process_mempool_transaction_balance_events() { + let (mut manager, _wallet_id, addr) = setup_manager_with_wallet(); + let mut rx = manager.subscribe_events(); + + // Relevant tx should emit BalanceUpdated + let tx = create_tx_paying_to(&addr, 0xaa); + manager.process_mempool_transaction(&tx, false).await; + + let mut found = false; + while let Ok(event) = rx.try_recv() { + if let WalletEvent::BalanceUpdated { + unconfirmed, + .. + } = event + { + assert!(unconfirmed > 0, "unconfirmed balance should increase"); + found = true; + break; + } + } + assert!(found, "should emit BalanceUpdated for mempool transaction"); + + // Irrelevant tx should not emit any events + let unrelated_tx = Transaction { + version: 2, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint { + txid: Txid::from_byte_array([0xbb; 32]), + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: u32::MAX, + witness: Witness::default(), + }], + output: vec![TxOut { + value: 100_000, + script_pubkey: ScriptBuf::new_p2pkh(&dashcore::PubkeyHash::from_byte_array( + [0xff; 20], + )), + }], + special_transaction_payload: None, + }; + manager.process_mempool_transaction(&unrelated_tx, false).await; + assert!(rx.try_recv().is_err(), "should not emit events for irrelevant transaction"); + } + + #[tokio::test] + async fn test_process_block_emits_balance_updated() { + let (mut manager, _wallet_id, addr) = setup_manager_with_wallet(); + let tx = create_tx_paying_to(&addr, 0xcc); + let block = make_block(vec![tx]); + + let mut rx = manager.subscribe_events(); + manager.process_block(&block, 100).await; + + let mut found = false; + while let Ok(event) = rx.try_recv() { + if let WalletEvent::BalanceUpdated { + spendable, + .. + } = event + { + assert!(spendable > 0, "spendable balance should increase after block"); + found = true; + break; + } + } + assert!(found, "should emit BalanceUpdated for block processing"); + } + + #[tokio::test] + async fn test_mempool_transaction_result_contains_wallet_effect_data() { + let (mut manager, _wallet_id, addr) = setup_manager_with_wallet(); + let tx = create_tx_paying_to(&addr, 0xaa); + + let result = manager.process_mempool_transaction(&tx, false).await; + + assert!(result.is_relevant); + assert_eq!(result.net_amount, TX_AMOUNT as i64); + assert!(!result.is_outgoing); + assert!(!result.addresses.is_empty()); + } + + #[tokio::test] + async fn test_check_transaction_populates_totals() { + let (mut manager, _wallet_id, addr) = setup_manager_with_wallet(); + + let tx = create_tx_paying_to(&addr, 0xf0); + let result = manager + .check_transaction_in_all_wallets(&tx, TransactionContext::Mempool, true, true) + .await; + + assert!(!result.affected_wallets.is_empty()); + assert_eq!(result.total_received, TX_AMOUNT); + assert_eq!(result.total_sent, 0); + assert!( + !result.involved_addresses.is_empty(), + "involved_addresses should contain the target address" + ); + assert!( + result.involved_addresses.contains(&addr), + "involved_addresses should contain the target address" + ); + } } From c451a1c640f24ae1d4f1879c29f7ae4b91d8c062 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:07:22 +0100 Subject: [PATCH 5/7] feat(dash-spv): add notify_wallet_addresses_changed() API Add a public method on DashSpvClient to trigger a mempool bloom filter rebuild when wallet addresses change externally (new wallet added, DashPay contact addresses registered, etc.). Introduces SyncEvent::WalletAddressesChanged handled by MempoolManager to rebuild the bloom filter on all active peers. Co-Authored-By: Claude Opus 4.6 --- dash-spv-ffi/src/callbacks.rs | 1 + dash-spv/src/client/events.rs | 10 ++++++++++ dash-spv/src/sync/events.rs | 7 +++++++ dash-spv/src/sync/mempool/sync_manager.rs | 6 ++++++ dash-spv/src/sync/sync_coordinator.rs | 11 +++++++++++ 5 files changed, 35 insertions(+) diff --git a/dash-spv-ffi/src/callbacks.rs b/dash-spv-ffi/src/callbacks.rs index 09076636d..a5b88564a 100644 --- a/dash-spv-ffi/src/callbacks.rs +++ b/dash-spv-ffi/src/callbacks.rs @@ -426,6 +426,7 @@ impl FFISyncEventCallbacks { cb(*header_tip, *cycle, self.user_data); } } + SyncEvent::WalletAddressesChanged => {} } } } diff --git a/dash-spv/src/client/events.rs b/dash-spv/src/client/events.rs index 160116b2c..258e2ba94 100644 --- a/dash-spv/src/client/events.rs +++ b/dash-spv/src/client/events.rs @@ -34,4 +34,14 @@ impl DashSpvClient broadcast::Receiver { self.network.lock().await.subscribe_network_events() } + + /// Notify the SPV client that wallet addresses have changed. + /// + /// This triggers a mempool bloom filter rebuild so that newly added + /// addresses (from new wallets, DashPay contact registration, etc.) + /// are monitored for incoming transactions. + pub async fn notify_wallet_addresses_changed(&self) { + let coordinator = self.sync_coordinator.lock().await; + let _ = coordinator.send_sync_event(SyncEvent::WalletAddressesChanged); + } } diff --git a/dash-spv/src/sync/events.rs b/dash-spv/src/sync/events.rs index 58df8805d..1b86114d3 100644 --- a/dash-spv/src/sync/events.rs +++ b/dash-spv/src/sync/events.rs @@ -159,6 +159,12 @@ pub enum SyncEvent { /// Sync cycle (0 = initial, 1+ = incremental) cycle: u32, }, + + /// Signal that wallet addresses have changed (new wallet added, addresses registered). + /// + /// Emitted by: External caller via `DashSpvClient::notify_wallet_addresses_changed()` + /// Consumed by: `MempoolManager` (to rebuild its bloom filter) + WalletAddressesChanged, } impl SyncEvent { @@ -248,6 +254,7 @@ impl SyncEvent { } => { format!("SyncComplete(tip={}, cycle={})", header_tip, cycle) } + SyncEvent::WalletAddressesChanged => "WalletAddressesChanged".to_string(), } } } diff --git a/dash-spv/src/sync/mempool/sync_manager.rs b/dash-spv/src/sync/mempool/sync_manager.rs index f73611220..b2cdd1cb5 100644 --- a/dash-spv/src/sync/mempool/sync_manager.rs +++ b/dash-spv/src/sync/mempool/sync_manager.rs @@ -105,6 +105,12 @@ impl SyncManager for MempoolManager { self.mark_instant_send(&instant_lock.txid).await; Ok(vec![]) } + SyncEvent::WalletAddressesChanged => { + if self.state() == SyncState::Synced { + self.rebuild_filter(requests).await?; + } + Ok(vec![]) + } _ => Ok(vec![]), } } diff --git a/dash-spv/src/sync/sync_coordinator.rs b/dash-spv/src/sync/sync_coordinator.rs index 3490ad5b4..eb51c7eeb 100644 --- a/dash-spv/src/sync/sync_coordinator.rs +++ b/dash-spv/src/sync/sync_coordinator.rs @@ -331,6 +331,17 @@ where pub fn sync_duration(&self) -> Option { self.sync_start_time.map(|start| start.elapsed()) } + + /// Send a sync event to all managers. + /// + /// Used to notify managers of external changes (e.g., wallet addresses changed) + /// that aren't triggered by the normal sync pipeline. + pub fn send_sync_event( + &self, + event: SyncEvent, + ) -> Result>> { + self.sync_event_sender.send(event).map_err(Box::new) + } } impl std::fmt::Debug for SyncCoordinator From 3aef8518ad64f0c5799d3270ff0a128d80ec905b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:01:26 +0100 Subject: [PATCH 6/7] test(dash-spv): prove mempool blocked by masternode sync failure Add three unit tests that prove the bug cascade observed on Dash testnet: 1. test_is_synced_blocked_by_masternode_failure (progress.rs): SyncProgress::is_synced() returns false when masternodes is not Synced, even though headers, filter_headers, filters, and blocks are all Synced. This prevents the coordinator from emitting SyncComplete. 2. test_mempool_not_activated_by_filters_sync_complete (sync_manager.rs): MempoolManager ignores FiltersSyncComplete -- it only activates on SyncComplete. This proves the coupling to the coordinator event. 3. test_mempool_stays_dead_without_sync_complete (sync_manager.rs): MempoolManager stays in WaitForEvents after receiving all other sync events (BlockHeaderSyncComplete, FilterHeadersSyncComplete, FiltersSyncComplete, BlockProcessed, ManagerError) but NOT SyncComplete. This reproduces the production scenario where masternode failure causes the wallet to never see unconfirmed transactions. Co-Authored-By: Claude Opus 4.6 --- dash-spv/src/sync/mempool/sync_manager.rs | 94 +++++++++++++++++++++++ dash-spv/src/sync/progress.rs | 71 +++++++++++++++++ 2 files changed, 165 insertions(+) diff --git a/dash-spv/src/sync/mempool/sync_manager.rs b/dash-spv/src/sync/mempool/sync_manager.rs index b2cdd1cb5..d2dfb1d20 100644 --- a/dash-spv/src/sync/mempool/sync_manager.rs +++ b/dash-spv/src/sync/mempool/sync_manager.rs @@ -598,6 +598,100 @@ mod tests { assert!(found_filter_load, "expected FilterLoad after confirmed txids"); } + /// Prove that MempoolManager does NOT activate on FiltersSyncComplete -- + /// it only activates on SyncComplete. Combined with the + /// `test_is_synced_blocked_by_masternode_failure` test in progress.rs, this + /// proves the full cascade: masternode failure -> no SyncComplete -> mempool + /// never activates -> wallet never sees unconfirmed transactions. + #[tokio::test] + async fn test_mempool_not_activated_by_filters_sync_complete() { + let (mut manager, requests, _rx) = create_test_manager(); + let peer = test_socket_address(1); + manager.handle_peer_connected(peer); + + // Send FiltersSyncComplete -- this is the last filter-chain event. + let event = SyncEvent::FiltersSyncComplete { + tip_height: 1000, + }; + let events = manager.handle_sync_event(&event, &requests).await.unwrap(); + assert!(events.is_empty()); + + // BUG: MempoolManager is still in WaitForEvents. FiltersSyncComplete + // does not activate it -- only SyncComplete does. + assert_eq!( + manager.state(), + SyncState::WaitForEvents, + "mempool must NOT activate on FiltersSyncComplete" + ); + + // Now send SyncComplete -- this is what actually activates mempool. + let event = SyncEvent::SyncComplete { + header_tip: 1000, + cycle: 0, + }; + manager.handle_sync_event(&event, &requests).await.unwrap(); + assert_eq!(manager.state(), SyncState::Synced, "mempool must activate on SyncComplete"); + } + + /// Prove that MempoolManager stays in WaitForEvents after receiving every + /// sync event EXCEPT SyncComplete. This is the observable production bug: + /// when MasternodeManager fails, the coordinator never emits SyncComplete, + /// so the mempool monitor never starts, and the wallet never sees + /// unconfirmed transactions as spendable. + #[tokio::test] + async fn test_mempool_stays_dead_without_sync_complete() { + let (mut manager, requests, _rx) = create_test_manager(); + let peer = test_socket_address(1); + manager.handle_peer_connected(peer); + + // Fire every sync event that would occur during a successful sync -- + // except SyncComplete (which the coordinator withholds when masternodes + // fail). + let events_without_sync_complete: Vec = vec![ + SyncEvent::BlockHeaderSyncComplete { + tip_height: 1000, + }, + SyncEvent::FilterHeadersSyncComplete { + tip_height: 1000, + }, + SyncEvent::FiltersSyncComplete { + tip_height: 1000, + }, + SyncEvent::BlockProcessed { + block_hash: dashcore::BlockHash::all_zeros(), + height: 1000, + new_addresses: vec![], + confirmed_txids: vec![], + }, + SyncEvent::ManagerError { + manager: ManagerIdentifier::Masternode, + error: "Required rotated chain lock sig at h - 0 not present".into(), + }, + ]; + + for event in &events_without_sync_complete { + manager.handle_sync_event(event, &requests).await.unwrap(); + } + + // BUG: MempoolManager is still in WaitForEvents. It never activated + // because SyncComplete was never sent. + assert_eq!( + manager.state(), + SyncState::WaitForEvents, + "mempool must stay in WaitForEvents when SyncComplete never arrives" + ); + + // Verify zero mempool activity. + let progress = manager.progress(); + if let crate::sync::SyncManagerProgress::Mempool(p) = progress { + assert_eq!(p.received(), 0, "no transactions should have been received"); + assert_eq!(p.relevant(), 0, "no transactions should be relevant"); + assert_eq!(p.tracked(), 0, "no transactions should be tracked"); + } else { + panic!("expected Mempool progress variant"); + } + } + #[tokio::test] async fn test_block_processed_no_changes_no_rebuild_flag() { let (mut manager, requests, _rx) = create_test_manager(); diff --git a/dash-spv/src/sync/progress.rs b/dash-spv/src/sync/progress.rs index 724dc993a..560cfd0e9 100644 --- a/dash-spv/src/sync/progress.rs +++ b/dash-spv/src/sync/progress.rs @@ -273,3 +273,74 @@ pub trait ProgressPercentage { (self.current_height() as f64 / self.target_height() as f64).min(1.0) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::sync::{ + BlockHeadersProgress, BlocksProgress, FilterHeadersProgress, FiltersProgress, + MasternodesProgress, + }; + + /// Prove that `is_synced()` returns false when masternodes are not Synced, + /// even though all other core managers (headers, filter_headers, filters, + /// blocks) are Synced. + /// + /// This is the root cause of the production bug: when MasternodeManager + /// fails on testnet with "Required rotated chain lock sig at h - 0 not + /// present", it never reaches `Synced`. Because `is_synced()` checks + /// masternodes, it returns false, `SyncComplete` is never emitted by the + /// coordinator, and downstream managers (mempool, instant-send) never + /// activate. + #[test] + fn test_is_synced_blocked_by_masternode_failure() { + let mut progress = SyncProgress::default(); + + // Set all five core managers to Synced. + let mut headers = BlockHeadersProgress::default(); + headers.set_state(SyncState::Synced); + progress.update_headers(headers); + + let mut filter_headers = FilterHeadersProgress::default(); + filter_headers.set_state(SyncState::Synced); + progress.update_filter_headers(filter_headers); + + let mut filters = FiltersProgress::default(); + filters.set_state(SyncState::Synced); + progress.update_filters(filters); + + let mut blocks = BlocksProgress::default(); + blocks.set_state(SyncState::Synced); + progress.update_blocks(blocks); + + let mut masternodes = MasternodesProgress::default(); + masternodes.set_state(SyncState::Synced); + progress.update_masternodes(masternodes); + + // Baseline: all Synced => is_synced() is true. + assert!(progress.is_synced(), "expected is_synced() == true when all managers are Synced"); + + // Now simulate masternode failure: set masternodes back to Syncing. + let mut mn_failed = MasternodesProgress::default(); + mn_failed.set_state(SyncState::Syncing); + progress.update_masternodes(mn_failed); + + // BUG: is_synced() returns false because masternodes is not Synced, + // even though headers, filter_headers, filters, and blocks are all + // Synced. This blocks SyncComplete from ever being emitted. + assert!( + !progress.is_synced(), + "expected is_synced() == false when masternodes is Syncing (the bug)" + ); + + // Also verify WaitForEvents has the same effect. + let mn_waiting = MasternodesProgress::default(); + // Default state is WaitForEvents + progress.update_masternodes(mn_waiting); + + assert!( + !progress.is_synced(), + "expected is_synced() == false when masternodes is WaitForEvents" + ); + } +} From 8e6e2675c3e8fdddd250544117cfac1f5ce15252 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:05:56 +0100 Subject: [PATCH 7/7] test(dash-spv): add failing tests for mempool blocked by masternode sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three tests that FAIL until the underlying bugs are fixed, proving the cascade observed on Dash testnet: 1. `test_is_synced_not_blocked_by_masternode_failure` (progress.rs): `is_synced()` should return true when chain sync is complete even if masternodes are still syncing/failed. Currently blocks SyncComplete. 2. `test_mempool_activates_on_filters_sync_complete` (sync_manager.rs): MempoolManager should activate on FiltersSyncComplete since mempool monitoring only needs the filter chain. Currently requires SyncComplete. 3. `test_mempool_activates_despite_masternode_failure` (sync_manager.rs): Replays exact testnet event sequence where masternodes fail. Mempool should still activate after FiltersSyncComplete. All marked #[ignore] to avoid breaking CI — run with --ignored to verify the bugs still exist. Root cause: MasternodeManager fails on testnet with "Required rotated chain lock sig at h - 0 not present" → never Synced → is_synced() false → SyncComplete never emitted → MempoolManager never activates → wallet never sees unconfirmed txs as spendable via IS locks. Co-Authored-By: Claude Opus 4.6 --- dash-spv/src/sync/mempool/sync_manager.rs | 90 +++++++++++------------ dash-spv/src/sync/progress.rs | 57 ++++++-------- 2 files changed, 67 insertions(+), 80 deletions(-) diff --git a/dash-spv/src/sync/mempool/sync_manager.rs b/dash-spv/src/sync/mempool/sync_manager.rs index d2dfb1d20..b4b09ce63 100644 --- a/dash-spv/src/sync/mempool/sync_manager.rs +++ b/dash-spv/src/sync/mempool/sync_manager.rs @@ -598,56 +598,63 @@ mod tests { assert!(found_filter_load, "expected FilterLoad after confirmed txids"); } - /// Prove that MempoolManager does NOT activate on FiltersSyncComplete -- - /// it only activates on SyncComplete. Combined with the - /// `test_is_synced_blocked_by_masternode_failure` test in progress.rs, this - /// proves the full cascade: masternode failure -> no SyncComplete -> mempool - /// never activates -> wallet never sees unconfirmed transactions. + /// MempoolManager should activate on FiltersSyncComplete when chain sync + /// is done — it should not require SyncComplete (which depends on + /// masternode sync). + /// + /// Production bug: when MasternodeManager fails on testnet, SyncComplete + /// is never emitted, and MempoolManager stays in WaitForEvents forever. + /// The wallet never sees unconfirmed transactions. + /// + /// Expected fix: MempoolManager should treat FiltersSyncComplete as a + /// sufficient activation trigger, since mempool monitoring only requires + /// the filter chain (headers + filters + blocks) to be synced. + /// + /// This test FAILS until the fix is applied. #[tokio::test] - async fn test_mempool_not_activated_by_filters_sync_complete() { + #[ignore = "fails until MempoolManager activates on FiltersSyncComplete"] + async fn test_mempool_activates_on_filters_sync_complete() { let (mut manager, requests, _rx) = create_test_manager(); let peer = test_socket_address(1); manager.handle_peer_connected(peer); - // Send FiltersSyncComplete -- this is the last filter-chain event. + // FiltersSyncComplete means the chain is fully synced — mempool + // monitoring should be safe to start. let event = SyncEvent::FiltersSyncComplete { tip_height: 1000, }; - let events = manager.handle_sync_event(&event, &requests).await.unwrap(); - assert!(events.is_empty()); + manager.handle_sync_event(&event, &requests).await.unwrap(); - // BUG: MempoolManager is still in WaitForEvents. FiltersSyncComplete - // does not activate it -- only SyncComplete does. + // EXPECTED: mempool activates after FiltersSyncComplete. assert_eq!( manager.state(), - SyncState::WaitForEvents, - "mempool must NOT activate on FiltersSyncComplete" + SyncState::Synced, + "mempool should activate on FiltersSyncComplete (currently requires SyncComplete)" ); - - // Now send SyncComplete -- this is what actually activates mempool. - let event = SyncEvent::SyncComplete { - header_tip: 1000, - cycle: 0, - }; - manager.handle_sync_event(&event, &requests).await.unwrap(); - assert_eq!(manager.state(), SyncState::Synced, "mempool must activate on SyncComplete"); } - /// Prove that MempoolManager stays in WaitForEvents after receiving every - /// sync event EXCEPT SyncComplete. This is the observable production bug: - /// when MasternodeManager fails, the coordinator never emits SyncComplete, - /// so the mempool monitor never starts, and the wallet never sees - /// unconfirmed transactions as spendable. + /// MempoolManager should activate even when MasternodeManager fails and + /// SyncComplete is never emitted. + /// + /// This reproduces the exact event sequence observed on Dash testnet: + /// headers sync, filter sync complete, blocks processed, but masternodes + /// fail with "Required rotated chain lock sig at h - 0 not present". + /// The coordinator never emits SyncComplete, so MempoolManager stays dead. + /// + /// Expected: mempool should be active after FiltersSyncComplete regardless + /// of masternode sync state. IS lock validation depends on masternodes, but + /// mempool transaction detection does not. + /// + /// This test FAILS until the fix is applied. #[tokio::test] - async fn test_mempool_stays_dead_without_sync_complete() { + #[ignore = "fails until MempoolManager decouples from masternode sync"] + async fn test_mempool_activates_despite_masternode_failure() { let (mut manager, requests, _rx) = create_test_manager(); let peer = test_socket_address(1); manager.handle_peer_connected(peer); - // Fire every sync event that would occur during a successful sync -- - // except SyncComplete (which the coordinator withholds when masternodes - // fail). - let events_without_sync_complete: Vec = vec![ + // Replay exact testnet event sequence (no SyncComplete). + let testnet_events: Vec = vec![ SyncEvent::BlockHeaderSyncComplete { tip_height: 1000, }, @@ -669,27 +676,18 @@ mod tests { }, ]; - for event in &events_without_sync_complete { + for event in &testnet_events { manager.handle_sync_event(event, &requests).await.unwrap(); } - // BUG: MempoolManager is still in WaitForEvents. It never activated - // because SyncComplete was never sent. + // EXPECTED: mempool is active — it should have activated on + // FiltersSyncComplete, independently of masternode sync. assert_eq!( manager.state(), - SyncState::WaitForEvents, - "mempool must stay in WaitForEvents when SyncComplete never arrives" + SyncState::Synced, + "mempool should be active after chain sync completes, \ + even when masternodes fail (currently stays in WaitForEvents)" ); - - // Verify zero mempool activity. - let progress = manager.progress(); - if let crate::sync::SyncManagerProgress::Mempool(p) = progress { - assert_eq!(p.received(), 0, "no transactions should have been received"); - assert_eq!(p.relevant(), 0, "no transactions should be relevant"); - assert_eq!(p.tracked(), 0, "no transactions should be tracked"); - } else { - panic!("expected Mempool progress variant"); - } } #[tokio::test] diff --git a/dash-spv/src/sync/progress.rs b/dash-spv/src/sync/progress.rs index 560cfd0e9..5fcf59f0f 100644 --- a/dash-spv/src/sync/progress.rs +++ b/dash-spv/src/sync/progress.rs @@ -282,21 +282,27 @@ mod tests { MasternodesProgress, }; - /// Prove that `is_synced()` returns false when masternodes are not Synced, - /// even though all other core managers (headers, filter_headers, filters, - /// blocks) are Synced. + /// Masternode sync failure should NOT block `is_synced()` for the purpose + /// of downstream managers (mempool, chainlock, instant-send). /// - /// This is the root cause of the production bug: when MasternodeManager - /// fails on testnet with "Required rotated chain lock sig at h - 0 not - /// present", it never reaches `Synced`. Because `is_synced()` checks - /// masternodes, it returns false, `SyncComplete` is never emitted by the - /// coordinator, and downstream managers (mempool, instant-send) never - /// activate. + /// Production bug: on Dash testnet, MasternodeManager fails with "Required + /// rotated chain lock sig at h - 0 not present" and never reaches Synced. + /// Because `is_synced()` includes masternodes, it returns false, + /// `SyncComplete` is never emitted, and MempoolManager never activates — + /// the wallet never sees unconfirmed transactions. + /// + /// Expected fix: `is_synced()` should return true when all chain-sync + /// managers (headers, filter_headers, filters, blocks) are Synced, even if + /// masternodes is still syncing or has failed. Masternode sync is needed + /// for IS lock validation but should not gate mempool monitoring. + /// + /// This test FAILS until the fix is applied. #[test] - fn test_is_synced_blocked_by_masternode_failure() { + #[ignore = "fails until is_synced() decouples masternode sync from chain sync"] + fn test_is_synced_not_blocked_by_masternode_failure() { let mut progress = SyncProgress::default(); - // Set all five core managers to Synced. + // Set all four chain-sync managers to Synced. let mut headers = BlockHeadersProgress::default(); headers.set_state(SyncState::Synced); progress.update_headers(headers); @@ -313,34 +319,17 @@ mod tests { blocks.set_state(SyncState::Synced); progress.update_blocks(blocks); - let mut masternodes = MasternodesProgress::default(); - masternodes.set_state(SyncState::Synced); - progress.update_masternodes(masternodes); - - // Baseline: all Synced => is_synced() is true. - assert!(progress.is_synced(), "expected is_synced() == true when all managers are Synced"); - - // Now simulate masternode failure: set masternodes back to Syncing. + // Masternodes stuck in Syncing (simulates testnet failure). let mut mn_failed = MasternodesProgress::default(); mn_failed.set_state(SyncState::Syncing); progress.update_masternodes(mn_failed); - // BUG: is_synced() returns false because masternodes is not Synced, - // even though headers, filter_headers, filters, and blocks are all - // Synced. This blocks SyncComplete from ever being emitted. - assert!( - !progress.is_synced(), - "expected is_synced() == false when masternodes is Syncing (the bug)" - ); - - // Also verify WaitForEvents has the same effect. - let mn_waiting = MasternodesProgress::default(); - // Default state is WaitForEvents - progress.update_masternodes(mn_waiting); - + // EXPECTED: is_synced() returns true — chain sync is complete, + // masternode sync should not block mempool activation. assert!( - !progress.is_synced(), - "expected is_synced() == false when masternodes is WaitForEvents" + progress.is_synced(), + "is_synced() should return true when chain sync is complete, \ + even if masternodes are still syncing" ); } }