diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index e589ccfef..7ca092f50 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -999,6 +999,23 @@ pub enum TaskError { /// Creating a network context failed during a network switch. #[error("Could not connect to {network}. Check your network configuration and retry.")] NetworkContextCreationFailed { network: Network, detail: String }, + + // ────────────────────────────────────────────────────────────────────────── + // Address-watch registration errors + // ────────────────────────────────────────────────────────────────────────── + /// An off-tree address (not covered by the standard BIP44 account watch) + /// could not be registered with the SPV subsystem. The running SPV loop + /// currently has no runtime registration API, so addresses outside the + /// wallet's BIP44 account may not receive their incoming transactions until + /// the wallet is reloaded. + /// + /// The associated Base58 address lets users identify which handle is + /// affected. + #[error( + "Could not start watching address {address}. Please reload the wallet and retry, \ + or switch to Dash Core RPC mode in Expert settings if the problem persists." + )] + SpvOffTreeAddressRegistrationUnsupported { address: String }, } /// Escapes control characters in a token name for safe display in error messages. diff --git a/src/context/address_watch.rs b/src/context/address_watch.rs new file mode 100644 index 000000000..3c28e885b --- /dev/null +++ b/src/context/address_watch.rs @@ -0,0 +1,103 @@ +//! Typed address-watch registration for `AppContext`. +//! +//! The previous pair `ensure_address_imported` / `try_import_address` silently +//! no-opped in SPV mode and relied on an undocumented precondition — that the +//! address was already covered by the wallet's account-level watch. That was +//! true for standard BIP44 receive addresses derived from the wallet xprv, but +//! not for DashPay DIP-15 contact paths, imported single-key addresses, or +//! P2SH multisig outputs. +//! +//! [`AddressCoverage`] forces callers to declare how SPV coverage is provided +//! for an address so [`AppContext::ensure_address_watched`] can dispatch to +//! the right subsystem and never silently drop coverage. +//! +//! [`AppContext::ensure_address_watched`]: crate::context::AppContext::ensure_address_watched + +use crate::model::wallet::DerivationPathReference; + +/// How SPV coverage is provided for an address being registered. +/// +/// Callers must pick the variant that matches how the address was obtained. +/// The wrong variant can cause incoming transactions to be missed (off-tree +/// address classified as BIP44) or waste a runtime SPV registration slot +/// (BIP44 address classified as off-tree). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AddressCoverage { + /// Address derived inside the wallet's standard BIP44 account + /// (`m/44'/coin'/0'/change/index`). + /// + /// SPV coverage is automatic via the account-level watch set up at wallet + /// load; no explicit SPV registration is required. In Core RPC mode the + /// address is imported into the wallet as watch-only. + StandardBip44Account, + + /// Address derived outside the standard BIP44 account. + /// + /// Covers DashPay DIP-15 contact paths, imported single-key wallets, + /// Blockchain-Identities paths (`m/9'/…`), platform-payment addresses + /// (DIP-17), multisig outputs, and anything else that does not belong to + /// the wallet's BIP44 receive/change chains. + /// + /// In Core RPC mode the address is imported into the wallet as watch-only. + /// In SPV mode the running subsystem currently has no runtime registration + /// hook — see the `TODO(refactor)` note in + /// [`AppContext::ensure_address_watched`] and [`TaskError::SpvOffTreeAddressRegistrationUnsupported`]. + /// + /// [`AppContext::ensure_address_watched`]: crate::context::AppContext::ensure_address_watched + /// [`TaskError::SpvOffTreeAddressRegistrationUnsupported`]: crate::backend_task::error::TaskError::SpvOffTreeAddressRegistrationUnsupported + OffTree, +} + +impl AddressCoverage { + /// Map a [`DerivationPathReference`] to the appropriate coverage variant. + /// + /// Used by the wallet's internal address-registration helpers to pick the + /// correct variant without burdening every caller with the classification. + pub fn from_derivation_path_reference(reference: DerivationPathReference) -> Self { + match reference { + DerivationPathReference::BIP44 => Self::StandardBip44Account, + _ => Self::OffTree, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bip44_maps_to_standard_account() { + assert_eq!( + AddressCoverage::from_derivation_path_reference(DerivationPathReference::BIP44), + AddressCoverage::StandardBip44Account, + ); + } + + #[test] + fn non_bip44_references_map_to_off_tree() { + let off_tree_refs = [ + DerivationPathReference::BIP32, + DerivationPathReference::BlockchainIdentities, + DerivationPathReference::ProviderFunds, + DerivationPathReference::ContactBasedFunds, + DerivationPathReference::ContactBasedFundsRoot, + DerivationPathReference::ContactBasedFundsExternal, + DerivationPathReference::BlockchainIdentityCreditRegistrationFunding, + DerivationPathReference::BlockchainIdentityCreditTopupFunding, + DerivationPathReference::BlockchainIdentityCreditInvitationFunding, + DerivationPathReference::ProviderPlatformNodeKeys, + DerivationPathReference::CoinJoin, + DerivationPathReference::PlatformPayment, + DerivationPathReference::Root, + DerivationPathReference::Unknown, + ]; + for reference in off_tree_refs { + assert_eq!( + AddressCoverage::from_derivation_path_reference(reference), + AddressCoverage::OffTree, + "reference {:?} should map to OffTree", + reference, + ); + } + } +} diff --git a/src/context/mod.rs b/src/context/mod.rs index bf371f568..898ad9de3 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -1,3 +1,4 @@ +pub mod address_watch; pub mod connection_status; mod contract_token_db; mod identity_db; @@ -6,6 +7,8 @@ pub mod shielded; mod transaction_processing; mod wallet_lifecycle; +pub use address_watch::AddressCoverage; + pub(crate) use transaction_processing::get_transaction_info; use crate::app_dir::core_cookie_path; @@ -703,10 +706,59 @@ impl AppContext { Self::create_core_rpc_client(&url, self.network, &cfg.devnet_name, &cfg) } + /// Ensure SPV/Core is watching the given address, dispatching by + /// [`AddressCoverage`] and the active backend mode. + /// + /// # Dispatch matrix + /// + /// | Coverage | Core RPC mode | SPV mode | + /// |----------------------------|-------------------------------------------|------------------------------------------------------------| + /// | `StandardBip44Account` | Import into the targeted Core wallet. | No-op — wallet-level account watch already covers it. | + /// | `OffTree` | Import into the targeted Core wallet. | Returns [`TaskError::SpvOffTreeAddressRegistrationUnsupported`]. | + /// + /// See [`crate::context::address_watch`] for rationale and the history + /// of the legacy `ensure_address_imported` / `try_import_address` pair + /// this method replaced. + /// + /// Fire-and-forget callers must use `let _` explicitly so the type system + /// makes the error-swallowing visible at the call site. + pub fn ensure_address_watched( + &self, + address: &Address, + coverage: AddressCoverage, + core_wallet_name: Option<&str>, + label: Option<&str>, + ) -> Result<(), TaskError> { + match self.core_backend_mode() { + CoreBackendMode::Rpc => self.import_address_into_core(address, core_wallet_name, label), + CoreBackendMode::Spv => match coverage { + AddressCoverage::StandardBip44Account => Ok(()), + AddressCoverage::OffTree => { + // TODO(refactor): SpvManager has no runtime API to register an + // extra address outside the wallet's BIP44 account watch. + // Until that upstream hook exists, surface a typed error so + // callers can decide (warn-and-continue vs abort). The wallet + // reload path covers these addresses once the user restarts + // the SPV loop. + tracing::warn!( + address = %address, + "Off-tree address registration requested in SPV mode; \ + running SPV loop has no runtime registration hook. \ + Incoming transactions to this address may be missed \ + until the wallet is reloaded." + ); + Err(TaskError::SpvOffTreeAddressRegistrationUnsupported { + address: address.to_string(), + }) + } + }, + } + } + /// Import an address into the correct Core wallet if it's not already known. /// Uses `core_wallet_name` to target the right wallet on multi-wallet nodes. /// No-op if the address is already watched/mine. - pub fn ensure_address_imported( + fn import_address_into_core( &self, address: &Address, core_wallet_name: Option<&str>, @@ -724,18 +776,6 @@ impl AppContext { Ok(()) } - /// Import address into Core, ignoring errors. For best-effort registration. - pub fn try_import_address( - &self, - address: &Address, - core_wallet_name: Option<&str>, - label: Option<&str>, - ) { - if let Ok(client) = self.core_client_for_wallet(core_wallet_name) { - let _ = client.import_address(address, label, Some(false)); - } - } - /// Convert an RPC error to `TaskError`, enriching connection failures with /// the configured host:port so the user knows which address was unreachable. pub(crate) fn rpc_error_with_url(&self, e: dash_sdk::dashcore_rpc::Error) -> TaskError { diff --git a/src/model/wallet/mod.rs b/src/model/wallet/mod.rs index 7897398b5..be44877bf 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -1000,16 +1000,8 @@ impl Wallet { known_public_key = Some(public_key); if let Some(app_context) = register { let address = Address::p2pkh(&public_key, network); - app_context.try_import_address( - &address, - self.core_wallet_name.as_deref(), - Some(&format!( - "Managed by Dash Evo Tool {} {}", - self.alias.clone().unwrap_or_default(), - derivation_path - )), - ); - + // `register_address` handles address-watch registration internally + // (dispatches by backend mode via `ensure_address_watched`). self.register_address( address, &derivation_path, @@ -1199,8 +1191,26 @@ impl Wallet { }, ); - if app_context.core_backend_mode() == crate::spv::CoreBackendMode::Rpc { - app_context.try_import_address(&address, self.core_wallet_name.as_deref(), None); + let coverage = + crate::context::AddressCoverage::from_derivation_path_reference(path_reference); + if let Err(e) = app_context.ensure_address_watched( + &address, + coverage, + self.core_wallet_name.as_deref(), + None, + ) { + // Best-effort: we've already persisted the address in the wallet + // maps and database, so downstream lookups still work. Only the + // external watch (Core RPC import or SPV registration) failed; + // log loudly and continue so the caller isn't blocked. + tracing::warn!( + address = %address, + error = %e, + ?coverage, + "Failed to register address for external watch; wallet-side \ + bookkeeping succeeded but incoming transactions may be missed \ + until the wallet is reloaded." + ); } tracing::trace!( diff --git a/src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs b/src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs index d8c173e59..c67f03fa8 100644 --- a/src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs +++ b/src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs @@ -4,6 +4,7 @@ use crate::backend_task::error::TaskError; use crate::backend_task::identity::{ IdentityRegistrationInfo, IdentityTask, RegisterIdentityFundingMethod, }; +use crate::context::AddressCoverage; use crate::ui::MessageType; use crate::ui::components::MessageBanner; use crate::ui::identities::add_new_identity_screen::{ @@ -31,16 +32,18 @@ impl AddNewIdentityScreen { if let Some(has_address) = self.core_has_funding_address { if !has_address { - self.app_context.ensure_address_imported( + self.app_context.ensure_address_watched( &receive_address, + AddressCoverage::StandardBip44Account, core_wallet_name.as_deref(), Some("Managed by Dash Evo Tool"), )?; } self.funding_address = Some(receive_address); } else { - self.app_context.ensure_address_imported( + self.app_context.ensure_address_watched( &receive_address, + AddressCoverage::StandardBip44Account, core_wallet_name.as_deref(), Some("Managed by Dash Evo Tool"), )?; diff --git a/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs b/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs index 3e4264a2a..f0e4fd9c1 100644 --- a/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs +++ b/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs @@ -2,6 +2,7 @@ use crate::app::AppAction; use crate::backend_task::BackendTask; use crate::backend_task::error::TaskError; use crate::backend_task::identity::{IdentityTask, IdentityTopUpInfo, TopUpIdentityFundingMethod}; +use crate::context::AddressCoverage; use crate::ui::MessageType; use crate::ui::components::MessageBanner; use crate::ui::identities::funding_common::{self, copy_to_clipboard, generate_qr_code_image}; @@ -23,8 +24,9 @@ impl TopUpIdentityScreen { let core_wallet_name = wallet.core_wallet_name.clone(); drop(wallet); - self.app_context.ensure_address_imported( + self.app_context.ensure_address_watched( &receive_address, + AddressCoverage::StandardBip44Account, core_wallet_name.as_deref(), Some("Managed by Dash Evo Tool"), )?; diff --git a/src/ui/wallets/create_asset_lock_screen.rs b/src/ui/wallets/create_asset_lock_screen.rs index 46ab006ef..810d77486 100644 --- a/src/ui/wallets/create_asset_lock_screen.rs +++ b/src/ui/wallets/create_asset_lock_screen.rs @@ -2,7 +2,7 @@ use crate::app::AppAction; use crate::backend_task::core::{CoreItem, CoreTask}; use crate::backend_task::error::TaskError; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; -use crate::context::AppContext; +use crate::context::{AddressCoverage, AppContext}; use crate::model::amount::Amount; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; @@ -106,19 +106,22 @@ impl CreateAssetLockScreen { let core_wallet_name = wallet.core_wallet_name.clone(); drop(wallet); - // Import address to core if needed + // Ensure the funding address is watched (Core-RPC: imports into the + // targeted Core wallet; SPV: covered by the BIP44 account watch). if let Some(has_address) = self.core_has_funding_address { if !has_address { - self.app_context.ensure_address_imported( + self.app_context.ensure_address_watched( &receive_address, + AddressCoverage::StandardBip44Account, core_wallet_name.as_deref(), Some("Managed by Dash Evo Tool - Asset Lock"), )?; } self.funding_address = Some(receive_address); } else { - self.app_context.ensure_address_imported( + self.app_context.ensure_address_watched( &receive_address, + AddressCoverage::StandardBip44Account, core_wallet_name.as_deref(), Some("Managed by Dash Evo Tool - Asset Lock"), )?;