-
Notifications
You must be signed in to change notification settings - Fork 12
refactor(context): typed address-watch registration (ensure_address_watched) #848
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: v1.0-dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| ); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(), | ||
| }) | ||
| } | ||
| }, | ||
| } | ||
| } | ||
|
Comment on lines
+725
to
+756
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: Dispatch matrix of The whole point of this refactor is to make the SPV+OffTree case fail loudly rather than silently no-op. Yet the only added tests cover Because the SPV branches don't touch RPC, the cleanest path is extracting a pure helper such as source: ['claude', 'codex'] 🤖 Fix this with AI agents |
||
|
|
||
| /// 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 { | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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." | ||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+1196
to
+1213
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion:
Worth confirming intent: if the design goal is for users to see a one-time warning when entering an SPV/OffTree flow, the warning currently only goes to logs ( source: ['codex'] 🤖 Fix this with AI agents
Comment on lines
+1196
to
+1213
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💬 Nitpick: Off-tree SPV registration warns twice per call When
Identity helpers ( source: ['claude']
Comment on lines
+1206
to
+1213
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💬 Nitpick: Log the
💡 Suggested change
Suggested change
source: ['claude'] |
||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| tracing::trace!( | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Suggestion: User-facing error uses 'RPC' jargon banned by CLAUDE.md
CLAUDE.md (User-facing message rules) explicitly bans 'RPC' as jargon for messages shown via
DisplayinMessageBanner. The new variant says "switch to Dash Core RPC mode in Expert settings". The codebase already standardizes on the alternative phrasing — seeSINGLE_KEY_REQUIRES_COREatsrc/ui/wallets/wallets_screen/single_key_view.rs:14andsrc/ui/components/tools_subscreen_chooser_panel.rs:150, which both say "switch to Expert mode, and select Local Dash Core node". Aligning keeps the message Everyday-User friendly and removes the banned term.💡 Suggested change
source: ['claude']
🤖 Fix this with AI agents