From d521873ee01ebb8952c9cdcf19bc3f7a71e5c11e Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:51:21 +0300 Subject: [PATCH] fix(key-wallet): stop counting a contact's watch-only coins as the wallet's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `all_funding_accounts` included `dashpay_external_accounts`. A DashPay external account is watch-only by construction — its addresses derive from a *contact's* xpub, so this wallet can observe those outputs but can never sign for them. They are the contact's coins, and this wallet only ever pays into them. Counting them as funding had two visible consequences. The wallet balance did not move after paying a contact: the amount left the standard account and reappeared under the external one, because the payment output landed there. Observed on device — after sending 0.01 DASH, `account_balances=[Standard{idx:0,BIP44}=>0.99587131 DASH, DashpayExternal{idx:0}=>0.01 DASH]`, with the external account climbing across successive payments (0.111 → 0.461 → 0.511). To a user this reads as "my balance is wrong", or worse, "the send did not happen". And `utxos()` fed `get_spendable_utxos()`, so coin selection could pick an output the wallet cannot sign for, failing at signing time with funds apparently available. Receival accounts stay: those addresses derive from our own xpub and a contact pays into them, so the funds are genuinely ours. This does not stop the wallet seeing those transactions. Detection runs through `transaction_checking`, whose `AccountTypeToCheck` list still includes `DashpayExternalAccount`, and the monitored-address / compact-filter set is built from `all_accounts`, which also still includes it. Only balance and UTXO aggregation change. --- .../managed_account_collection.rs | 112 +++++++++++++++++- 1 file changed, 108 insertions(+), 4 deletions(-) diff --git a/key-wallet/src/managed_account/managed_account_collection.rs b/key-wallet/src/managed_account/managed_account_collection.rs index 4cf5f459f..803e1db45 100644 --- a/key-wallet/src/managed_account/managed_account_collection.rs +++ b/key-wallet/src/managed_account/managed_account_collection.rs @@ -917,31 +917,51 @@ impl ManagedAccountCollection { accounts } - /// Get all funds-bearing accounts (Standard BIP44/32, CoinJoin, DashPay). + /// Get the accounts whose funds belong to **this** wallet (Standard + /// BIP44/32, CoinJoin, DashPay receival). /// /// Use this from callsites that operate on balance / UTXO state — keys-only /// accounts (identity, asset-lock, provider) don't track those, so iterating /// [`Self::all_accounts`] and filtering via [`ManagedAccountRef::as_funds`] /// in those callsites is just noise. + /// + /// **`dashpay_external_accounts` are deliberately excluded.** 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 + /// made the wallet's own balance grow by whatever it had just paid out — + /// send 0.01 DASH to a contact and the total does not move, because the + /// amount leaves the standard account and reappears under the contact's — + /// and put unspendable outputs into `get_spendable_utxos`, where coin + /// selection can pick one and fail at signing time. + /// + /// Receival accounts stay: those addresses derive from *our* xpub and a + /// contact pays into them, so the funds are genuinely ours. + /// + /// Excluding them here does not stop the wallet seeing those transactions. + /// Detection runs through + /// [`transaction_checking`](crate::transaction_checking), whose + /// `AccountTypeToCheck` list still includes `DashpayExternalAccount`, and + /// the monitored-address / compact-filter set is built from + /// [`Self::all_accounts`], which also still includes it. pub fn all_funding_accounts(&self) -> Vec<&ManagedCoreFundsAccount> { let mut accounts = Vec::new(); accounts.extend(self.standard_bip44_accounts.values()); accounts.extend(self.standard_bip32_accounts.values()); accounts.extend(self.coinjoin_accounts.values()); accounts.extend(self.dashpay_receival_accounts.values()); - accounts.extend(self.dashpay_external_accounts.values()); accounts } /// Get all funds-bearing accounts mutably. See [`Self::all_funding_accounts`] - /// for which account types are visited. + /// for which account types are visited — in particular, why DashPay + /// *external* (watch-only) accounts are not among them. pub fn all_funding_accounts_mut(&mut self) -> Vec<&mut ManagedCoreFundsAccount> { let mut accounts = Vec::new(); accounts.extend(self.standard_bip44_accounts.values_mut()); accounts.extend(self.standard_bip32_accounts.values_mut()); accounts.extend(self.coinjoin_accounts.values_mut()); accounts.extend(self.dashpay_receival_accounts.values_mut()); - accounts.extend(self.dashpay_external_accounts.values_mut()); accounts } @@ -1035,3 +1055,87 @@ impl ManagedAccountCollection { self.platform_payment_accounts.get_mut(key) } } + +#[cfg(test)] +mod dashpay_funding_scope_tests { + use super::*; + use crate::account::{Account, AccountType, StandardAccountType}; + use crate::managed_account::ManagedCoreFundsAccount; + use crate::Network; + + fn xpub() -> crate::bip32::ExtendedPubKey { + let seed = [7u8; 32]; + let xpriv = + crate::bip32::ExtendedPrivKey::new_master(Network::Testnet, &seed).expect("master key"); + { + let secp = secp256k1::Secp256k1::new(); + crate::bip32::ExtendedPubKey::from_priv(&secp, &xpriv) + } + } + + fn managed(account_type: AccountType) -> ManagedCoreFundsAccount { + ManagedCoreFundsAccount::from_account(&Account { + parent_wallet_id: Some([1u8; 32]), + account_type, + network: Network::Testnet, + account_xpub: xpub(), + is_watch_only: matches!(account_type, AccountType::DashpayExternalAccount { .. }), + }) + } + + /// A DashPay **external** account is watch-only — its addresses derive from + /// the contact's xpub, so those coins are the contact's and this wallet can + /// never sign for them. Counting them as funding made the wallet's balance + /// absorb whatever it had just paid out, and put unspendable outputs in + /// front of coin selection. + /// + /// A DashPay **receival** account derives from our own xpub and must stay. + #[test] + fn external_dashpay_accounts_are_not_funding_accounts() { + let mut collection = ManagedAccountCollection::default(); + collection + .insert_funds_bearing_account(managed(AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + })) + .expect("standard"); + collection + .insert_funds_bearing_account(managed(AccountType::DashpayReceivingFunds { + index: 0, + user_identity_id: [0xAA; 32], + friend_identity_id: [0xBB; 32], + })) + .expect("receival"); + collection + .insert_funds_bearing_account(managed(AccountType::DashpayExternalAccount { + index: 0, + user_identity_id: [0xAA; 32], + friend_identity_id: [0xBB; 32], + })) + .expect("external"); + + let funding: Vec = collection + .all_funding_accounts() + .iter() + .map(|a| a.managed_account_type().to_account_type()) + .collect(); + + assert!( + funding.iter().any(|t| matches!(t, AccountType::DashpayReceivingFunds { .. })), + "receival accounts hold our own coins and must fund" + ); + assert!( + !funding.iter().any(|t| matches!(t, AccountType::DashpayExternalAccount { .. })), + "the contact's watch-only account must not count as this wallet's funds" + ); + assert_eq!( + funding.len(), + collection.all_funding_accounts_mut().len(), + "the mutable view must visit exactly the same accounts" + ); + + // The account still exists and is still watched — only its role in + // balance/UTXO aggregation changed. + assert_eq!(collection.dashpay_external_accounts.len(), 1); + } +}