diff --git a/src/backend_task/core/recover_asset_locks.rs b/src/backend_task/core/recover_asset_locks.rs index 20ff09839..b645f9fa7 100644 --- a/src/backend_task/core/recover_asset_locks.rs +++ b/src/backend_task/core/recover_asset_locks.rs @@ -2,6 +2,7 @@ use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; use crate::context::AppContext; use crate::model::wallet::Wallet; +use crate::spv::CoreBackendMode; use dash_sdk::dashcore_rpc::RpcApi; use dash_sdk::dpp::dashcore::hashes::Hash; use dash_sdk::dpp::dashcore::transaction::special_transaction::TransactionPayload; @@ -14,10 +15,29 @@ use std::sync::{Arc, RwLock}; impl AppContext { /// Search for unused asset locks by scanning the Core wallet for asset lock transactions /// that belong to this wallet but aren't tracked in the database. + /// + /// This operation requires Dash Core because it queries the Core wallet's + /// transaction and UTXO indices via `list_unspent` / `get_raw_transaction`. + /// In SPV mode, asset lock finality events are delivered directly to the + /// wallet as InstantLocks / ChainLocks arrive, so no explicit recovery + /// pass is needed — we return a zero-count success result rather than + /// failing on a missing Core RPC connection. pub fn recover_asset_locks( &self, wallet: Arc>, ) -> Result { + if self.core_backend_mode() == CoreBackendMode::Spv { + tracing::info!( + "recover_asset_locks: SPV mode — asset locks are reconciled \ + automatically via InstantLock / ChainLock events. Nothing to \ + do here." + ); + return Ok(BackendTaskSuccessResult::RecoveredAssetLocks { + recovered_count: 0, + total_amount: 0, + }); + } + let (known_addresses, seed_hash, already_tracked_txids, core_wallet_name) = { let wallet_guard = wallet.read()?; let addresses: Vec
= wallet_guard.known_addresses.keys().cloned().collect(); diff --git a/src/backend_task/core/refresh_single_key_wallet_info.rs b/src/backend_task/core/refresh_single_key_wallet_info.rs index b42813af3..71b9fde1f 100644 --- a/src/backend_task/core/refresh_single_key_wallet_info.rs +++ b/src/backend_task/core/refresh_single_key_wallet_info.rs @@ -3,17 +3,29 @@ use crate::backend_task::error::TaskError; use crate::context::AppContext; use crate::model::wallet::single_key::SingleKeyWallet; +use crate::spv::CoreBackendMode; use dash_sdk::dashcore_rpc::RpcApi; use dash_sdk::dpp::dashcore::{OutPoint, TxOut}; use std::collections::HashMap; use std::sync::{Arc, RwLock}; impl AppContext { - /// Refresh a single key wallet by reloading UTXOs from Core RPC + /// Refresh a single key wallet by reloading UTXOs from Core RPC. + /// + /// Single-key wallets require Dash Core for UTXO discovery — the SPV + /// subsystem tracks HD-wallet-derived addresses only. In SPV mode this + /// function surfaces a typed error instead of silently hitting RPC and + /// producing a `ConnectionRefused` banner. pub fn refresh_single_key_wallet_info( &self, wallet: Arc>, ) -> Result<(), TaskError> { + if self.core_backend_mode() == CoreBackendMode::Spv { + return Err(TaskError::OperationRequiresDashCore { + operation: "Refreshing single-key wallet balances", + }); + } + let (address, key_hash, core_wallet_name) = { let wallet_guard = wallet.read()?; ( diff --git a/src/backend_task/core/send_single_key_wallet_payment.rs b/src/backend_task/core/send_single_key_wallet_payment.rs index a8279102e..490a54035 100644 --- a/src/backend_task/core/send_single_key_wallet_payment.rs +++ b/src/backend_task/core/send_single_key_wallet_payment.rs @@ -5,7 +5,6 @@ use crate::backend_task::core::WalletPaymentRequest; use crate::backend_task::error::TaskError; use crate::context::AppContext; use crate::model::wallet::single_key::SingleKeyWallet; -use dash_sdk::dashcore_rpc::RpcApi; use dash_sdk::dashcore_rpc::dashcore::{Address, OutPoint, ScriptBuf, Transaction, TxIn, TxOut}; use dash_sdk::dpp::dashcore::hashes::Hash; use dash_sdk::dpp::dashcore::sighash::SighashCache; @@ -15,20 +14,20 @@ use std::str::FromStr; use std::sync::{Arc, RwLock}; impl AppContext { - /// Send a payment from a single key wallet + /// Send a payment from a single key wallet. + /// + /// Builds and signs the transaction locally from the wallet's cached + /// UTXO set, then dispatches the broadcast to the configured Core + /// backend (RPC or SPV) via [`AppContext::broadcast_raw_transaction`]. + /// + /// Note that single-key wallets rely on Core RPC for UTXO discovery + /// (`refresh_single_key_wallet_info`). SPV mode can still broadcast + /// the resulting transaction but cannot populate UTXOs — callers + /// running in SPV mode must use an HD wallet for end-to-end flows. pub async fn send_single_key_wallet_payment( &self, wallet: Arc>, request: WalletPaymentRequest, - ) -> Result { - self.send_single_key_wallet_payment_via_rpc(wallet, request) - .await - } - - async fn send_single_key_wallet_payment_via_rpc( - &self, - wallet: Arc>, - request: WalletPaymentRequest, ) -> Result { let mut outputs: Vec = Vec::new(); let mut total_output: u64 = 0; @@ -174,11 +173,7 @@ impl AppContext { tx.input[i].script_sig = ScriptBuf::from_bytes(script_sig); } - let txid = self - .core_client - .read()? - .send_raw_transaction(&tx) - .map_err(TaskError::from)?; + let txid = self.broadcast_raw_transaction(&tx).await?; { let mut wallet_guard = wallet.write()?; diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 725a2ad78..e589ccfef 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -572,6 +572,16 @@ pub enum TaskError { allowed_networks: &'static str, }, + /// The requested operation requires Dash Core (RPC) and cannot run in light-wallet (SPV) mode. + /// + /// The `operation` field is preserved for diagnostic purposes (Debug / log inspection) + /// but is intentionally omitted from the user-facing `Display` text so the message is a + /// single complete sentence — no fragment composition, safe for i18n extraction. + #[error( + "This action is only available when connected to Dash Core. Switch to Dash Core in Settings and retry." + )] + OperationRequiresDashCore { operation: &'static str }, + // ────────────────────────────────────────────────────────────────────────── // Platform info errors // ────────────────────────────────────────────────────────────────────────── diff --git a/src/backend_task/shielded/bundle.rs b/src/backend_task/shielded/bundle.rs index a0fffbe71..864dbe6a5 100644 --- a/src/backend_task/shielded/bundle.rs +++ b/src/backend_task/shielded/bundle.rs @@ -429,7 +429,6 @@ pub async fn shield_from_asset_lock( amount_duffs: u64, source_address: Option<&Address>, ) -> Result { - use dash_sdk::dashcore_rpc::RpcApi; use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; use dash_sdk::dpp::prelude::AssetLockProof; use dash_sdk::dpp::shielded::builder::build_shield_from_asset_lock_transition; @@ -493,14 +492,29 @@ pub async fn shield_from_asset_lock( proofs.insert(tx_id, None); } - // Step 3: Broadcast the transaction - app_context - .core_client - .read() - .map_err(|_| TaskError::LockPoisoned { - resource: "core_client", - })? - .send_raw_transaction(&asset_lock_transaction)?; + // Step 3: Broadcast the transaction (routes through SPV or RPC per + // `core_backend_mode()`). On failure, drop the finality tracking entry + // we just inserted so it does not leak across retries. + if let Err(e) = app_context + .broadcast_raw_transaction(&asset_lock_transaction) + .await + { + match app_context.transactions_waiting_for_finality.lock() { + Ok(mut proofs) => { + proofs.remove(&tx_id); + } + Err(poisoned) => { + tracing::warn!( + %tx_id, + "transactions_waiting_for_finality lock is poisoned after broadcast failure; \ + recovering through poisoned guard so the finality tracking entry is cleared" + ); + let mut proofs = poisoned.into_inner(); + proofs.remove(&tx_id); + } + } + return Err(e); + } // Step 4: Remove used UTXOs from wallet { @@ -539,8 +553,23 @@ pub async fn shield_from_asset_lock( loop { tokio::select! { _ = &mut timeout => { - if let Ok(mut proofs) = app_context.transactions_waiting_for_finality.try_lock() { - proofs.remove(&tx_id); + // Block briefly to guarantee cleanup; the critical section is a + // small BTreeMap remove. Mirrors the broadcast-failure branch + // above so a timeout cannot leak a finality tracking entry + // that the finality listener would otherwise keep servicing. + match app_context.transactions_waiting_for_finality.lock() { + Ok(mut proofs) => { + proofs.remove(&tx_id); + } + Err(poisoned) => { + tracing::warn!( + %tx_id, + "transactions_waiting_for_finality lock is poisoned on timeout; \ + recovering through poisoned guard so the finality tracking entry is cleared" + ); + let mut proofs = poisoned.into_inner(); + proofs.remove(&tx_id); + } } if app_context.core_backend_mode() == crate::spv::CoreBackendMode::Rpc diff --git a/src/ui/components/message_banner.rs b/src/ui/components/message_banner.rs index cac83b74c..908f1aebe 100644 --- a/src/ui/components/message_banner.rs +++ b/src/ui/components/message_banner.rs @@ -287,8 +287,20 @@ impl MessageBanner { self } + /// Disable auto-dismiss for the current message so the banner stays + /// visible until cleared via [`clear`](Self::clear) or replaced via + /// [`set_message`](Self::set_message). Intended for persistent in-screen + /// state notices that are bound to a long-lived app mode (e.g. "Single + /// key wallet unavailable while on SPV backend"). No-op if no message is + /// currently set. + pub fn disable_auto_dismiss(&mut self) -> &mut Self { + if let Some(state) = &mut self.state { + state.auto_dismiss_after = None; + } + self + } + /// Clears the current message immediately. - #[allow(dead_code)] pub fn clear(&mut self) { self.state = None; } diff --git a/src/ui/components/tools_subscreen_chooser_panel.rs b/src/ui/components/tools_subscreen_chooser_panel.rs index ca2b9b130..3ff9a6156 100644 --- a/src/ui/components/tools_subscreen_chooser_panel.rs +++ b/src/ui/components/tools_subscreen_chooser_panel.rs @@ -1,4 +1,5 @@ use crate::context::AppContext; +use crate::spv::CoreBackendMode; use crate::ui::RootScreenType; use crate::ui::theme::{DashColors, Shadow, Shape, Spacing, Typography}; use crate::{app::AppAction, ui}; @@ -18,6 +19,15 @@ pub enum ToolsSubscreen { DPNS, } +impl ToolsSubscreen { + /// Returns `true` when the tool only works with an RPC connection to a + /// local Dash Core node and must be disabled while the app is running on + /// its built-in SPV backend. + fn requires_core_rpc(&self) -> bool { + matches!(self, Self::MasternodeListDiff) + } +} + impl ToolsSubscreen { pub fn display_name(&self) -> &'static str { match self { @@ -38,6 +48,7 @@ impl ToolsSubscreen { pub fn add_tools_subscreen_chooser_panel(ctx: &Context, app_context: &AppContext) -> AppAction { let mut action = AppAction::None; let dark_mode = ctx.style().visuals.dark_mode; + let is_rpc_mode = app_context.core_backend_mode() == CoreBackendMode::Rpc; let subscreens = vec![ ToolsSubscreen::PlatformInfo, @@ -105,6 +116,8 @@ pub fn add_tools_subscreen_chooser_panel(ctx: &Context, app_context: &AppContext for subscreen in subscreens { let is_active = active_screen == subscreen; + let requires_core_rpc = subscreen.requires_core_rpc(); + let is_enabled = is_rpc_mode || !requires_core_rpc; let button = if is_active { egui::Button::new( @@ -128,8 +141,16 @@ pub fn add_tools_subscreen_chooser_panel(ctx: &Context, app_context: &AppContext .min_size(egui::Vec2::new(150.0, 28.0)) }; - // Show the subscreen name as a clickable option - if ui.add(button).clicked() { + // Show the subscreen name as a clickable option. Disable + // tools that require a Core RPC connection while running + // on the built-in SPV backend. + let mut response = ui.add_enabled(is_enabled, button); + if !is_enabled { + response = response.on_disabled_hover_text( + "This tool requires a local Dash Core node. Open Settings, switch to Expert mode, and select Local Dash Core node to enable it.", + ); + } + if response.clicked() { // Handle navigation based on which subscreen is selected match subscreen { ToolsSubscreen::PlatformInfo => { @@ -189,3 +210,34 @@ pub fn add_tools_subscreen_chooser_panel(ctx: &Context, app_context: &AppContext action } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn masternode_list_diff_requires_core_rpc() { + assert!(ToolsSubscreen::MasternodeListDiff.requires_core_rpc()); + } + + #[test] + fn spv_safe_tools_do_not_require_core_rpc() { + for tool in [ + ToolsSubscreen::PlatformInfo, + ToolsSubscreen::AddressBalance, + ToolsSubscreen::ProofLog, + ToolsSubscreen::TransactionViewer, + ToolsSubscreen::DocumentViewer, + ToolsSubscreen::ProofViewer, + ToolsSubscreen::ContractViewer, + ToolsSubscreen::GroveSTARK, + ToolsSubscreen::DPNS, + ] { + assert!( + !tool.requires_core_rpc(), + "Tool {} should be available in SPV mode", + tool.display_name() + ); + } + } +} diff --git a/src/ui/wallets/single_key_send_screen.rs b/src/ui/wallets/single_key_send_screen.rs index ebc6150de..394db3ad4 100644 --- a/src/ui/wallets/single_key_send_screen.rs +++ b/src/ui/wallets/single_key_send_screen.rs @@ -6,12 +6,15 @@ use crate::backend_task::core::{CoreTask, PaymentRecipient, WalletPaymentRequest use crate::context::AppContext; use crate::model::amount::{Amount, DASH_DECIMAL_PLACES}; use crate::model::wallet::single_key::SingleKeyWallet; +use crate::spv::CoreBackendMode; use crate::ui::components::MessageBanner; +use crate::ui::components::component_trait::Component; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::password_input::PasswordInput; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::theme::{ComponentStyles, DashColors}; +use crate::ui::wallets::wallets_screen::SINGLE_KEY_REQUIRES_CORE; use crate::ui::{MessageType, RootScreenType, ScreenLike}; use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::fee::FeeRate; use eframe::egui::{self, Context, RichText, Ui}; @@ -70,6 +73,12 @@ pub struct SingleKeyWalletSendScreen { // Advanced options toggle show_advanced_options: bool, + + /// Persistent warning banner rendered when the app is running on the SPV + /// backend. Stored on the screen (rather than constructed fresh each + /// frame) so the underlying tracing log fires once on mode entry instead + /// of every repaint. + spv_warning_banner: MessageBanner, } impl SingleKeyWalletSendScreen { @@ -85,6 +94,7 @@ impl SingleKeyWalletSendScreen { password_input: PasswordInput::new().with_hint_text("Enter password"), fee_dialog: FeeConfirmationDialog::default(), show_advanced_options: false, + spv_warning_banner: MessageBanner::new(), } } @@ -808,21 +818,32 @@ impl SingleKeyWalletSendScreen { .selected_wallet .as_ref() .is_some_and(|w| w.read().map(|g| g.is_open()).unwrap_or(false)); - - let send_button = egui::Button::new( - RichText::new(if self.sending { "Sending..." } else { "Send" }) - .color(Color32::WHITE) - .strong(), - ) - .fill(if wallet_is_open && !self.sending { - DashColors::DASH_BLUE + let is_rpc_mode = self.app_context.core_backend_mode() == CoreBackendMode::Rpc; + + let button_enabled = wallet_is_open && !self.sending && is_rpc_mode; + // Only force white label text when the button is actually clickable; + // otherwise let egui's default disabled visuals take over so the + // greyed-out state is visually unambiguous. + let send_label = + RichText::new(if self.sending { "Sending..." } else { "Send" }).strong(); + let send_label = if button_enabled { + send_label.color(Color32::WHITE) } else { - DashColors::DASH_BLUE.gamma_multiply(0.5) - }) - .min_size(egui::vec2(120.0, 36.0)); + send_label + }; + let send_button = egui::Button::new(send_label) + .fill(if button_enabled { + DashColors::DASH_BLUE + } else { + DashColors::DASH_BLUE.gamma_multiply(0.5) + }) + .min_size(egui::vec2(120.0, 36.0)); - let button_enabled = wallet_is_open && !self.sending; - if ui.add_enabled(button_enabled, send_button).clicked() { + let mut response = ui.add_enabled(button_enabled, send_button); + if !is_rpc_mode { + response = response.on_disabled_hover_text(SINGLE_KEY_REQUIRES_CORE); + } + if response.clicked() { match self.validate_and_send() { Ok(send_action) => { action = send_action; @@ -853,6 +874,8 @@ impl ScreenLike for SingleKeyWalletSendScreen { RootScreenType::RootScreenWalletsBalances, ); + let is_rpc_mode = self.app_context.core_backend_mode() == CoreBackendMode::Rpc; + action |= island_central_panel(ctx, |ui| { let mut inner_action = AppAction::None; let dark_mode = ui.ctx().style().visuals.dark_mode; @@ -862,6 +885,22 @@ impl ScreenLike for SingleKeyWalletSendScreen { egui::ScrollArea::vertical() .auto_shrink([true; 2]) .show(ui, |ui| { + // Persistent warning banner for the SPV backend. Stored on + // the screen so the underlying tracing log fires once on + // mode entry instead of every repaint — see the matching + // note in `single_key_view.rs`. + if !is_rpc_mode { + if !self.spv_warning_banner.has_message() { + self.spv_warning_banner + .set_message(SINGLE_KEY_REQUIRES_CORE, MessageType::Warning) + .disable_auto_dismiss(); + } + self.spv_warning_banner.show(ui); + ui.add_space(10.0); + } else if self.spv_warning_banner.has_message() { + self.spv_warning_banner.clear(); + } + // Heading with Advanced Options checkbox ui.horizontal(|ui| { ui.heading( diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 24caa78bc..d01a8d81e 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -3,6 +3,8 @@ mod asset_locks; mod dialogs; mod single_key_view; +pub(crate) use single_key_view::SINGLE_KEY_REQUIRES_CORE; + use crate::app::{AppAction, BackendTasksExecutionMode, DesiredAppAction}; use crate::backend_task::BackendTask; use crate::backend_task::core::CoreTask; @@ -163,6 +165,11 @@ pub struct WalletsBalancesScreen { /// Transaction count at the time `cached_tx_indices` was last built. /// Used to detect list growth that doesn't make existing indices OOB. cached_tx_source_len: Option, + /// Persistent warning banner rendered on the single-key wallet detail + /// view when the app is running on the SPV backend. Stored on the screen + /// (rather than constructed fresh each frame) so the underlying tracing + /// log fires once on mode entry instead of every repaint. + pub(crate) sk_spv_warning_banner: crate::ui::components::MessageBanner, } impl WalletsBalancesScreen { @@ -273,6 +280,7 @@ impl WalletsBalancesScreen { pending_list_is_single_key: false, cached_tx_indices: None, cached_tx_source_len: None, + sk_spv_warning_banner: crate::ui::components::MessageBanner::new(), } } diff --git a/src/ui/wallets/wallets_screen/single_key_view.rs b/src/ui/wallets/wallets_screen/single_key_view.rs index 7fc8768dc..b13c30142 100644 --- a/src/ui/wallets/wallets_screen/single_key_view.rs +++ b/src/ui/wallets/wallets_screen/single_key_view.rs @@ -1,11 +1,18 @@ use crate::app::AppAction; -use crate::ui::ScreenType; +use crate::spv::CoreBackendMode; +use crate::ui::components::component_trait::Component; use crate::ui::theme::DashColors; +use crate::ui::{MessageType, ScreenType}; use eframe::egui; use egui::{Frame, Margin, RichText, Ui}; use super::WalletsBalancesScreen; +/// Shown as a disabled-button tooltip and in the in-screen warning banner for +/// any single-key-wallet action that depends on Dash Core RPC. Exported so the +/// dedicated send screen can reuse the same copy. +pub(crate) const SINGLE_KEY_REQUIRES_CORE: &str = "Sending from a single-key wallet requires a local Dash Core node. You can still receive funds at this address. To send, open Settings, switch to Expert mode, and select Local Dash Core node."; + impl WalletsBalancesScreen { /// Render the detail view for a selected single key wallet pub(super) fn render_single_key_wallet_view( @@ -33,6 +40,7 @@ impl WalletsBalancesScreen { drop(wallet); let text_color = DashColors::text_primary(dark_mode); + let is_rpc_mode = self.app_context.core_backend_mode() == CoreBackendMode::Rpc; Frame::group(ui.style()) .fill(DashColors::surface(dark_mode)) @@ -46,18 +54,58 @@ impl WalletsBalancesScreen { ui.label(RichText::new(format!("Balance: {:.8} DASH", balance_dash))); ui.add_space(10.0); + // When the app is on its built-in SPV backend, surface a + // warning banner explaining that single-key wallet actions + // are unavailable. The actions themselves are greyed out + // below; this banner is the "why" users otherwise wouldn't + // see from a silent disable. + // + // The banner lives on the screen struct so its state is + // constructed once and then re-rendered each frame. Setting + // the message via the struct field (instead of a fresh + // local) means `BannerState::logged` is preserved, so the + // underlying tracing log fires once on mode entry — not 60 + // times a second while the screen is visible. + if !is_rpc_mode { + if !self.sk_spv_warning_banner.has_message() { + self.sk_spv_warning_banner + .set_message(SINGLE_KEY_REQUIRES_CORE, MessageType::Warning) + .disable_auto_dismiss(); + } + self.sk_spv_warning_banner.show(ui); + ui.add_space(10.0); + } else if self.sk_spv_warning_banner.has_message() { + self.sk_spv_warning_banner.clear(); + } + // Action buttons for SK wallet ui.horizontal(|ui| { - if ui - .button(RichText::new("Send").color(text_color).strong()) - .clicked() - { + // Only force the primary text color when the button is + // enabled; otherwise let egui apply its default disabled + // visuals so the button actually looks greyed out. + let send_label = RichText::new("Send").strong(); + let send_label = if is_rpc_mode { + send_label.color(text_color) + } else { + send_label + }; + let send_button = egui::Button::new(send_label); + let send_response = ui.add_enabled(is_rpc_mode, send_button); + let send_response = if is_rpc_mode { + send_response + } else { + send_response.on_disabled_hover_text(SINGLE_KEY_REQUIRES_CORE) + }; + if send_response.clicked() { action = AppAction::AddScreen( ScreenType::SingleKeyWalletSendScreen(wallet_arc.clone()) .create_screen(&self.app_context), ); } + // Receive only displays the local address — it does + // not touch Core or SPV, so it stays enabled in both + // modes. if ui .button(RichText::new("Receive").color(text_color)) .clicked() diff --git a/tests/backend-e2e/core_tasks.rs b/tests/backend-e2e/core_tasks.rs index dab43e78b..2b5c192ba 100644 --- a/tests/backend-e2e/core_tasks.rs +++ b/tests/backend-e2e/core_tasks.rs @@ -67,10 +67,11 @@ async fn test_tc002_refresh_wallet_info_core_and_platform() { } // TC-003: RefreshSingleKeyWalletInfo -// TODO: Fails in SPV mode — RefreshSingleKeyWalletInfo uses Core RPC -// (listunspent, getaddressbalance) which are not available when running with -// SPV backend. Needs either an SPV-compatible implementation or should be -// skipped in SPV-only test runs. +// +// Single-key wallets require Dash Core (RPC) for UTXO discovery — SPV tracks +// HD wallet-derived addresses only. The backend now returns a typed +// `OperationRequiresDashCore` error in SPV mode; the test asserts that +// mode-specific outcome rather than an unconditional success. #[ignore] #[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] async fn test_tc003_refresh_single_key_wallet_info() { @@ -95,17 +96,34 @@ async fn test_tc003_refresh_single_key_wallet_info() { let skw_arc = Arc::new(RwLock::new(skw)); let task = BackendTask::CoreTask(CoreTask::RefreshSingleKeyWalletInfo(skw_arc.clone())); - let result = run_task(app_context, task) - .await - .expect("RefreshSingleKeyWalletInfo should succeed"); + let result = run_task(app_context, task).await; - match result { - BackendTaskSuccessResult::RefreshedWallet { .. } => {} - other => panic!("Expected RefreshedWallet, got: {:?}", other), + // Single-key wallets require Dash Core for UTXO discovery. In SPV mode + // the backend returns a typed `OperationRequiresDashCore` error; in RPC + // mode the refresh should succeed. + match app_context.core_backend_mode() { + dash_evo_tool::spv::CoreBackendMode::Spv => { + let err = result + .expect_err("RefreshSingleKeyWalletInfo must fail in SPV mode with a typed error"); + assert!( + matches!( + err, + dash_evo_tool::backend_task::error::TaskError::OperationRequiresDashCore { .. } + ), + "Expected OperationRequiresDashCore in SPV mode, got: {:?}", + err + ); + } + dash_evo_tool::spv::CoreBackendMode::Rpc => { + let result = result.expect("RefreshSingleKeyWalletInfo should succeed in RPC mode"); + match result { + BackendTaskSuccessResult::RefreshedWallet { .. } => {} + other => panic!("Expected RefreshedWallet, got: {:?}", other), + } + // Balance may be 0 for a fresh key — just verify the read succeeds + let _balance = skw_arc.read().expect("skw lock").total_balance_duffs(); + } } - - // Balance may be 0 for a fresh key — just verify the read succeeds - let _balance = skw_arc.read().expect("skw lock").total_balance_duffs(); } // TC-004: CreateRegistrationAssetLock @@ -187,8 +205,11 @@ async fn test_tc005_create_top_up_asset_lock() { } // TC-006: RecoverAssetLocks -// TODO: Fails in SPV mode — RecoverAssetLocks relies on Core RPC to scan -// for asset lock transactions, which is not available in SPV backend. +// +// In SPV mode the backend returns an empty `RecoveredAssetLocks { 0, 0 }` +// result because asset lock finality is delivered via InstantLock / ChainLock +// events — no explicit recovery pass is needed. In RPC mode the Core wallet +// is scanned for untracked asset lock transactions. #[ignore] #[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] async fn test_tc006_recover_asset_locks() { @@ -228,9 +249,12 @@ async fn test_tc006_recover_asset_locks() { // TC-008: GetBestChainLocks — REMOVED (Core RPC-specific, not available in SPV mode) // TC-009: SendSingleKeyWalletPayment -// TODO: Fails in SPV mode — single-key wallets use Core RPC for UTXO queries -// and transaction broadcasting. SPV mode only supports HD wallets registered -// via the bloom filter. +// +// Broadcast now routes through `AppContext::broadcast_raw_transaction`, so a +// single-key send can reach the network in both RPC and SPV modes. UTXO +// discovery still requires Dash Core; in SPV mode the test verifies that +// `RefreshSingleKeyWalletInfo` returns `OperationRequiresDashCore` and stops +// before attempting the send (no spendable UTXOs available). #[ignore] #[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] async fn test_tc009_send_single_key_wallet_payment() { @@ -283,76 +307,109 @@ async fn test_tc009_send_single_key_wallet_payment() { .await .expect("Funding single-key wallet should succeed"); - // Wait for the transaction to propagate, then refresh UTXOs + // Wait for the transaction to propagate, then refresh UTXOs. tokio::time::sleep(std::time::Duration::from_secs(5)).await; - run_task( + // Backend E2E runs against SPV only (see tests/backend-e2e/README.md), and + // single-key wallets depend on Core RPC for UTXO refresh. The refresh task + // therefore returns `OperationRequiresDashCore` — we verify the typed error + // and stop; the send step is unreachable without refreshed UTXOs. + let refresh_result = run_task( app_context, BackendTask::CoreTask(CoreTask::RefreshSingleKeyWalletInfo(skw_arc.clone())), ) - .await - .expect("RefreshSingleKeyWalletInfo should succeed after funding"); - - let balance = skw_arc.read().expect("skw lock").total_balance_duffs(); - if balance == 0 { - tracing::warn!( - "TC-009: SKIPPED — single-key wallet has no balance after funding + refresh. \ - This usually means Core RPC (listunspent/getaddressbalance) is not available \ - in SPV mode. The test cannot proceed without spendable UTXOs." - ); - return; - } + .await; - // Derive a recipient address from the framework wallet - let recipient_address = { - let wallets = app_context.wallets().read().expect("wallets lock"); - let fw = wallets - .get(&ctx.framework_wallet_hash) - .expect("framework wallet") - .clone(); - let mut fw_guard = fw.write().expect("fw lock"); - fw_guard - .receive_address( - dash_sdk::dpp::dashcore::Network::Testnet, - false, - Some(app_context), - ) - .expect("receive address") - .to_string() - }; - - let result = run_task( - app_context, - BackendTask::CoreTask(CoreTask::SendSingleKeyWalletPayment { - wallet: skw_arc.clone(), - request: WalletPaymentRequest { - recipients: vec![PaymentRecipient { - address: recipient_address, - amount_duffs: 1_000, - }], - subtract_fee_from_amount: true, - memo: Some("TC-009 send back".to_string()), - override_fee: None, - }, - }), - ) - .await - .expect("SendSingleKeyWalletPayment should succeed"); + let err = refresh_result + .expect_err("RefreshSingleKeyWalletInfo must fail in SPV mode with a typed error"); + assert!( + matches!( + err, + dash_evo_tool::backend_task::error::TaskError::OperationRequiresDashCore { .. } + ), + "Expected OperationRequiresDashCore in SPV mode, got: {:?}", + err + ); + tracing::info!( + "TC-009: single-key wallet flow is not supported in SPV mode; \ + verified typed OperationRequiresDashCore error and skipping send step." + ); - match result { - BackendTaskSuccessResult::WalletPayment { - txid, total_amount, .. - } => { - assert_eq!(txid.len(), 64, "txid should be 64 hex chars"); - assert!(total_amount > 0, "total_amount should be > 0"); - tracing::info!( - "TC-009: single-key payment txid={}, amount={}", - txid, - total_amount - ); - } - other => panic!("Expected WalletPayment, got: {:?}", other), - } + // ---------------------------------------------------------------------- + // Planned: full single-key wallet send flow, unreachable today under SPV. + // + // Once single-key wallets gain SPV parity (UTXO refresh + broadcast + // without Core RPC) the block below should be un-commented so the test + // exercises the real happy path: refresh UTXOs, derive a recipient from + // the framework wallet, send a small payment back, and assert the + // resulting `WalletPayment` txid + amount. + // + // Left in place (commented) so the intent and the shape of the future + // assertions survive the SPV-only gap — saves re-deriving this when SPV + // parity lands. + // ---------------------------------------------------------------------- + // + // refresh_result.expect("RefreshSingleKeyWalletInfo should succeed after funding"); + // + // let balance = skw_arc.read().expect("skw lock").total_balance_duffs(); + // if balance == 0 { + // tracing::warn!( + // "TC-009: SKIPPED — single-key wallet has no balance after funding + refresh. \ + // Core RPC did not return any UTXOs for the funded address." + // ); + // return; + // } + // + // // Derive a recipient address from the framework wallet + // let recipient_address = { + // let wallets = app_context.wallets().read().expect("wallets lock"); + // let fw = wallets + // .get(&ctx.framework_wallet_hash) + // .expect("framework wallet") + // .clone(); + // let mut fw_guard = fw.write().expect("fw lock"); + // fw_guard + // .receive_address( + // dash_sdk::dpp::dashcore::Network::Testnet, + // false, + // Some(app_context), + // ) + // .expect("receive address") + // .to_string() + // }; + // + // let result = run_task( + // app_context, + // BackendTask::CoreTask(CoreTask::SendSingleKeyWalletPayment { + // wallet: skw_arc.clone(), + // request: WalletPaymentRequest { + // recipients: vec![PaymentRecipient { + // address: recipient_address, + // amount_duffs: 1_000, + // }], + // subtract_fee_from_amount: true, + // memo: Some("TC-009 send back".to_string()), + // override_fee: None, + // }, + // }), + // ) + // .await + // .expect("SendSingleKeyWalletPayment should succeed"); + // + // match result { + // BackendTaskSuccessResult::WalletPayment { + // txid, total_amount, .. + // } => { + // assert_eq!(txid.len(), 64, "txid should be 64 hex chars"); + // assert!(total_amount > 0, "total_amount should be > 0"); + // tracing::info!( + // "TC-009: single-key payment txid={}, amount={}", + // txid, + // total_amount + // ); + // } + // other => panic!("Expected WalletPayment, got: {:?}", other), + // } } // TC-010: ListCoreWallets — REMOVED (Core RPC-specific, not available in SPV mode)