From b29b23a5ff03dc8a1e265e0ccc65f6e18b9dbd99 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 5 Feb 2026 16:38:51 +0100 Subject: [PATCH 01/18] fix: connection status not updated --- src/app.rs | 212 +++++++++++----------- src/context.rs | 10 +- src/context/connection_status.rs | 294 +++++++++++++++++++++++++++++++ src/ui/components/top_panel.rs | 21 +-- src/ui/network_chooser_screen.rs | 180 +++++++++++++------ 5 files changed, 544 insertions(+), 173 deletions(-) create mode 100644 src/context/connection_status.rs diff --git a/src/app.rs b/src/app.rs index cecc1a608..6283565e5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -789,14 +789,14 @@ impl App for AppState { // Apply Dash theme with user preference crate::ui::theme::apply_theme(ctx, self.theme_preference); - if let Ok(event) = self.current_app_context().rx_zmq_status.try_recv() - && let Ok(mut status) = self.current_app_context().zmq_connection_status.lock() - { - *status = event; - } + let active_context = self.current_app_context().clone(); // Poll the receiver for any new task results while let Ok(task_result) = self.task_result_receiver.try_recv() { + active_context + .connection_status() + .handle_task_result(&task_result, active_context.network); + // Handle the result on the main thread match task_result { TaskResult::Success(message) => { @@ -984,112 +984,120 @@ impl App for AppState { } // Show welcome screen if onboarding not completed - let action = if self.show_welcome_screen { - if let Some(welcome_screen) = &mut self.welcome_screen { - welcome_screen.ui(ctx) - } else { - AppAction::None - } + let mut actions = Vec::new(); + if self.show_welcome_screen + && let Some(welcome_screen) = &mut self.welcome_screen + { + actions.push(welcome_screen.ui(ctx)); } else { - self.visible_screen_mut().ui(ctx) + actions.push(self.visible_screen_mut().ui(ctx)); }; - match action { - AppAction::AddScreen(screen) => self.screen_stack.push(screen), - AppAction::None => {} - AppAction::Refresh => self.visible_screen_mut().refresh(), - AppAction::PopScreen => { - if !self.screen_stack.is_empty() { - self.screen_stack.pop(); + // Schedule connection status refresh + actions.push( + active_context + .connection_status() + .trigger_refresh(active_context.as_ref()), + ); + + for action in actions { + match action { + AppAction::None => {} + AppAction::AddScreen(screen) => self.screen_stack.push(screen), + AppAction::Refresh => self.visible_screen_mut().refresh(), + AppAction::PopScreen => { + if !self.screen_stack.is_empty() { + self.screen_stack.pop(); + } } - } - AppAction::PopScreenAndRefresh => { - if !self.screen_stack.is_empty() { - self.screen_stack.pop(); + AppAction::PopScreenAndRefresh => { + if !self.screen_stack.is_empty() { + self.screen_stack.pop(); + } + if let Some(screen) = self.screen_stack.last_mut() { + screen.refresh(); + } else { + self.active_root_screen_mut().refresh_on_arrival(); + } } - if let Some(screen) = self.screen_stack.last_mut() { - screen.refresh(); - } else { + AppAction::GoToMainScreen => { + self.screen_stack = vec![]; self.active_root_screen_mut().refresh_on_arrival(); } - } - AppAction::GoToMainScreen => { - self.screen_stack = vec![]; - self.active_root_screen_mut().refresh_on_arrival(); - } - AppAction::BackendTask(task) => { - self.handle_backend_task(task); - } - AppAction::BackendTasks(tasks, mode) => { - self.handle_backend_tasks(tasks, mode); - } - AppAction::SetMainScreen(root_screen_type) => { - self.selected_main_screen = root_screen_type; - self.active_root_screen_mut().refresh_on_arrival(); - self.current_app_context() - .update_settings(root_screen_type) - .ok(); - } - AppAction::SetMainScreenThenGoToMainScreen(root_screen_type) => { - self.selected_main_screen = root_screen_type; - self.active_root_screen_mut().refresh_on_arrival(); - self.current_app_context() - .update_settings(root_screen_type) - .ok(); - self.screen_stack = vec![]; - } - AppAction::SetMainScreenThenPopScreen(root_screen_type) => { - self.selected_main_screen = root_screen_type; - self.active_root_screen_mut().refresh_on_arrival(); - self.current_app_context() - .update_settings(root_screen_type) - .ok(); - if !self.screen_stack.is_empty() { - self.screen_stack.pop(); + AppAction::BackendTask(task) => { + self.handle_backend_task(task); } - } - AppAction::SwitchNetwork(network) => { - self.change_network(network); - self.current_app_context() - .update_settings(RootScreenType::RootScreenNetworkChooser) - .ok(); - } - AppAction::PopThenAddScreenToMainScreen(root_screen_type, screen) => { - self.screen_stack = vec![screen]; - self.selected_main_screen = root_screen_type; - self.active_root_screen_mut().refresh_on_arrival(); - self.current_app_context() - .update_settings(root_screen_type) - .ok(); - } - AppAction::Custom(_) => {} - AppAction::OnboardingComplete { - main_screen, - add_screen, - } => { - self.show_welcome_screen = false; - self.welcome_screen = None; - self.selected_main_screen = main_screen; - self.active_root_screen_mut().refresh_on_arrival(); - self.current_app_context().update_settings(main_screen).ok(); - // If there's an additional screen to push, create and push it - if let Some(screen_type) = add_screen { - let screen = screen_type.create_screen(self.current_app_context()); - self.screen_stack.push(screen); + AppAction::BackendTasks(tasks, mode) => { + self.handle_backend_tasks(tasks, mode); } - // Start SPV sync after onboarding completes (if auto-start is enabled and developer mode is on) - // TODO: SPV auto-start is gated behind developer mode while SPV is in development. - // Remove the is_developer_mode() check once SPV is production-ready. - let current_context = self.current_app_context(); - let auto_start_spv = current_context.db.get_auto_start_spv().unwrap_or(false); - if auto_start_spv - && current_context.is_developer_mode() - && current_context.core_backend_mode() == crate::spv::CoreBackendMode::Spv - { - if let Err(e) = current_context.start_spv() { - tracing::warn!("Failed to start SPV sync after onboarding: {}", e); - } else { - tracing::info!("SPV sync started after onboarding"); + AppAction::SetMainScreen(root_screen_type) => { + self.selected_main_screen = root_screen_type; + self.active_root_screen_mut().refresh_on_arrival(); + self.current_app_context() + .update_settings(root_screen_type) + .ok(); + } + AppAction::SetMainScreenThenGoToMainScreen(root_screen_type) => { + self.selected_main_screen = root_screen_type; + self.active_root_screen_mut().refresh_on_arrival(); + self.current_app_context() + .update_settings(root_screen_type) + .ok(); + self.screen_stack = vec![]; + } + AppAction::SetMainScreenThenPopScreen(root_screen_type) => { + self.selected_main_screen = root_screen_type; + self.active_root_screen_mut().refresh_on_arrival(); + self.current_app_context() + .update_settings(root_screen_type) + .ok(); + if !self.screen_stack.is_empty() { + self.screen_stack.pop(); + } + } + AppAction::SwitchNetwork(network) => { + self.change_network(network); + self.current_app_context() + .update_settings(RootScreenType::RootScreenNetworkChooser) + .ok(); + } + AppAction::PopThenAddScreenToMainScreen(root_screen_type, screen) => { + self.screen_stack = vec![screen]; + self.selected_main_screen = root_screen_type; + self.active_root_screen_mut().refresh_on_arrival(); + self.current_app_context() + .update_settings(root_screen_type) + .ok(); + } + AppAction::Custom(_) => {} + AppAction::OnboardingComplete { + main_screen, + add_screen, + } => { + self.show_welcome_screen = false; + self.welcome_screen = None; + self.selected_main_screen = main_screen; + self.active_root_screen_mut().refresh_on_arrival(); + self.current_app_context().update_settings(main_screen).ok(); + // If there's an additional screen to push, create and push it + if let Some(screen_type) = add_screen { + let screen = screen_type.create_screen(self.current_app_context()); + self.screen_stack.push(screen); + } + // Start SPV sync after onboarding completes (if auto-start is enabled and developer mode is on) + // TODO: SPV auto-start is gated behind developer mode while SPV is in development. + // Remove the is_developer_mode() check once SPV is production-ready. + let current_context = self.current_app_context(); + let auto_start_spv = current_context.db.get_auto_start_spv().unwrap_or(false); + if auto_start_spv + && current_context.is_developer_mode() + && current_context.core_backend_mode() == crate::spv::CoreBackendMode::Spv + { + if let Err(e) = current_context.start_spv() { + tracing::warn!("Failed to start SPV sync after onboarding: {}", e); + } else { + tracing::info!("SPV sync started after onboarding"); + } } } } diff --git a/src/context.rs b/src/context.rs index 4d7be12ff..b0e1ab2b0 100644 --- a/src/context.rs +++ b/src/context.rs @@ -1,6 +1,7 @@ use crate::app_dir::core_cookie_path; use crate::backend_task::contested_names::ScheduledDPNSVote; use crate::components::core_zmq_listener::ZMQConnectionEvent; +pub mod connection_status; use crate::config::{Config, NetworkConfig}; use crate::context_provider::Provider as RpcProvider; use crate::context_provider_spv::SpvProvider; @@ -18,6 +19,7 @@ use crate::model::wallet::{ }; use crate::sdk_wrapper::initialize_sdk; use crate::spv::{CoreBackendMode, SpvManager}; +use connection_status::ConnectionStatus; use crate::ui::RootScreenType; use crate::ui::tokens::tokens_screen::{IdentityTokenBalance, IdentityTokenIdentifier}; use crate::utils::tasks::TaskManager; @@ -75,7 +77,6 @@ pub struct AppContext { pub(crate) config: Arc>, pub(crate) rx_zmq_status: Receiver, pub(crate) sx_zmq_status: Sender, - pub(crate) zmq_connection_status: Mutex, pub(crate) dpns_contract: Arc, pub(crate) withdraws_contract: Arc, pub(crate) dashpay_contract: Arc, @@ -100,6 +101,7 @@ pub struct AppContext { pub(crate) subtasks: Arc, pub(crate) spv_manager: Arc, core_backend_mode: AtomicU8, + pub(crate) connection_status: ConnectionStatus, /// Pending wallet selection - set after creating/importing a wallet /// so the wallet screen can auto-select the new wallet pub(crate) pending_wallet_selection: Mutex>, @@ -270,12 +272,12 @@ impl AppContext { single_key_wallets: RwLock::new(single_key_wallets), password_info, transactions_waiting_for_finality: Mutex::new(BTreeMap::new()), - zmq_connection_status: Mutex::new(ZMQConnectionEvent::Disconnected), animate, cached_settings: RwLock::new(None), subtasks, spv_manager, core_backend_mode: AtomicU8::new(saved_core_backend_mode), + connection_status: ConnectionStatus::new(), pending_wallet_selection: Mutex::new(None), selected_wallet_hash: Mutex::new(selected_wallet_hash), selected_single_key_hash: Mutex::new(selected_single_key_hash), @@ -349,6 +351,10 @@ impl AppContext { self.core_backend_mode.load(Ordering::Relaxed).into() } + pub fn connection_status(&self) -> &ConnectionStatus { + &self.connection_status + } + pub fn set_core_backend_mode(self: &Arc, mode: CoreBackendMode) { self.core_backend_mode .store(mode.as_u8(), Ordering::Relaxed); diff --git a/src/context/connection_status.rs b/src/context/connection_status.rs new file mode 100644 index 000000000..0172409c0 --- /dev/null +++ b/src/context/connection_status.rs @@ -0,0 +1,294 @@ +use crate::app::AppAction; +use crate::app::TaskResult; +use crate::backend_task::BackendTask; +use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::core::{CoreItem, CoreTask}; +use crate::components::core_zmq_listener::ZMQConnectionEvent; +use crate::spv::{CoreBackendMode, SpvStatus}; +use dash_sdk::dpp::dashcore::{ChainLock, Network}; +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::time::{Duration, Instant}; + +const REFRESH_CONNECTED: Duration = Duration::from_secs(10); +const REFRESH_DISCONNECTED: Duration = Duration::from_secs(2); +#[derive(Debug)] +pub struct ConnectionStatus { + rpc_online: AtomicBool, + zmq_status: Mutex, + spv_status: AtomicU8, + backend_mode: AtomicU8, + disable_zmq: AtomicBool, + overall_connected: AtomicBool, + last_update: Mutex, +} + +impl ConnectionStatus { + pub fn new() -> Self { + Self { + rpc_online: AtomicBool::new(false), + zmq_status: Mutex::new(ZMQConnectionEvent::Disconnected), + spv_status: AtomicU8::new(SpvStatus::Idle as u8), + backend_mode: AtomicU8::new(CoreBackendMode::Rpc.as_u8()), + disable_zmq: AtomicBool::new(false), + overall_connected: AtomicBool::new(false), + last_update: Mutex::new(Instant::now()), + } + } + + pub fn rpc_online(&self) -> bool { + self.rpc_online.load(Ordering::Relaxed) + } + + pub fn set_rpc_online(&self, online: bool) { + self.rpc_online.store(online, Ordering::Relaxed); + } + + pub fn zmq_connected(&self) -> bool { + self.zmq_status + .lock() + .map(|status| matches!(*status, ZMQConnectionEvent::Connected)) + .unwrap_or(false) + } + + pub fn set_zmq_status(&self, event: ZMQConnectionEvent) { + if let Ok(mut status) = self.zmq_status.lock() { + *status = event; + } + } + + pub fn spv_status(&self) -> SpvStatus { + match self.spv_status.load(Ordering::Relaxed) { + 1 => SpvStatus::Starting, + 2 => SpvStatus::Syncing, + 3 => SpvStatus::Running, + 4 => SpvStatus::Stopping, + 5 => SpvStatus::Stopped, + 6 => SpvStatus::Error, + _ => SpvStatus::Idle, + } + } + + pub fn set_spv_status(&self, status: SpvStatus) { + self.spv_status.store(status as u8, Ordering::Relaxed); + } + + pub fn backend_mode(&self) -> CoreBackendMode { + self.backend_mode.load(Ordering::Relaxed).into() + } + + pub fn set_backend_mode(&self, mode: CoreBackendMode) { + self.backend_mode.store(mode.as_u8(), Ordering::Relaxed); + } + + pub fn disable_zmq(&self) -> bool { + self.disable_zmq.load(Ordering::Relaxed) + } + + pub fn set_disable_zmq(&self, disable: bool) { + self.disable_zmq.store(disable, Ordering::Relaxed); + } + + pub fn spv_connected(status: SpvStatus) -> bool { + status.is_active() || status == SpvStatus::Running + } + + pub fn rpc_connected(&self) -> bool { + self.rpc_online() + } + + pub fn zmq_required(&self) -> bool { + !self.disable_zmq() + } + + pub fn rpc_zmq_healthy(&self) -> bool { + self.rpc_online() && (self.disable_zmq() || self.zmq_connected()) + } + + pub fn overall_connected(&self) -> bool { + self.overall_connected.load(Ordering::Relaxed) + } + + pub fn refresh_overall(&self) { + let backend_mode = self.backend_mode(); + let disable_zmq = self.disable_zmq(); + let spv_status = self.spv_status(); + let connected = match backend_mode { + CoreBackendMode::Rpc => self.rpc_online() && (disable_zmq || self.zmq_connected()), + CoreBackendMode::Spv => Self::spv_connected(spv_status), + }; + self.overall_connected.store(connected, Ordering::Relaxed); + } + + pub fn overall_connected_with( + &self, + backend_mode: CoreBackendMode, + disable_zmq: bool, + spv_status: SpvStatus, + ) -> bool { + match backend_mode { + CoreBackendMode::Rpc => self.rpc_online() && (disable_zmq || self.zmq_connected()), + CoreBackendMode::Spv => Self::spv_connected(spv_status), + } + } + + pub fn tooltip_text(&self) -> String { + let backend_mode = self.backend_mode(); + let disable_zmq = self.disable_zmq(); + let spv_status = self.spv_status(); + match backend_mode { + CoreBackendMode::Rpc => { + let rpc_status = if self.rpc_online() { + "RPC: Connected" + } else { + "RPC: Disconnected" + }; + let zmq_status = if disable_zmq { + "ZMQ: Disabled" + } else if self.zmq_connected() { + "ZMQ: Connected" + } else { + "ZMQ: Disconnected" + }; + + if self.overall_connected() { + format!("Connected to Dash Core Wallet\n{rpc_status}\n{zmq_status}") + } else if self.rpc_online() { + format!("Dash Core connection incomplete\n{rpc_status}\n{zmq_status}") + } else { + format!( + "Disconnected from Dash Core Wallet. Click to start it.\n{rpc_status}\n{zmq_status}" + ) + } + } + CoreBackendMode::Spv => { + let spv_label = format!("SPV: {:?}", spv_status); + if self.overall_connected() { + format!("SPV connected\n{spv_label}") + } else { + format!("SPV disconnected\n{spv_label}") + } + } + } + } + + pub fn update_from_chainlocks( + &self, + network: Network, + mainnet_chainlock: &Option, + testnet_chainlock: &Option, + devnet_chainlock: &Option, + local_chainlock: &Option, + ) { + let online = match network { + Network::Dash => mainnet_chainlock.is_some(), + Network::Testnet => testnet_chainlock.is_some(), + Network::Devnet => devnet_chainlock.is_some(), + Network::Regtest => local_chainlock.is_some(), + _ => false, + }; + self.set_rpc_online(online); + } + + pub fn handle_task_result(&self, task_result: &TaskResult, active_network: Network) { + match task_result { + TaskResult::Success(message) => match message.as_ref() { + BackendTaskSuccessResult::CoreItem(CoreItem::ChainLocks( + mainnet_chainlock, + testnet_chainlock, + devnet_chainlock, + local_chainlock, + )) => { + self.update_from_chainlocks( + active_network, + mainnet_chainlock, + testnet_chainlock, + devnet_chainlock, + local_chainlock, + ); + self.refresh_overall(); + } + BackendTaskSuccessResult::CoreItem(CoreItem::ChainLock(_, network)) => { + if *network == active_network { + self.set_rpc_online(true); + self.refresh_overall(); + } + } + _ => {} + }, + TaskResult::Error(message) => { + if message.contains( + "Failed to get best chain lock for mainnet, testnet, devnet, and local", + ) { + self.set_rpc_online(false); + self.refresh_overall(); + } + } + _ => {} + } + } + + pub fn trigger_refresh(&self, app_context: &crate::context::AppContext) -> AppAction { + // throttle updates to once every 2 seconds + let mut last_update = match self.last_update.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + let now = Instant::now(); + let timeout = if self.overall_connected() { + REFRESH_CONNECTED + } else { + REFRESH_DISCONNECTED + }; + if now.duration_since(*last_update) < timeout { + return AppAction::None; + } + *last_update = now; + + self.refersh_zmq_and_spv(app_context); + AppAction::BackendTask(BackendTask::CoreTask(CoreTask::GetBestChainLocks)) + } + + fn refersh_zmq_and_spv(&self, app_context: &crate::context::AppContext) { + // Get current backend mode + let backend_mode = app_context.core_backend_mode(); + self.set_backend_mode(backend_mode); + + if backend_mode == CoreBackendMode::Spv { + // SPV status is updated elsewhere + let spv_status = app_context.spv_manager().status().status; + self.set_spv_status(spv_status); + return; + } + + // just a safety check + if CoreBackendMode::Rpc != backend_mode { + tracing::error!( + "Unexpected backend mode in connection status refresh: {:?}", + backend_mode + ); + return; + } + + // Update ZMQ status if there's a new event + let disable_zmq = app_context + .get_settings() + .ok() + .flatten() + .map(|s| s.disable_zmq) + .unwrap_or(false); + self.set_disable_zmq(disable_zmq); + + if let Ok(event) = app_context.rx_zmq_status.try_recv() { + self.set_zmq_status(event); + } + + self.refresh_overall(); + } +} + +impl Default for ConnectionStatus { + fn default() -> Self { + Self::new() + } +} diff --git a/src/ui/components/top_panel.rs b/src/ui/components/top_panel.rs index 031817d92..b0a93a5b4 100644 --- a/src/ui/components/top_panel.rs +++ b/src/ui/components/top_panel.rs @@ -1,8 +1,8 @@ use crate::app::{AppAction, DesiredAppAction}; use crate::backend_task::BackendTask; use crate::backend_task::core::CoreTask; -use crate::components::core_zmq_listener::ZMQConnectionEvent; use crate::context::AppContext; +use crate::spv::CoreBackendMode; use crate::ui::ScreenType; use crate::ui::theme::{DashColors, Shadow, Shape}; use dash_sdk::dashcore_rpc::dashcore::Network; @@ -96,11 +96,9 @@ fn add_location_view(ui: &mut Ui, location: Vec<(&str, AppAction)>, dark_mode: b fn add_connection_indicator(ui: &mut Ui, app_context: &Arc) -> AppAction { let mut action = AppAction::None; - let connected = app_context - .zmq_connection_status - .lock() - .map(|status| matches!(*status, ZMQConnectionEvent::Connected)) - .unwrap_or(false); + let status = app_context.connection_status(); + let backend_mode = status.backend_mode(); + let connected = status.overall_connected(); // Get time for pulsating animation (only when connected) let pulse_scale = if connected { @@ -149,14 +147,13 @@ fn add_connection_indicator(ui: &mut Ui, app_context: &Arc) -> AppAc if connected { app_context.repaint_animation(ui.ctx()); } - let tip = if connected { - "Connected to Dash Core Wallet" - } else { - "Disconnected from Dash Core Wallet. Click to start it." - }; + let tip = status.tooltip_text(); let resp = resp.on_hover_text(tip); - if resp.clicked() && !connected { + if resp.clicked() + && backend_mode == CoreBackendMode::Rpc + && !status.rpc_online() + { let settings = app_context.get_settings().ok().flatten(); let (custom_path, overwrite) = settings diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index 527fafa98..75ff24a80 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -1,9 +1,10 @@ use crate::app::AppAction; -use crate::backend_task::core::{CoreItem, CoreTask}; +use crate::backend_task::core::CoreTask; use crate::backend_task::system_task::SystemTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::config::Config; use crate::context::AppContext; +use crate::context::connection_status::ConnectionStatus; use crate::model::wallet::DerivationPathHelpers; use crate::spv::{CoreBackendMode, SpvStatus, SpvStatusSnapshot}; use crate::ui::components::component_trait::Component; @@ -43,10 +44,6 @@ pub struct NetworkChooserScreen { pub local_app_context: Option>, pub local_network_dashmate_password: String, pub current_network: Network, - pub mainnet_core_status_online: bool, - pub testnet_core_status_online: bool, - pub devnet_core_status_online: bool, - pub local_core_status_online: bool, pub recheck_time: Option, custom_dash_qt_path: Option, custom_dash_qt_error_message: Option, @@ -141,10 +138,6 @@ impl NetworkChooserScreen { local_app_context: local_app_context.cloned(), local_network_dashmate_password, current_network, - mainnet_core_status_online: false, - testnet_core_status_online: false, - devnet_core_status_online: false, - local_core_status_online: false, recheck_time: None, custom_dash_qt_path, custom_dash_qt_error_message: None, @@ -450,20 +443,24 @@ impl NetworkChooserScreen { .entry(self.current_network) .or_insert(CoreBackendMode::Rpc); - // Check connection status - let (is_connected, snapshot) = match current_backend_mode { - CoreBackendMode::Rpc => (self.check_network_status(self.current_network), None), - CoreBackendMode::Spv => { - let ctx = self.current_app_context(); - let snap = ctx.spv_manager().status(); - let connected = snap.status.is_active() || snap.status == SpvStatus::Running; - (connected, Some(snap)) - } + let ctx = self.current_app_context(); + let status = ctx.connection_status(); + let disable_zmq = status.disable_zmq(); + let rpc_online = self.check_network_status(self.current_network); + let zmq_connected = status.zmq_connected(); + let spv_snapshot = ctx.spv_manager().status(); + let spv_status = status.spv_status(); + let spv_connected = ConnectionStatus::spv_connected(spv_status); + let snapshot = if current_backend_mode == CoreBackendMode::Spv { + Some(spv_snapshot.clone()) + } else { + None }; + let overall_connected = status.overall_connected(); // Button on the left with status ui.horizontal(|ui| { - if is_connected { + if overall_connected { if current_backend_mode == CoreBackendMode::Spv { let disconnect_button = egui::Button::new( egui::RichText::new("Disconnect").color(DashColors::WHITE), @@ -510,13 +507,22 @@ impl NetworkChooserScreen { } } else { // For Core mode, just show status since it can switch networks freely - ui.colored_label(DashColors::DASH_BLUE, "✅ Connected"); + let label = if disable_zmq { + "✅ Connected (RPC, ZMQ disabled)" + } else { + "✅ Connected (RPC + ZMQ)" + }; + ui.colored_label(DashColors::DASH_BLUE, label); } } else { // Don't show Connect button for Local network in RPC mode // (there's no Dash-Qt to start for local/regtest) - let show_connect_button = !(self.current_network == Network::Regtest - && current_backend_mode == CoreBackendMode::Rpc); + let show_connect_button = match current_backend_mode { + CoreBackendMode::Spv => true, + CoreBackendMode::Rpc => { + !rpc_online && self.current_network != Network::Regtest + } + }; if show_connect_button { let connect_button = egui::Button::new( @@ -552,6 +558,7 @@ impl NetworkChooserScreen { } } } + } }); @@ -568,6 +575,82 @@ impl NetworkChooserScreen { self.render_spv_sync_progress(ui, snap); } + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + ui.vertical(|ui| { + if current_backend_mode == CoreBackendMode::Rpc && !self.developer_mode { + ui.horizontal(|ui| { + ui.label("Core RPC:"); + let rpc_color = if rpc_online { + DashColors::SUCCESS + } else { + DashColors::ERROR + }; + let rpc_label = if rpc_online { "Connected" } else { "Disconnected" }; + ui.colored_label(rpc_color, rpc_label); + + ui.label(","); + ui.label("ZMQ:"); + if disable_zmq { + ui.colored_label(DashColors::text_secondary(dark_mode), "Disabled"); + } else { + let zmq_color = if zmq_connected { + DashColors::SUCCESS + } else { + DashColors::ERROR + }; + let zmq_label = if zmq_connected { "Connected" } else { "Disconnected" }; + ui.colored_label(zmq_color, zmq_label); + } + }); + } + + if current_backend_mode == CoreBackendMode::Rpc && self.developer_mode { + ui.horizontal(|ui| { + ui.label("Dash Core RPC:"); + let color = if rpc_online { + DashColors::SUCCESS + } else { + DashColors::ERROR + }; + let label = if rpc_online { "Connected" } else { "Disconnected" }; + ui.colored_label(color, label); + }); + + ui.horizontal(|ui| { + ui.label("ZMQ:"); + if disable_zmq { + ui.colored_label( + DashColors::text_secondary(dark_mode), + "Disabled", + ); + } else { + let color = if zmq_connected { + DashColors::SUCCESS + } else { + DashColors::ERROR + }; + let label = if zmq_connected { "Connected" } else { "Disconnected" }; + ui.colored_label(color, label); + } + }); + } + + if current_backend_mode == CoreBackendMode::Spv { + ui.horizontal(|ui| { + ui.label("SPV:"); + let color = if spv_connected { + DashColors::SUCCESS + } else { + DashColors::ERROR + }; + ui.colored_label(color, format!("{:?}", spv_status)); + }); + } + }); }); // Advanced Settings section with clean dropdown @@ -1700,10 +1783,22 @@ impl NetworkChooserScreen { /// Check if the network is working fn check_network_status(&self, network: Network) -> bool { match network { - Network::Dash => self.mainnet_core_status_online, - Network::Testnet => self.testnet_core_status_online, - Network::Devnet => self.devnet_core_status_online, - Network::Regtest => self.local_core_status_online, + Network::Dash => self.mainnet_app_context.connection_status().rpc_online(), + Network::Testnet => self + .testnet_app_context + .as_ref() + .map(|ctx| ctx.connection_status().rpc_online()) + .unwrap_or(false), + Network::Devnet => self + .devnet_app_context + .as_ref() + .map(|ctx| ctx.connection_status().rpc_online()) + .unwrap_or(false), + Network::Regtest => self + .local_app_context + .as_ref() + .map(|ctx| ctx.connection_status().rpc_online()) + .unwrap_or(false), _ => false, } } @@ -1811,40 +1906,11 @@ impl ScreenLike for NetworkChooserScreen { } fn display_message(&mut self, message: &str, _message_type: super::MessageType) { - if message.contains("Failed to get best chain lock for mainnet, testnet, devnet, and local") - { - self.mainnet_core_status_online = false; - self.testnet_core_status_online = false; - self.devnet_core_status_online = false; - self.local_core_status_online = false; - } + let _ = message; } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { - if let BackendTaskSuccessResult::CoreItem(CoreItem::ChainLocks( - mainnet_chainlock, - testnet_chainlock, - devnet_chainlock, - local_chainlock, - )) = backend_task_success_result - { - match mainnet_chainlock { - Some(_) => self.mainnet_core_status_online = true, - None => self.mainnet_core_status_online = false, - } - match testnet_chainlock { - Some(_) => self.testnet_core_status_online = true, - None => self.testnet_core_status_online = false, - } - match devnet_chainlock { - Some(_) => self.devnet_core_status_online = true, - None => self.devnet_core_status_online = false, - } - match local_chainlock { - Some(_) => self.local_core_status_online = true, - None => self.local_core_status_online = false, - } - } + let _ = backend_task_success_result; } fn ui(&mut self, ctx: &Context) -> AppAction { From 852135eb5ed5f3872592edc5a9591549e7eef38a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:13:52 +0100 Subject: [PATCH 02/18] chore: rabbit feedback --- src/context.rs | 2 +- src/context/connection_status.rs | 47 ++++++++++++++------------------ 2 files changed, 21 insertions(+), 28 deletions(-) diff --git a/src/context.rs b/src/context.rs index b0e1ab2b0..5db214c1e 100644 --- a/src/context.rs +++ b/src/context.rs @@ -19,11 +19,11 @@ use crate::model::wallet::{ }; use crate::sdk_wrapper::initialize_sdk; use crate::spv::{CoreBackendMode, SpvManager}; -use connection_status::ConnectionStatus; use crate::ui::RootScreenType; use crate::ui::tokens::tokens_screen::{IdentityTokenBalance, IdentityTokenIdentifier}; use crate::utils::tasks::TaskManager; use bincode::config; +use connection_status::ConnectionStatus; use crossbeam_channel::{Receiver, Sender}; use dash_sdk::Sdk; use dash_sdk::dashcore_rpc::dashcore::{InstantLock, Transaction}; diff --git a/src/context/connection_status.rs b/src/context/connection_status.rs index 0172409c0..c332aebb0 100644 --- a/src/context/connection_status.rs +++ b/src/context/connection_status.rs @@ -254,33 +254,26 @@ impl ConnectionStatus { let backend_mode = app_context.core_backend_mode(); self.set_backend_mode(backend_mode); - if backend_mode == CoreBackendMode::Spv { - // SPV status is updated elsewhere - let spv_status = app_context.spv_manager().status().status; - self.set_spv_status(spv_status); - return; - } - - // just a safety check - if CoreBackendMode::Rpc != backend_mode { - tracing::error!( - "Unexpected backend mode in connection status refresh: {:?}", - backend_mode - ); - return; - } - - // Update ZMQ status if there's a new event - let disable_zmq = app_context - .get_settings() - .ok() - .flatten() - .map(|s| s.disable_zmq) - .unwrap_or(false); - self.set_disable_zmq(disable_zmq); - - if let Ok(event) = app_context.rx_zmq_status.try_recv() { - self.set_zmq_status(event); + match backend_mode { + CoreBackendMode::Spv => { + // SPV status is updated elsewhere + let spv_status = app_context.spv_manager().status().status; + self.set_spv_status(spv_status); + } + CoreBackendMode::Rpc => { + // Update ZMQ status if there's a new event + let disable_zmq = app_context + .get_settings() + .ok() + .flatten() + .map(|s| s.disable_zmq) + .unwrap_or(false); + self.set_disable_zmq(disable_zmq); + + if let Ok(event) = app_context.rx_zmq_status.try_recv() { + self.set_zmq_status(event); + } + } } self.refresh_overall(); From c0ee39163e0260208538f25fd802c6a249a0359f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 12:51:32 +0000 Subject: [PATCH 03/18] Initial plan From 577127671089ce4f4132bf33db91f1b361b2546f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:00:48 +0000 Subject: [PATCH 04/18] Track DAPI connection status and display in tooltips and connection info - Add dapi_total_endpoints and dapi_available fields to ConnectionStatus - Factor DAPI availability into overall_connected status (RED when no endpoints available) - Query SDK AddressList during periodic refresh for endpoint counts and availability - Display DAPI status in connection indicator tooltip - Display DAPI status in network chooser Connection Status card (all modes) - Add dapi_status_label() helper for consistent status text formatting Co-authored-by: lklimek <842586+lklimek@users.noreply.github.com> --- src/context/connection_status.rs | 65 +++++++++++++++++++++++++++----- src/ui/network_chooser_screen.rs | 47 +++++++++++++++++++++++ 2 files changed, 102 insertions(+), 10 deletions(-) diff --git a/src/context/connection_status.rs b/src/context/connection_status.rs index c332aebb0..fac394527 100644 --- a/src/context/connection_status.rs +++ b/src/context/connection_status.rs @@ -7,7 +7,7 @@ use crate::components::core_zmq_listener::ZMQConnectionEvent; use crate::spv::{CoreBackendMode, SpvStatus}; use dash_sdk::dpp::dashcore::{ChainLock, Network}; use std::sync::Mutex; -use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU8, Ordering}; use std::time::{Duration, Instant}; const REFRESH_CONNECTED: Duration = Duration::from_secs(10); @@ -21,6 +21,8 @@ pub struct ConnectionStatus { disable_zmq: AtomicBool, overall_connected: AtomicBool, last_update: Mutex, + dapi_total_endpoints: AtomicU16, + dapi_available: AtomicBool, } impl ConnectionStatus { @@ -33,6 +35,8 @@ impl ConnectionStatus { disable_zmq: AtomicBool::new(false), overall_connected: AtomicBool::new(false), last_update: Mutex::new(Instant::now()), + dapi_total_endpoints: AtomicU16::new(0), + dapi_available: AtomicBool::new(true), } } @@ -89,6 +93,32 @@ impl ConnectionStatus { self.disable_zmq.store(disable, Ordering::Relaxed); } + pub fn dapi_total_endpoints(&self) -> u16 { + self.dapi_total_endpoints.load(Ordering::Relaxed) + } + + pub fn dapi_available(&self) -> bool { + self.dapi_available.load(Ordering::Relaxed) + } + + pub fn set_dapi_status(&self, total: u16, available: bool) { + self.dapi_total_endpoints.store(total, Ordering::Relaxed); + self.dapi_available.store(available, Ordering::Relaxed); + } + + /// Returns the DAPI status label suitable for display. + pub fn dapi_status_label(&self) -> String { + let total = self.dapi_total_endpoints(); + let available = self.dapi_available(); + if total == 0 { + "No endpoints configured".to_string() + } else if available { + format!("Available ({total} endpoints)") + } else { + format!("All {total} endpoints banned") + } + } + pub fn spv_connected(status: SpvStatus) -> bool { status.is_active() || status == SpvStatus::Running } @@ -113,9 +143,12 @@ impl ConnectionStatus { let backend_mode = self.backend_mode(); let disable_zmq = self.disable_zmq(); let spv_status = self.spv_status(); + let dapi_available = self.dapi_available(); let connected = match backend_mode { - CoreBackendMode::Rpc => self.rpc_online() && (disable_zmq || self.zmq_connected()), - CoreBackendMode::Spv => Self::spv_connected(spv_status), + CoreBackendMode::Rpc => { + self.rpc_online() && (disable_zmq || self.zmq_connected()) && dapi_available + } + CoreBackendMode::Spv => Self::spv_connected(spv_status) && dapi_available, }; self.overall_connected.store(connected, Ordering::Relaxed); } @@ -126,9 +159,12 @@ impl ConnectionStatus { disable_zmq: bool, spv_status: SpvStatus, ) -> bool { + let dapi_available = self.dapi_available(); match backend_mode { - CoreBackendMode::Rpc => self.rpc_online() && (disable_zmq || self.zmq_connected()), - CoreBackendMode::Spv => Self::spv_connected(spv_status), + CoreBackendMode::Rpc => { + self.rpc_online() && (disable_zmq || self.zmq_connected()) && dapi_available + } + CoreBackendMode::Spv => Self::spv_connected(spv_status) && dapi_available, } } @@ -136,6 +172,7 @@ impl ConnectionStatus { let backend_mode = self.backend_mode(); let disable_zmq = self.disable_zmq(); let spv_status = self.spv_status(); + let dapi_status = format!("DAPI: {}", self.dapi_status_label()); match backend_mode { CoreBackendMode::Rpc => { let rpc_status = if self.rpc_online() { @@ -152,21 +189,21 @@ impl ConnectionStatus { }; if self.overall_connected() { - format!("Connected to Dash Core Wallet\n{rpc_status}\n{zmq_status}") + format!("Connected to Dash Core Wallet\n{rpc_status}\n{zmq_status}\n{dapi_status}") } else if self.rpc_online() { - format!("Dash Core connection incomplete\n{rpc_status}\n{zmq_status}") + format!("Dash Core connection incomplete\n{rpc_status}\n{zmq_status}\n{dapi_status}") } else { format!( - "Disconnected from Dash Core Wallet. Click to start it.\n{rpc_status}\n{zmq_status}" + "Disconnected from Dash Core Wallet. Click to start it.\n{rpc_status}\n{zmq_status}\n{dapi_status}" ) } } CoreBackendMode::Spv => { let spv_label = format!("SPV: {:?}", spv_status); if self.overall_connected() { - format!("SPV connected\n{spv_label}") + format!("SPV connected\n{spv_label}\n{dapi_status}") } else { - format!("SPV disconnected\n{spv_label}") + format!("SPV disconnected\n{spv_label}\n{dapi_status}") } } } @@ -276,6 +313,14 @@ impl ConnectionStatus { } } + // Update DAPI endpoint status + if let Ok(sdk) = app_context.sdk.read() { + let address_list = sdk.address_list(); + let total = address_list.len() as u16; + let available = address_list.get_live_address().is_some(); + self.set_dapi_status(total, available); + } + self.refresh_overall(); } } diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index 75ff24a80..9e6955bd7 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -605,6 +605,21 @@ impl NetworkChooserScreen { let zmq_label = if zmq_connected { "Connected" } else { "Disconnected" }; ui.colored_label(zmq_color, zmq_label); } + + ui.label(","); + ui.label("DAPI:"); + let dapi_total = status.dapi_total_endpoints(); + let dapi_available = status.dapi_available(); + if dapi_total == 0 { + ui.colored_label(DashColors::text_secondary(dark_mode), status.dapi_status_label()); + } else { + let dapi_color = if dapi_available { + DashColors::SUCCESS + } else { + DashColors::ERROR + }; + ui.colored_label(dapi_color, status.dapi_status_label()); + } }); } @@ -637,6 +652,22 @@ impl NetworkChooserScreen { ui.colored_label(color, label); } }); + + ui.horizontal(|ui| { + ui.label("DAPI:"); + let dapi_total = status.dapi_total_endpoints(); + let dapi_available = status.dapi_available(); + if dapi_total == 0 { + ui.colored_label(DashColors::text_secondary(dark_mode), status.dapi_status_label()); + } else { + let dapi_color = if dapi_available { + DashColors::SUCCESS + } else { + DashColors::ERROR + }; + ui.colored_label(dapi_color, status.dapi_status_label()); + } + }); } if current_backend_mode == CoreBackendMode::Spv { @@ -649,6 +680,22 @@ impl NetworkChooserScreen { }; ui.colored_label(color, format!("{:?}", spv_status)); }); + + ui.horizontal(|ui| { + ui.label("DAPI:"); + let dapi_total = status.dapi_total_endpoints(); + let dapi_available = status.dapi_available(); + if dapi_total == 0 { + ui.colored_label(DashColors::text_secondary(dark_mode), status.dapi_status_label()); + } else { + let dapi_color = if dapi_available { + DashColors::SUCCESS + } else { + DashColors::ERROR + }; + ui.colored_label(dapi_color, status.dapi_status_label()); + } + }); } }); }); From 3ab7413bfd81e57d4953704aebb419453659b986 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:21:59 +0000 Subject: [PATCH 05/18] Address review feedback: store available endpoint count and extract DRY helper - Changed dapi_available from AtomicBool to AtomicU16 (dapi_available_endpoints) to store the count of available endpoints instead of just a boolean - Display format now shows "Available ({available}/{total} endpoints)" - Extracted repeated DAPI status rendering into add_dapi_status_label() helper function in network_chooser_screen.rs to eliminate code duplication Co-authored-by: lklimek <842586+lklimek@users.noreply.github.com> --- src/context/connection_status.rs | 28 ++++++++++----- src/ui/network_chooser_screen.rs | 60 +++++++++++--------------------- 2 files changed, 40 insertions(+), 48 deletions(-) diff --git a/src/context/connection_status.rs b/src/context/connection_status.rs index fac394527..6abc57eb2 100644 --- a/src/context/connection_status.rs +++ b/src/context/connection_status.rs @@ -22,7 +22,7 @@ pub struct ConnectionStatus { overall_connected: AtomicBool, last_update: Mutex, dapi_total_endpoints: AtomicU16, - dapi_available: AtomicBool, + dapi_available_endpoints: AtomicU16, } impl ConnectionStatus { @@ -36,7 +36,7 @@ impl ConnectionStatus { overall_connected: AtomicBool::new(false), last_update: Mutex::new(Instant::now()), dapi_total_endpoints: AtomicU16::new(0), - dapi_available: AtomicBool::new(true), + dapi_available_endpoints: AtomicU16::new(0), } } @@ -97,23 +97,27 @@ impl ConnectionStatus { self.dapi_total_endpoints.load(Ordering::Relaxed) } + pub fn dapi_available_endpoints(&self) -> u16 { + self.dapi_available_endpoints.load(Ordering::Relaxed) + } + pub fn dapi_available(&self) -> bool { - self.dapi_available.load(Ordering::Relaxed) + self.dapi_available_endpoints.load(Ordering::Relaxed) > 0 } - pub fn set_dapi_status(&self, total: u16, available: bool) { + pub fn set_dapi_status(&self, total: u16, available: u16) { self.dapi_total_endpoints.store(total, Ordering::Relaxed); - self.dapi_available.store(available, Ordering::Relaxed); + self.dapi_available_endpoints.store(available, Ordering::Relaxed); } /// Returns the DAPI status label suitable for display. pub fn dapi_status_label(&self) -> String { let total = self.dapi_total_endpoints(); - let available = self.dapi_available(); + let available = self.dapi_available_endpoints(); if total == 0 { "No endpoints configured".to_string() - } else if available { - format!("Available ({total} endpoints)") + } else if available > 0 { + format!("Available ({available}/{total} endpoints)") } else { format!("All {total} endpoints banned") } @@ -317,7 +321,13 @@ impl ConnectionStatus { if let Ok(sdk) = app_context.sdk.read() { let address_list = sdk.address_list(); let total = address_list.len() as u16; - let available = address_list.get_live_address().is_some(); + let available = if address_list.get_live_address().is_some() { + // At least one endpoint is live; report total since we can't + // count individual available endpoints through the SDK API. + total + } else { + 0 + }; self.set_dapi_status(total, available); } diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index 9e6955bd7..fba1a0e61 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -37,6 +37,24 @@ enum DatabaseClearMessage { Error(String), } +/// Renders DAPI endpoint status with appropriate color coding. +fn add_dapi_status_label(ui: &mut Ui, status: &ConnectionStatus, dark_mode: bool) { + ui.label("DAPI:"); + if status.dapi_total_endpoints() == 0 { + ui.colored_label( + DashColors::text_secondary(dark_mode), + status.dapi_status_label(), + ); + } else { + let color = if status.dapi_available() { + DashColors::SUCCESS + } else { + DashColors::ERROR + }; + ui.colored_label(color, status.dapi_status_label()); + } +} + pub struct NetworkChooserScreen { pub mainnet_app_context: Arc, pub testnet_app_context: Option>, @@ -607,19 +625,7 @@ impl NetworkChooserScreen { } ui.label(","); - ui.label("DAPI:"); - let dapi_total = status.dapi_total_endpoints(); - let dapi_available = status.dapi_available(); - if dapi_total == 0 { - ui.colored_label(DashColors::text_secondary(dark_mode), status.dapi_status_label()); - } else { - let dapi_color = if dapi_available { - DashColors::SUCCESS - } else { - DashColors::ERROR - }; - ui.colored_label(dapi_color, status.dapi_status_label()); - } + add_dapi_status_label(ui, status, dark_mode); }); } @@ -654,19 +660,7 @@ impl NetworkChooserScreen { }); ui.horizontal(|ui| { - ui.label("DAPI:"); - let dapi_total = status.dapi_total_endpoints(); - let dapi_available = status.dapi_available(); - if dapi_total == 0 { - ui.colored_label(DashColors::text_secondary(dark_mode), status.dapi_status_label()); - } else { - let dapi_color = if dapi_available { - DashColors::SUCCESS - } else { - DashColors::ERROR - }; - ui.colored_label(dapi_color, status.dapi_status_label()); - } + add_dapi_status_label(ui, status, dark_mode); }); } @@ -682,19 +676,7 @@ impl NetworkChooserScreen { }); ui.horizontal(|ui| { - ui.label("DAPI:"); - let dapi_total = status.dapi_total_endpoints(); - let dapi_available = status.dapi_available(); - if dapi_total == 0 { - ui.colored_label(DashColors::text_secondary(dark_mode), status.dapi_status_label()); - } else { - let dapi_color = if dapi_available { - DashColors::SUCCESS - } else { - DashColors::ERROR - }; - ui.colored_label(dapi_color, status.dapi_status_label()); - } + add_dapi_status_label(ui, status, dark_mode); }); } }); From 3f6b0c856e3047d224baaf387f01716b5ad39dbc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:53:01 +0000 Subject: [PATCH 06/18] Fix rustfmt formatting issues in connection_status.rs - Reorder atomic imports (AtomicU8 before AtomicU16) per rustfmt - Wrap long .store() call to respect line length - Wrap long format!() strings to respect line length Co-authored-by: lklimek <842586+lklimek@users.noreply.github.com> --- src/context/connection_status.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/context/connection_status.rs b/src/context/connection_status.rs index 6abc57eb2..822d02b7a 100644 --- a/src/context/connection_status.rs +++ b/src/context/connection_status.rs @@ -7,7 +7,7 @@ use crate::components::core_zmq_listener::ZMQConnectionEvent; use crate::spv::{CoreBackendMode, SpvStatus}; use dash_sdk::dpp::dashcore::{ChainLock, Network}; use std::sync::Mutex; -use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU8, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU16, Ordering}; use std::time::{Duration, Instant}; const REFRESH_CONNECTED: Duration = Duration::from_secs(10); @@ -107,7 +107,8 @@ impl ConnectionStatus { pub fn set_dapi_status(&self, total: u16, available: u16) { self.dapi_total_endpoints.store(total, Ordering::Relaxed); - self.dapi_available_endpoints.store(available, Ordering::Relaxed); + self.dapi_available_endpoints + .store(available, Ordering::Relaxed); } /// Returns the DAPI status label suitable for display. @@ -193,9 +194,13 @@ impl ConnectionStatus { }; if self.overall_connected() { - format!("Connected to Dash Core Wallet\n{rpc_status}\n{zmq_status}\n{dapi_status}") + format!( + "Connected to Dash Core Wallet\n{rpc_status}\n{zmq_status}\n{dapi_status}" + ) } else if self.rpc_online() { - format!("Dash Core connection incomplete\n{rpc_status}\n{zmq_status}\n{dapi_status}") + format!( + "Dash Core connection incomplete\n{rpc_status}\n{zmq_status}\n{dapi_status}" + ) } else { format!( "Disconnected from Dash Core Wallet. Click to start it.\n{rpc_status}\n{zmq_status}\n{dapi_status}" From a7c7ea41dfb873cc02c1eb839d412d9f8459669b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Feb 2026 17:53:10 +0000 Subject: [PATCH 07/18] Fix borrow checker error: extract DAPI status values before mutable self borrow The add_dapi_status_label helper was capturing a &ConnectionStatus reference (derived from self) in closures, which extended the immutable borrow past the self.render_spv_sync_progress() mutable borrow on line 594. Fix: change add_dapi_status_label to accept pre-computed owned values (dapi_total, dapi_available, dapi_label) instead of &ConnectionStatus, and extract those values early alongside other status fields. Co-authored-by: lklimek <842586+lklimek@users.noreply.github.com> --- src/ui/network_chooser_screen.rs | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index fba1a0e61..e1a16d677 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -38,20 +38,23 @@ enum DatabaseClearMessage { } /// Renders DAPI endpoint status with appropriate color coding. -fn add_dapi_status_label(ui: &mut Ui, status: &ConnectionStatus, dark_mode: bool) { +fn add_dapi_status_label( + ui: &mut Ui, + dapi_total: u16, + dapi_available: bool, + dapi_label: &str, + dark_mode: bool, +) { ui.label("DAPI:"); - if status.dapi_total_endpoints() == 0 { - ui.colored_label( - DashColors::text_secondary(dark_mode), - status.dapi_status_label(), - ); + if dapi_total == 0 { + ui.colored_label(DashColors::text_secondary(dark_mode), dapi_label); } else { - let color = if status.dapi_available() { + let color = if dapi_available { DashColors::SUCCESS } else { DashColors::ERROR }; - ui.colored_label(color, status.dapi_status_label()); + ui.colored_label(color, dapi_label); } } @@ -475,6 +478,9 @@ impl NetworkChooserScreen { None }; let overall_connected = status.overall_connected(); + let dapi_total = status.dapi_total_endpoints(); + let dapi_available = status.dapi_available(); + let dapi_label = status.dapi_status_label(); // Button on the left with status ui.horizontal(|ui| { @@ -625,7 +631,7 @@ impl NetworkChooserScreen { } ui.label(","); - add_dapi_status_label(ui, status, dark_mode); + add_dapi_status_label(ui, dapi_total, dapi_available, &dapi_label, dark_mode); }); } @@ -660,7 +666,7 @@ impl NetworkChooserScreen { }); ui.horizontal(|ui| { - add_dapi_status_label(ui, status, dark_mode); + add_dapi_status_label(ui, dapi_total, dapi_available, &dapi_label, dark_mode); }); } @@ -676,7 +682,7 @@ impl NetworkChooserScreen { }); ui.horizontal(|ui| { - add_dapi_status_label(ui, status, dark_mode); + add_dapi_status_label(ui, dapi_total, dapi_available, &dapi_label, dark_mode); }); } }); From f50948433580c23f1f59c53697cd5f1bc2ab9b16 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 09:28:57 +0000 Subject: [PATCH 08/18] Switch Platform to c2c88e4 and use get_live_addresses() for accurate available count Updated dash-sdk dependency to Platform commit c2c88e4a988ce930 which adds AddressList::get_live_addresses() method. Replaced the workaround that used get_live_address().is_some() (which could only tell if at least one endpoint was live) with get_live_addresses().len() to get the exact count of available non-banned DAPI endpoints. Co-authored-by: lklimek <842586+lklimek@users.noreply.github.com> --- Cargo.lock | 44 ++++++++++++++++---------------- Cargo.toml | 2 +- src/context/connection_status.rs | 8 +----- 3 files changed, 24 insertions(+), 30 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fe7c0b67d..23cb1cba8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1724,7 +1724,7 @@ dependencies = [ [[package]] name = "dapi-grpc" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" dependencies = [ "dash-platform-macros", "futures-core", @@ -1792,7 +1792,7 @@ dependencies = [ [[package]] name = "dash-context-provider" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" dependencies = [ "dpp", "drive", @@ -1881,7 +1881,7 @@ dependencies = [ [[package]] name = "dash-platform-macros" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" dependencies = [ "heck", "quote", @@ -1891,7 +1891,7 @@ dependencies = [ [[package]] name = "dash-sdk" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" dependencies = [ "arc-swap", "async-trait", @@ -2040,7 +2040,7 @@ dependencies = [ [[package]] name = "dashpay-contract" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" dependencies = [ "platform-value", "platform-version", @@ -2051,7 +2051,7 @@ dependencies = [ [[package]] name = "data-contracts" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" dependencies = [ "dashpay-contract", "dpns-contract", @@ -2304,7 +2304,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "dpns-contract" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" dependencies = [ "platform-value", "platform-version", @@ -2315,7 +2315,7 @@ dependencies = [ [[package]] name = "dpp" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" dependencies = [ "anyhow", "async-trait", @@ -2363,7 +2363,7 @@ dependencies = [ [[package]] name = "drive" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" dependencies = [ "bincode 2.0.1", "byteorder", @@ -2388,7 +2388,7 @@ dependencies = [ [[package]] name = "drive-proof-verifier" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" dependencies = [ "bincode 2.0.1", "dapi-grpc", @@ -2964,7 +2964,7 @@ dependencies = [ [[package]] name = "feature-flags-contract" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" dependencies = [ "platform-value", "platform-version", @@ -4434,7 +4434,7 @@ dependencies = [ [[package]] name = "keyword-search-contract" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" dependencies = [ "platform-value", "platform-version", @@ -4650,7 +4650,7 @@ dependencies = [ [[package]] name = "masternode-reward-shares-contract" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" dependencies = [ "platform-value", "platform-version", @@ -5722,7 +5722,7 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "platform-serialization" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" dependencies = [ "bincode 2.0.1", "platform-version", @@ -5731,7 +5731,7 @@ dependencies = [ [[package]] name = "platform-serialization-derive" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" dependencies = [ "proc-macro2", "quote", @@ -5742,7 +5742,7 @@ dependencies = [ [[package]] name = "platform-value" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" dependencies = [ "base64 0.22.1", "bincode 2.0.1", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "platform-version" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" dependencies = [ "bincode 2.0.1", "grovedb-version", @@ -5774,7 +5774,7 @@ dependencies = [ [[package]] name = "platform-versioning" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" dependencies = [ "proc-macro2", "quote", @@ -6383,7 +6383,7 @@ checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" [[package]] name = "rs-dapi-client" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" dependencies = [ "backon", "chrono", @@ -7504,7 +7504,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "token-history-contract" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" dependencies = [ "platform-value", "platform-version", @@ -8257,7 +8257,7 @@ dependencies = [ [[package]] name = "wallet-utils-contract" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" dependencies = [ "platform-value", "platform-version", @@ -9513,7 +9513,7 @@ checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" [[package]] name = "withdrawals-contract" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" dependencies = [ "num_enum 0.5.11", "platform-value", diff --git a/Cargo.toml b/Cargo.toml index f535fb180..f16023bb4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ qrcode = "0.14.1" nix = { version = "0.30.1", features = ["signal"] } eframe = { version = "0.32.0", features = ["persistence"] } base64 = "0.22.1" -dash-sdk = { git = "https://github.com/dashpay/platform", rev = "98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9", features = [ +dash-sdk = { git = "https://github.com/dashpay/platform", rev = "c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56", features = [ "core_key_wallet", "core_key_wallet_manager", "core_bincode", diff --git a/src/context/connection_status.rs b/src/context/connection_status.rs index 822d02b7a..628d2b016 100644 --- a/src/context/connection_status.rs +++ b/src/context/connection_status.rs @@ -326,13 +326,7 @@ impl ConnectionStatus { if let Ok(sdk) = app_context.sdk.read() { let address_list = sdk.address_list(); let total = address_list.len() as u16; - let available = if address_list.get_live_address().is_some() { - // At least one endpoint is live; report total since we can't - // count individual available endpoints through the SDK API. - total - } else { - 0 - }; + let available = address_list.get_live_addresses().len() as u16; self.set_dapi_status(total, available); } From bf49217e7170237d00f527b4676fde69dd52cb7e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 9 Feb 2026 16:54:58 +0100 Subject: [PATCH 09/18] chore: typo + network changes --- src/context/connection_status.rs | 4 ++-- src/ui/network_chooser_screen.rs | 8 -------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/context/connection_status.rs b/src/context/connection_status.rs index c332aebb0..1e56cb79e 100644 --- a/src/context/connection_status.rs +++ b/src/context/connection_status.rs @@ -245,11 +245,11 @@ impl ConnectionStatus { } *last_update = now; - self.refersh_zmq_and_spv(app_context); + self.refresh_zmq_and_spv(app_context); AppAction::BackendTask(BackendTask::CoreTask(CoreTask::GetBestChainLocks)) } - fn refersh_zmq_and_spv(&self, app_context: &crate::context::AppContext) { + fn refresh_zmq_and_spv(&self, app_context: &crate::context::AppContext) { // Get current backend mode let backend_mode = app_context.core_backend_mode(); self.set_backend_mode(backend_mode); diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index 75ff24a80..e4412847b 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -1905,14 +1905,6 @@ impl ScreenLike for NetworkChooserScreen { } } - fn display_message(&mut self, message: &str, _message_type: super::MessageType) { - let _ = message; - } - - fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { - let _ = backend_task_success_result; - } - fn ui(&mut self, ctx: &Context) -> AppAction { let mut action = add_top_panel( ctx, From 6a7251c8dbcf14bb9dc542bf010b989e0aa92087 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:14:56 +0100 Subject: [PATCH 10/18] chore: apply feedback --- src/app.rs | 10 +++++ src/context.rs | 6 ++- src/context/connection_status.rs | 59 +++++++++++++----------------- src/spv/manager.rs | 30 +++++++++++---- src/ui/network_chooser_screen.rs | 30 ++++----------- src/ui/tokens/tokens_screen/mod.rs | 6 +-- 6 files changed, 72 insertions(+), 69 deletions(-) diff --git a/src/app.rs b/src/app.rs index 6283565e5..2cd015170 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7,6 +7,7 @@ use crate::backend_task::core::CoreItem; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::components::core_zmq_listener::{CoreZMQListener, ZMQMessage}; use crate::context::AppContext; +use crate::context::connection_status::ConnectionStatus; use crate::database::Database; use crate::logging::initialize_logger; use crate::model::settings::Settings; @@ -65,6 +66,7 @@ pub struct AppState { pub selected_main_screen: RootScreenType, pub screen_stack: Vec, pub chosen_network: Network, + pub connection_status: Arc, pub mainnet_app_context: Arc, pub testnet_app_context: Option>, pub devnet_app_context: Option>, @@ -182,11 +184,13 @@ impl AppState { let onboarding_completed = settings.onboarding_completed; let subtasks = Arc::new(TaskManager::new()); + let connection_status = Arc::new(ConnectionStatus::new()); let mainnet_app_context = match AppContext::new( Network::Dash, db.clone(), password_info.clone(), subtasks.clone(), + connection_status.clone(), ) { Some(context) => context, None => { @@ -201,18 +205,21 @@ impl AppState { db.clone(), password_info.clone(), subtasks.clone(), + connection_status.clone(), ); let devnet_app_context = AppContext::new( Network::Devnet, db.clone(), password_info.clone(), subtasks.clone(), + connection_status.clone(), ); let local_app_context = AppContext::new( Network::Regtest, db.clone(), password_info, subtasks.clone(), + connection_status.clone(), ); // load fonts @@ -604,6 +611,7 @@ impl AppState { selected_main_screen, screen_stack: vec![], chosen_network, + connection_status, mainnet_app_context, testnet_app_context, devnet_app_context, @@ -738,6 +746,8 @@ impl AppState { for screen in self.main_screens.values_mut() { screen.change_context(app_context.clone()) } + + self.connection_status.reset(); } pub fn visible_screen_mut(&mut self) -> &mut Screen { diff --git a/src/context.rs b/src/context.rs index 5db214c1e..414c5a759 100644 --- a/src/context.rs +++ b/src/context.rs @@ -101,7 +101,8 @@ pub struct AppContext { pub(crate) subtasks: Arc, pub(crate) spv_manager: Arc, core_backend_mode: AtomicU8, - pub(crate) connection_status: ConnectionStatus, + /// Tracks the connection status to currently active network + pub(crate) connection_status: Arc, /// Pending wallet selection - set after creating/importing a wallet /// so the wallet screen can auto-select the new wallet pub(crate) pending_wallet_selection: Mutex>, @@ -120,6 +121,7 @@ impl AppContext { db: Arc, password_info: Option, subtasks: Arc, + connection_status: Arc, ) -> Option> { let config = match Config::load() { Ok(config) => config, @@ -277,7 +279,7 @@ impl AppContext { subtasks, spv_manager, core_backend_mode: AtomicU8::new(saved_core_backend_mode), - connection_status: ConnectionStatus::new(), + connection_status, pending_wallet_selection: Mutex::new(None), selected_wallet_hash: Mutex::new(selected_wallet_hash), selected_single_key_hash: Mutex::new(selected_single_key_hash), diff --git a/src/context/connection_status.rs b/src/context/connection_status.rs index 1e56cb79e..53feb3a49 100644 --- a/src/context/connection_status.rs +++ b/src/context/connection_status.rs @@ -12,6 +12,10 @@ use std::time::{Duration, Instant}; const REFRESH_CONNECTED: Duration = Duration::from_secs(10); const REFRESH_DISCONNECTED: Duration = Duration::from_secs(2); +/// Tracks the connection status to currently active network, and provides helper methods +/// to determine overall connectivity status. +/// +/// Supports Dash Core and SPV. #[derive(Debug)] pub struct ConnectionStatus { rpc_online: AtomicBool, @@ -36,6 +40,25 @@ impl ConnectionStatus { } } + /// Reset all connection state. Called when switching the active network + /// so the status reflects the new network from a clean slate. + pub fn reset(&self) { + self.rpc_online.store(false, Ordering::Relaxed); + if let Ok(mut status) = self.zmq_status.lock() { + *status = ZMQConnectionEvent::Disconnected; + } + self.spv_status + .store(SpvStatus::Idle as u8, Ordering::Relaxed); + self.backend_mode + .store(CoreBackendMode::Rpc.as_u8(), Ordering::Relaxed); + self.disable_zmq.store(false, Ordering::Relaxed); + self.overall_connected.store(false, Ordering::Relaxed); + // Set last_update to epoch so the next trigger_refresh fires immediately + if let Ok(mut last) = self.last_update.lock() { + *last = Instant::now() - REFRESH_CONNECTED; + } + } + pub fn rpc_online(&self) -> bool { self.rpc_online.load(Ordering::Relaxed) } @@ -58,15 +81,7 @@ impl ConnectionStatus { } pub fn spv_status(&self) -> SpvStatus { - match self.spv_status.load(Ordering::Relaxed) { - 1 => SpvStatus::Starting, - 2 => SpvStatus::Syncing, - 3 => SpvStatus::Running, - 4 => SpvStatus::Stopping, - 5 => SpvStatus::Stopped, - 6 => SpvStatus::Error, - _ => SpvStatus::Idle, - } + SpvStatus::from(self.spv_status.load(Ordering::Relaxed)) } pub fn set_spv_status(&self, status: SpvStatus) { @@ -90,19 +105,7 @@ impl ConnectionStatus { } pub fn spv_connected(status: SpvStatus) -> bool { - status.is_active() || status == SpvStatus::Running - } - - pub fn rpc_connected(&self) -> bool { - self.rpc_online() - } - - pub fn zmq_required(&self) -> bool { - !self.disable_zmq() - } - - pub fn rpc_zmq_healthy(&self) -> bool { - self.rpc_online() && (self.disable_zmq() || self.zmq_connected()) + status.is_active() } pub fn overall_connected(&self) -> bool { @@ -120,18 +123,6 @@ impl ConnectionStatus { self.overall_connected.store(connected, Ordering::Relaxed); } - pub fn overall_connected_with( - &self, - backend_mode: CoreBackendMode, - disable_zmq: bool, - spv_status: SpvStatus, - ) -> bool { - match backend_mode { - CoreBackendMode::Rpc => self.rpc_online() && (disable_zmq || self.zmq_connected()), - CoreBackendMode::Spv => Self::spv_connected(spv_status), - } - } - pub fn tooltip_text(&self) -> String { let backend_mode = self.backend_mode(); let disable_zmq = self.disable_zmq(); diff --git a/src/spv/manager.rs b/src/spv/manager.rs index ebad6b7ee..16dd0f4e0 100644 --- a/src/spv/manager.rs +++ b/src/spv/manager.rs @@ -56,15 +56,16 @@ impl From for CoreBackendMode { /// High-level status of the SPV client runtime. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[repr(u8)] pub enum SpvStatus { #[default] - Idle, - Starting, - Syncing, - Running, - Stopping, - Stopped, - Error, + Idle = 0, + Starting = 1, + Syncing = 2, + Running = 3, + Stopping = 4, + Stopped = 5, + Error = 6, } impl SpvStatus { @@ -76,6 +77,21 @@ impl SpvStatus { } } +impl From for SpvStatus { + fn from(value: u8) -> Self { + match value { + 0 => SpvStatus::Idle, + 1 => SpvStatus::Starting, + 2 => SpvStatus::Syncing, + 3 => SpvStatus::Running, + 4 => SpvStatus::Stopping, + 5 => SpvStatus::Stopped, + 6 => SpvStatus::Error, + _ => SpvStatus::Idle, + } + } +} + /// Snapshot of the SPV runtime state for UI consumption. /// Uses dash-spv's built-in progress types directly instead of duplicating. #[derive(Debug, Clone, Default)] diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index e4412847b..93422fccd 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -1,7 +1,7 @@ use crate::app::AppAction; use crate::backend_task::core::CoreTask; use crate::backend_task::system_task::SystemTask; -use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; +use crate::backend_task::BackendTask; use crate::config::Config; use crate::context::AppContext; use crate::context::connection_status::ConnectionStatus; @@ -446,7 +446,7 @@ impl NetworkChooserScreen { let ctx = self.current_app_context(); let status = ctx.connection_status(); let disable_zmq = status.disable_zmq(); - let rpc_online = self.check_network_status(self.current_network); + let rpc_online = self.check_network_status(); let zmq_connected = status.zmq_connected(); let spv_snapshot = ctx.spv_manager().status(); let spv_status = status.spv_status(); @@ -1780,27 +1780,11 @@ impl NetworkChooserScreen { } } - /// Check if the network is working - fn check_network_status(&self, network: Network) -> bool { - match network { - Network::Dash => self.mainnet_app_context.connection_status().rpc_online(), - Network::Testnet => self - .testnet_app_context - .as_ref() - .map(|ctx| ctx.connection_status().rpc_online()) - .unwrap_or(false), - Network::Devnet => self - .devnet_app_context - .as_ref() - .map(|ctx| ctx.connection_status().rpc_online()) - .unwrap_or(false), - Network::Regtest => self - .local_app_context - .as_ref() - .map(|ctx| ctx.connection_status().rpc_online()) - .unwrap_or(false), - _ => false, - } + /// Check if the current network's RPC is online. + fn check_network_status(&self) -> bool { + self.current_app_context() + .connection_status() + .rpc_online() } fn any_rpc_backend(&self) -> bool { diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index 652feb847..ada097aa1 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -3233,7 +3233,7 @@ mod tests { db.initialize(Path::new(&db_file_path)).unwrap(); ensure_test_env(); - let app_context = AppContext::new(Network::Regtest, db, None, Default::default()) + let app_context = AppContext::new(Network::Regtest, db, None, Default::default(), Default::default()) .expect("Expected to create AppContext"); let mut token_creator_ui = TokensScreen::new(&app_context, TokensSubscreen::TokenCreator); @@ -3539,7 +3539,7 @@ mod tests { db.initialize(Path::new(&db_file_path)).unwrap(); ensure_test_env(); - let app_context = AppContext::new(Network::Regtest, db, None, Default::default()) + let app_context = AppContext::new(Network::Regtest, db, None, Default::default(), Default::default()) .expect("Expected to create AppContext"); let mut token_creator_ui = TokensScreen::new(&app_context, TokensSubscreen::TokenCreator); @@ -3659,7 +3659,7 @@ mod tests { db.initialize(Path::new(&db_file_path)).unwrap(); ensure_test_env(); - let app_context = AppContext::new(Network::Regtest, db, None, Default::default()) + let app_context = AppContext::new(Network::Regtest, db, None, Default::default(), Default::default()) .expect("Expected to create AppContext"); let mut token_creator_ui = TokensScreen::new(&app_context, TokensSubscreen::TokenCreator); From 5a07e24cd00cc65d6219c1236e2c774ee954c221 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:37:41 +0100 Subject: [PATCH 11/18] chore: rabbit review --- src/app.rs | 3 ++- src/context/connection_status.rs | 7 +++++-- src/ui/network_chooser_screen.rs | 13 ++++--------- src/ui/tokens/tokens_screen/mod.rs | 30 ++++++++++++++++++++++++------ 4 files changed, 35 insertions(+), 18 deletions(-) diff --git a/src/app.rs b/src/app.rs index 2cd015170..4bf5ba5f8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -747,7 +747,8 @@ impl AppState { screen.change_context(app_context.clone()) } - self.connection_status.reset(); + self.connection_status + .reset(app_context.core_backend_mode()); } pub fn visible_screen_mut(&mut self) -> &mut Screen { diff --git a/src/context/connection_status.rs b/src/context/connection_status.rs index 53feb3a49..703adc869 100644 --- a/src/context/connection_status.rs +++ b/src/context/connection_status.rs @@ -42,7 +42,10 @@ impl ConnectionStatus { /// Reset all connection state. Called when switching the active network /// so the status reflects the new network from a clean slate. - pub fn reset(&self) { + /// + /// `backend_mode` should be the new network's current backend mode so that + /// `overall_connected()` and `tooltip_text()` read the correct mode immediately. + pub fn reset(&self, backend_mode: CoreBackendMode) { self.rpc_online.store(false, Ordering::Relaxed); if let Ok(mut status) = self.zmq_status.lock() { *status = ZMQConnectionEvent::Disconnected; @@ -50,7 +53,7 @@ impl ConnectionStatus { self.spv_status .store(SpvStatus::Idle as u8, Ordering::Relaxed); self.backend_mode - .store(CoreBackendMode::Rpc.as_u8(), Ordering::Relaxed); + .store(backend_mode.as_u8(), Ordering::Relaxed); self.disable_zmq.store(false, Ordering::Relaxed); self.overall_connected.store(false, Ordering::Relaxed); // Set last_update to epoch so the next trigger_refresh fires immediately diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index 93422fccd..d23b8065f 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -1,7 +1,7 @@ use crate::app::AppAction; +use crate::backend_task::BackendTask; use crate::backend_task::core::CoreTask; use crate::backend_task::system_task::SystemTask; -use crate::backend_task::BackendTask; use crate::config::Config; use crate::context::AppContext; use crate::context::connection_status::ConnectionStatus; @@ -446,7 +446,9 @@ impl NetworkChooserScreen { let ctx = self.current_app_context(); let status = ctx.connection_status(); let disable_zmq = status.disable_zmq(); - let rpc_online = self.check_network_status(); + let rpc_online = self.current_app_context() + .connection_status() + .rpc_online(); let zmq_connected = status.zmq_connected(); let spv_snapshot = ctx.spv_manager().status(); let spv_status = status.spv_status(); @@ -1780,13 +1782,6 @@ impl NetworkChooserScreen { } } - /// Check if the current network's RPC is online. - fn check_network_status(&self) -> bool { - self.current_app_context() - .connection_status() - .rpc_online() - } - fn any_rpc_backend(&self) -> bool { self.backend_modes .iter() diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index ada097aa1..c64536615 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -3233,8 +3233,14 @@ mod tests { db.initialize(Path::new(&db_file_path)).unwrap(); ensure_test_env(); - let app_context = AppContext::new(Network::Regtest, db, None, Default::default(), Default::default()) - .expect("Expected to create AppContext"); + let app_context = AppContext::new( + Network::Regtest, + db, + None, + Default::default(), + Default::default(), + ) + .expect("Expected to create AppContext"); let mut token_creator_ui = TokensScreen::new(&app_context, TokensSubscreen::TokenCreator); // Identity selection @@ -3539,8 +3545,14 @@ mod tests { db.initialize(Path::new(&db_file_path)).unwrap(); ensure_test_env(); - let app_context = AppContext::new(Network::Regtest, db, None, Default::default(), Default::default()) - .expect("Expected to create AppContext"); + let app_context = AppContext::new( + Network::Regtest, + db, + None, + Default::default(), + Default::default(), + ) + .expect("Expected to create AppContext"); let mut token_creator_ui = TokensScreen::new(&app_context, TokensSubscreen::TokenCreator); // Identity selection @@ -3659,8 +3671,14 @@ mod tests { db.initialize(Path::new(&db_file_path)).unwrap(); ensure_test_env(); - let app_context = AppContext::new(Network::Regtest, db, None, Default::default(), Default::default()) - .expect("Expected to create AppContext"); + let app_context = AppContext::new( + Network::Regtest, + db, + None, + Default::default(), + Default::default(), + ) + .expect("Expected to create AppContext"); let mut token_creator_ui = TokensScreen::new(&app_context, TokensSubscreen::TokenCreator); // Identity selection From 398dd7418921c082dd9d347a4661cad6057e432a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:40:16 +0100 Subject: [PATCH 12/18] chore: rabbit feedback --- src/ui/network_chooser_screen.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index d23b8065f..9b8ed4ee2 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -446,15 +446,12 @@ impl NetworkChooserScreen { let ctx = self.current_app_context(); let status = ctx.connection_status(); let disable_zmq = status.disable_zmq(); - let rpc_online = self.current_app_context() - .connection_status() - .rpc_online(); + let rpc_online = status.rpc_online(); let zmq_connected = status.zmq_connected(); - let spv_snapshot = ctx.spv_manager().status(); let spv_status = status.spv_status(); let spv_connected = ConnectionStatus::spv_connected(spv_status); let snapshot = if current_backend_mode == CoreBackendMode::Spv { - Some(spv_snapshot.clone()) + Some(ctx.spv_manager().status().clone()) } else { None }; From 158f53e8d5176de870eb85927c5886c54f5ca434 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:55:22 +0100 Subject: [PATCH 13/18] chore: rabbitting --- src/spv/manager.rs | 14 ++++++++++++++ src/ui/network_chooser_screen.rs | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/spv/manager.rs b/src/spv/manager.rs index 16dd0f4e0..9d5395664 100644 --- a/src/spv/manager.rs +++ b/src/spv/manager.rs @@ -77,6 +77,20 @@ impl SpvStatus { } } +impl std::fmt::Display for SpvStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SpvStatus::Idle => write!(f, "Idle"), + SpvStatus::Starting => write!(f, "Starting"), + SpvStatus::Syncing => write!(f, "Syncing"), + SpvStatus::Running => write!(f, "Running"), + SpvStatus::Stopping => write!(f, "Stopping"), + SpvStatus::Stopped => write!(f, "Stopped"), + SpvStatus::Error => write!(f, "Error"), + } + } +} + impl From for SpvStatus { fn from(value: u8) -> Self { match value { diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index 9b8ed4ee2..573117051 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -646,7 +646,7 @@ impl NetworkChooserScreen { } else { DashColors::ERROR }; - ui.colored_label(color, format!("{:?}", spv_status)); + ui.colored_label(color, spv_status.to_string()); }); } }); From 4048cec05da23ced25cb9d2b5aad810849683806 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:11:43 +0000 Subject: [PATCH 14/18] Remove overall_connected_with method (deleted upstream in base branch) Co-authored-by: lklimek <842586+lklimek@users.noreply.github.com> --- src/context/connection_status.rs | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/context/connection_status.rs b/src/context/connection_status.rs index 4d7207cc6..53886fe6c 100644 --- a/src/context/connection_status.rs +++ b/src/context/connection_status.rs @@ -164,21 +164,6 @@ impl ConnectionStatus { self.overall_connected.store(connected, Ordering::Relaxed); } - pub fn overall_connected_with( - &self, - backend_mode: CoreBackendMode, - disable_zmq: bool, - spv_status: SpvStatus, - ) -> bool { - let dapi_available = self.dapi_available(); - match backend_mode { - CoreBackendMode::Rpc => { - self.rpc_online() && (disable_zmq || self.zmq_connected()) && dapi_available - } - CoreBackendMode::Spv => Self::spv_connected(spv_status) && dapi_available, - } - } - pub fn tooltip_text(&self) -> String { let backend_mode = self.backend_mode(); let disable_zmq = self.disable_zmq(); From 5acbaf3176a6d6feb33440676afef010b43ddf37 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 17:08:34 +0000 Subject: [PATCH 15/18] Merge v1.0-dev into copilot/update-dapi-connection-status Resolved modify/delete conflict on src/context.rs: removed the file since v1.0-dev refactored it into src/context/mod.rs and submodules, which already include our Arc changes. Co-authored-by: lklimek <842586+lklimek@users.noreply.github.com> --- CLAUDE.md | 69 +- Cargo.lock | 785 +++++-- Cargo.toml | 31 +- src/backend_task/core/create_asset_lock.rs | 21 +- src/backend_task/core/mod.rs | 9 +- src/backend_task/dashpay/incoming_payments.rs | 2 +- src/backend_task/dashpay/profile.rs | 2 +- .../identity/register_identity.rs | 146 +- src/backend_task/identity/top_up_identity.rs | 145 +- .../fund_platform_address_from_asset_lock.rs | 4 +- ...fund_platform_address_from_wallet_utxos.rs | 126 +- .../wallet/generate_receive_address.rs | 2 +- src/components/core_p2p_handler.rs | 8 +- src/config.rs | 29 +- src/context.rs | 1762 --------------- src/context/contract_token_db.rs | 198 ++ src/context/identity_db.rs | 205 ++ src/context/mod.rs | 551 +++++ src/context/settings_db.rs | 84 + src/context/transaction_processing.rs | 295 +++ src/context/wallet_lifecycle.rs | 731 ++++++ src/context_provider.rs | 14 +- src/logging.rs | 63 +- src/main.rs | 2 +- .../encrypted_key_storage.rs | 10 +- src/model/qualified_identity/mod.rs | 4 +- src/model/wallet/asset_lock_transaction.rs | 2 +- src/model/wallet/mod.rs | 37 + src/spv/manager.rs | 447 +++- src/spv/mod.rs | 3 +- src/ui/components/confirmation_dialog.rs | 2 +- src/ui/components/info_popup.rs | 2 +- src/ui/components/left_panel.rs | 9 +- src/ui/components/left_wallet_panel.rs | 7 +- src/ui/components/top_panel.rs | 3 +- src/ui/components/wallet_unlock_popup.rs | 2 +- src/ui/helpers.rs | 444 ++-- .../by_wallet_qr_code.rs | 2 +- src/ui/identities/identities_screen.rs | 4 +- .../by_wallet_qr_code.rs | 2 +- .../identities/top_up_identity_screen/mod.rs | 37 +- .../data_contract_json_pop_up.rs | 2 +- src/ui/tokens/tokens_screen/mod.rs | 13 +- src/ui/tools/masternode_list_diff_screen.rs | 1649 +++++++------- src/ui/wallets/account_summary.rs | 11 +- src/ui/wallets/add_new_wallet_screen.rs | 2 +- src/ui/wallets/asset_lock_detail_screen.rs | 2 +- src/ui/wallets/create_asset_lock_screen.rs | 2 +- .../wallets/wallets_screen/address_table.rs | 398 ++++ src/ui/wallets/wallets_screen/asset_locks.rs | 166 ++ src/ui/wallets/wallets_screen/dialogs.rs | 1195 ++++++++++ src/ui/wallets/wallets_screen/mod.rs | 1964 +---------------- .../wallets/wallets_screen/single_key_view.rs | 186 ++ 53 files changed, 6572 insertions(+), 5319 deletions(-) delete mode 100644 src/context.rs create mode 100644 src/context/contract_token_db.rs create mode 100644 src/context/identity_db.rs create mode 100644 src/context/mod.rs create mode 100644 src/context/settings_db.rs create mode 100644 src/context/transaction_processing.rs create mode 100644 src/context/wallet_lifecycle.rs create mode 100644 src/ui/wallets/wallets_screen/address_table.rs create mode 100644 src/ui/wallets/wallets_screen/asset_locks.rs create mode 100644 src/ui/wallets/wallets_screen/dialogs.rs create mode 100644 src/ui/wallets/wallets_screen/single_key_view.rs diff --git a/CLAUDE.md b/CLAUDE.md index a9288d619..060577e3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,21 +33,22 @@ Test locations: ### Core Module Structure -- **app.rs** - Main application state, task result handling, screen management -- **ui/** - Screens (network_chooser, dpns, identities, wallets, contracts_documents, tokens, dashpay, tools) and reusable components -- **backend_task/** - Async business logic (contract, document, platform_info, identity, wallet operations) +- **app.rs** - `AppState`: owns all screens, polls task results each frame, dispatches to visible screen +- **ui/** - Screens and reusable components (`ui/components/`) +- **backend_task/** - Async business logic, one submodule per domain (identity, wallet, contract, etc.) - **model/** - Data types (amounts, fees, settings, wallet/identity models) -- **database/** - SQLite persistence (rusqlite) for wallets, identities, settings, proof logs -- **context.rs** - Application context (network config, SDK client, database connection) +- **database/** - SQLite persistence (rusqlite), one module per domain +- **context/** - `AppContext`: network config, SDK client, database, wallets, settings cache (split into submodules: `identity_db.rs`, `wallet_lifecycle.rs`, `settings_db.rs`, etc.) - **spv/** - Simplified Payment Verification for light wallet support -- **components/core_zmq_listener** - Real-time Dash Core event listening +- **components/core_zmq_listener** - Real-time Dash Core event listening via ZMQ ### Key Dependencies -- `dash-sdk` - Dash blockchain SDK (platform protocol, core interactions) -- `egui/eframe` - Immediate mode GUI framework +- `dash-sdk` - Dash blockchain SDK (git dep from dashpay/platform) +- `egui/eframe 0.33` - Immediate mode GUI framework - `tokio` - Async runtime (12 worker threads) - `rusqlite` - SQLite with bundled library +- Rust edition 2024, minimum rust-version 1.92 ### Configuration @@ -58,6 +59,50 @@ Environment config via `.env` in app directory: See `.env.example` for network configuration options. +## App Task System (Critical Pattern) + +The UI and async backend communicate through an action/channel pattern: + +1. **Screens return `AppAction`** from their `ui()` method (e.g., `AppAction::BackendTask(task)`) +2. **`AppState` spawns a tokio task** that calls `app_context.run_backend_task(task, sender)` +3. **`AppContext::run_backend_task()`** matches on the `BackendTask` enum and dispatches to domain-specific async methods +4. **Results come back** via tokio MPSC channel as `TaskResult` (Success/Error/Refresh) +5. **Main `update()` loop** polls `task_result_receiver.try_recv()` each frame and routes results to the visible screen's `display_task_result()` + +``` +Screen::ui() → AppAction::BackendTask(task) + → tokio::spawn → AppContext::run_backend_task() + → sender.send(TaskResult::Success(result)) + → AppState::update() polls receiver → Screen::display_task_result() +``` + +**Backend task enums**: `BackendTask` has variants like `IdentityTask(IdentityTask)`, `WalletTask(WalletTask)`, `TokenTask(Box)`, etc. Each sub-enum has its own variants and corresponding `run_*_task()` method. Results are `BackendTaskSuccessResult` with 50+ typed variants. + +## Screen Pattern + +All screens implement the `ScreenLike` trait: +- `ui(&mut self, ctx: &Context) -> AppAction` - Render UI, return actions +- `display_task_result(&mut self, result: BackendTaskSuccessResult)` - Handle async results +- `display_message(&mut self, msg: &str, type: MessageType)` - Show user feedback +- `refresh(&mut self)` / `refresh_on_arrival(&mut self)` - Re-fetch data +- `change_context(&mut self, app_context: &Arc)` - Handle network switch + +**Screen types**: +- **Root screens**: Stored in `AppState.main_screens` (BTreeMap by `RootScreenType`), persist across navigation +- **Modal/detail screens**: Pushed onto `AppState.screen_stack`, popped when dismissed + +Screens hold `Arc` and manage their own UI state. + +## AppContext + +`AppContext` (~50 fields) is `Arc`-wrapped and shared across all screens and async tasks. Key contents: +- `sdk: RwLock` - Dash SDK (clone for async use to avoid holding lock across await) +- `db: Arc` - SQLite persistence +- `wallets: RwLock>` - Loaded wallets +- Cached system contracts (DPNS, DashPay, withdrawals, tokens, keyword search) +- `connection_status`, `developer_mode`, `fee_multiplier_permille` +- Per-network instances (mainnet always present, others created on demand) + ## UI Component Pattern Components follow a lazy initialization pattern (see `doc/COMPONENT_DESIGN_PATTERN.md`): @@ -77,14 +122,18 @@ response.inner.update(&mut self.amount); **Requirements:** - Private fields only - Builder methods for configuration (`with_label()`, etc.) -- Response struct with `ComponentResponse` trait +- Response struct with `ComponentResponse` trait (`has_changed()`, `is_valid()`, `changed_value()`) - Self-contained validation and error handling - Support both light and dark mode via `ComponentStyles` **Anti-patterns:** public mutable fields, eager initialization, not clearing invalid data +## Database + +Single SQLite connection wrapped in `Mutex`. Schema initialized in `database/initialization.rs`. Domain modules provide typed CRUD methods. Backend task errors are `Result` — string errors display directly to users. + ## Platform Targets Linux (x86_64/aarch64), Windows (x86_64), macOS (x86_64/aarch64 with code signing) -Requires protoc v25.2+ for protocol buffer compilation. +Requires protoc v25.2+ for protocol buffer compilation. Different ZMQ libraries for Windows (`zeromq`) vs Unix (`zmq`). diff --git a/Cargo.lock b/Cargo.lock index 97271dcf7..12811c73d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,9 +20,9 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "accesskit" -version = "0.19.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e25ae84c0260bdf5df07796d7cc4882460de26a2b406ec0e6c42461a723b271b" +checksum = "cf203f9d3bd8f29f98833d1fbef628df18f759248a547e7e01cfbf63cda36a99" dependencies = [ "enumn", "serde", @@ -30,12 +30,12 @@ dependencies = [ [[package]] name = "accesskit_atspi_common" -version = "0.12.0" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29bd41de2e54451a8ca0dd95ebf45b54d349d29ebceb7f20be264eee14e3d477" +checksum = "890d241cf51fc784f0ac5ac34dfc847421f8d39da6c7c91a0fcc987db62a8267" dependencies = [ "accesskit", - "accesskit_consumer", + "accesskit_consumer 0.31.0", "atspi-common", "serde", "thiserror 1.0.69", @@ -44,9 +44,19 @@ dependencies = [ [[package]] name = "accesskit_consumer" -version = "0.28.0" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bfae7c152994a31dc7d99b8eeac7784a919f71d1b306f4b83217e110fd3824c" +checksum = "bdd06f5fea9819250fffd4debf926709f3593ac22f8c1541a2573e5ee0ca01cd" +dependencies = [ + "accesskit", + "hashbrown 0.15.5", +] + +[[package]] +name = "accesskit_consumer" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db81010a6895d8707f9072e6ce98070579b43b717193d2614014abd5cb17dd43" dependencies = [ "accesskit", "hashbrown 0.15.5", @@ -54,12 +64,12 @@ dependencies = [ [[package]] name = "accesskit_macos" -version = "0.20.0" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692dd318ff8a7a0ffda67271c4bd10cf32249656f4e49390db0b26ca92b095f2" +checksum = "a0089e5c0ac0ca281e13ea374773898d9354cc28d15af9f0f7394d44a495b575" dependencies = [ "accesskit", - "accesskit_consumer", + "accesskit_consumer 0.31.0", "hashbrown 0.15.5", "objc2 0.5.2", "objc2-app-kit 0.2.2", @@ -68,9 +78,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.15.0" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5f7474c36606d0fe4f438291d667bae7042ea2760f506650ad2366926358fc8" +checksum = "301e55b39cfc15d9c48943ce5f572204a551646700d0e8efa424585f94fec528" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -86,12 +96,12 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.27.0" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a042b62c9c05bf7b616f015515c17d2813f3ba89978d6f4fc369735d60700a" +checksum = "d2d63dd5041e49c363d83f5419a896ecb074d309c414036f616dc0b04faca971" dependencies = [ "accesskit", - "accesskit_consumer", + "accesskit_consumer 0.31.0", "hashbrown 0.15.5", "static_assertions", "windows 0.61.3", @@ -100,9 +110,9 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.27.0" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1f0d3d13113d8857542a4f8d1a1c24d1dc1527b77aee8426127f4901588708" +checksum = "c8cfabe59d0eaca7412bfb1f70198dd31e3b0496fee7e15b066f9c36a1a140a0" dependencies = [ "accesskit", "accesskit_macos", @@ -198,7 +208,7 @@ dependencies = [ "log", "ndk", "ndk-context", - "ndk-sys 0.6.0+11769913", + "ndk-sys", "num_enum 0.7.5", "thiserror 1.0.69", ] @@ -270,9 +280,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" [[package]] name = "arboard" @@ -430,28 +440,6 @@ dependencies = [ "zbus", ] -[[package]] -name = "ashpd" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" -dependencies = [ - "async-fs", - "async-net", - "enumflags2", - "futures-channel", - "futures-util", - "rand 0.9.2", - "raw-window-handle", - "serde", - "serde_repr", - "url", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "zbus", -] - [[package]] name = "async-broadcast" version = "0.7.2" @@ -732,6 +720,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "backon" version = "1.6.0" @@ -1401,6 +1411,15 @@ dependencies = [ "error-code", ] +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + [[package]] name = "codespan-reporting" version = "0.12.0" @@ -1498,7 +1517,7 @@ checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", - "core-graphics-types", + "core-graphics-types 0.1.3", "foreign-types 0.5.0", "libc", ] @@ -1514,6 +1533,17 @@ dependencies = [ "libc", ] +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.1", + "libc", +] + [[package]] name = "core2" version = "0.4.0" @@ -1709,7 +1739,7 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "18e1a09f280e29a8b00bc7e81eca5ac87dca0575639c9422a5fa25a07bb884b8" dependencies = [ - "ashpd 0.10.3", + "ashpd", "async-std", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -1798,15 +1828,14 @@ dependencies = [ "humantime", "image", "itertools 0.14.0", - "libsqlite3-sys", "native-dialog", "nix", "qrcode", - "rand 0.8.5", + "rand 0.9.2", "raw-cpuid", "rayon", "regex", - "reqwest", + "reqwest 0.13.2", "resvg", "rfd", "rusqlite", @@ -1832,8 +1861,8 @@ dependencies = [ [[package]] name = "dash-network" -version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=53d699c9b551ac7d3644e11ca46dc3819277ff87#53d699c9b551ac7d3644e11ca46dc3819277ff87" +version = "0.42.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=12ba186fd24a85fcb4736f0868b393f8a5b58c46#12ba186fd24a85fcb4736f0868b393f8a5b58c46" dependencies = [ "bincode 2.0.1", "bincode_derive", @@ -1887,8 +1916,8 @@ dependencies = [ [[package]] name = "dash-spv" -version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=53d699c9b551ac7d3644e11ca46dc3819277ff87#53d699c9b551ac7d3644e11ca46dc3819277ff87" +version = "0.42.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=12ba186fd24a85fcb4736f0868b393f8a5b58c46#12ba186fd24a85fcb4736f0868b393f8a5b58c46" dependencies = [ "anyhow", "async-trait", @@ -1898,6 +1927,7 @@ dependencies = [ "clap", "dashcore", "dashcore_hashes", + "futures", "hex", "hickory-resolver", "indexmap 2.13.0", @@ -1910,6 +1940,7 @@ dependencies = [ "serde_json", "thiserror 1.0.69", "tokio", + "tokio-stream", "tokio-util", "tracing", "tracing-appender", @@ -1918,8 +1949,8 @@ dependencies = [ [[package]] name = "dashcore" -version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=53d699c9b551ac7d3644e11ca46dc3819277ff87#53d699c9b551ac7d3644e11ca46dc3819277ff87" +version = "0.42.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=12ba186fd24a85fcb4736f0868b393f8a5b58c46#12ba186fd24a85fcb4736f0868b393f8a5b58c46" dependencies = [ "anyhow", "base64-compat", @@ -1944,13 +1975,13 @@ dependencies = [ [[package]] name = "dashcore-private" -version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=53d699c9b551ac7d3644e11ca46dc3819277ff87#53d699c9b551ac7d3644e11ca46dc3819277ff87" +version = "0.42.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=12ba186fd24a85fcb4736f0868b393f8a5b58c46#12ba186fd24a85fcb4736f0868b393f8a5b58c46" [[package]] name = "dashcore-rpc" -version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=53d699c9b551ac7d3644e11ca46dc3819277ff87#53d699c9b551ac7d3644e11ca46dc3819277ff87" +version = "0.42.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=12ba186fd24a85fcb4736f0868b393f8a5b58c46#12ba186fd24a85fcb4736f0868b393f8a5b58c46" dependencies = [ "dashcore-rpc-json", "hex", @@ -1962,8 +1993,8 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" -version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=53d699c9b551ac7d3644e11ca46dc3819277ff87#53d699c9b551ac7d3644e11ca46dc3819277ff87" +version = "0.42.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=12ba186fd24a85fcb4736f0868b393f8a5b58c46#12ba186fd24a85fcb4736f0868b393f8a5b58c46" dependencies = [ "bincode 2.0.1", "dashcore", @@ -1977,8 +2008,8 @@ dependencies = [ [[package]] name = "dashcore_hashes" -version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=53d699c9b551ac7d3644e11ca46dc3819277ff87#53d699c9b551ac7d3644e11ca46dc3819277ff87" +version = "0.42.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=12ba186fd24a85fcb4736f0868b393f8a5b58c46#12ba186fd24a85fcb4736f0868b393f8a5b58c46" dependencies = [ "bincode 2.0.1", "dashcore-private", @@ -2369,11 +2400,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "ecolor" -version = "0.32.3" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94bdf37f8d5bd9aa7f753573fdda9cf7343afa73dd28d7bfe9593bd9798fc07e" +checksum = "71ddb8ac7643d1dba1bb02110e804406dd459a838efcb14011ced10556711a8e" dependencies = [ "bytemuck", "emath", @@ -2430,9 +2467,9 @@ dependencies = [ [[package]] name = "eframe" -version = "0.32.3" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14d1c15e7bd136b309bd3487e6ffe5f668b354cd9768636a836dd738ac90eb0b" +checksum = "457481173e6db5ca9fa2be93a58df8f4c7be639587aeb4853b526c6cf87db4e6" dependencies = [ "ahash", "bytemuck", @@ -2462,16 +2499,15 @@ dependencies = [ "wasm-bindgen-futures", "web-sys", "web-time", - "winapi", - "windows-sys 0.59.0", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui" -version = "0.32.3" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d5d0306cd61ca75e29682926d71f2390160247f135965242e904a636f51c0dc" +checksum = "6a9b567d356674e9a5121ed3fedfb0a7c31e059fe71f6972b691bcd0bfc284e3" dependencies = [ "accesskit", "ahash", @@ -2489,9 +2525,9 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.32.3" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c12eca13293f8eba27a32aaaa1c765bfbf31acd43e8d30d5881dcbe5e99ca0c7" +checksum = "5e4d209971c84b2352a06174abdba701af1e552ce56b144d96f2bd50a3c91236" dependencies = [ "ahash", "bytemuck", @@ -2500,7 +2536,7 @@ dependencies = [ "epaint", "log", "profiling", - "thiserror 1.0.69", + "thiserror 2.0.18", "type-map", "web-time", "wgpu", @@ -2509,16 +2545,18 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.32.3" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f95d0a91f9cb0dc2e732d49c2d521ac8948e1f0b758f306fb7b14d6f5db3927f" +checksum = "ec6687e5bb551702f4ad10ac428bab12acf9d53047ebb1082d4a0ed8c6251a29" dependencies = [ "accesskit_winit", - "ahash", "arboard", "bytemuck", "egui", "log", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", "profiling", "raw-window-handle", "serde", @@ -2530,9 +2568,9 @@ dependencies = [ [[package]] name = "egui_commonmark" -version = "0.21.1" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26c9caff9c964af1e3d913acd85e86d2170e3169a43cf4ff84eea3106691c14d" +checksum = "d5246a4e9b83c345ec8230933bd0dca16d1c3c11db0edd4fd9c1a90683240b49" dependencies = [ "egui", "egui_commonmark_backend", @@ -2542,9 +2580,9 @@ dependencies = [ [[package]] name = "egui_commonmark_backend" -version = "0.21.0" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e317aa4031f27be77d4c1c33cb038cdf02d77790c28e5cf1283a66cceb88695" +checksum = "d3cff846279556f57af8ea606f2e4ceaf83e60b81db014c126dfb926fa06c75b" dependencies = [ "egui", "egui_extras", @@ -2553,9 +2591,9 @@ dependencies = [ [[package]] name = "egui_extras" -version = "0.32.3" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dddbceddf39805fc6c62b1f7f9c05e23590b40844dc9ed89c6dc6dbc886e3e3b" +checksum = "d01d34e845f01c62e3fded726961092e70417d66570c499b9817ab24674ca4ed" dependencies = [ "ahash", "egui", @@ -2568,11 +2606,10 @@ dependencies = [ [[package]] name = "egui_glow" -version = "0.32.3" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7037813341727937f9e22f78d912f3e29bc3c46e2f40a9e82bb51cbf5e4cfb" +checksum = "6420863ea1d90e750f75075231a260030ad8a9f30a7cef82cdc966492dc4c4eb" dependencies = [ - "ahash", "bytemuck", "egui", "glow", @@ -2586,9 +2623,9 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.32.3" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bb00f16e00af09092c117515246732adba4ca4649463bdbc2ab6114a2944765" +checksum = "43afb5f968dfa9e6c8f5e609ab9039e11a2c4af79a326f4cb1b99cf6875cb6a0" dependencies = [ "eframe", "egui", @@ -2638,9 +2675,9 @@ dependencies = [ [[package]] name = "emath" -version = "0.32.3" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45fd7bc25f769a3c198fe1cf183124bf4de3bd62ef7b4f1eaf6b08711a3af8db" +checksum = "491bdf728bf25ddd9ad60d4cf1c48588fa82c013a2440b91aa7fc43e34a07c32" dependencies = [ "bytemuck", "serde", @@ -2785,9 +2822,9 @@ dependencies = [ [[package]] name = "epaint" -version = "0.32.3" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63adcea970b7a13094fe97a36ab9307c35a750f9e24bf00bb7ef3de573e0fddb" +checksum = "009d0dd3c2163823a0abdb899451ecbc78798dec545ee91b43aff1fa790bab62" dependencies = [ "ab_glyph", "ahash", @@ -2804,9 +2841,9 @@ dependencies = [ [[package]] name = "epaint_default_fonts" -version = "0.32.3" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1537accc50c9cab5a272c39300bdd0dd5dca210f6e5e8d70be048df9596e7ca2" +checksum = "5c4fbe202b6578d3d56428fa185cdf114a05e49da05f477b3c7f0fbb221f1862" [[package]] name = "equivalent" @@ -3005,7 +3042,7 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" dependencies = [ - "roxmltree", + "roxmltree 0.20.0", ] [[package]] @@ -3243,11 +3280,26 @@ name = "getrandom" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" dependencies = [ "cfg-if", "libc", "r-efi", "wasip2", + "wasip3", ] [[package]] @@ -3262,9 +3314,9 @@ dependencies = [ [[package]] name = "gif" -version = "0.13.3" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" dependencies = [ "color_quant", "weezl", @@ -3458,7 +3510,7 @@ dependencies = [ "hex-literal", "indexmap 2.13.0", "integer-encoding", - "reqwest", + "reqwest 0.12.28", "sha2", "thiserror 2.0.18", ] @@ -3657,11 +3709,11 @@ dependencies = [ [[package]] name = "hashlink" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] @@ -4054,6 +4106,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -4109,9 +4167,9 @@ dependencies = [ [[package]] name = "imagesize" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" [[package]] name = "indexmap" @@ -4230,9 +4288,9 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jiff" -version = "0.2.18" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50" +checksum = "d89a5b5e10d5a9ad6e5d1f4bd58225f655d6fe9767575a5e8ac5a6fe64e04495" dependencies = [ "jiff-static", "log", @@ -4243,9 +4301,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.18" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" +checksum = "ff7a39c8862fc1369215ccf0a8f12dd4598c7f6484704359f0351bd617034dbf" dependencies = [ "proc-macro2", "quote", @@ -4326,8 +4384,8 @@ dependencies = [ [[package]] name = "key-wallet" -version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=53d699c9b551ac7d3644e11ca46dc3819277ff87#53d699c9b551ac7d3644e11ca46dc3819277ff87" +version = "0.42.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=12ba186fd24a85fcb4736f0868b393f8a5b58c46#12ba186fd24a85fcb4736f0868b393f8a5b58c46" dependencies = [ "async-trait", "base58ck", @@ -4353,15 +4411,17 @@ dependencies = [ [[package]] name = "key-wallet-manager" -version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=53d699c9b551ac7d3644e11ca46dc3819277ff87#53d699c9b551ac7d3644e11ca46dc3819277ff87" +version = "0.42.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=12ba186fd24a85fcb4736f0868b393f8a5b58c46#12ba186fd24a85fcb4736f0868b393f8a5b58c46" dependencies = [ "async-trait", "bincode 2.0.1", "dashcore", "dashcore_hashes", "key-wallet", + "rayon", "secp256k1", + "tokio", "zeroize", ] @@ -4395,20 +4455,20 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kittest" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c1bfc4cb16136b6f00fb85a281e4b53d026401cf5dff9a427c466bde5891f0b" +checksum = "01fd6dd2cce251a360101038acb9334e3a50cd38cd02fefddbf28aa975f043c8" dependencies = [ "accesskit", - "accesskit_consumer", + "accesskit_consumer 0.30.1", "parking_lot", ] [[package]] name = "kurbo" -version = "0.11.3" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +checksum = "7564e90fe3c0d5771e1f0bc95322b21baaeaa0d9213fa6a0b61c99f8b17b3bfb" dependencies = [ "arrayvec", "euclid", @@ -4436,6 +4496,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "lhash" version = "1.1.0" @@ -4477,11 +4543,10 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.35.0" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" dependencies = [ - "cc", "pkg-config", "vcpkg", ] @@ -4537,6 +4602,12 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "malloc_buf" version = "0.0.6" @@ -4568,9 +4639,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memmap2" @@ -4604,13 +4675,13 @@ dependencies = [ [[package]] name = "metal" -version = "0.31.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f569fb946490b5743ad69813cb19629130ce9374034abe31614a36402d18f99e" +checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605" dependencies = [ "bitflags 2.10.0", "block", - "core-graphics-types", + "core-graphics-types 0.2.0", "foreign-types 0.5.0", "log", "objc", @@ -4717,25 +4788,26 @@ checksum = "9252111cf132ba0929b6f8e030cac2a24b507f3a4d6db6fb2896f27b354c714b" [[package]] name = "naga" -version = "25.0.1" +version = "27.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b977c445f26e49757f9aca3631c3b8b836942cb278d69a92e7b80d3b24da632" +checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" dependencies = [ "arrayvec", "bit-set 0.8.0", "bitflags 2.10.0", + "cfg-if", "cfg_aliases", "codespan-reporting", "half", - "hashbrown 0.15.5", + "hashbrown 0.16.1", "hexf-parse", "indexmap 2.13.0", + "libm", "log", "num-traits", "once_cell", "rustc-hash 1.1.0", "spirv", - "strum", "thiserror 2.0.18", "unicode-ident", ] @@ -4790,7 +4862,7 @@ dependencies = [ "bitflags 2.10.0", "jni-sys", "log", - "ndk-sys 0.6.0+11769913", + "ndk-sys", "num_enum 0.7.5", "raw-window-handle", "thiserror 1.0.69", @@ -4802,15 +4874,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" -[[package]] -name = "ndk-sys" -version = "0.5.0+25.2.9519653" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" -dependencies = [ - "jni-sys", -] - [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -4822,9 +4885,9 @@ dependencies = [ [[package]] name = "nix" -version = "0.30.1" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +checksum = "225e7cfe711e0ba79a68baeddb2982723e4235247aefce1482f2f16c27865b66" dependencies = [ "bitflags 2.10.0", "cfg-if", @@ -5386,9 +5449,9 @@ dependencies = [ [[package]] name = "ordered-float" -version = "4.6.0" +version = "5.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d" dependencies = [ "num-traits", ] @@ -5934,6 +5997,62 @@ dependencies = [ "serde", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.1", + "rustls", + "socket2 0.6.2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash 2.1.1", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.44" @@ -6170,6 +6289,47 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -6177,7 +6337,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.5.0", "web-sys", ] @@ -6189,9 +6349,9 @@ checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" [[package]] name = "resvg" -version = "0.45.1" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" +checksum = "b563218631706d614e23059436526d005b50ab5f2d506b55a17eb65c5eb83419" dependencies = [ "gif", "image-webp", @@ -6201,31 +6361,34 @@ dependencies = [ "svgtypes", "tiny-skia", "usvg", - "zune-jpeg 0.4.21", + "zune-jpeg 0.5.12", ] [[package]] name = "rfd" -version = "0.15.4" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" dependencies = [ - "ashpd 0.11.1", "block2 0.6.2", "dispatch2", "js-sys", + "libc", "log", "objc2 0.6.3", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", + "percent-encoding", "pollster", "raw-window-handle", - "urlencoding", "wasm-bindgen", "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", "web-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6253,9 +6416,9 @@ dependencies = [ [[package]] name = "ron" -version = "0.10.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "beceb6f7bf81c73e73aeef6dd1356d9a1b2b4909e1f0fc3e59b034f9572d7b7f" +checksum = "db09040cc89e461f1a265139777a2bde7f8d8c67c4936f700c63ce3e2904d468" dependencies = [ "base64 0.22.1", "bitflags 2.10.0", @@ -6270,6 +6433,15 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rs-dapi-client" version = "3.0.0" @@ -6307,11 +6479,21 @@ dependencies = [ "libc", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.18", +] + [[package]] name = "rusqlite" -version = "0.37.0" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" dependencies = [ "bitflags 2.10.0", "fallible-iterator", @@ -6319,6 +6501,7 @@ dependencies = [ "hashlink", "libsqlite3-sys", "smallvec", + "sqlite-wasm-rs", ] [[package]] @@ -6408,6 +6591,7 @@ version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ + "aws-lc-rs", "log", "once_cell", "ring", @@ -6435,15 +6619,44 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ + "web-time", "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework 3.5.1", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -6475,9 +6688,9 @@ dependencies = [ [[package]] name = "ryu" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "same-file" @@ -6959,6 +7172,18 @@ dependencies = [ "der", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "sqlparser" version = "0.38.0" @@ -7045,9 +7270,9 @@ dependencies = [ [[package]] name = "svgtypes" -version = "0.15.3" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" dependencies = [ "kurbo", "siphasher", @@ -7149,12 +7374,12 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tempfile" -version = "3.24.0" +version = "3.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.1", "once_cell", "rustix 1.1.3", "windows-sys 0.61.2", @@ -7290,9 +7515,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.46" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", @@ -7311,9 +7536,9 @@ checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.26" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -7438,6 +7663,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -7629,7 +7855,7 @@ dependencies = [ "tower-service", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.4.2", "web-sys", ] @@ -7850,9 +8076,9 @@ checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" [[package]] name = "unicode-normalization" @@ -7929,9 +8155,9 @@ checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" [[package]] name = "ureq" -version = "3.1.4" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d39cb1dbab692d82a977c0392ffac19e188bd9186a9f32806f0aaa859d75585a" +checksum = "fdc97a28575b85cfedf2a7e7d3cc64b3e11bd8ac766666318003abbacc7a21fc" dependencies = [ "base64 0.22.1", "flate2", @@ -7969,17 +8195,11 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - [[package]] name = "usvg" -version = "0.45.1" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +checksum = "e419dff010bb12512b0ae9e3d2f318dfbdf0167fde7eb05465134d4e8756076f" dependencies = [ "base64 0.22.1", "data-url", @@ -7989,7 +8209,7 @@ dependencies = [ "kurbo", "log", "pico-args", - "roxmltree", + "roxmltree 0.21.1", "rustybuzz", "simplecss", "siphasher", @@ -8158,6 +8378,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.108" @@ -8217,6 +8446,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.4.2" @@ -8230,6 +8481,31 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", +] + [[package]] name = "wayland-backend" version = "0.3.12" @@ -8387,9 +8663,9 @@ dependencies = [ [[package]] name = "webbrowser" -version = "1.0.6" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00f1243ef785213e3a32fa0396093424a3a6ea566f9948497e5a2309261a4c97" +checksum = "3f00bb839c1cf1e3036066614cbdcd035ecf215206691ea646aa3c60a24f68f2" dependencies = [ "core-foundation 0.10.1", "jni", @@ -8401,6 +8677,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "1.0.6" @@ -8428,15 +8713,16 @@ dependencies = [ [[package]] name = "wgpu" -version = "25.0.2" +version = "27.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec8fb398f119472be4d80bc3647339f56eb63b2a331f6a3d16e25d8144197dd9" +checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" dependencies = [ "arrayvec", "bitflags 2.10.0", + "cfg-if", "cfg_aliases", "document-features", - "hashbrown 0.15.5", + "hashbrown 0.16.1", "js-sys", "log", "naga", @@ -8456,17 +8742,18 @@ dependencies = [ [[package]] name = "wgpu-core" -version = "25.0.2" +version = "27.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7b882196f8368511d613c6aeec80655160db6646aebddf8328879a88d54e500" +checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7" dependencies = [ "arrayvec", "bit-set 0.8.0", "bit-vec 0.8.0", "bitflags 2.10.0", + "bytemuck", "cfg_aliases", "document-features", - "hashbrown 0.15.5", + "hashbrown 0.16.1", "indexmap 2.13.0", "log", "naga", @@ -8487,36 +8774,36 @@ dependencies = [ [[package]] name = "wgpu-core-deps-apple" -version = "25.0.0" +version = "27.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfd488b3239b6b7b185c3b045c39ca6bf8af34467a4c5de4e0b1a564135d093d" +checksum = "0772ae958e9be0c729561d5e3fd9a19679bcdfb945b8b1a1969d9bfe8056d233" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-emscripten" -version = "25.0.0" +version = "27.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09ad7aceb3818e52539acc679f049d3475775586f3f4e311c30165cf2c00445" +checksum = "b06ac3444a95b0813ecfd81ddb2774b66220b264b3e2031152a4a29fda4da6b5" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-windows-linux-android" -version = "25.0.0" +version = "27.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cba5fb5f7f9c98baa7c889d444f63ace25574833df56f5b817985f641af58e46" +checksum = "71197027d61a71748e4120f05a9242b2ad142e3c01f8c1b47707945a879a03c3" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-hal" -version = "25.0.2" +version = "27.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f968767fe4d3d33747bbd1473ccd55bf0f6451f55d733b5597e67b5deab4ad17" +checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce" dependencies = [ "android_system_properties", "arrayvec", @@ -8527,13 +8814,13 @@ dependencies = [ "bytemuck", "cfg-if", "cfg_aliases", - "core-graphics-types", + "core-graphics-types 0.2.0", "glow", "glutin_wgl_sys", "gpu-alloc", "gpu-allocator", "gpu-descriptor", - "hashbrown 0.15.5", + "hashbrown 0.16.1", "js-sys", "khronos-egl", "libc", @@ -8541,11 +8828,13 @@ dependencies = [ "log", "metal", "naga", - "ndk-sys 0.5.0+25.2.9519653", + "ndk-sys", "objc", + "once_cell", "ordered-float", "parking_lot", "portable-atomic", + "portable-atomic-util", "profiling", "range-alloc", "raw-window-handle", @@ -8561,9 +8850,9 @@ dependencies = [ [[package]] name = "wgpu-types" -version = "25.0.0" +version = "27.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2aa49460c2a8ee8edba3fca54325540d904dd85b2e086ada762767e17d06e8bc" +checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" dependencies = [ "bitflags 2.10.0", "bytemuck", @@ -9378,6 +9667,88 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.13.0", + "prettyplease", + "syn 2.0.114", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.114", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.10.0", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "withdrawals-contract" @@ -9599,18 +9970,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.38" +version = "0.8.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57cf3aa6855b23711ee9852dfc97dfaa51c45feaba5b645d0c777414d494a961" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.38" +version = "0.8.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a616990af1a287837c4fe6596ad77ef57948f787e46ce28e166facc0cc1cb75" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" dependencies = [ "proc-macro2", "quote", @@ -9731,9 +10102,9 @@ dependencies = [ [[package]] name = "zip" -version = "7.3.0" +version = "7.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "268bf6f9ceb991e07155234071501490bb41fd1e39c6a588106dad10ae2a5804" +checksum = "cc12baa6db2b15a140161ce53d72209dacea594230798c24774139b54ecaa980" dependencies = [ "crc32fast", "flate2", @@ -9751,9 +10122,9 @@ checksum = "a7948af682ccbc3342b6e9420e8c51c1fe5d7bf7756002b4a3c6cabfe96a7e3c" [[package]] name = "zmij" -version = "1.0.19" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" +checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" [[package]] name = "zmq" diff --git a/Cargo.toml b/Cargo.toml index 81c174c64..6e1597977 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,14 +9,14 @@ rust-version = "1.92" [dependencies] tokio-util = { version = "0.7.15" } bip39 = { version = "2.2.0", features = ["all-languages", "rand"] } -derive_more = "2.0.1" -egui = "0.32.0" -egui_extras = "0.32.0" -egui_commonmark = "0.21.1" -rfd = "0.15.4" +derive_more = "2.1.1" +egui = "0.33.3" +egui_extras = "0.33.3" +egui_commonmark = "0.22.0" +rfd = "0.17.2" qrcode = "0.14.1" -nix = { version = "0.30.1", features = ["signal"] } -eframe = { version = "0.32.0", features = ["persistence"] } +nix = { version = "0.31.1", features = ["signal"] } +eframe = { version = "0.33.3", features = ["persistence"] } base64 = "0.22.1" dash-sdk = { git = "https://github.com/dashpay/platform", rev = "c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56", features = [ "core_key_wallet", @@ -29,7 +29,7 @@ dash-sdk = { git = "https://github.com/dashpay/platform", rev = "c2c88e4a988ce93 ] } grovestark = { git = "https://www.github.com/dashpay/grovestark", rev = "5b9e289cca54c79b1305d5f4f40bf1148f1eb0e3" } rayon = "1.8" -thiserror = "2.0.12" +thiserror = "2.0.18" serde = "1.0.219" serde_json = "1.0.140" serde_yaml = { version = "0.9.34-deprecated" } @@ -40,7 +40,7 @@ itertools = "0.14.0" enum-iterator = "2.1.0" futures = "0.3.31" tracing = "0.1.41" -rand = "0.8" +rand = "0.9" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } dotenvy = "0.15.7" envy = "0.4.2" @@ -52,16 +52,15 @@ arboard = { version = "3.6.0", default-features = false, features = [ "windows-sys", ] } directories = "6.0.0" -rusqlite = { version = "0.37.0", features = ["functions"] } +rusqlite = { version = "0.38.0", features = ["functions", "fallible_uint"] } dark-light = "2.0.0" image = { version = "0.25.6", default-features = false, features = [ "png", "jpeg", ] } -resvg = "0.45" -reqwest = { version = "0.12", features = ["json", "stream"] } -bitflags = "2.9.1" -libsqlite3-sys = { version = "0.35.0", features = ["bundled"] } +resvg = "0.46" +reqwest = { version = "0.13", features = ["json", "stream"] } +bitflags = "2.10" rust-embed = "8.7.2" zeroize = "1.8.1" zxcvbn = "3.1.0" @@ -73,6 +72,7 @@ regex = "1.11.1" humantime = "2.2.0" which = { version = "8.0.0" } tz-rs = { version = "0.7.0" } +tempfile = "3.20.0" [target.'cfg(not(target_os = "windows"))'.dependencies] zmq = "0.10.0" @@ -85,8 +85,7 @@ native-dialog = "0.9.0" raw-cpuid = "11.5.0" [dev-dependencies] -tempfile = { version = "3.20.0" } -egui_kittest = { version = "0.32.0", features = ["eframe"] } +egui_kittest = { version = "0.33.3", features = ["eframe"] } [build-dependencies] winres = "0.1" diff --git a/src/backend_task/core/create_asset_lock.rs b/src/backend_task/core/create_asset_lock.rs index 94faf5379..5111d83dd 100644 --- a/src/backend_task/core/create_asset_lock.rs +++ b/src/backend_task/core/create_asset_lock.rs @@ -1,13 +1,12 @@ use crate::backend_task::BackendTaskSuccessResult; use crate::context::AppContext; use crate::model::wallet::Wallet; -use dash_sdk::dashcore_rpc::RpcApi; use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; use dash_sdk::dpp::fee::Credits; use std::sync::{Arc, RwLock}; impl AppContext { - pub fn create_registration_asset_lock( + pub async fn create_registration_asset_lock( &self, wallet: Arc>, amount: Credits, @@ -39,10 +38,8 @@ impl AppContext { } // Broadcast the transaction - self.core_client - .read() - .expect("Core client lock was poisoned") - .send_raw_transaction(&asset_lock_transaction) + self.broadcast_raw_transaction(&asset_lock_transaction) + .await .map_err(|e| format!("Failed to broadcast asset lock transaction: {}", e))?; // Update wallet UTXOs @@ -59,6 +56,8 @@ impl AppContext { .drop_utxo(utxo, &self.network.to_string()) .map_err(|e| e.to_string())?; } + + wallet_guard.recalculate_affected_address_balances(&used_utxos, self)?; } Ok(BackendTaskSuccessResult::Message(format!( @@ -67,7 +66,7 @@ impl AppContext { ))) } - pub fn create_top_up_asset_lock( + pub async fn create_top_up_asset_lock( &self, wallet: Arc>, amount: Credits, @@ -101,10 +100,8 @@ impl AppContext { } // Broadcast the transaction - self.core_client - .read() - .expect("Core client lock was poisoned") - .send_raw_transaction(&asset_lock_transaction) + self.broadcast_raw_transaction(&asset_lock_transaction) + .await .map_err(|e| format!("Failed to broadcast asset lock transaction: {}", e))?; // Update wallet UTXOs @@ -121,6 +118,8 @@ impl AppContext { .drop_utxo(utxo, &self.network.to_string()) .map_err(|e| e.to_string())?; } + + wallet_guard.recalculate_affected_address_balances(&used_utxos, self)?; } Ok(BackendTaskSuccessResult::Message(format!( diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index bdad43d1f..543a1614a 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -255,9 +255,11 @@ impl AppContext { .map(|_| BackendTaskSuccessResult::None), CoreTask::CreateRegistrationAssetLock(wallet, amount, identity_index) => self .create_registration_asset_lock(wallet, amount, true, identity_index) + .await .map_err(|e| format!("Error creating asset lock: {}", e)), CoreTask::CreateTopUpAssetLock(wallet, amount, identity_index, top_up_index) => self .create_top_up_asset_lock(wallet, amount, true, identity_index, top_up_index) + .await .map_err(|e| format!("Error creating top up asset lock: {}", e)), CoreTask::SendWalletPayment { wallet, request } => { self.send_wallet_payment(wallet, request).await @@ -483,7 +485,12 @@ impl AppContext { const FALLBACK_STEP: u64 = 100; let network = self.wallet_network_key(); - let current_height = wm.current_height(); + let current_height = self + .spv_manager() + .status() + .sync_progress + .map(|p| p.header_height) + .ok_or("Cannot build transaction: SPV sync height is not yet known")?; let total_amount: u64 = recipients.iter().map(|(_, amt)| *amt).sum(); let mut scale_factor = 1.0f64; let mut attempted_fallback = false; diff --git a/src/backend_task/dashpay/incoming_payments.rs b/src/backend_task/dashpay/incoming_payments.rs index 5390fc112..5ddcbe95c 100644 --- a/src/backend_task/dashpay/incoming_payments.rs +++ b/src/backend_task/dashpay/incoming_payments.rs @@ -283,7 +283,7 @@ pub fn match_transaction_to_contact( } /// Process an incoming transaction that was detected by SPV -/// This should be called when SpvEvent::TransactionDetected is received +/// This should be called when WalletEvent::TransactionReceived is received pub async fn process_incoming_payment( app_context: &Arc, tx_id: &str, diff --git a/src/backend_task/dashpay/profile.rs b/src/backend_task/dashpay/profile.rs index 66d387781..ee4b325f0 100644 --- a/src/backend_task/dashpay/profile.rs +++ b/src/backend_task/dashpay/profile.rs @@ -262,7 +262,7 @@ pub async fn update_profile( // Create new profile using DocumentCreateTransitionBuilder // Generate random entropy for document ID (security: prevents predictable IDs) let mut entropy = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut entropy); + rand::rng().fill_bytes(&mut entropy); let profile_doc_id = Document::generate_document_id_v0( &dashpay_contract.id(), diff --git a/src/backend_task/identity/register_identity.rs b/src/backend_task/identity/register_identity.rs index 855f08387..47ae6fa5d 100644 --- a/src/backend_task/identity/register_identity.rs +++ b/src/backend_task/identity/register_identity.rs @@ -1,11 +1,11 @@ use crate::backend_task::identity::{IdentityRegistrationInfo, RegisterIdentityFundingMethod}; use crate::backend_task::{BackendTaskSuccessResult, FeeResult}; -use crate::context::{AppContext, get_transaction_info_via_dapi}; +use crate::context::{AppContext, get_transaction_info}; use crate::model::fee_estimation::PlatformFeeEstimator; use crate::model::proof_log_item::{ProofLogItem, RequestType}; use crate::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedIdentity}; +use crate::spv::CoreBackendMode; use dash_sdk::dash_spv::Network; -use dash_sdk::dashcore_rpc::RpcApi; use dash_sdk::dpp::ProtocolError; use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::block::extended_epoch_info::ExtendedEpochInfo; @@ -22,7 +22,6 @@ use dash_sdk::platform::{Fetch, FetchMany, Identity}; use dash_sdk::query_types::AddressInfo; use dash_sdk::{Error, Sdk}; use std::collections::BTreeMap; -use std::time::Duration; impl AppContext { pub(super) async fn register_identity( @@ -65,7 +64,7 @@ impl AppContext { asset_lock_proof.as_ref() { // we need to make sure the instant send asset lock is recent - let tx_info = get_transaction_info_via_dapi(&sdk, &tx_id).await?; + let tx_info = get_transaction_info(&sdk, &tx_id).await?; if tx_info.is_chain_locked && tx_info.height > 0 && tx_info.confirmations > 8 { // Transaction is old enough that instant lock may have expired @@ -107,24 +106,34 @@ impl AppContext { Some(self), ) { Ok(transaction) => transaction, - Err(_) => { - wallet - .reload_utxos( - &self - .core_client - .read() - .expect("Core client lock was poisoned"), - self.network, - Some(self), - ) - .map_err(|e| e.to_string())?; - wallet.registration_asset_lock_transaction( - sdk.network, - amount, - true, - identity_index, - Some(self), - )? + Err(e) => { + match self.core_backend_mode() { + CoreBackendMode::Rpc => { + wallet + .reload_utxos( + &self + .core_client + .read() + .expect("Core client lock was poisoned"), + self.network, + Some(self), + ) + .map_err(|e| e.to_string())?; + wallet.registration_asset_lock_transaction( + sdk.network, + amount, + true, + identity_index, + Some(self), + )? + } + CoreBackendMode::Spv => { + // SPV wallet state is authoritative — UTXOs are synced + // continuously via compact block filters. No Core RPC + // fallback available. + return Err(e); + } + } } } }; @@ -136,11 +145,8 @@ impl AppContext { proofs.insert(tx_id, None); } - self.core_client - .read() - .expect("Core client lock was poisoned") - .send_raw_transaction(&asset_lock_transaction) - .map_err(|e| e.to_string())?; + self.broadcast_raw_transaction(&asset_lock_transaction) + .await?; // Store the asset lock transaction in the database immediately after sending. // This ensures it's tracked even if the proof times out or identity creation fails. @@ -172,47 +178,10 @@ impl AppContext { .map_err(|e| e.to_string())?; } - // Update address_balances for affected addresses - let affected_addresses: std::collections::BTreeSet<_> = - used_utxos.values().map(|(_, addr)| addr.clone()).collect(); - for address in affected_addresses { - // Recalculate balance from remaining UTXOs for this address - let new_balance = wallet - .utxos - .get(&address) - .map(|utxo_map| utxo_map.values().map(|tx_out| tx_out.value).sum()) - .unwrap_or(0); - let _ = wallet.update_address_balance(&address, new_balance, self); - } + wallet.recalculate_affected_address_balances(&used_utxos, self)?; } - // Wait for asset lock proof with timeout (2 minutes) - const ASSET_LOCK_PROOF_TIMEOUT: Duration = Duration::from_secs(120); - let asset_lock_proof = match tokio::time::timeout(ASSET_LOCK_PROOF_TIMEOUT, async { - loop { - { - let proofs = self.transactions_waiting_for_finality.lock().unwrap(); - if let Some(Some(proof)) = proofs.get(&tx_id) { - return proof.clone(); - } - } - tokio::time::sleep(Duration::from_millis(200)).await; - } - }) - .await - { - Ok(proof) => proof, - Err(_) => { - // Clean up on timeout - let mut proofs = self.transactions_waiting_for_finality.lock().unwrap(); - proofs.remove(&tx_id); - return Err(format!( - "Timeout waiting for asset lock proof after {} seconds. \ - The transaction may not have been confirmed by the network.", - ASSET_LOCK_PROOF_TIMEOUT.as_secs() - )); - } - }; + let asset_lock_proof = self.wait_for_asset_lock_proof(tx_id).await?; (asset_lock_proof, asset_lock_proof_private_key, tx_id) } @@ -287,11 +256,8 @@ impl AppContext { proofs.insert(tx_id, None); } - self.core_client - .read() - .expect("Core client lock was poisoned") - .send_raw_transaction(&asset_lock_transaction) - .map_err(|e| e.to_string())?; + self.broadcast_raw_transaction(&asset_lock_transaction) + .await?; // Store the asset lock transaction in the database immediately after sending. // This ensures it's tracked even if the proof times out or identity creation fails. @@ -317,42 +283,10 @@ impl AppContext { .drop_utxo(&utxo, &self.network.to_string()) .map_err(|e| e.to_string())?; - // Update address_balance for the affected address - let new_balance = wallet - .utxos - .get(&input_address) - .map(|utxo_map| utxo_map.values().map(|tx_out| tx_out.value).sum()) - .unwrap_or(0); - let _ = wallet.update_address_balance(&input_address, new_balance, self); + wallet.recalculate_address_balance(&input_address, self)?; } - // Wait for asset lock proof with timeout (2 minutes) - const ASSET_LOCK_PROOF_TIMEOUT: Duration = Duration::from_secs(120); - let asset_lock_proof = match tokio::time::timeout(ASSET_LOCK_PROOF_TIMEOUT, async { - loop { - { - let proofs = self.transactions_waiting_for_finality.lock().unwrap(); - if let Some(Some(proof)) = proofs.get(&tx_id) { - return proof.clone(); - } - } - tokio::time::sleep(Duration::from_millis(200)).await; - } - }) - .await - { - Ok(proof) => proof, - Err(_) => { - // Clean up on timeout - let mut proofs = self.transactions_waiting_for_finality.lock().unwrap(); - proofs.remove(&tx_id); - return Err(format!( - "Timeout waiting for asset lock proof after {} seconds. \ - The transaction may not have been confirmed by the network.", - ASSET_LOCK_PROOF_TIMEOUT.as_secs() - )); - } - }; + let asset_lock_proof = self.wait_for_asset_lock_proof(tx_id).await?; (asset_lock_proof, asset_lock_proof_private_key, tx_id) } @@ -481,7 +415,7 @@ impl AppContext { || e.contains("wasn't created recently") { // Try to use chain asset lock proof instead - let tx_info = get_transaction_info_via_dapi(&sdk, &tx_id).await?; + let tx_info = get_transaction_info(&sdk, &tx_id).await?; if tx_info.is_chain_locked && tx_info.height > 0 { let tx_block_height = tx_info.height; diff --git a/src/backend_task/identity/top_up_identity.rs b/src/backend_task/identity/top_up_identity.rs index 5a309e89f..76d115a26 100644 --- a/src/backend_task/identity/top_up_identity.rs +++ b/src/backend_task/identity/top_up_identity.rs @@ -1,10 +1,10 @@ use crate::backend_task::identity::{IdentityTopUpInfo, TopUpIdentityFundingMethod}; use crate::backend_task::{BackendTaskSuccessResult, FeeResult}; -use crate::context::{AppContext, get_transaction_info_via_dapi}; +use crate::context::{AppContext, get_transaction_info}; use crate::model::fee_estimation::PlatformFeeEstimator; use crate::model::proof_log_item::{ProofLogItem, RequestType}; +use crate::spv::CoreBackendMode; use dash_sdk::Error; -use dash_sdk::dashcore_rpc::RpcApi; use dash_sdk::dpp::ProtocolError; use dash_sdk::dpp::block::extended_epoch_info::ExtendedEpochInfo; use dash_sdk::dpp::dashcore::OutPoint; @@ -16,7 +16,6 @@ use dash_sdk::dpp::state_transition::identity_topup_transition::IdentityTopUpTra use dash_sdk::dpp::state_transition::identity_topup_transition::methods::IdentityTopUpTransitionMethodsV0; use dash_sdk::platform::Fetch; use dash_sdk::platform::transition::top_up_identity::TopUpIdentity; -use std::time::Duration; impl AppContext { pub(super) async fn top_up_identity( @@ -59,7 +58,7 @@ impl AppContext { ) = asset_lock_proof.as_ref() { // we need to make sure the instant send asset lock is recent - let tx_info = get_transaction_info_via_dapi(&sdk, &tx_id).await?; + let tx_info = get_transaction_info(&sdk, &tx_id).await?; if tx_info.is_chain_locked && tx_info.height > 0 @@ -115,26 +114,27 @@ impl AppContext { Some(self), ) { Ok(transaction) => transaction, - Err(_) => { - wallet - .reload_utxos( - &self - .core_client - .read() - .expect("Core client lock was poisoned"), - self.network, + Err(e) => match self.core_backend_mode() { + CoreBackendMode::Rpc => { + let core_client = self.core_client.read().map_err(|e| { + format!("Core client lock was poisoned: {}", e) + })?; + wallet + .reload_utxos(&core_client, self.network, Some(self)) + .map_err(|e| e.to_string())?; + wallet.top_up_asset_lock_transaction( + sdk.network, + amount, + true, + identity_index, + top_up_index, Some(self), - ) - .map_err(|e| e.to_string())?; - wallet.top_up_asset_lock_transaction( - sdk.network, - amount, - true, - identity_index, - top_up_index, - Some(self), - )? - } + )? + } + CoreBackendMode::Spv => { + return Err(e); + } + }, }; ( tx_result.0, @@ -158,11 +158,8 @@ impl AppContext { proofs.insert(tx_id, None); } - self.core_client - .read() - .expect("Core client lock was poisoned") - .send_raw_transaction(&asset_lock_transaction) - .map_err(|e| e.to_string())?; + self.broadcast_raw_transaction(&asset_lock_transaction) + .await?; // Store the asset lock transaction in the database immediately after sending. // This ensures it's tracked even if the proof times out or top-up fails. @@ -189,50 +186,10 @@ impl AppContext { .map_err(|e| e.to_string())?; } - // Update address_balances for affected addresses - let affected_addresses: std::collections::BTreeSet<_> = - used_utxos.values().map(|(_, addr)| addr.clone()).collect(); - for address in affected_addresses { - // Recalculate balance from remaining UTXOs for this address - let new_balance = wallet - .utxos - .get(&address) - .map(|utxo_map| utxo_map.values().map(|tx_out| tx_out.value).sum()) - .unwrap_or(0); - let _ = wallet.update_address_balance(&address, new_balance, self); - } + wallet.recalculate_affected_address_balances(&used_utxos, self)?; } - // Wait for asset lock proof with timeout (2 minutes) - const ASSET_LOCK_PROOF_TIMEOUT: Duration = Duration::from_secs(120); - let asset_lock_proof = - match tokio::time::timeout(ASSET_LOCK_PROOF_TIMEOUT, async { - loop { - { - let proofs = - self.transactions_waiting_for_finality.lock().unwrap(); - if let Some(Some(proof)) = proofs.get(&tx_id) { - return proof.clone(); - } - } - tokio::time::sleep(Duration::from_millis(200)).await; - } - }) - .await - { - Ok(proof) => proof, - Err(_) => { - // Clean up on timeout - let mut proofs = - self.transactions_waiting_for_finality.lock().unwrap(); - proofs.remove(&tx_id); - return Err(format!( - "Timeout waiting for asset lock proof after {} seconds. \ - The transaction may not have been confirmed by the network.", - ASSET_LOCK_PROOF_TIMEOUT.as_secs() - )); - } - }; + let asset_lock_proof = self.wait_for_asset_lock_proof(tx_id).await?; ( asset_lock_proof, @@ -277,11 +234,8 @@ impl AppContext { proofs.insert(tx_id, None); } - self.core_client - .read() - .expect("Core client lock was poisoned") - .send_raw_transaction(&asset_lock_transaction) - .map_err(|e| e.to_string())?; + self.broadcast_raw_transaction(&asset_lock_transaction) + .await?; // Store the asset lock transaction in the database immediately after sending. // This ensures it's tracked even if the proof times out or top-up fails. @@ -306,45 +260,10 @@ impl AppContext { .drop_utxo(&utxo, &self.network.to_string()) .map_err(|e| e.to_string())?; - // Update address_balance for the affected address - let new_balance = wallet - .utxos - .get(&input_address) - .map(|utxo_map| utxo_map.values().map(|tx_out| tx_out.value).sum()) - .unwrap_or(0); - let _ = wallet.update_address_balance(&input_address, new_balance, self); + wallet.recalculate_address_balance(&input_address, self)?; } - // Wait for asset lock proof with timeout (2 minutes) - const ASSET_LOCK_PROOF_TIMEOUT: Duration = Duration::from_secs(120); - let asset_lock_proof = - match tokio::time::timeout(ASSET_LOCK_PROOF_TIMEOUT, async { - loop { - { - let proofs = - self.transactions_waiting_for_finality.lock().unwrap(); - if let Some(Some(proof)) = proofs.get(&tx_id) { - return proof.clone(); - } - } - tokio::time::sleep(Duration::from_millis(200)).await; - } - }) - .await - { - Ok(proof) => proof, - Err(_) => { - // Clean up on timeout - let mut proofs = - self.transactions_waiting_for_finality.lock().unwrap(); - proofs.remove(&tx_id); - return Err(format!( - "Timeout waiting for asset lock proof after {} seconds. \ - The transaction may not have been confirmed by the network.", - ASSET_LOCK_PROOF_TIMEOUT.as_secs() - )); - } - }; + let asset_lock_proof = self.wait_for_asset_lock_proof(tx_id).await?; ( asset_lock_proof, @@ -406,7 +325,7 @@ impl AppContext { || error_string.contains("wasn't created recently") { // Try to use chain asset lock proof instead - let tx_info = get_transaction_info_via_dapi(&sdk, &tx_id).await?; + let tx_info = get_transaction_info(&sdk, &tx_id).await?; if tx_info.is_chain_locked && tx_info.height > 0 { let tx_block_height = tx_info.height; diff --git a/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs b/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs index 5da5ff228..02983677c 100644 --- a/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs +++ b/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs @@ -46,7 +46,7 @@ impl AppContext { }; // Check if we need to convert an old instant lock proof to a chain lock proof - use crate::context::get_transaction_info_via_dapi; + use crate::context::get_transaction_info; use dash_sdk::dpp::block::extended_epoch_info::ExtendedEpochInfo; use dash_sdk::platform::Fetch; @@ -57,7 +57,7 @@ impl AppContext { let tx_id = instant_asset_lock_proof.transaction().txid(); // Query DAPI to check if the transaction has been chain-locked - let tx_info = get_transaction_info_via_dapi(&sdk, &tx_id).await?; + let tx_info = get_transaction_info(&sdk, &tx_id).await?; if tx_info.is_chain_locked && tx_info.height > 0 && tx_info.confirmations > 8 { // Transaction has been chain-locked with sufficient confirmations diff --git a/src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs b/src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs index 12edbfa43..aa67973ab 100644 --- a/src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs +++ b/src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs @@ -1,9 +1,10 @@ use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::wallet::PlatformSyncMode; use crate::context::AppContext; -use crate::model::fee_estimation::PlatformFeeEstimator; use crate::model::wallet::WalletSeedHash; +use crate::spv::CoreBackendMode; use dash_sdk::dpp::address_funds::PlatformAddress; +use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; use dash_sdk::dpp::prelude::AssetLockProof; use std::sync::Arc; use std::time::Duration; @@ -31,9 +32,12 @@ impl AppContext { // Fees deducted from output: use the requested amount, allow core fee to be taken from it (amount, true) } else { - // Fees paid from wallet: add estimated platform fee to asset lock amount - let estimated_platform_fee_duffs = - PlatformFeeEstimator::new().estimate_address_funding_from_asset_lock_duffs(1); + // Fees paid from wallet: add estimated platform fee to asset lock amount. + // We use 2 outputs: the destination (explicit amount) and a change address + // (remainder recipient that absorbs the fee). + let estimated_platform_fee_duffs = self + .fee_estimator() + .estimate_address_funding_from_asset_lock_duffs(2); let asset_lock_amount = amount.saturating_add(estimated_platform_fee_duffs); (asset_lock_amount, false) }; @@ -120,31 +124,51 @@ impl AppContext { .map_err(|e| e.to_string())?; } - // Update address_balances for affected addresses - let affected_addresses: std::collections::BTreeSet<_> = - used_utxos.values().map(|(_, addr)| addr.clone()).collect(); - for address in affected_addresses { - // Recalculate balance from remaining UTXOs for this address - let new_balance = wallet - .utxos - .get(&address) - .map(|utxo_map| utxo_map.values().map(|tx_out| tx_out.value).sum()) - .unwrap_or(0); - let _ = wallet.update_address_balance(&address, new_balance, self); - } + wallet.recalculate_affected_address_balances(&used_utxos, self)?; } - // Step 5: Wait for asset lock proof (InstantLock or ChainLock) + // Step 5: Wait for asset lock proof (InstantLock or ChainLock) with timeout let asset_lock_proof: AssetLockProof; + let timeout = tokio::time::sleep(Duration::from_secs(300)); // 5 minute timeout + tokio::pin!(timeout); + loop { - { - let proofs = self.transactions_waiting_for_finality.lock().unwrap(); - if let Some(Some(proof)) = proofs.get(&tx_id) { - asset_lock_proof = proof.clone(); - break; + tokio::select! { + _ = &mut timeout => { + // Best-effort cleanup: use try_lock to avoid blocking the + // async runtime if another thread holds the mutex. + if let Ok(mut proofs) = self.transactions_waiting_for_finality.try_lock() { + proofs.remove(&tx_id); + } + + // Auto-refresh wallet UTXOs in RPC mode so the broadcast tx's + // spent inputs are reconciled (the tx was already broadcast and + // may confirm later). SPV handles its own reconciliation. + if self.core_backend_mode() == CoreBackendMode::Rpc + && let Some(wallet_arc) = self.wallets.read().ok() + .and_then(|w| w.get(&seed_hash).cloned()) + { + let ctx = Arc::clone(self); + // Fire-and-forget — don't block the error return on refresh + tokio::task::spawn_blocking(move || { + if let Err(e) = ctx.refresh_wallet_info(wallet_arc) { + tracing::warn!("Failed to auto-refresh wallet after timeout: {}", e); + } + }); + } + + return Err("Timeout waiting for asset lock proof — no InstantLock or ChainLock received within 5 minutes".to_string()); + } + _ = tokio::time::sleep(Duration::from_millis(200)) => { + // Brief lock to check for proof — acquired and released quickly + // so contention is minimal. + let proofs = self.transactions_waiting_for_finality.lock().unwrap(); + if let Some(Some(proof)) = proofs.get(&tx_id) { + asset_lock_proof = proof.clone(); + break; + } } } - tokio::time::sleep(Duration::from_millis(200)).await; } // Step 6: Clean up the finality tracking @@ -153,8 +177,8 @@ impl AppContext { proofs.remove(&tx_id); } - // Step 7: Get wallet and SDK for the platform funding operation - let (wallet, sdk) = { + // Step 7: Get wallet, SDK, and derive a fresh change address if needed + let (wallet, sdk, change_platform_address) = { let wallet_arc = { let wallets = self.wallets.read().unwrap(); wallets @@ -162,16 +186,62 @@ impl AppContext { .cloned() .ok_or_else(|| "Wallet not found".to_string())? }; + + // Derive a fresh change address from the BIP44 internal (change) path + // while we have write access (only needed when fees are NOT deducted + // from the output). Using change_address() ensures proper BIP44 + // separation between receive and change addresses. + let change_platform_address = if !fee_deduct_from_output { + let mut wallet_w = wallet_arc.write().map_err(|e| e.to_string())?; + let addr = wallet_w.change_address(self.network, Some(self))?; + Some( + PlatformAddress::try_from(addr) + .map_err(|e| format!("Failed to convert change address: {}", e))?, + ) + } else { + None + }; + let wallet = wallet_arc.read().map_err(|e| e.to_string())?.clone(); let sdk = self.sdk.read().map_err(|e| e.to_string())?.clone(); - (wallet, sdk) + (wallet, sdk, change_platform_address) }; // Step 8: Fund the destination platform address let mut outputs = std::collections::BTreeMap::new(); - outputs.insert(destination, None); // None means use all available funds - let fee_strategy = vec![AddressFundsFeeStrategyStep::ReduceOutput(0)]; + let fee_strategy = if fee_deduct_from_output { + // Fee deducted from output: destination is the remainder recipient (gets + // asset lock value minus fee). ReduceOutput(0) tells Platform to deduct + // the fee from the single output. + outputs.insert(destination, None); + vec![AddressFundsFeeStrategyStep::ReduceOutput(0)] + } else { + // Fee NOT deducted from output: destination receives the exact requested + // amount. We use a fresh wallet-controlled change address to absorb the + // fee estimate surplus, keeping it spendable. + let amount_credits = amount.checked_mul(CREDITS_PER_DUFF).ok_or_else(|| { + format!( + "Overflow converting {amount} duffs to credits (CREDITS_PER_DUFF = {CREDITS_PER_DUFF})" + ) + })?; + + if let Some(change_address) = change_platform_address { + outputs.insert(destination, Some(amount_credits)); + outputs.insert(change_address, None); // Remainder recipient + + // Determine the BTreeMap index of the change address to target it + // with the fee strategy (BTreeMap iterates in key order). + let change_index = outputs + .keys() + .position(|k| *k == change_address) + .ok_or("Change address not found in outputs map")? + as u16; + vec![AddressFundsFeeStrategyStep::ReduceOutput(change_index)] + } else { + return Err("Failed to derive a change address for platform funding".to_string()); + } + }; outputs .top_up( diff --git a/src/backend_task/wallet/generate_receive_address.rs b/src/backend_task/wallet/generate_receive_address.rs index 90b3c3b07..f627a035b 100644 --- a/src/backend_task/wallet/generate_receive_address.rs +++ b/src/backend_task/wallet/generate_receive_address.rs @@ -35,7 +35,7 @@ impl AppContext { } else { let mut wallet = wallet_arc.write().map_err(|e| e.to_string())?; wallet - .receive_address(self.network, false, Some(self))? + .receive_address(self.network, true, Some(self))? .to_string() }; diff --git a/src/components/core_p2p_handler.rs b/src/components/core_p2p_handler.rs index 6b354598b..dab0ef3c3 100644 --- a/src/components/core_p2p_handler.rs +++ b/src/components/core_p2p_handler.rs @@ -7,7 +7,7 @@ use dash_sdk::dpp::dashcore::network::message::{NetworkMessage, RawNetworkMessag use dash_sdk::dpp::dashcore::network::message_qrinfo::QRInfo; use dash_sdk::dpp::dashcore::network::message_sml::{GetMnListDiff, MnListDiff}; use dash_sdk::dpp::dashcore::network::{Address, message_network, message_qrinfo}; -use rand::prelude::StdRng; +use rand::rngs::StdRng; use rand::{Rng, SeedableRng}; use sha2::{Digest, Sha256}; use std::io::{ErrorKind, Read, Write}; @@ -250,7 +250,7 @@ impl CoreP2PHandler { // Note: get_dml_diff and get_qr_info are already defined above (lines ~351 and ~364) /// Perform the handshake (version/verack exchange) with the peer. pub fn handshake(&mut self) -> Result<(), String> { - let mut rng = StdRng::from_entropy(); + let mut rng = StdRng::from_os_rng(); // Build a version message. let version_msg = NetworkMessage::Version(message_network::VersionMessage { @@ -267,11 +267,11 @@ impl CoreP2PHandler { address: Default::default(), port: self.stream.local_addr().map_err(|e| e.to_string())?.port(), }, - nonce: rng.r#gen(), + nonce: rng.random(), user_agent: "/dash-evo-tool:0.9/".to_string(), start_height: 0, relay: false, - mn_auth_challenge: rng.r#gen(), + mn_auth_challenge: rng.random(), masternode_connection: false, }); diff --git a/src/config.rs b/src/config.rs index 16b05c04e..d8f9fb5d2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,3 @@ -use std::fs::File; use std::io::Write; use std::str::FromStr; @@ -7,6 +6,7 @@ use dash_sdk::dapi_client::AddressList; use dash_sdk::dpp::dashcore::Network; use dash_sdk::sdk::Uri; use serde::Deserialize; +use tempfile::NamedTempFile; #[derive(Debug, Deserialize, Clone)] pub struct Config { @@ -63,13 +63,23 @@ impl Config { /// Write the current configuration back to the `.env` file so that /// subsequent calls to `Config::load()` will reflect changes. + /// + /// Uses atomic write (write to temp file, then rename) to prevent + /// config corruption if a write fails partway through. pub fn save(&self) -> Result<(), ConfigError> { let env_file_path = app_user_data_file_path(".env").map_err(|e| ConfigError::LoadError(e.to_string()))?; - // Create / truncate the `.env` file + // Write to a temporary file in the same directory first, then + // atomically replace. This prevents corruption if the write fails + // partway through. NamedTempFile::persist() closes the handle before + // renaming and uses MoveFileEx with MOVEFILE_REPLACE_EXISTING on + // Windows for atomic replacement. + let parent_dir = env_file_path.parent().ok_or_else(|| { + ConfigError::LoadError("Config file path has no parent directory".to_string()) + })?; let mut env_file = - File::create(&env_file_path).map_err(|e| ConfigError::LoadError(e.to_string()))?; + NamedTempFile::new_in(parent_dir).map_err(|e| ConfigError::LoadError(e.to_string()))?; // Helper function to write a single network config to the `.env` file let mut write_network_config = |prefix: &str, config: &NetworkConfig| { @@ -166,6 +176,19 @@ impl Config { .map_err(|e| ConfigError::LoadError(e.to_string()))?; } + // Sync all data to disk before renaming to ensure crash-safety + env_file + .as_file() + .sync_all() + .map_err(|e| ConfigError::LoadError(e.to_string()))?; + + // Atomically replace the old config with the new one. + // persist() closes the file handle and uses platform-safe rename + // (MoveFileEx with MOVEFILE_REPLACE_EXISTING on Windows). + env_file.persist(&env_file_path).map_err(|e| { + ConfigError::LoadError(format!("Failed to persist temp config file: {}", e)) + })?; + tracing::info!("Successfully saved configuration to {:?}", env_file_path); Ok(()) } diff --git a/src/context.rs b/src/context.rs deleted file mode 100644 index 414c5a759..000000000 --- a/src/context.rs +++ /dev/null @@ -1,1762 +0,0 @@ -use crate::app_dir::core_cookie_path; -use crate::backend_task::contested_names::ScheduledDPNSVote; -use crate::components::core_zmq_listener::ZMQConnectionEvent; -pub mod connection_status; -use crate::config::{Config, NetworkConfig}; -use crate::context_provider::Provider as RpcProvider; -use crate::context_provider_spv::SpvProvider; -use crate::database::Database; -use crate::model::contested_name::ContestedName; -use crate::model::fee_estimation::PlatformFeeEstimator; -use crate::model::password_info::PasswordInfo; -use crate::model::qualified_contract::QualifiedContract; -use crate::model::qualified_identity::{DPNSNameInfo, QualifiedIdentity}; -use crate::model::settings::Settings; -use crate::model::wallet::single_key::{SingleKeyHash, SingleKeyWallet}; -use crate::model::wallet::{ - AddressInfo as WalletAddressInfo, DerivationPathReference, DerivationPathType, Wallet, - WalletSeedHash, WalletTransaction, -}; -use crate::sdk_wrapper::initialize_sdk; -use crate::spv::{CoreBackendMode, SpvManager}; -use crate::ui::RootScreenType; -use crate::ui::tokens::tokens_screen::{IdentityTokenBalance, IdentityTokenIdentifier}; -use crate::utils::tasks::TaskManager; -use bincode::config; -use connection_status::ConnectionStatus; -use crossbeam_channel::{Receiver, Sender}; -use dash_sdk::Sdk; -use dash_sdk::dashcore_rpc::dashcore::{InstantLock, Transaction}; -use dash_sdk::dashcore_rpc::{Auth, Client}; -use dash_sdk::dpp::dashcore::hashes::Hash; -use dash_sdk::dpp::dashcore::transaction::special_transaction::TransactionPayload::AssetLockPayloadType; -use dash_sdk::dpp::dashcore::{Address, Network, OutPoint, TxOut, Txid}; -use dash_sdk::dpp::data_contract::TokenConfiguration; -use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; -use dash_sdk::dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; -use dash_sdk::dpp::key_wallet::Network as WalletNetwork; -use dash_sdk::dpp::key_wallet::account::AccountType; -use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, DerivationPath}; -use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::{ - ManagedWalletInfo, wallet_info_interface::WalletInfoInterface, -}; -use dash_sdk::dpp::prelude::{AssetLockProof, CoreBlockHeight}; -use dash_sdk::dpp::state_transition::StateTransitionSigningOptions; -use dash_sdk::dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; -use dash_sdk::dpp::system_data_contracts::{SystemDataContract, load_system_data_contract}; -use dash_sdk::dpp::version::PlatformVersion; -use dash_sdk::dpp::version::v11::PLATFORM_V11; -use dash_sdk::platform::{DataContract, Identifier}; -use dash_sdk::query_types::IndexMap; -use egui::Context; -use rusqlite::Result; -use std::collections::{BTreeMap, HashMap}; -use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, RwLock, RwLockWriteGuard}; - -const ANIMATION_REFRESH_TIME: std::time::Duration = std::time::Duration::from_millis(100); - -/// A guard that ensures settings cache invalidation happens atomically -/// -/// This guard holds a write lock on the cached settings, preventing reads -/// until the database update is complete and the cache is properly invalidated. -type SettingsCacheGuard<'a> = RwLockWriteGuard<'a, Option>; - -#[derive(Debug)] -pub struct AppContext { - pub(crate) network: Network, - developer_mode: AtomicBool, - #[allow(dead_code)] // May be used for devnet identification - pub(crate) devnet_name: Option, - pub(crate) db: Arc, - pub(crate) sdk: RwLock, - // Context providers for SDK, so we can switch when backend mode changes - spv_context_provider: RwLock, - rpc_context_provider: RwLock, - pub(crate) config: Arc>, - pub(crate) rx_zmq_status: Receiver, - pub(crate) sx_zmq_status: Sender, - pub(crate) dpns_contract: Arc, - pub(crate) withdraws_contract: Arc, - pub(crate) dashpay_contract: Arc, - pub(crate) token_history_contract: Arc, - pub(crate) keyword_search_contract: Arc, - pub(crate) core_client: RwLock, - pub(crate) has_wallet: AtomicBool, - pub(crate) wallets: RwLock>>>, - pub(crate) single_key_wallets: RwLock>>>, - #[allow(dead_code)] // May be used for password validation - pub(crate) password_info: Option, - pub(crate) transactions_waiting_for_finality: Mutex>>, - /// Whether to animate the UI elements. - /// - /// This is used to control animations in the UI, such as loading spinners or transitions. - /// Disable for automated tests. - animate: AtomicBool, - /// Cached settings to avoid expensive database reads - /// Use RwLock to allow multiple readers but exclusive writers for cache invalidation - cached_settings: RwLock>, - // subtasks started by the app context, used for graceful shutdown - pub(crate) subtasks: Arc, - pub(crate) spv_manager: Arc, - core_backend_mode: AtomicU8, - /// Tracks the connection status to currently active network - pub(crate) connection_status: Arc, - /// Pending wallet selection - set after creating/importing a wallet - /// so the wallet screen can auto-select the new wallet - pub(crate) pending_wallet_selection: Mutex>, - /// Currently selected HD wallet (persisted across screen navigation) - pub(crate) selected_wallet_hash: Mutex>, - /// Currently selected single key wallet (persisted across screen navigation) - pub(crate) selected_single_key_hash: Mutex>, - /// Cached fee multiplier permille from current epoch (1000 = 1x, 2000 = 2x) - /// Updated when epoch info is fetched from Platform - fee_multiplier_permille: AtomicU64, -} - -impl AppContext { - pub fn new( - network: Network, - db: Arc, - password_info: Option, - subtasks: Arc, - connection_status: Arc, - ) -> Option> { - let config = match Config::load() { - Ok(config) => config, - Err(e) => { - println!("Failed to load config: {e}"); - return None; - } - }; - - let network_config = config.config_for_network(network).clone()?; - let config_lock = Arc::new(RwLock::new(network_config.clone())); - let (sx_zmq_status, rx_zmq_status) = crossbeam_channel::unbounded(); - - // Create both providers; bind to app context later (post construction) due to circularity - let spv_provider = - SpvProvider::new(db.clone(), network).expect("Failed to initialize SPV provider"); - let rpc_provider = RpcProvider::new(db.clone(), network, &network_config) - .expect("Failed to initialize RPC provider"); - - // Default to SPV provider initially; UI can switch backend after - let sdk = initialize_sdk(&network_config, network, spv_provider.clone()); - let platform_version = sdk.version(); - - let dpns_contract = load_system_data_contract(SystemDataContract::DPNS, platform_version) - .expect("expected to load dpns contract"); - - let withdrawal_contract = - load_system_data_contract(SystemDataContract::Withdrawals, platform_version) - .expect("expected to get withdrawal contract"); - - let token_history_contract = - load_system_data_contract(SystemDataContract::TokenHistory, platform_version) - .expect("expected to get token history contract"); - - let keyword_search_contract = - load_system_data_contract(SystemDataContract::KeywordSearch, platform_version) - .expect("expected to get keyword search contract"); - - let dashpay_contract = - load_system_data_contract(SystemDataContract::Dashpay, platform_version) - .expect("expected to get dashpay contract"); - - let addr = format!( - "http://{}:{}", - network_config.core_host, network_config.core_rpc_port - ); - let cookie_path = core_cookie_path(network, &network_config.devnet_name) - .expect("expected to get cookie path"); - - // Try cookie authentication first - let core_client = match Client::new(&addr, Auth::CookieFile(cookie_path.clone())) { - Ok(client) => Ok(client), - Err(_) => { - // If cookie auth fails, try user/password authentication - tracing::info!( - "Failed to authenticate using .cookie file at {:?}, falling back to user/pass", - cookie_path, - ); - Client::new( - &addr, - Auth::UserPass( - network_config.core_rpc_user.to_string(), - network_config.core_rpc_password.to_string(), - ), - ) - } - } - .expect("Failed to create CoreClient"); - - let wallets: BTreeMap<_, _> = db - .get_wallets(&network) - .expect("expected to get wallets") - .into_iter() - .map(|w| (w.seed_hash(), Arc::new(RwLock::new(w)))) - .collect(); - - let single_key_wallets: BTreeMap<_, _> = db - .get_single_key_wallets(network) - .expect("expected to get single key wallets") - .into_iter() - .map(|w| (w.key_hash(), Arc::new(RwLock::new(w)))) - .collect(); - - let developer_mode_enabled = config.developer_mode.unwrap_or(false); - - let animate = match developer_mode_enabled { - true => { - tracing::debug!("developer_mode is enabled, disabling animations"); - AtomicBool::new(false) - } - false => AtomicBool::new(true), // Animations are enabled by default - }; - - let spv_manager = match SpvManager::new(network, Arc::clone(&config_lock), subtasks.clone()) - { - Ok(manager) => manager, - Err(err) => { - tracing::error!(?err, ?network, "Failed to initialize SPV manager"); - return None; - } - }; - - // Load the use_local_spv_node setting and apply to SPV manager - let use_local_spv_node = db.get_use_local_spv_node().unwrap_or(false); - spv_manager.set_use_local_node(use_local_spv_node); - - // Load the core backend mode from settings, defaulting to RPC if not set - let saved_core_backend_mode = db - .get_settings() - .ok() - .flatten() - .map(|s| s.7) // core_backend_mode is the 8th element (index 7) - .unwrap_or(CoreBackendMode::Rpc.as_u8()); - - // If not in developer mode, force RPC mode (SPV is gated behind dev mode) - let saved_core_backend_mode = if developer_mode_enabled { - saved_core_backend_mode - } else { - CoreBackendMode::Rpc.as_u8() - }; - - // Load saved wallet selection, validating that the wallets still exist - let (saved_wallet_hash, saved_single_key_hash) = - db.get_selected_wallet_hashes().unwrap_or((None, None)); - - // Only use the saved hash if the wallet still exists - let selected_wallet_hash = saved_wallet_hash.filter(|h| wallets.contains_key(h)); - let selected_single_key_hash = - saved_single_key_hash.filter(|h| single_key_wallets.contains_key(h)); - - let app_context = AppContext { - network, - developer_mode: AtomicBool::new(developer_mode_enabled), - devnet_name: None, - db, - sdk: sdk.into(), - spv_context_provider: spv_provider.into(), - rpc_context_provider: rpc_provider.into(), - config: config_lock, - sx_zmq_status, - rx_zmq_status, - dpns_contract: Arc::new(dpns_contract), - withdraws_contract: Arc::new(withdrawal_contract), - dashpay_contract: Arc::new(dashpay_contract), - token_history_contract: Arc::new(token_history_contract), - keyword_search_contract: Arc::new(keyword_search_contract), - core_client: core_client.into(), - has_wallet: (!wallets.is_empty() || !single_key_wallets.is_empty()).into(), - wallets: RwLock::new(wallets), - single_key_wallets: RwLock::new(single_key_wallets), - password_info, - transactions_waiting_for_finality: Mutex::new(BTreeMap::new()), - animate, - cached_settings: RwLock::new(None), - subtasks, - spv_manager, - core_backend_mode: AtomicU8::new(saved_core_backend_mode), - connection_status, - pending_wallet_selection: Mutex::new(None), - selected_wallet_hash: Mutex::new(selected_wallet_hash), - selected_single_key_hash: Mutex::new(selected_single_key_hash), - fee_multiplier_permille: AtomicU64::new( - PlatformFeeEstimator::DEFAULT_FEE_MULTIPLIER_PERMILLE, - ), - }; - - let app_context = Arc::new(app_context); - // Bind providers to the newly created app_context. - // Only the active provider is registered with the SDK here (SPV by default). - if let Err(e) = app_context - .spv_context_provider - .read() - .map_err(|_| "SPV provider lock poisoned".to_string()) - .and_then(|provider| provider.bind_app_context(app_context.clone())) - { - tracing::error!("Failed to bind SPV provider: {}", e); - return None; - } - - // If defaulting to RPC is desired, swap provider after binding. - if app_context.core_backend_mode() == CoreBackendMode::Rpc { - if let Err(e) = app_context - .rpc_context_provider - .read() - .map_err(|_| "RPC provider lock poisoned".to_string()) - .and_then(|provider| provider.bind_app_context(app_context.clone())) - { - tracing::error!("Failed to bind RPC provider: {}", e); - return None; - } - } else { - // Ensure SDK uses the SPV provider - let sdk_lock = match app_context.sdk.write() { - Ok(lock) => lock, - Err(_) => { - tracing::error!("SDK lock poisoned"); - return None; - } - }; - let provider = match app_context.spv_context_provider.read() { - Ok(p) => p.clone(), - Err(_) => { - tracing::error!("SPV provider lock poisoned"); - return None; - } - }; - sdk_lock.set_context_provider(provider); - } - - app_context.bootstrap_loaded_wallets(); - - Some(app_context) - } - - /// Enables animations in the UI. - /// - /// This is used to control whether UI elements should animate, such as loading spinners or transitions. - pub fn enable_animations(&self, animate: bool) { - self.animate.store(animate, Ordering::Relaxed); - } - - pub fn enable_developer_mode(&self, enable: bool) { - self.developer_mode.store(enable, Ordering::Relaxed); - // Animations are reverse of developer mode - self.enable_animations(!enable); - } - - pub fn core_backend_mode(&self) -> CoreBackendMode { - self.core_backend_mode.load(Ordering::Relaxed).into() - } - - pub fn connection_status(&self) -> &ConnectionStatus { - &self.connection_status - } - - pub fn set_core_backend_mode(self: &Arc, mode: CoreBackendMode) { - self.core_backend_mode - .store(mode.as_u8(), Ordering::Relaxed); - - // Persist the mode to the database (hold the guard to ensure cache invalidation) - let _guard = self.invalidate_settings_cache(); - if let Err(e) = self.db.update_core_backend_mode(mode.as_u8()) { - tracing::error!("Failed to persist core backend mode: {}", e); - } - - // Switch SDK context provider to match the selected backend - match mode { - CoreBackendMode::Spv => { - // Make sure SPV provider knows about the app context - if let Err(e) = self - .spv_context_provider - .read() - .map_err(|_| "SPV provider lock poisoned".to_string()) - .and_then(|provider| provider.bind_app_context(Arc::clone(self))) - { - tracing::error!("Failed to bind SPV provider: {}", e); - return; - } - let sdk = match self.sdk.write() { - Ok(lock) => lock, - Err(_) => { - tracing::error!("SDK lock poisoned in set_core_backend_mode"); - return; - } - }; - let provider = match self.spv_context_provider.read() { - Ok(p) => p.clone(), - Err(_) => { - tracing::error!("SPV provider lock poisoned"); - return; - } - }; - sdk.set_context_provider(provider); - } - CoreBackendMode::Rpc => { - // RPC provider binding also sets itself on the SDK - if let Err(e) = self - .rpc_context_provider - .read() - .map_err(|_| "RPC provider lock poisoned".to_string()) - .and_then(|provider| provider.bind_app_context(Arc::clone(self))) - { - tracing::error!("Failed to bind RPC provider: {}", e); - } - } - } - } - - /// Get the cached fee multiplier permille (1000 = 1x, 2000 = 2x) - pub fn fee_multiplier_permille(&self) -> u64 { - self.fee_multiplier_permille.load(Ordering::Relaxed) - } - - /// Update the cached fee multiplier from epoch info - pub fn set_fee_multiplier_permille(&self, multiplier: u64) { - self.fee_multiplier_permille - .store(multiplier, Ordering::Relaxed); - } - - /// Get a fee estimator configured with the cached fee multiplier. - /// Use this instead of `PlatformFeeEstimator::new()` to get accurate fee estimates - /// that reflect the current network fee multiplier. - pub fn fee_estimator(&self) -> PlatformFeeEstimator { - PlatformFeeEstimator::with_fee_multiplier(self.fee_multiplier_permille()) - } - - pub fn spv_manager(&self) -> &Arc { - &self.spv_manager - } - - pub fn clear_spv_data(&self) -> Result<(), String> { - self.spv_manager.clear_data_dir() - } - - pub fn clear_network_database(&self) -> Result<(), String> { - self.db - .clear_network_data(self.network) - .map_err(|e| e.to_string())?; - - if let Ok(mut wallets) = self.wallets.write() { - wallets.clear(); - } - - if let Ok(mut single_key_wallets) = self.single_key_wallets.write() { - single_key_wallets.clear(); - } - - self.has_wallet.store(false, Ordering::Relaxed); - - Ok(()) - } - - pub fn start_spv(self: &Arc) -> Result<(), String> { - self.spv_manager.start()?; - self.spv_setup_reconcile_listener(); - Ok(()) - } - - 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); - } - } - - pub fn handle_wallet_unlocked(self: &Arc, wallet: &Arc>) { - if let Some((seed_hash, seed_bytes)) = Self::wallet_seed_snapshot(wallet) { - self.queue_spv_wallet_load(seed_hash, seed_bytes); - // Note: Platform address sync and Core UTXO refresh are NOT done automatically on unlock. - // User must explicitly click Refresh to update balances. - } - } - - pub fn handle_wallet_locked(self: &Arc, wallet: &Arc>) { - let seed_hash = match wallet.read() { - Ok(guard) => guard.seed_hash(), - Err(err) => { - tracing::warn!(error = %err, "Unable to read wallet during lock handling"); - return; - } - }; - self.queue_spv_wallet_unload(seed_hash); - } - - fn wallet_seed_snapshot(wallet: &Arc>) -> Option<(WalletSeedHash, [u8; 64])> { - let guard = wallet.read().ok()?; - if !guard.is_open() { - return None; - } - let seed_bytes = match guard.seed_bytes() { - Ok(bytes) => *bytes, - Err(err) => { - tracing::warn!(error = %err, wallet = %hex::encode(guard.seed_hash()), "Unable to snapshot wallet seed for SPV load"); - return None; - } - }; - Some((guard.seed_hash(), seed_bytes)) - } - - fn queue_spv_wallet_load(self: &Arc, seed_hash: WalletSeedHash, seed_bytes: [u8; 64]) { - let spv = Arc::clone(&self.spv_manager); - self.subtasks.spawn_sync(async move { - if let Err(error) = spv.load_wallet_from_seed(seed_hash, seed_bytes).await { - tracing::error!(seed = %hex::encode(seed_hash), %error, "Failed to load SPV wallet from seed"); - } - }); - } - - fn queue_spv_wallet_unload(self: &Arc, seed_hash: WalletSeedHash) { - let spv = Arc::clone(&self.spv_manager); - self.subtasks.spawn_sync(async move { - if let Err(error) = spv.unload_wallet(seed_hash).await { - tracing::error!(seed = %hex::encode(seed_hash), %error, "Failed to unload SPV wallet"); - } - }); - } - - /// Queue automatic discovery of identities derived from a wallet. - /// Checks identity indices 0 through max_identity_index for existing identities on the network. - pub fn queue_wallet_identity_discovery( - self: &Arc, - wallet: &Arc>, - max_identity_index: u32, - ) { - let ctx = Arc::clone(self); - let wallet_clone = Arc::clone(wallet); - self.subtasks.spawn_sync(async move { - if let Err(error) = ctx - .discover_identities_from_wallet(&wallet_clone, max_identity_index) - .await - { - tracing::warn!( - %error, - "Failed to discover identities from wallet" - ); - } - }); - } - - pub fn bootstrap_loaded_wallets(self: &Arc) { - let wallets: Vec<_> = { - let guard = self.wallets.read().unwrap(); - guard.values().cloned().collect() - }; - - for wallet in wallets { - self.bootstrap_wallet_addresses(&wallet); - self.handle_wallet_unlocked(&wallet); - } - } - - /// Update wallet platform address info from SDK-returned AddressInfos. - /// This uses the proof-verified data from SDK operations rather than fetching. - pub(crate) fn update_wallet_platform_address_info_from_sdk( - &self, - seed_hash: WalletSeedHash, - address_infos: &dash_sdk::query_types::AddressInfos, - ) -> Result<(), String> { - let wallet_arc = { - let wallets = self.wallets.read().unwrap(); - wallets - .get(&seed_hash) - .cloned() - .ok_or_else(|| "Wallet not found".to_string())? - }; - - let mut wallet = wallet_arc.write().map_err(|e| e.to_string())?; - - for (platform_addr, maybe_info) in address_infos.iter() { - if let Some(info) = maybe_info { - // Convert PlatformAddress to core Address using the network - let core_addr = platform_addr.to_address_with_network(self.network); - - // Update in-memory wallet state - wallet.set_platform_address_info(core_addr.clone(), info.balance, info.nonce); - - // Update database (not a sync operation - preserve last_full_sync_balance - // so the next terminal sync can correctly apply any pending AddToCredits) - if let Err(e) = self.db.set_platform_address_info( - &seed_hash, - &core_addr, - info.balance, - info.nonce, - &self.network, - false, // Not a sync operation - ) { - tracing::warn!("Failed to store Platform address info in database: {}", e); - } - - tracing::debug!( - "Updated platform address {} balance={} nonce={} from SDK response", - core_addr, - info.balance, - info.nonce - ); - } - } - - Ok(()) - } - - pub(crate) fn register_spv_address( - &self, - wallet: &Arc>, - address: Address, - derivation_path: DerivationPath, - path_type: DerivationPathType, - path_reference: DerivationPathReference, - ) -> Result { - let mut guard = wallet.write().map_err(|e| e.to_string())?; - if guard.known_addresses.contains_key(&address) { - return Ok(false); - } - - let (path_reference, path_type) = - self.classify_derivation_metadata(&derivation_path, path_reference, path_type); - - let seed_hash = guard.seed_hash(); - - self.db - .add_address_if_not_exists( - &seed_hash, - &address, - &self.network, - &derivation_path, - path_reference, - path_type, - None, - ) - .map_err(|e| e.to_string())?; - - guard - .known_addresses - .insert(address.clone(), derivation_path.clone()); - guard.watched_addresses.insert( - derivation_path, - WalletAddressInfo { - address, - path_type, - path_reference, - }, - ); - - Ok(true) - } - - pub(crate) fn wallet_network_key(&self) -> WalletNetwork { - match self.network { - Network::Dash => WalletNetwork::Dash, - Network::Testnet => WalletNetwork::Testnet, - Network::Devnet => WalletNetwork::Devnet, - Network::Regtest => WalletNetwork::Regtest, - _ => WalletNetwork::Dash, - } - } - - fn sync_spv_account_addresses( - &self, - wallet_info: &ManagedWalletInfo, - wallet_arc: &Arc>, - ) { - let collection = wallet_info.accounts(); - - let mut inserted = 0u32; - for account in collection.all_accounts() { - let account_type = account.account_type.to_account_type(); - if matches!(account_type, AccountType::Standard { .. }) { - continue; - } - let Some((path_reference, path_type)) = Self::spv_account_metadata(&account_type) - else { - continue; - }; - - for address in account.account_type.all_addresses() { - if let Some(info) = account.get_address_info(&address) - && let Ok(true) = self.register_spv_address( - wallet_arc, - address.clone(), - info.path.clone(), - path_type, - path_reference, - ) - { - inserted += 1; - } - } - } - - if inserted > 0 { - tracing::debug!(added = inserted, "Registered SPV-managed addresses"); - } - } - - fn spv_account_metadata( - account_type: &AccountType, - ) -> Option<(DerivationPathReference, DerivationPathType)> { - match account_type { - AccountType::IdentityRegistration => Some(( - DerivationPathReference::BlockchainIdentityCreditRegistrationFunding, - DerivationPathType::CREDIT_FUNDING, - )), - AccountType::IdentityInvitation => Some(( - DerivationPathReference::BlockchainIdentityCreditInvitationFunding, - DerivationPathType::CREDIT_FUNDING, - )), - AccountType::IdentityTopUp { .. } | AccountType::IdentityTopUpNotBoundToIdentity => { - Some(( - DerivationPathReference::BlockchainIdentityCreditTopupFunding, - DerivationPathType::CREDIT_FUNDING, - )) - } - AccountType::Standard { .. } => Some(( - DerivationPathReference::BIP44, - DerivationPathType::CLEAR_FUNDS, - )), - _ => None, - } - } - - fn classify_derivation_metadata( - &self, - derivation_path: &DerivationPath, - default_ref: DerivationPathReference, - default_type: DerivationPathType, - ) -> (DerivationPathReference, DerivationPathType) { - let components = derivation_path.as_ref(); - if components.len() >= 5 - && matches!(components[0], ChildNumber::Hardened { index: 9 }) - && matches!(components[2], ChildNumber::Hardened { index: 5 }) - && matches!(components[3], ChildNumber::Hardened { .. }) - { - let hardened_leaf = matches!(components.last(), Some(ChildNumber::Hardened { .. })); - if !hardened_leaf { - return ( - DerivationPathReference::BlockchainIdentities, - DerivationPathType::SINGLE_USER_AUTHENTICATION, - ); - } - } - - (default_ref, default_type) - } - - /// Subscribe to SPV reconcile signals and debounce updates. - pub fn spv_setup_reconcile_listener(self: &Arc) { - use tokio::time::{Duration, Instant, sleep}; - let rx = self.spv_manager.register_reconcile_channel(); - let ctx = Arc::clone(self); - self.subtasks.spawn_sync(async move { - tokio::pin!(rx); - let mut last = Instant::now(); - loop { - tokio::select! { - maybe = rx.recv() => { - if maybe.is_none() { break; } - // simple debounce window - if last.elapsed() > Duration::from_millis(300) { - if let Err(e) = ctx.reconcile_spv_wallets().await { tracing::debug!("SPV reconcile error: {}", e); } - last = Instant::now(); - } else { - sleep(Duration::from_millis(300)).await; - if let Err(e) = ctx.reconcile_spv_wallets().await { tracing::debug!("SPV reconcile error: {}", e); } - last = Instant::now(); - } - } - } - } - }); - } - - /// Reconcile SPV wallet state into DET. - pub async fn reconcile_spv_wallets(&self) -> Result<(), String> { - let wm_arc = self.spv_manager.wallet(); - let wm = wm_arc.read().await; - let mapping = self.spv_manager.det_wallets_snapshot(); - - // Take a snapshot of known addresses per wallet so we can scope DB updates - let wallets_guard = self.wallets.read().unwrap(); - - for (seed_hash, wallet_id) in mapping.iter() { - // Log total balance for visibility - let balance = wm - .get_wallet_balance(wallet_id) - .map_err(|e| format!("get_wallet_balance failed: {e}"))?; - tracing::debug!(wallet = %hex::encode(seed_hash), spendable = balance.spendable(), unconfirmed = balance.unconfirmed, total = balance.total, "SPV balance snapshot"); - - let Some(wallet_info) = wm.get_wallet_info(wallet_id) else { - continue; - }; - - let Some(wallet_arc) = wallets_guard.get(seed_hash).cloned() else { - continue; - }; - - self.sync_spv_account_addresses(wallet_info, &wallet_arc); - - if let Ok(mut wallet) = wallet_arc.write() { - wallet.update_spv_balances(balance.spendable(), balance.unconfirmed, balance.total); - // Persist balances to database - if let Err(e) = self.db.update_wallet_balances( - seed_hash, - balance.spendable(), - balance.unconfirmed, - balance.total, - ) { - tracing::warn!(wallet = %hex::encode(seed_hash), error = %e, "Failed to persist wallet balances"); - } - } - - // Get the wallet's known addresses (only update those to avoid cross-wallet churn) - let mut known_addresses: std::collections::BTreeSet = { - let w = wallet_arc.read().unwrap(); - w.known_addresses.keys().cloned().collect() - }; - - // Clear existing UTXOs for these addresses in this network - for addr in &known_addresses { - let _ = self.db.execute( - "DELETE FROM utxos WHERE address = ? AND network = ?", - rusqlite::params![addr.to_string(), self.network.to_string()], - ); - } - - // Read current UTXOs from SPV and re-insert, registering unknown addresses if derivation metadata is available - let utxos = wm - .wallet_utxos(wallet_id) - .map_err(|e| format!("wallet_utxos failed: {e}"))?; - - use dash_sdk::dpp::dashcore::Address as CoreAddress; - // no-op - - let mut per_address_sum: std::collections::BTreeMap = - Default::default(); - - for u in utxos { - // Best-effort accessors for outpoint/txout; adjust if API differs - // Try field access (common struct layout): `outpoint` + `txout` - let outpoint = u.outpoint; - let tx_out = u.txout.clone(); - - // Derive address from script - let address = match CoreAddress::from_script(&tx_out.script_pubkey, self.network) { - Ok(a) => a, - Err(_) => continue, - }; - - // If address unknown to DET, try to register using SPV metadata - if !known_addresses.contains(&address) { - let collection = wallet_info.accounts(); - let mut registered = false; - for acc in collection.all_accounts() { - if let Some(ai) = acc.get_address_info(&address) { - let account_type = acc.account_type.to_account_type(); - let (path_reference, path_type) = - Self::spv_account_metadata(&account_type).unwrap_or(( - DerivationPathReference::BIP44, - DerivationPathType::CLEAR_FUNDS, - )); - - if let Ok(inserted) = self.register_spv_address( - &wallet_arc, - address.clone(), - ai.path.clone(), - path_type, - path_reference, - ) { - if inserted { - known_addresses.insert(address.clone()); - } - registered = true; - } - break; - } - } - if !registered { - continue; - } - } - - // Insert UTXO row - self.db - .insert_utxo( - outpoint.txid.as_ref(), - outpoint.vout, - &address, - tx_out.value, - &tx_out.script_pubkey.to_bytes(), - self.network, - ) - .map_err(|e| e.to_string())?; - - // Sum per address for balance update - *per_address_sum.entry(address).or_default() += tx_out.value; - } - - // Write per-address balances into DB and wallet model - if let Some(wref) = wallets_guard.get(seed_hash) - && let Ok(mut w) = wref.write() - { - for (addr, sum) in per_address_sum.into_iter() { - // Update wallet and DB through model helper - let _ = w.update_address_balance(&addr, sum, self); - } - } - - let history = wm - .wallet_transaction_history(wallet_id) - .map_err(|e| format!("wallet_transaction_history failed: {e}"))?; - let wallet_transactions: Vec = history - .into_iter() - .map(|record| WalletTransaction { - txid: record.txid, - transaction: record.transaction.clone(), - timestamp: record.timestamp, - height: record.height, - block_hash: record.block_hash, - net_amount: record.net_amount, - fee: record.fee, - label: record.label.clone(), - is_ours: record.is_ours, - }) - .collect(); - - self.db - .replace_wallet_transactions(seed_hash, &self.network, &wallet_transactions) - .map_err(|e| e.to_string())?; - - if let Some(wref) = wallets_guard.get(seed_hash) - && let Ok(mut wallet) = wref.write() - { - wallet.set_transactions(wallet_transactions.clone()); - } - } - - Ok(()) - } - - pub fn stop_spv(&self) { - self.spv_manager.stop(); - } - - pub fn is_developer_mode(&self) -> bool { - self.developer_mode.load(Ordering::Relaxed) - } - - /// Repaints the UI if animations are enabled. - /// - /// Called by UI elements that need to trigger a repaint, such as loading spinners or animated icons. - pub(super) fn repaint_animation(&self, ctx: &Context) { - if self.animate.load(Ordering::Relaxed) { - // Request a repaint after a short delay to allow for animations - ctx.request_repaint_after(ANIMATION_REFRESH_TIME); - } - } - - pub fn platform_version(&self) -> &'static PlatformVersion { - default_platform_version(&self.network) - } - - pub fn state_transition_options(&self) -> Option { - if self.is_developer_mode() { - Some(StateTransitionCreationOptions { - signing_options: StateTransitionSigningOptions { - allow_signing_with_any_security_level: true, - allow_signing_with_any_purpose: true, - }, - batch_feature_version: None, - method_feature_version: None, - base_feature_version: None, - }) - } else { - None - } - } - - /// Rebuild both the Dash RPC `core_client` and the `Sdk` using the - /// updated `NetworkConfig` from `self.config`. - pub fn reinit_core_client_and_sdk(self: Arc) -> Result<(), String> { - // 1. Grab a fresh snapshot of your NetworkConfig - let cfg = { - let cfg_lock = self - .config - .read() - .map_err(|_| "Config lock poisoned".to_string())?; - cfg_lock.clone() - }; - - // Note: developer_mode is now global and managed separately - - // 2. Rebuild the RPC client with the new password - let addr = format!("http://{}:{}", cfg.core_host, cfg.core_rpc_port); - let new_client = Client::new( - &addr, - Auth::UserPass(cfg.core_rpc_user.clone(), cfg.core_rpc_password.clone()), - ) - .map_err(|e| format!("Failed to create new Core RPC client: {e}"))?; - - // 3. Rebuild the Sdk with the updated config and current backend mode - let new_sdk = match self.core_backend_mode() { - CoreBackendMode::Spv => { - // Reuse existing SPV provider (rebinding below to ensure context is set) - let provider = self - .spv_context_provider - .read() - .map_err(|_| "SPV provider lock poisoned".to_string())? - .clone(); - initialize_sdk(&cfg, self.network, provider) - } - CoreBackendMode::Rpc => { - // Create a fresh RPC provider with the new config - let rpc_provider = RpcProvider::new(self.db.clone(), self.network, &cfg) - .map_err(|e| format!("Failed to init RPC provider: {e}"))?; - // Swap in the updated RPC provider for future switches - { - let mut guard = self - .rpc_context_provider - .write() - .map_err(|_| "RPC provider lock poisoned".to_string())?; - *guard = rpc_provider.clone(); - } - initialize_sdk(&cfg, self.network, rpc_provider) - } - }; - - // 4. Swap them in - { - let mut client_lock = self - .core_client - .write() - .map_err(|_| "Core client lock poisoned".to_string())?; - *client_lock = new_client; - } - { - let mut sdk_lock = self - .sdk - .write() - .map_err(|_| "SDK lock poisoned".to_string())?; - *sdk_lock = new_sdk; - } - - // Rebind providers to ensure they hold the new AppContext reference - self.spv_context_provider - .read() - .map_err(|_| "SPV provider lock poisoned".to_string())? - .bind_app_context(self.clone())?; - if self.core_backend_mode() == CoreBackendMode::Rpc { - self.rpc_context_provider - .read() - .map_err(|_| "RPC provider lock poisoned".to_string())? - .bind_app_context(self.clone())?; - } else { - let sdk_lock = self - .sdk - .write() - .map_err(|_| "SDK lock poisoned".to_string())?; - let provider = self - .spv_context_provider - .read() - .map_err(|_| "SPV provider lock poisoned".to_string())? - .clone(); - sdk_lock.set_context_provider(provider); - } - - Ok(()) - } - - /// Inserts a local qualified identity into the database - pub fn insert_local_qualified_identity( - &self, - qualified_identity: &QualifiedIdentity, - wallet_and_identity_id_info: &Option<(WalletSeedHash, u32)>, - ) -> Result<()> { - self.db.insert_local_qualified_identity( - qualified_identity, - wallet_and_identity_id_info, - self, - ) - } - - /// Updates a local qualified identity in the database - pub fn update_local_qualified_identity( - &self, - qualified_identity: &QualifiedIdentity, - ) -> Result<()> { - self.db - .update_local_qualified_identity(qualified_identity, self) - } - - /// Sets the alias for an identity - pub fn set_identity_alias( - &self, - identifier: &Identifier, - new_alias: Option<&str>, - ) -> Result<()> { - self.db.set_identity_alias(identifier, new_alias) - } - - pub fn set_contract_alias( - &self, - contract_id: &Identifier, - new_alias: Option<&str>, - ) -> Result<()> { - self.db.set_contract_alias(contract_id, new_alias) - } - - /// Gets the alias for an identity - pub fn get_identity_alias(&self, identifier: &Identifier) -> Result> { - self.db.get_identity_alias(identifier) - } - - /// Fetches all local qualified identities from the database - pub fn load_local_qualified_identities(&self) -> Result> { - let wallets = self.wallets.read().unwrap(); - self.db.get_local_qualified_identities(self, &wallets) - } - - /// Fetches all local qualified identities from the database - #[allow(dead_code)] // May be used for loading identities in wallets - pub fn load_local_qualified_identities_in_wallets(&self) -> Result> { - let wallets = self.wallets.read().unwrap(); - self.db - .get_local_qualified_identities_in_wallets(self, &wallets) - } - - pub fn get_identity_by_id( - &self, - identity_id: &Identifier, - ) -> Result> { - let wallets = self.wallets.read().unwrap(); - // Get the identity from the database - let result = self.db.get_identity_by_id(identity_id, self, &wallets)?; - - Ok(result) - } - - /// Fetches all voting identities from the database - pub fn load_local_voting_identities(&self) -> Result> { - self.db.get_local_voting_identities(self) - } - - /// Fetches all local user identities from the database - pub fn load_local_user_identities(&self) -> Result> { - let identities = self.db.get_local_user_identities(self)?; - - Ok(identities - .into_iter() - .map(|(mut identity, wallet_hash)| { - if let Some(wallet_id) = wallet_hash { - // Load wallets for each identity - self.load_wallet_for_identity( - &mut identity, - &[wallet_id], - ) - .unwrap_or_else(|e| { - tracing::warn!( - identity = %identity.identity.id(), - error = ?e, - "cannot load wallet for identity when loading local user identities", - ) - }) - } else { - tracing::debug!( - identity = %identity.identity.id(), - "no wallet hash found for identity when loading local user identities", - ); - } - identity - }) - .collect()) - } - - fn load_wallet_for_identity( - &self, - identity: &mut QualifiedIdentity, - wallet_hashes: &[WalletSeedHash], - ) -> Result<()> { - let wallets = self.wallets.read().unwrap(); - for wallet_hash in wallet_hashes { - if let Some(wallet) = wallets.get(wallet_hash) { - identity - .associated_wallets - .insert(*wallet_hash, wallet.clone()); - } else { - tracing::warn!( - wallet = %hex::encode(wallet_hash), - identity = %identity.identity.id(), - "wallet not found for identity when loading local user identities", - ); - } - } - - Ok(()) - } - - /// Fetches all contested names from the database including past and active ones - pub fn all_contested_names(&self) -> Result> { - self.db.get_all_contested_names(self) - } - - /// Fetches all ongoing contested names from the database - pub fn ongoing_contested_names(&self) -> Result> { - self.db.get_ongoing_contested_names(self) - } - - /// Inserts scheduled votes into the database - pub fn insert_scheduled_votes(&self, scheduled_votes: &Vec) -> Result<()> { - self.db.insert_scheduled_votes(self, scheduled_votes) - } - - /// Fetches all scheduled votes from the database - pub fn get_scheduled_votes(&self) -> Result> { - self.db.get_scheduled_votes(self) - } - - /// Clears all scheduled votes from the database - pub fn clear_all_scheduled_votes(&self) -> Result<()> { - self.db.clear_all_scheduled_votes(self) - } - - /// Clears all executed scheduled votes from the database - pub fn clear_executed_scheduled_votes(&self) -> Result<()> { - self.db.clear_executed_scheduled_votes(self) - } - - /// Deletes a scheduled vote from the database - #[allow(clippy::ptr_arg)] - pub fn delete_scheduled_vote(&self, identity_id: &[u8], contested_name: &String) -> Result<()> { - self.db - .delete_scheduled_vote(self, identity_id, contested_name) - } - - /// Marks a scheduled vote as executed in the database - pub fn mark_vote_executed(&self, identity_id: &[u8], contested_name: String) -> Result<()> { - self.db - .mark_vote_executed(self, identity_id, contested_name) - } - - /// Fetches the local identities from the database and then maps them to their DPNS names. - pub fn local_dpns_names(&self) -> Result> { - let wallets = self.wallets.read().unwrap(); - let qualified_identities = self.db.get_local_qualified_identities(self, &wallets)?; - - // Map each identity's DPNS names to (Identifier, DPNSNameInfo) tuples - let dpns_names = qualified_identities - .iter() - .flat_map(|qualified_identity| { - qualified_identity.dpns_names.iter().map(|dpns_name_info| { - ( - qualified_identity.identity.id(), - DPNSNameInfo { - name: dpns_name_info.name.clone(), - acquired_at: dpns_name_info.acquired_at, - }, - ) - }) - }) - .collect::>(); - - Ok(dpns_names) - } - - /// Updates the `start_root_screen` in the settings table - pub fn update_settings(&self, root_screen_type: RootScreenType) -> Result<()> { - let _guard = self.invalidate_settings_cache(); - - self.db - .insert_or_update_settings(self.network, root_screen_type) - } - - /// Updates the main password settings - pub fn update_main_password( - &self, - salt: &[u8], - nonce: &[u8], - password_check: &[u8], - ) -> Result<()> { - let _guard = self.invalidate_settings_cache(); - - self.db.update_main_password(salt, nonce, password_check) - } - - /// Updates the Dash Core execution settings - pub fn update_dash_core_execution_settings( - &self, - custom_dash_qt_path: Option, - overwrite_dash_conf: bool, - ) -> Result<()> { - let _guard = self.invalidate_settings_cache(); - - self.db - .update_dash_core_execution_settings(custom_dash_qt_path, overwrite_dash_conf) - } - - /// Updates the disable_zmq flag in settings - pub fn update_disable_zmq(&self, disable: bool) -> Result<()> { - let _guard = self.invalidate_settings_cache(); - self.db.update_disable_zmq(disable) - } - - /// Invalidates the settings cache and returns a guard - /// - /// The cache is invalidated immediately and the guard prevents concurrent access - /// until the database operation is complete. This ensures atomicity and prevents - /// race conditions regardless of whether the database operation succeeds or fails. - pub fn invalidate_settings_cache(&'_ self) -> SettingsCacheGuard<'_> { - let mut guard = self.cached_settings.write().unwrap(); - *guard = None; - guard - } - - /// Retrieves the current settings - /// - /// ## Cached - /// - /// This function uses a cache to avoid expensive database operations. - /// The cache is invalidated when settings are updated. - /// - /// Use [`AppContext::invalidate_settings_cache`] to invalidate the cache. - pub fn get_settings(&self) -> Result> { - // First, try to read from cache - { - let cache = self.cached_settings.read().unwrap(); - if let Some(ref settings) = *cache { - return Ok(Some(settings.clone())); - } - } - - // Cache miss, read from database - let settings = self.db.get_settings()?.map(Settings::from); - - // Update cache with the fresh data - { - let mut cache = self.cached_settings.write().unwrap(); - *cache = settings.clone(); - } - - Ok(settings) - } - - /// Retrieves all contracts from the database plus the system contracts from app context. - pub fn get_contracts( - &self, - limit: Option, - offset: Option, - ) -> Result> { - // Get contracts from the database - let mut contracts = self.db.get_contracts(self, limit, offset)?; - - // Add the DPNS contract to the list - let dpns_contract = QualifiedContract { - contract: Arc::clone(&self.dpns_contract).as_ref().clone(), - alias: Some("dpns".to_string()), - }; - - // Insert the DPNS contract at 0 - contracts.insert(0, dpns_contract); - - // Add the token history contract to the list - let token_history_contract = QualifiedContract { - contract: Arc::clone(&self.token_history_contract).as_ref().clone(), - alias: Some("token_history".to_string()), - }; - - // Insert the token history contract at 1 - contracts.insert(1, token_history_contract); - - // Add the withdrawal contract to the list - let withdraws_contract = QualifiedContract { - contract: Arc::clone(&self.withdraws_contract).as_ref().clone(), - alias: Some("withdrawals".to_string()), - }; - - // Insert the withdrawal contract at 2 - contracts.insert(2, withdraws_contract); - - // Add the keyword search contract to the list - let keyword_search_contract = QualifiedContract { - contract: Arc::clone(&self.keyword_search_contract).as_ref().clone(), - alias: Some("keyword_search".to_string()), - }; - - // Insert the keyword search contract at 3 - contracts.insert(3, keyword_search_contract); - - // Add the DashPay contract to the list - let dashpay_contract = QualifiedContract { - contract: Arc::clone(&self.dashpay_contract).as_ref().clone(), - alias: Some("dashpay".to_string()), - }; - - // Insert the DashPay contract at 4 - contracts.insert(4, dashpay_contract); - - Ok(contracts) - } - - pub fn get_contract_by_id( - &self, - contract_id: &Identifier, - ) -> Result> { - // Get the contract from the database - self.db.get_contract_by_id(*contract_id, self) - } - - pub fn get_unqualified_contract_by_id( - &self, - contract_id: &Identifier, - ) -> Result> { - // Get the contract from the database - self.db.get_unqualified_contract_by_id(*contract_id, self) - } - - // Remove contract from the database by ID - pub fn remove_contract(&self, contract_id: &Identifier) -> Result<()> { - self.db.remove_contract(contract_id.as_bytes(), self) - } - - pub fn replace_contract( - &self, - contract_id: Identifier, - new_contract: &DataContract, - ) -> Result<()> { - self.db.replace_contract(contract_id, new_contract, self) - } - - pub(crate) fn received_transaction_finality( - &self, - tx: &Transaction, - islock: Option, - chain_locked_height: Option, - ) -> Result> { - // Initialize a vector to collect wallet outpoints - let mut wallet_outpoints = Vec::new(); - - // Identify the wallets associated with the transaction - let wallets = self.wallets.read().unwrap(); - for wallet_arc in wallets.values() { - let mut wallet = wallet_arc.write().unwrap(); - for (vout, tx_out) in tx.output.iter().enumerate() { - let address = if let Ok(output_addr) = - Address::from_script(&tx_out.script_pubkey, self.network) - { - if wallet.known_addresses.contains_key(&output_addr) { - output_addr - } else { - continue; - } - } else { - continue; - }; - self.db.insert_utxo( - tx.txid().as_byte_array(), - vout as u32, - &address, - tx_out.value, - &tx_out.script_pubkey.to_bytes(), - self.network, - )?; - self.db - .add_to_address_balance(&wallet.seed_hash(), &address, tx_out.value)?; - - // Create the OutPoint and insert it into the wallet.utxos entry - let out_point = OutPoint::new(tx.txid(), vout as u32); - wallet - .utxos - .entry(address.clone()) - .or_insert_with(HashMap::new) // Initialize inner HashMap if needed - .insert(out_point, tx_out.clone()); // Insert the TxOut at the OutPoint - - // Collect the outpoint - wallet_outpoints.push((out_point, tx_out.clone(), address.clone())); - - wallet - .address_balances - .entry(address.clone()) - .and_modify(|balance| *balance += tx_out.value) - .or_insert(tx_out.value); - - // Check if this is a DashPay contact payment - if let Ok(Some((owner_id, contact_id, address_index))) = - self.db.get_dashpay_address_mapping(&address) - { - // Update the highest receive index if needed - if let Ok(indices) = self.db.get_contact_address_indices(&owner_id, &contact_id) - && address_index >= indices.highest_receive_index - { - let _ = self.db.update_highest_receive_index( - &owner_id, - &contact_id, - address_index + 1, - ); - } - - // Save the payment record - let _ = self.db.save_payment( - &tx.txid().to_string(), - &contact_id, // from contact - &owner_id, // to us - tx_out.value as i64, - None, // memo not available for incoming - "received", - ); - - tracing::info!( - "DashPay payment received: {} duffs from contact {} to address {} (index {})", - tx_out.value, - contact_id.to_string( - dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58 - ), - address, - address_index - ); - } - } - } - if matches!( - tx.special_transaction_payload, - Some(AssetLockPayloadType(_)) - ) { - self.received_asset_lock_finality(tx, islock, chain_locked_height)?; - } - Ok(wallet_outpoints) - } - - /// Store the asset lock transaction in the database and update the wallet. - pub(crate) fn received_asset_lock_finality( - &self, - tx: &Transaction, - islock: Option, - chain_locked_height: Option, - ) -> Result<()> { - // Extract the asset lock payload from the transaction - let Some(AssetLockPayloadType(payload)) = tx.special_transaction_payload.as_ref() else { - return Ok(()); - }; - - let proof = if let Some(islock) = islock.as_ref() { - // Deserialize the InstantLock - Some(AssetLockProof::Instant(InstantAssetLockProof::new( - islock.clone(), - tx.clone(), - 0, - ))) - } else { - chain_locked_height.map(|chain_locked_height| { - AssetLockProof::Chain(ChainAssetLockProof { - core_chain_locked_height: chain_locked_height, - out_point: OutPoint::new(tx.txid(), 0), - }) - }) - }; - - { - let mut transactions = self.transactions_waiting_for_finality.lock().unwrap(); - - if let Some(asset_lock_proof) = transactions.get_mut(&tx.txid()) { - *asset_lock_proof = proof.clone(); - } - } - - // Identify the wallet associated with the transaction - let wallets = self.wallets.read().unwrap(); - for wallet_arc in wallets.values() { - let mut wallet = wallet_arc.write().unwrap(); - - // Check if any of the addresses in the transaction outputs match the wallet's known addresses - let matches_wallet = payload.credit_outputs.iter().any(|tx_out| { - if let Ok(output_addr) = Address::from_script(&tx_out.script_pubkey, self.network) { - wallet.known_addresses.contains_key(&output_addr) - } else { - false - } - }); - - if matches_wallet { - // Calculate the total amount from the credit outputs - let amount: u64 = payload - .credit_outputs - .iter() - .map(|tx_out| tx_out.value) - .sum(); - - // Store the asset lock transaction in the database - self.db.store_asset_lock_transaction( - tx, - amount, - islock.as_ref(), - &wallet.seed_hash(), - self.network, - )?; - - let first = payload - .credit_outputs - .first() - .expect("Expected at least one credit output"); - - let address = Address::from_script(&first.script_pubkey, self.network) - .expect("expected an address"); - - // Add the asset lock to the wallet's unused_asset_locks - wallet - .unused_asset_locks - .push((tx.clone(), address, amount, islock, proof)); - - break; // Exit the loop after updating the relevant wallet - } - } - - Ok(()) - } - - pub fn identity_token_balances( - &self, - ) -> Result> { - self.db.get_identity_token_balances(self) - } - - pub fn remove_token_balance( - &self, - token_id: Identifier, - identity_id: Identifier, - ) -> Result<()> { - self.db.remove_token_balance(&token_id, &identity_id, self) - } - - pub fn insert_token( - &self, - token_id: &Identifier, - token_name: &str, - token_configuration: TokenConfiguration, - contract_id: &Identifier, - token_position: u16, - ) -> Result<()> { - let config = config::standard(); - let Some(serialized_token_configuration) = - bincode::encode_to_vec(&token_configuration, config).ok() - else { - // We should always be able to serialize - return Ok(()); - }; - - self.db.insert_token( - token_id, - token_name, - serialized_token_configuration.as_slice(), - contract_id, - token_position, - self, - )?; - - Ok(()) - } - - pub fn remove_token(&self, token_id: &Identifier) -> Result<()> { - self.db.remove_token(token_id, self) - } - - pub fn remove_wallet(&self, seed_hash: &WalletSeedHash) -> Result<(), String> { - { - let wallets = self - .wallets - .read() - .map_err(|_| "Failed to access wallets".to_string())?; - if !wallets.contains_key(seed_hash) { - return Err("Wallet not found".to_string()); - } - } - - self.db - .remove_wallet(seed_hash, &self.network) - .map_err(|e| e.to_string())?; - - let mut wallets = self - .wallets - .write() - .map_err(|_| "Failed to update wallets".to_string())?; - - wallets.remove(seed_hash); - let has_wallet = !wallets.is_empty(); - drop(wallets); - - self.has_wallet.store(has_wallet, Ordering::Relaxed); - - Ok(()) - } - - #[allow(dead_code)] // May be used for storing token balances - pub fn insert_token_identity_balance( - &self, - token_id: &Identifier, - identity_id: &Identifier, - balance: u64, - ) -> Result<()> { - self.db - .insert_identity_token_balance(token_id, identity_id, balance, self)?; - - Ok(()) - } - - pub fn get_contract_by_token_id( - &self, - token_id: &Identifier, - ) -> Result> { - let contract_id = self - .db - .get_contract_id_by_token_id(token_id, self)? - .ok_or(rusqlite::Error::QueryReturnedNoRows)?; - self.db.get_contract_by_id(contract_id, self) - } -} - -pub(crate) struct DapiTransactionInfo { - pub is_chain_locked: bool, - pub height: u32, - pub confirmations: u32, -} - -/// Query transaction info from DAPI. Works in both SPV and RPC modes -/// since DAPI (platform gRPC) is always available via the SDK. -pub(crate) async fn get_transaction_info_via_dapi( - sdk: &Sdk, - tx_id: &Txid, -) -> Result { - use dash_sdk::dapi_client::{DapiRequestExecutor, IntoInner, RequestSettings}; - use dash_sdk::dapi_grpc::core::v0::GetTransactionRequest; - - let response = sdk - .execute( - GetTransactionRequest { - id: tx_id.to_string(), - }, - RequestSettings::default(), - ) - .await - .into_inner() - .map_err(|e| format!("DAPI GetTransaction failed: {}", e))?; - - Ok(DapiTransactionInfo { - is_chain_locked: response.is_chain_locked, - height: response.height, - confirmations: response.confirmations, - }) -} - -/// Returns the default platform version for the given network. -pub(crate) const fn default_platform_version(network: &Network) -> &'static PlatformVersion { - // TODO: Use self.sdk.read().unwrap().version() instead of hardcoding - match network { - Network::Dash => &PLATFORM_V11, - Network::Testnet => &PLATFORM_V11, - Network::Devnet => &PLATFORM_V11, - Network::Regtest => &PLATFORM_V11, - _ => panic!("unsupported network"), - } -} diff --git a/src/context/contract_token_db.rs b/src/context/contract_token_db.rs new file mode 100644 index 000000000..677bbfd8a --- /dev/null +++ b/src/context/contract_token_db.rs @@ -0,0 +1,198 @@ +use super::AppContext; +use crate::model::qualified_contract::QualifiedContract; +use crate::model::wallet::WalletSeedHash; +use crate::ui::tokens::tokens_screen::{IdentityTokenBalance, IdentityTokenIdentifier}; +use bincode::config; +use dash_sdk::dpp::data_contract::TokenConfiguration; +use dash_sdk::platform::{DataContract, Identifier}; +use dash_sdk::query_types::IndexMap; +use rusqlite::Result; +use std::sync::Arc; +use std::sync::atomic::Ordering; + +impl AppContext { + /// Retrieves all contracts from the database plus the system contracts from app context. + pub fn get_contracts( + &self, + limit: Option, + offset: Option, + ) -> Result> { + // Get contracts from the database + let mut contracts = self.db.get_contracts(self, limit, offset)?; + + // Add the DPNS contract to the list + let dpns_contract = QualifiedContract { + contract: Arc::clone(&self.dpns_contract).as_ref().clone(), + alias: Some("dpns".to_string()), + }; + + // Insert the DPNS contract at 0 + contracts.insert(0, dpns_contract); + + // Add the token history contract to the list + let token_history_contract = QualifiedContract { + contract: Arc::clone(&self.token_history_contract).as_ref().clone(), + alias: Some("token_history".to_string()), + }; + + // Insert the token history contract at 1 + contracts.insert(1, token_history_contract); + + // Add the withdrawal contract to the list + let withdraws_contract = QualifiedContract { + contract: Arc::clone(&self.withdraws_contract).as_ref().clone(), + alias: Some("withdrawals".to_string()), + }; + + // Insert the withdrawal contract at 2 + contracts.insert(2, withdraws_contract); + + // Add the keyword search contract to the list + let keyword_search_contract = QualifiedContract { + contract: Arc::clone(&self.keyword_search_contract).as_ref().clone(), + alias: Some("keyword_search".to_string()), + }; + + // Insert the keyword search contract at 3 + contracts.insert(3, keyword_search_contract); + + // Add the DashPay contract to the list + let dashpay_contract = QualifiedContract { + contract: Arc::clone(&self.dashpay_contract).as_ref().clone(), + alias: Some("dashpay".to_string()), + }; + + // Insert the DashPay contract at 4 + contracts.insert(4, dashpay_contract); + + Ok(contracts) + } + + pub fn get_contract_by_id( + &self, + contract_id: &Identifier, + ) -> Result> { + // Get the contract from the database + self.db.get_contract_by_id(*contract_id, self) + } + + pub fn get_unqualified_contract_by_id( + &self, + contract_id: &Identifier, + ) -> Result> { + // Get the contract from the database + self.db.get_unqualified_contract_by_id(*contract_id, self) + } + + // Remove contract from the database by ID + pub fn remove_contract(&self, contract_id: &Identifier) -> Result<()> { + self.db.remove_contract(contract_id.as_bytes(), self) + } + + pub fn replace_contract( + &self, + contract_id: Identifier, + new_contract: &DataContract, + ) -> Result<()> { + self.db.replace_contract(contract_id, new_contract, self) + } + + pub fn identity_token_balances( + &self, + ) -> Result> { + self.db.get_identity_token_balances(self) + } + + pub fn remove_token_balance( + &self, + token_id: Identifier, + identity_id: Identifier, + ) -> Result<()> { + self.db.remove_token_balance(&token_id, &identity_id, self) + } + + pub fn insert_token( + &self, + token_id: &Identifier, + token_name: &str, + token_configuration: TokenConfiguration, + contract_id: &Identifier, + token_position: u16, + ) -> Result<()> { + let config = config::standard(); + let Some(serialized_token_configuration) = + bincode::encode_to_vec(&token_configuration, config).ok() + else { + // We should always be able to serialize + return Ok(()); + }; + + self.db.insert_token( + token_id, + token_name, + serialized_token_configuration.as_slice(), + contract_id, + token_position, + self, + )?; + + Ok(()) + } + + pub fn remove_token(&self, token_id: &Identifier) -> Result<()> { + self.db.remove_token(token_id, self) + } + + pub fn remove_wallet(&self, seed_hash: &WalletSeedHash) -> Result<(), String> { + { + let wallets = self + .wallets + .read() + .map_err(|_| "Failed to access wallets".to_string())?; + if !wallets.contains_key(seed_hash) { + return Err("Wallet not found".to_string()); + } + } + + self.db + .remove_wallet(seed_hash, &self.network) + .map_err(|e| e.to_string())?; + + let mut wallets = self + .wallets + .write() + .map_err(|_| "Failed to update wallets".to_string())?; + + wallets.remove(seed_hash); + let has_wallet = !wallets.is_empty(); + drop(wallets); + + self.has_wallet.store(has_wallet, Ordering::Relaxed); + + Ok(()) + } + + #[allow(dead_code)] // May be used for storing token balances + pub fn insert_token_identity_balance( + &self, + token_id: &Identifier, + identity_id: &Identifier, + balance: u64, + ) -> Result<()> { + self.db + .insert_identity_token_balance(token_id, identity_id, balance, self)?; + + Ok(()) + } + + pub fn get_contract_by_token_id( + &self, + token_id: &Identifier, + ) -> Result> { + let contract_id = self + .db + .get_contract_id_by_token_id(token_id, self)? + .ok_or(rusqlite::Error::QueryReturnedNoRows)?; + self.db.get_contract_by_id(contract_id, self) + } +} diff --git a/src/context/identity_db.rs b/src/context/identity_db.rs new file mode 100644 index 000000000..348629696 --- /dev/null +++ b/src/context/identity_db.rs @@ -0,0 +1,205 @@ +use super::AppContext; +use crate::backend_task::contested_names::ScheduledDPNSVote; +use crate::model::contested_name::ContestedName; +use crate::model::qualified_identity::{DPNSNameInfo, QualifiedIdentity}; +use crate::model::wallet::WalletSeedHash; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::platform::Identifier; +use rusqlite::Result; + +impl AppContext { + /// Inserts a local qualified identity into the database + pub fn insert_local_qualified_identity( + &self, + qualified_identity: &QualifiedIdentity, + wallet_and_identity_id_info: &Option<(WalletSeedHash, u32)>, + ) -> Result<()> { + self.db.insert_local_qualified_identity( + qualified_identity, + wallet_and_identity_id_info, + self, + ) + } + + /// Updates a local qualified identity in the database + pub fn update_local_qualified_identity( + &self, + qualified_identity: &QualifiedIdentity, + ) -> Result<()> { + self.db + .update_local_qualified_identity(qualified_identity, self) + } + + /// Sets the alias for an identity + pub fn set_identity_alias( + &self, + identifier: &Identifier, + new_alias: Option<&str>, + ) -> Result<()> { + self.db.set_identity_alias(identifier, new_alias) + } + + pub fn set_contract_alias( + &self, + contract_id: &Identifier, + new_alias: Option<&str>, + ) -> Result<()> { + self.db.set_contract_alias(contract_id, new_alias) + } + + /// Gets the alias for an identity + pub fn get_identity_alias(&self, identifier: &Identifier) -> Result> { + self.db.get_identity_alias(identifier) + } + + /// Fetches all local qualified identities from the database + pub fn load_local_qualified_identities(&self) -> Result> { + let wallets = self.wallets.read().unwrap(); + self.db.get_local_qualified_identities(self, &wallets) + } + + /// Fetches all local qualified identities from the database + #[allow(dead_code)] // May be used for loading identities in wallets + pub fn load_local_qualified_identities_in_wallets(&self) -> Result> { + let wallets = self.wallets.read().unwrap(); + self.db + .get_local_qualified_identities_in_wallets(self, &wallets) + } + + pub fn get_identity_by_id( + &self, + identity_id: &Identifier, + ) -> Result> { + let wallets = self.wallets.read().unwrap(); + // Get the identity from the database + let result = self.db.get_identity_by_id(identity_id, self, &wallets)?; + + Ok(result) + } + + /// Fetches all voting identities from the database + pub fn load_local_voting_identities(&self) -> Result> { + self.db.get_local_voting_identities(self) + } + + /// Fetches all local user identities from the database + pub fn load_local_user_identities(&self) -> Result> { + let identities = self.db.get_local_user_identities(self)?; + + Ok(identities + .into_iter() + .map(|(mut identity, wallet_hash)| { + if let Some(wallet_id) = wallet_hash { + // Load wallets for each identity + self.load_wallet_for_identity( + &mut identity, + &[wallet_id], + ) + .unwrap_or_else(|e| { + tracing::warn!( + identity = %identity.identity.id(), + error = ?e, + "cannot load wallet for identity when loading local user identities", + ) + }) + } else { + tracing::debug!( + identity = %identity.identity.id(), + "no wallet hash found for identity when loading local user identities", + ); + } + identity + }) + .collect()) + } + + fn load_wallet_for_identity( + &self, + identity: &mut QualifiedIdentity, + wallet_hashes: &[WalletSeedHash], + ) -> Result<()> { + let wallets = self.wallets.read().unwrap(); + for wallet_hash in wallet_hashes { + if let Some(wallet) = wallets.get(wallet_hash) { + identity + .associated_wallets + .insert(*wallet_hash, wallet.clone()); + } else { + tracing::warn!( + wallet = %hex::encode(wallet_hash), + identity = %identity.identity.id(), + "wallet not found for identity when loading local user identities", + ); + } + } + + Ok(()) + } + + /// Fetches all contested names from the database including past and active ones + pub fn all_contested_names(&self) -> Result> { + self.db.get_all_contested_names(self) + } + + /// Fetches all ongoing contested names from the database + pub fn ongoing_contested_names(&self) -> Result> { + self.db.get_ongoing_contested_names(self) + } + + /// Inserts scheduled votes into the database + pub fn insert_scheduled_votes(&self, scheduled_votes: &Vec) -> Result<()> { + self.db.insert_scheduled_votes(self, scheduled_votes) + } + + /// Fetches all scheduled votes from the database + pub fn get_scheduled_votes(&self) -> Result> { + self.db.get_scheduled_votes(self) + } + + /// Clears all scheduled votes from the database + pub fn clear_all_scheduled_votes(&self) -> Result<()> { + self.db.clear_all_scheduled_votes(self) + } + + /// Clears all executed scheduled votes from the database + pub fn clear_executed_scheduled_votes(&self) -> Result<()> { + self.db.clear_executed_scheduled_votes(self) + } + + /// Deletes a scheduled vote from the database + #[allow(clippy::ptr_arg)] + pub fn delete_scheduled_vote(&self, identity_id: &[u8], contested_name: &String) -> Result<()> { + self.db + .delete_scheduled_vote(self, identity_id, contested_name) + } + + /// Marks a scheduled vote as executed in the database + pub fn mark_vote_executed(&self, identity_id: &[u8], contested_name: String) -> Result<()> { + self.db + .mark_vote_executed(self, identity_id, contested_name) + } + + /// Fetches the local identities from the database and then maps them to their DPNS names. + pub fn local_dpns_names(&self) -> Result> { + let wallets = self.wallets.read().unwrap(); + let qualified_identities = self.db.get_local_qualified_identities(self, &wallets)?; + + // Map each identity's DPNS names to (Identifier, DPNSNameInfo) tuples + let dpns_names = qualified_identities + .iter() + .flat_map(|qualified_identity| { + qualified_identity.dpns_names.iter().map(|dpns_name_info| { + ( + qualified_identity.identity.id(), + DPNSNameInfo { + name: dpns_name_info.name.clone(), + acquired_at: dpns_name_info.acquired_at, + }, + ) + }) + }) + .collect::>(); + + Ok(dpns_names) + } +} diff --git a/src/context/mod.rs b/src/context/mod.rs new file mode 100644 index 000000000..925791461 --- /dev/null +++ b/src/context/mod.rs @@ -0,0 +1,551 @@ +pub mod connection_status; +mod contract_token_db; +mod identity_db; +mod settings_db; +mod transaction_processing; +mod wallet_lifecycle; + +pub(crate) use transaction_processing::get_transaction_info; + +use crate::app_dir::core_cookie_path; +use crate::components::core_zmq_listener::ZMQConnectionEvent; +use crate::config::{Config, NetworkConfig}; +use crate::context_provider::Provider as RpcProvider; +use crate::context_provider_spv::SpvProvider; +use crate::database::Database; +use crate::model::fee_estimation::PlatformFeeEstimator; +use crate::model::password_info::PasswordInfo; +use crate::model::wallet::single_key::{SingleKeyHash, SingleKeyWallet}; +use crate::model::wallet::{Wallet, WalletSeedHash}; +use crate::sdk_wrapper::initialize_sdk; +use crate::spv::{CoreBackendMode, SpvManager}; +use crate::utils::tasks::TaskManager; +use connection_status::ConnectionStatus; +use crossbeam_channel::{Receiver, Sender}; +use dash_sdk::Sdk; +use dash_sdk::dashcore_rpc::{Auth, Client}; +use dash_sdk::dpp::dashcore::{Network, Txid}; +use dash_sdk::dpp::prelude::AssetLockProof; +use dash_sdk::dpp::state_transition::StateTransitionSigningOptions; +use dash_sdk::dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; +use dash_sdk::dpp::system_data_contracts::{SystemDataContract, load_system_data_contract}; +use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::dpp::version::v11::PLATFORM_V11; +use dash_sdk::platform::DataContract; +use egui::Context; +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, RwLock, RwLockWriteGuard}; + +use crate::model::settings::Settings; + +const ANIMATION_REFRESH_TIME: std::time::Duration = std::time::Duration::from_millis(100); + +/// A guard that ensures settings cache invalidation happens atomically +/// +/// This guard holds a write lock on the cached settings, preventing reads +/// until the database update is complete and the cache is properly invalidated. +pub(crate) type SettingsCacheGuard<'a> = RwLockWriteGuard<'a, Option>; + +#[derive(Debug)] +pub struct AppContext { + pub(crate) network: Network, + developer_mode: AtomicBool, + #[allow(dead_code)] // May be used for devnet identification + pub(crate) devnet_name: Option, + pub(crate) db: Arc, + pub(crate) sdk: RwLock, + // Context providers for SDK, so we can switch when backend mode changes + spv_context_provider: RwLock, + rpc_context_provider: RwLock, + pub(crate) config: Arc>, + pub(crate) rx_zmq_status: Receiver, + pub(crate) sx_zmq_status: Sender, + pub(crate) dpns_contract: Arc, + pub(crate) withdraws_contract: Arc, + pub(crate) dashpay_contract: Arc, + pub(crate) token_history_contract: Arc, + pub(crate) keyword_search_contract: Arc, + pub(crate) core_client: RwLock, + pub(crate) has_wallet: AtomicBool, + pub(crate) wallets: RwLock>>>, + pub(crate) single_key_wallets: RwLock>>>, + #[allow(dead_code)] // May be used for password validation + pub(crate) password_info: Option, + pub(crate) transactions_waiting_for_finality: Mutex>>, + /// Whether to animate the UI elements. + /// + /// This is used to control animations in the UI, such as loading spinners or transitions. + /// Disable for automated tests. + animate: AtomicBool, + /// Cached settings to avoid expensive database reads + /// Use RwLock to allow multiple readers but exclusive writers for cache invalidation + cached_settings: RwLock>, + // subtasks started by the app context, used for graceful shutdown + pub(crate) subtasks: Arc, + pub(crate) spv_manager: Arc, + core_backend_mode: AtomicU8, + /// Tracks the connection status to currently active network + pub(crate) connection_status: Arc, + /// Pending wallet selection - set after creating/importing a wallet + /// so the wallet screen can auto-select the new wallet + pub(crate) pending_wallet_selection: Mutex>, + /// Currently selected HD wallet (persisted across screen navigation) + pub(crate) selected_wallet_hash: Mutex>, + /// Currently selected single key wallet (persisted across screen navigation) + pub(crate) selected_single_key_hash: Mutex>, + /// Cached fee multiplier permille from current epoch (1000 = 1x, 2000 = 2x) + /// Updated when epoch info is fetched from Platform + fee_multiplier_permille: AtomicU64, +} + +impl AppContext { + pub fn new( + network: Network, + db: Arc, + password_info: Option, + subtasks: Arc, + connection_status: Arc, + ) -> Option> { + let config = match Config::load() { + Ok(config) => config, + Err(e) => { + println!("Failed to load config: {e}"); + return None; + } + }; + + let network_config = config.config_for_network(network).clone()?; + let config_lock = Arc::new(RwLock::new(network_config.clone())); + let (sx_zmq_status, rx_zmq_status) = crossbeam_channel::unbounded(); + + // Create both providers; bind to app context later (post construction) due to circularity + let spv_provider = + SpvProvider::new(db.clone(), network).expect("Failed to initialize SPV provider"); + let rpc_provider = RpcProvider::new(db.clone(), network, &network_config) + .expect("Failed to initialize RPC provider"); + + // Default to SPV provider initially; UI can switch backend after + let sdk = initialize_sdk(&network_config, network, spv_provider.clone()); + let platform_version = sdk.version(); + + let dpns_contract = load_system_data_contract(SystemDataContract::DPNS, platform_version) + .expect("expected to load dpns contract"); + + let withdrawal_contract = + load_system_data_contract(SystemDataContract::Withdrawals, platform_version) + .expect("expected to get withdrawal contract"); + + let token_history_contract = + load_system_data_contract(SystemDataContract::TokenHistory, platform_version) + .expect("expected to get token history contract"); + + let keyword_search_contract = + load_system_data_contract(SystemDataContract::KeywordSearch, platform_version) + .expect("expected to get keyword search contract"); + + let dashpay_contract = + load_system_data_contract(SystemDataContract::Dashpay, platform_version) + .expect("expected to get dashpay contract"); + + let addr = format!( + "http://{}:{}", + network_config.core_host, network_config.core_rpc_port + ); + let cookie_path = core_cookie_path(network, &network_config.devnet_name) + .expect("expected to get cookie path"); + + // Try cookie authentication first + let core_client = match Client::new(&addr, Auth::CookieFile(cookie_path.clone())) { + Ok(client) => Ok(client), + Err(_) => { + // If cookie auth fails, try user/password authentication + tracing::info!( + "Failed to authenticate using .cookie file at {:?}, falling back to user/pass", + cookie_path, + ); + Client::new( + &addr, + Auth::UserPass( + network_config.core_rpc_user.to_string(), + network_config.core_rpc_password.to_string(), + ), + ) + } + } + .expect("Failed to create CoreClient"); + + let wallets: BTreeMap<_, _> = db + .get_wallets(&network) + .expect("expected to get wallets") + .into_iter() + .map(|w| (w.seed_hash(), Arc::new(RwLock::new(w)))) + .collect(); + + let single_key_wallets: BTreeMap<_, _> = db + .get_single_key_wallets(network) + .expect("expected to get single key wallets") + .into_iter() + .map(|w| (w.key_hash(), Arc::new(RwLock::new(w)))) + .collect(); + + let developer_mode_enabled = config.developer_mode.unwrap_or(false); + + let animate = match developer_mode_enabled { + true => { + tracing::debug!("developer_mode is enabled, disabling animations"); + AtomicBool::new(false) + } + false => AtomicBool::new(true), // Animations are enabled by default + }; + + let spv_manager = match SpvManager::new(network, Arc::clone(&config_lock), subtasks.clone()) + { + Ok(manager) => manager, + Err(err) => { + tracing::error!(?err, ?network, "Failed to initialize SPV manager"); + return None; + } + }; + + // Load the use_local_spv_node setting and apply to SPV manager + let use_local_spv_node = db.get_use_local_spv_node().unwrap_or(false); + spv_manager.set_use_local_node(use_local_spv_node); + + // Load the core backend mode from settings, defaulting to RPC if not set + let saved_core_backend_mode = db + .get_settings() + .ok() + .flatten() + .map(|s| s.7) // core_backend_mode is the 8th element (index 7) + .unwrap_or(CoreBackendMode::Rpc.as_u8()); + + // If not in developer mode, force RPC mode (SPV is gated behind dev mode) + let saved_core_backend_mode = if developer_mode_enabled { + saved_core_backend_mode + } else { + CoreBackendMode::Rpc.as_u8() + }; + + // Load saved wallet selection, validating that the wallets still exist + let (saved_wallet_hash, saved_single_key_hash) = + db.get_selected_wallet_hashes().unwrap_or((None, None)); + + // Only use the saved hash if the wallet still exists + let selected_wallet_hash = saved_wallet_hash.filter(|h| wallets.contains_key(h)); + let selected_single_key_hash = + saved_single_key_hash.filter(|h| single_key_wallets.contains_key(h)); + + let app_context = AppContext { + network, + developer_mode: AtomicBool::new(developer_mode_enabled), + devnet_name: None, + db, + sdk: sdk.into(), + spv_context_provider: spv_provider.into(), + rpc_context_provider: rpc_provider.into(), + config: config_lock, + sx_zmq_status, + rx_zmq_status, + dpns_contract: Arc::new(dpns_contract), + withdraws_contract: Arc::new(withdrawal_contract), + dashpay_contract: Arc::new(dashpay_contract), + token_history_contract: Arc::new(token_history_contract), + keyword_search_contract: Arc::new(keyword_search_contract), + core_client: core_client.into(), + has_wallet: (!wallets.is_empty() || !single_key_wallets.is_empty()).into(), + wallets: RwLock::new(wallets), + single_key_wallets: RwLock::new(single_key_wallets), + password_info, + transactions_waiting_for_finality: Mutex::new(BTreeMap::new()), + animate, + cached_settings: RwLock::new(None), + subtasks, + spv_manager, + core_backend_mode: AtomicU8::new(saved_core_backend_mode), + connection_status, + pending_wallet_selection: Mutex::new(None), + selected_wallet_hash: Mutex::new(selected_wallet_hash), + selected_single_key_hash: Mutex::new(selected_single_key_hash), + fee_multiplier_permille: AtomicU64::new( + PlatformFeeEstimator::DEFAULT_FEE_MULTIPLIER_PERMILLE, + ), + }; + + let app_context = Arc::new(app_context); + // Bind providers to the newly created app_context. + // Only the active provider is registered with the SDK here (SPV by default). + if let Err(e) = app_context + .spv_context_provider + .read() + .map_err(|_| "SPV provider lock poisoned".to_string()) + .and_then(|provider| provider.bind_app_context(app_context.clone())) + { + tracing::error!("Failed to bind SPV provider: {}", e); + return None; + } + + // If defaulting to RPC is desired, swap provider after binding. + if app_context.core_backend_mode() == CoreBackendMode::Rpc { + if let Err(e) = app_context + .rpc_context_provider + .read() + .map_err(|_| "RPC provider lock poisoned".to_string()) + .and_then(|provider| provider.bind_app_context(app_context.clone())) + { + tracing::error!("Failed to bind RPC provider: {}", e); + return None; + } + } else { + // Ensure SDK uses the SPV provider + let sdk_lock = match app_context.sdk.write() { + Ok(lock) => lock, + Err(_) => { + tracing::error!("SDK lock poisoned"); + return None; + } + }; + let provider = match app_context.spv_context_provider.read() { + Ok(p) => p.clone(), + Err(_) => { + tracing::error!("SPV provider lock poisoned"); + return None; + } + }; + sdk_lock.set_context_provider(provider); + } + + app_context.bootstrap_loaded_wallets(); + + Some(app_context) + } + + /// Enables animations in the UI. + /// + /// This is used to control whether UI elements should animate, such as loading spinners or transitions. + pub fn enable_animations(&self, animate: bool) { + self.animate.store(animate, Ordering::Relaxed); + } + + pub fn enable_developer_mode(&self, enable: bool) { + self.developer_mode.store(enable, Ordering::Relaxed); + // Animations are reverse of developer mode + self.enable_animations(!enable); + } + + pub fn core_backend_mode(&self) -> CoreBackendMode { + self.core_backend_mode.load(Ordering::Relaxed).into() + } + + pub fn connection_status(&self) -> &ConnectionStatus { + &self.connection_status + } + + pub fn set_core_backend_mode(self: &Arc, mode: CoreBackendMode) { + self.core_backend_mode + .store(mode.as_u8(), Ordering::Relaxed); + + // Persist the mode to the database (hold the guard to ensure cache invalidation) + let _guard = self.invalidate_settings_cache(); + if let Err(e) = self.db.update_core_backend_mode(mode.as_u8()) { + tracing::error!("Failed to persist core backend mode: {}", e); + } + + // Switch SDK context provider to match the selected backend + match mode { + CoreBackendMode::Spv => { + // Make sure SPV provider knows about the app context + if let Err(e) = self + .spv_context_provider + .read() + .map_err(|_| "SPV provider lock poisoned".to_string()) + .and_then(|provider| provider.bind_app_context(Arc::clone(self))) + { + tracing::error!("Failed to bind SPV provider: {}", e); + return; + } + let sdk = match self.sdk.write() { + Ok(lock) => lock, + Err(_) => { + tracing::error!("SDK lock poisoned in set_core_backend_mode"); + return; + } + }; + let provider = match self.spv_context_provider.read() { + Ok(p) => p.clone(), + Err(_) => { + tracing::error!("SPV provider lock poisoned"); + return; + } + }; + sdk.set_context_provider(provider); + } + CoreBackendMode::Rpc => { + // RPC provider binding also sets itself on the SDK + if let Err(e) = self + .rpc_context_provider + .read() + .map_err(|_| "RPC provider lock poisoned".to_string()) + .and_then(|provider| provider.bind_app_context(Arc::clone(self))) + { + tracing::error!("Failed to bind RPC provider: {}", e); + } + } + } + } + + /// Get the cached fee multiplier permille (1000 = 1x, 2000 = 2x) + pub fn fee_multiplier_permille(&self) -> u64 { + self.fee_multiplier_permille.load(Ordering::Relaxed) + } + + /// Update the cached fee multiplier from epoch info + pub fn set_fee_multiplier_permille(&self, multiplier: u64) { + self.fee_multiplier_permille + .store(multiplier, Ordering::Relaxed); + } + + /// Get a fee estimator configured with the cached fee multiplier. + /// Use this instead of `PlatformFeeEstimator::new()` to get accurate fee estimates + /// that reflect the current network fee multiplier. + pub fn fee_estimator(&self) -> PlatformFeeEstimator { + PlatformFeeEstimator::with_fee_multiplier(self.fee_multiplier_permille()) + } + + pub fn is_developer_mode(&self) -> bool { + self.developer_mode.load(Ordering::Relaxed) + } + + /// Repaints the UI if animations are enabled. + /// + /// Called by UI elements that need to trigger a repaint, such as loading spinners or animated icons. + pub(super) fn repaint_animation(&self, ctx: &Context) { + if self.animate.load(Ordering::Relaxed) { + // Request a repaint after a short delay to allow for animations + ctx.request_repaint_after(ANIMATION_REFRESH_TIME); + } + } + + pub fn platform_version(&self) -> &'static PlatformVersion { + default_platform_version(&self.network) + } + + pub fn state_transition_options(&self) -> Option { + if self.is_developer_mode() { + Some(StateTransitionCreationOptions { + signing_options: StateTransitionSigningOptions { + allow_signing_with_any_security_level: true, + allow_signing_with_any_purpose: true, + }, + batch_feature_version: None, + method_feature_version: None, + base_feature_version: None, + }) + } else { + None + } + } + + /// Rebuild both the Dash RPC `core_client` and the `Sdk` using the + /// updated `NetworkConfig` from `self.config`. + pub fn reinit_core_client_and_sdk(self: Arc) -> Result<(), String> { + // 1. Grab a fresh snapshot of your NetworkConfig + let cfg = { + let cfg_lock = self + .config + .read() + .map_err(|_| "Config lock poisoned".to_string())?; + cfg_lock.clone() + }; + + // Note: developer_mode is now global and managed separately + + // 2. Rebuild the RPC client with the new password + let addr = format!("http://{}:{}", cfg.core_host, cfg.core_rpc_port); + let new_client = Client::new( + &addr, + Auth::UserPass(cfg.core_rpc_user.clone(), cfg.core_rpc_password.clone()), + ) + .map_err(|e| format!("Failed to create new Core RPC client: {e}"))?; + + // 3. Rebuild the Sdk with the updated config and current backend mode + let new_sdk = match self.core_backend_mode() { + CoreBackendMode::Spv => { + // Reuse existing SPV provider (rebinding below to ensure context is set) + let provider = self + .spv_context_provider + .read() + .map_err(|_| "SPV provider lock poisoned".to_string())? + .clone(); + initialize_sdk(&cfg, self.network, provider) + } + CoreBackendMode::Rpc => { + // Create a fresh RPC provider with the new config + let rpc_provider = RpcProvider::new(self.db.clone(), self.network, &cfg) + .map_err(|e| format!("Failed to init RPC provider: {e}"))?; + // Swap in the updated RPC provider for future switches + { + let mut guard = self + .rpc_context_provider + .write() + .map_err(|_| "RPC provider lock poisoned".to_string())?; + *guard = rpc_provider.clone(); + } + initialize_sdk(&cfg, self.network, rpc_provider) + } + }; + + // 4. Swap them in + { + let mut client_lock = self + .core_client + .write() + .map_err(|_| "Core client lock poisoned".to_string())?; + *client_lock = new_client; + } + { + let mut sdk_lock = self + .sdk + .write() + .map_err(|_| "SDK lock poisoned".to_string())?; + *sdk_lock = new_sdk; + } + + // Rebind providers to ensure they hold the new AppContext reference + self.spv_context_provider + .read() + .map_err(|_| "SPV provider lock poisoned".to_string())? + .bind_app_context(self.clone())?; + if self.core_backend_mode() == CoreBackendMode::Rpc { + self.rpc_context_provider + .read() + .map_err(|_| "RPC provider lock poisoned".to_string())? + .bind_app_context(self.clone())?; + } else { + let sdk_lock = self + .sdk + .write() + .map_err(|_| "SDK lock poisoned".to_string())?; + let provider = self + .spv_context_provider + .read() + .map_err(|_| "SPV provider lock poisoned".to_string())? + .clone(); + sdk_lock.set_context_provider(provider); + } + + Ok(()) + } +} + +/// Returns the default platform version for the given network. +pub(crate) const fn default_platform_version(network: &Network) -> &'static PlatformVersion { + // TODO: Use self.sdk.read().unwrap().version() instead of hardcoding + match network { + Network::Dash => &PLATFORM_V11, + Network::Testnet => &PLATFORM_V11, + Network::Devnet => &PLATFORM_V11, + Network::Regtest => &PLATFORM_V11, + _ => panic!("unsupported network"), + } +} diff --git a/src/context/settings_db.rs b/src/context/settings_db.rs new file mode 100644 index 000000000..88f3c7542 --- /dev/null +++ b/src/context/settings_db.rs @@ -0,0 +1,84 @@ +use super::{AppContext, SettingsCacheGuard}; +use crate::model::settings::Settings; +use crate::ui::RootScreenType; +use rusqlite::Result; + +impl AppContext { + /// Updates the `start_root_screen` in the settings table + pub fn update_settings(&self, root_screen_type: RootScreenType) -> Result<()> { + let _guard = self.invalidate_settings_cache(); + + self.db + .insert_or_update_settings(self.network, root_screen_type) + } + + /// Updates the main password settings + pub fn update_main_password( + &self, + salt: &[u8], + nonce: &[u8], + password_check: &[u8], + ) -> Result<()> { + let _guard = self.invalidate_settings_cache(); + + self.db.update_main_password(salt, nonce, password_check) + } + + /// Updates the Dash Core execution settings + pub fn update_dash_core_execution_settings( + &self, + custom_dash_qt_path: Option, + overwrite_dash_conf: bool, + ) -> Result<()> { + let _guard = self.invalidate_settings_cache(); + + self.db + .update_dash_core_execution_settings(custom_dash_qt_path, overwrite_dash_conf) + } + + /// Updates the disable_zmq flag in settings + pub fn update_disable_zmq(&self, disable: bool) -> Result<()> { + let _guard = self.invalidate_settings_cache(); + self.db.update_disable_zmq(disable) + } + + /// Invalidates the settings cache and returns a guard + /// + /// The cache is invalidated immediately and the guard prevents concurrent access + /// until the database operation is complete. This ensures atomicity and prevents + /// race conditions regardless of whether the database operation succeeds or fails. + pub fn invalidate_settings_cache(&'_ self) -> SettingsCacheGuard<'_> { + let mut guard = self.cached_settings.write().unwrap(); + *guard = None; + guard + } + + /// Retrieves the current settings + /// + /// ## Cached + /// + /// This function uses a cache to avoid expensive database operations. + /// The cache is invalidated when settings are updated. + /// + /// Use [`AppContext::invalidate_settings_cache`] to invalidate the cache. + pub fn get_settings(&self) -> Result> { + // First, try to read from cache + { + let cache = self.cached_settings.read().unwrap(); + if let Some(ref settings) = *cache { + return Ok(Some(settings.clone())); + } + } + + // Cache miss, read from database + let settings = self.db.get_settings()?.map(Settings::from); + + // Update cache with the fresh data + { + let mut cache = self.cached_settings.write().unwrap(); + *cache = settings.clone(); + } + + Ok(settings) + } +} diff --git a/src/context/transaction_processing.rs b/src/context/transaction_processing.rs new file mode 100644 index 000000000..79104db57 --- /dev/null +++ b/src/context/transaction_processing.rs @@ -0,0 +1,295 @@ +use super::AppContext; +use crate::spv::CoreBackendMode; +use dash_sdk::Sdk; +use dash_sdk::dashcore_rpc::RpcApi; +use dash_sdk::dpp::dashcore::hashes::Hash; +use dash_sdk::dpp::dashcore::transaction::special_transaction::TransactionPayload::AssetLockPayloadType; +use dash_sdk::dpp::dashcore::{Address, InstantLock, OutPoint, Transaction, TxOut, Txid}; +use dash_sdk::dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; +use dash_sdk::dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; +use dash_sdk::dpp::prelude::{AssetLockProof, CoreBlockHeight}; +use rusqlite::Result; +use std::collections::HashMap; + +impl AppContext { + /// Broadcast a raw transaction via Core RPC or SPV depending on backend mode. + pub(crate) async fn broadcast_raw_transaction(&self, tx: &Transaction) -> Result { + match self.core_backend_mode() { + CoreBackendMode::Rpc => self + .core_client + .read() + .map_err(|e| format!("core client lock poisoned: {}", e))? + .send_raw_transaction(tx) + .map_err(|e| e.to_string()), + CoreBackendMode::Spv => { + self.spv_manager.broadcast_transaction(tx).await?; + Ok(tx.txid()) + } + } + } + + /// Wait for an asset lock proof (InstantLock or ChainLock) for the given transaction. + /// + /// Polls `transactions_waiting_for_finality` until a proof appears, with a + /// backend-mode-dependent timeout (SPV: 5 min, RPC: 2 min). Cleans up the + /// tracking entry on both success and timeout. + pub(crate) async fn wait_for_asset_lock_proof( + &self, + tx_id: Txid, + ) -> Result { + use tokio::time::Duration; + + let timeout_duration = match self.core_backend_mode() { + CoreBackendMode::Spv => Duration::from_secs(300), + CoreBackendMode::Rpc => Duration::from_secs(120), + }; + + match tokio::time::timeout(timeout_duration, async { + loop { + { + let proofs = self.transactions_waiting_for_finality.lock().unwrap(); + if let Some(Some(proof)) = proofs.get(&tx_id) { + return proof.clone(); + } + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + }) + .await + { + Ok(proof) => { + let mut proofs = self.transactions_waiting_for_finality.lock().unwrap(); + proofs.remove(&tx_id); + Ok(proof) + } + Err(_) => { + let mut proofs = self.transactions_waiting_for_finality.lock().unwrap(); + proofs.remove(&tx_id); + Err(format!( + "Timeout waiting for asset lock proof after {} seconds. \ + The transaction may not have been confirmed by the network.", + timeout_duration.as_secs() + )) + } + } + } + + pub(crate) fn received_transaction_finality( + &self, + tx: &Transaction, + islock: Option, + chain_locked_height: Option, + ) -> Result> { + // Initialize a vector to collect wallet outpoints + let mut wallet_outpoints = Vec::new(); + + // Identify the wallets associated with the transaction + let wallets = self.wallets.read().unwrap(); + for wallet_arc in wallets.values() { + let mut wallet = wallet_arc.write().unwrap(); + for (vout, tx_out) in tx.output.iter().enumerate() { + let address = if let Ok(output_addr) = + Address::from_script(&tx_out.script_pubkey, self.network) + { + if wallet.known_addresses.contains_key(&output_addr) { + output_addr + } else { + continue; + } + } else { + continue; + }; + self.db.insert_utxo( + tx.txid().as_byte_array(), + vout as u32, + &address, + tx_out.value, + &tx_out.script_pubkey.to_bytes(), + self.network, + )?; + self.db + .add_to_address_balance(&wallet.seed_hash(), &address, tx_out.value)?; + + // Create the OutPoint and insert it into the wallet.utxos entry + let out_point = OutPoint::new(tx.txid(), vout as u32); + wallet + .utxos + .entry(address.clone()) + .or_insert_with(HashMap::new) // Initialize inner HashMap if needed + .insert(out_point, tx_out.clone()); // Insert the TxOut at the OutPoint + + // Collect the outpoint + wallet_outpoints.push((out_point, tx_out.clone(), address.clone())); + + wallet + .address_balances + .entry(address.clone()) + .and_modify(|balance| *balance += tx_out.value) + .or_insert(tx_out.value); + + // Check if this is a DashPay contact payment + if let Ok(Some((owner_id, contact_id, address_index))) = + self.db.get_dashpay_address_mapping(&address) + { + // Update the highest receive index if needed + if let Ok(indices) = self.db.get_contact_address_indices(&owner_id, &contact_id) + && address_index >= indices.highest_receive_index + { + let _ = self.db.update_highest_receive_index( + &owner_id, + &contact_id, + address_index + 1, + ); + } + + // Save the payment record + let _ = self.db.save_payment( + &tx.txid().to_string(), + &contact_id, // from contact + &owner_id, // to us + tx_out.value as i64, + None, // memo not available for incoming + "received", + ); + + tracing::info!( + "DashPay payment received: {} duffs from contact {} to address {} (index {})", + tx_out.value, + contact_id.to_string( + dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58 + ), + address, + address_index + ); + } + } + } + if matches!( + tx.special_transaction_payload, + Some(AssetLockPayloadType(_)) + ) { + self.received_asset_lock_finality(tx, islock, chain_locked_height)?; + } + Ok(wallet_outpoints) + } + + /// Store the asset lock transaction in the database and update the wallet. + pub(crate) fn received_asset_lock_finality( + &self, + tx: &Transaction, + islock: Option, + chain_locked_height: Option, + ) -> Result<()> { + // Extract the asset lock payload from the transaction + let Some(AssetLockPayloadType(payload)) = tx.special_transaction_payload.as_ref() else { + return Ok(()); + }; + + let proof = if let Some(islock) = islock.as_ref() { + // Deserialize the InstantLock + Some(AssetLockProof::Instant(InstantAssetLockProof::new( + islock.clone(), + tx.clone(), + 0, + ))) + } else { + chain_locked_height.map(|chain_locked_height| { + AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: chain_locked_height, + out_point: OutPoint::new(tx.txid(), 0), + }) + }) + }; + + { + let mut transactions = self.transactions_waiting_for_finality.lock().unwrap(); + + if let Some(asset_lock_proof) = transactions.get_mut(&tx.txid()) { + *asset_lock_proof = proof.clone(); + } + } + + // Identify the wallet associated with the transaction + let wallets = self.wallets.read().unwrap(); + for wallet_arc in wallets.values() { + let mut wallet = wallet_arc.write().unwrap(); + + // Check if any of the addresses in the transaction outputs match the wallet's known addresses + let matches_wallet = payload.credit_outputs.iter().any(|tx_out| { + if let Ok(output_addr) = Address::from_script(&tx_out.script_pubkey, self.network) { + wallet.known_addresses.contains_key(&output_addr) + } else { + false + } + }); + + if matches_wallet { + // Calculate the total amount from the credit outputs + let amount: u64 = payload + .credit_outputs + .iter() + .map(|tx_out| tx_out.value) + .sum(); + + // Store the asset lock transaction in the database + self.db.store_asset_lock_transaction( + tx, + amount, + islock.as_ref(), + &wallet.seed_hash(), + self.network, + )?; + + let first = payload + .credit_outputs + .first() + .expect("Expected at least one credit output"); + + let address = Address::from_script(&first.script_pubkey, self.network) + .expect("expected an address"); + + // Add the asset lock to the wallet's unused_asset_locks + wallet + .unused_asset_locks + .push((tx.clone(), address, amount, islock, proof)); + + break; // Exit the loop after updating the relevant wallet + } + } + + Ok(()) + } +} + +pub(crate) struct DapiTransactionInfo { + pub is_chain_locked: bool, + pub height: u32, + pub confirmations: u32, +} + +/// Query transaction info from DAPI. Works in both SPV and RPC modes +/// since DAPI (platform gRPC) is always available via the SDK. +pub(crate) async fn get_transaction_info( + sdk: &Sdk, + tx_id: &Txid, +) -> Result { + use dash_sdk::dapi_client::{DapiRequestExecutor, IntoInner, RequestSettings}; + use dash_sdk::dapi_grpc::core::v0::GetTransactionRequest; + + let response = sdk + .execute( + GetTransactionRequest { + id: tx_id.to_string(), + }, + RequestSettings::default(), + ) + .await + .into_inner() + .map_err(|e| format!("DAPI GetTransaction failed: {}", e))?; + + Ok(DapiTransactionInfo { + is_chain_locked: response.is_chain_locked, + height: response.height, + confirmations: response.confirmations, + }) +} diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs new file mode 100644 index 000000000..ceef9c658 --- /dev/null +++ b/src/context/wallet_lifecycle.rs @@ -0,0 +1,731 @@ +use super::AppContext; +use super::get_transaction_info; +use crate::model::wallet::{ + AddressInfo as WalletAddressInfo, DerivationPathHelpers, DerivationPathReference, + DerivationPathType, Wallet, WalletSeedHash, WalletTransaction, +}; +use crate::spv::{AssetLockFinalityEvent, CoreBackendMode, SpvManager}; +use dash_sdk::dpp::dashcore::hashes::Hash; +use dash_sdk::dpp::dashcore::{Address, Network}; +use dash_sdk::dpp::key_wallet::Network as WalletNetwork; +use dash_sdk::dpp::key_wallet::account::AccountType; +use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, DerivationPath}; +use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::{ + ManagedWalletInfo, wallet_info_interface::WalletInfoInterface, +}; +use std::sync::atomic::Ordering; +use std::sync::{Arc, RwLock}; + +impl AppContext { + pub fn spv_manager(&self) -> &Arc { + &self.spv_manager + } + + pub fn clear_spv_data(&self) -> rusqlite::Result<(), String> { + self.spv_manager.clear_data_dir() + } + + pub fn clear_network_database(&self) -> rusqlite::Result<(), String> { + self.db + .clear_network_data(self.network) + .map_err(|e| e.to_string())?; + + if let Ok(mut wallets) = self.wallets.write() { + wallets.clear(); + } + + if let Ok(mut single_key_wallets) = self.single_key_wallets.write() { + single_key_wallets.clear(); + } + + self.has_wallet.store(false, Ordering::Relaxed); + + Ok(()) + } + + pub fn start_spv(self: &Arc) -> Result<(), String> { + // Skip if SPV is already active — avoids orphaned listener tasks from + // re-registering channels while existing handlers still hold old senders. + if self.spv_manager.status().status.is_active() { + return Ok(()); + } + + // Count wallets that will be loaded into SPV (open wallets with accessible seeds). + // This is read synchronously so the SPV thread can wait for exactly this many. + let expected_wallets = self + .wallets + .read() + .map(|guard| { + guard + .values() + .filter(|w| { + w.read() + .ok() + .is_some_and(|g| g.is_open() && g.seed_bytes().is_ok()) + }) + .count() + }) + .unwrap_or(0); + // Register reconcile channel BEFORE starting SPV so the event handlers + // (spawned inside run_spv_loop) always capture a valid sender. + self.spv_setup_reconcile_listener(); + self.spv_setup_finality_listener(); + self.spv_manager.start(expected_wallets)?; + Ok(()) + } + + 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); + } + } + + pub fn handle_wallet_unlocked(self: &Arc, wallet: &Arc>) { + if let Some((seed_hash, seed_bytes)) = Self::wallet_seed_snapshot(wallet) { + 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. + } + } + + pub fn handle_wallet_locked(self: &Arc, wallet: &Arc>) { + let seed_hash = match wallet.read() { + Ok(guard) => guard.seed_hash(), + Err(err) => { + tracing::warn!(error = %err, "Unable to read wallet during lock handling"); + return; + } + }; + self.queue_spv_wallet_unload(seed_hash); + } + + fn wallet_seed_snapshot(wallet: &Arc>) -> Option<(WalletSeedHash, [u8; 64])> { + let guard = wallet.read().ok()?; + if !guard.is_open() { + return None; + } + let seed_bytes = match guard.seed_bytes() { + Ok(bytes) => *bytes, + Err(err) => { + tracing::warn!(error = %err, wallet = %hex::encode(guard.seed_hash()), "Unable to snapshot wallet seed for SPV load"); + return None; + } + }; + Some((guard.seed_hash(), seed_bytes)) + } + + fn queue_spv_wallet_load(self: &Arc, seed_hash: WalletSeedHash, seed_bytes: [u8; 64]) { + let spv = Arc::clone(&self.spv_manager); + self.subtasks.spawn_sync(async move { + if let Err(error) = spv.load_wallet_from_seed(seed_hash, seed_bytes).await { + tracing::error!(seed = %hex::encode(seed_hash), %error, "Failed to load SPV wallet from seed"); + } + }); + } + + fn queue_spv_wallet_unload(self: &Arc, seed_hash: WalletSeedHash) { + let spv = Arc::clone(&self.spv_manager); + self.subtasks.spawn_sync(async move { + if let Err(error) = spv.unload_wallet(seed_hash).await { + tracing::error!(seed = %hex::encode(seed_hash), %error, "Failed to unload SPV wallet"); + } + }); + } + + /// Queue automatic discovery of identities derived from a wallet. + /// Checks identity indices 0 through max_identity_index for existing identities on the network. + pub fn queue_wallet_identity_discovery( + self: &Arc, + wallet: &Arc>, + max_identity_index: u32, + ) { + let ctx = Arc::clone(self); + let wallet_clone = Arc::clone(wallet); + self.subtasks.spawn_sync(async move { + if let Err(error) = ctx + .discover_identities_from_wallet(&wallet_clone, max_identity_index) + .await + { + tracing::warn!( + %error, + "Failed to discover identities from wallet" + ); + } + }); + } + + pub fn bootstrap_loaded_wallets(self: &Arc) { + let wallets: Vec<_> = { + let guard = self.wallets.read().unwrap(); + guard.values().cloned().collect() + }; + + for wallet in wallets.iter() { + self.bootstrap_wallet_addresses(wallet); + self.handle_wallet_unlocked(wallet); + } + + // Auto-refresh UTXOs from Core on startup so balances are current + // without requiring the user to manually click Refresh (fixes GH#522). + // Only in RPC mode — SPV mode handles UTXO loading via reconciliation. + if self.core_backend_mode() == CoreBackendMode::Rpc { + for wallet in wallets { + let ctx = Arc::clone(self); + self.subtasks.spawn_sync(async move { + if let Err(e) = + tokio::task::spawn_blocking(move || ctx.refresh_wallet_info(wallet)) + .await + .map_err(|e| format!("Task join error: {}", e)) + .and_then(|r| r.map(|_| ())) + { + tracing::warn!("Failed to auto-refresh wallet UTXOs on startup: {}", e); + } + }); + } + + let single_key_wallets: Vec<_> = { + let guard = self.single_key_wallets.read().unwrap(); + guard.values().cloned().collect() + }; + for wallet in single_key_wallets { + let ctx = Arc::clone(self); + self.subtasks.spawn_sync(async move { + if let Err(e) = tokio::task::spawn_blocking(move || { + ctx.refresh_single_key_wallet_info(wallet) + }) + .await + .map_err(|e| format!("Task join error: {}", e)) + .and_then(|r| r) + { + tracing::warn!( + "Failed to auto-refresh single key wallet UTXOs on startup: {}", + e + ); + } + }); + } + } + } + + /// Update wallet platform address info from SDK-returned AddressInfos. + /// This uses the proof-verified data from SDK operations rather than fetching. + pub(crate) fn update_wallet_platform_address_info_from_sdk( + &self, + seed_hash: WalletSeedHash, + address_infos: &dash_sdk::query_types::AddressInfos, + ) -> Result<(), String> { + let wallet_arc = { + let wallets = self.wallets.read().unwrap(); + wallets + .get(&seed_hash) + .cloned() + .ok_or_else(|| "Wallet not found".to_string())? + }; + + let mut wallet = wallet_arc.write().map_err(|e| e.to_string())?; + + for (platform_addr, maybe_info) in address_infos.iter() { + if let Some(info) = maybe_info { + // Convert PlatformAddress to core Address using the network + let core_addr = platform_addr.to_address_with_network(self.network); + + // Update in-memory wallet state + wallet.set_platform_address_info(core_addr.clone(), info.balance, info.nonce); + + // Update database (not a sync operation - preserve last_full_sync_balance + // so the next terminal sync can correctly apply any pending AddToCredits) + if let Err(e) = self.db.set_platform_address_info( + &seed_hash, + &core_addr, + info.balance, + info.nonce, + &self.network, + false, // Not a sync operation + ) { + tracing::warn!("Failed to store Platform address info in database: {}", e); + } + + tracing::debug!( + "Updated platform address {} balance={} nonce={} from SDK response", + core_addr, + info.balance, + info.nonce + ); + } + } + + Ok(()) + } + + pub(crate) fn register_spv_address( + &self, + wallet: &Arc>, + address: Address, + derivation_path: DerivationPath, + path_type: DerivationPathType, + path_reference: DerivationPathReference, + ) -> Result { + let mut guard = wallet.write().map_err(|e| e.to_string())?; + if guard.known_addresses.contains_key(&address) { + return Ok(false); + } + + let (path_reference, path_type) = + self.classify_derivation_metadata(&derivation_path, path_reference, path_type); + + let seed_hash = guard.seed_hash(); + + self.db + .add_address_if_not_exists( + &seed_hash, + &address, + &self.network, + &derivation_path, + path_reference, + path_type, + None, + ) + .map_err(|e| e.to_string())?; + + guard + .known_addresses + .insert(address.clone(), derivation_path.clone()); + guard.watched_addresses.insert( + derivation_path, + WalletAddressInfo { + address, + path_type, + path_reference, + }, + ); + + Ok(true) + } + + pub(crate) fn wallet_network_key(&self) -> WalletNetwork { + match self.network { + Network::Dash => WalletNetwork::Dash, + Network::Testnet => WalletNetwork::Testnet, + Network::Devnet => WalletNetwork::Devnet, + Network::Regtest => WalletNetwork::Regtest, + _ => WalletNetwork::Dash, + } + } + + fn sync_spv_account_addresses( + &self, + wallet_info: &ManagedWalletInfo, + wallet_arc: &Arc>, + ) { + let collection = wallet_info.accounts(); + + let mut inserted = 0u32; + for account in collection.all_accounts() { + let account_type = account.account_type.to_account_type(); + let Some((path_reference, path_type)) = Self::spv_account_metadata(&account_type) + else { + continue; + }; + + for address in account.account_type.all_addresses() { + if let Some(info) = account.get_address_info(&address) + && let Ok(true) = self.register_spv_address( + wallet_arc, + address.clone(), + info.path.clone(), + path_type, + path_reference, + ) + { + inserted += 1; + } + } + } + + if inserted > 0 { + tracing::debug!(added = inserted, "Registered SPV-managed addresses"); + } + } + + fn spv_account_metadata( + account_type: &AccountType, + ) -> Option<(DerivationPathReference, DerivationPathType)> { + match account_type { + AccountType::IdentityRegistration => Some(( + DerivationPathReference::BlockchainIdentityCreditRegistrationFunding, + DerivationPathType::CREDIT_FUNDING, + )), + AccountType::IdentityInvitation => Some(( + DerivationPathReference::BlockchainIdentityCreditInvitationFunding, + DerivationPathType::CREDIT_FUNDING, + )), + AccountType::IdentityTopUp { .. } | AccountType::IdentityTopUpNotBoundToIdentity => { + Some(( + DerivationPathReference::BlockchainIdentityCreditTopupFunding, + DerivationPathType::CREDIT_FUNDING, + )) + } + AccountType::Standard { .. } => Some(( + DerivationPathReference::BIP44, + DerivationPathType::CLEAR_FUNDS, + )), + _ => None, + } + } + + fn classify_derivation_metadata( + &self, + derivation_path: &DerivationPath, + default_ref: DerivationPathReference, + default_type: DerivationPathType, + ) -> (DerivationPathReference, DerivationPathType) { + let components = derivation_path.as_ref(); + if components.len() >= 5 + && matches!(components[0], ChildNumber::Hardened { index: 9 }) + && matches!(components[2], ChildNumber::Hardened { index: 5 }) + && matches!(components[3], ChildNumber::Hardened { .. }) + { + let hardened_leaf = matches!(components.last(), Some(ChildNumber::Hardened { .. })); + if !hardened_leaf { + return ( + DerivationPathReference::BlockchainIdentities, + DerivationPathType::SINGLE_USER_AUTHENTICATION, + ); + } + } + + (default_ref, default_type) + } + + /// Listen for SPV instant lock / chain lock events and populate + /// transactions_waiting_for_finality so identity registration can proceed. + pub fn spv_setup_finality_listener(self: &Arc) { + let rx = self.spv_manager.register_finality_channel(); + let ctx = Arc::clone(self); + self.subtasks.spawn_sync(async move { + tokio::pin!(rx); + while let Some(event) = rx.recv().await { + if let Err(e) = ctx.handle_spv_finality_event(event).await { + tracing::debug!("SPV finality event error: {}", e); + } + } + }); + } + + async fn handle_spv_finality_event(&self, event: AssetLockFinalityEvent) -> Result<(), String> { + match event { + AssetLockFinalityEvent::InstantLock { txid, instant_lock } => { + // Check if this txid is pending in transactions_waiting_for_finality + let is_pending = { + let transactions = self.transactions_waiting_for_finality.lock().unwrap(); + matches!(transactions.get(&txid), Some(None)) + }; + if !is_pending { + return Ok(()); + } + + // Retrieve the full transaction from the database + let (tx, ..) = self + .db + .get_asset_lock_transaction(txid.as_byte_array()) + .map_err(|e| format!("DB error: {}", e))? + .ok_or_else(|| "Asset lock transaction not found in DB".to_string())?; + + self.received_asset_lock_finality(&tx, Some(*instant_lock), None) + .map_err(|e| format!("Finality processing error: {}", e))?; + } + AssetLockFinalityEvent::ChainLock { + height: _height, .. + } => { + // Get all pending txids (where proof is None) + let pending_txids: Vec = { + let transactions = self.transactions_waiting_for_finality.lock().unwrap(); + transactions + .iter() + .filter_map( + |(txid, proof)| if proof.is_none() { Some(*txid) } else { None }, + ) + .collect() + }; + if pending_txids.is_empty() { + return Ok(()); + } + + let sdk = { + let guard = self.sdk.read().map_err(|_| "SDK lock poisoned")?; + guard.clone() + }; + + for txid in pending_txids { + match get_transaction_info(&sdk, &txid).await { + Ok(tx_info) if tx_info.is_chain_locked && tx_info.height > 0 => { + if let Ok(Some((tx, ..))) = + self.db.get_asset_lock_transaction(txid.as_byte_array()) + { + let _ = self.received_asset_lock_finality( + &tx, + None, + Some(tx_info.height), + ); + } + } + _ => { + // Transaction not yet chain-locked at this height, or DAPI + // lookup failed — will retry on next chain lock event. + } + } + } + } + } + Ok(()) + } + + /// Subscribe to SPV reconcile signals and debounce updates. + pub fn spv_setup_reconcile_listener(self: &Arc) { + use tokio::time::{Duration, Instant, sleep}; + let rx = self.spv_manager.register_reconcile_channel(); + let ctx = Arc::clone(self); + self.subtasks.spawn_sync(async move { + tokio::pin!(rx); + let mut last = Instant::now(); + loop { + tokio::select! { + maybe = rx.recv() => { + if maybe.is_none() { break; } + // simple debounce window + if last.elapsed() > Duration::from_millis(300) { + if let Err(e) = ctx.reconcile_spv_wallets().await { tracing::debug!("SPV reconcile error: {}", e); } + last = Instant::now(); + } else { + sleep(Duration::from_millis(300)).await; + if let Err(e) = ctx.reconcile_spv_wallets().await { tracing::debug!("SPV reconcile error: {}", e); } + last = Instant::now(); + } + } + } + } + }); + } + + /// Reconcile SPV wallet state into DET. + pub async fn reconcile_spv_wallets(&self) -> Result<(), String> { + let wm_arc = self.spv_manager.wallet(); + let wm = wm_arc.read().await; + let mapping = self.spv_manager.det_wallets_snapshot(); + + // Take a snapshot of known addresses per wallet so we can scope DB updates + let wallets_guard = self.wallets.read().unwrap(); + + for (seed_hash, wallet_id) in mapping.iter() { + // Log total balance for visibility + let balance = wm + .get_wallet_balance(wallet_id) + .map_err(|e| format!("get_wallet_balance failed: {e}"))?; + tracing::debug!(wallet = %hex::encode(seed_hash), spendable = balance.spendable(), unconfirmed = balance.unconfirmed(), total = balance.total(), "SPV balance snapshot"); + + let Some(wallet_info) = wm.get_wallet_info(wallet_id) else { + continue; + }; + + let Some(wallet_arc) = wallets_guard.get(seed_hash).cloned() else { + continue; + }; + + self.sync_spv_account_addresses(wallet_info, &wallet_arc); + + if let Ok(mut wallet) = wallet_arc.write() { + wallet.update_spv_balances( + balance.spendable(), + balance.unconfirmed(), + balance.total(), + ); + // Persist balances to database + if let Err(e) = self.db.update_wallet_balances( + seed_hash, + balance.spendable(), + balance.unconfirmed(), + balance.total(), + ) { + tracing::warn!(wallet = %hex::encode(seed_hash), error = %e, "Failed to persist wallet balances"); + } + } + + // Get the wallet's known addresses (only update those to avoid cross-wallet churn) + let mut known_addresses: std::collections::BTreeSet
= { + let w = wallet_arc.read().unwrap(); + w.known_addresses.keys().cloned().collect() + }; + + // Clear existing UTXOs for these addresses in this network + for addr in &known_addresses { + let _ = self.db.execute( + "DELETE FROM utxos WHERE address = ? AND network = ?", + rusqlite::params![addr.to_string(), self.network.to_string()], + ); + } + + // Read current UTXOs from SPV and re-insert, registering unknown addresses if derivation metadata is available + let utxos = wm + .wallet_utxos(wallet_id) + .map_err(|e| format!("wallet_utxos failed: {e}"))?; + + let mut per_address_sum: std::collections::BTreeMap = Default::default(); + // Build in-memory UTXO map to update wallet model + let mut new_utxos: std::collections::HashMap< + Address, + std::collections::HashMap< + dash_sdk::dpp::dashcore::OutPoint, + dash_sdk::dpp::dashcore::TxOut, + >, + > = Default::default(); + + for u in utxos { + let outpoint = u.outpoint; + let tx_out = u.txout.clone(); + + // Derive address from script + let address = match Address::from_script(&tx_out.script_pubkey, self.network) { + Ok(a) => a, + Err(_) => continue, + }; + + // Always track the UTXO in the in-memory map for correct balance calculation + new_utxos + .entry(address.clone()) + .or_default() + .insert(outpoint, tx_out.clone()); + + // Always count the UTXO value in per-address sum + *per_address_sum.entry(address.clone()).or_default() += tx_out.value; + + // If address unknown to DET, try to register using SPV metadata + if !known_addresses.contains(&address) { + let collection = wallet_info.accounts(); + let mut registered = false; + for acc in collection.all_accounts() { + if let Some(ai) = acc.get_address_info(&address) { + let account_type = acc.account_type.to_account_type(); + let (path_reference, path_type) = + Self::spv_account_metadata(&account_type).unwrap_or_else(|| { + let default_ref = if ai.path.is_bip44(self.network) { + DerivationPathReference::BIP44 + } else if ai.path.is_bip32() { + DerivationPathReference::BIP32 + } else { + tracing::warn!( + path = %ai.path, + "SPV address has unrecognized derivation path structure" + ); + DerivationPathReference::Unknown + }; + (default_ref, DerivationPathType::CLEAR_FUNDS) + }); + + if let Ok(inserted) = self.register_spv_address( + &wallet_arc, + address.clone(), + ai.path.clone(), + path_type, + path_reference, + ) { + if inserted { + known_addresses.insert(address.clone()); + } + registered = true; + } + break; + } + } + if !registered { + tracing::debug!( + wallet = %hex::encode(seed_hash), + address = %address, + value = tx_out.value, + "SPV UTXO address not registered in DET (counted in balance but not in address table)" + ); + // Still persist the UTXO to DB and delete stale entry first + let _ = self.db.execute( + "DELETE FROM utxos WHERE address = ? AND network = ?", + rusqlite::params![address.to_string(), self.network.to_string()], + ); + } + } + + // Insert UTXO row into DB + self.db + .insert_utxo( + outpoint.txid.as_ref(), + outpoint.vout, + &address, + tx_out.value, + &tx_out.script_pubkey.to_bytes(), + self.network, + ) + .map_err(|e| e.to_string())?; + } + + // Write per-address balances and UTXOs into wallet model + if let Some(wref) = wallets_guard.get(seed_hash) + && let Ok(mut w) = wref.write() + { + // Update in-memory UTXOs map + w.utxos = new_utxos; + + for (addr, sum) in per_address_sum.into_iter() { + // Update wallet and DB through model helper + let _ = w.update_address_balance(&addr, sum, self); + } + } + + let history = wm + .wallet_transaction_history(wallet_id) + .map_err(|e| format!("wallet_transaction_history failed: {e}"))?; + let wallet_transactions: Vec = history + .into_iter() + .map(|record| WalletTransaction { + txid: record.txid, + transaction: record.transaction.clone(), + timestamp: record.timestamp, + height: record.height, + block_hash: record.block_hash, + net_amount: record.net_amount, + fee: record.fee, + label: record.label.clone(), + is_ours: record.is_ours, + }) + .collect(); + + tracing::info!( + wallet = %hex::encode(seed_hash), + spv_transactions = wallet_transactions.len(), + spv_spendable = balance.spendable(), + spv_total = balance.total(), + "SPV reconcile summary" + ); + + // Only replace transactions if SPV returned some, to avoid wiping + // previously persisted history when SPV hasn't populated history yet. + if !wallet_transactions.is_empty() { + self.db + .replace_wallet_transactions(seed_hash, &self.network, &wallet_transactions) + .map_err(|e| e.to_string())?; + } + + if let Some(wref) = wallets_guard.get(seed_hash) + && let Ok(mut wallet) = wref.write() + && !wallet_transactions.is_empty() + { + wallet.set_transactions(wallet_transactions); + } + } + + Ok(()) + } + + pub fn stop_spv(&self) { + self.spv_manager.stop(); + } +} diff --git a/src/context_provider.rs b/src/context_provider.rs index 483ae7469..65e931466 100644 --- a/src/context_provider.rs +++ b/src/context_provider.rs @@ -27,17 +27,19 @@ impl Provider { network: Network, config: &NetworkConfig, ) -> Result { - let cookie_path = - core_cookie_path(network, &config.devnet_name).expect("Failed to get core cookie path"); + let cookie_path = core_cookie_path(network, &config.devnet_name) + .map_err(|e| format!("Failed to get core cookie path: {}", e))?; // Read the cookie from disk let cookie = std::fs::read_to_string(cookie_path); let (user, pass) = if let Ok(cookie) = cookie { + let cookie = cookie.trim(); // split the cookie at ":", first part is user (__cookie__), second part is password - let cookie_parts: Vec<&str> = cookie.split(':').collect(); - let user = cookie_parts[0]; - let password = cookie_parts[1]; - (user.to_string(), password.to_string()) + if let Some((user, password)) = cookie.split_once(':') { + (user.to_string(), password.to_string()) + } else { + return Err("Malformed cookie file: expected 'user:password' format".to_string()); + } } else { // Fall back to the pre-set user / pass if needed ( diff --git a/src/logging.rs b/src/logging.rs index 1e22463c5..c001b9b2c 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -13,27 +13,45 @@ pub fn initialize_logger() { } fn initialize_logger_internal() { - // Initialize log file, with improved error handling - let log_file_path = app_user_data_file_path("det.log").expect("should create log file path"); - let log_file = match std::fs::File::create(&log_file_path) { - Ok(file) => file, - Err(e) => panic!("Failed to create log file: {:?}", e), - }; let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| { EnvFilter::try_new( "info,dash_evo_tool=trace,dash_sdk=debug,dash_sdk::platform::transition=trace,tenderdash_abci=debug,drive=debug,drive_proof_verifier=debug,rs_dapi_client=debug,h2=warn,dash_spv=debug", ) - .unwrap_or_else(|e| panic!("Failed to create EnvFilter: {:?}", e)) + .unwrap_or_else(|_| EnvFilter::new("info")) }); - let subscriber = tracing_subscriber::fmt() - .with_env_filter(filter) - .with_writer(log_file) - .with_ansi(false) - .finish(); + // Try to create a log file; fall back to stderr if it fails + let log_file_result = app_user_data_file_path("det.log").and_then(std::fs::File::create); + + let (subscriber_set, log_file_path_for_msg) = match log_file_result { + Ok(log_file) => { + let subscriber = tracing_subscriber::fmt() + .with_env_filter(filter) + .with_writer(log_file) + .with_ansi(false) + .finish(); + let set = tracing::subscriber::set_global_default(subscriber).is_ok(); + (set, Some(app_user_data_file_path("det.log").ok())) + } + Err(e) => { + // Fall back to stderr logging + let subscriber = tracing_subscriber::fmt() + .with_env_filter(filter) + .with_writer(std::io::stderr) + .with_ansi(true) + .finish(); + let set = tracing::subscriber::set_global_default(subscriber).is_ok(); + if set { + eprintln!( + "Warning: Could not create log file, logging to stderr: {}", + e + ); + } + (set, None) + } + }; - // Set global subscriber - ignore error if already set (can happen in tests) - if let Err(_e) = tracing::subscriber::set_global_default(subscriber) { + if !subscriber_set { // Logger already initialized, this is fine return; } @@ -59,9 +77,16 @@ fn initialize_logger_internal() { default_panic_hook(panic_info); })); - info!( - version = VERSION, - log_file = ?log_file_path, - "Dash-Evo-Tool logging initialized successfully" - ); + if let Some(Some(path)) = log_file_path_for_msg { + info!( + version = VERSION, + log_file = ?path, + "Dash-Evo-Tool logging initialized successfully" + ); + } else { + info!( + version = VERSION, + "Dash-Evo-Tool logging initialized (stderr fallback)" + ); + } } diff --git a/src/main.rs b/src/main.rs index 0dae520d3..50d1065db 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,7 +36,7 @@ fn load_icon() -> egui::IconData { let image = image::imageops::resize(&image, 64, 64, image::imageops::FilterType::Lanczos3); let (width, height) = image.dimensions(); egui::IconData { - rgba: image.into_raw(), + rgba: image.to_vec(), width, height, } diff --git a/src/model/qualified_identity/encrypted_key_storage.rs b/src/model/qualified_identity/encrypted_key_storage.rs index a19eed6d7..055b154a6 100644 --- a/src/model/qualified_identity/encrypted_key_storage.rs +++ b/src/model/qualified_identity/encrypted_key_storage.rs @@ -54,8 +54,8 @@ impl Encode for WalletDerivationPath { } } -impl Decode for WalletDerivationPath { - fn decode(decoder: &mut D) -> Result { +impl Decode for WalletDerivationPath { + fn decode>(decoder: &mut D) -> Result { // Decode `wallet_seed_hash` let wallet_seed_hash = WalletSeedHash::decode(decoder)?; @@ -92,8 +92,10 @@ impl Decode for WalletDerivationPath { } } -impl<'de, Context> BorrowDecode<'de, Context> for WalletDerivationPath { - fn borrow_decode>(decoder: &mut D) -> Result { +impl<'de, C> BorrowDecode<'de, C> for WalletDerivationPath { + fn borrow_decode>( + decoder: &mut D, + ) -> Result { // Decode `wallet_seed_hash` let wallet_seed_hash = WalletSeedHash::decode(decoder)?; diff --git a/src/model/qualified_identity/mod.rs b/src/model/qualified_identity/mod.rs index 4152843ab..510d10f0d 100644 --- a/src/model/qualified_identity/mod.rs +++ b/src/model/qualified_identity/mod.rs @@ -270,8 +270,8 @@ impl Encode for QualifiedIdentity { } // Implement Decode manually for QualifiedIdentity, excluding decrypted_wallets -impl Decode for QualifiedIdentity { - fn decode( +impl Decode for QualifiedIdentity { + fn decode>( decoder: &mut D, ) -> Result { Ok(Self { diff --git a/src/model/wallet/asset_lock_transaction.rs b/src/model/wallet/asset_lock_transaction.rs index 55cf609be..fbc73f1fb 100644 --- a/src/model/wallet/asset_lock_transaction.rs +++ b/src/model/wallet/asset_lock_transaction.rs @@ -95,7 +95,7 @@ impl Wallet { ), String, > { - use rand::rngs::OsRng; + use bip39::rand::rngs::OsRng; // Generate a random private key for the asset lock let secp = Secp256k1::new(); diff --git a/src/model/wallet/mod.rs b/src/model/wallet/mod.rs index f2c3209a9..c2939032c 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -99,6 +99,7 @@ pub trait DerivationPathHelpers { fn is_bip44(&self, network: Network) -> bool; fn is_bip44_external(&self, network: Network) -> bool; fn is_bip44_change(&self, network: Network) -> bool; + fn is_bip32(&self) -> bool; fn is_asset_lock_funding(&self, network: Network) -> bool; fn is_platform_payment(&self, network: Network) -> bool; fn bip44_account_index(&self) -> Option; @@ -139,6 +140,11 @@ impl DerivationPathHelpers for DerivationPath { components.len() >= 5 && components[3] == ChildNumber::Normal { index: 1 } } + fn is_bip32(&self) -> bool { + let components = self.as_ref(); + matches!(components.len(), 2..=3) && components[0] == ChildNumber::Hardened { index: 0 } + } + fn is_asset_lock_funding(&self, network: Network) -> bool { let coin_type = match network { Network::Dash => 5, @@ -1807,6 +1813,37 @@ impl Wallet { .map_err(|e| e.to_string()) } + /// Recalculate and persist balances for all addresses affected by spent UTXOs. + /// + /// Call this after removing entries from `self.utxos` to keep `address_balances` + /// and the database in sync. + pub fn recalculate_affected_address_balances( + &mut self, + used_utxos: &BTreeMap, + context: &AppContext, + ) -> Result<(), String> { + let affected_addresses: BTreeSet<_> = + used_utxos.values().map(|(_, addr)| addr.clone()).collect(); + for address in affected_addresses { + self.recalculate_address_balance(&address, context)?; + } + Ok(()) + } + + /// Recalculate and persist the balance for a single address from its remaining UTXOs. + pub fn recalculate_address_balance( + &mut self, + address: &Address, + context: &AppContext, + ) -> Result<(), String> { + let new_balance = self + .utxos + .get(address) + .map(|utxo_map| utxo_map.values().map(|tx_out| tx_out.value).sum()) + .unwrap_or(0); + self.update_address_balance(address, new_balance, context) + } + pub fn update_address_total_received( &mut self, address: &Address, diff --git a/src/spv/manager.rs b/src/spv/manager.rs index 9d5395664..da4ae699e 100644 --- a/src/spv/manager.rs +++ b/src/spv/manager.rs @@ -4,19 +4,23 @@ use crate::config::NetworkConfig; use crate::model::wallet::WalletSeedHash; use crate::utils::tasks::TaskManager; use dash_sdk::dash_spv::client::interface::{DashSpvClientCommand, DashSpvClientInterface}; +use dash_sdk::dash_spv::network::NetworkEvent; use dash_sdk::dash_spv::network::PeerNetworkManager; use dash_sdk::dash_spv::storage::DiskStorageManager; -use dash_sdk::dash_spv::types::{ - DetailedSyncProgress, SpvEvent, SyncProgress, SyncStage, ValidationMode, -}; +use dash_sdk::dash_spv::sync::SyncEvent; +use dash_sdk::dash_spv::sync::SyncProgress as WatchSyncProgress; +use dash_sdk::dash_spv::sync::SyncState; +use dash_sdk::dash_spv::types::{DetailedSyncProgress, SyncProgress, SyncStage, ValidationMode}; use dash_sdk::dash_spv::{ClientConfig, DashSpvClient, Hash, LLMQType, QuorumHash}; -use dash_sdk::dpp::dashcore::{Address, Network, Transaction}; +use dash_sdk::dpp::dashcore::{Address, InstantLock, Network, Transaction, Txid}; use dash_sdk::dpp::key_wallet::bip32::{DerivationPath, ExtendedPrivKey}; use dash_sdk::dpp::key_wallet::wallet::initialization::WalletAccountCreationOptions; use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::{ ManagedWalletInfo, transaction_building::AccountTypePreference, wallet_info_interface::WalletInfoInterface, }; +use dash_sdk::dpp::key_wallet_manager::WalletEvent; +use dash_sdk::dpp::key_wallet_manager::wallet_interface::WalletInterface; use dash_sdk::dpp::key_wallet_manager::wallet_manager::{WalletError, WalletId, WalletManager}; // use dash_sdk::dpp::key_wallet::bip32::ExtendedPubKey; // not needed directly here use std::fmt; @@ -116,12 +120,24 @@ pub struct SpvStatusSnapshot { pub last_error: Option, pub started_at: Option, pub last_updated: Option, + pub connected_peers: usize, } /// Type alias for the SPV client with our specific configuration type SpvClient = DashSpvClient, PeerNetworkManager, DiskStorageManager>; +/// Events forwarded from SPV to AppContext for asset lock proof construction. +pub(crate) enum AssetLockFinalityEvent { + InstantLock { + txid: Txid, + instant_lock: Box, + }, + ChainLock { + height: u32, + }, +} + /// Manages SPV client lifecycle and exposes status updates. /// Uses dash-spv's built-in state management while maintaining a dedicated runtime for performance. /// @@ -147,6 +163,8 @@ pub struct SpvManager { det_wallets: Arc>>, // signal channel to trigger external reconcile on wallet-related events reconcile_tx: Mutex>>, + // signal channel to forward instant lock / chain lock events for asset lock proof construction + finality_tx: Mutex>>, // Whether to use local Dash Core node instead of DNS seed discovery use_local_node: Arc, // Cancellation token for clean shutdown @@ -155,6 +173,8 @@ pub struct SpvManager { request_tx: Mutex>>, // Network manager clone for broadcasting transactions (set when client is running) network_manager: Arc>>, + // Number of currently connected SPV peers + connected_peers: Arc>, } /// Requests that can be sent to the SPV runtime thread @@ -305,10 +325,12 @@ impl SpvManager { progress_updated_at: Arc::new(RwLock::new(None)), det_wallets: Arc::new(RwLock::new(std::collections::BTreeMap::new())), reconcile_tx: Mutex::new(None), + finality_tx: Mutex::new(None), use_local_node: Arc::new(AtomicBool::new(false)), stop_token: Mutex::new(None), request_tx: Mutex::new(None), network_manager: Arc::new(AsyncRwLock::new(None)), + connected_peers: Arc::new(RwLock::new(0)), }); Ok(manager) @@ -337,6 +359,7 @@ impl SpvManager { .read_progress_updated_at() .unwrap_or(None) .or(Some(SystemTime::now())); + let connected_peers = self.connected_peers.read().map(|g| *g).unwrap_or(0); SpvStatusSnapshot { status, @@ -345,6 +368,7 @@ impl SpvManager { last_error, started_at, last_updated, + connected_peers, } } @@ -360,6 +384,7 @@ impl SpvManager { .read_progress_updated_at() .unwrap_or(None) .or(Some(SystemTime::now())); + let connected_peers = self.connected_peers.read().map(|g| *g).unwrap_or(0); SpvStatusSnapshot { status, @@ -368,10 +393,11 @@ impl SpvManager { last_error, started_at, last_updated, + connected_peers, } } - pub fn start(self: &Arc) -> Result<(), String> { + pub fn start(self: &Arc, expected_wallet_count: usize) -> Result<(), String> { // Check if already running { let stop_token_guard = self @@ -420,7 +446,7 @@ impl SpvManager { rt.block_on(async move { let manager_for_loop = Arc::clone(&manager); - if let Err(err) = manager_for_loop.run_spv_loop(stop_token, global_cancel).await { + if let Err(err) = manager_for_loop.run_spv_loop(stop_token, global_cancel, expected_wallet_count).await { tracing::error!(error = %err, network = ?manager.network, "SPV runtime failed"); if let Err(e) = manager.write_last_error(Some(err.clone())) { tracing::error!("Failed to write SPV error: {}", e); @@ -518,6 +544,9 @@ impl SpvManager { /// Create a reconciliation signal channel for external listeners. /// Returns a receiver that will get a signal when SPV wallet state likely changed. + /// + /// Only one subscriber is supported at a time. Calling this again replaces + /// the previous sender, so the earlier receiver will stop receiving signals. pub fn register_reconcile_channel(&self) -> mpsc::Receiver<()> { let (tx, rx) = mpsc::channel(64); if let Ok(mut guard) = self.reconcile_tx.lock() { @@ -526,6 +555,19 @@ impl SpvManager { rx } + /// Create a finality event channel for external listeners. + /// Returns a receiver that will get events when SPV detects instant locks or chain locks. + /// + /// Only one subscriber is supported at a time. Calling this again replaces + /// the previous sender, so the earlier receiver will stop receiving events. + pub(crate) fn register_finality_channel(&self) -> mpsc::Receiver { + let (tx, rx) = mpsc::channel(64); + if let Ok(mut guard) = self.finality_tx.lock() { + *guard = Some(tx); + } + rx + } + /// Remove all cached SPV data on disk for the current network. /// /// This requires the SPV runtime to be stopped first; otherwise the @@ -552,6 +594,17 @@ impl SpvManager { wallet_map.clear(); } + // Reset the in-memory WalletManager's synced_height so the next SPV session + // scans filters from genesis instead of the stale height from the previous run. + match self.wallet.try_write() { + Ok(mut wm) => { + wm.update_synced_height(0); + } + Err(_) => { + tracing::warn!("Failed to reset WalletManager synced_height during SPV data clear"); + } + } + self.write_sync_progress(None).map_err(|e| e.to_string())?; self.write_detailed_progress(None) .map_err(|e| e.to_string())?; @@ -561,6 +614,9 @@ impl SpvManager { self.write_last_error(None).map_err(|e| e.to_string())?; self.write_status(SpvStatus::Idle) .map_err(|e| e.to_string())?; + if let Ok(mut guard) = self.connected_peers.write() { + *guard = 0; + } if self.data_dir.exists() { fs::remove_dir_all(&self.data_dir).map_err(|e| { @@ -756,9 +812,49 @@ impl SpvManager { self: Arc, stop_token: CancellationToken, global_cancel: CancellationToken, + expected_wallet_count: usize, ) -> Result<(), String> { + // Wait for all expected wallets to be fully loaded into the WalletManager + // before building the client. Wallet loading happens via queue_spv_wallet_load + // which calls import_wallet_from_extended_priv_key (derives all accounts and + // addresses) under a write lock. Once wallet_count() reaches the expected + // value, all addresses are derived and monitored_addresses() is populated. + if expected_wallet_count > 0 { + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + { + let wm = self.wallet.read().await; + let loaded = wm.wallet_count(); + if loaded >= expected_wallet_count { + let addr_count = wm.monitored_addresses().len(); + tracing::info!( + expected = expected_wallet_count, + loaded, + addresses = addr_count, + "SPV: all wallets loaded with monitored addresses, proceeding" + ); + break; + } + } + if tokio::time::Instant::now() >= deadline { + let wm = self.wallet.read().await; + tracing::warn!( + expected = expected_wallet_count, + loaded = wm.wallet_count(), + "SPV: timed out waiting for all wallets to load, proceeding anyway" + ); + break; + } + if stop_token.is_cancelled() || global_cancel.is_cancelled() { + return Ok(()); + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + } + // Build and start the client - let mut client = self.build_client().await?; + let has_wallets = expected_wallet_count > 0; + let mut client = self.build_client(has_wallets).await?; client .start() .await @@ -772,16 +868,25 @@ impl SpvManager { } } - // Set up progress handler - if let Some(progress_rx) = client.take_progress_receiver() { - self.spawn_progress_handler(progress_rx); - } + // Subscribe to sync events (broadcast) + let sync_rx = client.subscribe_sync_events(); + self.spawn_sync_event_handler(sync_rx); - // Set up event handler - if let Some(event_rx) = client.take_event_receiver() { - self.spawn_event_handler(event_rx); + // Subscribe to wallet events (broadcast from WalletManager) + { + let wm = self.wallet.read().await; + let wallet_rx = wm.subscribe_events(); + self.spawn_wallet_event_handler(wallet_rx); } + // Subscribe to network events (broadcast) + let net_rx = client.subscribe_network_events(); + self.spawn_network_event_handler(net_rx); + + // Set up progress handler using watch channel + let progress_rx = client.subscribe_progress(); + self.spawn_progress_watcher(progress_rx); + // Set up request handler with access to shared components let (request_tx, request_rx) = mpsc::channel(32); { @@ -826,6 +931,9 @@ impl SpvManager { let mut nm_guard = self.network_manager.write().await; *nm_guard = None; } + if let Ok(mut guard) = self.connected_peers.write() { + *guard = 0; + } { // Drop shared storage/request handles so the disk lock is released before restart. if let Ok(mut storage_guard) = self.storage.lock() { @@ -952,73 +1060,226 @@ impl SpvManager { }); } - fn spawn_progress_handler( + fn spawn_progress_watcher( &self, - mut progress_rx: tokio::sync::mpsc::UnboundedReceiver, + mut progress_rx: tokio::sync::watch::Receiver, ) { let status = Arc::clone(&self.status); - let last_error = Arc::clone(&self.last_error); let sync_progress_state = Arc::clone(&self.sync_progress_state); let detailed_progress_state = Arc::clone(&self.detailed_progress_state); let progress_updated_at = Arc::clone(&self.progress_updated_at); let cancel = self.subtasks.cancellation_token.clone(); self.subtasks.spawn_sync(async move { - let mut last_update = std::time::Instant::now(); - let min_interval = std::time::Duration::from_millis(500); + loop { + tokio::select! { + _ = cancel.cancelled() => break, + result = progress_rx.changed() => { + if result.is_err() { + break; // Channel closed + } + let watch_progress = progress_rx.borrow(); + + // Extract all available heights from WatchSyncProgress + let header_height = watch_progress + .headers() + .map(|h| h.current_height()) + .unwrap_or(0); + let masternode_height = watch_progress + .masternodes() + .map(|m| m.current_height()) + .unwrap_or(0); + let filter_header_height = watch_progress + .filter_headers() + .map(|fh| fh.current_height()) + .unwrap_or(0); + + let sync_progress = SyncProgress { + header_height, + masternode_height, + filter_header_height, + ..Default::default() + }; + + // Build detailed progress with sync stage information + let peer_best_height = watch_progress + .headers() + .map(|h| h.target_height()) + .unwrap_or(0); + let sync_stage = Self::determine_sync_stage(&watch_progress); + let detailed = DetailedSyncProgress { + sync_progress: sync_progress.clone(), + peer_best_height, + percentage: if peer_best_height > 0 { + (header_height as f64 / peer_best_height as f64 * 100.0).min(100.0) + } else { + 0.0 + }, + headers_per_second: 0.0, + bytes_per_second: 0, + estimated_time_remaining: None, + sync_stage, + total_headers_processed: 0, + total_bytes_downloaded: 0, + sync_start_time: SystemTime::now(), + last_update_time: SystemTime::now(), + }; + + // Update sync progress state + if let Ok(mut stored_sync) = sync_progress_state.write() { + *stored_sync = Some(sync_progress); + } + if let Ok(mut stored_detailed) = detailed_progress_state.write() { + *stored_detailed = Some(detailed); + } + if let Ok(mut updated_at) = progress_updated_at.write() { + *updated_at = Some(SystemTime::now()); + } + + // Update status based on progress + if let Ok(mut status_guard) = status.write() { + if watch_progress.is_synced() { + *status_guard = SpvStatus::Running; + } else if !matches!(*status_guard, SpvStatus::Stopping | SpvStatus::Stopped | SpvStatus::Error) { + *status_guard = SpvStatus::Syncing; + } + } + } + } + } + tracing::info!("SPV progress watcher exiting"); + }); + } + + /// Map the parallel WatchSyncProgress managers into a single UI-facing SyncStage. + /// + /// The parallel sync system has independent managers for headers, masternodes, + /// filter-headers, filters, and blocks. We map to a single stage by checking + /// which manager is actively syncing, preferring later pipeline stages. + fn determine_sync_stage(watch: &WatchSyncProgress) -> SyncStage { + // Check stages from latest to earliest in the pipeline + if let Ok(blocks) = watch.blocks() + && blocks.state() == SyncState::Syncing + { + return SyncStage::DownloadingBlocks { + pending: blocks.requested().saturating_sub(blocks.processed()) as usize, + }; + } + if let Ok(filters) = watch.filters() + && filters.state() == SyncState::Syncing + { + return SyncStage::DownloadingFilters { + completed: filters.downloaded(), + total: filters + .target_height() + .saturating_sub(filters.current_height()), + }; + } + if let Ok(fh) = watch.filter_headers() + && fh.state() == SyncState::Syncing + { + return SyncStage::DownloadingFilterHeaders { + current: fh.current_height(), + target: fh.target_height(), + }; + } + if let Ok(mn) = watch.masternodes() + && mn.state() == SyncState::Syncing + { + return SyncStage::ValidatingHeaders { + batch_size: mn.diffs_processed() as usize, + }; + } + if let Ok(headers) = watch.headers() + && headers.state() == SyncState::Syncing + { + return SyncStage::DownloadingHeaders { + start: 0, + end: headers.target_height(), + }; + } + + if watch.is_synced() { + SyncStage::Complete + } else { + SyncStage::Connecting + } + } + fn spawn_sync_event_handler(&self, mut sync_rx: tokio::sync::broadcast::Receiver) { + let reconcile_tx = self.reconcile_tx.lock().ok().and_then(|g| g.clone()); + let finality_tx = self.finality_tx.lock().ok().and_then(|g| g.clone()); + let status = Arc::clone(&self.status); + let cancel = self.subtasks.cancellation_token.clone(); + + self.subtasks.spawn_sync(async move { loop { tokio::select! { _ = cancel.cancelled() => break, - msg = progress_rx.recv() => { - match msg { - Some(detailed) => { - if let Ok(mut stored_detailed) = detailed_progress_state.write() { - *stored_detailed = Some(detailed.clone()); - } - if let Ok(mut stored_sync) = sync_progress_state.write() { - *stored_sync = Some(detailed.sync_progress.clone()); - } - if let Ok(mut updated_at) = progress_updated_at.write() { - *updated_at = Some(detailed.last_update_time); - } + result = sync_rx.recv() => { + match result { + Ok(event) => { + let should_signal = matches!( + event, + SyncEvent::BlockProcessed { .. } + | SyncEvent::ChainLockReceived { .. } + | SyncEvent::InstantLockReceived { .. } + | SyncEvent::SyncComplete { .. } + ); - if last_update.elapsed() >= min_interval { - // Update status based on progress stage and completeness - if let Ok(mut status_guard) = status.write() { - let current = *status_guard; - match &detailed.sync_stage { - SyncStage::Complete => { - *status_guard = SpvStatus::Running; - } - SyncStage::Failed(message) => { - *status_guard = SpvStatus::Error; - if let Ok(mut err_guard) = last_error.write() { - *err_guard = Some(format!("SPV sync failed: {message}")); - } + // Forward finality-relevant events for asset lock proof construction + if let Some(ref ftx) = finality_tx { + match &event { + SyncEvent::InstantLockReceived { instant_lock, .. } => { + if let Err(e) = ftx.try_send(AssetLockFinalityEvent::InstantLock { + txid: instant_lock.txid, + instant_lock: Box::new(instant_lock.clone()), + }) { + tracing::warn!("Failed to forward InstantLock finality event for txid {}: {}", instant_lock.txid, e); } - _ => { - if !matches!( - current, - SpvStatus::Stopping | SpvStatus::Stopped | SpvStatus::Error - ) { - *status_guard = SpvStatus::Syncing; - } + } + SyncEvent::ChainLockReceived { chain_lock, .. } => { + if let Err(e) = ftx.try_send(AssetLockFinalityEvent::ChainLock { + height: chain_lock.block_height, + }) { + tracing::warn!("Failed to forward ChainLock finality event for height {}: {}", chain_lock.block_height, e); } } + _ => {} } - last_update = std::time::Instant::now(); + } + + if matches!(event, SyncEvent::SyncComplete { .. }) + && let Ok(mut guard) = status.write() + { + *guard = SpvStatus::Running; + } + if should_signal + && let Some(ref tx) = reconcile_tx + { + let _ = tx.try_send(()); } } - None => break, + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + tracing::warn!("Sync event handler lagged by {} events", n); + // Trigger reconcile to catch up on any missed state changes + if let Some(ref tx) = reconcile_tx { + let _ = tx.try_send(()); + } + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, } } } } + tracing::info!("SPV sync event handler exiting"); }); } - fn spawn_event_handler(&self, mut event_rx: tokio::sync::mpsc::UnboundedReceiver) { + fn spawn_wallet_event_handler( + &self, + mut wallet_rx: tokio::sync::broadcast::Receiver, + ) { let reconcile_tx = self.reconcile_tx.lock().ok().and_then(|g| g.clone()); let cancel = self.subtasks.cancellation_token.clone(); @@ -1026,41 +1287,79 @@ impl SpvManager { loop { tokio::select! { _ = cancel.cancelled() => break, - evt = event_rx.recv() => { - match evt { - Some(event) => { - // Push reconcile signal for wallet-related updates - let should_signal = matches!(event, - SpvEvent::TransactionDetected { .. } | - SpvEvent::BalanceUpdate { .. } | - SpvEvent::BlockProcessed { .. } - ); - if should_signal - && let Some(ref tx) = reconcile_tx { + result = wallet_rx.recv() => { + match result { + Ok(_event) => { + // All wallet events trigger reconcile + if let Some(ref tx) = reconcile_tx { let _ = tx.try_send(()); } } - None => break, + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + tracing::warn!("Wallet event handler lagged by {} events", n); + // Still trigger reconcile to catch up + if let Some(ref tx) = reconcile_tx { + let _ = tx.try_send(()); + } + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + } + } + tracing::info!("SPV wallet event handler exiting"); + }); + } + + fn spawn_network_event_handler( + &self, + mut net_rx: tokio::sync::broadcast::Receiver, + ) { + let connected_peers = Arc::clone(&self.connected_peers); + let cancel = self.subtasks.cancellation_token.clone(); + + self.subtasks.spawn_sync(async move { + loop { + tokio::select! { + _ = cancel.cancelled() => break, + result = net_rx.recv() => { + match result { + Ok(NetworkEvent::PeersUpdated { connected_count, .. }) => { + if let Ok(mut guard) = connected_peers.write() { + *guard = connected_count; + } + } + Ok(_) => { + // PeerConnected / PeerDisconnected — PeersUpdated follows + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + tracing::warn!("Network event handler lagged by {} events", n); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, } } } } + tracing::info!("SPV network event handler exiting"); }); } async fn build_client( &self, + has_wallets: bool, ) -> Result< DashSpvClient, PeerNetworkManager, DiskStorageManager>, String, > { - let start_height = { - let guard = self.wallet.read().await; - if guard.wallet_count() == 0 { - u32::MAX - } else { - 0 - } + // When wallets exist, scan from genesis so historical transactions are found via + // compact block filters. When no wallets are loaded, skip to chain tip (u32::MAX) + // to avoid unnecessary work. We check both the caller hint and the actual wallet + // count (the wait loop in run_spv_loop should have ensured loading completed). + let wallet_count = self.wallet.read().await.wallet_count(); + let start_height = if has_wallets || wallet_count > 0 { + 0 + } else { + u32::MAX }; let mut config = ClientConfig::new(self.network) .with_storage_path(self.data_dir.clone()) @@ -1093,7 +1392,7 @@ impl SpvManager { *nm_guard = Some(network_manager.clone()); } - let storage_manager = DiskStorageManager::new(self.data_dir.clone()) + let storage_manager = DiskStorageManager::new(&config) .await .map_err(|e| format!("Failed to initialize SPV storage: {e}"))?; diff --git a/src/spv/mod.rs b/src/spv/mod.rs index 3eebea869..022d2c569 100644 --- a/src/spv/mod.rs +++ b/src/spv/mod.rs @@ -1,5 +1,6 @@ mod error; -mod manager; +pub(crate) mod manager; pub use error::{SpvError, SpvResult}; +pub(crate) use manager::AssetLockFinalityEvent; pub use manager::{CoreBackendMode, SpvDerivedAddress, SpvManager, SpvStatus, SpvStatusSnapshot}; diff --git a/src/ui/components/confirmation_dialog.rs b/src/ui/components/confirmation_dialog.rs index 6f04f2a2b..1dcf86ff3 100644 --- a/src/ui/components/confirmation_dialog.rs +++ b/src/ui/components/confirmation_dialog.rs @@ -145,7 +145,7 @@ impl ConfirmationDialog { } // Draw dark overlay behind the dialog for better visibility - let screen_rect = ui.ctx().screen_rect(); + let screen_rect = ui.ctx().content_rect(); let painter = ui.ctx().layer_painter(egui::LayerId::new( egui::Order::Background, egui::Id::new("confirmation_dialog_overlay"), diff --git a/src/ui/components/info_popup.rs b/src/ui/components/info_popup.rs index 8fc393afd..0119fe64b 100644 --- a/src/ui/components/info_popup.rs +++ b/src/ui/components/info_popup.rs @@ -56,7 +56,7 @@ impl InfoPopup { } // Draw dark overlay behind the popup for better visibility - let screen_rect = ui.ctx().screen_rect(); + let screen_rect = ui.ctx().content_rect(); let painter = ui.ctx().layer_painter(egui::LayerId::new( egui::Order::Background, egui::Id::new("info_popup_overlay"), diff --git a/src/ui/components/left_panel.rs b/src/ui/components/left_panel.rs index 235677d67..216e708cd 100644 --- a/src/ui/components/left_panel.rs +++ b/src/ui/components/left_panel.rs @@ -5,7 +5,7 @@ use crate::ui::components::styled::GradientButton; use crate::ui::theme::{DashColors, Shadow, Shape, Spacing}; use dash_sdk::dashcore_rpc::dashcore::Network; use eframe::epaint::Margin; -use egui::{Color32, Context, Frame, ImageButton, RichText, SidePanel, TextureHandle}; +use egui::{Color32, Context, Frame, Image, RichText, SidePanel, TextureHandle}; use egui_extras::{Size, StripBuilder}; use rust_embed::RustEmbed; use std::sync::Arc; @@ -249,9 +249,10 @@ pub fn add_left_panel( }; if let Some(ref texture) = texture { - let button = ImageButton::new(texture) - .frame(false) - .tint(button_color); + let button = egui::Button::image( + Image::new(texture).tint(button_color), + ) + .frame(false); let added = ui.add(button); if added.clicked() { diff --git a/src/ui/components/left_wallet_panel.rs b/src/ui/components/left_wallet_panel.rs index f5922d3ba..b81ac1eb7 100644 --- a/src/ui/components/left_wallet_panel.rs +++ b/src/ui/components/left_wallet_panel.rs @@ -2,7 +2,7 @@ use crate::app::AppAction; use crate::context::AppContext; use crate::ui::RootScreenType; use eframe::epaint::{Color32, Margin}; -use egui::{Context, Frame, ImageButton, SidePanel, TextureHandle}; +use egui::{Context, Frame, Image, SidePanel, TextureHandle}; use rust_embed::RustEmbed; use std::sync::Arc; @@ -92,9 +92,8 @@ pub fn add_left_panel( // Add icon-based button if texture is loaded if let Some(ref texture) = texture { - let button = ImageButton::new(texture) - .frame(false) // Remove button frame - .tint(button_color); + let button = egui::Button::image(Image::new(texture).tint(button_color)) + .frame(false); // Remove button frame if ui.add(button).clicked() { action = AppAction::SetMainScreen(*screen_type); diff --git a/src/ui/components/top_panel.rs b/src/ui/components/top_panel.rs index b0a93a5b4..67a42c227 100644 --- a/src/ui/components/top_panel.rs +++ b/src/ui/components/top_panel.rs @@ -394,7 +394,8 @@ pub fn add_top_panel( ui.add_space(3.0); let font = egui::FontId::proportional(16.0); let text_size = ui - .fonts(|f| { + .ctx() + .fonts_mut(|f| { f.layout_no_wrap( text.to_string(), font.clone(), diff --git a/src/ui/components/wallet_unlock_popup.rs b/src/ui/components/wallet_unlock_popup.rs index afdf00464..0f8d65a48 100644 --- a/src/ui/components/wallet_unlock_popup.rs +++ b/src/ui/components/wallet_unlock_popup.rs @@ -75,7 +75,7 @@ impl WalletUnlockPopup { } // Draw dark overlay behind the popup - let screen_rect = ctx.screen_rect(); + let screen_rect = ctx.content_rect(); let painter = ctx.layer_painter(egui::LayerId::new( egui::Order::Background, egui::Id::new("wallet_unlock_popup_overlay"), diff --git a/src/ui/helpers.rs b/src/ui/helpers.rs index e12bd1dbb..77bded3af 100644 --- a/src/ui/helpers.rs +++ b/src/ui/helpers.rs @@ -11,7 +11,11 @@ use arboard::Clipboard; use dash_sdk::{ dpp::{ data_contract::{ + GroupContractPosition, accessors::v0::DataContractV0Getters, + accessors::v1::DataContractV1Getters, + associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters, + change_control_rules::authorized_action_takers::AuthorizedActionTakers, document_type::{DocumentType, accessors::DocumentTypeV0Getters}, group::{Group, accessors::v0::GroupV0Getters}, }, @@ -31,6 +35,174 @@ use super::tokens::tokens_screen::IdentityTokenInfo; /// This constant provides a constant padding to be used in such cases to ensure proper alignment. pub const BUTTON_ADJUSTMENT_PADDING_TOP: f32 = 15.0; +/// Formats a key label for display in combo boxes and lists. +/// Returns a string like "Key 0 | AUTHENTICATION | CRITICAL | ECDSA_SECP256K1" +pub fn format_key_label(key: &IdentityPublicKey) -> String { + format!( + "Key {} | {} | {} | {}", + key.id(), + key.purpose(), + key.security_level(), + key.key_type() + ) +} + +/// Formats a key label with a [DEV] suffix for dev mode display. +pub fn format_key_label_dev(key: &IdentityPublicKey) -> String { + format!( + "Key {} | {} | {} | {} [DEV]", + key.id(), + key.purpose(), + key.security_level(), + key.key_type() + ) +} + +/// Returns the display label for a QualifiedIdentity (alias or Base58 ID). +pub fn identity_display_label(identity: &QualifiedIdentity) -> String { + identity + .alias + .clone() + .unwrap_or_else(|| identity.identity.id().to_string(Encoding::Base58)) +} + +/// Returns the display label for a QualifiedContract (alias or Base58 ID). +pub fn contract_display_label(contract: &QualifiedContract) -> String { + contract + .alias + .clone() + .unwrap_or_else(|| contract.contract.id().to_string(Encoding::Base58)) +} + +/// Computes the allowed security levels for a given transaction type and optional document type. +/// This centralizes the logic that was previously duplicated in multiple places. +pub fn compute_allowed_security_levels( + transaction_type: TransactionType, + document_type: Option<&DocumentType>, +) -> Vec { + match (transaction_type, document_type) { + (TransactionType::DocumentAction, Some(doc_type)) => { + let required_level = doc_type.security_level_requirement(); + let allowed_range = SecurityLevel::CRITICAL as u8..=required_level as u8; + [ + SecurityLevel::CRITICAL, + SecurityLevel::HIGH, + SecurityLevel::MEDIUM, + ] + .into_iter() + .filter(|level| allowed_range.contains(&(*level as u8))) + .collect() + } + _ => transaction_type.allowed_security_levels(), + } +} + +/// Result of checking token action authorization. +/// Contains the group if applicable and any error message. +pub struct TokenAuthorizationResult { + pub group: Option<(GroupContractPosition, Group)>, + pub error_message: Option, + pub is_unilateral_group_member: bool, +} + +/// Checks if the given identity is authorized to perform a token action. +/// This centralizes the authorization checking logic used across token screens (mint, burn, pause, etc.). +/// +/// # Arguments +/// * `action_takers` - The authorized action takers for the operation +/// * `identity_token_info` - The token and identity information +/// * `action_name` - Human-readable name of the action (e.g., "mint", "burn") +/// +/// # Returns +/// A `TokenAuthorizationResult` containing the group (if applicable), any error message, +/// and whether the user is a unilateral group member. +pub fn check_token_authorization( + action_takers: &AuthorizedActionTakers, + identity_token_info: &IdentityTokenInfo, + action_name: &str, +) -> TokenAuthorizationResult { + let mut error_message = None; + + let group = match action_takers { + AuthorizedActionTakers::NoOne => { + error_message = Some(format!("{} is not allowed on this token", action_name)); + None + } + AuthorizedActionTakers::ContractOwner => { + if identity_token_info.data_contract.contract.owner_id() + != identity_token_info.identity.identity.id() + { + error_message = Some(format!( + "You are not allowed to {} this token. Only the contract owner is.", + action_name.to_lowercase() + )); + } + None + } + AuthorizedActionTakers::Identity(identifier) => { + if identifier != &identity_token_info.identity.identity.id() { + error_message = Some(format!( + "You are not allowed to {} this token", + action_name.to_lowercase() + )); + } + None + } + AuthorizedActionTakers::MainGroup => { + match identity_token_info.token_config.main_control_group() { + None => { + error_message = Some( + "Invalid contract: No main control group, though one should exist" + .to_string(), + ); + None + } + Some(group_pos) => { + match identity_token_info + .data_contract + .contract + .expected_group(group_pos) + { + Ok(group) => Some((group_pos, group.clone())), + Err(e) => { + error_message = Some(format!("Invalid contract: {}", e)); + None + } + } + } + } + } + AuthorizedActionTakers::Group(group_pos) => { + match identity_token_info + .data_contract + .contract + .expected_group(*group_pos) + { + Ok(group) => Some((*group_pos, group.clone())), + Err(e) => { + error_message = Some(format!("Invalid contract: {}", e)); + None + } + } + } + }; + + let is_unilateral_group_member = if let Some((_, ref g)) = group { + g.members() + .get(&identity_token_info.identity.identity.id()) + .map(|power| *power >= g.required_power()) + .unwrap_or(false) + } else { + false + }; + + TokenAuthorizationResult { + group, + error_message, + is_unilateral_group_member, + } +} + /// Helper function to create a styled info icon button with a circle and "i" /// Returns a Response that can be checked for .clicked() to show an info popup pub fn info_icon_button(ui: &mut egui::Ui, hover_text: &str) -> Response { @@ -220,21 +392,7 @@ pub fn add_key_chooser_with_doc_type( let mut action = AppAction::None; let allowed_purposes = transaction_type.allowed_purposes(); - let allowed_security_levels: Vec = match (transaction_type, document_type) { - (TransactionType::DocumentAction, Some(doc_type)) => { - let required_level = doc_type.security_level_requirement(); - let allowed_levels = SecurityLevel::CRITICAL as u8..=required_level as u8; - [ - SecurityLevel::CRITICAL, - SecurityLevel::HIGH, - SecurityLevel::MEDIUM, - ] - .into_iter() - .filter(|level| allowed_levels.contains(&(*level as u8))) - .collect() - } - _ => transaction_type.allowed_security_levels(), - }; + let allowed_security_levels = compute_allowed_security_levels(transaction_type, document_type); // Check for keys with private keys loaded let has_suitable_keys_with_private = @@ -300,48 +458,25 @@ pub fn add_key_chooser_with_doc_type( .selected_text( selected_key .as_ref() - .map(|k| { - format!( - "Key {} | {} | {} | {}", - k.id(), - k.purpose(), - k.security_level(), - k.key_type() - ) - }) + .map(format_key_label) .unwrap_or_else(|| "Select Key...".into()), ) .show_ui(ui, |kui| { for key_ref in identity.private_keys.identity_public_keys() { let key = &key_ref.1.identity_public_key; - let is_allowed = if is_dev_mode { - true - } else { - allowed_purposes.contains(&key.purpose()) - && allowed_security_levels.contains(&key.security_level()) - }; + let is_allowed = is_dev_mode + || (allowed_purposes.contains(&key.purpose()) + && allowed_security_levels.contains(&key.security_level())); if is_allowed { - let label = if is_dev_mode + let is_dev_override = is_dev_mode && (!allowed_purposes.contains(&key.purpose()) - || !allowed_security_levels.contains(&key.security_level())) - { - format!( - "Key {} | {} | {} | {} [DEV]", - key.id(), - key.purpose(), - key.security_level(), - key.key_type() - ) + || !allowed_security_levels.contains(&key.security_level())); + let label = if is_dev_override { + format_key_label_dev(key) } else { - format!( - "Key {} | {} | {} | {}", - key.id(), - key.purpose(), - key.security_level(), - key.key_type() - ) + format_key_label(key) }; if kui @@ -407,23 +542,19 @@ where ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { ComboBox::from_id_salt("identity_combo") .width(220.0) - .selected_text(match selected_identity { - Some(qi) => qi - .alias - .clone() - .unwrap_or_else(|| qi.identity.id().to_string(Encoding::Base58)), - None => "Select Identity…".into(), - }) + .selected_text( + selected_identity + .as_ref() + .map(identity_display_label) + .unwrap_or_else(|| "Select Identity…".into()), + ) .show_ui(ui, |iui| { for qi in identities { - let label = qi - .alias - .clone() - .unwrap_or_else(|| qi.identity.id().to_string(Encoding::Base58)); + let label = identity_display_label(qi); if iui .selectable_label( selected_identity.as_ref() == Some(qi), - label.clone(), + label, ) .clicked() { @@ -443,17 +574,8 @@ where let mut show_combo = true; if let Some(qi) = selected_identity { let allowed_purposes = transaction_type.allowed_purposes(); - let allowed_security_levels: Vec = match (transaction_type, document_type) { - (TransactionType::DocumentAction, Some(doc_type)) => { - let required_level = doc_type.security_level_requirement(); - let allowed_levels = SecurityLevel::CRITICAL as u8..=required_level as u8; - [SecurityLevel::CRITICAL, SecurityLevel::HIGH, SecurityLevel::MEDIUM] - .into_iter() - .filter(|level| allowed_levels.contains(&(*level as u8))) - .collect() - } - _ => transaction_type.allowed_security_levels(), - }; + let allowed_security_levels = + compute_allowed_security_levels(transaction_type, document_type); // Check for keys with private keys loaded let has_suitable_keys_with_private = qi @@ -520,100 +642,49 @@ where } if show_combo { - ComboBox::from_id_salt("key_combo") - .width(220.0) - .selected_text( - selected_key - .as_ref() - .map(|k| { - format!( - "Key {} | {} | {} | {}", - k.id(), - k.purpose(), - k.security_level(), - k.key_type() - ) - }) - .unwrap_or_else(|| "Select Key…".into()), - ) - .show_ui(ui, |kui| { - if let Some(qi) = selected_identity { - let allowed_purposes = transaction_type.allowed_purposes(); - let allowed_security_levels = if transaction_type - == TransactionType::DocumentAction - { - if let Some(document_type) = document_type { - // For document actions with a specific document type, use its security requirement - let required_level = document_type.security_level_requirement(); - let allowed_levels = - SecurityLevel::CRITICAL as u8..=required_level as u8; - let allowed_levels: Vec = [ - SecurityLevel::CRITICAL, - SecurityLevel::HIGH, - SecurityLevel::MEDIUM, - ] - .iter() - .cloned() - .filter(|level| allowed_levels.contains(&(*level as u8))) - .collect(); - allowed_levels - } else { - transaction_type.allowed_security_levels() - } - } else { - transaction_type.allowed_security_levels() - }; - - for key_ref in qi.private_keys.identity_public_keys() { - let key = &key_ref.1.identity_public_key; - - // In dev mode, show all keys - // In production mode, filter by transaction requirements - let is_allowed = if is_dev_mode { - true - } else { - allowed_purposes - .contains(&key.purpose()) - && allowed_security_levels.contains(&key.security_level()) - }; - - if is_allowed { - let label = if is_dev_mode - && (!allowed_purposes.contains(&key.purpose()) - || !allowed_security_levels - .contains(&key.security_level())) - { - // In dev mode, mark keys that wouldn't normally be allowed - format!( - "Key {} | {} | {} | {} [DEV]", - key.id(), - key.purpose(), - key.security_level(), - key.key_type() - ) - } else { - format!( - "Key {} | {} | {} | {}", - key.id(), - key.purpose(), - key.security_level(), - key.key_type() - ) - }; - - if kui - .selectable_label(selected_key.as_ref() == Some(key), label) - .clicked() - { - *selected_key = Some(key.clone()); + ComboBox::from_id_salt("key_combo") + .width(220.0) + .selected_text( + selected_key + .as_ref() + .map(format_key_label) + .unwrap_or_else(|| "Select Key…".into()), + ) + .show_ui(ui, |kui| { + if let Some(qi) = selected_identity { + let allowed_purposes = transaction_type.allowed_purposes(); + let allowed_security_levels = + compute_allowed_security_levels(transaction_type, document_type); + + for key_ref in qi.private_keys.identity_public_keys() { + let key = &key_ref.1.identity_public_key; + + let is_allowed = is_dev_mode + || (allowed_purposes.contains(&key.purpose()) + && allowed_security_levels.contains(&key.security_level())); + + if is_allowed { + let is_dev_override = is_dev_mode + && (!allowed_purposes.contains(&key.purpose()) + || !allowed_security_levels.contains(&key.security_level())); + let label = if is_dev_override { + format_key_label_dev(key) + } else { + format_key_label(key) + }; + + if kui + .selectable_label(selected_key.as_ref() == Some(key), label) + .clicked() + { + *selected_key = Some(key.clone()); + } } } + } else { + kui.label("Pick an identity first"); } - - } else { - kui.label("Pick an identity first"); - } - }); + }); } }); ui.end_row(); @@ -632,11 +703,9 @@ pub fn add_contract_doc_type_chooser_with_filtering( let contracts = app_context.get_contracts(None, None).unwrap_or_default(); let search_term_lowercase = search_term.to_lowercase(); let filtered = contracts.iter().filter(|qc| { - let key = qc - .alias - .clone() - .unwrap_or_else(|| qc.contract.id().to_string(Encoding::Base58)); - key.to_lowercase().contains(&search_term_lowercase) + contract_display_label(qc) + .to_lowercase() + .contains(&search_term_lowercase) }); add_contract_doc_type_chooser_pre_filtered( @@ -675,24 +744,17 @@ pub fn add_contract_doc_type_chooser_pre_filtered<'a, T>( ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { ComboBox::from_id_salt("contract_combo") .width(220.0) - .selected_text(match selected_contract { - Some(qc) => qc - .alias - .clone() - .unwrap_or_else(|| qc.contract.id().to_string(Encoding::Base58)), - None => "Select Contract…".into(), - }) + .selected_text( + selected_contract + .as_ref() + .map(contract_display_label) + .unwrap_or_else(|| "Select Contract…".into()), + ) .show_ui(ui, |cui| { for qc in filtered_contracts { - let label = qc - .alias - .clone() - .unwrap_or_else(|| qc.contract.id().to_string(Encoding::Base58)); + let label = contract_display_label(qc); if cui - .selectable_label( - selected_contract.as_ref() == Some(qc), - label.clone(), - ) + .selectable_label(selected_contract.as_ref() == Some(qc), label) .clicked() { *selected_contract = Some(qc.clone()); @@ -763,21 +825,17 @@ pub fn add_contract_chooser_pre_filtered<'a, T>( ui.label("Contract:"); ComboBox::from_id_salt("contract_chooser") .width(220.0) - .selected_text(match selected_contract { - Some(qc) => qc - .alias - .clone() - .unwrap_or_else(|| qc.contract.id().to_string(Encoding::Base58)), - None => "Select Contract…".into(), - }) + .selected_text( + selected_contract + .as_ref() + .map(contract_display_label) + .unwrap_or_else(|| "Select Contract…".into()), + ) .show_ui(ui, |cui| { for qc in filtered_contracts { - let label = qc - .alias - .clone() - .unwrap_or_else(|| qc.contract.id().to_string(Encoding::Base58)); + let label = contract_display_label(qc); if cui - .selectable_label(selected_contract.as_ref() == Some(qc), label.clone()) + .selectable_label(selected_contract.as_ref() == Some(qc), label) .clicked() { *selected_contract = Some(qc.clone()); diff --git a/src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs b/src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs index 332e82f3a..5c8b06a9a 100644 --- a/src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs +++ b/src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs @@ -23,7 +23,7 @@ impl AddNewIdentityScreen { let mut wallet = wallet_guard.write().unwrap(); let receive_address = wallet.receive_address( self.app_context.network, - false, + true, Some(&self.app_context), )?; diff --git a/src/ui/identities/identities_screen.rs b/src/ui/identities/identities_screen.rs index 963ed4c9d..816b5606a 100644 --- a/src/ui/identities/identities_screen.rs +++ b/src/ui/identities/identities_screen.rs @@ -843,7 +843,7 @@ impl IdentitiesScreen { let action = AppAction::None; // Draw dark overlay behind the popup - let screen_rect = ctx.screen_rect(); + let screen_rect = ctx.content_rect(); let painter = ctx.layer_painter(egui::LayerId::new( egui::Order::Background, egui::Id::new("confirm_removal_overlay"), @@ -969,7 +969,7 @@ impl IdentitiesScreen { let identity_id = self.editing_alias_identity.unwrap(); // Draw dark overlay behind the popup - let screen_rect = ctx.screen_rect(); + let screen_rect = ctx.content_rect(); let painter = ctx.layer_painter(egui::LayerId::new( egui::Order::Background, egui::Id::new("edit_alias_overlay"), diff --git a/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs b/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs index ec069f2d7..12339a127 100644 --- a/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs +++ b/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs @@ -17,7 +17,7 @@ impl TopUpIdentityScreen { let mut wallet = wallet_guard.write().unwrap(); let receive_address = wallet.receive_address( self.app_context.network, - false, + true, Some(&self.app_context), )?; diff --git a/src/ui/identities/top_up_identity_screen/mod.rs b/src/ui/identities/top_up_identity_screen/mod.rs index 3b1ea0001..7bc8fae6f 100644 --- a/src/ui/identities/top_up_identity_screen/mod.rs +++ b/src/ui/identities/top_up_identity_screen/mod.rs @@ -10,6 +10,7 @@ use crate::backend_task::identity::{IdentityTask, IdentityTopUpInfo, TopUpIdenti use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::amount::Amount; +use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::components::amount_input::AmountInput; @@ -371,17 +372,30 @@ impl TopUpIdentityScreen { // Only apply max amount restriction when using wallet balance // For QR code funding, funds come from external source so no max applies - let (max_amount, show_max_button) = if funding_method == FundingMethod::UseWalletBalance { - let max_amount_duffs = self - .wallet - .as_ref() - .map(|w| w.read().unwrap().total_balance_duffs()) - .unwrap_or(0); - // Convert Duffs to Credits (1 Duff = 1000 Credits) - (Some(max_amount_duffs * 1000), true) - } else { - (None, false) - }; + let (max_amount, show_max_button, fee_hint) = + if funding_method == FundingMethod::UseWalletBalance { + let max_amount_duffs = self + .wallet + .as_ref() + .map(|w| w.read().unwrap().total_balance_duffs()) + .unwrap_or(0); + // Convert Duffs to Credits (1 Duff = 1000 Credits) + let total_credits = max_amount_duffs * 1000; + // Reserve estimated fees so "Max" doesn't exceed spendable amount + let fee_estimator = self.app_context.fee_estimator(); + let estimated_fee = fee_estimator.estimate_identity_topup(); + let max_with_fee_reserved = total_credits.saturating_sub(estimated_fee); + ( + Some(max_with_fee_reserved), + true, + Some(format!( + "~{} reserved for fees", + format_credits_as_dash(estimated_fee) + )), + ) + } else { + (None, false, None) + }; // Lazy initialization of the AmountInput component let amount_input = self.funding_amount_input.get_or_insert_with(|| { @@ -394,6 +408,7 @@ impl TopUpIdentityScreen { // Update max amount and button visibility in case funding method or wallet balance changed amount_input.set_max_amount(max_amount); amount_input.set_show_max_button(show_max_button); + amount_input.set_max_exceeded_hint(fee_hint); let response = amount_input.show(ui); diff --git a/src/ui/tokens/tokens_screen/data_contract_json_pop_up.rs b/src/ui/tokens/tokens_screen/data_contract_json_pop_up.rs index 80d5521ac..44a71c11a 100644 --- a/src/ui/tokens/tokens_screen/data_contract_json_pop_up.rs +++ b/src/ui/tokens/tokens_screen/data_contract_json_pop_up.rs @@ -9,7 +9,7 @@ impl TokensScreen { let mut is_open = true; // Draw dark overlay behind the dialog for better visibility - let screen_rect = ui.ctx().screen_rect(); + let screen_rect = ui.ctx().content_rect(); let painter = ui.ctx().layer_painter(egui::LayerId::new( egui::Order::Background, egui::Id::new("json_popup_overlay"), diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index c64536615..938579d95 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -1795,8 +1795,15 @@ impl TokensScreen { } } + // Append any tokens not present in the saved order (e.g., newly added tokens) + for (key, value) in &self.my_tokens { + if !reordered.contains_key(key) { + reordered.insert(*key, value.clone()); + } + } + // Replace the original with the reordered map - //self.my_tokens = reordered; + self.my_tokens = reordered; } /// Save the current map's order of token IDs to the DB @@ -2041,7 +2048,7 @@ impl TokensScreen { .step_decreasing_initial_emission_input .parse::() .unwrap_or(0), - min_value: if self.step_decreasing_start_period_offset_input.is_empty() { + min_value: if self.step_decreasing_min_value_input.is_empty() { None } else { match self.step_decreasing_min_value_input.parse::() { @@ -2055,7 +2062,7 @@ impl TokensScreen { } } }, - max_interval_count: if self.step_decreasing_start_period_offset_input.is_empty() + max_interval_count: if self.step_decreasing_max_interval_count_input.is_empty() { None } else { diff --git a/src/ui/tools/masternode_list_diff_screen.rs b/src/ui/tools/masternode_list_diff_screen.rs index 80d2186ca..ee3374ee9 100644 --- a/src/ui/tools/masternode_list_diff_screen.rs +++ b/src/ui/tools/masternode_list_diff_screen.rs @@ -56,102 +56,42 @@ enum SelectedQRItem { QuorumEntry(Box), } -/// Screen for viewing MNList diffs (diffs in the masternode list and quorums) -pub struct MasternodeListDiffScreen { - pub app_context: Arc, - - /// Are we syncing? - syncing: bool, - - /// The chain locked blocks received through zmq that we can attempt to verify - chain_locked_blocks: BTreeMap, - - /// Instant send locked transactions received through zmq that we can attempt to verify - instant_send_transactions: Vec<(Transaction, InstantLock, bool)>, - - /// The user‐entered base block height (as text) +/// User-entered inputs and transient filters. +#[derive(Default)] +struct InputState { base_block_height: String, - /// The user‐entered end block height (as text) end_block_height: String, + search_term: Option, +} +/// UI presentation state (tabs, banners, dialogs). +#[derive(Default)] +struct UiState { + selected_tab: usize, show_popup_for_render_masternode_list_engine: bool, + message: Option<(String, MessageType)>, + error: Option, +} - /// Selected tab (0 = Diffs, 1 = Masternode Lists) - selected_tab: usize, +/// Backend task state and sync toggles. +#[derive(Default)] +struct TaskState { + syncing: bool, + pending: Option, + queued_task: Option, +} - /// The engine to compute masternode lists +/// Domain data for the masternode list diff tool. +struct MnListData { masternode_list_engine: MasternodeListEngine, - - /// Masternode_list_heights with all quorum heights known - masternode_lists_with_all_quorum_heights_known: BTreeSet, - - /// The list of MNList diff items (one per block height) mnlist_diffs: BTreeMap<(CoreBlockHeight, CoreBlockHeight), MnListDiff>, - - /// The list of qr infos qr_infos: BTreeMap, - - /// Selected MNList diff - selected_dml_diff_key: Option<(CoreBlockHeight, CoreBlockHeight)>, - - /// This is to know which ones we have already checked for quorum heights - dml_diffs_with_cached_quorum_heights: HashSet<(CoreBlockHeight, CoreBlockHeight)>, - - /// Selected MNList - selected_dml_height_key: Option, - - /// Selected display option - selected_option_index: Option, - /// Selected quorum within the MNList diff - selected_quorum_in_diff_index: Option, - - /// Selected masternode within the MNList diff - selected_masternode_in_diff_index: Option, - - /// Selected quorum within the MNList diff - selected_quorum_hash_in_mnlist_diff: Option<(LLMQType, QuorumHash)>, - - /// Selected quorum within the quorum_viewer - selected_quorum_type_in_quorum_viewer: Option, - - /// Selected quorum within the quorum_viewer - selected_quorum_hash_in_quorum_viewer: Option, - - /// Selected masternode within the MNList diff - selected_masternode_pro_tx_hash: Option, - - /// Search term - search_term: Option, - - /// The block height cache - block_height_cache: BTreeMap, - - /// The block hash cache - block_hash_cache: BTreeMap, - - /// The masternode list quorum hash cache - masternode_list_quorum_hash_cache: - BTreeMap>>, - - chain_lock_sig_cache: BTreeMap<(CoreBlockHeight, BlockHash), Option>, - - chain_lock_reversed_sig_cache: BTreeMap>, - - error: Option, - selected_qr_field: Option, - selected_qr_list_index: Option, - selected_core_item: Option<(CoreItem, bool)>, - selected_qr_item: Option, - pending: Option, - queued_task: Option, - message: Option<(String, MessageType)>, } -impl MasternodeListDiffScreen { - /// Create a new MNListDiffScreen - pub fn new(app_context: &Arc) -> Self { +impl MnListData { + fn new(app_context: &Arc) -> Self { let mut mnlist_diffs = BTreeMap::new(); - let engine = match app_context.network { + let masternode_list_engine = match app_context.network { Network::Dash => { use std::env; println!( @@ -213,44 +153,104 @@ impl MasternodeListDiffScreen { }; Self { - app_context: app_context.clone(), - syncing: false, - chain_locked_blocks: Default::default(), - instant_send_transactions: vec![], - base_block_height: "".to_string(), - end_block_height: "".to_string(), - show_popup_for_render_masternode_list_engine: false, - selected_tab: 0, - masternode_list_engine: engine, - search_term: None, + masternode_list_engine, mnlist_diffs, qr_infos: Default::default(), - selected_dml_diff_key: None, - dml_diffs_with_cached_quorum_heights: Default::default(), - selected_dml_height_key: None, - selected_option_index: None, - selected_quorum_in_diff_index: None, - selected_masternode_in_diff_index: None, - selected_quorum_hash_in_mnlist_diff: None, - selected_quorum_type_in_quorum_viewer: None, - selected_quorum_hash_in_quorum_viewer: None, - selected_masternode_pro_tx_hash: None, - error: None, - selected_qr_field: None, - selected_qr_list_index: None, - block_height_cache: Default::default(), - block_hash_cache: Default::default(), - masternode_list_quorum_hash_cache: Default::default(), - selected_qr_item: None, - selected_core_item: None, - masternode_lists_with_all_quorum_heights_known: Default::default(), - chain_lock_sig_cache: Default::default(), - chain_lock_reversed_sig_cache: Default::default(), - pending: None, - queued_task: None, - message: None, } } +} + +/// Derived caches to avoid repeated lookups or recomputation. +#[derive(Default)] +struct CacheState { + masternode_lists_with_all_quorum_heights_known: BTreeSet, + dml_diffs_with_cached_quorum_heights: HashSet<(CoreBlockHeight, CoreBlockHeight)>, + block_height_cache: BTreeMap, + block_hash_cache: BTreeMap, + masternode_list_quorum_hash_cache: + BTreeMap>>, + chain_lock_sig_cache: BTreeMap<(CoreBlockHeight, BlockHash), Option>, + chain_lock_reversed_sig_cache: BTreeMap>, +} + +/// User selection state for lists and detail panes. +#[derive(Default)] +struct SelectionState { + selected_dml_diff_key: Option<(CoreBlockHeight, CoreBlockHeight)>, + selected_dml_height_key: Option, + selected_option_index: Option, + selected_quorum_in_diff_index: Option, + selected_masternode_in_diff_index: Option, + selected_quorum_hash_in_mnlist_diff: Option<(LLMQType, QuorumHash)>, + selected_quorum_type_in_quorum_viewer: Option, + selected_quorum_hash_in_quorum_viewer: Option, + selected_masternode_pro_tx_hash: Option, + selected_qr_field: Option, + selected_qr_list_index: Option, + selected_core_item: Option<(CoreItem, bool)>, + selected_qr_item: Option, +} + +/// Incoming core items received via ZMQ or backend tasks. +#[derive(Default)] +struct IncomingState { + chain_locked_blocks: BTreeMap, + instant_send_transactions: Vec<(Transaction, InstantLock, bool)>, +} + +/// Screen for viewing MNList diffs (diffs in the masternode list and quorums) +pub struct MasternodeListDiffScreen { + pub app_context: Arc, + input: InputState, + ui_state: UiState, + task: TaskState, + data: MnListData, + cache: CacheState, + selection: SelectionState, + incoming: IncomingState, +} + +impl MasternodeListDiffScreen { + /// Create a new MNListDiffScreen + pub fn new(app_context: &Arc) -> Self { + let data = MnListData::new(app_context); + Self { + app_context: app_context.clone(), + input: InputState::default(), + ui_state: UiState::default(), + task: TaskState::default(), + data, + cache: CacheState::default(), + selection: SelectionState::default(), + incoming: IncomingState::default(), + } + } + + fn selected_dml(&self) -> Option<&MnListDiff> { + self.selection + .selected_dml_diff_key + .and_then(|key| self.data.mnlist_diffs.get(&key)) + } + + fn selected_mn_list(&self) -> Option<&MasternodeList> { + self.selection.selected_dml_height_key.and_then(|height| { + self.data + .masternode_list_engine + .masternode_lists + .get(&height) + }) + } + + fn known_block_hashes_with_base(&self, base_hash: BlockHash) -> Vec { + let mut known_block_hashes: Vec<_> = self + .data + .mnlist_diffs + .values() + .map(|mn_list_diff| mn_list_diff.block_hash) + .collect(); + known_block_hashes.push(base_hash); + known_block_hashes + } fn get_height_or_error_as_string(&self, block_hash: &BlockHash) -> String { match self.get_height(block_hash) { @@ -264,6 +264,7 @@ impl MasternodeListDiffScreen { fn build_validation_diffs_task(&mut self) -> Option { // Determine hashes we need to validate let hashes = self + .data .masternode_list_engine .latest_masternode_list_non_rotating_quorum_hashes( &[LLMQType::Llmqtype50_60, LLMQType::Llmqtype400_85], @@ -291,6 +292,7 @@ impl MasternodeListDiffScreen { // Determine base starting point similar to previous logic let (first_engine_height, first_engine_hash_opt) = self + .data .masternode_list_engine .masternode_lists .first_key_value() @@ -332,11 +334,12 @@ impl MasternodeListDiffScreen { fn get_height(&self, block_hash: &BlockHash) -> Result { let Some(height) = self + .data .masternode_list_engine .block_container .get_height(block_hash) else { - let Some(height) = self.block_height_cache.get(block_hash) else { + let Some(height) = self.cache.block_height_cache.get(block_hash) else { println!( "Asking core for height no cache {} ({})", block_hash, @@ -369,11 +372,12 @@ impl MasternodeListDiffScreen { fn get_height_and_cache(&mut self, block_hash: &BlockHash) -> Result { let Some(height) = self + .data .masternode_list_engine .block_container .get_height(block_hash) else { - let Some(height) = self.block_height_cache.get(block_hash) else { + let Some(height) = self.cache.block_height_cache.get(block_hash) else { println!( "Asking core for height {} ({})", block_hash, @@ -388,9 +392,11 @@ impl MasternodeListDiffScreen { &(BlockHash2::from_byte_array(block_hash.to_byte_array())), ) { Ok(result) => { - self.block_height_cache + self.cache + .block_height_cache .insert(*block_hash, result.height as CoreBlockHeight); - self.masternode_list_engine + self.data + .masternode_list_engine .feed_block_height(result.height as CoreBlockHeight, *block_hash); Ok(result.height as CoreBlockHeight) } @@ -409,6 +415,7 @@ impl MasternodeListDiffScreen { ) -> Result, String> { let height = self.get_height_and_cache(block_hash)?; if !self + .cache .chain_lock_sig_cache .contains_key(&(height, *block_hash)) { @@ -427,12 +434,13 @@ impl MasternodeListDiffScreen { return Err(format!("coinbase not found on block hash {}", block_hash)); }; //todo clean up - self.chain_lock_sig_cache.insert( + self.cache.chain_lock_sig_cache.insert( (height, *block_hash), coinbase.best_cl_signature.map(|sig| sig.to_bytes().into()), ); if let Some(sig) = coinbase.best_cl_signature.map(|sig| sig.to_bytes().into()) { - self.chain_lock_reversed_sig_cache + self.cache + .chain_lock_reversed_sig_cache .entry(sig) .or_default() .insert((height, *block_hash)); @@ -440,6 +448,7 @@ impl MasternodeListDiffScreen { } Ok(*self + .cache .chain_lock_sig_cache .get(&(height, *block_hash)) .unwrap()) @@ -448,6 +457,7 @@ impl MasternodeListDiffScreen { fn get_chain_lock_sig(&self, block_hash: &BlockHash) -> Result, String> { let height = self.get_height(block_hash)?; if !self + .cache .chain_lock_sig_cache .contains_key(&(height, *block_hash)) { @@ -468,6 +478,7 @@ impl MasternodeListDiffScreen { Ok(coinbase.best_cl_signature.map(|sig| sig.to_bytes().into())) } else { Ok(*self + .cache .chain_lock_sig_cache .get(&(height, *block_hash)) .unwrap()) @@ -476,11 +487,12 @@ impl MasternodeListDiffScreen { fn get_block_hash(&self, height: CoreBlockHeight) -> Result { let Some(block_hash) = self + .data .masternode_list_engine .block_container .get_hash(&height) else { - let Some(block_hash) = self.block_hash_cache.get(&height) else { + let Some(block_hash) = self.cache.block_hash_cache.get(&height) else { // println!("Asking core for hash of {}", height); return match self .app_context @@ -502,6 +514,7 @@ impl MasternodeListDiffScreen { fn get_block_hash_and_cache(&mut self, height: CoreBlockHeight) -> Result { // First, try to get the hash from masternode_list_engine's block_container. if let Some(block_hash) = self + .data .masternode_list_engine .block_container .get_hash(&height) @@ -510,7 +523,7 @@ impl MasternodeListDiffScreen { } // Then, check the cache. - if let Some(cached_hash) = self.block_hash_cache.get(&height) { + if let Some(cached_hash) = self.cache.block_hash_cache.get(&height) { return Ok(*cached_hash); } @@ -525,7 +538,7 @@ impl MasternodeListDiffScreen { { Ok(core_block_hash) => { let block_hash = BlockHash::from_byte_array(core_block_hash.to_byte_array()); - self.block_hash_cache.insert(height, block_hash); + self.cache.block_hash_cache.insert(height, block_hash); Ok(block_hash) } Err(e) => Err(e.to_string()), @@ -533,10 +546,10 @@ impl MasternodeListDiffScreen { } // // fn feed_qr_info_cl_sigs(&mut self, qr_info: &QRInfo) { - // let heights = match self.masternode_list_engine.required_cl_sig_heights(qr_info) { + // let heights = match self.data.masternode_list_engine.required_cl_sig_heights(qr_info) { // Ok(heights) => heights, // Err(e) => { - // self.error = Some(e.to_string()); + // self.ui_state.error = Some(e.to_string()); // return; // } // }; @@ -544,7 +557,7 @@ impl MasternodeListDiffScreen { // let block_hash = match self.get_block_hash(height) { // Ok(block_hash) => block_hash, // Err(e) => { - // self.error = Some(e.to_string()); + // self.ui_state.error = Some(e.to_string()); // return; // } // }; @@ -559,19 +572,19 @@ impl MasternodeListDiffScreen { // .and_then(|coinbase| coinbase.special_transaction_payload.as_ref()) // .and_then(|payload| payload.clone().to_coinbase_payload().ok()) // else { - // self.error = + // self.ui_state.error = // Some(format!("coinbase not found on block hash {}", block_hash)); // return; // }; // coinbase.best_cl_signature // } // Err(e) => { - // self.error = Some(e.to_string()); + // self.ui_state.error = Some(e.to_string()); // return; // } // }; // if let Some(maybe_chain_lock_sig) = maybe_chain_lock_sig { - // self.masternode_list_engine.feed_chain_lock_sig( + // self.data.masternode_list_engine.feed_chain_lock_sig( // block_hash, // BLSSignature::from(maybe_chain_lock_sig.to_bytes()), // ); @@ -624,10 +637,11 @@ impl MasternodeListDiffScreen { // Feed base block hash height if let Ok(base_height) = self.get_height(&mn_list_diff.base_block_hash) { println!("feeding {} {}", base_height, mn_list_diff.base_block_hash); - self.masternode_list_engine + self.data + .masternode_list_engine .feed_block_height(base_height, mn_list_diff.base_block_hash); } else { - self.error = Some(format!( + self.ui_state.error = Some(format!( "Failed to get height for base block hash: {}", mn_list_diff.base_block_hash )); @@ -636,10 +650,11 @@ impl MasternodeListDiffScreen { // Feed block hash height if let Ok(block_height) = self.get_height(&mn_list_diff.block_hash) { println!("feeding {} {}", block_height, mn_list_diff.block_hash); - self.masternode_list_engine + self.data + .masternode_list_engine .feed_block_height(block_height, mn_list_diff.block_hash); } else { - self.error = Some(format!( + self.ui_state.error = Some(format!( "Failed to get height for block hash: {}", mn_list_diff.block_hash )); @@ -649,10 +664,11 @@ impl MasternodeListDiffScreen { /// **Helper function:** Feeds the quorum hash height of a `QuorumEntry` fn feed_quorum_entry_height(&mut self, quorum_entry: &QuorumEntry) { if let Ok(height) = self.get_height(&quorum_entry.quorum_hash) { - self.masternode_list_engine + self.data + .masternode_list_engine .feed_block_height(height, quorum_entry.quorum_hash); } else { - self.error = Some(format!( + self.ui_state.error = Some(format!( "Failed to get height for quorum hash: {}", quorum_entry.quorum_hash )); @@ -660,8 +676,8 @@ impl MasternodeListDiffScreen { } fn parse_heights(&mut self) -> Result<(HeightHash, HeightHash), String> { - let base = if self.base_block_height.is_empty() { - self.base_block_height = "0".to_string(); + let base = if self.input.base_block_height.is_empty() { + self.input.base_block_height = "0".to_string(); match self .app_context .core_client @@ -675,7 +691,7 @@ impl MasternodeListDiffScreen { } } } else { - match self.base_block_height.trim().parse() { + match self.input.base_block_height.trim().parse() { Ok(start) => match self .app_context .core_client @@ -696,7 +712,7 @@ impl MasternodeListDiffScreen { } } }; - let end = if self.end_block_height.is_empty() { + let end = if self.input.end_block_height.is_empty() { match self .app_context .core_client @@ -713,7 +729,7 @@ impl MasternodeListDiffScreen { .get_block_header_info(&block_hash) { Ok(header) => { - self.end_block_height = format!("{}", header.height); + self.input.end_block_height = format!("{}", header.height); ( header.height as u32, BlockHash::from_byte_array(block_hash.to_byte_array()), @@ -729,7 +745,7 @@ impl MasternodeListDiffScreen { } } } else { - match self.end_block_height.trim().parse() { + match self.input.end_block_height.trim().parse() { Ok(end) => match self .app_context .core_client @@ -751,7 +767,10 @@ impl MasternodeListDiffScreen { } fn serialize_masternode_list_engine(&self) -> Result { - match bincode::encode_to_vec(&self.masternode_list_engine, bincode::config::standard()) { + match bincode::encode_to_vec( + &self.data.masternode_list_engine, + bincode::config::standard(), + ) { Ok(encoded_bytes) => Ok(hex::encode(encoded_bytes)), // Convert to hex string Err(e) => Err(format!("Serialization failed: {}", e)), } @@ -762,7 +781,7 @@ impl MasternodeListDiffScreen { let base_height = match self.get_height_and_cache(&base_block_hash) { Ok(height) => height, Err(e) => { - self.error = Some(e); + self.ui_state.error = Some(e); return; } }; @@ -770,12 +789,13 @@ impl MasternodeListDiffScreen { let height = match self.get_height_and_cache(&block_hash) { Ok(height) => height, Err(e) => { - self.error = Some(e); + self.ui_state.error = Some(e); return; } }; - self.mnlist_diffs + self.data + .mnlist_diffs .insert((base_height, height), mn_list_diff.clone()); } @@ -785,12 +805,7 @@ impl MasternodeListDiffScreen { base_block_hash: BlockHash, block_hash: BlockHash, ) -> Option { - let mut known_block_hashes: Vec<_> = self - .mnlist_diffs - .values() - .map(|mn_list_diff| mn_list_diff.block_hash) - .collect(); - known_block_hashes.push(base_block_hash); + let known_block_hashes = self.known_block_hashes_with_base(base_block_hash); println!( "requesting with known_block_hashes {}", known_block_hashes @@ -801,7 +816,7 @@ impl MasternodeListDiffScreen { let qr_info = match p2p_handler.get_qr_info(known_block_hashes, block_hash) { Ok(list_diff) => list_diff, Err(e) => { - self.error = Some(e); + self.ui_state.error = Some(e); return None; } }; @@ -818,7 +833,7 @@ impl MasternodeListDiffScreen { for diff in &qr_info.mn_list_diff_list { self.insert_mn_list_diff(diff) } - self.qr_infos.insert(block_hash, qr_info.clone()); + self.data.qr_infos.insert(block_hash, qr_info.clone()); Some(qr_info) } @@ -832,7 +847,7 @@ impl MasternodeListDiffScreen { let height = match self.get_height_and_cache(&quorum_hash) { Ok(height) => height, Err(e) => { - self.error = Some(e.to_string()); + self.ui_state.error = Some(e.to_string()); return; } }; @@ -845,7 +860,7 @@ impl MasternodeListDiffScreen { { Ok(block_hash) => block_hash, Err(e) => { - self.error = Some(e.to_string()); + self.ui_state.error = Some(e.to_string()); return; } }; @@ -857,6 +872,7 @@ impl MasternodeListDiffScreen { if let Some((oldest_needed_height, _)) = hashes_needed_to_validate.first_key_value() { let (first_engine_height, first_masternode_list) = self + .data .masternode_list_engine .masternode_lists .first_key_value() @@ -867,6 +883,7 @@ impl MasternodeListDiffScreen { (*first_engine_height, first_masternode_list.block_hash) } else { let known_genesis_block_hash = match self + .data .masternode_list_engine .network .known_genesis_block_hash() @@ -880,7 +897,7 @@ impl MasternodeListDiffScreen { { Ok(block_hash) => BlockHash::from_byte_array(block_hash.to_byte_array()), Err(e) => { - self.error = Some(e.to_string()); + self.ui_state.error = Some(e.to_string()); return; } }, @@ -916,35 +933,37 @@ impl MasternodeListDiffScreen { let list_diff = match p2p_handler.get_dml_diff(base_block_hash, block_hash) { Ok(list_diff) => list_diff, Err(e) => { - self.error = Some(e); + self.ui_state.error = Some(e); return; } }; - if base_block_height == 0 && self.masternode_list_engine.masternode_lists.is_empty() { - self.masternode_list_engine = match MasternodeListEngine::initialize_with_diff_to_height( - list_diff.clone(), - block_height, - self.app_context.network, - ) { - Ok(masternode_list_engine) => masternode_list_engine, - Err(e) => { - self.error = Some(e.to_string()); - return; + if base_block_height == 0 && self.data.masternode_list_engine.masternode_lists.is_empty() { + self.data.masternode_list_engine = + match MasternodeListEngine::initialize_with_diff_to_height( + list_diff.clone(), + block_height, + self.app_context.network, + ) { + Ok(masternode_list_engine) => masternode_list_engine, + Err(e) => { + self.ui_state.error = Some(e.to_string()); + return; + } } - } - } else if let Err(e) = self.masternode_list_engine.apply_diff( + } else if let Err(e) = self.data.masternode_list_engine.apply_diff( list_diff.clone(), Some(block_height), false, None, ) { - self.error = Some(e.to_string()); + self.ui_state.error = Some(e.to_string()); return; } - if validate_quorums && !self.masternode_list_engine.masternode_lists.is_empty() { + if validate_quorums && !self.data.masternode_list_engine.masternode_lists.is_empty() { let hashes = self + .data .masternode_list_engine .latest_masternode_list_non_rotating_quorum_hashes( &[LLMQType::Llmqtype50_60, LLMQType::Llmqtype400_85], @@ -952,31 +971,34 @@ impl MasternodeListDiffScreen { ); self.fetch_diffs_with_hashes(p2p_handler, hashes); let hashes = self + .data .masternode_list_engine .latest_masternode_list_rotating_quorum_hashes(&[]); for hash in &hashes { let height = match self.get_height_and_cache(hash) { Ok(height) => height, Err(e) => { - self.error = Some(e.to_string()); + self.ui_state.error = Some(e.to_string()); return; } }; - self.block_height_cache.insert(*hash, height); + self.cache.block_height_cache.insert(*hash, height); } if let Err(e) = self + .data .masternode_list_engine .verify_non_rotating_masternode_list_quorums( block_height, &[LLMQType::Llmqtype50_60, LLMQType::Llmqtype400_85], ) { - self.error = Some(e.to_string()); + self.ui_state.error = Some(e.to_string()); } } - self.mnlist_diffs + self.data + .mnlist_diffs .insert((base_block_height, block_height), list_diff); } @@ -985,7 +1007,7 @@ impl MasternodeListDiffScreen { // match self.parse_heights() { // Ok(a) => a, // Err(e) => { - // self.error = Some(e); + // self.ui_state.error = Some(e); // return; // } // }; @@ -993,7 +1015,7 @@ impl MasternodeListDiffScreen { // let mut p2p_handler = match CoreP2PHandler::new(self.app_context.network, None) { // Ok(p2p_handler) => p2p_handler, // Err(e) => { - // self.error = Some(e); + // self.ui_state.error = Some(e); // return; // } // }; @@ -1009,7 +1031,7 @@ impl MasternodeListDiffScreen { // { // Ok(block_hash) => BlockHash::from_byte_array(block_hash.to_byte_array()), // Err(e) => { - // self.error = Some(e.to_string()); + // self.ui_state.error = Some(e.to_string()); // return; // } // }; @@ -1032,7 +1054,7 @@ impl MasternodeListDiffScreen { // let end_block_hash = match self.app_context.core_client.read().unwrap().get_block_hash(end_height) { // Ok(block_hash) => BlockHash::from_byte_array(block_hash.to_byte_array()), // Err(e) => { - // self.error = Some(e.to_string()); + // self.ui_state.error = Some(e.to_string()); // return; // } // }; @@ -1050,7 +1072,7 @@ impl MasternodeListDiffScreen { // let end_block_hash = match self.app_context.core_client.read().unwrap().get_block_hash(end_height) { // Ok(block_hash) => BlockHash::from_byte_array(block_hash.to_byte_array()), // Err(e) => { - // self.error = Some(e.to_string()); + // self.ui_state.error = Some(e.to_string()); // return; // } // }; @@ -1068,7 +1090,7 @@ impl MasternodeListDiffScreen { // let end_block_hash = match self.app_context.core_client.read().unwrap().get_block_hash(end_height) { // Ok(block_hash) => BlockHash::from_byte_array(block_hash.to_byte_array()), // Err(e) => { - // self.error = Some(e.to_string()); + // self.ui_state.error = Some(e.to_string()); // return; // } // }; @@ -1089,7 +1111,7 @@ impl MasternodeListDiffScreen { // let end_block_hash = match self.app_context.core_client.read().unwrap().get_block_hash(end_height) { // Ok(block_hash) => BlockHash::from_byte_array(block_hash.to_byte_array()), // Err(e) => { - // self.error = Some(e.to_string()); + // self.ui_state.error = Some(e.to_string()); // return; // } // }; @@ -1103,49 +1125,51 @@ impl MasternodeListDiffScreen { // } // // // Reset selections when new data is loaded - // self.selected_dml_diff_key = None; - // self.selected_quorum_in_diff_index = None; + // self.selection.selected_dml_diff_key = None; + // self.selection.selected_quorum_in_diff_index = None; // } /// Clear all data and reset to initial state pub(crate) fn clear(&mut self) { - self.masternode_list_engine = + self.data.masternode_list_engine = MasternodeListEngine::default_for_network(self.app_context.network); // Clear cached data structures - self.mnlist_diffs.clear(); - self.qr_infos.clear(); - self.chain_locked_blocks.clear(); - self.instant_send_transactions.clear(); - self.block_height_cache.clear(); - self.block_hash_cache.clear(); - self.masternode_list_quorum_hash_cache.clear(); - self.masternode_lists_with_all_quorum_heights_known.clear(); - self.dml_diffs_with_cached_quorum_heights.clear(); - self.chain_lock_sig_cache.clear(); - self.chain_lock_reversed_sig_cache.clear(); + self.data.mnlist_diffs.clear(); + self.data.qr_infos.clear(); + self.incoming.chain_locked_blocks.clear(); + self.incoming.instant_send_transactions.clear(); + self.cache.block_height_cache.clear(); + self.cache.block_hash_cache.clear(); + self.cache.masternode_list_quorum_hash_cache.clear(); + self.cache + .masternode_lists_with_all_quorum_heights_known + .clear(); + self.cache.dml_diffs_with_cached_quorum_heights.clear(); + self.cache.chain_lock_sig_cache.clear(); + self.cache.chain_lock_reversed_sig_cache.clear(); // Reset selections and UI state - self.selected_dml_diff_key = None; - self.selected_dml_height_key = None; - self.selected_option_index = None; - self.selected_quorum_in_diff_index = None; - self.selected_masternode_in_diff_index = None; - self.selected_quorum_hash_in_mnlist_diff = None; - self.selected_masternode_pro_tx_hash = None; - self.selected_qr_item = None; - self.selected_core_item = None; - self.pending = None; - self.queued_task = None; - self.search_term = None; - self.error = None; - self.message = None; + self.selection.selected_dml_diff_key = None; + self.selection.selected_dml_height_key = None; + self.selection.selected_option_index = None; + self.selection.selected_quorum_in_diff_index = None; + self.selection.selected_masternode_in_diff_index = None; + self.selection.selected_quorum_hash_in_mnlist_diff = None; + self.selection.selected_masternode_pro_tx_hash = None; + self.selection.selected_qr_item = None; + self.selection.selected_core_item = None; + self.task.pending = None; + self.task.queued_task = None; + self.input.search_term = None; + self.ui_state.error = None; + self.ui_state.message = None; } /// Clear all data except the oldest MNList diff starting from height 0 fn clear_keep_base(&mut self) { let (engine, start_end_diff) = - if let Some(((start, end), oldest_diff)) = self.mnlist_diffs.first_key_value() { + if let Some(((start, end), oldest_diff)) = self.data.mnlist_diffs.first_key_value() { if start == &0 { MasternodeListEngine::initialize_with_diff_to_height( oldest_diff.clone(), @@ -1170,23 +1194,23 @@ impl MasternodeListDiffScreen { ) }; - self.masternode_list_engine = engine; - self.mnlist_diffs = Default::default(); + self.data.masternode_list_engine = engine; + self.data.mnlist_diffs = Default::default(); if let Some((key, oldest_diff)) = start_end_diff { - self.mnlist_diffs.insert(key, oldest_diff); + self.data.mnlist_diffs.insert(key, oldest_diff); } - self.selected_dml_diff_key = None; - self.selected_dml_height_key = None; - self.selected_option_index = None; - self.selected_quorum_in_diff_index = None; - self.selected_masternode_in_diff_index = None; - self.selected_quorum_hash_in_mnlist_diff = None; - self.selected_masternode_pro_tx_hash = None; - self.qr_infos = Default::default(); - self.message = None; + self.selection.selected_dml_diff_key = None; + self.selection.selected_dml_height_key = None; + self.selection.selected_option_index = None; + self.selection.selected_quorum_in_diff_index = None; + self.selection.selected_masternode_in_diff_index = None; + self.selection.selected_quorum_hash_in_mnlist_diff = None; + self.selection.selected_masternode_pro_tx_hash = None; + self.data.qr_infos = Default::default(); + self.ui_state.message = None; // Clear chain lock signatures caches as these are independent of the retained base diff - self.chain_lock_sig_cache.clear(); - self.chain_lock_reversed_sig_cache.clear(); + self.cache.chain_lock_sig_cache.clear(); + self.cache.chain_lock_reversed_sig_cache.clear(); } /// Fetch the MNList diffs between the given base and end block heights. @@ -1198,7 +1222,7 @@ impl MasternodeListDiffScreen { match self.parse_heights() { Ok(a) => a, Err(e) => { - self.error = Some(e); + self.ui_state.error = Some(e); return; } }; @@ -1206,7 +1230,7 @@ impl MasternodeListDiffScreen { let mut p2p_handler = match CoreP2PHandler::new(self.app_context.network, None) { Ok(p2p_handler) => p2p_handler, Err(e) => { - self.error = Some(e); + self.ui_state.error = Some(e); return; } }; @@ -1221,8 +1245,8 @@ impl MasternodeListDiffScreen { ); // Reset selections when new data is loaded - self.selected_dml_diff_key = None; - self.selected_quorum_in_diff_index = None; + self.selection.selected_dml_diff_key = None; + self.selection.selected_quorum_in_diff_index = None; } #[allow(dead_code)] @@ -1230,7 +1254,7 @@ impl MasternodeListDiffScreen { let ((_, base_block_hash), (_, block_hash)) = match self.parse_heights() { Ok(a) => a, Err(e) => { - self.error = Some(e); + self.ui_state.error = Some(e); return; } }; @@ -1238,7 +1262,7 @@ impl MasternodeListDiffScreen { let mut p2p_handler = match CoreP2PHandler::new(self.app_context.network, None) { Ok(p2p_handler) => p2p_handler, Err(e) => { - self.error = Some(e); + self.ui_state.error = Some(e); return; } }; @@ -1246,8 +1270,8 @@ impl MasternodeListDiffScreen { self.fetch_rotated_quorum_info(&mut p2p_handler, base_block_hash, block_hash); // Reset selections when new data is loaded - self.selected_dml_diff_key = None; - self.selected_quorum_in_diff_index = None; + self.selection.selected_dml_diff_key = None; + self.selection.selected_quorum_in_diff_index = None; } #[allow(dead_code)] @@ -1256,7 +1280,7 @@ impl MasternodeListDiffScreen { match self.parse_heights() { Ok(a) => a, Err(e) => { - self.error = Some(e); + self.ui_state.error = Some(e); return; } }; @@ -1286,8 +1310,8 @@ impl MasternodeListDiffScreen { #[allow(dead_code)] fn sync(&mut self) { - if !self.syncing { - self.syncing = true; + if !self.task.syncing { + self.task.syncing = true; self.fetch_end_qr_info_with_dmls(); } } @@ -1297,7 +1321,7 @@ impl MasternodeListDiffScreen { let ((_, base_block_hash), (_, block_hash)) = match self.parse_heights() { Ok(a) => a, Err(e) => { - self.error = Some(e); + self.ui_state.error = Some(e); return; } }; @@ -1305,7 +1329,7 @@ impl MasternodeListDiffScreen { let mut p2p_handler = match CoreP2PHandler::new(self.app_context.network, None) { Ok(p2p_handler) => p2p_handler, Err(e) => { - self.error = Some(e); + self.ui_state.error = Some(e); return; } }; @@ -1328,7 +1352,7 @@ impl MasternodeListDiffScreen { None => match CoreP2PHandler::new(self.app_context.network, None) { Ok(p2p_handler) => p2p_handler, Err(e) => { - self.error = Some(e); + self.ui_state.error = Some(e); return; } }, @@ -1337,7 +1361,7 @@ impl MasternodeListDiffScreen { // Extracting immutable references before calling `feed_qr_info` let get_height_fn = { - let block_height_cache = &self.block_height_cache; + let block_height_cache = &self.cache.block_height_cache; let app_context = &self.app_context; move |block_hash: &BlockHash| { @@ -1363,14 +1387,16 @@ impl MasternodeListDiffScreen { }; if let Err(e) = - self.masternode_list_engine + self.data + .masternode_list_engine .feed_qr_info(qr_info, false, true, Some(get_height_fn)) { - self.error = Some(e.to_string()); + self.ui_state.error = Some(e.to_string()); return; } let hashes = self + .data .masternode_list_engine .latest_masternode_list_non_rotating_quorum_hashes( &[LLMQType::Llmqtype50_60, LLMQType::Llmqtype400_85], @@ -1378,33 +1404,36 @@ impl MasternodeListDiffScreen { ); self.fetch_diffs_with_hashes(&mut p2p_handler, hashes); let hashes = self + .data .masternode_list_engine .latest_masternode_list_rotating_quorum_hashes(&[]); for hash in &hashes { let height = match self.get_height_and_cache(hash) { Ok(height) => height, Err(e) => { - self.error = Some(e.to_string()); + self.ui_state.error = Some(e.to_string()); return; } }; - self.block_height_cache.insert(*hash, height); + self.cache.block_height_cache.insert(*hash, height); } - if let Some(latest_masternode_list) = self.masternode_list_engine.latest_masternode_list() + if let Some(latest_masternode_list) = + self.data.masternode_list_engine.latest_masternode_list() && let Err(e) = self + .data .masternode_list_engine .verify_non_rotating_masternode_list_quorums( latest_masternode_list.known_height, &[LLMQType::Llmqtype50_60, LLMQType::Llmqtype400_85], ) { - self.error = Some(e.to_string()); + self.ui_state.error = Some(e.to_string()); } // Reset selections when new data is loaded - self.selected_dml_diff_key = None; - self.selected_quorum_in_diff_index = None; + self.selection.selected_dml_diff_key = None; + self.selection.selected_quorum_in_diff_index = None; } /// Render the input area at the top (base and end block height fields plus Get DMLs button) @@ -1415,13 +1444,17 @@ impl MasternodeListDiffScreen { .show(ui, |ui| { ui.horizontal(|ui| { ui.label("Base Block Height:"); - ui.add(TextEdit::singleline(&mut self.base_block_height).desired_width(80.0)); + ui.add( + TextEdit::singleline(&mut self.input.base_block_height).desired_width(80.0), + ); ui.label("End Block Height:"); - ui.add(TextEdit::singleline(&mut self.end_block_height).desired_width(80.0)); + ui.add( + TextEdit::singleline(&mut self.input.end_block_height).desired_width(80.0), + ); if ui.button("Get single end DML diff").clicked() && let Ok(((base_h, base_hash), (h, hash))) = self.parse_heights() { - self.pending = Some(PendingTask::DmlDiffSingle); + self.task.pending = Some(PendingTask::DmlDiffSingle); action = AppAction::BackendTask(BackendTask::MnListTask( MnListTask::FetchEndDmlDiff { base_block_height: base_h, @@ -1435,14 +1468,9 @@ impl MasternodeListDiffScreen { if ui.button("Get single end QR info").clicked() && let Ok(((_, base_hash), (_, hash))) = self.parse_heights() { - self.pending = Some(PendingTask::QrInfo); + self.task.pending = Some(PendingTask::QrInfo); // Build known_block_hashes from current diffs + base hash (old UI behavior) - let mut known_block_hashes: Vec<_> = self - .mnlist_diffs - .values() - .map(|mn_list_diff| mn_list_diff.block_hash) - .collect(); - known_block_hashes.push(base_hash); + let known_block_hashes = self.known_block_hashes_with_base(base_hash); action = AppAction::BackendTask(BackendTask::MnListTask( MnListTask::FetchEndQrInfo { known_block_hashes, @@ -1453,7 +1481,7 @@ impl MasternodeListDiffScreen { if ui.button("Get DMLs w/o rotation").clicked() && let Ok(((base_h, base_hash), (h, hash))) = self.parse_heights() { - self.pending = Some(PendingTask::DmlDiffNoRotation); + self.task.pending = Some(PendingTask::DmlDiffNoRotation); action = AppAction::BackendTask(BackendTask::MnListTask( MnListTask::FetchEndDmlDiff { base_block_height: base_h, @@ -1467,14 +1495,9 @@ impl MasternodeListDiffScreen { if ui.button("Get DMLs w/ rotation").clicked() && let Ok(((_, base_hash), (_, hash))) = self.parse_heights() { - self.pending = Some(PendingTask::QrInfoWithDmls); + self.task.pending = Some(PendingTask::QrInfoWithDmls); // Build known_block_hashes from current diffs + base hash (old UI behavior) - let mut known_block_hashes: Vec<_> = self - .mnlist_diffs - .values() - .map(|mn_list_diff| mn_list_diff.block_hash) - .collect(); - known_block_hashes.push(base_hash); + let known_block_hashes = self.known_block_hashes_with_base(base_hash); action = AppAction::BackendTask(BackendTask::MnListTask( MnListTask::FetchEndQrInfoWithDmls { known_block_hashes, @@ -1485,14 +1508,9 @@ impl MasternodeListDiffScreen { if ui.button("Sync").clicked() && let Ok(((_, base_hash), (_, hash))) = self.parse_heights() { - self.pending = Some(PendingTask::QrInfoWithDmls); + self.task.pending = Some(PendingTask::QrInfoWithDmls); // Build known_block_hashes from current diffs + base hash (old UI behavior) - let mut known_block_hashes: Vec<_> = self - .mnlist_diffs - .values() - .map(|mn_list_diff| mn_list_diff.block_hash) - .collect(); - known_block_hashes.push(base_hash); + let known_block_hashes = self.known_block_hashes_with_base(base_hash); action = AppAction::BackendTask(BackendTask::MnListTask( MnListTask::FetchEndQrInfoWithDmls { known_block_hashes, @@ -1503,7 +1521,7 @@ impl MasternodeListDiffScreen { if ui.button("Get chain locks").clicked() && let Ok(((base_h, _), (h, _))) = self.parse_heights() { - self.pending = Some(PendingTask::ChainLocks); + self.task.pending = Some(PendingTask::ChainLocks); action = AppAction::BackendTask(BackendTask::MnListTask( MnListTask::FetchChainLocks { base_block_height: base_h, @@ -1539,6 +1557,93 @@ impl MasternodeListDiffScreen { action } + fn render_message_banner(&mut self, ui: &mut Ui) { + let Some((msg, msg_type)) = self.ui_state.message.clone() else { + return; + }; + + let dark_mode = ui.ctx().style().visuals.dark_mode; + let message_color = match msg_type { + MessageType::Error => Color32::from_rgb(255, 100, 100), + MessageType::Info => crate::ui::theme::DashColors::text_primary(dark_mode), + // Dark green for success text + MessageType::Success => Color32::DARK_GREEN, + }; + ui.horizontal(|ui| { + Frame::new() + .fill(message_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, message_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(msg).color(message_color)); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.ui_state.message = None; + } + }); + }); + }); + ui.add_space(10.0); + } + + fn render_error_banner(&mut self, ui: &mut Ui) { + let Some(error_msg) = self.ui_state.error.clone() else { + return; + }; + + let message_color = Color32::from_rgb(255, 100, 100); + ui.horizontal(|ui| { + Frame::new() + .fill(message_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, message_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(error_msg).color(message_color)); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.ui_state.error = None; + } + }); + }); + }); + ui.add_space(10.0); + } + + fn render_pending_status(&self, ui: &mut Ui) { + let Some(pending) = self.task.pending else { + return; + }; + + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.scope(|ui| { + let style = ui.style_mut(); + // Force spinner (fg stroke) to Dash Blue + style.visuals.widgets.inactive.fg_stroke.color = + crate::ui::theme::DashColors::DASH_BLUE; + style.visuals.widgets.active.fg_stroke.color = + crate::ui::theme::DashColors::DASH_BLUE; + style.visuals.widgets.hovered.fg_stroke.color = + crate::ui::theme::DashColors::DASH_BLUE; + ui.add(egui::Spinner::new()); + }); + let label = match pending { + PendingTask::DmlDiffSingle => "Fetching DML diff…", + PendingTask::DmlDiffNoRotation => "Fetching DMLs (no rotation)…", + PendingTask::QrInfo => "Fetching QR info…", + PendingTask::QrInfoWithDmls => "Fetching QR info + DMLs…", + PendingTask::ChainLocks => "Fetching chain locks…", + }; + let text_primary = DashColors::text_primary(ui.ctx().style().visuals.dark_mode); + ui.colored_label(text_primary, label); + }); + ui.add_space(6.0); + } + fn load_masternode_list_engine(&mut self) { if let Some(path) = rfd::FileDialog::new() .add_filter("Binary", &["dat"]) @@ -1551,7 +1656,7 @@ impl MasternodeListDiffScreen { bincode::config::standard(), ) { Ok((engine, _)) => { - self.masternode_list_engine = engine; + self.data.masternode_list_engine = engine; } Err(e) => { eprintln!("Failed to decode QRInfo: {}", e); @@ -1570,7 +1675,7 @@ impl MasternodeListDiffScreen { let serialized = match self.serialize_masternode_list_engine() { Ok(serialized) => serialized, Err(e) => { - self.error = Some(format!("Serialization failed: {}", e)); + self.ui_state.error = Some(format!("Serialization failed: {}", e)); return; } }; @@ -1589,7 +1694,7 @@ impl MasternodeListDiffScreen { println!("Masternode list engine saved to {:?}", path); } Err(e) => { - self.error = Some(format!("Failed to save file: {}", e)); + self.ui_state.error = Some(format!("Failed to save file: {}", e)); } } } @@ -1600,19 +1705,19 @@ impl MasternodeListDiffScreen { ScrollArea::vertical() .id_salt("dml_list_scroll_area") .show(ui, |ui| { - for height in self.masternode_list_engine.masternode_lists.keys() { + for height in self.data.masternode_list_engine.masternode_lists.keys() { let height_label = format!("{}", height); if ui .selectable_label( - self.selected_dml_height_key == Some(*height), + self.selection.selected_dml_height_key == Some(*height), height_label, ) .clicked() { - self.selected_dml_diff_key = None; - self.selected_dml_height_key = Some(*height); - self.selected_quorum_in_diff_index = None; + self.selection.selected_dml_diff_key = None; + self.selection.selected_dml_height_key = Some(*height); + self.selection.selected_quorum_in_diff_index = None; } } }); @@ -1624,16 +1729,19 @@ impl MasternodeListDiffScreen { ScrollArea::vertical() .id_salt("dml_list_scroll_area") .show(ui, |ui| { - for (key, _dml) in self.mnlist_diffs.iter() { + for (key, _dml) in self.data.mnlist_diffs.iter() { let block_label = format!("Base: {} -> Block: {}", key.0, key.1); if ui - .selectable_label(self.selected_dml_diff_key == Some(*key), block_label) + .selectable_label( + self.selection.selected_dml_diff_key == Some(*key), + block_label, + ) .clicked() { - self.selected_dml_diff_key = Some(*key); - self.selected_dml_height_key = None; - self.selected_quorum_in_diff_index = None; + self.selection.selected_dml_diff_key = Some(*key); + self.selection.selected_dml_height_key = None; + self.selection.selected_quorum_in_diff_index = None; } } }); @@ -1643,100 +1751,62 @@ impl MasternodeListDiffScreen { fn render_new_quorums(&mut self, ui: &mut Ui) { ui.heading("New Quorums"); - let should_get_heights = if let Some(selected_key) = self.selected_dml_diff_key { - if self.mnlist_diffs.contains_key(&selected_key) { - !self - .dml_diffs_with_cached_quorum_heights - .contains(&selected_key) - } else { - false - } - } else { - false + let Some(selected_key) = self.selection.selected_dml_diff_key else { + ui.label("Select a block height to show quorums."); + return; }; - let heights = if should_get_heights { - if let Some(selected_key) = self.selected_dml_diff_key { - if let Some(quorums) = self - .mnlist_diffs - .get(&selected_key) - .map(|dml| dml.new_quorums.clone()) - { - let mut map = HashMap::new(); - for quorum in quorums { - let height = self - .get_height_and_cache(&quorum.quorum_hash) - .ok() - .unwrap_or_default(); - map.insert(quorum.quorum_hash, height); - } - map - } else { - HashMap::new() - } - } else { - HashMap::new() - } - } else if let Some(selected_key) = self.selected_dml_diff_key { - if let Some(quorums) = self - .mnlist_diffs - .get(&selected_key) - .map(|dml| dml.new_quorums.clone()) - { - let mut map = HashMap::new(); - for quorum in quorums { - let height = self - .get_height(&quorum.quorum_hash) - .ok() - .unwrap_or_default(); - map.insert(quorum.quorum_hash, height); - } - map - } else { - HashMap::new() - } - } else { - HashMap::new() + let Some(dml) = self.data.mnlist_diffs.get(&selected_key) else { + ui.label("Select a block height to show quorums."); + return; }; - let new_quorums = self - .selected_dml_diff_key - .and_then(|selected_key| self.mnlist_diffs.get(&selected_key)) - .map(|diff| &diff.new_quorums); + let should_get_heights = !self + .cache + .dml_diffs_with_cached_quorum_heights + .contains(&selected_key); + let new_quorums = dml.new_quorums.clone(); + let mut heights: HashMap = HashMap::new(); + for quorum in &new_quorums { + let height = if should_get_heights { + self.get_height_and_cache(&quorum.quorum_hash) + } else { + self.get_height(&quorum.quorum_hash) + } + .ok() + .unwrap_or_default(); + heights.insert(quorum.quorum_hash, height); + } - if let Some(new_quorums) = new_quorums { - ScrollArea::vertical() - .id_salt("quorum_list_scroll_area") - .show(ui, |ui| { - for (q_index, quorum) in new_quorums.iter().enumerate() { - let quorum_height = heights - .get(&quorum.quorum_hash) - .copied() - .unwrap_or_default(); - if ui - .selectable_label( - self.selected_quorum_in_diff_index == Some(q_index), - format!( - "Quorum height {} [..]{}{} Type: {}", - quorum_height, - quorum.quorum_hash.to_string().as_str().split_at(58).1, - quorum - .quorum_index - .map(|i| format!(" (index {})", i)) - .unwrap_or_default(), - QuorumType::from(quorum.llmq_type as u32) - ), - ) - .clicked() - { - self.selected_quorum_in_diff_index = Some(q_index); - self.selected_masternode_in_diff_index = None; - } + ScrollArea::vertical() + .id_salt("quorum_list_scroll_area") + .show(ui, |ui| { + for (q_index, quorum) in new_quorums.iter().enumerate() { + let quorum_height = heights + .get(&quorum.quorum_hash) + .copied() + .unwrap_or_default(); + if ui + .selectable_label( + self.selection.selected_quorum_in_diff_index == Some(q_index), + format!( + "Quorum height {} [..]{}{} Type: {}", + quorum_height, + quorum.quorum_hash.to_string().as_str().split_at(58).1, + quorum + .quorum_index + .map(|i| format!(" (index {})", i)) + .unwrap_or_default(), + QuorumType::from(quorum.llmq_type as u32) + ), + ) + .clicked() + { + self.selection.selected_quorum_in_diff_index = Some(q_index); + self.selection.selected_masternode_in_diff_index = None; } - }); - } else { - ui.label("Select a block height to show quorums."); - } + } + }); } fn render_selected_masternode_list_items(&mut self, ui: &mut Ui) { @@ -1744,7 +1814,7 @@ impl MasternodeListDiffScreen { // Define available options for selection let options = ["Quorums", "Masternodes"]; - let selected_index = self.selected_option_index.unwrap_or(0); + let selected_index = self.selection.selected_option_index.unwrap_or(0); // Render the selection buttons ui.horizontal(|ui| { @@ -1753,7 +1823,7 @@ impl MasternodeListDiffScreen { .selectable_label(selected_index == index, *option) .clicked() { - self.selected_option_index = Some(index); + self.selection.selected_option_index = Some(index); } } }); @@ -1761,7 +1831,7 @@ impl MasternodeListDiffScreen { ui.separator(); // Borrow mn_list separately to avoid multiple borrows of `self` - if self.selected_dml_height_key.is_some() { + if self.selection.selected_dml_height_key.is_some() { ScrollArea::vertical() .id_salt("mnlist_items_scroll_area") .show(ui, |ui| match selected_index { @@ -1777,12 +1847,14 @@ impl MasternodeListDiffScreen { fn render_quorums_in_masternode_list(&mut self, ui: &mut Ui) { let mut heights: BTreeMap = BTreeMap::new(); let mut masternode_block_hash = None; - if let Some(selected_height) = self.selected_dml_height_key { + if let Some(selected_height) = self.selection.selected_dml_height_key { if !self + .cache .masternode_lists_with_all_quorum_heights_known .contains(&selected_height) { if let Some(quorum_hashes) = self + .data .masternode_list_engine .masternode_lists .get(&selected_height) @@ -1800,10 +1872,12 @@ impl MasternodeListDiffScreen { } } } - self.masternode_lists_with_all_quorum_heights_known + self.cache + .masternode_lists_with_all_quorum_heights_known .insert(selected_height); } if let Some(mn_list) = self + .data .masternode_list_engine .masternode_lists .get(&selected_height) @@ -1821,7 +1895,8 @@ impl MasternodeListDiffScreen { } } } - self.masternode_list_quorum_hash_cache + self.cache + .masternode_list_quorum_hash_cache .entry(mn_list.block_hash) .or_insert_with(|| { let mut btree_map = BTreeMap::new(); @@ -1841,9 +1916,11 @@ impl MasternodeListDiffScreen { }); } } - if let Some(quorums) = masternode_block_hash - .and_then(|block_hash| self.masternode_list_quorum_hash_cache.get(&block_hash)) - { + if let Some(quorums) = masternode_block_hash.and_then(|block_hash| { + self.cache + .masternode_list_quorum_hash_cache + .get(&block_hash) + }) { ui.heading("Quorums in Masternode List"); ui.label("(excluding 50_60 and 400_85)"); ScrollArea::vertical() @@ -1858,7 +1935,7 @@ impl MasternodeListDiffScreen { for (quorum_height, quorum_entry) in quorum_map.iter() { if ui .selectable_label( - self.selected_quorum_hash_in_mnlist_diff + self.selection.selected_quorum_hash_in_mnlist_diff == Some(( *llmq_type, quorum_entry.quorum_entry.quorum_hash, @@ -1873,10 +1950,10 @@ impl MasternodeListDiffScreen { ) .clicked() { - self.selected_quorum_hash_in_mnlist_diff = + self.selection.selected_quorum_hash_in_mnlist_diff = Some((*llmq_type, quorum_entry.quorum_entry.quorum_hash)); - self.selected_masternode_pro_tx_hash = None; - self.selected_dml_diff_key = None; + self.selection.selected_masternode_pro_tx_hash = None; + self.selection.selected_dml_diff_key = None; } } } @@ -1890,7 +1967,7 @@ impl MasternodeListDiffScreen { mn_list: &MasternodeList, ) -> BTreeMap { // If no search term, return all masternodes - if let Some(search_term) = &self.search_term { + if let Some(search_term) = &self.input.search_term { let search_term = search_term.to_lowercase(); if search_term.len() < 3 { @@ -1941,11 +2018,11 @@ impl MasternodeListDiffScreen { fn render_search_bar(&mut self, ui: &mut Ui) { ui.horizontal(|ui| { ui.label("Search:"); - let mut search_term = self.search_term.clone().unwrap_or_default(); + let mut search_term = self.input.search_term.clone().unwrap_or_default(); let response = ui.add(TextEdit::singleline(&mut search_term).desired_width(200.0)); if response.changed() { - self.search_term = if search_term.trim().is_empty() { + self.input.search_term = if search_term.trim().is_empty() { None } else { Some(search_term) @@ -1955,50 +2032,42 @@ impl MasternodeListDiffScreen { } fn render_masternodes_in_masternode_list(&mut self, ui: &mut Ui) { - if let Some(selected_height) = self.selected_dml_height_key - && self - .masternode_list_engine - .masternode_lists - .contains_key(&selected_height) - { + if self.selected_mn_list().is_some() { ui.heading("Masternodes in List"); self.render_search_bar(ui); } - if let Some(selected_height) = self.selected_dml_height_key - && let Some(mn_list) = self - .masternode_list_engine - .masternode_lists - .get(&selected_height) - { - let filtered_masternodes = self.filter_masternodes(mn_list); - ScrollArea::vertical() - .id_salt("masternode_list_scroll_area") - .show(ui, |ui| { - for (pro_tx_hash, masternode) in filtered_masternodes.iter() { - if ui - .selectable_label( - self.selected_masternode_pro_tx_hash == Some(*pro_tx_hash), - format!( - "{} {} {}", - if masternode.masternode_list_entry.mn_type - == EntryMasternodeType::Regular - { - "MN" - } else { - "EN" - }, - masternode.masternode_list_entry.service_address.ip(), - pro_tx_hash.to_string().as_str().split_at(5).0 - ), - ) - .clicked() - { - self.selected_quorum_hash_in_mnlist_diff = None; - self.selected_masternode_pro_tx_hash = Some(*pro_tx_hash); - } + let Some(mn_list) = self.selected_mn_list() else { + return; + }; + + let filtered_masternodes = self.filter_masternodes(mn_list); + ScrollArea::vertical() + .id_salt("masternode_list_scroll_area") + .show(ui, |ui| { + for (pro_tx_hash, masternode) in filtered_masternodes.iter() { + if ui + .selectable_label( + self.selection.selected_masternode_pro_tx_hash == Some(*pro_tx_hash), + format!( + "{} {} {}", + if masternode.masternode_list_entry.mn_type + == EntryMasternodeType::Regular + { + "MN" + } else { + "EN" + }, + masternode.masternode_list_entry.service_address.ip(), + pro_tx_hash.to_string().as_str().split_at(5).0 + ), + ) + .clicked() + { + self.selection.selected_quorum_hash_in_mnlist_diff = None; + self.selection.selected_masternode_pro_tx_hash = Some(*pro_tx_hash); } - }); - } + } + }); } fn render_masternode_list_page(&mut self, ui: &mut Ui) { @@ -2035,9 +2104,9 @@ impl MasternodeListDiffScreen { egui::Vec2::new(ui.available_width(), ui.available_height()), Layout::top_down(Align::Min), |ui| { - if self.selected_quorum_hash_in_mnlist_diff.is_some() { + if self.selection.selected_quorum_hash_in_mnlist_diff.is_some() { self.render_quorum_details(ui); - } else if self.selected_masternode_pro_tx_hash.is_some() { + } else if self.selection.selected_masternode_pro_tx_hash.is_some() { self.render_mn_details(ui); } }, @@ -2060,7 +2129,7 @@ impl MasternodeListDiffScreen { "Load Masternode List Engine", ]; - if self.syncing { + if self.task.syncing { tabs.push("Stop Syncing"); } @@ -2070,7 +2139,7 @@ impl MasternodeListDiffScreen { .show(ui, |ui| { ui.horizontal(|ui| { for (index, tab) in tabs.iter().enumerate() { - let is_selected = self.selected_tab == index; + let is_selected = self.ui_state.selected_tab == index; if is_selected { // Match the selected look used under "Masternode List Explorer" let _ = ui.selectable_label(true, *tab); @@ -2078,15 +2147,16 @@ impl MasternodeListDiffScreen { match index { 7 => { // Show the popup when "Masternode List Engine" is selected - self.show_popup_for_render_masternode_list_engine = true; + self.ui_state.show_popup_for_render_masternode_list_engine = + true; } 8 => { self.load_masternode_list_engine(); } 9 => { - self.syncing = false; + self.task.syncing = false; } - index => self.selected_tab = index, + index => self.ui_state.selected_tab = index, } } } @@ -2099,7 +2169,7 @@ impl MasternodeListDiffScreen { // Scroll only the content below the tab row; for the Masternode Lists page, // let its own columns manage scrolling independently. - if self.selected_tab == 0 { + if self.ui_state.selected_tab == 0 { // Make the Masternode Lists section occupy remaining height let full_w = ui.available_width(); let full_h = ui.available_height(); @@ -2114,7 +2184,7 @@ impl MasternodeListDiffScreen { ScrollArea::vertical() .auto_shrink([false; 2]) .id_salt("dml_tab_content_scroll") - .show(ui, |ui| match self.selected_tab { + .show(ui, |ui| match self.ui_state.selected_tab { 1 => self.render_quorums(ui), 2 => self.render_diffs(ui), 3 => self.render_qr_info(ui), @@ -2126,7 +2196,7 @@ impl MasternodeListDiffScreen { } // Render the confirmation popup if needed - if self.show_popup_for_render_masternode_list_engine { + if self.ui_state.show_popup_for_render_masternode_list_engine { egui::Window::new("Confirmation") .collapsible(false) .resizable(false) @@ -2137,10 +2207,10 @@ impl MasternodeListDiffScreen { ui.horizontal(|ui| { if ui.button("Yes").clicked() { self.save_masternode_list_engine(); - self.show_popup_for_render_masternode_list_engine = false; + self.ui_state.show_popup_for_render_masternode_list_engine = false; } if ui.button("Cancel").clicked() { - self.show_popup_for_render_masternode_list_engine = false; + self.ui_state.show_popup_for_render_masternode_list_engine = false; } }); }); @@ -2162,7 +2232,7 @@ impl MasternodeListDiffScreen { ui.label("Chain Lock Sig"); ui.end_row(); - for ((height, block_hash), sig) in &self.chain_lock_sig_cache { + for ((height, block_hash), sig) in &self.cache.chain_lock_sig_cache { ui.label(format!("{}", height)); ui.label(format!("{}", block_hash)); if let Some(sig) = sig { @@ -2191,7 +2261,7 @@ impl MasternodeListDiffScreen { { // Serialize and save the block container let serialized_data = bincode::encode_to_vec( - &self.masternode_list_engine.block_container, + &self.data.masternode_list_engine.block_container, bincode::config::standard(), ) .expect("serialize container"); @@ -2207,7 +2277,8 @@ impl MasternodeListDiffScreen { .show(ui, |ui| { ui.label(format!( "Total Known Blocks: {}", - self.masternode_list_engine + self.data + .masternode_list_engine .block_container .known_block_count() )); @@ -2221,7 +2292,7 @@ impl MasternodeListDiffScreen { ui.end_row(); let MasternodeListEngineBlockContainer::BTreeMapContainer(map) = - &self.masternode_list_engine.block_container; + &self.data.masternode_list_engine.block_container; // Sort block heights for ordered display let mut known_blocks: Vec<_> = map.block_heights.iter().collect(); @@ -2252,9 +2323,11 @@ impl MasternodeListDiffScreen { .save_file() { // Serialize and save the block container - let serialized_data = - bincode::encode_to_vec(&self.mnlist_diffs, bincode::config::standard()) - .expect("serialize container"); + let serialized_data = bincode::encode_to_vec( + &self.data.mnlist_diffs, + bincode::config::standard(), + ) + .expect("serialize container"); if let Err(e) = std::fs::write(&path, serialized_data) { eprintln!("Failed to write file: {}", e); } @@ -2288,9 +2361,9 @@ impl MasternodeListDiffScreen { egui::Vec2::new(ui.available_width(), ui.available_height()), // Right column takes remaining space Layout::top_down(Align::Min), |ui| { - if self.selected_quorum_in_diff_index.is_some() { + if self.selection.selected_quorum_in_diff_index.is_some() { self.render_quorum_details(ui); - } else if self.selected_masternode_in_diff_index.is_some() { + } else if self.selection.selected_masternode_in_diff_index.is_some() { self.render_mn_details(ui); } }, @@ -2300,74 +2373,74 @@ impl MasternodeListDiffScreen { fn render_masternode_changes(&mut self, ui: &mut Ui) { ui.heading("Masternode changes"); - if let Some(selected_key) = self.selected_dml_diff_key { - if let Some(dml) = self.mnlist_diffs.get(&selected_key) { - ScrollArea::vertical() - .id_salt("quorum_list_scroll_area") - .show(ui, |ui| { - for (m_index, masternode) in dml.new_masternodes.iter().enumerate() { - if ui - .selectable_label( - self.selected_masternode_in_diff_index == Some(m_index), - format!( - "{} {} {}", - if masternode.mn_type == EntryMasternodeType::Regular { - "MN" - } else { - "EN" - }, - masternode.service_address.ip(), - masternode - .pro_reg_tx_hash - .to_string() - .as_str() - .split_at(5) - .0 - ), - ) - .clicked() - { - self.selected_quorum_in_diff_index = None; - self.selected_masternode_in_diff_index = Some(m_index); - } - } - }); - } - } else { + let Some(dml) = self.selected_dml() else { ui.label("Select a block height to show quorums."); - } + return; + }; + let new_masternodes = dml.new_masternodes.clone(); + + ScrollArea::vertical() + .id_salt("quorum_list_scroll_area") + .show(ui, |ui| { + for (m_index, masternode) in new_masternodes.iter().enumerate() { + if ui + .selectable_label( + self.selection.selected_masternode_in_diff_index == Some(m_index), + format!( + "{} {} {}", + if masternode.mn_type == EntryMasternodeType::Regular { + "MN" + } else { + "EN" + }, + masternode.service_address.ip(), + masternode + .pro_reg_tx_hash + .to_string() + .as_str() + .split_at(5) + .0 + ), + ) + .clicked() + { + self.selection.selected_quorum_in_diff_index = None; + self.selection.selected_masternode_in_diff_index = Some(m_index); + } + } + }); } fn render_mn_diff_chain_locks(&mut self, ui: &mut Ui) { ui.heading("MN list diff chain locks"); - if let Some(selected_key) = self.selected_dml_diff_key - && let Some(dml) = self.mnlist_diffs.get(&selected_key) - { - ScrollArea::vertical() - .id_salt("quorum_list_chain_locks_scroll_area") - .show(ui, |ui| { - for (index, sig) in dml.quorums_chainlock_signatures.iter().enumerate() { - ui.group(|ui| { - ui.label(format!("Signature #{}", index)); - ui.monospace(format!( - "Signature: {}", - hex::encode(sig.signature.as_bytes()) - )); - ui.label(format!("Index Set: {:?}", sig.index_set)); - }); - } - }); - } + let Some(dml) = self.selected_dml() else { + return; + }; + + ScrollArea::vertical() + .id_salt("quorum_list_chain_locks_scroll_area") + .show(ui, |ui| { + for (index, sig) in dml.quorums_chainlock_signatures.iter().enumerate() { + ui.group(|ui| { + ui.label(format!("Signature #{}", index)); + ui.monospace(format!( + "Signature: {}", + hex::encode(sig.signature.as_bytes()) + )); + ui.label(format!("Index Set: {:?}", sig.index_set)); + }); + } + }); } fn save_mn_list_diff(&mut self) { - let Some(selected_key) = self.selected_dml_diff_key else { - self.error = Some("No MNListDiff selected.".to_string()); + let Some(selected_key) = self.selection.selected_dml_diff_key else { + self.ui_state.error = Some("No MNListDiff selected.".to_string()); return; }; - let Some(mn_list_diff) = self.mnlist_diffs.get(&selected_key) else { - self.error = Some("Failed to retrieve selected MNListDiff.".to_string()); + let Some(mn_list_diff) = self.data.mnlist_diffs.get(&selected_key) else { + self.ui_state.error = Some("Failed to retrieve selected MNListDiff.".to_string()); return; }; @@ -2393,7 +2466,7 @@ impl MasternodeListDiffScreen { println!("MNListDiff saved to {:?}", path); } Err(e) => { - self.error = Some(format!("Failed to save file: {}", e)); + self.ui_state.error = Some(format!("Failed to save file: {}", e)); } } } @@ -2410,7 +2483,7 @@ impl MasternodeListDiffScreen { "Chain Locks", "Save Diff", ]; - let selected_index = self.selected_option_index.unwrap_or(0); + let selected_index = self.selection.selected_option_index.unwrap_or(0); // Render the selection buttons ui.horizontal(|ui| { @@ -2423,7 +2496,7 @@ impl MasternodeListDiffScreen { if index == 3 { self.save_mn_list_diff(); } else { - self.selected_option_index = Some(index); + self.selection.selected_option_index = Some(index); } } } @@ -2432,17 +2505,15 @@ impl MasternodeListDiffScreen { ui.separator(); // Determine the selected category and display corresponding information - if let Some(selected_key) = self.selected_dml_diff_key { - if self.mnlist_diffs.contains_key(&selected_key) { - ScrollArea::vertical() - .id_salt("dml_items_scroll_area") - .show(ui, |ui| match selected_index { - 0 => self.render_new_quorums(ui), - 1 => self.render_masternode_changes(ui), - 2 => self.render_mn_diff_chain_locks(ui), - _ => (), - }); - } + if self.selected_dml().is_some() { + ScrollArea::vertical() + .id_salt("dml_items_scroll_area") + .show(ui, |ui| match selected_index { + 0 => self.render_new_quorums(ui), + 1 => self.render_masternode_changes(ui), + 2 => self.render_mn_diff_chain_locks(ui), + _ => (), + }); } else { ui.label("Select a block height to show details."); } @@ -2468,102 +2539,111 @@ impl MasternodeListDiffScreen { let dark_mode = ui.ctx().style().visuals.dark_mode; let border = DashColors::border(dark_mode); ui.heading("Quorum Details"); - if let Some(dml_key) = self.selected_dml_diff_key { - if let Some(dml) = self.mnlist_diffs.get(&dml_key) { - if let Some(q_index) = self.selected_quorum_in_diff_index { - if let Some(quorum) = dml.new_quorums.get(q_index) { - Frame::NONE - .stroke(Stroke::new(1.0, border)) - .show(ui, |ui| { - ui.set_min_size(Vec2::new(ui.available_width(), 300.0)); - let height = self.get_height(&quorum.quorum_hash).ok(); - - // Build a vector of optional signatures with slots matching new_quorums length - let mut quorum_sig_lookup: Vec> = vec![None; dml.new_quorums.len()]; - - // Fill each slot with the corresponding signature - for quorum_sig_obj in &dml.quorums_chainlock_signatures { - for &index in &quorum_sig_obj.index_set { - if let Some(slot) = quorum_sig_lookup.get_mut(index as usize) { - *slot = Some(&quorum_sig_obj.signature); - } else { - return; - } - } - } + if let Some(dml_key) = self.selection.selected_dml_diff_key { + let Some(dml) = self.data.mnlist_diffs.get(&dml_key) else { + return; + }; + let Some(q_index) = self.selection.selected_quorum_in_diff_index else { + ui.label("Select a quorum to view details."); + return; + }; + let Some(quorum) = dml.new_quorums.get(q_index) else { + return; + }; - // Verify all slots have been filled - if quorum_sig_lookup.iter().any(Option::is_none) { - return; - } + Frame::NONE + .stroke(Stroke::new(1.0, border)) + .show(ui, |ui| { + ui.set_min_size(Vec2::new(ui.available_width(), 300.0)); + let height = self.get_height(&quorum.quorum_hash).ok(); - let chain_lock_msg = if let Some(a) = quorum_sig_lookup.get(q_index) { - if let Some(b) = a { - hex::encode(b) - } else { - "Error a".to_string() - } - } else { - "Error b".to_string() - }; - - let expected_chain_lock_sig = if let Some(height) = height { - if let Ok(hash) = self.get_block_hash(height - 8) { - if let Ok(Some(sig)) = self.get_chain_lock_sig(&hash) { - hex::encode(sig) - } else { - "Error (Did not find chain lock sig for hash)".to_string() - } - } else { - "Error (Did not find block hash of 8 blocks ago)".to_string() - } - } else { - "Error (Did not find quorum hash height)".to_string() - }; - if quorum.llmq_type.is_rotating_quorum_type() { - ScrollArea::vertical().id_salt("render_quorum_details").show(ui, |ui| { - ui.label(format!( - "Version: {}\nQuorum Hash Height: {}\nQuorum Hash: {}\nCycle Hash Height: {}\nQuorum Index: {}\nSigners: {} members\nValid Members: {} members\nQuorum Public Key: {}\nAssociated Chain Lock Sig: {}\nExpected Chain Lock Sig: {}", - quorum.version, - self.get_height(&quorum.quorum_hash).ok().map(|height| format!("{}", height)).unwrap_or("Unknown".to_string()), - quorum.quorum_hash, - self.get_height(&quorum.quorum_hash).ok().and_then(|height| quorum.quorum_index.map(|index| format!("{}", height - index as CoreBlockHeight))).unwrap_or("Unknown".to_string()), - quorum.quorum_index.map(|quorum_index| quorum_index.to_string()).unwrap_or("Unknown".to_string()), - quorum.signers.iter().filter(|&&b| b).count(), - quorum.valid_members.iter().filter(|&&b| b).count(), - quorum.quorum_public_key, - chain_lock_msg, - expected_chain_lock_sig, - )); - }); - } else { - ScrollArea::vertical().id_salt("render_quorum_details").show(ui, |ui| { - ui.label(format!( - "Version: {}\nQuorum Hash Height: {}\nQuorum Hash: {}\nSigners: {} members\nValid Members: {} members\nQuorum Public Key: {}\nAssociated Chain Lock Sig: {}\nExpected Chain Lock Sig: {}", - quorum.version, - self.get_height(&quorum.quorum_hash).ok().map(|height| format!("{}", height)).unwrap_or("Unknown".to_string()), - quorum.quorum_hash, - quorum.signers.iter().filter(|&&b| b).count(), - quorum.valid_members.iter().filter(|&&b| b).count(), - quorum.quorum_public_key, - chain_lock_msg, - expected_chain_lock_sig, - )); - }); - } - }); + // Build a vector of optional signatures with slots matching new_quorums length + let mut quorum_sig_lookup: Vec> = vec![None; dml.new_quorums.len()]; + + // Fill each slot with the corresponding signature + for quorum_sig_obj in &dml.quorums_chainlock_signatures { + for &index in &quorum_sig_obj.index_set { + if let Some(slot) = quorum_sig_lookup.get_mut(index as usize) { + *slot = Some(&quorum_sig_obj.signature); + } else { + return; + } + } } - } else { - ui.label("Select a quorum to view details."); - } - } - } else if let Some(selected_height) = self.selected_dml_height_key { + + // Verify all slots have been filled + if quorum_sig_lookup.iter().any(Option::is_none) { + return; + } + + let chain_lock_msg = if let Some(a) = quorum_sig_lookup.get(q_index) { + if let Some(b) = a { + hex::encode(b) + } else { + "Error a".to_string() + } + } else { + "Error b".to_string() + }; + + let expected_chain_lock_sig = if let Some(height) = height { + if let Ok(hash) = self.get_block_hash(height - 8) { + if let Ok(Some(sig)) = self.get_chain_lock_sig(&hash) { + hex::encode(sig) + } else { + "Error (Did not find chain lock sig for hash)".to_string() + } + } else { + "Error (Did not find block hash of 8 blocks ago)".to_string() + } + } else { + "Error (Did not find quorum hash height)".to_string() + }; + if quorum.llmq_type.is_rotating_quorum_type() { + ScrollArea::vertical().id_salt("render_quorum_details").show(ui, |ui| { + ui.label(format!( + "Version: {}\nQuorum Hash Height: {}\nQuorum Hash: {}\nCycle Hash Height: {}\nQuorum Index: {}\nSigners: {} members\nValid Members: {} members\nQuorum Public Key: {}\nAssociated Chain Lock Sig: {}\nExpected Chain Lock Sig: {}", + quorum.version, + self.get_height(&quorum.quorum_hash).ok().map(|height| format!("{}", height)).unwrap_or("Unknown".to_string()), + quorum.quorum_hash, + self.get_height(&quorum.quorum_hash).ok().and_then(|height| quorum.quorum_index.map(|index| format!("{}", height - index as CoreBlockHeight))).unwrap_or("Unknown".to_string()), + quorum.quorum_index.map(|quorum_index| quorum_index.to_string()).unwrap_or("Unknown".to_string()), + quorum.signers.iter().filter(|&&b| b).count(), + quorum.valid_members.iter().filter(|&&b| b).count(), + quorum.quorum_public_key, + chain_lock_msg, + expected_chain_lock_sig, + )); + }); + } else { + ScrollArea::vertical().id_salt("render_quorum_details").show(ui, |ui| { + ui.label(format!( + "Version: {}\nQuorum Hash Height: {}\nQuorum Hash: {}\nSigners: {} members\nValid Members: {} members\nQuorum Public Key: {}\nAssociated Chain Lock Sig: {}\nExpected Chain Lock Sig: {}", + quorum.version, + self.get_height(&quorum.quorum_hash).ok().map(|height| format!("{}", height)).unwrap_or("Unknown".to_string()), + quorum.quorum_hash, + quorum.signers.iter().filter(|&&b| b).count(), + quorum.valid_members.iter().filter(|&&b| b).count(), + quorum.quorum_public_key, + chain_lock_msg, + expected_chain_lock_sig, + )); + }); + } + }); + return; + } + + if let Some(selected_height) = self.selection.selected_dml_height_key { if let Some(mn_list) = self + .data .masternode_list_engine .masternode_lists .get(&selected_height) { - if let Some((llmq_type, quorum_hash)) = self.selected_quorum_hash_in_mnlist_diff { + if let Some((llmq_type, quorum_hash)) = + self.selection.selected_quorum_hash_in_mnlist_diff + { if let Some(quorum) = mn_list .quorums .get(&llmq_type) @@ -2592,7 +2672,8 @@ impl MasternodeListDiffScreen { }; let get_used_heights = |bls_signature: BLSSignature| { - let Some(used) = self.chain_lock_reversed_sig_cache.get(&bls_signature) + let Some(used) = + self.cache.chain_lock_reversed_sig_cache.get(&bls_signature) else { return String::default(); }; @@ -2670,9 +2751,9 @@ impl MasternodeListDiffScreen { let border = DashColors::border(dark_mode); ui.heading("Masternode Details"); - if let Some(dml_key) = self.selected_dml_diff_key { - if let Some(dml) = self.mnlist_diffs.get(&dml_key) { - if let Some(mn_index) = self.selected_masternode_in_diff_index { + if let Some(dml_key) = self.selection.selected_dml_diff_key { + if let Some(dml) = self.data.mnlist_diffs.get(&dml_key) { + if let Some(mn_index) = self.selection.selected_masternode_in_diff_index { if let Some(masternode) = dml.new_masternodes.get(mn_index) { Frame::NONE.stroke(Stroke::new(1.0, border)).show(ui, |ui| { ui.set_min_size(Vec2::new(ui.available_width(), 300.0)); @@ -2720,12 +2801,13 @@ impl MasternodeListDiffScreen { ui.label("Select a Masternode to view details."); } } - } else if let Some(selected_height) = self.selected_dml_height_key { + } else if let Some(selected_height) = self.selection.selected_dml_height_key { if let Some(mn_list) = self + .data .masternode_list_engine .masternode_lists .get(&selected_height) - && let Some(selected_pro_tx_hash) = self.selected_masternode_pro_tx_hash + && let Some(selected_pro_tx_hash) = self.selection.selected_masternode_pro_tx_hash && let Some(qualified_masternode) = mn_list.masternodes.get(&selected_pro_tx_hash) { let masternode = &qualified_masternode.masternode_list_entry; @@ -2832,7 +2914,7 @@ impl MasternodeListDiffScreen { // Select the first available QRInfo if none is selected let selected_qr_info = { - let Some((_, selected_qr_info)) = self.qr_infos.first_key_value() else { + let Some((_, selected_qr_info)) = self.data.qr_infos.first_key_value() else { ui.label("No QRInfo available."); if ui.button("Load QR Info").clicked() && let Some(path) = FileDialog::new() @@ -2845,7 +2927,7 @@ impl MasternodeListDiffScreen { match QRInfo::consensus_decode(&mut std::io::Cursor::new(&bytes)) { Ok(qr_info) => { let key = qr_info.mn_list_diff_tip.block_hash; - self.qr_infos.insert(key, qr_info.clone()); + self.data.qr_infos.insert(key, qr_info.clone()); self.feed_qr_info_and_get_dmls(qr_info, None); } Err(_) => { @@ -2855,7 +2937,7 @@ impl MasternodeListDiffScreen { ) { Ok((qr_info, _)) => { let key = qr_info.mn_list_diff_tip.block_hash; - self.qr_infos.insert(key, qr_info); + self.data.qr_infos.insert(key, qr_info); } Err(e) => { eprintln!("Failed to decode QRInfo: {}", e); @@ -2897,8 +2979,8 @@ impl MasternodeListDiffScreen { } // Track user selections - if self.selected_qr_field.is_none() { - self.selected_qr_field = Some("Quorum Snapshots".to_string()); + if self.selection.selected_qr_field.is_none() { + self.selection.selected_qr_field = Some("Quorum Snapshots".to_string()); } ui.horizontal(|ui| { @@ -2919,14 +3001,14 @@ impl MasternodeListDiffScreen { for field in &fields { if ui .selectable_label( - self.selected_qr_field.as_deref() == Some(*field), + self.selection.selected_qr_field.as_deref() == Some(*field), *field, ) .clicked() { - self.selected_qr_field = Some(field.to_string()); - self.selected_qr_list_index = None; - self.selected_qr_item = None; + self.selection.selected_qr_field = Some(field.to_string()); + self.selection.selected_qr_list_index = None; + self.selection.selected_qr_item = None; } } }, @@ -2941,7 +3023,7 @@ impl MasternodeListDiffScreen { |ui| { ui.heading("Selected Field Items"); - match self.selected_qr_field.as_deref() { + match self.selection.selected_qr_field.as_deref() { Some("Quorum Snapshots") => { self.render_quorum_snapshots(ui, &selected_qr_info) } @@ -2975,7 +3057,7 @@ impl MasternodeListDiffScreen { egui::Vec2::new(ui.available_width(), ui.available_height()), Layout::top_down(Align::Min), |ui| { - if let Some(selected_item) = &self.selected_qr_item { + if let Some(selected_item) = &self.selection.selected_qr_item { match selected_item { SelectedQRItem::SelectedSnapshot(snapshot) => { Self::render_selected_shapshot_details(ui, snapshot); @@ -3153,24 +3235,29 @@ impl MasternodeListDiffScreen { if let Some((qs4c, _)) = &qr_info.quorum_snapshot_and_mn_list_diff_at_h_minus_4c { snapshots.iter().for_each(|(name, snapshot)| { if ui - .selectable_label(self.selected_qr_list_index == Some(name.to_string()), *name) + .selectable_label( + self.selection.selected_qr_list_index == Some(name.to_string()), + *name, + ) .clicked() { - self.selected_qr_list_index = Some(name.to_string()); - self.selected_qr_item = + self.selection.selected_qr_list_index = Some(name.to_string()); + self.selection.selected_qr_item = Some(SelectedQRItem::SelectedSnapshot((*snapshot).clone())); } }); if ui .selectable_label( - self.selected_qr_list_index == Some("Quorum Snapshot h-4c".to_string()), + self.selection.selected_qr_list_index + == Some("Quorum Snapshot h-4c".to_string()), "Quorum Snapshot h-4c", ) .clicked() { - self.selected_qr_list_index = Some("Quorum Snapshot h-4c".to_string()); - self.selected_qr_item = Some(SelectedQRItem::SelectedSnapshot((*qs4c).clone())); + self.selection.selected_qr_list_index = Some("Quorum Snapshot h-4c".to_string()); + self.selection.selected_qr_item = + Some(SelectedQRItem::SelectedSnapshot((*qs4c).clone())); } } } @@ -3456,24 +3543,28 @@ impl MasternodeListDiffScreen { if ui .selectable_label( - self.selected_qr_list_index == Some(string.clone()), + self.selection.selected_qr_list_index == Some(string.clone()), string.as_str(), ) .clicked() { - self.selected_qr_list_index = Some(string); - self.selected_qr_item = + self.selection.selected_qr_list_index = Some(string); + self.selection.selected_qr_item = Some(SelectedQRItem::MNListDiff(Box::new((*mn_diff4c).clone()))); } } mn_diffs.iter().for_each(|(name, diff)| { if ui - .selectable_label(self.selected_qr_list_index == Some(name.to_string()), name) + .selectable_label( + self.selection.selected_qr_list_index == Some(name.to_string()), + name, + ) .clicked() { - self.selected_qr_list_index = Some(name.to_string()); - self.selected_qr_item = Some(SelectedQRItem::MNListDiff(Box::new((*diff).clone()))); + self.selection.selected_qr_list_index = Some(name.to_string()); + self.selection.selected_qr_item = + Some(SelectedQRItem::MNListDiff(Box::new((*diff).clone()))); } }); } @@ -3484,6 +3575,7 @@ impl MasternodeListDiffScreen { return; }; let Some(cycle_quorums) = self + .data .masternode_list_engine .rotated_quorums_per_cycle .get(&cycle_hash) @@ -3492,7 +3584,8 @@ impl MasternodeListDiffScreen { "Engine does not know of cycle {} at height {}, we know of cycles [{}]", cycle_hash, self.get_height_or_error_as_string(&cycle_hash), - self.masternode_list_engine + self.data + .masternode_list_engine .rotated_quorums_per_cycle .keys() .map(|key| format!("{}, {}", self.get_height_or_error_as_string(key), key)) @@ -3520,13 +3613,13 @@ impl MasternodeListDiffScreen { if ui .selectable_label( - self.selected_qr_list_index == Some(index.to_string()), + self.selection.selected_qr_list_index == Some(index.to_string()), label_text, ) .clicked() { - self.selected_qr_list_index = Some(index.to_string()); - self.selected_qr_item = + self.selection.selected_qr_list_index = Some(index.to_string()); + self.selection.selected_qr_item = Some(SelectedQRItem::QuorumEntry(Box::new(commitment.clone()))); } } @@ -3536,13 +3629,14 @@ impl MasternodeListDiffScreen { for (index, snapshot) in qr_info.quorum_snapshot_list.iter().enumerate() { if ui .selectable_label( - self.selected_qr_list_index == Some(index.to_string()), + self.selection.selected_qr_list_index == Some(index.to_string()), format!("Snapshot {}", index), ) .clicked() { - self.selected_qr_list_index = Some(index.to_string()); - self.selected_qr_item = Some(SelectedQRItem::SelectedSnapshot(snapshot.clone())); + self.selection.selected_qr_list_index = Some(index.to_string()); + self.selection.selected_qr_item = + Some(SelectedQRItem::SelectedSnapshot(snapshot.clone())); } } } @@ -3551,13 +3645,14 @@ impl MasternodeListDiffScreen { for (index, diff) in qr_info.mn_list_diff_list.iter().enumerate() { if ui .selectable_label( - self.selected_qr_list_index == Some(index.to_string()), + self.selection.selected_qr_list_index == Some(index.to_string()), format!("MNListDiff {}", index), ) .clicked() { - self.selected_qr_list_index = Some(index.to_string()); - self.selected_qr_item = Some(SelectedQRItem::MNListDiff(Box::new(diff.clone()))); + self.selection.selected_qr_list_index = Some(index.to_string()); + self.selection.selected_qr_item = + Some(SelectedQRItem::MNListDiff(Box::new(diff.clone()))); } } } @@ -3567,6 +3662,7 @@ impl MasternodeListDiffScreen { // Get all available quorum types let quorum_types: Vec = self + .data .masternode_list_engine .quorum_statuses .keys() @@ -3574,8 +3670,12 @@ impl MasternodeListDiffScreen { .collect(); // Ensure a quorum type is selected - if self.selected_quorum_type_in_quorum_viewer.is_none() { - self.selected_quorum_type_in_quorum_viewer = quorum_types.first().copied(); + if self + .selection + .selected_quorum_type_in_quorum_viewer + .is_none() + { + self.selection.selected_quorum_type_in_quorum_viewer = quorum_types.first().copied(); } // Render quorum type selection bar @@ -3583,25 +3683,27 @@ impl MasternodeListDiffScreen { for quorum_type in &quorum_types { if ui .selectable_label( - self.selected_quorum_type_in_quorum_viewer == Some(*quorum_type), + self.selection.selected_quorum_type_in_quorum_viewer == Some(*quorum_type), quorum_type.to_string(), ) .clicked() { - self.selected_quorum_type_in_quorum_viewer = Some(*quorum_type); - self.selected_quorum_hash_in_quorum_viewer = None; // Reset selected quorum when switching types + self.selection.selected_quorum_type_in_quorum_viewer = Some(*quorum_type); + self.selection.selected_quorum_hash_in_quorum_viewer = None; // Reset selected quorum when switching types } } }); ui.separator(); - let Some(selected_quorum_type) = self.selected_quorum_type_in_quorum_viewer else { + let Some(selected_quorum_type) = self.selection.selected_quorum_type_in_quorum_viewer + else { ui.label("No quorum types available."); return; }; let Some(quorum_map) = self + .data .masternode_list_engine .quorum_statuses .get(&selected_quorum_type) @@ -3635,13 +3737,13 @@ impl MasternodeListDiffScreen { // Display quorum hash as selectable let hash_response = ui.selectable_label( - self.selected_quorum_hash_in_quorum_viewer + self.selection.selected_quorum_hash_in_quorum_viewer == Some(*quorum_hash), hash_label, ); if hash_response.clicked() { - self.selected_quorum_hash_in_quorum_viewer = + self.selection.selected_quorum_hash_in_quorum_viewer = Some(*quorum_hash); } @@ -3689,7 +3791,9 @@ impl MasternodeListDiffScreen { |ui| { ui.heading("Quorum Heights"); - if let Some(selected_quorum_hash) = self.selected_quorum_hash_in_quorum_viewer { + if let Some(selected_quorum_hash) = + self.selection.selected_quorum_hash_in_quorum_viewer + { if let Some((heights, key, status)) = quorum_map.get(&selected_quorum_hash) { ui.label(format!("Public Key: {}", key)); @@ -3736,7 +3840,7 @@ impl MasternodeListDiffScreen { ScrollArea::vertical().id_salt("chain_locked_blocks_scroll").show(ui, |ui| { for (block_height, (block, chain_lock, is_valid)) in - self.chain_locked_blocks.iter() + self.incoming.chain_locked_blocks.iter() { let label_text = format!( "{} {} {}", @@ -3747,12 +3851,12 @@ impl MasternodeListDiffScreen { if ui .selectable_label( - matches!(self.selected_core_item, Some((CoreItem::ChainLockedBlock(_, ref l), _)) if l.block_height == *block_height), + matches!(self.selection.selected_core_item, Some((CoreItem::ChainLockedBlock(_, ref l), _)) if l.block_height == *block_height), label_text, ) .clicked() { - self.selected_core_item = Some((CoreItem::ChainLockedBlock(block.clone(), chain_lock.clone()), *is_valid)); + self.selection.selected_core_item = Some((CoreItem::ChainLockedBlock(block.clone(), chain_lock.clone()), *is_valid)); } } }); @@ -3770,7 +3874,7 @@ impl MasternodeListDiffScreen { ScrollArea::vertical().id_salt("instant_send_scroll").show(ui, |ui| { for (transaction, instant_lock, is_valid) in - self.instant_send_transactions.iter() + self.incoming.instant_send_transactions.iter() { let label_text = format!( "{} TxID: {}", @@ -3780,12 +3884,12 @@ impl MasternodeListDiffScreen { if ui .selectable_label( - matches!(self.selected_core_item, Some((CoreItem::InstantLockedTransaction(ref t, _, _), _)) if t == transaction), + matches!(self.selection.selected_core_item, Some((CoreItem::InstantLockedTransaction(ref t, _, _), _)) if t == transaction), label_text, ) .clicked() { - self.selected_core_item = Some((CoreItem::InstantLockedTransaction(transaction.clone(), vec![], instant_lock.clone()), *is_valid)); + self.selection.selected_core_item = Some((CoreItem::InstantLockedTransaction(transaction.clone(), vec![], instant_lock.clone()), *is_valid)); } } }); @@ -3799,7 +3903,7 @@ impl MasternodeListDiffScreen { egui::Vec2::new(ui.available_width(), ui.available_height()), Layout::top_down(Align::Min), |ui| { - if let Some((selected_core_item, _)) = &self.selected_core_item { + if let Some((selected_core_item, _)) = &self.selection.selected_core_item { match selected_core_item { CoreItem::ChainLockedBlock(..) => self.render_chain_lock_details(ui), CoreItem::InstantLockedTransaction(..) => self.render_instant_send_details(ui), @@ -3820,7 +3924,7 @@ impl MasternodeListDiffScreen { ui.heading("ChainLock Details"); if let Some((CoreItem::ChainLockedBlock(block, chain_lock), is_valid)) = - &self.selected_core_item + &self.selection.selected_core_item { ui.label(format!( "Block Height: {}\nBlock Hash: {}\nValid: {}", @@ -3855,6 +3959,7 @@ impl MasternodeListDiffScreen { let b = serialize2(chain_lock); let chain_lock_2: ChainLock2 = deserialize(b.as_slice()).expect("todo"); match self + .data .masternode_list_engine .chain_lock_potential_quorum_under(&chain_lock_2) { @@ -3905,7 +4010,7 @@ impl MasternodeListDiffScreen { ui.heading("Instant Send Details"); if let Some((CoreItem::InstantLockedTransaction(transaction, _, instant_lock), is_valid)) = - &self.selected_core_item + &self.selection.selected_core_item { ui.label(format!( "TxID: {}\nValid: {}\nCycle Hash:{}", @@ -3955,7 +4060,11 @@ impl MasternodeListDiffScreen { //todo clean this let b = serialize2(instant_lock); let instant_lock_2: InstantLock2 = deserialize(b.as_slice()).expect("todo"); - match self.masternode_list_engine.is_lock_quorum(&instant_lock_2) { + match self + .data + .masternode_list_engine + .is_lock_quorum(&instant_lock_2) + { Ok((quorum, request_sign_id, index)) => { ui.label(format!( "Quorum Hash: {} at index {}", @@ -4009,7 +4118,8 @@ impl MasternodeListDiffScreen { fn attempt_verify_chain_lock(&self, chain_lock: &ChainLock) -> bool { let b = serialize2(chain_lock); let chain_lock_2: ChainLock2 = deserialize(b.as_slice()).expect("todo"); - self.masternode_list_engine + self.data + .masternode_list_engine .verify_chain_lock(&chain_lock_2) .is_ok() } @@ -4017,16 +4127,18 @@ impl MasternodeListDiffScreen { fn attempt_verify_transaction_lock(&self, instant_lock: &InstantLock) -> bool { let b = serialize2(instant_lock); let instant_lock_2: InstantLock2 = deserialize(b.as_slice()).expect("todo"); - self.masternode_list_engine + self.data + .masternode_list_engine .verify_is_lock(&instant_lock_2) .is_ok() } fn received_new_block(&mut self, block: Block, chain_lock: ChainLock) { let valid = self.attempt_verify_chain_lock(&chain_lock); - self.end_block_height = chain_lock.block_height.to_string(); - if self.syncing + self.input.end_block_height = chain_lock.block_height.to_string(); + if self.task.syncing && let Some((base_block_height, masternode_list)) = self + .data .masternode_list_engine .masternode_lists .last_key_value() @@ -4035,7 +4147,7 @@ impl MasternodeListDiffScreen { let mut p2p_handler = match CoreP2PHandler::new(self.app_context.network, None) { Ok(p2p_handler) => p2p_handler, Err(e) => { - self.error = Some(e); + self.ui_state.error = Some(e); return; } }; @@ -4060,10 +4172,11 @@ impl MasternodeListDiffScreen { // ); // Reset selections when new data is loaded - self.selected_dml_diff_key = None; - self.selected_quorum_in_diff_index = None; + self.selection.selected_dml_diff_key = None; + self.selection.selected_quorum_in_diff_index = None; } - self.chain_locked_blocks + self.incoming + .chain_locked_blocks .insert(chain_lock.block_height, (block, chain_lock, valid)); } } @@ -4072,11 +4185,11 @@ impl ScreenLike for MasternodeListDiffScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { match message_type { MessageType::Error => { - self.pending = None; - self.error = Some(message.to_string()); + self.task.pending = None; + self.ui_state.error = Some(message.to_string()); } MessageType::Success => { - self.message = Some((message.to_string(), message_type)); + self.ui_state.message = Some((message.to_string(), message_type)); } MessageType::Info => { // Do not show transient info messages to avoid noisy black text banners. @@ -4090,8 +4203,11 @@ impl ScreenLike for MasternodeListDiffScreen { match core_item { CoreItem::InstantLockedTransaction(transaction, _, instant_lock) => { let valid = self.attempt_verify_transaction_lock(&instant_lock); - self.instant_send_transactions - .push((transaction, instant_lock, valid)); + self.incoming.instant_send_transactions.push(( + transaction, + instant_lock, + valid, + )); } CoreItem::ChainLockedBlock(block, chain_lock) => { self.received_new_block(block, chain_lock); @@ -4107,53 +4223,57 @@ impl ScreenLike for MasternodeListDiffScreen { diff, } => { // Apply to engine similarly to original UI method - if base_height == 0 && self.masternode_list_engine.masternode_lists.is_empty() { + if base_height == 0 && self.data.masternode_list_engine.masternode_lists.is_empty() + { match MasternodeListEngine::initialize_with_diff_to_height( diff.clone(), height, self.app_context.network, ) { - Ok(engine) => self.masternode_list_engine = engine, - Err(e) => self.error = Some(e.to_string()), + Ok(engine) => self.data.masternode_list_engine = engine, + Err(e) => self.ui_state.error = Some(e.to_string()), } - } else if let Err(e) = - self.masternode_list_engine - .apply_diff(diff.clone(), Some(height), false, None) - { - self.error = Some(e.to_string()); + } else if let Err(e) = self.data.masternode_list_engine.apply_diff( + diff.clone(), + Some(height), + false, + None, + ) { + self.ui_state.error = Some(e.to_string()); } - self.mnlist_diffs.insert((base_height, height), diff); + self.data.mnlist_diffs.insert((base_height, height), diff); // If this was the no-rotation path, queue the extra diffs needed for verification (restored behavior) - if matches!(self.pending, Some(PendingTask::DmlDiffNoRotation)) { + if matches!(self.task.pending, Some(PendingTask::DmlDiffNoRotation)) { if let Some(task) = self.build_validation_diffs_task() { - self.queued_task = Some(task); + self.task.queued_task = Some(task); self.display_message( "Fetched DMLs (no rotation); fetching validation diffs…", MessageType::Info, ); - } else if !self.masternode_list_engine.masternode_lists.is_empty() { + } else if !self.data.masternode_list_engine.masternode_lists.is_empty() { // Fallback: attempt verification directly if let Err(e) = self + .data .masternode_list_engine .verify_non_rotating_masternode_list_quorums( height, &[LLMQType::Llmqtype50_60, LLMQType::Llmqtype400_85], ) { - self.error = Some(e.to_string()); + self.ui_state.error = Some(e.to_string()); } - self.pending = None; + self.task.pending = None; self.display_message("Fetched DMLs (no rotation)", MessageType::Success); } else { - self.pending = None; + self.task.pending = None; self.display_message("Fetched DMLs (no rotation)", MessageType::Success); } } else { - self.pending = None; + self.task.pending = None; self.display_message("Fetched DML diff", MessageType::Success); } - self.selected_dml_diff_key = None; - self.selected_quorum_in_diff_index = None; + self.selection.selected_dml_diff_key = None; + self.selection.selected_quorum_in_diff_index = None; } BackendTaskSuccessResult::MnListFetchedQrInfo { qr_info } => { // Warm heights and cache diffs before feed_qr_info (replicates old flow) @@ -4170,7 +4290,7 @@ impl ScreenLike for MasternodeListDiffScreen { } // Apply to engine using the same closure as before to resolve heights - let block_height_cache = self.block_height_cache.clone(); + let block_height_cache = self.cache.block_height_cache.clone(); let app_context = self.app_context.clone(); let get_height_fn = move |block_hash: &BlockHash| { if block_hash.as_byte_array() == &[0; 32] { @@ -4192,74 +4312,76 @@ impl ScreenLike for MasternodeListDiffScreen { )), } }; - if let Err(e) = self.masternode_list_engine.feed_qr_info( + if let Err(e) = self.data.masternode_list_engine.feed_qr_info( qr_info.clone(), false, true, Some(get_height_fn), ) { - self.error = Some(e.to_string()); + self.ui_state.error = Some(e.to_string()); } // Store full qr_info for the QR tab let key = qr_info.mn_list_diff_tip.block_hash; - self.qr_infos.insert(key, qr_info); - self.selected_dml_diff_key = None; - self.selected_quorum_in_diff_index = None; + self.data.qr_infos.insert(key, qr_info); + self.selection.selected_dml_diff_key = None; + self.selection.selected_quorum_in_diff_index = None; // Queue extra diffs required for verification (previous behavior) if let Some(task) = self.build_validation_diffs_task() { - self.queued_task = Some(task); + self.task.queued_task = Some(task); self.display_message( "Fetched QR info + DMLs; fetching validation diffs…", MessageType::Info, ); } else { - self.pending = None; + self.task.pending = None; self.display_message("Fetched QR info + DMLs", MessageType::Success); } } BackendTaskSuccessResult::MnListFetchedDiffs { items } => { // Apply returned diffs sequentially for ((base_h, h), diff) in items { - if base_h == 0 && self.masternode_list_engine.masternode_lists.is_empty() { + if base_h == 0 && self.data.masternode_list_engine.masternode_lists.is_empty() { if let Ok(engine) = MasternodeListEngine::initialize_with_diff_to_height( diff.clone(), h, self.app_context.network, ) { - self.masternode_list_engine = engine; + self.data.masternode_list_engine = engine; } } else { - let _ = self.masternode_list_engine.apply_diff( + let _ = self.data.masternode_list_engine.apply_diff( diff.clone(), Some(h), false, None, ); } - self.mnlist_diffs.insert((base_h, h), diff); + self.data.mnlist_diffs.insert((base_h, h), diff); } // Update rotating quorum heights cache (previous behavior) let hashes = self + .data .masternode_list_engine .latest_masternode_list_rotating_quorum_hashes(&[]); for hash in &hashes { if let Ok(height) = self.get_height_and_cache(hash) { - self.block_height_cache.insert(*hash, height); + self.cache.block_height_cache.insert(*hash, height); } } // Verify non-rotating quorums as before if let Some(latest_masternode_list) = - self.masternode_list_engine.latest_masternode_list() + self.data.masternode_list_engine.latest_masternode_list() && let Err(e) = self + .data .masternode_list_engine .verify_non_rotating_masternode_list_quorums( latest_masternode_list.known_height, &[LLMQType::Llmqtype50_60, LLMQType::Llmqtype400_85], ) { - self.error = Some(e.to_string()); + self.ui_state.error = Some(e.to_string()); } - self.pending = None; + self.task.pending = None; self.display_message( "Fetched validation diffs and verified non-rotating quorums", MessageType::Success, @@ -4267,15 +4389,16 @@ impl ScreenLike for MasternodeListDiffScreen { } BackendTaskSuccessResult::MnListChainLockSigs { entries } => { for ((h, bh), sig) in entries { - self.chain_lock_sig_cache.insert((h, bh), sig); + self.cache.chain_lock_sig_cache.insert((h, bh), sig); if let Some(sig) = sig { - self.chain_lock_reversed_sig_cache + self.cache + .chain_lock_reversed_sig_cache .entry(sig) .or_default() .insert((h, bh)); } } - self.pending = None; + self.task.pending = None; self.display_message("Fetched chain lock signatures", MessageType::Success); } _ => {} @@ -4308,85 +4431,13 @@ impl ScreenLike for MasternodeListDiffScreen { let mut inner = AppAction::None; inner |= self.render_input_area(ui); // If we queued a backend task from a prior result processing, send it now - if let Some(task) = self.queued_task.take() { + if let Some(task) = self.task.queued_task.take() { inner |= AppAction::BackendTask(task); } - if let Some((msg, msg_type)) = self.message.clone() { - let dark_mode = ui.ctx().style().visuals.dark_mode; - let message_color = match msg_type { - MessageType::Error => Color32::from_rgb(255, 100, 100), - MessageType::Info => crate::ui::theme::DashColors::text_primary(dark_mode), - // Dark green for success text - MessageType::Success => Color32::DARK_GREEN, - }; - ui.horizontal(|ui| { - Frame::new() - .fill(message_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, message_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(msg).color(message_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.message = None; - } - }); - }); - }); - ui.add_space(10.0); - } - - if let Some(error_msg) = self.error.clone() { - let message_color = Color32::from_rgb(255, 100, 100); - ui.horizontal(|ui| { - Frame::new() - .fill(message_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, message_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(error_msg).color(message_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.error = None; - } - }); - }); - }); - ui.add_space(10.0); - } - - // Pending spinner (Dash Blue spinner, black text) - if let Some(p) = self.pending { - ui.add_space(6.0); - ui.horizontal(|ui| { - ui.scope(|ui| { - let style = ui.style_mut(); - // Force spinner (fg stroke) to Dash Blue - style.visuals.widgets.inactive.fg_stroke.color = - crate::ui::theme::DashColors::DASH_BLUE; - style.visuals.widgets.active.fg_stroke.color = - crate::ui::theme::DashColors::DASH_BLUE; - style.visuals.widgets.hovered.fg_stroke.color = - crate::ui::theme::DashColors::DASH_BLUE; - ui.add(egui::Spinner::new()); - }); - let label = match p { - PendingTask::DmlDiffSingle => "Fetching DML diff…", - PendingTask::DmlDiffNoRotation => "Fetching DMLs (no rotation)…", - PendingTask::QrInfo => "Fetching QR info…", - PendingTask::QrInfoWithDmls => "Fetching QR info + DMLs…", - PendingTask::ChainLocks => "Fetching chain locks…", - }; - let text_primary = DashColors::text_primary(ui.ctx().style().visuals.dark_mode); - ui.colored_label(text_primary, label); - }); - ui.add_space(6.0); - } + self.render_message_banner(ui); + self.render_error_banner(ui); + self.render_pending_status(ui); ui.separator(); diff --git a/src/ui/wallets/account_summary.rs b/src/ui/wallets/account_summary.rs index 2167ecb07..a766d7f46 100644 --- a/src/ui/wallets/account_summary.rs +++ b/src/ui/wallets/account_summary.rs @@ -55,7 +55,10 @@ impl AccountCategory { 0 => "Main Account".to_string(), idx => format!("BIP44 Account #{}", idx), }, - AccountCategory::Bip32 => format!("BIP32 Account {:?}", index.unwrap_or(0)), + AccountCategory::Bip32 => match index { + Some(idx) if idx > 0 => format!("Legacy BIP32 Account #{}", idx), + _ => "Legacy BIP32 Account".to_string(), + }, AccountCategory::CoinJoin => "CoinJoin".to_string(), AccountCategory::IdentityRegistration => "Identity Registration".to_string(), AccountCategory::IdentitySystem => "Identity System".to_string(), @@ -93,9 +96,9 @@ impl AccountCategory { AccountCategory::Bip44 => { Some("Standard BIP44 account (m/44'/5'/… ) used for normal wallet funds.") } - AccountCategory::Bip32 => { - Some("Legacy BIP32 branch reserved for custom derivations or advanced tools.") - } + AccountCategory::Bip32 => Some( + "Legacy BIP32 account (m/0'/… ). Funds here were received on older derivation paths.", + ), AccountCategory::CoinJoin => { Some("CoinJoin mixing account. Funds here are earmarked for privacy transactions.") } diff --git a/src/ui/wallets/add_new_wallet_screen.rs b/src/ui/wallets/add_new_wallet_screen.rs index 22b9c64f6..2a508bb06 100644 --- a/src/ui/wallets/add_new_wallet_screen.rs +++ b/src/ui/wallets/add_new_wallet_screen.rs @@ -467,7 +467,7 @@ impl AddNewWalletScreen { } // Draw dark overlay behind the dialog - let screen_rect = ctx.screen_rect(); + let screen_rect = ctx.content_rect(); let painter = ctx.layer_painter(egui::LayerId::new( egui::Order::Background, egui::Id::new("receive_funds_overlay"), diff --git a/src/ui/wallets/asset_lock_detail_screen.rs b/src/ui/wallets/asset_lock_detail_screen.rs index 7e65e4395..e52ead8b0 100644 --- a/src/ui/wallets/asset_lock_detail_screen.rs +++ b/src/ui/wallets/asset_lock_detail_screen.rs @@ -388,7 +388,7 @@ impl ScreenLike for AssetLockDetailScreen { // Private key popup if self.show_private_key_popup { // Draw dark overlay behind the popup - let screen_rect = ctx.screen_rect(); + let screen_rect = ctx.content_rect(); let painter = ctx.layer_painter(egui::LayerId::new( egui::Order::Background, egui::Id::new("private_key_popup_overlay"), diff --git a/src/ui/wallets/create_asset_lock_screen.rs b/src/ui/wallets/create_asset_lock_screen.rs index c581f7e84..eea9978b7 100644 --- a/src/ui/wallets/create_asset_lock_screen.rs +++ b/src/ui/wallets/create_asset_lock_screen.rs @@ -107,7 +107,7 @@ impl CreateAssetLockScreen { // Generate a new asset lock funding address let receive_address = - wallet.receive_address(self.app_context.network, false, Some(&self.app_context))?; + wallet.receive_address(self.app_context.network, true, Some(&self.app_context))?; // Import address to core if needed if let Some(has_address) = self.core_has_funding_address { diff --git a/src/ui/wallets/wallets_screen/address_table.rs b/src/ui/wallets/wallets_screen/address_table.rs new file mode 100644 index 000000000..2e92c4af8 --- /dev/null +++ b/src/ui/wallets/wallets_screen/address_table.rs @@ -0,0 +1,398 @@ +use crate::app::AppAction; +use crate::model::wallet::{DerivationPathHelpers, DerivationPathReference}; +use crate::ui::wallets::account_summary::AccountCategory; +use crate::ui::{MessageType, ScreenLike}; +use dash_sdk::dashcore_rpc::dashcore::{Address, Network}; +use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; +use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, DerivationPath}; +use eframe::egui::{self, Ui}; +use egui_extras::{Column, TableBuilder}; + +use super::WalletsBalancesScreen; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum SortColumn { + Address, + Balance, + UTXOs, + TotalReceived, + Type, + Index, + DerivationPath, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum SortOrder { + Ascending, + Descending, +} + +pub(super) struct AddressData { + address: Address, + balance: u64, + /// Platform credits balance for Platform Payment addresses + platform_credits: u64, + utxo_count: usize, + total_received: u64, + address_type: String, + index: u32, + derivation_path: DerivationPath, + account_category: AccountCategory, + account_index: Option, +} + +impl AddressData { + /// Returns the address formatted for display. + /// Platform Payment addresses are shown in DIP-18 Bech32m format (e.g., tevo1...). + fn display_address(&self, network: Network) -> String { + if self.account_category == AccountCategory::PlatformPayment { + use dash_sdk::dpp::address_funds::PlatformAddress; + PlatformAddress::try_from(self.address.clone()) + .map(|pa| pa.to_bech32m_string(network)) + .unwrap_or_else(|_| self.address.to_string()) + } else { + self.address.to_string() + } + } +} + +impl WalletsBalancesScreen { + pub(super) fn toggle_sort(&mut self, column: SortColumn) { + if self.sort_column == column { + self.sort_order = match self.sort_order { + SortOrder::Ascending => SortOrder::Descending, + SortOrder::Descending => SortOrder::Ascending, + }; + } else { + self.sort_column = column; + self.sort_order = SortOrder::Ascending; + } + } + + #[allow(clippy::ptr_arg)] + fn sort_address_data(&self, data: &mut Vec) { + data.sort_by(|a, b| { + let order = match self.sort_column { + SortColumn::Address => a.address.cmp(&b.address), + SortColumn::Balance => a.balance.cmp(&b.balance), + SortColumn::UTXOs => a.utxo_count.cmp(&b.utxo_count), + SortColumn::TotalReceived => a.total_received.cmp(&b.total_received), + SortColumn::Type => a.address_type.cmp(&b.address_type), + SortColumn::Index => a.index.cmp(&b.index), + SortColumn::DerivationPath => a.derivation_path.cmp(&b.derivation_path), + }; + + if self.sort_order == SortOrder::Ascending { + order + } else { + order.reverse() + } + }); + } + + pub(super) fn categorize_path( + path: &DerivationPath, + reference: DerivationPathReference, + ) -> (AccountCategory, Option) { + let category = AccountCategory::from_reference(reference); + let index = match category { + AccountCategory::Bip44 | AccountCategory::Bip32 => path.bip44_account_index(), + _ => None, + }; + (category, index) + } + + pub(super) fn render_address_table(&mut self, ui: &mut Ui) -> AppAction { + let action = AppAction::None; + + // Move the data preparation into its own scope + let mut address_data = { + let wallet = self.selected_wallet.as_ref().unwrap().read().unwrap(); + + // Prepare data for the table + wallet + .known_addresses + .iter() + .map(|(address, derivation_path)| { + let utxo_info = wallet.utxos.get(address); + + let utxo_count = utxo_info.map(|outpoints| outpoints.len()).unwrap_or(0); + + // Get total received from the wallet (fetched from Core RPC) + let total_received = wallet + .address_total_received + .get(address) + .cloned() + .unwrap_or(0u64); + + let index = derivation_path + .into_iter() + .last() + .cloned() + .unwrap_or(ChildNumber::Normal { index: 0 }); + let index = match index { + ChildNumber::Normal { index } => index, + ChildNumber::Hardened { index } => index, + _ => 0, + }; + let address_type = + if derivation_path.is_bip44_external(self.app_context.network) { + "Funds".to_string() + } else if derivation_path.is_bip44_change(self.app_context.network) { + "Change".to_string() + } else if derivation_path.is_asset_lock_funding(self.app_context.network) { + "Identity Creation".to_string() + } else if derivation_path.is_platform_payment(self.app_context.network) { + "Platform".to_string() + } else { + "System".to_string() + }; + + let path_reference = wallet + .watched_addresses + .get(derivation_path) + .map(|info| info.path_reference) + .unwrap_or(DerivationPathReference::Unknown); + let (account_category, account_index) = + Self::categorize_path(derivation_path, path_reference); + + // Get Platform credits balance for Platform Payment addresses + // Use canonical lookup to handle potential Address key mismatches + let platform_credits = wallet + .get_platform_address_info(address) + .map(|info| info.balance) + .unwrap_or_default(); + + AddressData { + address: address.clone(), + balance: wallet + .address_balances + .get(address) + .cloned() + .unwrap_or_default(), + platform_credits, + utxo_count, + total_received, + address_type, + index, + derivation_path: derivation_path.clone(), + account_category, + account_index, + } + }) + .collect::>() + }; // The borrow of `wallet` ends here + + // Now you can use `self` mutably without conflict + // Sort the data + self.sort_address_data(&mut address_data); + + if let Some((category, index)) = self.selected_account.clone() { + address_data + .retain(|data| data.account_category == category && data.account_index == index); + } + + // Space allocation for UI elements is handled by the layout system + + // Render the table + TableBuilder::new(ui) + .id_salt("addresses_table") + .striped(false) + .resizable(true) + .vscroll(false) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .column(Column::auto()) // Address + .column(Column::initial(140.0)) // Balance + .column(Column::initial(70.0)) // UTXOs + .column(Column::initial(150.0)) // Total Received + .column(Column::initial(100.0)) // Type + .column(Column::initial(70.0)) // Index + .column(Column::initial(120.0)) // Derivation Path + .column(Column::initial(120.0)) // Actions + .header(30.0, |mut header| { + header.col(|ui| { + let label = if self.sort_column == SortColumn::Address { + match self.sort_order { + SortOrder::Ascending => "Address ^", + SortOrder::Descending => "Address v", + } + } else { + "Address" + }; + if ui.button(label).clicked() { + self.toggle_sort(SortColumn::Address); + } + }); + header.col(|ui| { + let label = if self.sort_column == SortColumn::Balance { + match self.sort_order { + SortOrder::Ascending => "Balance (DASH) ^", + SortOrder::Descending => "Balance (DASH) v", + } + } else { + "Balance (DASH)" + }; + if ui.button(label).clicked() { + self.toggle_sort(SortColumn::Balance); + } + }); + header.col(|ui| { + let label = if self.sort_column == SortColumn::UTXOs { + match self.sort_order { + SortOrder::Ascending => "UTXOs ^", + SortOrder::Descending => "UTXOs v", + } + } else { + "UTXOs" + }; + if ui.button(label).clicked() { + self.toggle_sort(SortColumn::UTXOs); + } + }); + header.col(|ui| { + let label = if self.sort_column == SortColumn::TotalReceived { + match self.sort_order { + SortOrder::Ascending => "Total Received (DASH) ^", + SortOrder::Descending => "Total Received (DASH) v", + } + } else { + "Total Received (DASH)" + }; + if ui.button(label).clicked() { + self.toggle_sort(SortColumn::TotalReceived); + } + }); + header.col(|ui| { + let label = if self.sort_column == SortColumn::Type { + match self.sort_order { + SortOrder::Ascending => "Type ^", + SortOrder::Descending => "Type v", + } + } else { + "Type" + }; + if ui.button(label).clicked() { + self.toggle_sort(SortColumn::Type); + } + }); + header.col(|ui| { + let label = if self.sort_column == SortColumn::Index { + match self.sort_order { + SortOrder::Ascending => "Index ^", + SortOrder::Descending => "Index v", + } + } else { + "Index" + }; + if ui.button(label).clicked() { + self.toggle_sort(SortColumn::Index); + } + }); + header.col(|ui| { + let label = if self.sort_column == SortColumn::DerivationPath { + match self.sort_order { + SortOrder::Ascending => "Full Path ^", + SortOrder::Descending => "Full Path v", + } + } else { + "Full Path" + }; + if ui.button(label).clicked() { + self.toggle_sort(SortColumn::DerivationPath); + } + }); + header.col(|ui| { + ui.label("Private Key"); + }); + }) + .body(|mut body| { + let network = self.app_context.network; + for data in &address_data { + body.row(25.0, |mut row| { + let is_key_only = data.account_category.is_key_only(); + let is_platform_payment = + data.account_category == AccountCategory::PlatformPayment; + + row.col(|ui| { + ui.label(data.display_address(network)); + }); + row.col(|ui| { + if is_key_only { + ui.label("N/A"); + } else if is_platform_payment { + // Platform credits: convert from credits to DASH + // Credits are in duffs * 1000, so divide by 1000 then by 1e8 + let dash_balance = + data.platform_credits as f64 / CREDITS_PER_DUFF as f64 / 1e8; + ui.label(format!("{:.8}", dash_balance)); + } else { + let dash_balance = data.balance as f64 * 1e-8; + ui.label(format!("{:.8}", dash_balance)); + } + }); + row.col(|ui| { + // Key-only addresses and Platform addresses don't hold UTXOs + if is_key_only || is_platform_payment { + ui.label("N/A"); + } else { + ui.label(format!("{}", data.utxo_count)); + } + }); + row.col(|ui| { + // These address types don't track historical received amounts + if is_key_only || is_platform_payment { + ui.label("N/A"); + } else { + let dash_received = data.total_received as f64 * 1e-8; + ui.label(format!("{:.8}", dash_received)); + } + }); + row.col(|ui| { + ui.label(&data.address_type); + }); + row.col(|ui| { + ui.label(format!("{}", data.index)); + }); + row.col(|ui| { + ui.label(format!("{}", data.derivation_path)); + }); + row.col(|ui| { + if ui.button("View Key").clicked() { + // Check if wallet is locked first + let wallet_locked = self + .selected_wallet + .as_ref() + .map(|w| { + w.read() + .map(|g| g.uses_password && !g.is_open()) + .unwrap_or(false) + }) + .unwrap_or(false); + + let display_address = data.display_address(network); + + if wallet_locked { + // Store pending info and show unlock popup + self.private_key_dialog.pending_derivation_path = + Some(data.derivation_path.clone()); + self.private_key_dialog.pending_address = Some(display_address); + self.wallet_unlock_popup.open(); + } else { + match self.derive_private_key_wif(&data.derivation_path) { + Ok(key) => { + self.private_key_dialog.is_open = true; + self.private_key_dialog.address = display_address; + self.private_key_dialog.private_key_wif = key; + self.private_key_dialog.show_key = false; + } + Err(err) => self.display_message(&err, MessageType::Error), + } + } + } + }); + }); + } + }); + action + } +} diff --git a/src/ui/wallets/wallets_screen/asset_locks.rs b/src/ui/wallets/wallets_screen/asset_locks.rs new file mode 100644 index 000000000..f7b152c10 --- /dev/null +++ b/src/ui/wallets/wallets_screen/asset_locks.rs @@ -0,0 +1,166 @@ +use crate::app::AppAction; +use crate::model::wallet::DerivationPathHelpers; +use crate::ui::ScreenType; +use crate::ui::theme::DashColors; +use eframe::egui::{self, Ui}; +use egui::{Color32, Frame, Margin, RichText}; +use egui_extras::{Column, TableBuilder}; + +use super::WalletsBalancesScreen; + +impl WalletsBalancesScreen { + pub(super) fn render_wallet_asset_locks(&mut self, ui: &mut Ui) -> AppAction { + let mut app_action = AppAction::None; + let mut open_fund_dialog_for_idx: Option<(usize, Vec<(String, u64)>)> = None; + let mut recover_asset_locks_clicked = false; + + if let Some(arc_wallet) = &self.selected_wallet { + let wallet = arc_wallet.read().unwrap(); + + let dark_mode = ui.ctx().style().visuals.dark_mode; + Frame::new() + .fill(DashColors::surface(dark_mode)) + .corner_radius(5.0) + .inner_margin(Margin::same(15)) + .stroke(egui::Stroke::new(1.0, DashColors::border_light(dark_mode))) + .show(ui, |ui| { + let dark_mode = ui.ctx().style().visuals.dark_mode; + ui.horizontal(|ui| { + ui.heading(RichText::new("Asset Locks").color(DashColors::text_primary(dark_mode))); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Create Asset Lock").clicked() { + app_action = AppAction::AddScreen( + ScreenType::CreateAssetLock(arc_wallet.clone()).create_screen(&self.app_context) + ); + } + if ui.button("Search for Unused").on_hover_text("Scan Core wallet for untracked asset locks").clicked() { + recover_asset_locks_clicked = true; + } + }); + }); + ui.add_space(10.0); + + if wallet.unused_asset_locks.is_empty() { + ui.vertical_centered(|ui| { + ui.add_space(20.0); + ui.label(RichText::new("No asset locks found").color(Color32::GRAY).size(14.0)); + ui.add_space(10.0); + ui.label(RichText::new("Asset locks are special transactions that can be used to create identities or fund Platform addresses").color(Color32::GRAY).size(12.0)); + ui.add_space(20.0); + }); + } else { + // Collect Platform addresses for the fund dialog (using DIP-18 Bech32m format) + // Get from known_addresses where path is platform payment + let network = self.app_context.network; + let platform_addresses: Vec<(String, u64)> = wallet + .known_addresses + .iter() + .filter(|(_, path)| path.is_platform_payment(network)) + .filter_map(|(addr, _)| { + use dash_sdk::dpp::address_funds::PlatformAddress; + let balance = wallet + .get_platform_address_info(addr) + .map(|info| info.balance) + .unwrap_or(0); + PlatformAddress::try_from(addr.clone()) + .ok() + .map(|pa| (pa.to_bech32m_string(network), balance)) + }) + .collect(); + + egui::ScrollArea::both() + .id_salt("asset_locks_table") + .min_scrolled_height(200.0) + .show(ui, |ui| { + TableBuilder::new(ui) + .striped(false) + .resizable(true) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .column(Column::initial(200.0)) // Transaction ID + .column(Column::initial(100.0)) // Address + .column(Column::initial(100.0)) // Amount (Duffs) + .column(Column::initial(100.0)) // InstantLock status + .column(Column::initial(100.0)) // Usable status + .column(Column::initial(200.0)) // Actions + .header(30.0, |mut header| { + header.col(|ui| { + ui.label("Transaction ID"); + }); + header.col(|ui| { + ui.label("Address"); + }); + header.col(|ui| { + ui.label("Amount (Duffs)"); + }); + header.col(|ui| { + ui.label("InstantLock"); + }); + header.col(|ui| { + ui.label("Usable"); + }); + header.col(|ui| { + ui.label("Actions"); + }); + }) + .body(|mut body| { + for (index, (tx, address, amount, islock, proof)) in wallet.unused_asset_locks.iter().enumerate() { + body.row(25.0, |mut row| { + row.col(|ui| { + ui.label(tx.txid().to_string()); + }); + row.col(|ui| { + ui.label(address.to_string()); + }); + row.col(|ui| { + ui.label(format!("{}", amount)); + }); + row.col(|ui| { + let status = if islock.is_some() { "Yes" } else { "No" }; + ui.label(status); + }); + row.col(|ui| { + let status = if proof.is_some() { "Yes" } else { "No" }; + ui.label(status); + }); + row.col(|ui| { + if ui.small_button("View").on_hover_text("View full asset lock details").clicked() { + app_action = AppAction::AddScreen( + ScreenType::AssetLockDetail( + wallet.seed_hash(), + index + ).create_screen(&self.app_context) + ); + } + if proof.is_some() + && ui.small_button("Fund").on_hover_text("Fund a Platform address with this asset lock").clicked() { + open_fund_dialog_for_idx = Some((index, platform_addresses.clone())); + } + }); + }); + } + }); + }); + } + }); + } else { + ui.label("No wallet selected."); + } + + // Handle dialog opening outside the borrow + if let Some((idx, platform_addresses)) = open_fund_dialog_for_idx { + self.fund_platform_dialog.selected_asset_lock_index = Some(idx); + self.fund_platform_dialog.is_open = true; + self.fund_platform_dialog.platform_addresses = platform_addresses; + self.fund_platform_dialog.selected_platform_address = None; + self.fund_platform_dialog.status = None; + self.fund_platform_dialog.is_processing = false; + } + + // Handle recover asset locks button click - use custom action to check lock status + if recover_asset_locks_clicked { + app_action = AppAction::Custom("SearchAssetLocks".to_string()); + } + + app_action + } +} diff --git a/src/ui/wallets/wallets_screen/dialogs.rs b/src/ui/wallets/wallets_screen/dialogs.rs new file mode 100644 index 000000000..23a71f366 --- /dev/null +++ b/src/ui/wallets/wallets_screen/dialogs.rs @@ -0,0 +1,1195 @@ +use crate::app::AppAction; +use crate::backend_task::BackendTask; +use crate::backend_task::core::{CoreTask, PaymentRecipient, WalletPaymentRequest}; +use crate::backend_task::wallet::WalletTask; +use crate::model::amount::Amount; +use crate::model::wallet::{DerivationPathHelpers, Wallet}; +use crate::ui::components::amount_input::AmountInput; +use crate::ui::components::component_trait::{Component, ComponentResponse}; +use crate::ui::helpers::copy_text_to_clipboard; +use crate::ui::identities::funding_common::generate_qr_code_image; +use crate::ui::theme::DashColors; +use dash_sdk::dashcore_rpc::dashcore::Address; +use dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked; +use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; +use dash_sdk::dpp::key_wallet::bip32::DerivationPath; +use eframe::egui::{self, ComboBox, Context}; +use eframe::epaint::TextureHandle; +use egui::load::SizedTexture; +use egui::{Color32, Frame, Margin, RichText, TextureOptions}; +use std::sync::{Arc, RwLock}; + +use super::WalletsBalancesScreen; + +#[derive(Default)] +pub(super) struct SendDialogState { + pub is_open: bool, + pub address: String, + pub amount: Option, + pub amount_input: Option, + pub subtract_fee: bool, + pub memo: String, + pub error: Option, +} + +/// Type of address to receive to +#[derive(Default, Clone, Copy, PartialEq, Eq)] +pub(super) enum ReceiveAddressType { + /// Core (L1) address for receiving Dash + #[default] + Core, + /// Platform address for receiving credits + Platform, +} + +/// Unified state for the receive dialog (Core and Platform) +#[derive(Default)] +pub(super) struct ReceiveDialogState { + pub is_open: bool, + /// Selected address type (Core or Platform) + pub address_type: ReceiveAddressType, + /// Core addresses with balances: (address, balance_duffs) + pub core_addresses: Vec<(String, u64)>, + /// Currently selected Core address index + pub selected_core_index: usize, + /// Platform addresses with balances: (display_address, balance_credits) + pub platform_addresses: Vec<(String, u64)>, + /// Currently selected Platform address index + pub selected_platform_index: usize, + pub qr_texture: Option, + pub qr_address: Option, + pub status: Option, +} + +/// State for the Fund Platform Address from Asset Lock dialog +#[derive(Default)] +pub(super) struct FundPlatformAddressDialogState { + pub is_open: bool, + /// Selected asset lock index + pub selected_asset_lock_index: Option, + /// Selected Platform address to fund + pub selected_platform_address: Option, + /// List of Platform addresses available + pub platform_addresses: Vec<(String, u64)>, + pub status: Option, + /// Whether the current status is an error message + pub status_is_error: bool, + pub is_processing: bool, + /// Whether we should continue funding after the wallet is unlocked + pub pending_fund_after_unlock: bool, +} + +/// State for the Private Key dialog +#[derive(Default)] +pub(super) struct PrivateKeyDialogState { + pub is_open: bool, + /// The address being displayed + pub address: String, + /// The private key in WIF format + pub private_key_wif: String, + /// Whether to show the private key (hidden by default) + pub show_key: bool, + /// Pending derivation path (when wallet needs unlock first) + pub pending_derivation_path: Option, + /// Pending address string (when wallet needs unlock first) + pub pending_address: Option, +} + +impl WalletsBalancesScreen { + pub(super) fn draw_modal_overlay(ctx: &Context, id: &str) { + let screen_rect = ctx.content_rect(); + let painter = ctx.layer_painter(egui::LayerId::new( + egui::Order::Background, + egui::Id::new(id), + )); + painter.rect_filled( + screen_rect, + 0.0, + egui::Color32::from_rgba_unmultiplied(0, 0, 0, 120), + ); + } + + pub(super) fn modal_frame(ctx: &Context) -> Frame { + Frame { + inner_margin: egui::Margin::same(20), + outer_margin: egui::Margin::same(0), + corner_radius: egui::CornerRadius::same(8), + shadow: egui::epaint::Shadow { + offset: [0, 8], + blur: 16, + spread: 0, + color: egui::Color32::from_rgba_unmultiplied(0, 0, 0, 100), + }, + fill: ctx.style().visuals.window_fill, + stroke: egui::Stroke::new( + 1.0, + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30), + ), + } + } + + pub(super) fn render_send_dialog(&mut self, ctx: &Context) -> AppAction { + if !self.send_dialog.is_open { + return AppAction::None; + } + + let mut action = AppAction::None; + let mut open = self.send_dialog.is_open; + egui::Window::new("Send Dash") + .collapsible(false) + .resizable(false) + .open(&mut open) + .show(ctx, |ui| { + ui.label("Recipient Address"); + ui.add(egui::TextEdit::singleline(&mut self.send_dialog.address).hint_text("y...")); + + ui.add_space(8.0); + + // Amount input using AmountInput component + let amount_input = self.send_dialog.amount_input.get_or_insert_with(|| { + AmountInput::new(Amount::new_dash(0.0)) + .with_label("Amount (DASH):") + .with_hint_text("Enter amount (e.g., 0.01)") + .with_desired_width(150.0) + }); + + let response = amount_input.show(ui); + response.inner.update(&mut self.send_dialog.amount); + + ui.checkbox( + &mut self.send_dialog.subtract_fee, + "Subtract fee from amount", + ); + + ui.label("Memo (optional)"); + ui.add(egui::TextEdit::singleline(&mut self.send_dialog.memo)); + + if let Some(error) = self.send_dialog.error.clone() { + let error_color = Color32::from_rgb(255, 100, 100); + Frame::new() + .fill(error_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, error_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("Error: {}", error)).color(error_color), + ); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.send_dialog.error = None; + } + }); + }); + } + + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button("Send").clicked() { + match self.prepare_send_action() { + Ok(app_action) => { + action = app_action; + self.send_dialog = SendDialogState::default(); + } + Err(err) => self.send_dialog.error = Some(err), + } + } + }); + }); + + self.send_dialog.is_open = open; + action + } + + pub(super) fn render_receive_dialog(&mut self, ctx: &Context) -> AppAction { + if !self.receive_dialog.is_open { + return AppAction::None; + } + + // Refresh cached balances from the wallet so SPV updates are reflected + if let Some(wallet) = &self.selected_wallet + && let Ok(wallet_guard) = wallet.read() + { + use dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked; + for (addr_str, balance) in &mut self.receive_dialog.core_addresses { + if let Ok(addr) = addr_str.parse::>() + && let Ok(addr) = addr.require_network(self.app_context.network) + { + *balance = wallet_guard + .address_balances + .get(&addr) + .copied() + .unwrap_or(0); + } + } + } + + let dark_mode = ctx.style().visuals.dark_mode; + + // Determine current address based on selected type + let current_address = match self.receive_dialog.address_type { + ReceiveAddressType::Core => self + .receive_dialog + .core_addresses + .get(self.receive_dialog.selected_core_index) + .map(|(addr, _)| addr.clone()), + ReceiveAddressType::Platform => self + .receive_dialog + .platform_addresses + .get(self.receive_dialog.selected_platform_index) + .map(|(addr, _)| addr.clone()), + }; + + // Generate QR texture if needed + if let Some(address) = current_address.clone() { + let needs_texture = self.receive_dialog.qr_texture.is_none() + || self.receive_dialog.qr_address.as_deref() != Some(&address); + if needs_texture { + match generate_qr_code_image(&address) { + Ok(image) => { + let texture = ctx.load_texture( + format!("receive_{}", address), + image, + TextureOptions::LINEAR, + ); + self.receive_dialog.qr_texture = Some(texture); + self.receive_dialog.qr_address = Some(address); + } + Err(err) => { + self.receive_dialog.status = Some(err.to_string()); + } + } + } + } + + let mut open = self.receive_dialog.is_open; + + // Draw dark overlay behind the dialog (only when open) + if open { + Self::draw_modal_overlay(ctx, "receive_dialog_overlay"); + } + + egui::Window::new("Receive") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .open(&mut open) + .frame(Self::modal_frame(ctx)) + .show(ctx, |ui| { + ui.set_min_width(350.0); + ui.vertical_centered(|ui| { + ui.add_space(5.0); + + // Address type selector at the top + ui.horizontal(|ui| { + ui.selectable_value( + &mut self.receive_dialog.address_type, + ReceiveAddressType::Core, + RichText::new("Core").color(DashColors::text_primary(dark_mode)), + ); + ui.selectable_value( + &mut self.receive_dialog.address_type, + ReceiveAddressType::Platform, + RichText::new("Platform").color(DashColors::text_primary(dark_mode)), + ); + }); + + // Clear QR when switching types + let type_label = match self.receive_dialog.address_type { + ReceiveAddressType::Core => "Core Address", + ReceiveAddressType::Platform => "Platform Address", + }; + + ui.add_space(5.0); + ui.label( + RichText::new(type_label) + .color(DashColors::text_secondary(dark_mode)) + .size(12.0), + ); + ui.add_space(10.0); + + // Show QR code + if let Some(texture) = &self.receive_dialog.qr_texture { + ui.image(SizedTexture::new(texture.id(), egui::vec2(220.0, 220.0))); + } else if current_address.is_some() { + ui.label("Generating QR code..."); + } + + ui.add_space(10.0); + + match self.receive_dialog.address_type { + ReceiveAddressType::Core => { + // Core address selector (if multiple addresses) + if self.receive_dialog.core_addresses.len() > 1 { + ui.horizontal(|ui| { + ui.label("Address:"); + ComboBox::from_id_salt("core_addr_selector") + .selected_text( + self.receive_dialog + .core_addresses + .get(self.receive_dialog.selected_core_index) + .map(|(addr, balance)| { + let balance_dash = *balance as f64 / 1e8; + format!( + "{}... ({:.4} DASH)", + &addr[..12.min(addr.len())], + balance_dash + ) + }) + .unwrap_or_default(), + ) + .show_ui(ui, |ui| { + for (idx, (addr, balance)) in + self.receive_dialog.core_addresses.iter().enumerate() + { + let balance_dash = *balance as f64 / 1e8; + let label = format!( + "{}... ({:.4} DASH)", + &addr[..12.min(addr.len())], + balance_dash + ); + if ui + .selectable_label( + idx == self.receive_dialog.selected_core_index, + label, + ) + .clicked() + { + self.receive_dialog.selected_core_index = idx; + // Clear QR so it regenerates + self.receive_dialog.qr_texture = None; + self.receive_dialog.qr_address = None; + } + } + }); + }); + ui.add_space(5.0); + } + + // Show selected Core address + if let Some((address, balance)) = self + .receive_dialog + .core_addresses + .get(self.receive_dialog.selected_core_index) + .cloned() + { + ui.label( + RichText::new(&address) + .monospace() + .color(DashColors::text_primary(dark_mode)), + ); + + let balance_dash = balance as f64 / 1e8; + ui.label( + RichText::new(format!("Balance: {:.8} DASH", balance_dash)) + .color(DashColors::text_secondary(dark_mode)), + ); + + ui.add_space(8.0); + + let mut copy_status: Option = None; + let mut generate_new = false; + + ui.horizontal(|ui| { + if ui.button("Copy Address").clicked() { + if let Err(err) = copy_text_to_clipboard(&address) { + copy_status = Some(format!("Error: {}", err)); + } else { + copy_status = Some("Address copied!".to_string()); + } + } + + if ui.button("New Address").clicked() { + generate_new = true; + } + }); + + if let Some(status) = copy_status { + self.receive_dialog.status = Some(status); + } + + if generate_new + && let Some(wallet) = &self.selected_wallet { + match self.generate_new_core_receive_address(wallet) { + Ok((new_addr, new_balance)) => { + self.receive_dialog.core_addresses.push((new_addr, new_balance)); + self.receive_dialog.selected_core_index = + self.receive_dialog.core_addresses.len() - 1; + self.receive_dialog.qr_texture = None; + self.receive_dialog.qr_address = None; + self.receive_dialog.status = Some("New address generated!".to_string()); + } + Err(err) => { + self.receive_dialog.status = Some(err); + } + } + } + } + + ui.add_space(10.0); + ui.label( + RichText::new("Send Dash to this address to add funds to your wallet.") + .color(DashColors::text_secondary(dark_mode)) + .size(11.0) + .italics(), + ); + } + ReceiveAddressType::Platform => { + // Platform address selector (if multiple addresses) + if self.receive_dialog.platform_addresses.len() > 1 { + ui.horizontal(|ui| { + ui.label("Address:"); + ComboBox::from_id_salt("platform_addr_selector") + .selected_text( + self.receive_dialog + .platform_addresses + .get(self.receive_dialog.selected_platform_index) + .map(|(addr, balance)| { + let credits_as_dash = + *balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; + format!( + "{}... ({:.4} DASH)", + &addr[..12.min(addr.len())], + credits_as_dash + ) + }) + .unwrap_or_default(), + ) + .show_ui(ui, |ui| { + for (idx, (addr, balance)) in + self.receive_dialog.platform_addresses.iter().enumerate() + { + let credits_as_dash = + *balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; + let label = format!( + "{}... ({:.4} DASH)", + &addr[..12.min(addr.len())], + credits_as_dash + ); + if ui + .selectable_label( + idx == self.receive_dialog.selected_platform_index, + label, + ) + .clicked() + { + self.receive_dialog.selected_platform_index = idx; + // Clear QR so it regenerates + self.receive_dialog.qr_texture = None; + self.receive_dialog.qr_address = None; + } + } + }); + }); + ui.add_space(5.0); + } + + // Show selected Platform address + let selected_addr_data = self + .receive_dialog + .platform_addresses + .get(self.receive_dialog.selected_platform_index) + .cloned(); + + if let Some((address, balance)) = selected_addr_data { + ui.label( + RichText::new(&address) + .monospace() + .color(DashColors::text_primary(dark_mode)), + ); + + let credits_as_dash = balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; + ui.label( + RichText::new(format!("Balance: {:.8} DASH", credits_as_dash)) + .color(DashColors::text_secondary(dark_mode)), + ); + + ui.add_space(8.0); + + let mut copy_status: Option = None; + let mut new_addr_result: Option> = None; + + ui.horizontal(|ui| { + if ui.button("Copy Address").clicked() { + if let Err(err) = copy_text_to_clipboard(&address) { + copy_status = Some(format!("Error: {}", err)); + } else { + copy_status = Some("Address copied!".to_string()); + } + } + + // Button to add new Platform address + if let Some(wallet) = &self.selected_wallet + && ui.button("New Address").clicked() + { + new_addr_result = Some(self.generate_platform_address(wallet)); + } + }); + + // Handle copy status after the closure + if let Some(status) = copy_status { + self.receive_dialog.status = Some(status); + } + + // Handle new address generation after the closure + if let Some(result) = new_addr_result { + match result { + Ok(new_addr) => { + self.receive_dialog.platform_addresses.push((new_addr, 0)); + self.receive_dialog.selected_platform_index = + self.receive_dialog.platform_addresses.len() - 1; + self.receive_dialog.qr_texture = None; + self.receive_dialog.qr_address = None; + self.receive_dialog.status = + Some("New address generated!".to_string()); + } + Err(err) => { + self.receive_dialog.status = Some(err); + } + } + } + } + + ui.add_space(10.0); + ui.label( + RichText::new( + "Send credits from an identity or another Platform address to fund this address.", + ) + .color(DashColors::text_secondary(dark_mode)) + .size(11.0) + .italics(), + ); + } + } + + if let Some(status) = &self.receive_dialog.status { + ui.add_space(8.0); + ui.label( + RichText::new(status).color(DashColors::text_secondary(dark_mode)), + ); + } + }); + }); + + self.receive_dialog.is_open = open; + if !self.receive_dialog.is_open { + self.receive_dialog = ReceiveDialogState::default(); + } + AppAction::None + } + + /// Generate a new Platform address for the wallet. + /// Returns the address in Bech32m format (e.g., tevo1... for testnet) + pub(super) fn generate_platform_address( + &self, + wallet: &Arc>, + ) -> Result { + use dash_sdk::dpp::address_funds::PlatformAddress; + let mut wallet_guard = wallet.write().map_err(|e| e.to_string())?; + // Pass true to skip known addresses and generate a new one + let address = wallet_guard + .platform_receive_address(self.app_context.network, true, Some(&self.app_context)) + .map_err(|e| e.to_string())?; + // Convert to PlatformAddress and encode as Bech32m per DIP-18 + let platform_addr = + PlatformAddress::try_from(address).map_err(|e| format!("Invalid address: {}", e))?; + Ok(platform_addr.to_bech32m_string(self.app_context.network)) + } + + /// Generate a new Core receive address for the wallet + /// Returns (address_string, balance_duffs) + pub(super) fn generate_new_core_receive_address( + &self, + wallet: &Arc>, + ) -> Result<(String, u64), String> { + let mut wallet_guard = wallet.write().map_err(|e| e.to_string())?; + let address = wallet_guard + .receive_address(self.app_context.network, true, Some(&self.app_context)) + .map_err(|e| e.to_string())?; + let balance = wallet_guard + .address_balances + .get(&address) + .copied() + .unwrap_or(0); + Ok((address.to_string(), balance)) + } + + /// Render the Fund Platform Address from Asset Lock dialog + pub(super) fn render_fund_platform_dialog(&mut self, ctx: &Context) -> AppAction { + if !self.fund_platform_dialog.is_open { + return AppAction::None; + } + + let mut action = AppAction::None; + let mut open = self.fund_platform_dialog.is_open; + let dark_mode = ctx.style().visuals.dark_mode; + + // Draw dark overlay behind the popup + Self::draw_modal_overlay(ctx, "fund_platform_dialog_overlay"); + + egui::Window::new("Fund Platform Address from Asset Lock") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .open(&mut open) + .frame(Self::modal_frame(ctx)) + .show(ctx, |ui| { + ui.set_min_width(400.0); + + ui.vertical(|ui| { + ui.label( + RichText::new("Select a Platform address to fund:") + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(10.0); + + // Platform address selector + if self.fund_platform_dialog.platform_addresses.is_empty() { + ui.label( + RichText::new("No Platform addresses found. Generate one first.") + .color(DashColors::text_secondary(dark_mode)) + .italics(), + ); + } else { + ComboBox::from_id_salt("fund_platform_addr_selector") + .selected_text( + self.fund_platform_dialog + .selected_platform_address + .as_deref() + .map(|addr| { + let balance = self + .fund_platform_dialog + .platform_addresses + .iter() + .find(|(a, _)| a == addr) + .map(|(_, b)| *b) + .unwrap_or(0); + let credits_as_dash = + balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; + format!( + "{}... ({:.4} DASH)", + &addr[..12.min(addr.len())], + credits_as_dash + ) + }) + .unwrap_or_else(|| "Select an address".to_string()), + ) + .show_ui(ui, |ui| { + for (addr, balance) in &self.fund_platform_dialog.platform_addresses + { + let credits_as_dash = + *balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; + let label = format!( + "{}... ({:.4} DASH)", + &addr[..12.min(addr.len())], + credits_as_dash + ); + let is_selected = self + .fund_platform_dialog + .selected_platform_address + .as_deref() + == Some(addr.as_str()); + if ui.selectable_label(is_selected, label).clicked() { + self.fund_platform_dialog.selected_platform_address = + Some(addr.clone()); + } + } + }); + } + + ui.add_space(15.0); + + // Status message + if let Some(status) = &self.fund_platform_dialog.status { + let status_color = if self.fund_platform_dialog.status_is_error { + egui::Color32::from_rgb(220, 50, 50) + } else { + DashColors::text_secondary(dark_mode) + }; + ui.label(RichText::new(status).color(status_color)); + ui.add_space(10.0); + } + + // Buttons + ui.horizontal(|ui| { + let can_fund = self.fund_platform_dialog.selected_platform_address.is_some() + && self.fund_platform_dialog.selected_asset_lock_index.is_some() + && !self.fund_platform_dialog.is_processing; + + // Cancel button + let cancel_button = egui::Button::new( + RichText::new("Cancel").color(DashColors::text_primary(dark_mode)), + ) + .fill(egui::Color32::TRANSPARENT) + .stroke(egui::Stroke::new(1.0, DashColors::text_secondary(dark_mode))) + .corner_radius(egui::CornerRadius::same(4)) + .min_size(egui::Vec2::new(80.0, 32.0)); + + if ui.add(cancel_button).clicked() { + self.fund_platform_dialog.is_open = false; + } + + ui.add_space(8.0); + + // Fund button + let fund_button = egui::Button::new( + RichText::new(if self.fund_platform_dialog.is_processing { + "Funding..." + } else { + "Fund Address" + }) + .color(egui::Color32::WHITE), + ) + .fill(if can_fund { + DashColors::DASH_BLUE + } else { + DashColors::text_secondary(dark_mode) + }) + .corner_radius(egui::CornerRadius::same(4)) + .min_size(egui::Vec2::new(100.0, 32.0)); + + if ui.add_enabled(can_fund, fund_button).clicked() { + // Check if wallet is locked + let is_locked = self + .selected_wallet + .as_ref() + .and_then(|w| w.read().ok()) + .map(|w| !w.is_open()) + .unwrap_or(false); + + if is_locked { + // Wallet is locked - open unlock popup and set pending flag + self.fund_platform_dialog.pending_fund_after_unlock = true; + self.wallet_unlock_popup.open(); + } else { + action = self.prepare_fund_platform_action(); + } + } + }); + + ui.add_space(10.0); + ui.label( + RichText::new( + "The entire asset lock amount will be used to fund the Platform address.", + ) + .color(DashColors::text_secondary(dark_mode)) + .size(11.0) + .italics(), + ); + }); + }); + + // Only update from `open` if we didn't manually close via cancel button + if self.fund_platform_dialog.is_open { + self.fund_platform_dialog.is_open = open; + } + if !self.fund_platform_dialog.is_open { + self.fund_platform_dialog = FundPlatformAddressDialogState::default(); + } + action + } + + /// Render the Private Key dialog + pub(super) fn render_private_key_dialog(&mut self, ctx: &Context) { + if !self.private_key_dialog.is_open { + return; + } + + let dark_mode = ctx.style().visuals.dark_mode; + let mut open = self.private_key_dialog.is_open; + + // Draw dark overlay behind the dialog + if open { + Self::draw_modal_overlay(ctx, "private_key_dialog_overlay"); + } + + egui::Window::new("Private Key") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .open(&mut open) + .frame(Self::modal_frame(ctx)) + .show(ctx, |ui| { + ui.set_min_width(400.0); + ui.vertical_centered(|ui| { + ui.add_space(5.0); + + // Address label + ui.label( + RichText::new("Address") + .color(DashColors::text_secondary(dark_mode)) + .size(12.0), + ); + ui.add_space(5.0); + + // Address value + ui.label( + RichText::new(&self.private_key_dialog.address) + .monospace() + .color(DashColors::text_primary(dark_mode)), + ); + + ui.add_space(5.0); + + // Copy address button + if ui.button("Copy Address").clicked() { + let _ = copy_text_to_clipboard(&self.private_key_dialog.address); + } + + ui.add_space(15.0); + ui.separator(); + ui.add_space(15.0); + + // Private key label + ui.label( + RichText::new("Private Key (WIF)") + .color(DashColors::text_secondary(dark_mode)) + .size(12.0), + ); + ui.add_space(5.0); + + // Private key value (hidden by default) + if self.private_key_dialog.show_key { + ui.label( + RichText::new(&self.private_key_dialog.private_key_wif) + .monospace() + .color(DashColors::text_primary(dark_mode)), + ); + } else { + ui.label( + RichText::new("••••••••••••••••••••••••••••••••••••••••••••••••••••") + .monospace() + .color(DashColors::text_secondary(dark_mode)), + ); + } + + ui.add_space(10.0); + + // Show/Hide and Copy buttons + ui.horizontal(|ui| { + if ui + .button(if self.private_key_dialog.show_key { + "Hide Key" + } else { + "Show Key" + }) + .clicked() + { + self.private_key_dialog.show_key = !self.private_key_dialog.show_key; + } + + if ui.button("Copy Key").clicked() { + let _ = + copy_text_to_clipboard(&self.private_key_dialog.private_key_wif); + } + }); + + ui.add_space(15.0); + + // Warning message + ui.label( + RichText::new("Keep your private key secure. Never share it with anyone.") + .color(DashColors::error_color(dark_mode)) + .size(11.0) + .italics(), + ); + }); + }); + + self.private_key_dialog.is_open = open; + if !self.private_key_dialog.is_open { + self.private_key_dialog = PrivateKeyDialogState::default(); + } + } + + /// Prepare the backend task for funding a Platform address from asset lock + pub(super) fn prepare_fund_platform_action(&mut self) -> AppAction { + use dash_sdk::dpp::address_funds::PlatformAddress; + use std::collections::BTreeMap; + + let Some(wallet_arc) = &self.selected_wallet else { + self.fund_platform_dialog.status = Some("No wallet selected".to_string()); + self.fund_platform_dialog.status_is_error = true; + return AppAction::None; + }; + + let Some(selected_addr) = &self.fund_platform_dialog.selected_platform_address else { + self.fund_platform_dialog.status = Some("Select a Platform address".to_string()); + self.fund_platform_dialog.status_is_error = true; + return AppAction::None; + }; + + let Some(asset_lock_idx) = self.fund_platform_dialog.selected_asset_lock_index else { + self.fund_platform_dialog.status = Some("No asset lock selected".to_string()); + self.fund_platform_dialog.status_is_error = true; + return AppAction::None; + }; + + // Get the asset lock proof and address from the wallet + let (seed_hash, asset_lock_proof, asset_lock_address, platform_addr) = { + let wallet = match wallet_arc.read() { + Ok(guard) => guard, + Err(e) => { + self.fund_platform_dialog.status = Some(e.to_string()); + self.fund_platform_dialog.status_is_error = true; + return AppAction::None; + } + }; + + let asset_lock = wallet.unused_asset_locks.get(asset_lock_idx); + let Some((_, addr, _, _, Some(proof))) = asset_lock else { + self.fund_platform_dialog.status = + Some("Asset lock not found or not ready".to_string()); + self.fund_platform_dialog.status_is_error = true; + return AppAction::None; + }; + + // Parse the Platform address (Bech32m format: evo1.../tevo1...) + let platform_addr = if selected_addr.starts_with("evo1") + || selected_addr.starts_with("tevo1") + { + match PlatformAddress::from_bech32m_string(selected_addr) { + Ok((addr, network)) => { + // Validate that address network matches app network + if network != self.app_context.network { + self.fund_platform_dialog.status = Some(format!( + "Address network mismatch: address is for {:?} but app is on {:?}", + network, self.app_context.network + )); + self.fund_platform_dialog.status_is_error = true; + return AppAction::None; + } + addr + } + Err(e) => { + self.fund_platform_dialog.status = + Some(format!("Invalid Bech32m address: {}", e)); + self.fund_platform_dialog.status_is_error = true; + return AppAction::None; + } + } + } else { + // Fall back to base58 parsing for backwards compatibility + match selected_addr + .parse::>() + .map_err(|e| e.to_string()) + .and_then(|a: Address| { + PlatformAddress::try_from(a.assume_checked()) + .map_err(|e| format!("Invalid Platform address: {}", e)) + }) { + Ok(addr) => addr, + Err(e) => { + self.fund_platform_dialog.status = Some(e); + self.fund_platform_dialog.status_is_error = true; + return AppAction::None; + } + } + }; + + ( + wallet.seed_hash(), + Box::new(proof.clone()), + addr.clone(), + platform_addr, + ) + }; + + // Build outputs - fund the entire asset lock to the selected Platform address + let mut outputs: BTreeMap> = BTreeMap::new(); + outputs.insert(platform_addr, None); // None = take the full amount + + self.fund_platform_dialog.is_processing = true; + self.fund_platform_dialog.status = Some("Processing...".to_string()); + self.fund_platform_dialog.status_is_error = false; + + AppAction::BackendTask(BackendTask::WalletTask( + WalletTask::FundPlatformAddressFromAssetLock { + seed_hash, + asset_lock_proof, + asset_lock_address, + outputs, + }, + )) + } + + pub(super) fn prepare_send_action(&mut self) -> Result { + let wallet = self + .selected_wallet + .as_ref() + .ok_or_else(|| "Select a wallet first".to_string())?; + + let amount_duffs = self + .send_dialog + .amount + .as_ref() + .ok_or_else(|| "Enter an amount".to_string())? + .dash_to_duffs()?; + + if amount_duffs == 0 { + return Err("Amount must be greater than 0".to_string()); + } + + { + let wallet_guard = wallet.read().map_err(|e| e.to_string())?; + if amount_duffs > wallet_guard.confirmed_balance_duffs() { + return Err("Insufficient confirmed balance".to_string()); + } + } + + if self.send_dialog.address.trim().is_empty() { + return Err("Enter a recipient address".to_string()); + } + + let memo = self.send_dialog.memo.trim(); + let request = WalletPaymentRequest { + recipients: vec![PaymentRecipient { + address: self.send_dialog.address.trim().to_string(), + amount_duffs, + }], + subtract_fee_from_amount: self.send_dialog.subtract_fee, + memo: if memo.is_empty() { + None + } else { + Some(memo.to_string()) + }, + override_fee: None, + }; + + Ok(AppAction::BackendTask(BackendTask::CoreTask( + CoreTask::SendWalletPayment { + wallet: wallet.clone(), + request, + }, + ))) + } + + pub(super) fn open_receive_dialog(&mut self, _ctx: &Context) -> AppAction { + let Some(wallet) = self.selected_wallet.clone() else { + self.receive_dialog.status = Some("Select a wallet first".to_string()); + self.receive_dialog.core_addresses.clear(); + self.receive_dialog.platform_addresses.clear(); + self.receive_dialog.qr_texture = None; + self.receive_dialog.qr_address = None; + self.receive_dialog.is_open = true; + return AppAction::None; + }; + + self.receive_dialog.is_open = true; + self.receive_dialog.qr_texture = None; + self.receive_dialog.qr_address = None; + + // Load Core addresses (works with locked wallet - uses existing addresses) + self.load_core_addresses_for_receive(&wallet); + + // Load Platform addresses (works with locked wallet - uses existing addresses) + self.load_platform_addresses_for_receive(&wallet); + + AppAction::None + } + + /// Load Core addresses into the receive dialog + fn load_core_addresses_for_receive(&mut self, wallet: &Arc>) { + let wallet_guard = match wallet.read() { + Ok(guard) => guard, + Err(err) => { + self.receive_dialog.status = Some(err.to_string()); + return; + } + }; + + // Collect all BIP44 external (receive) addresses with their balances + let network = self.app_context.network; + let core_addresses: Vec<(String, u64)> = wallet_guard + .watched_addresses + .iter() + .filter(|(path, _)| path.is_bip44_external(network)) + .map(|(_, info)| { + let balance = wallet_guard + .address_balances + .get(&info.address) + .copied() + .unwrap_or(0); + (info.address.to_string(), balance) + }) + .collect(); + + drop(wallet_guard); + + if core_addresses.is_empty() { + // Generate a new Core address if none exists + match self.generate_new_core_receive_address(wallet) { + Ok((address, balance)) => { + self.receive_dialog.core_addresses = vec![(address, balance)]; + self.receive_dialog.selected_core_index = 0; + } + Err(err) => { + self.receive_dialog.status = Some(err); + self.receive_dialog.core_addresses.clear(); + } + } + } else { + self.receive_dialog.core_addresses = core_addresses; + self.receive_dialog.selected_core_index = 0; + } + } + + /// Load Platform addresses into the receive dialog + fn load_platform_addresses_for_receive(&mut self, wallet: &Arc>) { + let wallet_guard = match wallet.read() { + Ok(guard) => guard, + Err(err) => { + self.receive_dialog.status = Some(err.to_string()); + return; + } + }; + + // Collect Platform addresses with their balances (using DIP-18 Bech32m format) + // Use platform_addresses() which checks watched_addresses, not just platform_address_info + // This includes addresses that have been derived but may not have been synced yet + let network = self.app_context.network; + let platform_addresses: Vec<(String, u64)> = wallet_guard + .platform_addresses(network) + .into_iter() + .map(|(core_addr, platform_addr)| { + let balance = wallet_guard + .get_platform_address_info(&core_addr) + .map(|info| info.balance) + .unwrap_or(0); + (platform_addr.to_bech32m_string(network), balance) + }) + .collect(); + + drop(wallet_guard); + + if platform_addresses.is_empty() { + // Generate a new Platform address if none exists + match self.generate_platform_address(wallet) { + Ok(address) => { + self.receive_dialog.platform_addresses = vec![(address, 0)]; + self.receive_dialog.selected_platform_index = 0; + } + Err(err) => { + self.receive_dialog.status = Some(err); + self.receive_dialog.platform_addresses.clear(); + } + } + } else { + self.receive_dialog.platform_addresses = platform_addresses; + self.receive_dialog.selected_platform_index = 0; + } + } + + pub(super) fn derive_private_key_wif(&self, path: &DerivationPath) -> Result { + let wallet_arc = self + .selected_wallet + .clone() + .ok_or_else(|| "Select a wallet first".to_string())?; + let wallet = wallet_arc.read().map_err(|e| e.to_string())?; + if wallet.uses_password && !wallet.is_open() { + return Err("Unlock this wallet to view private keys.".to_string()); + } + let private_key = wallet.private_key_at_derivation_path(path, self.app_context.network)?; + Ok(private_key.to_wif()) + } +} diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 62a756543..4af4817ff 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -1,56 +1,40 @@ +mod address_table; +mod asset_locks; +mod dialogs; +mod single_key_view; + use crate::app::{AppAction, DesiredAppAction}; use crate::backend_task::BackendTask; -use crate::backend_task::core::{CoreTask, PaymentRecipient, WalletPaymentRequest}; -use crate::backend_task::wallet::WalletTask; +use crate::backend_task::core::CoreTask; use crate::context::AppContext; use crate::model::amount::Amount; -use crate::model::wallet::{ - DerivationPathHelpers, DerivationPathReference, Wallet, WalletSeedHash, WalletTransaction, -}; +use crate::model::wallet::{Wallet, WalletSeedHash, WalletTransaction}; use crate::spv::CoreBackendMode; -use crate::ui::components::amount_input::AmountInput; -use crate::ui::components::component_trait::{Component, ComponentResponse}; +use crate::ui::components::component_trait::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{WalletUnlockPopup, WalletUnlockResult}; use crate::ui::helpers::copy_text_to_clipboard; -use crate::ui::identities::funding_common::generate_qr_code_image; use crate::ui::theme::DashColors; use crate::ui::wallets::account_summary::{ AccountCategory, AccountSummary, collect_account_summaries, }; use crate::ui::{MessageType, RootScreenType, ScreenLike, ScreenType}; use chrono::{DateTime, Utc}; -use dash_sdk::dashcore_rpc::dashcore::{Address, Network}; +use dash_sdk::dashcore_rpc::dashcore::Address; use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; -use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, DerivationPath}; use eframe::egui::{self, ComboBox, Context, Ui}; -use eframe::epaint::TextureHandle; -use egui::load::SizedTexture; -use egui::{Color32, Frame, Margin, RichText, TextureOptions}; +use egui::{Color32, Frame, Margin, RichText}; use egui_extras::{Column, TableBuilder}; use std::sync::{Arc, RwLock}; use crate::model::wallet::single_key::SingleKeyWallet; - -#[derive(Clone, Copy, PartialEq, Eq)] -enum SortColumn { - Address, - Balance, - UTXOs, - TotalReceived, - Type, - Index, - DerivationPath, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum SortOrder { - Ascending, - Descending, -} +use address_table::{SortColumn, SortOrder}; +use dialogs::{ + FundPlatformAddressDialogState, PrivateKeyDialogState, ReceiveDialogState, SendDialogState, +}; /// Refresh mode for dev mode dropdown - controls what gets refreshed #[derive(Clone, Copy, PartialEq, Eq, Default)] @@ -131,110 +115,6 @@ pub struct WalletsBalancesScreen { refresh_mode: RefreshMode, } -// Define a struct to hold the address data -struct AddressData { - address: Address, - balance: u64, - /// Platform credits balance for Platform Payment addresses - platform_credits: u64, - utxo_count: usize, - total_received: u64, - address_type: String, - index: u32, - derivation_path: DerivationPath, - account_category: AccountCategory, - account_index: Option, -} - -impl AddressData { - /// Returns the address formatted for display. - /// Platform Payment addresses are shown in DIP-18 Bech32m format (e.g., tevo1...). - fn display_address(&self, network: Network) -> String { - if self.account_category == AccountCategory::PlatformPayment { - use dash_sdk::dpp::address_funds::PlatformAddress; - PlatformAddress::try_from(self.address.clone()) - .map(|pa| pa.to_bech32m_string(network)) - .unwrap_or_else(|_| self.address.to_string()) - } else { - self.address.to_string() - } - } -} - -#[derive(Default)] -struct SendDialogState { - is_open: bool, - address: String, - amount: Option, - amount_input: Option, - subtract_fee: bool, - memo: String, - error: Option, -} - -/// Type of address to receive to -#[derive(Default, Clone, Copy, PartialEq, Eq)] -enum ReceiveAddressType { - /// Core (L1) address for receiving Dash - #[default] - Core, - /// Platform address for receiving credits - Platform, -} - -/// Unified state for the receive dialog (Core and Platform) -#[derive(Default)] -struct ReceiveDialogState { - is_open: bool, - /// Selected address type (Core or Platform) - address_type: ReceiveAddressType, - /// Core addresses with balances: (address, balance_duffs) - core_addresses: Vec<(String, u64)>, - /// Currently selected Core address index - selected_core_index: usize, - /// Platform addresses with balances: (display_address, balance_credits) - platform_addresses: Vec<(String, u64)>, - /// Currently selected Platform address index - selected_platform_index: usize, - qr_texture: Option, - qr_address: Option, - status: Option, -} - -/// State for the Fund Platform Address from Asset Lock dialog -#[derive(Default)] -struct FundPlatformAddressDialogState { - is_open: bool, - /// Selected asset lock index - selected_asset_lock_index: Option, - /// Selected Platform address to fund - selected_platform_address: Option, - /// List of Platform addresses available - platform_addresses: Vec<(String, u64)>, - status: Option, - /// Whether the current status is an error message - status_is_error: bool, - is_processing: bool, - /// Whether we should continue funding after the wallet is unlocked - pending_fund_after_unlock: bool, -} - -/// State for the Private Key dialog -#[derive(Default)] -struct PrivateKeyDialogState { - is_open: bool, - /// The address being displayed - address: String, - /// The private key in WIF format - private_key_wif: String, - /// Whether to show the private key (hidden by default) - show_key: bool, - /// Pending derivation path (when wallet needs unlock first) - pending_derivation_path: Option, - /// Pending address string (when wallet needs unlock first) - pending_address: Option, -} - impl WalletsBalancesScreen { pub fn new(app_context: &Arc) -> Self { // Try to restore previously selected wallet from AppContext @@ -437,39 +317,6 @@ impl WalletsBalancesScreen { } } - fn toggle_sort(&mut self, column: SortColumn) { - if self.sort_column == column { - self.sort_order = match self.sort_order { - SortOrder::Ascending => SortOrder::Descending, - SortOrder::Descending => SortOrder::Ascending, - }; - } else { - self.sort_column = column; - self.sort_order = SortOrder::Ascending; - } - } - - #[allow(clippy::ptr_arg)] - fn sort_address_data(&self, data: &mut Vec) { - data.sort_by(|a, b| { - let order = match self.sort_column { - SortColumn::Address => a.address.cmp(&b.address), - SortColumn::Balance => a.balance.cmp(&b.balance), - SortColumn::UTXOs => a.utxo_count.cmp(&b.utxo_count), - SortColumn::TotalReceived => a.total_received.cmp(&b.total_received), - SortColumn::Type => a.address_type.cmp(&b.address_type), - SortColumn::Index => a.index.cmp(&b.index), - SortColumn::DerivationPath => a.derivation_path.cmp(&b.derivation_path), - }; - - if self.sort_order == SortOrder::Ascending { - order - } else { - order.reverse() - } - }); - } - fn render_wallet_selection(&mut self, ui: &mut Ui) -> AppAction { let action = AppAction::None; @@ -738,300 +585,6 @@ impl WalletsBalancesScreen { action } - fn render_address_table(&mut self, ui: &mut Ui) -> AppAction { - let action = AppAction::None; - - // Move the data preparation into its own scope - let mut address_data = { - let wallet = self.selected_wallet.as_ref().unwrap().read().unwrap(); - - // Prepare data for the table - wallet - .known_addresses - .iter() - .map(|(address, derivation_path)| { - let utxo_info = wallet.utxos.get(address); - - let utxo_count = utxo_info.map(|outpoints| outpoints.len()).unwrap_or(0); - - // Get total received from the wallet (fetched from Core RPC) - let total_received = wallet - .address_total_received - .get(address) - .cloned() - .unwrap_or(0u64); - - let index = derivation_path - .into_iter() - .last() - .cloned() - .unwrap_or(ChildNumber::Normal { index: 0 }); - let index = match index { - ChildNumber::Normal { index } => index, - ChildNumber::Hardened { index } => index, - _ => 0, - }; - let address_type = - if derivation_path.is_bip44_external(self.app_context.network) { - "Funds".to_string() - } else if derivation_path.is_bip44_change(self.app_context.network) { - "Change".to_string() - } else if derivation_path.is_asset_lock_funding(self.app_context.network) { - "Identity Creation".to_string() - } else if derivation_path.is_platform_payment(self.app_context.network) { - "Platform".to_string() - } else { - "System".to_string() - }; - - let path_reference = wallet - .watched_addresses - .get(derivation_path) - .map(|info| info.path_reference) - .unwrap_or(DerivationPathReference::Unknown); - let (account_category, account_index) = - Self::categorize_path(derivation_path, path_reference); - - // Get Platform credits balance for Platform Payment addresses - // Use canonical lookup to handle potential Address key mismatches - let platform_credits = wallet - .get_platform_address_info(address) - .map(|info| info.balance) - .unwrap_or_default(); - - AddressData { - address: address.clone(), - balance: wallet - .address_balances - .get(address) - .cloned() - .unwrap_or_default(), - platform_credits, - utxo_count, - total_received, - address_type, - index, - derivation_path: derivation_path.clone(), - account_category, - account_index, - } - }) - .collect::>() - }; // The borrow of `wallet` ends here - - // Now you can use `self` mutably without conflict - // Sort the data - self.sort_address_data(&mut address_data); - - if let Some((category, index)) = self.selected_account.clone() { - address_data - .retain(|data| data.account_category == category && data.account_index == index); - } - - // Space allocation for UI elements is handled by the layout system - - // Render the table - TableBuilder::new(ui) - .id_salt("addresses_table") - .striped(false) - .resizable(true) - .vscroll(false) - .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) - .column(Column::auto()) // Address - .column(Column::initial(140.0)) // Balance - .column(Column::initial(70.0)) // UTXOs - .column(Column::initial(150.0)) // Total Received - .column(Column::initial(100.0)) // Type - .column(Column::initial(70.0)) // Index - .column(Column::initial(120.0)) // Derivation Path - .column(Column::initial(120.0)) // Actions - .header(30.0, |mut header| { - header.col(|ui| { - let label = if self.sort_column == SortColumn::Address { - match self.sort_order { - SortOrder::Ascending => "Address ^", - SortOrder::Descending => "Address v", - } - } else { - "Address" - }; - if ui.button(label).clicked() { - self.toggle_sort(SortColumn::Address); - } - }); - header.col(|ui| { - let label = if self.sort_column == SortColumn::Balance { - match self.sort_order { - SortOrder::Ascending => "Balance (DASH) ^", - SortOrder::Descending => "Balance (DASH) v", - } - } else { - "Balance (DASH)" - }; - if ui.button(label).clicked() { - self.toggle_sort(SortColumn::Balance); - } - }); - header.col(|ui| { - let label = if self.sort_column == SortColumn::UTXOs { - match self.sort_order { - SortOrder::Ascending => "UTXOs ^", - SortOrder::Descending => "UTXOs v", - } - } else { - "UTXOs" - }; - if ui.button(label).clicked() { - self.toggle_sort(SortColumn::UTXOs); - } - }); - header.col(|ui| { - let label = if self.sort_column == SortColumn::TotalReceived { - match self.sort_order { - SortOrder::Ascending => "Total Received (DASH) ^", - SortOrder::Descending => "Total Received (DASH) v", - } - } else { - "Total Received (DASH)" - }; - if ui.button(label).clicked() { - self.toggle_sort(SortColumn::TotalReceived); - } - }); - header.col(|ui| { - let label = if self.sort_column == SortColumn::Type { - match self.sort_order { - SortOrder::Ascending => "Type ^", - SortOrder::Descending => "Type v", - } - } else { - "Type" - }; - if ui.button(label).clicked() { - self.toggle_sort(SortColumn::Type); - } - }); - header.col(|ui| { - let label = if self.sort_column == SortColumn::Index { - match self.sort_order { - SortOrder::Ascending => "Index ^", - SortOrder::Descending => "Index v", - } - } else { - "Index" - }; - if ui.button(label).clicked() { - self.toggle_sort(SortColumn::Index); - } - }); - header.col(|ui| { - let label = if self.sort_column == SortColumn::DerivationPath { - match self.sort_order { - SortOrder::Ascending => "Full Path ^", - SortOrder::Descending => "Full Path v", - } - } else { - "Full Path" - }; - if ui.button(label).clicked() { - self.toggle_sort(SortColumn::DerivationPath); - } - }); - header.col(|ui| { - ui.label("Private Key"); - }); - }) - .body(|mut body| { - let network = self.app_context.network; - for data in &address_data { - body.row(25.0, |mut row| { - let is_key_only = data.account_category.is_key_only(); - let is_platform_payment = - data.account_category == AccountCategory::PlatformPayment; - - row.col(|ui| { - ui.label(data.display_address(network)); - }); - row.col(|ui| { - if is_key_only { - ui.label("N/A"); - } else if is_platform_payment { - // Platform credits: convert from credits to DASH - // Credits are in duffs * 1000, so divide by 1000 then by 1e8 - let dash_balance = - data.platform_credits as f64 / CREDITS_PER_DUFF as f64 / 1e8; - ui.label(format!("{:.8}", dash_balance)); - } else { - let dash_balance = data.balance as f64 * 1e-8; - ui.label(format!("{:.8}", dash_balance)); - } - }); - row.col(|ui| { - // Key-only addresses and Platform addresses don't hold UTXOs - if is_key_only || is_platform_payment { - ui.label("N/A"); - } else { - ui.label(format!("{}", data.utxo_count)); - } - }); - row.col(|ui| { - // These address types don't track historical received amounts - if is_key_only || is_platform_payment { - ui.label("N/A"); - } else { - let dash_received = data.total_received as f64 * 1e-8; - ui.label(format!("{:.8}", dash_received)); - } - }); - row.col(|ui| { - ui.label(&data.address_type); - }); - row.col(|ui| { - ui.label(format!("{}", data.index)); - }); - row.col(|ui| { - ui.label(format!("{}", data.derivation_path)); - }); - row.col(|ui| { - if ui.button("View Key").clicked() { - // Check if wallet is locked first - let wallet_locked = self - .selected_wallet - .as_ref() - .map(|w| { - w.read() - .map(|g| g.uses_password && !g.is_open()) - .unwrap_or(false) - }) - .unwrap_or(false); - - let display_address = data.display_address(network); - - if wallet_locked { - // Store pending info and show unlock popup - self.private_key_dialog.pending_derivation_path = - Some(data.derivation_path.clone()); - self.private_key_dialog.pending_address = Some(display_address); - self.wallet_unlock_popup.open(); - } else { - match self.derive_private_key_wif(&data.derivation_path) { - Ok(key) => { - self.private_key_dialog.is_open = true; - self.private_key_dialog.address = display_address; - self.private_key_dialog.private_key_wif = key; - self.private_key_dialog.show_key = false; - } - Err(err) => self.display_message(&err, MessageType::Error), - } - } - } - }); - }); - } - }); - action - } - fn render_bottom_options(&mut self, ui: &mut Ui) { let wallet_is_open = self .selected_wallet @@ -1159,161 +712,6 @@ impl WalletsBalancesScreen { } } - fn render_wallet_asset_locks(&mut self, ui: &mut Ui) -> AppAction { - let mut app_action = AppAction::None; - let mut open_fund_dialog_for_idx: Option<(usize, Vec<(String, u64)>)> = None; - let mut recover_asset_locks_clicked = false; - - if let Some(arc_wallet) = &self.selected_wallet { - let wallet = arc_wallet.read().unwrap(); - - let dark_mode = ui.ctx().style().visuals.dark_mode; - Frame::new() - .fill(DashColors::surface(dark_mode)) - .corner_radius(5.0) - .inner_margin(Margin::same(15)) - .stroke(egui::Stroke::new(1.0, DashColors::border_light(dark_mode))) - .show(ui, |ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; - ui.horizontal(|ui| { - ui.heading(RichText::new("Asset Locks").color(DashColors::text_primary(dark_mode))); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button("Create Asset Lock").clicked() { - app_action = AppAction::AddScreen( - ScreenType::CreateAssetLock(arc_wallet.clone()).create_screen(&self.app_context) - ); - } - if ui.button("Search for Unused").on_hover_text("Scan Core wallet for untracked asset locks").clicked() { - recover_asset_locks_clicked = true; - } - }); - }); - ui.add_space(10.0); - - if wallet.unused_asset_locks.is_empty() { - ui.vertical_centered(|ui| { - ui.add_space(20.0); - ui.label(RichText::new("No asset locks found").color(Color32::GRAY).size(14.0)); - ui.add_space(10.0); - ui.label(RichText::new("Asset locks are special transactions that can be used to create identities or fund Platform addresses").color(Color32::GRAY).size(12.0)); - ui.add_space(20.0); - }); - } else { - // Collect Platform addresses for the fund dialog (using DIP-18 Bech32m format) - // Get from known_addresses where path is platform payment - let network = self.app_context.network; - let platform_addresses: Vec<(String, u64)> = wallet - .known_addresses - .iter() - .filter(|(_, path)| path.is_platform_payment(network)) - .filter_map(|(addr, _)| { - use dash_sdk::dpp::address_funds::PlatformAddress; - let balance = wallet - .get_platform_address_info(addr) - .map(|info| info.balance) - .unwrap_or(0); - PlatformAddress::try_from(addr.clone()) - .ok() - .map(|pa| (pa.to_bech32m_string(network), balance)) - }) - .collect(); - - egui::ScrollArea::both() - .id_salt("asset_locks_table") - .min_scrolled_height(200.0) - .show(ui, |ui| { - TableBuilder::new(ui) - .striped(false) - .resizable(true) - .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) - .column(Column::initial(200.0)) // Transaction ID - .column(Column::initial(100.0)) // Address - .column(Column::initial(100.0)) // Amount (Duffs) - .column(Column::initial(100.0)) // InstantLock status - .column(Column::initial(100.0)) // Usable status - .column(Column::initial(200.0)) // Actions - .header(30.0, |mut header| { - header.col(|ui| { - ui.label("Transaction ID"); - }); - header.col(|ui| { - ui.label("Address"); - }); - header.col(|ui| { - ui.label("Amount (Duffs)"); - }); - header.col(|ui| { - ui.label("InstantLock"); - }); - header.col(|ui| { - ui.label("Usable"); - }); - header.col(|ui| { - ui.label("Actions"); - }); - }) - .body(|mut body| { - for (index, (tx, address, amount, islock, proof)) in wallet.unused_asset_locks.iter().enumerate() { - body.row(25.0, |mut row| { - row.col(|ui| { - ui.label(tx.txid().to_string()); - }); - row.col(|ui| { - ui.label(address.to_string()); - }); - row.col(|ui| { - ui.label(format!("{}", amount)); - }); - row.col(|ui| { - let status = if islock.is_some() { "Yes" } else { "No" }; - ui.label(status); - }); - row.col(|ui| { - let status = if proof.is_some() { "Yes" } else { "No" }; - ui.label(status); - }); - row.col(|ui| { - if ui.small_button("View").on_hover_text("View full asset lock details").clicked() { - app_action = AppAction::AddScreen( - ScreenType::AssetLockDetail( - wallet.seed_hash(), - index - ).create_screen(&self.app_context) - ); - } - if proof.is_some() - && ui.small_button("Fund").on_hover_text("Fund a Platform address with this asset lock").clicked() { - open_fund_dialog_for_idx = Some((index, platform_addresses.clone())); - } - }); - }); - } - }); - }); - } - }); - } else { - ui.label("No wallet selected."); - } - - // Handle dialog opening outside the borrow - if let Some((idx, platform_addresses)) = open_fund_dialog_for_idx { - self.fund_platform_dialog.selected_asset_lock_index = Some(idx); - self.fund_platform_dialog.is_open = true; - self.fund_platform_dialog.platform_addresses = platform_addresses; - self.fund_platform_dialog.selected_platform_address = None; - self.fund_platform_dialog.status = None; - self.fund_platform_dialog.is_processing = false; - } - - // Handle recover asset locks button click - use custom action to check lock status - if recover_asset_locks_clicked { - app_action = AppAction::Custom("SearchAssetLocks".to_string()); - } - - app_action - } - fn render_no_wallets_view(&self, ui: &mut Ui) { // Optionally put everything in a framed "card"-like container Frame::group(ui.style()) @@ -1779,1318 +1177,54 @@ impl WalletsBalancesScreen { action } - fn draw_modal_overlay(ctx: &Context, id: &str) { - let screen_rect = ctx.screen_rect(); - let painter = ctx.layer_painter(egui::LayerId::new( - egui::Order::Background, - egui::Id::new(id), - )); - painter.rect_filled( - screen_rect, - 0.0, - egui::Color32::from_rgba_unmultiplied(0, 0, 0, 120), - ); - } - - fn modal_frame(ctx: &Context) -> Frame { - Frame { - inner_margin: egui::Margin::same(20), - outer_margin: egui::Margin::same(0), - corner_radius: egui::CornerRadius::same(8), - shadow: egui::epaint::Shadow { - offset: [0, 8], - blur: 16, - spread: 0, - color: egui::Color32::from_rgba_unmultiplied(0, 0, 0, 100), - }, - fill: ctx.style().visuals.window_fill, - stroke: egui::Stroke::new( - 1.0, - egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30), - ), + fn ensure_account_selection(&mut self, summaries: &[AccountSummary]) { + if summaries.is_empty() { + self.selected_account = None; + return; } - } - fn render_send_dialog(&mut self, ctx: &Context) -> AppAction { - if !self.send_dialog.is_open { - return AppAction::None; + if let Some((cat, idx)) = &self.selected_account + && summaries + .iter() + .any(|summary| &summary.category == cat && summary.index == *idx) + { + return; } - let mut action = AppAction::None; - let mut open = self.send_dialog.is_open; - egui::Window::new("Send Dash") - .collapsible(false) - .resizable(false) - .open(&mut open) - .show(ctx, |ui| { - ui.label("Recipient Address"); - ui.add(egui::TextEdit::singleline(&mut self.send_dialog.address).hint_text("y...")); - - ui.add_space(8.0); - - // Amount input using AmountInput component - let amount_input = self.send_dialog.amount_input.get_or_insert_with(|| { - AmountInput::new(Amount::new_dash(0.0)) - .with_label("Amount (DASH):") - .with_hint_text("Enter amount (e.g., 0.01)") - .with_desired_width(150.0) - }); - - let response = amount_input.show(ui); - response.inner.update(&mut self.send_dialog.amount); - - ui.checkbox( - &mut self.send_dialog.subtract_fee, - "Subtract fee from amount", - ); - - ui.label("Memo (optional)"); - ui.add(egui::TextEdit::singleline(&mut self.send_dialog.memo)); - - if let Some(error) = self.send_dialog.error.clone() { - let error_color = Color32::from_rgb(255, 100, 100); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!("Error: {}", error)).color(error_color), - ); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.send_dialog.error = None; - } - }); - }); - } - - ui.add_space(8.0); - ui.horizontal(|ui| { - if ui.button("Send").clicked() { - match self.prepare_send_action() { - Ok(app_action) => { - action = app_action; - self.send_dialog = SendDialogState::default(); - } - Err(err) => self.send_dialog.error = Some(err), - } - } - }); - }); - - self.send_dialog.is_open = open; - action - } - - fn render_receive_dialog(&mut self, ctx: &Context) -> AppAction { - if !self.receive_dialog.is_open { - return AppAction::None; + if let Some(first) = summaries.first() { + self.selected_account = Some((first.category.clone(), first.index)); } + } - let dark_mode = ctx.style().visuals.dark_mode; - - // Determine current address based on selected type - let current_address = match self.receive_dialog.address_type { - ReceiveAddressType::Core => self - .receive_dialog - .core_addresses - .get(self.receive_dialog.selected_core_index) - .map(|(addr, _)| addr.clone()), - ReceiveAddressType::Platform => self - .receive_dialog - .platform_addresses - .get(self.receive_dialog.selected_platform_index) - .map(|(addr, _)| addr.clone()), + fn lock_selected_wallet(&mut self) { + let Some(wallet_arc) = self.selected_wallet.clone() else { + return; }; - // Generate QR texture if needed - if let Some(address) = current_address.clone() { - let needs_texture = self.receive_dialog.qr_texture.is_none() - || self.receive_dialog.qr_address.as_deref() != Some(&address); - if needs_texture { - match generate_qr_code_image(&address) { - Ok(image) => { - let texture = ctx.load_texture( - format!("receive_{}", address), - image, - TextureOptions::LINEAR, - ); - self.receive_dialog.qr_texture = Some(texture); - self.receive_dialog.qr_address = Some(address); - } - Err(err) => { - self.receive_dialog.status = Some(err.to_string()); - } + let locked = { + let mut wallet = match wallet_arc.write() { + Ok(guard) => guard, + Err(err) => { + self.display_message( + &format!("Failed to lock wallet: {}", err), + MessageType::Error, + ); + return; } + }; + + if !wallet.is_open() { + return; } - } - let mut open = self.receive_dialog.is_open; + wallet.wallet_seed.close(); + true + }; - // Draw dark overlay behind the dialog (only when open) - if open { - Self::draw_modal_overlay(ctx, "receive_dialog_overlay"); + if locked { + self.app_context.handle_wallet_locked(&wallet_arc); + self.display_message("Wallet locked", MessageType::Info); } - - egui::Window::new("Receive") - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) - .open(&mut open) - .frame(Self::modal_frame(ctx)) - .show(ctx, |ui| { - ui.set_min_width(350.0); - ui.vertical_centered(|ui| { - ui.add_space(5.0); - - // Address type selector at the top - ui.horizontal(|ui| { - ui.selectable_value( - &mut self.receive_dialog.address_type, - ReceiveAddressType::Core, - RichText::new("Core").color(DashColors::text_primary(dark_mode)), - ); - ui.selectable_value( - &mut self.receive_dialog.address_type, - ReceiveAddressType::Platform, - RichText::new("Platform").color(DashColors::text_primary(dark_mode)), - ); - }); - - // Clear QR when switching types - let type_label = match self.receive_dialog.address_type { - ReceiveAddressType::Core => "Core Address", - ReceiveAddressType::Platform => "Platform Address", - }; - - ui.add_space(5.0); - ui.label( - RichText::new(type_label) - .color(DashColors::text_secondary(dark_mode)) - .size(12.0), - ); - ui.add_space(10.0); - - // Show QR code - if let Some(texture) = &self.receive_dialog.qr_texture { - ui.image(SizedTexture::new(texture.id(), egui::vec2(220.0, 220.0))); - } else if current_address.is_some() { - ui.label("Generating QR code..."); - } - - ui.add_space(10.0); - - match self.receive_dialog.address_type { - ReceiveAddressType::Core => { - // Core address selector (if multiple addresses) - if self.receive_dialog.core_addresses.len() > 1 { - ui.horizontal(|ui| { - ui.label("Address:"); - ComboBox::from_id_salt("core_addr_selector") - .selected_text( - self.receive_dialog - .core_addresses - .get(self.receive_dialog.selected_core_index) - .map(|(addr, balance)| { - let balance_dash = *balance as f64 / 1e8; - format!( - "{}... ({:.4} DASH)", - &addr[..12.min(addr.len())], - balance_dash - ) - }) - .unwrap_or_default(), - ) - .show_ui(ui, |ui| { - for (idx, (addr, balance)) in - self.receive_dialog.core_addresses.iter().enumerate() - { - let balance_dash = *balance as f64 / 1e8; - let label = format!( - "{}... ({:.4} DASH)", - &addr[..12.min(addr.len())], - balance_dash - ); - if ui - .selectable_label( - idx == self.receive_dialog.selected_core_index, - label, - ) - .clicked() - { - self.receive_dialog.selected_core_index = idx; - // Clear QR so it regenerates - self.receive_dialog.qr_texture = None; - self.receive_dialog.qr_address = None; - } - } - }); - }); - ui.add_space(5.0); - } - - // Show selected Core address - if let Some((address, balance)) = self - .receive_dialog - .core_addresses - .get(self.receive_dialog.selected_core_index) - .cloned() - { - ui.label( - RichText::new(&address) - .monospace() - .color(DashColors::text_primary(dark_mode)), - ); - - let balance_dash = balance as f64 / 1e8; - ui.label( - RichText::new(format!("Balance: {:.8} DASH", balance_dash)) - .color(DashColors::text_secondary(dark_mode)), - ); - - ui.add_space(8.0); - - let mut copy_status: Option = None; - let mut generate_new = false; - - ui.horizontal(|ui| { - if ui.button("Copy Address").clicked() { - if let Err(err) = copy_text_to_clipboard(&address) { - copy_status = Some(format!("Error: {}", err)); - } else { - copy_status = Some("Address copied!".to_string()); - } - } - - if ui.button("New Address").clicked() { - generate_new = true; - } - }); - - if let Some(status) = copy_status { - self.receive_dialog.status = Some(status); - } - - if generate_new - && let Some(wallet) = &self.selected_wallet { - match self.generate_new_core_receive_address(wallet) { - Ok((new_addr, new_balance)) => { - self.receive_dialog.core_addresses.push((new_addr, new_balance)); - self.receive_dialog.selected_core_index = - self.receive_dialog.core_addresses.len() - 1; - self.receive_dialog.qr_texture = None; - self.receive_dialog.qr_address = None; - self.receive_dialog.status = Some("New address generated!".to_string()); - } - Err(err) => { - self.receive_dialog.status = Some(err); - } - } - } - } - - ui.add_space(10.0); - ui.label( - RichText::new("Send Dash to this address to add funds to your wallet.") - .color(DashColors::text_secondary(dark_mode)) - .size(11.0) - .italics(), - ); - } - ReceiveAddressType::Platform => { - // Platform address selector (if multiple addresses) - if self.receive_dialog.platform_addresses.len() > 1 { - ui.horizontal(|ui| { - ui.label("Address:"); - ComboBox::from_id_salt("platform_addr_selector") - .selected_text( - self.receive_dialog - .platform_addresses - .get(self.receive_dialog.selected_platform_index) - .map(|(addr, balance)| { - let credits_as_dash = - *balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; - format!( - "{}... ({:.4} DASH)", - &addr[..12.min(addr.len())], - credits_as_dash - ) - }) - .unwrap_or_default(), - ) - .show_ui(ui, |ui| { - for (idx, (addr, balance)) in - self.receive_dialog.platform_addresses.iter().enumerate() - { - let credits_as_dash = - *balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; - let label = format!( - "{}... ({:.4} DASH)", - &addr[..12.min(addr.len())], - credits_as_dash - ); - if ui - .selectable_label( - idx == self.receive_dialog.selected_platform_index, - label, - ) - .clicked() - { - self.receive_dialog.selected_platform_index = idx; - // Clear QR so it regenerates - self.receive_dialog.qr_texture = None; - self.receive_dialog.qr_address = None; - } - } - }); - }); - ui.add_space(5.0); - } - - // Show selected Platform address - let selected_addr_data = self - .receive_dialog - .platform_addresses - .get(self.receive_dialog.selected_platform_index) - .cloned(); - - if let Some((address, balance)) = selected_addr_data { - ui.label( - RichText::new(&address) - .monospace() - .color(DashColors::text_primary(dark_mode)), - ); - - let credits_as_dash = balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; - ui.label( - RichText::new(format!("Balance: {:.8} DASH", credits_as_dash)) - .color(DashColors::text_secondary(dark_mode)), - ); - - ui.add_space(8.0); - - let mut copy_status: Option = None; - let mut new_addr_result: Option> = None; - - ui.horizontal(|ui| { - if ui.button("Copy Address").clicked() { - if let Err(err) = copy_text_to_clipboard(&address) { - copy_status = Some(format!("Error: {}", err)); - } else { - copy_status = Some("Address copied!".to_string()); - } - } - - // Button to add new Platform address - if let Some(wallet) = &self.selected_wallet - && ui.button("New Address").clicked() - { - new_addr_result = Some(self.generate_platform_address(wallet)); - } - }); - - // Handle copy status after the closure - if let Some(status) = copy_status { - self.receive_dialog.status = Some(status); - } - - // Handle new address generation after the closure - if let Some(result) = new_addr_result { - match result { - Ok(new_addr) => { - self.receive_dialog.platform_addresses.push((new_addr, 0)); - self.receive_dialog.selected_platform_index = - self.receive_dialog.platform_addresses.len() - 1; - self.receive_dialog.qr_texture = None; - self.receive_dialog.qr_address = None; - self.receive_dialog.status = - Some("New address generated!".to_string()); - } - Err(err) => { - self.receive_dialog.status = Some(err); - } - } - } - } - - ui.add_space(10.0); - ui.label( - RichText::new( - "Send credits from an identity or another Platform address to fund this address.", - ) - .color(DashColors::text_secondary(dark_mode)) - .size(11.0) - .italics(), - ); - } - } - - if let Some(status) = &self.receive_dialog.status { - ui.add_space(8.0); - ui.label( - RichText::new(status).color(DashColors::text_secondary(dark_mode)), - ); - } - }); - }); - - self.receive_dialog.is_open = open; - if !self.receive_dialog.is_open { - self.receive_dialog = ReceiveDialogState::default(); - } - AppAction::None - } - - /// Generate a new Platform address for the wallet. - /// Returns the address in Bech32m format (e.g., tevo1... for testnet) - fn generate_platform_address(&self, wallet: &Arc>) -> Result { - use dash_sdk::dpp::address_funds::PlatformAddress; - let mut wallet_guard = wallet.write().map_err(|e| e.to_string())?; - // Pass true to skip known addresses and generate a new one - let address = wallet_guard - .platform_receive_address(self.app_context.network, true, Some(&self.app_context)) - .map_err(|e| e.to_string())?; - // Convert to PlatformAddress and encode as Bech32m per DIP-18 - let platform_addr = - PlatformAddress::try_from(address).map_err(|e| format!("Invalid address: {}", e))?; - Ok(platform_addr.to_bech32m_string(self.app_context.network)) - } - - /// Generate a new Core receive address for the wallet - /// Returns (address_string, balance_duffs) - fn generate_new_core_receive_address( - &self, - wallet: &Arc>, - ) -> Result<(String, u64), String> { - let mut wallet_guard = wallet.write().map_err(|e| e.to_string())?; - let address = wallet_guard - .receive_address(self.app_context.network, true, Some(&self.app_context)) - .map_err(|e| e.to_string())?; - let balance = wallet_guard - .address_balances - .get(&address) - .copied() - .unwrap_or(0); - Ok((address.to_string(), balance)) - } - - /// Render the Fund Platform Address from Asset Lock dialog - fn render_fund_platform_dialog(&mut self, ctx: &Context) -> AppAction { - if !self.fund_platform_dialog.is_open { - return AppAction::None; - } - - let mut action = AppAction::None; - let mut open = self.fund_platform_dialog.is_open; - let dark_mode = ctx.style().visuals.dark_mode; - - // Draw dark overlay behind the popup - Self::draw_modal_overlay(ctx, "fund_platform_dialog_overlay"); - - egui::Window::new("Fund Platform Address from Asset Lock") - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) - .open(&mut open) - .frame(Self::modal_frame(ctx)) - .show(ctx, |ui| { - ui.set_min_width(400.0); - - ui.vertical(|ui| { - ui.label( - RichText::new("Select a Platform address to fund:") - .color(DashColors::text_primary(dark_mode)), - ); - ui.add_space(10.0); - - // Platform address selector - if self.fund_platform_dialog.platform_addresses.is_empty() { - ui.label( - RichText::new("No Platform addresses found. Generate one first.") - .color(DashColors::text_secondary(dark_mode)) - .italics(), - ); - } else { - ComboBox::from_id_salt("fund_platform_addr_selector") - .selected_text( - self.fund_platform_dialog - .selected_platform_address - .as_deref() - .map(|addr| { - let balance = self - .fund_platform_dialog - .platform_addresses - .iter() - .find(|(a, _)| a == addr) - .map(|(_, b)| *b) - .unwrap_or(0); - let credits_as_dash = - balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; - format!( - "{}... ({:.4} DASH)", - &addr[..12.min(addr.len())], - credits_as_dash - ) - }) - .unwrap_or_else(|| "Select an address".to_string()), - ) - .show_ui(ui, |ui| { - for (addr, balance) in &self.fund_platform_dialog.platform_addresses - { - let credits_as_dash = - *balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; - let label = format!( - "{}... ({:.4} DASH)", - &addr[..12.min(addr.len())], - credits_as_dash - ); - let is_selected = self - .fund_platform_dialog - .selected_platform_address - .as_deref() - == Some(addr.as_str()); - if ui.selectable_label(is_selected, label).clicked() { - self.fund_platform_dialog.selected_platform_address = - Some(addr.clone()); - } - } - }); - } - - ui.add_space(15.0); - - // Status message - if let Some(status) = &self.fund_platform_dialog.status { - let status_color = if self.fund_platform_dialog.status_is_error { - egui::Color32::from_rgb(220, 50, 50) - } else { - DashColors::text_secondary(dark_mode) - }; - ui.label(RichText::new(status).color(status_color)); - ui.add_space(10.0); - } - - // Buttons - ui.horizontal(|ui| { - let can_fund = self.fund_platform_dialog.selected_platform_address.is_some() - && self.fund_platform_dialog.selected_asset_lock_index.is_some() - && !self.fund_platform_dialog.is_processing; - - // Cancel button - let cancel_button = egui::Button::new( - RichText::new("Cancel").color(DashColors::text_primary(dark_mode)), - ) - .fill(egui::Color32::TRANSPARENT) - .stroke(egui::Stroke::new(1.0, DashColors::text_secondary(dark_mode))) - .corner_radius(egui::CornerRadius::same(4)) - .min_size(egui::Vec2::new(80.0, 32.0)); - - if ui.add(cancel_button).clicked() { - self.fund_platform_dialog.is_open = false; - } - - ui.add_space(8.0); - - // Fund button - let fund_button = egui::Button::new( - RichText::new(if self.fund_platform_dialog.is_processing { - "Funding..." - } else { - "Fund Address" - }) - .color(egui::Color32::WHITE), - ) - .fill(if can_fund { - DashColors::DASH_BLUE - } else { - DashColors::text_secondary(dark_mode) - }) - .corner_radius(egui::CornerRadius::same(4)) - .min_size(egui::Vec2::new(100.0, 32.0)); - - if ui.add_enabled(can_fund, fund_button).clicked() { - // Check if wallet is locked - let is_locked = self - .selected_wallet - .as_ref() - .and_then(|w| w.read().ok()) - .map(|w| !w.is_open()) - .unwrap_or(false); - - if is_locked { - // Wallet is locked - open unlock popup and set pending flag - self.fund_platform_dialog.pending_fund_after_unlock = true; - self.wallet_unlock_popup.open(); - } else { - action = self.prepare_fund_platform_action(); - } - } - }); - - ui.add_space(10.0); - ui.label( - RichText::new( - "The entire asset lock amount will be used to fund the Platform address.", - ) - .color(DashColors::text_secondary(dark_mode)) - .size(11.0) - .italics(), - ); - }); - }); - - // Only update from `open` if we didn't manually close via cancel button - if self.fund_platform_dialog.is_open { - self.fund_platform_dialog.is_open = open; - } - if !self.fund_platform_dialog.is_open { - self.fund_platform_dialog = FundPlatformAddressDialogState::default(); - } - action - } - - /// Render the Private Key dialog - fn render_private_key_dialog(&mut self, ctx: &Context) { - if !self.private_key_dialog.is_open { - return; - } - - let dark_mode = ctx.style().visuals.dark_mode; - let mut open = self.private_key_dialog.is_open; - - // Draw dark overlay behind the dialog - if open { - Self::draw_modal_overlay(ctx, "private_key_dialog_overlay"); - } - - egui::Window::new("Private Key") - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) - .open(&mut open) - .frame(Self::modal_frame(ctx)) - .show(ctx, |ui| { - ui.set_min_width(400.0); - ui.vertical_centered(|ui| { - ui.add_space(5.0); - - // Address label - ui.label( - RichText::new("Address") - .color(DashColors::text_secondary(dark_mode)) - .size(12.0), - ); - ui.add_space(5.0); - - // Address value - ui.label( - RichText::new(&self.private_key_dialog.address) - .monospace() - .color(DashColors::text_primary(dark_mode)), - ); - - ui.add_space(5.0); - - // Copy address button - if ui.button("Copy Address").clicked() { - let _ = copy_text_to_clipboard(&self.private_key_dialog.address); - } - - ui.add_space(15.0); - ui.separator(); - ui.add_space(15.0); - - // Private key label - ui.label( - RichText::new("Private Key (WIF)") - .color(DashColors::text_secondary(dark_mode)) - .size(12.0), - ); - ui.add_space(5.0); - - // Private key value (hidden by default) - if self.private_key_dialog.show_key { - ui.label( - RichText::new(&self.private_key_dialog.private_key_wif) - .monospace() - .color(DashColors::text_primary(dark_mode)), - ); - } else { - ui.label( - RichText::new("••••••••••••••••••••••••••••••••••••••••••••••••••••") - .monospace() - .color(DashColors::text_secondary(dark_mode)), - ); - } - - ui.add_space(10.0); - - // Show/Hide and Copy buttons - ui.horizontal(|ui| { - if ui - .button(if self.private_key_dialog.show_key { - "Hide Key" - } else { - "Show Key" - }) - .clicked() - { - self.private_key_dialog.show_key = !self.private_key_dialog.show_key; - } - - if ui.button("Copy Key").clicked() { - let _ = - copy_text_to_clipboard(&self.private_key_dialog.private_key_wif); - } - }); - - ui.add_space(15.0); - - // Warning message - ui.label( - RichText::new("Keep your private key secure. Never share it with anyone.") - .color(DashColors::error_color(dark_mode)) - .size(11.0) - .italics(), - ); - }); - }); - - self.private_key_dialog.is_open = open; - if !self.private_key_dialog.is_open { - self.private_key_dialog = PrivateKeyDialogState::default(); - } - } - - /// Prepare the backend task for funding a Platform address from asset lock - fn prepare_fund_platform_action(&mut self) -> AppAction { - use dash_sdk::dpp::address_funds::PlatformAddress; - use std::collections::BTreeMap; - - let Some(wallet_arc) = &self.selected_wallet else { - self.fund_platform_dialog.status = Some("No wallet selected".to_string()); - self.fund_platform_dialog.status_is_error = true; - return AppAction::None; - }; - - let Some(selected_addr) = &self.fund_platform_dialog.selected_platform_address else { - self.fund_platform_dialog.status = Some("Select a Platform address".to_string()); - self.fund_platform_dialog.status_is_error = true; - return AppAction::None; - }; - - let Some(asset_lock_idx) = self.fund_platform_dialog.selected_asset_lock_index else { - self.fund_platform_dialog.status = Some("No asset lock selected".to_string()); - self.fund_platform_dialog.status_is_error = true; - return AppAction::None; - }; - - // Get the asset lock proof and address from the wallet - let (seed_hash, asset_lock_proof, asset_lock_address, platform_addr) = { - let wallet = match wallet_arc.read() { - Ok(guard) => guard, - Err(e) => { - self.fund_platform_dialog.status = Some(e.to_string()); - self.fund_platform_dialog.status_is_error = true; - return AppAction::None; - } - }; - - let asset_lock = wallet.unused_asset_locks.get(asset_lock_idx); - let Some((_, addr, _, _, Some(proof))) = asset_lock else { - self.fund_platform_dialog.status = - Some("Asset lock not found or not ready".to_string()); - self.fund_platform_dialog.status_is_error = true; - return AppAction::None; - }; - - // Parse the Platform address (Bech32m format: evo1.../tevo1...) - use dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked; - let platform_addr = if selected_addr.starts_with("evo1") - || selected_addr.starts_with("tevo1") - { - match PlatformAddress::from_bech32m_string(selected_addr) { - Ok((addr, network)) => { - // Validate that address network matches app network - if network != self.app_context.network { - self.fund_platform_dialog.status = Some(format!( - "Address network mismatch: address is for {:?} but app is on {:?}", - network, self.app_context.network - )); - self.fund_platform_dialog.status_is_error = true; - return AppAction::None; - } - addr - } - Err(e) => { - self.fund_platform_dialog.status = - Some(format!("Invalid Bech32m address: {}", e)); - self.fund_platform_dialog.status_is_error = true; - return AppAction::None; - } - } - } else { - // Fall back to base58 parsing for backwards compatibility - match selected_addr - .parse::>() - .map_err(|e| e.to_string()) - .and_then(|a| { - PlatformAddress::try_from(a.assume_checked()) - .map_err(|e| format!("Invalid Platform address: {}", e)) - }) { - Ok(addr) => addr, - Err(e) => { - self.fund_platform_dialog.status = Some(e); - self.fund_platform_dialog.status_is_error = true; - return AppAction::None; - } - } - }; - - ( - wallet.seed_hash(), - Box::new(proof.clone()), - addr.clone(), - platform_addr, - ) - }; - - // Build outputs - fund the entire asset lock to the selected Platform address - let mut outputs: BTreeMap> = BTreeMap::new(); - outputs.insert(platform_addr, None); // None = take the full amount - - self.fund_platform_dialog.is_processing = true; - self.fund_platform_dialog.status = Some("Processing...".to_string()); - self.fund_platform_dialog.status_is_error = false; - - AppAction::BackendTask(BackendTask::WalletTask( - WalletTask::FundPlatformAddressFromAssetLock { - seed_hash, - asset_lock_proof, - asset_lock_address, - outputs, - }, - )) - } - - fn prepare_send_action(&mut self) -> Result { - let wallet = self - .selected_wallet - .as_ref() - .ok_or_else(|| "Select a wallet first".to_string())?; - - let amount_duffs = self - .send_dialog - .amount - .as_ref() - .ok_or_else(|| "Enter an amount".to_string())? - .dash_to_duffs()?; - - if amount_duffs == 0 { - return Err("Amount must be greater than 0".to_string()); - } - - { - let wallet_guard = wallet.read().map_err(|e| e.to_string())?; - if amount_duffs > wallet_guard.confirmed_balance_duffs() { - return Err("Insufficient confirmed balance".to_string()); - } - } - - if self.send_dialog.address.trim().is_empty() { - return Err("Enter a recipient address".to_string()); - } - - let memo = self.send_dialog.memo.trim(); - let request = WalletPaymentRequest { - recipients: vec![PaymentRecipient { - address: self.send_dialog.address.trim().to_string(), - amount_duffs, - }], - subtract_fee_from_amount: self.send_dialog.subtract_fee, - memo: if memo.is_empty() { - None - } else { - Some(memo.to_string()) - }, - override_fee: None, - }; - - Ok(AppAction::BackendTask(BackendTask::CoreTask( - CoreTask::SendWalletPayment { - wallet: wallet.clone(), - request, - }, - ))) - } - - fn open_receive_dialog(&mut self, _ctx: &Context) -> AppAction { - let Some(wallet) = self.selected_wallet.clone() else { - self.receive_dialog.status = Some("Select a wallet first".to_string()); - self.receive_dialog.core_addresses.clear(); - self.receive_dialog.platform_addresses.clear(); - self.receive_dialog.qr_texture = None; - self.receive_dialog.qr_address = None; - self.receive_dialog.is_open = true; - return AppAction::None; - }; - - self.receive_dialog.is_open = true; - self.receive_dialog.qr_texture = None; - self.receive_dialog.qr_address = None; - - // Load Core addresses (works with locked wallet - uses existing addresses) - self.load_core_addresses_for_receive(&wallet); - - // Load Platform addresses (works with locked wallet - uses existing addresses) - self.load_platform_addresses_for_receive(&wallet); - - AppAction::None - } - - /// Load Core addresses into the receive dialog - fn load_core_addresses_for_receive(&mut self, wallet: &Arc>) { - let wallet_guard = match wallet.read() { - Ok(guard) => guard, - Err(err) => { - self.receive_dialog.status = Some(err.to_string()); - return; - } - }; - - // Collect all BIP44 external (receive) addresses with their balances - let network = self.app_context.network; - let core_addresses: Vec<(String, u64)> = wallet_guard - .watched_addresses - .iter() - .filter(|(path, _)| path.is_bip44_external(network)) - .map(|(_, info)| { - let balance = wallet_guard - .address_balances - .get(&info.address) - .copied() - .unwrap_or(0); - (info.address.to_string(), balance) - }) - .collect(); - - drop(wallet_guard); - - if core_addresses.is_empty() { - // Generate a new Core address if none exists - match self.generate_new_core_receive_address(wallet) { - Ok((address, balance)) => { - self.receive_dialog.core_addresses = vec![(address, balance)]; - self.receive_dialog.selected_core_index = 0; - } - Err(err) => { - self.receive_dialog.status = Some(err); - self.receive_dialog.core_addresses.clear(); - } - } - } else { - self.receive_dialog.core_addresses = core_addresses; - self.receive_dialog.selected_core_index = 0; - } - } - - /// Load Platform addresses into the receive dialog - fn load_platform_addresses_for_receive(&mut self, wallet: &Arc>) { - let wallet_guard = match wallet.read() { - Ok(guard) => guard, - Err(err) => { - self.receive_dialog.status = Some(err.to_string()); - return; - } - }; - - // Collect Platform addresses with their balances (using DIP-18 Bech32m format) - // Use platform_addresses() which checks watched_addresses, not just platform_address_info - // This includes addresses that have been derived but may not have been synced yet - let network = self.app_context.network; - let platform_addresses: Vec<(String, u64)> = wallet_guard - .platform_addresses(network) - .into_iter() - .map(|(core_addr, platform_addr)| { - let balance = wallet_guard - .get_platform_address_info(&core_addr) - .map(|info| info.balance) - .unwrap_or(0); - (platform_addr.to_bech32m_string(network), balance) - }) - .collect(); - - drop(wallet_guard); - - if platform_addresses.is_empty() { - // Generate a new Platform address if none exists - match self.generate_platform_address(wallet) { - Ok(address) => { - self.receive_dialog.platform_addresses = vec![(address, 0)]; - self.receive_dialog.selected_platform_index = 0; - } - Err(err) => { - self.receive_dialog.status = Some(err); - self.receive_dialog.platform_addresses.clear(); - } - } - } else { - self.receive_dialog.platform_addresses = platform_addresses; - self.receive_dialog.selected_platform_index = 0; - } - } - - fn categorize_path( - path: &DerivationPath, - reference: DerivationPathReference, - ) -> (AccountCategory, Option) { - let category = AccountCategory::from_reference(reference); - let index = match category { - AccountCategory::Bip44 | AccountCategory::Bip32 => path.bip44_account_index(), - _ => None, - }; - (category, index) - } - - 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 derive_private_key_wif(&self, path: &DerivationPath) -> Result { - let wallet_arc = self - .selected_wallet - .clone() - .ok_or_else(|| "Select a wallet first".to_string())?; - let wallet = wallet_arc.read().map_err(|e| e.to_string())?; - if wallet.uses_password && !wallet.is_open() { - return Err("Unlock this wallet to view private keys.".to_string()); - } - let private_key = wallet.private_key_at_derivation_path(path, self.app_context.network)?; - Ok(private_key.to_wif()) - } - - fn lock_selected_wallet(&mut self) { - let Some(wallet_arc) = self.selected_wallet.clone() else { - return; - }; - - let locked = { - let mut wallet = match wallet_arc.write() { - Ok(guard) => guard, - Err(err) => { - self.display_message( - &format!("Failed to lock wallet: {}", err), - MessageType::Error, - ); - return; - } - }; - - if !wallet.is_open() { - return; - } - - wallet.wallet_seed.close(); - true - }; - - if locked { - self.app_context.handle_wallet_locked(&wallet_arc); - self.display_message("Wallet locked", MessageType::Info); - } - } - - /// Render the detail view for a selected single key wallet - fn render_single_key_wallet_view(&mut self, ui: &mut Ui, dark_mode: bool) -> AppAction { - let mut action = AppAction::None; - - let wallet_arc = match &self.selected_single_key_wallet { - Some(w) => w.clone(), - None => return action, - }; - - let wallet = wallet_arc.read().unwrap(); - let address = wallet.address.to_string(); - let alias = wallet - .alias - .clone() - .unwrap_or_else(|| "Unnamed Key".to_string()); - let balance_duffs = wallet.total_balance_duffs(); - let balance_dash = balance_duffs as f64 * 1e-8; - let utxo_count = wallet.utxos.len(); - let utxos: Vec<_> = wallet.utxos.iter().map(|(o, t)| (*o, t.clone())).collect(); - drop(wallet); - - let text_color = DashColors::text_primary(dark_mode); - - Frame::group(ui.style()) - .fill(DashColors::surface(dark_mode)) - .inner_margin(Margin::symmetric(16, 16)) - .show(ui, |ui| { - ui.vertical(|ui| { - ui.heading(RichText::new(&alias).strong().color(text_color)); - ui.add_space(10.0); - - // Balance info - ui.label(RichText::new(format!("Balance: {:.8} DASH", balance_dash))); - ui.add_space(10.0); - - // Action buttons for SK wallet - ui.horizontal(|ui| { - if ui - .button(RichText::new("Send").color(text_color).strong()) - .clicked() - { - action = AppAction::AddScreen( - crate::ui::ScreenType::SingleKeyWalletSendScreen( - wallet_arc.clone(), - ) - .create_screen(&self.app_context), - ); - } - - if ui - .button(RichText::new("Receive").color(text_color)) - .clicked() - { - self.receive_dialog.core_addresses = - vec![(address.clone(), balance_duffs)]; - self.receive_dialog.selected_core_index = 0; - self.receive_dialog.is_open = true; - } - }); - ui.add_space(15.0); - - // UTXOs section - ui.separator(); - ui.add_space(10.0); - ui.heading(RichText::new(format!("UTXOs ({})", utxo_count)).color(text_color)); - ui.add_space(10.0); - - if utxos.is_empty() { - ui.label("No UTXOs available. Click 'Refresh' to load UTXOs from Core."); - } else { - const UTXOS_PER_PAGE: usize = 50; - let total_pages = utxo_count.div_ceil(UTXOS_PER_PAGE); - - // Ensure current page is valid - if self.utxo_page >= total_pages { - self.utxo_page = total_pages.saturating_sub(1); - } - - let start_idx = self.utxo_page * UTXOS_PER_PAGE; - let utxos_page: Vec<_> = - utxos.iter().skip(start_idx).take(UTXOS_PER_PAGE).collect(); - - // Pagination controls - if total_pages > 1 { - ui.horizontal(|ui| { - if ui - .add_enabled(self.utxo_page > 0, egui::Button::new("<< First")) - .clicked() - { - self.utxo_page = 0; - } - if ui - .add_enabled(self.utxo_page > 0, egui::Button::new("< Prev")) - .clicked() - { - self.utxo_page = self.utxo_page.saturating_sub(1); - } - - ui.label(format!( - "Page {} of {} ({}-{} of {})", - self.utxo_page + 1, - total_pages, - start_idx + 1, - (start_idx + utxos_page.len()).min(utxo_count), - utxo_count - )); - - if ui - .add_enabled( - self.utxo_page < total_pages - 1, - egui::Button::new("Next >"), - ) - .clicked() - { - self.utxo_page += 1; - } - if ui - .add_enabled( - self.utxo_page < total_pages - 1, - egui::Button::new("Last >>"), - ) - .clicked() - { - self.utxo_page = total_pages - 1; - } - }); - ui.add_space(10.0); - } - - egui::ScrollArea::vertical() - .max_height(300.0) - .show(ui, |ui| { - for (outpoint, tx_out) in utxos_page { - Frame::group(ui.style()) - .fill(DashColors::surface(dark_mode).gamma_multiply(0.9)) - .inner_margin(Margin::symmetric(10, 8)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.vertical(|ui| { - ui.horizontal(|ui| { - ui.label("TxID:"); - ui.label( - RichText::new(format!( - "{}:{}", - outpoint.txid, outpoint.vout - )) - .monospace() - .size(11.0) - .color(text_color), - ); - }); - ui.horizontal(|ui| { - ui.label("Amount:"); - ui.label( - RichText::new(format!( - "{:.8} DASH", - tx_out.value as f64 * 1e-8 - )) - .strong() - .color(text_color), - ); - }); - }); - }); - }); - ui.add_space(5.0); - } - }); - } - }); - }); - - action } /// Creates the appropriate refresh action based on the current refresh mode diff --git a/src/ui/wallets/wallets_screen/single_key_view.rs b/src/ui/wallets/wallets_screen/single_key_view.rs new file mode 100644 index 000000000..7fc8768dc --- /dev/null +++ b/src/ui/wallets/wallets_screen/single_key_view.rs @@ -0,0 +1,186 @@ +use crate::app::AppAction; +use crate::ui::ScreenType; +use crate::ui::theme::DashColors; +use eframe::egui; +use egui::{Frame, Margin, RichText, Ui}; + +use super::WalletsBalancesScreen; + +impl WalletsBalancesScreen { + /// Render the detail view for a selected single key wallet + pub(super) fn render_single_key_wallet_view( + &mut self, + ui: &mut Ui, + dark_mode: bool, + ) -> AppAction { + let mut action = AppAction::None; + + let wallet_arc = match &self.selected_single_key_wallet { + Some(w) => w.clone(), + None => return action, + }; + + let wallet = wallet_arc.read().unwrap(); + let address = wallet.address.to_string(); + let alias = wallet + .alias + .clone() + .unwrap_or_else(|| "Unnamed Key".to_string()); + let balance_duffs = wallet.total_balance_duffs(); + let balance_dash = balance_duffs as f64 * 1e-8; + let utxo_count = wallet.utxos.len(); + let utxos: Vec<_> = wallet.utxos.iter().map(|(o, t)| (*o, t.clone())).collect(); + drop(wallet); + + let text_color = DashColors::text_primary(dark_mode); + + Frame::group(ui.style()) + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(16, 16)) + .show(ui, |ui| { + ui.vertical(|ui| { + ui.heading(RichText::new(&alias).strong().color(text_color)); + ui.add_space(10.0); + + // Balance info + ui.label(RichText::new(format!("Balance: {:.8} DASH", balance_dash))); + ui.add_space(10.0); + + // Action buttons for SK wallet + ui.horizontal(|ui| { + if ui + .button(RichText::new("Send").color(text_color).strong()) + .clicked() + { + action = AppAction::AddScreen( + ScreenType::SingleKeyWalletSendScreen(wallet_arc.clone()) + .create_screen(&self.app_context), + ); + } + + if ui + .button(RichText::new("Receive").color(text_color)) + .clicked() + { + self.receive_dialog.core_addresses = + vec![(address.clone(), balance_duffs)]; + self.receive_dialog.selected_core_index = 0; + self.receive_dialog.is_open = true; + } + }); + ui.add_space(15.0); + + // UTXOs section + ui.separator(); + ui.add_space(10.0); + ui.heading(RichText::new(format!("UTXOs ({})", utxo_count)).color(text_color)); + ui.add_space(10.0); + + if utxos.is_empty() { + ui.label("No UTXOs available. Click 'Refresh' to load UTXOs from Core."); + } else { + const UTXOS_PER_PAGE: usize = 50; + let total_pages = utxo_count.div_ceil(UTXOS_PER_PAGE); + + // Ensure current page is valid + if self.utxo_page >= total_pages { + self.utxo_page = total_pages.saturating_sub(1); + } + + let start_idx = self.utxo_page * UTXOS_PER_PAGE; + let utxos_page: Vec<_> = + utxos.iter().skip(start_idx).take(UTXOS_PER_PAGE).collect(); + + // Pagination controls + if total_pages > 1 { + ui.horizontal(|ui| { + if ui + .add_enabled(self.utxo_page > 0, egui::Button::new("<< First")) + .clicked() + { + self.utxo_page = 0; + } + if ui + .add_enabled(self.utxo_page > 0, egui::Button::new("< Prev")) + .clicked() + { + self.utxo_page = self.utxo_page.saturating_sub(1); + } + + ui.label(format!( + "Page {} of {} ({}-{} of {})", + self.utxo_page + 1, + total_pages, + start_idx + 1, + (start_idx + utxos_page.len()).min(utxo_count), + utxo_count + )); + + if ui + .add_enabled( + self.utxo_page < total_pages - 1, + egui::Button::new("Next >"), + ) + .clicked() + { + self.utxo_page += 1; + } + if ui + .add_enabled( + self.utxo_page < total_pages - 1, + egui::Button::new("Last >>"), + ) + .clicked() + { + self.utxo_page = total_pages - 1; + } + }); + ui.add_space(10.0); + } + + egui::ScrollArea::vertical() + .max_height(300.0) + .show(ui, |ui| { + for (outpoint, tx_out) in utxos_page { + Frame::group(ui.style()) + .fill(DashColors::surface(dark_mode).gamma_multiply(0.9)) + .inner_margin(Margin::symmetric(10, 8)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label("TxID:"); + ui.label( + RichText::new(format!( + "{}:{}", + outpoint.txid, outpoint.vout + )) + .monospace() + .size(11.0) + .color(text_color), + ); + }); + ui.horizontal(|ui| { + ui.label("Amount:"); + ui.label( + RichText::new(format!( + "{:.8} DASH", + tx_out.value as f64 * 1e-8 + )) + .strong() + .color(text_color), + ); + }); + }); + }); + }); + ui.add_space(5.0); + } + }); + } + }); + }); + + action + } +} From 4eb2e2ef4aed6e847f1d37176e10bbf575487cc3 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:10:41 +0100 Subject: [PATCH 16/18] chore: fix platfom versioning issues --- Cargo.lock | 90 +++++++++++++++++--------------- Cargo.toml | 2 +- src/context/connection_status.rs | 7 ++- 3 files changed, 55 insertions(+), 44 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a92c17aa5..5d78153fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1717,7 +1717,7 @@ dependencies = [ [[package]] name = "dapi-grpc" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" +source = "git+https://github.com/dashpay/platform?rev=060515987a#060515987a9d54bb3046bc8deca821f449649856" dependencies = [ "dash-platform-macros", "futures-core", @@ -1785,7 +1785,7 @@ dependencies = [ [[package]] name = "dash-context-provider" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" +source = "git+https://github.com/dashpay/platform?rev=060515987a#060515987a9d54bb3046bc8deca821f449649856" dependencies = [ "dpp", "drive", @@ -1861,8 +1861,8 @@ dependencies = [ [[package]] name = "dash-network" -version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=53d699c9b551ac7d3644e11ca46dc3819277ff87#53d699c9b551ac7d3644e11ca46dc3819277ff87" +version = "0.42.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=12ba186fd24a85fcb4736f0868b393f8a5b58c46#12ba186fd24a85fcb4736f0868b393f8a5b58c46" dependencies = [ "bincode 2.0.1", "bincode_derive", @@ -1873,7 +1873,7 @@ dependencies = [ [[package]] name = "dash-platform-macros" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" +source = "git+https://github.com/dashpay/platform?rev=060515987a#060515987a9d54bb3046bc8deca821f449649856" dependencies = [ "heck", "quote", @@ -1883,7 +1883,7 @@ dependencies = [ [[package]] name = "dash-sdk" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" +source = "git+https://github.com/dashpay/platform?rev=060515987a#060515987a9d54bb3046bc8deca821f449649856" dependencies = [ "arc-swap", "async-trait", @@ -1916,8 +1916,8 @@ dependencies = [ [[package]] name = "dash-spv" -version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=53d699c9b551ac7d3644e11ca46dc3819277ff87#53d699c9b551ac7d3644e11ca46dc3819277ff87" +version = "0.42.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=12ba186fd24a85fcb4736f0868b393f8a5b58c46#12ba186fd24a85fcb4736f0868b393f8a5b58c46" dependencies = [ "anyhow", "async-trait", @@ -1927,6 +1927,7 @@ dependencies = [ "clap", "dashcore", "dashcore_hashes", + "futures", "hex", "hickory-resolver", "indexmap 2.13.0", @@ -1939,6 +1940,7 @@ dependencies = [ "serde_json", "thiserror 1.0.69", "tokio", + "tokio-stream", "tokio-util", "tracing", "tracing-appender", @@ -1947,8 +1949,8 @@ dependencies = [ [[package]] name = "dashcore" -version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=53d699c9b551ac7d3644e11ca46dc3819277ff87#53d699c9b551ac7d3644e11ca46dc3819277ff87" +version = "0.42.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=12ba186fd24a85fcb4736f0868b393f8a5b58c46#12ba186fd24a85fcb4736f0868b393f8a5b58c46" dependencies = [ "anyhow", "base64-compat", @@ -1973,13 +1975,13 @@ dependencies = [ [[package]] name = "dashcore-private" -version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=53d699c9b551ac7d3644e11ca46dc3819277ff87#53d699c9b551ac7d3644e11ca46dc3819277ff87" +version = "0.42.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=12ba186fd24a85fcb4736f0868b393f8a5b58c46#12ba186fd24a85fcb4736f0868b393f8a5b58c46" [[package]] name = "dashcore-rpc" -version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=53d699c9b551ac7d3644e11ca46dc3819277ff87#53d699c9b551ac7d3644e11ca46dc3819277ff87" +version = "0.42.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=12ba186fd24a85fcb4736f0868b393f8a5b58c46#12ba186fd24a85fcb4736f0868b393f8a5b58c46" dependencies = [ "dashcore-rpc-json", "hex", @@ -1991,8 +1993,8 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" -version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=53d699c9b551ac7d3644e11ca46dc3819277ff87#53d699c9b551ac7d3644e11ca46dc3819277ff87" +version = "0.42.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=12ba186fd24a85fcb4736f0868b393f8a5b58c46#12ba186fd24a85fcb4736f0868b393f8a5b58c46" dependencies = [ "bincode 2.0.1", "dashcore", @@ -2006,8 +2008,8 @@ dependencies = [ [[package]] name = "dashcore_hashes" -version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=53d699c9b551ac7d3644e11ca46dc3819277ff87#53d699c9b551ac7d3644e11ca46dc3819277ff87" +version = "0.42.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=12ba186fd24a85fcb4736f0868b393f8a5b58c46#12ba186fd24a85fcb4736f0868b393f8a5b58c46" dependencies = [ "bincode 2.0.1", "dashcore-private", @@ -2032,7 +2034,7 @@ dependencies = [ [[package]] name = "dashpay-contract" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" +source = "git+https://github.com/dashpay/platform?rev=060515987a#060515987a9d54bb3046bc8deca821f449649856" dependencies = [ "platform-value", "platform-version", @@ -2043,7 +2045,7 @@ dependencies = [ [[package]] name = "data-contracts" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" +source = "git+https://github.com/dashpay/platform?rev=060515987a#060515987a9d54bb3046bc8deca821f449649856" dependencies = [ "dashpay-contract", "dpns-contract", @@ -2296,7 +2298,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "dpns-contract" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" +source = "git+https://github.com/dashpay/platform?rev=060515987a#060515987a9d54bb3046bc8deca821f449649856" dependencies = [ "platform-value", "platform-version", @@ -2307,7 +2309,7 @@ dependencies = [ [[package]] name = "dpp" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" +source = "git+https://github.com/dashpay/platform?rev=060515987a#060515987a9d54bb3046bc8deca821f449649856" dependencies = [ "anyhow", "async-trait", @@ -2355,7 +2357,7 @@ dependencies = [ [[package]] name = "drive" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" +source = "git+https://github.com/dashpay/platform?rev=060515987a#060515987a9d54bb3046bc8deca821f449649856" dependencies = [ "bincode 2.0.1", "byteorder", @@ -2380,7 +2382,7 @@ dependencies = [ [[package]] name = "drive-proof-verifier" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" +source = "git+https://github.com/dashpay/platform?rev=060515987a#060515987a9d54bb3046bc8deca821f449649856" dependencies = [ "bincode 2.0.1", "dapi-grpc", @@ -2962,7 +2964,7 @@ dependencies = [ [[package]] name = "feature-flags-contract" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" +source = "git+https://github.com/dashpay/platform?rev=060515987a#060515987a9d54bb3046bc8deca821f449649856" dependencies = [ "platform-value", "platform-version", @@ -4382,8 +4384,8 @@ dependencies = [ [[package]] name = "key-wallet" -version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=53d699c9b551ac7d3644e11ca46dc3819277ff87#53d699c9b551ac7d3644e11ca46dc3819277ff87" +version = "0.42.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=12ba186fd24a85fcb4736f0868b393f8a5b58c46#12ba186fd24a85fcb4736f0868b393f8a5b58c46" dependencies = [ "async-trait", "base58ck", @@ -4409,22 +4411,24 @@ dependencies = [ [[package]] name = "key-wallet-manager" -version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=53d699c9b551ac7d3644e11ca46dc3819277ff87#53d699c9b551ac7d3644e11ca46dc3819277ff87" +version = "0.42.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=12ba186fd24a85fcb4736f0868b393f8a5b58c46#12ba186fd24a85fcb4736f0868b393f8a5b58c46" dependencies = [ "async-trait", "bincode 2.0.1", "dashcore", "dashcore_hashes", "key-wallet", + "rayon", "secp256k1", + "tokio", "zeroize", ] [[package]] name = "keyword-search-contract" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" +source = "git+https://github.com/dashpay/platform?rev=060515987a#060515987a9d54bb3046bc8deca821f449649856" dependencies = [ "platform-value", "platform-version", @@ -4616,7 +4620,7 @@ dependencies = [ [[package]] name = "masternode-reward-shares-contract" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" +source = "git+https://github.com/dashpay/platform?rev=060515987a#060515987a9d54bb3046bc8deca821f449649856" dependencies = [ "platform-value", "platform-version", @@ -5680,7 +5684,7 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "platform-serialization" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" +source = "git+https://github.com/dashpay/platform?rev=060515987a#060515987a9d54bb3046bc8deca821f449649856" dependencies = [ "bincode 2.0.1", "platform-version", @@ -5689,7 +5693,7 @@ dependencies = [ [[package]] name = "platform-serialization-derive" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" +source = "git+https://github.com/dashpay/platform?rev=060515987a#060515987a9d54bb3046bc8deca821f449649856" dependencies = [ "proc-macro2", "quote", @@ -5700,7 +5704,7 @@ dependencies = [ [[package]] name = "platform-value" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" +source = "git+https://github.com/dashpay/platform?rev=060515987a#060515987a9d54bb3046bc8deca821f449649856" dependencies = [ "base64 0.22.1", "bincode 2.0.1", @@ -5720,10 +5724,11 @@ dependencies = [ [[package]] name = "platform-version" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" +source = "git+https://github.com/dashpay/platform?rev=060515987a#060515987a9d54bb3046bc8deca821f449649856" dependencies = [ "bincode 2.0.1", "grovedb-version", + "once_cell", "thiserror 2.0.18", "versioned-feature-core 1.0.0 (git+https://github.com/dashpay/versioned-feature-core)", ] @@ -5731,7 +5736,7 @@ dependencies = [ [[package]] name = "platform-versioning" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" +source = "git+https://github.com/dashpay/platform?rev=060515987a#060515987a9d54bb3046bc8deca821f449649856" dependencies = [ "proc-macro2", "quote", @@ -6440,7 +6445,7 @@ dependencies = [ [[package]] name = "rs-dapi-client" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" +source = "git+https://github.com/dashpay/platform?rev=060515987a#060515987a9d54bb3046bc8deca821f449649856" dependencies = [ "backon", "chrono", @@ -7593,7 +7598,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "token-history-contract" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" +source = "git+https://github.com/dashpay/platform?rev=060515987a#060515987a9d54bb3046bc8deca821f449649856" dependencies = [ "platform-value", "platform-version", @@ -7658,6 +7663,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -7751,9 +7757,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.6+spec-1.1.0" +version = "1.0.7+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +checksum = "247eaa3197818b831697600aadf81514e577e0cba5eab10f7e064e78ae154df1" dependencies = [ "winnow 0.7.14", ] @@ -8340,7 +8346,7 @@ dependencies = [ [[package]] name = "wallet-utils-contract" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" +source = "git+https://github.com/dashpay/platform?rev=060515987a#060515987a9d54bb3046bc8deca821f449649856" dependencies = [ "platform-value", "platform-version", @@ -9747,7 +9753,7 @@ dependencies = [ [[package]] name = "withdrawals-contract" version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56#c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56" +source = "git+https://github.com/dashpay/platform?rev=060515987a#060515987a9d54bb3046bc8deca821f449649856" dependencies = [ "num_enum 0.5.11", "platform-value", diff --git a/Cargo.toml b/Cargo.toml index 6e1597977..9a5279417 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ qrcode = "0.14.1" nix = { version = "0.31.1", features = ["signal"] } eframe = { version = "0.33.3", features = ["persistence"] } base64 = "0.22.1" -dash-sdk = { git = "https://github.com/dashpay/platform", rev = "c2c88e4a988ce93065fb7c22f6dc6ca1e8939b56", features = [ +dash-sdk = { git = "https://github.com/dashpay/platform", rev = "060515987a", features = [ "core_key_wallet", "core_key_wallet_manager", "core_bincode", diff --git a/src/context/connection_status.rs b/src/context/connection_status.rs index 53886fe6c..53271b4ed 100644 --- a/src/context/connection_status.rs +++ b/src/context/connection_status.rs @@ -317,7 +317,12 @@ impl ConnectionStatus { if let Ok(sdk) = app_context.sdk.read() { let address_list = sdk.address_list(); let total = address_list.len() as u16; - let available = address_list.get_live_addresses().len() as u16; + // get_live_address() returns Option<&Uri>, so count it as 1 if available, 0 if not + let available = if address_list.get_live_address().is_some() { + 1 + } else { + 0 + }; self.set_dapi_status(total, available); } From b417bce846c8b6dae98ae809458a131c8f2aee7e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:12:36 +0100 Subject: [PATCH 17/18] chore: add todo --- src/context/connection_status.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/context/connection_status.rs b/src/context/connection_status.rs index 53271b4ed..e21848f1a 100644 --- a/src/context/connection_status.rs +++ b/src/context/connection_status.rs @@ -318,6 +318,9 @@ impl ConnectionStatus { let address_list = sdk.address_list(); let total = address_list.len() as u16; // get_live_address() returns Option<&Uri>, so count it as 1 if available, 0 if not + // TODO: once Dash Platform SDK supports rust-dashcore v0.42, + // update platform and use get_live_addresses() which returns all available addresses + //for more accurate status let available = if address_list.get_live_address().is_some() { 1 } else { From 777c7151ee7d9412f3ec23eb4296ea1e9ce7a70e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:25:53 +0100 Subject: [PATCH 18/18] chore: fmt --- src/context/connection_status.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/context/connection_status.rs b/src/context/connection_status.rs index e21848f1a..df725b8a1 100644 --- a/src/context/connection_status.rs +++ b/src/context/connection_status.rs @@ -318,7 +318,7 @@ impl ConnectionStatus { let address_list = sdk.address_list(); let total = address_list.len() as u16; // get_live_address() returns Option<&Uri>, so count it as 1 if available, 0 if not - // TODO: once Dash Platform SDK supports rust-dashcore v0.42, + // TODO: once Dash Platform SDK supports rust-dashcore v0.42, // update platform and use get_live_addresses() which returns all available addresses //for more accurate status let available = if address_list.get_live_address().is_some() {