-
Notifications
You must be signed in to change notification settings - Fork 53
fix: case-insensitive .dash suffix and UTXO double-spend prevention (re-target of #3466) #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
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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}; | ||
|
|
@@ -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( | ||
|
|
@@ -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); | ||
|
|
@@ -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 on lines
+141
to
+152
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:
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
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: 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?; | ||
|
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
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. 🟡 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 |
||
| } | ||
|
Claudius-Maginificent marked this conversation as resolved.
Comment on lines
+227
to
+246
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: 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
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: 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
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. 🟡 Suggestion: Post-broadcast After a successful broadcast, the only path that records the spend in local state is source: ['claude', 'codex'] 🤖 Fix this with AI agents |
||
|
|
||
| Ok(tx) | ||
|
Comment on lines
182
to
248
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. 🟡 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:
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 |
||
| } | ||
|
lklimek marked this conversation as resolved.
Comment on lines
134
to
249
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. 🟡 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 source: ['claude', 'codex'] 🤖 Fix this with AI agents |
||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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 { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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
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: 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
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:
💡 Suggested change
Suggested change
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, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.