diff --git a/docs/user-stories.md b/docs/user-stories.md index aaf975c30..52ee64cdd 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -186,6 +186,43 @@ As a user, I want to withdraw credits from a Platform address back to a Core add - Destination Core address input. - Fee strategy configuration. +### WAL-021: Navigate wallet accounts via tabs [Implemented] +**Persona:** Alex, Priya + +As a user, I want to see clear tabs for Dash Core, Platform, and Shielded so that I can switch between account views without searching through a dropdown. + +- Tab bar replaces account category dropdown. +- Each tab shows its balance in the label. +- Empty accounts display "(empty)" indicator. +- Switching tabs is instant with no data reload. + +### WAL-022: View system accounts in developer mode [Implemented] +**Persona:** Jordan + +As a developer, I want a System tab that reveals all internal account categories (Identity Registration, CoinJoin, Provider keys, etc.) so that I can inspect low-level wallet structure without cluttering the default view. + +- System tab appears only when developer mode is enabled. +- Each system account category is shown as a collapsible section. +- Section headers display address count and balance. + +### WAL-023: Collapsible transaction history [Implemented] +**Persona:** Alex, Priya + +As a user, I want the transaction history to be collapsible so that I can focus on addresses or balances without scrolling past a long list of transactions. + +- Transaction history section has a collapsible header. +- Collapsed by default to reduce visual clutter. +- Expand/collapse state persists within the session. + +### WAL-024: Collapsible balance breakdown [Implemented] +**Persona:** Priya + +As a power user, I want the balance breakdown and address table to be collapsible so that I can focus on the information I need at the moment. + +- Address table section has a collapsible header. +- Asset locks section has a collapsible header. +- Sections are expanded by default for quick access. + --- ## Send and Receive (SND) diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index 05085e08a..2136b401b 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -197,11 +197,14 @@ impl AppContext { if let Some(task_err) = Self::chain_lock_rpc_error(active_config, e) { return Err(task_err); } - // Non-auth, non-connection error — log the raw error but show - // a sanitized message in the UI status display. + // Non-auth, non-connection error — show the actual error + // in the Networks page status display for debugging. tracing::warn!(network = ?self.network, error = %e, "Chain lock query failed on active network"); - Some("RPC error — check Dash Core status".to_string()) + Some(format!("RPC error: {e}")) } else { + // Successful chain lock fetch — clear any lingering RPC error + // so the connection status recovers after a transient outage. + self.connection_status.set_rpc_last_error(None); None }; diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 208323440..8e22bed9b 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -830,6 +830,16 @@ pub enum TaskError { source: Box, }, + /// The nonce used for a shielded transaction was stale. The wallet's cached + /// nonce was behind Platform's expected nonce. Retrying will use the correct nonce. + #[error( + "The transaction used an outdated sequence number. Please retry — the wallet will use the correct number automatically." + )] + ShieldedNonceMismatch { + #[source] + source_error: Box, + }, + /// The address used for a shielded transaction does not have enough locked funds. #[error( "Not enough funds locked for this shielded transaction. \ @@ -1006,6 +1016,13 @@ pub fn shielded_broadcast_error(e: SdkError) -> TaskError { source_error: Box::new(e), }; } + if let Some(ConsensusError::StateError(StateError::AddressInvalidNonceError(_))) = + consensus_error + { + return TaskError::ShieldedNonceMismatch { + source_error: Box::new(e), + }; + } TaskError::ShieldedBroadcastFailed { source: Box::new(e), } diff --git a/src/backend_task/shielded/sync.rs b/src/backend_task/shielded/sync.rs index b63661d64..96a512fc3 100644 --- a/src/backend_task/shielded/sync.rs +++ b/src/backend_task/shielded/sync.rs @@ -110,11 +110,31 @@ pub async fn sync_notes( } // Persist and record decrypted notes that are new (position >= already_have). + // Also skip notes already in memory (loaded from DB during init) to prevent + // double-counting when the commitment tree resets but persisted notes remain. + // Build a HashMap of position->value for O(1) lookups and divergence detection. + let existing_notes: std::collections::HashMap = shielded_state + .notes + .iter() + .map(|n| (u64::from(n.position), n.note.value().inner())) + .collect(); let mut new_note_count = 0u32; for dn in result.decrypted_notes { if dn.position < already_have { continue; // already stored in a previous sync } + if let Some(&existing_value) = existing_notes.get(&dn.position) { + let new_value = dn.note.value().inner(); + if new_value != existing_value { + tracing::warn!( + position = dn.position, + existing_value, + new_value, + "Shielded note dedup: value divergence at existing position" + ); + } + continue; // already loaded from DB during init + } // Compute the spending nullifier from our FVK (dn.nullifier is the rho/nf // field from the compact action, not the spending nullifier). diff --git a/src/context/shielded.rs b/src/context/shielded.rs index b91118487..f030281cc 100644 --- a/src/context/shielded.rs +++ b/src/context/shielded.rs @@ -150,7 +150,7 @@ impl AppContext { } /// Initialize shielded wallet state by deriving ZIP32 keys from the wallet seed. - fn initialize_shielded_wallet( + pub(crate) fn initialize_shielded_wallet( self: &Arc, seed_hash: WalletSeedHash, ) -> Result { @@ -277,7 +277,7 @@ impl AppContext { } /// Sync shielded notes from platform. - async fn sync_shielded_notes( + pub(crate) async fn sync_shielded_notes( self: &Arc, seed_hash: WalletSeedHash, ) -> Result { @@ -539,7 +539,7 @@ impl AppContext { } /// Check nullifiers to detect spent notes. - async fn check_nullifiers_task( + pub(crate) async fn check_nullifiers_task( self: &Arc, seed_hash: WalletSeedHash, ) -> Result { diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index e91216933..10d231c7f 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -137,11 +137,18 @@ impl AppContext { } pub fn bootstrap_wallet_addresses(&self, wallet: &Arc>) { - if let Ok(mut guard) = wallet.write() - && guard.known_addresses.is_empty() - { - tracing::info!(wallet = %hex::encode(guard.seed_hash()), "Bootstrapping wallet addresses"); - guard.bootstrap_known_addresses(self); + if let Ok(mut guard) = wallet.write() { + // Bootstrap when no addresses exist (fresh wallet) or when + // platform payment addresses haven't been derived yet (wallet + // created with only a Core address via new_from_seed). + let has_platform_addresses = guard.watched_addresses.values().any(|info| { + info.path_reference + == crate::model::wallet::DerivationPathReference::PlatformPayment + }); + if guard.known_addresses.is_empty() || !has_platform_addresses { + tracing::info!(wallet = %hex::encode(guard.seed_hash()), "Bootstrapping wallet addresses"); + guard.bootstrap_known_addresses(self); + } } } @@ -150,6 +157,13 @@ impl AppContext { self.queue_spv_wallet_load(seed_hash, seed_bytes); // Note: Platform address sync is not done here. // Core UTXO refresh is handled at startup in bootstrap_loaded_wallets. + + // Initialize shielded wallet on a background thread to avoid + // blocking the UI — ZIP32 key derivation and DB reads can stall. + // After init completes, queue async SyncNotes -> CheckNullifiers. + // This is the single init path — the UI never dispatches + // InitializeShieldedWallet. + self.queue_shielded_init_and_sync(seed_hash); } } @@ -164,6 +178,72 @@ impl AppContext { self.queue_spv_wallet_unload(seed_hash); } + /// Queue shielded wallet initialization on a blocking thread, then + /// follow up with note sync + nullifier check. Tracked via `subtasks` + /// so it participates in graceful shutdown and cancellation. + fn queue_shielded_init_and_sync(self: &Arc, seed_hash: WalletSeedHash) { + let ctx = Arc::clone(self); + self.subtasks.spawn_sync("shielded_init", async move { + let ctx2 = Arc::clone(&ctx); + let init_result = + tokio::task::spawn_blocking(move || ctx2.initialize_shielded_wallet(seed_hash)) + .await; + match init_result { + Ok(Ok(_)) => { + tracing::trace!( + seed = %hex::encode(seed_hash), + "Shielded wallet state initialized on unlock" + ); + ctx.run_shielded_sync(seed_hash).await; + } + Ok(Err(e)) => tracing::debug!( + seed = %hex::encode(seed_hash), + error = %e, + "Shielded wallet init skipped on unlock" + ), + Err(e) => tracing::debug!( + seed = %hex::encode(seed_hash), + error = %e, + "Shielded init task panicked" + ), + } + }); + } + + /// Run SyncNotes -> CheckNullifiers sequence on a blocking thread. + async fn run_shielded_sync(self: &Arc, seed_hash: WalletSeedHash) { + let ctx = Arc::clone(self); + let handle = tokio::runtime::Handle::current(); + let result = tokio::task::spawn_blocking(move || { + handle.block_on(async { + match ctx.sync_shielded_notes(seed_hash).await { + Ok(_) => { + if let Err(e) = ctx.check_nullifiers_task(seed_hash).await { + tracing::debug!( + seed = %hex::encode(seed_hash), + error = %e, + "Shielded nullifier check after init failed" + ); + } + } + Err(e) => tracing::debug!( + seed = %hex::encode(seed_hash), + error = %e, + "Shielded note sync after init failed" + ), + } + }) + }) + .await; + if let Err(e) = result { + tracing::debug!( + seed = %hex::encode(seed_hash), + error = %e, + "Shielded sync task panicked" + ); + } + } + fn wallet_seed_snapshot(wallet: &Arc>) -> Option<(WalletSeedHash, [u8; 64])> { let guard = wallet.read().ok()?; if !guard.is_open() { @@ -841,7 +921,7 @@ impl AppContext { net_amount: record.net_amount, fee: record.fee, label: record.label.clone(), - is_ours: record.is_ours, + is_ours: spv_is_ours_override(record.is_ours, record.net_amount), status, } }) @@ -889,3 +969,51 @@ impl AppContext { self.connection_status.reset_timer(); } } + +/// SPV transaction history is per-wallet — all entries involve our addresses +/// (they passed bloom filter + `check_transaction()` address matching). +/// Upstream sets `is_ours` only for sends (`net_amount < 0`); we override +/// to `true` for all matched transactions since address ownership was +/// already verified by the SPV layer. +/// +/// Bloom filter false positives are filtered by `check_transaction()` before +/// records reach this point, so the override is safe. Testing actual bloom +/// filter FP behavior would require mocking the SPV layer's bloom filter, +/// which is out of scope for unit tests. +fn spv_is_ours_override(upstream_is_ours: bool, net_amount: i64) -> bool { + if !upstream_is_ours && net_amount >= 0 { + tracing::debug!( + net_amount, + "SPV: overriding is_ours to true for receive transaction" + ); + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_ours_override_true_for_outgoing_already_ours() { + assert!(spv_is_ours_override(true, -50_000)); + } + + #[test] + fn is_ours_override_true_for_incoming_not_ours() { + // Upstream marks receive transactions as !is_ours — we override. + assert!(spv_is_ours_override(false, 100_000)); + } + + #[test] + fn is_ours_override_true_for_zero_amount_not_ours() { + // Edge case: net_amount == 0 (e.g. self-transfer minus fee) + assert!(spv_is_ours_override(false, 0)); + } + + #[test] + fn is_ours_override_true_for_outgoing_not_ours() { + // Even if upstream says !is_ours for a send, we override. + assert!(spv_is_ours_override(false, -10_000)); + } +} diff --git a/src/database/mod.rs b/src/database/mod.rs index 4fc1dcdbf..a2af9431c 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -81,110 +81,115 @@ impl Database { /// Removes all application data tied to a specific Dash network. pub fn clear_network_data(&self, network: Network) -> rusqlite::Result<()> { let network_str = network.to_string(); - let mut conn = self.conn.lock().unwrap(); - let tx = conn.transaction()?; - - // Remove DashPay/contact data referencing identities from this network. - tx.execute( - "DELETE FROM dashpay_payments - WHERE from_identity_id IN (SELECT id FROM identity WHERE network = ?1) - OR to_identity_id IN (SELECT id FROM identity WHERE network = ?1)", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM dashpay_contact_requests - WHERE from_identity_id IN (SELECT id FROM identity WHERE network = ?1) - OR to_identity_id IN (SELECT id FROM identity WHERE network = ?1)", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM dashpay_contacts - WHERE owner_identity_id IN (SELECT id FROM identity WHERE network = ?1) - OR contact_identity_id IN (SELECT id FROM identity WHERE network = ?1)", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM contact_private_info - WHERE owner_identity_id IN (SELECT id FROM identity WHERE network = ?1) - OR contact_identity_id IN (SELECT id FROM identity WHERE network = ?1)", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM dashpay_profiles - WHERE identity_id IN (SELECT id FROM identity WHERE network = ?1)", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM identity_token_balances WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM token WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM contract WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM scheduled_votes WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM wallet_transactions WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM utxos WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM asset_lock_transaction WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM contestant WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM contested_name WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM identity WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM wallet WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM single_key_wallet WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM shielded_notes WHERE network = ?1", - rusqlite::params![&network_str], - )?; - - tx.commit()?; + + // Scope the connection lock so it's released before + // clear_commitment_tree_tables acquires it again. + { + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + + // Remove DashPay/contact data referencing identities from this network. + tx.execute( + "DELETE FROM dashpay_payments + WHERE from_identity_id IN (SELECT id FROM identity WHERE network = ?1) + OR to_identity_id IN (SELECT id FROM identity WHERE network = ?1)", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM dashpay_contact_requests + WHERE from_identity_id IN (SELECT id FROM identity WHERE network = ?1) + OR to_identity_id IN (SELECT id FROM identity WHERE network = ?1)", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM dashpay_contacts + WHERE owner_identity_id IN (SELECT id FROM identity WHERE network = ?1) + OR contact_identity_id IN (SELECT id FROM identity WHERE network = ?1)", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM contact_private_info + WHERE owner_identity_id IN (SELECT id FROM identity WHERE network = ?1) + OR contact_identity_id IN (SELECT id FROM identity WHERE network = ?1)", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM dashpay_profiles + WHERE identity_id IN (SELECT id FROM identity WHERE network = ?1)", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM identity_token_balances WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM token WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM contract WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM scheduled_votes WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM wallet_transactions WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM utxos WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM asset_lock_transaction WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM contestant WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM contested_name WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM identity WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM wallet WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM single_key_wallet WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM shielded_notes WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.commit()?; + } // conn lock released here // Commitment tree tables are optional (created lazily by grovedb). // Log and continue if clearing them fails — the main network data diff --git a/src/model/address.rs b/src/model/address.rs index 7e4180a1e..9f6d7bbc7 100644 --- a/src/model/address.rs +++ b/src/model/address.rs @@ -2,10 +2,27 @@ use dash_sdk::dashcore_rpc::dashcore::Address; #[cfg(test)] use dash_sdk::dashcore_rpc::dashcore::Network; use dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked; -use dash_sdk::dpp::address_funds::PlatformAddress; +use dash_sdk::dpp::address_funds::{PLATFORM_HRP_MAINNET, PLATFORM_HRP_TESTNET, PlatformAddress}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::Identifier; +/// Checks if a string looks like a Platform address (bech32m with dash/tdash HRP per DIP-18). +/// +/// This checks whether the string starts with a known Platform HRP followed by the +/// bech32 separator '1'. It does NOT fully validate the address — use +/// `PlatformAddress::from_bech32m_string()` for that. +pub fn is_platform_address_string(s: &str) -> bool { + for hrp in [PLATFORM_HRP_MAINNET, PLATFORM_HRP_TESTNET] { + if s.len() > hrp.len() + && s[..hrp.len()].eq_ignore_ascii_case(hrp) + && s.as_bytes()[hrp.len()] == b'1' + { + return true; + } + } + false +} + /// Classification of a Dash address for filtering and display purposes. /// /// This enum represents the four recognized address categories. It is used @@ -72,18 +89,36 @@ impl AddressKind { } // 2. Platform (Bech32m per DIP-18, but NOT shielded — already excluded above) - if crate::ui::helpers::is_platform_address_string(trimmed) { + if is_platform_address_string(trimmed) { return Some(AddressKind::Platform); } - // 3. Core (Base58Check) - if trimmed.parse::>().is_ok() { - return Some(AddressKind::Core); - } + // 3 & 4. Core vs Identity disambiguation. + // + // Both Core addresses and Identity IDs use Base58. Core addresses + // on Dash always start with X/Y (mainnet) or y/8/7 (testnet). + // If the input starts with a known Core prefix, try Core first. + // Otherwise try Identity first to avoid misclassifying IDs as + // Core addresses (they share the Base58 alphabet). + let core_prefix = matches!( + trimmed.as_bytes().first(), + Some(b'X' | b'Y' | b'y' | b'8' | b'7') + ); - // 4. Identity (Base58 fallback) - if Identifier::from_string(trimmed, Encoding::Base58).is_ok() { - return Some(AddressKind::Identity); + if core_prefix { + if trimmed.parse::>().is_ok() { + return Some(AddressKind::Core); + } + if Identifier::from_string(trimmed, Encoding::Base58).is_ok() { + return Some(AddressKind::Identity); + } + } else { + if Identifier::from_string(trimmed, Encoding::Base58).is_ok() { + return Some(AddressKind::Identity); + } + if trimmed.parse::>().is_ok() { + return Some(AddressKind::Core); + } } None @@ -198,6 +233,24 @@ impl std::fmt::Display for ValidatedAddress { } } +/// Truncate an address string for display, showing a prefix and suffix +/// separated by an ellipsis. +/// +/// Assumes ASCII input (Base58 and Bech32/Bech32m addresses are always ASCII). +/// Addresses shorter than `prefix_len + suffix_len + 3` characters are returned +/// unchanged (truncation would not save space). +pub fn truncate_address(addr: &str, prefix_len: usize, suffix_len: usize) -> String { + let min_useful = prefix_len + suffix_len + 3; // 3 for "..." + if addr.len() < min_useful { + return addr.to_string(); + } + format!( + "{}...{}", + &addr[..prefix_len], + &addr[addr.len() - suffix_len..] + ) +} + #[cfg(test)] mod tests { use super::*; @@ -297,15 +350,49 @@ mod tests { } #[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) == Some(AddressKind::Core) { - return; + fn detect_identity_base58() { + // Identity IDs that don't start with a Core prefix (X/Y/y/8/7) + // should always detect as Identity, not Core. + for _ in 0..20 { + let id = Identifier::random(); + let id_str = id.to_string(Encoding::Base58); + let first = id_str.as_bytes()[0]; + if matches!(first, b'X' | b'Y' | b'y' | b'8' | b'7') { + // Core prefix — detection correctly prefers Core. Skip. + continue; + } + assert_eq!( + AddressKind::detect(&id_str), + Some(AddressKind::Identity), + "Non-Core-prefix identifier {id_str} should detect as Identity" + ); + } + } + + #[test] + fn detect_identity_with_core_prefix_still_works_when_not_valid_core() { + // An Identity ID that happens to start with a Core prefix but + // doesn't pass Core address parsing should still detect as Identity. + // We test this by creating identifiers until we find one starting + // with a Core prefix that isn't a valid Core address. + for _ in 0..100 { + let id = Identifier::random(); + let id_str = id.to_string(Encoding::Base58); + let first = id_str.as_bytes()[0]; + if !matches!(first, b'X' | b'Y' | b'y' | b'8' | b'7') { + continue; + } + // Has Core prefix — if it doesn't parse as Core, it should be Identity + if id_str.parse::>().is_err() { + assert_eq!( + AddressKind::detect(&id_str), + Some(AddressKind::Identity), + "Core-prefix identifier {id_str} that fails Core parse should detect as Identity" + ); + return; + } } - assert_eq!(AddressKind::detect(&id_str), Some(AddressKind::Identity)); + // If all 100 parsed as valid Core, that's fine — test is probabilistic } #[test] diff --git a/src/ui/components/address_input.rs b/src/ui/components/address_input.rs index 3e1d84c39..f4ea72330 100644 --- a/src/ui/components/address_input.rs +++ b/src/ui/components/address_input.rs @@ -1,7 +1,7 @@ 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::model::wallet::{DerivationPathHelpers, Wallet}; use crate::ui::components::{Component, ComponentResponse}; use crate::ui::theme::DashColors; use dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked; @@ -51,6 +51,12 @@ struct AddressEntry { balance: u64, /// Pre-built ValidatedAddress for immediate use on selection. validated: ValidatedAddress, + /// Whether this is a change address (BIP44 m/44'/5'/0'/1/x). + /// Only meaningful for Core addresses; always false for other types. + /// Stored for potential future use in display styling; the "(change)" + /// suffix is already baked into `display_label` at construction time. + #[allow(dead_code)] + is_change: bool, } /// Concrete balance range bounds. @@ -152,6 +158,7 @@ pub struct AddressInput { desired_width: Option, show_validation_errors: bool, balance_range: Option, + exclude_change: bool, // --- Autocomplete data (set via builder, read each frame) --- all_entries: Vec, @@ -194,6 +201,7 @@ impl AddressInput { selected_from_autocomplete: false, cached_detection: None, changed: false, + exclude_change: false, } } @@ -238,12 +246,23 @@ impl AddressInput { /// Filter autocomplete entries by balance range (in native units). /// - /// Does not affect manual input validation. Default: no filter. + /// All known wallet addresses are included by default (including zero-balance). + /// Use `with_balance_range(1..)` to show only funded addresses. + /// Does not affect manual input validation. Default: no filter (all addresses). pub fn with_balance_range(mut self, range: impl std::ops::RangeBounds) -> Self { self.balance_range = Some(BalanceRange::from_range(&range)); self } + /// Exclude change addresses (BIP44 m/44'/5'/0'/1/x) from autocomplete. + /// + /// Send inputs should typically exclude change addresses since users + /// don't share change addresses with others. Default: false (show all). + pub fn with_exclude_change(mut self, exclude: bool) -> Self { + self.exclude_change = exclude; + 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; @@ -348,13 +367,41 @@ impl AddressInput { String::new() }; - // Core addresses from address_balances - for (address, &balance) in &guard.address_balances { + // Build a set of system addresses to exclude from autocomplete. + // System addresses (Identity Registration, CoinJoin, Provider keys, etc.) + // are internal wallet infrastructure — not for user-facing send/receive. + use crate::ui::wallets::account_summary::AccountCategory; + let system_addresses: std::collections::HashSet<&Address> = guard + .watched_addresses + .values() + .filter(|info| { + AccountCategory::from_reference(info.path_reference).is_system_category() + }) + .map(|info| &info.address) + .collect(); + + // Core addresses from known_addresses (all derived addresses). + // Balance is looked up from address_balances; addresses without UTXOs + // get balance 0. Use `with_balance_range(1..)` to show only funded + // addresses — do NOT filter at the data source. + // Change addresses (BIP44 m/44'/5'/0'/1/x) are tagged and can be + // excluded via `with_exclude_change(true)`. + // System addresses are always excluded. + for (address, derivation_path) in &guard.known_addresses { + if system_addresses.contains(address) { + continue; + } + let is_change = derivation_path.is_bip44_change(self.network); + if self.exclude_change && is_change { + continue; + } + let balance = guard.address_balances.get(address).copied().unwrap_or(0); let addr_str = address.to_string(); + let change_suffix = if is_change { " (change)" } else { "" }; let display = if self.full_addresses { - format!("{}{}", prefix, addr_str) + format!("{}{}{}", prefix, addr_str, change_suffix) } else { - format!("{}{}", prefix, truncate_address(&addr_str)) + format!("{}{}{}", prefix, truncate_address(&addr_str), change_suffix) }; self.all_entries.push(AddressEntry { address_string: addr_str, @@ -362,13 +409,31 @@ impl AddressInput { display_label: display, balance, validated: ValidatedAddress::Core(address.clone()), + is_change, }); } - // Platform addresses from platform_address_info - for (core_addr, info) in &guard.platform_address_info { + // Platform addresses: derive from watched_addresses (all bootstrapped + // platform payment addresses), with balance from platform_address_info. + // This ensures fresh wallets with no on-chain activity still show + // their derived platform addresses. + use crate::model::wallet::DerivationPathReference; + let mut seen_platform = std::collections::HashSet::new(); + for addr_info in guard.watched_addresses.values() { + if addr_info.path_reference != DerivationPathReference::PlatformPayment { + continue; + } + let core_addr = &addr_info.address; if let Ok(platform_addr) = PlatformAddress::try_from(core_addr.clone()) { let addr_str = platform_addr.to_bech32m_string(self.network); + if !seen_platform.insert(addr_str.clone()) { + continue; + } + let balance = guard + .platform_address_info + .get(core_addr) + .map(|info| info.balance) + .unwrap_or(0); let display = if self.full_addresses { format!("{}{}", prefix, addr_str) } else { @@ -379,11 +444,12 @@ impl AddressInput { address_string: addr_str, address_kind: AddressKind::Platform, display_label: display, - balance: info.balance, + balance, validated: ValidatedAddress::Platform { address: platform_addr, bech32m, }, + is_change: false, }); } } @@ -412,6 +478,7 @@ impl AddressInput { id, dpns_name: dpns_name.clone(), }, + is_change: false, }); } } @@ -428,6 +495,7 @@ impl AddressInput { display_label: display, balance, validated: ValidatedAddress::Shielded(address), + is_change: false, }); } @@ -465,7 +533,9 @@ impl AddressInput { if detected == DetectedType::Unknown { return ( - Some("This does not look like a valid address.".to_string()), + Some( + "This does not look like a valid address. Please check for typos.".to_string(), + ), None, ); } @@ -499,12 +569,12 @@ impl AddressInput { 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()), + Some("This address belongs to a different network. Please check you are using the correct network.".to_string()), None, ), }, Err(_) => ( - Some("This does not look like a valid address.".to_string()), + Some("This does not look like a valid address. Please check for typos.".to_string()), None, ), } @@ -517,7 +587,7 @@ impl AddressInput { if !is_lower && !is_upper { return ( Some( - "Platform addresses must not mix upper and lower case characters.".to_string(), + "Platform addresses must not mix upper and lower case characters. Please use all lowercase.".to_string(), ), None, ); @@ -531,7 +601,7 @@ impl AddressInput { || canonical.starts_with(&format!("{}z", expected_prefix)) { return ( - Some("This address belongs to a different network.".to_string()), + Some("This address belongs to a different network. Please check you are using the correct network.".to_string()), None, ); } @@ -544,7 +614,9 @@ impl AddressInput { }), ), Err(_) => ( - Some("This does not look like a valid address.".to_string()), + Some( + "This does not look like a valid address. Please check for typos.".to_string(), + ), None, ), } @@ -557,7 +629,7 @@ impl AddressInput { }; if !trimmed.starts_with(expected_prefix) { return ( - Some("This address belongs to a different network.".to_string()), + Some("This address belongs to a different network. Please check you are using the correct network.".to_string()), None, ); } @@ -574,11 +646,14 @@ impl AddressInput { 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) - { + // Shielded addresses only encode mainnet vs non-mainnet in the HRP. + // Testnet, Devnet, and Local all share "tdash1z" and cannot be + // distinguished at the address level. Enforce mainnet isolation only. + let same_mainnet_class = + (self.network == Network::Mainnet) == (network == Network::Mainnet); + if !same_mainnet_class { ( - Some("This address belongs to a different network.".to_string()), + Some("This address belongs to a different network. Please check you are using the correct network.".to_string()), None, ) } else { @@ -617,7 +692,9 @@ impl AddressInput { ) } Err(_) => ( - Some("This does not look like a valid address.".to_string()), + Some( + "This does not look like a valid address. Please check for typos.".to_string(), + ), None, ), } @@ -654,9 +731,15 @@ impl AddressInput { if query.is_empty() { return true; } - // Substring match against address and label + // Substring match against address, label, and type name. + // Typing "platform" or "core" filters to that address type. e.address_string.to_lowercase().contains(&query) || e.display_label.to_lowercase().contains(&query) + || e.address_kind.short_label().to_lowercase().contains(&query) + || e.address_kind + .display_name() + .to_lowercase() + .contains(&query) }) .collect(); @@ -796,15 +879,11 @@ impl AddressInput { // 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| { - let label = if show_type_suffix { - format!("{} ({})", e.display_label, e.address_kind.short_label()) - } else { - e.display_label.clone() - }; + let label = + format!("{} ({})", e.display_label, e.address_kind.short_label()); (label, self.format_balance(e), (*e).clone()) }) .collect(); @@ -1032,21 +1111,9 @@ fn detect_address_type(input: &str, identity_enabled: bool) -> DetectedType { } } -/// Truncate an address string for display, showing prefix and suffix. +/// Truncate an address for display in the address input component (8 prefix + 6 suffix). fn truncate_address(addr: &str) -> String { - 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}") + crate::model::address::truncate_address(addr, 8, 6) } #[cfg(test)] @@ -1163,7 +1230,9 @@ mod tests { assert!(val.is_none()); assert_eq!( err.as_deref(), - Some("This address belongs to a different network.") + Some( + "This address belongs to a different network. Please check you are using the correct network." + ) ); } @@ -1184,7 +1253,9 @@ mod tests { assert!(val.is_none()); assert_eq!( err.as_deref(), - Some("This address belongs to a different network.") + Some( + "This address belongs to a different network. Please check you are using the correct network." + ) ); } @@ -1195,7 +1266,9 @@ mod tests { assert!(val.is_none()); assert_eq!( err.as_deref(), - Some("This address belongs to a different network.") + Some( + "This address belongs to a different network. Please check you are using the correct network." + ) ); } @@ -1313,7 +1386,7 @@ mod tests { assert!(val.is_none()); assert_eq!( err.as_deref(), - Some("This does not look like a valid address.") + Some("This does not look like a valid address. Please check for typos.") ); } @@ -1492,7 +1565,9 @@ mod tests { 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.") + Some( + "Platform addresses must not mix upper and lower case characters. Please use all lowercase." + ) ); } @@ -1503,7 +1578,9 @@ mod tests { let (err, _) = input.validate_platform("tdash1qwer1234"); assert_ne!( err.as_deref(), - Some("Platform addresses must not mix upper and lower case characters."), + Some( + "Platform addresses must not mix upper and lower case characters. Please use all lowercase." + ), "all-lowercase should pass the case check" ); } @@ -1515,7 +1592,9 @@ mod tests { let (err, _) = input.validate_platform("TDASH1QWER1234"); assert_ne!( err.as_deref(), - Some("Platform addresses must not mix upper and lower case characters."), + Some( + "Platform addresses must not mix upper and lower case characters. Please use all lowercase." + ), "all-uppercase should pass the case check" ); } diff --git a/src/ui/helpers.rs b/src/ui/helpers.rs index 7b4224718..a522eb721 100644 --- a/src/ui/helpers.rs +++ b/src/ui/helpers.rs @@ -1,7 +1,9 @@ use crate::ui::theme::ResponseExt; -use dash_sdk::dpp::address_funds::{PLATFORM_HRP_MAINNET, PLATFORM_HRP_TESTNET}; use std::sync::Arc; +// Re-export from the model layer so existing callers don't break. +pub use crate::model::address::is_platform_address_string; + /// Returns true if the user left-clicked outside the given window rect this frame. /// Use after painting a modal overlay and showing the dialog window. pub fn clicked_outside_window(ctx: &egui::Context, window_rect: egui::Rect) -> bool { @@ -13,21 +15,6 @@ pub fn clicked_outside_window(ctx: &egui::Context, window_rect: egui::Rect) -> b }) } -/// Checks if a string looks like a Platform address (bech32m with dash/tdash HRP per DIP-18). -/// -/// This checks whether the string starts with a known Platform HRP followed by the -/// bech32 separator '1'. It does NOT fully validate the address — use -/// `PlatformAddress::from_bech32m_string()` for that. -pub fn is_platform_address_string(s: &str) -> bool { - let s = s.to_lowercase(); - for hrp in [PLATFORM_HRP_MAINNET, PLATFORM_HRP_TESTNET] { - if s.starts_with(hrp) && s.get(hrp.len()..hrp.len() + 1) == Some("1") { - return true; - } - } - false -} - use crate::{ app::AppAction, context::AppContext, diff --git a/src/ui/identities/transfer_screen.rs b/src/ui/identities/transfer_screen.rs index 7eac3f453..b22ede8f1 100644 --- a/src/ui/identities/transfer_screen.rs +++ b/src/ui/identities/transfer_screen.rs @@ -121,6 +121,10 @@ impl TransferScreen { } } + pub(crate) fn invalidate_address_input(&mut self) { + self.platform_address_input.clear(); + } + fn render_key_selection(&mut self, ui: &mut Ui) -> AppAction { add_key_chooser( ui, diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 502a0b4f1..1165e9e3a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -803,7 +803,10 @@ impl Screen { screen.app_context = app_context; screen.reset_core_wallets_cache(); } - Screen::TransferScreen(screen) => screen.app_context = app_context, + Screen::TransferScreen(screen) => { + screen.app_context = app_context; + screen.invalidate_address_input(); + } Screen::TopUpIdentityScreen(screen) => screen.app_context = app_context, Screen::WalletsBalancesScreen(screen) => { screen.app_context = app_context; @@ -836,7 +839,10 @@ impl Screen { Screen::DocumentVisualizerScreen(screen) => screen.app_context = app_context, Screen::PlatformInfoScreen(screen) => screen.app_context = app_context, Screen::GroveSTARKScreen(screen) => screen.app_context = app_context, - Screen::AddressBalanceScreen(screen) => screen.app_context = app_context, + Screen::AddressBalanceScreen(screen) => { + screen.app_context = app_context; + screen.invalidate_address_input(); + } // Token Screens Screen::TokensScreen(screen) => screen.app_context = app_context, @@ -875,7 +881,10 @@ impl Screen { // Shielded screens Screen::ShieldCreditsScreen(screen) => screen.app_context = app_context.clone(), Screen::ShieldFromAssetLockScreen(screen) => screen.app_context = app_context.clone(), - Screen::ShieldedSendScreen(screen) => screen.app_context = app_context.clone(), + Screen::ShieldedSendScreen(screen) => { + screen.app_context = app_context.clone(); + screen.invalidate_address_input(); + } Screen::UnshieldCreditsScreen(screen) => { screen.app_context = app_context.clone(); screen.invalidate_address_input(); diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index 7e70f8822..175fcc639 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -415,14 +415,18 @@ impl NetworkChooserScreen { { app_action = AppAction::SwitchNetwork(Network::Regtest); } - if self.current_network != prev_network - && let Ok(config) = - Config::load_from(&self.mainnet_app_context.data_dir) - && let Some(network_config) = - config.config_for_network(self.current_network) - { - self.dashmate_password_input - .set_text(network_config.core_rpc_password.clone()); + if self.current_network != prev_network { + let password = Config::load_from( + &self.mainnet_app_context.data_dir, + ) + .ok() + .and_then(|c| { + c.config_for_network(self.current_network) + .as_ref() + .map(|nc| nc.core_rpc_password.clone()) + }) + .unwrap_or_default(); + self.dashmate_password_input.set_text(password); } }); }); diff --git a/src/ui/tools/address_balance_screen.rs b/src/ui/tools/address_balance_screen.rs index 165aeccae..ef85636fe 100644 --- a/src/ui/tools/address_balance_screen.rs +++ b/src/ui/tools/address_balance_screen.rs @@ -35,6 +35,11 @@ impl AddressBalanceScreen { } } + pub(crate) fn invalidate_address_input(&mut self) { + self.address_input.clear(); + self.result = None; + } + fn trigger_fetch(&mut self) -> AppAction { let address = self.address_input.trim().to_string(); if address.is_empty() { diff --git a/src/ui/wallets/account_summary.rs b/src/ui/wallets/account_summary.rs index 7dbd44864..9f1e7edc8 100644 --- a/src/ui/wallets/account_summary.rs +++ b/src/ui/wallets/account_summary.rs @@ -54,7 +54,7 @@ impl AccountCategory { pub fn label(&self, index: Option) -> String { match self { AccountCategory::Bip44 => match index { - Some(0) => "Main Account".to_string(), + Some(0) => "Dash Core".to_string(), Some(idx) => format!("BIP44 Account #{}", idx), None => "BIP44 Account".to_string(), }, @@ -71,7 +71,7 @@ impl AccountCategory { AccountCategory::ProviderOwner => "Provider Owner".to_string(), AccountCategory::ProviderOperator => "Provider Operator".to_string(), AccountCategory::ProviderPlatform => "Provider Platform".to_string(), - AccountCategory::PlatformPayment => "Platform Account".to_string(), + AccountCategory::PlatformPayment => "Platform".to_string(), AccountCategory::Other(reference) => format!("{:?}", reference), } } @@ -136,22 +136,41 @@ impl AccountCategory { } } - /// Returns true if this account category is primarily used for key - /// derivation and proofs rather than holding funds. Used for account - /// dropdown label formatting. - pub fn is_key_only(&self) -> bool { + /// Returns a short label suitable for tab headers. + pub fn tab_label(&self, index: Option) -> &'static str { + match self { + AccountCategory::Bip44 => match index { + Some(0) => "Dash Core", + _ => "BIP44", + }, + AccountCategory::Bip32 => "Legacy BIP32", + AccountCategory::CoinJoin => "CoinJoin", + AccountCategory::IdentityRegistration => "Identity Registration", + AccountCategory::IdentitySystem => "Identity System", + AccountCategory::IdentityTopup => "Identity Top-up", + AccountCategory::IdentityInvitation => "Identity Invitation", + AccountCategory::ProviderVoting + | AccountCategory::ProviderOwner + | AccountCategory::ProviderOperator + | AccountCategory::ProviderPlatform => "Provider", + AccountCategory::PlatformPayment => "Platform", + AccountCategory::Other(_) => "Other", + } + } + + /// Whether this account tab is visible in default (non-developer) mode. + pub fn is_visible_in_default_mode(&self) -> bool { matches!( self, - AccountCategory::IdentityRegistration - | AccountCategory::IdentityTopup - | AccountCategory::IdentityInvitation - | AccountCategory::IdentitySystem - | AccountCategory::ProviderVoting - | AccountCategory::ProviderOwner - | AccountCategory::ProviderOperator - | AccountCategory::ProviderPlatform + AccountCategory::Bip44 | AccountCategory::PlatformPayment ) } + + /// Returns true if this is a "system" account category shown only in + /// developer mode under the consolidated System tab. + pub fn is_system_category(&self) -> bool { + !self.is_visible_in_default_mode() + } } pub(crate) fn categorize_account_path( @@ -180,7 +199,6 @@ pub(crate) fn categorize_account_path( #[derive(Clone, Debug)] pub struct AccountSummary { pub category: AccountCategory, - pub label: String, pub index: Option, pub confirmed_balance: u64, /// Platform credits balance for Platform Payment addresses @@ -214,11 +232,8 @@ impl AccountSummaryBuilder { } fn build(self) -> AccountSummary { - let label = self.key.category.label(self.key.index); - AccountSummary { category: self.key.category, - label, index: self.key.index, confirmed_balance: self.confirmed_balance, platform_credits: self.platform_credits, @@ -273,7 +288,7 @@ mod tests { use dash_sdk::dpp::key_wallet::bip32::ChildNumber; #[test] - fn bip44_without_account_index_is_not_main_account() { + fn bip44_without_account_index_is_not_dash_core() { assert_eq!(AccountCategory::Bip44.label(None), "BIP44 Account"); } diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 45c476a86..f259ad195 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -471,6 +471,7 @@ impl WalletSendScreen { /// Clear the AddressInput widget so it picks up the new network on next frame. pub(crate) fn invalidate_address_input(&mut self) { self.address_input = None; + self.validated_destination = None; } fn reset_form(&mut self) { @@ -1506,7 +1507,8 @@ impl WalletSendScreen { let mut builder = AddressInput::new(self.app_context.network) .with_label("Send to") .with_hint_text("Enter address (X.../y.../dash1.../tdash1...)") - .with_address_kinds(&allowed_kinds); + .with_address_kinds(&allowed_kinds) + .with_exclude_change(true); // Provide all wallet addresses for autocomplete if let Ok(wallets_guard) = self.app_context.wallets.read() { diff --git a/src/ui/wallets/shielded_send_screen.rs b/src/ui/wallets/shielded_send_screen.rs index b23408981..cf6b4263f 100644 --- a/src/ui/wallets/shielded_send_screen.rs +++ b/src/ui/wallets/shielded_send_screen.rs @@ -64,6 +64,10 @@ impl ShieldedSendScreen { } } + pub(crate) fn invalidate_address_input(&mut self) { + self.recipient_address_input.clear(); + } + fn validate_recipient(&self) -> Option> { let trimmed = self.recipient_address_input.trim(); if trimmed.is_empty() { @@ -243,6 +247,12 @@ impl ScreenLike for ShieldedSendScreen { new_notes, balance, ); + self.max_balance = balance; + self.balance_update_pending = false; + let dash = balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; + if let Some(msg) = self.success_message.as_mut() { + *msg = format!("{}\nBalance updated: {:.8} DASH remaining.", msg, dash,); + } } _ => {} } diff --git a/src/ui/wallets/shielded_tab.rs b/src/ui/wallets/shielded_tab.rs index c7631c1b0..db916a140 100644 --- a/src/ui/wallets/shielded_tab.rs +++ b/src/ui/wallets/shielded_tab.rs @@ -4,9 +4,7 @@ use crate::backend_task::shielded::ShieldedTask; use crate::context::AppContext; use crate::model::wallet::WalletSeedHash; use crate::ui::ScreenType; -use crate::ui::components::wallet_unlock_popup::{ - WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, -}; +use crate::ui::components::wallet_unlock_popup::wallet_needs_unlock; use crate::ui::helpers::copy_text_to_clipboard; use crate::ui::theme::DashColors; use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; @@ -26,12 +24,8 @@ pub struct ShieldedTabView { is_initialized: bool, /// Whether the commitment tree has been synced (enables spend operations). tree_synced: bool, - /// Pending backend task to dispatch on next ui() call (e.g., auto-sync after init). + /// Pending backend task to dispatch on next ui() call (e.g., sync after Resync). pending_task: Option, - /// Wallet unlock popup for the initialize flow. - wallet_unlock_popup: WalletUnlockPopup, - /// Currently selected diversified address index. - selected_address_index: u32, /// Number of diversified addresses generated (always >= 1). address_count: u32, } @@ -49,8 +43,6 @@ impl ShieldedTabView { is_initialized: false, tree_synced: false, pending_task: None, - wallet_unlock_popup: WalletUnlockPopup::new(), - selected_address_index: 0, address_count: 1, } } @@ -74,6 +66,147 @@ impl ShieldedTabView { self.app_context = app_context.clone(); } + /// Drain pending backend tasks (from explicit user actions like Resync). + /// Initialization is handled entirely by the backend in + /// `handle_wallet_unlocked` — the UI never triggers it. + pub fn tick(&mut self) -> AppAction { + self.refresh_from_backend_state(); + + self.pending_task + .take() + .map(AppAction::BackendTask) + .unwrap_or(AppAction::None) + } + + /// Sync local display state from `AppContext::shielded_states`. + fn refresh_from_backend_state(&mut self) { + if let Ok(states) = self.app_context.shielded_states.lock() + && let Some(state) = states.get(&self.seed_hash) + { + self.is_initialized = true; + self.shielded_balance = state.shielded_balance; + // The background sync chain (SyncNotes -> CheckNullifiers) runs + // outside the UI task system. Derive tree_synced from state so + // spend buttons become enabled after the backend finishes. + if state.last_notes_synced_at.is_some() { + self.tree_synced = true; + } + if state.last_nullifiers_synced_at.is_some() { + self.syncing = false; + } + } + } + + /// Render the collapsible shielded addresses section with a table of all + /// diversified addresses. + fn render_address_section(&mut self, ui: &mut Ui, dark_mode: bool) { + let dev_mode = self.app_context.is_developer_mode(); + + let header = egui::CollapsingHeader::new( + RichText::new("Shielded Addresses") + .size(16.0) + .color(DashColors::text_primary(dark_mode)), + ) + .id_salt("shielded_addresses") + .default_open(dev_mode); + + header.show(ui, |ui| { + ui.horizontal(|ui| { + if ui + .small_button("+") + .on_hover_text("Generate new diversified address") + .clicked() + { + self.address_count += 1; + } + }); + + ui.add_space(4.0); + + // Collect all addresses for the table + let addresses: Vec<(u32, String)> = { + let Ok(states) = self.app_context.shielded_states.lock() else { + ui.label( + RichText::new("Unable to read shielded state.") + .color(DashColors::text_secondary(dark_mode)), + ); + return; + }; + if let Some(state) = states.get(&self.seed_hash) { + (0..self.address_count) + .filter_map(|idx| { + use dash_sdk::dpp::address_funds::OrchardAddress; + use dash_sdk::grovedb_commitment_tree::Scope; + let addr = state.keys.fvk.address_at(idx, Scope::External); + let raw = addr.to_raw_address_bytes(); + let orchard_addr = OrchardAddress::from_raw_bytes(&raw).ok()?; + Some(( + idx, + orchard_addr.to_bech32m_string(self.app_context.network), + )) + }) + .collect() + } else { + vec![] + } + }; + + if addresses.is_empty() { + ui.label( + RichText::new("No addresses generated yet.") + .color(DashColors::text_secondary(dark_mode)), + ); + return; + } + + egui::Grid::new("shielded_addresses_grid") + .num_columns(4) + .striped(true) + .spacing([20.0, 4.0]) + .show(ui, |ui| { + ui.label(RichText::new("Index").strong()); + ui.label(RichText::new("Address").strong()); + ui.label(RichText::new("Status").strong()); + ui.label(""); // Copy column header + ui.end_row(); + + for (idx, full_addr) in &addresses { + // Index column + if *idx == 0 { + ui.label("0 (Default)"); + } else { + ui.label(idx.to_string()); + } + + // Address column: truncated, clickable to copy + let truncated = truncate_address(full_addr); + let addr_response = ui.add( + egui::Label::new(RichText::new(&truncated).monospace()) + .sense(egui::Sense::click()), + ); + if addr_response.clicked() { + let _ = copy_text_to_clipboard(full_addr); + } + addr_response.on_hover_text(full_addr.as_str()); + + // Status column + if *idx == 0 { + ui.label("Default"); + } else { + ui.label(""); + } + + // Copy button column + if ui.small_button("Copy").clicked() { + let _ = copy_text_to_clipboard(full_addr); + } + + ui.end_row(); + } + }); + }); + } + /// Handle backend task results for shielded operations. pub fn handle_result( &mut self, @@ -87,11 +220,16 @@ impl ShieldedTabView { self.initializing = false; self.is_initialized = true; self.shielded_balance = *balance; - // Auto-sync notes after initialization - self.syncing = true; - self.pending_task = Some(BackendTask::ShieldedTask(ShieldedTask::SyncNotes { - seed_hash: self.seed_hash, - })); + // Chain SyncNotes after user-initiated Resync (the only UI + // path that dispatches InitializeShieldedWallet). + if self.syncing || self.pending_task.is_some() { + // Already in a sync flow — skip duplicate chain. + } else { + self.syncing = true; + self.pending_task = Some(BackendTask::ShieldedTask(ShieldedTask::SyncNotes { + seed_hash: self.seed_hash, + })); + } true } BackendTaskSuccessResult::ShieldedNotesSynced { @@ -146,11 +284,11 @@ impl ShieldedTabView { } if *seed_hash == self.seed_hash => { self.syncing = false; // Update balance from state after nullifier check - let states = self.app_context.shielded_states.lock().unwrap(); - if let Some(state) = states.get(&self.seed_hash) { + if let Ok(states) = self.app_context.shielded_states.lock() + && let Some(state) = states.get(&self.seed_hash) + { self.shielded_balance = state.shielded_balance; } - drop(states); if *spent_count > 0 { self.success_message = Some(format!("Detected {} spent note(s)", spent_count)); } @@ -166,14 +304,17 @@ impl ShieldedTabView { self.error_message = Some(error.to_string()); } + // TODO: Redesign shielded tab layout for visual consistency with other tabs: + // 1. Action buttons row at top: Shield, Shield from Core, Transfer, Unshield + // 2. Shielded Addresses (collapsible) — diversified addresses in a table + // 3. Shielded Notes (collapsible) — notes table (index, value, spent/unspent) + // Currently the layout is: balance card -> address card -> buttons -> notes list. + // The redesign should move buttons to the top and use collapsible sections. + /// Render the shielded tab content. pub fn ui(&mut self, ui: &mut Ui) -> AppAction { let dark_mode = ui.ctx().style().visuals.dark_mode; - let mut action = self - .pending_task - .take() - .map(AppAction::BackendTask) - .unwrap_or(AppAction::None); + let mut action = self.tick(); // Messages if let Some(err) = &self.error_message.clone() { @@ -209,25 +350,9 @@ impl ShieldedTabView { } // --- Not yet initialized --- - // Auto-initialize if the wallet is already open (no user click needed) - if !self.is_initialized && !self.initializing { - let wallet_arc = { - let wallets = self.app_context.wallets.read().unwrap(); - wallets.get(&self.seed_hash).cloned() - }; - if let Some(wallet) = &wallet_arc - && !wallet_needs_unlock(wallet) - { - let _ = try_open_wallet_no_password(wallet); - self.initializing = true; - action |= AppAction::BackendTask(BackendTask::ShieldedTask( - ShieldedTask::InitializeShieldedWallet { - seed_hash: self.seed_hash, - }, - )); - } - } - + // Initialization is handled by the backend (handle_wallet_unlocked). + // If the state is not yet available, the wallet is either locked or + // init is still running — show an appropriate message. if !self.is_initialized { if self.initializing { ui.horizontal(|ui| { @@ -235,79 +360,28 @@ impl ShieldedTabView { ui.label("Initializing shielded wallet (deriving ZIP32 keys)..."); }); } else { - ui.add_space(20.0); - ui.label( - RichText::new( - "Initialize your shielded wallet to enable private transactions.", - ) - .color(DashColors::text_secondary(dark_mode)), - ); - ui.add_space(10.0); - - let init_btn = egui::Button::new( - RichText::new("Initialize Shielded Wallet") - .color(Color32::WHITE) - .size(16.0), - ) - .fill(DashColors::DASH_BLUE); - - if ui.add(init_btn).clicked() { - // Get the wallet Arc - let wallet_arc = { - let wallets = self.app_context.wallets.read().unwrap(); - wallets.get(&self.seed_hash).cloned() + let wallet_locked = { + let Some(wallets) = self.app_context.wallets.read().ok() else { + ui.label("Unable to read wallet state. Please try again."); + return action; }; - - if let Some(wallet) = &wallet_arc { - if wallet_needs_unlock(wallet) { - // Wallet is locked — open unlock popup - self.wallet_unlock_popup.open(); - } else { - // Try open without password (for passwordless wallets) - let _ = try_open_wallet_no_password(wallet); - // Proceed to initialize - self.initializing = true; - action |= AppAction::BackendTask(BackendTask::ShieldedTask( - ShieldedTask::InitializeShieldedWallet { - seed_hash: self.seed_hash, - }, - )); - } - } - } - } - - // Show unlock popup if open - if self.wallet_unlock_popup.is_open() { - let wallet_arc = { - let wallets = self.app_context.wallets.read().unwrap(); - wallets.get(&self.seed_hash).cloned() + wallets + .get(&self.seed_hash) + .is_some_and(wallet_needs_unlock) }; - - if let Some(wallet) = &wallet_arc { - let unlock_result = - self.wallet_unlock_popup - .show(ui.ctx(), wallet, &self.app_context); - match unlock_result { - WalletUnlockResult::Unlocked => { - // Wallet is now open — proceed to initialize - self.initializing = true; - action |= AppAction::BackendTask(BackendTask::ShieldedTask( - ShieldedTask::InitializeShieldedWallet { - seed_hash: self.seed_hash, - }, - )); - } - WalletUnlockResult::Cancelled => { - // User cancelled — do nothing - } - WalletUnlockResult::Pending => { - // Still showing popup - } - } + ui.add_space(20.0); + if wallet_locked { + ui.label( + RichText::new("Unlock the wallet to enable the shielded pool.") + .color(DashColors::text_secondary(dark_mode)), + ); + } else { + ui.horizontal(|ui| { + ui.add(egui::Spinner::new().color(DashColors::DASH_BLUE)); + ui.label("Preparing shielded wallet..."); + }); } } - return action; } @@ -338,67 +412,8 @@ impl ShieldedTabView { ui.add_space(10.0); - // Payment address (bech32m encoded: dash1z... or tdash1z...) - let address_str = { - let states = self.app_context.shielded_states.lock().unwrap(); - states.get(&self.seed_hash).and_then(|state| { - use dash_sdk::dpp::address_funds::OrchardAddress; - use dash_sdk::grovedb_commitment_tree::Scope; - let addr = state - .keys - .fvk - .address_at(self.selected_address_index, Scope::External); - let raw = addr.to_raw_address_bytes(); - let orchard_addr = OrchardAddress::from_raw_bytes(&raw).ok()?; - Some(orchard_addr.to_bech32m_string(self.app_context.network)) - }) - }; - - if let Some(addr) = &address_str { - Frame::new() - .fill(DashColors::surface(dark_mode)) - .inner_margin(Margin::symmetric(16, 12)) - .corner_radius(8.0) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!( - "Shielded Payment Address ({})", - self.selected_address_index - )) - .size(14.0) - .color(DashColors::text_secondary(dark_mode)), - ); - - // Address selector: prev/next arrows - if self.selected_address_index > 0 && ui.small_button("<").clicked() { - self.selected_address_index -= 1; - } - if self.selected_address_index + 1 < self.address_count - && ui.small_button(">").clicked() - { - self.selected_address_index += 1; - } - - // Generate new diversified address - if ui - .small_button("+") - .on_hover_text("Generate new diversified address") - .clicked() - { - self.selected_address_index = self.address_count; - self.address_count += 1; - } - }); - ui.add_space(4.0); - ui.horizontal(|ui| { - ui.monospace(addr); - if ui.small_button("Copy").clicked() { - let _ = copy_text_to_clipboard(addr); - } - }); - }); - } + // Shielded Addresses (collapsible table) + self.render_address_section(ui, dark_mode); ui.add_space(10.0); @@ -480,140 +495,147 @@ impl ShieldedTabView { // Notes section header with sync status and buttons let (notes_info, synced_index): (Vec<(u64, u64, bool)>, u64) = { - let states = self.app_context.shielded_states.lock().unwrap(); - states - .get(&self.seed_hash) - .map(|state| { - let notes = state - .notes - .iter() - .map(|n| (n.value, n.block_height, n.is_spent)) - .collect(); - (notes, state.last_synced_index) + self.app_context + .shielded_states + .lock() + .ok() + .and_then(|states| { + states.get(&self.seed_hash).map(|state| { + let notes = state + .notes + .iter() + .map(|n| (n.value, n.block_height, n.is_spent)) + .collect(); + (notes, state.last_synced_index) + }) }) .unwrap_or_default() }; - ui.horizontal(|ui| { - ui.label( - RichText::new("Shielded Notes") - .size(16.0) - .color(DashColors::text_primary(dark_mode)), - ); - - if !notes_info.is_empty() { - ui.label( - RichText::new(format!( - "(synced to index {}, {} our notes)", - synced_index, - notes_info.len() - )) - .size(12.0) - .color(DashColors::text_secondary(dark_mode)), - ); - } - - // Sync status indicator - if self.syncing { - ui.add(egui::Spinner::new().color(DashColors::DASH_BLUE)); - ui.label( - RichText::new("Syncing...") - .size(12.0) - .color(DashColors::DASH_BLUE), - ); - } else if self.tree_synced { - ui.label( - RichText::new("Synced") - .size(12.0) - .color(Color32::DARK_GREEN), - ); - } - - // Sync buttons - if !self.syncing { - if ui.small_button("Sync Notes").clicked() { - self.syncing = true; - self.success_message = None; - self.error_message = None; - action |= AppAction::BackendTask(BackendTask::ShieldedTask( - ShieldedTask::SyncNotes { - seed_hash: self.seed_hash, - }, - )); + // Shielded Notes (collapsible) + let notes_label = if notes_info.is_empty() { + "Shielded Notes".to_string() + } else { + format!( + "Shielded Notes (synced to index {}, {} notes)", + synced_index, + notes_info.len() + ) + }; + let notes_header = egui::CollapsingHeader::new( + RichText::new(notes_label) + .size(16.0) + .color(DashColors::text_primary(dark_mode)), + ) + .id_salt("shielded_notes") + .default_open(true); + notes_header.show(ui, |ui| { + ui.horizontal(|ui| { + // Sync status indicator + if self.syncing { + ui.add(egui::Spinner::new().color(DashColors::DASH_BLUE)); + ui.label( + RichText::new("Syncing...") + .size(12.0) + .color(DashColors::DASH_BLUE), + ); + } else if self.tree_synced { + ui.label( + RichText::new("Synced") + .size(12.0) + .color(Color32::DARK_GREEN), + ); } - if self.app_context.is_developer_mode() && ui.small_button("Resync Notes").clicked() - { - // Remove in-memory state entirely (will be recreated by init) + // Sync buttons + if !self.syncing { + if ui.small_button("Sync Notes").clicked() { + self.syncing = true; + self.success_message = None; + self.error_message = None; + action |= AppAction::BackendTask(BackendTask::ShieldedTask( + ShieldedTask::SyncNotes { + seed_hash: self.seed_hash, + }, + )); + } + + if self.app_context.is_developer_mode() + && ui.small_button("Resync Notes").clicked() { - let mut states = self.app_context.shielded_states.lock().unwrap(); - states.remove(&self.seed_hash); + if let Ok(mut states) = self.app_context.shielded_states.lock() { + states.remove(&self.seed_hash); + } + let network_str = self.app_context.network.to_string(); + let _ = self + .app_context + .db + .delete_shielded_notes(&self.seed_hash, &network_str); + let _ = self.app_context.db.clear_commitment_tree_tables(); + + self.shielded_balance = 0; + self.tree_synced = false; + self.is_initialized = false; + self.initializing = true; + self.syncing = false; + self.success_message = None; + self.error_message = None; + action |= AppAction::BackendTask(BackendTask::ShieldedTask( + ShieldedTask::InitializeShieldedWallet { + seed_hash: self.seed_hash, + }, + )); } - // Clear persisted notes and commitment tree data - let network_str = self.app_context.network.to_string(); - let _ = self - .app_context - .db - .delete_shielded_notes(&self.seed_hash, &network_str); - let _ = self.app_context.db.clear_commitment_tree_tables(); - - self.shielded_balance = 0; - self.tree_synced = false; - self.is_initialized = false; - self.initializing = true; - self.syncing = false; - self.success_message = None; - self.error_message = None; - // Re-initialize (creates fresh persistent tree) then auto-syncs - action |= AppAction::BackendTask(BackendTask::ShieldedTask( - ShieldedTask::InitializeShieldedWallet { - seed_hash: self.seed_hash, - }, - )); } - } - }); - ui.add_space(5.0); + }); + ui.add_space(5.0); - if !notes_info.is_empty() { - egui::Grid::new("shielded_notes_grid") - .num_columns(3) - .striped(true) - .spacing([20.0, 4.0]) - .show(ui, |ui| { - ui.label(RichText::new("Value").strong()); - ui.label(RichText::new("Block").strong()); - ui.label(RichText::new("Status").strong()); - ui.end_row(); + if !notes_info.is_empty() { + egui::Grid::new("shielded_notes_grid") + .num_columns(3) + .striped(true) + .spacing([20.0, 4.0]) + .show(ui, |ui| { + ui.label(RichText::new("Value").strong()); + ui.label(RichText::new("Block").strong()); + ui.label(RichText::new("Status").strong()); + ui.end_row(); - for (value, height, is_spent) in ¬es_info { - ui.label(format_credits(*value)); - ui.label(if *height > 0 { - height.to_string() - } else { - "-".to_string() - }); - if *is_spent { - ui.label( - RichText::new("Spent").color(DashColors::text_secondary(dark_mode)), - ); - } else { - ui.label(RichText::new("Unspent").color(Color32::DARK_GREEN)); + for (value, height, is_spent) in ¬es_info { + ui.label(format_credits(*value)); + ui.label(if *height > 0 { + height.to_string() + } else { + "-".to_string() + }); + if *is_spent { + ui.label( + RichText::new("Spent") + .color(DashColors::text_secondary(dark_mode)), + ); + } else { + ui.label(RichText::new("Unspent").color(Color32::DARK_GREEN)); + } + ui.end_row(); } - ui.end_row(); - } - }); - } else if !self.syncing { - ui.label( - RichText::new("No shielded notes yet. Shield some credits to get started.") - .color(DashColors::text_secondary(dark_mode)), - ); - } + }); + } else if !self.syncing { + ui.label( + RichText::new("No shielded notes yet. Shield some credits to get started.") + .color(DashColors::text_secondary(dark_mode)), + ); + } + }); action } } +/// Truncate a bech32m address for display (12 prefix + 8 suffix). +fn truncate_address(addr: &str) -> String { + crate::model::address::truncate_address(addr, 12, 8) +} + fn format_credits(credits: u64) -> String { let dash = credits as f64 / CREDITS_PER_DUFF as f64 / 1e8; if dash >= 0.01 { diff --git a/src/ui/wallets/unshield_credits_screen.rs b/src/ui/wallets/unshield_credits_screen.rs index 9fc741ea5..eafb8183a 100644 --- a/src/ui/wallets/unshield_credits_screen.rs +++ b/src/ui/wallets/unshield_credits_screen.rs @@ -46,6 +46,7 @@ impl UnshieldCreditsScreen { /// Clear the AddressInput widget so it picks up the new network on next frame. pub(crate) fn invalidate_address_input(&mut self) { self.address_input = None; + self.validated_destination = None; } pub fn new(seed_hash: WalletSeedHash, app_context: &Arc) -> Self { diff --git a/src/ui/wallets/wallets_screen/address_table.rs b/src/ui/wallets/wallets_screen/address_table.rs index ddcada425..af4348bbf 100644 --- a/src/ui/wallets/wallets_screen/address_table.rs +++ b/src/ui/wallets/wallets_screen/address_table.rs @@ -101,7 +101,11 @@ impl WalletsBalancesScreen { categorize_account_path(path, network, reference) } - pub(super) fn render_address_table(&mut self, ui: &mut Ui) -> AppAction { + pub(super) fn render_address_table( + &mut self, + ui: &mut Ui, + account_filter: (AccountCategory, Option), + ) -> AppAction { let action = AppAction::None; // Move the data preparation into its own scope @@ -197,9 +201,10 @@ impl WalletsBalancesScreen { // Sort the data self.sort_address_data(&mut address_data); - if let Some((category, index)) = self.selected_account.clone() { + { + let (ref category, ref index) = account_filter; address_data - .retain(|data| data.account_category == category && data.account_index == index); + .retain(|data| data.account_category == *category && data.account_index == *index); } let account_address_count = address_data.len(); @@ -236,11 +241,7 @@ impl WalletsBalancesScreen { // Space allocation for UI elements is handled by the layout system - let is_platform_account = self - .selected_account - .as_ref() - .map(|(cat, _)| *cat == AccountCategory::PlatformPayment) - .unwrap_or(false); + let is_platform_account = account_filter.0 == AccountCategory::PlatformPayment; // Reset sort column if it refers to a column not visible for the current account type if is_platform_account diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 1b76aad4a..9579fe172 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -45,12 +45,27 @@ use dialogs::{ SendDialogState, }; -/// Tab selector for the wallet detail panel. -#[derive(Default, Clone, Copy, PartialEq)] -enum WalletViewTab { - #[default] - Balances, +/// Tab selector for the Accounts & Addresses section. +/// +/// Each tab corresponds to either an `AccountCategory` or the special Shielded +/// view. Visibility is controlled by developer mode: only DashCore, Platform, +/// and Shielded are shown by default; the System tab appears in developer mode +/// and consolidates all system/dev account categories into collapsible sections. +#[derive(Clone, PartialEq, Eq)] +enum AccountTab { + /// Regular account category (BIP44, PlatformPayment) + Category(AccountCategory, Option), + /// Shielded wallet view (replaces the old top-level Shielded tab) Shielded, + /// Consolidated system tab (developer mode only) — shows all non-primary + /// account categories as collapsible sections. + System, +} + +impl Default for AccountTab { + fn default() -> Self { + AccountTab::Category(AccountCategory::Bip44, Some(0)) + } } /// Refresh mode for dev mode dropdown - controls what gets refreshed @@ -74,12 +89,12 @@ impl RefreshMode { } } - fn all_modes() -> &'static [RefreshMode] { - &[ - RefreshMode::All, - RefreshMode::CoreOnly, - RefreshMode::PlatformOnly, - ] + fn next(self) -> Self { + match self { + RefreshMode::All => RefreshMode::CoreOnly, + RefreshMode::CoreOnly => RefreshMode::PlatformOnly, + RefreshMode::PlatformOnly => RefreshMode::All, + } } } @@ -103,7 +118,6 @@ pub struct WalletsBalancesScreen { fund_platform_dialog: FundPlatformAddressDialogState, private_key_dialog: PrivateKeyDialogState, mine_dialog: MineDialogState, - selected_account: Option<(AccountCategory, Option)>, show_zero_balance_addresses: bool, /// Pending refresh of platform address balances (triggered after transfers) pending_platform_balance_refresh: Option, @@ -119,8 +133,8 @@ pub struct WalletsBalancesScreen { utxo_page: usize, /// Selected refresh mode (only shown in dev mode) refresh_mode: RefreshMode, - /// Currently selected tab in the wallet detail panel - selected_tab: WalletViewTab, + /// Currently selected account tab in the Accounts & Addresses section + selected_account_tab: AccountTab, /// Shielded tab view component (lazily initialized per wallet) shielded_tab_view: Option, /// Cached platform sync info: (last_sync_timestamp, last_sync_height) @@ -144,6 +158,8 @@ pub struct WalletsBalancesScreen { /// Cached filtered transaction indices for the currently selected wallet. /// Invalidated (set to None) on wallet switch or transaction updates. cached_tx_indices: Option>, + /// Whether a Core receive address generation is in progress (disables button) + generating_core_address: bool, } impl WalletsBalancesScreen { @@ -207,6 +223,11 @@ impl WalletsBalancesScreen { .and_then(|hash| app_context.db.get_platform_sync_info(&hash).ok()) .filter(|(ts, _)| *ts > 0); + let shielded_tab_view = selected_wallet + .as_ref() + .and_then(|w| w.read().ok().map(|g| g.seed_hash())) + .map(|hash| ShieldedTabView::new(app_context, hash)); + Self { selected_wallet, selected_single_key_wallet, @@ -227,7 +248,6 @@ impl WalletsBalancesScreen { fund_platform_dialog: FundPlatformAddressDialogState::default(), private_key_dialog: PrivateKeyDialogState::default(), mine_dialog: MineDialogState::default(), - selected_account: None, show_zero_balance_addresses: false, pending_platform_balance_refresh: None, pending_refresh_after_unlock: false, @@ -236,8 +256,8 @@ impl WalletsBalancesScreen { asset_lock_search_banner: None, utxo_page: 0, refresh_mode: RefreshMode::default(), - selected_tab: WalletViewTab::default(), - shielded_tab_view: None, + selected_account_tab: AccountTab::default(), + shielded_tab_view, platform_sync_info, core_wallet_dialog: None, pending_core_wallet_seed_hash: None, @@ -248,6 +268,7 @@ impl WalletsBalancesScreen { pending_list_wallet_hash: None, pending_list_is_single_key: false, cached_tx_indices: None, + generating_core_address: false, } } @@ -345,11 +366,13 @@ impl WalletsBalancesScreen { .and_then(|w| w.read().ok().map(|g| g.seed_hash())); self.selected_wallet = wallet; self.selected_single_key_wallet = None; - self.selected_account = None; - self.selected_tab = WalletViewTab::default(); - self.shielded_tab_view = None; + + self.selected_account_tab = AccountTab::default(); self.cached_tx_indices = None; + self.shielded_tab_view = + seed_hash.map(|hash| ShieldedTabView::new(&self.app_context, hash)); + if let Some(hash) = seed_hash { self.persist_selected_wallet_hash(Some(hash)); self.refresh_platform_sync_info_cache(&hash); @@ -371,7 +394,7 @@ impl WalletsBalancesScreen { fn select_single_key_wallet(&mut self, wallet: Arc>) { self.selected_single_key_wallet = Some(wallet.clone()); self.selected_wallet = None; - self.selected_account = None; + self.platform_sync_info = None; self.utxo_page = 0; @@ -389,7 +412,6 @@ impl WalletsBalancesScreen { && let Ok(wallets) = self.app_context.wallets.read() && wallets.contains_key(&hash) { - self.selected_account = None; return; } // HD wallet no longer valid @@ -403,7 +425,6 @@ impl WalletsBalancesScreen { && let Ok(wallets) = self.app_context.single_key_wallets.read() && wallets.contains_key(&hash) { - self.selected_account = None; return; } // Single key wallet no longer valid @@ -427,12 +448,11 @@ impl WalletsBalancesScreen { { self.selected_single_key_wallet = Some(wallet); self.selected_wallet = None; - self.selected_account = None; + self.platform_sync_info = None; return; } - self.selected_account = None; self.platform_sync_info = None; } @@ -445,38 +465,10 @@ impl WalletsBalancesScreen { /// Reset all cached AddressInput widgets so they pick up the new network. pub(crate) fn invalidate_address_inputs(&mut self) { self.mine_dialog.address_input = None; + self.mine_dialog.validated_address = None; self.cached_tx_indices = None; } - fn add_receiving_address(&mut self) { - if let Some(wallet) = &self.selected_wallet { - let result = { - let mut wallet = wallet.write().unwrap(); - wallet.receive_address(self.app_context.network, true, Some(&self.app_context)) - }; - - match result { - Ok(address) => { - let message = format!("Added new receiving address: {}", address); - MessageBanner::set_global( - self.app_context.egui_ctx(), - &message, - MessageType::Success, - ); - } - Err(e) => { - MessageBanner::set_global(self.app_context.egui_ctx(), &e, MessageType::Error); - } - } - } else { - MessageBanner::set_global( - self.app_context.egui_ctx(), - "No wallet selected", - MessageType::Error, - ); - } - } - fn render_wallet_selection(&mut self, ui: &mut Ui) -> AppAction { let action = AppAction::None; @@ -495,7 +487,9 @@ impl WalletsBalancesScreen { let guard = wallet.read().unwrap(); let core_balance = guard.total_balance_duffs(); let platform_balance = Self::platform_balance_duffs(&guard); - let balance_dash = (core_balance + platform_balance) as f64 * 1e-8; + let shielded_balance = self.shielded_balance_duffs(&guard.seed_hash()); + let balance_dash = + (core_balance + platform_balance + shielded_balance) as f64 * 1e-8; let label = format!( "HD: {} ({:.4} DASH)", guard.alias.clone().unwrap_or_else(|| "Unnamed".to_string()), @@ -559,7 +553,8 @@ impl WalletsBalancesScreen { .map(|g| { let core = g.total_balance_duffs(); let platform = Self::platform_balance_duffs(&g); - core + platform + let shielded = self.shielded_balance_duffs(&g.seed_hash()); + core + platform + shielded }) .unwrap_or(0) } else if let Some(wallet) = &self.selected_single_key_wallet { @@ -607,31 +602,6 @@ impl WalletsBalancesScreen { DashColors::text_primary(ui.ctx().style().visuals.dark_mode), format!(" Balance: {}", Self::format_dash(current_balance)), ); - - ui.separator(); - - // Dev mode: Refresh mode selector - if self.app_context.is_developer_mode() { - ui.label( - egui::RichText::new("Refresh Mode:").color(DashColors::text_primary( - ui.ctx().style().visuals.dark_mode, - )), - ); - - ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { - ComboBox::from_id_salt("refresh_mode_selector") - .selected_text(self.refresh_mode.label()) - .show_ui(ui, |ui| { - for mode in RefreshMode::all_modes() { - ui.selectable_value( - &mut self.refresh_mode, - *mode, - mode.label(), - ); - } - }); - }); - } }); ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| { @@ -756,31 +726,90 @@ impl WalletsBalancesScreen { action } - fn render_bottom_options(&mut self, ui: &mut Ui) { + fn render_bottom_options( + &mut self, + ui: &mut Ui, + account_filter: &(AccountCategory, Option), + ) -> AppAction { + let mut action = AppAction::None; + let wallet_is_open = self .selected_wallet .as_ref() .is_some_and(|wallet_guard| wallet_guard.read().unwrap().is_open()); - // Only show "Add Receiving Address" button for Main Account (BIP44 account 0) - let is_main_account = self - .selected_account - .as_ref() - .is_some_and(|(category, index)| { - *category == AccountCategory::Bip44 && *index == Some(0) - }); + if !wallet_is_open { + return action; + } - if wallet_is_open && is_main_account { - ui.add_space(10.0); - ui.horizontal(|ui| { + let is_bip44 = account_filter.0 == AccountCategory::Bip44; + let is_platform = account_filter.0 == AccountCategory::PlatformPayment; + + if is_bip44 { + ui.add_space(8.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + let button = egui::Button::new(RichText::new("+ New Receive Address").size(13.0)) + .min_size(egui::vec2(0.0, 24.0)); if ui - .button(RichText::new("➕ Add Receiving Address").size(14.0)) + .add_enabled(!self.generating_core_address, button) .clicked() + && let Some(wallet) = &self.selected_wallet { - self.add_receiving_address(); + let seed_hash = wallet.read().unwrap().seed_hash(); + self.generating_core_address = true; + action = AppAction::BackendTask(BackendTask::WalletTask( + crate::backend_task::wallet::WalletTask::GenerateReceiveAddress { + seed_hash, + }, + )); + } + }); + } else if is_platform { + ui.add_space(8.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + let button = egui::Button::new(RichText::new("+ New Platform Address").size(13.0)) + .min_size(egui::vec2(0.0, 24.0)); + if ui.add(button).clicked() { + self.add_new_platform_address(); } }); } + + action + } + + fn add_new_platform_address(&mut self) { + if let Some(wallet) = &self.selected_wallet { + let result = { + let mut wallet = wallet.write().unwrap(); + wallet.platform_receive_address( + self.app_context.network, + true, + Some(&self.app_context), + ) + }; + match result { + Ok(address) => { + use dash_sdk::dpp::address_funds::PlatformAddress; + let display = PlatformAddress::try_from(address) + .map(|pa| pa.to_bech32m_string(self.app_context.network)) + .unwrap_or_else(|_| "new address".to_string()); + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("New Platform address generated: {display}"), + MessageType::Success, + ); + } + Err(e) => { + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Could not generate a new Platform address. Please try again.", + MessageType::Error, + ) + .with_details(e); + } + } + } } fn render_remove_wallet_button(&mut self, ui: &mut Ui) { @@ -1027,29 +1056,14 @@ impl WalletsBalancesScreen { .sum() } - fn render_wallet_overview(&self, ui: &mut Ui, wallet: &Wallet) { - let dark_mode = ui.ctx().style().visuals.dark_mode; - let total = wallet.total_balance_duffs(); - let platform = Self::platform_balance_duffs(wallet); - let combined = total + platform; - - ui.horizontal(|ui| { - ui.label(RichText::new(format!( - "Core balance: {}", - Self::format_dash(total) - ))); - }); - ui.label( - RichText::new(format!("Platform balance: {}", Self::format_dash(platform))) - .color(DashColors::text_primary(dark_mode)), - ); - if platform > 0 { - ui.label( - RichText::new(format!("Total: {}", Self::format_dash(combined))) - .color(DashColors::text_primary(dark_mode)) - .strong(), - ); - } + fn shielded_balance_duffs(&self, seed_hash: &WalletSeedHash) -> u64 { + self.app_context + .shielded_states + .lock() + .ok() + .and_then(|states| states.get(seed_hash).map(|s| s.shielded_balance)) + .unwrap_or(0) + / CREDITS_PER_DUFF } fn render_action_buttons(&mut self, ui: &mut Ui, ctx: &Context) -> AppAction { @@ -1091,103 +1105,446 @@ impl WalletsBalancesScreen { action |= self.open_receive_dialog(ctx); } - if matches!( - self.app_context.network, - dash_sdk::dpp::dashcore::Network::Regtest - | dash_sdk::dpp::dashcore::Network::Devnet - ) && self.app_context.is_developer_mode() - && self.app_context.core_backend_mode() == CoreBackendMode::Rpc - && ui - .button( - RichText::new("Mine") - .color(DashColors::text_primary(dark_mode)) - .strong(), - ) - .clicked() - { - self.open_mine_dialog(); + if self.refreshing { + ui.add(egui::Spinner::new().color(DashColors::DASH_BLUE)); + } + + // Dev-mode buttons: right-aligned, filling all remaining space + if self.app_context.is_developer_mode() { + let remaining = ui.available_width(); + ui.allocate_ui_with_layout( + egui::vec2(remaining, ui.min_size().y), + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + if matches!( + self.app_context.network, + dash_sdk::dpp::dashcore::Network::Testnet + ) && ui + .button( + RichText::new("Get Test Dash") + .color(DashColors::text_primary(dark_mode)) + .strong(), + ) + .clicked() + { + ui.ctx().open_url(egui::OpenUrl::new_tab( + "https://faucet.testnet.networks.dash.org/", + )); + } + + if matches!( + self.app_context.network, + dash_sdk::dpp::dashcore::Network::Regtest + | dash_sdk::dpp::dashcore::Network::Devnet + ) && self.app_context.core_backend_mode() == CoreBackendMode::Rpc + && ui + .button( + RichText::new("Mine") + .color(DashColors::text_primary(dark_mode)) + .strong(), + ) + .clicked() + { + self.open_mine_dialog(); + } + + if ui + .button( + RichText::new(format!( + "Refresh mode: {}", + self.refresh_mode.label() + )) + .color(DashColors::text_primary(dark_mode)) + .strong(), + ) + .clicked() + { + self.refresh_mode = self.refresh_mode.next(); + } + }, + ); } }); + action } - fn render_accounts_section(&mut self, ui: &mut Ui, summaries: &[AccountSummary]) { - ui.add_space(14.0); - ui.heading("Accounts"); - ui.add_space(6.0); + /// Build the list of visible account tabs based on current summaries and dev mode. + fn build_account_tabs(&self, summaries: &[AccountSummary]) -> Vec { + let developer_mode = self.app_context.is_developer_mode(); + let mut tabs: Vec = Vec::new(); - if summaries.is_empty() { - ui.label("No account activity yet."); - return; + // Always-visible primary tabs: all BIP44 accounts and Platform + for summary in summaries { + if !summary.category.is_visible_in_default_mode() { + continue; + } + tabs.push(AccountTab::Category( + summary.category.clone(), + summary.index, + )); + } + + // Ensure Dash Core tab exists even without summaries + if !tabs + .iter() + .any(|t| matches!(t, AccountTab::Category(AccountCategory::Bip44, Some(0)))) + { + tabs.insert(0, AccountTab::Category(AccountCategory::Bip44, Some(0))); + } + + // Always add the Shielded tab + tabs.push(AccountTab::Shielded); + + // In developer mode, add the consolidated System tab last + if developer_mode { + tabs.push(AccountTab::System); + } + + tabs + } + + /// Collect the system account categories to display inside the System tab. + /// Returns `(category, index, address_count, balance_duffs)` tuples in a + /// fixed display order (identity categories first, then provider, then legacy). + /// Each `(category, index)` pair gets its own section with accurate counts. + fn system_tab_sections( + &self, + summaries: &[AccountSummary], + ) -> Vec<(AccountCategory, Option, usize, u64)> { + let category_order: &[AccountCategory] = &[ + AccountCategory::IdentityRegistration, + AccountCategory::IdentitySystem, + AccountCategory::IdentityTopup, + AccountCategory::IdentityInvitation, + AccountCategory::CoinJoin, + AccountCategory::ProviderOwner, + AccountCategory::ProviderVoting, + AccountCategory::ProviderOperator, + AccountCategory::ProviderPlatform, + AccountCategory::Bip32, + ]; + + // Precompute per-(category, index) address counts in a single pass. + let address_counts = self.precompute_address_counts(); + + let mut sections = Vec::new(); + + // For each category, emit one section per distinct index found in + // summaries. Categories with no summary entries get a single section + // with index from the first matching summary (or None). + for cat in category_order { + let matching: Vec<_> = summaries.iter().filter(|s| &s.category == cat).collect(); + if matching.is_empty() { + let address_count = address_counts + .get(&(cat.clone(), None)) + .copied() + .unwrap_or(0); + sections.push((cat.clone(), None, address_count, 0u64)); + } else { + for summary in &matching { + let key = (cat.clone(), summary.index); + let address_count = address_counts.get(&key).copied().unwrap_or(0); + sections.push(( + cat.clone(), + summary.index, + address_count, + summary.confirmed_balance, + )); + } + } } + // Also include any Other(...) categories from summaries + for summary in summaries { + if matches!(summary.category, AccountCategory::Other(_)) + && !sections + .iter() + .any(|(c, idx, _, _)| *c == summary.category && *idx == summary.index) + { + let key = (summary.category.clone(), summary.index); + let address_count = address_counts.get(&key).copied().unwrap_or(0); + sections.push(( + summary.category.clone(), + summary.index, + address_count, + summary.confirmed_balance, + )); + } + } + + sections + } + + /// Build a per-(category, index) address count map in a single pass over + /// `watched_addresses`. Used by `system_tab_sections` to avoid + /// O(num_categories * num_addresses) per frame. + fn precompute_address_counts( + &self, + ) -> std::collections::HashMap<(AccountCategory, Option), usize> { + let mut counts = std::collections::HashMap::new(); + let Some(wallet_arc) = self.selected_wallet.as_ref() else { + return counts; + }; + let Ok(wallet) = wallet_arc.read() else { + return counts; + }; + let network = self.app_context.network; + for (path, info) in &wallet.watched_addresses { + let (cat, idx) = crate::ui::wallets::account_summary::categorize_account_path( + path, + network, + info.path_reference, + ); + *counts.entry((cat, idx)).or_insert(0) += 1; + } + counts + } + + /// Format a duffs balance for tab labels: max 4 decimal places, trimmed. + fn format_tab_balance(duffs: u64) -> String { + let dash = duffs as f64 / 100_000_000.0; + // Format with 4 decimal places, then trim trailing zeros + let formatted = format!("{:.4}", dash); + let trimmed = formatted.trim_end_matches('0').trim_end_matches('.'); + format!("{} DASH", trimmed) + } + + /// Render the Accounts & Addresses tab bar and content. + fn render_account_tabs(&mut self, ui: &mut Ui, summaries: &[AccountSummary]) -> AppAction { + let mut action = AppAction::None; let dark_mode = ui.ctx().style().visuals.dark_mode; - // Find the currently selected summary - let selected_summary = self.selected_account.as_ref().and_then(|(cat, idx)| { - summaries - .iter() - .find(|s| &s.category == cat && s.index == *idx) - }); + ui.add_space(14.0); + + let tabs = self.build_account_tabs(summaries); + + // Ensure the selected tab is still valid + if !tabs.contains(&self.selected_account_tab) + && let Some(first) = tabs.first() + { + self.selected_account_tab = first.clone(); + } - // Build the selected text for the dropdown - let selected_text = selected_summary - .map(|s| { - if s.category.is_key_only() { - s.label.clone() - } else if s.category == AccountCategory::PlatformPayment { - let credits_as_dash = s.platform_credits as f64 / CREDITS_PER_DUFF as f64 / 1e8; - format!("{} - {:.4} DASH", s.label, credits_as_dash) + // Tab bar + ui.horizontal_wrapped(|ui| { + for tab in &tabs { + let (base_label, balance_duffs) = match tab { + AccountTab::Category(cat, idx) => { + let balance = if matches!(cat, AccountCategory::PlatformPayment) { + summaries + .iter() + .filter(|s| s.category == *cat && s.index == *idx) + .map(|s| s.platform_credits / CREDITS_PER_DUFF) + .sum::() + } else { + summaries + .iter() + .filter(|s| s.category == *cat && s.index == *idx) + .map(|s| s.confirmed_balance) + .sum::() + }; + (cat.tab_label(*idx).to_string(), balance) + } + AccountTab::Shielded => { + let balance = self + .selected_wallet + .as_ref() + .and_then(|w| w.read().ok()) + .map(|g| self.shielded_balance_duffs(&g.seed_hash())) + .unwrap_or(0); + ("Shielded".to_string(), balance) + } + AccountTab::System => { + let balance: u64 = summaries + .iter() + .filter(|s| s.category.is_system_category()) + .map(|s| s.confirmed_balance) + .sum(); + ("System".to_string(), balance) + } + }; + let label = if balance_duffs == 0 { + format!("{} (empty)", base_label) } else { - format!("{} - {}", s.label, Self::format_dash(s.confirmed_balance)) + format!( + "{} ({})", + base_label, + Self::format_tab_balance(balance_duffs) + ) + }; + let is_selected = &self.selected_account_tab == tab; + let text = if is_selected { + RichText::new(&label) + .strong() + .color(DashColors::text_primary(dark_mode)) + } else { + RichText::new(&label).color(DashColors::text_secondary(dark_mode)) + }; + ui.add_space(4.0); + if ui.selectable_label(is_selected, text).clicked() { + self.selected_account_tab = tab.clone(); } - }) - .unwrap_or_else(|| "Select an account".to_string()); - - // Account dropdown selector - ComboBox::from_id_salt("account_selector") - .selected_text(&selected_text) - .width(ui.available_width() - 16.0) - .show_ui(ui, |ui| { - for summary in summaries { - let is_selected = self - .selected_account - .as_ref() - .map(|(cat, idx)| cat == &summary.category && *idx == summary.index) - .unwrap_or(false); - - let label = if summary.category.is_key_only() { - summary.label.clone() - } else if summary.category == AccountCategory::PlatformPayment { - let credits_as_dash = - summary.platform_credits as f64 / CREDITS_PER_DUFF as f64 / 1e8; - format!("{} - {:.4} DASH", summary.label, credits_as_dash) - } else { - format!( - "{} - {}", - summary.label, - Self::format_dash(summary.confirmed_balance) - ) - }; + } + }); + ui.separator(); + ui.add_space(4.0); - if ui.selectable_label(is_selected, &label).clicked() { - self.selected_account = Some((summary.category.clone(), summary.index)); - } + // Tab content — extract category data to avoid cloning the whole enum + let tab_category = match &self.selected_account_tab { + AccountTab::Category(cat, idx) => Some((cat.clone(), *idx)), + _ => None, + }; + match (&self.selected_account_tab, tab_category) { + (AccountTab::Shielded, _) => { + let seed_hash = self + .selected_wallet + .as_ref() + .and_then(|w| w.read().ok().map(|g| g.seed_hash())); + if let Some(seed_hash) = seed_hash { + let shielded_view = self + .shielded_tab_view + .get_or_insert_with(|| ShieldedTabView::new(&self.app_context, seed_hash)); + shielded_view.update_seed_hash(seed_hash); + shielded_view.update_app_context(&self.app_context); + action |= shielded_view.ui(ui); + } + } + (AccountTab::System, _) => { + action |= self.render_system_tab_content(ui, summaries); + } + (AccountTab::Category(..), Some((cat, idx))) => { + // Show empty state if no summaries match this category + if !summaries + .iter() + .any(|s| s.category == cat && s.index == idx) + && !matches!(cat, AccountCategory::Bip44) + { + ui.label( + RichText::new("No account activity yet.") + .color(DashColors::text_secondary(dark_mode)), + ); + return action; } - }); - // Show description of the selected account below the dropdown - if let Some(summary) = selected_summary - && let Some(description) = summary.category.description() - { - ui.add_space(4.0); - ui.label( - RichText::new(description) - .color(DashColors::text_secondary(dark_mode)) - .italics() - .size(12.0), + // Show description for the selected account category + if let Some(description) = cat.description() { + ui.label( + RichText::new(description) + .color(DashColors::text_secondary(dark_mode)) + .italics() + .size(12.0), + ); + ui.add_space(4.0); + } + + let account_filter = (cat.clone(), idx); + + // Addresses (collapsible) + let addresses_heading = format!("Addresses ({})", cat.label(idx)); + let addr_header = egui::CollapsingHeader::new( + RichText::new(addresses_heading) + .size(16.0) + .color(DashColors::text_primary(dark_mode)), + ) + .id_salt(format!("addresses_{}_{:?}", cat.tab_label(idx), idx)) + .default_open(true); + addr_header.show(ui, |ui| { + ui.horizontal(|ui| { + 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(4.0); + action |= self.render_address_table(ui, account_filter.clone()); + action |= self.render_bottom_options(ui, &account_filter); + }); + + // Dash Core tab: transaction history + asset locks + if cat == AccountCategory::Bip44 && idx == Some(0) { + // Transaction History (collapsible) + ui.add_space(10.0); + let tx_header = egui::CollapsingHeader::new( + RichText::new("Transaction History") + .size(16.0) + .color(DashColors::text_primary(dark_mode)), + ) + .id_salt("transaction_history") + .default_open(false); + tx_header.show(ui, |ui| { + self.render_transactions_section(ui); + }); + + // Asset Locks (collapsible) + ui.add_space(10.0); + let locks_header = egui::CollapsingHeader::new( + RichText::new("Asset Locks") + .size(16.0) + .color(DashColors::text_primary(dark_mode)), + ) + .id_salt("asset_locks") + .default_open(true); + locks_header.show(ui, |ui| { + action |= self.render_wallet_asset_locks(ui); + }); + } + } + _ => {} + } + + action + } + + /// Render the System tab content: each system account category as a + /// collapsible section, collapsed by default. + fn render_system_tab_content( + &mut self, + ui: &mut Ui, + summaries: &[AccountSummary], + ) -> AppAction { + let mut action = AppAction::None; + let dark_mode = ui.ctx().style().visuals.dark_mode; + let sections = self.system_tab_sections(summaries); + + for (cat, idx, addr_count, balance) in §ions { + let balance_text = if *balance == 0 { + "empty".to_string() + } else { + Self::format_tab_balance(*balance) + }; + let heading = format!( + "{} ({} addresses, {})", + cat.label(*idx), + addr_count, + balance_text ); + let header = egui::CollapsingHeader::new( + RichText::new(heading) + .size(14.0) + .color(DashColors::text_primary(dark_mode)), + ) + .id_salt(format!("system_section_{:?}_{:?}", cat, idx)) + .default_open(false); + header.show(ui, |ui| { + if let Some(description) = cat.description() { + ui.label( + RichText::new(description) + .color(DashColors::text_secondary(dark_mode)) + .italics() + .size(12.0), + ); + ui.add_space(4.0); + } + + action |= self.render_address_table(ui, (cat.clone(), *idx)); + }); + ui.add_space(2.0); } + + action } fn render_transactions_section(&mut self, ui: &mut Ui) { @@ -1227,22 +1584,12 @@ impl WalletsBalancesScreen { return; } - // Filter transactions to only those involving this wallet's addresses. - // We check outputs only — transactions are already fetched per-wallet - // from SPV/RPC, so inputs are implicitly relevant. The output filter - // only excludes transactions that leaked from other wallets' data. + // Filter to transactions involving this wallet's addresses. + // The `is_ours` flag is set by both RPC and SPV paths for all + // transactions that belong to this wallet (sends and receives). let relevant_indices = self.cached_tx_indices.get_or_insert_with(|| { - let wallet_addresses: std::collections::HashSet<&Address> = - wallet_guard.known_addresses.keys().collect(); (0..wallet_guard.transactions.len()) - .filter(|&i| { - let tx = &wallet_guard.transactions[i]; - tx.transaction.output.iter().any(|output| { - Address::from_script(&output.script_pubkey, self.app_context.network) - .ok() - .is_some_and(|addr| wallet_addresses.contains(&addr)) - }) - }) + .filter(|&i| wallet_guard.transactions[i].is_ours) .collect() }); @@ -1254,6 +1601,7 @@ impl WalletsBalancesScreen { } let dark_mode = ui.ctx().style().visuals.dark_mode; + let show_fee = self.app_context.is_developer_mode(); let mut order: Vec = relevant_indices.clone(); order.sort_by(|&a, &b| { wallet_guard.transactions[b] @@ -1267,12 +1615,18 @@ impl WalletsBalancesScreen { }); let row_height = 26.0; - TableBuilder::new(ui) + let mut builder = TableBuilder::new(ui) .id_salt("transactions_table") .striped(true) .column(Column::initial(150.0)) // Date .column(Column::initial(80.0)) // Type - .column(Column::initial(120.0)) // Amount + .column(Column::initial(120.0)); // Amount + + if show_fee { + builder = builder.column(Column::initial(100.0)); // Fee + } + + builder .column(Column::initial(150.0)) // Status .column(Column::remainder()) // TxID .header(row_height, |mut header| { @@ -1297,6 +1651,15 @@ impl WalletsBalancesScreen { .color(DashColors::text_primary(dark_mode)), ); }); + if show_fee { + header.col(|ui| { + ui.label( + RichText::new("Fee") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + }); + } header.col(|ui| { ui.label( RichText::new("Status") @@ -1327,6 +1690,15 @@ impl WalletsBalancesScreen { Self::transaction_amount_display(tx, dark_mode); ui.label(RichText::new(amount_text).color(amount_color).strong()); }); + if show_fee { + row.col(|ui| { + let fee_text = tx + .fee + .map(Self::format_dash) + .unwrap_or_else(|| "-".to_string()); + ui.label(fee_text); + }); + } row.col(|ui| { ui.label(Self::format_transaction_status(tx)); }); @@ -1342,6 +1714,27 @@ impl WalletsBalancesScreen { { let _ = copy_text_to_clipboard(&full_txid); } + // Show "View" button for networks with a public explorer + let explorer_base = match self.app_context.network { + dash_sdk::dpp::dashcore::Network::Mainnet => { + Some("https://insight.dash.org/insight/tx/") + } + dash_sdk::dpp::dashcore::Network::Testnet => Some( + "https://insight.testnet.networks.dash.org/insight/tx/", + ), + _ => None, + }; + if let Some(base_url) = explorer_base + && ui + .small_button("View") + .clickable_tooltip("View on block explorer") + .clicked() + { + ui.ctx().open_url(egui::OpenUrl::new_tab(format!( + "{}{}", + base_url, full_txid + ))); + } }); }); }); @@ -1352,261 +1745,250 @@ impl WalletsBalancesScreen { /// Render a compact sync status panel showing Core, Platform, and Shielded sync progress. fn render_sync_status(&self, ui: &mut Ui) { let dark_mode = ui.ctx().style().visuals.dark_mode; + let secondary = DashColors::text_secondary(dark_mode); + let syncing_color = DashColors::DASH_BLUE; + let sz = 12.0; ui.collapsing( - RichText::new("Sync Status") - .size(12.0) - .color(DashColors::text_secondary(dark_mode)), + RichText::new("Sync Status").size(sz).color(secondary), |ui| { - Frame::group(ui.style()) - .fill(DashColors::surface(dark_mode)) - .inner_margin(Margin::symmetric(16, 8)) - .show(ui, |ui| { - // Line 1 -- Core sync status - ui.horizontal(|ui| { - ui.label( - RichText::new("Core:") - .size(12.0) - .strong() - .color(DashColors::text_primary(dark_mode)), - ); - - match self.app_context.core_backend_mode() { - CoreBackendMode::Rpc => { - if self.app_context.connection_status().rpc_online() { - ui.colored_label( - Color32::DARK_GREEN, - RichText::new("Connected").size(12.0), - ); - } else { - ui.colored_label( - DashColors::ERROR, - RichText::new("Disconnected").size(12.0), - ); - } - } - CoreBackendMode::Spv => { - let snapshot = self.app_context.spv_manager().status(); - match snapshot.status { - SpvStatus::Idle | SpvStatus::Stopped => { - ui.label( - RichText::new("Disconnected") - .size(12.0) - .color(DashColors::text_secondary(dark_mode)), - ); - } - SpvStatus::Starting => { - ui.add( - egui::Spinner::new() - .size(12.0) - .color(DashColors::DASH_BLUE), - ); - ui.label( - RichText::new("Connecting...") - .size(12.0) - .color(DashColors::DASH_BLUE), - ); - } - SpvStatus::Syncing => { - ui.add( - egui::Spinner::new() - .size(12.0) - .color(DashColors::DASH_BLUE), - ); - let phase_text = snapshot - .sync_progress - .as_ref() - .map(spv_phase_summary) - .unwrap_or_else(|| "starting...".to_string()); - ui.label( - RichText::new(format!("Syncing — {phase_text}")) - .size(12.0) - .color(DashColors::DASH_BLUE), - ); - } - SpvStatus::Running => { - ui.colored_label( - Color32::DARK_GREEN, - RichText::new(format!( - "Synced — {} peers", - snapshot.connected_peers - )) - .size(12.0), - ); - } - SpvStatus::Stopping => { - ui.add( - egui::Spinner::new() - .size(12.0) - .color(DashColors::DASH_BLUE), - ); - ui.label( - RichText::new("Disconnecting...") - .size(12.0) - .color(DashColors::DASH_BLUE), - ); - } - SpvStatus::Error => { - ui.colored_label( - DashColors::ERROR, - RichText::new("Error").size(12.0), - ); - } - } - } - } - }); - - // Line 2 -- Platform sync status - ui.horizontal(|ui| { - ui.label( - RichText::new("Platform:") - .size(12.0) - .strong() - .color(DashColors::text_primary(dark_mode)), - ); - - // Addresses - let addr_count = self - .selected_wallet - .as_ref() - .and_then(|w| w.read().ok()) - .map(|w| w.platform_address_info.len()) - .unwrap_or(0); - if self.refreshing { - ui.add( - egui::Spinner::new().size(12.0).color(DashColors::DASH_BLUE), + // -- Core sync status -- + ui.horizontal(|ui| { + ui.label(RichText::new("•").size(sz).color(secondary)); + ui.label( + RichText::new("Core:") + .size(sz) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + match self.app_context.core_backend_mode() { + CoreBackendMode::Rpc => { + if self.app_context.connection_status().rpc_online() { + ui.colored_label( + Color32::DARK_GREEN, + RichText::new("Connected").size(sz), ); - } - let addr_text = if let Some((last_sync_ts, sync_height)) = - self.platform_sync_info - { - let ago = Self::format_unix_time_ago(last_sync_ts); - format!( - "Addresses: {} synced (blk {}, {})", - addr_count, sync_height, ago - ) } else { - "Addresses: never synced".to_string() - }; - ui.label(RichText::new(addr_text).size(12.0).color( - if self.refreshing { - DashColors::DASH_BLUE - } else { - DashColors::text_secondary(dark_mode) - }, - )); - - ui.label( - RichText::new("|") - .size(12.0) - .color(DashColors::text_secondary(dark_mode)), - ); - - // Shielded notes + nullifiers - let seed_hash = self - .selected_wallet - .as_ref() - .and_then(|w| w.read().ok().map(|g| g.seed_hash())); - let shielded_info = seed_hash.and_then(|hash| { - let states = self.app_context.shielded_states.lock().ok()?; - let state = states.get(&hash)?; - Some(( - state.last_synced_index, - state.notes.iter().filter(|n| !n.is_spent).count(), - state.last_nullifier_sync_height, - state.last_notes_synced_at, - state.last_nullifiers_synced_at, - )) - }); - let shielded_syncing = self - .shielded_tab_view - .as_ref() - .is_some_and(|v| v.is_syncing()); - - match shielded_info { - Some(( - synced_index, - note_count, - nf_height, - notes_synced_at, - nf_synced_at, - )) => { - if shielded_syncing { - ui.add( - egui::Spinner::new() - .size(12.0) - .color(DashColors::DASH_BLUE), - ); - } - let notes_text = if let Some(t) = notes_synced_at { - let ago = Self::format_instant_ago(t); - format!( - "Notes: {} synced ({} notes, {})", - synced_index, note_count, ago - ) - } else if synced_index > 0 { - format!( - "Notes: {} synced ({} notes)", - synced_index, note_count - ) - } else { - "Notes: never synced".to_string() - }; - ui.label(RichText::new(notes_text).size(12.0).color( - if shielded_syncing { - DashColors::DASH_BLUE - } else { - DashColors::text_secondary(dark_mode) - }, - )); - + ui.colored_label( + DashColors::ERROR, + RichText::new("Disconnected").size(sz), + ); + } + } + CoreBackendMode::Spv => { + let snapshot = self.app_context.spv_manager().status(); + match snapshot.status { + SpvStatus::Idle | SpvStatus::Stopped => { ui.label( - RichText::new("|") - .size(12.0) - .color(DashColors::text_secondary(dark_mode)), + RichText::new("Disconnected").size(sz).color(secondary), ); - - let nf_text = if let Some(t) = nf_synced_at { - let ago = Self::format_instant_ago(t); - format!("Nullifiers: height {} ({})", nf_height, ago) - } else if nf_height > 0 { - format!("Nullifiers: height {}", nf_height) - } else { - "Nullifiers: never synced".to_string() - }; - ui.label(RichText::new(nf_text).size(12.0).color( - if shielded_syncing { - DashColors::DASH_BLUE - } else { - DashColors::text_secondary(dark_mode) - }, - )); } - None => { + SpvStatus::Starting => { + ui.add(egui::Spinner::new().size(sz).color(syncing_color)); ui.label( - RichText::new("Notes: never synced") - .size(12.0) - .color(DashColors::text_secondary(dark_mode)), + RichText::new("Connecting...") + .size(sz) + .color(syncing_color), ); + } + SpvStatus::Syncing => { + ui.add(egui::Spinner::new().size(sz).color(syncing_color)); + let phase_text = snapshot + .sync_progress + .as_ref() + .map(spv_phase_summary) + .unwrap_or_else(|| "starting...".to_string()); ui.label( - RichText::new("|") - .size(12.0) - .color(DashColors::text_secondary(dark_mode)), + RichText::new(format!("Syncing — {phase_text}")) + .size(sz) + .color(syncing_color), ); + } + SpvStatus::Running => { + ui.colored_label( + Color32::DARK_GREEN, + RichText::new(format!( + "Synced — {} peers", + snapshot.connected_peers + )) + .size(sz), + ); + } + SpvStatus::Stopping => { + ui.add(egui::Spinner::new().size(sz).color(syncing_color)); ui.label( - RichText::new("Nullifiers: never synced") - .size(12.0) - .color(DashColors::text_secondary(dark_mode)), + RichText::new("Disconnecting...") + .size(sz) + .color(syncing_color), ); } + SpvStatus::Error => { + ui.colored_label( + DashColors::ERROR, + RichText::new("Error").size(sz), + ); + } + } + } + } + }); + + // -- Platform: Addresses -- + let addr_count = self + .selected_wallet + .as_ref() + .and_then(|w| w.read().ok()) + .map(|w| w.platform_address_info.len()) + .unwrap_or(0); + let addr_color = if self.refreshing { + syncing_color + } else { + secondary + }; + ui.horizontal(|ui| { + ui.label(RichText::new("•").size(sz).color(secondary)); + if self.refreshing { + ui.add(egui::Spinner::new().size(sz).color(syncing_color)); + } + let addr_text = + if let Some((last_sync_ts, sync_height)) = self.platform_sync_info { + let ago = Self::format_unix_time_ago(last_sync_ts); + format!( + "Addresses: {} synced (blk {}, {})", + addr_count, sync_height, ago + ) + } else { + "Addresses: never synced".to_string() + }; + ui.label(RichText::new(addr_text).size(sz).color(addr_color)); + }); + + // -- Shielded: Notes + Nullifiers -- + let seed_hash = self + .selected_wallet + .as_ref() + .and_then(|w| w.read().ok().map(|g| g.seed_hash())); + let shielded_info = seed_hash.and_then(|hash| { + let states = self.app_context.shielded_states.lock().ok()?; + let state = states.get(&hash)?; + Some(( + state.last_synced_index, + state.notes.iter().filter(|n| !n.is_spent).count(), + state.last_nullifier_sync_height, + state.last_notes_synced_at, + state.last_nullifiers_synced_at, + )) + }); + let shielded_syncing = self + .shielded_tab_view + .as_ref() + .is_some_and(|v| v.is_syncing()); + let shielded_color = if shielded_syncing { + syncing_color + } else { + secondary + }; + + match shielded_info { + Some((synced_index, note_count, nf_height, notes_synced_at, nf_synced_at)) => { + // Notes bullet + ui.horizontal(|ui| { + ui.label(RichText::new("•").size(sz).color(secondary)); + if shielded_syncing { + ui.add(egui::Spinner::new().size(sz).color(syncing_color)); } + let notes_text = if let Some(t) = notes_synced_at { + let ago = Self::format_instant_ago(t); + format!( + "Notes: {} synced ({} notes, {})", + synced_index, note_count, ago + ) + } else if synced_index > 0 { + format!("Notes: {} synced ({} notes)", synced_index, note_count) + } else { + "Notes: never synced".to_string() + }; + ui.label(RichText::new(notes_text).size(sz).color(shielded_color)); }); - }); + // Nullifiers bullet + ui.horizontal(|ui| { + ui.label(RichText::new("•").size(sz).color(secondary)); + let nf_text = if let Some(t) = nf_synced_at { + let ago = Self::format_instant_ago(t); + format!("Nullifiers: height {} ({})", nf_height, ago) + } else if nf_height > 0 { + format!("Nullifiers: height {}", nf_height) + } else { + "Nullifiers: never synced".to_string() + }; + ui.label(RichText::new(nf_text).size(sz).color(shielded_color)); + }); + } + None => { + ui.horizontal(|ui| { + ui.label(RichText::new("•").size(sz).color(secondary)); + ui.label( + RichText::new("Notes: never synced") + .size(sz) + .color(secondary), + ); + }); + ui.horizontal(|ui| { + ui.label(RichText::new("•").size(sz).color(secondary)); + ui.label( + RichText::new("Nullifiers: never synced") + .size(sz) + .color(secondary), + ); + }); + } + } }, ); } + /// Render the total balance label only (used in the left column of the header). + fn render_balance_total(&self, ui: &mut Ui, wallet: &Wallet) { + let dark_mode = ui.ctx().style().visuals.dark_mode; + let core_balance = wallet.total_balance_duffs(); + let platform_balance = Self::platform_balance_duffs(wallet); + let shielded_balance = self.shielded_balance_duffs(&wallet.seed_hash()); + let total = core_balance + platform_balance + shielded_balance; + + ui.label( + RichText::new(format!("Balance: {}", Self::format_dash(total))) + .color(DashColors::text_primary(dark_mode)) + .size(20.0) + .strong(), + ); + } + + /// Render the collapsible breakdown detail (used in the right column of the header). + fn render_balance_breakdown_detail(&mut self, ui: &mut Ui, wallet: &Wallet) { + let dark_mode = ui.ctx().style().visuals.dark_mode; + let core_balance = wallet.total_balance_duffs(); + let platform_balance = Self::platform_balance_duffs(wallet); + let shielded_balance = self.shielded_balance_duffs(&wallet.seed_hash()); + + let header = egui::CollapsingHeader::new( + RichText::new("Balance breakdown") + .size(13.0) + .color(DashColors::text_secondary(dark_mode)), + ) + .id_salt("balance_breakdown") + .default_open(self.app_context.is_developer_mode()); + + header.show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(format!("Core: {}", Self::format_dash(core_balance))); + ui.label(" | "); + ui.label(format!("Platform: {}", Self::format_dash(platform_balance))); + ui.label(" | "); + ui.label(format!("Shielded: {}", Self::format_dash(shielded_balance))); + }); + }); + } + fn render_wallet_detail_panel(&mut self, ui: &mut Ui, ctx: &Context) -> AppAction { let Some(wallet_arc) = self.selected_wallet.clone() else { self.render_no_wallets_view(ui); @@ -1635,132 +2017,67 @@ impl WalletsBalancesScreen { .fill(DashColors::surface(dark_mode)) .inner_margin(Margin::symmetric(18, 16)) .show(col, |ui| { - ui.horizontal(|ui| { - ui.heading( - RichText::new(alias.clone()) - .color(DashColors::text_primary(dark_mode)) - .size(25.0), - ); - - ui.with_layout( - egui::Layout::right_to_left(egui::Align::Center), - |ui| { - if self.refreshing { - ui.add(egui::Spinner::new().color(DashColors::DASH_BLUE)) - } else { - ui.add(egui::Label::new("")) - } - }, - ); - }); + // --- Two-column header --- + let available = ui.available_width(); + let left_width = available * 0.55; + let right_width = available - left_width; - // Tab bar: Balances | Shielded - ui.add_space(6.0); ui.horizontal(|ui| { - let balances_text = if self.selected_tab == WalletViewTab::Balances { - RichText::new("Balances") - .strong() - .color(DashColors::DASH_BLUE) - } else { - RichText::new("Balances") - .color(DashColors::text_secondary(dark_mode)) - }; - if ui - .selectable_label( - self.selected_tab == WalletViewTab::Balances, - balances_text, - ) - .clicked() - { - self.selected_tab = WalletViewTab::Balances; - } + // LEFT COLUMN: name, total balance + ui.vertical(|ui| { + ui.set_width(left_width); - let shielded_text = if self.selected_tab == WalletViewTab::Shielded { - RichText::new("Shielded") - .strong() - .color(DashColors::DASH_BLUE) - } else { - RichText::new("Shielded") - .color(DashColors::text_secondary(dark_mode)) - }; - if ui - .selectable_label( - self.selected_tab == WalletViewTab::Shielded, - shielded_text, - ) - .clicked() - { - self.selected_tab = WalletViewTab::Shielded; - } - }); - ui.separator(); - ui.add_space(4.0); - - match self.selected_tab { - WalletViewTab::Balances => { - let summaries = { - let wallet = wallet_arc.read().unwrap(); - self.render_wallet_overview(ui, &wallet); - collect_account_summaries(&wallet, self.app_context.network) - }; - - self.ensure_account_selection(&summaries); - action |= self.render_action_buttons(ui, ctx); - ui.add_space(10.0); - ui.separator(); - self.render_accounts_section(ui, &summaries); - ui.add_space(10.0); - ui.separator(); - ui.add_space(10.0); - let addresses_heading = self - .selected_account - .as_ref() - .map(|(category, index)| { - format!("Addresses ({})", category.label(*index)) - }) - .unwrap_or_else(|| "Addresses".to_string()); + // Wallet name + [DEV] badge 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", - ); - }, + RichText::new(alias.clone()) + .color(DashColors::text_primary(dark_mode)) + .size(25.0), ); + if self.app_context.is_developer_mode() { + ui.label( + RichText::new("[DEV]") + .color(DashColors::text_secondary(dark_mode)) + .size(12.0), + ); + } }); - ui.add_space(8.0); - action |= self.render_address_table(ui); - - // Transactions section - requires SPV which is dev mode only - if self.app_context.is_developer_mode() { - ui.add_space(10.0); - ui.separator(); - self.render_transactions_section(ui); + + // Total balance line + { + let wallet = wallet_arc.read().unwrap(); + self.render_balance_total(ui, &wallet); } + }); - ui.add_space(14.0); - self.render_bottom_options(ui); + // RIGHT COLUMN: balance breakdown + sync status, right-aligned + ui.with_layout(egui::Layout::top_down(egui::Align::Max), |ui| { + ui.set_width(right_width); - ui.add_space(16.0); - action |= self.render_wallet_asset_locks(ui); - } - WalletViewTab::Shielded => { - let seed_hash = wallet_arc.read().unwrap().seed_hash(); - let shielded_view = - self.shielded_tab_view.get_or_insert_with(|| { - ShieldedTabView::new(&self.app_context, seed_hash) - }); - shielded_view.update_seed_hash(seed_hash); - shielded_view.update_app_context(&self.app_context); - action |= shielded_view.ui(ui); - } - } + // Collapsible balance breakdown + { + let wallet = wallet_arc.read().unwrap(); + self.render_balance_breakdown_detail(ui, &wallet); + } + + // Collapsible sync status + self.render_sync_status(ui); + }); + }); + + // Action buttons span full width below the header + action |= self.render_action_buttons(ui, ctx); + + // --- Accounts & Addresses (tabs, full-width below header) --- + ui.add_space(10.0); + ui.separator(); + + let summaries = { + let wallet = wallet_arc.read().unwrap(); + collect_account_summaries(&wallet, self.app_context.network) + }; + self.ensure_account_selection(&summaries); + action |= self.render_account_tabs(ui, &summaries); }); }); }); @@ -1768,23 +2085,10 @@ impl WalletsBalancesScreen { action } - fn ensure_account_selection(&mut self, summaries: &[AccountSummary]) { - if summaries.is_empty() { - self.selected_account = None; - return; - } - - if let Some((cat, idx)) = &self.selected_account - && summaries - .iter() - .any(|summary| &summary.category == cat && summary.index == *idx) - { - return; - } - - if let Some(first) = summaries.first() { - self.selected_account = Some((first.category.clone(), first.index)); - } + fn ensure_account_selection(&mut self, _summaries: &[AccountSummary]) { + // The tab bar in `render_account_tabs` already validates + // `selected_account_tab` against the built tab list and resets it + // to the first tab if invalid. Nothing extra needed here. } fn lock_selected_wallet(&mut self) { @@ -1826,7 +2130,7 @@ impl WalletsBalancesScreen { /// Returns a SyncNotes backend task if the shielded wallet has been initialized /// for the given seed hash. fn shielded_sync_task(&self, seed_hash: &WalletSeedHash) -> Option { - let states = self.app_context.shielded_states.lock().unwrap(); + let states = self.app_context.shielded_states.lock().ok()?; if states.contains_key(seed_hash) { Some(BackendTask::ShieldedTask(ShieldedTask::SyncNotes { seed_hash: *seed_hash, @@ -1919,6 +2223,20 @@ impl ScreenLike for WalletsBalancesScreen { AppAction::None }; + // Tick the shielded tab view to drain any pending user-initiated + // tasks (e.g. Resync) even when the Shielded tab is not active. + // Skip when the Shielded tab IS active — its ui() method already + // calls tick(), and double-ticking would acquire the lock twice + // per frame for no benefit. + let shielded_tick_action = if self.selected_account_tab != AccountTab::Shielded { + self.shielded_tab_view + .as_mut() + .map(|v| v.tick()) + .unwrap_or(AppAction::None) + } else { + AppAction::None + }; + let mut right_buttons = vec![ ( "Import Wallet", @@ -1996,12 +2314,6 @@ impl ScreenLike for WalletsBalancesScreen { ui.add_space(10.0); - // Sync status panel (only for HD wallets) - if self.selected_wallet.is_some() { - self.render_sync_status(ui); - ui.add_space(6.0); - } - // Render the appropriate detail view based on selection if self.selected_wallet.is_some() { inner_action |= self.render_wallet_detail_panel(ui, ctx); @@ -2392,6 +2704,7 @@ impl ScreenLike for WalletsBalancesScreen { // Combine with pending actions action |= pending_refresh_action; action |= pending_switch_action; + action |= shielded_tick_action; action } @@ -2399,6 +2712,7 @@ impl ScreenLike for WalletsBalancesScreen { // Banner display is handled globally by AppState; this is only for side-effects. // Always clear refreshing — the originating task is done regardless of result type. self.refreshing = false; + self.generating_core_address = false; if matches!(message_type, MessageType::Error | MessageType::Warning) { self.asset_lock_search_banner.take_and_clear(); @@ -2521,6 +2835,7 @@ impl ScreenLike for WalletsBalancesScreen { MessageBanner::set_global(self.app_context.egui_ctx(), &msg, MessageType::Success); } crate::ui::BackendTaskSuccessResult::GeneratedReceiveAddress { seed_hash, address } => { + self.generating_core_address = false; if let Some(selected) = &self.selected_wallet && let Ok(wallet) = selected.read() && wallet.seed_hash() == seed_hash @@ -2541,6 +2856,12 @@ impl ScreenLike for WalletsBalancesScreen { self.receive_dialog.qr_texture = None; self.receive_dialog.qr_address = None; self.receive_dialog.status = None; + + MessageBanner::set_global( + self.app_context.egui_ctx(), + format!("New receive address generated: {address}"), + MessageType::Success, + ); } } crate::ui::BackendTaskSuccessResult::PlatformAddressWithdrawal { .. } => { diff --git a/tests/backend-e2e/main.rs b/tests/backend-e2e/main.rs index 2363aea40..e16cd371e 100644 --- a/tests/backend-e2e/main.rs +++ b/tests/backend-e2e/main.rs @@ -16,3 +16,4 @@ mod identity_withdraw; mod register_dpns; mod send_funds; mod spv_wallet; +mod tx_is_ours; diff --git a/tests/backend-e2e/tx_is_ours.rs b/tests/backend-e2e/tx_is_ours.rs new file mode 100644 index 000000000..1281b69d2 --- /dev/null +++ b/tests/backend-e2e/tx_is_ours.rs @@ -0,0 +1,135 @@ +//! Test: Verify `is_ours` flag is set correctly for SPV transactions. +//! +//! SPV transactions pass through bloom filter → `check_transaction()` (address +//! matching) → `record_transaction()`. The upstream library sets `is_ours` only +//! for sends (`net_amount < 0`). We override to `true` for all matched +//! transactions in the SPV reconcile path, since `check_transaction` already +//! verified address ownership (bloom filter FPs are filtered there). +//! +//! This test sends funds between two wallets and verifies that both the sender +//! and receiver have `is_ours: true` on the resulting transaction. + +use crate::framework::harness::ctx; +use crate::framework::identity_helpers::get_receive_address; +use crate::framework::task_runner::run_task; +use crate::framework::wait::{wait_for_balance, wait_for_spendable_balance}; +use dash_evo_tool::backend_task::core::{CoreTask, PaymentRecipient, WalletPaymentRequest}; +use dash_evo_tool::backend_task::{BackendTask, BackendTaskSuccessResult}; +use std::time::Duration; + +/// After an SPV send, both sender and receiver wallets must have `is_ours: true` +/// on the resulting transaction. +#[ignore] +#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] +async fn test_spv_transactions_is_ours_flag() { + let ctx = ctx().await; + let app_context = &ctx.app_context; + + // Create two funded wallets + let (hash_a, wallet_a) = ctx.create_funded_test_wallet(3_000_000).await; + let (hash_b, wallet_b) = ctx.create_funded_test_wallet(1_000_000).await; + + let send_amount: u64 = 500_000; + let b_address = get_receive_address(app_context, &wallet_b); + + // Wait for A to have spendable funds + wait_for_spendable_balance(app_context, hash_a, send_amount, Duration::from_secs(120)) + .await + .expect("Wallet A should have spendable funds"); + + // Send from A to B + let request = WalletPaymentRequest { + recipients: vec![PaymentRecipient { + address: b_address.clone(), + amount_duffs: send_amount, + }], + subtract_fee_from_amount: false, + memo: Some("is_ours test".to_string()), + override_fee: None, + }; + + let task = BackendTask::CoreTask(CoreTask::SendWalletPayment { + wallet: wallet_a.clone(), + request, + }); + + let result = run_task(app_context, task) + .await + .expect("Payment A->B should succeed"); + + let payment_txid = match &result { + BackendTaskSuccessResult::WalletPayment { txid, .. } => { + tracing::info!("Payment txid: {txid}"); + txid.clone() + } + other => panic!("Expected WalletPayment, got: {other:?}"), + }; + + // Wait for B to receive the funds (ensures SPV has propagated the tx) + let initial_b = { + let w = wallet_b.read().expect("lock"); + w.total_balance_duffs() + }; + wait_for_balance( + app_context, + hash_b, + initial_b + send_amount, + Duration::from_secs(120), + ) + .await + .expect("B should receive funds"); + + // Force a reconcile to ensure latest SPV state is reflected + app_context + .reconcile_spv_wallets() + .await + .expect("reconcile should succeed"); + + // Check is_ours on wallet A (sender) — should be true + { + let wallets = app_context.wallets().read().expect("wallets lock"); + let wallet = wallets + .get(&hash_a) + .expect("wallet A") + .read() + .expect("lock"); + let tx = wallet + .transactions + .iter() + .find(|t| t.txid.to_string() == payment_txid) + .unwrap_or_else(|| panic!("Wallet A should have tx {payment_txid}")); + assert!( + tx.is_ours, + "Sender wallet should have is_ours=true for outgoing tx {payment_txid}" + ); + assert!( + tx.net_amount < 0, + "Sender tx should have negative net_amount" + ); + } + + // Check is_ours on wallet B (receiver) — should be true + { + let wallets = app_context.wallets().read().expect("wallets lock"); + let wallet = wallets + .get(&hash_b) + .expect("wallet B") + .read() + .expect("lock"); + let tx = wallet + .transactions + .iter() + .find(|t| t.txid.to_string() == payment_txid) + .unwrap_or_else(|| panic!("Wallet B should have tx {payment_txid}")); + assert!( + tx.is_ours, + "Receiver wallet should have is_ours=true for incoming tx {payment_txid}" + ); + assert!( + tx.net_amount > 0, + "Receiver tx should have positive net_amount" + ); + } + + tracing::info!("is_ours flag verified for both sender and receiver"); +}