From 7896c7fa18874bba9465ea17e8b43946c0d3c941 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 3 May 2026 08:54:24 +0800 Subject: [PATCH 1/9] feat(key-wallet): add keep_txs_in_memory toggle on ManagedCoreAccount Lets callers opt out of in-memory transaction history retention while still tracking UTXOs and balance. Defaults to true to preserve existing behavior; serde deserialization of legacy payloads also defaults to true. Co-Authored-By: Claude Opus 4.7 (1M context) --- key-wallet/src/managed_account/mod.rs | 48 +++++++- .../src/tests/keep_txs_in_memory_tests.rs | 106 ++++++++++++++++++ key-wallet/src/tests/mod.rs | 2 + 3 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 key-wallet/src/tests/keep_txs_in_memory_tests.rs diff --git a/key-wallet/src/managed_account/mod.rs b/key-wallet/src/managed_account/mod.rs index b13808fc3..abb18ac1c 100644 --- a/key-wallet/src/managed_account/mod.rs +++ b/key-wallet/src/managed_account/mod.rs @@ -60,10 +60,16 @@ pub struct ManagedCoreAccount { pub is_watch_only: bool, /// Account balance information pub balance: WalletCoreBalance, - /// Transaction history for this account + /// Transaction history for this account. + /// Stays empty when [`Self::keep_txs_in_memory`] is `false`. pub transactions: BTreeMap, /// UTXO set for this account pub utxos: BTreeMap, + /// When `true` (default), processed transactions are inserted into + /// `transactions`. When `false`, the map stays empty even as new + /// transactions are processed; UTXOs and balance are still tracked. + #[cfg_attr(feature = "serde", serde(default = "default_keep_txs_in_memory"))] + pub keep_txs_in_memory: bool, /// Outpoints spent by recorded transactions. /// Rebuilt from `transactions` during deserialization. #[cfg_attr(feature = "serde", serde(skip_serializing))] @@ -74,6 +80,11 @@ pub struct ManagedCoreAccount { monitor_revision: u64, } +#[cfg(feature = "serde")] +fn default_keep_txs_in_memory() -> bool { + true +} + impl ManagedCoreAccount { /// Create a new managed account pub fn new( @@ -89,11 +100,31 @@ impl ManagedCoreAccount { balance: WalletCoreBalance::default(), transactions: BTreeMap::new(), utxos: BTreeMap::new(), + keep_txs_in_memory: true, spent_outpoints: HashSet::new(), monitor_revision: 0, } } + /// Builder helper: set whether transactions are retained in memory. + /// Disabling clears any already-stored records. + pub fn with_keep_txs_in_memory(mut self, keep: bool) -> Self { + self.set_keep_txs_in_memory(keep); + self + } + + /// Enable or disable transaction history retention in memory. + /// + /// When set to `false`, the `transactions` map is cleared and future + /// transactions are not inserted into it. UTXOs and balance continue to + /// be tracked normally. + pub fn set_keep_txs_in_memory(&mut self, keep: bool) { + self.keep_txs_in_memory = keep; + if !keep { + self.transactions.clear(); + } + } + /// Return the current monitor revision. pub fn monitor_revision(&self) -> u64 { self.monitor_revision @@ -560,7 +591,9 @@ impl ManagedCoreAccount { ); let record = tx_record.clone(); - self.transactions.insert(tx.txid(), tx_record); + if self.keep_txs_in_memory { + self.transactions.insert(tx.txid(), tx_record); + } self.update_utxos(tx, account_match, context); record @@ -1354,6 +1387,8 @@ impl<'de> Deserialize<'de> for ManagedCoreAccount { balance: WalletCoreBalance, transactions: BTreeMap, utxos: BTreeMap, + #[serde(default = "default_keep_txs_in_memory")] + keep_txs_in_memory: bool, } let helper = Helper::deserialize(deserializer)?; @@ -1365,14 +1400,21 @@ impl<'de> Deserialize<'de> for ManagedCoreAccount { .map(|input| input.previous_output) .collect(); + let transactions = if helper.keep_txs_in_memory { + helper.transactions + } else { + BTreeMap::new() + }; + Ok(ManagedCoreAccount { managed_account_type: helper.managed_account_type, network: helper.network, metadata: helper.metadata, is_watch_only: helper.is_watch_only, balance: helper.balance, - transactions: helper.transactions, + transactions, utxos: helper.utxos, + keep_txs_in_memory: helper.keep_txs_in_memory, spent_outpoints, monitor_revision: 0, }) diff --git a/key-wallet/src/tests/keep_txs_in_memory_tests.rs b/key-wallet/src/tests/keep_txs_in_memory_tests.rs new file mode 100644 index 000000000..fb7bde0bf --- /dev/null +++ b/key-wallet/src/tests/keep_txs_in_memory_tests.rs @@ -0,0 +1,106 @@ +//! Tests for the `keep_txs_in_memory` flag on `ManagedCoreAccount`. + +use crate::managed_account::ManagedCoreAccount; +use crate::test_utils::TestWalletContext; +use crate::transaction_checking::TransactionContext; +use dashcore::Transaction; + +#[test] +fn default_keeps_txs_in_memory() { + let account = ManagedCoreAccount::dummy_bip44(); + assert!(account.keep_txs_in_memory); +} + +#[test] +fn disabling_clears_existing_transactions() { + use crate::account::{StandardAccountType, TransactionRecord}; + use crate::managed_account::transaction_record::TransactionDirection; + use crate::transaction_checking::TransactionType; + use crate::AccountType; + use dashcore::blockdata::transaction::Transaction; + use dashcore::TxIn; + + let mut account = ManagedCoreAccount::dummy_bip44(); + let tx = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn::default()], + output: Vec::new(), + special_transaction_payload: None, + }; + let record = TransactionRecord::new( + tx.clone(), + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + TransactionContext::Mempool, + TransactionType::Standard, + TransactionDirection::Incoming, + Vec::new(), + Vec::new(), + 0, + ); + account.transactions.insert(tx.txid(), record); + assert_eq!(account.transactions.len(), 1); + + account.set_keep_txs_in_memory(false); + assert!(!account.keep_txs_in_memory); + assert!(account.transactions.is_empty()); +} + +#[tokio::test] +async fn record_transaction_skips_storage_when_disabled() { + let mut ctx = TestWalletContext::new_random(); + ctx.managed_wallet + .first_bip44_managed_account_mut() + .expect("Should have BIP44 account") + .set_keep_txs_in_memory(false); + + let tx = Transaction::dummy(&ctx.receive_address, 0..1, &[200_000]); + let result = ctx.check_transaction(&tx, TransactionContext::Mempool).await; + assert!(result.is_relevant); + + let account = ctx.bip44_account(); + assert!( + account.transactions.is_empty(), + "transactions should not be stored when keep_txs_in_memory is false" + ); + assert_eq!( + account.utxos.len(), + 1, + "UTXOs should still be tracked even with txs disabled" + ); + assert_eq!(account.balance.spendable(), 200_000); +} + +#[cfg(feature = "serde")] +#[test] +fn serde_round_trip_preserves_flag() { + let mut account = ManagedCoreAccount::dummy_bip44(); + account.set_keep_txs_in_memory(false); + + let json = serde_json::to_string(&account).unwrap(); + let deserialized: ManagedCoreAccount = serde_json::from_str(&json).unwrap(); + assert!(!deserialized.keep_txs_in_memory); +} + +#[cfg(feature = "serde")] +#[test] +fn serde_defaults_when_field_missing_from_legacy_payload() { + // Simulate a serialized account written before the `keep_txs_in_memory` + // field existed by stripping it from a freshly-serialized account. + let account = ManagedCoreAccount::dummy_bip44(); + let mut value = serde_json::to_value(&account).unwrap(); + value + .as_object_mut() + .expect("object") + .remove("keep_txs_in_memory"); + let json = serde_json::to_string(&value).unwrap(); + + let restored: ManagedCoreAccount = serde_json::from_str(&json).unwrap(); + assert!( + restored.keep_txs_in_memory, + "missing flag should default to true to preserve legacy behavior" + ); +} diff --git a/key-wallet/src/tests/mod.rs b/key-wallet/src/tests/mod.rs index d92850c79..ea8fd472e 100644 --- a/key-wallet/src/tests/mod.rs +++ b/key-wallet/src/tests/mod.rs @@ -26,6 +26,8 @@ mod transaction_tests; mod spent_outpoints_tests; +mod keep_txs_in_memory_tests; + mod unit_variant_wallet_tests; mod wallet_tests; From 0282498836bcf6c112f1d2610a18d596c326e01f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 3 May 2026 15:28:25 +0700 Subject: [PATCH 2/9] refactor(key-wallet): make keep_txs_in_memory a Cargo feature Replaces the runtime `keep_txs_in_memory: bool` field with a Cargo feature of the same name (enabled by default). When the feature is off, `ManagedCoreAccount::transactions` is removed at compile time so memory isn't reserved for per-tx history; UTXOs and balance continue to be tracked normally. - Cargo.toml: add `keep_txs_in_memory` to default features; mark the dev-dep self-reference as `default-features = false` so the feature can actually be turned off in tests. - ManagedCoreAccount: gate the `transactions` field; expose `has_transaction` / `get_transaction` helpers that always work and return false / None when the feature is off. - ManagedAccountTrait: gate `transactions()` / `transactions_mut()`. - wallet_checker / wallet_info_interface: route field access through the helpers or `#[cfg]` blocks; wallet-level history queries return empty when the feature is off. - Tests: gate sub-modules that exercise the field; rewrite the feature-specific test module to cover both compile-time configurations. Co-Authored-By: Claude Opus 4.7 (1M context) --- key-wallet/Cargo.toml | 7 +- .../managed_account/managed_account_trait.rs | 12 +- key-wallet/src/managed_account/mod.rs | 135 ++++++++++-------- key-wallet/src/test_utils/wallet.rs | 11 +- .../src/tests/keep_txs_in_memory_tests.rs | 121 +++++++--------- key-wallet/src/tests/mod.rs | 3 + .../transaction_router/tests/mod.rs | 4 +- .../transaction_checking/wallet_checker.rs | 20 ++- .../wallet_info_interface.rs | 83 ++++++----- 9 files changed, 222 insertions(+), 174 deletions(-) diff --git a/key-wallet/Cargo.toml b/key-wallet/Cargo.toml index a19209a38..4d1b94378 100644 --- a/key-wallet/Cargo.toml +++ b/key-wallet/Cargo.toml @@ -9,13 +9,16 @@ readme = "README.md" license = "CC0-1.0" [features] -default = ["getrandom"] +default = ["getrandom", "keep_txs_in_memory"] serde = ["dep:serde", "dep:serde_json", "dashcore_hashes/serde", "secp256k1/serde", "dashcore/serde"] bincode = ["serde", "dep:bincode", "dep:bincode_derive", "dashcore_hashes/bincode", "dashcore/bincode"] bip38 = ["scrypt", "aes", "bs58", "rand"] eddsa = ["dashcore/eddsa"] bls = ["dashcore/bls"] test-utils = ["dashcore/test-utils"] +# Keep per-account transaction history (`ManagedCoreAccount::transactions`) in memory. +# Disable to skip storing transaction records when only UTXOs and balances matter. +keep_txs_in_memory = [] [dependencies] internals = { path = "../internals", package = "dashcore-private" } @@ -46,7 +49,7 @@ async-trait = "0.1" [dev-dependencies] dashcore = { path="../dash", features = ["test-utils"] } hex = "0.4" -key-wallet = { path = ".", features = ["test-utils", "bip38", "serde", "bincode", "eddsa", "bls"] } +key-wallet = { path = ".", default-features = false, features = ["test-utils", "bip38", "serde", "bincode", "eddsa", "bls", "getrandom"] } rand = { version = "0.8", features = ["std", "std_rng"] } test-case = "3.3" tokio = { version = "1", features = ["macros", "rt"] } diff --git a/key-wallet/src/managed_account/managed_account_trait.rs b/key-wallet/src/managed_account/managed_account_trait.rs index dfcf38e25..7307f5c19 100644 --- a/key-wallet/src/managed_account/managed_account_trait.rs +++ b/key-wallet/src/managed_account/managed_account_trait.rs @@ -5,12 +5,14 @@ use std::collections::BTreeMap; use crate::account::AccountMetadata; +#[cfg(feature = "keep_txs_in_memory")] use crate::account::TransactionRecord; use crate::managed_account::managed_account_type::ManagedAccountType; use crate::utxo::Utxo; use crate::wallet::balance::WalletCoreBalance; use crate::Network; use dashcore::blockdata::transaction::OutPoint; +#[cfg(feature = "keep_txs_in_memory")] use dashcore::Txid; /// Common trait for all managed account types @@ -39,10 +41,16 @@ pub trait ManagedAccountTrait { /// Get mutable balance fn balance_mut(&mut self) -> &mut WalletCoreBalance; - /// Get transactions + /// Get transactions. + /// + /// Only available when the `keep_txs_in_memory` Cargo feature is enabled. + #[cfg(feature = "keep_txs_in_memory")] fn transactions(&self) -> &BTreeMap; - /// Get mutable transactions + /// Get mutable transactions. + /// + /// Only available when the `keep_txs_in_memory` Cargo feature is enabled. + #[cfg(feature = "keep_txs_in_memory")] fn transactions_mut(&mut self) -> &mut BTreeMap; /// Get UTXOs diff --git a/key-wallet/src/managed_account/mod.rs b/key-wallet/src/managed_account/mod.rs index abb18ac1c..98ccd797b 100644 --- a/key-wallet/src/managed_account/mod.rs +++ b/key-wallet/src/managed_account/mod.rs @@ -61,15 +61,14 @@ pub struct ManagedCoreAccount { /// Account balance information pub balance: WalletCoreBalance, /// Transaction history for this account. - /// Stays empty when [`Self::keep_txs_in_memory`] is `false`. + /// + /// Only present when the `keep_txs_in_memory` Cargo feature is enabled. + /// With the feature off, processed transactions update UTXOs and balance + /// but no per-tx history is retained. + #[cfg(feature = "keep_txs_in_memory")] pub transactions: BTreeMap, /// UTXO set for this account pub utxos: BTreeMap, - /// When `true` (default), processed transactions are inserted into - /// `transactions`. When `false`, the map stays empty even as new - /// transactions are processed; UTXOs and balance are still tracked. - #[cfg_attr(feature = "serde", serde(default = "default_keep_txs_in_memory"))] - pub keep_txs_in_memory: bool, /// Outpoints spent by recorded transactions. /// Rebuilt from `transactions` during deserialization. #[cfg_attr(feature = "serde", serde(skip_serializing))] @@ -80,11 +79,6 @@ pub struct ManagedCoreAccount { monitor_revision: u64, } -#[cfg(feature = "serde")] -fn default_keep_txs_in_memory() -> bool { - true -} - impl ManagedCoreAccount { /// Create a new managed account pub fn new( @@ -98,30 +92,43 @@ impl ManagedCoreAccount { metadata: AccountMetadata::default(), is_watch_only, balance: WalletCoreBalance::default(), + #[cfg(feature = "keep_txs_in_memory")] transactions: BTreeMap::new(), utxos: BTreeMap::new(), - keep_txs_in_memory: true, spent_outpoints: HashSet::new(), monitor_revision: 0, } } - /// Builder helper: set whether transactions are retained in memory. - /// Disabling clears any already-stored records. - pub fn with_keep_txs_in_memory(mut self, keep: bool) -> Self { - self.set_keep_txs_in_memory(keep); - self + /// Returns `true` if a transaction record for `txid` is stored on this account. + /// + /// Always returns `false` when the `keep_txs_in_memory` Cargo feature is + /// disabled, since no records are kept. + pub fn has_transaction(&self, txid: &Txid) -> bool { + #[cfg(feature = "keep_txs_in_memory")] + { + self.transactions.contains_key(txid) + } + #[cfg(not(feature = "keep_txs_in_memory"))] + { + let _ = txid; + false + } } - /// Enable or disable transaction history retention in memory. + /// Returns the stored transaction record for `txid`, if any. /// - /// When set to `false`, the `transactions` map is cleared and future - /// transactions are not inserted into it. UTXOs and balance continue to - /// be tracked normally. - pub fn set_keep_txs_in_memory(&mut self, keep: bool) { - self.keep_txs_in_memory = keep; - if !keep { - self.transactions.clear(); + /// Always returns `None` when the `keep_txs_in_memory` Cargo feature is + /// disabled. + pub fn get_transaction(&self, txid: &Txid) -> Option<&TransactionRecord> { + #[cfg(feature = "keep_txs_in_memory")] + { + self.transactions.get(txid) + } + #[cfg(not(feature = "keep_txs_in_memory"))] + { + let _ = txid; + None } } @@ -455,6 +462,10 @@ impl ManagedCoreAccount { /// Re-process an existing transaction with updated context (e.g., mempool→block confirmation) /// and potentially new address matches from gap limit rescans. + /// + /// When the `keep_txs_in_memory` Cargo feature is disabled, no per-tx + /// records exist, so this always falls through to `record_transaction` + /// and reports the call as a state change. pub(crate) fn confirm_transaction( &mut self, tx: &Transaction, @@ -462,31 +473,39 @@ impl ManagedCoreAccount { context: TransactionContext, transaction_type: TransactionType, ) -> bool { - if !self.transactions.contains_key(&tx.txid()) { - self.record_transaction(tx, account_match, context, transaction_type); - return true; - } - - let mut changed = false; - if let Some(tx_record) = self.transactions.get_mut(&tx.txid()) { - debug_assert_eq!( - tx_record.transaction_type, - transaction_type, - "transaction_type changed between recordings for {}", - tx.txid() - ); - if tx_record.context != context { - let was_confirmed = tx_record.context.confirmed(); - tx_record.update_context(context.clone()); - // Only signal a change when confirmation status actually changes, - // not for upgrades within the confirmed state (e.g. InBlock → InChainLockedBlock). - // TODO: emit a change event for InBlock → InChainLockedBlock once chainlock - // wallet transaction events are properly handled - changed = !was_confirmed; + #[cfg(feature = "keep_txs_in_memory")] + { + if !self.transactions.contains_key(&tx.txid()) { + self.record_transaction(tx, account_match, context, transaction_type); + return true; + } + + let mut changed = false; + if let Some(tx_record) = self.transactions.get_mut(&tx.txid()) { + debug_assert_eq!( + tx_record.transaction_type, + transaction_type, + "transaction_type changed between recordings for {}", + tx.txid() + ); + if tx_record.context != context { + let was_confirmed = tx_record.context.confirmed(); + tx_record.update_context(context.clone()); + // Only signal a change when confirmation status actually changes, + // not for upgrades within the confirmed state (e.g. InBlock → InChainLockedBlock). + // TODO: emit a change event for InBlock → InChainLockedBlock once chainlock + // wallet transaction events are properly handled + changed = !was_confirmed; + } } + self.update_utxos(tx, account_match, context); + changed + } + #[cfg(not(feature = "keep_txs_in_memory"))] + { + self.record_transaction(tx, account_match, context, transaction_type); + true } - self.update_utxos(tx, account_match, context); - changed } /// Record a new transaction and update UTXOs for spendable account types @@ -591,9 +610,8 @@ impl ManagedCoreAccount { ); let record = tx_record.clone(); - if self.keep_txs_in_memory { - self.transactions.insert(tx.txid(), tx_record); - } + #[cfg(feature = "keep_txs_in_memory")] + self.transactions.insert(tx.txid(), tx_record); self.update_utxos(tx, account_match, context); record @@ -1355,10 +1373,12 @@ impl ManagedAccountTrait for ManagedCoreAccount { &mut self.balance } + #[cfg(feature = "keep_txs_in_memory")] fn transactions(&self) -> &BTreeMap { &self.transactions } + #[cfg(feature = "keep_txs_in_memory")] fn transactions_mut(&mut self) -> &mut BTreeMap { &mut self.transactions } @@ -1385,10 +1405,9 @@ impl<'de> Deserialize<'de> for ManagedCoreAccount { metadata: AccountMetadata, is_watch_only: bool, balance: WalletCoreBalance, + #[serde(default)] transactions: BTreeMap, utxos: BTreeMap, - #[serde(default = "default_keep_txs_in_memory")] - keep_txs_in_memory: bool, } let helper = Helper::deserialize(deserializer)?; @@ -1400,21 +1419,15 @@ impl<'de> Deserialize<'de> for ManagedCoreAccount { .map(|input| input.previous_output) .collect(); - let transactions = if helper.keep_txs_in_memory { - helper.transactions - } else { - BTreeMap::new() - }; - Ok(ManagedCoreAccount { managed_account_type: helper.managed_account_type, network: helper.network, metadata: helper.metadata, is_watch_only: helper.is_watch_only, balance: helper.balance, - transactions, + #[cfg(feature = "keep_txs_in_memory")] + transactions: helper.transactions, utxos: helper.utxos, - keep_txs_in_memory: helper.keep_txs_in_memory, spent_outpoints, monitor_revision: 0, }) diff --git a/key-wallet/src/test_utils/wallet.rs b/key-wallet/src/test_utils/wallet.rs index 2d342d3bf..8678eb7d6 100644 --- a/key-wallet/src/test_utils/wallet.rs +++ b/key-wallet/src/test_utils/wallet.rs @@ -1,11 +1,15 @@ -use dashcore::{Address, Network, Transaction, Txid}; +use dashcore::{Address, Network, Transaction}; +#[cfg(feature = "keep_txs_in_memory")] +use dashcore::Txid; use crate::{ - account::{ManagedCoreAccount, TransactionRecord}, + account::ManagedCoreAccount, transaction_checking::{TransactionCheckResult, TransactionContext, WalletTransactionChecker}, wallet::{initialization::WalletAccountCreationOptions, ManagedWalletInfo}, ExtendedPubKey, Utxo, Wallet, }; +#[cfg(feature = "keep_txs_in_memory")] +use crate::account::TransactionRecord; impl ManagedWalletInfo { pub fn dummy(id: u8) -> Self { @@ -60,6 +64,9 @@ impl TestWalletContext { } /// Returns a transaction record by txid from the first BIP44 account. + /// + /// Only available when the `keep_txs_in_memory` Cargo feature is enabled. + #[cfg(feature = "keep_txs_in_memory")] pub fn transaction(&self, txid: &Txid) -> &TransactionRecord { self.bip44_account().transactions.get(txid).expect("Should have transaction") } diff --git a/key-wallet/src/tests/keep_txs_in_memory_tests.rs b/key-wallet/src/tests/keep_txs_in_memory_tests.rs index fb7bde0bf..416302bef 100644 --- a/key-wallet/src/tests/keep_txs_in_memory_tests.rs +++ b/key-wallet/src/tests/keep_txs_in_memory_tests.rs @@ -1,23 +1,62 @@ -//! Tests for the `keep_txs_in_memory` flag on `ManagedCoreAccount`. +//! Tests for the `keep_txs_in_memory` Cargo feature. +//! +//! When the feature is enabled (the default), `ManagedCoreAccount` keeps a +//! per-account `transactions` map populated as transactions are processed. +//! When the feature is disabled, the field does not exist and processing a +//! transaction only updates UTXOs and balance. -use crate::managed_account::ManagedCoreAccount; use crate::test_utils::TestWalletContext; use crate::transaction_checking::TransactionContext; use dashcore::Transaction; -#[test] -fn default_keeps_txs_in_memory() { - let account = ManagedCoreAccount::dummy_bip44(); - assert!(account.keep_txs_in_memory); +#[tokio::test] +async fn record_transaction_updates_utxos_and_balance() { + let mut ctx = TestWalletContext::new_random(); + + let tx = Transaction::dummy(&ctx.receive_address, 0..1, &[200_000]); + let result = ctx.check_transaction(&tx, TransactionContext::Mempool).await; + assert!(result.is_relevant); + + let account = ctx.bip44_account(); + assert_eq!(account.utxos.len(), 1, "UTXOs are tracked regardless of feature"); + assert_eq!(account.balance.spendable(), 200_000); +} + +#[cfg(feature = "keep_txs_in_memory")] +#[tokio::test] +async fn transactions_are_stored_when_feature_enabled() { + let mut ctx = TestWalletContext::new_random(); + + let tx = Transaction::dummy(&ctx.receive_address, 0..1, &[200_000]); + let _ = ctx.check_transaction(&tx, TransactionContext::Mempool).await; + + let account = ctx.bip44_account(); + assert_eq!(account.transactions.len(), 1); + assert!(account.has_transaction(&tx.txid())); + assert!(account.get_transaction(&tx.txid()).is_some()); +} + +#[cfg(not(feature = "keep_txs_in_memory"))] +#[tokio::test] +async fn transactions_are_not_stored_when_feature_disabled() { + let mut ctx = TestWalletContext::new_random(); + + let tx = Transaction::dummy(&ctx.receive_address, 0..1, &[200_000]); + let _ = ctx.check_transaction(&tx, TransactionContext::Mempool).await; + + let account = ctx.bip44_account(); + assert!(!account.has_transaction(&tx.txid())); + assert!(account.get_transaction(&tx.txid()).is_none()); } +#[cfg(feature = "keep_txs_in_memory")] #[test] -fn disabling_clears_existing_transactions() { +fn helper_methods_proxy_transactions_field() { use crate::account::{StandardAccountType, TransactionRecord}; use crate::managed_account::transaction_record::TransactionDirection; + use crate::managed_account::ManagedCoreAccount; use crate::transaction_checking::TransactionType; use crate::AccountType; - use dashcore::blockdata::transaction::Transaction; use dashcore::TxIn; let mut account = ManagedCoreAccount::dummy_bip44(); @@ -41,66 +80,12 @@ fn disabling_clears_existing_transactions() { Vec::new(), 0, ); - account.transactions.insert(tx.txid(), record); - assert_eq!(account.transactions.len(), 1); + let txid = tx.txid(); - account.set_keep_txs_in_memory(false); - assert!(!account.keep_txs_in_memory); - assert!(account.transactions.is_empty()); -} - -#[tokio::test] -async fn record_transaction_skips_storage_when_disabled() { - let mut ctx = TestWalletContext::new_random(); - ctx.managed_wallet - .first_bip44_managed_account_mut() - .expect("Should have BIP44 account") - .set_keep_txs_in_memory(false); + assert!(!account.has_transaction(&txid)); + assert!(account.get_transaction(&txid).is_none()); - let tx = Transaction::dummy(&ctx.receive_address, 0..1, &[200_000]); - let result = ctx.check_transaction(&tx, TransactionContext::Mempool).await; - assert!(result.is_relevant); - - let account = ctx.bip44_account(); - assert!( - account.transactions.is_empty(), - "transactions should not be stored when keep_txs_in_memory is false" - ); - assert_eq!( - account.utxos.len(), - 1, - "UTXOs should still be tracked even with txs disabled" - ); - assert_eq!(account.balance.spendable(), 200_000); -} - -#[cfg(feature = "serde")] -#[test] -fn serde_round_trip_preserves_flag() { - let mut account = ManagedCoreAccount::dummy_bip44(); - account.set_keep_txs_in_memory(false); - - let json = serde_json::to_string(&account).unwrap(); - let deserialized: ManagedCoreAccount = serde_json::from_str(&json).unwrap(); - assert!(!deserialized.keep_txs_in_memory); -} - -#[cfg(feature = "serde")] -#[test] -fn serde_defaults_when_field_missing_from_legacy_payload() { - // Simulate a serialized account written before the `keep_txs_in_memory` - // field existed by stripping it from a freshly-serialized account. - let account = ManagedCoreAccount::dummy_bip44(); - let mut value = serde_json::to_value(&account).unwrap(); - value - .as_object_mut() - .expect("object") - .remove("keep_txs_in_memory"); - let json = serde_json::to_string(&value).unwrap(); - - let restored: ManagedCoreAccount = serde_json::from_str(&json).unwrap(); - assert!( - restored.keep_txs_in_memory, - "missing flag should default to true to preserve legacy behavior" - ); + account.transactions.insert(txid, record); + assert!(account.has_transaction(&txid)); + assert!(account.get_transaction(&txid).is_some()); } diff --git a/key-wallet/src/tests/mod.rs b/key-wallet/src/tests/mod.rs index ea8fd472e..238da7a64 100644 --- a/key-wallet/src/tests/mod.rs +++ b/key-wallet/src/tests/mod.rs @@ -24,6 +24,9 @@ mod special_transaction_tests; mod transaction_tests; +// `spent_outpoints_tests` populates `account.transactions` directly, so it +// only compiles with the `keep_txs_in_memory` Cargo feature enabled. +#[cfg(feature = "keep_txs_in_memory")] mod spent_outpoints_tests; mod keep_txs_in_memory_tests; diff --git a/key-wallet/src/transaction_checking/transaction_router/tests/mod.rs b/key-wallet/src/transaction_checking/transaction_router/tests/mod.rs index 3fa992fd5..2c5285fff 100644 --- a/key-wallet/src/transaction_checking/transaction_router/tests/mod.rs +++ b/key-wallet/src/transaction_checking/transaction_router/tests/mod.rs @@ -10,7 +10,9 @@ mod helpers; mod asset_unlock; #[cfg(test)] mod classification; -#[cfg(test)] +// `coinbase` exercises in-memory transaction storage; only built when the +// `keep_txs_in_memory` Cargo feature is enabled. +#[cfg(all(test, feature = "keep_txs_in_memory"))] mod coinbase; #[cfg(test)] mod coinjoin; diff --git a/key-wallet/src/transaction_checking/wallet_checker.rs b/key-wallet/src/transaction_checking/wallet_checker.rs index c7830788e..5dda2e2dc 100644 --- a/key-wallet/src/transaction_checking/wallet_checker.rs +++ b/key-wallet/src/transaction_checking/wallet_checker.rs @@ -71,7 +71,7 @@ impl WalletTransactionChecker for ManagedWalletInfo { if let Some(account) = self.accounts.get_by_account_type_match(&account_match.account_type_match) { - if account.transactions.contains_key(&txid) { + if account.has_transaction(&txid) { is_new = false; break; } @@ -89,7 +89,7 @@ impl WalletTransactionChecker for ManagedWalletInfo { let already_confirmed = result.affected_accounts.iter().any(|am| { self.accounts .get_by_account_type_match(&am.account_type_match) - .and_then(|a| a.transactions.get(&txid)) + .and_then(|a| a.get_transaction(&txid)) .map_or(false, |r| r.is_confirmed()) }); if already_confirmed { @@ -107,8 +107,14 @@ impl WalletTransactionChecker for ManagedWalletInfo { else { continue; }; - if account.transactions.contains_key(&txid) { + #[cfg(feature = "keep_txs_in_memory")] + let has_record = account.transactions.contains_key(&txid); + #[cfg(not(feature = "keep_txs_in_memory"))] + let has_record = false; + + if has_record { account.mark_utxos_instant_send(&txid); + #[cfg(feature = "keep_txs_in_memory")] if let Some(record) = account.transactions.get_mut(&txid) { record.update_context(context.clone()); result.updated_records.push(record.clone()); @@ -150,10 +156,10 @@ impl WalletTransactionChecker for ManagedWalletInfo { result.new_records.push(record); result.state_modified = true; } else { - let existed_before = account.transactions.contains_key(&tx.txid()); + let existed_before = account.has_transaction(&tx.txid()); if account.confirm_transaction(tx, &account_match, context.clone(), tx_type) { result.state_modified = true; - if let Some(record) = account.transactions.get(&tx.txid()) { + if let Some(record) = account.get_transaction(&tx.txid()) { if existed_before { result.updated_records.push(record.clone()); } else { @@ -220,7 +226,9 @@ impl WalletTransactionChecker for ManagedWalletInfo { } } -#[cfg(test)] +// These tests rely on per-account transaction history and so only run when +// the `keep_txs_in_memory` Cargo feature is enabled. +#[cfg(all(test, feature = "keep_txs_in_memory"))] mod tests { use super::*; use crate::account::account_type::StandardAccountType; diff --git a/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs b/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs index 51e5ebe24..703be78bf 100644 --- a/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs +++ b/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs @@ -7,6 +7,7 @@ use std::collections::{BTreeMap, BTreeSet}; use super::managed_account_operations::ManagedAccountOperations; use crate::account::{AccountType, ManagedAccountTrait}; use crate::managed_account::managed_account_collection::ManagedAccountCollection; +#[cfg(feature = "keep_txs_in_memory")] use crate::transaction_checking::TransactionContext; use crate::transaction_checking::WalletTransactionChecker; use crate::wallet::managed_wallet_info::TransactionRecord; @@ -229,11 +230,16 @@ impl WalletInfoInterface for ManagedWalletInfo { } fn transaction_history(&self) -> Vec<&TransactionRecord> { - let mut transactions = Vec::new(); - for account in self.accounts.all_accounts() { - transactions.extend(account.transactions.values()); + #[cfg(feature = "keep_txs_in_memory")] + { + let mut transactions = Vec::new(); + for account in self.accounts.all_accounts() { + transactions.extend(account.transactions.values()); + } + transactions } - transactions + #[cfg(not(feature = "keep_txs_in_memory"))] + Vec::new() } fn accounts_mut(&mut self) -> &mut ManagedAccountCollection { @@ -245,27 +251,32 @@ impl WalletInfoInterface for ManagedWalletInfo { } fn immature_transactions(&self) -> Vec { - let mut immature_txids: BTreeSet = BTreeSet::new(); - - // Find txids of immature coinbase UTXOs - for account in self.accounts.all_accounts() { - for utxo in account.utxos.values() { - if utxo.is_coinbase && !utxo.is_mature(self.last_processed_height()) { - immature_txids.insert(utxo.outpoint.txid); + #[cfg(feature = "keep_txs_in_memory")] + { + let mut immature_txids: BTreeSet = BTreeSet::new(); + + // Find txids of immature coinbase UTXOs + for account in self.accounts.all_accounts() { + for utxo in account.utxos.values() { + if utxo.is_coinbase && !utxo.is_mature(self.last_processed_height()) { + immature_txids.insert(utxo.outpoint.txid); + } } } - } - // Get the actual transactions - let mut transactions = Vec::new(); - for account in self.accounts.all_accounts() { - for (txid, record) in &account.transactions { - if immature_txids.contains(txid) { - transactions.push(record.transaction.clone()); + // Get the actual transactions + let mut transactions = Vec::new(); + for account in self.accounts.all_accounts() { + for (txid, record) in &account.transactions { + if immature_txids.contains(txid) { + transactions.push(record.transaction.clone()); + } } } + transactions } - transactions + #[cfg(not(feature = "keep_txs_in_memory"))] + Vec::new() } fn update_last_processed_height(&mut self, current_height: u32) { @@ -286,22 +297,27 @@ impl WalletInfoInterface for ManagedWalletInfo { if new_height <= old_height { return Vec::new(); } - let mut matured = Vec::new(); - for account in self.accounts.all_accounts() { - for record in account.transactions.values() { - if !record.transaction.is_coin_base() { - continue; - } - let Some(record_height) = record.height() else { - continue; - }; - let maturity_height = record_height.saturating_add(100); - if maturity_height > old_height && maturity_height <= new_height { - matured.push(record.clone()); + #[cfg(feature = "keep_txs_in_memory")] + { + let mut matured = Vec::new(); + for account in self.accounts.all_accounts() { + for record in account.transactions.values() { + if !record.transaction.is_coin_base() { + continue; + } + let Some(record_height) = record.height() else { + continue; + }; + let maturity_height = record_height.saturating_add(100); + if maturity_height > old_height && maturity_height <= new_height { + matured.push(record.clone()); + } } } + matured } - matured + #[cfg(not(feature = "keep_txs_in_memory"))] + Vec::new() } fn mark_instant_send_utxos(&mut self, txid: &Txid, lock: &InstantLock) -> bool { @@ -313,10 +329,13 @@ impl WalletInfoInterface for ManagedWalletInfo { if account.mark_utxos_instant_send(txid) { any_changed = true; } + #[cfg(feature = "keep_txs_in_memory")] if let Some(record) = account.transactions_mut().get_mut(txid) { record.update_context(TransactionContext::InstantSend(lock.clone())); } } + #[cfg(not(feature = "keep_txs_in_memory"))] + let _ = lock; if any_changed { self.update_balance(); } From 0e6da68827ef36c515e52e0d64566532931e18a2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 3 May 2026 15:41:35 +0700 Subject: [PATCH 3/9] chore(key-wallet): drop keep_txs_in_memory from default features Make in-memory transaction history opt-in. Crates that need the `ManagedCoreAccount::transactions` map enable the feature explicitly; key-wallet-ffi already does so to keep its FFI surface unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- key-wallet-ffi/Cargo.toml | 4 ++-- key-wallet/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/key-wallet-ffi/Cargo.toml b/key-wallet-ffi/Cargo.toml index 3237e0579..e85d647c0 100644 --- a/key-wallet-ffi/Cargo.toml +++ b/key-wallet-ffi/Cargo.toml @@ -20,7 +20,7 @@ eddsa = ["dashcore/eddsa", "key-wallet/eddsa"] bls = ["dashcore/bls", "key-wallet/bls"] [dependencies] -key-wallet = { path = "../key-wallet" } +key-wallet = { path = "../key-wallet", features = ["keep_txs_in_memory"] } key-wallet-manager = { path = "../key-wallet-manager" } dashcore = { path = "../dash" } dash-network = { path = "../dash-network", features = ["ffi"] } @@ -33,6 +33,6 @@ hex = "0.4" cbindgen = "0.29" [dev-dependencies] -key-wallet = { path = "../key-wallet", features = ["test-utils"] } +key-wallet = { path = "../key-wallet", features = ["test-utils", "keep_txs_in_memory"] } key-wallet-manager = { path = "../key-wallet-manager", features = ["test-utils"] } hex = "0.4" diff --git a/key-wallet/Cargo.toml b/key-wallet/Cargo.toml index 4d1b94378..cffbb1e30 100644 --- a/key-wallet/Cargo.toml +++ b/key-wallet/Cargo.toml @@ -9,7 +9,7 @@ readme = "README.md" license = "CC0-1.0" [features] -default = ["getrandom", "keep_txs_in_memory"] +default = ["getrandom"] serde = ["dep:serde", "dep:serde_json", "dashcore_hashes/serde", "secp256k1/serde", "dashcore/serde"] bincode = ["serde", "dep:bincode", "dep:bincode_derive", "dashcore_hashes/bincode", "dashcore/bincode"] bip38 = ["scrypt", "aes", "bs58", "rand"] From 8d4cfa7113e52d280d3c9020c63ea7a0845d345a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 3 May 2026 21:43:54 +0700 Subject: [PATCH 4/9] fix(key-wallet-manager): require keep_txs_in_memory; cargo fmt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit key-wallet-manager's events depend on per-account transaction history (matured coinbase records, mempool→block transitions), so it pulls in the `keep_txs_in_memory` feature on its key-wallet dependency. This also fixes downstream crates (dash-spv) whose tests inspect `account.transactions` directly. Co-Authored-By: Claude Opus 4.7 (1M context) --- key-wallet-manager/Cargo.toml | 2 +- key-wallet/src/test_utils/wallet.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/key-wallet-manager/Cargo.toml b/key-wallet-manager/Cargo.toml index 1b7347c05..1df23c4b4 100644 --- a/key-wallet-manager/Cargo.toml +++ b/key-wallet-manager/Cargo.toml @@ -16,7 +16,7 @@ bls = ["key-wallet/bls"] eddsa = ["key-wallet/eddsa"] [dependencies] -key-wallet = { path = "../key-wallet", default-features = false } +key-wallet = { path = "../key-wallet", default-features = false, features = ["keep_txs_in_memory"] } dashcore = { path = "../dash" } async-trait = "0.1" tokio = { version = "1", features = ["macros", "rt", "sync"] } diff --git a/key-wallet/src/test_utils/wallet.rs b/key-wallet/src/test_utils/wallet.rs index 8678eb7d6..68b8d68d5 100644 --- a/key-wallet/src/test_utils/wallet.rs +++ b/key-wallet/src/test_utils/wallet.rs @@ -1,15 +1,15 @@ -use dashcore::{Address, Network, Transaction}; #[cfg(feature = "keep_txs_in_memory")] use dashcore::Txid; +use dashcore::{Address, Network, Transaction}; +#[cfg(feature = "keep_txs_in_memory")] +use crate::account::TransactionRecord; use crate::{ account::ManagedCoreAccount, transaction_checking::{TransactionCheckResult, TransactionContext, WalletTransactionChecker}, wallet::{initialization::WalletAccountCreationOptions, ManagedWalletInfo}, ExtendedPubKey, Utxo, Wallet, }; -#[cfg(feature = "keep_txs_in_memory")] -use crate::account::TransactionRecord; impl ManagedWalletInfo { pub fn dummy(id: u8) -> Self { From 2d38624f729bf009a19889b8082dacd36fdd7a84 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 3 May 2026 21:58:15 +0700 Subject: [PATCH 5/9] fix(key-wallet): keep dedup working when keep_txs_in_memory is off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit review caught that confirm_transaction's dedup guard (self.transactions.contains_key(&txid)) is always false with the feature off, so re-events (mempool→block, IS-lock arrivals after a block) are treated as brand new every time — replaying record_transaction, re-bumping monitor_revision, and spuriously flipping state_modified. Add a `processed_txids: HashSet` field that's always populated regardless of the feature, and use it as the dedup signal. has_transaction now reads it (so call sites still work in both configs); record_transaction inserts into it; Deserialize rebuilds it from `transactions` keys. Also adds an idempotency regression test (mempool→block on the same tx must not change utxo count or balance and must bump monitor_revision at most once) that runs in both feature configurations. Existing wallet_checker tests that simulated a missing record by removing from `transactions` directly are updated to clear `processed_txids` too (since the dedup signal now lives there). Co-Authored-By: Claude Opus 4.7 (1M context) --- key-wallet/src/managed_account/mod.rs | 92 ++++++++++--------- .../src/tests/keep_txs_in_memory_tests.rs | 90 ++++++++++-------- .../transaction_checking/wallet_checker.rs | 7 +- 3 files changed, 106 insertions(+), 83 deletions(-) diff --git a/key-wallet/src/managed_account/mod.rs b/key-wallet/src/managed_account/mod.rs index 98ccd797b..f6604aa72 100644 --- a/key-wallet/src/managed_account/mod.rs +++ b/key-wallet/src/managed_account/mod.rs @@ -69,6 +69,14 @@ pub struct ManagedCoreAccount { pub transactions: BTreeMap, /// UTXO set for this account pub utxos: BTreeMap, + /// Txids of every transaction this account has already processed. + /// + /// Always populated regardless of `keep_txs_in_memory`, so the + /// dedup guard in `confirm_transaction` works in either feature + /// configuration. Rebuilt from `transactions` during deserialization + /// when the feature is enabled. + #[cfg_attr(feature = "serde", serde(skip_serializing))] + pub(crate) processed_txids: HashSet, /// Outpoints spent by recorded transactions. /// Rebuilt from `transactions` during deserialization. #[cfg_attr(feature = "serde", serde(skip_serializing))] @@ -95,25 +103,20 @@ impl ManagedCoreAccount { #[cfg(feature = "keep_txs_in_memory")] transactions: BTreeMap::new(), utxos: BTreeMap::new(), + processed_txids: HashSet::new(), spent_outpoints: HashSet::new(), monitor_revision: 0, } } - /// Returns `true` if a transaction record for `txid` is stored on this account. + /// Returns `true` if this account has already processed `txid`. /// - /// Always returns `false` when the `keep_txs_in_memory` Cargo feature is - /// disabled, since no records are kept. + /// Backed by an always-present `processed_txids` set, so this works + /// regardless of whether the `keep_txs_in_memory` Cargo feature is + /// enabled. Use [`Self::get_transaction`] to retrieve the full record + /// (only available when the feature is enabled). pub fn has_transaction(&self, txid: &Txid) -> bool { - #[cfg(feature = "keep_txs_in_memory")] - { - self.transactions.contains_key(txid) - } - #[cfg(not(feature = "keep_txs_in_memory"))] - { - let _ = txid; - false - } + self.processed_txids.contains(txid) } /// Returns the stored transaction record for `txid`, if any. @@ -463,9 +466,11 @@ impl ManagedCoreAccount { /// Re-process an existing transaction with updated context (e.g., mempool→block confirmation) /// and potentially new address matches from gap limit rescans. /// - /// When the `keep_txs_in_memory` Cargo feature is disabled, no per-tx - /// records exist, so this always falls through to `record_transaction` - /// and reports the call as a state change. + /// Deduplication uses `processed_txids`, which is populated regardless + /// of the `keep_txs_in_memory` Cargo feature. When the feature is off + /// the per-tx record isn't stored, so we can't detect a confirmation + /// status transition; we still refresh the UTXO state and report no + /// change. pub(crate) fn confirm_transaction( &mut self, tx: &Transaction, @@ -473,39 +478,39 @@ impl ManagedCoreAccount { context: TransactionContext, transaction_type: TransactionType, ) -> bool { + if !self.processed_txids.contains(&tx.txid()) { + self.record_transaction(tx, account_match, context, transaction_type); + return true; + } + + #[cfg_attr(not(feature = "keep_txs_in_memory"), allow(unused_mut))] + let mut changed = false; #[cfg(feature = "keep_txs_in_memory")] - { - if !self.transactions.contains_key(&tx.txid()) { - self.record_transaction(tx, account_match, context, transaction_type); - return true; - } - - let mut changed = false; - if let Some(tx_record) = self.transactions.get_mut(&tx.txid()) { - debug_assert_eq!( - tx_record.transaction_type, - transaction_type, - "transaction_type changed between recordings for {}", - tx.txid() - ); - if tx_record.context != context { - let was_confirmed = tx_record.context.confirmed(); - tx_record.update_context(context.clone()); - // Only signal a change when confirmation status actually changes, - // not for upgrades within the confirmed state (e.g. InBlock → InChainLockedBlock). - // TODO: emit a change event for InBlock → InChainLockedBlock once chainlock - // wallet transaction events are properly handled - changed = !was_confirmed; - } + if let Some(tx_record) = self.transactions.get_mut(&tx.txid()) { + debug_assert_eq!( + tx_record.transaction_type, + transaction_type, + "transaction_type changed between recordings for {}", + tx.txid() + ); + if tx_record.context != context { + let was_confirmed = tx_record.context.confirmed(); + tx_record.update_context(context.clone()); + // Only signal a change when confirmation status actually changes, + // not for upgrades within the confirmed state (e.g. InBlock → InChainLockedBlock). + // TODO: emit a change event for InBlock → InChainLockedBlock once chainlock + // wallet transaction events are properly handled + changed = !was_confirmed; } - self.update_utxos(tx, account_match, context); - changed } #[cfg(not(feature = "keep_txs_in_memory"))] { - self.record_transaction(tx, account_match, context, transaction_type); - true + // No record stored — silence the unused-binding warning on + // `transaction_type` and let UTXO state convey the upgrade. + let _ = transaction_type; } + self.update_utxos(tx, account_match, context); + changed } /// Record a new transaction and update UTXOs for spendable account types @@ -610,6 +615,7 @@ impl ManagedCoreAccount { ); let record = tx_record.clone(); + self.processed_txids.insert(tx.txid()); #[cfg(feature = "keep_txs_in_memory")] self.transactions.insert(tx.txid(), tx_record); @@ -1412,6 +1418,7 @@ impl<'de> Deserialize<'de> for ManagedCoreAccount { let helper = Helper::deserialize(deserializer)?; + let processed_txids: HashSet = helper.transactions.keys().copied().collect(); let spent_outpoints = helper .transactions .values() @@ -1428,6 +1435,7 @@ impl<'de> Deserialize<'de> for ManagedCoreAccount { #[cfg(feature = "keep_txs_in_memory")] transactions: helper.transactions, utxos: helper.utxos, + processed_txids, spent_outpoints, monitor_revision: 0, }) diff --git a/key-wallet/src/tests/keep_txs_in_memory_tests.rs b/key-wallet/src/tests/keep_txs_in_memory_tests.rs index 416302bef..5181e09a8 100644 --- a/key-wallet/src/tests/keep_txs_in_memory_tests.rs +++ b/key-wallet/src/tests/keep_txs_in_memory_tests.rs @@ -3,11 +3,14 @@ //! When the feature is enabled (the default), `ManagedCoreAccount` keeps a //! per-account `transactions` map populated as transactions are processed. //! When the feature is disabled, the field does not exist and processing a -//! transaction only updates UTXOs and balance. +//! transaction only updates UTXOs and balance — but `processed_txids` +//! still tracks which txids have been seen so `confirm_transaction` +//! continues to deduplicate re-events. use crate::test_utils::TestWalletContext; -use crate::transaction_checking::TransactionContext; -use dashcore::Transaction; +use crate::transaction_checking::{BlockInfo, TransactionContext}; +use dashcore::{BlockHash, Transaction}; +use dashcore_hashes::Hash; #[tokio::test] async fn record_transaction_updates_utxos_and_balance() { @@ -45,47 +48,54 @@ async fn transactions_are_not_stored_when_feature_disabled() { let _ = ctx.check_transaction(&tx, TransactionContext::Mempool).await; let account = ctx.bip44_account(); - assert!(!account.has_transaction(&tx.txid())); + // No record is stored, so `get_transaction` returns None ... assert!(account.get_transaction(&tx.txid()).is_none()); + // ... but `has_transaction` still returns true so re-events dedupe. + assert!(account.has_transaction(&tx.txid())); } -#[cfg(feature = "keep_txs_in_memory")] -#[test] -fn helper_methods_proxy_transactions_field() { - use crate::account::{StandardAccountType, TransactionRecord}; - use crate::managed_account::transaction_record::TransactionDirection; - use crate::managed_account::ManagedCoreAccount; - use crate::transaction_checking::TransactionType; - use crate::AccountType; - use dashcore::TxIn; +/// Re-processing the same transaction (mempool → block) must not double-count +/// UTXOs or change balance, regardless of whether the `keep_txs_in_memory` +/// feature is enabled. Without the always-present `processed_txids` set this +/// regresses when the feature is off because `confirm_transaction`'s only +/// dedup signal disappears with the `transactions` map. +#[tokio::test] +async fn confirm_transaction_dedupes_repeat_events() { + let mut ctx = TestWalletContext::new_random(); - let mut account = ManagedCoreAccount::dummy_bip44(); - let tx = Transaction { - version: 1, - lock_time: 0, - input: vec![TxIn::default()], - output: Vec::new(), - special_transaction_payload: None, - }; - let record = TransactionRecord::new( - tx.clone(), - AccountType::Standard { - index: 0, - standard_account_type: StandardAccountType::BIP44Account, - }, - TransactionContext::Mempool, - TransactionType::Standard, - TransactionDirection::Incoming, - Vec::new(), - Vec::new(), - 0, - ); - let txid = tx.txid(); + let tx = Transaction::dummy(&ctx.receive_address, 0..1, &[200_000]); + + // First: mempool + let r1 = ctx.check_transaction(&tx, TransactionContext::Mempool).await; + assert!(r1.is_relevant); + assert!(r1.is_new_transaction); + let utxo_count_after_mempool = ctx.bip44_account().utxos.len(); + let balance_after_mempool = ctx.bip44_account().balance.spendable(); + let revision_after_mempool = ctx.bip44_account().monitor_revision(); - assert!(!account.has_transaction(&txid)); - assert!(account.get_transaction(&txid).is_none()); + // Second: same tx confirmed in a block + let block = TransactionContext::InBlock(BlockInfo::new( + 100, + BlockHash::from_slice(&[7u8; 32]).unwrap(), + 1_700_000_000, + )); + let r2 = ctx.check_transaction(&tx, block).await; + assert!(r2.is_relevant); + assert!(!r2.is_new_transaction, "block confirmation must not be flagged as a new tx"); - account.transactions.insert(txid, record); - assert!(account.has_transaction(&txid)); - assert!(account.get_transaction(&txid).is_some()); + let account = ctx.bip44_account(); + assert_eq!( + account.utxos.len(), + utxo_count_after_mempool, + "UTXO count must not grow on confirmation" + ); + assert_eq!( + account.balance.spendable(), + balance_after_mempool, + "spendable balance must not change between mempool and block confirmation" + ); + // monitor_revision can advance once for the block-context UTXO refresh, + // but it must not balloon to N per replay. + let bumped_by = account.monitor_revision().saturating_sub(revision_after_mempool); + assert!(bumped_by <= 1, "monitor_revision bumped {bumped_by} times on a single confirmation"); } diff --git a/key-wallet/src/transaction_checking/wallet_checker.rs b/key-wallet/src/transaction_checking/wallet_checker.rs index 5dda2e2dc..a62ea5e77 100644 --- a/key-wallet/src/transaction_checking/wallet_checker.rs +++ b/key-wallet/src/transaction_checking/wallet_checker.rs @@ -1139,13 +1139,17 @@ mod tests { let (mut ctx, tx) = TestWalletContext::new_random().with_mempool_funding(300_000).await; let txid = tx.txid(); - // Simulate the account missing the mempool record by removing it + // Simulate the account missing the mempool record by removing it. + // Clear `processed_txids` too — otherwise the always-present dedup + // set would keep `has_transaction` truthy and the wallet checker + // wouldn't treat this confirmation as new. let account = ctx .managed_wallet .first_bip44_managed_account_mut() .expect("Should have BIP44 account"); assert!(account.transactions.contains_key(&txid)); account.transactions.remove(&txid); + account.processed_txids.remove(&txid); assert!(!account.transactions.contains_key(&txid)); // Now process the same tx as a block confirmation. @@ -1195,6 +1199,7 @@ mod tests { .first_bip44_managed_account_mut() .expect("Should have BIP44 account"); account.transactions.remove(&txid); + account.processed_txids.remove(&txid); account.utxos.clear(); assert!(!account.transactions.contains_key(&txid)); assert!(account.utxos.is_empty()); From b6622959a928b7acb1c369de4a8436ed3a6a338d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 3 May 2026 22:31:06 +0700 Subject: [PATCH 6/9] refactor(key-wallet): split ManagedCoreAccount into funds + keys variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the existing struct out of `managed_account/mod.rs` into a dedicated `managed_core_funds_account.rs` and rename it to `ManagedCoreFundsAccount` to make room for a sibling `ManagedCoreKeysAccount` that omits funds-tracking state (`balance`, `utxos`, `spent_outpoints`). The keys variant is intended for accounts that derive special-purpose keys (identity registration, asset locks, masternode provider keys) but is not yet wired into `ManagedAccountCollection` — that migration follows in a subsequent PR. This change is purely structural: `ManagedAccountCollection` continues to store `ManagedCoreFundsAccount` for every account type and behavior is unchanged. The C ABI is preserved — `FFIManagedCoreAccount` and the `managed_core_account_*` C functions keep their original names and now wrap the renamed Rust struct internally. Co-Authored-By: Claude Opus 4.7 (1M context) --- dash-spv/src/sync/mempool/sync_manager.rs | 2 +- key-wallet-ffi/src/address_pool.rs | 6 +- key-wallet-ffi/src/managed_account.rs | 8 +- key-wallet-ffi/src/managed_wallet.rs | 4 +- key-wallet-ffi/src/utxo_tests.rs | 16 +- key-wallet/CLAUDE.md | 4 +- key-wallet/IMPLEMENTATION_SUMMARY.md | 2 +- key-wallet/README.md | 8 +- key-wallet/src/account/mod.rs | 2 +- .../managed_account_collection.rs | 64 +- .../managed_core_funds_account.rs | 1374 ++++++++++++++++ .../managed_core_keys_account.rs | 679 ++++++++ .../managed_platform_account.rs | 4 +- key-wallet/src/managed_account/mod.rs | 1385 +---------------- key-wallet/src/test_utils/account.rs | 6 +- key-wallet/src/test_utils/wallet.rs | 4 +- key-wallet/src/tests/balance_tests.rs | 10 +- key-wallet/src/tests/spent_outpoints_tests.rs | 22 +- .../transaction_checking/account_checker.rs | 6 +- .../transaction_checking/wallet_checker.rs | 2 +- .../managed_wallet_info/asset_lock_builder.rs | 4 +- .../src/wallet/managed_wallet_info/helpers.rs | 64 +- .../managed_wallet_info/managed_accounts.rs | 14 +- 23 files changed, 2195 insertions(+), 1495 deletions(-) create mode 100644 key-wallet/src/managed_account/managed_core_funds_account.rs create mode 100644 key-wallet/src/managed_account/managed_core_keys_account.rs diff --git a/dash-spv/src/sync/mempool/sync_manager.rs b/dash-spv/src/sync/mempool/sync_manager.rs index aef25554f..8038d8247 100644 --- a/dash-spv/src/sync/mempool/sync_manager.rs +++ b/dash-spv/src/sync/mempool/sync_manager.rs @@ -123,7 +123,7 @@ impl SyncManager for MempoolManager { // Rebuild bloom filter if the wallet's monitored set has changed. // // We poll the revision counter rather than using push-based wallet events - // for simplicity: the revision lives on `ManagedCoreAccount` and auto-bumps + // for simplicity: the revision lives on `ManagedCoreFundsAccount` and auto-bumps // on address generation and UTXO mutations, giving us a single source of // truth without needing event emission after every wallet operation. // Adding a push-based approach would require a new `select!` branch in the diff --git a/key-wallet-ffi/src/address_pool.rs b/key-wallet-ffi/src/address_pool.rs index fcde7c022..728a24d6a 100644 --- a/key-wallet-ffi/src/address_pool.rs +++ b/key-wallet-ffi/src/address_pool.rs @@ -15,14 +15,14 @@ use key_wallet::account::ManagedAccountCollection; use key_wallet::managed_account::address_pool::{ AddressInfo, AddressPool, KeySource, PublicKeyType, }; -use key_wallet::managed_account::ManagedCoreAccount; +use key_wallet::managed_account::ManagedCoreFundsAccount; use key_wallet::AccountType; // Helper functions to get managed accounts by type fn get_managed_account_by_type<'a>( collection: &'a ManagedAccountCollection, account_type: &AccountType, -) -> Option<&'a ManagedCoreAccount> { +) -> Option<&'a ManagedCoreFundsAccount> { match account_type { AccountType::Standard { index, @@ -75,7 +75,7 @@ fn get_managed_account_by_type<'a>( fn get_managed_account_by_type_mut<'a>( collection: &'a mut ManagedAccountCollection, account_type: &AccountType, -) -> Option<&'a mut ManagedCoreAccount> { +) -> Option<&'a mut ManagedCoreFundsAccount> { match account_type { AccountType::Standard { index, diff --git a/key-wallet-ffi/src/managed_account.rs b/key-wallet-ffi/src/managed_account.rs index 38b54fe5a..1c47c95ee 100644 --- a/key-wallet-ffi/src/managed_account.rs +++ b/key-wallet-ffi/src/managed_account.rs @@ -22,25 +22,25 @@ use key_wallet::account::account_collection::{DashpayAccountKey, PlatformPayment use key_wallet::account::TransactionRecord; use key_wallet::managed_account::address_pool::AddressPool; use key_wallet::managed_account::managed_platform_account::ManagedPlatformAccount; -use key_wallet::managed_account::ManagedCoreAccount; +use key_wallet::managed_account::ManagedCoreFundsAccount; use key_wallet::AccountType; /// Opaque managed account handle that wraps ManagedAccount pub struct FFIManagedCoreAccount { /// The underlying managed account - pub(crate) account: Arc, + pub(crate) account: Arc, } impl FFIManagedCoreAccount { /// Create a new FFI managed account handle - pub fn new(account: &ManagedCoreAccount) -> Self { + pub fn new(account: &ManagedCoreFundsAccount) -> Self { FFIManagedCoreAccount { account: Arc::new(account.clone()), } } /// Get a reference to the inner managed account - pub fn inner(&self) -> &ManagedCoreAccount { + pub fn inner(&self) -> &ManagedCoreFundsAccount { self.account.as_ref() } } diff --git a/key-wallet-ffi/src/managed_wallet.rs b/key-wallet-ffi/src/managed_wallet.rs index dc155298c..d1c9801e4 100644 --- a/key-wallet-ffi/src/managed_wallet.rs +++ b/key-wallet-ffi/src/managed_wallet.rs @@ -554,7 +554,7 @@ mod tests { #[test] fn test_comprehensive_address_generation() { use key_wallet::account::{ - ManagedAccountCollection, ManagedCoreAccount, StandardAccountType, + ManagedAccountCollection, ManagedCoreFundsAccount, StandardAccountType, }; use key_wallet::bip32::DerivationPath; use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType}; @@ -608,7 +608,7 @@ mod tests { ) .expect("Failed to create internal pool"); - let managed_account = ManagedCoreAccount::new( + let managed_account = ManagedCoreFundsAccount::new( ManagedAccountType::Standard { index: 0, standard_account_type: StandardAccountType::BIP44Account, diff --git a/key-wallet-ffi/src/utxo_tests.rs b/key-wallet-ffi/src/utxo_tests.rs index 6b8fc35cc..05cd85bde 100644 --- a/key-wallet-ffi/src/utxo_tests.rs +++ b/key-wallet-ffi/src/utxo_tests.rs @@ -175,7 +175,7 @@ mod utxo_tests { use dashcore::blockdata::script::ScriptBuf; use dashcore::{Address, OutPoint, TxOut, Txid}; use key_wallet::account::account_type::StandardAccountType; - use key_wallet::managed_account::ManagedCoreAccount; + use key_wallet::managed_account::ManagedCoreFundsAccount; use key_wallet::utxo::Utxo; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::Network; @@ -189,7 +189,7 @@ mod utxo_tests { let mut managed_info = ManagedWalletInfo::new(Network::Testnet, [1u8; 32]); // Create a BIP44 account with UTXOs - let mut bip44_account = ManagedCoreAccount::new( + let mut bip44_account = ManagedCoreFundsAccount::new( ManagedAccountType::Standard { index: 0, standard_account_type: StandardAccountType::BIP44Account, @@ -280,7 +280,7 @@ mod utxo_tests { fn test_managed_wallet_get_utxos_multiple_accounts() { use crate::managed_wallet::FFIManagedWalletInfo; use key_wallet::account::account_type::StandardAccountType; - use key_wallet::managed_account::ManagedCoreAccount; + use key_wallet::managed_account::ManagedCoreFundsAccount; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::Network; @@ -292,7 +292,7 @@ mod utxo_tests { let mut managed_info = ManagedWalletInfo::new(Network::Testnet, [2u8; 32]); // Create BIP44 account with 2 UTXOs - let mut bip44_account = ManagedCoreAccount::new( + let mut bip44_account = ManagedCoreFundsAccount::new( ManagedAccountType::Standard { index: 0, standard_account_type: StandardAccountType::BIP44Account, @@ -318,7 +318,7 @@ mod utxo_tests { managed_info.accounts.insert(bip44_account).unwrap(); // Create BIP32 account with 1 UTXO - let mut bip32_account = ManagedCoreAccount::new( + let mut bip32_account = ManagedCoreFundsAccount::new( ManagedAccountType::Standard { index: 0, standard_account_type: StandardAccountType::BIP32Account, @@ -344,7 +344,7 @@ mod utxo_tests { managed_info.accounts.insert(bip32_account).unwrap(); // Create CoinJoin account with 2 UTXOs - let mut coinjoin_account = ManagedCoreAccount::new( + let mut coinjoin_account = ManagedCoreFundsAccount::new( ManagedAccountType::CoinJoin { index: 0, addresses: key_wallet::managed_account::address_pool::AddressPoolBuilder::default() @@ -383,7 +383,7 @@ mod utxo_tests { fn test_managed_wallet_get_utxos() { use crate::managed_wallet::FFIManagedWalletInfo; use key_wallet::account::account_type::StandardAccountType; - use key_wallet::managed_account::ManagedCoreAccount; + use key_wallet::managed_account::ManagedCoreFundsAccount; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::Network; @@ -396,7 +396,7 @@ mod utxo_tests { let mut managed_info = ManagedWalletInfo::new(Network::Testnet, [3u8; 32]); // Add a UTXO to Testnet account - let mut testnet_account = ManagedCoreAccount::new( + let mut testnet_account = ManagedCoreFundsAccount::new( ManagedAccountType::Standard { index: 0, standard_account_type: StandardAccountType::BIP44Account, diff --git a/key-wallet/CLAUDE.md b/key-wallet/CLAUDE.md index 54eed21f3..662825e88 100644 --- a/key-wallet/CLAUDE.md +++ b/key-wallet/CLAUDE.md @@ -58,7 +58,7 @@ Account (Immutable) ├── account_xpub: ExtendedPubKey └── is_watch_only: bool -ManagedCoreAccount (Mutable) +ManagedCoreFundsAccount (Mutable) ├── account_type: ManagedAccountType (contains address pools) ├── metadata: AccountMetadata ├── balance: WalletCoreBalance @@ -202,7 +202,7 @@ Tests are organized by functionality: let account_xpriv = derive_account_key(master, account_type)?; let account_xpub = ExtendedPubKey::from_priv(&secp, &account_xpriv); let account = Account::new(wallet_id, account_type, account_xpub, network)?; -let managed_account = ManagedCoreAccount::from_account(&account); +let managed_account = ManagedCoreFundsAccount::from_account(&account); ``` ### 2. Address Generation Pattern diff --git a/key-wallet/IMPLEMENTATION_SUMMARY.md b/key-wallet/IMPLEMENTATION_SUMMARY.md index 8e4454774..d38c31301 100644 --- a/key-wallet/IMPLEMENTATION_SUMMARY.md +++ b/key-wallet/IMPLEMENTATION_SUMMARY.md @@ -180,7 +180,7 @@ let account = wallet.create_account( )?; // Wrap in a managed account for mutable operations -let mut managed = ManagedCoreAccount::from_account(&account); +let mut managed = ManagedCoreFundsAccount::from_account(&account); // Get a receive address let address = managed.get_next_receive_address()?; diff --git a/key-wallet/README.md b/key-wallet/README.md index cd2f9785e..8e0d3a880 100644 --- a/key-wallet/README.md +++ b/key-wallet/README.md @@ -94,7 +94,7 @@ println!("Wallet ID: {:?}", hex::encode(wallet.wallet_id)); ```rust use key_wallet::account::{Account, AccountType, StandardAccountType}; -use key_wallet::managed_account::ManagedCoreAccount; +use key_wallet::managed_account::ManagedCoreFundsAccount; // Create a standard BIP44 account let account = wallet.create_account( @@ -106,7 +106,7 @@ let account = wallet.create_account( )?; // Convert to managed account for mutable operations -let mut managed_account = ManagedCoreAccount::from_account(&account); +let mut managed_account = ManagedCoreFundsAccount::from_account(&account); // Generate receive addresses let addresses = managed_account.generate_receive_addresses(10)?; @@ -218,7 +218,7 @@ if result.is_relevant { - `Wallet`: Main wallet structure managing accounts and keys - `Account`: Individual account within a wallet -- `ManagedCoreAccount`: Mutable account with address pools and metadata +- `ManagedCoreFundsAccount`: Mutable account with address pools and metadata - `Mnemonic`: BIP39 mnemonic phrase handling - `ExtendedPrivKey`/`ExtendedPubKey`: BIP32 extended keys - `DerivationPath`: HD wallet derivation paths @@ -240,7 +240,7 @@ if result.is_relevant { ### From v0.39 to v0.40 -- Account structure split into immutable `Account` and mutable `ManagedCoreAccount` +- Account structure split into immutable `Account` and mutable `ManagedCoreFundsAccount` - New transaction checking system with optimized routing - Enhanced address pool management with pre-generation support - Improved gap limit tracking with staged discovery diff --git a/key-wallet/src/account/mod.rs b/key-wallet/src/account/mod.rs index 177a42747..b1581a7ba 100644 --- a/key-wallet/src/account/mod.rs +++ b/key-wallet/src/account/mod.rs @@ -38,7 +38,7 @@ pub use crate::managed_account::metadata::AccountMetadata; pub use crate::managed_account::transaction_record::{ InputDetail, OutputDetail, OutputRole, TransactionDirection, TransactionRecord, }; -pub use crate::managed_account::ManagedCoreAccount; +pub use crate::managed_account::ManagedCoreFundsAccount; pub use account_collection::AccountCollection; pub use account_trait::AccountTrait; pub use account_type::{AccountType, StandardAccountType}; diff --git a/key-wallet/src/managed_account/managed_account_collection.rs b/key-wallet/src/managed_account/managed_account_collection.rs index 307b30bd8..748b8ea76 100644 --- a/key-wallet/src/managed_account/managed_account_collection.rs +++ b/key-wallet/src/managed_account/managed_account_collection.rs @@ -14,7 +14,7 @@ use crate::gap_limit::{ use crate::managed_account::address_pool::{AddressPool, AddressPoolType}; use crate::managed_account::managed_account_type::ManagedAccountType; use crate::managed_account::managed_platform_account::ManagedPlatformAccount; -use crate::managed_account::ManagedCoreAccount; +use crate::managed_account::ManagedCoreFundsAccount; use crate::transaction_checking::account_checker::CoreAccountTypeMatch; use crate::{Account, AccountCollection}; use crate::{KeySource, Network}; @@ -113,35 +113,35 @@ macro_rules! get_by_account_type_match_impl { #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub struct ManagedAccountCollection { /// Standard BIP44 accounts by index - pub standard_bip44_accounts: BTreeMap, + pub standard_bip44_accounts: BTreeMap, /// Standard BIP32 accounts by index - pub standard_bip32_accounts: BTreeMap, + pub standard_bip32_accounts: BTreeMap, /// CoinJoin accounts by index - pub coinjoin_accounts: BTreeMap, + pub coinjoin_accounts: BTreeMap, /// Identity registration account (optional) - pub identity_registration: Option, + pub identity_registration: Option, /// Identity top-up accounts by registration index - pub identity_topup: BTreeMap, + pub identity_topup: BTreeMap, /// Identity top-up not bound to identity (optional) - pub identity_topup_not_bound: Option, + pub identity_topup_not_bound: Option, /// Identity invitation account (optional) - pub identity_invitation: Option, + pub identity_invitation: Option, /// Asset lock address top-up account (optional) - pub asset_lock_address_topup: Option, + pub asset_lock_address_topup: Option, /// Asset lock shielded address top-up account (optional) - pub asset_lock_shielded_address_topup: Option, + pub asset_lock_shielded_address_topup: Option, /// Provider voting keys (optional) - pub provider_voting_keys: Option, + pub provider_voting_keys: Option, /// Provider owner keys (optional) - pub provider_owner_keys: Option, + pub provider_owner_keys: Option, /// Provider operator keys (optional) - pub provider_operator_keys: Option, + pub provider_operator_keys: Option, /// Provider platform keys (optional) - pub provider_platform_keys: Option, + pub provider_platform_keys: Option, /// DashPay receiving funds accounts keyed by (index, user_id, friend_id) - pub dashpay_receival_accounts: BTreeMap, + pub dashpay_receival_accounts: BTreeMap, /// DashPay external accounts keyed by (index, user_id, friend_id) - pub dashpay_external_accounts: BTreeMap, + pub dashpay_external_accounts: BTreeMap, /// Platform Payment accounts (DIP-17) /// Uses ManagedPlatformAccount for simplified balance tracking without transactions/UTXOs pub platform_payment_accounts: BTreeMap, @@ -266,7 +266,7 @@ impl ManagedAccountCollection { /// /// Returns an error if a PlatformPayment account type is passed, since those /// should use `insert_platform_account()` with `ManagedPlatformAccount` instead. - pub fn insert(&mut self, account: ManagedCoreAccount) -> Result<(), crate::error::Error> { + pub fn insert(&mut self, account: ManagedCoreFundsAccount) -> Result<(), crate::error::Error> { use crate::account::StandardAccountType; match &account.managed_account_type { @@ -369,7 +369,7 @@ impl ManagedAccountCollection { .. } => { // Platform Payment accounts should use insert_platform_account() instead - // as they use ManagedPlatformAccount, not ManagedCoreAccount + // as they use ManagedPlatformAccount, not ManagedCoreFundsAccount return Err(crate::error::Error::InvalidParameter( "Use insert_platform_account() for Platform Payment accounts".into(), )); @@ -507,7 +507,7 @@ impl ManagedAccountCollection { /// Create a ManagedAccount from an Account fn create_managed_account_from_account( account: &Account, - ) -> Result { + ) -> Result { // Use the account's existing public key let key_source = KeySource::Public(account.account_xpub); Self::create_managed_account_from_account_type( @@ -521,8 +521,8 @@ impl ManagedAccountCollection { /// Create a ManagedAccount from a BLS Account #[cfg(feature = "bls")] fn create_managed_account_from_bls_account( - account: &super::BLSAccount, - ) -> Result { + account: &crate::account::BLSAccount, + ) -> Result { let key_source = KeySource::BLSPublic(account.bls_public_key.clone()); Self::create_managed_account_from_account_type( account.account_type, @@ -535,9 +535,9 @@ impl ManagedAccountCollection { /// Create a ManagedAccount from an EdDSA Account #[cfg(feature = "eddsa")] fn create_managed_account_from_eddsa_account( - account: &super::EdDSAAccount, + account: &crate::account::EdDSAAccount, xpriv: Option, - ) -> Result { + ) -> Result { // EdDSA requires hardened derivation, so we need the private key to generate addresses let key_source = match xpriv { Some(priv_key) => KeySource::EdDSAPrivate(priv_key), @@ -557,7 +557,7 @@ impl ManagedAccountCollection { network: Network, is_watch_only: bool, key_source: &KeySource, - ) -> Result { + ) -> Result { // Get the derivation path for this account type let base_path = account_type .derivation_path(network) @@ -785,7 +785,7 @@ impl ManagedAccountCollection { } }; - Ok(ManagedCoreAccount::new(managed_type, network, is_watch_only)) + Ok(ManagedCoreFundsAccount::new(managed_type, network, is_watch_only)) } /// Create a ManagedPlatformAccount from an Account for Platform Payment accounts @@ -819,7 +819,7 @@ impl ManagedAccountCollection { )) } - pub fn get(&self, index: u32) -> Option<&ManagedCoreAccount> { + pub fn get(&self, index: u32) -> Option<&ManagedCoreFundsAccount> { // Try standard BIP44 first if let Some(account) = self.standard_bip44_accounts.get(&index) { return Some(account); @@ -844,7 +844,7 @@ impl ManagedAccountCollection { } /// Get a mutable account by index - pub fn get_mut(&mut self, index: u32) -> Option<&mut ManagedCoreAccount> { + pub fn get_mut(&mut self, index: u32) -> Option<&mut ManagedCoreFundsAccount> { // Try standard BIP44 first if let Some(account) = self.standard_bip44_accounts.get_mut(&index) { return Some(account); @@ -872,7 +872,7 @@ impl ManagedAccountCollection { pub fn get_by_account_type_match( &self, account_type_match: &CoreAccountTypeMatch, - ) -> Option<&ManagedCoreAccount> { + ) -> Option<&ManagedCoreFundsAccount> { get_by_account_type_match_impl!(self, account_type_match, get, as_ref, values) } @@ -880,12 +880,12 @@ impl ManagedAccountCollection { pub fn get_by_account_type_match_mut( &mut self, account_type_match: &CoreAccountTypeMatch, - ) -> Option<&mut ManagedCoreAccount> { + ) -> Option<&mut ManagedCoreFundsAccount> { get_by_account_type_match_impl!(self, account_type_match, get_mut, as_mut, values_mut) } /// Remove an account from the collection - pub fn remove(&mut self, index: u32) -> Option { + pub fn remove(&mut self, index: u32) -> Option { // Try standard BIP44 first if let Some(account) = self.standard_bip44_accounts.remove(&index) { return Some(account); @@ -935,7 +935,7 @@ impl ManagedAccountCollection { } /// Get all accounts - pub fn all_accounts(&self) -> Vec<&ManagedCoreAccount> { + pub fn all_accounts(&self) -> Vec<&ManagedCoreFundsAccount> { let mut accounts = Vec::new(); // Add standard BIP44 accounts @@ -994,7 +994,7 @@ impl ManagedAccountCollection { } /// Get all accounts mutably - pub fn all_accounts_mut(&mut self) -> Vec<&mut ManagedCoreAccount> { + pub fn all_accounts_mut(&mut self) -> Vec<&mut ManagedCoreFundsAccount> { let mut accounts = Vec::new(); // Add standard BIP44 accounts diff --git a/key-wallet/src/managed_account/managed_core_funds_account.rs b/key-wallet/src/managed_account/managed_core_funds_account.rs new file mode 100644 index 000000000..41efe959f --- /dev/null +++ b/key-wallet/src/managed_account/managed_core_funds_account.rs @@ -0,0 +1,1374 @@ +//! Managed core funds account with mutable state including balance and UTXOs +//! +//! This module contains the mutable account state that changes during wallet operation, +//! kept separate from the immutable Account structure. Used for accounts that hold and +//! spend funds (Standard, CoinJoin, DashPay). + +use crate::account::AccountMetadata; +#[cfg(feature = "bls")] +use crate::account::BLSAccount; +#[cfg(feature = "eddsa")] +use crate::account::EdDSAAccount; +use crate::account::ManagedAccountTrait; +use crate::account::TransactionRecord; +#[cfg(feature = "bls")] +use crate::derivation_bls_bip32::ExtendedBLSPubKey; +#[cfg(any(feature = "bls", feature = "eddsa"))] +use crate::managed_account::address_pool::PublicKeyType; +use crate::managed_account::managed_account_type::ManagedAccountType; +use crate::managed_account::transaction_record::{ + InputDetail, OutputDetail, OutputRole, TransactionDirection, +}; +use crate::managed_account::address_pool; +use crate::transaction_checking::transaction_router::TransactionType; +use crate::transaction_checking::{AccountMatch, TransactionContext}; +use crate::utxo::Utxo; +use crate::wallet::balance::WalletCoreBalance; +#[cfg(feature = "eddsa")] +use crate::AddressInfo; +use crate::{ExtendedPubKey, Network}; +use dashcore::blockdata::transaction::OutPoint; +use dashcore::{Address, ScriptBuf}; +use dashcore::{Transaction, Txid}; +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::collections::{BTreeSet, HashSet}; + +/// Managed core funds account with mutable state +/// +/// This struct contains the mutable state of an account including address pools, +/// metadata, balance, UTXO set, and transaction history. It is managed separately +/// from the immutable Account structure and is used for accounts that hold and +/// spend funds (Standard, CoinJoin, DashPay). +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(Serialize))] +pub struct ManagedCoreFundsAccount { + /// Account type with embedded address pools and index + pub managed_account_type: ManagedAccountType, + /// Network this account belongs to + pub network: Network, + /// Account metadata + pub metadata: AccountMetadata, + /// Whether this is a watch-only account + pub is_watch_only: bool, + /// Account balance information + pub balance: WalletCoreBalance, + /// Transaction history for this account + pub transactions: BTreeMap, + /// UTXO set for this account + pub utxos: BTreeMap, + /// Outpoints spent by recorded transactions. + /// Rebuilt from `transactions` during deserialization. + #[cfg_attr(feature = "serde", serde(skip_serializing))] + spent_outpoints: HashSet, + /// Revision counter incremented when the monitored address set changes + /// (e.g. new addresses generated). Used to detect bloom filter staleness. + #[cfg_attr(feature = "serde", serde(skip_serializing))] + monitor_revision: u64, +} + +impl ManagedCoreFundsAccount { + /// Create a new managed account + pub fn new( + managed_account_type: ManagedAccountType, + network: Network, + is_watch_only: bool, + ) -> Self { + Self { + managed_account_type, + network, + metadata: AccountMetadata::default(), + is_watch_only, + balance: WalletCoreBalance::default(), + transactions: BTreeMap::new(), + utxos: BTreeMap::new(), + spent_outpoints: HashSet::new(), + monitor_revision: 0, + } + } + + /// Return the current monitor revision. + pub fn monitor_revision(&self) -> u64 { + self.monitor_revision + } + + /// Increment the monitor revision to signal that the monitored address set changed. + pub fn bump_monitor_revision(&mut self) { + self.monitor_revision += 1; + } + + /// Check if an outpoint was spent by a previously recorded transaction. + fn is_outpoint_spent(&self, outpoint: &OutPoint) -> bool { + self.spent_outpoints.contains(outpoint) + } + + /// Create a ManagedAccount from an Account + pub fn from_account(account: &super::super::Account) -> Self { + // Use the account's public key as the key source + let key_source = address_pool::KeySource::Public(account.account_xpub); + let managed_type = ManagedAccountType::from_account_type( + account.account_type, + account.network, + &key_source, + ) + .unwrap_or_else(|_| { + // Fallback: create without pre-generated addresses + let no_key_source = address_pool::KeySource::NoKeySource; + ManagedAccountType::from_account_type( + account.account_type, + account.network, + &no_key_source, + ) + .expect("Should succeed with NoKeySource") + }); + + Self::new(managed_type, account.network, account.is_watch_only) + } + + /// Create a ManagedAccount from a BLS Account + #[cfg(feature = "bls")] + pub fn from_bls_account(account: &BLSAccount) -> Self { + // Use the BLS public key as the key source + let key_source = address_pool::KeySource::BLSPublic(account.bls_public_key.clone()); + let managed_type = ManagedAccountType::from_account_type( + account.account_type, + account.network, + &key_source, + ) + .unwrap_or_else(|_| { + // Fallback: create without pre-generated addresses + let no_key_source = address_pool::KeySource::NoKeySource; + ManagedAccountType::from_account_type( + account.account_type, + account.network, + &no_key_source, + ) + .expect("Should succeed with NoKeySource") + }); + + Self::new(managed_type, account.network, account.is_watch_only) + } + + /// Create a ManagedAccount from an EdDSA Account + #[cfg(feature = "eddsa")] + pub fn from_eddsa_account(account: &EdDSAAccount) -> Self { + // EdDSA requires hardened derivation, so we can't generate addresses without private key + let key_source = address_pool::KeySource::NoKeySource; + let managed_type = ManagedAccountType::from_account_type( + account.account_type, + account.network, + &key_source, + ) + .expect("Should succeed with NoKeySource"); + + Self::new(managed_type, account.network, account.is_watch_only) + } + + /// Get the account index + pub fn index(&self) -> Option { + self.managed_account_type.index() + } + + /// Get the account index or 0 if none exists + pub fn index_or_default(&self) -> u32 { + self.managed_account_type.index_or_default() + } + + /// Get the managed account type + pub fn managed_type(&self) -> &ManagedAccountType { + &self.managed_account_type + } + + /// Get the next unused receive address index for standard accounts + /// Note: This requires a key source which is not available in ManagedAccount + /// Address generation should be done through a method that has access to the Account's keys + pub fn get_next_receive_address_index(&self) -> Option { + // Only applicable for standard accounts + if let ManagedAccountType::Standard { + external_addresses, + .. + } = &self.managed_account_type + { + // Get the first unused address or the next index after the last used one + if let Some(addr) = external_addresses.unused_addresses().first() { + external_addresses.address_index(addr) + } else { + // If no unused addresses, return the next index based on stats + let stats = external_addresses.stats(); + Some(stats.highest_generated.map(|h| h + 1).unwrap_or(0)) + } + } else { + None + } + } + + /// Get the next unused change address index for standard accounts + /// Note: This requires a key source which is not available in ManagedAccount + /// Address generation should be done through a method that has access to the Account's keys + pub fn get_next_change_address_index(&self) -> Option { + // Only applicable for standard accounts + if let ManagedAccountType::Standard { + internal_addresses, + .. + } = &self.managed_account_type + { + // Get the first unused address or the next index after the last used one + if let Some(addr) = internal_addresses.unused_addresses().first() { + internal_addresses.address_index(addr) + } else { + // If no unused addresses, return the next index based on stats + let stats = internal_addresses.stats(); + Some(stats.highest_generated.map(|h| h + 1).unwrap_or(0)) + } + } else { + None + } + } + + /// Get the next unused address index for single-pool account types + pub fn get_next_address_index(&self) -> Option { + match &self.managed_account_type { + ManagedAccountType::Standard { + .. + } => self.get_next_receive_address_index(), + ManagedAccountType::CoinJoin { + addresses, + .. + } + | ManagedAccountType::IdentityRegistration { + addresses, + .. + } + | ManagedAccountType::IdentityTopUp { + addresses, + .. + } + | ManagedAccountType::IdentityTopUpNotBoundToIdentity { + addresses, + .. + } + | ManagedAccountType::IdentityInvitation { + addresses, + .. + } + | ManagedAccountType::AssetLockAddressTopUp { + addresses, + .. + } + | ManagedAccountType::AssetLockShieldedAddressTopUp { + addresses, + .. + } + | ManagedAccountType::ProviderVotingKeys { + addresses, + .. + } + | ManagedAccountType::ProviderOwnerKeys { + addresses, + .. + } + | ManagedAccountType::ProviderOperatorKeys { + addresses, + .. + } + | ManagedAccountType::ProviderPlatformKeys { + addresses, + .. + } + | ManagedAccountType::DashpayReceivingFunds { + addresses, + .. + } + | ManagedAccountType::DashpayExternalAccount { + addresses, + .. + } + | ManagedAccountType::PlatformPayment { + addresses, + .. + } => { + addresses.unused_addresses().first().and_then(|addr| addresses.address_index(addr)) + } + } + } + + /// Mark an address as used + pub fn mark_address_used(&mut self, address: &Address) -> bool { + // Update metadata timestamp + self.metadata.last_used = Some(Self::current_timestamp()); + + // Use the account type's mark_address_used method + // The address pools already track gap limits internally + self.managed_account_type.mark_address_used(address) + } + + /// Add new ones for received outputs, remove spent ones + fn update_utxos( + &mut self, + tx: &Transaction, + account_match: &AccountMatch, + context: TransactionContext, + ) { + // Update UTXOs only for spendable account types + match &mut self.managed_account_type { + ManagedAccountType::Standard { + .. + } + | ManagedAccountType::CoinJoin { + .. + } + | ManagedAccountType::DashpayReceivingFunds { + .. + } + | ManagedAccountType::DashpayExternalAccount { + .. + } => { + let involved_addrs: BTreeSet<_> = account_match + .account_type_match + .all_involved_addresses() + .iter() + .map(|info| info.address.clone()) + .collect(); + let change_addrs: BTreeSet<_> = account_match + .account_type_match + .involved_change_addresses() + .iter() + .map(|info| info.address.clone()) + .collect(); + + // Detect a self-send: this account owns at least one input being + // spent. `account_match.sent` is computed by matching inputs against + // this account's UTXO set, so a non-zero value means we owned at + // least one of the spent outpoints. + let has_owned_input = account_match.sent > 0; + + let txid = tx.txid(); + let mut utxos_changed = false; + + // Insert UTXOs for outputs paying to our addresses + for (vout, output) in tx.output.iter().enumerate() { + if let Ok(addr) = Address::from_script(&output.script_pubkey, self.network) { + if involved_addrs.contains(&addr) { + let outpoint = OutPoint { + txid, + vout: vout as u32, + }; + + // Check if this outpoint was already spent by a transaction we've seen. + // This handles out-of-order block processing during rescan where a + // spending transaction at a higher height may be processed before + // the transaction that created the UTXO. + // TODO: This is mostly needed for wallet rescan from storage with the + // there is a timing issue with event processing which might lead to + // invalid UTXO set / balances. There might be a way around it. + if self.is_outpoint_spent(&outpoint) { + tracing::debug!( + outpoint = %outpoint, + "Skipping UTXO already spent by previously processed transaction" + ); + continue; + } + + // Flag outputs from a "trusted" mempool transaction we created — + // one that spends at least one of our own UTXOs and pays this + // output back to one of our internal (change) addresses. Such + // an output is just our previously-tracked funds returning, so + // `update_balance` credits it to the confirmed bucket even + // before the parent transaction settles. + let is_trusted_output = has_owned_input && change_addrs.contains(&addr); + let txout = dashcore::TxOut { + value: output.value, + script_pubkey: output.script_pubkey.clone(), + }; + let block_height = context.block_info().map_or(0, |info| info.height); + let mut utxo = + Utxo::new(outpoint, txout, addr, block_height, tx.is_coin_base()); + utxo.is_confirmed = context.confirmed(); + utxo.is_instantlocked = + matches!(context, TransactionContext::InstantSend(_)); + utxo.is_trusted = is_trusted_output; + self.utxos.insert(outpoint, utxo); + utxos_changed = true; + } + } + } + + // Remove UTXOs spent by this transaction and track spent outpoints + for input in &tx.input { + self.spent_outpoints.insert(input.previous_output); + + if self.utxos.remove(&input.previous_output).is_some() { + tracing::debug!( + outpoint = %input.previous_output, + txid = %tx.txid(), + "Removed spent UTXO" + ); + utxos_changed = true; + } + } + + if utxos_changed { + self.monitor_revision += 1; + } + } + _ => {} + } + } + + /// Re-process an existing transaction with updated context (e.g., mempool→block confirmation) + /// and potentially new address matches from gap limit rescans. + pub(crate) fn confirm_transaction( + &mut self, + tx: &Transaction, + account_match: &AccountMatch, + context: TransactionContext, + transaction_type: TransactionType, + ) -> bool { + if !self.transactions.contains_key(&tx.txid()) { + self.record_transaction(tx, account_match, context, transaction_type); + return true; + } + + let mut changed = false; + if let Some(tx_record) = self.transactions.get_mut(&tx.txid()) { + debug_assert_eq!( + tx_record.transaction_type, + transaction_type, + "transaction_type changed between recordings for {}", + tx.txid() + ); + if tx_record.context != context { + let was_confirmed = tx_record.context.confirmed(); + tx_record.update_context(context.clone()); + // Only signal a change when confirmation status actually changes, + // not for upgrades within the confirmed state (e.g. InBlock → InChainLockedBlock). + // TODO: emit a change event for InBlock → InChainLockedBlock once chainlock + // wallet transaction events are properly handled + changed = !was_confirmed; + } + } + self.update_utxos(tx, account_match, context); + changed + } + + /// Record a new transaction and update UTXOs for spendable account types + pub(crate) fn record_transaction( + &mut self, + tx: &Transaction, + account_match: &AccountMatch, + context: TransactionContext, + transaction_type: TransactionType, + ) -> TransactionRecord { + let net_amount = account_match.received as i64 - account_match.sent as i64; + + let receive_addrs: HashSet<_> = account_match + .account_type_match + .involved_receive_addresses() + .iter() + .map(|info| &info.address) + .collect(); + let change_addrs: HashSet<_> = account_match + .account_type_match + .involved_change_addresses() + .iter() + .map(|info| &info.address) + .collect(); + + // Input details must be built before `update_utxos` removes spent UTXOs + let mut input_details = Vec::new(); + if !tx.is_coin_base() { + for (idx, input) in tx.input.iter().enumerate() { + if let Some(utxo) = self.utxos.get(&input.previous_output) { + input_details.push(InputDetail { + index: idx as u32, + value: utxo.txout.value, + address: utxo.address.clone(), + }); + } + } + } + + // Use both UTXO-based input details and `account_match.sent` as signals + // that we created this transaction. The UTXO set may be incomplete + // (e.g., partial rescan) so `account_match.sent > 0` catches cases where + // the transaction still spent our funds even without matching UTXOs. + let has_inputs = !input_details.is_empty() || account_match.sent > 0; + + let resolved_outputs: Vec> = tx + .output + .iter() + .map(|output| Address::from_script(&output.script_pubkey, self.network).ok()) + .collect(); + + // Build output details — annotate every output with its role + let mut output_details = Vec::new(); + for (idx, output) in tx.output.iter().enumerate() { + let role = match &resolved_outputs[idx] { + Some(addr) if receive_addrs.contains(addr) => OutputRole::Received, + Some(addr) if change_addrs.contains(addr) => OutputRole::Change, + Some(_) if has_inputs => OutputRole::Sent, + Some(_) => continue, + None => { + if output.script_pubkey.is_provably_unspendable() { + OutputRole::Unspendable + } else if has_inputs { + OutputRole::Sent + } else { + continue; + } + } + }; + output_details.push(OutputDetail { + index: idx as u32, + role, + address: resolved_outputs[idx].clone(), + value: output.value, + }); + } + + // Determine direction + let has_sent = output_details.iter().any(|d| d.role == OutputRole::Sent); + let has_our_outputs = output_details + .iter() + .any(|d| d.role == OutputRole::Received || d.role == OutputRole::Change); + let direction = if transaction_type == TransactionType::CoinJoin { + TransactionDirection::CoinJoin + } else if !has_sent && has_inputs && has_our_outputs { + TransactionDirection::Internal + } else if has_inputs { + TransactionDirection::Outgoing + } else { + TransactionDirection::Incoming + }; + + let tx_record = TransactionRecord::new( + tx.clone(), + self.managed_account_type.to_account_type(), + context.clone(), + transaction_type, + direction, + input_details, + output_details, + net_amount, + ); + + let record = tx_record.clone(); + self.transactions.insert(tx.txid(), tx_record); + + self.update_utxos(tx, account_match, context); + record + } + + /// Mark all UTXOs belonging to a transaction as InstantSend-locked. + /// Returns `true` if any UTXO was newly marked. + pub(crate) fn mark_utxos_instant_send(&mut self, txid: &Txid) -> bool { + let mut any_changed = false; + for utxo in self.utxos.values_mut() { + if utxo.outpoint.txid == *txid && !utxo.is_instantlocked { + utxo.is_instantlocked = true; + any_changed = true; + } + } + any_changed + } + + /// Return the UTXOs of this account for which + /// [`Utxo::is_spendable`] holds at `last_processed_height`. See that method + /// for the exact policy. Call this per-account rather than + /// aggregating across the wallet, since spendability is + /// account-type specific. + pub fn spendable_utxos(&self, last_processed_height: u32) -> BTreeSet<&Utxo> { + self.utxos.values().filter(|utxo| utxo.is_spendable(last_processed_height)).collect() + } + + /// Update the account balance. + /// + /// Mature, non-locked UTXOs land in either the `confirmed` bucket + /// (in a block, InstantSend-locked, or trusted mempool change) or + /// the `unconfirmed` bucket (untrusted mempool only). Trusted + /// mempool change is surfaced as confirmed because it is just our + /// previously-tracked funds returning — see [`Utxo::is_trusted`]. + /// Both buckets are spendable per [`Utxo::is_spendable`]; the split + /// is only for display. + pub fn update_balance(&mut self, last_processed_height: u32) { + let mut confirmed = 0; + let mut unconfirmed = 0; + let mut immature = 0; + let mut locked = 0; + for utxo in self.utxos.values() { + let value = utxo.txout.value; + if utxo.is_locked { + locked += value; + } else if !utxo.is_mature(last_processed_height) { + immature += value; + } else if utxo.is_confirmed || utxo.is_instantlocked || utxo.is_trusted { + confirmed += value; + } else { + unconfirmed += value; + } + } + self.balance = WalletCoreBalance::new(confirmed, unconfirmed, immature, locked); + self.metadata.last_used = Some(Self::current_timestamp()); + } + + /// Get all addresses from all pools + pub fn all_addresses(&self) -> Vec
{ + self.managed_account_type.all_addresses() + } + + /// Check if an address belongs to this account + pub fn contains_address(&self, address: &Address) -> bool { + self.managed_account_type.contains_address(address) + } + + /// Check if a script pub key belongs to this account + pub fn contains_script_pub_key(&self, script_pub_key: &ScriptBuf) -> bool { + self.managed_account_type.contains_script_pub_key(script_pub_key) + } + + /// Get address info for a given address + pub fn get_address_info(&self, address: &Address) -> Option { + self.managed_account_type.get_address_info(address) + } + + /// Generate the next receive address using the optionally provided extended public key + /// If no key is provided, can only return pre-generated unused addresses + /// This method derives a new address from the account's xpub but does not add it to the pool + /// The address must be added to the pool separately with proper tracking + pub fn next_receive_address( + &mut self, + account_xpub: Option<&ExtendedPubKey>, + add_to_state: bool, + ) -> Result { + // For standard accounts, use the address pool to get the next unused address + if let ManagedAccountType::Standard { + external_addresses, + .. + } = &mut self.managed_account_type + { + // Create appropriate key source based on whether xpub is provided + let key_source = match account_xpub { + Some(xpub) => address_pool::KeySource::Public(*xpub), + None => address_pool::KeySource::NoKeySource, + }; + + let addr = + external_addresses.next_unused(&key_source, add_to_state).map_err(|e| match e { + crate::error::Error::NoKeySource => { + "No unused addresses available and no key source provided" + } + _ => "Failed to generate receive address", + })?; + self.monitor_revision += 1; + Ok(addr) + } else { + Err("Cannot generate receive address for non-standard account type") + } + } + + /// Generate the next change address using the optionally provided extended public key + /// If no key is provided, can only return pre-generated unused addresses + /// This method uses the address pool to properly track and generate addresses + pub fn next_change_address( + &mut self, + account_xpub: Option<&ExtendedPubKey>, + add_to_state: bool, + ) -> Result { + // For standard accounts, use the address pool to get the next unused address + if let ManagedAccountType::Standard { + internal_addresses, + .. + } = &mut self.managed_account_type + { + // Create appropriate key source based on whether xpub is provided + let key_source = match account_xpub { + Some(xpub) => address_pool::KeySource::Public(*xpub), + None => address_pool::KeySource::NoKeySource, + }; + + let addr = + internal_addresses.next_unused(&key_source, add_to_state).map_err(|e| match e { + crate::error::Error::NoKeySource => { + "No unused addresses available and no key source provided" + } + _ => "Failed to generate change address", + })?; + self.monitor_revision += 1; + Ok(addr) + } else { + Err("Cannot generate change address for non-standard account type") + } + } + + /// Generate multiple receive addresses at once using the optionally provided extended public key + /// + /// Returns the requested number of unused receive addresses, generating new ones if needed. + /// This is more efficient than calling `next_receive_address` multiple times. + /// If no key is provided, can only return pre-generated unused addresses. + pub fn next_receive_addresses( + &mut self, + account_xpub: Option<&ExtendedPubKey>, + count: usize, + add_to_state: bool, + ) -> Result, String> { + // For standard accounts, use the address pool to get multiple unused addresses + if let ManagedAccountType::Standard { + external_addresses, + .. + } = &mut self.managed_account_type + { + // Create appropriate key source based on whether xpub is provided + let key_source = match account_xpub { + Some(xpub) => address_pool::KeySource::Public(*xpub), + None => address_pool::KeySource::NoKeySource, + }; + + let addresses = + external_addresses.next_unused_multiple(count, &key_source, add_to_state); + if addresses.is_empty() && count > 0 { + Err("Failed to generate any receive addresses".to_string()) + } else if addresses.len() < count + && matches!(key_source, address_pool::KeySource::NoKeySource) + { + Err(format!( + "Could only generate {} out of {} requested addresses (no key source)", + addresses.len(), + count + )) + } else { + Ok(addresses) + } + } else { + Err("Cannot generate receive addresses for non-standard account type".to_string()) + } + } + + /// Generate multiple change addresses at once using the optionally provided extended public key + /// + /// Returns the requested number of unused change addresses, generating new ones if needed. + /// This is more efficient than calling `next_change_address` multiple times. + /// If no key is provided, can only return pre-generated unused addresses. + pub fn next_change_addresses( + &mut self, + account_xpub: Option<&ExtendedPubKey>, + count: usize, + add_to_state: bool, + ) -> Result, String> { + // For standard accounts, use the address pool to get multiple unused addresses + if let ManagedAccountType::Standard { + internal_addresses, + .. + } = &mut self.managed_account_type + { + // Create appropriate key source based on whether xpub is provided + let key_source = match account_xpub { + Some(xpub) => address_pool::KeySource::Public(*xpub), + None => address_pool::KeySource::NoKeySource, + }; + + let addresses = + internal_addresses.next_unused_multiple(count, &key_source, add_to_state); + if addresses.is_empty() && count > 0 { + Err("Failed to generate any change addresses".to_string()) + } else if addresses.len() < count + && matches!(key_source, address_pool::KeySource::NoKeySource) + { + Err(format!( + "Could only generate {} out of {} requested addresses (no key source)", + addresses.len(), + count + )) + } else { + Ok(addresses) + } + } else { + Err("Cannot generate change addresses for non-standard account type".to_string()) + } + } + + /// Generate the next address for non-standard accounts + /// This method is for special accounts like Identity, Provider accounts, etc. + /// Standard accounts (BIP44/BIP32) should use next_receive_address or next_change_address + pub fn next_address( + &mut self, + account_xpub: Option<&ExtendedPubKey>, + add_to_state: bool, + ) -> Result { + match &mut self.managed_account_type { + ManagedAccountType::Standard { + .. + } => Err("Standard accounts must use next_receive_address or next_change_address"), + ManagedAccountType::CoinJoin { + addresses, + .. + } + | ManagedAccountType::IdentityRegistration { + addresses, + .. + } + | ManagedAccountType::IdentityTopUpNotBoundToIdentity { + addresses, + .. + } + | ManagedAccountType::IdentityInvitation { + addresses, + .. + } + | ManagedAccountType::AssetLockAddressTopUp { + addresses, + .. + } + | ManagedAccountType::AssetLockShieldedAddressTopUp { + addresses, + .. + } + | ManagedAccountType::ProviderVotingKeys { + addresses, + .. + } + | ManagedAccountType::ProviderOwnerKeys { + addresses, + .. + } + | ManagedAccountType::ProviderOperatorKeys { + addresses, + .. + } + | ManagedAccountType::ProviderPlatformKeys { + addresses, + .. + } + | ManagedAccountType::DashpayReceivingFunds { + addresses, + .. + } + | ManagedAccountType::DashpayExternalAccount { + addresses, + .. + } + | ManagedAccountType::PlatformPayment { + addresses, + .. + } => { + // Create appropriate key source based on whether xpub is provided + let key_source = match account_xpub { + Some(xpub) => address_pool::KeySource::Public(*xpub), + None => address_pool::KeySource::NoKeySource, + }; + + addresses.next_unused(&key_source, add_to_state).map_err(|e| match e { + crate::error::Error::NoKeySource => { + "No unused addresses available and no key source provided" + } + _ => "Failed to generate address", + }) + } + ManagedAccountType::IdentityTopUp { + addresses, + .. + } => { + // Identity top-up has an address pool + let key_source = match account_xpub { + Some(xpub) => address_pool::KeySource::Public(*xpub), + None => address_pool::KeySource::NoKeySource, + }; + + addresses.next_unused(&key_source, add_to_state).map_err(|e| match e { + crate::error::Error::NoKeySource => { + "No unused addresses available and no key source provided" + } + _ => "Failed to generate address", + }) + } + } + } + + /// Generate the next address with full info for non-standard accounts + /// This method is for special accounts like Identity, Provider accounts, etc. + /// Standard accounts (BIP44/BIP32) should use next_receive_address_with_info or next_change_address_with_info + pub fn next_address_with_info( + &mut self, + account_xpub: Option<&ExtendedPubKey>, + add_to_state: bool, + ) -> Result { + match &mut self.managed_account_type { + ManagedAccountType::Standard { + .. + } => Err("Standard accounts must use next_receive_address_with_info or next_change_address_with_info"), + ManagedAccountType::CoinJoin { + addresses, + .. + } + | ManagedAccountType::IdentityRegistration { + addresses, + .. + } + | ManagedAccountType::IdentityTopUpNotBoundToIdentity { + addresses, + .. + } + | ManagedAccountType::IdentityInvitation { + addresses, + .. + } + | ManagedAccountType::AssetLockAddressTopUp { + addresses, + .. + } + | ManagedAccountType::AssetLockShieldedAddressTopUp { + addresses, + .. + } + | ManagedAccountType::ProviderVotingKeys { + addresses, + .. + } + | ManagedAccountType::ProviderOwnerKeys { + addresses, + .. + } + | ManagedAccountType::ProviderOperatorKeys { + addresses, + .. + } + | ManagedAccountType::ProviderPlatformKeys { + addresses, + .. + } + | ManagedAccountType::DashpayReceivingFunds { + addresses, + .. + } + | ManagedAccountType::DashpayExternalAccount { + addresses, + .. + } + | ManagedAccountType::PlatformPayment { + addresses, + .. + } => { + // Create appropriate key source based on whether xpub is provided + let key_source = match account_xpub { + Some(xpub) => address_pool::KeySource::Public(*xpub), + None => address_pool::KeySource::NoKeySource, + }; + + addresses.next_unused_with_info(&key_source, add_to_state).map_err(|e| match e { + crate::error::Error::NoKeySource => { + "No unused addresses available and no key source provided" + } + _ => "Failed to generate address with info", + }) + } + ManagedAccountType::IdentityTopUp { + addresses, + .. + } => { + // Identity top-up has an address pool + let key_source = match account_xpub { + Some(xpub) => address_pool::KeySource::Public(*xpub), + None => address_pool::KeySource::NoKeySource, + }; + + addresses.next_unused_with_info(&key_source, add_to_state).map_err(|e| match e { + crate::error::Error::NoKeySource => { + "No unused addresses available and no key source provided" + } + _ => "Failed to generate address with info", + }) + } + } + } + + /// Generate the next BLS operator key (only for ProviderOperatorKeys accounts) + /// Returns the BLS public key at the next unused index + #[cfg(feature = "bls")] + pub fn next_bls_operator_key( + &mut self, + account_xpub: Option, + add_to_state: bool, + ) -> Result, &'static str> { + match &mut self.managed_account_type { + ManagedAccountType::ProviderOperatorKeys { + addresses, + .. + } => { + // Create key source from the optional BLS public key + let key_source = match account_xpub { + Some(xpub) => address_pool::KeySource::BLSPublic(xpub), + None => address_pool::KeySource::NoKeySource, + }; + + // Use next_unused_with_info to get the next address (handles caching and derivation) + let info = addresses + .next_unused_with_info(&key_source, add_to_state) + .map_err(|_| "Failed to get next unused address")?; + + // Extract the BLS public key from the address info + let Some(PublicKeyType::BLS(pub_key_bytes)) = info.public_key else { + return Err("Expected BLS public key but got different key type"); + }; + + // Mark as used + addresses.mark_index_used(info.index); + + // Convert bytes to BLS public key + use dashcore::blsful::{Bls12381G2Impl, PublicKey, SerializationFormat}; + let public_key = PublicKey::::from_bytes_with_mode( + &pub_key_bytes, + SerializationFormat::Modern, + ) + .map_err(|_| "Failed to deserialize BLS public key")?; + + Ok(public_key) + } + _ => Err("This method only works for ProviderOperatorKeys accounts"), + } + } + + /// Generate the next EdDSA platform key (only for ProviderPlatformKeys accounts) + /// Returns the Ed25519 public key and address info at the next unused index + #[cfg(feature = "eddsa")] + pub fn next_eddsa_platform_key( + &mut self, + account_xpriv: crate::derivation_slip10::ExtendedEd25519PrivKey, + add_to_state: bool, + ) -> Result<(crate::derivation_slip10::VerifyingKey, AddressInfo), &'static str> { + match &mut self.managed_account_type { + ManagedAccountType::ProviderPlatformKeys { + addresses, + .. + } => { + // Create key source from the EdDSA private key + let key_source = address_pool::KeySource::EdDSAPrivate(account_xpriv); + + // Use next_unused_with_info to get the next address (handles caching and derivation) + let info = addresses + .next_unused_with_info(&key_source, add_to_state) + .map_err(|_| "Failed to get next unused address")?; + + // Extract the EdDSA public key from the address info + let Some(PublicKeyType::EdDSA(pub_key_bytes)) = info.public_key.clone() else { + return Err("Expected EdDSA public key but got different key type"); + }; + + // Mark as used + addresses.mark_index_used(info.index); + + let verifying_key = crate::derivation_slip10::VerifyingKey::from_bytes( + &pub_key_bytes.try_into().map_err(|_| "Invalid EdDSA public key length")?, + ) + .map_err(|_| "Failed to deserialize EdDSA public key")?; + + Ok((verifying_key, info)) + } + _ => Err("This method only works for ProviderPlatformKeys accounts"), + } + } + + /// Consume the next unused address and derive its private key. + /// + /// Used for one-time keys (asset lock funding, identity registration, etc.). + /// The address is marked as used so subsequent calls return fresh keys. + /// + /// Only works for single-pool account types (not Standard accounts). + pub fn next_private_key( + &mut self, + root_xpriv: &crate::wallet::root_extended_keys::RootExtendedPrivKey, + network: Network, + ) -> Result<[u8; 32], &'static str> { + if matches!(self.managed_account_type, ManagedAccountType::Standard { .. }) { + return Err("Standard accounts must use next_receive_address or next_change_address"); + } + + let mut pools = self.managed_account_type.address_pools_mut(); + let pool = pools.first_mut().ok_or("Account has no address pool")?; + + let info = pool + .next_unused_with_info(&address_pool::KeySource::NoKeySource, false) + .map_err(|_| "No unused address available")?; + + pool.mark_index_used(info.index); + + let secp = secp256k1::Secp256k1::new(); + let root_ext_priv = root_xpriv.to_extended_priv_key(network); + let derived_xpriv = + root_ext_priv.derive_priv(&secp, &info.path).map_err(|_| "Key derivation failed")?; + + let mut private_key = [0u8; 32]; + private_key.copy_from_slice(&derived_xpriv.private_key[..]); + Ok(private_key) + } + + /// Peek at the next unused address's path and index **without** marking + /// the index used. + /// + /// Intended for two-phase flows where path consumption must not commit + /// until an external operation (e.g. an async signer request) has + /// succeeded. Pair with [`Self::mark_first_pool_index_used`] to + /// commit, or drop the result to leave the pool untouched. Calling + /// `peek_next_path` twice without committing in between returns the + /// same `(path, index)`. + /// + /// Only works for single-pool account types (not Standard accounts). + pub fn peek_next_path(&mut self) -> Result<(crate::DerivationPath, u32), &'static str> { + if matches!(self.managed_account_type, ManagedAccountType::Standard { .. }) { + return Err("Standard accounts must use next_receive_address or next_change_address"); + } + + let mut pools = self.managed_account_type.address_pools_mut(); + let pool = pools.first_mut().ok_or("Account has no address pool")?; + + let info = pool + .next_unused_with_info(&address_pool::KeySource::NoKeySource, false) + .map_err(|_| "No unused address available")?; + + Ok((info.path, info.index)) + } + + /// Mark an index on the account's first address pool as used. + /// + /// Commits what [`Self::peek_next_path`] returned. Accepts any index — + /// callers are responsible for passing back the index they peeked, not + /// an arbitrary one. + /// + /// Only works for single-pool account types (not Standard accounts). + pub fn mark_first_pool_index_used(&mut self, index: u32) -> Result<(), &'static str> { + if matches!(self.managed_account_type, ManagedAccountType::Standard { .. }) { + return Err("Standard accounts must use next_receive_address or next_change_address"); + } + + let mut pools = self.managed_account_type.address_pools_mut(); + let pool = pools.first_mut().ok_or("Account has no address pool")?; + pool.mark_index_used(index); + Ok(()) + } + + /// Consume the next unused address and return only its derivation path. + /// + /// Analogous to [`Self::next_private_key`] but does not require any + /// root extended private key: used when signing is delegated to an + /// external [`Signer`](crate::signer::Signer), which holds the keys + /// and only needs the path to produce signatures or public keys. + /// + /// Consumes the index immediately; callers that need to defer the + /// commit until after an external operation succeeds should use + /// [`Self::peek_next_path`] + [`Self::mark_first_pool_index_used`] + /// instead. + /// + /// Only works for single-pool account types (not Standard accounts). + pub fn next_path(&mut self) -> Result { + let (path, index) = self.peek_next_path()?; + self.mark_first_pool_index_used(index)?; + Ok(path) + } + + /// Get the derivation path for an address if it belongs to this account + pub fn address_derivation_path(&self, address: &Address) -> Option { + self.managed_account_type.get_address_derivation_path(address) + } + + /// Get the current timestamp (for metadata) + fn current_timestamp() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + } + + /// Get total address count across all pools + pub fn total_address_count(&self) -> usize { + self.managed_account_type + .address_pools() + .iter() + .map(|pool| pool.stats().total_generated as usize) + .sum() + } + + /// Get used address count across all pools + pub fn used_address_count(&self) -> usize { + self.managed_account_type + .address_pools() + .iter() + .map(|pool| pool.stats().used_count as usize) + .sum() + } + + /// Get the external gap limit for standard accounts + pub fn external_gap_limit(&self) -> Option { + match &self.managed_account_type { + ManagedAccountType::Standard { + external_addresses, + .. + } => Some(external_addresses.gap_limit), + _ => None, + } + } + + /// Get the internal gap limit for standard accounts + pub fn internal_gap_limit(&self) -> Option { + match &self.managed_account_type { + ManagedAccountType::Standard { + internal_addresses, + .. + } => Some(internal_addresses.gap_limit), + _ => None, + } + } + + /// Get the gap limit for non-standard (single-pool) accounts + pub fn gap_limit(&self) -> Option { + match &self.managed_account_type { + ManagedAccountType::Standard { + .. + } => None, + ManagedAccountType::CoinJoin { + addresses, + .. + } + | ManagedAccountType::IdentityRegistration { + addresses, + .. + } + | ManagedAccountType::IdentityTopUp { + addresses, + .. + } + | ManagedAccountType::IdentityTopUpNotBoundToIdentity { + addresses, + .. + } + | ManagedAccountType::IdentityInvitation { + addresses, + .. + } + | ManagedAccountType::AssetLockAddressTopUp { + addresses, + .. + } + | ManagedAccountType::AssetLockShieldedAddressTopUp { + addresses, + .. + } + | ManagedAccountType::ProviderVotingKeys { + addresses, + .. + } + | ManagedAccountType::ProviderOwnerKeys { + addresses, + .. + } + | ManagedAccountType::ProviderOperatorKeys { + addresses, + .. + } + | ManagedAccountType::ProviderPlatformKeys { + addresses, + .. + } + | ManagedAccountType::DashpayReceivingFunds { + addresses, + .. + } + | ManagedAccountType::DashpayExternalAccount { + addresses, + .. + } + | ManagedAccountType::PlatformPayment { + addresses, + .. + } => Some(addresses.gap_limit), + } + } +} + +impl ManagedAccountTrait for ManagedCoreFundsAccount { + fn managed_account_type(&self) -> &ManagedAccountType { + &self.managed_account_type + } + + fn managed_account_type_mut(&mut self) -> &mut ManagedAccountType { + &mut self.managed_account_type + } + + fn network(&self) -> Network { + self.network + } + + fn metadata(&self) -> &AccountMetadata { + &self.metadata + } + + fn metadata_mut(&mut self) -> &mut AccountMetadata { + &mut self.metadata + } + + fn is_watch_only(&self) -> bool { + self.is_watch_only + } + + fn balance(&self) -> &WalletCoreBalance { + &self.balance + } + + fn balance_mut(&mut self) -> &mut WalletCoreBalance { + &mut self.balance + } + + fn transactions(&self) -> &BTreeMap { + &self.transactions + } + + fn transactions_mut(&mut self) -> &mut BTreeMap { + &mut self.transactions + } + + fn utxos(&self) -> &BTreeMap { + &self.utxos + } + + fn utxos_mut(&mut self) -> &mut BTreeMap { + &mut self.utxos + } +} + +#[cfg(feature = "serde")] +impl<'de> Deserialize<'de> for ManagedCoreFundsAccount { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct Helper { + managed_account_type: ManagedAccountType, + network: Network, + metadata: AccountMetadata, + is_watch_only: bool, + balance: WalletCoreBalance, + transactions: BTreeMap, + utxos: BTreeMap, + } + + let helper = Helper::deserialize(deserializer)?; + + let spent_outpoints = helper + .transactions + .values() + .flat_map(|record| &record.transaction.input) + .map(|input| input.previous_output) + .collect(); + + Ok(ManagedCoreFundsAccount { + managed_account_type: helper.managed_account_type, + network: helper.network, + metadata: helper.metadata, + is_watch_only: helper.is_watch_only, + balance: helper.balance, + transactions: helper.transactions, + utxos: helper.utxos, + spent_outpoints, + monitor_revision: 0, + }) + } +} diff --git a/key-wallet/src/managed_account/managed_core_keys_account.rs b/key-wallet/src/managed_account/managed_core_keys_account.rs new file mode 100644 index 000000000..f58c84a9e --- /dev/null +++ b/key-wallet/src/managed_account/managed_core_keys_account.rs @@ -0,0 +1,679 @@ +//! Managed core keys account: address pools and key derivation without funds tracking +//! +//! This module contains a lightweight mutable account state that omits the funds +//! bookkeeping (balance, UTXOs, spent outpoints) carried by [`ManagedCoreFundsAccount`]. +//! It is intended for accounts that exist primarily to derive keys/addresses for +//! special-purpose flows (identity registration, asset locks, masternode provider +//! keys) rather than to hold and spend Dash directly. + +use crate::account::AccountMetadata; +#[cfg(feature = "bls")] +use crate::account::BLSAccount; +#[cfg(feature = "eddsa")] +use crate::account::EdDSAAccount; +use crate::account::TransactionRecord; +#[cfg(feature = "bls")] +use crate::derivation_bls_bip32::ExtendedBLSPubKey; +#[cfg(any(feature = "bls", feature = "eddsa"))] +use crate::managed_account::address_pool::PublicKeyType; +use crate::managed_account::address_pool; +use crate::managed_account::managed_account_type::ManagedAccountType; +#[cfg(feature = "eddsa")] +use crate::AddressInfo; +use crate::{ExtendedPubKey, Network}; +use dashcore::Txid; +use dashcore::{Address, ScriptBuf}; +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// Managed core keys account with mutable state but no funds tracking +/// +/// Like [`ManagedCoreFundsAccount`] but without `balance`, `utxos`, or +/// `spent_outpoints`. Used for accounts that derive special-purpose keys +/// (identity registration, asset locks, masternode provider keys) where +/// per-account UTXO/balance bookkeeping is not meaningful. +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct ManagedCoreKeysAccount { + /// Account type with embedded address pools and index + pub managed_account_type: ManagedAccountType, + /// Network this account belongs to + pub network: Network, + /// Account metadata + pub metadata: AccountMetadata, + /// Whether this is a watch-only account + pub is_watch_only: bool, + /// Transaction history for this account + pub transactions: BTreeMap, + /// Revision counter incremented when the monitored address set changes + /// (e.g. new addresses generated). Used to detect bloom filter staleness. + #[cfg_attr(feature = "serde", serde(skip))] + monitor_revision: u64, +} + +impl ManagedCoreKeysAccount { + /// Create a new managed keys account + pub fn new( + managed_account_type: ManagedAccountType, + network: Network, + is_watch_only: bool, + ) -> Self { + Self { + managed_account_type, + network, + metadata: AccountMetadata::default(), + is_watch_only, + transactions: BTreeMap::new(), + monitor_revision: 0, + } + } + + /// Return the current monitor revision. + pub fn monitor_revision(&self) -> u64 { + self.monitor_revision + } + + /// Increment the monitor revision to signal that the monitored address set changed. + pub fn bump_monitor_revision(&mut self) { + self.monitor_revision += 1; + } + + /// Create a ManagedCoreKeysAccount from an Account + pub fn from_account(account: &super::super::Account) -> Self { + let key_source = address_pool::KeySource::Public(account.account_xpub); + let managed_type = ManagedAccountType::from_account_type( + account.account_type, + account.network, + &key_source, + ) + .unwrap_or_else(|_| { + let no_key_source = address_pool::KeySource::NoKeySource; + ManagedAccountType::from_account_type( + account.account_type, + account.network, + &no_key_source, + ) + .expect("Should succeed with NoKeySource") + }); + + Self::new(managed_type, account.network, account.is_watch_only) + } + + /// Create a ManagedCoreKeysAccount from a BLS Account + #[cfg(feature = "bls")] + pub fn from_bls_account(account: &BLSAccount) -> Self { + let key_source = address_pool::KeySource::BLSPublic(account.bls_public_key.clone()); + let managed_type = ManagedAccountType::from_account_type( + account.account_type, + account.network, + &key_source, + ) + .unwrap_or_else(|_| { + let no_key_source = address_pool::KeySource::NoKeySource; + ManagedAccountType::from_account_type( + account.account_type, + account.network, + &no_key_source, + ) + .expect("Should succeed with NoKeySource") + }); + + Self::new(managed_type, account.network, account.is_watch_only) + } + + /// Create a ManagedCoreKeysAccount from an EdDSA Account + #[cfg(feature = "eddsa")] + pub fn from_eddsa_account(account: &EdDSAAccount) -> Self { + let key_source = address_pool::KeySource::NoKeySource; + let managed_type = ManagedAccountType::from_account_type( + account.account_type, + account.network, + &key_source, + ) + .expect("Should succeed with NoKeySource"); + + Self::new(managed_type, account.network, account.is_watch_only) + } + + /// Get the account index + pub fn index(&self) -> Option { + self.managed_account_type.index() + } + + /// Get the account index or 0 if none exists + pub fn index_or_default(&self) -> u32 { + self.managed_account_type.index_or_default() + } + + /// Get the managed account type + pub fn managed_type(&self) -> &ManagedAccountType { + &self.managed_account_type + } + + /// Get the next unused receive address index for standard accounts + pub fn get_next_receive_address_index(&self) -> Option { + if let ManagedAccountType::Standard { + external_addresses, + .. + } = &self.managed_account_type + { + if let Some(addr) = external_addresses.unused_addresses().first() { + external_addresses.address_index(addr) + } else { + let stats = external_addresses.stats(); + Some(stats.highest_generated.map(|h| h + 1).unwrap_or(0)) + } + } else { + None + } + } + + /// Get the next unused change address index for standard accounts + pub fn get_next_change_address_index(&self) -> Option { + if let ManagedAccountType::Standard { + internal_addresses, + .. + } = &self.managed_account_type + { + if let Some(addr) = internal_addresses.unused_addresses().first() { + internal_addresses.address_index(addr) + } else { + let stats = internal_addresses.stats(); + Some(stats.highest_generated.map(|h| h + 1).unwrap_or(0)) + } + } else { + None + } + } + + /// Get the next unused address index for single-pool account types + pub fn get_next_address_index(&self) -> Option { + match &self.managed_account_type { + ManagedAccountType::Standard { + .. + } => self.get_next_receive_address_index(), + ManagedAccountType::CoinJoin { + addresses, + .. + } + | ManagedAccountType::IdentityRegistration { + addresses, + .. + } + | ManagedAccountType::IdentityTopUp { + addresses, + .. + } + | ManagedAccountType::IdentityTopUpNotBoundToIdentity { + addresses, + .. + } + | ManagedAccountType::IdentityInvitation { + addresses, + .. + } + | ManagedAccountType::AssetLockAddressTopUp { + addresses, + .. + } + | ManagedAccountType::AssetLockShieldedAddressTopUp { + addresses, + .. + } + | ManagedAccountType::ProviderVotingKeys { + addresses, + .. + } + | ManagedAccountType::ProviderOwnerKeys { + addresses, + .. + } + | ManagedAccountType::ProviderOperatorKeys { + addresses, + .. + } + | ManagedAccountType::ProviderPlatformKeys { + addresses, + .. + } + | ManagedAccountType::DashpayReceivingFunds { + addresses, + .. + } + | ManagedAccountType::DashpayExternalAccount { + addresses, + .. + } + | ManagedAccountType::PlatformPayment { + addresses, + .. + } => { + addresses.unused_addresses().first().and_then(|addr| addresses.address_index(addr)) + } + } + } + + /// Mark an address as used + pub fn mark_address_used(&mut self, address: &Address) -> bool { + self.metadata.last_used = Some(Self::current_timestamp()); + self.managed_account_type.mark_address_used(address) + } + + /// Get all addresses from all pools + pub fn all_addresses(&self) -> Vec
{ + self.managed_account_type.all_addresses() + } + + /// Check if an address belongs to this account + pub fn contains_address(&self, address: &Address) -> bool { + self.managed_account_type.contains_address(address) + } + + /// Check if a script pub key belongs to this account + pub fn contains_script_pub_key(&self, script_pub_key: &ScriptBuf) -> bool { + self.managed_account_type.contains_script_pub_key(script_pub_key) + } + + /// Get address info for a given address + pub fn get_address_info(&self, address: &Address) -> Option { + self.managed_account_type.get_address_info(address) + } + + /// Generate the next address for non-standard accounts + pub fn next_address( + &mut self, + account_xpub: Option<&ExtendedPubKey>, + add_to_state: bool, + ) -> Result { + match &mut self.managed_account_type { + ManagedAccountType::Standard { + .. + } => Err("Standard accounts must use next_receive_address or next_change_address"), + ManagedAccountType::CoinJoin { + addresses, + .. + } + | ManagedAccountType::IdentityRegistration { + addresses, + .. + } + | ManagedAccountType::IdentityTopUpNotBoundToIdentity { + addresses, + .. + } + | ManagedAccountType::IdentityInvitation { + addresses, + .. + } + | ManagedAccountType::AssetLockAddressTopUp { + addresses, + .. + } + | ManagedAccountType::AssetLockShieldedAddressTopUp { + addresses, + .. + } + | ManagedAccountType::ProviderVotingKeys { + addresses, + .. + } + | ManagedAccountType::ProviderOwnerKeys { + addresses, + .. + } + | ManagedAccountType::ProviderOperatorKeys { + addresses, + .. + } + | ManagedAccountType::ProviderPlatformKeys { + addresses, + .. + } + | ManagedAccountType::DashpayReceivingFunds { + addresses, + .. + } + | ManagedAccountType::DashpayExternalAccount { + addresses, + .. + } + | ManagedAccountType::PlatformPayment { + addresses, + .. + } + | ManagedAccountType::IdentityTopUp { + addresses, + .. + } => { + let key_source = match account_xpub { + Some(xpub) => address_pool::KeySource::Public(*xpub), + None => address_pool::KeySource::NoKeySource, + }; + + addresses.next_unused(&key_source, add_to_state).map_err(|e| match e { + crate::error::Error::NoKeySource => { + "No unused addresses available and no key source provided" + } + _ => "Failed to generate address", + }) + } + } + } + + /// Generate the next address with full info for non-standard accounts + pub fn next_address_with_info( + &mut self, + account_xpub: Option<&ExtendedPubKey>, + add_to_state: bool, + ) -> Result { + match &mut self.managed_account_type { + ManagedAccountType::Standard { + .. + } => Err("Standard accounts must use next_receive_address_with_info or next_change_address_with_info"), + ManagedAccountType::CoinJoin { + addresses, + .. + } + | ManagedAccountType::IdentityRegistration { + addresses, + .. + } + | ManagedAccountType::IdentityTopUpNotBoundToIdentity { + addresses, + .. + } + | ManagedAccountType::IdentityInvitation { + addresses, + .. + } + | ManagedAccountType::AssetLockAddressTopUp { + addresses, + .. + } + | ManagedAccountType::AssetLockShieldedAddressTopUp { + addresses, + .. + } + | ManagedAccountType::ProviderVotingKeys { + addresses, + .. + } + | ManagedAccountType::ProviderOwnerKeys { + addresses, + .. + } + | ManagedAccountType::ProviderOperatorKeys { + addresses, + .. + } + | ManagedAccountType::ProviderPlatformKeys { + addresses, + .. + } + | ManagedAccountType::DashpayReceivingFunds { + addresses, + .. + } + | ManagedAccountType::DashpayExternalAccount { + addresses, + .. + } + | ManagedAccountType::PlatformPayment { + addresses, + .. + } + | ManagedAccountType::IdentityTopUp { + addresses, + .. + } => { + let key_source = match account_xpub { + Some(xpub) => address_pool::KeySource::Public(*xpub), + None => address_pool::KeySource::NoKeySource, + }; + + addresses.next_unused_with_info(&key_source, add_to_state).map_err(|e| match e { + crate::error::Error::NoKeySource => { + "No unused addresses available and no key source provided" + } + _ => "Failed to generate address with info", + }) + } + } + } + + /// Generate the next BLS operator key (only for ProviderOperatorKeys accounts) + #[cfg(feature = "bls")] + pub fn next_bls_operator_key( + &mut self, + account_xpub: Option, + add_to_state: bool, + ) -> Result, &'static str> { + match &mut self.managed_account_type { + ManagedAccountType::ProviderOperatorKeys { + addresses, + .. + } => { + let key_source = match account_xpub { + Some(xpub) => address_pool::KeySource::BLSPublic(xpub), + None => address_pool::KeySource::NoKeySource, + }; + + let info = addresses + .next_unused_with_info(&key_source, add_to_state) + .map_err(|_| "Failed to get next unused address")?; + + let Some(PublicKeyType::BLS(pub_key_bytes)) = info.public_key else { + return Err("Expected BLS public key but got different key type"); + }; + + addresses.mark_index_used(info.index); + + use dashcore::blsful::{Bls12381G2Impl, PublicKey, SerializationFormat}; + let public_key = PublicKey::::from_bytes_with_mode( + &pub_key_bytes, + SerializationFormat::Modern, + ) + .map_err(|_| "Failed to deserialize BLS public key")?; + + Ok(public_key) + } + _ => Err("This method only works for ProviderOperatorKeys accounts"), + } + } + + /// Generate the next EdDSA platform key (only for ProviderPlatformKeys accounts) + #[cfg(feature = "eddsa")] + pub fn next_eddsa_platform_key( + &mut self, + account_xpriv: crate::derivation_slip10::ExtendedEd25519PrivKey, + add_to_state: bool, + ) -> Result<(crate::derivation_slip10::VerifyingKey, AddressInfo), &'static str> { + match &mut self.managed_account_type { + ManagedAccountType::ProviderPlatformKeys { + addresses, + .. + } => { + let key_source = address_pool::KeySource::EdDSAPrivate(account_xpriv); + + let info = addresses + .next_unused_with_info(&key_source, add_to_state) + .map_err(|_| "Failed to get next unused address")?; + + let Some(PublicKeyType::EdDSA(pub_key_bytes)) = info.public_key.clone() else { + return Err("Expected EdDSA public key but got different key type"); + }; + + addresses.mark_index_used(info.index); + + let verifying_key = crate::derivation_slip10::VerifyingKey::from_bytes( + &pub_key_bytes.try_into().map_err(|_| "Invalid EdDSA public key length")?, + ) + .map_err(|_| "Failed to deserialize EdDSA public key")?; + + Ok((verifying_key, info)) + } + _ => Err("This method only works for ProviderPlatformKeys accounts"), + } + } + + /// Consume the next unused address and derive its private key. + pub fn next_private_key( + &mut self, + root_xpriv: &crate::wallet::root_extended_keys::RootExtendedPrivKey, + network: Network, + ) -> Result<[u8; 32], &'static str> { + if matches!(self.managed_account_type, ManagedAccountType::Standard { .. }) { + return Err("Standard accounts must use next_receive_address or next_change_address"); + } + + let mut pools = self.managed_account_type.address_pools_mut(); + let pool = pools.first_mut().ok_or("Account has no address pool")?; + + let info = pool + .next_unused_with_info(&address_pool::KeySource::NoKeySource, false) + .map_err(|_| "No unused address available")?; + + pool.mark_index_used(info.index); + + let secp = secp256k1::Secp256k1::new(); + let root_ext_priv = root_xpriv.to_extended_priv_key(network); + let derived_xpriv = + root_ext_priv.derive_priv(&secp, &info.path).map_err(|_| "Key derivation failed")?; + + let mut private_key = [0u8; 32]; + private_key.copy_from_slice(&derived_xpriv.private_key[..]); + Ok(private_key) + } + + /// Peek at the next unused address's path and index without marking the index used. + pub fn peek_next_path(&mut self) -> Result<(crate::DerivationPath, u32), &'static str> { + if matches!(self.managed_account_type, ManagedAccountType::Standard { .. }) { + return Err("Standard accounts must use next_receive_address or next_change_address"); + } + + let mut pools = self.managed_account_type.address_pools_mut(); + let pool = pools.first_mut().ok_or("Account has no address pool")?; + + let info = pool + .next_unused_with_info(&address_pool::KeySource::NoKeySource, false) + .map_err(|_| "No unused address available")?; + + Ok((info.path, info.index)) + } + + /// Mark an index on the account's first address pool as used. + pub fn mark_first_pool_index_used(&mut self, index: u32) -> Result<(), &'static str> { + if matches!(self.managed_account_type, ManagedAccountType::Standard { .. }) { + return Err("Standard accounts must use next_receive_address or next_change_address"); + } + + let mut pools = self.managed_account_type.address_pools_mut(); + let pool = pools.first_mut().ok_or("Account has no address pool")?; + pool.mark_index_used(index); + Ok(()) + } + + /// Consume the next unused address and return only its derivation path. + pub fn next_path(&mut self) -> Result { + let (path, index) = self.peek_next_path()?; + self.mark_first_pool_index_used(index)?; + Ok(path) + } + + /// Get the derivation path for an address if it belongs to this account + pub fn address_derivation_path(&self, address: &Address) -> Option { + self.managed_account_type.get_address_derivation_path(address) + } + + /// Get the current timestamp (for metadata) + fn current_timestamp() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + } + + /// Get total address count across all pools + pub fn total_address_count(&self) -> usize { + self.managed_account_type + .address_pools() + .iter() + .map(|pool| pool.stats().total_generated as usize) + .sum() + } + + /// Get used address count across all pools + pub fn used_address_count(&self) -> usize { + self.managed_account_type + .address_pools() + .iter() + .map(|pool| pool.stats().used_count as usize) + .sum() + } + + /// Get the gap limit for non-standard (single-pool) accounts + pub fn gap_limit(&self) -> Option { + match &self.managed_account_type { + ManagedAccountType::Standard { + .. + } => None, + ManagedAccountType::CoinJoin { + addresses, + .. + } + | ManagedAccountType::IdentityRegistration { + addresses, + .. + } + | ManagedAccountType::IdentityTopUp { + addresses, + .. + } + | ManagedAccountType::IdentityTopUpNotBoundToIdentity { + addresses, + .. + } + | ManagedAccountType::IdentityInvitation { + addresses, + .. + } + | ManagedAccountType::AssetLockAddressTopUp { + addresses, + .. + } + | ManagedAccountType::AssetLockShieldedAddressTopUp { + addresses, + .. + } + | ManagedAccountType::ProviderVotingKeys { + addresses, + .. + } + | ManagedAccountType::ProviderOwnerKeys { + addresses, + .. + } + | ManagedAccountType::ProviderOperatorKeys { + addresses, + .. + } + | ManagedAccountType::ProviderPlatformKeys { + addresses, + .. + } + | ManagedAccountType::DashpayReceivingFunds { + addresses, + .. + } + | ManagedAccountType::DashpayExternalAccount { + addresses, + .. + } + | ManagedAccountType::PlatformPayment { + addresses, + .. + } => Some(addresses.gap_limit), + } + } +} diff --git a/key-wallet/src/managed_account/managed_platform_account.rs b/key-wallet/src/managed_account/managed_platform_account.rs index aa85ce5b1..3dbc01e84 100644 --- a/key-wallet/src/managed_account/managed_platform_account.rs +++ b/key-wallet/src/managed_account/managed_platform_account.rs @@ -1,7 +1,7 @@ //! Managed Platform Account for DIP-17 Platform Payment addresses //! //! This module provides the `ManagedPlatformAccount` type which is a simplified -//! account structure for Platform Payment accounts. Unlike `ManagedCoreAccount`, +//! account structure for Platform Payment accounts. Unlike `ManagedCoreFundsAccount`, //! this type: //! - Uses a simple `u64` balance instead of `WalletCoreBalance` //! - Tracks per-address balances directly @@ -23,7 +23,7 @@ use serde::{Deserialize, Serialize}; /// Managed Platform Account for DIP-17 Platform Payment addresses /// /// This is a simplified account structure designed specifically for Platform -/// Payment accounts (DIP-17). It differs from `ManagedCoreAccount` in that: +/// Payment accounts (DIP-17). It differs from `ManagedCoreFundsAccount` in that: /// /// - **Balance**: Simple `u64` credit balance (1000 credits = 1 duff) /// - **Address Balances**: Direct mapping of addresses to their credit balances diff --git a/key-wallet/src/managed_account/mod.rs b/key-wallet/src/managed_account/mod.rs index b13808fc3..3b254c243 100644 --- a/key-wallet/src/managed_account/mod.rs +++ b/key-wallet/src/managed_account/mod.rs @@ -1,1380 +1,27 @@ -//! Managed account structure with mutable state +//! Managed account state and helpers //! -//! This module contains the mutable account state that changes during wallet operation, -//! kept separate from the immutable Account structure. - -use crate::account::AccountMetadata; -#[cfg(feature = "bls")] -use crate::account::BLSAccount; -#[cfg(feature = "eddsa")] -use crate::account::EdDSAAccount; -use crate::account::ManagedAccountTrait; -use crate::account::TransactionRecord; -#[cfg(feature = "bls")] -use crate::derivation_bls_bip32::ExtendedBLSPubKey; -#[cfg(any(feature = "bls", feature = "eddsa"))] -use crate::managed_account::address_pool::PublicKeyType; -use crate::managed_account::transaction_record::{ - InputDetail, OutputDetail, OutputRole, TransactionDirection, -}; -use crate::transaction_checking::transaction_router::TransactionType; -use crate::transaction_checking::{AccountMatch, TransactionContext}; -use crate::utxo::Utxo; -use crate::wallet::balance::WalletCoreBalance; -#[cfg(feature = "eddsa")] -use crate::AddressInfo; -use crate::{ExtendedPubKey, Network}; -use dashcore::blockdata::transaction::OutPoint; -use dashcore::{Address, ScriptBuf}; -use dashcore::{Transaction, Txid}; -use managed_account_type::ManagedAccountType; -#[cfg(feature = "serde")] -use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; -use std::collections::{BTreeSet, HashSet}; +//! This module groups the mutable account state used during wallet operation, +//! kept separate from the immutable [`Account`](crate::Account) structure. +//! +//! Two managed-account variants are exposed: +//! +//! - [`ManagedCoreFundsAccount`]: full state including balance, UTXO set and +//! spent-outpoint tracking. Used for accounts that hold and spend funds +//! (Standard, CoinJoin, DashPay). +//! - [`ManagedCoreKeysAccount`]: lightweight state without balance/UTXO/spent-outpoint +//! tracking. Intended for accounts that primarily derive keys for special-purpose +//! flows (identity registration, asset locks, masternode provider keys). pub mod address_pool; pub mod managed_account_collection; pub mod managed_account_trait; pub mod managed_account_type; +pub mod managed_core_funds_account; +pub mod managed_core_keys_account; pub mod managed_platform_account; pub mod metadata; pub mod platform_address; pub mod transaction_record; -/// Managed account with mutable state -/// -/// This struct contains the mutable state of an account including address pools, -/// metadata, and balance information. It is managed separately from -/// the immutable Account structure. -#[derive(Debug, Clone)] -#[cfg_attr(feature = "serde", derive(Serialize))] -pub struct ManagedCoreAccount { - /// Account type with embedded address pools and index - pub managed_account_type: ManagedAccountType, - /// Network this account belongs to - pub network: Network, - /// Account metadata - pub metadata: AccountMetadata, - /// Whether this is a watch-only account - pub is_watch_only: bool, - /// Account balance information - pub balance: WalletCoreBalance, - /// Transaction history for this account - pub transactions: BTreeMap, - /// UTXO set for this account - pub utxos: BTreeMap, - /// Outpoints spent by recorded transactions. - /// Rebuilt from `transactions` during deserialization. - #[cfg_attr(feature = "serde", serde(skip_serializing))] - spent_outpoints: HashSet, - /// Revision counter incremented when the monitored address set changes - /// (e.g. new addresses generated). Used to detect bloom filter staleness. - #[cfg_attr(feature = "serde", serde(skip_serializing))] - monitor_revision: u64, -} - -impl ManagedCoreAccount { - /// Create a new managed account - pub fn new( - managed_account_type: ManagedAccountType, - network: Network, - is_watch_only: bool, - ) -> Self { - Self { - managed_account_type, - network, - metadata: AccountMetadata::default(), - is_watch_only, - balance: WalletCoreBalance::default(), - transactions: BTreeMap::new(), - utxos: BTreeMap::new(), - spent_outpoints: HashSet::new(), - monitor_revision: 0, - } - } - - /// Return the current monitor revision. - pub fn monitor_revision(&self) -> u64 { - self.monitor_revision - } - - /// Increment the monitor revision to signal that the monitored address set changed. - pub fn bump_monitor_revision(&mut self) { - self.monitor_revision += 1; - } - - /// Check if an outpoint was spent by a previously recorded transaction. - fn is_outpoint_spent(&self, outpoint: &OutPoint) -> bool { - self.spent_outpoints.contains(outpoint) - } - - /// Create a ManagedAccount from an Account - pub fn from_account(account: &super::Account) -> Self { - // Use the account's public key as the key source - let key_source = address_pool::KeySource::Public(account.account_xpub); - let managed_type = ManagedAccountType::from_account_type( - account.account_type, - account.network, - &key_source, - ) - .unwrap_or_else(|_| { - // Fallback: create without pre-generated addresses - let no_key_source = address_pool::KeySource::NoKeySource; - ManagedAccountType::from_account_type( - account.account_type, - account.network, - &no_key_source, - ) - .expect("Should succeed with NoKeySource") - }); - - Self::new(managed_type, account.network, account.is_watch_only) - } - - /// Create a ManagedAccount from a BLS Account - #[cfg(feature = "bls")] - pub fn from_bls_account(account: &BLSAccount) -> Self { - // Use the BLS public key as the key source - let key_source = address_pool::KeySource::BLSPublic(account.bls_public_key.clone()); - let managed_type = ManagedAccountType::from_account_type( - account.account_type, - account.network, - &key_source, - ) - .unwrap_or_else(|_| { - // Fallback: create without pre-generated addresses - let no_key_source = address_pool::KeySource::NoKeySource; - ManagedAccountType::from_account_type( - account.account_type, - account.network, - &no_key_source, - ) - .expect("Should succeed with NoKeySource") - }); - - Self::new(managed_type, account.network, account.is_watch_only) - } - - /// Create a ManagedAccount from an EdDSA Account - #[cfg(feature = "eddsa")] - pub fn from_eddsa_account(account: &EdDSAAccount) -> Self { - // EdDSA requires hardened derivation, so we can't generate addresses without private key - let key_source = address_pool::KeySource::NoKeySource; - let managed_type = ManagedAccountType::from_account_type( - account.account_type, - account.network, - &key_source, - ) - .expect("Should succeed with NoKeySource"); - - Self::new(managed_type, account.network, account.is_watch_only) - } - - /// Get the account index - pub fn index(&self) -> Option { - self.managed_account_type.index() - } - - /// Get the account index or 0 if none exists - pub fn index_or_default(&self) -> u32 { - self.managed_account_type.index_or_default() - } - - /// Get the managed account type - pub fn managed_type(&self) -> &ManagedAccountType { - &self.managed_account_type - } - - /// Get the next unused receive address index for standard accounts - /// Note: This requires a key source which is not available in ManagedAccount - /// Address generation should be done through a method that has access to the Account's keys - pub fn get_next_receive_address_index(&self) -> Option { - // Only applicable for standard accounts - if let ManagedAccountType::Standard { - external_addresses, - .. - } = &self.managed_account_type - { - // Get the first unused address or the next index after the last used one - if let Some(addr) = external_addresses.unused_addresses().first() { - external_addresses.address_index(addr) - } else { - // If no unused addresses, return the next index based on stats - let stats = external_addresses.stats(); - Some(stats.highest_generated.map(|h| h + 1).unwrap_or(0)) - } - } else { - None - } - } - - /// Get the next unused change address index for standard accounts - /// Note: This requires a key source which is not available in ManagedAccount - /// Address generation should be done through a method that has access to the Account's keys - pub fn get_next_change_address_index(&self) -> Option { - // Only applicable for standard accounts - if let ManagedAccountType::Standard { - internal_addresses, - .. - } = &self.managed_account_type - { - // Get the first unused address or the next index after the last used one - if let Some(addr) = internal_addresses.unused_addresses().first() { - internal_addresses.address_index(addr) - } else { - // If no unused addresses, return the next index based on stats - let stats = internal_addresses.stats(); - Some(stats.highest_generated.map(|h| h + 1).unwrap_or(0)) - } - } else { - None - } - } - - /// Get the next unused address index for single-pool account types - pub fn get_next_address_index(&self) -> Option { - match &self.managed_account_type { - ManagedAccountType::Standard { - .. - } => self.get_next_receive_address_index(), - ManagedAccountType::CoinJoin { - addresses, - .. - } - | ManagedAccountType::IdentityRegistration { - addresses, - .. - } - | ManagedAccountType::IdentityTopUp { - addresses, - .. - } - | ManagedAccountType::IdentityTopUpNotBoundToIdentity { - addresses, - .. - } - | ManagedAccountType::IdentityInvitation { - addresses, - .. - } - | ManagedAccountType::AssetLockAddressTopUp { - addresses, - .. - } - | ManagedAccountType::AssetLockShieldedAddressTopUp { - addresses, - .. - } - | ManagedAccountType::ProviderVotingKeys { - addresses, - .. - } - | ManagedAccountType::ProviderOwnerKeys { - addresses, - .. - } - | ManagedAccountType::ProviderOperatorKeys { - addresses, - .. - } - | ManagedAccountType::ProviderPlatformKeys { - addresses, - .. - } - | ManagedAccountType::DashpayReceivingFunds { - addresses, - .. - } - | ManagedAccountType::DashpayExternalAccount { - addresses, - .. - } - | ManagedAccountType::PlatformPayment { - addresses, - .. - } => { - addresses.unused_addresses().first().and_then(|addr| addresses.address_index(addr)) - } - } - } - - /// Mark an address as used - pub fn mark_address_used(&mut self, address: &Address) -> bool { - // Update metadata timestamp - self.metadata.last_used = Some(Self::current_timestamp()); - - // Use the account type's mark_address_used method - // The address pools already track gap limits internally - self.managed_account_type.mark_address_used(address) - } - - /// Add new ones for received outputs, remove spent ones - fn update_utxos( - &mut self, - tx: &Transaction, - account_match: &AccountMatch, - context: TransactionContext, - ) { - // Update UTXOs only for spendable account types - match &mut self.managed_account_type { - ManagedAccountType::Standard { - .. - } - | ManagedAccountType::CoinJoin { - .. - } - | ManagedAccountType::DashpayReceivingFunds { - .. - } - | ManagedAccountType::DashpayExternalAccount { - .. - } => { - let involved_addrs: BTreeSet<_> = account_match - .account_type_match - .all_involved_addresses() - .iter() - .map(|info| info.address.clone()) - .collect(); - let change_addrs: BTreeSet<_> = account_match - .account_type_match - .involved_change_addresses() - .iter() - .map(|info| info.address.clone()) - .collect(); - - // Detect a self-send: this account owns at least one input being - // spent. `account_match.sent` is computed by matching inputs against - // this account's UTXO set, so a non-zero value means we owned at - // least one of the spent outpoints. - let has_owned_input = account_match.sent > 0; - - let txid = tx.txid(); - let mut utxos_changed = false; - - // Insert UTXOs for outputs paying to our addresses - for (vout, output) in tx.output.iter().enumerate() { - if let Ok(addr) = Address::from_script(&output.script_pubkey, self.network) { - if involved_addrs.contains(&addr) { - let outpoint = OutPoint { - txid, - vout: vout as u32, - }; - - // Check if this outpoint was already spent by a transaction we've seen. - // This handles out-of-order block processing during rescan where a - // spending transaction at a higher height may be processed before - // the transaction that created the UTXO. - // TODO: This is mostly needed for wallet rescan from storage with the - // there is a timing issue with event processing which might lead to - // invalid UTXO set / balances. There might be a way around it. - if self.is_outpoint_spent(&outpoint) { - tracing::debug!( - outpoint = %outpoint, - "Skipping UTXO already spent by previously processed transaction" - ); - continue; - } - - // Flag outputs from a "trusted" mempool transaction we created — - // one that spends at least one of our own UTXOs and pays this - // output back to one of our internal (change) addresses. Such - // an output is just our previously-tracked funds returning, so - // `update_balance` credits it to the confirmed bucket even - // before the parent transaction settles. - let is_trusted_output = has_owned_input && change_addrs.contains(&addr); - let txout = dashcore::TxOut { - value: output.value, - script_pubkey: output.script_pubkey.clone(), - }; - let block_height = context.block_info().map_or(0, |info| info.height); - let mut utxo = - Utxo::new(outpoint, txout, addr, block_height, tx.is_coin_base()); - utxo.is_confirmed = context.confirmed(); - utxo.is_instantlocked = - matches!(context, TransactionContext::InstantSend(_)); - utxo.is_trusted = is_trusted_output; - self.utxos.insert(outpoint, utxo); - utxos_changed = true; - } - } - } - - // Remove UTXOs spent by this transaction and track spent outpoints - for input in &tx.input { - self.spent_outpoints.insert(input.previous_output); - - if self.utxos.remove(&input.previous_output).is_some() { - tracing::debug!( - outpoint = %input.previous_output, - txid = %tx.txid(), - "Removed spent UTXO" - ); - utxos_changed = true; - } - } - - if utxos_changed { - self.monitor_revision += 1; - } - } - _ => {} - } - } - - /// Re-process an existing transaction with updated context (e.g., mempool→block confirmation) - /// and potentially new address matches from gap limit rescans. - pub(crate) fn confirm_transaction( - &mut self, - tx: &Transaction, - account_match: &AccountMatch, - context: TransactionContext, - transaction_type: TransactionType, - ) -> bool { - if !self.transactions.contains_key(&tx.txid()) { - self.record_transaction(tx, account_match, context, transaction_type); - return true; - } - - let mut changed = false; - if let Some(tx_record) = self.transactions.get_mut(&tx.txid()) { - debug_assert_eq!( - tx_record.transaction_type, - transaction_type, - "transaction_type changed between recordings for {}", - tx.txid() - ); - if tx_record.context != context { - let was_confirmed = tx_record.context.confirmed(); - tx_record.update_context(context.clone()); - // Only signal a change when confirmation status actually changes, - // not for upgrades within the confirmed state (e.g. InBlock → InChainLockedBlock). - // TODO: emit a change event for InBlock → InChainLockedBlock once chainlock - // wallet transaction events are properly handled - changed = !was_confirmed; - } - } - self.update_utxos(tx, account_match, context); - changed - } - - /// Record a new transaction and update UTXOs for spendable account types - pub(crate) fn record_transaction( - &mut self, - tx: &Transaction, - account_match: &AccountMatch, - context: TransactionContext, - transaction_type: TransactionType, - ) -> TransactionRecord { - let net_amount = account_match.received as i64 - account_match.sent as i64; - - let receive_addrs: HashSet<_> = account_match - .account_type_match - .involved_receive_addresses() - .iter() - .map(|info| &info.address) - .collect(); - let change_addrs: HashSet<_> = account_match - .account_type_match - .involved_change_addresses() - .iter() - .map(|info| &info.address) - .collect(); - - // Input details must be built before `update_utxos` removes spent UTXOs - let mut input_details = Vec::new(); - if !tx.is_coin_base() { - for (idx, input) in tx.input.iter().enumerate() { - if let Some(utxo) = self.utxos.get(&input.previous_output) { - input_details.push(InputDetail { - index: idx as u32, - value: utxo.txout.value, - address: utxo.address.clone(), - }); - } - } - } - - // Use both UTXO-based input details and `account_match.sent` as signals - // that we created this transaction. The UTXO set may be incomplete - // (e.g., partial rescan) so `account_match.sent > 0` catches cases where - // the transaction still spent our funds even without matching UTXOs. - let has_inputs = !input_details.is_empty() || account_match.sent > 0; - - let resolved_outputs: Vec> = tx - .output - .iter() - .map(|output| Address::from_script(&output.script_pubkey, self.network).ok()) - .collect(); - - // Build output details — annotate every output with its role - let mut output_details = Vec::new(); - for (idx, output) in tx.output.iter().enumerate() { - let role = match &resolved_outputs[idx] { - Some(addr) if receive_addrs.contains(addr) => OutputRole::Received, - Some(addr) if change_addrs.contains(addr) => OutputRole::Change, - Some(_) if has_inputs => OutputRole::Sent, - Some(_) => continue, - None => { - if output.script_pubkey.is_provably_unspendable() { - OutputRole::Unspendable - } else if has_inputs { - OutputRole::Sent - } else { - continue; - } - } - }; - output_details.push(OutputDetail { - index: idx as u32, - role, - address: resolved_outputs[idx].clone(), - value: output.value, - }); - } - - // Determine direction - let has_sent = output_details.iter().any(|d| d.role == OutputRole::Sent); - let has_our_outputs = output_details - .iter() - .any(|d| d.role == OutputRole::Received || d.role == OutputRole::Change); - let direction = if transaction_type == TransactionType::CoinJoin { - TransactionDirection::CoinJoin - } else if !has_sent && has_inputs && has_our_outputs { - TransactionDirection::Internal - } else if has_inputs { - TransactionDirection::Outgoing - } else { - TransactionDirection::Incoming - }; - - let tx_record = TransactionRecord::new( - tx.clone(), - self.managed_account_type.to_account_type(), - context.clone(), - transaction_type, - direction, - input_details, - output_details, - net_amount, - ); - - let record = tx_record.clone(); - self.transactions.insert(tx.txid(), tx_record); - - self.update_utxos(tx, account_match, context); - record - } - - /// Mark all UTXOs belonging to a transaction as InstantSend-locked. - /// Returns `true` if any UTXO was newly marked. - pub(crate) fn mark_utxos_instant_send(&mut self, txid: &Txid) -> bool { - let mut any_changed = false; - for utxo in self.utxos.values_mut() { - if utxo.outpoint.txid == *txid && !utxo.is_instantlocked { - utxo.is_instantlocked = true; - any_changed = true; - } - } - any_changed - } - - /// Return the UTXOs of this account for which - /// [`Utxo::is_spendable`] holds at `last_processed_height`. See that method - /// for the exact policy. Call this per-account rather than - /// aggregating across the wallet, since spendability is - /// account-type specific. - pub fn spendable_utxos(&self, last_processed_height: u32) -> BTreeSet<&Utxo> { - self.utxos.values().filter(|utxo| utxo.is_spendable(last_processed_height)).collect() - } - - /// Update the account balance. - /// - /// Mature, non-locked UTXOs land in either the `confirmed` bucket - /// (in a block, InstantSend-locked, or trusted mempool change) or - /// the `unconfirmed` bucket (untrusted mempool only). Trusted - /// mempool change is surfaced as confirmed because it is just our - /// previously-tracked funds returning — see [`Utxo::is_trusted`]. - /// Both buckets are spendable per [`Utxo::is_spendable`]; the split - /// is only for display. - pub fn update_balance(&mut self, last_processed_height: u32) { - let mut confirmed = 0; - let mut unconfirmed = 0; - let mut immature = 0; - let mut locked = 0; - for utxo in self.utxos.values() { - let value = utxo.txout.value; - if utxo.is_locked { - locked += value; - } else if !utxo.is_mature(last_processed_height) { - immature += value; - } else if utxo.is_confirmed || utxo.is_instantlocked || utxo.is_trusted { - confirmed += value; - } else { - unconfirmed += value; - } - } - self.balance = WalletCoreBalance::new(confirmed, unconfirmed, immature, locked); - self.metadata.last_used = Some(Self::current_timestamp()); - } - - /// Get all addresses from all pools - pub fn all_addresses(&self) -> Vec
{ - self.managed_account_type.all_addresses() - } - - /// Check if an address belongs to this account - pub fn contains_address(&self, address: &Address) -> bool { - self.managed_account_type.contains_address(address) - } - - /// Check if a script pub key belongs to this account - pub fn contains_script_pub_key(&self, script_pub_key: &ScriptBuf) -> bool { - self.managed_account_type.contains_script_pub_key(script_pub_key) - } - - /// Get address info for a given address - pub fn get_address_info(&self, address: &Address) -> Option { - self.managed_account_type.get_address_info(address) - } - - /// Generate the next receive address using the optionally provided extended public key - /// If no key is provided, can only return pre-generated unused addresses - /// This method derives a new address from the account's xpub but does not add it to the pool - /// The address must be added to the pool separately with proper tracking - pub fn next_receive_address( - &mut self, - account_xpub: Option<&ExtendedPubKey>, - add_to_state: bool, - ) -> Result { - // For standard accounts, use the address pool to get the next unused address - if let ManagedAccountType::Standard { - external_addresses, - .. - } = &mut self.managed_account_type - { - // Create appropriate key source based on whether xpub is provided - let key_source = match account_xpub { - Some(xpub) => address_pool::KeySource::Public(*xpub), - None => address_pool::KeySource::NoKeySource, - }; - - let addr = - external_addresses.next_unused(&key_source, add_to_state).map_err(|e| match e { - crate::error::Error::NoKeySource => { - "No unused addresses available and no key source provided" - } - _ => "Failed to generate receive address", - })?; - self.monitor_revision += 1; - Ok(addr) - } else { - Err("Cannot generate receive address for non-standard account type") - } - } - - /// Generate the next change address using the optionally provided extended public key - /// If no key is provided, can only return pre-generated unused addresses - /// This method uses the address pool to properly track and generate addresses - pub fn next_change_address( - &mut self, - account_xpub: Option<&ExtendedPubKey>, - add_to_state: bool, - ) -> Result { - // For standard accounts, use the address pool to get the next unused address - if let ManagedAccountType::Standard { - internal_addresses, - .. - } = &mut self.managed_account_type - { - // Create appropriate key source based on whether xpub is provided - let key_source = match account_xpub { - Some(xpub) => address_pool::KeySource::Public(*xpub), - None => address_pool::KeySource::NoKeySource, - }; - - let addr = - internal_addresses.next_unused(&key_source, add_to_state).map_err(|e| match e { - crate::error::Error::NoKeySource => { - "No unused addresses available and no key source provided" - } - _ => "Failed to generate change address", - })?; - self.monitor_revision += 1; - Ok(addr) - } else { - Err("Cannot generate change address for non-standard account type") - } - } - - /// Generate multiple receive addresses at once using the optionally provided extended public key - /// - /// Returns the requested number of unused receive addresses, generating new ones if needed. - /// This is more efficient than calling `next_receive_address` multiple times. - /// If no key is provided, can only return pre-generated unused addresses. - pub fn next_receive_addresses( - &mut self, - account_xpub: Option<&ExtendedPubKey>, - count: usize, - add_to_state: bool, - ) -> Result, String> { - // For standard accounts, use the address pool to get multiple unused addresses - if let ManagedAccountType::Standard { - external_addresses, - .. - } = &mut self.managed_account_type - { - // Create appropriate key source based on whether xpub is provided - let key_source = match account_xpub { - Some(xpub) => address_pool::KeySource::Public(*xpub), - None => address_pool::KeySource::NoKeySource, - }; - - let addresses = - external_addresses.next_unused_multiple(count, &key_source, add_to_state); - if addresses.is_empty() && count > 0 { - Err("Failed to generate any receive addresses".to_string()) - } else if addresses.len() < count - && matches!(key_source, address_pool::KeySource::NoKeySource) - { - Err(format!( - "Could only generate {} out of {} requested addresses (no key source)", - addresses.len(), - count - )) - } else { - Ok(addresses) - } - } else { - Err("Cannot generate receive addresses for non-standard account type".to_string()) - } - } - - /// Generate multiple change addresses at once using the optionally provided extended public key - /// - /// Returns the requested number of unused change addresses, generating new ones if needed. - /// This is more efficient than calling `next_change_address` multiple times. - /// If no key is provided, can only return pre-generated unused addresses. - pub fn next_change_addresses( - &mut self, - account_xpub: Option<&ExtendedPubKey>, - count: usize, - add_to_state: bool, - ) -> Result, String> { - // For standard accounts, use the address pool to get multiple unused addresses - if let ManagedAccountType::Standard { - internal_addresses, - .. - } = &mut self.managed_account_type - { - // Create appropriate key source based on whether xpub is provided - let key_source = match account_xpub { - Some(xpub) => address_pool::KeySource::Public(*xpub), - None => address_pool::KeySource::NoKeySource, - }; - - let addresses = - internal_addresses.next_unused_multiple(count, &key_source, add_to_state); - if addresses.is_empty() && count > 0 { - Err("Failed to generate any change addresses".to_string()) - } else if addresses.len() < count - && matches!(key_source, address_pool::KeySource::NoKeySource) - { - Err(format!( - "Could only generate {} out of {} requested addresses (no key source)", - addresses.len(), - count - )) - } else { - Ok(addresses) - } - } else { - Err("Cannot generate change addresses for non-standard account type".to_string()) - } - } - - /// Generate the next address for non-standard accounts - /// This method is for special accounts like Identity, Provider accounts, etc. - /// Standard accounts (BIP44/BIP32) should use next_receive_address or next_change_address - pub fn next_address( - &mut self, - account_xpub: Option<&ExtendedPubKey>, - add_to_state: bool, - ) -> Result { - match &mut self.managed_account_type { - ManagedAccountType::Standard { - .. - } => Err("Standard accounts must use next_receive_address or next_change_address"), - ManagedAccountType::CoinJoin { - addresses, - .. - } - | ManagedAccountType::IdentityRegistration { - addresses, - .. - } - | ManagedAccountType::IdentityTopUpNotBoundToIdentity { - addresses, - .. - } - | ManagedAccountType::IdentityInvitation { - addresses, - .. - } - | ManagedAccountType::AssetLockAddressTopUp { - addresses, - .. - } - | ManagedAccountType::AssetLockShieldedAddressTopUp { - addresses, - .. - } - | ManagedAccountType::ProviderVotingKeys { - addresses, - .. - } - | ManagedAccountType::ProviderOwnerKeys { - addresses, - .. - } - | ManagedAccountType::ProviderOperatorKeys { - addresses, - .. - } - | ManagedAccountType::ProviderPlatformKeys { - addresses, - .. - } - | ManagedAccountType::DashpayReceivingFunds { - addresses, - .. - } - | ManagedAccountType::DashpayExternalAccount { - addresses, - .. - } - | ManagedAccountType::PlatformPayment { - addresses, - .. - } => { - // Create appropriate key source based on whether xpub is provided - let key_source = match account_xpub { - Some(xpub) => address_pool::KeySource::Public(*xpub), - None => address_pool::KeySource::NoKeySource, - }; - - addresses.next_unused(&key_source, add_to_state).map_err(|e| match e { - crate::error::Error::NoKeySource => { - "No unused addresses available and no key source provided" - } - _ => "Failed to generate address", - }) - } - ManagedAccountType::IdentityTopUp { - addresses, - .. - } => { - // Identity top-up has an address pool - let key_source = match account_xpub { - Some(xpub) => address_pool::KeySource::Public(*xpub), - None => address_pool::KeySource::NoKeySource, - }; - - addresses.next_unused(&key_source, add_to_state).map_err(|e| match e { - crate::error::Error::NoKeySource => { - "No unused addresses available and no key source provided" - } - _ => "Failed to generate address", - }) - } - } - } - - /// Generate the next address with full info for non-standard accounts - /// This method is for special accounts like Identity, Provider accounts, etc. - /// Standard accounts (BIP44/BIP32) should use next_receive_address_with_info or next_change_address_with_info - pub fn next_address_with_info( - &mut self, - account_xpub: Option<&ExtendedPubKey>, - add_to_state: bool, - ) -> Result { - match &mut self.managed_account_type { - ManagedAccountType::Standard { - .. - } => Err("Standard accounts must use next_receive_address_with_info or next_change_address_with_info"), - ManagedAccountType::CoinJoin { - addresses, - .. - } - | ManagedAccountType::IdentityRegistration { - addresses, - .. - } - | ManagedAccountType::IdentityTopUpNotBoundToIdentity { - addresses, - .. - } - | ManagedAccountType::IdentityInvitation { - addresses, - .. - } - | ManagedAccountType::AssetLockAddressTopUp { - addresses, - .. - } - | ManagedAccountType::AssetLockShieldedAddressTopUp { - addresses, - .. - } - | ManagedAccountType::ProviderVotingKeys { - addresses, - .. - } - | ManagedAccountType::ProviderOwnerKeys { - addresses, - .. - } - | ManagedAccountType::ProviderOperatorKeys { - addresses, - .. - } - | ManagedAccountType::ProviderPlatformKeys { - addresses, - .. - } - | ManagedAccountType::DashpayReceivingFunds { - addresses, - .. - } - | ManagedAccountType::DashpayExternalAccount { - addresses, - .. - } - | ManagedAccountType::PlatformPayment { - addresses, - .. - } => { - // Create appropriate key source based on whether xpub is provided - let key_source = match account_xpub { - Some(xpub) => address_pool::KeySource::Public(*xpub), - None => address_pool::KeySource::NoKeySource, - }; - - addresses.next_unused_with_info(&key_source, add_to_state).map_err(|e| match e { - crate::error::Error::NoKeySource => { - "No unused addresses available and no key source provided" - } - _ => "Failed to generate address with info", - }) - } - ManagedAccountType::IdentityTopUp { - addresses, - .. - } => { - // Identity top-up has an address pool - let key_source = match account_xpub { - Some(xpub) => address_pool::KeySource::Public(*xpub), - None => address_pool::KeySource::NoKeySource, - }; - - addresses.next_unused_with_info(&key_source, add_to_state).map_err(|e| match e { - crate::error::Error::NoKeySource => { - "No unused addresses available and no key source provided" - } - _ => "Failed to generate address with info", - }) - } - } - } - - /// Generate the next BLS operator key (only for ProviderOperatorKeys accounts) - /// Returns the BLS public key at the next unused index - #[cfg(feature = "bls")] - pub fn next_bls_operator_key( - &mut self, - account_xpub: Option, - add_to_state: bool, - ) -> Result, &'static str> { - match &mut self.managed_account_type { - ManagedAccountType::ProviderOperatorKeys { - addresses, - .. - } => { - // Create key source from the optional BLS public key - let key_source = match account_xpub { - Some(xpub) => address_pool::KeySource::BLSPublic(xpub), - None => address_pool::KeySource::NoKeySource, - }; - - // Use next_unused_with_info to get the next address (handles caching and derivation) - let info = addresses - .next_unused_with_info(&key_source, add_to_state) - .map_err(|_| "Failed to get next unused address")?; - - // Extract the BLS public key from the address info - let Some(PublicKeyType::BLS(pub_key_bytes)) = info.public_key else { - return Err("Expected BLS public key but got different key type"); - }; - - // Mark as used - addresses.mark_index_used(info.index); - - // Convert bytes to BLS public key - use dashcore::blsful::{Bls12381G2Impl, PublicKey, SerializationFormat}; - let public_key = PublicKey::::from_bytes_with_mode( - &pub_key_bytes, - SerializationFormat::Modern, - ) - .map_err(|_| "Failed to deserialize BLS public key")?; - - Ok(public_key) - } - _ => Err("This method only works for ProviderOperatorKeys accounts"), - } - } - - /// Generate the next EdDSA platform key (only for ProviderPlatformKeys accounts) - /// Returns the Ed25519 public key and address info at the next unused index - #[cfg(feature = "eddsa")] - pub fn next_eddsa_platform_key( - &mut self, - account_xpriv: crate::derivation_slip10::ExtendedEd25519PrivKey, - add_to_state: bool, - ) -> Result<(crate::derivation_slip10::VerifyingKey, AddressInfo), &'static str> { - match &mut self.managed_account_type { - ManagedAccountType::ProviderPlatformKeys { - addresses, - .. - } => { - // Create key source from the EdDSA private key - let key_source = address_pool::KeySource::EdDSAPrivate(account_xpriv); - - // Use next_unused_with_info to get the next address (handles caching and derivation) - let info = addresses - .next_unused_with_info(&key_source, add_to_state) - .map_err(|_| "Failed to get next unused address")?; - - // Extract the EdDSA public key from the address info - let Some(PublicKeyType::EdDSA(pub_key_bytes)) = info.public_key.clone() else { - return Err("Expected EdDSA public key but got different key type"); - }; - - // Mark as used - addresses.mark_index_used(info.index); - - let verifying_key = crate::derivation_slip10::VerifyingKey::from_bytes( - &pub_key_bytes.try_into().map_err(|_| "Invalid EdDSA public key length")?, - ) - .map_err(|_| "Failed to deserialize EdDSA public key")?; - - Ok((verifying_key, info)) - } - _ => Err("This method only works for ProviderPlatformKeys accounts"), - } - } - - /// Consume the next unused address and derive its private key. - /// - /// Used for one-time keys (asset lock funding, identity registration, etc.). - /// The address is marked as used so subsequent calls return fresh keys. - /// - /// Only works for single-pool account types (not Standard accounts). - pub fn next_private_key( - &mut self, - root_xpriv: &crate::wallet::root_extended_keys::RootExtendedPrivKey, - network: Network, - ) -> Result<[u8; 32], &'static str> { - if matches!(self.managed_account_type, ManagedAccountType::Standard { .. }) { - return Err("Standard accounts must use next_receive_address or next_change_address"); - } - - let mut pools = self.managed_account_type.address_pools_mut(); - let pool = pools.first_mut().ok_or("Account has no address pool")?; - - let info = pool - .next_unused_with_info(&address_pool::KeySource::NoKeySource, false) - .map_err(|_| "No unused address available")?; - - pool.mark_index_used(info.index); - - let secp = secp256k1::Secp256k1::new(); - let root_ext_priv = root_xpriv.to_extended_priv_key(network); - let derived_xpriv = - root_ext_priv.derive_priv(&secp, &info.path).map_err(|_| "Key derivation failed")?; - - let mut private_key = [0u8; 32]; - private_key.copy_from_slice(&derived_xpriv.private_key[..]); - Ok(private_key) - } - - /// Peek at the next unused address's path and index **without** marking - /// the index used. - /// - /// Intended for two-phase flows where path consumption must not commit - /// until an external operation (e.g. an async signer request) has - /// succeeded. Pair with [`Self::mark_first_pool_index_used`] to - /// commit, or drop the result to leave the pool untouched. Calling - /// `peek_next_path` twice without committing in between returns the - /// same `(path, index)`. - /// - /// Only works for single-pool account types (not Standard accounts). - pub fn peek_next_path(&mut self) -> Result<(crate::DerivationPath, u32), &'static str> { - if matches!(self.managed_account_type, ManagedAccountType::Standard { .. }) { - return Err("Standard accounts must use next_receive_address or next_change_address"); - } - - let mut pools = self.managed_account_type.address_pools_mut(); - let pool = pools.first_mut().ok_or("Account has no address pool")?; - - let info = pool - .next_unused_with_info(&address_pool::KeySource::NoKeySource, false) - .map_err(|_| "No unused address available")?; - - Ok((info.path, info.index)) - } - - /// Mark an index on the account's first address pool as used. - /// - /// Commits what [`Self::peek_next_path`] returned. Accepts any index — - /// callers are responsible for passing back the index they peeked, not - /// an arbitrary one. - /// - /// Only works for single-pool account types (not Standard accounts). - pub fn mark_first_pool_index_used(&mut self, index: u32) -> Result<(), &'static str> { - if matches!(self.managed_account_type, ManagedAccountType::Standard { .. }) { - return Err("Standard accounts must use next_receive_address or next_change_address"); - } - - let mut pools = self.managed_account_type.address_pools_mut(); - let pool = pools.first_mut().ok_or("Account has no address pool")?; - pool.mark_index_used(index); - Ok(()) - } - - /// Consume the next unused address and return only its derivation path. - /// - /// Analogous to [`Self::next_private_key`] but does not require any - /// root extended private key: used when signing is delegated to an - /// external [`Signer`](crate::signer::Signer), which holds the keys - /// and only needs the path to produce signatures or public keys. - /// - /// Consumes the index immediately; callers that need to defer the - /// commit until after an external operation succeeds should use - /// [`Self::peek_next_path`] + [`Self::mark_first_pool_index_used`] - /// instead. - /// - /// Only works for single-pool account types (not Standard accounts). - pub fn next_path(&mut self) -> Result { - let (path, index) = self.peek_next_path()?; - self.mark_first_pool_index_used(index)?; - Ok(path) - } - - /// Get the derivation path for an address if it belongs to this account - pub fn address_derivation_path(&self, address: &Address) -> Option { - self.managed_account_type.get_address_derivation_path(address) - } - - /// Get the current timestamp (for metadata) - fn current_timestamp() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - } - - /// Get total address count across all pools - pub fn total_address_count(&self) -> usize { - self.managed_account_type - .address_pools() - .iter() - .map(|pool| pool.stats().total_generated as usize) - .sum() - } - - /// Get used address count across all pools - pub fn used_address_count(&self) -> usize { - self.managed_account_type - .address_pools() - .iter() - .map(|pool| pool.stats().used_count as usize) - .sum() - } - - /// Get the external gap limit for standard accounts - pub fn external_gap_limit(&self) -> Option { - match &self.managed_account_type { - ManagedAccountType::Standard { - external_addresses, - .. - } => Some(external_addresses.gap_limit), - _ => None, - } - } - - /// Get the internal gap limit for standard accounts - pub fn internal_gap_limit(&self) -> Option { - match &self.managed_account_type { - ManagedAccountType::Standard { - internal_addresses, - .. - } => Some(internal_addresses.gap_limit), - _ => None, - } - } - - /// Get the gap limit for non-standard (single-pool) accounts - pub fn gap_limit(&self) -> Option { - match &self.managed_account_type { - ManagedAccountType::Standard { - .. - } => None, - ManagedAccountType::CoinJoin { - addresses, - .. - } - | ManagedAccountType::IdentityRegistration { - addresses, - .. - } - | ManagedAccountType::IdentityTopUp { - addresses, - .. - } - | ManagedAccountType::IdentityTopUpNotBoundToIdentity { - addresses, - .. - } - | ManagedAccountType::IdentityInvitation { - addresses, - .. - } - | ManagedAccountType::AssetLockAddressTopUp { - addresses, - .. - } - | ManagedAccountType::AssetLockShieldedAddressTopUp { - addresses, - .. - } - | ManagedAccountType::ProviderVotingKeys { - addresses, - .. - } - | ManagedAccountType::ProviderOwnerKeys { - addresses, - .. - } - | ManagedAccountType::ProviderOperatorKeys { - addresses, - .. - } - | ManagedAccountType::ProviderPlatformKeys { - addresses, - .. - } - | ManagedAccountType::DashpayReceivingFunds { - addresses, - .. - } - | ManagedAccountType::DashpayExternalAccount { - addresses, - .. - } - | ManagedAccountType::PlatformPayment { - addresses, - .. - } => Some(addresses.gap_limit), - } - } -} - -impl ManagedAccountTrait for ManagedCoreAccount { - fn managed_account_type(&self) -> &ManagedAccountType { - &self.managed_account_type - } - - fn managed_account_type_mut(&mut self) -> &mut ManagedAccountType { - &mut self.managed_account_type - } - - fn network(&self) -> Network { - self.network - } - - fn metadata(&self) -> &AccountMetadata { - &self.metadata - } - - fn metadata_mut(&mut self) -> &mut AccountMetadata { - &mut self.metadata - } - - fn is_watch_only(&self) -> bool { - self.is_watch_only - } - - fn balance(&self) -> &WalletCoreBalance { - &self.balance - } - - fn balance_mut(&mut self) -> &mut WalletCoreBalance { - &mut self.balance - } - - fn transactions(&self) -> &BTreeMap { - &self.transactions - } - - fn transactions_mut(&mut self) -> &mut BTreeMap { - &mut self.transactions - } - - fn utxos(&self) -> &BTreeMap { - &self.utxos - } - - fn utxos_mut(&mut self) -> &mut BTreeMap { - &mut self.utxos - } -} - -#[cfg(feature = "serde")] -impl<'de> Deserialize<'de> for ManagedCoreAccount { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - #[derive(Deserialize)] - struct Helper { - managed_account_type: ManagedAccountType, - network: Network, - metadata: AccountMetadata, - is_watch_only: bool, - balance: WalletCoreBalance, - transactions: BTreeMap, - utxos: BTreeMap, - } - - let helper = Helper::deserialize(deserializer)?; - - let spent_outpoints = helper - .transactions - .values() - .flat_map(|record| &record.transaction.input) - .map(|input| input.previous_output) - .collect(); - - Ok(ManagedCoreAccount { - managed_account_type: helper.managed_account_type, - network: helper.network, - metadata: helper.metadata, - is_watch_only: helper.is_watch_only, - balance: helper.balance, - transactions: helper.transactions, - utxos: helper.utxos, - spent_outpoints, - monitor_revision: 0, - }) - } -} +pub use managed_core_funds_account::ManagedCoreFundsAccount; +pub use managed_core_keys_account::ManagedCoreKeysAccount; diff --git a/key-wallet/src/test_utils/account.rs b/key-wallet/src/test_utils/account.rs index a4d8fa5ba..720ab9492 100644 --- a/key-wallet/src/test_utils/account.rs +++ b/key-wallet/src/test_utils/account.rs @@ -1,10 +1,10 @@ use crate::account::StandardAccountType; use crate::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; use crate::managed_account::managed_account_type::ManagedAccountType; -use crate::managed_account::ManagedCoreAccount; +use crate::managed_account::ManagedCoreFundsAccount; use crate::{DerivationPath, Network}; -impl ManagedCoreAccount { +impl ManagedCoreFundsAccount { /// Create a test managed account with a standard BIP44 type and empty address pools pub fn dummy_bip44() -> Self { let base_path = DerivationPath::master(); @@ -34,6 +34,6 @@ impl ManagedCoreAccount { internal_addresses: internal_pool, }; - ManagedCoreAccount::new(account_type, Network::Regtest, false) + ManagedCoreFundsAccount::new(account_type, Network::Regtest, false) } } diff --git a/key-wallet/src/test_utils/wallet.rs b/key-wallet/src/test_utils/wallet.rs index 2d342d3bf..7872abfe8 100644 --- a/key-wallet/src/test_utils/wallet.rs +++ b/key-wallet/src/test_utils/wallet.rs @@ -1,7 +1,7 @@ use dashcore::{Address, Network, Transaction, Txid}; use crate::{ - account::{ManagedCoreAccount, TransactionRecord}, + account::{ManagedCoreFundsAccount, TransactionRecord}, transaction_checking::{TransactionCheckResult, TransactionContext, WalletTransactionChecker}, wallet::{initialization::WalletAccountCreationOptions, ManagedWalletInfo}, ExtendedPubKey, Utxo, Wallet, @@ -55,7 +55,7 @@ impl TestWalletContext { } /// Returns the first BIP44 managed account (immutable). - pub fn bip44_account(&self) -> &ManagedCoreAccount { + pub fn bip44_account(&self) -> &ManagedCoreFundsAccount { self.managed_wallet.first_bip44_managed_account().expect("Should have BIP44 account") } diff --git a/key-wallet/src/tests/balance_tests.rs b/key-wallet/src/tests/balance_tests.rs index 05706879b..e47de9066 100644 --- a/key-wallet/src/tests/balance_tests.rs +++ b/key-wallet/src/tests/balance_tests.rs @@ -1,6 +1,6 @@ //! Tests for update_balance() UTXO categorization. -use crate::managed_account::ManagedCoreAccount; +use crate::managed_account::ManagedCoreFundsAccount; use crate::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use crate::wallet::managed_wallet_info::ManagedWalletInfo; use crate::{Utxo, WalletCoreBalance}; @@ -8,7 +8,7 @@ use crate::{Utxo, WalletCoreBalance}; #[test] fn test_balance_with_mixed_utxo_types() { let mut wallet_info = ManagedWalletInfo::dummy(1); - let mut account = ManagedCoreAccount::dummy_bip44(); + let mut account = ManagedCoreFundsAccount::dummy_bip44(); // Regular confirmed UTXO let utxo1 = Utxo::dummy(1, 100_000, 1000, false, true); @@ -30,7 +30,7 @@ fn test_balance_with_mixed_utxo_types() { #[test] fn test_coinbase_maturity_boundary() { let mut wallet_info = ManagedWalletInfo::dummy(2); - let mut account = ManagedCoreAccount::dummy_bip44(); + let mut account = ManagedCoreFundsAccount::dummy_bip44(); // Coinbase at height 1000 let utxo = Utxo::dummy(1, 50_000_000, 1000, true, true); @@ -52,7 +52,7 @@ fn test_coinbase_maturity_boundary() { #[test] fn test_locked_utxos_in_locked_balance() { let mut wallet_info = ManagedWalletInfo::dummy(3); - let mut account = ManagedCoreAccount::dummy_bip44(); + let mut account = ManagedCoreFundsAccount::dummy_bip44(); let mut utxo = Utxo::dummy(1, 100_000, 1000, false, true); utxo.is_locked = true; @@ -68,7 +68,7 @@ fn test_locked_utxos_in_locked_balance() { #[test] fn test_unconfirmed_utxos_in_unconfirmed_balance() { let mut wallet_info = ManagedWalletInfo::dummy(4); - let mut account = ManagedCoreAccount::dummy_bip44(); + let mut account = ManagedCoreFundsAccount::dummy_bip44(); let utxo = Utxo::dummy(1, 100_000, 0, false, false); account.utxos.insert(utxo.outpoint, utxo); diff --git a/key-wallet/src/tests/spent_outpoints_tests.rs b/key-wallet/src/tests/spent_outpoints_tests.rs index 92f92244c..931c01610 100644 --- a/key-wallet/src/tests/spent_outpoints_tests.rs +++ b/key-wallet/src/tests/spent_outpoints_tests.rs @@ -5,7 +5,7 @@ use dashcore::{TxIn, Txid}; use crate::account::{AccountType, StandardAccountType, TransactionRecord}; use crate::managed_account::transaction_record::TransactionDirection; -use crate::managed_account::ManagedCoreAccount; +use crate::managed_account::ManagedCoreFundsAccount; use crate::transaction_checking::{TransactionContext, TransactionType}; /// Create a transaction that spends the given outpoints. @@ -54,14 +54,14 @@ fn record_from_tx(tx: &Transaction) -> TransactionRecord { #[test] fn fresh_account_has_empty_spent_outpoints() { - let account = ManagedCoreAccount::dummy_bip44(); + let account = ManagedCoreFundsAccount::dummy_bip44(); assert!(account.transactions.is_empty()); let probe = OutPoint::new(Txid::from([0xAA; 32]), 0); // Accessing spent_outpoints on a fresh account should not panic or misbehave. // We verify indirectly via serde round-trip (spent_outpoints is private). let json = serde_json::to_string(&account).unwrap(); - let deserialized: ManagedCoreAccount = serde_json::from_str(&json).unwrap(); + let deserialized: ManagedCoreFundsAccount = serde_json::from_str(&json).unwrap(); // No transactions, so spent_outpoints stays empty after round-trip. assert!(deserialized.transactions.is_empty()); // Confirm the serialized form does not contain spent_outpoints. @@ -71,7 +71,7 @@ fn fresh_account_has_empty_spent_outpoints() { #[test] fn serde_round_trip_rebuilds_spent_outpoints() { - let mut account = ManagedCoreAccount::dummy_bip44(); + let mut account = ManagedCoreFundsAccount::dummy_bip44(); let outpoint_a = OutPoint::new(Txid::from([0x01; 32]), 0); let outpoint_b = OutPoint::new(Txid::from([0x02; 32]), 1); @@ -84,20 +84,20 @@ fn serde_round_trip_rebuilds_spent_outpoints() { assert!(!json.contains("spent_outpoints")); // Deserialize: spent_outpoints should be rebuilt from transactions - let deserialized: ManagedCoreAccount = serde_json::from_str(&json).unwrap(); + let deserialized: ManagedCoreFundsAccount = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.transactions.len(), 1); // Verify the rebuilt set by serializing again and comparing transactions // (spent_outpoints is private, so we test behavior through a second round-trip // to confirm stability) let json2 = serde_json::to_string(&deserialized).unwrap(); - let deserialized2: ManagedCoreAccount = serde_json::from_str(&json2).unwrap(); + let deserialized2: ManagedCoreFundsAccount = serde_json::from_str(&json2).unwrap(); assert_eq!(deserialized2.transactions.len(), 1); } #[test] fn receive_only_account_round_trips_correctly() { - let mut account = ManagedCoreAccount::dummy_bip44(); + let mut account = ManagedCoreFundsAccount::dummy_bip44(); // Add a receive-only transaction (coinbase-like, no real spent outpoints) let tx = receive_only_tx(); @@ -108,18 +108,18 @@ fn receive_only_account_round_trips_correctly() { // Round-trip should work without issues (no rebuild loop) let json = serde_json::to_string(&account).unwrap(); - let deserialized: ManagedCoreAccount = serde_json::from_str(&json).unwrap(); + let deserialized: ManagedCoreFundsAccount = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.transactions.len(), 1); // A second round-trip should be stable let json2 = serde_json::to_string(&deserialized).unwrap(); - let deserialized2: ManagedCoreAccount = serde_json::from_str(&json2).unwrap(); + let deserialized2: ManagedCoreFundsAccount = serde_json::from_str(&json2).unwrap(); assert_eq!(deserialized2.transactions.len(), 1); } #[test] fn multiple_transactions_all_inputs_tracked_after_round_trip() { - let mut account = ManagedCoreAccount::dummy_bip44(); + let mut account = ManagedCoreFundsAccount::dummy_bip44(); let outpoint_1 = OutPoint::new(Txid::from([0x10; 32]), 0); let outpoint_2 = OutPoint::new(Txid::from([0x20; 32]), 0); @@ -132,7 +132,7 @@ fn multiple_transactions_all_inputs_tracked_after_round_trip() { account.transactions.insert(tx2.txid(), record_from_tx(&tx2)); let json = serde_json::to_string(&account).unwrap(); - let deserialized: ManagedCoreAccount = serde_json::from_str(&json).unwrap(); + let deserialized: ManagedCoreFundsAccount = serde_json::from_str(&json).unwrap(); // All three outpoints should be in the rebuilt spent set. // We verify by confirming the transaction inputs survived the round-trip. diff --git a/key-wallet/src/transaction_checking/account_checker.rs b/key-wallet/src/transaction_checking/account_checker.rs index 61da3737b..6ab764aca 100644 --- a/key-wallet/src/transaction_checking/account_checker.rs +++ b/key-wallet/src/transaction_checking/account_checker.rs @@ -6,7 +6,7 @@ use std::collections::BTreeMap; use super::transaction_router::AccountTypeToCheck; -use crate::account::{ManagedAccountCollection, ManagedCoreAccount}; +use crate::account::{ManagedAccountCollection, ManagedCoreFundsAccount}; use crate::managed_account::address_pool::{AddressInfo, PublicKeyType}; use crate::managed_account::managed_account_type::ManagedAccountType; use crate::managed_account::transaction_record::TransactionRecord; @@ -501,7 +501,7 @@ impl ManagedAccountCollection { /// Check indexed accounts (BTreeMap of accounts) fn check_indexed_accounts( - accounts: &BTreeMap, + accounts: &BTreeMap, tx: &Transaction, ) -> Vec { let mut matches = Vec::new(); @@ -514,7 +514,7 @@ impl ManagedAccountCollection { } } -impl ManagedCoreAccount { +impl ManagedCoreFundsAccount { /// Classify an address within this account pub fn classify_address(&self, address: &Address) -> AddressClassification { match &self.managed_account_type { diff --git a/key-wallet/src/transaction_checking/wallet_checker.rs b/key-wallet/src/transaction_checking/wallet_checker.rs index c7830788e..f01f8c6d1 100644 --- a/key-wallet/src/transaction_checking/wallet_checker.rs +++ b/key-wallet/src/transaction_checking/wallet_checker.rs @@ -1168,7 +1168,7 @@ mod tests { assert!(ctx.first_utxo().is_confirmed); } - /// Test `confirm_transaction` backfill directly on `ManagedCoreAccount` when the + /// Test `confirm_transaction` backfill directly on `ManagedCoreFundsAccount` when the /// account has no prior record of the transaction. #[tokio::test] async fn test_managed_account_confirm_backfills_missing_transaction() { diff --git a/key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs b/key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs index 4386ed691..f4971479b 100644 --- a/key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs +++ b/key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs @@ -11,7 +11,7 @@ use secp256k1::PublicKey; use std::collections::HashMap; use std::fmt; -use crate::managed_account::ManagedCoreAccount; +use crate::managed_account::ManagedCoreFundsAccount; use crate::signer::{Signer, SignerMethod}; use crate::wallet::managed_wallet_info::coin_selection::SelectionStrategy; use crate::wallet::managed_wallet_info::fee::FeeRate; @@ -141,7 +141,7 @@ fn resolve_funding_account( accounts: &mut crate::account::ManagedAccountCollection, funding_type: AssetLockFundingType, identity_index: u32, -) -> Result<&mut ManagedCoreAccount, AssetLockError> { +) -> Result<&mut ManagedCoreFundsAccount, AssetLockError> { match funding_type { AssetLockFundingType::IdentityRegistration => accounts .identity_registration diff --git a/key-wallet/src/wallet/managed_wallet_info/helpers.rs b/key-wallet/src/wallet/managed_wallet_info/helpers.rs index 0271c372f..ae6bbd62f 100644 --- a/key-wallet/src/wallet/managed_wallet_info/helpers.rs +++ b/key-wallet/src/wallet/managed_wallet_info/helpers.rs @@ -2,24 +2,24 @@ use super::ManagedWalletInfo; use crate::account::account_collection::PlatformPaymentAccountKey; -use crate::account::ManagedCoreAccount; +use crate::account::ManagedCoreFundsAccount; use crate::managed_account::managed_platform_account::ManagedPlatformAccount; impl ManagedWalletInfo { // BIP44 Account Helpers /// Get the first BIP44 managed account - pub fn first_bip44_managed_account(&self) -> Option<&ManagedCoreAccount> { + pub fn first_bip44_managed_account(&self) -> Option<&ManagedCoreFundsAccount> { self.bip44_managed_account_at_index(0) } /// Get the first BIP44 managed account (mutable) - pub fn first_bip44_managed_account_mut(&mut self) -> Option<&mut ManagedCoreAccount> { + pub fn first_bip44_managed_account_mut(&mut self) -> Option<&mut ManagedCoreFundsAccount> { self.bip44_managed_account_at_index_mut(0) } /// Get a BIP44 managed account at a specific index - pub fn bip44_managed_account_at_index(&self, index: u32) -> Option<&ManagedCoreAccount> { + pub fn bip44_managed_account_at_index(&self, index: u32) -> Option<&ManagedCoreFundsAccount> { self.accounts.standard_bip44_accounts.get(&index) } @@ -27,24 +27,24 @@ impl ManagedWalletInfo { pub fn bip44_managed_account_at_index_mut( &mut self, index: u32, - ) -> Option<&mut ManagedCoreAccount> { + ) -> Option<&mut ManagedCoreFundsAccount> { self.accounts.standard_bip44_accounts.get_mut(&index) } // BIP32 Account Helpers /// Get the first BIP32 managed account - pub fn first_bip32_managed_account(&self) -> Option<&ManagedCoreAccount> { + pub fn first_bip32_managed_account(&self) -> Option<&ManagedCoreFundsAccount> { self.bip32_managed_account_at_index(0) } /// Get the first BIP32 managed account (mutable) - pub fn first_bip32_managed_account_mut(&mut self) -> Option<&mut ManagedCoreAccount> { + pub fn first_bip32_managed_account_mut(&mut self) -> Option<&mut ManagedCoreFundsAccount> { self.bip32_managed_account_at_index_mut(0) } /// Get a BIP32 managed account at a specific index - pub fn bip32_managed_account_at_index(&self, index: u32) -> Option<&ManagedCoreAccount> { + pub fn bip32_managed_account_at_index(&self, index: u32) -> Option<&ManagedCoreFundsAccount> { self.accounts.standard_bip32_accounts.get(&index) } @@ -52,24 +52,24 @@ impl ManagedWalletInfo { pub fn bip32_managed_account_at_index_mut( &mut self, index: u32, - ) -> Option<&mut ManagedCoreAccount> { + ) -> Option<&mut ManagedCoreFundsAccount> { self.accounts.standard_bip32_accounts.get_mut(&index) } // CoinJoin Account Helpers /// Get the first CoinJoin managed account - pub fn first_coinjoin_managed_account(&self) -> Option<&ManagedCoreAccount> { + pub fn first_coinjoin_managed_account(&self) -> Option<&ManagedCoreFundsAccount> { self.coinjoin_managed_account_at_index(0) } /// Get the first CoinJoin managed account (mutable) - pub fn first_coinjoin_managed_account_mut(&mut self) -> Option<&mut ManagedCoreAccount> { + pub fn first_coinjoin_managed_account_mut(&mut self) -> Option<&mut ManagedCoreFundsAccount> { self.coinjoin_managed_account_at_index_mut(0) } /// Get a CoinJoin managed account at a specific index - pub fn coinjoin_managed_account_at_index(&self, index: u32) -> Option<&ManagedCoreAccount> { + pub fn coinjoin_managed_account_at_index(&self, index: u32) -> Option<&ManagedCoreFundsAccount> { self.accounts.coinjoin_accounts.get(&index) } @@ -77,19 +77,19 @@ impl ManagedWalletInfo { pub fn coinjoin_managed_account_at_index_mut( &mut self, index: u32, - ) -> Option<&mut ManagedCoreAccount> { + ) -> Option<&mut ManagedCoreFundsAccount> { self.accounts.coinjoin_accounts.get_mut(&index) } // TopUp Account Helpers /// Get the first TopUp managed account - pub fn first_topup_managed_account(&self) -> Option<&ManagedCoreAccount> { + pub fn first_topup_managed_account(&self) -> Option<&ManagedCoreFundsAccount> { self.accounts.identity_topup.values().next() } /// Get the first TopUp managed account (mutable) - pub fn first_topup_managed_account_mut(&mut self) -> Option<&mut ManagedCoreAccount> { + pub fn first_topup_managed_account_mut(&mut self) -> Option<&mut ManagedCoreFundsAccount> { self.accounts.identity_topup.values_mut().next() } @@ -97,7 +97,7 @@ impl ManagedWalletInfo { pub fn topup_managed_account_at_registration_index( &self, registration_index: u32, - ) -> Option<&ManagedCoreAccount> { + ) -> Option<&ManagedCoreFundsAccount> { self.accounts.identity_topup.get(®istration_index) } @@ -105,97 +105,97 @@ impl ManagedWalletInfo { pub fn topup_managed_account_at_registration_index_mut( &mut self, registration_index: u32, - ) -> Option<&mut ManagedCoreAccount> { + ) -> Option<&mut ManagedCoreFundsAccount> { self.accounts.identity_topup.get_mut(®istration_index) } // Identity Registration Account Helper /// Get the identity registration managed account - pub fn identity_registration_managed_account(&self) -> Option<&ManagedCoreAccount> { + pub fn identity_registration_managed_account(&self) -> Option<&ManagedCoreFundsAccount> { self.accounts.identity_registration.as_ref() } /// Get the identity registration managed account (mutable) - pub fn identity_registration_managed_account_mut(&mut self) -> Option<&mut ManagedCoreAccount> { + pub fn identity_registration_managed_account_mut(&mut self) -> Option<&mut ManagedCoreFundsAccount> { self.accounts.identity_registration.as_mut() } // Identity TopUp Not Bound Account Helper /// Get the identity top-up not bound managed account - pub fn identity_topup_not_bound_managed_account(&self) -> Option<&ManagedCoreAccount> { + pub fn identity_topup_not_bound_managed_account(&self) -> Option<&ManagedCoreFundsAccount> { self.accounts.identity_topup_not_bound.as_ref() } /// Get the identity top-up not bound managed account (mutable) pub fn identity_topup_not_bound_managed_account_mut( &mut self, - ) -> Option<&mut ManagedCoreAccount> { + ) -> Option<&mut ManagedCoreFundsAccount> { self.accounts.identity_topup_not_bound.as_mut() } // Identity Invitation Account Helper /// Get the identity invitation managed account - pub fn identity_invitation_managed_account(&self) -> Option<&ManagedCoreAccount> { + pub fn identity_invitation_managed_account(&self) -> Option<&ManagedCoreFundsAccount> { self.accounts.identity_invitation.as_ref() } /// Get the identity invitation managed account (mutable) - pub fn identity_invitation_managed_account_mut(&mut self) -> Option<&mut ManagedCoreAccount> { + pub fn identity_invitation_managed_account_mut(&mut self) -> Option<&mut ManagedCoreFundsAccount> { self.accounts.identity_invitation.as_mut() } // Provider Voting Keys Account Helper /// Get the provider voting keys managed account - pub fn provider_voting_keys_managed_account(&self) -> Option<&ManagedCoreAccount> { + pub fn provider_voting_keys_managed_account(&self) -> Option<&ManagedCoreFundsAccount> { self.accounts.provider_voting_keys.as_ref() } /// Get the provider voting keys managed account (mutable) - pub fn provider_voting_keys_managed_account_mut(&mut self) -> Option<&mut ManagedCoreAccount> { + pub fn provider_voting_keys_managed_account_mut(&mut self) -> Option<&mut ManagedCoreFundsAccount> { self.accounts.provider_voting_keys.as_mut() } // Provider Owner Keys Account Helper /// Get the provider owner keys managed account - pub fn provider_owner_keys_managed_account(&self) -> Option<&ManagedCoreAccount> { + pub fn provider_owner_keys_managed_account(&self) -> Option<&ManagedCoreFundsAccount> { self.accounts.provider_owner_keys.as_ref() } /// Get the provider owner keys managed account (mutable) - pub fn provider_owner_keys_managed_account_mut(&mut self) -> Option<&mut ManagedCoreAccount> { + pub fn provider_owner_keys_managed_account_mut(&mut self) -> Option<&mut ManagedCoreFundsAccount> { self.accounts.provider_owner_keys.as_mut() } // Provider Operator Keys Account Helper /// Get the provider operator keys managed account - pub fn provider_operator_keys_managed_account(&self) -> Option<&ManagedCoreAccount> { + pub fn provider_operator_keys_managed_account(&self) -> Option<&ManagedCoreFundsAccount> { self.accounts.provider_operator_keys.as_ref() } /// Get the provider operator keys managed account (mutable) pub fn provider_operator_keys_managed_account_mut( &mut self, - ) -> Option<&mut ManagedCoreAccount> { + ) -> Option<&mut ManagedCoreFundsAccount> { self.accounts.provider_operator_keys.as_mut() } // Provider Platform Keys Account Helper /// Get the provider platform keys managed account - pub fn provider_platform_keys_managed_account(&self) -> Option<&ManagedCoreAccount> { + pub fn provider_platform_keys_managed_account(&self) -> Option<&ManagedCoreFundsAccount> { self.accounts.provider_platform_keys.as_ref() } /// Get the provider platform keys managed account (mutable) pub fn provider_platform_keys_managed_account_mut( &mut self, - ) -> Option<&mut ManagedCoreAccount> { + ) -> Option<&mut ManagedCoreFundsAccount> { self.accounts.provider_platform_keys.as_mut() } @@ -298,7 +298,7 @@ impl ManagedWalletInfo { } /// Get all accounts - pub fn all_managed_accounts(&self) -> Vec<&ManagedCoreAccount> { + pub fn all_managed_accounts(&self) -> Vec<&ManagedCoreFundsAccount> { self.accounts.all_accounts() } } diff --git a/key-wallet/src/wallet/managed_wallet_info/managed_accounts.rs b/key-wallet/src/wallet/managed_wallet_info/managed_accounts.rs index f21713c1b..7169fbcb1 100644 --- a/key-wallet/src/wallet/managed_wallet_info/managed_accounts.rs +++ b/key-wallet/src/wallet/managed_wallet_info/managed_accounts.rs @@ -7,7 +7,7 @@ use super::{managed_account_operations::ManagedAccountOperations, ManagedWalletI use crate::account::BLSAccount; #[cfg(feature = "eddsa")] use crate::account::EdDSAAccount; -use crate::account::{Account, AccountType, ManagedCoreAccount}; +use crate::account::{Account, AccountType, ManagedCoreFundsAccount}; use crate::bip32::ExtendedPubKey; use crate::error::{Error, Result}; use crate::wallet::{Wallet, WalletType}; @@ -42,7 +42,7 @@ impl ManagedAccountOperations for ManagedWalletInfo { })?; // Create the ManagedAccount from the Account - let managed_account = ManagedCoreAccount::from_account(account); + let managed_account = ManagedCoreFundsAccount::from_account(account); // Check if managed account already exists if self.accounts.contains_managed_account_type(managed_account.managed_type()) { @@ -117,7 +117,7 @@ impl ManagedAccountOperations for ManagedWalletInfo { let account = Account::new(None, account_type, account_xpub, self.network)?; // Create the ManagedAccount from the Account - let managed_account = ManagedCoreAccount::from_account(&account); + let managed_account = ManagedCoreFundsAccount::from_account(&account); // Check if managed account already exists if self.accounts.contains_managed_account_type(managed_account.managed_type()) { @@ -162,7 +162,7 @@ impl ManagedAccountOperations for ManagedWalletInfo { })?; // Create the ManagedAccount from the BLS Account - let managed_account = ManagedCoreAccount::from_bls_account(bls_account); + let managed_account = ManagedCoreFundsAccount::from_bls_account(bls_account); // Check if managed account already exists if self.accounts.contains_managed_account_type(managed_account.managed_type()) { @@ -234,7 +234,7 @@ impl ManagedAccountOperations for ManagedWalletInfo { BLSAccount::from_public_key_bytes(None, account_type, bls_public_key, self.network)?; // Create the ManagedAccount from the BLS Account - let managed_account = ManagedCoreAccount::from_bls_account(&bls_account); + let managed_account = ManagedCoreFundsAccount::from_bls_account(&bls_account); // Check if managed account already exists if self.accounts.contains_managed_account_type(managed_account.managed_type()) { @@ -280,7 +280,7 @@ impl ManagedAccountOperations for ManagedWalletInfo { })?; // Create the ManagedAccount from the EdDSA Account - let managed_account = ManagedCoreAccount::from_eddsa_account(eddsa_account); + let managed_account = ManagedCoreFundsAccount::from_eddsa_account(eddsa_account); // Check if managed account already exists if self.accounts.contains_managed_account_type(managed_account.managed_type()) { @@ -356,7 +356,7 @@ impl ManagedAccountOperations for ManagedWalletInfo { )?; // Create the ManagedAccount from the EdDSA Account - let managed_account = ManagedCoreAccount::from_eddsa_account(&eddsa_account); + let managed_account = ManagedCoreFundsAccount::from_eddsa_account(&eddsa_account); // Check if managed account already exists if self.accounts.contains_managed_account_type(managed_account.managed_type()) { From 2f1ea9e544cf197d8ea753491777b2f83d73b4ce Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 3 May 2026 22:52:31 +0700 Subject: [PATCH 7/9] fix(key-wallet): rustfmt + qualify intra-doc link to ManagedCoreFundsAccount - Sort imports and rewrap long signatures (rustfmt fixes flagged by pre-commit) - Qualify the `[\`ManagedCoreFundsAccount\`]` intra-doc links in `managed_core_keys_account.rs` to `[\`crate::managed_account::ManagedCoreFundsAccount\`]` so rustdoc can resolve them under `-D warnings` Co-Authored-By: Claude Opus 4.7 (1M context) --- .../managed_core_funds_account.rs | 2 +- .../managed_core_keys_account.rs | 6 +++--- .../src/wallet/managed_wallet_info/helpers.rs | 21 ++++++++++++++----- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/key-wallet/src/managed_account/managed_core_funds_account.rs b/key-wallet/src/managed_account/managed_core_funds_account.rs index 41efe959f..64e683897 100644 --- a/key-wallet/src/managed_account/managed_core_funds_account.rs +++ b/key-wallet/src/managed_account/managed_core_funds_account.rs @@ -13,13 +13,13 @@ use crate::account::ManagedAccountTrait; use crate::account::TransactionRecord; #[cfg(feature = "bls")] use crate::derivation_bls_bip32::ExtendedBLSPubKey; +use crate::managed_account::address_pool; #[cfg(any(feature = "bls", feature = "eddsa"))] use crate::managed_account::address_pool::PublicKeyType; use crate::managed_account::managed_account_type::ManagedAccountType; use crate::managed_account::transaction_record::{ InputDetail, OutputDetail, OutputRole, TransactionDirection, }; -use crate::managed_account::address_pool; use crate::transaction_checking::transaction_router::TransactionType; use crate::transaction_checking::{AccountMatch, TransactionContext}; use crate::utxo::Utxo; diff --git a/key-wallet/src/managed_account/managed_core_keys_account.rs b/key-wallet/src/managed_account/managed_core_keys_account.rs index f58c84a9e..8ce3edf4c 100644 --- a/key-wallet/src/managed_account/managed_core_keys_account.rs +++ b/key-wallet/src/managed_account/managed_core_keys_account.rs @@ -1,7 +1,7 @@ //! Managed core keys account: address pools and key derivation without funds tracking //! //! This module contains a lightweight mutable account state that omits the funds -//! bookkeeping (balance, UTXOs, spent outpoints) carried by [`ManagedCoreFundsAccount`]. +//! bookkeeping (balance, UTXOs, spent outpoints) carried by [`crate::managed_account::ManagedCoreFundsAccount`]. //! It is intended for accounts that exist primarily to derive keys/addresses for //! special-purpose flows (identity registration, asset locks, masternode provider //! keys) rather than to hold and spend Dash directly. @@ -14,9 +14,9 @@ use crate::account::EdDSAAccount; use crate::account::TransactionRecord; #[cfg(feature = "bls")] use crate::derivation_bls_bip32::ExtendedBLSPubKey; +use crate::managed_account::address_pool; #[cfg(any(feature = "bls", feature = "eddsa"))] use crate::managed_account::address_pool::PublicKeyType; -use crate::managed_account::address_pool; use crate::managed_account::managed_account_type::ManagedAccountType; #[cfg(feature = "eddsa")] use crate::AddressInfo; @@ -29,7 +29,7 @@ use std::collections::BTreeMap; /// Managed core keys account with mutable state but no funds tracking /// -/// Like [`ManagedCoreFundsAccount`] but without `balance`, `utxos`, or +/// Like [`crate::managed_account::ManagedCoreFundsAccount`] but without `balance`, `utxos`, or /// `spent_outpoints`. Used for accounts that derive special-purpose keys /// (identity registration, asset locks, masternode provider keys) where /// per-account UTXO/balance bookkeeping is not meaningful. diff --git a/key-wallet/src/wallet/managed_wallet_info/helpers.rs b/key-wallet/src/wallet/managed_wallet_info/helpers.rs index ae6bbd62f..4def07ec7 100644 --- a/key-wallet/src/wallet/managed_wallet_info/helpers.rs +++ b/key-wallet/src/wallet/managed_wallet_info/helpers.rs @@ -69,7 +69,10 @@ impl ManagedWalletInfo { } /// Get a CoinJoin managed account at a specific index - pub fn coinjoin_managed_account_at_index(&self, index: u32) -> Option<&ManagedCoreFundsAccount> { + pub fn coinjoin_managed_account_at_index( + &self, + index: u32, + ) -> Option<&ManagedCoreFundsAccount> { self.accounts.coinjoin_accounts.get(&index) } @@ -117,7 +120,9 @@ impl ManagedWalletInfo { } /// Get the identity registration managed account (mutable) - pub fn identity_registration_managed_account_mut(&mut self) -> Option<&mut ManagedCoreFundsAccount> { + pub fn identity_registration_managed_account_mut( + &mut self, + ) -> Option<&mut ManagedCoreFundsAccount> { self.accounts.identity_registration.as_mut() } @@ -143,7 +148,9 @@ impl ManagedWalletInfo { } /// Get the identity invitation managed account (mutable) - pub fn identity_invitation_managed_account_mut(&mut self) -> Option<&mut ManagedCoreFundsAccount> { + pub fn identity_invitation_managed_account_mut( + &mut self, + ) -> Option<&mut ManagedCoreFundsAccount> { self.accounts.identity_invitation.as_mut() } @@ -155,7 +162,9 @@ impl ManagedWalletInfo { } /// Get the provider voting keys managed account (mutable) - pub fn provider_voting_keys_managed_account_mut(&mut self) -> Option<&mut ManagedCoreFundsAccount> { + pub fn provider_voting_keys_managed_account_mut( + &mut self, + ) -> Option<&mut ManagedCoreFundsAccount> { self.accounts.provider_voting_keys.as_mut() } @@ -167,7 +176,9 @@ impl ManagedWalletInfo { } /// Get the provider owner keys managed account (mutable) - pub fn provider_owner_keys_managed_account_mut(&mut self) -> Option<&mut ManagedCoreFundsAccount> { + pub fn provider_owner_keys_managed_account_mut( + &mut self, + ) -> Option<&mut ManagedCoreFundsAccount> { self.accounts.provider_owner_keys.as_mut() } From 2b9fbade4e6f6bbdc8383da73a886e52360c46c1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 3 May 2026 23:02:26 +0700 Subject: [PATCH 8/9] docs(key-wallet): fix broken receive-address example in IMPLEMENTATION_SUMMARY The example called `get_next_receive_address()`, which is not a method on this struct. Replace with the real `next_receive_address(...)` signature so the snippet compiles. Co-Authored-By: Claude Opus 4.7 (1M context) --- key-wallet/IMPLEMENTATION_SUMMARY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/key-wallet/IMPLEMENTATION_SUMMARY.md b/key-wallet/IMPLEMENTATION_SUMMARY.md index d38c31301..07b2bb206 100644 --- a/key-wallet/IMPLEMENTATION_SUMMARY.md +++ b/key-wallet/IMPLEMENTATION_SUMMARY.md @@ -183,7 +183,7 @@ let account = wallet.create_account( let mut managed = ManagedCoreFundsAccount::from_account(&account); // Get a receive address -let address = managed.get_next_receive_address()?; +let address = managed.next_receive_address(Some(&account.account_xpub), true)?; ``` ## Dependencies From 92d7568ccbb89611658c648f86c808f35d26505d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 4 May 2026 00:33:53 +0700 Subject: [PATCH 9/9] refactor(key-wallet): wire ManagedCoreKeysAccount into the collection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promotes the dead-code `ManagedCoreKeysAccount` introduced in the previous refactor into the active type for accounts that derive special-purpose keys but don't track funds. Identity, asset-lock, and provider account fields on `ManagedAccountCollection` switch from `ManagedCoreFundsAccount` to `ManagedCoreKeysAccount`; standard, CoinJoin, and DashPay fields stay on the funds variant. The keys account gains the methods it needs to be a first-class participant in transaction matching and recording: `record_transaction` / `confirm_transaction` (simplified, no UTXO/balance work), `processed_txids` dedup set, feature-gated `transactions` mirroring the funds-account pattern, plus `classify_address`, `check_transaction_for_match`, `check_asset_lock_transaction_for_match`, and the four `check_provider_*_key_in_transaction_for_match` methods that previously lived on `ManagedCoreFundsAccount`. Spanning collection accessors (`get_by_account_type_match`, `all_accounts`, `get`, `remove`, …) return a new `ManagedAccountRef<'a> { Funds, Keys }` enum (with a mutable counterpart). The enum carries delegating helpers so callers that don't care about the variant — most of `wallet_info_interface`, `wallet_checker`, FFI address-pool lookup — keep working without explicit match dispatch. `insert` now takes an owned `OwnedManagedCoreAccount` enum; direct-typed `insert_funds` / `insert_keys` are also exposed for callers that statically know the variant. `wallet/managed_wallet_info` accessors that returned `&mut ManagedCoreFundsAccount` for identity / asset-lock / provider accounts now return `&mut ManagedCoreKeysAccount`. `account_balances`, `transaction_history`, `update_balance`, `mark_instant_send_utxos`, and the matured-coinbase / immature-tx surfaces filter to the funds variant, since those concepts only apply there. `ManagedAccountTrait` stays funds-only; only `ManagedCoreFundsAccount` implements it. The keys variant uses inherent methods plus the enum. C ABI is preserved on the FFI side: `FFIManagedCoreAccount` now wraps an internal `Funds | Keys` enum carrying an `Arc<…>`, with new `as_funds` / `as_keys` accessors and a `new_keys` constructor. The legacy `inner()` helper panics on a keys-only handle (it's only reachable from funds-only FFI functions; the variant-aware entry points create the right kind of handle). Co-Authored-By: Claude Opus 4.7 (1M context) --- dash-spv/tests/dashd_sync/helpers.rs | 8 +- dash-spv/tests/dashd_sync/setup.rs | 20 +- key-wallet-ffi/src/address_pool.rs | 191 +++-- key-wallet-ffi/src/managed_account.rs | 170 ++++- .../src/managed_account_collection.rs | 16 +- key-wallet-ffi/src/managed_wallet.rs | 2 +- key-wallet-ffi/src/utxo_tests.rs | 10 +- .../managed_account_collection.rs | 665 +++++++++++++----- .../managed_account/managed_account_ref.rs | 516 ++++++++++++++ .../managed_core_keys_account.rs | 645 ++++++++++++++++- key-wallet/src/managed_account/mod.rs | 3 + key-wallet/src/tests/balance_tests.rs | 8 +- .../transaction_checking/account_checker.rs | 32 +- .../transaction_checking/wallet_checker.rs | 18 +- .../managed_wallet_info/asset_lock_builder.rs | 4 +- .../src/wallet/managed_wallet_info/helpers.rs | 42 +- .../managed_wallet_info/managed_accounts.rs | 36 +- .../wallet_info_interface.rs | 89 ++- 18 files changed, 2095 insertions(+), 380 deletions(-) create mode 100644 key-wallet/src/managed_account/managed_account_ref.rs diff --git a/dash-spv/tests/dashd_sync/helpers.rs b/dash-spv/tests/dashd_sync/helpers.rs index c318224ce..57db235e2 100644 --- a/dash-spv/tests/dashd_sync/helpers.rs +++ b/dash-spv/tests/dashd_sync/helpers.rs @@ -62,8 +62,12 @@ pub(super) async fn count_wallet_transactions( ) -> usize { let wallet_read = wallet.read().await; let wallet_info = wallet_read.get_wallet_info(wallet_id).expect("Wallet info not found"); - let txids: HashSet<_> = - wallet_info.accounts().all_accounts().iter().flat_map(|a| a.transactions.keys()).collect(); + let txids: HashSet<_> = wallet_info + .accounts() + .all_accounts() + .into_iter() + .flat_map(|a| a.transactions_iter().map(|(txid, _)| txid)) + .collect(); txids.len() } diff --git a/dash-spv/tests/dashd_sync/setup.rs b/dash-spv/tests/dashd_sync/setup.rs index 585509231..ce1fdd094 100644 --- a/dash-spv/tests/dashd_sync/setup.rs +++ b/dash-spv/tests/dashd_sync/setup.rs @@ -121,7 +121,12 @@ impl TestContext { let wallet_read = self.wallet.read().await; let wallet_info = wallet_read.get_wallet_info(&self.wallet_id).expect("Wallet info not found"); - wallet_info.accounts().all_accounts().iter().map(|a| a.transactions.len()).sum() + wallet_info + .accounts() + .all_accounts() + .into_iter() + .map(|a| a.transactions_iter().count()) + .sum() } /// Retrieves the spendable balance of the wallet. pub(super) async fn spendable_balance(&self) -> u64 { @@ -150,6 +155,9 @@ impl TestContext { else { panic!("Account 0 is not a Standard account type"); }; + // `account` is a `&ManagedCoreFundsAccount` from the funds-typed + // `standard_bip44_accounts` map; the destructure above reads through + // its public `managed_account_type` field. external_addresses .unused_addresses() @@ -166,8 +174,8 @@ impl TestContext { wallet_info .accounts() .all_accounts() - .iter() - .any(|account| account.transactions.contains_key(txid)) + .into_iter() + .any(|account| account.transactions_iter().any(|(stored, _)| stored == txid)) || wallet_info.immature_transactions().iter().any(|tx| &tx.txid() == txid) } @@ -196,7 +204,7 @@ impl TestContext { let mut spv_txids = HashSet::new(); for managed_account in wallet_info.accounts().all_accounts() { - for txid in managed_account.transactions.keys() { + for (txid, _) in managed_account.transactions_iter() { spv_txids.insert(txid.to_string()); } } @@ -303,8 +311,8 @@ pub(super) async fn client_has_transaction( wallet_info .accounts() .all_accounts() - .iter() - .any(|account| account.transactions.contains_key(txid)) + .into_iter() + .any(|account| account.transactions_iter().any(|(stored, _)| stored == txid)) || wallet_info.immature_transactions().iter().any(|tx| &tx.txid() == txid) } diff --git a/key-wallet-ffi/src/address_pool.rs b/key-wallet-ffi/src/address_pool.rs index 728a24d6a..bf5d24a83 100644 --- a/key-wallet-ffi/src/address_pool.rs +++ b/key-wallet-ffi/src/address_pool.rs @@ -15,45 +15,61 @@ use key_wallet::account::ManagedAccountCollection; use key_wallet::managed_account::address_pool::{ AddressInfo, AddressPool, KeySource, PublicKeyType, }; -use key_wallet::managed_account::ManagedCoreFundsAccount; +use key_wallet::managed_account::{ManagedAccountRef, ManagedAccountRefMut, ManagedAccountType}; use key_wallet::AccountType; -// Helper functions to get managed accounts by type +// Helper functions to get managed accounts by type. Returns the funds- or +// keys-typed reference via `ManagedAccountRef` so callers don't need to know +// the concrete variant statically. fn get_managed_account_by_type<'a>( collection: &'a ManagedAccountCollection, account_type: &AccountType, -) -> Option<&'a ManagedCoreFundsAccount> { +) -> Option> { match account_type { AccountType::Standard { index, standard_account_type, } => match standard_account_type { key_wallet::account::StandardAccountType::BIP44Account => { - collection.standard_bip44_accounts.get(index) + collection.standard_bip44_accounts.get(index).map(ManagedAccountRef::Funds) } key_wallet::account::StandardAccountType::BIP32Account => { - collection.standard_bip32_accounts.get(index) + collection.standard_bip32_accounts.get(index).map(ManagedAccountRef::Funds) } }, AccountType::CoinJoin { index, - } => collection.coinjoin_accounts.get(index), - AccountType::IdentityRegistration => collection.identity_registration.as_ref(), + } => collection.coinjoin_accounts.get(index).map(ManagedAccountRef::Funds), + AccountType::IdentityRegistration => { + collection.identity_registration.as_ref().map(ManagedAccountRef::Keys) + } AccountType::IdentityTopUp { registration_index, - } => collection.identity_topup.get(registration_index), + } => collection.identity_topup.get(registration_index).map(ManagedAccountRef::Keys), AccountType::IdentityTopUpNotBoundToIdentity => { - collection.identity_topup_not_bound.as_ref() + collection.identity_topup_not_bound.as_ref().map(ManagedAccountRef::Keys) + } + AccountType::IdentityInvitation => { + collection.identity_invitation.as_ref().map(ManagedAccountRef::Keys) + } + AccountType::AssetLockAddressTopUp => { + collection.asset_lock_address_topup.as_ref().map(ManagedAccountRef::Keys) } - AccountType::IdentityInvitation => collection.identity_invitation.as_ref(), - AccountType::AssetLockAddressTopUp => collection.asset_lock_address_topup.as_ref(), AccountType::AssetLockShieldedAddressTopUp => { - collection.asset_lock_shielded_address_topup.as_ref() + collection.asset_lock_shielded_address_topup.as_ref().map(ManagedAccountRef::Keys) + } + AccountType::ProviderVotingKeys => { + collection.provider_voting_keys.as_ref().map(ManagedAccountRef::Keys) + } + AccountType::ProviderOwnerKeys => { + collection.provider_owner_keys.as_ref().map(ManagedAccountRef::Keys) + } + AccountType::ProviderOperatorKeys => { + collection.provider_operator_keys.as_ref().map(ManagedAccountRef::Keys) + } + AccountType::ProviderPlatformKeys => { + collection.provider_platform_keys.as_ref().map(ManagedAccountRef::Keys) } - AccountType::ProviderVotingKeys => collection.provider_voting_keys.as_ref(), - AccountType::ProviderOwnerKeys => collection.provider_owner_keys.as_ref(), - AccountType::ProviderOperatorKeys => collection.provider_operator_keys.as_ref(), - AccountType::ProviderPlatformKeys => collection.provider_platform_keys.as_ref(), AccountType::DashpayReceivingFunds { .. } @@ -75,38 +91,52 @@ fn get_managed_account_by_type<'a>( fn get_managed_account_by_type_mut<'a>( collection: &'a mut ManagedAccountCollection, account_type: &AccountType, -) -> Option<&'a mut ManagedCoreFundsAccount> { +) -> Option> { match account_type { AccountType::Standard { index, standard_account_type, } => match standard_account_type { key_wallet::account::StandardAccountType::BIP44Account => { - collection.standard_bip44_accounts.get_mut(index) + collection.standard_bip44_accounts.get_mut(index).map(ManagedAccountRefMut::Funds) } key_wallet::account::StandardAccountType::BIP32Account => { - collection.standard_bip32_accounts.get_mut(index) + collection.standard_bip32_accounts.get_mut(index).map(ManagedAccountRefMut::Funds) } }, AccountType::CoinJoin { index, - } => collection.coinjoin_accounts.get_mut(index), - AccountType::IdentityRegistration => collection.identity_registration.as_mut(), + } => collection.coinjoin_accounts.get_mut(index).map(ManagedAccountRefMut::Funds), + AccountType::IdentityRegistration => { + collection.identity_registration.as_mut().map(ManagedAccountRefMut::Keys) + } AccountType::IdentityTopUp { registration_index, - } => collection.identity_topup.get_mut(registration_index), + } => collection.identity_topup.get_mut(registration_index).map(ManagedAccountRefMut::Keys), AccountType::IdentityTopUpNotBoundToIdentity => { - collection.identity_topup_not_bound.as_mut() + collection.identity_topup_not_bound.as_mut().map(ManagedAccountRefMut::Keys) + } + AccountType::IdentityInvitation => { + collection.identity_invitation.as_mut().map(ManagedAccountRefMut::Keys) + } + AccountType::AssetLockAddressTopUp => { + collection.asset_lock_address_topup.as_mut().map(ManagedAccountRefMut::Keys) } - AccountType::IdentityInvitation => collection.identity_invitation.as_mut(), - AccountType::AssetLockAddressTopUp => collection.asset_lock_address_topup.as_mut(), AccountType::AssetLockShieldedAddressTopUp => { - collection.asset_lock_shielded_address_topup.as_mut() + collection.asset_lock_shielded_address_topup.as_mut().map(ManagedAccountRefMut::Keys) + } + AccountType::ProviderVotingKeys => { + collection.provider_voting_keys.as_mut().map(ManagedAccountRefMut::Keys) + } + AccountType::ProviderOwnerKeys => { + collection.provider_owner_keys.as_mut().map(ManagedAccountRefMut::Keys) + } + AccountType::ProviderOperatorKeys => { + collection.provider_operator_keys.as_mut().map(ManagedAccountRefMut::Keys) + } + AccountType::ProviderPlatformKeys => { + collection.provider_platform_keys.as_mut().map(ManagedAccountRefMut::Keys) } - AccountType::ProviderVotingKeys => collection.provider_voting_keys.as_mut(), - AccountType::ProviderOwnerKeys => collection.provider_owner_keys.as_mut(), - AccountType::ProviderOperatorKeys => collection.provider_operator_keys.as_mut(), - AccountType::ProviderPlatformKeys => collection.provider_platform_keys.as_mut(), AccountType::DashpayReceivingFunds { .. } @@ -304,34 +334,43 @@ pub unsafe extern "C" fn managed_wallet_get_address_pool_info( ); // Get the appropriate address pool + let managed_account_type = managed_account.managed_account_type(); let pool = match pool_type { FFIAddressPoolType::External => { // Only standard accounts have external/internal pools - if let key_wallet::managed_account::managed_account_type::ManagedAccountType::Standard { + if let ManagedAccountType::Standard { external_addresses, .. - } = &managed_account.managed_account_type { + } = managed_account_type + { external_addresses } else { - (*error).set(FFIErrorCode::InvalidInput, "Account type does not have external address pool"); + (*error).set( + FFIErrorCode::InvalidInput, + "Account type does not have external address pool", + ); return false; } } FFIAddressPoolType::Internal => { // Only standard accounts have external/internal pools - if let key_wallet::managed_account::managed_account_type::ManagedAccountType::Standard { + if let ManagedAccountType::Standard { internal_addresses, .. - } = &managed_account.managed_account_type { + } = managed_account_type + { internal_addresses } else { - (*error).set(FFIErrorCode::InvalidInput, "Account type does not have internal address pool"); + (*error).set( + FFIErrorCode::InvalidInput, + "Account type does not have internal address pool", + ); return false; } } FFIAddressPoolType::Single => { // Get the first (and only) address pool for non-standard accounts - let pools = managed_account.managed_account_type.address_pools(); + let pools = managed_account_type.address_pools(); if pools.is_empty() { (*error).set(FFIErrorCode::InvalidInput, "Account has no address pools"); return false; @@ -383,40 +422,49 @@ pub unsafe extern "C" fn managed_wallet_set_gap_limit( let account_type_rust = account_type.to_account_type(account_index); // Get the specific managed account - let managed_account = unwrap_or_return!( + let mut managed_account = unwrap_or_return!( get_managed_account_by_type_mut(&mut managed_wallet.accounts, &account_type_rust), error ); // Get the appropriate address pool + let managed_account_type = managed_account.managed_account_type_mut(); let pool = match pool_type { FFIAddressPoolType::External => { // Only standard accounts have external/internal pools - if let key_wallet::managed_account::managed_account_type::ManagedAccountType::Standard { + if let ManagedAccountType::Standard { external_addresses, .. - } = &mut managed_account.managed_account_type { + } = managed_account_type + { external_addresses } else { - (*error).set(FFIErrorCode::InvalidInput, "Account type does not have external address pool"); + (*error).set( + FFIErrorCode::InvalidInput, + "Account type does not have external address pool", + ); return false; } } FFIAddressPoolType::Internal => { // Only standard accounts have external/internal pools - if let key_wallet::managed_account::managed_account_type::ManagedAccountType::Standard { + if let ManagedAccountType::Standard { internal_addresses, .. - } = &mut managed_account.managed_account_type { + } = managed_account_type + { internal_addresses } else { - (*error).set(FFIErrorCode::InvalidInput, "Account type does not have internal address pool"); + (*error).set( + FFIErrorCode::InvalidInput, + "Account type does not have internal address pool", + ); return false; } } FFIAddressPoolType::Single => { // Get the first (and only) address pool for non-standard accounts - let pools = managed_account.managed_account_type.address_pools_mut(); + let pools = managed_account_type.address_pools_mut(); if pools.is_empty() { (*error).set(FFIErrorCode::InvalidInput, "Account has no address pools"); return false; @@ -470,56 +518,61 @@ pub unsafe extern "C" fn managed_wallet_generate_addresses_to_index( let key_source = KeySource::Public(xpub); // Get the specific managed account - let managed_account = unwrap_or_return!( + let mut managed_account = unwrap_or_return!( get_managed_account_by_type_mut(&mut managed_wallet.accounts, &account_type_rust), error ); // Get the appropriate address pool and generate addresses + let managed_account_type = managed_account.managed_account_type_mut(); let result = match pool_type { FFIAddressPoolType::External => { // Only standard accounts have external/internal pools - if let key_wallet::managed_account::managed_account_type::ManagedAccountType::Standard { + if let ManagedAccountType::Standard { external_addresses, .. - } = &mut managed_account.managed_account_type { - { - let current = external_addresses.highest_generated.unwrap_or(0); - if target_index > current { - let needed = target_index - current; - external_addresses.generate_addresses(needed, &key_source, true) - } else { - Ok(Vec::new()) - } + } = managed_account_type + { + let current = external_addresses.highest_generated.unwrap_or(0); + if target_index > current { + let needed = target_index - current; + external_addresses.generate_addresses(needed, &key_source, true) + } else { + Ok(Vec::new()) } } else { - (*error).set(FFIErrorCode::InvalidInput, "Account type does not have external address pool"); + (*error).set( + FFIErrorCode::InvalidInput, + "Account type does not have external address pool", + ); return false; } } FFIAddressPoolType::Internal => { // Only standard accounts have external/internal pools - if let key_wallet::managed_account::managed_account_type::ManagedAccountType::Standard { + if let ManagedAccountType::Standard { internal_addresses, .. - } = &mut managed_account.managed_account_type { - { - let current = internal_addresses.highest_generated.unwrap_or(0); - if target_index > current { - let needed = target_index - current; - internal_addresses.generate_addresses(needed, &key_source, true) - } else { - Ok(Vec::new()) - } + } = managed_account_type + { + let current = internal_addresses.highest_generated.unwrap_or(0); + if target_index > current { + let needed = target_index - current; + internal_addresses.generate_addresses(needed, &key_source, true) + } else { + Ok(Vec::new()) } } else { - (*error).set(FFIErrorCode::InvalidInput, "Account type does not have internal address pool"); + (*error).set( + FFIErrorCode::InvalidInput, + "Account type does not have internal address pool", + ); return false; } } FFIAddressPoolType::Single => { // Get the first (and only) address pool for non-standard accounts - let mut pools = managed_account.managed_account_type.address_pools_mut(); + let mut pools = managed_account_type.address_pools_mut(); if pools.is_empty() { (*error).set(FFIErrorCode::InvalidInput, "Account has no address pools"); return false; diff --git a/key-wallet-ffi/src/managed_account.rs b/key-wallet-ffi/src/managed_account.rs index 1c47c95ee..691f8a847 100644 --- a/key-wallet-ffi/src/managed_account.rs +++ b/key-wallet-ffi/src/managed_account.rs @@ -22,26 +22,101 @@ use key_wallet::account::account_collection::{DashpayAccountKey, PlatformPayment use key_wallet::account::TransactionRecord; use key_wallet::managed_account::address_pool::AddressPool; use key_wallet::managed_account::managed_platform_account::ManagedPlatformAccount; -use key_wallet::managed_account::ManagedCoreFundsAccount; +use key_wallet::managed_account::{ + ManagedAccountType, ManagedCoreFundsAccount, ManagedCoreKeysAccount, +}; use key_wallet::AccountType; -/// Opaque managed account handle that wraps ManagedAccount +/// Opaque managed account handle that wraps a managed core account. +/// +/// Internally stores either a funds-bearing or a keys-only account; FFI +/// callers can ignore the distinction for read-only queries that work on +/// either variant (network, addresses, watch-only flag, etc.). Operations +/// that only make sense for funds accounts (balance, UTXOs, on-chain +/// transactions) require the funds variant; calling them on a keys +/// account returns a sensible default (zero, empty collection). pub struct FFIManagedCoreAccount { - /// The underlying managed account - pub(crate) account: Arc, + pub(crate) inner: FFIManagedCoreAccountInner, +} + +/// Internal variant carried by [`FFIManagedCoreAccount`]. +pub(crate) enum FFIManagedCoreAccountInner { + /// Funds-bearing managed account (Standard, CoinJoin, DashPay). + Funds(Arc), + /// Keys-only managed account (identity, asset-lock, provider). + Keys(Arc), } impl FFIManagedCoreAccount { - /// Create a new FFI managed account handle + /// Create a new FFI managed account handle from a funds-bearing account. pub fn new(account: &ManagedCoreFundsAccount) -> Self { FFIManagedCoreAccount { - account: Arc::new(account.clone()), + inner: FFIManagedCoreAccountInner::Funds(Arc::new(account.clone())), + } + } + + /// Create a new FFI managed account handle from a keys-only account. + pub fn new_keys(account: &ManagedCoreKeysAccount) -> Self { + FFIManagedCoreAccount { + inner: FFIManagedCoreAccountInner::Keys(Arc::new(account.clone())), + } + } + + /// Get the funds variant if this handle wraps one. + pub fn as_funds(&self) -> Option<&ManagedCoreFundsAccount> { + match &self.inner { + FFIManagedCoreAccountInner::Funds(a) => Some(a.as_ref()), + FFIManagedCoreAccountInner::Keys(_) => None, + } + } + + /// Get the keys variant if this handle wraps one. + pub fn as_keys(&self) -> Option<&ManagedCoreKeysAccount> { + match &self.inner { + FFIManagedCoreAccountInner::Funds(_) => None, + FFIManagedCoreAccountInner::Keys(a) => Some(a.as_ref()), + } + } + + /// Get the underlying [`ManagedAccountType`], regardless of variant. + pub fn managed_account_type(&self) -> &ManagedAccountType { + match &self.inner { + FFIManagedCoreAccountInner::Funds(a) => &a.managed_account_type, + FFIManagedCoreAccountInner::Keys(a) => &a.managed_account_type, + } + } + + /// Get the network of the underlying account, regardless of variant. + pub fn network(&self) -> key_wallet::Network { + match &self.inner { + FFIManagedCoreAccountInner::Funds(a) => a.network, + FFIManagedCoreAccountInner::Keys(a) => a.network, + } + } + + /// Whether the underlying account is watch-only. + pub fn is_watch_only(&self) -> bool { + match &self.inner { + FFIManagedCoreAccountInner::Funds(a) => a.is_watch_only, + FFIManagedCoreAccountInner::Keys(a) => a.is_watch_only, } } - /// Get a reference to the inner managed account + /// Get a reference to the inner funds account, panicking if this handle + /// wraps a keys-only account. + /// + /// Kept for backwards source compatibility with callers that have not + /// yet been migrated to dispatch on the variant. Prefer + /// [`Self::as_funds`] or the explicit accessors below for new code. pub fn inner(&self) -> &ManagedCoreFundsAccount { - self.account.as_ref() + match &self.inner { + FFIManagedCoreAccountInner::Funds(a) => a.as_ref(), + FFIManagedCoreAccountInner::Keys(_) => { + panic!( + "FFIManagedCoreAccount::inner() called on a keys-only account; use as_keys() or the variant-aware accessors" + ) + } + } } } @@ -226,39 +301,67 @@ pub unsafe extern "C" fn managed_wallet_get_account( use key_wallet::account::StandardAccountType; let managed_collection = &managed_wallet.inner().accounts; - let managed_account = match account_type_rust { + // Funds-bearing variants build the FFI handle directly; keys-only + // variants go through `FFIManagedCoreAccount::new_keys`. The two + // halves run inside small closures so each branch returns the same + // `Option` shape. + let ffi_account: Option = match account_type_rust { AccountType::Standard { index, standard_account_type, } => match standard_account_type { - StandardAccountType::BIP44Account => { - managed_collection.standard_bip44_accounts.get(&index) - } - StandardAccountType::BIP32Account => { - managed_collection.standard_bip32_accounts.get(&index) - } + StandardAccountType::BIP44Account => managed_collection + .standard_bip44_accounts + .get(&index) + .map(FFIManagedCoreAccount::new), + StandardAccountType::BIP32Account => managed_collection + .standard_bip32_accounts + .get(&index) + .map(FFIManagedCoreAccount::new), }, AccountType::CoinJoin { index, - } => managed_collection.coinjoin_accounts.get(&index), - AccountType::IdentityRegistration => managed_collection.identity_registration.as_ref(), + } => managed_collection.coinjoin_accounts.get(&index).map(FFIManagedCoreAccount::new), + AccountType::IdentityRegistration => managed_collection + .identity_registration + .as_ref() + .map(FFIManagedCoreAccount::new_keys), AccountType::IdentityTopUp { registration_index, - } => managed_collection.identity_topup.get(®istration_index), - AccountType::IdentityTopUpNotBoundToIdentity => { - managed_collection.identity_topup_not_bound.as_ref() + } => managed_collection + .identity_topup + .get(®istration_index) + .map(FFIManagedCoreAccount::new_keys), + AccountType::IdentityTopUpNotBoundToIdentity => managed_collection + .identity_topup_not_bound + .as_ref() + .map(FFIManagedCoreAccount::new_keys), + AccountType::IdentityInvitation => { + managed_collection.identity_invitation.as_ref().map(FFIManagedCoreAccount::new_keys) } - AccountType::IdentityInvitation => managed_collection.identity_invitation.as_ref(), - AccountType::AssetLockAddressTopUp => { - managed_collection.asset_lock_address_topup.as_ref() - } - AccountType::AssetLockShieldedAddressTopUp => { - managed_collection.asset_lock_shielded_address_topup.as_ref() + AccountType::AssetLockAddressTopUp => managed_collection + .asset_lock_address_topup + .as_ref() + .map(FFIManagedCoreAccount::new_keys), + AccountType::AssetLockShieldedAddressTopUp => managed_collection + .asset_lock_shielded_address_topup + .as_ref() + .map(FFIManagedCoreAccount::new_keys), + AccountType::ProviderVotingKeys => managed_collection + .provider_voting_keys + .as_ref() + .map(FFIManagedCoreAccount::new_keys), + AccountType::ProviderOwnerKeys => { + managed_collection.provider_owner_keys.as_ref().map(FFIManagedCoreAccount::new_keys) } - AccountType::ProviderVotingKeys => managed_collection.provider_voting_keys.as_ref(), - AccountType::ProviderOwnerKeys => managed_collection.provider_owner_keys.as_ref(), - AccountType::ProviderOperatorKeys => managed_collection.provider_operator_keys.as_ref(), - AccountType::ProviderPlatformKeys => managed_collection.provider_platform_keys.as_ref(), + AccountType::ProviderOperatorKeys => managed_collection + .provider_operator_keys + .as_ref() + .map(FFIManagedCoreAccount::new_keys), + AccountType::ProviderPlatformKeys => managed_collection + .provider_platform_keys + .as_ref() + .map(FFIManagedCoreAccount::new_keys), AccountType::DashpayReceivingFunds { .. } => None, @@ -270,9 +373,8 @@ pub unsafe extern "C" fn managed_wallet_get_account( } => None, }; - match managed_account { - Some(account) => { - let ffi_account = FFIManagedCoreAccount::new(account); + match ffi_account { + Some(ffi_account) => { FFIManagedCoreAccountResult::success(Box::into_raw(Box::new(ffi_account))) } None => FFIManagedCoreAccountResult::error( @@ -341,7 +443,7 @@ pub unsafe extern "C" fn managed_wallet_get_top_up_account_with_registration_ind let result = match managed_wallet.inner().accounts.identity_topup.get(®istration_index) { Some(account) => { - let ffi_account = FFIManagedCoreAccount::new(account); + let ffi_account = FFIManagedCoreAccount::new_keys(account); FFIManagedCoreAccountResult::success(Box::into_raw(Box::new(ffi_account))) } None => FFIManagedCoreAccountResult::error( diff --git a/key-wallet-ffi/src/managed_account_collection.rs b/key-wallet-ffi/src/managed_account_collection.rs index 19d6c6760..5912abd1e 100644 --- a/key-wallet-ffi/src/managed_account_collection.rs +++ b/key-wallet-ffi/src/managed_account_collection.rs @@ -354,7 +354,7 @@ pub unsafe extern "C" fn managed_account_collection_get_identity_registration( let collection = &*collection; match &collection.collection.identity_registration { Some(account) => { - let ffi_account = FFIManagedCoreAccount::new(account); + let ffi_account = FFIManagedCoreAccount::new_keys(account); Box::into_raw(Box::new(ffi_account)) } None => ptr::null_mut(), @@ -396,7 +396,7 @@ pub unsafe extern "C" fn managed_account_collection_get_identity_topup( let collection = &*collection; match collection.collection.identity_topup.get(®istration_index) { Some(account) => { - let ffi_account = FFIManagedCoreAccount::new(account); + let ffi_account = FFIManagedCoreAccount::new_keys(account); Box::into_raw(Box::new(ffi_account)) } None => ptr::null_mut(), @@ -460,7 +460,7 @@ pub unsafe extern "C" fn managed_account_collection_get_identity_topup_not_bound let collection = &*collection; match &collection.collection.identity_topup_not_bound { Some(account) => { - let ffi_account = FFIManagedCoreAccount::new(account); + let ffi_account = FFIManagedCoreAccount::new_keys(account); Box::into_raw(Box::new(ffi_account)) } None => ptr::null_mut(), @@ -501,7 +501,7 @@ pub unsafe extern "C" fn managed_account_collection_get_identity_invitation( let collection = &*collection; match &collection.collection.identity_invitation { Some(account) => { - let ffi_account = FFIManagedCoreAccount::new(account); + let ffi_account = FFIManagedCoreAccount::new_keys(account); Box::into_raw(Box::new(ffi_account)) } None => ptr::null_mut(), @@ -544,7 +544,7 @@ pub unsafe extern "C" fn managed_account_collection_get_provider_voting_keys( let collection = &*collection; match &collection.collection.provider_voting_keys { Some(account) => { - let ffi_account = FFIManagedCoreAccount::new(account); + let ffi_account = FFIManagedCoreAccount::new_keys(account); Box::into_raw(Box::new(ffi_account)) } None => ptr::null_mut(), @@ -585,7 +585,7 @@ pub unsafe extern "C" fn managed_account_collection_get_provider_owner_keys( let collection = &*collection; match &collection.collection.provider_owner_keys { Some(account) => { - let ffi_account = FFIManagedCoreAccount::new(account); + let ffi_account = FFIManagedCoreAccount::new_keys(account); Box::into_raw(Box::new(ffi_account)) } None => ptr::null_mut(), @@ -629,7 +629,7 @@ pub unsafe extern "C" fn managed_account_collection_get_provider_operator_keys( let collection = &*collection; match &collection.collection.provider_operator_keys { Some(account) => { - let ffi_account = FFIManagedCoreAccount::new(account); + let ffi_account = FFIManagedCoreAccount::new_keys(account); Box::into_raw(Box::new(ffi_account)) as *mut std::os::raw::c_void } None => ptr::null_mut(), @@ -689,7 +689,7 @@ pub unsafe extern "C" fn managed_account_collection_get_provider_platform_keys( let collection = &*collection; match &collection.collection.provider_platform_keys { Some(account) => { - let ffi_account = FFIManagedCoreAccount::new(account); + let ffi_account = FFIManagedCoreAccount::new_keys(account); Box::into_raw(Box::new(ffi_account)) as *mut std::os::raw::c_void } None => ptr::null_mut(), diff --git a/key-wallet-ffi/src/managed_wallet.rs b/key-wallet-ffi/src/managed_wallet.rs index d1c9801e4..2a7b4a2e6 100644 --- a/key-wallet-ffi/src/managed_wallet.rs +++ b/key-wallet-ffi/src/managed_wallet.rs @@ -623,7 +623,7 @@ mod tests { // Insert the managed account directly into managed_info's accounts managed_info .accounts - .insert(managed_account) + .insert_funds(managed_account) .expect("insert should succeed for Standard account"); // Create wrapper for managed info heap-allocated like C would do diff --git a/key-wallet-ffi/src/utxo_tests.rs b/key-wallet-ffi/src/utxo_tests.rs index 05cd85bde..b8e0247fd 100644 --- a/key-wallet-ffi/src/utxo_tests.rs +++ b/key-wallet-ffi/src/utxo_tests.rs @@ -230,7 +230,7 @@ mod utxo_tests { bip44_account.utxos.insert(outpoint, utxo); } - managed_info.accounts.insert(bip44_account).unwrap(); + managed_info.accounts.insert_funds(bip44_account).unwrap(); let ffi_managed_info = Box::into_raw(Box::new(FFIManagedWalletInfo::new(managed_info))); unsafe { (*ffi_managed_info).inner_mut() }.update_last_processed_height(300); @@ -315,7 +315,7 @@ mod utxo_tests { for utxo in utxos { bip44_account.utxos.insert(utxo.outpoint, utxo); } - managed_info.accounts.insert(bip44_account).unwrap(); + managed_info.accounts.insert_funds(bip44_account).unwrap(); // Create BIP32 account with 1 UTXO let mut bip32_account = ManagedCoreFundsAccount::new( @@ -341,7 +341,7 @@ mod utxo_tests { for utxo in utxos { bip32_account.utxos.insert(utxo.outpoint, utxo); } - managed_info.accounts.insert(bip32_account).unwrap(); + managed_info.accounts.insert_funds(bip32_account).unwrap(); // Create CoinJoin account with 2 UTXOs let mut coinjoin_account = ManagedCoreFundsAccount::new( @@ -360,7 +360,7 @@ mod utxo_tests { for utxo in utxos { coinjoin_account.utxos.insert(utxo.outpoint, utxo); } - managed_info.accounts.insert(coinjoin_account).unwrap(); + managed_info.accounts.insert_funds(coinjoin_account).unwrap(); let ffi_managed_info = Box::into_raw(Box::new(FFIManagedWalletInfo::new(managed_info))); @@ -419,7 +419,7 @@ mod utxo_tests { for utxo in utxos { testnet_account.utxos.insert(utxo.outpoint, utxo); } - managed_info.accounts.insert(testnet_account).unwrap(); + managed_info.accounts.insert_funds(testnet_account).unwrap(); let ffi_managed_info = Box::into_raw(Box::new(FFIManagedWalletInfo::new(managed_info))); diff --git a/key-wallet/src/managed_account/managed_account_collection.rs b/key-wallet/src/managed_account/managed_account_collection.rs index 748b8ea76..71a53a40c 100644 --- a/key-wallet/src/managed_account/managed_account_collection.rs +++ b/key-wallet/src/managed_account/managed_account_collection.rs @@ -12,17 +12,105 @@ use crate::gap_limit::{ DEFAULT_SPECIAL_GAP_LIMIT, DIP17_GAP_LIMIT, }; use crate::managed_account::address_pool::{AddressPool, AddressPoolType}; +use crate::managed_account::managed_account_ref::{ManagedAccountRef, ManagedAccountRefMut}; use crate::managed_account::managed_account_type::ManagedAccountType; use crate::managed_account::managed_platform_account::ManagedPlatformAccount; -use crate::managed_account::ManagedCoreFundsAccount; +use crate::managed_account::{ManagedCoreFundsAccount, ManagedCoreKeysAccount}; use crate::transaction_checking::account_checker::CoreAccountTypeMatch; use crate::{Account, AccountCollection}; use crate::{KeySource, Network}; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; -/// Macro to look up an account by CoreAccountTypeMatch, parameterized by accessor methods -macro_rules! get_by_account_type_match_impl { +/// Owned managed core account, dispatched by variant. +/// +/// Returned by `ManagedAccountCollection::create_managed_account_from_account_type` +/// so callers can hand a freshly built account back to +/// [`ManagedAccountCollection::insert`] without first knowing which struct +/// variant the [`AccountType`] resolved to. +#[derive(Debug, Clone)] +pub enum OwnedManagedCoreAccount { + /// A funds-bearing managed account (Standard, CoinJoin, DashPay). + Funds(ManagedCoreFundsAccount), + /// A keys-only managed account (identity, asset-lock, provider). + Keys(ManagedCoreKeysAccount), +} + +impl OwnedManagedCoreAccount { + /// Get the underlying managed account type. + pub fn managed_account_type(&self) -> &ManagedAccountType { + match self { + OwnedManagedCoreAccount::Funds(a) => &a.managed_account_type, + OwnedManagedCoreAccount::Keys(a) => &a.managed_account_type, + } + } + + /// Get the network for this account. + pub fn network(&self) -> Network { + match self { + OwnedManagedCoreAccount::Funds(a) => a.network, + OwnedManagedCoreAccount::Keys(a) => a.network, + } + } + + /// Whether this account is watch-only. + pub fn is_watch_only(&self) -> bool { + match self { + OwnedManagedCoreAccount::Funds(a) => a.is_watch_only, + OwnedManagedCoreAccount::Keys(a) => a.is_watch_only, + } + } + + /// Borrow as a [`ManagedAccountRef`]. + pub fn as_ref(&self) -> ManagedAccountRef<'_> { + match self { + OwnedManagedCoreAccount::Funds(a) => ManagedAccountRef::Funds(a), + OwnedManagedCoreAccount::Keys(a) => ManagedAccountRef::Keys(a), + } + } + + /// Try to extract the funds variant. + pub fn into_funds(self) -> Option { + match self { + OwnedManagedCoreAccount::Funds(a) => Some(a), + OwnedManagedCoreAccount::Keys(_) => None, + } + } + + /// Try to extract the keys variant. + pub fn into_keys(self) -> Option { + match self { + OwnedManagedCoreAccount::Funds(_) => None, + OwnedManagedCoreAccount::Keys(a) => Some(a), + } + } +} + +/// Returns `true` if `account_type` should be wired to a +/// [`ManagedCoreKeysAccount`] rather than a [`ManagedCoreFundsAccount`]. +fn account_type_is_keys_only(account_type: AccountType) -> bool { + matches!( + account_type, + AccountType::IdentityRegistration + | AccountType::IdentityTopUp { .. } + | AccountType::IdentityTopUpNotBoundToIdentity + | AccountType::IdentityInvitation + | AccountType::AssetLockAddressTopUp + | AccountType::AssetLockShieldedAddressTopUp + | AccountType::ProviderVotingKeys + | AccountType::ProviderOwnerKeys + | AccountType::ProviderOperatorKeys + | AccountType::ProviderPlatformKeys + ) +} + +/// Macro to look up a funds-typed account by [`CoreAccountTypeMatch`]. +/// +/// Only handles match variants whose target account is a +/// [`ManagedCoreFundsAccount`]. Identity / asset-lock / provider variants +/// are dispatched separately to the keys-typed lookup since they map to +/// [`ManagedCoreKeysAccount`]. +macro_rules! get_by_account_type_match_funds_impl { ($self:expr, $match:expr, $get:ident, $as_opt:ident, $values:ident) => { match $match { CoreAccountTypeMatch::StandardBIP44 { @@ -37,37 +125,6 @@ macro_rules! get_by_account_type_match_impl { account_index, .. } => $self.coinjoin_accounts.$get(account_index), - CoreAccountTypeMatch::IdentityRegistration { - .. - } => $self.identity_registration.$as_opt(), - CoreAccountTypeMatch::IdentityTopUp { - account_index, - .. - } => $self.identity_topup.$get(account_index), - CoreAccountTypeMatch::IdentityTopUpNotBound { - .. - } => $self.identity_topup_not_bound.$as_opt(), - CoreAccountTypeMatch::IdentityInvitation { - .. - } => $self.identity_invitation.$as_opt(), - CoreAccountTypeMatch::AssetLockAddressTopUp { - .. - } => $self.asset_lock_address_topup.$as_opt(), - CoreAccountTypeMatch::AssetLockShieldedAddressTopUp { - .. - } => $self.asset_lock_shielded_address_topup.$as_opt(), - CoreAccountTypeMatch::ProviderVotingKeys { - .. - } => $self.provider_voting_keys.$as_opt(), - CoreAccountTypeMatch::ProviderOwnerKeys { - .. - } => $self.provider_owner_keys.$as_opt(), - CoreAccountTypeMatch::ProviderOperatorKeys { - .. - } => $self.provider_operator_keys.$as_opt(), - CoreAccountTypeMatch::ProviderPlatformKeys { - .. - } => $self.provider_platform_keys.$as_opt(), CoreAccountTypeMatch::DashpayReceivingFunds { account_index, involved_addresses, @@ -104,6 +161,95 @@ macro_rules! get_by_account_type_match_impl { _ => false, } }), + // Keys-typed match variants don't resolve to a funds account. + CoreAccountTypeMatch::IdentityRegistration { + .. + } + | CoreAccountTypeMatch::IdentityTopUp { + .. + } + | CoreAccountTypeMatch::IdentityTopUpNotBound { + .. + } + | CoreAccountTypeMatch::IdentityInvitation { + .. + } + | CoreAccountTypeMatch::AssetLockAddressTopUp { + .. + } + | CoreAccountTypeMatch::AssetLockShieldedAddressTopUp { + .. + } + | CoreAccountTypeMatch::ProviderVotingKeys { + .. + } + | CoreAccountTypeMatch::ProviderOwnerKeys { + .. + } + | CoreAccountTypeMatch::ProviderOperatorKeys { + .. + } + | CoreAccountTypeMatch::ProviderPlatformKeys { + .. + } => None, + } + }; +} + +/// Macro to look up a keys-typed account by [`CoreAccountTypeMatch`]. +/// +/// Inverse of [`get_by_account_type_match_funds_impl`]: handles identity / +/// asset-lock / provider variants and returns `None` for funds-typed ones. +macro_rules! get_by_account_type_match_keys_impl { + ($self:expr, $match:expr, $get:ident, $as_opt:ident) => { + match $match { + CoreAccountTypeMatch::IdentityRegistration { + .. + } => $self.identity_registration.$as_opt(), + CoreAccountTypeMatch::IdentityTopUp { + account_index, + .. + } => $self.identity_topup.$get(account_index), + CoreAccountTypeMatch::IdentityTopUpNotBound { + .. + } => $self.identity_topup_not_bound.$as_opt(), + CoreAccountTypeMatch::IdentityInvitation { + .. + } => $self.identity_invitation.$as_opt(), + CoreAccountTypeMatch::AssetLockAddressTopUp { + .. + } => $self.asset_lock_address_topup.$as_opt(), + CoreAccountTypeMatch::AssetLockShieldedAddressTopUp { + .. + } => $self.asset_lock_shielded_address_topup.$as_opt(), + CoreAccountTypeMatch::ProviderVotingKeys { + .. + } => $self.provider_voting_keys.$as_opt(), + CoreAccountTypeMatch::ProviderOwnerKeys { + .. + } => $self.provider_owner_keys.$as_opt(), + CoreAccountTypeMatch::ProviderOperatorKeys { + .. + } => $self.provider_operator_keys.$as_opt(), + CoreAccountTypeMatch::ProviderPlatformKeys { + .. + } => $self.provider_platform_keys.$as_opt(), + // Funds-typed variants don't resolve to a keys account. + CoreAccountTypeMatch::StandardBIP44 { + .. + } + | CoreAccountTypeMatch::StandardBIP32 { + .. + } + | CoreAccountTypeMatch::CoinJoin { + .. + } + | CoreAccountTypeMatch::DashpayReceivingFunds { + .. + } + | CoreAccountTypeMatch::DashpayExternalAccount { + .. + } => None, } }; } @@ -119,25 +265,25 @@ pub struct ManagedAccountCollection { /// CoinJoin accounts by index pub coinjoin_accounts: BTreeMap, /// Identity registration account (optional) - pub identity_registration: Option, + pub identity_registration: Option, /// Identity top-up accounts by registration index - pub identity_topup: BTreeMap, + pub identity_topup: BTreeMap, /// Identity top-up not bound to identity (optional) - pub identity_topup_not_bound: Option, + pub identity_topup_not_bound: Option, /// Identity invitation account (optional) - pub identity_invitation: Option, + pub identity_invitation: Option, /// Asset lock address top-up account (optional) - pub asset_lock_address_topup: Option, + pub asset_lock_address_topup: Option, /// Asset lock shielded address top-up account (optional) - pub asset_lock_shielded_address_topup: Option, + pub asset_lock_shielded_address_topup: Option, /// Provider voting keys (optional) - pub provider_voting_keys: Option, + pub provider_voting_keys: Option, /// Provider owner keys (optional) - pub provider_owner_keys: Option, + pub provider_owner_keys: Option, /// Provider operator keys (optional) - pub provider_operator_keys: Option, + pub provider_operator_keys: Option, /// Provider platform keys (optional) - pub provider_platform_keys: Option, + pub provider_platform_keys: Option, /// DashPay receiving funds accounts keyed by (index, user_id, friend_id) pub dashpay_receival_accounts: BTreeMap, /// DashPay external accounts keyed by (index, user_id, friend_id) @@ -262,11 +408,32 @@ impl ManagedAccountCollection { } } - /// Insert a managed account into the collection + /// Insert a managed account into the collection. + /// + /// Dispatches on the inner [`ManagedAccountType`] to place the account + /// into the right field, and on the variant of [`OwnedManagedCoreAccount`] + /// to enforce the funds/keys split. Returns an error if the variants + /// disagree (for example, a [`OwnedManagedCoreAccount::Funds`] holding an + /// `IdentityRegistration` account, which must be a keys-only account) + /// or if a `PlatformPayment` account is passed (those use + /// [`Self::insert_platform_account`] with `ManagedPlatformAccount`). + pub fn insert(&mut self, account: OwnedManagedCoreAccount) -> Result<(), crate::error::Error> { + match account { + OwnedManagedCoreAccount::Funds(a) => self.insert_funds(a), + OwnedManagedCoreAccount::Keys(a) => self.insert_keys(a), + } + } + + /// Insert a [`ManagedCoreFundsAccount`] into the collection. /// - /// Returns an error if a PlatformPayment account type is passed, since those - /// should use `insert_platform_account()` with `ManagedPlatformAccount` instead. - pub fn insert(&mut self, account: ManagedCoreFundsAccount) -> Result<(), crate::error::Error> { + /// Returns an error if the account type is keys-only (identity, asset-lock, + /// provider) — those must go through [`Self::insert_keys`] — or if it is a + /// `PlatformPayment` account (use [`Self::insert_platform_account`] with + /// [`ManagedPlatformAccount`]). + pub fn insert_funds( + &mut self, + account: ManagedCoreFundsAccount, + ) -> Result<(), crate::error::Error> { use crate::account::StandardAccountType; match &account.managed_account_type { @@ -288,6 +455,90 @@ impl ManagedAccountCollection { } => { self.coinjoin_accounts.insert(*index, account); } + ManagedAccountType::DashpayReceivingFunds { + index, + user_identity_id, + friend_identity_id, + .. + } => { + let key = DashpayAccountKey { + index: *index, + user_identity_id: *user_identity_id, + friend_identity_id: *friend_identity_id, + }; + self.dashpay_receival_accounts.insert(key, account); + } + ManagedAccountType::DashpayExternalAccount { + index, + user_identity_id, + friend_identity_id, + .. + } => { + let key = DashpayAccountKey { + index: *index, + user_identity_id: *user_identity_id, + friend_identity_id: *friend_identity_id, + }; + self.dashpay_external_accounts.insert(key, account); + } + ManagedAccountType::PlatformPayment { + .. + } => { + // Platform Payment accounts should use insert_platform_account() instead + // as they use ManagedPlatformAccount, not ManagedCoreFundsAccount + return Err(crate::error::Error::InvalidParameter( + "Use insert_platform_account() for Platform Payment accounts".into(), + )); + } + // Keys-only types should go through insert_keys. + ManagedAccountType::IdentityRegistration { + .. + } + | ManagedAccountType::IdentityTopUp { + .. + } + | ManagedAccountType::IdentityTopUpNotBoundToIdentity { + .. + } + | ManagedAccountType::IdentityInvitation { + .. + } + | ManagedAccountType::AssetLockAddressTopUp { + .. + } + | ManagedAccountType::AssetLockShieldedAddressTopUp { + .. + } + | ManagedAccountType::ProviderVotingKeys { + .. + } + | ManagedAccountType::ProviderOwnerKeys { + .. + } + | ManagedAccountType::ProviderOperatorKeys { + .. + } + | ManagedAccountType::ProviderPlatformKeys { + .. + } => { + return Err(crate::error::Error::InvalidParameter( + "Use insert_keys() for identity/asset-lock/provider account types".into(), + )); + } + } + Ok(()) + } + + /// Insert a [`ManagedCoreKeysAccount`] into the collection. + /// + /// Returns an error if the account type is funds-bearing (Standard, + /// CoinJoin, DashPay) or `PlatformPayment` — those must go through + /// [`Self::insert_funds`] or [`Self::insert_platform_account`]. + pub fn insert_keys( + &mut self, + account: ManagedCoreKeysAccount, + ) -> Result<(), crate::error::Error> { + match &account.managed_account_type { ManagedAccountType::IdentityRegistration { .. } => { @@ -339,37 +590,26 @@ impl ManagedAccountCollection { } => { self.provider_platform_keys = Some(account); } - ManagedAccountType::DashpayReceivingFunds { - index, - user_identity_id, - friend_identity_id, + // Funds-bearing or platform variants belong elsewhere. + ManagedAccountType::Standard { .. - } => { - let key = DashpayAccountKey { - index: *index, - user_identity_id: *user_identity_id, - friend_identity_id: *friend_identity_id, - }; - self.dashpay_receival_accounts.insert(key, account); } - ManagedAccountType::DashpayExternalAccount { - index, - user_identity_id, - friend_identity_id, + | ManagedAccountType::CoinJoin { + .. + } + | ManagedAccountType::DashpayReceivingFunds { + .. + } + | ManagedAccountType::DashpayExternalAccount { .. } => { - let key = DashpayAccountKey { - index: *index, - user_identity_id: *user_identity_id, - friend_identity_id: *friend_identity_id, - }; - self.dashpay_external_accounts.insert(key, account); + return Err(crate::error::Error::InvalidParameter( + "Use insert_funds() for funds-bearing account types".into(), + )); } ManagedAccountType::PlatformPayment { .. } => { - // Platform Payment accounts should use insert_platform_account() instead - // as they use ManagedPlatformAccount, not ManagedCoreFundsAccount return Err(crate::error::Error::InvalidParameter( "Use insert_platform_account() for Platform Payment accounts".into(), )); @@ -394,84 +634,108 @@ impl ManagedAccountCollection { // Convert standard BIP44 accounts for (index, account) in &account_collection.standard_bip44_accounts { - if let Ok(managed_account) = Self::create_managed_account_from_account(account) { + if let Ok(OwnedManagedCoreAccount::Funds(managed_account)) = + Self::create_managed_account_from_account(account) + { managed_collection.standard_bip44_accounts.insert(*index, managed_account); } } // Convert standard BIP32 accounts for (index, account) in &account_collection.standard_bip32_accounts { - if let Ok(managed_account) = Self::create_managed_account_from_account(account) { + if let Ok(OwnedManagedCoreAccount::Funds(managed_account)) = + Self::create_managed_account_from_account(account) + { managed_collection.standard_bip32_accounts.insert(*index, managed_account); } } // Convert CoinJoin accounts for (index, account) in &account_collection.coinjoin_accounts { - if let Ok(managed_account) = Self::create_managed_account_from_account(account) { + if let Ok(OwnedManagedCoreAccount::Funds(managed_account)) = + Self::create_managed_account_from_account(account) + { managed_collection.coinjoin_accounts.insert(*index, managed_account); } } // Convert special purpose accounts if let Some(account) = &account_collection.identity_registration { - if let Ok(managed_account) = Self::create_managed_account_from_account(account) { + if let Ok(OwnedManagedCoreAccount::Keys(managed_account)) = + Self::create_managed_account_from_account(account) + { managed_collection.identity_registration = Some(managed_account); } } for (index, account) in &account_collection.identity_topup { - if let Ok(managed_account) = Self::create_managed_account_from_account(account) { + if let Ok(OwnedManagedCoreAccount::Keys(managed_account)) = + Self::create_managed_account_from_account(account) + { managed_collection.identity_topup.insert(*index, managed_account); } } if let Some(account) = &account_collection.identity_topup_not_bound { - if let Ok(managed_account) = Self::create_managed_account_from_account(account) { + if let Ok(OwnedManagedCoreAccount::Keys(managed_account)) = + Self::create_managed_account_from_account(account) + { managed_collection.identity_topup_not_bound = Some(managed_account); } } if let Some(account) = &account_collection.identity_invitation { - if let Ok(managed_account) = Self::create_managed_account_from_account(account) { + if let Ok(OwnedManagedCoreAccount::Keys(managed_account)) = + Self::create_managed_account_from_account(account) + { managed_collection.identity_invitation = Some(managed_account); } } if let Some(account) = &account_collection.asset_lock_address_topup { - if let Ok(managed_account) = Self::create_managed_account_from_account(account) { + if let Ok(OwnedManagedCoreAccount::Keys(managed_account)) = + Self::create_managed_account_from_account(account) + { managed_collection.asset_lock_address_topup = Some(managed_account); } } if let Some(account) = &account_collection.asset_lock_shielded_address_topup { - if let Ok(managed_account) = Self::create_managed_account_from_account(account) { + if let Ok(OwnedManagedCoreAccount::Keys(managed_account)) = + Self::create_managed_account_from_account(account) + { managed_collection.asset_lock_shielded_address_topup = Some(managed_account); } } if let Some(account) = &account_collection.provider_voting_keys { - if let Ok(managed_account) = Self::create_managed_account_from_account(account) { + if let Ok(OwnedManagedCoreAccount::Keys(managed_account)) = + Self::create_managed_account_from_account(account) + { managed_collection.provider_voting_keys = Some(managed_account); } } if let Some(account) = &account_collection.provider_owner_keys { - if let Ok(managed_account) = Self::create_managed_account_from_account(account) { + if let Ok(OwnedManagedCoreAccount::Keys(managed_account)) = + Self::create_managed_account_from_account(account) + { managed_collection.provider_owner_keys = Some(managed_account); } } #[cfg(feature = "bls")] if let Some(account) = &account_collection.provider_operator_keys { - if let Ok(managed_account) = Self::create_managed_account_from_bls_account(account) { + if let Ok(OwnedManagedCoreAccount::Keys(managed_account)) = + Self::create_managed_account_from_bls_account(account) + { managed_collection.provider_operator_keys = Some(managed_account); } } #[cfg(feature = "eddsa")] if let Some(account) = &account_collection.provider_platform_keys { - if let Ok(managed_account) = + if let Ok(OwnedManagedCoreAccount::Keys(managed_account)) = Self::create_managed_account_from_eddsa_account(account, None) { managed_collection.provider_platform_keys = Some(managed_account); @@ -480,14 +744,18 @@ impl ManagedAccountCollection { // Convert DashPay receiving accounts for (key, account) in &account_collection.dashpay_receival_accounts { - if let Ok(managed_account) = Self::create_managed_account_from_account(account) { + if let Ok(OwnedManagedCoreAccount::Funds(managed_account)) = + Self::create_managed_account_from_account(account) + { managed_collection.dashpay_receival_accounts.insert(*key, managed_account); } } // Convert DashPay external accounts for (key, account) in &account_collection.dashpay_external_accounts { - if let Ok(managed_account) = Self::create_managed_account_from_account(account) { + if let Ok(OwnedManagedCoreAccount::Funds(managed_account)) = + Self::create_managed_account_from_account(account) + { managed_collection.dashpay_external_accounts.insert(*key, managed_account); } } @@ -504,10 +772,10 @@ impl ManagedAccountCollection { managed_collection } - /// Create a ManagedAccount from an Account - fn create_managed_account_from_account( + /// Create an [`OwnedManagedCoreAccount`] from an [`Account`]. + pub(crate) fn create_managed_account_from_account( account: &Account, - ) -> Result { + ) -> Result { // Use the account's existing public key let key_source = KeySource::Public(account.account_xpub); Self::create_managed_account_from_account_type( @@ -518,11 +786,11 @@ impl ManagedAccountCollection { ) } - /// Create a ManagedAccount from a BLS Account + /// Create an [`OwnedManagedCoreAccount`] from a BLS [`crate::account::BLSAccount`]. #[cfg(feature = "bls")] - fn create_managed_account_from_bls_account( + pub(crate) fn create_managed_account_from_bls_account( account: &crate::account::BLSAccount, - ) -> Result { + ) -> Result { let key_source = KeySource::BLSPublic(account.bls_public_key.clone()); Self::create_managed_account_from_account_type( account.account_type, @@ -532,12 +800,13 @@ impl ManagedAccountCollection { ) } - /// Create a ManagedAccount from an EdDSA Account + /// Create an [`OwnedManagedCoreAccount`] from an EdDSA + /// [`crate::account::EdDSAAccount`]. #[cfg(feature = "eddsa")] - fn create_managed_account_from_eddsa_account( + pub(crate) fn create_managed_account_from_eddsa_account( account: &crate::account::EdDSAAccount, xpriv: Option, - ) -> Result { + ) -> Result { // EdDSA requires hardened derivation, so we need the private key to generate addresses let key_source = match xpriv { Some(priv_key) => KeySource::EdDSAPrivate(priv_key), @@ -551,13 +820,19 @@ impl ManagedAccountCollection { ) } - /// Create a ManagedAccount from an Account type with network and watch-only status - fn create_managed_account_from_account_type( + /// Create an [`OwnedManagedCoreAccount`] from an [`AccountType`] plus + /// network and watch-only status. + /// + /// The variant of the returned [`OwnedManagedCoreAccount`] is determined + /// by `account_type`: identity / asset-lock / provider types build a + /// [`ManagedCoreKeysAccount`], everything else (Standard, CoinJoin, DashPay, + /// PlatformPayment) builds a [`ManagedCoreFundsAccount`]. + pub(crate) fn create_managed_account_from_account_type( account_type: AccountType, network: Network, is_watch_only: bool, key_source: &KeySource, - ) -> Result { + ) -> Result { // Get the derivation path for this account type let base_path = account_type .derivation_path(network) @@ -785,7 +1060,19 @@ impl ManagedAccountCollection { } }; - Ok(ManagedCoreFundsAccount::new(managed_type, network, is_watch_only)) + if account_type_is_keys_only(account_type) { + Ok(OwnedManagedCoreAccount::Keys(ManagedCoreKeysAccount::new( + managed_type, + network, + is_watch_only, + ))) + } else { + Ok(OwnedManagedCoreAccount::Funds(ManagedCoreFundsAccount::new( + managed_type, + network, + is_watch_only, + ))) + } } /// Create a ManagedPlatformAccount from an Account for Platform Payment accounts @@ -819,91 +1106,113 @@ impl ManagedAccountCollection { )) } - pub fn get(&self, index: u32) -> Option<&ManagedCoreFundsAccount> { - // Try standard BIP44 first + /// Get an account by index, scanning the funds-bearing collections first + /// and then keys-only identity-topup accounts. + pub fn get(&self, index: u32) -> Option> { if let Some(account) = self.standard_bip44_accounts.get(&index) { - return Some(account); + return Some(ManagedAccountRef::Funds(account)); } - // Try standard BIP32 if let Some(account) = self.standard_bip32_accounts.get(&index) { - return Some(account); + return Some(ManagedAccountRef::Funds(account)); } - // Try CoinJoin if let Some(account) = self.coinjoin_accounts.get(&index) { - return Some(account); + return Some(ManagedAccountRef::Funds(account)); } - // For identity top-up with registration index if let Some(account) = self.identity_topup.get(&index) { - return Some(account); + return Some(ManagedAccountRef::Keys(account)); } None } - /// Get a mutable account by index - pub fn get_mut(&mut self, index: u32) -> Option<&mut ManagedCoreFundsAccount> { - // Try standard BIP44 first + /// Get a mutable account by index, scanning the funds-bearing collections + /// first and then keys-only identity-topup accounts. + pub fn get_mut(&mut self, index: u32) -> Option> { if let Some(account) = self.standard_bip44_accounts.get_mut(&index) { - return Some(account); + return Some(ManagedAccountRefMut::Funds(account)); } - // Try standard BIP32 if let Some(account) = self.standard_bip32_accounts.get_mut(&index) { - return Some(account); + return Some(ManagedAccountRefMut::Funds(account)); } - // Try CoinJoin if let Some(account) = self.coinjoin_accounts.get_mut(&index) { - return Some(account); + return Some(ManagedAccountRefMut::Funds(account)); } - // For identity top-up with registration index if let Some(account) = self.identity_topup.get_mut(&index) { - return Some(account); + return Some(ManagedAccountRefMut::Keys(account)); } None } - /// Get an account reference by CoreAccountTypeMatch + /// Get an account reference by [`CoreAccountTypeMatch`]. pub fn get_by_account_type_match( &self, account_type_match: &CoreAccountTypeMatch, - ) -> Option<&ManagedCoreFundsAccount> { - get_by_account_type_match_impl!(self, account_type_match, get, as_ref, values) + ) -> Option> { + if let Some(funds) = + get_by_account_type_match_funds_impl!(self, account_type_match, get, as_ref, values) + { + return Some(ManagedAccountRef::Funds(funds)); + } + get_by_account_type_match_keys_impl!(self, account_type_match, get, as_ref) + .map(ManagedAccountRef::Keys) } - /// Get a mutable account reference by AccountTypeMatch + /// Get a mutable account reference by [`CoreAccountTypeMatch`]. pub fn get_by_account_type_match_mut( &mut self, account_type_match: &CoreAccountTypeMatch, - ) -> Option<&mut ManagedCoreFundsAccount> { - get_by_account_type_match_impl!(self, account_type_match, get_mut, as_mut, values_mut) + ) -> Option> { + // Borrow checker note: we have to commit to one branch at a time — + // if the funds-side macro yields nothing we can borrow `self` again + // for the keys-side lookup. + let funds_hit = matches!( + account_type_match, + CoreAccountTypeMatch::StandardBIP44 { .. } + | CoreAccountTypeMatch::StandardBIP32 { .. } + | CoreAccountTypeMatch::CoinJoin { .. } + | CoreAccountTypeMatch::DashpayReceivingFunds { .. } + | CoreAccountTypeMatch::DashpayExternalAccount { .. } + ); + if funds_hit { + return get_by_account_type_match_funds_impl!( + self, + account_type_match, + get_mut, + as_mut, + values_mut + ) + .map(ManagedAccountRefMut::Funds); + } + get_by_account_type_match_keys_impl!(self, account_type_match, get_mut, as_mut) + .map(ManagedAccountRefMut::Keys) } - /// Remove an account from the collection - pub fn remove(&mut self, index: u32) -> Option { - // Try standard BIP44 first + /// Remove an account from the collection by index. + /// + /// Scans funds-bearing collections first, then identity-topup keys + /// accounts. Returns the removed account wrapped in [`OwnedManagedCoreAccount`]. + pub fn remove(&mut self, index: u32) -> Option { if let Some(account) = self.standard_bip44_accounts.remove(&index) { - return Some(account); + return Some(OwnedManagedCoreAccount::Funds(account)); } - // Try standard BIP32 if let Some(account) = self.standard_bip32_accounts.remove(&index) { - return Some(account); + return Some(OwnedManagedCoreAccount::Funds(account)); } - // Try CoinJoin if let Some(account) = self.coinjoin_accounts.remove(&index) { - return Some(account); + return Some(OwnedManagedCoreAccount::Funds(account)); } - // For identity top-up with registration index if let Some(account) = self.identity_topup.remove(&index) { - return Some(account); + return Some(OwnedManagedCoreAccount::Keys(account)); } None @@ -934,120 +1243,112 @@ impl ManagedAccountCollection { false } - /// Get all accounts - pub fn all_accounts(&self) -> Vec<&ManagedCoreFundsAccount> { + /// Get all accounts as a mixed collection of funds- and keys-typed + /// references. + /// + /// See [`ManagedAccountRef`] for how to dispatch on the variant. + pub fn all_accounts(&self) -> Vec> { let mut accounts = Vec::new(); - // Add standard BIP44 accounts - accounts.extend(self.standard_bip44_accounts.values()); - - // Add standard BIP32 accounts - accounts.extend(self.standard_bip32_accounts.values()); + accounts.extend(self.standard_bip44_accounts.values().map(ManagedAccountRef::Funds)); + accounts.extend(self.standard_bip32_accounts.values().map(ManagedAccountRef::Funds)); + accounts.extend(self.coinjoin_accounts.values().map(ManagedAccountRef::Funds)); - // Add CoinJoin accounts - accounts.extend(self.coinjoin_accounts.values()); - - // Add special purpose accounts if let Some(account) = &self.identity_registration { - accounts.push(account); + accounts.push(ManagedAccountRef::Keys(account)); } - accounts.extend(self.identity_topup.values()); + accounts.extend(self.identity_topup.values().map(ManagedAccountRef::Keys)); if let Some(account) = &self.identity_topup_not_bound { - accounts.push(account); + accounts.push(ManagedAccountRef::Keys(account)); } if let Some(account) = &self.identity_invitation { - accounts.push(account); + accounts.push(ManagedAccountRef::Keys(account)); } if let Some(account) = &self.asset_lock_address_topup { - accounts.push(account); + accounts.push(ManagedAccountRef::Keys(account)); } if let Some(account) = &self.asset_lock_shielded_address_topup { - accounts.push(account); + accounts.push(ManagedAccountRef::Keys(account)); } if let Some(account) = &self.provider_voting_keys { - accounts.push(account); + accounts.push(ManagedAccountRef::Keys(account)); } if let Some(account) = &self.provider_owner_keys { - accounts.push(account); + accounts.push(ManagedAccountRef::Keys(account)); } if let Some(account) = &self.provider_operator_keys { - accounts.push(account); + accounts.push(ManagedAccountRef::Keys(account)); } if let Some(account) = &self.provider_platform_keys { - accounts.push(account); + accounts.push(ManagedAccountRef::Keys(account)); } - // Add DashPay accounts - accounts.extend(self.dashpay_receival_accounts.values()); - accounts.extend(self.dashpay_external_accounts.values()); + accounts.extend(self.dashpay_receival_accounts.values().map(ManagedAccountRef::Funds)); + accounts.extend(self.dashpay_external_accounts.values().map(ManagedAccountRef::Funds)); accounts } - /// Get all accounts mutably - pub fn all_accounts_mut(&mut self) -> Vec<&mut ManagedCoreFundsAccount> { + /// Get all accounts mutably as a mixed collection of funds- and + /// keys-typed references. + pub fn all_accounts_mut(&mut self) -> Vec> { let mut accounts = Vec::new(); - // Add standard BIP44 accounts - accounts.extend(self.standard_bip44_accounts.values_mut()); - - // Add standard BIP32 accounts - accounts.extend(self.standard_bip32_accounts.values_mut()); + accounts.extend(self.standard_bip44_accounts.values_mut().map(ManagedAccountRefMut::Funds)); + accounts.extend(self.standard_bip32_accounts.values_mut().map(ManagedAccountRefMut::Funds)); + accounts.extend(self.coinjoin_accounts.values_mut().map(ManagedAccountRefMut::Funds)); - // Add CoinJoin accounts - accounts.extend(self.coinjoin_accounts.values_mut()); - - // Add special purpose accounts if let Some(account) = &mut self.identity_registration { - accounts.push(account); + accounts.push(ManagedAccountRefMut::Keys(account)); } - accounts.extend(self.identity_topup.values_mut()); + accounts.extend(self.identity_topup.values_mut().map(ManagedAccountRefMut::Keys)); if let Some(account) = &mut self.identity_topup_not_bound { - accounts.push(account); + accounts.push(ManagedAccountRefMut::Keys(account)); } if let Some(account) = &mut self.identity_invitation { - accounts.push(account); + accounts.push(ManagedAccountRefMut::Keys(account)); } if let Some(account) = &mut self.asset_lock_address_topup { - accounts.push(account); + accounts.push(ManagedAccountRefMut::Keys(account)); } if let Some(account) = &mut self.asset_lock_shielded_address_topup { - accounts.push(account); + accounts.push(ManagedAccountRefMut::Keys(account)); } if let Some(account) = &mut self.provider_voting_keys { - accounts.push(account); + accounts.push(ManagedAccountRefMut::Keys(account)); } if let Some(account) = &mut self.provider_owner_keys { - accounts.push(account); + accounts.push(ManagedAccountRefMut::Keys(account)); } if let Some(account) = &mut self.provider_operator_keys { - accounts.push(account); + accounts.push(ManagedAccountRefMut::Keys(account)); } if let Some(account) = &mut self.provider_platform_keys { - accounts.push(account); + accounts.push(ManagedAccountRefMut::Keys(account)); } - // Add DashPay accounts - accounts.extend(self.dashpay_receival_accounts.values_mut()); - accounts.extend(self.dashpay_external_accounts.values_mut()); + accounts + .extend(self.dashpay_receival_accounts.values_mut().map(ManagedAccountRefMut::Funds)); + accounts + .extend(self.dashpay_external_accounts.values_mut().map(ManagedAccountRefMut::Funds)); accounts } diff --git a/key-wallet/src/managed_account/managed_account_ref.rs b/key-wallet/src/managed_account/managed_account_ref.rs new file mode 100644 index 000000000..85dc5a437 --- /dev/null +++ b/key-wallet/src/managed_account/managed_account_ref.rs @@ -0,0 +1,516 @@ +//! Borrowed enum spanning [`ManagedCoreFundsAccount`] and [`ManagedCoreKeysAccount`]. +//! +//! Several collection-level accessors (`all_accounts`, `get_by_account_type_match`, +//! etc.) need to return references to either funds-bearing or keys-only managed +//! accounts. [`ManagedAccountRef`] (and its mutable counterpart) provides the +//! shared API surface for those callers without requiring them to dispatch on +//! the concrete account type. +//! +//! Operations that only make sense on funds accounts (balance, UTXOs, transaction +//! recording, etc.) are NOT exposed here — callers that need them must match on +//! the variant explicitly. + +use crate::account::AccountMetadata; +#[cfg(feature = "keep_txs_in_memory")] +use crate::account::TransactionRecord; +use crate::managed_account::address_pool::AddressInfo; +use crate::managed_account::{ManagedCoreFundsAccount, ManagedCoreKeysAccount}; +use crate::transaction_checking::account_checker::AccountMatch; +use crate::transaction_checking::transaction_router::TransactionType; +use crate::transaction_checking::TransactionContext; +use crate::Network; +use dashcore::{Address, ScriptBuf, Transaction, Txid}; + +/// Immutable reference to a managed core account, either funds-bearing or +/// keys-only. +/// +/// See the [module-level docs](self) for context. +#[derive(Debug, Clone, Copy)] +pub enum ManagedAccountRef<'a> { + /// Funds-bearing variant (Standard, CoinJoin, DashPay). + Funds(&'a ManagedCoreFundsAccount), + /// Keys-only variant (identity, asset-lock, provider). + Keys(&'a ManagedCoreKeysAccount), +} + +/// Mutable reference to a managed core account, either funds-bearing or +/// keys-only. +/// +/// See the [module-level docs](self) for context. +#[derive(Debug)] +pub enum ManagedAccountRefMut<'a> { + /// Funds-bearing variant (Standard, CoinJoin, DashPay). + Funds(&'a mut ManagedCoreFundsAccount), + /// Keys-only variant (identity, asset-lock, provider). + Keys(&'a mut ManagedCoreKeysAccount), +} + +impl<'a> ManagedAccountRef<'a> { + /// Get the managed account type. + pub fn managed_account_type(&self) -> &crate::managed_account::ManagedAccountType { + match self { + ManagedAccountRef::Funds(a) => &a.managed_account_type, + ManagedAccountRef::Keys(a) => &a.managed_account_type, + } + } + + /// Get the network this account belongs to. + pub fn network(&self) -> Network { + match self { + ManagedAccountRef::Funds(a) => a.network, + ManagedAccountRef::Keys(a) => a.network, + } + } + + /// Get the account metadata. + pub fn metadata(&self) -> &AccountMetadata { + match self { + ManagedAccountRef::Funds(a) => &a.metadata, + ManagedAccountRef::Keys(a) => &a.metadata, + } + } + + /// Whether this is a watch-only account. + pub fn is_watch_only(&self) -> bool { + match self { + ManagedAccountRef::Funds(a) => a.is_watch_only, + ManagedAccountRef::Keys(a) => a.is_watch_only, + } + } + + /// Check whether the address belongs to this account. + pub fn contains_address(&self, address: &Address) -> bool { + match self { + ManagedAccountRef::Funds(a) => a.contains_address(address), + ManagedAccountRef::Keys(a) => a.contains_address(address), + } + } + + /// Check whether the script pubkey belongs to this account. + pub fn contains_script_pub_key(&self, script_pub_key: &ScriptBuf) -> bool { + match self { + ManagedAccountRef::Funds(a) => a.contains_script_pub_key(script_pub_key), + ManagedAccountRef::Keys(a) => a.contains_script_pub_key(script_pub_key), + } + } + + /// Get address info for the given address, if owned. + pub fn get_address_info(&self, address: &Address) -> Option { + match self { + ManagedAccountRef::Funds(a) => a.get_address_info(address), + ManagedAccountRef::Keys(a) => a.get_address_info(address), + } + } + + /// Get every address known to this account, across all pools. + pub fn all_addresses(&self) -> Vec
{ + match self { + ManagedAccountRef::Funds(a) => a.all_addresses(), + ManagedAccountRef::Keys(a) => a.all_addresses(), + } + } + + /// Get the account's index, if it carries one. + pub fn index(&self) -> Option { + match self { + ManagedAccountRef::Funds(a) => a.index(), + ManagedAccountRef::Keys(a) => a.index(), + } + } + + /// Get the account index or 0 if none exists. + pub fn index_or_default(&self) -> u32 { + match self { + ManagedAccountRef::Funds(a) => a.index_or_default(), + ManagedAccountRef::Keys(a) => a.index_or_default(), + } + } + + /// Return the current monitor revision. + pub fn monitor_revision(&self) -> u64 { + match self { + ManagedAccountRef::Funds(a) => a.monitor_revision(), + ManagedAccountRef::Keys(a) => a.monitor_revision(), + } + } + + /// Returns whether this account has already processed `txid`. + pub fn has_transaction(&self, txid: &Txid) -> bool { + match self { + ManagedAccountRef::Funds(a) => a.has_transaction(txid), + ManagedAccountRef::Keys(a) => a.has_transaction(txid), + } + } + + /// Returns the stored transaction record for `txid`, if any. + /// + /// The returned reference shares the borrow with the underlying account + /// rather than this enum value, so callers can hold it past a drop of + /// the [`ManagedAccountRef`] wrapper. Always returns `None` when the + /// `keep_txs_in_memory` Cargo feature is disabled. + #[cfg(feature = "keep_txs_in_memory")] + pub fn get_transaction(self, txid: &Txid) -> Option<&'a TransactionRecord> { + match self { + ManagedAccountRef::Funds(a) => a.get_transaction(txid), + ManagedAccountRef::Keys(a) => a.get_transaction(txid), + } + } + + /// Iterate over the stored transactions of this account. + /// + /// Returns an empty iterator when the `keep_txs_in_memory` feature is + /// disabled. + #[cfg(feature = "keep_txs_in_memory")] + pub fn transactions_iter( + self, + ) -> Box + 'a> { + match self { + ManagedAccountRef::Funds(a) => Box::new(a.transactions.iter()), + ManagedAccountRef::Keys(a) => Box::new(a.transactions.iter()), + } + } + + /// Iterate over the stored transactions of this account. + /// + /// Returns an empty iterator when the `keep_txs_in_memory` feature is + /// disabled. + #[cfg(not(feature = "keep_txs_in_memory"))] + pub fn transactions_iter( + self, + ) -> Box< + dyn Iterator< + Item = ( + &'a Txid, + &'a crate::managed_account::transaction_record::TransactionRecord, + ), + > + 'a, + > { + Box::new(std::iter::empty()) + } + + /// Returns the stored transaction record for `txid`, if any. + /// + /// Always returns `None` when the `keep_txs_in_memory` Cargo feature is + /// disabled. + #[cfg(not(feature = "keep_txs_in_memory"))] + pub fn get_transaction( + self, + _txid: &Txid, + ) -> Option<&'a crate::managed_account::transaction_record::TransactionRecord> { + None + } + + /// Borrow the funds variant if this is a funds account. + pub fn as_funds(&self) -> Option<&'a ManagedCoreFundsAccount> { + match self { + ManagedAccountRef::Funds(a) => Some(*a), + ManagedAccountRef::Keys(_) => None, + } + } + + /// Borrow the keys variant if this is a keys account. + pub fn as_keys(&self) -> Option<&'a ManagedCoreKeysAccount> { + match self { + ManagedAccountRef::Funds(_) => None, + ManagedAccountRef::Keys(a) => Some(*a), + } + } +} + +impl<'a> ManagedAccountRefMut<'a> { + /// Get the managed account type. + pub fn managed_account_type(&self) -> &crate::managed_account::ManagedAccountType { + match self { + ManagedAccountRefMut::Funds(a) => &a.managed_account_type, + ManagedAccountRefMut::Keys(a) => &a.managed_account_type, + } + } + + /// Get the mutable managed account type. + pub fn managed_account_type_mut(&mut self) -> &mut crate::managed_account::ManagedAccountType { + match self { + ManagedAccountRefMut::Funds(a) => &mut a.managed_account_type, + ManagedAccountRefMut::Keys(a) => &mut a.managed_account_type, + } + } + + /// Get the network this account belongs to. + pub fn network(&self) -> Network { + match self { + ManagedAccountRefMut::Funds(a) => a.network, + ManagedAccountRefMut::Keys(a) => a.network, + } + } + + /// Get the account metadata. + pub fn metadata(&self) -> &AccountMetadata { + match self { + ManagedAccountRefMut::Funds(a) => &a.metadata, + ManagedAccountRefMut::Keys(a) => &a.metadata, + } + } + + /// Get mutable account metadata. + pub fn metadata_mut(&mut self) -> &mut AccountMetadata { + match self { + ManagedAccountRefMut::Funds(a) => &mut a.metadata, + ManagedAccountRefMut::Keys(a) => &mut a.metadata, + } + } + + /// Whether this is a watch-only account. + pub fn is_watch_only(&self) -> bool { + match self { + ManagedAccountRefMut::Funds(a) => a.is_watch_only, + ManagedAccountRefMut::Keys(a) => a.is_watch_only, + } + } + + /// Check whether the address belongs to this account. + pub fn contains_address(&self, address: &Address) -> bool { + match self { + ManagedAccountRefMut::Funds(a) => a.contains_address(address), + ManagedAccountRefMut::Keys(a) => a.contains_address(address), + } + } + + /// Check whether the script pubkey belongs to this account. + pub fn contains_script_pub_key(&self, script_pub_key: &ScriptBuf) -> bool { + match self { + ManagedAccountRefMut::Funds(a) => a.contains_script_pub_key(script_pub_key), + ManagedAccountRefMut::Keys(a) => a.contains_script_pub_key(script_pub_key), + } + } + + /// Get address info for the given address, if owned. + pub fn get_address_info(&self, address: &Address) -> Option { + match self { + ManagedAccountRefMut::Funds(a) => a.get_address_info(address), + ManagedAccountRefMut::Keys(a) => a.get_address_info(address), + } + } + + /// Get every address known to this account, across all pools. + pub fn all_addresses(&self) -> Vec
{ + match self { + ManagedAccountRefMut::Funds(a) => a.all_addresses(), + ManagedAccountRefMut::Keys(a) => a.all_addresses(), + } + } + + /// Get the account's index, if it carries one. + pub fn index(&self) -> Option { + match self { + ManagedAccountRefMut::Funds(a) => a.index(), + ManagedAccountRefMut::Keys(a) => a.index(), + } + } + + /// Get the account index or 0 if none exists. + pub fn index_or_default(&self) -> u32 { + match self { + ManagedAccountRefMut::Funds(a) => a.index_or_default(), + ManagedAccountRefMut::Keys(a) => a.index_or_default(), + } + } + + /// Increment the monitor revision to signal that the monitored address set + /// changed. + pub fn bump_monitor_revision(&mut self) { + match self { + ManagedAccountRefMut::Funds(a) => a.bump_monitor_revision(), + ManagedAccountRefMut::Keys(a) => a.bump_monitor_revision(), + } + } + + /// Mark an address as used. + pub fn mark_address_used(&mut self, address: &Address) -> bool { + match self { + ManagedAccountRefMut::Funds(a) => a.mark_address_used(address), + ManagedAccountRefMut::Keys(a) => a.mark_address_used(address), + } + } + + /// Returns whether this account has already processed `txid`. + pub fn has_transaction(&self, txid: &Txid) -> bool { + match self { + ManagedAccountRefMut::Funds(a) => a.has_transaction(txid), + ManagedAccountRefMut::Keys(a) => a.has_transaction(txid), + } + } + + /// Returns the stored transaction record for `txid`, if any. + /// + /// Always returns `None` when the `keep_txs_in_memory` Cargo feature is + /// disabled. + #[cfg(feature = "keep_txs_in_memory")] + pub fn get_transaction(&self, txid: &Txid) -> Option<&TransactionRecord> { + match self { + ManagedAccountRefMut::Funds(a) => a.get_transaction(txid), + ManagedAccountRefMut::Keys(a) => a.get_transaction(txid), + } + } + + /// Returns the stored transaction record for `txid`, if any. + /// + /// Always returns `None` when the `keep_txs_in_memory` Cargo feature is + /// disabled. + #[cfg(not(feature = "keep_txs_in_memory"))] + pub fn get_transaction( + &self, + _txid: &Txid, + ) -> Option<&crate::managed_account::transaction_record::TransactionRecord> { + None + } + + /// Record a new transaction on this account. + /// + /// On a funds account this updates UTXOs and balance bookkeeping; on a + /// keys account it only updates `processed_txids` (and the per-tx record + /// when `keep_txs_in_memory` is enabled). + pub(crate) fn record_transaction( + &mut self, + tx: &Transaction, + account_match: &AccountMatch, + context: TransactionContext, + transaction_type: TransactionType, + ) -> crate::managed_account::transaction_record::TransactionRecord { + match self { + ManagedAccountRefMut::Funds(a) => { + a.record_transaction(tx, account_match, context, transaction_type) + } + ManagedAccountRefMut::Keys(a) => { + a.record_transaction(tx, account_match, context, transaction_type) + } + } + } + + /// Re-process an existing transaction with updated context. + pub(crate) fn confirm_transaction( + &mut self, + tx: &Transaction, + account_match: &AccountMatch, + context: TransactionContext, + transaction_type: TransactionType, + ) -> bool { + match self { + ManagedAccountRefMut::Funds(a) => { + a.confirm_transaction(tx, account_match, context, transaction_type) + } + ManagedAccountRefMut::Keys(a) => { + a.confirm_transaction(tx, account_match, context, transaction_type) + } + } + } + + /// Mark UTXOs belonging to a transaction as InstantSend-locked. + /// + /// No-op on keys accounts, which never carry UTXOs. + pub(crate) fn mark_utxos_instant_send(&mut self, txid: &Txid) -> bool { + match self { + ManagedAccountRefMut::Funds(a) => a.mark_utxos_instant_send(txid), + ManagedAccountRefMut::Keys(_) => { + let _ = txid; + false + } + } + } + + /// Update an in-memory transaction record's context. + /// + /// No-op (returns `false`) when `keep_txs_in_memory` is disabled or no + /// matching record exists. + #[cfg(feature = "keep_txs_in_memory")] + pub(crate) fn update_transaction_record_context( + &mut self, + txid: &Txid, + context: TransactionContext, + ) -> Option { + match self { + ManagedAccountRefMut::Funds(a) => a.transactions.get_mut(txid).map(|r| { + r.update_context(context); + r.clone() + }), + ManagedAccountRefMut::Keys(a) => a.transactions.get_mut(txid).map(|r| { + r.update_context(context); + r.clone() + }), + } + } + + /// Returns `true` if this account currently holds an in-memory record + /// for `txid`. + /// + /// Equivalent to `has_transaction` when `keep_txs_in_memory` is enabled, + /// otherwise always `false`. + pub(crate) fn contains_transaction_record(&self, txid: &Txid) -> bool { + #[cfg(feature = "keep_txs_in_memory")] + { + match self { + ManagedAccountRefMut::Funds(a) => a.transactions.contains_key(txid), + ManagedAccountRefMut::Keys(a) => a.transactions.contains_key(txid), + } + } + #[cfg(not(feature = "keep_txs_in_memory"))] + { + let _ = txid; + false + } + } + + /// Borrow the funds variant if this is a funds account. + pub fn as_funds(&self) -> Option<&ManagedCoreFundsAccount> { + match self { + ManagedAccountRefMut::Funds(a) => Some(&**a), + ManagedAccountRefMut::Keys(_) => None, + } + } + + /// Borrow the funds variant mutably if this is a funds account. + pub fn as_funds_mut(&mut self) -> Option<&mut ManagedCoreFundsAccount> { + match self { + ManagedAccountRefMut::Funds(a) => Some(&mut **a), + ManagedAccountRefMut::Keys(_) => None, + } + } + + /// Borrow the keys variant if this is a keys account. + pub fn as_keys(&self) -> Option<&ManagedCoreKeysAccount> { + match self { + ManagedAccountRefMut::Funds(_) => None, + ManagedAccountRefMut::Keys(a) => Some(&**a), + } + } + + /// Borrow the keys variant mutably if this is a keys account. + pub fn as_keys_mut(&mut self) -> Option<&mut ManagedCoreKeysAccount> { + match self { + ManagedAccountRefMut::Funds(_) => None, + ManagedAccountRefMut::Keys(a) => Some(&mut **a), + } + } + + /// Convert into the underlying funds variant if this is a funds account. + pub fn into_funds(self) -> Option<&'a mut ManagedCoreFundsAccount> { + match self { + ManagedAccountRefMut::Funds(a) => Some(a), + ManagedAccountRefMut::Keys(_) => None, + } + } + + /// Convert into the underlying keys variant if this is a keys account. + pub fn into_keys(self) -> Option<&'a mut ManagedCoreKeysAccount> { + match self { + ManagedAccountRefMut::Funds(_) => None, + ManagedAccountRefMut::Keys(a) => Some(a), + } + } + + /// Reborrow as an immutable [`ManagedAccountRef`]. + pub fn as_ref(&self) -> ManagedAccountRef<'_> { + match self { + ManagedAccountRefMut::Funds(a) => ManagedAccountRef::Funds(a), + ManagedAccountRefMut::Keys(a) => ManagedAccountRef::Keys(a), + } + } +} diff --git a/key-wallet/src/managed_account/managed_core_keys_account.rs b/key-wallet/src/managed_account/managed_core_keys_account.rs index 8ce3edf4c..e3951504a 100644 --- a/key-wallet/src/managed_account/managed_core_keys_account.rs +++ b/key-wallet/src/managed_account/managed_core_keys_account.rs @@ -18,14 +18,23 @@ use crate::managed_account::address_pool; #[cfg(any(feature = "bls", feature = "eddsa"))] use crate::managed_account::address_pool::PublicKeyType; use crate::managed_account::managed_account_type::ManagedAccountType; +use crate::managed_account::transaction_record::{OutputDetail, OutputRole, TransactionDirection}; +use crate::transaction_checking::account_checker::{ + AccountMatch, AddressClassification, CoreAccountTypeMatch, +}; +use crate::transaction_checking::transaction_router::TransactionType; +use crate::transaction_checking::TransactionContext; #[cfg(feature = "eddsa")] use crate::AddressInfo; use crate::{ExtendedPubKey, Network}; -use dashcore::Txid; -use dashcore::{Address, ScriptBuf}; +use dashcore::address::Payload; +use dashcore::transaction::TransactionPayload; +use dashcore::{Address, ScriptBuf, Transaction, Txid}; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; +#[cfg(feature = "keep_txs_in_memory")] use std::collections::BTreeMap; +use std::collections::HashSet; /// Managed core keys account with mutable state but no funds tracking /// @@ -34,7 +43,7 @@ use std::collections::BTreeMap; /// (identity registration, asset locks, masternode provider keys) where /// per-account UTXO/balance bookkeeping is not meaningful. #[derive(Debug, Clone)] -#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde", derive(Serialize))] pub struct ManagedCoreKeysAccount { /// Account type with embedded address pools and index pub managed_account_type: ManagedAccountType, @@ -44,8 +53,21 @@ pub struct ManagedCoreKeysAccount { pub metadata: AccountMetadata, /// Whether this is a watch-only account pub is_watch_only: bool, - /// Transaction history for this account + /// Transaction history for this account. + /// + /// Only present when the `keep_txs_in_memory` Cargo feature is enabled. + /// With the feature off, processed transactions update `processed_txids` + /// only — no per-tx history is retained. + #[cfg(feature = "keep_txs_in_memory")] pub transactions: BTreeMap, + /// Txids of every transaction this account has already processed. + /// + /// Always populated regardless of `keep_txs_in_memory`, so the + /// dedup guard in `confirm_transaction` works in either feature + /// configuration. Rebuilt from `transactions` during deserialization + /// when the feature is enabled. + #[cfg_attr(feature = "serde", serde(skip_serializing))] + pub(crate) processed_txids: HashSet, /// Revision counter incremented when the monitored address set changes /// (e.g. new addresses generated). Used to detect bloom filter staleness. #[cfg_attr(feature = "serde", serde(skip))] @@ -64,11 +86,42 @@ impl ManagedCoreKeysAccount { network, metadata: AccountMetadata::default(), is_watch_only, + #[cfg(feature = "keep_txs_in_memory")] transactions: BTreeMap::new(), + processed_txids: HashSet::new(), monitor_revision: 0, } } + /// Returns `true` if this account has already processed `txid`. + /// + /// Backed by an always-present `processed_txids` set, so this works + /// regardless of whether the `keep_txs_in_memory` Cargo feature is + /// enabled. Use [`Self::get_transaction`] to retrieve the full record + /// (only available when the feature is enabled). + pub fn has_transaction(&self, txid: &Txid) -> bool { + self.processed_txids.contains(txid) + } + + /// Returns the stored transaction record for `txid`, if any. + /// + /// Always returns `None` when the `keep_txs_in_memory` Cargo feature is + /// disabled. + #[cfg(feature = "keep_txs_in_memory")] + pub fn get_transaction(&self, txid: &Txid) -> Option<&TransactionRecord> { + self.transactions.get(txid) + } + + /// Returns the stored transaction record for `txid`, if any. + /// + /// Always returns `None` when the `keep_txs_in_memory` Cargo feature is + /// disabled. + #[cfg(not(feature = "keep_txs_in_memory"))] + pub fn get_transaction(&self, txid: &Txid) -> Option<&TransactionRecord> { + let _ = txid; + None + } + /// Return the current monitor revision. pub fn monitor_revision(&self) -> u64 { self.monitor_revision @@ -676,4 +729,588 @@ impl ManagedCoreKeysAccount { } => Some(addresses.gap_limit), } } + + /// Record a new transaction for this keys account. + /// + /// Unlike [`ManagedCoreFundsAccount::record_transaction`](crate::managed_account::ManagedCoreFundsAccount::record_transaction), + /// this variant performs no UTXO bookkeeping or balance updates: keys + /// accounts (identity, asset-lock, provider) do not track per-account + /// UTXOs, so input details are always empty and output annotation is + /// limited to outputs paying to addresses we own. + /// + /// Always inserts into `processed_txids`; only inserts into + /// `transactions` when the `keep_txs_in_memory` feature is enabled. + pub(crate) fn record_transaction( + &mut self, + tx: &Transaction, + account_match: &AccountMatch, + context: TransactionContext, + transaction_type: TransactionType, + ) -> TransactionRecord { + let net_amount = account_match.received as i64 - account_match.sent as i64; + + let receive_addrs: HashSet<_> = account_match + .account_type_match + .involved_receive_addresses() + .iter() + .map(|info| &info.address) + .collect(); + let change_addrs: HashSet<_> = account_match + .account_type_match + .involved_change_addresses() + .iter() + .map(|info| &info.address) + .collect(); + + // Keys accounts have no UTXO state to look up — input details + // come from UTXOs only, so there is nothing to record. + let input_details = Vec::new(); + + // Use `account_match.sent` as the sole signal that we contributed + // to this transaction's inputs (UTXO-based detection isn't + // available here). + let has_inputs = account_match.sent > 0; + + let resolved_outputs: Vec> = tx + .output + .iter() + .map(|output| Address::from_script(&output.script_pubkey, self.network).ok()) + .collect(); + + let mut output_details = Vec::new(); + for (idx, output) in tx.output.iter().enumerate() { + let role = match &resolved_outputs[idx] { + Some(addr) if receive_addrs.contains(addr) => OutputRole::Received, + Some(addr) if change_addrs.contains(addr) => OutputRole::Change, + Some(_) if has_inputs => OutputRole::Sent, + Some(_) => continue, + None => { + if output.script_pubkey.is_provably_unspendable() { + OutputRole::Unspendable + } else if has_inputs { + OutputRole::Sent + } else { + continue; + } + } + }; + output_details.push(OutputDetail { + index: idx as u32, + role, + address: resolved_outputs[idx].clone(), + value: output.value, + }); + } + + let has_sent = output_details.iter().any(|d| d.role == OutputRole::Sent); + let has_our_outputs = output_details + .iter() + .any(|d| d.role == OutputRole::Received || d.role == OutputRole::Change); + let direction = if transaction_type == TransactionType::CoinJoin { + TransactionDirection::CoinJoin + } else if !has_sent && has_inputs && has_our_outputs { + TransactionDirection::Internal + } else if has_inputs { + TransactionDirection::Outgoing + } else { + TransactionDirection::Incoming + }; + + let tx_record = TransactionRecord::new( + tx.clone(), + self.managed_account_type.to_account_type(), + context.clone(), + transaction_type, + direction, + input_details, + output_details, + net_amount, + ); + + let record = tx_record.clone(); + self.processed_txids.insert(tx.txid()); + #[cfg(feature = "keep_txs_in_memory")] + self.transactions.insert(tx.txid(), tx_record); + #[cfg(not(feature = "keep_txs_in_memory"))] + let _ = tx_record; + + // Silence unused-variable warnings when `keep_txs_in_memory` is off + // and `context` was not consumed by the record store. + #[cfg(not(feature = "keep_txs_in_memory"))] + let _ = context; + + record + } + + /// Re-process an existing transaction with updated context for this keys account. + /// + /// Mirrors [`ManagedCoreFundsAccount::confirm_transaction`](crate::managed_account::ManagedCoreFundsAccount::confirm_transaction) + /// but without UTXO updates. Returns `true` if the transaction transitioned + /// from unconfirmed to confirmed. + pub(crate) fn confirm_transaction( + &mut self, + tx: &Transaction, + account_match: &AccountMatch, + context: TransactionContext, + transaction_type: TransactionType, + ) -> bool { + if !self.processed_txids.contains(&tx.txid()) { + self.record_transaction(tx, account_match, context, transaction_type); + return true; + } + + #[cfg_attr(not(feature = "keep_txs_in_memory"), allow(unused_mut))] + let mut changed = false; + #[cfg(feature = "keep_txs_in_memory")] + if let Some(tx_record) = self.transactions.get_mut(&tx.txid()) { + debug_assert_eq!( + tx_record.transaction_type, + transaction_type, + "transaction_type changed between recordings for {}", + tx.txid() + ); + if tx_record.context != context { + let was_confirmed = tx_record.context.confirmed(); + tx_record.update_context(context); + changed = !was_confirmed; + } + } + #[cfg(not(feature = "keep_txs_in_memory"))] + { + // Silence unused-binding warnings. + let _ = (transaction_type, context, account_match); + } + // Silence unused-variable warning when keep_txs_in_memory is off + #[cfg(not(feature = "keep_txs_in_memory"))] + let _ = tx; + changed + } + + /// Classify an address within this account. + /// + /// Keys accounts only ever return [`AddressClassification::Other`], + /// because they don't model the external/internal split that standard + /// (BIP44/BIP32) accounts use. + pub fn classify_address(&self, address: &Address) -> AddressClassification { + let _ = address; + AddressClassification::Other + } + + /// Check if a script pubkey is a provider payout that belongs to this account + fn check_provider_payout( + &self, + script_pubkey: &ScriptBuf, + ) -> Option { + if self.contains_script_pub_key(script_pubkey) { + if let Ok(address) = Address::from_script(script_pubkey, self.network) { + return self.get_address_info(&address); + } + } + None + } + + /// Check a single keys account for transaction involvement. + /// + /// Mirrors [`ManagedCoreFundsAccount::check_transaction_for_match`](crate::managed_account::ManagedCoreFundsAccount::check_transaction_for_match) + /// but skips UTXO-based input matching (`sent` is always 0 for keys accounts). + pub fn check_transaction_for_match( + &self, + tx: &Transaction, + index: Option, + ) -> Option { + let mut involved_other_addresses = Vec::new(); + let mut received = 0u64; + let mut provider_payout_involved = false; + + // Check provider payouts in special transactions + if let Some(payload) = &tx.special_transaction_payload { + let script_payout = match payload { + TransactionPayload::ProviderRegistrationPayloadType(reg) => { + Some(®.script_payout) + } + TransactionPayload::ProviderUpdateRegistrarPayloadType(update) => { + Some(&update.script_payout) + } + TransactionPayload::ProviderUpdateServicePayloadType(update) => { + Some(&update.script_payout) + } + _ => None, + }; + + if let Some(payout_script) = script_payout { + if let Some(payout_info) = self.check_provider_payout(payout_script) { + provider_payout_involved = true; + if Address::from_script(payout_script, self.network).is_ok() { + involved_other_addresses.push(payout_info); + } + } + } + } + + // Check outputs (received) — keys accounts never own change addresses, + // so every match goes into `involved_other_addresses`. + for output in &tx.output { + if self.contains_script_pub_key(&output.script_pubkey) { + if let Ok(address) = Address::from_script(&output.script_pubkey, self.network) { + if let Some(address_info) = self.get_address_info(&address) { + involved_other_addresses.push(address_info); + } + } + received += output.value; + } + } + + // Keys accounts don't track UTXOs, so we cannot detect spends from this + // account based on the input set. `sent` is always 0. + let sent = 0u64; + + let has_addresses = !involved_other_addresses.is_empty() || provider_payout_involved; + + if has_addresses { + let account_type_match = match &self.managed_account_type { + ManagedAccountType::IdentityRegistration { + .. + } => CoreAccountTypeMatch::IdentityRegistration { + involved_addresses: involved_other_addresses, + }, + ManagedAccountType::IdentityTopUp { + .. + } => CoreAccountTypeMatch::IdentityTopUp { + account_index: index.unwrap_or(0), + involved_addresses: involved_other_addresses, + }, + ManagedAccountType::IdentityTopUpNotBoundToIdentity { + .. + } => CoreAccountTypeMatch::IdentityTopUpNotBound { + involved_addresses: involved_other_addresses, + }, + ManagedAccountType::IdentityInvitation { + .. + } => CoreAccountTypeMatch::IdentityInvitation { + involved_addresses: involved_other_addresses, + }, + ManagedAccountType::AssetLockAddressTopUp { + .. + } => CoreAccountTypeMatch::AssetLockAddressTopUp { + involved_addresses: involved_other_addresses, + }, + ManagedAccountType::AssetLockShieldedAddressTopUp { + .. + } => CoreAccountTypeMatch::AssetLockShieldedAddressTopUp { + involved_addresses: involved_other_addresses, + }, + ManagedAccountType::ProviderVotingKeys { + .. + } => CoreAccountTypeMatch::ProviderVotingKeys { + involved_addresses: involved_other_addresses, + }, + ManagedAccountType::ProviderOwnerKeys { + .. + } => CoreAccountTypeMatch::ProviderOwnerKeys { + involved_addresses: involved_other_addresses, + }, + ManagedAccountType::ProviderOperatorKeys { + .. + } => CoreAccountTypeMatch::ProviderOperatorKeys { + involved_addresses: involved_other_addresses, + }, + ManagedAccountType::ProviderPlatformKeys { + .. + } => CoreAccountTypeMatch::ProviderPlatformKeys { + involved_addresses: involved_other_addresses, + }, + // Funds-only variants are not expected on keys accounts. + _ => return None, + }; + + Some(AccountMatch { + account_type_match, + received, + sent, + received_for_credit_conversion: 0, + }) + } else { + None + } + } + + /// Check AssetLock transaction credit_outputs for this keys account. + /// + /// Mirrors [`ManagedCoreFundsAccount::check_asset_lock_transaction_for_match`](crate::managed_account::ManagedCoreFundsAccount::check_asset_lock_transaction_for_match). + pub fn check_asset_lock_transaction_for_match( + &self, + tx: &Transaction, + index: Option, + ) -> Option { + if let Some(TransactionPayload::AssetLockPayloadType(ref payload)) = + tx.special_transaction_payload + { + let mut involved_addresses = Vec::new(); + let mut received = 0u64; + + for credit_output in &payload.credit_outputs { + if self.contains_script_pub_key(&credit_output.script_pubkey) { + if let Ok(address) = + Address::from_script(&credit_output.script_pubkey, self.network) + { + if let Some(address_info) = self.get_address_info(&address) { + involved_addresses.push(address_info.clone()); + } + } + received += credit_output.value; + } + } + + if !involved_addresses.is_empty() { + let account_type_match = match &self.managed_account_type { + ManagedAccountType::IdentityRegistration { + .. + } => CoreAccountTypeMatch::IdentityRegistration { + involved_addresses, + }, + ManagedAccountType::IdentityTopUp { + .. + } => CoreAccountTypeMatch::IdentityTopUp { + account_index: index.unwrap_or(0), + involved_addresses, + }, + ManagedAccountType::IdentityTopUpNotBoundToIdentity { + .. + } => CoreAccountTypeMatch::IdentityTopUpNotBound { + involved_addresses, + }, + ManagedAccountType::IdentityInvitation { + .. + } => CoreAccountTypeMatch::IdentityInvitation { + involved_addresses, + }, + ManagedAccountType::AssetLockAddressTopUp { + .. + } => CoreAccountTypeMatch::AssetLockAddressTopUp { + involved_addresses, + }, + ManagedAccountType::AssetLockShieldedAddressTopUp { + .. + } => CoreAccountTypeMatch::AssetLockShieldedAddressTopUp { + involved_addresses, + }, + _ => return None, + }; + + return Some(AccountMatch { + account_type_match, + received: 0, + sent: 0, + received_for_credit_conversion: received, + }); + } + } + + None + } + + /// Check if transaction contains provider voting key from this account. + pub fn check_provider_voting_key_in_transaction_for_match( + &self, + tx: &Transaction, + ) -> Option { + if let ManagedAccountType::ProviderVotingKeys { + addresses, + } = &self.managed_account_type + { + if let Some(payload) = &tx.special_transaction_payload { + let voting_key_hash = match payload { + TransactionPayload::ProviderRegistrationPayloadType(reg) => { + ®.voting_key_hash + } + TransactionPayload::ProviderUpdateRegistrarPayloadType(update) => { + &update.voting_key_hash + } + _ => return None, + }; + + for (address, &addr_index) in &addresses.address_index { + if let Payload::PubkeyHash(addr_hash) = address.payload() { + if addr_hash == voting_key_hash { + if let Some(address_info) = addresses.addresses.get(&addr_index) { + return Some(AccountMatch { + account_type_match: CoreAccountTypeMatch::ProviderVotingKeys { + involved_addresses: vec![address_info.clone()], + }, + received: 0, + sent: 0, + received_for_credit_conversion: 0, + }); + } + } + } + } + } + } + + None + } + + /// Check if transaction contains provider owner key from this account. + pub fn check_provider_owner_key_in_transaction_for_match( + &self, + tx: &Transaction, + ) -> Option { + if let ManagedAccountType::ProviderOwnerKeys { + addresses, + } = &self.managed_account_type + { + if let Some(payload) = &tx.special_transaction_payload { + let owner_key_hash = match payload { + TransactionPayload::ProviderRegistrationPayloadType(reg) => ®.owner_key_hash, + _ => return None, + }; + + for (address, &addr_index) in &addresses.address_index { + if let Payload::PubkeyHash(addr_hash) = address.payload() { + if addr_hash == owner_key_hash { + if let Some(address_info) = addresses.addresses.get(&addr_index) { + return Some(AccountMatch { + account_type_match: CoreAccountTypeMatch::ProviderOwnerKeys { + involved_addresses: vec![address_info.clone()], + }, + received: 0, + sent: 0, + received_for_credit_conversion: 0, + }); + } + } + } + } + } + } + + None + } + + /// Check if transaction contains provider operator key from this account. + pub fn check_provider_operator_key_in_transaction_for_match( + &self, + tx: &Transaction, + ) -> Option { + if let ManagedAccountType::ProviderOperatorKeys { + addresses, + } = &self.managed_account_type + { + #[cfg(feature = "bls")] + if let Some(payload) = &tx.special_transaction_payload { + let operator_public_key = match payload { + TransactionPayload::ProviderRegistrationPayloadType(reg) => { + ®.operator_public_key + } + TransactionPayload::ProviderUpdateRegistrarPayloadType(reg) => { + ®.operator_public_key + } + _ => return None, + }; + + for address_info in addresses.addresses.values() { + if let Some(PublicKeyType::BLS(bls_key)) = &address_info.public_key { + let operator_key_bytes: &[u8; 48] = operator_public_key.as_ref(); + if bls_key.len() == 48 && bls_key.as_slice() == operator_key_bytes { + return Some(AccountMatch { + account_type_match: CoreAccountTypeMatch::ProviderOperatorKeys { + involved_addresses: vec![address_info.clone()], + }, + received: 0, + sent: 0, + received_for_credit_conversion: 0, + }); + } + } + } + } + // Without the `bls` feature there's no way to compare operator keys. + #[cfg(not(feature = "bls"))] + let _ = (tx, addresses); + } + + None + } + + /// Check if transaction contains provider platform key from this account. + pub fn check_provider_platform_key_in_transaction_for_match( + &self, + tx: &Transaction, + ) -> Option { + if let ManagedAccountType::ProviderPlatformKeys { + addresses, + } = &self.managed_account_type + { + if let Some(payload) = &tx.special_transaction_payload { + let platform_node_id = match payload { + TransactionPayload::ProviderRegistrationPayloadType(reg) => { + if let Some(platform_node_id) = ®.platform_node_id { + platform_node_id + } else { + return None; + } + } + _ => return None, + }; + + for (address, &addr_index) in &addresses.address_index { + if let Payload::PubkeyHash(addr_hash) = address.payload() { + if addr_hash == platform_node_id { + if let Some(address_info) = addresses.addresses.get(&addr_index) { + return Some(AccountMatch { + account_type_match: + CoreAccountTypeMatch::ProviderPlatformKeys { + involved_addresses: vec![address_info.clone()], + }, + received: 0, + sent: 0, + received_for_credit_conversion: 0, + }); + } + } + } + } + } + } + + None + } +} + +#[cfg(feature = "serde")] +impl<'de> Deserialize<'de> for ManagedCoreKeysAccount { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct Helper { + managed_account_type: ManagedAccountType, + network: Network, + metadata: AccountMetadata, + is_watch_only: bool, + #[cfg(feature = "keep_txs_in_memory")] + #[serde(default)] + transactions: BTreeMap, + } + + let helper = Helper::deserialize(deserializer)?; + + #[cfg(feature = "keep_txs_in_memory")] + let processed_txids: HashSet = helper.transactions.keys().copied().collect(); + #[cfg(not(feature = "keep_txs_in_memory"))] + let processed_txids: HashSet = HashSet::new(); + + Ok(ManagedCoreKeysAccount { + managed_account_type: helper.managed_account_type, + network: helper.network, + metadata: helper.metadata, + is_watch_only: helper.is_watch_only, + #[cfg(feature = "keep_txs_in_memory")] + transactions: helper.transactions, + processed_txids, + monitor_revision: 0, + }) + } } diff --git a/key-wallet/src/managed_account/mod.rs b/key-wallet/src/managed_account/mod.rs index 3b254c243..231576894 100644 --- a/key-wallet/src/managed_account/mod.rs +++ b/key-wallet/src/managed_account/mod.rs @@ -14,6 +14,7 @@ pub mod address_pool; pub mod managed_account_collection; +pub mod managed_account_ref; pub mod managed_account_trait; pub mod managed_account_type; pub mod managed_core_funds_account; @@ -23,5 +24,7 @@ pub mod metadata; pub mod platform_address; pub mod transaction_record; +pub use managed_account_ref::{ManagedAccountRef, ManagedAccountRefMut}; +pub use managed_account_type::ManagedAccountType; pub use managed_core_funds_account::ManagedCoreFundsAccount; pub use managed_core_keys_account::ManagedCoreKeysAccount; diff --git a/key-wallet/src/tests/balance_tests.rs b/key-wallet/src/tests/balance_tests.rs index e47de9066..ccb8d8b1f 100644 --- a/key-wallet/src/tests/balance_tests.rs +++ b/key-wallet/src/tests/balance_tests.rs @@ -19,7 +19,7 @@ fn test_balance_with_mixed_utxo_types() { // Immature coinbase (<100 confirmations at height 1100) let utxo3 = Utxo::dummy(3, 20_000_000, 1050, true, true); account.utxos.insert(utxo3.outpoint, utxo3); - wallet_info.accounts.insert(account).unwrap(); + wallet_info.accounts.insert_funds(account).unwrap(); assert_eq!(wallet_info.balance(), WalletCoreBalance::default()); wallet_info.update_last_processed_height(1100); @@ -35,7 +35,7 @@ fn test_coinbase_maturity_boundary() { // Coinbase at height 1000 let utxo = Utxo::dummy(1, 50_000_000, 1000, true, true); account.utxos.insert(utxo.outpoint, utxo); - wallet_info.accounts.insert(account).unwrap(); + wallet_info.accounts.insert_funds(account).unwrap(); assert_eq!(wallet_info.balance(), WalletCoreBalance::default()); // 99 confirmations: immature @@ -57,7 +57,7 @@ fn test_locked_utxos_in_locked_balance() { let mut utxo = Utxo::dummy(1, 100_000, 1000, false, true); utxo.is_locked = true; account.utxos.insert(utxo.outpoint, utxo); - wallet_info.accounts.insert(account).unwrap(); + wallet_info.accounts.insert_funds(account).unwrap(); assert_eq!(wallet_info.balance(), WalletCoreBalance::default()); wallet_info.update_last_processed_height(1100); @@ -72,7 +72,7 @@ fn test_unconfirmed_utxos_in_unconfirmed_balance() { let utxo = Utxo::dummy(1, 100_000, 0, false, false); account.utxos.insert(utxo.outpoint, utxo); - wallet_info.accounts.insert(account).unwrap(); + wallet_info.accounts.insert_funds(account).unwrap(); assert_eq!(wallet_info.balance(), WalletCoreBalance::default()); wallet_info.update_last_processed_height(1100); diff --git a/key-wallet/src/transaction_checking/account_checker.rs b/key-wallet/src/transaction_checking/account_checker.rs index 6ab764aca..3158bb9b6 100644 --- a/key-wallet/src/transaction_checking/account_checker.rs +++ b/key-wallet/src/transaction_checking/account_checker.rs @@ -10,6 +10,7 @@ use crate::account::{ManagedAccountCollection, ManagedCoreFundsAccount}; use crate::managed_account::address_pool::{AddressInfo, PublicKeyType}; use crate::managed_account::managed_account_type::ManagedAccountType; use crate::managed_account::transaction_record::TransactionRecord; +use crate::managed_account::ManagedCoreKeysAccount; use crate::Address; use dashcore::address::Payload; use dashcore::blockdata::transaction::Transaction; @@ -424,7 +425,7 @@ impl ManagedAccountCollection { .into_iter() .collect(), AccountTypeToCheck::IdentityTopUp => { - Self::check_indexed_accounts(&self.identity_topup, tx) + Self::check_indexed_keys_accounts(&self.identity_topup, tx) } AccountTypeToCheck::IdentityTopUpNotBound => self .identity_topup_not_bound @@ -499,7 +500,7 @@ impl ManagedAccountCollection { } } - /// Check indexed accounts (BTreeMap of accounts) + /// Check indexed funds accounts (BTreeMap of [`ManagedCoreFundsAccount`]) fn check_indexed_accounts( accounts: &BTreeMap, tx: &Transaction, @@ -512,6 +513,33 @@ impl ManagedAccountCollection { } matches } + + /// Check indexed keys accounts (BTreeMap of [`ManagedCoreKeysAccount`]). + /// + /// IdentityTopUp accounts hold keys but no funds, so they live in + /// `BTreeMap` rather than the funds-typed + /// map used by Standard, BIP32, CoinJoin, etc. Both AssetLock-payload + /// matches and regular-output matches are checked, so funding + /// transactions and the asset-lock special transaction itself are both + /// recognised. + fn check_indexed_keys_accounts( + accounts: &BTreeMap, + tx: &Transaction, + ) -> Vec { + let mut matches = Vec::new(); + for (index, account) in accounts { + if let Some(match_info) = + account.check_asset_lock_transaction_for_match(tx, Some(*index)) + { + matches.push(match_info); + continue; + } + if let Some(match_info) = account.check_transaction_for_match(tx, Some(*index)) { + matches.push(match_info); + } + } + matches + } } impl ManagedCoreFundsAccount { diff --git a/key-wallet/src/transaction_checking/wallet_checker.rs b/key-wallet/src/transaction_checking/wallet_checker.rs index bd8a168ed..b869773c7 100644 --- a/key-wallet/src/transaction_checking/wallet_checker.rs +++ b/key-wallet/src/transaction_checking/wallet_checker.rs @@ -101,23 +101,21 @@ impl WalletTransactionChecker for ManagedWalletInfo { // before marking UTXOs so the freshly registered UTXOs get the // IS-lock flag too. for account_match in result.affected_accounts.clone() { - let Some(account) = self + let Some(mut account) = self .accounts .get_by_account_type_match_mut(&account_match.account_type_match) else { continue; }; - #[cfg(feature = "keep_txs_in_memory")] - let has_record = account.transactions.contains_key(&txid); - #[cfg(not(feature = "keep_txs_in_memory"))] - let has_record = false; + let has_record = account.contains_transaction_record(&txid); if has_record { account.mark_utxos_instant_send(&txid); #[cfg(feature = "keep_txs_in_memory")] - if let Some(record) = account.transactions.get_mut(&txid) { - record.update_context(context.clone()); - result.updated_records.push(record.clone()); + if let Some(record) = + account.update_transaction_record_context(&txid, context.clone()) + { + result.updated_records.push(record); } } else { let record = account.record_transaction( @@ -144,7 +142,7 @@ impl WalletTransactionChecker for ManagedWalletInfo { // Process each affected account for account_match in result.affected_accounts.clone() { - let Some(account) = + let Some(mut account) = self.accounts.get_by_account_type_match_mut(&account_match.account_type_match) else { continue; @@ -182,7 +180,7 @@ impl WalletTransactionChecker for ManagedWalletInfo { let key_source = KeySource::Public(xpub); let rev_before = result.new_addresses.len(); - for pool in account.managed_account_type.address_pools_mut() { + for pool in account.managed_account_type_mut().address_pools_mut() { match pool.maintain_gap_limit(&key_source) { Ok(addrs) => result.new_addresses.extend(addrs), Err(e) => { diff --git a/key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs b/key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs index f4971479b..4321b8136 100644 --- a/key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs +++ b/key-wallet/src/wallet/managed_wallet_info/asset_lock_builder.rs @@ -11,7 +11,7 @@ use secp256k1::PublicKey; use std::collections::HashMap; use std::fmt; -use crate::managed_account::ManagedCoreFundsAccount; +use crate::managed_account::ManagedCoreKeysAccount; use crate::signer::{Signer, SignerMethod}; use crate::wallet::managed_wallet_info::coin_selection::SelectionStrategy; use crate::wallet::managed_wallet_info::fee::FeeRate; @@ -141,7 +141,7 @@ fn resolve_funding_account( accounts: &mut crate::account::ManagedAccountCollection, funding_type: AssetLockFundingType, identity_index: u32, -) -> Result<&mut ManagedCoreFundsAccount, AssetLockError> { +) -> Result<&mut ManagedCoreKeysAccount, AssetLockError> { match funding_type { AssetLockFundingType::IdentityRegistration => accounts .identity_registration diff --git a/key-wallet/src/wallet/managed_wallet_info/helpers.rs b/key-wallet/src/wallet/managed_wallet_info/helpers.rs index 4def07ec7..0db802ddb 100644 --- a/key-wallet/src/wallet/managed_wallet_info/helpers.rs +++ b/key-wallet/src/wallet/managed_wallet_info/helpers.rs @@ -4,6 +4,7 @@ use super::ManagedWalletInfo; use crate::account::account_collection::PlatformPaymentAccountKey; use crate::account::ManagedCoreFundsAccount; use crate::managed_account::managed_platform_account::ManagedPlatformAccount; +use crate::managed_account::ManagedCoreKeysAccount; impl ManagedWalletInfo { // BIP44 Account Helpers @@ -87,12 +88,12 @@ impl ManagedWalletInfo { // TopUp Account Helpers /// Get the first TopUp managed account - pub fn first_topup_managed_account(&self) -> Option<&ManagedCoreFundsAccount> { + pub fn first_topup_managed_account(&self) -> Option<&ManagedCoreKeysAccount> { self.accounts.identity_topup.values().next() } /// Get the first TopUp managed account (mutable) - pub fn first_topup_managed_account_mut(&mut self) -> Option<&mut ManagedCoreFundsAccount> { + pub fn first_topup_managed_account_mut(&mut self) -> Option<&mut ManagedCoreKeysAccount> { self.accounts.identity_topup.values_mut().next() } @@ -100,7 +101,7 @@ impl ManagedWalletInfo { pub fn topup_managed_account_at_registration_index( &self, registration_index: u32, - ) -> Option<&ManagedCoreFundsAccount> { + ) -> Option<&ManagedCoreKeysAccount> { self.accounts.identity_topup.get(®istration_index) } @@ -108,105 +109,105 @@ impl ManagedWalletInfo { pub fn topup_managed_account_at_registration_index_mut( &mut self, registration_index: u32, - ) -> Option<&mut ManagedCoreFundsAccount> { + ) -> Option<&mut ManagedCoreKeysAccount> { self.accounts.identity_topup.get_mut(®istration_index) } // Identity Registration Account Helper /// Get the identity registration managed account - pub fn identity_registration_managed_account(&self) -> Option<&ManagedCoreFundsAccount> { + pub fn identity_registration_managed_account(&self) -> Option<&ManagedCoreKeysAccount> { self.accounts.identity_registration.as_ref() } /// Get the identity registration managed account (mutable) pub fn identity_registration_managed_account_mut( &mut self, - ) -> Option<&mut ManagedCoreFundsAccount> { + ) -> Option<&mut ManagedCoreKeysAccount> { self.accounts.identity_registration.as_mut() } // Identity TopUp Not Bound Account Helper /// Get the identity top-up not bound managed account - pub fn identity_topup_not_bound_managed_account(&self) -> Option<&ManagedCoreFundsAccount> { + pub fn identity_topup_not_bound_managed_account(&self) -> Option<&ManagedCoreKeysAccount> { self.accounts.identity_topup_not_bound.as_ref() } /// Get the identity top-up not bound managed account (mutable) pub fn identity_topup_not_bound_managed_account_mut( &mut self, - ) -> Option<&mut ManagedCoreFundsAccount> { + ) -> Option<&mut ManagedCoreKeysAccount> { self.accounts.identity_topup_not_bound.as_mut() } // Identity Invitation Account Helper /// Get the identity invitation managed account - pub fn identity_invitation_managed_account(&self) -> Option<&ManagedCoreFundsAccount> { + pub fn identity_invitation_managed_account(&self) -> Option<&ManagedCoreKeysAccount> { self.accounts.identity_invitation.as_ref() } /// Get the identity invitation managed account (mutable) pub fn identity_invitation_managed_account_mut( &mut self, - ) -> Option<&mut ManagedCoreFundsAccount> { + ) -> Option<&mut ManagedCoreKeysAccount> { self.accounts.identity_invitation.as_mut() } // Provider Voting Keys Account Helper /// Get the provider voting keys managed account - pub fn provider_voting_keys_managed_account(&self) -> Option<&ManagedCoreFundsAccount> { + pub fn provider_voting_keys_managed_account(&self) -> Option<&ManagedCoreKeysAccount> { self.accounts.provider_voting_keys.as_ref() } /// Get the provider voting keys managed account (mutable) pub fn provider_voting_keys_managed_account_mut( &mut self, - ) -> Option<&mut ManagedCoreFundsAccount> { + ) -> Option<&mut ManagedCoreKeysAccount> { self.accounts.provider_voting_keys.as_mut() } // Provider Owner Keys Account Helper /// Get the provider owner keys managed account - pub fn provider_owner_keys_managed_account(&self) -> Option<&ManagedCoreFundsAccount> { + pub fn provider_owner_keys_managed_account(&self) -> Option<&ManagedCoreKeysAccount> { self.accounts.provider_owner_keys.as_ref() } /// Get the provider owner keys managed account (mutable) pub fn provider_owner_keys_managed_account_mut( &mut self, - ) -> Option<&mut ManagedCoreFundsAccount> { + ) -> Option<&mut ManagedCoreKeysAccount> { self.accounts.provider_owner_keys.as_mut() } // Provider Operator Keys Account Helper /// Get the provider operator keys managed account - pub fn provider_operator_keys_managed_account(&self) -> Option<&ManagedCoreFundsAccount> { + pub fn provider_operator_keys_managed_account(&self) -> Option<&ManagedCoreKeysAccount> { self.accounts.provider_operator_keys.as_ref() } /// Get the provider operator keys managed account (mutable) pub fn provider_operator_keys_managed_account_mut( &mut self, - ) -> Option<&mut ManagedCoreFundsAccount> { + ) -> Option<&mut ManagedCoreKeysAccount> { self.accounts.provider_operator_keys.as_mut() } // Provider Platform Keys Account Helper /// Get the provider platform keys managed account - pub fn provider_platform_keys_managed_account(&self) -> Option<&ManagedCoreFundsAccount> { + pub fn provider_platform_keys_managed_account(&self) -> Option<&ManagedCoreKeysAccount> { self.accounts.provider_platform_keys.as_ref() } /// Get the provider platform keys managed account (mutable) pub fn provider_platform_keys_managed_account_mut( &mut self, - ) -> Option<&mut ManagedCoreFundsAccount> { + ) -> Option<&mut ManagedCoreKeysAccount> { self.accounts.provider_platform_keys.as_mut() } @@ -308,8 +309,9 @@ impl ManagedWalletInfo { self.accounts.all_accounts().len() } - /// Get all accounts - pub fn all_managed_accounts(&self) -> Vec<&ManagedCoreFundsAccount> { + /// Get all accounts as a mixed collection of funds- and keys-typed + /// references. + pub fn all_managed_accounts(&self) -> Vec> { self.accounts.all_accounts() } } diff --git a/key-wallet/src/wallet/managed_wallet_info/managed_accounts.rs b/key-wallet/src/wallet/managed_wallet_info/managed_accounts.rs index 7169fbcb1..df6ad1ed4 100644 --- a/key-wallet/src/wallet/managed_wallet_info/managed_accounts.rs +++ b/key-wallet/src/wallet/managed_wallet_info/managed_accounts.rs @@ -7,7 +7,7 @@ use super::{managed_account_operations::ManagedAccountOperations, ManagedWalletI use crate::account::BLSAccount; #[cfg(feature = "eddsa")] use crate::account::EdDSAAccount; -use crate::account::{Account, AccountType, ManagedCoreFundsAccount}; +use crate::account::{Account, AccountType, ManagedAccountCollection}; use crate::bip32::ExtendedPubKey; use crate::error::{Error, Result}; use crate::wallet::{Wallet, WalletType}; @@ -42,10 +42,11 @@ impl ManagedAccountOperations for ManagedWalletInfo { })?; // Create the ManagedAccount from the Account - let managed_account = ManagedCoreFundsAccount::from_account(account); + let managed_account = + ManagedAccountCollection::create_managed_account_from_account(account)?; // Check if managed account already exists - if self.accounts.contains_managed_account_type(managed_account.managed_type()) { + if self.accounts.contains_managed_account_type(managed_account.managed_account_type()) { return Err(Error::InvalidParameter(format!( "Managed account type {:?} already exists for network {:?}", account_type, self.network @@ -117,10 +118,11 @@ impl ManagedAccountOperations for ManagedWalletInfo { let account = Account::new(None, account_type, account_xpub, self.network)?; // Create the ManagedAccount from the Account - let managed_account = ManagedCoreFundsAccount::from_account(&account); + let managed_account = + ManagedAccountCollection::create_managed_account_from_account(&account)?; // Check if managed account already exists - if self.accounts.contains_managed_account_type(managed_account.managed_type()) { + if self.accounts.contains_managed_account_type(managed_account.managed_account_type()) { return Err(Error::InvalidParameter(format!( "Managed account type {:?} already exists for network {:?}", account_type, self.network @@ -162,10 +164,11 @@ impl ManagedAccountOperations for ManagedWalletInfo { })?; // Create the ManagedAccount from the BLS Account - let managed_account = ManagedCoreFundsAccount::from_bls_account(bls_account); + let managed_account = + ManagedAccountCollection::create_managed_account_from_bls_account(bls_account)?; // Check if managed account already exists - if self.accounts.contains_managed_account_type(managed_account.managed_type()) { + if self.accounts.contains_managed_account_type(managed_account.managed_account_type()) { return Err(Error::InvalidParameter(format!( "Managed BLS account type {:?} already exists for network {:?}", account_type, self.network @@ -234,10 +237,11 @@ impl ManagedAccountOperations for ManagedWalletInfo { BLSAccount::from_public_key_bytes(None, account_type, bls_public_key, self.network)?; // Create the ManagedAccount from the BLS Account - let managed_account = ManagedCoreFundsAccount::from_bls_account(&bls_account); + let managed_account = + ManagedAccountCollection::create_managed_account_from_bls_account(&bls_account)?; // Check if managed account already exists - if self.accounts.contains_managed_account_type(managed_account.managed_type()) { + if self.accounts.contains_managed_account_type(managed_account.managed_account_type()) { return Err(Error::InvalidParameter(format!( "Managed BLS account type {:?} already exists for network {:?}", account_type, self.network @@ -280,10 +284,13 @@ impl ManagedAccountOperations for ManagedWalletInfo { })?; // Create the ManagedAccount from the EdDSA Account - let managed_account = ManagedCoreFundsAccount::from_eddsa_account(eddsa_account); + let managed_account = ManagedAccountCollection::create_managed_account_from_eddsa_account( + eddsa_account, + None, + )?; // Check if managed account already exists - if self.accounts.contains_managed_account_type(managed_account.managed_type()) { + if self.accounts.contains_managed_account_type(managed_account.managed_account_type()) { return Err(Error::InvalidParameter(format!( "Managed EdDSA account type {:?} already exists for network {:?}", account_type, self.network @@ -356,10 +363,13 @@ impl ManagedAccountOperations for ManagedWalletInfo { )?; // Create the ManagedAccount from the EdDSA Account - let managed_account = ManagedCoreFundsAccount::from_eddsa_account(&eddsa_account); + let managed_account = ManagedAccountCollection::create_managed_account_from_eddsa_account( + &eddsa_account, + None, + )?; // Check if managed account already exists - if self.accounts.contains_managed_account_type(managed_account.managed_type()) { + if self.accounts.contains_managed_account_type(managed_account.managed_account_type()) { return Err(Error::InvalidParameter(format!( "Managed EdDSA account type {:?} already exists for network {:?}", account_type, self.network diff --git a/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs b/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs index 703be78bf..dd8a269c5 100644 --- a/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs +++ b/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs @@ -78,11 +78,17 @@ pub trait WalletInfoInterface: Sized + WalletTransactionChecker + ManagedAccount fn update_balance(&mut self); /// Per-account balances keyed by `AccountType`. + /// + /// Only funds-bearing accounts (Standard, CoinJoin, DashPay) carry a + /// balance — keys-only accounts are skipped. fn account_balances(&self) -> BTreeMap { self.accounts() .all_accounts() .iter() - .map(|acc| (acc.managed_account_type().to_account_type(), *acc.balance())) + .filter_map(|acc| { + acc.as_funds() + .map(|funds| (funds.managed_account_type.to_account_type(), *funds.balance())) + }) .collect() } @@ -204,7 +210,9 @@ impl WalletInfoInterface for ManagedWalletInfo { fn utxos(&self) -> BTreeSet<&Utxo> { let mut utxos = BTreeSet::new(); for account in self.accounts.all_accounts() { - utxos.extend(account.utxos.values()); + if let Some(funds) = account.as_funds() { + utxos.extend(funds.utxos.values()); + } } utxos } @@ -223,8 +231,10 @@ impl WalletInfoInterface for ManagedWalletInfo { let mut balance = WalletCoreBalance::default(); let last_processed_height = self.last_processed_height(); for account in self.accounts.all_accounts_mut() { - account.update_balance(last_processed_height); - balance += *account.balance(); + if let Some(funds) = account.into_funds() { + funds.update_balance(last_processed_height); + balance += *funds.balance(); + } } self.balance = balance; } @@ -234,7 +244,14 @@ impl WalletInfoInterface for ManagedWalletInfo { { let mut transactions = Vec::new(); for account in self.accounts.all_accounts() { - transactions.extend(account.transactions.values()); + match account { + crate::managed_account::ManagedAccountRef::Funds(funds) => { + transactions.extend(funds.transactions.values()); + } + crate::managed_account::ManagedAccountRef::Keys(keys) => { + transactions.extend(keys.transactions.values()); + } + } } transactions } @@ -255,19 +272,29 @@ impl WalletInfoInterface for ManagedWalletInfo { { let mut immature_txids: BTreeSet = BTreeSet::new(); - // Find txids of immature coinbase UTXOs + // Find txids of immature coinbase UTXOs (only funds accounts hold UTXOs). for account in self.accounts.all_accounts() { - for utxo in account.utxos.values() { - if utxo.is_coinbase && !utxo.is_mature(self.last_processed_height()) { - immature_txids.insert(utxo.outpoint.txid); + if let Some(funds) = account.as_funds() { + for utxo in funds.utxos.values() { + if utxo.is_coinbase && !utxo.is_mature(self.last_processed_height()) { + immature_txids.insert(utxo.outpoint.txid); + } } } } - // Get the actual transactions + // Get the actual transactions across both account variants. let mut transactions = Vec::new(); for account in self.accounts.all_accounts() { - for (txid, record) in &account.transactions { + let recs: Box> = match account { + crate::managed_account::ManagedAccountRef::Funds(funds) => { + Box::new(funds.transactions.iter()) + } + crate::managed_account::ManagedAccountRef::Keys(keys) => { + Box::new(keys.transactions.iter()) + } + }; + for (txid, record) in recs { if immature_txids.contains(txid) { transactions.push(record.transaction.clone()); } @@ -300,8 +327,19 @@ impl WalletInfoInterface for ManagedWalletInfo { #[cfg(feature = "keep_txs_in_memory")] { let mut matured = Vec::new(); + // Coinbase-bearing transactions only land in funds accounts — + // keys accounts don't track UTXOs and so never see coinbase + // outputs, but iterate both for safety. for account in self.accounts.all_accounts() { - for record in account.transactions.values() { + let recs: Box> = match account { + crate::managed_account::ManagedAccountRef::Funds(funds) => { + Box::new(funds.transactions.values()) + } + crate::managed_account::ManagedAccountRef::Keys(keys) => { + Box::new(keys.transactions.values()) + } + }; + for record in recs { if !record.transaction.is_coin_base() { continue; } @@ -326,12 +364,27 @@ impl WalletInfoInterface for ManagedWalletInfo { } let mut any_changed = false; for account in self.accounts.all_accounts_mut() { - if account.mark_utxos_instant_send(txid) { - any_changed = true; - } - #[cfg(feature = "keep_txs_in_memory")] - if let Some(record) = account.transactions_mut().get_mut(txid) { - record.update_context(TransactionContext::InstantSend(lock.clone())); + match account { + crate::managed_account::ManagedAccountRefMut::Funds(funds) => { + if funds.mark_utxos_instant_send(txid) { + any_changed = true; + } + #[cfg(feature = "keep_txs_in_memory")] + if let Some(record) = funds.transactions.get_mut(txid) { + record.update_context(TransactionContext::InstantSend(lock.clone())); + } + } + crate::managed_account::ManagedAccountRefMut::Keys(keys) => { + // Keys accounts have no UTXOs, but they may still track + // a record of the transaction whose context we want to + // update. + #[cfg(feature = "keep_txs_in_memory")] + if let Some(record) = keys.transactions.get_mut(txid) { + record.update_context(TransactionContext::InstantSend(lock.clone())); + } + #[cfg(not(feature = "keep_txs_in_memory"))] + let _ = keys; + } } } #[cfg(not(feature = "keep_txs_in_memory"))]