Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/backend_task/core/recover_asset_locks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<RwLock<Wallet>>,
) -> Result<BackendTaskSuccessResult, TaskError> {
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<Address> = wallet_guard.known_addresses.keys().cloned().collect();
Expand Down
14 changes: 13 additions & 1 deletion src/backend_task/core/refresh_single_key_wallet_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RwLock<SingleKeyWallet>>,
) -> 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()?;
(
Expand Down
27 changes: 11 additions & 16 deletions src/backend_task/core/send_single_key_wallet_payment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<RwLock<SingleKeyWallet>>,
request: WalletPaymentRequest,
) -> Result<BackendTaskSuccessResult, TaskError> {
self.send_single_key_wallet_payment_via_rpc(wallet, request)
.await
}

async fn send_single_key_wallet_payment_via_rpc(
&self,
wallet: Arc<RwLock<SingleKeyWallet>>,
request: WalletPaymentRequest,
) -> Result<BackendTaskSuccessResult, TaskError> {
let mut outputs: Vec<TxOut> = Vec::new();
let mut total_output: u64 = 0;
Expand Down Expand Up @@ -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()?;
Expand Down
10 changes: 10 additions & 0 deletions src/backend_task/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// ──────────────────────────────────────────────────────────────────────────
// Platform info errors
// ──────────────────────────────────────────────────────────────────────────
Expand Down
51 changes: 40 additions & 11 deletions src/backend_task/shielded/bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,6 @@ pub async fn shield_from_asset_lock(
amount_duffs: u64,
source_address: Option<&Address>,
) -> Result<u64, TaskError> {
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;
Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion src/ui/components/message_banner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
56 changes: 54 additions & 2 deletions src/ui/components/tools_subscreen_chooser_panel.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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 => {
Expand Down Expand Up @@ -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()
);
}
}
}
Loading
Loading