diff --git a/key-wallet/src/account/account_type.rs b/key-wallet/src/account/account_type.rs index d027317bf..0962a6bc0 100644 --- a/key-wallet/src/account/account_type.rs +++ b/key-wallet/src/account/account_type.rs @@ -254,6 +254,67 @@ impl AccountType { } } + /// Is this account a chain owned by a DashPay contact rather than by + /// this wallet? + /// + /// A [`DashpayExternalAccount`](Self::DashpayExternalAccount) derives + /// its addresses from the **contact's** xpub (the mirrored DIP-15 path, + /// `friend_id/user_id`), so this wallet can observe those outputs but + /// can never sign for them. They are the contact's coins; this wallet + /// only ever pays into them. + /// + /// This is the canonical form of the policy that keeps a contact's + /// coins out of every "the wallet's funds" aggregate. Balance and + /// spendable-UTXO aggregation already follow it + /// ([`ManagedAccountCollection::all_funding_accounts`]), and persisters + /// projecting per-account [`TransactionRecord`]s into a wallet-level + /// store must follow it too, or a payment *to* a contact gets recorded + /// as money arriving. Detection is deliberately unaffected: + /// contact-owned accounts stay monitored so contact address rotation + /// and gap-limit maintenance keep working. + /// + /// [`DashpayReceivingFunds`](Self::DashpayReceivingFunds) is **not** + /// contact-owned: its addresses derive from *our* xpub and a contact + /// pays into them, so those funds are genuinely this wallet's. + /// + /// [`ManagedAccountCollection::all_funding_accounts`]: + /// crate::managed_account::managed_account_collection::ManagedAccountCollection::all_funding_accounts + /// [`TransactionRecord`]: + /// crate::managed_account::transaction_record::TransactionRecord + pub fn is_contact_owned(&self) -> bool { + // Exhaustive on purpose: a new account type must decide here + // whether its coins are the wallet's or a contact's. + match self { + Self::DashpayExternalAccount { + .. + } => true, + Self::Standard { + .. + } + | Self::CoinJoin { + .. + } + | Self::IdentityRegistration + | Self::IdentityTopUp { + .. + } + | Self::IdentityTopUpNotBoundToIdentity + | Self::IdentityInvitation + | Self::AssetLockAddressTopUp + | Self::AssetLockShieldedAddressTopUp + | Self::ProviderVotingKeys + | Self::ProviderOwnerKeys + | Self::ProviderOperatorKeys + | Self::ProviderPlatformKeys + | Self::DashpayReceivingFunds { + .. + } + | Self::PlatformPayment { + .. + } => false, + } + } + /// Get the derivation path reference for this account type pub fn derivation_path_reference(&self) -> DerivationPathReference { match self { @@ -551,3 +612,60 @@ impl AccountType { } } } + +#[cfg(test)] +mod contact_owned_tests { + use super::*; + + /// Only the DashPay external account — addresses derived from the + /// *contact's* xpub — is contact-owned. Every other account type, + /// including its receival twin (derived from *our* xpub), holds this + /// wallet's own funds or keys. + #[test] + fn only_the_dashpay_external_account_is_contact_owned() { + let external = AccountType::DashpayExternalAccount { + index: 0, + user_identity_id: [0xAA; 32], + friend_identity_id: [0xBB; 32], + }; + assert!(external.is_contact_owned()); + + let ours = [ + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP32Account, + }, + AccountType::CoinJoin { + index: 0, + }, + AccountType::IdentityRegistration, + AccountType::IdentityTopUp { + registration_index: 0, + }, + AccountType::IdentityTopUpNotBoundToIdentity, + AccountType::IdentityInvitation, + AccountType::AssetLockAddressTopUp, + AccountType::AssetLockShieldedAddressTopUp, + AccountType::ProviderVotingKeys, + AccountType::ProviderOwnerKeys, + AccountType::ProviderOperatorKeys, + AccountType::ProviderPlatformKeys, + AccountType::DashpayReceivingFunds { + index: 0, + user_identity_id: [0xAA; 32], + friend_identity_id: [0xBB; 32], + }, + AccountType::PlatformPayment { + account: 0, + key_class: 0, + }, + ]; + for account_type in ours { + assert!(!account_type.is_contact_owned(), "{account_type} must not be contact-owned"); + } + } +} diff --git a/key-wallet/src/managed_account/managed_account_collection.rs b/key-wallet/src/managed_account/managed_account_collection.rs index 5df90c72c..cf60f086b 100644 --- a/key-wallet/src/managed_account/managed_account_collection.rs +++ b/key-wallet/src/managed_account/managed_account_collection.rs @@ -988,7 +988,11 @@ impl ManagedAccountCollection { /// [`Self::all_accounts`] and filtering via [`ManagedAccountRef::as_funds`] /// in those callsites is just noise. /// - /// **`dashpay_external_accounts` are deliberately excluded.** A DashPay + /// **`dashpay_external_accounts` are deliberately excluded** — they are + /// the only funds-bearing accounts that are + /// [contact-owned](crate::account::AccountType::is_contact_owned), and + /// that predicate is the canonical statement of the policy applied here. + /// A DashPay /// external account is watch-only by construction: its addresses are derived /// from a *contact's* xpub, so this wallet can observe those outputs but can /// never sign for them. They are the contact's coins. Counting them here @@ -1191,6 +1195,17 @@ mod dashpay_funding_scope_tests { !funding.iter().any(|t| matches!(t, AccountType::DashpayExternalAccount { .. })), "the contact's watch-only account must not count as this wallet's funds" ); + assert!( + funding.iter().all(|t| !t.is_contact_owned()), + "all_funding_accounts must exclude exactly what AccountType::is_contact_owned flags" + ); + assert!( + collection + .dashpay_external_accounts + .values() + .all(|a| a.managed_account_type().is_contact_owned()), + "the managed-side predicate must agree" + ); assert_eq!( funding.len(), collection.all_funding_accounts_mut().len(), diff --git a/key-wallet/src/managed_account/managed_account_type.rs b/key-wallet/src/managed_account/managed_account_type.rs index cb91b9655..0732bd471 100644 --- a/key-wallet/src/managed_account/managed_account_type.rs +++ b/key-wallet/src/managed_account/managed_account_type.rs @@ -408,6 +408,13 @@ impl ManagedAccountType { self.address_pools().iter().flat_map(|pool| pool.all_script_pubkeys()).collect() } + /// Is this account a chain owned by a DashPay contact rather than by + /// this wallet? See [`AccountType::is_contact_owned`] — this is the + /// same predicate viewed from the managed side. + pub fn is_contact_owned(&self) -> bool { + self.to_account_type().is_contact_owned() + } + /// Get the account type as the original enum pub fn to_account_type(&self) -> AccountType { match self {