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
4 changes: 2 additions & 2 deletions dash-spv/tests/dashd_sync/tests_transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ async fn build_and_sign(

TransactionBuilder::new()
.set_current_height(height)
.set_funding(funds_account, &account)
.add_funding(funds_account, &account)
.add_output(&dest, amount)
.build_signed(w, |a| funds_account.address_derivation_path(&a))
.await
Expand Down Expand Up @@ -605,7 +605,7 @@ async fn test_drain_account_into_another() {
let (w, info) = lock.get_wallet_and_info_mut(&wallet_id).expect("wallet");
info.build_and_sign_transaction(
w,
AccountTypePreference::BIP32,
&[AccountTypePreference::BIP32],
0,
vec![(dest.as_unchecked().clone(), 0)],
FeeRate::normal(),
Expand Down
2 changes: 1 addition & 1 deletion key-wallet-ffi/src/transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ pub unsafe extern "C" fn wallet_build_and_sign_transaction(
manager
.build_and_sign_transaction(
&wallet_id,
AccountTypePreference::BIP44,
&[AccountTypePreference::BIP44],
account_index,
outputs,
FeeRate::new(fee_per_kb),
Expand Down
4 changes: 2 additions & 2 deletions key-wallet-manager/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -588,7 +588,7 @@ impl WalletManager<ManagedWalletInfo> {
pub async fn build_and_sign_transaction(
&mut self,
wallet_id: &WalletId,
source: AccountTypePreference,
sources: &[AccountTypePreference],
source_index: u32,
outputs: Vec<(Address<NetworkUnchecked>, u64)>,
fee_rate: FeeRate,
Expand All @@ -600,7 +600,7 @@ impl WalletManager<ManagedWalletInfo> {
.ok_or(WalletError::WalletNotFound(*wallet_id))?;

managed_wallet
.build_and_sign_transaction(wallet, source, source_index, outputs, fee_rate, strategy)
.build_and_sign_transaction(wallet, sources, source_index, outputs, fee_rate, strategy)
.await
.map_err(|e| WalletError::TransactionBuild(e.to_string()))
}
Expand Down
117 changes: 63 additions & 54 deletions key-wallet/src/managed_account/reservation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
//! why the check must be atomic under this set's mutex (`dashpay/platform#4185`).

use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, MutexGuard};

use dashcore::blockdata::transaction::OutPoint;
Expand All @@ -41,27 +42,39 @@ use dashcore::blockdata::transaction::OutPoint;
/// the very double-spend this guards against.
const RESERVATION_TTL_BLOCKS: u32 = 24;

/// Opaque, per-`reserve`-call identity stamped onto every outpoint that call
/// reserves.
/// Opaque, per-build identity stamped onto every outpoint that build reserves.
///
/// A token is the *proof of ownership* a build presents to the reservation
/// set's `release_if_owner` so a release only removes the build's own
/// reservation. Each `reserve` call mints a fresh token from a monotonic
/// counter, so two reservations never share one — not even two reservations
/// taken at the same block height, which is why the height (which collides
/// freely) cannot serve as the identity.
/// reservation. Tokens are minted from a process-wide counter, so two builds
/// never share one — not even two reservations taken at the same block height,
/// which is why the height (which collides freely) cannot serve as the
/// identity, and not even across two accounts' sets, which is what lets one
/// build funded from several accounts reserve in each of them under a single
/// token.
///
/// The inner counter is private and there is no public constructor: a token can
/// only originate from a real `reserve` call. That is deliberate — it prevents a
/// caller from forging a token that happens to match another build's ownership
/// and releasing inputs out from under it.
/// The inner counter is private and minting is crate-private: a token can only
/// originate from this crate. That is deliberate — it prevents a caller from
/// forging a token that happens to match another build's ownership and
/// releasing inputs out from under it.
///
/// Copy semantics let a build hold its token cheaply across an `.await` (e.g.
/// the platform broadcast path in `dashpay/platform#4185`) and present it again
/// when releasing.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ReservationToken(u64);

impl ReservationToken {
/// Mint a token, unique within this process.
///
/// (Wraparound would need ~2^64 mints in one process lifetime, which is
/// unreachable in practice.)
pub(crate) fn next() -> Self {
static NEXT_TOKEN: AtomicU64 = AtomicU64::new(0);
Self(NEXT_TOKEN.fetch_add(1, Ordering::Relaxed))
}
}

/// A single reserved outpoint: when it was reserved (for the TTL backstop) and
/// which build owns it (for owner-guarded release).
#[derive(Debug, Clone, Copy)]
Expand All @@ -73,16 +86,10 @@ struct Reservation {
owner: ReservationToken,
}

/// Mutex-guarded interior: the reservations keyed by outpoint plus the counter
/// that mints the next [`ReservationToken`].
/// Mutex-guarded interior: the reservations keyed by outpoint.
#[derive(Debug, Default)]
struct Reserved {
entries: HashMap<OutPoint, Reservation>,
/// Monotonically increasing source of unique tokens. Never persisted and
/// only ever incremented, so within a process every issued token is unique.
/// (Wraparound would need ~2^64 reserves in one process lifetime, which is
/// unreachable in practice.)
next_token: u64,
}

/// Ephemeral, in-memory set of reserved outpoints. Cloning shares the
Expand All @@ -95,7 +102,7 @@ pub(crate) struct ReservationSet {

impl ReservationSet {
/// Recovers from a poisoned mutex rather than panicking: the guarded data is
/// a plain map plus a counter with no invariant a partial write could break,
/// a plain map with no invariant a partial write could break,
/// and panicking here would strand all later coin selection in a
/// long-running node.
fn lock(&self) -> MutexGuard<'_, Reserved> {
Expand All @@ -115,25 +122,26 @@ impl ReservationSet {
});
}

/// Reserve `outpoints` as of `current_height`, dropping expired entries
/// first, and return the [`ReservationToken`] stamped onto all of them.
/// Reserve `outpoints` as of `current_height` on behalf of `owner`,
/// dropping expired entries first.
///
/// Every outpoint in a single call shares the one returned token: the caller
/// keeps it and later presents it to [`Self::release_if_owner`] to release
/// only what this call reserved. Re-reserving an outpoint (a later `reserve`
/// naming it again) refreshes its height *and* transfers ownership to the new
/// token — the previous owner's [`Self::release_if_owner`] then becomes a
/// no-op for it, which is precisely the behavior that closes the
/// The owner is the token the reserving build was minted, and it is what the
/// build later presents to [`Self::release_if_owner`] to release only what it
/// reserved — here and, for a build funded from several accounts, in every
/// other account's set it reserved in. Re-reserving an outpoint (a later
/// `reserve` naming it again) refreshes its height *and* transfers ownership
/// to the new owner — the previous owner's [`Self::release_if_owner`] then
/// becomes a no-op for it, which is precisely the behavior that closes the
/// release/re-reserve race described in the module docs (`platform#4185`).
pub(crate) fn reserve(&self, outpoints: &[OutPoint], current_height: u32) -> ReservationToken {
pub(crate) fn reserve(
&self,
outpoints: &[OutPoint],
current_height: u32,
owner: ReservationToken,
) {
let mut reserved = self.lock();
Self::sweep(&mut reserved, current_height);

let owner = ReservationToken(reserved.next_token);
// Increment even on an empty `outpoints` slice so a token is never
// reissued; wrapping is documented-unreachable but avoids a debug panic.
reserved.next_token = reserved.next_token.wrapping_add(1);

for outpoint in outpoints {
reserved.entries.insert(
*outpoint,
Expand All @@ -143,7 +151,6 @@ impl ReservationSet {
},
);
}
owner
}

/// Return the currently reserved outpoints, dropping expired entries first.
Expand Down Expand Up @@ -224,7 +231,7 @@ mod tests {
let a = outpoint(0x01, 0);
let b = outpoint(0x02, 1);

set.reserve(&[a], 100);
set.reserve(&[a], 100, ReservationToken::next());
assert!(set.reserved(100).contains(&a));
assert!(!set.reserved(100).contains(&b));
assert_eq!(set.reserved(100), HashSet::from([a]));
Expand All @@ -239,7 +246,7 @@ mod tests {
let a = outpoint(0x03, 0);
// Releasing an unreserved outpoint is a no-op.
set.release([&a]);
set.reserve(&[a], 10);
set.reserve(&[a], 10, ReservationToken::next());
set.release([&a]);
set.release([&a]);
assert!(!set.reserved(10).contains(&a));
Expand All @@ -249,7 +256,7 @@ mod tests {
fn ttl_reclaims_stale_reservation_by_height() {
let set = ReservationSet::default();
let a = outpoint(0x04, 0);
set.reserve(&[a], 100);
set.reserve(&[a], 100, ReservationToken::next());

// The boundary is exclusive, so the entry survives until exactly
// `reserved_at + RESERVATION_TTL_BLOCKS`.
Expand All @@ -262,25 +269,25 @@ mod tests {
fn zero_height_disables_sweep() {
let set = ReservationSet::default();
let a = outpoint(0x07, 0);
set.reserve(&[a], 0);
set.reserve(&[a], 0, ReservationToken::next());

// Height 0 means the wallet has no processed height yet, so the elapsed
// span is unknown and the sweep is suppressed: the reservation stands.
assert!(set.reserved(0).contains(&a));

// Once a real height is known the TTL backstop applies again.
set.reserve(&[a], 1);
set.reserve(&[a], 1, ReservationToken::next());
assert!(set.reserved(1 + RESERVATION_TTL_BLOCKS).is_empty());
}

#[test]
fn re_reserving_refreshes_the_ttl_height() {
let set = ReservationSet::default();
let a = outpoint(0x06, 0);
set.reserve(&[a], 100);
set.reserve(&[a], 100, ReservationToken::next());
// A later reserve replaces the stored height, so the TTL is measured
// from the most recent reservation, not the first.
set.reserve(&[a], 150);
set.reserve(&[a], 150, ReservationToken::next());

assert_eq!(set.reserved(150 + RESERVATION_TTL_BLOCKS - 1), HashSet::from([a]));
assert!(set.reserved(150 + RESERVATION_TTL_BLOCKS).is_empty());
Expand All @@ -293,18 +300,16 @@ mod tests {
let a = outpoint(0x05, 0);
// A reservation taken on one handle is visible through the other, which
// is what lets a build's reservation outlive the wallet lock.
set.reserve(&[a], 1);
set.reserve(&[a], 1, ReservationToken::next());
assert!(clone.reserved(1).contains(&a));
}

#[test]
fn each_reserve_call_mints_a_distinct_token() {
let set = ReservationSet::default();
// Two reserves at the SAME height must still get different tokens — the
// whole point of not keying ownership on height, which collides.
let token_a = set.reserve(&[outpoint(0x10, 0)], 100);
let token_b = set.reserve(&[outpoint(0x11, 0)], 100);
assert_ne!(token_a, token_b);
fn each_build_mints_a_distinct_token() {
// Two builds at the SAME height must still get different tokens — the
// whole point of not keying ownership on height, which collides. The
// counter is process-wide, so this holds across accounts' sets too.
assert_ne!(ReservationToken::next(), ReservationToken::next());
}

#[test]
Expand All @@ -313,8 +318,9 @@ mod tests {
let mine = outpoint(0x20, 0);
let theirs = outpoint(0x21, 0);

let my_token = set.reserve(&[mine], 100);
let _their_token = set.reserve(&[theirs], 100);
let my_token = ReservationToken::next();
set.reserve(&[mine], 100, my_token);
set.reserve(&[theirs], 100, ReservationToken::next());

// Releasing with my token frees only my outpoint; theirs is untouched.
set.release_if_owner(&[mine, theirs], my_token);
Expand All @@ -326,11 +332,12 @@ mod tests {
fn release_if_owner_is_a_noop_for_unreserved_or_wrong_token() {
let set = ReservationSet::default();
let a = outpoint(0x22, 0);
let stale_token = set.reserve(&[a], 100);
let stale_token = ReservationToken::next();
set.reserve(&[a], 100, stale_token);

// Simulate the outpoint being released and re-reserved by someone else.
set.release([&a]);
let _new_token = set.reserve(&[a], 100);
set.reserve(&[a], 100, ReservationToken::next());

// The stale token no longer owns `a`, so its release must not remove it.
set.release_if_owner(&[a], stale_token);
Expand All @@ -354,14 +361,16 @@ mod tests {
let x = outpoint(0x30, 0);

// Build A reserves X.
let token_a = set.reserve(&[x], 100);
let token_a = ReservationToken::next();
set.reserve(&[x], 100, token_a);
assert!(set.reserved(100).contains(&x));

// TTL sweep reclaims A's reservation mid-await (modeled by advancing the
// height past the TTL so the next reserve's sweep drops A's entry)...
let swept_height = 100 + RESERVATION_TTL_BLOCKS;
// ...and build B re-reserves the very same outpoint under a new token.
let token_b = set.reserve(&[x], swept_height);
let token_b = ReservationToken::next();
set.reserve(&[x], swept_height, token_b);
assert_ne!(token_a, token_b);
assert!(set.reserved(swept_height).contains(&x));

Expand Down
7 changes: 4 additions & 3 deletions key-wallet/src/tests/spent_outpoints_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use dashcore::{BlockHash, TxIn, Txid};

use crate::account::{AccountType, StandardAccountType, TransactionRecord};
use crate::managed_account::managed_account_trait::ManagedAccountTrait;
use crate::managed_account::reservation::ReservationToken;
use crate::managed_account::transaction_record::TransactionDirection;
use crate::managed_account::ManagedCoreFundsAccount;
use crate::test_utils::TestWalletContext;
Expand Down Expand Up @@ -77,7 +78,7 @@ fn reservations_are_not_persisted() {
let account = ManagedCoreFundsAccount::dummy_bip44();
let outpoint = OutPoint::new(Txid::from([0x42; 32]), 0);

account.reservations().reserve(&[outpoint], 0);
account.reservations().reserve(&[outpoint], 0, ReservationToken::next());
assert!(account.reservations().reserved(0).contains(&outpoint));

let json = serde_json::to_string(&account).unwrap();
Expand All @@ -96,7 +97,7 @@ async fn processing_a_spend_releases_its_reservation() {

let account = ctx.managed_wallet.first_bip44_managed_account_mut().expect("BIP44 account");
assert!(account.utxos.contains_key(&funded));
account.reservations().reserve(&[funded], 0);
account.reservations().reserve(&[funded], 0, ReservationToken::next());
assert!(account.reservations().reserved(0).contains(&funded));

let spend = spending_tx(&[funded]);
Expand All @@ -114,7 +115,7 @@ async fn processing_a_spend_releases_its_reservation() {

let account = ctx.managed_wallet.first_bip44_managed_account_mut().expect("BIP44 account");
assert!(account.utxos.contains_key(&second_funded));
account.reservations().reserve(&[second_funded], 0);
account.reservations().reserve(&[second_funded], 0, ReservationToken::next());
assert!(account.reservations().reserved(0).contains(&second_funded));

let block_hash = BlockHash::from_slice(&[7u8; 32]).expect("hash");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ impl ManagedWalletInfo {
builder = builder.set_selection_strategy(SelectionStrategy::All);
}
let (transaction, fee, reservation_token) = builder
.set_funding(funds_acc, acc)
.add_funding(funds_acc, acc)
.require_final_inputs()
.build_signed_reserved(wallet, |addr| funds_acc.address_derivation_path(&addr))
.await?;
Expand Down Expand Up @@ -446,7 +446,7 @@ impl ManagedWalletInfo {
builder = builder.set_selection_strategy(SelectionStrategy::All);
}
let (transaction, fee, reservation_token) = builder
.set_funding(funds_acc, &acc)
.add_funding(funds_acc, &acc)
.require_final_inputs()
.build_signed_reserved(signer, |addr| funds_acc.address_derivation_path(&addr))
.await?;
Expand Down
Loading
Loading