Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions key-wallet/src/account/account_type.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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");
}
}
}
17 changes: 16 additions & 1 deletion key-wallet/src/managed_account/managed_account_collection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down
7 changes: 7 additions & 0 deletions key-wallet/src/managed_account/managed_account_type.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading