diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f1ec14fc..850351327 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ -# 0.7.0-rc.18 (Synonym Fork) +# 0.7.0-rc.22 (Synonym Fork) ## Bug Fixes + - Backported upstream Electrum sync fix (PR #4341): Skip unconfirmed `get_history` entries in `ElectrumSyncClient`. Previously, mempool entries (height=0 or -1) were incorrectly treated as confirmed, causing `get_merkle` to fail for 0-conf channel funding transactions. @@ -8,6 +9,23 @@ emitted when LDK replays events after node restart. ## Synonym Fork Additions + +- Added multi-address type wallet support with a new `bdk-wallet-aggregate` crate: + - `AddressType` enum: `Legacy`, `NestedSegwit`, `NativeSegwit`, `Taproot` + - `NodeBuilder::set_address_type()` to configure the primary wallet address type + - `NodeBuilder::set_address_types_to_monitor()` to track funds across multiple address types + - `Node::get_balance_for_address_type()` to query per-wallet balances + - `Node::list_monitored_address_types()` to list loaded wallet types + - `OnchainPayment::new_address_for_type()` to generate addresses for a specific type + - `send_to_address` / `send_all_to_address`: unified coin selection pools UTXOs from all loaded wallets and selects optimally across the full set; `send_all` drains all wallets + - RBF and CPFP fee bumping work across wallets (cross-wallet inputs are re-signed) + - Channel funding uses unified coin selection across all SegWit wallets (NestedSegwit can fund channels; Legacy excluded per BOLT 2) + - Channel shutdown and destination scripts always use native witness addresses, with automatic fallback to a loaded NativeSegwit/Taproot wallet when the primary is Legacy or NestedSegwit + - Splicing is restricted to native witness primaries (NativeSegwit/Taproot); non-native primaries return a graceful error + - Change outputs go to the primary wallet + - Monitored wallets are synced in parallel alongside the primary (Esplora and Electrum) + - P2PKH and P2SH UTXOs are now handled in `OutputSpender` (previously panicked on non-witness scripts) + - Migration-safe persistence: existing NativeSegwit wallet data is read from the legacy (un-namespaced) storage location - Upgraded to Kotlin 2.2.0 for compatibility with consuming apps using Kotlin 2.x - Added JitPack support for `ldk-node-jvm` module to enable unit testing in consuming apps - Added runtime-adjustable wallet sync intervals for battery optimization on mobile: @@ -88,11 +106,13 @@ would use internally. # 0.7.0 - Dec. 3, 2025 + This seventh minor release introduces numerous new features, bug fixes, and API improvements. In particular, it adds support for channel Splicing, Async Payments, as well as sourcing chain data from a Bitcoin Core REST backend. ## Feature and API updates + - Experimental support for channel splicing has been added. (#677) - - **Note**: Splicing-related transactions might currently still get misclassified in the payment store. + - **Note**: Splicing-related transactions might currently still get misclassified in the payment store. - Support for serving and paying static invoices for Async Payments has been added. (#621, #632) - Sourcing chain data via Bitcoin Core's REST interface is now supported. (#526) - A new `Builder::set_chain_source_esplora_with_headers` method has been added @@ -111,6 +131,7 @@ This seventh minor release introduces numerous new features, bug fixes, and API - The `generate_entropy_mnemonic` method now supports specifying a word count. (#699) ## Bug Fixes and Improvements + - Robustness of the shutdown procedure has been improved, minimizing risk of blocking during `Node::stop`. (#592, #612, #619, #622) - The VSS storage backend now supports 'lazy' deletes, allowing it to avoid unnecessarily waiting on remote calls for certain operations. (#689, #722) @@ -127,6 +148,7 @@ This seventh minor release introduces numerous new features, bug fixes, and API - The node now listens on all provided listening addresses. (#644) ## Compatibility Notes + - The minimum supported Rust version (MSRV) has been bumped to `rustc` v1.85 (#606) - The LDK dependency has been bumped to v0.2. - The BDK dependency has been bumped to v2.2. (#656) @@ -155,11 +177,13 @@ deletions in 264 commits from 14 authors in alphabetical order: - tosynthegeek # 0.6.2 - Aug. 14, 2025 + This patch release fixes a panic that could have been hit when syncing to a TLS-enabled Electrum server, as well as some minor issues when shutting down the node. ## Bug Fixes and Improvements + - If not set by the user, we now install a default `CryptoProvider` for the `rustls` TLS library. This fixes an issue that would have the node panic whenever they first try to access an Electrum server behind an `ssl://` @@ -176,15 +200,18 @@ deletions in 13 commits from 2 authors in alphabetical order: - moisesPomilio # 0.6.1 - Jun. 19, 2025 + This patch release fixes minor issues with the recently-exposed `Bolt11Invoice` type in bindings. ## Feature and API updates + - The `Bolt11Invoice::description` method is now exposed as `Bolt11Invoice::invoice_description` in bindings, to avoid collisions with a Swift standard method of same name (#576) ## Bug Fixes and Improvements + - The `Display` implementation of `Bolt11Invoice` is now exposed in bindings, (re-)allowing to render the invoice as a string. (#574) @@ -194,16 +221,19 @@ in 8 commits from 1 author in alphabetical order: - Elias Rohrer # 0.6.0 - Jun. 9, 2025 + This sixth minor release mainly fixes an issue that could have left the on-chain wallet unable to spend funds if transactions that had previously been accepted to the mempool ended up being evicted. ## Feature and API updates + - Onchain addresses are now validated against the expected network before use (#519). - The API methods on the `Bolt11Invoice` type are now exposed in bindings (#522). - The `UnifiedQrPayment::receive` flow no longer aborts if we're unable to generate a BOLT12 offer (#548). ## Bug Fixes and Improvements + - Previously, the node could potentially enter a state that would have left the onchain wallet unable spend any funds if previously-generated transactions had been first accepted, and then evicted from the mempool. This has been @@ -213,6 +243,7 @@ accepted to the mempool ended up being evicted. - The output of the `log` facade logger has been corrected (#547). ## Compatibility Notes + - The BDK dependency has been bumped to `bdk_wallet` v2.0 (#551). In total, this release features 20 files changed, 1188 insertions, 447 deletions, in 18 commits from 3 authors in alphabetical order: @@ -222,9 +253,11 @@ In total, this release features 20 files changed, 1188 insertions, 447 deletions - Elias Rohrer # 0.5.0 - Apr. 29, 2025 + Besides numerous API improvements and bugfixes this fifth minor release notably adds support for sourcing chain and fee rate data from an Electrum backend, requesting channels via the [bLIP-51 / LSPS1](https://github.com/lightning/blips/blob/master/blip-0051.md) protocol, as well as experimental support for operating as a [bLIP-52 / LSPS2](https://github.com/lightning/blips/blob/master/blip-0052.md) service. ## Feature and API updates + - The `PaymentSuccessful` event now exposes a `payment_preimage` field (#392). - The node now emits `PaymentForwarded` events for forwarded payments (#404). - The ability to send custom TLVs as part of spontaneous payments has been added (#411). @@ -238,7 +271,7 @@ Besides numerous API improvements and bugfixes this fifth minor release notably - On-chain transactions are now added to the internal payment store and exposed via `Node::list_payments` (#432). - Inbound announced channels are now rejected if not all requirements for operating as a forwarding node (set listening addresses and node alias) have been met (#467). - Initial support for operating as an bLIP-52 / LSPS2 service has been added (#420). - - **Note**: bLIP-52 / LSPS2 support is considered 'alpha'/'experimental' and should *not* yet be used in production. + - **Note**: bLIP-52 / LSPS2 support is considered 'alpha'/'experimental' and should _not_ yet be used in production. - The `Builder::set_entropy_seed_bytes` method now takes an array rather than a `Vec` (#493). - The builder will now return a `NetworkMismatch` error in case of network switching (#485). - The `Bolt11Jit` payment variant now exposes a field telling how much fee the LSP withheld (#497). @@ -248,6 +281,7 @@ Besides numerous API improvements and bugfixes this fifth minor release notably - The ability to sync the node via an Electrum backend has been added (#486). ## Bug Fixes and Improvements + - When syncing from Bitcoin Core RPC, syncing mempool entries has been made more efficient (#410, #465). - We now ensure the our configured fallback rates are used when the configured chain source would return huge bogus values during fee estimation (#430). - We now re-enabled trying to bump Anchor channel transactions for trusted counterparties in the `ContentiousClaimable` case to reduce the risk of losing funds in certain edge cases (#461). @@ -255,6 +289,7 @@ Besides numerous API improvements and bugfixes this fifth minor release notably - The `Node::remove_payment` now also removes the respective entry from the in-memory state, not only from the persisted payment store (#514). ## Compatibility Notes + - The filesystem logger was simplified and its default path changed to `ldk_node.log` in the configured storage directory (#394). - The BDK dependency has been bumped to `bdk_wallet` v1.0 (#426). - The LDK dependency has been bumped to `lightning` v0.1 (#426). @@ -295,7 +330,6 @@ In total, this release features 1 files changed, 40 insertions, 4 deletions in 3 - Fuyin - Elias Rohrer - # 0.4.1 - Oct 18, 2024 This patch release fixes a wallet syncing issue where full syncs were used instead of incremental syncs, and vice versa (#383). @@ -311,10 +345,11 @@ In total, this release features 3 files changed, 13 insertions, 9 deletions in 6 Besides numerous API improvements and bugfixes this fourth minor release notably adds support for sourcing chain and fee rate data from a Bitcoin Core RPC backend, as well as experimental support for the [VSS] remote storage backend. ## Feature and API updates + - Support for multiple chain sources has been added. To this end, Esplora-specific configuration options can now be given via `EsploraSyncConfig` to `Builder::set_chain_source_esplora`. Furthermore, all configuration objects (including the main `Config`) is now exposed via the `config` sub-module (#365). - Support for sourcing chain and fee estimation data from a Bitcoin Core RPC backed has been added (#370). - Initial experimental support for an encrypted [VSS] remote storage backend has been added (#369, #376, #378). - - **Caution**: VSS support is in **alpha** and is considered experimental. Using VSS (or any remote persistence) may cause LDK to panic if persistence failures are unrecoverable, i.e., if they remain unresolved after internal retries are exhausted. + - **Caution**: VSS support is in **alpha** and is considered experimental. Using VSS (or any remote persistence) may cause LDK to panic if persistence failures are unrecoverable, i.e., if they remain unresolved after internal retries are exhausted. - Support for setting the `NodeAlias` in public node announcements as been added. We now ensure that announced channels can only be opened and accepted when the required configuration options to operate as a public forwarding node are set (listening addresses and node alias). As part of this `Node::connect_open_channel` was split into `open_channel` and `open_announced_channel` API methods. (#330, #366). - The `Node` can now be started via a new `Node::start_with_runtime` call that allows to reuse an outer `tokio` runtime context, avoiding runtime stacking when run in `async` environments (#319). - Support for generating and paying unified QR codes has been added (#302). @@ -322,16 +357,19 @@ Besides numerous API improvements and bugfixes this fourth minor release notably - Support for setting additional parameters when sending BOLT11 payments has been added (#336, #351). ## Bug Fixes + - The `ChannelConfig` object has been refactored, now allowing to query the currently applied `MaxDustHTLCExposure` limit (#350). - A bug potentially leading to panicking on shutdown when stacking `tokio` runtime contexts has been fixed (#373). - We now no longer panic when hitting a persistence failure during event handling. Instead, events will be replayed until successful (#374). -, + , + ## Compatibility Notes + - The LDK dependency has been updated to version 0.0.125 (#358, #375). - The BDK dependency has been updated to version 1.0-beta.4 (#358). - - Going forward, the BDK state will be persisted in the configured `KVStore` backend. - - **Note**: The old descriptor state will *not* be automatically migrated on upgrade, potentially leading to address reuse. Privacy-concious users might want to manually advance the descriptor by requesting new addresses until it reaches the previously observed height. - - After the node as been successfully upgraded users may safely delete `bdk_wallet_*.sqlite` from the storage path. + - Going forward, the BDK state will be persisted in the configured `KVStore` backend. + - **Note**: The old descriptor state will _not_ be automatically migrated on upgrade, potentially leading to address reuse. Privacy-concious users might want to manually advance the descriptor by requesting new addresses until it reaches the previously observed height. + - After the node as been successfully upgraded users may safely delete `bdk_wallet_*.sqlite` from the storage path. - The `rust-bitcoin` dependency has been updated to version 0.32.2 (#358). - The UniFFI dependency has been updated to version 0.27.3 (#379). - The `bip21` dependency has been updated to version 0.5 (#358). @@ -354,6 +392,7 @@ This third minor release notably adds support for BOLT12 payments, Anchor channels, and sourcing inbound liquidity via LSPS2 just-in-time channels. ## Feature and API updates + - Support for creating and paying BOLT12 offers and refunds has been added (#265). - Support for Anchor channels has been added (#141). - Support for sourcing inbound liquidity via LSPS2 just-in-time (JIT) channels has been added (#223). @@ -371,6 +410,7 @@ channels, and sourcing inbound liquidity via LSPS2 just-in-time channels. - The ability to register and claim from custom payment hashes generated outside of LDK Node has been added (#308). ## Bug Fixes + - Node announcements are now correctly only broadcast if we have any public, sufficiently confirmed channels (#248, #314). - Falling back to default fee values is now disallowed on mainnet, ensuring we won't startup without a successful fee cache update (#249). - Persisted peers are now correctly reconnected after startup (#265). @@ -378,6 +418,7 @@ channels, and sourcing inbound liquidity via LSPS2 just-in-time channels. - Several steps have been taken to reduce the risk of blocking node operation on wallet syncing in the face of unresponsive Esplora services (#281). ## Compatibility Notes + - LDK has been updated to version 0.0.123 (#291). In total, this release features 54 files changed, 7282 insertions, 2410 deletions in 165 commits from 3 authors, in alphabetical order: @@ -406,9 +447,11 @@ This is a bugfix release bumping the used LDK and BDK dependencies to the latest stable versions. ## Bug Fixes + - Swift bindings now can be built on macOS again. ## Compatibility Notes + - LDK has been updated to version 0.0.121 (#214, #229) - BDK has been updated to version 0.29.0 (#229) @@ -422,6 +465,7 @@ deletions in 26 commits from 3 authors, in alphabetical order: # 0.2.0 - Dec 13, 2023 ## Feature and API updates + - The capability to send pre-flight probes has been added (#147). - Pre-flight probes will skip outbound channels based on the liquidity available (#156). - Additional fields are now exposed via `ChannelDetails` (#165). @@ -432,10 +476,12 @@ deletions in 26 commits from 3 authors, in alphabetical order: - A module persisting, sweeping, and rebroadcasting output spends has been added (#152). ## Bug Fixes + - No errors are logged anymore when we choose to omit spending of `StaticOutput`s (#137). - An inconsistent state of the log file symlink no longer results in an error during startup (#153). ## Compatibility Notes + - Our currently supported minimum Rust version (MSRV) is 1.63.0. - The Rust crate edition has been bumped to 2021. - Building on Windows is now supported (#160). @@ -454,6 +500,7 @@ In total, this release features 57 files changed, 7369 insertions, 1738 deletion - Orbital # 0.1.0 - Jun 22, 2023 + This is the first non-experimental release of LDK Node. - Log files are now split based on the start date of the node (#116). @@ -467,15 +514,18 @@ This is the first non-experimental release of LDK Node. - The API has been updated to be more aligned between Rust and bindings (#114). ## Compatibility Notes + - Our currently supported minimum Rust version (MSRV) is 1.60.0. - The superfluous `SendingFailed` payment status has been removed, breaking serialization compatibility with alpha releases (#125). - The serialization formats of `PaymentDetails` and `Event` types have been updated, ensuring users upgrading from an alpha release fail to start rather than continuing operating with bogus data. Alpha users should wipe their persisted payment metadata (`payments/*`) and event queue (`events`) after the update (#130). In total, this release includes changes in 52 commits from 2 authors: + - Elias Rohrer - Richard Ulrich # 0.1-alpha.1 - Jun 6, 2023 + - Generation of Swift, Kotlin (JVM and Android), and Python bindings is now supported through UniFFI (#25). - Lists of connected peers and channels may now be retrieved in bindings (#56). - Gossip data may now be sourced from the P2P network, or a Rapid Gossip Sync server (#70). @@ -490,8 +540,8 @@ In total, this release includes changes in 52 commits from 2 authors: - The wallet sync intervals are now configurable (#102). - Granularity of logging can now be configured (#108). - In total, this release includes changes in 64 commits from 4 authors: + - Steve Myers - Elias Rohrer - Jurvis Tan @@ -501,6 +551,7 @@ In total, this release includes changes in 64 commits from 4 authors: production, and no compatibility guarantees are given until the release of 0.1. # 0.1-alpha - Apr 27, 2023 + This is the first alpha release of LDK Node. It features support for sourcing chain data via an Esplora server, file system persistence, gossip sourcing via the Lightning peer-to-peer network, and configurable entropy sources for the diff --git a/Cargo.toml b/Cargo.toml index 76c8fda7a..b399aa503 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,10 @@ +[workspace] +members = [".", "crates/bdk-wallet-aggregate"] +exclude = ["bindings/uniffi-bindgen"] + [package] name = "ldk-node" -version = "0.7.0-rc.18" +version = "0.7.0-rc.22" authors = ["Elias Rohrer "] homepage = "https://lightningdevkit.org/" license = "MIT OR Apache-2.0" @@ -45,6 +49,7 @@ bdk_chain = { version = "0.23.0", default-features = false, features = ["std"] } bdk_esplora = { version = "0.22.0", default-features = false, features = ["async-https-rustls", "tokio"]} bdk_electrum = { version = "0.23.0", default-features = false, features = ["use-rustls-ring"]} bdk_wallet = { version = "2.2.0", default-features = false, features = ["std", "keys-bip39"]} +bdk-wallet-aggregate = { path = "crates/bdk-wallet-aggregate" } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } rustls = { version = "0.23", default-features = false } diff --git a/Package.swift b/Package.swift index ab06e22ba..b42c3e299 100644 --- a/Package.swift +++ b/Package.swift @@ -3,8 +3,8 @@ import PackageDescription -let tag = "v0.7.0-rc.18" -let checksum = "05903150276c3c31b2552b89d3781157ac1bbf55a10598655897abd9fe936b6c" +let tag = "v0.7.0-rc.22" +let checksum = "3ec3b86365ecfb925cf00c5268bf92176188f06db04a4775c76f249e2cffabfd" let url = "https://github.com/synonymdev/ldk-node/releases/download/\(tag)/LDKNodeFFI.xcframework.zip" let package = Package( diff --git a/bindings/kotlin/ldk-node-android/gradle.properties b/bindings/kotlin/ldk-node-android/gradle.properties index 2b0abdb57..d6fcc821e 100644 --- a/bindings/kotlin/ldk-node-android/gradle.properties +++ b/bindings/kotlin/ldk-node-android/gradle.properties @@ -2,4 +2,4 @@ org.gradle.jvmargs=-Xmx1536m android.useAndroidX=true android.enableJetifier=true kotlin.code.style=official -libraryVersion=0.7.0-rc.18 +libraryVersion=0.7.0-rc.22 diff --git a/bindings/kotlin/ldk-node-android/lib/src/main/jniLibs/arm64-v8a/libldk_node.so b/bindings/kotlin/ldk-node-android/lib/src/main/jniLibs/arm64-v8a/libldk_node.so index 1798f33a0..8b0c8982f 100755 Binary files a/bindings/kotlin/ldk-node-android/lib/src/main/jniLibs/arm64-v8a/libldk_node.so and b/bindings/kotlin/ldk-node-android/lib/src/main/jniLibs/arm64-v8a/libldk_node.so differ diff --git a/bindings/kotlin/ldk-node-android/lib/src/main/jniLibs/armeabi-v7a/libldk_node.so b/bindings/kotlin/ldk-node-android/lib/src/main/jniLibs/armeabi-v7a/libldk_node.so index 45afa3297..b5067839e 100755 Binary files a/bindings/kotlin/ldk-node-android/lib/src/main/jniLibs/armeabi-v7a/libldk_node.so and b/bindings/kotlin/ldk-node-android/lib/src/main/jniLibs/armeabi-v7a/libldk_node.so differ diff --git a/bindings/kotlin/ldk-node-android/lib/src/main/jniLibs/x86_64/libldk_node.so b/bindings/kotlin/ldk-node-android/lib/src/main/jniLibs/x86_64/libldk_node.so index b75d024e8..611fb7f87 100755 Binary files a/bindings/kotlin/ldk-node-android/lib/src/main/jniLibs/x86_64/libldk_node.so and b/bindings/kotlin/ldk-node-android/lib/src/main/jniLibs/x86_64/libldk_node.so differ diff --git a/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.android.kt b/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.android.kt index 9aca7ae51..d97998ebf 100644 --- a/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.android.kt +++ b/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.android.kt @@ -1487,6 +1487,16 @@ internal typealias UniffiVTableCallbackInterfaceVssHeaderProviderUniffiByValue = + + + + + + + + + + @@ -1949,6 +1959,16 @@ internal interface UniffiLib : Library { `headerProvider`: Pointer?, uniffiCallStatus: UniffiRustCallStatus, ): Pointer? + fun uniffi_ldk_node_fn_method_builder_set_address_type( + `ptr`: Pointer?, + `addressType`: RustBufferByValue, + uniffiCallStatus: UniffiRustCallStatus, + ): Unit + fun uniffi_ldk_node_fn_method_builder_set_address_types_to_monitor( + `ptr`: Pointer?, + `addressTypesToMonitor`: RustBufferByValue, + uniffiCallStatus: UniffiRustCallStatus, + ): Unit fun uniffi_ldk_node_fn_method_builder_set_announcement_addresses( `ptr`: Pointer?, `announcementAddresses`: RustBufferByValue, @@ -2230,6 +2250,11 @@ internal interface UniffiLib : Library { `addressStr`: RustBufferByValue, uniffiCallStatus: UniffiRustCallStatus, ): Long + fun uniffi_ldk_node_fn_method_node_get_balance_for_address_type( + `ptr`: Pointer?, + `addressType`: RustBufferByValue, + uniffiCallStatus: UniffiRustCallStatus, + ): RustBufferByValue fun uniffi_ldk_node_fn_method_node_get_transaction_details( `ptr`: Pointer?, `txid`: RustBufferByValue, @@ -2243,6 +2268,10 @@ internal interface UniffiLib : Library { `ptr`: Pointer?, uniffiCallStatus: UniffiRustCallStatus, ): RustBufferByValue + fun uniffi_ldk_node_fn_method_node_list_monitored_address_types( + `ptr`: Pointer?, + uniffiCallStatus: UniffiRustCallStatus, + ): RustBufferByValue fun uniffi_ldk_node_fn_method_node_list_payments( `ptr`: Pointer?, uniffiCallStatus: UniffiRustCallStatus, @@ -2500,6 +2529,11 @@ internal interface UniffiLib : Library { `ptr`: Pointer?, uniffiCallStatus: UniffiRustCallStatus, ): RustBufferByValue + fun uniffi_ldk_node_fn_method_onchainpayment_new_address_for_type( + `ptr`: Pointer?, + `addressType`: RustBufferByValue, + uniffiCallStatus: UniffiRustCallStatus, + ): RustBufferByValue fun uniffi_ldk_node_fn_method_onchainpayment_select_utxos_with_algorithm( `ptr`: Pointer?, `targetAmountSats`: Long, @@ -3039,6 +3073,10 @@ internal interface UniffiLib : Library { ): Short fun uniffi_ldk_node_checksum_method_builder_build_with_vss_store_and_header_provider( ): Short + fun uniffi_ldk_node_checksum_method_builder_set_address_type( + ): Short + fun uniffi_ldk_node_checksum_method_builder_set_address_types_to_monitor( + ): Short fun uniffi_ldk_node_checksum_method_builder_set_announcement_addresses( ): Short fun uniffi_ldk_node_checksum_method_builder_set_async_payments_role( @@ -3127,12 +3165,16 @@ internal interface UniffiLib : Library { ): Short fun uniffi_ldk_node_checksum_method_node_get_address_balance( ): Short + fun uniffi_ldk_node_checksum_method_node_get_balance_for_address_type( + ): Short fun uniffi_ldk_node_checksum_method_node_get_transaction_details( ): Short fun uniffi_ldk_node_checksum_method_node_list_balances( ): Short fun uniffi_ldk_node_checksum_method_node_list_channels( ): Short + fun uniffi_ldk_node_checksum_method_node_list_monitored_address_types( + ): Short fun uniffi_ldk_node_checksum_method_node_list_payments( ): Short fun uniffi_ldk_node_checksum_method_node_list_peers( @@ -3223,6 +3265,8 @@ internal interface UniffiLib : Library { ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_new_address( ): Short + fun uniffi_ldk_node_checksum_method_onchainpayment_new_address_for_type( + ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_select_utxos_with_algorithm( ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_send_all_to_address( @@ -3508,6 +3552,12 @@ private fun uniffiCheckApiChecksums(lib: UniffiLib) { if (lib.uniffi_ldk_node_checksum_method_builder_build_with_vss_store_and_header_provider() != 9090.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_ldk_node_checksum_method_builder_set_address_type() != 647.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_ldk_node_checksum_method_builder_set_address_types_to_monitor() != 23561.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_ldk_node_checksum_method_builder_set_announcement_addresses() != 39271.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -3640,6 +3690,9 @@ private fun uniffiCheckApiChecksums(lib: UniffiLib) { if (lib.uniffi_ldk_node_checksum_method_node_get_address_balance() != 45284.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_ldk_node_checksum_method_node_get_balance_for_address_type() != 34906.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_ldk_node_checksum_method_node_get_transaction_details() != 65000.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -3649,6 +3702,9 @@ private fun uniffiCheckApiChecksums(lib: UniffiLib) { if (lib.uniffi_ldk_node_checksum_method_node_list_channels() != 7954.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_ldk_node_checksum_method_node_list_monitored_address_types() != 25084.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_ldk_node_checksum_method_node_list_payments() != 35002.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -3784,6 +3840,9 @@ private fun uniffiCheckApiChecksums(lib: UniffiLib) { if (lib.uniffi_ldk_node_checksum_method_onchainpayment_new_address() != 37251.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_ldk_node_checksum_method_onchainpayment_new_address_for_type() != 9083.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_ldk_node_checksum_method_onchainpayment_select_utxos_with_algorithm() != 14084.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -5641,6 +5700,30 @@ open class Builder: Disposable, BuilderInterface { }) } + override fun `setAddressType`(`addressType`: AddressType) { + callWithPointer { + uniffiRustCall { uniffiRustCallStatus -> + UniffiLib.INSTANCE.uniffi_ldk_node_fn_method_builder_set_address_type( + it, + FfiConverterTypeAddressType.lower(`addressType`), + uniffiRustCallStatus, + ) + } + } + } + + override fun `setAddressTypesToMonitor`(`addressTypesToMonitor`: List) { + callWithPointer { + uniffiRustCall { uniffiRustCallStatus -> + UniffiLib.INSTANCE.uniffi_ldk_node_fn_method_builder_set_address_types_to_monitor( + it, + FfiConverterSequenceTypeAddressType.lower(`addressTypesToMonitor`), + uniffiRustCallStatus, + ) + } + } + } + @Throws(BuildException::class) override fun `setAnnouncementAddresses`(`announcementAddresses`: List) { callWithPointer { @@ -6953,6 +7036,19 @@ open class Node: Disposable, NodeInterface { }) } + @Throws(NodeException::class) + override fun `getBalanceForAddressType`(`addressType`: AddressType): AddressTypeBalance { + return FfiConverterTypeAddressTypeBalance.lift(callWithPointer { + uniffiRustCallWithError(NodeExceptionErrorHandler) { uniffiRustCallStatus -> + UniffiLib.INSTANCE.uniffi_ldk_node_fn_method_node_get_balance_for_address_type( + it, + FfiConverterTypeAddressType.lower(`addressType`), + uniffiRustCallStatus, + ) + } + }) + } + override fun `getTransactionDetails`(`txid`: Txid): TransactionDetails? { return FfiConverterOptionalTypeTransactionDetails.lift(callWithPointer { uniffiRustCall { uniffiRustCallStatus -> @@ -6987,6 +7083,17 @@ open class Node: Disposable, NodeInterface { }) } + override fun `listMonitoredAddressTypes`(): List { + return FfiConverterSequenceTypeAddressType.lift(callWithPointer { + uniffiRustCall { uniffiRustCallStatus -> + UniffiLib.INSTANCE.uniffi_ldk_node_fn_method_node_list_monitored_address_types( + it, + uniffiRustCallStatus, + ) + } + }) + } + override fun `listPayments`(): List { return FfiConverterSequenceTypePaymentDetails.lift(callWithPointer { uniffiRustCall { uniffiRustCallStatus -> @@ -7846,6 +7953,19 @@ open class OnchainPayment: Disposable, OnchainPaymentInterface { }) } + @Throws(NodeException::class) + override fun `newAddressForType`(`addressType`: AddressType): Address { + return FfiConverterTypeAddress.lift(callWithPointer { + uniffiRustCallWithError(NodeExceptionErrorHandler) { uniffiRustCallStatus -> + UniffiLib.INSTANCE.uniffi_ldk_node_fn_method_onchainpayment_new_address_for_type( + it, + FfiConverterTypeAddressType.lower(`addressType`), + uniffiRustCallStatus, + ) + } + }) + } + @Throws(NodeException::class) override fun `selectUtxosWithAlgorithm`(`targetAmountSats`: kotlin.ULong, `feeRate`: FeeRate?, `algorithm`: CoinSelectionAlgorithm, `utxos`: List?): List { return FfiConverterSequenceTypeSpendableUtxo.lift(callWithPointer { @@ -8737,6 +8857,28 @@ object FfiConverterTypeVssHeaderProvider: FfiConverter { + override fun read(buf: ByteBuffer): AddressTypeBalance { + return AddressTypeBalance( + FfiConverterULong.read(buf), + FfiConverterULong.read(buf), + ) + } + + override fun allocationSize(value: AddressTypeBalance) = ( + FfiConverterULong.allocationSize(value.`totalSats`) + + FfiConverterULong.allocationSize(value.`spendableSats`) + ) + + override fun write(value: AddressTypeBalance, buf: ByteBuffer) { + FfiConverterULong.write(value.`totalSats`, buf) + FfiConverterULong.write(value.`spendableSats`, buf) + } +} + + + + object FfiConverterTypeAnchorChannelsConfig: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): AnchorChannelsConfig { return AnchorChannelsConfig( @@ -9086,6 +9228,8 @@ object FfiConverterTypeConfig: FfiConverterRustBuffer { FfiConverterOptionalTypeAnchorChannelsConfig.read(buf), FfiConverterOptionalTypeRouteParametersConfig.read(buf), FfiConverterBoolean.read(buf), + FfiConverterTypeAddressType.read(buf), + FfiConverterSequenceTypeAddressType.read(buf), ) } @@ -9099,7 +9243,9 @@ object FfiConverterTypeConfig: FfiConverterRustBuffer { FfiConverterULong.allocationSize(value.`probingLiquidityLimitMultiplier`) + FfiConverterOptionalTypeAnchorChannelsConfig.allocationSize(value.`anchorChannelsConfig`) + FfiConverterOptionalTypeRouteParametersConfig.allocationSize(value.`routeParameters`) + - FfiConverterBoolean.allocationSize(value.`includeUntrustedPendingInSpendable`) + FfiConverterBoolean.allocationSize(value.`includeUntrustedPendingInSpendable`) + + FfiConverterTypeAddressType.allocationSize(value.`addressType`) + + FfiConverterSequenceTypeAddressType.allocationSize(value.`addressTypesToMonitor`) ) override fun write(value: Config, buf: ByteBuffer) { @@ -9113,6 +9259,8 @@ object FfiConverterTypeConfig: FfiConverterRustBuffer { FfiConverterOptionalTypeAnchorChannelsConfig.write(value.`anchorChannelsConfig`, buf) FfiConverterOptionalTypeRouteParametersConfig.write(value.`routeParameters`, buf) FfiConverterBoolean.write(value.`includeUntrustedPendingInSpendable`, buf) + FfiConverterTypeAddressType.write(value.`addressType`, buf) + FfiConverterSequenceTypeAddressType.write(value.`addressTypesToMonitor`, buf) } } @@ -9854,6 +10002,24 @@ object FfiConverterTypeTxOutput: FfiConverterRustBuffer { +object FfiConverterTypeAddressType: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer) = try { + AddressType.entries[buf.getInt() - 1] + } catch (e: IndexOutOfBoundsException) { + throw RuntimeException("invalid enum value, something is very wrong!!", e) + } + + override fun allocationSize(value: AddressType) = 4UL + + override fun write(value: AddressType, buf: ByteBuffer) { + buf.putInt(value.ordinal + 1) + } +} + + + + + object FfiConverterTypeAsyncPaymentsRole: FfiConverterRustBuffer { override fun read(buf: ByteBuffer) = try { AsyncPaymentsRole.entries[buf.getInt() - 1] @@ -13487,6 +13653,31 @@ object FfiConverterSequenceTypeTxOutput: FfiConverterRustBuffer> +object FfiConverterSequenceTypeAddressType: FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { + val len = buf.getInt() + return List(len) { + FfiConverterTypeAddressType.read(buf) + } + } + + override fun allocationSize(value: List): ULong { + val sizeForLength = 4UL + val sizeForItems = value.sumOf { FfiConverterTypeAddressType.allocationSize(it) } + return sizeForLength + sizeForItems + } + + override fun write(value: List, buf: ByteBuffer) { + buf.putInt(value.size) + value.iterator().forEach { + FfiConverterTypeAddressType.write(it, buf) + } + } +} + + + + object FfiConverterSequenceTypeLightningBalance: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() diff --git a/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.common.kt b/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.common.kt index 572ca6180..66177cbd0 100644 --- a/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.common.kt +++ b/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.common.kt @@ -299,6 +299,10 @@ interface BuilderInterface { @Throws(BuildException::class) fun `buildWithVssStoreAndHeaderProvider`(`vssUrl`: kotlin.String, `storeId`: kotlin.String, `headerProvider`: VssHeaderProvider): Node + fun `setAddressType`(`addressType`: AddressType) + + fun `setAddressTypesToMonitor`(`addressTypesToMonitor`: List) + @Throws(BuildException::class) fun `setAnnouncementAddresses`(`announcementAddresses`: List) @@ -441,12 +445,17 @@ interface NodeInterface { @Throws(NodeException::class) fun `getAddressBalance`(`addressStr`: kotlin.String): kotlin.ULong + @Throws(NodeException::class) + fun `getBalanceForAddressType`(`addressType`: AddressType): AddressTypeBalance + fun `getTransactionDetails`(`txid`: Txid): TransactionDetails? fun `listBalances`(): BalanceDetails fun `listChannels`(): List + fun `listMonitoredAddressTypes`(): List + fun `listPayments`(): List fun `listPeers`(): List @@ -569,6 +578,9 @@ interface OnchainPaymentInterface { @Throws(NodeException::class) fun `newAddress`(): Address + @Throws(NodeException::class) + fun `newAddressForType`(`addressType`: AddressType): Address + @Throws(NodeException::class) fun `selectUtxosWithAlgorithm`(`targetAmountSats`: kotlin.ULong, `feeRate`: FeeRate?, `algorithm`: CoinSelectionAlgorithm, `utxos`: List?): List @@ -660,6 +672,16 @@ interface VssHeaderProviderInterface { +@kotlinx.serialization.Serializable +data class AddressTypeBalance ( + val `totalSats`: kotlin.ULong, + val `spendableSats`: kotlin.ULong +) { + companion object +} + + + @kotlinx.serialization.Serializable data class AnchorChannelsConfig ( val `trustedPeersNoReserve`: List, @@ -807,7 +829,9 @@ data class Config ( val `probingLiquidityLimitMultiplier`: kotlin.ULong, val `anchorChannelsConfig`: AnchorChannelsConfig?, val `routeParameters`: RouteParametersConfig?, - val `includeUntrustedPendingInSpendable`: kotlin.Boolean + val `includeUntrustedPendingInSpendable`: kotlin.Boolean, + val `addressType`: AddressType, + val `addressTypesToMonitor`: List ) { companion object } @@ -1165,6 +1189,22 @@ data class TxOutput ( +@kotlinx.serialization.Serializable +enum class AddressType { + + LEGACY, + NESTED_SEGWIT, + NATIVE_SEGWIT, + TAPROOT; + companion object +} + + + + + + + @kotlinx.serialization.Serializable enum class AsyncPaymentsRole { @@ -2174,6 +2214,8 @@ enum class WordCount { + + diff --git a/bindings/kotlin/ldk-node-jvm/gradle.properties b/bindings/kotlin/ldk-node-jvm/gradle.properties index f2e17b9f8..54e754965 100644 --- a/bindings/kotlin/ldk-node-jvm/gradle.properties +++ b/bindings/kotlin/ldk-node-jvm/gradle.properties @@ -1,3 +1,3 @@ org.gradle.jvmargs=-Xmx1536m kotlin.code.style=official -libraryVersion=0.7.0-rc.18 +libraryVersion=0.7.0-rc.22 diff --git a/bindings/kotlin/ldk-node-jvm/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.common.kt b/bindings/kotlin/ldk-node-jvm/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.common.kt index 572ca6180..66177cbd0 100644 --- a/bindings/kotlin/ldk-node-jvm/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.common.kt +++ b/bindings/kotlin/ldk-node-jvm/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.common.kt @@ -299,6 +299,10 @@ interface BuilderInterface { @Throws(BuildException::class) fun `buildWithVssStoreAndHeaderProvider`(`vssUrl`: kotlin.String, `storeId`: kotlin.String, `headerProvider`: VssHeaderProvider): Node + fun `setAddressType`(`addressType`: AddressType) + + fun `setAddressTypesToMonitor`(`addressTypesToMonitor`: List) + @Throws(BuildException::class) fun `setAnnouncementAddresses`(`announcementAddresses`: List) @@ -441,12 +445,17 @@ interface NodeInterface { @Throws(NodeException::class) fun `getAddressBalance`(`addressStr`: kotlin.String): kotlin.ULong + @Throws(NodeException::class) + fun `getBalanceForAddressType`(`addressType`: AddressType): AddressTypeBalance + fun `getTransactionDetails`(`txid`: Txid): TransactionDetails? fun `listBalances`(): BalanceDetails fun `listChannels`(): List + fun `listMonitoredAddressTypes`(): List + fun `listPayments`(): List fun `listPeers`(): List @@ -569,6 +578,9 @@ interface OnchainPaymentInterface { @Throws(NodeException::class) fun `newAddress`(): Address + @Throws(NodeException::class) + fun `newAddressForType`(`addressType`: AddressType): Address + @Throws(NodeException::class) fun `selectUtxosWithAlgorithm`(`targetAmountSats`: kotlin.ULong, `feeRate`: FeeRate?, `algorithm`: CoinSelectionAlgorithm, `utxos`: List?): List @@ -660,6 +672,16 @@ interface VssHeaderProviderInterface { +@kotlinx.serialization.Serializable +data class AddressTypeBalance ( + val `totalSats`: kotlin.ULong, + val `spendableSats`: kotlin.ULong +) { + companion object +} + + + @kotlinx.serialization.Serializable data class AnchorChannelsConfig ( val `trustedPeersNoReserve`: List, @@ -807,7 +829,9 @@ data class Config ( val `probingLiquidityLimitMultiplier`: kotlin.ULong, val `anchorChannelsConfig`: AnchorChannelsConfig?, val `routeParameters`: RouteParametersConfig?, - val `includeUntrustedPendingInSpendable`: kotlin.Boolean + val `includeUntrustedPendingInSpendable`: kotlin.Boolean, + val `addressType`: AddressType, + val `addressTypesToMonitor`: List ) { companion object } @@ -1165,6 +1189,22 @@ data class TxOutput ( +@kotlinx.serialization.Serializable +enum class AddressType { + + LEGACY, + NESTED_SEGWIT, + NATIVE_SEGWIT, + TAPROOT; + companion object +} + + + + + + + @kotlinx.serialization.Serializable enum class AsyncPaymentsRole { @@ -2174,6 +2214,8 @@ enum class WordCount { + + diff --git a/bindings/kotlin/ldk-node-jvm/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.jvm.kt b/bindings/kotlin/ldk-node-jvm/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.jvm.kt index 050492d2f..3a2430505 100644 --- a/bindings/kotlin/ldk-node-jvm/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.jvm.kt +++ b/bindings/kotlin/ldk-node-jvm/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.jvm.kt @@ -1485,6 +1485,16 @@ internal typealias UniffiVTableCallbackInterfaceVssHeaderProviderUniffiByValue = + + + + + + + + + + @@ -1947,6 +1957,16 @@ internal interface UniffiLib : Library { `headerProvider`: Pointer?, uniffiCallStatus: UniffiRustCallStatus, ): Pointer? + fun uniffi_ldk_node_fn_method_builder_set_address_type( + `ptr`: Pointer?, + `addressType`: RustBufferByValue, + uniffiCallStatus: UniffiRustCallStatus, + ): Unit + fun uniffi_ldk_node_fn_method_builder_set_address_types_to_monitor( + `ptr`: Pointer?, + `addressTypesToMonitor`: RustBufferByValue, + uniffiCallStatus: UniffiRustCallStatus, + ): Unit fun uniffi_ldk_node_fn_method_builder_set_announcement_addresses( `ptr`: Pointer?, `announcementAddresses`: RustBufferByValue, @@ -2228,6 +2248,11 @@ internal interface UniffiLib : Library { `addressStr`: RustBufferByValue, uniffiCallStatus: UniffiRustCallStatus, ): Long + fun uniffi_ldk_node_fn_method_node_get_balance_for_address_type( + `ptr`: Pointer?, + `addressType`: RustBufferByValue, + uniffiCallStatus: UniffiRustCallStatus, + ): RustBufferByValue fun uniffi_ldk_node_fn_method_node_get_transaction_details( `ptr`: Pointer?, `txid`: RustBufferByValue, @@ -2241,6 +2266,10 @@ internal interface UniffiLib : Library { `ptr`: Pointer?, uniffiCallStatus: UniffiRustCallStatus, ): RustBufferByValue + fun uniffi_ldk_node_fn_method_node_list_monitored_address_types( + `ptr`: Pointer?, + uniffiCallStatus: UniffiRustCallStatus, + ): RustBufferByValue fun uniffi_ldk_node_fn_method_node_list_payments( `ptr`: Pointer?, uniffiCallStatus: UniffiRustCallStatus, @@ -2498,6 +2527,11 @@ internal interface UniffiLib : Library { `ptr`: Pointer?, uniffiCallStatus: UniffiRustCallStatus, ): RustBufferByValue + fun uniffi_ldk_node_fn_method_onchainpayment_new_address_for_type( + `ptr`: Pointer?, + `addressType`: RustBufferByValue, + uniffiCallStatus: UniffiRustCallStatus, + ): RustBufferByValue fun uniffi_ldk_node_fn_method_onchainpayment_select_utxos_with_algorithm( `ptr`: Pointer?, `targetAmountSats`: Long, @@ -3037,6 +3071,10 @@ internal interface UniffiLib : Library { ): Short fun uniffi_ldk_node_checksum_method_builder_build_with_vss_store_and_header_provider( ): Short + fun uniffi_ldk_node_checksum_method_builder_set_address_type( + ): Short + fun uniffi_ldk_node_checksum_method_builder_set_address_types_to_monitor( + ): Short fun uniffi_ldk_node_checksum_method_builder_set_announcement_addresses( ): Short fun uniffi_ldk_node_checksum_method_builder_set_async_payments_role( @@ -3125,12 +3163,16 @@ internal interface UniffiLib : Library { ): Short fun uniffi_ldk_node_checksum_method_node_get_address_balance( ): Short + fun uniffi_ldk_node_checksum_method_node_get_balance_for_address_type( + ): Short fun uniffi_ldk_node_checksum_method_node_get_transaction_details( ): Short fun uniffi_ldk_node_checksum_method_node_list_balances( ): Short fun uniffi_ldk_node_checksum_method_node_list_channels( ): Short + fun uniffi_ldk_node_checksum_method_node_list_monitored_address_types( + ): Short fun uniffi_ldk_node_checksum_method_node_list_payments( ): Short fun uniffi_ldk_node_checksum_method_node_list_peers( @@ -3221,6 +3263,8 @@ internal interface UniffiLib : Library { ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_new_address( ): Short + fun uniffi_ldk_node_checksum_method_onchainpayment_new_address_for_type( + ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_select_utxos_with_algorithm( ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_send_all_to_address( @@ -3506,6 +3550,12 @@ private fun uniffiCheckApiChecksums(lib: UniffiLib) { if (lib.uniffi_ldk_node_checksum_method_builder_build_with_vss_store_and_header_provider() != 9090.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_ldk_node_checksum_method_builder_set_address_type() != 647.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_ldk_node_checksum_method_builder_set_address_types_to_monitor() != 23561.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_ldk_node_checksum_method_builder_set_announcement_addresses() != 39271.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -3638,6 +3688,9 @@ private fun uniffiCheckApiChecksums(lib: UniffiLib) { if (lib.uniffi_ldk_node_checksum_method_node_get_address_balance() != 45284.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_ldk_node_checksum_method_node_get_balance_for_address_type() != 34906.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_ldk_node_checksum_method_node_get_transaction_details() != 65000.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -3647,6 +3700,9 @@ private fun uniffiCheckApiChecksums(lib: UniffiLib) { if (lib.uniffi_ldk_node_checksum_method_node_list_channels() != 7954.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_ldk_node_checksum_method_node_list_monitored_address_types() != 25084.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_ldk_node_checksum_method_node_list_payments() != 35002.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -3782,6 +3838,9 @@ private fun uniffiCheckApiChecksums(lib: UniffiLib) { if (lib.uniffi_ldk_node_checksum_method_onchainpayment_new_address() != 37251.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_ldk_node_checksum_method_onchainpayment_new_address_for_type() != 9083.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_ldk_node_checksum_method_onchainpayment_select_utxos_with_algorithm() != 14084.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -5630,6 +5689,30 @@ open class Builder: Disposable, BuilderInterface { }) } + override fun `setAddressType`(`addressType`: AddressType) { + callWithPointer { + uniffiRustCall { uniffiRustCallStatus -> + UniffiLib.INSTANCE.uniffi_ldk_node_fn_method_builder_set_address_type( + it, + FfiConverterTypeAddressType.lower(`addressType`), + uniffiRustCallStatus, + ) + } + } + } + + override fun `setAddressTypesToMonitor`(`addressTypesToMonitor`: List) { + callWithPointer { + uniffiRustCall { uniffiRustCallStatus -> + UniffiLib.INSTANCE.uniffi_ldk_node_fn_method_builder_set_address_types_to_monitor( + it, + FfiConverterSequenceTypeAddressType.lower(`addressTypesToMonitor`), + uniffiRustCallStatus, + ) + } + } + } + @Throws(BuildException::class) override fun `setAnnouncementAddresses`(`announcementAddresses`: List) { callWithPointer { @@ -6942,6 +7025,19 @@ open class Node: Disposable, NodeInterface { }) } + @Throws(NodeException::class) + override fun `getBalanceForAddressType`(`addressType`: AddressType): AddressTypeBalance { + return FfiConverterTypeAddressTypeBalance.lift(callWithPointer { + uniffiRustCallWithError(NodeExceptionErrorHandler) { uniffiRustCallStatus -> + UniffiLib.INSTANCE.uniffi_ldk_node_fn_method_node_get_balance_for_address_type( + it, + FfiConverterTypeAddressType.lower(`addressType`), + uniffiRustCallStatus, + ) + } + }) + } + override fun `getTransactionDetails`(`txid`: Txid): TransactionDetails? { return FfiConverterOptionalTypeTransactionDetails.lift(callWithPointer { uniffiRustCall { uniffiRustCallStatus -> @@ -6976,6 +7072,17 @@ open class Node: Disposable, NodeInterface { }) } + override fun `listMonitoredAddressTypes`(): List { + return FfiConverterSequenceTypeAddressType.lift(callWithPointer { + uniffiRustCall { uniffiRustCallStatus -> + UniffiLib.INSTANCE.uniffi_ldk_node_fn_method_node_list_monitored_address_types( + it, + uniffiRustCallStatus, + ) + } + }) + } + override fun `listPayments`(): List { return FfiConverterSequenceTypePaymentDetails.lift(callWithPointer { uniffiRustCall { uniffiRustCallStatus -> @@ -7835,6 +7942,19 @@ open class OnchainPayment: Disposable, OnchainPaymentInterface { }) } + @Throws(NodeException::class) + override fun `newAddressForType`(`addressType`: AddressType): Address { + return FfiConverterTypeAddress.lift(callWithPointer { + uniffiRustCallWithError(NodeExceptionErrorHandler) { uniffiRustCallStatus -> + UniffiLib.INSTANCE.uniffi_ldk_node_fn_method_onchainpayment_new_address_for_type( + it, + FfiConverterTypeAddressType.lower(`addressType`), + uniffiRustCallStatus, + ) + } + }) + } + @Throws(NodeException::class) override fun `selectUtxosWithAlgorithm`(`targetAmountSats`: kotlin.ULong, `feeRate`: FeeRate?, `algorithm`: CoinSelectionAlgorithm, `utxos`: List?): List { return FfiConverterSequenceTypeSpendableUtxo.lift(callWithPointer { @@ -8726,6 +8846,28 @@ object FfiConverterTypeVssHeaderProvider: FfiConverter { + override fun read(buf: ByteBuffer): AddressTypeBalance { + return AddressTypeBalance( + FfiConverterULong.read(buf), + FfiConverterULong.read(buf), + ) + } + + override fun allocationSize(value: AddressTypeBalance) = ( + FfiConverterULong.allocationSize(value.`totalSats`) + + FfiConverterULong.allocationSize(value.`spendableSats`) + ) + + override fun write(value: AddressTypeBalance, buf: ByteBuffer) { + FfiConverterULong.write(value.`totalSats`, buf) + FfiConverterULong.write(value.`spendableSats`, buf) + } +} + + + + object FfiConverterTypeAnchorChannelsConfig: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): AnchorChannelsConfig { return AnchorChannelsConfig( @@ -9075,6 +9217,8 @@ object FfiConverterTypeConfig: FfiConverterRustBuffer { FfiConverterOptionalTypeAnchorChannelsConfig.read(buf), FfiConverterOptionalTypeRouteParametersConfig.read(buf), FfiConverterBoolean.read(buf), + FfiConverterTypeAddressType.read(buf), + FfiConverterSequenceTypeAddressType.read(buf), ) } @@ -9088,7 +9232,9 @@ object FfiConverterTypeConfig: FfiConverterRustBuffer { FfiConverterULong.allocationSize(value.`probingLiquidityLimitMultiplier`) + FfiConverterOptionalTypeAnchorChannelsConfig.allocationSize(value.`anchorChannelsConfig`) + FfiConverterOptionalTypeRouteParametersConfig.allocationSize(value.`routeParameters`) + - FfiConverterBoolean.allocationSize(value.`includeUntrustedPendingInSpendable`) + FfiConverterBoolean.allocationSize(value.`includeUntrustedPendingInSpendable`) + + FfiConverterTypeAddressType.allocationSize(value.`addressType`) + + FfiConverterSequenceTypeAddressType.allocationSize(value.`addressTypesToMonitor`) ) override fun write(value: Config, buf: ByteBuffer) { @@ -9102,6 +9248,8 @@ object FfiConverterTypeConfig: FfiConverterRustBuffer { FfiConverterOptionalTypeAnchorChannelsConfig.write(value.`anchorChannelsConfig`, buf) FfiConverterOptionalTypeRouteParametersConfig.write(value.`routeParameters`, buf) FfiConverterBoolean.write(value.`includeUntrustedPendingInSpendable`, buf) + FfiConverterTypeAddressType.write(value.`addressType`, buf) + FfiConverterSequenceTypeAddressType.write(value.`addressTypesToMonitor`, buf) } } @@ -9843,6 +9991,24 @@ object FfiConverterTypeTxOutput: FfiConverterRustBuffer { +object FfiConverterTypeAddressType: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer) = try { + AddressType.entries[buf.getInt() - 1] + } catch (e: IndexOutOfBoundsException) { + throw RuntimeException("invalid enum value, something is very wrong!!", e) + } + + override fun allocationSize(value: AddressType) = 4UL + + override fun write(value: AddressType, buf: ByteBuffer) { + buf.putInt(value.ordinal + 1) + } +} + + + + + object FfiConverterTypeAsyncPaymentsRole: FfiConverterRustBuffer { override fun read(buf: ByteBuffer) = try { AsyncPaymentsRole.entries[buf.getInt() - 1] @@ -13476,6 +13642,31 @@ object FfiConverterSequenceTypeTxOutput: FfiConverterRustBuffer> +object FfiConverterSequenceTypeAddressType: FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { + val len = buf.getInt() + return List(len) { + FfiConverterTypeAddressType.read(buf) + } + } + + override fun allocationSize(value: List): ULong { + val sizeForLength = 4UL + val sizeForItems = value.sumOf { FfiConverterTypeAddressType.allocationSize(it) } + return sizeForLength + sizeForItems + } + + override fun write(value: List, buf: ByteBuffer) { + buf.putInt(value.size) + value.iterator().forEach { + FfiConverterTypeAddressType.write(it, buf) + } + } +} + + + + object FfiConverterSequenceTypeLightningBalance: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() diff --git a/bindings/kotlin/ldk-node-jvm/lib/src/main/resources/darwin-aarch64/libldk_node.dylib b/bindings/kotlin/ldk-node-jvm/lib/src/main/resources/darwin-aarch64/libldk_node.dylib index b77fef4a4..bcd2e1a4d 100644 Binary files a/bindings/kotlin/ldk-node-jvm/lib/src/main/resources/darwin-aarch64/libldk_node.dylib and b/bindings/kotlin/ldk-node-jvm/lib/src/main/resources/darwin-aarch64/libldk_node.dylib differ diff --git a/bindings/kotlin/ldk-node-jvm/lib/src/main/resources/darwin-x86-64/libldk_node.dylib b/bindings/kotlin/ldk-node-jvm/lib/src/main/resources/darwin-x86-64/libldk_node.dylib index c9b9a44e9..99fe380d3 100644 Binary files a/bindings/kotlin/ldk-node-jvm/lib/src/main/resources/darwin-x86-64/libldk_node.dylib and b/bindings/kotlin/ldk-node-jvm/lib/src/main/resources/darwin-x86-64/libldk_node.dylib differ diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index 2cfdf54c1..d5cd8e144 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -17,6 +17,8 @@ dictionary Config { AnchorChannelsConfig? anchor_channels_config; RouteParametersConfig? route_parameters; boolean include_untrusted_pending_in_spendable; + AddressType address_type; + sequence address_types_to_monitor; }; dictionary AnchorChannelsConfig { @@ -65,6 +67,13 @@ enum WordCount { "Words24", }; +enum AddressType { + "Legacy", + "NestedSegwit", + "NativeSegwit", + "Taproot", +}; + enum LogLevel { "Gossip", "Trace", @@ -114,6 +123,8 @@ interface Builder { void set_log_facade_logger(); void set_custom_logger(LogWriter log_writer); void set_network(Network network); + void set_address_type(AddressType address_type); + void set_address_types_to_monitor(sequence address_types_to_monitor); [Throws=BuildError] void set_listening_addresses(sequence listening_addresses); [Throws=BuildError] @@ -184,6 +195,9 @@ interface Node { [Throws=NodeError] void remove_payment([ByRef]PaymentId payment_id); BalanceDetails list_balances(); + [Throws=NodeError] + AddressTypeBalance get_balance_for_address_type(AddressType address_type); + sequence list_monitored_address_types(); sequence list_payments(); sequence list_peers(); sequence list_channels(); @@ -276,6 +290,8 @@ interface OnchainPayment { [Throws=NodeError] Address new_address(); [Throws=NodeError] + Address new_address_for_type(AddressType address_type); + [Throws=NodeError] sequence list_spendable_outputs(); [Throws=NodeError] sequence select_utxos_with_algorithm(u64 target_amount_sats, FeeRate? fee_rate, CoinSelectionAlgorithm algorithm, sequence? utxos); @@ -392,8 +408,8 @@ enum NodeError { "TransactionAlreadyConfirmed", "NoSpendableOutputs", "CoinSelectionFailed", - "InvalidMnemonic", - "BackgroundSyncNotEnabled", + "InvalidMnemonic", + "BackgroundSyncNotEnabled", }; dictionary NodeStatus { @@ -771,6 +787,11 @@ dictionary BalanceDetails { sequence pending_balances_from_channel_closures; }; +dictionary AddressTypeBalance { + u64 total_sats; + u64 spendable_sats; +}; + dictionary ChannelConfig { u32 forwarding_fee_proportional_millionths; u32 forwarding_fee_base_msat; diff --git a/bindings/python/pyproject.toml b/bindings/python/pyproject.toml index 079fbce67..ea72f1e0c 100644 --- a/bindings/python/pyproject.toml +++ b/bindings/python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ldk_node" -version = "0.7.0-rc.18" +version = "0.7.0-rc.22" authors = [ { name="Elias Rohrer", email="dev@tnull.de" }, ] diff --git a/bindings/python/src/ldk_node/ldk_node.py b/bindings/python/src/ldk_node/ldk_node.py index facc250f5..dd32add08 100644 --- a/bindings/python/src/ldk_node/ldk_node.py +++ b/bindings/python/src/ldk_node/ldk_node.py @@ -601,6 +601,10 @@ def _uniffi_check_api_checksums(lib): raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_builder_build_with_vss_store_and_header_provider() != 9090: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_ldk_node_checksum_method_builder_set_address_type() != 647: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_ldk_node_checksum_method_builder_set_address_types_to_monitor() != 23561: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_builder_set_announcement_addresses() != 39271: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_builder_set_async_payments_role() != 16463: @@ -689,12 +693,16 @@ def _uniffi_check_api_checksums(lib): raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_node_get_address_balance() != 45284: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_ldk_node_checksum_method_node_get_balance_for_address_type() != 34906: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_node_get_transaction_details() != 65000: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_node_list_balances() != 57528: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_node_list_channels() != 7954: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_ldk_node_checksum_method_node_list_monitored_address_types() != 25084: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_node_list_payments() != 35002: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_node_list_peers() != 14889: @@ -785,6 +793,8 @@ def _uniffi_check_api_checksums(lib): raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_onchainpayment_new_address() != 37251: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_ldk_node_checksum_method_onchainpayment_new_address_for_type() != 9083: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_onchainpayment_select_utxos_with_algorithm() != 14084: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_onchainpayment_send_all_to_address() != 37748: @@ -1464,6 +1474,18 @@ class _UniffiVTableCallbackInterfaceVssHeaderProvider(ctypes.Structure): ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.uniffi_ldk_node_fn_method_builder_build_with_vss_store_and_header_provider.restype = ctypes.c_void_p +_UniffiLib.uniffi_ldk_node_fn_method_builder_set_address_type.argtypes = ( + ctypes.c_void_p, + _UniffiRustBuffer, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_ldk_node_fn_method_builder_set_address_type.restype = None +_UniffiLib.uniffi_ldk_node_fn_method_builder_set_address_types_to_monitor.argtypes = ( + ctypes.c_void_p, + _UniffiRustBuffer, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_ldk_node_fn_method_builder_set_address_types_to_monitor.restype = None _UniffiLib.uniffi_ldk_node_fn_method_builder_set_announcement_addresses.argtypes = ( ctypes.c_void_p, _UniffiRustBuffer, @@ -1802,6 +1824,12 @@ class _UniffiVTableCallbackInterfaceVssHeaderProvider(ctypes.Structure): ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.uniffi_ldk_node_fn_method_node_get_address_balance.restype = ctypes.c_uint64 +_UniffiLib.uniffi_ldk_node_fn_method_node_get_balance_for_address_type.argtypes = ( + ctypes.c_void_p, + _UniffiRustBuffer, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_ldk_node_fn_method_node_get_balance_for_address_type.restype = _UniffiRustBuffer _UniffiLib.uniffi_ldk_node_fn_method_node_get_transaction_details.argtypes = ( ctypes.c_void_p, _UniffiRustBuffer, @@ -1818,6 +1846,11 @@ class _UniffiVTableCallbackInterfaceVssHeaderProvider(ctypes.Structure): ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.uniffi_ldk_node_fn_method_node_list_channels.restype = _UniffiRustBuffer +_UniffiLib.uniffi_ldk_node_fn_method_node_list_monitored_address_types.argtypes = ( + ctypes.c_void_p, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_ldk_node_fn_method_node_list_monitored_address_types.restype = _UniffiRustBuffer _UniffiLib.uniffi_ldk_node_fn_method_node_list_payments.argtypes = ( ctypes.c_void_p, ctypes.POINTER(_UniffiRustCallStatus), @@ -2129,6 +2162,12 @@ class _UniffiVTableCallbackInterfaceVssHeaderProvider(ctypes.Structure): ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_new_address.restype = _UniffiRustBuffer +_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_new_address_for_type.argtypes = ( + ctypes.c_void_p, + _UniffiRustBuffer, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_new_address_for_type.restype = _UniffiRustBuffer _UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_select_utxos_with_algorithm.argtypes = ( ctypes.c_void_p, ctypes.c_uint64, @@ -2832,6 +2871,12 @@ class _UniffiVTableCallbackInterfaceVssHeaderProvider(ctypes.Structure): _UniffiLib.uniffi_ldk_node_checksum_method_builder_build_with_vss_store_and_header_provider.argtypes = ( ) _UniffiLib.uniffi_ldk_node_checksum_method_builder_build_with_vss_store_and_header_provider.restype = ctypes.c_uint16 +_UniffiLib.uniffi_ldk_node_checksum_method_builder_set_address_type.argtypes = ( +) +_UniffiLib.uniffi_ldk_node_checksum_method_builder_set_address_type.restype = ctypes.c_uint16 +_UniffiLib.uniffi_ldk_node_checksum_method_builder_set_address_types_to_monitor.argtypes = ( +) +_UniffiLib.uniffi_ldk_node_checksum_method_builder_set_address_types_to_monitor.restype = ctypes.c_uint16 _UniffiLib.uniffi_ldk_node_checksum_method_builder_set_announcement_addresses.argtypes = ( ) _UniffiLib.uniffi_ldk_node_checksum_method_builder_set_announcement_addresses.restype = ctypes.c_uint16 @@ -2964,6 +3009,9 @@ class _UniffiVTableCallbackInterfaceVssHeaderProvider(ctypes.Structure): _UniffiLib.uniffi_ldk_node_checksum_method_node_get_address_balance.argtypes = ( ) _UniffiLib.uniffi_ldk_node_checksum_method_node_get_address_balance.restype = ctypes.c_uint16 +_UniffiLib.uniffi_ldk_node_checksum_method_node_get_balance_for_address_type.argtypes = ( +) +_UniffiLib.uniffi_ldk_node_checksum_method_node_get_balance_for_address_type.restype = ctypes.c_uint16 _UniffiLib.uniffi_ldk_node_checksum_method_node_get_transaction_details.argtypes = ( ) _UniffiLib.uniffi_ldk_node_checksum_method_node_get_transaction_details.restype = ctypes.c_uint16 @@ -2973,6 +3021,9 @@ class _UniffiVTableCallbackInterfaceVssHeaderProvider(ctypes.Structure): _UniffiLib.uniffi_ldk_node_checksum_method_node_list_channels.argtypes = ( ) _UniffiLib.uniffi_ldk_node_checksum_method_node_list_channels.restype = ctypes.c_uint16 +_UniffiLib.uniffi_ldk_node_checksum_method_node_list_monitored_address_types.argtypes = ( +) +_UniffiLib.uniffi_ldk_node_checksum_method_node_list_monitored_address_types.restype = ctypes.c_uint16 _UniffiLib.uniffi_ldk_node_checksum_method_node_list_payments.argtypes = ( ) _UniffiLib.uniffi_ldk_node_checksum_method_node_list_payments.restype = ctypes.c_uint16 @@ -3108,6 +3159,9 @@ class _UniffiVTableCallbackInterfaceVssHeaderProvider(ctypes.Structure): _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_new_address.argtypes = ( ) _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_new_address.restype = ctypes.c_uint16 +_UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_new_address_for_type.argtypes = ( +) +_UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_new_address_for_type.restype = ctypes.c_uint16 _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_select_utxos_with_algorithm.argtypes = ( ) _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_select_utxos_with_algorithm.restype = ctypes.c_uint16 @@ -4514,6 +4568,10 @@ def build_with_vss_store_and_fixed_headers(self, vss_url: "str",store_id: "str", raise NotImplementedError def build_with_vss_store_and_header_provider(self, vss_url: "str",store_id: "str",header_provider: "VssHeaderProvider"): raise NotImplementedError + def set_address_type(self, address_type: "AddressType"): + raise NotImplementedError + def set_address_types_to_monitor(self, address_types_to_monitor: "typing.List[AddressType]"): + raise NotImplementedError def set_announcement_addresses(self, announcement_addresses: "typing.List[SocketAddress]"): raise NotImplementedError def set_async_payments_role(self, role: "typing.Optional[AsyncPaymentsRole]"): @@ -4668,6 +4726,28 @@ def build_with_vss_store_and_header_provider(self, vss_url: "str",store_id: "str + def set_address_type(self, address_type: "AddressType") -> None: + _UniffiConverterTypeAddressType.check_lower(address_type) + + _uniffi_rust_call(_UniffiLib.uniffi_ldk_node_fn_method_builder_set_address_type,self._uniffi_clone_pointer(), + _UniffiConverterTypeAddressType.lower(address_type)) + + + + + + + def set_address_types_to_monitor(self, address_types_to_monitor: "typing.List[AddressType]") -> None: + _UniffiConverterSequenceTypeAddressType.check_lower(address_types_to_monitor) + + _uniffi_rust_call(_UniffiLib.uniffi_ldk_node_fn_method_builder_set_address_types_to_monitor,self._uniffi_clone_pointer(), + _UniffiConverterSequenceTypeAddressType.lower(address_types_to_monitor)) + + + + + + def set_announcement_addresses(self, announcement_addresses: "typing.List[SocketAddress]") -> None: _UniffiConverterSequenceTypeSocketAddress.check_lower(announcement_addresses) @@ -5463,12 +5543,16 @@ def force_close_channel(self, user_channel_id: "UserChannelId",counterparty_node raise NotImplementedError def get_address_balance(self, address_str: "str"): raise NotImplementedError + def get_balance_for_address_type(self, address_type: "AddressType"): + raise NotImplementedError def get_transaction_details(self, txid: "Txid"): raise NotImplementedError def list_balances(self, ): raise NotImplementedError def list_channels(self, ): raise NotImplementedError + def list_monitored_address_types(self, ): + raise NotImplementedError def list_payments(self, ): raise NotImplementedError def list_peers(self, ): @@ -5683,6 +5767,18 @@ def get_address_balance(self, address_str: "str") -> "int": + def get_balance_for_address_type(self, address_type: "AddressType") -> "AddressTypeBalance": + _UniffiConverterTypeAddressType.check_lower(address_type) + + return _UniffiConverterTypeAddressTypeBalance.lift( + _uniffi_rust_call_with_error(_UniffiConverterTypeNodeError,_UniffiLib.uniffi_ldk_node_fn_method_node_get_balance_for_address_type,self._uniffi_clone_pointer(), + _UniffiConverterTypeAddressType.lower(address_type)) + ) + + + + + def get_transaction_details(self, txid: "Txid") -> "typing.Optional[TransactionDetails]": _UniffiConverterTypeTxid.check_lower(txid) @@ -5713,6 +5809,15 @@ def list_channels(self, ) -> "typing.List[ChannelDetails]": + def list_monitored_address_types(self, ) -> "typing.List[AddressType]": + return _UniffiConverterSequenceTypeAddressType.lift( + _uniffi_rust_call(_UniffiLib.uniffi_ldk_node_fn_method_node_list_monitored_address_types,self._uniffi_clone_pointer(),) + ) + + + + + def list_payments(self, ) -> "typing.List[PaymentDetails]": return _UniffiConverterSequenceTypePaymentDetails.lift( _uniffi_rust_call(_UniffiLib.uniffi_ldk_node_fn_method_node_list_payments,self._uniffi_clone_pointer(),) @@ -6320,6 +6425,8 @@ def list_spendable_outputs(self, ): raise NotImplementedError def new_address(self, ): raise NotImplementedError + def new_address_for_type(self, address_type: "AddressType"): + raise NotImplementedError def select_utxos_with_algorithm(self, target_amount_sats: "int",fee_rate: "typing.Optional[FeeRate]",algorithm: "CoinSelectionAlgorithm",utxos: "typing.Optional[typing.List[SpendableUtxo]]"): raise NotImplementedError def send_all_to_address(self, address: "Address",retain_reserve: "bool",fee_rate: "typing.Optional[FeeRate]"): @@ -6440,6 +6547,18 @@ def new_address(self, ) -> "Address": + def new_address_for_type(self, address_type: "AddressType") -> "Address": + _UniffiConverterTypeAddressType.check_lower(address_type) + + return _UniffiConverterTypeAddress.lift( + _uniffi_rust_call_with_error(_UniffiConverterTypeNodeError,_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_new_address_for_type,self._uniffi_clone_pointer(), + _UniffiConverterTypeAddressType.lower(address_type)) + ) + + + + + def select_utxos_with_algorithm(self, target_amount_sats: "int",fee_rate: "typing.Optional[FeeRate]",algorithm: "CoinSelectionAlgorithm",utxos: "typing.Optional[typing.List[SpendableUtxo]]") -> "typing.List[SpendableUtxo]": _UniffiConverterUInt64.check_lower(target_amount_sats) @@ -7084,6 +7203,42 @@ def write(cls, value: VssHeaderProviderProtocol, buf: _UniffiRustBuffer): buf.write_u64(cls.lower(value)) +class AddressTypeBalance: + total_sats: "int" + spendable_sats: "int" + def __init__(self, *, total_sats: "int", spendable_sats: "int"): + self.total_sats = total_sats + self.spendable_sats = spendable_sats + + def __str__(self): + return "AddressTypeBalance(total_sats={}, spendable_sats={})".format(self.total_sats, self.spendable_sats) + + def __eq__(self, other): + if self.total_sats != other.total_sats: + return False + if self.spendable_sats != other.spendable_sats: + return False + return True + +class _UniffiConverterTypeAddressTypeBalance(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return AddressTypeBalance( + total_sats=_UniffiConverterUInt64.read(buf), + spendable_sats=_UniffiConverterUInt64.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiConverterUInt64.check_lower(value.total_sats) + _UniffiConverterUInt64.check_lower(value.spendable_sats) + + @staticmethod + def write(value, buf): + _UniffiConverterUInt64.write(value.total_sats, buf) + _UniffiConverterUInt64.write(value.spendable_sats, buf) + + class AnchorChannelsConfig: trusted_peers_no_reserve: "typing.List[PublicKey]" per_channel_reserve_sats: "int" @@ -7741,7 +7896,9 @@ class Config: anchor_channels_config: "typing.Optional[AnchorChannelsConfig]" route_parameters: "typing.Optional[RouteParametersConfig]" include_untrusted_pending_in_spendable: "bool" - def __init__(self, *, storage_dir_path: "str", network: "Network", listening_addresses: "typing.Optional[typing.List[SocketAddress]]", announcement_addresses: "typing.Optional[typing.List[SocketAddress]]", node_alias: "typing.Optional[NodeAlias]", trusted_peers_0conf: "typing.List[PublicKey]", probing_liquidity_limit_multiplier: "int", anchor_channels_config: "typing.Optional[AnchorChannelsConfig]", route_parameters: "typing.Optional[RouteParametersConfig]", include_untrusted_pending_in_spendable: "bool"): + address_type: "AddressType" + address_types_to_monitor: "typing.List[AddressType]" + def __init__(self, *, storage_dir_path: "str", network: "Network", listening_addresses: "typing.Optional[typing.List[SocketAddress]]", announcement_addresses: "typing.Optional[typing.List[SocketAddress]]", node_alias: "typing.Optional[NodeAlias]", trusted_peers_0conf: "typing.List[PublicKey]", probing_liquidity_limit_multiplier: "int", anchor_channels_config: "typing.Optional[AnchorChannelsConfig]", route_parameters: "typing.Optional[RouteParametersConfig]", include_untrusted_pending_in_spendable: "bool", address_type: "AddressType", address_types_to_monitor: "typing.List[AddressType]"): self.storage_dir_path = storage_dir_path self.network = network self.listening_addresses = listening_addresses @@ -7752,9 +7909,11 @@ def __init__(self, *, storage_dir_path: "str", network: "Network", listening_add self.anchor_channels_config = anchor_channels_config self.route_parameters = route_parameters self.include_untrusted_pending_in_spendable = include_untrusted_pending_in_spendable + self.address_type = address_type + self.address_types_to_monitor = address_types_to_monitor def __str__(self): - return "Config(storage_dir_path={}, network={}, listening_addresses={}, announcement_addresses={}, node_alias={}, trusted_peers_0conf={}, probing_liquidity_limit_multiplier={}, anchor_channels_config={}, route_parameters={}, include_untrusted_pending_in_spendable={})".format(self.storage_dir_path, self.network, self.listening_addresses, self.announcement_addresses, self.node_alias, self.trusted_peers_0conf, self.probing_liquidity_limit_multiplier, self.anchor_channels_config, self.route_parameters, self.include_untrusted_pending_in_spendable) + return "Config(storage_dir_path={}, network={}, listening_addresses={}, announcement_addresses={}, node_alias={}, trusted_peers_0conf={}, probing_liquidity_limit_multiplier={}, anchor_channels_config={}, route_parameters={}, include_untrusted_pending_in_spendable={}, address_type={}, address_types_to_monitor={})".format(self.storage_dir_path, self.network, self.listening_addresses, self.announcement_addresses, self.node_alias, self.trusted_peers_0conf, self.probing_liquidity_limit_multiplier, self.anchor_channels_config, self.route_parameters, self.include_untrusted_pending_in_spendable, self.address_type, self.address_types_to_monitor) def __eq__(self, other): if self.storage_dir_path != other.storage_dir_path: @@ -7777,6 +7936,10 @@ def __eq__(self, other): return False if self.include_untrusted_pending_in_spendable != other.include_untrusted_pending_in_spendable: return False + if self.address_type != other.address_type: + return False + if self.address_types_to_monitor != other.address_types_to_monitor: + return False return True class _UniffiConverterTypeConfig(_UniffiConverterRustBuffer): @@ -7793,6 +7956,8 @@ def read(buf): anchor_channels_config=_UniffiConverterOptionalTypeAnchorChannelsConfig.read(buf), route_parameters=_UniffiConverterOptionalTypeRouteParametersConfig.read(buf), include_untrusted_pending_in_spendable=_UniffiConverterBool.read(buf), + address_type=_UniffiConverterTypeAddressType.read(buf), + address_types_to_monitor=_UniffiConverterSequenceTypeAddressType.read(buf), ) @staticmethod @@ -7807,6 +7972,8 @@ def check_lower(value): _UniffiConverterOptionalTypeAnchorChannelsConfig.check_lower(value.anchor_channels_config) _UniffiConverterOptionalTypeRouteParametersConfig.check_lower(value.route_parameters) _UniffiConverterBool.check_lower(value.include_untrusted_pending_in_spendable) + _UniffiConverterTypeAddressType.check_lower(value.address_type) + _UniffiConverterSequenceTypeAddressType.check_lower(value.address_types_to_monitor) @staticmethod def write(value, buf): @@ -7820,6 +7987,8 @@ def write(value, buf): _UniffiConverterOptionalTypeAnchorChannelsConfig.write(value.anchor_channels_config, buf) _UniffiConverterOptionalTypeRouteParametersConfig.write(value.route_parameters, buf) _UniffiConverterBool.write(value.include_untrusted_pending_in_spendable, buf) + _UniffiConverterTypeAddressType.write(value.address_type, buf) + _UniffiConverterSequenceTypeAddressType.write(value.address_types_to_monitor, buf) class CustomTlvRecord: @@ -9139,6 +9308,60 @@ def write(value, buf): +class AddressType(enum.Enum): + LEGACY = 0 + + NESTED_SEGWIT = 1 + + NATIVE_SEGWIT = 2 + + TAPROOT = 3 + + + +class _UniffiConverterTypeAddressType(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + variant = buf.read_i32() + if variant == 1: + return AddressType.LEGACY + if variant == 2: + return AddressType.NESTED_SEGWIT + if variant == 3: + return AddressType.NATIVE_SEGWIT + if variant == 4: + return AddressType.TAPROOT + raise InternalError("Raw enum value doesn't match any cases") + + @staticmethod + def check_lower(value): + if value == AddressType.LEGACY: + return + if value == AddressType.NESTED_SEGWIT: + return + if value == AddressType.NATIVE_SEGWIT: + return + if value == AddressType.TAPROOT: + return + raise ValueError(value) + + @staticmethod + def write(value, buf): + if value == AddressType.LEGACY: + buf.write_i32(1) + if value == AddressType.NESTED_SEGWIT: + buf.write_i32(2) + if value == AddressType.NATIVE_SEGWIT: + buf.write_i32(3) + if value == AddressType.TAPROOT: + buf.write_i32(4) + + + + + + + class AsyncPaymentsRole(enum.Enum): CLIENT = 0 @@ -15247,6 +15470,31 @@ def read(cls, buf): +class _UniffiConverterSequenceTypeAddressType(_UniffiConverterRustBuffer): + @classmethod + def check_lower(cls, value): + for item in value: + _UniffiConverterTypeAddressType.check_lower(item) + + @classmethod + def write(cls, value, buf): + items = len(value) + buf.write_i32(items) + for item in value: + _UniffiConverterTypeAddressType.write(item, buf) + + @classmethod + def read(cls, buf): + count = buf.read_i32() + if count < 0: + raise InternalError("Unexpected negative sequence length") + + return [ + _UniffiConverterTypeAddressType.read(buf) for i in range(count) + ] + + + class _UniffiConverterSequenceTypeLightningBalance(_UniffiConverterRustBuffer): @classmethod def check_lower(cls, value): @@ -16033,6 +16281,7 @@ def generate_entropy_mnemonic(word_count: "typing.Optional[WordCount]") -> "Mnem __all__ = [ "InternalError", + "AddressType", "AsyncPaymentsRole", "BalanceSource", "Bolt11InvoiceDescription", @@ -16058,6 +16307,7 @@ def generate_entropy_mnemonic(word_count: "typing.Optional[WordCount]") -> "Mnem "SyncType", "VssHeaderProviderError", "WordCount", + "AddressTypeBalance", "AnchorChannelsConfig", "BackgroundSyncConfig", "BalanceDetails", diff --git a/bindings/swift/Sources/LDKNode/LDKNode.swift b/bindings/swift/Sources/LDKNode/LDKNode.swift index 8dfc0cde6..1b2a1d06c 100644 --- a/bindings/swift/Sources/LDKNode/LDKNode.swift +++ b/bindings/swift/Sources/LDKNode/LDKNode.swift @@ -1562,6 +1562,10 @@ public protocol BuilderProtocol: AnyObject { func buildWithVssStoreAndHeaderProvider(vssUrl: String, storeId: String, headerProvider: VssHeaderProvider) throws -> Node + func setAddressType(addressType: AddressType) + + func setAddressTypesToMonitor(addressTypesToMonitor: [AddressType]) + func setAnnouncementAddresses(announcementAddresses: [SocketAddress]) throws func setAsyncPaymentsRole(role: AsyncPaymentsRole?) throws @@ -1711,6 +1715,18 @@ open class Builder: }) } + open func setAddressType(addressType: AddressType) { try! rustCall { + uniffi_ldk_node_fn_method_builder_set_address_type(self.uniffiClonePointer(), + FfiConverterTypeAddressType.lower(addressType), $0) + } + } + + open func setAddressTypesToMonitor(addressTypesToMonitor: [AddressType]) { try! rustCall { + uniffi_ldk_node_fn_method_builder_set_address_types_to_monitor(self.uniffiClonePointer(), + FfiConverterSequenceTypeAddressType.lower(addressTypesToMonitor), $0) + } + } + open func setAnnouncementAddresses(announcementAddresses: [SocketAddress]) throws { try rustCallWithError(FfiConverterTypeBuildError.lift) { uniffi_ldk_node_fn_method_builder_set_announcement_addresses(self.uniffiClonePointer(), FfiConverterSequenceTypeSocketAddress.lower(announcementAddresses), $0) @@ -2483,12 +2499,16 @@ public protocol NodeProtocol: AnyObject { func getAddressBalance(addressStr: String) throws -> UInt64 + func getBalanceForAddressType(addressType: AddressType) throws -> AddressTypeBalance + func getTransactionDetails(txid: Txid) -> TransactionDetails? func listBalances() -> BalanceDetails func listChannels() -> [ChannelDetails] + func listMonitoredAddressTypes() -> [AddressType] + func listPayments() -> [PaymentDetails] func listPeers() -> [PeerDetails] @@ -2670,6 +2690,13 @@ open class Node: }) } + open func getBalanceForAddressType(addressType: AddressType) throws -> AddressTypeBalance { + return try FfiConverterTypeAddressTypeBalance.lift(rustCallWithError(FfiConverterTypeNodeError.lift) { + uniffi_ldk_node_fn_method_node_get_balance_for_address_type(self.uniffiClonePointer(), + FfiConverterTypeAddressType.lower(addressType), $0) + }) + } + open func getTransactionDetails(txid: Txid) -> TransactionDetails? { return try! FfiConverterOptionTypeTransactionDetails.lift(try! rustCall { uniffi_ldk_node_fn_method_node_get_transaction_details(self.uniffiClonePointer(), @@ -2689,6 +2716,12 @@ open class Node: }) } + open func listMonitoredAddressTypes() -> [AddressType] { + return try! FfiConverterSequenceTypeAddressType.lift(try! rustCall { + uniffi_ldk_node_fn_method_node_list_monitored_address_types(self.uniffiClonePointer(), $0) + }) + } + open func listPayments() -> [PaymentDetails] { return try! FfiConverterSequenceTypePaymentDetails.lift(try! rustCall { uniffi_ldk_node_fn_method_node_list_payments(self.uniffiClonePointer(), $0) @@ -3174,6 +3207,8 @@ public protocol OnchainPaymentProtocol: AnyObject { func newAddress() throws -> Address + func newAddressForType(addressType: AddressType) throws -> Address + func selectUtxosWithAlgorithm(targetAmountSats: UInt64, feeRate: FeeRate?, algorithm: CoinSelectionAlgorithm, utxos: [SpendableUtxo]?) throws -> [SpendableUtxo] func sendAllToAddress(address: Address, retainReserve: Bool, feeRate: FeeRate?) throws -> Txid @@ -3277,6 +3312,13 @@ open class OnchainPayment: }) } + open func newAddressForType(addressType: AddressType) throws -> Address { + return try FfiConverterTypeAddress.lift(rustCallWithError(FfiConverterTypeNodeError.lift) { + uniffi_ldk_node_fn_method_onchainpayment_new_address_for_type(self.uniffiClonePointer(), + FfiConverterTypeAddressType.lower(addressType), $0) + }) + } + open func selectUtxosWithAlgorithm(targetAmountSats: UInt64, feeRate: FeeRate?, algorithm: CoinSelectionAlgorithm, utxos: [SpendableUtxo]?) throws -> [SpendableUtxo] { return try FfiConverterSequenceTypeSpendableUtxo.lift(rustCallWithError(FfiConverterTypeNodeError.lift) { uniffi_ldk_node_fn_method_onchainpayment_select_utxos_with_algorithm(self.uniffiClonePointer(), @@ -3963,6 +4005,67 @@ public func FfiConverterTypeVssHeaderProvider_lower(_ value: VssHeaderProvider) return FfiConverterTypeVssHeaderProvider.lower(value) } +public struct AddressTypeBalance { + public var totalSats: UInt64 + public var spendableSats: UInt64 + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(totalSats: UInt64, spendableSats: UInt64) { + self.totalSats = totalSats + self.spendableSats = spendableSats + } +} + +extension AddressTypeBalance: Equatable, Hashable { + public static func == (lhs: AddressTypeBalance, rhs: AddressTypeBalance) -> Bool { + if lhs.totalSats != rhs.totalSats { + return false + } + if lhs.spendableSats != rhs.spendableSats { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(totalSats) + hasher.combine(spendableSats) + } +} + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif +public struct FfiConverterTypeAddressTypeBalance: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> AddressTypeBalance { + return + try AddressTypeBalance( + totalSats: FfiConverterUInt64.read(from: &buf), + spendableSats: FfiConverterUInt64.read(from: &buf) + ) + } + + public static func write(_ value: AddressTypeBalance, into buf: inout [UInt8]) { + FfiConverterUInt64.write(value.totalSats, into: &buf) + FfiConverterUInt64.write(value.spendableSats, into: &buf) + } +} + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif +public func FfiConverterTypeAddressTypeBalance_lift(_ buf: RustBuffer) throws -> AddressTypeBalance { + return try FfiConverterTypeAddressTypeBalance.lift(buf) +} + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif +public func FfiConverterTypeAddressTypeBalance_lower(_ value: AddressTypeBalance) -> RustBuffer { + return FfiConverterTypeAddressTypeBalance.lower(value) +} + public struct AnchorChannelsConfig { public var trustedPeersNoReserve: [PublicKey] public var perChannelReserveSats: UInt64 @@ -4891,10 +4994,12 @@ public struct Config { public var anchorChannelsConfig: AnchorChannelsConfig? public var routeParameters: RouteParametersConfig? public var includeUntrustedPendingInSpendable: Bool + public var addressType: AddressType + public var addressTypesToMonitor: [AddressType] // Default memberwise initializers are never public by default, so we // declare one manually. - public init(storageDirPath: String, network: Network, listeningAddresses: [SocketAddress]?, announcementAddresses: [SocketAddress]?, nodeAlias: NodeAlias?, trustedPeers0conf: [PublicKey], probingLiquidityLimitMultiplier: UInt64, anchorChannelsConfig: AnchorChannelsConfig?, routeParameters: RouteParametersConfig?, includeUntrustedPendingInSpendable: Bool) { + public init(storageDirPath: String, network: Network, listeningAddresses: [SocketAddress]?, announcementAddresses: [SocketAddress]?, nodeAlias: NodeAlias?, trustedPeers0conf: [PublicKey], probingLiquidityLimitMultiplier: UInt64, anchorChannelsConfig: AnchorChannelsConfig?, routeParameters: RouteParametersConfig?, includeUntrustedPendingInSpendable: Bool, addressType: AddressType, addressTypesToMonitor: [AddressType]) { self.storageDirPath = storageDirPath self.network = network self.listeningAddresses = listeningAddresses @@ -4905,6 +5010,8 @@ public struct Config { self.anchorChannelsConfig = anchorChannelsConfig self.routeParameters = routeParameters self.includeUntrustedPendingInSpendable = includeUntrustedPendingInSpendable + self.addressType = addressType + self.addressTypesToMonitor = addressTypesToMonitor } } @@ -4940,6 +5047,12 @@ extension Config: Equatable, Hashable { if lhs.includeUntrustedPendingInSpendable != rhs.includeUntrustedPendingInSpendable { return false } + if lhs.addressType != rhs.addressType { + return false + } + if lhs.addressTypesToMonitor != rhs.addressTypesToMonitor { + return false + } return true } @@ -4954,6 +5067,8 @@ extension Config: Equatable, Hashable { hasher.combine(anchorChannelsConfig) hasher.combine(routeParameters) hasher.combine(includeUntrustedPendingInSpendable) + hasher.combine(addressType) + hasher.combine(addressTypesToMonitor) } } @@ -4973,7 +5088,9 @@ public struct FfiConverterTypeConfig: FfiConverterRustBuffer { probingLiquidityLimitMultiplier: FfiConverterUInt64.read(from: &buf), anchorChannelsConfig: FfiConverterOptionTypeAnchorChannelsConfig.read(from: &buf), routeParameters: FfiConverterOptionTypeRouteParametersConfig.read(from: &buf), - includeUntrustedPendingInSpendable: FfiConverterBool.read(from: &buf) + includeUntrustedPendingInSpendable: FfiConverterBool.read(from: &buf), + addressType: FfiConverterTypeAddressType.read(from: &buf), + addressTypesToMonitor: FfiConverterSequenceTypeAddressType.read(from: &buf) ) } @@ -4988,6 +5105,8 @@ public struct FfiConverterTypeConfig: FfiConverterRustBuffer { FfiConverterOptionTypeAnchorChannelsConfig.write(value.anchorChannelsConfig, into: &buf) FfiConverterOptionTypeRouteParametersConfig.write(value.routeParameters, into: &buf) FfiConverterBool.write(value.includeUntrustedPendingInSpendable, into: &buf) + FfiConverterTypeAddressType.write(value.addressType, into: &buf) + FfiConverterSequenceTypeAddressType.write(value.addressTypesToMonitor, into: &buf) } } @@ -6914,6 +7033,70 @@ public func FfiConverterTypeTxOutput_lower(_ value: TxOutput) -> RustBuffer { // Note that we don't yet support `indirect` for enums. // See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. +public enum AddressType { + case legacy + case nestedSegwit + case nativeSegwit + case taproot +} + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif +public struct FfiConverterTypeAddressType: FfiConverterRustBuffer { + typealias SwiftType = AddressType + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> AddressType { + let variant: Int32 = try readInt(&buf) + switch variant { + case 1: return .legacy + + case 2: return .nestedSegwit + + case 3: return .nativeSegwit + + case 4: return .taproot + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: AddressType, into buf: inout [UInt8]) { + switch value { + case .legacy: + writeInt(&buf, Int32(1)) + + case .nestedSegwit: + writeInt(&buf, Int32(2)) + + case .nativeSegwit: + writeInt(&buf, Int32(3)) + + case .taproot: + writeInt(&buf, Int32(4)) + } + } +} + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif +public func FfiConverterTypeAddressType_lift(_ buf: RustBuffer) throws -> AddressType { + return try FfiConverterTypeAddressType.lift(buf) +} + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif +public func FfiConverterTypeAddressType_lower(_ value: AddressType) -> RustBuffer { + return FfiConverterTypeAddressType.lower(value) +} + +extension AddressType: Equatable, Hashable {} + +// Note that we don't yet support `indirect` for enums. +// See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. + public enum AsyncPaymentsRole { case client case server @@ -10720,6 +10903,31 @@ private struct FfiConverterSequenceTypeTxOutput: FfiConverterRustBuffer { } } +#if swift(>=5.8) + @_documentation(visibility: private) +#endif +private struct FfiConverterSequenceTypeAddressType: FfiConverterRustBuffer { + typealias SwiftType = [AddressType] + + static func write(_ value: [AddressType], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeAddressType.write(item, into: &buf) + } + } + + static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [AddressType] { + let len: Int32 = try readInt(&buf) + var seq = [AddressType]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + try seq.append(FfiConverterTypeAddressType.read(from: &buf)) + } + return seq + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -12038,6 +12246,12 @@ private var initializationResult: InitializationResult = { if uniffi_ldk_node_checksum_method_builder_build_with_vss_store_and_header_provider() != 9090 { return InitializationResult.apiChecksumMismatch } + if uniffi_ldk_node_checksum_method_builder_set_address_type() != 647 { + return InitializationResult.apiChecksumMismatch + } + if uniffi_ldk_node_checksum_method_builder_set_address_types_to_monitor() != 23561 { + return InitializationResult.apiChecksumMismatch + } if uniffi_ldk_node_checksum_method_builder_set_announcement_addresses() != 39271 { return InitializationResult.apiChecksumMismatch } @@ -12170,6 +12384,9 @@ private var initializationResult: InitializationResult = { if uniffi_ldk_node_checksum_method_node_get_address_balance() != 45284 { return InitializationResult.apiChecksumMismatch } + if uniffi_ldk_node_checksum_method_node_get_balance_for_address_type() != 34906 { + return InitializationResult.apiChecksumMismatch + } if uniffi_ldk_node_checksum_method_node_get_transaction_details() != 65000 { return InitializationResult.apiChecksumMismatch } @@ -12179,6 +12396,9 @@ private var initializationResult: InitializationResult = { if uniffi_ldk_node_checksum_method_node_list_channels() != 7954 { return InitializationResult.apiChecksumMismatch } + if uniffi_ldk_node_checksum_method_node_list_monitored_address_types() != 25084 { + return InitializationResult.apiChecksumMismatch + } if uniffi_ldk_node_checksum_method_node_list_payments() != 35002 { return InitializationResult.apiChecksumMismatch } @@ -12314,6 +12534,9 @@ private var initializationResult: InitializationResult = { if uniffi_ldk_node_checksum_method_onchainpayment_new_address() != 37251 { return InitializationResult.apiChecksumMismatch } + if uniffi_ldk_node_checksum_method_onchainpayment_new_address_for_type() != 9083 { + return InitializationResult.apiChecksumMismatch + } if uniffi_ldk_node_checksum_method_onchainpayment_select_utxos_with_algorithm() != 14084 { return InitializationResult.apiChecksumMismatch } diff --git a/bindings/uniffi-bindgen/Cargo.toml b/bindings/uniffi-bindgen/Cargo.toml index a33c0f9ae..baeafe91b 100644 --- a/bindings/uniffi-bindgen/Cargo.toml +++ b/bindings/uniffi-bindgen/Cargo.toml @@ -3,6 +3,8 @@ name = "uniffi-bindgen" version = "0.1.0" edition = "2021" +[workspace] + # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] diff --git a/crates/bdk-wallet-aggregate/Cargo.toml b/crates/bdk-wallet-aggregate/Cargo.toml new file mode 100644 index 000000000..af81a0db5 --- /dev/null +++ b/crates/bdk-wallet-aggregate/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "bdk-wallet-aggregate" +version = "0.1.0" +edition = "2021" +rust-version = "1.85" +description = "Aggregates multiple BDK wallets (one per address type) into a single logical wallet." +license = "MIT OR Apache-2.0" + +[dependencies] +bdk_chain = { version = "0.23.0", default-features = false, features = ["std"] } +bdk_wallet = { version = "2.2.0", default-features = false, features = ["std", "keys-bip39"] } +bitcoin = "0.32.7" +bip39 = { version = "2.0.0", features = ["rand"] } +log = { version = "0.4", default-features = false, features = ["std"] } diff --git a/crates/bdk-wallet-aggregate/src/lib.rs b/crates/bdk-wallet-aggregate/src/lib.rs new file mode 100644 index 000000000..4c291906c --- /dev/null +++ b/crates/bdk-wallet-aggregate/src/lib.rs @@ -0,0 +1,1306 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +//! A library that aggregates multiple BDK wallets into a single logical wallet +//! with unified balance, UTXO management, transaction building, and signing. +//! +//! # Overview +//! +//! [`AggregateWallet`] wraps a *primary* BDK wallet and zero or more +//! *secondary* wallets, keyed by a user-defined type `K` (e.g. an address-type +//! enum). The primary wallet is used for generating new addresses and change +//! outputs; secondary wallets are monitored for existing funds and their UTXOs +//! participate in transaction construction and signing. + +pub mod rbf; +pub mod signing; +pub mod types; +pub mod utxo; + +use std::collections::{HashMap, HashSet}; +use std::fmt::Debug; +use std::hash::Hash; +use std::ops::{Deref, DerefMut}; +use std::sync::Arc; + +use bdk_chain::spk_client::{FullScanRequest, SyncRequest}; +use bdk_wallet::event::WalletEvent; +use bdk_wallet::{Balance, KeychainKind, LocalOutput, PersistedWallet, Update, WalletPersister}; +use bitcoin::blockdata::locktime::absolute::LockTime; +use bitcoin::hashes::Hash as _; +use bitcoin::psbt::Psbt; +use bitcoin::{ + Address, Amount, Block, BlockHash, FeeRate, OutPoint, Script, ScriptBuf, Transaction, Txid, + WPubkeyHash, +}; +pub use types::{CoinSelectionAlgorithm, Error, UtxoPsbtInfo}; + +/// A wallet aggregator that presents multiple BDK wallets as a single logical +/// wallet. +/// +/// Generic over: +/// * `K` – the key type used to identify individual wallets (e.g. an +/// `AddressType` enum). +/// * `P` – the BDK `WalletPersister` implementation. +pub struct AggregateWallet +where + K: Eq + Hash + Copy + Debug, + P: WalletPersister, +{ + wallets: HashMap>, + persisters: HashMap, + primary: K, +} + +/// Delegates to the primary wallet. +impl Deref for AggregateWallet +where + K: Eq + Hash + Copy + Debug, + P: WalletPersister, +{ + type Target = PersistedWallet

; + + fn deref(&self) -> &Self::Target { + self.wallets.get(&self.primary).expect("Primary wallet must always exist") + } +} + +impl DerefMut for AggregateWallet +where + K: Eq + Hash + Copy + Debug, + P: WalletPersister, +{ + fn deref_mut(&mut self) -> &mut Self::Target { + self.wallets.get_mut(&self.primary).expect("Primary wallet must always exist") + } +} + +impl AggregateWallet +where + K: Eq + Hash + Copy + Debug, + P: WalletPersister, + P::Error: std::fmt::Display, +{ + /// Create a new aggregate wallet. + /// + /// * `primary_wallet` / `primary_persister` – the wallet used for new + /// address generation and change outputs. + /// * `primary_key` – the key identifying the primary wallet. + /// * `additional_wallets` – secondary wallets to monitor and use for + /// transaction construction. + pub fn new( + primary_wallet: PersistedWallet

, primary_persister: P, primary_key: K, + additional_wallets: Vec<(K, PersistedWallet

, P)>, + ) -> Self { + let mut wallets = HashMap::new(); + let mut persisters = HashMap::new(); + + wallets.insert(primary_key, primary_wallet); + persisters.insert(primary_key, primary_persister); + + for (key, wallet, persister) in additional_wallets { + wallets.insert(key, wallet); + persisters.insert(key, persister); + } + + Self { wallets, persisters, primary: primary_key } + } + + // ─── Accessors ────────────────────────────────────────────────────── + + /// The primary wallet key. + pub fn primary_key(&self) -> K { + self.primary + } + + /// All loaded wallet keys (primary + monitored). + pub fn loaded_keys(&self) -> Vec { + self.wallets.keys().copied().collect() + } + + /// Immutable access to the underlying wallet map. + pub fn wallets(&self) -> &HashMap> { + &self.wallets + } + + /// Mutable access to the underlying wallet map. + pub fn wallets_mut(&mut self) -> &mut HashMap> { + &mut self.wallets + } + + /// Immutable access to the persister map. + pub fn persisters(&self) -> &HashMap { + &self.persisters + } + + /// Mutable access to the underlying persister map. + pub fn persisters_mut(&mut self) -> &mut HashMap { + &mut self.persisters + } + + /// Get a reference to a specific wallet. + pub fn wallet(&self, key: &K) -> Option<&PersistedWallet

> { + self.wallets.get(key) + } + + /// Get a mutable reference to a specific wallet. + pub fn wallet_mut(&mut self, key: &K) -> Option<&mut PersistedWallet

> { + self.wallets.get_mut(key) + } + + /// Get a reference to the primary wallet. + pub fn primary_wallet(&self) -> &PersistedWallet

{ + self.wallets.get(&self.primary).expect("Primary wallet must always exist") + } + + /// Get a mutable reference to the primary wallet. + pub fn primary_wallet_mut(&mut self) -> &mut PersistedWallet

{ + self.wallets.get_mut(&self.primary).expect("Primary wallet must always exist") + } + + /// Get a mutable reference to the primary persister. + pub fn primary_persister_mut(&mut self) -> &mut P { + self.persisters.get_mut(&self.primary).expect("Primary persister must always exist") + } + + // ─── Balance ──────────────────────────────────────────────────────── + + /// Aggregate balance across all wallets. + pub fn balance(&self) -> Balance { + let mut total = Balance::default(); + for wallet in self.wallets.values() { + let balance = wallet.balance(); + total.confirmed += balance.confirmed; + total.trusted_pending += balance.trusted_pending; + total.untrusted_pending += balance.untrusted_pending; + total.immature += balance.immature; + } + total + } + + /// Balance for a single wallet identified by key. + pub fn balance_for(&self, key: &K) -> Result { + self.wallets.get(key).map(|w| w.balance()).ok_or(Error::WalletNotFound) + } + + // ─── UTXO Listing ─────────────────────────────────────────────────── + + /// List all unspent outputs across every wallet. + pub fn list_unspent(&self) -> Vec { + self.wallets.values().flat_map(|w| w.list_unspent()).collect() + } + + /// List confirmed unspent outputs across all wallets. + /// + /// Only returns UTXOs whose creating transaction is confirmed in at + /// least one wallet. + pub fn list_confirmed_unspent(&self) -> Vec { + let mut confirmed_txids = HashSet::new(); + for wallet in self.wallets.values() { + for t in wallet.transactions().filter(|t| t.chain_position.is_confirmed()) { + confirmed_txids.insert(t.tx_node.txid); + } + } + + self.list_unspent() + .into_iter() + .filter(|u| confirmed_txids.contains(&u.outpoint.txid)) + .collect() + } + + /// Derive the inner `WPubkeyHash` for a P2SH-wrapped P2WPKH UTXO. + /// + /// For NestedSegwit wallets (BIP-49) the descriptor is `Sh(Wpkh(...))`. + /// This method finds the wallet that owns `utxo`, derives the script at + /// the UTXO's derivation index, and extracts the 20-byte witness + /// program hash from the inner redeemScript (`OP_0 <20-byte-wpkh>`). + /// + /// Returns `None` if the UTXO is not owned by any wallet or if the + /// inner script cannot be parsed as P2WPKH. + pub fn derive_wpkh_for_p2sh(&self, utxo: &LocalOutput) -> Option { + for wallet in self.wallets.values() { + if wallet.get_utxo(utxo.outpoint).is_some() { + let descriptor = wallet.public_descriptor(utxo.keychain); + if let Ok(derived_desc) = descriptor.at_derivation_index(utxo.derivation_index) { + // For Sh(Wpkh(..)) descriptors, `explicit_script()` gives + // the inner P2WPKH redeemScript: OP_0 <20-byte-wpkh>. + if let Ok(explicit) = derived_desc.explicit_script() { + if explicit.len() == 22 + && explicit.as_bytes()[0] == 0x00 + && explicit.as_bytes()[1] == 0x14 + { + return WPubkeyHash::from_slice(&explicit.as_bytes()[2..22]).ok(); + } + } + } + break; + } + } + None + } + + // ─── Transaction Lookup ───────────────────────────────────────────── + + /// Find which wallet key contains a transaction. + pub fn find_wallet_for_tx(&self, txid: Txid) -> Option { + self.wallets.iter().find_map(|(key, wallet)| wallet.get_tx(txid).map(|_| *key)) + } + + /// Find a transaction across all wallets. + pub fn find_tx(&self, txid: Txid) -> Option { + for wallet in self.wallets.values() { + if let Some(tx_node) = wallet.get_tx(txid) { + return Some((*tx_node.tx_node.tx).clone()); + } + } + None + } + + /// Check whether a transaction is confirmed in any wallet. + pub fn is_tx_confirmed(&self, txid: &Txid) -> bool { + for wallet in self.wallets.values() { + if let Some(tx_node) = wallet.get_tx(*txid) { + if tx_node.chain_position.is_confirmed() { + return true; + } + } + } + false + } + + /// Aggregated sent and received amounts for a transaction across all wallets. + pub fn sent_and_received(&self, txid: Txid) -> Option<(u64, u64)> { + let tx = self.find_tx(txid)?; + let mut total_sent = 0u64; + let mut total_received = 0u64; + for wallet in self.wallets.values() { + if wallet.get_tx(txid).is_some() { + let (sent, received) = wallet.sent_and_received(&tx); + total_sent += sent.to_sat(); + total_received += received.to_sat(); + } + } + Some((total_sent, total_received)) + } + + /// Collect all cached transactions from all wallets, deduplicated by txid. + pub fn cached_txs(&self) -> Vec> { + let mut seen = HashSet::new(); + self.wallets + .values() + .flat_map(|w| w.tx_graph().full_txs()) + .filter(|tx_node| seen.insert(tx_node.txid)) + .map(|tx_node| tx_node.tx) + .collect() + } + + /// Collect all unconfirmed transaction IDs across wallets (deduplicated). + pub fn unconfirmed_txids(&self) -> Vec { + let mut seen = HashSet::new(); + self.wallets + .values() + .flat_map(|w| { + w.transactions() + .filter(|t| t.chain_position.is_unconfirmed()) + .map(|t| t.tx_node.txid) + }) + .filter(|txid| seen.insert(*txid)) + .collect() + } + + /// All transaction IDs across all wallets. + pub fn all_txids(&self) -> Vec { + self.wallets.values().flat_map(|w| w.transactions().map(|wtx| wtx.tx_node.txid)).collect() + } + + // ─── Chain Tip ────────────────────────────────────────────────────── + + /// The latest checkpoint from the primary wallet, returned as + /// `(block_hash, height)`. + pub fn current_best_block(&self) -> (BlockHash, u32) { + let checkpoint = self.primary_wallet().latest_checkpoint(); + (checkpoint.hash(), checkpoint.height()) + } + + // ─── Address Generation ───────────────────────────────────────────── + + /// Generate a new receiving address from the primary wallet. + pub fn new_address(&mut self) -> Result { + let key = self.primary; + self.new_address_for(&key) + } + + /// Generate a new receiving address for a specific wallet. + pub fn new_address_for(&mut self, key: &K) -> Result { + let wallet = self.wallets.get_mut(key).ok_or(Error::WalletNotFound)?; + let persister = self.persisters.get_mut(key).ok_or(Error::PersisterNotFound)?; + + let address_info = wallet.reveal_next_address(KeychainKind::External); + wallet.persist(persister).map_err(|e| { + log::error!("Failed to persist wallet for {:?}: {}", key, e); + Error::PersistenceFailed + })?; + Ok(address_info.address) + } + + /// Generate a new internal (change) address from the primary wallet. + pub fn new_internal_address(&mut self) -> Result { + let primary = self.primary; + let wallet = self.wallets.get_mut(&primary).ok_or(Error::WalletNotFound)?; + let persister = self.persisters.get_mut(&primary).ok_or(Error::PersisterNotFound)?; + + let address_info = wallet.next_unused_address(KeychainKind::Internal); + wallet.persist(persister).map_err(|e| { + log::error!("Failed to persist wallet: {}", e); + Error::PersistenceFailed + })?; + Ok(address_info.address) + } + + // ─── Transaction Cancellation ─────────────────────────────────────── + + /// Cancel a transaction in all wallets that know about it. + pub fn cancel_tx(&mut self, tx: &Transaction) -> Result<(), Error> { + for (key, wallet) in self.wallets.iter_mut() { + wallet.cancel_tx(tx); + if let Some(persister) = self.persisters.get_mut(key) { + wallet.persist(persister).map_err(|e| { + log::error!("Failed to persist wallet {:?}: {}", key, e); + Error::PersistenceFailed + })?; + } + } + Ok(()) + } + + // ─── Fee Calculation ──────────────────────────────────────────────── + + /// Calculate the fee of a PSBT by summing input values and subtracting + /// output values. + pub fn calculate_fee_from_psbt(&self, psbt: &Psbt) -> Result { + let mut total_input_value = 0u64; + + for (i, txin) in psbt.unsigned_tx.input.iter().enumerate() { + if let Some(psbt_input) = psbt.inputs.get(i) { + if let Some(witness_utxo) = &psbt_input.witness_utxo { + total_input_value += witness_utxo.value.to_sat(); + } else if let Some(non_witness_tx) = &psbt_input.non_witness_utxo { + if let Some(txout) = + non_witness_tx.output.get(txin.previous_output.vout as usize) + { + total_input_value += txout.value.to_sat(); + } else { + return Err(Error::OnchainTxCreationFailed); + } + } else { + let mut found = false; + for wallet in self.wallets.values() { + if let Some(local_utxo) = wallet.get_utxo(txin.previous_output) { + total_input_value += local_utxo.txout.value.to_sat(); + found = true; + break; + } + } + if !found { + return Err(Error::OnchainTxCreationFailed); + } + } + } else { + let mut found = false; + for wallet in self.wallets.values() { + if let Some(local_utxo) = wallet.get_utxo(txin.previous_output) { + total_input_value += local_utxo.txout.value.to_sat(); + found = true; + break; + } + } + if !found { + return Err(Error::OnchainTxCreationFailed); + } + } + } + + let total_output_value: u64 = + psbt.unsigned_tx.output.iter().map(|txout| txout.value.to_sat()).sum(); + + Ok(total_input_value.saturating_sub(total_output_value)) + } + + /// Calculate fee from PSBT with fallback to primary wallet calculation. + pub fn calculate_fee_with_fallback(&self, psbt: &Psbt) -> Result { + self.calculate_fee_from_psbt(psbt).or_else(|_| { + self.primary_wallet() + .calculate_fee(&psbt.unsigned_tx) + .map(|f| f.to_sat()) + .map_err(|_| Error::OnchainTxCreationFailed) + }) + } + + /// Calculate the drain amount from a PSBT using sent_and_received across + /// all wallets. Returns the net outgoing amount (sent - received) in sats. + /// + /// For not-yet-broadcast PSBTs the transaction won't be in any wallet's + /// tx-graph, so we compute sent/received directly from the unsigned tx + /// against every wallet's script set. + pub fn drain_amount_from_psbt(&self, psbt: &Psbt) -> u64 { + let mut total_sent = Amount::ZERO; + let mut total_received = Amount::ZERO; + for wallet in self.wallets.values() { + let (s, r) = wallet.sent_and_received(&psbt.unsigned_tx); + total_sent += s; + total_received += r; + } + total_sent.to_sat().saturating_sub(total_received.to_sat()) + } + + // ─── Sync ─────────────────────────────────────────────────────────── + + /// Build sync requests for a specific wallet. + #[allow(clippy::type_complexity)] + pub fn wallet_sync_request( + &self, key: &K, + ) -> Result<(FullScanRequest, SyncRequest<(KeychainKind, u32)>), Error> { + let wallet = self.wallets.get(key).ok_or(Error::WalletNotFound)?; + let full_scan = wallet.start_full_scan().build(); + let incremental_sync = wallet.start_sync_with_revealed_spks().build(); + Ok((full_scan, incremental_sync)) + } + + /// Apply a chain update to the primary wallet. + /// + /// Returns the wallet events and a list of all transaction IDs in the + /// primary wallet (so the caller can update its payment store). + pub fn apply_update( + &mut self, update: impl Into, + ) -> Result<(Vec, Vec), Error> { + let update = update.into(); + let primary = self.primary; + let wallet = self.wallets.get_mut(&primary).ok_or(Error::WalletNotFound)?; + + let events = wallet.apply_update_events(update).map_err(|e| { + log::error!("Failed to apply update to primary wallet: {}", e); + Error::WalletOperationFailed + })?; + + let persister = self.persisters.get_mut(&primary).ok_or(Error::PersisterNotFound)?; + wallet.persist(persister).map_err(|e| { + log::error!("Failed to persist primary wallet: {}", e); + Error::PersistenceFailed + })?; + + let txids: Vec = wallet.transactions().map(|wtx| wtx.tx_node.txid).collect(); + + Ok((events, txids)) + } + + /// Apply a chain update to a specific wallet. + /// + /// Returns the wallet events and a list of all transaction IDs in that + /// wallet. + pub fn apply_update_to_wallet( + &mut self, key: K, update: impl Into, + ) -> Result<(Vec, Vec), Error> { + let update = update.into(); + let wallet = self.wallets.get_mut(&key).ok_or(Error::WalletNotFound)?; + + let events = wallet.apply_update_events(update).map_err(|e| { + log::error!("Failed to apply update to wallet {:?}: {}", key, e); + Error::WalletOperationFailed + })?; + + let persister = self.persisters.get_mut(&key).ok_or(Error::PersisterNotFound)?; + wallet.persist(persister).map_err(|e| { + log::error!("Failed to persist wallet {:?}: {}", key, e); + Error::PersistenceFailed + })?; + + let txids: Vec = wallet.transactions().map(|wtx| wtx.tx_node.txid).collect(); + + Ok((events, txids)) + } + + /// Apply mempool (unconfirmed) transactions to all wallets. + pub fn apply_mempool_txs( + &mut self, unconfirmed_txs: Vec<(Transaction, u64)>, evicted_txids: Vec<(Txid, u64)>, + ) -> Result<(), Error> { + for (key, wallet) in self.wallets.iter_mut() { + wallet.apply_unconfirmed_txs(unconfirmed_txs.clone()); + wallet.apply_evicted_txs(evicted_txids.clone()); + + if let Some(persister) = self.persisters.get_mut(key) { + wallet.persist(persister).map_err(|e| { + log::error!("Failed to persist wallet {:?}: {}", key, e); + Error::PersistenceFailed + })?; + } + } + Ok(()) + } + + /// Apply a connected block to all wallets and persist. + /// + /// Returns the set of all transaction IDs across all wallets. + pub fn apply_block(&mut self, block: &Block, height: u32) -> Result, Error> { + let pre_checkpoint = self.primary_wallet().latest_checkpoint(); + if height > 0 + && (pre_checkpoint.height() != height - 1 + || pre_checkpoint.hash() != block.header.prev_blockhash) + { + log::debug!("Detected reorg while applying connected block at height {}", height); + } + + for (key, wallet) in self.wallets.iter_mut() { + wallet.apply_block(block, height).map_err(|e| { + log::error!("Failed to apply connected block to wallet {:?}: {}", key, e); + Error::WalletOperationFailed + })?; + + if let Some(persister) = self.persisters.get_mut(key) { + wallet.persist(persister).map_err(|e| { + log::error!("Failed to persist wallet {:?}: {}", key, e); + Error::PersistenceFailed + })?; + } + } + + let mut all_txids = HashSet::new(); + for wallet in self.wallets.values() { + for wtx in wallet.transactions() { + all_txids.insert(wtx.tx_node.txid); + } + } + + Ok(all_txids) + } + + // ─── UTXO Preparation Helpers ─────────────────────────────────────── + + /// Prepare local outputs for cross-wallet PSBT building. + pub fn prepare_utxos_for_psbt( + &self, utxos: &[LocalOutput], + ) -> Result, Error> { + utxo::prepare_utxos_for_psbt(utxos, &self.wallets, &self.primary) + } + + /// Prepare outpoints for cross-wallet PSBT building. + pub fn prepare_outpoints_for_psbt( + &self, outpoints: &[OutPoint], + ) -> Result, Error> { + utxo::prepare_outpoints_for_psbt(outpoints, &self.wallets, &self.primary) + } + + // ─── Signing ──────────────────────────────────────────────────────── + + /// Sign an unsigned transaction using all wallets that own inputs. + pub fn sign_owned_inputs(&mut self, unsigned_tx: Transaction) -> Result { + signing::sign_owned_inputs(unsigned_tx, &mut self.wallets) + } + + /// Sign a PSBT using all wallets that own inputs. + pub fn sign_psbt_all(&mut self, psbt: Psbt) -> Result { + signing::sign_psbt_all_wallets(psbt, &mut self.wallets) + } + + // ─── Cross-wallet RBF ─────────────────────────────────────────────── + + /// Build a cross-wallet RBF replacement, adding extra inputs if needed. + /// + /// 1. Tries adjusting the change output only (no new inputs). + /// 2. If that is insufficient, selects minimum additional UTXOs from all + /// wallets and retries. + fn build_cross_wallet_rbf_with_fallback( + &mut self, original_tx: &Transaction, new_fee_rate: FeeRate, + ) -> Result { + // Attempt 1: change-only bump. + match rbf::build_cross_wallet_rbf(&mut self.wallets, original_tx, new_fee_rate, &[]) { + Ok(tx) => return Ok(tx), + Err(Error::InsufficientFunds) => {}, + Err(e) => return Err(e), + } + + // Attempt 2: select minimum additional UTXOs. + let original_fee = self.calculate_tx_fee(original_tx)?; + let new_fee = new_fee_rate.fee_wu(original_tx.weight()).unwrap_or(Amount::ZERO); + let fee_increase = new_fee.checked_sub(original_fee).unwrap_or(Amount::ZERO); + + // Change value absorbable from the original tx. + let change_value: Amount = original_tx + .output + .iter() + .filter(|out| { + self.wallets.values().any(|w| { + w.list_unspent().any(|u| { + u.keychain == KeychainKind::Internal + && u.txout.script_pubkey == out.script_pubkey + }) + }) + }) + .map(|out| out.value) + .sum(); + + let deficit = fee_increase.checked_sub(change_value).unwrap_or(Amount::ZERO); + + // Collect UTXOs from all wallets, excluding those already in the + // original tx and outputs created by the original tx. + let original_outpoints: HashSet = + original_tx.input.iter().map(|i| i.previous_output).collect(); + let original_txid = original_tx.compute_txid(); + + let available: Vec = self + .list_unspent() + .into_iter() + .filter(|u| !original_outpoints.contains(&u.outpoint)) + .filter(|u| u.outpoint.txid != original_txid) + .collect(); + + if available.is_empty() { + return Err(Error::InsufficientFunds); + } + + let drain_script = + self.primary_wallet().peek_address(KeychainKind::Internal, 0).address.script_pubkey(); + + let selected = utxo::select_utxos_with_algorithm( + deficit.to_sat(), + available, + new_fee_rate, + CoinSelectionAlgorithm::BranchAndBound, + &drain_script, + &[], + &self.wallets, + )?; + + let extra_infos = self.prepare_outpoints_for_psbt(&selected)?; + if extra_infos.is_empty() { + return Err(Error::InsufficientFunds); + } + + rbf::build_cross_wallet_rbf(&mut self.wallets, original_tx, new_fee_rate, &extra_infos) + } + + /// Look up an input value across local wallets. + pub fn get_input_value(&self, outpoint: &OutPoint) -> Result { + rbf::get_input_value(outpoint, &self.wallets) + } + + // ─── Coin Selection ───────────────────────────────────────────────── + + /// Run coin selection across all wallets. + pub fn select_utxos( + &self, target_amount: u64, available_utxos: Vec, fee_rate: FeeRate, + algorithm: CoinSelectionAlgorithm, drain_script: &Script, excluded_outpoints: &[OutPoint], + ) -> Result, Error> { + utxo::select_utxos_with_algorithm( + target_amount, + available_utxos, + fee_rate, + algorithm, + drain_script, + excluded_outpoints, + &self.wallets, + ) + } + + // ─── Fee Calculation ───────────────────────────────────────────────── + + /// Calculate the fee of a transaction by looking up input values across + /// all wallets and subtracting total output values. + /// + /// Tries BDK's native `calculate_fee` on each wallet first (works + /// reliably for transactions that were built by that wallet), then + /// falls back to manual input-value lookup across all wallets. + pub fn calculate_tx_fee(&self, tx: &Transaction) -> Result { + // Fast path: BDK knows the fee for transactions it built. + for wallet in self.wallets.values() { + if let Ok(fee) = wallet.calculate_fee(tx) { + return Ok(fee); + } + } + + // Slow path: manually look up each input value. + let mut total_input = 0u64; + for txin in &tx.input { + total_input += self.get_input_value(&txin.previous_output)?; + } + let total_output: u64 = tx.output.iter().map(|o| o.value.to_sat()).sum(); + Ok(Amount::from_sat(total_input.saturating_sub(total_output))) + } + + // ─── High-Level Helpers ───────────────────────────────────────────── + + /// Return prepared PSBT info for all non-primary UTXOs, suitable for + /// adding as foreign inputs to a transaction built on the primary wallet. + /// + /// `excluded_txids` allows the caller to filter out UTXOs belonging to + /// specific transactions (e.g. channel funding transactions). + pub fn non_primary_foreign_utxos( + &self, excluded_txids: &HashSet, + ) -> Result, Error> { + let primary_outpoints: HashSet = + self.primary_wallet().list_unspent().map(|u| u.outpoint).collect(); + + let non_primary: Vec = self + .list_unspent() + .into_iter() + .filter(|u| !primary_outpoints.contains(&u.outpoint)) + .filter(|u| !excluded_txids.contains(&u.outpoint.txid)) + .collect(); + + if non_primary.is_empty() { + return Ok(Vec::new()); + } + + self.prepare_utxos_for_psbt(&non_primary) + } + + /// Select the minimum set of non-primary foreign UTXOs needed to cover + /// `deficit`, using the given coin selection algorithm. + /// + /// * `segwit_only` – if `true`, excludes bare P2PKH UTXOs (includes + /// P2SH-P2WPKH / NestedSegwit since those carry witness data). + /// * `algorithm` – the coin selection strategy to use. + /// + /// Returns prepared `UtxoPsbtInfo` entries ready to add as foreign + /// inputs, or an empty vec if no non-primary UTXOs are available. + pub fn select_non_primary_foreign_utxos( + &self, deficit: Amount, fee_rate: FeeRate, excluded_txids: &HashSet, + segwit_only: bool, algorithm: CoinSelectionAlgorithm, + ) -> Result, Error> { + let primary_outpoints: HashSet = + self.primary_wallet().list_unspent().map(|u| u.outpoint).collect(); + + let non_primary: Vec = self + .list_unspent() + .into_iter() + .filter(|u| !primary_outpoints.contains(&u.outpoint)) + .filter(|u| !excluded_txids.contains(&u.outpoint.txid)) + .filter(|u| { + !segwit_only + || u.txout.script_pubkey.witness_version().is_some() + || u.txout.script_pubkey.is_p2sh() + }) + .collect(); + + if non_primary.is_empty() { + return Ok(Vec::new()); + } + + let drain_script = + self.primary_wallet().peek_address(KeychainKind::Internal, 0).address.script_pubkey(); + + let selected_outpoints = utxo::select_utxos_with_algorithm( + deficit.to_sat(), + non_primary, + fee_rate, + algorithm, + &drain_script, + &[], + &self.wallets, + )?; + + self.prepare_outpoints_for_psbt(&selected_outpoints) + } + + /// Build a transaction using unified coin selection across all eligible + /// wallets. + /// + /// Pools UTXOs from every wallet that passes `utxo_filter`, runs coin + /// selection once on the combined set, then builds a PSBT on the + /// `build_key` wallet with primary UTXOs as native inputs and foreign + /// UTXOs via `add_foreign_utxo`. Signs with all wallets and persists. + #[allow(deprecated, clippy::too_many_arguments)] + fn build_tx_unified( + &mut self, build_key: &K, output_script: ScriptBuf, amount: Amount, fee_rate: FeeRate, + locktime: Option, excluded_txids: &HashSet, + algorithm: CoinSelectionAlgorithm, utxo_filter: impl Fn(&LocalOutput) -> bool, + ) -> Result { + let all_utxos: Vec = self + .list_unspent() + .into_iter() + .filter(|u| !excluded_txids.contains(&u.outpoint.txid)) + .filter(&utxo_filter) + .collect(); + + if all_utxos.is_empty() { + return Err(Error::NoSpendableOutputs); + } + + let drain_script = self + .wallet(build_key) + .ok_or(Error::WalletNotFound)? + .peek_address(KeychainKind::Internal, 0) + .address + .script_pubkey(); + + let selected = utxo::select_utxos_with_algorithm( + amount.to_sat(), + all_utxos, + fee_rate, + algorithm, + &drain_script, + &[], + &self.wallets, + )?; + + let infos = self.prepare_outpoints_for_psbt(&selected)?; + if infos.is_empty() { + return Err(Error::InsufficientFunds); + } + + let w = self.wallets.get_mut(build_key).ok_or(Error::WalletNotFound)?; + let mut b = w.build_tx(); + b.add_recipient(output_script, amount).fee_rate(fee_rate); + if let Some(lt) = locktime { + b.nlocktime(lt); + } + utxo::add_utxos_to_tx_builder(&mut b, &infos)?; + // BDK must not add extra UTXOs — we already selected everything. + b.manually_selected_only(); + b.finish().map_err(|e| { + log::error!("Failed to build tx with unified selection: {}", e); + Error::InsufficientFunds + }) + } + + /// Build a channel-funding transaction using unified coin selection + /// across all wallets. Only SegWit-compatible UTXOs are included + /// (Legacy/P2PKH excluded) as required by BOLT 2. + #[allow(deprecated)] + pub fn build_and_sign_funding_tx( + &mut self, output_script: ScriptBuf, amount: Amount, fee_rate: FeeRate, locktime: LockTime, + ) -> Result { + let psbt = self.build_tx_unified( + &self.primary.clone(), + output_script, + amount, + fee_rate, + Some(locktime), + &HashSet::new(), + CoinSelectionAlgorithm::BranchAndBound, + |u| { + u.txout.script_pubkey.witness_version().is_some() || u.txout.script_pubkey.is_p2sh() + }, + )?; + + let tx = self.sign_psbt_all(psbt)?; + self.persist_all()?; + Ok(tx) + } + + /// Build a channel-funding transaction when the primary wallet is not + /// eligible (e.g. Legacy). Picks the highest-balance wallet from those + /// passing `filter` as the PSBT builder, then runs unified coin + /// selection across all eligible wallets. + #[allow(deprecated)] + pub fn build_and_sign_tx_with_best_wallet( + &mut self, output_script: ScriptBuf, amount: Amount, fee_rate: FeeRate, locktime: LockTime, + filter: impl Fn(&K) -> bool, + ) -> Result { + let mut matching_keys: Vec = + self.wallets.keys().filter(|k| filter(k)).cloned().collect(); + + if matching_keys.is_empty() { + return Err(Error::InsufficientFunds); + } + + matching_keys.sort_by(|a, b| { + let bal_a = self + .balance_for(a) + .map(|bal| bal.confirmed.to_sat() + bal.trusted_pending.to_sat()) + .unwrap_or(0); + let bal_b = self + .balance_for(b) + .map(|bal| bal.confirmed.to_sat() + bal.trusted_pending.to_sat()) + .unwrap_or(0); + bal_b.cmp(&bal_a) + }); + + let build_key = matching_keys[0]; + + let psbt = self.build_tx_unified( + &build_key, + output_script, + amount, + fee_rate, + Some(locktime), + &HashSet::new(), + CoinSelectionAlgorithm::BranchAndBound, + |u| { + u.txout.script_pubkey.witness_version().is_some() || u.txout.script_pubkey.is_p2sh() + }, + )?; + + let tx = self.sign_psbt_all(psbt)?; + self.persist_all()?; + Ok(tx) + } + + /// Build a PSBT using unified coin selection across all wallets. + /// Returns the **unsigned** PSBT — the caller is responsible for + /// signing and persisting. + #[allow(deprecated)] + pub fn build_psbt_with_cross_wallet_fallback( + &mut self, output_script: ScriptBuf, amount: Amount, fee_rate: FeeRate, + excluded_txids: &HashSet, algorithm: CoinSelectionAlgorithm, + ) -> Result { + self.build_tx_unified( + &self.primary.clone(), + output_script, + amount, + fee_rate, + None, + excluded_txids, + algorithm, + |_| true, + ) + } + + /// Aggregate balance across wallets whose keys satisfy `filter`. + pub fn balance_filtered(&self, filter: impl Fn(&K) -> bool) -> Balance { + let mut total = Balance::default(); + for (key, wallet) in &self.wallets { + if filter(key) { + let balance = wallet.balance(); + total.confirmed += balance.confirmed; + total.trusted_pending += balance.trusted_pending; + total.untrusted_pending += balance.untrusted_pending; + total.immature += balance.immature; + } + } + total + } + + /// Build and sign a transaction that drains ALL wallets (primary + + /// monitored) to a single destination script. + /// + /// Uses `drain_wallet()` for the primary wallet and adds non-primary + /// UTXOs as foreign inputs. BDK's drain calculation then accounts for + /// the full input value (primary + foreign) minus fees. + pub fn build_and_sign_drain( + &mut self, destination: ScriptBuf, fee_rate: FeeRate, + ) -> Result { + // Collect non-primary UTXOs first (immutable borrow). + let non_primary_infos = self.non_primary_foreign_utxos(&HashSet::new())?; + + let primary = self.primary; + let wallet = self.wallets.get_mut(&primary).ok_or(Error::WalletNotFound)?; + + // Check primary wallet has at least something to drain. + if wallet.balance().total() == Amount::ZERO && non_primary_infos.is_empty() { + return Err(Error::NoSpendableOutputs); + } + + let mut builder = wallet.build_tx(); + + // drain_wallet() tells BDK to include all primary UTXOs. + builder.drain_wallet(); + builder.drain_to(destination); + builder.fee_rate(fee_rate); + + // Add non-primary UTXOs as foreign inputs. + for info in &non_primary_infos { + builder + .add_foreign_utxo_with_sequence( + info.outpoint, + info.psbt_input.clone(), + info.weight, + bitcoin::Sequence::ENABLE_RBF_NO_LOCKTIME, + ) + .map_err(|e| { + log::error!("Failed to add foreign UTXO {:?} for drain: {}", info.outpoint, e); + Error::OnchainTxCreationFailed + })?; + } + + let psbt = builder.finish().map_err(|e| { + log::error!("Failed to build drain transaction: {}", e); + Error::OnchainTxCreationFailed + })?; + + self.sign_psbt_all(psbt) + } + + // ─── CPFP ────────────────────────────────────────────────────────── + + /// Build a CPFP (Child-Pays-For-Parent) transaction. + /// + /// Finds spendable outputs of `parent_txid` across all wallets, builds + /// a child transaction at `fee_rate` that spends those outputs to + /// `destination_script`. If `destination_script` is `None` the + /// primary wallet's next internal address is used. + /// + /// Returns `(signed_child_tx, parent_fee, parent_fee_rate)`. + pub fn build_cpfp( + &mut self, parent_txid: Txid, fee_rate: FeeRate, destination_script: Option, + ) -> Result<(Transaction, Amount, FeeRate), Error> { + // Find and validate the parent transaction. + let parent_tx = self.find_tx(parent_txid).ok_or(Error::TransactionNotFound)?; + if self.is_tx_confirmed(&parent_txid) { + return Err(Error::TransactionAlreadyConfirmed); + } + + // Calculate parent fee. + let parent_fee = self.calculate_tx_fee(&parent_tx)?; + let parent_fee_rate = parent_fee / parent_tx.weight(); + + // Find spendable outputs from this transaction across ALL wallets. + let utxos: Vec = + self.list_unspent().into_iter().filter(|u| u.outpoint.txid == parent_txid).collect(); + if utxos.is_empty() { + return Err(Error::NoSpendableOutputs); + } + + // Classify UTXOs as primary or foreign for tx building. + let utxo_infos = self.prepare_utxos_for_psbt(&utxos)?; + + // Determine destination. + let drain_script = match destination_script { + Some(s) => s, + None => { + let wallet = self.primary_wallet_mut(); + wallet.next_unused_address(KeychainKind::Internal).address.script_pubkey() + }, + }; + + // Build the child transaction on the primary wallet. + let wallet = self.primary_wallet_mut(); + let mut tx_builder = wallet.build_tx(); + + utxo::add_utxos_to_tx_builder(&mut tx_builder, &utxo_infos) + .map_err(|_| Error::OnchainTxCreationFailed)?; + + tx_builder.fee_rate(fee_rate); + tx_builder.drain_to(drain_script); + tx_builder.manually_selected_only(); + + let psbt = tx_builder.finish().map_err(|e| { + log::error!("Failed to create CPFP transaction: {}", e); + Error::OnchainTxCreationFailed + })?; + + let tx = self.sign_psbt_all(psbt)?; + Ok((tx, parent_fee, parent_fee_rate)) + } + + /// Calculate an appropriate CPFP child fee rate given a parent txid and + /// a `target_fee_rate` that the caller wants the package to achieve. + /// + /// Returns the recommended child fee rate. If the parent already meets + /// the target, returns `target + 1 sat/vB`. + pub fn calculate_cpfp_fee_rate( + &self, parent_txid: Txid, target_fee_rate: FeeRate, + ) -> Result { + let parent_tx = self.find_tx(parent_txid).ok_or(Error::TransactionNotFound)?; + if self.is_tx_confirmed(&parent_txid) { + return Err(Error::TransactionAlreadyConfirmed); + } + + let parent_fee = self.calculate_tx_fee(&parent_tx)?; + let parent_fee_rate = parent_fee / parent_tx.weight(); + + // If parent already meets target, return slightly higher rate. + if parent_fee_rate >= target_fee_rate { + return Ok(FeeRate::from_sat_per_kwu(parent_fee_rate.to_sat_per_kwu() + 250)); + } + + // Estimate child size: conservative 1-in/1-out (~120 vB). + let estimated_child_vbytes: u64 = 120; + let parent_vbytes = parent_tx.weight().to_vbytes_ceil(); + + let parent_fee_deficit = (target_fee_rate.to_sat_per_vb_ceil() + - parent_fee_rate.to_sat_per_vb_ceil()) + * parent_vbytes; + let base_child_fee = target_fee_rate.to_sat_per_vb_ceil() * estimated_child_vbytes; + let total_child_fee = base_child_fee + parent_fee_deficit; + let child_fee_rate_sat_vb = total_child_fee / estimated_child_vbytes; + + Ok(FeeRate::from_sat_per_vb(child_fee_rate_sat_vb) + .unwrap_or(FeeRate::from_sat_per_kwu(child_fee_rate_sat_vb * 250))) + } + + // ─── RBF ──────────────────────────────────────────────────────────── + + /// Returns `true` if all inputs of the transaction belong to the primary wallet. + pub fn is_primary_only_tx(&self, txid: Txid) -> bool { + let primary = self.primary_wallet(); + if primary.get_tx(txid).is_none() { + return false; + } + let tx = match self.find_tx(txid) { + Some(tx) => tx, + None => return false, + }; + tx.input.iter().all(|txin| { + // Fast path: primary still has the UTXO (unspent). + if primary.get_utxo(txin.previous_output).is_some() { + return true; + } + // Slow path: the UTXO was already spent (by this tx). Check + // whether the output script belongs to the primary wallet. + if let Some(tx_node) = primary.get_tx(txin.previous_output.txid) { + if let Some(txout) = + tx_node.tx_node.tx.output.get(txin.previous_output.vout as usize) + { + return primary.is_mine(txout.script_pubkey.clone()); + } + } + false + }) + } + + /// Build an RBF replacement transaction. + /// + /// This is a high-level method that: + /// 1. Finds the original transaction across all wallets. + /// 2. Validates that it is unconfirmed and the new fee rate is higher. + /// 3. If the original tx was primary-only, tries BDK's built-in fee + /// bump and falls back to adding foreign inputs if the primary wallet + /// has insufficient funds. + /// 4. If the original tx was cross-wallet, uses manual RBF construction. + /// 5. Signs the replacement with all wallets. + /// + /// Returns the signed replacement transaction and the original fee + /// (for caller logging). + pub fn build_rbf( + &mut self, txid: Txid, new_fee_rate: FeeRate, + ) -> Result<(Transaction, Amount), Error> { + // Find original transaction. + let original_tx = self.find_tx(txid).ok_or(Error::TransactionNotFound)?; + + // Must not be confirmed. + if self.is_tx_confirmed(&txid) { + return Err(Error::TransactionAlreadyConfirmed); + } + + // Calculate original fee. + let original_fee = self.calculate_tx_fee(&original_tx)?; + let original_fee_rate = original_fee / original_tx.weight(); + + // New fee rate must be higher. + if new_fee_rate <= original_fee_rate { + return Err(Error::InvalidFeeRate); + } + + // Build the replacement. + let is_primary = self.is_primary_only_tx(txid); + + let tx = if is_primary { + self.build_rbf_primary_with_fallback(txid, new_fee_rate)? + } else { + self.build_cross_wallet_rbf_with_fallback(&original_tx, new_fee_rate)? + }; + + Ok((tx, original_fee)) + } + + /// Try a primary-only RBF first; if the primary wallet doesn't have + /// enough funds for the higher fee, fall back to adding non-primary + /// UTXOs as additional inputs. + fn build_rbf_primary_with_fallback( + &mut self, txid: Txid, fee_rate: FeeRate, + ) -> Result { + // Attempt 1: primary-only fee bump. + let primary_result: Result = { + let wallet = self.wallets.get_mut(&self.primary).ok_or(Error::WalletNotFound)?; + match wallet.build_fee_bump(txid) { + Ok(mut builder) => { + builder.fee_rate(fee_rate); + builder.finish().map_err(|e| { + log::debug!("Primary-only RBF failed: {}", e); + Error::InsufficientFunds + }) + }, + Err(e) => { + log::debug!("build_fee_bump failed: {}", e); + Err(Error::OnchainTxCreationFailed) + }, + } + }; // mutable wallet borrow released here + + match primary_result { + Ok(psbt) => self.sign_psbt_all(psbt), + Err(_) => { + // Attempt 2: select minimum non-primary UTXOs for the fee deficit. + let original_tx = self.find_tx(txid).ok_or(Error::TransactionNotFound)?; + let original_fee = self.calculate_tx_fee(&original_tx)?; + let new_fee = fee_rate.fee_wu(original_tx.weight()).unwrap_or(Amount::ZERO); + let fee_increase = new_fee.checked_sub(original_fee).unwrap_or(Amount::ZERO); + + // Wallet-owned outputs (change) in the original tx can absorb + // part of the fee increase, reducing what foreign UTXOs must cover. + let absorbable: Amount = original_tx + .output + .iter() + .filter(|out| self.is_mine(out.script_pubkey.clone())) + .map(|out| out.value) + .sum(); + // No explicit buffer needed: BDK's coin selection already + // accounts for each candidate's input weight cost. + let deficit = fee_increase.checked_sub(absorbable).unwrap_or(Amount::ZERO); + + let non_primary_infos = self.select_non_primary_foreign_utxos( + deficit, + fee_rate, + &HashSet::new(), + false, + CoinSelectionAlgorithm::BranchAndBound, + )?; + if non_primary_infos.is_empty() { + return Err(Error::InsufficientFunds); + } + + let wallet = self.wallets.get_mut(&self.primary).ok_or(Error::WalletNotFound)?; + let mut builder = wallet.build_fee_bump(txid).map_err(|e| { + log::error!("Failed to create fee bump builder (with foreign): {}", e); + Error::OnchainTxCreationFailed + })?; + builder.fee_rate(fee_rate); + + for info in &non_primary_infos { + builder + .add_foreign_utxo_with_sequence( + info.outpoint, + info.psbt_input.clone(), + info.weight, + bitcoin::Sequence::ENABLE_RBF_NO_LOCKTIME, + ) + .map_err(|e| { + log::error!("Failed to add foreign UTXO {:?}: {}", info.outpoint, e); + Error::OnchainTxCreationFailed + })?; + } + + let psbt = builder.finish().map_err(|e| { + log::error!("Failed to build RBF with additional inputs: {}", e); + Error::OnchainTxCreationFailed + })?; + + self.sign_psbt_all(psbt) + }, + } + } + + // ─── Persistence ──────────────────────────────────────────────────── + + /// Persist all wallets. + pub fn persist_all(&mut self) -> Result<(), Error> { + for (key, wallet) in self.wallets.iter_mut() { + if let Some(persister) = self.persisters.get_mut(key) { + wallet.persist(persister).map_err(|e| { + log::error!("Failed to persist wallet {:?}: {}", key, e); + Error::PersistenceFailed + })?; + } + } + Ok(()) + } + + /// Check if an output belongs to any wallet. + pub fn is_mine(&self, script_pubkey: ScriptBuf) -> bool { + self.wallets.values().any(|w| w.is_mine(script_pubkey.clone())) + } +} diff --git a/crates/bdk-wallet-aggregate/src/rbf.rs b/crates/bdk-wallet-aggregate/src/rbf.rs new file mode 100644 index 000000000..73aafe54f --- /dev/null +++ b/crates/bdk-wallet-aggregate/src/rbf.rs @@ -0,0 +1,219 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +//! Cross-wallet RBF (Replace-By-Fee) transaction construction. + +use std::collections::HashMap; +use std::fmt::Debug; +use std::hash::Hash; + +use bdk_wallet::{KeychainKind, PersistedWallet, WalletPersister}; +use bitcoin::{Amount, FeeRate, OutPoint, ScriptBuf, Transaction, TxIn, TxOut}; + +use crate::signing; +use crate::types::{Error, UtxoPsbtInfo}; +use crate::utxo::DUST_LIMIT_SATS; + +/// Get the value of an input by looking up the referenced UTXO across all +/// wallets. +pub fn get_input_value( + outpoint: &OutPoint, wallets: &HashMap>, +) -> Result +where + K: Eq + Hash + Copy + Debug, + P: WalletPersister, +{ + for wallet in wallets.values() { + if let Some(utxo) = wallet.get_utxo(*outpoint) { + return Ok(utxo.txout.value.to_sat()); + } + + if let Some(tx_node) = wallet.get_tx(outpoint.txid) { + if let Some(txout) = tx_node.tx_node.tx.output.get(outpoint.vout as usize) { + return Ok(txout.value.to_sat()); + } + } + } + + Err(Error::UtxoNotFoundLocally(*outpoint)) +} + +/// Build a cross-wallet RBF replacement transaction. +/// +/// Re-uses the original transaction's inputs, optionally adds `extra_utxos` +/// as new inputs, recalculates the fee at `new_fee_rate`, and adjusts the +/// change output accordingly. Signs with all wallets. +/// +/// Pass an empty slice for `extra_utxos` to attempt a change-only bump. +/// If that is insufficient the caller can select additional UTXOs and retry. +pub fn build_cross_wallet_rbf( + wallets: &mut HashMap>, original_tx: &Transaction, new_fee_rate: FeeRate, + extra_utxos: &[UtxoPsbtInfo], +) -> Result +where + K: Eq + Hash + Copy + Debug, + P: WalletPersister, +{ + // Total input value: original inputs + extra inputs. + let mut total_input_value: u64 = 0; + for input in &original_tx.input { + total_input_value += get_input_value(&input.previous_output, wallets)?; + } + for info in extra_utxos { + total_input_value += + info.psbt_input.witness_utxo.as_ref().map(|u| u.value.to_sat()).unwrap_or(0); + } + + // Original fee (for BIP 125 rule 4: replacement fee must be higher). + let original_output_value: u64 = original_tx.output.iter().map(|o| o.value.to_sat()).sum(); + let original_input_only: u64 = original_tx + .input + .iter() + .map(|i| get_input_value(&i.previous_output, wallets).unwrap_or(0)) + .sum(); + let original_fee = original_input_only.saturating_sub(original_output_value); + + // Build all inputs with RBF signalling. + let mut new_inputs: Vec = original_tx + .input + .iter() + .map(|txin| TxIn { + previous_output: txin.previous_output, + script_sig: ScriptBuf::new(), + sequence: bitcoin::Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: bitcoin::Witness::default(), + }) + .collect(); + for info in extra_utxos { + new_inputs.push(TxIn { + previous_output: info.outpoint, + script_sig: ScriptBuf::new(), + sequence: bitcoin::Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: bitcoin::Witness::default(), + }); + } + + // Estimate weight with all inputs and calculate required fee. + let estimated_weight = estimate_tx_weight(wallets, &new_inputs, &original_tx.output); + let required_fee = new_fee_rate.to_sat_per_kwu() * estimated_weight.to_wu() / 1000; + let required_fee = std::cmp::max(required_fee, original_fee + 1); // BIP 125 rule 4 + + // Find the change output: only adjust an Internal (change) keychain output. + let mut new_outputs = original_tx.output.clone(); + let change_idx = new_outputs.iter().position(|out| { + wallets.values().any(|w| { + w.list_unspent().any(|u| { + u.keychain == KeychainKind::Internal && u.txout.script_pubkey == out.script_pubkey + }) + }) + }); + + // Recipient value = sum of all non-change outputs. + let recipient_value: u64 = new_outputs + .iter() + .enumerate() + .filter(|(i, _)| Some(*i) != change_idx) + .map(|(_, out)| out.value.to_sat()) + .sum(); + + // Calculate the new change value from scratch. + let available_for_change = total_input_value.saturating_sub(required_fee + recipient_value); + + match change_idx { + Some(idx) => { + if available_for_change < DUST_LIMIT_SATS { + log::error!( + "Insufficient funds for cross-wallet RBF: available_for_change={}, required_fee={}, total_input={}", + available_for_change, + required_fee, + total_input_value + ); + return Err(Error::InsufficientFunds); + } + new_outputs[idx].value = Amount::from_sat(available_for_change); + }, + None => { + // No change output (drain tx) — cannot absorb fee increase. + if total_input_value < required_fee + recipient_value { + return Err(Error::InsufficientFunds); + } + // All surplus goes to fees (no change output to create). + return Err(Error::OnchainTxCreationFailed); + }, + } + + let unsigned_tx = Transaction { + version: original_tx.version, + lock_time: original_tx.lock_time, + input: new_inputs, + output: new_outputs, + }; + + signing::sign_owned_inputs(unsigned_tx, wallets) +} + +/// Estimate the weight of a transaction for fee calculation purposes. +fn estimate_tx_weight( + wallets: &HashMap>, inputs: &[TxIn], outputs: &[TxOut], +) -> bitcoin::Weight +where + K: Eq + Hash + Copy + Debug, + P: WalletPersister, +{ + use bitcoin::Weight; + + // Base transaction overhead (conservative estimate): + // version (4 bytes) + locktime (4 bytes) = 8 non-witness bytes = 32 WU + // + witness marker & flag (2 bytes) = 2 WU = 34 WU actual. + // We use 40 WU as a conservative buffer for fee estimation safety. + let base_weight = Weight::from_wu(40); + + // Input count varint + let input_count_weight = Weight::from_wu(4); // 1 byte * 4 WU + + // Per-input weight + let mut input_weight = Weight::ZERO; + for txin in inputs { + // 32 bytes txid + 4 bytes vout + 4 bytes sequence + 1 byte script_sig + // length = 41 bytes * 4 WU + let base_input = Weight::from_wu(164); + + let satisfaction = + wallets + .values() + .find_map(|w| { + w.get_utxo(txin.previous_output) + .map(|utxo| crate::utxo::calculate_utxo_weight(&utxo.txout.script_pubkey)) + }) + .or_else(|| { + // Fallback for spent UTXOs: look up via the transaction. + wallets.values().find_map(|w| { + w.get_tx(txin.previous_output.txid).and_then(|tx_node| { + tx_node.tx_node.tx.output.get(txin.previous_output.vout as usize).map( + |txout| crate::utxo::calculate_utxo_weight(&txout.script_pubkey), + ) + }) + }) + }) + .unwrap_or(Weight::from_wu(272)); // default P2WPKH + + input_weight = input_weight + base_input + satisfaction; + } + + // Output count varint + let output_count_weight = Weight::from_wu(4); + + // Per-output weight + let mut output_weight = Weight::ZERO; + for txout in outputs { + // 8 bytes value + 1 byte script length + script_pubkey length, all *4 + let script_len = txout.script_pubkey.len() as u64; + output_weight += Weight::from_wu((8 + 1 + script_len) * 4); + } + + base_weight + input_count_weight + input_weight + output_count_weight + output_weight +} diff --git a/crates/bdk-wallet-aggregate/src/signing.rs b/crates/bdk-wallet-aggregate/src/signing.rs new file mode 100644 index 000000000..87043d1f6 --- /dev/null +++ b/crates/bdk-wallet-aggregate/src/signing.rs @@ -0,0 +1,160 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +//! Multi-wallet PSBT signing logic. + +use std::collections::HashMap; +use std::fmt::Debug; +use std::hash::Hash; + +#[allow(deprecated)] +use bdk_wallet::{PersistedWallet, SignOptions, WalletPersister}; +use bitcoin::psbt::{self, Psbt}; +use bitcoin::Transaction; + +use crate::types::Error; + +/// Sign an unsigned transaction using every wallet that owns one of its +/// inputs. This works by creating a PSBT with the transaction's inputs +/// and outputs and then having each wallet sign its owned inputs. +pub fn sign_owned_inputs( + unsigned_tx: Transaction, wallets: &mut HashMap>, +) -> Result +where + K: Eq + Hash + Copy + Debug, + P: WalletPersister, +{ + let psbt = Psbt::from_unsigned_tx(unsigned_tx).map_err(|e| { + log::error!("Failed to create PSBT from unsigned tx: {}", e); + Error::OnchainTxSigningFailed + })?; + + sign_psbt_all_wallets(psbt, wallets) +} + +/// Sign a PSBT using every wallet that owns at least one of its inputs. +pub fn sign_psbt_all_wallets( + mut psbt: Psbt, wallets: &mut HashMap>, +) -> Result +where + K: Eq + Hash + Copy + Debug, + P: WalletPersister, +{ + populate_psbt_inputs_from_wallets(&mut psbt, wallets); + + let mut wallets_that_signed = 0u32; + #[allow(deprecated)] + let sign_options = SignOptions { trust_witness_utxo: true, ..Default::default() }; + + for (key, wallet) in wallets.iter_mut() { + match wallet.sign(&mut psbt, sign_options.clone()) { + Ok(_finalized) => { + // BDK returns `finalized = true` only when ALL inputs are + // signed. For multi-wallet PSBTs no single wallet will + // finalize the whole PSBT, so we track whether at least one + // wallet contributed signatures instead of relying on + // `finalized`. + wallets_that_signed += 1; + log::trace!("Wallet {:?} signed PSBT inputs", key); + }, + Err(e) => { + log::trace!( + "Wallet {:?} could not sign PSBT: {} (expected for foreign inputs)", + key, + e + ); + }, + } + } + + if wallets_that_signed == 0 { + log::warn!("No wallet was able to sign any inputs in the PSBT"); + } + + match psbt.extract_tx() { + Ok(tx) => Ok(tx), + Err(psbt::ExtractTxError::MissingInputValue { tx }) => { + log::warn!( + "extract_tx could not verify fee (MissingInputValue) for txid {}", + tx.compute_txid() + ); + Ok(tx) + }, + Err(e) => { + log::error!("Failed to extract signed transaction: {}", e); + Err(Error::OnchainTxSigningFailed) + }, + } +} + +/// Populate PSBT inputs with UTXO data from all wallets. +/// +/// Handles both unspent and already-spent UTXOs (important for RBF where +/// the original transaction already consumed the inputs). +fn populate_psbt_inputs_from_wallets( + psbt: &mut Psbt, wallets: &HashMap>, +) where + K: Eq + Hash + Copy + Debug, + P: WalletPersister, +{ + for (i, txin) in psbt.unsigned_tx.input.iter().enumerate() { + if i >= psbt.inputs.len() { + psbt.inputs.push(bitcoin::psbt::Input::default()); + } + + let psbt_input = &psbt.inputs[i]; + // Only skip if BOTH witness_utxo and non_witness_utxo are already populated. + if psbt_input.witness_utxo.is_some() && psbt_input.non_witness_utxo.is_some() { + continue; + } + + let mut found = false; + for wallet in wallets.values() { + // Try get_utxo first (works for unspent outputs). + if let Some(utxo) = wallet.get_utxo(txin.previous_output) { + if psbt.inputs[i].witness_utxo.is_none() { + psbt.inputs[i].witness_utxo = Some(utxo.txout.clone()); + } + + // Always populate non_witness_utxo to guard against SegWit fee + // vulnerability and ensure BDK can verify input values. + if psbt.inputs[i].non_witness_utxo.is_none() { + if let Some(tx_node) = wallet.get_tx(txin.previous_output.txid) { + psbt.inputs[i].non_witness_utxo = Some(tx_node.tx_node.tx.as_ref().clone()); + } + } + found = true; + break; + } + + // Fallback: look up via the transaction that created this output. + // This handles the RBF case where the UTXO has already been spent + // by the transaction we are replacing. + if let Some(tx_node) = wallet.get_tx(txin.previous_output.txid) { + if let Some(txout) = + tx_node.tx_node.tx.output.get(txin.previous_output.vout as usize) + { + if psbt.inputs[i].witness_utxo.is_none() { + psbt.inputs[i].witness_utxo = Some(txout.clone()); + } + if psbt.inputs[i].non_witness_utxo.is_none() { + psbt.inputs[i].non_witness_utxo = Some(tx_node.tx_node.tx.as_ref().clone()); + } + found = true; + break; + } + } + } + + if !found { + log::debug!( + "Could not find UTXO data for input {:?} in any wallet", + txin.previous_output + ); + } + } +} diff --git a/crates/bdk-wallet-aggregate/src/types.rs b/crates/bdk-wallet-aggregate/src/types.rs new file mode 100644 index 000000000..5565d7d41 --- /dev/null +++ b/crates/bdk-wallet-aggregate/src/types.rs @@ -0,0 +1,95 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +//! Core types for the aggregate wallet library. + +use std::fmt; + +use bitcoin::{psbt, OutPoint, Weight}; + +/// Errors returned by the aggregate wallet library. +#[derive(Debug, Clone, PartialEq)] +pub enum Error { + /// A wallet for the requested key was not found. + WalletNotFound, + /// A persister for the requested key was not found. + PersisterNotFound, + /// Failed to persist wallet state. + PersistenceFailed, + /// On-chain transaction creation failed. + OnchainTxCreationFailed, + /// On-chain transaction signing failed. + OnchainTxSigningFailed, + /// Insufficient funds for the requested operation. + InsufficientFunds, + /// The requested fee rate is invalid. + InvalidFeeRate, + /// No spendable outputs available. + NoSpendableOutputs, + /// Coin selection failed. + CoinSelectionFailed, + /// A generic wallet operation failed. + WalletOperationFailed, + /// UTXO not found locally (caller may want to try a chain source fallback). + UtxoNotFoundLocally(OutPoint), + /// Transaction not found in any wallet. + TransactionNotFound, + /// Cannot replace a transaction that is already confirmed. + TransactionAlreadyConfirmed, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::WalletNotFound => write!(f, "Wallet not found"), + Error::PersisterNotFound => write!(f, "Persister not found"), + Error::PersistenceFailed => write!(f, "Failed to persist wallet state"), + Error::OnchainTxCreationFailed => write!(f, "On-chain transaction creation failed"), + Error::OnchainTxSigningFailed => write!(f, "On-chain transaction signing failed"), + Error::InsufficientFunds => write!(f, "Insufficient funds"), + Error::InvalidFeeRate => write!(f, "Invalid fee rate"), + Error::NoSpendableOutputs => write!(f, "No spendable outputs available"), + Error::CoinSelectionFailed => write!(f, "Coin selection failed"), + Error::WalletOperationFailed => write!(f, "Wallet operation failed"), + Error::UtxoNotFoundLocally(op) => { + write!(f, "UTXO {:?} not found in any local wallet", op) + }, + Error::TransactionNotFound => write!(f, "Transaction not found in any wallet"), + Error::TransactionAlreadyConfirmed => { + write!(f, "Cannot replace an already-confirmed transaction") + }, + } + } +} + +impl std::error::Error for Error {} + +/// UTXO info for multi-wallet PSBT building. +#[derive(Clone)] +pub struct UtxoPsbtInfo { + /// The outpoint being spent. + pub outpoint: OutPoint, + /// The PSBT input data for this UTXO. + pub psbt_input: psbt::Input, + /// The satisfaction weight for this input type. + pub weight: Weight, + /// Whether this UTXO belongs to the primary wallet. + pub is_primary: bool, +} + +/// Available coin selection algorithms. +#[derive(Debug, Clone, Copy)] +pub enum CoinSelectionAlgorithm { + /// Branch and bound algorithm (tries to find exact match). + BranchAndBound, + /// Select largest UTXOs first. + LargestFirst, + /// Select oldest UTXOs first. + OldestFirst, + /// Select UTXOs randomly. + SingleRandomDraw, +} diff --git a/crates/bdk-wallet-aggregate/src/utxo.rs b/crates/bdk-wallet-aggregate/src/utxo.rs new file mode 100644 index 000000000..d90349beb --- /dev/null +++ b/crates/bdk-wallet-aggregate/src/utxo.rs @@ -0,0 +1,245 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +//! UTXO weight calculation, PSBT preparation, and coin selection helpers. + +use std::collections::HashMap; +use std::fmt::Debug; +use std::hash::Hash; + +#[allow(deprecated)] +use bdk_wallet::coin_selection::CoinSelectionAlgorithm as BdkCoinSelectionAlgorithm; +use bdk_wallet::coin_selection::{ + BranchAndBoundCoinSelection, Excess, LargestFirstCoinSelection, OldestFirstCoinSelection, + SingleRandomDraw, +}; +use bdk_wallet::{LocalOutput, PersistedWallet, WalletPersister, WeightedUtxo}; +use bip39::rand::rngs::OsRng; +use bitcoin::{psbt, Amount, FeeRate, OutPoint, Script, ScriptBuf, Weight}; + +use crate::types::{CoinSelectionAlgorithm, Error, UtxoPsbtInfo}; + +/// Minimum economical output value (dust limit). +pub const DUST_LIMIT_SATS: u64 = 546; + +/// Calculate the satisfaction weight for a UTXO based on its script type. +pub fn calculate_utxo_weight(script_pubkey: &ScriptBuf) -> Weight { + match script_pubkey.witness_version() { + Some(bitcoin::WitnessVersion::V0) => Weight::from_wu(272), // P2WPKH + Some(bitcoin::WitnessVersion::V1) => Weight::from_wu(230), // P2TR + None => { + if script_pubkey.is_p2sh() { + Weight::from_wu(360) // P2SH-wrapped P2WPKH + } else { + Weight::from_wu(588) // P2PKH (legacy) + } + }, + _ => Weight::from_wu(272), // Fallback to P2WPKH weight + } +} + +/// Prepare UTXO information needed to add local outputs as foreign UTXOs in a +/// cross-wallet PSBT. +pub fn prepare_utxos_for_psbt( + utxos: &[LocalOutput], wallets: &HashMap>, primary_key: &K, +) -> Result, Error> +where + K: Eq + Hash + Copy + Debug, + P: WalletPersister, +{ + let mut result = Vec::with_capacity(utxos.len()); + + for utxo in utxos { + let is_primary = + wallets.get(primary_key).map(|w| w.get_utxo(utxo.outpoint).is_some()).unwrap_or(false); + + let weight = calculate_utxo_weight(&utxo.txout.script_pubkey); + + let mut psbt_input = + psbt::Input { witness_utxo: Some(utxo.txout.clone()), ..Default::default() }; + + // Always include the full previous transaction (non_witness_utxo). + // BDK requires this for foreign UTXOs during PSBT construction to + // guard against the SegWit fee vulnerability, even for witness inputs. + let mut found_tx = false; + for wallet in wallets.values() { + if let Some(tx_node) = wallet.get_tx(utxo.outpoint.txid) { + psbt_input.non_witness_utxo = Some(tx_node.tx_node.tx.as_ref().clone()); + found_tx = true; + break; + } + } + + if !found_tx { + return Err(Error::UtxoNotFoundLocally(utxo.outpoint)); + } + + result.push(UtxoPsbtInfo { outpoint: utxo.outpoint, psbt_input, weight, is_primary }); + } + + Ok(result) +} + +/// Prepare outpoints for PSBT by looking them up across all wallets. +pub fn prepare_outpoints_for_psbt( + outpoints: &[OutPoint], wallets: &HashMap>, primary_key: &K, +) -> Result, Error> +where + K: Eq + Hash + Copy + Debug, + P: WalletPersister, +{ + let utxos: Vec = outpoints + .iter() + .filter_map(|outpoint| { + wallets.values().find_map(|w| w.get_utxo(*outpoint).filter(|u| !u.is_spent)) + }) + .collect(); + + if utxos.len() != outpoints.len() { + log::error!("Some outpoints were not found in any wallet"); + return Err(Error::WalletOperationFailed); + } + + prepare_utxos_for_psbt(&utxos, wallets, primary_key) +} + +/// Add UTXO information to a BDK `TxBuilder`. +pub fn add_utxos_to_tx_builder( + tx_builder: &mut bdk_wallet::TxBuilder<'_, Cs>, utxo_infos: &[UtxoPsbtInfo], +) -> Result<(), Error> { + for info in utxo_infos { + if info.is_primary { + tx_builder.add_utxo(info.outpoint).map_err(|e| { + log::error!("Failed to add primary UTXO {:?}: {}", info.outpoint, e); + Error::OnchainTxCreationFailed + })?; + } else { + tx_builder + .add_foreign_utxo_with_sequence( + info.outpoint, + info.psbt_input.clone(), + info.weight, + bitcoin::Sequence::ENABLE_RBF_NO_LOCKTIME, + ) + .map_err(|e| { + log::error!("Failed to add foreign UTXO {:?}: {}", info.outpoint, e); + Error::OnchainTxCreationFailed + })?; + } + } + Ok(()) +} + +/// Run coin selection across UTXOs from any wallet. +#[allow(clippy::too_many_arguments)] +pub fn select_utxos_with_algorithm( + target_amount: u64, available_utxos: Vec, fee_rate: FeeRate, + algorithm: CoinSelectionAlgorithm, drain_script: &Script, excluded_outpoints: &[OutPoint], + wallets: &HashMap>, +) -> Result, Error> +where + K: Eq + Hash + Copy + Debug, + P: WalletPersister, +{ + let safe_utxos: Vec = available_utxos + .into_iter() + .filter(|utxo| !excluded_outpoints.contains(&utxo.outpoint)) + .collect(); + + if safe_utxos.is_empty() { + log::error!("No spendable UTXOs available after filtering"); + return Err(Error::NoSpendableOutputs); + } + + let weighted_utxos: Vec = safe_utxos + .iter() + .map(|utxo| { + // Find the wallet that actually owns this UTXO and use its + // descriptor for an accurate satisfaction weight. Falls back to + // script-type heuristic if no wallet claims the output. + let satisfaction_weight = wallets + .values() + .find_map(|w| { + if w.get_utxo(utxo.outpoint).is_some() { + w.public_descriptor(utxo.keychain).max_weight_to_satisfy().ok() + } else { + None + } + }) + .unwrap_or_else(|| calculate_utxo_weight(&utxo.txout.script_pubkey)); + + WeightedUtxo { satisfaction_weight, utxo: bdk_wallet::Utxo::Local(utxo.clone()) } + }) + .collect(); + + let target = Amount::from_sat(target_amount); + let mut rng = OsRng; + + let result = match algorithm { + CoinSelectionAlgorithm::BranchAndBound => { + BranchAndBoundCoinSelection::::default().coin_select( + vec![], + weighted_utxos, + fee_rate, + target, + drain_script, + &mut rng, + ) + }, + CoinSelectionAlgorithm::LargestFirst => LargestFirstCoinSelection.coin_select( + vec![], + weighted_utxos, + fee_rate, + target, + drain_script, + &mut rng, + ), + CoinSelectionAlgorithm::OldestFirst => OldestFirstCoinSelection.coin_select( + vec![], + weighted_utxos, + fee_rate, + target, + drain_script, + &mut rng, + ), + CoinSelectionAlgorithm::SingleRandomDraw => SingleRandomDraw.coin_select( + vec![], + weighted_utxos, + fee_rate, + target, + drain_script, + &mut rng, + ), + } + .map_err(|e| { + log::error!("Coin selection failed: {}", e); + Error::CoinSelectionFailed + })?; + + if let Excess::Change { amount, .. } = result.excess { + if amount.to_sat() > 0 && amount.to_sat() < DUST_LIMIT_SATS { + return Err(Error::CoinSelectionFailed); + } + } + + let selected_outputs: Vec = result + .selected + .into_iter() + .filter_map(|utxo| match utxo { + bdk_wallet::Utxo::Local(local) => Some(local), + _ => None, + }) + .collect(); + + log::debug!( + "Selected {} UTXOs using {:?} algorithm for target {} sats", + selected_outputs.len(), + algorithm, + target_amount, + ); + Ok(selected_outputs.into_iter().map(|u| u.outpoint).collect()) +} diff --git a/src/balance.rs b/src/balance.rs index d96278dae..fa4d6b645 100644 --- a/src/balance.rs +++ b/src/balance.rs @@ -13,6 +13,15 @@ use lightning::sign::SpendableOutputDescriptor; use lightning::util::sweep::{OutputSpendStatus, TrackedSpendableOutput}; use lightning_types::payment::{PaymentHash, PaymentPreimage}; +/// Balance details for a specific address type wallet. +#[derive(Debug, Clone)] +pub struct AddressTypeBalance { + /// The total balance of the wallet for this address type. + pub total_sats: u64, + /// The currently spendable balance of the wallet for this address type. + pub spendable_sats: u64, +} + /// Details of the known available balances returned by [`Node::list_balances`]. /// /// [`Node::list_balances`]: crate::Node::list_balances diff --git a/src/builder.rs b/src/builder.rs index a85040d8c..f9bdb3803 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -14,8 +14,8 @@ use std::sync::{Arc, Mutex, Once, RwLock}; use std::time::SystemTime; use std::{fmt, fs}; -use bdk_wallet::template::Bip84; -use bdk_wallet::{KeychainKind, Wallet as BdkWallet}; +use bdk_wallet::template::{Bip44, Bip49, Bip84, Bip86}; +use bdk_wallet::{KeychainKind, PersistedWallet, Wallet as BdkWallet}; use bip39::Mnemonic; use bitcoin::bip32::{ChildNumber, Xpriv}; use bitcoin::secp256k1::PublicKey; @@ -49,7 +49,7 @@ use vss_client::headers::{FixedHeaders, LnurlAuthToJwtProvider, VssHeaderProvide use crate::chain::ChainSource; use crate::config::{ - default_user_config, may_announce_channel, AnnounceError, AsyncPaymentsRole, + default_user_config, may_announce_channel, AddressType, AnnounceError, AsyncPaymentsRole, BitcoindRestClientConfig, Config, ElectrumSyncConfig, EsploraSyncConfig, RuntimeSyncIntervals, DEFAULT_ESPLORA_SERVER_URL, DEFAULT_LOG_FILENAME, DEFAULT_LOG_LEVEL, WALLET_KEYS_SEED_LEN, }; @@ -567,6 +567,30 @@ impl NodeBuilder { self } + /// Sets the primary address type for the on-chain wallet. + /// + /// This determines what type of addresses will be generated for new receiving and change + /// addresses. Default is [`AddressType::NativeSegwit`]. + pub fn set_address_type(&mut self, address_type: AddressType) -> &mut Self { + self.config.address_type = address_type; + self + } + + /// Sets additional address types to monitor for existing funds. + /// + /// These address types will be scanned alongside the primary address type during chain + /// synchronization. Useful when migrating from one address type to another — for example, + /// if switching from Legacy to NativeSegwit, add `Legacy` here to continue monitoring + /// old Legacy addresses. + /// + /// Default is empty (only the primary address type is monitored). + pub fn set_address_types_to_monitor( + &mut self, address_types_to_monitor: Vec, + ) -> &mut Self { + self.config.address_types_to_monitor = address_types_to_monitor; + self + } + /// Sets the IP address and TCP port on which [`Node`] will listen for incoming network connections. pub fn set_listening_addresses( &mut self, listening_addresses: Vec, @@ -1064,6 +1088,16 @@ impl ArcedNodeBuilder { self.inner.write().unwrap().set_network(network); } + /// Sets the primary address type for the on-chain wallet. + pub fn set_address_type(&self, address_type: AddressType) { + self.inner.write().unwrap().set_address_type(address_type); + } + + /// Sets additional address types to monitor for existing funds. + pub fn set_address_types_to_monitor(&self, address_types_to_monitor: Vec) { + self.inner.write().unwrap().set_address_types_to_monitor(address_types_to_monitor); + } + /// Sets the IP address and TCP port on which [`Node`] will listen for incoming network connections. pub fn set_listening_addresses( &self, listening_addresses: Vec, @@ -1186,7 +1220,124 @@ impl ArcedNodeBuilder { } } -/// Builds a [`Node`] instance according to the options previously configured. +fn get_or_create_wallet_for_address_type( + address_type: AddressType, xprv: Xpriv, network: Network, + persister: &mut KVStoreWalletPersister, +) -> Result, BuildError> { + macro_rules! load_or_create { + ($ext:expr, $int:expr) => {{ + let wallet_opt = BdkWallet::load() + .descriptor(KeychainKind::External, Some($ext)) + .descriptor(KeychainKind::Internal, Some($int)) + .extract_keys() + .check_network(network) + .load_wallet(persister) + .map_err(|e| match e { + bdk_wallet::LoadWithPersistError::InvalidChangeSet( + bdk_wallet::LoadError::Mismatch(bdk_wallet::LoadMismatch::Network { + loaded, + expected, + }), + ) => { + log::error!( + "Failed to setup wallet: Networks do not match. Expected {} but got {}", + expected, + loaded + ); + BuildError::NetworkMismatch + }, + _ => { + log::error!("Failed to set up wallet: {}", e); + BuildError::WalletSetupFailed + }, + })?; + match wallet_opt { + Some(w) => Ok(w), + None => BdkWallet::create($ext, $int) + .network(network) + .create_wallet(persister) + .map_err(|_| BuildError::WalletSetupFailed), + } + }}; + } + + match address_type { + AddressType::Legacy => load_or_create!( + Bip44(xprv, KeychainKind::External), + Bip44(xprv, KeychainKind::Internal) + ), + AddressType::NestedSegwit => load_or_create!( + Bip49(xprv, KeychainKind::External), + Bip49(xprv, KeychainKind::Internal) + ), + AddressType::NativeSegwit => load_or_create!( + Bip84(xprv, KeychainKind::External), + Bip84(xprv, KeychainKind::Internal) + ), + AddressType::Taproot => load_or_create!( + Bip86(xprv, KeychainKind::External), + Bip86(xprv, KeychainKind::Internal) + ), + } +} + +fn build_additional_wallets( + config: &Config, xprv: Xpriv, kv_store: Arc, logger: Arc, + chain_tip_opt: Option<&BestBlock>, +) -> Vec<(AddressType, PersistedWallet, KVStoreWalletPersister)> { + let mut additional_wallets = Vec::new(); + + for address_type in config.additional_address_types() { + match (|| -> Result<_, BuildError> { + let mut persister = KVStoreWalletPersister::new( + Arc::clone(&kv_store), + Arc::clone(&logger), + address_type, + ); + + let mut wallet = get_or_create_wallet_for_address_type( + address_type, + xprv, + config.network, + &mut persister, + ) + .map_err(|e| { + log_error!(logger, "Failed to setup wallet for {:?}: {}", address_type, e); + e + })?; + + // For newly created wallets, set the chain tip checkpoint. + if let Some(best_block) = chain_tip_opt { + let mut cp = wallet.latest_checkpoint(); + let block_id = + bdk_chain::BlockId { height: best_block.height, hash: best_block.block_hash }; + cp = cp.insert(block_id); + let update = bdk_wallet::Update { chain: Some(cp), ..Default::default() }; + wallet.apply_update(update).map_err(|e| { + log_error!(logger, "Failed to apply checkpoint for {:?}: {}", address_type, e); + BuildError::WalletSetupFailed + })?; + } + Ok((address_type, wallet, persister)) + })() { + Ok(tuple) => { + log_info!(logger, "Created additional wallet for {:?}", tuple.0); + additional_wallets.push(tuple); + }, + Err(e) => { + log_error!( + logger, + "Failed to create additional wallet for {:?}: {}. Continuing without it.", + address_type, + e + ); + }, + } + } + + additional_wallets +} + fn build_with_store_internal( config: Arc, chain_data_source_config: Option<&ChainDataSourceConfig>, gossip_source_config: Option<&GossipSourceConfig>, @@ -1220,8 +1371,6 @@ fn build_with_store_internal( log_error!(logger, "Failed to derive master secret: {}", e); BuildError::InvalidSeedBytes })?; - let descriptor = Bip84(xprv, KeychainKind::External); - let change_descriptor = Bip84(xprv, KeychainKind::Internal); let tx_broadcaster = Arc::new(TransactionBroadcaster::new(Arc::clone(&logger))); let fee_estimator = Arc::new(OnchainFeeEstimator::new()); @@ -1242,16 +1391,16 @@ fn build_with_store_internal( read_payments_async(payments_store, payments_logger), tokio::task::spawn_blocking({ let network = config.network; - let descriptor = descriptor.clone(); - let change_descriptor = change_descriptor.clone(); + let address_type = config.address_type; move || { - let mut persister = KVStoreWalletPersister::new(wallet_store, wallet_logger); - let result = BdkWallet::load() - .descriptor(KeychainKind::External, Some(descriptor)) - .descriptor(KeychainKind::Internal, Some(change_descriptor)) - .extract_keys() - .check_network(network) - .load_wallet(&mut persister); + let mut persister = + KVStoreWalletPersister::new(wallet_store, wallet_logger, address_type); + let result = get_or_create_wallet_for_address_type( + address_type, + xprv, + network, + &mut persister, + ); (result, persister) } }) @@ -1291,30 +1440,14 @@ fn build_with_store_internal( }; // Process wallet result - let (wallet_load_result, mut wallet_persister) = match wallet_result { + let (wallet_load_result, wallet_persister) = match wallet_result { Ok(result) => result, Err(e) => { log_error!(logger, "Task join error loading wallet: {}", e); return Err(BuildError::WalletSetupFailed); }, }; - let wallet_opt = wallet_load_result.map_err(|e| match e { - bdk_wallet::LoadWithPersistError::InvalidChangeSet(bdk_wallet::LoadError::Mismatch( - bdk_wallet::LoadMismatch::Network { loaded, expected }, - )) => { - log_error!( - logger, - "Failed to setup wallet: Networks do not match. Expected {} but got {}", - expected, - loaded - ); - BuildError::NetworkMismatch - }, - _ => { - log_error!(logger, "Failed to set up wallet: {}", e); - BuildError::WalletSetupFailed - }, - })?; + let bdk_wallet_loaded = wallet_load_result?; // Chain source setup let (chain_source, chain_tip_opt) = match chain_data_source_config { @@ -1404,39 +1537,31 @@ fn build_with_store_internal( }; let chain_source = Arc::new(chain_source); - // Initialize the on-chain wallet - let bdk_wallet = match wallet_opt { - Some(wallet) => wallet, - None => { - let mut wallet = BdkWallet::create(descriptor, change_descriptor) - .network(config.network) - .create_wallet(&mut wallet_persister) - .map_err(|e| { - log_error!(logger, "Failed to set up wallet: {}", e); - BuildError::WalletSetupFailed - })?; + let mut bdk_wallet = bdk_wallet_loaded; + if let Some(best_block) = chain_tip_opt { + let mut latest_checkpoint = bdk_wallet.latest_checkpoint(); + let block_id = + bdk_chain::BlockId { height: best_block.height, hash: best_block.block_hash }; + latest_checkpoint = latest_checkpoint.insert(block_id); + let update = bdk_wallet::Update { chain: Some(latest_checkpoint), ..Default::default() }; + bdk_wallet.apply_update(update).map_err(|e| { + log_error!(logger, "Failed to apply checkpoint during wallet setup: {}", e); + BuildError::WalletSetupFailed + })?; + } - if let Some(best_block) = chain_tip_opt { - // Insert the first checkpoint if we have it, to avoid resyncing from genesis. - // TODO: Use a proper wallet birthday once BDK supports it. - let mut latest_checkpoint = wallet.latest_checkpoint(); - let block_id = - bdk_chain::BlockId { height: best_block.height, hash: best_block.block_hash }; - latest_checkpoint = latest_checkpoint.insert(block_id); - let update = - bdk_wallet::Update { chain: Some(latest_checkpoint), ..Default::default() }; - wallet.apply_update(update).map_err(|e| { - log_error!(logger, "Failed to apply checkpoint during wallet setup: {}", e); - BuildError::WalletSetupFailed - })?; - } - wallet - }, - }; + let additional_wallets = build_additional_wallets( + &config, + xprv, + Arc::clone(&kv_store), + Arc::clone(&logger), + chain_tip_opt.as_ref(), + ); let wallet = Arc::new(Wallet::new( bdk_wallet, wallet_persister, + additional_wallets, Arc::clone(&tx_broadcaster), Arc::clone(&fee_estimator), Arc::clone(&payment_store), diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index e524c8a64..475c32243 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -21,6 +21,7 @@ use electrum_client::{ Batch, Client as ElectrumClient, ConfigBuilder as ElectrumConfigBuilder, ElectrumApi, }; use lightning::chain::{Confirm, Filter, WatchedOutput}; +use lightning::log_warn; use lightning::util::ser::Writeable; use lightning_transaction_sync::ElectrumSyncClient; @@ -172,10 +173,10 @@ impl ElectrumChainSource { let cached_txs = onchain_wallet.get_cached_txs(); - let res = if incremental_sync { + let primary_result = if incremental_sync { let incremental_sync_request = onchain_wallet.get_incremental_sync_request(); let incremental_sync_fut = electrum_client - .get_incremental_sync_wallet_update(incremental_sync_request, cached_txs); + .get_incremental_sync_wallet_update(incremental_sync_request, cached_txs.clone()); let now = Instant::now(); let update_res = incremental_sync_fut.await.map(|u| u.into()); @@ -183,13 +184,70 @@ impl ElectrumChainSource { } else { let full_scan_request = onchain_wallet.get_full_scan_request(); let full_scan_fut = - electrum_client.get_full_scan_wallet_update(full_scan_request, cached_txs); + electrum_client.get_full_scan_wallet_update(full_scan_request, cached_txs.clone()); let now = Instant::now(); let update_res = full_scan_fut.await.map(|u| u.into()); apply_wallet_update(update_res, now) }; - res + let (mut all_events, primary_error) = match primary_result { + Ok(events) => (events, None), + Err(e) => (Vec::new(), Some(e)), + }; + + let sync_requests = super::collect_additional_sync_requests( + &self.config, + &onchain_wallet, + &self.node_metrics, + &self.logger, + ); + + let mut join_set = tokio::task::JoinSet::new(); + for (address_type, full_scan_req, incremental_req, do_incremental) in sync_requests { + let client = Arc::clone(&electrum_client); + let txs = cached_txs.clone(); + join_set.spawn(async move { + let result: Result = if do_incremental { + client + .get_incremental_sync_wallet_update(incremental_req, txs) + .await + .map(|u| u.into()) + } else { + client.get_full_scan_wallet_update(full_scan_req, txs).await.map(|u| u.into()) + }; + (address_type, result) + }); + } + + let mut sync_results = Vec::new(); + while let Some(join_result) = join_set.join_next().await { + match join_result { + Ok((address_type, Ok(update))) => { + sync_results.push((address_type, Some(update))); + }, + Ok((address_type, Err(e))) => { + log_warn!(self.logger, "Failed to sync wallet {:?}: {}", address_type, e); + sync_results.push((address_type, None)); + }, + Err(e) => { + log_warn!(self.logger, "Wallet sync task panicked: {}", e); + }, + }; + } + + all_events.extend(super::apply_additional_sync_results( + sync_results, + &onchain_wallet, + &self.node_metrics, + &self.kv_store, + &self.logger, + )); + + if let Some(e) = primary_error { + return Err(e); + } + + Ok(all_events) } pub(crate) async fn sync_lightning_wallet( diff --git a/src/chain/esplora.rs b/src/chain/esplora.rs index 2cffaf0bb..67691ec92 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -14,6 +14,7 @@ use bdk_wallet::event::WalletEvent; use bitcoin::{FeeRate, Network, Script, Transaction, Txid}; use esplora_client::AsyncClient as EsploraAsyncClient; use lightning::chain::{Confirm, Filter, WatchedOutput}; +use lightning::log_warn; use lightning::util::ser::Writeable; use lightning_transaction_sync::EsploraSyncClient; @@ -190,7 +191,7 @@ impl EsploraChainSource { }} } - if incremental_sync { + let primary_result = if incremental_sync { let sync_request = onchain_wallet.get_incremental_sync_request(); let wallet_sync_timeout_fut = tokio::time::timeout( Duration::from_secs(BDK_WALLET_SYNC_TIMEOUT_SECS), @@ -208,7 +209,80 @@ impl EsploraChainSource { ), ); get_and_apply_wallet_update!(wallet_sync_timeout_fut) + }; + + let (mut all_events, primary_error) = match primary_result { + Ok(events) => (events, None), + Err(e) => (Vec::new(), Some(e)), + }; + + let sync_requests = super::collect_additional_sync_requests( + &self.config, + &onchain_wallet, + &self.node_metrics, + &self.logger, + ); + + let mut join_set = tokio::task::JoinSet::new(); + for (address_type, full_scan_req, incremental_req, do_incremental) in sync_requests { + let client = self.esplora_client.clone(); + join_set.spawn(async move { + let result = if do_incremental { + tokio::time::timeout( + Duration::from_secs(BDK_WALLET_SYNC_TIMEOUT_SECS), + client.sync(incremental_req, BDK_CLIENT_CONCURRENCY), + ) + .await + .map(|r| r.map(|u| bdk_wallet::Update::from(u))) + } else { + tokio::time::timeout( + Duration::from_secs(BDK_WALLET_SYNC_TIMEOUT_SECS), + client.full_scan( + full_scan_req, + BDK_CLIENT_STOP_GAP, + BDK_CLIENT_CONCURRENCY, + ), + ) + .await + .map(|r| r.map(|u| bdk_wallet::Update::from(u))) + }; + (address_type, result) + }); } + + let mut sync_results = Vec::new(); + while let Some(join_result) = join_set.join_next().await { + match join_result { + Ok((address_type, Ok(Ok(update)))) => { + sync_results.push((address_type, Some(update))); + }, + Ok((address_type, Ok(Err(e)))) => { + log_warn!(self.logger, "Failed to sync wallet {:?}: {}", address_type, e); + sync_results.push((address_type, None)); + }, + Ok((address_type, Err(_))) => { + log_warn!(self.logger, "Sync timeout for wallet {:?}", address_type); + sync_results.push((address_type, None)); + }, + Err(e) => { + log_warn!(self.logger, "Wallet sync task panicked: {}", e); + }, + }; + } + + all_events.extend(super::apply_additional_sync_results( + sync_results, + &onchain_wallet, + &self.node_metrics, + &self.kv_store, + &self.logger, + )); + + if let Some(e) = primary_error { + return Err(e); + } + + Ok(all_events) } pub(super) async fn sync_lightning_wallet( diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 611017999..284ee925f 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -12,19 +12,23 @@ mod esplora; use std::collections::HashMap; use std::ops::Deref; use std::sync::{Arc, Mutex, RwLock}; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use bdk_chain::spk_client::{FullScanRequest, SyncRequest}; use bdk_wallet::event::WalletEvent as BdkWalletEvent; +use bdk_wallet::{KeychainKind, Update as BdkUpdate}; use bitcoin::{Script, Txid}; use lightning::chain::{BestBlock, Filter}; +use lightning::log_warn; use lightning_block_sync::gossip::UtxoSource; use crate::chain::bitcoind::BitcoindChainSource; use crate::chain::electrum::ElectrumChainSource; use crate::chain::esplora::EsploraChainSource; use crate::config::{ - BackgroundSyncConfig, BitcoindRestClientConfig, Config, ElectrumSyncConfig, EsploraSyncConfig, - RESOLVED_CHANNEL_MONITOR_ARCHIVAL_INTERVAL, WALLET_SYNC_INTERVAL_MINIMUM_SECS, + AddressType, BackgroundSyncConfig, BitcoindRestClientConfig, Config, ElectrumSyncConfig, + EsploraSyncConfig, RESOLVED_CHANNEL_MONITOR_ARCHIVAL_INTERVAL, + WALLET_SYNC_INTERVAL_MINIMUM_SECS, }; use crate::event::{Event, EventQueue, SyncType, TransactionDetails}; use crate::fee_estimator::OnchainFeeEstimator; @@ -162,6 +166,66 @@ fn get_transaction_details( Some(TransactionDetails { amount_sats, inputs, outputs }) } +pub(super) fn collect_additional_sync_requests( + config: &Config, onchain_wallet: &Wallet, node_metrics: &Arc>, + logger: &Arc, +) -> Vec<(AddressType, FullScanRequest, SyncRequest<(KeychainKind, u32)>, bool)> { + config + .additional_address_types() + .into_iter() + .filter_map(|address_type| { + let do_incremental = + node_metrics.read().unwrap().get_wallet_sync_timestamp(address_type).is_some(); + match onchain_wallet.get_wallet_sync_request(address_type) { + Ok((full_scan_req, incremental_req)) => { + Some((address_type, full_scan_req, incremental_req, do_incremental)) + }, + Err(e) => { + log_info!(logger, "Skipping sync for wallet {:?}: {}", address_type, e); + None + }, + } + }) + .collect() +} + +pub(super) fn apply_additional_sync_results( + results: Vec<(AddressType, Option)>, onchain_wallet: &Wallet, + node_metrics: &Arc>, kv_store: &Arc, logger: &Arc, +) -> Vec { + let mut events = Vec::new(); + for (address_type, update_opt) in results { + if let Some(update) = update_opt { + let wallet_events = onchain_wallet + .apply_update_for_address_type(address_type, update) + .unwrap_or_else(|e| { + log_warn!(logger, "Failed to apply update to wallet {:?}: {}", address_type, e); + Vec::new() + }); + if let Some(ts) = SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs()) + { + node_metrics.write().unwrap().set_wallet_sync_timestamp(address_type, ts); + } + events.extend(wallet_events); + } + } + + { + let locked_node_metrics = node_metrics.read().unwrap(); + if let Err(e) = + write_node_metrics(&locked_node_metrics, Arc::clone(kv_store), Arc::clone(logger)) + { + log_error!(logger, "Failed to persist node metrics: {}", e); + } + } + + if let Err(e) = onchain_wallet.update_payment_store_for_all_transactions() { + log_info!(logger, "Failed to update payment store after wallet syncs: {}", e); + } + + events +} + // Process BDK wallet events and emit corresponding ldk-node events via the event queue. async fn process_wallet_events( wallet_events: Vec, wallet: &crate::wallet::Wallet, diff --git a/src/config.rs b/src/config.rs index fd162dcb5..49020979f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -99,6 +99,83 @@ pub const WALLET_KEYS_SEED_LEN: usize = 64; // The timeout after which we abort a external scores sync operation. pub(crate) const EXTERNAL_PATHFINDING_SCORES_SYNC_TIMEOUT_SECS: u64 = 5; +/// Supported Bitcoin address types for the on-chain wallet. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AddressType { + /// Legacy addresses (P2PKH) - BIP 44. + Legacy, + /// Nested SegWit addresses (P2SH-wrapped P2WPKH) - BIP 49. + NestedSegwit, + /// Native SegWit addresses (P2WPKH) - BIP 84. + NativeSegwit, + /// Taproot addresses (P2TR) - BIP 86. + Taproot, +} + +impl AddressType { + /// Returns `true` for address types with a native witness `scriptPubKey` + /// (`NativeSegwit` and `Taproot`). Required by BOLT 2 for channel scripts. + pub fn is_native_witness(&self) -> bool { + matches!(self, AddressType::NativeSegwit | AddressType::Taproot) + } + + /// Returns a stable string suffix used for namespacing persisted wallet data. + pub(crate) fn storage_suffix(&self) -> &'static str { + match self { + AddressType::Legacy => "legacy", + AddressType::NestedSegwit => "nested_segwit", + AddressType::NativeSegwit => "native_segwit", + AddressType::Taproot => "taproot", + } + } +} + +impl Default for AddressType { + fn default() -> Self { + AddressType::NativeSegwit + } +} + +impl lightning::util::ser::Writeable for AddressType { + fn write( + &self, writer: &mut W, + ) -> Result<(), bitcoin::io::Error> { + match self { + AddressType::Legacy => 0u8.write(writer), + AddressType::NestedSegwit => 1u8.write(writer), + AddressType::NativeSegwit => 2u8.write(writer), + AddressType::Taproot => 3u8.write(writer), + } + } +} + +impl lightning::util::ser::Readable for AddressType { + fn read( + reader: &mut R, + ) -> Result { + let discriminant: u8 = lightning::util::ser::Readable::read(reader)?; + match discriminant { + 0 => Ok(AddressType::Legacy), + 1 => Ok(AddressType::NestedSegwit), + 2 => Ok(AddressType::NativeSegwit), + 3 => Ok(AddressType::Taproot), + _ => Err(lightning::ln::msgs::DecodeError::InvalidValue), + } + } +} + +impl Config { + /// Returns the additional address types to monitor, excluding the primary. + pub fn additional_address_types(&self) -> Vec { + let mut seen = std::collections::HashSet::new(); + self.address_types_to_monitor + .iter() + .copied() + .filter(|&at| at != self.address_type && seen.insert(at)) + .collect() + } +} + #[derive(Debug, Clone)] /// Represents the configuration of an [`Node`] instance. /// @@ -197,6 +274,10 @@ pub struct Config { /// /// [`BalanceDetails::spendable_onchain_balance_sats`]: crate::BalanceDetails::spendable_onchain_balance_sats pub include_untrusted_pending_in_spendable: bool, + /// The address type for the on-chain wallet. Default is `NativeSegwit`. + pub address_type: AddressType, + /// Additional address types to monitor for existing funds. + pub address_types_to_monitor: Vec, } impl Default for Config { @@ -212,6 +293,8 @@ impl Default for Config { route_parameters: None, node_alias: None, include_untrusted_pending_in_spendable: false, + address_type: AddressType::default(), + address_types_to_monitor: Vec::new(), } } } diff --git a/src/ffi/types.rs b/src/ffi/types.rs index 90b29d70b..020f5bd22 100644 --- a/src/ffi/types.rs +++ b/src/ffi/types.rs @@ -42,13 +42,16 @@ pub use lightning_types::payment::{PaymentHash, PaymentPreimage, PaymentSecret}; pub use lightning_types::string::UntrustedString; pub use vss_client::headers::{VssHeaderProvider, VssHeaderProviderError}; +pub use crate::balance::AddressTypeBalance; use crate::builder::sanitize_alias; pub use crate::config::{ - default_config, AnchorChannelsConfig, BackgroundSyncConfig, ElectrumSyncConfig, - EsploraSyncConfig, MaxDustHTLCExposure, + battery_saving_sync_intervals, default_config, AddressType, AnchorChannelsConfig, + BackgroundSyncConfig, ElectrumSyncConfig, EsploraSyncConfig, MaxDustHTLCExposure, + RuntimeSyncIntervals, }; use crate::error::Error; pub use crate::graph::{ChannelInfo, ChannelUpdateInfo, NodeAnnouncementInfo, NodeInfo}; +pub use crate::io::utils::derive_node_secret_from_mnemonic; pub use crate::liquidity::{LSPS1OrderStatus, LSPS2ServiceConfig}; pub use crate::logger::{LogLevel, LogRecord, LogWriter}; pub use crate::payment::store::{ diff --git a/src/io/utils.rs b/src/io/utils.rs index b616b79cf..ba8a6e33c 100644 --- a/src/io/utils.rs +++ b/src/io/utils.rs @@ -421,24 +421,57 @@ macro_rules! impl_read_write_change_set_type { $key:expr ) => { pub(crate) fn $read_name( - kv_store: Arc, logger: L, + kv_store: Arc, logger: L, address_type: crate::config::AddressType, ) -> Result, std::io::Error> where L::Target: LdkLogger, { + let suffix = address_type.storage_suffix(); + let secondary_namespace = if $secondary_namespace.is_empty() { + suffix.to_string() + } else { + format!("{}/{}", $secondary_namespace, suffix) + }; let bytes = - match KVStoreSync::read(&*kv_store, $primary_namespace, $secondary_namespace, $key) + match KVStoreSync::read(&*kv_store, $primary_namespace, &secondary_namespace, $key) { Ok(bytes) => bytes, Err(e) => { if e.kind() == lightning::io::ErrorKind::NotFound { - return Ok(None); + // Fallback: try the un-namespaced key for pre-multi-wallet data. + if address_type == crate::config::AddressType::NativeSegwit { + match KVStoreSync::read( + &*kv_store, + $primary_namespace, + $secondary_namespace, + $key, + ) { + Ok(bytes) => bytes, + Err(e2) => { + if e2.kind() == lightning::io::ErrorKind::NotFound { + return Ok(None); + } else { + log_error!( + logger, + "Reading legacy data from key {}/{}/{} failed due to: {}", + $primary_namespace, + $secondary_namespace, + $key, + e2 + ); + return Err(e2.into()); + } + }, + } + } else { + return Ok(None); + } } else { log_error!( logger, "Reading data from key {}/{}/{} failed due to: {}", $primary_namespace, - $secondary_namespace, + secondary_namespace, $key, e ); @@ -464,18 +497,25 @@ macro_rules! impl_read_write_change_set_type { pub(crate) fn $write_name( value: &$change_set_type, kv_store: Arc, logger: L, + address_type: crate::config::AddressType, ) -> Result<(), std::io::Error> where L::Target: LdkLogger, { + let suffix = address_type.storage_suffix(); + let secondary_namespace = if $secondary_namespace.is_empty() { + suffix.to_string() + } else { + format!("{}/{}", $secondary_namespace, suffix) + }; let data = ChangeSetSerWrapper(value).encode(); - KVStoreSync::write(&*kv_store, $primary_namespace, $secondary_namespace, $key, data) + KVStoreSync::write(&*kv_store, $primary_namespace, &secondary_namespace, $key, data) .map_err(|e| { log_error!( logger, "Writing data to key {}/{}/{} failed due to: {}", $primary_namespace, - $secondary_namespace, + secondary_namespace, $key, e ); @@ -541,13 +581,13 @@ impl_read_write_change_set_type!( // Reads the full BdkWalletChangeSet or returns default fields pub(crate) fn read_bdk_wallet_change_set( - kv_store: Arc, logger: Arc, + kv_store: Arc, logger: Arc, address_type: crate::config::AddressType, ) -> Result, std::io::Error> { let mut change_set = BdkWalletChangeSet::default(); // We require a descriptor and return `None` to signal creation of a new wallet otherwise. if let Some(descriptor) = - read_bdk_wallet_descriptor(Arc::clone(&kv_store), Arc::clone(&logger))? + read_bdk_wallet_descriptor(Arc::clone(&kv_store), Arc::clone(&logger), address_type)? { change_set.descriptor = Some(descriptor); } else { @@ -556,7 +596,7 @@ pub(crate) fn read_bdk_wallet_change_set( // We require a change_descriptor and return `None` to signal creation of a new wallet otherwise. if let Some(change_descriptor) = - read_bdk_wallet_change_descriptor(Arc::clone(&kv_store), Arc::clone(&logger))? + read_bdk_wallet_change_descriptor(Arc::clone(&kv_store), Arc::clone(&logger), address_type)? { change_set.change_descriptor = Some(change_descriptor); } else { @@ -564,17 +604,19 @@ pub(crate) fn read_bdk_wallet_change_set( } // We require a network and return `None` to signal creation of a new wallet otherwise. - if let Some(network) = read_bdk_wallet_network(Arc::clone(&kv_store), Arc::clone(&logger))? { + if let Some(network) = + read_bdk_wallet_network(Arc::clone(&kv_store), Arc::clone(&logger), address_type)? + { change_set.network = Some(network); } else { return Ok(None); } - read_bdk_wallet_local_chain(Arc::clone(&kv_store), Arc::clone(&logger))? + read_bdk_wallet_local_chain(Arc::clone(&kv_store), Arc::clone(&logger), address_type)? .map(|local_chain| change_set.local_chain = local_chain); - read_bdk_wallet_tx_graph(Arc::clone(&kv_store), Arc::clone(&logger))? + read_bdk_wallet_tx_graph(Arc::clone(&kv_store), Arc::clone(&logger), address_type)? .map(|tx_graph| change_set.tx_graph = tx_graph); - read_bdk_wallet_indexer(Arc::clone(&kv_store), Arc::clone(&logger))? + read_bdk_wallet_indexer(Arc::clone(&kv_store), Arc::clone(&logger), address_type)? .map(|indexer| change_set.indexer = indexer); Ok(Some(change_set)) } diff --git a/src/lib.rs b/src/lib.rs index 4b94448fa..cd1aed572 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -110,7 +110,7 @@ use std::sync::atomic::AtomicU32; use std::sync::{Arc, Mutex, RwLock}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -pub use balance::{BalanceDetails, LightningBalance, PendingSweepBalance}; +pub use balance::{AddressTypeBalance, BalanceDetails, LightningBalance, PendingSweepBalance}; use bitcoin::secp256k1::PublicKey; use bitcoin::{Address, Amount}; #[cfg(feature = "uniffi")] @@ -121,8 +121,9 @@ pub use builder::{BuildError, ChannelDataMigration}; use chain::ChainSource; pub use config::{battery_saving_sync_intervals, RuntimeSyncIntervals}; use config::{ - default_user_config, may_announce_channel, AsyncPaymentsRole, BackgroundSyncConfig, - ChannelConfig, Config, NODE_ANN_BCAST_INTERVAL, PEER_RECONNECTION_INTERVAL, RGS_SYNC_INTERVAL, + default_user_config, may_announce_channel, AddressType, AsyncPaymentsRole, + BackgroundSyncConfig, ChannelConfig, Config, NODE_ANN_BCAST_INTERVAL, + PEER_RECONNECTION_INTERVAL, RGS_SYNC_INTERVAL, }; use connection::ConnectionManager; pub use error::Error as NodeError; @@ -1215,8 +1216,9 @@ impl Node { ) -> Result<(), Error> { let cur_anchor_reserve_sats = total_anchor_channels_reserve_sats(&self.channel_manager, &self.config); + let spendable_amount_sats = - self.wallet.get_spendable_amount_sats(cur_anchor_reserve_sats).unwrap_or(0); + self.wallet.get_witness_spendable_amount_sats(cur_anchor_reserve_sats).unwrap_or(0); // Fail early if we have less than the channel value available. if spendable_amount_sats < amount_sats { @@ -1749,6 +1751,20 @@ impl Node { } } + /// Retrieves the on-chain balance for a specific address type. + pub fn get_balance_for_address_type( + &self, address_type: AddressType, + ) -> Result { + let (total_sats, spendable_sats) = + self.wallet.get_balance_for_address_type(address_type)?; + Ok(AddressTypeBalance { total_sats, spendable_sats }) + } + + /// Returns all address types currently loaded and monitored by the wallet. + pub fn list_monitored_address_types(&self) -> Vec { + self.wallet.get_loaded_address_types() + } + /// Retrieves all payments that match the given predicate. /// /// For example, you could retrieve all stored outbound payments as follows: @@ -1924,6 +1940,7 @@ pub(crate) struct NodeMetrics { last_known_spendable_onchain_balance_sats: Option, last_known_total_onchain_balance_sats: Option, last_known_total_lightning_balance_sats: Option, + monitored_wallet_sync_timestamps: Vec<(AddressType, u64)>, } impl Default for NodeMetrics { @@ -1939,6 +1956,26 @@ impl Default for NodeMetrics { last_known_spendable_onchain_balance_sats: None, last_known_total_onchain_balance_sats: None, last_known_total_lightning_balance_sats: None, + monitored_wallet_sync_timestamps: Vec::new(), + } + } +} + +impl NodeMetrics { + pub(crate) fn get_wallet_sync_timestamp(&self, address_type: AddressType) -> Option { + self.monitored_wallet_sync_timestamps + .iter() + .find(|(at, _)| *at == address_type) + .map(|(_, ts)| *ts) + } + + pub(crate) fn set_wallet_sync_timestamp(&mut self, address_type: AddressType, timestamp: u64) { + if let Some(entry) = + self.monitored_wallet_sync_timestamps.iter_mut().find(|(at, _)| *at == address_type) + { + entry.1 = timestamp; + } else { + self.monitored_wallet_sync_timestamps.push((address_type, timestamp)); } } } @@ -1954,6 +1991,7 @@ impl_writeable_tlv_based!(NodeMetrics, { (12, last_known_spendable_onchain_balance_sats, option), (14, last_known_total_onchain_balance_sats, option), (16, last_known_total_lightning_balance_sats, option), + (18, monitored_wallet_sync_timestamps, optional_vec), }); // Check if balances have changed and emit BalanceChanged event if so. diff --git a/src/liquidity.rs b/src/liquidity.rs index 74e6098dd..24d3f3d21 100644 --- a/src/liquidity.rs +++ b/src/liquidity.rs @@ -745,8 +745,10 @@ where let channel_amount_sats = (amt_to_forward_msat + over_provisioning_msat) / 1000; let cur_anchor_reserve_sats = total_anchor_channels_reserve_sats(&self.channel_manager, &self.config); - let spendable_amount_sats = - self.wallet.get_spendable_amount_sats(cur_anchor_reserve_sats).unwrap_or(0); + let spendable_amount_sats = self + .wallet + .get_witness_spendable_amount_sats(cur_anchor_reserve_sats) + .unwrap_or(0); let required_funds_sats = channel_amount_sats + self.config.anchor_channels_config.as_ref().map_or(0, |c| { if init_features.requires_anchors_zero_fee_htlc_tx() diff --git a/src/payment/onchain.rs b/src/payment/onchain.rs index 1b4bd18dc..c302705f7 100644 --- a/src/payment/onchain.rs +++ b/src/payment/onchain.rs @@ -11,7 +11,7 @@ use std::sync::{Arc, RwLock}; use bitcoin::{Address, Txid}; -use crate::config::Config; +use crate::config::{AddressType, Config}; use crate::error::Error; use crate::fee_estimator::ConfirmationTarget; use crate::logger::{log_info, LdkLogger, Logger}; @@ -64,6 +64,22 @@ impl OnchainPayment { Ok(funding_address) } + /// Retrieve a new on-chain address for a specific address type. + pub fn new_address_for_type(&self, address_type: AddressType) -> Result { + if !*self.is_running.read().unwrap() { + return Err(Error::NotRunning); + } + + let funding_address = self.wallet.get_new_address_for_type(address_type)?; + log_info!( + self.logger, + "Generated new funding address for {:?}: {}", + address_type, + funding_address + ); + Ok(funding_address) + } + /// Returns a list of all UTXOs that are safe to spend. /// /// This excludes any outputs that are currently being used to fund Lightning channels. diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index de8701b72..949421e5c 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -13,16 +13,9 @@ use std::sync::{Arc, Mutex, MutexGuard}; use bdk_chain::spk_client::{FullScanRequest, SyncRequest}; pub use bdk_wallet::coin_selection::CoinSelectionAlgorithm as BdkCoinSelectionAlgorithm; -use bdk_wallet::coin_selection::{ - BranchAndBoundCoinSelection, Excess, LargestFirstCoinSelection, OldestFirstCoinSelection, - SingleRandomDraw, -}; -use bdk_wallet::descriptor::ExtendedDescriptor; use bdk_wallet::event::WalletEvent; -#[allow(deprecated)] -use bdk_wallet::SignOptions; -use bdk_wallet::{Balance, KeychainKind, LocalOutput, PersistedWallet, Update, WeightedUtxo}; -use bip39::rand::rngs::OsRng; +use bdk_wallet::{Balance, KeychainKind, LocalOutput, PersistedWallet, Update}; +use bdk_wallet_aggregate::AggregateWallet; use bitcoin::address::NetworkUnchecked; use bitcoin::blockdata::constants::WITNESS_SCALE_FACTOR; use bitcoin::blockdata::locktime::absolute::LockTime; @@ -33,8 +26,8 @@ use bitcoin::secp256k1::ecdh::SharedSecret; use bitcoin::secp256k1::ecdsa::{RecoverableSignature, Signature}; use bitcoin::secp256k1::{All, PublicKey, Scalar, Secp256k1, SecretKey}; use bitcoin::{ - Address, Amount, FeeRate, OutPoint, Script, ScriptBuf, Transaction, TxOut, Txid, WPubkeyHash, - Weight, WitnessProgram, WitnessVersion, + Address, Amount, FeeRate, OutPoint, PubkeyHash, Script, ScriptBuf, Transaction, TxOut, Txid, + WPubkeyHash, Weight, WitnessProgram, WitnessVersion, }; use lightning::chain::chaininterface::BroadcasterInterface; use lightning::chain::channelmonitor::ANTI_REORG_DELAY; @@ -45,7 +38,6 @@ use lightning::ln::funding::FundingTxInput; use lightning::ln::inbound_payment::ExpandedKey; use lightning::ln::msgs::UnsignedGossipMessage; use lightning::ln::script::ShutdownScript; -use lightning::log_warn; use lightning::sign::{ ChangeDestinationSource, EntropySource, InMemorySigner, KeysManager, NodeSigner, OutputSpender, PeerStorageKey, Recipient, SignerProvider, SpendableOutputDescriptor, @@ -54,7 +46,7 @@ use lightning::util::message_signing; use lightning_invoice::RawBolt11Invoice; use persist::KVStoreWalletPersister; -use crate::config::Config; +use crate::config::{AddressType, Config}; use crate::event::{TxInput, TxOutput}; use crate::fee_estimator::{ConfirmationTarget, FeeEstimator, OnchainFeeEstimator}; use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; @@ -90,9 +82,7 @@ pub(crate) mod persist; pub(crate) mod ser; pub(crate) struct Wallet { - // A BDK on-chain wallet. - inner: Mutex>, - persister: Mutex, + inner: Mutex>, broadcaster: Arc, fee_estimator: Arc, payment_store: Arc, @@ -103,13 +93,19 @@ pub(crate) struct Wallet { impl Wallet { pub(crate) fn new( wallet: bdk_wallet::PersistedWallet, - wallet_persister: KVStoreWalletPersister, broadcaster: Arc, - fee_estimator: Arc, payment_store: Arc, - config: Arc, logger: Arc, + wallet_persister: KVStoreWalletPersister, + additional_wallets: Vec<( + AddressType, + PersistedWallet, + KVStoreWalletPersister, + )>, + broadcaster: Arc, fee_estimator: Arc, + payment_store: Arc, config: Arc, logger: Arc, ) -> Self { - let inner = Mutex::new(wallet); - let persister = Mutex::new(wallet_persister); - Self { inner, persister, broadcaster, fee_estimator, payment_store, config, logger } + let aggregate = + AggregateWallet::new(wallet, wallet_persister, config.address_type, additional_wallets); + let inner = Mutex::new(aggregate); + Self { inner, broadcaster, fee_estimator, payment_store, config, logger } } pub(crate) fn is_funding_transaction( @@ -144,27 +140,25 @@ impl Wallet { self.inner.lock().unwrap().start_sync_with_revealed_spks().build() } + pub(crate) fn get_wallet_sync_request( + &self, address_type: AddressType, + ) -> Result< + (FullScanRequest, SyncRequest<(KeychainKind, u32)>), + bdk_wallet_aggregate::Error, + > { + self.inner.lock().unwrap().wallet_sync_request(&address_type) + } + pub(crate) fn get_cached_txs(&self) -> Vec> { - self.inner.lock().unwrap().tx_graph().full_txs().map(|tx_node| tx_node.tx).collect() + self.inner.lock().unwrap().cached_txs() } pub(crate) fn get_unconfirmed_txids(&self) -> Vec { - self.inner - .lock() - .unwrap() - .transactions() - .filter(|t| t.chain_position.is_unconfirmed()) - .map(|t| t.tx_node.txid) - .collect() + self.inner.lock().unwrap().unconfirmed_txids() } pub(crate) fn is_tx_confirmed(&self, txid: &Txid) -> bool { - self.inner - .lock() - .unwrap() - .get_tx(*txid) - .map(|tx_node| tx_node.chain_position.is_confirmed()) - .unwrap_or(false) + self.inner.lock().unwrap().is_tx_confirmed(txid) } pub(crate) fn current_best_block(&self) -> BestBlock { @@ -179,27 +173,50 @@ impl Wallet { Ok(change_address.address.script_pubkey()) } + /// Returns the list of all loaded address types (primary + monitored). + pub(crate) fn get_loaded_address_types(&self) -> Vec { + self.inner.lock().unwrap().loaded_keys() + } + pub(crate) fn apply_update( &self, update: impl Into, ) -> Result, Error> { let mut locked_wallet = self.inner.lock().unwrap(); - match locked_wallet.apply_update_events(update) { - Ok(events) => { - let mut locked_persister = self.persister.lock().unwrap(); - locked_wallet.persist(&mut locked_persister).map_err(|e| { - log_error!(self.logger, "Failed to persist wallet: {}", e); + match locked_wallet.apply_update(update) { + Ok((events, _txids)) => { + self.update_payment_store(&locked_wallet).map_err(|e| { + log_error!(self.logger, "Failed to update payment store: {}", e); Error::PersistenceFailed })?; - self.update_payment_store(&mut *locked_wallet).map_err(|e| { + Ok(events) + }, + Err(e) => { + log_error!(self.logger, "Sync failed due to chain connection error: {}", e); + Err(Error::WalletOperationFailed) + }, + } + } + + pub(crate) fn apply_update_for_address_type( + &self, address_type: AddressType, update: impl Into, + ) -> Result, Error> { + let mut locked_wallet = self.inner.lock().unwrap(); + match locked_wallet.apply_update_to_wallet(address_type, update) { + Ok((events, _txids)) => { + self.update_payment_store(&locked_wallet).map_err(|e| { log_error!(self.logger, "Failed to update payment store: {}", e); Error::PersistenceFailed })?; - Ok(events) }, Err(e) => { - log_error!(self.logger, "Sync failed due to chain connection error: {}", e); + log_error!( + self.logger, + "Failed to apply update for address type {:?}: {}", + address_type, + e + ); Err(Error::WalletOperationFailed) }, } @@ -209,16 +226,10 @@ impl Wallet { &self, unconfirmed_txs: Vec<(Transaction, u64)>, evicted_txids: Vec<(Txid, u64)>, ) -> Result<(), Error> { let mut locked_wallet = self.inner.lock().unwrap(); - locked_wallet.apply_unconfirmed_txs(unconfirmed_txs); - locked_wallet.apply_evicted_txs(evicted_txids); - - let mut locked_persister = self.persister.lock().unwrap(); - locked_wallet.persist(&mut locked_persister).map_err(|e| { - log_error!(self.logger, "Failed to persist wallet: {}", e); + locked_wallet.apply_mempool_txs(unconfirmed_txs, evicted_txids).map_err(|e| { + log_error!(self.logger, "Failed to apply mempool txs: {}", e); Error::PersistenceFailed - })?; - - Ok(()) + }) } // Bumps the fee of an existing transaction using Replace-By-Fee (RBF). @@ -235,135 +246,59 @@ impl Wallet { ); return Err(Error::CannotRbfFundingTransaction); } - let mut locked_wallet = self.inner.lock().unwrap(); - - // Find the transaction in the wallet - let tx_node = locked_wallet.get_tx(*txid).ok_or_else(|| { - log_error!(self.logger, "Transaction not found in wallet: {}", txid); - Error::TransactionNotFound - })?; - - // Check if transaction is confirmed - can't replace confirmed transactions - if tx_node.chain_position.is_confirmed() { - log_error!(self.logger, "Cannot replace confirmed transaction: {}", txid); - return Err(Error::TransactionAlreadyConfirmed); - } - - // Calculate original transaction fee and fee rate - let original_tx = &tx_node.tx_node.tx; - let original_fee = locked_wallet.calculate_fee(original_tx).map_err(|e| { - log_error!(self.logger, "Failed to calculate original fee: {}", e); - Error::WalletOperationFailed - })?; - - // Use Bitcoin crate's built-in fee rate calculation for accuracy - let original_fee_rate = original_fee / original_tx.weight(); - - // Log detailed information for debugging - log_info!(self.logger, "RBF Analysis for transaction {}", txid); - log_info!(self.logger, " Original fee: {} sats", original_fee.to_sat()); - log_info!( - self.logger, - " Original weight: {} WU ({} vB)", - original_tx.weight().to_wu(), - original_tx.weight().to_vbytes_ceil() - ); - log_info!( - self.logger, - " Original fee rate: {} sat/kwu ({} sat/vB)", - original_fee_rate.to_sat_per_kwu(), - original_fee_rate.to_sat_per_vb_ceil() - ); - log_info!( - self.logger, - " Requested fee rate: {} sat/kwu ({} sat/vB)", - fee_rate.to_sat_per_kwu(), - fee_rate.to_sat_per_vb_ceil() - ); - - // Essential validation: new fee rate must be higher than original - // This prevents definite rejections by the Bitcoin network - if fee_rate <= original_fee_rate { - log_error!( - self.logger, - "RBF rejected: New fee rate ({} sat/vB) must be higher than original fee rate ({} sat/vB)", - fee_rate.to_sat_per_vb_ceil(), - original_fee_rate.to_sat_per_vb_ceil() - ); - return Err(Error::InvalidFeeRate); - } - - log_info!( - self.logger, - "RBF approved: Fee rate increase from {} to {} sat/vB", - original_fee_rate.to_sat_per_vb_ceil(), - fee_rate.to_sat_per_vb_ceil() - ); - - // Build a new transaction with higher fee using BDK's fee bump functionality - let mut tx_builder = locked_wallet.build_fee_bump(*txid).map_err(|e| { - log_error!(self.logger, "Failed to create fee bump builder: {}", e); - Error::OnchainTxCreationFailed - })?; - // Set the new fee rate - tx_builder.fee_rate(fee_rate); + let mut locked_wallet = self.inner.lock().unwrap(); - // Finalize the transaction - let mut psbt = match tx_builder.finish() { - Ok(psbt) => { - log_trace!(self.logger, "Created RBF PSBT: {:?}", psbt); - psbt + let (tx, original_fee) = locked_wallet.build_rbf(*txid, fee_rate).map_err(|e| match e { + bdk_wallet_aggregate::Error::TransactionNotFound => { + log_error!(self.logger, "Transaction not found in any wallet: {}", txid); + Error::TransactionNotFound }, - Err(err) => { - log_error!(self.logger, "Failed to create RBF transaction: {}", err); - return Err(Error::OnchainTxCreationFailed); + bdk_wallet_aggregate::Error::TransactionAlreadyConfirmed => { + log_error!(self.logger, "Cannot replace confirmed transaction: {}", txid); + Error::TransactionAlreadyConfirmed }, - }; - - // Sign the transaction - match locked_wallet.sign(&mut psbt, SignOptions::default()) { - Ok(finalized) => { - if !finalized { - log_error!(self.logger, "Failed to finalize RBF transaction"); - return Err(Error::OnchainTxSigningFailed); - } + bdk_wallet_aggregate::Error::InvalidFeeRate => { + log_error!(self.logger, "RBF rejected: new fee rate is not higher"); + Error::InvalidFeeRate }, - Err(err) => { - log_error!(self.logger, "Failed to sign RBF transaction: {}", err); - return Err(Error::OnchainTxSigningFailed); + bdk_wallet_aggregate::Error::InsufficientFunds => { + log_error!(self.logger, "Insufficient funds for RBF fee bump of {}", txid); + Error::InsufficientFunds }, - } + bdk_wallet_aggregate::Error::UtxoNotFoundLocally(outpoint) => { + log_error!( + self.logger, + "Cannot calculate fee for RBF of {}: input UTXO {} not found locally. \ + Try syncing the wallet first.", + txid, + outpoint, + ); + Error::OnchainTxCreationFailed + }, + other => { + log_error!(self.logger, "Failed to build RBF for {}: {}", txid, other); + Error::OnchainTxCreationFailed + }, + })?; // Persist wallet changes - let mut locked_persister = self.persister.lock().unwrap(); - locked_wallet.persist(&mut locked_persister).map_err(|e| { + locked_wallet.persist_all().map_err(|e| { log_error!(self.logger, "Failed to persist wallet: {}", e); Error::PersistenceFailed })?; // Extract and broadcast the transaction - let tx = psbt.extract_tx().map_err(|e| { - log_error!(self.logger, "Failed to extract transaction: {}", e); - Error::OnchainTxCreationFailed - })?; - self.broadcaster.broadcast_transactions(&[&tx]); let new_txid = tx.compute_txid(); // Calculate and log the actual fee increase achieved - let new_fee = locked_wallet.calculate_fee(&tx).unwrap_or(Amount::ZERO); + let new_fee = locked_wallet.calculate_tx_fee(&tx).unwrap_or(Amount::ZERO); let actual_fee_rate = new_fee / tx.weight(); log_info!(self.logger, "RBF transaction created successfully!"); - log_info!( - self.logger, - " Original: {} ({} sat/vB, {} sats fee)", - txid, - original_fee_rate.to_sat_per_vb_ceil(), - original_fee.to_sat() - ); + log_info!(self.logger, " Original: {} ({} sats fee)", txid, original_fee.to_sat()); log_info!( self.logger, " Replacement: {} ({} sat/vB, {} sats fee)", @@ -385,173 +320,42 @@ impl Wallet { pub(crate) fn accelerate_by_cpfp( &self, txid: &Txid, fee_rate: FeeRate, destination_address: Option

, ) -> Result { - let mut locked_wallet = self.inner.lock().unwrap(); - - // Find the transaction in the wallet - let parent_tx_node = locked_wallet.get_tx(*txid).ok_or_else(|| { - log_error!(self.logger, "Transaction not found in wallet: {}", txid); - Error::TransactionNotFound - })?; - - // Check if transaction is confirmed - can't accelerate confirmed transactions - if parent_tx_node.chain_position.is_confirmed() { - log_error!(self.logger, "Cannot accelerate confirmed transaction: {}", txid); - return Err(Error::TransactionAlreadyConfirmed); - } - - // Calculate parent transaction fee and fee rate for validation - let parent_tx = &parent_tx_node.tx_node.tx; - let parent_fee = locked_wallet.calculate_fee(parent_tx).map_err(|e| { - log_error!(self.logger, "Failed to calculate parent fee: {}", e); - Error::WalletOperationFailed - })?; - - // Use Bitcoin crate's built-in fee rate calculation for accuracy - let parent_fee_rate = parent_fee / parent_tx.weight(); - - // Log detailed information for debugging - log_info!(self.logger, "CPFP Analysis for transaction {}", txid); - log_info!(self.logger, " Parent fee: {} sats", parent_fee.to_sat()); - log_info!( - self.logger, - " Parent weight: {} WU ({} vB)", - parent_tx.weight().to_wu(), - parent_tx.weight().to_vbytes_ceil() - ); - log_info!( - self.logger, - " Parent fee rate: {} sat/kwu ({} sat/vB)", - parent_fee_rate.to_sat_per_kwu(), - parent_fee_rate.to_sat_per_vb_ceil() - ); - log_info!( - self.logger, - " Child fee rate: {} sat/kwu ({} sat/vB)", - fee_rate.to_sat_per_kwu(), - fee_rate.to_sat_per_vb_ceil() - ); - - // Validate that child fee rate is higher than parent (for effective acceleration) - if fee_rate <= parent_fee_rate { - log_info!( - self.logger, - "CPFP warning: Child fee rate ({} sat/vB) is not higher than parent fee rate ({} sat/vB). This may not effectively accelerate confirmation.", - fee_rate.to_sat_per_vb_ceil(), - parent_fee_rate.to_sat_per_vb_ceil() - ); - // Note: We warn but don't reject - CPFP can still work in some cases - } else { - let acceleration_ratio = - fee_rate.to_sat_per_kwu() as f64 / parent_fee_rate.to_sat_per_kwu() as f64; - log_info!( - self.logger, - "CPFP acceleration: Child fee rate is {:.1}x higher than parent ({} vs {} sat/vB)", - acceleration_ratio, - fee_rate.to_sat_per_vb_ceil(), - parent_fee_rate.to_sat_per_vb_ceil() - ); - } - - // Find spendable outputs from this transaction - let utxos = locked_wallet - .list_unspent() - .filter(|utxo| utxo.outpoint.txid == *txid) - .collect::>(); - - if utxos.is_empty() { - log_error!(self.logger, "No spendable outputs found for transaction: {}", txid); - return Err(Error::NoSpendableOutputs); - } - - log_info!(self.logger, "Found {} spendable output(s) from parent transaction", utxos.len()); - let total_input_value: u64 = utxos.iter().map(|utxo| utxo.txout.value.to_sat()).sum(); - log_info!(self.logger, " Total input value: {} sats", total_input_value); - - // Determine where to send the funds - let script_pubkey = match destination_address { - Some(addr) => { - log_info!(self.logger, " Destination: {} (user-specified)", addr); - // Validate the address - self.parse_and_validate_address(&addr)?; - addr.script_pubkey() - }, - None => { - // Create a new address to send the funds to - let address_info = locked_wallet.next_unused_address(KeychainKind::Internal); - log_info!( - self.logger, - " Destination: {} (wallet internal address)", - address_info.address - ); - address_info.address.script_pubkey() + let destination_script = match destination_address { + Some(ref addr) => { + self.parse_and_validate_address(addr)?; + Some(addr.script_pubkey()) }, + None => None, }; - // Build a transaction that spends these UTXOs - let mut tx_builder = locked_wallet.build_tx(); - - // Add the UTXOs explicitly - for utxo in &utxos { - match tx_builder.add_utxo(utxo.outpoint) { - Ok(_) => {}, - Err(e) => { - log_error!(self.logger, "Failed to add UTXO: {:?} - {}", utxo.outpoint, e); - return Err(Error::OnchainTxCreationFailed); - }, - } - } - - // Set the fee rate for the child transaction - tx_builder.fee_rate(fee_rate); - - // Drain all inputs to the destination - tx_builder.drain_to(script_pubkey); - - // Finalize the transaction - let mut psbt = match tx_builder.finish() { - Ok(psbt) => { - log_trace!(self.logger, "Created CPFP PSBT: {:?}", psbt); - psbt - }, - Err(err) => { - log_error!(self.logger, "Failed to create CPFP transaction: {}", err); - return Err(Error::OnchainTxCreationFailed); - }, - }; + let mut locked_wallet = self.inner.lock().unwrap(); - // Sign the transaction - match locked_wallet.sign(&mut psbt, SignOptions::default()) { - Ok(finalized) => { - if !finalized { - log_error!(self.logger, "Failed to finalize CPFP transaction"); - return Err(Error::OnchainTxSigningFailed); + let (tx, parent_fee, parent_fee_rate) = + locked_wallet.build_cpfp(*txid, fee_rate, destination_script).map_err(|e| { + log_error!(self.logger, "Failed to build CPFP for {}: {}", txid, e); + match e { + bdk_wallet_aggregate::Error::TransactionNotFound => Error::TransactionNotFound, + bdk_wallet_aggregate::Error::TransactionAlreadyConfirmed => { + Error::TransactionAlreadyConfirmed + }, + bdk_wallet_aggregate::Error::NoSpendableOutputs => Error::NoSpendableOutputs, + _ => Error::OnchainTxCreationFailed, } - }, - Err(err) => { - log_error!(self.logger, "Failed to sign CPFP transaction: {}", err); - return Err(Error::OnchainTxSigningFailed); - }, - } + })?; // Persist wallet changes - let mut locked_persister = self.persister.lock().unwrap(); - locked_wallet.persist(&mut locked_persister).map_err(|e| { + locked_wallet.persist_all().map_err(|e| { log_error!(self.logger, "Failed to persist wallet: {}", e); Error::PersistenceFailed })?; // Extract and broadcast the transaction - let tx = psbt.extract_tx().map_err(|e| { - log_error!(self.logger, "Failed to extract transaction: {}", e); - Error::OnchainTxCreationFailed - })?; - self.broadcaster.broadcast_transactions(&[&tx]); let child_txid = tx.compute_txid(); // Calculate and log the actual results - let child_fee = locked_wallet.calculate_fee(&tx).unwrap_or(Amount::ZERO); + let child_fee = locked_wallet.calculate_tx_fee(&tx).unwrap_or(Amount::ZERO); let actual_child_fee_rate = child_fee / tx.weight(); log_info!(self.logger, "CPFP transaction created successfully!"); @@ -569,12 +373,6 @@ impl Wallet { actual_child_fee_rate.to_sat_per_vb_ceil(), child_fee.to_sat() ); - log_info!( - self.logger, - " Combined package fee rate: approximately {:.1} sat/vB", - ((parent_fee.to_sat() + child_fee.to_sat()) as f64) - / ((parent_tx.weight().to_vbytes_ceil() + tx.weight().to_vbytes_ceil()) as f64) - ); Ok(child_txid) } @@ -583,32 +381,6 @@ impl Wallet { pub(crate) fn calculate_cpfp_fee_rate( &self, parent_txid: &Txid, urgent: bool, ) -> Result { - let locked_wallet = self.inner.lock().unwrap(); - - // Get the parent transaction - let parent_tx_node = locked_wallet.get_tx(*parent_txid).ok_or_else(|| { - log_error!(self.logger, "Transaction not found in wallet: {}", parent_txid); - Error::TransactionNotFound - })?; - - // Make sure it's not confirmed - if parent_tx_node.chain_position.is_confirmed() { - log_error!(self.logger, "Transaction is already confirmed: {}", parent_txid); - return Err(Error::TransactionAlreadyConfirmed); - } - - let parent_tx = &parent_tx_node.tx_node.tx; - - // Calculate parent fee and fee rate using accurate method - let parent_fee = locked_wallet.calculate_fee(parent_tx).map_err(|e| { - log_error!(self.logger, "Failed to calculate parent fee: {}", e); - Error::WalletOperationFailed - })?; - - // Use Bitcoin crate's built-in fee rate calculation for accuracy - let parent_fee_rate = parent_fee / parent_tx.weight(); - - // Get current mempool fee rates from fee estimator based on urgency let target = if urgent { ConfirmationTarget::Lightning( lightning::chain::chaininterface::ConfirmationTarget::MaximumFeeEstimate, @@ -616,151 +388,104 @@ impl Wallet { } else { ConfirmationTarget::OnchainPayment }; - let target_fee_rate = self.fee_estimator.estimate_fee_rate(target); - log_info!(self.logger, "CPFP Fee Rate Calculation for transaction {}", parent_txid); - log_info!(self.logger, " Parent fee: {} sats", parent_fee.to_sat()); - log_info!( - self.logger, - " Parent weight: {} WU ({} vB)", - parent_tx.weight().to_wu(), - parent_tx.weight().to_vbytes_ceil() - ); - log_info!( - self.logger, - " Parent fee rate: {} sat/kwu ({} sat/vB)", - parent_fee_rate.to_sat_per_kwu(), - parent_fee_rate.to_sat_per_vb_ceil() - ); - log_info!( - self.logger, - " Target fee rate: {} sat/kwu ({} sat/vB)", - target_fee_rate.to_sat_per_kwu(), - target_fee_rate.to_sat_per_vb_ceil() - ); - log_info!(self.logger, " Urgency level: {}", if urgent { "HIGH" } else { "NORMAL" }); - - // If parent fee rate is already sufficient, return a slightly higher one - if parent_fee_rate >= target_fee_rate { - let recommended_rate = - FeeRate::from_sat_per_kwu(parent_fee_rate.to_sat_per_kwu() + 250); // +1 sat/vB - log_info!( - self.logger, - "Parent fee rate is already sufficient. Recommending slightly higher rate: {} sat/vB", - recommended_rate.to_sat_per_vb_ceil() - ); - return Ok(recommended_rate); - } - - // Estimate child transaction size (weight units) - // Conservative estimate for a typical 1-input, 1-output transaction - let estimated_child_weight_units = 480; // ~120 vbytes * 4 = 480 wu - let estimated_child_vbytes = estimated_child_weight_units / 4; - - // Calculate the fee deficit for the parent (in sats) - // let parent_weight_units = parent_tx.weight().to_wu(); - let parent_vbytes = parent_tx.weight().to_vbytes_ceil(); - let parent_fee_deficit = (target_fee_rate.to_sat_per_vb_ceil() - - parent_fee_rate.to_sat_per_vb_ceil()) - * parent_vbytes; - - // Calculate what the child needs to pay to cover both transactions - let base_child_fee = target_fee_rate.to_sat_per_vb_ceil() * estimated_child_vbytes; - let total_child_fee = base_child_fee + parent_fee_deficit; - - // Calculate the effective fee rate for the child - let child_fee_rate_sat_vb = total_child_fee / estimated_child_vbytes; - let child_fee_rate = FeeRate::from_sat_per_vb(child_fee_rate_sat_vb) - .unwrap_or(FeeRate::from_sat_per_kwu(child_fee_rate_sat_vb * 250)); - - log_info!(self.logger, "CPFP Calculation Results:"); - log_info!(self.logger, " Parent fee deficit: {} sats", parent_fee_deficit); - log_info!(self.logger, " Base child fee needed: {} sats", base_child_fee); - log_info!(self.logger, " Total child fee needed: {} sats", total_child_fee); - log_info!( - self.logger, - " Recommended child fee rate: {} sat/vB", - child_fee_rate.to_sat_per_vb_ceil() - ); - log_info!( - self.logger, - " Combined package rate: ~{} sat/vB", - ((parent_fee.to_sat() + total_child_fee) / (parent_vbytes + estimated_child_vbytes)) - ); + let locked_wallet = self.inner.lock().unwrap(); + locked_wallet.calculate_cpfp_fee_rate(*parent_txid, target_fee_rate).map_err(|e| { + log_error!(self.logger, "Failed to calculate CPFP fee rate for {}: {}", parent_txid, e); + match e { + bdk_wallet_aggregate::Error::TransactionNotFound => Error::TransactionNotFound, + bdk_wallet_aggregate::Error::TransactionAlreadyConfirmed => { + Error::TransactionAlreadyConfirmed + }, + _ => Error::WalletOperationFailed, + } + }) + } - Ok(child_fee_rate) + pub(crate) fn update_payment_store_for_all_transactions(&self) -> Result<(), Error> { + let locked_wallet = self.inner.lock().unwrap(); + self.update_payment_store(&locked_wallet) } - fn update_payment_store<'a>( - &self, locked_wallet: &'a mut PersistedWallet, + fn update_payment_store( + &self, locked_wallet: &AggregateWallet, ) -> Result<(), Error> { - for wtx in locked_wallet.transactions() { - let id = PaymentId(wtx.tx_node.txid.to_byte_array()); - let txid = wtx.tx_node.txid; - let (payment_status, confirmation_status) = match wtx.chain_position { - bdk_chain::ChainPosition::Confirmed { anchor, .. } => { - let confirmation_height = anchor.block_id.height; - let cur_height = locked_wallet.latest_checkpoint().height(); - let payment_status = if cur_height >= confirmation_height + ANTI_REORG_DELAY - 1 - { - PaymentStatus::Succeeded - } else { - PaymentStatus::Pending - }; - let confirmation_status = ConfirmationStatus::Confirmed { - block_hash: anchor.block_id.hash, - height: confirmation_height, - timestamp: anchor.confirmation_time, - }; - (payment_status, confirmation_status) - }, - bdk_chain::ChainPosition::Unconfirmed { .. } => { - (PaymentStatus::Pending, ConfirmationStatus::Unconfirmed) - }, - }; - // TODO: It would be great to introduce additional variants for - // `ChannelFunding` and `ChannelClosing`. For the former, we could just - // take a reference to `ChannelManager` here and check against - // `list_channels`. But for the latter the best approach is much less - // clear: for force-closes/HTLC spends we should be good querying - // `OutputSweeper::tracked_spendable_outputs`, but regular channel closes - // (i.e., `SpendableOutputDescriptor::StaticOutput` variants) are directly - // spent to a wallet address. The only solution I can come up with is to - // create and persist a list of 'static pending outputs' that we could use - // here to determine the `PaymentKind`, but that's not really satisfactory, so - // we're punting on it until we can come up with a better solution. - let kind = crate::payment::PaymentKind::Onchain { txid, status: confirmation_status }; - let fee = locked_wallet.calculate_fee(&wtx.tx_node.tx).unwrap_or(Amount::ZERO); - let (sent, received) = locked_wallet.sent_and_received(&wtx.tx_node.tx); - let (direction, amount_msat) = if sent > received { - let direction = PaymentDirection::Outbound; - let amount_msat = Some( - sent.to_sat().saturating_sub(fee.to_sat()).saturating_sub(received.to_sat()) - * 1000, - ); - (direction, amount_msat) - } else { - let direction = PaymentDirection::Inbound; - let amount_msat = Some( - received.to_sat().saturating_sub(sent.to_sat().saturating_sub(fee.to_sat())) - * 1000, - ); - (direction, amount_msat) - }; + let mut seen_txids = std::collections::HashSet::new(); + let cur_height = locked_wallet.latest_checkpoint().height(); + + for wallet in locked_wallet.wallets().values() { + for wtx in wallet.transactions() { + let txid = wtx.tx_node.txid; + if !seen_txids.insert(txid) { + continue; + } - let fee_paid_msat = Some(fee.to_sat() * 1000); + let id = PaymentId(txid.to_byte_array()); + let (payment_status, confirmation_status) = match wtx.chain_position { + bdk_chain::ChainPosition::Confirmed { anchor, .. } => { + let confirmation_height = anchor.block_id.height; + let payment_status = + if cur_height >= confirmation_height + ANTI_REORG_DELAY - 1 { + PaymentStatus::Succeeded + } else { + PaymentStatus::Pending + }; + let confirmation_status = ConfirmationStatus::Confirmed { + block_hash: anchor.block_id.hash, + height: confirmation_height, + timestamp: anchor.confirmation_time, + }; + (payment_status, confirmation_status) + }, + bdk_chain::ChainPosition::Unconfirmed { .. } => { + (PaymentStatus::Pending, ConfirmationStatus::Unconfirmed) + }, + }; + // TODO: It would be great to introduce additional variants for + // `ChannelFunding` and `ChannelClosing`. For the former, we could just + // take a reference to `ChannelManager` here and check against + // `list_channels`. But for the latter the best approach is much less + // clear: for force-closes/HTLC spends we should be good querying + // `OutputSweeper::tracked_spendable_outputs`, but regular channel closes + // (i.e., `SpendableOutputDescriptor::StaticOutput` variants) are directly + // spent to a wallet address. The only solution I can come up with is to + // create and persist a list of 'static pending outputs' that we could use + // here to determine the `PaymentKind`, but that's not really satisfactory, so + // we're punting on it until we can come up with a better solution. + let kind = + crate::payment::PaymentKind::Onchain { txid, status: confirmation_status }; + + let fee = locked_wallet.calculate_tx_fee(&wtx.tx_node.tx).unwrap_or(Amount::ZERO); + let (sent_sat, received_sat) = + locked_wallet.sent_and_received(txid).unwrap_or((0, 0)); + let fee_sat = fee.to_sat(); + + let (direction, amount_msat) = if sent_sat > received_sat { + let direction = PaymentDirection::Outbound; + let amount_msat = + Some(sent_sat.saturating_sub(fee_sat).saturating_sub(received_sat) * 1000); + (direction, amount_msat) + } else { + let direction = PaymentDirection::Inbound; + let amount_msat = + Some(received_sat.saturating_sub(sent_sat.saturating_sub(fee_sat)) * 1000); + (direction, amount_msat) + }; - let payment = PaymentDetails::new( - id, - kind, - amount_msat, - fee_paid_msat, - direction, - payment_status, - ); + let fee_paid_msat = Some(fee_sat * 1000); - self.payment_store.insert_or_update(payment)?; + let payment = PaymentDetails::new( + id, + kind, + amount_msat, + fee_paid_msat, + direction, + payment_status, + ); + + self.payment_store.insert_or_update(payment)?; + } } Ok(()) @@ -772,84 +497,93 @@ impl Wallet { locktime: LockTime, ) -> Result { let fee_rate = self.fee_estimator.estimate_fee_rate(confirmation_target); - let mut locked_wallet = self.inner.lock().unwrap(); - let mut tx_builder = locked_wallet.build_tx(); + let primary_type = locked_wallet.primary_key(); - tx_builder.add_recipient(output_script, amount).fee_rate(fee_rate).nlocktime(locktime); - - let mut psbt = match tx_builder.finish() { - Ok(psbt) => { - log_trace!(self.logger, "Created funding PSBT: {:?}", psbt); - psbt - }, - Err(err) => { - log_error!(self.logger, "Failed to create funding transaction: {}", err); - return Err(err.into()); - }, + let tx = if primary_type == AddressType::Legacy { + log_info!( + self.logger, + "Primary is Legacy, using best SegWit wallet for channel funding" + ); + locked_wallet.build_and_sign_tx_with_best_wallet( + output_script, + amount, + fee_rate, + locktime, + |k| *k != AddressType::Legacy, + ) + } else { + locked_wallet.build_and_sign_funding_tx(output_script, amount, fee_rate, locktime) }; - match locked_wallet.sign(&mut psbt, SignOptions::default()) { - Ok(finalized) => { - if !finalized { - return Err(Error::OnchainTxCreationFailed); - } - }, - Err(err) => { - log_error!(self.logger, "Failed to create funding transaction: {}", err); - return Err(err.into()); - }, - } - - let mut locked_persister = self.persister.lock().unwrap(); - locked_wallet.persist(&mut locked_persister).map_err(|e| { - log_error!(self.logger, "Failed to persist wallet: {}", e); - Error::PersistenceFailed - })?; - - let tx = psbt.extract_tx().map_err(|e| { - log_error!(self.logger, "Failed to extract transaction: {}", e); - e - })?; - - Ok(tx) + tx.map_err(|e| { + log_error!(self.logger, "Failed to create funding transaction: {}", e); + match e { + bdk_wallet_aggregate::Error::InsufficientFunds => Error::InsufficientFunds, + bdk_wallet_aggregate::Error::OnchainTxSigningFailed => { + Error::OnchainTxSigningFailed + }, + bdk_wallet_aggregate::Error::PersistenceFailed => Error::PersistenceFailed, + _ => Error::OnchainTxCreationFailed, + } + }) } pub(crate) fn get_new_address(&self) -> Result { let mut locked_wallet = self.inner.lock().unwrap(); - let mut locked_persister = self.persister.lock().unwrap(); + locked_wallet.new_address().map_err(|e| { + log_error!(self.logger, "Failed to get new address: {}", e); + Error::WalletOperationFailed + }) + } - let address_info = locked_wallet.reveal_next_address(KeychainKind::External); - locked_wallet.persist(&mut locked_persister).map_err(|e| { - log_error!(self.logger, "Failed to persist wallet: {}", e); - Error::PersistenceFailed - })?; - Ok(address_info.address) + pub(crate) fn get_new_address_for_type( + &self, address_type: AddressType, + ) -> Result { + let mut locked_wallet = self.inner.lock().unwrap(); + locked_wallet.new_address_for(&address_type).map_err(|e| { + log_error!(self.logger, "Failed to get new address for type {:?}: {}", address_type, e); + Error::WalletOperationFailed + }) + } + + // Returns a native witness address for Lightning channel scripts. + // Falls back to a loaded NativeSegwit/Taproot wallet if the primary is not one. + pub(crate) fn get_new_witness_address(&self) -> Result { + let locked_wallet = self.inner.lock().unwrap(); + let primary = locked_wallet.primary_key(); + + if primary.is_native_witness() { + drop(locked_wallet); + return self.get_new_address(); + } + + let witness_key = locked_wallet.loaded_keys().into_iter().find(|k| k.is_native_witness()); + drop(locked_wallet); + + match witness_key { + Some(key) => self.get_new_address_for_type(key), + None => { + log_error!(self.logger, "No native witness wallet loaded for Lightning operations"); + Err(Error::WalletOperationFailed) + }, + } } pub(crate) fn get_new_internal_address(&self) -> Result { let mut locked_wallet = self.inner.lock().unwrap(); - let mut locked_persister = self.persister.lock().unwrap(); - - let address_info = locked_wallet.next_unused_address(KeychainKind::Internal); - locked_wallet.persist(&mut locked_persister).map_err(|e| { - log_error!(self.logger, "Failed to persist wallet: {}", e); - Error::PersistenceFailed - })?; - Ok(address_info.address) + locked_wallet.new_internal_address().map_err(|e| { + log_error!(self.logger, "Failed to get new internal address: {}", e); + Error::WalletOperationFailed + }) } pub(crate) fn cancel_tx(&self, tx: &Transaction) -> Result<(), Error> { let mut locked_wallet = self.inner.lock().unwrap(); - let mut locked_persister = self.persister.lock().unwrap(); - - locked_wallet.cancel_tx(tx); - locked_wallet.persist(&mut locked_persister).map_err(|e| { - log_error!(self.logger, "Failed to persist wallet: {}", e); + locked_wallet.cancel_tx(tx).map_err(|e| { + log_error!(self.logger, "Failed to cancel transaction: {}", e); Error::PersistenceFailed - })?; - - Ok(()) + }) } pub(crate) fn get_balances( @@ -867,11 +601,23 @@ impl Wallet { ); } - self.get_balances_inner(balance, total_anchor_channels_reserve_sats) + self.get_balances_inner(&balance, total_anchor_channels_reserve_sats) + } + + pub(crate) fn get_balance_for_address_type( + &self, address_type: AddressType, + ) -> Result<(u64, u64), Error> { + let locked_wallet = self.inner.lock().unwrap(); + let balance = locked_wallet.balance_for(&address_type).map_err(|e| { + log_error!(self.logger, "Failed to get balance for {:?}: {}", address_type, e); + Error::WalletOperationFailed + })?; + + self.get_balances_inner(&balance, 0) } fn get_balances_inner( - &self, balance: Balance, total_anchor_channels_reserve_sats: u64, + &self, balance: &Balance, total_anchor_channels_reserve_sats: u64, ) -> Result<(u64, u64), Error> { let spendable_base = if self.config.include_untrusted_pending_in_spendable { balance.trusted_spendable().to_sat() + balance.untrusted_pending.to_sat() @@ -893,17 +639,23 @@ impl Wallet { self.get_balances(total_anchor_channels_reserve_sats).map(|(_, s)| s) } + pub(crate) fn get_witness_spendable_amount_sats( + &self, total_anchor_channels_reserve_sats: u64, + ) -> Result { + let locked_wallet = self.inner.lock().unwrap(); + let balance = locked_wallet.balance_filtered(|k| *k != AddressType::Legacy); + self.get_balances_inner(&balance, total_anchor_channels_reserve_sats).map(|(_, s)| s) + } + // Get transaction details including inputs, outputs, and net amount. - // Returns None if the transaction is not found in the wallet. + // Returns None if the transaction is not found in any wallet. pub(crate) fn get_tx_details(&self, txid: &Txid) -> Option<(i64, Vec, Vec)> { let locked_wallet = self.inner.lock().unwrap(); - let tx_node = locked_wallet.get_tx(*txid)?; - let tx = &tx_node.tx_node.tx; - let (sent, received) = locked_wallet.sent_and_received(tx); - let net_amount = received.to_sat() as i64 - sent.to_sat() as i64; + let (sent_sat, received_sat) = locked_wallet.sent_and_received(*txid)?; + let tx = locked_wallet.find_tx(*txid)?; + let net_amount = received_sat as i64 - sent_sat as i64; - let inputs: Vec = - tx.input.iter().map(|tx_input| TxInput::from_tx_input(tx_input)).collect(); + let inputs: Vec = tx.input.iter().map(TxInput::from_tx_input).collect(); let outputs: Vec = tx .output @@ -930,8 +682,7 @@ impl Wallet { ) -> Result, Error> { let locked_wallet = self.inner.lock().unwrap(); - // Get all unspent outputs from the wallet - let all_utxos: Vec = locked_wallet.list_unspent().collect(); + let all_utxos: Vec = locked_wallet.list_unspent(); let total_count = all_utxos.len(); // Filter out channel funding transactions @@ -968,157 +719,66 @@ impl Wallet { &self, target_amount: u64, available_utxos: Vec, fee_rate: FeeRate, algorithm: CoinSelectionAlgorithm, drain_script: &Script, channel_manager: &ChannelManager, ) -> Result, Error> { - // First, filter out any funding transactions for safety - let safe_utxos: Vec = available_utxos - .into_iter() - .filter(|utxo| { - if self.is_funding_transaction(&utxo.outpoint.txid, channel_manager) { - log_debug!( - self.logger, - "Filtering out UTXO {:?} as it's part of a channel funding transaction", - utxo.outpoint - ); - false - } else { - true - } - }) - .collect(); - - if safe_utxos.is_empty() { - log_error!( - self.logger, - "No spendable UTXOs available after filtering funding transactions" - ); - return Err(Error::NoSpendableOutputs); - } - - // Use the improved weight calculation from the second implementation - let locked_wallet = self.inner.lock().unwrap(); - let weighted_utxos: Vec = safe_utxos + let excluded_outpoints: Vec = available_utxos .iter() - .map(|utxo| { - // Use BDK's descriptor to calculate satisfaction weight - let descriptor = locked_wallet.public_descriptor(utxo.keychain); - let satisfaction_weight = descriptor.max_weight_to_satisfy().unwrap_or_else(|_| { - // Fallback to manual calculation if BDK method fails - log_debug!( - self.logger, - "Failed to calculate descriptor weight, using fallback for UTXO {:?}", - utxo.outpoint - ); - match utxo.txout.script_pubkey.witness_version() { - Some(WitnessVersion::V0) => { - // P2WPKH input weight calculation: - // Non-witness data: 32 (txid) + 4 (vout) + 1 (script_sig length) + 4 (sequence) = 41 bytes - // Witness data: 1 (item count) + 1 (sig length) + 72 (sig) + 1 (pubkey length) + 33 (pubkey) = 108 bytes - // Total weight = 41 * 4 + 108 = 272 WU - Weight::from_wu(272) - }, - Some(WitnessVersion::V1) => { - // P2TR key-path spend weight calculation: - // Non-witness data: 32 + 4 + 1 + 4 = 41 bytes - // Witness data: 1 (item count) + 1 (sig length) + 64 (schnorr sig) = 66 bytes - // Total weight = 41 * 4 + 66 = 230 WU - Weight::from_wu(230) - }, - _ => { - // Conservative fallback for unknown script types - log_warn!( - self.logger, - "Unknown script type for UTXO {:?}, using conservative weight", - utxo.outpoint - ); - Weight::from_wu(272) - }, - } - }); - - WeightedUtxo { satisfaction_weight, utxo: bdk_wallet::Utxo::Local(utxo.clone()) } - }) + .filter(|utxo| self.is_funding_transaction(&utxo.outpoint.txid, channel_manager)) + .map(|utxo| utxo.outpoint) .collect(); - drop(locked_wallet); + let locked_wallet = self.inner.lock().unwrap(); + let algo = match algorithm { + CoinSelectionAlgorithm::BranchAndBound => { + bdk_wallet_aggregate::CoinSelectionAlgorithm::BranchAndBound + }, + CoinSelectionAlgorithm::LargestFirst => { + bdk_wallet_aggregate::CoinSelectionAlgorithm::LargestFirst + }, + CoinSelectionAlgorithm::OldestFirst => { + bdk_wallet_aggregate::CoinSelectionAlgorithm::OldestFirst + }, + CoinSelectionAlgorithm::SingleRandomDraw => { + bdk_wallet_aggregate::CoinSelectionAlgorithm::SingleRandomDraw + }, + }; - let target = Amount::from_sat(target_amount); - let mut rng = OsRng; - - // Run coin selection based on the algorithm - let result = - match algorithm { - CoinSelectionAlgorithm::BranchAndBound => { - BranchAndBoundCoinSelection::::default().coin_select( - vec![], // required UTXOs - weighted_utxos, - fee_rate, - target, - drain_script, - &mut rng, - ) - }, - CoinSelectionAlgorithm::LargestFirst => LargestFirstCoinSelection::default() - .coin_select(vec![], weighted_utxos, fee_rate, target, drain_script, &mut rng), - CoinSelectionAlgorithm::OldestFirst => OldestFirstCoinSelection::default() - .coin_select(vec![], weighted_utxos, fee_rate, target, drain_script, &mut rng), - CoinSelectionAlgorithm::SingleRandomDraw => SingleRandomDraw::default() - .coin_select(vec![], weighted_utxos, fee_rate, target, drain_script, &mut rng), - } + locked_wallet + .select_utxos( + target_amount, + available_utxos, + fee_rate, + algo, + drain_script, + &excluded_outpoints, + ) .map_err(|e| { log_error!(self.logger, "Coin selection failed: {}", e); Error::CoinSelectionFailed - })?; - - // Validate change amount is not dust - if let Excess::Change { amount, .. } = result.excess { - if amount.to_sat() > 0 && amount.to_sat() < DUST_LIMIT_SATS { - return Err(Error::CoinSelectionFailed); - } - } - - // Extract the selected outputs - let selected_outputs: Vec = result - .selected - .into_iter() - .filter_map(|utxo| match utxo { - bdk_wallet::Utxo::Local(local) => Some(local), - _ => None, }) - .collect(); - - log_info!( - self.logger, - "Selected {} UTXOs using {:?} algorithm for target {} sats (fee: {} sats)", - selected_outputs.len(), - algorithm, - target_amount, - result.fee_amount.to_sat(), - ); - Ok(selected_outputs.into_iter().map(|u| u.outpoint).collect()) } // Helper that builds a transaction PSBT with shared logic for send_to_address // and calculate_transaction_fee. + // Supports cross-wallet spending: unified coin selection pools UTXOs from all + // loaded wallets and selects optimally across the full set. fn build_transaction_psbt( &self, address: &Address, send_amount: OnchainSendAmount, fee_rate: FeeRate, utxos_to_spend: Option>, channel_manager: &ChannelManager, - ) -> Result<(Psbt, MutexGuard<'_, PersistedWallet>), Error> { + ) -> Result<(Psbt, MutexGuard<'_, AggregateWallet>), Error> + { let mut locked_wallet = self.inner.lock().unwrap(); + let all_utxos = locked_wallet.list_unspent(); + // Validate and check UTXOs if provided if let Some(ref outpoints) = utxos_to_spend { - // Get all wallet UTXOs for validation - let wallet_utxos: Vec<_> = locked_wallet.list_unspent().collect(); - let wallet_utxo_set: std::collections::HashSet<_> = - wallet_utxos.iter().map(|u| u.outpoint).collect(); + let all_utxo_set: std::collections::HashSet<_> = + all_utxos.iter().map(|u| u.outpoint).collect(); - // Validate all requested UTXOs exist and are safe to spend for outpoint in outpoints { - if !wallet_utxo_set.contains(outpoint) { - log_error!(self.logger, "UTXO {:?} not found in wallet", outpoint); + if !all_utxo_set.contains(outpoint) { + log_error!(self.logger, "UTXO {:?} not found in any wallet", outpoint); return Err(Error::WalletOperationFailed); } - - // Check if this UTXO's transaction is a channel funding transaction if self.is_funding_transaction(&outpoint.txid, channel_manager) { log_error!( self.logger, @@ -1130,7 +790,7 @@ impl Wallet { } // Calculate total value of selected UTXOs - let selected_value: u64 = wallet_utxos + let selected_value: u64 = all_utxos .iter() .filter(|u| outpoints.contains(&u.outpoint)) .map(|u| u.txout.value.to_sat()) @@ -1165,11 +825,40 @@ impl Wallet { ); } + let funding_txids: std::collections::HashSet = all_utxos + .iter() + .filter(|u| self.is_funding_transaction(&u.outpoint.txid, channel_manager)) + .map(|u| u.outpoint.txid) + .collect(); + + let non_primary_utxo_infos: Option> = + match (&utxos_to_spend, send_amount) { + (Some(_), _) => None, + (None, OnchainSendAmount::AllDrainingReserve) + | (None, OnchainSendAmount::AllRetainingReserve { .. }) => locked_wallet + .non_primary_foreign_utxos(&funding_txids) + .ok() + .filter(|v| !v.is_empty()), + (None, OnchainSendAmount::ExactRetainingReserve { .. }) => None, + }; + + let manual_utxo_infos: Option> = + if let Some(ref outpoints) = utxos_to_spend { + Some(locked_wallet.prepare_outpoints_for_psbt(outpoints).map_err(|e| { + log_error!(self.logger, "Failed to prepare manually selected UTXOs: {}", e); + Error::WalletOperationFailed + })?) + } else { + None + }; + + let aggregate_balance = locked_wallet.balance(); + let primary = locked_wallet.primary_wallet_mut(); + // Prepare the tx_builder. We properly check the reserve requirements (again) further down. - const DUST_LIMIT_SATS: u64 = 546; let mut tx_builder = match send_amount { OnchainSendAmount::ExactRetainingReserve { amount_sats, .. } => { - let mut tx_builder = locked_wallet.build_tx(); + let mut tx_builder = primary.build_tx(); let amount = Amount::from_sat(amount_sats); tx_builder.add_recipient(address.script_pubkey(), amount).fee_rate(fee_rate); tx_builder @@ -1177,14 +866,13 @@ impl Wallet { OnchainSendAmount::AllRetainingReserve { cur_anchor_reserve_sats } if cur_anchor_reserve_sats > DUST_LIMIT_SATS => { - let change_address_info = locked_wallet.peek_address(KeychainKind::Internal, 0); - let balance = locked_wallet.balance(); + let change_address_info = primary.peek_address(KeychainKind::Internal, 0); let spendable_amount_sats = self - .get_balances_inner(balance, cur_anchor_reserve_sats) + .get_balances_inner(&aggregate_balance, cur_anchor_reserve_sats) .map(|(_, s)| s) .unwrap_or(0); let tmp_tx = { - let mut tmp_tx_builder = locked_wallet.build_tx(); + let mut tmp_tx_builder = primary.build_tx(); tmp_tx_builder .drain_wallet() .drain_to(address.script_pubkey()) @@ -1223,7 +911,7 @@ impl Wallet { } }; - let estimated_tx_fee = locked_wallet.calculate_fee(&tmp_tx).map_err(|e| { + let base_fee = primary.calculate_fee(&tmp_tx).map_err(|e| { log_error!( self.logger, "Failed to calculate fee of temporary transaction: {}", @@ -1233,7 +921,18 @@ impl Wallet { })?; // 'cancel' the transaction to free up any used change addresses - locked_wallet.cancel_tx(&tmp_tx); + primary.cancel_tx(&tmp_tx); + + // Adjust the fee estimate for non-primary inputs that will be + // added to the actual tx (the temp tx only used primary UTXOs). + let extra_input_weight: u64 = non_primary_utxo_infos + .as_ref() + .map(|infos| infos.iter().map(|i| i.weight.to_wu()).sum::()) + .unwrap_or(0); + let extra_input_fee = fee_rate + .fee_wu(bitcoin::Weight::from_wu(extra_input_weight)) + .unwrap_or(Amount::ZERO); + let estimated_tx_fee = base_fee + extra_input_fee; let estimated_spendable_amount = Amount::from_sat( spendable_amount_sats.saturating_sub(estimated_tx_fee.to_sat()), @@ -1241,14 +940,14 @@ impl Wallet { if estimated_spendable_amount < Amount::from_sat(DUST_LIMIT_SATS) { log_error!(self.logger, - "Unable to send payment without infringing on Anchor reserves. Available: {}sats, estimated fee required: {}sats.", - spendable_amount_sats, - estimated_tx_fee, - ); + "Unable to send payment without infringing on Anchor reserves. Available: {}sats, estimated fee required: {}sats.", + spendable_amount_sats, + estimated_tx_fee, + ); return Err(Error::InsufficientFunds); } - let mut tx_builder = locked_wallet.build_tx(); + let mut tx_builder = primary.build_tx(); tx_builder .add_recipient(address.script_pubkey(), estimated_spendable_amount) .fee_absolute(estimated_tx_fee); @@ -1256,55 +955,86 @@ impl Wallet { }, OnchainSendAmount::AllDrainingReserve | OnchainSendAmount::AllRetainingReserve { cur_anchor_reserve_sats: _ } => { - let mut tx_builder = locked_wallet.build_tx(); + let mut tx_builder = primary.build_tx(); tx_builder.drain_wallet().drain_to(address.script_pubkey()).fee_rate(fee_rate); tx_builder }, }; - // Add specified UTXOs if provided - if let Some(outpoints) = utxos_to_spend { - for outpoint in outpoints { - tx_builder.add_utxo(outpoint).map_err(|e| { - log_error!(self.logger, "Failed to add UTXO {:?}: {}", outpoint, e); + if let Some(ref utxo_infos) = manual_utxo_infos { + bdk_wallet_aggregate::utxo::add_utxos_to_tx_builder(&mut tx_builder, utxo_infos) + .map_err(|e| { + log_error!(self.logger, "Failed to add manually selected UTXOs: {}", e); Error::OnchainTxCreationFailed })?; - } - - // Since UTXOs were specified, only use those tx_builder.manually_selected_only(); } + if let Some(ref infos) = non_primary_utxo_infos { + bdk_wallet_aggregate::utxo::add_utxos_to_tx_builder(&mut tx_builder, infos).map_err( + |e| { + log_error!(self.logger, "Failed to add cross-wallet UTXOs: {}", e); + Error::OnchainTxCreationFailed + }, + )?; + } + let psbt = match tx_builder.finish() { Ok(psbt) => { log_trace!(self.logger, "Created PSBT: {:?}", psbt); psbt }, Err(err) => { - log_error!(self.logger, "Failed to create transaction: {}", err); - return Err(err.into()); + let can_retry = + matches!(send_amount, OnchainSendAmount::ExactRetainingReserve { .. }) + && manual_utxo_infos.is_none() + && non_primary_utxo_infos.is_none(); + + if can_retry { + let amount_sats = match send_amount { + OnchainSendAmount::ExactRetainingReserve { amount_sats, .. } => amount_sats, + _ => unreachable!(), + }; + locked_wallet + .build_psbt_with_cross_wallet_fallback( + address.script_pubkey(), + Amount::from_sat(amount_sats), + fee_rate, + &funding_txids, + bdk_wallet_aggregate::CoinSelectionAlgorithm::BranchAndBound, + ) + .map_err(|e| { + log_error!(self.logger, "Failed to create transaction: {}", e); + match e { + bdk_wallet_aggregate::Error::InsufficientFunds => { + Error::InsufficientFunds + }, + _ => Error::OnchainTxCreationFailed, + } + })? + } else { + log_error!(self.logger, "Failed to create transaction: {}", err); + return Err(err.into()); + } }, }; // Check the reserve requirements (again) and return an error if they aren't met. match send_amount { OnchainSendAmount::ExactRetainingReserve { amount_sats, cur_anchor_reserve_sats } => { - let balance = locked_wallet.balance(); let spendable_amount_sats = self - .get_balances_inner(balance, cur_anchor_reserve_sats) + .get_balances_inner(&aggregate_balance, cur_anchor_reserve_sats) .map(|(_, s)| s) .unwrap_or(0); - let tx_fee_sats = locked_wallet - .calculate_fee(&psbt.unsigned_tx) - .map_err(|e| { + let tx_fee_sats = + locked_wallet.calculate_fee_with_fallback(&psbt).map_err(|e| { log_error!( self.logger, "Failed to calculate fee of candidate transaction: {}", e ); - e - })? - .to_sat(); + Error::WalletOperationFailed + })?; if spendable_amount_sats < amount_sats.saturating_add(tx_fee_sats) { log_error!(self.logger, "Unable to send payment due to insufficient funds. Available: {}sats, Required: {}sats + {}sats fee", @@ -1316,16 +1046,14 @@ impl Wallet { } }, OnchainSendAmount::AllRetainingReserve { cur_anchor_reserve_sats } => { - let balance = locked_wallet.balance(); let spendable_amount_sats = self - .get_balances_inner(balance, cur_anchor_reserve_sats) + .get_balances_inner(&aggregate_balance, cur_anchor_reserve_sats) .map(|(_, s)| s) .unwrap_or(0); - let (sent, received) = locked_wallet.sent_and_received(&psbt.unsigned_tx); - let drain_amount = sent - received; - if spendable_amount_sats < drain_amount.to_sat() { + let drain_amount = locked_wallet.drain_amount_from_psbt(&psbt); + if spendable_amount_sats < drain_amount { log_error!(self.logger, - "Unable to send payment due to insufficient funds. Available: {}sats, Required: {}", + "Unable to send payment due to insufficient funds. Available: {}sats, Required: {}sats", spendable_amount_sats, drain_amount, ); @@ -1357,14 +1085,10 @@ impl Wallet { channel_manager, )?; - // Calculate the final fee - let calculated_fee = locked_wallet - .calculate_fee(&psbt.unsigned_tx) - .map_err(|e| { - log_error!(self.logger, "Failed to calculate fee of final transaction: {}", e); - e - })? - .to_sat(); + let calculated_fee = locked_wallet.calculate_fee_with_fallback(&psbt).map_err(|e| { + log_error!(self.logger, "Failed to calculate transaction fee: {}", e); + Error::WalletOperationFailed + })?; log_info!( self.logger, @@ -1388,39 +1112,47 @@ impl Wallet { let fee_rate = fee_rate.unwrap_or_else(|| self.fee_estimator.estimate_fee_rate(confirmation_target)); - let (mut psbt, mut locked_wallet) = self.build_transaction_psbt( - address, - send_amount, - fee_rate, - utxos_to_spend, - channel_manager, - )?; - - // Sign the transaction - match locked_wallet.sign(&mut psbt, SignOptions::default()) { - Ok(finalized) => { - if !finalized { - return Err(Error::OnchainTxCreationFailed); - } - }, - Err(err) => { - log_error!(self.logger, "Failed to create transaction: {}", err); - return Err(err.into()); + let is_drain_all = match send_amount { + OnchainSendAmount::AllDrainingReserve => true, + OnchainSendAmount::AllRetainingReserve { cur_anchor_reserve_sats } => { + cur_anchor_reserve_sats <= DUST_LIMIT_SATS }, - } + _ => false, + }; - // Persist the wallet - let mut locked_persister = self.persister.lock().unwrap(); - locked_wallet.persist(&mut locked_persister).map_err(|e| { - log_error!(self.logger, "Failed to persist wallet: {}", e); - Error::PersistenceFailed - })?; + let tx = if is_drain_all && utxos_to_spend.is_none() { + let mut locked_wallet = self.inner.lock().unwrap(); + let tx = locked_wallet + .build_and_sign_drain(address.script_pubkey(), fee_rate) + .map_err(|e| { + log_error!(self.logger, "Failed to drain wallets: {}", e); + Error::OnchainTxCreationFailed + })?; + locked_wallet.persist_all().map_err(|e| { + log_error!(self.logger, "Failed to persist wallet: {}", e); + Error::PersistenceFailed + })?; + tx + } else { + let (psbt, mut locked_wallet) = self.build_transaction_psbt( + address, + send_amount, + fee_rate, + utxos_to_spend, + channel_manager, + )?; + + let tx = locked_wallet.sign_psbt_all(psbt).map_err(|e| { + log_error!(self.logger, "Failed to sign transaction: {}", e); + Error::OnchainTxSigningFailed + })?; - // Extract the transaction - let tx = psbt.extract_tx().map_err(|e| { - log_error!(self.logger, "Failed to extract transaction: {}", e); - e - })?; + locked_wallet.persist_all().map_err(|e| { + log_error!(self.logger, "Failed to persist wallet: {}", e); + Error::PersistenceFailed + })?; + tx + }; self.broadcaster.broadcast_transactions(&[&tx]); @@ -1462,14 +1194,16 @@ impl Wallet { &self, must_spend: Vec, must_pay_to: &[TxOut], fee_rate: FeeRate, ) -> Result, ()> { let mut locked_wallet = self.inner.lock().unwrap(); - debug_assert!(matches!( - locked_wallet.public_descriptor(KeychainKind::External), - ExtendedDescriptor::Wpkh(_) - )); - debug_assert!(matches!( - locked_wallet.public_descriptor(KeychainKind::Internal), - ExtendedDescriptor::Wpkh(_) - )); + + // Splicing requires native witness (P2WPKH/P2TR) primary because + // FundingTxInput only supports native witness script types. + if !locked_wallet.primary_key().is_native_witness() { + log_error!( + self.logger, + "Splicing requires a native witness primary wallet (NativeSegwit or Taproot)" + ); + return Err(()); + } let mut tx_builder = locked_wallet.build_tx(); tx_builder.only_witness_utxo(); @@ -1511,16 +1245,9 @@ impl Wallet { fn list_confirmed_utxos_inner(&self) -> Result, ()> { let locked_wallet = self.inner.lock().unwrap(); let mut utxos = Vec::new(); - let confirmed_txs: Vec = locked_wallet - .transactions() - .filter(|t| t.chain_position.is_confirmed()) - .map(|t| t.tx_node.txid) - .collect(); - let unspent_confirmed_utxos = - locked_wallet.list_unspent().filter(|u| confirmed_txs.contains(&u.outpoint.txid)); - for u in unspent_confirmed_utxos { - let script_pubkey = u.txout.script_pubkey; + for u in locked_wallet.list_confirmed_unspent() { + let script_pubkey = u.txout.script_pubkey.clone(); match script_pubkey.witness_version() { Some(version @ WitnessVersion::V0) => { // According to the SegWit rules of [BIP 141] a witness program is defined as: @@ -1588,11 +1315,29 @@ impl Wallet { log_error!(self.logger, "Unexpected witness version: {}", version,); }, None => { - log_error!( - self.logger, - "Tried to use a non-witness script. This must never happen." - ); - panic!("Tried to use a non-witness script. This must never happen."); + let script_bytes = script_pubkey.as_bytes(); + if script_pubkey.is_p2pkh() { + let pkh = PubkeyHash::from_slice(&script_bytes[3..23]).map_err(|e| { + log_error!(self.logger, "Failed to extract PubkeyHash: {}", e); + })?; + utxos.push(Utxo::new_p2pkh(u.outpoint, u.txout.value, &pkh)); + } else if script_pubkey.is_p2sh() { + if let Some(wpkh) = locked_wallet.derive_wpkh_for_p2sh(&u) { + utxos.push(Utxo::new_nested_p2wpkh(u.outpoint, u.txout.value, &wpkh)); + } else { + log_debug!( + self.logger, + "Skipping P2SH UTXO {:?}: could not derive inner WPubkeyHash", + u.outpoint + ); + } + } else { + log_debug!( + self.logger, + "Skipping non-standard non-witness UTXO {:?}", + u.outpoint + ); + } }, } } @@ -1603,81 +1348,23 @@ impl Wallet { #[allow(deprecated)] fn get_change_script_inner(&self) -> Result { let mut locked_wallet = self.inner.lock().unwrap(); - let mut locked_persister = self.persister.lock().unwrap(); - - let address_info = locked_wallet.next_unused_address(KeychainKind::Internal); - locked_wallet.persist(&mut locked_persister).map_err(|e| { - log_error!(self.logger, "Failed to persist wallet: {}", e); - () - })?; - Ok(address_info.address.script_pubkey()) + locked_wallet.new_internal_address().map(|addr| addr.script_pubkey()).map_err(|e| { + log_error!(self.logger, "Failed to get change script: {}", e); + }) } - #[allow(deprecated)] pub(crate) fn sign_owned_inputs(&self, unsigned_tx: Transaction) -> Result { - let locked_wallet = self.inner.lock().unwrap(); - - let mut psbt = Psbt::from_unsigned_tx(unsigned_tx).map_err(|e| { - log_error!(self.logger, "Failed to construct PSBT: {}", e); - })?; - for (i, txin) in psbt.unsigned_tx.input.iter().enumerate() { - if let Some(utxo) = locked_wallet.get_utxo(txin.previous_output) { - debug_assert!(!utxo.is_spent); - psbt.inputs[i] = locked_wallet.get_psbt_input(utxo, None, true).map_err(|e| { - log_error!(self.logger, "Failed to construct PSBT input: {}", e); - })?; - } - } - - let mut sign_options = SignOptions::default(); - sign_options.trust_witness_utxo = true; - - match locked_wallet.sign(&mut psbt, sign_options) { - Ok(finalized) => debug_assert!(!finalized), - Err(e) => { - log_error!(self.logger, "Failed to sign owned inputs: {}", e); - return Err(()); - }, - } - - match psbt.extract_tx() { - Ok(tx) => Ok(tx), - Err(bitcoin::psbt::ExtractTxError::MissingInputValue { tx }) => Ok(tx), - Err(e) => { - log_error!(self.logger, "Failed to extract transaction: {}", e); - Err(()) - }, - } + let mut locked_wallet = self.inner.lock().unwrap(); + locked_wallet.sign_owned_inputs(unsigned_tx).map_err(|e| { + log_error!(self.logger, "Failed to sign transaction: {}", e); + }) } - #[allow(deprecated)] - fn sign_psbt_inner(&self, mut psbt: Psbt) -> Result { - let locked_wallet = self.inner.lock().unwrap(); - - // While BDK populates both `witness_utxo` and `non_witness_utxo` fields, LDK does not. As - // BDK by default doesn't trust the witness UTXO to account for the Segwit bug, we must - // disable it here as otherwise we fail to sign. - let mut sign_options = SignOptions::default(); - sign_options.trust_witness_utxo = true; - - match locked_wallet.sign(&mut psbt, sign_options) { - Ok(_finalized) => { - // BDK will fail to finalize for all LDK-provided inputs of the PSBT. Unfortunately - // we can't check more fine grained if it succeeded for all the other inputs here, - // so we just ignore the returned `finalized` bool. - }, - Err(err) => { - log_error!(self.logger, "Failed to sign transaction: {}", err); - return Err(()); - }, - } - - let tx = psbt.extract_tx().map_err(|e| { - log_error!(self.logger, "Failed to extract transaction: {}", e); - () - })?; - - Ok(tx) + fn sign_psbt_inner(&self, psbt: Psbt) -> Result { + let mut locked_wallet = self.inner.lock().unwrap(); + locked_wallet.sign_psbt_all(psbt).map_err(|e| { + log_error!(self.logger, "Failed to sign PSBT: {}", e); + }) } } @@ -1696,8 +1383,9 @@ impl Listen for Wallet { let mut locked_wallet = self.inner.lock().unwrap(); let pre_checkpoint = locked_wallet.latest_checkpoint(); - if pre_checkpoint.height() != height - 1 - || pre_checkpoint.hash() != block.header.prev_blockhash + if height > 0 + && (pre_checkpoint.height() != height - 1 + || pre_checkpoint.hash() != block.header.prev_blockhash) { log_debug!( self.logger, @@ -1708,8 +1396,8 @@ impl Listen for Wallet { } match locked_wallet.apply_block(block, height) { - Ok(()) => { - if let Err(e) = self.update_payment_store(&mut *locked_wallet) { + Ok(_all_txids) => { + if let Err(e) = self.update_payment_store(&locked_wallet) { log_error!(self.logger, "Failed to update payment store: {}", e); return; } @@ -1723,15 +1411,6 @@ impl Listen for Wallet { return; }, }; - - let mut locked_persister = self.persister.lock().unwrap(); - match locked_wallet.persist(&mut locked_persister) { - Ok(_) => (), - Err(e) => { - log_error!(self.logger, "Failed to persist on-chain wallet: {}", e); - return; - }, - }; } fn blocks_disconnected(&self, _fork_point_block: BestBlock) { @@ -1874,15 +1553,15 @@ impl SignerProvider for WalletKeysManager { } fn get_destination_script(&self, _channel_keys_id: [u8; 32]) -> Result { - let address = self.wallet.get_new_address().map_err(|e| { - log_error!(self.logger, "Failed to retrieve new address from wallet: {}", e); + let address = self.wallet.get_new_witness_address().map_err(|e| { + log_error!(self.logger, "Failed to retrieve new witness address from wallet: {}", e); })?; Ok(address.script_pubkey()) } fn get_shutdown_scriptpubkey(&self) -> Result { - let address = self.wallet.get_new_address().map_err(|e| { - log_error!(self.logger, "Failed to retrieve new address from wallet: {}", e); + let address = self.wallet.get_new_witness_address().map_err(|e| { + log_error!(self.logger, "Failed to retrieve new witness address from wallet: {}", e); })?; match address.witness_program() { @@ -1892,9 +1571,10 @@ impl SignerProvider for WalletKeysManager { _ => { log_error!( self.logger, - "Tried to use a non-witness address. This must never happen." + "get_shutdown_scriptpubkey received a non-native-witness address. \ + This is a bug in get_new_witness_address." ); - panic!("Tried to use a non-witness address. This must never happen."); + Err(()) }, } } diff --git a/src/wallet/persist.rs b/src/wallet/persist.rs index 5c8668937..e07fa59cc 100644 --- a/src/wallet/persist.rs +++ b/src/wallet/persist.rs @@ -10,6 +10,7 @@ use std::sync::Arc; use bdk_chain::Merge; use bdk_wallet::{ChangeSet, WalletPersister}; +use crate::config::AddressType; use crate::io::utils::{ read_bdk_wallet_change_set, write_bdk_wallet_change_descriptor, write_bdk_wallet_descriptor, write_bdk_wallet_indexer, write_bdk_wallet_local_chain, write_bdk_wallet_network, @@ -21,11 +22,14 @@ pub(crate) struct KVStoreWalletPersister { latest_change_set: Option, kv_store: Arc, logger: Arc, + address_type: AddressType, } impl KVStoreWalletPersister { - pub(crate) fn new(kv_store: Arc, logger: Arc) -> Self { - Self { latest_change_set: None, kv_store, logger } + pub(crate) fn new( + kv_store: Arc, logger: Arc, address_type: AddressType, + ) -> Self { + Self { latest_change_set: None, kv_store, logger, address_type } } } @@ -41,6 +45,7 @@ impl WalletPersister for KVStoreWalletPersister { let change_set_opt = read_bdk_wallet_change_set( Arc::clone(&persister.kv_store), Arc::clone(&persister.logger), + persister.address_type, )?; let change_set = match change_set_opt { @@ -91,6 +96,7 @@ impl WalletPersister for KVStoreWalletPersister { &descriptor, Arc::clone(&persister.kv_store), Arc::clone(&persister.logger), + persister.address_type, )?; } } @@ -114,6 +120,7 @@ impl WalletPersister for KVStoreWalletPersister { &change_descriptor, Arc::clone(&persister.kv_store), Arc::clone(&persister.logger), + persister.address_type, )?; } } @@ -135,6 +142,7 @@ impl WalletPersister for KVStoreWalletPersister { &network, Arc::clone(&persister.kv_store), Arc::clone(&persister.logger), + persister.address_type, )?; } } @@ -159,6 +167,7 @@ impl WalletPersister for KVStoreWalletPersister { &latest_change_set.indexer, Arc::clone(&persister.kv_store), Arc::clone(&persister.logger), + persister.address_type, )?; } @@ -168,6 +177,7 @@ impl WalletPersister for KVStoreWalletPersister { &latest_change_set.tx_graph, Arc::clone(&persister.kv_store), Arc::clone(&persister.logger), + persister.address_type, )?; } @@ -177,6 +187,7 @@ impl WalletPersister for KVStoreWalletPersister { &latest_change_set.local_chain, Arc::clone(&persister.kv_store), Arc::clone(&persister.logger), + persister.address_type, )?; } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index eadd502d9..72efa9a8a 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -295,9 +295,14 @@ pub(crate) fn random_port() -> u16 { pub(crate) fn random_listening_addresses() -> Vec { let num_addresses = 2; let mut listening_addresses = Vec::with_capacity(num_addresses); + let mut used_ports = std::collections::HashSet::new(); for _ in 0..num_addresses { - let rand_port = random_port(); + let mut rand_port = random_port(); + while used_ports.contains(&rand_port) { + rand_port = random_port(); + } + used_ports.insert(rand_port); let address: SocketAddress = format!("127.0.0.1:{}", rand_port).parse().unwrap(); listening_addresses.push(address); } diff --git a/tests/multi_address_types_tests.rs b/tests/multi_address_types_tests.rs new file mode 100644 index 000000000..3e8241141 --- /dev/null +++ b/tests/multi_address_types_tests.rs @@ -0,0 +1,2825 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +mod common; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- +mod helpers { + use std::str::FromStr; + + use bitcoin::{Address, Txid}; + use electrsd::corepc_node::Node as BitcoinD; + use electrsd::ElectrsD; + use ldk_node::bitcoin::Amount; + use ldk_node::config::AddressType; + use ldk_node::Node; + + use crate::common::{generate_blocks_and_wait, premine_and_distribute_funds}; + + /// Default amount to fund a peer node for channel tests. + pub const CHANNEL_PEER_FUNDING_SATS: u64 = 500_000; + + /// Fake txid used in error-case tests (RBF/CPFP reject unknown tx). + pub fn fake_txid_for_error_tests() -> Txid { + "0000000000000000000000000000000000000000000000000000000000000001".parse().unwrap() + } + + /// Standard regtest recipient address used across all tests. + pub fn test_recipient() -> Address { + Address::from_str("bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080") + .unwrap() + .require_network(bitcoin::Network::Regtest) + .unwrap() + } + + /// Fund a single address, mine 6 blocks, sync the node, and sleep. + pub async fn fund_and_sync( + bitcoind: &BitcoinD, electrsd: &ElectrsD, node: &Node, addr: Address, amount: u64, + ) { + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![addr], + Amount::from_sat(amount), + ) + .await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node.sync_wallets().unwrap(); + std::thread::sleep(std::time::Duration::from_secs(2)); + } + + /// Fund multiple addresses (each with its own amount), mine 6 blocks, + /// sync, and sleep once at the end. + pub async fn fund_multiple_and_sync( + bitcoind: &BitcoinD, electrsd: &ElectrsD, node: &Node, + addrs_and_amounts: Vec<(Address, u64)>, + ) { + for (addr, amount) in addrs_and_amounts { + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![addr], + Amount::from_sat(amount), + ) + .await; + } + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node.sync_wallets().unwrap(); + std::thread::sleep(std::time::Duration::from_secs(2)); + } + + /// Fund a peer node for channel tests: premine, mine 6 blocks, sync node, sleep. + pub async fn fund_peer_node_and_sync( + bitcoind: &BitcoinD, electrsd: &ElectrsD, node: &Node, addr: Address, amount: u64, + ) { + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![addr], + Amount::from_sat(amount), + ) + .await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node.sync_wallets().unwrap(); + std::thread::sleep(std::time::Duration::from_secs(2)); + } + + /// Mine blocks, sync node(s), and sleep. Used after confirming txs. + pub async fn confirm_and_sync( + bitcoind: &BitcoinD, electrsd: &ElectrsD, blocks: usize, nodes: &[&Node], + ) { + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, blocks).await; + for node in nodes { + node.sync_wallets().unwrap(); + } + std::thread::sleep(std::time::Duration::from_secs(1)); + } + + /// Convenience: create a node config with a given primary + monitored types. + pub fn node_config( + primary: AddressType, monitored: Vec, + ) -> crate::common::TestConfig { + let mut config = crate::common::random_config(true); + config.node_config.address_type = primary; + config.node_config.address_types_to_monitor = monitored; + config + } +} + +// --------------------------------------------------------------------------- +// Setup & Configuration +// --------------------------------------------------------------------------- +mod setup { + use ldk_node::config::AddressType; + + use crate::common::{setup_bitcoind_and_electrsd, setup_node, TestChainSource}; + use crate::helpers::{fund_multiple_and_sync, node_config}; + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_multi_wallet_setup() { + let (_bitcoind, _electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&_electrsd); + + let config = node_config(AddressType::NativeSegwit, vec![AddressType::Legacy]); + let node = setup_node(&chain_source, config, None); + + assert!(node.status().is_running); + let addr = node.onchain_payment().new_address().unwrap(); + assert!(!addr.to_string().is_empty()); + node.stop().unwrap(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_all_address_types_as_primary() { + let address_types = vec![ + AddressType::Legacy, + AddressType::NestedSegwit, + AddressType::NativeSegwit, + AddressType::Taproot, + ]; + + for primary_type in address_types { + let (_bitcoind, _electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&_electrsd); + + let config = node_config(primary_type, vec![]); + let node = setup_node(&chain_source, config, None); + assert!(node.status().is_running); + + let addr = node.onchain_payment().new_address().unwrap(); + assert!(!addr.to_string().is_empty()); + node.stop().unwrap(); + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_multi_wallet_all_combinations() { + let address_types = vec![ + AddressType::Legacy, + AddressType::NestedSegwit, + AddressType::NativeSegwit, + AddressType::Taproot, + ]; + + for primary_type in &address_types { + for monitored_type in &address_types { + if primary_type == monitored_type { + continue; + } + + let (_bitcoind, _electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&_electrsd); + + let config = node_config(*primary_type, vec![*monitored_type]); + let node = setup_node(&chain_source, config, None); + assert!(node.status().is_running); + + let addr = node.onchain_payment().new_address().unwrap(); + assert!(!addr.to_string().is_empty()); + node.stop().unwrap(); + + // Brief delay to allow OS to release ports (TIME_WAIT) before next bind + std::thread::sleep(std::time::Duration::from_millis(100)); + } + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_multi_wallet_electrum_setup() { + let (_bitcoind, _electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Electrum(&_electrsd); + + let config = node_config( + AddressType::NestedSegwit, + vec![AddressType::NativeSegwit, AddressType::Taproot], + ); + let node = setup_node(&chain_source, config, None); + assert!(node.status().is_running); + + let addr = node.onchain_payment().new_address().unwrap(); + assert!(!addr.to_string().is_empty()); + node.stop().unwrap(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_multi_wallet_empty_monitoring() { + let (_bitcoind, _electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&_electrsd); + + let config = node_config(AddressType::NativeSegwit, vec![]); + let node = setup_node(&chain_source, config, None); + assert!(node.status().is_running); + + let addr = node.onchain_payment().new_address().unwrap(); + assert!(!addr.to_string().is_empty()); + node.stop().unwrap(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_new_address_for_type() { + let (_bitcoind, _electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&_electrsd); + + let config = node_config( + AddressType::NativeSegwit, + vec![AddressType::Legacy, AddressType::NestedSegwit, AddressType::Taproot], + ); + let node = setup_node(&chain_source, config, None); + + for address_type in [ + AddressType::Legacy, + AddressType::NestedSegwit, + AddressType::NativeSegwit, + AddressType::Taproot, + ] { + let addr = node.onchain_payment().new_address_for_type(address_type).unwrap(); + assert!(!addr.to_string().is_empty()); + } + node.stop().unwrap(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_new_address_for_unmonitored_type() { + let (_bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config(AddressType::NativeSegwit, vec![]); + let node = setup_node(&chain_source, config, None); + + assert!(node.onchain_payment().new_address_for_type(AddressType::NativeSegwit).is_ok()); + assert!(node.onchain_payment().new_address_for_type(AddressType::Legacy).is_err()); + assert!(node.onchain_payment().new_address_for_type(AddressType::Taproot).is_err()); + + node.stop().unwrap(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_duplicate_monitor_types_deduplicated() { + let (_bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config( + AddressType::NativeSegwit, + vec![AddressType::Legacy, AddressType::Legacy, AddressType::Taproot], + ); + let node = setup_node(&chain_source, config, None); + + let monitored = node.list_monitored_address_types(); + assert_eq!( + monitored.len(), + 3, + "Duplicate Legacy should be deduplicated, got {:?}", + monitored + ); + + node.stop().unwrap(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_multi_wallet_persistence_across_restart() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = + node_config(AddressType::NativeSegwit, vec![AddressType::Legacy, AddressType::Taproot]); + let node = setup_node(&chain_source, config, None); + + let native_addr = node.onchain_payment().new_address().unwrap(); + let legacy_addr = node.onchain_payment().new_address_for_type(AddressType::Legacy).unwrap(); + let taproot_addr = + node.onchain_payment().new_address_for_type(AddressType::Taproot).unwrap(); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node, + vec![(native_addr, 100_000), (legacy_addr, 100_000), (taproot_addr, 100_000)], + ) + .await; + + let native_before = + node.get_balance_for_address_type(AddressType::NativeSegwit).unwrap().total_sats; + let legacy_before = + node.get_balance_for_address_type(AddressType::Legacy).unwrap().total_sats; + let taproot_before = + node.get_balance_for_address_type(AddressType::Taproot).unwrap().total_sats; + let total_before = node.list_balances().total_onchain_balance_sats; + + assert!(native_before >= 99_000); + assert!(legacy_before >= 99_000); + assert!(taproot_before >= 99_000); + + node.stop().unwrap(); + node.start().unwrap(); + + node.sync_wallets().unwrap(); + std::thread::sleep(std::time::Duration::from_secs(1)); + + assert_eq!( + node.get_balance_for_address_type(AddressType::NativeSegwit).unwrap().total_sats, + native_before + ); + assert_eq!( + node.get_balance_for_address_type(AddressType::Legacy).unwrap().total_sats, + legacy_before + ); + assert_eq!( + node.get_balance_for_address_type(AddressType::Taproot).unwrap().total_sats, + taproot_before + ); + assert_eq!(node.list_balances().total_onchain_balance_sats, total_before); + + let monitored = node.list_monitored_address_types(); + assert!(monitored.contains(&AddressType::NativeSegwit)); + assert!(monitored.contains(&AddressType::Legacy)); + assert!(monitored.contains(&AddressType::Taproot)); + + node.stop().unwrap(); + } +} + +// --------------------------------------------------------------------------- +// Electrum chain source +// --------------------------------------------------------------------------- +mod electrum_chain { + use ldk_node::config::AddressType; + + use crate::common::{setup_bitcoind_and_electrsd, setup_node, wait_for_tx, TestChainSource}; + use crate::helpers::{confirm_and_sync, fund_multiple_and_sync, node_config, test_recipient}; + + /// Electrum chain source: balance sync and send work with multi-address-types. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_electrum_sync_and_send() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Electrum(&electrsd); + + let config = node_config(AddressType::NativeSegwit, vec![AddressType::Legacy]); + let node = setup_node(&chain_source, config, None); + + let native_addr = node.onchain_payment().new_address().unwrap(); + let legacy_addr = node.onchain_payment().new_address_for_type(AddressType::Legacy).unwrap(); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node, + vec![(native_addr, 100_000), (legacy_addr, 100_000)], + ) + .await; + + let native_balance = node.get_balance_for_address_type(AddressType::NativeSegwit).unwrap(); + let legacy_balance = node.get_balance_for_address_type(AddressType::Legacy).unwrap(); + assert!(native_balance.total_sats >= 99_000); + assert!(legacy_balance.total_sats >= 99_000); + + let txid = node + .onchain_payment() + .send_to_address(&test_recipient(), 50_000, None, None) + .expect("Send with Electrum should succeed"); + + wait_for_tx(&electrsd.client, txid).await; + confirm_and_sync(&bitcoind, &electrsd, 1, &[&node]).await; + + let total_after = node.list_balances().total_onchain_balance_sats; + assert!(total_after < 200_000 - 50_000 + 5_000); + + node.stop().unwrap(); + } +} + +// --------------------------------------------------------------------------- +// Balance +// --------------------------------------------------------------------------- +mod balance { + use ldk_node::config::AddressType; + + use crate::common::{setup_bitcoind_and_electrsd, setup_node, wait_for_tx, TestChainSource}; + use crate::helpers::{ + confirm_and_sync, fund_and_sync, fund_multiple_and_sync, node_config, test_recipient, + }; + + // --- Balance sync & queries --- + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_sync_updates_all_wallet_balances() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config( + AddressType::NativeSegwit, + vec![AddressType::Legacy, AddressType::NestedSegwit, AddressType::Taproot], + ); + let node = setup_node(&chain_source, config, None); + + let native_addr = node.onchain_payment().new_address().unwrap(); + let legacy_addr = node.onchain_payment().new_address_for_type(AddressType::Legacy).unwrap(); + let nested_addr = + node.onchain_payment().new_address_for_type(AddressType::NestedSegwit).unwrap(); + let taproot_addr = + node.onchain_payment().new_address_for_type(AddressType::Taproot).unwrap(); + + // Verify all balances are 0 before funding. + assert_eq!( + node.get_balance_for_address_type(AddressType::NativeSegwit).unwrap().total_sats, + 0 + ); + assert_eq!(node.get_balance_for_address_type(AddressType::Legacy).unwrap().total_sats, 0); + assert_eq!( + node.get_balance_for_address_type(AddressType::NestedSegwit).unwrap().total_sats, + 0 + ); + assert_eq!(node.get_balance_for_address_type(AddressType::Taproot).unwrap().total_sats, 0); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node, + vec![ + (native_addr, 100_000), + (legacy_addr, 200_000), + (nested_addr, 300_000), + (taproot_addr, 400_000), + ], + ) + .await; + + let native_balance = node.get_balance_for_address_type(AddressType::NativeSegwit).unwrap(); + let legacy_balance = node.get_balance_for_address_type(AddressType::Legacy).unwrap(); + let nested_balance = node.get_balance_for_address_type(AddressType::NestedSegwit).unwrap(); + let taproot_balance = node.get_balance_for_address_type(AddressType::Taproot).unwrap(); + + assert!(native_balance.total_sats >= 99_000, "NativeSegwit: {}", native_balance.total_sats); + assert!(legacy_balance.total_sats >= 199_000, "Legacy: {}", legacy_balance.total_sats); + assert!( + nested_balance.total_sats >= 299_000, + "NestedSegwit: {}", + nested_balance.total_sats + ); + assert!(taproot_balance.total_sats >= 399_000, "Taproot: {}", taproot_balance.total_sats); + + let total = node.list_balances().total_onchain_balance_sats; + let expected_total = native_balance.total_sats + + legacy_balance.total_sats + + nested_balance.total_sats + + taproot_balance.total_sats; + assert_eq!(total, expected_total); + + node.stop().unwrap(); + } + + // --- Error cases --- + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_get_balance_for_unmonitored_type() { + let (_bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config(AddressType::NativeSegwit, vec![]); + let node = setup_node(&chain_source, config, None); + + assert!(node.get_balance_for_address_type(AddressType::NativeSegwit).is_ok()); + assert!(node.get_balance_for_address_type(AddressType::Legacy).is_err()); + assert!(node.get_balance_for_address_type(AddressType::Taproot).is_err()); + + node.stop().unwrap(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_spend_with_empty_monitored_wallet() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config(AddressType::NativeSegwit, vec![AddressType::Legacy]); + let node = setup_node(&chain_source, config, None); + + let native_addr = node.onchain_payment().new_address().unwrap(); + fund_and_sync(&bitcoind, &electrsd, &node, native_addr, 200_000).await; + + let native_balance = node.get_balance_for_address_type(AddressType::NativeSegwit).unwrap(); + let legacy_balance = node.get_balance_for_address_type(AddressType::Legacy).unwrap(); + assert!(native_balance.total_sats >= 199_000); + assert_eq!(legacy_balance.total_sats, 0); + + let result = node.onchain_payment().send_to_address(&test_recipient(), 50_000, None, None); + assert!(result.is_ok(), "Send should succeed using only NativeSegwit funds"); + + node.stop().unwrap(); + } + + /// Send fails with InsufficientFunds when total across all wallets is insufficient. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_insufficient_funds_returns_error() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = + node_config(AddressType::NativeSegwit, vec![AddressType::Legacy, AddressType::Taproot]); + let node = setup_node(&chain_source, config, None); + + let native_addr = node.onchain_payment().new_address().unwrap(); + let legacy_addr = node.onchain_payment().new_address_for_type(AddressType::Legacy).unwrap(); + let taproot_addr = + node.onchain_payment().new_address_for_type(AddressType::Taproot).unwrap(); + + // Total ~150k across all wallets. + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node, + vec![(native_addr, 50_000), (legacy_addr, 50_000), (taproot_addr, 50_000)], + ) + .await; + + let total = node.list_balances().total_onchain_balance_sats; + assert!(total < 200_000); + + let result = node.onchain_payment().send_to_address(&test_recipient(), 200_000, None, None); + assert!(result.is_err(), "Send should fail when total balance is insufficient"); + let err = result.unwrap_err(); + assert!( + matches!( + err, + ldk_node::NodeError::InsufficientFunds + | ldk_node::NodeError::OnchainTxCreationFailed + ), + "Expected InsufficientFunds or OnchainTxCreationFailed, got {:?}", + err + ); + + node.stop().unwrap(); + } + + // --- Balance consistency --- + + /// Asserts listBalances().spendableOnchainBalanceSats equals sum of listSpendableOutputs() values. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_list_balances_matches_utxo_sum() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config(AddressType::NativeSegwit, vec![AddressType::Taproot]); + let node = setup_node(&chain_source, config, None); + + let native_addr = node.onchain_payment().new_address().unwrap(); + let taproot_addr = + node.onchain_payment().new_address_for_type(AddressType::Taproot).unwrap(); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node, + vec![(native_addr, 100_000), (taproot_addr, 200_000)], + ) + .await; + + let balances = node.list_balances(); + let utxos = node.onchain_payment().list_spendable_outputs().unwrap(); + let utxo_sum: u64 = utxos.iter().map(|u| u.value_sats).sum(); + + assert_eq!( + balances.spendable_onchain_balance_sats, + utxo_sum, + "listBalances spendable should equal UTXO sum (spendable={} utxo_sum={})", + balances.spendable_onchain_balance_sats, + utxo_sum + ); + + node.stop().unwrap(); + } + + /// Asserts sum of per-type spendable equals listBalances().spendableOnchainBalanceSats. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_per_type_sum_equals_aggregate_spendable() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config( + AddressType::NativeSegwit, + vec![AddressType::Legacy, AddressType::Taproot], + ); + let node = setup_node(&chain_source, config, None); + + let native_addr = node.onchain_payment().new_address().unwrap(); + let legacy_addr = node.onchain_payment().new_address_for_type(AddressType::Legacy).unwrap(); + let taproot_addr = + node.onchain_payment().new_address_for_type(AddressType::Taproot).unwrap(); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node, + vec![ + (native_addr, 80_000), + (legacy_addr, 70_000), + (taproot_addr, 90_000), + ], + ) + .await; + + let aggregate_spendable = node.list_balances().spendable_onchain_balance_sats; + let per_type_sum: u64 = node + .list_monitored_address_types() + .iter() + .filter_map(|t| node.get_balance_for_address_type(*t).ok()) + .map(|b| b.spendable_sats) + .sum(); + + assert_eq!( + per_type_sum, + aggregate_spendable, + "Sum of per-type spendable should equal aggregate (per_type_sum={} aggregate={})", + per_type_sum, + aggregate_spendable + ); + + node.stop().unwrap(); + } + + /// Asserts no phantom balance after spend-all: listSpendableOutputs empty, total==0. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_no_phantom_balance_after_spend_all() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config(AddressType::NativeSegwit, vec![AddressType::Taproot]); + let node = setup_node(&chain_source, config, None); + + let native_addr = node.onchain_payment().new_address().unwrap(); + let taproot_addr = + node.onchain_payment().new_address_for_type(AddressType::Taproot).unwrap(); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node, + vec![(native_addr, 50_000), (taproot_addr, 50_000)], + ) + .await; + + let txid = node + .onchain_payment() + .send_all_to_address(&test_recipient(), false, None) + .expect("spend-all should succeed"); + + wait_for_tx(&electrsd.client, txid).await; + confirm_and_sync(&bitcoind, &electrsd, 1, &[&node]).await; + + // Poll a few times to catch sync timing issues + for _ in 0..5 { + let utxos = node.onchain_payment().list_spendable_outputs().unwrap(); + let balances = node.list_balances(); + + assert!( + utxos.is_empty(), + "listSpendableOutputs should be empty after spend-all" + ); + assert_eq!( + balances.total_onchain_balance_sats, + 0, + "total_onchain_balance_sats should be 0" + ); + assert_eq!( + balances.spendable_onchain_balance_sats, + 0, + "spendable_onchain_balance_sats should be 0" + ); + + std::thread::sleep(std::time::Duration::from_secs(1)); + } + + node.stop().unwrap(); + } + + /// Asserts spendable equals UTXO sum for a single-address-type wallet. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_balance_consistency_single_address_type() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config(AddressType::NativeSegwit, vec![]); + let node = setup_node(&chain_source, config, None); + + let addr = node.onchain_payment().new_address().unwrap(); + fund_and_sync(&bitcoind, &electrsd, &node, addr, 150_000).await; + + let balances = node.list_balances(); + let utxos = node.onchain_payment().list_spendable_outputs().unwrap(); + let utxo_sum: u64 = utxos.iter().map(|u| u.value_sats).sum(); + + assert_eq!( + balances.spendable_onchain_balance_sats, + utxo_sum, + "Single-type: spendable should equal UTXO sum" + ); + + node.stop().unwrap(); + } +} + +// --------------------------------------------------------------------------- +// Send +// --------------------------------------------------------------------------- +mod send { + use bitcoin::FeeRate; + use electrum_client::ElectrumApi; + use ldk_node::config::AddressType; + + use crate::common::{setup_bitcoind_and_electrsd, setup_node, wait_for_tx, TestChainSource}; + use crate::helpers::{ + confirm_and_sync, fund_and_sync, fund_multiple_and_sync, node_config, test_recipient, + }; + + // --- Basic & cross-wallet sends --- + + /// Basic send works for all primary address types. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_send_basic() { + let test_cases = vec![ + (AddressType::NativeSegwit, vec![AddressType::Legacy]), + (AddressType::Legacy, vec![AddressType::NativeSegwit, AddressType::Taproot]), + (AddressType::Taproot, vec![AddressType::NestedSegwit]), + ]; + + for (primary_type, monitored_types) in test_cases { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config(primary_type, monitored_types); + let node = setup_node(&chain_source, config, None); + + let addr = node.onchain_payment().new_address().unwrap(); + fund_and_sync(&bitcoind, &electrsd, &node, addr, 200_000).await; + + let balances = node.list_balances(); + assert!(balances.spendable_onchain_balance_sats >= 190_000); + + let txid = node + .onchain_payment() + .send_to_address(&test_recipient(), 50_000, None, None) + .expect("Send should succeed for all address type combinations"); + + wait_for_tx(&electrsd.client, txid).await; + confirm_and_sync(&bitcoind, &electrsd, 1, &[&node]).await; + + let new_balances = node.list_balances(); + assert!( + new_balances.spendable_onchain_balance_sats + < balances.spendable_onchain_balance_sats + ); + + node.stop().unwrap(); + } + } + + /// Send with custom fee rate across address types. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_send_with_custom_fee_rate() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config(AddressType::NativeSegwit, vec![AddressType::Legacy]); + let node = setup_node(&chain_source, config, None); + + let native_addr = node.onchain_payment().new_address().unwrap(); + let legacy_addr = node.onchain_payment().new_address_for_type(AddressType::Legacy).unwrap(); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node, + vec![(native_addr, 100_000), (legacy_addr, 100_000)], + ) + .await; + + let custom_fee_rate = bitcoin::FeeRate::from_sat_per_kwu(800); + let txid = node + .onchain_payment() + .send_to_address(&test_recipient(), 50_000, Some(custom_fee_rate), None) + .expect("Send with custom fee rate should succeed"); + + wait_for_tx(&electrsd.client, txid).await; + confirm_and_sync(&bitcoind, &electrsd, 1, &[&node]).await; + + let new_balance = node.list_balances().total_onchain_balance_sats; + assert!(new_balance < 200_000 - 50_000); + + node.stop().unwrap(); + } + + /// Send requiring UTXOs from two different wallet types. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_cross_wallet_spending() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config(AddressType::NativeSegwit, vec![AddressType::Legacy]); + let node = setup_node(&chain_source, config, None); + + let native_addr = node.onchain_payment().new_address().unwrap(); + let legacy_addr = node.onchain_payment().new_address_for_type(AddressType::Legacy).unwrap(); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node, + vec![(native_addr, 100_000), (legacy_addr, 100_000)], + ) + .await; + + let native_balance = node.get_balance_for_address_type(AddressType::NativeSegwit).unwrap(); + let legacy_balance = node.get_balance_for_address_type(AddressType::Legacy).unwrap(); + assert!(native_balance.total_sats >= 99_000); + assert!(legacy_balance.total_sats >= 99_000); + + // 150k > either wallet alone, requires both. + let txid = node + .onchain_payment() + .send_to_address(&test_recipient(), 150_000, None, None) + .expect("Cross-wallet spending should succeed"); + + wait_for_tx(&electrsd.client, txid).await; + confirm_and_sync(&bitcoind, &electrsd, 1, &[&node]).await; + + let monitored_types = node.list_monitored_address_types(); + assert!(monitored_types.contains(&AddressType::NativeSegwit)); + assert!(monitored_types.contains(&AddressType::Legacy)); + + node.stop().unwrap(); + } + + /// Send requiring UTXOs from all four wallet types. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_cross_wallet_spending_all_four_types() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config( + AddressType::NativeSegwit, + vec![AddressType::Legacy, AddressType::NestedSegwit, AddressType::Taproot], + ); + let node = setup_node(&chain_source, config, None); + + let native_addr = node.onchain_payment().new_address().unwrap(); + let legacy_addr = node.onchain_payment().new_address_for_type(AddressType::Legacy).unwrap(); + let nested_addr = + node.onchain_payment().new_address_for_type(AddressType::NestedSegwit).unwrap(); + let taproot_addr = + node.onchain_payment().new_address_for_type(AddressType::Taproot).unwrap(); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node, + vec![ + (native_addr, 60_000), + (legacy_addr, 60_000), + (nested_addr, 60_000), + (taproot_addr, 60_000), + ], + ) + .await; + + let total_before = node.list_balances().total_onchain_balance_sats; + + // 200k > any three wallets (180k), requires all four. + let txid = node + .onchain_payment() + .send_to_address(&test_recipient(), 200_000, None, None) + .expect("Cross-wallet spending with all 4 types should succeed"); + + wait_for_tx(&electrsd.client, txid).await; + + let tx = electrsd.client.transaction_get(&txid).unwrap(); + assert!(tx.input.len() >= 4, "Should use at least 4 inputs, got {}", tx.input.len()); + + confirm_and_sync(&bitcoind, &electrsd, 1, &[&node]).await; + + let total_after = node.list_balances().total_onchain_balance_sats; + assert!(total_after < total_before - 200_000 + 10_000); + + node.stop().unwrap(); + } + + // --- send_all --- + + /// send_all drains both wallets (retain_reserves=true). + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_send_all_drains_all_wallets() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config(AddressType::Taproot, vec![AddressType::NativeSegwit]); + let node = setup_node(&chain_source, config, None); + + let taproot_addr = node.onchain_payment().new_address().unwrap(); + let native_addr = + node.onchain_payment().new_address_for_type(AddressType::NativeSegwit).unwrap(); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node, + vec![(taproot_addr, 100_000), (native_addr, 100_000)], + ) + .await; + + assert!( + node.get_balance_for_address_type(AddressType::Taproot).unwrap().total_sats >= 99_000 + ); + assert!( + node.get_balance_for_address_type(AddressType::NativeSegwit).unwrap().total_sats + >= 99_000 + ); + + let txid = node + .onchain_payment() + .send_all_to_address(&test_recipient(), true, None) + .expect("send_all should succeed"); + + wait_for_tx(&electrsd.client, txid).await; + confirm_and_sync(&bitcoind, &electrsd, 1, &[&node]).await; + + assert!( + node.get_balance_for_address_type(AddressType::Taproot).unwrap().spendable_sats + < 10_000 + ); + assert!( + node.get_balance_for_address_type(AddressType::NativeSegwit).unwrap().spendable_sats + < 10_000 + ); + assert!(node.list_balances().total_onchain_balance_sats < 10_000); + + node.stop().unwrap(); + } + + /// send_all with retain_reserves=false drains every wallet to zero. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_send_all_drain_reserve_multi_wallet() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = + node_config(AddressType::NativeSegwit, vec![AddressType::Legacy, AddressType::Taproot]); + let node = setup_node(&chain_source, config, None); + + let native_addr = node.onchain_payment().new_address().unwrap(); + let legacy_addr = node.onchain_payment().new_address_for_type(AddressType::Legacy).unwrap(); + let taproot_addr = + node.onchain_payment().new_address_for_type(AddressType::Taproot).unwrap(); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node, + vec![(native_addr, 50_000), (legacy_addr, 50_000), (taproot_addr, 50_000)], + ) + .await; + + let total_before = node.list_balances().total_onchain_balance_sats; + assert!(total_before >= 140_000); + + let txid = node + .onchain_payment() + .send_all_to_address(&test_recipient(), false, None) + .expect("drain all should succeed"); + + wait_for_tx(&electrsd.client, txid).await; + confirm_and_sync(&bitcoind, &electrsd, 1, &[&node]).await; + + assert_eq!(node.list_balances().total_onchain_balance_sats, 0); + + let drain_tx = electrsd.client.transaction_get(&txid).unwrap(); + assert!( + drain_tx.input.len() >= 3, + "Should drain all 3 wallets, got {} inputs", + drain_tx.input.len() + ); + + node.stop().unwrap(); + } + + /// send_all with custom fee rate drains all wallets. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_send_all_with_custom_fee_rate() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config(AddressType::NativeSegwit, vec![AddressType::Legacy]); + let node = setup_node(&chain_source, config, None); + + let native_addr = node.onchain_payment().new_address().unwrap(); + let legacy_addr = node.onchain_payment().new_address_for_type(AddressType::Legacy).unwrap(); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node, + vec![(native_addr, 100_000), (legacy_addr, 100_000)], + ) + .await; + + let custom_fee_rate = bitcoin::FeeRate::from_sat_per_kwu(1000); + let txid = node + .onchain_payment() + .send_all_to_address(&test_recipient(), true, Some(custom_fee_rate)) + .expect("send_all with custom fee rate should succeed"); + + wait_for_tx(&electrsd.client, txid).await; + confirm_and_sync(&bitcoind, &electrsd, 1, &[&node]).await; + + assert!(node.list_balances().total_onchain_balance_sats < 10_000); + + node.stop().unwrap(); + } + + /// send_all works correctly when primary is Legacy. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_send_all_legacy_primary() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config(AddressType::Legacy, vec![AddressType::NativeSegwit]); + let node = setup_node(&chain_source, config, None); + + let legacy_addr = node.onchain_payment().new_address().unwrap(); + let segwit_addr = + node.onchain_payment().new_address_for_type(AddressType::NativeSegwit).unwrap(); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node, + vec![(legacy_addr, 100_000), (segwit_addr, 100_000)], + ) + .await; + + assert!( + node.get_balance_for_address_type(AddressType::Legacy).unwrap().total_sats >= 99_000 + ); + assert!( + node.get_balance_for_address_type(AddressType::NativeSegwit).unwrap().total_sats + >= 99_000 + ); + + let txid = node + .onchain_payment() + .send_all_to_address(&test_recipient(), true, None) + .expect("send_all with legacy primary should succeed"); + + wait_for_tx(&electrsd.client, txid).await; + confirm_and_sync(&bitcoind, &electrsd, 1, &[&node]).await; + + assert!( + node.get_balance_for_address_type(AddressType::Legacy).unwrap().spendable_sats < 10_000 + ); + assert!( + node.get_balance_for_address_type(AddressType::NativeSegwit).unwrap().spendable_sats + < 10_000 + ); + assert!(node.list_balances().total_onchain_balance_sats < 10_000); + + node.stop().unwrap(); + } + + // --- Manual UTXO selection --- + + /// Send using manually-selected UTXOs from different address types. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_send_with_specific_utxos_from_different_types() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = + node_config(AddressType::NativeSegwit, vec![AddressType::Legacy, AddressType::Taproot]); + let node = setup_node(&chain_source, config, None); + + let native_addr = node.onchain_payment().new_address().unwrap(); + let legacy_addr = node.onchain_payment().new_address_for_type(AddressType::Legacy).unwrap(); + let taproot_addr = + node.onchain_payment().new_address_for_type(AddressType::Taproot).unwrap(); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node, + vec![(native_addr, 50_000), (legacy_addr, 50_000), (taproot_addr, 50_000)], + ) + .await; + + let selected_utxos = node + .onchain_payment() + .select_utxos_with_algorithm( + 140_000, + Some(FeeRate::from_sat_per_kwu(500)), + ldk_node::CoinSelectionAlgorithm::LargestFirst, + None, + ) + .expect("Should select UTXOs from multiple wallet types"); + + assert!( + selected_utxos.len() >= 3, + "Should have at least 3 UTXOs, got {}", + selected_utxos.len() + ); + + let txid = node + .onchain_payment() + .send_to_address(&test_recipient(), 100_000, None, Some(selected_utxos)) + .expect("Send with specific UTXOs should succeed"); + + wait_for_tx(&electrsd.client, txid).await; + + let tx = electrsd.client.transaction_get(&txid).unwrap(); + assert!(tx.input.len() >= 3, "Tx should have at least 3 inputs, got {}", tx.input.len()); + + node.stop().unwrap(); + } +} + +// --------------------------------------------------------------------------- +// Channel Funding +// --------------------------------------------------------------------------- +mod channel_funding { + use electrum_client::ElectrumApi; + use ldk_node::config::AddressType; + use ldk_node::Event; + + use crate::common::{ + expect_channel_ready_event, expect_event, open_channel, setup_bitcoind_and_electrsd, + setup_node, wait_for_outpoint_spend, TestChainSource, + }; + use crate::helpers::{ + confirm_and_sync, fund_multiple_and_sync, fund_peer_node_and_sync, node_config, + CHANNEL_PEER_FUNDING_SATS, + }; + + // --- Cross-wallet & witness requirements --- + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_open_channel_with_cross_wallet_witness_inputs() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config_a = node_config( + AddressType::NativeSegwit, + vec![AddressType::Legacy, AddressType::NestedSegwit, AddressType::Taproot], + ); + let node_a = setup_node(&chain_source, config_a, None); + + let config_b = crate::common::random_config(true); + let node_b = setup_node(&chain_source, config_b, None); + + let native_addr = node_a.onchain_payment().new_address().unwrap(); + let legacy_addr = + node_a.onchain_payment().new_address_for_type(AddressType::Legacy).unwrap(); + let nested_addr = + node_a.onchain_payment().new_address_for_type(AddressType::NestedSegwit).unwrap(); + let taproot_addr = + node_a.onchain_payment().new_address_for_type(AddressType::Taproot).unwrap(); + + let addr_b = node_b.onchain_payment().new_address().unwrap(); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node_a, + vec![ + (native_addr, 55_000), + (legacy_addr, 55_000), + (nested_addr, 55_000), + (taproot_addr, 55_000), + ], + ) + .await; + + fund_peer_node_and_sync(&bitcoind, &electrsd, &node_b, addr_b, CHANNEL_PEER_FUNDING_SATS) + .await; + + // 120k > any two witness wallets (110k), needs all three. + let funding_txo = open_channel(&node_a, &node_b, 120_000, false, &electrsd).await; + + let funding_tx = electrsd.client.transaction_get(&funding_txo.txid).unwrap(); + assert!( + funding_tx.input.len() >= 3, + "Should have at least 3 witness inputs, got {}", + funding_tx.input.len() + ); + + confirm_and_sync(&bitcoind, &electrsd, 6, &[&node_a, &node_b]).await; + + assert!(!node_a.list_channels().is_empty()); + + // Legacy funds untouched. + let legacy_after = node_a.get_balance_for_address_type(AddressType::Legacy).unwrap(); + assert!(legacy_after.total_sats >= 54_000); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); + } + + /// Opening a channel with only non-witness outputs (Legacy or NestedSegwit) fails. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_open_channel_non_witness_only_returns_error() { + for primary_type in [AddressType::Legacy, AddressType::NestedSegwit] { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config_a = node_config(primary_type, vec![]); + let node_a = setup_node(&chain_source, config_a, None); + + let config_b = crate::common::random_config(true); + let node_b = setup_node(&chain_source, config_b, None); + + let addr_a = node_a.onchain_payment().new_address().unwrap(); + let addr_b = node_b.onchain_payment().new_address().unwrap(); + + fund_multiple_and_sync(&bitcoind, &electrsd, &node_a, vec![(addr_a, 500_000)]).await; + fund_peer_node_and_sync( + &bitcoind, + &electrsd, + &node_b, + addr_b, + CHANNEL_PEER_FUNDING_SATS, + ) + .await; + + let result = node_a.open_channel( + node_b.node_id(), + node_b.listening_addresses().unwrap().first().unwrap().clone(), + 100_000, + None, + None, + ); + + assert!(result.is_err(), "Opening with only {:?} should fail", primary_type); + // Legacy returns InsufficientFunds; NestedSegwit may return ChannelCreationFailed + let err = result.unwrap_err(); + assert!( + matches!( + err, + ldk_node::NodeError::InsufficientFunds + | ldk_node::NodeError::ChannelCreationFailed + ), + "Expected InsufficientFunds or ChannelCreationFailed, got {:?}", + err + ); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); + } + } + + // --- Legacy primary + Segwit --- + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_open_channel_legacy_primary_uses_segwit_wallet() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config_a = node_config(AddressType::Legacy, vec![AddressType::NativeSegwit]); + let node_a = setup_node(&chain_source, config_a, None); + + let config_b = crate::common::random_config(true); + let node_b = setup_node(&chain_source, config_b, None); + + let legacy_addr = node_a.onchain_payment().new_address().unwrap(); + let segwit_addr = + node_a.onchain_payment().new_address_for_type(AddressType::NativeSegwit).unwrap(); + let addr_b = node_b.onchain_payment().new_address().unwrap(); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node_a, + vec![(legacy_addr, 200_000), (segwit_addr, 200_000)], + ) + .await; + fund_peer_node_and_sync(&bitcoind, &electrsd, &node_b, addr_b, CHANNEL_PEER_FUNDING_SATS) + .await; + + let funding_txo = open_channel(&node_a, &node_b, 100_000, false, &electrsd).await; + + confirm_and_sync(&bitcoind, &electrsd, 6, &[&node_a, &node_b]).await; + + assert!(!node_a.list_channels().is_empty()); + + // Legacy untouched. + let legacy_balance = node_a.get_balance_for_address_type(AddressType::Legacy).unwrap(); + assert!(legacy_balance.total_sats >= 190_000); + + // NativeSegwit was used. + let segwit_balance = + node_a.get_balance_for_address_type(AddressType::NativeSegwit).unwrap(); + assert!(segwit_balance.total_sats < 190_000); + + // All funding inputs have witness data. + let funding_tx = electrsd.client.transaction_get(&funding_txo.txid).unwrap(); + for input in &funding_tx.input { + assert!( + !input.witness.is_empty(), + "Input {:?} has empty witness", + input.previous_output + ); + } + + node_a.stop().unwrap(); + node_b.stop().unwrap(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_legacy_primary_channel_funding_excludes_legacy_inputs() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config_a = + node_config(AddressType::Legacy, vec![AddressType::NativeSegwit, AddressType::Taproot]); + let node_a = setup_node(&chain_source, config_a, None); + + let config_b = crate::common::random_config(true); + let node_b = setup_node(&chain_source, config_b, None); + + let legacy_addr = node_a.onchain_payment().new_address().unwrap(); + let native_addr = + node_a.onchain_payment().new_address_for_type(AddressType::NativeSegwit).unwrap(); + let taproot_addr = + node_a.onchain_payment().new_address_for_type(AddressType::Taproot).unwrap(); + let addr_b = node_b.onchain_payment().new_address().unwrap(); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node_a, + vec![(legacy_addr, 200_000), (native_addr, 100_000), (taproot_addr, 100_000)], + ) + .await; + fund_peer_node_and_sync(&bitcoind, &electrsd, &node_b, addr_b, CHANNEL_PEER_FUNDING_SATS) + .await; + + // Legacy has 200k (enough alone) but must be excluded. + let funding_txo = open_channel(&node_a, &node_b, 120_000, false, &electrsd).await; + + let funding_tx = electrsd.client.transaction_get(&funding_txo.txid).unwrap(); + for input in &funding_tx.input { + assert!(!input.witness.is_empty(), "All funding inputs must have witness data"); + } + + // Legacy balance untouched. + node_a.sync_wallets().unwrap(); + let legacy_balance = node_a.get_balance_for_address_type(AddressType::Legacy).unwrap(); + assert!(legacy_balance.total_sats >= 190_000); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); + } + + // --- NestedSegwit primary --- + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_inbound_channel_with_non_native_witness_primary() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config_a = node_config(AddressType::Legacy, vec![AddressType::Taproot]); + let node_a = setup_node(&chain_source, config_a, None); + + let config_b = crate::common::random_config(true); + let node_b = setup_node(&chain_source, config_b, None); + + let addr_b = node_b.onchain_payment().new_address().unwrap(); + fund_peer_node_and_sync(&bitcoind, &electrsd, &node_b, addr_b, CHANNEL_PEER_FUNDING_SATS) + .await; + node_a.sync_wallets().unwrap(); + + // Node B opens toward node A (inbound). get_shutdown_scriptpubkey must + // produce a native witness address from Taproot, not Legacy. + let _funding_txo = open_channel(&node_b, &node_a, 100_000, false, &electrsd).await; + + confirm_and_sync(&bitcoind, &electrsd, 6, &[&node_a, &node_b]).await; + + assert!(!node_a.list_channels().is_empty(), "Node A should have an inbound channel"); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_open_channel_nested_primary_with_native_monitor() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config_a = node_config(AddressType::NestedSegwit, vec![AddressType::NativeSegwit]); + let node_a = setup_node(&chain_source, config_a, None); + + let config_b = crate::common::random_config(true); + let node_b = setup_node(&chain_source, config_b, None); + + let nested_addr = node_a.onchain_payment().new_address().unwrap(); + let addr_b = node_b.onchain_payment().new_address().unwrap(); + + fund_multiple_and_sync(&bitcoind, &electrsd, &node_a, vec![(nested_addr, 300_000)]).await; + fund_peer_node_and_sync(&bitcoind, &electrsd, &node_b, addr_b, CHANNEL_PEER_FUNDING_SATS) + .await; + + let funding_txo = open_channel(&node_a, &node_b, 100_000, false, &electrsd).await; + + confirm_and_sync(&bitcoind, &electrsd, 6, &[&node_a, &node_b]).await; + + assert!(!node_a.list_channels().is_empty()); + + let funding_tx = electrsd.client.transaction_get(&funding_txo.txid).unwrap(); + for input in &funding_tx.input { + assert!(!input.witness.is_empty(), "All inputs must have witness data"); + } + + node_a.stop().unwrap(); + node_b.stop().unwrap(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_open_channel_nested_primary_mixed_inputs() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config_a = node_config(AddressType::NestedSegwit, vec![AddressType::NativeSegwit]); + let node_a = setup_node(&chain_source, config_a, None); + + let config_b = crate::common::random_config(true); + let node_b = setup_node(&chain_source, config_b, None); + + // Fund NestedSegwit with 80k — not enough for 120k channel. + let nested_addr = node_a.onchain_payment().new_address().unwrap(); + let native_addr = + node_a.onchain_payment().new_address_for_type(AddressType::NativeSegwit).unwrap(); + let addr_b = node_b.onchain_payment().new_address().unwrap(); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node_a, + vec![(nested_addr, 80_000), (native_addr, 80_000)], + ) + .await; + fund_peer_node_and_sync(&bitcoind, &electrsd, &node_b, addr_b, CHANNEL_PEER_FUNDING_SATS) + .await; + + let funding_txo = open_channel(&node_a, &node_b, 120_000, false, &electrsd).await; + + confirm_and_sync(&bitcoind, &electrsd, 6, &[&node_a, &node_b]).await; + + assert!(!node_a.list_channels().is_empty()); + + let funding_tx = electrsd.client.transaction_get(&funding_txo.txid).unwrap(); + assert!(funding_tx.input.len() >= 2, "Should use inputs from both wallets"); + for input in &funding_tx.input { + assert!(!input.witness.is_empty()); + } + + node_a.stop().unwrap(); + node_b.stop().unwrap(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_open_channel_native_primary_with_nested_inputs() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config_a = node_config(AddressType::NativeSegwit, vec![AddressType::NestedSegwit]); + let node_a = setup_node(&chain_source, config_a, None); + + let config_b = crate::common::random_config(true); + let node_b = setup_node(&chain_source, config_b, None); + + let native_addr = node_a.onchain_payment().new_address().unwrap(); + let nested_addr = + node_a.onchain_payment().new_address_for_type(AddressType::NestedSegwit).unwrap(); + let addr_b = node_b.onchain_payment().new_address().unwrap(); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node_a, + vec![(native_addr, 80_000), (nested_addr, 80_000)], + ) + .await; + fund_peer_node_and_sync(&bitcoind, &electrsd, &node_b, addr_b, CHANNEL_PEER_FUNDING_SATS) + .await; + + let funding_txo = open_channel(&node_a, &node_b, 120_000, false, &electrsd).await; + + confirm_and_sync(&bitcoind, &electrsd, 6, &[&node_a, &node_b]).await; + + assert!(!node_a.list_channels().is_empty()); + + let funding_tx = electrsd.client.transaction_get(&funding_txo.txid).unwrap(); + assert!(funding_tx.input.len() >= 2, "Should use inputs from both wallets"); + for input in &funding_tx.input { + assert!(!input.witness.is_empty()); + } + + node_a.stop().unwrap(); + node_b.stop().unwrap(); + } + + // --- Channel close --- + + /// Cooperative channel close: verify funds return to the correct address type. + /// With Legacy primary + NativeSegwit monitored, channel scripts come from + /// NativeSegwit. On close, the output should land in NativeSegwit and show + /// in that wallet's balance. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_channel_close_funds_return_to_correct_address_type() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + // Legacy primary, NativeSegwit monitored — channel scripts from NativeSegwit. + let config_a = node_config(AddressType::Legacy, vec![AddressType::NativeSegwit]); + let node_a = setup_node(&chain_source, config_a, None); + + let config_b = crate::common::random_config(true); + let node_b = setup_node(&chain_source, config_b, None); + + let legacy_addr = node_a.onchain_payment().new_address().unwrap(); + let segwit_addr = + node_a.onchain_payment().new_address_for_type(AddressType::NativeSegwit).unwrap(); + let addr_b = node_b.onchain_payment().new_address().unwrap(); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node_a, + vec![(legacy_addr, 200_000), (segwit_addr, 200_000)], + ) + .await; + fund_peer_node_and_sync(&bitcoind, &electrsd, &node_b, addr_b, CHANNEL_PEER_FUNDING_SATS) + .await; + + let funding_txo = open_channel(&node_a, &node_b, 100_000, false, &electrsd).await; + + confirm_and_sync(&bitcoind, &electrsd, 6, &[&node_a, &node_b]).await; + + // NativeSegwit balance after open (channel holds 100k; we spent it from NativeSegwit). + let segwit_after_open = + node_a.get_balance_for_address_type(AddressType::NativeSegwit).unwrap().total_sats; + + let user_channel_id = expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + // Cooperative close. + node_a.close_channel(&user_channel_id, node_b.node_id()).unwrap(); + expect_event!(node_a, ChannelClosed); + expect_event!(node_b, ChannelClosed); + + wait_for_outpoint_spend(&electrsd.client, funding_txo).await; + confirm_and_sync(&bitcoind, &electrsd, 1, &[&node_a, &node_b]).await; + + // Close output should land in NativeSegwit (shutdown script). + let segwit_after = + node_a.get_balance_for_address_type(AddressType::NativeSegwit).unwrap().total_sats; + + // NativeSegwit should increase after close (channel value returned minus closing fee). + assert!( + segwit_after > segwit_after_open, + "NativeSegwit balance should increase after close (after_open: {}, after_close: {})", + segwit_after_open, + segwit_after + ); + // Legacy should be untouched. + let legacy_after = node_a.get_balance_for_address_type(AddressType::Legacy).unwrap(); + assert!(legacy_after.total_sats >= 199_000, "Legacy balance should be untouched"); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); + } + + /// Cooperative channel close: verify funds return to Taproot when primary is Taproot. + /// With Taproot primary, get_shutdown_scriptpubkey returns a Taproot address. + /// On close, the output should land in Taproot and show in that wallet's balance. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_channel_close_funds_return_to_taproot() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + // Taproot primary — channel scripts and shutdown from Taproot. + let config_a = node_config(AddressType::Taproot, vec![AddressType::NativeSegwit]); + let node_a = setup_node(&chain_source, config_a, None); + + let config_b = crate::common::random_config(true); + let node_b = setup_node(&chain_source, config_b, None); + + let taproot_addr = node_a.onchain_payment().new_address().unwrap(); + let segwit_addr = + node_a.onchain_payment().new_address_for_type(AddressType::NativeSegwit).unwrap(); + let addr_b = node_b.onchain_payment().new_address().unwrap(); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node_a, + vec![(taproot_addr, 200_000), (segwit_addr, 200_000)], + ) + .await; + fund_peer_node_and_sync(&bitcoind, &electrsd, &node_b, addr_b, CHANNEL_PEER_FUNDING_SATS) + .await; + + let funding_txo = open_channel(&node_a, &node_b, 100_000, false, &electrsd).await; + + confirm_and_sync(&bitcoind, &electrsd, 6, &[&node_a, &node_b]).await; + + // Taproot balance after open (channel holds 100k; we spent it from Taproot). + let taproot_after_open = + node_a.get_balance_for_address_type(AddressType::Taproot).unwrap().total_sats; + + let user_channel_id = expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + node_a.close_channel(&user_channel_id, node_b.node_id()).unwrap(); + expect_event!(node_a, ChannelClosed); + expect_event!(node_b, ChannelClosed); + + wait_for_outpoint_spend(&electrsd.client, funding_txo).await; + confirm_and_sync(&bitcoind, &electrsd, 1, &[&node_a, &node_b]).await; + + // Close output should land in Taproot (shutdown script). + let taproot_after = + node_a.get_balance_for_address_type(AddressType::Taproot).unwrap().total_sats; + + assert!( + taproot_after > taproot_after_open, + "Taproot balance should increase after close (after_open: {}, after_close: {})", + taproot_after_open, + taproot_after + ); + // Close output went to Taproot; NativeSegwit should not have gained it. + let segwit_after_open = + node_a.get_balance_for_address_type(AddressType::NativeSegwit).unwrap().total_sats; + let segwit_after = + node_a.get_balance_for_address_type(AddressType::NativeSegwit).unwrap().total_sats; + assert!( + segwit_after <= segwit_after_open + 1000, + "Close output should not land in NativeSegwit (after_open: {}, after_close: {})", + segwit_after_open, + segwit_after + ); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); + } + + /// Cooperative channel close: verify funds return to NativeSegwit when primary is NativeSegwit. + /// With NativeSegwit primary, get_shutdown_scriptpubkey returns a NativeSegwit address. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_channel_close_funds_return_to_native_segwit() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config_a = node_config(AddressType::NativeSegwit, vec![AddressType::Legacy]); + let node_a = setup_node(&chain_source, config_a, None); + + let config_b = crate::common::random_config(true); + let node_b = setup_node(&chain_source, config_b, None); + + let segwit_addr = node_a.onchain_payment().new_address().unwrap(); + let legacy_addr = + node_a.onchain_payment().new_address_for_type(AddressType::Legacy).unwrap(); + let addr_b = node_b.onchain_payment().new_address().unwrap(); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node_a, + vec![(segwit_addr, 200_000), (legacy_addr, 200_000)], + ) + .await; + fund_peer_node_and_sync(&bitcoind, &electrsd, &node_b, addr_b, CHANNEL_PEER_FUNDING_SATS) + .await; + + let funding_txo = open_channel(&node_a, &node_b, 100_000, false, &electrsd).await; + + confirm_and_sync(&bitcoind, &electrsd, 6, &[&node_a, &node_b]).await; + + let segwit_after_open = + node_a.get_balance_for_address_type(AddressType::NativeSegwit).unwrap().total_sats; + + let user_channel_id = expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + node_a.close_channel(&user_channel_id, node_b.node_id()).unwrap(); + expect_event!(node_a, ChannelClosed); + expect_event!(node_b, ChannelClosed); + + wait_for_outpoint_spend(&electrsd.client, funding_txo).await; + confirm_and_sync(&bitcoind, &electrsd, 1, &[&node_a, &node_b]).await; + + let segwit_after = + node_a.get_balance_for_address_type(AddressType::NativeSegwit).unwrap().total_sats; + + assert!( + segwit_after > segwit_after_open, + "NativeSegwit balance should increase after close (after_open: {}, after_close: {})", + segwit_after_open, + segwit_after + ); + let legacy_after = node_a.get_balance_for_address_type(AddressType::Legacy).unwrap(); + assert!(legacy_after.total_sats >= 199_000, "Legacy balance should be untouched"); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); + } + + /// Cooperative channel close: NestedSegwit primary uses monitored NativeSegwit for shutdown. + /// Verify funds return to NativeSegwit (monitored native witness) on close. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_channel_close_nested_primary_funds_return_to_native_segwit() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + // NestedSegwit primary cannot use its own script for shutdown; uses monitored NativeSegwit. + let config_a = node_config(AddressType::NestedSegwit, vec![AddressType::NativeSegwit]); + let node_a = setup_node(&chain_source, config_a, None); + + let config_b = crate::common::random_config(true); + let node_b = setup_node(&chain_source, config_b, None); + + let nested_addr = node_a.onchain_payment().new_address().unwrap(); + let segwit_addr = + node_a.onchain_payment().new_address_for_type(AddressType::NativeSegwit).unwrap(); + let addr_b = node_b.onchain_payment().new_address().unwrap(); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node_a, + vec![(nested_addr, 200_000), (segwit_addr, 200_000)], + ) + .await; + fund_peer_node_and_sync(&bitcoind, &electrsd, &node_b, addr_b, CHANNEL_PEER_FUNDING_SATS) + .await; + + let funding_txo = open_channel(&node_a, &node_b, 100_000, false, &electrsd).await; + + confirm_and_sync(&bitcoind, &electrsd, 6, &[&node_a, &node_b]).await; + + let segwit_after_open = + node_a.get_balance_for_address_type(AddressType::NativeSegwit).unwrap().total_sats; + + let user_channel_id = expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + node_a.close_channel(&user_channel_id, node_b.node_id()).unwrap(); + expect_event!(node_a, ChannelClosed); + expect_event!(node_b, ChannelClosed); + + wait_for_outpoint_spend(&electrsd.client, funding_txo).await; + confirm_and_sync(&bitcoind, &electrsd, 1, &[&node_a, &node_b]).await; + + let segwit_after = + node_a.get_balance_for_address_type(AddressType::NativeSegwit).unwrap().total_sats; + + assert!( + segwit_after > segwit_after_open, + "NativeSegwit (shutdown script) should increase after close (after_open: {}, after_close: {})", + segwit_after_open, + segwit_after + ); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); + } +} + +// --------------------------------------------------------------------------- +// RBF +// --------------------------------------------------------------------------- +mod rbf { + use bitcoin::FeeRate; + use electrum_client::ElectrumApi; + use ldk_node::config::AddressType; + + use crate::common::{setup_bitcoind_and_electrsd, setup_node, wait_for_tx, TestChainSource}; + use crate::helpers::{ + confirm_and_sync, fake_txid_for_error_tests, fund_and_sync, fund_multiple_and_sync, + node_config, test_recipient, + }; + + // --- Success cases --- + + /// RBF on a transaction using inputs from only the primary wallet. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_rbf_single_wallet() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config(AddressType::NativeSegwit, vec![AddressType::Legacy]); + let node = setup_node(&chain_source, config, None); + + let native_addr = node.onchain_payment().new_address().unwrap(); + let legacy_addr = node.onchain_payment().new_address_for_type(AddressType::Legacy).unwrap(); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node, + vec![(native_addr, 300_000), (legacy_addr, 100_000)], + ) + .await; + + // Send a smaller amount that can be satisfied by primary alone. + let initial_txid = node + .onchain_payment() + .send_to_address(&test_recipient(), 50_000, None, None) + .expect("Initial send should succeed"); + + wait_for_tx(&electrsd.client, initial_txid).await; + node.sync_wallets().unwrap(); + std::thread::sleep(std::time::Duration::from_millis(500)); + + let higher_fee_rate = FeeRate::from_sat_per_kwu(1000); + let rbf_txid = node + .onchain_payment() + .bump_fee_by_rbf(&initial_txid, higher_fee_rate) + .expect("RBF should succeed"); + + assert_ne!(initial_txid, rbf_txid); + wait_for_tx(&electrsd.client, rbf_txid).await; + + node.stop().unwrap(); + } + + /// RBF on a cross-wallet transaction (change output reduction). + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_rbf_cross_wallet_transaction() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config(AddressType::NativeSegwit, vec![AddressType::Legacy]); + let node = setup_node(&chain_source, config, None); + + let native_addr = node.onchain_payment().new_address().unwrap(); + let legacy_addr = node.onchain_payment().new_address_for_type(AddressType::Legacy).unwrap(); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node, + vec![(native_addr, 100_000), (legacy_addr, 100_000)], + ) + .await; + + // 120k requires both wallets. + let initial_txid = node + .onchain_payment() + .send_to_address(&test_recipient(), 120_000, None, None) + .expect("Initial cross-wallet send should succeed"); + + wait_for_tx(&electrsd.client, initial_txid).await; + node.sync_wallets().unwrap(); + std::thread::sleep(std::time::Duration::from_secs(1)); + + let total_before_rbf = node.list_balances().total_onchain_balance_sats; + + let higher_fee_rate = FeeRate::from_sat_per_kwu(2000); + let rbf_txid = node + .onchain_payment() + .bump_fee_by_rbf(&initial_txid, higher_fee_rate) + .expect("Cross-wallet RBF should succeed"); + + assert_ne!(initial_txid, rbf_txid); + wait_for_tx(&electrsd.client, rbf_txid).await; + node.sync_wallets().unwrap(); + std::thread::sleep(std::time::Duration::from_secs(1)); + + // Confirm replacement; balance check after confirm avoids sync timing flakiness. + confirm_and_sync(&bitcoind, &electrsd, 1, &[&node]).await; + + let total_after_rbf = node.list_balances().total_onchain_balance_sats; + assert!( + total_after_rbf < total_before_rbf, + "Balance should decrease after RBF (higher fee): before={} after={}", + total_before_rbf, + total_after_rbf + ); + + node.stop().unwrap(); + } + + /// RBF on a transaction with inputs from all four address types. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_rbf_cross_wallet_all_four_types() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config( + AddressType::NativeSegwit, + vec![AddressType::Legacy, AddressType::NestedSegwit, AddressType::Taproot], + ); + let node = setup_node(&chain_source, config, None); + + let native_addr = node.onchain_payment().new_address().unwrap(); + let legacy_addr = node.onchain_payment().new_address_for_type(AddressType::Legacy).unwrap(); + let nested_addr = + node.onchain_payment().new_address_for_type(AddressType::NestedSegwit).unwrap(); + let taproot_addr = + node.onchain_payment().new_address_for_type(AddressType::Taproot).unwrap(); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node, + vec![ + (native_addr, 80_000), + (legacy_addr, 80_000), + (nested_addr, 80_000), + (taproot_addr, 80_000), + ], + ) + .await; + + // 280k requires all four. + let initial_txid = node + .onchain_payment() + .send_to_address(&test_recipient(), 280_000, None, None) + .expect("Initial cross-wallet send should succeed"); + + wait_for_tx(&electrsd.client, initial_txid).await; + + let initial_tx = electrsd.client.transaction_get(&initial_txid).unwrap(); + assert!(initial_tx.input.len() >= 4); + + node.sync_wallets().unwrap(); + std::thread::sleep(std::time::Duration::from_secs(1)); + + let total_before_rbf = node.list_balances().total_onchain_balance_sats; + + let rbf_txid = node + .onchain_payment() + .bump_fee_by_rbf(&initial_txid, FeeRate::from_sat_per_kwu(2000)) + .expect("RBF with all 4 types should succeed"); + + assert_ne!(initial_txid, rbf_txid); + wait_for_tx(&electrsd.client, rbf_txid).await; + + let rbf_tx = electrsd.client.transaction_get(&rbf_txid).unwrap(); + assert!(rbf_tx.input.len() >= 4); + + confirm_and_sync(&bitcoind, &electrsd, 1, &[&node]).await; + + assert!(node.list_balances().total_onchain_balance_sats < total_before_rbf); + + node.stop().unwrap(); + } + + /// RBF that needs additional inputs verifies fee accounting. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_rbf_additional_inputs_fee_rate_correctness() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config(AddressType::NativeSegwit, vec![]); + let node = setup_node(&chain_source, config, None); + + let addr1 = node.onchain_payment().new_address().unwrap(); + let addr2 = node.onchain_payment().new_address().unwrap(); + let addr3 = node.onchain_payment().new_address().unwrap(); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node, + vec![(addr1, 50_000), (addr2, 50_000), (addr3, 50_000)], + ) + .await; + + let initial_balance = node.list_balances().total_onchain_balance_sats; + assert!(initial_balance >= 147_000); + + // Send 30k — uses 1 UTXO, ~20k change. + let initial_txid = node + .onchain_payment() + .send_to_address(&test_recipient(), 30_000, None, None) + .expect("Initial send should succeed"); + + wait_for_tx(&electrsd.client, initial_txid).await; + node.sync_wallets().unwrap(); + std::thread::sleep(std::time::Duration::from_secs(1)); + + // Very high fee forces adding inputs. + let high_fee_rate = FeeRate::from_sat_per_kwu(5000); + let rbf_txid = node + .onchain_payment() + .bump_fee_by_rbf(&initial_txid, high_fee_rate) + .expect("RBF with added inputs should succeed"); + + assert_ne!(initial_txid, rbf_txid); + wait_for_tx(&electrsd.client, rbf_txid).await; + + confirm_and_sync(&bitcoind, &electrsd, 1, &[&node]).await; + + let final_balance = node.list_balances().total_onchain_balance_sats; + let fee_paid = initial_balance - final_balance - 30_000; + assert!(fee_paid < 15_000, "Fee {} too high", fee_paid); + assert!(fee_paid > 1_000, "Fee {} too low", fee_paid); + + node.stop().unwrap(); + } + + /// RBF adds inputs from a different address-type wallet when change is insufficient. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_rbf_adds_inputs_from_different_address_type() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config(AddressType::NativeSegwit, vec![AddressType::Legacy]); + let node = setup_node(&chain_source, config, None); + + let native_addr = node.onchain_payment().new_address().unwrap(); + let legacy_addr = node.onchain_payment().new_address_for_type(AddressType::Legacy).unwrap(); + + // NativeSegwit: just enough for 50k send with small change. + // Legacy: available for RBF fee bumping. + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node, + vec![(native_addr, 55_000), (legacy_addr, 60_000)], + ) + .await; + + let initial_txid = node + .onchain_payment() + .send_to_address(&test_recipient(), 50_000, None, None) + .expect("Initial send should succeed"); + + wait_for_tx(&electrsd.client, initial_txid).await; + + let initial_tx = electrsd.client.transaction_get(&initial_txid).unwrap(); + assert_eq!(initial_tx.input.len(), 1); + + node.sync_wallets().unwrap(); + std::thread::sleep(std::time::Duration::from_secs(1)); + + // Extremely high fee rate to exhaust the small change. + let high_fee_rate = FeeRate::from_sat_per_kwu(50_000); + let rbf_txid = node + .onchain_payment() + .bump_fee_by_rbf(&initial_txid, high_fee_rate) + .expect("RBF should add Legacy input"); + + assert_ne!(initial_txid, rbf_txid); + wait_for_tx(&electrsd.client, rbf_txid).await; + + let rbf_tx = electrsd.client.transaction_get(&rbf_txid).unwrap(); + assert!(rbf_tx.input.len() > initial_tx.input.len()); + + confirm_and_sync(&bitcoind, &electrsd, 1, &[&node]).await; + + node.stop().unwrap(); + } + + /// Cross-wallet RBF adds extra inputs when change cannot absorb fee increase. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_cross_wallet_rbf_adds_extra_inputs_when_needed() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config(AddressType::NativeSegwit, vec![AddressType::Legacy]); + let node = setup_node(&chain_source, config, None); + + let native_addr = node.onchain_payment().new_address().unwrap(); + let legacy_addr1 = + node.onchain_payment().new_address_for_type(AddressType::Legacy).unwrap(); + let legacy_addr2 = + node.onchain_payment().new_address_for_type(AddressType::Legacy).unwrap(); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node, + vec![(native_addr, 60_000), (legacy_addr1, 60_000), (legacy_addr2, 60_000)], + ) + .await; + + // 115k requires primary (60k) + 1 Legacy (60k) = 120k. + let txid = + node.onchain_payment().send_to_address(&test_recipient(), 115_000, None, None).unwrap(); + wait_for_tx(&electrsd.client, txid).await; + node.sync_wallets().unwrap(); + std::thread::sleep(std::time::Duration::from_secs(1)); + + let initial_tx = electrsd.client.transaction_get(&txid).unwrap(); + assert_eq!(initial_tx.input.len(), 2); + + // High fee forces adding the remaining Legacy UTXO. + let rbf_txid = node + .onchain_payment() + .bump_fee_by_rbf(&txid, FeeRate::from_sat_per_kwu(25_000)) + .expect("Cross-wallet RBF with extra input should succeed"); + + wait_for_tx(&electrsd.client, rbf_txid).await; + + let rbf_tx = electrsd.client.transaction_get(&rbf_txid).unwrap(); + assert!(rbf_tx.input.len() > initial_tx.input.len()); + + node.stop().unwrap(); + } + + /// RBF with a Legacy primary wallet. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_rbf_legacy_primary() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config(AddressType::Legacy, vec![AddressType::NativeSegwit]); + let node = setup_node(&chain_source, config, None); + + let legacy_addr = node.onchain_payment().new_address().unwrap(); + fund_and_sync(&bitcoind, &electrsd, &node, legacy_addr, 300_000).await; + + let initial_txid = node + .onchain_payment() + .send_to_address(&test_recipient(), 50_000, None, None) + .expect("Send from Legacy should succeed"); + + wait_for_tx(&electrsd.client, initial_txid).await; + node.sync_wallets().unwrap(); + std::thread::sleep(std::time::Duration::from_millis(500)); + + let rbf_txid = node + .onchain_payment() + .bump_fee_by_rbf(&initial_txid, FeeRate::from_sat_per_kwu(1000)) + .expect("RBF should succeed for Legacy primary"); + + assert_ne!(initial_txid, rbf_txid); + wait_for_tx(&electrsd.client, rbf_txid).await; + + node.stop().unwrap(); + } + + // --- Error cases --- + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_rbf_rejects_lower_fee_rate() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config(AddressType::NativeSegwit, vec![]); + let node = setup_node(&chain_source, config, None); + + let addr = node.onchain_payment().new_address().unwrap(); + fund_and_sync(&bitcoind, &electrsd, &node, addr, 200_000).await; + + let initial_fee_rate = FeeRate::from_sat_per_kwu(2000); + let txid = node + .onchain_payment() + .send_to_address(&test_recipient(), 50_000, Some(initial_fee_rate), None) + .unwrap(); + + wait_for_tx(&electrsd.client, txid).await; + node.sync_wallets().unwrap(); + + let result = node.onchain_payment().bump_fee_by_rbf(&txid, FeeRate::from_sat_per_kwu(1000)); + assert!(result.is_err()); + + node.stop().unwrap(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_rbf_rejects_confirmed_transaction() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config(AddressType::NativeSegwit, vec![]); + let node = setup_node(&chain_source, config, None); + + let addr = node.onchain_payment().new_address().unwrap(); + fund_and_sync(&bitcoind, &electrsd, &node, addr, 200_000).await; + + let txid = + node.onchain_payment().send_to_address(&test_recipient(), 50_000, None, None).unwrap(); + + wait_for_tx(&electrsd.client, txid).await; + confirm_and_sync(&bitcoind, &electrsd, 1, &[&node]).await; + + let result = node.onchain_payment().bump_fee_by_rbf(&txid, FeeRate::from_sat_per_kwu(5000)); + assert!(result.is_err()); + + node.stop().unwrap(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_rbf_rejects_unknown_transaction() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config(AddressType::NativeSegwit, vec![]); + let node = setup_node(&chain_source, config, None); + + let addr = node.onchain_payment().new_address().unwrap(); + fund_and_sync(&bitcoind, &electrsd, &node, addr, 100_000).await; + + let result = node + .onchain_payment() + .bump_fee_by_rbf(&fake_txid_for_error_tests(), FeeRate::from_sat_per_kwu(2000)); + assert!(result.is_err()); + + node.stop().unwrap(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_rbf_insufficient_funds_for_fee_bump() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config(AddressType::NativeSegwit, vec![]); + let node = setup_node(&chain_source, config, None); + + let addr = node.onchain_payment().new_address().unwrap(); + fund_and_sync(&bitcoind, &electrsd, &node, addr, 51_000).await; + + let txid = + node.onchain_payment().send_to_address(&test_recipient(), 50_000, None, None).unwrap(); + wait_for_tx(&electrsd.client, txid).await; + node.sync_wallets().unwrap(); + + let result = + node.onchain_payment().bump_fee_by_rbf(&txid, FeeRate::from_sat_per_kwu(100_000)); + assert!(result.is_err()); + + node.stop().unwrap(); + } + + /// RBF on send_all (no change) must not reduce the recipient amount. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_rbf_send_all_no_change_does_not_reduce_recipient() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config(AddressType::NativeSegwit, vec![AddressType::Legacy]); + let node = setup_node(&chain_source, config, None); + + let addr = node.onchain_payment().new_address().unwrap(); + fund_and_sync(&bitcoind, &electrsd, &node, addr, 100_000).await; + + let txid = + node.onchain_payment().send_all_to_address(&test_recipient(), true, None).unwrap(); + wait_for_tx(&electrsd.client, txid).await; + node.sync_wallets().unwrap(); + std::thread::sleep(std::time::Duration::from_millis(500)); + + let result = node.onchain_payment().bump_fee_by_rbf(&txid, FeeRate::from_sat_per_kwu(5000)); + assert!(result.is_err(), "RBF on send-all (no change) should fail"); + + node.stop().unwrap(); + } +} + +// --------------------------------------------------------------------------- +// CPFP +// --------------------------------------------------------------------------- +mod cpfp { + use bitcoin::FeeRate; + use electrum_client::ElectrumApi; + use ldk_node::config::AddressType; + + use crate::common::{setup_bitcoind_and_electrsd, setup_node, wait_for_tx, TestChainSource}; + use crate::helpers::{ + confirm_and_sync, fake_txid_for_error_tests, fund_and_sync, fund_multiple_and_sync, + node_config, test_recipient, + }; + + // --- Success cases --- + + /// Single-wallet CPFP with fee rate calculation. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_cpfp_single_wallet() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config( + AddressType::NestedSegwit, + vec![AddressType::Legacy, AddressType::NativeSegwit, AddressType::Taproot], + ); + let node = setup_node(&chain_source, config, None); + + let addr = node.onchain_payment().new_address().unwrap(); + fund_and_sync(&bitcoind, &electrsd, &node, addr, 400_000).await; + + let parent_txid = + node.onchain_payment().send_to_address(&test_recipient(), 60_000, None, None).unwrap(); + + wait_for_tx(&electrsd.client, parent_txid).await; + node.sync_wallets().unwrap(); + std::thread::sleep(std::time::Duration::from_millis(500)); + + let cpfp_fee_rate = FeeRate::from_sat_per_kwu(1500); + let cpfp_txid = node + .onchain_payment() + .accelerate_by_cpfp(&parent_txid, Some(cpfp_fee_rate), None) + .unwrap(); + + assert_ne!(parent_txid, cpfp_txid); + + let calculated_fee_rate = + node.onchain_payment().calculate_cpfp_fee_rate(&parent_txid, false).unwrap(); + assert!(calculated_fee_rate.to_sat_per_kwu() > 0); + } + + /// CPFP on a cross-wallet transaction (change goes to primary). + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_cpfp_for_cross_wallet_transaction() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config(AddressType::NativeSegwit, vec![AddressType::Legacy]); + let node = setup_node(&chain_source, config, None); + + let native_addr = node.onchain_payment().new_address().unwrap(); + let legacy_addr = node.onchain_payment().new_address_for_type(AddressType::Legacy).unwrap(); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node, + vec![(native_addr, 100_000), (legacy_addr, 100_000)], + ) + .await; + + // 140k requires both wallets. + let parent_txid = node + .onchain_payment() + .send_to_address(&test_recipient(), 140_000, None, None) + .expect("Cross-wallet send should succeed"); + + wait_for_tx(&electrsd.client, parent_txid).await; + node.sync_wallets().unwrap(); + std::thread::sleep(std::time::Duration::from_secs(1)); + + let cpfp_txid = node + .onchain_payment() + .accelerate_by_cpfp(&parent_txid, Some(FeeRate::from_sat_per_kwu(1500)), None) + .expect("CPFP should work for cross-wallet transactions"); + + assert_ne!(parent_txid, cpfp_txid); + wait_for_tx(&electrsd.client, cpfp_txid).await; + + node.stop().unwrap(); + } + + /// CPFP on a transaction with inputs from all four address types. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_cpfp_cross_wallet_all_four_types() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config( + AddressType::NativeSegwit, + vec![AddressType::Legacy, AddressType::NestedSegwit, AddressType::Taproot], + ); + let node = setup_node(&chain_source, config, None); + + let native_addr = node.onchain_payment().new_address().unwrap(); + let legacy_addr = node.onchain_payment().new_address_for_type(AddressType::Legacy).unwrap(); + let nested_addr = + node.onchain_payment().new_address_for_type(AddressType::NestedSegwit).unwrap(); + let taproot_addr = + node.onchain_payment().new_address_for_type(AddressType::Taproot).unwrap(); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node, + vec![ + (native_addr, 100_000), + (legacy_addr, 100_000), + (nested_addr, 100_000), + (taproot_addr, 100_000), + ], + ) + .await; + + // 320k requires all four. + let parent_txid = node + .onchain_payment() + .send_to_address(&test_recipient(), 320_000, None, None) + .expect("Cross-wallet send should succeed"); + + wait_for_tx(&electrsd.client, parent_txid).await; + + let parent_tx = electrsd.client.transaction_get(&parent_txid).unwrap(); + assert!(parent_tx.input.len() >= 4); + + node.sync_wallets().unwrap(); + std::thread::sleep(std::time::Duration::from_secs(1)); + + let cpfp_txid = node + .onchain_payment() + .accelerate_by_cpfp(&parent_txid, Some(FeeRate::from_sat_per_kwu(1500)), None) + .expect("CPFP with all 4 types should succeed"); + + assert_ne!(parent_txid, cpfp_txid); + wait_for_tx(&electrsd.client, cpfp_txid).await; + + let cpfp_tx = electrsd.client.transaction_get(&cpfp_txid).unwrap(); + assert!(cpfp_tx.input.iter().any(|i| i.previous_output.txid == parent_txid)); + + node.stop().unwrap(); + } + + /// CPFP with a Legacy primary wallet. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_cpfp_legacy_primary() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config(AddressType::Legacy, vec![AddressType::NativeSegwit]); + let node = setup_node(&chain_source, config, None); + + let legacy_addr = node.onchain_payment().new_address().unwrap(); + let segwit_addr = + node.onchain_payment().new_address_for_type(AddressType::NativeSegwit).unwrap(); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node, + vec![(legacy_addr, 200_000), (segwit_addr, 100_000)], + ) + .await; + + let parent_txid = node + .onchain_payment() + .send_to_address(&test_recipient(), 60_000, None, None) + .expect("Send from legacy should succeed"); + + wait_for_tx(&electrsd.client, parent_txid).await; + node.sync_wallets().unwrap(); + std::thread::sleep(std::time::Duration::from_millis(500)); + + let cpfp_txid = node + .onchain_payment() + .accelerate_by_cpfp(&parent_txid, Some(FeeRate::from_sat_per_kwu(1500)), None) + .expect("CPFP should succeed for Legacy primary"); + + assert_ne!(parent_txid, cpfp_txid); + wait_for_tx(&electrsd.client, cpfp_txid).await; + + node.stop().unwrap(); + } + + // --- Error cases --- + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_cpfp_rejects_unknown_transaction() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config(AddressType::NativeSegwit, vec![]); + let node = setup_node(&chain_source, config, None); + + let addr = node.onchain_payment().new_address().unwrap(); + fund_and_sync(&bitcoind, &electrsd, &node, addr, 100_000).await; + + let result = + node.onchain_payment().accelerate_by_cpfp(&fake_txid_for_error_tests(), None, None); + assert!(result.is_err()); + + node.stop().unwrap(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_cpfp_rejects_confirmed_transaction() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config(AddressType::NativeSegwit, vec![]); + let node = setup_node(&chain_source, config, None); + + let addr = node.onchain_payment().new_address().unwrap(); + fund_and_sync(&bitcoind, &electrsd, &node, addr, 200_000).await; + + let txid = + node.onchain_payment().send_to_address(&test_recipient(), 50_000, None, None).unwrap(); + + wait_for_tx(&electrsd.client, txid).await; + confirm_and_sync(&bitcoind, &electrsd, 1, &[&node]).await; + + let result = node.onchain_payment().accelerate_by_cpfp(&txid, None, None); + assert!(result.is_err()); + + node.stop().unwrap(); + } +} + +// --------------------------------------------------------------------------- +// Coin Selection +// --------------------------------------------------------------------------- +mod coin_selection { + use bitcoin::FeeRate; + use electrum_client::ElectrumApi; + use ldk_node::config::AddressType; + + use crate::common::{ + open_channel, setup_bitcoind_and_electrsd, setup_node, wait_for_tx, TestChainSource, + }; + use crate::helpers::{ + fund_and_sync, fund_multiple_and_sync, fund_peer_node_and_sync, node_config, + test_recipient, CHANNEL_PEER_FUNDING_SATS, + }; + + // --- API & fee calculation --- + + /// Basic UTXO selection API works with multi-wallet. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_utxo_selection() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config( + AddressType::NativeSegwit, + vec![AddressType::Legacy, AddressType::NestedSegwit], + ); + let node = setup_node(&chain_source, config, None); + + let addr = node.onchain_payment().new_address().unwrap(); + fund_and_sync(&bitcoind, &electrsd, &node, addr, 500_000).await; + + let spendable = node.onchain_payment().list_spendable_outputs().unwrap(); + assert!(!spendable.is_empty()); + + let selected = node + .onchain_payment() + .select_utxos_with_algorithm( + 100_000, + Some(FeeRate::from_sat_per_kwu(500)), + ldk_node::CoinSelectionAlgorithm::LargestFirst, + None, + ) + .unwrap(); + + assert!(!selected.is_empty()); + } + + /// list_spendable_outputs returns UTXOs from multiple address types when funded. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_list_spendable_outputs_includes_multiple_address_types() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = + node_config(AddressType::NativeSegwit, vec![AddressType::Legacy, AddressType::Taproot]); + let node = setup_node(&chain_source, config, None); + + let native_addr = node.onchain_payment().new_address().unwrap(); + let legacy_addr = node.onchain_payment().new_address_for_type(AddressType::Legacy).unwrap(); + let taproot_addr = + node.onchain_payment().new_address_for_type(AddressType::Taproot).unwrap(); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node, + vec![(native_addr, 60_000), (legacy_addr, 50_000), (taproot_addr, 55_000)], + ) + .await; + + let spendable = node.onchain_payment().list_spendable_outputs().unwrap(); + assert!( + spendable.len() >= 3, + "Should have at least 3 UTXOs from 3 address types, got {}", + spendable.len() + ); + + let total_value: u64 = spendable.iter().map(|u| u.value_sats).sum(); + let expected_min = 60_000 + 50_000 + 55_000 - 5_000; + assert!( + total_value >= expected_min, + "Total spendable value should be >= {} (got {})", + expected_min, + total_value + ); + + node.stop().unwrap(); + } + + /// Fee calculation considers all wallets. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_calculate_total_fee() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config(AddressType::Taproot, vec![AddressType::NativeSegwit]); + let node = setup_node(&chain_source, config, None); + + let addr = node.onchain_payment().new_address().unwrap(); + fund_and_sync(&bitcoind, &electrsd, &node, addr, 300_000).await; + + let total_fee = node + .onchain_payment() + .calculate_total_fee( + &test_recipient(), + 100_000, + Some(FeeRate::from_sat_per_kwu(500)), + None, + ) + .unwrap(); + + assert!(total_fee > 0); + } + + // --- Minimum inputs --- + + /// Unified selection pools all UTXOs; should pick an efficient subset, + /// not dump all foreign UTXOs. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_cross_wallet_send_uses_minimum_inputs() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config(AddressType::NativeSegwit, vec![AddressType::Legacy]); + let node = setup_node(&chain_source, config, None); + + let primary_addr = node.onchain_payment().new_address().unwrap(); + let legacy_addr1 = + node.onchain_payment().new_address_for_type(AddressType::Legacy).unwrap(); + let legacy_addr2 = + node.onchain_payment().new_address_for_type(AddressType::Legacy).unwrap(); + let legacy_addr3 = + node.onchain_payment().new_address_for_type(AddressType::Legacy).unwrap(); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node, + vec![ + (primary_addr, 10_000), + (legacy_addr1, 20_000), + (legacy_addr2, 30_000), + (legacy_addr3, 50_000), + ], + ) + .await; + + // BranchAndBound should pick an efficient combination — not all 4. + let txid = + node.onchain_payment().send_to_address(&test_recipient(), 15_000, None, None).unwrap(); + wait_for_tx(&electrsd.client, txid).await; + + let tx = electrsd.client.transaction_get(&txid).unwrap(); + assert!(tx.input.len() <= 2, "Should use minimum inputs, got {}", tx.input.len()); + + node.stop().unwrap(); + } + + // --- Channel funding --- + + /// Channel funding picks minimum witness inputs. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_channel_funding_uses_minimum_witness_inputs() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config_a = node_config(AddressType::NativeSegwit, vec![AddressType::Taproot]); + let node_a = setup_node(&chain_source, config_a, None); + + let config_b = crate::common::random_config(true); + let node_b = setup_node(&chain_source, config_b, None); + + let native_addr = node_a.onchain_payment().new_address().unwrap(); + let taproot_addr1 = + node_a.onchain_payment().new_address_for_type(AddressType::Taproot).unwrap(); + let taproot_addr2 = + node_a.onchain_payment().new_address_for_type(AddressType::Taproot).unwrap(); + let taproot_addr3 = + node_a.onchain_payment().new_address_for_type(AddressType::Taproot).unwrap(); + let addr_b = node_b.onchain_payment().new_address().unwrap(); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node_a, + vec![ + (native_addr, 20_000), + (taproot_addr1, 40_000), + (taproot_addr2, 40_000), + (taproot_addr3, 40_000), + ], + ) + .await; + fund_peer_node_and_sync(&bitcoind, &electrsd, &node_b, addr_b, CHANNEL_PEER_FUNDING_SATS) + .await; + + let funding_txo = open_channel(&node_a, &node_b, 50_000, false, &electrsd).await; + + let funding_tx = electrsd.client.transaction_get(&funding_txo.txid).unwrap(); + assert!( + funding_tx.input.len() <= 3, + "Should not use all UTXOs, got {}", + funding_tx.input.len() + ); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); + } + + // --- RBF fallback --- + + /// RBF fallback selects minimum foreign inputs. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_rbf_fallback_uses_minimum_foreign_inputs() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config(AddressType::NativeSegwit, vec![AddressType::Legacy]); + let node = setup_node(&chain_source, config, None); + + let native_addr = node.onchain_payment().new_address().unwrap(); + let legacy_addr1 = + node.onchain_payment().new_address_for_type(AddressType::Legacy).unwrap(); + let legacy_addr2 = + node.onchain_payment().new_address_for_type(AddressType::Legacy).unwrap(); + let legacy_addr3 = + node.onchain_payment().new_address_for_type(AddressType::Legacy).unwrap(); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node, + vec![ + (native_addr, 55_000), + (legacy_addr1, 60_000), + (legacy_addr2, 60_000), + (legacy_addr3, 60_000), + ], + ) + .await; + + // Send 50k — uses primary (55k). Leaves ~5k change. + let txid = + node.onchain_payment().send_to_address(&test_recipient(), 50_000, None, None).unwrap(); + wait_for_tx(&electrsd.client, txid).await; + node.sync_wallets().unwrap(); + std::thread::sleep(std::time::Duration::from_secs(1)); + + let initial_tx = electrsd.client.transaction_get(&txid).unwrap(); + assert_eq!(initial_tx.input.len(), 1); + + // High fee rate forces adding 1 Legacy UTXO. + let rbf_txid = node + .onchain_payment() + .bump_fee_by_rbf(&txid, FeeRate::from_sat_per_kwu(50_000)) + .expect("RBF should succeed by adding Legacy input"); + + wait_for_tx(&electrsd.client, rbf_txid).await; + + let rbf_tx = electrsd.client.transaction_get(&rbf_txid).unwrap(); + assert!( + rbf_tx.input.len() <= 2, + "Should use minimum foreign inputs, got {}", + rbf_tx.input.len() + ); + + node.stop().unwrap(); + } + + // --- Optimal UTXO preference --- + + /// Unified selection prefers a single optimal UTXO over many small ones. + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_unified_selection_prefers_optimal_utxo() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config = node_config(AddressType::NativeSegwit, vec![AddressType::Taproot]); + let node = setup_node(&chain_source, config, None); + + // 3 small primary UTXOs (10k each) + 1 large Taproot (25k). + let addr1 = node.onchain_payment().new_address().unwrap(); + let addr2 = node.onchain_payment().new_address().unwrap(); + let addr3 = node.onchain_payment().new_address().unwrap(); + let taproot_addr = + node.onchain_payment().new_address_for_type(AddressType::Taproot).unwrap(); + + fund_multiple_and_sync( + &bitcoind, + &electrsd, + &node, + vec![(addr1, 10_000), (addr2, 10_000), (addr3, 10_000), (taproot_addr, 25_000)], + ) + .await; + + // Send 20k. Unified selection should not use all 4 UTXOs. + let txid = + node.onchain_payment().send_to_address(&test_recipient(), 20_000, None, None).unwrap(); + wait_for_tx(&electrsd.client, txid).await; + + let tx = electrsd.client.transaction_get(&txid).unwrap(); + assert!(tx.input.len() <= 3, "Should pick optimal UTXOs, got {}", tx.input.len()); + + node.stop().unwrap(); + } +}