diff --git a/dash-spv-ffi/src/callbacks.rs b/dash-spv-ffi/src/callbacks.rs index b7b300e5d..549f3f83d 100644 --- a/dash-spv-ffi/src/callbacks.rs +++ b/dash-spv-ffi/src/callbacks.rs @@ -568,7 +568,7 @@ pub type OnTransactionStatusChangedCallback = Option< pub type OnBalanceUpdatedCallback = Option< extern "C" fn( wallet_id: *const c_char, - spendable: u64, + confirmed: u64, unconfirmed: u64, immature: u64, locked: u64, @@ -828,7 +828,7 @@ impl FFIWalletEventCallbacks { } WalletEvent::BalanceUpdated { wallet_id, - spendable, + confirmed, unconfirmed, immature, locked, @@ -838,7 +838,7 @@ impl FFIWalletEventCallbacks { let c_wallet_id = CString::new(wallet_id_hex).unwrap_or_default(); cb( c_wallet_id.as_ptr(), - *spendable, + *confirmed, *unconfirmed, *immature, *locked, diff --git a/key-wallet-ffi/src/managed_account.rs b/key-wallet-ffi/src/managed_account.rs index fa91a46dd..107f1d2bf 100644 --- a/key-wallet-ffi/src/managed_account.rs +++ b/key-wallet-ffi/src/managed_account.rs @@ -618,7 +618,7 @@ pub unsafe extern "C" fn managed_core_account_get_balance( let balance = &account.inner().balance; *balance_out = crate::types::FFIBalance { - confirmed: balance.spendable(), + confirmed: balance.confirmed(), unconfirmed: balance.unconfirmed(), immature: balance.immature(), locked: balance.locked(), diff --git a/key-wallet-ffi/src/managed_wallet.rs b/key-wallet-ffi/src/managed_wallet.rs index cc3927e6a..9c7ff0b5f 100644 --- a/key-wallet-ffi/src/managed_wallet.rs +++ b/key-wallet-ffi/src/managed_wallet.rs @@ -579,7 +579,7 @@ pub unsafe extern "C" fn managed_wallet_get_balance( let balance = &managed_wallet.inner().balance; unsafe { - *confirmed_out = balance.spendable(); + *confirmed_out = balance.confirmed(); *unconfirmed_out = balance.unconfirmed(); *immature_out = balance.immature(); *locked_out = balance.locked(); diff --git a/key-wallet-ffi/src/types.rs b/key-wallet-ffi/src/types.rs index 12495596b..9408e55d4 100644 --- a/key-wallet-ffi/src/types.rs +++ b/key-wallet-ffi/src/types.rs @@ -150,7 +150,7 @@ pub struct FFIBalance { impl From for FFIBalance { fn from(balance: key_wallet::WalletCoreBalance) -> Self { FFIBalance { - confirmed: balance.spendable(), + confirmed: balance.confirmed(), unconfirmed: balance.unconfirmed(), immature: balance.immature(), locked: balance.locked(), diff --git a/key-wallet-ffi/src/wallet_manager.rs b/key-wallet-ffi/src/wallet_manager.rs index cc9652f9a..cd5e29734 100644 --- a/key-wallet-ffi/src/wallet_manager.rs +++ b/key-wallet-ffi/src/wallet_manager.rs @@ -703,7 +703,7 @@ pub unsafe extern "C" fn wallet_manager_get_wallet_balance( match result { Ok(balance) => { unsafe { - *confirmed_out = balance.spendable(); + *confirmed_out = balance.confirmed(); *unconfirmed_out = balance.unconfirmed(); } FFIError::set_success(error); diff --git a/key-wallet-manager/src/accessors.rs b/key-wallet-manager/src/accessors.rs index 1f72d4616..46e5cf842 100644 --- a/key-wallet-manager/src/accessors.rs +++ b/key-wallet-manager/src/accessors.rs @@ -190,7 +190,7 @@ impl WalletManager { if *old_balance != new_balance { let event = WalletEvent::BalanceUpdated { wallet_id: *wallet_id, - spendable: new_balance.spendable(), + confirmed: new_balance.confirmed(), unconfirmed: new_balance.unconfirmed(), immature: new_balance.immature(), locked: new_balance.locked(), diff --git a/key-wallet-manager/src/event_tests.rs b/key-wallet-manager/src/event_tests.rs index a97cc93aa..3e851cad7 100644 --- a/key-wallet-manager/src/event_tests.rs +++ b/key-wallet-manager/src/event_tests.rs @@ -197,11 +197,11 @@ async fn test_mempool_tx_emits_balance_updated() { WalletEvent::BalanceUpdated { wallet_id: wid, unconfirmed, - spendable, + confirmed, .. - } if *wid == wallet_id && *unconfirmed == TX_AMOUNT && *spendable == 0 + } if *wid == wallet_id && *unconfirmed == TX_AMOUNT && *confirmed == 0 ), - "expected BalanceUpdated with unconfirmed={TX_AMOUNT}, spendable=0, got {:?}", + "expected BalanceUpdated with unconfirmed={TX_AMOUNT}, confirmed=0, got {:?}", balance_events[0] ); } @@ -223,12 +223,12 @@ async fn test_instantsend_tx_emits_balance_updated_spendable() { balance_events[0], WalletEvent::BalanceUpdated { wallet_id: wid, - spendable, + confirmed, unconfirmed, .. - } if *wid == wallet_id && *spendable == TX_AMOUNT && *unconfirmed == 0 + } if *wid == wallet_id && *confirmed == TX_AMOUNT && *unconfirmed == 0 ), - "expected BalanceUpdated with spendable={TX_AMOUNT}, unconfirmed=0, got {:?}", + "expected BalanceUpdated with confirmed={TX_AMOUNT}, unconfirmed=0, got {:?}", balance_events[0] ); } @@ -248,15 +248,15 @@ async fn test_mempool_to_instantsend_transitions_balance() { WalletEvent::BalanceUpdated { wallet_id: wid, unconfirmed, - spendable, + confirmed, .. - } if *wid == wallet_id && *unconfirmed == TX_AMOUNT && *spendable == 0 + } if *wid == wallet_id && *unconfirmed == TX_AMOUNT && *confirmed == 0 )), "expected unconfirmed balance after mempool, got {:?}", events ); - // IS lock: balance should move from unconfirmed to spendable + // IS lock: balance should move from unconfirmed to confirmed manager.process_instant_send_lock(dummy_instant_lock(tx.txid())); let events = drain_events(&mut rx); assert!( @@ -264,12 +264,12 @@ async fn test_mempool_to_instantsend_transitions_balance() { e, WalletEvent::BalanceUpdated { wallet_id: wid, - spendable, + confirmed, unconfirmed, .. - } if *wid == wallet_id && *spendable == TX_AMOUNT && *unconfirmed == 0 + } if *wid == wallet_id && *confirmed == TX_AMOUNT && *unconfirmed == 0 )), - "expected spendable balance after IS lock, got {:?}", + "expected confirmed balance after IS lock, got {:?}", events ); } diff --git a/key-wallet-manager/src/events.rs b/key-wallet-manager/src/events.rs index 77999f29e..5d0e2b282 100644 --- a/key-wallet-manager/src/events.rs +++ b/key-wallet-manager/src/events.rs @@ -37,9 +37,9 @@ pub enum WalletEvent { BalanceUpdated { /// ID of the affected wallet. wallet_id: WalletId, - /// New spendable balance in duffs (confirmed and mature). - spendable: u64, - /// New unconfirmed balance in duffs. + /// New confirmed balance in duffs (mature, in a block or InstantSend-locked). + confirmed: u64, + /// New unconfirmed balance in duffs (mature, mempool-only). Also spendable. unconfirmed: u64, /// New immature balance (coinbase UTXOs not yet mature). immature: u64, @@ -71,15 +71,15 @@ impl WalletEvent { format!("TransactionStatusChanged(txid={}, status={})", txid, status) } WalletEvent::BalanceUpdated { - spendable, + confirmed, unconfirmed, immature, locked, .. } => { format!( - "BalanceUpdated(spendable={}, unconfirmed={}, immature={}, locked={})", - Amount::from_sat(*spendable), + "BalanceUpdated(confirmed={}, unconfirmed={}, immature={}, locked={})", + Amount::from_sat(*confirmed), Amount::from_sat(*unconfirmed), Amount::from_sat(*immature), Amount::from_sat(*locked) diff --git a/key-wallet-manager/src/process_block.rs b/key-wallet-manager/src/process_block.rs index 98fb14298..2e5d27cb2 100644 --- a/key-wallet-manager/src/process_block.rs +++ b/key-wallet-manager/src/process_block.rs @@ -291,11 +291,11 @@ mod tests { let mut found = false; while let Ok(event) = rx.try_recv() { if let WalletEvent::BalanceUpdated { - spendable, + confirmed, .. } = event { - assert!(spendable > 0, "spendable balance should increase after block"); + assert!(confirmed > 0, "confirmed balance should increase after block"); found = true; break; } diff --git a/key-wallet/src/managed_account/mod.rs b/key-wallet/src/managed_account/mod.rs index 16a1c6539..a209a67a8 100644 --- a/key-wallet/src/managed_account/mod.rs +++ b/key-wallet/src/managed_account/mod.rs @@ -552,9 +552,23 @@ impl ManagedCoreAccount { any_changed } - /// Update the account balance + /// Return the UTXOs of this account for which + /// [`Utxo::is_spendable`] holds at `synced_height`. See that method + /// for the exact policy. Call this per-account rather than + /// aggregating across the wallet, since spendability is + /// account-type specific. + pub fn spendable_utxos(&self, synced_height: u32) -> BTreeSet<&Utxo> { + self.utxos.values().filter(|utxo| utxo.is_spendable(synced_height)).collect() + } + + /// Update the account balance. + /// + /// Mature, non-locked UTXOs land in either the `confirmed` bucket + /// (in a block or InstantSend-locked) or the `unconfirmed` bucket + /// (mempool only). Both are spendable per [`Utxo::is_spendable`]; + /// the split is only for display. pub fn update_balance(&mut self, synced_height: u32) { - let mut spendable = 0; + let mut confirmed = 0; let mut unconfirmed = 0; let mut immature = 0; let mut locked = 0; @@ -564,13 +578,13 @@ impl ManagedCoreAccount { locked += value; } else if !utxo.is_mature(synced_height) { immature += value; - } else if utxo.is_spendable(synced_height) { - spendable += value; + } else if utxo.is_confirmed || utxo.is_instantlocked { + confirmed += value; } else { unconfirmed += value; } } - self.balance = WalletCoreBalance::new(spendable, unconfirmed, immature, locked); + self.balance = WalletCoreBalance::new(confirmed, unconfirmed, immature, locked); self.metadata.last_used = Some(Self::current_timestamp()); } diff --git a/key-wallet/src/transaction_checking/wallet_checker.rs b/key-wallet/src/transaction_checking/wallet_checker.rs index 020f796b4..05a8625a1 100644 --- a/key-wallet/src/transaction_checking/wallet_checker.rs +++ b/key-wallet/src/transaction_checking/wallet_checker.rs @@ -428,8 +428,13 @@ mod tests { assert_eq!(managed_wallet.balance().immature(), 5_000_000_000); // Spendable UTXOs should be empty (coinbase not mature) + let synced_height = managed_wallet.synced_height(); assert!( - managed_wallet.get_spendable_utxos().is_empty(), + managed_wallet + .first_bip44_managed_account() + .expect("Should have managed account") + .spendable_utxos(synced_height) + .is_empty(), "Coinbase UTXO should not be spendable until mature" ); } @@ -582,14 +587,13 @@ mod tests { assert_eq!(managed_wallet.balance().immature(), 5_000_000_000); // Spendable UTXOs should be empty (coinbase not mature yet) + let synced_height = managed_wallet.synced_height(); assert!( - managed_wallet.get_spendable_utxos().is_empty(), - "No spendable UTXOs while coinbase is immature" - ); - - // Spendable UTXOs should be empty (coinbase not mature yet) - assert!( - managed_wallet.get_spendable_utxos().is_empty(), + managed_wallet + .first_bip44_managed_account() + .expect("Should have managed account") + .spendable_utxos(synced_height) + .is_empty(), "No spendable UTXOs while coinbase is immature" ); @@ -613,7 +617,11 @@ mod tests { assert_eq!(immature_balance, 0, "Immature balance should be zero after maturity"); // Spendable UTXOs should now contain the matured coinbase - let spendable = managed_wallet.get_spendable_utxos(); + let synced_height = managed_wallet.synced_height(); + let spendable = managed_wallet + .first_bip44_managed_account() + .expect("Should have managed account") + .spendable_utxos(synced_height); assert_eq!(spendable.len(), 1, "Should have one spendable UTXO after maturity"); } @@ -876,9 +884,11 @@ mod tests { let (mut ctx, tx) = TestWalletContext::new_random().with_mempool_funding(200_000).await; let txid = tx.txid(); - // Stage 1: mempool (already done in setup) + // Stage 1: mempool (already done in setup). Mempool funds land + // in the unconfirmed bucket but are spendable. assert_eq!(ctx.managed_wallet.balance().unconfirmed(), 200_000); - assert_eq!(ctx.managed_wallet.balance().spendable(), 0); + assert_eq!(ctx.managed_wallet.balance().confirmed(), 0); + assert_eq!(ctx.managed_wallet.balance().spendable(), 200_000); assert_eq!(ctx.managed_wallet.metadata.total_transactions, 1); // Stage 2: IS lock diff --git a/key-wallet/src/utxo.rs b/key-wallet/src/utxo.rs index d144fe507..86b104a59 100644 --- a/key-wallet/src/utxo.rs +++ b/key-wallet/src/utxo.rs @@ -60,19 +60,18 @@ impl Utxo { self.txout.value } - /// Check if this UTXO can be spent at the given height + /// Check if this UTXO can be spent at the given height. + /// + /// A UTXO is spendable unless it is locked or (for coinbase) + /// immature. Mempool 0-conf outputs are spendable — callers that + /// want to restrict to confirmed/InstantLocked UTXOs (e.g. the + /// "spendable" balance bucket or conservative coin selection) + /// should check `is_confirmed || is_instantlocked` themselves. pub fn is_spendable(&self, current_height: u32) -> bool { if self.is_locked { return false; } - - if !self.is_coinbase { - // Regular UTXOs need to be confirmed or InstantLocked - self.is_confirmed || self.is_instantlocked - } else { - // Coinbase outputs require 100 confirmations - current_height >= self.height + 100 - } + self.is_mature(current_height) } /// Check if this UTXO is mature enough for spending @@ -126,16 +125,24 @@ mod tests { fn test_utxo_spendability() { let mut utxo = Utxo::dummy(0, 100000, 100, false, false); - // Unconfirmed UTXO should not be spendable - assert!(!utxo.is_spendable(200)); + // Non-coinbase UTXOs are spendable even at 0 confs + assert!(utxo.is_spendable(200)); - // Confirmed UTXO should be spendable + // Setting is_confirmed does not affect spendability utxo.is_confirmed = true; assert!(utxo.is_spendable(200)); // Locked UTXO should not be spendable utxo.lock(); assert!(!utxo.is_spendable(200)); + utxo.unlock(); + + // Coinbase still requires 100 confirmations + let mut cb = Utxo::dummy(0, 100000, 100, true, false); + assert!(!cb.is_spendable(150)); + assert!(cb.is_spendable(200)); + cb.lock(); + assert!(!cb.is_spendable(200)); } #[test_case(false, 0, 500, 0 ; "unconfirmed utxo has 0 confirmations")] diff --git a/key-wallet/src/wallet/balance.rs b/key-wallet/src/wallet/balance.rs index 550155e06..c91252cd9 100644 --- a/key-wallet/src/wallet/balance.rs +++ b/key-wallet/src/wallet/balance.rs @@ -7,41 +7,52 @@ use core::ops::AddAssign; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; -/// Wallet balance breakdown +/// Wallet balance breakdown. +/// +/// Both `confirmed` and `unconfirmed` funds are spendable — the +/// split exists purely so callers can surface the distinction to +/// users. `spendable()` returns their sum. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub struct WalletCoreBalance { - /// Confirmed and mature balance (UTXOs with enough confirmations to be spendable) - spendable: u64, - /// Unconfirmed balance (UTXOs without confirmations) + /// Mature UTXOs that are confirmed in a block or InstantSend-locked. + confirmed: u64, + /// Mature UTXOs that are seen in the mempool but not yet confirmed + /// or InstantSend-locked. Still spendable — just not settled. unconfirmed: u64, - /// Immature balance (UTXOs without enough confirmations for maturity. e.g., 100 for coinbase.) + /// Immature balance (UTXOs without enough confirmations for maturity, e.g. 100 for coinbase). immature: u64, - /// Locked balance (UTXOs reserved for specific purposes like CoinJoin) + /// Locked balance (UTXOs reserved for specific purposes like CoinJoin). locked: u64, } impl WalletCoreBalance { - /// Create a new wallet balance - pub fn new(spendable: u64, unconfirmed: u64, immature: u64, locked: u64) -> Self { + /// Create a new wallet balance. + pub fn new(confirmed: u64, unconfirmed: u64, immature: u64, locked: u64) -> Self { Self { - spendable, + confirmed, unconfirmed, immature, locked, } } - /// Get the spendable balance. - pub fn spendable(&self) -> u64 { - self.spendable + /// Get the confirmed balance: mature UTXOs that are in a block or InstantSend-locked. + pub fn confirmed(&self) -> u64 { + self.confirmed } - /// Get the unconfirmed balance. + /// Get the unconfirmed balance: mature mempool UTXOs that are not yet + /// confirmed or InstantSend-locked. Also spendable. pub fn unconfirmed(&self) -> u64 { self.unconfirmed } + /// Get the total spendable balance (confirmed + unconfirmed). + pub fn spendable(&self) -> u64 { + self.confirmed + self.unconfirmed + } + /// Get the immature balance. pub fn immature(&self) -> u64 { self.immature @@ -54,7 +65,7 @@ impl WalletCoreBalance { /// Get the total balance. pub fn total(&self) -> u64 { - self.spendable + self.unconfirmed + self.immature + self.locked + self.confirmed + self.unconfirmed + self.immature + self.locked } } @@ -62,8 +73,8 @@ impl Display for WalletCoreBalance { fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result { write!( f, - "Spendable: {}, Unconfirmed: {}, Immature: {}, Locked: {}, Total: {}", - self.spendable, + "Confirmed: {}, Unconfirmed: {}, Immature: {}, Locked: {}, Total: {}", + self.confirmed, self.unconfirmed, self.immature, self.locked, @@ -74,7 +85,7 @@ impl Display for WalletCoreBalance { impl AddAssign for WalletCoreBalance { fn add_assign(&mut self, other: Self) { - self.spendable += other.spendable; + self.confirmed += other.confirmed; self.unconfirmed += other.unconfirmed; self.immature += other.immature; self.locked += other.locked; @@ -88,8 +99,9 @@ mod tests { #[test] fn test_balance_creation_and_getters() { let balance = WalletCoreBalance::new(1000, 500, 100, 200); - assert_eq!(balance.spendable(), 1000); + assert_eq!(balance.confirmed(), 1000); assert_eq!(balance.unconfirmed(), 500); + assert_eq!(balance.spendable(), 1500); assert_eq!(balance.immature(), 100); assert_eq!(balance.locked(), 200); assert_eq!(balance.total(), 1800); @@ -107,14 +119,14 @@ mod tests { let zero = WalletCoreBalance::default(); assert_eq!( zero.to_string(), - "Spendable: 0, Unconfirmed: 0, Immature: 0, Locked: 0, Total: 0" + "Confirmed: 0, Unconfirmed: 0, Immature: 0, Locked: 0, Total: 0" ); let balance = WalletCoreBalance::new(1000, 500, 100, 200); let display = balance.to_string(); assert_eq!( display, - "Spendable: 1000, Unconfirmed: 500, Immature: 100, Locked: 200, Total: 1800" + "Confirmed: 1000, Unconfirmed: 500, Immature: 100, Locked: 200, Total: 1800" ); } @@ -124,7 +136,7 @@ mod tests { let balance_add = WalletCoreBalance::new(300, 100, 100, 50); // Test adding actual balances balance += balance_add; - assert_eq!(balance.spendable(), 1300); + assert_eq!(balance.confirmed(), 1300); assert_eq!(balance.unconfirmed(), 600); assert_eq!(balance.immature(), 150); assert_eq!(balance.locked(), 250); 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 1c9fa13e7..33f7aba6c 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 @@ -65,9 +65,6 @@ pub trait WalletInfoInterface: Sized + WalletTransactionChecker + ManagedAccount /// Get all UTXOs for the wallet fn utxos(&self) -> BTreeSet<&Utxo>; - /// Get spendable UTXOs (confirmed and not locked) - fn get_spendable_utxos(&self) -> BTreeSet<&Utxo>; - /// Get the wallet balance fn balance(&self) -> WalletCoreBalance; @@ -178,9 +175,6 @@ impl WalletInfoInterface for ManagedWalletInfo { } utxos } - fn get_spendable_utxos(&self) -> BTreeSet<&Utxo> { - self.utxos().into_iter().filter(|utxo| utxo.is_spendable(self.synced_height())).collect() - } fn balance(&self) -> WalletCoreBalance { self.balance