diff --git a/docs/ai-design/2026-02-24-sync-status-panel/manual-test-scenarios.md b/docs/ai-design/2026-02-24-sync-status-panel/manual-test-scenarios.md new file mode 100644 index 000000000..7dcb2b409 --- /dev/null +++ b/docs/ai-design/2026-02-24-sync-status-panel/manual-test-scenarios.md @@ -0,0 +1,423 @@ +# Manual Test Scenarios: Sync Status Panel (PR #642) + +**Feature:** Compact sync status panel on the Wallets screen showing Core and Platform sync status. +**Branch:** `zk-extract/sync-status-panel` +**Date:** 2026-02-24 + +--- + +## TS-01: Panel visibility -- no wallet selected + +### Preconditions +- Application launched and on the Wallets screen. +- Developer mode is enabled. +- No HD wallet is selected (either no wallets exist or the user has not yet clicked one). + +### Steps +1. Navigate to the Wallets screen. +2. Observe the area between the wallet selector and the wallet detail panel. + +### Expected Results +- The sync status panel is **not visible**. +- No "Core:" or "Platform:" labels appear. + +--- + +## TS-02: Panel visibility -- HD wallet selected + +### Preconditions +- At least one HD wallet is loaded in the application. +- Developer mode is enabled. + +### Steps +1. Navigate to the Wallets screen. +2. Select an HD wallet from the left panel. + +### Expected Results +- A compact panel appears between the wallet selector and the wallet detail panel. +- The panel contains two lines: + - **Line 1:** Starts with bold "Core:" label. + - **Line 2:** Starts with bold "Platform:" label. + +--- + +## TS-03: Core status -- RPC mode, connected + +### Preconditions +- Application configured in RPC mode (Dash Core wallet running and reachable). +- RPC connection is healthy. +- An HD wallet is selected. + +### Steps +1. Observe the Core line in the sync status panel. + +### Expected Results +- Core line displays: **Core: Connected** +- "Connected" text is in dark green. +- No spinner is shown. + +--- + +## TS-04: Core status -- RPC mode, disconnected + +### Preconditions +- Application configured in RPC mode. +- Dash Core wallet is stopped or unreachable. +- An HD wallet is selected. + +### Steps +1. Observe the Core line in the sync status panel. + +### Expected Results +- Core line displays: **Core: Disconnected** +- "Disconnected" text is in the error/red color. +- No spinner is shown. + +--- + +## TS-05: Core status -- SPV mode, idle/stopped + +### Preconditions +- Application configured in SPV mode. +- SPV service has not started yet or has been stopped. +- An HD wallet is selected. + +### Steps +1. Observe the Core line in the sync status panel. + +### Expected Results +- Core line displays: **Core: Disconnected** +- Text uses the secondary/muted color. + +--- + +## TS-06: Core status -- SPV mode, starting + +### Preconditions +- Application configured in SPV mode. +- SPV service is in the process of connecting (Starting state). +- An HD wallet is selected. + +### Steps +1. Watch the Core line as the SPV service initializes. + +### Expected Results +- A blue spinner appears next to the "Core:" label. +- Text reads: **Core: Connecting...** +- Text is in Dash blue color. + +--- + +## TS-07: Core status -- SPV mode, syncing (Headers phase) + +### Preconditions +- Application configured in SPV mode. +- SPV sync is actively downloading block headers (earliest sync phase). +- An HD wallet is selected. + +### Steps +1. Observe the Core line during initial header sync. + +### Expected Results +- A blue spinner is visible. +- Text reads: **Core: Syncing — Headers: C / T (NN%)** where C is the current height, T is the target height, and NN is a whole number 0-100. +- The numbers increase over time as headers are downloaded. + +--- + +## TS-08: Core status -- SPV mode, syncing (Filter Headers phase) + +### Preconditions +- Application configured in SPV mode. +- SPV sync has completed headers and is downloading filter headers. +- An HD wallet is selected. + +### Steps +1. Observe the Core line during filter header sync. + +### Expected Results +- A blue spinner is visible. +- Text reads: **Core: Syncing — Filter Headers: C / T (NN%)** +- Numbers reflect progress of filter header download. + +--- + +## TS-09: Core status -- SPV mode, syncing (Filters phase) + +### Preconditions +- Application configured in SPV mode. +- SPV sync is downloading compact block filters. +- An HD wallet is selected. + +### Steps +1. Observe the Core line during filter sync. + +### Expected Results +- A blue spinner is visible. +- Text reads: **Core: Syncing — Filters: C / T (NN%)** + +--- + +## TS-10: Core status -- SPV mode, syncing (Blocks phase) + +### Preconditions +- Application configured in SPV mode. +- SPV sync is downloading relevant blocks. +- An HD wallet is selected. + +### Steps +1. Observe the Core line during block sync. + +### Expected Results +- A blue spinner is visible. +- Text reads: **Core: Syncing — Blocks: C / T (NN%)** + +--- + +## TS-11: Core status -- SPV mode, fully synced (Running) + +### Preconditions +- Application configured in SPV mode. +- SPV sync has completed and the node is running normally. +- An HD wallet is selected. + +### Steps +1. Observe the Core line after sync completes. + +### Expected Results +- No spinner is shown. +- Text reads: **Core: Synced -- N peers** where N is the number of connected peers. +- Text is in dark green. + +--- + +## TS-12: Core status -- SPV mode, stopping + +### Preconditions +- Application configured in SPV mode. +- SPV service is shutting down. +- An HD wallet is selected. + +### Steps +1. Trigger an action that stops SPV (e.g., switching networks). +2. Observe the Core line during shutdown. + +### Expected Results +- A blue spinner is visible. +- Text reads: **Core: Disconnecting...** +- Text is in Dash blue color. + +--- + +## TS-13: Core status -- SPV mode, error + +### Preconditions +- Application configured in SPV mode. +- SPV service has encountered an error. +- An HD wallet is selected. + +### Steps +1. Observe the Core line when SPV is in error state. + +### Expected Results +- Text reads: **Core: Error** +- Text is in the error/red color. +- No spinner is shown. + +--- + +## TS-14: Platform status -- wallet never synced + +### Preconditions +- An HD wallet is selected. +- The wallet has never performed a platform sync (no sync record in database, or timestamp is 0). + +### Steps +1. Observe the Platform line in the sync status panel. + +### Expected Results +- Platform line displays: **Platform: Addresses: never synced** +- Text uses the secondary/muted color. + +--- + +## TS-15: Platform status -- wallet previously synced + +### Preconditions +- An HD wallet is selected. +- The wallet has been synced with the platform at least once (database contains a non-zero sync timestamp and block height). + +### Steps +1. Observe the Platform line in the sync status panel. + +### Expected Results +- Platform line displays: **Platform: Addresses: N synced (blk H, T ago)** + - N = number of platform addresses in the wallet. + - H = the block height at which the last sync occurred. + - T = relative time since last sync (e.g., "30s ago", "5m ago", "2h ago", "1d ago"). +- Text uses the secondary/muted color. + +--- + +## TS-16: Platform status -- active refresh in progress + +### Preconditions +- An HD wallet is selected. +- A platform address balance refresh is currently in progress (the `refreshing` flag is true). + +### Steps +1. Trigger a platform refresh (e.g., click the refresh button). +2. Observe the Platform line while the refresh is running. + +### Expected Results +- A blue spinner appears on the Platform line. +- The address text (count, block height, time ago) is displayed in Dash blue instead of the secondary color. +- Once the refresh completes, the spinner disappears and text returns to secondary color. + +--- + +## TS-17: Time-ago formatting -- seconds + +### Preconditions +- A wallet has been synced very recently (less than 60 seconds ago). + +### Steps +1. Trigger a platform sync. +2. Immediately observe the Platform line after sync completes. + +### Expected Results +- The time-ago portion reads something like "5s ago", "12s ago", etc. +- The number is between 0 and 59 (inclusive). + +--- + +## TS-18: Time-ago formatting -- minutes + +### Preconditions +- A wallet was last synced between 1 and 59 minutes ago. + +### Steps +1. Observe the Platform line. + +### Expected Results +- The time-ago portion reads something like "3m ago", "45m ago". +- Uses integer division (e.g., 90 seconds shows "1m ago", not "1.5m ago"). + +--- + +## TS-19: Time-ago formatting -- hours + +### Preconditions +- A wallet was last synced between 1 and 23 hours ago. + +### Steps +1. Observe the Platform line. + +### Expected Results +- The time-ago portion reads something like "2h ago", "18h ago". + +--- + +## TS-20: Time-ago formatting -- days + +### Preconditions +- A wallet was last synced 24 hours or more ago. + +### Steps +1. Observe the Platform line. + +### Expected Results +- The time-ago portion reads something like "1d ago", "7d ago". + +--- + +## TS-21: Wallet switching updates the panel + +### Preconditions +- Two or more HD wallets are loaded. +- Wallet A has been synced recently (has platform sync info). +- Wallet B has never been synced (no platform sync info). + +### Steps +1. Select Wallet A from the left panel. +2. Observe the sync status panel -- note the platform sync info (address count, block height, time ago). +3. Switch to Wallet B by clicking it in the left panel. +4. Observe the sync status panel. + +### Expected Results +- After step 2: Platform line shows address count, block height, and time-ago for Wallet A. +- After step 4: Platform line updates to show "Addresses: never synced" for Wallet B. +- The Core line remains unchanged (it reflects the global core connection, not per-wallet state). + +--- + +## TS-22: Panel respects light and dark mode + +### Preconditions +- An HD wallet is selected. +- The application supports theme switching. + +### Steps +1. Switch the application to dark mode. +2. Observe the sync status panel colors and contrast. +3. Switch the application to light mode. +4. Observe the sync status panel colors and contrast. + +### Expected Results +- In dark mode: panel background uses the dark surface color; "Core:" and "Platform:" labels use the dark-mode primary text color; secondary text is readable against the dark background. +- In light mode: panel background uses the light surface color; labels and secondary text are readable against the light background. +- Status colors (dark green for connected/synced, red for error/disconnected, blue for syncing) remain visually distinct in both modes. + +--- + +## TS-23: SPV sync phase progression + +### Preconditions +- Application configured in SPV mode. +- Starting from a fresh state (no previously synced data) so that all five sync phases are traversed. +- An HD wallet is selected. +- Developer mode is enabled. + +### Steps +1. Start the application and let SPV sync begin. +2. Continuously monitor the Core line throughout the entire sync process. + +### Expected Results +- The Core line progresses through these states in order: + 1. "Connecting..." + 2. "Syncing — Headers: C / T (NN%)" (numbers climb toward target) + 3. "Syncing — Masternodes: C / T (NN%)" + 4. "Syncing — Filter Headers: C / T (NN%)" + 5. "Syncing — Filters: C / T (NN%)" + 6. "Syncing — Blocks: C / T (NN%)" + 7. "Synced -- N peers" +- Each phase shows current/target heights and a percentage that progresses upward. +- A spinner is visible during all syncing phases and disappears when fully synced. + +--- + +## TS-24: SPV sync progress -- target height zero + +### Preconditions +- Application configured in SPV mode. +- SPV sync is in early startup where the target height may not yet be known (target = 0). + +### Steps +1. Observe the Core line during the very first moments of sync. + +### Expected Results +- If a phase has target_height = 0, the progress displays "0%" (not a division-by-zero crash or NaN). +- The panel remains stable and does not flicker or show garbled text. + +--- + +## Edge Cases + +| # | Scenario | Expected Behavior | +|---|----------|-------------------| +| E1 | System clock is set to the past (before the last sync timestamp) | `format_unix_time_ago` uses `saturating_sub`, so it shows "0s ago" rather than panicking or showing negative time. | +| E2 | SPV sync progress is `None` while status is `Syncing` | Phase text falls back to "starting..." rather than crashing. | +| E3 | Wallet read lock fails (poisoned RwLock) | Address count defaults to 0; platform sync info shows as "never synced". The panel does not crash. | +| E4 | Very large block heights or peer counts | Numbers render as plain integers without overflow; the panel layout adjusts to accommodate wider text. | +| E5 | Rapidly switching between wallets | The `platform_sync_info` cache is refreshed on each selection; no stale data from the previous wallet is shown. | +| E6 | Single-key wallet selected (not HD) | The sync status panel is hidden (it only renders when `selected_wallet` is `Some`, which is for HD wallets only). | diff --git a/src/backend_task/core/create_asset_lock.rs b/src/backend_task/core/create_asset_lock.rs index c0735f4f3..5023f1531 100644 --- a/src/backend_task/core/create_asset_lock.rs +++ b/src/backend_task/core/create_asset_lock.rs @@ -33,14 +33,32 @@ impl AppContext { // Insert the transaction into waiting for finality { - let mut proofs = self.transactions_waiting_for_finality.lock().unwrap(); + let mut proofs = self + .transactions_waiting_for_finality + .lock() + .map_err(|e| e.to_string())?; proofs.insert(tx_id, None); } - // Broadcast the transaction - self.broadcast_raw_transaction(&asset_lock_transaction) + // Broadcast the transaction. If broadcast fails, the UTXOs have already + // been removed from the wallet (inside the transaction builder) but were + // never actually spent on-chain. The caller should handle refreshing + // the wallet so the next UTXO reload reconciles in-memory state with + // the chain. See also: https://github.com/dashpay/dash-evo-tool/issues/657 + if let Err(e) = self + .broadcast_raw_transaction(&asset_lock_transaction) .await - .map_err(|e| format!("Failed to broadcast asset lock transaction: {}", e))?; + { + // Clean up the finality tracking entry + if let Ok(mut proofs) = self.transactions_waiting_for_finality.lock() { + proofs.remove(&tx_id); + } else { + tracing::warn!( + "Failed to clean up finality tracking for tx {tx_id}: Mutex poisoned" + ); + } + return Err(format!("Failed to broadcast asset lock transaction: {}", e)); + } Ok(BackendTaskSuccessResult::Message(format!( "Asset lock transaction broadcast successfully. TX ID: {}", @@ -77,14 +95,27 @@ impl AppContext { // Insert the transaction into waiting for finality { - let mut proofs = self.transactions_waiting_for_finality.lock().unwrap(); + let mut proofs = self + .transactions_waiting_for_finality + .lock() + .map_err(|e| e.to_string())?; proofs.insert(tx_id, None); } - // Broadcast the transaction - self.broadcast_raw_transaction(&asset_lock_transaction) + // Broadcast the transaction (see registration path above for cleanup rationale) + if let Err(e) = self + .broadcast_raw_transaction(&asset_lock_transaction) .await - .map_err(|e| format!("Failed to broadcast asset lock transaction: {}", e))?; + { + if let Ok(mut proofs) = self.transactions_waiting_for_finality.lock() { + proofs.remove(&tx_id); + } else { + tracing::warn!( + "Failed to clean up finality tracking for tx {tx_id}: Mutex poisoned" + ); + } + return Err(format!("Failed to broadcast asset lock transaction: {}", e)); + } Ok(BackendTaskSuccessResult::Message(format!( "Asset lock transaction broadcast successfully. TX ID: {}", diff --git a/src/backend_task/identity/top_up_identity.rs b/src/backend_task/identity/top_up_identity.rs index 56b3b9ac1..277ffee33 100644 --- a/src/backend_task/identity/top_up_identity.rs +++ b/src/backend_task/identity/top_up_identity.rs @@ -448,7 +448,7 @@ impl AppContext { "Top-up fee mismatch: estimated {} vs actual {} (diff: {})", estimated_fee, actual_fee, - actual_fee as i64 - estimated_fee as i64 + actual_fee as i128 - estimated_fee as i128 ); } } else { 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 e77511d0b..44396380d 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 @@ -161,7 +161,7 @@ impl AppContext { } }; - // Step 7: Get wallet, SDK, and derive a fresh change address if needed + // Step 6: 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(); @@ -191,7 +191,7 @@ impl AppContext { (wallet, sdk, change_platform_address) }; - // Step 8: Fund the destination platform address + // Step 7: Fund the destination platform address let mut outputs = std::collections::BTreeMap::new(); let fee_strategy = if fee_deduct_from_output { @@ -239,7 +239,7 @@ impl AppContext { .await .map_err(|e| format!("Failed to fund platform address: {}", e))?; - // Step 9: Refresh platform address balances + // Step 8: Refresh platform address balances self.fetch_platform_address_balances(seed_hash, PlatformSyncMode::Auto) .await?; diff --git a/src/context/connection_status.rs b/src/context/connection_status.rs index e4bd8cca3..e7e685247 100644 --- a/src/context/connection_status.rs +++ b/src/context/connection_status.rs @@ -5,13 +5,14 @@ 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::dash_spv::sync::{ProgressPercentage, SyncProgress as SpvSyncProgress, SyncState}; use dash_sdk::dpp::dashcore::{ChainLock, Network}; use std::sync::Mutex; use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU16, Ordering}; use std::time::{Duration, Instant}; -const REFRESH_CONNECTED: Duration = Duration::from_secs(10); -const REFRESH_DISCONNECTED: Duration = Duration::from_secs(2); +const REFRESH_CONNECTED: Duration = Duration::from_secs(4); +const REFRESH_DISCONNECTED: Duration = Duration::from_secs(1); /// Three-state connection indicator matching the UI's red/orange/green circle. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -169,7 +170,7 @@ impl ConnectionStatus { if total == 0 { "No endpoints configured".to_string() } else if available > 0 { - format!("Available ({available}/{total} endpoints)") + format!("Available ({available} unbanned / {total} total endpoints)") } else { format!("All {total} endpoints banned") } @@ -215,7 +216,12 @@ impl ConnectionStatus { self.overall_state.store(state as u8, Ordering::Relaxed); } - pub fn tooltip_text(&self) -> String { + /// Build the tooltip string for the connection indicator. + /// + /// In SPV mode, fetches sync progress from the [`SpvManager`] to display + /// a detailed phase summary (e.g. `"SPV: Headers: 12345 / 27000 (45%)"`) + /// instead of the bare `"SPV: Syncing"`. + pub fn tooltip_text(&self, app_context: &crate::context::AppContext) -> String { let backend_mode = self.backend_mode(); let disable_zmq = self.disable_zmq(); let spv_status = self.spv_status(); @@ -250,11 +256,21 @@ impl ConnectionStatus { format!("{header}\n{rpc_status}\n{zmq_status}\n{dapi_status}") } CoreBackendMode::Spv => { - let spv_label = format!("SPV: {:?}", spv_status); let header = match overall { - OverallConnectionState::Synced => "SPV synced", - OverallConnectionState::Syncing => "SPV syncing", - OverallConnectionState::Disconnected => "SPV disconnected", + OverallConnectionState::Synced => "Ready", + OverallConnectionState::Syncing => "Syncing", + OverallConnectionState::Disconnected => "Disconnected", + }; + let spv_label = if spv_status == SpvStatus::Running { + "SPV: Synced".to_string() + } else { + app_context + .spv_manager() + .status() + .sync_progress + .as_ref() + .map(|p| format!("SPV: {}", spv_phase_summary(p))) + .unwrap_or_else(|| format!("SPV: {:?}", spv_status)) }; format!("{header}\n{spv_label}\n{dapi_status}") } @@ -318,7 +334,7 @@ impl ConnectionStatus { } pub fn trigger_refresh(&self, app_context: &crate::context::AppContext) -> AppAction { - // throttle updates to once every 2 seconds + // throttle updates based on connection state (1s disconnected, 4s connected) let mut last_update = match self.last_update.lock() { Ok(guard) => guard, Err(poisoned) => poisoned.into_inner(), @@ -387,6 +403,69 @@ impl ConnectionStatus { } } +/// Compact text summary of the active SPV sync phase. +/// +/// Returns e.g. `"Headers: 12345 / 27000 (45%)"`, `"Masternodes: 800 / 2000 (40%)"`, +/// or `"syncing..."` if no phase is actively syncing. +/// +/// Phases are checked in pipeline execution order (early → late) so the user +/// sees progression from headers through to blocks. +pub fn spv_phase_summary(progress: &SpvSyncProgress) -> String { + // Check phases in order of execution + if let Ok(headers) = progress.headers() + && headers.state() == SyncState::Syncing + { + let (cur, tgt) = (headers.current_height(), headers.target_height()); + return format!("Headers: {} / {} ({}%)", cur, tgt, pct(cur, tgt)); + } + + if let Ok(mn) = progress.masternodes() + && mn.state() == SyncState::Syncing + { + let (cur, tgt) = (mn.current_height(), mn.target_height()); + return format!("Masternodes: {} / {} ({}%)", cur, tgt, pct(cur, tgt)); + } + + if let Ok(fh) = progress.filter_headers() + && fh.state() == SyncState::Syncing + { + let (cur, tgt) = (fh.current_height(), fh.target_height()); + return format!("Filter Headers: {} / {} ({}%)", cur, tgt, pct(cur, tgt)); + } + + if let Ok(filters) = progress.filters() + && filters.state() == SyncState::Syncing + { + let (cur, tgt) = (filters.current_height(), filters.target_height()); + return format!("Filters: {} / {} ({}%)", cur, tgt, pct(cur, tgt)); + } + + if let Ok(blocks) = progress.blocks() + && blocks.state() == SyncState::Syncing + { + // Blocks doesn't expose its own target_height; use the best available + // approximation: max of headers target and blocks last_processed. + let target = progress + .headers() + .ok() + .map(|h| h.target_height()) + .unwrap_or(0) + .max(blocks.last_processed()); + let cur = blocks.last_processed(); + return format!("Blocks: {} / {} ({}%)", cur, target, pct(cur, target)); + } + + "syncing...".to_string() +} + +fn pct(current: u32, target: u32) -> u32 { + if target == 0 { + 0 + } else { + ((current as f64 / target as f64) * 100.0).clamp(0.0, 100.0) as u32 + } +} + impl Default for ConnectionStatus { fn default() -> Self { Self::new() diff --git a/src/model/wallet/asset_lock_transaction.rs b/src/model/wallet/asset_lock_transaction.rs index 2a8f88a63..d50ff8dc8 100644 --- a/src/model/wallet/asset_lock_transaction.rs +++ b/src/model/wallet/asset_lock_transaction.rs @@ -366,22 +366,28 @@ impl Wallet { // Next, collect the sighashes for each input since that's what we need from the // cache - let sighashes: Vec<_> = tx + let sighashes: Result, String> = tx .input .iter() .enumerate() .map(|(i, input)| { let script_pubkey = utxos .get(&input.previous_output) - .expect("expected a txout") + .ok_or_else(|| { + format!( + "UTXO not found in selected set for input {}", + input.previous_output + ) + })? .0 .script_pubkey .clone(); cache .legacy_signature_hash(i, &script_pubkey, sighash_u32) - .expect("expected sighash") + .map_err(|e| format!("Failed to compute sighash for input {}: {}", i, e)) }) .collect(); + let sighashes = sighashes?; // Now we can drop the cache to end the immutable borrow #[allow(clippy::drop_non_drop)] @@ -394,9 +400,13 @@ impl Wallet { .zip(sighashes.into_iter()) .try_for_each(|(input, sighash)| { // You need to provide the actual script_pubkey of the UTXO being spent - let (_, input_address) = check_utxos - .remove(&input.previous_output) - .expect("expected a txout"); + let (_, input_address) = + check_utxos.remove(&input.previous_output).ok_or_else(|| { + format!( + "UTXO not found in selected set for input {}", + input.previous_output + ) + })?; let message = Message::from_digest(sighash.into()); let private_key = self @@ -537,16 +547,17 @@ impl Wallet { // Next, collect the sighashes for each input since that's what we need from the // cache - let sighashes: Vec<_> = tx + let sighashes: Result, String> = tx .input .iter() .enumerate() .map(|(i, _)| { cache .legacy_signature_hash(i, &previous_tx_output.script_pubkey, sighash_u32) - .expect("expected sighash") + .map_err(|e| format!("Failed to compute sighash for input {}: {}", i, e)) }) .collect(); + let sighashes = sighashes?; // Now we can drop the cache to end the immutable borrow #[allow(clippy::drop_non_drop)] diff --git a/src/ui/components/top_panel.rs b/src/ui/components/top_panel.rs index b2f46add3..e022f1a59 100644 --- a/src/ui/components/top_panel.rs +++ b/src/ui/components/top_panel.rs @@ -151,7 +151,7 @@ fn add_connection_indicator(ui: &mut Ui, app_context: &Arc) -> AppAc if overall != OverallConnectionState::Disconnected { app_context.repaint_animation(ui.ctx()); } - let tip = status.tooltip_text(); + let tip = status.tooltip_text(app_context); let resp = resp.on_hover_text(tip); if resp.clicked() diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index d5d5df75b..92e4d8fa8 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -7,9 +7,10 @@ use crate::app::{AppAction, DesiredAppAction}; use crate::backend_task::BackendTask; use crate::backend_task::core::CoreTask; use crate::context::AppContext; +use crate::context::connection_status::spv_phase_summary; use crate::model::amount::Amount; use crate::model::wallet::{Wallet, WalletSeedHash, WalletTransaction}; -use crate::spv::CoreBackendMode; +use crate::spv::{CoreBackendMode, SpvStatus}; use crate::ui::components::component_trait::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::left_panel::add_left_panel; @@ -116,6 +117,8 @@ pub struct WalletsBalancesScreen { utxo_page: usize, /// Selected refresh mode (only shown in dev mode) refresh_mode: RefreshMode, + /// Cached platform sync info: (last_sync_timestamp, last_sync_height) + platform_sync_info: Option<(u64, u64)>, } impl WalletsBalancesScreen { @@ -173,6 +176,13 @@ impl WalletsBalancesScreen { selected_wallet: Option>>, selected_single_key_wallet: Option>>, ) -> Self { + let platform_sync_info = selected_wallet + .as_ref() + .and_then(|w| w.read().ok().map(|g| g.seed_hash())) + .and_then(|hash| app_context.db.get_platform_sync_info(&hash).ok()) + .map(|(ts, checkpoint, _terminal)| (ts, checkpoint)) + .filter(|(ts, _)| *ts > 0); + Self { selected_wallet, selected_single_key_wallet, @@ -204,6 +214,7 @@ impl WalletsBalancesScreen { pending_asset_lock_search_after_unlock: false, utxo_page: 0, refresh_mode: RefreshMode::default(), + platform_sync_info, } } @@ -227,14 +238,40 @@ impl WalletsBalancesScreen { .update_selected_single_key_hash(hash.as_ref()); } - fn select_hd_wallet(&mut self, wallet: Arc>) { - self.selected_wallet = Some(wallet.clone()); + /// Refresh the cached platform sync info from the database. + fn refresh_platform_sync_info_cache(&mut self, seed_hash: &WalletSeedHash) { + self.platform_sync_info = self + .app_context + .db + .get_platform_sync_info(seed_hash) + .ok() + .map(|(ts, checkpoint, _terminal)| (ts, checkpoint)) + .filter(|(ts, _)| *ts > 0); + } + + /// Set the selected HD wallet and update all associated state (persisted + /// hash, platform sync info cache). All code paths that change + /// `selected_wallet` should go through this helper to keep the sync + /// status panel consistent. + fn set_selected_hd_wallet(&mut self, wallet: Option>>) { + let seed_hash = wallet + .as_ref() + .and_then(|w| w.read().ok().map(|g| g.seed_hash())); + self.selected_wallet = wallet; self.selected_single_key_wallet = None; self.selected_account = None; - if let Ok(hash) = wallet.read().map(|g| g.seed_hash()) { + if let Some(hash) = seed_hash { self.persist_selected_wallet_hash(Some(hash)); + self.refresh_platform_sync_info_cache(&hash); + } else { + self.persist_selected_wallet_hash(None); + self.platform_sync_info = None; } + } + + fn select_hd_wallet(&mut self, wallet: Arc>) { + self.set_selected_hd_wallet(Some(wallet)); self.persist_selected_single_key_hash(None); } @@ -242,6 +279,7 @@ impl WalletsBalancesScreen { self.selected_single_key_wallet = Some(wallet.clone()); self.selected_wallet = None; self.selected_account = None; + self.platform_sync_info = None; self.utxo_page = 0; if let Ok(hash) = wallet.read().map(|g| g.key_hash) { @@ -262,7 +300,7 @@ impl WalletsBalancesScreen { return; } // HD wallet no longer valid - self.selected_wallet = None; + self.set_selected_hd_wallet(None); } // Check if single key wallet selection is still valid @@ -280,12 +318,14 @@ impl WalletsBalancesScreen { } // No valid selection, pick a new one (HD wallet first, then single key) - if let Ok(wallets) = self.app_context.wallets.read() - && let Some(wallet) = wallets.values().next().cloned() - { - self.selected_wallet = Some(wallet); - self.selected_single_key_wallet = None; - self.selected_account = None; + let next_hd = self + .app_context + .wallets + .read() + .ok() + .and_then(|w| w.values().next().cloned()); + if let Some(wallet) = next_hd { + self.set_selected_hd_wallet(Some(wallet)); return; } @@ -295,10 +335,12 @@ impl WalletsBalancesScreen { self.selected_single_key_wallet = Some(wallet); self.selected_wallet = None; self.selected_account = None; + self.platform_sync_info = None; return; } self.selected_account = None; + self.platform_sync_info = None; } fn add_receiving_address(&mut self) { @@ -690,13 +732,7 @@ impl WalletsBalancesScreen { .ok() .and_then(|wallets| wallets.values().next().cloned()); - self.selected_wallet = next_wallet.clone(); - - // Update persisted selection in AppContext and database - let new_hash = next_wallet - .as_ref() - .and_then(|w| w.read().ok().map(|g| g.seed_hash())); - self.persist_selected_wallet_hash(new_hash); + self.set_selected_hd_wallet(next_wallet); self.show_rename_dialog = false; self.rename_input.clear(); @@ -797,6 +833,29 @@ impl WalletsBalancesScreen { Amount::dash_from_duffs(amount_duffs).to_string() } + /// Format a Unix timestamp (seconds since epoch) as a relative "time ago" string. + fn format_unix_time_ago(unix_ts: u64) -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let elapsed_secs = now.saturating_sub(unix_ts); + Self::format_duration_ago(std::time::Duration::from_secs(elapsed_secs)) + } + + fn format_duration_ago(duration: std::time::Duration) -> String { + let secs = duration.as_secs(); + if secs < 60 { + format!("{}s ago", secs) + } else if secs < 3600 { + format!("{}m ago", secs / 60) + } else if secs < 86400 { + format!("{}h ago", secs / 3600) + } else { + format!("{}d ago", secs / 86400) + } + } + fn transaction_direction_label(tx: &WalletTransaction) -> &'static str { if tx.is_incoming() { "Received" @@ -1105,6 +1164,158 @@ impl WalletsBalancesScreen { }); } + fn render_sync_status(&self, ui: &mut Ui) { + let dark_mode = ui.ctx().style().visuals.dark_mode; + + ui.collapsing( + RichText::new("Sync Status") + .size(12.0) + .color(DashColors::text_secondary(dark_mode)), + |ui| { + Frame::group(ui.style()) + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(16, 8)) + .show(ui, |ui| { + // Line 1 -- Core sync status + ui.horizontal(|ui| { + ui.label( + RichText::new("Core:") + .size(12.0) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + + match self.app_context.core_backend_mode() { + CoreBackendMode::Rpc => { + if self.app_context.connection_status().rpc_online() { + ui.colored_label( + Color32::DARK_GREEN, + RichText::new("Connected").size(12.0), + ); + } else { + ui.colored_label( + DashColors::ERROR, + RichText::new("Disconnected").size(12.0), + ); + } + } + CoreBackendMode::Spv => { + let snapshot = self.app_context.spv_manager().status(); + match snapshot.status { + SpvStatus::Idle | SpvStatus::Stopped => { + ui.label( + RichText::new("Disconnected") + .size(12.0) + .color(DashColors::text_secondary(dark_mode)), + ); + } + SpvStatus::Starting => { + ui.add( + egui::Spinner::new() + .size(12.0) + .color(DashColors::DASH_BLUE), + ); + ui.label( + RichText::new("Connecting...") + .size(12.0) + .color(DashColors::DASH_BLUE), + ); + } + SpvStatus::Syncing => { + ui.add( + egui::Spinner::new() + .size(12.0) + .color(DashColors::DASH_BLUE), + ); + let phase_text = snapshot + .sync_progress + .as_ref() + .map(spv_phase_summary) + .unwrap_or_else(|| "starting...".to_string()); + ui.label( + RichText::new(format!("Syncing — {phase_text}")) + .size(12.0) + .color(DashColors::DASH_BLUE), + ); + } + SpvStatus::Running => { + ui.colored_label( + Color32::DARK_GREEN, + RichText::new(format!( + "Synced — {} peers", + snapshot.connected_peers + )) + .size(12.0), + ); + } + SpvStatus::Stopping => { + ui.add( + egui::Spinner::new() + .size(12.0) + .color(DashColors::DASH_BLUE), + ); + ui.label( + RichText::new("Disconnecting...") + .size(12.0) + .color(DashColors::DASH_BLUE), + ); + } + SpvStatus::Error => { + ui.colored_label( + DashColors::ERROR, + RichText::new("Error").size(12.0), + ); + } + } + } + } + }); + + // Line 2 -- Platform sync status + ui.horizontal(|ui| { + ui.label( + RichText::new("Platform:") + .size(12.0) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + + // Addresses + let addr_count = self + .selected_wallet + .as_ref() + .and_then(|w| w.read().ok()) + .map(|w| w.platform_address_info.len()) + .unwrap_or(0); + if self.refreshing { + ui.add( + egui::Spinner::new().size(12.0).color(DashColors::DASH_BLUE), + ); + } + let addr_text = if let Some((last_sync_ts, sync_height)) = + self.platform_sync_info + { + let ago = Self::format_unix_time_ago(last_sync_ts); + format!( + "Addresses: {} synced (blk {}, {})", + addr_count, sync_height, ago + ) + } else { + "Addresses: never synced".to_string() + }; + ui.label(RichText::new(addr_text).size(12.0).color( + if self.refreshing { + DashColors::DASH_BLUE + } else { + DashColors::text_secondary(dark_mode) + }, + )); + }); + }); + }, + ); + } + fn render_wallet_detail_panel(&mut self, ui: &mut Ui, ctx: &Context) -> AppAction { let Some(wallet_arc) = self.selected_wallet.clone() else { self.render_no_wallets_view(ui); @@ -1459,6 +1670,12 @@ impl ScreenLike for WalletsBalancesScreen { ui.add_space(10.0); + // Sync status panel (only for HD wallets, dev mode only) + if self.selected_wallet.is_some() && self.app_context.is_developer_mode() { + self.render_sync_status(ui); + ui.add_space(6.0); + } + // Render the appropriate detail view based on selection if self.selected_wallet.is_some() { inner_action |= self.render_wallet_detail_panel(ui, ctx); @@ -1821,6 +2038,15 @@ impl ScreenLike for WalletsBalancesScreen { match backend_task_success_result { crate::ui::BackendTaskSuccessResult::RefreshedWallet { warning } => { self.refreshing = false; + // Refresh the cached platform sync info so the panel shows + // updated timestamps and block heights after a wallet sync. + let seed_hash = self + .selected_wallet + .as_ref() + .and_then(|w| w.read().ok().map(|g| g.seed_hash())); + if let Some(hash) = seed_hash { + self.refresh_platform_sync_info_cache(&hash); + } if let Some(warn_msg) = warning { self.set_message( format!("Wallet refreshed with warning: {}", warn_msg), @@ -1926,6 +2152,7 @@ impl ScreenLike for WalletsBalancesScreen { wallet.set_platform_address_info(addr, balance, nonce); } } + self.refresh_platform_sync_info_cache(&seed_hash); self.set_message( "Successfully synced Platform balances".to_string(), MessageType::Success, @@ -1969,10 +2196,14 @@ impl ScreenLike for WalletsBalancesScreen { // If no wallet of either type is selected but wallets exist, select the first HD wallet if self.selected_wallet.is_none() && self.selected_single_key_wallet.is_none() { - if let Ok(wallets) = self.app_context.wallets.read() - && let Some(wallet) = wallets.values().next().cloned() - { - self.selected_wallet = Some(wallet); + let next_hd = self + .app_context + .wallets + .read() + .ok() + .and_then(|w| w.values().next().cloned()); + if let Some(wallet) = next_hd { + self.set_selected_hd_wallet(Some(wallet)); return; } // If no HD wallets, try single key wallets