From 0453adac6bef8a7cd88a27a3c67f2a71f5be8fad Mon Sep 17 00:00:00 2001 From: xdustinface Date: Mon, 30 Mar 2026 14:10:46 +1100 Subject: [PATCH 1/3] refactor: change `immature_transactions()` to return `BTreeSet`, rebuild `spent_outpoints` from `input_details` --- dash-spv/tests/dashd_sync/setup.rs | 8 +++---- key-wallet/src/managed_account/mod.rs | 8 +++++-- .../transaction_checking/wallet_checker.rs | 2 +- .../wallet_info_interface.rs | 24 +++++-------------- key-wallet/tests/spv_integration_tests.rs | 6 ++--- 5 files changed, 20 insertions(+), 28 deletions(-) diff --git a/dash-spv/tests/dashd_sync/setup.rs b/dash-spv/tests/dashd_sync/setup.rs index 002663a5b..57d8a4587 100644 --- a/dash-spv/tests/dashd_sync/setup.rs +++ b/dash-spv/tests/dashd_sync/setup.rs @@ -168,7 +168,7 @@ impl TestContext { .all_accounts() .iter() .any(|account| account.transactions.contains_key(txid)) - || wallet_info.immature_transactions().iter().any(|tx| &tx.txid() == txid) + || wallet_info.immature_transactions().contains(txid) } /// Validate that the context wallet matches the expected baseline from dashd. @@ -200,8 +200,8 @@ impl TestContext { spv_txids.insert(txid.to_string()); } } - for tx in wallet_info.immature_transactions() { - spv_txids.insert(tx.txid().to_string()); + for txid in wallet_info.immature_transactions() { + spv_txids.insert(txid.to_string()); } let expected_txids: HashSet = self @@ -309,7 +309,7 @@ pub(super) async fn client_has_transaction( .all_accounts() .iter() .any(|account| account.transactions.contains_key(txid)) - || wallet_info.immature_transactions().iter().any(|tx| &tx.txid() == txid) + || wallet_info.immature_transactions().contains(txid) } /// Creates a new SPV client and starts it with a `TestEventHandler`. diff --git a/key-wallet/src/managed_account/mod.rs b/key-wallet/src/managed_account/mod.rs index b6a55316a..b3c880279 100644 --- a/key-wallet/src/managed_account/mod.rs +++ b/key-wallet/src/managed_account/mod.rs @@ -1203,8 +1203,12 @@ impl<'de> Deserialize<'de> for ManagedCoreAccount { let spent_outpoints = helper .transactions .values() - .flat_map(|record| &record.transaction.input) - .map(|input| input.previous_output) + .flat_map(|record| { + record + .input_details + .iter() + .map(|d| record.transaction.input[d.index as usize].previous_output) + }) .collect(); Ok(ManagedCoreAccount { diff --git a/key-wallet/src/transaction_checking/wallet_checker.rs b/key-wallet/src/transaction_checking/wallet_checker.rs index de4c51851..38f91d792 100644 --- a/key-wallet/src/transaction_checking/wallet_checker.rs +++ b/key-wallet/src/transaction_checking/wallet_checker.rs @@ -414,7 +414,7 @@ mod tests { // Coinbase should be in immature_transactions() since it hasn't matured let immature_txs = managed_wallet.immature_transactions(); assert_eq!(immature_txs.len(), 1, "Should have one immature transaction"); - assert_eq!(immature_txs[0].txid(), coinbase_tx.txid()); + assert!(immature_txs.contains(&coinbase_tx.txid())); // Immature balance should reflect the coinbase value assert_eq!(managed_wallet.balance().immature(), 5_000_000_000); 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 b4cf407d3..13a652552 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 @@ -12,7 +12,7 @@ use crate::{Network, Utxo, Wallet, WalletCoreBalance}; use alloc::collections::BTreeSet; use alloc::vec::Vec; use dashcore::prelude::CoreBlockHeight; -use dashcore::{Address as DashAddress, Transaction, Txid}; +use dashcore::{Address as DashAddress, Txid}; /// Trait that wallet info types must implement to work with WalletManager pub trait WalletInfoInterface: Sized + WalletTransactionChecker + ManagedAccountOperations { @@ -81,8 +81,8 @@ pub trait WalletInfoInterface: Sized + WalletTransactionChecker + ManagedAccount /// Get accounts (immutable) fn accounts(&self) -> &ManagedAccountCollection; - /// Get immature transactions - fn immature_transactions(&self) -> Vec; + /// Get immature transaction IDs + fn immature_transactions(&self) -> BTreeSet; /// Return the last fully processed height of the wallet. fn synced_height(&self) -> CoreBlockHeight; @@ -209,10 +209,8 @@ impl WalletInfoInterface for ManagedWalletInfo { &self.accounts } - fn immature_transactions(&self) -> Vec { - let mut immature_txids: BTreeSet = BTreeSet::new(); - - // Find txids of immature coinbase UTXOs + fn immature_transactions(&self) -> BTreeSet { + let mut immature_txids = BTreeSet::new(); for account in self.accounts.all_accounts() { for utxo in account.utxos.values() { if utxo.is_coinbase && !utxo.is_mature(self.synced_height()) { @@ -220,17 +218,7 @@ impl WalletInfoInterface for ManagedWalletInfo { } } } - - // 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 + immature_txids } fn update_synced_height(&mut self, current_height: u32) { diff --git a/key-wallet/tests/spv_integration_tests.rs b/key-wallet/tests/spv_integration_tests.rs index d3fd99655..d4bb58e69 100644 --- a/key-wallet/tests/spv_integration_tests.rs +++ b/key-wallet/tests/spv_integration_tests.rs @@ -138,7 +138,7 @@ async fn test_immature_balance_matures_during_block_processing() { // Verify the coinbase is detected and stored as immature let wallet_info = manager.get_wallet_info(&wallet_id).expect("Wallet info should exist"); assert!( - wallet_info.immature_transactions().contains(&coinbase_tx), + wallet_info.immature_transactions().contains(&coinbase_tx.txid()), "Coinbase should be in immature transactions" ); assert_eq!( @@ -158,7 +158,7 @@ async fn test_immature_balance_matures_during_block_processing() { // Verify still immature just before maturity let wallet_info = manager.get_wallet_info(&wallet_id).expect("Wallet info should exist"); assert!( - wallet_info.immature_transactions().contains(&coinbase_tx), + wallet_info.immature_transactions().contains(&coinbase_tx.txid()), "Coinbase should still be immature at height {}", maturity_height - 1 ); @@ -170,7 +170,7 @@ async fn test_immature_balance_matures_during_block_processing() { // Verify the coinbase has matured let wallet_info = manager.get_wallet_info(&wallet_id).expect("Wallet info should exist"); assert!( - !wallet_info.immature_transactions().contains(&coinbase_tx), + !wallet_info.immature_transactions().contains(&coinbase_tx.txid()), "Coinbase should no longer be immature after maturity height" ); assert_eq!( From 21c58296a3ccd1c9ad0c730b8b7f11ed18bb3e2e Mon Sep 17 00:00:00 2001 From: xdustinface Date: Tue, 31 Mar 2026 12:27:12 +1100 Subject: [PATCH 2/3] fix: rebuild `spent_outpoints` from all inputs instead of `input_details` The deserialization used `input_details` (our inputs only) with unchecked indexing into `transaction.input`, which could panic on corrupted data and produced an incomplete `spent_outpoints` set. Runtime behavior tracks all inputs of every recorded transaction, so deserialization must match. --- key-wallet/src/managed_account/mod.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/key-wallet/src/managed_account/mod.rs b/key-wallet/src/managed_account/mod.rs index b3c880279..6c471d2af 100644 --- a/key-wallet/src/managed_account/mod.rs +++ b/key-wallet/src/managed_account/mod.rs @@ -1200,15 +1200,13 @@ impl<'de> Deserialize<'de> for ManagedCoreAccount { let helper = Helper::deserialize(deserializer)?; + // Rebuild from all transaction inputs (not just `input_details`) to match + // runtime behavior, which tracks every input of every recorded transaction. let spent_outpoints = helper .transactions .values() - .flat_map(|record| { - record - .input_details - .iter() - .map(|d| record.transaction.input[d.index as usize].previous_output) - }) + .flat_map(|record| &record.transaction.input) + .map(|input| input.previous_output) .collect(); Ok(ManagedCoreAccount { From 0906c2116c77e40dff0e9ced89e3cb0e318c2f05 Mon Sep 17 00:00:00 2001 From: xdustinface Date: Tue, 31 Mar 2026 15:57:42 +1100 Subject: [PATCH 3/3] fix: rename `immature_transactions()` to `immature_txids()` --- dash-spv/tests/dashd_sync/setup.rs | 6 +++--- .../src/transaction_checking/wallet_checker.rs | 12 ++++++------ .../managed_wallet_info/wallet_info_interface.rs | 6 +++--- key-wallet/tests/spv_integration_tests.rs | 6 +++--- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/dash-spv/tests/dashd_sync/setup.rs b/dash-spv/tests/dashd_sync/setup.rs index 57d8a4587..1f30232d0 100644 --- a/dash-spv/tests/dashd_sync/setup.rs +++ b/dash-spv/tests/dashd_sync/setup.rs @@ -168,7 +168,7 @@ impl TestContext { .all_accounts() .iter() .any(|account| account.transactions.contains_key(txid)) - || wallet_info.immature_transactions().contains(txid) + || wallet_info.immature_txids().contains(txid) } /// Validate that the context wallet matches the expected baseline from dashd. @@ -200,7 +200,7 @@ impl TestContext { spv_txids.insert(txid.to_string()); } } - for txid in wallet_info.immature_transactions() { + for txid in wallet_info.immature_txids() { spv_txids.insert(txid.to_string()); } @@ -309,7 +309,7 @@ pub(super) async fn client_has_transaction( .all_accounts() .iter() .any(|account| account.transactions.contains_key(txid)) - || wallet_info.immature_transactions().contains(txid) + || wallet_info.immature_txids().contains(txid) } /// Creates a new SPV client and starts it with a `TestEventHandler`. diff --git a/key-wallet/src/transaction_checking/wallet_checker.rs b/key-wallet/src/transaction_checking/wallet_checker.rs index 38f91d792..9f50b34ea 100644 --- a/key-wallet/src/transaction_checking/wallet_checker.rs +++ b/key-wallet/src/transaction_checking/wallet_checker.rs @@ -411,8 +411,8 @@ mod tests { let utxo = managed_account.utxos.values().next().expect("Should have UTXO"); assert!(utxo.is_coinbase, "UTXO should be marked as coinbase"); - // Coinbase should be in immature_transactions() since it hasn't matured - let immature_txs = managed_wallet.immature_transactions(); + // Coinbase should be in immature_txids() since it hasn't matured + let immature_txs = managed_wallet.immature_txids(); assert_eq!(immature_txs.len(), 1, "Should have one immature transaction"); assert!(immature_txs.contains(&coinbase_tx.txid())); @@ -566,8 +566,8 @@ mod tests { assert!(utxo.is_coinbase, "UTXO should be marked as coinbase"); assert_eq!(utxo.height, block_height); - // Coinbase is in immature_transactions() since it hasn't matured - let immature_txs = managed_wallet.immature_transactions(); + // Coinbase is in immature_txids() since it hasn't matured + let immature_txs = managed_wallet.immature_txids(); assert_eq!(immature_txs.len(), 1, "Should have one immature transaction"); // Immature balance should reflect the coinbase value @@ -596,8 +596,8 @@ mod tests { "Coinbase should still be in regular transactions" ); - // Coinbase is no longer in immature_transactions() - let immature_txs = managed_wallet.immature_transactions(); + // Coinbase is no longer in immature_txids() + let immature_txs = managed_wallet.immature_txids(); assert!(immature_txs.is_empty(), "Matured coinbase should not be in immature transactions"); // Immature balance should now be zero 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 13a652552..40d111b92 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 @@ -81,8 +81,8 @@ pub trait WalletInfoInterface: Sized + WalletTransactionChecker + ManagedAccount /// Get accounts (immutable) fn accounts(&self) -> &ManagedAccountCollection; - /// Get immature transaction IDs - fn immature_transactions(&self) -> BTreeSet; + /// Get immature coinbase transaction IDs + fn immature_txids(&self) -> BTreeSet; /// Return the last fully processed height of the wallet. fn synced_height(&self) -> CoreBlockHeight; @@ -209,7 +209,7 @@ impl WalletInfoInterface for ManagedWalletInfo { &self.accounts } - fn immature_transactions(&self) -> BTreeSet { + fn immature_txids(&self) -> BTreeSet { let mut immature_txids = BTreeSet::new(); for account in self.accounts.all_accounts() { for utxo in account.utxos.values() { diff --git a/key-wallet/tests/spv_integration_tests.rs b/key-wallet/tests/spv_integration_tests.rs index d4bb58e69..d87bcd689 100644 --- a/key-wallet/tests/spv_integration_tests.rs +++ b/key-wallet/tests/spv_integration_tests.rs @@ -138,7 +138,7 @@ async fn test_immature_balance_matures_during_block_processing() { // Verify the coinbase is detected and stored as immature let wallet_info = manager.get_wallet_info(&wallet_id).expect("Wallet info should exist"); assert!( - wallet_info.immature_transactions().contains(&coinbase_tx.txid()), + wallet_info.immature_txids().contains(&coinbase_tx.txid()), "Coinbase should be in immature transactions" ); assert_eq!( @@ -158,7 +158,7 @@ async fn test_immature_balance_matures_during_block_processing() { // Verify still immature just before maturity let wallet_info = manager.get_wallet_info(&wallet_id).expect("Wallet info should exist"); assert!( - wallet_info.immature_transactions().contains(&coinbase_tx.txid()), + wallet_info.immature_txids().contains(&coinbase_tx.txid()), "Coinbase should still be immature at height {}", maturity_height - 1 ); @@ -170,7 +170,7 @@ async fn test_immature_balance_matures_during_block_processing() { // Verify the coinbase has matured let wallet_info = manager.get_wallet_info(&wallet_id).expect("Wallet info should exist"); assert!( - !wallet_info.immature_transactions().contains(&coinbase_tx.txid()), + !wallet_info.immature_txids().contains(&coinbase_tx.txid()), "Coinbase should no longer be immature after maturity height" ); assert_eq!(