diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index e2ae378dd7..5e5149ac5a 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -6,6 +6,7 @@ on: jobs: audit: + timeout-minutes: 60 permissions: issues: write checks: write diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index cd3980b9af..4a884ab2a6 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -8,25 +8,27 @@ concurrency: jobs: benchmark: - runs-on: ubuntu-latest + timeout-minutes: 60 + runs-on: self-hosted env: TOOLCHAIN: stable steps: - name: Checkout source code - uses: actions/checkout@v6 + uses: actions/checkout@v4 - name: Install Rust toolchain run: | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain stable - rustup override set stable + - name: Set Rust override + run: rustup override set stable - name: Enable caching for bitcoind id: cache-bitcoind - uses: actions/cache@v5 + uses: actions/cache@v4 with: path: bin/bitcoind-${{ runner.os }}-${{ runner.arch }} key: bitcoind-29.0-${{ runner.os }}-${{ runner.arch }} - name: Enable caching for electrs id: cache-electrs - uses: actions/cache@v5 + uses: actions/cache@v4 with: path: bin/electrs-${{ runner.os }}-${{ runner.arch }} key: electrs-${{ runner.os }}-${{ runner.arch }} diff --git a/.github/workflows/cln-integration.yml b/.github/workflows/cln-integration.yml index 81eb822502..3c1a8f5809 100644 --- a/.github/workflows/cln-integration.yml +++ b/.github/workflows/cln-integration.yml @@ -8,6 +8,7 @@ concurrency: jobs: check-cln: + timeout-minutes: 60 runs-on: ubuntu-latest steps: - name: Checkout repository diff --git a/.github/workflows/cron-weekly-rustfmt.yml b/.github/workflows/cron-weekly-rustfmt.yml index 9e54ab9f32..65ca21511e 100644 --- a/.github/workflows/cron-weekly-rustfmt.yml +++ b/.github/workflows/cron-weekly-rustfmt.yml @@ -11,7 +11,8 @@ on: jobs: format: name: Nightly rustfmt - runs-on: ubuntu-24.04 + timeout-minutes: 60 + runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@nightly diff --git a/.github/workflows/eclair-integration.yml b/.github/workflows/eclair-integration.yml index 56d51b77ee..daa4572ccd 100644 --- a/.github/workflows/eclair-integration.yml +++ b/.github/workflows/eclair-integration.yml @@ -8,6 +8,7 @@ concurrency: jobs: check-eclair: + timeout-minutes: 60 runs-on: ubuntu-latest steps: - name: Checkout repository diff --git a/.github/workflows/hrn-integration.yml b/.github/workflows/hrn-integration.yml index f7ded7bc56..76a95f93de 100644 --- a/.github/workflows/hrn-integration.yml +++ b/.github/workflows/hrn-integration.yml @@ -8,7 +8,8 @@ concurrency: jobs: build-and-test: - runs-on: ubuntu-latest + timeout-minutes: 60 + runs-on: self-hosted steps: - name: Checkout source code @@ -42,4 +43,4 @@ jobs: - name: Run HRN Integration Tests run: | RUSTFLAGS="--cfg no_download --cfg hrn_tests $RUSTFLAGS" cargo test --test integration_tests_hrn - RUSTFLAGS="--cfg no_download --cfg hrn_tests $RUSTFLAGS" cargo test --test integration_tests_hrn --features uniffi \ No newline at end of file + RUSTFLAGS="--cfg no_download --cfg hrn_tests $RUSTFLAGS" cargo test --test integration_tests_hrn --features uniffi diff --git a/.github/workflows/kotlin.yml b/.github/workflows/kotlin.yml index f4d55e3bcc..f3066e4c7e 100644 --- a/.github/workflows/kotlin.yml +++ b/.github/workflows/kotlin.yml @@ -8,6 +8,7 @@ concurrency: jobs: check-kotlin: + timeout-minutes: 60 runs-on: ubuntu-latest env: diff --git a/.github/workflows/lnd-integration.yml b/.github/workflows/lnd-integration.yml index caefbdb6b2..6006ecf2ba 100644 --- a/.github/workflows/lnd-integration.yml +++ b/.github/workflows/lnd-integration.yml @@ -8,6 +8,7 @@ concurrency: jobs: check-lnd: + timeout-minutes: 60 runs-on: ubuntu-latest steps: - name: Checkout repository diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index e154faa7e9..be5bbeb25a 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -8,6 +8,7 @@ concurrency: jobs: check-python: + timeout-minutes: 60 runs-on: ubuntu-latest env: diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 16064fa45c..106f2c4f95 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -8,10 +8,11 @@ concurrency: jobs: build: + timeout-minutes: 60 strategy: matrix: platform: [ - ubuntu-latest, + self-hosted, macos-latest, windows-latest, ] @@ -24,7 +25,7 @@ jobs: - toolchain: stable check-fmt: true build-uniffi: true - platform: ubuntu-latest + platform: self-hosted - toolchain: stable platform: macos-latest - toolchain: stable @@ -34,7 +35,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - name: Checkout source code - uses: actions/checkout@v6 + uses: actions/checkout@v4 - name: Install Rust ${{ matrix.toolchain }} toolchain run: | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain ${{ matrix.toolchain }} @@ -50,13 +51,13 @@ jobs: run: echo "RUSTFLAGS=-D warnings" >> "$GITHUB_ENV" - name: Enable caching for bitcoind id: cache-bitcoind - uses: actions/cache@v5 + uses: actions/cache@v4 with: path: bin/bitcoind-${{ runner.os }}-${{ runner.arch }} key: bitcoind-29.0-${{ runner.os }}-${{ runner.arch }} - name: Enable caching for electrs id: cache-electrs - uses: actions/cache@v5 + uses: actions/cache@v4 with: path: bin/electrs-${{ runner.os }}-${{ runner.arch }} key: electrs-${{ runner.os }}-${{ runner.arch }} @@ -92,14 +93,16 @@ jobs: linting: name: Linting - runs-on: ubuntu-latest + timeout-minutes: 60 + runs-on: self-hosted steps: - name: Checkout source code - uses: actions/checkout@v6 - - name: Install Rust and clippy + uses: actions/checkout@v4 + - name: Install Rust stable toolchain run: | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain stable - rustup component add clippy + - name: Add clippy component + run: rustup component add clippy - name: Ban `unwrap` in library code run: | cargo clippy --lib --verbose --color always -- -A warnings -D clippy::unwrap_used -A clippy::tabs_in_doc_comments @@ -107,11 +110,12 @@ jobs: doc: name: Documentation - runs-on: ubuntu-latest + timeout-minutes: 60 + runs-on: self-hosted env: RUSTDOCFLAGS: -Dwarnings steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@nightly - uses: dtolnay/install@cargo-docs-rs - run: cargo docs-rs diff --git a/.github/workflows/semver.yml b/.github/workflows/semver.yml index 0fdfbe2137..52c505b5b8 100644 --- a/.github/workflows/semver.yml +++ b/.github/workflows/semver.yml @@ -3,6 +3,7 @@ on: [push, pull_request] jobs: semver-checks: + timeout-minutes: 60 runs-on: ubuntu-latest steps: - name: Checkout source code diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index c1e385e2d3..2973892bf9 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -8,6 +8,7 @@ concurrency: jobs: check-swift: + timeout-minutes: 60 runs-on: macos-latest steps: diff --git a/.github/workflows/vss-integration.yml b/.github/workflows/vss-integration.yml index c67e9194e1..7ffea3dd67 100644 --- a/.github/workflows/vss-integration.yml +++ b/.github/workflows/vss-integration.yml @@ -8,6 +8,7 @@ concurrency: jobs: build-and-test: + timeout-minutes: 60 runs-on: ubuntu-latest services: diff --git a/.github/workflows/vss-no-auth-integration.yml b/.github/workflows/vss-no-auth-integration.yml index 35666df038..8ee2fe54b9 100644 --- a/.github/workflows/vss-no-auth-integration.yml +++ b/.github/workflows/vss-no-auth-integration.yml @@ -8,6 +8,7 @@ concurrency: jobs: build-and-test: + timeout-minutes: 60 runs-on: ubuntu-latest services: diff --git a/CHANGELOG.md b/CHANGELOG.md index 93c7cf59b2..e7e012a146 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,25 @@ ## Compatibility Notes - Pending JIT-channel payments created before upgrading may fail after upgrade because the prior LSPS2 fee-limit state stored in `PaymentKind::Bolt11Jit` is not migrated. +- Users of the VSS storage backend must upgrade their VSS server to at least version + `v0.1.0-alpha.0` before upgrading LDK Node. + +## Feature and API updates +- The Bitcoin Core RPC and REST chain-source builder methods now accept an optional + `wallet_rescan_from_height` argument. Passing a height lets fresh wallets rescan from a known + birthday block instead of checkpointing at the current tip, which is useful when restoring a + wallet on a pruned node where the full history is unavailable but the wallet birthday height is + known. Existing wallets are not rewound, and future heights fail the build. Passing `Some(0)` + rescans from genesis; passing `None` keeps the default current-tip checkpoint behavior. (#884) +- `EsploraSyncConfig` and `ElectrumSyncConfig` now support `force_wallet_full_scan`. When set, + the on-chain wallet keeps using BDK `full_scan` instead of incremental sync until a full scan + succeeds, allowing restored wallets to rediscover funds sent to previously-unknown addresses. + +## Bug Fixes and Improvements +- Building a fresh node against a Bitcoin Core RPC or REST chain source that fails to return the + current chain tip now aborts with a new `BuildError::ChainTipFetchFailed` variant instead of + silently pinning the wallet birthday to genesis, which would have forced a full-history rescan + once the chain source became reachable again. (#884) # 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. diff --git a/Cargo.toml b/Cargo.toml index bed984f071..9f1c257cb8 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,10 +54,10 @@ lightning-liquidity = { git = "https://github.com/lightningdevkit/rust-lightning lightning-macros = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c" } lightning-dns-resolver = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c" } -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_chain = { version = "0.23.3", default-features = false, features = ["std"] } +bdk_esplora = { version = "0.22.2", default-features = false, features = ["async-https-rustls", "tokio"]} bdk_electrum = { version = "0.24.0", default-features = false, features = ["use-rustls-ring"]} -bdk_wallet = { version = "2.3.0", default-features = false, features = ["std", "keys-bip39"]} +bdk_wallet = { version = "3.1.0", default-features = false, features = ["std", "keys-bip39"]} bitreq = { version = "0.3", default-features = false, features = ["async-https", "json-using-serde"] } rustls = { version = "0.23", default-features = false } @@ -69,7 +69,7 @@ bip21 = { version = "0.5", features = ["std"], default-features = false } base64 = { version = "0.22.1", default-features = false, features = ["std"] } getrandom = { version = "0.3", default-features = false } chrono = { version = "0.4", default-features = false, features = ["clock"] } -tokio = { version = "1.37", default-features = false, features = [ "rt-multi-thread", "time", "sync", "macros", "net" ] } +tokio = { version = "1.39", default-features = false, features = [ "rt-multi-thread", "time", "sync", "macros", "net" ] } esplora-client = { version = "0.12", default-features = false, features = ["tokio", "async-https-rustls"] } electrum-client = { version = "0.25", default-features = false, features = ["proxy", "use-rustls-ring"] } libc = "0.2" @@ -82,7 +82,7 @@ async-trait = { version = "0.1", default-features = false } tokio-postgres = { version = "0.7", default-features = false, features = ["runtime"], optional = true } native-tls = { version = "0.2", default-features = false, optional = true } postgres-native-tls = { version = "0.5", default-features = false, features = ["runtime"], optional = true } -vss-client = { package = "vss-client-ng", version = "0.5" } +vss-client = { package = "vss-client-ng", version = "0.6" } prost = { version = "0.11.6", default-features = false} #bitcoin-payment-instructions = { version = "0.6" } bitcoin-payment-instructions = { git = "https://github.com/tnull/bitcoin-payment-instructions", rev = "ff09ce9401afa448549a8f101172700bcd14d7bb" } @@ -92,6 +92,7 @@ winapi = { version = "0.3", features = ["winbase"] } [dev-dependencies] lightning = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c", features = ["std", "_test_utils"] } +lightning-persister = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c", features = ["tokio"] } rand = { version = "0.9.2", default-features = false, features = ["std", "thread_rng", "os_rng"] } proptest = "1.0.0" regex = "1.5.6" diff --git a/README.md b/README.md index 0068b6e07a..289ada1792 100644 --- a/README.md +++ b/README.md @@ -62,10 +62,14 @@ LDK Node currently comes with a decidedly opinionated set of design choices: - On-chain data is handled by the integrated [BDK][bdk] wallet. - Chain data may currently be sourced from the Bitcoin Core RPC interface, or from an [Electrum][electrum] or [Esplora][esplora] server. -- Wallet and channel state may be persisted to an [SQLite][sqlite] database, to file system, or to a custom back-end to be implemented by the user. +- Wallet and channel state may be persisted to an [SQLite][sqlite] or [PostgreSQL][postgresql] database, to file system, or to a custom back-end to be implemented by the user. - Gossip data may be sourced via Lightning's peer-to-peer network or the [Rapid Gossip Sync](https://docs.rs/lightning-rapid-gossip-sync/*/lightning_rapid_gossip_sync/) protocol. - Entropy for the Lightning and on-chain wallets may be sourced from raw bytes or a [BIP39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki) mnemonic. In addition, LDK Node offers the means to generate and persist the entropy bytes to disk. +## Compatibility + +LDK Node does not provide a stable public API until v1.0. Persisted node state is backwards compatible: newer releases are guaranteed to load state written by older releases. Downgrades are not supported, so state written by a newer release may not load with an older release. + ## Language Support LDK Node itself is written in [Rust][rust] and may therefore be natively added as a library dependency to any `std` Rust program. However, beyond its Rust API it also offers language bindings for [Swift][swift], [Kotlin][kotlin], and [Python][python] based on the [UniFFI](https://github.com/mozilla/uniffi-rs/). @@ -81,6 +85,7 @@ The Minimum Supported Rust Version (MSRV) is currently 1.85.0. [electrum]: https://github.com/spesmilo/electrum-protocol [esplora]: https://github.com/Blockstream/esplora [sqlite]: https://sqlite.org/ +[postgresql]: https://www.postgresql.org/ [rust]: https://www.rust-lang.org/ [swift]: https://www.swift.org/ [kotlin]: https://kotlinlang.org/ diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index 7e9e61f5d5..7c0edc5359 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -38,13 +38,12 @@ interface Builder { constructor(Config config); void set_chain_source_esplora(string server_url, EsploraSyncConfig? config); void set_chain_source_electrum(string server_url, ElectrumSyncConfig? config); - void set_chain_source_bitcoind_rpc(string rpc_host, u16 rpc_port, string rpc_user, string rpc_password); - void set_chain_source_bitcoind_rest(string rest_host, u16 rest_port, string rpc_host, u16 rpc_port, string rpc_user, string rpc_password); + void set_chain_source_bitcoind_rpc(string rpc_host, u16 rpc_port, string rpc_user, string rpc_password, u32? wallet_rescan_from_height); + void set_chain_source_bitcoind_rest(string rest_host, u16 rest_port, string rpc_host, u16 rpc_port, string rpc_user, string rpc_password, u32? wallet_rescan_from_height); void set_gossip_source_p2p(); void set_gossip_source_rgs(string rgs_server_url); void set_pathfinding_scores_source(string url); - void set_liquidity_source_lsps1(PublicKey node_id, SocketAddress address, string? token); - void set_liquidity_source_lsps2(PublicKey node_id, SocketAddress address, string? token); + void add_liquidity_source(PublicKey node_id, SocketAddress address, string? token, boolean trust_peer_0conf); void set_storage_dir_path(string storage_dir_path); void set_filesystem_logger(string? log_file_path, LogLevel? max_log_level); void set_log_facade_logger(); @@ -60,7 +59,6 @@ interface Builder { void set_node_alias(string node_alias); [Throws=BuildError] void set_async_payments_role(AsyncPaymentsRole? role); - void set_wallet_recovery_mode(); [Throws=BuildError] Node build(NodeEntropy node_entropy); [Throws=BuildError] @@ -99,7 +97,7 @@ interface Node { SpontaneousPayment spontaneous_payment(); OnchainPayment onchain_payment(); UnifiedPayment unified_payment(); - LSPS1Liquidity lsps1_liquidity(); + Liquidity liquidity(); [Throws=NodeError] void lnurl_auth(string lnurl); [Throws=NodeError] @@ -125,6 +123,8 @@ interface Node { [Throws=NodeError] void splice_out([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, [ByRef]Address address, u64 splice_amount_sats); [Throws=NodeError] + void bump_channel_funding_fee([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id); + [Throws=NodeError] void close_channel([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id); [Throws=NodeError] void force_close_channel([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, string? reason); @@ -167,7 +167,7 @@ interface FeeRate { typedef interface UnifiedPayment; -typedef interface LSPS1Liquidity; +typedef interface Liquidity; [Error] enum NodeError { @@ -275,6 +275,7 @@ dictionary LSPS1OrderStatus { LSPS1OrderParams order_params; LSPS1PaymentInfo payment_options; LSPS1ChannelInfo? channel_state; + PublicKey counterparty_node_id; }; [Remote] diff --git a/bindings/python/src/ldk_node/test_ldk_node.py b/bindings/python/src/ldk_node/test_ldk_node.py index 4f53dbabfc..304caf9c04 100644 --- a/bindings/python/src/ldk_node/test_ldk_node.py +++ b/bindings/python/src/ldk_node/test_ldk_node.py @@ -5,6 +5,7 @@ import os import re import requests +import socket from ldk_node import * @@ -118,7 +119,90 @@ def expect_event(node, expected_event_type): assert isinstance(event, expected_event_type) print("EVENT:", event) node.event_handled() - return event + return event + +def find_two_free_ports(): + with socket.socket() as s1, socket.socket() as s2: + s1.bind(("127.0.0.1", 0)) + s2.bind(("127.0.0.1",0)) + port_1 = s1.getsockname()[1] + port_2 = s2.getsockname()[1] + return port_1, port_2 + +def setup_two_nodes(esplora_endpoint): + port_1, port_2 = find_two_free_ports() + tmp_dir_1 = tempfile.TemporaryDirectory("_ldk_node_1") + listening_addresses_1 = [f"127.0.0.1:{port_1}"] + node_1 = setup_node(tmp_dir_1.name, esplora_endpoint, listening_addresses_1) + node_1.start() + node_id_1 = node_1.node_id() + + tmp_dir_2 = tempfile.TemporaryDirectory("_ldk_node_2") + listening_addresses_2 = [f"127.0.0.1:{port_2}"] + node_2 = setup_node(tmp_dir_2.name, esplora_endpoint, listening_addresses_2) + node_2.start() + node_id_2 = node_2.node_id() + + return node_1, node_2, tmp_dir_1, tmp_dir_2, node_id_1, node_id_2, listening_addresses_2 + +def fund_nodes(node_1, node_2, esplora_endpoint, amount_sats=100000): + address_1 = node_1.onchain_payment().new_address() + txid_1 = send_to_address(address_1, amount_sats) + address_2 = node_2.onchain_payment().new_address() + txid_2 = send_to_address(address_2, amount_sats) + + wait_for_tx(esplora_endpoint, txid_1) + wait_for_tx(esplora_endpoint, txid_2) + mine_and_wait(esplora_endpoint, 6) + + node_1.sync_wallets() + node_2.sync_wallets() + +def open_channel_and_wait_ready(node_1, node_2, node_id_2, listening_address_2, esplora_endpoint, channel_amount_sats=50000): + node_1.open_channel(node_id_2, listening_address_2, channel_amount_sats, None, None) + + channel_pending_event_1 = expect_event(node_1, Event.CHANNEL_PENDING) + expect_event(node_2, Event.CHANNEL_PENDING) + + funding_txid = channel_pending_event_1.funding_txo.txid + wait_for_tx(esplora_endpoint, funding_txid) + mine_and_wait(esplora_endpoint, 6) + + node_1.sync_wallets() + node_2.sync_wallets() + + channel_ready_event_1 = expect_event(node_1, Event.CHANNEL_READY) + channel_ready_event_2 = expect_event(node_2, Event.CHANNEL_READY) + return channel_ready_event_1, channel_ready_event_2, funding_txid + +def stop_and_cleanup(node_1, node_2, tmp_dir_1, tmp_dir_2): + node_1.stop() + node_2.stop() + time.sleep(1) + tmp_dir_1.cleanup() + tmp_dir_2.cleanup() + +def assert_feature_helpers_return_bool(test_case, features): + feature_methods = [ + method_name for method_name in dir(features) + if method_name.startswith("supports_") or method_name.startswith("requires_") + ] + + test_case.assertGreater(len(feature_methods), 0) + for method_name in feature_methods: + with test_case.subTest(method_name=method_name): + test_case.assertIsInstance(getattr(features, method_name)(), bool) + + +def node_features_exposed(test_case, node_features): + test_case.assertIsInstance(node_features, NodeFeatures) + assert_feature_helpers_return_bool(test_case, node_features) + + +def init_features_exposed(test_case, init_features): + test_case.assertIsInstance(init_features, InitFeatures) + assert_feature_helpers_return_bool(test_case, init_features) + test_case.assertIsInstance(init_features.initial_routing_sync(), bool) @@ -130,41 +214,57 @@ def setUp(self): esplora_endpoint = get_esplora_endpoint() mine_and_wait(esplora_endpoint, 1) - def test_channel_full_cycle(self): + def test_spontaneous_payment(self): + """Spontaneous payment test in python: keysend after channel ready.""" esplora_endpoint = get_esplora_endpoint() - ## Setup Node 1 - tmp_dir_1 = tempfile.TemporaryDirectory("_ldk_node_1") - print("TMP DIR 1:", tmp_dir_1.name) + node_1, node_2, tmp_dir_1, tmp_dir_2, node_id_1, node_id_2, listening_addresses_2 = setup_two_nodes(esplora_endpoint) + fund_nodes(node_1, node_2, esplora_endpoint) + open_channel_and_wait_ready(node_1, node_2, node_id_2, listening_addresses_2[0], esplora_endpoint) - listening_addresses_1 = ["127.0.0.1:2323"] - node_1 = setup_node(tmp_dir_1.name, esplora_endpoint, listening_addresses_1) - node_1.start() - node_id_1 = node_1.node_id() - print("Node ID 1:", node_id_1) + keysend_amount_msat = 2_500_000 + custom_tlvs = [CustomTlvRecord(type_num=13377331, value=bytes([1, 2, 3]))] + keysend_payment_id = node_1.spontaneous_payment().send_with_custom_tlvs( + keysend_amount_msat, node_id_2, None, custom_tlvs + ) - # Setup Node 2 - tmp_dir_2 = tempfile.TemporaryDirectory("_ldk_node_2") - print("TMP DIR 2:", tmp_dir_2.name) + expect_event(node_1, Event.PAYMENT_SUCCESSFUL) + received_event = expect_event(node_2, Event.PAYMENT_RECEIVED) - listening_addresses_2 = ["127.0.0.1:2324"] - node_2 = setup_node(tmp_dir_2.name, esplora_endpoint, listening_addresses_2) - node_2.start() - node_id_2 = node_2.node_id() - print("Node ID 2:", node_id_2) + self.assertEqual(received_event.amount_msat, keysend_amount_msat) + self.assertEqual(received_event.custom_records, custom_tlvs) - address_1 = node_1.onchain_payment().new_address() - txid_1 = send_to_address(address_1, 100000) - address_2 = node_2.onchain_payment().new_address() - txid_2 = send_to_address(address_2, 100000) + sender_payment = node_1.payment(keysend_payment_id) + receiver_payment = node_2.payment(keysend_payment_id) - wait_for_tx(esplora_endpoint, txid_1) - wait_for_tx(esplora_endpoint, txid_2) + self.assertIsNotNone(sender_payment) + self.assertIsNotNone(receiver_payment) + self.assertEqual(sender_payment.status, PaymentStatus.SUCCEEDED) + self.assertEqual(sender_payment.direction, PaymentDirection.OUTBOUND) + self.assertEqual(sender_payment.amount_msat, keysend_amount_msat) + self.assertTrue(sender_payment.kind.is_spontaneous()) - mine_and_wait(esplora_endpoint, 6) + self.assertEqual(receiver_payment.status, PaymentStatus.SUCCEEDED) + self.assertEqual(receiver_payment.direction, PaymentDirection.INBOUND) + self.assertEqual(receiver_payment.amount_msat, keysend_amount_msat) + self.assertTrue(receiver_payment.kind.is_spontaneous()) + + stop_and_cleanup(node_1, node_2, tmp_dir_1, tmp_dir_2) + + def test_channel_full_cycle(self): + esplora_endpoint = get_esplora_endpoint() + + ## Setup two nodes + node_1, node_2, tmp_dir_1, tmp_dir_2, node_id_1, node_id_2, listening_addresses_2 = setup_two_nodes(esplora_endpoint) + print("Node ID 1:", node_id_1) + print("Node ID 2:", node_id_2) + + # Check node-announcement features exposed through NodeStatus. + for node in [node_1, node_2]: + node_features_exposed(self, node.status().node_features) + + fund_nodes(node_1, node_2, esplora_endpoint) - node_1.sync_wallets() - node_2.sync_wallets() spendable_balance_1 = node_1.list_balances().spendable_onchain_balance_sats spendable_balance_2 = node_2.list_balances().spendable_onchain_balance_sats @@ -183,22 +283,13 @@ def test_channel_full_cycle(self): print("TOTAL 2:", total_balance_2) self.assertEqual(total_balance_2, 100000) - node_1.open_channel(node_id_2, listening_addresses_2[0], 50000, None, None) - - - channel_pending_event_1 = expect_event(node_1, Event.CHANNEL_PENDING) - channel_pending_event_2 = expect_event(node_2, Event.CHANNEL_PENDING) - funding_txid = channel_pending_event_1.funding_txo.txid - wait_for_tx(esplora_endpoint, funding_txid) - mine_and_wait(esplora_endpoint, 6) - - node_1.sync_wallets() - node_2.sync_wallets() - - channel_ready_event_1 = expect_event(node_1, Event.CHANNEL_READY) + channel_ready_event_1, channel_ready_event_2, funding_txid = open_channel_and_wait_ready(node_1, node_2, node_id_2, listening_addresses_2[0], esplora_endpoint) print("funding_txo:", funding_txid) - channel_ready_event_2 = expect_event(node_2, Event.CHANNEL_READY) + + # Check negotiated init features exposed through ChannelDetails. + for channel in [node_1.list_channels()[0], node_2.list_channels()[0]]: + init_features_exposed(self, channel.counterparty.features) description = Bolt11InvoiceDescription.DIRECT("asdf") invoice = node_2.bolt11_payment().receive(2500000, description, 9217) @@ -228,13 +319,7 @@ def test_channel_full_cycle(self): self.assertEqual(spendable_balance_after_close_2, 102500) # Stop nodes - node_1.stop() - node_2.stop() - - # Cleanup - time.sleep(1) # Wait a sec so our logs can finish writing - tmp_dir_1.cleanup() - tmp_dir_2.cleanup() + stop_and_cleanup(node_1, node_2, tmp_dir_1, tmp_dir_2) if __name__ == '__main__': unittest.main() diff --git a/src/builder.rs b/src/builder.rs index c88c867cc1..8b575cc3f2 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -37,8 +37,8 @@ use lightning::routing::scoring::{ use lightning::sign::{EntropySource, NodeSigner}; use lightning::util::config::HTLCInterceptionFlags; use lightning::util::persist::{ - KVStore, CHANNEL_MANAGER_PERSISTENCE_KEY, CHANNEL_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, - CHANNEL_MANAGER_PERSISTENCE_SECONDARY_NAMESPACE, + KVStore, PaginatedKVStore, CHANNEL_MANAGER_PERSISTENCE_KEY, + CHANNEL_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, CHANNEL_MANAGER_PERSISTENCE_SECONDARY_NAMESPACE, }; use lightning::util::ser::ReadableArgs; use lightning::util::sweep::OutputSweeper; @@ -68,9 +68,7 @@ use crate::io::{ PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, }; -use crate::liquidity::{ - LSPS1ClientConfig, LSPS2ClientConfig, LSPS2ServiceConfig, LiquiditySourceBuilder, -}; +use crate::liquidity::{LSPS2ServiceConfig, LiquiditySourceBuilder, LspConfig}; use crate::lnurl_auth::LnurlAuth; use crate::logger::{log_error, LdkLogger, LogLevel, LogWriter, Logger}; use crate::message_handler::NodeCustomMessageHandler; @@ -107,6 +105,7 @@ enum ChainDataSourceConfig { rpc_user: String, rpc_password: String, rest_client_config: Option, + wallet_rescan_from_height: Option, }, } @@ -123,10 +122,8 @@ struct PathfindingScoresSyncConfig { #[derive(Debug, Clone, Default)] struct LiquiditySourceConfig { - // Act as an LSPS1 client connecting to the given service. - lsps1_client: Option, - // Act as an LSPS2 client connecting to the given service. - lsps2_client: Option, + // Acts for both LSPS1 and LSPS2 clients connecting to the given service. + lsp_nodes: Vec, // Act as an LSPS2 service. lsps2_service: Option, } @@ -200,6 +197,15 @@ pub enum BuildError { AsyncPaymentsConfigMismatch, /// An attempt to setup a DNS Resolver failed. DNSResolverSetupFailed, + /// We failed to determine the current chain tip on first startup. + /// + /// Returned when a fresh node is built against a Bitcoin Core RPC or REST chain source that + /// is unreachable or misconfigured, so we cannot learn the tip height/hash to use as the + /// wallet birthday. Falling back to genesis would silently force a full-history rescan on + /// the next successful startup, so we abort instead. + ChainTipFetchFailed, + /// The configured wallet rescan height is above the current chain tip. + WalletRescanHeightTooHigh, } impl fmt::Display for BuildError { @@ -237,6 +243,15 @@ impl fmt::Display for BuildError { Self::DNSResolverSetupFailed => { write!(f, "An attempt to setup a DNS resolver has failed.") }, + Self::ChainTipFetchFailed => { + write!( + f, + "Failed to determine the current chain tip on first startup. Verify the chain data source is reachable and correctly configured." + ) + }, + Self::WalletRescanHeightTooHigh => { + write!(f, "Wallet rescan height is above the current chain tip.") + }, } } } @@ -258,7 +273,7 @@ impl std::error::Error for BuildError {} /// - [`build`] uses an SQLite database (recommended default). /// - [`build_with_fs_store`] uses a filesystem-based store. /// - [`build_with_vss_store`] and variants use a [VSS] remote store (**experimental**). -/// - [`build_with_store`] allows providing a custom [`KVStore`] implementation. +/// - [`build_with_store`] allows providing a custom [`PaginatedKVStore`] implementation. /// /// ### Logging /// @@ -274,7 +289,7 @@ impl std::error::Error for BuildError {} /// [`build_with_vss_store`]: Self::build_with_vss_store /// [`build_with_store`]: Self::build_with_store /// [VSS]: https://github.com/lightningdevkit/vss-server/blob/main/README.md -/// [`KVStore`]: lightning::util::persist::KVStore +/// [`PaginatedKVStore`]: lightning::util::persist::PaginatedKVStore /// [`DEFAULT_LOG_LEVEL`]: crate::config::DEFAULT_LOG_LEVEL /// [`set_filesystem_logger`]: Self::set_filesystem_logger /// [`set_log_facade_logger`]: Self::set_log_facade_logger @@ -291,7 +306,6 @@ pub struct NodeBuilder { async_payments_role: Option, runtime_handle: Option, pathfinding_scores_sync_config: Option, - recovery_mode: bool, } impl NodeBuilder { @@ -309,7 +323,6 @@ impl NodeBuilder { let log_writer_config = None; let runtime_handle = None; let pathfinding_scores_sync_config = None; - let recovery_mode = false; Self { config, chain_data_source_config, @@ -319,7 +332,6 @@ impl NodeBuilder { runtime_handle, async_payments_role: None, pathfinding_scores_sync_config, - recovery_mode, } } @@ -384,8 +396,13 @@ impl NodeBuilder { /// ## Parameters: /// * `rpc_host`, `rpc_port`, `rpc_user`, `rpc_password` - Required parameters for the Bitcoin Core RPC /// connection. + /// * `wallet_rescan_from_height` - Optional wallet birthday height to rescan from on first + /// startup, before wallet state exists. Existing wallets are not rewound. The height must + /// be at or below the current tip. Passing `Some(0)` rescans from genesis; passing `None` + /// checkpoints at the current tip. pub fn set_chain_source_bitcoind_rpc( &mut self, rpc_host: String, rpc_port: u16, rpc_user: String, rpc_password: String, + wallet_rescan_from_height: Option, ) -> &mut Self { self.chain_data_source_config = Some(ChainDataSourceConfig::Bitcoind { rpc_host, @@ -393,6 +410,7 @@ impl NodeBuilder { rpc_user, rpc_password, rest_client_config: None, + wallet_rescan_from_height, }); self } @@ -406,9 +424,13 @@ impl NodeBuilder { /// * `rest_host`, `rest_port` - Required parameters for the Bitcoin Core REST connection. /// * `rpc_host`, `rpc_port`, `rpc_user`, `rpc_password` - Required parameters for the Bitcoin Core RPC /// connection + /// * `wallet_rescan_from_height` - Optional wallet birthday height to rescan from on first + /// startup, before wallet state exists. Existing wallets are not rewound. The height must + /// be at or below the current tip. Passing `Some(0)` rescans from genesis; passing `None` + /// checkpoints at the current tip. pub fn set_chain_source_bitcoind_rest( &mut self, rest_host: String, rest_port: u16, rpc_host: String, rpc_port: u16, - rpc_user: String, rpc_password: String, + rpc_user: String, rpc_password: String, wallet_rescan_from_height: Option, ) -> &mut Self { self.chain_data_source_config = Some(ChainDataSourceConfig::Bitcoind { rpc_host, @@ -416,6 +438,7 @@ impl NodeBuilder { rpc_user, rpc_password, rest_client_config: Some(BitcoindRestClientConfig { rest_host, rest_port }), + wallet_rescan_from_height, }); self @@ -443,45 +466,36 @@ impl NodeBuilder { self } - /// Configures the [`Node`] instance to source inbound liquidity from the given - /// [bLIP-51 / LSPS1] service. + /// Configures the [`Node`] instance to source inbound liquidity from the given LSP. /// - /// Will mark the LSP as trusted for 0-confirmation channels, see [`Config::trusted_peers_0conf`]. + /// The node will discover the LSP's supported protocols (LSPS1/LSPS2) on startup via [bLIP-50 / LSPS0] + /// and select the appropriate protocol per request automatically. /// /// The given `token` will be used by the LSP to authenticate the user. + /// `trust_peer_0conf` controls whether the node will additionally accept + /// 0-confirmation channels opened by this LSP. If `false`, 0-confirmation + /// acceptance for this peer falls back to [`Config::trusted_peers_0conf`]. + /// + /// May be called multiple times to register several LSPs. Duplicate `node_id`s are ignored. /// - /// [bLIP-51 / LSPS1]: https://github.com/lightning/blips/blob/master/blip-0051.md - pub fn set_liquidity_source_lsps1( + /// [bLIP-50 / LSPS0]: https://github.com/lightning/blips/blob/master/blip-0050.md + pub fn add_liquidity_source( &mut self, node_id: PublicKey, address: SocketAddress, token: Option, + trust_peer_0conf: bool, ) -> &mut Self { - // Mark the LSP as trusted for 0conf - self.config.trusted_peers_0conf.push(node_id.clone()); - let liquidity_source_config = self.liquidity_source_config.get_or_insert(LiquiditySourceConfig::default()); - let lsps1_client_config = LSPS1ClientConfig { node_id, address, token }; - liquidity_source_config.lsps1_client = Some(lsps1_client_config); - self - } - /// Configures the [`Node`] instance to source just-in-time inbound liquidity from the given - /// [bLIP-52 / LSPS2] service. - /// - /// Will mark the LSP as trusted for 0-confirmation channels, see [`Config::trusted_peers_0conf`]. - /// - /// The given `token` will be used by the LSP to authenticate the user. - /// - /// [bLIP-52 / LSPS2]: https://github.com/lightning/blips/blob/master/blip-0052.md - pub fn set_liquidity_source_lsps2( - &mut self, node_id: PublicKey, address: SocketAddress, token: Option, - ) -> &mut Self { - // Mark the LSP as trusted for 0conf - self.config.trusted_peers_0conf.push(node_id.clone()); + if liquidity_source_config.lsp_nodes.iter().any(|n| n.node_id == node_id) { + return self; + } - let liquidity_source_config = - self.liquidity_source_config.get_or_insert(LiquiditySourceConfig::default()); - let lsps2_client_config = LSPS2ClientConfig { node_id, address, token }; - liquidity_source_config.lsps2_client = Some(lsps2_client_config); + liquidity_source_config.lsp_nodes.push(LspConfig { + node_id, + address, + token, + trust_peer_0conf, + }); self } @@ -491,12 +505,12 @@ impl NodeBuilder { /// **Caution**: LSP service support is in **alpha** and is considered an experimental feature. /// /// [LSPS2]: https://github.com/BitcoinAndLightningLayerSpecs/lsp/blob/main/LSPS2/README.md - pub fn set_liquidity_provider_lsps2( - &mut self, service_config: LSPS2ServiceConfig, + pub fn enable_liquidity_provider( + &mut self, lsps2_service_config: LSPS2ServiceConfig, ) -> &mut Self { let liquidity_source_config = self.liquidity_source_config.get_or_insert(LiquiditySourceConfig::default()); - liquidity_source_config.lsps2_service = Some(service_config); + liquidity_source_config.lsps2_service = Some(lsps2_service_config); self } @@ -615,16 +629,6 @@ impl NodeBuilder { Ok(self) } - /// Configures the [`Node`] to resync chain data from genesis on first startup, recovering any - /// historical wallet funds. - /// - /// This should only be set on first startup when importing an older wallet from a previously - /// used [`NodeEntropy`]. - pub fn set_wallet_recovery_mode(&mut self) -> &mut Self { - self.recovery_mode = true; - self - } - /// Builds a [`Node`] instance with a [`SqliteStore`] backend and according to the options /// previously configured. pub fn build(&self, node_entropy: NodeEntropy) -> Result { @@ -826,7 +830,7 @@ impl NodeBuilder { } /// Builds a [`Node`] instance according to the options previously configured. - pub fn build_with_store( + pub fn build_with_store( &self, node_entropy: NodeEntropy, kv_store: S, ) -> Result { let logger = setup_logger(&self.log_writer_config, &self.config)?; @@ -845,14 +849,14 @@ impl NodeBuilder { } } - fn build_with_store_and_logger( + fn build_with_store_and_logger( &self, node_entropy: NodeEntropy, kv_store: S, logger: Arc, ) -> Result { let runtime = self.setup_runtime(&logger)?; self.build_with_store_runtime_and_logger(node_entropy, kv_store, runtime, logger) } - fn build_with_store_runtime_and_logger( + fn build_with_store_runtime_and_logger( &self, node_entropy: NodeEntropy, kv_store: S, runtime: Arc, logger: Arc, ) -> Result { let seed_bytes = node_entropy.to_seed_bytes(); @@ -865,7 +869,6 @@ impl NodeBuilder { self.liquidity_source_config.as_ref(), self.pathfinding_scores_sync_config.as_ref(), self.async_payments_role, - self.recovery_mode, seed_bytes, runtime, logger, @@ -889,7 +892,7 @@ impl NodeBuilder { /// - [`build`] uses an SQLite database (recommended default). /// - [`build_with_fs_store`] uses a filesystem-based store. /// - [`build_with_vss_store`] and variants use a [VSS] remote store (**experimental**). -/// - [`build_with_store`] allows providing a custom [`KVStore`] implementation. +/// - [`build_with_store`] allows providing a custom [`PaginatedKVStore`] implementation. /// /// ### Logging /// @@ -905,7 +908,7 @@ impl NodeBuilder { /// [`build_with_vss_store`]: Self::build_with_vss_store /// [`build_with_store`]: Self::build_with_store /// [VSS]: https://github.com/lightningdevkit/vss-server/blob/main/README.md -/// [`KVStore`]: lightning::util::persist::KVStore +/// [`PaginatedKVStore`]: lightning::util::persist::PaginatedKVStore /// [`DEFAULT_LOG_LEVEL`]: crate::config::DEFAULT_LOG_LEVEL /// [`set_filesystem_logger`]: Self::set_filesystem_logger /// [`set_log_facade_logger`]: Self::set_log_facade_logger @@ -979,14 +982,20 @@ impl ArcedNodeBuilder { /// ## Parameters: /// * `rpc_host`, `rpc_port`, `rpc_user`, `rpc_password` - Required parameters for the Bitcoin Core RPC /// connection. + /// * `wallet_rescan_from_height` - Optional wallet birthday height to rescan from on first + /// startup, before wallet state exists. Existing wallets are not rewound. The height must + /// be at or below the current tip. Passing `Some(0)` rescans from genesis; passing `None` + /// checkpoints at the current tip. pub fn set_chain_source_bitcoind_rpc( &self, rpc_host: String, rpc_port: u16, rpc_user: String, rpc_password: String, + wallet_rescan_from_height: Option, ) { self.inner.write().expect("lock").set_chain_source_bitcoind_rpc( rpc_host, rpc_port, rpc_user, rpc_password, + wallet_rescan_from_height, ); } @@ -999,9 +1008,13 @@ impl ArcedNodeBuilder { /// * `rest_host`, `rest_port` - Required parameters for the Bitcoin Core REST connection. /// * `rpc_host`, `rpc_port`, `rpc_user`, `rpc_password` - Required parameters for the Bitcoin Core RPC /// connection + /// * `wallet_rescan_from_height` - Optional wallet birthday height to rescan from on first + /// startup, before wallet state exists. Existing wallets are not rewound. The height must + /// be at or below the current tip. Passing `Some(0)` rescans from genesis; passing `None` + /// checkpoints at the current tip. pub fn set_chain_source_bitcoind_rest( &self, rest_host: String, rest_port: u16, rpc_host: String, rpc_port: u16, - rpc_user: String, rpc_password: String, + rpc_user: String, rpc_password: String, wallet_rescan_from_height: Option, ) { self.inner.write().expect("lock").set_chain_source_bitcoind_rest( rest_host, @@ -1010,6 +1023,7 @@ impl ArcedNodeBuilder { rpc_port, rpc_user, rpc_password, + wallet_rescan_from_height, ); } @@ -1032,32 +1046,29 @@ impl ArcedNodeBuilder { self.inner.write().expect("lock").set_pathfinding_scores_source(url); } - /// Configures the [`Node`] instance to source inbound liquidity from the given - /// [bLIP-51 / LSPS1] service. + /// Configures the [`Node`] instance to source inbound liquidity from the given LSP. /// - /// Will mark the LSP as trusted for 0-confirmation channels, see [`Config::trusted_peers_0conf`]. + /// The node will discover the LSP's supported protocols (LSPS1/LSPS2) on startup via [bLIP-50 / LSPS0] + /// and select the appropriate protocol per request automatically. /// /// The given `token` will be used by the LSP to authenticate the user. + /// `trust_peer_0conf` controls whether the node will additionally accept + /// 0-confirmation channels opened by this LSP. If `false`, 0-confirmation + /// acceptance for this peer falls back to [`Config::trusted_peers_0conf`]. /// - /// [bLIP-51 / LSPS1]: https://github.com/lightning/blips/blob/master/blip-0051.md - pub fn set_liquidity_source_lsps1( - &self, node_id: PublicKey, address: SocketAddress, token: Option, - ) { - self.inner.write().expect("lock").set_liquidity_source_lsps1(node_id, address, token); - } - - /// Configures the [`Node`] instance to source just-in-time inbound liquidity from the given - /// [bLIP-52 / LSPS2] service. - /// - /// Will mark the LSP as trusted for 0-confirmation channels, see [`Config::trusted_peers_0conf`]. + /// May be called multiple times to register several LSPs. Duplicate `node_id`s are ignored. /// - /// The given `token` will be used by the LSP to authenticate the user. - /// - /// [bLIP-52 / LSPS2]: https://github.com/lightning/blips/blob/master/blip-0052.md - pub fn set_liquidity_source_lsps2( + /// [bLIP-50 / LSPS0]: https://github.com/lightning/blips/blob/master/blip-0050.md + pub fn add_liquidity_source( &self, node_id: PublicKey, address: SocketAddress, token: Option, + trust_peer_0conf: bool, ) { - self.inner.write().expect("lock").set_liquidity_source_lsps2(node_id, address, token); + self.inner.write().expect("lock").add_liquidity_source( + node_id, + address, + token, + trust_peer_0conf, + ); } /// Configures the [`Node`] instance to provide an [LSPS2] service, issuing just-in-time @@ -1066,8 +1077,8 @@ impl ArcedNodeBuilder { /// **Caution**: LSP service support is in **alpha** and is considered an experimental feature. /// /// [LSPS2]: https://github.com/BitcoinAndLightningLayerSpecs/lsp/blob/main/LSPS2/README.md - pub fn set_liquidity_provider_lsps2(&self, service_config: LSPS2ServiceConfig) { - self.inner.write().expect("lock").set_liquidity_provider_lsps2(service_config); + pub fn enable_liquidity_provider(&self, lsps2_service_config: LSPS2ServiceConfig) { + self.inner.write().expect("lock").enable_liquidity_provider(lsps2_service_config); } /// Sets the used storage directory path. @@ -1155,15 +1166,6 @@ impl ArcedNodeBuilder { self.inner.write().expect("lock").set_async_payments_role(role).map(|_| ()) } - /// Configures the [`Node`] to resync chain data from genesis on first startup, recovering any - /// historical wallet funds. - /// - /// This should only be set on first startup when importing an older wallet from a previously - /// used [`NodeEntropy`]. - pub fn set_wallet_recovery_mode(&self) { - self.inner.write().expect("lock").set_wallet_recovery_mode(); - } - /// Builds a [`Node`] instance with a [`SqliteStore`] backend and according to the options /// previously configured. pub fn build(&self, node_entropy: Arc) -> Result, BuildError> { @@ -1346,7 +1348,7 @@ impl ArcedNodeBuilder { /// Builds a [`Node`] instance according to the options previously configured. // Note that the generics here don't actually work for Uniffi, but we don't currently expose // this so its not needed. - pub fn build_with_store( + pub fn build_with_store( &self, node_entropy: Arc, kv_store: S, ) -> Result, BuildError> { self.inner.read().expect("lock").build_with_store(*node_entropy, kv_store).map(Arc::new) @@ -1359,8 +1361,8 @@ fn build_with_store_internal( gossip_source_config: Option<&GossipSourceConfig>, liquidity_source_config: Option<&LiquiditySourceConfig>, pathfinding_scores_sync_config: Option<&PathfindingScoresSyncConfig>, - async_payments_role: Option, recovery_mode: bool, seed_bytes: [u8; 64], - runtime: Arc, logger: Arc, kv_store: Arc, + async_payments_role: Option, seed_bytes: [u8; 64], runtime: Arc, + logger: Arc, kv_store: Arc, ) -> Result { optionally_install_rustls_cryptoprovider(); @@ -1476,6 +1478,7 @@ fn build_with_store_internal( rpc_user, rpc_password, rest_client_config, + .. }) => match rest_client_config { Some(rest_client_config) => runtime.block_on(async { ChainSource::new_bitcoind_rest( @@ -1529,6 +1532,12 @@ fn build_with_store_internal( }, }; let chain_source = Arc::new(chain_source); + let wallet_rescan_from_height = match chain_data_source_config { + Some(ChainDataSourceConfig::Bitcoind { wallet_rescan_from_height, .. }) => { + *wallet_rescan_from_height + }, + _ => None, + }; // Initialize the on-chain wallet and chain access let xprv = bitcoin::bip32::Xpriv::new_master(config.network, &seed_bytes).map_err(|e| { @@ -1571,8 +1580,33 @@ fn build_with_store_internal( }, })?; let bdk_wallet = match wallet_opt { - Some(wallet) => wallet, + Some(wallet) => { + // `wallet_rescan_from_height`, when set, is fresh-wallet-only. Rewinding a + // persisted wallet is not just replacing BDK's best block: its local-chain and + // tx-graph changesets are already persisted, and LDK state may also have synced + // to a later tip. A safe rewind needs an explicit recovery flow that invalidates + // all dependent state before replaying blocks. + wallet + }, None => { + // Guard against silently setting the wallet birthday to genesis on a fresh node: + // if we are creating a new wallet but failed to learn the current chain tip from + // a Bitcoin Core RPC/REST backend, we'd otherwise persist fresh wallet state + // pinned at height 0 and force a full-history rescan once the backend comes back. + // Abort cleanly instead so the misconfiguration surfaces on the first startup. + // Esplora/Electrum backends currently never return a tip at build time, so they + // retain their existing behavior. + if wallet_rescan_from_height.is_none() + && chain_tip_opt.is_none() + && matches!(chain_data_source_config, Some(ChainDataSourceConfig::Bitcoind { .. })) + { + log_error!( + logger, + "Failed to determine chain tip on first startup. Aborting to avoid pinning the wallet birthday to genesis." + ); + return Err(BuildError::ChainTipFetchFailed); + } + let mut wallet = runtime .block_on(async { BdkWallet::create(descriptor, change_descriptor) @@ -1585,23 +1619,67 @@ fn build_with_store_internal( BuildError::WalletSetupFailed })?; - if !recovery_mode { - 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); + // Decide which block (if any) to insert as the initial BDK checkpoint. If the + // bitcoind config provides a wallet rescan height, resolve that block and use it as + // the checkpoint. Otherwise, use the current chain tip to avoid any rescan. + let checkpoint_block = match wallet_rescan_from_height { + None => chain_tip_opt, + Some(height) => { + if let Some(chain_tip) = chain_tip_opt { + if height > chain_tip.height { + log_error!( + logger, + "Wallet rescan height {} is above current chain tip {}.", + height, + chain_tip.height + ); + return Err(BuildError::WalletRescanHeightTooHigh); + } + } + + let utxo_source = chain_source.as_utxo_source().ok_or_else(|| { + log_error!( + logger, + "Wallet rescan height requested but the chain source does not support block-by-height lookups.", + ); BuildError::WalletSetupFailed })?; - } + let hash_res = runtime.block_on(async { + lightning_block_sync::gossip::UtxoSource::get_block_hash_by_height( + &utxo_source, + height, + ) + .await + }); + match hash_res { + Ok(hash) => Some(BlockLocator::new(hash, height)), + Err(e) => { + log_error!( + logger, + "Failed to resolve block hash at height {} for wallet rescan: {:?}", + height, + e, + ); + return Err(BuildError::WalletSetupFailed); + }, + } + }, + }; + + if let Some(best_block) = checkpoint_block { + // Insert the checkpoint so BDK starts scanning from there instead of 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 }, @@ -1634,6 +1712,8 @@ fn build_with_store_internal( Arc::clone(&pending_payment_store), )); + tx_broadcaster.set_wallet(Arc::downgrade(&wallet)); + // Initialize the KeysManager let cur_time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).map_err(|e| { log_error!(logger, "Failed to get current time: {}", e); @@ -1975,33 +2055,19 @@ fn build_with_store_internal( }, }; - let (liquidity_source, custom_message_handler) = - if let Some(lsc) = liquidity_source_config.as_ref() { - let mut liquidity_source_builder = LiquiditySourceBuilder::new( - Arc::clone(&wallet), - Arc::clone(&channel_manager), - Arc::clone(&keys_manager), - Arc::clone(&tx_broadcaster), - Arc::clone(&kv_store), - Arc::clone(&config), - Arc::clone(&logger), - ); - - lsc.lsps1_client.as_ref().map(|config| { - liquidity_source_builder.lsps1_client( - config.node_id, - config.address.clone(), - config.token.clone(), - ) - }); + let (liquidity_source, custom_message_handler) = { + let mut liquidity_source_builder = LiquiditySourceBuilder::new( + Arc::clone(&wallet), + Arc::clone(&channel_manager), + Arc::clone(&keys_manager), + Arc::clone(&tx_broadcaster), + Arc::clone(&kv_store), + Arc::clone(&config), + Arc::clone(&logger), + ); - lsc.lsps2_client.as_ref().map(|config| { - liquidity_source_builder.lsps2_client( - config.node_id, - config.address.clone(), - config.token.clone(), - ) - }); + if let Some(lsc) = liquidity_source_config.as_ref() { + liquidity_source_builder.set_lsp_nodes(lsc.lsp_nodes.clone()); let promise_secret = { let lsps_xpriv = derive_xprv( @@ -2015,15 +2081,15 @@ fn build_with_store_internal( lsc.lsps2_service.as_ref().map(|config| { liquidity_source_builder.lsps2_service(promise_secret, config.clone()) }); + } - let liquidity_source = runtime - .block_on(async move { liquidity_source_builder.build().await.map(Arc::new) })?; - let custom_message_handler = - Arc::new(NodeCustomMessageHandler::new_liquidity(Arc::clone(&liquidity_source))); - (Some(liquidity_source), custom_message_handler) - } else { - (None, Arc::new(NodeCustomMessageHandler::new_ignoring())) - }; + let liquidity_source = runtime + .block_on(async move { liquidity_source_builder.build().await.map(Arc::new) })?; + let custom_message_handler = + Arc::new(NodeCustomMessageHandler::new(Arc::clone(&liquidity_source))); + + (liquidity_source, custom_message_handler) + }; let msg_handler = match gossip_source.as_gossip_sync() { GossipSync::P2P(p2p_gossip_sync) => MessageHandler { @@ -2072,7 +2138,7 @@ fn build_with_store_internal( })); } - liquidity_source.as_ref().map(|l| l.set_peer_manager(Arc::downgrade(&peer_manager))); + liquidity_source.lsps2_service().set_peer_manager(Arc::downgrade(&peer_manager)); let connection_manager = Arc::new(ConnectionManager::new( Arc::clone(&peer_manager), diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index ad0ef1b7ba..23c930d983 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -6,6 +6,7 @@ // accordance with one or both of these licenses. use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, RwLock}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -50,6 +51,7 @@ pub(super) struct ElectrumChainSource { config: Arc, logger: Arc, node_metrics: Arc, + force_wallet_full_scan: AtomicBool, } impl ElectrumChainSource { @@ -61,6 +63,7 @@ impl ElectrumChainSource { let electrum_runtime_status = RwLock::new(ElectrumRuntimeStatus::new()); let onchain_wallet_sync_status = Mutex::new(WalletSyncStatus::Completed); let lightning_wallet_sync_status = Mutex::new(WalletSyncStatus::Completed); + let force_wallet_full_scan = AtomicBool::new(sync_config.force_wallet_full_scan); Self { server_url, sync_config, @@ -72,6 +75,7 @@ impl ElectrumChainSource { config, logger: Arc::clone(&logger), node_metrics, + force_wallet_full_scan, } } @@ -125,9 +129,11 @@ impl ElectrumChainSource { return Err(Error::FeerateEstimationUpdateFailed); }; // If this is our first sync, do a full scan with the configured gap limit. - // Otherwise just do an incremental sync. - let incremental_sync = + // Otherwise just do an incremental sync, unless a forced full scan is still pending. + let has_prior_sync = self.node_metrics.read().expect("lock").latest_onchain_wallet_sync_timestamp.is_some(); + let forced_full_scan = self.force_wallet_full_scan.load(Ordering::Acquire); + let incremental_sync = has_prior_sync && !forced_full_scan; let cached_txs = onchain_wallet.get_cached_txs(); @@ -160,6 +166,9 @@ impl ElectrumChainSource { .await }; + if forced_full_scan && res.is_ok() { + self.force_wallet_full_scan.store(false, Ordering::Release); + } res } @@ -426,10 +435,11 @@ impl ElectrumRuntimeClient { ); let bdk_electrum_client = Arc::new(BdkElectrumClient::new(Arc::clone(&electrum_client))); let tx_sync = Arc::new( - ElectrumSyncClient::new(server_url.clone(), Arc::clone(&logger)).map_err(|e| { - log_error!(logger, "Failed to connect to electrum server: {}", e); - Error::ConnectionFailed - })?, + ElectrumSyncClient::from_client(Arc::clone(&electrum_client), Arc::clone(&logger)) + .map_err(|e| { + log_error!(logger, "Failed to connect to electrum server: {}", e); + Error::ConnectionFailed + })?, ); Ok(Self { electrum_client, diff --git a/src/chain/esplora.rs b/src/chain/esplora.rs index eb23a395d3..0754986e8b 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -6,6 +6,7 @@ // accordance with one or both of these licenses. use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -38,6 +39,7 @@ pub(super) struct EsploraChainSource { config: Arc, logger: Arc, node_metrics: Arc, + force_wallet_full_scan: AtomicBool, } impl EsploraChainSource { @@ -62,6 +64,7 @@ impl EsploraChainSource { let onchain_wallet_sync_status = Mutex::new(WalletSyncStatus::Completed); let lightning_wallet_sync_status = Mutex::new(WalletSyncStatus::Completed); + let force_wallet_full_scan = AtomicBool::new(sync_config.force_wallet_full_scan); Ok(Self { sync_config, esplora_client, @@ -73,6 +76,7 @@ impl EsploraChainSource { config, logger, node_metrics, + force_wallet_full_scan, }) } @@ -101,9 +105,11 @@ impl EsploraChainSource { async fn sync_onchain_wallet_inner(&self, onchain_wallet: Arc) -> Result<(), Error> { // If this is our first sync, do a full scan with the configured gap limit. - // Otherwise just do an incremental sync. - let incremental_sync = + // Otherwise just do an incremental sync, unless a forced full scan is still pending. + let has_prior_sync = self.node_metrics.read().expect("lock").latest_onchain_wallet_sync_timestamp.is_some(); + let forced_full_scan = self.force_wallet_full_scan.load(Ordering::Acquire); + let incremental_sync = has_prior_sync && !forced_full_scan; macro_rules! get_and_apply_wallet_update { ($sync_future: expr) => {{ @@ -177,7 +183,7 @@ impl EsploraChainSource { }} } - if incremental_sync { + let res = if incremental_sync { let sync_request = onchain_wallet.get_incremental_sync_request(); let wallet_sync_timeout_fut = tokio::time::timeout( Duration::from_secs( @@ -199,7 +205,11 @@ impl EsploraChainSource { ), ); get_and_apply_wallet_update!(wallet_sync_timeout_fut) + }; + if forced_full_scan && res.is_ok() { + self.force_wallet_full_scan.store(false, Ordering::Release); } + res } pub(super) async fn sync_lightning_wallet( diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 92c4bdb641..8a8115e4f5 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -9,11 +9,11 @@ pub(crate) mod bitcoind; mod electrum; mod esplora; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; use std::time::Duration; -use bitcoin::{Script, Txid}; +use bitcoin::{Script, Transaction, Txid}; use lightning::chain::{BlockLocator, Filter}; use crate::chain::bitcoind::{BitcoindChainSource, UtxoSourceClient}; @@ -24,7 +24,7 @@ use crate::config::{ WALLET_SYNC_INTERVAL_MINIMUM_SECS, }; use crate::fee_estimator::OnchainFeeEstimator; -use crate::logger::{log_debug, log_info, log_trace, LdkLogger, Logger}; +use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; use crate::runtime::Runtime; use crate::types::{Broadcaster, ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::{Error, PersistedNodeMetrics}; @@ -84,7 +84,7 @@ impl WalletSyncStatus { pub(crate) struct ChainSource { kind: ChainSourceKind, - registered_txids: Mutex>, + registered_txids: Mutex>, tx_broadcaster: Arc, logger: Arc, } @@ -113,7 +113,7 @@ impl ChainSource { node_metrics, )?; let kind = ChainSourceKind::Esplora(esplora_chain_source); - let registered_txids = Mutex::new(Vec::new()); + let registered_txids = Mutex::new(HashSet::new()); Ok((Self { kind, registered_txids, tx_broadcaster, logger }, None)) } @@ -133,7 +133,7 @@ impl ChainSource { node_metrics, ); let kind = ChainSourceKind::Electrum(electrum_chain_source); - let registered_txids = Mutex::new(Vec::new()); + let registered_txids = Mutex::new(HashSet::new()); (Self { kind, registered_txids, tx_broadcaster, logger }, None) } @@ -156,7 +156,7 @@ impl ChainSource { ); let best_block = bitcoind_chain_source.poll_best_block().await.ok(); let kind = ChainSourceKind::Bitcoind(bitcoind_chain_source); - let registered_txids = Mutex::new(Vec::new()); + let registered_txids = Mutex::new(HashSet::new()); (Self { kind, registered_txids, tx_broadcaster, logger }, best_block) } @@ -180,7 +180,7 @@ impl ChainSource { ); let best_block = bitcoind_chain_source.poll_best_block().await.ok(); let kind = ChainSourceKind::Bitcoind(bitcoind_chain_source); - let registered_txids = Mutex::new(Vec::new()); + let registered_txids = Mutex::new(HashSet::new()); (Self { kind, registered_txids, tx_broadcaster, logger }, best_block) } @@ -214,7 +214,7 @@ impl ChainSource { } } - pub(crate) fn registered_txids(&self) -> Vec { + pub(crate) fn registered_txids(&self) -> HashSet { self.registered_txids.lock().expect("lock").clone() } @@ -453,15 +453,30 @@ impl ChainSource { return; } Some(next_package) = receiver.recv() => { + // Classify funding broadcasts into payment records before sending. If + // classification fails we skip the broadcast, since broadcasting a tx we + // failed to record would leave it on-chain without a payment. + let package = match self.tx_broadcaster.classify_package(next_package).await { + Ok(package) => package, + Err(e) => { + log_error!( + tx_bcast_logger, + "Skipping broadcast: failed to persist payment records: {:?}", + e, + ); + continue; + }, + }; + let txs: Vec = package.into_transactions(); match &self.kind { ChainSourceKind::Esplora(esplora_chain_source) => { - esplora_chain_source.process_broadcast_package(next_package).await + esplora_chain_source.process_broadcast_package(txs).await }, ChainSourceKind::Electrum(electrum_chain_source) => { - electrum_chain_source.process_broadcast_package(next_package).await + electrum_chain_source.process_broadcast_package(txs).await }, ChainSourceKind::Bitcoind(bitcoind_chain_source) => { - bitcoind_chain_source.process_broadcast_package(next_package).await + bitcoind_chain_source.process_broadcast_package(txs).await }, } } @@ -472,7 +487,7 @@ impl ChainSource { impl Filter for ChainSource { fn register_tx(&self, txid: &Txid, script_pubkey: &Script) { - self.registered_txids.lock().expect("lock").push(*txid); + self.registered_txids.lock().expect("lock").insert(*txid); match &self.kind { ChainSourceKind::Esplora(esplora_chain_source) => { esplora_chain_source.register_tx(txid, script_pubkey) diff --git a/src/config.rs b/src/config.rs index 558a4d0618..ad1b911819 100644 --- a/src/config.rs +++ b/src/config.rs @@ -506,6 +506,11 @@ pub struct EsploraSyncConfig { pub background_sync_config: Option, /// Sync timeouts configuration. pub timeouts_config: SyncTimeoutsConfig, + /// Whether to force BDK full scans until one succeeds. + /// + /// This can be useful when restoring a wallet from seed on a node that has already synced + /// before, but may be missing funds sent to previously-unknown addresses. + pub force_wallet_full_scan: bool, } impl Default for EsploraSyncConfig { @@ -513,6 +518,7 @@ impl Default for EsploraSyncConfig { Self { background_sync_config: Some(BackgroundSyncConfig::default()), timeouts_config: SyncTimeoutsConfig::default(), + force_wallet_full_scan: false, } } } @@ -533,6 +539,11 @@ pub struct ElectrumSyncConfig { pub background_sync_config: Option, /// Sync timeouts configuration. pub timeouts_config: SyncTimeoutsConfig, + /// Whether to force BDK full scans until one succeeds. + /// + /// This can be useful when restoring a wallet from seed on a node that has already synced + /// before, but may be missing funds sent to previously-unknown addresses. + pub force_wallet_full_scan: bool, } impl Default for ElectrumSyncConfig { @@ -540,6 +551,7 @@ impl Default for ElectrumSyncConfig { Self { background_sync_config: Some(BackgroundSyncConfig::default()), timeouts_config: SyncTimeoutsConfig::default(), + force_wallet_full_scan: false, } } } diff --git a/src/data_store.rs b/src/data_store.rs index 70abfcc3fd..13afeca7e3 100644 --- a/src/data_store.rs +++ b/src/data_store.rs @@ -5,7 +5,7 @@ // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in // accordance with one or both of these licenses. -use std::collections::{hash_map, HashMap}; +use std::collections::HashMap; use std::ops::Deref; use std::sync::{Arc, Mutex}; @@ -83,34 +83,38 @@ where pub(crate) async fn insert_or_update(&self, object: SO) -> Result { let _guard = self.mutation_lock.lock().await; - let (updated, data_to_persist) = { - let mut locked_objects = self.objects.lock().expect("lock"); - match locked_objects.entry(object.id()) { - hash_map::Entry::Occupied(mut e) => { - let update = object.to_update(); - let updated = e.get_mut().update(update); - let data_to_persist = - if updated { Some(Self::encode_object(e.get())) } else { None }; - (updated, data_to_persist) - }, - hash_map::Entry::Vacant(e) => { - let data_to_persist = Self::encode_object(&object); - e.insert(object); - (true, Some(data_to_persist)) - }, + + let id = object.id(); + let data_to_persist = { + let locked_objects = self.objects.lock().expect("lock"); + if let Some(existing_object) = locked_objects.get(&id) { + let mut updated_object = existing_object.clone(); + let updated = updated_object.update(object.to_update()); + if updated { + Some(updated_object) + } else { + None + } + } else { + Some(object) } }; - if let Some((store_key, data)) = data_to_persist { - self.persist_encoded(store_key, data).await?; + match data_to_persist { + Some(updated_object) => { + self.persist(&updated_object).await?; + let mut locked_objects = self.objects.lock().expect("lock"); + locked_objects.insert(id, updated_object); + Ok(true) + }, + None => Ok(false), } - Ok(updated) } pub(crate) async fn remove(&self, id: &SO::Id) -> Result<(), Error> { let _guard = self.mutation_lock.lock().await; - let removed = { self.objects.lock().expect("lock").remove(id).is_some() }; - if removed { + let should_remove = { self.objects.lock().expect("lock").contains_key(id) }; + if should_remove { let store_key = id.encode_to_hex_str(); KVStore::remove( &*self.kv_store, @@ -131,6 +135,7 @@ where ); Error::PersistenceFailed })?; + self.objects.lock().expect("lock").remove(id); } Ok(()) } @@ -138,38 +143,38 @@ where /// Returns the current in-memory object for `id`. /// /// The async mutation lock serializes writers, but this synchronous reader cannot wait on it. - /// Until store reads are async, callers may temporarily see in-memory state that is either - /// still being persisted or has not yet caught up to a write in progress. + /// Until store reads are async, callers may temporarily see in-memory state that has not yet + /// caught up to a write in progress. pub(crate) fn get(&self, id: &SO::Id) -> Option { self.objects.lock().expect("lock").get(id).cloned() } pub(crate) async fn update(&self, update: SO::Update) -> Result { let _guard = self.mutation_lock.lock().await; - let (res, data_to_persist) = { - let mut locked_objects = self.objects.lock().expect("lock"); - if let Some(object) = locked_objects.get_mut(&update.id()) { - let updated = object.update(update); - if updated { - (DataStoreUpdateResult::Updated, Some(Self::encode_object(object))) - } else { - (DataStoreUpdateResult::Unchanged, None) - } - } else { - (DataStoreUpdateResult::NotFound, None) + let id = update.id(); + let updated_object = { + let locked_objects = self.objects.lock().expect("lock"); + let Some(object) = locked_objects.get(&id) else { + return Ok(DataStoreUpdateResult::NotFound); + }; + let mut updated_object = object.clone(); + if !updated_object.update(update) { + return Ok(DataStoreUpdateResult::Unchanged); } + updated_object }; - if let Some((store_key, data)) = data_to_persist { - self.persist_encoded(store_key, data).await?; - } - Ok(res) + + self.persist(&updated_object).await?; + let mut locked_objects = self.objects.lock().expect("lock"); + locked_objects.insert(id, updated_object); + Ok(DataStoreUpdateResult::Updated) } /// Returns in-memory objects matching `f`. /// /// The async mutation lock serializes writers, but this synchronous reader cannot wait on it. - /// Until store reads are async, callers may temporarily see in-memory state that is either - /// still being persisted or has not yet caught up to a write in progress. + /// Until store reads are async, callers may temporarily see in-memory state that has not yet + /// caught up to a write in progress. pub(crate) fn list_filter bool>(&self, f: F) -> Vec { self.objects.lock().expect("lock").values().filter(f).cloned().collect::>() } @@ -209,8 +214,8 @@ where /// Returns whether the in-memory store contains `id`. /// /// The async mutation lock serializes writers, but this synchronous reader cannot wait on it. - /// Until store reads are async, callers may temporarily see in-memory state that is either - /// still being persisted or has not yet caught up to a write in progress. + /// Until store reads are async, callers may temporarily see in-memory state that has not yet + /// caught up to a write in progress. pub(crate) fn contains_key(&self, id: &SO::Id) -> bool { self.objects.lock().expect("lock").contains_key(id) } @@ -219,6 +224,8 @@ where #[cfg(test)] mod tests { use lightning::impl_writeable_tlv_based; + use lightning::io; + use lightning::util::persist::{PageToken, PaginatedKVStore, PaginatedListResponse}; use lightning::util::test_utils::TestLogger; use super::*; @@ -281,6 +288,56 @@ mod tests { (2, data, required), }); + struct FailingStore; + + impl KVStore for FailingStore { + fn read( + &self, _primary_namespace: &str, _secondary_namespace: &str, _key: &str, + ) -> impl std::future::Future, io::Error>> + 'static + Send { + async { Err(io::Error::new(io::ErrorKind::Other, "read failed")) } + } + + fn write( + &self, _primary_namespace: &str, _secondary_namespace: &str, _key: &str, _buf: Vec, + ) -> impl std::future::Future> + 'static + Send { + async { Err(io::Error::new(io::ErrorKind::Other, "write failed")) } + } + + fn remove( + &self, _primary_namespace: &str, _secondary_namespace: &str, _key: &str, _lazy: bool, + ) -> impl std::future::Future> + 'static + Send { + async { Err(io::Error::new(io::ErrorKind::Other, "remove failed")) } + } + + fn list( + &self, _primary_namespace: &str, _secondary_namespace: &str, + ) -> impl std::future::Future, io::Error>> + 'static + Send { + async { Err(io::Error::new(io::ErrorKind::Other, "list failed")) } + } + } + + impl PaginatedKVStore for FailingStore { + fn list_paginated( + &self, _primary_namespace: &str, _secondary_namespace: &str, + _page_token: Option, + ) -> impl std::future::Future> + 'static + Send + { + async { Err(io::Error::new(io::ErrorKind::Other, "list_paginated failed")) } + } + } + + fn new_failing_data_store(objects: Vec) -> DataStore> { + let store: Arc = Arc::new(DynStoreWrapper(FailingStore)); + let logger = Arc::new(TestLogger::new()); + DataStore::new( + objects, + "datastore_test_primary".to_string(), + "datastore_test_secondary".to_string(), + store, + logger, + ) + } + #[tokio::test] async fn data_is_persisted() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); @@ -346,4 +403,54 @@ mod tests { new_iou_object.data[0] += 1; assert_eq!(Ok(true), data_store.insert_or_update(new_iou_object).await); } + + #[tokio::test] + async fn insert_or_update_does_not_mutate_memory_if_persist_fails() { + let existing_id = TestObjectId { id: [42u8; 4] }; + let existing_object = TestObject { id: existing_id, data: [23u8; 3] }; + let data_store = new_failing_data_store(vec![existing_object]); + + let updated_object = TestObject { id: existing_id, data: [24u8; 3] }; + assert_eq!( + Err(Error::PersistenceFailed), + data_store.insert_or_update(updated_object).await + ); + assert_eq!(Some(existing_object), data_store.get(&existing_id)); + + let new_id = TestObjectId { id: [55u8; 4] }; + let new_object = TestObject { id: new_id, data: [34u8; 3] }; + assert_eq!(Err(Error::PersistenceFailed), data_store.insert_or_update(new_object).await); + assert!(data_store.get(&new_id).is_none()); + } + + #[tokio::test] + async fn insert_does_not_mutate_memory_if_persist_fails() { + let id = TestObjectId { id: [42u8; 4] }; + let object = TestObject { id, data: [23u8; 3] }; + let data_store = new_failing_data_store(vec![]); + + assert_eq!(Err(Error::PersistenceFailed), data_store.insert(object).await); + assert!(data_store.get(&id).is_none()); + } + + #[tokio::test] + async fn update_does_not_mutate_memory_if_persist_fails() { + let id = TestObjectId { id: [42u8; 4] }; + let object = TestObject { id, data: [23u8; 3] }; + let data_store = new_failing_data_store(vec![object]); + + let update = TestObjectUpdate { id, data: [24u8; 3] }; + assert_eq!(Err(Error::PersistenceFailed), data_store.update(update).await); + assert_eq!(Some(object), data_store.get(&id)); + } + + #[tokio::test] + async fn remove_does_not_mutate_memory_if_persist_fails() { + let id = TestObjectId { id: [42u8; 4] }; + let object = TestObject { id, data: [23u8; 3] }; + let data_store = new_failing_data_store(vec![object]); + + assert_eq!(Err(Error::PersistenceFailed), data_store.remove(&id).await); + assert_eq!(Some(object), data_store.get(&id)); + } } diff --git a/src/event.rs b/src/event.rs index 86ee7bb05a..93d274ff7f 100644 --- a/src/event.rs +++ b/src/event.rs @@ -533,7 +533,7 @@ where connection_manager: Arc>, output_sweeper: Arc, network_graph: Arc, - liquidity_source: Option>>>, + liquidity_source: Arc>>, payment_store: Arc, peer_store: Arc>, keys_manager: Arc, @@ -554,11 +554,11 @@ where bump_tx_event_handler: Arc, channel_manager: Arc, connection_manager: Arc>, output_sweeper: Arc, network_graph: Arc, - liquidity_source: Option>>>, - payment_store: Arc, peer_store: Arc>, - keys_manager: Arc, static_invoice_store: Option, - onion_messenger: Arc, om_mailbox: Option>, - runtime: Arc, logger: L, config: Arc, + liquidity_source: Arc>>, payment_store: Arc, + peer_store: Arc>, keys_manager: Arc, + static_invoice_store: Option, onion_messenger: Arc, + om_mailbox: Option>, runtime: Arc, logger: L, + config: Arc, ) -> Self { Self { event_queue, @@ -637,22 +637,21 @@ where locktime, ) { Ok(final_tx) => { - let needs_manual_broadcast = - self.liquidity_source.as_ref().map_or(false, |ls| { - ls.as_ref().lsps2_channel_needs_manual_broadcast( - counterparty_node_id, - user_channel_id, - ) - }); + let needs_manual_broadcast = self + .liquidity_source + .lsps2_service() + .lsps2_channel_needs_manual_broadcast( + counterparty_node_id, + user_channel_id, + ); let result = if needs_manual_broadcast { - self.liquidity_source.as_ref().map(|ls| { - ls.lsps2_store_funding_transaction( - user_channel_id, - counterparty_node_id, - final_tx.clone(), - ); - }); + self.liquidity_source.lsps2_service().lsps2_store_funding_transaction( + user_channel_id, + counterparty_node_id, + final_tx.clone(), + ); + self.channel_manager.funding_transaction_generated_manual_broadcast( temporary_channel_id, counterparty_node_id, @@ -710,9 +709,9 @@ where } }, LdkEvent::FundingTxBroadcastSafe { user_channel_id, counterparty_node_id, .. } => { - self.liquidity_source.as_ref().map(|ls| { - ls.lsps2_funding_tx_broadcast_safe(user_channel_id, counterparty_node_id); - }); + self.liquidity_source + .lsps2_service() + .lsps2_funding_tx_broadcast_safe(user_channel_id, counterparty_node_id); }, LdkEvent::PaymentClaimable { payment_hash, @@ -1213,9 +1212,10 @@ where LdkEvent::ProbeSuccessful { .. } => {}, LdkEvent::ProbeFailed { .. } => {}, LdkEvent::HTLCHandlingFailed { failure_type, .. } => { - if let Some(liquidity_source) = self.liquidity_source.as_ref() { - liquidity_source.handle_htlc_handling_failed(failure_type).await; - } + self.liquidity_source + .lsps2_service() + .handle_htlc_handling_failed(failure_type) + .await; }, LdkEvent::SpendableOutputs { outputs, channel_id, counterparty_node_id } => { match self @@ -1315,35 +1315,36 @@ where .try_into() .expect("slice is exactly 16 bytes"), ); - let allow_0conf = self.config.trusted_peers_0conf.contains(&counterparty_node_id); + let mut allow_0conf = + self.config.trusted_peers_0conf.contains(&counterparty_node_id); let mut channel_override_config = None; - if let Some((lsp_node_id, _)) = self - .liquidity_source - .as_ref() - .and_then(|ls| ls.as_ref().get_lsps2_lsp_details()) + + // If the peer is a configured LSP node, additionally honor its trust_peer_0conf flag. + if let Some(lsp) = + self.liquidity_source.get_lsp_config(&counterparty_node_id, 2).await { - if lsp_node_id == counterparty_node_id { - // When we're an LSPS2 client, allow claiming underpaying HTLCs as the LSP will skim off some fee. We'll - // check that they don't take too much before claiming. - channel_override_config = Some(ChannelConfigOverrides { - update_overrides: Some(ChannelConfigUpdate { - accept_underpaying_htlcs: Some(true), - ..Default::default() - }), + allow_0conf = allow_0conf || lsp.trust_peer_0conf; + + // When we're an LSPS2 client, allow claiming underpaying HTLCs as the LSP will skim off some fee. We'll + // check that they don't take too much before claiming. + channel_override_config = Some(ChannelConfigOverrides { + update_overrides: Some(ChannelConfigUpdate { + accept_underpaying_htlcs: Some(true), ..Default::default() - }); + }), + ..Default::default() + }); - // LSPS2 channels are unannounced; rely on LDK's default of 100% - // inbound HTLC value-in-flight so the LSP can forward the initial - // payment in full. - debug_assert_eq!( - self.channel_manager - .get_current_config() - .channel_handshake_config - .unannounced_channel_max_inbound_htlc_value_in_flight_percentage, - 100 - ); - } + // LSPS2 channels are unannounced; rely on LDK's default of 100% + // inbound HTLC value-in-flight so the LSP can forward the initial + // payment in full. + debug_assert_eq!( + self.channel_manager + .get_current_config() + .channel_handshake_config + .unannounced_channel_max_inbound_htlc_value_in_flight_percentage, + 100 + ); } let res = if allow_0conf { self.channel_manager.accept_inbound_channel_from_trusted_peer( @@ -1468,13 +1469,15 @@ where "unexpected skimmed fee for trampoline forward, fee may be double counted" ); } - if let Some(liquidity_source) = self.liquidity_source.as_ref() { - let skimmed_fee_msat = skimmed_fee_msat.unwrap_or(0); - for next_htlc in next_htlcs.iter() { - liquidity_source - .handle_payment_forwarded(Some(next_htlc.channel_id), skimmed_fee_msat) - .await; - } + + for next_htlc in next_htlcs.iter() { + self.liquidity_source + .lsps2_service() + .handle_payment_forwarded( + Some(next_htlc.channel_id), + skimmed_fee_msat.unwrap_or(0), + ) + .await; } let event = Event::PaymentForwarded { @@ -1582,11 +1585,10 @@ where ); } - if let Some(liquidity_source) = self.liquidity_source.as_ref() { - liquidity_source - .handle_channel_ready(user_channel_id, &channel_id, &counterparty_node_id) - .await; - } + self.liquidity_source + .lsps2_service() + .handle_channel_ready(user_channel_id, &channel_id, &counterparty_node_id) + .await; let event = Event::ChannelReady { channel_id, @@ -1646,7 +1648,7 @@ where }) .collect(), }; - if let Err(e) = self.wallet.cancel_tx(&tx) { + if let Err(e) = self.wallet.cancel_tx(tx) { log_error!(self.logger, "Failed reclaiming unused addresses: {}", e); return Err(ReplayEvent()); } @@ -1659,16 +1661,15 @@ where payment_hash, .. } => { - if let Some(liquidity_source) = self.liquidity_source.as_ref() { - liquidity_source - .handle_htlc_intercepted( - requested_next_hop_scid, - intercept_id, - expected_outbound_amount_msat, - payment_hash, - ) - .await; - } + self.liquidity_source + .lsps2_service() + .handle_htlc_intercepted( + requested_next_hop_scid, + intercept_id, + expected_outbound_amount_msat, + payment_hash, + ) + .await; }, LdkEvent::InvoiceReceived { .. } => { debug_assert!(false, "We currently don't handle BOLT12 invoices manually, so this event should never be emitted."); diff --git a/src/fee_estimator.rs b/src/fee_estimator.rs index 34fe7b64ca..b785bfca40 100644 --- a/src/fee_estimator.rs +++ b/src/fee_estimator.rs @@ -164,3 +164,42 @@ pub(crate) fn apply_post_estimation_adjustments( _ => estimated_rate, } } + +/// The most we are willing to pay for a channel funding transaction: `1.5x` our funding feerate +/// estimate. Used as the `max_feerate` ceiling for splices and their RBF fee bumps. +pub(crate) fn max_funding_feerate(estimate: FeeRate) -> FeeRate { + FeeRate::from_sat_per_kwu(estimate.to_sat_per_kwu() * 3 / 2) +} + +/// Picks the `(target, max)` feerates for replacing a pending splice's in-flight funding +/// transaction via RBF, or `None` if the RBF can't be done within our fee ceiling. +/// +/// `max` is the most we are willing to pay (see [`max_funding_feerate`]), which tracks our current +/// estimate and so may have risen or fallen since the original splice; it is never inflated to meet +/// the RBF minimum. `target` is what we actually pay — our current estimate, or the template's RBF +/// minimum if that is higher (required to replace the transaction). If that minimum exceeds `max`, +/// we can't RBF. +pub(crate) fn rbf_splice_feerates( + estimate: FeeRate, min_rbf_feerate: FeeRate, +) -> Option<(FeeRate, FeeRate)> { + let max = max_funding_feerate(estimate); + let target = estimate.max(min_rbf_feerate); + (target <= max).then_some((target, max)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rbf_splice_feerates_target_and_max() { + let kwu = FeeRate::from_sat_per_kwu; + // Estimate below the RBF minimum but within our ceiling: pay the minimum to replace the + // transaction; the max stays 1.5x the estimate (never inflated) and already clears it. + assert_eq!(rbf_splice_feerates(kwu(253), kwu(278)), Some((kwu(278), kwu(253 * 3 / 2)))); + // Estimate risen above the RBF minimum: pay the higher estimate, not the stale minimum. + assert_eq!(rbf_splice_feerates(kwu(500), kwu(278)), Some((kwu(500), kwu(500 * 3 / 2)))); + // RBF minimum above our max (1.5x a fallen estimate): we can't RBF within our ceiling. + assert_eq!(rbf_splice_feerates(kwu(100), kwu(278)), None); + } +} diff --git a/src/ffi/types.rs b/src/ffi/types.rs index 7380d75cac..9bb03bb075 100644 --- a/src/ffi/types.rs +++ b/src/ffi/types.rs @@ -25,7 +25,7 @@ pub use bitcoin::{Address, BlockHash, Network, OutPoint, ScriptBuf, Txid}; pub use lightning::chain::channelmonitor::BalanceSource; use lightning::events::PaidBolt12Invoice as LdkPaidBolt12Invoice; pub use lightning::events::{ClosureReason, PaymentFailureReason}; -use lightning::ln::channel_state::ChannelShutdownState; +use lightning::ln::channel_state::{ChannelShutdownState, CounterpartyForwardingInfo}; use lightning::ln::channelmanager::PaymentId; use lightning::ln::msgs::DecodeError; pub use lightning::ln::types::ChannelId; @@ -44,7 +44,7 @@ pub use lightning_liquidity::lsps0::ser::LSPSDateTime; pub use lightning_liquidity::lsps1::msgs::{ LSPS1ChannelInfo, LSPS1OrderId, LSPS1OrderParams, LSPS1PaymentState, }; -use lightning_types::features::NodeFeatures as LdkNodeFeatures; +use lightning_types::features::{InitFeatures as LdkInitFeatures, NodeFeatures as LdkNodeFeatures}; pub use lightning_types::payment::{PaymentHash, PaymentPreimage, PaymentSecret}; pub use lightning_types::string::UntrustedString; use vss_client::headers::{ @@ -1526,6 +1526,7 @@ pub struct NodeFeatures { pub(crate) inner: LdkNodeFeatures, } +#[uniffi::export] impl NodeFeatures { /// Constructs node features from big-endian BOLT 9 encoded bytes. #[uniffi::constructor] @@ -1815,6 +1816,298 @@ impl From for NodeFeatures { } } +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Object)] +#[uniffi::export(Debug, Eq)] +pub struct InitFeatures { + pub(crate) inner: LdkInitFeatures, +} + +#[uniffi::export] +impl InitFeatures { + /// Constructs init features from big-endian BOLT 9 encoded bytes. + #[uniffi::constructor] + pub fn from_bytes(bytes: &[u8]) -> Self { + Self { inner: LdkInitFeatures::from_be_bytes(bytes.to_vec()).into() } + } + + /// Returns the BOLT 9 big-endian encoded representation of these features. + pub fn to_bytes(&self) -> Vec { + self.inner.encode() + } + + /// Whether the peer's `init` message advertises support for `option_static_remotekey`. + pub fn supports_static_remote_key(&self) -> bool { + self.inner.supports_static_remote_key() + } + + /// Whether the peer's `init` message requires `option_static_remotekey`. + pub fn requires_static_remote_key(&self) -> bool { + self.inner.requires_static_remote_key() + } + + /// Whether the peer's `init` message advertises support for `option_anchors_zero_fee_htlc_tx`. + pub fn supports_anchors_zero_fee_htlc_tx(&self) -> bool { + self.inner.supports_anchors_zero_fee_htlc_tx() + } + + /// Whether the peer's `init` message requires `option_anchors_zero_fee_htlc_tx`. + pub fn requires_anchors_zero_fee_htlc_tx(&self) -> bool { + self.inner.requires_anchors_zero_fee_htlc_tx() + } + + /// Whether the peer's `init` message advertises support for `option_anchors_nonzero_fee_htlc_tx`. + pub fn supports_anchors_nonzero_fee_htlc_tx(&self) -> bool { + self.inner.supports_anchors_nonzero_fee_htlc_tx() + } + + /// Whether the peer's `init` message requires `option_anchors_nonzero_fee_htlc_tx`. + pub fn requires_anchors_nonzero_fee_htlc_tx(&self) -> bool { + self.inner.requires_anchors_nonzero_fee_htlc_tx() + } + + /// Whether the peer's `init` message advertises support for `option_support_large_channel`. + pub fn supports_wumbo(&self) -> bool { + self.inner.supports_wumbo() + } + + /// Whether the peer's `init` message requires `option_support_large_channel`. + pub fn requires_wumbo(&self) -> bool { + self.inner.requires_wumbo() + } + + /// Whether the peer's `init` message advertises support for `option_route_blinding`. + pub fn supports_route_blinding(&self) -> bool { + self.inner.supports_route_blinding() + } + + /// Whether the peer's `init` message requires `option_route_blinding`. + pub fn requires_route_blinding(&self) -> bool { + self.inner.requires_route_blinding() + } + + /// Whether the peer's `init` message advertises support for `option_onion_messages`. + pub fn supports_onion_messages(&self) -> bool { + self.inner.supports_onion_messages() + } + + /// Whether the peer's `init` message requires `option_onion_messages`. + pub fn requires_onion_messages(&self) -> bool { + self.inner.requires_onion_messages() + } + + /// Whether the peer's `init` message advertises support for `option_scid_alias`. + pub fn supports_scid_privacy(&self) -> bool { + self.inner.supports_scid_privacy() + } + + /// Whether the peer's `init` message requires `option_scid_alias`. + pub fn requires_scid_privacy(&self) -> bool { + self.inner.requires_scid_privacy() + } + + /// Whether the peer's `init` message advertises support for `option_zeroconf`. + pub fn supports_zero_conf(&self) -> bool { + self.inner.supports_zero_conf() + } + + /// Whether the peer's `init` message requires `option_zeroconf`. + pub fn requires_zero_conf(&self) -> bool { + self.inner.requires_zero_conf() + } + + /// Whether the peer's `init` message advertises support for `option_dual_fund`. + pub fn supports_dual_fund(&self) -> bool { + self.inner.supports_dual_fund() + } + + /// Whether the peer's `init` message requires `option_dual_fund`. + pub fn requires_dual_fund(&self) -> bool { + self.inner.requires_dual_fund() + } + + /// Whether the peer's `init` message advertises support for `option_quiesce`. + pub fn supports_quiescence(&self) -> bool { + self.inner.supports_quiescence() + } + + /// Whether the peer's `init` message requires `option_quiesce`. + pub fn requires_quiescence(&self) -> bool { + self.inner.requires_quiescence() + } + + /// Whether the peer's `init` message advertises support for `option_data_loss_protect`. + pub fn supports_data_loss_protect(&self) -> bool { + self.inner.supports_data_loss_protect() + } + + /// Whether the peer's `init` message requires `option_data_loss_protect`. + pub fn requires_data_loss_protect(&self) -> bool { + self.inner.requires_data_loss_protect() + } + + /// Whether the peer's `init` message advertises support for `option_upfront_shutdown_script`. + pub fn supports_upfront_shutdown_script(&self) -> bool { + self.inner.supports_upfront_shutdown_script() + } + + /// Whether the peer's `init` message requires `option_upfront_shutdown_script`. + pub fn requires_upfront_shutdown_script(&self) -> bool { + self.inner.requires_upfront_shutdown_script() + } + + /// Whether the peer's `init` message advertises support for `gossip_queries`. + pub fn supports_gossip_queries(&self) -> bool { + self.inner.supports_gossip_queries() + } + + /// Whether the peer's `init` message requires `gossip_queries`. + pub fn requires_gossip_queries(&self) -> bool { + self.inner.requires_gossip_queries() + } + + /// Whether the peer's `init` message advertises support for `var_onion_optin`. + pub fn supports_variable_length_onion(&self) -> bool { + self.inner.supports_variable_length_onion() + } + + /// Whether the peer's `init` message requires `var_onion_optin`. + pub fn requires_variable_length_onion(&self) -> bool { + self.inner.requires_variable_length_onion() + } + + /// Whether the peer's `init` message advertises support for `payment_secret`. + pub fn supports_payment_secret(&self) -> bool { + self.inner.supports_payment_secret() + } + + /// Whether the peer's `init` message requires `payment_secret`. + pub fn requires_payment_secret(&self) -> bool { + self.inner.requires_payment_secret() + } + + /// Whether the peer's `init` message advertises support for `basic_mpp`. + pub fn supports_basic_mpp(&self) -> bool { + self.inner.supports_basic_mpp() + } + + /// Whether the peer's `init` message requires `basic_mpp`. + pub fn requires_basic_mpp(&self) -> bool { + self.inner.requires_basic_mpp() + } + + /// Whether the peer's `init` message advertises support for `opt_shutdown_anysegwit`. + pub fn supports_shutdown_anysegwit(&self) -> bool { + self.inner.supports_shutdown_anysegwit() + } + + /// Whether the peer's `init` message requires `opt_shutdown_anysegwit`. + pub fn requires_shutdown_anysegwit(&self) -> bool { + self.inner.requires_shutdown_anysegwit() + } + + /// Whether the peer's `init` message advertises support for `option_channel_type`. + pub fn supports_channel_type(&self) -> bool { + self.inner.supports_channel_type() + } + + /// Whether the peer's `init` message requires `option_channel_type`. + pub fn requires_channel_type(&self) -> bool { + self.inner.requires_channel_type() + } + + /// Whether the peer's `init` message advertises support for `option_trampoline`. + pub fn supports_trampoline_routing(&self) -> bool { + self.inner.supports_trampoline_routing() + } + + /// Whether the peer's `init` message requires `option_trampoline`. + pub fn requires_trampoline_routing(&self) -> bool { + self.inner.requires_trampoline_routing() + } + + /// Whether the peer's `init` message advertises support for `option_simple_close`. + pub fn supports_simple_close(&self) -> bool { + self.inner.supports_simple_close() + } + + /// Whether the peer's `init` message requires `option_simple_close`. + pub fn requires_simple_close(&self) -> bool { + self.inner.requires_simple_close() + } + + /// Whether the peer's `init` message advertises support for `option_splice`. + pub fn supports_splicing(&self) -> bool { + self.inner.supports_splicing() + } + + /// Whether the peer's `init` message requires `option_splice`. + pub fn requires_splicing(&self) -> bool { + self.inner.requires_splicing() + } + + /// Whether the peer's `init` message advertises support for `option_provide_storage`. + pub fn supports_provide_storage(&self) -> bool { + self.inner.supports_provide_storage() + } + + /// Whether the peer's `init` message requires `option_provide_storage`. + pub fn requires_provide_storage(&self) -> bool { + self.inner.requires_provide_storage() + } + + /// Whether the peer's `init` message set `initial_routing_sync`. + pub fn initial_routing_sync(&self) -> bool { + self.inner.initial_routing_sync() + } + + /// Whether the peer's `init` message advertises support for `option_taproot`. + pub fn supports_taproot(&self) -> bool { + self.inner.supports_taproot() + } + + /// Whether the peer's `init` message requires `option_taproot`. + pub fn requires_taproot(&self) -> bool { + self.inner.requires_taproot() + } + + /// Whether the peer's `init` message advertises support for `option_zero_fee_commitments`. + pub fn supports_anchor_zero_fee_commitments(&self) -> bool { + self.inner.supports_anchor_zero_fee_commitments() + } + + /// Whether the peer's `init` message requires `option_zero_fee_commitments`. + pub fn requires_anchor_zero_fee_commitments(&self) -> bool { + self.inner.requires_anchor_zero_fee_commitments() + } + + /// Whether the peer's `init` message advertises support for HTLC hold. + pub fn supports_htlc_hold(&self) -> bool { + self.inner.supports_htlc_hold() + } + + /// Whether the peer's `init` message requires HTLC hold. + pub fn requires_htlc_hold(&self) -> bool { + self.inner.requires_htlc_hold() + } +} + +impl From for InitFeatures { + fn from(ldk_init: LdkInitFeatures) -> Self { + Self { inner: ldk_init } + } +} +/// Information needed for constructing an invoice route hint for this channel. +#[uniffi::remote(Record)] +pub struct CounterpartyForwardingInfo { + /// Base routing fee in millisatoshis. + pub fee_base_msat: u32, + /// Amount in millionths of a satoshi the channel will charge per transferred satoshi. + pub fee_proportional_millionths: u32, + /// The minimum difference in cltv_expiry between an ingoing HTLC and its outgoing counterpart, + /// such that the outgoing HTLC is forwardable to this counterparty. + pub cltv_expiry_delta: u16, +} + #[cfg(test)] mod tests { use std::num::NonZeroU64; diff --git a/src/io/in_memory_store.rs b/src/io/in_memory_store.rs index 8b7d41c843..156fef3a38 100644 --- a/src/io/in_memory_store.rs +++ b/src/io/in_memory_store.rs @@ -11,7 +11,9 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Mutex; use lightning::io; -use lightning::util::persist::{KVStore, PageToken, PaginatedKVStore, PaginatedListResponse}; +use lightning::util::persist::{ + KVStore, MigratableKVStore, PageToken, PaginatedKVStore, PaginatedListResponse, +}; const IN_MEMORY_PAGE_SIZE: usize = 50; @@ -96,6 +98,28 @@ impl InMemoryStore { hash_map::Entry::Vacant(_) => Ok(Vec::new()), } } + + fn list_all_keys_internal(&self) -> io::Result> { + let persisted_lock = self.persisted_bytes.lock().unwrap(); + let capacity = persisted_lock.values().map(|entries| entries.len()).sum(); + let mut keys = Vec::with_capacity(capacity); + + for (prefixed_namespace, namespace_entries) in persisted_lock.iter() { + let (primary_namespace, secondary_namespace) = + prefixed_namespace.split_once('/').ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "Invalid namespace format") + })?; + for key in namespace_entries.keys() { + keys.push(( + primary_namespace.to_string(), + secondary_namespace.to_string(), + key.clone(), + )); + } + } + + Ok(keys) + } } impl KVStore for InMemoryStore { @@ -187,5 +211,40 @@ impl PaginatedKVStore for InMemoryStore { } } +impl MigratableKVStore for InMemoryStore { + fn list_all_keys( + &self, + ) -> impl Future, io::Error>> + 'static + Send { + let res = self.list_all_keys_internal(); + async move { res } + } +} + unsafe impl Sync for InMemoryStore {} unsafe impl Send for InMemoryStore {} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn in_memory_store_list_all_keys() { + let store = InMemoryStore::new(); + + KVStore::write(&store, "ns_a", "sub_a", "key_a", vec![1u8]).await.unwrap(); + KVStore::write(&store, "ns_a", "sub_b", "key_b", vec![2u8]).await.unwrap(); + KVStore::write(&store, "ns_b", "", "key_c", vec![3u8]).await.unwrap(); + + let mut keys = MigratableKVStore::list_all_keys(&store).await.unwrap(); + keys.sort(); + + assert_eq!( + keys, + vec![ + ("ns_a".to_string(), "sub_a".to_string(), "key_a".to_string()), + ("ns_a".to_string(), "sub_b".to_string(), "key_b".to_string()), + ("ns_b".to_string(), "".to_string(), "key_c".to_string()), + ] + ); + } +} diff --git a/src/io/mod.rs b/src/io/mod.rs index e16a999752..a01aa59a83 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -29,6 +29,10 @@ pub(crate) const PEER_INFO_PERSISTENCE_KEY: &str = "peers"; pub(crate) const PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE: &str = "payments"; pub(crate) const PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; +/// The pending payment information will be persisted under this prefix. +pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE: &str = "pending_payments"; +pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; + /// The node metrics will be persisted under this key. pub(crate) const NODE_METRICS_PRIMARY_NAMESPACE: &str = ""; pub(crate) const NODE_METRICS_SECONDARY_NAMESPACE: &str = ""; @@ -80,7 +84,3 @@ pub(crate) const BDK_WALLET_INDEXER_KEY: &str = "indexer"; /// /// [`StaticInvoice`]: lightning::offers::static_invoice::StaticInvoice pub(crate) const STATIC_INVOICE_STORE_PRIMARY_NAMESPACE: &str = "static_invoices"; - -/// The pending payment information will be persisted under this prefix. -pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE: &str = "pending_payments"; -pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; diff --git a/src/io/postgres_store/mod.rs b/src/io/postgres_store/mod.rs index c0770de5f0..90b8cdc391 100644 --- a/src/io/postgres_store/mod.rs +++ b/src/io/postgres_store/mod.rs @@ -12,7 +12,9 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use lightning::io; -use lightning::util::persist::{KVStore, PageToken, PaginatedKVStore, PaginatedListResponse}; +use lightning::util::persist::{ + KVStore, MigratableKVStore, PageToken, PaginatedKVStore, PaginatedListResponse, +}; use lightning_types::string::PrintableString; use native_tls::TlsConnector; use postgres_native_tls::MakeTlsConnector; @@ -351,6 +353,24 @@ impl PaginatedKVStore for PostgresStore { } } +impl MigratableKVStore for PostgresStore { + fn list_all_keys( + &self, + ) -> impl Future, io::Error>> + 'static + Send { + let inner = Arc::clone(&self.inner); + let runtime = self.internal_runtime(); + async move { + let task = runtime.spawn(async move { inner.list_all_keys_internal().await }); + task.await.map_err(|e| { + io::Error::new( + io::ErrorKind::Other, + format!("PostgreSQL runtime task failed: {}", e), + ) + })? + } + } +} + struct PostgresStoreInner { pool: SmallPool, config: Config, @@ -725,6 +745,25 @@ impl PostgresStoreInner { Ok(keys) } + async fn list_all_keys_internal(&self) -> io::Result> { + let sql = format!( + "SELECT primary_namespace, secondary_namespace, key FROM {}", + self.kv_table_name_sql + ); + + let err_map = |e: PgError| { + let msg = format!("Failed to retrieve queried rows: {e}"); + io::Error::new(io::ErrorKind::Other, msg) + }; + + let mut locked = self.locked_client().await?; + let rows = query_with_retry!(self, locked, err_map, locked.query(sql.as_str(), &[]))?; + + let keys: Vec<(String, String, String)> = + rows.iter().map(|row| (row.get(0), row.get(1), row.get(2))).collect(); + Ok(keys) + } + async fn list_paginated_internal( &self, primary_namespace: &str, secondary_namespace: &str, page_token: Option, ) -> io::Result { @@ -904,6 +943,29 @@ mod tests { cleanup_store(&store_1).await; } + #[tokio::test(flavor = "multi_thread")] + async fn test_postgres_store_list_all_keys() { + let store = create_test_store("test_pg_list_all_keys").await; + + KVStore::write(&store, "ns_a", "sub_a", "key_a", vec![1u8]).await.unwrap(); + KVStore::write(&store, "ns_a", "sub_b", "key_b", vec![2u8]).await.unwrap(); + KVStore::write(&store, "ns_b", "", "key_c", vec![3u8]).await.unwrap(); + + let mut keys = MigratableKVStore::list_all_keys(&store).await.unwrap(); + keys.sort(); + + assert_eq!( + keys, + vec![ + ("ns_a".to_string(), "sub_a".to_string(), "key_a".to_string()), + ("ns_a".to_string(), "sub_b".to_string(), "key_b".to_string()), + ("ns_b".to_string(), "".to_string(), "key_c".to_string()), + ] + ); + + cleanup_store(&store).await; + } + async fn kill_connection(store: &PostgresStore) { // Terminate every backend in the pool so the next op deterministically // hits a closed connection regardless of which slot `get` selects. diff --git a/src/io/sqlite_store/mod.rs b/src/io/sqlite_store/mod.rs index 076aeef9bd..2587220598 100644 --- a/src/io/sqlite_store/mod.rs +++ b/src/io/sqlite_store/mod.rs @@ -14,7 +14,9 @@ use std::sync::atomic::{AtomicI64, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use lightning::io; -use lightning::util::persist::{KVStore, PageToken, PaginatedKVStore, PaginatedListResponse}; +use lightning::util::persist::{ + KVStore, MigratableKVStore, PageToken, PaginatedKVStore, PaginatedListResponse, +}; use lightning_types::string::PrintableString; use rusqlite::{named_params, Connection}; @@ -202,6 +204,21 @@ impl PaginatedKVStore for SqliteStore { } } +impl MigratableKVStore for SqliteStore { + fn list_all_keys( + &self, + ) -> impl Future, io::Error>> + 'static + Send { + let inner = Arc::clone(&self.inner); + let fut = tokio::task::spawn_blocking(move || inner.list_all_keys_internal()); + async move { + fut.await.unwrap_or_else(|e| { + let msg = format!("Failed to IO operation due join error: {}", e); + Err(io::Error::new(io::ErrorKind::Other, msg)) + }) + } + } +} + struct SqliteStoreInner { connection: Arc>, data_dir: PathBuf, @@ -486,6 +503,42 @@ impl SqliteStoreInner { Ok(keys) } + fn list_all_keys_internal(&self) -> io::Result> { + let locked_conn = self.connection.lock().expect("lock"); + + let sql = format!( + "SELECT primary_namespace, secondary_namespace, key FROM {}", + self.kv_table_name + ); + let count_sql = format!("SELECT COUNT(*) FROM {}", self.kv_table_name); + let count: usize = + locked_conn.query_row(&count_sql, [], |row| row.get(0)).map_err(|e| { + let msg = format!("Failed to count rows: {}", e); + io::Error::new(io::ErrorKind::Other, msg) + })?; + + let mut stmt = locked_conn.prepare_cached(&sql).map_err(|e| { + let msg = format!("Failed to prepare statement: {}", e); + io::Error::new(io::ErrorKind::Other, msg) + })?; + + let mut keys = Vec::with_capacity(count); + let rows_iter = + stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))).map_err(|e| { + let msg = format!("Failed to retrieve queried rows: {}", e); + io::Error::new(io::ErrorKind::Other, msg) + })?; + + for key in rows_iter { + keys.push(key.map_err(|e| { + let msg = format!("Failed to retrieve queried rows: {}", e); + io::Error::new(io::ErrorKind::Other, msg) + })?); + } + + Ok(keys) + } + fn list_paginated_internal( &self, primary_namespace: &str, secondary_namespace: &str, page_token: Option, ) -> io::Result { @@ -679,6 +732,34 @@ mod tests { do_test_store(&store_0, &store_1) } + #[tokio::test] + async fn test_sqlite_store_list_all_keys() { + let mut temp_path = random_storage_path(); + temp_path.push("test_sqlite_store_list_all_keys"); + let store = SqliteStore::new( + temp_path, + Some("test_db".to_string()), + Some("test_table".to_string()), + ) + .unwrap(); + + KVStore::write(&store, "ns_a", "sub_a", "key_a", vec![1u8]).await.unwrap(); + KVStore::write(&store, "ns_a", "sub_b", "key_b", vec![2u8]).await.unwrap(); + KVStore::write(&store, "ns_b", "", "key_c", vec![3u8]).await.unwrap(); + + let mut keys = MigratableKVStore::list_all_keys(&store).await.unwrap(); + keys.sort(); + + assert_eq!( + keys, + vec![ + ("ns_a".to_string(), "sub_a".to_string(), "key_a".to_string()), + ("ns_a".to_string(), "sub_b".to_string(), "key_b".to_string()), + ("ns_b".to_string(), "".to_string(), "key_c".to_string()), + ] + ); + } + #[tokio::test] async fn test_sqlite_store_paginated_listing() { let mut temp_path = random_storage_path(); diff --git a/src/io/vss_store.rs b/src/io/vss_store.rs index 6c3535627a..61d4e7abc2 100644 --- a/src/io/vss_store.rs +++ b/src/io/vss_store.rs @@ -24,7 +24,9 @@ use bitcoin::Network; use lightning::impl_writeable_tlv_based_enum; use lightning::io::{self, Error, ErrorKind}; use lightning::sign::{EntropySource as LdkEntropySource, RandomBytes}; -use lightning::util::persist::KVStore; +use lightning::util::persist::{ + KVStore, MigratableKVStore, PageToken, PaginatedKVStore, PaginatedListResponse, +}; use lightning::util::ser::{Readable, Writeable}; use prost::Message; use vss_client::client::VssClient; @@ -70,6 +72,8 @@ impl_writeable_tlv_based_enum!(VssSchemaVersion, (1, V1) => {}, ); +const PAGE_SIZE: i32 = 50; + const VSS_HARDENED_CHILD_INDEX: u32 = 877; const VSS_SIGS_AUTH_HARDENED_CHILD_INDEX: u32 = 139; const VSS_SCHEMA_VERSION_KEY: &str = "vss_schema_version"; @@ -293,6 +297,48 @@ impl KVStore for VssStore { } } +impl PaginatedKVStore for VssStore { + fn list_paginated( + &self, primary_namespace: &str, secondary_namespace: &str, page_token: Option, + ) -> impl Future> + 'static + Send { + let primary_namespace = primary_namespace.to_string(); + let secondary_namespace = secondary_namespace.to_string(); + let inner = Arc::clone(&self.inner); + let runtime = self.internal_runtime(); + async move { + let task = runtime.spawn(async move { + inner + .list_paginated_internal( + &inner.async_client, + primary_namespace, + secondary_namespace, + page_token, + ) + .await + }); + task.await.map_err(|e| { + io::Error::new(io::ErrorKind::Other, format!("VSS runtime task failed: {}", e)) + })? + } + } +} + +impl MigratableKVStore for VssStore { + fn list_all_keys( + &self, + ) -> impl Future, io::Error>> + 'static + Send { + let inner = Arc::clone(&self.inner); + let runtime = self.internal_runtime(); + async move { + let task = runtime + .spawn(async move { inner.list_all_keys_internal(&inner.async_client).await }); + task.await.map_err(|e| { + io::Error::new(io::ErrorKind::Other, format!("VSS runtime task failed: {}", e)) + })? + } + } +} + impl Drop for VssStore { fn drop(&mut self) { if let Some(runtime) = self.internal_runtime.take() { @@ -371,7 +417,7 @@ impl VssStoreInner { } } - fn extract_key(&self, unified_key: &str) -> io::Result { + fn extract_obfuscated_key<'a>(&self, unified_key: &'a str) -> io::Result<&'a str> { let mut parts = if self.schema_version == VssSchemaVersion::V1 { let mut parts = unified_key.splitn(2, '#'); let _obfuscated_namespace = parts.next(); @@ -383,43 +429,80 @@ impl VssStoreInner { parts }; match parts.next() { - Some(obfuscated_key) => { - let actual_key = self.key_obfuscator.deobfuscate(obfuscated_key)?; - Ok(actual_key) - }, + Some(obfuscated_key) => Ok(obfuscated_key), None => Err(Error::new(ErrorKind::InvalidData, "Invalid key format")), } } - async fn list_all_keys( + fn extract_key(&self, unified_key: &str) -> io::Result { + let obfuscated_key = self.extract_obfuscated_key(unified_key)?; + let actual_key = self.key_obfuscator.deobfuscate(obfuscated_key)?; + Ok(actual_key) + } + + fn extract_namespaces(&self, unified_key: &str) -> io::Result<(String, String)> { + if self.schema_version == VssSchemaVersion::V1 { + let mut parts = unified_key.splitn(2, '#'); + let obfuscated_namespace = parts.next(); + let _obfuscated_key = parts.next(); + match (obfuscated_namespace, _obfuscated_key) { + (Some(obfuscated_namespace), Some(_obfuscated_key)) => { + let namespace = self.key_obfuscator.deobfuscate(obfuscated_namespace)?; + let mut namespace_parts = namespace.splitn(2, '#'); + let primary_namespace = namespace_parts.next(); + let secondary_namespace = namespace_parts.next(); + match (primary_namespace, secondary_namespace) { + (Some(primary_namespace), Some(secondary_namespace)) => { + Ok((primary_namespace.to_string(), secondary_namespace.to_string())) + }, + _ => Err(Error::new(ErrorKind::InvalidData, "Invalid namespace format")), + } + }, + _ => Err(Error::new(ErrorKind::InvalidData, "Invalid key format")), + } + } else { + // Default to V0 schema. + let mut parts = unified_key.splitn(3, '#'); + let primary_namespace = parts.next(); + let secondary_namespace = parts.next(); + match (primary_namespace, secondary_namespace) { + (Some(_obfuscated_key), None) => Ok(("".to_string(), "".to_string())), + (Some(primary_namespace), Some(secondary_namespace)) => { + Ok((primary_namespace.to_string(), secondary_namespace.to_string())) + }, + _ => Err(Error::new(ErrorKind::InvalidData, "Invalid key format")), + } + } + } + + async fn list_keys( &self, client: &VssClient, primary_namespace: &str, - secondary_namespace: &str, - ) -> io::Result> { - let mut page_token = None; - let mut keys = vec![]; - let key_prefix = self.build_obfuscated_prefix(primary_namespace, secondary_namespace); - while page_token != Some("".to_string()) { - let request = ListKeyVersionsRequest { - store_id: self.store_id.clone(), - key_prefix: Some(key_prefix.clone()), - page_token, - page_size: None, - }; + secondary_namespace: &str, key_prefix: String, page_token: Option, + page_size: Option, + ) -> io::Result<(Vec, Option)> { + let request = ListKeyVersionsRequest { + store_id: self.store_id.clone(), + key_prefix: Some(key_prefix), + page_token, + page_size, + }; - let response = client.list_key_versions(&request).await.map_err(|e| { - let msg = format!( - "Failed to list keys in {}/{}: {}", - primary_namespace, secondary_namespace, e - ); - Error::new(ErrorKind::Other, msg) - })?; + let response = client.list_key_versions(&request).await.map_err(|e| { + let msg = format!( + "Failed to list keys in {}/{}: {}", + primary_namespace, secondary_namespace, e + ); + Error::new(ErrorKind::Other, msg) + })?; - for kv in response.key_versions { - keys.push(self.extract_key(&kv.key)?); - } - page_token = response.next_page_token; + let mut keys = Vec::with_capacity(response.key_versions.len()); + for kv in response.key_versions { + keys.push(self.extract_key(&kv.key)?); } - Ok(keys) + + // VSS may return an empty string instead of None to signal the last page. + let next_page_token = response.next_page_token.filter(|t| !t.is_empty()); + Ok((keys, next_page_token)) } async fn read_internal( @@ -543,17 +626,101 @@ impl VssStoreInner { ) -> io::Result> { check_namespace_key_validity(&primary_namespace, &secondary_namespace, None, "list")?; - let keys = self - .list_all_keys(client, &primary_namespace, &secondary_namespace) - .await - .map_err(|e| { - let msg = format!( - "Failed to retrieve keys in namespace: {}/{} : {}", - primary_namespace, secondary_namespace, e - ); + let key_prefix = self.build_obfuscated_prefix(&primary_namespace, &secondary_namespace); + let mut page_token: Option = None; + let mut keys = vec![]; + loop { + let (page_keys, next_page_token) = self + .list_keys( + client, + &primary_namespace, + &secondary_namespace, + key_prefix.clone(), + page_token, + None, + ) + .await?; + keys.extend(page_keys); + match next_page_token { + Some(t) => page_token = Some(t), + None => break, + } + } + Ok(keys) + } + + async fn list_paginated_internal( + &self, client: &VssClient, primary_namespace: String, + secondary_namespace: String, page_token: Option, + ) -> io::Result { + check_namespace_key_validity( + &primary_namespace, + &secondary_namespace, + None, + "list_paginated", + )?; + + let key_prefix = self.build_obfuscated_prefix(&primary_namespace, &secondary_namespace); + let vss_page_token = page_token.map(|t| t.to_string()); + let (keys, next_page_token) = self + .list_keys( + client, + &primary_namespace, + &secondary_namespace, + key_prefix, + vss_page_token, + Some(PAGE_SIZE), + ) + .await?; + + let next_page_token = next_page_token.map(PageToken::new); + + Ok(PaginatedListResponse { keys, next_page_token }) + } + + async fn list_all_keys_internal( + &self, client: &VssClient, + ) -> io::Result> { + let mut page_token: Option = None; + let mut keys = vec![]; + loop { + let request = ListKeyVersionsRequest { + store_id: self.store_id.clone(), + key_prefix: None, + page_token, + page_size: Some(PAGE_SIZE), + }; + + let response = client.list_key_versions(&request).await.map_err(|e| { + let msg = format!("Failed to list all keys: {}", e); Error::new(ErrorKind::Other, msg) })?; + for kv in response.key_versions { + let (primary_namespace, secondary_namespace) = self.extract_namespaces(&kv.key)?; + let key = match self.extract_key(&kv.key) { + Ok(key) => key, + Err(_) + if self.schema_version == VssSchemaVersion::V0 && !kv.key.contains('#') => + { + self.key_obfuscator.deobfuscate(&kv.key)? + }, + Err(e) => return Err(e), + }; + if primary_namespace.is_empty() + && secondary_namespace.is_empty() + && key == VSS_SCHEMA_VERSION_KEY + { + continue; + } + keys.push((primary_namespace, secondary_namespace, key)); + } + + match response.next_page_token.filter(|t| !t.is_empty()) { + Some(t) => page_token = Some(t), + None => break, + } + } Ok(keys) } @@ -626,6 +793,7 @@ fn retry_policy() -> CustomRetryPolicy { VssError::NoSuchKeyError(..) | VssError::InvalidRequestError(..) | VssError::ConflictError(..) + | VssError::VSSVersionMismatchError { .. } ) }) as _) } @@ -647,6 +815,12 @@ async fn determine_and_write_schema_version( // The value is not set. None }, + Err(VssError::VSSVersionMismatchError { version_served, version_expected }) => { + let msg = format!( + "VSS version mismatch, expected: {version_expected}, got: {version_served:?}" + ); + return Err(Error::new(ErrorKind::Other, msg)); + }, Err(e) => { let msg = format!("Failed to read schema version: {}", e); return Err(Error::new(ErrorKind::Other, msg)); @@ -941,35 +1115,130 @@ mod tests { use super::*; use crate::io::test_utils::do_read_write_remove_list_persist; - #[tokio::test] - async fn vss_read_write_remove_list_persist() { + fn build_vss_store() -> VssStore { let vss_base_url = std::env::var("TEST_VSS_BASE_URL").unwrap(); let mut rng = rng(); let rand_store_id: String = (0..7).map(|_| rng.sample(Alphanumeric) as char).collect(); let mut node_seed = [0u8; 64]; rng.fill_bytes(&mut node_seed); let entropy = NodeEntropy::from_seed_bytes(node_seed); - let vss_store = - VssStoreBuilder::new(entropy, vss_base_url, rand_store_id, Network::Testnet) - .build_with_sigs_auth(HashMap::new()) - .unwrap(); + VssStoreBuilder::new(entropy, vss_base_url, rand_store_id, Network::Testnet) + .build_with_sigs_auth(HashMap::new()) + .unwrap() + } + + #[tokio::test] + async fn vss_read_write_remove_list_persist() { + let vss_store = build_vss_store(); do_read_write_remove_list_persist(&vss_store).await; } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn vss_read_write_remove_list_persist_in_runtime_context() { - let vss_base_url = std::env::var("TEST_VSS_BASE_URL").unwrap(); - let mut rng = rng(); - let rand_store_id: String = (0..7).map(|_| rng.sample(Alphanumeric) as char).collect(); - let mut node_seed = [0u8; 64]; - rng.fill_bytes(&mut node_seed); - let entropy = NodeEntropy::from_seed_bytes(node_seed); - let vss_store = - VssStoreBuilder::new(entropy, vss_base_url, rand_store_id, Network::Testnet) - .build_with_sigs_auth(HashMap::new()) - .unwrap(); - + let vss_store = build_vss_store(); do_read_write_remove_list_persist(&vss_store).await; drop(vss_store) } + + #[tokio::test] + async fn vss_list_all_keys() { + let store = build_vss_store(); + + KVStore::write(&store, "ns_a", "sub_a", "key_a", vec![1u8]).await.unwrap(); + KVStore::write(&store, "ns_a", "sub_b", "key_b", vec![2u8]).await.unwrap(); + KVStore::write(&store, "ns_b", "", "key_c", vec![3u8]).await.unwrap(); + + let mut keys = MigratableKVStore::list_all_keys(&store).await.unwrap(); + keys.sort(); + + assert_eq!( + keys, + vec![ + ("ns_a".to_string(), "sub_a".to_string(), "key_a".to_string()), + ("ns_a".to_string(), "sub_b".to_string(), "key_b".to_string()), + ("ns_b".to_string(), "".to_string(), "key_c".to_string()), + ] + ); + } + + #[tokio::test] + async fn vss_paginated_listing() { + let store = build_vss_store(); + let ns = "test_paginated"; + let sub = "listing"; + let num_entries = 5; + + for i in 0..num_entries { + let key = format!("key_{:04}", i); + let data = vec![i as u8; 32]; + KVStore::write(&store, ns, sub, &key, data).await.unwrap(); + } + + let mut all_keys = Vec::new(); + let mut page_token = None; + + loop { + let response = + PaginatedKVStore::list_paginated(&store, ns, sub, page_token).await.unwrap(); + all_keys.extend(response.keys); + match response.next_page_token { + Some(token) => page_token = Some(token), + _ => break, + } + } + + assert_eq!(all_keys.len(), num_entries); + + // Verify no duplicates + let mut unique = all_keys.clone(); + unique.sort(); + unique.dedup(); + assert_eq!(unique.len(), num_entries); + } + + #[tokio::test] + async fn vss_paginated_empty_namespace() { + let store = build_vss_store(); + let response = + PaginatedKVStore::list_paginated(&store, "nonexistent", "ns", None).await.unwrap(); + assert!(response.keys.is_empty()); + assert!(response.next_page_token.is_none()); + } + + #[tokio::test] + async fn vss_paginated_removal() { + let store = build_vss_store(); + let ns = "test_paginated"; + let sub = "removal"; + + KVStore::write(&store, ns, sub, "a", vec![1u8; 8]).await.unwrap(); + KVStore::write(&store, ns, sub, "b", vec![2u8; 8]).await.unwrap(); + KVStore::write(&store, ns, sub, "c", vec![3u8; 8]).await.unwrap(); + + KVStore::remove(&store, ns, sub, "b", false).await.unwrap(); + + let response = PaginatedKVStore::list_paginated(&store, ns, sub, None).await.unwrap(); + assert_eq!(response.keys.len(), 2); + assert!(response.keys.contains(&"a".to_string())); + assert!(!response.keys.contains(&"b".to_string())); + assert!(response.keys.contains(&"c".to_string())); + } + + #[tokio::test] + async fn vss_paginated_namespace_isolation() { + let store = build_vss_store(); + + KVStore::write(&store, "ns_a", "sub", "key_1", vec![1u8; 8]).await.unwrap(); + KVStore::write(&store, "ns_a", "sub", "key_2", vec![2u8; 8]).await.unwrap(); + KVStore::write(&store, "ns_b", "sub", "key_3", vec![3u8; 8]).await.unwrap(); + + let response = PaginatedKVStore::list_paginated(&store, "ns_a", "sub", None).await.unwrap(); + assert_eq!(response.keys.len(), 2); + assert!(response.keys.contains(&"key_1".to_string())); + assert!(response.keys.contains(&"key_2".to_string())); + + let response = PaginatedKVStore::list_paginated(&store, "ns_b", "sub", None).await.unwrap(); + assert_eq!(response.keys.len(), 1); + assert!(response.keys.contains(&"key_3".to_string())); + } } diff --git a/src/lib.rs b/src/lib.rs index 7ed69031c3..c97e16fe67 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -119,8 +119,6 @@ pub use bitcoin; use bitcoin::secp256k1::PublicKey; #[cfg(feature = "uniffi")] pub use bitcoin::FeeRate; -#[cfg(not(feature = "uniffi"))] -use bitcoin::FeeRate; use bitcoin::{Address, Amount, BlockHash, Network}; #[cfg(feature = "uniffi")] pub use builder::ArcedNodeBuilder as Builder; @@ -138,7 +136,9 @@ pub use error::Error as NodeError; use error::Error; pub use event::Event; use event::{EventHandler, EventQueue}; -use fee_estimator::{ConfirmationTarget, FeeEstimator, OnchainFeeEstimator}; +use fee_estimator::{ + max_funding_feerate, rbf_splice_feerates, ConfirmationTarget, FeeEstimator, OnchainFeeEstimator, +}; #[cfg(feature = "uniffi")] use ffi::*; use gossip::GossipSource; @@ -162,7 +162,7 @@ pub use lightning_invoice; pub use lightning_liquidity; pub use lightning_types; use lightning_types::features::NodeFeatures as LdkNodeFeatures; -use liquidity::{LSPS1Liquidity, LiquiditySource}; +use liquidity::LiquiditySource; use lnurl_auth::LnurlAuth; use logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; use payment::asynchronous::om_mailbox::OnionMessageMailbox; @@ -179,10 +179,13 @@ use types::{ HRNResolver, KeysManager, OnionMessenger, PaymentStore, PeerManager, Router, Scorer, Sweeper, Wallet, }; -pub use types::{ChannelDetails, CustomTlvRecord, PeerDetails, UserChannelId}; +pub use types::{ + ChannelCounterparty, ChannelDetails, CustomTlvRecord, PeerDetails, ReserveType, UserChannelId, +}; pub use vss_client; use crate::ffi::maybe_wrap; +use crate::liquidity::Liquidity; use crate::scoring::setup_background_pathfinding_scores_sync; use crate::wallet::FundingAmount; @@ -234,7 +237,7 @@ pub struct Node { network_graph: Arc, gossip_source: Arc, pathfinding_scores_sync_url: Option, - liquidity_source: Option>>>, + liquidity_source: Arc>>, kv_store: Arc, logger: Arc, _router: Arc, @@ -274,6 +277,8 @@ impl Node { self.config.network ); + self.runtime.allow_cancellable_background_task_spawns(); + // Start up any runtime-dependant chain sources (e.g. Electrum) self.chain_source.start(Arc::clone(&self.runtime)).map_err(|e| { log_error!(self.logger, "Failed to start chain syncing: {}", e); @@ -598,7 +603,7 @@ impl Node { Arc::clone(&self.connection_manager), Arc::clone(&self.output_sweeper), Arc::clone(&self.network_graph), - self.liquidity_source.clone(), + Arc::clone(&self.liquidity_source), Arc::clone(&self.payment_store), Arc::clone(&self.peer_store), Arc::clone(&self.keys_manager), @@ -617,8 +622,7 @@ impl Node { let background_chan_man = Arc::clone(&self.channel_manager); let background_gossip_sync = self.gossip_source.as_gossip_sync(); let background_peer_man = Arc::clone(&self.peer_manager); - let background_liquidity_man_opt = - self.liquidity_source.as_ref().map(|ls| ls.liquidity_manager()); + let background_liquidity_man = self.liquidity_source.liquidity_manager(); let background_sweeper = Arc::clone(&self.output_sweeper); let background_onion_messenger = Arc::clone(&self.onion_messenger); let background_logger = Arc::clone(&self.logger); @@ -654,7 +658,7 @@ impl Node { Some(background_onion_messenger), background_gossip_sync, background_peer_man, - background_liquidity_man_opt, + Some(background_liquidity_man), Some(background_sweeper), background_logger, Some(background_scorer), @@ -675,25 +679,74 @@ impl Node { }); }); - if let Some(liquidity_source) = self.liquidity_source.as_ref() { - let mut stop_liquidity_handler = self.stop_sender.subscribe(); - let liquidity_handler = Arc::clone(&liquidity_source); - let liquidity_logger = Arc::clone(&self.logger); - self.runtime.spawn_background_task(async move { - loop { - tokio::select! { - _ = stop_liquidity_handler.changed() => { - log_debug!( + let mut stop_liquidity_handler = self.stop_sender.subscribe(); + let liquidity_handler = Arc::clone(&self.liquidity_source); + let liquidity_logger = Arc::clone(&self.logger); + let discovery_cm = Arc::clone(&self.connection_manager); + self.runtime.spawn_background_task(async move { + // Spawn discovery for configured LSPs in parallel. + let discovery_logger = Arc::clone(&liquidity_logger); + let mut discovery_set = tokio::task::JoinSet::new(); + for (node_id, address) in liquidity_handler.get_all_lsp_details() { + let cm = Arc::clone(&discovery_cm); + let logger = Arc::clone(&discovery_logger); + let ls = Arc::clone(&liquidity_handler); + discovery_set.spawn(async move { + if let Err(e) = cm.connect_peer_if_necessary(node_id, address.clone()).await { + log_error!( + logger, + "Failed to connect to LSP {} for protocol discovery: {}", + node_id, + e + ); + return; + } + match ls.discover_lsp_protocols(&node_id).await { + Ok(protocols) => { + log_info!( + logger, + "Discovered protocols for LSP {}: {:?}", + node_id, + protocols + ); + }, + Err(e) => { + log_error!( + logger, + "Failed to discover protocols for LSP {}: {:?}", + node_id, + e + ); + }, + } + }); + } + + let mut discovery_done = false; + loop { + tokio::select! { + _ = stop_liquidity_handler.changed() => { + log_debug!( + liquidity_logger, + "Stopping processing liquidity events.", + ); + discovery_set.shutdown().await; + return; + } + _ = liquidity_handler.handle_next_event() => {} + res = discovery_set.join_next(), if !discovery_done => { + if res.is_none() { + liquidity_handler.mark_discovery_done(); + discovery_done = true; + log_info!( liquidity_logger, - "Stopping processing liquidity events.", + "LSP protocols discovery complete.", ); - return; } - _ = liquidity_handler.handle_next_event() => {} } } - }); - } + } + }); log_info!(self.logger, "Startup complete."); *is_running_lock = true; @@ -893,7 +946,7 @@ impl Node { Arc::clone(&self.runtime), Arc::clone(&self.channel_manager), Arc::clone(&self.connection_manager), - self.liquidity_source.clone(), + Arc::clone(&self.liquidity_source), Arc::clone(&self.payment_store), Arc::clone(&self.peer_store), Arc::clone(&self.config), @@ -911,7 +964,7 @@ impl Node { Arc::clone(&self.runtime), Arc::clone(&self.channel_manager), Arc::clone(&self.connection_manager), - self.liquidity_source.clone(), + Arc::clone(&self.liquidity_source), Arc::clone(&self.payment_store), Arc::clone(&self.peer_store), Arc::clone(&self.config), @@ -1068,37 +1121,37 @@ impl Node { }) } - /// Returns a liquidity handler allowing to request channels via the [bLIP-51 / LSPS1] protocol. - /// - /// [bLIP-51 / LSPS1]: https://github.com/lightning/blips/blob/master/blip-0051.md + /// Returns a liquidity handler allowing to manage LSP connections and request channels. #[cfg(not(feature = "uniffi"))] - pub fn lsps1_liquidity(&self) -> LSPS1Liquidity { - LSPS1Liquidity::new( + pub fn liquidity(&self) -> Liquidity { + Liquidity::new( Arc::clone(&self.runtime), Arc::clone(&self.wallet), Arc::clone(&self.connection_manager), - self.liquidity_source.clone(), + Arc::clone(&self.liquidity_source), Arc::clone(&self.logger), ) } - /// Returns a liquidity handler allowing to request channels via the [bLIP-51 / LSPS1] protocol. - /// - /// [bLIP-51 / LSPS1]: https://github.com/lightning/blips/blob/master/blip-0051.md + /// Returns a liquidity handler allowing to manage LSP connections and request channels. #[cfg(feature = "uniffi")] - pub fn lsps1_liquidity(&self) -> Arc { - Arc::new(LSPS1Liquidity::new( + pub fn liquidity(&self) -> Arc { + Arc::new(Liquidity::new( Arc::clone(&self.runtime), Arc::clone(&self.wallet), Arc::clone(&self.connection_manager), - self.liquidity_source.clone(), + Arc::clone(&self.liquidity_source), Arc::clone(&self.logger), )) } /// Retrieve a list of known channels. pub fn list_channels(&self) -> Vec { - self.channel_manager.list_channels().into_iter().map(|c| c.into()).collect() + self.channel_manager + .list_channels() + .into_iter() + .map(|c| ChannelDetails::from_ldk(c, self.config.anchor_channels_config.as_ref())) + .collect() } /// Connect to a node on the peer-to-peer network. @@ -1531,7 +1584,7 @@ impl Node { { let min_feerate = self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding); - let max_feerate = FeeRate::from_sat_per_kwu(min_feerate.to_sat_per_kwu() * 3 / 2); + let max_feerate = max_funding_feerate(min_feerate); let splice_amount_sats = match splice_amount_sats { FundingAmount::Exact { amount_sats } => amount_sats, @@ -1600,16 +1653,26 @@ impl Node { if funding_template.prior_contribution().is_some() { log_error!( self.logger, - "Failed to splice channel: a prior splice contribution is pending" + "Failed to splice channel: a prior splice contribution is pending; use bump_channel_funding_fee to bump its fee" ); return Err(Error::ChannelSplicingFailed); } + // When contributing to a pending splice, the funding template requires at least the RBF + // minimum feerate to replace the in-flight transaction. Use it in place of our funding + // feerate estimate when it's higher, as long as it stays within our max. + let feerate = match funding_template.min_rbf_feerate() { + Some(min_rbf_feerate) if min_rbf_feerate <= max_feerate => { + min_feerate.max(min_rbf_feerate) + }, + _ => min_feerate, + }; + let contribution = self .runtime .block_on(funding_template.splice_in( Amount::from_sat(splice_amount_sats), - min_feerate, + feerate, max_feerate, Arc::clone(&self.wallet), )) @@ -1700,7 +1763,9 @@ impl Node { if let Some(channel_details) = open_channels.iter().find(|c| c.user_channel_id == user_channel_id.0) { - if splice_amount_sats > channel_details.outbound_capacity_msat { + let splice_amount_msat = + splice_amount_sats.checked_mul(1_000).ok_or(Error::ChannelSplicingFailed)?; + if splice_amount_msat > channel_details.outbound_capacity_msat { return Err(Error::ChannelSplicingFailed); } @@ -1708,7 +1773,7 @@ impl Node { let min_feerate = self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding); - let max_feerate = FeeRate::from_sat_per_kwu(min_feerate.to_sat_per_kwu() * 3 / 2); + let max_feerate = max_funding_feerate(min_feerate); let funding_template = self .channel_manager @@ -1721,17 +1786,27 @@ impl Node { if funding_template.prior_contribution().is_some() { log_error!( self.logger, - "Failed to splice channel: a prior splice contribution is pending" + "Failed to splice channel: a prior splice contribution is pending; use bump_channel_funding_fee to bump its fee" ); return Err(Error::ChannelSplicingFailed); } + // When contributing to a pending splice, the funding template requires at least the RBF + // minimum feerate to replace the in-flight transaction. Use it in place of our funding + // feerate estimate when it's higher, as long as it stays within our max. + let feerate = match funding_template.min_rbf_feerate() { + Some(min_rbf_feerate) if min_rbf_feerate <= max_feerate => { + min_feerate.max(min_rbf_feerate) + }, + _ => min_feerate, + }; + let outputs = vec![bitcoin::TxOut { value: Amount::from_sat(splice_amount_sats), script_pubkey: address.script_pubkey(), }]; let contribution = - funding_template.splice_out(outputs, min_feerate, max_feerate).map_err(|e| { + funding_template.splice_out(outputs, feerate, max_feerate).map_err(|e| { log_error!(self.logger, "Failed to splice channel: {}", e); Error::ChannelSplicingFailed })?; @@ -1758,6 +1833,77 @@ impl Node { } } + /// Fee-bumps the pending splice on a channel by replacing its in-flight funding transaction + /// (RBF). The splice's amount and destination are preserved; only the fee rate is raised. + /// Errors if the channel has no pending splice to bump. + pub fn bump_channel_funding_fee( + &self, user_channel_id: &UserChannelId, counterparty_node_id: PublicKey, + ) -> Result<(), Error> { + let open_channels = + self.channel_manager.list_channels_with_counterparty(&counterparty_node_id); + if let Some(channel_details) = + open_channels.iter().find(|c| c.user_channel_id == user_channel_id.0) + { + let min_feerate = + self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding); + + let funding_template = self + .channel_manager + .splice_channel(&channel_details.channel_id, &counterparty_node_id) + .map_err(|e| { + log_error!(self.logger, "Failed to RBF channel: {:?}", e); + Error::ChannelSplicingFailed + })?; + + let Some(min_rbf_feerate) = funding_template.min_rbf_feerate() else { + log_error!(self.logger, "Failed to RBF channel: no pending splice to replace"); + return Err(Error::ChannelSplicingFailed); + }; + + let Some((target_feerate, max_feerate)) = + rbf_splice_feerates(min_feerate, min_rbf_feerate) + else { + log_error!( + self.logger, + "Failed to RBF channel: the RBF minimum feerate exceeds our maximum" + ); + return Err(Error::ChannelSplicingFailed); + }; + + let contribution = self + .runtime + .block_on(funding_template.rbf_prior_contribution( + Some(target_feerate), + max_feerate, + Arc::clone(&self.wallet), + )) + .map_err(|e| { + log_error!(self.logger, "Failed to RBF channel: {}", e); + Error::ChannelSplicingFailed + })?; + + self.channel_manager + .funding_contributed( + &channel_details.channel_id, + &counterparty_node_id, + contribution, + None, + ) + .map_err(|e| { + log_error!(self.logger, "Failed to RBF channel: {:?}", e); + Error::ChannelSplicingFailed + }) + } else { + log_error!( + self.logger, + "Channel not found for user_channel_id {} and counterparty {}", + user_channel_id, + counterparty_node_id + ); + Err(Error::ChannelSplicingFailed) + } + } + /// Manually sync the LDK and BDK wallets with the current chain state and update the fee rate /// cache. /// @@ -2094,11 +2240,7 @@ impl Node { | self.chain_monitor.provided_node_features() | self.onion_messenger.provided_node_features() | gossip_features - | self - .liquidity_source - .as_ref() - .map(|ls| ls.liquidity_manager().provided_node_features()) - .unwrap_or_else(LdkNodeFeatures::empty) + | self.liquidity_source.liquidity_manager().provided_node_features() } } diff --git a/src/liquidity.rs b/src/liquidity.rs deleted file mode 100644 index 3cd6d110da..0000000000 --- a/src/liquidity.rs +++ /dev/null @@ -1,1600 +0,0 @@ -// 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. - -//! Objects related to liquidity management. - -use std::collections::HashMap; -use std::ops::Deref; -use std::sync::{Arc, Mutex, RwLock, Weak}; -use std::time::Duration; - -use bitcoin::secp256k1::{PublicKey, Secp256k1}; -use bitcoin::Transaction; -use chrono::Utc; -use lightning::events::HTLCHandlingFailureType; -use lightning::ln::channelmanager::{InterceptId, MIN_FINAL_CLTV_EXPIRY_DELTA}; -use lightning::ln::msgs::SocketAddress; -use lightning::ln::types::ChannelId; -use lightning::routing::router::{RouteHint, RouteHintHop}; -use lightning::sign::EntropySource; -use lightning::util::ser::Writeable; -use lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription, InvoiceBuilder, RoutingFees}; -use lightning_liquidity::events::LiquidityEvent; -use lightning_liquidity::lsps0::ser::{LSPSDateTime, LSPSRequestId}; -use lightning_liquidity::lsps1::client::LSPS1ClientConfig as LdkLSPS1ClientConfig; -use lightning_liquidity::lsps1::event::LSPS1ClientEvent; -use lightning_liquidity::lsps1::msgs::{ - LSPS1ChannelInfo, LSPS1Options, LSPS1OrderId, LSPS1OrderParams, -}; -use lightning_liquidity::lsps2::client::LSPS2ClientConfig as LdkLSPS2ClientConfig; -use lightning_liquidity::lsps2::event::{LSPS2ClientEvent, LSPS2ServiceEvent}; -use lightning_liquidity::lsps2::msgs::{LSPS2OpeningFeeParams, LSPS2RawOpeningFeeParams}; -use lightning_liquidity::lsps2::service::LSPS2ServiceConfig as LdkLSPS2ServiceConfig; -use lightning_liquidity::lsps2::utils::compute_opening_fee; -use lightning_liquidity::{LiquidityClientConfig, LiquidityServiceConfig}; -use lightning_types::payment::PaymentHash; -use tokio::sync::oneshot; - -use crate::builder::BuildError; -use crate::connection::ConnectionManager; -use crate::logger::{log_debug, log_error, log_info, LdkLogger, Logger}; -use crate::payment::store::LSPS2Parameters; -use crate::payment::PaymentMetadata; -use crate::runtime::Runtime; -use crate::types::{ - Broadcaster, ChannelManager, DynStore, KeysManager, LiquidityManager, PeerManager, Wallet, -}; -use crate::{total_anchor_channels_reserve_sats, Config, Error}; - -const LIQUIDITY_REQUEST_TIMEOUT_SECS: u64 = 5; - -const LSPS2_GETINFO_REQUEST_EXPIRY: Duration = Duration::from_secs(60 * 60 * 24); -const LSPS2_CHANNEL_CLTV_EXPIRY_DELTA: u32 = 72; - -struct LSPS1Client { - lsp_node_id: PublicKey, - lsp_address: SocketAddress, - token: Option, - ldk_client_config: LdkLSPS1ClientConfig, - pending_opening_params_requests: - Mutex>>, - pending_create_order_requests: Mutex>>, - pending_check_order_status_requests: - Mutex>>, -} - -#[derive(Debug, Clone)] -pub(crate) struct LSPS1ClientConfig { - pub node_id: PublicKey, - pub address: SocketAddress, - pub token: Option, -} - -struct LSPS2Client { - lsp_node_id: PublicKey, - lsp_address: SocketAddress, - token: Option, - ldk_client_config: LdkLSPS2ClientConfig, - pending_fee_requests: Mutex>>, - pending_buy_requests: Mutex>>, -} - -#[derive(Debug, Clone)] -pub(crate) struct LSPS2ClientConfig { - pub node_id: PublicKey, - pub address: SocketAddress, - pub token: Option, -} - -struct LSPS2Service { - service_config: LSPS2ServiceConfig, - ldk_service_config: LdkLSPS2ServiceConfig, -} - -/// Represents the configuration of the LSPS2 service. -/// -/// See [bLIP-52 / LSPS2] for more information. -/// -/// [bLIP-52 / LSPS2]: https://github.com/lightning/blips/blob/master/blip-0052.md -#[derive(Debug, Clone)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] -pub struct LSPS2ServiceConfig { - /// A token we may require to be sent by the clients. - /// - /// If set, only requests matching this token will be accepted. - pub require_token: Option, - /// Indicates whether the LSPS service will be announced via the gossip network. - pub advertise_service: bool, - /// The fee we withhold for the channel open from the initial payment. - /// - /// This fee is proportional to the client-requested amount, in parts-per-million. - pub channel_opening_fee_ppm: u32, - /// The proportional overprovisioning for the channel. - /// - /// This determines, in parts-per-million, how much value we'll provision on top of the amount - /// we need to forward the payment to the client. - /// - /// For example, setting this to `100_000` will result in a channel being opened that is 10% - /// larger than then the to-be-forwarded amount (i.e., client-requested amount minus the - /// channel opening fee fee). - pub channel_over_provisioning_ppm: u32, - /// The minimum fee required for opening a channel. - pub min_channel_opening_fee_msat: u64, - /// The minimum number of blocks after confirmation we promise to keep the channel open. - pub min_channel_lifetime: u32, - /// The maximum number of blocks that the client is allowed to set its `to_self_delay` parameter. - pub max_client_to_self_delay: u32, - /// The minimum payment size that we will accept when opening a channel. - pub min_payment_size_msat: u64, - /// The maximum payment size that we will accept when opening a channel. - pub max_payment_size_msat: u64, - /// Use the 'client-trusts-LSP' trust model. - /// - /// When set, the service will delay *broadcasting* the JIT channel's funding transaction until - /// the client claimed sufficient HTLC parts to pay for the channel open. - /// - /// Note this will render the flow incompatible with clients utilizing the 'LSP-trust-client' - /// trust model, i.e., in turn delay *claiming* any HTLCs until they see the funding - /// transaction in the mempool. - /// - /// Please refer to [`bLIP-52`] for more information. - /// - /// [`bLIP-52`]: https://github.com/lightning/blips/blob/master/blip-0052.md#trust-models - pub client_trusts_lsp: bool, - /// When set, we will allow clients to spend their entire channel balance in the channels - /// we open to them. This allows clients to try to steal your channel balance with - /// no financial penalty, so this should only be set if you trust your clients. - /// - /// See [`Node::open_0reserve_channel`] to manually open these channels. - /// - /// [`Node::open_0reserve_channel`]: crate::Node::open_0reserve_channel - pub disable_client_reserve: bool, -} - -pub(crate) struct LiquiditySourceBuilder -where - L::Target: LdkLogger, -{ - lsps1_client: Option, - lsps2_client: Option, - lsps2_service: Option, - wallet: Arc, - channel_manager: Arc, - keys_manager: Arc, - tx_broadcaster: Arc, - kv_store: Arc, - config: Arc, - logger: L, -} - -impl LiquiditySourceBuilder -where - L::Target: LdkLogger, -{ - pub(crate) fn new( - wallet: Arc, channel_manager: Arc, keys_manager: Arc, - tx_broadcaster: Arc, kv_store: Arc, config: Arc, logger: L, - ) -> Self { - let lsps1_client = None; - let lsps2_client = None; - let lsps2_service = None; - Self { - lsps1_client, - lsps2_client, - lsps2_service, - wallet, - channel_manager, - keys_manager, - tx_broadcaster, - kv_store, - config, - logger, - } - } - - pub(crate) fn lsps1_client( - &mut self, lsp_node_id: PublicKey, lsp_address: SocketAddress, token: Option, - ) -> &mut Self { - // TODO: allow to set max_channel_fees_msat - let ldk_client_config = LdkLSPS1ClientConfig { max_channel_fees_msat: None }; - let pending_opening_params_requests = Mutex::new(HashMap::new()); - let pending_create_order_requests = Mutex::new(HashMap::new()); - let pending_check_order_status_requests = Mutex::new(HashMap::new()); - self.lsps1_client = Some(LSPS1Client { - lsp_node_id, - lsp_address, - token, - ldk_client_config, - pending_opening_params_requests, - pending_create_order_requests, - pending_check_order_status_requests, - }); - self - } - - pub(crate) fn lsps2_client( - &mut self, lsp_node_id: PublicKey, lsp_address: SocketAddress, token: Option, - ) -> &mut Self { - let ldk_client_config = LdkLSPS2ClientConfig {}; - let pending_fee_requests = Mutex::new(HashMap::new()); - let pending_buy_requests = Mutex::new(HashMap::new()); - self.lsps2_client = Some(LSPS2Client { - lsp_node_id, - lsp_address, - token, - ldk_client_config, - pending_fee_requests, - pending_buy_requests, - }); - self - } - - pub(crate) fn lsps2_service( - &mut self, promise_secret: [u8; 32], service_config: LSPS2ServiceConfig, - ) -> &mut Self { - let ldk_service_config = LdkLSPS2ServiceConfig { promise_secret }; - self.lsps2_service = Some(LSPS2Service { service_config, ldk_service_config }); - self - } - - pub(crate) async fn build(self) -> Result, BuildError> { - let liquidity_service_config = self.lsps2_service.as_ref().map(|s| { - let lsps2_service_config = Some(s.ldk_service_config.clone()); - let lsps5_service_config = None; - let advertise_service = s.service_config.advertise_service; - LiquidityServiceConfig { - lsps1_service_config: None, - lsps2_service_config, - lsps5_service_config, - advertise_service, - } - }); - - let lsps1_client_config = self.lsps1_client.as_ref().map(|s| s.ldk_client_config.clone()); - let lsps2_client_config = self.lsps2_client.as_ref().map(|s| s.ldk_client_config.clone()); - let lsps5_client_config = None; - let liquidity_client_config = Some(LiquidityClientConfig { - lsps1_client_config, - lsps2_client_config, - lsps5_client_config, - }); - - let liquidity_manager = Arc::new( - LiquidityManager::new( - Arc::clone(&self.keys_manager), - Arc::clone(&self.keys_manager), - Arc::clone(&self.channel_manager), - Arc::clone(&self.kv_store), - Arc::clone(&self.tx_broadcaster), - liquidity_service_config, - liquidity_client_config, - ) - .await - .map_err(|_| BuildError::ReadFailed)?, - ); - - Ok(LiquiditySource { - lsps1_client: self.lsps1_client, - lsps2_client: self.lsps2_client, - lsps2_service: self.lsps2_service, - wallet: self.wallet, - channel_manager: self.channel_manager, - peer_manager: RwLock::new(None), - keys_manager: self.keys_manager, - liquidity_manager, - config: self.config, - logger: self.logger, - }) - } -} - -pub(crate) struct LiquiditySource -where - L::Target: LdkLogger, -{ - lsps1_client: Option, - lsps2_client: Option, - lsps2_service: Option, - wallet: Arc, - channel_manager: Arc, - peer_manager: RwLock>>, - keys_manager: Arc, - liquidity_manager: Arc, - config: Arc, - logger: L, -} - -impl LiquiditySource -where - L::Target: LdkLogger, -{ - pub(crate) fn set_peer_manager(&self, peer_manager: Weak) { - *self.peer_manager.write().expect("lock") = Some(peer_manager); - } - - pub(crate) fn liquidity_manager(&self) -> Arc { - Arc::clone(&self.liquidity_manager) - } - - pub(crate) fn get_lsps1_lsp_details(&self) -> Option<(PublicKey, SocketAddress)> { - self.lsps1_client.as_ref().map(|s| (s.lsp_node_id, s.lsp_address.clone())) - } - - pub(crate) fn get_lsps2_lsp_details(&self) -> Option<(PublicKey, SocketAddress)> { - self.lsps2_client.as_ref().map(|s| (s.lsp_node_id, s.lsp_address.clone())) - } - - pub(crate) fn lsps2_channel_needs_manual_broadcast( - &self, counterparty_node_id: PublicKey, user_channel_id: u128, - ) -> bool { - self.lsps2_service.as_ref().map_or(false, |lsps2_service| { - lsps2_service.service_config.client_trusts_lsp - && self - .liquidity_manager() - .lsps2_service_handler() - .and_then(|handler| { - handler - .channel_needs_manual_broadcast(user_channel_id, &counterparty_node_id) - .ok() - }) - .unwrap_or(false) - }) - } - - pub(crate) fn lsps2_store_funding_transaction( - &self, user_channel_id: u128, counterparty_node_id: PublicKey, funding_tx: Transaction, - ) { - if self.lsps2_service.as_ref().map_or(false, |svc| !svc.service_config.client_trusts_lsp) { - // Only necessary for client-trusts-LSP flow - return; - } - - let lsps2_service_handler = self.liquidity_manager.lsps2_service_handler(); - if let Some(handler) = lsps2_service_handler { - handler - .store_funding_transaction(user_channel_id, &counterparty_node_id, funding_tx) - .unwrap_or_else(|e| { - debug_assert!(false, "Failed to store funding transaction: {:?}", e); - log_error!(self.logger, "Failed to store funding transaction: {:?}", e); - }); - } else { - log_error!(self.logger, "LSPS2 service handler is not available."); - } - } - - pub(crate) fn lsps2_funding_tx_broadcast_safe( - &self, user_channel_id: u128, counterparty_node_id: PublicKey, - ) { - if self.lsps2_service.as_ref().map_or(false, |svc| !svc.service_config.client_trusts_lsp) { - // Only necessary for client-trusts-LSP flow - return; - } - - let lsps2_service_handler = self.liquidity_manager.lsps2_service_handler(); - if let Some(handler) = lsps2_service_handler { - handler - .set_funding_tx_broadcast_safe(user_channel_id, &counterparty_node_id) - .unwrap_or_else(|e| { - debug_assert!( - false, - "Failed to mark funding transaction safe to broadcast: {:?}", - e - ); - log_error!( - self.logger, - "Failed to mark funding transaction safe to broadcast: {:?}", - e - ); - }); - } else { - log_error!(self.logger, "LSPS2 service handler is not available."); - } - } - - pub(crate) async fn handle_next_event(&self) { - match self.liquidity_manager.next_event_async().await { - LiquidityEvent::LSPS1Client(LSPS1ClientEvent::SupportedOptionsReady { - request_id, - counterparty_node_id, - supported_options, - }) => { - if let Some(lsps1_client) = self.lsps1_client.as_ref() { - if counterparty_node_id != lsps1_client.lsp_node_id { - debug_assert!( - false, - "Received response from unexpected LSP counterparty. This should never happen." - ); - log_error!( - self.logger, - "Received response from unexpected LSP counterparty. This should never happen." - ); - return; - } - - if let Some(sender) = lsps1_client - .pending_opening_params_requests - .lock() - .expect("lock") - .remove(&request_id) - { - let response = LSPS1OpeningParamsResponse { supported_options }; - - match sender.send(response) { - Ok(()) => (), - Err(_) => { - log_error!( - self.logger, - "Failed to handle response for request {:?} from liquidity service", - request_id - ); - }, - } - } else { - debug_assert!( - false, - "Received response from liquidity service for unknown request." - ); - log_error!( - self.logger, - "Received response from liquidity service for unknown request." - ); - } - } else { - log_error!( - self.logger, - "Received unexpected LSPS1Client::SupportedOptionsReady event!" - ); - } - }, - LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderCreated { - request_id, - counterparty_node_id, - order_id, - order, - payment, - channel, - }) => { - if let Some(lsps1_client) = self.lsps1_client.as_ref() { - if counterparty_node_id != lsps1_client.lsp_node_id { - debug_assert!( - false, - "Received response from unexpected LSP counterparty. This should never happen." - ); - log_error!( - self.logger, - "Received response from unexpected LSP counterparty. This should never happen." - ); - return; - } - - if let Some(sender) = lsps1_client - .pending_create_order_requests - .lock() - .expect("lock") - .remove(&request_id) - { - let response = LSPS1OrderStatus { - order_id, - order_params: order, - payment_options: payment.into(), - channel_state: channel, - }; - - match sender.send(response) { - Ok(()) => (), - Err(_) => { - log_error!( - self.logger, - "Failed to handle response for request {:?} from liquidity service", - request_id - ); - }, - } - } else { - debug_assert!( - false, - "Received response from liquidity service for unknown request." - ); - log_error!( - self.logger, - "Received response from liquidity service for unknown request." - ); - } - } else { - log_error!(self.logger, "Received unexpected LSPS1Client::OrderCreated event!"); - } - }, - LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderStatus { - request_id, - counterparty_node_id, - order_id, - order, - payment, - channel, - }) => { - if let Some(lsps1_client) = self.lsps1_client.as_ref() { - if counterparty_node_id != lsps1_client.lsp_node_id { - debug_assert!( - false, - "Received response from unexpected LSP counterparty. This should never happen." - ); - log_error!( - self.logger, - "Received response from unexpected LSP counterparty. This should never happen." - ); - return; - } - - if let Some(sender) = lsps1_client - .pending_check_order_status_requests - .lock() - .expect("lock") - .remove(&request_id) - { - let response = LSPS1OrderStatus { - order_id, - order_params: order, - payment_options: payment.into(), - channel_state: channel, - }; - - match sender.send(response) { - Ok(()) => (), - Err(_) => { - log_error!( - self.logger, - "Failed to handle response for request {:?} from liquidity service", - request_id - ); - }, - } - } else { - debug_assert!( - false, - "Received response from liquidity service for unknown request." - ); - log_error!( - self.logger, - "Received response from liquidity service for unknown request." - ); - } - } else { - log_error!(self.logger, "Received unexpected LSPS1Client::OrderStatus event!"); - } - }, - LiquidityEvent::LSPS2Service(LSPS2ServiceEvent::GetInfo { - request_id, - counterparty_node_id, - token, - }) => { - if let Some(lsps2_service_handler) = - self.liquidity_manager.lsps2_service_handler().as_ref() - { - let service_config = if let Some(service_config) = - self.lsps2_service.as_ref().map(|s| s.service_config.clone()) - { - service_config - } else { - log_error!(self.logger, "Failed to handle LSPS2ServiceEvent as LSPS2 liquidity service was not configured.",); - return; - }; - - if let Some(required) = service_config.require_token { - if token != Some(required) { - log_error!( - self.logger, - "Rejecting LSPS2 request {:?} from counterparty {} as the client provided an invalid token.", - request_id, - counterparty_node_id - ); - lsps2_service_handler.invalid_token_provided(&counterparty_node_id, request_id.clone()).unwrap_or_else(|e| { - debug_assert!(false, "Failed to reject LSPS2 request. This should never happen."); - log_error!( - self.logger, - "Failed to reject LSPS2 request {:?} from counterparty {} due to: {:?}. This should never happen.", - request_id, - counterparty_node_id, - e - ); - }); - return; - } - } - - let valid_until = LSPSDateTime(Utc::now() + LSPS2_GETINFO_REQUEST_EXPIRY); - let opening_fee_params = LSPS2RawOpeningFeeParams { - min_fee_msat: service_config.min_channel_opening_fee_msat, - proportional: service_config.channel_opening_fee_ppm, - valid_until, - min_lifetime: service_config.min_channel_lifetime, - max_client_to_self_delay: service_config.max_client_to_self_delay, - min_payment_size_msat: service_config.min_payment_size_msat, - max_payment_size_msat: service_config.max_payment_size_msat, - }; - - let opening_fee_params_menu = vec![opening_fee_params]; - - if let Err(e) = lsps2_service_handler.opening_fee_params_generated( - &counterparty_node_id, - request_id, - opening_fee_params_menu, - ) { - log_error!( - self.logger, - "Failed to handle generated opening fee params: {:?}", - e - ); - } - } else { - log_error!(self.logger, "Failed to handle LSPS2ServiceEvent as LSPS2 liquidity service was not configured.",); - return; - } - }, - LiquidityEvent::LSPS2Service(LSPS2ServiceEvent::BuyRequest { - request_id, - counterparty_node_id, - opening_fee_params: _, - payment_size_msat, - }) => { - if let Some(lsps2_service_handler) = - self.liquidity_manager.lsps2_service_handler().as_ref() - { - let service_config = if let Some(service_config) = - self.lsps2_service.as_ref().map(|s| s.service_config.clone()) - { - service_config - } else { - log_error!(self.logger, "Failed to handle LSPS2ServiceEvent as LSPS2 liquidity service was not configured.",); - return; - }; - - let user_channel_id: u128 = u128::from_ne_bytes( - self.keys_manager.get_secure_random_bytes()[..16] - .try_into() - .expect("a 16-byte slice should convert into a [u8; 16]"), - ); - let intercept_scid = self.channel_manager.get_intercept_scid(); - - if let Some(payment_size_msat) = payment_size_msat { - // We already check this in `lightning-liquidity`, but better safe than - // sorry. - // - // TODO: We might want to eventually send back an error here, but we - // currently can't and have to trust `lightning-liquidity` is doing the - // right thing. - // - // TODO: Eventually we also might want to make sure that we have sufficient - // liquidity for the channel opening here. - if payment_size_msat > service_config.max_payment_size_msat - || payment_size_msat < service_config.min_payment_size_msat - { - log_error!( - self.logger, - "Rejecting to handle LSPS2 buy request {:?} from counterparty {} as the client requested an invalid payment size.", - request_id, - counterparty_node_id - ); - return; - } - } - - match lsps2_service_handler - .invoice_parameters_generated( - &counterparty_node_id, - request_id, - intercept_scid, - LSPS2_CHANNEL_CLTV_EXPIRY_DELTA, - service_config.client_trusts_lsp, - user_channel_id, - ) - .await - { - Ok(()) => {}, - Err(e) => { - log_error!( - self.logger, - "Failed to provide invoice parameters: {:?}", - e - ); - return; - }, - } - } else { - log_error!(self.logger, "Failed to handle LSPS2ServiceEvent as LSPS2 liquidity service was not configured.",); - return; - } - }, - LiquidityEvent::LSPS2Service(LSPS2ServiceEvent::OpenChannel { - their_network_key, - amt_to_forward_msat, - opening_fee_msat: _, - user_channel_id, - intercept_scid: _, - }) => { - if self.liquidity_manager.lsps2_service_handler().is_none() { - log_error!(self.logger, "Failed to handle LSPS2ServiceEvent as LSPS2 liquidity service was not configured.",); - return; - }; - - let service_config = if let Some(service_config) = - self.lsps2_service.as_ref().map(|s| s.service_config.clone()) - { - service_config - } else { - log_error!(self.logger, "Failed to handle LSPS2ServiceEvent as LSPS2 liquidity service was not configured.",); - return; - }; - - let init_features = if let Some(Some(peer_manager)) = - self.peer_manager.read().expect("lock").as_ref().map(|weak| weak.upgrade()) - { - // Fail if we're not connected to the prospective channel partner. - if let Some(peer) = peer_manager.peer_by_node_id(&their_network_key) { - peer.init_features - } else { - // TODO: We just silently fail here. Eventually we will need to remember - // the pending requests and regularly retry opening the channel until we - // succeed. - log_error!( - self.logger, - "Failed to open LSPS2 channel to {} due to peer not being not connected.", - their_network_key, - ); - return; - } - } else { - debug_assert!(false, "Failed to handle LSPS2ServiceEvent as peer manager isn't available. This should never happen.",); - log_error!(self.logger, "Failed to handle LSPS2ServiceEvent as peer manager isn't available. This should never happen.",); - return; - }; - - // Fail if we have insufficient onchain funds available. - let over_provisioning_msat = (amt_to_forward_msat - * service_config.channel_over_provisioning_ppm as u64) - / 1_000_000; - 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 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() - && !c.trusted_peers_no_reserve.contains(&their_network_key) - { - c.per_channel_reserve_sats - } else { - 0 - } - }); - if spendable_amount_sats < required_funds_sats { - log_error!(self.logger, - "Unable to create channel due to insufficient funds. Available: {}sats, Required: {}sats", - spendable_amount_sats, channel_amount_sats - ); - // TODO: We just silently fail here. Eventually we will need to remember - // the pending requests and regularly retry opening the channel until we - // succeed. - return; - } - - let mut config = self.channel_manager.get_current_config().clone(); - - // If we act as an LSPS2 service, the HTLC-value-in-flight must be 100% of the - // channel value to ensure we can forward the initial payment. That cap only - // applies to unannounced channels, so the channel must also be unannounced. - debug_assert_eq!( - config - .channel_handshake_config - .unannounced_channel_max_inbound_htlc_value_in_flight_percentage, - 100 - ); - debug_assert!(!config.channel_handshake_config.announce_for_forwarding); - debug_assert!(config.accept_forwards_to_priv_channels); - - // We set the forwarding fee to 0 for now as we're getting paid by the channel fee. - // - // TODO: revisit this decision eventually. - config.channel_config.forwarding_fee_base_msat = 0; - config.channel_config.forwarding_fee_proportional_millionths = 0; - - let result = if service_config.disable_client_reserve { - self.channel_manager.create_channel_to_trusted_peer_0reserve( - their_network_key, - channel_amount_sats, - 0, - user_channel_id, - None, - Some(config), - ) - } else { - self.channel_manager.create_channel( - their_network_key, - channel_amount_sats, - 0, - user_channel_id, - None, - Some(config), - ) - }; - - match result { - Ok(_) => {}, - Err(e) => { - // TODO: We just silently fail here. Eventually we will need to remember - // the pending requests and regularly retry opening the channel until we - // succeed. - let zero_reserve_string = - if service_config.disable_client_reserve { "0reserve " } else { "" }; - log_error!( - self.logger, - "Failed to open LSPS2 {}channel to {}: {:?}", - zero_reserve_string, - their_network_key, - e - ); - return; - }, - } - }, - LiquidityEvent::LSPS2Client(LSPS2ClientEvent::OpeningParametersReady { - request_id, - counterparty_node_id, - opening_fee_params_menu, - }) => { - if let Some(lsps2_client) = self.lsps2_client.as_ref() { - if counterparty_node_id != lsps2_client.lsp_node_id { - debug_assert!( - false, - "Received response from unexpected LSP counterparty. This should never happen." - ); - log_error!( - self.logger, - "Received response from unexpected LSP counterparty. This should never happen." - ); - return; - } - - if let Some(sender) = - lsps2_client.pending_fee_requests.lock().expect("lock").remove(&request_id) - { - let response = LSPS2FeeResponse { opening_fee_params_menu }; - - match sender.send(response) { - Ok(()) => (), - Err(_) => { - log_error!( - self.logger, - "Failed to handle response for request {:?} from liquidity service", - request_id - ); - }, - } - } else { - debug_assert!( - false, - "Received response from liquidity service for unknown request." - ); - log_error!( - self.logger, - "Received response from liquidity service for unknown request." - ); - } - } else { - log_error!( - self.logger, - "Received unexpected LSPS2Client::OpeningParametersReady event!" - ); - } - }, - LiquidityEvent::LSPS2Client(LSPS2ClientEvent::InvoiceParametersReady { - request_id, - counterparty_node_id, - intercept_scid, - cltv_expiry_delta, - .. - }) => { - if let Some(lsps2_client) = self.lsps2_client.as_ref() { - if counterparty_node_id != lsps2_client.lsp_node_id { - debug_assert!( - false, - "Received response from unexpected LSP counterparty. This should never happen." - ); - log_error!( - self.logger, - "Received response from unexpected LSP counterparty. This should never happen." - ); - return; - } - - if let Some(sender) = - lsps2_client.pending_buy_requests.lock().expect("lock").remove(&request_id) - { - let response = LSPS2BuyResponse { intercept_scid, cltv_expiry_delta }; - - match sender.send(response) { - Ok(()) => (), - Err(_) => { - log_error!( - self.logger, - "Failed to handle response for request {:?} from liquidity service", - request_id - ); - }, - } - } else { - debug_assert!( - false, - "Received response from liquidity service for unknown request." - ); - log_error!( - self.logger, - "Received response from liquidity service for unknown request." - ); - } - } else { - log_error!( - self.logger, - "Received unexpected LSPS2Client::InvoiceParametersReady event!" - ); - } - }, - e => { - log_error!(self.logger, "Received unexpected liquidity event: {:?}", e); - }, - } - } - - pub(crate) async fn lsps1_request_opening_params( - &self, - ) -> Result { - let lsps1_client = self.lsps1_client.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; - - let client_handler = self.liquidity_manager.lsps1_client_handler().ok_or_else(|| { - log_error!(self.logger, "LSPS1 liquidity client was not configured.",); - Error::LiquiditySourceUnavailable - })?; - - let (request_sender, request_receiver) = oneshot::channel(); - { - let mut pending_opening_params_requests_lock = - lsps1_client.pending_opening_params_requests.lock().expect("lock"); - let request_id = client_handler.request_supported_options(lsps1_client.lsp_node_id); - pending_opening_params_requests_lock.insert(request_id, request_sender); - } - - tokio::time::timeout(Duration::from_secs(LIQUIDITY_REQUEST_TIMEOUT_SECS), request_receiver) - .await - .map_err(|e| { - log_error!(self.logger, "Liquidity request timed out: {}", e); - Error::LiquidityRequestFailed - })? - .map_err(|e| { - log_error!(self.logger, "Failed to handle response from liquidity service: {}", e); - Error::LiquidityRequestFailed - }) - } - - pub(crate) async fn lsps1_request_channel( - &self, lsp_balance_sat: u64, client_balance_sat: u64, channel_expiry_blocks: u32, - announce_channel: bool, refund_address: bitcoin::Address, - ) -> Result { - let lsps1_client = self.lsps1_client.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; - let client_handler = self.liquidity_manager.lsps1_client_handler().ok_or_else(|| { - log_error!(self.logger, "LSPS1 liquidity client was not configured.",); - Error::LiquiditySourceUnavailable - })?; - - let lsp_limits = self.lsps1_request_opening_params().await?.supported_options; - let channel_size_sat = lsp_balance_sat + client_balance_sat; - - if channel_size_sat < lsp_limits.min_channel_balance_sat - || channel_size_sat > lsp_limits.max_channel_balance_sat - { - log_error!( - self.logger, - "Requested channel size of {}sat doesn't meet the LSP-provided limits (min: {}sat, max: {}sat).", - channel_size_sat, - lsp_limits.min_channel_balance_sat, - lsp_limits.max_channel_balance_sat - ); - return Err(Error::LiquidityRequestFailed); - } - - if lsp_balance_sat < lsp_limits.min_initial_lsp_balance_sat - || lsp_balance_sat > lsp_limits.max_initial_lsp_balance_sat - { - log_error!( - self.logger, - "Requested LSP-side balance of {}sat doesn't meet the LSP-provided limits (min: {}sat, max: {}sat).", - lsp_balance_sat, - lsp_limits.min_initial_lsp_balance_sat, - lsp_limits.max_initial_lsp_balance_sat - ); - return Err(Error::LiquidityRequestFailed); - } - - if client_balance_sat < lsp_limits.min_initial_client_balance_sat - || client_balance_sat > lsp_limits.max_initial_client_balance_sat - { - log_error!( - self.logger, - "Requested client-side balance of {}sat doesn't meet the LSP-provided limits (min: {}sat, max: {}sat).", - client_balance_sat, - lsp_limits.min_initial_client_balance_sat, - lsp_limits.max_initial_client_balance_sat - ); - return Err(Error::LiquidityRequestFailed); - } - - let order_params = LSPS1OrderParams { - lsp_balance_sat, - client_balance_sat, - required_channel_confirmations: lsp_limits.min_required_channel_confirmations, - funding_confirms_within_blocks: lsp_limits.min_funding_confirms_within_blocks, - channel_expiry_blocks, - token: lsps1_client.token.clone(), - announce_channel, - }; - - let (request_sender, request_receiver) = oneshot::channel(); - let request_id; - { - let mut pending_create_order_requests_lock = - lsps1_client.pending_create_order_requests.lock().expect("lock"); - request_id = client_handler.create_order( - &lsps1_client.lsp_node_id, - order_params.clone(), - Some(refund_address), - ); - pending_create_order_requests_lock.insert(request_id.clone(), request_sender); - } - - let response = tokio::time::timeout( - Duration::from_secs(LIQUIDITY_REQUEST_TIMEOUT_SECS), - request_receiver, - ) - .await - .map_err(|e| { - log_error!(self.logger, "Liquidity request with ID {:?} timed out: {}", request_id, e); - Error::LiquidityRequestFailed - })? - .map_err(|e| { - log_error!(self.logger, "Failed to handle response from liquidity service: {}", e); - Error::LiquidityRequestFailed - })?; - - if response.order_params != order_params { - log_error!( - self.logger, - "Aborting LSPS1 request as LSP-provided parameters don't match our order. Expected: {:?}, Received: {:?}", order_params, response.order_params - ); - return Err(Error::LiquidityRequestFailed); - } - - Ok(response) - } - - pub(crate) async fn lsps1_check_order_status( - &self, order_id: LSPS1OrderId, - ) -> Result { - let lsps1_client = self.lsps1_client.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; - let client_handler = self.liquidity_manager.lsps1_client_handler().ok_or_else(|| { - log_error!(self.logger, "LSPS1 liquidity client was not configured.",); - Error::LiquiditySourceUnavailable - })?; - - let (request_sender, request_receiver) = oneshot::channel(); - { - let mut pending_check_order_status_requests_lock = - lsps1_client.pending_check_order_status_requests.lock().expect("lock"); - let request_id = client_handler.check_order_status(&lsps1_client.lsp_node_id, order_id); - pending_check_order_status_requests_lock.insert(request_id, request_sender); - } - - let response = tokio::time::timeout( - Duration::from_secs(LIQUIDITY_REQUEST_TIMEOUT_SECS), - request_receiver, - ) - .await - .map_err(|e| { - log_error!(self.logger, "Liquidity request timed out: {}", e); - Error::LiquidityRequestFailed - })? - .map_err(|e| { - log_error!(self.logger, "Failed to handle response from liquidity service: {}", e); - Error::LiquidityRequestFailed - })?; - - Ok(response) - } - - pub(crate) async fn lsps2_receive_to_jit_channel( - &self, amount_msat: u64, description: &Bolt11InvoiceDescription, expiry_secs: u32, - max_total_lsp_fee_limit_msat: Option, payment_hash: Option, - ) -> Result { - let fee_response = self.lsps2_request_opening_fee_params().await?; - - let (min_total_fee_msat, min_opening_params) = fee_response - .opening_fee_params_menu - .into_iter() - .filter_map(|params| { - if amount_msat < params.min_payment_size_msat - || amount_msat > params.max_payment_size_msat - { - log_debug!(self.logger, - "Skipping LSP-offered JIT parameters as the payment of {}msat doesn't meet LSP limits (min: {}msat, max: {}msat)", - amount_msat, - params.min_payment_size_msat, - params.max_payment_size_msat - ); - None - } else { - compute_opening_fee(amount_msat, params.min_fee_msat, params.proportional as u64) - .map(|fee| (fee, params)) - } - }) - .min_by_key(|p| p.0) - .ok_or_else(|| { - log_error!(self.logger, "Failed to handle response from liquidity service",); - Error::LiquidityRequestFailed - })?; - - if let Some(max_total_lsp_fee_limit_msat) = max_total_lsp_fee_limit_msat { - if min_total_fee_msat > max_total_lsp_fee_limit_msat { - log_error!(self.logger, - "Failed to request inbound JIT channel as LSP's requested total opening fee of {}msat exceeds our fee limit of {}msat", - min_total_fee_msat, max_total_lsp_fee_limit_msat - ); - return Err(Error::LiquidityFeeTooHigh); - } - } - - log_debug!( - self.logger, - "Choosing cheapest liquidity offer, will pay {}msat in total LSP fees", - min_total_fee_msat - ); - - let buy_response = - self.lsps2_send_buy_request(Some(amount_msat), min_opening_params).await?; - let lsps2_parameters = LSPS2Parameters { - max_total_opening_fee_msat: Some(min_total_fee_msat), - max_proportional_opening_fee_ppm_msat: None, - }; - let invoice = self.lsps2_create_jit_invoice( - buy_response, - Some(amount_msat), - description, - expiry_secs, - payment_hash, - lsps2_parameters, - )?; - - log_info!(self.logger, "JIT-channel invoice created: {}", invoice); - Ok(invoice) - } - - pub(crate) async fn lsps2_receive_variable_amount_to_jit_channel( - &self, description: &Bolt11InvoiceDescription, expiry_secs: u32, - max_proportional_lsp_fee_limit_ppm_msat: Option, payment_hash: Option, - ) -> Result { - let fee_response = self.lsps2_request_opening_fee_params().await?; - - let (min_prop_fee_ppm_msat, min_opening_params) = fee_response - .opening_fee_params_menu - .into_iter() - .map(|params| (params.proportional as u64, params)) - .min_by_key(|p| p.0) - .ok_or_else(|| { - log_error!(self.logger, "Failed to handle response from liquidity service",); - Error::LiquidityRequestFailed - })?; - - if let Some(max_proportional_lsp_fee_limit_ppm_msat) = - max_proportional_lsp_fee_limit_ppm_msat - { - if min_prop_fee_ppm_msat > max_proportional_lsp_fee_limit_ppm_msat { - log_error!(self.logger, - "Failed to request inbound JIT channel as LSP's requested proportional opening fee of {} ppm msat exceeds our fee limit of {} ppm msat", - min_prop_fee_ppm_msat, - max_proportional_lsp_fee_limit_ppm_msat - ); - return Err(Error::LiquidityFeeTooHigh); - } - } - - log_debug!( - self.logger, - "Choosing cheapest liquidity offer, will pay {}ppm msat in proportional LSP fees", - min_prop_fee_ppm_msat - ); - - let buy_response = self.lsps2_send_buy_request(None, min_opening_params).await?; - let lsps2_parameters = LSPS2Parameters { - max_total_opening_fee_msat: None, - max_proportional_opening_fee_ppm_msat: Some(min_prop_fee_ppm_msat), - }; - let invoice = self.lsps2_create_jit_invoice( - buy_response, - None, - description, - expiry_secs, - payment_hash, - lsps2_parameters, - )?; - - log_info!(self.logger, "JIT-channel invoice created: {}", invoice); - Ok(invoice) - } - - async fn lsps2_request_opening_fee_params(&self) -> Result { - let lsps2_client = self.lsps2_client.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; - - let client_handler = self.liquidity_manager.lsps2_client_handler().ok_or_else(|| { - log_error!(self.logger, "Liquidity client was not configured.",); - Error::LiquiditySourceUnavailable - })?; - - let (fee_request_sender, fee_request_receiver) = oneshot::channel(); - { - let mut pending_fee_requests_lock = - lsps2_client.pending_fee_requests.lock().expect("lock"); - let request_id = client_handler - .request_opening_params(lsps2_client.lsp_node_id, lsps2_client.token.clone()); - pending_fee_requests_lock.insert(request_id, fee_request_sender); - } - - tokio::time::timeout( - Duration::from_secs(LIQUIDITY_REQUEST_TIMEOUT_SECS), - fee_request_receiver, - ) - .await - .map_err(|e| { - log_error!(self.logger, "Liquidity request timed out: {}", e); - Error::LiquidityRequestFailed - })? - .map_err(|e| { - log_error!(self.logger, "Failed to handle response from liquidity service: {}", e); - Error::LiquidityRequestFailed - }) - } - - async fn lsps2_send_buy_request( - &self, amount_msat: Option, opening_fee_params: LSPS2OpeningFeeParams, - ) -> Result { - let lsps2_client = self.lsps2_client.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; - - let client_handler = self.liquidity_manager.lsps2_client_handler().ok_or_else(|| { - log_error!(self.logger, "Liquidity client was not configured.",); - Error::LiquiditySourceUnavailable - })?; - - let (buy_request_sender, buy_request_receiver) = oneshot::channel(); - { - let mut pending_buy_requests_lock = - lsps2_client.pending_buy_requests.lock().expect("lock"); - let request_id = client_handler - .select_opening_params(lsps2_client.lsp_node_id, amount_msat, opening_fee_params) - .map_err(|e| { - log_error!( - self.logger, - "Failed to send buy request to liquidity service: {:?}", - e - ); - Error::LiquidityRequestFailed - })?; - pending_buy_requests_lock.insert(request_id, buy_request_sender); - } - - let buy_response = tokio::time::timeout( - Duration::from_secs(LIQUIDITY_REQUEST_TIMEOUT_SECS), - buy_request_receiver, - ) - .await - .map_err(|e| { - log_error!(self.logger, "Liquidity request timed out: {}", e); - Error::LiquidityRequestFailed - })? - .map_err(|e| { - log_error!(self.logger, "Failed to handle response from liquidity service: {:?}", e); - Error::LiquidityRequestFailed - })?; - - Ok(buy_response) - } - - fn lsps2_create_jit_invoice( - &self, buy_response: LSPS2BuyResponse, amount_msat: Option, - description: &Bolt11InvoiceDescription, expiry_secs: u32, - payment_hash: Option, lsps2_parameters: LSPS2Parameters, - ) -> Result { - let lsps2_client = self.lsps2_client.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; - - // LSPS2 requires min_final_cltv_expiry_delta to be at least 2 more than usual. - let min_final_cltv_expiry_delta = MIN_FINAL_CLTV_EXPIRY_DELTA + 2; - let encoded_payment_metadata = - PaymentMetadata { lsps2_parameters: Some(lsps2_parameters) }.encode(); - let (payment_hash, payment_secret, payment_metadata) = match payment_hash { - Some(payment_hash) => { - let (payment_secret, payment_metadata) = self - .channel_manager - .create_inbound_payment_for_hash( - payment_hash, - None, - expiry_secs, - Some(min_final_cltv_expiry_delta), - Some(encoded_payment_metadata), - ) - .map_err(|e| { - log_error!(self.logger, "Failed to register inbound payment: {:?}", e); - Error::InvoiceCreationFailed - })?; - (payment_hash, payment_secret, payment_metadata) - }, - None => self - .channel_manager - .create_inbound_payment( - None, - expiry_secs, - Some(min_final_cltv_expiry_delta), - Some(encoded_payment_metadata), - ) - .map_err(|e| { - log_error!(self.logger, "Failed to register inbound payment: {:?}", e); - Error::InvoiceCreationFailed - })?, - }; - - let route_hint = RouteHint(vec![RouteHintHop { - src_node_id: lsps2_client.lsp_node_id, - short_channel_id: buy_response.intercept_scid, - fees: RoutingFees { base_msat: 0, proportional_millionths: 0 }, - cltv_expiry_delta: buy_response.cltv_expiry_delta as u16, - htlc_minimum_msat: None, - htlc_maximum_msat: None, - }]); - - let currency = self.config.network.into(); - let mut invoice_builder = InvoiceBuilder::new(currency) - .invoice_description(description.clone()) - .payment_hash(payment_hash) - .payment_secret(payment_secret) - .current_timestamp() - .min_final_cltv_expiry_delta(min_final_cltv_expiry_delta.into()) - .expiry_time(Duration::from_secs(expiry_secs.into())) - .private_route(route_hint); - - if let Some(amount_msat) = amount_msat { - invoice_builder = invoice_builder.amount_milli_satoshis(amount_msat).basic_mpp(); - } - - let invoice = if let Some(payment_metadata) = payment_metadata { - invoice_builder.payment_metadata(payment_metadata).build_signed(|hash| { - Secp256k1::new() - .sign_ecdsa_recoverable(hash, &self.keys_manager.get_node_secret_key()) - }) - } else { - invoice_builder.build_signed(|hash| { - Secp256k1::new() - .sign_ecdsa_recoverable(hash, &self.keys_manager.get_node_secret_key()) - }) - }; - invoice.map_err(|e| { - log_error!(self.logger, "Failed to build and sign invoice: {}", e); - Error::InvoiceCreationFailed - }) - } - - pub(crate) async fn handle_channel_ready( - &self, user_channel_id: u128, channel_id: &ChannelId, counterparty_node_id: &PublicKey, - ) { - if let Some(lsps2_service_handler) = self.liquidity_manager.lsps2_service_handler() { - if let Err(e) = lsps2_service_handler - .channel_ready(user_channel_id, channel_id, counterparty_node_id) - .await - { - log_error!( - self.logger, - "LSPS2 service failed to handle ChannelReady event: {:?}", - e - ); - } - } - } - - pub(crate) async fn handle_htlc_intercepted( - &self, intercept_scid: u64, intercept_id: InterceptId, expected_outbound_amount_msat: u64, - payment_hash: PaymentHash, - ) { - if let Some(lsps2_service_handler) = self.liquidity_manager.lsps2_service_handler() { - if let Err(e) = lsps2_service_handler - .htlc_intercepted( - intercept_scid, - intercept_id, - expected_outbound_amount_msat, - payment_hash, - ) - .await - { - log_error!( - self.logger, - "LSPS2 service failed to handle HTLCIntercepted event: {:?}", - e - ); - } - } - } - - pub(crate) async fn handle_htlc_handling_failed(&self, failure_type: HTLCHandlingFailureType) { - if let Some(lsps2_service_handler) = self.liquidity_manager.lsps2_service_handler() { - if let Err(e) = lsps2_service_handler.htlc_handling_failed(failure_type).await { - log_error!( - self.logger, - "LSPS2 service failed to handle HTLCHandlingFailed event: {:?}", - e - ); - } - } - } - - pub(crate) async fn handle_payment_forwarded( - &self, next_channel_id: Option, skimmed_fee_msat: u64, - ) { - if let Some(next_channel_id) = next_channel_id { - if let Some(lsps2_service_handler) = self.liquidity_manager.lsps2_service_handler() { - if let Err(e) = - lsps2_service_handler.payment_forwarded(next_channel_id, skimmed_fee_msat).await - { - log_error!( - self.logger, - "LSPS2 service failed to handle PaymentForwarded: {:?}", - e - ); - } - } - } - } -} - -#[derive(Debug, Clone)] -pub(crate) struct LSPS1OpeningParamsResponse { - supported_options: LSPS1Options, -} - -/// Represents the status of an LSPS1 channel request. -#[derive(Debug, Clone)] -pub struct LSPS1OrderStatus { - /// The id of the channel order. - pub order_id: LSPS1OrderId, - /// The parameters of channel order. - pub order_params: LSPS1OrderParams, - /// Contains details about how to pay for the order. - pub payment_options: LSPS1PaymentInfo, - /// Contains information about the channel state. - pub channel_state: Option, -} - -#[cfg(not(feature = "uniffi"))] -type LSPS1PaymentInfo = lightning_liquidity::lsps1::msgs::LSPS1PaymentInfo; - -#[cfg(feature = "uniffi")] -type LSPS1PaymentInfo = crate::ffi::LSPS1PaymentInfo; - -#[derive(Debug, Clone)] -pub(crate) struct LSPS2FeeResponse { - opening_fee_params_menu: Vec, -} - -#[derive(Debug, Clone)] -pub(crate) struct LSPS2BuyResponse { - intercept_scid: u64, - cltv_expiry_delta: u32, -} - -/// A liquidity handler allowing to request channels via the [bLIP-51 / LSPS1] protocol. -/// -/// Should be retrieved by calling [`Node::lsps1_liquidity`]. -/// -/// To open [bLIP-52 / LSPS2] JIT channels, please refer to -/// [`Bolt11Payment::receive_via_jit_channel`]. -/// -/// [bLIP-51 / LSPS1]: https://github.com/lightning/blips/blob/master/blip-0051.md -/// [bLIP-52 / LSPS2]: https://github.com/lightning/blips/blob/master/blip-0052.md -/// [`Node::lsps1_liquidity`]: crate::Node::lsps1_liquidity -/// [`Bolt11Payment::receive_via_jit_channel`]: crate::payment::Bolt11Payment::receive_via_jit_channel -#[derive(Clone)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] -pub struct LSPS1Liquidity { - runtime: Arc, - wallet: Arc, - connection_manager: Arc>>, - liquidity_source: Option>>>, - logger: Arc, -} - -impl LSPS1Liquidity { - pub(crate) fn new( - runtime: Arc, wallet: Arc, - connection_manager: Arc>>, - liquidity_source: Option>>>, logger: Arc, - ) -> Self { - Self { runtime, wallet, connection_manager, liquidity_source, logger } - } -} - -#[cfg_attr(feature = "uniffi", uniffi::export)] -impl LSPS1Liquidity { - /// Connects to the configured LSP and places an order for an inbound channel. - /// - /// The channel will be opened after one of the returned payment options has successfully been - /// paid. - pub fn request_channel( - &self, lsp_balance_sat: u64, client_balance_sat: u64, channel_expiry_blocks: u32, - announce_channel: bool, - ) -> Result { - let liquidity_source = - self.liquidity_source.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; - - let (lsp_node_id, lsp_address) = - liquidity_source.get_lsps1_lsp_details().ok_or(Error::LiquiditySourceUnavailable)?; - - let con_node_id = lsp_node_id; - let con_addr = lsp_address.clone(); - let con_cm = Arc::clone(&self.connection_manager); - - // We need to use our main runtime here as a local runtime might not be around to poll - // connection futures going forward. - self.runtime.block_on(async move { - con_cm.connect_peer_if_necessary(con_node_id, con_addr).await - })?; - - log_info!(self.logger, "Connected to LSP {}@{}. ", lsp_node_id, lsp_address); - - let refund_address = self.wallet.get_new_address()?; - - let liquidity_source = Arc::clone(&liquidity_source); - let response = self.runtime.block_on(async move { - liquidity_source - .lsps1_request_channel( - lsp_balance_sat, - client_balance_sat, - channel_expiry_blocks, - announce_channel, - refund_address, - ) - .await - })?; - - Ok(response) - } - - /// Connects to the configured LSP and checks for the status of a previously-placed order. - pub fn check_order_status(&self, order_id: LSPS1OrderId) -> Result { - let liquidity_source = - self.liquidity_source.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; - - let (lsp_node_id, lsp_address) = - liquidity_source.get_lsps1_lsp_details().ok_or(Error::LiquiditySourceUnavailable)?; - - let con_node_id = lsp_node_id; - let con_addr = lsp_address.clone(); - let con_cm = Arc::clone(&self.connection_manager); - - // We need to use our main runtime here as a local runtime might not be around to poll - // connection futures going forward. - self.runtime.block_on(async move { - con_cm.connect_peer_if_necessary(con_node_id, con_addr).await - })?; - - let liquidity_source = Arc::clone(&liquidity_source); - let response = self - .runtime - .block_on(async move { liquidity_source.lsps1_check_order_status(order_id).await })?; - Ok(response) - } -} diff --git a/src/liquidity/client/lsps1.rs b/src/liquidity/client/lsps1.rs new file mode 100644 index 0000000000..dff374fe2d --- /dev/null +++ b/src/liquidity/client/lsps1.rs @@ -0,0 +1,534 @@ +// 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. + +use std::collections::HashMap; +use std::ops::Deref; +use std::sync::{Arc, Mutex, RwLock}; +use std::time::Duration; + +use bitcoin::secp256k1::PublicKey; +use lightning::log_debug; +use lightning_liquidity::lsps0::ser::LSPSRequestId; +use lightning_liquidity::lsps1::event::LSPS1ClientEvent; +use lightning_liquidity::lsps1::msgs::{ + LSPS1ChannelInfo, LSPS1Options, LSPS1OrderId, LSPS1OrderParams, +}; +use tokio::sync::oneshot; + +use crate::connection::ConnectionManager; +use crate::liquidity::{ + select_lsps_for_protocol, LspConfig, LspNode, LIQUIDITY_REQUEST_TIMEOUT_SECS, + LSPS_DISCOVERY_WAIT_TIMEOUT_SECS, +}; +use crate::logger::{log_error, log_info, LdkLogger, Logger}; +use crate::runtime::Runtime; +use crate::types::{LiquidityManager, Wallet}; +use crate::Error; + +pub(crate) struct LSPS1Client +where + L::Target: LdkLogger, +{ + pub(crate) lsp_nodes: Arc>>, + pub(crate) pending_opening_params_requests: + Mutex>>, + pub(crate) pending_create_order_requests: + Mutex>>, + pub(crate) pending_check_order_status_requests: + Mutex>>, + pub(crate) discovery_done_rx: tokio::sync::watch::Receiver, + pub(crate) liquidity_manager: Arc, + pub(crate) logger: L, +} + +impl LSPS1Client +where + L::Target: LdkLogger, +{ + pub(crate) async fn lsps1_request_opening_params( + &self, node_id: &PublicKey, + ) -> Result { + let lsps1_node = select_lsps_for_protocol(&self.lsp_nodes, 1, Some(node_id)) + .ok_or(Error::LiquiditySourceUnavailable)?; + + let client_handler = self.liquidity_manager.lsps1_client_handler().ok_or_else(|| { + log_error!(self.logger, "LSPS1 liquidity client was not configured.",); + Error::LiquiditySourceUnavailable + })?; + + let (request_sender, request_receiver) = oneshot::channel(); + { + let mut pending_opening_params_requests_lock = + self.pending_opening_params_requests.lock().expect("lock"); + let request_id = client_handler.request_supported_options(lsps1_node.node_id); + pending_opening_params_requests_lock.insert(request_id, request_sender); + } + + tokio::time::timeout(Duration::from_secs(LIQUIDITY_REQUEST_TIMEOUT_SECS), request_receiver) + .await + .map_err(|e| { + log_error!(self.logger, "Liquidity request timed out: {}", e); + Error::LiquidityRequestFailed + })? + .map_err(|e| { + log_error!(self.logger, "Failed to handle response from liquidity service: {}", e); + Error::LiquidityRequestFailed + }) + } + + pub(crate) async fn lsps1_request_channel( + &self, lsp_balance_sat: u64, client_balance_sat: u64, channel_expiry_blocks: u32, + announce_channel: bool, refund_address: bitcoin::Address, node_id: &PublicKey, + ) -> Result { + let lsps1_node = select_lsps_for_protocol(&self.lsp_nodes, 1, Some(node_id)) + .ok_or(Error::LiquiditySourceUnavailable)?; + + let client_handler = self.liquidity_manager.lsps1_client_handler().ok_or_else(|| { + log_error!(self.logger, "LSPS1 liquidity client was not configured.",); + Error::LiquiditySourceUnavailable + })?; + + let lsp_limits = self.lsps1_request_opening_params(node_id).await?.supported_options; + let channel_size_sat = lsp_balance_sat + client_balance_sat; + + if channel_size_sat < lsp_limits.min_channel_balance_sat + || channel_size_sat > lsp_limits.max_channel_balance_sat + { + log_error!( + self.logger, + "Requested channel size of {}sat doesn't meet the LSP-provided limits (min: {}sat, max: {}sat).", + channel_size_sat, + lsp_limits.min_channel_balance_sat, + lsp_limits.max_channel_balance_sat + ); + return Err(Error::LiquidityRequestFailed); + } + + if lsp_balance_sat < lsp_limits.min_initial_lsp_balance_sat + || lsp_balance_sat > lsp_limits.max_initial_lsp_balance_sat + { + log_error!( + self.logger, + "Requested LSP-side balance of {}sat doesn't meet the LSP-provided limits (min: {}sat, max: {}sat).", + lsp_balance_sat, + lsp_limits.min_initial_lsp_balance_sat, + lsp_limits.max_initial_lsp_balance_sat + ); + return Err(Error::LiquidityRequestFailed); + } + + if client_balance_sat < lsp_limits.min_initial_client_balance_sat + || client_balance_sat > lsp_limits.max_initial_client_balance_sat + { + log_error!( + self.logger, + "Requested client-side balance of {}sat doesn't meet the LSP-provided limits (min: {}sat, max: {}sat).", + client_balance_sat, + lsp_limits.min_initial_client_balance_sat, + lsp_limits.max_initial_client_balance_sat + ); + return Err(Error::LiquidityRequestFailed); + } + + let order_params = LSPS1OrderParams { + lsp_balance_sat, + client_balance_sat, + required_channel_confirmations: lsp_limits.min_required_channel_confirmations, + funding_confirms_within_blocks: lsp_limits.min_funding_confirms_within_blocks, + channel_expiry_blocks, + token: lsps1_node.token.clone(), + announce_channel, + }; + + let (request_sender, request_receiver) = oneshot::channel(); + let request_id; + { + let mut pending_create_order_requests_lock = + self.pending_create_order_requests.lock().expect("lock"); + request_id = client_handler.create_order( + &lsps1_node.node_id, + order_params.clone(), + Some(refund_address), + ); + pending_create_order_requests_lock.insert(request_id.clone(), request_sender); + } + + let response = tokio::time::timeout( + Duration::from_secs(LIQUIDITY_REQUEST_TIMEOUT_SECS), + request_receiver, + ) + .await + .map_err(|e| { + log_error!(self.logger, "Liquidity request with ID {:?} timed out: {}", request_id, e); + Error::LiquidityRequestFailed + })? + .map_err(|e| { + log_error!(self.logger, "Failed to handle response from liquidity service: {}", e); + Error::LiquidityRequestFailed + })?; + + if response.order_params != order_params { + log_error!( + self.logger, + "Aborting LSPS1 request as LSP-provided parameters don't match our order. Expected: {:?}, Received: {:?}", order_params, response.order_params + ); + return Err(Error::LiquidityRequestFailed); + } + + Ok(response) + } + + pub(crate) async fn lsps1_check_order_status( + &self, order_id: LSPS1OrderId, lsp_node_id: PublicKey, + ) -> Result { + let client_handler = self.liquidity_manager.lsps1_client_handler().ok_or_else(|| { + log_error!(self.logger, "LSPS1 liquidity client was not configured.",); + Error::LiquiditySourceUnavailable + })?; + + let (request_sender, request_receiver) = oneshot::channel(); + { + let mut pending_check_order_status_requests_lock = + self.pending_check_order_status_requests.lock().expect("lock"); + let request_id = client_handler.check_order_status(&lsp_node_id, order_id); + pending_check_order_status_requests_lock.insert(request_id, request_sender); + } + + let response = tokio::time::timeout( + Duration::from_secs(LIQUIDITY_REQUEST_TIMEOUT_SECS), + request_receiver, + ) + .await + .map_err(|e| { + log_error!(self.logger, "Liquidity request timed out: {}", e); + Error::LiquidityRequestFailed + })? + .map_err(|e| { + log_error!(self.logger, "Failed to handle response from liquidity service: {}", e); + Error::LiquidityRequestFailed + })?; + + Ok(response) + } + + pub(crate) async fn handle_event(&self, event: LSPS1ClientEvent) { + match event { + LSPS1ClientEvent::SupportedOptionsReady { + request_id, + counterparty_node_id, + supported_options, + } => { + if self + .lsp_nodes + .read() + .expect("lock") + .iter() + .any(|n| n.node_id == counterparty_node_id) + { + if let Some(sender) = self + .pending_opening_params_requests + .lock() + .expect("lock") + .remove(&request_id) + { + let response = LSPS1OpeningParamsResponse { supported_options }; + + match sender.send(response) { + Ok(()) => (), + Err(_) => { + log_error!( + self.logger, + "Failed to handle response for request {:?} from liquidity service", + request_id + ); + }, + } + } else { + debug_assert!( + false, + "Received response from liquidity service for unknown request." + ); + log_error!( + self.logger, + "Received response from liquidity service for unknown request." + ); + } + } else { + log_error!( + self.logger, + "Received unexpected LSPS1Client::SupportedOptionsReady event!" + ); + } + }, + LSPS1ClientEvent::OrderCreated { + request_id, + counterparty_node_id, + order_id, + order, + payment, + channel, + } => { + if self + .lsp_nodes + .read() + .expect("lock") + .iter() + .any(|n| n.node_id == counterparty_node_id) + { + if let Some(sender) = + self.pending_create_order_requests.lock().expect("lock").remove(&request_id) + { + let response = LSPS1OrderStatus { + order_id, + order_params: order, + payment_options: payment.into(), + channel_state: channel, + counterparty_node_id, + }; + + match sender.send(response) { + Ok(()) => (), + Err(_) => { + log_error!( + self.logger, + "Failed to handle response for request {:?} from liquidity service", + request_id + ); + }, + } + } else { + debug_assert!( + false, + "Received response from liquidity service for unknown request." + ); + log_error!( + self.logger, + "Received response from liquidity service for unknown request." + ); + } + } else { + log_error!(self.logger, "Received unexpected LSPS1Client::OrderCreated event!"); + } + }, + LSPS1ClientEvent::OrderStatus { + request_id, + counterparty_node_id, + order_id, + order, + payment, + channel, + } => { + if self + .lsp_nodes + .read() + .expect("lock") + .iter() + .any(|n| n.node_id == counterparty_node_id) + { + if let Some(sender) = self + .pending_check_order_status_requests + .lock() + .expect("lock") + .remove(&request_id) + { + let response = LSPS1OrderStatus { + order_id, + order_params: order, + payment_options: payment.into(), + channel_state: channel, + counterparty_node_id, + }; + + match sender.send(response) { + Ok(()) => (), + Err(_) => { + log_error!( + self.logger, + "Failed to handle response for request {:?} from liquidity service", + request_id + ); + }, + } + } else { + debug_assert!( + false, + "Received response from liquidity service for unknown request." + ); + log_error!( + self.logger, + "Received response from liquidity service for unknown request." + ); + } + } else { + log_error!(self.logger, "Received unexpected LSPS1Client::OrderStatus event!"); + } + }, + _ => { + log_error!(self.logger, "Received unexpected LSPS1Client liquidity event!"); + }, + } + } + + async fn get_lsps1_node( + &self, override_node_id: Option<&PublicKey>, + ) -> Result { + if let Some(node) = select_lsps_for_protocol(&self.lsp_nodes, 1, override_node_id) { + return Ok(node); + } + + let has_undiscovered_protocol = + self.lsp_nodes.read().expect("lock").iter().any(|n| n.supported_protocols.is_none()); + + // LSP protocol discovery may still be in flight, we wait briefly for it to finish, then re-check. + if has_undiscovered_protocol && !*self.discovery_done_rx.borrow() { + log_debug!( + self.logger, + "No LSPS1 node available yet, waiting for protocol discovery to complete." + ); + let mut rx = self.discovery_done_rx.clone(); + let _ = tokio::time::timeout( + Duration::from_secs(LSPS_DISCOVERY_WAIT_TIMEOUT_SECS), + rx.wait_for(|done| *done), + ) + .await; + } + + select_lsps_for_protocol(&self.lsp_nodes, 1, override_node_id) + .ok_or(Error::LiquiditySourceUnavailable) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct LSPS1OpeningParamsResponse { + supported_options: LSPS1Options, +} + +/// Represents the status of an LSPS1 channel request. +#[derive(Debug, Clone)] +pub struct LSPS1OrderStatus { + /// The id of the channel order. + pub order_id: LSPS1OrderId, + /// The parameters of channel order. + pub order_params: LSPS1OrderParams, + /// Contains details about how to pay for the order. + pub payment_options: LSPS1PaymentInfo, + /// Contains information about the channel state. + pub channel_state: Option, + /// The node id of the LSP. + pub counterparty_node_id: PublicKey, +} + +#[cfg(not(feature = "uniffi"))] +type LSPS1PaymentInfo = lightning_liquidity::lsps1::msgs::LSPS1PaymentInfo; + +#[cfg(feature = "uniffi")] +type LSPS1PaymentInfo = crate::ffi::LSPS1PaymentInfo; + +/// A liquidity handler allowing to request channels via the [bLIP-51 / LSPS1] protocol. +/// +/// Should be retrieved by calling [`Node::liquidity`]. +/// +/// To open [bLIP-52 / LSPS2] JIT channels, please refer to +/// [`Bolt11Payment::receive_via_jit_channel`]. +/// +/// [bLIP-51 / LSPS1]: https://github.com/lightning/blips/blob/master/blip-0051.md +/// [bLIP-52 / LSPS2]: https://github.com/lightning/blips/blob/master/blip-0052.md +/// [`Node::liquidity`]: crate::Node::liquidity +/// [`Bolt11Payment::receive_via_jit_channel`]: crate::payment::Bolt11Payment::receive_via_jit_channel +#[derive(Clone)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] +pub struct LSPS1Liquidity { + runtime: Arc, + wallet: Arc, + connection_manager: Arc>>, + liquidity_source: Arc>>, + logger: Arc, +} + +impl LSPS1Liquidity { + pub(crate) fn new( + runtime: Arc, wallet: Arc, + connection_manager: Arc>>, + liquidity_source: Arc>>, logger: Arc, + ) -> Self { + Self { runtime, wallet, connection_manager, liquidity_source, logger } + } +} + +#[cfg_attr(feature = "uniffi", uniffi::export)] +impl LSPS1Liquidity { + /// Connects to the configured LSP and places an order for an inbound channel. + /// + /// The channel will be opened after one of the returned payment options has successfully been + /// paid. + /// + /// If `node_id` is `None` and multiple LSPs support LSPS1, the first one registered + /// via [`crate::Builder::add_liquidity_source`] or [`crate::Liquidity::add_liquidity_source`] is used. + pub fn request_channel( + &self, lsp_balance_sat: u64, client_balance_sat: u64, channel_expiry_blocks: u32, + announce_channel: bool, node_id: Option, + ) -> Result { + let lsps1_node = self + .runtime + .block_on(async { self.liquidity_source.get_lsps1_node(node_id.as_ref()).await })?; + + let con_node_id = lsps1_node.node_id; + let con_addr = lsps1_node.address.clone(); + let con_cm = Arc::clone(&self.connection_manager); + + // We need to use our main runtime here as a local runtime might not be around to poll + // connection futures going forward. + self.runtime.block_on(async move { + con_cm.connect_peer_if_necessary(con_node_id, con_addr).await + })?; + + log_info!(self.logger, "Connected to LSP {}@{}. ", lsps1_node.node_id, lsps1_node.address); + + let refund_address = self.wallet.get_new_address()?; + + let liquidity_source = Arc::clone(&self.liquidity_source); + let response = self.runtime.block_on(async move { + liquidity_source + .lsps1_request_channel( + lsp_balance_sat, + client_balance_sat, + channel_expiry_blocks, + announce_channel, + refund_address, + &con_node_id, + ) + .await + })?; + + Ok(response) + } + + /// Connects to the configured LSP and checks for the status of a previously-placed order with the given node ID. + pub fn check_order_status( + &self, order_id: LSPS1OrderId, lsp_node_id: PublicKey, + ) -> Result { + let lsps1_node = self + .runtime + .block_on(async { self.liquidity_source.get_lsps1_node(Some(&lsp_node_id)).await })?; + + let con_node_id = lsps1_node.node_id; + let con_addr = lsps1_node.address.clone(); + let con_cm = Arc::clone(&self.connection_manager); + + // We need to use our main runtime here as a local runtime might not be around to poll + // connection futures going forward. + self.runtime.block_on(async move { + con_cm.connect_peer_if_necessary(con_node_id, con_addr).await + })?; + + let liquidity_source = Arc::clone(&self.liquidity_source); + let response = self.runtime.block_on(async move { + liquidity_source.lsps1_check_order_status(order_id, lsp_node_id).await + })?; + Ok(response) + } +} diff --git a/src/liquidity/client/lsps2.rs b/src/liquidity/client/lsps2.rs new file mode 100644 index 0000000000..3033f8d827 --- /dev/null +++ b/src/liquidity/client/lsps2.rs @@ -0,0 +1,553 @@ +// 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. + +use std::collections::HashMap; +use std::ops::Deref; +use std::sync::{Arc, Mutex, RwLock}; +use std::time::Duration; + +use bitcoin::secp256k1::{PublicKey, Secp256k1}; +use lightning::ln::channelmanager::MIN_FINAL_CLTV_EXPIRY_DELTA; +use lightning::log_warn; +use lightning::routing::router::{RouteHint, RouteHintHop}; +use lightning::util::ser::Writeable; +use lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription, InvoiceBuilder, RoutingFees}; +use lightning_liquidity::lsps0::ser::LSPSRequestId; +use lightning_liquidity::lsps2::event::LSPS2ClientEvent; +use lightning_liquidity::lsps2::msgs::LSPS2OpeningFeeParams; +use lightning_liquidity::lsps2::utils::compute_opening_fee; +use lightning_types::payment::PaymentHash; +use tokio::sync::oneshot; +use tokio::task::JoinSet; + +use crate::connection::ConnectionManager; +use crate::liquidity::{ + select_all_lsps_for_protocol, select_lsps_for_protocol, LspConfig, LspNode, + LIQUIDITY_REQUEST_TIMEOUT_SECS, LSPS_DISCOVERY_WAIT_TIMEOUT_SECS, +}; +use crate::logger::{log_debug, log_error, log_info, LdkLogger}; +use crate::payment::store::LSPS2Parameters; +use crate::payment::PaymentMetadata; +use crate::types::{ChannelManager, KeysManager, LiquidityManager}; +use crate::{Config, Error}; + +pub(crate) struct LSPS2Client +where + L::Target: LdkLogger, +{ + pub(crate) lsp_nodes: Arc>>, + pub(crate) pending_lsps2_fee_requests: + Mutex>>, + pub(crate) pending_buy_requests: + Mutex>>, + pub(crate) channel_manager: Arc, + pub(crate) keys_manager: Arc, + pub(crate) discovery_done_rx: tokio::sync::watch::Receiver, + pub(crate) liquidity_manager: Arc, + pub(crate) config: Arc, + pub(crate) logger: L, +} + +impl LSPS2Client +where + L::Target: LdkLogger, +{ + pub(crate) async fn lsps2_receive_to_jit_channel( + self: Arc, amount_msat: u64, description: &Bolt11InvoiceDescription, + expiry_secs: u32, max_total_lsp_fee_limit_msat: Option, + payment_hash: Option, connection_manager: Arc>, + ) -> Result<(Bolt11Invoice, LspConfig), Error> { + // Connect to all candidate LSPs before querying fees. + let all_offers = self.gather_lsps2_offers(&connection_manager).await?; + let (cheapest_lsp, min_total_fee_msat, min_opening_params) = all_offers + .into_iter() + .flat_map(|(lsp, resp)| { + resp.opening_fee_params_menu + .into_iter() + .map(move |params| (lsp.clone(), params)) + }) + .filter_map(|(lsp, params)| { + if amount_msat < params.min_payment_size_msat + || amount_msat > params.max_payment_size_msat + { + log_debug!(self.logger, + "Skipping LSP {}'s JIT offer as the payment of {}msat doesn't meet LSP limits (min: {}msat, max: {}msat)", + lsp.node_id, + amount_msat, + params.min_payment_size_msat, + params.max_payment_size_msat + ); + None + } else { + compute_opening_fee(amount_msat, params.min_fee_msat, params.proportional as u64) + .map(|fee| (lsp, fee, params)) + } + }) + .min_by_key(|(_, fee, _)| *fee) + .ok_or_else(|| { + log_error!(self.logger, "Failed to handle response from liquidity service",); + Error::LiquidityRequestFailed + })?; + + if let Some(max_total_lsp_fee_limit_msat) = max_total_lsp_fee_limit_msat { + if min_total_fee_msat > max_total_lsp_fee_limit_msat { + log_error!(self.logger, + "Failed to request inbound JIT channel as LSP's requested total opening fee of {}msat exceeds our fee limit of {}msat", + min_total_fee_msat, max_total_lsp_fee_limit_msat + ); + return Err(Error::LiquidityFeeTooHigh); + } + } + + log_debug!( + self.logger, + "Choosing cheapest liquidity offer from LSP {}, will pay {}msat in total LSP fees", + cheapest_lsp.node_id, + min_total_fee_msat + ); + + let buy_response = self + .lsps2_send_buy_request( + Some(amount_msat), + min_opening_params, + Some(&cheapest_lsp.node_id), + ) + .await?; + let lsps2_parameters = LSPS2Parameters { + max_total_opening_fee_msat: Some(min_total_fee_msat), + max_proportional_opening_fee_ppm_msat: None, + }; + + let invoice = self.lsps2_create_jit_invoice( + buy_response, + Some(amount_msat), + description, + expiry_secs, + payment_hash, + lsps2_parameters, + Some(&cheapest_lsp.node_id), + )?; + + log_info!(self.logger, "JIT-channel invoice created: {}", invoice); + Ok((invoice, cheapest_lsp)) + } + + pub(crate) async fn lsps2_receive_variable_amount_to_jit_channel( + self: Arc, description: &Bolt11InvoiceDescription, expiry_secs: u32, + max_proportional_lsp_fee_limit_ppm_msat: Option, payment_hash: Option, + connection_manager: Arc>, + ) -> Result<(Bolt11Invoice, LspConfig), Error> { + // Connect to all candidate LSPs before querying fees. + let all_offers = self.gather_lsps2_offers(&connection_manager).await?; + let (cheapest_lsp, min_prop_fee_ppm_msat, min_opening_params) = all_offers + .into_iter() + .flat_map(|(lsp, resp)| { + resp.opening_fee_params_menu.into_iter().map(move |params| (lsp.clone(), params)) + }) + .map(|(lsp, params)| { + let ppm = params.proportional as u64; + (lsp, ppm, params) + }) + .min_by_key(|(_, ppm, _)| *ppm) + .ok_or_else(|| { + log_error!(self.logger, "Failed to handle response from liquidity service",); + Error::LiquidityRequestFailed + })?; + + if let Some(max_proportional_lsp_fee_limit_ppm_msat) = + max_proportional_lsp_fee_limit_ppm_msat + { + if min_prop_fee_ppm_msat > max_proportional_lsp_fee_limit_ppm_msat { + log_error!(self.logger, + "Failed to request inbound JIT channel as LSP's requested proportional opening fee of {} ppm msat exceeds our fee limit of {} ppm msat", + min_prop_fee_ppm_msat, + max_proportional_lsp_fee_limit_ppm_msat + ); + return Err(Error::LiquidityFeeTooHigh); + } + } + + log_debug!( + self.logger, + "Choosing cheapest liquidity offer from LSP {}, will pay {}ppm msat in proportional LSP fees", + cheapest_lsp.node_id, + min_prop_fee_ppm_msat + ); + + let buy_response = self + .lsps2_send_buy_request(None, min_opening_params, Some(&cheapest_lsp.node_id)) + .await?; + let lsps2_parameters = LSPS2Parameters { + max_total_opening_fee_msat: None, + max_proportional_opening_fee_ppm_msat: Some(min_prop_fee_ppm_msat), + }; + let invoice = self.lsps2_create_jit_invoice( + buy_response, + None, + description, + expiry_secs, + payment_hash, + lsps2_parameters, + Some(&cheapest_lsp.node_id), + )?; + + log_info!(self.logger, "JIT-channel invoice created: {}", invoice); + Ok((invoice, cheapest_lsp)) + } + + async fn gather_lsps2_offers( + self: &Arc, connection_manager: &Arc>, + ) -> Result, Error> { + let lsps2_nodes = self.get_lsps2_nodes().await?; + + // Connect to all candidate LSPs in parallel. + let mut connect_set = JoinSet::new(); + for lsp_node in &lsps2_nodes { + let cm = Arc::clone(connection_manager); + let node_id = lsp_node.node_id; + let addr = lsp_node.address.clone(); + let logger = self.logger.clone(); + connect_set.spawn(async move { + if let Err(e) = cm.connect_peer_if_necessary(node_id, addr).await { + log_warn!(logger, "Failed to connect to LSP {} for fee query: {}", node_id, e); + } + }); + } + while connect_set.join_next().await.is_some() {} + + let mut all_offers: Vec<(LspConfig, LSPS2FeeResponse)> = + Vec::with_capacity(lsps2_nodes.len()); + let mut fee_set: JoinSet<(LspConfig, Result)> = JoinSet::new(); + for lsp_node in &lsps2_nodes { + let lsp = lsp_node.clone(); + let client = Arc::clone(self); + fee_set.spawn(async move { + let res = client.lsps2_request_opening_fee_params(Some(&lsp.node_id)).await; + (lsp, res) + }); + } + while let Some(join_result) = fee_set.join_next().await { + match join_result { + Ok((lsp, Ok(fees))) => all_offers.push((lsp, fees)), + Ok((lsp, Err(e))) => { + log_warn!(self.logger, "Failed to get fees from LSP {}: {}", lsp.node_id, e) + }, + Err(e) => { + log_warn!(self.logger, "Failed to get fees from LSP: {}", e) + }, + } + } + + Ok(all_offers) + } +} + +impl LSPS2Client +where + L::Target: LdkLogger, +{ + async fn lsps2_request_opening_fee_params( + &self, node_id: Option<&PublicKey>, + ) -> Result { + let lsps2_node = select_lsps_for_protocol(&self.lsp_nodes, 2, node_id) + .ok_or(Error::LiquiditySourceUnavailable)?; + + let client_handler = self.liquidity_manager.lsps2_client_handler().ok_or_else(|| { + log_error!(self.logger, "Liquidity client was not configured.",); + Error::LiquiditySourceUnavailable + })?; + + let (fee_request_sender, fee_request_receiver) = oneshot::channel(); + { + let mut pending_fee_requests_lock = + self.pending_lsps2_fee_requests.lock().expect("lock"); + let request_id = + client_handler.request_opening_params(lsps2_node.node_id, lsps2_node.token.clone()); + pending_fee_requests_lock.insert(request_id, fee_request_sender); + } + + tokio::time::timeout( + Duration::from_secs(LIQUIDITY_REQUEST_TIMEOUT_SECS), + fee_request_receiver, + ) + .await + .map_err(|e| { + log_error!(self.logger, "Liquidity request timed out: {}", e); + Error::LiquidityRequestFailed + })? + .map_err(|e| { + log_error!(self.logger, "Failed to handle response from liquidity service: {}", e); + Error::LiquidityRequestFailed + }) + } + + async fn lsps2_send_buy_request( + &self, amount_msat: Option, opening_fee_params: LSPS2OpeningFeeParams, + node_id: Option<&PublicKey>, + ) -> Result { + let lsps2_node = select_lsps_for_protocol(&self.lsp_nodes, 2, node_id) + .ok_or(Error::LiquiditySourceUnavailable)?; + + let client_handler = self.liquidity_manager.lsps2_client_handler().ok_or_else(|| { + log_error!(self.logger, "Liquidity client was not configured.",); + Error::LiquiditySourceUnavailable + })?; + + let (buy_request_sender, buy_request_receiver) = oneshot::channel(); + { + let mut pending_buy_requests_lock = self.pending_buy_requests.lock().expect("lock"); + let request_id = client_handler + .select_opening_params(lsps2_node.node_id, amount_msat, opening_fee_params) + .map_err(|e| { + log_error!( + self.logger, + "Failed to send buy request to liquidity service: {:?}", + e + ); + Error::LiquidityRequestFailed + })?; + pending_buy_requests_lock.insert(request_id, buy_request_sender); + } + + let buy_response = tokio::time::timeout( + Duration::from_secs(LIQUIDITY_REQUEST_TIMEOUT_SECS), + buy_request_receiver, + ) + .await + .map_err(|e| { + log_error!(self.logger, "Liquidity request timed out: {}", e); + Error::LiquidityRequestFailed + })? + .map_err(|e| { + log_error!(self.logger, "Failed to handle response from liquidity service: {:?}", e); + Error::LiquidityRequestFailed + })?; + + Ok(buy_response) + } + + fn lsps2_create_jit_invoice( + &self, buy_response: LSPS2BuyResponse, amount_msat: Option, + description: &Bolt11InvoiceDescription, expiry_secs: u32, + payment_hash: Option, lsps2_parameters: LSPS2Parameters, + node_id: Option<&PublicKey>, + ) -> Result { + let lsps2_node = select_lsps_for_protocol(&self.lsp_nodes, 2, node_id) + .ok_or(Error::LiquiditySourceUnavailable)?; + + // LSPS2 requires min_final_cltv_expiry_delta to be at least 2 more than usual. + let min_final_cltv_expiry_delta = MIN_FINAL_CLTV_EXPIRY_DELTA + 2; + let encoded_payment_metadata = + PaymentMetadata { lsps2_parameters: Some(lsps2_parameters) }.encode(); + let (payment_hash, payment_secret, payment_metadata) = match payment_hash { + Some(payment_hash) => { + let (payment_secret, payment_metadata) = self + .channel_manager + .create_inbound_payment_for_hash( + payment_hash, + None, + expiry_secs, + Some(min_final_cltv_expiry_delta), + Some(encoded_payment_metadata), + ) + .map_err(|e| { + log_error!(self.logger, "Failed to register inbound payment: {:?}", e); + Error::InvoiceCreationFailed + })?; + (payment_hash, payment_secret, payment_metadata) + }, + None => self + .channel_manager + .create_inbound_payment( + None, + expiry_secs, + Some(min_final_cltv_expiry_delta), + Some(encoded_payment_metadata), + ) + .map_err(|e| { + log_error!(self.logger, "Failed to register inbound payment: {:?}", e); + Error::InvoiceCreationFailed + })?, + }; + + let route_hint = RouteHint(vec![RouteHintHop { + src_node_id: lsps2_node.node_id, + short_channel_id: buy_response.intercept_scid, + fees: RoutingFees { base_msat: 0, proportional_millionths: 0 }, + cltv_expiry_delta: buy_response.cltv_expiry_delta as u16, + htlc_minimum_msat: None, + htlc_maximum_msat: None, + }]); + + let currency = self.config.network.into(); + let mut invoice_builder = InvoiceBuilder::new(currency) + .invoice_description(description.clone()) + .payment_hash(payment_hash) + .payment_secret(payment_secret) + .current_timestamp() + .min_final_cltv_expiry_delta(min_final_cltv_expiry_delta.into()) + .expiry_time(Duration::from_secs(expiry_secs.into())) + .private_route(route_hint); + + if let Some(amount_msat) = amount_msat { + invoice_builder = invoice_builder.amount_milli_satoshis(amount_msat).basic_mpp(); + } + + let invoice = if let Some(payment_metadata) = payment_metadata { + invoice_builder.payment_metadata(payment_metadata).build_signed(|hash| { + Secp256k1::new() + .sign_ecdsa_recoverable(hash, &self.keys_manager.get_node_secret_key()) + }) + } else { + invoice_builder.build_signed(|hash| { + Secp256k1::new() + .sign_ecdsa_recoverable(hash, &self.keys_manager.get_node_secret_key()) + }) + }; + invoice.map_err(|e| { + log_error!(self.logger, "Failed to build and sign invoice: {}", e); + Error::InvoiceCreationFailed + }) + } + + pub(crate) async fn handle_event(&self, event: LSPS2ClientEvent) { + match event { + LSPS2ClientEvent::OpeningParametersReady { + request_id, + counterparty_node_id, + opening_fee_params_menu, + } => { + if self + .lsp_nodes + .read() + .expect("lock") + .iter() + .any(|n| n.node_id == counterparty_node_id) + { + if let Some(sender) = + self.pending_lsps2_fee_requests.lock().expect("lock").remove(&request_id) + { + let response = LSPS2FeeResponse { opening_fee_params_menu }; + + match sender.send(response) { + Ok(()) => (), + Err(_) => { + log_error!( + self.logger, + "Failed to handle response for request {:?} from liquidity service", + request_id + ); + }, + } + } else { + debug_assert!( + false, + "Received response from liquidity service for unknown request." + ); + log_error!( + self.logger, + "Received response from liquidity service for unknown request." + ); + } + } else { + log_error!( + self.logger, + "Received unexpected LSPS2Client::OpeningParametersReady event!" + ); + } + }, + LSPS2ClientEvent::InvoiceParametersReady { + request_id, + counterparty_node_id, + intercept_scid, + cltv_expiry_delta, + .. + } => { + if self + .lsp_nodes + .read() + .expect("lock") + .iter() + .any(|n| n.node_id == counterparty_node_id) + { + if let Some(sender) = + self.pending_buy_requests.lock().expect("lock").remove(&request_id) + { + let response = LSPS2BuyResponse { intercept_scid, cltv_expiry_delta }; + + match sender.send(response) { + Ok(()) => (), + Err(_) => { + log_error!( + self.logger, + "Failed to handle response for request {:?} from liquidity service", + request_id + ); + }, + } + } else { + debug_assert!( + false, + "Received response from liquidity service for unknown request." + ); + log_error!( + self.logger, + "Received response from liquidity service for unknown request." + ); + } + } else { + log_error!( + self.logger, + "Received unexpected LSPS2Client::InvoiceParametersReady event!" + ); + } + }, + _ => { + log_error!(self.logger, "Received unexpected LSPS2Client liquidity event!"); + }, + } + } + + async fn get_lsps2_nodes(&self) -> Result, Error> { + let has_undiscovered_protocol = + self.lsp_nodes.read().expect("lock").iter().any(|n| n.supported_protocols.is_none()); + + if has_undiscovered_protocol { + // LSP protocol discovery is still in flight, we wait briefly for it to finish, then re-check. + let mut rx = self.discovery_done_rx.clone(); + if !*rx.borrow() { + log_debug!( + self.logger, + "Waiting for LSP protocol discovery to complete before selecting LSPS2 nodes." + ); + let _ = tokio::time::timeout( + Duration::from_secs(LSPS_DISCOVERY_WAIT_TIMEOUT_SECS), + rx.wait_for(|done| *done), + ) + .await; + } + } + + let lsps2_nodes = select_all_lsps_for_protocol(&self.lsp_nodes, 2); + if lsps2_nodes.is_empty() { + log_error!(self.logger, "No LSPs available for LSPS2 protocol."); + return Err(Error::LiquiditySourceUnavailable); + }; + Ok(lsps2_nodes) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct LSPS2FeeResponse { + opening_fee_params_menu: Vec, +} + +#[derive(Debug, Clone)] +pub(crate) struct LSPS2BuyResponse { + intercept_scid: u64, + cltv_expiry_delta: u32, +} diff --git a/src/liquidity/client/mod.rs b/src/liquidity/client/mod.rs new file mode 100644 index 0000000000..15ca7e9650 --- /dev/null +++ b/src/liquidity/client/mod.rs @@ -0,0 +1,11 @@ +// 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. + +pub(crate) mod lsps1; +pub(crate) mod lsps2; + +pub use lsps1::LSPS1OrderStatus; diff --git a/src/liquidity/mod.rs b/src/liquidity/mod.rs new file mode 100644 index 0000000000..87a0650c83 --- /dev/null +++ b/src/liquidity/mod.rs @@ -0,0 +1,527 @@ +// 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. + +//! Objects related to liquidity management. + +pub(crate) mod client; +pub(crate) mod service; + +use std::collections::hash_map::Entry; +use std::collections::HashMap; +use std::ops::Deref; +use std::sync::{Arc, Mutex, RwLock}; +use std::time::Duration; + +use bitcoin::secp256k1::PublicKey; +pub use client::lsps1::LSPS1Liquidity; +pub use client::LSPS1OrderStatus; +use lightning::ln::msgs::SocketAddress; +use lightning_liquidity::events::LiquidityEvent; +use lightning_liquidity::lsps0::event::LSPS0ClientEvent; +use lightning_liquidity::lsps1::client::LSPS1ClientConfig as LdkLSPS1ClientConfig; +use lightning_liquidity::lsps2::client::LSPS2ClientConfig as LdkLSPS2ClientConfig; +use lightning_liquidity::lsps2::service::LSPS2ServiceConfig as LdkLSPS2ServiceConfig; +use lightning_liquidity::{LiquidityClientConfig, LiquidityServiceConfig}; +pub use service::lsps2::LSPS2ServiceConfig; +use tokio::sync::oneshot; + +use crate::builder::BuildError; +use crate::connection::ConnectionManager; +use crate::liquidity::client::lsps1::LSPS1Client; +use crate::liquidity::client::lsps2::LSPS2Client; +use crate::liquidity::service::lsps2::{LSPS2Service, LSPS2ServiceLiquiditySource}; +use crate::logger::{log_debug, log_error, log_info, LdkLogger, Logger}; +use crate::runtime::Runtime; +use crate::types::{Broadcaster, ChannelManager, DynStore, KeysManager, LiquidityManager, Wallet}; +use crate::{Config, Error}; + +const LIQUIDITY_REQUEST_TIMEOUT_SECS: u64 = 5; +const LSPS_DISCOVERY_WAIT_TIMEOUT_SECS: u64 = 10; + +fn select_lsps_for_protocol( + lsp_nodes: &Arc>>, protocol: u16, override_node_id: Option<&PublicKey>, +) -> Option { + lsp_nodes + .read() + .expect("lock") + .iter() + .find(|lsp_node| { + if let Some(override_node_id) = override_node_id { + lsp_node.node_id == *override_node_id + && lsp_node.supported_protocols.as_ref().is_some_and(|p| p.contains(&protocol)) + } else { + lsp_node.supported_protocols.as_ref().is_some_and(|p| p.contains(&protocol)) + } + }) + .map(|n| LspConfig { + node_id: n.node_id, + address: n.address.clone(), + token: n.token.clone(), + trust_peer_0conf: n.trust_peer_0conf, + }) +} + +fn select_all_lsps_for_protocol( + lsp_nodes: &Arc>>, protocol: u16, +) -> Vec { + lsp_nodes + .read() + .expect("lock") + .iter() + .filter(|lsp_node| { + lsp_node.supported_protocols.as_ref().is_some_and(|p| p.contains(&protocol)) + }) + .map(|n| LspConfig { + node_id: n.node_id, + address: n.address.clone(), + token: n.token.clone(), + trust_peer_0conf: n.trust_peer_0conf, + }) + .collect() +} + +/// A liquidity handler allowing to manage LSP connections and request channels. +/// +/// Should be retrieved by calling [`Node::liquidity`]. +/// +/// [`Node::liquidity`]: crate::Node::liquidity +#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] +pub struct Liquidity { + runtime: Arc, + wallet: Arc, + connection_manager: Arc>>, + liquidity_source: Arc>>, + logger: Arc, +} + +impl Liquidity { + pub(crate) fn new( + runtime: Arc, wallet: Arc, + connection_manager: Arc>>, + liquidity_source: Arc>>, logger: Arc, + ) -> Self { + Self { runtime, wallet, connection_manager, liquidity_source, logger } + } +} + +#[cfg_attr(feature = "uniffi", uniffi::export)] +impl Liquidity { + /// Adds an LSP as an inbound liquidity source at runtime. + /// + /// The given `token` will be used by the LSP to authenticate the user. + /// `trust_peer_0conf` controls whether the node will accept 0-confirmation channels opened by this + /// LSP. Note this supersedes [`Config::trusted_peers_0conf`] for this peer. + /// Duplicate `node_id`s are ignored. + pub fn add_liquidity_source( + &self, node_id: PublicKey, address: SocketAddress, token: Option, + trust_peer_0conf: bool, + ) -> Result<(), Error> { + { + let mut lsp_nodes = self.liquidity_source.lsp_nodes.write().expect("lock"); + if lsp_nodes.iter().any(|n| n.node_id == node_id) { + log_info!(self.logger, "LSP node {} already added, skipping.", node_id); + return Ok(()); + } + + lsp_nodes.push(LspNode { + node_id, + address: address.clone(), + token: token.clone(), + trust_peer_0conf, + supported_protocols: None, + }); + } + + // If anything below fails, drop the half-initialized entry so the user can retry cleanly. + let lsp_nodes = Arc::clone(&self.liquidity_source.lsp_nodes); + let cleanup = move || { + lsp_nodes.write().expect("lock").retain(|n| n.node_id != node_id); + }; + + let con_cm = Arc::clone(&self.connection_manager); + let connect_addr = address.clone(); + if let Err(e) = self + .runtime + .block_on(async move { con_cm.connect_peer_if_necessary(node_id, connect_addr).await }) + { + cleanup(); + return Err(e); + } + log_info!(self.logger, "Connected to LSP {}@{}.", node_id, address); + + if let Err(e) = self + .runtime + .block_on(async { self.liquidity_source.discover_lsp_protocols(&node_id).await }) + { + cleanup(); + return Err(e); + } + + Ok(()) + } + + /// Returns a liquidity handler allowing to request channels via the [bLIP-51 / LSPS1] protocol. + /// + /// [bLIP-51 / LSPS1]: https://github.com/lightning/blips/blob/master/blip-0051.md + pub fn lsps1(&self) -> LSPS1Liquidity { + LSPS1Liquidity::new( + Arc::clone(&self.runtime), + Arc::clone(&self.wallet), + Arc::clone(&self.connection_manager), + self.liquidity_source.lsps1_client(), + Arc::clone(&self.logger), + ) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct LspConfig { + pub node_id: PublicKey, + pub address: SocketAddress, + pub token: Option, + pub trust_peer_0conf: bool, +} + +pub(crate) struct LspNode { + node_id: PublicKey, + address: SocketAddress, + token: Option, + trust_peer_0conf: bool, + // Protocol numbers discovered via LSPS0 (e.g., 1 = LSPS1, 2 = LSPS2, 5 = LSPS5). + supported_protocols: Option>, +} + +pub(crate) struct LiquiditySourceBuilder +where + L::Target: LdkLogger, +{ + lsp_nodes: Vec, + lsps2_service: Option, + wallet: Arc, + channel_manager: Arc, + keys_manager: Arc, + tx_broadcaster: Arc, + kv_store: Arc, + config: Arc, + logger: L, +} + +impl LiquiditySourceBuilder +where + L::Target: LdkLogger, +{ + pub(crate) fn new( + wallet: Arc, channel_manager: Arc, keys_manager: Arc, + tx_broadcaster: Arc, kv_store: Arc, config: Arc, logger: L, + ) -> Self { + let lsp_nodes = Vec::new(); + let lsps2_service = None; + Self { + lsp_nodes, + lsps2_service, + wallet, + channel_manager, + keys_manager, + tx_broadcaster, + kv_store, + config, + logger, + } + } + + pub(crate) fn set_lsp_nodes(&mut self, lsp_nodes: Vec) -> &mut Self { + self.lsp_nodes = lsp_nodes; + self + } + + pub(crate) fn lsps2_service( + &mut self, promise_secret: [u8; 32], service_config: LSPS2ServiceConfig, + ) -> &mut Self { + let ldk_service_config = LdkLSPS2ServiceConfig { promise_secret }; + self.lsps2_service = Some(LSPS2Service { service_config, ldk_service_config }); + self + } + + pub(crate) async fn build(self) -> Result, BuildError> { + let liquidity_service_config = self.lsps2_service.as_ref().map(|s| { + let lsps2_service_config = Some(s.ldk_service_config.clone()); + let lsps5_service_config = None; + let advertise_service = s.service_config.advertise_service; + LiquidityServiceConfig { + lsps1_service_config: None, + lsps2_service_config, + lsps5_service_config, + advertise_service, + } + }); + + let (discovery_done_tx, discovery_done_rx) = tokio::sync::watch::channel(false); + + // Adding LSPS at runtime is now supported, so we create the client + // config regardless of whether LSPs exist at build time + let liquidity_client_config = Some(LiquidityClientConfig { + lsps1_client_config: Some(LdkLSPS1ClientConfig { max_channel_fees_msat: None }), + lsps2_client_config: Some(LdkLSPS2ClientConfig {}), + lsps5_client_config: None, + }); + + let liquidity_manager = Arc::new( + LiquidityManager::new( + Arc::clone(&self.keys_manager), + Arc::clone(&self.keys_manager), + Arc::clone(&self.channel_manager), + Arc::clone(&self.kv_store), + Arc::clone(&self.tx_broadcaster), + liquidity_service_config, + liquidity_client_config, + ) + .await + .map_err(|_| BuildError::ReadFailed)?, + ); + + let lsp_nodes = Arc::new(RwLock::new( + self.lsp_nodes + .into_iter() + .map(|cfg| LspNode { + node_id: cfg.node_id, + address: cfg.address, + token: cfg.token, + trust_peer_0conf: cfg.trust_peer_0conf, + supported_protocols: None, + }) + .collect(), + )); + + Ok(LiquiditySource { + lsp_nodes: Arc::clone(&lsp_nodes), + lsps1_client: Arc::new(LSPS1Client { + lsp_nodes: Arc::clone(&lsp_nodes), + pending_opening_params_requests: Mutex::new(HashMap::new()), + pending_create_order_requests: Mutex::new(HashMap::new()), + pending_check_order_status_requests: Mutex::new(HashMap::new()), + discovery_done_rx: discovery_done_rx.clone(), + liquidity_manager: Arc::clone(&liquidity_manager), + logger: self.logger.clone(), + }), + lsps2_client: Arc::new(LSPS2Client { + lsp_nodes: Arc::clone(&lsp_nodes), + pending_lsps2_fee_requests: Mutex::new(HashMap::new()), + pending_buy_requests: Mutex::new(HashMap::new()), + channel_manager: self.channel_manager.clone(), + keys_manager: self.keys_manager.clone(), + discovery_done_rx: discovery_done_rx.clone(), + liquidity_manager: Arc::clone(&liquidity_manager), + config: self.config.clone(), + logger: self.logger.clone(), + }), + lsps2_service: Arc::new(LSPS2ServiceLiquiditySource { + lsps2_service: self.lsps2_service, + wallet: self.wallet, + channel_manager: self.channel_manager, + peer_manager: RwLock::new(None), + keys_manager: self.keys_manager, + liquidity_manager: Arc::clone(&liquidity_manager), + config: self.config.clone(), + logger: self.logger.clone(), + }), + pending_lsps0_discovery: Mutex::new(HashMap::new()), + discovery_done_tx, + discovery_done_rx, + liquidity_manager, + logger: self.logger, + }) + } +} + +pub(crate) struct LiquiditySource +where + L::Target: LdkLogger, +{ + lsp_nodes: Arc>>, + lsps1_client: Arc>, + lsps2_client: Arc>, + lsps2_service: Arc>, + pending_lsps0_discovery: Mutex>>>, + discovery_done_tx: tokio::sync::watch::Sender, + discovery_done_rx: tokio::sync::watch::Receiver, + liquidity_manager: Arc, + logger: L, +} + +impl LiquiditySource +where + L::Target: LdkLogger, +{ + pub(crate) fn liquidity_manager(&self) -> Arc { + Arc::clone(&self.liquidity_manager) + } + + pub(crate) fn lsps1_client(&self) -> Arc> { + Arc::clone(&self.lsps1_client) + } + + pub(crate) fn lsps2_client(&self) -> Arc> { + Arc::clone(&self.lsps2_client) + } + + pub(crate) fn lsps2_service(&self) -> Arc> { + Arc::clone(&self.lsps2_service) + } + + pub(crate) async fn handle_next_event(&self) { + match self.liquidity_manager.next_event_async().await { + LiquidityEvent::LSPS1Client(event) => self.lsps1_client.handle_event(event).await, + LiquidityEvent::LSPS2Client(event) => self.lsps2_client.handle_event(event).await, + LiquidityEvent::LSPS2Service(event) => self.lsps2_service.handle_event(event).await, + + LiquidityEvent::LSPS0Client(LSPS0ClientEvent::ListProtocolsResponse { + counterparty_node_id, + protocols, + }) => { + if self.is_lsps_node(&counterparty_node_id) { + if let Some(sender) = self + .pending_lsps0_discovery + .lock() + .expect("lock") + .remove(&counterparty_node_id) + { + match sender.send(protocols) { + Ok(()) => (), + Err(_) => { + log_error!( + self.logger, + "Failed to handle response for request {:?} from liquidity service", + counterparty_node_id + ); + }, + } + } else { + log_error!( + self.logger, + "Received response from liquidity service for unknown request." + ); + } + } else { + log_error!( + self.logger, + "Received LSPS0 ListProtocolsResponse from unexpected counterparty {}.", + counterparty_node_id + ); + } + }, + e => { + log_error!(self.logger, "Received unexpected liquidity event: {:?}", e); + }, + } + } + + pub(crate) fn is_lsps_node(&self, node_id: &PublicKey) -> bool { + self.lsp_nodes.read().expect("lock").iter().any(|n| n.node_id == *node_id) + } + + pub(crate) fn get_all_lsp_details(&self) -> Vec<(PublicKey, SocketAddress)> { + self.lsp_nodes + .read() + .expect("lock") + .iter() + .map(|n| (n.node_id, n.address.clone())) + .collect() + } + + pub(crate) async fn discover_lsp_protocols( + &self, node_id: &PublicKey, + ) -> Result, Error> { + let lsps0_handler = self.liquidity_manager.lsps0_client_handler(); + + let (sender, receiver) = oneshot::channel(); + { + let mut pending_discovery = self.pending_lsps0_discovery.lock().expect("lock"); + match pending_discovery.entry(*node_id) { + Entry::Occupied(_) => { + log_error!( + self.logger, + "LSPS0 protocol discovery already in flight for {}", + node_id + ); + return Err(Error::LiquidityRequestFailed); + }, + Entry::Vacant(v) => { + v.insert(sender); + lsps0_handler.list_protocols(node_id); + }, + } + } + + let protocols = + tokio::time::timeout(Duration::from_secs(LIQUIDITY_REQUEST_TIMEOUT_SECS), receiver) + .await + .map_err(|e| { + log_error!( + self.logger, + "LSPS0 discovery request timed out for {}: {}", + node_id, + e + ); + self.pending_lsps0_discovery.lock().expect("lock").remove(node_id); + Error::LiquidityRequestFailed + })? + .map_err(|e| { + log_error!( + self.logger, + "Failed to handle LSPS0 discovery response from {}: {}", + node_id, + e + ); + self.pending_lsps0_discovery.lock().expect("lock").remove(node_id); + Error::LiquidityRequestFailed + })?; + + if let Some(lsp_node) = + self.lsp_nodes.write().expect("lock").iter_mut().find(|n| &n.node_id == node_id) + { + lsp_node.supported_protocols = Some(protocols.clone()); + } + + Ok(protocols) + } + + pub(crate) async fn get_lsp_config( + &self, node_id: &PublicKey, protocol: u16, + ) -> Option { + if let Some(node) = select_lsps_for_protocol(&self.lsp_nodes, protocol, Some(node_id)) { + return Some(node); + } + + let has_undiscovered_protocol = + self.lsp_nodes.read().expect("lock").iter().any(|n| n.supported_protocols.is_none()); + + // LSP protocol discovery may still be in flight, we wait briefly for it to finish, then re-check. + if has_undiscovered_protocol && !*self.discovery_done_rx.borrow() { + log_debug!( + self.logger, + "LSP {} protocols not yet discovered, waiting for protocol discovery to complete.", + node_id + ); + let mut rx = self.discovery_done_rx.clone(); + let _ = tokio::time::timeout( + Duration::from_secs(LSPS_DISCOVERY_WAIT_TIMEOUT_SECS), + rx.wait_for(|done| *done), + ) + .await; + } + + select_lsps_for_protocol(&self.lsp_nodes, protocol, Some(node_id)) + } + + /// Flips the `discovery_done` watch to `true`. + /// + /// Called once after the *initial* batch of LSPs configured at build time has been + /// discovered by the background task spawned in `Node::start`. + pub(crate) fn mark_discovery_done(&self) { + let _ = self.discovery_done_tx.send(true); + } +} diff --git a/src/liquidity/service/lsps2.rs b/src/liquidity/service/lsps2.rs new file mode 100644 index 0000000000..875438b0fb --- /dev/null +++ b/src/liquidity/service/lsps2.rs @@ -0,0 +1,537 @@ +// 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. + +use std::ops::Deref; +use std::sync::{Arc, RwLock, Weak}; +use std::time::Duration; + +use bitcoin::secp256k1::PublicKey; +use bitcoin::Transaction; +use chrono::Utc; +use lightning::events::HTLCHandlingFailureType; +use lightning::ln::channelmanager::InterceptId; +use lightning::ln::types::ChannelId; +use lightning::sign::EntropySource; +use lightning_liquidity::lsps0::ser::LSPSDateTime; +use lightning_liquidity::lsps2::event::LSPS2ServiceEvent; +use lightning_liquidity::lsps2::msgs::LSPS2RawOpeningFeeParams; +use lightning_liquidity::lsps2::service::LSPS2ServiceConfig as LdkLSPS2ServiceConfig; +use lightning_types::payment::PaymentHash; + +use crate::logger::{log_error, LdkLogger}; +use crate::types::{ChannelManager, KeysManager, LiquidityManager, PeerManager, Wallet}; +use crate::{total_anchor_channels_reserve_sats, Config}; + +const LSPS2_GETINFO_REQUEST_EXPIRY: Duration = Duration::from_secs(60 * 60 * 24); +const LSPS2_CHANNEL_CLTV_EXPIRY_DELTA: u32 = 72; + +pub(crate) struct LSPS2Service { + pub(crate) service_config: LSPS2ServiceConfig, + pub(crate) ldk_service_config: LdkLSPS2ServiceConfig, +} + +pub(crate) struct LSPS2ServiceLiquiditySource +where + L::Target: LdkLogger, +{ + pub(crate) lsps2_service: Option, + pub(crate) wallet: Arc, + pub(crate) channel_manager: Arc, + pub(crate) peer_manager: RwLock>>, + pub(crate) keys_manager: Arc, + pub(crate) liquidity_manager: Arc, + pub(crate) config: Arc, + pub(crate) logger: L, +} + +/// Represents the configuration of the LSPS2 service. +/// +/// See [bLIP-52 / LSPS2] for more information. +/// +/// [bLIP-52 / LSPS2]: https://github.com/lightning/blips/blob/master/blip-0052.md +#[derive(Debug, Clone)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct LSPS2ServiceConfig { + /// A token we may require to be sent by the clients. + /// + /// If set, only requests matching this token will be accepted. + pub require_token: Option, + /// Indicates whether the LSPS service will be announced via the gossip network. + pub advertise_service: bool, + /// The fee we withhold for the channel open from the initial payment. + /// + /// This fee is proportional to the client-requested amount, in parts-per-million. + pub channel_opening_fee_ppm: u32, + /// The proportional overprovisioning for the channel. + /// + /// This determines, in parts-per-million, how much value we'll provision on top of the amount + /// we need to forward the payment to the client. + /// + /// For example, setting this to `100_000` will result in a channel being opened that is 10% + /// larger than then the to-be-forwarded amount (i.e., client-requested amount minus the + /// channel opening fee fee). + pub channel_over_provisioning_ppm: u32, + /// The minimum fee required for opening a channel. + pub min_channel_opening_fee_msat: u64, + /// The minimum number of blocks after confirmation we promise to keep the channel open. + pub min_channel_lifetime: u32, + /// The maximum number of blocks that the client is allowed to set its `to_self_delay` parameter. + pub max_client_to_self_delay: u32, + /// The minimum payment size that we will accept when opening a channel. + pub min_payment_size_msat: u64, + /// The maximum payment size that we will accept when opening a channel. + pub max_payment_size_msat: u64, + /// Use the 'client-trusts-LSP' trust model. + /// + /// When set, the service will delay *broadcasting* the JIT channel's funding transaction until + /// the client claimed sufficient HTLC parts to pay for the channel open. + /// + /// Note this will render the flow incompatible with clients utilizing the 'LSP-trust-client' + /// trust model, i.e., in turn delay *claiming* any HTLCs until they see the funding + /// transaction in the mempool. + /// + /// Please refer to [`bLIP-52`] for more information. + /// + /// [`bLIP-52`]: https://github.com/lightning/blips/blob/master/blip-0052.md#trust-models + pub client_trusts_lsp: bool, + /// When set, we will allow clients to spend their entire channel balance in the channels + /// we open to them. This allows clients to try to steal your channel balance with + /// no financial penalty, so this should only be set if you trust your clients. + /// + /// See [`Node::open_0reserve_channel`] to manually open these channels. + /// + /// [`Node::open_0reserve_channel`]: crate::Node::open_0reserve_channel + pub disable_client_reserve: bool, +} + +impl LSPS2ServiceLiquiditySource +where + L::Target: LdkLogger, +{ + pub(crate) fn set_peer_manager(&self, peer_manager: Weak) { + *self.peer_manager.write().expect("lock") = Some(peer_manager); + } + + pub(crate) fn liquidity_manager(&self) -> Arc { + Arc::clone(&self.liquidity_manager) + } + + pub(crate) fn lsps2_channel_needs_manual_broadcast( + &self, counterparty_node_id: PublicKey, user_channel_id: u128, + ) -> bool { + self.lsps2_service.as_ref().map_or(false, |lsps2_service| { + lsps2_service.service_config.client_trusts_lsp + && self + .liquidity_manager() + .lsps2_service_handler() + .and_then(|handler| { + handler + .channel_needs_manual_broadcast(user_channel_id, &counterparty_node_id) + .ok() + }) + .unwrap_or(false) + }) + } + + pub(crate) fn lsps2_store_funding_transaction( + &self, user_channel_id: u128, counterparty_node_id: PublicKey, funding_tx: Transaction, + ) { + let Some(lsps2_service) = self.lsps2_service.as_ref() else { return }; + if !lsps2_service.service_config.client_trusts_lsp { + // Only necessary for client-trusts-LSP flow + return; + } + + let lsps2_service_handler = self.liquidity_manager.lsps2_service_handler(); + if let Some(handler) = lsps2_service_handler { + handler + .store_funding_transaction(user_channel_id, &counterparty_node_id, funding_tx) + .unwrap_or_else(|e| { + debug_assert!(false, "Failed to store funding transaction: {:?}", e); + log_error!(self.logger, "Failed to store funding transaction: {:?}", e); + }); + } else { + log_error!(self.logger, "LSPS2 service handler is not available."); + } + } + + pub(crate) fn lsps2_funding_tx_broadcast_safe( + &self, user_channel_id: u128, counterparty_node_id: PublicKey, + ) { + let Some(lsps2_service) = self.lsps2_service.as_ref() else { return }; + if !lsps2_service.service_config.client_trusts_lsp { + // Only necessary for client-trusts-LSP flow + return; + } + + let lsps2_service_handler = self.liquidity_manager.lsps2_service_handler(); + if let Some(handler) = lsps2_service_handler { + handler + .set_funding_tx_broadcast_safe(user_channel_id, &counterparty_node_id) + .unwrap_or_else(|e| { + debug_assert!( + false, + "Failed to mark funding transaction safe to broadcast: {:?}", + e + ); + log_error!( + self.logger, + "Failed to mark funding transaction safe to broadcast: {:?}", + e + ); + }); + } else { + log_error!(self.logger, "LSPS2 service handler is not available."); + } + } + + pub(crate) async fn handle_channel_ready( + &self, user_channel_id: u128, channel_id: &ChannelId, counterparty_node_id: &PublicKey, + ) { + if let Some(lsps2_service_handler) = self.liquidity_manager.lsps2_service_handler() { + if let Err(e) = lsps2_service_handler + .channel_ready(user_channel_id, channel_id, counterparty_node_id) + .await + { + log_error!( + self.logger, + "LSPS2 service failed to handle ChannelReady event: {:?}", + e + ); + } + } + } + + pub(crate) async fn handle_htlc_intercepted( + &self, intercept_scid: u64, intercept_id: InterceptId, expected_outbound_amount_msat: u64, + payment_hash: PaymentHash, + ) { + if let Some(lsps2_service_handler) = self.liquidity_manager.lsps2_service_handler() { + if let Err(e) = lsps2_service_handler + .htlc_intercepted( + intercept_scid, + intercept_id, + expected_outbound_amount_msat, + payment_hash, + ) + .await + { + log_error!( + self.logger, + "LSPS2 service failed to handle HTLCIntercepted event: {:?}", + e + ); + } + } + } + + pub(crate) async fn handle_htlc_handling_failed(&self, failure_type: HTLCHandlingFailureType) { + if let Some(lsps2_service_handler) = self.liquidity_manager.lsps2_service_handler() { + if let Err(e) = lsps2_service_handler.htlc_handling_failed(failure_type).await { + log_error!( + self.logger, + "LSPS2 service failed to handle HTLCHandlingFailed event: {:?}", + e + ); + } + } + } + + pub(crate) async fn handle_payment_forwarded( + &self, next_channel_id: Option, skimmed_fee_msat: u64, + ) { + if let Some(next_channel_id) = next_channel_id { + if let Some(lsps2_service_handler) = self.liquidity_manager.lsps2_service_handler() { + if let Err(e) = + lsps2_service_handler.payment_forwarded(next_channel_id, skimmed_fee_msat).await + { + log_error!( + self.logger, + "LSPS2 service failed to handle PaymentForwarded: {:?}", + e + ); + } + } + } + } + + pub(crate) async fn handle_event(&self, event: LSPS2ServiceEvent) { + match event { + LSPS2ServiceEvent::GetInfo { request_id, counterparty_node_id, token } => { + if let Some(lsps2_service_handler) = + self.liquidity_manager.lsps2_service_handler().as_ref() + { + let service_config = if let Some(service_config) = + self.lsps2_service.as_ref().map(|s| s.service_config.clone()) + { + service_config + } else { + log_error!(self.logger, "Failed to handle LSPS2ServiceEvent as LSPS2 liquidity service was not configured.",); + return; + }; + + if let Some(required) = service_config.require_token { + if token != Some(required) { + log_error!( + self.logger, + "Rejecting LSPS2 request {:?} from counterparty {} as the client provided an invalid token.", + request_id, + counterparty_node_id + ); + lsps2_service_handler.invalid_token_provided(&counterparty_node_id, request_id.clone()).unwrap_or_else(|e| { + debug_assert!(false, "Failed to reject LSPS2 request. This should never happen."); + log_error!( + self.logger, + "Failed to reject LSPS2 request {:?} from counterparty {} due to: {:?}. This should never happen.", + request_id, + counterparty_node_id, + e + ); + }); + return; + } + } + + let valid_until = LSPSDateTime(Utc::now() + LSPS2_GETINFO_REQUEST_EXPIRY); + let opening_fee_params = LSPS2RawOpeningFeeParams { + min_fee_msat: service_config.min_channel_opening_fee_msat, + proportional: service_config.channel_opening_fee_ppm, + valid_until, + min_lifetime: service_config.min_channel_lifetime, + max_client_to_self_delay: service_config.max_client_to_self_delay, + min_payment_size_msat: service_config.min_payment_size_msat, + max_payment_size_msat: service_config.max_payment_size_msat, + }; + + let opening_fee_params_menu = vec![opening_fee_params]; + + if let Err(e) = lsps2_service_handler.opening_fee_params_generated( + &counterparty_node_id, + request_id, + opening_fee_params_menu, + ) { + log_error!( + self.logger, + "Failed to handle generated opening fee params: {:?}", + e + ); + } + } else { + log_error!(self.logger, "Failed to handle LSPS2ServiceEvent as LSPS2 liquidity service was not configured.",); + return; + } + }, + LSPS2ServiceEvent::BuyRequest { + request_id, + counterparty_node_id, + opening_fee_params: _, + payment_size_msat, + } => { + if let Some(lsps2_service_handler) = + self.liquidity_manager.lsps2_service_handler().as_ref() + { + let service_config = if let Some(service_config) = + self.lsps2_service.as_ref().map(|s| s.service_config.clone()) + { + service_config + } else { + log_error!(self.logger, "Failed to handle LSPS2ServiceEvent as LSPS2 liquidity service was not configured.",); + return; + }; + + let user_channel_id: u128 = u128::from_ne_bytes( + self.keys_manager.get_secure_random_bytes()[..16] + .try_into() + .expect("a 16-byte slice should convert into a [u8; 16]"), + ); + let intercept_scid = self.channel_manager.get_intercept_scid(); + + if let Some(payment_size_msat) = payment_size_msat { + // We already check this in `lightning-liquidity`, but better safe than + // sorry. + // + // TODO: We might want to eventually send back an error here, but we + // currently can't and have to trust `lightning-liquidity` is doing the + // right thing. + // + // TODO: Eventually we also might want to make sure that we have sufficient + // liquidity for the channel opening here. + if payment_size_msat > service_config.max_payment_size_msat + || payment_size_msat < service_config.min_payment_size_msat + { + log_error!( + self.logger, + "Rejecting to handle LSPS2 buy request {:?} from counterparty {} as the client requested an invalid payment size.", + request_id, + counterparty_node_id + ); + return; + } + } + + match lsps2_service_handler + .invoice_parameters_generated( + &counterparty_node_id, + request_id, + intercept_scid, + LSPS2_CHANNEL_CLTV_EXPIRY_DELTA, + service_config.client_trusts_lsp, + user_channel_id, + ) + .await + { + Ok(()) => {}, + Err(e) => { + log_error!( + self.logger, + "Failed to provide invoice parameters: {:?}", + e + ); + return; + }, + } + } else { + log_error!(self.logger, "Failed to handle LSPS2ServiceEvent as LSPS2 liquidity service was not configured.",); + return; + } + }, + LSPS2ServiceEvent::OpenChannel { + their_network_key, + amt_to_forward_msat, + opening_fee_msat: _, + user_channel_id, + intercept_scid: _, + } => { + if self.liquidity_manager.lsps2_service_handler().is_none() { + log_error!(self.logger, "Failed to handle LSPS2ServiceEvent as LSPS2 liquidity service was not configured.",); + return; + }; + + let service_config = if let Some(service_config) = + self.lsps2_service.as_ref().map(|s| s.service_config.clone()) + { + service_config + } else { + log_error!(self.logger, "Failed to handle LSPS2ServiceEvent as LSPS2 liquidity service was not configured.",); + return; + }; + + let init_features = if let Some(Some(peer_manager)) = + self.peer_manager.read().expect("lock").as_ref().map(|weak| weak.upgrade()) + { + // Fail if we're not connected to the prospective channel partner. + if let Some(peer) = peer_manager.peer_by_node_id(&their_network_key) { + peer.init_features + } else { + // TODO: We just silently fail here. Eventually we will need to remember + // the pending requests and regularly retry opening the channel until we + // succeed. + log_error!( + self.logger, + "Failed to open LSPS2 channel to {} due to peer not being not connected.", + their_network_key, + ); + return; + } + } else { + debug_assert!(false, "Failed to handle LSPS2ServiceEvent as peer manager isn't available. This should never happen.",); + log_error!(self.logger, "Failed to handle LSPS2ServiceEvent as peer manager isn't available. This should never happen.",); + return; + }; + + // Fail if we have insufficient onchain funds available. + let over_provisioning_msat = (amt_to_forward_msat + * service_config.channel_over_provisioning_ppm as u64) + / 1_000_000; + 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 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() + && !c.trusted_peers_no_reserve.contains(&their_network_key) + { + c.per_channel_reserve_sats + } else { + 0 + } + }); + if spendable_amount_sats < required_funds_sats { + log_error!(self.logger, + "Unable to create channel due to insufficient funds. Available: {}sats, Required: {}sats", + spendable_amount_sats, channel_amount_sats + ); + // TODO: We just silently fail here. Eventually we will need to remember + // the pending requests and regularly retry opening the channel until we + // succeed. + return; + } + + let mut config = self.channel_manager.get_current_config().clone(); + + // If we act as an LSPS2 service, the HTLC-value-in-flight must be 100% of the + // channel value to ensure we can forward the initial payment. That cap only + // applies to unannounced channels, so the channel must also be unannounced. + debug_assert_eq!( + config + .channel_handshake_config + .unannounced_channel_max_inbound_htlc_value_in_flight_percentage, + 100 + ); + debug_assert!(!config.channel_handshake_config.announce_for_forwarding); + debug_assert!(config.accept_forwards_to_priv_channels); + + // We set the forwarding fee to 0 for now as we're getting paid by the channel fee. + // + // TODO: revisit this decision eventually. + config.channel_config.forwarding_fee_base_msat = 0; + config.channel_config.forwarding_fee_proportional_millionths = 0; + + let result = if service_config.disable_client_reserve { + self.channel_manager.create_channel_to_trusted_peer_0reserve( + their_network_key, + channel_amount_sats, + 0, + user_channel_id, + None, + Some(config), + ) + } else { + self.channel_manager.create_channel( + their_network_key, + channel_amount_sats, + 0, + user_channel_id, + None, + Some(config), + ) + }; + + match result { + Ok(_) => {}, + Err(e) => { + // TODO: We just silently fail here. Eventually we will need to remember + // the pending requests and regularly retry opening the channel until we + // succeed. + let zero_reserve_string = + if service_config.disable_client_reserve { "0reserve " } else { "" }; + log_error!( + self.logger, + "Failed to open LSPS2 {}channel to {}: {:?}", + zero_reserve_string, + their_network_key, + e + ); + return; + }, + } + }, + } + } +} diff --git a/src/liquidity/service/mod.rs b/src/liquidity/service/mod.rs new file mode 100644 index 0000000000..5e3a3b1833 --- /dev/null +++ b/src/liquidity/service/mod.rs @@ -0,0 +1,8 @@ +// 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. + +pub(crate) mod lsps2; diff --git a/src/message_handler.rs b/src/message_handler.rs index fc206ec4da..9c4010458b 100644 --- a/src/message_handler.rs +++ b/src/message_handler.rs @@ -18,24 +18,19 @@ use lightning_types::features::{InitFeatures, NodeFeatures}; use crate::liquidity::LiquiditySource; -pub(crate) enum NodeCustomMessageHandler +pub(crate) struct NodeCustomMessageHandler where L::Target: Logger, { - Ignoring, - Liquidity { liquidity_source: Arc> }, + liquidity_source: Arc>, } impl NodeCustomMessageHandler where L::Target: Logger, { - pub(crate) fn new_liquidity(liquidity_source: Arc>) -> Self { - Self::Liquidity { liquidity_source } - } - - pub(crate) fn new_ignoring() -> Self { - Self::Ignoring + pub(crate) fn new(liquidity_source: Arc>) -> Self { + Self { liquidity_source } } } @@ -48,12 +43,7 @@ where fn read( &self, message_type: u16, buffer: &mut RD, ) -> Result, lightning::ln::msgs::DecodeError> { - match self { - Self::Ignoring => Ok(None), - Self::Liquidity { liquidity_source, .. } => { - liquidity_source.liquidity_manager().read(message_type, buffer) - }, - } + self.liquidity_source.liquidity_manager().read(message_type, buffer) } } @@ -64,58 +54,28 @@ where fn handle_custom_message( &self, msg: Self::CustomMessage, sender_node_id: PublicKey, ) -> Result<(), lightning::ln::msgs::LightningError> { - match self { - Self::Ignoring => Ok(()), // Should be unreachable!() as the reader will return `None` - Self::Liquidity { liquidity_source, .. } => { - liquidity_source.liquidity_manager().handle_custom_message(msg, sender_node_id) - }, - } + self.liquidity_source.liquidity_manager().handle_custom_message(msg, sender_node_id) } fn get_and_clear_pending_msg(&self) -> Vec<(PublicKey, Self::CustomMessage)> { - match self { - Self::Ignoring => Vec::new(), - Self::Liquidity { liquidity_source, .. } => { - liquidity_source.liquidity_manager().get_and_clear_pending_msg() - }, - } + self.liquidity_source.liquidity_manager().get_and_clear_pending_msg() } fn provided_node_features(&self) -> NodeFeatures { - match self { - Self::Ignoring => NodeFeatures::empty(), - Self::Liquidity { liquidity_source, .. } => { - liquidity_source.liquidity_manager().provided_node_features() - }, - } + self.liquidity_source.liquidity_manager().provided_node_features() } fn provided_init_features(&self, their_node_id: PublicKey) -> InitFeatures { - match self { - Self::Ignoring => InitFeatures::empty(), - Self::Liquidity { liquidity_source, .. } => { - liquidity_source.liquidity_manager().provided_init_features(their_node_id) - }, - } + self.liquidity_source.liquidity_manager().provided_init_features(their_node_id) } fn peer_connected( &self, their_node_id: PublicKey, msg: &lightning::ln::msgs::Init, inbound: bool, ) -> Result<(), ()> { - match self { - Self::Ignoring => Ok(()), - Self::Liquidity { liquidity_source, .. } => { - liquidity_source.liquidity_manager().peer_connected(their_node_id, msg, inbound) - }, - } + self.liquidity_source.liquidity_manager().peer_connected(their_node_id, msg, inbound) } fn peer_disconnected(&self, their_node_id: PublicKey) { - match self { - Self::Ignoring => {}, - Self::Liquidity { liquidity_source, .. } => { - liquidity_source.liquidity_manager().peer_disconnected(their_node_id) - }, - } + self.liquidity_source.liquidity_manager().peer_disconnected(their_node_id) } } diff --git a/src/payment/bolt11.rs b/src/payment/bolt11.rs index 068269997f..4503dfa061 100644 --- a/src/payment/bolt11.rs +++ b/src/payment/bolt11.rs @@ -70,7 +70,7 @@ pub struct Bolt11Payment { runtime: Arc, channel_manager: Arc, connection_manager: Arc>>, - liquidity_source: Option>>>, + liquidity_source: Arc>>, payment_store: Arc, peer_store: Arc>>, config: Arc, @@ -82,9 +82,9 @@ impl Bolt11Payment { pub(crate) fn new( runtime: Arc, channel_manager: Arc, connection_manager: Arc>>, - liquidity_source: Option>>>, - payment_store: Arc, peer_store: Arc>>, - config: Arc, is_running: Arc>, logger: Arc, + liquidity_source: Arc>>, payment_store: Arc, + peer_store: Arc>>, config: Arc, + is_running: Arc>, logger: Arc, ) -> Self { Self { runtime, @@ -168,45 +168,29 @@ impl Bolt11Payment { expiry_secs: u32, max_total_lsp_fee_limit_msat: Option, max_proportional_lsp_fee_limit_ppm_msat: Option, payment_hash: Option, ) -> Result { - let liquidity_source = - self.liquidity_source.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; - - let (node_id, address) = - liquidity_source.get_lsps2_lsp_details().ok_or(Error::LiquiditySourceUnavailable)?; - - let peer_info = PeerInfo { node_id, address }; - - let con_node_id = peer_info.node_id; - let con_addr = peer_info.address.clone(); - let con_cm = Arc::clone(&self.connection_manager); - - // We need to use our main runtime here as a local runtime might not be around to poll - // connection futures going forward. - self.runtime.block_on(async move { - con_cm.connect_peer_if_necessary(con_node_id, con_addr).await - })?; - - log_info!(self.logger, "Connected to LSP {}@{}. ", peer_info.node_id, peer_info.address); - - let liquidity_source = Arc::clone(&liquidity_source); - let invoice = self.runtime.block_on(async move { + let connection_manager = Arc::clone(&self.connection_manager); + let (invoice, chosen_lsp) = self.runtime.block_on(async move { if let Some(amount_msat) = amount_msat { - liquidity_source + self.liquidity_source + .lsps2_client() .lsps2_receive_to_jit_channel( amount_msat, description, expiry_secs, max_total_lsp_fee_limit_msat, payment_hash, + connection_manager, ) .await } else { - liquidity_source + self.liquidity_source + .lsps2_client() .lsps2_receive_variable_amount_to_jit_channel( description, expiry_secs, max_proportional_lsp_fee_limit_ppm_msat, payment_hash, + connection_manager, ) .await } @@ -241,7 +225,8 @@ impl Bolt11Payment { ); self.runtime.block_on(self.payment_store.insert(payment))?; - // Persist LSP peer to make sure we reconnect on restart. + // Persist the chosen LSP peer to make sure we reconnect on restart. + let peer_info = PeerInfo { node_id: chosen_lsp.node_id, address: chosen_lsp.address }; self.runtime.block_on(self.peer_store.add_peer(peer_info))?; Ok(invoice) @@ -279,20 +264,16 @@ mod tests { } } -#[cfg_attr(feature = "uniffi", uniffi::export)] impl Bolt11Payment { - /// Send a payment given an invoice. - /// - /// If `route_parameters` are provided they will override the default as well as the - /// node-wide parameters configured via [`Config::route_parameters`] on a per-field basis. - pub fn send( - &self, invoice: &Bolt11Invoice, route_parameters: Option, + fn send_internal( + &self, invoice: &LdkBolt11Invoice, amount_msat: Option, + route_parameters: Option, + declared_total_mpp_value_msat_override: Option, invalid_amount_log: &'static str, ) -> Result { if !*self.is_running.read().expect("lock") { return Err(Error::NotRunning); } - let invoice = maybe_deref(invoice); let payment_hash = invoice.payment_hash(); let payment_id = PaymentId(invoice.payment_hash().0); if let Some(payment) = self.payment_store.get(&payment_id) { @@ -308,23 +289,34 @@ impl Bolt11Payment { route_parameters.or(self.config.route_parameters).unwrap_or_default(); let retry_strategy = Retry::Timeout(LDK_PAYMENT_RETRY_TIMEOUT); let payment_secret = Some(*invoice.payment_secret()); + let payment_amount_msat = match amount_msat.or_else(|| invoice.amount_milli_satoshis()) { + Some(amount_msat) => amount_msat, + None => { + log_error!(self.logger, "{}", invalid_amount_log); + return Err(Error::InvalidInvoice); + }, + }; let optional_params = OptionalBolt11PaymentParams { retry_strategy, route_params_config, + declared_total_mpp_value_msat_override, ..Default::default() }; match self.channel_manager.pay_for_bolt11_invoice( invoice, payment_id, - None, + amount_msat, optional_params, ) { Ok(()) => { let payee_pubkey = invoice.recover_payee_pub_key(); - let amt_msat = - invoice.amount_milli_satoshis().expect("invoice amount should be set"); - log_info!(self.logger, "Initiated sending {}msat to {}", amt_msat, payee_pubkey); + log_info!( + self.logger, + "Initiated sending {} msat to {}", + payment_amount_msat, + payee_pubkey + ); let kind = PaymentKind::Bolt11 { hash: payment_hash, @@ -335,7 +327,7 @@ impl Bolt11Payment { let payment = PaymentDetails::new( payment_id, kind, - invoice.amount_milli_satoshis(), + Some(payment_amount_msat), None, PaymentDirection::Outbound, PaymentStatus::Pending, @@ -346,9 +338,7 @@ impl Bolt11Payment { Ok(payment_id) }, Err(Bolt11PaymentError::InvalidAmount) => { - log_error!(self.logger, - "Failed to send payment due to the given invoice being \"zero-amount\". Please use send_using_amount instead." - ); + log_error!(self.logger, "{}", invalid_amount_log); return Err(Error::InvalidInvoice); }, Err(Bolt11PaymentError::SendingFailed(e)) => { @@ -365,7 +355,7 @@ impl Bolt11Payment { let payment = PaymentDetails::new( payment_id, kind, - invoice.amount_milli_satoshis(), + Some(payment_amount_msat), None, PaymentDirection::Outbound, PaymentStatus::Failed, @@ -378,6 +368,30 @@ impl Bolt11Payment { }, } } +} + +#[cfg_attr(feature = "uniffi", uniffi::export)] +impl Bolt11Payment { + /// Send a payment given an invoice. + /// + /// If `route_parameters` are provided they will override the default as well as the + /// node-wide parameters configured via [`Config::route_parameters`] on a per-field basis. + pub fn send( + &self, invoice: &Bolt11Invoice, route_parameters: Option, + ) -> Result { + if !*self.is_running.read().expect("lock") { + return Err(Error::NotRunning); + } + + let invoice = maybe_deref(invoice); + self.send_internal( + invoice, + None, + route_parameters, + None, + "Failed to send payment due to the given invoice being \"zero-amount\". Please use send_using_amount instead.", + ) + } /// Send a payment given an invoice and an amount in millisatoshis. /// @@ -406,94 +420,58 @@ impl Bolt11Payment { } } - let payment_hash = invoice.payment_hash(); - let payment_id = PaymentId(invoice.payment_hash().0); - if let Some(payment) = self.payment_store.get(&payment_id) { - if payment.status == PaymentStatus::Pending - || payment.status == PaymentStatus::Succeeded - { - log_error!(self.logger, "Payment error: an invoice must not be paid twice."); - return Err(Error::DuplicatePayment); - } - } - - let route_params_config = - route_parameters.or(self.config.route_parameters).unwrap_or_default(); - let retry_strategy = Retry::Timeout(LDK_PAYMENT_RETRY_TIMEOUT); - let payment_secret = Some(*invoice.payment_secret()); - - let optional_params = OptionalBolt11PaymentParams { - retry_strategy, - route_params_config, - ..Default::default() - }; - match self.channel_manager.pay_for_bolt11_invoice( + self.send_internal( invoice, - payment_id, Some(amount_msat), - optional_params, - ) { - Ok(()) => { - let payee_pubkey = invoice.recover_payee_pub_key(); - log_info!( - self.logger, - "Initiated sending {} msat to {}", - amount_msat, - payee_pubkey - ); - - let kind = PaymentKind::Bolt11 { - hash: payment_hash, - preimage: None, - secret: payment_secret, - counterparty_skimmed_fee_msat: None, - }; + route_parameters, + None, + "Failed to send payment due to amount given being insufficient.", + ) + } - let payment = PaymentDetails::new( - payment_id, - kind, - Some(amount_msat), - None, - PaymentDirection::Outbound, - PaymentStatus::Pending, - ); - self.runtime.block_on(self.payment_store.insert(payment))?; + /// Send a payment given an invoice and an amount lower than the invoice amount. + /// + /// This uses LDK's partial MPP support by declaring the invoice amount as the total MPP value + /// while only sending `amount_msat` from this node. The receiving node must be willing to + /// accept underpaying HTLCs for the payment to complete. + /// + /// This will fail if the invoice is a zero-amount invoice, or if the amount given is greater + /// than or equal to the value required by the invoice. Use [`Self::send_using_amount`] instead + /// when paying a zero-amount invoice or paying at least the invoice amount. + /// + /// If `route_parameters` are provided they will override the default as well as the + /// node-wide parameters configured via [`Config::route_parameters`] on a per-field basis. + pub fn send_using_amount_underpaying( + &self, invoice: &Bolt11Invoice, amount_msat: u64, + route_parameters: Option, + ) -> Result { + if !*self.is_running.read().expect("lock") { + return Err(Error::NotRunning); + } - Ok(payment_id) - }, - Err(Bolt11PaymentError::InvalidAmount) => { - log_error!( - self.logger, - "Failed to send payment due to amount given being insufficient." - ); - return Err(Error::InvalidInvoice); - }, - Err(Bolt11PaymentError::SendingFailed(e)) => { - log_error!(self.logger, "Failed to send payment: {:?}", e); - match e { - RetryableSendFailure::DuplicatePayment => Err(Error::DuplicatePayment), - _ => { - let kind = PaymentKind::Bolt11 { - hash: payment_hash, - preimage: None, - secret: payment_secret, - counterparty_skimmed_fee_msat: None, - }; - let payment = PaymentDetails::new( - payment_id, - kind, - Some(amount_msat), - None, - PaymentDirection::Outbound, - PaymentStatus::Failed, - ); + let invoice = maybe_deref(invoice); + let invoice_amount_msat = invoice.amount_milli_satoshis().ok_or_else(|| { + log_error!(self.logger, "Failed to underpay as the given invoice is \"zero-amount\"."); + Error::InvalidInvoice + })?; - self.runtime.block_on(self.payment_store.insert(payment))?; - Err(Error::PaymentSendingFailed) - }, - } - }, + if amount_msat >= invoice_amount_msat { + log_error!( + self.logger, + "Failed to underpay as the given amount needs to be less than the invoice amount: required less than {}msat, gave {}msat.", + invoice_amount_msat, + amount_msat + ); + return Err(Error::InvalidAmount); } + + self.send_internal( + invoice, + Some(amount_msat), + route_parameters, + Some(invoice_amount_msat), + "Failed to send payment due to amount given being insufficient.", + ) } /// Allows to attempt manually claiming payments with the given preimage that have previously @@ -539,7 +517,7 @@ impl Bolt11Payment { _ => 0, }; if let Some(invoice_amount_msat) = details.amount_msat { - if claimable_amount_msat < invoice_amount_msat - skimmed_fee_msat { + if claimable_amount_msat < invoice_amount_msat.saturating_sub(skimmed_fee_msat) { log_error!( self.logger, "Failed to manually claim payment {} as the claimable amount is less than expected", diff --git a/src/payment/mod.rs b/src/payment/mod.rs index 71daa48b0a..2d3acf90e1 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -20,10 +20,11 @@ pub use bolt11::Bolt11Payment; pub(crate) use bolt11::PaymentMetadata; pub use bolt12::Bolt12Payment; pub use onchain::OnchainPayment; -pub use pending_payment_store::PendingPaymentDetails; +pub(crate) use pending_payment_store::FundingTxCandidate; +pub(crate) use pending_payment_store::PendingPaymentDetails; pub use spontaneous::SpontaneousPayment; pub use store::{ - ConfirmationStatus, LSPS2Parameters, PaymentDetails, PaymentDirection, PaymentKind, - PaymentStatus, + Channel, ConfirmationStatus, LSPS2Parameters, PaymentDetails, PaymentDirection, PaymentKind, + PaymentStatus, TransactionType, }; pub use unified::{UnifiedPayment, UnifiedPaymentResult}; diff --git a/src/payment/onchain.rs b/src/payment/onchain.rs index 9d00968fcc..da2685970c 100644 --- a/src/payment/onchain.rs +++ b/src/payment/onchain.rs @@ -134,11 +134,18 @@ impl OnchainPayment { /// The new transaction will have the same outputs as the original but with a /// higher fee, resulting in faster confirmation potential. /// + /// This will respect any on-chain reserve we need to keep, i.e., won't allow to cut into + /// [`BalanceDetails::total_anchor_channels_reserve_sats`]. + /// /// Returns the [`Txid`] of the new replacement transaction if successful. + /// + /// [`BalanceDetails::total_anchor_channels_reserve_sats`]: crate::BalanceDetails::total_anchor_channels_reserve_sats pub fn bump_fee_rbf( &self, payment_id: PaymentId, fee_rate: Option, ) -> Result { + let cur_anchor_reserve_sats = + crate::total_anchor_channels_reserve_sats(&self.channel_manager, &self.config); let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate); - self.wallet.bump_fee_rbf(payment_id, fee_rate_opt) + self.wallet.bump_fee_rbf(payment_id, fee_rate_opt, cur_anchor_reserve_sats) } } diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs index eb72f89ec9..c8b792ccb1 100644 --- a/src/payment/pending_payment_store.rs +++ b/src/payment/pending_payment_store.rs @@ -11,7 +11,30 @@ use lightning::ln::channelmanager::PaymentId; use crate::data_store::{StorableObject, StorableObjectUpdate}; use crate::payment::store::PaymentDetailsUpdate; -use crate::payment::PaymentDetails; +use crate::payment::{PaymentDetails, PaymentKind}; + +/// One candidate transaction in an interactive-funding (splice) RBF history, holding this node's +/// share of the funding amount and fee for that candidate. Both are `None` for a candidate this +/// node did not contribute to — e.g. a counterparty-initiated round before our `splice_in` joined +/// it via RBF. Recorded per pending payment so that, on confirmation, the payment reports the +/// figures of the candidate that actually confirmed, which need not be the last one broadcast. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct FundingTxCandidate { + /// The candidate's broadcast transaction id. + pub txid: Txid, + /// This node's share of the funding amount for this candidate, in millisatoshis, or `None` if + /// this node did not contribute to it. + pub amount_msat: Option, + /// This node's share of the on-chain fee for this candidate, in millisatoshis, or `None` if + /// this node did not contribute to it. + pub fee_paid_msat: Option, +} + +impl_writeable_tlv_based!(FundingTxCandidate, { + (0, txid, required), + (2, amount_msat, option), + (4, fee_paid_msat, option), +}); /// Represents a pending payment #[derive(Clone, Debug, PartialEq, Eq)] @@ -20,22 +43,29 @@ pub struct PendingPaymentDetails { pub details: PaymentDetails, /// Transaction IDs that have replaced or conflict with this payment. pub conflicting_txids: Vec, + /// For interactive funding (splices), this node's per-candidate funding figures across the + /// RBF history, keyed by each candidate's txid. Empty for non-funding payments and for + /// records written before per-candidate tracking existed. + pub(crate) candidates: Vec, } impl PendingPaymentDetails { - pub(crate) fn new(details: PaymentDetails, conflicting_txids: Vec) -> Self { - Self { details, conflicting_txids } + pub(crate) fn new( + details: PaymentDetails, conflicting_txids: Vec, candidates: Vec, + ) -> Self { + Self { details, conflicting_txids, candidates } } - /// Convert to finalized payment for the main payment store - pub fn into_payment_details(self) -> PaymentDetails { - self.details + /// Returns this node's recorded funding figures for the candidate with the given txid, if any. + pub(crate) fn candidate(&self, txid: Txid) -> Option<&FundingTxCandidate> { + self.candidates.iter().find(|candidate| candidate.txid == txid) } } impl_writeable_tlv_based!(PendingPaymentDetails, { (0, details, required), (2, conflicting_txids, optional_vec), + (4, candidates, optional_vec), }); #[derive(Clone, Debug, PartialEq, Eq)] @@ -43,6 +73,7 @@ pub(crate) struct PendingPaymentDetailsUpdate { pub id: PaymentId, pub payment_update: Option, pub conflicting_txids: Option>, + pub candidates: Vec, } impl StorableObject for PendingPaymentDetails { @@ -68,6 +99,19 @@ impl StorableObject for PendingPaymentDetails { } } + if let PaymentKind::Onchain { txid, .. } = &self.details.kind { + let conflicts_len = self.conflicting_txids.len(); + self.conflicting_txids.retain(|conflicting_txid| conflicting_txid != txid); + updated |= self.conflicting_txids.len() != conflicts_len; + } + + // Each classify passes the complete candidate history, so a non-empty update replaces the + // stored list. An empty update (e.g. a non-funding payment) leaves it untouched. + if !update.candidates.is_empty() && self.candidates != update.candidates { + self.candidates = update.candidates; + updated = true; + } + updated } @@ -89,6 +133,112 @@ impl From<&PendingPaymentDetails> for PendingPaymentDetailsUpdate { } else { Some(value.conflicting_txids.clone()) }; - Self { id: value.id(), payment_update: Some(value.details.to_update()), conflicting_txids } + Self { + id: value.id(), + payment_update: Some(value.details.to_update()), + conflicting_txids, + candidates: value.candidates.clone(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::payment::store::ConfirmationStatus; + use crate::payment::{PaymentDirection, PaymentKind, PaymentStatus}; + use bitcoin::hashes::Hash; + + #[test] + fn pending_payment_candidate_lookup() { + let payment_id = PaymentId([1u8; 32]); + let first_txid = Txid::from_byte_array([2u8; 32]); + let rbf_txid = Txid::from_byte_array([3u8; 32]); + + // A leading counterparty-initiated round we didn't contribute to (no figures), then our own + // original and RBF candidates. + let counterparty_txid = Txid::from_byte_array([4u8; 32]); + let candidates = vec![ + FundingTxCandidate { txid: counterparty_txid, amount_msat: None, fee_paid_msat: None }, + FundingTxCandidate { + txid: first_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(1_000), + }, + FundingTxCandidate { + txid: rbf_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(5_000), + }, + ]; + + // The stored details only need to be a valid funding payment; `candidate` resolves figures + // purely from the recorded candidate list. + let details = PaymentDetails::new( + payment_id, + PaymentKind::Onchain { + txid: rbf_txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: None, + }, + Some(1_000_000), + Some(5_000), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + let pending = + PendingPaymentDetails::new(details, vec![first_txid, counterparty_txid], candidates); + + // Each candidate resolves to its own figures, so a non-last candidate that confirms reports + // its own (lower) fee rather than the last-broadcast candidate's. + assert_eq!(pending.candidate(first_txid).and_then(|c| c.fee_paid_msat), Some(1_000)); + assert_eq!(pending.candidate(rbf_txid).and_then(|c| c.fee_paid_msat), Some(5_000)); + // A candidate we didn't contribute to carries no figures, so the payment reports `None` + // rather than another candidate's stale figures. + let counterparty = pending.candidate(counterparty_txid).expect("candidate is recorded"); + assert_eq!(counterparty.amount_msat, None); + assert_eq!(counterparty.fee_paid_msat, None); + assert_eq!(pending.candidate(Txid::from_byte_array([9u8; 32])), None); + } + + fn test_txid(byte: u8) -> Txid { + Txid::from_byte_array([byte; 32]) + } + + fn pending_onchain_payment(payment_id: PaymentId, txid: Txid) -> PaymentDetails { + PaymentDetails::new( + payment_id, + PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, tx_type: None }, + Some(1_000), + Some(100), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ) + } + + #[test] + fn pending_onchain_conflicts_exclude_current_txid_after_txid_rotation() { + let original_txid = test_txid(1); + let replacement_txid = test_txid(2); + let payment_id = PaymentId(original_txid.to_byte_array()); + + let mut pending_payment = PendingPaymentDetails::new( + pending_onchain_payment(payment_id, replacement_txid), + vec![original_txid], + Vec::new(), + ); + let update = PendingPaymentDetails::new( + pending_onchain_payment(payment_id, original_txid), + Vec::new(), + Vec::new(), + ) + .to_update(); + + assert!(pending_payment.update(update)); + assert_eq!( + pending_payment.conflicting_txids, + Vec::::new(), + "current txid must not remain in its own conflict list" + ); } } diff --git a/src/payment/store.rs b/src/payment/store.rs index f80ab6f8a5..1608908958 100644 --- a/src/payment/store.rs +++ b/src/payment/store.rs @@ -7,9 +7,12 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use bitcoin::secp256k1::PublicKey; use bitcoin::{BlockHash, Txid}; +use lightning::chain::chaininterface::TransactionType as LdkTransactionType; use lightning::ln::channelmanager::PaymentId; use lightning::ln::msgs::DecodeError; +use lightning::ln::types::ChannelId; use lightning::offers::offer::OfferId; use lightning::util::ser::{Readable, Writeable}; use lightning::{ @@ -282,6 +285,15 @@ impl StorableObject for PaymentDetails { } } + if let Some(tx_type_update) = update.tx_type { + match self.kind { + PaymentKind::Onchain { ref mut tx_type, .. } => { + update_if_necessary!(*tx_type, tx_type_update); + }, + _ => {}, + } + } + if updated { self.latest_update_timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -330,6 +342,156 @@ impl_writeable_tlv_based_enum!(PaymentStatus, (4, Failed) => {} ); +/// A channel referenced by a [`TransactionType`]. +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct Channel { + /// The `node_id` of the channel counterparty. + pub counterparty_node_id: PublicKey, + /// The ID of the channel. + pub channel_id: ChannelId, +} + +impl_writeable_tlv_based!(Channel, { + (0, counterparty_node_id, required), + (2, channel_id, required), +}); + +/// The classification of a [`PaymentKind::Onchain`] transaction, as reported by LDK when the +/// transaction was broadcast. +/// +/// Mirrors [`lightning::chain::chaininterface::TransactionType`], retaining the channel references +/// but dropping the broadcast-time contribution data; a transaction's amount and fee are tracked on +/// the [`PaymentDetails`] itself. +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum TransactionType { + /// A funding transaction establishing one or more new channels. + Funding { + /// The channels being funded. + channels: Vec, + }, + /// A transaction cooperatively closing a channel. + CooperativeClose { + /// The `node_id` of the channel counterparty. + counterparty_node_id: PublicKey, + /// The ID of the channel being closed. + channel_id: ChannelId, + }, + /// A transaction force-closing a channel. + UnilateralClose { + /// The `node_id` of the channel counterparty. + counterparty_node_id: PublicKey, + /// The ID of the channel being force-closed. + channel_id: ChannelId, + }, + /// An anchor transaction CPFP fee-bumping a closing transaction. + AnchorBump { + /// The `node_id` of the channel counterparty. + counterparty_node_id: PublicKey, + /// The ID of the channel whose closing transaction is being fee-bumped. + channel_id: ChannelId, + }, + /// A transaction resolving an output spendable by both us and our counterparty. + Claim { + /// The `node_id` of the channel counterparty. + counterparty_node_id: PublicKey, + /// The ID of the channel from which outputs are being claimed. + channel_id: ChannelId, + }, + /// A transaction sweeping spendable outputs to the on-chain wallet. + Sweep { + /// The channels from which outputs are being swept, if known. + channels: Vec, + }, + /// An interactively-negotiated funding transaction: a splice, or (once supported) a V2 + /// dual-funded channel open. + InteractiveFunding { + /// The channels participating in the negotiation. + channels: Vec, + }, +} + +impl_writeable_tlv_based_enum!(TransactionType, + (0, Funding) => { + (0, channels, optional_vec), + }, + (2, CooperativeClose) => { + (0, counterparty_node_id, required), + (2, channel_id, required), + }, + (4, UnilateralClose) => { + (0, counterparty_node_id, required), + (2, channel_id, required), + }, + (6, AnchorBump) => { + (0, counterparty_node_id, required), + (2, channel_id, required), + }, + (8, Claim) => { + (0, counterparty_node_id, required), + (2, channel_id, required), + }, + (10, Sweep) => { + (0, channels, optional_vec), + }, + (12, InteractiveFunding) => { + (0, channels, optional_vec), + } +); + +impl From for TransactionType { + fn from(tx_type: LdkTransactionType) -> Self { + let to_channels = |channels: Vec<(PublicKey, ChannelId)>| -> Vec { + channels + .into_iter() + .map(|(counterparty_node_id, channel_id)| Channel { + counterparty_node_id, + channel_id, + }) + .collect() + }; + match tx_type { + LdkTransactionType::Funding { channels } => { + TransactionType::Funding { channels: to_channels(channels) } + }, + LdkTransactionType::CooperativeClose { counterparty_node_id, channel_id } => { + TransactionType::CooperativeClose { counterparty_node_id, channel_id } + }, + LdkTransactionType::UnilateralClose { counterparty_node_id, channel_id } => { + TransactionType::UnilateralClose { counterparty_node_id, channel_id } + }, + LdkTransactionType::AnchorBump { counterparty_node_id, channel_id } => { + TransactionType::AnchorBump { counterparty_node_id, channel_id } + }, + LdkTransactionType::Claim { counterparty_node_id, channel_id } => { + TransactionType::Claim { counterparty_node_id, channel_id } + }, + LdkTransactionType::Sweep { channels } => { + TransactionType::Sweep { channels: to_channels(channels) } + }, + LdkTransactionType::InteractiveFunding { candidates } => { + // Every candidate (the original negotiation plus any RBF replacements) references + // the same channel(s); take the active (last) candidate's channel references. + let channels = candidates + .last() + .map(|candidate| { + candidate + .channels + .iter() + .map(|cf| Channel { + counterparty_node_id: cf.counterparty_node_id, + channel_id: cf.channel_id, + }) + .collect() + }) + .unwrap_or_default(); + TransactionType::InteractiveFunding { channels } + }, + } + } +} + /// Represents the kind of a payment. #[derive(Clone, Debug, PartialEq, Eq)] #[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] @@ -345,6 +507,11 @@ pub enum PaymentKind { txid: Txid, /// The confirmation status of this payment. status: ConfirmationStatus, + /// The classification of this transaction, if known. + /// + /// `None` for plain on-chain sends, and for records written by versions of LDK Node that + /// predate on-chain transaction classification. + tx_type: Option, }, /// A [BOLT 11] payment. /// @@ -423,6 +590,7 @@ pub enum PaymentKind { impl_writeable_tlv_based_enum!(PaymentKind, (0, Onchain) => { (0, txid, required), + (1, tx_type, option), (2, status, required), }, (2, Bolt11) => { @@ -522,6 +690,7 @@ pub(crate) struct PaymentDetailsUpdate { pub status: Option, pub confirmation_status: Option, pub txid: Option, + pub tx_type: Option>, } impl PaymentDetailsUpdate { @@ -538,6 +707,7 @@ impl PaymentDetailsUpdate { status: None, confirmation_status: None, txid: None, + tx_type: None, } } } @@ -552,9 +722,11 @@ impl From<&PaymentDetails> for PaymentDetailsUpdate { _ => (None, None, None), }; - let (confirmation_status, txid) = match &value.kind { - PaymentKind::Onchain { status, txid, .. } => (Some(*status), Some(*txid)), - _ => (None, None), + let (confirmation_status, txid, tx_type) = match &value.kind { + PaymentKind::Onchain { status, txid, tx_type } => { + (Some(*status), Some(*txid), Some(tx_type.clone())) + }, + _ => (None, None, None), }; let counterparty_skimmed_fee_msat = match value.kind { @@ -576,6 +748,7 @@ impl From<&PaymentDetails> for PaymentDetailsUpdate { status: Some(value.status), confirmation_status, txid, + tx_type, } } } @@ -697,6 +870,57 @@ mod tests { } } + #[derive(Clone, Debug, PartialEq, Eq)] + struct OldOnchainKind { + txid: Txid, + status: ConfirmationStatus, + } + + impl_writeable_tlv_based!(OldOnchainKind, { + (0, txid, required), + (2, status, required), + }); + + #[test] + fn onchain_tx_type_deser_compat() { + use bitcoin::hashes::Hash; + use std::str::FromStr; + + let txid = Txid::from_byte_array([7u8; 32]); + let status = ConfirmationStatus::Unconfirmed; + + // An `Onchain` record written before `tx_type` existed (only txid + status) must read back + // with `tx_type: None`. + let old = OldOnchainKind { txid, status }; + let mut on_disk = Vec::new(); + 0u8.write(&mut on_disk).unwrap(); // the `Onchain` enum discriminant + on_disk.extend_from_slice(&old.encode()); + match PaymentKind::read(&mut &*on_disk).unwrap() { + PaymentKind::Onchain { txid: t, status: s, tx_type } => { + assert_eq!(t, txid); + assert_eq!(s, status); + assert_eq!(tx_type, None); + }, + other => panic!("Unexpected kind: {:?}", other), + } + + // A populated `tx_type` round-trips. + let kind = PaymentKind::Onchain { + txid, + status, + tx_type: Some(TransactionType::InteractiveFunding { + channels: vec![Channel { + counterparty_node_id: PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + channel_id: ChannelId([3u8; 32]), + }], + }), + }; + assert_eq!(kind, PaymentKind::read(&mut &*kind.encode()).unwrap()); + } + #[derive(Clone, Debug, PartialEq, Eq)] struct LegacyBolt11JitKind { hash: PaymentHash, diff --git a/src/payment/unified.rs b/src/payment/unified.rs index 3708afe8e6..2ad77f7728 100644 --- a/src/payment/unified.rs +++ b/src/payment/unified.rs @@ -129,9 +129,9 @@ impl UnifiedPayment { pub fn receive( &self, amount_sats: u64, description: &str, expiry_sec: u32, ) -> Result { - let onchain_address = self.onchain_payment.new_address()?; + let amount_msats = amount_sats.checked_mul(1_000).ok_or(Error::InvalidAmount)?; - let amount_msats = amount_sats * 1_000; + let onchain_address = self.onchain_payment.new_address()?; let bolt12_offer = match self.bolt12_payment.receive_inner(amount_msats, description, None, None) { diff --git a/src/runtime.rs b/src/runtime.rs index 9673d0eb7a..7e29996e62 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -22,11 +22,22 @@ use crate::logger::{log_debug, log_error, log_trace, LdkLogger, Logger}; pub(crate) struct Runtime { mode: RuntimeMode, background_tasks: Mutex>, - cancellable_background_tasks: Mutex>, + cancellable_background_tasks: Mutex, background_processor_task: Mutex>>, logger: Arc, } +struct CancellableBackgroundTasks { + tasks: JoinSet<()>, + accepting_tasks: bool, +} + +impl CancellableBackgroundTasks { + fn new() -> Self { + Self { tasks: JoinSet::new(), accepting_tasks: true } + } +} + impl Runtime { pub fn new(logger: Arc) -> Result { let mode = match tokio::runtime::Handle::try_current() { @@ -55,7 +66,7 @@ impl Runtime { }, }; let background_tasks = Mutex::new(JoinSet::new()); - let cancellable_background_tasks = Mutex::new(JoinSet::new()); + let cancellable_background_tasks = Mutex::new(CancellableBackgroundTasks::new()); let background_processor_task = Mutex::new(None); Ok(Self { @@ -70,7 +81,7 @@ impl Runtime { pub fn with_handle(handle: tokio::runtime::Handle, logger: Arc) -> Self { let mode = RuntimeMode::Handle(handle); let background_tasks = Mutex::new(JoinSet::new()); - let cancellable_background_tasks = Mutex::new(JoinSet::new()); + let cancellable_background_tasks = Mutex::new(CancellableBackgroundTasks::new()); let background_processor_task = Mutex::new(None); Self { @@ -100,11 +111,22 @@ impl Runtime { { let mut cancellable_background_tasks = self.cancellable_background_tasks.lock().expect("lock"); + if !cancellable_background_tasks.accepting_tasks { + log_trace!( + self.logger, + "Ignoring cancellable background task spawned during shutdown." + ); + return; + } let runtime_handle = self.handle(); // Since it seems to make a difference to `tokio` (see // https://docs.rs/tokio/latest/tokio/time/fn.timeout.html#panics) we make sure the futures // are always put in an `async` / `.await` closure. - cancellable_background_tasks.spawn_on(async { future.await }, runtime_handle); + cancellable_background_tasks.tasks.spawn_on(async { future.await }, runtime_handle); + } + + pub fn allow_cancellable_background_task_spawns(&self) { + self.cancellable_background_tasks.lock().expect("lock").accepting_tasks = true; } pub fn spawn_background_processor_task(&self, future: F) @@ -142,8 +164,12 @@ impl Runtime { } pub fn abort_cancellable_background_tasks(&self) { - let mut tasks = - core::mem::take(&mut *self.cancellable_background_tasks.lock().expect("lock")); + let mut tasks = { + let mut cancellable_background_tasks = + self.cancellable_background_tasks.lock().expect("lock"); + cancellable_background_tasks.accepting_tasks = false; + core::mem::take(&mut cancellable_background_tasks.tasks) + }; debug_assert!(tasks.len() > 0, "Expected some cancellable background_tasks"); tasks.abort_all(); self.block_on(async { while let Some(_) = tasks.join_next().await {} }) @@ -226,7 +252,7 @@ impl Runtime { log_trace!( self.logger, "Active runtime tasks left prior to shutdown: {}", - runtime_handle.metrics().active_tasks_count() + runtime_handle.metrics().num_alive_tasks() ); } @@ -352,3 +378,56 @@ impl FutureSpawner for RuntimeSpawner { output } } + +#[cfg(test)] +mod tests { + use tokio::sync::oneshot; + + use super::*; + + fn test_runtime() -> Runtime { + Runtime::new(Arc::new(Logger::new_log_facade())).unwrap() + } + + #[test] + fn late_cancellable_spawns_are_not_polled_after_abort() { + let runtime = test_runtime(); + let (started_sender, started_receiver) = oneshot::channel(); + runtime.spawn_cancellable_background_task(async move { + let _ = started_sender.send(()); + std::future::pending::<()>().await; + }); + runtime.block_on(async { + started_receiver.await.expect("initial task should start"); + }); + + runtime.abort_cancellable_background_tasks(); + + let (late_spawn_sender, late_spawn_receiver) = oneshot::channel(); + runtime.spawn_cancellable_background_task(async move { + let _ = late_spawn_sender.send(()); + }); + let late_spawn_was_polled = runtime.block_on(async { + match tokio::time::timeout(Duration::from_secs(1), late_spawn_receiver).await { + Ok(Ok(())) => true, + Ok(Err(_)) | Err(_) => false, + } + }); + + assert!( + !late_spawn_was_polled, + "cancellable task spawned after shutdown started should not be polled" + ); + + runtime.allow_cancellable_background_task_spawns(); + + let (restarted_sender, restarted_receiver) = oneshot::channel(); + runtime.spawn_cancellable_background_task(async move { + let _ = restarted_sender.send(()); + }); + runtime.block_on(async { + restarted_receiver.await.expect("spawn should be allowed after restart"); + }); + runtime.abort_cancellable_background_tasks(); + } +} diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index 7084135b00..5722a3ebe3 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -6,21 +6,52 @@ // accordance with one or both of these licenses. use std::ops::Deref; +use std::sync::{Mutex as StdMutex, Weak}; use bitcoin::Transaction; use lightning::chain::chaininterface::{BroadcasterInterface, TransactionType}; use tokio::sync::{mpsc, Mutex, MutexGuard}; use crate::logger::{log_error, LdkLogger}; +use crate::types::Wallet; +use crate::Error; const BCAST_PACKAGE_QUEUE_SIZE: usize = 50; +/// A package of transactions that LDK handed to the broadcaster in one `broadcast_transactions` +/// call, along with each transaction's type. Queued until the background task classifies and +/// broadcasts it. Built only via [`BroadcastPackage::new`] from such a call, so unrelated +/// transactions can't be grouped into one package by accident. +pub(crate) struct BroadcastPackage(Vec<(Transaction, TransactionType)>); + +impl BroadcastPackage { + /// Builds a package from the transactions of a single `broadcast_transactions` call. + fn new(txs: &[(&Transaction, TransactionType)]) -> Self { + Self(txs.iter().map(|(tx, tx_type)| ((*tx).clone(), tx_type.clone())).collect()) + } + + /// The packaged transactions and their types, for classification. + fn transactions(&self) -> &[(Transaction, TransactionType)] { + &self.0 + } + + /// Consumes the package into its transactions, ready for the chain client. + pub(crate) fn into_transactions(self) -> Vec { + self.0.into_iter().map(|(tx, _)| tx).collect() + } +} + pub(crate) struct TransactionBroadcaster where L::Target: LdkLogger, { - queue_sender: mpsc::Sender>, - queue_receiver: Mutex>>, + queue_sender: mpsc::Sender, + queue_receiver: Mutex>, + /// Weak handle to the [`Wallet`] that classifies funding broadcasts (channel opens and + /// splices) into payment records. Remains `None` while the builder is wiring the node up, + /// during which broadcasts are forwarded to the queue but no payment record is written. + /// [`Self::set_wallet`] installs the handle once the [`Wallet`] exists. + wallet: StdMutex>>, logger: L, } @@ -30,14 +61,41 @@ where { pub(crate) fn new(logger: L) -> Self { let (queue_sender, queue_receiver) = mpsc::channel(BCAST_PACKAGE_QUEUE_SIZE); - Self { queue_sender, queue_receiver: Mutex::new(queue_receiver), logger } + Self { + queue_sender, + queue_receiver: Mutex::new(queue_receiver), + wallet: StdMutex::new(None), + logger, + } + } + + /// Installs the [`Wallet`] handle used to classify funding broadcasts (channel opens and + /// splices) into payment records. Called once the builder has constructed both the + /// broadcaster and the wallet. + pub(crate) fn set_wallet(&self, wallet: Weak) { + *self.wallet.lock().expect("lock") = Some(wallet); } pub(crate) async fn get_broadcast_queue( &self, - ) -> MutexGuard<'_, mpsc::Receiver>> { + ) -> MutexGuard<'_, mpsc::Receiver> { self.queue_receiver.lock().await } + + /// Classifies a queued package into payment records and returns the package ready for the + /// chain client. Returns `Err` if any classification fails; callers must not broadcast the + /// package in that case, since a crash would leave the transaction on-chain without a record. + pub(crate) async fn classify_package( + &self, package: BroadcastPackage, + ) -> Result { + let wallet_opt = self.wallet.lock().expect("lock").as_ref().and_then(Weak::upgrade); + if let Some(wallet) = wallet_opt { + for (tx, tx_type) in package.transactions() { + wallet.classify_broadcast(tx, tx_type).await?; + } + } + Ok(package) + } } impl BroadcasterInterface for TransactionBroadcaster @@ -45,8 +103,7 @@ where L::Target: LdkLogger, { fn broadcast_transactions(&self, txs: &[(&Transaction, TransactionType)]) { - let package = txs.iter().map(|(t, _)| (*t).clone()).collect::>(); - self.queue_sender.try_send(package).unwrap_or_else(|e| { + self.queue_sender.try_send(BroadcastPackage::new(txs)).unwrap_or_else(|e| { log_error!(self.logger, "Failed to broadcast transactions: {}", e); }); } diff --git a/src/types.rs b/src/types.rs index 64209430be..e24db4d253 100644 --- a/src/types.rs +++ b/src/types.rs @@ -20,7 +20,9 @@ use bitcoin_payment_instructions::hrn_resolution::{ use bitcoin_payment_instructions::onion_message_resolver::LDKOnionMessageDNSSECHrnResolver; use lightning::chain::chainmonitor; use lightning::impl_writeable_tlv_based; -use lightning::ln::channel_state::{ChannelDetails as LdkChannelDetails, ChannelShutdownState}; +use lightning::ln::channel_state::{ + ChannelDetails as LdkChannelDetails, ChannelShutdownState, CounterpartyForwardingInfo, +}; use lightning::ln::msgs::{RoutingMessageHandler, SocketAddress}; use lightning::ln::peer_handler::IgnoringMessageHandler; use lightning::ln::types::ChannelId; @@ -29,7 +31,9 @@ use lightning::routing::gossip; use lightning::routing::router::DefaultRouter; use lightning::routing::scoring::{CombinedScorer, ProbabilisticScoringFeeParameters}; use lightning::sign::InMemorySigner; -use lightning::util::persist::{KVStore, MonitorUpdatingPersisterAsync}; +use lightning::util::persist::{ + KVStore, MonitorUpdatingPersisterAsync, PageToken, PaginatedKVStore, PaginatedListResponse, +}; use lightning::util::ser::{Readable, Writeable, Writer}; use lightning::util::sweep::OutputSweeper; use lightning_block_sync::gossip::GossipVerifier; @@ -38,14 +42,20 @@ use lightning_net_tokio::SocketDescriptor; use crate::chain::bitcoind::UtxoSourceClient; use crate::chain::ChainSource; -use crate::config::ChannelConfig; +use crate::config::{AnchorChannelsConfig, ChannelConfig}; use crate::data_store::DataStore; use crate::fee_estimator::OnchainFeeEstimator; +use crate::ffi::maybe_wrap; use crate::logger::Logger; use crate::message_handler::NodeCustomMessageHandler; use crate::payment::{PaymentDetails, PendingPaymentDetails}; use crate::runtime::RuntimeSpawner; +#[cfg(not(feature = "uniffi"))] +type InitFeatures = lightning::types::features::InitFeatures; +#[cfg(feature = "uniffi")] +type InitFeatures = Arc; + pub(crate) trait DynStoreTrait: Send + Sync { fn read_async( &self, primary_namespace: &str, secondary_namespace: &str, key: &str, @@ -59,6 +69,13 @@ pub(crate) trait DynStoreTrait: Send + Sync { fn list_async( &self, primary_namespace: &str, secondary_namespace: &str, ) -> Pin, bitcoin::io::Error>> + Send + 'static>>; + fn list_paginated_async( + &self, primary_namespace: &str, secondary_namespace: &str, page_token: Option, + ) -> Pin< + Box< + dyn Future> + Send + 'static, + >, + >; } impl<'a> KVStore for dyn DynStoreTrait + 'a { @@ -87,6 +104,19 @@ impl<'a> KVStore for dyn DynStoreTrait + 'a { } } +impl<'a> PaginatedKVStore for dyn DynStoreTrait + 'a { + fn list_paginated( + &self, primary_namespace: &str, secondary_namespace: &str, page_token: Option, + ) -> impl Future> + Send + 'static { + DynStoreTrait::list_paginated_async( + self, + primary_namespace, + secondary_namespace, + page_token, + ) + } +} + pub(crate) type DynStore = dyn DynStoreTrait; // Newtype wrapper that implements `KVStore` for `Arc`. This is needed because `KVStore` @@ -122,9 +152,22 @@ impl KVStore for DynStoreRef { } } -pub(crate) struct DynStoreWrapper(pub(crate) T); +impl PaginatedKVStore for DynStoreRef { + fn list_paginated( + &self, primary_namespace: &str, secondary_namespace: &str, page_token: Option, + ) -> impl Future> + Send + 'static { + DynStoreTrait::list_paginated_async( + &*self.0, + primary_namespace, + secondary_namespace, + page_token, + ) + } +} + +pub(crate) struct DynStoreWrapper(pub(crate) T); -impl DynStoreTrait for DynStoreWrapper { +impl DynStoreTrait for DynStoreWrapper { fn read_async( &self, primary_namespace: &str, secondary_namespace: &str, key: &str, ) -> Pin, bitcoin::io::Error>> + Send + 'static>> { @@ -148,6 +191,21 @@ impl DynStoreTrait for DynStoreWrapper { ) -> Pin, bitcoin::io::Error>> + Send + 'static>> { Box::pin(KVStore::list(&self.0, primary_namespace, secondary_namespace)) } + + fn list_paginated_async( + &self, primary_namespace: &str, secondary_namespace: &str, page_token: Option, + ) -> Pin< + Box< + dyn Future> + Send + 'static, + >, + > { + Box::pin(PaginatedKVStore::list_paginated( + &self.0, + primary_namespace, + secondary_namespace, + page_token, + )) + } } pub(crate) type AsyncPersister = MonitorUpdatingPersisterAsync< @@ -341,6 +399,78 @@ impl fmt::Display for UserChannelId { } } +/// Channel parameters which apply to our counterparty. These are split out from [`ChannelDetails`] +/// to better separate parameters. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct ChannelCounterparty { + /// The node_id of our counterparty + pub node_id: PublicKey, + /// The Features the channel counterparty provided upon last connection. + /// Useful for routing as it is the most up-to-date copy of the counterparty's features and + /// many routing-relevant features are present in the init context. + pub features: InitFeatures, + /// The value, in satoshis, that must always be held in the channel for our counterparty. This + /// value ensures that if our counterparty broadcasts a revoked state, we can punish them by + /// claiming at least this value on chain. + /// + /// This value is not included in [`inbound_capacity_msat`] as it can never be spent. + /// + /// [`inbound_capacity_msat`]: ChannelDetails::inbound_capacity_msat + pub unspendable_punishment_reserve: u64, + /// Information on the fees and requirements that the counterparty requires when forwarding + /// payments to us through this channel. + pub forwarding_info: Option, + /// The smallest value HTLC (in msat) the remote peer will accept, for this channel. + /// + /// Will be `None` before we have received the `OpenChannel` or `AcceptChannel` message + /// from the remote peer. + pub outbound_htlc_minimum_msat: Option, + /// The largest value HTLC (in msat) the remote peer currently will accept, for this channel. + pub outbound_htlc_maximum_msat: Option, +} + +/// Describes the reserve behavior of a channel based on its type and trust configuration. +/// +/// This captures the combination of the channel's on-chain construction (anchor outputs vs legacy +/// static_remote_key) and whether the counterparty is in our trusted peers list. It tells the +/// user what reserve obligations exist for this channel without exposing internal protocol details. +/// +/// See [`AnchorChannelsConfig`] for how reserve behavior is configured. +/// +/// [`AnchorChannelsConfig`]: crate::config::AnchorChannelsConfig +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum ReserveType { + /// An anchor outputs channel where we maintain a per-channel on-chain reserve for fee + /// bumping force-close transactions. + /// + /// Anchor channels allow either party to fee-bump commitment transactions via CPFP + /// at broadcast time. Because the pre-signed commitment fee may be insufficient under + /// current fee conditions, the broadcaster must supply additional funds (hence adaptive) + /// through an anchor output spend. The reserve ensures sufficient on-chain funds are + /// available to cover this. + /// + /// This is the default for anchor channels when the counterparty is not in + /// [`trusted_peers_no_reserve`]. + /// + /// [`trusted_peers_no_reserve`]: crate::config::AnchorChannelsConfig::trusted_peers_no_reserve + Adaptive, + /// An anchor outputs channel where we do not maintain any reserve, because the counterparty + /// is in our [`trusted_peers_no_reserve`] list. + /// + /// In this mode, we trust the counterparty to broadcast a valid commitment transaction on + /// our behalf and do not set aside funds for fee bumping. + /// + /// [`trusted_peers_no_reserve`]: crate::config::AnchorChannelsConfig::trusted_peers_no_reserve + TrustedPeersNoReserve, + /// A legacy (pre-anchor) channel using only `option_static_remotekey`. + /// + /// These channels do not use anchor outputs and therefore do not require an on-chain reserve + /// for fee bumping. Commitment transaction fees are pre-committed at channel open time. + Legacy, +} + /// Details of a channel as returned by [`Node::list_channels`]. /// /// When a channel is spliced, most fields continue to refer to the original pre-splice channel @@ -357,8 +487,8 @@ pub struct ChannelDetails { /// Note that this means this value is *not* persistent - it can change once during the /// lifetime of the channel. pub channel_id: ChannelId, - /// The node ID of our the channel's counterparty. - pub counterparty_node_id: PublicKey, + /// Parameters which apply to our counterparty. See individual fields for more information. + pub counterparty: ChannelCounterparty, /// The channel's funding transaction output, if we've negotiated the funding transaction with /// our counterparty already. /// @@ -474,28 +604,6 @@ pub struct ChannelDetails { /// The difference in the CLTV value between incoming HTLCs and an outbound HTLC forwarded over /// the channel. pub cltv_expiry_delta: Option, - /// The value, in satoshis, that must always be held in the channel for our counterparty. This - /// value ensures that if our counterparty broadcasts a revoked state, we can punish them by - /// claiming at least this value on chain. - /// - /// This value is not included in [`inbound_capacity_msat`] as it can never be spent. - /// - /// [`inbound_capacity_msat`]: ChannelDetails::inbound_capacity_msat - pub counterparty_unspendable_punishment_reserve: u64, - /// The smallest value HTLC (in msat) the remote peer will accept, for this channel. - /// - /// This field is only `None` before we have received either the `OpenChannel` or - /// `AcceptChannel` message from the remote peer. - pub counterparty_outbound_htlc_minimum_msat: Option, - /// The largest value HTLC (in msat) the remote peer currently will accept, for this channel. - pub counterparty_outbound_htlc_maximum_msat: Option, - /// Base routing fee in millisatoshis. - pub counterparty_forwarding_info_fee_base_msat: Option, - /// Proportional fee, in millionths of a satoshi the channel will charge per transferred satoshi. - pub counterparty_forwarding_info_fee_proportional_millionths: Option, - /// The minimum difference in CLTV expiry between an ingoing HTLC and its outgoing counterpart, - /// such that the outgoing HTLC is forwardable to this counterparty. - pub counterparty_forwarding_info_cltv_expiry_delta: Option, /// The available outbound capacity for sending a single HTLC to the remote peer. This is /// similar to [`ChannelDetails::outbound_capacity_msat`] but it may be further restricted by /// the current state and per-HTLC limit(s). This is intended for use when routing, allowing us @@ -527,13 +635,52 @@ pub struct ChannelDetails { /// /// Will be `None` for objects serialized with LDK Node v0.1 and earlier. pub channel_shutdown_state: Option, + /// The type of on-chain reserve maintained for this channel. + /// + /// Will be `None` until channel negotiation has completed and determined whether + /// this channel uses anchor or legacy reserve behavior. + /// + /// See [`ReserveType`] for details on how reserves differ between anchor and legacy channels. + pub reserve_type: Option, } -impl From for ChannelDetails { - fn from(value: LdkChannelDetails) -> Self { +impl ChannelDetails { + pub(crate) fn from_ldk( + value: LdkChannelDetails, anchor_channels_config: Option<&AnchorChannelsConfig>, + ) -> Self { + let reserve_type = value.channel_type.as_ref().map(|channel_type| { + if channel_type.supports_anchors_zero_fee_htlc_tx() { + if let Some(config) = anchor_channels_config { + if config.trusted_peers_no_reserve.contains(&value.counterparty.node_id) { + ReserveType::TrustedPeersNoReserve + } else { + ReserveType::Adaptive + } + } else { + // Edge case: if `AnchorChannelsConfig` was previously set and later + // removed, we can no longer distinguish whether this anchor channel's + // reserve was `Adaptive` or `TrustedPeersNoReserve`. We default to + // `Adaptive` here, which may incorrectly override a prior + // `TrustedPeersNoReserve` designation. This is acceptable since + // unsetting `AnchorChannelsConfig` on a node with existing anchor + // channels is not an expected operation. + ReserveType::Adaptive + } + } else { + ReserveType::Legacy + } + }); + ChannelDetails { channel_id: value.channel_id, - counterparty_node_id: value.counterparty.node_id, + counterparty: ChannelCounterparty { + node_id: value.counterparty.node_id, + features: maybe_wrap(value.counterparty.features), + unspendable_punishment_reserve: value.counterparty.unspendable_punishment_reserve, + forwarding_info: value.counterparty.forwarding_info, + outbound_htlc_minimum_msat: value.counterparty.outbound_htlc_minimum_msat, + outbound_htlc_maximum_msat: value.counterparty.outbound_htlc_maximum_msat, + }, funding_txo: value.funding_txo.map(|o| o.into_bitcoin_outpoint()), funding_redeem_script: value.funding_redeem_script, short_channel_id: value.short_channel_id, @@ -554,26 +701,6 @@ impl From for ChannelDetails { is_usable: value.is_usable, is_announced: value.is_announced, cltv_expiry_delta: value.config.map(|c| c.cltv_expiry_delta), - counterparty_unspendable_punishment_reserve: value - .counterparty - .unspendable_punishment_reserve, - counterparty_outbound_htlc_minimum_msat: value.counterparty.outbound_htlc_minimum_msat, - counterparty_outbound_htlc_maximum_msat: value.counterparty.outbound_htlc_maximum_msat, - counterparty_forwarding_info_fee_base_msat: value - .counterparty - .forwarding_info - .as_ref() - .map(|f| f.fee_base_msat), - counterparty_forwarding_info_fee_proportional_millionths: value - .counterparty - .forwarding_info - .as_ref() - .map(|f| f.fee_proportional_millionths), - counterparty_forwarding_info_cltv_expiry_delta: value - .counterparty - .forwarding_info - .as_ref() - .map(|f| f.cltv_expiry_delta), next_outbound_htlc_limit_msat: value.next_outbound_htlc_limit_msat, next_outbound_htlc_minimum_msat: value.next_outbound_htlc_minimum_msat, force_close_spend_delay: value.force_close_spend_delay, @@ -586,6 +713,7 @@ impl From for ChannelDetails { .map(|c| c.into()) .expect("value is set for objects serialized with LDK v0.0.109+"), channel_shutdown_state: value.channel_shutdown_state, + reserve_type, } } } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 76f2aa9ce6..ad4f8d45ee 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -5,6 +5,7 @@ // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in // accordance with one or both of these licenses. +use std::collections::HashMap; use std::future::Future; use std::ops::Deref; use std::str::FromStr; @@ -13,10 +14,9 @@ use std::sync::{Arc, Mutex}; use bdk_chain::spk_client::{FullScanRequest, SyncRequest}; use bdk_wallet::descriptor::ExtendedDescriptor; use bdk_wallet::error::{BuildFeeBumpError, CreateTxError}; -use bdk_wallet::event::WalletEvent; #[allow(deprecated)] use bdk_wallet::SignOptions; -use bdk_wallet::{Balance, KeychainKind, PersistedWallet, Update}; +use bdk_wallet::{Balance, KeychainKind, LocalOutput, PersistedWallet, Update, WalletEvent}; use bitcoin::address::NetworkUnchecked; use bitcoin::blockdata::constants::WITNESS_SCALE_FACTOR; use bitcoin::blockdata::locktime::absolute::LockTime; @@ -28,11 +28,12 @@ use bitcoin::secp256k1::ecdsa::{RecoverableSignature, Signature}; use bitcoin::secp256k1::{All, PublicKey, Scalar, Secp256k1, SecretKey}; use bitcoin::transaction::Sequence; use bitcoin::{ - Address, Amount, FeeRate, OutPoint, ScriptBuf, Transaction, TxOut, Txid, WPubkeyHash, Weight, - WitnessProgram, WitnessVersion, + Address, Amount, FeeRate, OutPoint, ScriptBuf, SignedAmount, Transaction, TxOut, Txid, + WPubkeyHash, Weight, WitnessProgram, WitnessVersion, }; use lightning::chain::chaininterface::{ - BroadcasterInterface, INCREMENTAL_RELAY_FEE_SAT_PER_1000_WEIGHT, + BroadcasterInterface, FundingCandidate, TransactionType as LdkTransactionType, + INCREMENTAL_RELAY_FEE_SAT_PER_1000_WEIGHT, }; use lightning::chain::channelmonitor::ANTI_REORG_DELAY; use lightning::chain::{BlockLocator, ClaimId, Listen}; @@ -40,6 +41,7 @@ use lightning::ln::channelmanager::PaymentId; use lightning::ln::inbound_payment::ExpandedKey; use lightning::ln::msgs::UnsignedGossipMessage; use lightning::ln::script::ShutdownScript; +use lightning::ln::types::ChannelId; use lightning::sign::{ ChangeDestinationSource, EntropySource, InMemorySigner, KeysManager, NodeSigner, OutputSpender, PeerStorageKey, Recipient, SignerProvider, SpendableOutputDescriptor, @@ -56,7 +58,8 @@ use crate::fee_estimator::{ConfirmationTarget, FeeEstimator, OnchainFeeEstimator use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; use crate::payment::store::ConfirmationStatus; use crate::payment::{ - PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, PendingPaymentDetails, + FundingTxCandidate, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, + PendingPaymentDetails, TransactionType, }; use crate::runtime::Runtime; use crate::types::{Broadcaster, PaymentStore, PendingPaymentStore}; @@ -186,29 +189,13 @@ impl Wallet { let mut locked_wallet = self.inner.lock().expect("lock"); - let chain_tip1 = locked_wallet.latest_checkpoint().block_id(); - let wallet_txs1 = locked_wallet - .transactions() - .map(|wtx| (wtx.tx_node.txid, (wtx.tx_node.tx.clone(), wtx.chain_position))) - .collect::, bdk_chain::ChainPosition), - >>(); - - locked_wallet.apply_unconfirmed_txs(unconfirmed_txs); - locked_wallet.apply_evicted_txs(evicted_txids); - - let chain_tip2 = locked_wallet.latest_checkpoint().block_id(); - let wallet_txs2 = locked_wallet - .transactions() - .map(|wtx| (wtx.tx_node.txid, (wtx.tx_node.tx.clone(), wtx.chain_position))) - .collect::, bdk_chain::ChainPosition), - >>(); - - let events = - wallet_events(&mut *locked_wallet, chain_tip1, chain_tip2, wallet_txs1, wallet_txs2); + let events = locked_wallet + .events_helper(|wallet| -> Result<(), std::convert::Infallible> { + wallet.apply_unconfirmed_txs(unconfirmed_txs); + wallet.apply_evicted_txs(evicted_txids); + Ok(()) + }) + .expect("applying mempool updates cannot fail"); self.update_payment_store(&mut *locked_wallet, events).map_err(|e| { log_error!(self.logger, "Failed to update payment store: {}", e); @@ -274,6 +261,10 @@ impl Wallet { .find_payment_by_txid(txid) .unwrap_or_else(|| PaymentId(txid.to_byte_array())); + if self.apply_funding_status_update(payment_id, txid, confirmation_status)? { + continue; + } + let payment = self.create_payment_from_tx( locked_wallet, txid, @@ -327,6 +318,7 @@ impl Wallet { PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, + .. } if payment.details.direction == PaymentDirection::Outbound => { unconfirmed_outbound_txids.push(txid); }, @@ -362,11 +354,19 @@ impl Wallet { } } }, - WalletEvent::TxUnconfirmed { txid, tx, old_block_time: None } => { + WalletEvent::TxUnconfirmed { txid, tx, .. } => { let payment_id = self .find_payment_by_txid(txid) .unwrap_or_else(|| PaymentId(txid.to_byte_array())); + if self.apply_funding_status_update( + payment_id, + txid, + ConfirmationStatus::Unconfirmed, + )? { + continue; + } + let payment = self.create_payment_from_tx( locked_wallet, txid, @@ -417,6 +417,15 @@ impl Wallet { let payment_id = self .find_payment_by_txid(txid) .unwrap_or_else(|| PaymentId(txid.to_byte_array())); + + if self.apply_funding_status_update( + payment_id, + txid, + ConfirmationStatus::Unconfirmed, + )? { + continue; + } + let payment = self.create_payment_from_tx( locked_wallet, txid, @@ -513,11 +522,11 @@ impl Wallet { Ok(address_info.address) } - pub(crate) fn cancel_tx(&self, tx: &Transaction) -> Result<(), Error> { + pub(crate) fn cancel_tx(&self, tx: Transaction) -> Result<(), Error> { let mut locked_wallet = self.inner.lock().expect("lock"); let mut locked_persister = self.persister.lock().expect("lock"); - locked_wallet.cancel_tx(tx); + Self::cancel_tx_inner(&mut locked_wallet, tx); self.runtime.block_on(locked_wallet.persist_async(&mut locked_persister)).map_err(|e| { log_error!(self.logger, "Failed to persist wallet: {}", e); Error::PersistenceFailed @@ -526,6 +535,17 @@ impl Wallet { Ok(()) } + fn cancel_tx_inner( + locked_wallet: &mut PersistedWallet, tx: Transaction, + ) { + for txout in tx.output { + if let Some((keychain, index)) = locked_wallet.derivation_of_spk(txout.script_pubkey) { + // This mirrors the removed BDK helper: it only frees superficial usage marks. + locked_wallet.unmark_used(keychain, index); + } + } + } + pub(crate) fn get_balances( &self, total_anchor_channels_reserve_sats: u64, ) -> Result<(u64, u64), Error> { @@ -678,7 +698,7 @@ impl Wallet { None, )?; - locked_wallet.cancel_tx(&tmp_psbt.unsigned_tx); + Self::cancel_tx_inner(&mut locked_wallet, tmp_psbt.unsigned_tx); Ok(max_amount) } @@ -708,7 +728,7 @@ impl Wallet { Some(&shared_input), )?; - locked_wallet.cancel_tx(&tmp_psbt.unsigned_tx); + Self::cancel_tx_inner(&mut locked_wallet, tmp_psbt.unsigned_tx); Ok(splice_amount) } @@ -764,7 +784,7 @@ impl Wallet { e })?; - locked_wallet.cancel_tx(&tmp_psbt.unsigned_tx); + Self::cancel_tx_inner(&mut locked_wallet, tmp_psbt.unsigned_tx); let mut tx_builder = locked_wallet.build_tx(); tx_builder @@ -1100,9 +1120,13 @@ impl Wallet { let mut psbt = Psbt::from_unsigned_tx(unsigned_tx).map_err(|e| { log_error!(self.logger, "Failed to construct PSBT: {}", e); })?; + // Use list_output rather than get_utxo to include outputs spent by unconfirmed + // transactions (e.g., a prior splice being replaced via RBF), which a synced wallet would + // otherwise no longer treat as an owned UTXO. + let mut wallet_outputs: HashMap = + locked_wallet.list_output().map(|output| (output.outpoint, output)).collect(); 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); + if let Some(utxo) = wallet_outputs.remove(&txin.previous_output) { psbt.inputs[i] = locked_wallet.get_psbt_input(utxo, None, true).map_err(|e| { log_error!(self.logger, "Failed to construct PSBT input: {}", e); })?; @@ -1160,25 +1184,184 @@ impl Wallet { Ok(tx) } - fn create_payment_from_tx( - &self, locked_wallet: &PersistedWallet, txid: Txid, - payment_id: PaymentId, tx: &Transaction, payment_status: PaymentStatus, - confirmation_status: ConfirmationStatus, - ) -> PaymentDetails { - // 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. + /// Classifies a funding broadcast (channel open or splice) handed to the broadcaster by LDK, + /// recording a payment for it before it is sent. Other transaction types are left for wallet + /// sync to record normally. + pub(crate) async fn classify_broadcast( + &self, tx: &Transaction, tx_type: &LdkTransactionType, + ) -> Result<(), Error> { + match tx_type { + LdkTransactionType::Funding { channels } => { + self.classify_funding(tx, channels, tx_type.clone().into()).await + }, + LdkTransactionType::InteractiveFunding { candidates } => { + self.classify_interactive_funding(tx, candidates, tx_type.clone().into()).await + }, + _ => Ok(()), + } + } + + /// Records a single-channel funding (channel open) broadcast as a pending on-chain payment, + /// tagged with its transaction type. Amount and fee come from the wallet's view of the + /// transaction. Batched funding is left for wallet sync. + async fn classify_funding( + &self, tx: &Transaction, channels: &[(PublicKey, ChannelId)], tx_type: TransactionType, + ) -> Result<(), Error> { + if channels.len() != 1 { + if channels.len() > 1 { + log_trace!( + self.logger, + "Skipping funding classification for batched broadcast ({} channels)", + channels.len() + ); + } + return Ok(()); + } + + let (_counterparty_node_id, channel_id) = channels[0]; + let txid = tx.compute_txid(); + let (amount_msat, fee_paid_msat, direction) = self.onchain_payment_fields(tx); + + let payment_id = PaymentId(txid.to_byte_array()); + let details = PaymentDetails::new( + payment_id, + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(tx_type), + }, + amount_msat, + fee_paid_msat, + direction, + PaymentStatus::Pending, + ); + self.persist_funding_payment(details, Vec::new()).await?; + log_debug!( + self.logger, + "Recorded channel-funding broadcast {} for channel {}", + txid, + channel_id, + ); + Ok(()) + } + + /// Records an interactive-funding broadcast (splice, or a V2 dual-funded open) as a pending + /// on-chain payment, tagged with its transaction type. Amount and fee are this node's share, + /// derived from the active candidate's contributions; broadcasts we didn't contribute to, or + /// that don't move wallet funds, are left for wallet sync. + async fn classify_interactive_funding( + &self, tx: &Transaction, candidates: &[FundingCandidate], tx_type: TransactionType, + ) -> Result<(), Error> { + // `InteractiveFunding` carries the full negotiated history; the currently-broadcast + // candidate is the last entry, earlier entries are RBF predecessors. + let active = match candidates.last() { + Some(c) => c, + None => return Ok(()), + }; + let first = match candidates.first() { + Some(c) => c, + None => return Ok(()), + }; + + let txid = tx.compute_txid(); + debug_assert_eq!(active.txid, txid, "broadcast tx must match the active candidate"); + + let aggregate = aggregate_local_stakes(active); + let amount_msat = match aggregate.amount_msat { + Some(amt) => Some(amt), + None => { + log_trace!( + self.logger, + "Not recording interactive-funding broadcast {} as a payment: no local contribution", + txid, + ); + return Ok(()); + }, + }; + let fee_paid_msat = aggregate.fee_paid_msat; + let direction = aggregate.direction; + + // A contribution doesn't mean the tx touches our on-chain wallet: a splice-out to an + // external address sends channel funds to a third party, which BDK sees as zero wallet + // movement. Nothing for the on-chain payment store to record, so skip it. + let (wallet_amount_msat, _wallet_fee_msat, _wallet_direction) = + self.onchain_payment_fields(tx); + if wallet_amount_msat == Some(0) { + log_trace!( + self.logger, + "Not recording interactive-funding broadcast {} as a payment: no wallet-level activity", + txid, + ); + return Ok(()); + } + + // Anchor the `PaymentId` to the first negotiated candidate so the record stays stable + // across RBF replacements. + let payment_id = PaymentId(first.txid.to_byte_array()); - let kind = PaymentKind::Onchain { txid, status: confirmation_status }; + // Record every candidate's figures (`None` for any round we didn't contribute to, e.g. a + // counterparty-initiated splice our `splice_in` later joined via RBF) so the confirmed + // candidate's amount/fee can be applied on confirmation, even if it isn't the last one + // broadcast or one we contributed to. + let candidate_records: Vec = candidates + .iter() + .map(|candidate| { + let aggregate = aggregate_local_stakes(candidate); + FundingTxCandidate { + txid: candidate.txid, + amount_msat: aggregate.amount_msat, + fee_paid_msat: aggregate.fee_paid_msat, + } + }) + .collect(); + let details = PaymentDetails::new( + payment_id, + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(tx_type), + }, + amount_msat, + fee_paid_msat, + direction, + PaymentStatus::Pending, + ); + self.persist_funding_payment(details, candidate_records).await?; + log_debug!( + self.logger, + "Recorded interactive-funding broadcast {} ({} candidates, {} channels)", + txid, + candidates.len(), + active.channels.len(), + ); + Ok(()) + } + + /// Writes a freshly-classified funding payment to the authoritative payment store and adds a + /// pending-store index entry, so wallet sync graduates it through `ANTI_REORG_DELAY`. + async fn persist_funding_payment( + &self, details: PaymentDetails, candidates: Vec, + ) -> Result<(), Error> { + self.payment_store.insert_or_update(details.clone()).await?; + let pending = PendingPaymentDetails::new(details, Vec::new(), candidates); + self.pending_payment_store.insert_or_update(pending).await?; + Ok(()) + } + + /// Returns the wallet's view of a transaction as `(amount_msat, fee_msat, direction)`. + pub(crate) fn onchain_payment_fields( + &self, tx: &Transaction, + ) -> (Option, Option, PaymentDirection) { + let locked_wallet = self.inner.lock().expect("lock"); + self.onchain_payment_fields_locked(&locked_wallet, tx) + } + + /// [`Self::onchain_payment_fields`] against an already-locked wallet, so callers that hold the + /// lock (e.g. [`Self::create_payment_from_tx`]) can reuse the derivation without re-locking. + fn onchain_payment_fields_locked( + &self, locked_wallet: &PersistedWallet, tx: &Transaction, + ) -> (Option, Option, PaymentDirection) { let fee = locked_wallet.calculate_fee(tx).unwrap_or(Amount::ZERO); let (sent, received) = locked_wallet.sent_and_received(tx); let fee_sat = fee.to_sat(); @@ -1200,20 +1383,38 @@ impl Wallet { ) }; - PaymentDetails::new( - payment_id, - kind, - amount_msat, - Some(fee_sat * 1000), - direction, - payment_status, - ) + (amount_msat, Some(fee_sat * 1000), direction) + } + + fn create_payment_from_tx( + &self, locked_wallet: &PersistedWallet, txid: Txid, + payment_id: PaymentId, tx: &Transaction, payment_status: PaymentStatus, + confirmation_status: ConfirmationStatus, + ) -> PaymentDetails { + // 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 = PaymentKind::Onchain { txid, status: confirmation_status, tx_type: None }; + + let (amount_msat, fee_paid_msat, direction) = + self.onchain_payment_fields_locked(locked_wallet, tx); + + PaymentDetails::new(payment_id, kind, amount_msat, fee_paid_msat, direction, payment_status) } fn create_pending_payment_from_tx( &self, payment: PaymentDetails, conflicting_txids: Vec, ) -> PendingPaymentDetails { - PendingPaymentDetails::new(payment, conflicting_txids) + PendingPaymentDetails::new(payment, conflicting_txids, Vec::new()) } fn find_payment_by_txid(&self, target_txid: Txid) -> Option { @@ -1236,15 +1437,81 @@ impl Wallet { None } + /// If `payment_id` refers to a classified funding payment, refreshes its confirmation status + /// and the candidate txid the event refers to, while preserving the contribution-derived + /// amount/fee and `tx_type` that wallet sync must not recompute from its own view: the wallet's + /// `sent`/`received` don't capture our contribution to a shared funding output. Returns `true` + /// when it handled the payment, so the caller skips the default on-chain path. Graduation to + /// `Succeeded` is left to `ChainTipChanged` after `ANTI_REORG_DELAY`. + fn apply_funding_status_update( + &self, payment_id: PaymentId, event_txid: Txid, confirmation_status: ConfirmationStatus, + ) -> Result { + let Some(mut payment) = self.payment_store.get(&payment_id) else { + return Ok(false); + }; + let tx_type = match &payment.kind { + PaymentKind::Onchain { + tx_type: + tx_type @ Some( + TransactionType::Funding { .. } + | TransactionType::InteractiveFunding { .. }, + ), + .. + } => tx_type.clone(), + _ => return Ok(false), + }; + // Report the figures of the candidate that actually confirmed, which need not be the last + // one broadcast (an earlier, lower-fee candidate may win) and may carry no figures at all + // (`None`) for a round we didn't contribute to. (`direction` is invariant across a splice's + // candidates and cannot be changed through the store anyway.) + if let Some(pending) = self.pending_payment_store.get(&payment_id) { + if let Some(candidate) = pending.candidate(event_txid) { + payment.amount_msat = candidate.amount_msat; + payment.fee_paid_msat = candidate.fee_paid_msat; + } + } + + payment.kind = + PaymentKind::Onchain { txid: event_txid, status: confirmation_status, tx_type }; + self.runtime.block_on(self.payment_store.insert_or_update(payment.clone()))?; + // Mirror the refreshed confirmation status onto the pending entry: `ChainTipChanged` + // graduates by reading the pending entry's details, so it must see the new status. This is + // the same dual-write the default `TxConfirmed` path performs; an empty conflicting-txids + // list leaves any stored conflicts intact (the update treats absent as "unchanged"). + if payment.status == PaymentStatus::Pending { + let pending = self.create_pending_payment_from_tx(payment, Vec::new()); + self.runtime.block_on(self.pending_payment_store.insert_or_update(pending))?; + } + Ok(true) + } + #[allow(deprecated)] pub(crate) fn bump_fee_rbf( - &self, payment_id: PaymentId, fee_rate: Option, + &self, payment_id: PaymentId, fee_rate: Option, cur_anchor_reserve_sats: u64, ) -> Result { let payment = self.payment_store.get(&payment_id).ok_or_else(|| { log_error!(self.logger, "Payment {} not found in payment store", payment_id); Error::InvalidPaymentId })?; + // Funding transactions (channel opens and splices) are driven by LDK's funding/splice + // lifecycle, not the on-chain wallet. Replacing one via on-chain RBF would broadcast a + // transaction LDK isn't tracking (and, for splices, can't sign). Fee-bumping a pending + // splice goes through `bump_channel_funding_fee` instead. + if let PaymentKind::Onchain { + tx_type: + Some(TransactionType::Funding { .. } | TransactionType::InteractiveFunding { .. }), + .. + } = &payment.kind + { + log_error!( + self.logger, + "Cannot RBF funding payment {} via bump_fee_rbf; use bump_channel_funding_fee instead", + payment_id, + ); + return Err(Error::InvalidPaymentId); + } + if let PaymentKind::Onchain { status, .. } = &payment.kind { match status { ConfirmationStatus::Confirmed { .. } => { @@ -1386,6 +1653,41 @@ impl Wallet { }? }; + let old_fee_sats = locked_wallet + .calculate_fee(&old_tx) + .map_err(|e| { + log_error!(self.logger, "Failed to calculate fee of transaction {}: {}", txid, e); + Error::WalletOperationFailed + })? + .to_sat(); + let replacement_fee_sats = locked_wallet + .calculate_fee(&psbt.unsigned_tx) + .map_err(|e| { + log_error!( + self.logger, + "Failed to calculate fee of replacement transaction for {}: {}", + txid, + e + ); + Error::WalletOperationFailed + })? + .to_sat(); + let additional_fee_sats = replacement_fee_sats.saturating_sub(old_fee_sats); + let balance = locked_wallet.balance(); + let spendable_amount_sats = + self.get_balances_inner(balance, cur_anchor_reserve_sats).map(|(_, s)| s).unwrap_or(0); + if spendable_amount_sats < additional_fee_sats { + log_error!( + self.logger, + "Unable to bump fee due to insufficient reserve-preserving funds. \ + Available: {}sats, required additional fee: {}sats, reserve: {}sats", + spendable_amount_sats, + additional_fee_sats, + cur_anchor_reserve_sats, + ); + return Err(Error::InsufficientFunds); + } + match locked_wallet.sign(&mut psbt, SignOptions::default()) { Ok(finalized) => { if !finalized { @@ -1444,6 +1746,48 @@ impl Wallet { } } +struct LocalStakeAggregate { + amount_msat: Option, + fee_paid_msat: Option, + direction: PaymentDirection, +} + +/// Aggregates our net stake across the channels of a single [`FundingCandidate`] by summing each +/// channel's signed [`FundingContribution::net_value`]. Returns no amount if we contributed to none +/// of them. +fn aggregate_local_stakes(candidate: &FundingCandidate) -> LocalStakeAggregate { + let mut net_stake = SignedAmount::ZERO; + let mut fee = Amount::ZERO; + let mut have_contribution = false; + for channel in &candidate.channels { + if let Some(contribution) = channel.contribution.as_ref() { + have_contribution = true; + net_stake += contribution.net_value(); + // `estimated_fee` is our per-contributor share, so summing across channels is correct. + fee += contribution.estimated_fee(); + } + } + if !have_contribution { + return LocalStakeAggregate { + amount_msat: None, + fee_paid_msat: None, + direction: PaymentDirection::Outbound, + }; + } + // Direction is from our on-chain wallet's perspective: a positive net stake funds the channel + // (Outbound), while a negative one is a splice-out that returns funds to the wallet (Inbound). + let direction = if net_stake >= SignedAmount::ZERO { + PaymentDirection::Outbound + } else { + PaymentDirection::Inbound + }; + LocalStakeAggregate { + amount_msat: Some(net_stake.unsigned_abs().to_sat() * 1000), + fee_paid_msat: Some(fee.to_sat() * 1000), + direction, + } +} + impl Listen for Wallet { fn filtered_block_connected( &self, _header: &bitcoin::block::Header, @@ -1755,105 +2099,3 @@ fn ldk_to_bdk_satisfaction_weight(ldk_satisfaction_weight: u64) -> Weight { .saturating_sub(EMPTY_SCRIPT_SIG_WEIGHT + EMPTY_WITNESS_COUNT_WEIGHT), ) } - -// FIXME/TODO: This is copied-over from bdk_wallet and only used to generate `WalletEvent`s after -// applying mempool transactions. We should drop this when BDK offers to generate events for -// mempool transactions natively. -pub(crate) fn wallet_events( - wallet: &mut bdk_wallet::Wallet, chain_tip1: bdk_chain::BlockId, - chain_tip2: bdk_chain::BlockId, - wallet_txs1: std::collections::BTreeMap< - Txid, - (Arc, bdk_chain::ChainPosition), - >, - wallet_txs2: std::collections::BTreeMap< - Txid, - (Arc, bdk_chain::ChainPosition), - >, -) -> Vec { - let mut events: Vec = Vec::new(); - - if chain_tip1 != chain_tip2 { - events.push(WalletEvent::ChainTipChanged { old_tip: chain_tip1, new_tip: chain_tip2 }); - } - - wallet_txs2.iter().for_each(|(txid2, (tx2, cp2))| { - if let Some((tx1, cp1)) = wallet_txs1.get(txid2) { - assert_eq!(tx1.compute_txid(), *txid2); - match (cp1, cp2) { - ( - bdk_chain::ChainPosition::Unconfirmed { .. }, - bdk_chain::ChainPosition::Confirmed { anchor, .. }, - ) => { - events.push(WalletEvent::TxConfirmed { - txid: *txid2, - tx: tx2.clone(), - block_time: *anchor, - old_block_time: None, - }); - }, - ( - bdk_chain::ChainPosition::Confirmed { anchor, .. }, - bdk_chain::ChainPosition::Unconfirmed { .. }, - ) => { - events.push(WalletEvent::TxUnconfirmed { - txid: *txid2, - tx: tx2.clone(), - old_block_time: Some(*anchor), - }); - }, - ( - bdk_chain::ChainPosition::Confirmed { anchor: anchor1, .. }, - bdk_chain::ChainPosition::Confirmed { anchor: anchor2, .. }, - ) => { - if *anchor1 != *anchor2 { - events.push(WalletEvent::TxConfirmed { - txid: *txid2, - tx: tx2.clone(), - block_time: *anchor2, - old_block_time: Some(*anchor1), - }); - } - }, - ( - bdk_chain::ChainPosition::Unconfirmed { .. }, - bdk_chain::ChainPosition::Unconfirmed { .. }, - ) => { - // do nothing if still unconfirmed - }, - } - } else { - match cp2 { - bdk_chain::ChainPosition::Confirmed { anchor, .. } => { - events.push(WalletEvent::TxConfirmed { - txid: *txid2, - tx: tx2.clone(), - block_time: *anchor, - old_block_time: None, - }); - }, - bdk_chain::ChainPosition::Unconfirmed { .. } => { - events.push(WalletEvent::TxUnconfirmed { - txid: *txid2, - tx: tx2.clone(), - old_block_time: None, - }); - }, - } - } - }); - - // find tx that are no longer canonical - wallet_txs1.iter().for_each(|(txid1, (tx1, _))| { - if !wallet_txs2.contains_key(txid1) { - let conflicts = wallet.tx_graph().direct_conflicts(tx1).collect::>(); - if !conflicts.is_empty() { - events.push(WalletEvent::TxReplaced { txid: *txid1, tx: tx1.clone(), conflicts }); - } else { - events.push(WalletEvent::TxDropped { txid: *txid1, tx: tx1.clone() }); - } - } - }); - - events -} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index d7775e67b3..a56d46e056 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -50,7 +50,7 @@ use ldk_node::{ use lightning::io; use lightning::ln::msgs::SocketAddress; use lightning::routing::gossip::NodeAlias; -use lightning::util::persist::KVStore; +use lightning::util::persist::{KVStore, PageToken, PaginatedKVStore, PaginatedListResponse}; use lightning_invoice::{Bolt11InvoiceDescription, Description}; use lightning_persister::fs_store::v1::FilesystemStore; use lightning_types::payment::{PaymentHash, PaymentPreimage}; @@ -435,7 +435,8 @@ pub(crate) struct TestConfig { pub store_type: TestStoreType, pub node_entropy: NodeEntropy, pub async_payments_role: Option, - pub recovery_mode: bool, + pub wallet_rescan_from_height: Option, + pub force_wallet_full_scan: bool, } impl Default for TestConfig { @@ -447,14 +448,16 @@ impl Default for TestConfig { let mnemonic = generate_entropy_mnemonic(None); let node_entropy = NodeEntropy::from_bip39_mnemonic(mnemonic, None); let async_payments_role = None; - let recovery_mode = false; + let wallet_rescan_from_height = None; + let force_wallet_full_scan = false; TestConfig { node_config, log_writer, store_type, node_entropy, async_payments_role, - recovery_mode, + wallet_rescan_from_height, + force_wallet_full_scan, } } } @@ -537,12 +540,14 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) -> let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); let mut sync_config = EsploraSyncConfig::default(); sync_config.background_sync_config = None; + sync_config.force_wallet_full_scan = config.force_wallet_full_scan; builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); }, TestChainSource::Electrum(electrsd) => { let electrum_url = format!("tcp://{}", electrsd.electrum_url); let mut sync_config = ElectrumSyncConfig::default(); sync_config.background_sync_config = None; + sync_config.force_wallet_full_scan = config.force_wallet_full_scan; builder.set_chain_source_electrum(electrum_url.clone(), Some(sync_config)); }, TestChainSource::BitcoindRpcSync(bitcoind) => { @@ -551,7 +556,13 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) -> let values = bitcoind.params.get_cookie_values().unwrap().unwrap(); let rpc_user = values.user; let rpc_password = values.password; - builder.set_chain_source_bitcoind_rpc(rpc_host, rpc_port, rpc_user, rpc_password); + builder.set_chain_source_bitcoind_rpc( + rpc_host, + rpc_port, + rpc_user, + rpc_password, + config.wallet_rescan_from_height, + ); }, TestChainSource::BitcoindRestSync(bitcoind) => { let rpc_host = bitcoind.params.rpc_socket.ip().to_string(); @@ -568,6 +579,7 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) -> rpc_port, rpc_user, rpc_password, + config.wallet_rescan_from_height, ); }, } @@ -586,10 +598,6 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) -> builder.set_async_payments_role(config.async_payments_role).unwrap(); - if config.recovery_mode { - builder.set_wallet_recovery_mode(); - } - let node = match config.store_type { TestStoreType::TestSyncStore => { let kv_store = TestSyncStore::new(config.node_config.storage_dir_path.into()); @@ -601,10 +609,6 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) -> }, }; - if config.recovery_mode { - builder.set_wallet_recovery_mode(); - } - node.start().unwrap(); assert!(node.status().is_running); assert!(node.status().latest_fee_rate_cache_update_timestamp.is_some()); @@ -1364,7 +1368,9 @@ pub(crate) async fn do_channel_full_cycle( println!("\nB splices out to pay A"); let addr_a = node_a.onchain_payment().new_address().unwrap(); - let splice_out_sat = funding_amount_sat / 2; + let available_splice_out_sat = node_b.list_channels()[0].outbound_capacity_msat / 1000; + let splice_out_sat = available_splice_out_sat / 2; + assert!(splice_out_sat > 500_000); node_b.splice_out(&user_channel_id_b, node_a.node_id(), &addr_a, splice_out_sat).unwrap(); expect_splice_negotiated_event!(node_a, node_b.node_id()); @@ -1700,6 +1706,21 @@ impl KVStore for TestSyncStore { } } +impl PaginatedKVStore for TestSyncStore { + fn list_paginated( + &self, primary_namespace: &str, secondary_namespace: &str, page_token: Option, + ) -> impl Future> + 'static + Send { + let primary_namespace = primary_namespace.to_string(); + let secondary_namespace = secondary_namespace.to_string(); + let inner = Arc::clone(&self.inner); + async move { + inner + .list_paginated_internal_async(&primary_namespace, &secondary_namespace, page_token) + .await + } + } +} + struct TestSyncStoreInner { serializer: tokio::sync::RwLock<()>, test_store: InMemoryStore, @@ -1763,6 +1784,37 @@ impl TestSyncStoreInner { self.do_list_async(primary_namespace, secondary_namespace).await } + async fn list_paginated_internal_async( + &self, primary_namespace: &str, secondary_namespace: &str, page_token: Option, + ) -> lightning::io::Result { + let _guard = self.serializer.read().await; + let sqlite_res = PaginatedKVStore::list_paginated( + &self.sqlite_store, + primary_namespace, + secondary_namespace, + page_token.clone(), + ) + .await; + let test_res = PaginatedKVStore::list_paginated( + &self.test_store, + primary_namespace, + secondary_namespace, + page_token, + ) + .await; + + match sqlite_res { + Ok(sqlite_response) => { + assert_eq!(sqlite_response, test_res.unwrap()); + Ok(sqlite_response) + }, + Err(e) => { + assert!(test_res.is_err()); + Err(e) + }, + } + } + async fn read_internal_async( &self, primary_namespace: &str, secondary_namespace: &str, key: &str, ) -> lightning::io::Result> { @@ -1874,3 +1926,26 @@ impl TestSyncStoreInner { } } } + +/// The PostgreSQL connection string used by the Postgres-backed tests, overridable via the +/// `TEST_POSTGRES_URL` environment variable. +#[cfg(feature = "postgres")] +pub(crate) fn test_connection_string() -> String { + std::env::var("TEST_POSTGRES_URL") + .unwrap_or_else(|_| "host=localhost user=postgres password=postgres".to_string()) +} + +/// Drops the given table from the `ldk_db` database, ignoring the case where the database doesn't +/// exist yet. Used to ensure a clean slate before and after Postgres-backed tests. +#[cfg(feature = "postgres")] +pub(crate) async fn drop_table(table_name: &str) { + let connection_string = format!("{} dbname=ldk_db", test_connection_string()); + let Ok((client, connection)) = + tokio_postgres::connect(&connection_string, tokio_postgres::NoTls).await + else { + // Database doesn't exist yet — nothing to drop. + return; + }; + tokio::spawn(connection); + let _ = client.execute(&format!("DROP TABLE IF EXISTS {table_name}"), &[]).await; +} diff --git a/tests/integration_tests_migration.rs b/tests/integration_tests_migration.rs new file mode 100644 index 0000000000..ee5ad26c8e --- /dev/null +++ b/tests/integration_tests_migration.rs @@ -0,0 +1,263 @@ +// 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. + +// The migration test exercises the filesystem, SQLite, and Postgres stores. It is gated on the +// `postgres` feature because Postgres is the only one of the three that needs an external service. +#![cfg(feature = "postgres")] + +mod common; + +use std::path::PathBuf; + +use common::{ + drop_table, expect_channel_ready_event, expect_payment_received_event, + expect_payment_successful_event, test_connection_string, +}; +use ldk_node::entropy::NodeEntropy; +use ldk_node::io::postgres_store::PostgresStore; +use ldk_node::io::sqlite_store::{SqliteStore, KV_TABLE_NAME, SQLITE_DB_FILE_NAME}; +use ldk_node::{Builder, Event}; +use lightning::util::persist::migrate_kv_store_data_async; +use lightning_invoice::{Bolt11InvoiceDescription, Description}; +use lightning_persister::fs_store::v2::FilesystemStoreV2; +use rand::seq::SliceRandom; + +async fn drop_tables<'a>(table_names: impl IntoIterator) { + for table_name in table_names { + drop_table(table_name).await; + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum MigrationBackend { + FilesystemStore, + Sqlite, + Postgres, +} + +struct BackendInstance { + backend: MigrationBackend, + path: String, + connection_string: String, + table: String, +} + +impl BackendInstance { + fn new( + backend: MigrationBackend, base_dir: &str, connection_string: &str, table: &str, + ) -> Self { + let path = match backend { + MigrationBackend::FilesystemStore => format!("{base_dir}/fs_store"), + MigrationBackend::Sqlite => format!("{base_dir}/sqlite_store"), + MigrationBackend::Postgres => base_dir.to_string(), + }; + BackendInstance { + backend, + path, + connection_string: connection_string.to_string(), + table: table.to_string(), + } + } +} + +macro_rules! with_opened_store { + ($instance:expr, |$store:ident| $body:expr) => {{ + let instance = $instance; + match instance.backend { + MigrationBackend::FilesystemStore => { + let $store = open_fs_store(&instance.path); + $body + }, + MigrationBackend::Sqlite => { + let $store = open_sqlite_store(&instance.path); + $body + }, + MigrationBackend::Postgres => { + let $store = + open_postgres_store(&instance.connection_string, &instance.table).await; + $body + }, + } + }}; +} + +async fn build_migration_node( + instance: &BackendInstance, node_config: ldk_node::config::Config, node_entropy: NodeEntropy, + esplora_url: &str, +) -> ldk_node::Node { + let mut builder = Builder::from_config(node_config); + builder.set_chain_source_esplora(esplora_url.to_string(), None); + with_opened_store!(instance, |store| builder.build_with_store(node_entropy, store).unwrap()) +} + +fn open_fs_store(data_dir: &str) -> FilesystemStoreV2 { + std::fs::create_dir_all(data_dir).unwrap(); + FilesystemStoreV2::new(PathBuf::from(data_dir)).unwrap() +} + +fn open_sqlite_store(data_dir: &str) -> SqliteStore { + std::fs::create_dir_all(data_dir).unwrap(); + SqliteStore::new( + PathBuf::from(data_dir), + Some(SQLITE_DB_FILE_NAME.to_string()), + Some(KV_TABLE_NAME.to_string()), + ) + .unwrap() +} + +async fn open_postgres_store(connection_string: &str, table: &str) -> PostgresStore { + PostgresStore::new(connection_string.to_string(), None, Some(table.to_string()), None) + .await + .unwrap() +} + +/// Migrates all data from a freshly-opened handle on the `source` backend to a freshly-opened +/// handle on the `dest` backend. The node owning the source store must be stopped beforehand. +async fn migrate_between_backends(source: &BackendInstance, dest: &BackendInstance) { + with_opened_store!(source, |source_store| { + with_opened_store!(dest, |dest_store| { + migrate_kv_store_data_async(&source_store, &dest_store).await.unwrap(); + }) + }) +} + +/// Spins up a node on a KV store backend, creates some on-chain and Lightning transaction history, +/// then migrates its data through every other backend in turn. After each migration it restarts +/// the node on the new backend and verifies that the node identity, on-chain balance, channel, and +/// payment history are all preserved. +/// +/// The order in which the backends are visited is randomized. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn migrate_node_across_all_backends() { + let mut order = + [MigrationBackend::FilesystemStore, MigrationBackend::Sqlite, MigrationBackend::Postgres]; + order.shuffle(&mut rand::rng()); + println!("Migrating node across backends in order: {:?}", order); + + // Tables we might use: one per hop plus node B's. (Only the Postgres hops actually use them.) + let tables: Vec = (0..order.len()).map(|i| format!("migrate_chain_{i}")).collect(); + let node_b_table = "migrate_chain_node_b".to_string(); + drop_tables(tables.iter().chain(std::iter::once(&node_b_table))).await; + + let (bitcoind, electrsd) = common::setup_bitcoind_and_electrsd(); + let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); + let connection_string = test_connection_string(); + + // Set up node B, the Lightning counterparty. + let config_b = common::random_config(false); + let node_b_instance = BackendInstance::new( + MigrationBackend::Postgres, + &config_b.node_config.storage_dir_path, + &connection_string, + &node_b_table, + ); + let node_b = build_migration_node( + &node_b_instance, + config_b.node_config, + config_b.node_entropy, + &esplora_url, + ) + .await; + node_b.start().unwrap(); + + // Spin up the node we'll migrate on the first backend. The same node config (storage dir, + // listening addresses, identity) is reused across every hop — only the backend changes — so + // each backend's store lives in its own subdirectory of the one storage dir. + let config = common::random_config(false); + let node_entropy = config.node_entropy; + let node_config = config.node_config; + let base_dir = node_config.storage_dir_path.clone(); + + let mut current = BackendInstance::new(order[0], &base_dir, &connection_string, &tables[0]); + let mut node = + build_migration_node(¤t, node_config.clone(), node_entropy, &esplora_url).await; + node.start().unwrap(); + let expected_node_id = node.node_id(); + + // On-chain receive: fund the node. + let addr = node.onchain_payment().new_address().unwrap(); + common::premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![addr], + bitcoin::Amount::from_sat(1_000_000), + ) + .await; + node.sync_wallets().unwrap(); + + // Open a channel to node B (pushing half so both sides can route) and let it confirm. + common::open_channel_push_amt(&node, &node_b, 200_000, Some(100_000_000), false, &electrsd) + .await; + common::generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + expect_channel_ready_event!(node, node_b.node_id()); + expect_channel_ready_event!(node_b, node.node_id()); + + // Lightning send: node -> node B. + let description = + Bolt11InvoiceDescription::Direct(Description::new("ln send".to_string()).unwrap()); + let invoice = node_b.bolt11_payment().receive(10_000, &description.into(), 3600).unwrap(); + let ln_send_id = node.bolt11_payment().send(&invoice, None).unwrap(); + expect_payment_successful_event!(node, Some(ln_send_id), None); + expect_payment_received_event!(node_b, 10_000); + + // Lightning receive: node B -> node. + let description = + Bolt11InvoiceDescription::Direct(Description::new("ln receive".to_string()).unwrap()); + let invoice = node.bolt11_payment().receive(5_000, &description.into(), 3600).unwrap(); + let ln_receive_id = node_b.bolt11_payment().send(&invoice, None).unwrap(); + expect_payment_successful_event!(node_b, Some(ln_receive_id), None); + expect_payment_received_event!(node, 5_000); + + // On-chain send: node -> a foreign address. + let bitcoind_addr = bitcoind.client.new_address().unwrap(); + let txid = node.onchain_payment().send_to_address(&bitcoind_addr, 50_000, None).unwrap(); + common::wait_for_tx(&electrsd.client, txid).await; + common::generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node.sync_wallets().unwrap(); + + // Capture the state we expect to survive every migration. + let expected_balance_sats = node.list_balances().total_onchain_balance_sats; + let expected_ln_balance_sats = node.list_balances().total_lightning_balance_sats; + let mut expected_payments = node.list_payments(); + expected_payments.sort_by_key(|p| p.id.0); + assert!(expected_payments.len() >= 4); + + for (i, &next_backend) in order.iter().enumerate().skip(1) { + println!("Migrating from {:?} to {:?}", current.backend, next_backend); + + let next = BackendInstance::new(next_backend, &base_dir, &connection_string, &tables[i]); + + // Spin the node down so the source store is no longer being written to. + node.stop().unwrap(); + drop(node); + + migrate_between_backends(¤t, &next).await; + + // Spin the node back up on the new backend. + node = build_migration_node(&next, node_config.clone(), node_entropy, &esplora_url).await; + node.start().unwrap(); + node.sync_wallets().unwrap(); + + // The balance, channel, and transaction history are preserved across the migration. + assert_eq!(node.node_id(), expected_node_id); + assert_eq!(node.list_balances().total_onchain_balance_sats, expected_balance_sats); + assert_eq!(node.list_balances().total_lightning_balance_sats, expected_ln_balance_sats); + assert_eq!(node.list_channels().len(), 1); + let mut migrated_payments = node.list_payments(); + migrated_payments.sort_by_key(|p| p.id.0); + assert_eq!(migrated_payments, expected_payments); + + current = next; + } + + node.stop().unwrap(); + node_b.stop().unwrap(); + + drop_tables(tables.iter().chain(std::iter::once(&node_b_table))).await; +} diff --git a/tests/integration_tests_postgres.rs b/tests/integration_tests_postgres.rs index b96b0c277c..0c93c705c2 100644 --- a/tests/integration_tests_postgres.rs +++ b/tests/integration_tests_postgres.rs @@ -9,27 +9,11 @@ mod common; +use common::{drop_table, test_connection_string}; use ldk_node::entropy::NodeEntropy; use ldk_node::Builder; use rand::RngCore; -fn test_connection_string() -> String { - std::env::var("TEST_POSTGRES_URL") - .unwrap_or_else(|_| "host=localhost user=postgres password=postgres".to_string()) -} - -async fn drop_table(table_name: &str) { - let connection_string = format!("{} dbname=ldk_db", test_connection_string()); - let Ok((client, connection)) = - tokio_postgres::connect(&connection_string, tokio_postgres::NoTls).await - else { - // Database doesn't exist yet — nothing to drop. - return; - }; - tokio::spawn(connection); - let _ = client.execute(&format!("DROP TABLE IF EXISTS {table_name}"), &[]).await; -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn channel_full_cycle_with_postgres_store() { drop_table("channel_cycle_a").await; diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 91cc8f3620..c3c2f4262b 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -21,32 +21,62 @@ use common::{ expect_channel_pending_event, expect_channel_ready_event, expect_channel_ready_events, expect_event, expect_payment_claimable_event, expect_payment_received_event, expect_payment_successful_event, expect_splice_negotiated_event, generate_blocks_and_wait, - generate_listening_addresses, open_channel, open_channel_push_amt, open_channel_with_all, - premine_and_distribute_funds, premine_blocks, prepare_rbf, random_chain_source, random_config, - setup_bitcoind_and_electrsd, setup_builder, setup_node, setup_two_nodes, splice_in_with_all, - wait_for_tx, TestChainSource, TestConfig, TestStoreType, TestSyncStore, + generate_listening_addresses, invalidate_blocks, open_channel, open_channel_push_amt, + open_channel_with_all, premine_and_distribute_funds, premine_blocks, prepare_rbf, + random_chain_source, random_config, setup_bitcoind_and_electrsd, setup_builder, setup_node, + setup_two_nodes, splice_in_with_all, wait_for_block, wait_for_tx, TestChainSource, TestConfig, + TestStoreType, TestSyncStore, }; -use electrsd::corepc_node::Node as BitcoinD; +use electrsd::corepc_node::{self, Node as BitcoinD}; use electrsd::ElectrsD; use ldk_node::config::{AsyncPaymentsRole, EsploraSyncConfig}; use ldk_node::entropy::NodeEntropy; use ldk_node::liquidity::LSPS2ServiceConfig; use ldk_node::payment::{ ConfirmationStatus, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, - UnifiedPaymentResult, + TransactionType, UnifiedPaymentResult, }; -use ldk_node::{Builder, Event, NodeError}; +use ldk_node::{BuildError, Builder, Event, Node, NodeError}; use lightning::ln::channelmanager::PaymentId; use lightning::routing::gossip::{NodeAlias, NodeId}; use lightning::routing::router::RouteParametersConfig; use lightning_invoice::{Bolt11InvoiceDescription, Description}; use lightning_types::payment::{PaymentHash, PaymentPreimage}; use log::LevelFilter; +use serde_json::json; + +/// Waits until `node` has classified the funding broadcast `funding_txid` (a channel open or splice +/// candidate) into a payment record carrying a `tx_type`. Classification runs off the broadcaster's +/// queue, which can lag a `sync_wallets` call under load — and for a splice the counterparty also +/// broadcasts the same tx, so a racing sync can see it before this node classifies. Waiting here +/// keeps the next sync on the funding short-circuit instead of recording a generic on-chain payment +/// that clobbers the classification. +async fn wait_for_classified_funding_payment(node: &Node, funding_txid: Txid) { + let poll = async { + loop { + let classified = node.list_payments().into_iter().any(|p| { + matches!( + p.kind, + PaymentKind::Onchain { txid, tx_type: Some(_), .. } if txid == funding_txid + ) + }); + if classified { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + }; + tokio::time::timeout(std::time::Duration::from_secs(common::INTEROP_TIMEOUT_SECS), poll) + .await + .unwrap_or_else(|_| { + panic!("timed out waiting for funding broadcast {} to be classified", funding_txid) + }); +} #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn channel_full_cycle() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); - let chain_source = random_chain_source(&bitcoind, &electrsd); + let chain_source = TestChainSource::BitcoindRpcSync(&bitcoind); let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); do_channel_full_cycle( node_a, @@ -304,6 +334,101 @@ async fn multi_hop_sending() { expect_payment_successful_event!(nodes[0], payment_id, Some(fee_paid_msat)); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn split_underpaid_bolt11_payment() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let node_c = setup_node(&chain_source, random_config(true)); + + let addr_a = node_a.onchain_payment().new_address().unwrap(); + let addr_b = node_b.onchain_payment().new_address().unwrap(); + let addr_c = node_c.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 5_000_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![addr_a, addr_b, addr_c], + Amount::from_sat(premine_amount_sat), + ) + .await; + + for node in [&node_a, &node_b, &node_c] { + node.sync_wallets().unwrap(); + assert_eq!(node.list_balances().spendable_onchain_balance_sats, premine_amount_sat); + } + + // The receiver opens both channels and pushes liquidity to both payers so each payer can send + // half of the invoice back. + let channel_amount_sat = 1_000_000; + let push_amount_msat = Some(500_000_000); + for payer in [&node_a, &node_b] { + node_c + .open_channel( + payer.node_id(), + payer.listening_addresses().unwrap().first().unwrap().clone(), + channel_amount_sat, + push_amount_msat, + None, + ) + .unwrap(); + + let funding_txo_c = expect_channel_pending_event!(node_c, payer.node_id()); + let funding_txo_payer = expect_channel_pending_event!(payer, node_c.node_id()); + assert_eq!(funding_txo_c, funding_txo_payer); + wait_for_tx(&electrsd.client, funding_txo_c.txid).await; + + node_c.sync_wallets().unwrap(); + } + + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + + for node in [&node_a, &node_b, &node_c] { + node.sync_wallets().unwrap(); + } + + expect_channel_ready_events!(node_c, node_a.node_id(), node_b.node_id()); + expect_channel_ready_event!(node_a, node_c.node_id()); + expect_channel_ready_event!(node_b, node_c.node_id()); + + let amount_msat = 100_000_000; + let half_amount_msat = amount_msat / 2; + let invoice_description = + Bolt11InvoiceDescription::Direct(Description::new(String::from("split")).unwrap()); + let invoice = + node_c.bolt11_payment().receive(amount_msat, &invoice_description.into(), 3600).unwrap(); + + // Each payer sends only half the invoice amount, while declaring the full invoice amount as + // the total MPP value. The receiver should claim only once both HTLCs arrive. + let payment_id_a = node_a + .bolt11_payment() + .send_using_amount_underpaying(&invoice, half_amount_msat, None) + .unwrap(); + let payment_id_b = node_b + .bolt11_payment() + .send_using_amount_underpaying(&invoice, half_amount_msat, None) + .unwrap(); + + let receiver_payment_id = expect_payment_received_event!(node_c, amount_msat); + assert_eq!(receiver_payment_id, Some(PaymentId(invoice.payment_hash().0))); + expect_payment_successful_event!(node_a, Some(payment_id_a), None); + expect_payment_successful_event!(node_b, Some(payment_id_b), None); + + // The receiver records the full invoice amount; each payer records only its own half. + let receiver_payments = + node_c.list_payments_with_filter(|p| p.id == receiver_payment_id.unwrap()); + assert_eq!(receiver_payments.len(), 1); + assert_eq!(receiver_payments.first().unwrap().amount_msat, Some(amount_msat)); + + let node_a_payments = node_a.list_payments_with_filter(|p| p.id == payment_id_a); + assert_eq!(node_a_payments.len(), 1); + assert_eq!(node_a_payments.first().unwrap().amount_msat, Some(half_amount_msat)); + + let node_b_payments = node_b.list_payments_with_filter(|p| p.id == payment_id_b); + assert_eq!(node_b_payments.len(), 1); + assert_eq!(node_b_payments.first().unwrap().amount_msat, Some(half_amount_msat)); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn start_stop_reinit() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); @@ -513,7 +638,7 @@ async fn onchain_send_receive() { let payment_a = node_a.payment(&payment_id).unwrap(); match payment_a.kind { - PaymentKind::Onchain { txid: _txid, status } => { + PaymentKind::Onchain { txid: _txid, status, .. } => { assert_eq!(_txid, txid); assert!(matches!(status, ConfirmationStatus::Confirmed { .. })); }, @@ -522,7 +647,7 @@ async fn onchain_send_receive() { let payment_b = node_a.payment(&payment_id).unwrap(); match payment_b.kind { - PaymentKind::Onchain { txid: _txid, status } => { + PaymentKind::Onchain { txid: _txid, status, .. } => { assert_eq!(_txid, txid); assert!(matches!(status, ConfirmationStatus::Confirmed { .. })); }, @@ -577,6 +702,74 @@ async fn onchain_send_receive() { assert_eq!(node_b_payments.len(), 5); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn reorged_onchain_payment_returns_to_unconfirmed() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + + let addr_a = node_a.onchain_payment().new_address().unwrap(); + let addr_b = node_b.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 500_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![addr_b], + Amount::from_sat(premine_amount_sat), + ) + .await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let amount_to_send_sats = 100_000; + let txid = + node_b.onchain_payment().send_to_address(&addr_a, amount_to_send_sats, None).unwrap(); + wait_for_tx(&electrsd.client, txid).await; + + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let payment_id = PaymentId(txid.to_byte_array()); + for node in [&node_a, &node_b] { + let payment = node.payment(&payment_id).unwrap(); + assert_eq!(payment.status, PaymentStatus::Pending); + match payment.kind { + PaymentKind::Onchain { status, .. } => { + assert!(matches!(status, ConfirmationStatus::Confirmed { .. })); + }, + _ => panic!("Unexpected payment kind"), + } + } + + let original_height = + bitcoind.client.get_blockchain_info().expect("failed to get blockchain info").blocks; + invalidate_blocks(&bitcoind.client, 1); + let replacement_address = bitcoind.client.new_address().expect("failed to get new address"); + for _ in 0..2 { + let _res: serde_json::Value = bitcoind + .client + .call("generateblock", &[json!(replacement_address.to_string()), json!([])]) + .expect("failed to generate empty block"); + } + wait_for_block(&electrsd.client, original_height as usize + 1).await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + for node in [&node_a, &node_b] { + let payment = node.payment(&payment_id).unwrap(); + assert_eq!(payment.status, PaymentStatus::Pending); + match payment.kind { + PaymentKind::Onchain { status, .. } => { + assert!(matches!(status, ConfirmationStatus::Unconfirmed)); + }, + _ => panic!("Unexpected payment kind"), + } + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn onchain_send_all_retains_reserve() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); @@ -710,7 +903,7 @@ async fn onchain_wallet_recovery() { // Now we start from scratch, only the seed remains the same. let mut recovered_config = random_config(true); recovered_config.node_entropy = original_node_entropy; - recovered_config.recovery_mode = true; + recovered_config.wallet_rescan_from_height = Some(0); let recovered_node = setup_node(&chain_source, recovered_config); recovered_node.sync_wallets().unwrap(); @@ -743,6 +936,228 @@ async fn onchain_wallet_recovery() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn onchain_wallet_force_full_scan_rediscovers_esplora_funds() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + premine_blocks(&bitcoind.client, &electrsd.client).await; + + let address_source_config = random_config(true); + let node_entropy = address_source_config.node_entropy; + let address_source_node = setup_node(&chain_source, address_source_config); + let addr_1 = address_source_node.onchain_payment().new_address().unwrap(); + let addr_2 = address_source_node.onchain_payment().new_address().unwrap(); + address_source_node.stop().unwrap(); + drop(address_source_node); + + let premine_amount_sat = 100_000; + let mut stale_config = random_config(true); + stale_config.node_entropy = node_entropy; + stale_config.store_type = TestStoreType::Sqlite; + let stale_node = setup_node(&chain_source, stale_config.clone()); + stale_node.sync_wallets().unwrap(); + assert_eq!(stale_node.list_balances().spendable_onchain_balance_sats, 0); + stale_node.stop().unwrap(); + drop(stale_node); + + let txid_1 = bitcoind + .client + .send_to_address(&addr_1, Amount::from_sat(premine_amount_sat)) + .unwrap() + .0 + .parse() + .unwrap(); + wait_for_tx(&electrsd.client, txid_1).await; + let txid_2 = bitcoind + .client + .send_to_address(&addr_2, Amount::from_sat(premine_amount_sat)) + .unwrap() + .0 + .parse() + .unwrap(); + wait_for_tx(&electrsd.client, txid_2).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + + let normal_node = setup_node(&chain_source, stale_config.clone()); + normal_node.sync_wallets().unwrap(); + assert_eq!( + normal_node.list_balances().spendable_onchain_balance_sats, + 0, + "normal incremental sync should not rediscover previously-unknown addresses" + ); + normal_node.stop().unwrap(); + drop(normal_node); + + stale_config.force_wallet_full_scan = true; + let recovered_node = setup_node(&chain_source, stale_config); + recovered_node.sync_wallets().unwrap(); + assert_eq!( + recovered_node.list_balances().spendable_onchain_balance_sats, + premine_amount_sat * 2, + "forced full scan should rediscover funds sent to previously-unknown addresses" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn onchain_wallet_recovery_rescans_from_birthday_height() { + // End-to-end test for `wallet_rescan_from_height` against a bitcoind chain source. The + // scenario: + // + // 1. Create a node at some "birthday" height and generate two receive addresses. + // 2. Shut the node down and drop all persisted state except the seed. + // 3. Advance the chain past the birthday. + // 4. Send funds to the addresses generated at the birthday height and confirm them. + // 5. Restart a fresh node with just the seed and no rescan height. Its wallet birthday + // is pinned at the current tip, which is above the blocks containing the funding + // transactions — so the node must not see the funds. + // 6. Restart again with `wallet_rescan_from_height: Some(birthday)`. Now the wallet must + // find and report both funding transactions. + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + // We specifically exercise the bitcoind RPC backend because that's where + // `rescan_from_height` is honored precisely (via `get_block_hash_by_height`). + let chain_source = TestChainSource::BitcoindRpcSync(&bitcoind); + + // Mine the initial 101 blocks so bitcoind's wallet can fund our later sends. + premine_blocks(&bitcoind.client, &electrsd.client).await; + + // Step 1: bring up an "original" node at the birthday height and generate addresses. + let original_config = random_config(true); + let original_node_entropy = original_config.node_entropy; + let original_node = setup_node(&chain_source, original_config); + + let premine_amount_sat = 100_000; + + let addr_1 = original_node.onchain_payment().new_address().unwrap(); + let addr_2 = original_node.onchain_payment().new_address().unwrap(); + + let birthday_height: u32 = bitcoind + .client + .get_blockchain_info() + .expect("failed to get blockchain info") + .blocks + .try_into() + .unwrap(); + + // Step 2: shut the node down and drop its state. + original_node.stop().unwrap(); + drop(original_node); + + // Step 3: advance the chain past the birthday, so a fresh node would otherwise pin its + // wallet birthday at a height above the funding transactions in step 4. + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 10).await; + + // Step 4: fund both addresses and confirm them. + let txid_1 = bitcoind + .client + .send_to_address(&addr_1, Amount::from_sat(premine_amount_sat)) + .unwrap() + .0 + .parse() + .unwrap(); + wait_for_tx(&electrsd.client, txid_1).await; + let txid_2 = bitcoind + .client + .send_to_address(&addr_2, Amount::from_sat(premine_amount_sat)) + .unwrap() + .0 + .parse() + .unwrap(); + wait_for_tx(&electrsd.client, txid_2).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + + // Step 5: restart a fresh node with only the seed and no rescan height. It must NOT see + // the funds, because its wallet birthday sits above the funding transactions. + let mut pinned_config = random_config(true); + pinned_config.node_entropy = original_node_entropy; + let pinned_node = setup_node(&chain_source, pinned_config); + pinned_node.sync_wallets().unwrap(); + assert_eq!( + pinned_node.list_balances().spendable_onchain_balance_sats, + 0, + "fresh node without rescan height should not find funds below its wallet birthday" + ); + pinned_node.stop().unwrap(); + drop(pinned_node); + + // Step 6: restart with a rescan height set to the birthday height. Funds must be + // re-discovered. + let mut recovered_config = random_config(true); + recovered_config.node_entropy = original_node_entropy; + recovered_config.wallet_rescan_from_height = Some(birthday_height); + let recovered_node = setup_node(&chain_source, recovered_config); + recovered_node.sync_wallets().unwrap(); + assert_eq!( + recovered_node.list_balances().spendable_onchain_balance_sats, + premine_amount_sat * 2, + "node recovered with rescan_from_height should see funds sent to pre-birthday addresses" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn build_fails_when_wallet_rescan_height_is_above_tip() { + let (bitcoind, _electrsd) = setup_bitcoind_and_electrsd(); + let current_tip_height: u32 = bitcoind + .client + .get_blockchain_info() + .expect("failed to get blockchain info") + .blocks + .try_into() + .unwrap(); + + let config = random_config(false); + let entropy = config.node_entropy; + + setup_builder!(builder, config.node_config); + let values = bitcoind.params.get_cookie_values().unwrap().unwrap(); + builder.set_chain_source_bitcoind_rpc( + bitcoind.params.rpc_socket.ip().to_string(), + bitcoind.params.rpc_socket.port(), + values.user, + values.password, + Some(current_tip_height + 1), + ); + + match builder.build(entropy.into()) { + Err(err) => { + assert_eq!(err, BuildError::WalletRescanHeightTooHigh); + assert_eq!(err.to_string(), "Wallet rescan height is above the current chain tip."); + }, + Ok(_) => panic!("expected build to fail for future wallet rescan height"), + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn build_aborts_on_first_startup_bitcoind_tip_fetch_failure() { + // A fresh node pointed at an unreachable bitcoind RPC endpoint must not silently + // fall back to genesis as the wallet birthday. The build must abort cleanly so the + // misconfiguration surfaces immediately. + let config = random_config(false); + let entropy = config.node_entropy; + + setup_builder!(builder, config.node_config); + // Pick a localhost port that is extremely unlikely to be bound. The kernel will + // refuse the connection immediately so the test does not have to wait for the + // chain-polling timeout. + let unreachable_port: u16 = 1; + builder.set_chain_source_bitcoind_rpc( + "127.0.0.1".to_string(), + unreachable_port, + "user".to_string(), + "password".to_string(), + None, + ); + + let res = builder.build(entropy.into()); + match res { + Err(BuildError::ChainTipFetchFailed) => {}, + other => panic!( + "expected BuildError::ChainTipFetchFailed on fresh node with unreachable bitcoind, got {:?}", + other.map(|_| "Ok(_)") + ), + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_rbf_via_mempool() { run_rbf_test(false).await; @@ -1036,6 +1451,18 @@ async fn splice_channel() { ); assert_eq!(node_b.list_balances().total_lightning_balance_sats, 0); + let address = node_a.onchain_payment().new_address().unwrap(); + let excessive_splice_out_sats = node_a.list_channels()[0].outbound_capacity_msat / 1000 + 1; + assert_eq!( + node_a.splice_out( + &user_channel_id_a, + node_b.node_id(), + &address, + excessive_splice_out_sats + ), + Err(NodeError::ChannelSplicingFailed), + ); + // Test that splicing and payments fail when there are insufficient funds let address = node_b.onchain_payment().new_address().unwrap(); let amount_msat = 400_000_000; @@ -1059,6 +1486,10 @@ async fn splice_channel() { let txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); expect_splice_negotiated_event!(node_b, node_a.node_id()); + // Node B contributed to this splice, so wait for its funding broadcast to be classified before + // syncing — otherwise a sync racing the broadcaster's queue records a generic on-chain payment. + wait_for_classified_funding_payment(&node_b, txo.txid).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); @@ -1068,15 +1499,13 @@ async fn splice_channel() { expect_channel_ready_event!(node_b, node_a.node_id()); let expected_splice_in_fee_sat = 251; - let expected_splice_in_onchain_cost_sat = 254; + let expected_splice_in_onchain_cost_sat = 253; - // LDK's fee calculation differs from BDK wallet's, which over pays on fees. Rather than giving - // the extra fees to the miner, LDK sends it to the channel balance since there may not be a - // change output. - // - // TODO: Some of the discrepancy is addressed upstream, so this number should be adjusted when - // updating the BDK wallet dependency. See: https://github.com/bitcoindevkit/bdk_wallet/pull/479 - let expected_splice_in_lightning_balance_sat = 4_000_003; + // BDK 3.1.0 avoids the previous per-UTXO fee rounding during coin selection. Keep the + // remaining 2-sat LDK/BDK fee-accounting drift explicit so a dependency change cannot silently + // reintroduce the larger surplus. Rather than giving the extra sats to the miner, LDK sends + // them to the channel balance since there may not be a change output. + let expected_splice_in_lightning_balance_sat = 4_000_002; let payments = node_b.list_payments(); let payment = @@ -1117,6 +1546,10 @@ async fn splice_channel() { let txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); expect_splice_negotiated_event!(node_b, node_a.node_id()); + // Node A contributed to this splice, so wait for its funding broadcast to be classified before + // syncing — otherwise a sync racing the broadcaster's queue records a generic on-chain payment. + wait_for_classified_funding_payment(&node_a, txo.txid).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); @@ -1131,6 +1564,18 @@ async fn splice_channel() { let payment = payments.into_iter().find(|p| p.id == PaymentId(txo.txid.to_byte_array())).unwrap(); assert_eq!(payment.fee_paid_msat, Some(expected_splice_out_fee_sat * 1_000)); + // The splice-out graduated to a confirmed interactive-funding payment. Its `direction` is left + // unasserted on purpose: the destination is our own address, so it is a self-transfer (channel + // balance -> on-chain wallet) whose inbound/outbound sense is ambiguous. + assert_eq!(payment.status, PaymentStatus::Succeeded); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { + status: ConfirmationStatus::Confirmed { .. }, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + .. + } + )); assert_eq!( node_a.list_balances().total_onchain_balance_sats, @@ -1142,6 +1587,416 @@ async fn splice_channel() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn rbf_splice_channel() { + run_rbf_splice_channel_test(false).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn rbf_splice_channel_original_candidate_confirms() { + run_rbf_splice_channel_test(true).await; +} + +async fn run_rbf_splice_channel_test(confirm_original: bool) { + // Use a custom bitcoind config with a lower incrementalrelayfee so that the +25 sat/kwu + // (0.1 sat/vB) RBF feerate bump satisfies BIP125's absolute fee increase requirement. + let bitcoind_exe = std::env::var("BITCOIND_EXE") + .ok() + .or_else(|| corepc_node::downloaded_exe_path().ok()) + .expect( + "you need to provide an env var BITCOIND_EXE or specify a bitcoind version feature", + ); + let mut bitcoind_conf = corepc_node::Conf::default(); + bitcoind_conf.network = "regtest"; + bitcoind_conf.args.push("-rest"); + bitcoind_conf.args.push("-incrementalrelayfee=0.00000100"); + let bitcoind = BitcoinD::with_conf(bitcoind_exe, &bitcoind_conf).unwrap(); + + let electrs_exe = std::env::var("ELECTRS_EXE") + .ok() + .or_else(electrsd::downloaded_exe_path) + .expect("you need to provide env var ELECTRS_EXE or specify an electrsd version feature"); + let mut electrsd_conf = electrsd::Conf::default(); + electrsd_conf.http_enabled = true; + electrsd_conf.network = "regtest"; + let electrsd = ElectrsD::with_conf(electrs_exe, &bitcoind, &electrsd_conf).unwrap(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + + let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + + let address_a = node_a.onchain_payment().new_address().unwrap(); + let address_b = node_b.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 5_000_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a, address_b], + Amount::from_sat(premine_amount_sat), + ) + .await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await; + + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let _user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + let user_channel_id_b = expect_channel_ready_event!(node_b, node_a.node_id()); + + // bump_channel_funding_fee should fail when there's no pending splice + assert_eq!( + node_b.bump_channel_funding_fee(&user_channel_id_b, node_a.node_id()), + Err(NodeError::ChannelSplicingFailed), + ); + + // Initiate a splice-in to create a pending splice + node_b.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000).unwrap(); + + let original_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + expect_splice_negotiated_event!(node_b, node_a.node_id()); + + // Sync so the original splice candidate is recorded as a canonical wallet transaction before + // the RBF below replaces it. The post-RBF sync then observes the original candidate being + // replaced (a `WalletEvent::TxReplaced`), which must not drop the payment's durable funding + // classification — the `tx_type` assertion below catches a regression deterministically. + wait_for_tx(&electrsd.client, original_txo.txid).await; + // Node B contributed to this splice; wait for its classification before syncing so the sync + // takes the funding short-circuit rather than racing the broadcaster's queue. + wait_for_classified_funding_payment(&node_b, original_txo.txid).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + // For `confirm_original`, capture the original candidate's fee and raw transaction now, before + // the RBF replaces it, so it can be force-confirmed (instead of the RBF) further below. + let original_candidate: Option<(Option, String)> = if confirm_original { + let payment_id = PaymentId(original_txo.txid.to_byte_array()); + let fee = node_b.payment(&payment_id).expect("splice payment exists").fee_paid_msat; + let raw_tx: String = bitcoind + .client + .call("getrawtransaction", &[json!(original_txo.txid.to_string())]) + .expect("failed to fetch the original splice transaction"); + Some((fee, raw_tx)) + } else { + None + }; + + // Re-splicing the pending splice we already contributed to is rejected; the RBF guard points at + // bump_channel_funding_fee instead. + assert_eq!( + node_b.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000), + Err(NodeError::ChannelSplicingFailed), + ); + + // bump_channel_funding_fee should succeed when there's a pending splice + node_b.bump_channel_funding_fee(&user_channel_id_b, node_a.node_id()).unwrap(); + + let rbf_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + expect_splice_negotiated_event!(node_b, node_a.node_id()); + + assert_ne!(original_txo, rbf_txo, "RBF should produce a different funding txo"); + + // Wait for the RBF transaction to replace the original in the mempool. + wait_for_tx(&electrsd.client, rbf_txo.txid).await; + // Wait for node_b's re-classification of the RBF candidate before syncing, so the recorded + // candidate figures reflect the replacement rather than racing the broadcaster's queue. + wait_for_classified_funding_payment(&node_b, rbf_txo.txid).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + // After RBF but before confirmation, node_b (the initiator) should have a single on-chain + // payment covering both candidates: id anchored to the first broadcast, `kind.txid` pointing + // at the latest (RBF) candidate, and the durable interactive-funding `tx_type` preserved across + // the replacement. + let rbf_candidate_fee = { + let payment_id = PaymentId(original_txo.txid.to_byte_array()); + let payment = node_b.payment(&payment_id).expect("splice payment exists"); + match payment.kind { + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + } => { + assert_eq!(txid, rbf_txo.txid); + }, + ref other => { + panic!("expected Onchain Unconfirmed interactive-funding, got {:?}", other) + }, + } + assert_eq!(payment.status, PaymentStatus::Pending); + // Only one Onchain Pending payment for this splice attempt (not one per candidate). + let splice_payments = node_b.list_payments_with_filter(|p| { + p.direction == PaymentDirection::Outbound + && matches!(p.kind, PaymentKind::Onchain { .. }) + && p.status == PaymentStatus::Pending + }); + assert_eq!( + splice_payments.len(), + 1, + "expected exactly one pending Onchain payment for the splice, got {}: {:#?}", + splice_payments.len(), + splice_payments, + ); + + // The fee recorded for the latest (RBF) candidate, which is the one that confirms below. + assert!(payment.fee_paid_msat.is_some()); + payment.fee_paid_msat + }; + + // Confirm the splice. Normally the latest (RBF) candidate wins through the mempool; for + // `confirm_original` we instead mine the original candidate directly into a block so an + // earlier, lower-fee candidate is the one that confirms. + let winning_txo = if confirm_original { original_txo } else { rbf_txo }; + if let Some((_, ref original_tx_hex)) = original_candidate { + let address = bitcoind.client.new_address().expect("failed to get new address"); + let _: serde_json::Value = bitcoind + .client + .call("generateblock", &[json!(address.to_string()), json!([original_tx_hex])]) + .expect("failed to mine the original splice candidate"); + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 5).await; + } else { + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + } + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + // Verify the candidate that locked is the one that confirmed, not necessarily the last broadcast. + match node_a.next_event_async().await { + Event::ChannelReady { funding_txo, counterparty_node_id, .. } => { + assert_eq!(counterparty_node_id, Some(node_b.node_id())); + assert_eq!(funding_txo, Some(winning_txo)); + node_a.event_handled().unwrap(); + }, + ref e => panic!("node_a got unexpected event: {:?}", e), + } + match node_b.next_event_async().await { + Event::ChannelReady { funding_txo, counterparty_node_id, .. } => { + assert_eq!(counterparty_node_id, Some(node_a.node_id())); + assert_eq!(funding_txo, Some(winning_txo)); + node_b.event_handled().unwrap(); + }, + ref e => panic!("node_b got unexpected event: {:?}", e), + } + + // The splice payment graduates to `Succeeded` purely from wallet sync reaching + // `ANTI_REORG_DELAY` confirmations — the `ChannelReady` events above are a separate + // channel-lifecycle signal, not what drives payment status. Its `kind.txid` reflects the + // winning RBF candidate, and `fee_paid_msat` carries this node's `FundingContribution` fee. + { + let payment_id = PaymentId(original_txo.txid.to_byte_array()); + let payment = node_b.payment(&payment_id).expect("splice payment graduated"); + assert_eq!(payment.status, PaymentStatus::Succeeded); + match payment.kind { + PaymentKind::Onchain { txid, status: ConfirmationStatus::Confirmed { .. }, .. } => { + assert_eq!(txid, winning_txo.txid); + }, + ref other => panic!("expected Onchain Confirmed, got {:?}", other), + } + // Graduation stamps the economics of the candidate that actually confirmed. For + // `confirm_original` that is the earlier, lower-fee candidate, whose fee differs from the + // last-broadcast (RBF) candidate's — so this would fail if the payment kept the + // last-broadcast figures instead of the confirmed candidate's. + let expected_fee = match original_candidate { + Some((original_fee, _)) => { + assert_ne!(original_fee, rbf_candidate_fee); + original_fee + }, + None => rbf_candidate_fee, + }; + assert!(expected_fee.is_some()); + assert_eq!(payment.fee_paid_msat, expected_fee); + } + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn funding_payment_graduates_without_channel_ready() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + + let address_a = node_a.onchain_payment().new_address().unwrap(); + let address_b = node_b.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 5_000_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a, address_b], + Amount::from_sat(premine_amount_sat), + ) + .await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + // node_a funds the channel, so it holds the funding payment. `open_channel` drains only the + // `ChannelPending` events, leaving any `ChannelReady` queued and undrained. + let funding_txo = open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await; + + // Mine past `ANTI_REORG_DELAY` and sync only node_a. node_b stays behind, so it cannot yet + // send `channel_ready` and node_a therefore cannot have emitted a `ChannelReady` event — any + // graduation below must come from wallet sync alone. + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + + // The funding payment is `Succeeded` purely from wallet sync reaching `ANTI_REORG_DELAY` + // confirmations, asserted before draining any LDK event — so graduation is not driven by the + // Lightning `ChannelReady` signal. + let payment_id = PaymentId(funding_txo.txid.to_byte_array()); + let payment = node_a.payment(&payment_id).expect("funding payment exists"); + assert_eq!(payment.status, PaymentStatus::Succeeded); + match payment.kind { + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Confirmed { .. }, + tx_type: Some(TransactionType::Funding { .. }), + } => assert_eq!(txid, funding_txo.txid), + ref other => panic!("expected Onchain Confirmed funding payment, got {:?}", other), + } + + // Let node_b catch up so the channel completes; the `ChannelReady` events follow the + // already-`Succeeded` payment rather than driving it. + node_b.sync_wallets().unwrap(); + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn splice_payment_reorged_to_unconfirmed() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + + let address_a = node_a.onchain_payment().new_address().unwrap(); + let address_b = node_b.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 5_000_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a, address_b], + Amount::from_sat(premine_amount_sat), + ) + .await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let _user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + let user_channel_id_b = expect_channel_ready_event!(node_b, node_a.node_id()); + + // node_b splices in, recording a funding payment it contributed to. + node_b.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000).unwrap(); + let splice_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + expect_splice_negotiated_event!(node_b, node_a.node_id()); + wait_for_tx(&electrsd.client, splice_txo.txid).await; + // Ensure node_b classified the splice before syncing so the test exercises a funding payment's + // reorg rather than a generic on-chain payment's. + wait_for_classified_funding_payment(&node_b, splice_txo.txid).await; + + // Confirm the splice with a single block — confirmed, but short of `ANTI_REORG_DELAY`, so the + // payment is `Confirmed`/`Pending` rather than graduated. + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + node_b.sync_wallets().unwrap(); + + let payment_id = PaymentId(splice_txo.txid.to_byte_array()); + let payment = node_b.payment(&payment_id).expect("splice payment exists"); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { status: ConfirmationStatus::Confirmed { .. }, .. } + )); + + // Reorg the splice transaction out by replacing its block with a longer, transaction-free chain. + let original_height = + bitcoind.client.get_blockchain_info().expect("failed to get blockchain info").blocks; + invalidate_blocks(&bitcoind.client, 1); + let replacement_address = bitcoind.client.new_address().expect("failed to get new address"); + for _ in 0..2 { + let _res: serde_json::Value = bitcoind + .client + .call("generateblock", &[json!(replacement_address.to_string()), json!([])]) + .expect("failed to generate empty block"); + } + wait_for_block(&electrsd.client, original_height as usize + 1).await; + node_b.sync_wallets().unwrap(); + + // The funding payment returns to `Unconfirmed` and stays `Pending`, exercising the + // `TxUnconfirmed` arm for a funding payment. + let payment = node_b.payment(&payment_id).expect("splice payment still exists"); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { status: ConfirmationStatus::Unconfirmed, .. } + )); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn splice_in_rbf_joins_counterparty_splice() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + + let address_a = node_a.onchain_payment().new_address().unwrap(); + let address_b = node_b.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 5_000_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a, address_b], + Amount::from_sat(premine_amount_sat), + ) + .await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + let user_channel_id_b = expect_channel_ready_event!(node_b, node_a.node_id()); + + // node_b (which didn't fund the channel open, so holds the on-chain balance) initiates a + // splice-in; node_a does not contribute to this first candidate. + node_b.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000).unwrap(); + let counterparty_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + expect_splice_negotiated_event!(node_b, node_a.node_id()); + wait_for_tx(&electrsd.client, counterparty_txo.txid).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + // node_a contributes to the pending splice via RBF. Before honoring the funding template's RBF + // minimum feerate, this was rejected with FeeRateBelowRbfMinimum because node_a's funding + // feerate estimate sat below the minimum required to replace the in-flight transaction. + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 100_000).unwrap(); + let rbf_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + expect_splice_negotiated_event!(node_b, node_a.node_id()); + assert_ne!(counterparty_txo, rbf_txo, "node_a's RBF should produce a different funding txo"); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn simple_bolt12_send_receive() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); @@ -1668,6 +2523,18 @@ async fn generate_bip21_uri() { assert!(uni_payment.contains("lno=")); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn unified_receive_rejects_msat_overflow() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + let node = setup_node(&chain_source, random_config(true)); + + assert_eq!( + Err(NodeError::InvalidAmount), + node.unified_payment().receive(u64::MAX, "asdf", 4_000) + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn unified_send_receive_bip21_uri() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); @@ -1813,7 +2680,7 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { let service_config = random_config(true); setup_builder!(service_builder, service_config.node_config); service_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); - service_builder.set_liquidity_provider_lsps2(lsps2_service_config); + service_builder.enable_liquidity_provider(lsps2_service_config); let service_node = service_builder.build(service_config.node_entropy.into()).unwrap(); service_node.start().unwrap(); @@ -1823,7 +2690,7 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { let client_config = random_config(true); setup_builder!(client_builder, client_config.node_config); client_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); - client_builder.set_liquidity_source_lsps2(service_node_id, service_addr, None); + client_builder.add_liquidity_source(service_node_id, service_addr, None, true); let client_node = client_builder.build(client_config.node_entropy.into()).unwrap(); client_node.start().unwrap(); @@ -2132,7 +2999,7 @@ async fn lsps2_client_trusts_lsp() { let service_config = random_config(true); setup_builder!(service_builder, service_config.node_config); service_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); - service_builder.set_liquidity_provider_lsps2(lsps2_service_config); + service_builder.enable_liquidity_provider(lsps2_service_config); let service_node = service_builder.build(service_config.node_entropy.into()).unwrap(); service_node.start().unwrap(); let service_node_id = service_node.node_id(); @@ -2141,7 +3008,7 @@ async fn lsps2_client_trusts_lsp() { let client_config = random_config(true); setup_builder!(client_builder, client_config.node_config); client_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); - client_builder.set_liquidity_source_lsps2(service_node_id, service_addr.clone(), None); + client_builder.add_liquidity_source(service_node_id, service_addr.clone(), None, true); let client_node = client_builder.build(client_config.node_entropy.into()).unwrap(); client_node.start().unwrap(); let client_node_id = client_node.node_id(); @@ -2218,7 +3085,7 @@ async fn lsps2_client_trusts_lsp() { client_node .list_channels() .iter() - .find(|c| c.counterparty_node_id == service_node_id) + .find(|c| c.counterparty.node_id == service_node_id) .unwrap() .confirmations, Some(0) @@ -2227,7 +3094,7 @@ async fn lsps2_client_trusts_lsp() { service_node .list_channels() .iter() - .find(|c| c.counterparty_node_id == client_node_id) + .find(|c| c.counterparty.node_id == client_node_id) .unwrap() .confirmations, Some(0) @@ -2262,7 +3129,7 @@ async fn lsps2_client_trusts_lsp() { client_node .list_channels() .iter() - .find(|c| c.counterparty_node_id == service_node_id) + .find(|c| c.counterparty.node_id == service_node_id) .unwrap() .confirmations, Some(6) @@ -2271,7 +3138,7 @@ async fn lsps2_client_trusts_lsp() { service_node .list_channels() .iter() - .find(|c| c.counterparty_node_id == client_node_id) + .find(|c| c.counterparty.node_id == client_node_id) .unwrap() .confirmations, Some(6) @@ -2307,7 +3174,7 @@ async fn lsps2_lsp_trusts_client_but_client_does_not_claim() { let service_config = random_config(true); setup_builder!(service_builder, service_config.node_config); service_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); - service_builder.set_liquidity_provider_lsps2(lsps2_service_config); + service_builder.enable_liquidity_provider(lsps2_service_config); let service_node = service_builder.build(service_config.node_entropy.into()).unwrap(); service_node.start().unwrap(); @@ -2317,7 +3184,7 @@ async fn lsps2_lsp_trusts_client_but_client_does_not_claim() { let client_config = random_config(true); setup_builder!(client_builder, client_config.node_config); client_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); - client_builder.set_liquidity_source_lsps2(service_node_id, service_addr.clone(), None); + client_builder.add_liquidity_source(service_node_id, service_addr.clone(), None, true); let client_node = client_builder.build(client_config.node_entropy.into()).unwrap(); client_node.start().unwrap(); @@ -2391,7 +3258,7 @@ async fn lsps2_lsp_trusts_client_but_client_does_not_claim() { client_node .list_channels() .iter() - .find(|c| c.counterparty_node_id == service_node_id) + .find(|c| c.counterparty.node_id == service_node_id) .unwrap() .confirmations, Some(6) @@ -2400,7 +3267,7 @@ async fn lsps2_lsp_trusts_client_but_client_does_not_claim() { service_node .list_channels() .iter() - .find(|c| c.counterparty_node_id == client_node_id) + .find(|c| c.counterparty.node_id == client_node_id) .unwrap() .confirmations, Some(6) @@ -2556,7 +3423,11 @@ async fn build_0_6_2_node( assert!(balance > 0); let node_id = node_old.node_id(); - node_old.stop().unwrap(); + // Workaround necessary as v0.6.2's runtime wasn't dropsafe in a tokio context. + tokio::task::block_in_place(move || { + node_old.stop().unwrap(); + drop(node_old); + }); (balance, node_id) } @@ -2840,6 +3711,55 @@ async fn onchain_fee_bump_rbf() { assert_eq!(node_a_received_payment[0].status, PaymentStatus::Succeeded); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn onchain_fee_bump_rbf_respects_anchor_reserve() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + + let addr_a = node_a.onchain_payment().new_address().unwrap(); + let addr_b = node_b.onchain_payment().new_address().unwrap(); + + let premine_amount_sat = 1_000_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![addr_a.clone(), addr_b], + Amount::from_sat(premine_amount_sat), + ) + .await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + open_channel(&node_b, &node_a, 200_000, false, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + expect_channel_ready_event!(node_b, node_a.node_id()); + + let balances_before = node_b.list_balances(); + let reserve = balances_before.total_anchor_channels_reserve_sats; + assert!(reserve > 0, "Anchor reserve should be non-zero after channel open"); + let spendable_before = balances_before.spendable_onchain_balance_sats; + + let buffer_sats = 5_000; + assert!(spendable_before > buffer_sats); + let amount_to_send_sats = spendable_before - buffer_sats; + let txid = + node_b.onchain_payment().send_to_address(&addr_a, amount_to_send_sats, None).unwrap(); + wait_for_tx(&electrsd.client, txid).await; + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + node_b.sync_wallets().unwrap(); + + let payment_id = PaymentId(txid.to_byte_array()); + let high_fee_rate = bitcoin::FeeRate::from_sat_per_kwu(20_000); + assert_eq!( + Err(NodeError::InsufficientFunds), + node_b.onchain_payment().bump_fee_rbf(payment_id, Some(high_fee_rate.into())) + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn open_channel_with_all_with_anchors() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); @@ -2886,7 +3806,7 @@ async fn open_channel_with_all_with_anchors() { assert_eq!(channels.len(), 1); let channel = &channels[0]; assert!(channel.channel_value_sats > premine_amount_sat - anchor_reserve_sat - 500); - assert_eq!(channel.counterparty_node_id, node_b.node_id()); + assert_eq!(channel.counterparty.node_id, node_b.node_id()); assert_eq!(channel.funding_txo.unwrap(), funding_txo); node_a.stop().unwrap(); @@ -2937,7 +3857,7 @@ async fn open_channel_with_all_without_anchors() { assert_eq!(channels.len(), 1); let channel = &channels[0]; assert!(channel.channel_value_sats > premine_amount_sat - 500); - assert_eq!(channel.counterparty_node_id, node_b.node_id()); + assert_eq!(channel.counterparty.node_id, node_b.node_id()); assert_eq!(channel.funding_txo.unwrap(), funding_txo); node_a.stop().unwrap(); @@ -3021,3 +3941,98 @@ async fn splice_in_with_all_balance() { node_a.stop().unwrap(); node_b.stop().unwrap(); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn lsps2_multi_lsp_picks_cheapest() { + do_lsps2_multi_lsp_picks_cheapest(false).await; + do_lsps2_multi_lsp_picks_cheapest(true).await; +} + +async fn do_lsps2_multi_lsp_picks_cheapest(reverse_order: bool) { + let (_bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); + + let mut sync_config = EsploraSyncConfig::default(); + sync_config.background_sync_config = None; + + // Cheap LSP: 10_000 ppm. + let cheap_cfg = LSPS2ServiceConfig { + require_token: None, + advertise_service: false, + channel_opening_fee_ppm: 10_000, + channel_over_provisioning_ppm: 100_000, + max_payment_size_msat: 1_000_000_000, + min_payment_size_msat: 0, + min_channel_lifetime: 100, + min_channel_opening_fee_msat: 10, + max_client_to_self_delay: 1024, + client_trusts_lsp: true, + disable_client_reserve: false, + }; + let cheap_node_config = random_config(true); + setup_builder!(cheap_builder, cheap_node_config.node_config); + cheap_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); + cheap_builder.enable_liquidity_provider(cheap_cfg); + let cheap = cheap_builder.build(cheap_node_config.node_entropy.into()).unwrap(); + cheap.start().unwrap(); + let cheap_id = cheap.node_id(); + let cheap_addr = cheap.listening_addresses().unwrap().first().unwrap().clone(); + + // Expensive LSP: 20_000 ppm. + let expensive_cfg = LSPS2ServiceConfig { + require_token: None, + advertise_service: false, + channel_opening_fee_ppm: 20_000, + channel_over_provisioning_ppm: 200_000, + max_payment_size_msat: 1_000_000_000, + min_payment_size_msat: 0, + min_channel_lifetime: 100, + min_channel_opening_fee_msat: 5, + max_client_to_self_delay: 1024, + client_trusts_lsp: true, + disable_client_reserve: false, + }; + let expensive_node_config = random_config(true); + setup_builder!(expensive_builder, expensive_node_config.node_config); + expensive_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); + expensive_builder.enable_liquidity_provider(expensive_cfg); + let expensive = expensive_builder.build(expensive_node_config.node_entropy.into()).unwrap(); + expensive.start().unwrap(); + let expensive_id = expensive.node_id(); + let expensive_addr = expensive.listening_addresses().unwrap().first().unwrap().clone(); + + // Client knows both LSPs. Registration order is varied to confirm selection isn't order-based. + let client_config = random_config(true); + setup_builder!(client_builder, client_config.node_config); + client_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); + if reverse_order { + client_builder.add_liquidity_source(expensive_id, expensive_addr, None, true); + client_builder.add_liquidity_source(cheap_id, cheap_addr, None, true); + } else { + client_builder.add_liquidity_source(cheap_id, cheap_addr, None, true); + client_builder.add_liquidity_source(expensive_id, expensive_addr, None, true); + } + let client = client_builder.build(client_config.node_entropy.into()).unwrap(); + client.start().unwrap(); + + let invoice_description = + Bolt11InvoiceDescription::Direct(Description::new(String::from("asdf")).unwrap()).into(); + let jit_invoice = client + .bolt11_payment() + .receive_via_jit_channel(100_000_000, &invoice_description, 1024, None) + .unwrap(); + + // The route hint's src_node_id is the LSP the client picked. + let route_hints = jit_invoice.route_hints(); + let first_hint = route_hints.first().expect("JIT invoice should have a route hint"); + #[cfg(feature = "uniffi")] + let first_hop = first_hint.first(); + #[cfg(not(feature = "uniffi"))] + let first_hop = first_hint.0.first(); + let route_hint_src = first_hop.expect("route hint should have at least one hop").src_node_id; + assert_eq!(route_hint_src, cheap_id, "expected cheaper LSP to be selected."); + + client.stop().unwrap(); + cheap.stop().unwrap(); + expensive.stop().unwrap(); +} diff --git a/tests/upgrade_downgrade_tests.rs b/tests/upgrade_downgrade_tests.rs new file mode 100644 index 0000000000..de5bef96e8 --- /dev/null +++ b/tests/upgrade_downgrade_tests.rs @@ -0,0 +1,421 @@ +// 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. + +// This file is a downgrade monitoring canary for serialized LDK Node state, not a +// compatibility test for the filesystem-store IO layout itself. The current +// `build_with_fs_store` path writes filesystem-store v2 data, while LDK Node v0.7.0 +// reads filesystem-store v1 data. There is no supported v2-to-v1 IO-layer downgrade: +// v2 stores empty namespaces under `[empty]`, which v1 readers do not look up. +// +// TODO(@benthecarman) Bring back after 0.8 is cut. + +// To keep monitoring whether the serialized node/channel/payment state remains +// understandable by v0.7.0, these tests intentionally write current state through +// the legacy v1 filesystem-store implementation via `build_with_store`, then +// reopen it with v0.7.0's `build_with_fs_store`. + +// #[allow(unused_imports, unused_macros)] +// mod common; +// +// use std::path::PathBuf; +// use std::time::Duration; +// +// use bitcoin::secp256k1::PublicKey; +// use bitcoin::Amount; +// use common::{ +// generate_blocks_and_wait, generate_listening_addresses, premine_and_distribute_funds, +// random_storage_path, setup_bitcoind_and_electrsd, wait_for_tx, +// }; +// use ldk_node::config::{Config, EsploraSyncConfig}; +// use ldk_node::entropy::NodeEntropy; +// use ldk_node::lightning::ln::msgs::SocketAddress as CurrentSocketAddress; +// use ldk_node::lightning_invoice::{ +// Bolt11InvoiceDescription as CurrentBolt11InvoiceDescription, Description as CurrentDescription, +// }; +// use lightning_persister::fs_store::v1::FilesystemStore; +// +// #[cfg(feature = "uniffi")] +// type CurrentNode = std::sync::Arc; +// #[cfg(not(feature = "uniffi"))] +// type CurrentNode = ldk_node::Node; +// +// const NODE_A_SEED_BYTES: [u8; 64] = [42; 64]; +// const NODE_B_SEED_BYTES: [u8; 64] = [43; 64]; +// const FUNDING_AMOUNT_SAT: u64 = 2_000_000; +// const CHANNEL_AMOUNT_SAT: u64 = 1_000_000; +// const PUSH_AMOUNT_MSAT: u64 = 500_000_000; +// const PRE_DOWNGRADE_PAYMENT_MSAT: u64 = 100_000; +// const POST_DOWNGRADE_PAYMENT_MSAT: u64 = 200_000; +// +// #[tokio::test(flavor = "multi_thread", worker_threads = 1)] +// async fn monitor_v0_7_0_serialization_downgrade_channel_payment() { +// let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); +// let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); +// +// let storage_path_a = random_storage_path().to_str().unwrap().to_owned(); +// let storage_path_b = random_storage_path().to_str().unwrap().to_owned(); +// let current_addresses_a = generate_listening_addresses(); +// let current_addresses_b = generate_listening_addresses(); +// let v070_addresses_a = to_v070_socket_addresses(¤t_addresses_a); +// let v070_addresses_b = to_v070_socket_addresses(¤t_addresses_b); +// +// let node_id_a; +// let node_id_b; +// let pre_downgrade_payment_id; +// +// { +// let node_a = build_current_node( +// storage_path_a.clone(), +// NODE_A_SEED_BYTES, +// current_addresses_a.clone(), +// "downgrade-a", +// &esplora_url, +// ); +// let node_b = build_current_node( +// storage_path_b.clone(), +// NODE_B_SEED_BYTES, +// current_addresses_b.clone(), +// "downgrade-b", +// &esplora_url, +// ); +// node_id_a = node_a.node_id(); +// node_id_b = node_b.node_id(); +// +// let addr_a = node_a.onchain_payment().new_address().unwrap(); +// let addr_b = node_b.onchain_payment().new_address().unwrap(); +// premine_and_distribute_funds( +// &bitcoind.client, +// &electrsd.client, +// vec![addr_a, addr_b], +// Amount::from_sat(FUNDING_AMOUNT_SAT), +// ) +// .await; +// node_a.sync_wallets().unwrap(); +// node_b.sync_wallets().unwrap(); +// assert_eq!(node_a.list_balances().spendable_onchain_balance_sats, FUNDING_AMOUNT_SAT); +// assert_eq!(node_b.list_balances().spendable_onchain_balance_sats, FUNDING_AMOUNT_SAT); +// +// let funding_txo = open_current_channel(&node_a, &node_b).await; +// wait_for_tx(&electrsd.client, funding_txo.txid).await; +// generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; +// node_a.sync_wallets().unwrap(); +// node_b.sync_wallets().unwrap(); +// expect_current_channel_ready(&node_a, node_id_b).await; +// expect_current_channel_ready(&node_b, node_id_a).await; +// assert_current_channel_ready(&node_a, node_id_b); +// assert_current_channel_ready(&node_b, node_id_a); +// +// pre_downgrade_payment_id = send_current_bolt11_payment( +// &node_a, +// &node_b, +// PRE_DOWNGRADE_PAYMENT_MSAT, +// "pre-downgrade", +// ) +// .await; +// +// node_a.stop().unwrap(); +// node_b.stop().unwrap(); +// } +// +// let node_a_v070 = build_v070_node( +// storage_path_a, +// NODE_A_SEED_BYTES, +// v070_addresses_a.clone(), +// "downgrade-a", +// &esplora_url, +// ); +// let node_b_v070 = build_v070_node( +// storage_path_b, +// NODE_B_SEED_BYTES, +// v070_addresses_b.clone(), +// "downgrade-b", +// &esplora_url, +// ); +// +// assert_eq!(node_a_v070.node_id(), node_id_a); +// assert_eq!(node_b_v070.node_id(), node_id_b); +// +// let pre_downgrade_payment_id = +// ldk_node_070::lightning::ln::channelmanager::PaymentId(pre_downgrade_payment_id.0); +// assert_v070_bolt11_payment( +// &node_a_v070, +// &pre_downgrade_payment_id, +// ldk_node_070::payment::PaymentDirection::Outbound, +// PRE_DOWNGRADE_PAYMENT_MSAT, +// ); +// assert_v070_bolt11_payment( +// &node_b_v070, +// &pre_downgrade_payment_id, +// ldk_node_070::payment::PaymentDirection::Inbound, +// PRE_DOWNGRADE_PAYMENT_MSAT, +// ); +// +// node_a_v070.sync_wallets().unwrap(); +// node_b_v070.sync_wallets().unwrap(); +// node_a_v070.connect(node_id_b, v070_addresses_b.first().unwrap().clone(), true).unwrap(); +// wait_for_v070_usable_channel(&node_a_v070, node_id_b).await; +// wait_for_v070_usable_channel(&node_b_v070, node_id_a).await; +// drain_v070_events(&node_a_v070).await; +// drain_v070_events(&node_b_v070).await; +// +// send_v070_bolt11_payment( +// &node_a_v070, +// &node_b_v070, +// POST_DOWNGRADE_PAYMENT_MSAT, +// "post-downgrade", +// ) +// .await; +// +// node_a_v070.stop().unwrap(); +// node_b_v070.stop().unwrap(); +// } +// +// fn build_current_node( +// storage_path: String, seed_bytes: [u8; 64], listening_addresses: Vec, +// alias: &str, esplora_url: &str, +// ) -> CurrentNode { +// let mut config = Config::default(); +// config.network = bitcoin::Network::Regtest; +// config.storage_dir_path = storage_path; +// config.listening_addresses = Some(listening_addresses); +// config.anchor_channels_config = None; +// +// // Use the v1 filesystem layout that v0.7.0's filesystem builder can reopen. +// let mut fs_store_path = PathBuf::from(&config.storage_dir_path); +// fs_store_path.push("fs_store"); +// #[allow(unused_mut)] +// let mut builder = ldk_node::Builder::from_config(config); +// builder.set_node_alias(alias.to_string()).unwrap(); +// +// let mut sync_config = EsploraSyncConfig::default(); +// sync_config.background_sync_config = None; +// builder.set_chain_source_esplora(esplora_url.to_owned(), Some(sync_config)); +// +// #[cfg(feature = "uniffi")] +// let node_entropy = std::sync::Arc::new(NodeEntropy::from_seed_bytes(seed_bytes.to_vec()).unwrap()); +// #[cfg(not(feature = "uniffi"))] +// let node_entropy = NodeEntropy::from_seed_bytes(seed_bytes); +// +// let kv_store = FilesystemStore::new(fs_store_path); +// let node = builder.build_with_store(node_entropy.into(), kv_store).unwrap(); +// node.start().unwrap(); +// node +// } +// +// fn build_v070_node( +// storage_path: String, seed_bytes: [u8; 64], +// listening_addresses: Vec, alias: &str, +// esplora_url: &str, +// ) -> ldk_node_070::Node { +// let mut builder = ldk_node_070::Builder::new(); +// builder.set_network(bitcoin::Network::Regtest); +// builder.set_storage_dir_path(storage_path); +// builder.set_entropy_seed_bytes(seed_bytes); +// builder.set_listening_addresses(listening_addresses).unwrap(); +// builder.set_node_alias(alias.to_string()).unwrap(); +// builder.set_chain_source_esplora(esplora_url.to_owned(), None); +// let node = builder.build_with_fs_store().unwrap(); +// node.start().unwrap(); +// node +// } +// +// async fn open_current_channel(node_a: &CurrentNode, node_b: &CurrentNode) -> bitcoin::OutPoint { +// node_a +// .open_channel( +// node_b.node_id(), +// node_b.listening_addresses().unwrap().first().unwrap().clone(), +// CHANNEL_AMOUNT_SAT, +// Some(PUSH_AMOUNT_MSAT), +// None, +// ) +// .unwrap(); +// +// let funding_txo_a = expect_current_channel_pending(node_a, node_b.node_id()).await; +// let funding_txo_b = expect_current_channel_pending(node_b, node_a.node_id()).await; +// assert_eq!(funding_txo_a, funding_txo_b); +// funding_txo_a +// } +// +// async fn send_current_bolt11_payment( +// payer: &CurrentNode, payee: &CurrentNode, amount_msat: u64, description: &str, +// ) -> ldk_node::lightning::ln::channelmanager::PaymentId { +// let invoice_description = CurrentBolt11InvoiceDescription::Direct( +// CurrentDescription::new(description.to_owned()).unwrap(), +// ); +// let invoice = payee +// .bolt11_payment() +// .receive(amount_msat, &invoice_description.clone().into(), 3600) +// .unwrap(); +// let payment_id = payer.bolt11_payment().send(&invoice, None).unwrap(); +// expect_current_payment_successful(payer, &payment_id).await; +// expect_current_payment_received(payee, amount_msat).await; +// assert_eq!( +// payer.payment(&payment_id).unwrap().status, +// ldk_node::payment::PaymentStatus::Succeeded +// ); +// payment_id +// } +// +// async fn send_v070_bolt11_payment( +// payer: &ldk_node_070::Node, payee: &ldk_node_070::Node, amount_msat: u64, description: &str, +// ) { +// let invoice_description = ldk_node_070::lightning_invoice::Bolt11InvoiceDescription::Direct( +// ldk_node_070::lightning_invoice::Description::new(description.to_owned()).unwrap(), +// ); +// let invoice = payee.bolt11_payment().receive(amount_msat, &invoice_description, 3600).unwrap(); +// let payment_id = payer.bolt11_payment().send(&invoice, None).unwrap(); +// expect_v070_payment_successful(payer, &payment_id).await; +// expect_v070_payment_received(payee, amount_msat).await; +// assert_eq!( +// payer.payment(&payment_id).unwrap().status, +// ldk_node_070::payment::PaymentStatus::Succeeded +// ); +// } +// +// async fn expect_current_channel_pending( +// node: &CurrentNode, expected_counterparty: PublicKey, +// ) -> bitcoin::OutPoint { +// match next_current_event(node).await { +// ldk_node::Event::ChannelPending { counterparty_node_id, funding_txo, .. } => { +// assert_eq!(counterparty_node_id, expected_counterparty); +// node.event_handled().unwrap(); +// funding_txo +// }, +// event => panic!("{} got unexpected event: {:?}", node.node_id(), event), +// } +// } +// +// async fn expect_current_channel_ready(node: &CurrentNode, expected_counterparty: PublicKey) { +// match next_current_event(node).await { +// ldk_node::Event::ChannelReady { counterparty_node_id, .. } => { +// assert_eq!(counterparty_node_id, Some(expected_counterparty)); +// node.event_handled().unwrap(); +// }, +// event => panic!("{} got unexpected event: {:?}", node.node_id(), event), +// } +// } +// +// async fn expect_current_payment_successful( +// node: &CurrentNode, expected_payment_id: &ldk_node::lightning::ln::channelmanager::PaymentId, +// ) { +// match next_current_event(node).await { +// ldk_node::Event::PaymentSuccessful { payment_id, .. } => { +// assert_eq!(payment_id.as_ref(), Some(expected_payment_id)); +// node.event_handled().unwrap(); +// }, +// event => panic!("{} got unexpected event: {:?}", node.node_id(), event), +// } +// } +// +// async fn expect_current_payment_received(node: &CurrentNode, expected_amount_msat: u64) { +// match next_current_event(node).await { +// ldk_node::Event::PaymentReceived { amount_msat, payment_id, .. } => { +// assert_eq!(amount_msat, expected_amount_msat); +// assert!(payment_id.is_some()); +// node.event_handled().unwrap(); +// }, +// event => panic!("{} got unexpected event: {:?}", node.node_id(), event), +// } +// } +// +// async fn expect_v070_payment_successful( +// node: &ldk_node_070::Node, +// expected_payment_id: &ldk_node_070::lightning::ln::channelmanager::PaymentId, +// ) { +// match next_v070_event(node).await { +// ldk_node_070::Event::PaymentSuccessful { payment_id, .. } => { +// assert_eq!(payment_id.as_ref(), Some(expected_payment_id)); +// node.event_handled().unwrap(); +// }, +// event => panic!("{} got unexpected event: {:?}", node.node_id(), event), +// } +// } +// +// async fn expect_v070_payment_received(node: &ldk_node_070::Node, expected_amount_msat: u64) { +// match next_v070_event(node).await { +// ldk_node_070::Event::PaymentReceived { amount_msat, payment_id, .. } => { +// assert_eq!(amount_msat, expected_amount_msat); +// assert!(payment_id.is_some()); +// node.event_handled().unwrap(); +// }, +// event => panic!("{} got unexpected event: {:?}", node.node_id(), event), +// } +// } +// +// async fn next_current_event(node: &CurrentNode) -> ldk_node::Event { +// tokio::time::timeout(Duration::from_secs(common::INTEROP_TIMEOUT_SECS), node.next_event_async()) +// .await +// .unwrap_or_else(|_| panic!("{} timed out waiting for event", node.node_id())) +// } +// +// async fn next_v070_event(node: &ldk_node_070::Node) -> ldk_node_070::Event { +// tokio::time::timeout(Duration::from_secs(common::INTEROP_TIMEOUT_SECS), node.next_event_async()) +// .await +// .unwrap_or_else(|_| panic!("{} timed out waiting for event", node.node_id())) +// } +// +// async fn drain_v070_events(node: &ldk_node_070::Node) { +// while tokio::time::timeout(Duration::from_millis(250), node.next_event_async()).await.is_ok() { +// node.event_handled().unwrap(); +// } +// } +// +// async fn wait_for_v070_usable_channel(node: &ldk_node_070::Node, counterparty_node_id: PublicKey) { +// for _ in 0..40 { +// let channels = node.list_channels(); +// if let Some(channel) = +// channels.iter().find(|c| c.counterparty_node_id == counterparty_node_id) +// { +// assert_eq!(channel.channel_value_sats, CHANNEL_AMOUNT_SAT); +// if channel.is_channel_ready && channel.is_usable { +// return; +// } +// } +// tokio::time::sleep(Duration::from_millis(250)).await; +// } +// +// panic!( +// "{} failed to restore a usable v0.7.0 channel with {}", +// node.node_id(), +// counterparty_node_id +// ); +// } +// +// fn assert_current_channel_ready(node: &CurrentNode, counterparty_node_id: PublicKey) { +// let channels = node.list_channels(); +// let channel = channels.iter().find(|c| c.counterparty.node_id == counterparty_node_id).unwrap(); +// assert_eq!(channel.channel_value_sats, CHANNEL_AMOUNT_SAT); +// assert!(channel.is_channel_ready); +// } +// +// fn assert_v070_bolt11_payment( +// node: &ldk_node_070::Node, payment_id: &ldk_node_070::lightning::ln::channelmanager::PaymentId, +// expected_direction: ldk_node_070::payment::PaymentDirection, expected_amount_msat: u64, +// ) { +// let payment = node.payment(payment_id).unwrap(); +// assert_eq!(payment.amount_msat, Some(expected_amount_msat)); +// assert_eq!(payment.direction, expected_direction); +// assert_eq!(payment.status, ldk_node_070::payment::PaymentStatus::Succeeded); +// assert!(matches!(payment.kind, ldk_node_070::payment::PaymentKind::Bolt11 { .. })); +// } +// +// fn to_v070_socket_addresses( +// addresses: &[CurrentSocketAddress], +// ) -> Vec { +// addresses +// .iter() +// .map(|address| match address { +// CurrentSocketAddress::TcpIpV4 { addr, port } => { +// ldk_node_070::lightning::ln::msgs::SocketAddress::TcpIpV4 { +// addr: *addr, +// port: *port, +// } +// }, +// _ => panic!("unexpected non-IPv4 test address: {:?}", address), +// }) +// .collect() +// }