-
Notifications
You must be signed in to change notification settings - Fork 54
fix: case-insensitive .dash + atomic state on broadcast failure #3585
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: v3.1-dev
Are you sure you want to change the base?
Changes from all commits
fe6d7c1
26f13d9
0d17a63
1bd306a
4616cba
23d8943
a3a5d96
41c9493
2c7e22a
97d1532
79843e3
391768c
43e3f9d
5d4a61b
cc2104f
6aa4f42
4dd55d2
349b95b
6239fda
4d204cd
543a8dc
0bacd25
0188fa9
9902cbd
371e2c3
ff56c56
5466501
e4cf6b3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,139 @@ | ||
| //! Per-wallet outpoint reservation set for [`CoreWallet::send_to_addresses`](super::broadcast). | ||
| //! | ||
| //! Closes the same-UTXO concurrent-selection race: the first caller reserves its selected | ||
| //! outpoints under the write lock; subsequent callers filter them out and short-circuit with | ||
| //! [`PlatformWalletError::NoSpendableInputs`](crate::PlatformWalletError) before hitting the | ||
| //! network. Reservations are released by an RAII guard on success, error, or panic. | ||
|
|
||
| use std::collections::HashSet; | ||
| use std::sync::{Arc, Mutex}; | ||
|
|
||
| use dashcore::OutPoint; | ||
|
|
||
| /// Per-wallet set of outpoints that have been selected for an in-flight | ||
| /// broadcast but not yet marked spent in `ManagedWalletInfo`. | ||
| /// | ||
| /// Cheaply cloneable: holds an `Arc<Mutex<…>>` internally. All clones share | ||
| /// the same set. | ||
| #[derive(Debug, Default, Clone)] | ||
| pub(crate) struct OutpointReservations { | ||
| inner: Arc<Mutex<HashSet<OutPoint>>>, | ||
| } | ||
|
|
||
| impl OutpointReservations { | ||
| pub(crate) fn new() -> Self { | ||
| Self::default() | ||
| } | ||
|
|
||
| /// Test whether `outpoint` is currently reserved. | ||
| #[cfg(test)] | ||
| pub(crate) fn contains(&self, outpoint: &OutPoint) -> bool { | ||
| let guard = self | ||
| .inner | ||
| .lock() | ||
| .unwrap_or_else(|poisoned| poisoned.into_inner()); | ||
| guard.contains(outpoint) | ||
| } | ||
|
|
||
| /// Clone the current reservation set under a single lock acquisition. | ||
| /// | ||
| /// Callers filter spendable UTXOs against the returned snapshot to | ||
| /// avoid one mutex lock per candidate outpoint. | ||
| pub(crate) fn snapshot(&self) -> HashSet<OutPoint> { | ||
| let guard = self | ||
| .inner | ||
| .lock() | ||
| .unwrap_or_else(|poisoned| poisoned.into_inner()); | ||
| guard.clone() | ||
| } | ||
|
|
||
| /// Reserve `outpoints`, returning an RAII guard that releases them on | ||
| /// drop. The guard must be held until the broadcast outcome is | ||
| /// reconciled into wallet state (success → `check_core_transaction` | ||
| /// has run; failure → caller has propagated the error). | ||
| pub(crate) fn reserve(&self, outpoints: Vec<OutPoint>) -> OutpointReservationGuard { | ||
| { | ||
| let mut guard = self | ||
| .inner | ||
| .lock() | ||
| .unwrap_or_else(|poisoned| poisoned.into_inner()); | ||
| for op in &outpoints { | ||
| guard.insert(*op); | ||
| } | ||
| } | ||
| OutpointReservationGuard { | ||
| reservations: Arc::clone(&self.inner), | ||
| outpoints, | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// RAII guard releasing reservations on drop. | ||
| /// | ||
| /// Drop is infallible and panic-safe — the underlying `Mutex` is recovered | ||
| /// from poisoning so a panicking caller still releases its outpoints. | ||
| #[must_use = "dropping the guard immediately releases the reservation"] | ||
| pub(crate) struct OutpointReservationGuard { | ||
| reservations: Arc<Mutex<HashSet<OutPoint>>>, | ||
| outpoints: Vec<OutPoint>, | ||
| } | ||
|
|
||
| impl Drop for OutpointReservationGuard { | ||
| fn drop(&mut self) { | ||
| let mut guard = self | ||
| .reservations | ||
| .lock() | ||
| .unwrap_or_else(|poisoned| poisoned.into_inner()); | ||
| for op in &self.outpoints { | ||
| guard.remove(op); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
| use dashcore::hashes::Hash; | ||
| use dashcore::Txid; | ||
|
|
||
| fn op(n: u32) -> OutPoint { | ||
| OutPoint::new(Txid::all_zeros(), n) | ||
| } | ||
|
|
||
| #[test] | ||
| fn reserve_then_drop_releases() { | ||
| let res = OutpointReservations::new(); | ||
| let a = op(1); | ||
| { | ||
| let _g = res.reserve(vec![a]); | ||
| assert!(res.contains(&a)); | ||
| } | ||
| assert!(!res.contains(&a)); | ||
| } | ||
|
|
||
| #[test] | ||
| fn second_reservation_is_disjoint() { | ||
| let res = OutpointReservations::new(); | ||
| let a = op(1); | ||
| let b = op(2); | ||
| let _g1 = res.reserve(vec![a]); | ||
| let _g2 = res.reserve(vec![b]); | ||
| assert!(res.contains(&a)); | ||
| assert!(res.contains(&b)); | ||
| } | ||
|
|
||
| #[test] | ||
| fn poisoned_mutex_still_releases() { | ||
| let res = OutpointReservations::new(); | ||
| let a = op(7); | ||
| let res_clone = res.clone(); | ||
| let _ = std::thread::spawn(move || { | ||
| let _g = res_clone.reserve(vec![a]); | ||
| panic!("intentional"); | ||
| }) | ||
| .join(); | ||
| // Guard dropped during unwind — outpoint must be released even | ||
| // though the mutex was poisoned. | ||
| assert!(!res.contains(&a)); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ | |
| use std::sync::Arc; | ||
|
|
||
| use super::balance::WalletBalance; | ||
| use super::reservations::OutpointReservations; | ||
|
|
||
| use dashcore::Address as DashAddress; | ||
| use tokio::sync::RwLock; | ||
|
|
@@ -31,6 +32,10 @@ pub struct CoreWallet<B: TransactionBroadcaster + ?Sized> { | |
| pub(crate) broadcaster: Arc<B>, | ||
| /// Lock-free balance for UI reads. | ||
| balance: Arc<WalletBalance>, | ||
| /// Outpoints currently reserved by an in-flight `send_to_addresses` | ||
| /// call on this handle. Closes the same-UTXO concurrent-selection | ||
| /// race — see [`super::reservations`]. | ||
| pub(crate) reservations: OutpointReservations, | ||
|
lklimek marked this conversation as resolved.
|
||
| } | ||
|
|
||
| impl<B: TransactionBroadcaster + ?Sized> CoreWallet<B> { | ||
|
|
@@ -47,6 +52,7 @@ impl<B: TransactionBroadcaster + ?Sized> CoreWallet<B> { | |
| wallet_id, | ||
| broadcaster, | ||
| balance, | ||
| reservations: OutpointReservations::new(), | ||
| } | ||
|
Comment on lines
35
to
56
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💬 Nitpick: OutpointReservations is per-CoreWallet handle; cross-handle race-safety relies on convention
source: ['claude']
lklimek marked this conversation as resolved.
|
||
| } | ||
|
|
||
|
|
@@ -244,6 +250,7 @@ impl<B: TransactionBroadcaster + ?Sized> Clone for CoreWallet<B> { | |
| wallet_id: self.wallet_id, | ||
| broadcaster: Arc::clone(&self.broadcaster), | ||
| balance: Arc::clone(&self.balance), | ||
| reservations: self.reservations.clone(), | ||
| } | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.