Skip to content
3 changes: 3 additions & 0 deletions packages/rs-platform-wallet/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ pub enum PlatformWalletError {
#[error("Transaction building failed: {0}")]
TransactionBuild(String),

#[error("Transaction builder selected an unavailable UTXO (concurrent spend); retry")]
ConcurrentSpendConflict,

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

Expand Down
122 changes: 117 additions & 5 deletions packages/rs-platform-wallet/src/wallet/core/broadcast.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
use dashcore::{Address as DashAddress, Transaction};
use std::collections::BTreeSet;

use dashcore::{Address as DashAddress, OutPoint, Transaction};
use key_wallet::account::account_type::StandardAccountType;
use key_wallet::transaction_checking::{TransactionContext, WalletTransactionChecker};
use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface;

use crate::broadcaster::TransactionBroadcaster;
use crate::{CoreWallet, PlatformWalletError};
Expand Down Expand Up @@ -35,7 +39,6 @@ impl<B: TransactionBroadcaster + ?Sized> CoreWallet<B> {
) -> Result<Transaction, PlatformWalletError> {
use key_wallet::wallet::managed_wallet_info::coin_selection::SelectionStrategy;
use key_wallet::wallet::managed_wallet_info::transaction_builder::TransactionBuilder;
use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface;

if outputs.is_empty() {
return Err(PlatformWalletError::TransactionBuild(
Expand Down Expand Up @@ -103,8 +106,12 @@ impl<B: TransactionBroadcaster + ?Sized> CoreWallet<B> {
))
})?;

// Peek at the next change address without advancing the derivation
// index. We commit the advance only after post-build revalidation
// succeeds, so a revalidation failure does not burn an index and
// widen the gap-limit window on retry.
let change_addr = change_account
.next_change_address(Some(&xpub), true)
.next_change_address(Some(&xpub), false)
.map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))?;

builder = builder.set_change_address(change_addr);
Expand All @@ -127,12 +134,117 @@ impl<B: TransactionBroadcaster + ?Sized> CoreWallet<B> {
)
.map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))?;

builder
let tx = builder
.build()
.map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))?
.map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))?;

// Sanity-check that the builder only selected outpoints from
// the same height-aware spendable set we handed to input
// selection. We deliberately do NOT mark the inputs as spent here
// — that happens after a successful broadcast (see #3466 review).
// A failed broadcast must not leave UTXOs falsely marked spent.
let selected: BTreeSet<OutPoint> =
tx.input.iter().map(|txin| txin.previous_output).collect();
let spendable_outpoints: BTreeSet<OutPoint> =
spendable.iter().map(|utxo| utxo.outpoint).collect();
if !selected.is_subset(&spendable_outpoints) {
return Err(PlatformWalletError::ConcurrentSpendConflict);
}
Comment thread
Claudius-Maginificent marked this conversation as resolved.
Comment on lines +141 to +152
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: ConcurrentSpendConflict subset check cannot detect the race the variant is named after

select_inputs(&spendable, ...) at lines 119-135 is the only input source the builder receives, and the wallet_manager.write().await guard acquired at line 50 is held continuously through builder.build() (line 137) and the subset check (lines 146-152) — the closure ends at line 181. No concurrent writer can mutate the spendable set between selection and this check, and by the builder's contract tx.input outpoints must already be a subset of the slice it was given. The only way this branch can fire is an upstream builder invariant break — a deterministic bug, not a transient — yet the variant name ConcurrentSpendConflict and its Display text "...concurrent spend); retry" instruct callers to retry, potentially spinning a retry loop against a broken builder. The genuine TOCTOU window is between the lock drop at line 181 and the broadcast at line 190 (correctly described in the comments), but this branch cannot detect it. Either downgrade to debug_assert!, or split the typed variant so an internal-invariant case is reported separately from the retryable race.

source: ['claude', 'codex']


// Revalidation passed; now commit the change-address advance so
// the next send picks up the next index. Re-borrow the managed
// account because `select_inputs` above borrowed
// `info.core_wallet.accounts` and ended the earlier reborrow.
let change_account = match account_type {
StandardAccountType::BIP44Account => info
.core_wallet
.accounts
.standard_bip44_accounts
.get_mut(&account_index),
StandardAccountType::BIP32Account => info
.core_wallet
.accounts
.standard_bip32_accounts
.get_mut(&account_index),
}
Comment on lines +141 to +169
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: Builder-vs-spendable subset check is effectively unreachable but framed as a user-retryable error

select_inputs(&spendable, ...) on lines 115-131 hands TransactionBuilder the only input source it has access to in this code path; by the builder's contract tx.input outpoints must be a subset of spendable. The branch on line 146 is only reachable if the upstream key-wallet builder violates its own invariant — a bug, not a transient. The user-facing message "Transaction builder selected an unavailable UTXO. Please retry." then misleads callers into a retry that will hit the same broken builder, and pays for two BTreeSet allocations on the happy path. Prefer a debug_assert! or a distinct InternalInvariant error variant rather than runtime Err framed as retryable.

source: ['claude']

.ok_or_else(|| {
PlatformWalletError::TransactionBuild(format!(
"{:?} managed account {} not found",
account_type, account_index
))
})?;
change_account
.next_change_address(Some(&xpub), true)
.map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))?;

tx
};

// Broadcast first; if the network rejects we leave wallet state
// untouched so the caller can retry without manual sync repair.
// This is intentional even if the remote accepted the transaction
// but the broadcast path returned an error: in that ambiguous case
// later attempts may reuse the same inputs locally, but the network
// rejects the duplicate spend instead of us marking UTXOs spent for
// a transaction that might not have propagated.
self.broadcast_transaction(&tx).await?;
Comment thread
lklimek marked this conversation as resolved.

// Now that the tx is in flight, register it as a mempool transaction
// so subsequent callers see the inputs as spent and don't reselect
// them. The trade-off is that two callers racing between the lock
// drop above and the broadcast can both pick the same UTXOs; the
// network resolves that race exactly as it does on `v3.1-dev`
// today, but neither caller corrupts local state on a transient
// broadcast failure.
//
// Broadcast-first semantics: by the time we get here the network has
// already accepted the transaction, so the two warning paths below
// intentionally do NOT convert into a post-success `Err`. They
// simply mean local wallet state did not get updated to reflect the
// mempool spend / change output. Recovery in both cases:
//
// * The next `send_to_addresses` from the same handle may reselect
// the same UTXOs because they still look spendable locally. That
// follow-up transaction will be rejected by the network as a
// duplicate spend (the broadcaster surfaces that as an error to
// the caller), so funds are never double-spent on-chain.
// * Once mempool/block sync catches up, the wallet will see the
// original transaction and reconcile its UTXO set, after which
// subsequent sends pick up the correct change outputs.
//
// The two cases differ in what they imply:
//
// * `!check_result.is_relevant` is the expected transient: the
// wallet just hasn't ingested the tx yet (or some derivation
// path/script is unrecognised), and a later sync will fix it.
// * The `else` branch (wallet missing in the manager) is NOT a
// normal transient — the broadcast succeeded against a
// `CoreWallet` handle whose underlying wallet entry is gone
// from the manager. That is a broken/inconsistent local handle
// and the warning exists so operators can spot it; future
// sends through the same handle will keep failing the lookup
// above and surface a clean `WalletNotFound` error.
{
let mut wm = self.wallet_manager.write().await;
if let Some((wallet, info)) = wm.get_wallet_mut_and_info_mut(&self.wallet_id) {
let check_result = info
.check_core_transaction(&tx, TransactionContext::Mempool, wallet, true, true)
.await;
if !check_result.is_relevant {
tracing::warn!(
txid = %tx.txid(),
"broadcast transaction was not relevant during post-broadcast wallet registration"
);
}
} else {
tracing::warn!(
wallet_id = %hex::encode(self.wallet_id),
txid = %tx.txid(),
"wallet missing during post-broadcast transaction registration"
);
}
Comment on lines +227 to +245
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.

🟡 Suggestion: Post-broadcast !is_relevant is treated as a transient even for transactions built from this wallet's own UTXOs

After broadcast_transaction succeeds, the only place that records the spend in local state is the check_core_transaction(.., Mempool, ..) call on line 203. Both the !is_relevant path (lines 205-210) and the wallet-missing path (lines 211-217) downgrade to tracing::warn! and the function still returns Ok(tx). For a transaction the wallet just built using its own spendable UTXOs and its own derived change address, !is_relevant indicates a wallet-internal invariant break (xpub mismatch, derivation drift, account map staleness) — not the kind of transient the comment treats it as. Letting it pass silently means the next send_to_addresses can reselect the same inputs and only discover the problem via a network rejection later. Consider distinguishing the two warning branches: keep the wallet-missing branch as best-effort logging, but treat !is_relevant for an own-built transaction as an internal error (or at minimum surface a counter/structured error field) so operators can see it independently of free-form log lines.

source: ['codex']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet/src/wallet/core/broadcast.rs`:
- [SUGGESTION] lines 199-217: Post-broadcast !is_relevant is treated as a transient even for transactions built from this wallet's own UTXOs
  After broadcast_transaction succeeds, the only place that records the spend in local state is the check_core_transaction(.., Mempool, ..) call on line 203. Both the !is_relevant path (lines 205-210) and the wallet-missing path (lines 211-217) downgrade to tracing::warn! and the function still returns Ok(tx). For a transaction the wallet just built using its own spendable UTXOs and its own derived change address, !is_relevant indicates a wallet-internal invariant break (xpub mismatch, derivation drift, account map staleness) — not the kind of transient the comment treats it as. Letting it pass silently means the next send_to_addresses can reselect the same inputs and only discover the problem via a network rejection later. Consider distinguishing the two warning branches: keep the wallet-missing branch as best-effort logging, but treat !is_relevant for an own-built transaction as an internal error (or at minimum surface a counter/structured error field) so operators can see it independently of free-form log lines.

}
Comment thread
Claudius-Maginificent marked this conversation as resolved.
Comment on lines +227 to +246
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: Wallet-missing-after-successful-broadcast branch is observable only as a single log line

The else branch on lines 211-217 logs a warning and returns Ok(tx) when the wallet's underlying entry has been removed from the manager between the lock drop on line 153 and the lock reacquisition on line 200. The PR comment correctly explains why this is not converted to Err (broadcast already succeeded), but there is no metric, no counter, and the function returns Ok(tx) indistinguishably from the happy path. If this is broken-handle territory as the comment states, the next operation through the handle will hit WalletNotFound anyway, so local state has only one chance to be reconciled by mempool/block sync. A counter or a structured tracing field (e.g. wallet manager's known wallet count) would make the inconsistency observable in production telemetry.

source: ['claude']

Comment on lines 181 to +246
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: Lock-drop window between selection and post-broadcast registration permits same-owner UTXO double-pick

The write lock is dropped at line 153 and re-acquired at line 200; during that window a concurrent task with the same CoreWallet handle still sees the just-spent UTXOs as spendable and can pick them. There is no exploitable vector: only the wallet owner's own async tasks can race, and the network rejects the loser, so on-chain double-spend is impossible. The PR explicitly accepts this trade-off. Worth flagging only as defense-in-depth: if a future broadcaster ever runs in a context where ordering is not network-arbitrated (private mempool / sequencer), the local race becomes the only backstop. A cheap hardening would be an in-memory BTreeSet of pending-broadcast outpoints that selection skips. No action required for merge.

source: ['claude']

Comment on lines +227 to +246
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.

🟡 Suggestion: Post-broadcast !is_relevant for self-built tx is silently downgraded to a warning

After a successful broadcast, the only path that records the spend in local state is info.check_core_transaction(&tx, TransactionContext::Mempool, wallet, true, true) at lines 230-232. For a transaction this wallet just built from its own spendable UTXOs (lines 78-82) and its own derived change address (lines 113-117), check_result.is_relevant == false is not a transient sync gap — it indicates a wallet-internal invariant break (xpub mismatch, derivation drift, account-map staleness). The branch at lines 233-238 emits only a tracing::warn! and returns Ok(tx), so the next send_to_addresses from the same handle can reselect the same inputs and only learn of the desync via a network duplicate-spend rejection — exactly the failure mode this PR is meant to improve. The comment block at lines 215-226 explicitly bundles this with the wallet-missing branch as 'transient', but they are categorically different: wallet-missing is best-effort cleanup, while !is_relevant for an own-built tx is an internal contract violation. Surface it via a structured metric or distinct tracing::error! so operators can distinguish it from genuine sync-lag warnings in production telemetry.

source: ['claude', 'codex']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet/src/wallet/core/broadcast.rs`:
- [SUGGESTION] lines 227-246: Post-broadcast `!is_relevant` for self-built tx is silently downgraded to a warning
  After a successful broadcast, the only path that records the spend in local state is `info.check_core_transaction(&tx, TransactionContext::Mempool, wallet, true, true)` at lines 230-232. For a transaction this wallet just built from its own `spendable` UTXOs (lines 78-82) and its own derived change address (lines 113-117), `check_result.is_relevant == false` is not a transient sync gap — it indicates a wallet-internal invariant break (xpub mismatch, derivation drift, account-map staleness). The branch at lines 233-238 emits only a `tracing::warn!` and returns `Ok(tx)`, so the next `send_to_addresses` from the same handle can reselect the same inputs and only learn of the desync via a network duplicate-spend rejection — exactly the failure mode this PR is meant to improve. The comment block at lines 215-226 explicitly bundles this with the wallet-missing branch as 'transient', but they are categorically different: wallet-missing is best-effort cleanup, while `!is_relevant` for an own-built tx is an internal contract violation. Surface it via a structured metric or distinct `tracing::error!` so operators can distinguish it from genuine sync-lag warnings in production telemetry.


Ok(tx)
Comment on lines 182 to 248
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.

🟡 Suggestion: No automated test for the broadcast-first ordering or the failure-rollback contract

The PR's central correctness claim — broadcast failure leaves spendable UTXOs untouched, broadcast success makes them non-spendable for the next caller via post-broadcast check_core_transaction — is exactly the regression flagged on the original #3466. CoreWallet is generic over B: TransactionBroadcaster + ?Sized, so the seam for deterministic unit tests already exists. Two short async tests would lock this in:

  1. Inject a broadcaster that returns Err(...) and assert the spendable-UTXO set is byte-identical before vs. after the failed send_to_addresses (no check_core_transaction applied).
  2. Inject a broadcaster that returns Ok(...) and assert get_spendable_utxos() afterward no longer contains the spent inputs and includes the change output.

No test in packages/rs-platform-wallet currently exercises send_to_addresses or broadcast_transaction (verified via grep across src/ and tests/). The PR's manual checkboxes for both behaviors are deferred to a running node, which is exactly what a unit test should cover. Without coverage, only code review prevents a future refactor from re-introducing the original mark-spent-before-broadcast bug.

source: ['claude', 'codex']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet/src/wallet/core/broadcast.rs`:
- [SUGGESTION] lines 154-220: No automated test for the broadcast-first ordering or the failure-rollback contract
  The PR's central correctness claim — broadcast failure leaves spendable UTXOs untouched, broadcast success makes them non-spendable for the next caller via post-broadcast check_core_transaction — is exactly the regression flagged on the original #3466. CoreWallet is generic over B: TransactionBroadcaster + ?Sized, so the seam for deterministic unit tests already exists. Two short async tests would lock this in:

1. Inject a broadcaster that returns Err(...) and assert the spendable-UTXO set is byte-identical before vs. after the failed send_to_addresses (no check_core_transaction applied).
2. Inject a broadcaster that returns Ok(...) and assert get_spendable_utxos() afterward no longer contains the spent inputs and includes the change output.

No test in packages/rs-platform-wallet currently exercises send_to_addresses or broadcast_transaction (verified via grep across src/ and tests/). The PR's manual checkboxes for both behaviors are deferred to a running node, which is exactly what a unit test should cover. Without coverage, only code review prevents a future refactor from re-introducing the original mark-spent-before-broadcast bug.

}
Comment thread
lklimek marked this conversation as resolved.
Comment on lines 134 to 249
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.

🟡 Suggestion: Broadcast-first ordering and rollback-on-failure contract have no automated coverage

The PR's central correctness claim — broadcast failure leaves the spendable UTXO set untouched, broadcast success makes those inputs non-spendable for the next caller via post-broadcast check_core_transaction — is exactly the regression flagged on #3466. CoreWallet is generic over B: TransactionBroadcaster + ?Sized, so the seam for deterministic unit tests already exists, but a repository-wide grep confirms no test under packages/rs-platform-wallet exercises send_to_addresses (only the implementation file matches). Two short async tests would lock this in: (1) inject a broadcaster that returns Err(...) and assert the spendable-UTXO set is byte-identical before vs. after the failed call (and check_core_transaction is never invoked); (2) inject a broadcaster that returns Ok(...) and assert get_spendable_utxos afterward no longer contains the spent inputs and includes the change output. Without coverage, only code review prevents a future refactor from re-introducing the original mark-spent-before-broadcast bug.

source: ['claude', 'codex']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet/src/wallet/core/broadcast.rs`:
- [SUGGESTION] lines 34-249: Broadcast-first ordering and rollback-on-failure contract have no automated coverage
  The PR's central correctness claim — broadcast failure leaves the spendable UTXO set untouched, broadcast success makes those inputs non-spendable for the next caller via post-broadcast `check_core_transaction` — is exactly the regression flagged on #3466. `CoreWallet` is generic over `B: TransactionBroadcaster + ?Sized`, so the seam for deterministic unit tests already exists, but a repository-wide grep confirms no test under `packages/rs-platform-wallet` exercises `send_to_addresses` (only the implementation file matches). Two short async tests would lock this in: (1) inject a broadcaster that returns `Err(...)` and assert the spendable-UTXO set is byte-identical before vs. after the failed call (and `check_core_transaction` is never invoked); (2) inject a broadcaster that returns `Ok(...)` and assert `get_spendable_utxos` afterward no longer contains the spent inputs and includes the change output. Without coverage, only code review prevents a future refactor from re-introducing the original mark-spent-before-broadcast bug.

}
97 changes: 74 additions & 23 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);

// 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 normalized_label = convert_to_homograph_safe_chars(label);
let dpns_contract = self.fetch_dpns_contract().await?;

// Query for existing domain with this label
let query = DocumentQuery {
Expand Down Expand Up @@ -422,29 +455,13 @@ impl Sdk {

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() {
// Validate the normalized label before proceeding
if normalized_label.is_empty() {
return Ok(None);
}
Comment on lines 456 to 463
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: resolve_dpns_name fetches the DPNS contract before the empty-label early return

is_dpns_name_available now normalizes and returns Ok(false) for empty inputs before calling self.fetch_dpns_contract().await? (lines 402-411). resolve_dpns_name does the opposite at lines 456-463: it fetches the contract first, then returns Ok(None) on empty normalization. So resolve_dpns_name(""), resolve_dpns_name(".dash"), or resolve_dpns_name(".DASH") performs a network round-trip that gets discarded. The PR description calls out that both APIs should agree on malformed input — this is the easy half of that contract. Move fetch_dpns_contract below the empty-label guard to mirror is_dpns_name_available.

source: ['claude']

Comment on lines 456 to 463
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: resolve_dpns_name still fetches the DPNS contract before its empty-label guard

is_dpns_name_available was reordered (lines 402-411) so an empty normalized label short-circuits before any network I/O, with a comment claiming it 'mirrors the early-return guard in resolve_dpns_name so the two APIs agree on malformed input.' But resolve_dpns_name does the opposite: line 456 calls self.fetch_dpns_contract().await? before normalize_dpns_label and the normalized_label.is_empty() check at lines 458-463. So resolve_dpns_name(""), resolve_dpns_name(".dash"), and resolve_dpns_name(".DASH") still pay for a contract round-trip whose result is discarded. The two APIs agree on return value but not on side-effects/cost. Moving fetch_dpns_contract below the empty-label guard would make the sibling comment accurate and align cost characteristics across the pair.

💡 Suggested change
Suggested change
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() {
// Validate the normalized label before proceeding
if normalized_label.is_empty() {
return Ok(None);
}
let normalized_label = normalize_dpns_label(name);
// Validate the normalized label before proceeding
if normalized_label.is_empty() {
return Ok(None);
}
let dpns_contract = self.fetch_dpns_contract().await?;

source: ['claude', 'codex']


let normalized_label = convert_to_homograph_safe_chars(label);

// Query for domain with this label
let query = DocumentQuery {
data_contract: dpns_contract,
Expand Down Expand Up @@ -499,6 +516,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