Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
fe6d7c1
fix(rs-sdk): case-insensitive .dash suffix in resolve_dpns_name
lklimek May 5, 2026
26f13d9
fix(rs-platform-wallet): prevent UTXO double-spend race in send_to_ad…
lklimek May 5, 2026
0d17a63
Merge branch 'v3.1-dev' into fix/dpns-case-and-utxo-race-v3.1-dev
lklimek May 5, 2026
1bd306a
fix: improve platform wallet UTXO checks and DPNS parsing (#3595)
thepastaclaw May 6, 2026
4616cba
Merge branch 'v3.1-dev' into fix/dpns-case-and-utxo-race-v3.1-dev
lklimek May 6, 2026
23d8943
fix(rs-platform-wallet): defer change-address advance until after rev…
lklimek May 6, 2026
a3a5d96
fix(rs-platform-wallet): typed ConcurrentSpendConflict variant for re…
lklimek May 6, 2026
41c9493
fix(rs-sdk): skip DPNS contract fetch when label is empty (CMT-001)
lklimek May 6, 2026
2c7e22a
docs(rs-platform-wallet): rewrite revalidation comment to match build…
lklimek May 6, 2026
97d1532
fix(rs-platform-wallet): structured event for post-broadcast !is_rele…
lklimek May 6, 2026
79843e3
test(rs-platform-wallet): broadcast ordering + rollback contract (CMT…
lklimek May 6, 2026
391768c
docs(rs-platform-wallet): tighten and deduplicate inline comments on …
lklimek May 6, 2026
43e3f9d
fix(rs-platform-wallet): defer change-address commit past broadcast (…
lklimek May 6, 2026
5d4a61b
test(rs-platform-wallet): rename broadcast pass-through tests to matc…
lklimek May 6, 2026
cc2104f
Merge remote-tracking branch 'origin/v3.1-dev' into fix/dpns-case-and…
lklimek May 8, 2026
6aa4f42
Merge branch 'v3.1-dev' into fix/dpns-case-and-utxo-race-v3.1-dev
lklimek May 8, 2026
4dd55d2
fix: close same-UTXO concurrent-selection race in send_to_addresses (…
lklimek May 8, 2026
349b95b
feat(rs-platform-wallet): attach outpoint context to ConcurrentSpendC…
lklimek May 8, 2026
6239fda
chore(rs-platform-wallet): drop CMT-NNN review tombstones from broadc…
lklimek May 8, 2026
4d204cd
fix(rs-platform-wallet): structured tracing fields on wallet-missing …
lklimek May 8, 2026
543a8dc
Merge remote-tracking branch 'origin/v3.1-dev' into fix/dpns-case-and…
lklimek May 12, 2026
0bacd25
chore: improve error type
lklimek May 12, 2026
0188fa9
chore: improve docs
lklimek May 12, 2026
9902cbd
chore: fix build
lklimek May 12, 2026
371e2c3
fix(rs-platform-wallet): restore defensive post-build UTXO revalidation
lklimek May 13, 2026
ff56c56
chore(rs-platform-wallet-ffi): use Result::is_err in group_info tests
lklimek May 13, 2026
5466501
chore: fmt
lklimek May 13, 2026
e4cf6b3
Merge remote-tracking branch 'origin/v3.1-dev' into fix/dpns-case-and…
lklimek May 14, 2026
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
16 changes: 15 additions & 1 deletion packages/rs-platform-wallet/src/error.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use dashcore::OutPoint;
use dpp::identifier::Identifier;
use key_wallet::Network;
use key_wallet::{account::StandardAccountType, Network};

/// Errors that can occur in platform wallet operations
#[derive(Debug, thiserror::Error)]
Expand Down Expand Up @@ -60,6 +61,19 @@ pub enum PlatformWalletError {
#[error("Transaction building failed: {0}")]
TransactionBuild(String),

#[error(
"Transaction builder selected an unavailable UTXO (concurrent spend); retry. \
Selected outpoints: {selected:?}"
)]
ConcurrentSpendConflict { selected: Vec<OutPoint> },

#[error("no spendable inputs available on {account_type} account {account_index}: {context}")]
NoSpendableInputs {
account_type: StandardAccountType,
account_index: u32,
context: String,
},

#[error("Asset lock proof waiting failed: {0}")]
AssetLockProofWait(String),

Expand Down
632 changes: 616 additions & 16 deletions packages/rs-platform-wallet/src/wallet/core/broadcast.rs

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/rs-platform-wallet/src/wallet/core/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
pub mod balance;
pub mod balance_handler;
mod broadcast;
mod reservations;
pub mod wallet;

pub use balance::WalletBalance;
Expand Down
139 changes: 139 additions & 0 deletions packages/rs-platform-wallet/src/wallet/core/reservations.rs
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));
}
}
7 changes: 7 additions & 0 deletions packages/rs-platform-wallet/src/wallet/core/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Comment thread
lklimek marked this conversation as resolved.
Comment thread
lklimek marked this conversation as resolved.
}

impl<B: TransactionBroadcaster + ?Sized> CoreWallet<B> {
Expand All @@ -47,6 +52,7 @@ impl<B: TransactionBroadcaster + ?Sized> CoreWallet<B> {
wallet_id,
broadcaster,
balance,
reservations: OutpointReservations::new(),
}
Comment on lines 35 to 56
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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

CoreWallet::new at line 55 initializes a fresh OutpointReservations::new(), and Clone shares it across cloned handles. This is correct for the documented usage where higher layers keep one CoreWallet and clone it for concurrent tasks. But two independently-constructed CoreWallet::new(.., wallet_id, ..) instances pointing at the same wallet_id (same Arc<RwLock<WalletManager>>) would have disjoint reservation sets — reopening exactly the same-UTXO race this PR closes. WalletManager doesn't keep a per-wallet_id reservation registry to prevent this. Either (a) tighten the docstring on the reservations field to state explicitly that all CoreWallet instances for a given wallet_id MUST be derived via Clone, not constructed independently, or (b) move the reservation set into PlatformWalletInfo (already keyed by wallet_id inside WalletManager) so the invariant becomes structural rather than convention-based.

source: ['claude']

Comment thread
lklimek marked this conversation as resolved.
}

Expand Down Expand Up @@ -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(),
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,7 @@ impl PlatformAddressWallet {
.platform_payment_managed_account_at_index_mut(*account_index)
{
for (p2pkh, funds) in account_state.found() {
account.set_address_credit_balance(
*p2pkh,
funds.balance,
None,
);
account.set_address_credit_balance(*p2pkh, funds.balance, None);
}
}
}
Expand Down
100 changes: 76 additions & 24 deletions packages/rs-sdk/src/platform/dpns_usernames/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,27 @@ pub fn convert_to_homograph_safe_chars(input: &str) -> String {
.collect()
}

fn extract_dpns_label(name: &str) -> &str {
if let Some(dot_pos) = name.rfind('.') {
let (label_part, suffix) = name.split_at(dot_pos);
if suffix.eq_ignore_ascii_case(".dash") {
return label_part;
}
}
name
}

/// Strip an optional case-insensitive `.dash` suffix and apply DPNS
/// homograph-safe normalization, producing a value suitable for matching
/// against the `normalizedLabel` field of `domain` documents.
///
/// Accepts either a bare label (e.g. `"alice"`) or a full DPNS name
/// (e.g. `"alice.dash"`, `"Alice.DASH"`) and returns the normalized label
/// (e.g. `"a11ce"`).
fn normalize_dpns_label(input: &str) -> String {
convert_to_homograph_safe_chars(extract_dpns_label(input))
}

/// Check if a username is valid according to DPNS rules
///
/// A username is valid if:
Expand Down Expand Up @@ -365,19 +386,31 @@ impl Sdk {
///
/// # Arguments
///
/// * `label` - The username label to check (e.g., "alice")
/// * `name` - The username label (e.g., "alice") or full DPNS name
/// (e.g., "alice.dash"). The `.dash` suffix is matched
/// case-insensitively and stripped before normalization, mirroring
/// [`Sdk::resolve_dpns_name`].
///
/// # Returns
///
/// Returns `true` if the name is available, `false` if it's taken
pub async fn is_dpns_name_available(&self, label: &str) -> Result<bool, Error> {
pub async fn is_dpns_name_available(&self, name: &str) -> Result<bool, Error> {
use crate::platform::documents::document_query::DocumentQuery;
use drive::query::WhereClause;
use drive::query::WhereOperator;

let dpns_contract = self.fetch_dpns_contract().await?;
let normalized_label = normalize_dpns_label(name);

let normalized_label = convert_to_homograph_safe_chars(label);
// An empty normalized label (e.g. `""`, `".dash"`, `".DASH"`) is not
// a registrable DPNS name, so report it as unavailable rather than
// doing a network round-trip that would query for
// `normalizedLabel == ""`. This mirrors the early-return guard in
// `resolve_dpns_name` so the two APIs agree on malformed input.
if normalized_label.is_empty() {
return Ok(false);
}

let dpns_contract = self.fetch_dpns_contract().await?;

// Query for existing domain with this label
let query = DocumentQuery {
Expand Down Expand Up @@ -420,30 +453,15 @@ impl Sdk {
use drive::query::WhereClause;
use drive::query::WhereOperator;

let dpns_contract = self.fetch_dpns_contract().await?;

// Extract label from full name if needed
// Handle both "alice" and "alice.dash" formats
let label = if let Some(dot_pos) = name.rfind('.') {
let (label_part, suffix) = name.split_at(dot_pos);
// Only strip the suffix if it's exactly ".dash"
if suffix == ".dash" {
label_part
} else {
// If it's not ".dash", treat the whole thing as the label
name
}
} else {
// No dot found, use the whole name as the label
name
};
let normalized_label = normalize_dpns_label(name);

// Validate the label before proceeding
if label.is_empty() {
// Empty normalized label (e.g. `""`, `".dash"`) can't resolve to an
// identity; bail before the contract fetch. Mirrors `is_dpns_name_available`.
if normalized_label.is_empty() {
return Ok(None);
}

let normalized_label = convert_to_homograph_safe_chars(label);
let dpns_contract = self.fetch_dpns_contract().await?;

// Query for domain with this label
let query = DocumentQuery {
Expand Down Expand Up @@ -499,6 +517,40 @@ mod tests {
assert_eq!(convert_to_homograph_safe_chars("test123"), "test123");
}

#[test]
fn test_normalize_dpns_label_strips_dash_suffix_case_insensitively() {
// Bare label and full name normalize to the same value, regardless
// of the case of the .dash suffix. This is the contract that
// `is_dpns_name_available` and `resolve_dpns_name` share so that
// queries against `normalizedLabel` agree.
let expected = "a11ce";
assert_eq!(normalize_dpns_label("alice"), expected);
assert_eq!(normalize_dpns_label("alice.dash"), expected);
assert_eq!(normalize_dpns_label("alice.DASH"), expected);
assert_eq!(normalize_dpns_label("Alice.DaSh"), expected);
assert_eq!(normalize_dpns_label("ALICE.DASH"), expected);

// Non-.dash suffixes are not stripped (they are treated as part of
// the label and normalized whole).
assert_eq!(normalize_dpns_label("alice.eth"), "a11ce.eth");

// Empty / suffix-only inputs normalize to an empty label.
assert_eq!(normalize_dpns_label(""), "");
assert_eq!(normalize_dpns_label(".dash"), "");
assert_eq!(normalize_dpns_label(".DASH"), "");
}

#[test]
fn test_extract_dpns_label() {
assert_eq!(extract_dpns_label("alice.dash"), "alice");
assert_eq!(extract_dpns_label("alice.DASH"), "alice");
assert_eq!(extract_dpns_label("alice.DaSh"), "alice");
assert_eq!(extract_dpns_label("Alice.DASH"), "Alice");
assert_eq!(extract_dpns_label("alice"), "alice");
assert_eq!(extract_dpns_label("alice.eth"), "alice.eth");
assert_eq!(extract_dpns_label(".dash"), "");
}

#[test]
fn test_is_valid_username() {
// Valid usernames
Expand Down
Loading