From 3477f90921dc1517959274bfd2437326ca718758 Mon Sep 17 00:00:00 2001 From: moisesPomilio <93723302+moisesPompilio@users.noreply.github.com> Date: Wed, 8 Apr 2026 23:35:21 -0300 Subject: [PATCH 1/5] feat(wallet)!: introduce wallet provider abstraction for descriptor management The provider layer establishes a new abstraction for managing all descriptor-related operations in the watch-only wallet. It centralizes responsibility for tracking wallet state across descriptors, including address derivation, transaction indexing, and balance calculations. The provider serves as the single source of truth for descriptor-scoped information, isolating this logic from higher-level wallet coordination. Key responsibilities: - Manage descriptor persistence and retrieval - Track generated and observed addresses per descriptor - Index transactions associated with each descriptor - Maintain UTXO sets and output tracking - Calculate balances on a per-descriptor basis - Process blockchain events (blocks, mempool) and emit descriptor-specific events - Added `WalletProvider` trait defining the provider interface - Introduced `WalletProviderEvent` enum for event-driven transaction notifications - Defined `WalletProviderError` for comprehensive error handling - Implemented feature-gated BDK provider backend via `bdk-provider` feature test(provider): add comprehensive provider unit tests Established test coverage for the provider interface, validating core descriptor and transaction management operations. Test scenarios: - Descriptor lifecycle (persist, retrieve, list, deduplicate) - Transaction indexing and querying by descriptor - Balance calculations with confirmation requirements - Address generation and management - UTXO tracking and spend status filtering - Mempool and block event processing - Edge cases (empty wallets, nonexistent descriptors, duplicate operations) - Script buffer management and local output tracking --- Cargo.lock | 110 +- Cargo.toml | 2 +- Dockerfile | 2 +- crates/floresta-watch-only/Cargo.toml | 4 +- crates/floresta-watch-only/src/lib.rs | 81 ++ .../src/provider/bdk_provider.rs | 1284 +++++++++++++++++ .../floresta-watch-only/src/provider/mod.rs | 254 ++++ .../floresta-watch-only/tests/common/mod.rs | 199 +++ crates/floresta-watch-only/tests/provider.rs | 773 ++++++++++ 9 files changed, 2702 insertions(+), 7 deletions(-) create mode 100644 crates/floresta-watch-only/src/provider/bdk_provider.rs create mode 100644 crates/floresta-watch-only/src/provider/mod.rs create mode 100644 crates/floresta-watch-only/tests/common/mod.rs create mode 100644 crates/floresta-watch-only/tests/provider.rs diff --git a/Cargo.lock b/Cargo.lock index 9b4f1ff1b..27543b08a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -234,6 +234,43 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bdk_chain" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b5d691fd092aacec7e05046b7d04897d58d6d65ed3152cb6cf65dababcfabed" +dependencies = [ + "bdk_core", + "bitcoin", + "miniscript", + "rusqlite", + "serde", +] + +[[package]] +name = "bdk_core" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dbbe4aad0c898bfeb5253c222be3ea3dccfb380a07e72c87e3e4ed6664a6753" +dependencies = [ + "bitcoin", + "hashbrown 0.14.5", + "serde", +] + +[[package]] +name = "bdk_wallet" +version = "3.0.0-alpha.0" +source = "git+https://github.com/thunderbiscuit/bdk_wallet?branch=feature%2Fmulti-keychain-wallet#c43e6d831d6dc474af305751bebbe9c55f4c7093" +dependencies = [ + "bdk_chain", + "bitcoin", + "miniscript", + "rand_core", + "serde", + "serde_json", +] + [[package]] name = "bech32" version = "0.11.1" @@ -1002,6 +1039,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -1081,7 +1130,7 @@ name = "floresta-common" version = "0.4.0" dependencies = [ "bitcoin", - "hashbrown", + "hashbrown 0.16.1", "miniscript", "sha2", "spin", @@ -1192,6 +1241,7 @@ dependencies = [ name = "floresta-watch-only" version = "0.4.0" dependencies = [ + "bdk_wallet", "bitcoin", "floresta-chain", "floresta-common", @@ -1397,6 +1447,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "serde", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1408,6 +1468,15 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "hdrhistogram" version = "7.5.4" @@ -1703,7 +1772,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", ] [[package]] @@ -1836,6 +1905,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1875,7 +1955,7 @@ version = "0.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" dependencies = [ - "hashbrown", + "hashbrown 0.16.1", ] [[package]] @@ -1938,6 +2018,7 @@ checksum = "487906208f38448e186e3deb02f2b8ef046a9078b0de00bdb28bf4fb9b76951c" dependencies = [ "bech32", "bitcoin", + "serde", ] [[package]] @@ -2452,6 +2533,20 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags 2.11.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -2533,7 +2628,7 @@ checksum = "8353cd48bea30340eced2a11770e47bb6b83f0e7e679742301f3332e6ec1f6ab" dependencies = [ "bitcoin-io 0.3.0", "bitcoin_hashes 0.20.0", - "hashbrown", + "hashbrown 0.16.1", "hex-conservative 1.0.1", ] @@ -2565,6 +2660,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ "bitcoin_hashes 0.14.1", + "rand", "secp256k1-sys", "serde", ] @@ -3340,6 +3436,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index cd20c744a..b1e08cdba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,7 @@ default-members = [ ] [workspace.package] -rust-version = "1.81.0" # MSRV declaration +rust-version = "1.85.0" # MSRV declaration readme = "README.md" # Version Convention: Use major.minor only (e.g., "1.9" not "1.9.1") diff --git a/Dockerfile b/Dockerfile index 716d8322a..4100da928 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,7 @@ RUN apt-get update && apt-get install -y \ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y ENV PATH="/root/.cargo/bin:${PATH}" -RUN rustup default 1.81.0 +RUN rustup default 1.85.0 WORKDIR /opt/app diff --git a/crates/floresta-watch-only/Cargo.toml b/crates/floresta-watch-only/Cargo.toml index b25bf4511..f24bde188 100644 --- a/crates/floresta-watch-only/Cargo.toml +++ b/crates/floresta-watch-only/Cargo.toml @@ -16,6 +16,7 @@ kv = { workspace = true } serde = { workspace = true } serde_json = { workspace = true, features = ["alloc"] } tracing = { workspace = true } +bdk_wallet = { optional = true, git = "https://github.com/thunderbiscuit/bdk_wallet", branch = "feature/multi-keychain-wallet", features = ["rusqlite"] } # Local dependencies floresta-chain = { workspace = true } @@ -25,8 +26,9 @@ floresta-common = { workspace = true, features = ["descriptors-no-std"] } rand = { workspace = true } [features] -default = ["std"] +default = ["std", "bdk-provider"] memory-database = [] +bdk-provider = ["bdk_wallet"] # The default features in common are `std` and `descriptors-std` (which is a superset of `descriptors-no-std`) std = ["floresta-common/default", "serde/std"] diff --git a/crates/floresta-watch-only/src/lib.rs b/crates/floresta-watch-only/src/lib.rs index a0ab5843f..ae806727c 100644 --- a/crates/floresta-watch-only/src/lib.rs +++ b/crates/floresta-watch-only/src/lib.rs @@ -26,6 +26,7 @@ pub mod kv_database; #[cfg(any(test, feature = "memory-database"))] pub mod memory_database; pub mod merkle; +pub mod provider; use bitcoin::consensus::deserialize; use bitcoin::consensus::encode::serialize_hex; @@ -1010,3 +1011,83 @@ mod test { assert_eq!(address.utxos.len(), 1); } } + +#[cfg(all(test, feature = "bdk-provider"))] +pub mod utils { + + use bitcoin::hashes::sha256d; + use bitcoin::hashes::Hash as HashTrait; + use bitcoin::Amount; + use bitcoin::OutPoint; + use bitcoin::Transaction; + use bitcoin::TxOut; + use bitcoin::Txid; + + #[cfg(feature = "bdk-provider")] + pub(crate) fn create_transaction_with_txo(txo: TxOut) -> Transaction { + let mut tx = create_test_transaction(); + tx.output.push(txo); + + tx + } + + pub(crate) fn create_test_transaction() -> Transaction { + create_test_transaction_with_seed(42) // default seed + } + + pub(crate) fn create_test_transaction_with_seed(seed: u64) -> Transaction { + // Generate deterministic inputs based on seed + let mut inputs = vec![]; + let num_inputs = (seed % 4) as usize; + + for i in 0..num_inputs { + // Create a deterministic "fake" txid + let mut hash_bytes = [0u8; 32]; + let input_seed = seed.wrapping_mul(31).wrapping_add(i as u64); + + for (j, item) in hash_bytes.iter_mut().enumerate().take(8) { + *item = (input_seed >> (j * 8)) as u8; + } + + for (j, item) in hash_bytes.iter_mut().enumerate().skip(8).take(8) { + *item = ((seed >> ((j - 8) * 8)) as u8).wrapping_add(i as u8); + } + + let hash = sha256d::Hash::from_slice(&hash_bytes).unwrap(); + let txid = Txid::from_raw_hash(hash); + + let outpoint = OutPoint { + txid, + vout: (seed.wrapping_add(i as u64) % 2) as u32, + }; + + inputs.push(bitcoin::TxIn { + previous_output: outpoint, + script_sig: bitcoin::ScriptBuf::new(), + sequence: bitcoin::Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: bitcoin::Witness::new(), + }); + } + + // Generate deterministic outputs (MINIMUM 1) + let mut outputs = vec![]; + let num_outputs = ((seed >> 8) % 3) + 1; // Guarantees at least 1 + + for i in 0..num_outputs { + let amount_sat = (seed.wrapping_mul(1000).wrapping_add(i)) % 100000 + 1; + let amount = Amount::from_sat(amount_sat); + + outputs.push(TxOut { + value: amount, + script_pubkey: bitcoin::ScriptBuf::new(), + }); + } + + Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::locktime::absolute::LockTime::ZERO, + input: inputs, + output: outputs, + } + } +} diff --git a/crates/floresta-watch-only/src/provider/bdk_provider.rs b/crates/floresta-watch-only/src/provider/bdk_provider.rs new file mode 100644 index 000000000..4140390fe --- /dev/null +++ b/crates/floresta-watch-only/src/provider/bdk_provider.rs @@ -0,0 +1,1284 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +#![deny(clippy::unwrap_used)] + +use core::fmt; +use core::fmt::Debug; +use core::fmt::Display; +use core::fmt::Formatter; +use std::collections::BTreeMap; +use std::collections::HashSet; +use std::result; +use std::sync::Mutex; +use std::sync::RwLock; +use std::sync::RwLockReadGuard; +use std::sync::RwLockWriteGuard; + +use bdk_wallet::chain::local_chain::CannotConnectError; +use bdk_wallet::keyring::KeyRing; +use bdk_wallet::keyring::KeyRingError; +use bdk_wallet::rusqlite::types::FromSql; +use bdk_wallet::rusqlite::types::FromSqlError; +use bdk_wallet::rusqlite::types::ToSql; +use bdk_wallet::rusqlite::types::ToSqlOutput; +use bdk_wallet::rusqlite::types::ValueRef; +use bdk_wallet::rusqlite::Connection; +use bdk_wallet::rusqlite::Error as RusqliteError; +use bdk_wallet::CreateWithPersistError; +use bdk_wallet::LoadWithPersistError; +use bdk_wallet::PersistedWallet; +use bdk_wallet::Wallet; +use bdk_wallet::WalletEvent; +use bdk_wallet::WalletPersister; +use bitcoin::Address; +use bitcoin::Amount; +use bitcoin::Block; +use bitcoin::Network; +use bitcoin::OutPoint; +use bitcoin::ScriptBuf; +use bitcoin::Transaction; +use bitcoin::TxOut; +use bitcoin::Txid; +use floresta_common::prelude::sync::Arc; + +use super::Balance; +use super::LastProcessedBlock; +use super::LocalOutput; +use super::WalletProviderError; +use super::WalletProviderEvent; + +#[derive(Ord, PartialOrd, Eq, PartialEq, Clone, Debug)] +pub struct KeyId(String); + +impl From for KeyId { + fn from(s: String) -> Self { + KeyId(s) + } +} + +impl Display for KeyId { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl ToSql for KeyId { + fn to_sql(&self) -> Result, RusqliteError> { + Ok(ToSqlOutput::from(self.0.clone())) + } +} + +impl FromSql for KeyId { + fn column_result(value: ValueRef) -> Result { + String::column_result(value).map(KeyId) + } +} + +pub struct BdkWalletProvider +where + K: Ord + Clone + Debug + From + Send, + P: WalletPersister + Send, +{ + wallet: Option>>, + persister: Arc>, + network: Network, +} + +impl BdkWalletProvider +where + K: Ord + Clone + Debug + ToSql + FromSql + From + Display + 'static + Send + Sync, +{ + pub(crate) fn new( + db_path: &str, + network: Network, + is_initialized: bool, + ) -> Result { + let persister = Connection::open(db_path).map_err(|e| { + WalletProviderError::PersistenceError(format!("Failed to open db: {}", e)) + })?; + + Self::setup(persister, network, is_initialized) + } + + #[cfg(test)] + pub(crate) fn new_in_memory(network: Network) -> Result { + let persister = Connection::open_in_memory().map_err(|e| { + WalletProviderError::PersistenceError(format!("Failed to create in-memory db: {}", e)) + })?; + + Self::setup(persister, network, false) + } + + fn setup( + persister: Connection, + network: Network, + is_initialized: bool, + ) -> Result { + if is_initialized { + return Self::load_wallet_from_sqlite(persister); + } + + Ok(Self { + wallet: None, + persister: Arc::new(Mutex::new(persister)), + network, + }) + } + + fn load_wallet_from_sqlite(mut persister: Connection) -> Result { + let wallet = Wallet::load().load_wallet(&mut persister)?.ok_or_else(|| { + WalletProviderError::WalletLoadError("Option wallet is None".to_string()) + })?; + + Ok(Self { + wallet: Some(RwLock::new(wallet)), + persister: Arc::new(Mutex::new(persister)), + network: Network::Bitcoin, + }) + } +} + +impl BdkWalletProvider +where + K: Ord + Clone + Debug + From + Send + Sync, + P: WalletPersister + Send, +{ + fn initialize_wallet(&mut self, id: &str, descriptor: &str) -> Result<(), WalletProviderError> { + let wallet = { + let mut persister = self.get_persister()?; + + let keyring = KeyRing::new_with_descriptors( + self.network, + BTreeMap::from([(K::from(id.to_string()), descriptor.to_string())]), + )?; + + Wallet::create(keyring).create_wallet(&mut *persister)? + }; + + self.wallet = Some(RwLock::new(wallet)); + Ok(()) + } + + fn get_wallet( + &self, + ) -> Result>, WalletProviderError> { + if let Some(wallet) = &self.wallet { + wallet + .read() + .map_err(|e| WalletProviderError::LockPoisoned(e.to_string())) + } else { + Err(WalletProviderError::WalletNotInitialized) + } + } + + fn get_wallet_mut( + &self, + ) -> Result>, WalletProviderError> { + if let Some(wallet) = &self.wallet { + wallet + .write() + .map_err(|e| WalletProviderError::LockPoisoned(e.to_string())) + } else { + Err(WalletProviderError::WalletNotInitialized) + } + } + + fn get_persister(&self) -> result::Result, WalletProviderError> { + self.persister + .lock() + .map_err(|e| WalletProviderError::LockPoisoned(e.to_string())) + } + + fn event_process( + &self, + events: Vec, + ) -> Result, WalletProviderError> { + let mut result_events = Vec::new(); + + for event in events { + match event { + WalletEvent::ChainTipChanged { + old_tip: _, + new_tip: _, + } => {} + WalletEvent::TxConfirmed { + txid: _, + tx, + block_time: _, + old_block_time: _, + } => { + result_events.extend(self.get_owned_transaction_outputs(&tx)?); + + result_events + .push(WalletProviderEvent::ConfirmedTransaction { tx: (*tx).clone() }); + } + WalletEvent::TxUnconfirmed { + txid: _, + tx, + old_block_time: _, + } => { + result_events.extend(self.get_owned_transaction_outputs(&tx)?); + + result_events.push(WalletProviderEvent::UnconfirmedTransactionInBlock { + tx: (*tx).clone(), + }); + } + WalletEvent::TxDropped { txid: _, tx } => { + result_events.extend(self.get_owned_transaction_outputs(&tx)?); + + result_events.push(WalletProviderEvent::UnconfirmedTransactionInBlock { + tx: (*tx).clone(), + }); + } + WalletEvent::TxReplaced { + txid: _, + tx, + conflicts: _, + } => { + result_events.extend(self.get_owned_transaction_outputs(&tx)?); + + result_events.push(WalletProviderEvent::UnconfirmedTransactionInBlock { + tx: (*tx).clone(), + }); + } + _other => {} + } + } + + Ok(result_events) + } + + fn get_owned_transaction_outputs( + &self, + transaction: &Transaction, + ) -> Result, WalletProviderError> { + let wallet = self.get_wallet()?; + + let events = transaction + .output + .iter() + .filter(|out| wallet.is_mine(out.script_pubkey.clone())) + .map(|out| WalletProviderEvent::UpdateTransaction { + output: out.clone(), + tx: transaction.clone(), + }) + .collect(); + + Ok(events) + } +} + +impl super::WalletProvider for BdkWalletProvider +where + K: Ord + Clone + Debug + From + ToString + Send + Sync, + P: WalletPersister + Send, +{ + fn block_process( + &self, + block: &Block, + height: u32, + ) -> Result, WalletProviderError> { + let mut wallet = self.get_wallet_mut()?; + + let events = wallet.apply_block_events(block, height)?; + + wallet.persist(&mut *self.get_persister()?).map_err(|_| { + WalletProviderError::PersistenceError( + "Error persist the wallet after applying block events".to_string(), + ) + })?; + + drop(wallet); + + self.event_process(events) + } + + fn persist_descriptor( + &mut self, + id: &str, + descriptor: &str, + ) -> Result<(), WalletProviderError> { + // if wallet is not initialized, initialize it with the provided descriptor. Otherwise, add the + if self.wallet.is_none() { + self.initialize_wallet(id, descriptor)?; + return Ok(()); + } + + // Add the descriptor to the keyring and persist it, then reload the wallet to pick up the + // new descriptor. We have to do this dance because the BDK wallet doesn't support adding + // descriptors at runtime, so we have to persist the new keyring and then reload the wallet + // to pick it up. + { + let wallet = self.get_wallet()?; + let mut keyring = wallet.keyring().clone(); + if keyring.list_keychains().keys().any(|k| k.to_string() == id) { + return Err(WalletProviderError::DescriptorAlreadyExists(format!( + "Descriptor with id {id} already exists in provider" + ))); + } + let change_keyring = + keyring.add_descriptor(id.to_string().into(), descriptor.to_string())?; + + let changeset = bdk_wallet::ChangeSet { + keyring: change_keyring, + ..Default::default() + }; + + let mut persister = self.get_persister()?; + + P::persist(&mut *persister, &changeset).map_err(|_| { + WalletProviderError::PersistenceError("Error persisting keyring".to_string()) + })?; + } // Drop wallet read lock before acquiring write lock in next step + + // Now reload the wallet to pick up the new descriptor + { + let mut wallet = self.get_wallet_mut()?; + let mut persister = self.get_persister()?; + + let new_wallet = Wallet::load() + .load_wallet(&mut *persister) + .map_err(|_| { + WalletProviderError::WalletLoadError("Error loading wallet".to_string()) + })? + .ok_or_else(|| { + WalletProviderError::WalletLoadError("Option wallet is None".to_string()) + })?; + + *wallet = new_wallet; + } + + Ok(()) + } + + fn get_transaction(&self, txid: &Txid) -> Result { + let wallet = self.get_wallet()?; + + if let Some(tx) = wallet.get_tx(*txid) { + Ok((*tx.tx_node.tx).clone()) + } else { + Err(WalletProviderError::TransactionNotFound(*txid)) + } + } + + fn get_transactions(&self) -> Result, WalletProviderError> { + let wallet = self.get_wallet()?; + + let transactions: Vec = wallet + .transactions() + .map(|c_tx| (*c_tx.tx_node.tx).clone()) + .collect(); + + Ok(transactions) + } + + fn get_transaction_by_wallet( + &self, + _ids: HashSet, + txid: &Txid, + ) -> Result { + // Note: BDK wallet does not support querying transactions by keychain + self.get_transaction(txid) + } + + fn get_transactions_by_wallet( + &self, + _ids: HashSet, + ) -> Result, WalletProviderError> { + // Note: BDK wallet does not support querying transactions by keychain + let transactions = self.get_transactions()?; + + Ok(transactions) + } + + fn get_balance( + &self, + ids: HashSet, + params: super::GetBalanceParams, + ) -> Result { + let wallet = self.get_wallet()?; + if params.minconf < 1 { + let balance = self.get_balances(ids)?.total(); + return Ok(balance); + } + + let checkpoint = wallet.latest_checkpoint(); + + let mut balance = Amount::from_sat(0); + let unspent = wallet.list_unspent(); + + let wallet_unspent = unspent.into_iter().filter(|u| { + !u.is_spent + && ids.contains(&u.keychain.to_string()) + && u.chain_position + .confirmation_height_upper_bound() + .is_some_and(|height| { + params.minconf + <= checkpoint.height().saturating_add(1).saturating_sub(height) + // Confirmations = checkpoint_height - (height - 1) + }) + }); + + for utxo in wallet_unspent { + balance += utxo.txout.value; + } + + Ok(balance) + } + + fn get_balances(&self, ids: HashSet) -> Result { + let wallet = self.get_wallet()?; + + let mut immature = Amount::from_sat(0); + let mut trusted = Amount::from_sat(0); + let mut untrusted_pending = Amount::from_sat(0); + + for keychain in ids { + let balance = wallet.balance_keychain(keychain.into()); + + immature += balance.immature; + trusted += balance.trusted_spendable(); + untrusted_pending += balance.untrusted_pending; + } + let checkpoint = wallet.latest_checkpoint(); + Ok(Balance { + immature, + trusted, + untrusted_pending, + used: None, // The BDK wallet does not differentiate used vs unused balance + last_processed_block: LastProcessedBlock { + hash: checkpoint.hash(), + height: checkpoint.height(), + }, + }) + } + + fn create_transaction( + &self, + _ids: HashSet, + _address: &str, + ) -> Result<(), WalletProviderError> { + // let amount_sats = 100_000; // Exemplo: enviar 0.001 BTC + // let wallet = self.get_wallet()?; + + // // Parsear endereço + // let address = Address::try_from_unchecked(address) + // .map_err(|e| WalletProviderError::Other(format!("Invalid address: {}", e)))?; + + // // Construir transação + // let mut tx_builder = wallet.build_tx(); + + // tx_builder + // .add_recipient(address.script_pubkey(), Amount::from_sat(amount_sats)) + // .map_err(|e| WalletProviderError::Other(format!("Failed to add recipient: {}", e)))?; + + // // Definir taxa + // tx_builder.fee_rate(bdk_wallet::FeeRate::from_sat_per_vb(5.0)); + + // // Finalizar + // let (mut psbt, details) = tx_builder.finish().map_err(|e| { + // WalletProviderError::Other(format!("Failed to build transaction: {}", e)) + // })?; + + // // Assinar PSBT + // wallet + // .sign(&mut psbt, Default::default()) + // .map_err(|e| WalletProviderError::Other(format!("Failed to sign: {}", e)))?; + + // // Extrair transação assinada + // let tx = psbt + // .extract_tx() + // .map_err(|e| WalletProviderError::Other(format!("Failed to extract tx: {}", e)))?; + + // println!("Transação assinada! TXID: {}", tx.compute_txid()); + // Ok(tx) + Ok(()) + } + + fn new_address(&self, id: &str) -> Result { + let mut wallet = self.get_wallet_mut()?; + let keychain_key = K::from(id.to_string()); + + // Now keychain is K, no need to match against &str + let address = wallet + .next_unused_address(keychain_key) + .map(|address_info| address_info.address) + .ok_or_else(|| { + WalletProviderError::AddressError("No unused address available".to_string()) + })?; + + wallet.persist(&mut *self.get_persister()?).map_err(|_| { + WalletProviderError::PersistenceError("Persist error wallet".to_string()) + })?; + + Ok(address) + } + + fn sent_and_received( + &self, + ids: HashSet, + txid: &Txid, + ) -> Result<(u64, u64), WalletProviderError> { + let wallet = self.get_wallet()?; + + let tx = self.get_transaction_by_wallet(ids, txid)?; + let (sent, receive) = wallet.sent_and_received(&tx); + + Ok((sent.to_sat(), receive.to_sat())) + } + + fn process_mempool_transactions( + &self, + transactions: Vec<&Transaction>, + ) -> Result, WalletProviderError> { + let mut events = Vec::new(); + let mut unconfirmed_txs = Vec::new(); + let current_time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| WalletProviderError::Other(format!("System time error: {e:?}")))? + .as_secs(); + + for tx in &transactions { + let tx_event = self.get_owned_transaction_outputs(tx)?; + if !tx_event.is_empty() { + events.extend(tx_event); + } + unconfirmed_txs.push(((*tx).clone(), current_time)); + } + + if unconfirmed_txs.is_empty() { + return Ok(vec![]); + } + + let mut wallet = self.get_wallet_mut()?; + + wallet.apply_unconfirmed_txs(unconfirmed_txs); + + wallet.persist(&mut *self.get_persister()?).map_err(|_| { + WalletProviderError::PersistenceError("Persist error wallet".to_string()) + })?; + + for tx in transactions { + if wallet.get_tx(tx.compute_txid()).is_some() { + events + .push(WalletProviderEvent::UnconfirmedTransactionInBlock { tx: (*tx).clone() }); + } + } + + Ok(events) + } + + fn get_txo( + &self, + outpoint: &OutPoint, + is_spent: Option, + ) -> Result, WalletProviderError> { + let wallet = self.get_wallet()?; + + if let Some(false) = is_spent { + return Ok(wallet.get_utxo(*outpoint).map(|utxo| utxo.txout.clone())); + } + + let out = wallet + .list_output() + .find(|o| is_spent.is_none_or(|spent| o.is_spent == spent) && o.outpoint == *outpoint); + + Ok(out.map(|o| o.txout.clone())) + } + + fn get_local_output_by_script( + &self, + script_hash: ScriptBuf, + is_spent: Option, + ) -> Result, WalletProviderError> { + let wallet = self.get_wallet()?; + + let outputs = wallet + .list_output() + .filter(|o| { + is_spent.is_none_or(|spent| o.is_spent == spent) + && o.txout.script_pubkey == script_hash + }) + .map(|o| LocalOutput { + outpoint: o.outpoint, + txout: o.txout.clone(), + is_spent: o.is_spent, + }) + .collect(); + + Ok(outputs) + } + + fn list_script_buff( + &self, + ids: Option>, + ) -> Result, WalletProviderError> { + let wallet = self.get_wallet()?; + + let mut script_buf = Vec::new(); + + for (id, spk_iter) in wallet.all_unbounded_spk_iters() { + if let Some(keychains) = &ids { + if !keychains.contains(&id.to_string()) { + continue; + } + } + let index = 30 + wallet.spk_index().last_revealed_index(id).unwrap_or(0); + let script = spk_iter + .into_iter() + .take(index as usize) + .map(|(_, s)| s) + .collect::>(); + script_buf.extend(script); + } + + Ok(script_buf) + } + + fn get_last_processed_block(&self) -> Result { + let wallet = self.get_wallet()?; + + let checkpoint = wallet.latest_checkpoint(); + + Ok(LastProcessedBlock { + hash: checkpoint.hash(), + height: checkpoint.height(), + }) + } + + fn get_descriptor(&self, id: &str) -> Result { + let wallet = self.get_wallet()?; + + let keychain = wallet + .keyring() + .list_keychains() + .get(&K::from(id.to_string())) + .ok_or_else(|| { + WalletProviderError::MissingWallet(format!("Keychain with id {id} not found")) + })?; + + Ok(keychain.to_string()) + } +} + +impl From for WalletProviderError { + fn from(value: RusqliteError) -> Self { + WalletProviderError::PersistenceError(format!("Rusqlite error: {value:?}")) + } +} + +impl From> for WalletProviderError +where + K: Ord + Clone + Debug + From, +{ + fn from(value: CreateWithPersistError) -> Self { + match value { + CreateWithPersistError::DataAlreadyExists(_) => { + WalletProviderError::WalletAlreadyExists("Data already exists".to_string()) + } + CreateWithPersistError::InvalidKeyRing(_) => { + WalletProviderError::WalletCreationError("Invalid keyring".to_string()) + } + CreateWithPersistError::Persist(_) => { + WalletProviderError::PersistenceError("Persist error".to_string()) + } + } + } +} + +impl From> for WalletProviderError +where + K: Ord + Clone + Debug, +{ + fn from(value: LoadWithPersistError) -> Self { + match value { + LoadWithPersistError::InvalidChangeSet(e) => { + WalletProviderError::WalletLoadError(format!("Wallet load error: {e:?}")) + } + LoadWithPersistError::Persist(e) => { + WalletProviderError::PersistenceError(format!("Rusqlite error: {e:?}")) + } + } + } +} + +impl From> for WalletProviderError +where + K: Ord + Clone + Debug + From, +{ + fn from(value: KeyRingError) -> Self { + match value { + KeyRingError::DescAlreadyExists(des) => { + WalletProviderError::DescriptorAlreadyExists(format!("{des:?}")) + } + KeyRingError::DescMissing => WalletProviderError::MissingDescriptor, + KeyRingError::Descriptor(e) => WalletProviderError::InvalidDescriptor(e.to_string()), + KeyRingError::DescriptorMismatch { + keychain, + loaded, + expected, + } => WalletProviderError::MismatchedDescriptor(format!( + "Descriptor mismatch for keychain {keychain:?}: loaded {loaded:?}, expected {expected:?}", + )), + KeyRingError::KeychainAlreadyExists(k) => WalletProviderError::WalletError(format!( + "Invalid label descriptors {k:?} already exists" + )), + KeyRingError::NetworkMismatch { loaded, expected } => { + WalletProviderError::NetworkMismatch { + expected, + found: loaded, + } + } + KeyRingError::MissingNetwork => WalletProviderError::NetworkMissing, + KeyRingError::MissingKeychain(k) => WalletProviderError::MissingWallet(format!( + "Missing label descriptor in wallet: {k:?}" + )), + } + } +} + +impl From for WalletProviderError { + fn from(value: CannotConnectError) -> Self { + WalletProviderError::BlockProcessingError(value.to_string()) + } +} + +#[allow(clippy::unwrap_used)] +#[cfg(test)] +mod tests { + + use bdk_wallet::chain::BlockId; + use bdk_wallet::chain::ConfirmationBlockTime; + + use super::*; + use crate::provider::WalletProvider; + use crate::utils::create_test_transaction; + use crate::utils::create_transaction_with_txo; + + const DESCRIPTOR: &str = "wpkh(tpubDDtyive2LqLWKzPZ8LZ9Ebi1JDoLcf1cEpn3Mshp6sxVfCupHZJRPQTozp2EpTF76vJcyQBN7VP7CjUntEJxeADnuTMNTYKoSWNae8soVyv/0/*)#7h6kdtnk"; + const DESCRIPTOR_ID: &str = "main"; + + const DESCRIPTOR_SECOND: &str = "wpkh(tpubDDtyive2LqLWKzPZ8LZ9Ebi1JDoLcf1cEpn3Mshp6sxVfCupHZJRPQTozp2EpTF76vJcyQBN7VP7CjUntEJxeADnuTMNTYKoSWNae8soVyv/1/*)#0rlhs7rw"; + const DESCRIPTOR_SECOND_ID: &str = "change"; + + fn create_test_provider() -> BdkWalletProvider { + BdkWalletProvider::::new_in_memory(Network::Regtest).unwrap() + } + + fn create_test_provider_initialized() -> BdkWalletProvider { + let mut provider = create_test_provider(); + provider + .persist_descriptor(DESCRIPTOR_ID, DESCRIPTOR) + .unwrap(); + + provider + .persist_descriptor(DESCRIPTOR_SECOND_ID, DESCRIPTOR_SECOND) + .unwrap(); + + provider + } + + fn check_descriptor_in_keychain( + provider: &BdkWalletProvider, + id: &str, + expected_descriptor: &str, + ) { + let result = provider.get_descriptor(id).unwrap(); + + assert_eq!( + result, expected_descriptor, + "Descriptor should match expected value" + ); + } + + fn create_txo_by_wallet(provider: &BdkWalletProvider) -> TxOut { + TxOut { + value: Amount::from_sat(100_000), + script_pubkey: provider.new_address(DESCRIPTOR_ID).unwrap().script_pubkey(), + } + } + + fn get_test_transaction( + provider: &BdkWalletProvider, + my_output: bool, + ) -> Transaction { + if my_output { + create_transaction_with_txo(create_txo_by_wallet(provider)) + } else { + create_test_transaction() + } + } + + macro_rules! assert_and_pop_event { + // ConfirmedTransaction + ($events:expr,ConfirmedTransaction, $expected_tx:expr) => {{ + let event = $events.remove(0); + if let WalletProviderEvent::ConfirmedTransaction { tx: result_tx } = event { + assert_eq!(result_tx, $expected_tx); + } else { + panic!("Expected ConfirmedTransaction, got {:?}", event); + } + }}; + + // UnconfirmedTransactionInBlock + ($events:expr,UnconfirmedTransactionInBlock, $expected_tx:expr) => {{ + let event = $events.remove(0); + if let WalletProviderEvent::UnconfirmedTransactionInBlock { tx: result_tx } = event { + assert_eq!(result_tx, $expected_tx); + } else { + panic!("Expected UnconfirmedTransactionInBlock, got {:?}", event); + } + }}; + + // UpdateTransaction + ($events:expr,UpdateTransaction, $expected_tx:expr, $expected_output:expr) => {{ + let event = $events.remove(0); + if let WalletProviderEvent::UpdateTransaction { + tx: result_tx, + output: result_output, + } = event + { + assert_eq!(result_tx, $expected_tx); + assert_eq!(result_output, $expected_output); + } else { + panic!("Expected UpdateTransaction, got {:?}", event); + } + }}; + } + + #[test] + fn test_get_wallet_not_initialized() { + let provider = create_test_provider(); + + let result = provider.get_wallet(); + + assert!( + result.is_err(), + "Should fail to get wallet when not initialized" + ); + assert!(matches!( + result.unwrap_err(), + WalletProviderError::WalletNotInitialized + )); + } + + #[test] + fn test_get_wallet_initialized() { + let provider = create_test_provider_initialized(); + + let result = provider.get_wallet(); + + assert!( + result.is_ok(), + "Should successfully get wallet when initialized" + ); + } + + #[test] + fn test_get_wallet_mut_not_initialized() { + let provider = create_test_provider(); + + let result = provider.get_wallet_mut(); + + assert!( + result.is_err(), + "Should fail to get mutable wallet when not initialized" + ); + assert!(matches!( + result.unwrap_err(), + WalletProviderError::WalletNotInitialized + )); + } + + #[test] + fn test_get_wallet_mut_initialized() { + let provider = create_test_provider_initialized(); + + let result = provider.get_wallet_mut(); + + assert!( + result.is_ok(), + "Should successfully get mutable wallet when initialized" + ); + } + + #[test] + fn test_get_persister() { + let provider = create_test_provider(); + + let result = provider.get_persister(); + + assert!(result.is_ok(), "Should successfully get persister"); + } + + #[test] + fn test_initialize_wallet() { + let mut provider = create_test_provider(); + + let result = provider.initialize_wallet(DESCRIPTOR_ID, DESCRIPTOR); + + assert!(result.is_ok(), "Should successfully initialize wallet"); + assert!( + provider.wallet.is_some(), + "Wallet should be set after initialization" + ); + + // Verify the descriptor is in the keychain + check_descriptor_in_keychain(&provider, DESCRIPTOR_ID, DESCRIPTOR); + } + + #[test] + fn test_event_process_confirmed_transaction_without_my_output() { + let provider = create_test_provider_initialized(); + + // Create a simple transaction + let tx = get_test_transaction(&provider, false); + let txid = tx.compute_txid(); + + let event = WalletEvent::TxConfirmed { + txid, + tx: Arc::new(tx.clone()), + block_time: ConfirmationBlockTime::default(), + old_block_time: None, + }; + + let mut result = provider.event_process(vec![event]).unwrap(); + + assert_eq!(result.len(), 1); + assert_and_pop_event!(result, ConfirmedTransaction, tx); + assert!(result.is_empty(), "No more events should be generated"); + } + + #[test] + fn test_event_process_confirmed_transaction_with_my_output() { + let provider = create_test_provider_initialized(); + + // Create a transaction with our output + let tx = get_test_transaction(&provider, true); + let txid = tx.compute_txid(); + + let event = WalletEvent::TxConfirmed { + txid, + tx: Arc::new(tx.clone()), + block_time: ConfirmationBlockTime::default(), + old_block_time: None, + }; + + let mut result = provider.event_process(vec![event]).unwrap(); + + assert_eq!(result.len(), 2); + assert_and_pop_event!( + result, + UpdateTransaction, + tx, + tx.output[tx.output.len() - 1].clone() + ); + assert_and_pop_event!(result, ConfirmedTransaction, tx); + + assert!(result.is_empty(), "No more events should be generated"); + } + + #[test] + fn test_event_process_unconfirmed_transaction_without_my_output() { + let provider = create_test_provider_initialized(); + + let tx = get_test_transaction(&provider, false); + let txid = tx.compute_txid(); + + let event = WalletEvent::TxUnconfirmed { + txid, + tx: Arc::new(tx.clone()), + old_block_time: None, + }; + + let mut result = provider.event_process(vec![event]).unwrap(); + + assert_eq!(result.len(), 1); + assert_and_pop_event!(result, UnconfirmedTransactionInBlock, tx); + assert!(result.is_empty(), "No more events should be generated"); + } + + #[test] + fn test_event_process_unconfirmed_transaction_with_my_output() { + let provider = create_test_provider_initialized(); + + let tx = get_test_transaction(&provider, true); + let txid = tx.compute_txid(); + + let event = WalletEvent::TxUnconfirmed { + txid, + tx: Arc::new(tx.clone()), + old_block_time: None, + }; + + let mut result = provider.event_process(vec![event]).unwrap(); + + assert_eq!(result.len(), 2); + assert_and_pop_event!( + result, + UpdateTransaction, + tx, + tx.output[tx.output.len() - 1].clone() + ); + assert_and_pop_event!(result, UnconfirmedTransactionInBlock, tx); + assert!(result.is_empty(), "No more events should be generated"); + } + + #[test] + fn test_event_process_drop_transaction_without_my_output() { + let provider = create_test_provider_initialized(); + + let tx = get_test_transaction(&provider, false); + let txid = tx.compute_txid(); + + let event = WalletEvent::TxDropped { + txid, + tx: Arc::new(tx.clone()), + }; + + let mut result = provider.event_process([event].to_vec()).unwrap(); + + assert_eq!(result.len(), 1); + assert_and_pop_event!(result, UnconfirmedTransactionInBlock, tx); + assert!(result.is_empty(), "No more events should be generated"); + } + + #[test] + fn test_event_process_drop_transaction_with_my_output() { + let provider = create_test_provider_initialized(); + + let tx = get_test_transaction(&provider, true); + let txid = tx.compute_txid(); + + let event = WalletEvent::TxDropped { + txid, + tx: Arc::new(tx.clone()), + }; + + let mut result = provider.event_process(vec![event]).unwrap(); + + assert_eq!(result.len(), 2); + assert_and_pop_event!( + result, + UpdateTransaction, + tx, + tx.output[tx.output.len() - 1].clone() + ); + assert_and_pop_event!(result, UnconfirmedTransactionInBlock, tx); + assert!(result.is_empty(), "No more events should be generated"); + } + + #[test] + fn test_event_process_replaced_transaction_without_my_output() { + let provider = create_test_provider_initialized(); + + let tx = get_test_transaction(&provider, false); + let txid = tx.compute_txid(); + + let event = WalletEvent::TxReplaced { + txid, + tx: Arc::new(tx.clone()), + conflicts: vec![], + }; + + let mut result = provider.event_process(vec![event]).unwrap(); + + assert_eq!(result.len(), 1); + assert_and_pop_event!(result, UnconfirmedTransactionInBlock, tx); + assert!(result.is_empty(), "No more events should be generated"); + } + + #[test] + fn test_event_process_replaced_transaction_with_my_output() { + let provider = create_test_provider_initialized(); + + let tx = get_test_transaction(&provider, true); + let txid = tx.compute_txid(); + + let event = WalletEvent::TxReplaced { + txid, + tx: Arc::new(tx.clone()), + conflicts: vec![], + }; + + let mut result = provider.event_process(vec![event]).unwrap(); + + assert_eq!(result.len(), 2); + assert_and_pop_event!( + result, + UpdateTransaction, + tx, + tx.output[tx.output.len() - 1].clone() + ); + assert_and_pop_event!(result, UnconfirmedTransactionInBlock, tx); + assert!(result.is_empty(), "No more events should be generated"); + } + + #[test] + fn test_event_process_chain_tip_changed() { + let provider = create_test_provider_initialized(); + + let event = WalletEvent::ChainTipChanged { + old_tip: BlockId::default(), + new_tip: BlockId::default(), + }; + + let result = provider.event_process(vec![event]).unwrap(); + + assert!( + result.is_empty(), + "ChainTipChanged should not generate any events" + ); + } + + #[test] + fn test_get_owned_transaction_outputs_empty() { + let provider = create_test_provider_initialized(); + + let tx = get_test_transaction(&provider, true); + + let result = provider.get_owned_transaction_outputs(&tx).unwrap(); + + assert_eq!(result.len(), 1); + assert_eq!( + result[0], + WalletProviderEvent::UpdateTransaction { + tx: tx.clone(), + output: tx.output[tx.output.len() - 1].clone() + } + ); + } + + #[test] + fn test_get_owned_transaction_outputs_with_outputs() { + let provider = create_test_provider_initialized(); + + let tx = get_test_transaction(&provider, true); + + let result = provider.get_owned_transaction_outputs(&tx).unwrap(); + assert!( + !result.is_empty(), + "Transaction with our outputs should generate events" + ); + } + + #[test] + fn test_event_process_multiple_events() { + let provider = create_test_provider_initialized(); + + // === Phase 1: Transactions WITH user output === + let tx_with_output = get_test_transaction(&provider, true); + let tx_with_output_id = tx_with_output.compute_txid(); + + // === Phase 2: Transactions WITHOUT user output === + let tx_without_output = get_test_transaction(&provider, false); + let tx_without_output_id = tx_without_output.compute_txid(); + + // Build events: first all WITH user output, then all WITHOUT user output + let events = vec![ + // --- Events WITH user output (should generate 2 events each) --- + WalletEvent::TxConfirmed { + txid: tx_with_output_id, + tx: Arc::new(tx_with_output.clone()), + block_time: ConfirmationBlockTime::default(), + old_block_time: None, + }, + WalletEvent::TxUnconfirmed { + txid: tx_with_output_id, + tx: Arc::new(tx_with_output.clone()), + old_block_time: None, + }, + WalletEvent::TxDropped { + txid: tx_with_output_id, + tx: Arc::new(tx_with_output.clone()), + }, + WalletEvent::TxReplaced { + txid: tx_with_output_id, + tx: Arc::new(tx_with_output.clone()), + conflicts: vec![], + }, + // --- Events WITHOUT user output (should generate 1 event each) --- + WalletEvent::TxConfirmed { + txid: tx_without_output_id, + tx: Arc::new(tx_without_output.clone()), + block_time: ConfirmationBlockTime::default(), + old_block_time: None, + }, + WalletEvent::TxUnconfirmed { + txid: tx_without_output_id, + tx: Arc::new(tx_without_output.clone()), + old_block_time: None, + }, + WalletEvent::TxDropped { + txid: tx_without_output_id, + tx: Arc::new(tx_without_output.clone()), + }, + WalletEvent::TxReplaced { + txid: tx_without_output_id, + tx: Arc::new(tx_without_output.clone()), + conflicts: vec![], + }, + WalletEvent::ChainTipChanged { + old_tip: BlockId::default(), + new_tip: BlockId::default(), + }, + ]; + + let mut result = provider.event_process(events).unwrap(); + + // === Validate: TxConfirmed WITH output (2 events) === + assert_and_pop_event!( + result, + UpdateTransaction, + tx_with_output, + tx_with_output.output[tx_with_output.output.len() - 1].clone() + ); + assert_and_pop_event!(result, ConfirmedTransaction, tx_with_output); + + // === Validate: TxUnconfirmed WITH output (2 events) === + assert_and_pop_event!( + result, + UpdateTransaction, + tx_with_output, + tx_with_output.output[tx_with_output.output.len() - 1].clone() + ); + assert_and_pop_event!(result, UnconfirmedTransactionInBlock, tx_with_output); + + // === Validate: TxDropped WITH output (2 events) === + assert_and_pop_event!( + result, + UpdateTransaction, + tx_with_output, + tx_with_output.output[tx_with_output.output.len() - 1].clone() + ); + assert_and_pop_event!(result, UnconfirmedTransactionInBlock, tx_with_output); + + // === Validate: TxReplaced WITH output (2 events) === + assert_and_pop_event!( + result, + UpdateTransaction, + tx_with_output, + tx_with_output.output[tx_with_output.output.len() - 1].clone() + ); + assert_and_pop_event!(result, UnconfirmedTransactionInBlock, tx_with_output); + + // === Validate: TxConfirmed WITHOUT output (1 event) === + assert_and_pop_event!(result, ConfirmedTransaction, tx_without_output); + + // === Validate: TxUnconfirmed WITHOUT output (1 event) === + assert_and_pop_event!(result, UnconfirmedTransactionInBlock, tx_without_output); + + // === Validate: TxDropped WITHOUT output (1 event) === + assert_and_pop_event!(result, UnconfirmedTransactionInBlock, tx_without_output); + + // === Validate: TxReplaced WITHOUT output (1 event) === + assert_and_pop_event!(result, UnconfirmedTransactionInBlock, tx_without_output); + + // === Validate: ChainTipChanged (0 events) === + // Already validated if we reach this point with empty result + assert!( + result.is_empty(), + "All events should have been processed correctly" + ); + } +} diff --git a/crates/floresta-watch-only/src/provider/mod.rs b/crates/floresta-watch-only/src/provider/mod.rs new file mode 100644 index 000000000..8bcf4839d --- /dev/null +++ b/crates/floresta-watch-only/src/provider/mod.rs @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +#![deny(clippy::unwrap_used)] + +use core::error::Error; +use core::fmt; +use std::collections::HashSet; + +#[cfg(feature = "bdk-provider")] +use bdk_wallet::rusqlite::Connection; +use bitcoin::amount::Amount; +use bitcoin::Address; +use bitcoin::Block; +use bitcoin::Network; +use bitcoin::OutPoint; +use bitcoin::ScriptBuf; +use bitcoin::Transaction; +use bitcoin::TxOut; +use bitcoin::Txid; + +use crate::models::Balance; +use crate::models::GetBalanceParams; +use crate::models::LastProcessedBlock; +use crate::models::LocalOutput; + +#[cfg(feature = "bdk-provider")] +pub mod bdk_provider; + +// For now we only have one provider, so we can just return it directly. +// In the future, we may want to support multiple providers and select them based on configuration. +#[cfg(feature = "bdk-provider")] +pub fn new_provider( + db_path: &str, + network: Network, + is_initialized: bool, +) -> Result, WalletProviderError> { + let provider = bdk_provider::BdkWalletProvider::::new( + db_path, + network, + is_initialized, + )?; + + Ok(Box::new(provider)) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WalletProviderEvent { + UpdateTransaction { tx: Transaction, output: TxOut }, + UnconfirmedTransactionInBlock { tx: Transaction }, + ConfirmedTransaction { tx: Transaction }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WalletProviderError { + // Persistência + PersistenceError(String), + + // Wallet Management + WalletCreationError(String), + + WalletLoadError(String), + + WalletNotInitialized, + + // Keyring & Descriptors + InvalidDescriptor(String), + + DescriptorAlreadyExists(String), + + MissingDescriptor, + + MismatchedDescriptor(String), + + WalletAlreadyExists(String), + + MissingWallet(String), + + // Block Processing + BlockProcessingError(String), + + TransactionNotFoundInBlock(Txid), + + // Address Management + NoAddressAvailable { keychain: String }, + + InvalidKeychain(String), + + // Synchronization + LockPoisoned(String), + + // Transactions + TransactionNotFound(Txid), + + NetworkMismatch { expected: Network, found: Network }, + + NetworkMissing, + + WalletError(String), + + AddressError(String), + + // Generic + Other(String), +} + +impl fmt::Display for WalletProviderError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + WalletProviderError::PersistenceError(e) => { + write!(f, "Persistence error: {e}") + } + WalletProviderError::WalletCreationError(e) => { + write!(f, "Failed to create wallet: {e}") + } + WalletProviderError::WalletLoadError(e) => { + write!(f, "Failed to load wallet: {e}") + } + WalletProviderError::WalletNotInitialized => { + write!(f, "Wallet not initialized") + } + WalletProviderError::InvalidDescriptor(e) => { + write!(f, "Invalid descriptor: {e}") + } + WalletProviderError::DescriptorAlreadyExists(e) => { + write!(f, "Descriptor already exists: {e}") + } + WalletProviderError::MissingDescriptor => { + write!(f, "Missing descriptor") + } + WalletProviderError::MismatchedDescriptor(e) => { + write!(f, "Mismatched descriptor: {e}") + } + WalletProviderError::WalletAlreadyExists(e) => { + write!(f, "Wallet already exists: {e}") + } + WalletProviderError::MissingWallet(e) => { + write!(f, "Missing wallet: {e}") + } + WalletProviderError::BlockProcessingError(e) => { + write!(f, "Block processing error: {e}") + } + WalletProviderError::TransactionNotFoundInBlock(txid) => { + write!(f, "Transaction {txid} not found in block") + } + WalletProviderError::NoAddressAvailable { keychain } => { + write!(f, "No address available for keychain: {keychain}") + } + WalletProviderError::InvalidKeychain(e) => { + write!(f, "Invalid keychain: {e}") + } + WalletProviderError::LockPoisoned(e) => { + write!(f, "Lock poisoned: {e}") + } + WalletProviderError::TransactionNotFound(txid) => { + write!(f, "Transaction {txid} not found") + } + WalletProviderError::NetworkMismatch { expected, found } => { + write!(f, "Network mismatch: expected {expected} but found {found}") + } + WalletProviderError::NetworkMissing => { + write!(f, "Network not specified") + } + WalletProviderError::WalletError(e) => { + write!(f, "Wallet error: {e}") + } + WalletProviderError::AddressError(e) => { + write!(f, "Address error: {e}") + } + WalletProviderError::Other(e) => { + write!(f, "Error: {e}") + } + } + } +} + +impl Error for WalletProviderError {} + +pub trait WalletProvider: Send + Sync { + fn persist_descriptor(&mut self, id: &str, descriptor: &str) + -> Result<(), WalletProviderError>; + + fn block_process( + &self, + block: &Block, + height: u32, + ) -> Result, WalletProviderError>; + + fn get_transaction(&self, txid: &Txid) -> Result; + + fn get_transactions(&self) -> Result, WalletProviderError>; + + fn get_transaction_by_wallet( + &self, + ids: HashSet, + txid: &Txid, + ) -> Result; + + fn get_transactions_by_wallet( + &self, + ids: HashSet, + ) -> Result, WalletProviderError>; + + /// Returns the total available balance. + /// + /// The available balance is what the wallet considers currently spendable, + /// and is thus affected by options which limit spendability such as avoid_reuse. + fn get_balance( + &self, + ids: HashSet, + params: GetBalanceParams, + ) -> Result; + + fn get_balances(&self, ids: HashSet) -> Result; + + fn create_transaction( + &self, + ids: HashSet, + address: &str, + ) -> Result<(), WalletProviderError>; + + fn new_address(&self, id: &str) -> Result; + + fn sent_and_received( + &self, + ids: HashSet, + txid: &Txid, + ) -> Result<(u64, u64), WalletProviderError>; + + fn process_mempool_transactions( + &self, + transactions: Vec<&Transaction>, + ) -> Result, WalletProviderError>; + + fn get_txo( + &self, + outpoint: &OutPoint, + is_spent: Option, + ) -> Result, WalletProviderError>; + + fn get_local_output_by_script( + &self, + script_hash: ScriptBuf, + is_spent: Option, + ) -> Result, WalletProviderError>; + + fn list_script_buff( + &self, + ids: Option>, + ) -> Result, WalletProviderError>; + + fn get_last_processed_block(&self) -> Result; + + fn get_descriptor(&self, id: &str) -> Result; +} diff --git a/crates/floresta-watch-only/tests/common/mod.rs b/crates/floresta-watch-only/tests/common/mod.rs new file mode 100644 index 000000000..94b95f53c --- /dev/null +++ b/crates/floresta-watch-only/tests/common/mod.rs @@ -0,0 +1,199 @@ +#![cfg(feature = "bdk-provider")] + +use std::fs::create_dir_all; + +use bitcoin::absolute::LockTime; +use bitcoin::block::Version; +use bitcoin::blockdata::block::Header; +use bitcoin::hashes::Hash; +use bitcoin::transaction::Version as TxVersion; +use bitcoin::Amount; +use bitcoin::Block; +use bitcoin::BlockHash; +use bitcoin::CompactTarget; +use bitcoin::OutPoint; +use bitcoin::ScriptBuf; +use bitcoin::Sequence; +use bitcoin::Transaction; +use bitcoin::TxIn; +use bitcoin::TxMerkleNode; +use bitcoin::TxOut; +use bitcoin::Txid; +use bitcoin::WPubkeyHash; +use bitcoin::Witness; + +pub(crate) const DESCRIPTOR: &str = "wpkh(tpubDDtyive2LqLWKzPZ8LZ9Ebi1JDoLcf1cEpn3Mshp6sxVfCupHZJRPQTozp2EpTF76vJcyQBN7VP7CjUntEJxeADnuTMNTYKoSWNae8soVyv/0/*)#7h6kdtnk"; +#[allow(dead_code)] +pub(crate) const DESCRIPTOR_ID: &str = + "3f3958f4779e4c23273f1821263a7d788efb4c8a7354a5b4accc5cf45040e404"; + +pub(crate) const DESCRIPTOR_SECOND: &str = "wpkh(tpubDDtyive2LqLWKzPZ8LZ9Ebi1JDoLcf1cEpn3Mshp6sxVfCupHZJRPQTozp2EpTF76vJcyQBN7VP7CjUntEJxeADnuTMNTYKoSWNae8soVyv/1/*)#0rlhs7rw"; +#[allow(dead_code)] +pub(crate) const DESCRIPTOR_SECOND_ID: &str = + "902b63d58c5126027a6709a20c5259f105534c1bafe44445491ec11b0c1708ec"; + +pub struct TransactionInner { + pub outpoint: Vec, + pub txo: Vec, +} + +impl TransactionInner { + pub fn to_transaction(&self) -> Transaction { + if self.outpoint.is_empty() && self.txo.is_empty() { + panic!("Cannot create transaction with empty inputs and outputs"); + } + + let outpoint = if self.outpoint.is_empty() { + let txid = Txid::all_zeros(); + vec![OutPoint { txid, vout: 0 }] + } else { + self.outpoint.clone() + }; + + let txo = if self.txo.is_empty() { + vec![TxOut { + value: Amount::from_sat(10_000_000), + script_pubkey: create_script_buff(), + }] + } else { + self.txo.clone() + }; + + Transaction { + version: TxVersion::TWO, + lock_time: LockTime::ZERO, + input: outpoint + .iter() + .map(|outpoint| TxIn { + previous_output: *outpoint, + script_sig: ScriptBuf::new(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: Witness::default(), + }) + .collect(), + output: txo, + } + } +} + +pub fn create_coinbase_transaction( + script_pubkey: Option, + value: Option, +) -> Transaction { + let script_pubkey = script_pubkey.unwrap_or_else(create_script_buff); + let value = value.unwrap_or(50 * 100_000_000); // Default to 50 BTC + + let txout = bitcoin::TxOut { + value: Amount::from_sat(value), + script_pubkey, + }; + + let tx = Transaction { + version: TxVersion::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + ..Default::default() + }], + output: vec![txout], + }; + + assert!(tx.is_coinbase()); + + tx +} + +pub fn create_script_buff() -> ScriptBuf { + ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()) +} + +pub fn create_block_with_transaction( + prev_block_hash: Option, + transaction: &Transaction, +) -> Block { + let coinbase_tx = create_coinbase_transaction(None, None); + + create_block(prev_block_hash, vec![coinbase_tx, transaction.clone()]) +} + +pub fn create_block_with_transactions( + prev_block_hash: Option, + transactions: Vec, +) -> Block { + let coinbase_tx = create_coinbase_transaction(None, None); + let mut all_txs = vec![coinbase_tx]; + all_txs.extend(transactions); + + create_block(prev_block_hash, all_txs) +} + +#[allow(dead_code)] +pub fn create_block_with_coinbase( + prev_block_hash: Option, + script_pubkey: ScriptBuf, + value: u64, +) -> Block { + let coinbase_tx = create_coinbase_transaction(Some(script_pubkey), Some(value)); + + create_block(prev_block_hash, vec![coinbase_tx]) +} + +// pub fn create_block_with_coinbase_and_transaction( +// prev_block_hash: Option, +// script_pubkey: ScriptBuf, +// value: u64, +// transaction: &Transaction, +// ) -> Block { +// let coinbase_tx = create_coinbase_transaction(Some(script_pubkey), Some(value)); + +// create_block(prev_block_hash, vec![coinbase_tx, transaction.clone()]) +// } + +pub fn create_block(prev_block_hash: Option, transactions: Vec) -> Block { + let header = Header { + bits: CompactTarget::default(), + nonce: 0, + version: Version::default(), + prev_blockhash: prev_block_hash.unwrap_or_else(BlockHash::all_zeros), + merkle_root: TxMerkleNode::all_zeros(), + time: 0, + }; + + let mut block = Block { + header, + txdata: transactions, + }; + + block.header.merkle_root = block.compute_merkle_root().unwrap(); + + block +} + +pub fn generate_blocks(count: u32, prev_block_hash: Option) -> Vec { + let mut blocks = Vec::new(); + let mut current_prev_hash = prev_block_hash.unwrap_or(BlockHash::all_zeros()); + + for _ in 0..count { + let block = create_block_with_transactions(Some(current_prev_hash), vec![]); + current_prev_hash = block.header.block_hash(); + blocks.push(block); + } + + blocks +} + +pub fn generate_random_path_tmpdir() -> String { + use std::time::SystemTime; + use std::time::UNIX_EPOCH; + + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards"); + + let random_suffix = duration.as_nanos(); + let path = format!("/tmp/{}", random_suffix); + + // Create all parent directories before returning the path + create_dir_all(&path).expect("Failed to create tmp directory"); + + path +} diff --git a/crates/floresta-watch-only/tests/provider.rs b/crates/floresta-watch-only/tests/provider.rs new file mode 100644 index 000000000..a7ded75ea --- /dev/null +++ b/crates/floresta-watch-only/tests/provider.rs @@ -0,0 +1,773 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +#![cfg(feature = "bdk-provider")] + +mod common; + +use std::collections::HashSet; +use std::ops::Add; +use std::str::FromStr; + +use bitcoin::Amount; +use bitcoin::Network; +use bitcoin::OutPoint; +use bitcoin::ScriptBuf; +use bitcoin::TxOut; +use bitcoin::Txid; +use floresta_watch_only::models::GetBalanceParams; +use floresta_watch_only::provider::new_provider; +use floresta_watch_only::provider::WalletProvider; +use floresta_watch_only::provider::WalletProviderError; +use floresta_watch_only::provider::WalletProviderEvent; + +use crate::common::create_block_with_transaction; +use crate::common::create_block_with_transactions; +use crate::common::create_script_buff; +use crate::common::generate_blocks; +use crate::common::generate_random_path_tmpdir; +use crate::common::TransactionInner; +use crate::common::DESCRIPTOR; +use crate::common::DESCRIPTOR_ID; +use crate::common::DESCRIPTOR_SECOND; +use crate::common::DESCRIPTOR_SECOND_ID; + +pub fn get_path_to_test_db() -> String { + let path = generate_random_path_tmpdir(); + + path.add("/provider.db3") +} + +fn create_test_provider() -> Box { + new_provider(&get_path_to_test_db(), Network::Regtest, false).unwrap() +} + +fn create_test_provider_initialized() -> Box { + let mut provider = create_test_provider(); + provider + .persist_descriptor(DESCRIPTOR_ID, DESCRIPTOR) + .unwrap(); + provider + .persist_descriptor(DESCRIPTOR_SECOND_ID, DESCRIPTOR_SECOND) + .unwrap(); + + provider +} + +fn add_blocks_to_provider(provider: &dyn WalletProvider, quantity: u32) { + let last_processed_block = provider.get_last_processed_block().unwrap(); + let blocks = generate_blocks(quantity, Some(last_processed_block.hash)); + let mut height = last_processed_block.height + 1; + + for block in blocks { + provider.block_process(&block, height).unwrap(); + height += 1; + } +} + +fn check_descriptor_in_keychain( + provider: &dyn WalletProvider, + id: &str, + expected_descriptor: &str, +) { + let result = provider.get_descriptor(id).unwrap(); + + assert_eq!( + result, expected_descriptor, + "Descriptor should match expected value" + ); +} + +#[test] +fn test_persist_descriptor_initial_creation() { + let mut provider = create_test_provider(); + + provider + .persist_descriptor(DESCRIPTOR_ID, DESCRIPTOR) + .unwrap(); + + check_descriptor_in_keychain(provider.as_ref(), DESCRIPTOR_ID, DESCRIPTOR); +} + +#[test] +fn test_persist_descriptor_add_second_descriptor() { + let mut provider = create_test_provider(); + + provider + .persist_descriptor(DESCRIPTOR_ID, DESCRIPTOR) + .unwrap(); + + check_descriptor_in_keychain(provider.as_ref(), DESCRIPTOR_ID, DESCRIPTOR); + + let result = provider.persist_descriptor(DESCRIPTOR_SECOND_ID, DESCRIPTOR_SECOND); + + assert!(result.is_ok(), "Failed to persist second descriptor"); + + check_descriptor_in_keychain(provider.as_ref(), DESCRIPTOR_SECOND_ID, DESCRIPTOR_SECOND); +} + +#[test] +fn test_persist_descriptor_duplicate_id_fails() { + let mut provider = create_test_provider(); + + provider + .persist_descriptor(DESCRIPTOR_ID, DESCRIPTOR) + .unwrap(); + + let err = provider + .persist_descriptor(DESCRIPTOR_ID, DESCRIPTOR_SECOND) + .unwrap_err(); + + assert!(matches!( + err, + WalletProviderError::DescriptorAlreadyExists(_) + )); +} + +#[test] +fn test_persist_descriptor_duplicate_descriptor_fails() { + let mut provider = create_test_provider(); + + provider + .persist_descriptor(DESCRIPTOR_ID, DESCRIPTOR) + .unwrap(); + + let err = provider + .persist_descriptor(DESCRIPTOR_SECOND_ID, DESCRIPTOR) + .unwrap_err(); + + assert!(matches!( + err, + WalletProviderError::DescriptorAlreadyExists(_) + )); +} + +#[test] +fn test_persist_multiple_descriptors_sequentially() { + let mut provider = create_test_provider(); + + let descriptor_configs = vec![ + ("receiving", "wpkh(tpubDBxWyYwpXjpaBVxm3UTZYJ7BMzSH45eZvsMge5Bk1UKpUGRgNxoAtQyV5ZumNycg4RRNdWwGb2LEPfSBwPUY4EVNNa2oDUR9vwRNohLjnuL/0/*)#q5xmwtdg"), + ("change", "wpkh(tpubDBxWyYwgC5Hbz6SYUpPcg3GUAcbtCDAxz5pgXK9hJ4pPGHff9sX1ckjpPCeNJDSNrffArawsmAvTfbKNvxAJBrRaHDCXDcDdbaUU3c7w6cr/0/*)#5zhtjjl7"), + ("savings", "wpkh(tpubD9iPRr2awBsAyCzKmEC46MMHC8vQAfxK2XmJrpuAgZ4yy1h5rkCEPoomRqFJHqXHWZCdHYghVJmUG1bfUXidh5HevfLWQf44W9BzwKRSWgG/0/*)#m0drp6yl"), + ]; + + for (id, descriptor) in &descriptor_configs { + provider.persist_descriptor(id, descriptor).unwrap(); + + check_descriptor_in_keychain(provider.as_ref(), id, descriptor); + } +} + +#[test] +fn test_list_script_buff() { + let provider = create_test_provider_initialized(); + + let script_bufs = provider.list_script_buff(None).unwrap(); + + assert!( + !script_bufs.is_empty(), + "Script buffers should not be empty" + ); + + for script_buf in &script_bufs { + assert!(script_buf.is_p2wpkh(), "Script buffer should be P2WPKH"); + } +} + +#[test] +fn test_list_script_buff_with_ids() { + let provider = create_test_provider_initialized(); + + let all_script_bufs = provider.list_script_buff(None).unwrap(); + let receiving_script_bufs = provider + .list_script_buff(Some(HashSet::from([DESCRIPTOR_ID.to_string()]))) + .unwrap(); + let change_script_bufs = provider + .list_script_buff(Some(HashSet::from([DESCRIPTOR_SECOND_ID.to_string()]))) + .unwrap(); + + assert_eq!(receiving_script_bufs.len(), 30); // Default index is 30, so we should have 30 script buffers for each descriptor + assert_eq!(change_script_bufs.len(), 30); + assert_eq!( + all_script_bufs.len(), + receiving_script_bufs.len() + change_script_bufs.len() + ); +} + +#[test] +fn test_get_transactions_from_empty_wallet() { + let provider = create_test_provider_initialized(); + + let transactions = provider.get_transactions().unwrap(); + + // New wallet without transactions should be empty + assert!( + transactions.is_empty(), + "New wallet should have no transactions" + ); +} + +#[test] +fn test_get_transaction_not_found() { + let provider = create_test_provider_initialized(); + + let nonexistent_txid = + Txid::from_str("0000000000000000000000000000000000000000000000000000000000000000").unwrap(); + + let result = provider.get_transaction(&nonexistent_txid); + + assert!( + result.is_err(), + "Should fail to get nonexistent transaction" + ); + assert!(matches!( + result.unwrap_err(), + WalletProviderError::TransactionNotFound(_) + )); +} + +#[test] +fn test_get_transaction_by_wallet_delegates_to_get_transaction() { + let provider = create_test_provider_initialized(); + + let nonexistent_txid = + Txid::from_str("0000000000000000000000000000000000000000000000000000000000000000").unwrap(); + + let result = provider.get_transaction_by_wallet( + HashSet::from([DESCRIPTOR_ID.to_string()]), + &nonexistent_txid, + ); + + assert!( + result.is_err(), + "Should delegate to get_transaction and fail for nonexistent txid" + ); +} + +#[test] +fn test_get_transactions_by_wallet_delegates_to_get_transactions() { + let provider = create_test_provider_initialized(); + + // With empty wallet, should return empty + let transactions = + provider.get_transactions_by_wallet(HashSet::from([DESCRIPTOR_ID.to_string()])); + + assert!(transactions.is_ok(), "Should successfully get transactions"); + assert!( + transactions.unwrap().is_empty(), + "New wallet should have no transactions" + ); +} + +#[test] +fn test_get_balance_empty_wallet() { + let provider = create_test_provider_initialized(); + + let balance = provider.get_balance( + HashSet::from([DESCRIPTOR_ID.to_string()]), + GetBalanceParams { + minconf: 1, + avoid_reuse: false, + }, + ); + + assert!( + balance.is_ok(), + "Should successfully get balance from empty wallet" + ); + assert_eq!( + balance.unwrap(), + Amount::from_sat(0), + "Empty wallet should have zero balance" + ); +} + +#[test] +fn test_get_balance_with_transaction() { + fn assert_balance(provider: &dyn WalletProvider, conf: u32, amount: u64) { + let round = 8; + for minconf in 0..round { + let expected = if conf >= minconf { amount } else { 0 }; + let balance = provider + .get_balance( + HashSet::from([DESCRIPTOR_ID.to_string()]), + GetBalanceParams { + minconf, + avoid_reuse: false, + }, + ) + .unwrap(); + assert_eq!( + balance, + Amount::from_sat(expected), + "Balance should be {} with minconf {} and conf {}", + expected, + minconf, + conf + ); + } + } + + let provider = create_test_provider_initialized(); + + // Create a transaction and apply it to the wallet, then check balance + let tx = TransactionInner { + outpoint: vec![], + txo: vec![TxOut { + value: Amount::from_sat(100_000), + script_pubkey: provider.new_address(DESCRIPTOR_ID).unwrap().script_pubkey(), + }], + } + .to_transaction(); + + provider.process_mempool_transactions(vec![&tx]).unwrap(); + + assert_balance(provider.as_ref(), 0, 100_000); + + let block = create_block_with_transaction(None, &tx); + provider.block_process(&block, 0).unwrap(); + + assert_balance(provider.as_ref(), 1, 100_000); + + add_blocks_to_provider(provider.as_ref(), 5); + + assert_balance(provider.as_ref(), 6, 100_000); +} + +#[test] +fn test_get_balances_empty_wallet() { + let provider = create_test_provider_initialized(); + + let balance = provider + .get_balances(HashSet::from([DESCRIPTOR_ID.to_string()])) + .unwrap(); + + assert_eq!(balance.immature, Amount::from_sat(0)); + assert_eq!(balance.trusted, Amount::from_sat(0)); + assert_eq!(balance.untrusted_pending, Amount::from_sat(0)); +} + +#[test] +fn test_get_balance_with_zero_minconf() { + let provider = create_test_provider_initialized(); + + let balance = provider + .get_balance( + HashSet::from([DESCRIPTOR_ID.to_string()]), + GetBalanceParams { + minconf: 0, + avoid_reuse: false, + }, + ) + .unwrap(); + + assert_eq!(balance, Amount::from_sat(0)); +} + +#[test] +fn test_sent_and_received_empty_wallet() { + let provider = create_test_provider_initialized(); + + let nonexistent_txid = + Txid::from_str("0000000000000000000000000000000000000000000000000000000000000000").unwrap(); + + let result = provider.sent_and_received( + HashSet::from([DESCRIPTOR_ID.to_string()]), + &nonexistent_txid, + ); + + assert!(result.is_err(), "Should fail for nonexistent transaction"); +} + +#[test] +fn test_get_txo_with_unspent_filter() { + let provider = create_test_provider_initialized(); + + let outpoint = OutPoint { + txid: Txid::from_str("0000000000000000000000000000000000000000000000000000000000000000") + .unwrap(), + vout: 0, + }; + + let result = provider.get_txo(&outpoint, Some(false)); + + assert!(result.is_ok(), "Should handle unspent filter"); + assert!( + result.unwrap().is_none(), + "Should return None for nonexistent UTXO" + ); +} + +#[test] +fn test_get_txo_with_spent_filter() { + let provider = create_test_provider_initialized(); + + let outpoint = OutPoint { + txid: Txid::from_str("0000000000000000000000000000000000000000000000000000000000000000") + .unwrap(), + vout: 0, + }; + + let result = provider.get_txo(&outpoint, Some(true)); + + assert!(result.is_ok(), "Should handle spent filter"); + assert!( + result.unwrap().is_none(), + "Should return None for nonexistent output" + ); +} + +#[test] +fn test_get_txo_with_no_filter() { + let provider = create_test_provider_initialized(); + + let outpoint = OutPoint { + txid: Txid::from_str("0000000000000000000000000000000000000000000000000000000000000000") + .unwrap(), + vout: 0, + }; + + let result = provider.get_txo(&outpoint, None); + + assert!(result.is_ok(), "Should handle no filter"); + assert!( + result.unwrap().is_none(), + "Should return None for nonexistent output" + ); +} + +#[test] +fn test_get_script_hash_txos_empty() { + let provider = create_test_provider_initialized(); + + let script = ScriptBuf::new(); + + let outputs = provider.get_local_output_by_script(script, None); + + assert!( + outputs.is_ok(), + "Should successfully get script hash outputs" + ); + assert!( + outputs.unwrap().is_empty(), + "Empty wallet should have no outputs" + ); +} + +#[test] +fn test_get_script_hash_txos_with_spent_filter() { + let provider = create_test_provider_initialized(); + + let script = ScriptBuf::new(); + + let outputs_spent = provider.get_local_output_by_script(script.clone(), Some(true)); + let outputs_unspent = provider.get_local_output_by_script(script, Some(false)); + + assert!(outputs_spent.is_ok()); + assert!(outputs_unspent.is_ok()); + assert!(outputs_spent.unwrap().is_empty()); + assert!(outputs_unspent.unwrap().is_empty()); +} + +#[test] +fn test_process_mempool_transactions_empty() { + let provider = create_test_provider_initialized(); + + let events = provider.process_mempool_transactions(vec![]); + + assert!(events.is_ok(), "Should handle empty mempool transactions"); + assert!( + events.unwrap().is_empty(), + "Empty transaction list should return empty events" + ); +} + +#[test] +fn test_new_address_after_descriptor() { + let provider = create_test_provider_initialized(); + + let address_result = provider.new_address(DESCRIPTOR_ID); + + assert!( + address_result.is_ok(), + "Should successfully generate new address" + ); + + let address = address_result.unwrap(); + // Verify it's a valid address by checking it can be converted to string + let addr_str = address.to_string(); + assert!( + !addr_str.is_empty(), + "Address should have valid string representation" + ); +} + +#[test] +fn test_new_address_for_each_descriptor() { + let provider = create_test_provider_initialized(); + + let addr1 = provider.new_address(DESCRIPTOR_ID).unwrap(); + let addr2 = provider.new_address(DESCRIPTOR_SECOND_ID).unwrap(); + + // Addresses from different descriptors may be different + // (although they could theoretically be the same in rare cases) + let addr1_str = addr1.to_string(); + let addr2_str = addr2.to_string(); + assert!(!addr1_str.is_empty()); + assert!(!addr2_str.is_empty()); +} + +#[test] +fn test_descriptor_persistence_through_reload() { + let mut provider = create_test_provider(); + + // First descriptor + provider + .persist_descriptor(DESCRIPTOR_ID, DESCRIPTOR) + .unwrap(); + + // Verify it's in the keychain + check_descriptor_in_keychain(provider.as_ref(), DESCRIPTOR_ID, DESCRIPTOR); + + // Add second descriptor + provider + .persist_descriptor(DESCRIPTOR_SECOND_ID, DESCRIPTOR_SECOND) + .unwrap(); + + // Both should be present + check_descriptor_in_keychain(provider.as_ref(), DESCRIPTOR_ID, DESCRIPTOR); + + check_descriptor_in_keychain(provider.as_ref(), DESCRIPTOR_SECOND_ID, DESCRIPTOR_SECOND); +} + +#[test] +fn test_list_script_buff_with_nonexistent_keychain_id() { + let provider = create_test_provider_initialized(); + + let result = provider.list_script_buff(Some(HashSet::from(["nonexistent".to_string()]))); + + assert!(result.is_ok(), "Should handle nonexistent keychain ID"); + assert!( + result.unwrap().is_empty(), + "Should return empty for nonexistent keychain" + ); +} + +#[test] +fn test_list_script_buff_with_multiple_ids() { + let provider = create_test_provider_initialized(); + + let ids = HashSet::from([DESCRIPTOR_ID.to_string(), DESCRIPTOR_SECOND_ID.to_string()]); + + let result = provider.list_script_buff(Some(ids)); + + assert!(result.is_ok()); + assert!( + !result.unwrap().is_empty(), + "Should have scripts for both descriptors" + ); +} + +#[test] +fn test_keyring_error_handling() { + let mut provider = create_test_provider(); + + // First descriptor should work + assert!(provider + .persist_descriptor(DESCRIPTOR_ID, DESCRIPTOR) + .is_ok()); + + // Try to add descriptor with duplicate ID + let dup_result = provider.persist_descriptor(DESCRIPTOR_ID, DESCRIPTOR_SECOND); + + assert!(dup_result.is_err()); + assert!(matches!( + dup_result.unwrap_err(), + WalletProviderError::DescriptorAlreadyExists(_) + )); +} + +#[test] +fn test_descriptor_descriptor_conflict() { + let mut provider = create_test_provider(); + + provider + .persist_descriptor(DESCRIPTOR_ID, DESCRIPTOR) + .unwrap(); + + // Same descriptor string, different ID, should also be rejected + let result = provider.persist_descriptor(DESCRIPTOR_SECOND_ID, DESCRIPTOR); + + assert!(result.is_err()); +} + +#[test] +fn test_process_mempool_transactions() { + let provider = create_test_provider_initialized(); + + let wallet_tx = TransactionInner { + outpoint: vec![], + txo: vec![TxOut { + value: Amount::from_sat(10_000_000), + script_pubkey: provider.new_address(DESCRIPTOR_ID).unwrap().script_pubkey(), + }], + } + .to_transaction(); + + let wallet_tx_spent = TransactionInner { + outpoint: vec![OutPoint { + txid: wallet_tx.compute_txid(), + vout: 0, + }], + txo: vec![], + } + .to_transaction(); + + let non_wallet_tx = TransactionInner { + outpoint: vec![], + txo: vec![TxOut { + value: Amount::from_sat(10_000_000), + script_pubkey: create_script_buff(), + }], + } + .to_transaction(); + + let non_wallet_tx_spent = TransactionInner { + outpoint: vec![OutPoint { + txid: non_wallet_tx.compute_txid(), + vout: 0, + }], + txo: vec![], + } + .to_transaction(); + + let events = provider + .process_mempool_transactions(vec![ + &wallet_tx, + &wallet_tx_spent, + &non_wallet_tx, + &non_wallet_tx_spent, + ]) + .unwrap(); + + assert_eq!(events.len(), 3); + + let event = WalletProviderEvent::UpdateTransaction { + tx: wallet_tx.clone(), + output: wallet_tx.clone().output[0].clone(), + }; + assert_eq!(events[0], event); + + let event = WalletProviderEvent::UnconfirmedTransactionInBlock { + tx: wallet_tx.clone(), + }; + assert_eq!(events[1], event); + + let event = WalletProviderEvent::UnconfirmedTransactionInBlock { + tx: wallet_tx_spent.clone(), + }; + assert_eq!(events[2], event); +} + +#[test] +fn test_process_mempool_transactions_with_non_wallet_tx() { + let provider = create_test_provider_initialized(); + + let non_wallet_tx = TransactionInner { + outpoint: vec![], + txo: vec![TxOut { + value: Amount::from_sat(10_000_000), + script_pubkey: create_script_buff(), + }], + } + .to_transaction(); + + let non_wallet_tx_spent = TransactionInner { + outpoint: vec![OutPoint { + txid: non_wallet_tx.compute_txid(), + vout: 0, + }], + txo: vec![], + } + .to_transaction(); + + let events = provider + .process_mempool_transactions(vec![&non_wallet_tx, &non_wallet_tx_spent]) + .unwrap(); + + assert!( + events.is_empty(), + "Non-wallet transaction should not generate events" + ); +} + +#[test] +fn test_process_block() { + let provider = create_test_provider_initialized(); + + let wallet_tx = TransactionInner { + outpoint: vec![], + txo: vec![TxOut { + value: Amount::from_sat(10_000_000), + script_pubkey: provider.new_address(DESCRIPTOR_ID).unwrap().script_pubkey(), + }], + } + .to_transaction(); + + let wallet_tx_spent = TransactionInner { + outpoint: vec![OutPoint { + txid: wallet_tx.compute_txid(), + vout: 0, + }], + txo: vec![], + } + .to_transaction(); + + let non_wallet_tx = TransactionInner { + outpoint: vec![], + txo: vec![TxOut { + value: Amount::from_sat(10_000_000), + script_pubkey: create_script_buff(), + }], + } + .to_transaction(); + + let non_wallet_tx_spent = TransactionInner { + outpoint: vec![OutPoint { + txid: non_wallet_tx.compute_txid(), + vout: 0, + }], + txo: vec![], + } + .to_transaction(); + + let block = create_block_with_transactions( + None, + vec![ + wallet_tx.clone(), + wallet_tx_spent.clone(), + non_wallet_tx.clone(), + non_wallet_tx_spent.clone(), + ], + ); + + let events = provider.block_process(&block, 0).unwrap(); + + assert_eq!(events.len(), 3); + + let event = WalletProviderEvent::UpdateTransaction { + tx: wallet_tx.clone(), + output: wallet_tx.clone().output[0].clone(), + }; + assert_eq!(events[0], event); + + let event = WalletProviderEvent::ConfirmedTransaction { + tx: wallet_tx.clone(), + }; + assert_eq!(events[1], event); + + let event = WalletProviderEvent::ConfirmedTransaction { + tx: wallet_tx_spent.clone(), + }; + assert_eq!(events[2], event); +} From 7f9b4b6cc245cef12a995daca1c463f9264b31df Mon Sep 17 00:00:00 2001 From: moisesPomilio <93723302+moisesPompilio@users.noreply.github.com> Date: Wed, 8 Apr 2026 23:57:18 -0300 Subject: [PATCH 2/5] feat(wallet)!: introduce SQLite repository abstraction for wallet persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repository layer establishes a higher-level persistence abstraction for the watch-only wallet, centralizing all data storage operations. It manages wallet metadata, descriptor configurations, transaction indexing, and script tracking—providing a clean interface between the wallet service and the underlying database backend. Core responsibilities: - Persist wallet names and lifecycle management - Store descriptors associated with each wallet with their metadata (active status, change flag, labels) - Maintain descriptor configurations and derivation information - Index and retrieve transactions for Electrum protocol support - Track script buffers and derive addresses for transaction monitoring - Provide auxiliary transaction data structures needed for Electrum responses Implementation: - Added SQLiteRepository backed by rusqlite with migration-based schema initialization - Designed comprehensive WalletPersist trait defining the repository interface - Implemented wallet CRUD operations supporting multi-wallet environments - Created database schema with normalized tables for wallets, descriptors, transactions, and script buffers - Established foreign key constraints for data integrity Test coverage: - Wallet creation, listing, and deletion operations - Descriptor lifecycle (persist, retrieve, update, deduplication) - Multi-wallet descriptor management and isolation - Transaction indexing and querying - Script buffer operations and state tracking - Edge cases and error conditions Dependencies: - rusqlite: SQLite driver for Rust - refinery: Database migration management --- Cargo.lock | 59 + crates/floresta-watch-only/Cargo.toml | 5 +- .../migrations/V1__initial_schema.sql | 31 + crates/floresta-watch-only/src/lib.rs | 4 +- .../floresta-watch-only/src/repository/mod.rs | 186 +++ .../src/repository/sqlite.rs | 1112 +++++++++++++++++ 6 files changed, 1395 insertions(+), 2 deletions(-) create mode 100644 crates/floresta-watch-only/migrations/V1__initial_schema.sql create mode 100644 crates/floresta-watch-only/src/repository/mod.rs create mode 100644 crates/floresta-watch-only/src/repository/sqlite.rs diff --git a/Cargo.lock b/Cargo.lock index 27543b08a..06f745c97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1247,6 +1247,8 @@ dependencies = [ "floresta-common", "kv", "rand", + "refinery", + "rusqlite", "serde", "serde_json", "tracing", @@ -2490,6 +2492,50 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "refinery" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba5d693abf62492c37268512ff35b77655d2e957ca53dab85bf993fe9172d15" +dependencies = [ + "refinery-core", + "refinery-macros", +] + +[[package]] +name = "refinery-core" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a83581f18c1a4c3a6ebd7a174bdc665f17f618d79f7edccb6a0ac67e660b319" +dependencies = [ + "async-trait", + "cfg-if", + "log", + "regex", + "rusqlite", + "serde", + "siphasher", + "thiserror 1.0.69", + "time", + "toml 0.8.23", + "url", + "walkdir", +] + +[[package]] +name = "refinery-macros" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72c225407d8e52ef8cf094393781ecda9a99d6544ec28d90a6915751de259264" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "refinery-core", + "regex", + "syn", +] + [[package]] name = "regex" version = "1.12.3" @@ -2794,6 +2840,12 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + [[package]] name = "slab" version = "0.4.12" @@ -3161,6 +3213,7 @@ dependencies = [ "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", + "toml_write", "winnow", ] @@ -3173,6 +3226,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "toml_writer" version = "1.0.6+spec-1.1.0" diff --git a/crates/floresta-watch-only/Cargo.toml b/crates/floresta-watch-only/Cargo.toml index f24bde188..823e73c36 100644 --- a/crates/floresta-watch-only/Cargo.toml +++ b/crates/floresta-watch-only/Cargo.toml @@ -17,6 +17,8 @@ serde = { workspace = true } serde_json = { workspace = true, features = ["alloc"] } tracing = { workspace = true } bdk_wallet = { optional = true, git = "https://github.com/thunderbiscuit/bdk_wallet", branch = "feature/multi-keychain-wallet", features = ["rusqlite"] } +rusqlite = { version = "0.31", optional = true, features = [ "bundled" ], default-features = false } +refinery = { version = "0.8.0", optional = true, features = ["rusqlite"] } # Local dependencies floresta-chain = { workspace = true } @@ -26,8 +28,9 @@ floresta-common = { workspace = true, features = ["descriptors-no-std"] } rand = { workspace = true } [features] -default = ["std", "bdk-provider"] +default = ["std", "sqlite", "bdk-provider"] memory-database = [] +sqlite = ["rusqlite", "refinery"] bdk-provider = ["bdk_wallet"] # The default features in common are `std` and `descriptors-std` (which is a superset of `descriptors-no-std`) std = ["floresta-common/default", "serde/std"] diff --git a/crates/floresta-watch-only/migrations/V1__initial_schema.sql b/crates/floresta-watch-only/migrations/V1__initial_schema.sql new file mode 100644 index 000000000..81123b7b9 --- /dev/null +++ b/crates/floresta-watch-only/migrations/V1__initial_schema.sql @@ -0,0 +1,31 @@ +-- Create wallets table +CREATE TABLE IF NOT EXISTS wallets ( + name TEXT PRIMARY KEY +); + +-- Create descriptors table +CREATE TABLE IF NOT EXISTS descriptors ( + wallet_id TEXT NOT NULL, + id TEXT NOT NULL, + descriptor TEXT NOT NULL, + label TEXT, + is_active BOOLEAN NOT NULL, + is_change BOOLEAN NOT NULL, + PRIMARY KEY (wallet_id, id), + FOREIGN KEY (wallet_id) REFERENCES wallets(name) ON DELETE CASCADE +); + +-- Create transactions table +CREATE TABLE IF NOT EXISTS transactions ( + hash TEXT PRIMARY KEY, + tx BLOB NOT NULL, + height INTEGER, + merkle_block BLOB, + position INTEGER +); + +-- Create script_buffers table +CREATE TABLE IF NOT EXISTS script_buffers ( + hash TEXT PRIMARY KEY, + script BLOB NOT NULL UNIQUE +); \ No newline at end of file diff --git a/crates/floresta-watch-only/src/lib.rs b/crates/floresta-watch-only/src/lib.rs index ae806727c..4ed38304c 100644 --- a/crates/floresta-watch-only/src/lib.rs +++ b/crates/floresta-watch-only/src/lib.rs @@ -27,6 +27,7 @@ pub mod kv_database; pub mod memory_database; pub mod merkle; pub mod provider; +pub mod repository; use bitcoin::consensus::deserialize; use bitcoin::consensus::encode::serialize_hex; @@ -1012,7 +1013,8 @@ mod test { } } -#[cfg(all(test, feature = "bdk-provider"))] +#[allow(clippy::non_minimal_cfg)] +#[cfg(all(test, any(feature = "bdk-provider", feature = "sqlite")))] pub mod utils { use bitcoin::hashes::sha256d; diff --git a/crates/floresta-watch-only/src/repository/mod.rs b/crates/floresta-watch-only/src/repository/mod.rs new file mode 100644 index 000000000..c60841d52 --- /dev/null +++ b/crates/floresta-watch-only/src/repository/mod.rs @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +#![deny(clippy::unwrap_used)] + +use core::fmt::Debug; +use core::fmt::Display; +use core::fmt::Formatter; + +use bitcoin::hash_types::Txid; +use bitcoin::hashes::sha256::Hash; +use bitcoin::ScriptBuf; +use bitcoin::Transaction; +use floresta_common::prelude::*; + +use crate::merkle::MerkleProof; + +#[cfg(feature = "sqlite")] +pub mod sqlite; + +#[cfg(feature = "sqlite")] +pub fn new_repository(db_path: &str) -> Result, WalletRepositoryError> { + let repo = sqlite::SqliteRepository::new(db_path)?; + + Ok(Box::new(repo)) +} + +// Represents a Bitcoin descriptor that can be used to derive addresses. +// A descriptor is associated with a wallet and can be marked as active for transaction generation and +// address derivation. +#[derive(Debug, Clone)] +pub struct DbDescriptor { + // The wallet that owns this descriptor + pub wallet: String, + + // Unique identifier for this descriptor within its wallet + pub id: String, + + // The descriptor string defining how addresses are derived (e.g., "wpkh(...)") + pub descriptor: String, + + // Optional human-readable label for this descriptor + pub label: Option, + + // Whether this descriptor is currently active for transaction generation and address derivation + pub is_active: bool, + + // Whether this is a change address descriptor (used for change outputs) + pub is_change: bool, +} + +// Represents a Bitcoin transaction persisted in the database. +// Includes the transaction data along with confirmation information. +#[derive(Debug, Clone)] +pub struct DbTransaction { + // The full transaction data + pub tx: Transaction, + + // Block height at which the transaction was confirmed (None if unconfirmed) + pub height: Option, + + // Merkle proof proving inclusion in a block (None if unconfirmed) + pub merkle_block: Option, + + // The transaction ID (hash) + pub hash: Txid, + + // Position of the transaction within its block (None if unconfirmed) + pub position: Option, +} + +#[derive(Debug, Clone)] +pub struct DbScriptBuffer { + pub script: ScriptBuf, + pub hash: Hash, +} + +#[derive(Debug)] +pub enum WalletRepositoryError { + // Error during database initialization or configuration + SetupError(String), + + // Error when inserting data into the database + InsertError(String), + + // Error when updating existing data in the database + UpdateError(String), + + // Error when deleting data from the database + DeleteError(String), + + // Error when a requested item was not found in the database + NotFound(String), + + // Generic error for other database operations + Other(String), +} + +impl Display for WalletRepositoryError { + fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result { + match self { + WalletRepositoryError::SetupError(msg) => { + write!(f, "Database setup error: {}", msg) + } + WalletRepositoryError::InsertError(msg) => { + write!(f, "Failed to insert data: {}", msg) + } + WalletRepositoryError::UpdateError(msg) => { + write!(f, "Failed to update data: {}", msg) + } + WalletRepositoryError::DeleteError(msg) => { + write!(f, "Failed to delete data: {}", msg) + } + WalletRepositoryError::NotFound(msg) => { + write!(f, "Data not found: {}", msg) + } + WalletRepositoryError::Other(msg) => { + write!(f, "Database error: {}", msg) + } + } + } +} + +pub trait WalletRepository: Send + Sync { + // Creates a new wallet with the given name and returns its ID + fn create_wallet(&self, name: &str) -> Result; + + // Returns a list of all wallet names stored in the database + fn list_wallets(&self) -> Result, WalletRepositoryError>; + + // Removes a wallet and all its associated data from the database + fn delete_wallet(&self, name: &str) -> Result<(), WalletRepositoryError>; + + // Stores a new descriptor in the database or updates it if it already exists + fn insert_or_update_descriptor( + &self, + descriptor: &DbDescriptor, + ) -> Result<(), WalletRepositoryError>; + + // Retrieves a specific descriptor by ID, optionally filtered by wallet name + fn get_descriptor( + &self, + id: &str, + wallet: Option<&str>, + ) -> Result; + + // Checks whether a descriptor exists, supports flexible filtering: + // - Both id and wallet: checks if descriptor with specific ID exists in specific wallet + // - Only id: checks if descriptor with that ID exists in any wallet + // - Only wallet: checks if wallet has any descriptors + // - Neither: checks if any descriptor exists in the database + fn exists_descriptor( + &self, + id: Option<&str>, + wallet: Option<&str>, + ) -> Result; + + // Loads all descriptors associated with a specific wallet + fn load_wallet(&self, wallet: &str) -> Result, WalletRepositoryError>; + + // Stores a new transaction in the database or updates it if it already exists + fn insert_or_update_transaction( + &self, + transaction: &DbTransaction, + ) -> Result<(), WalletRepositoryError>; + + // Retrieves a specific transaction by its transaction ID + fn get_transaction(&self, txid: &Txid) -> Result; + + // Returns all transactions stored in the database + fn list_transactions(&self) -> Result, WalletRepositoryError>; + + // Inserts or updates a script buffer in the database + fn insert_or_update_script_buffer( + &self, + script_buffer: &DbScriptBuffer, + ) -> Result<(), WalletRepositoryError>; + + // Retrieves a script buffer by its hash + fn get_script_buffer(&self, hash: &Hash) -> Result; + + // Lists all script buffers stored in the database + fn list_script_buffers(&self) -> Result, WalletRepositoryError>; + + // Deletes a script buffer by its hash + fn delete_script_buffer(&self, hash: &Hash) -> Result<(), WalletRepositoryError>; +} diff --git a/crates/floresta-watch-only/src/repository/sqlite.rs b/crates/floresta-watch-only/src/repository/sqlite.rs new file mode 100644 index 000000000..b56facd62 --- /dev/null +++ b/crates/floresta-watch-only/src/repository/sqlite.rs @@ -0,0 +1,1112 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +#![deny(clippy::unwrap_used)] + +use std::str::FromStr; +use std::sync::Mutex; + +use bitcoin::consensus::deserialize; +use bitcoin::consensus::serialize; +use bitcoin::hash_types::Txid; +use bitcoin::hashes::sha256::Hash; +use refinery::embed_migrations; +use rusqlite::params; +use rusqlite::Connection; +use rusqlite::Result as SqliteResult; + +use super::DbDescriptor; +use super::DbScriptBuffer; +use super::DbTransaction; +use super::WalletRepository; +use super::WalletRepositoryError; + +embed_migrations!("migrations"); + +pub struct SqliteRepository { + conn: Mutex, +} + +impl SqliteRepository { + /// Creates a new SQLite persister, initializing database schema if needed + pub fn new(db_path: &str) -> Result { + let conn = Connection::open(db_path) + .map_err(|e| WalletRepositoryError::SetupError(e.to_string()))?; + + Self::setup(conn) + } + + /// In-memory SQLite for testing + #[cfg(test)] + pub fn in_memory() -> Result { + let conn = Connection::open_in_memory() + .map_err(|e| WalletRepositoryError::SetupError(e.to_string()))?; + + Self::setup(conn) + } + + fn setup(conn: Connection) -> Result { + // Enable foreign key constraints + conn.execute("PRAGMA foreign_keys = ON", []) + .map_err(|e| WalletRepositoryError::SetupError(e.to_string()))?; + + let persister = SqliteRepository { + conn: Mutex::new(conn), + }; + persister.run_migrations()?; + Ok(persister) + } + + /// Acquires a lock on the database connection + fn get_connection( + &self, + ) -> Result, WalletRepositoryError> { + self.conn.lock().map_err(|e| { + WalletRepositoryError::Other(format!("Failed to acquire database lock: {}", e)) + }) + } + + fn run_migrations(&self) -> Result<(), WalletRepositoryError> { + let mut conn = self.get_connection()?; + + migrations::runner().run(&mut *conn).map_err(|e| { + WalletRepositoryError::SetupError(format!("Failed to run migrations: {}", e)) + })?; + + Ok(()) + } +} + +impl WalletRepository for SqliteRepository { + fn create_wallet(&self, name: &str) -> Result { + let conn = self.get_connection()?; + + conn.execute("INSERT INTO wallets (name) VALUES (?1)", params![name]) + .map_err(|e| { + WalletRepositoryError::InsertError(format!("Failed to create wallet: {}", e)) + })?; + + Ok(name.to_string()) + } + + fn list_wallets(&self) -> Result, WalletRepositoryError> { + let conn = self.get_connection()?; + + let mut stmt = conn + .prepare("SELECT name FROM wallets") + .map_err(|e| WalletRepositoryError::Other(e.to_string()))?; + + let wallets = stmt + .query_map([], |row| row.get::<_, String>(0)) + .map_err(|e| WalletRepositoryError::Other(e.to_string()))? + .collect::>>() + .map_err(|e| WalletRepositoryError::Other(e.to_string()))?; + + Ok(wallets) + } + + fn insert_or_update_descriptor( + &self, + descriptor: &DbDescriptor, + ) -> Result<(), WalletRepositoryError> { + let conn = self.get_connection()?; + + conn.execute( + "INSERT OR REPLACE INTO descriptors (wallet_id, id, descriptor, label, is_active, is_change) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + &descriptor.wallet, + &descriptor.id, + &descriptor.descriptor, + &descriptor.label, + descriptor.is_active, + descriptor.is_change + ], + ) + .map_err(|e| { + WalletRepositoryError::InsertError(format!( + "Failed to insert or update descriptor: {}", + e + )) + })?; + + Ok(()) + } + + fn get_descriptor( + &self, + id: &str, + wallet: Option<&str>, + ) -> Result { + let conn = self.get_connection()?; + + let query = if let Some(wallet_name) = wallet { + // Specific descriptor in specific wallet + let mut stmt = conn + .prepare( + "SELECT wallet_id, id, descriptor, label, is_active, is_change + FROM descriptors WHERE id = ?1 AND wallet_id = ?2", + ) + .map_err(|e| WalletRepositoryError::Other(e.to_string()))?; + + stmt.query_row(params![id, wallet_name], |row| { + Ok(DbDescriptor { + wallet: row.get(0)?, + id: row.get(1)?, + descriptor: row.get(2)?, + label: row.get(3)?, + is_active: row.get(4)?, + is_change: row.get(5)?, + }) + }) + .map_err(|e| { + WalletRepositoryError::NotFound(format!( + "Descriptor {} not found in wallet {}: {}", + id, wallet_name, e + )) + })? + } else { + // First descriptor with this id across all wallets + let mut stmt = conn + .prepare( + "SELECT wallet_id, id, descriptor, label, is_active, is_change + FROM descriptors WHERE id = ?1 LIMIT 1", + ) + .map_err(|e| WalletRepositoryError::Other(e.to_string()))?; + + stmt.query_row(params![id], |row| { + Ok(DbDescriptor { + wallet: row.get(0)?, + id: row.get(1)?, + descriptor: row.get(2)?, + label: row.get(3)?, + is_active: row.get(4)?, + is_change: row.get(5)?, + }) + }) + .map_err(|e| { + WalletRepositoryError::NotFound(format!("Descriptor {} not found: {}", id, e)) + })? + }; + + Ok(query) + } + + fn load_wallet(&self, name: &str) -> Result, WalletRepositoryError> { + let conn = self.get_connection()?; + + let mut stmt = conn + .prepare( + "SELECT wallet_id, id, descriptor, label, is_active, is_change + FROM descriptors WHERE wallet_id = ?1", + ) + .map_err(|e| WalletRepositoryError::Other(e.to_string()))?; + + let descriptors = stmt + .query_map(params![name], |row| { + Ok(DbDescriptor { + wallet: row.get(0)?, + id: row.get(1)?, + descriptor: row.get(2)?, + label: row.get(3)?, + is_active: row.get(4)?, + is_change: row.get(5)?, + }) + }) + .map_err(|e| WalletRepositoryError::Other(e.to_string()))? + .collect::>>() + .map_err(|e| WalletRepositoryError::Other(e.to_string()))?; + + Ok(descriptors) + } + + fn exists_descriptor( + &self, + id: Option<&str>, + wallet: Option<&str>, + ) -> Result { + let conn = self.get_connection()?; + + match (id, wallet) { + // Both id and wallet provided: check for specific descriptor in specific wallet + (Some(desc_id), Some(wallet_name)) => { + let mut stmt = conn + .prepare("SELECT COUNT(*) FROM descriptors WHERE id = ?1 AND wallet_id = ?2") + .map_err(|e| WalletRepositoryError::Other(e.to_string()))?; + + let count: i64 = stmt + .query_row(params![desc_id, wallet_name], |row| row.get(0)) + .map_err(|e| WalletRepositoryError::Other(e.to_string()))?; + + Ok(count > 0) + } + // Only id provided: check if descriptor exists with that id across all wallets + (Some(desc_id), None) => { + let mut stmt = conn + .prepare("SELECT COUNT(*) FROM descriptors WHERE id = ?1") + .map_err(|e| WalletRepositoryError::Other(e.to_string()))?; + + let count: i64 = stmt + .query_row(params![desc_id], |row| row.get(0)) + .map_err(|e| WalletRepositoryError::Other(e.to_string()))?; + + Ok(count > 0) + } + // Only wallet provided: check if wallet has any descriptors + (None, Some(wallet_name)) => { + let mut stmt = conn + .prepare("SELECT COUNT(*) FROM descriptors WHERE wallet_id = ?1") + .map_err(|e| WalletRepositoryError::Other(e.to_string()))?; + + let count: i64 = stmt + .query_row(params![wallet_name], |row| row.get(0)) + .map_err(|e| WalletRepositoryError::Other(e.to_string()))?; + + Ok(count > 0) + } + // Neither provided: check if any descriptor exists in database + (None, None) => { + let mut stmt = conn + .prepare("SELECT COUNT(*) FROM descriptors") + .map_err(|e| WalletRepositoryError::Other(e.to_string()))?; + + let count: i64 = stmt + .query_row([], |row| row.get(0)) + .map_err(|e| WalletRepositoryError::Other(e.to_string()))?; + + Ok(count > 0) + } + } + } + + fn insert_or_update_transaction( + &self, + transaction: &DbTransaction, + ) -> Result<(), WalletRepositoryError> { + let conn = self.get_connection()?; + + let tx_bytes = serialize(&transaction.tx); + let hash_bytes = transaction.hash.to_string(); + let merkle_bytes = transaction + .merkle_block + .as_ref() + .and_then(|m| serde_json::to_vec(m).ok()); + + conn.execute( + "INSERT OR REPLACE INTO transactions (hash, tx, height, merkle_block, position) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + hash_bytes, + tx_bytes, + transaction.height, + merkle_bytes, + transaction.position + ], + ) + .map_err(|e| { + WalletRepositoryError::InsertError(format!( + "Failed to insert or update transaction: {}", + e + )) + })?; + + Ok(()) + } + + fn get_transaction(&self, txid: &Txid) -> Result { + let conn = self.get_connection()?; + + let hash_bytes = txid.to_string(); + let mut stmt = conn + .prepare("SELECT tx, height, merkle_block, position FROM transactions WHERE hash = ?1") + .map_err(|e| WalletRepositoryError::Other(e.to_string()))?; + + let transaction = stmt + .query_row(params![hash_bytes], |row| { + let tx_bytes: Vec = row.get(0)?; + let height: Option = row.get(1)?; + let merkle_bytes: Option> = row.get(2)?; + let position: Option = row.get(3)?; + + let tx = deserialize(&tx_bytes).map_err(|_| rusqlite::Error::InvalidQuery)?; + + let merkle_block = merkle_bytes.and_then(|b| serde_json::from_slice(&b).ok()); + + Ok(DbTransaction { + tx, + height, + merkle_block, + hash: *txid, + position, + }) + }) + .map_err(|e| { + WalletRepositoryError::NotFound(format!("Transaction {} not found: {}", txid, e)) + })?; + + Ok(transaction) + } + + fn list_transactions(&self) -> Result, WalletRepositoryError> { + let conn = self.get_connection()?; + + let mut stmt = conn + .prepare("SELECT hash, tx, height, merkle_block, position FROM transactions") + .map_err(|e| WalletRepositoryError::Other(e.to_string()))?; + + let transactions = stmt + .query_map([], |row| { + let hash_string: String = row.get(0)?; + let tx_bytes: Vec = row.get(1)?; + let height: Option = row.get(2)?; + let merkle_bytes: Option> = row.get(3)?; + let position: Option = row.get(4)?; + + let tx = deserialize(&tx_bytes).map_err(|_| rusqlite::Error::InvalidQuery)?; + + let hash = + Txid::from_str(&hash_string).map_err(|_| rusqlite::Error::InvalidQuery)?; + + let merkle_block = merkle_bytes.and_then(|b| serde_json::from_slice(&b).ok()); + + Ok(DbTransaction { + tx, + height, + merkle_block, + hash, + position, + }) + }) + .map_err(|e| WalletRepositoryError::Other(e.to_string()))? + .collect::>>() + .map_err(|e| WalletRepositoryError::Other(e.to_string()))?; + + Ok(transactions) + } + + fn delete_wallet(&self, name: &str) -> Result<(), WalletRepositoryError> { + let conn = self.get_connection()?; + + let rows_affected = conn + .execute("DELETE FROM wallets WHERE name = ?1", params![name]) + .map_err(|e| { + WalletRepositoryError::DeleteError(format!("Failed to delete wallet: {}", e)) + })?; + + if rows_affected == 0 { + return Err(WalletRepositoryError::NotFound(format!( + "Wallet {} not found", + name + ))); + } + + Ok(()) + } + + fn insert_or_update_script_buffer( + &self, + script_buffer: &DbScriptBuffer, + ) -> Result<(), WalletRepositoryError> { + let conn = self.get_connection()?; + + let script_bytes = serialize(&script_buffer.script); + let hash_string = script_buffer.hash.to_string(); + + conn.execute( + "INSERT OR REPLACE INTO script_buffers (hash, script) VALUES (?1, ?2)", + params![hash_string, script_bytes], + ) + .map_err(|e| { + WalletRepositoryError::InsertError(format!( + "Failed to insert or update script buffer: {}", + e + )) + })?; + + Ok(()) + } + + fn get_script_buffer(&self, hash: &Hash) -> Result { + let conn = self.get_connection()?; + + let hash_string = hash.to_string(); + let mut stmt = conn + .prepare("SELECT script FROM script_buffers WHERE hash = ?1") + .map_err(|e| WalletRepositoryError::Other(e.to_string()))?; + + let script_buffer = stmt + .query_row(params![hash_string], |row| { + let script_bytes: Vec = row.get(0)?; + let script = + deserialize(&script_bytes).map_err(|_| rusqlite::Error::InvalidQuery)?; + + Ok(DbScriptBuffer { + script, + hash: *hash, + }) + }) + .map_err(|e| { + WalletRepositoryError::NotFound(format!("Script buffer {} not found: {}", hash, e)) + })?; + + Ok(script_buffer) + } + + fn list_script_buffers(&self) -> Result, WalletRepositoryError> { + let conn = self.get_connection()?; + + let mut stmt = conn + .prepare("SELECT hash, script FROM script_buffers") + .map_err(|e| WalletRepositoryError::Other(e.to_string()))?; + + let script_buffers = stmt + .query_map([], |row| { + let hash_string: String = row.get(0)?; + let script_bytes: Vec = row.get(1)?; + + let script = + deserialize(&script_bytes).map_err(|_| rusqlite::Error::InvalidQuery)?; + let hash = + Hash::from_str(&hash_string).map_err(|_| rusqlite::Error::InvalidQuery)?; + + Ok(DbScriptBuffer { script, hash }) + }) + .map_err(|e| WalletRepositoryError::Other(e.to_string()))? + .collect::>>() + .map_err(|e| WalletRepositoryError::Other(e.to_string()))?; + + Ok(script_buffers) + } + + fn delete_script_buffer(&self, hash: &Hash) -> Result<(), WalletRepositoryError> { + let conn = self.get_connection()?; + + let hash_string = hash.to_string(); + let rows_affected = conn + .execute( + "DELETE FROM script_buffers WHERE hash = ?1", + params![hash_string], + ) + .map_err(|e| { + WalletRepositoryError::DeleteError(format!("Failed to delete script buffer: {}", e)) + })?; + + if rows_affected == 0 { + return Err(WalletRepositoryError::NotFound(format!( + "Script buffer {} not found", + hash + ))); + } + + Ok(()) + } +} + +#[allow(clippy::unwrap_used)] +#[cfg(test)] +mod tests { + + use bitcoin::hashes::Hash as HashTrait; + use bitcoin::script::Builder; + use bitcoin::ScriptBuf; + + use super::*; + use crate::utils::create_test_transaction; + use crate::utils::create_test_transaction_with_seed; + + fn create_test_repo() -> SqliteRepository { + SqliteRepository::in_memory().unwrap() + } + + fn create_descriptor_default(wallet: &str, id: u64) -> DbDescriptor { + create_descriptor_info(wallet, id, false, true, false) + } + + fn create_descriptor_info( + wallet: &str, + id: u64, + label: bool, + is_active: bool, + is_change: bool, + ) -> DbDescriptor { + DbDescriptor { + wallet: wallet.to_string(), + id: id.to_string(), + descriptor: id.to_string(), + label: if label { + Some(format!("Descriptor {}", id)) + } else { + None + }, + is_active, + is_change, + } + } + + fn setup_wallet_one_descriptor(wallet: &str) -> (SqliteRepository, DbDescriptor) { + let persister = create_test_repo(); + + let descriptor = setup_wallet(wallet, &persister, 1).first().unwrap().clone(); + + (persister, descriptor) + } + + fn setup_wallet( + wallet: &str, + persister: &SqliteRepository, + quantity: u64, + ) -> Vec { + persister.create_wallet(wallet).unwrap(); + + let mut descriptors = Vec::new(); + for i in 0..quantity { + let label = i % 2 == 0; + let is_active = i % 3 == 0; + let is_change = i % 5 == 0; + + let descriptor = create_descriptor_info(wallet, i, label, is_active, is_change); + persister.insert_or_update_descriptor(&descriptor).unwrap(); + descriptors.push(descriptor); + } + descriptors + } + + fn check_descriptor_equality(d1: &DbDescriptor, d2: &DbDescriptor) { + assert_eq!(d1.id, d2.id); + assert_eq!(d1.wallet, d2.wallet); + assert_eq!(d1.descriptor, d2.descriptor); + assert_eq!(d1.label, d2.label); + assert_eq!(d1.is_active, d2.is_active); + assert_eq!(d1.is_change, d2.is_change); + } + + fn check_transaction_equality(t1: &DbTransaction, t2: &DbTransaction) { + assert_eq!(t1.hash, t2.hash); + assert_eq!(t1.height, t2.height); + assert_eq!(t1.position, t2.position); + assert_eq!(t1.merkle_block, t2.merkle_block); + assert_eq!(t1.tx, t2.tx); + } + + #[test] + fn test_create_and_list_wallets() { + let persister = create_test_repo(); + let wallet = "my_wallet"; + + let wallet_id = persister.create_wallet(wallet).unwrap(); + assert_eq!(wallet_id, wallet); + + let wallets = persister.list_wallets().unwrap(); + assert!(wallets.contains(&wallet.to_string())); + } + + #[test] + fn test_delete_wallet() { + let persister = create_test_repo(); + let wallet = "wallet_to_delete"; + persister.create_wallet(wallet).unwrap(); + + let wallets = persister.list_wallets().unwrap(); + assert!(wallets.contains(&wallet.to_string())); + + persister.delete_wallet(wallet).unwrap(); + + let wallets = persister.list_wallets().unwrap(); + assert!(!wallets.contains(&wallet.to_string())); + } + + #[test] + fn test_insert_descriptor() { + let (persister, descriptor) = setup_wallet_one_descriptor("wallet1"); + + let loaded = persister + .get_descriptor(&descriptor.id, Some(&descriptor.wallet)) + .unwrap(); + + check_descriptor_equality(&loaded, &descriptor); + } + + #[test] + fn test_descriptor_operations() { + let wallet = "wallet1"; + let (persister, descriptor) = setup_wallet_one_descriptor(wallet); + + let mut updated = descriptor.clone(); + updated.label = Some("Updated Label".to_string()); + updated.is_active = false; + persister.insert_or_update_descriptor(&updated).unwrap(); + + let reloaded = persister + .get_descriptor(&updated.id, Some(&updated.wallet)) + .unwrap(); + check_descriptor_equality(&reloaded, &updated); + } + + #[test] + fn test_load_multiple_descriptors() { + let persister = create_test_repo(); + let wallet = "wallet1"; + let descriptors1 = setup_wallet(wallet, &persister, 5); + + let wallet2 = "wallet2"; + let descriptors2 = setup_wallet(wallet2, &persister, 3); + + let loaded1 = persister.load_wallet(wallet).unwrap(); + for desc in &descriptors1 { + let loaded_desc = loaded1 + .iter() + .find(|d| d.id == desc.id) + .expect("Descriptor not found in loaded wallet"); + check_descriptor_equality(loaded_desc, desc); + } + + let loaded2 = persister.load_wallet(wallet2).unwrap(); + for desc in &descriptors2 { + let loaded_desc = loaded2 + .iter() + .find(|d| d.id == desc.id) + .expect("Descriptor not found in loaded wallet"); + check_descriptor_equality(loaded_desc, desc); + } + } + + #[test] + fn test_insert_and_get_transaction() { + let persister = create_test_repo(); + let tx = create_test_transaction(); + let txid = tx.compute_txid(); + + let transaction = DbTransaction { + tx: tx.clone(), + height: Some(100), + merkle_block: None, + hash: txid, + position: Some(0), + }; + + persister + .insert_or_update_transaction(&transaction) + .unwrap(); + + let loaded = persister.get_transaction(&txid).unwrap(); + check_transaction_equality(&loaded, &transaction); + } + + #[test] + fn test_update_transaction() { + let persister = create_test_repo(); + let tx = create_test_transaction(); + let txid = tx.compute_txid(); + + let mut transaction = DbTransaction { + tx: tx.clone(), + height: Some(100), + merkle_block: None, + hash: txid, + position: Some(0), + }; + + persister + .insert_or_update_transaction(&transaction) + .unwrap(); + + // Update height and position + transaction.height = Some(101); + transaction.position = Some(5); + persister + .insert_or_update_transaction(&transaction) + .unwrap(); + + let loaded = persister.get_transaction(&txid).unwrap(); + + check_transaction_equality(&loaded, &transaction); + } + + #[test] + fn test_list_transactions() { + let persister = create_test_repo(); + + let tx1 = create_test_transaction(); + let tx2 = create_test_transaction_with_seed(21); + + let txid1 = tx1.compute_txid(); + let txid2 = tx2.compute_txid(); + + let transaction1 = DbTransaction { + tx: tx1, + height: Some(100), + merkle_block: None, + hash: txid1, + position: Some(0), + }; + + let transaction2 = DbTransaction { + tx: tx2, + height: Some(101), + merkle_block: None, + hash: txid2, + position: Some(1), + }; + + persister + .insert_or_update_transaction(&transaction1) + .unwrap(); + persister + .insert_or_update_transaction(&transaction2) + .unwrap(); + + let loaded = persister.list_transactions().unwrap(); + assert_eq!(loaded.len(), 2); + + for tx in [transaction1, transaction2] { + let loaded_tx = loaded + .iter() + .find(|t| t.hash == tx.hash) + .expect("Transaction not found in list"); + check_transaction_equality(loaded_tx, &tx); + } + } + + #[test] + fn test_transaction_not_found() { + let persister = create_test_repo(); + let tx = create_test_transaction(); + let txid = tx.compute_txid(); + + let result = persister.get_transaction(&txid); + assert!(result.is_err()); + } + + #[test] + fn test_exists_descriptor_no_params() { + let persister = create_test_repo(); + let wallet = "wallet1"; + persister.create_wallet(wallet).unwrap(); + + // Should return false when no descriptors exist + let exists = persister.exists_descriptor(None, None).unwrap(); + assert!(!exists); + + // Add a descriptor + let descriptor = create_descriptor_default(wallet, 1); + persister.insert_or_update_descriptor(&descriptor).unwrap(); + + // Should return true when descriptor exists + let exists = persister.exists_descriptor(None, None).unwrap(); + assert!(exists); + } + + #[test] + fn test_exists_descriptor_by_id_only() { + let persister = create_test_repo(); + let wallet = "wallet1"; + persister.create_wallet(wallet).unwrap(); + + let id = 2; + + // Should return false when descriptor doesn't exist + let exists = persister + .exists_descriptor(Some(&id.to_string()), None) + .unwrap(); + assert!(!exists); + + // Add descriptor + let descriptor = create_descriptor_default(wallet, id); + persister.insert_or_update_descriptor(&descriptor).unwrap(); + + // Should return true when descriptor with that id exists + let exists = persister + .exists_descriptor(Some(&id.to_string()), None) + .unwrap(); + assert!(exists); + + // Should return false for non-existent id + let exists = persister + .exists_descriptor(Some(&(id + 1).to_string()), None) + .unwrap(); + assert!(!exists); + } + + #[test] + fn test_exists_descriptor_by_wallet_only() { + let persister = create_test_repo(); + let wallet1 = "wallet1"; + persister.create_wallet(wallet1).unwrap(); + + let wallet2 = "wallet2"; + persister.create_wallet(wallet2).unwrap(); + + // Should return false when wallet has no descriptors + let exists = persister.exists_descriptor(None, Some(wallet1)).unwrap(); + assert!(!exists); + + // Add descriptor to wallet1 + let descriptor = create_descriptor_default(wallet1, 1); + persister.insert_or_update_descriptor(&descriptor).unwrap(); + + // Should return true for wallet1 + let exists = persister.exists_descriptor(None, Some(wallet1)).unwrap(); + assert!(exists); + + // Should still return false for wallet2 + let exists = persister.exists_descriptor(None, Some(wallet2)).unwrap(); + assert!(!exists); + } + + #[test] + fn test_exists_descriptor_by_id_and_wallet() { + let persister = create_test_repo(); + let wallet1 = "wallet1"; + persister.create_wallet(wallet1).unwrap(); + let wallet2 = "wallet2"; + persister.create_wallet(wallet2).unwrap(); + + let id1 = 1; + let id2 = 2; + + // Add descriptor to wallet1 + let descriptor1 = create_descriptor_default(wallet1, id1); + persister.insert_or_update_descriptor(&descriptor1).unwrap(); + + // Add different descriptor to wallet2 + let descriptor2 = create_descriptor_default(wallet2, id2); + persister.insert_or_update_descriptor(&descriptor2).unwrap(); + + // Should return true for desc1 in wallet1 + let exists = persister + .exists_descriptor(Some(&id1.to_string()), Some(wallet1)) + .unwrap(); + assert!(exists); + + // Should return false for desc1 in wallet2 + let exists = persister + .exists_descriptor(Some(&id1.to_string()), Some(wallet2)) + .unwrap(); + assert!(!exists); + + // Should return false for desc2 in wallet1 + let exists = persister + .exists_descriptor(Some(&id2.to_string()), Some(wallet1)) + .unwrap(); + assert!(!exists); + + // Should return true for desc2 in wallet2 + let exists = persister + .exists_descriptor(Some(&id2.to_string()), Some(wallet2)) + .unwrap(); + assert!(exists); + } + + #[test] + fn test_exists_descriptor_across_wallets() { + let persister = create_test_repo(); + let wallet1 = "wallet1"; + persister.create_wallet(wallet1).unwrap(); + let wallet2 = "wallet2"; + persister.create_wallet(wallet2).unwrap(); + + let id = 1; + + // Add same id to different wallets (should be possible as primary key includes wallet) + let descriptor1 = create_descriptor_default(wallet1, id); + let descriptor2 = create_descriptor_default(wallet2, id); + + persister.insert_or_update_descriptor(&descriptor1).unwrap(); + persister.insert_or_update_descriptor(&descriptor2).unwrap(); + + // When querying by id only, should find it + let exists = persister + .exists_descriptor(Some(&id.to_string()), None) + .unwrap(); + assert!(exists); + + // Should find in both wallets specifically + let exists = persister + .exists_descriptor(Some(&id.to_string()), Some(wallet1)) + .unwrap(); + assert!(exists); + + let exists = persister + .exists_descriptor(Some(&id.to_string()), Some(wallet2)) + .unwrap(); + assert!(exists); + } + + #[test] + fn test_descriptor_update_across_wallets() { + let persister = create_test_repo(); + let wallet1 = "wallet1"; + persister.create_wallet(wallet1).unwrap(); + let wallet2 = "wallet2"; + persister.create_wallet(wallet2).unwrap(); + + let id1 = 1; + let id2 = 2; + let descriptor1 = create_descriptor_default(wallet1, id1); + persister.insert_or_update_descriptor(&descriptor1).unwrap(); + let descriptor2 = create_descriptor_default(wallet2, id2); + persister.insert_or_update_descriptor(&descriptor2).unwrap(); + + let mut descriptors = vec![descriptor1, descriptor2]; + + // Update each descriptor and verify changes are saved correctly without affecting the other wallet's descriptor + for d in &mut descriptors { + let loaded = persister + .get_descriptor(&d.id, Some(&d.wallet)) + .expect("Descriptor should exist"); + + check_descriptor_equality(d, &loaded); + + d.is_active = !d.is_active; + d.is_change = !d.is_change; + d.label = d + .label + .as_ref() + .map(|l| format!("{} Updated", l)) + .or_else(|| Some("Updated Label".to_string())); + + persister.insert_or_update_descriptor(d).unwrap(); + let updated = persister + .get_descriptor(&d.id, Some(&d.wallet)) + .expect("Descriptor should exist after update"); + + check_descriptor_equality(d, &updated); + } + } + + #[test] + fn test_insert_and_get_script_buffer() { + let persister = create_test_repo(); + + let script = ScriptBuf::new(); + let hash = Hash::hash(b"test script"); + + let script_buffer = DbScriptBuffer { + script: script.clone(), + hash, + }; + + persister + .insert_or_update_script_buffer(&script_buffer) + .unwrap(); + + let loaded = persister.get_script_buffer(&hash).unwrap(); + assert_eq!(loaded.script, script); + assert_eq!(loaded.hash, hash); + } + + #[test] + fn test_script_buffer_not_found() { + let persister = create_test_repo(); + let hash = Hash::hash(b"non-existent"); + + let result = persister.get_script_buffer(&hash); + assert!(result.is_err()); + } + + #[test] + fn test_update_script_buffer() { + let persister = create_test_repo(); + + let hash = Hash::hash(b"test"); + let script1 = ScriptBuf::new(); + + let script_buffer = DbScriptBuffer { + script: script1, + hash, + }; + + persister + .insert_or_update_script_buffer(&script_buffer) + .unwrap(); + + // Update with new script + let mut updated = script_buffer; + updated.script = Builder::new().push_int(1).into_script(); + + persister.insert_or_update_script_buffer(&updated).unwrap(); + + let loaded = persister.get_script_buffer(&hash).unwrap(); + assert_eq!(loaded.script, updated.script); + } + + #[test] + fn test_list_script_buffers() { + let persister = create_test_repo(); + + let hash1 = Hash::hash(b"script1"); + let hash2 = Hash::hash(b"script2"); + let hash3 = Hash::hash(b"script3"); + + let script1 = ScriptBuf::new(); + let script2 = Builder::new().push_int(1).into_script(); + let script3 = Builder::new().push_int(2).into_script(); + + persister + .insert_or_update_script_buffer(&DbScriptBuffer { + script: script1, + hash: hash1, + }) + .unwrap(); + persister + .insert_or_update_script_buffer(&DbScriptBuffer { + script: script2, + hash: hash2, + }) + .unwrap(); + persister + .insert_or_update_script_buffer(&DbScriptBuffer { + script: script3, + hash: hash3, + }) + .unwrap(); + + let loaded = persister.list_script_buffers().unwrap(); + assert_eq!(loaded.len(), 3); + assert!(loaded.iter().any(|sb| sb.hash == hash1)); + assert!(loaded.iter().any(|sb| sb.hash == hash2)); + assert!(loaded.iter().any(|sb| sb.hash == hash3)); + } + + #[test] + fn test_list_script_buffers_empty() { + let persister = create_test_repo(); + + let loaded = persister.list_script_buffers().unwrap(); + assert_eq!(loaded.len(), 0); + } + + #[test] + fn test_delete_script_buffer() { + let persister = create_test_repo(); + + let hash = Hash::hash(b"to delete"); + let script_buffer = DbScriptBuffer { + script: ScriptBuf::new(), + hash, + }; + + persister + .insert_or_update_script_buffer(&script_buffer) + .unwrap(); + + // Verify it exists + let loaded = persister.get_script_buffer(&hash).unwrap(); + assert_eq!(loaded.hash, hash); + + // Delete it + persister.delete_script_buffer(&hash).unwrap(); + + // Verify it's gone + let result = persister.get_script_buffer(&hash); + assert!(result.is_err()); + } + + #[test] + fn test_delete_non_existent_script_buffer() { + let persister = create_test_repo(); + let hash = Hash::hash(b"non-existent"); + + let result = persister.delete_script_buffer(&hash); + assert!(result.is_err()); + } +} From ec9dd69e69c0dd9004f32a5e0e622e14b80c0e41 Mon Sep 17 00:00:00 2001 From: moisesPomilio <93723302+moisesPompilio@users.noreply.github.com> Date: Thu, 9 Apr 2026 00:15:47 -0300 Subject: [PATCH 3/5] feat(wallet)!: introduce wallet service layer for wallet orchestration and lifecycle management The wallet service establishes the coordination layer between the provider, repository, and metadata layers, orchestrating wallet operations and managing the complete wallet lifecycle. It serves as the primary interface for wallet clients (such as Electrum servers), translating high-level operations into coordinated calls across the underlying layers while maintaining consistent wallet state. Architecture & Responsibilities: *Provider Integration:* - Queries descriptor-specific transaction data and balance information - Retrieves address generation and UTXOs for each descriptor - Processes blockchain events and emits descriptor-scoped notifications *Repository Integration:* - Persists wallet metadata (name, creation, deletion) - Stores descriptor configurations with metadata (active flag, change flag, labels) - Maintains transaction index for Electrum protocol responses - Tracks script buffers and historical transaction data *Metadata Management:* - Maintains in-memory wallet state and descriptor registry - Administers descriptor lifecycle (add, activate, deactivate, replace) - Handles descriptor state transitions when adding new descriptors - Enforces single active descriptor per category (external/change) *Core Operations:* - Wallet creation and loading from persistent storage - Descriptor management with automatic deactivation of replaced descriptors - Block and mempool transaction processing with event propagation - Balance calculations aggregating across descriptors - Transaction history and proof retrieval for Electrum clients - Address generation delegated to active descriptors Implementation: - Implemented `Wallet` trait defining the complete service interface - Multi-layered error handling with service-specific error types - RwLock-based concurrency for thread-safe metadata access - Deterministic descriptor ID generation via SHA256 hashing --- crates/floresta-watch-only/src/lib.rs | 3 + crates/floresta-watch-only/src/metadata.rs | 630 +++++++++++++++++ crates/floresta-watch-only/src/models.rs | 77 ++ crates/floresta-watch-only/src/service.rs | 669 ++++++++++++++++++ .../floresta-watch-only/tests/common/mod.rs | 2 +- crates/floresta-watch-only/tests/service.rs | 293 ++++++++ 6 files changed, 1673 insertions(+), 1 deletion(-) create mode 100644 crates/floresta-watch-only/src/metadata.rs create mode 100644 crates/floresta-watch-only/src/models.rs create mode 100644 crates/floresta-watch-only/src/service.rs create mode 100644 crates/floresta-watch-only/tests/service.rs diff --git a/crates/floresta-watch-only/src/lib.rs b/crates/floresta-watch-only/src/lib.rs index 4ed38304c..069c89e49 100644 --- a/crates/floresta-watch-only/src/lib.rs +++ b/crates/floresta-watch-only/src/lib.rs @@ -26,8 +26,11 @@ pub mod kv_database; #[cfg(any(test, feature = "memory-database"))] pub mod memory_database; pub mod merkle; +mod metadata; +pub mod models; pub mod provider; pub mod repository; +pub mod service; use bitcoin::consensus::deserialize; use bitcoin::consensus::encode::serialize_hex; diff --git a/crates/floresta-watch-only/src/metadata.rs b/crates/floresta-watch-only/src/metadata.rs new file mode 100644 index 000000000..a8a488aa0 --- /dev/null +++ b/crates/floresta-watch-only/src/metadata.rs @@ -0,0 +1,630 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +#![deny(clippy::unwrap_used)] + +use core::fmt; +use core::fmt::Display; +use core::fmt::Formatter; +use std::collections::HashSet; +use std::error::Error; + +#[derive(Debug, Clone, Default)] +pub struct WalletMetadata { + pub(super) name: String, + active: ActiveDescriptorsMetadata, + descriptors: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct ActiveDescriptorsMetadata { + external: Option, + internal: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct DescriptorInfoMetadata { + pub(super) id: String, + pub(super) label: Option, + pub(super) descriptor: String, +} + +#[derive(Debug)] +pub enum WalletMetadataError { + DescriptorNotFound(String), + DescriptorLabelConflict(String), + + ActiveDescriptorExternalNotFound, + ActiveDescriptorInternalNotFound, +} + +impl Display for WalletMetadataError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + WalletMetadataError::DescriptorNotFound(id) => { + write!(f, "Descriptor not found: {}", id) + } + WalletMetadataError::DescriptorLabelConflict(label) => { + write!(f, "Descriptor label conflict: {}", label) + } + WalletMetadataError::ActiveDescriptorExternalNotFound => { + write!(f, "Active external descriptor not found") + } + WalletMetadataError::ActiveDescriptorInternalNotFound => { + write!(f, "Active internal descriptor not found") + } + } + } +} + +impl Error for WalletMetadataError {} + +impl WalletMetadata { + pub fn new( + name: &str, + active_external: Option, + active_internal: Option, + descriptors: Vec, + ) -> Self { + Self { + name: name.to_string(), + active: ActiveDescriptorsMetadata { + external: active_external, + internal: active_internal, + }, + descriptors, + } + } + + pub fn get_active_descriptors( + &self, + ) -> Result<(DescriptorInfoMetadata, DescriptorInfoMetadata), WalletMetadataError> { + let external = self + .active + .external + .as_ref() + .cloned() + .ok_or(WalletMetadataError::ActiveDescriptorExternalNotFound)?; + + let internal = self + .active + .internal + .as_ref() + .cloned() + .ok_or(WalletMetadataError::ActiveDescriptorInternalNotFound)?; + + Ok((external, internal)) + } + + pub fn get_active_descriptor( + &self, + is_change: bool, + ) -> Result { + let (main, change) = self.get_active_descriptors()?; + + if is_change { + Ok(change) + } else { + Ok(main) + } + } + + pub fn add_descriptor( + &mut self, + descriptor_info: DescriptorInfoMetadata, + is_change: bool, + is_active: bool, + ) -> Result, WalletMetadataError> { + if let Some(label) = &descriptor_info.label { + if let Some(id) = self.get_id_by_label(label) { + if id != descriptor_info.id { + return Err(WalletMetadataError::DescriptorLabelConflict(label.clone())); + } + } + } + + if let Err(e) = self.remover_descriptor(&descriptor_info.id) { + if !matches!(e, WalletMetadataError::DescriptorNotFound(_)) { + return Err(e); + } + } + + if is_active { + let remove_desc = if is_change { + self.active.internal.replace(descriptor_info) + } else { + self.active.external.replace(descriptor_info) + }; + + if let Some(desc) = remove_desc { + self.descriptors.push(desc.clone()); + return Ok(Some(desc)); + } + } else { + self.descriptors.push(descriptor_info); + } + + Ok(None) + } + + pub fn remover_descriptor( + &mut self, + id: &str, + ) -> Result { + if let Some(desc) = self.active.external.take() { + if desc.id == id { + return Ok(desc); + } + self.active.external = Some(desc); + } + + if let Some(desc) = self.active.internal.take() { + if desc.id == id { + return Ok(desc); + } + self.active.internal = Some(desc); + } + + self.descriptors + .iter() + .position(|d| d.id == id) + .map(|index| self.descriptors.remove(index)) + .ok_or_else(|| WalletMetadataError::DescriptorNotFound(id.to_string())) + } + + pub fn get_ids(&self) -> HashSet { + let capacity = 2 + self.descriptors.len(); + let mut ids = HashSet::with_capacity(capacity); + + ids.extend(self.descriptors.iter().map(|d| d.id.clone())); + + if let Some(default_descriptor) = &self.active.external { + ids.insert(default_descriptor.id.clone()); + } + + if let Some(change_desc) = &self.active.internal { + ids.insert(change_desc.id.clone()); + } + + ids + } + + pub fn get_descriptors(&self) -> Vec<&DescriptorInfoMetadata> { + let all_descriptors_capacity = 2 + self.descriptors.len(); + let mut all_descriptors = Vec::with_capacity(all_descriptors_capacity); + + all_descriptors.extend(self.descriptors.iter()); + + if let Some(default_descriptor) = &self.active.external { + all_descriptors.push(default_descriptor); + } + + if let Some(change_desc) = &self.active.internal { + all_descriptors.push(change_desc); + } + + all_descriptors + } + + pub fn get_id_by_label(&self, label: &str) -> Option { + self.active + .external + .as_ref() + .filter(|d| d.label.as_deref() == Some(label)) + .or_else(|| { + self.active + .internal + .as_ref() + .filter(|d| d.label.as_deref() == Some(label)) + }) + .or_else(|| { + self.descriptors + .iter() + .find(|d| d.label.as_deref() == Some(label)) + }) + .map(|d| d.id.clone()) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + + use super::*; + + fn create_descriptor(id: &str, label: Option<&str>) -> DescriptorInfoMetadata { + DescriptorInfoMetadata { + id: id.to_string(), + descriptor: format!("descriptor_{}", id), + label: label.map(|l| l.to_string()), + } + } + + #[test] + fn test_wallet_metadata_creation() { + let external = create_descriptor("external", Some("Receiving")); + let internal = create_descriptor("internal", Some("Change")); + let descriptors = vec![create_descriptor("desc1", None)]; + + let wallet = WalletMetadata::new( + "test_wallet", + Some(external.clone()), + Some(internal.clone()), + descriptors, + ); + + assert_eq!(wallet.name, "test_wallet"); + assert_eq!( + wallet.active.external.as_ref().map(|d| d.id.as_str()), + Some("external") + ); + assert_eq!( + wallet.active.internal.as_ref().map(|d| d.id.as_str()), + Some("internal") + ); + } + + #[test] + fn test_wallet_metadata_creation_without_active_descriptors() { + let descriptors = vec![ + create_descriptor("desc1", Some("Descriptor 1")), + create_descriptor("desc2", None), + ]; + + let wallet = WalletMetadata::new("wallet_no_active", None, None, descriptors); + + assert_eq!(wallet.name, "wallet_no_active"); + assert!(wallet.active.external.is_none()); + assert!(wallet.active.internal.is_none()); + } + + #[test] + fn test_get_active_descriptors_success() { + let external = create_descriptor("external", Some("Receiving")); + let internal = create_descriptor("internal", Some("Change")); + + let wallet = WalletMetadata::new( + "wallet", + Some(external.clone()), + Some(internal.clone()), + vec![], + ); + + let result = wallet.get_active_descriptors(); + assert!(result.is_ok()); + + let (ext, int) = result.unwrap(); + assert_eq!(ext.id, "external"); + assert_eq!(int.id, "internal"); + } + + #[test] + fn test_get_active_descriptors_missing_external() { + let internal = create_descriptor("internal", Some("Change")); + let wallet = WalletMetadata::new("wallet", None, Some(internal), vec![]); + + let result = wallet.get_active_descriptors(); + assert!(matches!( + result, + Err(WalletMetadataError::ActiveDescriptorExternalNotFound) + )); + } + + #[test] + fn test_get_active_descriptors_missing_internal() { + let external = create_descriptor("external", Some("Receiving")); + let wallet = WalletMetadata::new("wallet", Some(external), None, vec![]); + + let result = wallet.get_active_descriptors(); + assert!(matches!( + result, + Err(WalletMetadataError::ActiveDescriptorInternalNotFound) + )); + } + + #[test] + fn test_get_active_descriptor_external() { + let external = create_descriptor("external", Some("Receiving")); + let internal = create_descriptor("internal", Some("Change")); + + let wallet = WalletMetadata::new("wallet", Some(external.clone()), Some(internal), vec![]); + + let result = wallet.get_active_descriptor(false); + assert!(result.is_ok()); + assert_eq!(result.unwrap().id, "external"); + } + + #[test] + fn test_get_active_descriptor_internal() { + let external = create_descriptor("external", Some("Receiving")); + let internal = create_descriptor("internal", Some("Change")); + + let wallet = WalletMetadata::new("wallet", Some(external), Some(internal.clone()), vec![]); + + let result = wallet.get_active_descriptor(true); + assert!(result.is_ok()); + assert_eq!(result.unwrap().id, "internal"); + } + + #[test] + fn test_get_active_descriptor_missing() { + let wallet = WalletMetadata::new("wallet", None, None, vec![]); + + let result = wallet.get_active_descriptor(false); + assert!(result.is_err()); + } + + #[test] + fn test_add_descriptor_to_empty_wallet() { + let mut wallet = WalletMetadata::new("wallet", None, None, vec![]); + let descriptor = create_descriptor("desc1", Some("First")); + + let result = wallet.add_descriptor(descriptor.clone(), false, false); + assert!(result.is_ok()); + assert_eq!(wallet.descriptors.len(), 1); + assert_eq!(wallet.descriptors[0].id, "desc1"); + } + + #[test] + fn test_add_descriptor_as_active_external() { + let mut wallet = WalletMetadata::new("wallet", None, None, vec![]); + let descriptor = create_descriptor("external", Some("Receiving")); + + let result = wallet.add_descriptor(descriptor, false, true); + assert!(result.is_ok()); + assert!(wallet.active.external.is_some()); + assert_eq!(wallet.active.external.as_ref().unwrap().id, "external"); + } + + #[test] + fn test_add_descriptor_as_active_internal() { + let mut wallet = WalletMetadata::new("wallet", None, None, vec![]); + let descriptor = create_descriptor("internal", Some("Change")); + + let result = wallet.add_descriptor(descriptor, true, true); + assert!(result.is_ok()); + assert!(wallet.active.internal.is_some()); + assert_eq!(wallet.active.internal.as_ref().unwrap().id, "internal"); + } + + #[test] + fn test_add_descriptor_replaces_existing_active() { + let old_external = create_descriptor("old_external", Some("Old Receiving")); + let mut wallet = WalletMetadata::new("wallet", Some(old_external), None, vec![]); + + let new_external = create_descriptor("new_external", Some("New Receiving")); + let result = wallet.add_descriptor(new_external, false, true); + + assert!(result.is_ok()); + assert_eq!(wallet.active.external.as_ref().unwrap().id, "new_external"); + // The old external should now be in the descriptors list + assert_eq!(wallet.descriptors.len(), 1); + assert_eq!(wallet.descriptors[0].id, "old_external"); + } + + #[test] + fn test_remove_descriptor_from_inactive() { + let descriptor = create_descriptor("desc1", Some("Descriptor 1")); + let mut wallet = WalletMetadata::new("wallet", None, None, vec![descriptor.clone()]); + + let result = wallet.remover_descriptor("desc1"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().id, "desc1"); + assert!(wallet.descriptors.is_empty()); + } + + #[test] + fn test_remove_active_external_descriptor() { + let external = create_descriptor("external", Some("Receiving")); + let mut wallet = WalletMetadata::new("wallet", Some(external), None, vec![]); + + let result = wallet.remover_descriptor("external"); + assert!(result.is_ok()); + assert!(wallet.active.external.is_none()); + } + + #[test] + fn test_remove_active_internal_descriptor() { + let internal = create_descriptor("internal", Some("Change")); + let mut wallet = WalletMetadata::new("wallet", None, Some(internal), vec![]); + + let result = wallet.remover_descriptor("internal"); + assert!(result.is_ok()); + assert!(wallet.active.internal.is_none()); + } + + #[test] + fn test_remove_nonexistent_descriptor() { + let mut wallet = WalletMetadata::new("wallet", None, None, vec![]); + + let result = wallet.remover_descriptor("nonexistent"); + assert!(matches!( + result, + Err(WalletMetadataError::DescriptorNotFound(ref id)) if id == "nonexistent" + )); + } + + #[test] + fn test_get_ids_with_all_descriptors() { + let external = create_descriptor("external", None); + let internal = create_descriptor("internal", None); + let descriptors = vec![ + create_descriptor("desc1", None), + create_descriptor("desc2", None), + ]; + + let wallet = WalletMetadata::new("wallet", Some(external), Some(internal), descriptors); + + let ids = wallet.get_ids(); + assert_eq!(ids.len(), 4); + assert!(ids.contains("external")); + assert!(ids.contains("internal")); + assert!(ids.contains("desc1")); + assert!(ids.contains("desc2")); + } + + #[test] + fn test_get_ids_only_active() { + let external = create_descriptor("external", None); + let internal = create_descriptor("internal", None); + + let wallet = WalletMetadata::new("wallet", Some(external), Some(internal), vec![]); + + let ids = wallet.get_ids(); + assert_eq!(ids.len(), 2); + assert!(ids.contains("external")); + assert!(ids.contains("internal")); + } + + #[test] + fn test_get_ids_with_duplicates_prevention() { + // Se um descriptor está nos ativos E na lista, deve aparecer apenas uma vez + let external = create_descriptor("external", None); + let descriptors = vec![create_descriptor("external", None)]; // Mesmo ID na lista + + let wallet = WalletMetadata::new("wallet", Some(external), None, descriptors); + + let ids = wallet.get_ids(); + assert_eq!(ids.len(), 1); + assert!(ids.contains("external")); + } + + #[test] + fn test_get_descriptors_all_types() { + let external = create_descriptor("external", Some("Receiving")); + let internal = create_descriptor("internal", Some("Change")); + let descriptors = vec![ + create_descriptor("desc1", Some("Extra 1")), + create_descriptor("desc2", Some("Extra 2")), + ]; + + let wallet = WalletMetadata::new("wallet", Some(external), Some(internal), descriptors); + + let all = wallet.get_descriptors(); + assert_eq!(all.len(), 4); + assert!(all.iter().any(|d| d.id == "external")); + assert!(all.iter().any(|d| d.id == "internal")); + assert!(all.iter().any(|d| d.id == "desc1")); + assert!(all.iter().any(|d| d.id == "desc2")); + } + + #[test] + fn test_get_descriptors_empty_wallet() { + let wallet = WalletMetadata::new("wallet", None, None, vec![]); + + let all = wallet.get_descriptors(); + assert!(all.is_empty()); + } + + #[test] + fn test_get_id_by_label_from_external() { + let external = create_descriptor("external", Some("Receiving Address")); + let wallet = WalletMetadata::new("wallet", Some(external), None, vec![]); + + let result = wallet.get_id_by_label("Receiving Address"); + assert!(result.is_some()); + assert_eq!(result.unwrap(), "external"); + } + + #[test] + fn test_get_id_by_label_from_internal() { + let internal = create_descriptor("internal", Some("Change Address")); + let wallet = WalletMetadata::new("wallet", None, Some(internal), vec![]); + + let result = wallet.get_id_by_label("Change Address"); + assert!(result.is_some()); + assert_eq!(result.unwrap(), "internal"); + } + + #[test] + fn test_get_id_by_label_from_list() { + let descriptors = vec![create_descriptor("desc1", Some("My Label"))]; + let wallet = WalletMetadata::new("wallet", None, None, descriptors); + + let result = wallet.get_id_by_label("My Label"); + assert!(result.is_some()); + assert_eq!(result.unwrap(), "desc1"); + } + + #[test] + fn test_get_id_by_label_not_found() { + let wallet = WalletMetadata::new("wallet", None, None, vec![]); + + let result = wallet.get_id_by_label("Nonexistent Label"); + assert!(result.is_none()); + } + + #[test] + fn test_get_id_by_label_returns_first_match() { + // If there are multiple descriptors with the same label, it should return the first one found (external > internal > list) + let external = create_descriptor("external", Some("Shared Label")); + let descriptors = vec![create_descriptor("desc1", Some("Shared Label"))]; + let wallet = WalletMetadata::new("wallet", Some(external), None, descriptors); + + let result = wallet.get_id_by_label("Shared Label"); + assert!(result.is_some()); // Pode ser "external" ou "desc1" + assert!(["external", "desc1"].contains(&result.unwrap().as_str())); + } + + #[test] + fn test_descriptor_info_metadata_creation() { + let desc = DescriptorInfoMetadata { + id: "test_id".to_string(), + descriptor: "wpkh(...)".to_string(), + label: Some("My Descriptor".to_string()), + }; + + assert_eq!(desc.id, "test_id"); + assert_eq!(desc.descriptor, "wpkh(...)"); + assert_eq!(desc.label, Some("My Descriptor".to_string())); + } + + #[test] + fn test_descriptor_info_metadata_without_label() { + let desc = DescriptorInfoMetadata { + id: "test_id".to_string(), + descriptor: "wpkh(...)".to_string(), + label: None, + }; + + assert_eq!(desc.id, "test_id"); + assert_eq!(desc.descriptor, "wpkh(...)"); + assert!(desc.label.is_none()); + } + + #[test] + fn test_complex_workflow() { + let mut wallet = WalletMetadata::new("my_wallet", None, None, vec![]); + + // Add active external descriptor + let external = create_descriptor("ext1", Some("Main Receiving")); + wallet.add_descriptor(external, false, true).unwrap(); + assert_eq!(wallet.get_ids().len(), 1); + + // Add active internal descriptor + let internal = create_descriptor("int1", Some("Change")); + wallet.add_descriptor(internal, true, true).unwrap(); + assert_eq!(wallet.get_ids().len(), 2); + + // Add inactive descriptors + let desc2 = create_descriptor("desc2", Some("Extra 1")); + wallet.add_descriptor(desc2, false, false).unwrap(); + + let desc3 = create_descriptor("desc3", Some("Extra 2")); + wallet.add_descriptor(desc3, false, false).unwrap(); + + // Check state + assert_eq!(wallet.get_ids().len(), 4); + assert_eq!(wallet.get_descriptors().len(), 4); + + // Replace active external descriptor + let new_external = create_descriptor("ext2", Some("New Receiving")); + wallet.add_descriptor(new_external, false, true).unwrap(); + assert_eq!(wallet.get_ids().len(), 5); // ext1 should now be in the list + + // Remove a descriptor + wallet.remover_descriptor("desc2").unwrap(); + assert_eq!(wallet.get_ids().len(), 4); + + // Check that we can retrieve by label + assert_eq!(wallet.get_id_by_label("Change"), Some("int1".to_string())); + } +} diff --git a/crates/floresta-watch-only/src/models.rs b/crates/floresta-watch-only/src/models.rs new file mode 100644 index 000000000..502fd1903 --- /dev/null +++ b/crates/floresta-watch-only/src/models.rs @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +#![deny(clippy::unwrap_used)] + +use bitcoin::Amount; +use bitcoin::BlockHash; +use bitcoin::OutPoint; +use bitcoin::TxOut; + +#[derive(Debug, Clone)] +pub struct GetBalanceParams { + /// Only include transactions confirmed at least this many times (default: 0) + pub minconf: u32, + + /// Exclude dirty outputs from balance calculation (default: true) + /// Only available if avoid_reuse wallet flag is set + pub avoid_reuse: bool, +} + +impl Default for GetBalanceParams { + fn default() -> Self { + Self { + minconf: 0, + avoid_reuse: true, + } + } +} + +#[derive(Debug, Clone)] +pub struct LastProcessedBlock { + /// Hash of the block this balance was generated on + pub hash: BlockHash, + /// Height of the block this balance was generated on + pub height: u32, +} + +#[derive(Debug, Clone)] +pub struct Balance { + // trusted balance (outputs created by the wallet or confirmed outputs) + pub trusted: Amount, + + // untrusted pending balance (outputs created by others that are in the mempool) + pub untrusted_pending: Amount, + + // balance from immature coinbase outputs + pub immature: Amount, + + // (optional) (only present if avoid_reuse is set) balance from coins sent to addresses that were + // previously spent from (potentially privacy violating) + pub used: Option, + + pub last_processed_block: LastProcessedBlock, +} + +impl Balance { + pub fn total(&self) -> Amount { + self.trusted + self.untrusted_pending + self.immature + } + + pub fn trusted_spendable(&self) -> Amount { + self.trusted + } +} + +#[derive(Debug, Clone)] +pub struct LocalOutput { + pub outpoint: OutPoint, + pub txout: TxOut, + pub is_spent: bool, +} + +#[derive(Debug, Clone)] +pub struct ImportDescriptor { + pub descriptor: String, + pub label: Option, + pub is_active: bool, + pub is_change: bool, +} diff --git a/crates/floresta-watch-only/src/service.rs b/crates/floresta-watch-only/src/service.rs new file mode 100644 index 000000000..61bb2bca6 --- /dev/null +++ b/crates/floresta-watch-only/src/service.rs @@ -0,0 +1,669 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +#![deny(clippy::unwrap_used)] + +use core::fmt; +use core::fmt::Display; +use core::fmt::Formatter; +use std::collections::HashMap; +use std::sync::RwLock; + +use bitcoin::consensus::encode::serialize_hex; +use bitcoin::hashes::sha256::Hash; +use bitcoin::hashes::Hash as HashTrait; +use bitcoin::Address; +use bitcoin::Amount; +use bitcoin::Block; +#[cfg(all(feature = "bdk-provider", feature = "sqlite"))] +use bitcoin::Network; +use bitcoin::OutPoint; +use bitcoin::ScriptBuf; +use bitcoin::Transaction; +use bitcoin::TxOut; +use bitcoin::Txid; +use floresta_chain::BlockConsumer; +use floresta_chain::UtxoData; +use floresta_common::get_spk_hash; +use floresta_common::impl_error_from; +use tracing::error; + +use crate::merkle::MerkleProof; +use crate::metadata::DescriptorInfoMetadata; +use crate::metadata::WalletMetadata; +use crate::metadata::WalletMetadataError; +use crate::models::Balance; +use crate::models::GetBalanceParams; +use crate::models::ImportDescriptor; +#[cfg(all(feature = "bdk-provider", feature = "sqlite"))] +use crate::provider::new_provider; +use crate::provider::WalletProvider; +use crate::provider::WalletProviderError; +use crate::provider::WalletProviderEvent; +#[cfg(all(feature = "bdk-provider", feature = "sqlite"))] +use crate::repository::new_repository; +use crate::repository::DbDescriptor; +use crate::repository::DbScriptBuffer; +use crate::repository::DbTransaction; +use crate::repository::WalletRepository; +use crate::repository::WalletRepositoryError; + +#[cfg(all(feature = "bdk-provider", feature = "sqlite"))] +pub fn new_wallet(datadir: &str, network: Network) -> Result, WalletServiceError> { + let service = WalletService::new_default(datadir, network)?; + + Ok(Box::new(service)) +} + +#[cfg(all(feature = "bdk-provider", feature = "sqlite"))] +pub fn new_block_consumer( + datadir: &str, + network: Network, +) -> Result, WalletServiceError> { + let service = WalletService::new_default(datadir, network)?; + + Ok(Box::new(service)) +} + +pub struct WalletService { + provider: RwLock>, + persister: Box, + metadata: RwLock, +} + +impl WalletService { + pub fn new(provider: Box, persister: Box) -> Self { + let metadata = WalletMetadata::default(); + + Self { + provider: RwLock::new(provider), + persister, + metadata: RwLock::new(metadata), + } + } + + #[cfg(all(feature = "bdk-provider", feature = "sqlite"))] + pub fn new_default(datadir: &str, network: Network) -> Result { + let persister_datadir = format!("{datadir}/repository.db3"); + let persister = new_repository(&persister_datadir)?; + + let is_wallet_initialized = persister.exists_descriptor(None, None).unwrap_or(false); + + let provider_datadir = format!("{datadir}/provider.db3"); + let provider = new_provider(&provider_datadir, network, is_wallet_initialized)?; + + Ok(Self::new(provider, persister)) + } + + fn get_provider( + &self, + ) -> Result>, WalletServiceError> { + self.provider + .read() + .map_err(|e| WalletServiceError::LockPoisoned(e.to_string())) + } + + fn get_provider_mut( + &self, + ) -> Result>, WalletServiceError> { + self.provider + .write() + .map_err(|e| WalletServiceError::LockPoisoned(e.to_string())) + } + + fn get_metadata( + &self, + ) -> Result, WalletServiceError> { + let metadata = self + .metadata + .read() + .map_err(|e| WalletServiceError::LockPoisoned(e.to_string()))?; + + if metadata.name.is_empty() { + return Err(WalletServiceError::WalletNotLoaded); + } + + Ok(metadata) + } + + fn get_metadata_mut( + &self, + ) -> Result, WalletServiceError> { + let metadata = self + .metadata + .write() + .map_err(|e| WalletServiceError::LockPoisoned(e.to_string()))?; + + if metadata.name.is_empty() { + return Err(WalletServiceError::WalletNotLoaded); + } + + Ok(metadata) + } + + fn get_metadata_mut_not_validated( + &self, + ) -> Result, WalletServiceError> { + self.metadata + .write() + .map_err(|e| WalletServiceError::LockPoisoned(e.to_string())) + } + + fn process_block_inner( + &self, + block: &Block, + height: u32, + ) -> Result, WalletServiceError> { + let provider = self.get_provider()?; + let events = provider + .block_process(block, height) + .map_err(WalletServiceError::ProviderError)?; + + self.process_event(events, Some(block), Some(height as u64)) + } + + fn process_event( + &self, + event: Vec, + block: Option<&Block>, + height: Option, + ) -> Result, WalletServiceError> { + let mut transaction_update = Vec::new(); + for e in event { + match e { + WalletProviderEvent::UpdateTransaction { tx, output } => { + // Persist the script buffer of this transaction output that we know about + let hash = get_spk_hash(&output.script_pubkey); + let script_info = DbScriptBuffer { + script: output.script_pubkey.clone(), + hash, + }; + self.persister + .insert_or_update_script_buffer(&script_info)?; + + // Add the transaction to the list of transactions to update in the wallet state + transaction_update.push((tx, output)); + } + WalletProviderEvent::ConfirmedTransaction { tx } => { + let block = block.ok_or_else(|| { + WalletServiceError::BlockProcessingError( + "Block must be provided for TxConfirmed event".to_string(), + ) + })?; + if height.is_none() { + return Err(WalletServiceError::BlockProcessingError( + "Height must be provided for TxConfirmed event".to_string(), + )); + } + + let position = self.get_transaction_position(&tx.compute_txid(), block)?; + + let proof = MerkleProof::from_block(block, position); + + let tx_persist = DbTransaction { + hash: tx.compute_txid(), + tx, + merkle_block: Some(proof), + height, + position: Some(position), + }; + + self.persister.insert_or_update_transaction(&tx_persist)?; + } + WalletProviderEvent::UnconfirmedTransactionInBlock { tx } => { + let tx_persist = DbTransaction { + hash: tx.compute_txid(), + tx, + merkle_block: None, + height: None, + position: None, + }; + + self.persister.insert_or_update_transaction(&tx_persist)?; + } + } + } + + Ok(transaction_update) + } + + fn get_transaction_position( + &self, + txid: &Txid, + block: &Block, + ) -> Result { + block + .txdata + .iter() + .position(|tx| &tx.compute_txid() == txid) + .map(|pos| pos as u64) + .ok_or(WalletProviderError::TransactionNotFoundInBlock(*txid)) + } +} + +impl BlockConsumer for WalletService { + fn wants_spent_utxos(&self) -> bool { + false + } + + fn on_block( + &self, + block: &Block, + height: u32, + _spent_utxos: Option<&HashMap>, + ) { + // We only process block if the wallet is initialized, + if self + .persister + .exists_descriptor(None, None) + .unwrap_or(false) + { + return; + } + + self.process_block_inner(block, height).unwrap_or_else(|e| { + error!("Error processing block({height}): {e:?}"); + Vec::new() + }); + } +} + +#[derive(Debug)] +pub enum WalletServiceError { + ProviderError(WalletProviderError), + + PersistError(WalletRepositoryError), + + MetadataError(WalletMetadataError), + + LockPoisoned(String), + + BlockProcessingError(String), + + NotFound(String), + + WalletNotLoaded, +} + +//impl display error +impl Display for WalletServiceError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + WalletServiceError::ProviderError(e) => write!(f, "Provider error: {e}"), + WalletServiceError::PersistError(e) => write!(f, "Persistence error: {e}"), + WalletServiceError::MetadataError(e) => write!(f, "Metadata error: {e}"), + WalletServiceError::LockPoisoned(e) => write!(f, "Lock poisoned: {e}"), + WalletServiceError::BlockProcessingError(e) => { + write!(f, "Block processing error: {e}") + } + WalletServiceError::NotFound(e) => write!(f, "Not found: {e}"), + WalletServiceError::WalletNotLoaded => write!(f, "Wallet not loaded"), + } + } +} + +impl_error_from!(WalletServiceError, WalletProviderError, ProviderError); +impl_error_from!(WalletServiceError, WalletRepositoryError, PersistError); +impl_error_from!(WalletServiceError, WalletMetadataError, MetadataError); + +pub trait Wallet { + // Event processing functions + + // Process transactions in a block and update the wallet state accordingly. + fn process_block( + &self, + block: &Block, + height: u32, + ) -> Result, WalletServiceError>; + + // Process mempool transactions and update the wallet state accordingly. + fn process_mempool_transactions( + &self, + transactions: Vec<&Transaction>, + ) -> Result, WalletServiceError>; + + // Get the UTXO for a given outpoint, if it belongs to the wallet and is unspent. + fn get_utxo(&self, outpoint: &OutPoint) -> Result, WalletServiceError>; + + // Get the transaction details for a given transaction ID + fn get_transaction(&self, txid: &Txid) -> Result, WalletServiceError>; + + // Get the transaction history for a given address/script hash + fn get_address_history( + &self, + script_hash: &Hash, + ) -> Result>, WalletServiceError>; + + // Get the balance for a given address/script hash + fn get_address_balance(&self, script_hash: &Hash) -> Result; + + // Get a list of all addresses currently in the wallet + fn get_cached_addresses(&self) -> Result, WalletServiceError>; + + // Get a list of all UTXOs currently in script hash, along with their outpoints + fn get_address_utxos( + &self, + script_hash: &Hash, + ) -> Result>, WalletServiceError>; + + // Get the Merkle proof for a given transaction ID, if it is confirmed in a block. + fn get_merkle_proof(&self, txid: &Txid) -> Result, WalletServiceError>; + + // Get the position of a transaction within its block, if it is confirmed. + fn get_position(&self, txid: &Txid) -> Result, WalletServiceError>; + + // Get the block height at which a transaction was confirmed, if it is confirmed. + fn get_height(&self, txid: &Txid) -> Result, WalletServiceError>; + + // Get the raw transaction hex for a given transaction ID, if it is known to the wallet. + fn get_cached_transaction(&self, txid: &Txid) -> Result, WalletServiceError>; + + // Create a new wallet with the given name. This will persist in repository and loaded in memory. + fn create_wallet(&self, wallet: &str) -> Result<(), WalletServiceError>; + + // Load an existing wallet by name. This will persist in memory and be used for all subsequent operations. + // If the wallet does not exist, an error is returned. + fn load_wallet(&self, wallet: &str) -> Result<(), WalletServiceError>; + + // Push a new descriptor to the wallet. Needed to load a wallet or create a new wallet. + fn push_descriptor(&self, descriptor: &ImportDescriptor) -> Result<(), WalletServiceError>; + + // Get a list of all descriptors currently in the wallet, along with their metadata. + fn get_descriptors(&self) -> Result, WalletServiceError>; + + // Generate a new address from the wallet. If is_change is true, generates a change address. + fn new_address(&self, is_change: bool) -> Result; + + // Find all unconfirmed transactions currently in the wallet. This is used to populate the mempool state on startup. + fn find_unconfirmed(&self) -> Result, WalletServiceError>; + + // Get the total balance of the wallet, with options to filter by minimum confirmations and avoid_reuse. + fn get_balance(&self, params: GetBalanceParams) -> Result; + + // Get the balances of all wallets. This includes the trusted, untrusted pending, immature and + // used balances, along with the last processed block information. + fn get_balances(&self) -> Result; +} + +impl Wallet for WalletService { + // Event processing functions + + fn process_block( + &self, + block: &Block, + height: u32, + ) -> Result, WalletServiceError> { + self.process_block_inner(block, height) + } + + fn process_mempool_transactions( + &self, + transactions: Vec<&Transaction>, + ) -> Result, WalletServiceError> { + let provider = self.get_provider()?; + + let events = provider.process_mempool_transactions(transactions)?; + + let vec = self.process_event(events, None, None)?; + + Ok(vec.into_iter().map(|(_, output)| output).collect()) + } + + // Data retrieval functions + + fn get_utxo(&self, outpoint: &OutPoint) -> Result, WalletServiceError> { + let provider = self.get_provider()?; + + let tx_out = provider.get_txo(outpoint, Some(false))?; + + Ok(tx_out) + } + + fn get_transaction(&self, txid: &Txid) -> Result, WalletServiceError> { + let tx = self.persister.get_transaction(txid); + + match tx { + Ok(tx) => Ok(Some(tx)), + Err(WalletRepositoryError::NotFound(_)) => Ok(None), + Err(e) => Err(WalletServiceError::PersistError(e)), + } + } + + fn get_address_history( + &self, + script_hash: &Hash, + ) -> Result>, WalletServiceError> { + let scriptt_info = self.persister.get_script_buffer(script_hash)?; + + let outpoints = self + .get_provider()? + .get_local_output_by_script(scriptt_info.script, Some(true))?; + + let mut transactions = Vec::new(); + for outpoint in outpoints { + let tx = self.persister.get_transaction(&outpoint.outpoint.txid)?; + transactions.push(tx); + } + + transactions.sort_by_key(|tx| tx.height.unwrap_or(0)); + + Ok(Some(transactions)) + } + + fn get_address_balance(&self, hash: &Hash) -> Result { + let provider = self.get_provider()?; + + let scriptt_info = self.persister.get_script_buffer(hash)?; + + let outpoints = provider.get_local_output_by_script(scriptt_info.script, Some(false))?; + + let balance = outpoints.iter().map(|o| o.txout.value.to_sat()).sum(); + + Ok(balance) + } + + fn get_cached_addresses(&self) -> Result, WalletServiceError> { + let provider = self.get_provider()?; + + let spk = provider.list_script_buff(None)?; + + Ok(spk) + } + + fn get_address_utxos( + &self, + script_hash: &Hash, + ) -> Result>, WalletServiceError> { + let scriptt_info = self.persister.get_script_buffer(script_hash)?; + + let outpoints = self + .get_provider()? + .get_local_output_by_script(scriptt_info.script, Some(false))?; + + let utxos = outpoints + .into_iter() + .map(|o| (o.txout, o.outpoint)) + .collect(); + + Ok(Some(utxos)) + } + + fn get_merkle_proof(&self, txid: &Txid) -> Result, WalletServiceError> { + let tx = self.persister.get_transaction(txid)?; + + Ok(tx.merkle_block) + } + + fn get_position(&self, txid: &Txid) -> Result, WalletServiceError> { + let tx = self.persister.get_transaction(txid)?; + + Ok(tx.position) + } + + fn get_height(&self, txid: &Txid) -> Result, WalletServiceError> { + let tx = self.persister.get_transaction(txid)?; + + Ok(tx.height) + } + + fn get_cached_transaction(&self, txid: &Txid) -> Result, WalletServiceError> { + let tx = self.get_transaction(txid)?; + + Ok(tx.map(|tx| serialize_hex(&tx.tx))) + } + + // Wallet management functions + + fn create_wallet(&self, wallet: &str) -> Result<(), WalletServiceError> { + self.persister.create_wallet(wallet)?; + + self.load_wallet(wallet) + } + + fn load_wallet(&self, wallet: &str) -> Result<(), WalletServiceError> { + let descriptor = self.persister.load_wallet(wallet)?; + + let mut active_external = None; + let mut active_internal = None; + let mut descriptos_metadata = Vec::new(); + for desc in descriptor { + let metadata = db_descriptor_to_metadata(&desc); + + if desc.is_active { + if desc.is_change { + active_internal = Some(metadata); + } else { + active_external = Some(metadata); + } + } else { + descriptos_metadata.push(metadata); + } + } + + let wallet_metadata = WalletMetadata::new( + wallet, + active_external, + active_internal, + descriptos_metadata, + ); + + let mut metadata = self.get_metadata_mut_not_validated()?; + *metadata = wallet_metadata; + + Ok(()) + } + + fn push_descriptor( + &self, + import_descriptor: &ImportDescriptor, + ) -> Result<(), WalletServiceError> { + let wallet_name; + { + let mut metadata = self.get_metadata_mut()?; + wallet_name = metadata.name.clone(); + + let descriptor = DbDescriptor { + wallet: metadata.name.clone(), + id: generate_id_for_descriptor(&import_descriptor.descriptor), + descriptor: import_descriptor.descriptor.clone(), + label: import_descriptor.label.clone(), + is_active: import_descriptor.is_active, + is_change: import_descriptor.is_change, + }; + + let existing_descriptor = self + .persister + .exists_descriptor(Some(&descriptor.id), None)?; + + if !existing_descriptor { + self.get_provider_mut()? + .persist_descriptor(&descriptor.id, &descriptor.descriptor)?; + } + self.persister.insert_or_update_descriptor(&descriptor)?; + + let desc_metadata = db_descriptor_to_metadata(&descriptor); + let replace_desc = metadata.add_descriptor( + desc_metadata, + descriptor.is_change, + descriptor.is_active, + )?; + + if let Some(replace_desc) = replace_desc { + self.persister.insert_or_update_descriptor(&DbDescriptor { + descriptor: replace_desc.descriptor, + id: replace_desc.id, + label: replace_desc.label, + wallet: metadata.name.clone(), + is_change: descriptor.is_change, + is_active: false, + })?; + } + } + + self.load_wallet(&wallet_name) + } + + fn get_descriptors(&self) -> Result, WalletServiceError> { + let descriptors = self + .get_metadata()? + .get_descriptors() + .iter() + .map(|desc| desc.descriptor.clone()) + .collect(); + + Ok(descriptors) + } + + fn new_address(&self, is_change: bool) -> Result { + let metadata = self.get_metadata()?; + let descriptor = metadata.get_active_descriptor(is_change)?; + + let provider = self.get_provider()?; + let address = provider.new_address(&descriptor.id)?; + + Ok(address) + } + + fn find_unconfirmed(&self) -> Result, WalletServiceError> { + let txs = self.persister.list_transactions()?; + + Ok(txs + .iter() + .filter(|tx| tx.height.is_none()) + .map(|tx| tx.tx.clone()) + .collect()) + } + + fn get_balance(&self, params: GetBalanceParams) -> Result { + let provider = self.get_provider()?; + + let metadata = self.get_metadata()?; + + let balance = provider.get_balance(metadata.get_ids(), params)?; + + Ok(balance) + } + + fn get_balances(&self) -> Result { + let provider = self.get_provider()?; + + let metadata = self.get_metadata()?; + + let balances = provider.get_balances(metadata.get_ids())?; + + Ok(balances) + } +} + +fn db_descriptor_to_metadata(desc: &DbDescriptor) -> DescriptorInfoMetadata { + DescriptorInfoMetadata { + descriptor: desc.descriptor.clone(), + id: desc.id.clone(), + label: desc.label.clone(), + } +} + +fn generate_id_for_descriptor(desc: &str) -> String { + let hash = Hash::hash(desc.as_bytes()); + + hash.to_string() +} diff --git a/crates/floresta-watch-only/tests/common/mod.rs b/crates/floresta-watch-only/tests/common/mod.rs index 94b95f53c..a60043fca 100644 --- a/crates/floresta-watch-only/tests/common/mod.rs +++ b/crates/floresta-watch-only/tests/common/mod.rs @@ -1,4 +1,4 @@ -#![cfg(feature = "bdk-provider")] +#![cfg(any(feature = "bdk-provider", feature = "sqlite"))] use std::fs::create_dir_all; diff --git a/crates/floresta-watch-only/tests/service.rs b/crates/floresta-watch-only/tests/service.rs new file mode 100644 index 000000000..633d9a397 --- /dev/null +++ b/crates/floresta-watch-only/tests/service.rs @@ -0,0 +1,293 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +#![cfg(all(feature = "bdk-provider", feature = "sqlite"))] +mod common; + +use bitcoin::Amount; +use bitcoin::Block; +use bitcoin::BlockHash; +use bitcoin::Network; +use bitcoin::OutPoint; +use bitcoin::Transaction; +use bitcoin::TxOut; +use floresta_watch_only::models::ImportDescriptor; +use floresta_watch_only::service::new_wallet; +use floresta_watch_only::service::Wallet; + +use crate::common::create_block_with_coinbase; +use crate::common::create_block_with_transaction; +use crate::common::generate_blocks; +use crate::common::generate_random_path_tmpdir; +use crate::common::TransactionInner; +use crate::common::DESCRIPTOR; +use crate::common::DESCRIPTOR_SECOND; + +const WALLET_NAME: &str = "test_wallet"; +const AMOUNT: Amount = Amount::from_sat(10_000_000); + +fn create_wallet() -> Box { + let data_dir = generate_random_path_tmpdir(); + new_wallet(&data_dir, Network::Bitcoin).expect("Failed to create wallet") +} + +fn create_wallet_initialized() -> Box { + let data_dir = generate_random_path_tmpdir(); + let wallet = new_wallet(&data_dir, Network::Regtest).expect("Failed to create wallet"); + + wallet.create_wallet(WALLET_NAME).unwrap(); + + let descriptor = ImportDescriptor { + descriptor: DESCRIPTOR.to_string(), + label: Some("receiving".to_string()), + is_active: true, + is_change: false, + }; + + wallet.push_descriptor(&descriptor).unwrap(); + + let descriptor = ImportDescriptor { + descriptor: DESCRIPTOR_SECOND.to_string(), + label: Some("change".to_string()), + is_active: true, + is_change: true, + }; + + wallet.push_descriptor(&descriptor).unwrap(); + + wallet +} + +fn create_my_output(wallet: &dyn Wallet, is_change: bool) -> TxOut { + TxOut { + value: AMOUNT, + script_pubkey: wallet.new_address(is_change).unwrap().script_pubkey(), + } +} + +fn create_spent_transaction( + wallet: &dyn Wallet, + outpoint: OutPoint, + my_output: Option, +) -> Transaction { + let mut tx_inner = TransactionInner { + outpoint: vec![outpoint], + txo: vec![], + }; + + if let Some(is_change) = my_output { + tx_inner.txo.push(create_my_output(wallet, is_change)); + } + + tx_inner.to_transaction() +} + +fn create_transaction(wallet: &dyn Wallet, is_change: bool) -> Transaction { + let tx_inner = TransactionInner { + outpoint: vec![], + txo: vec![create_my_output(wallet, is_change)], + }; + + tx_inner.to_transaction() +} + +fn create_block_with_wallet_transaction( + wallet: &dyn Wallet, + prevhash: Option, + is_change: bool, +) -> (Block, Transaction) { + let my_transaction = create_transaction(wallet, is_change); + + let block = create_block_with_transaction(prevhash, &my_transaction); + (block, my_transaction) +} + +fn create_block_with_wallet_transaction_and_spend( + wallet: &dyn Wallet, + prevhash: Option, + outpoint: OutPoint, + is_returned: Option, +) -> Block { + let spent_transaction = create_spent_transaction(wallet, outpoint, is_returned); + + create_block_with_transaction(prevhash, &spent_transaction) +} + +fn create_block_with_wallet_transaction_coinbase( + wallet: &dyn Wallet, + prevhash: Option, + is_change: bool, +) -> Block { + let txo = create_my_output(wallet, is_change); + create_block_with_coinbase(prevhash, txo.script_pubkey, AMOUNT.to_sat()) +} + +fn mine_blocks(wallet: &dyn Wallet, count: u32) { + let last_check_point = wallet.get_balances().unwrap().last_processed_block; + let current_prev_hash = Some(last_check_point.hash); + let mut current_height = last_check_point.height + 1; + + let blocks = generate_blocks(count, current_prev_hash); + for block in blocks { + wallet.process_block(&block, current_height).unwrap(); + current_height += 1; + } +} + +#[test] +fn test_wallet_creation() { + // Create wallet service + let wallet = create_wallet(); + + // Create a new wallet + wallet + .create_wallet("test_wallet") + .expect("Failed to create wallet"); + + // Verify wallet was created + let result = wallet.get_descriptors().unwrap(); + + assert_eq!(result.len(), 0); +} + +#[test] +fn test_wallet_initialization() { + // Create wallet service + let wallet = create_wallet_initialized(); + + // Verify descriptors were added + let result = wallet.get_descriptors().unwrap(); + + assert_eq!(result.len(), 2); + for descriptor in [DESCRIPTOR, DESCRIPTOR_SECOND] { + assert!(result.iter().any(|d| d == descriptor)); + } +} + +#[test] +fn test_wallet_balances_empty() { + // Create wallet service + let wallet = create_wallet_initialized(); + + // Verify balance is zero + let balance = wallet.get_balances().unwrap(); + + let amount = Amount::from_sat(0); + + assert_eq!(balance.total(), amount); + assert_eq!(balance.trusted, amount); + assert_eq!(balance.untrusted_pending, amount); + assert_eq!(balance.immature, amount); + assert_eq!(balance.used, None); + assert_eq!(balance.last_processed_block.height, 0); +} + +#[test] +fn test_wallet_balances_coinbase() { + // Create wallet service + let wallet = create_wallet_initialized(); + + // Create a block with a transaction that pays to the wallet + let block = create_block_with_wallet_transaction_coinbase(wallet.as_ref(), None, false); + + // Process the block + wallet.process_block(&block, 0).unwrap(); + + // Verify balance is updated + let balance = wallet.get_balances().unwrap(); + assert_eq!(balance.total(), AMOUNT); + assert_eq!(balance.trusted, Amount::from_sat(0)); + assert_eq!(balance.untrusted_pending, Amount::from_sat(0)); + assert_eq!(balance.immature, AMOUNT); + assert_eq!(balance.used, None); + assert_eq!(balance.last_processed_block.height, 0); + assert_eq!(balance.last_processed_block.hash, block.block_hash()); + + mine_blocks(wallet.as_ref(), 101); + + // Verify balance is updated + let balance = wallet.get_balances().unwrap(); + assert_eq!(balance.total(), AMOUNT); + assert_eq!(balance.trusted, AMOUNT); + assert_eq!(balance.untrusted_pending, Amount::from_sat(0)); + assert_eq!(balance.immature, Amount::from_sat(0)); + assert_eq!(balance.used, None); + assert_eq!(balance.last_processed_block.height, 101); +} + +#[test] +fn test_wallet_balances_with_transaction() { + // Create wallet service + let wallet = create_wallet_initialized(); + + // Create a block with a transaction that pays to the wallet + let (block, _) = create_block_with_wallet_transaction(wallet.as_ref(), None, false); + + // Process the block + wallet.process_block(&block, 0).unwrap(); + + // Verify balance is updated + let balance = wallet.get_balances().unwrap(); + assert_eq!(balance.total(), AMOUNT); + assert_eq!(balance.trusted, AMOUNT); + assert_eq!(balance.untrusted_pending, Amount::from_sat(0)); + assert_eq!(balance.immature, Amount::from_sat(0)); + assert_eq!(balance.used, None); + assert_eq!(balance.last_processed_block.height, 0); + assert_eq!(balance.last_processed_block.hash, block.block_hash()); + + mine_blocks(wallet.as_ref(), 101); + + // Verify balance is updated + let balance = wallet.get_balances().unwrap(); + assert_eq!(balance.total(), AMOUNT); + assert_eq!(balance.trusted, AMOUNT); + assert_eq!(balance.untrusted_pending, Amount::from_sat(0)); + assert_eq!(balance.immature, Amount::from_sat(0)); + assert_eq!(balance.used, None); + assert_eq!(balance.last_processed_block.height, 101); +} + +#[test] +fn test_wallet_balances_with_transaction_spent() { + // Create wallet service + let wallet = create_wallet_initialized(); + + // Create a block with a transaction that pays to the wallet + let (block, tx) = create_block_with_wallet_transaction(wallet.as_ref(), None, false); + + // Process the block + wallet.process_block(&block, 0).unwrap(); + + // Verify balance is updated + let balance = wallet.get_balances().unwrap(); + assert_eq!(balance.total(), AMOUNT); + assert_eq!(balance.trusted, AMOUNT); + assert_eq!(balance.untrusted_pending, Amount::from_sat(0)); + assert_eq!(balance.immature, Amount::from_sat(0)); + assert_eq!(balance.used, None); + assert_eq!(balance.last_processed_block.height, 0); + assert_eq!(balance.last_processed_block.hash, block.block_hash()); + + let outpoint = OutPoint { + txid: tx.compute_txid(), + vout: 0, + }; + let block = create_block_with_wallet_transaction_and_spend( + wallet.as_ref(), + Some(block.block_hash()), + outpoint, + None, + ); + wallet.process_block(&block, 1).unwrap(); + let expect_amount = Amount::from_sat(0); + + // Verify balance is updated + let balance = wallet.get_balances().unwrap(); + assert_eq!(balance.total(), expect_amount); + assert_eq!(balance.trusted, expect_amount); + assert_eq!(balance.untrusted_pending, expect_amount); + assert_eq!(balance.immature, expect_amount); + assert_eq!(balance.used, None); + assert_eq!(balance.last_processed_block.height, 1); + assert_eq!(balance.last_processed_block.hash, block.block_hash()); +} From 56484f457915a975e64555dae9e7acb6a6a4dcb6 Mon Sep 17 00:00:00 2001 From: moisesPomilio <93723302+moisesPompilio@users.noreply.github.com> Date: Thu, 9 Apr 2026 00:44:49 -0300 Subject: [PATCH 4/5] docs(wallet): add comprehensive README for floresta-watch-only wallet - Add architecture overview with three-layer design explanation - Include Mermaid class diagram showing trait relationships - Add sequence diagram for block processing data flow - Provide practical usage examples (wallet creation, descriptors, blocks, queries) - Document feature flags (bdk-provider, sqlite) and combinations - Detail error types, concurrency model, and development guidelines - All content in English with clear code examples --- crates/floresta-watch-only/README.md | 344 +++++++++++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 crates/floresta-watch-only/README.md diff --git a/crates/floresta-watch-only/README.md b/crates/floresta-watch-only/README.md new file mode 100644 index 000000000..05da2ba50 --- /dev/null +++ b/crates/floresta-watch-only/README.md @@ -0,0 +1,344 @@ +# Floresta Watch-Only Wallet + +A lightweight, modular watch-only Bitcoin wallet library designed for Electrum protocol support and descriptor-based address management. This crate provides a layered architecture that separates concerns across transaction discovery, state persistence, and wallet orchestration. + +## Overview + +The watch-only wallet enables applications to: +- Monitor Bitcoin transactions for multiple descriptors +- Track address derivation and UTXO management +- Store wallet state and transaction history persistently +- Expose wallet state via standardized interfaces (Electrum protocol) +- Support multiple blockchain provider backends (BDK, future providers) + +## Architecture + +The wallet is organized into three primary layers: + +``` +┌─────────────────────────────────────────────────┐ +│ Wallet Service (Orchestration) │ +│ - Wallet lifecycle management │ +│ - Transaction event coordination │ +│ - Balance aggregation │ +└──────────────┬──────────────────────────────────┘ + │ + ┌───────┴────────┬──────────────┐ + │ │ │ +┌──────▼────────┐ ┌────▼──────┐ ┌────▼───────┐ +│ Provider │ │Repository │ │ Metadata │ +│ (Discovery) │ │(Storage) │ │(State) │ +└───────────────┘ └───────────┘ └────────────┘ +``` + +### Layer Responsibilities + +#### **Provider Layer** (`provider/`) +Handles transaction discovery and descriptor-specific data retrieval: +- Persists Bitcoin descriptors +- Deriving addresses from descriptors +- Detects incoming and outgoing transactions +- Tracks UTXOs per descriptor +- Calculates balances with confirmation requirements +- Processes blockchain events (blocks, mempool) + +#### **Repository Layer** (`repository/`) +Manages persistent wallet state at the wallet level: +- Stores wallet names (**wallet lifecycle**) +- Persists descriptors with metadata (active flag, change flag, labels) +- Indexes transactions for Electrum responses +- Tracks script buffers for address monitoring +- Provides migration-based SQLite backend + +#### **Service Layer** (`service/`) +Orchestrates operations across provider, repository, and metadata: +- Manages wallet creation and loading +- Coordinates descriptor lifecycle (add, activate, deactivate) +- Aggregates balance from all descriptors +- Routes blockchain events to appropriate handlers +- Provides unified wallet interface to clients + +#### **Metadata Layer** (`metadata/`) +Maintains in-memory wallet configuration: +- Active descriptor management per category (external/change) +- Descriptor state administration +- Handles descriptor transitions when adding new descriptors +- Enforces business rules (e.g., single active descriptor) + +## Component Architecture + +### Class Diagram + +```mermaid +classDiagram + class Wallet{ + +process_block(block, height) Vec~(Transaction, TxOut)~ + +process_mempool_transactions(txs) Vec~TxOut~ + +get_balance(params) Amount + +get_balances() Balance + +new_address(is_change) Address + +create_wallet(name) void + +load_wallet(name) void + +push_descriptor(descriptor) void + } + + class WalletService{ + -provider: WalletProvider + -persister: WalletPersist + -metadata: WalletMetadata + -process_block_inner(block, height) + -process_event(events, block, height) + -get_provider() WalletProvider + -get_metadata() WalletMetadata + } + + class WalletProvider{ + <> + +persist_descriptor(id, descriptor) + +block_process(block, height) Vec~WalletProviderEvent~ + +get_transaction(txid) Transaction + +get_balance(ids, params) Amount + +get_balances(ids) Balance + +new_address(id) Address + +list_script_buff(ids) Vec~ScriptBuf~ + } + + class WalletPersist{ + <> + +create_wallet(name) String + +load_wallet(name) Vec~DbDescriptor~ + +insert_or_update_descriptor(descriptor) + +get_descriptor(id, wallet) DbDescriptor + +insert_or_update_transaction(tx) + +get_transaction(txid) DbTransaction + +insert_or_update_script_buffer(script) + } + + class WalletMetadata{ + -name: String + -active_external: DescriptorInfoMetadata + -active_internal: DescriptorInfoMetadata + +add_descriptor(desc, is_change, is_active) + +get_active_descriptor(is_change) DescriptorInfoMetadata + +get_descriptors() Vec~DescriptorInfoMetadata~ + } + + class BdkWalletProvider{ + -connection: Connection + -keyring: Keyring + +persist_descriptor(id, descriptor) + +block_process(block, height) Vec~WalletProviderEvent~ + } + + class SqliteRepository{ + -conn: Mutex~Connection~ + +create_wallet(name) String + +load_wallet(name) Vec~DbDescriptor~ + +insert_or_update_descriptor(descriptor) + } + + class WalletProviderEvent{ + <> + UpdateTransaction + UnconfirmedTransactionInBlock + ConfirmedTransaction + } + + Wallet <|-- WalletService + WalletService --> WalletProvider + WalletService --> WalletPersist + WalletService --> WalletMetadata + BdkWalletProvider ..|> WalletProvider + SqliteRepository ..|> WalletPersist + WalletProvider --> WalletProviderEvent +``` + +### Data Flow: Block Processing + +```mermaid +sequenceDiagram + participant Client + participant Service as WalletService + participant Provider as WalletProvider + participant Repository as WalletPersist + + Client->>Service: process_block(block, height) + Service->>Provider: block_process(block, height) + Provider->>Provider: scan transactions + Provider-->>Service: Vec~WalletProviderEvent~ + + Service->>Service: process_event(events) + + alt UpdateTransaction + Service->>Repository: insert_or_update_script_buffer(script) + else ConfirmedTransaction + Service->>Service: calculate merkle proof + Service->>Repository: insert_or_update_transaction(tx) + else UnconfirmedTransactionInBlock + Service->>Repository: insert_or_update_transaction(tx) + end + + Service-->>Client: Vec~(Transaction, TxOut)~ +``` + +## Usage Examples + +### Creating a Watch-Only Wallet + +```rust no-run +use floresta_watch_only::service::new_wallet; +use bitcoin::Network; + +// Create a new wallet instance +let wallet = new_wallet("./wallet_data", Network::Bitcoin)?; + +// Create a wallet with a name +wallet.create_wallet("my_wallet")?; +``` + +### Adding Descriptors + +```rust no-run +use floresta_watch_only::models::ImportDescriptor; + +let descriptor = ImportDescriptor { + descriptor: "wpkh(tpubDDtyive2LqLWKzPZ8LZ9Ebi1JDoLcf1cEpn3Mshp6sxVfCupHZJRPQTozp2EpTF76vJcyQBN7VP7CjUntEJxeADnuTMNTYKoSWNae8soVyv/0/*)#7h6kdtnk".to_string(), + label: Some("receiving".to_string()), + is_active: true, + is_change: false, +}; + +wallet.push_descriptor(&descriptor)?; +``` + +### Processing Blocks + +```rust no-run +use bitcoin::Block; + +// Process a new block and get affected transactions +let transactions = wallet.process_block(&block, block_height)?; + +for (tx, output) in transactions { + println!("Received: {} satoshis", output.value.to_sat()); +} +``` + +### Querying Balances + +```rust no-run +use floresta_watch_only::models::GetBalanceParams; + +// Get balance with 1 confirmation minimum +let balance = wallet.get_balance(GetBalanceParams { + minconf: 1, + avoid_reuse: false, +})?; + +println!("Balance: {} BTC", balance.to_btc()); + +// Get detailed balance breakdowns +let balances = wallet.get_balances()?; +println!("Trusted: {}", balances.trusted.to_btc()); +println!("Unconfirmed: {}", balances.untrusted_pending.to_btc()); +println!("Immature: {}", balances.immature.to_btc()); +``` + +### Generating Addresses + +```rust no-run +// Generate external address +let address = wallet.new_address(false)?; +println!("Receive at: {}", address); + +// Generate change address +let change_address = wallet.new_address(true)?; +println!("Change at: {}", change_address); +``` + +### Transaction Queries + +```rust no-run +use bitcoin::Txid; + +// Get a specific transaction +let tx = wallet.get_transaction(&txid)?; + +// Get transaction history for an address +let history = wallet.get_address_history(&script_hash)?; + +// Get merkle proof for confirmed transaction +let proof = wallet.get_merkle_proof(&txid)?; + +// Find all unconfirmed transactions +let unconfirmed = wallet.find_unconfirmed()?; +``` + +## Feature Flags + +The crate uses feature flags to enable different implementations: + +### `bdk-provider` +Enables the BDK-based wallet provider for transaction discovery. +- Requires: `bdk-wallet` dependency +- Provides: Descriptor-based address derivation and transaction scanning + +### `sqlite` +Enables SQLite-backed persistence layer for wallet state. +- Requires: `rusqlite`, `refinery` dependencies +- Provides: Durable wallet, descriptor, and transaction storage + +### Recommended Combinations + +- **Full Watch-Only Wallet**: `bdk-provider` + `sqlite` +- **Development/Testing**: `memory-database` (in-memory storage) + +```toml +# In your Cargo.toml +[dependencies] +floresta-watch-only = { version = "0.4", features = ["bdk-provider", "sqlite"] } +``` + +## Error Handling + +The crate provides specific error types for each layer: + +- **`WalletProviderError`**: Transaction discovery and descriptor issues +- **`WalletPersistError`**: Storage and database operations +- **`WalletServiceError`**: High-level wallet operations +- **`WalletMetadataError`**: Descriptor state management + +## Concurrency Model + +The wallet uses `RwLock` for thread-safe metadata access: +- Multiple readers can query wallet state concurrently +- Writes (adding descriptors, processing blocks) acquire exclusive locks +- Repository operations use internal `Mutex` for SQLite compatibility + +## Development + +### Running Tests + +```bash +# Unit tests +cargo test --lib + +# Provider tests (requires bdk-provider feature) +cargo test --test provider --features bdk-provider,sqlite + +# Service tests (requires both features) +cargo test --test service --features bdk-provider,sqlite + +# All tests +cargo test --features bdk-provider,sqlite +``` + +### Building Documentation + +```bash +cargo doc --features bdk-provider,sqlite --open +``` + +## License + +Licensed under either of Apache License, Version 2.0 or MIT license at your option. From 700fd0a438129ee4ac2516df43bd45fed6282981 Mon Sep 17 00:00:00 2001 From: moisesPomilio <93723302+moisesPompilio@users.noreply.github.com> Date: Thu, 9 Apr 2026 01:01:44 -0300 Subject: [PATCH 5/5] chore: update rust-toolchain version to 1.85.0 in CI workflows --- .github/workflows/benchmarks.yml | 2 +- .github/workflows/rust.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 105e8fb1f..977016e1f 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -14,7 +14,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@1.81.0 + - uses: dtolnay/rust-toolchain@1.85.0 with: components: rustfmt, clippy diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 8490bc09f..981b17e44 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -48,7 +48,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@1.81.0 # The version in our `Cargo.toml` + - uses: dtolnay/rust-toolchain@1.85.0 # The version in our `Cargo.toml` with: components: rustfmt, clippy - uses: taiki-e/install-action@cargo-hack @@ -146,7 +146,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@1.81.0 + - uses: dtolnay/rust-toolchain@1.85.0 with: # Common bare-metal Cortex-M target (no_std: `core` + `alloc`). targets: thumbv7em-none-eabi