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 90012ee9b..c30a4e725 100644 --- a/dash-spv-ffi/src/callbacks.rs +++ b/dash-spv-ffi/src/callbacks.rs @@ -9,6 +9,7 @@ use crate::{dash_spv_ffi_sync_progress_destroy, FFISyncProgress}; use dashcore::hashes::Hash; use key_wallet::manager::WalletEvent; +use key_wallet_ffi::types::FFITransactionContext; use std::ffi::CString; use std::os::raw::{c_char, c_void}; @@ -523,6 +524,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, @@ -531,6 +533,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 @@ -558,6 +573,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, } @@ -570,6 +586,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(), } @@ -624,6 +641,7 @@ impl FFIWalletEventCallbacks { match event { WalletEvent::TransactionReceived { wallet_id, + status, account_index, txid, amount, @@ -638,6 +656,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, @@ -646,6 +665,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/src/manager/event_tests.rs b/key-wallet/src/manager/event_tests.rs new file mode 100644 index 000000000..1b555ad90 --- /dev/null +++ b/key-wallet/src/manager/event_tests.rs @@ -0,0 +1,400 @@ +use super::test_helpers::*; +use super::*; +use crate::manager::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_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}; + 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/src/manager/events.rs b/key-wallet/src/manager/events.rs index 6f13467fc..16a4ac2ab 100644 --- a/key-wallet/src/manager/events.rs +++ b/key-wallet/src/manager/events.rs @@ -4,6 +4,7 @@ //! operations occur, allowing consumers to receive push-based notifications. use crate::manager::WalletId; +use crate::transaction_checking::TransactionContext; use alloc::string::String; use alloc::vec::Vec; use dashcore::{Address, Amount, SignedAmount, Txid}; @@ -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/src/manager/mod.rs b/key-wallet/src/manager/mod.rs index b97b67ae4..72e17b23a 100644 --- a/key-wallet/src/manager/mod.rs +++ b/key-wallet/src/manager/mod.rs @@ -540,27 +540,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); } @@ -1123,6 +1135,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 crate::Error to WalletError impl From for WalletError { fn from(err: crate::Error) -> Self { diff --git a/key-wallet/src/manager/process_block.rs b/key-wallet/src/manager/process_block.rs index 0fb66f9f9..62652b774 100644 --- a/key-wallet/src/manager/process_block.rs +++ b/key-wallet/src/manager/process_block.rs @@ -8,7 +8,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 tokio::sync::broadcast; #[async_trait] @@ -135,6 +135,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/src/manager/test_helpers.rs b/key-wallet/src/manager/test_helpers.rs new file mode 100644 index 000000000..ebd882c04 --- /dev/null +++ b/key-wallet/src/manager/test_helpers.rs @@ -0,0 +1,129 @@ +use super::*; +use crate::wallet::initialization::WalletAccountCreationOptions; +use crate::wallet::managed_wallet_info::ManagedWalletInfo; +use crate::Network; +use dashcore::hashes::Hash; +use dashcore::{OutPoint, ScriptBuf, TxIn, TxOut, Txid, Witness}; +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/manager/wallet_interface.rs b/key-wallet/src/manager/wallet_interface.rs index 257cdffb9..0e8188801 100644 --- a/key-wallet/src/manager/wallet_interface.rs +++ b/key-wallet/src/manager/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/src/test_utils/wallet.rs b/key-wallet/src/test_utils/wallet.rs index 1ec41ab1f..6293bdb82 100644 --- a/key-wallet/src/test_utils/wallet.rs +++ b/key-wallet/src/test_utils/wallet.rs @@ -29,6 +29,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 { @@ -46,9 +48,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)); @@ -105,6 +112,12 @@ impl WalletInterface for MockWallet { fn subscribe_events(&self) -> broadcast::Receiver { self.event_sender.subscribe() } + + fn process_instant_send_lock(&mut self, txid: Txid) { + let mut changes = + self.status_changes.try_lock().expect("status_changes lock contention in test helper"); + changes.push((txid, TransactionContext::InstantSend)); + } } /// Mock wallet that returns false for filter checks 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() { 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) {