diff --git a/key-wallet/src/managed_account/managed_core_funds_account.rs b/key-wallet/src/managed_account/managed_core_funds_account.rs index da4c2c13a..bfcffee88 100644 --- a/key-wallet/src/managed_account/managed_core_funds_account.rs +++ b/key-wallet/src/managed_account/managed_core_funds_account.rs @@ -121,6 +121,29 @@ impl ManagedCoreFundsAccount { &self.reservations } + /// Return the outpoints currently reserved by this account's in-flight + /// transaction builds, evaluated at `current_height` so stale reservations + /// past the TTL backstop are dropped first (see `ReservationSet`). + /// + /// This exposes the otherwise crate-private reservation ledger as a public, + /// read-only snapshot for callers that select inputs across accounts and so + /// cannot go through this account's own `set_funding`. A cross-account + /// sweep that force-adds another account's [`spendable_utxos`] bypasses that + /// account's coin selector and therefore its reservation awareness; reading + /// this set lets the caller skip inputs already committed to an in-flight + /// build, preventing a rebuild/retry from reselecting them into a + /// conflicting transaction. Prefer [`spendable_utxos_excluding_reserved`] + /// when the goal is simply to enumerate selectable UTXOs. + /// + /// The snapshot is a point-in-time copy: it reflects reservations taken up + /// to this call and is not updated as later builds reserve or release. + /// + /// [`spendable_utxos`]: Self::spendable_utxos + /// [`spendable_utxos_excluding_reserved`]: Self::spendable_utxos_excluding_reserved + pub fn reserved_outpoints(&self, current_height: u32) -> HashSet { + self.reservations.reserved(current_height) + } + /// Release the reservations held for `tx`'s inputs. Call this when a built /// transaction will not be broadcast, e.g. the user cancelled, so its inputs /// become selectable again immediately instead of waiting out the TTL @@ -510,6 +533,35 @@ impl ManagedCoreFundsAccount { self.utxos.values().filter(|utxo| utxo.is_spendable(last_processed_height)).collect() } + /// Like [`spendable_utxos`](Self::spendable_utxos) but additionally excludes + /// any UTXO whose outpoint is currently reserved by an in-flight + /// transaction build (see [`reserved_outpoints`](Self::reserved_outpoints)). + /// + /// [`spendable_utxos`](Self::spendable_utxos) filters on spendability + /// (maturity, lock, and confirmation policy) only; it is unaware of + /// reservations because an account selecting its own inputs goes through + /// `set_funding`, which consults the reservation ledger directly. A caller + /// that force-adds another account's UTXOs into a shared selection bypasses + /// that path, so a broadcast that left inputs reserved could be reselected + /// by a rebuild/retry and produce a conflicting transaction. This variant is + /// the reservation-aware enumeration for exactly those cross-account sweeps. + /// + /// Reservations are evaluated at `last_processed_height`, which also drives + /// spendability, so a single height keeps both filters consistent and drops + /// reservations past the TTL backstop. + pub fn spendable_utxos_excluding_reserved( + &self, + last_processed_height: u32, + ) -> BTreeSet<&Utxo> { + let reserved = self.reservations.reserved(last_processed_height); + self.utxos + .values() + .filter(|utxo| { + utxo.is_spendable(last_processed_height) && !reserved.contains(&utxo.outpoint) + }) + .collect() + } + /// Promote any `InBlock` records at height `<= cl_height` to /// [`TransactionContext::InChainLockedBlock`] and return the /// promoted txids. diff --git a/key-wallet/src/tests/spent_outpoints_tests.rs b/key-wallet/src/tests/spent_outpoints_tests.rs index 6e80f18fd..42177bad2 100644 --- a/key-wallet/src/tests/spent_outpoints_tests.rs +++ b/key-wallet/src/tests/spent_outpoints_tests.rs @@ -1,5 +1,7 @@ //! Tests for spent_outpoints deserialization and tracking. +use std::collections::HashSet; + use dashcore::blockdata::transaction::{OutPoint, Transaction}; use dashcore::hashes::Hash; use dashcore::{BlockHash, TxIn, Txid}; @@ -10,6 +12,7 @@ use crate::managed_account::transaction_record::TransactionDirection; use crate::managed_account::ManagedCoreFundsAccount; use crate::test_utils::TestWalletContext; use crate::transaction_checking::{BlockInfo, TransactionContext, TransactionType}; +use crate::Utxo; /// Create a transaction that spends the given outpoints. fn spending_tx(spent: &[OutPoint]) -> Transaction { @@ -129,6 +132,56 @@ async fn processing_a_spend_releases_its_reservation() { assert!(!account.reservations().reserved(0).contains(&second_funded)); } +#[test] +fn reserved_outpoints_are_excluded_from_spendable_and_reappear_on_release() { + // The public read API added for cross-account input selection: a caller + // that force-adds another account's UTXOs into a shared sweep must be able + // to see and skip outpoints an in-flight build already reserved, otherwise + // a rebuild/retry can reselect them into a conflicting transaction. + let height = 100; + let mut account = ManagedCoreFundsAccount::dummy_bip44(); + + // Two mature, non-coinbase UTXOs: both spendable at `height`. + let utxo_reserved = Utxo::dummy(0x11, 100_000, 10, false, true); + let utxo_free = Utxo::dummy(0x22, 200_000, 10, false, true); + let reserved_op = utxo_reserved.outpoint; + let free_op = utxo_free.outpoint; + account.utxos.insert(reserved_op, utxo_reserved); + account.utxos.insert(free_op, utxo_free); + + // Nothing reserved yet: the excluding view matches plain spendable, and the + // reserved set is empty. + assert!(account.reserved_outpoints(height).is_empty()); + let spendable: Vec = + account.spendable_utxos(height).iter().map(|u| u.outpoint).collect(); + assert!(spendable.contains(&reserved_op) && spendable.contains(&free_op)); + let excluding: Vec = + account.spendable_utxos_excluding_reserved(height).iter().map(|u| u.outpoint).collect(); + assert!(excluding.contains(&reserved_op) && excluding.contains(&free_op)); + + // Reserve one outpoint as an in-flight build would. + account.reservations().reserve(&[reserved_op], height); + + // The reserved outpoint is now visible via the public read API... + assert_eq!(account.reserved_outpoints(height), HashSet::from([reserved_op])); + // ...excluded from the reservation-aware enumeration... + let excluding: Vec = + account.spendable_utxos_excluding_reserved(height).iter().map(|u| u.outpoint).collect(); + assert_eq!(excluding, vec![free_op]); + // ...but still present in the reservation-unaware `spendable_utxos`, whose + // semantics are unchanged (the additive API must not alter it). + let spendable: Vec = + account.spendable_utxos(height).iter().map(|u| u.outpoint).collect(); + assert!(spendable.contains(&reserved_op) && spendable.contains(&free_op)); + + // Releasing the reservation makes the outpoint selectable again. + account.reservations().release([&reserved_op]); + assert!(account.reserved_outpoints(height).is_empty()); + let excluding: Vec = + account.spendable_utxos_excluding_reserved(height).iter().map(|u| u.outpoint).collect(); + assert!(excluding.contains(&reserved_op) && excluding.contains(&free_op)); +} + #[test] fn serde_round_trip_rebuilds_spent_outpoints() { let mut account = ManagedCoreFundsAccount::dummy_bip44();