Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions dash-spv-ffi/src/callbacks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -828,7 +828,7 @@ impl FFIWalletEventCallbacks {
}
WalletEvent::BalanceUpdated {
wallet_id,
spendable,
confirmed,
unconfirmed,
immature,
locked,
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion key-wallet-ffi/src/managed_account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
2 changes: 1 addition & 1 deletion key-wallet-ffi/src/managed_wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion key-wallet-ffi/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ pub struct FFIBalance {
impl From<key_wallet::WalletCoreBalance> 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(),
Expand Down
2 changes: 1 addition & 1 deletion key-wallet-ffi/src/wallet_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion key-wallet-manager/src/accessors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ impl<T: WalletInfoInterface> WalletManager<T> {
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(),
Expand Down
24 changes: 12 additions & 12 deletions key-wallet-manager/src/event_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
);
}
Expand All @@ -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]
);
}
Expand All @@ -248,28 +248,28 @@ 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!(
events.iter().any(|e| matches!(
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
);
}
Expand Down
12 changes: 6 additions & 6 deletions key-wallet-manager/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions key-wallet-manager/src/process_block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
24 changes: 19 additions & 5 deletions key-wallet/src/managed_account/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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());
}

Expand Down
32 changes: 21 additions & 11 deletions key-wallet/src/transaction_checking/wallet_checker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}
Expand Down Expand Up @@ -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"
);

Expand All @@ -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");
}

Expand Down Expand Up @@ -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
Expand Down
31 changes: 19 additions & 12 deletions key-wallet/src/utxo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")]
Expand Down
Loading
Loading