-
Notifications
You must be signed in to change notification settings - Fork 13
feat(ui): unified AddressInput component with autocomplete #787
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
f72ee9c
feat(ui): add unified AddressInput component with autocomplete
lklimek 6bf45a9
feat(ui): integrate AddressInput component into send and unshield scr…
lklimek a1010db
test(address-input): fix misleading test and add missing coverage
lklimek cf73b1e
fix(ui): address QA findings for AddressInput component
lklimek 8cad4f2
fix(ui): address bot review findings for AddressInput component
lklimek 383e360
Merge branch 'zk-fixes' into feat/unified-address-input
lklimek ada6c52
fix(ui): address remaining review findings for AddressInput component
lklimek 811c098
Merge branch 'zk-fixes' into feat/unified-address-input
lklimek 72fbab8
feat(ui): support multi-wallet autocomplete in AddressInput
lklimek b2daa80
Merge remote-tracking branch 'origin/zk-fixes' into feat/unified-addr…
lklimek 3d4b015
fix(ui): restore missing "Show zero-balance addresses" checkbox
lklimek afad2e1
fix(ui): improve AddressInput autocomplete UX
lklimek 4be0418
fix(ui): make entire autocomplete row clickable in AddressInput
lklimek 04d32ce
fix(ui): fix autocomplete click handling and format balances with 4dp
lklimek 8011784
fix(ui): use single interaction rect for full-row clickable autocomplete
lklimek 0838294
feat(ui): add wallet address autocomplete to unshield screen
lklimek File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,318 @@ | ||
| 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. | ||
| /// | ||
| /// 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", | ||
| } | ||
| } | ||
|
|
||
| /// 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, | ||
| AddressKind::Platform, | ||
| 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<AddressKind> { | ||
| 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::<Address<NetworkUnchecked>>().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 { | ||
| 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 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. | ||
| Identity { | ||
| /// The parsed identity identifier. | ||
| id: Identifier, | ||
| /// Resolved DPNS name, if available from local data. | ||
| dpns_name: Option<String>, | ||
| }, | ||
| } | ||
|
|
||
| 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 { bech32m, .. } => bech32m.clone(), | ||
| 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 { address, .. } => Some(address), | ||
| _ => 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 { bech32m, .. } => write!(f, "{}", bech32m), | ||
| 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()); | ||
| } | ||
|
|
||
| // --- 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 | ||
| ); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| pub mod address; | ||
| pub mod amount; | ||
| pub mod contested_name; | ||
| pub mod fee_estimation; | ||
|
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.