Skip to content

Commit 3dfeafc

Browse files
committed
fix: address platform wallet review feedback
1 parent 0d17a63 commit 3dfeafc

2 files changed

Lines changed: 95 additions & 50 deletions

File tree

  • packages

packages/rs-platform-wallet/src/wallet/core/broadcast.rs

Lines changed: 31 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -134,28 +134,18 @@ impl<B: TransactionBroadcaster + ?Sized> CoreWallet<B> {
134134
.build()
135135
.map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))?;
136136

137-
// Re-validate the selected outpoints are still spendable while
138-
// we still hold the write lock. The lock makes our build atomic
139-
// against other callers on this handle, but external mempool /
140-
// block events processed before we acquired the lock may have
141-
// invalidated UTXOs that were still in the spendable set when
142-
// `select_inputs` ran.
143-
//
144-
// We deliberately do NOT mark the inputs as spent here — that
145-
// happens after a successful broadcast (see #3466 review). A
146-
// failed broadcast must not leave UTXOs falsely marked spent.
137+
// Sanity-check that the builder only selected outpoints from
138+
// the same height-aware spendable set we handed to input
139+
// selection. We deliberately do NOT mark the inputs as spent here
140+
// — that happens after a successful broadcast (see #3466 review).
141+
// A failed broadcast must not leave UTXOs falsely marked spent.
147142
let selected: BTreeSet<OutPoint> =
148143
tx.input.iter().map(|txin| txin.previous_output).collect();
149-
let still_spendable: BTreeSet<OutPoint> = info
150-
.get_spendable_utxos()
151-
.into_iter()
152-
.map(|utxo| utxo.outpoint)
153-
.collect();
154-
if !selected.is_subset(&still_spendable) {
144+
let spendable_outpoints: BTreeSet<OutPoint> =
145+
spendable.iter().map(|utxo| utxo.outpoint).collect();
146+
if !selected.is_subset(&spendable_outpoints) {
155147
return Err(PlatformWalletError::TransactionBuild(
156-
"Selected UTXOs are no longer available (concurrent transaction). \
157-
Please retry."
158-
.to_string(),
148+
"Transaction builder selected an unavailable UTXO. Please retry.".to_string(),
159149
));
160150
}
161151

@@ -164,6 +154,11 @@ impl<B: TransactionBroadcaster + ?Sized> CoreWallet<B> {
164154

165155
// Broadcast first; if the network rejects we leave wallet state
166156
// untouched so the caller can retry without manual sync repair.
157+
// This is intentional even if the remote accepted the transaction
158+
// but the broadcast path returned an error: in that ambiguous case
159+
// later attempts may reuse the same inputs locally, but the network
160+
// rejects the duplicate spend instead of us marking UTXOs spent for
161+
// a transaction that might not have propagated.
167162
self.broadcast_transaction(&tx).await?;
168163

169164
// Now that the tx is in flight, register it as a mempool transaction
@@ -175,15 +170,23 @@ impl<B: TransactionBroadcaster + ?Sized> CoreWallet<B> {
175170
// broadcast failure.
176171
{
177172
let mut wm = self.wallet_manager.write().await;
178-
let (wallet, info) =
179-
wm.get_wallet_mut_and_info_mut(&self.wallet_id)
180-
.ok_or_else(|| {
181-
crate::error::PlatformWalletError::WalletNotFound(
182-
"Wallet not found in wallet manager".to_string(),
183-
)
184-
})?;
185-
info.check_core_transaction(&tx, TransactionContext::Mempool, wallet, true, true)
186-
.await;
173+
if let Some((wallet, info)) = wm.get_wallet_mut_and_info_mut(&self.wallet_id) {
174+
let check_result = info
175+
.check_core_transaction(&tx, TransactionContext::Mempool, wallet, true, true)
176+
.await;
177+
if !check_result.is_relevant {
178+
tracing::warn!(
179+
txid = %tx.txid(),
180+
"broadcast transaction was not relevant during post-broadcast wallet registration"
181+
);
182+
}
183+
} else {
184+
tracing::warn!(
185+
wallet_id = %hex::encode(self.wallet_id),
186+
txid = %tx.txid(),
187+
"wallet missing during post-broadcast transaction registration"
188+
);
189+
}
187190
}
188191

189192
Ok(tx)

packages/rs-sdk/src/platform/dpns_usernames/mod.rs

Lines changed: 64 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,27 @@ pub fn convert_to_homograph_safe_chars(input: &str) -> String {
3535
.collect()
3636
}
3737

38+
fn extract_dpns_label(name: &str) -> &str {
39+
if let Some(dot_pos) = name.rfind('.') {
40+
let (label_part, suffix) = name.split_at(dot_pos);
41+
if suffix.eq_ignore_ascii_case(".dash") {
42+
return label_part;
43+
}
44+
}
45+
name
46+
}
47+
48+
/// Strip an optional case-insensitive `.dash` suffix and apply DPNS
49+
/// homograph-safe normalization, producing a value suitable for matching
50+
/// against the `normalizedLabel` field of `domain` documents.
51+
///
52+
/// Accepts either a bare label (e.g. `"alice"`) or a full DPNS name
53+
/// (e.g. `"alice.dash"`, `"Alice.DASH"`) and returns the normalized label
54+
/// (e.g. `"a11ce"`).
55+
fn normalize_dpns_label(input: &str) -> String {
56+
convert_to_homograph_safe_chars(extract_dpns_label(input))
57+
}
58+
3859
/// Check if a username is valid according to DPNS rules
3960
///
4061
/// A username is valid if:
@@ -365,19 +386,22 @@ impl Sdk {
365386
///
366387
/// # Arguments
367388
///
368-
/// * `label` - The username label to check (e.g., "alice")
389+
/// * `name` - The username label (e.g., "alice") or full DPNS name
390+
/// (e.g., "alice.dash"). The `.dash` suffix is matched
391+
/// case-insensitively and stripped before normalization, mirroring
392+
/// [`Sdk::resolve_dpns_name`].
369393
///
370394
/// # Returns
371395
///
372396
/// Returns `true` if the name is available, `false` if it's taken
373-
pub async fn is_dpns_name_available(&self, label: &str) -> Result<bool, Error> {
397+
pub async fn is_dpns_name_available(&self, name: &str) -> Result<bool, Error> {
374398
use crate::platform::documents::document_query::DocumentQuery;
375399
use drive::query::WhereClause;
376400
use drive::query::WhereOperator;
377401

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

380-
let normalized_label = convert_to_homograph_safe_chars(label);
404+
let normalized_label = normalize_dpns_label(name);
381405

382406
// Query for existing domain with this label
383407
let query = DocumentQuery {
@@ -422,29 +446,13 @@ impl Sdk {
422446

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

425-
// Extract label from full name if needed
426-
// Handle both "alice" and "alice.dash" formats
427-
let label = if let Some(dot_pos) = name.rfind('.') {
428-
let (label_part, suffix) = name.split_at(dot_pos);
429-
// Strip ".dash" / ".DASH" / mixed case — DPNS itself is case-insensitive.
430-
if suffix.eq_ignore_ascii_case(".dash") {
431-
label_part
432-
} else {
433-
// If it's not ".dash", treat the whole thing as the label
434-
name
435-
}
436-
} else {
437-
// No dot found, use the whole name as the label
438-
name
439-
};
449+
let normalized_label = normalize_dpns_label(name);
440450

441-
// Validate the label before proceeding
442-
if label.is_empty() {
451+
// Validate the normalized label before proceeding
452+
if normalized_label.is_empty() {
443453
return Ok(None);
444454
}
445455

446-
let normalized_label = convert_to_homograph_safe_chars(label);
447-
448456
// Query for domain with this label
449457
let query = DocumentQuery {
450458
data_contract: dpns_contract,
@@ -499,6 +507,40 @@ mod tests {
499507
assert_eq!(convert_to_homograph_safe_chars("test123"), "test123");
500508
}
501509

510+
#[test]
511+
fn test_normalize_dpns_label_strips_dash_suffix_case_insensitively() {
512+
// Bare label and full name normalize to the same value, regardless
513+
// of the case of the .dash suffix. This is the contract that
514+
// `is_dpns_name_available` and `resolve_dpns_name` share so that
515+
// queries against `normalizedLabel` agree.
516+
let expected = "a11ce";
517+
assert_eq!(normalize_dpns_label("alice"), expected);
518+
assert_eq!(normalize_dpns_label("alice.dash"), expected);
519+
assert_eq!(normalize_dpns_label("alice.DASH"), expected);
520+
assert_eq!(normalize_dpns_label("Alice.DaSh"), expected);
521+
assert_eq!(normalize_dpns_label("ALICE.DASH"), expected);
522+
523+
// Non-.dash suffixes are not stripped (they are treated as part of
524+
// the label and normalized whole).
525+
assert_eq!(normalize_dpns_label("alice.eth"), "a11ce.eth");
526+
527+
// Empty / suffix-only inputs normalize to an empty label.
528+
assert_eq!(normalize_dpns_label(""), "");
529+
assert_eq!(normalize_dpns_label(".dash"), "");
530+
assert_eq!(normalize_dpns_label(".DASH"), "");
531+
}
532+
533+
#[test]
534+
fn test_extract_dpns_label() {
535+
assert_eq!(extract_dpns_label("alice.dash"), "alice");
536+
assert_eq!(extract_dpns_label("alice.DASH"), "alice");
537+
assert_eq!(extract_dpns_label("alice.DaSh"), "alice");
538+
assert_eq!(extract_dpns_label("Alice.DASH"), "Alice");
539+
assert_eq!(extract_dpns_label("alice"), "alice");
540+
assert_eq!(extract_dpns_label("alice.eth"), "alice.eth");
541+
assert_eq!(extract_dpns_label(".dash"), "");
542+
}
543+
502544
#[test]
503545
fn test_is_valid_username() {
504546
// Valid usernames

0 commit comments

Comments
 (0)