Skip to content
Open
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
52 changes: 52 additions & 0 deletions key-wallet/src/managed_account/managed_core_funds_account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<OutPoint> {
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
Expand Down Expand Up @@ -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.
Expand Down
53 changes: 53 additions & 0 deletions key-wallet/src/tests/spent_outpoints_tests.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -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 {
Expand Down Expand Up @@ -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<OutPoint> =
account.spendable_utxos(height).iter().map(|u| u.outpoint).collect();
assert!(spendable.contains(&reserved_op) && spendable.contains(&free_op));
let excluding: Vec<OutPoint> =
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<OutPoint> =
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<OutPoint> =
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<OutPoint> =
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();
Expand Down
Loading