From 902863dcd5571ba31f8866c75f3cd0742082edfe Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:50:09 +0200 Subject: [PATCH 01/12] feat(error): add OperationRequiresDashCore task error variant Introduces a typed TaskError for backend operations that cannot run when the app is connected via SPV. The variant carries the static operation name so the user-facing message is specific without any string concatenation, and the `#[error(...)]` template keeps the Display/Debug separation consistent with the rest of TaskError. Used by follow-up commits that gate single-key wallet refresh on Dash Core availability and will also back future UI "disable in SPV" affordances. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/backend_task/error.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 725a2ad78..50c60b599 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -572,6 +572,12 @@ pub enum TaskError { allowed_networks: &'static str, }, + /// The requested operation requires Dash Core (RPC) and cannot run in light-wallet (SPV) mode. + #[error( + "{operation} is only available when connected to Dash Core. Switch to Dash Core in Settings and retry." + )] + OperationRequiresDashCore { operation: &'static str }, + // ────────────────────────────────────────────────────────────────────────── // Platform info errors // ────────────────────────────────────────────────────────────────────────── From 8975d5d30c9de043ceb86cfafc59a6bb5778f2f1 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:50:27 +0200 Subject: [PATCH 02/12] fix(shielded): route asset-lock broadcast through backend-mode-aware helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `shield_from_asset_lock()` bundle was reaching directly for `app_context.core_client.read()?.send_raw_transaction(...)`, which would unconditionally hit Dash Core even when the user had selected SPV mode. In SPV the `core_client` is still constructed for legacy/devtool reasons but there is no expectation that it can reach a live `dashd` — the broadcast failed with `ConnectionRefused`. Switch to the shared `AppContext::broadcast_raw_transaction()` dispatcher (used by `create_registration_asset_lock`, `send_wallet_ payment`, `broadcast_and_commit_asset_lock`, etc.). It picks the right path per `core_backend_mode()` — Core RPC or `SpvManager::broadcast_transaction` — and keeps the finality tracker leak-free by cleaning the waiting entry when the broadcast itself fails. Addresses the shielded half of the backend-E2E failures flagged for `tc_014_wallet_platform_lifecycle` and `tc_018_fund_platform_address _from_asset_lock` (`ConfirmationTimeout` in SPV mode triggered by the RPC path never actually broadcasting in SPV). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/backend_task/shielded/bundle.rs | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/backend_task/shielded/bundle.rs b/src/backend_task/shielded/bundle.rs index a0fffbe71..4d1388453 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,18 @@ 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 + { + if let Ok(mut proofs) = app_context.transactions_waiting_for_finality.lock() { + proofs.remove(&tx_id); + } + return Err(e); + } // Step 4: Remove used UTXOs from wallet { From 7e74e698deb0b9ce868bc072b90dca0cc3287e09 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:50:40 +0200 Subject: [PATCH 03/12] fix(wallet-spv): broadcast single-key payments via backend-mode helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `send_single_key_wallet_payment()` built and signed the transaction locally but then dispatched the broadcast through `core_client.send_raw_transaction()` without checking `core_backend_mode()`. In SPV mode that blew up with `ConnectionRefused` from the unused Core RPC socket instead of going out on the wire via `SpvManager::broadcast_transaction`. Replace the direct RPC call with the shared `AppContext::broadcast_raw_transaction()` dispatcher, which routes per backend mode. The rest of the function — UTXO selection and signing from the wallet's in-memory cache — is unchanged and works in both modes; the caller is now responsible for populating those UTXOs (see `refresh_single_key_wallet_info`, still Core-RPC only). Addresses the broadcast half of `core_tasks::tc_009_send_single_key_ wallet_payment`. The UTXO-refresh prerequisite is handled in a follow-up commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../core/send_single_key_wallet_payment.rs | 27 ++++++++----------- 1 file changed, 11 insertions(+), 16 deletions(-) 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()?; From 4ee1a5a83b76b9ca18bc09beadfe8508dc41659e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:50:54 +0200 Subject: [PATCH 04/12] fix(wallet-spv): surface typed error when refreshing single-key wallet in SPV `refresh_single_key_wallet_info()` relies exclusively on Core RPC (`import_address`, `list_unspent`) to discover UTXOs for a standalone WIF/private-key wallet. The SPV subsystem only tracks addresses derived from HD-wallet account paths registered with its bloom filter, so there is no SPV-side equivalent for an imported single-key wallet. Previously calling the task in SPV mode hit the unused Core RPC socket and reported `ConnectionRefused` via a raw `CoreRpcConnectionFailed` banner. Guard the task with an early-return that produces the new `TaskError::OperationRequiresDashCore` variant, so the UI shows an actionable "connect to Dash Core and retry" message instead of a connection-level error the user cannot map to the actual cause. Update `core_tasks::tc_003_refresh_single_key_wallet_info` and the `tc_009` pre-flight refresh to assert the mode-dependent outcome: typed `OperationRequiresDashCore` in SPV, normal `RefreshedWallet` in RPC. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../core/refresh_single_key_wallet_info.rs | 14 ++- tests/backend-e2e/core_tasks.rs | 97 ++++++++++++++----- 2 files changed, 86 insertions(+), 25 deletions(-) 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/tests/backend-e2e/core_tasks.rs b/tests/backend-e2e/core_tasks.rs index dab43e78b..3a1f10d50 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,22 +307,47 @@ 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( + // Single-key wallets depend on Core RPC for UTXO refresh. In SPV mode the + // refresh task returns `OperationRequiresDashCore`, at which point the + // send payment flow cannot be exercised — we verify the expected typed + // error and stop here. + let refresh_result = run_task( app_context, BackendTask::CoreTask(CoreTask::RefreshSingleKeyWalletInfo(skw_arc.clone())), ) - .await - .expect("RefreshSingleKeyWalletInfo should succeed after funding"); + .await; + + match app_context.core_backend_mode() { + dash_evo_tool::spv::CoreBackendMode::Spv => { + 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." + ); + return; + } + dash_evo_tool::spv::CoreBackendMode::Rpc => { + 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. \ - This usually means Core RPC (listunspent/getaddressbalance) is not available \ - in SPV mode. The test cannot proceed without spendable UTXOs." + Core RPC did not return any UTXOs for the funded address." ); return; } From b29f416b82b4ee9bef7a7ba815b96d929479a131 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:51:06 +0200 Subject: [PATCH 05/12] fix(wallet-spv): short-circuit asset lock recovery in SPV mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `recover_asset_locks()` rescans the Core wallet's transaction and UTXO indices for asset-lock payloads that weren't tracked locally. In SPV mode none of the RPC queries it needs (`list_unspent`, `get_raw_transaction`, `get_raw_transaction_info`) are available, so the whole pass collapsed into a `ConnectionRefused` failure. In SPV the reconciliation that this pass performs on RPC is handled automatically by the InstantLock/ChainLock event stream — whenever an asset-lock transaction arrives, `received_asset_lock_finality()` populates `unused_asset_locks` and the DB row directly, so a standalone "recover" pass has nothing to do. Return an empty `RecoveredAssetLocks { 0, 0 }` result in SPV mode instead of failing. Addresses `core_tasks::tc_006_recover_asset_locks`, which expects a `RecoveredAssetLocks` response in both backends (0 recoveries is documented as a valid outcome in the test). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/backend_task/core/recover_asset_locks.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) 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(); From 99bec75673384253c73000ca01d5c17cb008a56f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:08:24 +0200 Subject: [PATCH 06/12] fix(ui-spv): disable MasternodeListDiff dev tool when not in RPC mode The MasternodeListDiff inspector issues ~16 direct Dash Core RPC calls (see audit section C-1 and `src/backend_task/mnlist.rs:81`). Running it while the app is on its built-in SPV backend produces a storm of connection-refused errors, so dim the entry in the Tools side panel and show a tooltip explaining the constraint instead. The check is a plain `core_backend_mode() == CoreBackendMode::Rpc` comparison per the dim-the-menu design chosen for v1.0-dev; the `FeatureGate::RpcBackend` predicate lands separately with #836. Complements backend commits 8975d5d3 / 7e74e698 / 4ee1a5a8 / b29f416b which surface typed "requires Core" errors for wallet flows. --- .../tools_subscreen_chooser_panel.rs | 56 ++++++++++++++++++- 1 file changed, 54 insertions(+), 2 deletions(-) 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() + ); + } + } +} From e118d2c3cddef4635910b02e241b24c1845bc792 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:12:46 +0200 Subject: [PATCH 07/12] fix(ui-spv): grey out single-key wallet actions in SPV mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per audit section C-2: single-key wallets are not in the SPV bloom filter (no `Account` entry to feed addresses from), so `refresh` and `send` fail even though broadcast itself can now route via `broadcast_raw_transaction`. Commit 4ee1a5a8 already surfaces a typed `OperationRequiresDashCore` error — this commit adds the matching UI preventive step so users cannot reach that error from the GUI. Changes: * In the single-key wallet detail panel, grey out the Send button with a disabled-hover tooltip and surface an in-panel warning label explaining the constraint. Receive stays enabled — it only displays the cached local address. * On the single-key send screen, prepend an info banner and disable the Send action with the same tooltip. Guards against users already sitting on the send screen when they switch the backend mode mid-session. Raw `core_backend_mode() == CoreBackendMode::Rpc` comparisons per the v1.0-dev pattern; `FeatureGate::RpcBackend` lands separately with #836. Complements backend commits 8975d5d3 / 7e74e698 / 4ee1a5a8 / b29f416b. --- src/ui/wallets/single_key_send_screen.rs | 32 +++++++++++++++-- .../wallets/wallets_screen/single_key_view.rs | 36 ++++++++++++++++--- 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/src/ui/wallets/single_key_send_screen.rs b/src/ui/wallets/single_key_send_screen.rs index ebc6150de..4d2c5d6ed 100644 --- a/src/ui/wallets/single_key_send_screen.rs +++ b/src/ui/wallets/single_key_send_screen.rs @@ -6,6 +6,7 @@ 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::left_panel::add_left_panel; use crate::ui::components::password_input::PasswordInput; @@ -18,6 +19,8 @@ use eframe::egui::{self, Context, RichText, Ui}; use egui::{Color32, Frame, Margin}; use std::sync::{Arc, RwLock}; +const SINGLE_KEY_REQUIRES_CORE_MESSAGE: &str = "Single-key wallets do not yet support SPV. Open Settings, switch to Expert mode, and select Local Dash Core node to send from this wallet."; + /// A single recipient entry with address and amount #[derive(Debug, Clone)] pub struct SendRecipient { @@ -808,21 +811,26 @@ impl SingleKeyWalletSendScreen { .selected_wallet .as_ref() .is_some_and(|w| w.read().map(|g| g.is_open()).unwrap_or(false)); + let is_rpc_mode = self.app_context.core_backend_mode() == CoreBackendMode::Rpc; 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 { + .fill(if wallet_is_open && !self.sending && is_rpc_mode { 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 button_enabled = wallet_is_open && !self.sending && is_rpc_mode; + let mut response = ui.add_enabled(button_enabled, send_button); + if !is_rpc_mode { + response = response.on_disabled_hover_text(SINGLE_KEY_REQUIRES_CORE_MESSAGE); + } + if response.clicked() { match self.validate_and_send() { Ok(send_action) => { action = send_action; @@ -853,6 +861,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 +872,22 @@ impl ScreenLike for SingleKeyWalletSendScreen { egui::ScrollArea::vertical() .auto_shrink([true; 2]) .show(ui, |ui| { + if !is_rpc_mode { + Frame::group(ui.style()) + .fill(DashColors::WARNING.gamma_multiply(0.15)) + .stroke(egui::Stroke::new(1.0, DashColors::WARNING)) + .inner_margin(Margin::symmetric(12, 10)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.label( + RichText::new(SINGLE_KEY_REQUIRES_CORE_MESSAGE) + .color(DashColors::text_primary(dark_mode)) + .size(13.0), + ); + }); + ui.add_space(10.0); + } + // Heading with Advanced Options checkbox ui.horizontal(|ui| { ui.heading( diff --git a/src/ui/wallets/wallets_screen/single_key_view.rs b/src/ui/wallets/wallets_screen/single_key_view.rs index 7fc8768dc..a783c702e 100644 --- a/src/ui/wallets/wallets_screen/single_key_view.rs +++ b/src/ui/wallets/wallets_screen/single_key_view.rs @@ -1,4 +1,5 @@ use crate::app::AppAction; +use crate::spv::CoreBackendMode; use crate::ui::ScreenType; use crate::ui::theme::DashColors; use eframe::egui; @@ -6,6 +7,10 @@ use egui::{Frame, Margin, RichText, Ui}; use super::WalletsBalancesScreen; +/// Shown as a disabled-button tooltip and the in-screen info banner for any +/// single-key-wallet action that depends on Dash Core RPC. +const SINGLE_KEY_REQUIRES_CORE_TOOLTIP: &str = "Single-key wallets do not yet support SPV. Open Settings, switch to Expert mode, and select Local Dash Core node to enable this action."; + impl WalletsBalancesScreen { /// Render the detail view for a selected single key wallet pub(super) fn render_single_key_wallet_view( @@ -33,6 +38,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 +52,40 @@ 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 an + // info banner explaining that single-key wallet actions + // are unavailable. The actions themselves are greyed out + // below; this label is the "why" users otherwise wouldn't + // see from a silent disable. + if !is_rpc_mode { + ui.label( + RichText::new(SINGLE_KEY_REQUIRES_CORE_TOOLTIP) + .color(DashColors::WARNING) + .size(13.0), + ); + ui.add_space(10.0); + } + // Action buttons for SK wallet ui.horizontal(|ui| { - if ui - .button(RichText::new("Send").color(text_color).strong()) - .clicked() - { + let send_button = + egui::Button::new(RichText::new("Send").color(text_color).strong()); + 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_TOOLTIP) + }; + 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() From d14d4fb52630300ee96199a4041b100fd88755fd Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:54:14 +0200 Subject: [PATCH 08/12] fix(spv): address review comments on single-key wallet SPV gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Consolidate duplicate SINGLE_KEY_REQUIRES_CORE copy between single_key_view and single_key_send_screen. - Render the SPV-mode warning through MessageBanner instead of inline RichText / Frame styling (lklimek review feedback). - Drop the dead CoreBackendMode::Rpc arm in backend-e2e tc_009 — backend-e2e is SPV-only, so only the typed-error path is exercised. - Gate explicit button text color on enabled state so egui's disabled visuals apply correctly (Copilot review). - Log a warning and recover through the poisoned guard when the transactions_waiting_for_finality mutex is poisoned, so finality tracking entries cannot leak silently across retries. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/backend_task/shielded/bundle.rs | 37 ++++++- src/ui/wallets/single_key_send_screen.rs | 52 ++++----- src/ui/wallets/wallets_screen/mod.rs | 2 +- .../wallets/wallets_screen/single_key_view.rs | 48 +++++--- tests/backend-e2e/core_tasks.rs | 104 +++--------------- 5 files changed, 111 insertions(+), 132 deletions(-) diff --git a/src/backend_task/shielded/bundle.rs b/src/backend_task/shielded/bundle.rs index 4d1388453..356d24512 100644 --- a/src/backend_task/shielded/bundle.rs +++ b/src/backend_task/shielded/bundle.rs @@ -499,8 +499,19 @@ pub async fn shield_from_asset_lock( .broadcast_raw_transaction(&asset_lock_transaction) .await { - if let Ok(mut proofs) = app_context.transactions_waiting_for_finality.lock() { - proofs.remove(&tx_id); + 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); } @@ -542,8 +553,26 @@ 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); + match app_context.transactions_waiting_for_finality.try_lock() { + Ok(mut proofs) => { + proofs.remove(&tx_id); + } + Err(std::sync::TryLockError::WouldBlock) => { + tracing::debug!( + %tx_id, + "transactions_waiting_for_finality lock is contended on timeout; \ + finality tracking entry not cleared" + ); + } + Err(std::sync::TryLockError::Poisoned(poisoned)) => { + tracing::warn!( + %tx_id, + "transactions_waiting_for_finality lock is poisoned on timeout; \ + recovering through poisoned guard so the 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/wallets/single_key_send_screen.rs b/src/ui/wallets/single_key_send_screen.rs index 4d2c5d6ed..9c980baa8 100644 --- a/src/ui/wallets/single_key_send_screen.rs +++ b/src/ui/wallets/single_key_send_screen.rs @@ -8,19 +8,19 @@ 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_view::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}; use egui::{Color32, Frame, Margin}; use std::sync::{Arc, RwLock}; -const SINGLE_KEY_REQUIRES_CORE_MESSAGE: &str = "Single-key wallets do not yet support SPV. Open Settings, switch to Expert mode, and select Local Dash Core node to send from this wallet."; - /// A single recipient entry with address and amount #[derive(Debug, Clone)] pub struct SendRecipient { @@ -813,22 +813,28 @@ impl SingleKeyWalletSendScreen { .is_some_and(|w| w.read().map(|g| g.is_open()).unwrap_or(false)); let is_rpc_mode = self.app_context.core_backend_mode() == CoreBackendMode::Rpc; - 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 && is_rpc_mode { - DashColors::DASH_BLUE + 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 && is_rpc_mode; let mut response = ui.add_enabled(button_enabled, send_button); if !is_rpc_mode { - response = response.on_disabled_hover_text(SINGLE_KEY_REQUIRES_CORE_MESSAGE); + response = response.on_disabled_hover_text(SINGLE_KEY_REQUIRES_CORE); } if response.clicked() { match self.validate_and_send() { @@ -872,19 +878,13 @@ impl ScreenLike for SingleKeyWalletSendScreen { egui::ScrollArea::vertical() .auto_shrink([true; 2]) .show(ui, |ui| { + // Persistent warning banner for the SPV backend. Constructed + // fresh each frame on purpose (see `single_key_view.rs` for + // the rationale): it is a state notice, not a task result. if !is_rpc_mode { - Frame::group(ui.style()) - .fill(DashColors::WARNING.gamma_multiply(0.15)) - .stroke(egui::Stroke::new(1.0, DashColors::WARNING)) - .inner_margin(Margin::symmetric(12, 10)) - .corner_radius(5.0) - .show(ui, |ui| { - ui.label( - RichText::new(SINGLE_KEY_REQUIRES_CORE_MESSAGE) - .color(DashColors::text_primary(dark_mode)) - .size(13.0), - ); - }); + let mut banner = MessageBanner::new(); + banner.set_message(SINGLE_KEY_REQUIRES_CORE, MessageType::Warning); + banner.show(ui); ui.add_space(10.0); } diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 24caa78bc..22ca05b15 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -1,7 +1,7 @@ mod address_table; mod asset_locks; mod dialogs; -mod single_key_view; +pub(crate) mod single_key_view; use crate::app::{AppAction, BackendTasksExecutionMode, DesiredAppAction}; use crate::backend_task::BackendTask; diff --git a/src/ui/wallets/wallets_screen/single_key_view.rs b/src/ui/wallets/wallets_screen/single_key_view.rs index a783c702e..f19f8a1c6 100644 --- a/src/ui/wallets/wallets_screen/single_key_view.rs +++ b/src/ui/wallets/wallets_screen/single_key_view.rs @@ -1,15 +1,18 @@ use crate::app::AppAction; use crate::spv::CoreBackendMode; -use crate::ui::ScreenType; +use crate::ui::components::MessageBanner; +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 the in-screen info banner for any -/// single-key-wallet action that depends on Dash Core RPC. -const SINGLE_KEY_REQUIRES_CORE_TOOLTIP: &str = "Single-key wallets do not yet support SPV. Open Settings, switch to Expert mode, and select Local Dash Core node to enable this action."; +/// 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 = "Single-key wallets do not yet support SPV. Open Settings, switch to Expert mode, and select Local Dash Core node to use this wallet."; impl WalletsBalancesScreen { /// Render the detail view for a selected single key wallet @@ -52,29 +55,44 @@ 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 an - // info banner explaining that single-key wallet actions + // 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 label is the "why" users otherwise wouldn't + // below; this banner is the "why" users otherwise wouldn't // see from a silent disable. + // + // We construct the `MessageBanner` as a fresh local each + // frame on purpose: this is a persistent state notice + // (bound to the SPV backend mode), not a transient task + // result, so we want it visible the whole time the mode + // is active. A fresh instance every frame means the + // auto-dismiss timer never fires and the banner is shown + // consistently; rendering is cheap and egui handles the + // repainting. if !is_rpc_mode { - ui.label( - RichText::new(SINGLE_KEY_REQUIRES_CORE_TOOLTIP) - .color(DashColors::WARNING) - .size(13.0), - ); + let mut banner = MessageBanner::new(); + banner.set_message(SINGLE_KEY_REQUIRES_CORE, MessageType::Warning); + banner.show(ui); ui.add_space(10.0); } // Action buttons for SK wallet ui.horizontal(|ui| { - let send_button = - egui::Button::new(RichText::new("Send").color(text_color).strong()); + // 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_TOOLTIP) + send_response.on_disabled_hover_text(SINGLE_KEY_REQUIRES_CORE) }; if send_response.clicked() { action = AppAction::AddScreen( diff --git a/tests/backend-e2e/core_tasks.rs b/tests/backend-e2e/core_tasks.rs index 3a1f10d50..64f3aa092 100644 --- a/tests/backend-e2e/core_tasks.rs +++ b/tests/backend-e2e/core_tasks.rs @@ -310,98 +310,30 @@ async fn test_tc009_send_single_key_wallet_payment() { // Wait for the transaction to propagate, then refresh UTXOs. tokio::time::sleep(std::time::Duration::from_secs(5)).await; - // Single-key wallets depend on Core RPC for UTXO refresh. In SPV mode the - // refresh task returns `OperationRequiresDashCore`, at which point the - // send payment flow cannot be exercised — we verify the expected typed - // error and stop here. + // 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; - match app_context.core_backend_mode() { - dash_evo_tool::spv::CoreBackendMode::Spv => { - 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." - ); - return; - } - dash_evo_tool::spv::CoreBackendMode::Rpc => { - 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), - } + 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." + ); } // TC-010: ListCoreWallets — REMOVED (Core RPC-specific, not available in SPV mode) From 0c84294a64eec7e0bae046a328ab54d235e7a74c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 22 Apr 2026 18:56:48 +0200 Subject: [PATCH 09/12] test(backend-e2e): restore tc_009 send flow as commented-out future work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The removed Rpc-arm assertions were the shape of the full single-key send test. Keep them commented out next to the SPV typed-error check so the intent and the assertion shape survive the SPV-only gap — when single-key wallets gain SPV parity, un-comment the block. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/backend-e2e/core_tasks.rs | 76 +++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/tests/backend-e2e/core_tasks.rs b/tests/backend-e2e/core_tasks.rs index 64f3aa092..2b5c192ba 100644 --- a/tests/backend-e2e/core_tasks.rs +++ b/tests/backend-e2e/core_tasks.rs @@ -334,6 +334,82 @@ async fn test_tc009_send_single_key_wallet_payment() { "TC-009: single-key wallet flow is not supported in SPV mode; \ verified typed OperationRequiresDashCore error and skipping send step." ); + + // ---------------------------------------------------------------------- + // 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) From e94c5783992b8ae60299f8da4dfe8d3a0800babc Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:08:12 +0200 Subject: [PATCH 10/12] fix(ui-spv): store single-key SPV warning banner to stop per-frame log spam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persistent SPV-mode warning on the single-key wallet detail view and the single-key send screen was constructed as a fresh `MessageBanner` every frame. Each fresh `BannerState` starts with `logged: false`, so `process_banner()` emits a `tracing::warn!` once and then flips the local flag — but the local is dropped at end of frame and the next frame repeats the whole dance. Result: a banner warn per repaint (~60/sec when egui is active) for the entire time the screen is visible on SPV. Store the banner as a screen-struct field instead, populate it once per SPV-mode entry (guarded by `has_message()`), clear it on mode change, and disable auto-dismiss so the state notice persists without needing the fresh-each-frame hack. Adds a small `MessageBanner::disable_auto_dismiss()` helper since the component previously only exposed an overriding setter. Co-Authored-By: Claude Opus 4.7 --- src/ui/components/message_banner.rs | 14 +++++++++- src/ui/wallets/single_key_send_screen.rs | 27 ++++++++++++++----- src/ui/wallets/wallets_screen/mod.rs | 10 ++++++- .../wallets/wallets_screen/single_key_view.rs | 26 +++++++++--------- 4 files changed, 56 insertions(+), 21 deletions(-) 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/wallets/single_key_send_screen.rs b/src/ui/wallets/single_key_send_screen.rs index 9c980baa8..394db3ad4 100644 --- a/src/ui/wallets/single_key_send_screen.rs +++ b/src/ui/wallets/single_key_send_screen.rs @@ -14,7 +14,7 @@ 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_view::SINGLE_KEY_REQUIRES_CORE; +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}; @@ -73,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 { @@ -88,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(), } } @@ -878,14 +885,20 @@ impl ScreenLike for SingleKeyWalletSendScreen { egui::ScrollArea::vertical() .auto_shrink([true; 2]) .show(ui, |ui| { - // Persistent warning banner for the SPV backend. Constructed - // fresh each frame on purpose (see `single_key_view.rs` for - // the rationale): it is a state notice, not a task result. + // 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 { - let mut banner = MessageBanner::new(); - banner.set_message(SINGLE_KEY_REQUIRES_CORE, MessageType::Warning); - banner.show(ui); + 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 diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 22ca05b15..d01a8d81e 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -1,7 +1,9 @@ mod address_table; mod asset_locks; mod dialogs; -pub(crate) mod single_key_view; +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; @@ -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 f19f8a1c6..c2ef76b7c 100644 --- a/src/ui/wallets/wallets_screen/single_key_view.rs +++ b/src/ui/wallets/wallets_screen/single_key_view.rs @@ -1,6 +1,5 @@ use crate::app::AppAction; use crate::spv::CoreBackendMode; -use crate::ui::components::MessageBanner; use crate::ui::components::component_trait::Component; use crate::ui::theme::DashColors; use crate::ui::{MessageType, ScreenType}; @@ -61,19 +60,22 @@ impl WalletsBalancesScreen { // below; this banner is the "why" users otherwise wouldn't // see from a silent disable. // - // We construct the `MessageBanner` as a fresh local each - // frame on purpose: this is a persistent state notice - // (bound to the SPV backend mode), not a transient task - // result, so we want it visible the whole time the mode - // is active. A fresh instance every frame means the - // auto-dismiss timer never fires and the banner is shown - // consistently; rendering is cheap and egui handles the - // repainting. + // 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 { - let mut banner = MessageBanner::new(); - banner.set_message(SINGLE_KEY_REQUIRES_CORE, MessageType::Warning); - banner.show(ui); + 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 From 89f463db2052d53dd96c980368fa4df4d60cd3cf Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:22:15 +0200 Subject: [PATCH 11/12] fix(shielded): block on finality tracker cleanup after asset-lock timeout On the asset-lock timeout path, `try_lock()` could return `WouldBlock` and silently skip cleanup of `transactions_waiting_for_finality`. That leaves a stale entry behind for a tx this flow has already abandoned, so the finality listener keeps servicing it across retries/runs. Switch to blocking `lock()` with the same poisoned-guard handling the broadcast-failure branch already uses. The critical section is a single `BTreeMap::remove`, so holding the std::sync Mutex across the await boundary is bounded and matches the surrounding pattern. Addresses copilot review on PR #837. Co-Authored-By: Claude Opus 4.7 --- src/backend_task/shielded/bundle.rs | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/backend_task/shielded/bundle.rs b/src/backend_task/shielded/bundle.rs index 356d24512..864dbe6a5 100644 --- a/src/backend_task/shielded/bundle.rs +++ b/src/backend_task/shielded/bundle.rs @@ -553,22 +553,19 @@ pub async fn shield_from_asset_lock( loop { tokio::select! { _ = &mut timeout => { - match app_context.transactions_waiting_for_finality.try_lock() { + // 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(std::sync::TryLockError::WouldBlock) => { - tracing::debug!( - %tx_id, - "transactions_waiting_for_finality lock is contended on timeout; \ - finality tracking entry not cleared" - ); - } - Err(std::sync::TryLockError::Poisoned(poisoned)) => { + Err(poisoned) => { tracing::warn!( %tx_id, "transactions_waiting_for_finality lock is poisoned on timeout; \ - recovering through poisoned guard so the entry is cleared" + recovering through poisoned guard so the finality tracking entry is cleared" ); let mut proofs = poisoned.into_inner(); proofs.remove(&tx_id); From a34970b96929fe7eeac23cb5dfc318c6ecd87ebd Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:32:56 +0200 Subject: [PATCH 12/12] fix(spv): address iteration-2 review findings on PR #837 - Narrow single_key_view module scope: re-export SINGLE_KEY_REQUIRES_CORE via pub(crate) use instead of publicising the whole submodule. - Replace TaskError::OperationRequiresDashCore Display fragment with a complete sentence; keep `operation` as a Debug-only field so log output still identifies the originating callsite. - Reword the single-key SPV banner to be action-specific (sending/refresh need Core; Receive still works) per coderabbit + copilot-pull-request-reviewer feedback. --- src/backend_task/error.rs | 6 +++++- src/ui/wallets/wallets_screen/single_key_view.rs | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 50c60b599..e589ccfef 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -573,8 +573,12 @@ pub enum TaskError { }, /// 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( - "{operation} is only available when connected to Dash Core. Switch to Dash Core in Settings and retry." + "This action is only available when connected to Dash Core. Switch to Dash Core in Settings and retry." )] OperationRequiresDashCore { operation: &'static str }, diff --git a/src/ui/wallets/wallets_screen/single_key_view.rs b/src/ui/wallets/wallets_screen/single_key_view.rs index c2ef76b7c..b13c30142 100644 --- a/src/ui/wallets/wallets_screen/single_key_view.rs +++ b/src/ui/wallets/wallets_screen/single_key_view.rs @@ -11,7 +11,7 @@ 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 = "Single-key wallets do not yet support SPV. Open Settings, switch to Expert mode, and select Local Dash Core node to use this wallet."; +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