diff --git a/.github/workflows/0fc-integration.yml b/.github/workflows/0fc-integration.yml new file mode 100644 index 0000000000..02a25eef5a --- /dev/null +++ b/.github/workflows/0fc-integration.yml @@ -0,0 +1,49 @@ +name: CI Checks - 0FC Integration Tests + +on: [push, pull_request] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-test: + timeout-minutes: 60 + runs-on: self-hosted + steps: + - name: Checkout source code + 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 + - name: Enable caching for bitcoind + id: cache-bitcoind + 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@v4 + with: + path: bin/electrs-${{ runner.os }}-${{ runner.arch }} + key: electrs-submit-package-${{ runner.os }}-${{ runner.arch }} + - name: Download bitcoind + if: "steps.cache-bitcoind.outputs.cache-hit != 'true'" + run: | + source ./scripts/download_bitcoind_electrs.sh + mkdir -p bin + mv "$BITCOIND_EXE" bin/bitcoind-${{ runner.os }}-${{ runner.arch }} + - name: Download electrs + if: "steps.cache-electrs.outputs.cache-hit != 'true'" + run: | + source ./scripts/build_electrs.sh + mkdir -p bin + mv "$ELECTRS_EXE" bin/electrs-${{ runner.os }}-${{ runner.arch }} + - name: Set bitcoind/electrs environment variables + run: | + echo "BITCOIND_EXE=$( pwd )/bin/bitcoind-${{ runner.os }}-${{ runner.arch }}" >> "$GITHUB_ENV" + echo "ELECTRS_EXE=$( pwd )/bin/electrs-${{ runner.os }}-${{ runner.arch }}" >> "$GITHUB_ENV" + - name: Test with 0FC enabled + run: | + RUSTFLAGS="--cfg no_download --cfg cycle_tests --cfg tokio_unstable --cfg zero_fee_commitment_tests" cargo test -- --test-threads=1 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..cd7c654125 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,33 @@ ## 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. +- Upgrading from LDK Node v0.1 is no longer supported if the event queue still contains + a persisted `ChannelClosed` event. +- 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) +- The compact-block-filter chain source gains a third compiled mainnet birthday anchor at block + 965,999 (hash `00000000000000000000dbb4d1e55ad22ed5b5a7d81d4c0fe992fceb8a5302d0`), so a fresh + wallet built with `set_chain_source_cbf(.., Some(966_000))` scans from block 966,000 instead of + falling back to the taproot-activation anchor and roughly 256,000 blocks of filters. +- `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. +- `Config::anchor_channels_config` is no longer optional, hence anchor channels can no longer be + disabled. We still negotiate legacy channels if the peer does not support anchor channels. + +## 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..b9c6866a43 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,13 @@ panic = 'abort' # Abort on panic [features] default = [] postgres = ["dep:tokio-postgres", "dep:native-tls", "dep:postgres-native-tls"] +# Peerswap native primitives (B-series). Empty for now; gates all swap +# additions so the default build is byte-for-byte unaffected. +swaps = [] +# Cooperative cycle-balance primitive (manual-route circular self-payment). +# Gates the `Node::send_along_route` entry point; the `PaymentKind::Rebalance` +# variant and its `event.rs` claim path stay ungated for TLV compatibility. +cycles = [] [dependencies] #lightning = { version = "0.2.0", features = ["std"] } @@ -41,23 +48,24 @@ postgres = ["dep:tokio-postgres", "dep:native-tls", "dep:postgres-native-tls"] #lightning-macros = { version = "0.2.0" } #lightning-dns-resolver = { version = "0.3.0" } -lightning = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c", features = ["std"] } -lightning-types = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c" } -lightning-invoice = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c", features = ["std"] } -lightning-net-tokio = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c" } -lightning-persister = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c", features = ["tokio"] } -lightning-background-processor = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c" } -lightning-rapid-gossip-sync = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c" } -lightning-block-sync = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c", features = ["rest-client", "rpc-client", "tokio"] } -lightning-transaction-sync = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } -lightning-liquidity = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c", features = ["std"] } -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"]} +lightning = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "506cb91f2e0fb87906188b79777bcf42595d3623", features = ["std"] } +lightning-types = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "506cb91f2e0fb87906188b79777bcf42595d3623" } +lightning-invoice = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "506cb91f2e0fb87906188b79777bcf42595d3623", features = ["std"] } +lightning-net-tokio = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "506cb91f2e0fb87906188b79777bcf42595d3623" } +lightning-persister = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "506cb91f2e0fb87906188b79777bcf42595d3623", features = ["tokio"] } +lightning-background-processor = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "506cb91f2e0fb87906188b79777bcf42595d3623" } +lightning-rapid-gossip-sync = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "506cb91f2e0fb87906188b79777bcf42595d3623" } +lightning-block-sync = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "506cb91f2e0fb87906188b79777bcf42595d3623", features = ["rest-client", "rpc-client", "tokio"] } +lightning-transaction-sync = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "506cb91f2e0fb87906188b79777bcf42595d3623", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } +lightning-liquidity = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "506cb91f2e0fb87906188b79777bcf42595d3623", features = ["std"] } +lightning-macros = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "506cb91f2e0fb87906188b79777bcf42595d3623" } +lightning-dns-resolver = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "506cb91f2e0fb87906188b79777bcf42595d3623" } + +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"]} +bip157 = { version = "0.6.3", default-features = false } bitreq = { version = "0.3", default-features = false, features = ["async-https", "json-using-serde"] } rustls = { version = "0.23", default-features = false } @@ -69,7 +77,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,16 +90,17 @@ 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" } +bitcoin-payment-instructions = { git = "https://github.com/tnull/bitcoin-payment-instructions", rev = "0e430be98c09540624a68a68022ee0551e86d1be" } [target.'cfg(windows)'.dependencies] winapi = { version = "0.3", features = ["winbase"] } [dev-dependencies] -lightning = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c", features = ["std", "_test_utils"] } +lightning = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "506cb91f2e0fb87906188b79777bcf42595d3623", features = ["std", "_test_utils"] } +lightning-persister = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "506cb91f2e0fb87906188b79777bcf42595d3623", features = ["tokio"] } rand = { version = "0.9.2", default-features = false, features = ["std", "thread_rng", "os_rng"] } proptest = "1.0.0" regex = "1.5.6" @@ -134,6 +143,7 @@ check-cfg = [ "cfg(eclair_test)", "cfg(cycle_tests)", "cfg(hrn_tests)", + "cfg(zero_fee_commitment_tests)", ] [[bench]] 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/benches/payments.rs b/benches/payments.rs index 52769d7949..926dc5dade 100644 --- a/benches/payments.rs +++ b/benches/payments.rs @@ -121,13 +121,8 @@ fn payment_benchmark(c: &mut Criterion) { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes_with_store( - &chain_source, - false, - true, - false, - common::TestStoreType::Sqlite, - ); + let (node_a, node_b) = + setup_two_nodes_with_store(&chain_source, false, false, common::TestStoreType::Sqlite); let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(4).enable_all().build().unwrap(); diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index 7e9e61f5d5..c1a926f2fd 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -13,6 +13,8 @@ typedef dictionary TorConfig; typedef interface NodeEntropy; +typedef interface ProbingConfig; + typedef enum WordCount; [Remote] @@ -32,19 +34,30 @@ interface LogWriter { void log(LogRecord record); }; +interface ProbingConfigBuilder { + [Name=high_degree] + constructor(u64 top_node_count); + [Name=random_walk] + constructor(u64 max_hops); + void set_interval(u64 secs); + void set_max_locked_msat(u64 max_msat); + void set_diversity_penalty_msat(u64 penalty_msat); + void set_cooldown(u64 secs); + ProbingConfig build(); +}; + interface Builder { constructor(); [Name=from_config] 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 +73,7 @@ interface Builder { void set_node_alias(string node_alias); [Throws=BuildError] void set_async_payments_role(AsyncPaymentsRole? role); - void set_wallet_recovery_mode(); + void set_probing_config(ProbingConfig config); [Throws=BuildError] Node build(NodeEntropy node_entropy); [Throws=BuildError] @@ -99,7 +112,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 +138,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 +182,7 @@ interface FeeRate { typedef interface UnifiedPayment; -typedef interface LSPS1Liquidity; +typedef interface Liquidity; [Error] enum NodeError { @@ -231,6 +246,7 @@ enum NodeError { "LnurlAuthFailed", "LnurlAuthTimeout", "InvalidLnurl", + "ChainSourceNotSupported", }; typedef dictionary NodeStatus; @@ -275,6 +291,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/examples/custom_gossip_example.rs b/examples/custom_gossip_example.rs new file mode 100644 index 0000000000..27f444139e --- /dev/null +++ b/examples/custom_gossip_example.rs @@ -0,0 +1,161 @@ +// Example demonstrating how to use custom gossip metadata in LDK Node + +use ldk_node::bitcoin::secp256k1::PublicKey; +use ldk_node::bitcoin::Network; +use ldk_node::{Builder, Event, Node}; +use std::collections::HashMap; +use std::str::FromStr; +use std::sync::Arc; +use std::time::Duration; + +fn main() -> Result<(), Box> { + // Create and configure a node with custom gossip enabled + let mut builder = Builder::new(); + builder.set_network(Network::Testnet); + builder.set_chain_source_esplora("https://blockstream.info/testnet/api".to_string(), None); + builder.set_gossip_source_rgs( + "https://rapidsync.lightningdevkit.org/testnet/snapshot".to_string(), + ); + + // Enable custom gossip functionality + builder.enable_custom_gossip(); + + let node = builder.build()?; + + // Start the node + node.start()?; + + // Get the custom gossip handler + if let Some(custom_gossip) = node.custom_gossip() { + println!("Custom gossip handler is available!"); + + // Set our own metadata to advertise + let our_metadata = serde_json::json!({ + "version": "1.0", + "features": ["feature_a", "feature_b"], + "timestamp": chrono::Utc::now().timestamp(), + "description": "LDK Node with custom features" + }) + .to_string() + .into_bytes(); + + custom_gossip.set_our_metadata(our_metadata); + + // Example: Send custom metadata to a specific peer + // (This would typically be done after connecting to a peer) + let peer_metadata = serde_json::json!({ + "message": "Hello from custom gossip!", + "data": { + "custom_field": "custom_value" + } + }) + .to_string() + .into_bytes(); + + // In a real scenario, you'd have connected peers + // custom_gossip.send_metadata_to_peer(peer_node_id, peer_metadata); + + // Example: Get stored metadata for all nodes + let all_metadata = custom_gossip.get_all_metadata().clone(); + println!("Currently have metadata for {} nodes", all_metadata.len()); + + // Print metadata information + for (node_id, metadata) in all_metadata.clone() { + println!("Node {}: {} bytes of metadata", node_id, metadata.metadata.len()); + + // Try to parse as JSON + if let Ok(json_str) = String::from_utf8(metadata.metadata.clone()) { + if let Ok(json_value) = serde_json::from_str::(&json_str) { + println!(" Parsed JSON: {}", json_value); + } + } + } + + // Demonstrate event handling with custom gossip + println!("Monitoring for custom gossip events..."); + + // In a real application, you would handle events in a loop + // This is just a demonstration + for _ in 0..5 { + // Wait for events (timeout after 1 second) + std::thread::sleep(Duration::from_secs(1)); + + // In a real application, you would process events like this: + // match node.wait_next_event() { + // Event::... => { + // // Handle other events + // } + // // Custom gossip events would be handled through the custom_gossip handler + // // as they are processed automatically when messages are received + // } + } + + // Example: Check if we received any new metadata + let updated_metadata = custom_gossip.get_all_metadata(); + if updated_metadata.len() > all_metadata.clone().len() { + println!( + "Received new metadata from {} nodes", + updated_metadata.len() - all_metadata.len() + ); + } + } else { + println!("Custom gossip not enabled. Use builder.enable_custom_gossip() to enable it."); + } + + // Stop the node + node.stop()?; + + peer_to_peer_example()?; + + Ok(()) +} + +/// Example of how to integrate custom gossip in a peer-to-peer scenario +#[allow(dead_code)] +fn peer_to_peer_example() -> Result<(), Box> { + // Create two nodes for demonstration + let mut builder1 = Builder::new(); + builder1.set_network(Network::Regtest); + builder1.enable_custom_gossip(); + let node1 = builder1.build()?; + + let mut builder2 = Builder::new(); + builder2.set_network(Network::Regtest); + builder2.enable_custom_gossip(); + let node2 = builder2.build()?; + + // Start both nodes + node1.start()?; + node2.start()?; + + // Get custom gossip handlers + let gossip1 = node1.custom_gossip().unwrap(); + let gossip2 = node2.custom_gossip().unwrap(); + + // Set metadata for each node + let metadata1 = b"Node 1 custom data".to_vec(); + let metadata2 = b"Node 2 custom data".to_vec(); + + gossip1.set_our_metadata(metadata1); + gossip2.set_our_metadata(metadata2); + + // In a real scenario, you would: + // 1. Connect the nodes to each other + // 2. Custom metadata would be automatically exchanged when peers connect + // 3. Monitor the get_all_metadata() results to see received data + + println!("Peer-to-peer custom gossip example completed"); + + // Stop nodes + node1.stop()?; + node2.stop()?; + + Ok(()) +} + +/// Example showing custom feature flags (placeholder for future implementation) +#[allow(dead_code)] +fn custom_features_example() { + println!("Custom feature flags would be implemented in the provided_node_features() method"); + println!("This allows advertising custom capabilities to peers during connection"); +} diff --git a/scripts/build_electrs.sh b/scripts/build_electrs.sh new file mode 100755 index 0000000000..2235986c8c --- /dev/null +++ b/scripts/build_electrs.sh @@ -0,0 +1,35 @@ +#!/bin/bash +set -eox pipefail + +# Our Esplora-based tests require `electrs` binaries. Here, we +# download the code, build the binaries, and export their location +# via `ELECTRS_EXE` which will be used by the `electrsd` crates in +# our tests. + +HOST_PLATFORM="$(rustc --version --verbose | grep "host:" | awk '{ print $2 }')" +ELECTRS_GIT_REPO="https://github.com/tankyleo/blockstream-electrs.git" +ELECTRS_TAG="2026-05-26-electrum-submit-package" +ELECTRS_REV="8c06d8010e43f793b1a65f83695ea846e5cd83ed" +if [[ "$HOST_PLATFORM" != *linux* && "$HOST_PLATFORM" != *darwin* ]]; then + printf "\n\n" + echo "Unsupported platform: $HOST_PLATFORM Exiting.." + exit 1 +fi + +DL_TMP_DIR=$(mktemp -d) +trap 'rm -rf -- "$DL_TMP_DIR"' EXIT + +pushd "$DL_TMP_DIR" +git clone --branch "$ELECTRS_TAG" --depth 1 "$ELECTRS_GIT_REPO" blockstream-electrs +cd blockstream-electrs +CURRENT_HEAD=$(git rev-parse HEAD) +if [ "$CURRENT_HEAD" != "$ELECTRS_REV" ]; then + echo "ERROR: HEAD does not match expected commit" + echo "expected: $ELECTRS_REV" + echo "actual: $CURRENT_HEAD" + exit 1 +fi +RUSTFLAGS="" cargo build +export ELECTRS_EXE="$DL_TMP_DIR"/blockstream-electrs/target/debug/electrs +chmod +x "$ELECTRS_EXE" +popd diff --git a/src/builder.rs b/src/builder.rs index c88c867cc1..125743aedd 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -37,21 +37,23 @@ 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; use lightning_dns_resolver::OMDomainResolver; use vss_client::headers::VssHeaderProvider; -use crate::chain::ChainSource; +use crate::chain::{CbfFeeSourceConfig, ChainServiceHooks, ChainSource}; use crate::config::{ default_user_config, may_announce_channel, AnnounceError, AsyncPaymentsRole, BitcoindRestClientConfig, Config, ElectrumSyncConfig, EsploraSyncConfig, HRNResolverConfig, TorConfig, DEFAULT_ESPLORA_SERVER_URL, DEFAULT_LOG_FILENAME, DEFAULT_LOG_LEVEL, + DEFAULT_MAX_PROBE_AMOUNT_MSAT, DEFAULT_MIN_PROBE_AMOUNT_MSAT, }; use crate::connection::ConnectionManager; +use crate::custom_gossip::CustomGossipMessageHandler; use crate::entropy::NodeEntropy; use crate::event::EventQueue; use crate::fee_estimator::OnchainFeeEstimator; @@ -68,14 +70,16 @@ 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::logger::{log_error, log_info, LdkLogger, LogLevel, LogWriter, Logger}; use crate::message_handler::NodeCustomMessageHandler; use crate::payment::asynchronous::om_mailbox::OnionMessageMailbox; use crate::peer_store::PeerStore; +use crate::probing::{ + HighDegreeStrategy, Prober, ProbingConfig, ProbingStrategy, ProbingStrategyKind, + RandomWalkStrategy, +}; use crate::runtime::{Runtime, RuntimeSpawner}; use crate::tx_broadcaster::TransactionBroadcaster; use crate::types::{ @@ -107,6 +111,12 @@ enum ChainDataSourceConfig { rpc_user: String, rpc_password: String, rest_client_config: Option, + wallet_rescan_from_height: Option, + }, + Cbf { + peers: Vec, + fee_source_config: Option, + wallet_rescan_from_height: Option, }, } @@ -123,10 +133,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 +208,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 +254,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 +284,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 +300,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 @@ -285,13 +311,15 @@ impl std::error::Error for BuildError {} pub struct NodeBuilder { config: Config, chain_data_source_config: Option, + cbf_chain_service_hooks: ChainServiceHooks, gossip_source_config: Option, liquidity_source_config: Option, log_writer_config: Option, async_payments_role: Option, runtime_handle: Option, pathfinding_scores_sync_config: Option, - recovery_mode: bool, + probing_config: Option, + custom_gossip_enabled: bool, } impl NodeBuilder { @@ -309,17 +337,20 @@ impl NodeBuilder { let log_writer_config = None; let runtime_handle = None; let pathfinding_scores_sync_config = None; - let recovery_mode = false; + let probing_config = None; + let custom_gossip_enabled = false; Self { config, chain_data_source_config, + cbf_chain_service_hooks: ChainServiceHooks::default(), gossip_source_config, liquidity_source_config, log_writer_config, runtime_handle, async_payments_role: None, pathfinding_scores_sync_config, - recovery_mode, + probing_config, + custom_gossip_enabled, } } @@ -376,6 +407,58 @@ impl NodeBuilder { self } + /// Configures the [`Node`] instance to source chain data via compact block filters + /// (BIP157/BIP158), connecting to the given peers (`ip:port`). + /// + /// `fee_source_config` optionally delegates fee estimation to an Esplora or Electrum server; + /// if `None`, fee rates are derived from recent blocks. + /// + /// `wallet_rescan_from_height` is an optional wallet birthday: the lowest height whose block + /// the wallet must still scan. It applies only while the wallet's persisted chain state is + /// still rooted at genesis; a wallet with a persisted block is never rewound. Because no + /// chain backend is reachable at build time, the wallet is anchored on the highest + /// checkpoint compiled into the `bip157` crate *strictly below* the given height — scanning + /// starts at the block after the anchor, so the block at the given height is always scanned + /// (heights at or below the lowest anchor fall back to a full scan from block 1; the + /// genesis block itself is unspendable by consensus and needs no scan). Mainnet anchors are + /// 481,823 (one block before SegWit activation), 709,631 (one block before taproot + /// activation) and 965,999 (mined 2026-09-08; a birthday of 966,000 scans from that block). + /// On all other networks a fresh wallet scans from genesis. Passing `None` + /// also scans from genesis — unlike the Bitcoin Core sources, where `None` anchors at the + /// current tip — because CBF has no trusted tip oracle at build time and anchoring lower is + /// the only direction that cannot skip wallet history. For a restored seed with older + /// on-chain activity, pass a height at or below its first transaction (or `None`). + pub fn set_chain_source_cbf( + &mut self, peers: Vec, fee_source_config: Option, + wallet_rescan_from_height: Option, + ) -> &mut Self { + self.chain_data_source_config = Some(ChainDataSourceConfig::Cbf { + peers, + fee_source_config, + wallet_rescan_from_height, + }); + self + } + + /// Configures app-supplied external chain-service hooks for the CBF chain source. + /// + /// `hooks.fee_estimates`, if set, is tried before CBF's own fee estimation (native + /// block-derived, or the [`CbfFeeSourceConfig`] passed to [`set_chain_source_cbf`]) on every + /// fee-update cycle; an `Err` result (or no hook at all) falls through to that configured + /// behavior unchanged. `hooks.broadcast`, if set, is tried before CBF's P2P broadcast on + /// every outgoing transaction package; an `Err` result (or no hook at all) falls through to + /// P2P broadcast unchanged. + /// + /// Only meaningful when paired with [`set_chain_source_cbf`]; a no-op for every other chain + /// source. This is intentionally payment-agnostic: the fork never learns anything about how + /// (or whether) a hook is backed by L402 or any other payment protocol. + /// + /// [`set_chain_source_cbf`]: Self::set_chain_source_cbf + pub fn set_cbf_chain_service_hooks(&mut self, hooks: ChainServiceHooks) -> &mut Self { + self.cbf_chain_service_hooks = hooks; + self + } + /// Configures the [`Node`] instance to connect to a Bitcoin Core node via RPC. /// /// This method establishes an RPC connection that enables all essential chain operations including @@ -384,8 +467,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 +481,7 @@ impl NodeBuilder { rpc_user, rpc_password, rest_client_config: None, + wallet_rescan_from_height, }); self } @@ -406,9 +495,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 +509,7 @@ impl NodeBuilder { rpc_user, rpc_password, rest_client_config: Some(BitcoindRestClientConfig { rest_host, rest_port }), + wallet_rescan_from_height, }); self @@ -443,45 +537,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 +576,24 @@ 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 + } + + /// Enables custom gossip message support for the [`Node`] instance. + /// + /// When enabled, the node will be able to send and receive custom gossip messages + /// containing metadata extensions to the standard Lightning gossip protocol. + /// + /// Custom gossip messages use message type 32769 and can contain arbitrary metadata + /// up to 4096 bytes in length. + pub fn enable_custom_gossip(&mut self) -> &mut Self { + self.custom_gossip_enabled = true; self } @@ -615,13 +712,27 @@ impl NodeBuilder { Ok(self) } - /// Configures the [`Node`] to resync chain data from genesis on first startup, recovering any - /// historical wallet funds. + /// Sets background probing config. + /// + /// Use [`ProbingConfigBuilder`] to build the configuration: + /// ```no_run + /// # #[cfg(not(feature = "uniffi"))] + /// # { + /// use std::time::Duration; + /// + /// use ldk_node::probing::ProbingConfigBuilder; + /// use ldk_node::Builder; /// - /// 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; + /// let mut builder = Builder::new(); + /// builder.set_probing_config( + /// ProbingConfigBuilder::high_degree(100).interval(Duration::from_secs(30)).build(), + /// ); + /// # } + /// ``` + /// + /// [`ProbingConfigBuilder`]: crate::probing::ProbingConfigBuilder + pub fn set_probing_config(&mut self, config: ProbingConfig) -> &mut Self { + self.probing_config = Some(config); self } @@ -826,7 +937,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 +956,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(); @@ -861,11 +972,13 @@ impl NodeBuilder { build_with_store_internal( config, self.chain_data_source_config.as_ref(), + self.cbf_chain_service_hooks.clone(), self.gossip_source_config.as_ref(), self.liquidity_source_config.as_ref(), self.pathfinding_scores_sync_config.as_ref(), + self.probing_config.as_ref(), self.async_payments_role, - self.recovery_mode, + self.custom_gossip_enabled, seed_bytes, runtime, logger, @@ -889,7 +1002,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 +1018,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 +1092,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 +1118,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 +1133,7 @@ impl ArcedNodeBuilder { rpc_port, rpc_user, rpc_password, + wallet_rescan_from_height, ); } @@ -1032,32 +1156,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`]. - /// - /// The given `token` will be used by the LSP to authenticate the user. + /// May be called multiple times to register several LSPs. Duplicate `node_id`s are ignored. /// - /// [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 +1187,19 @@ 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); + } + + /// Enables custom gossip message support for the [`Node`] instance. + /// + /// When enabled, the node will be able to send and receive custom gossip messages + /// containing metadata extensions to the standard Lightning gossip protocol. + /// + /// Custom gossip messages use message type 32769 and can contain arbitrary metadata + /// up to 4096 bytes in length. + pub fn enable_custom_gossip(&self) { + self.inner.write().unwrap().enable_custom_gossip(); } /// Sets the used storage directory path. @@ -1155,13 +1287,13 @@ 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. + /// Configures background probing. + /// + /// Use [`ProbingConfigBuilder`] to build the configuration. /// - /// 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(); + /// [`ProbingConfigBuilder`]: crate::probing::ProbingConfigBuilder + pub fn set_probing_config(&self, config: Arc) { + self.inner.write().expect("lock").set_probing_config((*config).clone()); } /// Builds a [`Node`] instance with a [`SqliteStore`] backend and according to the options @@ -1346,7 +1478,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) @@ -1356,11 +1488,12 @@ impl ArcedNodeBuilder { /// Builds a [`Node`] instance according to the options previously configured. fn build_with_store_internal( config: Arc, chain_data_source_config: Option<&ChainDataSourceConfig>, - gossip_source_config: Option<&GossipSourceConfig>, + cbf_chain_service_hooks: ChainServiceHooks, 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, + probing_config: Option<&ProbingConfig>, async_payments_role: Option, + custom_gossip_enabled: bool, seed_bytes: [u8; 64], runtime: Arc, logger: Arc, + kv_store: Arc, ) -> Result { optionally_install_rustls_cryptoprovider(); @@ -1470,12 +1603,31 @@ fn build_with_store_internal( Arc::clone(&node_metrics), ) }, + Some(ChainDataSourceConfig::Cbf { + peers, + fee_source_config, + wallet_rescan_from_height, + }) => ChainSource::new_cbf( + peers.clone(), + fee_source_config.clone(), + *wallet_rescan_from_height, + Arc::clone(&runtime), + Arc::clone(&fee_estimator), + Arc::clone(&tx_broadcaster), + Arc::clone(&kv_store), + Arc::clone(&config), + Arc::clone(&logger), + Arc::clone(&node_metrics), + cbf_chain_service_hooks.clone(), + ) + .map_err(|_| BuildError::ChainSourceSetupFailed)?, Some(ChainDataSourceConfig::Bitcoind { rpc_host, rpc_port, 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 +1681,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 +1729,76 @@ fn build_with_store_internal( }, })?; let bdk_wallet = match wallet_opt { - Some(wallet) => wallet, + Some(mut wallet) => { + // `wallet_rescan_from_height`, when set, applies only while the wallet's + // persisted chain state is still rooted at genesis. Rewinding a wallet with a + // persisted block 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. + // + // One exception heals the crash window between wallet creation and the initial + // checkpoint persist: a CBF wallet whose chain state never persisted a block + // past genesis is re-anchored at the compiled birthday checkpoint. The + // anchor is a deterministic constant, so this is exactly the checkpoint the + // wallet received at creation; without it, listeners already persisted at the + // birthday would latch divergence against the genesis-rooted wallet on every + // subsequent start. + if wallet.latest_checkpoint().height() == 0 + && matches!(chain_data_source_config, Some(ChainDataSourceConfig::Cbf { .. })) + { + if let Some(best_block) = chain_tip_opt { + let block_id = bdk_chain::BlockId { + height: best_block.height, + hash: best_block.block_hash, + }; + let latest_checkpoint = wallet.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 re-apply the wallet birthday checkpoint: {}", + e + ); + BuildError::WalletSetupFailed + })?; + runtime.block_on(wallet.persist_async(&mut wallet_persister)).map_err(|e| { + log_error!( + logger, + "Failed to persist the wallet birthday checkpoint: {}", + e + ); + BuildError::WalletSetupFailed + })?; + log_info!( + logger, + "Re-anchored an unscanned wallet at the configured CBF birthday (height {}).", + best_block.height + ); + } + } + 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 +1811,75 @@ 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 + })?; + // `apply_update` only stages the checkpoint. Persist it now: a crash before + // the first persisted block would otherwise resurrect a genesis-rooted + // wallet next to listeners already initialized at the checkpoint, which the + // CBF divergence gate then latches on every subsequent start. + runtime.block_on(wallet.persist_async(&mut wallet_persister)).map_err(|e| { + log_error!(logger, "Failed to persist checkpoint during wallet setup: {}", e); + BuildError::WalletSetupFailed + })?; } wallet }, @@ -1634,6 +1912,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); @@ -1932,6 +2212,7 @@ fn build_with_store_internal( Arc::clone(&channel_manager), Arc::clone(&om_resolver), IgnoringMessageHandler {}, + false, )) } else { Arc::new(OnionMessenger::new( @@ -1975,33 +2256,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,16 +2282,28 @@ 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 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) + // The liquidity handler is always wired up; custom gossip rides alongside it when enabled. + let custom_message_handler = if custom_gossip_enabled { + let gossip_handler = Arc::new(CustomGossipMessageHandler::new(Arc::clone(&logger))); + Arc::new(NodeCustomMessageHandler::new_combined( + Arc::clone(&liquidity_source), + gossip_handler, + )) } else { - (None, Arc::new(NodeCustomMessageHandler::new_ignoring())) + Arc::new(NodeCustomMessageHandler::new_liquidity(Arc::clone(&liquidity_source))) }; + (liquidity_source, custom_message_handler) + }; + + // Extract custom gossip handler for later use + let custom_gossip_handler = custom_message_handler.custom_gossip_handler(); + let msg_handler = match gossip_source.as_gossip_sync() { GossipSync::P2P(p2p_gossip_sync) => MessageHandler { chan_handler: Arc::clone(&channel_manager), @@ -2072,7 +2351,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), @@ -2152,6 +2431,51 @@ fn build_with_store_internal( _leak_checker.0.push(Arc::downgrade(&wallet) as Weak); } + let prober = probing_config.map(|probing_cfg| { + let strategy: Arc = match &probing_cfg.kind { + ProbingStrategyKind::HighDegree { top_node_count } => { + // Dedicated router for probing so the diversity penalty doesn't interfere + // with real payments; shares the scorer so probe results still train it. + let mut probing_fee_params = ProbabilisticScoringFeeParameters::default(); + if let Some(penalty) = probing_cfg.diversity_penalty_msat { + probing_fee_params.probing_diversity_penalty_msat = penalty; + } + let probing_router = Arc::new(DefaultRouter::new( + Arc::clone(&network_graph), + Arc::clone(&logger), + Arc::clone(&keys_manager), + Arc::clone(&scorer), + probing_fee_params, + )); + Arc::new(HighDegreeStrategy::new( + Arc::clone(&network_graph), + Arc::clone(&channel_manager), + probing_router, + *top_node_count, + DEFAULT_MIN_PROBE_AMOUNT_MSAT, + DEFAULT_MAX_PROBE_AMOUNT_MSAT, + probing_cfg.cooldown, + config.probing_liquidity_limit_multiplier, + )) + }, + ProbingStrategyKind::RandomWalk { max_hops } => Arc::new(RandomWalkStrategy::new( + Arc::clone(&network_graph), + Arc::clone(&channel_manager), + *max_hops, + DEFAULT_MIN_PROBE_AMOUNT_MSAT, + DEFAULT_MAX_PROBE_AMOUNT_MSAT, + )), + ProbingStrategyKind::Custom(s) => Arc::clone(s), + }; + Arc::new(Prober { + channel_manager: Arc::clone(&channel_manager), + logger: Arc::clone(&logger), + strategy, + interval: probing_cfg.interval, + max_locked_msat: probing_cfg.max_locked_msat, + }) + }); + Ok(Node { runtime, stop_sender, @@ -2173,6 +2497,7 @@ fn build_with_store_internal( gossip_source, pathfinding_scores_sync_url, liquidity_source, + custom_gossip_handler, kv_store, logger, _router: router, @@ -2185,8 +2510,11 @@ fn build_with_store_internal( om_mailbox, async_payments_role, hrn_resolver, + prober, #[cfg(cycle_tests)] _leak_checker, + #[cfg(feature = "swaps")] + swap_tx_watch: Arc::new(crate::chain::SwapTxWatch::new()), }) } diff --git a/src/chain/bitcoind.rs b/src/chain/bitcoind.rs index 6bfa8ffd27..3f8f8b84e7 100644 --- a/src/chain/bitcoind.rs +++ b/src/chain/bitcoind.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::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::fmt; use std::future::Future; use std::sync::atomic::{AtomicU64, Ordering}; @@ -14,6 +14,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use base64::prelude::BASE64_STANDARD; use base64::Engine; +use bitcoin::transaction::Version; use bitcoin::{BlockHash, FeeRate, Network, OutPoint, Transaction, Txid}; use lightning::chain::chaininterface::ConfirmationTarget as LdkConfirmationTarget; use lightning::chain::{BlockLocator, Listen}; @@ -41,6 +42,7 @@ use crate::fee_estimator::{ }; use crate::io::utils::update_and_persist_node_metrics; use crate::logger::{log_bytes, log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; +use crate::tx_broadcaster::SortedTransactions; use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::{Error, PersistedNodeMetrics}; @@ -119,6 +121,80 @@ impl BitcoindChainSource { self.api_client.utxo_source() } + /// Returns the shared on-chain fee estimator backing this chain source + /// (Peerswap native primitive B6). + #[cfg(feature = "swaps")] + pub(super) fn fee_estimator(&self) -> &Arc { + &self.fee_estimator + } + + /// Reorg-aware confirmation/eviction query for an ARBITRARY `txid` against + /// this Bitcoind backend (Peerswap native primitive B5). See + /// [`super::ChainSource::swap_query_tx`] for the fail-closed contract. + #[cfg(feature = "swaps")] + pub(super) async fn swap_query_tx(&self, txid: Txid) -> super::RawTxObservation { + match self.api_client.swap_tx_confirmations(&txid).await { + Ok(Some(0)) => super::RawTxObservation::InMempool, + Ok(Some(confirmations)) => { + // `getrawtransaction` returns the depth but not the height; derive + // it as `tip - (confs - 1)`. B5 LOW-2: read a FRESH best-chain tip + // (`get_best_block`) rather than the cached `latest_chain_tip`, + // which can lag the real tip and yield a height that is too low — + // and thus a CSV/claim deadline armed slightly EARLY. A fresh (or + // even a one-block-stale-newer) tip can only err on the LATE/safe + // side. Fail-soft on the HEIGHT ONLY: the depth is already + // authoritative, so on a tip-read error we fall back to the cached + // tip rather than failing the whole query closed. + let tip_height = match self.api_client.get_best_block().await { + Ok((_, Some(h))) => Some(h), + Ok((_, None)) => { + self.latest_chain_tip.read().unwrap().as_ref().map(|tip| tip.height) + }, + Err(e) => { + log_error!( + self.logger, + "swap_query_tx: Bitcoind fresh-tip read failed for {} ({:?}); falling back to cached tip for height", + txid, + e + ); + self.latest_chain_tip.read().unwrap().as_ref().map(|tip| tip.height) + }, + }; + let height = tip_height.map(|t| t.saturating_sub(confirmations.saturating_sub(1))); + super::RawTxObservation::Confirmed { height, confirmations } + }, + Ok(None) => super::RawTxObservation::NotFound, + Err(e) => { + log_error!(self.logger, "swap_query_tx: Bitcoind query failed for {}: {}", txid, e); + super::RawTxObservation::Unreachable + }, + } + } + + pub(super) async fn validate_zero_fee_commitments_support(&self) -> Result<(), Error> { + let node_version_result = tokio::time::timeout( + Duration::from_secs(CHAIN_POLLING_TIMEOUT_SECS), + self.api_client.get_node_version(), + ) + .await + .map_err(|e| { + log_error!(self.logger, "Failed to get node version: {:?}", e); + Error::ConnectionFailed + })?; + + let node_version = node_version_result.map_err(|e| { + log_error!(self.logger, "Failed to get node version: {:?}", e); + Error::ConnectionFailed + })?; + + // v26 first shipped the `submitpackage` RPC, but we need v29 to relay ephemeral dust + if node_version < 290000 { + log_error!(self.logger, "Bitcoin backend MUST be greater than or equal to v29"); + return Err(Error::ChainSourceNotSupported); + } + Ok(()) + } + pub(super) async fn continuously_sync_wallets( &self, mut stop_sync_receiver: tokio::sync::watch::Receiver<()>, onchain_wallet: Arc, channel_manager: Arc, @@ -399,6 +475,9 @@ impl BitcoindChainSource { channel_manager: Arc::clone(&channel_manager), chain_monitor: Arc::clone(&chain_monitor), output_sweeper, + logger: Arc::clone(&self.logger), + divergence: Arc::new(Mutex::new(None)), + replay_batch: Arc::new(Mutex::new(BTreeMap::new())), }; let mut spv_client = SpvClient::new(chain_tip, chain_poller, HeaderCache::new(), &chain_listener); @@ -435,11 +514,12 @@ impl BitcoindChainSource { evicted_txids.len(), elapsed_ms, ); - onchain_wallet.apply_mempool_txs(unconfirmed_txs, evicted_txids).unwrap_or_else( - |e| { + onchain_wallet + .apply_mempool_txs(unconfirmed_txs, evicted_txids) + .await + .unwrap_or_else(|e| { log_error!(self.logger, "Failed to apply mempool transactions: {:?}", e); - }, - ); + }); }, Err(e) => { log_error!(self.logger, "Failed to poll for mempool transactions: {:?}", e); @@ -571,46 +651,59 @@ impl BitcoindChainSource { Ok(()) } - pub(crate) async fn process_broadcast_package(&self, package: Vec) { - // While it's a bit unclear when we'd be able to lean on Bitcoin Core >v28 - // features, we should eventually switch to use `submitpackage` via the - // `rust-bitcoind-json-rpc` crate rather than just broadcasting individual - // transactions. - for tx in &package { - let txid = tx.compute_txid(); - let timeout_fut = tokio::time::timeout( - Duration::from_secs(DEFAULT_TX_BROADCAST_TIMEOUT_SECS), - self.api_client.broadcast_transaction(tx), - ); - match timeout_fut.await { - Ok(res) => match res { - Ok(id) => { - debug_assert_eq!(id, txid); - log_trace!(self.logger, "Successfully broadcast transaction {}", txid); - }, - Err(e) => { - log_error!(self.logger, "Failed to broadcast transaction {}: {}", txid, e); - log_trace!( - self.logger, - "Failed broadcast transaction bytes: {}", - log_bytes!(tx.encode()) - ); + fn log_broadcast_error( + &self, e: impl core::fmt::Display, txids: &[Txid], txs: &SortedTransactions, + ) { + log_error!(self.logger, "Failed to broadcast transaction(s) {:?}: {}", txids, e); + log_trace!(self.logger, "Failed broadcast transaction bytes:"); + for tx in txs.iter() { + log_trace!(self.logger, "{}", log_bytes!(tx.encode())); + } + } + + pub(crate) async fn process_transaction_broadcast(&self, txs: SortedTransactions) { + let all_txs_are_v3 = txs.iter().all(|tx| tx.version == Version::non_standard(3)); + match txs.len() { + 2.. if all_txs_are_v3 => { + let txids: Vec<_> = txs.iter().map(|tx| tx.compute_txid()).collect(); + let timeout_fut = tokio::time::timeout( + Duration::from_secs(DEFAULT_TX_BROADCAST_TIMEOUT_SECS), + self.api_client.submit_package(&txs), + ); + match timeout_fut.await { + Ok(res) => match res { + Ok(result) => { + log_trace!(self.logger, "Successfully broadcast package {:?}", txids); + log_trace!(self.logger, "Successfully broadcast package {}", result); + }, + Err(e) => self.log_broadcast_error(e, &txids, &txs), }, - }, - Err(e) => { - log_error!( - self.logger, - "Failed to broadcast transaction due to timeout {}: {}", - txid, - e - ); - log_trace!( - self.logger, - "Failed broadcast transaction bytes: {}", - log_bytes!(tx.encode()) + Err(e) => self.log_broadcast_error(e, &txids, &txs), + } + }, + _ => { + for tx in txs.iter() { + let txid = tx.compute_txid(); + let timeout_fut = tokio::time::timeout( + Duration::from_secs(DEFAULT_TX_BROADCAST_TIMEOUT_SECS), + self.api_client.broadcast_transaction(tx), ); - }, - } + match timeout_fut.await { + Ok(res) => match res { + Ok(id) => { + debug_assert_eq!(id, txid); + log_trace!( + self.logger, + "Successfully broadcast transaction {}", + txid + ); + }, + Err(e) => self.log_broadcast_error(e, &[txid], &txs), + }, + Err(e) => self.log_broadcast_error(e, &[txid], &txs), + } + } + }, } } } @@ -748,6 +841,31 @@ impl BitcoindClient { } } + pub(crate) async fn get_node_version(&self) -> Result { + match self { + BitcoindClient::Rpc { rpc_client, .. } => { + Self::get_node_version_inner(Arc::clone(rpc_client)) + .await + .map_err(BitcoindClientError::Rpc) + }, + BitcoindClient::Rest { rpc_client, .. } => { + // Bitcoin Core's REST interface does not support `getnetworkinfo` + // so we use the RPC client. + Self::get_node_version_inner(Arc::clone(rpc_client)) + .await + .map_err(BitcoindClientError::Rpc) + }, + } + } + + async fn get_node_version_inner(rpc_client: Arc) -> Result { + rpc_client.call_method::("getnetworkinfo", &[]).await.and_then(|value| { + value["version"].as_u64().ok_or(RpcClientError::InvalidData(String::from( + "The version field in the `getnetworkinfo` response should be a u64", + ))) + }) + } + /// Broadcasts the provided transaction. pub(crate) async fn broadcast_transaction( &self, tx: &Transaction, @@ -776,6 +894,38 @@ impl BitcoindClient { rpc_client.call_method::("sendrawtransaction", &[tx_json]).await } + /// Submits the provided package + pub(crate) async fn submit_package( + &self, package: &SortedTransactions, + ) -> Result { + match self { + BitcoindClient::Rpc { rpc_client, .. } => { + Self::submit_package_inner(Arc::clone(rpc_client), package) + .await + .map_err(BitcoindClientError::Rpc) + }, + BitcoindClient::Rest { rpc_client, .. } => { + // Bitcoin Core's REST interface does not support submitting packages + // so we use the RPC client. + Self::submit_package_inner(Arc::clone(rpc_client), package) + .await + .map_err(BitcoindClientError::Rpc) + }, + } + } + + async fn submit_package_inner( + rpc_client: Arc, package: &SortedTransactions, + ) -> Result { + let package_serialized: Vec<_> = + package.iter().map(|tx| bitcoin::consensus::encode::serialize_hex(tx)).collect(); + let package_json = serde_json::json!(package_serialized); + rpc_client + .call_method::("submitpackage", &[package_json]) + .await + .map(|response| response.0) + } + /// Retrieve the fee estimate needed for a transaction to begin /// confirmation within the provided `num_blocks`. pub(crate) async fn get_fee_estimate_for_target( @@ -908,6 +1058,45 @@ impl BitcoindClient { } } + /// Confirmation depth for an ARBITRARY `txid` via verbose `getrawtransaction` + /// (Peerswap native primitive B5). + /// + /// To locate a tx by its id alone (the swap case — a counterparty opening tx + /// that is not in our wallet), the backend must be able to find it, i.e. a + /// `-txindex` node (or the tx still resident in the mempool). Returns: + /// - `Ok(Some(n))` with `n >= 1` for a tx confirmed `n` blocks deep, + /// - `Ok(Some(0))` for a tx seen in the mempool but unconfirmed, + /// - `Ok(None)` when the node does not know the tx (RPC error code -5), + /// - `Err(..)` for any transport/other failure, so the caller fails closed. + #[cfg(feature = "swaps")] + pub(crate) async fn swap_tx_confirmations(&self, txid: &Txid) -> std::io::Result> { + let rpc_client = match self { + BitcoindClient::Rpc { rpc_client, .. } => Arc::clone(rpc_client), + BitcoindClient::Rest { rpc_client, .. } => Arc::clone(rpc_client), + }; + // `getrawtransaction` expects the txid in RPC/display (big-endian) order — + // exactly `Txid`'s `Display`. `consensus::encode::serialize_hex` emits the + // INTERNAL (little-endian) bytes, i.e. the REVERSED hex, which bitcoind rejects + // with -5 "No such transaction". That -5 is mapped to `Ok(None)` below, so the + // swap watcher would mistake EVERY confirmed opening tx for NotFound and never + // arm the confirmation/CSV ladder — wedging every swap on the bitcoind backend. + let txid_hex = txid.to_string(); + let txid_json = serde_json::json!(txid_hex); + let verbose_json = serde_json::json!(true); + match rpc_client + .call_method::( + "getrawtransaction", + &[txid_json, verbose_json], + ) + .await + { + Ok(resp) => Ok(Some(resp.0)), + // -5 == "No such mempool or blockchain transaction". + Err(RpcClientError::Rpc(rpc_error)) if rpc_error.code == -5 => Ok(None), + Err(e) => Err(std::io::Error::new(std::io::ErrorKind::Other, e.to_string())), + } + } + /// Retrieves the raw mempool. pub(crate) async fn get_raw_mempool(&self) -> Result, BitcoindClientError> { match self { @@ -1270,6 +1459,21 @@ impl TryInto for JsonResponse { } } +/// Confirmation depth parsed from a verbose `getrawtransaction` result +/// (Peerswap native primitive B5). The `confirmations` field is absent for an +/// unconfirmed (mempool) transaction, which we map to `0`. +#[cfg(feature = "swaps")] +pub(crate) struct SwapTxConfirmationResponse(pub u32); + +#[cfg(feature = "swaps")] +impl TryInto for JsonResponse { + type Error = String; + fn try_into(self) -> Result { + let confirmations = self.0["confirmations"].as_u64().unwrap_or(0); + Ok(SwapTxConfirmationResponse(confirmations as u32)) + } +} + pub struct GetRawMempoolResponse(Vec); impl TryInto for JsonResponse { @@ -1327,6 +1531,23 @@ impl TryInto for JsonResponse { } } +pub struct SubmitPackageResponse(String); + +impl TryInto for JsonResponse { + type Error = String; + fn try_into(self) -> Result { + let response = self.0.to_string(); + let res = self.0.as_object().ok_or("Failed to parse submitpackage response".to_string())?; + + match res["package_msg"].as_str() { + Some("success") => Ok(SubmitPackageResponse(response)), + Some(_) | None => { + return Err(response); + }, + } + } +} + #[derive(Debug, Clone)] pub(crate) struct MempoolEntry { /// The transaction id @@ -1344,11 +1565,322 @@ pub(crate) enum FeeRateEstimationMode { Conservative, } +#[derive(Clone)] pub(crate) struct ChainListener { pub(crate) onchain_wallet: Arc, pub(crate) channel_manager: Arc, pub(crate) chain_monitor: Arc, pub(crate) output_sweeper: Arc, + pub(crate) logger: Arc, + /// Records the first listener divergence seen since the last drain. + /// + /// `Listen` returns `()`, so divergence cannot be propagated through the trait. The chain + /// source drains this after each block and must stop advancing when it is set: continuing + /// would publish a "synced" tip while a listener sits on a stale chain. + pub(crate) divergence: Arc>>, + /// Per-listener tally of the replay batch in flight, drained at every tip boundary. + /// + /// Exists to tell two outcomes of [`ListenerAction::ReplayUnprovable`] apart, which are + /// indistinguishable block by block: a listener that skips a stretch it cannot prove and then + /// *reconnects* to the chain (benign — the resume-from-minimum design), and a listener the + /// replay can never reach at all (stranded on a fork). See + /// [`ChainListener::record_stranded_listeners`]. + pub(crate) replay_batch: Arc>>, +} + +/// What one listener decided about the blocks of the replay batch currently in flight. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ReplayTally { + /// Blocks skipped because ancestry could be neither confirmed nor refuted. + pub(crate) unprovable: u32, + /// Decisions that could actually be proven: delivered, an exact replay, or a fork. + pub(crate) provable: u32, + /// The listener's own tip height as of its most recent unprovable skip. + pub(crate) tip_height: u32, +} + +/// What a listener's replay batch amounts to once the replay has reached the chain's tip. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BatchVerdict { + /// Nothing to say: the listener was never asked about a block it could not prove. + Ordinary, + /// Skipped a stretch it could not prove and then reconnected to the chain. Benign — this is + /// what the resume-from-minimum design does on every restart with skewed listener durability. + SkippedThenReconnected, + /// The replay ended without this listener ever reaching a block it could prove, and its own + /// tip is at or above the tip we ended at, so no later replay of this chain can reach it + /// either. It is on a chain we can neither extend nor refute. + Stranded, +} + +impl ReplayTally { + /// Folds one decision into the batch tally. + fn note(&mut self, best: &BlockLocator, action: ListenerAction) { + match action { + ListenerAction::ReplayUnprovable => { + self.unprovable = self.unprovable.saturating_add(1); + self.tip_height = best.height; + }, + ListenerAction::Deliver | ListenerAction::AlreadyApplied | ListenerAction::Diverged => { + self.provable = self.provable.saturating_add(1) + }, + } + } + + /// Judges the batch against the tip the replay ended at. + /// + /// `provable == 0` on its own already implies the listener sits at or above `tip_height` — a + /// replay passes through every height at or below a listener's tip, and those compare + /// provably — but being unreachable is what actually makes the case terminal, so it is checked + /// rather than assumed. + fn verdict(&self, tip_height: u32) -> BatchVerdict { + if self.unprovable == 0 { + BatchVerdict::Ordinary + } else if self.provable > 0 || self.tip_height < tip_height { + BatchVerdict::SkippedThenReconnected + } else { + BatchVerdict::Stranded + } + } +} + +/// Whether a listener should be handed a given block. +/// +/// `ChannelManager` and `OutputSweeper` enforce LDK's `Listen` contract with `assert_eq!` on both +/// the previous block hash and `height == best + 1`, so handing either one a block it has already +/// applied panics the node rather than returning an error. Listener durability is not +/// synchronized — `ChannelManager` is persisted asynchronously by the background processor while +/// `OutputSweeper` is only marked dirty and flushed periodically — so after a crash they can be +/// durable at different heights. The chain source resumes from the *minimum* height across all +/// listeners, which replays blocks to any listener that got further ahead. +/// +/// Classification is deliberately hash-aware. Deciding on height alone would treat a *different* +/// block at an already-seen height as an ordinary replay and skip it, silently stranding the +/// listener on a stale fork — a worse failure than the panic being avoided, because it is silent. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ListenerAction { + /// The listener expects exactly this block. + Deliver, + /// The listener already has this exact block on its chain; skip it. + AlreadyApplied, + /// The block sits BELOW the listener's tip, but our ancestry record does not reach that far, + /// so we can neither confirm nor refute that it is on the listener's chain. Skip it — but, + /// unlike [`Self::Diverged`], do NOT halt the chain source. + /// + /// This is the *expected* case whenever listener durability skews by more than + /// `previous_blocks.len()` (LDK's `BlockLocator` carries `ANTI_REORG_DELAY * 2` = 12 + /// ancestors, and a locator restored from persistence can carry none at all), which is + /// exactly the situation the resume-from-minimum design above sets up. Treating it as + /// divergence halts CBF block application permanently for a listener that is merely further + /// ahead on the same chain. + /// + /// The decision is deferred, not dropped — but only for as long as the replay can still reach + /// the listener. While it climbs towards `best.height` the same-height arm will compare the + /// block hash exactly, and at `best.height + 1` the parent hash — both PROVABLE — so a + /// listener genuinely sitting on a fork is caught a few blocks later by a check that can + /// decide it. + /// + /// That argument holds only while the chain being replayed is at least as long as the + /// listener's own chain. It does NOT cover a reorg that leaves the canonical tip *below* an + /// ahead listener's persisted tip (needs to be deeper than the ancestry window, or any depth + /// at all against a locator restored from persistence with no ancestors): the replay ends + /// before it can prove anything, every block is unprovable, and deferring forever is the same + /// as dropping. That geometry is caught at the tip boundary instead — see + /// [`ChainListener::record_stranded_listeners`], which fails closed on it. + ReplayUnprovable, + /// The listener cannot accept this block without first being rewound. + Diverged, +} + +pub(crate) fn listener_action( + best: &BlockLocator, block_hash: bitcoin::BlockHash, prev_blockhash: bitcoin::BlockHash, + height: u32, +) -> ListenerAction { + if height == best.height + 1 { + // The ordinary case: extends the listener's tip. + return if best.block_hash == prev_blockhash { + ListenerAction::Deliver + } else { + ListenerAction::Diverged + }; + } + + if height == best.height { + // Same height: an exact replay is safe to skip, a different block is a fork. + return if best.block_hash == block_hash { + ListenerAction::AlreadyApplied + } else { + ListenerAction::Diverged + }; + } + + if height < best.height { + // Below the tip: only a skip if this exact block is on the listener's own chain. + // `previous_blocks` holds ancestors in reverse chronological order starting at + // `best.height - 1`, so the ancestor at `height` sits at index `best.height - height - 1`. + let idx = (best.height - height - 1) as usize; + return match best.previous_blocks.get(idx) { + Some(Some(ancestor)) if *ancestor == block_hash => ListenerAction::AlreadyApplied, + // An ancestor we DO hold at that height and that differs is a proven fork. + Some(Some(_)) => ListenerAction::Diverged, + // Empty slot (a locator restored from persistence carries no ancestry) or past the + // end of the 12-deep window: unprovable either way, and NOT evidence of a fork. See + // `ListenerAction::ReplayUnprovable`. + Some(None) | None => ListenerAction::ReplayUnprovable, + }; + } + + // height > best.height + 1: a gap. Delivering would violate the one-call-per-block contract. + ListenerAction::Diverged +} + +impl ChainListener { + /// Logs a diverged listener. Delivering anyway would panic on LDK's chain-order assertion, and + /// silently skipping would strand the listener on a stale chain, so the condition is surfaced + /// loudly rather than swallowed. + pub(crate) fn get_best_block(&self) -> BlockLocator { + let candidates = [ + self.onchain_wallet.current_best_block(), + self.channel_manager.current_best_block(), + self.output_sweeper.current_best_block(), + ]; + let mut min = candidates.into_iter().min_by_key(|b| b.height).expect("non-empty"); + if let Some(worst_monitor) = self.min_monitor_best_block() { + if worst_monitor.height < min.height { + min = worst_monitor; + } + } + min + } + + /// The furthest-behind channel monitor, or `None` when there are no monitors. + fn min_monitor_best_block(&self) -> Option { + self.chain_monitor + .list_monitors() + .iter() + .flat_map(|id| self.chain_monitor.get_monitor(*id)) + .map(|m| m.current_best_block()) + .min_by_key(|b| b.height) + } + + pub(crate) fn take_divergence(&self) -> Option { + self.divergence.lock().unwrap().take() + } + + /// Records one listener's decision about one replay block in the batch ledger, logs it, and + /// hands it back so the caller can act on it. + /// + /// EVERY decision must flow through here. The ledger's whole value is the contrast between a + /// listener that skipped some blocks and one that skipped *only* blocks, so a decision that + /// bypassed it would read as an absence of evidence. + fn note_decision( + &self, who: &'static str, best: &BlockLocator, height: u32, action: ListenerAction, + ) -> ListenerAction { + self.replay_batch.lock().unwrap().entry(who).or_default().note(best, action); + match action { + ListenerAction::ReplayUnprovable => self.log_unprovable_replay(who, best, height), + ListenerAction::Diverged => self.log_divergence(who, best, height), + ListenerAction::Deliver | ListenerAction::AlreadyApplied => {}, + } + action + } + + /// Drains the batch ledger at a tip boundary and fails closed on any listener the replay could + /// never prove a connection to. + /// + /// A batch that ends at `tip_height` is the last chance a listener gets: the replay is not + /// coming back for it. So a listener that saw nothing but [`ListenerAction::ReplayUnprovable`] + /// across the whole batch, and whose own tip is at or above the tip we just reached, is not + /// waiting to be caught up — it is stranded on a chain we cannot extend or refute, and the + /// deferral promised by `ReplayUnprovable` can never be honoured. Recording it as divergence + /// halts the chain source, which is the pre-`ReplayUnprovable` behaviour for exactly this + /// (true-positive) case. + /// + /// A listener that skipped a stretch and then reconnected — the ordinary consequence of the + /// resume-from-minimum design, and the case `ReplayUnprovable` exists for — has `provable > 0` + /// and is merely reported, at a level that does not depend on debug logging being on. + /// + /// Returns `true` when at least one listener was recorded as stranded. + pub(crate) fn record_stranded_listeners(&self, tip_height: u32) -> bool { + let batch = std::mem::take(&mut *self.replay_batch.lock().unwrap()); + let mut stranded = false; + for (who, tally) in batch { + match tally.verdict(tip_height) { + BatchVerdict::Ordinary => {}, + BatchVerdict::SkippedThenReconnected => log_info!( + self.logger, + "{} skipped {} block(s) of the replay it could not prove ancestry for, then \ + reconnected to the chain ({} proven decision(s), listener tip {}).", + who, + tally.unprovable, + tally.provable, + tally.tip_height, + ), + BatchVerdict::Stranded => { + stranded = true; + self.record_divergence(format!( + "{} is stranded at height {} above the synced tip {} ({} block(s) \ + replayed, none provable): the replay ended without ever reaching a block \ + it could prove", + who, tally.tip_height, tip_height, tally.unprovable + )); + log_error!( + self.logger, + "{} sits at height {} while the chain we just synced ends at {}, and none \ + of the {} replayed block(s) could be proven to be on its chain. It is on \ + a chain we can neither extend nor refute, so it is stranded rather than \ + lagging.", + who, + tally.tip_height, + tip_height, + tally.unprovable, + ); + }, + } + } + stranded + } + + /// Logs a replay this listener is too far ahead of for us to prove ancestry for. + /// + /// Deliberately does NOT touch `self.divergence`: on its own this is the ordinary consequence + /// of the resume-from-minimum design, not a fork. Whether it stayed ordinary is decided at the + /// tip boundary by [`Self::record_stranded_listeners`], so this stays at debug level. + fn log_unprovable_replay(&self, who: &str, best: &BlockLocator, height: u32) { + log_debug!( + self.logger, + "{} is at height {} and holds no ancestry back to height {}; skipping the replay of \ + that block for it (its own tip is re-checked by hash when the replay reaches it).", + who, + best.height, + height, + ); + } + + /// Records the first divergence seen since the last drain. Later ones are dropped: the chain + /// source halts on the first, and the first is the one that explains the rest. + fn record_divergence(&self, reason: String) { + let mut recorded = self.divergence.lock().unwrap(); + if recorded.is_none() { + *recorded = Some(reason); + } + } + + fn log_divergence(&self, who: &str, best: &BlockLocator, height: u32) { + self.record_divergence(format!( + "{} diverged at height {} (listener at {}, hash {})", + who, height, best.height, best.block_hash + )); + log_error!( + self.logger, + "{} cannot accept the block at height {}: it is at height {} (hash {}). It must be \ + rewound before it can continue; skipping to avoid a chain-order panic.", + who, + height, + best.height, + best.block_hash, + ); + } } impl Listen for ChainListener { @@ -1356,16 +1888,96 @@ impl Listen for ChainListener { &self, header: &bitcoin::block::Header, txdata: &lightning::chain::transaction::TransactionData, height: u32, ) { + // The on-chain wallet is deliberately not gated. `Wallet::blocks_disconnected` is a no-op + // because BDK expects blocks to be reconnected starting from the point of disagreement, so + // a height-based gate would starve it of the new chain after a reorg. BDK also tolerates an + // exact duplicate, which is the only case a gate would otherwise guard against. self.onchain_wallet.filtered_block_connected(header, txdata, height); - self.channel_manager.filtered_block_connected(header, txdata, height); - self.chain_monitor.filtered_block_connected(header, txdata, height); - self.output_sweeper.filtered_block_connected(header, txdata, height); + + let block_hash = header.block_hash(); + + // `note_decision` does the logging and the batch tally; the arms below decide only what + // reaches the listener. + let cm_best = self.channel_manager.current_best_block(); + let cm_action = listener_action(&cm_best, block_hash, header.prev_blockhash, height); + match self.note_decision("ChannelManager", &cm_best, height, cm_action) { + ListenerAction::Deliver => { + self.channel_manager.filtered_block_connected(header, txdata, height) + }, + ListenerAction::AlreadyApplied + | ListenerAction::ReplayUnprovable + | ListenerAction::Diverged => {}, + } + + // `ChainMonitor` has no chain-order assertion of its own, but `ChannelMonitor` advances its + // tip whenever the incoming height is greater *without validating the parent*, so replaying + // a different chain would silently graft a stale ancestor. Gate it on the furthest-behind + // monitor: monitors ahead of that point ignore heights at or below their own tip. + match self.min_monitor_best_block() { + Some(monitor_best) => { + let action = + listener_action(&monitor_best, block_hash, header.prev_blockhash, height); + match self.note_decision("ChainMonitor", &monitor_best, height, action) { + ListenerAction::Deliver | ListenerAction::AlreadyApplied => { + self.chain_monitor.filtered_block_connected(header, txdata, height) + }, + ListenerAction::ReplayUnprovable | ListenerAction::Diverged => {}, + } + }, + // No monitors: nothing to strand. + None => self.chain_monitor.filtered_block_connected(header, txdata, height), + } + + let sweeper_best = self.output_sweeper.current_best_block(); + let sweeper_action = + listener_action(&sweeper_best, block_hash, header.prev_blockhash, height); + match self.note_decision("OutputSweeper", &sweeper_best, height, sweeper_action) { + ListenerAction::Deliver => { + self.output_sweeper.filtered_block_connected(header, txdata, height) + }, + ListenerAction::AlreadyApplied + | ListenerAction::ReplayUnprovable + | ListenerAction::Diverged => {}, + } } + fn block_connected(&self, block: &bitcoin::Block, height: u32) { self.onchain_wallet.block_connected(block, height); - self.channel_manager.block_connected(block, height); - self.chain_monitor.block_connected(block, height); - self.output_sweeper.block_connected(block, height); + + let block_hash = block.header.block_hash(); + + let cm_best = self.channel_manager.current_best_block(); + let cm_action = listener_action(&cm_best, block_hash, block.header.prev_blockhash, height); + match self.note_decision("ChannelManager", &cm_best, height, cm_action) { + ListenerAction::Deliver => self.channel_manager.block_connected(block, height), + ListenerAction::AlreadyApplied + | ListenerAction::ReplayUnprovable + | ListenerAction::Diverged => {}, + } + + match self.min_monitor_best_block() { + Some(monitor_best) => { + let action = + listener_action(&monitor_best, block_hash, block.header.prev_blockhash, height); + match self.note_decision("ChainMonitor", &monitor_best, height, action) { + ListenerAction::Deliver | ListenerAction::AlreadyApplied => { + self.chain_monitor.block_connected(block, height) + }, + ListenerAction::ReplayUnprovable | ListenerAction::Diverged => {}, + } + }, + None => self.chain_monitor.block_connected(block, height), + } + + let sweeper_best = self.output_sweeper.current_best_block(); + let sweeper_action = + listener_action(&sweeper_best, block_hash, block.header.prev_blockhash, height); + match self.note_decision("OutputSweeper", &sweeper_best, height, sweeper_action) { + ListenerAction::Deliver => self.output_sweeper.block_connected(block, height), + ListenerAction::AlreadyApplied + | ListenerAction::ReplayUnprovable + | ListenerAction::Diverged => {}, + } } fn blocks_disconnected(&self, fork_point_block: lightning::chain::BlockLocator) { @@ -1373,6 +1985,10 @@ impl Listen for ChainListener { self.channel_manager.blocks_disconnected(fork_point_block); self.chain_monitor.blocks_disconnected(fork_point_block); self.output_sweeper.blocks_disconnected(fork_point_block); + // Every listener just moved back to the fork point, so the batch tallied against their old + // tips describes a chain none of them are on any more. The replay that follows is the one + // that gets judged. + self.replay_batch.lock().unwrap().clear(); } } @@ -1405,6 +2021,7 @@ impl std::error::Error for BitcoindClientError {} mod tests { use bitcoin::hashes::Hash; use bitcoin::{FeeRate, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid, Witness}; + use lightning::chain::BlockLocator; use lightning_block_sync::http::JsonResponse; use proptest::arbitrary::any; use proptest::collection::vec; @@ -1412,10 +2029,254 @@ mod tests { use serde_json::json; use crate::chain::bitcoind::{ - FeeResponse, GetMempoolEntryResponse, GetRawMempoolResponse, GetRawTransactionResponse, - MempoolMinFeeResponse, + listener_action, BatchVerdict, FeeResponse, GetMempoolEntryResponse, GetRawMempoolResponse, + GetRawTransactionResponse, ListenerAction, MempoolMinFeeResponse, ReplayTally, }; + fn hash(byte: u8) -> bitcoin::BlockHash { + bitcoin::BlockHash::from_byte_array([byte; 32]) + } + + /// A listener tip at `height`/`hash_byte` whose ancestors are byte-tagged `hash_byte - n`. + fn locator(height: u32, hash_byte: u8) -> BlockLocator { + let mut loc = BlockLocator::new(hash(hash_byte), height); + for (i, slot) in loc.previous_blocks.iter_mut().enumerate() { + *slot = Some(hash(hash_byte.wrapping_sub(i as u8 + 1))); + } + loc + } + + #[test] + fn listener_action_delivers_the_next_block_in_order() { + // Extends the tip: parent matches, height is best + 1. + assert_eq!( + listener_action(&locator(100, 50), hash(51), hash(50), 101), + ListenerAction::Deliver + ); + } + + #[test] + fn listener_action_rejects_a_next_height_block_with_the_wrong_parent() { + assert_eq!( + listener_action(&locator(100, 50), hash(99), hash(200), 101), + ListenerAction::Diverged + ); + } + + #[test] + fn listener_action_skips_an_exact_replay_at_the_tip() { + // The crash case the gating exists for: the resume floor is the minimum height across all + // listeners, so a listener that persisted further ahead is replayed its own blocks. + // `ChannelManager` and `OutputSweeper` assert on chain order, so delivering would panic. + assert_eq!( + listener_action(&locator(100, 50), hash(50), hash(49), 100), + ListenerAction::AlreadyApplied + ); + } + + #[test] + fn listener_action_skips_an_exact_replay_below_the_tip() { + // Two blocks back on the listener's own chain: ancestors are tagged 49, 48, ... + assert_eq!( + listener_action(&locator(100, 50), hash(48), hash(47), 98), + ListenerAction::AlreadyApplied + ); + } + + #[test] + fn listener_action_reports_a_different_block_at_the_same_height() { + // The bug this classifier exists to prevent. Height alone would call this an ordinary + // replay and skip it, silently stranding the listener on the stale fork — worse than the + // panic being avoided, because nothing reports it. + assert_eq!( + listener_action(&locator(100, 50), hash(0xbb), hash(0xaa), 100), + ListenerAction::Diverged + ); + } + + #[test] + fn listener_action_reports_a_different_block_below_the_tip() { + // Right height, but not the block on the listener's chain (ancestor there is tagged 48). + assert_eq!( + listener_action(&locator(100, 50), hash(0xbb), hash(0xaa), 98), + ListenerAction::Diverged + ); + } + + #[test] + fn listener_action_reports_an_unprovable_replay_beyond_known_ancestry() { + // `previous_blocks` holds 12 ancestors; past that we cannot prove the block is ours, so it + // must not be assumed to be a safe replay — it is NOT `AlreadyApplied`, the block is still + // withheld from the listener. + // + // It is not `Diverged` either. Resume-from-minimum deliberately replays blocks to any + // listener that persisted further ahead (see `ListenerAction`'s own doc comment), and + // listener durability routinely skews by more than 12 blocks — measured live at 20 + // (wallet 7871 / ChainMonitor 7888 / ChannelManager 7891, all three on the canonical + // chain). Calling that divergence halted CBF block application PERMANENTLY on a healthy + // node: every later restart re-derived the same minimum, re-hit the same unprovable + // replay, and re-halted, so `sync_state` latched `failed` forever. + assert_eq!( + listener_action(&locator(100, 50), hash(1), hash(0), 50), + ListenerAction::ReplayUnprovable + ); + } + + #[test] + fn listener_action_reports_an_unprovable_replay_when_the_locator_carries_no_ancestry() { + // `BlockLocator::new` leaves every ancestry slot empty, which is what a listener restored + // from persistence can look like. Inside the window but with nothing recorded is still + // "cannot decide", not "forked". + let no_ancestry = BlockLocator::new(hash(50), 100); + assert_eq!( + listener_action(&no_ancestry, hash(48), hash(47), 98), + ListenerAction::ReplayUnprovable + ); + } + + #[test] + fn an_unprovable_replay_still_meets_a_provable_check_at_the_listeners_own_tip() { + // The safety property that makes `ReplayUnprovable` safe to not halt on: the replay keeps + // climbing, and at the listener's own height the classifier compares block hashes + // exactly. A listener genuinely on a fork is caught there instead of below it. + // + // This holds only while the replay can actually GET to that height. When it cannot — a + // chain shorter than the listener's own — the tip-boundary check below is what decides, + // see `a_replay_that_ends_below_an_ahead_listener_strands_it`. + let ahead = locator(100, 50); + // Below the tip, past the ancestry window: undecidable, keep going. + assert_eq!(listener_action(&ahead, hash(1), hash(0), 50), ListenerAction::ReplayUnprovable); + // At the tip on the same chain: a plain replay. + assert_eq!( + listener_action(&ahead, hash(50), hash(49), 100), + ListenerAction::AlreadyApplied + ); + // At the tip on a DIFFERENT chain: still caught, still halts. + assert_eq!(listener_action(&ahead, hash(0xbb), hash(0xaa), 100), ListenerAction::Diverged); + } + + /// Replays the blocks of one batch over a single listener locator and returns what the batch + /// ledger would hold, using the same fold the live path uses. + /// + /// The locator is held fixed, so this models the listener for as long as the replay has not + /// advanced it — which is the entire batch for a listener the replay never reaches, and up to + /// its own tip for one it does. + fn replay_over( + best: &BlockLocator, from: u32, through: u32, chain: impl Fn(u32) -> bitcoin::BlockHash, + ) -> ReplayTally { + let mut tally = ReplayTally::default(); + for height in from..=through { + tally.note(best, listener_action(best, chain(height), chain(height - 1), height)); + } + tally + } + + #[test] + fn a_replay_that_ends_below_an_ahead_listener_strands_it() { + // The geometry the per-block classifier cannot decide: a reorg deeper than the 12-entry + // ancestry window that leaves the canonical tip BELOW an ahead listener's persisted tip. + // The replay walks the canonical chain and stops at 85, so it never climbs to the + // listener's own height where a hash comparison could rule — every block is + // `ReplayUnprovable`, and "we'll decide a few blocks later" never comes due. + let ahead = locator(100, 100); + let canonical = |h: u32| hash(h as u8 + 128); + + let tally = replay_over(&ahead, 76, 85, canonical); + + assert_eq!(tally.unprovable, 10, "every block of the batch was undecidable"); + assert_eq!(tally.provable, 0, "nothing in the batch could be proven either way"); + assert_eq!( + tally.verdict(85), + BatchVerdict::Stranded, + "a listener at 100 that the canonical chain ends 15 blocks below is stranded, not \ + lagging: no later replay of THIS chain reaches it either" + ); + } + + #[test] + fn a_replay_that_ends_below_a_listener_with_no_ancestry_strands_it_at_any_depth() { + // The same trap without needing a deep reorg: a locator restored from persistence carries + // no ancestors at all, so nothing below its tip is provable and the whole 12-block window + // stops helping. Any canonical tip below the listener's own is then unreachable. + let restored = BlockLocator::new(hash(100), 100); + let canonical = |h: u32| hash(h as u8); + + let tally = replay_over(&restored, 90, 99, canonical); + + assert_eq!(tally.provable, 0); + assert_eq!(tally.verdict(99), BatchVerdict::Stranded); + } + + #[test] + fn a_behind_listener_the_replay_catches_up_to_still_heals() { + // The case `ReplayUnprovable` exists for, and the one a stranded-detector must not eat: + // listener durability skews on every restart (measured live at 20 blocks), the resume + // floor is the minimum across listeners, and a listener with no persisted ancestry cannot + // prove ANY of the replay below its tip. It is still perfectly healthy — the proof + // arrives when the replay reaches its own height. + // + // Stopping the replay at the listener's tip is the pessimistic cut: every block above it + // is `Deliver`, which only adds proof. + let restored = BlockLocator::new(hash(100), 100); + let canonical = |h: u32| hash(h as u8); + + let tally = replay_over(&restored, 81, 100, canonical); + + assert_eq!(tally.unprovable, 19, "81..=99 could not be proven"); + assert_eq!(tally.provable, 1, "its own tip at 100 compares by hash, and matches"); + assert_eq!( + tally.verdict(120), + BatchVerdict::SkippedThenReconnected, + "skipping a stretch and then reconnecting must never halt the chain source — that \ + halt bricked CBF on healthy nodes" + ); + } + + #[test] + fn a_batch_that_proved_a_fork_is_not_also_reported_as_stranded() { + // Divergence is a proven decision. It halts through its own path, and reporting the same + // listener twice for the same batch would misdescribe why. + let ahead = locator(100, 100); + let forked = |h: u32| hash(h as u8 + 128); + + // 88..=99 are inside the ancestry window and disagree; 100 is the tip and disagrees. + let tally = replay_over(&ahead, 80, 100, forked); + + assert!(tally.provable > 0, "the window and the tip both ruled"); + assert_eq!(tally.verdict(100), BatchVerdict::SkippedThenReconnected); + } + + #[test] + fn the_stranded_verdict_needs_both_no_proof_and_an_unreachable_tip() { + let nothing_skipped = ReplayTally { unprovable: 0, provable: 5, tip_height: 0 }; + assert_eq!(nothing_skipped.verdict(90), BatchVerdict::Ordinary); + + let skipped_then_proved = ReplayTally { unprovable: 3, provable: 1, tip_height: 100 }; + assert_eq!(skipped_then_proved.verdict(90), BatchVerdict::SkippedThenReconnected); + + // Defensive: a listener below the tip the replay reached is reachable by construction, so + // however it got here it is not the terminal case. + let below_the_tip = ReplayTally { unprovable: 3, provable: 0, tip_height: 89 }; + assert_eq!(below_the_tip.verdict(90), BatchVerdict::SkippedThenReconnected); + + let unreachable = ReplayTally { unprovable: 3, provable: 0, tip_height: 100 }; + assert_eq!(unreachable.verdict(90), BatchVerdict::Stranded); + assert_eq!( + unreachable.verdict(100), + BatchVerdict::Stranded, + "at the tip it is still ahead" + ); + } + + #[test] + fn listener_action_reports_a_gap() { + // More than one block ahead: delivering violates LDK's one-call-per-block contract. + assert_eq!( + listener_action(&locator(90, 50), hash(60), hash(59), 101), + ListenerAction::Diverged + ); + } + prop_compose! { fn arbitrary_witness()( witness_elements in vec(vec(any::(), 0..100), 0..20) diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs new file mode 100644 index 0000000000..013ecf53c5 --- /dev/null +++ b/src/chain/cbf.rs @@ -0,0 +1,2446 @@ +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use bip157::chain::ChainState; +use bip157::{ + chain::BlockHeaderChanges, Builder as KyotoBuilder, Client, Event as KyotoEvent, + HashCheckpoint, Header, IndexedBlock, Info, Node as KyotoNode, Package, Requester, TrustedPeer, + Warning, +}; +use bitcoin::{BlockHash, FeeRate, Network, Script, ScriptBuf, Transaction, Txid}; +use electrum_client::{Client as ElectrumClient, ConfigBuilder as ElectrumConfigBuilder}; +use lightning::chain::{BlockLocator, Listen, WatchedOutput}; + +use tokio::sync::{mpsc, watch}; + +use crate::chain::bitcoind::ChainListener; +use crate::chain::electrum::get_electrum_fee_rate_cache_update; +use crate::chain::{BroadcastHookError, CbfFeeSourceConfig, CbfSyncStatus, ChainServiceHooks}; +use crate::config::{Config, DEFAULT_FEE_RATE_CACHE_UPDATE_INTERVAL_SECS}; +use crate::error::Error; +use crate::fee_estimator::{ + apply_post_estimation_adjustments, get_all_conf_targets, get_fallback_rate_for_target, + get_num_block_defaults_for_target, ConfirmationTarget, OnchainFeeEstimator, +}; +use crate::io::utils::update_and_persist_node_metrics; +use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; +use crate::runtime::Runtime; +use crate::types::DynStore; +use crate::util::{cbf_percentile_for_target, coinbase_fee_rate, percentile_of_sorted}; +use crate::wallet::Wallet; +use crate::PersistedNodeMetrics; + +/// Walk back this many blocks from the wallet's persisted tip when deriving +/// the kyoto resume checkpoint, so a recent reorg cannot strand the node +/// above the new best chain. +const REORG_SAFETY_BLOCKS: u32 = 7; +const BLOCK_FEE_CACHE_CAPACITY: usize = REORG_SAFETY_BLOCKS as usize * 2; + +/// Peer response timeout passed to kyoto's `Builder::response_timeout`. +const DEFAULT_RESPONSE_TIMEOUT_SECS: u64 = 30; + +/// Number of peers that must agree on filter headers before they're accepted. +const DEFAULT_REQUIRED_PEERS: u8 = 1; + +/// Maximum consecutive `node.run()` failures before the restart loop gives up. +const MAX_RESTART_RETRIES: u32 = 5; + +/// Initial backoff delay between restart attempts; doubles each failure. +const INITIAL_BACKOFF_MS: u64 = 500; + +/// Retry matched block downloads before surfacing a CBF sync failure. +const CBF_BLOCK_FETCH_RETRIES: u8 = 3; + +/// Bound on the queue between the kyoto event loop and [`BlockApplicator`]. +/// +/// This queue was previously unbounded. During a bulk sync, filter processing outruns listener +/// application (each applied block costs a BDK persist), so an unbounded queue lets hundreds of +/// thousands of `ChainOp` values accumulate with no backpressure — a concrete OOM risk on a +/// 512 MiB device. A bound makes the event loop wait for the applicator instead. +/// +/// Kept small because a `ConnectFull` op carries an entire block: at 64 slots the worst case is +/// bounded even when every queued op is a matched block. Most ops are `ConnectFiltered`, which +/// carries only an 80-byte header, so this depth is ample for pipelining in the common case. +const CBF_CHAIN_OP_QUEUE_DEPTH: usize = 64; + +/// How many applied blocks may accumulate before the deferred on-chain chain tip is written. +/// +/// While catching up, the wallet's `local_chain` write is deferred (see +/// `Wallet::set_bulk_chain_persistence`) so the growing full-chain map is not re-serialized on +/// every block. Flushing every retarget period bounds how many blocks a crash can force us to +/// replay, while collapsing ~2016 full-map writes into one. Once caught up, every block is +/// followed by a `Synced` op, which flushes — so at the tip this reverts to one write per block. +const CBF_CHAIN_FLUSH_INTERVAL_BLOCKS: u32 = 2016; + +const ESPLORA_TIMEOUT: u64 = 2; + +/// Retries and per-request timeout for the fresh Electrum connection opened each fee cycle. +const ELECTRUM_FEE_NUM_RETRIES: u8 = 3; +const ELECTRUM_FEE_TIMEOUT_SECS: u64 = 10; + +/// Timeout applied to every [`ChainServiceHooks`] invocation (both `fee_estimates` and +/// `broadcast`). Unlike the Esplora/Electrum fee paths, an app-supplied hook has no client-level +/// timeout of its own, so without this an indefinitely-hanging hook would stall the fee-update +/// cycle, or the 500-deep broadcast-queue drain loop (`CBF_CHAIN_OP_QUEUE_DEPTH`-adjacent — +/// `continuously_process_broadcast_queue` in `chain/mod.rs`), for as long as the hook never +/// resolves. A timeout is treated identically to `Err(())`: fall through to the native path. +/// Matches the file's existing 10s external-service timeouts (`ELECTRUM_FEE_TIMEOUT_SECS`, +/// `CBF_BLOCK_FETCH_TIMEOUT_SECS`). +const CHAIN_SERVICE_HOOK_TIMEOUT_SECS: u64 = 10; + +/// Sat/vB bounds clamped onto every value in an accepted `ChainServiceHooks::fee_estimates` +/// result before unit conversion. Defends against a malformed/hostile hook in two ways: a value +/// below the floor would starve fee bumping/relay, and an unclamped extreme value can overflow +/// `FeeRate::to_sat_per_vb_ceil` when a swap caller later reads the cached estimate back out in +/// sat/vB. `10_000` sat/vB is roughly two orders of magnitude above any real-world mempool +/// congestion spike, so this never clips a legitimate estimate. +const CHAIN_SERVICE_HOOK_MIN_SAT_PER_VB: f64 = 1.0; +const CHAIN_SERVICE_HOOK_MAX_SAT_PER_VB: f64 = 10_000.0; + +/// Runtime status of the underlying kyoto node. +enum CbfRuntimeStatus { + Started { requester: Requester }, + Stopped, +} + +#[derive(Clone, Copy)] +enum CbfSyncState { + Active { + /// Highest tip whose preceding chain updates have been applied to all listeners. + applied_tip: Option, + /// Whether kyoto has reported catching up to the network tip (via `FiltersSynced`) and + /// the resulting blocks have been applied. `wait_until_synced` blocks until this is set. + /// + /// This must not be derived from a locally-sampled chain tip: kyoto does not persist, so a + /// freshly (re)started node's local header chain sits at genesis until it syncs from peers. + /// Comparing against that would make `wait_until_synced` return before any sync happens. + synced_to_tip: bool, + }, + Failed(Error), +} + +/// Pure mapping from the internal, error-carrying [`CbfSyncState`] to the +/// externally-consumable [`CbfSyncStatus`] — extracted so the mapping is +/// unit-testable without a live kyoto node or `watch` channel. +fn simplify_sync_state(state: CbfSyncState) -> CbfSyncStatus { + match state { + CbfSyncState::Active { synced_to_tip: true, .. } => CbfSyncStatus::Synced, + CbfSyncState::Active { synced_to_tip: false, .. } => CbfSyncStatus::Syncing, + CbfSyncState::Failed(_) => CbfSyncStatus::Failed, + } +} + +/// Marks that we are applying a block past the last `FiltersSynced` tip, so a `sync_wallets` call +/// issued after new blocks are mined waits for the next `FiltersSynced` rather than returning on a +/// stale `synced_to_tip`. Only flips (and notifies waiters) when currently set. +/// +/// Called both when a new block's filter is received (before it is fetched and applied) and after +/// it is applied, so `synced_to_tip` reflects "behind by an unapplied block" as soon as we learn +/// that block exists, not only once we've finished catching up to it. +fn mark_syncing(sync_state_tx: &watch::Sender) { + // Copy the current state out and drop the `watch` read guard before calling `send_replace`: + // `borrow()` holds a read lock for the lifetime of its temporary, and `send_replace` takes + // the write lock, so holding the borrow across it deadlocks. `CbfSyncState` is `Copy`, so the + // deref copies and the guard is released at the end of this statement. + let current = *sync_state_tx.borrow(); + if let CbfSyncState::Active { applied_tip, synced_to_tip: true } = current { + sync_state_tx.send_replace(CbfSyncState::Active { applied_tip, synced_to_tip: false }); + } +} + +/// Struct for holding cbf chain source +pub struct CbfChainSource { + /// Trusted peer addresses for kyoto's `Builder::add_peers`. + trusted_peers: Vec, + /// Scripts tracked by LDK, onchain wallet's scripts are pulled from the onchain wallet + registered_scripts: Arc>>, + fee_source: FeeSource, + /// Tracks whether the kyoto node is running and holds the live requester. + cbf_runtime_status: Arc>, + /// Highest CBF sync tip whose preceding chain updates have been applied to all listeners. + sync_state_tx: watch::Sender, + /// Handle used to spawn the background tasks and offload blocking work. + runtime: Arc, + /// Node configuration (network, storage path). + config: Arc, + fee_estimator: Arc, + kv_store: Arc, + node_metrics: Arc, + logger: Arc, + /// App-supplied external chain-service hooks (fee estimates / broadcast); empty (no-op) + /// unless configured via [`crate::builder::NodeBuilder::set_cbf_chain_service_hooks`]. + hooks: ChainServiceHooks, +} + +#[derive(Debug)] +enum ChainOp { + ConnectFull { block: IndexedBlock }, + ConnectFiltered { header: Header, height: u32 }, + Disconnect { fork_point: BlockLocator }, + Synced { tip_height: u32 }, + Failed { error: Error }, +} + +struct BlockApplicator { + chain_listener: ChainListener, + ops_rx: mpsc::Receiver, + next_height: u32, + /// Blocks applied since the last deferred-chain-state flush. + blocks_since_flush: u32, + sync_state_tx: watch::Sender, + /// Present only for the native CBF fee source: lets us cache the fee rate of blocks we download + /// here, so the fee estimator doesn't have to re-download them. + block_fee_cache: Option, + kv_store: Arc, + node_metrics: Arc, + logger: Arc, +} + +impl BlockApplicator { + /// Writes the deferred on-chain chain tip, logging (but not propagating) a failure: the chain + /// state is reconstructible by replay, so a failed flush must not abort block application. + async fn flush_chain_state(&mut self) { + match self.chain_listener.onchain_wallet.flush_chain_persistence().await { + Ok(()) => self.blocks_since_flush = 0, + Err(e) => { + // Deliberately do NOT reset the counter: the chain state is still unwritten, so the + // next applied block should retry promptly rather than wait another full interval. + // `local_chain_dirty` likewise stays set, so no state is dropped on the floor. + log_error!( + self.logger, + "Failed to flush deferred CBF chain state ({}); will retry on the next block.", + e + ); + }, + } + } + + /// Drains any listener divergence recorded during the last block application. + /// + /// Returns `true` when the applicator must stop. A diverged listener is sitting on a chain we + /// cannot extend, so advancing `next_height` would march past it and eventually publish a + /// "synced" tip while that listener is stale — silent, and unrecoverable without a reorg whose + /// fork point happens to fall below it. Failing loudly instead surfaces the condition to + /// `wait_until_synced` and stops further damage. + async fn fail_on_divergence(&mut self) -> bool { + let Some(reason) = self.chain_listener.take_divergence() else { + return false; + }; + log_error!( + self.logger, + "Halting CBF block application: {}. The node must be restarted to re-derive a common \ + chain state from the persisted listener heights.", + reason + ); + // Publish the failure BEFORE flushing. The flush is an unbounded KV write; if it hangs, a + // `wait_until_synced` caller would otherwise block forever waiting for a state that is + // already decided. Signalling first makes the failure observable regardless. + self.sync_state_tx.send_replace(CbfSyncState::Failed(Error::TxSyncFailed)); + // Persist what was applied before the divergence so the resume floor reflects it. + self.flush_chain_state().await; + true + } + + /// Counts an applied block and flushes once the interval has elapsed. + async fn note_block_applied(&mut self) { + self.blocks_since_flush += 1; + if self.blocks_since_flush >= CBF_CHAIN_FLUSH_INTERVAL_BLOCKS { + self.flush_chain_state().await; + } + } + + async fn run(mut self) { + // Defer the wallet's full-chain map write for as long as this applicator runs. Every path + // that leaves the catching-up state (`Synced`, `Failed`, and the periodic interval) flushes, + // so deferral never outlives a sync boundary. + self.chain_listener.onchain_wallet.set_bulk_chain_persistence(true).await; + + while let Some(op) = self.ops_rx.recv().await { + match op { + ChainOp::ConnectFull { block: ib } => { + if ib.height != self.next_height { + log_debug!( + self.logger, + "CBF skipping out-of-sequence block at height {} (expected {})", + ib.height, + self.next_height + ); + continue; + } + self.chain_listener.block_connected(&ib.block, ib.height); + if self.fail_on_divergence().await { + return; + } + self.next_height += 1; + self.note_block_applied().await; + mark_syncing(&self.sync_state_tx); + if let Some(cache) = &self.block_fee_cache { + let fee_rate = coinbase_fee_rate(&ib.block, ib.height); + cache + .lock() + .expect("lock") + .insert(ib.height, (ib.block.block_hash(), fee_rate)); + } + }, + ChainOp::ConnectFiltered { header, height } => { + if height != self.next_height { + log_debug!( + self.logger, + "CBF skipping out-of-sequence block at height {} (expected {})", + height, + self.next_height + ); + continue; + } + self.chain_listener.filtered_block_connected(&header, &[], height); + if self.fail_on_divergence().await { + return; + } + self.next_height += 1; + self.note_block_applied().await; + mark_syncing(&self.sync_state_tx); + }, + ChainOp::Disconnect { fork_point } => { + self.chain_listener.blocks_disconnected(fork_point); + self.next_height = fork_point.height + 1; + self.sync_state_tx.send_replace(CbfSyncState::Active { + applied_tip: Some(fork_point.height), + synced_to_tip: false, + }); + }, + ChainOp::Synced { tip_height } => { + log_info!(self.logger, "CBF caught up to tip {}", tip_height); + if self.next_height > tip_height { + // The last chance a listener gets to be proven on this chain: the replay + // ends here and is not coming back. A listener the replay never reached is + // stranded, not lagging, and must not be left silently on its own chain + // while we advertise this one as synced. + if self.chain_listener.record_stranded_listeners(tip_height) + && self.fail_on_divergence().await + { + return; + } + // Reaching the tip is the durability boundary: write the deferred chain state + // before publishing, so the tip we advertise as applied is also persisted. + self.flush_chain_state().await; + self.publish_synced_tip(tip_height).await; + } else { + log_debug!( + self.logger, + "CBF waiting to apply blocks through tip {} (next height {})", + tip_height, + self.next_height + ); + } + log_info!(self.logger, "we set new tip and published at {}", tip_height); + }, + ChainOp::Failed { error } => { + log_info!(self.logger, "we received error chain op {}", error); + // Persist whatever we applied before the failure so the resume floor reflects it. + self.flush_chain_state().await; + self.sync_state_tx.send_replace(CbfSyncState::Failed(error)); + }, + } + } + + // The channel closed, which is how a normal shutdown reaches us. Deferred chain state lives + // only in memory, so without this flush a clean stop would silently discard every block + // applied since the last interval flush and force them to be re-synced on next start. + self.flush_chain_state().await; + } + + async fn publish_synced_tip(&self, tip_height: u32) { + let already_published = { + let sync_state = *self.sync_state_tx.borrow(); + match sync_state { + CbfSyncState::Active { applied_tip, .. } => applied_tip, + CbfSyncState::Failed(_) => None, + } + }; + if already_published.map_or(false, |published_height| published_height >= tip_height) { + // Even if the applied tip is unchanged, we have now confirmed we are caught up to the + // network tip, so ensure the synced flag is set for any `wait_until_synced` waiter. + self.sync_state_tx.send_replace(CbfSyncState::Active { + applied_tip: already_published, + synced_to_tip: true, + }); + return; + } + self.sync_state_tx.send_replace(CbfSyncState::Active { + applied_tip: Some(tip_height), + synced_to_tip: true, + }); + let unix_time_secs_opt = + SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs()); + if let Err(e) = update_and_persist_node_metrics( + &self.node_metrics, + &*self.kv_store, + &*self.logger, + |m| { + m.latest_lightning_wallet_sync_timestamp = unix_time_secs_opt; + m.latest_onchain_wallet_sync_timestamp = unix_time_secs_opt; + }, + ) + .await + { + log_error!(self.logger, "Failed to persist CBF sync metrics: {:?}", e); + } + } +} + +/// Number of most recent blocks whose coinbase-derived fee rates feed the native CBF estimator. +const FEE_WINDOW_BLOCKS: u32 = BLOCK_FEE_CACHE_CAPACITY as u32; + +/// Lower bound for native CBF fee estimates (1 sat/vB), matching the floor used by the Esplora and +/// Electrum fee sources. Coinbase-derived rates are frequently zero on regtest/signet. +const CBF_MIN_FEERATE_SAT_PER_KWU: u64 = 250; + +/// Per-attempt timeout when downloading a block from a peer — used both for matched blocks we apply +/// to the listeners and for the coinbase-fee-rate samples. Kyoto queues the request and awaits a +/// peer response with no timeout of its own, so a slow or unresponsive peer would otherwise park the +/// fetch forever. Kept short so a single request is bounded and can be retried (or, for fees, only +/// delays one sample) rather than stalling. +const CBF_BLOCK_FETCH_TIMEOUT_SECS: u64 = 10; + +/// Per-transaction timeout on the kyoto P2P broadcast handoff. +/// +/// [`Requester::submit_package`] resolves only once a peer has actually PULLED the transaction: +/// kyoto announces the wtxid in an `inv` and completes the caller's oneshot when it answers the +/// peer's `getdata`. A peer that already knows the transaction never sends `getdata` — Bitcoin +/// Core logs `got inv: wtx have peer=N` and drops it — so that future NEVER resolves, and +/// kyoto attaches no timeout of its own. +/// +/// Re-broadcasting a transaction the network already has is routine, not exceptional: both sides +/// of a cooperative close broadcast the same closing transaction, both sides of a force close may +/// broadcast the same commitment, and LDK re-broadcasts unconfirmed transactions on a timer. Since +/// [`crate::chain::ChainSource::continuously_process_broadcast_queue`] drains SERIALLY, a single +/// unresolvable handoff would wedge every LATER broadcast for the lifetime of the node — +/// including the force-close sweeps that recover a channel balance. Hence the bound, matching the +/// file's other kyoto-request timeout ([`CBF_BLOCK_FETCH_TIMEOUT_SECS`]). +/// +/// Timing out does not retract the announcement: the transaction stays in kyoto's broadcast queue +/// and is still served if a peer asks for it later, and is re-announced to every peer that +/// completes a handshake afterwards. +const CBF_P2P_BROADCAST_TIMEOUT_SECS: u64 = 10; + +/// Recent per-block coinbase-derived fee rates, keyed by height so we can window on the tip, evict +/// stale entries, and detect reorged-out blocks (a height whose cached hash no longer matches the +/// canonical chain). Shared via `Arc` between the fee estimator and the [`BlockApplicator`]. +type BlockFeeCache = Arc>>; + +enum FeeSource { + /// Derive fee rates from the coinbase reward of recent blocks. Downloads full blocks in order to calculate fee estimation. + /// + /// The [`BlockApplicator`] also opportunistically inserts the fee rate of any block it already + /// downloads on a filter match, saving a re-download in the reconciliation loop. + Cbf { block_fee_cache: BlockFeeCache }, + /// Delegate fee estimation to an Esplora HTTP server. + Esplora { client: esplora_client::AsyncClient }, + /// Delegate fee estimation to an Electrum server. + /// + /// A fresh connection is opened for each estimation cycle. + Electrum { server_url: String }, +} + +/// Result of parsing a single configured trusted-peer entry (`ip:port` or `host:port`). +/// +/// Kept distinct from [`TrustedPeer`] so the parse step is unit-testable without depending on +/// kyoto's internal representation; [`ParsedPeer::into_trusted_peer`] converts to the real type. +#[derive(Debug, Clone, PartialEq, Eq)] +enum ParsedPeer { + /// A literal IPv4/IPv6 socket address. + Addr(SocketAddr), + /// A hostname/port pair. Resolution happens at kyoto connect time, not here. + Hostname { host: String, port: u16 }, +} + +impl ParsedPeer { + fn into_trusted_peer(self) -> TrustedPeer { + match self { + ParsedPeer::Addr(addr) => TrustedPeer::from_socket_addr(addr), + ParsedPeer::Hostname { host, port } => TrustedPeer::from_hostname(host, port), + } + } +} + +/// Parses a single configured CBF trusted-peer entry. +/// +/// Tries a literal `SocketAddr` first (covers IPv4/IPv6). On failure, splits on the last `:` +/// and treats the left side as a hostname to be resolved at kyoto connect time via +/// [`TrustedPeer::from_hostname`] (backed by [`tokio::net::lookup_host`]). Returns `Err` for +/// entries with no parseable port, rather than silently dropping them — a mistyped peer should +/// surface as a startup error, not vanish from the trusted-peer list. +fn parse_trusted_peer(peer_str: &str) -> Result { + if let Ok(addr) = peer_str.parse::() { + return Ok(ParsedPeer::Addr(addr)); + } + + let (host, port_str) = peer_str.rsplit_once(':').ok_or(Error::InvalidSocketAddress)?; + if host.is_empty() { + return Err(Error::InvalidSocketAddress); + } + let port: u16 = port_str.parse().map_err(|_| Error::InvalidSocketAddress)?; + + Ok(ParsedPeer::Hostname { host: host.to_string(), port }) +} + +/// The distinct block-count keys the fee-rate cache needs — the set of +/// `get_num_block_defaults_for_target(target)` values across every [`get_all_conf_targets`] +/// entry. A `ChainServiceHooks::fee_estimates` hook result is only ever applied if it covers +/// every one of these; see [`resolve_fee_estimates`]. +fn required_hook_fee_targets() -> HashSet { + get_all_conf_targets() + .into_iter() + .map(|target| get_num_block_defaults_for_target(target) as u16) + .collect() +} + +/// Resolves the per-target fee-rate cache for a single fee-update cycle. +/// +/// If `hooks.fee_estimates` is configured, it is tried first, bounded by `hook_timeout` +/// (production callers pass [`CHAIN_SERVICE_HOOK_TIMEOUT_SECS`]; tests inject a short duration +/// so a deliberately-hung fake hook doesn't slow the test suite down). It is applied +/// **all-or-nothing**: an `Ok(by_blocks)` +/// map (keyed by conf-target in blocks, valued in sat/vB) is only converted into the estimator's +/// internal per-[`ConfirmationTarget`] cache and returned WITHOUT calling `fallback` if it has a +/// finite value for every key in [`required_hook_fee_targets`]. Every accepted value is clamped +/// to `[CHAIN_SERVICE_HOOK_MIN_SAT_PER_VB, CHAIN_SERVICE_HOOK_MAX_SAT_PER_VB]` before unit +/// conversion. +/// +/// A `by_blocks` map that is missing one or more required keys (including an empty map) is +/// **rejected in full** and treated exactly like `Err(())` — NOT partially applied. This matters +/// because [`OnchainFeeEstimator::set_fee_rate_cache`] replaces the whole cache rather than +/// merging: applying a partial map would silently pin every uncovered target to the crate's +/// static conservative fallback rate (worse than any pre-hook source) instead of falling through +/// to a live estimate from the configured source, and would also bypass that source's own +/// safety guards (e.g. the Esplora path's "empty estimates disallowed on Mainnet" check) for a +/// cycle that never actually reaches it. Because this function returns early only on full +/// acceptance, [`CbfChainSource::commit_fee_rate_cache`]'s timestamp stamp downstream is never +/// applied for a rejected/timed-out/errored hook result — only for whichever cache (hook or +/// fallback) was actually accepted. +/// +/// An `Err(())` result, a timeout, an incomplete/empty map, or no hook configured at all all +/// fall through to `fallback` — the existing (Esplora / Electrum / native-CBF) fee computation, +/// unchanged. +/// +/// A free function (not a method) so it is unit-testable with a fake hook and a fake fallback, +/// without a live kyoto node. +async fn resolve_fee_estimates( + hooks: &ChainServiceHooks, logger: &Logger, hook_timeout: Duration, fallback: F, +) -> Result, Error> +where + F: FnOnce() -> Fut, + Fut: std::future::Future, Error>>, +{ + if let Some(hook) = hooks.fee_estimates.as_ref() { + let hook_result = tokio::time::timeout(hook_timeout, hook()).await; + match hook_result { + Ok(Ok(by_blocks)) => { + let required = required_hook_fee_targets(); + let covers_all = required + .iter() + .all(|blocks| by_blocks.get(blocks).is_some_and(|v| v.is_finite())); + if covers_all { + let mut cache = HashMap::with_capacity(10); + for target in get_all_conf_targets() { + let num_blocks = get_num_block_defaults_for_target(target) as u16; + // Presence + finiteness guaranteed by `covers_all` above. + let sat_per_vb = by_blocks[&num_blocks].clamp( + CHAIN_SERVICE_HOOK_MIN_SAT_PER_VB, + CHAIN_SERVICE_HOOK_MAX_SAT_PER_VB, + ); + // sat/vB -> sat/kwu: 1 vB = 4 WU, 1 kwu = 1000 WU, so *1000/4 == *250. + let sat_per_kwu = (sat_per_vb * 250.0) as u64; + let fee_rate = FeeRate::from_sat_per_kwu(sat_per_kwu); + cache.insert(target, apply_post_estimation_adjustments(target, fee_rate)); + } + return Ok(cache); + } + log_debug!( + logger, + "External chain-service fee-estimates hook returned an incomplete map \ + (missing or non-finite for one or more of the {} required block-count \ + targets); rejecting it in full and falling back to the configured fee \ + source.", + required.len() + ); + }, + Ok(Err(())) => { + log_debug!( + logger, + "External chain-service fee-estimates hook declined; falling back to the \ + configured fee source." + ); + }, + Err(_elapsed) => { + log_debug!( + logger, + "External chain-service fee-estimates hook timed out after {:?}; falling \ + back to the configured fee source.", + hook_timeout + ); + }, + } + } + fallback().await +} + +/// Outcome of trying the app-supplied broadcast hook before falling back to P2P. +#[derive(Debug, Clone, PartialEq, Eq)] +enum BroadcastOutcome { + /// The hook accepted the package; P2P broadcast was not used. + HookHandled, + /// No hook configured, or the hook could not reach its service + /// ([`BroadcastHookError::Unavailable`]) or timed out; P2P broadcast handled it. + FellThroughToP2p, + /// The hook's service REFUSED the listed transactions ([`BroadcastHookError::Rejected`]); + /// P2P broadcast was deliberately not attempted. Package transactions not listed here were + /// accepted. + Rejected(Vec<(Txid, String)>), +} + +/// Tries the app-supplied broadcast hook before falling back to the node's own P2P broadcast. +/// +/// If `hooks.broadcast` is configured, it is called with the package's transactions, bounded by +/// `hook_timeout` (production callers pass [`CHAIN_SERVICE_HOOK_TIMEOUT_SECS`]; tests inject a +/// short duration so a deliberately-hung fake hook doesn't slow the test suite down): `Ok(())` +/// means the external service accepted the broadcast, so `p2p_send` is NOT called. +/// `Err(BroadcastHookError::Unavailable)`, a timeout, or no hook configured at all falls through +/// to `p2p_send` — the existing kyoto `submit_package`/per-tx broadcast path, unchanged. +/// `Err(BroadcastHookError::Rejected(..))` is a verdict on the transactions themselves, so +/// `p2p_send` is NOT called either and the rejections are handed back to the caller. `p2p_send` +/// is called (and must itself degrade gracefully, e.g. by logging) even when the underlying +/// kyoto runtime is stopped — only `p2p_send`'s own implementation depends on kyoto health, not +/// this decision function. +/// +/// A free function (not a method) so it is unit-testable with a fake hook and a fake +/// `p2p_send`, without a live kyoto node. +async fn dispatch_broadcast( + hooks: &ChainServiceHooks, logger: &Logger, hook_timeout: Duration, txs: Vec, + p2p_send: F, +) -> BroadcastOutcome +where + F: FnOnce(Vec) -> Fut, + Fut: std::future::Future, +{ + if let Some(hook) = hooks.broadcast.as_ref() { + let hook_result = tokio::time::timeout(hook_timeout, hook(txs.clone())).await; + match hook_result { + Ok(Ok(())) => return BroadcastOutcome::HookHandled, + Ok(Err(BroadcastHookError::Rejected(rejected))) => { + return BroadcastOutcome::Rejected(rejected); + }, + Ok(Err(BroadcastHookError::Unavailable)) => {}, + Err(_elapsed) => { + log_debug!( + logger, + "External chain-service broadcast hook timed out after {:?}; falling back \ + to P2P.", + hook_timeout + ); + }, + } + } + p2p_send(txs).await; + BroadcastOutcome::FellThroughToP2p +} + +/// Awaits ONE kyoto broadcast handoff under `hold_timeout`, never propagating a failure. +/// +/// `submit` is a [`Requester::submit_package`] future; `what` names the transaction (or package) +/// for the log. Returns `true` only when a peer pulled the transaction inside the budget. +/// +/// The timeout is the whole point — see [`CBF_P2P_BROADCAST_TIMEOUT_SECS`] for why an unbounded +/// await here wedges the entire serial broadcast queue. Expiring is logged at INFO rather than +/// ERROR because the overwhelmingly common cause is benign (the peer already has the transaction); +/// a genuine relay failure shows up as the transaction never confirming, which the caller's own +/// re-broadcast timer keeps retrying. +/// +/// A free function (not a method), generic over the future, so it is unit-testable without a live +/// kyoto node — the same pattern as [`dispatch_broadcast`]. +async fn bounded_p2p_handoff( + logger: &Logger, hold_timeout: Duration, what: &str, submit: Fut, +) -> bool +where + Fut: std::future::Future>, + E: std::fmt::Debug, +{ + match tokio::time::timeout(hold_timeout, submit).await { + Ok(Ok(_)) => true, + Ok(Err(e)) => { + log_error!(logger, "Failed to broadcast {}: {:?}", what, e); + false + }, + Err(_elapsed) => { + log_info!( + logger, + "No peer requested {} within {:?}; it stays queued for relay in the CBF client \ + (a peer that already has a transaction never asks for it) and the broadcast \ + queue moves on.", + what, + hold_timeout, + ); + false + }, + } +} + +impl CbfChainSource { + pub(crate) fn new( + peers: Vec, fee_source_config: Option, runtime: Arc, + fee_estimator: Arc, kv_store: Arc, config: Arc, + logger: Arc, node_metrics: Arc, hooks: ChainServiceHooks, + ) -> Result { + let mut trusted_peers = Vec::with_capacity(peers.len()); + for peer_str in &peers { + let parsed = parse_trusted_peer(peer_str).map_err(|e| { + log_error!(logger, "Invalid CBF trusted peer '{}': {}", peer_str, e); + e + })?; + trusted_peers.push(parsed.into_trusted_peer()); + } + + let fee_source = match fee_source_config { + Some(CbfFeeSourceConfig::Esplora(server_url)) => { + let mut esplora_builder = esplora_client::Builder::new(&server_url); + esplora_builder = esplora_builder.timeout(ESPLORA_TIMEOUT); + let client = esplora_builder.build_async().map_err(|e| { + log_error!(logger, "Failed to build esplora client: {}", e); + Error::ConnectionFailed + })?; + FeeSource::Esplora { client } + }, + Some(CbfFeeSourceConfig::Electrum(server_url)) => FeeSource::Electrum { server_url }, + None => FeeSource::Cbf { block_fee_cache: Arc::new(Mutex::new(BTreeMap::new())) }, + }; + let registered_scripts = Arc::new(Mutex::new(HashSet::new())); + let cbf_runtime_status = Arc::new(Mutex::new(CbfRuntimeStatus::Stopped)); + let (sync_state_tx, _) = + watch::channel(CbfSyncState::Active { applied_tip: None, synced_to_tip: false }); + Ok(Self { + trusted_peers, + fee_source, + registered_scripts, + cbf_runtime_status, + sync_state_tx, + runtime, + config, + fee_estimator, + kv_store, + node_metrics, + logger, + hooks, + }) + } + + fn build_kyoto( + trusted_peers: &[TrustedPeer], config: &Config, logger: &Logger, + chain_listener: &ChainListener, + ) -> (KyotoNode, Client) { + let mut kyoto_builder = KyotoBuilder::new(config.network); + + let data_dir = std::path::PathBuf::from(&config.storage_dir_path).join("bip157_data"); + kyoto_builder = kyoto_builder.data_dir(data_dir); + + if !trusted_peers.is_empty() { + kyoto_builder = kyoto_builder.add_peers(trusted_peers.to_vec()); + } + + kyoto_builder = kyoto_builder.required_peers(DEFAULT_REQUIRED_PEERS); + kyoto_builder = kyoto_builder.fetch_witness_data(); + kyoto_builder = + kyoto_builder.response_timeout(Duration::from_secs(DEFAULT_RESPONSE_TIMEOUT_SECS)); + + if let Some(header_cp) = resume_checkpoint(logger, chain_listener) { + log_debug!( + logger, + "CBF builder: resuming from checkpoint height={}, hash={}", + header_cp.height, + header_cp.hash, + ); + kyoto_builder = kyoto_builder.chain_state(ChainState::Checkpoint(header_cp)); + } + + kyoto_builder.build() + } + + /// Returns the shared on-chain fee estimator backing this chain source + /// (Peerswap native primitive B6). CBF derives real fee-rate estimates + /// (from an external source or recent-block coinbase outputs, see + /// [`CbfFeeSourceConfig`]), so — unlike arbitrary raw-tx confirmation + /// queries — feerate estimation is fully supported under CBF. + #[cfg(feature = "swaps")] + pub(super) fn fee_estimator(&self) -> &Arc { + &self.fee_estimator + } + + /// Returns a simplified, externally-consumable snapshot of the current + /// CBF sync state. Never blocks — reads the current value of the + /// `watch` channel without waiting for a change. + pub(super) fn sync_status(&self) -> CbfSyncStatus { + simplify_sync_state(*self.sync_state_tx.borrow()) + } + + pub(crate) fn start(&self, chain_listener: ChainListener) { + let (node, client) = + Self::build_kyoto(&self.trusted_peers, &self.config, &self.logger, &chain_listener); + let Client { requester, info_rx, warn_rx, event_rx } = client; + + { + let mut status = self.cbf_runtime_status.lock().expect("lock"); + if matches!(*status, CbfRuntimeStatus::Started { .. }) { + debug_assert!(false, "start() called while CBF chain source is already running"); + return; + } + *status = CbfRuntimeStatus::Started { requester }; + } + + let (ops_tx, ops_rx) = mpsc::channel(CBF_CHAIN_OP_QUEUE_DEPTH); + let block_fee_cache = match &self.fee_source { + FeeSource::Cbf { block_fee_cache } => Some(Arc::clone(block_fee_cache)), + _ => None, + }; + let best_block_height = chain_listener.get_best_block().height; + self.sync_state_tx.send_replace(CbfSyncState::Active { + applied_tip: Some(best_block_height), + synced_to_tip: false, + }); + let block_applicator = BlockApplicator { + next_height: best_block_height + 1, + blocks_since_flush: 0, + sync_state_tx: self.sync_state_tx.clone(), + chain_listener: chain_listener.clone(), + ops_rx, + block_fee_cache, + kv_store: Arc::clone(&self.kv_store), + node_metrics: Arc::clone(&self.node_metrics), + logger: Arc::clone(&self.logger), + }; + self.runtime.spawn_background_task(block_applicator.run()); + + log_info!(self.logger, "CBF chain source started."); + + let restart_status = Arc::clone(&self.cbf_runtime_status); + let restart_logger = Arc::clone(&self.logger); + let restart_peers = self.trusted_peers.clone(); + let restart_config = Arc::clone(&self.config); + let restart_listener = chain_listener; + let restart_registered_scripts = Arc::clone(&self.registered_scripts); + let restart_cbf_runtime_status = Arc::clone(&self.cbf_runtime_status); + let restart_sync_state_tx = self.sync_state_tx.clone(); + + self.runtime.spawn_background_task(async move { + let mut current_node = node; + let mut current_info_rx = info_rx; + let mut current_warn_rx = warn_rx; + let mut current_event_rx = event_rx; + let mut retries = 0u32; + let mut backoff_ms = INITIAL_BACKOFF_MS; + + loop { + let info_handle = tokio::spawn(Self::process_info_messages( + current_info_rx, + Arc::clone(&restart_logger), + )); + let warn_handle = tokio::spawn(Self::process_warn_messages( + current_warn_rx, + Arc::clone(&restart_logger), + )); + + let event_handle = tokio::spawn(Self::process_kyoto_events( + Arc::clone(&restart_logger), + current_event_rx, + Arc::clone(&restart_registered_scripts), + Arc::clone(&restart_cbf_runtime_status), + ops_tx.clone(), + Arc::clone(&restart_listener.onchain_wallet), + restart_sync_state_tx.clone(), + )); + + match current_node.run().await { + Ok(()) => { + log_info!(restart_logger, "CBF node shut down cleanly."); + *restart_status.lock().expect("lock") = CbfRuntimeStatus::Stopped; + restart_sync_state_tx.send_replace(CbfSyncState::Failed(Error::NotRunning)); + break; + }, + Err(e) => { + retries += 1; + if retries > MAX_RESTART_RETRIES { + log_error!( + restart_logger, + "CBF node failed {} times, giving up: {:?}", + retries, + e, + ); + *restart_status.lock().expect("lock") = CbfRuntimeStatus::Stopped; + restart_sync_state_tx + .send_replace(CbfSyncState::Failed(Error::TxSyncFailed)); + break; + } + log_error!( + restart_logger, + "CBF node exited with error (attempt {}/{}): {:?}. Restarting in {}ms.", + retries, + MAX_RESTART_RETRIES, + e, + backoff_ms, + ); + + // Abort the old log consumers before rebuilding. + info_handle.abort(); + warn_handle.abort(); + event_handle.abort(); + + tokio::time::sleep(Duration::from_millis(backoff_ms)).await; + backoff_ms = backoff_ms.saturating_mul(2); + let (new_node, new_client) = Self::build_kyoto( + &restart_peers, + &restart_config, + &restart_logger, + &restart_listener, + ); + let Client { + requester: new_requester, + info_rx: new_info_rx, + warn_rx: new_warn_rx, + event_rx: new_event_rx, + } = new_client; + + { + let mut status = restart_status.lock().expect("lock"); + if matches!(*status, CbfRuntimeStatus::Stopped) { + let _ = new_requester.shutdown(); + restart_sync_state_tx + .send_replace(CbfSyncState::Failed(Error::NotRunning)); + log_info!( + restart_logger, + "CBF restart aborted: stop() called during backoff." + ); + break; + } + *status = CbfRuntimeStatus::Started { requester: new_requester }; + restart_sync_state_tx.send_replace(CbfSyncState::Active { + applied_tip: Some(restart_listener.get_best_block().height), + synced_to_tip: false, + }); + } + + current_node = new_node; + current_info_rx = new_info_rx; + current_warn_rx = new_warn_rx; + current_event_rx = new_event_rx; + }, + } + } + }); + } + + pub(crate) fn stop(&self) { + let requester = { + let mut status = self.cbf_runtime_status.lock().expect("lock"); + match &*status { + CbfRuntimeStatus::Started { requester } => { + let requester = requester.clone(); + *status = CbfRuntimeStatus::Stopped; + Some(requester) + }, + CbfRuntimeStatus::Stopped => None, + } + }; + + if let Some(requester) = requester { + if let Err(e) = requester.shutdown() { + log_error!(self.logger, "Failed to shut down CBF node: {:?}", e); + } + } + self.sync_state_tx.send_replace(CbfSyncState::Failed(Error::NotRunning)); + } + + pub(crate) async fn wait_until_synced(&self) -> Result<(), Error> { + if matches!(&*self.cbf_runtime_status.lock().expect("lock"), CbfRuntimeStatus::Stopped) { + return Err(Error::NotRunning); + } + let mut sync_state_rx = self.sync_state_tx.subscribe(); + + // Wait for kyoto to report catching up to the network tip (a `FiltersSynced`-driven + // `synced_to_tip`) and for the resulting blocks to be applied. We must not target a + // locally-sampled `chain_tip()`: kyoto does not persist, so a freshly (re)started node's + // local header chain sits at genesis until it syncs from peers, which would let this return + // before any sync happens. + loop { + match *sync_state_rx.borrow() { + CbfSyncState::Active { synced_to_tip, .. } => { + if synced_to_tip { + return Ok(()); + } + }, + CbfSyncState::Failed(error) => return Err(error), + } + + if let Err(e) = sync_state_rx.changed().await { + debug_assert!(false, "Failed to receive CBF sync result: {:?}", e); + log_error!(self.logger, "Failed to receive CBF sync result: {:?}", e); + return Err(Error::TxSyncFailed); + } + } + } + + async fn process_info_messages(mut info_rx: mpsc::Receiver, logger: Arc) { + while let Some(info) = info_rx.recv().await { + log_debug!(logger, "CBF node info: {}", info); + } + } + + async fn process_warn_messages( + mut warn_rx: mpsc::UnboundedReceiver, logger: Arc, + ) { + while let Some(warning) = warn_rx.recv().await { + log_debug!(logger, "CBF node warning: {}", warning); + } + } + + async fn process_kyoto_events( + logger: Arc, mut event_rx: mpsc::UnboundedReceiver, + registered_scripts: Arc>>, + cbf_runtime_status: Arc>, ops_tx: mpsc::Sender, + onchain_wallet: Arc, sync_state_tx: watch::Sender, + ) { + while let Some(event) = event_rx.recv().await { + match event { + KyotoEvent::IndexedFilter(indexed_filter) => { + // A new block's filter arrived, so we're behind by at least this block until it + // is fetched (if matched) and applied. Flip this before the fetch, not after, + // so a `sync_wallets` call issued in between doesn't return on a stale + // `synced_to_tip` that predates this block. + mark_syncing(&sync_state_tx); + + // Copy the requester out and release the lock before any `.await` below: this is a + // `std::sync::Mutex`, so holding its guard across an await point would make this + // future non-`Send` and it could not be spawned. + let requester_opt = match &*cbf_runtime_status.lock().expect("lock") { + CbfRuntimeStatus::Started { requester } => Some(requester.clone()), + CbfRuntimeStatus::Stopped => None, + }; + let requester = match requester_opt { + Some(requester) => requester, + None => { + let _ = ops_tx.send(ChainOp::Failed { error: Error::NotRunning }).await; + return; + }, + }; + //registered_scripts contains only LDK scripts, not onchain wallet's scripts, + //as don't want to track them twice: once in bdk, once in CbfChainSource, thus + //each time we receive an IndexedFilter event, we ask bdk to give us all + //revealed scripts. We create all_scripts starting from onchain wallet's + //scripts and extend them with LDK's ones + let mut all_scripts = onchain_wallet.list_watched_scripts(); + all_scripts.extend(registered_scripts.lock().expect("lock").iter().cloned()); + + let block_hash = indexed_filter.block_hash(); + let matched = indexed_filter.contains_any(all_scripts.iter()); + + let chop: ChainOp = if matched { + let mut attempt = 0; + let block = loop { + attempt += 1; + let handle = match requester.request_block(block_hash) { + Ok(handle) => handle, + Err(_) => { + log_error!( + logger, + "Failed to obtain receiver for matched CBF block {}; node is stopped", + block_hash + ); + let _ = ops_tx + .send(ChainOp::Failed { error: Error::NotRunning }) + .await; + return; + }, + }; + + // Bound the download so an unresponsive peer can't park the fetch forever, + // then flatten the three error layers (timeout / receiver dropped / fetch + // error) into a single reason so the retry-or-fail decision is written once. + let fetched = tokio::time::timeout( + Duration::from_secs(CBF_BLOCK_FETCH_TIMEOUT_SECS), + handle, + ) + .await + .map_err(|_| { + format!("timed out after {}s", CBF_BLOCK_FETCH_TIMEOUT_SECS) + }) + .and_then(|recv| recv.map_err(|_| "receiver was dropped".to_string())) + .and_then(|fetch| fetch.map_err(|e| format!("failed: {:?}", e))); + + match fetched { + Ok(block) => break block, + Err(reason) if attempt < CBF_BLOCK_FETCH_RETRIES => { + log_debug!( + logger, + "CBF block fetch for {} {} on attempt {}; retrying", + block_hash, + reason, + attempt + ); + }, + Err(reason) => { + log_error!( + logger, + "CBF block fetch for {} {} after {} attempts; giving up", + block_hash, + reason, + CBF_BLOCK_FETCH_RETRIES + ); + let _ = ops_tx + .send(ChainOp::Failed { error: Error::TxSyncFailed }) + .await; + return; + }, + } + }; + ChainOp::ConnectFull { block } + } else { + ChainOp::ConnectFiltered { + header: indexed_filter.header(), + height: indexed_filter.height(), + } + }; + if let Err(e) = ops_tx.send(chop).await { + log_debug!(logger, "ops_rx gone: {}", e); + } + }, + KyotoEvent::FiltersSynced(sync_update) => { + //Because application of blocks is async, the fact that kyoto synced up to the + //tip does NOT mean that we caught everything up, that's why we send a ChainOp, + //only processing of which means we processed all blocks up to the tip. + log_info!(logger, "Kyoto synced up to the tip {}", sync_update.tip().height); + let _ = + ops_tx.send(ChainOp::Synced { tip_height: sync_update.tip().height }).await; + }, + KyotoEvent::ChainUpdate(BlockHeaderChanges::Connected(indexed_header)) => { + log_debug!( + logger, + "Kyoto connected header at height {}", + indexed_header.height + ); + }, + KyotoEvent::ChainUpdate(BlockHeaderChanges::Reorganized { + reorganized, + accepted: _, + }) => { + // Rewind to the fork point; kyoto will re-deliver the new chain's filters. + if let Some(lowest) = reorganized.first() { + let fork_point = BlockLocator::new( + lowest.prev_blockhash(), + lowest.height.saturating_sub(1), + ); + let _ = ops_tx.send(ChainOp::Disconnect { fork_point }).await; + } + }, + KyotoEvent::ChainUpdate(BlockHeaderChanges::ForkAdded(fork)) => { + log_debug!(logger, "Kyoto added fork header at height {}", fork.height); + }, + } + } + } + + pub(crate) fn register_tx(&self, _txid: &Txid, script_pubkey: &Script) { + self.registered_scripts.lock().expect("lock").insert(script_pubkey.into()); + } + + pub(crate) fn register_output(&self, output: WatchedOutput) { + self.registered_scripts.lock().expect("lock").insert(output.script_pubkey); + } + + pub(crate) async fn continuously_update_fee_rate_estimates( + &self, mut stop_sync_receiver: watch::Receiver<()>, + ) { + let mut fee_rate_update_interval = + tokio::time::interval(Duration::from_secs(DEFAULT_FEE_RATE_CACHE_UPDATE_INTERVAL_SECS)); + // We primed the cache once on startup, so skip the immediate first tick. + fee_rate_update_interval.reset(); + fee_rate_update_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + tokio::select! { + _ = stop_sync_receiver.changed() => { + log_trace!(self.logger, "Stopping CBF fee-rate update loop."); + return; + } + _ = fee_rate_update_interval.tick() => { + if let Err(e) = self.update_fee_rate_estimates().await { + log_error!(self.logger, "Failed to update fee rate estimates: {:?}", e); + } + } + } + } + } + + pub(crate) async fn update_fee_rate_estimates(&self) -> Result<(), Error> { + let new_fee_rate_cache = resolve_fee_estimates( + &self.hooks, + &self.logger, + Duration::from_secs(CHAIN_SERVICE_HOOK_TIMEOUT_SECS), + || self.fee_rate_cache_from_source(), + ) + .await?; + + self.commit_fee_rate_cache(new_fee_rate_cache).await + } + + /// The existing (Esplora / Electrum / native-CBF) fee computation, unchanged from before the + /// [`ChainServiceHooks`] fee-estimates hook was introduced. Called by + /// [`update_fee_rate_estimates`](Self::update_fee_rate_estimates) as the `fallback` passed to + /// [`resolve_fee_estimates`]. + async fn fee_rate_cache_from_source( + &self, + ) -> Result, Error> { + let new_fee_rate_cache = match &self.fee_source { + FeeSource::Esplora { client } => { + let estimates = client.get_fee_estimates().await.map_err(|e| { + log_error!(self.logger, "Failed to retrieve fee rate estimates: {}", e); + Error::FeerateEstimationUpdateFailed + })?; + + if estimates.is_empty() && self.config.network == Network::Bitcoin { + log_error!( + self.logger, + "Failed to retrieve fee rate: empty fee estimates are disallowed on Mainnet." + ); + return Err(Error::FeerateEstimationUpdateFailed); + } + + let mut new_fee_rate_cache = HashMap::with_capacity(10); + for target in get_all_conf_targets() { + let num_blocks = get_num_block_defaults_for_target(target); + // Fall back to 1 sat/vb if we fail or it yields less than that, mostly to keep + // going on signet/regtest where estimates may be missing or bogus. + let converted_estimate_sat_vb = + esplora_client::convert_fee_rate(num_blocks, estimates.clone()) + .map_or(1.0, |converted| converted.max(1.0)); + let fee_rate = + FeeRate::from_sat_per_kwu((converted_estimate_sat_vb * 250.0) as u64); + let adjusted_fee_rate = apply_post_estimation_adjustments(target, fee_rate); + new_fee_rate_cache.insert(target, adjusted_fee_rate); + } + new_fee_rate_cache + }, + FeeSource::Electrum { server_url } => { + let electrum_config = ElectrumConfigBuilder::new() + .retry(ELECTRUM_FEE_NUM_RETRIES) + .timeout(Some(Duration::from_secs(ELECTRUM_FEE_TIMEOUT_SECS))) + .build(); + + let server_url = server_url.clone(); + let electrum_client = self + .runtime + .spawn_blocking(move || { + ElectrumClient::from_config(&server_url, electrum_config) + }) + .await + .map_err(|e| { + log_error!(self.logger, "Fee rate estimation task panicked: {}", e); + Error::FeerateEstimationUpdateFailed + })? + .map_err(|e| { + log_error!(self.logger, "Failed to connect to electrum server: {}", e); + Error::ConnectionFailed + })?; + + get_electrum_fee_rate_cache_update( + Arc::clone(&self.runtime), + Arc::new(electrum_client), + self.config.network, + ELECTRUM_FEE_TIMEOUT_SECS, + Arc::clone(&self.logger), + ) + .await? + }, + FeeSource::Cbf { block_fee_cache } => { + let requester = match &*self.cbf_runtime_status.lock().expect("lock") { + CbfRuntimeStatus::Started { requester } => requester.clone(), + CbfRuntimeStatus::Stopped => return Err(Error::FeerateEstimationUpdateFailed), + }; + let mut samples_sat_per_kwu: Vec = self + .refresh_block_fee_window(&requester, block_fee_cache) + .await + .iter() + .map(|rate| rate.to_sat_per_kwu()) + .collect(); + samples_sat_per_kwu.sort_unstable(); + + let mut new_fee_rate_cache = HashMap::with_capacity(10); + for target in get_all_conf_targets() { + let fee_rate = if samples_sat_per_kwu.is_empty() { + FeeRate::from_sat_per_kwu(get_fallback_rate_for_target(target) as u64) + } else { + let percentile = cbf_percentile_for_target(target); + let sat_per_kwu = percentile_of_sorted(&samples_sat_per_kwu, percentile) + .max(CBF_MIN_FEERATE_SAT_PER_KWU); + FeeRate::from_sat_per_kwu(sat_per_kwu) + }; + let adjusted_fee_rate = apply_post_estimation_adjustments(target, fee_rate); + new_fee_rate_cache.insert(target, adjusted_fee_rate); + } + new_fee_rate_cache + }, + }; + + Ok(new_fee_rate_cache) + } + + /// Writes a freshly computed per-target fee-rate map into the estimator cache and records the + /// update timestamp in the node metrics. + async fn commit_fee_rate_cache( + &self, new_fee_rate_cache: HashMap, + ) -> Result<(), Error> { + self.fee_estimator.set_fee_rate_cache(new_fee_rate_cache); + let unix_time_secs_opt = + SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs()); + update_and_persist_node_metrics(&self.node_metrics, &*self.kv_store, &*self.logger, |m| { + m.latest_fee_rate_cache_update_timestamp = unix_time_secs_opt + }) + .await?; + Ok(()) + } + + /// Relays one broadcast package (hook first, P2P fallback second) and then tells the on-chain + /// wallet what just left the node. + /// + /// A CBF chain source has no mempool view of its own: nothing ever feeds it unconfirmed + /// transactions the way the bitcoind chain source's mempool poll does. Left alone, the wallet + /// would keep treating the coins a just-broadcast transaction spent as unspent until the + /// transaction confirms, and the next send would happily double-spend them (bitcoind then + /// refuses the second send as an underpaid replacement). So every transaction that was + /// handed to the hook or to P2P — the wallet's own sends and LDK's funding/sweep + /// transactions alike — is applied as unconfirmed here (BDK keeps only the ones relevant to + /// the wallet), and every transaction the hook's service REJECTED is evicted instead, which + /// hands its inputs back to the wallet. `Wallet::send_to_address` applies its own + /// transaction even earlier, before it is queued, so two back-to-back sends never race the + /// broadcast queue; the re-application here is a harmless `last_seen` refresh for those. + pub(crate) async fn process_broadcast_package( + &self, package: Vec, onchain_wallet: &Wallet, + ) { + // Read the requester (if any) up front, but do NOT bail out when the kyoto runtime is + // stopped: the broadcast hook must still be tried (it's payment-agnostic and may be the + // only working relay left once the CBF restart loop has given up, e.g. for a + // time-sensitive force-close tx). Only the P2P fallback leg below actually needs a live + // `Requester` — it degrades to an error log when there isn't one. + let requester = match &*self.cbf_runtime_status.lock().expect("lock") { + CbfRuntimeStatus::Started { requester } => Some(requester.clone()), + CbfRuntimeStatus::Stopped => None, + }; + let logger = Arc::clone(&self.logger); + + let outcome = dispatch_broadcast( + &self.hooks, + &self.logger, + Duration::from_secs(CHAIN_SERVICE_HOOK_TIMEOUT_SECS), + package.clone(), + move |package| async move { + let Some(requester) = requester else { + log_error!( + logger, + "Cannot P2P-broadcast transaction package: CBF chain source is stopped \ + and no external chain-service broadcast hook accepted it." + ); + return; + }; + let hold_timeout = Duration::from_secs(CBF_P2P_BROADCAST_TIMEOUT_SECS); + match Package::from_vec(package.clone()) { + Ok(kyoto_package) => { + bounded_p2p_handoff( + &logger, + hold_timeout, + "the transaction package", + requester.submit_package(kyoto_package), + ) + .await; + }, + Err(_) => { + for tx in package { + let what = format!("transaction {}", tx.compute_txid()); + bounded_p2p_handoff( + &logger, + hold_timeout, + &what, + requester.submit_package(tx), + ) + .await; + } + }, + } + }, + ) + .await; + + let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + let (unconfirmed, evicted): (Vec<(Transaction, u64)>, Vec<(Txid, u64)>) = match outcome { + BroadcastOutcome::HookHandled => { + log_debug!( + self.logger, + "External chain-service broadcast hook accepted the transaction package; P2P \ + relay skipped." + ); + (package.into_iter().map(|tx| (tx, now)).collect(), Vec::new()) + }, + BroadcastOutcome::FellThroughToP2p => { + (package.into_iter().map(|tx| (tx, now)).collect(), Vec::new()) + }, + BroadcastOutcome::Rejected(rejected) => { + let rejected_txids: HashSet = + rejected.iter().map(|(txid, _)| *txid).collect(); + for (txid, reason) in &rejected { + log_error!( + self.logger, + "External chain service rejected transaction {}; giving it up (no P2P \ + relay) and releasing its inputs in the on-chain wallet: {}", + txid, + reason + ); + } + let accepted = package + .into_iter() + .filter(|tx| !rejected_txids.contains(&tx.compute_txid())) + .map(|tx| (tx, now)) + .collect(); + (accepted, rejected.into_iter().map(|(txid, _)| (txid, now)).collect()) + }, + }; + + if let Err(e) = onchain_wallet.apply_mempool_txs(unconfirmed, evicted).await { + log_error!( + self.logger, + "Failed to record the broadcast package in the on-chain wallet: {}", + e + ); + } + } + + /// Reconciles the block-fee cache against the canonical chain and returns the per-block fee + /// rates for the most recent [`FEE_WINDOW_BLOCKS`] blocks. + /// + /// For each height in the window we fetch the canonical block hash; if the cached entry still + /// matches we reuse its rate, otherwise (new block, or a block that was reorged out) we download + /// it via [`Requester::average_fee_rate`]. Heights outside the window are evicted by replacing + /// the cache with the freshly built window. + /// + /// This is best-effort: a height we can't fetch a header or block for is simply skipped (so a + /// slow or unresponsive peer can't stall or void the whole update), and an empty result just + /// means we have no recent data yet. The window therefore fills incrementally over successive + /// updates rather than requiring all [`FEE_WINDOW_BLOCKS`] downloads to succeed at once. + async fn refresh_block_fee_window( + &self, requester: &Requester, cache: &Mutex>, + ) -> Vec { + let tip_height = match requester.chain_tip().await { + Ok(tip) => tip.height, + Err(e) => { + log_error!(self.logger, "CBF fee update: failed to fetch chain tip: {:?}", e); + return Vec::new(); + }, + }; + let lo = tip_height.saturating_sub(FEE_WINDOW_BLOCKS - 1); + + // Snapshot the cache so we never hold the std `Mutex` across an `.await`. + let cached = cache.lock().expect("lock").clone(); + + let mut window = BTreeMap::new(); + for height in lo..=tip_height { + let canonical_hash = match requester.get_header(height).await { + // Height not available (yet); skip it. + Ok(None) => continue, + Ok(Some(header)) => header.block_hash(), + Err(e) => { + log_debug!( + self.logger, + "CBF fee update: failed to fetch header at height {}, skipping: {:?}", + height, + e + ); + continue; + }, + }; + + // Reuse the cached rate while the block is still canonical; otherwise download it. + if let Some((hash, fee_rate)) = cached.get(&height) { + if *hash == canonical_hash { + window.insert(height, (canonical_hash, *fee_rate)); + continue; + } + } + + match tokio::time::timeout( + Duration::from_secs(CBF_BLOCK_FETCH_TIMEOUT_SECS), + requester.average_fee_rate(canonical_hash), + ) + .await + { + Ok(Ok(fee_rate)) => { + window.insert(height, (canonical_hash, fee_rate)); + }, + Ok(Err(e)) => { + log_debug!( + self.logger, + "CBF fee update: failed to fetch fee rate for block {}, skipping: {:?}", + canonical_hash, + e + ); + }, + Err(_) => { + log_debug!( + self.logger, + "CBF fee update: timed out fetching block {} for fee estimation, skipping.", + canonical_hash, + ); + }, + } + } + + let samples = window.values().map(|(_, fee_rate)| *fee_rate).collect(); + // Replacing the cache wholesale also evicts any entries that fell out of the window. + *cache.lock().expect("lock") = window; + samples + } +} + +fn resume_checkpoint(logger: &Logger, chain_listener: &ChainListener) -> Option { + let min_best_block = chain_listener.get_best_block(); + let bdk_cp = chain_listener.onchain_wallet.latest_checkpoint(); + + if let Some(bdk_at_height) = bdk_cp.get(min_best_block.height) { + if bdk_at_height.hash() != min_best_block.block_hash { + log_error!( + logger, + "CBF resume: listener best block at height {} has hash {} but BDK has {}; \ + a component may be on a stale fork. Anchoring on BDK's chain.", + min_best_block.height, + min_best_block.block_hash, + bdk_at_height.hash(), + ); + } + } + + // Walk BDK's checkpoint chain back to the reorg-safe anchor height. + let target_height = min_best_block.height.saturating_sub(REORG_SAFETY_BLOCKS); + let cursor = resume_anchor(bdk_cp, target_height); + + if cursor.height() > min_best_block.height { + // The wallet's lowest usable checkpoint sits above a listener (e.g. a wallet anchored + // at its birthday next to Lightning state persisted below it). Anchoring kyoto there + // would make it emit only blocks the applicator refuses to apply — `next_height` + // derives from the *minimum* listener — stalling sync forever without ever tripping + // the divergence gate. Fall back to a full scan so the lagging listener can catch up. + log_error!( + logger, + "CBF resume: wallet's lowest usable checkpoint (height {}) is above the \ + furthest-behind listener (height {}); falling back to a scan from genesis.", + cursor.height(), + min_best_block.height, + ); + return None; + } + + (cursor.height() > 0).then(|| HashCheckpoint::new(cursor.height(), cursor.hash())) +} + +/// Walks `bdk_cp` back toward `target_height` without ever stepping onto genesis. +/// +/// On a dense chain this lands on the checkpoint at `target_height`, exactly like a plain +/// walk. The genesis guard matters for sparse chains — most importantly a fresh wallet whose +/// only real anchor is its birthday checkpoint (`[genesis, birthday]`): stepping onto genesis +/// there would make [`resume_checkpoint`] return `None` and silently demote the node to a full +/// filter scan from block 1, with every block below the birthday discarded on arrival. +fn resume_anchor(bdk_cp: bdk_chain::CheckPoint, target_height: u32) -> bdk_chain::CheckPoint { + let mut cursor = bdk_cp; + while cursor.height() > target_height { + match cursor.prev() { + Some(prev) if prev.height() > 0 => cursor = prev, + _ => break, + } + } + cursor +} + +/// Returns the highest checkpoint compiled into the `bip157` crate strictly below +/// `first_scan_height`, or `None` when the wallet should root at genesis. +/// +/// Strictly below, because everything downstream — the initial BDK checkpoint, the listeners' +/// best block, kyoto's `ChainState::Checkpoint` — treats the anchor as already applied, and +/// scanning begins at the block after it. An anchor *at* `first_scan_height` would silently +/// skip that block's filters, losing a transaction confirmed exactly there. +/// +/// Only compiled-in constants are used: CBF has no chain backend at build time, and consulting +/// a third-party tip oracle would reintroduce exactly the dependency this chain source removes. +/// Rounding down can only extend the scanned range, never shrink it. Mainnet ships three such +/// anchors — 481,823 (one block before SegWit activation), 709,631 (one block before taproot +/// activation) and 965,999 (see [`anchor_965_999`]); all other networks resolve to `None`. +pub(crate) fn birthday_checkpoint( + network: Network, first_scan_height: u32, +) -> Option { + if network != Network::Bitcoin { + return None; + } + mainnet_anchors() + .into_iter() + .map(|(cp, _)| cp) + .filter(|cp| cp.height < first_scan_height) + .max_by_key(|cp| cp.height) +} + +/// Mainnet block 965,999 (mined 2026-09-08), the newest compiled birthday anchor: a wallet +/// born at block 966,000 or later scans nothing older than that, instead of falling through to +/// the taproot anchor and ~256,000 blocks of filters. The hash was cross-checked against three +/// independent explorers (mempool.space, blockstream.info, blockcypher.com) on 2026-09-09, +/// about 100 blocks below the tip, so no reorg can reach it. Add a newer entry to +/// [`mainnet_anchors`] when a later birthday is wanted; never edit an existing one — wallets +/// already anchored on it would latch divergence at the next start. +fn anchor_965_999() -> HashCheckpoint { + let hash = "00000000000000000000dbb4d1e55ad22ed5b5a7d81d4c0fe992fceb8a5302d0" + .parse::() + .expect("compiled block hash"); + HashCheckpoint::new(965_999, hash) +} + +/// Every compiled mainnet birthday anchor, ascending, with the provenance label the startup +/// log prints beside it. +fn mainnet_anchors() -> [(HashCheckpoint, &'static str); 3] { + [ + (HashCheckpoint::segwit_activation(), "bip157 segwit_activation constant"), + (HashCheckpoint::taproot_activation(), "bip157 taproot_activation constant"), + (anchor_965_999(), "ldk-node block 965,999 constant"), + ] +} + +/// Resolves a configured `wallet_rescan_from_height` into the initial chain tip handed to the +/// builder, logging the anchor and its provenance. +/// +/// The returned locator seeds the initial BDK checkpoint of a wallet whose persisted chain +/// state is still rooted at genesis, the best block of a freshly created `ChannelManager` and +/// sweeper, and — through them — kyoto's resume checkpoint and the block applicator's +/// `next_height`. A wallet with a persisted block is never rewound, but absent Lightning +/// components are still initialized from it. +pub(crate) fn resolve_birthday( + logger: &Logger, network: Network, wallet_rescan_from_height: Option, +) -> Option { + let requested = wallet_rescan_from_height?; + match birthday_checkpoint(network, requested) { + Some(cp) => { + let provenance = mainnet_anchors() + .into_iter() + .find(|(anchor, _)| *anchor == cp) + .map(|(_, label)| label) + .unwrap_or("compiled anchor"); + log_info!( + logger, + "CBF wallet birthday: requested height {} resolved to compiled checkpoint at \ + height {} (hash {}, {}); scanning starts at height {}. Applied only while the \ + wallet's persisted chain state is still rooted at genesis.", + requested, + cp.height, + cp.hash, + provenance, + cp.height + 1, + ); + Some(BlockLocator::new(cp.hash, cp.height)) + }, + None => { + log_info!( + logger, + "CBF wallet birthday: no compiled checkpoint strictly below requested height {} \ + on {}; a fresh wallet will scan from genesis.", + requested, + network, + ); + None + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::hashes::Hash; + + // ------------------------------------------------------------------------------------------ + // simplify_sync_state: internal CbfSyncState -> public CbfSyncStatus mapping. + // ------------------------------------------------------------------------------------------ + + #[test] + fn simplify_sync_state_active_not_synced_is_syncing() { + let state = CbfSyncState::Active { applied_tip: Some(100), synced_to_tip: false }; + assert_eq!(simplify_sync_state(state), CbfSyncStatus::Syncing); + } + + #[test] + fn simplify_sync_state_active_no_applied_tip_is_syncing() { + // Freshly constructed state before `start()` ever ran. + let state = CbfSyncState::Active { applied_tip: None, synced_to_tip: false }; + assert_eq!(simplify_sync_state(state), CbfSyncStatus::Syncing); + } + + #[test] + fn simplify_sync_state_active_synced_to_tip_is_synced() { + let state = CbfSyncState::Active { applied_tip: Some(900_000), synced_to_tip: true }; + assert_eq!(simplify_sync_state(state), CbfSyncStatus::Synced); + } + + #[test] + fn simplify_sync_state_failed_is_failed_regardless_of_error_variant() { + assert_eq!( + simplify_sync_state(CbfSyncState::Failed(Error::NotRunning)), + CbfSyncStatus::Failed + ); + assert_eq!( + simplify_sync_state(CbfSyncState::Failed(Error::TxSyncFailed)), + CbfSyncStatus::Failed + ); + } + + #[test] + fn parse_peer_accepts_hostname() { + let p = parse_trusted_peer("bitcoind.local:18444").expect("hostname peer"); + // shape assertion only — resolution happens at connect time + assert!( + matches!(p, ParsedPeer::Hostname { ref host, port } if host == "bitcoind.local" && port == 18444) + ); + let p2 = parse_trusted_peer("127.0.0.1:18444").expect("socketaddr peer"); + assert!(matches!(p2, ParsedPeer::Addr(_))); + assert!(parse_trusted_peer("no-port-here").is_err()); + } + + #[test] + fn birthday_anchors_strictly_below_the_first_scan_height() { + let newest = anchor_965_999(); + let taproot = HashCheckpoint::taproot_activation(); + let segwit = HashCheckpoint::segwit_activation(); + + assert_eq!(birthday_checkpoint(Network::Bitcoin, u32::MAX), Some(newest)); + // A wallet born at block 966,000 anchors one block below it and scans from 966,000. + assert_eq!(birthday_checkpoint(Network::Bitcoin, 966_000), Some(newest)); + assert_eq!(birthday_checkpoint(Network::Bitcoin, newest.height + 1), Some(newest)); + assert_eq!(birthday_checkpoint(Network::Bitcoin, newest.height), Some(taproot)); + assert_eq!(birthday_checkpoint(Network::Bitcoin, 900_000), Some(taproot)); + assert_eq!(birthday_checkpoint(Network::Bitcoin, taproot.height + 1), Some(taproot)); + // Scanning starts strictly after the anchor, so a first transaction exactly at a + // compiled anchor height must fall through to the next-lower anchor or that block's + // filters would never be checked. + assert_eq!(birthday_checkpoint(Network::Bitcoin, taproot.height), Some(segwit)); + assert_eq!(birthday_checkpoint(Network::Bitcoin, segwit.height + 1), Some(segwit)); + assert_eq!(birthday_checkpoint(Network::Bitcoin, segwit.height), None); + assert_eq!(birthday_checkpoint(Network::Bitcoin, 0), None); + } + + #[test] + fn birthday_is_mainnet_only() { + for network in [Network::Testnet, Network::Signet, Network::Regtest] { + assert_eq!(birthday_checkpoint(network, u32::MAX), None); + } + } + + #[test] + fn newest_anchor_is_block_965_999_with_its_published_hash() { + let newest = anchor_965_999(); + assert_eq!(newest.height, 965_999); + assert_eq!( + newest.hash.to_string(), + "00000000000000000000dbb4d1e55ad22ed5b5a7d81d4c0fe992fceb8a5302d0" + ); + } + + #[test] + fn mainnet_anchors_are_distinct_and_ascend() { + let anchors = mainnet_anchors(); + for pair in anchors.windows(2) { + assert!(pair[0].0.height < pair[1].0.height, "anchors must ascend: {:?}", anchors); + assert_ne!(pair[0].0.hash, pair[1].0.hash); + } + } + + #[test] + fn resolve_birthday_anchors_a_966_000_birthday_at_block_965_999() { + let logger = test_logger(); + let anchor = + resolve_birthday(&logger, Network::Bitcoin, Some(966_000)).expect("a mainnet anchor"); + assert_eq!(anchor.height, 965_999); + assert_eq!(anchor.block_hash, anchor_965_999().hash); + // `None` and non-mainnet networks still root a fresh wallet at genesis. + assert!(resolve_birthday(&logger, Network::Bitcoin, None).is_none()); + assert!(resolve_birthday(&logger, Network::Regtest, Some(966_000)).is_none()); + } + + fn chain_of(heights: &[u32]) -> bdk_chain::CheckPoint { + bdk_chain::CheckPoint::from_block_ids( + heights + .iter() + .map(|h| bdk_chain::BlockId { height: *h, hash: bitcoin::BlockHash::all_zeros() }), + ) + .expect("strictly increasing heights") + } + + #[test] + fn resume_anchor_walks_dense_chains_to_the_target() { + let heights: Vec = (0..=10).collect(); + let cp = chain_of(&heights); + assert_eq!(resume_anchor(cp.clone(), 3).height(), 3); + assert_eq!(resume_anchor(cp.clone(), 10).height(), 10); + // Target 0 stops at height 1: the anchor never falls onto genesis. + assert_eq!(resume_anchor(cp, 0).height(), 1); + } + + #[test] + fn resume_anchor_never_falls_onto_genesis_on_sparse_chains() { + // A fresh wallet with a birthday checkpoint: [genesis, birthday]. The reorg-safety + // walk-back must anchor on the birthday, not slide onto genesis and force a full + // scan from block 1. + let cp = chain_of(&[0, 709_631]); + assert_eq!(resume_anchor(cp, 709_624).height(), 709_631); + + let genesis_only = chain_of(&[0]); + assert_eq!(resume_anchor(genesis_only, 0).height(), 0); + } + + // ------------------------------------------------------------------------------------------ + // ChainServiceHooks (Task 5 + fix round 1): fee-estimates and broadcast decision functions. + // ------------------------------------------------------------------------------------------ + + use std::sync::atomic::{AtomicBool, Ordering}; + + use lightning::chain::chaininterface::ConfirmationTarget as LdkConfirmationTarget; + + use crate::chain::{BroadcastFuture, FeeEstimatesFuture}; + + fn test_logger() -> Logger { + Logger::new_log_facade() + } + + /// Generous timeout for tests where the hook resolves immediately — long enough to never be + /// mistaken for the hung-hook case, short enough to never meaningfully slow the suite down. + fn generous_test_timeout() -> Duration { + Duration::from_secs(5) + } + + /// A full, valid `fee_estimates` map: one entry per distinct block-count target the + /// estimator needs (`1, 3, 6, 12, 144, 1008`), each with a different sat/vB value so + /// per-target application can be told apart in assertions. + fn full_by_blocks_map() -> HashMap { + let mut by_blocks = HashMap::new(); + by_blocks.insert(1u16, 50.0); + by_blocks.insert(3u16, 30.0); + by_blocks.insert(6u16, 20.0); + by_blocks.insert(12u16, 15.0); + by_blocks.insert(144u16, 10.0); + by_blocks.insert(1008u16, 5.0); + by_blocks + } + + /// Mirrors `resolve_fee_estimates`'s own sat/vB -> sat/kwu conversion + post-estimation + /// adjustment, so tests assert against the real formula instead of a hardcoded, driftable + /// number. + fn expected_rate(sat_per_vb: f64, target: ConfirmationTarget) -> FeeRate { + apply_post_estimation_adjustments( + target, + FeeRate::from_sat_per_kwu((sat_per_vb * 250.0) as u64), + ) + } + + #[test] + fn required_hook_fee_targets_is_the_six_distinct_block_counts() { + // Sanity check on the fixture used by every full-map test below: if the per-target + // block-count defaults ever change, this (and `full_by_blocks_map`) should be the first + // thing to fail, not a confusing downstream assertion. + let required = required_hook_fee_targets(); + let mut expected: Vec = vec![1, 3, 6, 12, 144, 1008]; + expected.sort_unstable(); + let mut actual: Vec = required.into_iter().collect(); + actual.sort_unstable(); + assert_eq!(actual, expected); + } + + #[tokio::test] + async fn chain_service_hooks_fee_estimates_full_map_is_applied_per_target() { + let logger = test_logger(); + let hooks = ChainServiceHooks { + fee_estimates: Some(Arc::new(|| -> FeeEstimatesFuture { + Box::pin(async { Ok(full_by_blocks_map()) }) + })), + broadcast: None, + }; + + let cache = resolve_fee_estimates(&hooks, &logger, generous_test_timeout(), || async { + panic!("fallback must not run once the map covers every required target") + }) + .await + .expect("hook path succeeds"); + + // Every one of the 10 conf-targets must be present -- a full map is applied in full. + assert_eq!(cache.len(), get_all_conf_targets().len()); + + let max_fee = ConfirmationTarget::Lightning(LdkConfirmationTarget::MaximumFeeEstimate); + assert_eq!(cache[&max_fee], expected_rate(50.0, max_fee)); + + assert_eq!( + cache[&ConfirmationTarget::ChannelFunding], + expected_rate(30.0, ConfirmationTarget::ChannelFunding) + ); + + assert_eq!( + cache[&ConfirmationTarget::OnchainPayment], + expected_rate(20.0, ConfirmationTarget::OnchainPayment) + ); + let urgent_sweep = ConfirmationTarget::Lightning(LdkConfirmationTarget::UrgentOnChainSweep); + assert_eq!(cache[&urgent_sweep], expected_rate(20.0, urgent_sweep)); + + let non_anchor_fee = + ConfirmationTarget::Lightning(LdkConfirmationTarget::NonAnchorChannelFee); + assert_eq!(cache[&non_anchor_fee], expected_rate(15.0, non_anchor_fee)); + let output_spending = + ConfirmationTarget::Lightning(LdkConfirmationTarget::OutputSpendingFee); + assert_eq!(cache[&output_spending], expected_rate(15.0, output_spending)); + + // The special-cased adjustment (trims towards the relay floor) still applies to a + // hook-sourced rate, exactly as it does for the native sources. + let min_non_anchor = ConfirmationTarget::Lightning( + LdkConfirmationTarget::MinAllowedNonAnchorChannelRemoteFee, + ); + assert_eq!(cache[&min_non_anchor], expected_rate(10.0, min_non_anchor)); + let close_min = ConfirmationTarget::Lightning(LdkConfirmationTarget::ChannelCloseMinimum); + assert_eq!(cache[&close_min], expected_rate(10.0, close_min)); + + let min_anchor = + ConfirmationTarget::Lightning(LdkConfirmationTarget::MinAllowedAnchorChannelRemoteFee); + assert_eq!(cache[&min_anchor], expected_rate(5.0, min_anchor)); + let anchor_fee = ConfirmationTarget::Lightning(LdkConfirmationTarget::AnchorChannelFee); + assert_eq!(cache[&anchor_fee], expected_rate(5.0, anchor_fee)); + } + + #[tokio::test] + async fn chain_service_hooks_fee_estimates_sparse_map_falls_through_to_fallback() { + let logger = test_logger(); + // Covers every required block count except 1008 -- a realistic "hook forgot one tier" + // scenario, not just a totally empty map. + let hooks = ChainServiceHooks { + fee_estimates: Some(Arc::new(|| -> FeeEstimatesFuture { + Box::pin(async { + let mut by_blocks = full_by_blocks_map(); + by_blocks.remove(&1008u16); + Ok(by_blocks) + }) + })), + broadcast: None, + }; + + let mut configured_source_cache = HashMap::new(); + configured_source_cache + .insert(ConfirmationTarget::OnchainPayment, FeeRate::from_sat_per_kwu(999)); + let expected = configured_source_cache.clone(); + + let fallback_called = Arc::new(AtomicBool::new(false)); + let fallback_called_clone = Arc::clone(&fallback_called); + let cache = resolve_fee_estimates(&hooks, &logger, generous_test_timeout(), move || { + let fallback_called = Arc::clone(&fallback_called_clone); + let configured_source_cache = configured_source_cache.clone(); + async move { + fallback_called.store(true, Ordering::SeqCst); + Ok(configured_source_cache) + } + }) + .await + .expect("fallback succeeds"); + + assert!( + fallback_called.load(Ordering::SeqCst), + "a map missing even one required block-count target must be rejected in full, not \ + partially applied" + ); + assert_eq!(cache, expected); + } + + #[tokio::test] + async fn chain_service_hooks_fee_estimates_empty_map_falls_through_to_fallback() { + let logger = test_logger(); + let hooks = ChainServiceHooks { + fee_estimates: Some(Arc::new(|| -> FeeEstimatesFuture { + Box::pin(async { Ok(HashMap::new()) }) + })), + broadcast: None, + }; + + let mut configured_source_cache = HashMap::new(); + configured_source_cache + .insert(ConfirmationTarget::OnchainPayment, FeeRate::from_sat_per_kwu(999)); + let expected = configured_source_cache.clone(); + + let fallback_called = Arc::new(AtomicBool::new(false)); + let fallback_called_clone = Arc::clone(&fallback_called); + let cache = resolve_fee_estimates(&hooks, &logger, generous_test_timeout(), move || { + let fallback_called = Arc::clone(&fallback_called_clone); + let configured_source_cache = configured_source_cache.clone(); + async move { + fallback_called.store(true, Ordering::SeqCst); + Ok(configured_source_cache) + } + }) + .await + .expect("fallback succeeds"); + + assert!( + fallback_called.load(Ordering::SeqCst), + "an empty hook map must fall through to the configured source, never be silently \ + committed as a no-op cache with a fresh timestamp" + ); + // `resolve_fee_estimates` has exactly one return path per call: since the empty hook + // result was rejected, the only cache that could ever reach `commit_fee_rate_cache` + // (and therefore ever get a timestamp stamped) is this fallback cache. + assert_eq!(cache, expected); + } + + #[tokio::test] + async fn chain_service_hooks_fee_estimates_out_of_range_values_are_clamped() { + let logger = test_logger(); + let hooks = ChainServiceHooks { + fee_estimates: Some(Arc::new(|| -> FeeEstimatesFuture { + Box::pin(async { + let mut by_blocks = full_by_blocks_map(); + // Below the floor -> must clamp to CHAIN_SERVICE_HOOK_MIN_SAT_PER_VB, not to + // whatever a post-conversion `.max()` in sat/kwu units would have floored it + // to (that was 250x weaker than intended). + by_blocks.insert(3u16, 0.000_001); + // Absurdly large -> must clamp to CHAIN_SERVICE_HOOK_MAX_SAT_PER_VB rather + // than risk an overflow downstream in `FeeRate::to_sat_per_vb_ceil`. + by_blocks.insert(12u16, 1.0e12); + Ok(by_blocks) + }) + })), + broadcast: None, + }; + + let cache = resolve_fee_estimates(&hooks, &logger, generous_test_timeout(), || async { + panic!("fallback must not run once the map covers every required target") + }) + .await + .expect("hook path succeeds (all required targets present, just out of range)"); + + assert_eq!( + cache[&ConfirmationTarget::ChannelFunding], + expected_rate(CHAIN_SERVICE_HOOK_MIN_SAT_PER_VB, ConfirmationTarget::ChannelFunding) + ); + let non_anchor_fee = + ConfirmationTarget::Lightning(LdkConfirmationTarget::NonAnchorChannelFee); + assert_eq!( + cache[&non_anchor_fee], + expected_rate(CHAIN_SERVICE_HOOK_MAX_SAT_PER_VB, non_anchor_fee) + ); + } + + #[tokio::test] + async fn chain_service_hooks_fee_estimates_hook_error_falls_through() { + let logger = test_logger(); + let hooks = ChainServiceHooks { + fee_estimates: Some(Arc::new(|| -> FeeEstimatesFuture { Box::pin(async { Err(()) }) })), + broadcast: None, + }; + + let mut configured_source_cache = HashMap::new(); + configured_source_cache + .insert(ConfirmationTarget::OnchainPayment, FeeRate::from_sat_per_kwu(999)); + let expected = configured_source_cache.clone(); + + let fallback_called = Arc::new(AtomicBool::new(false)); + let fallback_called_clone = Arc::clone(&fallback_called); + let cache = resolve_fee_estimates(&hooks, &logger, generous_test_timeout(), move || { + let fallback_called = Arc::clone(&fallback_called_clone); + let configured_source_cache = configured_source_cache.clone(); + async move { + fallback_called.store(true, Ordering::SeqCst); + Ok(configured_source_cache) + } + }) + .await + .expect("fallback succeeds"); + + assert!( + fallback_called.load(Ordering::SeqCst), + "an errored hook must fall through to the configured fee source" + ); + assert_eq!(cache, expected); + } + + #[tokio::test] + async fn chain_service_hooks_fee_estimates_no_hook_falls_through() { + let logger = test_logger(); + let hooks = ChainServiceHooks::default(); + + let fallback_called = Arc::new(AtomicBool::new(false)); + let fallback_called_clone = Arc::clone(&fallback_called); + let _ = resolve_fee_estimates(&hooks, &logger, generous_test_timeout(), move || { + let fallback_called = Arc::clone(&fallback_called_clone); + async move { + fallback_called.store(true, Ordering::SeqCst); + Ok(HashMap::new()) + } + }) + .await + .expect("fallback succeeds"); + + assert!(fallback_called.load(Ordering::SeqCst), "no hook configured must fall through"); + } + + #[tokio::test] + async fn chain_service_hooks_fee_estimates_hook_timeout_falls_through() { + let logger = test_logger(); + // Never resolves -- combined with a short injected timeout below, this proves the + // timeout actually fires rather than hanging the fee-update cycle forever. + let hooks = ChainServiceHooks { + fee_estimates: Some(Arc::new(|| -> FeeEstimatesFuture { + Box::pin(std::future::pending()) + })), + broadcast: None, + }; + + let mut configured_source_cache = HashMap::new(); + configured_source_cache + .insert(ConfirmationTarget::OnchainPayment, FeeRate::from_sat_per_kwu(999)); + let expected = configured_source_cache.clone(); + + let fallback_called = Arc::new(AtomicBool::new(false)); + let fallback_called_clone = Arc::clone(&fallback_called); + let cache = resolve_fee_estimates(&hooks, &logger, Duration::from_millis(10), move || { + let fallback_called = Arc::clone(&fallback_called_clone); + let configured_source_cache = configured_source_cache.clone(); + async move { + fallback_called.store(true, Ordering::SeqCst); + Ok(configured_source_cache) + } + }) + .await + .expect("fallback succeeds"); + + assert!( + fallback_called.load(Ordering::SeqCst), + "a hung hook must time out and fall through" + ); + assert_eq!(cache, expected); + } + + #[tokio::test] + async fn chain_service_hooks_broadcast_hook_ok_skips_p2p() { + let logger = test_logger(); + let hooks = ChainServiceHooks { + fee_estimates: None, + broadcast: Some(Arc::new(|_txs| -> BroadcastFuture { Box::pin(async { Ok(()) }) })), + }; + let p2p_called = Arc::new(AtomicBool::new(false)); + let p2p_called_clone = Arc::clone(&p2p_called); + + let outcome = + dispatch_broadcast(&hooks, &logger, generous_test_timeout(), Vec::new(), move |_txs| { + let p2p_called = Arc::clone(&p2p_called_clone); + async move { + p2p_called.store(true, Ordering::SeqCst); + } + }) + .await; + + assert_eq!(outcome, BroadcastOutcome::HookHandled); + assert!( + !p2p_called.load(Ordering::SeqCst), + "P2P broadcast must not run when the hook accepts the package" + ); + } + + #[tokio::test] + async fn chain_service_hooks_broadcast_hook_err_falls_through_to_p2p() { + let logger = test_logger(); + let hooks = ChainServiceHooks { + fee_estimates: None, + broadcast: Some(Arc::new(|_txs| -> BroadcastFuture { + Box::pin(async { Err(BroadcastHookError::Unavailable) }) + })), + }; + let p2p_called = Arc::new(AtomicBool::new(false)); + let p2p_called_clone = Arc::clone(&p2p_called); + + let outcome = + dispatch_broadcast(&hooks, &logger, generous_test_timeout(), Vec::new(), move |_txs| { + let p2p_called = Arc::clone(&p2p_called_clone); + async move { + p2p_called.store(true, Ordering::SeqCst); + } + }) + .await; + + assert_eq!(outcome, BroadcastOutcome::FellThroughToP2p); + assert!( + p2p_called.load(Ordering::SeqCst), + "P2P broadcast must run when the hook declines the package" + ); + } + + #[tokio::test] + async fn chain_service_hooks_broadcast_hook_rejected_skips_p2p_and_reports_the_verdict() { + // A rejection is bitcoind's policy verdict on OUR transaction (e.g. "insufficient fee, + // rejecting replacement"): P2P relay would only collect the same verdict elsewhere, so + // the package must NOT fall through, and the verdict must reach the caller so the + // on-chain wallet can release the inputs of the refused transaction. + let logger = test_logger(); + let rejected_txid = Txid::all_zeros(); + let hooks = ChainServiceHooks { + fee_estimates: None, + broadcast: Some(Arc::new(move |_txs| -> BroadcastFuture { + Box::pin(async move { + Err(BroadcastHookError::Rejected(vec![( + rejected_txid, + "insufficient fee, rejecting replacement".to_string(), + )])) + }) + })), + }; + let p2p_called = Arc::new(AtomicBool::new(false)); + let p2p_called_clone = Arc::clone(&p2p_called); + + let outcome = + dispatch_broadcast(&hooks, &logger, generous_test_timeout(), Vec::new(), move |_txs| { + let p2p_called = Arc::clone(&p2p_called_clone); + async move { + p2p_called.store(true, Ordering::SeqCst); + } + }) + .await; + + assert_eq!( + outcome, + BroadcastOutcome::Rejected(vec![( + rejected_txid, + "insufficient fee, rejecting replacement".to_string() + )]) + ); + assert!( + !p2p_called.load(Ordering::SeqCst), + "a rejected package must never be relayed over P2P" + ); + } + + #[tokio::test] + async fn chain_service_hooks_broadcast_no_hook_falls_through_to_p2p() { + let logger = test_logger(); + let hooks = ChainServiceHooks::default(); + let p2p_called = Arc::new(AtomicBool::new(false)); + let p2p_called_clone = Arc::clone(&p2p_called); + + let outcome = + dispatch_broadcast(&hooks, &logger, generous_test_timeout(), Vec::new(), move |_txs| { + let p2p_called = Arc::clone(&p2p_called_clone); + async move { + p2p_called.store(true, Ordering::SeqCst); + } + }) + .await; + + assert_eq!(outcome, BroadcastOutcome::FellThroughToP2p); + assert!(p2p_called.load(Ordering::SeqCst), "no hook configured must fall through to P2P"); + } + + #[tokio::test] + async fn chain_service_hooks_broadcast_hook_timeout_falls_through_to_p2p() { + let logger = test_logger(); + let hooks = ChainServiceHooks { + fee_estimates: None, + broadcast: Some(Arc::new(|_txs| -> BroadcastFuture { + Box::pin(std::future::pending()) + })), + }; + let p2p_called = Arc::new(AtomicBool::new(false)); + let p2p_called_clone = Arc::clone(&p2p_called); + + let outcome = dispatch_broadcast( + &hooks, + &logger, + Duration::from_millis(10), + Vec::new(), + move |_txs| { + let p2p_called = Arc::clone(&p2p_called_clone); + async move { + p2p_called.store(true, Ordering::SeqCst); + } + }, + ) + .await; + + assert_eq!(outcome, BroadcastOutcome::FellThroughToP2p); + assert!( + p2p_called.load(Ordering::SeqCst), + "a hung broadcast hook must time out and fall through to P2P" + ); + } + + #[tokio::test] + async fn chain_service_hooks_broadcast_hook_succeeds_when_requester_unavailable() { + // Models `process_broadcast_package` once the CBF restart loop has given up + // (`CbfRuntimeStatus::Stopped`, no live `Requester`): the broadcast hook must still be + // tried, and if it accepts the package, the P2P leg (which needs the unavailable + // requester) is never reached at all. This is the fix for I-1: previously + // `process_broadcast_package` bailed out before even trying the hook once kyoto had + // given up -- exactly when an external hook might be the only working relay left (e.g. + // for a time-sensitive force-close tx). + let logger = test_logger(); + let hooks = ChainServiceHooks { + fee_estimates: None, + broadcast: Some(Arc::new(|_txs| -> BroadcastFuture { Box::pin(async { Ok(()) }) })), + }; + let p2p_attempted = Arc::new(AtomicBool::new(false)); + let p2p_attempted_clone = Arc::clone(&p2p_attempted); + + let outcome = + dispatch_broadcast(&hooks, &logger, generous_test_timeout(), Vec::new(), move |_txs| { + let p2p_attempted = Arc::clone(&p2p_attempted_clone); + async move { + // Stands in for `process_broadcast_package`'s real closure when there is no + // live `Requester` -- it would log an error and return without broadcasting. + p2p_attempted.store(true, Ordering::SeqCst); + } + }) + .await; + + assert_eq!(outcome, BroadcastOutcome::HookHandled); + assert!( + !p2p_attempted.load(Ordering::SeqCst), + "the broadcast hook must succeed without the (unavailable) P2P leg ever running" + ); + } + + // ------------------------------------------------------------------------------------------ + // P2P broadcast handoff (defect P2). `Requester::submit_package` completes only when a peer + // PULLS the transaction, so it never completes for a transaction the peer already has — and + // `continuously_process_broadcast_queue` drains serially, so one such handoff used to wedge + // every broadcast behind it (measured live: a cooperative-close re-broadcast stalled the + // queue, and NONE of the force-close sweeps generated over the next nine minutes ever + // reached the network). + // ------------------------------------------------------------------------------------------ + + /// Stand-in for `Requester::submit_package` against a peer that already knows the + /// transaction: kyoto queued the announcement, Bitcoin Core answered the `inv` with silence + /// (`got inv: wtx have peer=N`), and the oneshot is therefore never completed. + fn never_pulled_by_a_peer() -> impl std::future::Future> { + std::future::pending() + } + + /// Short enough that the never-pulled cases cost the suite nothing, long enough that the + /// pulled cases below are never mistaken for one. + fn short_handoff_timeout() -> Duration { + Duration::from_millis(50) + } + + #[tokio::test] + async fn a_handoff_no_peer_pulls_is_abandoned_rather_than_awaited_forever() { + let logger = test_logger(); + + // The outer timeout is what makes this a test rather than a hang: without the bound + // inside `bounded_p2p_handoff` the inner future is `Pending` forever. + let relayed = tokio::time::timeout( + Duration::from_secs(5), + bounded_p2p_handoff( + &logger, + short_handoff_timeout(), + "the transaction under test", + never_pulled_by_a_peer(), + ), + ) + .await + .expect( + "the P2P handoff must return on its own; an unbounded await here is the P2 wedge \ + that stops every later broadcast, force-close sweeps included", + ); + + assert!(!relayed, "a transaction no peer asked for was not relayed"); + } + + #[tokio::test] + async fn a_handoff_a_peer_does_pull_is_awaited_to_completion() { + // Non-vacuity for the bound: it must not turn every handoff into a timeout. + let logger = test_logger(); + + let relayed = bounded_p2p_handoff( + &logger, + generous_test_timeout(), + "the transaction under test", + async { + tokio::time::sleep(Duration::from_millis(5)).await; + Ok::<(), ()>(()) + }, + ) + .await; + + assert!(relayed, "a transaction a peer pulled must be reported as relayed"); + } + + #[tokio::test] + async fn a_handoff_rejected_by_the_cbf_client_reports_failure_without_stalling() { + // `submit_package` errors when the kyoto node has stopped. That is a fast, honest + // failure and must stay distinct from the timeout path. + let logger = test_logger(); + + let relayed = bounded_p2p_handoff( + &logger, + generous_test_timeout(), + "the transaction under test", + async { Err::<(), &str>("the CBF node has stopped") }, + ) + .await; + + assert!(!relayed, "a submit error must not be reported as a relay"); + } + + #[tokio::test] + async fn a_handoff_no_peer_pulls_does_not_wedge_the_broadcasts_behind_it() { + // The scenario-10 shape in miniature: the node re-broadcasts a cooperative-close + // transaction its counterparty already relayed (so no peer ever pulls it), and the + // force-close sweeps queued behind it must still go out. This is the property that + // makes the bound load-bearing, because the real drain loop is serial. + let logger = test_logger(); + let relayed: Arc>> = Arc::new(Mutex::new(Vec::new())); + + // (what, will a peer pull it?) + let queue = vec![ + ("the duplicate cooperative-close tx", false), + ("force-close sweep #1", true), + ("force-close sweep #2", true), + ]; + + let drained = tokio::time::timeout(Duration::from_secs(5), async { + for (what, pulled) in queue { + let ok = if pulled { + bounded_p2p_handoff(&logger, short_handoff_timeout(), what, async { + Ok::<(), ()>(()) + }) + .await + } else { + bounded_p2p_handoff( + &logger, + short_handoff_timeout(), + what, + never_pulled_by_a_peer(), + ) + .await + }; + if ok { + relayed.lock().expect("lock").push(what); + } + } + }) + .await; + + assert!( + drained.is_ok(), + "the serial broadcast drain must finish; hanging here is exactly the fund-safety \ + defect (sweeps generated forever, none relayed)" + ); + assert_eq!( + *relayed.lock().expect("lock"), + vec!["force-close sweep #1", "force-close sweep #2"], + "every broadcast queued behind an un-pulled one must still reach a peer" + ); + } +} diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index ad0ef1b7ba..574d7e2836 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}; @@ -15,6 +16,7 @@ use bdk_chain::bdk_core::spk_client::{ }; use bdk_electrum::BdkElectrumClient; use bdk_wallet::{KeychainKind as BdkKeyChainKind, Update as BdkUpdate}; +use bitcoin::transaction::Version; use bitcoin::{FeeRate, Network, Script, ScriptBuf, Transaction, Txid}; use electrum_client::{ Batch, Client as ElectrumClient, ConfigBuilder as ElectrumConfigBuilder, ElectrumApi, @@ -24,15 +26,19 @@ use lightning::util::ser::Writeable; use lightning_transaction_sync::ElectrumSyncClient; use super::WalletSyncStatus; -use crate::config::{Config, ElectrumSyncConfig, BDK_CLIENT_STOP_GAP}; +use crate::config::{ + clamp_full_scan_stop_gap, Config, ElectrumSyncConfig, MAX_FULL_SCAN_STOP_GAP, + MIN_FULL_SCAN_STOP_GAP, +}; use crate::error::Error; use crate::fee_estimator::{ apply_post_estimation_adjustments, get_all_conf_targets, get_num_block_defaults_for_target, ConfirmationTarget, OnchainFeeEstimator, }; use crate::io::utils::update_and_persist_node_metrics; -use crate::logger::{log_bytes, log_debug, log_error, log_trace, LdkLogger, Logger}; +use crate::logger::{log_bytes, log_debug, log_error, log_trace, log_warn, LdkLogger, Logger}; use crate::runtime::Runtime; +use crate::tx_broadcaster::SortedTransactions; use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::PersistedNodeMetrics; @@ -50,6 +56,7 @@ pub(super) struct ElectrumChainSource { config: Arc, logger: Arc, node_metrics: Arc, + force_wallet_full_scan: AtomicBool, } impl ElectrumChainSource { @@ -61,6 +68,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 +80,7 @@ impl ElectrumChainSource { config, logger: Arc::clone(&logger), node_metrics, + force_wallet_full_scan, } } @@ -89,6 +98,41 @@ impl ElectrumChainSource { self.electrum_runtime_status.write().expect("lock").stop(); } + /// Returns the shared on-chain fee estimator backing this chain source + /// (Peerswap native primitive B6). + #[cfg(feature = "swaps")] + pub(super) fn fee_estimator(&self) -> &Arc { + &self.fee_estimator + } + + /// Reorg-aware confirmation/eviction query for an ARBITRARY `txid` against + /// this Electrum backend (Peerswap native primitive B5). See + /// [`super::ChainSource::swap_query_tx`] for the fail-closed contract. + #[cfg(feature = "swaps")] + pub(super) async fn swap_query_tx( + &self, txid: Txid, script_pubkey: Option<&ScriptBuf>, + ) -> super::RawTxObservation { + let script_pubkey = match script_pubkey { + Some(script_pubkey) => script_pubkey.clone(), + None => { + log_error!( + self.logger, + "swap_query_tx: Electrum backend requires a watched scriptPubKey for {} (register via watch_txid)", + txid + ); + return super::RawTxObservation::Unreachable; + }, + }; + let client = match self.electrum_runtime_status.read().unwrap().client() { + Some(client) => client, + None => { + log_error!(self.logger, "swap_query_tx: Electrum chain source not started"); + return super::RawTxObservation::Unreachable; + }, + }; + client.swap_query_tx(txid, script_pubkey).await + } + pub(crate) async fn sync_onchain_wallet( &self, onchain_wallet: Arc, ) -> Result<(), Error> { @@ -125,9 +169,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 +206,9 @@ impl ElectrumChainSource { .await }; + if forced_full_scan && res.is_ok() { + self.force_wallet_full_scan.store(false, Ordering::Release); + } res } @@ -168,7 +217,7 @@ impl ElectrumChainSource { update_res: Result, now: Instant, ) -> Result<(), Error> { match update_res { - Ok(update) => match onchain_wallet.apply_update(update) { + Ok(update) => match onchain_wallet.apply_update(update).await { Ok(()) => { log_debug!( self.logger, @@ -275,7 +324,14 @@ impl ElectrumChainSource { let now = Instant::now(); - let new_fee_rate_cache = electrum_client.get_fee_rate_cache_update().await?; + let new_fee_rate_cache = get_electrum_fee_rate_cache_update( + Arc::clone(&electrum_client.runtime), + Arc::clone(&electrum_client.electrum_client), + self.config.network, + self.sync_config.timeouts_config.fee_rate_cache_update_timeout_secs, + Arc::clone(&self.logger), + ) + .await?; self.fee_estimator.set_fee_rate_cache(new_fee_rate_cache); log_debug!( @@ -294,7 +350,54 @@ impl ElectrumChainSource { Ok(()) } - pub(crate) async fn process_broadcast_package(&self, package: Vec) { + pub(crate) async fn validate_zero_fee_commitments_support(&self) -> Result<(), Error> { + let electrum_client: Arc = if let Some(client) = + self.electrum_runtime_status.read().expect("lock").client().as_ref() + { + Arc::clone(client) + } else { + debug_assert!( + false, + "We should have started the chain source before checking submitpackage support" + ); + return Err(Error::ConnectionFailed); + }; + + // TODO: Use `protocol_version` API once shipped in + // https://github.com/bitcoindevkit/rust-electrum-client/pull/213. + // + // This could still accept an Electrum server running against Bitcoin Core v26 + // through v28, which does not relay ephemeral dust. + let spawn_fut = electrum_client.runtime.spawn_blocking({ + let electrum_client = Arc::clone(&electrum_client.electrum_client); + move || electrum_client.transaction_broadcast_package(&super::dummy_package()) + }); + let timeout_fut = tokio::time::timeout( + Duration::from_secs(self.sync_config.timeouts_config.tx_broadcast_timeout_secs), + spawn_fut, + ); + + match timeout_fut.await { + Ok(Ok(Ok(_))) => Ok(()), + Ok(Ok(Err( + e @ (electrum_client::Error::Protocol(_) + | electrum_client::Error::AllAttemptsErrored(_)), + ))) => { + log_error!(self.logger, "Electrum server does not support submitpackage: {:?}", e); + Err(Error::ChainSourceNotSupported) + }, + e => { + log_error!( + self.logger, + "Failed to check support for submitpackage on the Electrum server: {:?}", + e + ); + Err(Error::ConnectionFailed) + }, + } + } + + pub(crate) async fn process_transaction_broadcast(&self, txs: SortedTransactions) { let electrum_client: Arc = if let Some(client) = self.electrum_runtime_status.read().expect("lock").client().as_ref() { @@ -304,8 +407,14 @@ impl ElectrumChainSource { return; }; - for tx in package { - electrum_client.broadcast(tx).await; + let all_txs_are_v3 = txs.iter().all(|tx| tx.version == Version::non_standard(3)); + match txs.len() { + 2.. if all_txs_are_v3 => electrum_client.submit_package(txs).await, + _ => { + for tx in txs.into_inner() { + electrum_client.broadcast(tx).await + } + }, } } } @@ -426,10 +535,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, @@ -442,6 +552,103 @@ impl ElectrumRuntimeClient { }) } + /// Reorg-aware confirmation/eviction query for an ARBITRARY `txid` + + /// `script_pubkey` (Peerswap native primitive B5). + /// + /// Electrum exposes no `getrawtransaction`-by-txid-alone RPC; the only + /// reorg-aware confirmation signal it exposes is per-scriptPubKey history + /// (`blockchain.scripthash.get_history`), so this scans that history for a + /// matching `tx_hash` and derives the confirmation depth against a freshly + /// polled tip. FAIL-CLOSED (E6) on any transport error or timeout. + #[cfg(feature = "swaps")] + async fn swap_query_tx(&self, txid: Txid, script_pubkey: ScriptBuf) -> super::RawTxObservation { + let electrum_client = Arc::clone(&self.electrum_client); + let history_spawn_fut = + self.runtime.spawn_blocking(move || electrum_client.script_get_history(&script_pubkey)); + let history_timeout_fut = tokio::time::timeout( + Duration::from_secs(self.sync_config.timeouts_config.per_request_timeout_secs as u64), + history_spawn_fut, + ); + let history = match history_timeout_fut.await { + Ok(Ok(Ok(history))) => history, + Ok(Ok(Err(e))) => { + log_error!( + self.logger, + "swap_query_tx: Electrum history query failed for {}: {}", + txid, + e + ); + return super::RawTxObservation::Unreachable; + }, + Ok(Err(e)) => { + log_error!( + self.logger, + "swap_query_tx: Electrum history query task failed for {}: {}", + txid, + e + ); + return super::RawTxObservation::Unreachable; + }, + Err(e) => { + log_error!( + self.logger, + "swap_query_tx: Electrum history query timed out for {}: {}", + txid, + e + ); + return super::RawTxObservation::Unreachable; + }, + }; + + let entry = match history.into_iter().find(|entry| entry.tx_hash == txid) { + Some(entry) => entry, + None => return super::RawTxObservation::NotFound, + }; + + // Electrum reports height `0` for a mempool tx, and a negative height for + // a mempool tx with an unconfirmed parent; neither is a confirmed height. + if entry.height <= 0 { + return super::RawTxObservation::InMempool; + } + let height = entry.height as u32; + + let electrum_client = Arc::clone(&self.electrum_client); + let tip_spawn_fut = + self.runtime.spawn_blocking(move || electrum_client.block_headers_subscribe()); + let tip_timeout_fut = tokio::time::timeout( + Duration::from_secs(self.sync_config.timeouts_config.per_request_timeout_secs as u64), + tip_spawn_fut, + ); + match tip_timeout_fut.await { + Ok(Ok(Ok(tip))) if tip.height as u32 >= height => { + let confirmations = (tip.height as u32).saturating_sub(height).saturating_add(1); + super::RawTxObservation::Confirmed { height: Some(height), confirmations } + }, + Ok(Ok(Ok(tip))) => { + log_error!( + self.logger, + "swap_query_tx: Electrum tip {} below confirming-block height {} for {} (reorg/race); failing closed", + tip.height, + height, + txid + ); + super::RawTxObservation::Unreachable + }, + Ok(Ok(Err(e))) => { + log_error!(self.logger, "swap_query_tx: Electrum tip query failed: {}", e); + super::RawTxObservation::Unreachable + }, + Ok(Err(e)) => { + log_error!(self.logger, "swap_query_tx: Electrum tip query task failed: {}", e); + super::RawTxObservation::Unreachable + }, + Err(e) => { + log_error!(self.logger, "swap_query_tx: Electrum tip query timed out: {}", e); + super::RawTxObservation::Unreachable + }, + } + } + async fn sync_confirmables( &self, confirmables: Vec>, ) -> Result<(), Error> { @@ -486,11 +693,12 @@ impl ElectrumRuntimeClient { ) -> Result, Error> { let bdk_electrum_client = Arc::clone(&self.bdk_electrum_client); bdk_electrum_client.populate_tx_cache(cached_txs); + let full_scan_stop_gap = self.bounded_full_scan_stop_gap(); let spawn_fut = self.runtime.spawn_blocking(move || { bdk_electrum_client.full_scan( request, - BDK_CLIENT_STOP_GAP, + full_scan_stop_gap, BDK_ELECTRUM_CLIENT_BATCH_SIZE, true, ) @@ -516,6 +724,22 @@ impl ElectrumRuntimeClient { }) } + fn bounded_full_scan_stop_gap(&self) -> usize { + let configured = self.sync_config.full_scan_stop_gap; + let bounded = clamp_full_scan_stop_gap(configured); + if bounded != configured { + log_warn!( + self.logger, + "Configured Electrum on-chain wallet full-scan stop gap {} is outside the allowed range {}..={}; using {}.", + configured, + MIN_FULL_SCAN_STOP_GAP, + MAX_FULL_SCAN_STOP_GAP, + bounded + ); + } + bounded as usize + } + async fn get_incremental_sync_wallet_update( &self, request: BdkSyncRequest<(BdkKeyChainKind, u32)>, cached_txs: impl IntoIterator>>, @@ -547,14 +771,24 @@ impl ElectrumRuntimeClient { }) } + fn log_broadcast_error(&self, e: impl core::fmt::Display, txids: &[Txid], txs: &[Transaction]) { + log_error!(self.logger, "Failed to broadcast transaction(s) {:?}: {}", txids, e); + log_trace!(self.logger, "Failed broadcast transaction bytes:"); + for tx in txs { + log_trace!(self.logger, "{}", log_bytes!(tx.encode())); + } + } + async fn broadcast(&self, tx: Transaction) { let electrum_client = Arc::clone(&self.electrum_client); let txid = tx.compute_txid(); - let tx_bytes = tx.encode(); + let tx = Arc::new(tx); - let spawn_fut = - self.runtime.spawn_blocking(move || electrum_client.transaction_broadcast(&tx)); + let spawn_fut = self.runtime.spawn_blocking({ + let tx = Arc::clone(&tx); + move || electrum_client.transaction_broadcast(tx.as_ref()) + }); let timeout_fut = tokio::time::timeout( Duration::from_secs(self.sync_config.timeouts_config.tx_broadcast_timeout_secs), spawn_fut, @@ -562,118 +796,135 @@ impl ElectrumRuntimeClient { match timeout_fut.await { Ok(res) => match res { - Ok(_) => { + Ok(Ok(txid)) => { log_trace!(self.logger, "Successfully broadcast transaction {}", txid); }, - Err(e) => { - log_error!(self.logger, "Failed to broadcast transaction {}: {}", txid, e); - log_trace!( - self.logger, - "Failed broadcast transaction bytes: {}", - log_bytes!(tx_bytes) - ); + Ok(Err(e)) => { + self.log_broadcast_error(e, &[txid], core::slice::from_ref(tx.as_ref())) }, + Err(e) => self.log_broadcast_error(e, &[txid], core::slice::from_ref(tx.as_ref())), }, - Err(e) => { - log_error!( - self.logger, - "Failed to broadcast transaction due to timeout {}: {}", - txid, - e - ); - log_trace!( - self.logger, - "Failed broadcast transaction bytes: {}", - log_bytes!(tx_bytes) - ); - }, + Err(e) => self.log_broadcast_error(e, &[txid], core::slice::from_ref(tx.as_ref())), } } - async fn get_fee_rate_cache_update( - &self, - ) -> Result, Error> { + async fn submit_package(&self, package: SortedTransactions) { let electrum_client = Arc::clone(&self.electrum_client); - let mut batch = Batch::default(); - let confirmation_targets = get_all_conf_targets(); - for target in confirmation_targets { - let num_blocks = get_num_block_defaults_for_target(target); - batch.estimate_fee(num_blocks, None); - } - - let spawn_fut = self.runtime.spawn_blocking(move || electrum_client.batch_call(&batch)); + let txids: Vec<_> = package.iter().map(|tx| tx.compute_txid()).collect(); + let package = Arc::new(package); + let spawn_fut = self.runtime.spawn_blocking({ + let package = Arc::clone(&package); + move || electrum_client.transaction_broadcast_package(&package) + }); let timeout_fut = tokio::time::timeout( - Duration::from_secs( - self.sync_config.timeouts_config.fee_rate_cache_update_timeout_secs, - ), + Duration::from_secs(self.sync_config.timeouts_config.tx_broadcast_timeout_secs), spawn_fut, ); - let raw_estimates_btc_kvb = timeout_fut - .await - .map_err(|e| { - log_error!(self.logger, "Updating fee rate estimates timed out: {}", e); - Error::FeerateEstimationUpdateTimeout - })? - .map_err(|e| { - log_error!(self.logger, "Failed to retrieve fee rate estimates: {}", e); - Error::FeerateEstimationUpdateFailed - })? - .map_err(|e| { - log_error!(self.logger, "Failed to retrieve fee rate estimates: {}", e); - Error::FeerateEstimationUpdateFailed - })?; + match timeout_fut.await { + Ok(res) => match res { + Ok(Ok(result)) => { + if result.success { + log_trace!( + self.logger, + "Successfully broadcast transaction(s) {:?}", + txids + ); + log_trace!( + self.logger, + "Successfully broadcast transaction(s) {:?}", + result + ); + } else { + self.log_broadcast_error(format!("{:?}", result), &txids, &package); + } + }, + Ok(Err(e)) => self.log_broadcast_error(e, &txids, &package), + Err(e) => self.log_broadcast_error(e, &txids, &package), + }, + Err(e) => self.log_broadcast_error(e, &txids, &package), + } + } +} - if raw_estimates_btc_kvb.len() != confirmation_targets.len() - && self.config.network == Network::Bitcoin - { - // Ensure we fail if we didn't receive all estimates. - debug_assert!(false, - "Electrum server didn't return all expected results. This is disallowed on Mainnet." - ); - log_error!(self.logger, +pub(crate) async fn get_electrum_fee_rate_cache_update( + runtime: Arc, electrum_client: Arc, network: Network, + fee_rate_cache_update_timeout_secs: u64, logger: Arc, +) -> Result, Error> { + let mut batch = Batch::default(); + let confirmation_targets = get_all_conf_targets(); + for target in confirmation_targets { + let num_blocks = get_num_block_defaults_for_target(target); + batch.estimate_fee(num_blocks, None); + } + + let spawn_fut = runtime.spawn_blocking(move || electrum_client.batch_call(&batch)); + + let timeout_fut = + tokio::time::timeout(Duration::from_secs(fee_rate_cache_update_timeout_secs), spawn_fut); + + let raw_estimates_btc_kvb = timeout_fut + .await + .map_err(|e| { + log_error!(logger, "Updating fee rate estimates timed out: {}", e); + Error::FeerateEstimationUpdateTimeout + })? + .map_err(|e| { + log_error!(logger, "Failed to retrieve fee rate estimates: {}", e); + Error::FeerateEstimationUpdateFailed + })? + .map_err(|e| { + log_error!(logger, "Failed to retrieve fee rate estimates: {}", e); + Error::FeerateEstimationUpdateFailed + })?; + + if raw_estimates_btc_kvb.len() != confirmation_targets.len() && network == Network::Bitcoin { + // Ensure we fail if we didn't receive all estimates. + debug_assert!( + false, + "Electrum server didn't return all expected results. This is disallowed on Mainnet." + ); + log_error!(logger, "Failed to retrieve fee rate estimates: Electrum server didn't return all expected results. This is disallowed on Mainnet." ); - return Err(Error::FeerateEstimationUpdateFailed); - } + return Err(Error::FeerateEstimationUpdateFailed); + } - let mut new_fee_rate_cache = HashMap::with_capacity(10); - for (target, raw_fee_rate_btc_per_kvb) in - confirmation_targets.into_iter().zip(raw_estimates_btc_kvb.into_iter()) - { - // Parse the retrieved serde_json::Value and fall back to 1 sat/vb (10^3 / 10^8 = 10^-5 - // = 0.00001 btc/kvb) if we fail or it yields less than that. This is mostly necessary - // to continue on `signet`/`regtest` where we might not get estimates (or bogus - // values). - let fee_rate_btc_per_kvb = raw_fee_rate_btc_per_kvb - .as_f64() - .map_or(0.00001, |converted| converted.max(0.00001)); - - // Electrum, just like Bitcoin Core, gives us a feerate in BTC/KvB. - // Thus, we multiply by 25_000_000 (10^8 / 4) to get satoshis/kwu. - let fee_rate = { - let fee_rate_sat_per_kwu = (fee_rate_btc_per_kvb * 25_000_000.0).round() as u64; - FeeRate::from_sat_per_kwu(fee_rate_sat_per_kwu) - }; - - // LDK 0.0.118 introduced changes to the `ConfirmationTarget` semantics that - // require some post-estimation adjustments to the fee rates, which we do here. - let adjusted_fee_rate = apply_post_estimation_adjustments(target, fee_rate); - - new_fee_rate_cache.insert(target, adjusted_fee_rate); - - log_trace!( - self.logger, - "Fee rate estimation updated for {:?}: {} sats/kwu", - target, - adjusted_fee_rate.to_sat_per_kwu(), - ); - } + let mut new_fee_rate_cache = HashMap::with_capacity(10); + for (target, raw_fee_rate_btc_per_kvb) in + confirmation_targets.into_iter().zip(raw_estimates_btc_kvb.into_iter()) + { + // Parse the retrieved serde_json::Value and fall back to 1 sat/vb (10^3 / 10^8 = 10^-5 + // = 0.00001 btc/kvb) if we fail or it yields less than that. This is mostly necessary + // to continue on `signet`/`regtest` where we might not get estimates (or bogus + // values). + let fee_rate_btc_per_kvb = + raw_fee_rate_btc_per_kvb.as_f64().map_or(0.00001, |converted| converted.max(0.00001)); + + // Electrum, just like Bitcoin Core, gives us a feerate in BTC/KvB. + // Thus, we multiply by 25_000_000 (10^8 / 4) to get satoshis/kwu. + let fee_rate = { + let fee_rate_sat_per_kwu = (fee_rate_btc_per_kvb * 25_000_000.0).round() as u64; + FeeRate::from_sat_per_kwu(fee_rate_sat_per_kwu) + }; + + // LDK 0.0.118 introduced changes to the `ConfirmationTarget` semantics that + // require some post-estimation adjustments to the fee rates, which we do here. + let adjusted_fee_rate = apply_post_estimation_adjustments(target, fee_rate); - Ok(new_fee_rate_cache) + new_fee_rate_cache.insert(target, adjusted_fee_rate); + + log_trace!( + logger, + "Fee rate estimation updated for {:?}: {} sats/kwu", + target, + adjusted_fee_rate.to_sat_per_kwu(), + ); } + + Ok(new_fee_rate_cache) } impl Filter for ElectrumRuntimeClient { diff --git a/src/chain/esplora.rs b/src/chain/esplora.rs index eb23a395d3..1cab71f3b3 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -6,24 +6,30 @@ // 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}; use bdk_esplora::EsploraAsyncExt; -use bitcoin::{FeeRate, Network, Script, Transaction, Txid}; +use bitcoin::transaction::Version; +use bitcoin::{FeeRate, Network, Script, Txid}; use esplora_client::AsyncClient as EsploraAsyncClient; use lightning::chain::{Confirm, Filter, WatchedOutput}; use lightning::util::ser::Writeable; use lightning_transaction_sync::EsploraSyncClient; use super::WalletSyncStatus; -use crate::config::{Config, EsploraSyncConfig, BDK_CLIENT_CONCURRENCY, BDK_CLIENT_STOP_GAP}; +use crate::config::{ + clamp_full_scan_stop_gap, Config, EsploraSyncConfig, BDK_CLIENT_CONCURRENCY, + MAX_FULL_SCAN_STOP_GAP, MIN_FULL_SCAN_STOP_GAP, +}; use crate::fee_estimator::{ apply_post_estimation_adjustments, get_all_conf_targets, get_num_block_defaults_for_target, OnchainFeeEstimator, }; use crate::io::utils::update_and_persist_node_metrics; -use crate::logger::{log_bytes, log_debug, log_error, log_trace, LdkLogger, Logger}; +use crate::logger::{log_bytes, log_debug, log_error, log_trace, log_warn, LdkLogger, Logger}; +use crate::tx_broadcaster::SortedTransactions; use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::{Error, PersistedNodeMetrics}; @@ -38,6 +44,7 @@ pub(super) struct EsploraChainSource { config: Arc, logger: Arc, node_metrics: Arc, + force_wallet_full_scan: AtomicBool, } impl EsploraChainSource { @@ -62,6 +69,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,9 +81,107 @@ impl EsploraChainSource { config, logger, node_metrics, + force_wallet_full_scan, }) } + /// Returns the shared on-chain fee estimator backing this chain source + /// (Peerswap native primitive B6). + #[cfg(feature = "swaps")] + pub(super) fn fee_estimator(&self) -> &Arc { + &self.fee_estimator + } + + /// Reorg-aware confirmation/eviction query for an ARBITRARY `txid` against + /// this Esplora backend (Peerswap native primitive B5). See + /// [`super::ChainSource::swap_query_tx`] for the fail-closed contract. + #[cfg(feature = "swaps")] + pub(super) async fn swap_query_tx(&self, txid: Txid) -> super::RawTxObservation { + let status = match self.esplora_client.get_tx_status(&txid).await { + Ok(status) => status, + Err(esplora_client::Error::HttpResponse { status: 404, .. }) => { + // Definitive "not in the chain or mempool" answer. + return super::RawTxObservation::NotFound; + }, + Err(e) => { + log_error!( + self.logger, + "swap_query_tx: Esplora status query failed for {}: {}", + txid, + e + ); + return super::RawTxObservation::Unreachable; + }, + }; + if !status.confirmed { + return super::RawTxObservation::InMempool; + } + let height = match status.block_height { + Some(height) => height, + None => { + log_error!( + self.logger, + "swap_query_tx: Esplora reported a confirmed tx {} without a block height", + txid + ); + return super::RawTxObservation::Unreachable; + }, + }; + // B5 LOW-2: the confirming-block height and the tip come from two + // separate Esplora calls; a block/reorg in the gap can make them + // inconsistent. Detect the one observable inconsistency — a tip BELOW + // the tx's confirming block (impossible on a single consistent chain) + // — and FAIL CLOSED (treat as unverifiable) rather than reporting a + // bogus `1`-confirmation from the saturating arithmetic. The benign + // gap (tip one block ahead of the status snapshot) only over-counts + // confirmations by ≤1, which errs on the safe/late side for deadlines. + match self.esplora_client.get_height().await { + Ok(tip_height) if tip_height >= height => { + let confirmations = tip_height.saturating_sub(height).saturating_add(1); + super::RawTxObservation::Confirmed { height: Some(height), confirmations } + }, + Ok(tip_height) => { + log_error!( + self.logger, + "swap_query_tx: Esplora tip {} below confirming-block height {} for {} (reorg/race); failing closed", + tip_height, + height, + txid + ); + super::RawTxObservation::Unreachable + }, + Err(e) => { + log_error!(self.logger, "swap_query_tx: Esplora tip query failed: {}", e); + super::RawTxObservation::Unreachable + }, + } + } + + pub(super) async fn validate_zero_fee_commitments_support(&self) -> Result<(), Error> { + // This could still accept an Esplora server running against Bitcoin Core v26 + // through v28, which does not relay ephemeral dust. + self.esplora_client.submit_package(&super::dummy_package(), None, None).await.map_err( + |e| { + if let esplora_client::Error::HttpResponse { status: 404, message } = e { + log_error!( + self.logger, + "Esplora server does not support submitpackage: {}", + message + ); + Error::ChainSourceNotSupported + } else { + log_error!( + self.logger, + "Failed to check support for submitpackage on the Esplora server: {}", + e + ); + Error::ConnectionFailed + } + }, + )?; + Ok(()) + } + pub(super) async fn sync_onchain_wallet( &self, onchain_wallet: Arc, ) -> Result<(), Error> { @@ -101,16 +207,18 @@ 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) => {{ let now = Instant::now(); match $sync_future.await { Ok(res) => match res { - Ok(update) => match onchain_wallet.apply_update(update) { + Ok(update) => match onchain_wallet.apply_update(update).await { Ok(()) => { log_debug!( self.logger, @@ -177,7 +285,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( @@ -188,18 +296,39 @@ impl EsploraChainSource { get_and_apply_wallet_update!(wallet_sync_timeout_fut) } else { let full_scan_request = onchain_wallet.get_full_scan_request(); + let full_scan_stop_gap = self.bounded_full_scan_stop_gap(); let wallet_sync_timeout_fut = tokio::time::timeout( Duration::from_secs( self.sync_config.timeouts_config.onchain_wallet_sync_timeout_secs, ), self.esplora_client.full_scan( full_scan_request, - BDK_CLIENT_STOP_GAP, + full_scan_stop_gap, BDK_CLIENT_CONCURRENCY, ), ); 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 + } + + fn bounded_full_scan_stop_gap(&self) -> usize { + let configured = self.sync_config.full_scan_stop_gap; + let bounded = clamp_full_scan_stop_gap(configured); + if bounded != configured { + log_warn!( + self.logger, + "Configured Esplora on-chain wallet full-scan stop gap {} is outside the allowed range {}..={}; using {}.", + configured, + MIN_FULL_SCAN_STOP_GAP, + MAX_FULL_SCAN_STOP_GAP, + bounded + ); + } + bounded as usize } pub(super) async fn sync_lightning_wallet( @@ -355,74 +484,111 @@ impl EsploraChainSource { Ok(()) } - pub(crate) async fn process_broadcast_package(&self, package: Vec) { - for tx in &package { - let txid = tx.compute_txid(); - let timeout_fut = tokio::time::timeout( - Duration::from_secs(self.sync_config.timeouts_config.tx_broadcast_timeout_secs), - self.esplora_client.broadcast(tx), - ); - match timeout_fut.await { - Ok(res) => match res { - Ok(()) => { - log_trace!(self.logger, "Successfully broadcast transaction {}", txid); - }, - Err(e) => match e { - esplora_client::Error::HttpResponse { status, message } => { - if status == 400 { - // Log 400 at lesser level, as this often just means bitcoind already knows the - // transaction. - // FIXME: We can further differentiate here based on the error - // message which will be available with rust-esplora-client 0.7 and - // later. + fn log_http_error(&self, e: esplora_client::Error, txids: &[Txid], txs: &SortedTransactions) { + match e { + esplora_client::Error::HttpResponse { status, message } => { + if status == 400 && txs.len() == 1 { + // Log 400 at lesser level, as this often just means bitcoind already knows the + // transaction. + // FIXME: We can further differentiate here based on the error + // message which will be available with rust-esplora-client 0.7 and + // later. + log_trace!( + self.logger, + "Failed to broadcast due to HTTP connection error: {}", + message + ); + log_trace!(self.logger, "Failed to broadcast transaction(s) {:?}", txids); + } else { + log_error!( + self.logger, + "Failed to broadcast due to HTTP connection error: {} - {}", + status, + message + ); + log_error!(self.logger, "Failed to broadcast transaction(s) {:?}", txids); + } + log_trace!(self.logger, "Failed broadcast transaction(s) bytes:"); + for tx in txs.iter() { + log_trace!(self.logger, "{}", log_bytes!(tx.encode())); + } + }, + _ => { + log_error!(self.logger, "Failed to broadcast transaction(s) {:?}: {}", txids, e); + log_trace!(self.logger, "Failed broadcast transaction(s) bytes:"); + for tx in txs.iter() { + log_trace!(self.logger, "{}", log_bytes!(tx.encode())); + } + }, + } + } + + fn log_broadcast_error( + &self, e: impl core::fmt::Display, txids: &[Txid], txs: &SortedTransactions, + ) { + log_error!(self.logger, "Failed to broadcast transaction(s) {:?}: {}", txids, e); + log_trace!(self.logger, "Failed broadcast transaction bytes:"); + for tx in txs.iter() { + log_trace!(self.logger, "{}", log_bytes!(tx.encode())); + } + } + + pub(crate) async fn process_transaction_broadcast(&self, txs: SortedTransactions) { + let all_txs_are_v3 = txs.iter().all(|tx| tx.version == Version::non_standard(3)); + match txs.len() { + 2.. if all_txs_are_v3 => { + let txids: Vec<_> = txs.iter().map(|tx| tx.compute_txid()).collect(); + let timeout_fut = tokio::time::timeout( + Duration::from_secs(self.sync_config.timeouts_config.tx_broadcast_timeout_secs), + self.esplora_client.submit_package(&txs, None, None), + ); + match timeout_fut.await { + Ok(res) => match res { + Ok(result) => { + if result.package_msg.eq_ignore_ascii_case("success") { log_trace!( self.logger, - "Failed to broadcast due to HTTP connection error: {}", - message + "Successfully broadcast transactions {:?}", + txids ); - } else { - log_error!( + log_trace!( self.logger, - "Failed to broadcast due to HTTP connection error: {} - {}", - status, - message + "Successfully broadcast transactions {:?}", + result ); + } else { + self.log_broadcast_error(format!("{:?}", result), &txids, &txs); } - log_trace!( - self.logger, - "Failed broadcast transaction bytes: {}", - log_bytes!(tx.encode()) - ); - }, - _ => { - log_error!( - self.logger, - "Failed to broadcast transaction {}: {}", - txid, - e - ); - log_trace!( - self.logger, - "Failed broadcast transaction bytes: {}", - log_bytes!(tx.encode()) - ); }, + Err(e) => self.log_http_error(e, &txids, &txs), }, - }, - Err(e) => { - log_error!( - self.logger, - "Failed to broadcast transaction due to timeout {}: {}", - txid, - e - ); - log_trace!( - self.logger, - "Failed broadcast transaction bytes: {}", - log_bytes!(tx.encode()) + Err(e) => self.log_broadcast_error(e, &txids, &txs), + } + }, + _ => { + for tx in txs.iter() { + let txid = tx.compute_txid(); + let timeout_fut = tokio::time::timeout( + Duration::from_secs( + self.sync_config.timeouts_config.tx_broadcast_timeout_secs, + ), + self.esplora_client.broadcast(tx), ); - }, - } + match timeout_fut.await { + Ok(res) => match res { + Ok(()) => { + log_trace!( + self.logger, + "Successfully broadcast transaction {}", + txid + ); + }, + Err(e) => self.log_http_error(e, &[txid], &txs), + }, + Err(e) => self.log_broadcast_error(e, &[txid], &txs), + } + } + }, } } } diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 92c4bdb641..cedbf16d45 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -6,17 +6,19 @@ // accordance with one or both of these licenses. pub(crate) mod bitcoind; +mod cbf; 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}; +use crate::chain::bitcoind::{BitcoindChainSource, ChainListener, UtxoSourceClient}; +use crate::chain::cbf::CbfChainSource; use crate::chain::electrum::ElectrumChainSource; use crate::chain::esplora::EsploraChainSource; use crate::config::{ @@ -24,11 +26,42 @@ 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}; +/// We use this parent-child TRUC package to make sure the configured chain source supports +/// broadcasting packages via the `submitpackage` Bitcoin Core RPC. +const PARENT_TXID: &str = "9a015f93fac6cb203c2b994e18b85176eb0354a22a468255516f3c6002d3f696"; +const PARENT_HEX: &str = + "0300000000010160d0cdb72f2ddf719f40ca32f44614c67577fc75996140544003915683c34a310000000000fd\ + ffffff0201000000000000000451024e73876100000000000022512042731375894dad3b25092cd0f713dc5bee4\ + a71e30a95e1db3d880906d7eba1fa01409327942924218e4eb1635a7cce6706fcb37b8bbb61a2f0b86357356681\ + 4e09419a3501e02252043bb237d479304632282fe9159db9e9a6ae6ec5bedea9f0f115a97b0e00"; +const CHILD_TXID: &str = "d011b3ff78cdfb8b93822639ea87771847936b04bb83afc8763a7c02a386ae26"; +const CHILD_HEX: &str = + "0300000000010296f6d302603c6f515582462aa25403eb7651b8184e992b3c20cbc6fa935f019a0000000000ff\ + ffffff96f6d302603c6f515582462aa25403eb7651b8184e992b3c20cbc6fa935f019a0100000000fdffffff015\ + 660000000000000225120ac18cd599a1be003595854e2eeec18dbe1c92d04b0ba05812d04445e3fcf16bc000140\ + 1462a35808d77a164f0a23a84c4721d1545befd09ad19945bb8aa0ea5576953a9699038725f944b1bc429942ef4\ + 7e6504a554babf022cb15db53be2d8c1dbfe5a97b0e00"; + +fn dummy_package() -> [bitcoin::Transaction; 2] { + use bitcoin::consensus::Decodable; + use bitcoin::hex::FromHex; + use bitcoin::Transaction; + let parent_tx_bytes = Vec::from_hex(PARENT_HEX).expect("read from a constant"); + let child_tx_bytes = Vec::from_hex(CHILD_HEX).expect("read from a constant"); + let parent = + Transaction::consensus_decode(&mut &parent_tx_bytes[..]).expect("read from a constant"); + let child = + Transaction::consensus_decode(&mut &child_tx_bytes[..]).expect("read from a constant"); + assert_eq!(parent.compute_txid().to_string(), PARENT_TXID); + assert_eq!(child.compute_txid().to_string(), CHILD_TXID); + [parent, child] +} + pub(crate) enum WalletSyncStatus { Completed, InProgress { subscribers: tokio::sync::broadcast::Sender> }, @@ -82,9 +115,129 @@ impl WalletSyncStatus { } } +/// Optional external fee estimation backend for the CBF chain source. +/// +/// By default CBF derives fee rates from recent blocks' coinbase outputs. +/// Setting an external source provides more accurate, per-target estimates +/// from a mempool-aware server. +#[derive(Debug, Clone)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum CbfFeeSourceConfig { + /// Use an Esplora HTTP server for fee rate estimation. + Esplora(String), + /// Use an Electrum server for fee rate estimation. + Electrum(String), +} + +/// A simplified, externally-consumable snapshot of the CBF chain source's sync +/// state. +/// +/// This deliberately does NOT expose the crate-internal, error-carrying sync +/// state type the CBF chain source tracks internally — only whether it is +/// still catching up, has caught up to the network tip, or has given up. +/// Returned by [`crate::Node::cbf_sync_status`]; `None` for every other chain +/// source. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum CbfSyncStatus { + /// CBF has not yet caught up to the network tip — either the initial + /// compact-filter sync, or catching up again after falling behind. + Syncing, + /// CBF has caught up to the network tip and applied all pending blocks. + Synced, + /// The CBF background restart loop gave up after repeated failures, or + /// the chain source has been cleanly stopped (e.g. during node + /// shutdown/reload). Not currently making sync progress either way. + Failed, +} + +/// A future resolving to per-conf-target (blocks) fee estimates in sat/vB, or `Err(())` if the +/// external chain service could not provide them this cycle. +pub type FeeEstimatesFuture = std::pin::Pin< + Box, ()>> + Send>, +>; +/// Why an app-supplied broadcast hook did not accept a transaction package. +/// +/// The two variants ask for opposite handling, which is the whole point of telling them apart: +/// an [`Unavailable`](Self::Unavailable) service is routed around (the node's own P2P broadcast +/// carries the package instead), while a [`Rejected`](Self::Rejected) package is a verdict from +/// the service's own bitcoind on OUR transactions — relaying it over P2P would only buy the +/// same verdict from every other node, so the CBF chain source stops there and forgets the +/// rejected transactions in the on-chain wallet, freeing the coins they tried to spend. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BroadcastHookError { + /// The external chain service could not be reached, could not be paid, or gave no usable + /// answer. Falls through to the node's own P2P broadcast, exactly like a timeout. + Unavailable, + /// The external chain service's bitcoind refused these transactions (a mempool-policy + /// verdict such as `insufficient fee, rejecting replacement`), each with the reason it gave. + /// No P2P fallback is attempted; the on-chain wallet evicts every listed transaction it + /// knows about so their inputs become spendable again. Transactions of the package that are + /// NOT listed here are treated as accepted. + Rejected(Vec<(Txid, String)>), +} + +impl std::fmt::Display for BroadcastHookError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unavailable => write!(f, "external chain service unavailable"), + Self::Rejected(rejected) => { + write!(f, "external chain service rejected {} transaction(s)", rejected.len()) + }, + } + } +} + +/// A future resolving to `Ok(())` if the external chain service accepted a raw-tx broadcast, or +/// to a [`BroadcastHookError`] saying whether to fall back to the node's own P2P broadcast +/// ([`BroadcastHookError::Unavailable`]) or to give the package up as refused +/// ([`BroadcastHookError::Rejected`]). +pub type BroadcastFuture = + std::pin::Pin> + Send>>; + +/// App-supplied hooks that let an external chain service short-circuit the CBF chain source's +/// native fee estimation and P2P transaction broadcast. +/// +/// Only meaningful when paired with the CBF chain source (see +/// [`crate::builder::NodeBuilder::set_cbf_chain_service_hooks`]); ignored for every other chain +/// source. This type is payment-agnostic — the fork never learns about L402, or any other +/// payment protocol a caller might use to obtain fee data or relay a broadcast. +#[derive(Clone, Default)] +pub struct ChainServiceHooks { + /// conf-target (blocks) → sat/vB. + /// + /// Applied **all-or-nothing**: the returned map MUST include a finite entry for every one + /// of the six distinct block-count targets the estimator needs — `1`, `3`, `6`, `12`, + /// `144`, and `1008` blocks — or the whole result is discarded and treated exactly like + /// `Err(())`. A map covering only some of these is never partially applied (the underlying + /// cache is a full replace, not a merge, so a partial map would silently pin the omitted + /// targets to the crate's static fallback rate rather than a live estimate). Accepted + /// sat/vB values are clamped to a sane range before use. The app-side endpoint backing this + /// hook must serve estimates for exactly these six block counts every time it is called. + /// + /// `Err(())`, an incomplete/empty map, a timeout, or leaving this unset all fall through to + /// `fee_source` / block-derived estimation. + pub fee_estimates: Option FeeEstimatesFuture + Send + Sync>>, + /// Attempt external broadcast of raw txs. Tried even if the underlying CBF/kyoto runtime is + /// not currently running. `Err(BroadcastHookError::Unavailable)`, a timeout, or leaving + /// this unset falls through to P2P broadcast; `Err(BroadcastHookError::Rejected(..))` does + /// NOT — see [`BroadcastHookError`]. + pub broadcast: + Option) -> BroadcastFuture + Send + Sync>>, +} + +impl std::fmt::Debug for ChainServiceHooks { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ChainServiceHooks") + .field("fee_estimates", &self.fee_estimates.is_some()) + .field("broadcast", &self.broadcast.is_some()) + .finish() + } +} + pub(crate) struct ChainSource { kind: ChainSourceKind, - registered_txids: Mutex>, + registered_txids: Mutex>, tx_broadcaster: Arc, logger: Arc, } @@ -93,9 +246,38 @@ enum ChainSourceKind { Esplora(EsploraChainSource), Electrum(ElectrumChainSource), Bitcoind(BitcoindChainSource), + Cbf(CbfChainSource), } impl ChainSource { + /// Returns the shared on-chain fee estimator backing this chain source + /// (Peerswap native primitive B6). Used by [`crate::Node`] to surface + /// source-bearing swap feerate quotes. + #[cfg(feature = "swaps")] + pub(crate) fn fee_estimator(&self) -> &Arc { + match &self.kind { + ChainSourceKind::Esplora(esplora_chain_source) => esplora_chain_source.fee_estimator(), + ChainSourceKind::Electrum(electrum_chain_source) => { + electrum_chain_source.fee_estimator() + }, + ChainSourceKind::Bitcoind(bitcoind_chain_source) => { + bitcoind_chain_source.fee_estimator() + }, + ChainSourceKind::Cbf(cbf_chain_source) => cbf_chain_source.fee_estimator(), + } + } + + /// Returns a snapshot of the CBF chain source's sync status + /// ([`CbfSyncStatus`]), or `None` if this chain source is not CBF. + pub(crate) fn cbf_sync_status(&self) -> Option { + match &self.kind { + ChainSourceKind::Cbf(cbf_chain_source) => Some(cbf_chain_source.sync_status()), + ChainSourceKind::Esplora(_) + | ChainSourceKind::Electrum(_) + | ChainSourceKind::Bitcoind(_) => None, + } + } + pub(crate) fn new_esplora( server_url: String, headers: HashMap, sync_config: EsploraSyncConfig, fee_estimator: Arc, tx_broadcaster: Arc, @@ -113,7 +295,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 +315,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 +338,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,15 +362,56 @@ 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) } - pub(crate) fn start(&self, runtime: Arc) -> Result<(), Error> { + pub(crate) fn new_cbf( + peers: Vec, fee_source_config: Option, + wallet_rescan_from_height: Option, runtime: Arc, + fee_estimator: Arc, tx_broadcaster: Arc, + kv_store: Arc, config: Arc, logger: Arc, + node_metrics: Arc, chain_service_hooks: ChainServiceHooks, + ) -> Result<(Self, Option), Error> { + let birthday_tip = + cbf::resolve_birthday(&logger, config.network, wallet_rescan_from_height); + let cbf_chain_source = CbfChainSource::new( + peers, + fee_source_config, + runtime, + Arc::clone(&fee_estimator), + Arc::clone(&kv_store), + Arc::clone(&config), + Arc::clone(&logger), + Arc::clone(&node_metrics), + chain_service_hooks, + )?; + let kind = ChainSourceKind::Cbf(cbf_chain_source); + let registered_txids = Mutex::new(HashSet::new()); + Ok((Self { kind, registered_txids, tx_broadcaster, logger }, birthday_tip)) + } + + pub(crate) fn start( + &self, runtime: Arc, onchain_wallet: Arc, + channel_manager: Arc, chain_monitor: Arc, + output_sweeper: Arc, + ) -> Result<(), Error> { match &self.kind { ChainSourceKind::Electrum(electrum_chain_source) => { electrum_chain_source.start(runtime)? }, + ChainSourceKind::Cbf(cbf_chain_source) => { + let chain_listener = ChainListener { + onchain_wallet, + channel_manager, + chain_monitor, + output_sweeper, + logger: Arc::clone(&self.logger), + divergence: Arc::new(Mutex::new(None)), + replay_batch: Arc::new(Mutex::new(std::collections::BTreeMap::new())), + }; + cbf_chain_source.start(chain_listener); + }, _ => { // Nothing to do for other chain sources. }, @@ -199,6 +422,7 @@ impl ChainSource { pub(crate) fn stop(&self) { match &self.kind { ChainSourceKind::Electrum(electrum_chain_source) => electrum_chain_source.stop(), + ChainSourceKind::Cbf(cbf_chain_source) => cbf_chain_source.stop(), _ => { // Nothing to do for other chain sources. }, @@ -214,7 +438,7 @@ impl ChainSource { } } - pub(crate) fn registered_txids(&self) -> Vec { + pub(crate) fn registered_txids(&self) -> HashSet { self.registered_txids.lock().expect("lock").clone() } @@ -223,6 +447,7 @@ impl ChainSource { ChainSourceKind::Esplora(_) => true, ChainSourceKind::Electrum { .. } => true, ChainSourceKind::Bitcoind { .. } => false, + ChainSourceKind::Cbf { .. } => false, } } @@ -249,9 +474,9 @@ impl ChainSource { } else { // Background syncing is disabled log_info!( - self.logger, - "Background syncing is disabled. Manual syncing required for onchain wallet, lightning wallet, and fee rate updates.", - ); + self.logger, + "Background syncing is disabled. Manual syncing required for onchain wallet, lightning wallet, and fee rate updates.", + ); return; } }, @@ -272,9 +497,9 @@ impl ChainSource { } else { // Background syncing is disabled log_info!( - self.logger, - "Background syncing is disabled. Manual syncing required for onchain wallet, lightning wallet, and fee rate updates.", - ); + self.logger, + "Background syncing is disabled. Manual syncing required for onchain wallet, lightning wallet, and fee rate updates.", + ); return; } }, @@ -289,6 +514,11 @@ impl ChainSource { ) .await }, + ChainSourceKind::Cbf(cbf_chain_source) => { + //CBF cannot run without background syncing, when the chain source is running, it + //syncs. Thus we don't have anything similar to other chain sources. + cbf_chain_source.continuously_update_fee_rate_estimates(stop_sync_receiver).await + }, } } @@ -331,7 +561,7 @@ impl ChainSource { log_trace!( logger, "Stopping background syncing on-chain wallet.", - ); + ); return; } _ = onchain_wallet_sync_interval.tick() => { @@ -345,7 +575,7 @@ impl ChainSource { Arc::clone(&channel_manager), Arc::clone(&chain_monitor), Arc::clone(&output_sweeper), - ).await; + ).await; } } } @@ -368,6 +598,9 @@ impl ChainSource { // `ChainPoller`. So nothing to do here. unreachable!("Onchain wallet will be synced via chain polling") }, + ChainSourceKind::Cbf { .. } => { + unreachable!("Onchain wallet synchronizes in background") + }, } } @@ -393,6 +626,9 @@ impl ChainSource { // `ChainPoller`. So nothing to do here. unreachable!("Lightning wallet will be synced via chain polling") }, + ChainSourceKind::Cbf { .. } => { + unreachable!("Lightning wallet synchronizes in background") + }, } } @@ -421,6 +657,7 @@ impl ChainSource { ) .await }, + ChainSourceKind::Cbf(cbf_chain_source) => cbf_chain_source.wait_until_synced().await, } } @@ -435,11 +672,42 @@ impl ChainSource { ChainSourceKind::Bitcoind(bitcoind_chain_source) => { bitcoind_chain_source.update_fee_rate_estimates().await }, + ChainSourceKind::Cbf(cbf_chain_source) => { + cbf_chain_source.update_fee_rate_estimates().await + }, + } + } + + pub(crate) async fn validate_zero_fee_commitments_support_if_required( + &self, zero_fee_commitments_support_required: bool, + ) -> Result<(), Error> { + if !zero_fee_commitments_support_required { + return Ok(()); + } + + match &self.kind { + ChainSourceKind::Esplora(esplora_chain_source) => { + esplora_chain_source.validate_zero_fee_commitments_support().await + }, + ChainSourceKind::Electrum(electrum_chain_source) => { + electrum_chain_source.validate_zero_fee_commitments_support().await + }, + ChainSourceKind::Bitcoind(bitcoind_chain_source) => { + bitcoind_chain_source.validate_zero_fee_commitments_support().await + }, + ChainSourceKind::Cbf(_) => { + log_error!( + self.logger, + "CBF chain sources cannot verify zero-fee commitment package relay support" + ); + Err(Error::ChainSourceNotSupported) + }, } } pub(crate) async fn continuously_process_broadcast_queue( &self, mut stop_tx_bcast_receiver: tokio::sync::watch::Receiver<()>, + onchain_wallet: Arc, ) { let mut receiver = self.tx_broadcaster.get_broadcast_queue().await; loop { @@ -453,26 +721,82 @@ 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 package = package.into_sorted_transactions(); match &self.kind { ChainSourceKind::Esplora(esplora_chain_source) => { - esplora_chain_source.process_broadcast_package(next_package).await + esplora_chain_source.process_transaction_broadcast(package).await }, ChainSourceKind::Electrum(electrum_chain_source) => { - electrum_chain_source.process_broadcast_package(next_package).await + electrum_chain_source.process_transaction_broadcast(package).await }, ChainSourceKind::Bitcoind(bitcoind_chain_source) => { - bitcoind_chain_source.process_broadcast_package(next_package).await + bitcoind_chain_source.process_transaction_broadcast(package).await + }, + ChainSourceKind::Cbf(cbf_chain_source) => { + cbf_chain_source + .process_broadcast_package(package.into_inner(), &onchain_wallet) + .await }, } } } } } + + /// Reorg-aware confirmation/eviction query for an ARBITRARY `txid` (Peerswap + /// native primitive B5). + /// + /// Unlike the wallet-owned confirmation lookups, this works on a + /// counterparty's swap opening tx that the local wallet does not own. It + /// returns a backend-agnostic [`RawTxObservation`]; the caller + /// ([`crate::Node::get_tx_confirmations`]) folds in the previously-observed + /// confirmation anchor to distinguish a first `Mempool`/`Dropped` sighting + /// from a `Reorged` un-confirmation. + /// + /// FAIL-CLOSED (E6): any chain source that cannot answer — an unstarted + /// backend client, a transport error, or a missing scriptPubKey for the + /// Electrum scriptHash lookup — yields [`RawTxObservation::Unreachable`], so + /// the public API never reports a falsely-confirmed result. + #[cfg(feature = "swaps")] + pub(crate) async fn swap_query_tx( + &self, txid: Txid, script_pubkey: Option<&ScriptBuf>, + ) -> RawTxObservation { + match &self.kind { + ChainSourceKind::Esplora(esplora_chain_source) => { + esplora_chain_source.swap_query_tx(txid).await + }, + ChainSourceKind::Electrum(electrum_chain_source) => { + electrum_chain_source.swap_query_tx(txid, script_pubkey).await + }, + ChainSourceKind::Bitcoind(bitcoind_chain_source) => { + bitcoind_chain_source.swap_query_tx(txid).await + }, + ChainSourceKind::Cbf(_) => { + // BIP157 cannot query arbitrary raw transactions; PeerSwap is + // unavailable under CBF (fail-closed, spec E6). + RawTxObservation::Unreachable + }, + } + } } 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) @@ -481,6 +805,9 @@ impl Filter for ChainSource { electrum_chain_source.register_tx(txid, script_pubkey) }, ChainSourceKind::Bitcoind { .. } => (), + ChainSourceKind::Cbf(cbf_chain_source) => { + cbf_chain_source.register_tx(txid, script_pubkey); + }, } } fn register_output(&self, output: lightning::chain::WatchedOutput) { @@ -492,6 +819,346 @@ impl Filter for ChainSource { electrum_chain_source.register_output(output) }, ChainSourceKind::Bitcoind { .. } => (), + ChainSourceKind::Cbf(cbf_chain_source) => { + cbf_chain_source.register_output(output); + }, + } + } +} + +#[cfg(feature = "swaps")] +use bitcoin::{Network, ScriptBuf}; + +// ============================================================================ +// Peerswap native primitive B5 — reorg-aware per-txid confirmation tracking. +// +// `register_tx`/`onchain_tx_confirmations` only cover wallet-owned txids; a +// swap taker must verify the *counterparty's* opening tx, which the wallet does +// not own. The types and helpers below add a brand-new, reorg-aware, per-txid +// chain query over whichever chain source the deployment configured +// (Esplora/Electrum/Bitcoind), and FAIL CLOSED (never a falsely-confirmed +// result) when that source cannot answer (E6). Everything here is gated behind +// the `swaps` cargo feature so the default build is byte-for-byte unaffected. +// ============================================================================ + +/// Reorg-aware chain status of a watched transaction (Peerswap native +/// primitive B5). +/// +/// [`ChainStatus::NoChainSource`] is the fail-closed sentinel returned when the +/// configured chain source cannot answer — it is never conflated with a +/// confirmed result (E6). [`ChainStatus::Reorged`] is reported when a tx that +/// was previously observed confirmed is no longer in the best chain, so the +/// caller can re-anchor CSV/claim deadlines (F4). +#[cfg(feature = "swaps")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ChainStatus { + /// Included in a block on the current best chain. + Confirmed, + /// Known to the chain source but still unconfirmed (in the mempool). + Mempool, + /// Previously observed confirmed, but no longer in the best chain (re-orged + /// back to the mempool or evicted). Deadlines must be re-anchored. + Reorged, + /// Unknown to the chain source and never observed confirmed (never broadcast + /// or evicted from the mempool before confirming). + Dropped, + /// The chain source is unconfigured/unreachable and could not answer. The + /// caller MUST treat this as "unverifiable", never as confirmed + /// (fail-closed, E6). + NoChainSource, +} + +#[cfg(feature = "swaps")] +impl ChainStatus { + /// Stable lowercase string form for capability payloads and logs. + pub fn as_str(&self) -> &'static str { + match self { + ChainStatus::Confirmed => "confirmed", + ChainStatus::Mempool => "mempool", + ChainStatus::Reorged => "reorged", + ChainStatus::Dropped => "dropped", + ChainStatus::NoChainSource => "no_chain_source", + } + } +} + +/// Reorg-aware confirmation/eviction status of a watched transaction +/// (Peerswap native primitive B5). +#[cfg(feature = "swaps")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TxStatus { + /// Confirmation depth on the current best chain (`0` when unconfirmed, + /// reorged, dropped, or unverifiable). + pub confirmations: u32, + /// Height of the confirming block, re-derived from the current best chain + /// (`None` when unconfirmed/reorged/dropped/unverifiable). + pub height: Option, + /// Reorg-aware chain status. + pub status: ChainStatus, +} + +/// Backend-agnostic raw observation of a `txid` against a chain source, before +/// the previously-observed confirmation anchor is folded in (Peerswap B5). +#[cfg(feature = "swaps")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RawTxObservation { + /// Included in a best-chain block at the given height/depth. + Confirmed { height: Option, confirmations: u32 }, + /// Known to the chain source but unconfirmed. + InMempool, + /// Unknown to the chain source. + NotFound, + /// The chain source could not answer (fail-closed sentinel, E6). + Unreachable, +} + +/// Fold a raw observation together with whether the tx was previously observed +/// confirmed into the reorg-aware [`TxStatus`] (Peerswap B5). +/// +/// Pure function — unit-tested independently of any live chain source. The +/// load-bearing invariant: a [`RawTxObservation::Unreachable`] can never become +/// [`ChainStatus::Confirmed`], regardless of prior confirmation history. +#[cfg(feature = "swaps")] +pub(crate) fn derive_tx_status( + observation: RawTxObservation, previously_confirmed: bool, +) -> TxStatus { + match observation { + RawTxObservation::Confirmed { height, confirmations } => TxStatus { + // A tx in the tip block is 1 confirmation deep, never 0. + confirmations: confirmations.max(1), + height, + status: ChainStatus::Confirmed, + }, + RawTxObservation::InMempool => TxStatus { + confirmations: 0, + height: None, + status: if previously_confirmed { ChainStatus::Reorged } else { ChainStatus::Mempool }, + }, + RawTxObservation::NotFound => TxStatus { + confirmations: 0, + height: None, + status: if previously_confirmed { ChainStatus::Reorged } else { ChainStatus::Dropped }, + }, + RawTxObservation::Unreachable => { + TxStatus { confirmations: 0, height: None, status: ChainStatus::NoChainSource } + }, + } +} + +/// In-memory registry of swap txids being watched (Peerswap B5). +/// +/// Stores the scriptPubKey (required by the Electrum scriptHash lookup) and the +/// last observed confirmation height, which lets +/// [`crate::Node::get_tx_confirmations`] distinguish a first unconfirmed +/// sighting from a reorg-induced un-confirmation. Durable reorg state +/// additionally lives in the native `swap.db` on the consumer side; this cache +/// is best-effort and is rebuilt by re-registering after a restart. +#[cfg(feature = "swaps")] +pub(crate) struct SwapTxWatch { + entries: Mutex>, +} + +#[cfg(feature = "swaps")] +#[derive(Clone)] +struct SwapWatchEntry { + script_pubkey: ScriptBuf, + last_confirmed_height: Option, +} + +#[cfg(feature = "swaps")] +impl SwapTxWatch { + pub(crate) fn new() -> Self { + Self { entries: Mutex::new(HashMap::new()) } + } + + /// Register (idempotently) a txid + its scriptPubKey for watching. A repeat + /// registration refreshes the scriptPubKey but preserves the prior + /// confirmation anchor, so reorg detection survives a re-arm. + pub(crate) fn register(&self, txid: Txid, script_pubkey: ScriptBuf) { + let mut entries = self.entries.lock().unwrap(); + entries + .entry(txid) + .and_modify(|entry| entry.script_pubkey = script_pubkey.clone()) + .or_insert(SwapWatchEntry { script_pubkey, last_confirmed_height: None }); + } + + /// Drop the watch entry for `txid` (B5 LOW-1). Without this the map grows + /// unbounded for the process lifetime — every distinct watched txid (each + /// swap's opening + spend txs) accumulates forever. The consumer calls this + /// once a swap reaches a terminal, settled state and no longer needs reorg + /// tracking. A no-op if `txid` was never registered. + pub(crate) fn unregister(&self, txid: &Txid) { + self.entries.lock().unwrap().remove(txid); + } + + /// Number of currently-watched txids (test/observability only). + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.entries.lock().unwrap().len() + } + + /// The watched scriptPubKey for `txid`, if registered. + pub(crate) fn script_pubkey(&self, txid: &Txid) -> Option { + self.entries.lock().unwrap().get(txid).map(|entry| entry.script_pubkey.clone()) + } + + /// Whether `txid` was ever observed confirmed (arms reorg detection). + pub(crate) fn previously_confirmed(&self, txid: &Txid) -> bool { + self.entries + .lock() + .unwrap() + .get(txid) + .map_or(false, |entry| entry.last_confirmed_height.is_some()) + } + + /// Persist the latest confirmation anchor after a query so subsequent + /// queries can detect a reorg/un-confirmation. Only a fresh confirmation + /// advances the anchor; a non-confirmed status never disarms it. + pub(crate) fn record(&self, txid: &Txid, status: &TxStatus) { + if status.status == ChainStatus::Confirmed { + if let Some(entry) = self.entries.lock().unwrap().get_mut(txid) { + entry.last_confirmed_height = status.height; + } + } + } +} + +#[cfg(all(test, feature = "swaps"))] +mod swap_b5_tests { + use super::{derive_tx_status, ChainStatus, RawTxObservation, SwapTxWatch}; + use bitcoin::hashes::Hash; + use bitcoin::{ScriptBuf, Txid}; + + fn dummy_txid(byte: u8) -> Txid { + Txid::from_byte_array([byte; 32]) + } + + #[test] + fn confirmed_reports_depth_and_height() { + let status = derive_tx_status( + RawTxObservation::Confirmed { height: Some(100), confirmations: 6 }, + false, + ); + assert_eq!(status.status, ChainStatus::Confirmed); + assert_eq!(status.confirmations, 6); + assert_eq!(status.height, Some(100)); + } + + #[test] + fn confirmed_depth_is_floored_to_one() { + // A tx in the tip block is 1 confirmation deep, never 0. + let status = derive_tx_status( + RawTxObservation::Confirmed { height: Some(100), confirmations: 0 }, + false, + ); + assert_eq!(status.status, ChainStatus::Confirmed); + assert_eq!(status.confirmations, 1); + } + + #[test] + fn first_sighting_distinguishes_mempool_from_dropped() { + let mempool = derive_tx_status(RawTxObservation::InMempool, false); + assert_eq!(mempool.status, ChainStatus::Mempool); + assert_eq!(mempool.confirmations, 0); + assert_eq!(mempool.height, None); + + let dropped = derive_tx_status(RawTxObservation::NotFound, false); + assert_eq!(dropped.status, ChainStatus::Dropped); + assert_eq!(dropped.confirmations, 0); + assert_eq!(dropped.height, None); + } + + #[test] + fn unconfirmation_after_confirm_is_reorg() { + // Previously confirmed, now back to the mempool or gone => Reorged, + // not Mempool/Dropped, so the caller re-anchors deadlines (F4). + let back_to_mempool = derive_tx_status(RawTxObservation::InMempool, true); + assert_eq!(back_to_mempool.status, ChainStatus::Reorged); + assert_eq!(back_to_mempool.confirmations, 0); + + let evicted = derive_tx_status(RawTxObservation::NotFound, true); + assert_eq!(evicted.status, ChainStatus::Reorged); + assert_eq!(evicted.confirmations, 0); + } + + #[test] + fn unreachable_fails_closed_and_is_never_confirmed() { + // The load-bearing E6 invariant: an unanswerable chain source is never + // reported as confirmed, regardless of prior confirmation history. + for previously_confirmed in [false, true] { + let status = derive_tx_status(RawTxObservation::Unreachable, previously_confirmed); + assert_eq!(status.status, ChainStatus::NoChainSource); + assert_ne!(status.status, ChainStatus::Confirmed); + assert_eq!(status.confirmations, 0); + assert_eq!(status.height, None); } } + + #[test] + fn unreachable_status_string_is_stable() { + assert_eq!(ChainStatus::NoChainSource.as_str(), "no_chain_source"); + assert_eq!(ChainStatus::Confirmed.as_str(), "confirmed"); + assert_eq!(ChainStatus::Reorged.as_str(), "reorged"); + assert_eq!(ChainStatus::Dropped.as_str(), "dropped"); + assert_eq!(ChainStatus::Mempool.as_str(), "mempool"); + } + + #[test] + fn watch_registry_tracks_spk_and_reorg_anchor() { + let watch = SwapTxWatch::new(); + let txid = dummy_txid(7); + let spk = ScriptBuf::from_bytes(vec![0x00, 0x14, 0x11, 0x22, 0x33]); + + // Unregistered: no scriptPubKey, not previously confirmed. + assert!(watch.script_pubkey(&txid).is_none()); + assert!(!watch.previously_confirmed(&txid)); + + watch.register(txid, spk.clone()); + assert_eq!(watch.script_pubkey(&txid), Some(spk)); + assert!(!watch.previously_confirmed(&txid)); + + // Recording a confirmation arms the reorg anchor. + let confirmed = derive_tx_status( + RawTxObservation::Confirmed { height: Some(200), confirmations: 3 }, + false, + ); + watch.record(&txid, &confirmed); + assert!(watch.previously_confirmed(&txid)); + + // A later mempool sighting for the now-armed txid derives Reorged. + let reorged = + derive_tx_status(RawTxObservation::InMempool, watch.previously_confirmed(&txid)); + assert_eq!(reorged.status, ChainStatus::Reorged); + + // Recording a non-confirmed status does not disarm the anchor. + watch.record(&txid, &reorged); + assert!(watch.previously_confirmed(&txid)); + } + + #[test] + fn unregister_drops_the_watch_entry_and_is_idempotent() { + // B5 LOW-1: the watch map must not grow unbounded — a terminalized swap's + // entry is dropped, and unregistering an unknown txid is a harmless no-op. + let watch = SwapTxWatch::new(); + let a = dummy_txid(1); + let b = dummy_txid(2); + let spk = ScriptBuf::from_bytes(vec![0x00, 0x14, 0xaa, 0xbb]); + + watch.register(a, spk.clone()); + watch.register(b, spk.clone()); + assert_eq!(watch.len(), 2); + + watch.unregister(&a); + assert_eq!(watch.len(), 1, "the terminalized txid's entry is dropped"); + assert!(watch.script_pubkey(&a).is_none()); + assert!(watch.script_pubkey(&b).is_some(), "unrelated entry untouched"); + + // Idempotent: dropping the same (or an unknown) txid again is a no-op. + watch.unregister(&a); + watch.unregister(&dummy_txid(99)); + assert_eq!(watch.len(), 1); + + watch.unregister(&b); + assert_eq!(watch.len(), 0); + } } diff --git a/src/config.rs b/src/config.rs index 558a4d0618..d4825636f7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -26,8 +26,14 @@ use crate::logger::LogLevel; const DEFAULT_NETWORK: Network = Network::Bitcoin; const DEFAULT_BDK_WALLET_SYNC_INTERVAL_SECS: u64 = 80; const DEFAULT_LDK_WALLET_SYNC_INTERVAL_SECS: u64 = 30; -const DEFAULT_FEE_RATE_CACHE_UPDATE_INTERVAL_SECS: u64 = 60 * 10; +pub(crate) const DEFAULT_FEE_RATE_CACHE_UPDATE_INTERVAL_SECS: u64 = 60 * 10; const DEFAULT_PROBING_LIQUIDITY_LIMIT_MULTIPLIER: u64 = 3; +pub(crate) const DEFAULT_PROBING_INTERVAL_SECS: u64 = 10; +pub(crate) const MIN_PROBING_INTERVAL: Duration = Duration::from_millis(100); +pub(crate) const DEFAULT_PROBED_NODE_COOLDOWN_SECS: u64 = 60 * 60; // 1 hour +pub(crate) const DEFAULT_MAX_PROBE_LOCKED_MSAT: u64 = 100_000_000; // 100k sats +pub(crate) const DEFAULT_MIN_PROBE_AMOUNT_MSAT: u64 = 1_000_000; // 1k sats +pub(crate) const DEFAULT_MAX_PROBE_AMOUNT_MSAT: u64 = 10_000_000; // 10k sats const DEFAULT_ANCHOR_PER_CHANNEL_RESERVE_SATS: u64 = 25_000; // The default timeout after which we abort a wallet syncing operation. @@ -54,12 +60,24 @@ pub const DEFAULT_LOG_FILENAME: &'static str = "ldk_node.log"; /// The default storage directory. pub const DEFAULT_STORAGE_DIR_PATH: &str = "/tmp/ldk_node"; -// The default Esplora server we're using. +// The default Esplora server we're using. It supports `submitpackage`, check using POST on the +// `/txs/package` endpoint. pub(crate) const DEFAULT_ESPLORA_SERVER_URL: &str = "https://blockstream.info/api"; -// The 'stop gap' parameter used by BDK's wallet sync. This seems to configure the threshold -// number of derivation indexes after which BDK stops looking for new scripts belonging to the wallet. -pub(crate) const BDK_CLIENT_STOP_GAP: usize = 20; +/// The default stop gap used for BDK full scans of the on-chain wallet. +/// +/// The current default is 20. +pub const DEFAULT_FULL_SCAN_STOP_GAP: u32 = 20; + +/// The minimum allowed stop gap used for BDK full scans of the on-chain wallet. +/// +/// Values below 1 are clamped to 1 when a full scan runs. +pub const MIN_FULL_SCAN_STOP_GAP: u32 = 1; + +/// The maximum allowed stop gap used for BDK full scans of the on-chain wallet. +/// +/// Values above 1000 are clamped to 1000 when a full scan runs. +pub const MAX_FULL_SCAN_STOP_GAP: u32 = 1000; // The number of concurrent requests made against the API provider. pub(crate) const BDK_CLIENT_CONCURRENCY: usize = 4; @@ -126,7 +144,7 @@ pub(crate) const LNURL_AUTH_TIMEOUT_SECS: u64 = 15; /// | `node_alias` | None | /// | `trusted_peers_0conf` | [] | /// | `probing_liquidity_limit_multiplier` | 3 | -/// | `anchor_channels_config` | Some(..) | +/// | `anchor_channels_config` | AnchorChannelsConfig::default() | /// | `route_parameters` | None | /// | `tor_config` | None | /// | `hrn_config` | HumanReadableNamesConfig::default() | @@ -170,22 +188,11 @@ pub struct Config { /// used to send pre-flight probes. pub probing_liquidity_limit_multiplier: u64, /// Configuration options pertaining to Anchor channels, i.e., channels for which the - /// `option_anchors_zero_fee_htlc_tx` channel type is negotiated. + /// `option_zero_fee_commitments` or `option_anchors_zero_fee_htlc_tx` channel type is + /// negotiated. /// /// Please refer to [`AnchorChannelsConfig`] for further information on Anchor channels. - /// - /// If set to `Some`, we'll try to open new channels with Anchors enabled, i.e., new channels - /// will be negotiated with the `option_anchors_zero_fee_htlc_tx` channel type if supported by - /// the counterparty. Note that this won't prevent us from opening non-Anchor channels if the - /// counterparty doesn't support `option_anchors_zero_fee_htlc_tx`. If set to `None`, new - /// channels will be negotiated with the legacy `option_static_remotekey` channel type only. - /// - /// **Note:** If set to `None` *after* some Anchor channels have already been - /// opened, no dedicated emergency on-chain reserve will be maintained for these channels, - /// which can be dangerous if only insufficient funds are available at the time of channel - /// closure. We *will* however still try to get the Anchor spending transactions confirmed - /// on-chain with the funds available. - pub anchor_channels_config: Option, + pub anchor_channels_config: AnchorChannelsConfig, /// Configuration options for payment routing and pathfinding. /// /// Setting the [`RouteParametersConfig`] provides flexibility to customize how payments are routed, @@ -216,7 +223,7 @@ impl Default for Config { announcement_addresses: None, trusted_peers_0conf: Vec::new(), probing_liquidity_limit_multiplier: DEFAULT_PROBING_LIQUIDITY_LIMIT_MULTIPLIER, - anchor_channels_config: Some(AnchorChannelsConfig::default()), + anchor_channels_config: AnchorChannelsConfig::default(), tor_config: None, route_parameters: None, node_alias: None, @@ -281,7 +288,7 @@ impl Default for HumanReadableNamesConfig { } /// Configuration options pertaining to 'Anchor' channels, i.e., channels for which the -/// `option_anchors_zero_fee_htlc_tx` channel type is negotiated. +/// `option_zero_fee_commitments` or `option_anchors_zero_fee_htlc_tx` channel type is negotiated. /// /// Prior to the introduction of Anchor channels, the on-chain fees paying for the transactions /// issued on channel closure were pre-determined and locked-in at the time of the channel @@ -300,10 +307,11 @@ impl Default for HumanReadableNamesConfig { /// /// ### Defaults /// -/// | Parameter | Value | -/// |----------------------------|--------| -/// | `trusted_peers_no_reserve` | [] | -/// | `per_channel_reserve_sats` | 25000 | +/// | Parameter | Value | +/// |-------------------------------|--------| +/// | `trusted_peers_no_reserve` | [] | +/// | `per_channel_reserve_sats` | 25000 | +/// | `enable_zero_fee_commitments` | false | /// /// /// [BOLT 3]: https://github.com/lightning/bolts/blob/master/03-transactions.md#htlc-timeout-and-htlc-success-transactions @@ -339,6 +347,21 @@ pub struct AnchorChannelsConfig { /// might not suffice to successfully spend the Anchor output and have the HTLC transactions /// confirmed on-chain, i.e., you may want to adjust this value accordingly. pub per_channel_reserve_sats: u64, + /// If set, we will first attempt to negotiate `option_zero_fee_commitments` before falling + /// back to `option_anchors_zero_fee_htlc_tx` and `option_static_remotekey`, as supported by + /// the peer. Zero-fee commitment channels remove all commitment feerate negotiation from + /// the channel, which eliminates a very common source of channel force-closures. These + /// channels instead source *all* the fees required to confirm the commitment from the + /// anchor reserve of the channel closer at the time of force-close. If set, your chain + /// source *must* support the `submitpackage` Bitcoin Core RPC, and relay [TRUC], [P2A], + /// and [Ephemeral Dust]. + /// See [BOLT 3] for more technical details. + /// + /// [TRUC]: https://github.com/bitcoin/bips/blob/master/bip-0431.mediawiki + /// [P2A]: https://github.com/bitcoin/bips/blob/master/bip-0433.mediawiki + /// [Ephemeral Dust]: https://bitcoincore.org/en/releases/29.0 + /// [BOLT 3]: https://github.com/lightning/bolts/blob/master/03-transactions.md#shared_anchor-output-zero_fee_commitments + pub enable_zero_fee_commitments: bool, } impl Default for AnchorChannelsConfig { @@ -346,6 +369,7 @@ impl Default for AnchorChannelsConfig { Self { trusted_peers_no_reserve: Vec::new(), per_channel_reserve_sats: DEFAULT_ANCHOR_PER_CHANNEL_RESERVE_SATS, + enable_zero_fee_commitments: false, } } } @@ -401,12 +425,37 @@ pub(crate) fn default_user_config(config: &Config) -> UserConfig { // will mostly be relevant for inbound channels. let mut user_config = UserConfig::default(); user_config.channel_handshake_limits.force_announced_channel_preference = false; - user_config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = - config.anchor_channels_config.is_some(); + user_config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = + config.anchor_channels_config.enable_zero_fee_commitments; user_config.reject_inbound_splices = false; + // Allow full-capacity HTLCs. LDK's max-inbound-HTLC-in-flight percentages default well below + // 100%, which fails any forward larger than that fraction of the channel with + // `temporary_channel_failure`. Onboarding sweep needs to push ~70% of a single-channel + // capacity through its LSP in one HTLC, so we raise the cap to 100% for every channel this + // node negotiates (both as opener and as accepting peer). + user_config + .channel_handshake_config + .announced_channel_max_inbound_htlc_value_in_flight_percentage = 100; + user_config + .channel_handshake_config + .unannounced_channel_max_inbound_htlc_value_in_flight_percentage = 100; + // Permit forwarding HTLCs over private channels regardless of whether + // this node has a publicly-announceable identity. Onboarding's HODL-peer + // flow relies on a private last hop (Alice→Bob inbound channel kept + // private so the buyer's invoice carries a route hint). Without this, + // ChannelManager's `can_forward_htlc_to_outgoing_channel` short-circuits + // with `unknown_next_peer (0x400a)` whenever an HTLC is targeted at a + // private channel — this is by design in upstream LDK to hide + // private-channel existence from forwarders, but it breaks any + // leaf-LSP topology that relies on private channels as forwardable + // hops. The two settings (forwarding-over-private-channels vs + // announcing-our-own-channels) are conceptually independent, so we + // leave this enabled even when `may_announce_channel` reports the + // node is missing alias/addresses; only the gossip-related toggles + // are gated on announceability. + user_config.accept_forwards_to_priv_channels = true; if may_announce_channel(config).is_err() { - user_config.accept_forwards_to_priv_channels = false; user_config.channel_handshake_config.announce_for_forwarding = false; user_config.channel_handshake_limits.force_announced_channel_preference = true; } @@ -506,6 +555,28 @@ pub struct EsploraSyncConfig { pub background_sync_config: Option, /// Sync timeouts configuration. pub timeouts_config: SyncTimeoutsConfig, + /// The stop gap used for BDK full scans of the on-chain wallet. + /// + /// A full scan for each keychain stops after this many consecutive script pubkeys + /// with no associated transactions. This value is only used for BDK `full_scan` + /// calls, which ldk-node performs on the first on-chain wallet sync or when + /// [`Self::force_wallet_full_scan`] is set. Incremental BDK `sync` calls do not use it. + /// + /// **Default:** 20 ([`DEFAULT_FULL_SCAN_STOP_GAP`]) + /// + /// **Allowed values:** 1 ([`MIN_FULL_SCAN_STOP_GAP`]) to 1000 + /// ([`MAX_FULL_SCAN_STOP_GAP`]), inclusive. Values outside this range will be clamped to the + /// nearest bound and a warning will be logged when the full scan runs. + /// + /// **Note:** Large values can cause many Esplora requests, hit server rate limits, + /// take a long time to complete, or cause syncs to fail with + /// [`SyncTimeoutsConfig::onchain_wallet_sync_timeout_secs`]. + pub full_scan_stop_gap: u32, + /// 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 +584,8 @@ impl Default for EsploraSyncConfig { Self { background_sync_config: Some(BackgroundSyncConfig::default()), timeouts_config: SyncTimeoutsConfig::default(), + full_scan_stop_gap: DEFAULT_FULL_SCAN_STOP_GAP, + force_wallet_full_scan: false, } } } @@ -533,6 +606,28 @@ pub struct ElectrumSyncConfig { pub background_sync_config: Option, /// Sync timeouts configuration. pub timeouts_config: SyncTimeoutsConfig, + /// The stop gap used for BDK full scans of the on-chain wallet. + /// + /// A full scan for each keychain stops after this many consecutive script pubkeys + /// with no associated transactions. This value is only used for BDK `full_scan` + /// calls, which ldk-node performs on the first on-chain wallet sync or when + /// [`Self::force_wallet_full_scan`] is set. Incremental BDK `sync` calls do not use it. + /// + /// **Default:** 20 ([`DEFAULT_FULL_SCAN_STOP_GAP`]) + /// + /// **Allowed values:** 1 ([`MIN_FULL_SCAN_STOP_GAP`]) to 1000 + /// ([`MAX_FULL_SCAN_STOP_GAP`]), inclusive. Values outside this range will be clamped to the + /// nearest bound and a warning will be logged when the full scan runs. + /// + /// **Note:** Large values can cause many Electrum requests, hit server rate limits, + /// take a long time to complete, or cause syncs to fail with + /// [`SyncTimeoutsConfig::onchain_wallet_sync_timeout_secs`]. + pub full_scan_stop_gap: u32, + /// 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,10 +635,16 @@ impl Default for ElectrumSyncConfig { Self { background_sync_config: Some(BackgroundSyncConfig::default()), timeouts_config: SyncTimeoutsConfig::default(), + full_scan_stop_gap: DEFAULT_FULL_SCAN_STOP_GAP, + force_wallet_full_scan: false, } } } +pub(crate) fn clamp_full_scan_stop_gap(full_scan_stop_gap: u32) -> u32 { + full_scan_stop_gap.clamp(MIN_FULL_SCAN_STOP_GAP, MAX_FULL_SCAN_STOP_GAP) +} + /// Configuration for syncing with Bitcoin Core backend via REST. #[derive(Debug, Clone)] pub struct BitcoindRestClientConfig { @@ -699,7 +800,11 @@ pub enum AsyncPaymentsRole { mod tests { use std::str::FromStr; - use super::{may_announce_channel, AnnounceError, Config, NodeAlias, SocketAddress}; + use super::{ + clamp_full_scan_stop_gap, may_announce_channel, AnnounceError, Config, ElectrumSyncConfig, + EsploraSyncConfig, NodeAlias, SocketAddress, DEFAULT_FULL_SCAN_STOP_GAP, + MAX_FULL_SCAN_STOP_GAP, MIN_FULL_SCAN_STOP_GAP, + }; #[test] fn node_announce_channel() { @@ -746,4 +851,22 @@ mod tests { } assert!(may_announce_channel(&node_config).is_ok()); } + + #[test] + fn full_scan_stop_gap_defaults() { + assert_eq!(EsploraSyncConfig::default().full_scan_stop_gap, DEFAULT_FULL_SCAN_STOP_GAP); + assert_eq!(ElectrumSyncConfig::default().full_scan_stop_gap, DEFAULT_FULL_SCAN_STOP_GAP); + } + + #[test] + fn full_scan_stop_gap_is_clamped_to_valid_range() { + assert_eq!(clamp_full_scan_stop_gap(MIN_FULL_SCAN_STOP_GAP), MIN_FULL_SCAN_STOP_GAP); + assert_eq!( + clamp_full_scan_stop_gap(DEFAULT_FULL_SCAN_STOP_GAP), + DEFAULT_FULL_SCAN_STOP_GAP + ); + assert_eq!(clamp_full_scan_stop_gap(MAX_FULL_SCAN_STOP_GAP), MAX_FULL_SCAN_STOP_GAP); + assert_eq!(clamp_full_scan_stop_gap(0), MIN_FULL_SCAN_STOP_GAP); + assert_eq!(clamp_full_scan_stop_gap(MAX_FULL_SCAN_STOP_GAP + 1), MAX_FULL_SCAN_STOP_GAP); + } } diff --git a/src/connection.rs b/src/connection.rs index b8946ffe3a..88135e841e 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -53,6 +53,10 @@ where self.do_connect_peer(node_id, addr).await } + pub(crate) fn disconnect_peer(&self, node_id: PublicKey) { + self.peer_manager.disconnect_by_node_id(node_id); + } + pub(crate) async fn do_connect_peer( &self, node_id: PublicKey, addr: SocketAddress, ) -> Result<(), Error> { diff --git a/src/custom_gossip.rs b/src/custom_gossip.rs new file mode 100644 index 0000000000..a59f01943a --- /dev/null +++ b/src/custom_gossip.rs @@ -0,0 +1,327 @@ +// 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. + +//! Custom gossip message handling for extending P2P gossip sync with custom metadata. + +use crate::logger::{log_debug, log_trace}; +use lightning::util::logger::Logger as LightningLogger; + +use lightning::io::{self, Read}; +use lightning::ln::msgs::LightningError; +use lightning::ln::peer_handler::CustomMessageHandler; +use lightning::ln::wire::CustomMessageReader; +use lightning::ln::wire::Type; +use lightning::util::ser::{LengthLimitedRead, Readable, Writeable, Writer}; +use lightning_types::features::{InitFeatures, NodeFeatures}; + +use bitcoin::secp256k1::PublicKey; + +use std::collections::HashMap; +use std::ops::Deref; +use std::sync::{Arc, Mutex}; + +/// Custom message type for gossip metadata extensions +pub const CUSTOM_GOSSIP_MESSAGE_TYPE: u16 = 32769; // Odd number in custom range + +/// Custom gossip message containing metadata extensions +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomGossipMessage { + /// The metadata payload + pub metadata: Vec, +} + +impl CustomGossipMessage { + /// Create a new custom gossip message with the given metadata + pub fn new(metadata: Vec) -> Self { + Self { metadata } + } + + /// Get the metadata payload + pub fn metadata(&self) -> &[u8] { + &self.metadata + } +} + +impl Type for CustomGossipMessage { + fn type_id(&self) -> u16 { + CUSTOM_GOSSIP_MESSAGE_TYPE + } +} + +impl Writeable for CustomGossipMessage { + fn write(&self, writer: &mut W) -> Result<(), io::Error> { + // Write length prefix (u16) followed by the metadata + (self.metadata.len() as u16).write(writer)?; + writer.write_all(&self.metadata) + } +} + +impl Readable for CustomGossipMessage { + fn read(reader: &mut R) -> Result { + let length = ::read(reader)? as usize; + + // Limit metadata size to prevent DoS attacks + if length > 4096 { + return Err(lightning::ln::msgs::DecodeError::InvalidValue); + } + + let mut metadata = vec![0u8; length]; + reader + .read_exact(&mut metadata) + .map_err(|_| lightning::ln::msgs::DecodeError::ShortRead)?; + + Ok(Self { metadata }) + } +} + +/// Metadata entry for a node +#[derive(Clone, Debug)] +pub struct NodeMetadata { + /// Node's public key + pub node_id: PublicKey, + /// Custom metadata payload + pub metadata: Vec, + /// Timestamp when metadata was received + pub timestamp: u32, +} + +/// Handler for custom gossip messages +pub struct CustomGossipMessageHandler +where + L::Target: LightningLogger, +{ + /// Logger instance + logger: L, + /// Store for node metadata + node_metadata: Arc>>, + /// Pending messages to send + pending_messages: Arc>>, + /// Our own metadata to advertise + our_metadata: Arc>>>, +} + +impl CustomGossipMessageHandler +where + L::Target: LightningLogger, +{ + /// Create a new custom gossip message handler + pub fn new(logger: L) -> Self { + Self { + logger, + node_metadata: Arc::new(Mutex::new(HashMap::new())), + pending_messages: Arc::new(Mutex::new(Vec::new())), + our_metadata: Arc::new(Mutex::new(None)), + } + } + + /// Set our own metadata to advertise to peers + pub fn set_our_metadata(&self, metadata: Vec) { + let mut our_metadata = self.our_metadata.lock().unwrap(); + *our_metadata = Some(metadata); + } + + /// Get OUR OWN advertised metadata blob (the one broadcast to peers), if set. + /// Distinct from [`get_all_metadata`], which returns PEERS' received blobs and + /// NEVER our own — so this is the only way for the owning node to read back what + /// it is currently advertising (needed for a correct read-merge-write of our blob). + pub fn get_our_metadata(&self) -> Option> { + self.our_metadata.lock().unwrap().clone() + } + + /// Get metadata for a specific node + pub fn get_node_metadata(&self, node_id: &PublicKey) -> Option { + let metadata_store = self.node_metadata.lock().unwrap(); + metadata_store.get(node_id).cloned() + } + + /// Get all stored node metadata + pub fn get_all_metadata(&self) -> HashMap { + let metadata_store = self.node_metadata.lock().unwrap(); + metadata_store.clone() + } + + /// Send custom metadata to a specific peer + pub fn send_metadata_to_peer(&self, peer_node_id: PublicKey, metadata: Vec) { + let message = CustomGossipMessage::new(metadata); + let mut pending = self.pending_messages.lock().unwrap(); + pending.push((peer_node_id, message)); + } + + /// Broadcast our metadata to all peers + pub fn broadcast_our_metadata(&self, peer_node_ids: Vec) { + let our_metadata = self.our_metadata.lock().unwrap(); + if let Some(ref metadata) = *our_metadata { + let message = CustomGossipMessage::new(metadata.clone()); + let mut pending = self.pending_messages.lock().unwrap(); + + for node_id in peer_node_ids { + pending.push((node_id, message.clone())); + } + } + } + + /// Handle received custom gossip message + fn handle_gossip_message(&self, msg: &CustomGossipMessage, sender_node_id: PublicKey) { + log_debug!( + self.logger, + "Received custom gossip metadata from {}: {} bytes", + sender_node_id, + msg.metadata.len() + ); + + let metadata_entry = NodeMetadata { + node_id: sender_node_id, + metadata: msg.metadata.clone(), + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as u32, + }; + + let mut metadata_store = self.node_metadata.lock().unwrap(); + metadata_store.insert(sender_node_id, metadata_entry); + + log_trace!( + self.logger, + "Stored metadata for node {}, total nodes: {}", + sender_node_id, + metadata_store.len() + ); + } +} + +impl CustomMessageReader for CustomGossipMessageHandler +where + L::Target: LightningLogger, +{ + type CustomMessage = CustomGossipMessage; + + fn read( + &self, message_type: u16, buffer: &mut RD, + ) -> Result, lightning::ln::msgs::DecodeError> { + if message_type == CUSTOM_GOSSIP_MESSAGE_TYPE { + log_trace!(self.logger, "Reading custom gossip message type {}", message_type); + Ok(Some(CustomGossipMessage::read(buffer)?)) + } else { + Ok(None) + } + } +} + +impl CustomMessageHandler for CustomGossipMessageHandler +where + L::Target: LightningLogger, +{ + fn handle_custom_message( + &self, msg: Self::CustomMessage, sender_node_id: PublicKey, + ) -> Result<(), LightningError> { + self.handle_gossip_message(&msg, sender_node_id); + Ok(()) + } + + fn get_and_clear_pending_msg(&self) -> Vec<(PublicKey, Self::CustomMessage)> { + let mut pending = self.pending_messages.lock().unwrap(); + std::mem::take(&mut *pending) + } + + fn provided_node_features(&self) -> NodeFeatures { + // Advertise that we support custom gossip messages + // You can extend this to include specific feature flags + NodeFeatures::empty() + } + + fn provided_init_features(&self, _their_node_id: PublicKey) -> InitFeatures { + // Advertise init features for custom gossip support + InitFeatures::empty() + } + + fn peer_connected( + &self, their_node_id: PublicKey, _msg: &lightning::ln::msgs::Init, _inbound: bool, + ) -> Result<(), ()> { + log_debug!(self.logger, "Peer {} connected, will broadcast our metadata", their_node_id); + + // Optionally broadcast our metadata when a peer connects + let our_metadata = self.our_metadata.lock().unwrap(); + if let Some(ref metadata) = *our_metadata { + let message = CustomGossipMessage::new(metadata.clone()); + let mut pending = self.pending_messages.lock().unwrap(); + pending.push((their_node_id, message)); + } + + Ok(()) + } + + fn peer_disconnected(&self, their_node_id: PublicKey) { + log_debug!(self.logger, "Peer {} disconnected", their_node_id); + // Optionally clean up metadata for disconnected peers + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::secp256k1::{Secp256k1, SecretKey}; + use lightning::util::ser::{Readable, Writeable}; + use lightning::util::test_utils::TestLogger; + use std::io::Cursor; + + #[test] + fn test_custom_gossip_message_serialization() { + let metadata = b"custom_metadata_payload".to_vec(); + let msg = CustomGossipMessage::new(metadata.clone()); + + assert_eq!(msg.metadata(), &metadata); + assert_eq!(msg.type_id(), CUSTOM_GOSSIP_MESSAGE_TYPE); + + // Test serialization + let mut buffer = Vec::new(); + msg.write(&mut buffer).unwrap(); + + // Test deserialization + let mut cursor = Cursor::new(buffer); + let deserialized = CustomGossipMessage::read(&mut cursor).unwrap(); + + assert_eq!(msg, deserialized); + } + + #[test] + fn test_custom_gossip_handler() { + let logger = Arc::new(TestLogger::new()); + let handler = CustomGossipMessageHandler::new(logger); + + // Test setting our metadata + let our_metadata = b"our_node_metadata".to_vec(); + handler.set_our_metadata(our_metadata.clone()); + + // Test handling a message + let secp_ctx = Secp256k1::new(); + let secret_key = SecretKey::from_slice(&[1; 32]).unwrap(); + let sender_node_id = PublicKey::from_secret_key(&secp_ctx, &secret_key); + + let msg = CustomGossipMessage::new(b"peer_metadata".to_vec()); + handler.handle_custom_message(msg, sender_node_id).unwrap(); + + // Verify metadata was stored + let stored_metadata = handler.get_node_metadata(&sender_node_id).unwrap(); + assert_eq!(stored_metadata.metadata, b"peer_metadata"); + assert_eq!(stored_metadata.node_id, sender_node_id); + } + + #[test] + fn test_message_size_limit() { + let large_metadata = vec![0u8; 5000]; // Exceeds 4096 byte limit + let msg = CustomGossipMessage::new(large_metadata); + + let mut buffer = Vec::new(); + msg.write(&mut buffer).unwrap(); + + let mut cursor = Cursor::new(buffer); + let result = CustomGossipMessage::read(&mut cursor); + + assert!(result.is_err()); + } +} diff --git a/src/data_store.rs b/src/data_store.rs index 70abfcc3fd..b1ed816df9 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) } @@ -218,8 +223,9 @@ where #[cfg(test)] mod tests { - use lightning::impl_writeable_tlv_based; + use lightning::util::persist::{PageToken, PaginatedKVStore, PaginatedListResponse}; use lightning::util::test_utils::TestLogger; + use lightning::{impl_writeable_tlv_based, io}; use super::*; use crate::hex_utils; @@ -281,6 +287,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 +402,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/error.rs b/src/error.rs index d07212b008..8546af0dd2 100644 --- a/src/error.rs +++ b/src/error.rs @@ -137,6 +137,8 @@ pub enum Error { LnurlAuthTimeout, /// The provided lnurl is invalid. InvalidLnurl, + /// The configured chain source is not supported. + ChainSourceNotSupported, } impl fmt::Display for Error { @@ -222,6 +224,9 @@ impl fmt::Display for Error { Self::LnurlAuthFailed => write!(f, "LNURL-auth authentication failed."), Self::LnurlAuthTimeout => write!(f, "LNURL-auth authentication timed out."), Self::InvalidLnurl => write!(f, "The provided lnurl is invalid."), + Self::ChainSourceNotSupported => { + write!(f, "The configured chain source is not supported.") + }, } } } diff --git a/src/event.rs b/src/event.rs index 86ee7bb05a..85c7288eb9 100644 --- a/src/event.rs +++ b/src/event.rs @@ -14,6 +14,7 @@ use std::sync::{Arc, Mutex}; use bitcoin::blockdata::locktime::absolute::LockTime; use bitcoin::secp256k1::PublicKey; use bitcoin::{Amount, OutPoint}; +use lightning::blinded_path::message::NextMessageHop; use lightning::events::bump_transaction::BumpTransactionEvent; #[cfg(not(feature = "uniffi"))] use lightning::events::PaidBolt12Invoice; @@ -33,7 +34,7 @@ use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; use lightning_liquidity::lsps2::utils::compute_opening_fee; use lightning_types::payment::{PaymentHash, PaymentPreimage}; -use crate::config::{may_announce_channel, Config}; +use crate::config::{may_announce_channel, Config, PEER_RECONNECTION_INTERVAL}; use crate::connection::ConnectionManager; use crate::data_store::DataStoreUpdateResult; use crate::fee_estimator::ConfirmationTarget; @@ -51,6 +52,7 @@ use crate::payment::store::{ PaymentDetails, PaymentDetailsUpdate, PaymentDirection, PaymentKind, PaymentStatus, }; use crate::payment::PaymentMetadata; +use crate::probing::Prober; use crate::runtime::Runtime; use crate::types::{ CustomTlvRecord, DynStore, KeysManager, OnionMessenger, PaymentStore, Sweeper, Wallet, @@ -265,9 +267,7 @@ pub enum Event { /// The `user_channel_id` of the channel. user_channel_id: UserChannelId, /// The `node_id` of the channel counterparty. - /// - /// This will be `None` for events serialized by LDK Node v0.1.0 and prior. - counterparty_node_id: Option, + counterparty_node_id: PublicKey, /// This will be `None` for events serialized by LDK Node v0.2.1 and prior. reason: Option, }, @@ -328,7 +328,7 @@ impl_writeable_tlv_based_enum!(Event, }, (5, ChannelClosed) => { (0, channel_id, required), - (1, counterparty_node_id, option), + (1, counterparty_node_id, required), (2, user_channel_id, required), (3, reason, upgradable_option), }, @@ -533,16 +533,17 @@ 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, - runtime: Arc, - logger: L, - config: Arc, static_invoice_store: Option, onion_messenger: Arc, om_mailbox: Option>, + prober: Option>, + runtime: Arc, + logger: L, + config: Arc, } impl EventHandler @@ -554,10 +555,10 @@ 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>, + liquidity_source: Arc>>, payment_store: Arc, + peer_store: Arc>, keys_manager: Arc, + static_invoice_store: Option, onion_messenger: Arc, + om_mailbox: Option>, prober: Option>, runtime: Arc, logger: L, config: Arc, ) -> Self { Self { @@ -572,15 +573,78 @@ where payment_store, peer_store, keys_manager, - logger, - runtime, - config, static_invoice_store, onion_messenger, om_mailbox, + prober, + runtime, + logger, + config, } } + fn remove_peer_after_reconnect(&self, peer_info: PeerInfo, closed_channel_id: ChannelId) { + let channel_manager = Arc::clone(&self.channel_manager); + let connection_manager = Arc::clone(&self.connection_manager); + let peer_store = Arc::clone(&self.peer_store); + let logger = self.logger.clone(); + self.runtime.spawn_cancellable_background_task(async move { + let has_other_channels = || { + channel_manager + .list_channels_with_counterparty(&peer_info.node_id) + .iter() + .any(|c| c.channel_id != closed_channel_id) + }; + + if peer_store.get_peer(&peer_info.node_id).is_none() || has_other_channels() { + return; + } + + // Ensure a connected peer cannot be mistaken for a completed recovery reconnect. + // With no other channels left, reconnecting once gives `channel_reestablish` a chance + // to retransmit the force-close error before we stop persisting the peer. + connection_manager.disconnect_peer(peer_info.node_id); + + loop { + if peer_store.get_peer(&peer_info.node_id).is_none() || has_other_channels() { + return; + } + + match connection_manager + .connect_peer_if_necessary(peer_info.node_id, peer_info.address.clone()) + .await + { + Ok(()) => { + if peer_store.get_peer(&peer_info.node_id).is_none() || has_other_channels() + { + return; + } + if let Err(e) = peer_store.remove_peer(&peer_info.node_id).await { + log_error!( + logger, + "Failed to remove peer {} from peer store: {}", + peer_info.node_id, + e + ); + } else { + return; + } + }, + Err(e) => { + log_debug!( + logger, + "Failed to reconnect peer {} before removing from peer store: {}", + peer_info.node_id, + e + ); + }, + } + + tokio::time::sleep(PEER_RECONNECTION_INTERVAL).await; + } + }); + } + async fn fail_claimable_payment( &self, payment_id: PaymentId, payment_hash: &PaymentHash, ) -> Result<(), ReplayEvent> { @@ -630,29 +694,32 @@ where // Sign the final funding transaction and broadcast it. let channel_amount = Amount::from_sat(channel_value_satoshis); - match self.wallet.create_funding_transaction( - output_script, - channel_amount, - confirmation_target, - locktime, - ) { + let funding_transaction = self + .wallet + .create_funding_transaction( + output_script, + channel_amount, + confirmation_target, + locktime, + ) + .await; + match funding_transaction { 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 +777,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, @@ -726,7 +793,11 @@ where let payment_id = PaymentId(payment_hash.0); let payment_info = self.payment_store.get(&payment_id); if let Some(info) = payment_info.as_ref() { - if info.direction == PaymentDirection::Outbound { + // Guard 1: refuse circular (self-loop) payments, EXCEPT for + // self-rebalance loops tagged as PaymentKind::Rebalance. Cross-node + // payments are never caught here (the remote recipient has no local + // Outbound record under the inbound hash). + if info.direction == PaymentDirection::Outbound && !info.is_rebalance() { log_info!( self.logger, "Refused inbound payment with ID {}: circular payments are unsupported.", @@ -747,8 +818,13 @@ where }; } + // Guard 2: refuse duplicate Succeeded payments and plain Spontaneous + // inbound payments. Self-rebalance loops (PaymentKind::Rebalance) must + // fall through here so we can claim the HTLC using our locally-held + // preimage and settle the loop. if info.status == PaymentStatus::Succeeded - || matches!(&info.kind, PaymentKind::Spontaneous { .. }) + || (matches!(&info.kind, PaymentKind::Spontaneous { .. }) + && !info.is_rebalance()) { let stored_preimage = match &info.kind { PaymentKind::Bolt11 { preimage, .. } @@ -846,6 +922,47 @@ where } if let Some(info) = payment_info { + // For self-rebalance loops the preimage is held locally in the + // Rebalance record. Claim immediately without inserting a new + // payment record (the outbound record already exists). + if let PaymentKind::Rebalance { preimage, .. } = info.kind { + // Belt-and-braces amount pin: the stateless inbound registration + // already enforces `min_value_msat = amount` before this event can + // fire, but never reveal the preimage for less than the recorded + // loop amount. + if amount_msat < info.amount_msat.unwrap_or(0) { + log_error!( + self.logger, + "Refusing underpaying self-rebalance HTLC for payment hash {}: got {}msat, expected {}msat", + hex_utils::to_string(&payment_hash.0), + amount_msat, + info.amount_msat.unwrap_or(0), + ); + self.channel_manager.fail_htlc_backwards(&payment_hash); + return Ok(()); + } + log_info!( + self.logger, + "Claiming self-rebalance loop for payment hash {} of {}msat", + hex_utils::to_string(&payment_hash.0), + amount_msat, + ); + self.channel_manager.claim_funds(preimage); + + let update = PaymentDetailsUpdate { + status: Some(PaymentStatus::Succeeded), + amount_msat: Some(Some(amount_msat)), + ..PaymentDetailsUpdate::new(payment_id) + }; + match self.payment_store.update(update).await { + Ok(_) => return Ok(()), + Err(e) => { + log_error!(self.logger, "Failed to access payment store: {}", e); + return Err(ReplayEvent()); + }, + }; + } + // If this is known by the store but ChannelManager doesn't know the preimage, // the payment has been registered via `_for_hash` variants and needs to be manually claimed via // user interaction. @@ -1210,13 +1327,22 @@ where LdkEvent::PaymentPathSuccessful { .. } => {}, LdkEvent::PaymentPathFailed { .. } => {}, - 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; + LdkEvent::ProbeSuccessful { path, payment_id, .. } => { + if let Some(prober) = &self.prober { + prober.handle_background_probe_successful(&path, payment_id); + } + }, + LdkEvent::ProbeFailed { path, payment_id, .. } => { + if let Some(prober) = &self.prober { + prober.handle_background_probe_failed(&path, payment_id); } }, + LdkEvent::HTLCHandlingFailed { failure_type, .. } => { + self.liquidity_source + .lsps2_service() + .handle_htlc_handling_failed(failure_type) + .await; + }, LdkEvent::SpendableOutputs { outputs, channel_id, counterparty_node_id } => { match self .output_sweeper @@ -1256,25 +1382,7 @@ where } } - let anchor_channel = channel_type.requires_anchors_zero_fee_htlc_tx(); - if anchor_channel && self.config.anchor_channels_config.is_none() { - log_error!( - self.logger, - "Rejecting inbound channel from peer {} due to Anchor channels being disabled.", - counterparty_node_id, - ); - self.channel_manager - .force_close_broadcasting_latest_txn( - &temporary_channel_id, - &counterparty_node_id, - "Channel request rejected".to_string(), - ) - .unwrap_or_else(|e| { - log_error!(self.logger, "Failed to reject channel: {:?}", e) - }); - return Ok(()); - } - + let anchor_channel = crate::requires_anchor_channel_type(&channel_type); let required_reserve_sats = crate::new_channel_anchor_reserve_sats( &self.config, &counterparty_node_id, @@ -1315,35 +1423,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() - }); - - // 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 - ); - } + }), + ..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 + ); } let res = if allow_0conf { self.channel_manager.accept_inbound_channel_from_trusted_peer( @@ -1468,13 +1577,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 +1693,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, @@ -1611,6 +1721,43 @@ where } => { log_info!(self.logger, "Channel {} closed due to: {}", channel_id, reason); + // `counterparty_node_id` has been set on every `ChannelClosed` since LDK 0.0.117. + let counterparty_node_id = counterparty_node_id + .expect("counterparty_node_id is always set since LDK 0.0.117"); + + // Drop the peer once its last channel with us has reached a terminal state. + // For `HolderForceClosed`, retain it through one recovery reconnect so that + // `channel_reestablish` can retransmit the force-close error before cleanup. + // This also cleans up peers persisted for a channel that closed before funding + // (e.g. `CounterpartyCoopClosedUnfundedChannel`), which would otherwise be + // retried forever. + // We exclude `channel_id` from the count because LDK emits `ChannelClosed` + // before removing it from its internal list. + let has_other_channels = self + .channel_manager + .list_channels_with_counterparty(&counterparty_node_id) + .iter() + .any(|c| c.channel_id != channel_id); + + let peer_to_reconnect = if !has_other_channels { + if matches!(reason, ClosureReason::HolderForceClosed { .. }) { + self.peer_store.get_peer(&counterparty_node_id) + } else { + if let Err(e) = self.peer_store.remove_peer(&counterparty_node_id).await { + log_error!( + self.logger, + "Failed to remove peer {} from peer store: {}", + counterparty_node_id, + e + ); + return Err(ReplayEvent()); + } + None + } + } else { + None + }; + let event = Event::ChannelClosed { channel_id, user_channel_id: UserChannelId(user_channel_id), @@ -1625,6 +1772,10 @@ where return Err(ReplayEvent()); }, }; + + if let Some(peer_info) = peer_to_reconnect { + self.remove_peer_after_reconnect(peer_info, channel_id); + } }, LdkEvent::DiscardFunding { channel_id, funding_info } => { if let FundingInfo::Contribution { inputs: _, outputs } = funding_info { @@ -1646,7 +1797,7 @@ where }) .collect(), }; - if let Err(e) = self.wallet.cancel_tx(&tx) { + if let Err(e) = self.wallet.cancel_tx(tx).await { log_error!(self.logger, "Failed reclaiming unused addresses: {}", e); return Err(ReplayEvent()); } @@ -1659,16 +1810,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."); @@ -1704,19 +1854,17 @@ where .. } => { // Skip bumping channel closes if our counterparty is trusted. - if let Some(anchor_channels_config) = - self.config.anchor_channels_config.as_ref() + if self + .config + .anchor_channels_config + .trusted_peers_no_reserve + .contains(counterparty_node_id) { - if anchor_channels_config - .trusted_peers_no_reserve - .contains(counterparty_node_id) - { - log_debug!(self.logger, - "Ignoring BumpTransactionEvent::ChannelClose for channel {} due to trusted counterparty {}", - channel_id, counterparty_node_id - ); - return Ok(()); - } + log_debug!(self.logger, + "Ignoring BumpTransactionEvent::ChannelClose for channel {} due to trusted counterparty {}", + channel_id, counterparty_node_id + ); + return Ok(()); } }, BumpTransactionEvent::HTLCResolution { .. } => {}, @@ -1724,14 +1872,18 @@ where self.bump_tx_event_handler.handle_event(&bte).await; }, - LdkEvent::OnionMessageIntercepted { peer_node_id, message } => { - if let Some(om_mailbox) = self.om_mailbox.as_ref() { - om_mailbox.onion_message_intercepted(peer_node_id, message); + LdkEvent::OnionMessageIntercepted { next_hop, message, .. } => { + if let NextMessageHop::NodeId(peer_node_id) = next_hop { + if let Some(om_mailbox) = self.om_mailbox.as_ref() { + om_mailbox.onion_message_intercepted(peer_node_id, message); + } else { + log_trace!( + self.logger, + "Onion message intercepted, but no onion message mailbox available" + ); + } } else { - log_trace!( - self.logger, - "Onion message intercepted, but no onion message mailbox available" - ); + log_error!(self.logger, "Onion message intercepted for unknown SCID"); } }, LdkEvent::OnionMessagePeerConnected { peer_node_id } => { @@ -1910,6 +2062,8 @@ where #[cfg(test)] mod tests { + use std::collections::VecDeque; + use std::str::FromStr; use std::sync::atomic::{AtomicU16, Ordering}; use std::time::Duration; @@ -2007,6 +2161,84 @@ mod tests { assert_eq!(event_queue.next_event(), None); } + #[derive(Clone, Debug, PartialEq, Eq)] + enum LegacyEvent { + ChannelClosed { + channel_id: ChannelId, + user_channel_id: UserChannelId, + counterparty_node_id: Option, + reason: Option, + }, + } + + impl_writeable_tlv_based_enum!(LegacyEvent, + (5, ChannelClosed) => { + (0, channel_id, required), + (1, counterparty_node_id, option), + (2, user_channel_id, required), + (3, reason, upgradable_option), + }, + ); + + fn encode_legacy_event_queue(event: LegacyEvent) -> Vec { + let mut queue = VecDeque::new(); + queue.push_back(event); + + let mut bytes = Vec::new(); + (queue.len() as u16).write(&mut bytes).unwrap(); + for event in queue.iter() { + event.write(&mut bytes).unwrap(); + } + bytes + } + + #[test] + fn event_queue_reads_legacy_channel_closed_with_counterparty() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let logger = Arc::new(TestLogger::new()); + let counterparty_node_id = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(); + let channel_id = ChannelId([42u8; 32]); + let user_channel_id = UserChannelId(4242); + let legacy_event = LegacyEvent::ChannelClosed { + channel_id, + user_channel_id, + counterparty_node_id: Some(counterparty_node_id), + reason: None, + }; + let persisted_bytes = encode_legacy_event_queue(legacy_event); + + let event_queue = + EventQueue::read(&mut &persisted_bytes[..], (Arc::clone(&store), logger)).unwrap(); + assert_eq!( + event_queue.next_event(), + Some(Event::ChannelClosed { + channel_id, + user_channel_id, + counterparty_node_id, + reason: None, + }) + ); + } + + #[test] + fn event_queue_rejects_legacy_channel_closed_without_counterparty() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let logger = Arc::new(TestLogger::new()); + let legacy_event = LegacyEvent::ChannelClosed { + channel_id: ChannelId([42u8; 32]), + user_channel_id: UserChannelId(4242), + counterparty_node_id: None, + reason: None, + }; + let persisted_bytes = encode_legacy_event_queue(legacy_event); + + let res = EventQueue::read(&mut &persisted_bytes[..], (Arc::clone(&store), logger)); + assert!(res.is_err()); + } + #[tokio::test] async fn event_queue_concurrency() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); diff --git a/src/fee_estimator.rs b/src/fee_estimator.rs index 34fe7b64ca..d087727171 100644 --- a/src/fee_estimator.rs +++ b/src/fee_estimator.rs @@ -87,7 +87,11 @@ impl LdkFeeEstimator for OnchainFeeEstimator { pub(crate) fn get_num_block_defaults_for_target(target: ConfirmationTarget) -> usize { match target { ConfirmationTarget::OnchainPayment => 6, - ConfirmationTarget::ChannelFunding => 12, + // Funding txs target ~3 blocks (mempool's "fast" tier) so they confirm + // promptly. The prior 12-block target resolved to a fee low enough that + // funding txs could sit unconfirmed for hours during normal congestion, + // stalling channels in `sync` (never reaching `channel_ready`). + ConfirmationTarget::ChannelFunding => 3, ConfirmationTarget::Lightning(ldk_target) => match ldk_target { LdkConfirmationTarget::MaximumFeeEstimate => 1, LdkConfirmationTarget::UrgentOnChainSweep => 6, @@ -164,3 +168,198 @@ 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); + } +} + +/// Public fee-priority selector for on-chain swap transactions (Peerswap +/// native primitives, B-series). +/// +/// This is the **public** surface used by swap code to ask for a fee rate +/// without exposing the crate-internal [`ConfirmationTarget`] enum. Each +/// variant maps onto an existing internal target via [`From`], so no new +/// `ConfirmationTarget` variant is introduced and every existing exhaustive +/// match is left untouched. +#[cfg(feature = "swaps")] +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] +pub enum SwapFeeTarget { + /// Fee target for broadcasting a swap funding (HTLC opening) transaction. + /// + /// Maps to [`ConfirmationTarget::ChannelFunding`] so the funding output + /// confirms promptly (~3 blocks) and the swap can proceed without stalling. + Funding, + /// Fee target for time-sensitive claim/sweep transactions. + /// + /// A swap claim is bounded by an on-chain timelock, so it must confirm + /// urgently. Maps to [`LdkConfirmationTarget::UrgentOnChainSweep`]. + Claim, + /// Fee target for refund / cooperative-spend transactions. + /// + /// Less time-critical than a [`SwapFeeTarget::Claim`]; maps to the standard + /// [`ConfirmationTarget::OnchainPayment`] priority. + Refund, +} + +#[cfg(feature = "swaps")] +impl From for ConfirmationTarget { + fn from(value: SwapFeeTarget) -> Self { + match value { + SwapFeeTarget::Funding => ConfirmationTarget::ChannelFunding, + SwapFeeTarget::Claim => { + ConfirmationTarget::Lightning(LdkConfirmationTarget::UrgentOnChainSweep) + }, + SwapFeeTarget::Refund => ConfirmationTarget::OnchainPayment, + } + } +} + +/// Provenance of a swap feerate estimate (Peerswap native primitive B6 / +/// plan FIX-B). +/// +/// Lets a swap caller distinguish a live estimate sourced from the chain +/// backend from a static fallback/relay-floor value, so it can refuse to fund +/// (fail-closed) on an estimate it does not trust. +#[cfg(feature = "swaps")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SwapFeerateSource { + /// A live estimate sourced from the chain backend's fee-rate cache. + Native, + /// No live estimate was available; a per-target fallback rate (or the + /// `FEERATE_FLOOR_SATS_PER_KW` relay floor) was used instead. + Static, +} + +/// A swap feerate estimate carrying its [`SwapFeerateSource`] provenance +/// (Peerswap native primitive B6 / plan FIX-B). +/// +/// This is intentionally NOT a bare `u64`/[`FeeRate`]: swap funding decisions +/// are fail-closed, so the consumer must be able to tell a live estimate from a +/// fallback/floor before committing funds. +#[cfg(feature = "swaps")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FeerateQuote { + /// The estimated feerate in satoshis per virtual byte (rounded up so the + /// transaction is never under-funded relative to the estimate). + pub sat_vb: u64, + /// Whether `sat_vb` came from a live estimate or a static fallback/floor. + pub source: SwapFeerateSource, +} + +#[cfg(feature = "swaps")] +impl OnchainFeeEstimator { + /// Estimate the on-chain fee rate for a swap transaction at the requested + /// [`SwapFeeTarget`] priority. + /// + /// Thin wrapper over [`FeeEstimator::estimate_fee_rate`] that maps the + /// public [`SwapFeeTarget`] onto the internal [`ConfirmationTarget`]. The + /// returned [`FeeRate`] is subject to the same `FEERATE_FLOOR_SATS_PER_KW` + /// lower bound as every other estimate, and falls back to the per-target + /// fallback rate when the cache is empty. + pub(crate) fn estimate_swap_fee_rate(&self, target: SwapFeeTarget) -> FeeRate { + self.estimate_fee_rate(target.into()) + } + + /// Source-bearing swap feerate estimate (B6 / FIX-B). + /// + /// Returns the estimate in sat/vB together with its [`SwapFeerateSource`]: + /// [`SwapFeerateSource::Native`] when a live cached estimate exists for the + /// mapped target, [`SwapFeerateSource::Static`] when the per-target + /// fallback / relay floor had to be used (cache empty). Callers MUST treat + /// a `Static` quote as untrusted for fail-closed funding decisions. + pub(crate) fn estimate_swap_feerate_quote(&self, target: SwapFeeTarget) -> FeerateQuote { + let conf_target: ConfirmationTarget = target.into(); + let source = if self.fee_rate_cache.read().unwrap().contains_key(&conf_target) { + SwapFeerateSource::Native + } else { + SwapFeerateSource::Static + }; + let rate = self.estimate_fee_rate(conf_target); + FeerateQuote { sat_vb: rate.to_sat_per_vb_ceil(), source } + } +} + +#[cfg(all(test, feature = "swaps"))] +mod swap_b6_tests { + use super::*; + + // An empty cache must yield a `Static` quote (fallback/floor), never a + // `Native` one — the fail-closed default for swap funding decisions. + #[test] + fn empty_cache_quote_is_static() { + let estimator = OnchainFeeEstimator::new(); + for target in [SwapFeeTarget::Funding, SwapFeeTarget::Claim, SwapFeeTarget::Refund] { + let quote = estimator.estimate_swap_feerate_quote(target); + assert_eq!(quote.source, SwapFeerateSource::Static); + // The fallback is always at least the relay floor, so sat/vB is > 0. + assert!(quote.sat_vb > 0); + } + } + + // A live cached estimate for the mapped target must yield a `Native` quote + // whose sat/vB reflects the cached rate (here well above the relay floor). + #[test] + fn cached_estimate_quote_is_native() { + let estimator = OnchainFeeEstimator::new(); + let target = SwapFeeTarget::Funding; + let conf_target: ConfirmationTarget = target.into(); + // 2500 sat/kwu == 10 sat/vB, comfortably above FEERATE_FLOOR_SATS_PER_KW. + let mut update = HashMap::new(); + update.insert(conf_target, FeeRate::from_sat_per_kwu(2500)); + estimator.set_fee_rate_cache(update); + + let quote = estimator.estimate_swap_feerate_quote(target); + assert_eq!(quote.source, SwapFeerateSource::Native); + assert_eq!(quote.sat_vb, 10); + } + + // A target absent from a populated cache still fails closed to `Static`. + #[test] + fn missing_target_in_populated_cache_is_static() { + let estimator = OnchainFeeEstimator::new(); + let mut update = HashMap::new(); + update.insert( + Into::::into(SwapFeeTarget::Funding), + FeeRate::from_sat_per_kwu(2500), + ); + estimator.set_fee_rate_cache(update); + + let quote = estimator.estimate_swap_feerate_quote(SwapFeeTarget::Claim); + assert_eq!(quote.source, SwapFeerateSource::Static); + } +} diff --git a/src/ffi/types.rs b/src/ffi/types.rs index 7380d75cac..c6b48dc961 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::{ @@ -149,6 +149,7 @@ pub use crate::entropy::{generate_entropy_mnemonic, NodeEntropy, WordCount}; use crate::error::Error; pub use crate::liquidity::LSPS1OrderStatus; pub use crate::logger::{LogLevel, LogRecord, LogWriter}; +pub use crate::probing::ProbingConfig; use crate::{hex_utils, SocketAddress, UserChannelId}; uniffi::custom_type!(PublicKey, String, { @@ -1526,6 +1527,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 +1817,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..f41c212c50 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -85,6 +85,7 @@ mod builder; mod chain; pub mod config; mod connection; +pub mod custom_gossip; mod data_store; pub mod entropy; mod error; @@ -101,10 +102,12 @@ pub mod logger; mod message_handler; pub mod payment; mod peer_store; +pub mod probing; mod runtime; mod scoring; mod tx_broadcaster; mod types; +mod util; mod wallet; use std::default::Default; @@ -117,10 +120,58 @@ pub use balance::{BalanceDetails, LightningBalance, PendingSweepBalance}; pub use bip39; pub use bitcoin; use bitcoin::secp256k1::PublicKey; + +/// Public swap fee-priority selector (Peerswap native primitives, B-series). +/// +/// Re-exported from the otherwise-private `fee_estimator` module so swap code +/// can request on-chain fee rates without the crate-internal +/// `ConfirmationTarget` leaking into the public API. +#[cfg(feature = "swaps")] +pub use fee_estimator::SwapFeeTarget; + +/// Public source-bearing swap feerate quote types (Peerswap native primitive +/// B6 / plan FIX-B). Re-exported from the otherwise-private `fee_estimator` +/// module so swap callers can detect estimate provenance (live vs fallback). +#[cfg(feature = "swaps")] +pub use fee_estimator::{FeerateQuote, SwapFeerateSource}; + +/// Public reorg-aware chain-status types for the swap-txid watch primitive +/// (Peerswap native primitives, B5). Re-exported from the otherwise-private +/// `chain` module. +#[cfg(feature = "swaps")] +pub use chain::{ChainStatus, TxStatus}; + +/// Public external chain-service hook types for the CBF chain source (fee estimates + +/// broadcast short-circuit). Re-exported from the otherwise-private `chain` module so a +/// consumer can name [`ChainServiceHooks`] to call +/// [`crate::builder::NodeBuilder::set_cbf_chain_service_hooks`]. Unlike the swap-primitive +/// re-exports above, these are CBF-only and not gated behind the `swaps` feature — matching +/// [`chain::ChainServiceHooks`]'s own (ungated) definition. +pub use chain::{BroadcastFuture, BroadcastHookError, ChainServiceHooks, FeeEstimatesFuture}; + +/// Optional external fee-estimation backend for [`set_chain_source_cbf`], re-exported from the +/// otherwise-private `chain` module so a consumer can name it. Not gated behind `swaps`, matching +/// [`chain::CbfFeeSourceConfig`]'s own (ungated) definition. +/// +/// [`set_chain_source_cbf`]: crate::builder::NodeBuilder::set_chain_source_cbf +pub use chain::CbfFeeSourceConfig; + +/// Simplified CBF sync-status snapshot, re-exported from the otherwise-private +/// `chain` module so a consumer can name the return type of +/// [`Node::cbf_sync_status`]. +pub use chain::CbfSyncStatus; + +/// Public types appearing in the swap-primitive signatures on [`Node`] +/// (Peerswap native primitives, B-series). Re-exported so the consumer crate +/// can name them without reaching into the (otherwise-private) LDK/bitcoin +/// module paths. +#[cfg(feature = "swaps")] +pub use bitcoin::psbt::Psbt; +/// Swap keypair type returned by [`Node::derive_swap_keypair`] (B7). +#[cfg(feature = "swaps")] +pub use bitcoin::secp256k1::Keypair as SwapKeypair; #[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; @@ -134,11 +185,14 @@ use config::{ RGS_SYNC_INTERVAL, }; use connection::ConnectionManager; +use custom_gossip::CustomGossipMessageHandler; 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; @@ -156,13 +210,19 @@ use lightning::ln::peer_handler::CustomMessageHandler; use lightning::routing::gossip::NodeAlias; use lightning::sign::EntropySource; use lightning::util::persist::KVStore; +/// Confirmed wallet UTXO type returned by [`Node::swap_list_confirmed_utxos`] +/// (B2). +#[cfg(feature = "swaps")] +pub use lightning::util::wallet_utils::Utxo; use lightning::util::wallet_utils::{Input, Wallet as LdkWallet}; use lightning_background_processor::process_events_async; pub use lightning_invoice; pub use lightning_liquidity; pub use lightning_types; -use lightning_types::features::NodeFeatures as LdkNodeFeatures; -use liquidity::{LSPS1Liquidity, LiquiditySource}; +use lightning_types::features::{ + ChannelTypeFeatures, InitFeatures, NodeFeatures as LdkNodeFeatures, +}; +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; @@ -172,6 +232,9 @@ use payment::{ UnifiedPayment, }; use peer_store::{PeerInfo, PeerStore}; +#[cfg(feature = "uniffi")] +pub use probing::ArcedProbingConfigBuilder as ProbingConfigBuilder; +use probing::{run_prober, Prober}; use runtime::Runtime; pub use tokio; use types::{ @@ -179,10 +242,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; @@ -211,6 +277,19 @@ impl LeakChecker { } } +fn peer_may_negotiate_anchor_channel_type( + config: &Config, their_init_features: &InitFeatures, +) -> bool { + their_init_features.supports_anchors_zero_fee_htlc_tx() + || (config.anchor_channels_config.enable_zero_fee_commitments + && their_init_features.supports_anchor_zero_fee_commitments()) +} + +fn requires_anchor_channel_type(channel_type: &ChannelTypeFeatures) -> bool { + channel_type.requires_anchors_zero_fee_htlc_tx() + || channel_type.requires_anchor_zero_fee_commitments() +} + /// The main interface object of LDK Node, wrapping the necessary LDK and BDK functionalities. /// /// Needs to be initialized and instantiated through [`Builder::build`]. @@ -234,7 +313,8 @@ pub struct Node { network_graph: Arc, gossip_source: Arc, pathfinding_scores_sync_url: Option, - liquidity_source: Option>>>, + liquidity_source: Arc>>, + custom_gossip_handler: Option>>>, kv_store: Arc, logger: Arc, _router: Arc, @@ -247,8 +327,12 @@ pub struct Node { om_mailbox: Option>, async_payments_role: Option, hrn_resolver: HRNResolver, + prober: Option>, #[cfg(cycle_tests)] _leak_checker: LeakChecker, + /// Reorg-aware swap-txid watch registry (Peerswap native primitive B5). + #[cfg(feature = "swaps")] + swap_tx_watch: Arc, } impl Node { @@ -274,15 +358,59 @@ impl Node { self.config.network ); - // 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); - e - })?; + self.runtime.allow_cancellable_background_task_spawns(); - // Block to ensure we update our fee rate cache once on startup + // Start up any runtime-dependant chain sources (e.g. Electrum, CBF) + self.chain_source + .start( + Arc::clone(&self.runtime), + Arc::clone(&self.wallet), + Arc::clone(&self.channel_manager), + Arc::clone(&self.chain_monitor), + Arc::clone(&self.output_sweeper), + ) + .map_err(|e| { + log_error!(self.logger, "Failed to start chain syncing: {}", e); + e + })?; + + let manager_owns_any_0fc_channels = + self.channel_manager.list_channels().into_iter().any(|channel| { + channel + .channel_shutdown_state + .map_or(true, |s| s != ChannelShutdownState::ShutdownComplete) + && channel + .channel_type + .as_ref() + .map_or(false, |c| c.requires_anchor_zero_fee_commitments()) + }); + let monitor_owns_any_0fc_channels = + self.chain_monitor.list_monitors().into_iter().any(|channel_id| { + self.chain_monitor + .get_monitor(channel_id) + .map(|monitor| { + monitor.channel_type_features().requires_anchor_zero_fee_commitments() + }) + .unwrap_or(false) + }); + let zero_fee_commitments_support_required = manager_owns_any_0fc_channels + || monitor_owns_any_0fc_channels + || self.config.anchor_channels_config.enable_zero_fee_commitments; + + // Block to ensure we update our fee rate cache once on startup. + // Also take this opportunity to make sure our chain source supports 0FC channels + // if they are enabled. + // + // TODO: drop 0FC chain source validation when support is ubiquitous let chain_source = Arc::clone(&self.chain_source); - self.runtime.block_on(async move { chain_source.update_fee_rate_estimates().await })?; + self.runtime.block_on(async move { + tokio::try_join!( + chain_source.update_fee_rate_estimates(), + chain_source.validate_zero_fee_commitments_support_if_required( + zero_fee_commitments_support_required + ) + ) + })?; // Spawn background task continuously syncing onchain, lightning, and fee rate cache. let stop_sync_receiver = self.stop_sender.subscribe(); @@ -572,8 +700,9 @@ impl Node { let stop_tx_bcast = self.stop_sender.subscribe(); let chain_source = Arc::clone(&self.chain_source); + let bcast_wallet = Arc::clone(&self.wallet); self.runtime.spawn_cancellable_background_task(async move { - chain_source.continuously_process_broadcast_queue(stop_tx_bcast).await + chain_source.continuously_process_broadcast_queue(stop_tx_bcast, bcast_wallet).await }); let bump_tx_event_handler = Arc::new(BumpTransactionEventHandler::new( @@ -598,18 +727,26 @@ 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), static_invoice_store, Arc::clone(&self.onion_messenger), self.om_mailbox.clone(), + self.prober.clone(), Arc::clone(&self.runtime), Arc::clone(&self.logger), Arc::clone(&self.config), )); + if let Some(prober) = self.prober.clone() { + let stop_rx = self.stop_sender.subscribe(); + self.runtime.spawn_cancellable_background_task(async move { + run_prober(prober, stop_rx).await; + }); + } + // Setup background processing let background_persister = Arc::clone(&self.kv_store); let background_event_handler = Arc::clone(&event_handler); @@ -617,8 +754,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 +790,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 +811,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; @@ -733,13 +918,15 @@ impl Node { self.peer_manager.disconnect_all_peers(); log_debug!(self.logger, "Disconnected all network peers."); - // Wait until non-cancellable background tasks (mod LDK's background processor) are done. - self.runtime.wait_on_background_tasks(); - - // Stop any runtime-dependant chain sources. + // Stop any runtime-dependant chain sources before waiting on non-cancellable + // background tasks. Some chain sources own background tasks that only exit + // after their client/requester is shut down. self.chain_source.stop(); log_debug!(self.logger, "Stopped chain sources."); + // Wait until non-cancellable background tasks (mod LDK's background processor) are done. + self.runtime.wait_on_background_tasks(); + // Stop the background processor. self.background_processor_stop_sender .send(()) @@ -805,6 +992,17 @@ impl Node { self.config.as_ref().clone() } + /// Returns a snapshot of the CBF chain source's sync status + /// ([`CbfSyncStatus`]), or `None` if this [`Node`] is not configured with + /// the CBF chain source (i.e. [`set_chain_source_cbf`] was not called). + /// + /// Never blocks — reads the current value without waiting for a change. + /// + /// [`set_chain_source_cbf`]: crate::builder::NodeBuilder::set_chain_source_cbf + pub fn cbf_sync_status(&self) -> Option { + self.chain_source.cbf_sync_status() + } + /// Returns the next event in the event queue, if currently available. /// /// Will return `Some(..)` if an event is available and `None` otherwise. @@ -884,6 +1082,12 @@ impl Node { self.config.node_alias } + /// Processes pending peer manager events and returns a handle to the peer manager. + pub fn process_events(&self) -> Arc { + self.peer_manager.process_events(); + Arc::clone(&self.peer_manager) + } + /// Returns a payment handler allowing to create and pay [BOLT 11] invoices. /// /// [BOLT 11]: https://github.com/lightning/bolts/blob/master/11-payment-encoding.md @@ -893,7 +1097,8 @@ impl Node { Arc::clone(&self.runtime), Arc::clone(&self.channel_manager), Arc::clone(&self.connection_manager), - self.liquidity_source.clone(), + Arc::clone(&self.keys_manager), + Arc::clone(&self.liquidity_source), Arc::clone(&self.payment_store), Arc::clone(&self.peer_store), Arc::clone(&self.config), @@ -911,7 +1116,8 @@ impl Node { Arc::clone(&self.runtime), Arc::clone(&self.channel_manager), Arc::clone(&self.connection_manager), - self.liquidity_source.clone(), + Arc::clone(&self.keys_manager), + Arc::clone(&self.liquidity_source), Arc::clone(&self.payment_store), Arc::clone(&self.peer_store), Arc::clone(&self.config), @@ -982,6 +1188,117 @@ impl Node { )) } + /// Sends a circular self-payment along a caller-supplied route (cooperative + /// cycle-balance primitive). + /// + /// The caller builds the exact [`Route`] (first hop = drain channel A, last hop = + /// fill channel B → self), generates a `preimage`/`payment_hash` pair (held + /// locally), and passes them here. The payment record is stored with + /// [`PaymentKind::Rebalance`] so the `event.rs` `PaymentClaimable` handler allows + /// the self-loop to settle instead of refusing it as a circular payment. + /// + /// Settlement is observed by polling [`Node::payment`] for the returned + /// [`PaymentId`]; no user-facing event is emitted for the loop. + /// + /// The raw [`ChannelManager`] is NOT exposed; this method is the sole entry + /// point for route-controlled self-pays. + /// + /// [`Route`]: lightning::routing::router::Route + /// [`PaymentKind::Rebalance`]: crate::payment::PaymentKind::Rebalance + /// [`ChannelManager`]: crate::types::ChannelManager + #[cfg(feature = "cycles")] + pub fn send_along_route( + &self, route: lightning::routing::router::Route, amount_msat: u64, + payment_hash: lightning_types::payment::PaymentHash, + preimage: lightning_types::payment::PaymentPreimage, + ) -> Result { + use lightning::ln::outbound_payment::{RecipientOnionFields, RetryableSendFailure}; + + if !*self.is_running.read().unwrap() { + return Err(Error::NotRunning); + } + + let payment_id = PaymentId(payment_hash.0); + + if let Some(existing) = self.payment_store.get(&payment_id) { + if existing.status == payment::PaymentStatus::Pending + || existing.status == payment::PaymentStatus::Succeeded + { + log_error!(self.logger, "Rebalance payment error: duplicate payment_id."); + return Err(Error::DuplicatePayment); + } + } + + // Register `payment_hash` with the ChannelManager's STATELESS inbound-payment + // verifier so the looped HTLC is receivable at the final hop. Without this the + // final onion payload carries neither a payment secret nor a keysend preimage + // and LDK fails it with "We require payment_secrets" BEFORE any + // `PaymentClaimable` fires — the loop could never settle. This creates NO + // payment-store record (unlike `Bolt11Payment::receive_for_hash`), so the + // scoped circular guard still sees only our single Outbound Rebalance record. + // `min_value_msat = amount_msat` means an underpaying HTLC never even surfaces + // a claimable event (no proof-of-payment leak); the secret only ever travels + // inside the onion we build, so no third party can construct a claimable HTLC + // for this hash. + let (payment_secret, _payment_metadata) = self + .channel_manager + .create_inbound_payment_for_hash(payment_hash, Some(amount_msat), 3600, None, None) + .map_err(|()| { + log_error!( + self.logger, + "Failed to register rebalance inbound payment for payment_id {}.", + payment_id, + ); + Error::PaymentSendingFailed + })?; + + let kind = payment::PaymentKind::Rebalance { hash: payment_hash, preimage }; + let payment_record = PaymentDetails::new( + payment_id, + kind, + Some(amount_msat), + None, + payment::PaymentDirection::Outbound, + payment::PaymentStatus::Pending, + ); + self.runtime.block_on(self.payment_store.insert(payment_record)).map_err(|e| { + log_error!(self.logger, "Failed to insert rebalance payment record: {}", e); + e + })?; + + match self.channel_manager.send_payment_with_route( + route, + payment_hash, + RecipientOnionFields::secret_only(payment_secret, amount_msat), + payment_id, + ) { + Ok(()) => { + log_info!( + self.logger, + "Initiated self-rebalance of {}msat (payment_id: {}).", + amount_msat, + payment_id, + ); + Ok(payment_id) + }, + Err(RetryableSendFailure::DuplicatePayment) => Err(Error::DuplicatePayment), + Err(e) => { + let update = payment::store::PaymentDetailsUpdate { + status: Some(payment::PaymentStatus::Failed), + ..payment::store::PaymentDetailsUpdate::new(payment_id) + }; + let _ = self.runtime.block_on(self.payment_store.update(update)); + log_error!( + self.logger, + "Self-rebalance send failed ({:?}) for payment_id {}.", + e, + payment_id, + ); + Err(Error::PaymentSendingFailed) + }, + } + } + /// Returns a payment handler allowing to send and receive on-chain payments. #[cfg(not(feature = "uniffi"))] pub fn onchain_payment(&self) -> OnchainPayment { @@ -990,10 +1307,172 @@ impl Node { Arc::clone(&self.channel_manager), Arc::clone(&self.config), Arc::clone(&self.is_running), + Arc::clone(&self.runtime), Arc::clone(&self.logger), ) } + /// Registers an arbitrary transaction (e.g. a counterparty's swap opening tx + /// that the local wallet does not own) for reorg-aware confirmation tracking + /// via [`Node::get_tx_confirmations`] (Peerswap native primitive B5). + /// + /// `scriptpubkey` is the output script being watched; it is required by the + /// Electrum chain source (which locates a tx through its scriptHash history) + /// and ignored by the Esplora/Bitcoind backends. Registration is idempotent. + #[cfg(feature = "swaps")] + pub fn watch_txid(&self, txid: bitcoin::Txid, scriptpubkey: bitcoin::ScriptBuf) { + self.swap_tx_watch.register(txid, scriptpubkey); + } + + /// Drops the reorg-aware watch for `txid` registered via [`Node::watch_txid`] + /// (Peerswap native primitive B5, LOW-1). The consumer calls this once a swap + /// reaches a terminal, settled state so the in-memory watch map does not grow + /// unbounded for the process lifetime. A no-op for a txid never watched. + #[cfg(feature = "swaps")] + pub fn unwatch_txid(&self, txid: bitcoin::Txid) { + self.swap_tx_watch.unregister(&txid); + } + + /// Queries the reorg-aware confirmation status of an arbitrary `txid` + /// against the configured chain source (Peerswap native primitive B5). + /// + /// Unlike wallet-owned confirmation lookups, this works on a counterparty's + /// opening tx. Confirmations are re-derived from the tx's *current* + /// best-chain block on every call, so a previously-confirmed tx that has + /// re-orged out is reported as [`ChainStatus::Reorged`] (and a never-confirmed + /// tx gone from the mempool as [`ChainStatus::Dropped`]) with zero + /// confirmations, allowing the caller to re-anchor deadlines (F4). + /// + /// FAIL-CLOSED (E6): if the chain source is unconfigured/unreachable, or an + /// Electrum lookup is attempted for a `txid` never registered via + /// [`Node::watch_txid`], the returned status is [`ChainStatus::NoChainSource`] + /// — never a confirmed result. Callers MUST NOT advance any state that + /// depends on a confirmation they could not verify. + #[cfg(feature = "swaps")] + pub async fn get_tx_confirmations(&self, txid: bitcoin::Txid) -> Result { + let script_pubkey = self.swap_tx_watch.script_pubkey(&txid); + let previously_confirmed = self.swap_tx_watch.previously_confirmed(&txid); + let observation = self.chain_source.swap_query_tx(txid, script_pubkey.as_ref()).await; + let status = chain::derive_tx_status(observation, previously_confirmed); + self.swap_tx_watch.record(&txid, &status); + Ok(status) + } + + /// Builds a fully-signed swap funding (HTLC opening) transaction paying + /// `amount` to `output_script` (e.g. a P2WSH submarine-swap HTLC output) at + /// the feerate implied by `fee_target`, with the supplied `locktime` + /// (Peerswap native primitive B1). + /// + /// The returned [`bitcoin::Transaction`] is signed and persisted but **not** + /// broadcast — call [`Node::broadcast_swap_tx`] to publish it. The public + /// [`SwapFeeTarget`] is used in place of the crate-internal confirmation + /// target so no internal type leaks across the crate boundary. + #[cfg(feature = "swaps")] + pub fn create_swap_funding_tx( + &self, output_script: bitcoin::ScriptBuf, amount: bitcoin::Amount, + fee_target: SwapFeeTarget, locktime: bitcoin::blockdata::locktime::absolute::LockTime, + ) -> Result { + self.wallet.create_swap_funding_tx(output_script, amount, fee_target.into(), locktime) + } + + /// Lists the wallet's confirmed, unspent outputs as [`Utxo`]s for use as + /// swap funding inputs (Peerswap native primitive B2). + #[cfg(feature = "swaps")] + pub fn swap_list_confirmed_utxos(&self) -> Result, Error> { + self.wallet.swap_list_confirmed_utxos() + } + + /// Signs a swap [`Psbt`] with the on-chain wallet, returning the extracted + /// [`bitcoin::Transaction`] (Peerswap native primitive B3). + /// + /// LDK-provided inputs are not finalized by BDK; the caller is responsible + /// for finalizing any swap-script (HTLC) inputs it owns. + #[cfg(feature = "swaps")] + pub fn swap_sign_psbt(&self, psbt: Psbt) -> Result { + self.wallet.swap_sign_psbt(psbt) + } + + /// Enqueues a fully-signed swap transaction for broadcast on the configured + /// chain backend (Peerswap native primitive B4). + /// + /// Fire-and-forget: the transaction is placed on the bounded broadcast queue + /// drained by the chain source and this returns immediately; it does not + /// confirm acceptance by the backend. + #[cfg(feature = "swaps")] + pub fn broadcast_swap_tx(&self, tx: &bitcoin::Transaction) { + self.tx_broadcaster.broadcast_tx(tx); + } + + /// Forgets the given unconfirmed transactions in the on-chain wallet, handing the coins they + /// spent back to the spendable balance. + /// + /// Meant for a transaction this node broadcast that the network has since dropped (the app's + /// transaction watcher learns this from its chain-service provider): under the CBF chain + /// source nothing else ever evicts an unconfirmed transaction, so without this the inputs of + /// a dropped send would stay locked until it confirmed — which it never will. Transactions + /// the wallet does not know, or already sees confirmed, are ignored. The eviction is + /// reversible: seeing the transaction again (a later [`Self::rebroadcast_unconfirmed_tx`], + /// or its confirmation) makes it canonical again. + pub fn evict_unconfirmed_txs(&self, txids: Vec) -> Result<(), Error> { + if !*self.is_running.read().expect("lock") { + return Err(Error::NotRunning); + } + if txids.is_empty() { + return Ok(()); + } + let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + let evicted = txids.into_iter().map(|txid| (txid, now)).collect(); + let wallet = Arc::clone(&self.wallet); + self.runtime.block_on(async move { wallet.apply_mempool_txs(Vec::new(), evicted).await }) + } + + /// Re-queues an unconfirmed transaction the on-chain wallet already holds for broadcast, + /// over the same path (chain-service hook first, P2P second) a fresh send takes. + /// + /// Returns [`Error::WalletOperationFailed`] when the wallet does not hold the transaction or + /// already sees it confirmed. Fire-and-forget like every other broadcast: the transaction is + /// placed on the bounded broadcast queue and this returns immediately. + pub fn rebroadcast_unconfirmed_tx(&self, txid: bitcoin::Txid) -> Result<(), Error> { + if !*self.is_running.read().expect("lock") { + return Err(Error::NotRunning); + } + let Some(tx) = self.wallet.get_unconfirmed_transaction(&txid) else { + log_debug!( + self.logger, + "Not rebroadcasting {}: the on-chain wallet holds no unconfirmed transaction by \ + that id.", + txid + ); + return Err(Error::WalletOperationFailed); + }; + self.tx_broadcaster.broadcast_unclassified_transaction(tx); + Ok(()) + } + + /// Estimates the on-chain feerate for a swap transaction at the requested + /// [`SwapFeeTarget`] priority, returning a source-bearing [`FeerateQuote`] + /// (Peerswap native primitive B6 / plan FIX-B). + /// + /// The quote's [`SwapFeerateSource`] lets a fail-closed caller distinguish a + /// live backend estimate from a static fallback/relay-floor value and refuse + /// to fund on an untrusted estimate. + #[cfg(feature = "swaps")] + pub fn estimate_onchain_feerate(&self, target: SwapFeeTarget) -> FeerateQuote { + self.chain_source.fee_estimator().estimate_swap_feerate_quote(target) + } + + /// Derives a deterministic swap [`SwapKeypair`] at `index` from a dedicated, + /// swaps-only BIP-32 derivation path (Peerswap native primitive B7). + /// + /// The keypair is NEVER derived from the node identity secret key: it comes + /// from a hardened path reserved exclusively for swaps, isolated from the + /// identity/channel keys. The returned keypair carries both the secret and + /// public key for building and signing swap HTLC scripts. + #[cfg(feature = "swaps")] + pub fn derive_swap_keypair(&self, index: u32) -> Result { + self.keys_manager.derive_swap_keypair(index) + } + /// Returns a payment handler allowing to send and receive on-chain payments. #[cfg(feature = "uniffi")] pub fn onchain_payment(&self) -> Arc { @@ -1002,6 +1481,7 @@ impl Node { Arc::clone(&self.channel_manager), Arc::clone(&self.config), Arc::clone(&self.is_running), + Arc::clone(&self.runtime), Arc::clone(&self.logger), )) } @@ -1048,6 +1528,30 @@ impl Node { )) } + /// Returns a custom gossip handler allowing to send and receive custom gossip messages. + /// + /// This returns `None` if custom gossip was not enabled during node construction. + /// To enable custom gossip, call [`Builder::enable_custom_gossip`] before building the node. + /// + /// Custom gossip messages can contain arbitrary metadata up to 4096 bytes in length + /// and use message type 32769 to extend the Lightning gossip protocol. + #[cfg(not(feature = "uniffi"))] + pub fn custom_gossip(&self) -> Option<&CustomGossipMessageHandler>> { + self.custom_gossip_handler.as_ref().map(|h| h.as_ref()) + } + + /// Returns a custom gossip handler allowing to send and receive custom gossip messages. + /// + /// This returns `None` if custom gossip was not enabled during node construction. + /// To enable custom gossip, call [`Builder::enable_custom_gossip`] before building the node. + /// + /// Custom gossip messages can contain arbitrary metadata up to 4096 bytes in length + /// and use message type 32769 to extend the Lightning gossip protocol. + #[cfg(feature = "uniffi")] + pub fn custom_gossip(&self) -> Option>>> { + self.custom_gossip_handler.clone() + } + /// Authenticates the user via [LNURL-auth] for the given LNURL string. /// /// [LNURL-auth]: https://github.com/lnurl/luds/blob/luds/04.md @@ -1068,37 +1572,42 @@ 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), )) } + /// Returns a reference to the [`Prober`], or `None` if no probing strategy is configured. + pub fn prober(&self) -> Option<&Prober> { + self.prober.as_deref() + } + /// 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)) + .collect() } /// Connect to a node on the peer-to-peer network. @@ -1179,7 +1688,7 @@ impl Node { FundingAmount::Exact { amount_sats } => { // Check funds availability after connection (includes anchor reserve // calculation). - self.check_sufficient_funds_for_channel(amount_sats, &peer_info.node_id)?; + self.check_sufficient_onchain_funds(amount_sats, &peer_info.node_id, true)?; amount_sats }, FundingAmount::Max => { @@ -1281,37 +1790,41 @@ impl Node { .peer_by_node_id(peer_node_id) .ok_or(Error::ConnectionFailed)? .init_features; - let anchor_channel = init_features.requires_anchors_zero_fee_htlc_tx(); + let anchor_channel = peer_may_negotiate_anchor_channel_type(&self.config, &init_features); Ok(new_channel_anchor_reserve_sats(&self.config, peer_node_id, anchor_channel)) } - fn check_sufficient_funds_for_channel( - &self, amount_sats: u64, peer_node_id: &PublicKey, + fn check_sufficient_onchain_funds( + &self, amount_sats: u64, peer_node_id: &PublicKey, for_new_channel: bool, ) -> Result<(), Error> { + let action_str = if for_new_channel { "create channel" } else { "splice-in" }; 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); - // Fail early if we have less than the channel value available. if spendable_amount_sats < amount_sats { - log_error!(self.logger, - "Unable to create channel due to insufficient funds. Available: {}sats, Required: {}sats", - spendable_amount_sats, amount_sats + log_error!( + self.logger, + "Unable to {} due to insufficient funds. Available: {}sats, Required: {}sats", + action_str, + spendable_amount_sats, + amount_sats ); return Err(Error::InsufficientFunds); } - // Fail if we have less than the channel value + anchor reserve available (if applicable). - let required_funds_sats = - amount_sats + self.new_channel_anchor_reserve_sats(peer_node_id)?; + if for_new_channel { + let required_funds_sats = + amount_sats + self.new_channel_anchor_reserve_sats(peer_node_id)?; - 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, required_funds_sats - ); - return Err(Error::InsufficientFunds); + 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, required_funds_sats + ); + return Err(Error::InsufficientFunds); + } } Ok(()) @@ -1531,7 +2044,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, @@ -1587,7 +2100,7 @@ impl Node { }, }; - self.check_sufficient_funds_for_channel(splice_amount_sats, &counterparty_node_id)?; + self.check_sufficient_onchain_funds(splice_amount_sats, &counterparty_node_id, false)?; let funding_template = self .channel_manager @@ -1600,16 +2113,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 +2223,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 +2233,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 +2246,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 +2293,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. /// @@ -1767,6 +2373,9 @@ impl Node { /// However, if background syncing is disabled (i.e., `background_sync_config` is set to `None`), /// this method must be called manually to keep wallets in sync with the chain state. /// + /// When using the CBF chain source, syncing always runs in the background. In that mode this + /// method waits until the background sync has applied chain updates through the current tip. + /// /// [`EsploraSyncConfig::background_sync_config`]: crate::config::EsploraSyncConfig::background_sync_config pub fn sync_wallets(&self) -> Result<(), Error> { if !*self.is_running.read().expect("lock") { @@ -1864,10 +2473,11 @@ impl Node { })?; } - // Check if this was the last open channel, if so, forget the peer. - if open_channels.len() == 1 { - self.runtime.block_on(self.peer_store.remove_peer(&counterparty_node_id))?; - } + // Peer store cleanup is handled centrally in the `ChannelClosed` event handler, + // which retains a force-closed peer through one recovery reconnect before + // dropping it. This lets `channel_reestablish` drive the recovery flow, which is + // especially important against LND peers that don't always handle force-closure + // error messages correctly. } Ok(()) @@ -2094,11 +2704,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() } } @@ -2238,21 +2844,20 @@ impl_writeable_tlv_based!(NodeMetrics, { pub(crate) fn total_anchor_channels_reserve_sats( channel_manager: &ChannelManager, config: &Config, ) -> u64 { - config.anchor_channels_config.as_ref().map_or(0, |anchor_channels_config| { - channel_manager - .list_channels() - .into_iter() - .filter(|c| { - !anchor_channels_config.trusted_peers_no_reserve.contains(&c.counterparty.node_id) - && c.channel_shutdown_state - .map_or(true, |s| s != ChannelShutdownState::ShutdownComplete) - && c.channel_type - .as_ref() - .map_or(false, |t| t.requires_anchors_zero_fee_htlc_tx()) - }) - .count() as u64 - * anchor_channels_config.per_channel_reserve_sats - }) + channel_manager + .list_channels() + .into_iter() + .filter(|c| { + !config + .anchor_channels_config + .trusted_peers_no_reserve + .contains(&c.counterparty.node_id) + && c.channel_shutdown_state + .map_or(true, |s| s != ChannelShutdownState::ShutdownComplete) + && c.channel_type.as_ref().map_or(false, requires_anchor_channel_type) + }) + .count() as u64 + * config.anchor_channels_config.per_channel_reserve_sats } pub(crate) fn new_channel_anchor_reserve_sats( @@ -2262,13 +2867,11 @@ pub(crate) fn new_channel_anchor_reserve_sats( return 0; } - config.anchor_channels_config.as_ref().map_or(0, |c| { - if c.trusted_peers_no_reserve.contains(peer_node_id) { - 0 - } else { - c.per_channel_reserve_sats - } - }) + if config.anchor_channels_config.trusted_peers_no_reserve.contains(peer_node_id) { + 0 + } else { + config.anchor_channels_config.per_channel_reserve_sats + } } #[cfg(test)] 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..6082414c12 --- /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.runtime.block_on(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..52fad2da20 --- /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..946511c5d9 --- /dev/null +++ b/src/liquidity/service/lsps2.rs @@ -0,0 +1,535 @@ +// 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 anchor_channel = + crate::peer_may_negotiate_anchor_channel_type(&self.config, &init_features); + let additional_reserve_required = crate::new_channel_anchor_reserve_sats( + &self.config, + &their_network_key, + anchor_channel, + ); + let required_funds_sats = channel_amount_sats + additional_reserve_required; + 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, required_funds_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..cdbaf54265 --- /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/logger.rs b/src/logger.rs index 3ef939b6d4..2ca87e4590 100644 --- a/src/logger.rs +++ b/src/logger.rs @@ -19,7 +19,7 @@ use lightning::ln::types::ChannelId; use lightning::types::payment::PaymentHash; pub use lightning::util::logger::Level as LogLevel; pub(crate) use lightning::util::logger::{Logger as LdkLogger, Record as LdkRecord}; -pub(crate) use lightning::{log_bytes, log_debug, log_error, log_info, log_trace}; +pub(crate) use lightning::{log_bytes, log_debug, log_error, log_info, log_trace, log_warn}; use log::{Level as LogFacadeLevel, Record as LogFacadeRecord}; /// A unit of logging output with metadata to enable filtering `module_path`, @@ -251,7 +251,8 @@ impl LogWriter for Writer { } } -pub(crate) struct Logger { +/// A logger for LDK Node that can write to files, the log facade, or custom writers. +pub struct Logger { /// Specifies the logger's writer. writer: Writer, } @@ -275,10 +276,12 @@ impl Logger { Ok(Self { writer: Writer::FileWriter { file_path, max_log_level } }) } + /// Creates a new logger that forwards logs to the `log` facade. pub fn new_log_facade() -> Self { Self { writer: Writer::LogFacadeWriter } } + /// Creates a new logger with a custom writer. pub fn new_custom_writer(log_writer: Arc) -> Self { Self { writer: Writer::CustomWriter(log_writer) } } diff --git a/src/message_handler.rs b/src/message_handler.rs index fc206ec4da..48049e751f 100644 --- a/src/message_handler.rs +++ b/src/message_handler.rs @@ -16,6 +16,7 @@ use lightning::util::ser::LengthLimitedRead; use lightning_liquidity::lsps0::ser::RawLSPSMessage; use lightning_types::features::{InitFeatures, NodeFeatures}; +use crate::custom_gossip::{CustomGossipMessage, CustomGossipMessageHandler}; use crate::liquidity::LiquiditySource; pub(crate) enum NodeCustomMessageHandler @@ -23,7 +24,16 @@ where L::Target: Logger, { Ignoring, - Liquidity { liquidity_source: Arc> }, + Liquidity { + liquidity_source: Arc>, + }, + CustomGossip { + gossip_handler: Arc>, + }, + Combined { + liquidity_source: Arc>, + gossip_handler: Arc>, + }, } impl NodeCustomMessageHandler @@ -37,13 +47,60 @@ where pub(crate) fn new_ignoring() -> Self { Self::Ignoring } + + pub(crate) fn new_custom_gossip(gossip_handler: Arc>) -> Self { + Self::CustomGossip { gossip_handler } + } + + pub(crate) fn new_combined( + liquidity_source: Arc>, + gossip_handler: Arc>, + ) -> Self { + Self::Combined { liquidity_source, gossip_handler } + } + + /// Returns the custom gossip handler if available + pub(crate) fn custom_gossip_handler(&self) -> Option>> { + match self { + Self::CustomGossip { gossip_handler } => Some(Arc::clone(gossip_handler)), + Self::Combined { gossip_handler, .. } => Some(Arc::clone(gossip_handler)), + _ => None, + } + } +} + +/// Combined custom message type that can handle both LSPS and custom gossip messages +#[derive(Clone, Debug)] +pub(crate) enum NodeCustomMessage { + Lsps(RawLSPSMessage), + CustomGossip(CustomGossipMessage), +} + +impl lightning::ln::wire::Type for NodeCustomMessage { + fn type_id(&self) -> u16 { + match self { + Self::Lsps(msg) => msg.type_id(), + Self::CustomGossip(msg) => msg.type_id(), + } + } +} + +impl lightning::util::ser::Writeable for NodeCustomMessage { + fn write( + &self, writer: &mut W, + ) -> Result<(), lightning::io::Error> { + match self { + Self::Lsps(msg) => msg.write(writer), + Self::CustomGossip(msg) => msg.write(writer), + } + } } impl CustomMessageReader for NodeCustomMessageHandler where L::Target: Logger, { - type CustomMessage = RawLSPSMessage; + type CustomMessage = NodeCustomMessage; fn read( &self, message_type: u16, buffer: &mut RD, @@ -51,7 +108,32 @@ where match self { Self::Ignoring => Ok(None), Self::Liquidity { liquidity_source, .. } => { - liquidity_source.liquidity_manager().read(message_type, buffer) + if let Ok(Some(lsps_msg)) = + liquidity_source.liquidity_manager().read(message_type, buffer) + { + Ok(Some(NodeCustomMessage::Lsps(lsps_msg))) + } else { + Ok(None) + } + }, + Self::CustomGossip { gossip_handler, .. } => { + if let Ok(Some(gossip_msg)) = gossip_handler.read(message_type, buffer) { + Ok(Some(NodeCustomMessage::CustomGossip(gossip_msg))) + } else { + Ok(None) + } + }, + Self::Combined { liquidity_source, gossip_handler } => { + // Try LSPS first, then custom gossip + if let Ok(Some(lsps_msg)) = + liquidity_source.liquidity_manager().read(message_type, buffer) + { + Ok(Some(NodeCustomMessage::Lsps(lsps_msg))) + } else if let Ok(Some(gossip_msg)) = gossip_handler.read(message_type, buffer) { + Ok(Some(NodeCustomMessage::CustomGossip(gossip_msg))) + } else { + Ok(None) + } }, } } @@ -66,8 +148,31 @@ where ) -> 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 { liquidity_source, .. } => match msg { + NodeCustomMessage::Lsps(lsps_msg) => liquidity_source + .liquidity_manager() + .handle_custom_message(lsps_msg, sender_node_id), + NodeCustomMessage::CustomGossip(_) => { + // Ignoring custom gossip in liquidity-only mode + Ok(()) + }, + }, + Self::CustomGossip { gossip_handler, .. } => match msg { + NodeCustomMessage::CustomGossip(gossip_msg) => { + gossip_handler.handle_custom_message(gossip_msg, sender_node_id) + }, + NodeCustomMessage::Lsps(_) => { + // Ignoring LSPS in gossip-only mode + Ok(()) + }, + }, + Self::Combined { liquidity_source, gossip_handler } => match msg { + NodeCustomMessage::Lsps(lsps_msg) => liquidity_source + .liquidity_manager() + .handle_custom_message(lsps_msg, sender_node_id), + NodeCustomMessage::CustomGossip(gossip_msg) => { + gossip_handler.handle_custom_message(gossip_msg, sender_node_id) + }, }, } } @@ -75,8 +180,38 @@ where 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 { liquidity_source, .. } => liquidity_source + .liquidity_manager() + .get_and_clear_pending_msg() + .into_iter() + .map(|(node_id, msg)| (node_id, NodeCustomMessage::Lsps(msg))) + .collect(), + Self::CustomGossip { gossip_handler, .. } => gossip_handler + .get_and_clear_pending_msg() + .into_iter() + .map(|(node_id, msg)| (node_id, NodeCustomMessage::CustomGossip(msg))) + .collect(), + Self::Combined { liquidity_source, gossip_handler } => { + let mut pending = Vec::new(); + + // Get LSPS messages + pending.extend( + liquidity_source + .liquidity_manager() + .get_and_clear_pending_msg() + .into_iter() + .map(|(node_id, msg)| (node_id, NodeCustomMessage::Lsps(msg))), + ); + + // Get custom gossip messages + pending.extend( + gossip_handler + .get_and_clear_pending_msg() + .into_iter() + .map(|(node_id, msg)| (node_id, NodeCustomMessage::CustomGossip(msg))), + ); + + pending }, } } @@ -87,6 +222,15 @@ where Self::Liquidity { liquidity_source, .. } => { liquidity_source.liquidity_manager().provided_node_features() }, + Self::CustomGossip { gossip_handler, .. } => gossip_handler.provided_node_features(), + Self::Combined { liquidity_source, gossip_handler } => { + // Combine features from both handlers + let features = liquidity_source.liquidity_manager().provided_node_features(); + let _gossip_features = gossip_handler.provided_node_features(); + // Note: In a real implementation, you'd need to properly merge features + // For now, we'll use the liquidity features as base + features + }, } } @@ -96,6 +240,18 @@ where Self::Liquidity { liquidity_source, .. } => { liquidity_source.liquidity_manager().provided_init_features(their_node_id) }, + Self::CustomGossip { gossip_handler, .. } => { + gossip_handler.provided_init_features(their_node_id) + }, + Self::Combined { liquidity_source, gossip_handler } => { + // Combine init features from both handlers + let features = + liquidity_source.liquidity_manager().provided_init_features(their_node_id); + let _gossip_features = gossip_handler.provided_init_features(their_node_id); + // Note: In a real implementation, you'd need to properly merge features + // For now, we'll use the liquidity features as base + features + }, } } @@ -107,6 +263,18 @@ where Self::Liquidity { liquidity_source, .. } => { liquidity_source.liquidity_manager().peer_connected(their_node_id, msg, inbound) }, + Self::CustomGossip { gossip_handler, .. } => { + gossip_handler.peer_connected(their_node_id, msg, inbound) + }, + Self::Combined { liquidity_source, gossip_handler } => { + // Notify both handlers + let _ = liquidity_source.liquidity_manager().peer_connected( + their_node_id, + msg, + inbound, + ); + gossip_handler.peer_connected(their_node_id, msg, inbound) + }, } } @@ -116,6 +284,14 @@ where Self::Liquidity { liquidity_source, .. } => { liquidity_source.liquidity_manager().peer_disconnected(their_node_id) }, + Self::CustomGossip { gossip_handler, .. } => { + gossip_handler.peer_disconnected(their_node_id) + }, + Self::Combined { liquidity_source, gossip_handler } => { + // Notify both handlers + liquidity_source.liquidity_manager().peer_disconnected(their_node_id); + gossip_handler.peer_disconnected(their_node_id); + }, } } } diff --git a/src/payment/bolt11.rs b/src/payment/bolt11.rs index 068269997f..8654ef4abd 100644 --- a/src/payment/bolt11.rs +++ b/src/payment/bolt11.rs @@ -10,17 +10,22 @@ //! [BOLT 11]: https://github.com/lightning/bolts/blob/master/11-payment-encoding.md use std::sync::{Arc, RwLock}; +use std::time::Duration; use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::Hash; +use bitcoin::secp256k1::Secp256k1; use lightning::impl_writeable_tlv_based; use lightning::ln::channelmanager::{ - Bolt11InvoiceParameters, OptionalBolt11PaymentParams, PaymentId, + Bolt11InvoiceParameters, OptionalBolt11PaymentParams, PaymentId, MIN_FINAL_CLTV_EXPIRY_DELTA, }; use lightning::ln::outbound_payment::{Bolt11PaymentError, Retry, RetryableSendFailure}; -use lightning::routing::router::{PaymentParameters, RouteParameters, RouteParametersConfig}; +use lightning::routing::router::{ + PaymentParameters, RouteHint, RouteParameters, RouteParametersConfig, +}; use lightning_invoice::{ Bolt11Invoice as LdkBolt11Invoice, Bolt11InvoiceDescription as LdkBolt11InvoiceDescription, + InvoiceBuilder, }; use lightning_types::payment::{PaymentHash, PaymentPreimage}; @@ -37,7 +42,7 @@ use crate::payment::store::{ }; use crate::peer_store::{PeerInfo, PeerStore}; use crate::runtime::Runtime; -use crate::types::{ChannelManager, PaymentStore}; +use crate::types::{ChannelManager, KeysManager, PaymentStore}; #[cfg(not(feature = "uniffi"))] type Bolt11Invoice = LdkBolt11Invoice; @@ -70,7 +75,8 @@ pub struct Bolt11Payment { runtime: Arc, channel_manager: Arc, connection_manager: Arc>>, - liquidity_source: Option>>>, + keys_manager: Arc, + liquidity_source: Arc>>, payment_store: Arc, peer_store: Arc>>, config: Arc, @@ -81,15 +87,16 @@ pub struct Bolt11Payment { 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, + connection_manager: Arc>>, keys_manager: Arc, + liquidity_source: Arc>>, payment_store: Arc, + peer_store: Arc>>, config: Arc, + is_running: Arc>, logger: Arc, ) -> Self { Self { runtime, channel_manager, connection_manager, + keys_manager, liquidity_source, payment_store, peer_store, @@ -168,45 +175,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 +232,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 +271,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 +296,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 +334,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 +345,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 +362,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 +375,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 +427,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 +524,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", @@ -677,6 +662,147 @@ impl Bolt11Payment { Ok(maybe_wrap(invoice)) } + /// Returns a payable invoice whose route hints are supplied by the caller, bypassing + /// `ChannelManager::create_bolt11_invoice`'s filter that drops any inbound channel whose + /// counterparty has not yet sent a `channel_update` (i.e. `forwarding_info = None`). + /// + /// This is required for topologies where a leaf LSP keeps the channel private and never + /// issues a unicast `channel_update`, making the default invoice builder return an empty + /// route-hint list. The caller is responsible for building hints that match the peer's + /// actual forwarding policy. + pub fn receive_with_hints( + &self, amount_msat: u64, description: &Bolt11InvoiceDescription, expiry_secs: u32, + route_hints: Vec, + ) -> Result { + let description = maybe_try_convert_enum(description)?; + let invoice = self.receive_with_hints_inner( + Some(amount_msat), + &description, + expiry_secs, + None, + route_hints, + )?; + Ok(maybe_wrap(invoice)) + } + + /// HODL variant of [`receive_with_hints`] — registers a caller-supplied `payment_hash` + /// so the caller can later release the preimage via [`claim_for_hash`]. + /// + /// [`claim_for_hash`]: Self::claim_for_hash + pub fn receive_for_hash_with_hints( + &self, amount_msat: u64, description: &Bolt11InvoiceDescription, expiry_secs: u32, + payment_hash: PaymentHash, route_hints: Vec, + ) -> Result { + let description = maybe_try_convert_enum(description)?; + let invoice = self.receive_with_hints_inner( + Some(amount_msat), + &description, + expiry_secs, + Some(payment_hash), + route_hints, + )?; + Ok(maybe_wrap(invoice)) + } + + pub(crate) fn receive_with_hints_inner( + &self, amount_msat: Option, invoice_description: &LdkBolt11InvoiceDescription, + expiry_secs: u32, manual_claim_payment_hash: Option, + route_hints: Vec, + ) -> Result { + let min_final_cltv_expiry_delta = MIN_FINAL_CLTV_EXPIRY_DELTA; + + let (payment_hash_ldk, payment_secret, mut payment_metadata) = if let Some(manual_hash) = + manual_claim_payment_hash + { + let (secret, metadata) = self + .channel_manager + .create_inbound_payment_for_hash( + manual_hash, + amount_msat, + expiry_secs, + Some(min_final_cltv_expiry_delta), + None, + ) + .map_err(|e| { + log_error!(self.logger, "Failed to register inbound payment for hash: {:?}", e); + Error::InvoiceCreationFailed + })?; + (manual_hash, secret, metadata) + } else { + self.channel_manager + .create_inbound_payment( + amount_msat, + expiry_secs, + Some(min_final_cltv_expiry_delta), + None, + ) + .map_err(|e| { + log_error!(self.logger, "Failed to register inbound payment: {:?}", e); + Error::InvoiceCreationFailed + })? + }; + + let currency = self.config.network.into(); + let mut invoice_builder = InvoiceBuilder::new(currency) + .invoice_description(invoice_description.clone()) + .payment_hash(payment_hash_ldk) + .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())); + + for hint in route_hints { + invoice_builder = invoice_builder.private_route(hint); + } + + if let Some(amount_msat) = amount_msat { + invoice_builder = invoice_builder.amount_milli_satoshis(amount_msat).basic_mpp(); + } + + let invoice = invoice_builder + .build_signed(|hash| { + Secp256k1::new() + .sign_ecdsa_recoverable(hash, &self.keys_manager.get_node_secret_key()) + }) + .map_err(|e| { + log_error!(self.logger, "Failed to build and sign invoice: {}", e); + Error::InvoiceCreationFailed + })?; + + log_info!(self.logger, "Invoice (with manual route hints) created: {}", invoice); + + let payment_hash = invoice.payment_hash(); + let id = PaymentId(payment_hash.0); + let preimage = if manual_claim_payment_hash.is_none() { + self.channel_manager + .get_payment_preimage_decrypt_metadata( + payment_hash, + invoice.payment_secret().clone(), + payment_metadata.as_deref_mut(), + ) + .ok() + } else { + None + }; + let kind = PaymentKind::Bolt11 { + hash: payment_hash, + preimage, + secret: Some(invoice.payment_secret().clone()), + counterparty_skimmed_fee_msat: None, + }; + let payment = PaymentDetails::new( + id, + kind, + amount_msat, + None, + PaymentDirection::Inbound, + PaymentStatus::Pending, + ); + self.runtime.block_on(self.payment_store.insert(payment))?; + + Ok(invoice) + } + /// Returns a payable invoice that can be used to request a payment of the amount given and /// receive it via a newly created just-in-time (JIT) channel. /// diff --git a/src/payment/mod.rs b/src/payment/mod.rs index 71daa48b0a..fd75322ceb 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -20,10 +20,10 @@ 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, 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..ad0a2d46c7 100644 --- a/src/payment/onchain.rs +++ b/src/payment/onchain.rs @@ -15,6 +15,7 @@ use lightning::ln::channelmanager::PaymentId; use crate::config::Config; use crate::error::Error; use crate::logger::{log_info, LdkLogger, Logger}; +use crate::runtime::Runtime; use crate::types::{ChannelManager, Wallet}; use crate::wallet::OnchainSendAmount; @@ -47,15 +48,30 @@ pub struct OnchainPayment { channel_manager: Arc, config: Arc, is_running: Arc>, + runtime: Arc, logger: Arc, } impl OnchainPayment { pub(crate) fn new( wallet: Arc, channel_manager: Arc, config: Arc, - is_running: Arc>, logger: Arc, + is_running: Arc>, runtime: Arc, logger: Arc, ) -> Self { - Self { wallet, channel_manager, config, is_running, logger } + Self { wallet, channel_manager, config, is_running, runtime, logger } + } + + pub(crate) async fn send_to_address_inner( + &self, address: &bitcoin::Address, amount_sats: u64, fee_rate: Option, + ) -> Result { + if !*self.is_running.read().expect("lock") { + return Err(Error::NotRunning); + } + + let cur_anchor_reserve_sats = + crate::total_anchor_channels_reserve_sats(&self.channel_manager, &self.config); + let send_amount = + OnchainSendAmount::ExactRetainingReserve { amount_sats, cur_anchor_reserve_sats }; + self.wallet.send_to_address(address, send_amount, fee_rate).await } } @@ -63,7 +79,7 @@ impl OnchainPayment { impl OnchainPayment { /// Retrieve a new on-chain/funding address. pub fn new_address(&self) -> Result { - let funding_address = self.wallet.get_new_address()?; + let funding_address = self.runtime.block_on(self.wallet.get_new_address())?; log_info!(self.logger, "Generated new funding address: {}", funding_address); Ok(funding_address) } @@ -80,16 +96,8 @@ impl OnchainPayment { pub fn send_to_address( &self, address: &bitcoin::Address, amount_sats: u64, fee_rate: Option, ) -> Result { - if !*self.is_running.read().expect("lock") { - return Err(Error::NotRunning); - } - - let cur_anchor_reserve_sats = - crate::total_anchor_channels_reserve_sats(&self.channel_manager, &self.config); - let send_amount = - OnchainSendAmount::ExactRetainingReserve { amount_sats, cur_anchor_reserve_sats }; let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate); - self.wallet.send_to_address(address, send_amount, fee_rate_opt) + self.runtime.block_on(self.send_to_address_inner(address, amount_sats, fee_rate_opt)) } /// Send an on-chain payment to the given address, draining the available funds. @@ -123,7 +131,7 @@ impl OnchainPayment { }; let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate); - self.wallet.send_to_address(address, send_amount, fee_rate_opt) + self.runtime.block_on(self.wallet.send_to_address(address, send_amount, fee_rate_opt)) } /// Attempt to bump the fee of an unconfirmed transaction using Replace-by-Fee (RBF). @@ -134,11 +142,22 @@ 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.runtime.block_on(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..f5f2fa40a2 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,113 @@ 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 bitcoin::hashes::Hash; + + use super::*; + use crate::payment::store::ConfirmationStatus; + use crate::payment::{PaymentDirection, PaymentKind, PaymentStatus}; + + #[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..0de5cdc771 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::{ @@ -60,6 +63,15 @@ impl PaymentDetails { .as_secs(); Self { id, kind, amount_msat, fee_paid_msat, direction, status, latest_update_timestamp } } + + /// Returns `true` if this is a circular self-rebalance payment sent along a + /// caller-supplied route. + /// + /// Used by the `event.rs` `PaymentClaimable` handler to allow the self-loop to + /// settle instead of being refused as a circular payment. + pub(crate) fn is_rebalance(&self) -> bool { + matches!(self.kind, PaymentKind::Rebalance { .. }) + } } impl Writeable for PaymentDetails { @@ -282,6 +294,17 @@ impl StorableObject for PaymentDetails { } } + if let Some(tx_type_update) = update.tx_type { + match self.kind { + PaymentKind::Onchain { ref mut tx_type, .. } => { + if tx_type.is_none() || tx_type_update.is_some() { + update_if_necessary!(*tx_type, tx_type_update); + } + }, + _ => {}, + } + } + if updated { self.latest_update_timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -330,6 +353,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 +518,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. /// @@ -418,11 +596,24 @@ pub enum PaymentKind { /// The pre-image used by the payment. preimage: Option, }, + /// A circular self-rebalance payment sent along a caller-supplied route. + /// + /// The sender generates the preimage locally, sends the payment over a pinned + /// route (out-channel A → intermediaries → in-channel B → self), and claims it on + /// receipt. The `event.rs` `PaymentClaimable` guard falls through for this kind so + /// the loop is allowed to settle — all other self-loops are still refused. + Rebalance { + /// The payment hash, i.e., the hash of the `preimage`. + hash: PaymentHash, + /// The pre-image used by the payment (held locally by the initiating node). + preimage: PaymentPreimage, + }, } impl_writeable_tlv_based_enum!(PaymentKind, (0, Onchain) => { (0, txid, required), + (1, tx_type, option), (2, status, required), }, (2, Bolt11) => { @@ -459,6 +650,10 @@ impl_writeable_tlv_based_enum!(PaymentKind, (2, preimage, option), (3, quantity, option), (4, secret, option), + }, + (12, Rebalance) => { + (0, hash, required), + (2, preimage, required), } ); @@ -522,6 +717,7 @@ pub(crate) struct PaymentDetailsUpdate { pub status: Option, pub confirmation_status: Option, pub txid: Option, + pub tx_type: Option>, } impl PaymentDetailsUpdate { @@ -538,6 +734,7 @@ impl PaymentDetailsUpdate { status: None, confirmation_status: None, txid: None, + tx_type: None, } } } @@ -552,9 +749,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 +775,7 @@ impl From<&PaymentDetails> for PaymentDetailsUpdate { status: Some(value.status), confirmation_status, txid, + tx_type, } } } @@ -697,6 +897,156 @@ 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 std::str::FromStr; + + use bitcoin::hashes::Hash; + + 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()); + } + + #[test] + fn known_onchain_tx_type_survives_unknown_update() { + use std::str::FromStr; + + use bitcoin::hashes::Hash; + + let txid = Txid::from_byte_array([8u8; 32]); + let payment_id = PaymentId(txid.to_byte_array()); + let pubkey = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(); + let tx_type = TransactionType::CooperativeClose { + counterparty_node_id: pubkey, + channel_id: ChannelId([4u8; 32]), + }; + let mut classified = PaymentDetails::new( + payment_id, + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(tx_type.clone()), + }, + Some(1_000), + Some(100), + PaymentDirection::Inbound, + PaymentStatus::Pending, + ); + let wallet_sync_update = PaymentDetails::new( + payment_id, + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Confirmed { + block_hash: BlockHash::from_byte_array([9u8; 32]), + height: 42, + timestamp: 123, + }, + tx_type: None, + }, + Some(1_000), + Some(100), + PaymentDirection::Inbound, + PaymentStatus::Pending, + ); + + assert!(classified.update(PaymentDetailsUpdate::from(&wallet_sync_update))); + match classified.kind { + PaymentKind::Onchain { status, tx_type: Some(updated_tx_type), .. } => { + assert!(matches!(status, ConfirmationStatus::Confirmed { height: 42, .. })); + assert_eq!(updated_tx_type, tx_type); + }, + other => panic!("Unexpected payment kind: {:?}", other), + } + } + + #[test] + fn transaction_type_from_ldk_variants() { + use std::str::FromStr; + + let pubkey = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(); + let channel_id = ChannelId([5u8; 32]); + let channel = Channel { counterparty_node_id: pubkey, channel_id }; + + let variants = vec![ + ( + LdkTransactionType::Funding { channels: vec![(pubkey, channel_id)] }, + TransactionType::Funding { channels: vec![channel.clone()] }, + ), + ( + LdkTransactionType::CooperativeClose { counterparty_node_id: pubkey, channel_id }, + TransactionType::CooperativeClose { counterparty_node_id: pubkey, channel_id }, + ), + ( + LdkTransactionType::UnilateralClose { counterparty_node_id: pubkey, channel_id }, + TransactionType::UnilateralClose { counterparty_node_id: pubkey, channel_id }, + ), + ( + LdkTransactionType::AnchorBump { counterparty_node_id: pubkey, channel_id }, + TransactionType::AnchorBump { counterparty_node_id: pubkey, channel_id }, + ), + ( + LdkTransactionType::Claim { counterparty_node_id: pubkey, channel_id }, + TransactionType::Claim { counterparty_node_id: pubkey, channel_id }, + ), + ( + LdkTransactionType::Sweep { channels: vec![(pubkey, channel_id)] }, + TransactionType::Sweep { channels: vec![channel] }, + ), + ]; + + for (ldk_type, expected_type) in variants { + assert_eq!(TransactionType::from(ldk_type), expected_type); + } + } + #[derive(Clone, Debug, PartialEq, Eq)] struct LegacyBolt11JitKind { hash: PaymentHash, diff --git a/src/payment/unified.rs b/src/payment/unified.rs index 3708afe8e6..cb51174140 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) { @@ -328,7 +328,10 @@ impl UnifiedPayment { Error::InvalidAmount })?; - let txid = self.onchain_payment.send_to_address(&address, amt_sats, None)?; + let txid = self + .onchain_payment + .send_to_address_inner(&address, amt_sats, None) + .await?; return Ok(UnifiedPaymentResult::Onchain { txid }); }, } diff --git a/src/peer_store.rs b/src/peer_store.rs index 8037f93471..8345bf7111 100644 --- a/src/peer_store.rs +++ b/src/peer_store.rs @@ -58,11 +58,14 @@ where pub(crate) async fn remove_peer(&self, node_id: &PublicKey) -> Result<(), Error> { let _guard = self.mutation_lock.lock().await; let data = { - let mut locked_peers = self.peers.write().expect("lock"); - locked_peers.remove(node_id); - PeerStoreSerWrapper(&locked_peers).encode() + let locked_peers = self.peers.read().expect("lock"); + let mut updated_peers = locked_peers.clone(); + updated_peers.remove(node_id); + PeerStoreSerWrapper(&updated_peers).encode() }; - self.persist_peers(data).await + self.persist_peers(data).await?; + self.peers.write().expect("lock").remove(node_id); + Ok(()) } /// Returns the current in-memory peer set. @@ -170,12 +173,52 @@ mod tests { use std::str::FromStr; use std::sync::Arc; + use bitcoin::io; + use lightning::util::persist::{PageToken, PaginatedKVStore, PaginatedListResponse}; use lightning::util::test_utils::TestLogger; use super::*; use crate::io::test_utils::InMemoryStore; use crate::types::DynStoreWrapper; + 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")) } + } + } + #[tokio::test] async fn peer_info_persistence() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); @@ -215,4 +258,23 @@ mod tests { assert_eq!(peers[0], expected_peer_info); assert_eq!(deser_peer_store.get_peer(&node_id), Some(expected_peer_info)); } + + #[tokio::test] + async fn remove_peer_does_not_mutate_memory_if_persist_fails() { + let store: Arc = Arc::new(DynStoreWrapper(FailingStore)); + let logger = Arc::new(TestLogger::new()); + let node_id = PublicKey::from_str( + "0276607124ebe6a6c9338517b6f485825b27c2dcc0b9fc2aa6a4c0df91194e5993", + ) + .unwrap(); + let peer_info = + PeerInfo { node_id, address: SocketAddress::from_str("127.0.0.1:9738").unwrap() }; + let mut peers = HashMap::new(); + peers.insert(node_id, peer_info.clone()); + let persisted_bytes = PeerStoreSerWrapper(&peers).encode(); + let peer_store = PeerStore::read(&mut &persisted_bytes[..], (store, logger)).unwrap(); + + assert_eq!(Err(Error::PersistenceFailed), peer_store.remove_peer(&node_id).await); + assert_eq!(Some(peer_info), peer_store.get_peer(&node_id)); + } } diff --git a/src/probing.rs b/src/probing.rs new file mode 100644 index 0000000000..ecb8bf6891 --- /dev/null +++ b/src/probing.rs @@ -0,0 +1,836 @@ +// 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. + +//! Background probing for training the payment scorer. +//! +//! Lightning Network nodes only know channels' capacities via their initially announced limits; +//! the real values change unpredictably after payments have been sent, which makes some of +//! the channels inoperable (capacity has been depleted). The only way to know about channel +//! depletion is to attempt sending a payment through it. Thus, sending a live payment +//! might involve a significant time delay for finding an appropriate channel with enough capacity, +//! up to complete failure when a route with enough capacity cannot be found. +//! +//! The background probing service fires probes to learn about the live state of channels and +//! their capacities, providing accurate data to the scorer and router. +//! +//! This module provides the configuration for such a service. There are two pre-built strategies, +//! [`RandomWalkStrategy`] and [`HighDegreeStrategy`], as well as a [`ProbingStrategy`] trait which +//! allows defining a custom probing strategy (for example if there is an established payment +//! pattern). +//! +//! # Configuration +//! +//! Probing is opt-in: a node only runs the service if a [`ProbingConfig`] has been registered +//! on the [`Builder`] via [`Builder::set_probing_config`] before [`Builder::build`]. Without a +//! config, no probes are sent. +//! +//! # Example +//! +//! ```no_run +//! # #[cfg(not(feature = "uniffi"))] +//! # { +//! use std::time::Duration; +//! +//! use ldk_node::probing::ProbingConfigBuilder; +//! use ldk_node::Builder; +//! +//! let probing_config = ProbingConfigBuilder::high_degree(100) +//! .interval(Duration::from_secs(30)) +//! .max_locked_msat(500_000) +//! .diversity_penalty_msat(250) +//! .build(); +//! +//! let mut builder = Builder::new(); +//! builder.set_probing_config(probing_config); +//! # } +//! ``` +//! +//! # Caution +//! +//! Probes send real HTLCs along real paths. If an intermediate hop is offline or +//! misbehaving, the probe HTLC can remain in-flight — locking outbound liquidity +//! on the first-hop channel until the HTLC timeout elapses (potentially hours). +//! `max_locked_msat` caps the total outbound capacity that in-flight probes may +//! hold at any one time; tune it conservatively for nodes with tight liquidity. +//! +//! [`Builder`]: crate::Builder +//! [`Builder::set_probing_config`]: crate::Builder::set_probing_config +//! [`Builder::build`]: crate::Builder::build + +use std::collections::HashMap; +use std::fmt; +#[cfg(feature = "uniffi")] +use std::sync::RwLock; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use bitcoin::secp256k1::PublicKey; +use lightning::ln::channelmanager::{PaymentId, RecentPaymentDetails}; +use lightning::routing::gossip::NodeId; +use lightning::routing::router::{ + Path, PaymentParameters, RouteHop, RouteParameters, Router as LdkRouter, + MAX_PATH_LENGTH_ESTIMATE, +}; +use lightning_invoice::DEFAULT_MIN_FINAL_CLTV_EXPIRY_DELTA; +use lightning_types::features::{ChannelFeatures, NodeFeatures}; + +use crate::config::{ + DEFAULT_MAX_PROBE_LOCKED_MSAT, DEFAULT_PROBED_NODE_COOLDOWN_SECS, + DEFAULT_PROBING_INTERVAL_SECS, MIN_PROBING_INTERVAL, +}; +use crate::logger::{log_debug, LdkLogger, Logger}; +use crate::types::{ChannelManager, Graph, Router}; +use crate::util::random_range; + +/// Which built-in probing strategy to use, or a custom one. +#[derive(Clone)] +pub(crate) enum ProbingStrategyKind { + HighDegree { top_node_count: usize }, + RandomWalk { max_hops: usize }, + Custom(Arc), +} + +impl fmt::Debug for ProbingStrategyKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::HighDegree { top_node_count } => { + f.debug_struct("HighDegree").field("top_node_count", top_node_count).finish() + }, + Self::RandomWalk { max_hops } => { + f.debug_struct("RandomWalk").field("max_hops", max_hops).finish() + }, + Self::Custom(_) => f.write_str("Custom()"), + } + } +} + +/// Configuration for the background probing subsystem. +/// +/// Instances are produced by [`ProbingConfigBuilder`], which exposes three strategy +/// constructors: [`ProbingConfigBuilder::high_degree`], [`ProbingConfigBuilder::random_walk`], +/// and [`ProbingConfigBuilder::custom`]. +/// +/// Optional setters on the builder tune timing and liquidity limits, and +/// [`ProbingConfigBuilder::build`] finalizes the value. +/// +/// # Examples +/// +/// Using pre-built strategy: +/// ```no_run +/// # #[cfg(not(feature = "uniffi"))] +/// # { +/// use std::time::Duration; +/// +/// use ldk_node::probing::ProbingConfigBuilder; +/// use ldk_node::Builder; +/// +/// let config = ProbingConfigBuilder::high_degree(100) +/// .interval(Duration::from_secs(30)) +/// .max_locked_msat(500_000) +/// .diversity_penalty_msat(250) +/// .build(); +/// +/// let mut builder = Builder::new(); +/// builder.set_probing_config(config); +/// # } +/// ``` +/// +/// Creating a custom strategy that always probes the same path: +/// ``` +/// use ldk_node::lightning::routing::router::Path; +/// use ldk_node::probing::ProbingStrategy; +/// +/// struct FixedPathStrategy { +/// path: Path, +/// } +/// impl ProbingStrategy for FixedPathStrategy { +/// fn next_probe(&self) -> Option { +/// if self.path.hops.len() > 1 { +/// Some(self.path.clone()) +/// } else { +/// None +/// } +/// } +/// } +/// ``` +#[derive(Clone, Debug)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] +pub struct ProbingConfig { + pub(crate) kind: ProbingStrategyKind, + pub(crate) interval: Duration, + pub(crate) max_locked_msat: u64, + pub(crate) diversity_penalty_msat: Option, + pub(crate) cooldown: Duration, +} + +/// Builder for [`ProbingConfig`]. +/// +/// A new instance starts from one of three strategy constructors — [`high_degree`], +/// [`random_walk`], or [`custom`] — and is finalized through [`build`]. Optional setters +/// in between override the timing and liquidity defaults. +/// +/// [`high_degree`]: Self::high_degree +/// [`random_walk`]: Self::random_walk +/// [`custom`]: Self::custom +/// [`build`]: Self::build +pub struct ProbingConfigBuilder { + kind: ProbingStrategyKind, + interval: Duration, + max_locked_msat: u64, + diversity_penalty_msat: Option, + cooldown: Duration, +} + +impl ProbingConfigBuilder { + fn with_kind(kind: ProbingStrategyKind) -> Self { + Self { + kind, + interval: Duration::from_secs(DEFAULT_PROBING_INTERVAL_SECS), + max_locked_msat: DEFAULT_MAX_PROBE_LOCKED_MSAT, + diversity_penalty_msat: None, + cooldown: Duration::from_secs(DEFAULT_PROBED_NODE_COOLDOWN_SECS), + } + } + + /// Start building a config that probes toward the highest-degree nodes in the graph. + /// + /// `top_node_count` controls how many of the most-connected nodes are cycled through. + pub fn high_degree(top_node_count: usize) -> Self { + Self::with_kind(ProbingStrategyKind::HighDegree { top_node_count }) + } + + /// Start building a config that probes via random graph walks. + /// + /// `max_hops` is the upper bound on the number of hops in a randomly constructed path. + /// Values below `2` are clamped to `2`. + pub fn random_walk(max_hops: usize) -> Self { + Self::with_kind(ProbingStrategyKind::RandomWalk { max_hops }) + } + + /// Start building a config with a custom [`ProbingStrategy`] implementation. + pub fn custom(strategy: Arc) -> Self { + Self::with_kind(ProbingStrategyKind::Custom(strategy)) + } + + /// Overrides the interval between probe attempts. + /// + /// Defaults to 10 seconds. + pub fn interval(&mut self, interval: Duration) -> &mut Self { + self.interval = interval; + self + } + + /// Overrides the maximum millisatoshis that may be locked in in-flight probes at any time. + /// + /// Defaults to 100 000 000 msat (100k sats). + pub fn max_locked_msat(&mut self, max_msat: u64) -> &mut Self { + self.max_locked_msat = max_msat; + self + } + + /// Sets the probing diversity penalty applied by the probabilistic scorer. + /// + /// When set, the scorer will penalize channels that have been recently probed, + /// encouraging path diversity during background probing. The penalty decays + /// quadratically over 24 hours. + /// + /// This is only useful for probing strategies that route through the scorer + /// (e.g., [`HighDegreeStrategy`]). Strategies that build paths manually + /// (e.g., [`RandomWalkStrategy`]) bypass the scorer entirely. + /// + /// If unset, LDK's default of `0` (no penalty) is used. + pub fn diversity_penalty_msat(&mut self, penalty_msat: u64) -> &mut Self { + self.diversity_penalty_msat = Some(penalty_msat); + self + } + + /// Sets how long a probed node stays ineligible before being probed again. + /// + /// Only applies to [`HighDegreeStrategy`]. Defaults to 1 hour. + pub fn cooldown(&mut self, cooldown: Duration) -> &mut Self { + self.cooldown = cooldown; + self + } + + /// Builds the [`ProbingConfig`]. + pub fn build(&self) -> ProbingConfig { + ProbingConfig { + kind: self.kind.clone(), + interval: self.interval.max(MIN_PROBING_INTERVAL), + max_locked_msat: self.max_locked_msat, + diversity_penalty_msat: self.diversity_penalty_msat, + cooldown: self.cooldown, + } + } +} + +/// Builder for [`ProbingConfig`]. +/// +/// A new instance starts from one of two strategy constructors — [`high_degree`] or +/// [`random_walk`] — and is finalized through [`build`]. Optional setters in between +/// override the timing and liquidity defaults. +/// +/// [`high_degree`]: Self::high_degree +/// [`random_walk`]: Self::random_walk +/// [`build`]: Self::build +#[cfg(feature = "uniffi")] +pub struct ArcedProbingConfigBuilder { + inner: RwLock, +} + +#[cfg(feature = "uniffi")] +impl ArcedProbingConfigBuilder { + /// Start building a config that probes toward the highest-degree nodes in the graph. + /// + /// `top_node_count` controls how many of the most-connected nodes are cycled through. + pub fn high_degree(top_node_count: u64) -> Self { + Self { inner: RwLock::new(ProbingConfigBuilder::high_degree(top_node_count as usize)) } + } + + /// Start building a config that probes via random graph walks. + /// + /// `max_hops` is the upper bound on the number of hops in a randomly constructed path. + /// Values below `2` are clamped to `2`. + pub fn random_walk(max_hops: u64) -> Self { + Self { inner: RwLock::new(ProbingConfigBuilder::random_walk(max_hops as usize)) } + } + + /// Overrides the interval between probe attempts. + /// + /// Defaults to 10 seconds. + pub fn set_interval(&self, secs: u64) { + self.inner.write().expect("lock").interval(Duration::from_secs(secs)); + } + + /// Overrides the maximum millisatoshis that may be locked in in-flight probes at any time. + /// + /// Defaults to 100 000 000 msat (100k sats). + pub fn set_max_locked_msat(&self, max_msat: u64) { + self.inner.write().expect("lock").max_locked_msat(max_msat); + } + + /// Sets the probing diversity penalty applied by the probabilistic scorer. + /// + /// When set, the scorer will penalize channels that have been recently probed, + /// encouraging path diversity during background probing. The penalty decays + /// quadratically over 24 hours. + /// + /// This is only useful for probing strategies that route through the scorer + /// (e.g., [`HighDegreeStrategy`]). Strategies that build paths manually + /// (e.g., [`RandomWalkStrategy`]) bypass the scorer entirely. + /// + /// If unset, LDK's default of `0` (no penalty) is used. + pub fn set_diversity_penalty_msat(&self, penalty_msat: u64) { + self.inner.write().expect("lock").diversity_penalty_msat(penalty_msat); + } + + /// Sets how long a probed node stays ineligible before being probed again. + /// + /// Only applies to [`HighDegreeStrategy`]. Defaults to 1 hour. + pub fn set_cooldown(&self, secs: u64) { + self.inner.write().expect("lock").cooldown(Duration::from_secs(secs)); + } + + /// Builds the [`ProbingConfig`]. + pub fn build(&self) -> Arc { + Arc::new(self.inner.read().expect("lock").build()) + } +} + +/// A strategy that decides which path the probing service should probe next. +pub trait ProbingStrategy: Send + Sync + 'static { + /// Returns the next probe path to run, or `None` to skip this tick. + fn next_probe(&self) -> Option; +} + +/// Probes toward the most-connected nodes in the graph. +/// +/// On each tick the strategy reads the current gossip graph, sorts nodes by +/// channel count, and picks the highest-degree node from the top +/// `top_node_count` that has not been probed within `cooldown`. +/// Nodes probed more recently are skipped so that the strategy +/// naturally spreads across the top nodes and picks up graph changes. +/// If all top nodes are on cooldown, the cooldown map is cleared and a new cycle begins +/// immediately. +/// +/// The probe amount is chosen uniformly at random from +/// `[min_amount_msat, max_amount_msat]`. +/// +/// `HighDegreeStrategy` can only use publicly announced channels for probing. +pub struct HighDegreeStrategy { + network_graph: Arc, + channel_manager: Arc, + router: Arc, + /// How many of the highest-degree nodes to cycle through. + pub top_node_count: usize, + /// Lower bound for the randomly chosen probe amount. + pub min_amount_msat: u64, + /// Upper bound for the randomly chosen probe amount. + pub max_amount_msat: u64, + /// How long a node stays ineligible after being probed. + pub cooldown: Duration, + /// Skip a path when the first-hop outbound liquidity is less than + /// `path_value * liquidity_limit_multiplier`. + pub liquidity_limit_multiplier: u64, + /// Nodes probed recently, with the time they were last probed. + recently_probed: Mutex>, +} + +impl HighDegreeStrategy { + /// Creates a new high-degree probing strategy. + pub(crate) fn new( + network_graph: Arc, channel_manager: Arc, router: Arc, + top_node_count: usize, min_amount_msat: u64, max_amount_msat: u64, cooldown: Duration, + liquidity_limit_multiplier: u64, + ) -> Self { + assert!( + min_amount_msat <= max_amount_msat, + "min_amount_msat must not exceed max_amount_msat" + ); + Self { + network_graph, + channel_manager, + router, + top_node_count, + min_amount_msat, + max_amount_msat, + cooldown, + liquidity_limit_multiplier, + recently_probed: Mutex::new(HashMap::new()), + } + } +} + +impl ProbingStrategy for HighDegreeStrategy { + fn next_probe(&self) -> Option { + let graph = self.network_graph.read_only(); + + let mut nodes_by_degree: Vec<(NodeId, usize)> = + graph.nodes().unordered_iter().map(|(id, info)| (*id, info.channels.len())).collect(); + + if nodes_by_degree.is_empty() { + return None; + } + + nodes_by_degree.sort_unstable_by(|a, b| b.1.cmp(&a.1)); + + let top_node_count = self.top_node_count.min(nodes_by_degree.len()); + let now = Instant::now(); + + let mut probed = self.recently_probed.lock().unwrap_or_else(|e| e.into_inner()); + + // We could check staleness when we use the entry, but that way we'd not clear cache at + // all. For hundreds of top nodes it's okay to call retain each tick. + probed.retain(|_, probed_at| now.duration_since(*probed_at) < self.cooldown); + + // If all top nodes are on cooldown, reset and start a new cycle. + let final_node_id = match nodes_by_degree[..top_node_count] + .iter() + .find(|(node_id, _)| !probed.contains_key(node_id)) + { + Some((node_id, _)) => *node_id, + None => { + probed.clear(); + nodes_by_degree[0].0 + }, + }; + + probed.insert(final_node_id, now); + drop(probed); + drop(graph); + + let final_node = PublicKey::try_from(final_node_id).ok()?; + + let amount_msat = random_range(self.min_amount_msat, self.max_amount_msat); + let payment_params = + PaymentParameters::from_node_id(final_node, DEFAULT_MIN_FINAL_CLTV_EXPIRY_DELTA as u32); + let route_params = + RouteParameters::from_payment_params_and_value(payment_params, amount_msat); + + let payer = self.channel_manager.get_our_node_id(); + let usable_channels = self.channel_manager.list_usable_channels(); + let first_hops: Vec<&_> = usable_channels.iter().collect(); + let inflight_htlcs = self.channel_manager.compute_inflight_htlcs(); + + let route = self + .router + .find_route(&payer, &route_params, Some(&first_hops), inflight_htlcs) + .ok()?; + + let path = route.paths.into_iter().next()?; + + if path.hops.len() < 2 && path.blinded_tail.is_none() { + return None; + } + + // Liquidity-limit check (mirrors send_preflight_probes): skip the path when the + // first-hop outbound liquidity is less than path_value * liquidity_limit_multiplier. + if let Some(first_hop_hop) = path.hops.first() { + if let Some(ch) = usable_channels + .iter() + .find(|h| h.get_outbound_payment_scid() == Some(first_hop_hop.short_channel_id)) + { + let path_value = path.final_value_msat() + path.fee_msat(); + if ch.next_outbound_htlc_limit_msat + < path_value.saturating_mul(self.liquidity_limit_multiplier) + { + return None; + } + } + } + + Some(path) + } +} + +/// Explores the graph by walking a random number (≥2) of hops outward from one of our own +/// channels, constructing the [`Path`] explicitly. +/// +/// On each tick: +/// 1. Picks one of our confirmed, usable channels to start from. +/// 2. Performs a random walk of a chosen depth (up to [`MAX_PATH_LENGTH_ESTIMATE`]) through the +/// gossip graph, skipping disabled channels and dead-ends. +/// +/// The probe amount is chosen uniformly at random from `[min_amount_msat, max_amount_msat]`. +/// +/// Because path selection ignores the scorer, this probes channels the router +/// would never try on its own, teaching the scorer about previously unknown paths. +/// +/// `RandomWalkStrategy` can only use publicly announced channels for probing. +pub struct RandomWalkStrategy { + network_graph: Arc, + channel_manager: Arc, + /// Upper bound on the number of hops in a randomly constructed path. + pub max_hops: usize, + /// Lower bound for the randomly chosen probe amount. + pub min_amount_msat: u64, + /// Upper bound for the randomly chosen probe amount. + pub max_amount_msat: u64, +} + +impl RandomWalkStrategy { + /// Creates a new random-walk probing strategy. + pub(crate) fn new( + network_graph: Arc, channel_manager: Arc, max_hops: usize, + min_amount_msat: u64, max_amount_msat: u64, + ) -> Self { + assert!( + min_amount_msat <= max_amount_msat, + "min_amount_msat must not exceed max_amount_msat" + ); + Self { + network_graph, + channel_manager, + max_hops: max_hops.clamp(2, MAX_PATH_LENGTH_ESTIMATE as usize), + min_amount_msat, + max_amount_msat, + } + } + + /// Tries to build a path of `target_hops` hops. Returns `None` if the local node has no + /// usable channels, or the walk terminates before reaching `target_hops`. + fn try_build_path(&self, target_hops: usize, amount_msat: u64) -> Option { + let initial_channels = self + .channel_manager + .list_channels() + .into_iter() + .filter(|c| c.is_usable && c.short_channel_id.is_some()) + .collect::>(); + + if initial_channels.is_empty() { + return None; + } + + let graph = self.network_graph.read_only(); + let first_hop = + &initial_channels[random_range(0, initial_channels.len() as u64 - 1) as usize]; + let first_hop_scid = first_hop.short_channel_id?; + let next_peer_pubkey = first_hop.counterparty.node_id; + let next_peer_node_id = NodeId::from_pubkey(&next_peer_pubkey); + + // Track the tightest HTLC limit across all hops to cap the probe amount. + // The first hop limit comes from our live channel state; subsequent hops use htlc_maximum_msat from the gossip channel update. + let mut route_least_htlc_upper_bound = first_hop.next_outbound_htlc_limit_msat; + let mut route_greatest_htlc_lower_bound = first_hop.next_outbound_htlc_minimum_msat; + + // Walk the graph: each entry is (node_id, arrived_via_scid, pubkey); first entry is set: + let mut route: Vec<(NodeId, u64, PublicKey)> = + vec![(next_peer_node_id, first_hop_scid, next_peer_pubkey)]; + + let mut prev_scid = first_hop_scid; + let mut current_node_id = next_peer_node_id; + + for _ in 1..target_hops { + let node_info = match graph.node(¤t_node_id) { + Some(n) => n, + None => break, + }; + + // Skip the edge we arrived on. Longer cycles aren't filtered — probes fail at + // the destination anyway, so revisiting nodes is harmless. + let candidates: Vec = + node_info.channels.iter().copied().filter(|&scid| scid != prev_scid).collect(); + + if candidates.is_empty() { + break; + } + + let next_scid = candidates[random_range(0, candidates.len() as u64 - 1) as usize]; + let next_channel = match graph.channel(next_scid) { + Some(c) => c, + None => break, + }; + + // as_directed_from validates that current_node_id is a channel endpoint and that + // both direction updates are present; effective_capacity covers both htlc_maximum_msat + // and funding capacity. + let Some((directed, next_node_id)) = next_channel.as_directed_from(¤t_node_id) + else { + break; + }; + // Retrieve the direction-specific update via the public ChannelInfo fields. + // as_directed_from already checked both directions are Some, but we break + // defensively rather than unwrap. + let update = match if directed.source() == &next_channel.node_one { + next_channel.one_to_two.as_ref() + } else { + next_channel.two_to_one.as_ref() + } { + Some(u) => u, + None => break, + }; + + if !update.enabled { + break; + } + + route_least_htlc_upper_bound = + route_least_htlc_upper_bound.min(update.htlc_maximum_msat); + + route_greatest_htlc_lower_bound = + route_greatest_htlc_lower_bound.max(update.htlc_minimum_msat); + + let next_pubkey = match PublicKey::try_from(*next_node_id) { + Ok(pk) => pk, + Err(_) => break, + }; + + route.push((*next_node_id, next_scid, next_pubkey)); + prev_scid = next_scid; + current_node_id = *next_node_id; + } + + if route_greatest_htlc_lower_bound > route_least_htlc_upper_bound { + return None; + } + let amount_msat = + amount_msat.max(route_greatest_htlc_lower_bound).min(route_least_htlc_upper_bound); + if amount_msat < self.min_amount_msat || amount_msat > self.max_amount_msat { + return None; + } + + // Assemble hops backwards so each hop's proportional fee is computed on the amount it actually forwards + let mut hops = Vec::with_capacity(route.len()); + let mut forwarded = amount_msat; + let last = route.len() - 1; + + // Resolve (node_features, channel_features, maybe_announced_channel) for a hop. + // The first hop is our local channel and may be unannounced, so its ChannelFeatures + // are not in the gossip graph — match on SCID to detect it and fall back to local-state + // defaults. All other (walked) hops were picked from the graph and must resolve there. + let hop_features = + |node_id: &NodeId, via_scid: u64| -> Option<(NodeFeatures, ChannelFeatures, bool)> { + let node_features = graph + .node(node_id) + .and_then(|n| n.announcement_info.as_ref().map(|a| a.features().clone())) + .unwrap_or_else(NodeFeatures::empty); + let (channel_features, maybe_announced_channel) = if via_scid == first_hop_scid { + (ChannelFeatures::empty(), false) + } else { + (graph.channel(via_scid)?.features.clone(), true) + }; + Some((node_features, channel_features, maybe_announced_channel)) + }; + + // Final hop: fee_msat carries the delivery amount; cltv_expiry_delta carries the + // destination's final CLTV (matching LDK's shifted-by-one RouteHop convention). + { + let (node_id, via_scid, pubkey) = route[last]; + let (node_features, channel_features, maybe_announced_channel) = + hop_features(&node_id, via_scid)?; + hops.push(RouteHop { + pubkey, + node_features, + short_channel_id: via_scid, + channel_features, + fee_msat: amount_msat, + cltv_expiry_delta: DEFAULT_MIN_FINAL_CLTV_EXPIRY_DELTA as u32, + maybe_announced_channel, + }); + } + + // Non-final hops, from second-to-last back to first. + for i in (0..last).rev() { + let (node_id, via_scid, pubkey) = route[i]; + let (node_features, channel_features, maybe_announced_channel) = + hop_features(&node_id, via_scid)?; + + let (_, next_scid, _) = route[i + 1]; + let next_channel = graph.channel(next_scid)?; + let (directed, _) = next_channel.as_directed_from(&node_id)?; + let update = match if directed.source() == &next_channel.node_one { + next_channel.one_to_two.as_ref() + } else { + next_channel.two_to_one.as_ref() + } { + Some(u) => u, + None => return None, + }; + let fee = update.fees.base_msat as u64 + + (forwarded * update.fees.proportional_millionths as u64 / 1_000_000); + forwarded += fee; + + hops.push(RouteHop { + pubkey, + node_features, + short_channel_id: via_scid, + channel_features, + fee_msat: fee, + cltv_expiry_delta: update.cltv_expiry_delta as u32, + maybe_announced_channel, + }); + } + + hops.reverse(); + + if hops.len() < 2 { + return None; + } + + // The first-hop HTLC carries amount_msat + all intermediate fees. + // Verify the total fits within our live outbound limit before returning. + let total_outgoing: u64 = hops.iter().map(|h| h.fee_msat).sum(); + if total_outgoing > first_hop.next_outbound_htlc_limit_msat { + return None; + } + + Some(Path { hops, blinded_tail: None }) + } +} + +impl ProbingStrategy for RandomWalkStrategy { + fn next_probe(&self) -> Option { + let target_hops = random_range(2, self.max_hops as u64) as usize; + let amount_msat = random_range(self.min_amount_msat, self.max_amount_msat); + + self.try_build_path(target_hops, amount_msat) + } +} + +/// Periodically dispatches probes according to a [`ProbingStrategy`]. +pub struct Prober { + pub(crate) channel_manager: Arc, + pub(crate) logger: Arc, + /// The strategy that decides what to probe. + pub strategy: Arc, + /// How often to fire a probe attempt. + pub interval: Duration, + /// Maximum total millisatoshis that may be locked in in-flight probes at any time. + pub max_locked_msat: u64, +} + +fn fmt_path(path: &lightning::routing::router::Path) -> String { + path.hops + .iter() + .map(|h| format!("{}(scid={})", h.pubkey, h.short_channel_id)) + .collect::>() + .join(" -> ") +} + +impl Prober { + /// Returns the total millisatoshis currently locked in in-flight probes. + pub fn locked_msat(&self) -> u64 { + return self + .channel_manager + .list_recent_payments() + .into_iter() + .filter_map(|p| match p { + RecentPaymentDetails::Pending { + is_probe: true, + total_msat, + pending_fee_msat, + .. + } => Some(total_msat + pending_fee_msat.unwrap_or(0)), + _ => None, + }) + .sum(); + } + + pub(crate) fn handle_background_probe_successful(&self, path: &Path, payment_id: PaymentId) { + log_debug!( + self.logger, + "Background probe with payment_id: {} succeeded along the path: {}", + payment_id, + fmt_path(path) + ); + } + + pub(crate) fn handle_background_probe_failed(&self, path: &Path, payment_id: PaymentId) { + log_debug!( + self.logger, + "Background probe with payment_id: {} failed along the path: {}", + payment_id, + fmt_path(path) + ); + } +} + +/// Runs the probing loop for the given [`Prober`] until `stop_rx` fires. +pub(crate) async fn run_prober(prober: Arc, mut stop_rx: tokio::sync::watch::Receiver<()>) { + let mut ticker = tokio::time::interval(prober.interval); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + tokio::select! { + biased; + _ = stop_rx.changed() => { + log_debug!(prober.logger, "Stopping background probing."); + return; + } + _ = ticker.tick() => { + let path = match prober.strategy.next_probe() { + Some(p) => p, + None => continue, + }; + let amount: u64 = path.hops.iter().map(|h| h.fee_msat).sum(); + if prober.locked_msat() + amount > prober.max_locked_msat { + log_debug!(prober.logger, "Skipping probe: locked-msat budget exceeded."); + continue; + } + match prober.channel_manager.send_probe(path.clone()) { + Ok((_, payment_id)) => { + log_debug!( + prober.logger, + "Background probe with payment_id {} sent: locked {} msat, path: {}", + payment_id, + amount, + fmt_path(&path) + ); + } + Err(e) => { + log_debug!( + prober.logger, + "Background probe send failed: {:?}, path: {}", + e, + fmt_path(&path) + ); + } + } + } + } + } +} 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..caa86ce7e2 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -6,21 +6,111 @@ // 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 lightning::chain::chaininterface::{ + BroadcasterInterface, TransactionType as LdkTransactionType, +}; 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; +// Bumped from 50 to 500 because LDK's onchain claim-bump logic floods the +// queue with rebroadcasts of stuck force-close commitment TXs (each new +// block triggers another retry). Once the queue fills up, new broadcasts — +// including one-shot sweep/funding TXs the onboarding flow depends on — +// are silently dropped with `try_send` returning `Full`. 500 is generous +// enough that legitimate sweep/funding broadcasts always make it through +// even when an old monitor's commitment-tx is stuck looping against +// bitcoind's "Transaction outputs already in utxo set" rejection. +const BCAST_PACKAGE_QUEUE_SIZE: usize = 500; + +/// 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, Option)>); + +impl BroadcastPackage { + /// Builds a package from the transactions of a single `broadcast_transactions` call. + fn new(txs: &[(&Transaction, LdkTransactionType)]) -> Self { + Self(txs.iter().map(|(tx, tx_type)| ((*tx).clone(), Some(tx_type.clone()))).collect()) + } + + /// Builds a package for wallet-originated broadcasts that have no LDK classification. + fn unclassified(tx: Transaction) -> Self { + Self(vec![(tx, None)]) + } + + /// The packaged transactions and their types, for classification. + fn transactions(&self) -> &[(Transaction, Option)] { + &self.0 + } + + /// Consumes the package into its transactions, ready for the chain client. + pub(crate) fn into_sorted_transactions(self) -> SortedTransactions { + let txs = self.0.into_iter().map(|(tx, _)| tx).collect(); + SortedTransactions::sort_parents_child_package_topologically(txs) + } +} + +pub(crate) struct SortedTransactions(Vec); + +impl SortedTransactions { + pub(crate) fn sort_parents_child_package_topologically( + mut txs: Vec, + ) -> SortedTransactions { + if txs.len() == 0 || txs.len() == 1 { + return SortedTransactions(txs); + } + let txids: Vec<_> = txs.iter().map(|tx| tx.compute_txid()).collect(); + let any_spends_from_package = |tx: &Transaction| -> bool { + tx.input.iter().any(|input| txids.contains(&input.previous_output.txid)) + }; + txs.sort_by_key(any_spends_from_package); + + #[cfg(debug_assertions)] + { + let child = txs.last().expect("txs is not empty"); + let child_input_txids: Vec<_> = + child.input.iter().map(|input| input.previous_output.txid).collect(); + let parents = &txs[..txs.len() - 1]; + let parent_txids: Vec<_> = parents.iter().map(|parent| parent.compute_txid()).collect(); + // Make sure all the parent txids are parents of the child transaction + debug_assert!(parent_txids.iter().all(|txid| child_input_txids.contains(&txid))); + // Make sure there are no grandparents + debug_assert_eq!(txs.iter().filter(|tx| any_spends_from_package(tx)).count(), 1); + } + + SortedTransactions(txs) + } + + pub(crate) fn into_inner(self) -> Vec { + self.0 + } +} + +impl Deref for SortedTransactions { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} 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,24 +120,225 @@ 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() { + if let Some(tx_type) = tx_type { + wallet.classify_broadcast(tx, tx_type).await?; + } + } + } + Ok(package) + } + + pub(crate) fn broadcast_unclassified_transaction(&self, tx: Transaction) { + self.queue_sender.try_send(BroadcastPackage::unclassified(tx)).unwrap_or_else(|e| { + log_error!(self.logger, "Failed to broadcast transactions: {}", e); + }); + } + + /// Enqueues a single fully-signed transaction for broadcast (swaps B4). + /// + /// A swap transaction isn't tied to any LDK channel, so none of + /// [`LdkTransactionType`]'s variants (all of which carry a channel/counterparty + /// identity) describe it. Like [`Self::broadcast_unclassified_transaction`], it + /// enqueues directly as an unclassified package onto the bounded broadcast queue + /// drained by the chain source's `process_broadcast_queue` loop, skipping the + /// [`BroadcasterInterface::broadcast_transactions`] classification path below. The + /// actual network send happens there, so this returns immediately and does not + /// confirm acceptance by the backend. + #[cfg(feature = "swaps")] + pub(crate) fn broadcast_tx(&self, tx: &Transaction) { + self.queue_sender.try_send(BroadcastPackage::unclassified(tx.clone())).unwrap_or_else( + |e| { + log_error!(self.logger, "Failed to broadcast transactions: {}", e); + }, + ); + } } impl BroadcasterInterface for TransactionBroadcaster 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| { + fn broadcast_transactions(&self, txs: &[(&Transaction, LdkTransactionType)]) { + self.queue_sender.try_send(BroadcastPackage::new(txs)).unwrap_or_else(|e| { log_error!(self.logger, "Failed to broadcast transactions: {}", e); }); } } + +#[cfg(test)] +mod tests { + use bitcoin::hashes::Hash; + use bitcoin::{Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness}; + + use super::SortedTransactions; + + fn txin(txid: Txid, vout: u32) -> TxIn { + TxIn { + previous_output: OutPoint { txid, vout }, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + } + } + + fn txout(value_sat: u64) -> TxOut { + TxOut { value: Amount::from_sat(value_sat), script_pubkey: ScriptBuf::new() } + } + + fn parent_tx(seed: u8) -> Transaction { + Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![txin(Txid::from_byte_array([seed; 32]), 0)], + output: vec![txout(1_000 + u64::from(seed))], + } + } + + fn child_tx(parents: &[&Transaction]) -> Transaction { + Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: parents + .iter() + .enumerate() + .map(|(idx, parent)| txin(parent.compute_txid(), idx as u32)) + .collect(), + output: vec![txout(1_000)], + } + } + + fn assert_parents_before_child( + txs: &[Transaction], expected_child: Txid, expected_parents: &[Txid], + ) { + assert_eq!(txs.last().map(Transaction::compute_txid), Some(expected_child)); + assert_eq!(txs.len(), expected_parents.len() + 1); + + let parent_txids = + txs[..txs.len() - 1].iter().map(Transaction::compute_txid).collect::>(); + for expected_parent in expected_parents { + assert!(parent_txids.contains(expected_parent)); + } + } + + #[test] + fn topological_sort_leaves_sorted_package_unchanged() { + let parent_a = parent_tx(1); + let parent_b = parent_tx(2); + let child = child_tx(&[&parent_a, &parent_b]); + + let original_txids = + [parent_a.compute_txid(), parent_b.compute_txid(), child.compute_txid()]; + let txs = vec![parent_a, parent_b, child]; + + let package = SortedTransactions::sort_parents_child_package_topologically(txs); + + assert_eq!( + package.iter().map(Transaction::compute_txid).collect::>(), + original_txids + ); + } + + #[test] + fn topological_sort_moves_single_parent_child_from_front_to_end() { + let parent = parent_tx(1); + let child = child_tx(&[&parent]); + let parent_txids = [parent.compute_txid()]; + let child_txid = child.compute_txid(); + let txs = vec![child, parent]; + + let package = SortedTransactions::sort_parents_child_package_topologically(txs); + + assert_parents_before_child(&package, child_txid, &parent_txids); + } + + #[test] + fn topological_sort_moves_child_from_front_to_end() { + let parent_a = parent_tx(1); + let parent_b = parent_tx(2); + let child = child_tx(&[&parent_a, &parent_b]); + let parent_txids = [parent_a.compute_txid(), parent_b.compute_txid()]; + let child_txid = child.compute_txid(); + let txs = vec![child, parent_a, parent_b]; + + let package = SortedTransactions::sort_parents_child_package_topologically(txs); + + assert_parents_before_child(&package, child_txid, &parent_txids); + } + + #[test] + fn topological_sort_moves_child_from_front_with_multiple_parents_to_end() { + let parent_a = parent_tx(1); + let parent_b = parent_tx(2); + let parent_c = parent_tx(3); + let child = child_tx(&[&parent_a, &parent_b, &parent_c]); + let parent_txids = + [parent_a.compute_txid(), parent_b.compute_txid(), parent_c.compute_txid()]; + let child_txid = child.compute_txid(); + let txs = vec![child, parent_a, parent_b, parent_c]; + + let package = SortedTransactions::sort_parents_child_package_topologically(txs); + + assert_parents_before_child(&package, child_txid, &parent_txids); + } + + #[test] + fn topological_sort_moves_child_from_middle_to_end() { + let parent_a = parent_tx(1); + let parent_b = parent_tx(2); + let child = child_tx(&[&parent_a, &parent_b]); + let parent_txids = [parent_a.compute_txid(), parent_b.compute_txid()]; + let child_txid = child.compute_txid(); + let txs = vec![parent_a, child, parent_b]; + + let package = SortedTransactions::sort_parents_child_package_topologically(txs); + + assert_parents_before_child(&package, child_txid, &parent_txids); + } + + #[test] + fn topological_sort_leaves_single_transaction_package_unchanged() { + let parent = parent_tx(1); + let parent_txid = parent.compute_txid(); + let txs = vec![parent]; + + let package = SortedTransactions::sort_parents_child_package_topologically(txs); + + assert_eq!(package.len(), 1); + assert_eq!(package[0].compute_txid(), parent_txid); + } + + #[test] + fn topological_sort_accepts_empty_vec() { + SortedTransactions::sort_parents_child_package_topologically(Vec::new()); + } +} diff --git a/src/types.rs b/src/types.rs index 64209430be..5552877ef8 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,44 @@ 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: &AnchorChannelsConfig, + ) -> Self { + let reserve_type = value.channel_type.as_ref().map(|channel_type| { + if crate::requires_anchor_channel_type(channel_type) { + if anchor_channels_config + .trusted_peers_no_reserve + .contains(&value.counterparty.node_id) + { + ReserveType::TrustedPeersNoReserve + } else { + 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 +693,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 +705,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/util.rs b/src/util.rs new file mode 100644 index 0000000000..8cd86665a2 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,92 @@ +// 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. + +//! Miscellaneous pure helper functions. + +use bitcoin::constants::SUBSIDY_HALVING_INTERVAL; +use bitcoin::{Amount, Block, FeeRate}; + +use crate::fee_estimator::{get_num_block_defaults_for_target, ConfirmationTarget}; + +/// Block subsidy at the given height (approximate on regtest). +pub(crate) fn block_subsidy(height: u32) -> Amount { + let halvings = height / SUBSIDY_HALVING_INTERVAL; + if halvings >= 64 { + return Amount::ZERO; + } + Amount::from_sat((Amount::ONE_BTC.to_sat() * 50) >> halvings) +} + +/// Average fee rate of a block, derived from its coinbase: `(coinbase output total - subsidy) / +/// weight`. Lets us compute the fee rate of a block we already hold without a re-download. +pub(crate) fn coinbase_fee_rate(block: &Block, height: u32) -> FeeRate { + let revenue: Amount = block + .txdata + .first() + .map(|coinbase| coinbase.output.iter().map(|txout| txout.value).sum()) + .unwrap_or(Amount::ZERO); + let block_fees = revenue.checked_sub(block_subsidy(height)).unwrap_or(Amount::ZERO); + let fee_rate = block_fees.to_sat().checked_div(block.weight().to_kwu_floor()).unwrap_or(0); + FeeRate::from_sat_per_kwu(fee_rate) +} + +/// Maps a confirmation target to the percentile of the recent-block fee-rate window we read for it. +/// +/// More urgent targets (shorter confirmation horizon) read a higher percentile; relaxed targets +/// read a lower one. This is a coarse stand-in for the per-horizon estimates a mempool-aware +/// backend would provide. +pub(crate) fn cbf_percentile_for_target(target: ConfirmationTarget) -> f64 { + match get_num_block_defaults_for_target(target) { + 0..=2 => 90.0, + 3..=6 => 75.0, + 7..=12 => 50.0, + 13..=144 => 25.0, + _ => 10.0, + } +} + +/// Returns the value at the given percentile of an ascending-sorted slice using nearest-rank. +/// Returns `0` for an empty slice. +pub(crate) fn percentile_of_sorted(sorted: &[u64], percentile: f64) -> u64 { + if sorted.is_empty() { + return 0; + } + let rank = ((percentile / 100.0) * sorted.len() as f64).ceil() as usize; + let idx = rank.saturating_sub(1).min(sorted.len() - 1); + sorted[idx] +} + +/// Returns a random `u64` uniformly distributed in `[min, max]` (inclusive). +pub(crate) fn random_range(min: u64, max: u64) -> u64 { + debug_assert!(min <= max); + if min == max { + return min; + } + let range = match (max - min).checked_add(1) { + Some(r) => r, + None => { + // overflowed — full u64::MAX range + let mut buf = [0u8; 8]; + getrandom::fill(&mut buf).expect("getrandom failed"); + return u64::from_ne_bytes(buf); + }, + }; + // We remove bias due to the fact that the range does not evenly divide 2⁶⁴. + // Imagine we had a range from 0 to 2⁶⁴-2 (of length 2⁶⁴-1), then + // the outcomes of 0 would be twice as frequent as any other, as 0 can be produced + // as randomly drawn 0 % 2⁶⁴-1 and as well as 2⁶⁴-1 % 2⁶⁴-1 + let limit = u64::MAX - (u64::MAX % range); + loop { + let mut buf = [0u8; 8]; + getrandom::fill(&mut buf).expect("getrandom failed"); + let val = u64::from_ne_bytes(buf); + if val < limit { + return min + (val % range); + } + // loop runs ~1 iteration on average, in worst case it's ~2 iterations on average + } +} diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 76f2aa9ce6..98199270dc 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, Network, OutPoint, ScriptBuf, SignedAmount, Transaction, TxOut, Txid, + WPubkeyHash, Weight, WitnessProgram, WitnessVersion, }; use lightning::chain::chaininterface::{ - BroadcasterInterface, INCREMENTAL_RELAY_FEE_SAT_PER_1000_WEIGHT, + 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, @@ -51,12 +53,18 @@ use lightning::util::wallet_utils::{ use lightning_invoice::RawBolt11Invoice; use persist::KVStoreWalletPersister; +#[cfg(feature = "swaps")] +use bitcoin::bip32::{ChildNumber, Xpriv}; +#[cfg(feature = "swaps")] +use bitcoin::secp256k1::Keypair; + use crate::config::Config; 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}; @@ -81,7 +89,7 @@ const DUST_LIMIT_SATS: u64 = 546; pub(crate) struct Wallet { // A BDK on-chain wallet. inner: Mutex>, - persister: Mutex, + persister: tokio::sync::Mutex, broadcaster: Arc, fee_estimator: Arc, chain_source: Arc, @@ -101,7 +109,7 @@ impl Wallet { logger: Arc, pending_payment_store: Arc, ) -> Self { let inner = Mutex::new(wallet); - let persister = Mutex::new(wallet_persister); + let persister = tokio::sync::Mutex::new(wallet_persister); Self { inner, persister, @@ -138,6 +146,21 @@ impl Wallet { .collect() } + /// The full transaction behind `txid` if the wallet holds it and still sees it unconfirmed + /// (the only kind worth rebroadcasting); `None` for unknown or confirmed transactions. + pub(crate) fn get_unconfirmed_transaction(&self, txid: &Txid) -> Option { + self.inner + .lock() + .expect("lock") + .get_tx(*txid) + .filter(|t| t.chain_position.is_unconfirmed()) + .map(|t| (*t.tx_node.tx).clone()) + } + + pub(crate) fn latest_checkpoint(&self) -> bdk_chain::local_chain::CheckPoint { + self.inner.lock().expect("lock").latest_checkpoint() + } + pub(crate) fn current_best_block(&self) -> BlockLocator { let checkpoint = self.inner.lock().expect("lock").latest_checkpoint(); let mut current_block = Some(checkpoint.clone()); @@ -151,72 +174,57 @@ impl Wallet { BlockLocator { block_hash: checkpoint.hash(), height: checkpoint.height(), previous_blocks } } - pub(crate) fn apply_update(&self, update: impl Into) -> Result<(), Error> { - let mut locked_wallet = self.inner.lock().expect("lock"); - match locked_wallet.apply_update_events(update) { - Ok(events) => { - self.update_payment_store(&mut *locked_wallet, events).map_err(|e| { - log_error!(self.logger, "Failed to update payment store: {}", e); - Error::PersistenceFailed - })?; - - let mut locked_persister = self.persister.lock().expect("lock"); - 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 - }, - )?; + pub(crate) async fn apply_update(&self, update: impl Into) -> Result<(), Error> { + let mut locked_persister = self.persister.lock().await; + let events = { + let mut locked_wallet = self.inner.lock().expect("lock"); + match locked_wallet.apply_update_events(update) { + Ok(events) => events, + Err(e) => { + log_error!(self.logger, "Sync failed due to chain connection error: {}", e); + return Err(Error::WalletOperationFailed); + }, + } + }; + self.update_payment_store(events).await.map_err(|e| { + log_error!(self.logger, "Failed to update payment store: {}", e); + Error::PersistenceFailed + })?; - Ok(()) - }, - Err(e) => { - log_error!(self.logger, "Sync failed due to chain connection error: {}", e); - Err(Error::WalletOperationFailed) - }, - } + let change_set = self.inner.lock().expect("lock").take_staged().unwrap_or_default(); + locked_persister.persist_changeset(change_set).await.map_err(|e| { + log_error!(self.logger, "Failed to persist wallet: {}", e); + Error::PersistenceFailed + })?; + Ok(()) } - pub(crate) fn apply_mempool_txs( + pub(crate) async fn apply_mempool_txs( &self, unconfirmed_txs: Vec<(Transaction, u64)>, evicted_txids: Vec<(Txid, u64)>, ) -> Result<(), Error> { if unconfirmed_txs.is_empty() && evicted_txids.is_empty() { return Ok(()); } - 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 mut locked_persister = self.persister.lock().await; + let events = { + let mut locked_wallet = self.inner.lock().expect("lock"); + 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| { + self.update_payment_store(events).await.map_err(|e| { log_error!(self.logger, "Failed to update payment store: {}", e); Error::PersistenceFailed })?; - let mut locked_persister = self.persister.lock().expect("lock"); - self.runtime.block_on(locked_wallet.persist_async(&mut locked_persister)).map_err(|e| { + let change_set = self.inner.lock().expect("lock").take_staged().unwrap_or_default(); + locked_persister.persist_changeset(change_set).await.map_err(|e| { log_error!(self.logger, "Failed to persist wallet: {}", e); Error::PersistenceFailed })?; @@ -224,10 +232,31 @@ impl Wallet { Ok(()) } - fn update_payment_store<'a>( - &self, locked_wallet: &'a mut PersistedWallet, - mut events: Vec, - ) -> Result<(), Error> { + /// Returns every script pubkey the wallet is watching for on-chain activity: all revealed + /// SPKs plus the lookahead window BDK derives beyond the last revealed index on each keychain. + /// A block may pay an address we have not explicitly revealed yet (e.g. on recovery, where a fresh + /// wallet has revealed nothing) but which is still within the gap limit. + pub(crate) fn list_watched_scripts(&self) -> Vec { + self.inner.lock().expect("lock").spk_index().inner().all_spks().values().cloned().collect() + } + + /// Defers persistence of the wallet's chain tip while a bulk chain sync is running. + /// + /// See `KVStoreWalletPersister::set_defer_local_chain` for why only the chain is deferred. + /// Callers must pair this with [`Self::flush_chain_persistence`]; nothing flushes implicitly. + pub(crate) async fn set_bulk_chain_persistence(&self, enabled: bool) { + self.persister.lock().await.set_defer_local_chain(enabled); + } + + /// Persists any chain state deferred by [`Self::set_bulk_chain_persistence`]. + pub(crate) async fn flush_chain_persistence(&self) -> Result<(), Error> { + self.persister.lock().await.flush_local_chain().await.map_err(|e| { + log_error!(self.logger, "Failed to flush deferred on-chain wallet chain state: {}", e); + Error::PersistenceFailed + }) + } + + async fn update_payment_store(&self, mut events: Vec) -> Result<(), Error> { if events.is_empty() { return Ok(()); } @@ -255,7 +284,7 @@ impl Wallet { for event in events { match event { WalletEvent::TxConfirmed { txid, tx, block_time, .. } => { - let cur_height = locked_wallet.latest_checkpoint().height(); + let cur_height = self.inner.lock().expect("lock").latest_checkpoint().height(); let confirmation_height = block_time.block_id.height; let payment_status = if cur_height >= confirmation_height + ANTI_REORG_DELAY - 1 { @@ -274,24 +303,32 @@ impl Wallet { .find_payment_by_txid(txid) .unwrap_or_else(|| PaymentId(txid.to_byte_array())); - let payment = self.create_payment_from_tx( - locked_wallet, - txid, - payment_id, - &tx, - payment_status, - confirmation_status, - ); + if self + .apply_funding_status_update(payment_id, txid, confirmation_status) + .await? + { + continue; + } - self.runtime.block_on(self.payment_store.insert_or_update(payment.clone()))?; + let payment = { + let locked_wallet = self.inner.lock().expect("lock"); + self.create_payment_from_tx( + &locked_wallet, + txid, + payment_id, + &tx, + payment_status, + confirmation_status, + ) + }; + + self.payment_store.insert_or_update(payment.clone()).await?; if payment_status == PaymentStatus::Pending { let pending_payment = self.create_pending_payment_from_tx(payment, Vec::new()); - self.runtime.block_on( - self.pending_payment_store.insert_or_update(pending_payment), - )?; + self.pending_payment_store.insert_or_update(pending_payment).await?; } }, WalletEvent::ChainTipChanged { new_tip, .. } => { @@ -317,16 +354,14 @@ impl Wallet { let payment_id = payment.details.id; if new_tip.height >= height + ANTI_REORG_DELAY - 1 { payment.details.status = PaymentStatus::Succeeded; - self.runtime.block_on( - self.payment_store.insert_or_update(payment.details), - )?; - self.runtime - .block_on(self.pending_payment_store.remove(&payment_id))?; + self.payment_store.insert_or_update(payment.details).await?; + self.pending_payment_store.remove(&payment_id).await?; } }, PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, + .. } if payment.details.direction == PaymentDirection::Outbound => { unconfirmed_outbound_txids.push(txid); }, @@ -335,51 +370,62 @@ impl Wallet { } if !unconfirmed_outbound_txids.is_empty() { - let txs_to_broadcast: Vec = unconfirmed_outbound_txids - .iter() - .filter_map(|txid| { - locked_wallet.tx_details(*txid).map(|d| (*d.tx).clone()) - }) - .collect(); + let txs_to_broadcast: Vec = { + let locked_wallet = self.inner.lock().expect("lock"); + unconfirmed_outbound_txids + .iter() + .filter_map(|txid| { + locked_wallet + .get_tx(*txid) + .map(|tx| tx.tx_node.tx.as_ref().clone()) + }) + .collect() + }; if !txs_to_broadcast.is_empty() { - let tx_refs: Vec<( - &Transaction, - lightning::chain::chaininterface::TransactionType, - )> = - txs_to_broadcast - .iter() - .map(|tx| { - (tx, lightning::chain::chaininterface::TransactionType::Sweep { channels: vec![] }) - }) - .collect(); - self.broadcaster.broadcast_transactions(&tx_refs); + let tx_count = txs_to_broadcast.len(); + for tx in txs_to_broadcast { + self.broadcaster.broadcast_unclassified_transaction(tx); + } log_info!( self.logger, "Rebroadcast {} unconfirmed transactions on chain tip change", - txs_to_broadcast.len() + tx_count ); } } }, - 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())); - let payment = self.create_payment_from_tx( - locked_wallet, - txid, - payment_id, - &tx, - PaymentStatus::Pending, - ConfirmationStatus::Unconfirmed, - ); + if self + .apply_funding_status_update( + payment_id, + txid, + ConfirmationStatus::Unconfirmed, + ) + .await? + { + continue; + } + + let payment = { + let locked_wallet = self.inner.lock().expect("lock"); + self.create_payment_from_tx( + &locked_wallet, + txid, + payment_id, + &tx, + PaymentStatus::Pending, + ConfirmationStatus::Unconfirmed, + ) + }; let pending_payment = self.create_pending_payment_from_tx(payment.clone(), Vec::new()); - self.runtime.block_on(self.payment_store.insert_or_update(payment))?; - self.runtime - .block_on(self.pending_payment_store.insert_or_update(pending_payment))?; + self.payment_store.insert_or_update(payment).await?; + self.pending_payment_store.insert_or_update(pending_payment).await?; }, WalletEvent::TxReplaced { txid, conflicts, .. } => { let Some(payment_id) = self.find_payment_by_txid(txid) else { @@ -409,27 +455,39 @@ impl Wallet { let pending_payment_details = self.create_pending_payment_from_tx(payment, conflict_txids.clone()); - self.runtime.block_on( - self.pending_payment_store.insert_or_update(pending_payment_details), - )?; + self.pending_payment_store.insert_or_update(pending_payment_details).await?; }, WalletEvent::TxDropped { txid, tx } => { let payment_id = self .find_payment_by_txid(txid) .unwrap_or_else(|| PaymentId(txid.to_byte_array())); - let payment = self.create_payment_from_tx( - locked_wallet, - txid, - payment_id, - &tx, - PaymentStatus::Pending, - ConfirmationStatus::Unconfirmed, - ); + + if self + .apply_funding_status_update( + payment_id, + txid, + ConfirmationStatus::Unconfirmed, + ) + .await? + { + continue; + } + + let payment = { + let locked_wallet = self.inner.lock().expect("lock"); + self.create_payment_from_tx( + &locked_wallet, + txid, + payment_id, + &tx, + PaymentStatus::Pending, + ConfirmationStatus::Unconfirmed, + ) + }; let pending_payment = self.create_pending_payment_from_tx(payment.clone(), Vec::new()); - self.runtime.block_on(self.payment_store.insert_or_update(payment))?; - self.runtime - .block_on(self.pending_payment_store.insert_or_update(pending_payment))?; + self.payment_store.insert_or_update(payment).await?; + self.pending_payment_store.insert_or_update(pending_payment).await?; }, _ => { continue; @@ -441,42 +499,43 @@ impl Wallet { } #[allow(deprecated)] - pub(crate) fn create_funding_transaction( + pub(crate) async fn create_funding_transaction( &self, output_script: ScriptBuf, amount: Amount, confirmation_target: ConfirmationTarget, locktime: LockTime, ) -> Result { let fee_rate = self.fee_estimator.estimate_fee_rate(confirmation_target); + let mut locked_persister = self.persister.lock().await; + let (psbt, change_set) = { + let mut locked_wallet = self.inner.lock().expect("lock"); + let mut tx_builder = locked_wallet.build_tx(); + tx_builder.add_recipient(output_script, amount).fee_rate(fee_rate).nlocktime(locktime); - let mut locked_wallet = self.inner.lock().expect("lock"); - let mut tx_builder = locked_wallet.build_tx(); + let mut psbt = match tx_builder.finish() { + Ok(psbt) => { + log_trace!(self.logger, "Created funding PSBT: {:?}", psbt); + psbt + }, + Err(err) => { + log_error!(self.logger, "Failed to create funding transaction: {}", err); + return Err(err.into()); + }, + }; - tx_builder.add_recipient(output_script, amount).fee_rate(fee_rate).nlocktime(locktime); + match locked_wallet.sign(&mut psbt, SignOptions::default()) { + Ok(finalized) => { + if !finalized { + return Err(Error::OnchainTxCreationFailed); + } + }, + Err(err) => { + log_error!(self.logger, "Failed to create funding transaction: {}", err); + return Err(err.into()); + }, + } - let mut psbt = match tx_builder.finish() { - Ok(psbt) => { - log_trace!(self.logger, "Created funding PSBT: {:?}", psbt); - psbt - }, - Err(err) => { - log_error!(self.logger, "Failed to create funding transaction: {}", err); - return Err(err.into()); - }, + (psbt, locked_wallet.take_staged().unwrap_or_default()) }; - - match locked_wallet.sign(&mut psbt, SignOptions::default()) { - Ok(finalized) => { - if !finalized { - return Err(Error::OnchainTxCreationFailed); - } - }, - Err(err) => { - log_error!(self.logger, "Failed to create funding transaction: {}", err); - return Err(err.into()); - }, - } - - let mut locked_persister = self.persister.lock().expect("lock"); - self.runtime.block_on(locked_wallet.persist_async(&mut locked_persister)).map_err(|e| { + locked_persister.persist_changeset(change_set).await.map_err(|e| { log_error!(self.logger, "Failed to persist wallet: {}", e); Error::PersistenceFailed })?; @@ -489,36 +548,83 @@ impl Wallet { Ok(tx) } - pub(crate) fn get_new_address(&self) -> Result { - let mut locked_wallet = self.inner.lock().expect("lock"); - let mut locked_persister = self.persister.lock().expect("lock"); - - let address_info = locked_wallet.reveal_next_address(KeychainKind::External); - self.runtime.block_on(locked_wallet.persist_async(&mut locked_persister)).map_err(|e| { + pub(crate) async fn get_new_address(&self) -> Result { + let mut locked_persister = self.persister.lock().await; + let (address_info, change_set) = { + let mut locked_wallet = self.inner.lock().expect("lock"); + let address_info = locked_wallet.reveal_next_address(KeychainKind::External); + (address_info, locked_wallet.take_staged().unwrap_or_default()) + }; + locked_persister.persist_changeset(change_set).await.map_err(|e| { log_error!(self.logger, "Failed to persist wallet: {}", e); Error::PersistenceFailed })?; Ok(address_info.address) } - pub(crate) fn get_new_internal_address(&self) -> Result { - let mut locked_wallet = self.inner.lock().expect("lock"); - let mut locked_persister = self.persister.lock().expect("lock"); + /// Builds a fully-signed funding transaction paying `amount` to an arbitrary `output_script` + /// (e.g. a P2WSH submarine-swap HTLC output) at the fee rate implied by `confirmation_target`, + /// with the supplied `locktime`. The returned [`Transaction`] is signed and persisted but **not** + /// broadcast. + /// + /// This is a thin swaps-gated wrapper over [`Wallet::create_funding_transaction`]; it does not + /// alter the existing behaviour of that method in any way. + #[cfg(feature = "swaps")] + pub(crate) fn create_swap_funding_tx( + &self, output_script: ScriptBuf, amount: Amount, confirmation_target: ConfirmationTarget, + locktime: LockTime, + ) -> Result { + self.runtime.block_on(self.create_funding_transaction( + output_script, + amount, + confirmation_target, + locktime, + )) + } + + /// Lists the wallet's confirmed, unspent outputs as [`Utxo`]s. + /// + /// This is a thin swaps-gated inherent wrapper over the [`WalletSource::list_confirmed_utxos`] + /// trait method. Unlike the trait method (whose error type is `()`), it surfaces a real + /// [`Error`] so swap call sites get a meaningful failure value. + #[cfg(feature = "swaps")] + pub(crate) fn swap_list_confirmed_utxos(&self) -> Result, Error> { + self.list_confirmed_utxos_inner().map_err(|()| Error::WalletOperationFailed) + } - let address_info = locked_wallet.next_unused_address(KeychainKind::Internal); - self.runtime.block_on(locked_wallet.persist_async(&mut locked_persister)).map_err(|e| { + /// Signs a PSBT with the BDK wallet, returning the extracted [`Transaction`]. + /// + /// This is a thin swaps-gated inherent wrapper over the [`WalletSource::sign_psbt`] trait + /// method. Unlike the trait method (whose error type is `()`), it surfaces a real [`Error`] so + /// swap call sites get a meaningful failure value. As with the trait method, LDK-provided inputs + /// are not finalized by BDK and the `finalized` bool is intentionally ignored. + #[cfg(feature = "swaps")] + pub(crate) fn swap_sign_psbt(&self, psbt: Psbt) -> Result { + self.sign_psbt_inner(psbt).map_err(|()| Error::WalletOperationFailed) + } + + pub(crate) async fn get_new_internal_address(&self) -> Result { + let mut locked_persister = self.persister.lock().await; + let (address_info, change_set) = { + let mut locked_wallet = self.inner.lock().expect("lock"); + let address_info = locked_wallet.next_unused_address(KeychainKind::Internal); + (address_info, locked_wallet.take_staged().unwrap_or_default()) + }; + locked_persister.persist_changeset(change_set).await.map_err(|e| { log_error!(self.logger, "Failed to persist wallet: {}", e); Error::PersistenceFailed })?; Ok(address_info.address) } - 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.runtime.block_on(locked_wallet.persist_async(&mut locked_persister)).map_err(|e| { + pub(crate) async fn cancel_tx(&self, tx: Transaction) -> Result<(), Error> { + let mut locked_persister = self.persister.lock().await; + let change_set = { + let mut locked_wallet = self.inner.lock().expect("lock"); + Self::cancel_tx_inner(&mut locked_wallet, tx); + locked_wallet.take_staged().unwrap_or_default() + }; + locked_persister.persist_changeset(change_set).await.map_err(|e| { log_error!(self.logger, "Failed to persist wallet: {}", e); Error::PersistenceFailed })?; @@ -526,6 +632,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 +795,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 +825,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) } @@ -721,7 +838,7 @@ impl Wallet { } #[allow(deprecated)] - pub(crate) fn send_to_address( + pub(crate) async fn send_to_address( &self, address: &bitcoin::Address, send_amount: OnchainSendAmount, fee_rate: Option, ) -> Result { @@ -732,7 +849,8 @@ impl Wallet { let fee_rate = fee_rate.unwrap_or_else(|| self.fee_estimator.estimate_fee_rate(confirmation_target)); - let tx = { + let mut locked_persister = self.persister.lock().await; + let (psbt, change_set) = { let mut locked_wallet = self.inner.lock().expect("lock"); // Prepare the tx_builder. We properly check the reserve requirements (again) further down. @@ -764,7 +882,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 @@ -855,27 +973,54 @@ impl Wallet { }, } - let mut locked_persister = self.persister.lock().expect("lock"); - 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 - }, - )?; - - psbt.extract_tx().map_err(|e| { - log_error!(self.logger, "Failed to extract transaction: {}", e); - e - })? + (psbt, locked_wallet.take_staged().unwrap_or_default()) }; + locked_persister.persist_changeset(change_set).await.map_err(|e| { + log_error!(self.logger, "Failed to persist wallet: {}", e); + Error::PersistenceFailed + })?; - self.broadcaster.broadcast_transactions(&[( - &tx, - lightning::chain::chaininterface::TransactionType::Sweep { channels: vec![] }, - )]); + let tx = psbt.extract_tx().map_err(|e| { + log_error!(self.logger, "Failed to extract transaction: {}", e); + e + })?; let txid = tx.compute_txid(); + // Teach the wallet about its own spend BEFORE the broadcast queue takes it. Until the + // transaction is applied as unconfirmed, BDK still lists the coins it spends as unspent, + // and a second send built in the meantime re-selects them — a double-spend the network + // then refuses as an underpaid replacement. Chain sources with a mempool view would + // eventually catch up on their own; the CBF chain source never would. Applying here also + // records the Pending on-chain payment right away, and makes the unconfirmed change + // output spendable by the very next send. The persister is still locked from the build + // above, so this deliberately does NOT go through `apply_mempool_txs` (which would take + // that same lock). + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let events = { + let mut locked_wallet = self.inner.lock().expect("lock"); + locked_wallet + .events_helper(|wallet| -> Result<(), std::convert::Infallible> { + wallet.apply_unconfirmed_txs(vec![(tx.clone(), now)]); + Ok(()) + }) + .expect("applying an unconfirmed transaction cannot fail") + }; + self.update_payment_store(events).await.map_err(|e| { + log_error!(self.logger, "Failed to update payment store: {}", e); + Error::PersistenceFailed + })?; + let change_set = self.inner.lock().expect("lock").take_staged().unwrap_or_default(); + locked_persister.persist_changeset(change_set).await.map_err(|e| { + log_error!(self.logger, "Failed to persist wallet: {}", e); + Error::PersistenceFailed + })?; + + self.broadcaster.broadcast_unclassified_transaction(tx); + match send_amount { OnchainSendAmount::ExactRetainingReserve { amount_sats, .. } => { log_info!( @@ -908,83 +1053,91 @@ impl Wallet { Ok(txid) } - pub(crate) fn select_confirmed_utxos( + pub(crate) async fn select_confirmed_utxos( &self, must_spend: Vec, must_pay_to: &[TxOut], fee_rate: FeeRate, ) -> Result { - let mut locked_wallet = self.inner.lock().expect("lock"); - let mut locked_persister = self.persister.lock().expect("lock"); - - debug_assert!(matches!( - locked_wallet.public_descriptor(KeychainKind::External), - ExtendedDescriptor::Wpkh(_) - )); - debug_assert!(matches!( - locked_wallet.public_descriptor(KeychainKind::Internal), - ExtendedDescriptor::Wpkh(_) - )); + let mut locked_persister = self.persister.lock().await; + let (coin_selection, change_set) = { + let mut locked_wallet = self.inner.lock().expect("lock"); - let mut tx_builder = locked_wallet.build_tx(); - tx_builder.only_witness_utxo(); + debug_assert!(matches!( + locked_wallet.public_descriptor(KeychainKind::External), + ExtendedDescriptor::Wpkh(_) + )); + debug_assert!(matches!( + locked_wallet.public_descriptor(KeychainKind::Internal), + ExtendedDescriptor::Wpkh(_) + )); + + let mut tx_builder = locked_wallet.build_tx(); + tx_builder.only_witness_utxo(); + + for input in &must_spend { + let psbt_input = psbt::Input { + witness_utxo: Some(input.previous_utxo.clone()), + ..Default::default() + }; + let weight = ldk_to_bdk_satisfaction_weight(input.satisfaction_weight); + tx_builder.add_foreign_utxo(input.outpoint, psbt_input, weight).map_err(|_| ())?; + } - for input in &must_spend { - let psbt_input = psbt::Input { - witness_utxo: Some(input.previous_utxo.clone()), - ..Default::default() - }; - let weight = ldk_to_bdk_satisfaction_weight(input.satisfaction_weight); - tx_builder.add_foreign_utxo(input.outpoint, psbt_input, weight).map_err(|_| ())?; - } + for output in must_pay_to { + tx_builder.add_recipient(output.script_pubkey.clone(), output.value); + } - for output in must_pay_to { - tx_builder.add_recipient(output.script_pubkey.clone(), output.value); - } + tx_builder.fee_rate(fee_rate); + tx_builder.exclude_unconfirmed(); - tx_builder.fee_rate(fee_rate); - tx_builder.exclude_unconfirmed(); + let unsigned_tx = tx_builder + .finish() + .map_err(|e| { + log_error!(self.logger, "Failed to select confirmed UTXOs: {}", e); + })? + .unsigned_tx; + + let confirmed_utxos = unsigned_tx + .input + .iter() + .filter(|txin| { + must_spend.iter().all(|input| input.outpoint != txin.previous_output) + }) + .filter_map(|txin| { + locked_wallet + .tx_details(txin.previous_output.txid) + .map(|tx_details| tx_details.tx.deref().clone()) + .map(|prevtx| ConfirmedUtxo::new_p2wpkh(prevtx, txin.previous_output.vout)) + }) + .collect::, ()>>()?; - let unsigned_tx = tx_builder - .finish() - .map_err(|e| { - log_error!(self.logger, "Failed to select confirmed UTXOs: {}", e); - })? - .unsigned_tx; + if unsigned_tx.output.len() > must_pay_to.len() + 1 { + log_error!( + self.logger, + "Unexpected number of change outputs during coin selection: {}", + unsigned_tx.output.len() - must_pay_to.len(), + ); + return Err(()); + } - let confirmed_utxos = unsigned_tx - .input - .iter() - .filter(|txin| must_spend.iter().all(|input| input.outpoint != txin.previous_output)) - .filter_map(|txin| { - locked_wallet - .tx_details(txin.previous_output.txid) - .map(|tx_details| tx_details.tx.deref().clone()) - .map(|prevtx| ConfirmedUtxo::new_p2wpkh(prevtx, txin.previous_output.vout)) - }) - .collect::, ()>>()?; + let change_output = unsigned_tx + .output + .into_iter() + .find(|txout| must_pay_to.iter().all(|output| output != txout)); + let change_set = if change_output.is_some() { + Some(locked_wallet.take_staged().unwrap_or_default()) + } else { + None + }; - if unsigned_tx.output.len() > must_pay_to.len() + 1 { - log_error!( - self.logger, - "Unexpected number of change outputs during coin selection: {}", - unsigned_tx.output.len() - must_pay_to.len(), - ); - return Err(()); - } + (CoinSelection { confirmed_utxos, change_output }, change_set) + }; - let change_output = unsigned_tx - .output - .into_iter() - .find(|txout| must_pay_to.iter().all(|output| output != txout)); - - if change_output.is_some() { - self.runtime.block_on(locked_wallet.persist_async(&mut locked_persister)).map_err( - |e| { - log_error!(self.logger, "Failed to persist wallet: {}", e); - () - }, - )?; + if let Some(change_set) = change_set { + locked_persister.persist_changeset(change_set).await.map_err(|e| { + log_error!(self.logger, "Failed to persist wallet: {}", e); + })?; } - Ok(CoinSelection { confirmed_utxos, change_output }) + Ok(coin_selection) } fn list_confirmed_utxos_inner(&self) -> Result, ()> { @@ -1081,14 +1234,15 @@ impl Wallet { } #[allow(deprecated)] - fn get_change_script_inner(&self) -> Result { - let mut locked_wallet = self.inner.lock().expect("lock"); - let mut locked_persister = self.persister.lock().expect("lock"); - - let address_info = locked_wallet.next_unused_address(KeychainKind::Internal); - self.runtime.block_on(locked_wallet.persist_async(&mut locked_persister)).map_err(|e| { + async fn get_change_script_inner(&self) -> Result { + let mut locked_persister = self.persister.lock().await; + let (address_info, change_set) = { + let mut locked_wallet = self.inner.lock().expect("lock"); + let address_info = locked_wallet.next_unused_address(KeychainKind::Internal); + (address_info, locked_wallet.take_staged().unwrap_or_default()) + }; + locked_persister.persist_changeset(change_set).await.map_err(|e| { log_error!(self.logger, "Failed to persist wallet: {}", e); - () })?; Ok(address_info.address.script_pubkey()) } @@ -1100,9 +1254,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 +1318,223 @@ 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 an on-chain broadcast handed to the broadcaster by LDK, recording a payment for it + /// before it is sent when it affects this node's wallet. + 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 + }, + LdkTransactionType::UnilateralClose { .. } => Ok(()), + LdkTransactionType::CooperativeClose { .. } + | LdkTransactionType::AnchorBump { .. } + | LdkTransactionType::Claim { .. } + | LdkTransactionType::Sweep { .. } => { + self.classify_regular_broadcast(tx, tx_type.clone().into()).await + }, + } + } - let kind = PaymentKind::Onchain { txid, status: confirmation_status }; + /// 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()); + + // 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(()) + } + + /// Records a non-funding LDK broadcast as an on-chain payment, tagged with its transaction type. + /// Wallet sync later refreshes confirmation status while preserving the type. + async fn classify_regular_broadcast( + &self, tx: &Transaction, tx_type: TransactionType, + ) -> Result<(), Error> { + let txid = tx.compute_txid(); + let (amount_msat, fee_paid_msat, direction) = self.onchain_payment_fields(tx); + + if amount_msat == Some(0) && fee_paid_msat == Some(0) { + log_trace!( + self.logger, + "Not recording classified broadcast {} as a payment: no wallet-level activity", + txid, + ); + return Ok(()); + } + + let details = PaymentDetails::new( + PaymentId(txid.to_byte_array()), + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(tx_type), + }, + amount_msat, + fee_paid_msat, + direction, + PaymentStatus::Pending, + ); + self.payment_store.insert_or_update(details).await?; + log_debug!(self.logger, "Recorded classified on-chain broadcast {}", txid); + 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 +1556,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 +1610,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`. + async 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.payment_store.insert_or_update(payment.clone()).await?; + // 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.pending_payment_store.insert_or_update(pending).await?; + } + Ok(true) + } + #[allow(deprecated)] - pub(crate) fn bump_fee_rbf( - &self, payment_id: PaymentId, fee_rate: Option, + pub(crate) async fn bump_fee_rbf( + &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 { .. } => { @@ -1280,6 +1720,7 @@ impl Wallet { }, }; + let mut locked_persister = self.persister.lock().await; let mut locked_wallet = self.inner.lock().expect("lock"); debug_assert!( @@ -1386,6 +1827,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 { @@ -1404,12 +1880,6 @@ impl Wallet { }, } - let mut locked_persister = self.persister.lock().expect("lock"); - self.runtime.block_on(locked_wallet.persist_async(&mut locked_persister)).map_err(|e| { - log_error!(self.logger, "Failed to persist wallet after fee bump of {}: {}", txid, e); - Error::PersistenceFailed - })?; - let fee_bumped_tx = psbt.extract_tx().map_err(|e| { log_error!(self.logger, "Failed to extract fee bump transaction for {}: {}", txid, e); e @@ -1417,11 +1887,6 @@ impl Wallet { let new_txid = fee_bumped_tx.compute_txid(); - self.broadcaster.broadcast_transactions(&[( - &fee_bumped_tx, - lightning::chain::chaininterface::TransactionType::Sweep { channels: vec![] }, - )]); - let new_payment = self.create_payment_from_tx( &locked_wallet, new_txid, @@ -1433,10 +1898,17 @@ impl Wallet { let pending_payment_store = self.create_pending_payment_from_tx(new_payment.clone(), Vec::new()); + let change_set = locked_wallet.take_staged().unwrap_or_default(); + drop(locked_wallet); + locked_persister.persist_changeset(change_set).await.map_err(|e| { + log_error!(self.logger, "Failed to persist wallet after fee bump of {}: {}", txid, e); + Error::PersistenceFailed + })?; - self.runtime - .block_on(self.pending_payment_store.insert_or_update(pending_payment_store))?; - self.runtime.block_on(self.payment_store.insert_or_update(new_payment))?; + self.payment_store.insert_or_update(new_payment).await?; + self.pending_payment_store.insert_or_update(pending_payment_store).await?; + + self.broadcaster.broadcast_unclassified_transaction(fee_bumped_tx); log_info!(self.logger, "RBF successful: replaced {} with {}", txid, new_txid); @@ -1444,76 +1916,121 @@ 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, - _txdata: &lightning::chain::transaction::TransactionData, _height: u32, + &self, header: &bitcoin::block::Header, + _txdata: &lightning::chain::transaction::TransactionData, height: u32, ) { - debug_assert!(false, "Syncing filtered blocks is currently not supported"); - // As far as we can tell this would be a no-op anyways as we don't have to tell BDK about - // the header chain of intermediate blocks. According to the BDK team, it's sufficient to - // only connect full blocks starting from the last point of disagreement. + // A non-matching filter means none of this block's transactions are relevant to us, so there + // is nothing but the header to apply. We still connect an empty block built from the header + // to keep the on-chain wallet's chain contiguous with the listeners. + let block = bitcoin::Block { header: *header, txdata: Vec::new() }; + self.block_connected(&block, height); } fn block_connected(&self, block: &bitcoin::Block, height: u32) { - let mut locked_wallet = self.inner.lock().expect("lock"); - - let pre_checkpoint = locked_wallet.latest_checkpoint(); - if pre_checkpoint.height() != height - 1 - || pre_checkpoint.hash() != block.header.prev_blockhash - { - log_debug!( - self.logger, - "Detected reorg while applying a connected block to on-chain wallet: new block with hash {} at height {}", - block.header.block_hash(), - height - ); - } + self.runtime.block_on(async { + let mut locked_persister = self.persister.lock().await; + let events = { + let mut locked_wallet = self.inner.lock().expect("lock"); + + let pre_checkpoint = locked_wallet.latest_checkpoint(); + if pre_checkpoint.height() != height - 1 + || pre_checkpoint.hash() != block.header.prev_blockhash + { + log_debug!( + self.logger, + "Detected reorg while applying a connected block to on-chain wallet: new block with hash {} at height {}", + block.header.block_hash(), + height + ); + } - // In order to be able to reliably calculate fees the `Wallet` needs access to the previous - // ouput data. To this end, we here insert any ouputs of transactions that LDK is intersted - // in (e.g., funding transaction ouputs) into the wallet's transaction graph when we see - // them, so it is reliably able to calculate fees for subsequent spends. - // - // FIXME: technically, we should also do this for mempool transactions. However, at the - // current time fixing the edge case doesn't seem worth the additional conplexity / - // additional overhead.. - let registered_txids = self.chain_source.registered_txids(); - for tx in &block.txdata { - let txid = tx.compute_txid(); - if registered_txids.contains(&txid) { - for (vout, txout) in tx.output.iter().enumerate() { - let outpoint = OutPoint { txid, vout: vout as u32 }; - locked_wallet.insert_txout(outpoint, txout.clone()); + // In order to be able to reliably calculate fees the `Wallet` needs access to the previous + // ouput data. To this end, we here insert any ouputs of transactions that LDK is intersted + // in (e.g., funding transaction ouputs) into the wallet's transaction graph when we see + // them, so it is reliably able to calculate fees for subsequent spends. + // + // FIXME: technically, we should also do this for mempool transactions. However, at the + // current time fixing the edge case doesn't seem worth the additional conplexity / + // additional overhead.. + let registered_txids = self.chain_source.registered_txids(); + for tx in &block.txdata { + let txid = tx.compute_txid(); + if registered_txids.contains(&txid) { + for (vout, txout) in tx.output.iter().enumerate() { + let outpoint = OutPoint { txid, vout: vout as u32 }; + locked_wallet.insert_txout(outpoint, txout.clone()); + } + } } - } - } - match locked_wallet.apply_block_events(block, height) { - Ok(events) => { - if let Err(e) = self.update_payment_store(&mut *locked_wallet, events) { - log_error!(self.logger, "Failed to update payment store: {}", e); - return; + match locked_wallet.apply_block_events(block, height) { + Ok(events) => events, + Err(e) => { + log_error!( + self.logger, + "Failed to apply connected block to on-chain wallet: {}", + e + ); + return; + }, } - }, - Err(e) => { - log_error!( - self.logger, - "Failed to apply connected block to on-chain wallet: {}", - e - ); + }; + + if let Err(e) = self.update_payment_store(events).await { + log_error!(self.logger, "Failed to update payment store: {}", e); return; - }, - }; + } - let mut locked_persister = self.persister.lock().expect("lock"); - match self.runtime.block_on(locked_wallet.persist_async(&mut locked_persister)) { - Ok(_) => (), - Err(e) => { + let change_set = self.inner.lock().expect("lock").take_staged().unwrap_or_default(); + if let Err(e) = locked_persister.persist_changeset(change_set).await { log_error!(self.logger, "Failed to persist on-chain wallet: {}", e); return; - }, - }; + } + }); } fn blocks_disconnected(&self, _fork_point_block: BlockLocator) { @@ -1531,7 +2048,7 @@ impl WalletSource for Wallet { } fn get_change_script<'a>(&'a self) -> impl Future> + Send + 'a { - async move { self.get_change_script_inner() } + async move { self.get_change_script_inner().await } } fn get_prevtx<'a>( @@ -1568,7 +2085,7 @@ impl CoinSelectionSource for Wallet { ) -> impl Future> + Send + 'a { debug_assert!(claim_id.is_none()); let fee_rate = FeeRate::from_sat_per_kwu(target_feerate_sat_per_1000_weight as u64); - async move { self.select_confirmed_utxos(must_spend, must_pay_to, fee_rate) } + async move { self.select_confirmed_utxos(must_spend, must_pay_to, fee_rate).await } } fn sign_psbt<'a>( @@ -1585,6 +2102,14 @@ pub(crate) struct WalletKeysManager { inner: KeysManager, wallet: Arc, logger: Arc, + /// Dedicated swap-key derivation master (Peerswap native primitive B7). + /// + /// Derived from the wallet seed at a hardened BIP-32 index reserved + /// exclusively for swaps. It is fully isolated from the node identity + /// secret key (which LDK derives at the low reserved children of the same + /// master), so a swap keypair can NEVER coincide with the node identity. + #[cfg(feature = "swaps")] + swap_master_xprv: Xpriv, } impl WalletKeysManager { @@ -1597,7 +2122,15 @@ impl WalletKeysManager { logger: Arc, ) -> Self { let inner = KeysManager::new(seed, starting_time_secs, starting_time_nanos, true); - Self { inner, wallet, logger } + #[cfg(feature = "swaps")] + let swap_master_xprv = Self::derive_swap_master_xprv(seed); + Self { + inner, + wallet, + logger, + #[cfg(feature = "swaps")] + swap_master_xprv, + } } pub fn sign_message(&self, msg: &[u8]) -> String { @@ -1611,6 +2144,64 @@ impl WalletKeysManager { pub fn verify_signature(&self, msg: &[u8], sig: &str, pkey: &PublicKey) -> bool { message_signing::verify(msg, sig, pkey) } + + /// Hardened BIP-32 child index of the dedicated swap-key domain (B7). + /// + /// Value is the ASCII bytes of `"swap"` (`0x73776170`), which is `< 2^31` + /// so it is a valid hardened index. It sits far outside the low children + /// (`0..=6`) that LDK's `KeysManager` reserves for the node identity, + /// channel, destination, shutdown, and inbound-payment keys — guaranteeing + /// the swap key tree never overlaps the node identity secret key. + #[cfg(feature = "swaps")] + const SWAP_KEY_HARDENED_CHILD_INDEX: u32 = 0x7377_6170; + + /// Derives the dedicated swap-domain master xpriv from the wallet `seed`. + /// + /// BIP-32 child-key derivation is network-independent for the secret + /// material, so the fixed network used to construct the master only affects + /// the (unused) serialization version bytes — never the derived keys. + #[cfg(feature = "swaps")] + fn derive_swap_master_xprv(seed: &[u8; 32]) -> Xpriv { + let secp = Secp256k1::new(); + let master = Xpriv::new_master(Network::Bitcoin, seed) + .expect("a 32-byte seed is always a valid BIP-32 master key"); + master + .derive_priv( + &secp, + &[ChildNumber::Hardened { index: Self::SWAP_KEY_HARDENED_CHILD_INDEX }], + ) + .expect("hardened derivation from a valid master key is infallible") + } + + /// Derives a deterministic swap [`Keypair`] at `index` from the dedicated, + /// swaps-only BIP-32 path (B7). + /// + /// The key is derived from [`Self::swap_master_xprv`], i.e. a hardened path + /// reserved exclusively for swaps; it is NEVER derived from the node + /// identity secret key. The returned [`Keypair`] carries both the secret + /// and the public key so callers can build and sign swap HTLC scripts. + #[cfg(feature = "swaps")] + pub(crate) fn derive_swap_keypair(&self, index: u32) -> Result { + swap_keypair_from_master(&self.swap_master_xprv, index).map_err(|e| { + log_error!(self.logger, "Failed to derive swap keypair at index {}: {}", index, e); + Error::InvalidSecretKey + }) + } +} + +/// Derives the swap [`Keypair`] at hardened `index` from an already-derived +/// swap-domain master xpriv (Peerswap native primitive B7). +/// +/// Split out from [`WalletKeysManager::derive_swap_keypair`] as a generic-free, +/// `self`-free helper so the deterministic derivation can be exercised by unit +/// test vectors without constructing a full wallet/keys-manager. The instance +/// method adds error logging on top of this pure derivation. Secret material is +/// never logged here. +#[cfg(feature = "swaps")] +fn swap_keypair_from_master(master: &Xpriv, index: u32) -> Result { + let secp = Secp256k1::new(); + let child = master.derive_priv(&secp, &[ChildNumber::Hardened { index }])?; + Ok(Keypair::from_secret_key(&secp, &child.private_key)) } impl NodeSigner for WalletKeysManager { @@ -1692,14 +2283,14 @@ impl SignerProvider for WalletKeysManager { } fn get_destination_script(&self, _channel_keys_id: [u8; 32]) -> Result { - let address = self.wallet.get_new_address().map_err(|e| { + let address = self.wallet.runtime.block_on(self.wallet.get_new_address()).map_err(|e| { log_error!(self.logger, "Failed to retrieve new address from wallet: {}", e); })?; Ok(address.script_pubkey()) } fn get_shutdown_scriptpubkey(&self) -> Result { - let address = self.wallet.get_new_address().map_err(|e| { + let address = self.wallet.runtime.block_on(self.wallet.get_new_address()).map_err(|e| { log_error!(self.logger, "Failed to retrieve new address from wallet: {}", e); })?; @@ -1725,6 +2316,7 @@ impl ChangeDestinationSource for WalletKeysManager { async move { self.wallet .get_new_internal_address() + .await .map_err(|e| { log_error!(self.logger, "Failed to retrieve new address from wallet: {}", e); }) @@ -1756,104 +2348,221 @@ fn ldk_to_bdk_satisfaction_weight(ldk_satisfaction_weight: u64) -> 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() }); - } +#[cfg(all(test, feature = "swaps"))] +mod swap_b7_tests { + //! Test vectors for the B7 dedicated swap-key derivation. + //! + //! These exercise the exact production derivation path used by + //! [`WalletKeysManager::derive_swap_keypair`] — namely + //! [`WalletKeysManager::derive_swap_master_xprv`] (the swaps-only hardened + //! BIP-32 domain) followed by [`swap_keypair_from_master`] — without having + //! to construct a full BDK-backed wallet/keys-manager. + + use super::swap_keypair_from_master; + use crate::types::KeysManager; + use bitcoin::secp256k1::{PublicKey, Secp256k1}; + use lightning::sign::KeysManager as LdkKeysManager; + + /// Fixed 32-byte seed used by every vector below. + const TEST_SEED: [u8; 32] = [ + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, + 0xff, 0x0f, 0x1e, 0x2d, 0x3c, 0x4b, 0x5a, 0x69, 0x78, 0x87, 0x96, 0xa5, 0xb4, 0xc3, 0xd2, + 0xe1, 0xf0, + ]; + + /// Derives the swap public key for `index` from `TEST_SEED` over the full + /// production path and returns it as a lowercase compressed-hex string. + fn swap_pubkey_hex(index: u32) -> String { + let master = KeysManager::derive_swap_master_xprv(&TEST_SEED); + let keypair = swap_keypair_from_master(&master, index).expect("derivation must succeed"); + keypair.public_key().to_string() + } + + #[test] + fn swap_keypair_matches_fixed_vector() { + // Fixed seed + index => fixed compressed public key. Regenerating this + // value would signal an (unintended) change to the swap derivation path. + assert_eq!( + swap_pubkey_hex(0), + "03d6c52bcef058703ff78e4d765f7b114ff5ad13f222596049b6a7bb66406bc6b6" + ); + assert_eq!( + swap_pubkey_hex(1), + "0203784b06423d07485e4378ebce2eca4c7db3caa15426d52715c2414f4b0cebd9" + ); + } + + #[test] + fn swap_keypair_is_deterministic() { + assert_eq!(swap_pubkey_hex(0), swap_pubkey_hex(0)); + // Distinct indices yield distinct keys. + assert_ne!(swap_pubkey_hex(0), swap_pubkey_hex(1)); + } + + #[test] + fn swap_key_differs_from_node_identity() { + // The node identity secret key is what LDK's KeysManager derives from the + // same seed. The swap key MUST come from a different (dedicated) path. + let ldk = LdkKeysManager::new(&TEST_SEED, 0, 0, true); + let node_secret = ldk.get_node_secret_key(); + let secp = Secp256k1::new(); + let node_pubkey = PublicKey::from_secret_key(&secp, &node_secret); + + let master = KeysManager::derive_swap_master_xprv(&TEST_SEED); + for index in 0..8u32 { + let swap_keypair = + swap_keypair_from_master(&master, index).expect("derivation must succeed"); + assert_ne!( + swap_keypair.secret_key(), + node_secret, + "swap secret at index {index} must never equal the node identity secret" + ); + assert_ne!( + swap_keypair.public_key(), + node_pubkey, + "swap pubkey at index {index} must never equal the node identity pubkey" + ); } - }); + } +} + +#[cfg(test)] +mod tests { + //! The BDK behaviour the "own send is applied as unconfirmed" fix relies on, pinned down on a + //! bare `bdk_wallet::Wallet` so it needs no chain source: an own unconfirmed spend takes the + //! coins it spent out of `list_unspent` and exposes its change; the next transaction builds + //! on that unconfirmed change instead of re-selecting the spent coins; an eviction hands the + //! original coins back; and all of it survives a reload from the persisted change set. + use bdk_chain::Merge; + use bdk_wallet::{ChangeSet, KeychainKind, SignOptions, Wallet as BdkWallet}; + use bitcoin::hashes::Hash; + use bitcoin::{ + absolute, transaction, Amount, FeeRate, Network, OutPoint, ScriptBuf, Sequence, + Transaction, TxIn, TxOut, Txid, WPubkeyHash, Witness, + }; + + const EXTERNAL_DESCRIPTOR: &str = "wpkh(tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L/84'/1'/0'/0/*)"; + const INTERNAL_DESCRIPTOR: &str = "wpkh(tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L/84'/1'/0'/1/*)"; + const FUNDING_SATS: u64 = 100_000; + + fn new_wallet() -> BdkWallet { + BdkWallet::create(EXTERNAL_DESCRIPTOR, INTERNAL_DESCRIPTOR) + .network(Network::Regtest) + .create_wallet_no_persist() + .expect("valid test descriptors") + } + + fn someone_elses_script() -> ScriptBuf { + ScriptBuf::new_p2wpkh(&WPubkeyHash::hash(&[0x42u8; 33])) + } + + /// An unconfirmed deposit into the wallet, spending an outpoint nobody checks. + fn fund(wallet: &mut BdkWallet, last_seen: u64) -> OutPoint { + let address = wallet.reveal_next_address(KeychainKind::External).address; + let funding = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { txid: Txid::from_byte_array([7u8; 32]), vout: 0 }, + script_sig: ScriptBuf::new(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(FUNDING_SATS), + script_pubkey: address.script_pubkey(), + }], + }; + let outpoint = OutPoint { txid: funding.compute_txid(), vout: 0 }; + wallet.apply_unconfirmed_txs(vec![(funding, last_seen)]); + outpoint + } + + /// Builds, signs and returns a send of `sats` to a foreign script — exactly what + /// `Wallet::send_to_address` does before it applies and queues the result. + fn build_send(wallet: &mut BdkWallet, sats: u64) -> Transaction { + let mut builder = wallet.build_tx(); + builder + .add_recipient(someone_elses_script(), Amount::from_sat(sats)) + .fee_rate(FeeRate::from_sat_per_vb_u32(1)); + let mut psbt = builder.finish().expect("the wallet can fund this send"); + assert!(wallet.sign(&mut psbt, SignOptions::default()).expect("signing works")); + psbt.extract_tx().expect("finalized psbt extracts") + } - events + fn unspent_outpoints(wallet: &BdkWallet) -> Vec { + wallet.list_unspent().map(|u| u.outpoint).collect() + } + + #[test] + fn own_unconfirmed_spend_locks_its_inputs_and_exposes_its_change() { + let mut wallet = new_wallet(); + let deposit = fund(&mut wallet, 1); + assert_eq!(unspent_outpoints(&wallet), vec![deposit]); + + let first = build_send(&mut wallet, 30_000); + assert_eq!(first.input[0].previous_output, deposit); + wallet.apply_unconfirmed_txs(vec![(first.clone(), 2)]); + + let unspent = unspent_outpoints(&wallet); + assert_eq!(unspent.len(), 1, "only the change output is left to spend"); + assert_eq!(unspent[0].txid, first.compute_txid(), "and it is the first send's change"); + assert!(!unspent.contains(&deposit), "the spent deposit is no longer offered"); + + // The very next send builds on the unconfirmed change instead of re-selecting the + // deposit — the second transaction of a block, chained on the first. + let second = build_send(&mut wallet, 20_000); + assert_eq!(second.input.len(), 1); + assert_eq!(second.input[0].previous_output.txid, first.compute_txid()); + } + + #[test] + fn evicting_an_unconfirmed_spend_hands_its_inputs_back() { + let mut wallet = new_wallet(); + let deposit = fund(&mut wallet, 1); + let first = build_send(&mut wallet, 30_000); + let first_txid = first.compute_txid(); + wallet.apply_unconfirmed_txs(vec![(first, 2)]); + assert!(!unspent_outpoints(&wallet).contains(&deposit)); + + // An eviction stamped no earlier than the last sighting wins (BDK: a transaction whose + // `last_evicted >= last_seen` is no longer canonical). + wallet.apply_evicted_txs(vec![(first_txid, 2)]); + assert_eq!(unspent_outpoints(&wallet), vec![deposit], "the deposit is spendable again"); + + // The eviction also hides the spend from the canonical view (`get_tx`), which is what + // keeps `Wallet::get_unconfirmed_transaction` from rebroadcasting an evicted one... + assert!(wallet.get_tx(first_txid).is_none()); + // ...but the transaction itself is kept, and seeing it again later (a rebroadcast, or + // the mempool) makes the spend canonical once more. + let again = wallet.tx_graph().get_tx(first_txid).expect("evicted, not forgotten"); + wallet.apply_unconfirmed_txs(vec![((*again).clone(), 3)]); + assert!(!unspent_outpoints(&wallet).contains(&deposit)); + } + + #[test] + fn an_unconfirmed_spend_survives_a_reload_from_the_persisted_change_set() { + let mut wallet = new_wallet(); + let deposit = fund(&mut wallet, 1); + let first = build_send(&mut wallet, 30_000); + let first_txid = first.compute_txid(); + wallet.apply_unconfirmed_txs(vec![(first, 2)]); + + let mut persisted = ChangeSet::default(); + persisted.merge(wallet.take_staged().expect("the wallet staged its creation and spend")); + + let reloaded = BdkWallet::load() + .descriptor(KeychainKind::External, Some(EXTERNAL_DESCRIPTOR)) + .descriptor(KeychainKind::Internal, Some(INTERNAL_DESCRIPTOR)) + .extract_keys() + .check_network(Network::Regtest) + .load_wallet_no_persist(persisted) + .expect("the change set loads") + .expect("the change set describes a wallet"); + + let unspent = unspent_outpoints(&reloaded); + assert_eq!(unspent.len(), 1); + assert_eq!(unspent[0].txid, first_txid, "the reloaded wallet still spends only the change"); + assert!(!unspent.contains(&deposit), "and still knows the deposit is spent"); + } } diff --git a/src/wallet/persist.rs b/src/wallet/persist.rs index 364dc4b475..4d76dfe260 100644 --- a/src/wallet/persist.rs +++ b/src/wallet/persist.rs @@ -22,13 +22,65 @@ use crate::types::DynStore; pub(crate) struct KVStoreWalletPersister { latest_change_set: Option, + pending_change_set: ChangeSet, kv_store: Arc, logger: Arc, + /// While set, `local_chain` updates are merged into the in-memory aggregate but not written to + /// the KV store. See [`Self::set_defer_local_chain`]. + defer_local_chain: bool, + /// Whether the in-memory `local_chain` aggregate holds changes not yet written to the KV store. + local_chain_dirty: bool, } impl KVStoreWalletPersister { pub(crate) fn new(kv_store: Arc, logger: Arc) -> Self { - Self { latest_change_set: None, kv_store, logger } + Self { + latest_change_set: None, + pending_change_set: ChangeSet::default(), + kv_store, + logger, + defer_local_chain: false, + local_chain_dirty: false, + } + } + + /// Defers `local_chain` writes while a bulk chain sync is in progress. + /// + /// The persisted `local_chain` is a `BTreeMap` covering the whole chain, and it is + /// re-serialized and re-written in full on every applied block. During an initial sync that + /// makes the total bytes written quadratic in chain height — the dominant cost on a + /// flash-storage device, and the reason a from-scratch sync is impractical there. + /// + /// Deferring is safe because `local_chain` is the one part of the wallet's state that is + /// reconstructible: a stale persisted chain simply lowers the resume floor, and the chain source + /// replays the missing blocks on restart. `indexer` and `tx_graph` are deliberately *not* + /// deferred — address-derivation indices and transactions are funds-critical and are not + /// cheaply reconstructible, so they keep writing through synchronously. + pub(super) fn set_defer_local_chain(&mut self, defer: bool) { + self.defer_local_chain = defer; + } + + /// Writes the in-memory `local_chain` aggregate if it has deferred changes. + pub(super) async fn flush_local_chain(&mut self) -> Result<(), std::io::Error> { + if !self.local_chain_dirty { + return Ok(()); + } + + let latest_change_set = self.latest_change_set.as_ref().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::Other, + "Wallet must be initialized before flushing the local chain", + ) + })?; + + write_bdk_wallet_local_chain( + &latest_change_set.local_chain, + &*self.kv_store, + Arc::clone(&self.logger), + ) + .await?; + self.local_chain_dirty = false; + Ok(()) } async fn initialize_inner(&mut self) -> Result { @@ -52,17 +104,20 @@ impl KVStoreWalletPersister { Ok(change_set) } - async fn persist_inner(&mut self, change_set: &ChangeSet) -> Result<(), std::io::Error> { + async fn persist_inner( + latest_change_set_opt: &mut Option, kv_store: &Arc, + logger: &Arc, change_set: &ChangeSet, defer_local_chain: bool, + local_chain_dirty: &mut bool, + ) -> Result<(), std::io::Error> { if change_set.is_empty() { return Ok(()); } - let kv_store = Arc::clone(&self.kv_store); - let logger = Arc::clone(&self.logger); + let kv_store = kv_store.as_ref(); // We're allowed to fail here if we're not initialized, BDK docs state: "This method can fail if the // persister is not initialized." - let latest_change_set = self.latest_change_set.as_mut().ok_or_else(|| { + let latest_change_set = latest_change_set_opt.as_mut().ok_or_else(|| { std::io::Error::new( std::io::ErrorKind::Other, "Wallet must be initialized before calling persist", @@ -159,16 +214,41 @@ impl KVStoreWalletPersister { if !change_set.local_chain.is_empty() { latest_change_set.local_chain.merge(change_set.local_chain.clone()); - write_bdk_wallet_local_chain( - &latest_change_set.local_chain, - &*kv_store, - Arc::clone(&logger), - ) - .await?; + if defer_local_chain { + // Merged in memory only; `flush_local_chain` writes the aggregate later. A crash + // before that flush leaves an older persisted chain, which lowers the resume floor + // and causes the missing blocks to be replayed. + *local_chain_dirty = true; + } else { + write_bdk_wallet_local_chain( + &latest_change_set.local_chain, + &*kv_store, + Arc::clone(&logger), + ) + .await?; + *local_chain_dirty = false; + } } Ok(()) } + + pub(super) async fn persist_changeset( + &mut self, change_set: ChangeSet, + ) -> Result<(), std::io::Error> { + self.pending_change_set.merge(change_set); + Self::persist_inner( + &mut self.latest_change_set, + &self.kv_store, + &self.logger, + &self.pending_change_set, + self.defer_local_chain, + &mut self.local_chain_dirty, + ) + .await?; + let _ = std::mem::take(&mut self.pending_change_set); + Ok(()) + } } impl AsyncWalletPersister for KVStoreWalletPersister { @@ -189,6 +269,140 @@ impl AsyncWalletPersister for KVStoreWalletPersister { where Self: 'a, { - Box::pin(persister.persist_inner(change_set)) + Box::pin(Self::persist_inner( + &mut persister.latest_change_set, + &persister.kv_store, + &persister.logger, + change_set, + persister.defer_local_chain, + &mut persister.local_chain_dirty, + )) + } +} + +#[cfg(test)] +mod tests { + use std::future::Future; + use std::sync::Arc; + use std::time::Duration; + + use bdk_wallet::{AsyncWalletPersister, ChangeSet, Wallet as BdkWallet}; + use bitcoin::Network; + use lightning::io; + use lightning::util::persist::{KVStore, PageToken, PaginatedKVStore, PaginatedListResponse}; + + use super::KVStoreWalletPersister; + use crate::io::test_utils::InMemoryStore; + use crate::logger::Logger; + use crate::types::{DynStore, DynStoreWrapper}; + + const EXTERNAL_DESCRIPTOR: &str = "wpkh(tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L/84'/1'/0'/0/*)"; + const INTERNAL_DESCRIPTOR: &str = "wpkh(tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L/84'/1'/0'/1/*)"; + + #[derive(Clone)] + struct GatedStore { + inner: Arc, + write_gate: Arc>, + } + + impl KVStore for GatedStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> impl Future, io::Error>> + 'static + Send { + KVStore::read(&*self.inner, primary_namespace, secondary_namespace, key) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> impl Future> + 'static + Send { + let inner = Arc::clone(&self.inner); + let write_gate = Arc::clone(&self.write_gate); + let primary_namespace = primary_namespace.to_string(); + let secondary_namespace = secondary_namespace.to_string(); + let key = key.to_string(); + async move { + let _guard = write_gate.read().await; + KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf).await + } + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> impl Future> + 'static + Send { + KVStore::remove(&*self.inner, primary_namespace, secondary_namespace, key, lazy) + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> impl Future, io::Error>> + 'static + Send { + KVStore::list(&*self.inner, primary_namespace, secondary_namespace) + } + } + + impl PaginatedKVStore for GatedStore { + fn list_paginated( + &self, primary_namespace: &str, secondary_namespace: &str, + page_token: Option, + ) -> impl Future> + 'static + Send { + PaginatedKVStore::list_paginated( + &*self.inner, + primary_namespace, + secondary_namespace, + page_token, + ) + } + } + + #[tokio::test] + async fn retains_pending_changes_when_persist_is_cancelled() { + let gated_store = GatedStore { + inner: Arc::new(InMemoryStore::new()), + write_gate: Arc::new(tokio::sync::RwLock::new(())), + }; + let store: Arc = Arc::new(DynStoreWrapper(gated_store.clone())); + let logger = Arc::new(Logger::new_log_facade()); + let mut persister = KVStoreWalletPersister::new(Arc::clone(&store), Arc::clone(&logger)); + AsyncWalletPersister::initialize(&mut persister).await.unwrap(); + + let mut wallet = BdkWallet::create(EXTERNAL_DESCRIPTOR, INTERNAL_DESCRIPTOR) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + let change_set = wallet.take_staged().unwrap(); + + let gate_guard = gated_store.write_gate.write().await; + { + let persist_fut = persister.persist_changeset(change_set); + tokio::pin!(persist_fut); + let poll_res = tokio::time::timeout(Duration::from_millis(100), &mut persist_fut).await; + assert!(poll_res.is_err(), "persist should be parked on the gated store write"); + } + drop(gate_guard); + + persister.persist_changeset(ChangeSet::default()).await.unwrap(); + + let mut reloaded_persister = KVStoreWalletPersister::new(store, logger); + let reloaded = AsyncWalletPersister::initialize(&mut reloaded_persister).await.unwrap(); + assert_eq!(reloaded.network, Some(Network::Regtest)); + } + + #[tokio::test] + async fn retries_changes_after_persistence_failure() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let logger = Arc::new(Logger::new_log_facade()); + let mut persister = KVStoreWalletPersister::new(Arc::clone(&store), Arc::clone(&logger)); + let mut wallet = BdkWallet::create(EXTERNAL_DESCRIPTOR, INTERNAL_DESCRIPTOR) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + let change_set = wallet.take_staged().unwrap(); + + assert!(persister.persist_changeset(change_set).await.is_err()); + AsyncWalletPersister::initialize(&mut persister).await.unwrap(); + persister.persist_changeset(ChangeSet::default()).await.unwrap(); + + let mut reloaded_persister = KVStoreWalletPersister::new(store, logger); + let reloaded = AsyncWalletPersister::initialize(&mut reloaded_persister).await.unwrap(); + assert_eq!(reloaded.network, Some(Network::Regtest)); } } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index d7775e67b3..3216c4c548 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -30,6 +30,7 @@ use std::time::Duration; use bitcoin::hashes::hex::FromHex; use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::Hash; +use bitcoin::secp256k1::PublicKey; use bitcoin::{ Address, Amount, Network, OutPoint, ScriptBuf, Sequence, Transaction, Txid, Witness, }; @@ -42,7 +43,8 @@ use ldk_node::config::{ }; use ldk_node::entropy::{generate_entropy_mnemonic, NodeEntropy}; use ldk_node::io::sqlite_store::SqliteStore; -use ldk_node::payment::{PaymentDirection, PaymentKind, PaymentStatus}; +use ldk_node::payment::{PaymentDirection, PaymentKind, PaymentStatus, TransactionType}; +use ldk_node::probing::ProbingConfig; use ldk_node::{ Builder, ChannelShutdownState, CustomTlvRecord, Event, LightningBalance, Node, NodeError, PendingSweepBalance, UserChannelId, @@ -50,7 +52,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}; @@ -61,7 +63,7 @@ use serde_json::{json, Value}; #[path = "../../src/io/in_memory_store.rs"] mod in_memory_store; -use in_memory_store::InMemoryStore; +pub(crate) use in_memory_store::InMemoryStore; /// Shared timeout (in seconds) for waiting on LDK events and external node operations. pub(crate) const INTEROP_TIMEOUT_SECS: u64 = 60; @@ -312,6 +314,10 @@ pub(crate) fn setup_bitcoind_and_electrsd() -> (BitcoinD, ElectrsD) { let mut bitcoind_conf = corepc_node::Conf::default(); bitcoind_conf.network = "regtest"; bitcoind_conf.args.push("-rest"); + // Enable P2P and compact block filters so the CBF (BIP157) chain source can connect and sync. + bitcoind_conf.p2p = corepc_node::P2P::Yes; + bitcoind_conf.args.push("-blockfilterindex=1"); + bitcoind_conf.args.push("-peerblockfilters=1"); let bitcoind = BitcoinD::with_conf(bitcoind_exe, &bitcoind_conf).unwrap(); let electrs_exe = env::var("ELECTRS_EXE") @@ -328,7 +334,14 @@ pub(crate) fn setup_bitcoind_and_electrsd() -> (BitcoinD, ElectrsD) { pub(crate) fn random_chain_source<'a>( bitcoind: &'a BitcoinD, electrsd: &'a ElectrsD, ) -> TestChainSource<'a> { - let r = rand::random_range(0..3); + let r = match std::env::var("LDK_TEST_CHAIN_SOURCE").ok().as_deref() { + Some("esplora") => 0, + Some("electrum") => 1, + Some("bitcoind-rpc") => 2, + Some("bitcoind-rest") => 3, + Some("cbf") => 4, + _ => rand::random_range(0..3), + }; match r { 0 => { println!("Randomly setting up Esplora chain syncing..."); @@ -346,6 +359,10 @@ pub(crate) fn random_chain_source<'a>( println!("Randomly setting up Bitcoind REST chain syncing..."); TestChainSource::BitcoindRestSync(bitcoind) }, + 4 => { + println!("Randomly setting up CBF compact block filter syncing..."); + TestChainSource::Cbf(bitcoind) + }, _ => unreachable!(), } } @@ -377,11 +394,12 @@ pub(crate) fn random_node_alias() -> Option { Some(NodeAlias(bytes)) } -pub(crate) fn random_config(anchor_channels: bool) -> TestConfig { +pub(crate) fn random_config() -> TestConfig { let mut node_config = Config::default(); - if !anchor_channels { - node_config.anchor_channels_config = None; + #[cfg(zero_fee_commitment_tests)] + { + node_config.anchor_channels_config.enable_zero_fee_commitments = true; } node_config.network = Network::Regtest; @@ -403,9 +421,128 @@ pub(crate) fn random_config(anchor_channels: bool) -> TestConfig { } #[cfg(feature = "uniffi")] -type TestNode = Arc; +pub(crate) type TestNode = Arc; #[cfg(not(feature = "uniffi"))] -type TestNode = Node; +pub(crate) type TestNode = Node; + +fn has_onchain_tx_type bool>(node: &TestNode, predicate: F) -> bool { + node.list_payments().into_iter().any(|payment| { + matches!( + payment.kind, + PaymentKind::Onchain { tx_type: Some(ref tx_type), .. } if predicate(tx_type) + ) + }) +} + +fn assert_any_node_has_onchain_tx_type bool + Copy>( + nodes: &[(&str, &TestNode)], tx_type_name: &str, predicate: F, +) { + if nodes.iter().any(|(_, node)| has_onchain_tx_type(node, predicate)) { + return; + } + + let observed: Vec = nodes + .iter() + .flat_map(|(name, node)| { + node.list_payments().into_iter().filter_map(move |payment| match payment.kind { + PaymentKind::Onchain { tx_type, .. } => Some(format!("{}:{:?}", name, tx_type)), + _ => None, + }) + }) + .collect(); + panic!("Expected on-chain payment with tx_type {}; observed {:?}", tx_type_name, observed); +} + +fn assert_all_nodes_have_onchain_tx_type bool + Copy>( + nodes: &[(&str, &TestNode)], panic_msg: &str, tx_type_name: &str, predicate: F, +) { + if nodes.iter().all(|(_, node)| has_onchain_tx_type(node, predicate)) { + return; + } + + let observed: Vec = nodes + .iter() + .flat_map(|(name, node)| { + node.list_payments().into_iter().filter_map(move |payment| match payment.kind { + PaymentKind::Onchain { tx_type, .. } => Some(format!("{}:{:?}", name, tx_type)), + _ => None, + }) + }) + .collect(); + panic!( + "Expected {}nodes to have on-chain payment with tx_type {}; observed {:?}", + panic_msg, tx_type_name, observed + ); +} + +async fn settle_force_close_balance( + node: &TestNode, counterparty_node_id: PublicKey, peer_node: &TestNode, + bitcoind: &BitcoindClient, electrsd: &E, +) { + let balances = node.list_balances(); + if balances.lightning_balances.len() == 1 { + match balances.lightning_balances[0] { + LightningBalance::ClaimableAwaitingConfirmations { + counterparty_node_id: actual_counterparty_node_id, + confirmation_height, + .. + } => { + assert_eq!(actual_counterparty_node_id, counterparty_node_id); + let cur_height = node.status().current_best_block.height; + let blocks_to_go = confirmation_height - cur_height; + let new_height = + generate_blocks_and_wait(bitcoind, electrsd, blocks_to_go as usize).await; + wait_for_node_tip(node, new_height).await; + wait_for_node_tip(peer_node, new_height).await; + node.sync_wallets().unwrap(); + peer_node.sync_wallets().unwrap(); + }, + _ => panic!("Unexpected balance state!"), + } + } else { + assert!(balances.lightning_balances.is_empty(), "Unexpected balance state: {:?}", balances); + assert_eq!(balances.pending_balances_from_channel_closures.len(), 1); + } + + for _ in 0..6 { + if node.list_balances().lightning_balances.is_empty() { + break; + } + let new_height = generate_blocks_and_wait(bitcoind, electrsd, 1).await; + wait_for_node_tip(node, new_height).await; + wait_for_node_tip(peer_node, new_height).await; + node.sync_wallets().unwrap(); + peer_node.sync_wallets().unwrap(); + } + + let balances = node.list_balances(); + assert!(balances.lightning_balances.is_empty(), "Unexpected balance state: {:?}", balances); + assert_eq!(balances.pending_balances_from_channel_closures.len(), 1); + match balances.pending_balances_from_channel_closures[0] { + PendingSweepBalance::BroadcastAwaitingConfirmation { .. } => { + let new_height = generate_blocks_and_wait(bitcoind, electrsd, 1).await; + wait_for_node_tip(node, new_height).await; + wait_for_node_tip(peer_node, new_height).await; + node.sync_wallets().unwrap(); + peer_node.sync_wallets().unwrap(); + + assert!(node.list_balances().lightning_balances.is_empty()); + assert_eq!(node.list_balances().pending_balances_from_channel_closures.len(), 1); + match node.list_balances().pending_balances_from_channel_closures[0] { + PendingSweepBalance::AwaitingThresholdConfirmations { .. } => {}, + _ => panic!("Unexpected balance state!"), + } + }, + PendingSweepBalance::AwaitingThresholdConfirmations { .. } => {}, + _ => panic!("Unexpected balance state!"), + } + + let new_height = generate_blocks_and_wait(bitcoind, electrsd, 5).await; + wait_for_node_tip(node, new_height).await; + wait_for_node_tip(peer_node, new_height).await; + node.sync_wallets().unwrap(); + peer_node.sync_wallets().unwrap(); +} #[derive(Clone)] pub(crate) enum TestChainSource<'a> { @@ -413,6 +550,7 @@ pub(crate) enum TestChainSource<'a> { Electrum(&'a ElectrsD), BitcoindRpcSync(&'a BitcoinD), BitcoindRestSync(&'a BitcoinD), + Cbf(&'a BitcoinD), } #[derive(Clone, Copy)] @@ -435,7 +573,10 @@ 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, + pub full_scan_stop_gap: Option, + pub probing: Option, } impl Default for TestConfig { @@ -447,14 +588,19 @@ 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; + let full_scan_stop_gap = None; TestConfig { node_config, log_writer, store_type, node_entropy, async_payments_role, - recovery_mode, + wallet_rescan_from_height, + force_wallet_full_scan, + full_scan_stop_gap, + probing: None, } } } @@ -474,24 +620,22 @@ pub(crate) use setup_builder; pub(crate) mod scenarios; pub(crate) fn setup_two_nodes( - chain_source: &TestChainSource, allow_0conf: bool, anchor_channels: bool, - anchors_trusted_no_reserve: bool, + chain_source: &TestChainSource, allow_0conf: bool, anchors_trusted_no_reserve: bool, ) -> (TestNode, TestNode) { setup_two_nodes_with_store( chain_source, allow_0conf, - anchor_channels, anchors_trusted_no_reserve, TestStoreType::TestSyncStore, ) } pub(crate) fn setup_two_nodes_with_store( - chain_source: &TestChainSource, allow_0conf: bool, anchor_channels: bool, - anchors_trusted_no_reserve: bool, store_type: TestStoreType, + chain_source: &TestChainSource, allow_0conf: bool, anchors_trusted_no_reserve: bool, + store_type: TestStoreType, ) -> (TestNode, TestNode) { println!("== Node A =="); - let mut config_a = random_config(anchor_channels); + let mut config_a = random_config(); config_a.store_type = store_type; if cfg!(hrn_tests) { @@ -502,7 +646,7 @@ pub(crate) fn setup_two_nodes_with_store( let node_a = setup_node(chain_source, config_a); println!("\n== Node B =="); - let mut config_b = random_config(anchor_channels); + let mut config_b = random_config(); config_b.store_type = store_type; if cfg!(hrn_tests) { @@ -517,14 +661,8 @@ pub(crate) fn setup_two_nodes_with_store( if allow_0conf { config_b.node_config.trusted_peers_0conf.push(node_a.node_id()); } - if anchor_channels && anchors_trusted_no_reserve { - config_b - .node_config - .anchor_channels_config - .as_mut() - .unwrap() - .trusted_peers_no_reserve - .push(node_a.node_id()); + if anchors_trusted_no_reserve { + config_b.node_config.anchor_channels_config.trusted_peers_no_reserve.push(node_a.node_id()); } let node_b = setup_node(chain_source, config_b); (node_a, node_b) @@ -537,12 +675,20 @@ 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; + if let Some(full_scan_stop_gap) = config.full_scan_stop_gap { + sync_config.full_scan_stop_gap = full_scan_stop_gap; + } 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; + if let Some(full_scan_stop_gap) = config.full_scan_stop_gap { + sync_config.full_scan_stop_gap = full_scan_stop_gap; + } builder.set_chain_source_electrum(electrum_url.clone(), Some(sync_config)); }, TestChainSource::BitcoindRpcSync(bitcoind) => { @@ -551,7 +697,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,8 +720,14 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) -> rpc_port, rpc_user, rpc_password, + config.wallet_rescan_from_height, ); }, + TestChainSource::Cbf(bitcoind) => { + let p2p_socket = bitcoind.params.p2p_socket.expect("P2P must be enabled for CBF"); + let peer_addr = format!("{}", p2p_socket); + builder.set_chain_source_cbf(vec![peer_addr], None, config.wallet_rescan_from_height); + }, } match &config.log_writer { @@ -586,8 +744,8 @@ 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(); + if let Some(probing) = config.probing { + builder.set_probing_config(probing.into()); } let node = match config.store_type { @@ -601,10 +759,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()); @@ -613,7 +767,7 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) -> pub(crate) async fn generate_blocks_and_wait( bitcoind: &BitcoindClient, electrs: &E, num: usize, -) { +) -> usize { let _ = bitcoind.create_wallet("ldk_node_test"); let _ = bitcoind.load_wallet("ldk_node_test"); print!("Generating {} blocks...", num); @@ -622,9 +776,11 @@ pub(crate) async fn generate_blocks_and_wait( let address = bitcoind.new_address().expect("failed to get new address"); // TODO: expect this Result once the WouldBlock issue is resolved upstream. let _block_hashes_res = bitcoind.generate_to_address(num, &address); - wait_for_block(electrs, cur_height as usize + num).await; + let new_height = cur_height as usize + num; + wait_for_block(bitcoind, electrs, new_height).await; print!(" Done!"); println!("\n"); + return new_height; } pub(crate) fn invalidate_blocks(bitcoind: &BitcoindClient, num_blocks: usize) { @@ -642,27 +798,25 @@ pub(crate) fn invalidate_blocks(bitcoind: &BitcoindClient, num_blocks: usize) { assert!(new_cur_height + num_blocks == cur_height); } -pub(crate) async fn wait_for_block(electrs: &E, min_height: usize) { - let mut header = match electrs.block_headers_subscribe() { - Ok(header) => header, - Err(_) => { - // While subscribing should succeed the first time around, we ran into some cases where - // it didn't. Since we can't proceed without subscribing, we try again after a delay - // and panic if it still fails. - tokio::time::sleep(Duration::from_secs(3)).await; - electrs.block_headers_subscribe().expect("failed to subscribe to block headers") - }, - }; - loop { - if header.height >= min_height { - break; +pub(crate) async fn wait_for_block( + bitcoind: &BitcoindClient, electrs: &E, min_height: usize, +) { + let expected_block_hash = exponential_backoff_poll(|| { + let bitcoind_height = + bitcoind.get_blockchain_info().expect("failed to get blockchain info").blocks as usize; + if bitcoind_height < min_height { + return None; } - header = exponential_backoff_poll(|| { - electrs.ping().expect("failed to ping electrs"); - electrs.block_headers_pop().expect("failed to pop block header") - }) - .await; - } + bitcoind.get_block_hash(min_height as u64).ok()?.block_hash().ok() + }) + .await; + // A height-only wait can return the old header during a same-height reorg. Require the + // replacement hash so callers cannot sync against the stale chain by mistake. + exponential_backoff_poll(|| { + let header = electrs.block_header(min_height).ok()?; + (header.block_hash() == expected_block_hash).then_some(()) + }) + .await; } pub(crate) async fn wait_for_tx(electrs: &E, txid: Txid) { @@ -681,20 +835,57 @@ pub(crate) async fn wait_for_outpoint_spend(electrs: &E, outpoin let tx = electrs.transaction_get(&outpoint.txid).unwrap(); let txout_script = tx.output.get(outpoint.vout as usize).unwrap().clone().script_pubkey; - let is_spent = !electrs.script_get_history(&txout_script).unwrap().is_empty(); - if is_spent { - return; - } - + // Script history already contains the funding transaction itself, so wait until the exact + // funding outpoint leaves the unspent set instead of treating any history as a spend. exponential_backoff_poll(|| { electrs.ping().unwrap(); - let is_spent = !electrs.script_get_history(&txout_script).unwrap().is_empty(); + let is_spent = !electrs.script_list_unspent(&txout_script).unwrap().iter().any(|output| { + output.tx_hash == outpoint.txid && output.tx_pos == outpoint.vout as usize + }); is_spent.then_some(()) }) .await; } +/// Polls the channel from `source_node` to `counterparty_node` until it reports `is_usable` +/// and can carry an HTLC of `min_amount_msat` from `source_node`'s side. +/// +/// After `ChannelReady`, channel-monitor persistence can lag for tens of seconds on slow +/// CI runners; during that window `send_probe`/`send_payment` reject with +/// `ParameterError("...monitor update is in progress...")`. This helper gives tests a +/// deterministic readiness gate instead of racing the monitor-update pipeline. +pub(crate) async fn wait_for_channel_ready_to_send( + source_node: &TestNode, counterparty_node: &TestNode, min_amount_msat: u64, +) { + let counterparty = counterparty_node.node_id(); + let deadline = tokio::time::Instant::now() + Duration::from_secs(180); + while tokio::time::Instant::now() < deadline { + let ready = source_node.list_channels().iter().any(|c| { + c.counterparty.node_id == counterparty + && c.is_usable + && c.next_outbound_htlc_limit_msat >= min_amount_msat + }); + if ready { + return; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + panic!( + "channel from {} to {} not ready to send {} msat within 180s", + source_node.node_id(), + counterparty, + min_amount_msat, + ); +} + +pub(crate) async fn wait_for_node_tip(node: &Node, height: usize) { + exponential_backoff_poll(|| { + (node.status().current_best_block.height as usize >= height).then_some(()) + }) + .await; +} + pub(crate) async fn exponential_backoff_poll(mut poll: F) -> T where F: FnMut() -> Option, @@ -820,12 +1011,18 @@ pub async fn open_channel( node_a: &TestNode, node_b: &TestNode, funding_amount_sat: u64, should_announce: bool, electrsd: &ElectrsD, ) -> OutPoint { - open_channel_push_amt(node_a, node_b, funding_amount_sat, None, should_announce, electrsd).await + let funding_txo = + open_channel_no_wait(node_a, node_b, funding_amount_sat, None, should_announce).await; + wait_for_tx(&electrsd.client, funding_txo.txid).await; + funding_txo } -pub async fn open_channel_push_amt( +/// Like [`open_channel`] but skips the `wait_for_tx` electrum check so that +/// multiple channels can be opened back-to-back before any blocks are mined. +/// The caller is responsible for mining blocks and confirming the funding txs. +pub async fn open_channel_no_wait( node_a: &TestNode, node_b: &TestNode, funding_amount_sat: u64, push_amount_msat: Option, - should_announce: bool, electrsd: &ElectrsD, + should_announce: bool, ) -> OutPoint { if should_announce { node_a @@ -853,11 +1050,20 @@ pub async fn open_channel_push_amt( let funding_txo_a = expect_channel_pending_event!(node_a, node_b.node_id()); let funding_txo_b = expect_channel_pending_event!(node_b, node_a.node_id()); assert_eq!(funding_txo_a, funding_txo_b); - wait_for_tx(&electrsd.client, funding_txo_a.txid).await; - funding_txo_a } +pub async fn open_channel_push_amt( + node_a: &TestNode, node_b: &TestNode, funding_amount_sat: u64, push_amount_msat: Option, + should_announce: bool, electrsd: &ElectrsD, +) -> OutPoint { + let funding_txo = + open_channel_no_wait(node_a, node_b, funding_amount_sat, push_amount_msat, should_announce) + .await; + wait_for_tx(&electrsd.client, funding_txo.txid).await; + funding_txo +} + pub async fn open_channel_with_all( node_a: &TestNode, node_b: &TestNode, should_announce: bool, electrsd: &ElectrsD, ) -> OutPoint { @@ -989,7 +1195,9 @@ pub(crate) async fn do_channel_full_cycle( wait_for_tx(electrsd, funding_txo_a.txid).await; if !allow_0conf { - generate_blocks_and_wait(&bitcoind, electrsd, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind, electrsd, 6).await; + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; } node_a.sync_wallets().unwrap(); @@ -1022,7 +1230,8 @@ pub(crate) async fn do_channel_full_cycle( let node_b_anchor_reserve_sat = if node_b .config() .anchor_channels_config - .map_or(true, |acc| acc.trusted_peers_no_reserve.contains(&node_a.node_id())) + .trusted_peers_no_reserve + .contains(&node_a.node_id()) { 0 } else { @@ -1360,17 +1569,25 @@ pub(crate) async fn do_channel_full_cycle( ); // Mine a block to give time for the HTLC to resolve - generate_blocks_and_wait(&bitcoind, electrsd, 1).await; + let new_height = generate_blocks_and_wait(&bitcoind, electrsd, 1).await; + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; 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()); expect_splice_negotiated_event!(node_b, node_a.node_id()); - generate_blocks_and_wait(&bitcoind, electrsd, 6).await; + tokio::time::sleep(Duration::from_secs(2)).await; + + let new_height = generate_blocks_and_wait(&bitcoind, electrsd, 6).await; + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); @@ -1392,7 +1609,10 @@ pub(crate) async fn do_channel_full_cycle( expect_splice_negotiated_event!(node_a, node_b.node_id()); expect_splice_negotiated_event!(node_b, node_a.node_id()); - generate_blocks_and_wait(&bitcoind, electrsd, 6).await; + tokio::time::sleep(Duration::from_secs(5)).await; + let new_height = generate_blocks_and_wait(&bitcoind, electrsd, 6).await; + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); @@ -1411,10 +1631,8 @@ pub(crate) async fn do_channel_full_cycle( let node_a_outbound_capacity_msat = node_a.list_channels()[0].outbound_capacity_msat; let node_a_reserve_msat = node_a.list_channels()[0].unspendable_punishment_reserve.unwrap() * 1000; - // TODO: Zero-fee commitment channels are anchor channels, but do not allocate any - // funds to the anchor, so this will need to be updated when we ship these channels - // in ldk-node. - let node_a_anchors_msat = if expect_anchor_channel { 2 * 330 * 1000 } else { 0 }; + let zero_fee_commitments = node_a.list_channels()[0].feerate_sat_per_1000_weight == 0; + let node_a_anchors_msat = if zero_fee_commitments { 0 } else { 2 * 330 * 1000 }; let funding_amount_msat = node_a.list_channels()[0].channel_value_sats * 1000; // Node B does not have any reserve, so we only subtract a few items on node A's // side to arrive at node B's capacity @@ -1444,8 +1662,10 @@ pub(crate) async fn do_channel_full_cycle( tokio::time::sleep(Duration::from_secs(1)).await; if force_close { node_a.force_close_channel(&user_channel_id_a, node_b.node_id(), None).unwrap(); + tokio::time::sleep(Duration::from_secs(2)).await; } else { node_a.close_channel(&user_channel_id_a, node_b.node_id()).unwrap(); + tokio::time::sleep(Duration::from_secs(2)).await; // The cooperative shutdown may complete before we get to check, but if the channel // is still visible it must already be in a shutdown state. if let Some(channel) = @@ -1467,89 +1687,18 @@ pub(crate) async fn do_channel_full_cycle( wait_for_outpoint_spend(electrsd, funding_txo_b).await; - generate_blocks_and_wait(&bitcoind, electrsd, 1).await; + let new_height = generate_blocks_and_wait(&bitcoind, electrsd, 1).await; + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); if force_close { - // Check node_b properly sees all balances and sweeps them. - assert_eq!(node_b.list_balances().lightning_balances.len(), 1); - match node_b.list_balances().lightning_balances[0] { - LightningBalance::ClaimableAwaitingConfirmations { - counterparty_node_id, - confirmation_height, - .. - } => { - assert_eq!(counterparty_node_id, node_a.node_id()); - let cur_height = node_b.status().current_best_block.height; - let blocks_to_go = confirmation_height - cur_height; - generate_blocks_and_wait(&bitcoind, electrsd, blocks_to_go as usize).await; - node_b.sync_wallets().unwrap(); - node_a.sync_wallets().unwrap(); - }, - _ => panic!("Unexpected balance state!"), - } - - assert!(node_b.list_balances().lightning_balances.is_empty()); - assert_eq!(node_b.list_balances().pending_balances_from_channel_closures.len(), 1); - match node_b.list_balances().pending_balances_from_channel_closures[0] { - PendingSweepBalance::BroadcastAwaitingConfirmation { .. } => {}, - _ => panic!("Unexpected balance state!"), - } - generate_blocks_and_wait(&bitcoind, electrsd, 1).await; - node_b.sync_wallets().unwrap(); - node_a.sync_wallets().unwrap(); - + settle_force_close_balance(&node_b, node_a.node_id(), &node_a, &bitcoind, electrsd).await; assert!(node_b.list_balances().lightning_balances.is_empty()); assert_eq!(node_b.list_balances().pending_balances_from_channel_closures.len(), 1); - match node_b.list_balances().pending_balances_from_channel_closures[0] { - PendingSweepBalance::AwaitingThresholdConfirmations { .. } => {}, - _ => panic!("Unexpected balance state!"), - } - generate_blocks_and_wait(&bitcoind, electrsd, 5).await; - node_b.sync_wallets().unwrap(); - node_a.sync_wallets().unwrap(); - - assert!(node_b.list_balances().lightning_balances.is_empty()); - assert_eq!(node_b.list_balances().pending_balances_from_channel_closures.len(), 1); - - // Check node_a properly sees all balances and sweeps them. - assert_eq!(node_a.list_balances().lightning_balances.len(), 1); - match node_a.list_balances().lightning_balances[0] { - LightningBalance::ClaimableAwaitingConfirmations { - counterparty_node_id, - confirmation_height, - .. - } => { - assert_eq!(counterparty_node_id, node_b.node_id()); - let cur_height = node_a.status().current_best_block.height; - let blocks_to_go = confirmation_height - cur_height; - generate_blocks_and_wait(&bitcoind, electrsd, blocks_to_go as usize).await; - node_a.sync_wallets().unwrap(); - node_b.sync_wallets().unwrap(); - }, - _ => panic!("Unexpected balance state!"), - } - - assert!(node_a.list_balances().lightning_balances.is_empty()); - assert_eq!(node_a.list_balances().pending_balances_from_channel_closures.len(), 1); - match node_a.list_balances().pending_balances_from_channel_closures[0] { - PendingSweepBalance::BroadcastAwaitingConfirmation { .. } => {}, - _ => panic!("Unexpected balance state!"), - } - generate_blocks_and_wait(&bitcoind, electrsd, 1).await; - node_a.sync_wallets().unwrap(); - node_b.sync_wallets().unwrap(); - assert!(node_a.list_balances().lightning_balances.is_empty()); - assert_eq!(node_a.list_balances().pending_balances_from_channel_closures.len(), 1); - match node_a.list_balances().pending_balances_from_channel_closures[0] { - PendingSweepBalance::AwaitingThresholdConfirmations { .. } => {}, - _ => panic!("Unexpected balance state!"), - } - generate_blocks_and_wait(&bitcoind, electrsd, 5).await; - node_a.sync_wallets().unwrap(); - node_b.sync_wallets().unwrap(); + settle_force_close_balance(&node_a, node_b.node_id(), &node_b, &bitcoind, electrsd).await; } else { assert_eq!(node_a.list_balances().lightning_balances.len(), 1); assert!(node_a.list_balances().pending_balances_from_channel_closures.is_empty()); @@ -1585,7 +1734,10 @@ pub(crate) async fn do_channel_full_cycle( assert_eq!(node_a_blocks_to_go, node_b_blocks_to_go); - generate_blocks_and_wait(&bitcoind, electrsd, node_a_blocks_to_go as usize).await; + let new_height = + generate_blocks_and_wait(&bitcoind, electrsd, node_a_blocks_to_go as usize).await; + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); @@ -1595,6 +1747,38 @@ pub(crate) async fn do_channel_full_cycle( assert!(node_b.list_balances().pending_balances_from_channel_closures.is_empty()); } + if force_close { + // The recovery reconnect completed while the force-close settled, so the peer no longer + // needs to remain persisted. + assert!( + !node_a.list_peers().iter().any(|p| p.node_id == node_b.node_id() && p.is_persisted), + "node_b should be removed from node_a peer store after the recovery reconnect" + ); + assert_all_nodes_have_onchain_tx_type( + &[("node_a", &node_a), ("node_b", &node_b)], + "no ", + "UnilateralClose", + |tx_type| !matches!(tx_type, TransactionType::UnilateralClose { .. }), + ); + assert_any_node_has_onchain_tx_type( + &[("node_a", &node_a), ("node_b", &node_b)], + "Sweep", + |tx_type| matches!(tx_type, TransactionType::Sweep { .. }), + ); + } else { + assert_all_nodes_have_onchain_tx_type( + &[("node_a", &node_a), ("node_b", &node_b)], + "all ", + "CooperativeClose", + |tx_type| matches!(tx_type, TransactionType::CooperativeClose { .. }), + ); + // Peer removed after cooperative close — no further reason to reconnect. + assert!( + !node_a.list_peers().iter().any(|p| p.node_id == node_b.node_id() && p.is_persisted), + "node_b should be removed from node_a peer store after cooperative close" + ); + } + let sum_of_all_payments_sat = (push_msat + invoice_amount_1_msat + overpaid_amount_msat @@ -1617,20 +1801,20 @@ pub(crate) async fn do_channel_full_cycle( assert_eq!(node_b.list_balances().total_anchor_channels_reserve_sats, 0); // Now we should have seen the channel closing transaction on-chain. - assert_eq!( - node_a - .list_payments_with_filter(|p| p.direction == PaymentDirection::Inbound - && matches!(p.kind, PaymentKind::Onchain { .. })) - .len(), - 3 - ); - assert_eq!( - node_b - .list_payments_with_filter(|p| p.direction == PaymentDirection::Inbound - && matches!(p.kind, PaymentKind::Onchain { .. })) - .len(), - 2 - ); + let node_a_inbound_onchain_count = node_a + .list_payments_with_filter(|p| { + p.direction == PaymentDirection::Inbound + && matches!(p.kind, PaymentKind::Onchain { .. }) + }) + .len(); + let node_b_inbound_onchain_count = node_b + .list_payments_with_filter(|p| { + p.direction == PaymentDirection::Inbound + && matches!(p.kind, PaymentKind::Onchain { .. }) + }) + .len(); + assert!(node_a_inbound_onchain_count >= 3); + assert!(node_b_inbound_onchain_count >= 2); // Check we handled all events assert_eq!(node_a.next_event(), None); @@ -1700,6 +1884,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 +1962,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 +2104,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/common/scenarios/mod.rs b/tests/common/scenarios/mod.rs index 7cbf56b8e1..ffbfc2b007 100644 --- a/tests/common/scenarios/mod.rs +++ b/tests/common/scenarios/mod.rs @@ -90,7 +90,7 @@ pub(crate) async fn wait_for_htlcs_settled( /// Build a fresh LDK node configured for interop tests. Uses electrum at the /// docker-compose default port and bumps sync timeouts for combo stress. pub(crate) fn setup_ldk_node() -> Node { - let config = crate::common::random_config(true); + let config = crate::common::random_config(); let mut builder = ldk_node::Builder::from_config(config.node_config); let mut sync_config = ldk_node::config::ElectrumSyncConfig::default(); sync_config.timeouts_config.onchain_wallet_sync_timeout_secs = 180; diff --git a/tests/integration_tests_hrn.rs b/tests/integration_tests_hrn.rs index 9102400398..6e758105a2 100644 --- a/tests/integration_tests_hrn.rs +++ b/tests/integration_tests_hrn.rs @@ -24,7 +24,7 @@ async fn unified_send_to_hrn() { 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_a, node_b) = setup_two_nodes(&chain_source, false, false); let address_a = node_a.onchain_payment().new_address().unwrap(); let premined_sats = 5_000_000; diff --git a/tests/integration_tests_migration.rs b/tests/integration_tests_migration.rs new file mode 100644 index 0000000000..7e5767dca6 --- /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(); + 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(); + 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..889d681ba4 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; @@ -38,7 +22,7 @@ async fn channel_full_cycle_with_postgres_store() { let (bitcoind, electrsd) = common::setup_bitcoind_and_electrsd(); println!("== Node A =="); let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); - let config_a = common::random_config(true); + let config_a = common::random_config(); let mut builder_a = Builder::from_config(config_a.node_config); builder_a.set_chain_source_esplora(esplora_url.clone(), None); let node_a = builder_a @@ -53,7 +37,7 @@ async fn channel_full_cycle_with_postgres_store() { node_a.start().unwrap(); println!("\n== Node B =="); - let config_b = common::random_config(true); + let config_b = common::random_config(); let mut builder_b = Builder::from_config(config_b.node_config); builder_b.set_chain_source_esplora(esplora_url.clone(), None); let node_b = builder_b diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 91cc8f3620..9e762d9c6a 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -8,8 +8,11 @@ mod common; use std::collections::HashSet; +use std::future::Future; use std::str::FromStr; -use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{mpsc, Arc}; +use std::time::Duration; use bitcoin::address::NetworkUnchecked; use bitcoin::hashes::sha256::Hash as Sha256Hash; @@ -21,33 +24,203 @@ 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_node_tip, wait_for_tx, + InMemoryStore, 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::config::{AsyncPaymentsRole, EsploraSyncConfig, DEFAULT_FULL_SCAN_STOP_GAP}; 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, ReserveType}; use lightning::ln::channelmanager::PaymentId; use lightning::routing::gossip::{NodeAlias, NodeId}; use lightning::routing::router::RouteParametersConfig; +use lightning::util::persist::{KVStore, PageToken, PaginatedKVStore, PaginatedListResponse}; 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) + }); +} + +#[derive(Clone)] +struct ContendedStore { + inner: Arc, + serializer: Arc>, + block_writes: Arc, + wallet_write_started: Arc, +} + +impl KVStore for ContendedStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> impl Future, lightning::io::Error>> + 'static + Send { + KVStore::read(&*self.inner, primary_namespace, secondary_namespace, key) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> impl Future> + 'static + Send { + let inner = Arc::clone(&self.inner); + let serializer = Arc::clone(&self.serializer); + let block_writes = Arc::clone(&self.block_writes); + let wallet_write_started = Arc::clone(&self.wallet_write_started); + let primary_namespace = primary_namespace.to_string(); + let secondary_namespace = secondary_namespace.to_string(); + let key = key.to_string(); + async move { + if block_writes.load(Ordering::Acquire) { + wallet_write_started.notify_one(); + } + let _guard = serializer.read().await; + KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf).await + } + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> impl Future> + 'static + Send { + KVStore::remove(&*self.inner, primary_namespace, secondary_namespace, key, lazy) + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> impl Future, lightning::io::Error>> + 'static + Send { + KVStore::list(&*self.inner, primary_namespace, secondary_namespace) + } +} + +impl PaginatedKVStore for ContendedStore { + fn list_paginated( + &self, primary_namespace: &str, secondary_namespace: &str, page_token: Option, + ) -> impl Future> + 'static + Send + { + PaginatedKVStore::list_paginated( + &*self.inner, + primary_namespace, + secondary_namespace, + page_token, + ) + } +} + +#[test] +fn wallet_store_contention_does_not_stall_runtime() { + let (ready_sender, ready_receiver) = mpsc::sync_channel(1); + let (result_sender, result_receiver) = mpsc::sync_channel(1); + std::thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_all() + .build() + .expect("test runtime"); + let result = runtime.block_on(async move { + let test_config = random_config(); + let builder = Builder::from_config(test_config.node_config.clone()); + let store = ContendedStore { + inner: Arc::new(InMemoryStore::new()), + serializer: Arc::new(tokio::sync::RwLock::new(())), + block_writes: Arc::new(AtomicBool::new(false)), + wallet_write_started: Arc::new(tokio::sync::Notify::new()), + }; + let node = builder + .build_with_store(test_config.node_entropy.into(), store.clone()) + .map_err(|e| format!("failed to build node: {e:?}"))?; + #[cfg(not(feature = "uniffi"))] + let node = Arc::new(node); + + let serializer = Arc::clone(&store.serializer); + let release_store = Arc::new(tokio::sync::Notify::new()); + let release_store_task = Arc::clone(&release_store); + let (store_locked_sender, store_locked_receiver) = tokio::sync::oneshot::channel(); + tokio::spawn(async move { + let _guard = serializer.write().await; + let _ = store_locked_sender.send(()); + release_store_task.notified().await; + }); + store_locked_receiver.await.map_err(|e| format!("store lock task failed: {e}"))?; + store.block_writes.store(true, Ordering::Release); + let _ = ready_sender.send(()); + + let address_node = Arc::clone(&node); + let (address_sender, address_receiver) = tokio::sync::oneshot::channel(); + std::thread::spawn(move || { + let result = address_node.onchain_payment().new_address().map(|_| ()); + let _ = address_sender.send(result); + }); + store.wallet_write_started.notified().await; + + let balances_node = Arc::clone(&node); + let (balances_started_sender, balances_started_receiver) = + tokio::sync::oneshot::channel(); + let balances_task = tokio::spawn(async move { + let _ = balances_started_sender.send(()); + balances_node.list_balances() + }); + balances_started_receiver + .await + .map_err(|e| format!("balance task failed to start: {e}"))?; + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(100)); + release_store.notify_one(); + }); + balances_task.await.map_err(|e| format!("balance task failed: {e}"))?; + address_receiver + .await + .map_err(|e| format!("address task failed: {e}"))? + .map_err(|e| format!("address generation failed: {e}")) + }); + let _ = result_sender.send(result); + }); + + ready_receiver + .recv_timeout(Duration::from_secs(30)) + .expect("failed to set up wallet contention test"); + let result = result_receiver + .recv_timeout(Duration::from_secs(3)) + .expect("wallet contention stalled the single-thread runtime"); + result.unwrap_or_else(|e| panic!("wallet contention test failed: {e}")); +} #[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 (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let chain_source = TestChainSource::BitcoindRpcSync(&bitcoind); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); do_channel_full_cycle( node_a, node_b, @@ -65,7 +238,7 @@ async fn channel_full_cycle() { async fn channel_full_cycle_force_close() { 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_a, node_b) = setup_two_nodes(&chain_source, false, false); do_channel_full_cycle( node_a, node_b, @@ -83,7 +256,7 @@ async fn channel_full_cycle_force_close() { async fn channel_full_cycle_force_close_trusted_no_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, true); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, true); do_channel_full_cycle( node_a, node_b, @@ -98,36 +271,68 @@ async fn channel_full_cycle_force_close_trusted_no_reserve() { } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn channel_full_cycle_0conf() { +async fn peer_removed_when_counterparty_force_closes_last_channel() { + // When we open a channel outbound, we persist the counterparty so the background + // reconnection task can reach them. If the counterparty then force-closes what turns out + // to be their last channel with us, the channel is terminal and there is nothing left for + // `channel_reestablish` to recover, so the peer should be dropped from the store rather + // than reconnected to forever. 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, true, true, false); - do_channel_full_cycle( - node_a, - node_b, + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); + + let address_a = node_a.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 5_000_000; + premine_and_distribute_funds( &bitcoind.client, &electrsd.client, - true, - false, - true, - false, + vec![address_a], + Amount::from_sat(premine_amount_sat), ) .await; + node_a.sync_wallets().unwrap(); + + // node_a opens the channel, so node_a persists node_b in its peer store. + 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()); + + assert!( + node_a.list_peers().iter().any(|p| p.node_id == node_b.node_id() && p.is_persisted), + "node_a should persist node_b after opening a channel to it" + ); + + // The counterparty force-closes their last channel with us. + node_b.force_close_channel(&user_channel_id_b, node_a.node_id(), None).unwrap(); + + expect_event!(node_a, ChannelClosed); + expect_event!(node_b, ChannelClosed); + + // node_a should have dropped node_b from its peer store. We assert on `is_persisted` rather + // than peer presence so a lingering transient TCP connection doesn't mask the removal. + assert!( + !node_a.list_peers().iter().any(|p| p.node_id == node_b.node_id() && p.is_persisted), + "node_a should drop node_b from its peer store after node_b force-closed the last channel" + ); } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn channel_full_cycle_legacy_staticremotekey() { +async fn channel_full_cycle_0conf() { 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, false, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, true, false); do_channel_full_cycle( node_a, node_b, &bitcoind.client, &electrsd.client, + true, false, - false, - false, + true, false, ) .await; @@ -137,7 +342,7 @@ async fn channel_full_cycle_legacy_staticremotekey() { async fn channel_full_cycle_0reserve() { 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_a, node_b) = setup_two_nodes(&chain_source, false, false); do_channel_full_cycle( node_a, node_b, @@ -155,7 +360,7 @@ async fn channel_full_cycle_0reserve() { async fn channel_full_cycle_0conf_0reserve() { 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, true, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, true, false); do_channel_full_cycle( node_a, node_b, @@ -173,7 +378,7 @@ async fn channel_full_cycle_0conf_0reserve() { async fn channel_open_fails_when_funds_insufficient() { 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_a, node_b) = setup_two_nodes(&chain_source, false, false); let addr_a = node_a.onchain_payment().new_address().unwrap(); let addr_b = node_b.onchain_payment().new_address().unwrap(); @@ -213,7 +418,7 @@ async fn multi_hop_sending() { // Setup and fund 5 nodes let mut nodes = Vec::new(); for _ in 0..5 { - let config = random_config(true); + let config = random_config(); let mut sync_config = EsploraSyncConfig::default(); sync_config.background_sync_config = None; setup_builder!(builder, config.node_config); @@ -304,10 +509,105 @@ 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, false); + let node_c = setup_node(&chain_source, random_config()); + + 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(); - let config = random_config(true); + let config = random_config(); let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); @@ -380,7 +680,7 @@ async fn start_stop_reinit() { async fn onchain_send_receive() { 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_a, node_b) = setup_two_nodes(&chain_source, false, false); let addr_a = node_a.onchain_payment().new_address().unwrap(); let addr_b = node_b.onchain_payment().new_address().unwrap(); @@ -424,10 +724,11 @@ async fn onchain_send_receive() { let channel_amount_sat = 1_000_000; let reserve_amount_sat = 25_000; open_channel(&node_b, &node_a, channel_amount_sat, true, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; - + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; expect_channel_ready_event!(node_a, node_b.node_id()); expect_channel_ready_event!(node_b, node_a.node_id()); @@ -474,17 +775,19 @@ async fn onchain_send_receive() { let payment_a = node_a.payment(&payment_id).unwrap(); assert_eq!(payment_a.status, PaymentStatus::Pending); match payment_a.kind { - PaymentKind::Onchain { status, .. } => { + PaymentKind::Onchain { status, tx_type, .. } => { assert!(matches!(status, ConfirmationStatus::Unconfirmed)); + assert_eq!(tx_type, None); }, _ => panic!("Unexpected payment kind"), } assert!(payment_a.fee_paid_msat > Some(0)); let payment_b = node_b.payment(&payment_id).unwrap(); assert_eq!(payment_b.status, PaymentStatus::Pending); - match payment_a.kind { - PaymentKind::Onchain { status, .. } => { + match payment_b.kind { + PaymentKind::Onchain { status, tx_type, .. } => { assert!(matches!(status, ConfirmationStatus::Unconfirmed)); + assert_eq!(tx_type, None); }, _ => panic!("Unexpected payment kind"), } @@ -493,9 +796,11 @@ async fn onchain_send_receive() { assert_eq!(payment_a.amount_msat, payment_b.amount_msat); assert_eq!(payment_a.fee_paid_msat, payment_b.fee_paid_msat); - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; let expected_node_a_balance = expected_node_a_balance + amount_to_send_sats; let expected_node_b_balance_lower = expected_node_b_balance_lower - amount_to_send_sats; @@ -513,30 +818,32 @@ 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, tx_type } => { assert_eq!(_txid, txid); assert!(matches!(status, ConfirmationStatus::Confirmed { .. })); + assert_eq!(tx_type, None); }, _ => panic!("Unexpected payment kind"), } - let payment_b = node_a.payment(&payment_id).unwrap(); + let payment_b = node_b.payment(&payment_id).unwrap(); match payment_b.kind { - PaymentKind::Onchain { txid: _txid, status } => { + PaymentKind::Onchain { txid: _txid, status, tx_type } => { assert_eq!(_txid, txid); assert!(matches!(status, ConfirmationStatus::Confirmed { .. })); + assert_eq!(tx_type, None); }, _ => panic!("Unexpected payment kind"), } let addr_b = node_b.onchain_payment().new_address().unwrap(); let txid = node_a.onchain_payment().send_all_to_address(&addr_b, true, None).unwrap(); - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; wait_for_tx(&electrsd.client, txid).await; - node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); - + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; let expected_node_b_balance_lower = expected_node_b_balance_lower + expected_node_a_balance; let expected_node_b_balance_upper = expected_node_b_balance_upper + expected_node_a_balance; let expected_node_a_balance = 0; @@ -554,11 +861,13 @@ async fn onchain_send_receive() { let addr_b = node_b.onchain_payment().new_address().unwrap(); let txid = node_a.onchain_payment().send_all_to_address(&addr_b, false, None).unwrap(); - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; wait_for_tx(&electrsd.client, txid).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; let expected_node_b_balance_lower = expected_node_b_balance_lower + reserve_amount_sat; let expected_node_b_balance_upper = expected_node_b_balance_upper + reserve_amount_sat; @@ -577,11 +886,79 @@ 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, 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(&bitcoind.client, &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(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); // Setup nodes let addr_a = node_a.onchain_payment().new_address().unwrap(); @@ -607,10 +984,11 @@ async fn onchain_send_all_retains_reserve() { let txid = node_a.onchain_payment().send_all_to_address(&addr_b, true, None).unwrap(); wait_for_tx(&electrsd.client, txid).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; - + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; // Check node a sent all and node b received it assert_eq!(node_a.list_balances().spendable_onchain_balance_sats, 0); assert!(((premine_amount_sat * 2 - onchain_fee_buffer_sat)..=(premine_amount_sat * 2)) @@ -625,16 +1003,20 @@ async fn onchain_send_all_retains_reserve() { .parse() .unwrap(); wait_for_tx(&electrsd.client, txid).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; assert_eq!(node_a.list_balances().spendable_onchain_balance_sats, reserve_amount_sat); // Open a channel. open_channel(&node_b, &node_a, premine_amount_sat, false, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; expect_channel_ready_event!(node_a, node_b.node_id()); expect_channel_ready_event!(node_b, node_a.node_id()); @@ -648,10 +1030,12 @@ async fn onchain_send_all_retains_reserve() { let txid = node_b.onchain_payment().send_all_to_address(&addr_a, true, None).unwrap(); wait_for_tx(&electrsd.client, txid).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; // Check node b sent all and node a received it assert_eq!(node_b.list_balances().total_onchain_balance_sats, reserve_amount_sat); @@ -667,7 +1051,7 @@ async fn onchain_wallet_recovery() { let chain_source = random_chain_source(&bitcoind, &electrsd); - let original_config = random_config(true); + let original_config = random_config(); let original_node_entropy = original_config.node_entropy; let original_node = setup_node(&chain_source, original_config); @@ -696,9 +1080,9 @@ async fn onchain_wallet_recovery() { .unwrap(); wait_for_tx(&electrsd.client, txid).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; - + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; original_node.sync_wallets().unwrap(); + wait_for_node_tip(&original_node, new_height).await; assert_eq!( original_node.list_balances().spendable_onchain_balance_sats, premine_amount_sat * 2 @@ -708,9 +1092,9 @@ async fn onchain_wallet_recovery() { drop(original_node); // Now we start from scratch, only the seed remains the same. - let mut recovered_config = random_config(true); + let mut recovered_config = random_config(); 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(); @@ -734,9 +1118,9 @@ async fn onchain_wallet_recovery() { .unwrap(); wait_for_tx(&electrsd.client, txid).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; - + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; recovered_node.sync_wallets().unwrap(); + wait_for_node_tip(&recovered_node, new_height).await; assert_eq!( recovered_node.list_balances().spendable_onchain_balance_sats, premine_amount_sat * 3 @@ -744,46 +1128,337 @@ async fn onchain_wallet_recovery() { } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn test_rbf_via_mempool() { - run_rbf_test(false).await; +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(); + 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(); + 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 test_rbf_via_direct_block_insertion() { - run_rbf_test(true).await; +async fn onchain_wallet_full_scan_stop_gap_recovers_far_esplora_and_electrum_funds() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + premine_blocks(&bitcoind.client, &electrsd.client).await; + + do_onchain_wallet_full_scan_stop_gap_recovers_far_funds( + TestChainSource::Esplora(&electrsd), + &bitcoind, + &electrsd, + ) + .await; + do_onchain_wallet_full_scan_stop_gap_recovers_far_funds( + TestChainSource::Electrum(&electrsd), + &bitcoind, + &electrsd, + ) + .await; } -// `is_insert_block`: -// - `true`: transaction is mined immediately (no mempool), testing confirmed-Tx handling. -// - `false`: transaction stays in mempool until confirmation, testing unconfirmed-Tx handling. -async fn run_rbf_test(is_insert_block: bool) { +async fn do_onchain_wallet_full_scan_stop_gap_recovers_far_funds( + chain_source: TestChainSource<'_>, bitcoind: &BitcoinD, electrsd: &ElectrsD, +) { + let configured_stop_gap = DEFAULT_FULL_SCAN_STOP_GAP + 5; + + let address_source_config = random_config(); + let node_entropy = address_source_config.node_entropy; + let address_source_node = setup_node(&chain_source, address_source_config); + let mut far_address = None; + for _ in 0..configured_stop_gap { + far_address = Some(address_source_node.onchain_payment().new_address().unwrap()); + } + address_source_node.stop().unwrap(); + drop(address_source_node); + let far_address = far_address.unwrap(); + + let premine_amount_sat = 100_000; + let txid = bitcoind + .client + .send_to_address(&far_address, Amount::from_sat(premine_amount_sat)) + .unwrap() + .0 + .parse() + .unwrap(); + wait_for_tx(&electrsd.client, txid).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + + let mut default_gap_config = random_config(); + default_gap_config.node_entropy = node_entropy.clone(); + let default_gap_node = setup_node(&chain_source, default_gap_config); + default_gap_node.sync_wallets().unwrap(); + assert_eq!( + default_gap_node.list_balances().spendable_onchain_balance_sats, + 0, + "default full-scan stop gap should not recover funds past its address gap" + ); + default_gap_node.stop().unwrap(); + drop(default_gap_node); + + let mut configured_gap_config = random_config(); + configured_gap_config.node_entropy = node_entropy; + configured_gap_config.full_scan_stop_gap = Some(configured_stop_gap); + let configured_gap_node = setup_node(&chain_source, configured_gap_config); + configured_gap_node.sync_wallets().unwrap(); + assert_eq!( + configured_gap_node.list_balances().spendable_onchain_balance_sats, + premine_amount_sat, + "configured full-scan stop gap should recover funds past the default address gap" + ); +} + +#[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(); - let chain_source_bitcoind = TestChainSource::BitcoindRpcSync(&bitcoind); - let chain_source_electrsd = TestChainSource::Electrum(&electrsd); - let chain_source_esplora = TestChainSource::Esplora(&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); - macro_rules! config_node { - ($chain_source:expr, $anchor_channels:expr) => {{ - let config_a = random_config($anchor_channels); - let node = setup_node(&$chain_source, config_a); - node - }}; - } - let anchor_channels = false; - let nodes = vec![ - config_node!(chain_source_electrsd, anchor_channels), - config_node!(chain_source_bitcoind, anchor_channels), - config_node!(chain_source_esplora, anchor_channels), - ]; + // Mine the initial 101 blocks so bitcoind's wallet can fund our later sends. + premine_blocks(&bitcoind.client, &electrsd.client).await; - let (bitcoind, electrs) = (&bitcoind.client, &electrsd.client); - premine_blocks(bitcoind, electrs).await; + // Step 1: bring up an "original" node at the birthday height and generate addresses. + let original_config = random_config(); + let original_node_entropy = original_config.node_entropy; + let original_node = setup_node(&chain_source, original_config); - // Helpers declaration before starting the test - let all_addrs = - nodes.iter().map(|node| node.onchain_payment().new_address().unwrap()).collect::>(); - let amount_sat = 2_100_000; - let mut txid; + 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(); + 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(); + 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(); + 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(); + 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; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_rbf_via_direct_block_insertion() { + run_rbf_test(true).await; +} + +// `is_insert_block`: +// - `true`: transaction is mined immediately (no mempool), testing confirmed-Tx handling. +// - `false`: transaction stays in mempool until confirmation, testing unconfirmed-Tx handling. +async fn run_rbf_test(is_insert_block: bool) { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source_bitcoind = TestChainSource::BitcoindRpcSync(&bitcoind); + let chain_source_electrsd = TestChainSource::Electrum(&electrsd); + let chain_source_esplora = TestChainSource::Esplora(&electrsd); + + macro_rules! config_node { + ($chain_source:expr) => {{ + let config_a = random_config(); + let node = setup_node(&$chain_source, config_a); + node + }}; + } + let nodes = vec![ + config_node!(chain_source_electrsd), + config_node!(chain_source_bitcoind), + config_node!(chain_source_esplora), + ]; + + let (bitcoind, electrs) = (&bitcoind.client, &electrsd.client); + premine_blocks(bitcoind, electrs).await; + + // Helpers declaration before starting the test + let all_addrs = + nodes.iter().map(|node| node.onchain_payment().new_address().unwrap()).collect::>(); + let amount_sat = 2_100_000; + let mut txid; macro_rules! distribute_funds_all_nodes { () => { txid = distribute_funds_unconfirmed( @@ -882,7 +1557,7 @@ async fn run_rbf_test(is_insert_block: bool) { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn sign_verify_msg() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); - let config = random_config(true); + let config = random_config(); let chain_source = random_chain_source(&bitcoind, &electrsd); let node = setup_node(&chain_source, config); @@ -897,7 +1572,7 @@ async fn sign_verify_msg() { async fn connection_multi_listen() { 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, false, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let node_id_b = node_b.node_id(); @@ -917,7 +1592,7 @@ async fn connection_restart_behavior() { async fn do_connection_restart_behavior(persist: bool) { 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, false, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let node_id_a = node_a.node_id(); let node_id_b = node_b.node_id(); @@ -964,7 +1639,7 @@ async fn do_connection_restart_behavior(persist: bool) { async fn concurrent_connections_succeed() { 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_a, node_b) = setup_two_nodes(&chain_source, false, false); let node_a = Arc::new(node_a); let node_b = Arc::new(node_b); @@ -992,7 +1667,7 @@ async fn splice_channel() { 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_a, node_b) = setup_two_nodes(&chain_source, false, false); let address_a = node_a.onchain_payment().new_address().unwrap(); let address_b = node_b.onchain_payment().new_address().unwrap(); @@ -1014,17 +1689,20 @@ async fn splice_channel() { open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await; // Open a channel with Node A contributing the funding - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; 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()); let opening_transaction_fee_sat = 156; - let closing_transaction_fee_sat = 614; - let anchor_output_sat = 330; + let zero_fee_commitments = node_a.list_channels()[0].feerate_sat_per_1000_weight == 0; + let closing_transaction_fee_sat = if zero_fee_commitments { 0 } else { 614 }; + let anchor_output_sat = if zero_fee_commitments { 0 } else { 330 }; assert_eq!( node_a.list_balances().total_onchain_balance_sats, @@ -1036,6 +1714,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; @@ -1056,97 +1746,527 @@ async fn splice_channel() { // Splice-in funds for Node B so that it has outbound liquidity to make a payment node_b.splice_in(&user_channel_id_b, node_a.node_id(), 4_000_000).unwrap(); - let txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); - expect_splice_negotiated_event!(node_b, node_a.node_id()); + 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(); + 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()); + + let expected_splice_in_fee_sat = 251; + let expected_splice_in_onchain_cost_sat = 253; + + // 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 = + payments.into_iter().find(|p| p.id == PaymentId(txo.txid.to_byte_array())).unwrap(); + assert_eq!(payment.fee_paid_msat, Some(expected_splice_in_fee_sat * 1_000)); + + assert_eq!( + node_b.list_balances().total_onchain_balance_sats, + premine_amount_sat - 4_000_000 - expected_splice_in_onchain_cost_sat + ); + assert_eq!( + node_b.list_balances().total_lightning_balance_sats, + expected_splice_in_lightning_balance_sat + ); + + let payment_id = + node_b.spontaneous_payment().send(amount_msat, node_a.node_id(), None).unwrap(); + + expect_payment_successful_event!(node_b, Some(payment_id), None); + expect_payment_received_event!(node_a, amount_msat); + + // Mine a block to give time for the HTLC to resolve + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; + + assert_eq!( + node_a.list_balances().total_lightning_balance_sats, + 4_000_000 - closing_transaction_fee_sat - anchor_output_sat + amount_msat / 1000 + ); + assert_eq!( + node_b.list_balances().total_lightning_balance_sats, + expected_splice_in_lightning_balance_sat - amount_msat / 1000 + ); + + // Splice-out funds for Node A from the payment sent by Node B + let address = node_a.onchain_payment().new_address().unwrap(); + node_a.splice_out(&user_channel_id_a, node_b.node_id(), &address, amount_msat / 1000).unwrap(); + + 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(); + 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()); + + let expected_splice_out_fee_sat = 183; + + let payments = node_a.list_payments(); + 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, + premine_amount_sat - 4_000_000 - opening_transaction_fee_sat + amount_msat / 1000 + ); + assert_eq!( + node_a.list_balances().total_lightning_balance_sats, + 4_000_000 - closing_transaction_fee_sat - anchor_output_sat - expected_splice_out_fee_sat + ); +} + +#[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, 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, 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, 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(); - expect_channel_ready_event!(node_a, node_b.node_id()); - 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; - - // 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; + 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()); - let payments = node_b.list_payments(); - 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_in_fee_sat * 1_000)); + // 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; - assert_eq!( - node_b.list_balances().total_onchain_balance_sats, - premine_amount_sat - 4_000_000 - expected_splice_in_onchain_cost_sat - ); - assert_eq!( - node_b.list_balances().total_lightning_balance_sats, - expected_splice_in_lightning_balance_sat - ); + // 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 = - node_b.spontaneous_payment().send(amount_msat, node_a.node_id(), None).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(&bitcoind.client, &electrsd.client, original_height as usize + 1).await; + node_b.sync_wallets().unwrap(); - expect_payment_successful_event!(node_b, Some(payment_id), None); - expect_payment_received_event!(node_a, amount_msat); + // 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, .. } + )); - // Mine a block to give time for the HTLC to resolve - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} - assert_eq!( - node_a.list_balances().total_lightning_balance_sats, - 4_000_000 - closing_transaction_fee_sat - anchor_output_sat + amount_msat / 1000 - ); - assert_eq!( - node_b.list_balances().total_lightning_balance_sats, - expected_splice_in_lightning_balance_sat - amount_msat / 1000 - ); +#[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, false); - // Splice-out funds for Node A from the payment sent by Node B - let address = node_a.onchain_payment().new_address().unwrap(); - node_a.splice_out(&user_channel_id_a, node_b.node_id(), &address, amount_msat / 1000).unwrap(); + 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; - let txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); - expect_splice_negotiated_event!(node_b, node_a.node_id()); + 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(); - expect_channel_ready_event!(node_a, node_b.node_id()); - expect_channel_ready_event!(node_b, node_a.node_id()); + 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()); - let expected_splice_out_fee_sat = 183; + // 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(); - let payments = node_a.list_payments(); - 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)); + // 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"); - assert_eq!( - node_a.list_balances().total_onchain_balance_sats, - premine_amount_sat - 4_000_000 - opening_transaction_fee_sat + amount_msat / 1000 - ); - assert_eq!( - node_a.list_balances().total_lightning_balance_sats, - 4_000_000 - closing_transaction_fee_sat - anchor_output_sat - expected_splice_out_fee_sat - ); + 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(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let address_a = node_a.onchain_payment().new_address().unwrap(); let premine_amount_sat = 5_000_000; @@ -1161,10 +2281,12 @@ async fn simple_bolt12_send_receive() { node_a.sync_wallets().unwrap(); open_channel(&node_a, &node_b, 4_000_000, true, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; expect_channel_ready_event!(node_a, node_b.node_id()); expect_channel_ready_event!(node_b, node_a.node_id()); @@ -1388,7 +2510,7 @@ async fn async_payment() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let mut config_sender = random_config(true); + let mut config_sender = random_config(); config_sender.node_config.listening_addresses = None; config_sender.node_config.node_alias = None; config_sender.log_writer = @@ -1396,20 +2518,20 @@ async fn async_payment() { config_sender.async_payments_role = Some(AsyncPaymentsRole::Client); let node_sender = setup_node(&chain_source, config_sender); - let mut config_sender_lsp = random_config(true); + let mut config_sender_lsp = random_config(); config_sender_lsp.log_writer = TestLogWriter::Custom(Arc::new(MultiNodeLogger::new("sender_lsp ".to_string()))); config_sender_lsp.async_payments_role = Some(AsyncPaymentsRole::Server); let node_sender_lsp = setup_node(&chain_source, config_sender_lsp); - let mut config_receiver_lsp = random_config(true); + let mut config_receiver_lsp = random_config(); config_receiver_lsp.log_writer = TestLogWriter::Custom(Arc::new(MultiNodeLogger::new("receiver_lsp".to_string()))); config_receiver_lsp.async_payments_role = Some(AsyncPaymentsRole::Server); let node_receiver_lsp = setup_node(&chain_source, config_receiver_lsp); - let mut config_receiver = random_config(true); + let mut config_receiver = random_config(); config_receiver.node_config.listening_addresses = None; config_receiver.node_config.node_alias = None; config_receiver.log_writer = @@ -1446,12 +2568,16 @@ async fn async_payment() { ) .await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_sender.sync_wallets().unwrap(); node_sender_lsp.sync_wallets().unwrap(); node_receiver_lsp.sync_wallets().unwrap(); node_receiver.sync_wallets().unwrap(); + wait_for_node_tip(&node_sender, new_height).await; + wait_for_node_tip(&node_sender_lsp, new_height).await; + wait_for_node_tip(&node_receiver_lsp, new_height).await; + wait_for_node_tip(&node_receiver, new_height).await; expect_channel_ready_event!(node_sender, node_sender_lsp.node_id()); expect_channel_ready_events!( @@ -1521,7 +2647,7 @@ async fn test_node_announcement_propagation() { let chain_source = random_chain_source(&bitcoind, &electrsd); // Node A will use both listening and announcement addresses - let mut config_a = random_config(true); + let mut config_a = random_config(); let node_a_alias_string = "ldk-node-a".to_string(); let mut node_a_alias_bytes = [0u8; 32]; node_a_alias_bytes[..node_a_alias_string.as_bytes().len()] @@ -1533,7 +2659,7 @@ async fn test_node_announcement_propagation() { config_a.node_config.announcement_addresses = Some(node_a_announcement_addresses.clone()); // Node B will only use listening addresses - let mut config_b = random_config(true); + let mut config_b = random_config(); let node_b_alias_string = "ldk-node-b".to_string(); let mut node_b_alias_bytes = [0u8; 32]; node_b_alias_bytes[..node_b_alias_string.as_bytes().len()] @@ -1618,7 +2744,7 @@ async fn generate_bip21_uri() { 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_a, node_b) = setup_two_nodes(&chain_source, false, false); let address_a = node_a.onchain_payment().new_address().unwrap(); let premined_sats = 5_000_000; @@ -1648,10 +2774,12 @@ async fn generate_bip21_uri() { node_a.sync_wallets().unwrap(); open_channel(&node_a, &node_b, 4_000_000, true, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; expect_channel_ready_event!(node_a, node_b.node_id()); expect_channel_ready_event!(node_b, node_a.node_id()); @@ -1668,12 +2796,24 @@ 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()); + + 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(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let address_a = node_a.onchain_payment().new_address().unwrap(); let premined_sats = 5_000_000; @@ -1688,10 +2828,12 @@ async fn unified_send_receive_bip21_uri() { node_a.sync_wallets().unwrap(); open_channel(&node_a, &node_b, 4_000_000, true, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; expect_channel_ready_event!(node_a, node_b.node_id()); expect_channel_ready_event!(node_b, node_a.node_id()); @@ -1770,11 +2912,13 @@ async fn unified_send_receive_bip21_uri() { }, }; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; wait_for_tx(&electrsd.client, txid).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; assert_eq!(node_b.list_balances().total_onchain_balance_sats, 800_000); assert_eq!(node_b.list_balances().total_lightning_balance_sats, 200_000); @@ -1810,24 +2954,24 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { disable_client_reserve: false, }; - let service_config = random_config(true); + let service_config = random_config(); 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(); let service_addr = service_node.listening_addresses().unwrap().first().unwrap().clone(); - let client_config = random_config(true); + let client_config = random_config(); 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(); - let payer_config = random_config(true); + let payer_config = random_config(); setup_builder!(payer_builder, payer_config.node_config); payer_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); let payer_node = payer_builder.build(payer_config.node_entropy.into()).unwrap(); @@ -1854,9 +2998,11 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { println!("Opening channel payer_node -> service_node!"); open_channel(&payer_node, &service_node, 5_000_000, false, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; service_node.sync_wallets().unwrap(); payer_node.sync_wallets().unwrap(); + wait_for_node_tip(&service_node, new_height).await; + wait_for_node_tip(&payer_node, new_height).await; expect_channel_ready_event!(payer_node, service_node.node_id()); expect_channel_ready_event!(service_node, payer_node.node_id()); @@ -2015,7 +3161,7 @@ async fn facade_logging() { let chain_source = random_chain_source(&bitcoind, &electrsd); let logger = init_log_logger(LevelFilter::Trace); - let mut config = random_config(false); + let mut config = random_config(); config.log_writer = TestLogWriter::LogFacade; println!("== Facade logging starts =="); @@ -2031,7 +3177,7 @@ async fn facade_logging() { async fn spontaneous_send_with_custom_preimage() { 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_a, node_b) = setup_two_nodes(&chain_source, false, false); let address_a = node_a.onchain_payment().new_address().unwrap(); let premine_sat = 1_000_000; @@ -2045,9 +3191,11 @@ async fn spontaneous_send_with_custom_preimage() { node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); open_channel(&node_a, &node_b, 500_000, true, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; expect_channel_ready_event!(node_a, node_b.node_id()); expect_channel_ready_event!(node_b, node_a.node_id()); @@ -2098,7 +3246,7 @@ async fn spontaneous_send_with_custom_preimage() { async fn drop_in_async_context() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let config = random_config(true); + let config = random_config(); let node = setup_node(&chain_source, config); node.stop().unwrap(); } @@ -2129,24 +3277,24 @@ async fn lsps2_client_trusts_lsp() { disable_client_reserve: false, }; - let service_config = random_config(true); + let service_config = random_config(); 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(); let service_addr = service_node.listening_addresses().unwrap().first().unwrap().clone(); - let client_config = random_config(true); + let client_config = random_config(); 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(); - let payer_config = random_config(true); + let payer_config = random_config(); setup_builder!(payer_builder, payer_config.node_config); payer_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); let payer_node = payer_builder.build(payer_config.node_entropy.into()).unwrap(); @@ -2218,7 +3366,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 +3375,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) @@ -2255,14 +3403,16 @@ async fn lsps2_client_trusts_lsp() { // Check the nodes pick up on the confirmed funding tx now. wait_for_tx(&electrsd.client, funding_txo.txid).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; service_node.sync_wallets().unwrap(); client_node.sync_wallets().unwrap(); + wait_for_node_tip(&service_node, new_height).await; + wait_for_node_tip(&payer_node, new_height).await; assert_eq!( 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 +3421,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) @@ -2304,26 +3454,26 @@ async fn lsps2_lsp_trusts_client_but_client_does_not_claim() { disable_client_reserve: false, }; - let service_config = random_config(true); + let service_config = random_config(); 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(); let service_addr = service_node.listening_addresses().unwrap().first().unwrap().clone(); - let client_config = random_config(true); + let client_config = random_config(); 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(); - let payer_config = random_config(true); + let payer_config = random_config(); setup_builder!(payer_builder, payer_config.node_config); payer_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); let payer_node = payer_builder.build(payer_config.node_entropy.into()).unwrap(); @@ -2349,9 +3499,11 @@ async fn lsps2_lsp_trusts_client_but_client_does_not_claim() { // Open a channel payer -> service that will allow paying the JIT invoice open_channel(&payer_node, &service_node, 5_000_000, false, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; service_node.sync_wallets().unwrap(); payer_node.sync_wallets().unwrap(); + wait_for_node_tip(&service_node, new_height).await; + wait_for_node_tip(&payer_node, new_height).await; expect_channel_ready_event!(payer_node, service_node.node_id()); expect_channel_ready_event!(service_node, payer_node.node_id()); @@ -2384,14 +3536,16 @@ async fn lsps2_lsp_trusts_client_but_client_does_not_claim() { // Check the nodes pick up on the confirmed funding tx now. wait_for_tx(&electrsd.client, funding_txo.txid).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; service_node.sync_wallets().unwrap(); client_node.sync_wallets().unwrap(); + wait_for_node_tip(&service_node, new_height).await; + wait_for_node_tip(&client_node, new_height).await; assert_eq!( 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 +3554,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) @@ -2414,7 +3568,7 @@ async fn payment_persistence_after_restart() { // Setup nodes manually so we can restart node_a with the same config println!("== Node A =="); - let mut config_a = random_config(true); + let mut config_a = random_config(); config_a.store_type = TestStoreType::Sqlite; let num_payments = 200; @@ -2424,7 +3578,7 @@ async fn payment_persistence_after_restart() { let node_a = setup_node(&chain_source, config_a.clone()); println!("\n== Node B =="); - let config_b = random_config(true); + let config_b = random_config(); let node_b = setup_node(&chain_source, config_b); let addr_a = node_a.onchain_payment().new_address().unwrap(); @@ -2447,9 +3601,11 @@ async fn payment_persistence_after_restart() { // Open a large channel from node_a to node_b let channel_amount_sat = 5_000_000; open_channel(&node_a, &node_b, channel_amount_sat, true, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; expect_channel_ready_event!(node_a, node_b.node_id()); expect_channel_ready_event!(node_b, node_a.node_id()); @@ -2556,7 +3712,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) } @@ -2702,7 +3862,7 @@ async fn fs_store_persistence_backwards_compatibility() { async fn onchain_fee_bump_rbf() { 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_a, node_b) = setup_two_nodes(&chain_source, false, false); // Fund both nodes let addr_a = node_a.onchain_payment().new_address().unwrap(); @@ -2802,9 +3962,11 @@ async fn onchain_fee_bump_rbf() { } // Confirm the transaction and try to bump again (should fail) - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; assert_eq!( Err(NodeError::InvalidPaymentId), @@ -2840,11 +4002,60 @@ 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, 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(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let addr_a = node_a.onchain_payment().new_address().unwrap(); let addr_b = node_b.onchain_payment().new_address().unwrap(); @@ -2864,10 +4075,12 @@ async fn open_channel_with_all_with_anchors() { let funding_txo = open_channel_with_all(&node_a, &node_b, false, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; 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()); @@ -2886,69 +4099,175 @@ 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(); node_b.stop().unwrap(); } +#[derive(Clone, Copy)] +enum OpenChannelVariant { + Standard, + Announced, + ZeroReserve, + StandardWithAll, + AnnouncedWithAll, + ZeroReserveWithAll, +} + +impl OpenChannelVariant { + fn label(&self) -> &'static str { + match self { + Self::Standard => "open_channel", + Self::Announced => "open_announced_channel", + Self::ZeroReserve => "open_0reserve_channel", + Self::StandardWithAll => "open_channel_with_all", + Self::AnnouncedWithAll => "open_announced_channel_with_all", + Self::ZeroReserveWithAll => "open_0reserve_channel_with_all", + } + } +} + +fn open_channel_variant( + variant: OpenChannelVariant, node_a: &Node, node_b: &Node, channel_amount_sats: u64, +) -> Result<(), NodeError> { + let address = node_b.listening_addresses().unwrap().first().unwrap().clone(); + match variant { + OpenChannelVariant::Standard => node_a + .open_channel(node_b.node_id(), address, channel_amount_sats, None, None) + .map(|_| ()), + OpenChannelVariant::Announced => node_a + .open_announced_channel(node_b.node_id(), address, channel_amount_sats, None, None) + .map(|_| ()), + OpenChannelVariant::ZeroReserve => node_a + .open_0reserve_channel(node_b.node_id(), address, channel_amount_sats, None, None) + .map(|_| ()), + OpenChannelVariant::StandardWithAll => { + node_a.open_channel_with_all(node_b.node_id(), address, None, None).map(|_| ()) + }, + OpenChannelVariant::AnnouncedWithAll => node_a + .open_announced_channel_with_all(node_b.node_id(), address, None, None) + .map(|_| ()), + OpenChannelVariant::ZeroReserveWithAll => { + node_a.open_0reserve_channel_with_all(node_b.node_id(), address, None, None).map(|_| ()) + }, + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn open_channel_with_all_without_anchors() { +async fn open_channel_variants_reserve_funds_for_anchor_peers() { 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, false, false); - let addr_a = node_a.onchain_payment().new_address().unwrap(); - let addr_b = node_b.onchain_payment().new_address().unwrap(); + let exact_variants = [ + OpenChannelVariant::Standard, + OpenChannelVariant::Announced, + OpenChannelVariant::ZeroReserve, + ]; + let with_all_variants = [ + OpenChannelVariant::StandardWithAll, + OpenChannelVariant::AnnouncedWithAll, + OpenChannelVariant::ZeroReserveWithAll, + ]; let premine_amount_sat = 1_000_000; + let exact_channel_amount_sat = premine_amount_sat - 10_000; + let anchor_reserve_sat = 25_000; + + let mut addresses = Vec::new(); + let mut exact_cases = Vec::new(); + for variant in exact_variants { + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); + addresses.push(node_a.onchain_payment().new_address().unwrap()); + addresses.push(node_b.onchain_payment().new_address().unwrap()); + exact_cases.push((variant, node_a, node_b)); + } + + let mut with_all_cases = Vec::new(); + for variant in with_all_variants { + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); + addresses.push(node_a.onchain_payment().new_address().unwrap()); + addresses.push(node_b.onchain_payment().new_address().unwrap()); + with_all_cases.push((variant, node_a, node_b)); + } premine_and_distribute_funds( &bitcoind.client, &electrsd.client, - vec![addr_a, addr_b], + addresses, Amount::from_sat(premine_amount_sat), ) .await; - node_a.sync_wallets().unwrap(); - node_b.sync_wallets().unwrap(); - assert_eq!(node_a.list_balances().spendable_onchain_balance_sats, premine_amount_sat); - let funding_txo = open_channel_with_all(&node_a, &node_b, false, &electrsd).await; + for (_, node_a, node_b) in exact_cases.iter().chain(with_all_cases.iter()) { + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + assert_eq!(node_a.list_balances().spendable_onchain_balance_sats, premine_amount_sat); + assert_eq!(node_b.list_balances().spendable_onchain_balance_sats, premine_amount_sat); + } - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + for (variant, node_a, node_b) in exact_cases { + assert_eq!( + Err(NodeError::InsufficientFunds), + open_channel_variant(variant, &node_a, &node_b, exact_channel_amount_sat), + "{} should require funds for the channel amount plus anchor reserve", + variant.label() + ); + node_a.stop().unwrap(); + node_b.stop().unwrap(); + } - node_a.sync_wallets().unwrap(); - node_b.sync_wallets().unwrap(); + let mut opened_with_all_cases = Vec::new(); + for (variant, node_a, node_b) in with_all_cases { + open_channel_variant(variant, &node_a, &node_b, 0) + .unwrap_or_else(|e| panic!("{} failed: {e:?}", variant.label())); - 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()); + let funding_txo_a = expect_channel_pending_event!(node_a, node_b.node_id()); + let funding_txo_b = expect_channel_pending_event!(node_b, node_a.node_id()); + assert_eq!(funding_txo_a, funding_txo_b, "{} funding txo mismatch", variant.label()); + wait_for_tx(&electrsd.client, funding_txo_a.txid).await; - // Without anchors, there should be no remaining balance - let remaining_balance = node_a.list_balances().spendable_onchain_balance_sats; - assert_eq!( - remaining_balance, 0, - "Remaining balance {remaining_balance} should be zero without anchor reserve" - ); + opened_with_all_cases.push((variant, node_a, node_b, funding_txo_a)); + } - // Verify a channel was opened with all the funds accounting for fees - let channels = node_a.list_channels(); - 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.funding_txo.unwrap(), funding_txo); + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; - node_a.stop().unwrap(); - node_b.stop().unwrap(); + for (variant, node_a, node_b, funding_txo) in opened_with_all_cases { + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; + + 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()); + + let balances = node_a.list_balances(); + assert_eq!(balances.total_onchain_balance_sats, anchor_reserve_sat - 1); + assert_eq!(balances.total_anchor_channels_reserve_sats, anchor_reserve_sat - 1); + assert_eq!(balances.spendable_onchain_balance_sats, 0); + + let channels = node_a.list_channels(); + assert_eq!(channels.len(), 1, "{} should have one channel", variant.label()); + let channel = &channels[0]; + // Also subtract the fees spent to open the channel + assert_eq!(channel.channel_value_sats, premine_amount_sat - anchor_reserve_sat - 155); + assert_eq!(channel.counterparty.node_id, node_b.node_id()); + assert!(channel.counterparty.features.supports_anchors_zero_fee_htlc_tx()); + assert!(!channel.counterparty.features.requires_anchors_zero_fee_htlc_tx()); + assert_eq!(channel.funding_txo.unwrap(), funding_txo); + assert_eq!(channel.reserve_type, Some(ReserveType::Adaptive)); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); + } } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn splice_in_with_all_balance() { 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_a, node_b) = setup_two_nodes(&chain_source, false, false); let addr_a = node_a.onchain_payment().new_address().unwrap(); let addr_b = node_b.onchain_payment().new_address().unwrap(); @@ -2970,10 +4289,12 @@ async fn splice_in_with_all_balance() { // Open a channel with a fixed amount first let funding_txo = open_channel(&node_a, &node_b, channel_amount_sat, false, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; 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()); @@ -2989,10 +4310,12 @@ async fn splice_in_with_all_balance() { // Splice in with all remaining on-chain funds splice_in_with_all(&node_a, &node_b, &user_channel_id_a, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; let _user_channel_id_a2 = expect_channel_ready_event!(node_a, node_b.node_id()); let _user_channel_id_b2 = expect_channel_ready_event!(node_b, node_a.node_id()); @@ -3021,3 +4344,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(); + 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(); + 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(); + 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/integration_tests_vss.rs b/tests/integration_tests_vss.rs index 210e9a8b25..f0838585f7 100644 --- a/tests/integration_tests_vss.rs +++ b/tests/integration_tests_vss.rs @@ -20,7 +20,7 @@ async fn channel_full_cycle_with_vss_store() { let (bitcoind, electrsd) = common::setup_bitcoind_and_electrsd(); println!("== Node A =="); let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); - let config_a = common::random_config(true); + let config_a = common::random_config(); let mut builder_a = Builder::from_config(config_a.node_config); builder_a.set_chain_source_esplora(esplora_url.clone(), None); let vss_base_url = std::env::var("TEST_VSS_BASE_URL").unwrap(); @@ -35,7 +35,7 @@ async fn channel_full_cycle_with_vss_store() { node_a.start().unwrap(); println!("\n== Node B =="); - let config_b = common::random_config(true); + let config_b = common::random_config(); let mut builder_b = Builder::from_config(config_b.node_config); builder_b.set_chain_source_esplora(esplora_url.clone(), None); let node_b = builder_b diff --git a/tests/probing_tests.rs b/tests/probing_tests.rs new file mode 100644 index 0000000000..f0480bc5e9 --- /dev/null +++ b/tests/probing_tests.rs @@ -0,0 +1,477 @@ +// Integration tests for the probing service. +// +// Budget tests – linear A ──[1M sats]──▶ B ──[1M sats]──▶ C topology: +// +// probe_budget_increments_and_decrements +// Verifies locked_msat rises when a probe is dispatched and returns +// to zero once the probe resolves. +// +// locked_msat_accounts_for_routing_fees +// Asserts the exact locked_msat (delivered amount + per-hop fee) for a single +// in-flight probe, proving fees are tracked and not just the delivered amount. +// +// exhausted_probe_budget_blocks_new_probes +// Samples locked_msat across multiple probe cycles and asserts it never +// exceeds the configured max_locked_msat budget cap. +// +// probing_budget_restored_after_node_restart +// Dispatches a probe, then stops node_b before the failure can propagate +// back so the pending probe HTLC is preserved. Restarts node_a and asserts +// the prober's locked_msat is rebuilt non-zero from list_recent_payments(). + +mod common; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use common::{ + expect_channel_ready_event, expect_event, generate_blocks_and_wait, open_channel, + premine_and_distribute_funds, random_chain_source, random_config, setup_bitcoind_and_electrsd, + setup_node, wait_for_channel_ready_to_send, TestNode, TestStoreType, +}; +use ldk_node::bitcoin::Amount; +use ldk_node::probing::{ProbingConfigBuilder, ProbingStrategy}; +use ldk_node::Event; +use lightning::routing::router::Path; + +const PROBE_AMOUNT_MSAT: u64 = 1_000_000; +const PROBING_INTERVAL_MILLISECONDS: u64 = 100; + +/// FixedPathStrategy — returns a fixed pre-built path; used by budget tests. +/// +/// The path is set after node and channel setup via [`set_path`]. +struct FixedPathStrategy { + path: Mutex>, + ready_to_probe: AtomicBool, +} + +impl FixedPathStrategy { + fn new() -> Arc { + Arc::new(Self { path: Mutex::new(None), ready_to_probe: AtomicBool::new(false) }) + } + + fn set_path(&self, path: Path) { + *self.path.lock().unwrap() = Some(path); + } + + fn start_probing(&self) { + self.ready_to_probe.store(true, Ordering::Relaxed); + } + + fn stop_probing(&self) { + self.ready_to_probe.store(false, Ordering::Relaxed); + } +} + +impl ProbingStrategy for FixedPathStrategy { + fn next_probe(&self) -> Option { + if self.ready_to_probe.load(Ordering::Relaxed) { + self.path.lock().unwrap().clone() + } else { + None + } + } +} + +/// Builds a 2-hop probe path: node_a → node_b → node_c using live channel info. +fn build_probe_path( + node_a: &TestNode, node_b: &TestNode, node_c: &TestNode, amount_msat: u64, +) -> Path { + use lightning::routing::router::RouteHop; + use lightning_types::features::{ChannelFeatures, NodeFeatures}; + + let ch_ab = node_a + .list_channels() + .into_iter() + .find(|ch| ch.counterparty.node_id == node_b.node_id() && ch.short_channel_id.is_some()) + .expect("A→B channel not found"); + let ch_bc = node_b + .list_channels() + .into_iter() + .find(|ch| ch.counterparty.node_id == node_c.node_id() && ch.short_channel_id.is_some()) + .expect("B→C channel not found"); + + Path { + hops: vec![ + RouteHop { + pubkey: node_b.node_id(), + node_features: NodeFeatures::empty(), + short_channel_id: ch_ab.short_channel_id.unwrap(), + channel_features: ChannelFeatures::empty(), + fee_msat: 1000, + cltv_expiry_delta: 144, + maybe_announced_channel: true, + }, + RouteHop { + pubkey: node_c.node_id(), + node_features: NodeFeatures::empty(), + short_channel_id: ch_bc.short_channel_id.unwrap(), + channel_features: ChannelFeatures::empty(), + fee_msat: amount_msat, + cltv_expiry_delta: 18, + maybe_announced_channel: true, + }, + ], + blinded_tail: None, + } +} + +/// Verifies that `locked_msat` increases when a probe is dispatched and returns +/// to zero once the probe resolves (succeeds or fails). +#[tokio::test(flavor = "multi_thread")] +async fn probe_budget_increments_and_decrements() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + + let node_b = setup_node(&chain_source, random_config()); + let node_c = setup_node(&chain_source, random_config()); + + let mut config_a = random_config(); + let strategy = FixedPathStrategy::new(); + config_a.probing = Some( + ProbingConfigBuilder::custom(strategy.clone()) + .interval(Duration::from_millis(PROBING_INTERVAL_MILLISECONDS)) + .max_locked_msat(10 * PROBE_AMOUNT_MSAT) + .build(), + ); + let node_a = setup_node(&chain_source, config_a); + + 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(2_000_000), + ) + .await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 1_000_000, true, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + node_b.sync_wallets().unwrap(); + open_channel(&node_b, &node_c, 1_000_000, true, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + node_c.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_event!(node_b, ChannelReady); + expect_event!(node_b, ChannelReady); + expect_event!(node_c, ChannelReady); + + // Build the probe path now that channels are ready, then enable probing. + strategy.set_path(build_probe_path(&node_a, &node_b, &node_c, PROBE_AMOUNT_MSAT)); + // First hop carries amount + per-hop fee; second hop carries just amount. + wait_for_channel_ready_to_send(&node_a, &node_b, PROBE_AMOUNT_MSAT + 1000).await; + wait_for_channel_ready_to_send(&node_b, &node_c, PROBE_AMOUNT_MSAT).await; + strategy.start_probing(); + + let went_up = tokio::time::timeout(Duration::from_secs(30), async { + loop { + if node_a.prober().unwrap().locked_msat() > 0 { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }) + .await + .is_ok(); + assert!(went_up, "locked_msat never increased — no probe was dispatched"); + println!("First probe dispatched; locked_msat = {}", node_a.prober().unwrap().locked_msat()); + + strategy.stop_probing(); + let cleared = tokio::time::timeout(Duration::from_secs(30), async { + loop { + if node_a.prober().unwrap().locked_msat() == 0 { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }) + .await + .is_ok(); + assert!(cleared, "locked_msat never returned to zero after probe resolved"); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); + node_c.stop().unwrap(); +} + +/// Verifies that `locked_msat` accounts for routing fees, not just the delivered amount: +/// a probe along A→B→C locks `delivered amount + per-hop fee` on the first-hop channel. +/// +/// The budget is sized to exactly one probe's worth, so at most one probe is in flight and +/// the observed `locked_msat` is deterministic. The existing budget test only checks that it +/// is non-zero; this asserts the precise value, which a fees-excluded accounting would miss. +#[tokio::test(flavor = "multi_thread")] +async fn locked_msat_accounts_for_routing_fees() { + // First hop carries the delivered amount plus this per-hop fee (see `build_probe_path`). + const FIRST_HOP_FEE_MSAT: u64 = 1000; + const LOCKED_PER_PROBE_MSAT: u64 = PROBE_AMOUNT_MSAT + FIRST_HOP_FEE_MSAT; + + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + + let node_b = setup_node(&chain_source, random_config()); + let node_c = setup_node(&chain_source, random_config()); + + let mut config_a = random_config(); + let strategy = FixedPathStrategy::new(); + config_a.probing = Some( + ProbingConfigBuilder::custom(strategy.clone()) + .interval(Duration::from_millis(PROBING_INTERVAL_MILLISECONDS)) + // Budget for exactly one in-flight probe so locked_msat is deterministic. + .max_locked_msat(LOCKED_PER_PROBE_MSAT) + .build(), + ); + let node_a = setup_node(&chain_source, config_a); + + 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(2_000_000), + ) + .await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 1_000_000, true, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + node_b.sync_wallets().unwrap(); + open_channel(&node_b, &node_c, 1_000_000, true, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + node_c.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_event!(node_b, ChannelReady); + expect_event!(node_b, ChannelReady); + expect_event!(node_c, ChannelReady); + + strategy.set_path(build_probe_path(&node_a, &node_b, &node_c, PROBE_AMOUNT_MSAT)); + wait_for_channel_ready_to_send(&node_a, &node_b, LOCKED_PER_PROBE_MSAT).await; + wait_for_channel_ready_to_send(&node_b, &node_c, PROBE_AMOUNT_MSAT).await; + strategy.start_probing(); + + // Capture locked_msat the moment the first probe goes in flight. With a single-probe + // budget the value is only ever 0 or exactly one probe's worth, so the first non-zero + // reading is the full first-hop HTLC. + let locked = tokio::time::timeout(Duration::from_secs(30), async { + loop { + let locked = node_a.prober().unwrap().locked_msat(); + if locked > 0 { + break locked; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }) + .await + .expect("locked_msat never increased — no probe was dispatched"); + + assert_eq!( + locked, LOCKED_PER_PROBE_MSAT, + "locked_msat must equal the delivered amount plus routing fees, not just the delivered amount" + ); + + strategy.stop_probing(); + node_a.stop().unwrap(); + node_b.stop().unwrap(); + node_c.stop().unwrap(); +} + +/// Verifies that `locked_msat` is restored after the node is stopped and restarted +/// while a probe is still in flight. +/// +/// Race-sensitive: once a probe is dispatched, the failure round-trip +/// (`A→B→C → C fails back → B → A`) resolves it within milliseconds. To keep the +/// HTLC pending across the restart we observe `locked_msat > 0` and then *immediately* +/// call `node_a.disconnect(node_b)`, which closes A's socket to B in-process — much +/// faster than `node_b.stop()` — so any failure message from B is dropped before A +/// processes it. If the race is lost on a given probe (locked_msat drops back to 0 +/// after the disconnect), we reconnect and let the next probe tick try again. +/// The pending Probe entry persists in `node_a`'s channel manager and must be +/// rebuilt by the prober's `locked_msat` on restart via `list_recent_payments()`. +#[tokio::test(flavor = "multi_thread")] +async fn probing_budget_restored_after_node_restart() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + + let node_b = setup_node(&chain_source, random_config()); + let node_c = setup_node(&chain_source, random_config()); + + let mut config_a = random_config(); + // Use a pure on-disk store so state survives the restart. + config_a.store_type = TestStoreType::Sqlite; + let strategy = FixedPathStrategy::new(); + config_a.probing = Some( + ProbingConfigBuilder::custom(strategy.clone()) + .interval(Duration::from_millis(PROBING_INTERVAL_MILLISECONDS)) + .max_locked_msat(10 * PROBE_AMOUNT_MSAT) + .build(), + ); + let restart_config = config_a.clone(); + let node_a = setup_node(&chain_source, config_a); + + 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(2_000_000), + ) + .await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 1_000_000, true, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + node_b.sync_wallets().unwrap(); + open_channel(&node_b, &node_c, 1_000_000, true, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + node_c.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_event!(node_b, ChannelReady); + expect_event!(node_b, ChannelReady); + expect_event!(node_c, ChannelReady); + + strategy.set_path(build_probe_path(&node_a, &node_b, &node_c, PROBE_AMOUNT_MSAT)); + wait_for_channel_ready_to_send(&node_a, &node_b, PROBE_AMOUNT_MSAT + 1000).await; + wait_for_channel_ready_to_send(&node_b, &node_c, PROBE_AMOUNT_MSAT).await; + + let node_b_id = node_b.node_id(); + let node_b_addr = node_b.listening_addresses().unwrap().into_iter().next().unwrap(); + + strategy.start_probing(); + + // Dispatch a probe and isolate node_a from node_b before the failure can + // propagate back. Tight polling + in-process disconnect minimises the race + // window; on a lost race we reconnect and let the prober's next tick try. + let isolated = tokio::time::timeout(Duration::from_secs(30), async { + loop { + if node_a.prober().unwrap().locked_msat() > 0 { + node_a.disconnect(node_b_id).ok(); + if node_a.prober().unwrap().locked_msat() > 0 { + return true; + } + node_a.connect(node_b_id, node_b_addr.clone(), false).ok(); + } + tokio::time::sleep(Duration::from_millis(1)).await; + } + }) + .await + .unwrap_or(false); + assert!(isolated, "could not preserve in-flight probe long enough to restart"); + strategy.stop_probing(); + + let locked_before = node_a.prober().unwrap().locked_msat(); + println!("Before restart: locked_msat = {}", locked_before); + assert!(locked_before > 0, "probe resolved before we could isolate node_a — flaky timing"); + + node_a.stop().unwrap(); + + // Restart node_a from the same persisted state. + let node_a = setup_node(&chain_source, restart_config); + + let locked_after = node_a.prober().unwrap().locked_msat(); + println!("After restart: locked_msat = {}", locked_after); + assert!( + locked_after > 0, + "locked_msat was not restored after restart (before={} after={})", + locked_before, + locked_after + ); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); + node_c.stop().unwrap(); +} + +/// Verifies that `locked_msat` never exceeds `max_locked_msat` across multiple probe cycles. +#[tokio::test(flavor = "multi_thread")] +async fn exhausted_probe_budget_blocks_new_probes() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + + let node_b = setup_node(&chain_source, random_config()); + let node_c = setup_node(&chain_source, random_config()); + + let mut config_a = random_config(); + let strategy = FixedPathStrategy::new(); + let max_locked_msat = 2 * PROBE_AMOUNT_MSAT; + config_a.probing = Some( + ProbingConfigBuilder::custom(strategy.clone()) + .interval(Duration::from_millis(PROBING_INTERVAL_MILLISECONDS)) + .max_locked_msat(max_locked_msat) + .build(), + ); + let node_a = setup_node(&chain_source, config_a); + + 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(2_000_000), + ) + .await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 1_000_000, true, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + node_b.sync_wallets().unwrap(); + open_channel(&node_b, &node_c, 1_000_000, true, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + node_c.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_event!(node_b, ChannelReady); + expect_event!(node_b, ChannelReady); + expect_event!(node_c, ChannelReady); + + assert_eq!(node_a.prober().map_or(1, |p| p.locked_msat()), 0, "initial locked_msat is nonzero"); + + strategy.set_path(build_probe_path(&node_a, &node_b, &node_c, PROBE_AMOUNT_MSAT)); + wait_for_channel_ready_to_send(&node_a, &node_b, PROBE_AMOUNT_MSAT + 1000).await; + wait_for_channel_ready_to_send(&node_b, &node_c, PROBE_AMOUNT_MSAT).await; + strategy.start_probing(); + + // Sample locked_msat across multiple probe cycles and assert the budget cap is never exceeded + let mut observed_locked = false; + let deadline = tokio::time::Instant::now() + Duration::from_secs(30); + while tokio::time::Instant::now() < deadline { + let msat = node_a.prober().map_or(0, |p| p.locked_msat()); + if msat > 0 { + observed_locked = true; + } + assert!( + msat <= max_locked_msat, + "locked_msat {msat} exceeded budget cap {max_locked_msat}" + ); + tokio::time::sleep(Duration::from_millis(25)).await; + } + + assert!(observed_locked, "no probe was dispatched during the observation window"); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); + node_c.stop().unwrap(); +} diff --git a/tests/reorg_test.proptest-regressions b/tests/reorg_test.proptest-regressions new file mode 100644 index 0000000000..74be29aeb1 --- /dev/null +++ b/tests/reorg_test.proptest-regressions @@ -0,0 +1,2 @@ +cc 06354c9b049db51c31557bf46d86a68bdd4049577cbd9190fb81c7824b18f0e6 # shrinks to reorg_depth = 6, force_close = false +cc ffba5725835411b0948e834640dd37fc9a35696a301d7f2d8f2054f158bd2cae # shrinks to reorg_depth = 1, force_close = true diff --git a/tests/reorg_test.rs b/tests/reorg_test.rs index 295d9fdd24..132d9de96b 100644 --- a/tests/reorg_test.rs +++ b/tests/reorg_test.rs @@ -8,11 +8,27 @@ use proptest::prelude::prop; use proptest::proptest; use crate::common::{ - expect_event, generate_blocks_and_wait, invalidate_blocks, open_channel, - premine_and_distribute_funds, random_chain_source, random_config, setup_bitcoind_and_electrsd, - setup_node, wait_for_outpoint_spend, + expect_event, exponential_backoff_poll, generate_blocks_and_wait, invalidate_blocks, + open_channel, premine_and_distribute_funds, random_chain_source, random_config, + setup_bitcoind_and_electrsd, setup_node, wait_for_outpoint_spend, wait_for_tx, }; +async fn wait_for_pending_sweep_balance( + node: &ldk_node::Node, mut matches_balance: F, +) -> PendingSweepBalance +where + F: FnMut(&PendingSweepBalance) -> bool, +{ + exponential_backoff_poll(|| { + node.sync_wallets().unwrap(); + node.list_balances() + .pending_balances_from_channel_closures + .into_iter() + .find(|balance| matches_balance(balance)) + }) + .await +} + proptest! { #![proptest_config(proptest::test_runner::Config::with_cases(5))] #[test] @@ -29,17 +45,16 @@ proptest! { let chain_source_c = random_chain_source(&bitcoind, &electrsd); macro_rules! config_node { - ($chain_source: expr, $anchor_channels: expr) => {{ - let config_a = random_config($anchor_channels); + ($chain_source: expr) => {{ + let config_a = random_config(); let node = setup_node(&$chain_source, config_a); node }}; } - let anchor_channels = true; let nodes = vec![ - config_node!(chain_source_a, anchor_channels), - config_node!(chain_source_b, anchor_channels), - config_node!(chain_source_c, anchor_channels), + config_node!(chain_source_a), + config_node!(chain_source_b), + config_node!(chain_source_c), ]; let (bitcoind, electrs) = (&bitcoind.client, &electrsd.client); @@ -76,7 +91,9 @@ proptest! { nodes_funding_tx.insert(node.node_id(), funding_txo); } - generate_blocks_and_wait(bitcoind, electrs, 6).await; + // Keep funding confirmed across the deepest reorg. rust-lightning PR #4231 exempts + // only trusted zero-conf channels; regular channels still force-close at zero confirmations. + generate_blocks_and_wait(bitcoind, electrs, 7).await; sync_wallets!(); reorg!(reorg_depth); @@ -144,40 +161,60 @@ proptest! { sync_wallets!(); if force_close { - for node in &nodes { - node.sync_wallets().unwrap(); - // If there is no more balance, there is nothing to process here. - if node.list_balances().lightning_balances.len() < 1 { - return; - } - match node.list_balances().lightning_balances[0] { - LightningBalance::ClaimableAwaitingConfirmations { - confirmation_height, - .. - } => { - let cur_height = node.status().current_best_block.height; - let blocks_to_go = confirmation_height - cur_height; - generate_blocks_and_wait(bitcoind, electrs, blocks_to_go as usize).await; - node.sync_wallets().unwrap(); - }, - _ => panic!("Unexpected balance state for node_hub!"), - } + let claimable_nodes = nodes + .iter() + .filter_map(|node| { + node.list_balances().lightning_balances.iter().find_map(|balance| { + match balance { + LightningBalance::ClaimableAwaitingConfirmations { + confirmation_height, + .. + } => Some((node, *confirmation_height)), + _ => None, + } + }) + }) + .collect::>(); + let confirmation_height = claimable_nodes + .iter() + .map(|(_, confirmation_height)| *confirmation_height) + .max() + .expect("Missing claimable force-close balance"); + let cur_height = nodes[0].status().current_best_block.height; + let blocks_to_go = confirmation_height.saturating_sub(cur_height); + if blocks_to_go > 0 { + generate_blocks_and_wait(bitcoind, electrs, blocks_to_go as usize).await; + sync_wallets!(); + } - assert!(node.list_balances().lightning_balances.len() < 2); - assert!(node.list_balances().pending_balances_from_channel_closures.len() > 0); - match node.list_balances().pending_balances_from_channel_closures[0] { - PendingSweepBalance::BroadcastAwaitingConfirmation { .. } => {}, - _ => panic!("Unexpected balance state!"), + // Mining for one node advances the shared chain for every node. Mature all + // claimable outputs together, wait for every sweep to reach the mempool, then + // confirm them and wait for `AwaitingThresholdConfirmations`. + for (node, _) in &claimable_nodes { + let pending_balance = wait_for_pending_sweep_balance(node, |balance| { + matches!( + balance, + PendingSweepBalance::BroadcastAwaitingConfirmation { .. } + | PendingSweepBalance::AwaitingThresholdConfirmations { .. } + ) + }) + .await; + if let PendingSweepBalance::BroadcastAwaitingConfirmation { + latest_spending_txid, + .. + } = pending_balance + { + wait_for_tx(electrs, latest_spending_txid).await; } + } - generate_blocks_and_wait(&bitcoind, electrs, 1).await; - node.sync_wallets().unwrap(); - assert!(node.list_balances().lightning_balances.len() < 2); - assert!(node.list_balances().pending_balances_from_channel_closures.len() > 0); - match node.list_balances().pending_balances_from_channel_closures[0] { - PendingSweepBalance::AwaitingThresholdConfirmations { .. } => {}, - _ => panic!("Unexpected balance state!"), - } + generate_blocks_and_wait(bitcoind, electrs, 1).await; + sync_wallets!(); + for (node, _) in &claimable_nodes { + wait_for_pending_sweep_balance(node, |balance| { + matches!(balance, PendingSweepBalance::AwaitingThresholdConfirmations { .. }) + }) + .await; } } 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() +// }