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
7 changes: 5 additions & 2 deletions key-wallet/src/account/account_type.rs
Original file line number Diff line number Diff line change
Expand Up @@ -344,11 +344,14 @@ impl AccountType {
Self::CoinJoin {
index,
} => {
// m/9'/coin_type'/account'
// m/9'/coin_type'/4'/account'
Ok(DerivationPath::from(vec![
ChildNumber::from_hardened_idx(9).map_err(crate::error::Error::Bip32)?,
ChildNumber::from_hardened_idx(crate::dip9::FEATURE_PURPOSE)
.map_err(crate::error::Error::Bip32)?,
ChildNumber::from_hardened_idx(coin_type)
.map_err(crate::error::Error::Bip32)?,
ChildNumber::from_hardened_idx(crate::dip9::FEATURE_PURPOSE_COINJOIN)
.map_err(crate::error::Error::Bip32)?,
ChildNumber::from_hardened_idx(*index).map_err(crate::error::Error::Bip32)?,
]))
}
Expand Down
8 changes: 6 additions & 2 deletions key-wallet/src/managed_account/managed_account_collection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -585,9 +585,13 @@ impl ManagedAccountCollection {
AccountType::CoinJoin {
index,
} => {
// CoinJoin addresses live on the external branch (m/9'/coin'/4'/account'/0/index)
// to match Dash Core, so derive through the `External` pool which uses [0, index].
let mut coinjoin_path = base_path;
coinjoin_path.push(crate::bip32::ChildNumber::from_normal_idx(0)?);
let addresses = AddressPool::new(
base_path,
AddressPoolType::Absent,
coinjoin_path,
AddressPoolType::External,
DEFAULT_COINJOIN_GAP_LIMIT,
network,
key_source,
Expand Down
7 changes: 5 additions & 2 deletions key-wallet/src/managed_account/managed_account_type.rs
Original file line number Diff line number Diff line change
Expand Up @@ -529,12 +529,15 @@ impl ManagedAccountType {
AccountType::CoinJoin {
index,
} => {
let path = account_type
// CoinJoin addresses live on the external branch (m/9'/coin'/4'/account'/0/index)
// to match Dash Core, so derive through the `External` pool which uses [0, index].
let mut path = account_type
.derivation_path(network)
.unwrap_or_else(|_| DerivationPath::master());
path.push(crate::bip32::ChildNumber::from_normal_idx(0)?);
let pool = AddressPool::new(
path,
AddressPoolType::Absent,
AddressPoolType::External,
DEFAULT_COINJOIN_GAP_LIMIT,
network,
key_source,
Expand Down
28 changes: 25 additions & 3 deletions key-wallet/src/tests/account_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

use crate::account::{Account, AccountType, StandardAccountType};
use crate::bip32::{ExtendedPrivKey, ExtendedPubKey};
use crate::managed_account::address_pool::KeySource;
use crate::managed_account::managed_account_type::ManagedAccountType;
use crate::mnemonic::{Language, Mnemonic};
use crate::Network;
use secp256k1::Secp256k1;
Expand Down Expand Up @@ -128,8 +130,28 @@ fn test_coinjoin_account_creation() {
_ => panic!("Expected CoinJoin account type"),
}

// Verify derivation path for CoinJoin: m/9'/1'/index' (testnet coin type)
assert_eq!(derivation_path.to_string(), format!("m/9'/1'/{}'", index));
// Verify the CoinJoin account derivation path matches Dash Core:
// m/9'/1'/4'/account' (testnet coin type, FEATURE_PURPOSE_COINJOIN = 4').
assert_eq!(derivation_path.to_string(), format!("m/9'/1'/4'/{}'", index));

// The managed CoinJoin pool must derive addresses on the external (`/0`)
// branch, so the first address sits at m/9'/1'/4'/account'/0/0.
let key_source = KeySource::Public(account.account_xpub);
let managed_type =
ManagedAccountType::from_account_type(account.account_type, network, &key_source)
.unwrap();
let pool = match &managed_type {
ManagedAccountType::CoinJoin {
addresses,
..
} => addresses,
_ => panic!("Expected CoinJoin managed account type"),
};
let first_address =
pool.address_at_index(0).expect("CoinJoin pool should pre-generate address 0");
let first_info =
pool.address_info(&first_address).expect("address info for index 0 should exist");
assert_eq!(first_info.path.to_string(), format!("m/9'/1'/4'/{}'/0/0", index));
}
}

Expand Down Expand Up @@ -498,7 +520,7 @@ fn test_account_derivation_path_uniqueness() {
AccountType::CoinJoin {
index: 0,
},
"m/9'/1'/0'".to_string(),
"m/9'/1'/4'/0'".to_string(),
),
(AccountType::IdentityRegistration, "m/9'/1'/5'/1'".to_string()),
(
Expand Down
51 changes: 36 additions & 15 deletions key-wallet/src/transaction_checking/transaction_router/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,18 +79,33 @@ impl TransactionRouter {
}
}

/// All account types that hold spendable funds on the Core chain.
///
/// Ownership of a transaction is membership-based across every keychain, exactly like
/// Dash Core's `IsMine`, which tests each scriptPubKey against all script-pubkey managers
/// uniformly (regular external, internal, and the CoinJoin descriptor). A transaction's
/// shape (`TransactionType::Standard` vs `TransactionType::CoinJoin`) is only a downstream
/// label, never a precondition for discovery, so both shapes must consult the full set of
/// fund-bearing accounts. An account only matches when a scriptPubKey or spent UTXO actually
/// belongs to it, so checking extra accounts never produces false positives.
fn fund_bearing_account_types() -> Vec<AccountTypeToCheck> {
vec![
AccountTypeToCheck::StandardBIP44,
AccountTypeToCheck::StandardBIP32,
AccountTypeToCheck::CoinJoin,
AccountTypeToCheck::DashpayReceivingFunds,
AccountTypeToCheck::DashpayExternalAccount,
]
}

/// Determine which account types should be checked for a given transaction type
pub fn get_relevant_account_types(tx_type: &TransactionType) -> Vec<AccountTypeToCheck> {
match tx_type {
TransactionType::Standard => {
vec![
AccountTypeToCheck::StandardBIP44,
AccountTypeToCheck::StandardBIP32,
AccountTypeToCheck::DashpayReceivingFunds,
AccountTypeToCheck::DashpayExternalAccount,
]
// Standard and CoinJoin transactions are distinguished only by their stored label;
// discovery is membership-based, so both check every fund-bearing account.
TransactionType::Standard | TransactionType::CoinJoin => {
Self::fund_bearing_account_types()
}
TransactionType::CoinJoin => vec![AccountTypeToCheck::CoinJoin],
TransactionType::ProviderRegistration => vec![
AccountTypeToCheck::ProviderOwnerKeys,
AccountTypeToCheck::ProviderOperatorKeys,
Expand Down Expand Up @@ -141,7 +156,12 @@ impl TransactionRouter {
}
}

/// Check if a transaction appears to be a CoinJoin transaction
/// Check if a transaction appears to be a CoinJoin transaction.
///
/// This heuristic only determines the stored [`TransactionType`] label; it never gates which
/// accounts are consulted for ownership (that is membership-based across all keychains, like
/// Dash Core). A small denomination spend that fails this heuristic is still discovered by the
/// CoinJoin account because that account owns the relevant scriptPubKeys.
fn is_coinjoin_transaction(tx: &Transaction) -> bool {
// CoinJoin transactions typically have:
// - Multiple inputs from different addresses
Expand All @@ -154,13 +174,14 @@ impl TransactionRouter {

/// Check if transaction has denomination outputs typical of CoinJoin
fn has_denomination_outputs(tx: &Transaction) -> bool {
// Check for standard CoinJoin denominations
// Standard CoinJoin denominations, each including the per-round fee
// (Dash Core `coinjoin/common.h`): denom + denom/1000 + 1, with COIN = 100_000_000.
const COINJOIN_DENOMINATIONS: [u64; 5] = [
100_000_000, // 1 DASH
10_000_000, // 0.1 DASH
1_000_000, // 0.01 DASH
100_000, // 0.001 DASH
10_000, // 0.0001 DASH
1_000_010_000, // 10 DASH + fee
100_001_000, // 1 DASH + fee
10_000_100, // 0.1 DASH + fee
1_000_010, // 0.01 DASH + fee
100_001, // 0.001 DASH + fee
];

let mut denomination_count = 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,11 @@ fn test_classify_coinjoin_transaction() {
&addr,
0..5,
&[
100_000_000, // 1 DASH denomination
100_000_000, // 1 DASH denomination
10_000_000, // 0.1 DASH denomination
10_000_000, // 0.1 DASH denomination
1_000_000, // 0.01 DASH denomination
100_001_000, // 1 DASH denomination (+ fee)
100_001_000, // 1 DASH denomination (+ fee)
10_000_100, // 0.1 DASH denomination (+ fee)
10_000_100, // 0.1 DASH denomination (+ fee)
1_000_010, // 0.01 DASH denomination (+ fee)
],
);
assert_eq!(TransactionRouter::classify_transaction(&tx), TransactionType::CoinJoin);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,27 @@ fn test_coinjoin_mixing_round() {
&addr,
0..6, // Multiple participants
&[
10_000_000, // 0.1 DASH denomination
10_000_000, // 0.1 DASH denomination
10_000_000, // 0.1 DASH denomination
10_000_000, // 0.1 DASH denomination
10_000_000, // 0.1 DASH denomination
10_000_000, // 0.1 DASH denomination
10_000_100, // 0.1 DASH denomination (+ fee)
10_000_100, // 0.1 DASH denomination (+ fee)
10_000_100, // 0.1 DASH denomination (+ fee)
10_000_100, // 0.1 DASH denomination (+ fee)
10_000_100, // 0.1 DASH denomination (+ fee)
10_000_100, // 0.1 DASH denomination (+ fee)
],
);

let tx_type = TransactionRouter::classify_transaction(&tx);
assert_eq!(tx_type, TransactionType::CoinJoin);

// The CoinJoin label does not narrow discovery: ownership is membership-based, so a CoinJoin
// tx checks every fund-bearing account (it commonly touches standard funds for collateral,
// funding, and change too).
let accounts = TransactionRouter::get_relevant_account_types(&tx_type);
assert_eq!(accounts.len(), 1);
assert_eq!(accounts[0], AccountTypeToCheck::CoinJoin);
assert!(accounts.contains(&AccountTypeToCheck::CoinJoin));
assert!(accounts.contains(&AccountTypeToCheck::StandardBIP44));
assert!(accounts.contains(&AccountTypeToCheck::StandardBIP32));
assert!(accounts.contains(&AccountTypeToCheck::DashpayReceivingFunds));
assert!(accounts.contains(&AccountTypeToCheck::DashpayExternalAccount));
}

#[test]
Expand All @@ -39,22 +45,23 @@ fn test_coinjoin_with_multiple_denominations() {
&addr,
0..8,
&[
100_000_000, // 1 DASH
100_000_000, // 1 DASH
10_000_000, // 0.1 DASH
10_000_000, // 0.1 DASH
1_000_000, // 0.01 DASH
1_000_000, // 0.01 DASH
100_000, // 0.001 DASH
100_000, // 0.001 DASH
100_001_000, // 1 DASH (+ fee)
100_001_000, // 1 DASH (+ fee)
10_000_100, // 0.1 DASH (+ fee)
10_000_100, // 0.1 DASH (+ fee)
1_000_010, // 0.01 DASH (+ fee)
1_000_010, // 0.01 DASH (+ fee)
100_001, // 0.001 DASH (+ fee)
100_001, // 0.001 DASH (+ fee)
],
);

let tx_type = TransactionRouter::classify_transaction(&tx);
assert_eq!(tx_type, TransactionType::CoinJoin);

let accounts = TransactionRouter::get_relevant_account_types(&tx_type);
assert_eq!(accounts[0], AccountTypeToCheck::CoinJoin);
assert!(accounts.contains(&AccountTypeToCheck::CoinJoin));
assert!(accounts.contains(&AccountTypeToCheck::StandardBIP44));
}

#[test]
Expand All @@ -65,8 +72,8 @@ fn test_coinjoin_threshold_exactly_half_denominations() {
&addr,
0..4,
&[
100_000_000, // Denomination
100_000_000, // Denomination
100_001_000, // Denomination
100_001_000, // Denomination
50_000_000, // Non-denomination
50_000_000, // Non-denomination
],
Expand All @@ -85,7 +92,7 @@ fn test_not_coinjoin_just_under_threshold() {
&addr,
0..3,
&[
100_000_000, // Denomination
100_001_000, // Denomination
50_000_000, // Non-denomination
75_000_000, // Non-denomination
25_000_000, // Non-denomination
Expand Down
Loading
Loading