From 3a7bc07c851a9cfceb0ecb35484ca6d7710dabcd Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:42:55 +0200 Subject: [PATCH 1/3] feat(context): introduce AddressCoverage enum and ensure_address_watched API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a typed entry point for address-watch registration that forces callers to declare how SPV coverage is provided, so the function can dispatch to the right subsystem and never silently drop coverage. `AddressCoverage` has two variants: - `StandardBip44Account` — wallet's BIP44 account watch already covers the address; SPV mode is a true no-op, Core RPC mode imports as watch-only. - `OffTree` — DashPay contact paths, imported single-key wallets, identity paths (m/9'/…), and anything outside the BIP44 chains. Core RPC imports as watch-only; SPV mode currently returns a typed error because the running SpvManager has no runtime registration hook (TODO tracked). Legacy `ensure_address_imported` / `try_import_address` are marked `#[deprecated]`; callers migrate in follow-up commits and the legacy pair is removed once migration is complete. Adds `TaskError::SpvOffTreeAddressRegistrationUnsupported` with an actionable user-facing message that includes the Base58 address. Co-Authored-By: Claude Opus 4.1 --- src/backend_task/error.rs | 17 ++++++ src/context/address_watch.rs | 103 +++++++++++++++++++++++++++++++++++ src/context/mod.rs | 84 +++++++++++++++++++++++++++- 3 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 src/context/address_watch.rs 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..be76f2480 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,9 +706,84 @@ 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`]. | + /// + /// Replaces the legacy `ensure_address_imported` / `try_import_address` + /// pair; see [`crate::context::address_watch`] for rationale. + /// + /// 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. + fn import_address_into_core( + &self, + address: &Address, + core_wallet_name: Option<&str>, + label: Option<&str>, + ) -> Result<(), TaskError> { + let client = self.core_client_for_wallet(core_wallet_name)?; + let info = client + .get_address_info(address) + .map_err(|e| self.rpc_error_with_url(e))?; + if !(info.is_watchonly || info.is_mine) { + client + .import_address(address, label, Some(false)) + .map_err(|e| self.rpc_error_with_url(e))?; + } + Ok(()) + } + + /// Legacy: retained only until all callers migrate to [`Self::ensure_address_watched`]. + /// + /// Pre-existing behaviour: works in Core RPC mode, silently no-ops in SPV + /// mode. This is exactly the bug the typed API fixes — once every caller + /// has migrated, this method is removed. + #[deprecated( + note = "use `ensure_address_watched` with an explicit `AddressCoverage`; \ + silent no-op in SPV mode hides off-tree coverage bugs" + )] pub fn ensure_address_imported( &self, address: &Address, @@ -724,7 +802,11 @@ impl AppContext { Ok(()) } - /// Import address into Core, ignoring errors. For best-effort registration. + /// Legacy: retained only until all callers migrate to [`Self::ensure_address_watched`]. + #[deprecated( + note = "use `ensure_address_watched` with an explicit `AddressCoverage`; \ + silent no-op in SPV mode hides off-tree coverage bugs" + )] pub fn try_import_address( &self, address: &Address, From eb4e2bc209ba00640c1b895d2c329837cea8d607 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:44:45 +0200 Subject: [PATCH 2/3] refactor(context): migrate address-watch callers to ensure_address_watched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate every caller of the legacy `ensure_address_imported` and `try_import_address` pair to the new typed `ensure_address_watched` API: - `add_new_identity_screen/by_wallet_qr_code.rs` — BIP44 receive address for identity-creation funding (StandardBip44Account). - `top_up_identity_screen/by_wallet_qr_code.rs` — BIP44 receive address for identity top-up funding (StandardBip44Account). - `wallets/create_asset_lock_screen.rs` — BIP44 receive address for asset-lock funding (StandardBip44Account). - `model/wallet/mod.rs` — generic `register_address` now picks the coverage variant from the `DerivationPathReference`, so BIP44 entries stay standard-account and BlockchainIdentities / DashPay / Platform paths become OffTree. Removes the duplicate `try_import_address` in `receive_address` — `register_address` already registers via the typed API. Errors from `ensure_address_watched` in the generic wallet-registration helper are logged as warnings rather than surfaced to the caller, to preserve the previous best-effort behaviour while making failures visible in logs. Co-Authored-By: Claude Opus 4.1 --- src/model/wallet/mod.rs | 34 ++++++++++++------- .../by_wallet_qr_code.rs | 7 ++-- .../by_wallet_qr_code.rs | 4 ++- src/ui/wallets/create_asset_lock_screen.rs | 11 +++--- 4 files changed, 37 insertions(+), 19 deletions(-) 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"), )?; From a03514efeb6c03e9d7a435f18e975a59689efbb2 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:47:19 +0200 Subject: [PATCH 3/3] chore(context): remove legacy ensure_address_imported / try_import_address All callers now go through `ensure_address_watched` with an explicit `AddressCoverage`, so the legacy pair is no longer needed. Drop both methods and the deprecation shims. Co-Authored-By: Claude Opus 4.1 --- src/context/mod.rs | 48 +++------------------------------------------- 1 file changed, 3 insertions(+), 45 deletions(-) diff --git a/src/context/mod.rs b/src/context/mod.rs index be76f2480..898ad9de3 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -716,8 +716,9 @@ impl AppContext { /// | `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`]. | /// - /// Replaces the legacy `ensure_address_imported` / `try_import_address` - /// pair; see [`crate::context::address_watch`] for rationale. + /// 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. @@ -775,49 +776,6 @@ impl AppContext { Ok(()) } - /// Legacy: retained only until all callers migrate to [`Self::ensure_address_watched`]. - /// - /// Pre-existing behaviour: works in Core RPC mode, silently no-ops in SPV - /// mode. This is exactly the bug the typed API fixes — once every caller - /// has migrated, this method is removed. - #[deprecated( - note = "use `ensure_address_watched` with an explicit `AddressCoverage`; \ - silent no-op in SPV mode hides off-tree coverage bugs" - )] - pub fn ensure_address_imported( - &self, - address: &Address, - core_wallet_name: Option<&str>, - label: Option<&str>, - ) -> Result<(), TaskError> { - let client = self.core_client_for_wallet(core_wallet_name)?; - let info = client - .get_address_info(address) - .map_err(|e| self.rpc_error_with_url(e))?; - if !(info.is_watchonly || info.is_mine) { - client - .import_address(address, label, Some(false)) - .map_err(|e| self.rpc_error_with_url(e))?; - } - Ok(()) - } - - /// Legacy: retained only until all callers migrate to [`Self::ensure_address_watched`]. - #[deprecated( - note = "use `ensure_address_watched` with an explicit `AddressCoverage`; \ - silent no-op in SPV mode hides off-tree coverage bugs" - )] - 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 {