diff --git a/dash-spv-ffi/src/bin/ffi_cli.rs b/dash-spv-ffi/src/bin/ffi_cli.rs index 42028d837..3c412eb93 100644 --- a/dash-spv-ffi/src/bin/ffi_cli.rs +++ b/dash-spv-ffi/src/bin/ffi_cli.rs @@ -176,6 +176,8 @@ extern "C" fn on_transaction_detected( wallet_id: *const c_char, record: *const FFITransactionRecord, balance: *const FFIBalance, + _account_balances: *const dash_spv_ffi::FFIAccountBalance, + account_balances_count: u32, _user_data: *mut c_void, ) { let wallet_short = short_wallet(wallet_id); @@ -187,14 +189,15 @@ extern "C" fn on_transaction_detected( let b = read_balance(balance); let txid_hex = hex::encode(r.txid); println!( - "[Wallet] TX detected: wallet={}..., txid={}, account_kind={:?}, account_index={}, amount={} duffs, balance[confirmed={}, unconfirmed={}]", + "[Wallet] TX detected: wallet={}..., txid={}, account_kind={:?}, account_index={}, amount={} duffs, balance[confirmed={}, unconfirmed={}], changed_accounts={}", wallet_short, txid_hex, r.account_type.kind, r.account_type.index, r.net_amount, b.confirmed, - b.unconfirmed + b.unconfirmed, + account_balances_count, ); } @@ -204,6 +207,8 @@ extern "C" fn on_transaction_instant_locked( _islock_data: *const u8, islock_len: usize, balance: *const FFIBalance, + _account_balances: *const dash_spv_ffi::FFIAccountBalance, + account_balances_count: u32, _user_data: *mut c_void, ) { let wallet_short = short_wallet(wallet_id); @@ -215,11 +220,17 @@ extern "C" fn on_transaction_instant_locked( let b = read_balance(balance); let txid_hex = hex::encode(txid_bytes); println!( - "[Wallet] TX instant-locked: wallet={}..., txid={}, islock_len={}, balance[confirmed={}, unconfirmed={}]", - wallet_short, txid_hex, islock_len, b.confirmed, b.unconfirmed + "[Wallet] TX instant-locked: wallet={}..., txid={}, islock_len={}, balance[confirmed={}, unconfirmed={}], changed_accounts={}", + wallet_short, + txid_hex, + islock_len, + b.confirmed, + b.unconfirmed, + account_balances_count, ); } +#[allow(clippy::too_many_arguments)] extern "C" fn on_wallet_block_processed( wallet_id: *const c_char, height: u32, @@ -230,12 +241,14 @@ extern "C" fn on_wallet_block_processed( _matured: *const FFITransactionRecord, matured_count: u32, balance: *const FFIBalance, + _account_balances: *const dash_spv_ffi::FFIAccountBalance, + account_balances_count: u32, _user_data: *mut c_void, ) { let wallet_short = short_wallet(wallet_id); let b = read_balance(balance); println!( - "[Wallet] Block processed: wallet={}..., height={}, inserted={}, updated={}, matured={}, balance[confirmed={}, unconfirmed={}, immature={}, locked={}]", + "[Wallet] Block processed: wallet={}..., height={}, inserted={}, updated={}, matured={}, balance[confirmed={}, unconfirmed={}, immature={}, locked={}], changed_accounts={}", wallet_short, height, inserted_count, @@ -244,7 +257,8 @@ extern "C" fn on_wallet_block_processed( b.confirmed, b.unconfirmed, b.immature, - b.locked + b.locked, + account_balances_count, ); } diff --git a/dash-spv-ffi/src/callbacks.rs b/dash-spv-ffi/src/callbacks.rs index 2f5990c9d..794dba2e0 100644 --- a/dash-spv-ffi/src/callbacks.rs +++ b/dash-spv-ffi/src/callbacks.rs @@ -11,9 +11,12 @@ use dash_spv::network::NetworkEvent; use dash_spv::sync::{SyncEvent, SyncProgress}; use dash_spv::EventHandler; use dashcore::hashes::Hash; -use key_wallet_ffi::managed_account::FFITransactionRecord; +use key_wallet::account::AccountType; +use key_wallet::WalletCoreBalance; +use key_wallet_ffi::managed_account::{FFIAccountType, FFITransactionRecord}; use key_wallet_ffi::types::FFIBalance; use key_wallet_manager::WalletEvent; +use std::collections::BTreeMap; use std::ffi::CString; use std::os::raw::{c_char, c_void}; use std::ptr; @@ -528,6 +531,42 @@ impl FFINetworkEventCallbacks { } } +// ============================================================================ +// FFIAccountBalance - Per-account balance entry +// ============================================================================ + +/// Per-account balance pair carried on wallet events. +/// +/// Wallet events deliver an array of these — one entry per account whose +/// balance changed during the event. Accounts whose balance was unchanged +/// are omitted to keep the payload small (most transactions touch only +/// 1–2 accounts). +/// +/// `account_type` follows the same memory rules as the equivalent field on +/// [`FFITransactionRecord`]: the embedded `identity_user` / `identity_friend` +/// pointers (non-null only for Dashpay variants) are owned by the +/// `FFIAccountType` and freed when the array is dropped after the callback +/// returns. Consumers that need to retain the data past the callback must +/// copy the contents. +#[repr(C)] +pub struct FFIAccountBalance { + /// Owning-account descriptor (discriminant + indices + identity ids). + pub account_type: FFIAccountType, + /// Balance for the account after the event. + pub balance: FFIBalance, +} + +impl FFIAccountBalance { + fn from_map(map: &BTreeMap) -> Vec { + map.iter() + .map(|(account_type, balance)| FFIAccountBalance { + account_type: FFIAccountType::from(account_type), + balance: FFIBalance::from(*balance), + }) + .collect() + } +} + // ============================================================================ // FFIWalletEventCallbacks - One callback per WalletEvent variant // ============================================================================ @@ -540,12 +579,18 @@ impl FFINetworkEventCallbacks { /// /// All pointer parameters are borrowed and only valid for the duration of the /// callback. `balance` is the wallet's balance *after* the transaction was -/// recorded. +/// recorded. `account_balances` is an array of size `account_balances_count` +/// containing one entry per account whose balance changed (typically 1–2 +/// entries for a normal transaction); accounts whose balance is unchanged +/// are omitted. The array is null with a zero count when no per-account +/// balance changed. pub type OnTransactionDetectedCallback = Option< extern "C" fn( wallet_id: *const c_char, record: *const FFITransactionRecord, balance: *const FFIBalance, + account_balances: *const FFIAccountBalance, + account_balances_count: u32, user_data: *mut c_void, ), >; @@ -559,6 +604,8 @@ pub type OnTransactionDetectedCallback = Option< /// /// All pointer parameters are borrowed and only valid for the duration of /// the callback. `balance` is the wallet's balance *after* the change. +/// `account_balances` follows the same contract as on +/// [`OnTransactionDetectedCallback`]. pub type OnTransactionInstantLockedCallback = Option< extern "C" fn( wallet_id: *const c_char, @@ -566,6 +613,8 @@ pub type OnTransactionInstantLockedCallback = Option< islock_data: *const u8, islock_len: usize, balance: *const FFIBalance, + account_balances: *const FFIAccountBalance, + account_balances_count: u32, user_data: *mut c_void, ), >; @@ -577,7 +626,8 @@ pub type OnTransactionInstantLockedCallback = Option< /// stored, `updated` is previously-known records confirmed, `matured` is /// older coinbase records whose maturity threshold was just crossed. Empty /// arrays are passed as null with a zero count. `balance` is the wallet's -/// balance *after* the block was processed. +/// balance *after* the block was processed. `account_balances` follows the +/// same contract as on [`OnTransactionDetectedCallback`]. /// /// All array pointers and their contents are borrowed and only valid for the /// duration of the callback. @@ -592,6 +642,8 @@ pub type OnWalletBlockProcessedCallback = Option< matured: *const FFITransactionRecord, matured_count: u32, balance: *const FFIBalance, + account_balances: *const FFIAccountBalance, + account_balances_count: u32, user_data: *mut c_void, ), >; @@ -731,19 +783,30 @@ impl FFIWalletEventCallbacks { wallet_id, record, balance, + account_balances, } => { if let Some(cb) = self.on_transaction_detected { let wallet_id_hex = hex::encode(wallet_id); let c_wallet_id = CString::new(wallet_id_hex).unwrap_or_default(); let ffi_record = FFITransactionRecord::from(record.as_ref()); let ffi_balance = FFIBalance::from(*balance); + let ffi_account_balances = FFIAccountBalance::from_map(account_balances); + let account_balances_ptr = if ffi_account_balances.is_empty() { + ptr::null() + } else { + ffi_account_balances.as_ptr() + }; cb( c_wallet_id.as_ptr(), &ffi_record as *const FFITransactionRecord, &ffi_balance as *const FFIBalance, + account_balances_ptr, + ffi_account_balances.len() as u32, self.user_data, ); + + drop(ffi_account_balances); } } WalletEvent::TransactionInstantLocked { @@ -751,6 +814,7 @@ impl FFIWalletEventCallbacks { txid, instant_lock, balance, + account_balances, } => { if let Some(cb) = self.on_transaction_instant_locked { let wallet_id_hex = hex::encode(wallet_id); @@ -758,6 +822,12 @@ impl FFIWalletEventCallbacks { let txid_bytes = *txid.as_byte_array(); let islock_bytes = dashcore::consensus::serialize(instant_lock); let ffi_balance = FFIBalance::from(*balance); + let ffi_account_balances = FFIAccountBalance::from_map(account_balances); + let account_balances_ptr = if ffi_account_balances.is_empty() { + ptr::null() + } else { + ffi_account_balances.as_ptr() + }; cb( c_wallet_id.as_ptr(), @@ -765,8 +835,12 @@ impl FFIWalletEventCallbacks { islock_bytes.as_ptr(), islock_bytes.len(), &ffi_balance as *const FFIBalance, + account_balances_ptr, + ffi_account_balances.len() as u32, self.user_data, ); + + drop(ffi_account_balances); } } WalletEvent::BlockProcessed { @@ -776,6 +850,7 @@ impl FFIWalletEventCallbacks { updated, matured, balance, + account_balances, } => { if let Some(cb) = self.on_block_processed { let wallet_id_hex = hex::encode(wallet_id); @@ -787,6 +862,7 @@ impl FFIWalletEventCallbacks { let ffi_matured: Vec = matured.iter().map(FFITransactionRecord::from).collect(); let ffi_balance = FFIBalance::from(*balance); + let ffi_account_balances = FFIAccountBalance::from_map(account_balances); // Pass a null pointer when an array is empty so C/Swift // consumers that null-check before reading don't see a @@ -806,6 +882,11 @@ impl FFIWalletEventCallbacks { } else { ffi_matured.as_ptr() }; + let account_balances_ptr = if ffi_account_balances.is_empty() { + ptr::null() + } else { + ffi_account_balances.as_ptr() + }; cb( c_wallet_id.as_ptr(), @@ -817,12 +898,15 @@ impl FFIWalletEventCallbacks { matured_ptr, ffi_matured.len() as u32, &ffi_balance as *const FFIBalance, + account_balances_ptr, + ffi_account_balances.len() as u32, self.user_data, ); drop(ffi_inserted); drop(ffi_updated); drop(ffi_matured); + drop(ffi_account_balances); } } WalletEvent::SyncHeightAdvanced { diff --git a/dash-spv-ffi/tests/dashd_sync/callbacks.rs b/dash-spv-ffi/tests/dashd_sync/callbacks.rs index 295137537..fd18ea14e 100644 --- a/dash-spv-ffi/tests/dashd_sync/callbacks.rs +++ b/dash-spv-ffi/tests/dashd_sync/callbacks.rs @@ -76,6 +76,15 @@ pub(super) struct CallbackTracker { // lands in `updated` rather than `inserted`. pub(super) block_record_inserted: Mutex>, + // Number of changed-account entries observed on the most recent wallet + // event. Lets tests assert that per-account balance diffs are wired + // through and arrive non-empty for state-changing events. + pub(super) last_changed_account_count: AtomicU32, + /// Highest changed-account count observed across all wallet events so a + /// single state-changing event can be detected without racing the + /// "last" snapshot. + pub(super) max_changed_account_count: AtomicU32, + // Balance data from the most recent wallet event. pub(super) last_confirmed: AtomicU64, pub(super) last_unconfirmed: AtomicU64, @@ -379,15 +388,45 @@ fn record_balance(tracker: &CallbackTracker, balance: *const FFIBalance) { tracker.last_unconfirmed.store(b.unconfirmed, Ordering::SeqCst); } +/// Capture the size of the per-account balance diff delivered with a wallet +/// event. Stores both the most recent and the running max so tests can wait +/// on a non-zero observation without racing the "last" snapshot. +fn record_account_balances( + tracker: &CallbackTracker, + account_balances: *const FFIAccountBalance, + count: u32, +) { + tracker.last_changed_account_count.store(count, Ordering::SeqCst); + tracker.max_changed_account_count.fetch_max(count, Ordering::SeqCst); + if account_balances.is_null() || count == 0 { + return; + } + // Borrow check: the array and its `FFIAccountType` entries are owned by + // the caller's dispatch and freed when control returns. We only read. + let slice = unsafe { slice::from_raw_parts(account_balances, count as usize) }; + for entry in slice { + tracing::debug!( + " account_balance: kind={:?}, idx={}, total={}", + entry.account_type.kind, + entry.account_type.index, + entry.balance.total + ); + } +} + +#[allow(clippy::too_many_arguments)] extern "C" fn on_transaction_detected( wallet_id: *const c_char, record: *const FFITransactionRecord, balance: *const FFIBalance, + account_balances: *const FFIAccountBalance, + account_balances_count: u32, user_data: *mut c_void, ) { let Some(tracker) = (unsafe { tracker_from(user_data) }) else { return; }; + record_account_balances(tracker, account_balances, account_balances_count); let mut account_log = None; if !record.is_null() { let r = unsafe { &*record }; @@ -417,17 +456,21 @@ extern "C" fn on_transaction_detected( tracing::info!("on_transaction_detected: wallet={}, account={:?}", wallet_str, account_log); } +#[allow(clippy::too_many_arguments)] extern "C" fn on_transaction_instant_locked( _wallet_id: *const c_char, _txid: *const [u8; 32], islock_data: *const u8, islock_len: usize, balance: *const FFIBalance, + account_balances: *const FFIAccountBalance, + account_balances_count: u32, user_data: *mut c_void, ) { let Some(tracker) = (unsafe { tracker_from(user_data) }) else { return; }; + record_account_balances(tracker, account_balances, account_balances_count); if !islock_data.is_null() && islock_len > 0 { let bytes = unsafe { slice::from_raw_parts(islock_data, islock_len) }.to_vec(); *tracker.last_islock_bytes.lock().unwrap_or_else(|e| e.into_inner()) = Some(bytes); @@ -448,11 +491,14 @@ extern "C" fn on_wallet_block_processed( _matured: *const FFITransactionRecord, matured_count: u32, balance: *const FFIBalance, + account_balances: *const FFIAccountBalance, + account_balances_count: u32, user_data: *mut c_void, ) { let Some(tracker) = (unsafe { tracker_from(user_data) }) else { return; }; + record_account_balances(tracker, account_balances, account_balances_count); // Append all per-record state before bumping either counter so that a // test waiting on `block_processed_wallet_count` (the per-callback counter) // is guaranteed to also observe the matching `block_processed_wallet_record_count` diff --git a/key-wallet-manager/src/event_tests.rs b/key-wallet-manager/src/event_tests.rs index fdbe91a20..fed3ced25 100644 --- a/key-wallet-manager/src/event_tests.rs +++ b/key-wallet-manager/src/event_tests.rs @@ -71,6 +71,7 @@ async fn test_mempool_tx_emits_single_event_with_balance() { wallet_id: wid, record, balance, + account_balances, } => { assert_eq!(*wid, wallet_id); assert_eq!(record.txid, tx.txid()); @@ -85,6 +86,23 @@ async fn test_mempool_tx_emits_single_event_with_balance() { )); assert_eq!(balance.unconfirmed(), TX_AMOUNT); assert_eq!(balance.confirmed(), 0); + // Only the BIP44 account that received the funds should be in + // the diff; idle accounts are omitted. + assert_eq!( + account_balances.len(), + 1, + "only the receiving account's balance should appear, got {:?}", + account_balances + ); + let receiving = AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }; + let acct_balance = account_balances + .get(&receiving) + .expect("receiving account balance should be present"); + assert_eq!(acct_balance.unconfirmed(), TX_AMOUNT); + assert_eq!(acct_balance.confirmed(), 0); } other => panic!("expected TransactionDetected, got {:?}", other), } @@ -105,11 +123,22 @@ async fn test_mempool_tx_with_instant_lock_emits_detected_event_with_locked_bala wallet_id: wid, record, balance, + account_balances, } => { assert_eq!(*wid, wallet_id); assert!(matches!(record.context, TransactionContext::InstantSend(_))); assert_eq!(balance.confirmed(), TX_AMOUNT); assert_eq!(balance.unconfirmed(), 0); + assert_eq!(account_balances.len(), 1, "only the receiving account should appear"); + let receiving = AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }; + let acct_balance = account_balances + .get(&receiving) + .expect("receiving account balance should be present"); + assert_eq!(acct_balance.confirmed(), TX_AMOUNT); + assert_eq!(acct_balance.unconfirmed(), 0); } other => panic!("expected TransactionDetected with IS context, got {:?}", other), } @@ -180,12 +209,30 @@ async fn test_instant_send_lock_on_known_mempool_tx_emits_instant_locked_event() txid, instant_lock, balance, + account_balances, } => { assert_eq!(*wid, wallet_id); assert_eq!(*txid, tx.txid()); assert_eq!(*instant_lock, lock); assert_eq!(balance.confirmed(), TX_AMOUNT); assert_eq!(balance.unconfirmed(), 0); + // The receiving account moved from unconfirmed -> confirmed, + // so it must appear in the diff. Other accounts must not. + assert_eq!( + account_balances.len(), + 1, + "only the affected account should appear, got {:?}", + account_balances + ); + let receiving = AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }; + let acct_balance = account_balances + .get(&receiving) + .expect("receiving account balance should be present"); + assert_eq!(acct_balance.confirmed(), TX_AMOUNT); + assert_eq!(acct_balance.unconfirmed(), 0); } other => panic!("expected TransactionInstantLocked, got {:?}", other), } @@ -290,6 +337,7 @@ async fn test_block_with_new_tx_emits_inserted_record() { updated, matured, balance, + account_balances, } => { assert_eq!(*wid, wallet_id); assert_eq!(*height, 100); @@ -309,6 +357,22 @@ async fn test_block_with_new_tx_emits_inserted_record() { TransactionContext::InBlock(info) if info.height() == 100 )); assert_eq!(balance.confirmed(), TX_AMOUNT); + // Only the receiving BIP44 account moved; idle accounts must + // be omitted from the diff. + assert_eq!( + account_balances.len(), + 1, + "only the receiving account should appear, got {:?}", + account_balances + ); + let receiving = AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }; + let acct_balance = account_balances + .get(&receiving) + .expect("receiving account balance should be present"); + assert_eq!(acct_balance.confirmed(), TX_AMOUNT); } other => panic!("expected BlockProcessed, got {:?}", other), } @@ -337,6 +401,7 @@ async fn test_block_confirming_known_mempool_tx_emits_updated_record() { updated, matured, balance, + account_balances, } => { assert_eq!(*wid, wallet_id); assert_eq!(*height, 200); @@ -347,6 +412,17 @@ async fn test_block_confirming_known_mempool_tx_emits_updated_record() { // Confirmation moves balance from unconfirmed to confirmed assert_eq!(balance.confirmed(), TX_AMOUNT); assert_eq!(balance.unconfirmed(), 0); + // The receiving account moved from unconfirmed -> confirmed, + // so it must appear in the diff. + let receiving = AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }; + let acct_balance = account_balances + .get(&receiving) + .expect("receiving account balance should be present"); + assert_eq!(acct_balance.confirmed(), TX_AMOUNT); + assert_eq!(acct_balance.unconfirmed(), 0); } other => panic!("expected BlockProcessed with updated record, got {:?}", other), } diff --git a/key-wallet-manager/src/events.rs b/key-wallet-manager/src/events.rs index d04ca3900..c43aa64f0 100644 --- a/key-wallet-manager/src/events.rs +++ b/key-wallet-manager/src/events.rs @@ -4,14 +4,53 @@ //! triggered it and the wallet's new balance after the change. Consumers can //! persist the transaction(s) and balance atomically off a single event. +use std::collections::BTreeMap; + use dashcore::ephemerealdata::instant_lock::InstantLock; use dashcore::prelude::CoreBlockHeight; use dashcore::Txid; +use key_wallet::account::AccountType; use key_wallet::managed_account::transaction_record::TransactionRecord; use key_wallet::WalletCoreBalance; use crate::WalletId; +/// Diff `current` against `prior` and return only the entries whose +/// balance changed (including ones missing from `prior`). Intended for +/// pairing two snapshots taken via +/// [`WalletInfoInterface::account_balances`] before and after a +/// mutation. +pub(crate) fn diff_account_balances( + prior: &BTreeMap, + current: &BTreeMap, +) -> BTreeMap { + let mut changed = BTreeMap::new(); + for (account_type, new_balance) in current { + match prior.get(account_type) { + Some(prior_balance) if prior_balance == new_balance => {} + _ => { + changed.insert(*account_type, *new_balance); + } + } + } + changed +} + +/// Render the changed-account balance map as a short bracketed list +/// suitable for log lines, e.g. `[Standard{idx:0,BIP44}=>1.5 DASH]`. +fn format_account_balances(map: &BTreeMap) -> String { + if map.is_empty() { + return "[]".to_string(); + } + let parts: Vec = map + .iter() + .map(|(account_type, balance)| { + format!("{}=>{}", account_type, dashcore::Amount::from_sat(balance.total())) + }) + .collect(); + format!("[{}]", parts.join(", ")) +} + /// Events emitted by the wallet manager. /// /// Each event represents a meaningful wallet state change. Events that @@ -29,6 +68,12 @@ pub enum WalletEvent { record: Box, /// Wallet balance after the transaction was recorded. balance: WalletCoreBalance, + /// Post-event balance **snapshots** for accounts whose balance + /// changed as a result of this event. Each value is the account's + /// full balance after the change — not a delta. Accounts whose + /// balance was unchanged are omitted to keep the payload small + /// (most transactions touch only 1–2 accounts). + account_balances: BTreeMap, }, /// An InstantSend lock was applied to a previously-seen off-chain /// wallet-relevant transaction. @@ -41,6 +86,10 @@ pub enum WalletEvent { instant_lock: InstantLock, /// Wallet balance after the status change. balance: WalletCoreBalance, + /// Post-event balance **snapshots** for accounts whose balance + /// changed as a result of this event. Each value is the account's + /// full balance after the change — not a delta. + account_balances: BTreeMap, }, /// A block was processed for a wallet. Carries records bucketed by what /// happened to them in this block, plus the post-block balance. @@ -62,6 +111,11 @@ pub enum WalletEvent { matured: Vec, /// Wallet balance after the block was processed. balance: WalletCoreBalance, + /// Post-event balance **snapshots** for accounts whose balance + /// changed during processing of this block. Each value is the + /// account's full balance after the change — not a delta. Accounts + /// whose balance was unchanged are omitted. + account_balances: BTreeMap, }, /// The wallet's scan cursor advanced because the filter pipeline /// committed a batch covering blocks up to `height`. No records or @@ -104,19 +158,29 @@ impl WalletEvent { WalletEvent::TransactionDetected { record, balance, + account_balances, .. } => { format!( - "TransactionDetected(txid={}, context={}, balance={})", - record.txid, record.context, balance + "TransactionDetected(txid={}, context={}, balance={}, account_balances={})", + record.txid, + record.context, + balance, + format_account_balances(account_balances), ) } WalletEvent::TransactionInstantLocked { txid, balance, + account_balances, .. } => { - format!("TransactionInstantLocked(txid={}, balance={})", txid, balance) + format!( + "TransactionInstantLocked(txid={}, balance={}, account_balances={})", + txid, + balance, + format_account_balances(account_balances), + ) } WalletEvent::BlockProcessed { height, @@ -124,15 +188,17 @@ impl WalletEvent { updated, matured, balance, + account_balances, .. } => { format!( - "BlockProcessed(height={}, inserted={}, updated={}, matured={}, balance={})", + "BlockProcessed(height={}, inserted={}, updated={}, matured={}, balance={}, account_balances={})", height, inserted.len(), updated.len(), matured.len(), - balance + balance, + format_account_balances(account_balances), ) } WalletEvent::SyncHeightAdvanced { diff --git a/key-wallet-manager/src/process_block.rs b/key-wallet-manager/src/process_block.rs index 9c313e177..cf1632fc3 100644 --- a/key-wallet-manager/src/process_block.rs +++ b/key-wallet-manager/src/process_block.rs @@ -1,3 +1,4 @@ +use crate::events::diff_account_balances; use crate::wallet_interface::{BlockProcessingResult, MempoolTransactionResult, WalletInterface}; use crate::{WalletEvent, WalletId, WalletManager}; use async_trait::async_trait; @@ -5,9 +6,11 @@ use core::fmt::Write as _; use dashcore::ephemerealdata::instant_lock::InstantLock; use dashcore::prelude::CoreBlockHeight; use dashcore::{Address, Block, Transaction}; +use key_wallet::account::AccountType; use key_wallet::managed_account::transaction_record::TransactionRecord; use key_wallet::transaction_checking::{BlockInfo, TransactionContext}; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use key_wallet::WalletCoreBalance; use std::collections::{BTreeMap, BTreeSet}; use tokio::sync::broadcast; @@ -80,10 +83,21 @@ impl WalletInterface for WalletM }; // Refresh cached balances for affected wallets before emitting so - // every event carries a post-change balance. + // every event carries a post-change balance, snapshotting before + // and after to surface only accounts whose balance actually + // changed. The cached `.balance` field is stale until + // `update_balance()` runs, so the pre-snapshot taken here captures + // the pre-transaction state. + let mut per_wallet_account_diff: BTreeMap< + WalletId, + BTreeMap, + > = BTreeMap::new(); for wallet_id in &check_result.affected_wallets { if let Some(info) = self.wallet_infos.get_mut(wallet_id) { + let prior = info.account_balances(); info.update_balance(); + let current = info.account_balances(); + per_wallet_account_diff.insert(*wallet_id, diff_account_balances(&prior, ¤t)); } } @@ -96,11 +110,14 @@ impl WalletInterface for WalletM continue; }; let balance = info.balance(); + let account_balances = + per_wallet_account_diff.get(&wallet_id).cloned().unwrap_or_default(); for record in records { let event = WalletEvent::TransactionDetected { wallet_id, record: Box::new(record), balance, + account_balances: account_balances.clone(), }; let _ = self.event_sender.send(event); } @@ -115,12 +132,15 @@ impl WalletInterface for WalletM continue; }; let balance = info.balance(); + let account_balances = + per_wallet_account_diff.get(&wallet_id).cloned().unwrap_or_default(); for record in records { let event = WalletEvent::TransactionInstantLocked { wallet_id, txid: record.txid, instant_lock: lock.clone(), balance, + account_balances: account_balances.clone(), }; let _ = self.event_sender.send(event); } @@ -210,6 +230,14 @@ impl WalletInterface for WalletM fn process_instant_send_lock(&mut self, instant_lock: InstantLock) { let txid = instant_lock.txid; + // `mark_instant_send_utxos` recomputes balances internally when any + // UTXO is newly marked, so we have to snapshot per-account balances + // up front to surface the diff afterwards. + let mut prior_account_balances: BTreeMap< + WalletId, + BTreeMap, + > = self.wallet_infos.iter().map(|(id, info)| (*id, info.account_balances())).collect(); + let mut affected_wallets = Vec::new(); for (wallet_id, info) in self.wallet_infos.iter_mut() { if info.mark_instant_send_utxos(&txid, &instant_lock) { @@ -226,11 +254,14 @@ impl WalletInterface for WalletM let Some(info) = self.wallet_infos.get(&wallet_id) else { continue; }; + let prior = prior_account_balances.remove(&wallet_id).unwrap_or_default(); + let account_balances = diff_account_balances(&prior, &info.account_balances()); let _ = self.event_sender().send(WalletEvent::TransactionInstantLocked { wallet_id, txid, instant_lock: instant_lock.clone(), balance: info.balance(), + account_balances, }); } } @@ -286,6 +317,13 @@ impl WalletManager { } let snapshot = self.snapshot_balances(); + let mut prior_account_balances: BTreeMap< + WalletId, + BTreeMap, + > = wallets + .iter() + .filter_map(|id| self.wallet_infos.get(id).map(|info| (*id, info.account_balances()))) + .collect(); let prior_heights: BTreeMap = wallets .iter() .filter_map(|id| { @@ -332,6 +370,8 @@ impl WalletManager { let updated = per_wallet_updated.remove(wallet_id).unwrap_or_default(); let matured = per_wallet_matured.remove(wallet_id).unwrap_or_default(); let balance_changed = snapshot.get(wallet_id).copied() != Some(new_balance); + let prior = prior_account_balances.remove(wallet_id).unwrap_or_default(); + let account_balances = diff_account_balances(&prior, &info.account_balances()); if !inserted.is_empty() || !updated.is_empty() || !matured.is_empty() || balance_changed { @@ -342,6 +382,7 @@ impl WalletManager { updated, matured, balance: new_balance, + account_balances, }; let _ = self.event_sender.send(event); } diff --git a/key-wallet/src/account/account_type.rs b/key-wallet/src/account/account_type.rs index ca3cd08b5..7c56faec8 100644 --- a/key-wallet/src/account/account_type.rs +++ b/key-wallet/src/account/account_type.rs @@ -2,6 +2,8 @@ //! //! This module contains the various account type enumerations. +use core::fmt::{self, Display, Formatter}; + use crate::bip32::{ChildNumber, DerivationPath}; use crate::dip9::DerivationPathReference; use crate::transaction_checking::transaction_router::{ @@ -14,7 +16,7 @@ use bincode_derive::{Decode, Encode}; use serde::{Deserialize, Serialize}; /// Account types supported by the wallet -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "bincode", derive(Encode, Decode))] pub enum StandardAccountType { @@ -26,7 +28,7 @@ pub enum StandardAccountType { } /// Account types supported by the wallet -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "bincode", derive(Encode, Decode))] pub enum AccountType { @@ -102,6 +104,58 @@ pub enum AccountType { }, } +impl Display for StandardAccountType { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + StandardAccountType::BIP44Account => f.write_str("BIP44"), + StandardAccountType::BIP32Account => f.write_str("BIP32"), + } + } +} + +impl Display for AccountType { + /// Compact, log-friendly rendering. Dashpay variants render with their + /// account index but elide the 32-byte identity hashes so log lines stay + /// readable. + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + AccountType::Standard { + index, + standard_account_type, + } => write!(f, "Standard{{idx:{},{}}}", index, standard_account_type), + AccountType::CoinJoin { + index, + } => write!(f, "CoinJoin{{idx:{}}}", index), + AccountType::IdentityRegistration => f.write_str("IdentityRegistration"), + AccountType::IdentityTopUp { + registration_index, + } => write!(f, "IdentityTopUp{{reg:{}}}", registration_index), + AccountType::IdentityTopUpNotBoundToIdentity => f.write_str("IdentityTopUpNotBound"), + AccountType::IdentityInvitation => f.write_str("IdentityInvitation"), + AccountType::AssetLockAddressTopUp => f.write_str("AssetLockAddressTopUp"), + AccountType::AssetLockShieldedAddressTopUp => { + f.write_str("AssetLockShieldedAddressTopUp") + } + AccountType::ProviderVotingKeys => f.write_str("ProviderVotingKeys"), + AccountType::ProviderOwnerKeys => f.write_str("ProviderOwnerKeys"), + AccountType::ProviderOperatorKeys => f.write_str("ProviderOperatorKeys"), + AccountType::ProviderPlatformKeys => f.write_str("ProviderPlatformKeys"), + AccountType::DashpayReceivingFunds { + index, + .. + } => write!(f, "DashpayReceiving{{idx:{}}}", index), + AccountType::DashpayExternalAccount { + index, + .. + } => write!(f, "DashpayExternal{{idx:{}}}", index), + AccountType::PlatformPayment { + account, + key_class, + } => write!(f, "PlatformPayment{{acct:{},class:{}}}", account, key_class), + } + } +} + impl TryFrom for AccountTypeToCheck { type Error = PlatformAccountConversionError; 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 568e228c1..51e5ebe24 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 @@ -2,10 +2,10 @@ //! //! This trait allows WalletManager to work with different wallet info implementations -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use super::managed_account_operations::ManagedAccountOperations; -use crate::account::ManagedAccountTrait; +use crate::account::{AccountType, ManagedAccountTrait}; use crate::managed_account::managed_account_collection::ManagedAccountCollection; use crate::transaction_checking::TransactionContext; use crate::transaction_checking::WalletTransactionChecker; @@ -76,6 +76,15 @@ pub trait WalletInfoInterface: Sized + WalletTransactionChecker + ManagedAccount /// Update the wallet balance fn update_balance(&mut self); + /// Per-account balances keyed by `AccountType`. + fn account_balances(&self) -> BTreeMap { + self.accounts() + .all_accounts() + .iter() + .map(|acc| (acc.managed_account_type().to_account_type(), *acc.balance())) + .collect() + } + /// Get transaction history fn transaction_history(&self) -> Vec<&TransactionRecord>;