From 05d419677730128f12b9275e010c7c5a9e78082a Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Fri, 17 Jul 2026 06:34:39 -0400 Subject: [PATCH 1/2] feat(key-wallet): expose reserved outpoints for cross-account input selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UTXO reservation ledger (ReservationSet) is consulted by an account's own coin selection via set_funding, but its read surface — reservations() and ReservationSet::reserved() — is pub(crate). A caller that selects inputs across accounts, force-adding another account's spendable_utxos() into a shared sweep, bypasses that account's selector and so its reservation awareness. spendable_utxos() filters on spendability only, so after a broadcast leaves inputs reserved a rebuild/retry can reselect the same foreign inputs and produce a conflicting transaction. Add two additive public methods on ManagedCoreFundsAccount: - reserved_outpoints(current_height) -> HashSet: a read-only snapshot of the currently reserved outpoints, TTL-swept at the given height. - spendable_utxos_excluding_reserved(last_processed_height): the reservation-aware counterpart of spendable_utxos, for cross-account sweeps. Existing spendable_utxos() semantics are unchanged so current callers are unaffected. Covered by a unit test proving a reserved outpoint is visible via the new read API, excluded from the excluding enumeration while still present in spendable_utxos, and reappears once released. Co-Authored-By: Claude Fable 5 --- .../managed_core_funds_account.rs | 52 ++++++++++++++++++ key-wallet/src/tests/spent_outpoints_tests.rs | 53 +++++++++++++++++++ 2 files changed, 105 insertions(+) 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..54a8fc8b8 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(); From 1c356647e6fcf13e5b2e64a2d032d26b2a02ee92 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:12:34 -0400 Subject: [PATCH 2/2] docs(key-wallet): drop private intra-doc link to ReservationSet The public reserved_outpoints doc linked [`ReservationSet`], a pub(crate) type, tripping rustdoc::private-intra-doc-links (-D warnings) and failing the Documentation CI check. Plain-code span instead of an intra-doc link. Co-Authored-By: Claude Fable 5 --- key-wallet/src/managed_account/managed_core_funds_account.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 54a8fc8b8..bfcffee88 100644 --- a/key-wallet/src/managed_account/managed_core_funds_account.rs +++ b/key-wallet/src/managed_account/managed_core_funds_account.rs @@ -123,7 +123,7 @@ impl ManagedCoreFundsAccount { /// 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`]). + /// 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