From f72ee9ccdad10cb190c586b000cedfa3388770f6 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:29:03 +0100 Subject: [PATCH 01/13] feat(ui): add unified AddressInput component with autocomplete Introduce a reusable AddressInput component that handles text input with real-time address type detection, autocomplete from wallet data, balance display, type filtering, and network-aware validation. Supports Core, Platform, Shielded, and Identity address kinds. New files: - src/model/address.rs: AddressKind enum and ValidatedAddress enum - src/ui/components/address_input.rs: full component with 33 unit tests Co-Authored-By: Claude Opus 4.6 (1M context) --- src/model/address.rs | 182 +++++ src/model/mod.rs | 1 + src/ui/components/address_input.rs | 1224 ++++++++++++++++++++++++++++ src/ui/components/mod.rs | 1 + 4 files changed, 1408 insertions(+) create mode 100644 src/model/address.rs create mode 100644 src/ui/components/address_input.rs diff --git a/src/model/address.rs b/src/model/address.rs new file mode 100644 index 000000000..7454d7cc8 --- /dev/null +++ b/src/model/address.rs @@ -0,0 +1,182 @@ +use dash_sdk::dashcore_rpc::dashcore::Address; +use dash_sdk::dpp::address_funds::PlatformAddress; +use dash_sdk::platform::Identifier; + +/// Classification of a Dash address for filtering and display purposes. +/// +/// This enum represents the four recognized address categories. It is used +/// by `AddressInput` to configure which address types are accepted and to +/// label entries in the autocomplete dropdown. +/// +/// Unlike the internal detection concept, there is no `Unknown` variant here -- +/// an address either falls into one of these categories or fails validation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AddressKind { + /// Core L1 address (P2PKH / P2SH, Base58Check). + Core, + /// Platform L2 address (Bech32m per DIP-18). + Platform, + /// Shielded Orchard address (dash1z... / tdash1z...). + Shielded, + /// Identity identifier (Base58-encoded Identifier). + Identity, +} + +impl AddressKind { + /// User-facing display name, suitable for i18n extraction. + pub fn display_name(&self) -> &'static str { + match self { + Self::Core => "Wallet address", + Self::Platform => "Platform address", + Self::Shielded => "Private address", + Self::Identity => "Identity", + } + } + + /// All address kinds in detection priority order. + pub const ALL: [AddressKind; 4] = [ + AddressKind::Core, + AddressKind::Platform, + AddressKind::Shielded, + AddressKind::Identity, + ]; +} + +impl std::fmt::Display for AddressKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.display_name()) + } +} + +/// A fully validated address with its parsed typed payload. +/// +/// This is the domain type produced by `AddressInput` via `ComponentResponse`. +/// Each variant carries the parsed representation for its address type. +#[derive(Debug, Clone)] +pub enum ValidatedAddress { + /// A validated Core L1 address. + Core(Address), + /// A validated Platform L2 address. + Platform(PlatformAddress), + /// A validated shielded Orchard address (stored as the raw string). + Shielded(String), + /// A validated identity identifier with optional DPNS name. + Identity { + /// The parsed identity identifier. + id: Identifier, + /// Resolved DPNS name, if available from local data. + dpns_name: Option, + }, +} + +impl ValidatedAddress { + /// Returns the `AddressKind` for this validated address. + pub fn kind(&self) -> AddressKind { + match self { + Self::Core(_) => AddressKind::Core, + Self::Platform(_) => AddressKind::Platform, + Self::Shielded(_) => AddressKind::Shielded, + Self::Identity { .. } => AddressKind::Identity, + } + } + + /// Returns the raw address string representation. + pub fn to_address_string(&self) -> String { + match self { + Self::Core(addr) => addr.to_string(), + Self::Platform(addr) => format!("{}", addr), + Self::Shielded(s) => s.clone(), + Self::Identity { id, .. } => { + id.to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58) + } + } + } + + /// Returns the core address if this is a Core variant. + pub fn as_core(&self) -> Option<&Address> { + match self { + Self::Core(addr) => Some(addr), + _ => None, + } + } + + /// Returns the platform address if this is a Platform variant. + pub fn as_platform(&self) -> Option<&PlatformAddress> { + match self { + Self::Platform(addr) => Some(addr), + _ => None, + } + } + + /// Returns the identity ID if this is an Identity variant. + pub fn as_identity_id(&self) -> Option<&Identifier> { + match self { + Self::Identity { id, .. } => Some(id), + _ => None, + } + } + + /// Returns the DPNS name if this is an Identity variant with a resolved name. + pub fn dpns_name(&self) -> Option<&str> { + match self { + Self::Identity { dpns_name, .. } => dpns_name.as_deref(), + _ => None, + } + } +} + +impl std::fmt::Display for ValidatedAddress { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Core(addr) => write!(f, "{}", addr), + Self::Platform(addr) => write!(f, "{}", addr), + Self::Shielded(s) => write!(f, "{}", s), + Self::Identity { + id, + dpns_name: Some(name), + } => write!(f, "{} ({})", name, id), + Self::Identity { + id, + dpns_name: None, + } => write!( + f, + "{}", + id.to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58) + ), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn address_kind_display_names() { + assert_eq!(AddressKind::Core.display_name(), "Wallet address"); + assert_eq!(AddressKind::Platform.display_name(), "Platform address"); + assert_eq!(AddressKind::Shielded.display_name(), "Private address"); + assert_eq!(AddressKind::Identity.display_name(), "Identity"); + } + + #[test] + fn address_kind_all_contains_four_variants() { + assert_eq!(AddressKind::ALL.len(), 4); + } + + #[test] + fn validated_address_kind_round_trips() { + let shielded = ValidatedAddress::Shielded("dash1z_test".to_string()); + assert_eq!(shielded.kind(), AddressKind::Shielded); + assert_eq!(shielded.to_address_string(), "dash1z_test"); + } + + #[test] + fn validated_address_accessors_return_none_for_wrong_variant() { + let shielded = ValidatedAddress::Shielded("dash1z_test".to_string()); + assert!(shielded.as_core().is_none()); + assert!(shielded.as_platform().is_none()); + assert!(shielded.as_identity_id().is_none()); + assert!(shielded.dpns_name().is_none()); + } +} diff --git a/src/model/mod.rs b/src/model/mod.rs index d92cf061f..3c4f7a09d 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,3 +1,4 @@ +pub mod address; pub mod amount; pub mod contested_name; pub mod fee_estimation; diff --git a/src/ui/components/address_input.rs b/src/ui/components/address_input.rs new file mode 100644 index 000000000..ba73e9e95 --- /dev/null +++ b/src/ui/components/address_input.rs @@ -0,0 +1,1224 @@ +use crate::model::address::{AddressKind, ValidatedAddress}; +use crate::model::amount::{Amount, DASH_DECIMAL_PLACES}; +use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::wallet::Wallet; +use crate::ui::components::{Component, ComponentResponse}; +use crate::ui::theme::DashColors; +use dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked; +use dash_sdk::dashcore_rpc::dashcore::{Address, Network}; +use dash_sdk::dpp::address_funds::PlatformAddress; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::platform::Identifier; +use egui::{InnerResponse, Response, Ui, WidgetText}; +use std::ops::Bound; +use std::sync::{Arc, RwLock}; + +/// Internal detection result including the `Unknown` state for unrecognized input. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DetectedType { + Core, + Platform, + Shielded, + Identity, + Unknown, +} + +impl DetectedType { + fn to_address_kind(self) -> Option { + match self { + Self::Core => Some(AddressKind::Core), + Self::Platform => Some(AddressKind::Platform), + Self::Shielded => Some(AddressKind::Shielded), + Self::Identity => Some(AddressKind::Identity), + Self::Unknown => None, + } + } +} + +/// A single autocomplete entry rendered in the dropdown. +/// +/// Pre-computed from wallet/identity data at builder/setter time. +#[derive(Debug, Clone)] +struct AddressEntry { + /// The full address string (populates the text field on selection). + address_string: String, + /// Classification of this entry. + address_kind: AddressKind, + /// Human-readable label (DPNS name, alias, or truncated address). + display_label: String, + /// Balance in native units (duffs for Core, credits for Platform/Shielded/Identity). + balance: u64, + /// Pre-built ValidatedAddress for immediate use on selection. + validated: ValidatedAddress, +} + +/// Concrete balance range bounds. +/// +/// `RangeBounds` is not object-safe, so we extract start/end bounds +/// at configuration time and store them concretely. +#[derive(Debug, Clone)] +struct BalanceRange { + start: Bound, + end: Bound, +} + +impl BalanceRange { + fn from_range(range: &impl std::ops::RangeBounds) -> Self { + Self { + start: range.start_bound().cloned(), + end: range.end_bound().cloned(), + } + } + + fn contains(&self, value: u64) -> bool { + let start_ok = match self.start { + Bound::Included(s) => value >= s, + Bound::Excluded(s) => value > s, + Bound::Unbounded => true, + }; + let end_ok = match self.end { + Bound::Included(e) => value <= e, + Bound::Excluded(e) => value < e, + Bound::Unbounded => true, + }; + start_ok && end_ok + } +} + +/// Response from the `AddressInput` component. +#[derive(Clone)] +pub struct AddressInputResponse { + /// The egui response from the primary text input widget. + pub response: Response, + /// Whether the component's value changed this frame. + changed: bool, + /// Validation error message, if any. + error_message: Option, + /// The validated address, if input is valid. + validated_address: Option, +} + +impl ComponentResponse for AddressInputResponse { + type DomainType = ValidatedAddress; + + fn has_changed(&self) -> bool { + self.changed + } + + fn is_valid(&self) -> bool { + self.error_message.is_none() + } + + fn changed_value(&self) -> &Option { + &self.validated_address + } + + fn error_message(&self) -> Option<&str> { + self.error_message.as_deref() + } +} + +/// Unified address input with autocomplete, type detection, and validation. +/// +/// Follows the Component design pattern: lazy-initialize as `Option` +/// in screen structs, configure via builder methods, render with `show()`, +/// bind to domain data with `response.inner.update(&mut self.address)`. +/// +/// # Usage +/// +/// ```rust,ignore +/// let addr_input = self.address_input.get_or_insert_with(|| { +/// AddressInput::new(network) +/// .with_wallet(wallet.clone()) +/// .with_label("Destination address") +/// .with_hint_text("Enter address or username") +/// }); +/// +/// let response = addr_input.show(ui); +/// response.inner.update(&mut self.validated_address); +/// ``` +pub struct AddressInput { + // --- Configuration --- + network: Network, + enabled_kinds: Vec, + show_type_filter: bool, + dpns_resolution: bool, + developer_mode: bool, + selection_only: bool, + full_addresses: bool, + label: Option, + hint_text: Option, + desired_width: Option, + show_validation_errors: bool, + balance_range: Option, + + // --- Autocomplete data (set via builder, read each frame) --- + all_entries: Vec, + + // --- Mutable UI state --- + input_text: String, + selected_type_filter: Option, + autocomplete_highlight: Option, + autocomplete_open: bool, + has_blurred: bool, + selected_from_autocomplete: bool, + cached_detection: Option<(String, DetectedType)>, + changed: bool, +} + +impl AddressInput { + /// Create a new `AddressInput` for the given network. + /// + /// Default: all four address kinds enabled, no wallet data, no autocomplete. + pub fn new(network: Network) -> Self { + Self { + network, + enabled_kinds: AddressKind::ALL.to_vec(), + show_type_filter: false, + dpns_resolution: true, + developer_mode: false, + selection_only: false, + full_addresses: false, + label: None, + hint_text: None, + desired_width: None, + show_validation_errors: true, + balance_range: None, + all_entries: Vec::new(), + input_text: String::new(), + selected_type_filter: None, + autocomplete_highlight: None, + autocomplete_open: false, + has_blurred: false, + selected_from_autocomplete: false, + cached_detection: None, + changed: false, + } + } + + /// Restrict which address kinds are accepted and shown. + pub fn with_address_kinds(mut self, kinds: &[AddressKind]) -> Self { + self.enabled_kinds = kinds.to_vec(); + self + } + + /// Provide wallet data for Core and Platform autocomplete. + /// + /// Entries are extracted immediately (read lock acquired once). + /// Skips gracefully if the wallet lock is poisoned. + pub fn with_wallet(mut self, wallet: Arc>) -> Self { + self.extract_wallet_entries(&wallet); + self + } + + /// Provide identity references for Identity-type autocomplete. + pub fn with_identities(mut self, identities: &[QualifiedIdentity]) -> Self { + self.extract_identity_entries(identities); + self + } + + /// Provide shielded address and balance for Shielded-type autocomplete. + pub fn with_shielded_balance(mut self, address: String, balance: u64) -> Self { + self.add_shielded_entry(address, balance); + self + } + + /// Show a type filter dropdown to the left of the text input. + /// + /// Only displayed when more than one address kind is enabled. Default: false. + pub fn with_type_filter_dropdown(mut self, show: bool) -> Self { + self.show_type_filter = show; + self + } + + /// Filter autocomplete entries by balance range (in native units). + /// + /// Does not affect manual input validation. Default: no filter. + pub fn with_balance_range(mut self, range: impl std::ops::RangeBounds) -> Self { + self.balance_range = Some(BalanceRange::from_range(&range)); + self + } + + /// Enable DPNS username resolution for Identity-type addresses. Default: true. + pub fn with_dpns_resolution(mut self, enabled: bool) -> Self { + self.dpns_resolution = enabled; + self + } + + /// Set the label displayed above the input field. + pub fn with_label(mut self, label: impl Into) -> Self { + self.label = Some(label.into()); + self + } + + /// Set the hint/placeholder text inside the input field. + pub fn with_hint_text(mut self, hint: impl Into) -> Self { + self.hint_text = Some(hint.into()); + self + } + + /// Set the desired width of the input field. + pub fn with_desired_width(mut self, width: f32) -> Self { + self.desired_width = Some(width); + self + } + + /// Enable or disable validation error display. Default: true. + pub fn with_show_validation_errors(mut self, show: bool) -> Self { + self.show_validation_errors = show; + self + } + + /// Enable developer mode display (exact credits alongside DASH). Default: false. + pub fn with_developer_mode(mut self, enabled: bool) -> Self { + self.developer_mode = enabled; + self + } + + /// Pre-populate the input field with an address string. + pub fn with_initial_value(mut self, address: impl Into) -> Self { + self.input_text = address.into(); + self + } + + /// Enable selection-only mode. When true, the user must pick from autocomplete; + /// manual arbitrary addresses are rejected. + pub fn with_selection_only(mut self, selection_only: bool) -> Self { + self.selection_only = selection_only; + self + } + + /// Show full addresses in dropdown instead of truncated. Default: false. + pub fn with_full_addresses(mut self, full: bool) -> Self { + self.full_addresses = full; + self + } + + // --- Mutable setters for runtime reconfiguration --- + + /// Update wallet data after initialization (e.g., balance refresh). + pub fn set_wallet(&mut self, wallet: &Arc>) { + self.all_entries.retain(|e| { + e.address_kind != AddressKind::Core && e.address_kind != AddressKind::Platform + }); + self.extract_wallet_entries(wallet); + } + + /// Update identity data after initialization. + pub fn set_identities(&mut self, identities: &[QualifiedIdentity]) { + self.all_entries + .retain(|e| e.address_kind != AddressKind::Identity); + self.extract_identity_entries(identities); + } + + /// Update shielded balance data after initialization. + pub fn set_shielded_balance(&mut self, address: String, balance: u64) { + self.all_entries + .retain(|e| e.address_kind != AddressKind::Shielded); + self.add_shielded_entry(address, balance); + } + + /// Update developer mode flag. + pub fn set_developer_mode(&mut self, enabled: bool) { + self.developer_mode = enabled; + } + + // --- Entry extraction --- + + fn extract_wallet_entries(&mut self, wallet: &Arc>) { + let guard = match wallet.read().ok() { + Some(g) => g, + None => return, + }; + + // Core addresses from address_balances + for (address, &balance) in &guard.address_balances { + let addr_str = address.to_string(); + let display = if self.full_addresses { + addr_str.clone() + } else { + truncate_address(&addr_str) + }; + self.all_entries.push(AddressEntry { + address_string: addr_str, + address_kind: AddressKind::Core, + display_label: display, + balance, + validated: ValidatedAddress::Core(address.clone()), + }); + } + + // Platform addresses from platform_address_info + for (core_addr, info) in &guard.platform_address_info { + if let Ok(platform_addr) = PlatformAddress::try_from(core_addr.clone()) { + let addr_str = platform_addr.to_bech32m_string(self.network); + let display = if self.full_addresses { + addr_str.clone() + } else { + truncate_address(&addr_str) + }; + self.all_entries.push(AddressEntry { + address_string: addr_str, + address_kind: AddressKind::Platform, + display_label: display, + balance: info.balance, + validated: ValidatedAddress::Platform(platform_addr), + }); + } + } + } + + fn extract_identity_entries(&mut self, identities: &[QualifiedIdentity]) { + for qi in identities { + let id = qi.identity.id(); + let id_str = id.to_string(Encoding::Base58); + let dpns_name = qi.dpns_names.first().map(|n| n.name.clone()); + let display = if let Some(ref name) = dpns_name { + name.clone() + } else if let Some(ref alias) = qi.alias { + alias.clone() + } else if self.full_addresses { + id_str.clone() + } else { + truncate_address(&id_str) + }; + self.all_entries.push(AddressEntry { + address_string: id_str, + address_kind: AddressKind::Identity, + display_label: display, + balance: qi.identity.balance(), + validated: ValidatedAddress::Identity { + id, + dpns_name: dpns_name.clone(), + }, + }); + } + } + + fn add_shielded_entry(&mut self, address: String, balance: u64) { + let display = if self.full_addresses { + address.clone() + } else { + truncate_address(&address) + }; + self.all_entries.push(AddressEntry { + address_string: address.clone(), + address_kind: AddressKind::Shielded, + display_label: display, + balance, + validated: ValidatedAddress::Shielded(address), + }); + } + + // --- Detection and validation --- + + fn detect_cached(&mut self, input: &str) -> DetectedType { + if let Some((ref cached_input, cached_type)) = self.cached_detection + && cached_input == input + { + return cached_type; + } + let identity_enabled = self.enabled_kinds.contains(&AddressKind::Identity); + let result = detect_address_type(input, identity_enabled); + self.cached_detection = Some((input.to_string(), result)); + result + } + + fn validate_input(&self) -> (Option, Option) { + let trimmed = self.input_text.trim(); + if trimmed.is_empty() { + return (None, None); + } + + // In selection-only mode, manual input that does not match an entry is rejected. + if self.selection_only { + return ( + Some("Please select an address from the list.".to_string()), + None, + ); + } + + let identity_enabled = self.enabled_kinds.contains(&AddressKind::Identity); + let detected = detect_address_type(trimmed, identity_enabled); + + if detected == DetectedType::Unknown { + return ( + Some("This does not look like a valid address.".to_string()), + None, + ); + } + + let detected_kind = detected.to_address_kind().unwrap(); + + // Check enabled kinds + if !self.enabled_kinds.contains(&detected_kind) { + let msg = match self.enabled_kinds.as_slice() { + [AddressKind::Core] => "Only wallet addresses are accepted here.", + [AddressKind::Platform] => "Only platform addresses are accepted here.", + [AddressKind::Shielded] => "Only private addresses are accepted here.", + [AddressKind::Identity] => "Only identity IDs are accepted here.", + _ => "This address type is not accepted here.", + }; + return (Some(msg.to_string()), None); + } + + // Type-specific validation + match detected { + DetectedType::Core => self.validate_core(trimmed), + DetectedType::Platform => self.validate_platform(trimmed), + DetectedType::Shielded => self.validate_shielded(trimmed), + DetectedType::Identity => self.validate_identity(trimmed), + DetectedType::Unknown => unreachable!(), + } + } + + fn validate_core(&self, trimmed: &str) -> (Option, Option) { + match trimmed.parse::>() { + Ok(addr) => match addr.require_network(self.network) { + Ok(checked) => (None, Some(ValidatedAddress::Core(checked))), + Err(_) => ( + Some("This address belongs to a different network.".to_string()), + None, + ), + }, + Err(_) => ( + Some("This does not look like a valid address.".to_string()), + None, + ), + } + } + + fn validate_platform(&self, trimmed: &str) -> (Option, Option) { + let canonical = trimmed.to_lowercase(); + let expected_prefix = match self.network { + Network::Mainnet => "dash1", + _ => "tdash1", + }; + if !canonical.starts_with(expected_prefix) + || canonical.starts_with(&format!("{}z", expected_prefix)) + { + return ( + Some("This address belongs to a different network.".to_string()), + None, + ); + } + match PlatformAddress::from_bech32m_string(&canonical) { + Ok((pa, _network)) => (None, Some(ValidatedAddress::Platform(pa))), + Err(_) => ( + Some("This does not look like a valid address.".to_string()), + None, + ), + } + } + + fn validate_shielded(&self, trimmed: &str) -> (Option, Option) { + let expected_prefix = match self.network { + Network::Mainnet => "dash1z", + _ => "tdash1z", + }; + if !trimmed.starts_with(expected_prefix) { + return ( + Some("This address belongs to a different network.".to_string()), + None, + ); + } + (None, Some(ValidatedAddress::Shielded(trimmed.to_string()))) + } + + fn validate_identity(&self, trimmed: &str) -> (Option, Option) { + match Identifier::from_string(trimmed, Encoding::Base58) { + Ok(id) => { + let dpns = if self.dpns_resolution { + self.all_entries + .iter() + .find(|e| { + e.address_kind == AddressKind::Identity + && e.validated.as_identity_id() == Some(&id) + }) + .and_then(|e| e.validated.dpns_name().map(|s| s.to_string())) + } else { + None + }; + ( + None, + Some(ValidatedAddress::Identity { + id, + dpns_name: dpns, + }), + ) + } + Err(_) => ( + Some("This does not look like a valid address.".to_string()), + None, + ), + } + } + + // --- Autocomplete filtering --- + + fn filtered_entries(&self) -> Vec<&AddressEntry> { + let query = self.input_text.trim().to_lowercase(); + if query.len() < 3 { + return Vec::new(); + } + + let mut results: Vec<&AddressEntry> = self + .all_entries + .iter() + .filter(|e| { + // Type filter + if let Some(filter_kind) = self.selected_type_filter + && e.address_kind != filter_kind + { + return false; + } + // Enabled kinds + if !self.enabled_kinds.contains(&e.address_kind) { + return false; + } + // Balance range + if let Some(ref range) = self.balance_range + && !range.contains(e.balance) + { + return false; + } + // Substring match against address and label + e.address_string.to_lowercase().contains(&query) + || e.display_label.to_lowercase().contains(&query) + }) + .collect(); + + // Sort: exact prefix matches first, then by label + results.sort_by(|a, b| { + let a_prefix = a.address_string.to_lowercase().starts_with(&query); + let b_prefix = b.address_string.to_lowercase().starts_with(&query); + b_prefix + .cmp(&a_prefix) + .then(a.display_label.cmp(&b.display_label)) + }); + + results.truncate(10); + results + } + + // --- Balance formatting --- + + fn format_balance(&self, entry: &AddressEntry) -> String { + match entry.address_kind { + AddressKind::Core => Amount::dash_from_duffs(entry.balance).to_string(), + AddressKind::Platform | AddressKind::Shielded | AddressKind::Identity => { + let dash = Amount::new(entry.balance, DASH_DECIMAL_PLACES).with_unit_name("DASH"); + if self.developer_mode { + format!("{} ({} credits)", dash, entry.balance) + } else { + dash.to_string() + } + } + } + } + + // --- show() implementation --- + + fn show_internal(&mut self, ui: &mut Ui) -> InnerResponse { + let resp = ui.vertical(|ui| { + // Label + if let Some(label) = &self.label { + ui.label(label.clone()); + } + + // Input row + let text_response = ui + .horizontal(|ui| { + // Type filter dropdown + if self.show_type_filter && self.enabled_kinds.len() > 1 { + let current_label = self + .selected_type_filter + .map(|t| t.display_name()) + .unwrap_or("All"); + egui::ComboBox::from_id_salt("address_type_filter") + .selected_text(current_label) + .width(120.0) + .show_ui(ui, |ui| { + if ui + .selectable_label(self.selected_type_filter.is_none(), "All") + .clicked() + { + self.selected_type_filter = None; + } + for &kind in &self.enabled_kinds { + let selected = self.selected_type_filter == Some(kind); + if ui.selectable_label(selected, kind.display_name()).clicked() + { + self.selected_type_filter = Some(kind); + } + } + }); + } + + // Text input + let mut text_edit = egui::TextEdit::singleline(&mut self.input_text); + if let Some(hint) = &self.hint_text { + text_edit = text_edit + .hint_text(egui::RichText::new(hint).color(egui::Color32::GRAY)); + } + if let Some(width) = self.desired_width { + text_edit = text_edit.desired_width(width); + } else { + text_edit = text_edit.desired_width(f32::INFINITY); + } + ui.add(text_edit) + }) + .inner; + + let text_changed = text_response.changed(); + let lost_focus = text_response.lost_focus(); + let has_focus = text_response.has_focus(); + + // On text change: reset validation state + if text_changed { + self.has_blurred = false; + self.selected_from_autocomplete = false; + self.cached_detection = None; + } + + // Detect address type (cached) + let input_clone = self.input_text.clone(); + let detected = self.detect_cached(&input_clone); + + // On blur: trigger validation + if lost_focus && !self.input_text.trim().is_empty() { + self.has_blurred = true; + } + + // Autocomplete popup + let mut selected_entry: Option = None; + if has_focus && self.input_text.trim().len() >= 3 { + // Collect filtered entries into an owned snapshot to release the borrow on self + let entries_snapshot: Vec<(String, String, AddressEntry)> = { + let filtered = self.filtered_entries(); + filtered + .iter() + .map(|e| { + ( + e.display_label.clone(), + self.format_balance(e), + (*e).clone(), + ) + }) + .collect() + }; + + if !entries_snapshot.is_empty() { + self.autocomplete_open = true; + let popup_id = ui.id().with("address_autocomplete"); + let total_entries = self + .all_entries + .iter() + .filter(|e| { + if let Some(fk) = self.selected_type_filter { + e.address_kind == fk + } else { + true + } + }) + .filter(|e| self.enabled_kinds.contains(&e.address_kind)) + .count(); + + egui::Area::new(popup_id) + .order(egui::Order::Foreground) + .fixed_pos(text_response.rect.left_bottom()) + .show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_width(text_response.rect.width()); + egui::ScrollArea::vertical() + .max_height(200.0) + .show(ui, |ui| { + for (i, (label, balance_str, entry)) in + entries_snapshot.iter().enumerate() + { + let highlighted = + self.autocomplete_highlight == Some(i); + ui.horizontal(|ui| { + let resp = ui + .selectable_label(highlighted, label.as_str()); + ui.with_layout( + egui::Layout::right_to_left( + egui::Align::Center, + ), + |ui| { + ui.label( + egui::RichText::new( + balance_str.as_str(), + ) + .small() + .color(DashColors::GRAY), + ); + }, + ); + if resp.clicked() { + selected_entry = Some(entry.clone()); + } + }); + } + if total_entries > 10 { + let remaining = total_entries - 10; + ui.label( + egui::RichText::new(format!( + "...and {} more", + remaining + )) + .small() + .color(DashColors::GRAY), + ); + } + }); + }); + }); + } else { + self.autocomplete_open = false; + } + } else { + self.autocomplete_open = false; + } + + // Keyboard navigation + if self.autocomplete_open { + let filtered_len = self.filtered_entries().len(); + ui.input(|i| { + if i.key_pressed(egui::Key::ArrowDown) { + self.autocomplete_highlight = Some( + self.autocomplete_highlight + .map(|h| (h + 1).min(filtered_len.saturating_sub(1))) + .unwrap_or(0), + ); + } + if i.key_pressed(egui::Key::ArrowUp) { + self.autocomplete_highlight = self + .autocomplete_highlight + .map(|h| h.saturating_sub(1)) + .or(Some(0)); + } + if i.key_pressed(egui::Key::Escape) { + self.autocomplete_open = false; + self.autocomplete_highlight = None; + } + if i.key_pressed(egui::Key::Enter) + && let Some(idx) = self.autocomplete_highlight + { + let filtered = self.filtered_entries(); + if let Some(entry) = filtered.get(idx) { + selected_entry = Some((*entry).clone()); + } + } + }); + } + + // Handle autocomplete selection + if let Some(entry) = selected_entry { + self.input_text = entry.address_string.clone(); + self.selected_from_autocomplete = true; + self.autocomplete_open = false; + self.autocomplete_highlight = None; + self.has_blurred = true; + } + + // Validation + let (error_message, validated_address) = if self.selected_from_autocomplete { + // Find the matching entry for the selected address + let validated = self + .all_entries + .iter() + .find(|e| e.address_string == self.input_text) + .map(|e| e.validated.clone()); + (None, validated) + } else if self.has_blurred && !self.input_text.trim().is_empty() { + self.validate_input() + } else { + (None, None) + }; + + // Status/error display below input + if self.show_validation_errors { + if let Some(ref error) = error_message { + ui.colored_label(DashColors::VALIDATION_WARNING, error); + } else if self.has_blurred + && validated_address.is_some() + && let Some(kind) = detected.to_address_kind() + { + ui.colored_label(DashColors::SUCCESS, kind.display_name()); + } + } + + // Build response + let changed = text_changed || self.selected_from_autocomplete || self.changed; + if self.changed { + self.changed = false; + } + + AddressInputResponse { + response: text_response, + changed, + error_message, + validated_address, + } + }); + + InnerResponse::new(resp.inner, resp.response) + } +} + +impl Component for AddressInput { + type DomainType = ValidatedAddress; + type Response = AddressInputResponse; + + fn show(&mut self, ui: &mut Ui) -> InnerResponse { + self.show_internal(ui) + } + + fn current_value(&self) -> Option { + if self.selected_from_autocomplete { + return self + .all_entries + .iter() + .find(|e| e.address_string == self.input_text) + .map(|e| e.validated.clone()); + } + if self.has_blurred && !self.input_text.trim().is_empty() { + let (err, val) = self.validate_input(); + if err.is_none() { + return val; + } + } + None + } +} + +// --- Free functions --- + +/// Detect the address type of a raw input string. +/// +/// Priority: Shielded > Platform > Core > Identity (Base58 fallback). +/// Identity detection only runs when `identity_enabled` is true. +fn detect_address_type(input: &str, identity_enabled: bool) -> DetectedType { + let trimmed = input.trim(); + if trimmed.is_empty() { + return DetectedType::Unknown; + } + + // 1. Shielded (dash1z... / tdash1z...) + if is_shielded_address(trimmed) { + return DetectedType::Shielded; + } + + // 2. Platform (Bech32m per DIP-18, but NOT shielded) + if crate::ui::helpers::is_platform_address_string(trimmed) { + return DetectedType::Platform; + } + + // 3. Core (Base58Check) + if trimmed.parse::>().is_ok() { + return DetectedType::Core; + } + + // 4. Identity (Base58 fallback, only when enabled) + if identity_enabled && Identifier::from_string(trimmed, Encoding::Base58).is_ok() { + return DetectedType::Identity; + } + + DetectedType::Unknown +} + +/// Check if a string looks like a shielded Orchard address. +fn is_shielded_address(s: &str) -> bool { + s.starts_with("dash1z") || s.starts_with("tdash1z") +} + +/// Truncate an address string for display, showing prefix and suffix. +fn truncate_address(addr: &str) -> String { + if addr.len() <= 16 { + addr.to_string() + } else { + format!("{}...{}", &addr[..8], &addr[addr.len() - 6..]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dash_sdk::dashcore_rpc::dashcore::secp256k1::{Secp256k1, SecretKey}; + use dash_sdk::dashcore_rpc::dashcore::{PrivateKey, PublicKey}; + + /// Generate a valid testnet P2PKH address for testing. + fn testnet_core_address() -> (String, Address) { + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&[1u8; 32]).unwrap(); + let privkey = PrivateKey::new(sk, Network::Testnet); + let pubkey = PublicKey::from_private_key(&secp, &privkey); + let addr = Address::p2pkh(&pubkey, Network::Testnet); + (addr.to_string(), addr) + } + + /// Generate a valid mainnet P2PKH address for testing. + fn mainnet_core_address() -> (String, Address) { + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&[2u8; 32]).unwrap(); + let privkey = PrivateKey::new(sk, Network::Mainnet); + let pubkey = PublicKey::from_private_key(&secp, &privkey); + let addr = Address::p2pkh(&pubkey, Network::Mainnet); + (addr.to_string(), addr) + } + + // --- detect_address_type tests --- + + #[test] + fn detect_shielded_mainnet() { + let result = detect_address_type("dash1z_some_shielded_addr", true); + assert_eq!(result, DetectedType::Shielded); + } + + #[test] + fn detect_shielded_testnet() { + let result = detect_address_type("tdash1z_some_shielded_addr", true); + assert_eq!(result, DetectedType::Shielded); + } + + #[test] + fn detect_platform_testnet() { + // A plausible platform address prefix + let result = detect_address_type("tdash1qwer1234", false); + assert_eq!(result, DetectedType::Platform); + } + + #[test] + fn detect_platform_mainnet() { + let result = detect_address_type("dash1qwer1234", false); + assert_eq!(result, DetectedType::Platform); + } + + #[test] + fn detect_core_address() { + let (addr_str, _) = testnet_core_address(); + let result = detect_address_type(&addr_str, false); + assert_eq!(result, DetectedType::Core); + } + + #[test] + fn detect_unknown_for_garbage() { + let result = detect_address_type("not-an-address", true); + assert_eq!(result, DetectedType::Unknown); + } + + #[test] + fn detect_empty_is_unknown() { + let result = detect_address_type("", true); + assert_eq!(result, DetectedType::Unknown); + } + + #[test] + fn detect_whitespace_is_unknown() { + let result = detect_address_type(" ", true); + assert_eq!(result, DetectedType::Unknown); + } + + #[test] + fn detect_identity_when_enabled() { + // A 32-byte Base58 identifier that does not parse as a Core address + let id = Identifier::random(); + let id_str = id.to_string(Encoding::Base58); + let result = detect_address_type(&id_str, true); + assert_eq!(result, DetectedType::Identity); + } + + #[test] + fn detect_identity_disabled_falls_through_to_unknown() { + let id = Identifier::random(); + let id_str = id.to_string(Encoding::Base58); + let result = detect_address_type(&id_str, false); + // Should be Unknown since identity detection is disabled + // (unless it happens to parse as a Core address, which is possible for some Base58 values) + assert!(result == DetectedType::Unknown || result == DetectedType::Core); + } + + #[test] + fn shielded_takes_priority_over_platform() { + // dash1z starts with "dash1" which could match platform, but shielded wins + let result = detect_address_type("dash1z_test_addr", false); + assert_eq!(result, DetectedType::Shielded); + } + + // --- Network validation tests --- + + #[test] + fn core_address_wrong_network_rejected() { + let input = AddressInput::new(Network::Testnet); + let (mainnet_str, _) = mainnet_core_address(); + let (err, val) = input.validate_core(&mainnet_str); + assert!(val.is_none()); + assert_eq!( + err.as_deref(), + Some("This address belongs to a different network.") + ); + } + + #[test] + fn core_address_correct_network_accepted() { + let input = AddressInput::new(Network::Testnet); + let (testnet_str, _) = testnet_core_address(); + let (err, val) = input.validate_core(&testnet_str); + assert!(err.is_none()); + assert!(val.is_some()); + } + + #[test] + fn platform_address_wrong_network_rejected() { + let input = AddressInput::new(Network::Mainnet); + // tdash1 prefix on mainnet + let (err, val) = input.validate_platform("tdash1qwer1234"); + assert!(val.is_none()); + assert_eq!( + err.as_deref(), + Some("This address belongs to a different network.") + ); + } + + #[test] + fn shielded_address_wrong_network_rejected() { + let input = AddressInput::new(Network::Mainnet); + let (err, val) = input.validate_shielded("tdash1z_test_addr"); + assert!(val.is_none()); + assert_eq!( + err.as_deref(), + Some("This address belongs to a different network.") + ); + } + + #[test] + fn shielded_address_correct_network_accepted() { + let input = AddressInput::new(Network::Testnet); + let (err, val) = input.validate_shielded("tdash1z_test_addr"); + assert!(err.is_none()); + assert!(val.is_some()); + } + + // --- Enabled type restriction tests --- + + #[test] + fn disabled_type_rejected_with_correct_error() { + let input = AddressInput::new(Network::Testnet).with_address_kinds(&[AddressKind::Core]); + // Simulate validation of a platform address with only Core enabled + let (err, val) = input.validate_input(); + // Input is empty, so no error + assert!(err.is_none()); + assert!(val.is_none()); + } + + // --- BalanceRange tests --- + + #[test] + fn balance_range_inclusive() { + let range = BalanceRange::from_range(&(10..=20)); + assert!(range.contains(10)); + assert!(range.contains(15)); + assert!(range.contains(20)); + assert!(!range.contains(9)); + assert!(!range.contains(21)); + } + + #[test] + fn balance_range_exclusive() { + let range = BalanceRange::from_range(&(10..20)); + assert!(range.contains(10)); + assert!(range.contains(19)); + assert!(!range.contains(20)); + assert!(!range.contains(9)); + } + + #[test] + fn balance_range_unbounded_start() { + let range = BalanceRange::from_range(&(..=100)); + assert!(range.contains(0)); + assert!(range.contains(100)); + assert!(!range.contains(101)); + } + + #[test] + fn balance_range_unbounded_end() { + let range = BalanceRange::from_range(&(50..)); + assert!(!range.contains(49)); + assert!(range.contains(50)); + assert!(range.contains(u64::MAX)); + } + + #[test] + fn balance_range_fully_unbounded() { + let range = BalanceRange::from_range(&(..)); + assert!(range.contains(0)); + assert!(range.contains(u64::MAX)); + } + + #[test] + fn balance_range_zero_only() { + let range = BalanceRange::from_range(&(0..=0)); + assert!(range.contains(0)); + assert!(!range.contains(1)); + } + + // --- AddressKind display name tests --- + + #[test] + fn address_kind_display_names() { + assert_eq!(AddressKind::Core.display_name(), "Wallet address"); + assert_eq!(AddressKind::Platform.display_name(), "Platform address"); + assert_eq!(AddressKind::Shielded.display_name(), "Private address"); + assert_eq!(AddressKind::Identity.display_name(), "Identity"); + } + + // --- truncate_address tests --- + + #[test] + fn truncate_short_address_unchanged() { + assert_eq!(truncate_address("short"), "short"); + } + + #[test] + fn truncate_long_address() { + let (addr_str, _) = testnet_core_address(); + let truncated = truncate_address(&addr_str); + assert!(truncated.contains("...")); + assert!(truncated.len() < addr_str.len()); + } + + // --- ValidatedAddress variant accessor tests --- + + #[test] + fn validated_core_accessors() { + let (_, addr) = testnet_core_address(); + let va = ValidatedAddress::Core(addr.clone()); + assert_eq!(va.kind(), AddressKind::Core); + assert_eq!(va.as_core(), Some(&addr)); + assert!(va.as_platform().is_none()); + assert!(va.as_identity_id().is_none()); + assert!(va.dpns_name().is_none()); + } + + #[test] + fn validated_identity_accessors() { + let id = Identifier::random(); + let va = ValidatedAddress::Identity { + id, + dpns_name: Some("alice.dash".to_string()), + }; + assert_eq!(va.kind(), AddressKind::Identity); + assert_eq!(va.as_identity_id(), Some(&id)); + assert_eq!(va.dpns_name(), Some("alice.dash")); + assert!(va.as_core().is_none()); + } + + #[test] + fn validated_shielded_accessors() { + let va = ValidatedAddress::Shielded("dash1z_test".to_string()); + assert_eq!(va.kind(), AddressKind::Shielded); + assert_eq!(va.to_address_string(), "dash1z_test"); + } +} diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index 5f123ba1c..20e7d880a 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -1,3 +1,4 @@ +pub mod address_input; pub mod amount_input; pub mod component_trait; pub mod confirmation_dialog; From 6bf45a9bb86f6af403be42a5756a80aa7cfb157f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:46:22 +0100 Subject: [PATCH 02/13] feat(ui): integrate AddressInput component into send and unshield screens Replace inline address parsing and validation with the unified AddressInput component across 3 proof-of-concept sites: - UnshieldCreditsScreen: full migration, removes local Destination enum and parse_destination(), gains autocomplete and type-restricted input - WalletSendScreen simple mode: full migration, removes AddressType enum, replaces detect_address_type/is_shielded_address with AddressKind-based detection, eliminates double-parsing in send handlers - WalletSendScreen advanced mode: minimal migration, updates type detection to use AddressKind, keeps CoreAddressInput/PlatformAddressInput structs unchanged Net reduction of ~47 lines per screen. All send flows preserved. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/send_screen.rs | 349 ++++++++++------------ src/ui/wallets/unshield_credits_screen.rs | 94 ++---- 2 files changed, 198 insertions(+), 245 deletions(-) diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index ff46ad922..58610d031 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -3,9 +3,11 @@ use crate::backend_task::BackendTask; use crate::backend_task::core::{CoreTask, PaymentRecipient, WalletPaymentRequest}; use crate::backend_task::wallet::WalletTask; use crate::context::AppContext; +use crate::model::address::{AddressKind, ValidatedAddress}; use crate::model::amount::{Amount, DASH_DECIMAL_PLACES}; use crate::model::fee_estimation::format_credits_as_dash; use crate::model::wallet::{Wallet, WalletSeedHash}; +use crate::ui::components::address_input::AddressInput; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::component_trait::{Component, ComponentResponse}; use crate::ui::components::left_panel::add_left_panel; @@ -262,15 +264,6 @@ fn allocate_platform_addresses( }) } -/// Detected address type -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AddressType { - Core, - Platform, - Shielded, - Unknown, -} - /// Source selection for sending #[derive(Debug, Clone, PartialEq)] pub enum SourceSelection { @@ -373,7 +366,8 @@ pub struct WalletSendScreen { // Unified send fields (simple mode) selected_source: Option, - destination_address: String, + address_input: Option, + validated_destination: Option, amount: Option, amount_input: Option, @@ -407,7 +401,8 @@ impl WalletSendScreen { selected_wallet: Some(wallet), selected_wallet_seed_hash: seed_hash, selected_source: Some(SourceSelection::CoreWallet), - destination_address: String::new(), + address_input: None, + validated_destination: None, amount: None, amount_input: None, show_advanced_options: false, @@ -445,14 +440,12 @@ impl WalletSendScreen { return estimate_platform_fee(fee_estimator, 1); } - let dest_type = Self::detect_address_type(&self.destination_address); - if dest_type == AddressType::Core { + let dest_kind = self.validated_destination.as_ref().map(|v| v.kind()); + if dest_kind == Some(AddressKind::Core) { let output_script = self - .destination_address - .trim() - .parse::>() - .ok() - .and_then(|addr| addr.require_network(self.app_context.network).ok()) + .validated_destination + .as_ref() + .and_then(|v| v.as_core()) .map(|addr| CoreScript::new(addr.script_pubkey())); if let Some(output_script) = output_script { let max_fee_inputs: BTreeMap = sorted_addresses @@ -472,7 +465,8 @@ impl WalletSendScreen { } fn reset_form(&mut self) { - self.destination_address.clear(); + self.address_input = None; + self.validated_destination = None; self.amount = None; self.amount_input = None; self.selected_source = Some(SourceSelection::CoreWallet); @@ -511,39 +505,37 @@ impl WalletSendScreen { Ok(duffs as Credits * 1000) } - /// Detect address type from the address string - fn detect_address_type(address: &str) -> AddressType { + /// Detect address kind from the address string. + /// + /// Returns `None` for empty or unrecognized input. + fn detect_address_kind(address: &str) -> Option { let trimmed = address.trim(); if trimmed.is_empty() { - return AddressType::Unknown; + return None; } // Check for shielded address (dash1z... or tdash1z...) - if Self::is_shielded_address(trimmed) { - return AddressType::Shielded; + if trimmed.starts_with("dash1z") || trimmed.starts_with("tdash1z") { + return Some(AddressKind::Shielded); } // Check for Platform address (Bech32m format per DIP-18) if crate::ui::helpers::is_platform_address_string(trimmed) { - return AddressType::Platform; + return Some(AddressKind::Platform); } // Try to parse as Core address if trimmed.parse::>().is_ok() { - return AddressType::Core; + return Some(AddressKind::Core); } - AddressType::Unknown - } - - fn is_shielded_address(s: &str) -> bool { - s.starts_with("dash1z") || s.starts_with("tdash1z") + None } fn min_output_amount( &self, - input_type: AddressType, - output_type: AddressType, + input_type: Option, + output_type: Option, ) -> Option { let core_min = 5460_u64 * CREDITS_PER_DUFF; let platform_min = self @@ -554,20 +546,22 @@ impl WalletSendScreen { .address_funds .min_output_amount; + use AddressKind::*; match (input_type, output_type) { - (AddressType::Unknown, AddressType::Unknown) => None, - (AddressType::Core, AddressType::Core) => Some(core_min), - (AddressType::Platform, AddressType::Platform) => Some(platform_min), - (AddressType::Core, AddressType::Platform) => Some(56000000), // needed for asset locks - (AddressType::Platform, AddressType::Core) => Some(core_min.max(platform_min)), - (AddressType::Unknown, AddressType::Core) => Some(core_min), - (AddressType::Unknown, AddressType::Platform) => Some(platform_min), - (AddressType::Core, AddressType::Unknown) => Some(core_min), - (AddressType::Platform, AddressType::Unknown) => Some(platform_min), - (AddressType::Shielded, AddressType::Shielded) => Some(platform_min), - (AddressType::Shielded, AddressType::Platform) => Some(platform_min), - (AddressType::Shielded, _) => Some(platform_min), - (_, AddressType::Shielded) => Some(platform_min), + (None, None) => None, + (Some(Core), Some(Core)) => Some(core_min), + (Some(Platform), Some(Platform)) => Some(platform_min), + (Some(Core), Some(Platform)) => Some(56000000), // needed for asset locks + (Some(Platform), Some(Core)) => Some(core_min.max(platform_min)), + (None, Some(Core)) => Some(core_min), + (None, Some(Platform)) => Some(platform_min), + (Some(Core), None) => Some(core_min), + (Some(Platform), None) => Some(platform_min), + (Some(Shielded), Some(Shielded)) => Some(platform_min), + (Some(Shielded), Some(Platform)) => Some(platform_min), + (Some(Shielded), _) => Some(platform_min), + (_, Some(Shielded)) => Some(platform_min), + (Some(Identity), _) | (_, Some(Identity)) => Some(platform_min), } } @@ -685,22 +679,45 @@ impl WalletSendScreen { /// Get description of transaction type based on source and destination fn get_transaction_type_description(&self) -> &'static str { - let dest_type = Self::detect_address_type(&self.destination_address); - match (&self.selected_source, dest_type) { - (Some(SourceSelection::CoreWallet), AddressType::Core) => "Core Transaction", - (Some(SourceSelection::CoreWallet), AddressType::Platform) => "Fund Platform Address", - (Some(SourceSelection::PlatformAddresses(_)), AddressType::Platform) => { + let dest_kind = self.destination_kind(); + match (&self.selected_source, dest_kind) { + (Some(SourceSelection::CoreWallet), Some(AddressKind::Core)) => "Core Transaction", + (Some(SourceSelection::CoreWallet), Some(AddressKind::Platform)) => { + "Fund Platform Address" + } + (Some(SourceSelection::PlatformAddresses(_)), Some(AddressKind::Platform)) => { "Platform Transfer" } - (Some(SourceSelection::PlatformAddresses(_)), AddressType::Core) => "Withdraw to Core", - (Some(SourceSelection::Shielded(..)), AddressType::Shielded) => { + (Some(SourceSelection::PlatformAddresses(_)), Some(AddressKind::Core)) => { + "Withdraw to Core" + } + (Some(SourceSelection::Shielded(..)), Some(AddressKind::Shielded)) => { "Private Transfer (Shielded)" } - (Some(SourceSelection::Shielded(..)), AddressType::Platform) => "Unshield to Platform", + (Some(SourceSelection::Shielded(..)), Some(AddressKind::Platform)) => { + "Unshield to Platform" + } _ => "Send", } } + /// Returns the detected address kind for the current destination. + /// + /// Uses the validated address if available (simple mode with AddressInput), + /// otherwise falls back to raw string detection (advanced mode outputs). + fn destination_kind(&self) -> Option { + self.validated_destination.as_ref().map(|v| v.kind()) + } + + /// Returns the destination address string, from the validated address if + /// available or an empty string otherwise. + fn destination_address_string(&self) -> String { + self.validated_destination + .as_ref() + .map(|v| v.to_address_string()) + .unwrap_or_default() + } + /// Clear the current send banner and show a new "Sending transaction..." progress banner. /// /// Called before dispatching any send backend task so the elapsed counter always starts fresh. @@ -722,7 +739,6 @@ impl WalletSendScreen { } let seed_hash = wallet_guard.seed_hash(); - let network = self.app_context.network; // Validate source let source = self @@ -731,8 +747,8 @@ impl WalletSendScreen { .ok_or("Please select a source")?; // Validate destination - let dest_type = Self::detect_address_type(&self.destination_address); - if dest_type == AddressType::Unknown { + let dest_kind = self.destination_kind(); + if dest_kind.is_none() { return Err( "Invalid destination address. Use a Dash address (X.../y...) or Platform address (dash1.../tdash1...)" .to_string(), @@ -751,21 +767,21 @@ impl WalletSendScreen { drop(wallet_guard); // Route to appropriate handler based on source and destination types - match (source.clone(), dest_type) { - (SourceSelection::CoreWallet, AddressType::Core) => self.send_core_to_core(), - (SourceSelection::CoreWallet, AddressType::Platform) => { + match (source.clone(), dest_kind) { + (SourceSelection::CoreWallet, Some(AddressKind::Core)) => self.send_core_to_core(), + (SourceSelection::CoreWallet, Some(AddressKind::Platform)) => { self.send_core_to_platform(seed_hash) } - (SourceSelection::PlatformAddresses(addresses), AddressType::Platform) => { + (SourceSelection::PlatformAddresses(addresses), Some(AddressKind::Platform)) => { self.send_platform_to_platform(seed_hash, addresses) } - (SourceSelection::PlatformAddresses(addresses), AddressType::Core) => { - self.send_platform_to_core(seed_hash, addresses, network) + (SourceSelection::PlatformAddresses(addresses), Some(AddressKind::Core)) => { + self.send_platform_to_core(seed_hash, addresses) } - (SourceSelection::Shielded(sh, _), AddressType::Shielded) => { + (SourceSelection::Shielded(sh, _), Some(AddressKind::Shielded)) => { self.send_shielded_to_shielded(sh) } - (SourceSelection::Shielded(sh, _), AddressType::Platform) => { + (SourceSelection::Shielded(sh, _), Some(AddressKind::Platform)) => { self.send_shielded_to_platform(sh) } _ => Err("Invalid source/destination combination".to_string()), @@ -799,7 +815,7 @@ impl WalletSendScreen { .clone(); let recipient = PaymentRecipient { - address: self.destination_address.trim().to_string(), + address: self.destination_address_string(), amount_duffs, }; @@ -828,11 +844,12 @@ impl WalletSendScreen { return Err("Amount must be greater than 0".to_string()); } - // Parse platform address - let address_str = self.destination_address.trim(); - let destination = PlatformAddress::from_bech32m_string(address_str) - .map(|(addr, _)| addr) - .map_err(|e| format!("Invalid platform address: {}", e))?; + // Extract validated platform address + let destination = self + .validated_destination + .as_ref() + .and_then(|v| v.as_platform().copied()) + .ok_or_else(|| "Invalid platform address".to_string())?; // Check balance; fees will be subtracted from amount let required = amount_duffs; @@ -894,11 +911,12 @@ impl WalletSendScreen { )); } - // Parse destination platform address - let address_str = self.destination_address.trim(); - let destination = PlatformAddress::from_bech32m_string(address_str) - .map(|(addr, _)| addr) - .map_err(|e| format!("Invalid platform address: {}", e))?; + // Extract validated platform address + let destination = self + .validated_destination + .as_ref() + .and_then(|v| v.as_platform().copied()) + .ok_or_else(|| "Invalid platform address".to_string())?; // Allocate addresses using the helper function let allocation = allocate_platform_addresses( @@ -990,7 +1008,6 @@ impl WalletSendScreen { &mut self, seed_hash: WalletSeedHash, addresses: Vec<(PlatformAddress, Address, u64)>, - network: dash_sdk::dpp::dashcore::Network, ) -> Result { // Amount in credits let amount_credits = self @@ -1020,14 +1037,12 @@ impl WalletSendScreen { )); } - // Parse destination Core address - let address_str = self.destination_address.trim(); - let dest_address: Address = address_str - .parse() - .map_err(|e| format!("Invalid Core address: {}", e))?; - let dest_address = dest_address - .require_network(network) - .map_err(|e| format!("Address network mismatch: {}", e))?; + // Extract validated Core address + let dest_address = self + .validated_destination + .as_ref() + .and_then(|v| v.as_core()) + .ok_or_else(|| "Invalid Core address".to_string())?; let output_script = CoreScript::new(dest_address.script_pubkey()); @@ -1277,7 +1292,7 @@ impl WalletSendScreen { .ok_or_else(|| "Amount is required".to_string())? .value(); - let recipient = self.destination_address.trim().to_string(); + let recipient = self.destination_address_string(); let recipient_bytes = if let Ok((addr, _)) = dash_sdk::dpp::address_funds::OrchardAddress::from_bech32m_string(&recipient) { @@ -1309,9 +1324,11 @@ impl WalletSendScreen { .ok_or_else(|| "Amount is required".to_string())? .value(); - let address_str = self.destination_address.trim(); - let (platform_addr, _) = PlatformAddress::from_bech32m_string(address_str) - .map_err(|e| format!("Invalid platform address: {e}"))?; + let platform_addr = self + .validated_destination + .as_ref() + .and_then(|v| v.as_platform().copied()) + .ok_or_else(|| "Invalid platform address".to_string())?; self.send_status = SendStatus::WaitingForResult; Ok(AppAction::BackendTask( @@ -1480,57 +1497,21 @@ impl WalletSendScreen { } fn render_destination_input(&mut self, ui: &mut Ui) { - let dark_mode = ui.ctx().style().visuals.dark_mode; - let dest_type = Self::detect_address_type(&self.destination_address); - - ui.horizontal(|ui| { - ui.label( - RichText::new("Send to") - .color(DashColors::text_primary(dark_mode)) - .strong() - .size(14.0), - ); - - // Show detected type - if dest_type != AddressType::Unknown { - ui.add_space(10.0); - let (type_text, type_color) = match dest_type { - AddressType::Core => ("Core Address", DashColors::DASH_BLUE), - AddressType::Platform => ("Platform Address", DashColors::PLATFORM_PURPLE), - AddressType::Shielded => ("Shielded Address", Color32::from_rgb(0, 180, 120)), - AddressType::Unknown => ("", Color32::GRAY), - }; - ui.label( - RichText::new(format!("({})", type_text)) - .color(type_color) - .size(12.0), - ); + let addr_input = self.address_input.get_or_insert_with(|| { + let mut builder = AddressInput::new(self.app_context.network) + .with_label("Send to") + .with_hint_text("Enter address (X.../y.../dash1.../tdash1...)"); + + // Provide wallet data for autocomplete if available + if let Some(wallet) = &self.selected_wallet { + builder = builder.with_wallet(wallet.clone()); } - }); - - ui.add_space(8.0); - Frame::group(ui.style()) - .fill(DashColors::surface(dark_mode)) - .inner_margin(Margin::symmetric(12, 10)) - .corner_radius(5.0) - .show(ui, |ui| { - ui.add( - egui::TextEdit::singleline(&mut self.destination_address) - .hint_text("Enter address (X.../y.../dash1.../tdash1...)") - .desired_width(f32::INFINITY), - ); - }); + builder + }); - // Show error for invalid address - if !self.destination_address.trim().is_empty() && dest_type == AddressType::Unknown { - ui.add_space(5.0); - ui.label( - RichText::new("Invalid address format") - .color(DashColors::ERROR) - .size(12.0), - ); - } + let resp = addr_input.show(ui); + resp.inner.update(&mut self.validated_destination); } fn render_amount_input(&mut self, ui: &mut Ui) { @@ -1554,12 +1535,12 @@ impl WalletSendScreen { .ok() .map(|wallet| wallet.total_balance_duffs() * CREDITS_PER_DUFF) // duffs to credits }); - let dest_type = Self::detect_address_type(&self.destination_address); - let hint = if dest_type == AddressType::Platform { - let destination = - PlatformAddress::from_bech32m_string(self.destination_address.trim()) - .map(|(addr, _)| addr) - .ok(); + let dest_kind = self.destination_kind(); + let hint = if dest_kind == Some(AddressKind::Platform) { + let destination = self + .validated_destination + .as_ref() + .and_then(|v| v.as_platform().copied()); if let Some(destination) = destination { let estimated_fee = estimate_address_funding_fee_from_transition( self.app_context.platform_version(), @@ -1579,11 +1560,11 @@ impl WalletSendScreen { (max, hint) } Some(SourceSelection::PlatformAddresses(addresses)) => { - // Parse destination to exclude it from max calculation (can't send to yourself) - let destination = - PlatformAddress::from_bech32m_string(self.destination_address.trim()) - .map(|(addr, _)| addr) - .ok(); + // Extract destination to exclude it from max calculation (can't send to yourself) + let destination = self + .validated_destination + .as_ref() + .and_then(|v| v.as_platform().copied()); // Filter out destination and sort by balance descending let mut sorted_addresses: Vec<_> = addresses @@ -1623,14 +1604,14 @@ impl WalletSendScreen { None => (None, None), }; - let input_type = match self.selected_source { - Some(SourceSelection::CoreWallet) => AddressType::Core, - Some(SourceSelection::PlatformAddresses(_)) => AddressType::Platform, - Some(SourceSelection::Shielded(_, _)) => AddressType::Shielded, - None => AddressType::Unknown, + let input_kind = match self.selected_source { + Some(SourceSelection::CoreWallet) => Some(AddressKind::Core), + Some(SourceSelection::PlatformAddresses(_)) => Some(AddressKind::Platform), + Some(SourceSelection::Shielded(_, _)) => Some(AddressKind::Shielded), + None => None, }; - let output_type = Self::detect_address_type(&self.destination_address); - let min_amount = self.min_output_amount(input_type, output_type); + let output_kind = self.destination_kind(); + let min_amount = self.min_output_amount(input_kind, output_kind); Frame::group(ui.style()) .fill(DashColors::surface(dark_mode)) @@ -1663,7 +1644,7 @@ impl WalletSendScreen { // Show transaction type hint let tx_type = self.get_transaction_type_description(); - if tx_type != "Send" && !self.destination_address.trim().is_empty() { + if tx_type != "Send" && self.validated_destination.is_some() { ui.add_space(5.0); ui.label( RichText::new(format!("Transaction type: {}", tx_type)) @@ -1674,9 +1655,9 @@ impl WalletSendScreen { } // Show subtract fee checkbox for Core wallet to Core address transactions - let dest_type = Self::detect_address_type(&self.destination_address); + let dest_kind = self.destination_kind(); if matches!(self.selected_source, Some(SourceSelection::CoreWallet)) - && dest_type == AddressType::Core + && dest_kind == Some(AddressKind::Core) { ui.add_space(8.0); ui.horizontal(|ui| { @@ -1711,10 +1692,11 @@ impl WalletSendScreen { _ => return, }; - // Parse destination platform address (if valid) to exclude it from inputs - let destination = PlatformAddress::from_bech32m_string(self.destination_address.trim()) - .map(|(addr, _)| addr) - .ok(); + // Extract destination platform address (if valid) to exclude it from inputs + let destination = self + .validated_destination + .as_ref() + .and_then(|v| v.as_platform().copied()); // Use the same allocation algorithm as the send logic, filtering out the destination let allocation = allocate_platform_addresses( @@ -1808,8 +1790,7 @@ impl WalletSendScreen { .as_ref() .is_some_and(|w| w.read().map(|g| g.is_open()).unwrap_or(false)); - let dest_type = Self::detect_address_type(&self.destination_address); - let has_destination = dest_type != AddressType::Unknown; + let has_destination = self.validated_destination.is_some(); let has_amount = self.amount.as_ref().map(|a| a.value() > 0).unwrap_or(false); let has_source = self.selected_source.is_some(); @@ -1975,10 +1956,10 @@ impl WalletSendScreen { // ========== FEE STRATEGY SECTION ========== // Only show for platform source or platform outputs - let has_platform_output = self.advanced_outputs.iter().any(|o| { - let addr_type = Self::detect_address_type(&o.address); - addr_type == AddressType::Platform - }); + let has_platform_output = self + .advanced_outputs + .iter() + .any(|o| Self::detect_address_kind(&o.address) == Some(AddressKind::Platform)); if self.advanced_source_type == AdvancedSourceType::Platform || has_platform_output { ui.label( @@ -2284,14 +2265,14 @@ impl WalletSendScreen { let mut outputs_to_remove = Vec::new(); let num_outputs = self.advanced_outputs.len(); - // Pre-compute address types to avoid borrow issues - let addr_types: Vec = self + // Pre-compute address kinds to avoid borrow issues + let addr_kinds: Vec> = self .advanced_outputs .iter() - .map(|o| Self::detect_address_type(&o.address)) + .map(|o| Self::detect_address_kind(&o.address)) .collect(); - for (idx, &addr_type) in addr_types.iter().enumerate() { + for (idx, &addr_kind) in addr_kinds.iter().enumerate() { Frame::group(ui.style()) .fill(DashColors::surface(dark_mode)) .inner_margin(Margin::symmetric(12, 10)) @@ -2307,16 +2288,18 @@ impl WalletSendScreen { ); // Show detected type - if addr_type != AddressType::Unknown { - let (type_text, type_color) = match addr_type { - AddressType::Core => ("Core", DashColors::DASH_BLUE), - AddressType::Platform => { + if let Some(kind) = addr_kind { + let (type_text, type_color) = match kind { + AddressKind::Core => ("Core", DashColors::DASH_BLUE), + AddressKind::Platform => { ("Platform", DashColors::PLATFORM_PURPLE) } - AddressType::Shielded => { + AddressKind::Shielded => { ("Shielded", Color32::from_rgb(0, 180, 120)) } - AddressType::Unknown => ("", Color32::GRAY), + AddressKind::Identity => { + ("Identity", DashColors::PLATFORM_PURPLE) + } }; ui.label( RichText::new(format!("({})", type_text)) @@ -2453,15 +2436,15 @@ impl WalletSendScreen { return Err("Please add at least one output".to_string()); } - // Determine output types - let output_types: Vec = self + // Determine output kinds + let output_kinds: Vec> = self .advanced_outputs .iter() - .map(|o| Self::detect_address_type(&o.address)) + .map(|o| Self::detect_address_kind(&o.address)) .collect(); - let has_core_output = output_types.contains(&AddressType::Core); - let has_platform_output = output_types.contains(&AddressType::Platform); + let has_core_output = output_kinds.contains(&Some(AddressKind::Core)); + let has_platform_output = output_kinds.contains(&Some(AddressKind::Platform)); // Validate that we don't mix output types if has_core_output && has_platform_output { diff --git a/src/ui/wallets/unshield_credits_screen.rs b/src/ui/wallets/unshield_credits_screen.rs index 749b320ad..855215fcd 100644 --- a/src/ui/wallets/unshield_credits_screen.rs +++ b/src/ui/wallets/unshield_credits_screen.rs @@ -2,17 +2,18 @@ use crate::app::AppAction; use crate::backend_task::shielded::ShieldedTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; +use crate::model::address::{AddressKind, ValidatedAddress}; use crate::model::wallet::WalletSeedHash; +use crate::ui::components::ComponentResponse; +use crate::ui::components::address_input::AddressInput; +use crate::ui::components::component_trait::Component; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::{MessageType, RootScreenType, ScreenLike}; -use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; -use dash_sdk::dpp::dashcore::Address; use eframe::egui::{self, Context}; use egui::{Color32, RichText}; -use std::str::FromStr; use std::sync::Arc; #[derive(PartialEq)] @@ -22,19 +23,12 @@ enum Status { Complete, } -/// Which kind of destination was parsed from the address input. -enum Destination { - /// Shielded pool → platform address (Type 17 Unshield) - Platform(PlatformAddress), - /// Shielded pool → core L1 address (Type 19 ShieldedWithdrawal) - Core(Address), -} - pub struct UnshieldCreditsScreen { pub app_context: Arc, pub seed_hash: WalletSeedHash, amount_str: String, - address_str: String, + address_input: Option, + validated_destination: Option, max_balance: u64, status: Status, error_message: Option, @@ -55,7 +49,8 @@ impl UnshieldCreditsScreen { app_context: app_context.clone(), seed_hash, amount_str: String::new(), - address_str: String::new(), + address_input: None, + validated_destination: None, max_balance, status: Status::NotStarted, error_message: None, @@ -82,30 +77,6 @@ impl UnshieldCreditsScreen { Some(credits) } } - - /// Parse the address field into a Destination. - /// - /// Tries platform address (Bech32m tdash1.../dash1...) first, then falls - /// back to a core address (Base58 P2PKH/P2SH). - fn parse_destination(&self) -> Option { - let s = self.address_str.trim(); - if s.is_empty() { - return None; - } - - // Try platform address first - if let Ok((pa, _network)) = PlatformAddress::from_bech32m_string(s) { - return Some(Destination::Platform(pa)); - } - - // Try core address - if let Ok(addr) = Address::from_str(s) { - let addr = addr.require_network(self.app_context.network).ok()?; - return Some(Destination::Core(addr)); - } - - None - } } impl ScreenLike for UnshieldCreditsScreen { @@ -155,33 +126,33 @@ impl ScreenLike for UnshieldCreditsScreen { return; } - // Destination address input - ui.horizontal(|ui| { - ui.label("To address:"); - ui.text_edit_singleline(&mut self.address_str); + // Destination address input via AddressInput component + let addr_input = self.address_input.get_or_insert_with(|| { + AddressInput::new(self.app_context.network) + .with_address_kinds(&[AddressKind::Core, AddressKind::Platform]) + .with_label("To address") + .with_hint_text( + "Enter a platform address (tdash1.../dash1...) or core DASH address", + ) }); + let resp = addr_input.show(ui); + resp.inner.update(&mut self.validated_destination); // Show what was parsed - match self.parse_destination() { - Some(Destination::Platform(_)) => { + match self.validated_destination.as_ref().map(|v| v.kind()) { + Some(AddressKind::Platform) => { ui.colored_label( Color32::DARK_GREEN, "Platform address — will unshield to platform (Type 17)", ); } - Some(Destination::Core(_)) => { + Some(AddressKind::Core) => { ui.colored_label( Color32::DARK_GREEN, "Core address — will withdraw to core DASH (Type 19)", ); } - None if !self.address_str.trim().is_empty() => { - ui.colored_label( - Color32::from_rgb(255, 100, 100), - "Unrecognised address — enter a platform address (tdash1…/dash1…) or a core DASH address", - ); - } - None => {} + _ => {} } ui.add_space(10.0); @@ -202,9 +173,8 @@ impl ScreenLike for UnshieldCreditsScreen { let amount_ok = self .parse_amount_credits() .is_some_and(|a| a <= self.max_balance); - let destination = self.parse_destination(); - let can_confirm = - self.status == Status::NotStarted && amount_ok && destination.is_some(); + let has_destination = self.validated_destination.is_some(); + let can_confirm = self.status == Status::NotStarted && amount_ok && has_destination; if self.status == Status::WaitingForResult { ui.horizontal(|ui| { @@ -213,8 +183,8 @@ impl ScreenLike for UnshieldCreditsScreen { }); } else { ui.horizontal(|ui| { - let btn_label = match &destination { - Some(Destination::Core(_)) => "Withdraw to Core", + let btn_label = match self.validated_destination.as_ref().map(|v| v.kind()) { + Some(AddressKind::Core) => "Withdraw to Core", _ => "Unshield", }; @@ -229,30 +199,30 @@ impl ScreenLike for UnshieldCreditsScreen { .clicked() && let Some(amount) = self.parse_amount_credits() { - match self.parse_destination() { - Some(Destination::Platform(addr)) => { + match &self.validated_destination { + Some(ValidatedAddress::Platform(addr)) => { self.status = Status::WaitingForResult; self.error_message = None; action = AppAction::BackendTask(BackendTask::ShieldedTask( ShieldedTask::UnshieldCredits { seed_hash: self.seed_hash, amount, - to_platform_address: addr, + to_platform_address: *addr, }, )); } - Some(Destination::Core(addr)) => { + Some(ValidatedAddress::Core(addr)) => { self.status = Status::WaitingForResult; self.error_message = None; action = AppAction::BackendTask(BackendTask::ShieldedTask( ShieldedTask::ShieldedWithdrawal { seed_hash: self.seed_hash, amount, - to_core_address: addr, + to_core_address: addr.clone(), }, )); } - None => {} + _ => {} } } From a1010db1551ad456ff9978d52247bb9e7b2b1b0c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:58:49 +0100 Subject: [PATCH 03/13] test(address-input): fix misleading test and add missing coverage The existing `disabled_type_rejected_with_correct_error` test was testing empty input (which returns no error) rather than actually verifying type restriction. Fixed the test and added coverage for: - Selection-only mode rejection of manual input - Identity validation with valid/invalid identifiers - Truncate boundary at exactly 16/17 characters - Empty input in selection-only and restricted-type modes Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/components/address_input.rs | 89 +++++++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 2 deletions(-) diff --git a/src/ui/components/address_input.rs b/src/ui/components/address_input.rs index ba73e9e95..6dd471b68 100644 --- a/src/ui/components/address_input.rs +++ b/src/ui/components/address_input.rs @@ -1105,12 +1105,97 @@ mod tests { #[test] fn disabled_type_rejected_with_correct_error() { + let mut input = + AddressInput::new(Network::Testnet).with_address_kinds(&[AddressKind::Core]); + // Set a platform-looking address with only Core enabled + input.input_text = "tdash1qwer1234".to_string(); + input.has_blurred = true; + let (err, val) = input.validate_input(); + assert!( + val.is_none(), + "should reject platform address when only Core is enabled" + ); + assert_eq!( + err.as_deref(), + Some("Only wallet addresses are accepted here.") + ); + } + + #[test] + fn disabled_type_empty_input_no_error() { let input = AddressInput::new(Network::Testnet).with_address_kinds(&[AddressKind::Core]); - // Simulate validation of a platform address with only Core enabled let (err, val) = input.validate_input(); - // Input is empty, so no error + assert!(err.is_none(), "empty input should not produce an error"); + assert!(val.is_none()); + } + + // --- Selection-only mode tests --- + + #[test] + fn selection_only_rejects_manual_input() { + let mut input = AddressInput::new(Network::Testnet).with_selection_only(true); + let (addr_str, _) = testnet_core_address(); + input.input_text = addr_str; + input.has_blurred = true; + let (err, val) = input.validate_input(); + assert!( + val.is_none(), + "selection-only mode should reject manual input" + ); + assert_eq!( + err.as_deref(), + Some("Please select an address from the list.") + ); + } + + #[test] + fn selection_only_empty_input_no_error() { + let input = AddressInput::new(Network::Testnet).with_selection_only(true); + let (err, val) = input.validate_input(); + assert!( + err.is_none(), + "empty input in selection-only mode should not error" + ); + assert!(val.is_none()); + } + + // --- Identity validation tests --- + + #[test] + fn validate_identity_valid_identifier() { + let input = + AddressInput::new(Network::Testnet).with_address_kinds(&[AddressKind::Identity]); + let id = Identifier::random(); + let id_str = id.to_string(Encoding::Base58); + let (err, val) = input.validate_identity(&id_str); assert!(err.is_none()); + let val = val.expect("valid identifier should produce ValidatedAddress"); + assert_eq!(val.kind(), AddressKind::Identity); + assert_eq!(val.as_identity_id(), Some(&id)); + } + + #[test] + fn validate_identity_invalid_string() { + let input = AddressInput::new(Network::Testnet); + let (err, val) = input.validate_identity("not-a-valid-identifier"); assert!(val.is_none()); + assert_eq!( + err.as_deref(), + Some("This does not look like a valid address.") + ); + } + + // --- Truncate boundary tests --- + + #[test] + fn truncate_address_boundary_16_unchanged() { + assert_eq!(truncate_address("1234567890123456"), "1234567890123456"); + } + + #[test] + fn truncate_address_boundary_17_truncated() { + let result = truncate_address("12345678901234567"); + assert_eq!(result, "12345678...234567"); } // --- BalanceRange tests --- From cf73b1ed5e8f8b0e138eac0ec804e0434f495fda Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:13:24 +0100 Subject: [PATCH 04/13] fix(ui): address QA findings for AddressInput component QA-001: Extract duplicated address detection logic from send_screen.rs into AddressKind::detect() on the model type. Both send_screen and address_input now delegate to the single canonical implementation. QA-002: Fix autocomplete "...and N more" count using unfiltered total. filtered_entries() now returns the pre-truncation match count so the overflow label shows the correct number of remaining matches. QA-003: Add minimum length check (>= 60 chars) to shielded address validation. Previously any string with the correct prefix was accepted. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/model/address.rs | 125 ++++++++++++++++++++++++++- src/ui/components/address_input.rs | 134 ++++++++++++++++------------- src/ui/wallets/send_screen.rs | 30 ++----- 3 files changed, 202 insertions(+), 87 deletions(-) diff --git a/src/model/address.rs b/src/model/address.rs index 7454d7cc8..a62f6ec09 100644 --- a/src/model/address.rs +++ b/src/model/address.rs @@ -1,5 +1,7 @@ -use dash_sdk::dashcore_rpc::dashcore::Address; +use dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked; +use dash_sdk::dashcore_rpc::dashcore::{Address, Network}; use dash_sdk::dpp::address_funds::PlatformAddress; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::Identifier; /// Classification of a Dash address for filtering and display purposes. @@ -40,6 +42,39 @@ impl AddressKind { AddressKind::Shielded, AddressKind::Identity, ]; + + /// Detect the address kind from a raw input string. + /// + /// Priority: Shielded > Platform > Core > Identity (Base58 fallback). + /// Returns `None` for empty or unrecognized input. + pub fn detect(input: &str, _network: Network) -> Option { + let trimmed = input.trim(); + if trimmed.is_empty() { + return None; + } + + // 1. Shielded (dash1z... / tdash1z...) + if trimmed.starts_with("dash1z") || trimmed.starts_with("tdash1z") { + return Some(AddressKind::Shielded); + } + + // 2. Platform (Bech32m per DIP-18, but NOT shielded — already excluded above) + if crate::ui::helpers::is_platform_address_string(trimmed) { + return Some(AddressKind::Platform); + } + + // 3. Core (Base58Check) + if trimmed.parse::>().is_ok() { + return Some(AddressKind::Core); + } + + // 4. Identity (Base58 fallback) + if Identifier::from_string(trimmed, Encoding::Base58).is_ok() { + return Some(AddressKind::Identity); + } + + None + } } impl std::fmt::Display for AddressKind { @@ -179,4 +214,92 @@ mod tests { assert!(shielded.as_identity_id().is_none()); assert!(shielded.dpns_name().is_none()); } + + // --- AddressKind::detect tests --- + + #[test] + fn detect_empty_returns_none() { + assert_eq!(AddressKind::detect("", Network::Testnet), None); + assert_eq!(AddressKind::detect(" ", Network::Testnet), None); + } + + #[test] + fn detect_shielded_mainnet() { + assert_eq!( + AddressKind::detect("dash1z_some_shielded_addr", Network::Mainnet), + Some(AddressKind::Shielded) + ); + } + + #[test] + fn detect_shielded_testnet() { + assert_eq!( + AddressKind::detect("tdash1z_some_shielded_addr", Network::Testnet), + Some(AddressKind::Shielded) + ); + } + + #[test] + fn detect_shielded_priority_over_platform() { + // dash1z starts with "dash1" which could match platform, but shielded wins + assert_eq!( + AddressKind::detect("dash1z_test", Network::Mainnet), + Some(AddressKind::Shielded) + ); + } + + #[test] + fn detect_platform_testnet() { + assert_eq!( + AddressKind::detect("tdash1qwer1234", Network::Testnet), + Some(AddressKind::Platform) + ); + } + + #[test] + fn detect_platform_mainnet() { + assert_eq!( + AddressKind::detect("dash1qwer1234", Network::Mainnet), + Some(AddressKind::Platform) + ); + } + + #[test] + fn detect_core_address() { + use dash_sdk::dashcore_rpc::dashcore::secp256k1::{Secp256k1, SecretKey}; + use dash_sdk::dashcore_rpc::dashcore::{PrivateKey, PublicKey}; + + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&[1u8; 32]).unwrap(); + let privkey = PrivateKey::new(sk, Network::Testnet); + let pubkey = PublicKey::from_private_key(&secp, &privkey); + let addr = Address::p2pkh(&pubkey, Network::Testnet); + assert_eq!( + AddressKind::detect(&addr.to_string(), Network::Testnet), + Some(AddressKind::Core) + ); + } + + #[test] + fn detect_identity_base58_fallback() { + let id = Identifier::random(); + let id_str = id.to_string(Encoding::Base58); + // Some random identifiers parse as Core addresses. Skip those for + // this test — only assert identity detection for ones that do not. + if AddressKind::detect(&id_str, Network::Testnet) == Some(AddressKind::Core) { + return; + } + assert_eq!( + AddressKind::detect(&id_str, Network::Testnet), + Some(AddressKind::Identity) + ); + } + + #[test] + fn detect_garbage_returns_none() { + assert_eq!( + AddressKind::detect("not-an-address", Network::Testnet), + None + ); + } } diff --git a/src/ui/components/address_input.rs b/src/ui/components/address_input.rs index 6dd471b68..a5b9ebea8 100644 --- a/src/ui/components/address_input.rs +++ b/src/ui/components/address_input.rs @@ -523,6 +523,16 @@ impl AddressInput { None, ); } + // Orchard shielded addresses are ~70+ chars; reject anything too short. + if trimmed.len() < 60 { + return ( + Some( + "This private address looks incomplete. Please paste the full address." + .to_string(), + ), + None, + ); + } (None, Some(ValidatedAddress::Shielded(trimmed.to_string()))) } @@ -557,10 +567,12 @@ impl AddressInput { // --- Autocomplete filtering --- - fn filtered_entries(&self) -> Vec<&AddressEntry> { + /// Returns matching entries (truncated to 10) and the total match count + /// before truncation. + fn filtered_entries(&self) -> (Vec<&AddressEntry>, usize) { let query = self.input_text.trim().to_lowercase(); if query.len() < 3 { - return Vec::new(); + return (Vec::new(), 0); } let mut results: Vec<&AddressEntry> = self @@ -598,8 +610,9 @@ impl AddressInput { .then(a.display_label.cmp(&b.display_label)) }); + let total = results.len(); results.truncate(10); - results + (results, total) } // --- Balance formatting --- @@ -695,35 +708,21 @@ impl AddressInput { let mut selected_entry: Option = None; if has_focus && self.input_text.trim().len() >= 3 { // Collect filtered entries into an owned snapshot to release the borrow on self - let entries_snapshot: Vec<(String, String, AddressEntry)> = { - let filtered = self.filtered_entries(); - filtered - .iter() - .map(|e| { - ( - e.display_label.clone(), - self.format_balance(e), - (*e).clone(), - ) - }) - .collect() - }; + let (filtered, total_entries) = self.filtered_entries(); + let entries_snapshot: Vec<(String, String, AddressEntry)> = filtered + .iter() + .map(|e| { + ( + e.display_label.clone(), + self.format_balance(e), + (*e).clone(), + ) + }) + .collect(); if !entries_snapshot.is_empty() { self.autocomplete_open = true; let popup_id = ui.id().with("address_autocomplete"); - let total_entries = self - .all_entries - .iter() - .filter(|e| { - if let Some(fk) = self.selected_type_filter { - e.address_kind == fk - } else { - true - } - }) - .filter(|e| self.enabled_kinds.contains(&e.address_kind)) - .count(); egui::Area::new(popup_id) .order(egui::Order::Foreground) @@ -784,7 +783,7 @@ impl AddressInput { // Keyboard navigation if self.autocomplete_open { - let filtered_len = self.filtered_entries().len(); + let filtered_len = self.filtered_entries().0.len(); ui.input(|i| { if i.key_pressed(egui::Key::ArrowDown) { self.autocomplete_highlight = Some( @@ -806,7 +805,7 @@ impl AddressInput { if i.key_pressed(egui::Key::Enter) && let Some(idx) = self.autocomplete_highlight { - let filtered = self.filtered_entries(); + let (filtered, _) = self.filtered_entries(); if let Some(entry) = filtered.get(idx) { selected_entry = Some((*entry).clone()); } @@ -901,37 +900,16 @@ impl Component for AddressInput { /// Priority: Shielded > Platform > Core > Identity (Base58 fallback). /// Identity detection only runs when `identity_enabled` is true. fn detect_address_type(input: &str, identity_enabled: bool) -> DetectedType { - let trimmed = input.trim(); - if trimmed.is_empty() { - return DetectedType::Unknown; + // Delegate to AddressKind::detect() with a dummy network (detection is + // network-agnostic — it only checks format, not network correctness). + match AddressKind::detect(input, Network::Testnet) { + Some(AddressKind::Identity) if !identity_enabled => DetectedType::Unknown, + Some(AddressKind::Core) => DetectedType::Core, + Some(AddressKind::Platform) => DetectedType::Platform, + Some(AddressKind::Shielded) => DetectedType::Shielded, + Some(AddressKind::Identity) => DetectedType::Identity, + None => DetectedType::Unknown, } - - // 1. Shielded (dash1z... / tdash1z...) - if is_shielded_address(trimmed) { - return DetectedType::Shielded; - } - - // 2. Platform (Bech32m per DIP-18, but NOT shielded) - if crate::ui::helpers::is_platform_address_string(trimmed) { - return DetectedType::Platform; - } - - // 3. Core (Base58Check) - if trimmed.parse::>().is_ok() { - return DetectedType::Core; - } - - // 4. Identity (Base58 fallback, only when enabled) - if identity_enabled && Identifier::from_string(trimmed, Encoding::Base58).is_ok() { - return DetectedType::Identity; - } - - DetectedType::Unknown -} - -/// Check if a string looks like a shielded Orchard address. -fn is_shielded_address(s: &str) -> bool { - s.starts_with("dash1z") || s.starts_with("tdash1z") } /// Truncate an address string for display, showing prefix and suffix. @@ -1096,7 +1074,41 @@ mod tests { #[test] fn shielded_address_correct_network_accepted() { let input = AddressInput::new(Network::Testnet); - let (err, val) = input.validate_shielded("tdash1z_test_addr"); + // Use a long enough address to pass the minimum length check + let long_addr = format!("tdash1z{}", "a".repeat(60)); + let (err, val) = input.validate_shielded(&long_addr); + assert!(err.is_none()); + assert!(val.is_some()); + } + + #[test] + fn shielded_address_too_short_rejected() { + let input = AddressInput::new(Network::Testnet); + let (err, val) = input.validate_shielded("tdash1z"); + assert!(val.is_none()); + assert_eq!( + err.as_deref(), + Some("This private address looks incomplete. Please paste the full address.") + ); + } + + #[test] + fn shielded_prefix_only_rejected() { + let input = AddressInput::new(Network::Mainnet); + let (err, val) = input.validate_shielded("dash1z"); + assert!(val.is_none()); + assert_eq!( + err.as_deref(), + Some("This private address looks incomplete. Please paste the full address.") + ); + } + + #[test] + fn shielded_address_with_invalid_chars_but_long_enough_accepted() { + // Length check only — no character validation beyond prefix + let input = AddressInput::new(Network::Testnet); + let long_addr = format!("tdash1z{}", "x".repeat(60)); + let (err, val) = input.validate_shielded(&long_addr); assert!(err.is_none()); assert!(val.is_some()); } diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 58610d031..893e14c1d 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -508,28 +508,8 @@ impl WalletSendScreen { /// Detect address kind from the address string. /// /// Returns `None` for empty or unrecognized input. - fn detect_address_kind(address: &str) -> Option { - let trimmed = address.trim(); - if trimmed.is_empty() { - return None; - } - - // Check for shielded address (dash1z... or tdash1z...) - if trimmed.starts_with("dash1z") || trimmed.starts_with("tdash1z") { - return Some(AddressKind::Shielded); - } - - // Check for Platform address (Bech32m format per DIP-18) - if crate::ui::helpers::is_platform_address_string(trimmed) { - return Some(AddressKind::Platform); - } - - // Try to parse as Core address - if trimmed.parse::>().is_ok() { - return Some(AddressKind::Core); - } - - None + fn detect_address_kind(&self, address: &str) -> Option { + AddressKind::detect(address, self.app_context.network) } fn min_output_amount( @@ -1959,7 +1939,7 @@ impl WalletSendScreen { let has_platform_output = self .advanced_outputs .iter() - .any(|o| Self::detect_address_kind(&o.address) == Some(AddressKind::Platform)); + .any(|o| self.detect_address_kind(&o.address) == Some(AddressKind::Platform)); if self.advanced_source_type == AdvancedSourceType::Platform || has_platform_output { ui.label( @@ -2269,7 +2249,7 @@ impl WalletSendScreen { let addr_kinds: Vec> = self .advanced_outputs .iter() - .map(|o| Self::detect_address_kind(&o.address)) + .map(|o| self.detect_address_kind(&o.address)) .collect(); for (idx, &addr_kind) in addr_kinds.iter().enumerate() { @@ -2440,7 +2420,7 @@ impl WalletSendScreen { let output_kinds: Vec> = self .advanced_outputs .iter() - .map(|o| Self::detect_address_kind(&o.address)) + .map(|o| self.detect_address_kind(&o.address)) .collect(); let has_core_output = output_kinds.contains(&Some(AddressKind::Core)); From 8cad4f23c866ddd481a1159d0e51279d2980541c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:10:05 +0100 Subject: [PATCH 05/13] fix(ui): address bot review findings for AddressInput component - Validate shielded addresses via OrchardAddress::from_bech32m_string() instead of prefix+length check only - Use char-aware truncation in truncate_address() to prevent panics on multi-byte UTF-8 input (DPNS labels, emoji) - Reset AddressInput when source selection changes in send screen, and configure allowed destination kinds per source type - Store bech32m string in ValidatedAddress::Platform variant so to_address_string() returns canonical encoding instead of debug hex Co-Authored-By: Claude Opus 4.6 (1M context) --- src/model/address.rs | 15 ++-- src/ui/components/address_input.rs | 87 +++++++++++++++++------ src/ui/wallets/send_screen.rs | 22 +++++- src/ui/wallets/unshield_credits_screen.rs | 2 +- 4 files changed, 96 insertions(+), 30 deletions(-) diff --git a/src/model/address.rs b/src/model/address.rs index a62f6ec09..6b334db5d 100644 --- a/src/model/address.rs +++ b/src/model/address.rs @@ -91,8 +91,11 @@ impl std::fmt::Display for AddressKind { pub enum ValidatedAddress { /// A validated Core L1 address. Core(Address), - /// A validated Platform L2 address. - Platform(PlatformAddress), + /// A validated Platform L2 address with its canonical bech32m encoding. + Platform { + address: PlatformAddress, + bech32m: String, + }, /// A validated shielded Orchard address (stored as the raw string). Shielded(String), /// A validated identity identifier with optional DPNS name. @@ -109,7 +112,7 @@ impl ValidatedAddress { pub fn kind(&self) -> AddressKind { match self { Self::Core(_) => AddressKind::Core, - Self::Platform(_) => AddressKind::Platform, + Self::Platform { .. } => AddressKind::Platform, Self::Shielded(_) => AddressKind::Shielded, Self::Identity { .. } => AddressKind::Identity, } @@ -119,7 +122,7 @@ impl ValidatedAddress { pub fn to_address_string(&self) -> String { match self { Self::Core(addr) => addr.to_string(), - Self::Platform(addr) => format!("{}", addr), + Self::Platform { bech32m, .. } => bech32m.clone(), Self::Shielded(s) => s.clone(), Self::Identity { id, .. } => { id.to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58) @@ -138,7 +141,7 @@ impl ValidatedAddress { /// Returns the platform address if this is a Platform variant. pub fn as_platform(&self) -> Option<&PlatformAddress> { match self { - Self::Platform(addr) => Some(addr), + Self::Platform { address, .. } => Some(address), _ => None, } } @@ -164,7 +167,7 @@ impl std::fmt::Display for ValidatedAddress { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Core(addr) => write!(f, "{}", addr), - Self::Platform(addr) => write!(f, "{}", addr), + Self::Platform { bech32m, .. } => write!(f, "{}", bech32m), Self::Shielded(s) => write!(f, "{}", s), Self::Identity { id, diff --git a/src/ui/components/address_input.rs b/src/ui/components/address_input.rs index a5b9ebea8..554e8dd63 100644 --- a/src/ui/components/address_input.rs +++ b/src/ui/components/address_input.rs @@ -358,12 +358,16 @@ impl AddressInput { } else { truncate_address(&addr_str) }; + let bech32m = addr_str.clone(); self.all_entries.push(AddressEntry { address_string: addr_str, address_kind: AddressKind::Platform, display_label: display, balance: info.balance, - validated: ValidatedAddress::Platform(platform_addr), + validated: ValidatedAddress::Platform { + address: platform_addr, + bech32m, + }, }); } } @@ -504,7 +508,13 @@ impl AddressInput { ); } match PlatformAddress::from_bech32m_string(&canonical) { - Ok((pa, _network)) => (None, Some(ValidatedAddress::Platform(pa))), + Ok((pa, _network)) => ( + None, + Some(ValidatedAddress::Platform { + address: pa, + bech32m: canonical, + }), + ), Err(_) => ( Some("This does not look like a valid address.".to_string()), None, @@ -533,7 +543,27 @@ impl AddressInput { None, ); } - (None, Some(ValidatedAddress::Shielded(trimmed.to_string()))) + use dash_sdk::dpp::address_funds::OrchardAddress; + match OrchardAddress::from_bech32m_string(trimmed) { + Ok((_, network)) => { + if network != self.network + && !(self.network != Network::Mainnet && network != Network::Mainnet) + { + ( + Some("This address belongs to a different network.".to_string()), + None, + ) + } else { + (None, Some(ValidatedAddress::Shielded(trimmed.to_string()))) + } + } + Err(_) => ( + Some( + "This private address is not valid. Please check it and try again.".to_string(), + ), + None, + ), + } } fn validate_identity(&self, trimmed: &str) -> (Option, Option) { @@ -914,11 +944,19 @@ fn detect_address_type(input: &str, identity_enabled: bool) -> DetectedType { /// Truncate an address string for display, showing prefix and suffix. fn truncate_address(addr: &str) -> String { - if addr.len() <= 16 { - addr.to_string() - } else { - format!("{}...{}", &addr[..8], &addr[addr.len() - 6..]) - } + if addr.chars().count() <= 16 { + return addr.to_string(); + } + let prefix: String = addr.chars().take(8).collect(); + let suffix: String = addr + .chars() + .rev() + .take(6) + .collect::() + .chars() + .rev() + .collect(); + format!("{prefix}...{suffix}") } #[cfg(test)] @@ -1071,16 +1109,6 @@ mod tests { ); } - #[test] - fn shielded_address_correct_network_accepted() { - let input = AddressInput::new(Network::Testnet); - // Use a long enough address to pass the minimum length check - let long_addr = format!("tdash1z{}", "a".repeat(60)); - let (err, val) = input.validate_shielded(&long_addr); - assert!(err.is_none()); - assert!(val.is_some()); - } - #[test] fn shielded_address_too_short_rejected() { let input = AddressInput::new(Network::Testnet); @@ -1104,13 +1132,15 @@ mod tests { } #[test] - fn shielded_address_with_invalid_chars_but_long_enough_accepted() { - // Length check only — no character validation beyond prefix + fn shielded_address_with_invalid_chars_rejected() { let input = AddressInput::new(Network::Testnet); let long_addr = format!("tdash1z{}", "x".repeat(60)); let (err, val) = input.validate_shielded(&long_addr); - assert!(err.is_none()); - assert!(val.is_some()); + assert!(val.is_none()); + assert_eq!( + err.as_deref(), + Some("This private address is not valid. Please check it and try again.") + ); } // --- Enabled type restriction tests --- @@ -1210,6 +1240,19 @@ mod tests { assert_eq!(result, "12345678...234567"); } + #[test] + fn truncate_address_non_ascii_does_not_panic() { + let addr = "\u{1F355}dash1ztestaddr\u{1F389}longstringpadding"; + let result = truncate_address(addr); + assert!(result.contains("...")); + } + + #[test] + fn truncate_address_multibyte_short_unchanged() { + let addr = "\u{00E9}\u{00E9}\u{00E9}abc"; + assert_eq!(truncate_address(addr), addr); + } + // --- BalanceRange tests --- #[test] diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 893e14c1d..35698a93e 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -1356,6 +1356,8 @@ impl WalletSendScreen { let mut selected = is_core_selected; if ui.radio_value(&mut selected, true, "").changed() && selected { self.selected_source = Some(SourceSelection::CoreWallet); + self.address_input = None; + self.validated_destination = None; } ui.label( RichText::new("Core Wallet") @@ -1412,6 +1414,8 @@ impl WalletSendScreen { .collect(); self.selected_source = Some(SourceSelection::PlatformAddresses(addresses_with_balances)); + self.address_input = None; + self.validated_destination = None; } ui.label( RichText::new("Platform Addresses") @@ -1458,6 +1462,8 @@ impl WalletSendScreen { if ui.radio_value(&mut selected, true, "").changed() && selected { self.selected_source = Some(SourceSelection::Shielded(seed_hash, balance)); + self.address_input = None; + self.validated_destination = None; } ui.label( RichText::new("Shielded Balance") @@ -1478,9 +1484,23 @@ impl WalletSendScreen { fn render_destination_input(&mut self, ui: &mut Ui) { let addr_input = self.address_input.get_or_insert_with(|| { + let allowed_kinds = match &self.selected_source { + Some(SourceSelection::CoreWallet) => { + vec![AddressKind::Core, AddressKind::Platform] + } + Some(SourceSelection::PlatformAddresses(_)) => { + vec![AddressKind::Platform, AddressKind::Core] + } + Some(SourceSelection::Shielded(..)) => { + vec![AddressKind::Shielded, AddressKind::Platform] + } + None => AddressKind::ALL.to_vec(), + }; + let mut builder = AddressInput::new(self.app_context.network) .with_label("Send to") - .with_hint_text("Enter address (X.../y.../dash1.../tdash1...)"); + .with_hint_text("Enter address (X.../y.../dash1.../tdash1...)") + .with_address_kinds(&allowed_kinds); // Provide wallet data for autocomplete if available if let Some(wallet) = &self.selected_wallet { diff --git a/src/ui/wallets/unshield_credits_screen.rs b/src/ui/wallets/unshield_credits_screen.rs index 855215fcd..67822f15a 100644 --- a/src/ui/wallets/unshield_credits_screen.rs +++ b/src/ui/wallets/unshield_credits_screen.rs @@ -200,7 +200,7 @@ impl ScreenLike for UnshieldCreditsScreen { && let Some(amount) = self.parse_amount_credits() { match &self.validated_destination { - Some(ValidatedAddress::Platform(addr)) => { + Some(ValidatedAddress::Platform { address: addr, .. }) => { self.status = Status::WaitingForResult; self.error_message = None; action = AppAction::BackendTask(BackendTask::ShieldedTask( From ada6c524150c9ddf13ac12970a9611686cd40f12 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:32:17 +0100 Subject: [PATCH 06/13] fix(ui): address remaining review findings for AddressInput component - Fix manual entry not propagating validated address on blur (HIGH) - Fix selected_from_autocomplete causing repeated change signals - Remove protocol jargon from unshield screen messages - Reject mixed-case bech32m platform addresses per BIP-350 - Use per-instance ComboBox ID to prevent state collision - Clear cached_detection after autocomplete selection - Fix doc comments and reuse filtered_entries() computation Co-Authored-By: Claude Opus 4.6 (1M context) --- src/model/address.rs | 2 +- src/ui/components/address_input.rs | 153 +++++++++++++++++----- src/ui/wallets/send_screen.rs | 5 +- src/ui/wallets/unshield_credits_screen.rs | 4 +- 4 files changed, 121 insertions(+), 43 deletions(-) diff --git a/src/model/address.rs b/src/model/address.rs index 6b334db5d..640e563ae 100644 --- a/src/model/address.rs +++ b/src/model/address.rs @@ -35,7 +35,7 @@ impl AddressKind { } } - /// All address kinds in detection priority order. + /// All supported address kinds. pub const ALL: [AddressKind; 4] = [ AddressKind::Core, AddressKind::Platform, diff --git a/src/ui/components/address_input.rs b/src/ui/components/address_input.rs index 554e8dd63..07ba8b156 100644 --- a/src/ui/components/address_input.rs +++ b/src/ui/components/address_input.rs @@ -435,7 +435,8 @@ impl AddressInput { return (None, None); } - // In selection-only mode, manual input that does not match an entry is rejected. + // In selection-only mode, all manual input is rejected. Users must select + // an address from the autocomplete dropdown. if self.selection_only { return ( Some("Please select an address from the list.".to_string()), @@ -494,6 +495,17 @@ impl AddressInput { } fn validate_platform(&self, trimmed: &str) -> (Option, Option) { + // BIP-350: bech32m must be either all-lowercase or all-uppercase; mixed case is invalid. + let is_lower = trimmed.chars().all(|c| !c.is_ascii_uppercase()); + let is_upper = trimmed.chars().all(|c| !c.is_ascii_lowercase()); + if !is_lower && !is_upper { + return ( + Some( + "Platform addresses must not mix upper and lower case characters.".to_string(), + ), + None, + ); + } let canonical = trimmed.to_lowercase(); let expected_prefix = match self.network { Network::Mainnet => "dash1", @@ -679,7 +691,7 @@ impl AddressInput { .selected_type_filter .map(|t| t.display_name()) .unwrap_or("All"); - egui::ComboBox::from_id_salt("address_type_filter") + egui::ComboBox::from_id_salt(ui.id().with("address_type_filter")) .selected_text(current_label) .width(120.0) .show_ui(ui, |ui| { @@ -739,6 +751,7 @@ impl AddressInput { if has_focus && self.input_text.trim().len() >= 3 { // Collect filtered entries into an owned snapshot to release the borrow on self let (filtered, total_entries) = self.filtered_entries(); + let filtered_len = filtered.len(); let entries_snapshot: Vec<(String, String, AddressEntry)> = filtered .iter() .map(|e| { @@ -804,6 +817,33 @@ impl AddressInput { }); }); }); + + // Keyboard navigation (uses snapshot data, no recomputation) + ui.input(|i| { + if i.key_pressed(egui::Key::ArrowDown) { + self.autocomplete_highlight = Some( + self.autocomplete_highlight + .map(|h| (h + 1).min(filtered_len.saturating_sub(1))) + .unwrap_or(0), + ); + } + if i.key_pressed(egui::Key::ArrowUp) { + self.autocomplete_highlight = self + .autocomplete_highlight + .map(|h| h.saturating_sub(1)) + .or(Some(0)); + } + if i.key_pressed(egui::Key::Escape) { + self.autocomplete_open = false; + self.autocomplete_highlight = None; + } + if i.key_pressed(egui::Key::Enter) + && let Some(idx) = self.autocomplete_highlight + && let Some((_, _, entry)) = entries_snapshot.get(idx) + { + selected_entry = Some(entry.clone()); + } + }); } else { self.autocomplete_open = false; } @@ -811,42 +851,12 @@ impl AddressInput { self.autocomplete_open = false; } - // Keyboard navigation - if self.autocomplete_open { - let filtered_len = self.filtered_entries().0.len(); - ui.input(|i| { - if i.key_pressed(egui::Key::ArrowDown) { - self.autocomplete_highlight = Some( - self.autocomplete_highlight - .map(|h| (h + 1).min(filtered_len.saturating_sub(1))) - .unwrap_or(0), - ); - } - if i.key_pressed(egui::Key::ArrowUp) { - self.autocomplete_highlight = self - .autocomplete_highlight - .map(|h| h.saturating_sub(1)) - .or(Some(0)); - } - if i.key_pressed(egui::Key::Escape) { - self.autocomplete_open = false; - self.autocomplete_highlight = None; - } - if i.key_pressed(egui::Key::Enter) - && let Some(idx) = self.autocomplete_highlight - { - let (filtered, _) = self.filtered_entries(); - if let Some(entry) = filtered.get(idx) { - selected_entry = Some((*entry).clone()); - } - } - }); - } - - // Handle autocomplete selection + // Handle autocomplete selection (FIX 7: clear cached_detection) + let selected_this_frame = selected_entry.is_some(); if let Some(entry) = selected_entry { self.input_text = entry.address_string.clone(); self.selected_from_autocomplete = true; + self.cached_detection = None; self.autocomplete_open = false; self.autocomplete_highlight = None; self.has_blurred = true; @@ -880,7 +890,10 @@ impl AddressInput { } // Build response - let changed = text_changed || self.selected_from_autocomplete || self.changed; + // FIX 1: blur validation produces a result => signal changed + let blur_validated = lost_focus && validated_address.is_some(); + // FIX 2: use one-frame local flag for autocomplete selection + let changed = text_changed || selected_this_frame || self.changed || blur_validated; if self.changed { self.changed = false; } @@ -1361,4 +1374,72 @@ mod tests { assert_eq!(va.kind(), AddressKind::Shielded); assert_eq!(va.to_address_string(), "dash1z_test"); } + + // --- FIX 1: Blur validation propagation --- + + #[test] + fn blur_triggers_validation_for_valid_core_address() { + let (addr_str, _) = testnet_core_address(); + let mut input = AddressInput::new(Network::Testnet); + input.input_text = addr_str; + // Simulate blur: has_blurred is set when focus leaves with non-empty input + input.has_blurred = true; + let (err, val) = input.validate_input(); + assert!(err.is_none(), "valid address after blur should not error"); + assert!( + val.is_some(), + "valid address after blur should produce a validated address" + ); + } + + #[test] + fn current_value_returns_validated_after_blur() { + let (addr_str, _) = testnet_core_address(); + let mut input = AddressInput::new(Network::Testnet); + input.input_text = addr_str; + input.has_blurred = true; + let val = input.current_value(); + assert!( + val.is_some(), + "current_value should return validated address after blur" + ); + assert_eq!(val.unwrap().kind(), AddressKind::Core); + } + + // --- FIX 4: Mixed-case bech32m rejection --- + + #[test] + fn platform_mixed_case_rejected() { + let input = AddressInput::new(Network::Testnet); + let (err, val) = input.validate_platform("tDash1qwer1234"); + assert!(val.is_none(), "mixed-case bech32m should be rejected"); + assert_eq!( + err.as_deref(), + Some("Platform addresses must not mix upper and lower case characters.") + ); + } + + #[test] + fn platform_all_lowercase_accepted_for_case_check() { + let input = AddressInput::new(Network::Testnet); + // This will fail bech32m parsing, but should NOT fail the case check + let (err, _) = input.validate_platform("tdash1qwer1234"); + assert_ne!( + err.as_deref(), + Some("Platform addresses must not mix upper and lower case characters."), + "all-lowercase should pass the case check" + ); + } + + #[test] + fn platform_all_uppercase_accepted_for_case_check() { + let input = AddressInput::new(Network::Testnet); + // All-uppercase is valid per BIP-350 (will fail other checks, but not case) + let (err, _) = input.validate_platform("TDASH1QWER1234"); + assert_ne!( + err.as_deref(), + Some("Platform addresses must not mix upper and lower case characters."), + "all-uppercase should pass the case check" + ); + } } diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 35698a93e..4775248a2 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -681,10 +681,7 @@ impl WalletSendScreen { } } - /// Returns the detected address kind for the current destination. - /// - /// Uses the validated address if available (simple mode with AddressInput), - /// otherwise falls back to raw string detection (advanced mode outputs). + /// Returns the address kind of the current validated destination, if any. fn destination_kind(&self) -> Option { self.validated_destination.as_ref().map(|v| v.kind()) } diff --git a/src/ui/wallets/unshield_credits_screen.rs b/src/ui/wallets/unshield_credits_screen.rs index 67822f15a..4f753b96b 100644 --- a/src/ui/wallets/unshield_credits_screen.rs +++ b/src/ui/wallets/unshield_credits_screen.rs @@ -143,13 +143,13 @@ impl ScreenLike for UnshieldCreditsScreen { Some(AddressKind::Platform) => { ui.colored_label( Color32::DARK_GREEN, - "Platform address — will unshield to platform (Type 17)", + "Platform address — credits will be moved to this platform address", ); } Some(AddressKind::Core) => { ui.colored_label( Color32::DARK_GREEN, - "Core address — will withdraw to core DASH (Type 19)", + "Core address — credits will be withdrawn as DASH to this address", ); } _ => {} From 72fbab895f676088ed2bbfe99f0267b472f1ea1f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 08:20:45 +0100 Subject: [PATCH 07/13] feat(ui): support multi-wallet autocomplete in AddressInput Replace with_wallet/set_wallet (single wallet) with with_wallets/set_wallets (slice of wallets). When multiple wallets are loaded, each autocomplete entry is prefixed with the wallet alias so the user can tell which wallet owns the address. send_screen.rs now passes all loaded wallets to AddressInput instead of only the selected one. Co-Authored-By: Claude Sonnet 4.6 --- src/ui/components/address_input.rs | 40 +++++++++++++++++++++--------- src/ui/wallets/send_screen.rs | 10 +++++--- 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/src/ui/components/address_input.rs b/src/ui/components/address_input.rs index 07ba8b156..12c27e28c 100644 --- a/src/ui/components/address_input.rs +++ b/src/ui/components/address_input.rs @@ -130,7 +130,7 @@ impl ComponentResponse for AddressInputResponse { /// ```rust,ignore /// let addr_input = self.address_input.get_or_insert_with(|| { /// AddressInput::new(network) -/// .with_wallet(wallet.clone()) +/// .with_wallets(&wallets) /// .with_label("Destination address") /// .with_hint_text("Enter address or username") /// }); @@ -205,10 +205,14 @@ impl AddressInput { /// Provide wallet data for Core and Platform autocomplete. /// - /// Entries are extracted immediately (read lock acquired once). - /// Skips gracefully if the wallet lock is poisoned. - pub fn with_wallet(mut self, wallet: Arc>) -> Self { - self.extract_wallet_entries(&wallet); + /// Entries are extracted immediately (read lock acquired once per wallet). + /// Skips gracefully if a wallet lock is poisoned. + /// When more than one wallet is provided, entries are prefixed with the wallet alias. + pub fn with_wallets(mut self, wallets: &[Arc>]) -> Self { + let multi = wallets.len() > 1; + for wallet in wallets { + self.extract_wallet_entries(wallet, multi); + } self } @@ -298,11 +302,16 @@ impl AddressInput { // --- Mutable setters for runtime reconfiguration --- /// Update wallet data after initialization (e.g., balance refresh). - pub fn set_wallet(&mut self, wallet: &Arc>) { + /// + /// When more than one wallet is provided, entries are prefixed with the wallet alias. + pub fn set_wallets(&mut self, wallets: &[Arc>]) { self.all_entries.retain(|e| { e.address_kind != AddressKind::Core && e.address_kind != AddressKind::Platform }); - self.extract_wallet_entries(wallet); + let multi = wallets.len() > 1; + for wallet in wallets { + self.extract_wallet_entries(wallet, multi); + } } /// Update identity data after initialization. @@ -326,19 +335,26 @@ impl AddressInput { // --- Entry extraction --- - fn extract_wallet_entries(&mut self, wallet: &Arc>) { + fn extract_wallet_entries(&mut self, wallet: &Arc>, multi_wallet: bool) { let guard = match wallet.read().ok() { Some(g) => g, None => return, }; + let prefix = if multi_wallet { + let name = guard.alias.as_deref().unwrap_or("Wallet"); + format!("[{}] ", name) + } else { + String::new() + }; + // Core addresses from address_balances for (address, &balance) in &guard.address_balances { let addr_str = address.to_string(); let display = if self.full_addresses { - addr_str.clone() + format!("{}{}", prefix, addr_str) } else { - truncate_address(&addr_str) + format!("{}{}", prefix, truncate_address(&addr_str)) }; self.all_entries.push(AddressEntry { address_string: addr_str, @@ -354,9 +370,9 @@ impl AddressInput { if let Ok(platform_addr) = PlatformAddress::try_from(core_addr.clone()) { let addr_str = platform_addr.to_bech32m_string(self.network); let display = if self.full_addresses { - addr_str.clone() + format!("{}{}", prefix, addr_str) } else { - truncate_address(&addr_str) + format!("{}{}", prefix, truncate_address(&addr_str)) }; let bech32m = addr_str.clone(); self.all_entries.push(AddressEntry { diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 4775248a2..fa029898d 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -1499,9 +1499,13 @@ impl WalletSendScreen { .with_hint_text("Enter address (X.../y.../dash1.../tdash1...)") .with_address_kinds(&allowed_kinds); - // Provide wallet data for autocomplete if available - if let Some(wallet) = &self.selected_wallet { - builder = builder.with_wallet(wallet.clone()); + // Provide all wallet addresses for autocomplete + if let Ok(wallets_guard) = self.app_context.wallets.read() { + let all_wallets: Vec>> = + wallets_guard.values().cloned().collect(); + if !all_wallets.is_empty() { + builder = builder.with_wallets(&all_wallets); + } } builder From 3d4b0151475a3f9119fe6d9da5f54276980678fb Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 09:43:58 +0100 Subject: [PATCH 08/13] fix(ui): restore missing "Show zero-balance addresses" checkbox The checkbox was accidentally dropped during a branch merge. Restored the horizontal layout with heading on the left and checkbox right-aligned, matching the v1.0-dev implementation. Co-Authored-By: Claude Opus 4.6 --- src/ui/wallets/wallets_screen/mod.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 9e208d942..8305f6e8c 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -1655,10 +1655,21 @@ impl WalletsBalancesScreen { format!("Addresses ({})", category.label(*index)) }) .unwrap_or_else(|| "Addresses".to_string()); - ui.heading( - RichText::new(addresses_heading) - .color(DashColors::text_primary(dark_mode)), - ); + ui.horizontal(|ui| { + ui.heading( + RichText::new(addresses_heading) + .color(DashColors::text_primary(dark_mode)), + ); + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + ui.checkbox( + &mut self.show_zero_balance_addresses, + "Show zero-balance addresses", + ); + }, + ); + }); ui.add_space(8.0); action |= self.render_address_table(ui); From afad2e1c30b6dbf6b2a7534ddae9c22899d4dc9e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 09:52:33 +0100 Subject: [PATCH 09/13] fix(ui): improve AddressInput autocomplete UX - Fix click selection: keep popup rendered when text field loses focus so the click handler fires (was gated on has_focus only) - Show dropdown on focus with any input length (removed 3-char minimum) - Add type suffix (Core), (Platform), (Identity), (Shielded) to dropdown entries when multiple address types are enabled - Validate immediately on paste (text >3 chars) instead of requiring blur first, so "Fund Platform Address" button activates without needing to click away Co-Authored-By: Claude Opus 4.6 --- src/model/address.rs | 10 ++++++++++ src/ui/components/address_input.rs | 28 ++++++++++++++++++---------- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/src/model/address.rs b/src/model/address.rs index 640e563ae..5a768ab18 100644 --- a/src/model/address.rs +++ b/src/model/address.rs @@ -35,6 +35,16 @@ impl AddressKind { } } + /// Short label for use in parenthetical suffixes, e.g. "(Core)". + pub fn short_label(&self) -> &'static str { + match self { + Self::Core => "Core", + Self::Platform => "Platform", + Self::Shielded => "Shielded", + Self::Identity => "Identity", + } + } + /// All supported address kinds. pub const ALL: [AddressKind; 4] = [ AddressKind::Core, diff --git a/src/ui/components/address_input.rs b/src/ui/components/address_input.rs index 12c27e28c..5f3792290 100644 --- a/src/ui/components/address_input.rs +++ b/src/ui/components/address_input.rs @@ -629,9 +629,6 @@ impl AddressInput { /// before truncation. fn filtered_entries(&self) -> (Vec<&AddressEntry>, usize) { let query = self.input_text.trim().to_lowercase(); - if query.len() < 3 { - return (Vec::new(), 0); - } let mut results: Vec<&AddressEntry> = self .all_entries @@ -653,6 +650,10 @@ impl AddressInput { { return false; } + // When query is empty, show all entries (no substring filter) + if query.is_empty() { + return true; + } // Substring match against address and label e.address_string.to_lowercase().contains(&query) || e.display_label.to_lowercase().contains(&query) @@ -661,6 +662,9 @@ impl AddressInput { // Sort: exact prefix matches first, then by label results.sort_by(|a, b| { + if query.is_empty() { + return a.display_label.cmp(&b.display_label); + } let a_prefix = a.address_string.to_lowercase().starts_with(&query); let b_prefix = b.address_string.to_lowercase().starts_with(&query); b_prefix @@ -748,9 +752,11 @@ impl AddressInput { // On text change: reset validation state if text_changed { - self.has_blurred = false; self.selected_from_autocomplete = false; self.cached_detection = None; + // Detect paste: a multi-character change in a single frame. + // Validate immediately so the user doesn't have to blur first. + self.has_blurred = self.input_text.trim().len() > 3; } // Detect address type (cached) @@ -764,18 +770,20 @@ impl AddressInput { // Autocomplete popup let mut selected_entry: Option = None; - if has_focus && self.input_text.trim().len() >= 3 { + if has_focus || self.autocomplete_open { // Collect filtered entries into an owned snapshot to release the borrow on self let (filtered, total_entries) = self.filtered_entries(); let filtered_len = filtered.len(); + let show_type_suffix = self.enabled_kinds.len() > 1; let entries_snapshot: Vec<(String, String, AddressEntry)> = filtered .iter() .map(|e| { - ( - e.display_label.clone(), - self.format_balance(e), - (*e).clone(), - ) + let label = if show_type_suffix { + format!("{} ({})", e.display_label, e.address_kind.short_label()) + } else { + e.display_label.clone() + }; + (label, self.format_balance(e), (*e).clone()) }) .collect(); From 4be0418147ddaf24f248ac1fa8cd1924ebfe01e6 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:14:59 +0100 Subject: [PATCH 10/13] fix(ui): make entire autocomplete row clickable in AddressInput MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously only the address label was clickable — clicking the balance text on the right side of the row did nothing. Now the click handler uses the horizontal row response, so the entire row triggers selection. Co-Authored-By: Claude Opus 4.6 --- src/ui/components/address_input.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/ui/components/address_input.rs b/src/ui/components/address_input.rs index 5f3792290..ae8085f56 100644 --- a/src/ui/components/address_input.rs +++ b/src/ui/components/address_input.rs @@ -805,9 +805,8 @@ impl AddressInput { { let highlighted = self.autocomplete_highlight == Some(i); - ui.horizontal(|ui| { - let resp = ui - .selectable_label(highlighted, label.as_str()); + let row_resp = ui.horizontal(|ui| { + let _ = ui.selectable_label(highlighted, label.as_str()); ui.with_layout( egui::Layout::right_to_left( egui::Align::Center, @@ -822,10 +821,10 @@ impl AddressInput { ); }, ); - if resp.clicked() { - selected_entry = Some(entry.clone()); - } }); + if row_resp.response.clicked() { + selected_entry = Some(entry.clone()); + } } if total_entries > 10 { let remaining = total_entries - 10; From 04d32ce82d3cfb1ba01cf89ef2a2a49ebcc309b2 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:44:12 +0100 Subject: [PATCH 11/13] fix(ui): fix autocomplete click handling and format balances with 4dp - Capture selectable_label click response instead of discarding it; combine with row-level click for full-row clickability - Format all DASH balances in dropdown with exactly 4 decimal places Co-Authored-By: Claude Opus 4.6 --- src/ui/components/address_input.rs | 38 ++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/src/ui/components/address_input.rs b/src/ui/components/address_input.rs index ae8085f56..7c0435d44 100644 --- a/src/ui/components/address_input.rs +++ b/src/ui/components/address_input.rs @@ -681,18 +681,40 @@ impl AddressInput { fn format_balance(&self, entry: &AddressEntry) -> String { match entry.address_kind { - AddressKind::Core => Amount::dash_from_duffs(entry.balance).to_string(), + AddressKind::Core => Self::format_dash_4dp(Amount::dash_from_duffs(entry.balance)), AddressKind::Platform | AddressKind::Shielded | AddressKind::Identity => { let dash = Amount::new(entry.balance, DASH_DECIMAL_PLACES).with_unit_name("DASH"); if self.developer_mode { - format!("{} ({} credits)", dash, entry.balance) + format!( + "{} ({} credits)", + Self::format_dash_4dp(dash), + entry.balance + ) } else { - dash.to_string() + Self::format_dash_4dp(dash) } } } } + /// Format a DASH amount with exactly 4 decimal places for dropdown display. + fn format_dash_4dp(amount: Amount) -> String { + // Get the full-precision string without trimming, then truncate to 4 dp. + let full = amount.to_string_opts(false, false); + let formatted = if let Some(dot_pos) = full.find('.') { + let decimals = &full[dot_pos + 1..]; + if decimals.len() > 4 { + format!("{}.{}", &full[..dot_pos], &decimals[..4]) + } else { + // Pad with zeros if fewer than 4 decimals + format!("{}.{:0<4}", &full[..dot_pos], decimals) + } + } else { + format!("{full}.0000") + }; + format!("{formatted} DASH") + } + // --- show() implementation --- fn show_internal(&mut self, ui: &mut Ui) -> InnerResponse { @@ -806,7 +828,9 @@ impl AddressInput { let highlighted = self.autocomplete_highlight == Some(i); let row_resp = ui.horizontal(|ui| { - let _ = ui.selectable_label(highlighted, label.as_str()); + let label_clicked = ui + .selectable_label(highlighted, label.as_str()) + .clicked(); ui.with_layout( egui::Layout::right_to_left( egui::Align::Center, @@ -821,8 +845,12 @@ impl AddressInput { ); }, ); + label_clicked }); - if row_resp.response.clicked() { + // Capture clicks on the label (consumed by + // selectable_label) OR on the rest of the + // row (balance area, dead space). + if row_resp.inner || row_resp.response.clicked() { selected_entry = Some(entry.clone()); } } From 8011784dac1fef1b90c3b4bbfaa8c33c51c14e11 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:48:33 +0100 Subject: [PATCH 12/13] fix(ui): use single interaction rect for full-row clickable autocomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace selectable_label + horizontal layout with allocate_exact_size and manual painting — no child widgets steal clicks, the entire row (address, balance, dead space) is clickable with hover feedback. Co-Authored-By: Claude Opus 4.6 --- src/ui/components/address_input.rs | 72 +++++++++++++++++++++--------- 1 file changed, 50 insertions(+), 22 deletions(-) diff --git a/src/ui/components/address_input.rs b/src/ui/components/address_input.rs index 7c0435d44..b1ca648b6 100644 --- a/src/ui/components/address_input.rs +++ b/src/ui/components/address_input.rs @@ -827,30 +827,58 @@ impl AddressInput { { let highlighted = self.autocomplete_highlight == Some(i); - let row_resp = ui.horizontal(|ui| { - let label_clicked = ui - .selectable_label(highlighted, label.as_str()) - .clicked(); - ui.with_layout( - egui::Layout::right_to_left( - egui::Align::Center, + + // Single interaction rect spanning the full + // row width. No child widgets — painted + // manually so nothing steals clicks. + let row_height = ui.spacing().interact_size.y; + let row_width = ui.available_width(); + let (rect, response) = ui.allocate_exact_size( + egui::vec2(row_width, row_height), + egui::Sense::click(), + ); + + if ui.is_rect_visible(rect) { + let hovered = response.hovered(); + if highlighted || hovered { + ui.painter().rect_filled( + rect, + egui::CornerRadius::from(2.0), + ui.style().visuals.widgets.hovered.bg_fill, + ); + } + + let text_color = if highlighted || hovered { + ui.style().visuals.widgets.hovered.text_color() + } else { + ui.style().visuals.widgets.inactive.text_color() + }; + + let padding = 4.0; + ui.painter().text( + egui::pos2( + rect.left() + padding, + rect.center().y, + ), + egui::Align2::LEFT_CENTER, + label.as_str(), + egui::TextStyle::Body.resolve(ui.style()), + text_color, + ); + + ui.painter().text( + egui::pos2( + rect.right() - padding, + rect.center().y, ), - |ui| { - ui.label( - egui::RichText::new( - balance_str.as_str(), - ) - .small() - .color(DashColors::GRAY), - ); - }, + egui::Align2::RIGHT_CENTER, + balance_str.as_str(), + egui::TextStyle::Small.resolve(ui.style()), + DashColors::GRAY, ); - label_clicked - }); - // Capture clicks on the label (consumed by - // selectable_label) OR on the rest of the - // row (balance area, dead space). - if row_resp.inner || row_resp.response.clicked() { + } + + if response.clicked() { selected_entry = Some(entry.clone()); } } From 08382943ddaca2f36f98ff57b2d8bf9173d016c8 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:53:56 +0100 Subject: [PATCH 13/13] feat(ui): add wallet address autocomplete to unshield screen Populate AddressInput with wallet addresses via .with_wallets() so users can select a destination from the dropdown instead of manually entering addresses. Co-Authored-By: Claude Opus 4.6 --- src/ui/wallets/unshield_credits_screen.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/ui/wallets/unshield_credits_screen.rs b/src/ui/wallets/unshield_credits_screen.rs index 4f753b96b..8c244ebe8 100644 --- a/src/ui/wallets/unshield_credits_screen.rs +++ b/src/ui/wallets/unshield_credits_screen.rs @@ -128,12 +128,19 @@ impl ScreenLike for UnshieldCreditsScreen { // Destination address input via AddressInput component let addr_input = self.address_input.get_or_insert_with(|| { - AddressInput::new(self.app_context.network) + let mut builder = AddressInput::new(self.app_context.network) .with_address_kinds(&[AddressKind::Core, AddressKind::Platform]) .with_label("To address") .with_hint_text( "Enter a platform address (tdash1.../dash1...) or core DASH address", - ) + ); + + if let Ok(wallets) = self.app_context.wallets.read() { + let all_wallets: Vec<_> = wallets.values().cloned().collect(); + builder = builder.with_wallets(&all_wallets); + } + + builder }); let resp = addr_input.show(ui); resp.inner.update(&mut self.validated_destination);