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 diff --git a/Cargo.lock b/Cargo.lock index 9b4f1ff1b..06f745c97 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,11 +1241,14 @@ dependencies = [ name = "floresta-watch-only" version = "0.4.0" dependencies = [ + "bdk_wallet", "bitcoin", "floresta-chain", "floresta-common", "kv", "rand", + "refinery", + "rusqlite", "serde", "serde_json", "tracing", @@ -1397,6 +1449,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 +1470,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 +1774,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", ] [[package]] @@ -1836,6 +1907,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 +1957,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 +2020,7 @@ checksum = "487906208f38448e186e3deb02f2b8ef046a9078b0de00bdb28bf4fb9b76951c" dependencies = [ "bech32", "bitcoin", + "serde", ] [[package]] @@ -2409,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" @@ -2452,6 +2579,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 +2674,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 +2706,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ "bitcoin_hashes 0.14.1", + "rand", "secp256k1-sys", "serde", ] @@ -2698,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" @@ -3065,6 +3213,7 @@ dependencies = [ "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", + "toml_write", "winnow", ] @@ -3077,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" @@ -3340,6 +3495,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..823e73c36 100644 --- a/crates/floresta-watch-only/Cargo.toml +++ b/crates/floresta-watch-only/Cargo.toml @@ -16,6 +16,9 @@ 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"] } +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 } @@ -25,8 +28,10 @@ floresta-common = { workspace = true, features = ["descriptors-no-std"] } rand = { workspace = true } [features] -default = ["std"] +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/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. 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 a0ab5843f..069c89e49 100644 --- a/crates/floresta-watch-only/src/lib.rs +++ b/crates/floresta-watch-only/src/lib.rs @@ -26,6 +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; @@ -1010,3 +1015,84 @@ mod test { assert_eq!(address.utxos.len(), 1); } } + +#[allow(clippy::non_minimal_cfg)] +#[cfg(all(test, any(feature = "bdk-provider", feature = "sqlite")))] +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/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/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/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()); + } +} 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 new file mode 100644 index 000000000..a60043fca --- /dev/null +++ b/crates/floresta-watch-only/tests/common/mod.rs @@ -0,0 +1,199 @@ +#![cfg(any(feature = "bdk-provider", feature = "sqlite"))] + +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); +} 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()); +}