From db194f761e12f6d1fbd808121e1b9d65a3b304e7 Mon Sep 17 00:00:00 2001 From: Diego Date: Wed, 26 Aug 2026 15:54:53 +0200 Subject: [PATCH 1/4] Sync upstream master (#151) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * pin rgb-lib to exact version * CI: switch from nightly to beta build * add coverage support * complete /listunspents fields * fix README * fix alphabetical order * consignment/media for LN ops via p2p * support out-of-band consignment/ack * improve /refresh result * add test reproducing sweeper breakage on force close with pending RGB HTLC * fix for close_force_pending_htlc test * use a generic drop guard for test-only node override statics * restart the payer with the HTLC pending in close_force_pending_htlc * update rust-lightning submodule * add test for push_asset_amount above channel asset amount * update rust-lightning (fixes push_asset_amount_above_chan_amt) * wait for electrs to be usable after restarting it * defer claiming payments in tests to fix pending status races * replace magic-crypt with scrypt and XChaCha20Poly1305 The mnemonic file now records the scrypt work factors next to the salt, nonce and ciphertext, so they can be raised later without leaving existing files undecryptable. Key derivation and cipher setup are shared with the backup code, whose derived key is unchanged. Mnemonic files written by previous versions can no longer be read, including those contained in older backups, so affected wallets have to be re-initialized. * fix network conversion in WalletSource::list_confirmed_utxos * shut down on panic or unexpected background processor exit * improve shutdown on panic * drop default indexer * add electrum/esplora features * Make bitcoind optional: add transaction-sync as an alternative chain backend Adds `lightning-transaction-sync` (electrum/esplora) as an alternative to the existing `lightning-block-sync` (bitcoind) chain backend, rather than replacing it. The chain backend is selected explicitly at unlock time via the `ldk_chain_sync` field of the `/unlock` request, an adjacently-tagged `{ "mode", "config" }` object: - `BlockSync`: consume full blocks from a trusted/local bitcoind over JSON-RPC. The `bitcoind_rpc_*` parameters live in this mode's `config`, so they are required exactly when this mode is selected. - `TransactionSync`: sync through an electrum/esplora indexer, so no bitcoind is required. By default it reuses the wallet's `indexer_url`; a dedicated LN indexer can be set in this mode's `config`. Each backend is gated behind a Cargo feature (`block-sync`, `transaction-sync`), both enabled by default and composing with the existing `electrum`/`esplora` features, so a user can build with only the sync dependency they need. The mode variants and their wiring are feature-gated accordingly, a build with neither feature fails with a `compile_error!`, and the single-feature builds are covered in the build and lint workflows. The backends live in a new `ldk_chain_backend` module (`block_sync`, `transaction_sync` and a shared `mod.rs` holding the chain-backend types and the common fee-estimate logic). LDK is wired against trait objects for the fee estimator, broadcaster and gossip UTXO lookup so one set of type aliases serves both backends. Because `lightning-block-sync`'s `GossipVerifier` requires the `P2PGossipSync` to be typed with `Arc`, a `BlockSyncGossipVerifier` provides the block-sync gossip UTXO lookup against the shared trait-object gossip sync, mirroring upstream's block cache. The transaction-sync gossip lookup enforces the same six-confirmation depth as the block-sync path. For the electrum backend, transactions registered via `Filter::register_tx` are confirmed with a supplementary pass against the electrum server (without holding its lock across network calls), as `ElectrumSyncClient::sync` alone does not notify the confirmables about them in this setup. Adds an integration test for the transaction-sync backend against both electrum and esplora indexers (an esplora service is added to the regtest compose file): three nodes are unlocked in transaction-sync mode, two announced RGB asset channels are opened and node1 makes a multihop RGB payment to node3 (exercising the indexer gossip lookup), the nodes are restarted (re-establishing the channels after syncing via the indexer) and a channel is cooperatively closed. Closes #125 * add electrum_opret_confirm test * update rust-lightning (fix for electrum_opret_confirm test) * improve the transaction-sync backend * drop workaround for note indexed transactions in electrum transaction sync * verify the funding output is unspent in the indexer gossip lookup as the block-sync path already did * require indexer_url in the transaction-sync config, following the default indexer removal * lint all 9 feature combinations in CI * test suite no longer needs block-sync; wait for esplora when mining * minor improvements/changes * return errors instead of panicking on recoverable wallet failures * point rgb-lib to master and bump MSRV to 1.94.0 * add asset filter and optional txid to listtransfers * add BOLT11 description and description_hash to invoice creation, decode and payment APIs * Send asset media over p2p on virtual channel opens and make funding transfer failures terminal * Restore consignment generation for external-signer sends and wire p2p transfer limits through config * decode sweep transfer info without panicking under the txes lock * keep consignment cleanup non-fatal and tolerate a poisoned txes lock * drop the cached sweep tx when persisting it fails * reuse sweep receives across retries instead of issuing one per attempt * shrink the cached sweep receive reuse margin when reusing addresses * point rgb-lib at the upstream sync branch * assert the sweep receive reuse margin is non-zero * lower the scrypt work factors for the library test targets too, editing only the two log_n expressions * pin the backup v1 derived key with a test vector * validate a backup before installing it, so a failed restore leaves the node initializable * say that the poisoned lock is ignored, not propagated * update rust-lightning (electrum unindexed-output fix) and enable lightning indexer features * migrate uniffi binding consumers to LdkChainSync and test the indexer_url config fallback * bump the stale MSRV pins, format c-ffi, and keep backup v1 work factors out of feature gating * point the rust-lightning submodule at the pushed sync branch * stop the peer reconnect loop when the node stops instead of polling its database forever * delete the staged funding consignment file once it is promoted to the KVStore * pin the p2p funding transport and mandatory rgb-lib expirations in the sdk payment test * make the asset-less transfer filter and receive reuse margin assertions non-vacuous * use as_chunks for the fixed-size hex pair split * route panics through a bounded shutdown that releases the vss fence only on a complete teardown * gate scrypt CURRENT work factors on cfg(test) only * run vss teardown headline tests in CI * error instead of vanilla-sweeping a colored output missing from a non-empty transfer map * stop the monitor kv store on abandoned vss teardown paths * extract main exit decision so watchdog tests exercise the real one * validate asset id and txid inputs to getconsignment against path traversal * treat empty invoice description as none so description_hash invoices mint * expose bolt11 description and description_hash on sdk and binding decode paths * cover the pre-sync colored-channel deserialization tripwire * bump rgb-lib pin and rust-lightning gitlink for the consignment and channel fixes * flip rgb-lib pin to released UTEXO tag v0.3.0-beta.34 and bump submodule * point rust-lightning submodule at the merged UTEXO dev branch * do not fail CI when the codecov upload has no token --------- Co-authored-by: Zoe FaltibĂ  Co-authored-by: Nicola Busanello Co-authored-by: 0xaudron Co-authored-by: Zoe FaltibĂ  <7492268+zoedberg@users.noreply.github.com> Co-authored-by: bitwalt --- .github/workflows/build.yaml | 7 +- .github/workflows/format.yaml | 5 + .github/workflows/release-sdk.yaml | 6 +- .github/workflows/sdk-e2e.yaml | 18 +- .github/workflows/test.yaml | 23 +- .github/workflows/uniffi-artifacts.yaml | 8 +- .gitignore | 4 + Cargo.lock | 1496 ++++++++++-- Cargo.toml | 53 +- README.md | 131 +- .../ConcurrentBtcPaymentsTest.kt | 11 +- .../rgblightningnode/MultiOpenCloseTest.kt | 11 +- .../java/org/rgblightningnode/PaymentTest.kt | 20 +- .../java/org/rgblightningnode/RestartTest.kt | 11 +- .../rgblightningnode/SwapRoundtripBuyTest.kt | 11 +- bindings/c-ffi/Cargo.lock | 1489 ++++++++++-- bindings/c-ffi/Cargo.toml | 2 +- bindings/c-ffi/src/api.rs | 14 +- bindings/c-ffi/src/json_types.rs | 124 +- bindings/rgb_lightning_node.udl | 30 +- bindings/wasm-sdk/e2e-specs/README.md | 13 +- codecov.yml | 8 + coverage.sh | 109 + openapi.yaml | 364 ++- rust-lightning | 2 +- sample-config.toml | 2 +- src/args.rs | 59 + src/async_kv_store.rs | 5 + src/async_order.rs | 3 +- src/auth.rs | 3 +- src/backup.rs | 82 +- src/chain_backend.rs | 33 - src/config/file.rs | 3 + src/config/tests.rs | 6 + src/core_types.rs | 20 +- src/crypto.rs | 355 +++ src/custom_msg_rpc.rs | 25 +- src/error.rs | 80 +- src/ffi/types.rs | 2 +- src/gossip.rs | 4 +- src/indexer.rs | 476 ---- src/ldk.rs | 1899 +++++++++------ .../block_sync.rs} | 226 +- src/ldk_chain_backend/mod.rs | 128 ++ src/ldk_chain_backend/transaction_sync.rs | 596 +++++ src/lib.rs | 22 +- src/main.rs | 89 +- src/node.rs | 15 + src/rgb.rs | 156 +- src/rgb_file_transfer.rs | 2044 +++++++++++++++++ src/routes.rs | 591 ++++- src/sdk/mod.rs | 144 +- src/synced_kv_store.rs | 37 +- src/test/auth_db_persistence.rs | 4 + src/test/chain_backend_bitcoind_dispatch.rs | 22 - src/test/chain_backend_dispatch.rs | 36 + src/test/close_force_pending_htlc.rs | 142 ++ src/test/colored_channel_electrum.rs | 7 +- src/test/concurrent_btc_payments.rs | 7 + src/test/electrum_opret_confirm.rs | 203 ++ src/test/esplora_indexer_defaults.rs | 18 +- src/test/init_electrum.rs | 7 +- src/test/init_esplora.rs | 47 +- src/test/invoice.rs | 26 + src/test/lib_sdk/external_signer.rs | 266 ++- src/test/lib_sdk/helpers.rs | 12 +- src/test/lib_sdk/payment.rs | 9 +- src/test/missing_acceptor.rs | 5 +- src/test/mnemonic_crypto.rs | 76 + src/test/mod.rs | 622 ++++- src/test/openchannel_media.rs | 102 + src/test/openchannel_no_indexer.rs | 12 +- src/test/out_of_band.rs | 276 +++ src/test/pagination_filters.rs | 15 +- src/test/payment.rs | 9 +- src/test/push_asset_amount_above_chan_amt.rs | 65 + src/test/refuse_high_fees.rs | 24 +- src/test/restore_legacy_backup.rs | 71 + src/test/swap_assets_liquidity_both_ways.rs | 12 +- src/test/swap_reverse_same_channel.rs | 16 +- src/test/swap_roundtrip_assets.rs | 4 +- src/test/swap_roundtrip_buy.rs | 16 + src/test/swap_roundtrip_buy_same_channel.rs | 4 +- src/test/swap_roundtrip_fail_whitelist.rs | 12 +- .../swap_roundtrip_multihop_asset_asset.rs | 4 +- src/test/swap_roundtrip_multihop_buy.rs | 2 - src/test/swap_roundtrip_multihop_sell.rs | 4 +- src/test/swap_roundtrip_sell.rs | 4 +- src/test/transaction_sync.rs | 157 ++ src/test/tripwire_legacy_colored_channel.rs | 242 ++ src/test/unlock_request_ldk_chain_sync.rs | 136 ++ src/test/unlock_request_optional_bitcoind.rs | 121 - src/test/virtual_channels.rs | 67 + src/test/vss.rs | 42 +- src/test/vss_unreachable_openchannel.rs | 7 +- src/test_utils.rs | 4 + .../manual_py_external_signer_e2e.py | 45 +- .../python-interop/manual_py_full_n2n.py | 10 +- .../manual_py_virtual_channels_sdk.py | 10 +- src/uniffi_api/mod.rs | 60 +- src/uniffi_api/state.rs | 1 - src/uniffi_api/tests.rs | 4 + src/uniffi_api/types.rs | 57 +- src/utils.rs | 140 +- test/kotlin-e2e/KotlinUniffiE2e.kt | 11 +- .../ExternalSignerSmoke.kt | 11 +- test/python-e2e/PythonUniffiE2e.py | 10 +- test/python-e2e/harness.py | 10 +- .../SwiftExternalSignerSmokeTests.swift | 10 +- .../SwiftUniffiE2ESmokeTests.swift | 20 +- 110 files changed, 11660 insertions(+), 2751 deletions(-) create mode 100644 codecov.yml create mode 100755 coverage.sh delete mode 100644 src/chain_backend.rs create mode 100644 src/crypto.rs delete mode 100644 src/indexer.rs rename src/{bitcoind.rs => ldk_chain_backend/block_sync.rs} (63%) create mode 100644 src/ldk_chain_backend/mod.rs create mode 100644 src/ldk_chain_backend/transaction_sync.rs create mode 100644 src/rgb_file_transfer.rs delete mode 100644 src/test/chain_backend_bitcoind_dispatch.rs create mode 100644 src/test/chain_backend_dispatch.rs create mode 100644 src/test/close_force_pending_htlc.rs create mode 100644 src/test/electrum_opret_confirm.rs create mode 100644 src/test/mnemonic_crypto.rs create mode 100644 src/test/openchannel_media.rs create mode 100644 src/test/out_of_band.rs create mode 100644 src/test/push_asset_amount_above_chan_amt.rs create mode 100644 src/test/restore_legacy_backup.rs create mode 100644 src/test/transaction_sync.rs create mode 100644 src/test/tripwire_legacy_colored_channel.rs create mode 100644 src/test/unlock_request_ldk_chain_sync.rs delete mode 100644 src/test/unlock_request_optional_bitcoind.rs diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 4ec45910..a94387cc 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -16,8 +16,13 @@ jobs: strategy: fail-fast: false matrix: - toolchain: [ nightly, stable, 1.88.0 ] + toolchain: [ stable, 1.94.0, beta ] name: [ linux, windows, macos ] + exclude: + - name: windows + toolchain: beta + - name: macos + toolchain: beta include: - name: linux os: warp-ubuntu-latest-x64-16x diff --git a/.github/workflows/format.yaml b/.github/workflows/format.yaml index a9d150e4..223dda7f 100644 --- a/.github/workflows/format.yaml +++ b/.github/workflows/format.yaml @@ -22,3 +22,8 @@ jobs: components: rustfmt - name: Format run: cargo fmt -- --check + # these crates have their own workspace, so the root `cargo fmt` skips them + - name: Format (out-of-workspace binding crates) + run: | + cargo fmt --manifest-path bindings/c-ffi/Cargo.toml -- --check + cargo fmt --manifest-path bindings/uniffi-bindgen/Cargo.toml -- --check diff --git a/.github/workflows/release-sdk.yaml b/.github/workflows/release-sdk.yaml index 1f7f2339..0bdea8ad 100644 --- a/.github/workflows/release-sdk.yaml +++ b/.github/workflows/release-sdk.yaml @@ -69,7 +69,7 @@ jobs: - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@v1 with: - toolchain: 1.88.0 + toolchain: 1.94.0 rustflags: "" - name: Add wasm32 target @@ -133,7 +133,7 @@ jobs: - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@v1 with: - toolchain: 1.88.0 + toolchain: 1.94.0 rustflags: "" - name: Setup Java @@ -275,7 +275,7 @@ jobs: - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@v1 with: - toolchain: 1.88.0 + toolchain: 1.94.0 rustflags: "" - name: Install bindgen-cli diff --git a/.github/workflows/sdk-e2e.yaml b/.github/workflows/sdk-e2e.yaml index 55169f4f..8b766314 100644 --- a/.github/workflows/sdk-e2e.yaml +++ b/.github/workflows/sdk-e2e.yaml @@ -24,7 +24,7 @@ jobs: - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@v1 with: - toolchain: 1.88.0 + toolchain: 1.94.0 rustflags: "" - name: Cache Rust build artifacts @@ -136,7 +136,7 @@ jobs: - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@v1 with: - toolchain: 1.88.0 + toolchain: 1.94.0 rustflags: "" - name: Cache Rust build artifacts @@ -173,7 +173,7 @@ jobs: - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@v1 with: - toolchain: 1.88.0 + toolchain: 1.94.0 rustflags: "" - name: Cache Rust build artifacts @@ -210,7 +210,7 @@ jobs: - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@v1 with: - toolchain: 1.88.0 + toolchain: 1.94.0 rustflags: "" - name: Cache Rust build artifacts @@ -247,7 +247,7 @@ jobs: - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@v1 with: - toolchain: 1.88.0 + toolchain: 1.94.0 rustflags: "" - name: Cache Rust build artifacts @@ -355,7 +355,7 @@ jobs: - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@v1 with: - toolchain: 1.88.0 + toolchain: 1.94.0 rustflags: "" - name: Cache Rust build artifacts @@ -419,7 +419,7 @@ jobs: - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@v1 with: - toolchain: 1.88.0 + toolchain: 1.94.0 rustflags: "" - name: Cache Rust build artifacts @@ -592,7 +592,7 @@ jobs: - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@v1 with: - toolchain: 1.88.0 + toolchain: 1.94.0 rustflags: "" - name: Cache Rust build artifacts @@ -633,7 +633,7 @@ jobs: - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@v1 with: - toolchain: 1.88.0 + toolchain: 1.94.0 rustflags: "" - name: Cache Rust build artifacts diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 8dd61e85..b6bda0a3 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -77,7 +77,7 @@ jobs: cargo test --features "uniffi,vls" --lib 'signer::channel_signer::tests::' -- --test-threads=1 cargo test --features "uniffi,vls" --lib 'signer::in_process_transport::tests::' -- --test-threads=1 - test: + test_and_coverage: runs-on: warp-ubuntu-latest-x64-16x timeout-minutes: 90 steps: @@ -86,9 +86,24 @@ jobs: submodules: true - uses: actions-rust-lang/setup-rust-toolchain@v1 with: + components: llvm-tools-preview + toolchain: stable rustflags: "" - - name: Tests - run: cargo test -- --test-threads=1 --skip test::gossip_ + - name: Install llvm-cov + env: + LLVM_COV_RELEASES: https://github.com/taiki-e/cargo-llvm-cov/releases + run: | + host=$(rustc -Vv | grep host | sed 's/host: //') + curl -fsSL $LLVM_COV_RELEASES/latest/download/cargo-llvm-cov-$host.tar.gz | tar xzf - -C "$HOME/.cargo/bin" + - name: Tests and coverage report + run: ./coverage.sh --ci --skip test::gossip_ + - name: Upload coverage report + uses: codecov/codecov-action@v6 + with: + fail_ci_if_error: false + file: coverage.lcov + flags: rust + token: ${{ secrets.CODECOV_TOKEN }} - name: UniFFI SDK smoke tests run: cargo test --features uniffi --lib 'uniffi_smoke_tests::' -- --test-threads=1 @@ -141,6 +156,8 @@ jobs: run: cargo build --features vss - name: Run deterministic VSS durability regressions run: cargo test --features vss "test::vss_durability_gaps" -- --test-threads=1 + - name: Run VSS teardown headline tests + run: cargo test --features "uniffi,test-utils,vls,vss" --bin rgb-lightning-node ldk::vss_teardown_tests -- --test-threads=1 - name: Start regtest and VSS services run: VSS=1 ./regtest.sh start - name: Run VSS tests (e2e against regtest + VSS server) diff --git a/.github/workflows/uniffi-artifacts.yaml b/.github/workflows/uniffi-artifacts.yaml index 73559e73..87ba344e 100644 --- a/.github/workflows/uniffi-artifacts.yaml +++ b/.github/workflows/uniffi-artifacts.yaml @@ -24,7 +24,7 @@ jobs: - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@v1 with: - toolchain: 1.88.0 + toolchain: 1.94.0 rustflags: "" - name: Run library-only test suite @@ -43,7 +43,7 @@ jobs: - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@v1 with: - toolchain: 1.88.0 + toolchain: 1.94.0 rustflags: "" - name: Build host cdylib @@ -77,7 +77,7 @@ jobs: - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@v1 with: - toolchain: 1.88.0 + toolchain: 1.94.0 rustflags: "" - name: Setup Java @@ -169,7 +169,7 @@ jobs: - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@v1 with: - toolchain: 1.88.0 + toolchain: 1.94.0 rustflags: "" - name: Install bindgen-cli diff --git a/.gitignore b/.gitignore index 6446aafb..5cd4a733 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Test files /datacore +/dataesplora /dataindex /dataldk0 /dataldk1 @@ -10,6 +11,9 @@ # will have compiled files and executables **/target +# coverage report generated for the CI +/coverage.lcov + # These are backup files generated by rustfmt **/*.rs.bk diff --git a/Cargo.lock b/Cargo.lock index a08dec61..ab4fa7f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,19 +23,29 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] [[package]] -name = "aes" -version = "0.8.4" +name = "aead" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", + "crypto-common 0.2.2", + "inout 0.2.2", +] + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", ] [[package]] @@ -45,6 +55,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "const-random", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -218,6 +230,165 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "arrow" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cfdd0833e32a9874d2b55089333ad310c0be208aafa277385ce2461dec90be3" +dependencies = [ + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-ord", + "arrow-row", + "arrow-schema", + "arrow-select", + "arrow-string", +] + +[[package]] +name = "arrow-arith" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a41203398f0eaa6f7ec8e62c0da742a21abf282c148fc157f6c35c90e29981a" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "num-traits", +] + +[[package]] +name = "arrow-array" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae33dad492b7df00a217563a7b0ef2874df68a0deea1b1a3acf628152f7f7a69" +dependencies = [ + "ahash 0.8.12", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "half", + "hashbrown 0.17.1", + "num-complex", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-buffer" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9552f96391c005e6ab449fa941420935e7e062489b12b8b1b08879b2163f5b5" +dependencies = [ + "bytes", + "half", + "num-bigint", + "num-traits", +] + +[[package]] +name = "arrow-cast" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8a327c9649f30d8406995f27642b68df354713cca3baaaf100f076f18d5f34" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-ord", + "arrow-schema", + "arrow-select", + "atoi", + "base64 0.22.1", + "chrono", + "half", + "lexical-core", + "num-traits", + "ryu", +] + +[[package]] +name = "arrow-data" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b24852db04738907e06c04ea61e42fe7fda962a34513022dc0d0e754fb7976b" +dependencies = [ + "arrow-buffer", + "arrow-schema", + "half", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-ord" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63a083ec750f5c043f02946b4baf05fcdbb55f4560a3277055caca5cc99f3eb0" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", +] + +[[package]] +name = "arrow-row" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "514ba0ef0d4c5896202dae736251ce415abb43a950bed570fb7981b8716c0e4c" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "half", +] + +[[package]] +name = "arrow-schema" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21ca356ad6425cecb6eb7b28e4f659f1ee7880fbb1a16127de7dd62901efee9e" + +[[package]] +name = "arrow-select" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c58da39eb3d8350ad4a549e5c2bc49284dac554016c69829310350f1731b0aad" +dependencies = [ + "ahash 0.8.12", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "num-traits", +] + +[[package]] +name = "arrow-string" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6789b388467525e3271326b6b4915666ecfdf5142aef09779445c954b67543c" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "memchr", + "num-traits", + "regex", + "regex-syntax", +] + [[package]] name = "as-any" version = "0.3.2" @@ -379,7 +550,7 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sha1", + "sha1 0.10.7", "sync_wrapper", "tokio", "tokio-tungstenite", @@ -560,12 +731,12 @@ dependencies = [ [[package]] name = "bdk_electrum" -version = "0.23.2" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b59a3f7fbe678874fa34354097644a171276e02a49934c13b3d61c54610ddf39" +checksum = "00a9846105bf6e751adbb6946b000ff919ce24a15a941cbfe68485549b552a9c" dependencies = [ "bdk_core", - "electrum-client 0.24.1", + "electrum-client 0.25.0", ] [[package]] @@ -591,9 +762,9 @@ dependencies = [ [[package]] name = "bdk_wallet" -version = "3.0.0" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67f3c4f9526d22374fca5b7ff1d6bf8d921ab56db2dac8df66a2c5561b31d4ef" +checksum = "1284fb23acc3e3022673712b55f4d5ce7e38aadc2c49bbef830dc3935f0a3289" dependencies = [ "bdk_chain", "bdk_file_store", @@ -646,7 +817,7 @@ version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ - "bitcoin_hashes 0.12.0", + "bitcoin_hashes 0.14.101", "serde", "unicode-normalization", ] @@ -866,6 +1037,18 @@ dependencies = [ "webpki-roots 1.0.9", ] +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "blake3" version = "1.8.5" @@ -899,12 +1082,12 @@ dependencies = [ ] [[package]] -name = "block-padding" -version = "0.3.3" +name = "block-buffer" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ - "generic-array", + "hybrid-array", ] [[package]] @@ -924,6 +1107,30 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" +[[package]] +name = "borsh" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f347189c62a579b8cd5f80714efa178f52e461dc2e6d701d264f5ff22e566c" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "bs58" version = "0.5.1" @@ -939,6 +1146,28 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "byteorder" version = "1.5.0" @@ -983,15 +1212,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "cbc" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" -dependencies = [ - "cipher", -] - [[package]] name = "cc" version = "1.4.0" @@ -1023,7 +1243,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.17", ] @@ -1034,6 +1254,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", + "cipher 0.5.2", "cpufeatures 0.3.0", "rand_core 0.10.1", ] @@ -1050,13 +1271,25 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ - "aead", + "aead 0.5.2", "chacha20 0.9.1", - "cipher", - "poly1305", + "cipher 0.4.4", + "poly1305 0.8.0", "zeroize", ] +[[package]] +name = "chacha20poly1305" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" +dependencies = [ + "aead 0.6.1", + "chacha20 0.10.1", + "cipher 0.5.2", + "poly1305 0.9.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -1089,11 +1322,22 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", - "inout", + "crypto-common 0.1.7", + "inout 0.1.4", "zeroize", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.2", +] + [[package]] name = "clap" version = "4.6.4" @@ -1143,6 +1387,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" version = "1.0.5" @@ -1165,6 +1415,32 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -1224,15 +1500,6 @@ dependencies = [ "crc-catalog", ] -[[package]] -name = "crc-any" -version = "2.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46db9f663dfb869b80fcf59e32d7a80fc6c464a4f6328f3f06a00f5e36d05f8c" -dependencies = [ - "debug-helper", -] - [[package]] name = "crc-catalog" version = "2.5.0" @@ -1301,6 +1568,24 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -1412,12 +1697,6 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" -[[package]] -name = "debug-helper" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80a4af69c60438a1a82af89d362f4729fd38db7b73f305a237636fad31ceb2bf" - [[package]] name = "defmt" version = "1.1.1" @@ -1466,7 +1745,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" dependencies = [ - "const-oid", + "const-oid 0.9.6", ] [[package]] @@ -1475,7 +1754,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "pem-rfc7468", "zeroize", ] @@ -1489,6 +1768,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive-where" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -1522,15 +1812,6 @@ dependencies = [ "unicode-xid", ] -[[package]] -name = "des" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" -dependencies = [ - "cipher", -] - [[package]] name = "digest" version = "0.9.0" @@ -1547,11 +1828,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "const-oid", - "crypto-common", + "const-oid 0.9.6", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "dircmp" version = "0.2.0" @@ -1669,9 +1962,9 @@ dependencies = [ [[package]] name = "electrum-client" -version = "0.20.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c7b1f8783238bb18e6e137875b0a66f3dffe6c7ea84066e05d033cf180b150f" +checksum = "a5059f13888a90486e7268bbce59b175f5f76b1c55e5b9c568ceaa42d2b8507c" dependencies = [ "bitcoin 0.32.102", "byteorder", @@ -1686,9 +1979,9 @@ dependencies = [ [[package]] name = "electrum-client" -version = "0.24.1" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5059f13888a90486e7268bbce59b175f5f76b1c55e5b9c568ceaa42d2b8507c" +checksum = "1970c5d7bd9de6d4041cbfc3e46faa3e85fe7efcea2b0eb06750d3ae1ef577b7" dependencies = [ "bitcoin 0.32.102", "byteorder", @@ -1804,6 +2097,16 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + [[package]] name = "event-listener" version = "5.4.2" @@ -1887,6 +2190,17 @@ dependencies = [ "spin", ] +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1899,6 +2213,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -1939,8 +2259,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] -name = "futures" -version = "0.3.33" +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" dependencies = [ @@ -2150,16 +2476,26 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", + "num-traits", "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + [[package]] name = "hashbrown" version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" dependencies = [ - "ahash", + "ahash 0.8.12", ] [[package]] @@ -2168,7 +2504,7 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ - "ahash", + "ahash 0.8.12", "serde", ] @@ -2180,7 +2516,18 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", ] [[package]] @@ -2198,6 +2545,15 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "heck" version = "0.4.1" @@ -2261,7 +2617,16 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", ] [[package]] @@ -2273,6 +2638,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "home" version = "0.5.12" @@ -2327,6 +2701,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.11.0" @@ -2551,6 +2934,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inherent" version = "1.0.14" @@ -2568,10 +2960,18 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "block-padding", "generic-array", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2734,6 +3134,63 @@ dependencies = [ "spin", ] +[[package]] +name = "lexical-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" +dependencies = [ + "lexical-parse-float", + "lexical-parse-integer", + "lexical-util", + "lexical-write-float", + "lexical-write-integer", +] + +[[package]] +name = "lexical-parse-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" +dependencies = [ + "lexical-parse-integer", + "lexical-util", +] + +[[package]] +name = "lexical-parse-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "lexical-util" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" + +[[package]] +name = "lexical-write-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361" +dependencies = [ + "lexical-util", + "lexical-write-integer", +] + +[[package]] +name = "lexical-write-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df" +dependencies = [ + "lexical-util", +] + [[package]] name = "libc" version = "0.2.189" @@ -3004,22 +3461,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "magic-crypt" -version = "4.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "844b6169eeaae32ae8a61855964331a67f12d2afba9170303fbd3e3c2a861a52" -dependencies = [ - "aes", - "base64 0.22.1", - "cbc", - "crc-any", - "des", - "md-5", - "sha2 0.10.9", - "tiger", -] - [[package]] name = "matchers" version = "0.2.0" @@ -3045,6 +3486,16 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + [[package]] name = "memchr" version = "2.8.3" @@ -3248,6 +3699,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -3454,6 +3914,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "password-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" +dependencies = [ + "phc", +] + [[package]] name = "paste" version = "1.0.15" @@ -3467,7 +3936,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest 0.10.7", - "hmac", + "hmac 0.12.1", +] + +[[package]] +name = "pbkdf2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest 0.11.3", + "hmac 0.13.0", ] [[package]] @@ -3527,6 +4006,16 @@ dependencies = [ "serde", ] +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -3576,6 +4065,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "pluralizer" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b3eba432a00a1f6c16f39147847a870e94e2e9b992759b503e330efec778cbe" +dependencies = [ + "once_cell", + "regex", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -3584,7 +4083,17 @@ checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ "cpufeatures 0.2.17", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", +] + +[[package]] +name = "poly1305" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +dependencies = [ + "cpufeatures 0.3.0", + "universal-hash 0.6.1", ] [[package]] @@ -3661,6 +4170,15 @@ dependencies = [ "elliptic-curve", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -3815,6 +4333,26 @@ dependencies = [ "prost 0.11.9", ] +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "quinn" version = "0.11.11" @@ -3893,6 +4431,12 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.8.7" @@ -4087,6 +4631,15 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -4184,15 +4737,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac", + "hmac 0.12.1", "subtle", ] [[package]] name = "rgb-aluvm" -version = "0.11.1-rc.3" +version = "0.11.1-rc.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b60686e4f1701ca4f73b77660777a49e651f3c611461ab62ef718db47a5a6caa" +checksum = "dcf4d92478fcff567ff0bd9f18684fd954f8426c74f16e5517254b0a2d34023a" dependencies = [ "amplify", "baid64", @@ -4205,28 +4758,28 @@ dependencies = [ "rgb-strict-types", "ripemd", "serde", - "sha2 0.10.9", + "sha2 0.11.0", "wasm-bindgen", ] [[package]] name = "rgb-ascii-armor" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fe59d42597231134da1a7b51e5e380b2e1cf7441c27f2a8c23c6c27ea206888" +checksum = "fcde5a129540e5911c24930fd854ea658a715b50a690d254273088f907a63516" dependencies = [ "amplify", "baid64", "base85", "rgb-strict-encoding", - "sha2 0.10.9", + "sha2 0.11.0", ] [[package]] name = "rgb-consensus" -version = "0.11.1-rc.10" +version = "0.11.1-rc.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535483ee9143782e33ddebd3eda465de0184061fc3c480e24990e42fefc58bb0" +checksum = "cee44070883ac31a112814ee635623b0bde44a2afab71ff3c591700a878a76d9" dependencies = [ "amplify", "baid64", @@ -4236,24 +4789,24 @@ dependencies = [ "daggy", "getrandom 0.2.17", "getrandom 0.3.4", - "hex-conservative 0.2.2", - "mime", + "hex-conservative 1.2.0", "rand 0.9.5", "rgb-aluvm", "rgb-strict-encoding", "rgb-strict-types", "ripemd", - "secp256k1 0.29.1", + "secp256k1 0.31.1", + "secp256k1 0.32.0-beta.2", "serde", - "sha2 0.10.9", + "sha2 0.11.0", "wasm-bindgen", ] [[package]] name = "rgb-invoicing" -version = "0.11.1-rc.10" +version = "0.11.1-rc.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10400124af86de579b0f13603e014512b6166ad94ea791212b71579ffb91dd8a" +checksum = "b44153a2411b2d1ddc859a011061f95ec07acf32e70c414193838244207e232e" dependencies = [ "amplify", "baid64", @@ -4261,7 +4814,6 @@ dependencies = [ "fluent-uri", "indexmap", "percent-encoding", - "rand 0.9.5", "rgb-consensus", "rgb-strict-encoding", "rgb-strict-types", @@ -4270,19 +4822,18 @@ dependencies = [ [[package]] name = "rgb-lib" -version = "0.3.0-beta.6" -source = "git+https://github.com/UTEXO-Protocol/rgb-lib.git?tag=v0.3.0-beta.32#9ce4e5f6a14450ae7d924b0c4b40185519fa5fc3" +version = "0.3.0-beta.7" +source = "git+https://github.com/UTEXO-Protocol/rgb-lib.git?tag=v0.3.0-beta.34#62a8c3a045901147b3b06aed9f1e61f345695dce" dependencies = [ "amplify", "base64 0.22.1", "bdk_electrum", "bdk_esplora", "bdk_wallet", - "chacha20poly1305", + "chacha20poly1305 0.11.0", "file-format", - "generic-array", "hex", - "hkdf", + "hkdf 0.12.4", "rand 0.10.2", "reqwest 0.13.4", "rgb-invoicing", @@ -4293,9 +4844,9 @@ dependencies = [ "rgb-strict-encoding", "rgb-strict-types", "rustls 0.23.42", - "scrypt", - "sea-orm", - "sea-query", + "scrypt 0.12.0", + "sea-orm 2.0.2", + "sea-query 1.0.2", "serde", "serde_json", "sha2 0.10.9", @@ -4306,7 +4857,6 @@ dependencies = [ "thiserror 2.0.19", "time", "tokio", - "typenum", "url", "vss-client-ng", "walkdir", @@ -4315,10 +4865,10 @@ dependencies = [ [[package]] name = "rgb-lib-migration" -version = "0.3.0-beta.4" -source = "git+https://github.com/UTEXO-Protocol/rgb-lib.git?tag=v0.3.0-beta.32#9ce4e5f6a14450ae7d924b0c4b40185519fa5fc3" +version = "0.3.0-beta.5" +source = "git+https://github.com/UTEXO-Protocol/rgb-lib.git?tag=v0.3.0-beta.34#62a8c3a045901147b3b06aed9f1e61f345695dce" dependencies = [ - "sea-orm-migration", + "sea-orm-migration 2.0.2", "tokio", ] @@ -4337,12 +4887,12 @@ dependencies = [ "bitcoin 0.32.102", "bitcoin-bech32", "chacha20 0.9.1", - "chacha20poly1305", + "chacha20poly1305 0.10.1", "chrono", "clap", "dircmp", "dirs", - "electrum-client 0.20.0", + "electrum-client 0.24.1", "esplora-client", "futures", "hex-conservative 0.3.2", @@ -4359,7 +4909,6 @@ dependencies = [ "lightning-persister", "lightning-rapid-gossip-sync", "lightning-transaction-sync", - "magic-crypt", "once_cell", "prost 0.13.5", "rand 0.8.7", @@ -4371,8 +4920,8 @@ dependencies = [ "rln-migration", "rustls 0.23.42", "rustls-pemfile", - "scrypt", - "sea-orm", + "scrypt 0.11.0", + "sea-orm 1.1.20", "serde", "serde_json", "serial_test", @@ -4389,7 +4938,6 @@ dependencies = [ "tracing-appender", "tracing-subscriber", "tracing-test", - "typenum", "uniffi", "uuid", "vls-core", @@ -4404,18 +4952,16 @@ dependencies = [ [[package]] name = "rgb-ops" -version = "0.11.1-rc.10" +version = "0.11.1-rc.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3782a5ff1e5408e23c2d14fc556347684f5c8f72d96c4ca357527a3dbffb7378" +checksum = "e454f98d8ab2ef78a2e0ba924e2a5c67b5ed55ba1664f4a6ee269d2f271353bc" dependencies = [ "amplify", "baid64", - "base85", "chrono", - "electrum-client 0.24.1", + "electrum-client 0.25.0", "esplora-client", "getrandom 0.3.4", - "indexmap", "nonasync", "rand 0.9.5", "rgb-aluvm", @@ -4431,12 +4977,11 @@ dependencies = [ [[package]] name = "rgb-psbt-utils" -version = "0.11.1-rc.10" +version = "0.11.1-rc.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0107634634c424a8b268ec6549df08da676768ec6ddbcda51f12e7882daeeee6" +checksum = "9df8f48e7c851814306af91411d7d6f7e8571939d509d4a786597a5eba0efbbf" dependencies = [ "amplify", - "baid64", "getrandom 0.3.4", "rgb-ops", "rgb-strict-encoding", @@ -4446,9 +4991,9 @@ dependencies = [ [[package]] name = "rgb-schemas" -version = "0.11.1-rc.10" +version = "0.11.1-rc.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1903961c836cc3f6963b06c69cb47491a815309f265439f20c932e0a3aa16a78" +checksum = "4fa6cd396adb8e48b558c12750d8c8837aefe230ad905f1be5424b1d57b4f55b" dependencies = [ "amplify", "rgb-aluvm", @@ -4458,9 +5003,9 @@ dependencies = [ [[package]] name = "rgb-strict-encoding" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cca0a8d70dd2d5b2a218ef7fd03e88b3ea69a926f3a9139c08c82d5f49fca236" +checksum = "0f68326e14d4b627f86634ea324510135cb551e27c5583a74811fa230b0cf34d" dependencies = [ "amplify", "bitcoin 0.32.102", @@ -4484,9 +5029,9 @@ dependencies = [ [[package]] name = "rgb-strict-types" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dfaa85981472cf64009d0bc605891917b320b4f735644c2940c2574f525354" +checksum = "e920bdf61b662a3743572a03696b0d88992eeb7d4f7afa0902915968327b3ce6" dependencies = [ "amplify", "baid64", @@ -4496,7 +5041,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "sha2 0.10.9", + "sha2 0.11.0", "toml 0.8.23", "wasm-bindgen", ] @@ -4517,18 +5062,47 @@ dependencies = [ [[package]] name = "ripemd" -version = "0.1.3" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" +checksum = "4dd4211456b4172d7e44261920c25acf07367c4f04bb5f5d54fc21b090d9b159" dependencies = [ - "digest 0.10.7", + "digest 0.11.3", +] + +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", ] [[package]] name = "rln-migration" version = "0.1.0" dependencies = [ - "sea-orm-migration", + "sea-orm-migration 1.1.20", "tokio", ] @@ -4538,7 +5112,7 @@ version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid", + "const-oid 0.9.6", "digest 0.10.7", "num-bigint-dig", "num-integer", @@ -4559,8 +5133,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" dependencies = [ "arrayvec", + "borsh", + "bytes", "num-traits", + "rand 0.8.7", + "rkyv", "serde", + "serde_json", "wasm-bindgen", ] @@ -4737,7 +5316,17 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" dependencies = [ - "cipher", + "cipher 0.4.4", +] + +[[package]] +name = "salsa20" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c" +dependencies = [ + "cfg-if", + "cipher 0.5.2", ] [[package]] @@ -4790,12 +5379,25 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" dependencies = [ - "password-hash", - "pbkdf2", - "salsa20", + "password-hash 0.5.0", + "pbkdf2 0.12.2", + "salsa20 0.10.2", "sha2 0.10.9", ] +[[package]] +name = "scrypt" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0" +dependencies = [ + "cfg-if", + "password-hash 0.6.1", + "pbkdf2 0.13.0", + "salsa20 0.11.0", + "sha2 0.11.0", +] + [[package]] name = "sct" version = "0.7.1" @@ -4823,30 +5425,71 @@ name = "sea-orm" version = "1.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dc312fedd460a47ea563911761d254a84e7b51d8cc73ec92c929e78f33fa957" +dependencies = [ + "async-stream", + "async-trait", + "chrono", + "derive_more", + "futures-util", + "log", + "ouroboros", + "sea-orm-macros 1.1.20", + "sea-query 0.32.7", + "sea-query-binder", + "serde", + "sqlx 0.8.6", + "strum 0.26.3", + "thiserror 2.0.19", + "tracing", + "url", +] + +[[package]] +name = "sea-orm" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a334e83ced3ae3ee44db0f84d1fcf8d2087a1ad9bb9036f00f9f6067156ea197" dependencies = [ "async-stream", "async-trait", "bigdecimal", "chrono", + "derive-where", "derive_more", "futures-util", + "itertools 0.14.0", "log", "mac_address", "ouroboros", "pgvector", "rust_decimal", - "sea-orm-macros", - "sea-query", - "sea-query-binder", + "sea-orm-arrow", + "sea-orm-macros 2.0.2", + "sea-query 1.0.2", + "sea-query-sqlx", + "sea-schema 0.18.1", "serde", "serde_json", - "sqlx", - "strum", + "sqlx 0.9.0", + "sqlx-core 0.9.0", + "strum 0.28.0", "thiserror 2.0.19", "time", "tracing", "url", "uuid", + "web-time", +] + +[[package]] +name = "sea-orm-arrow" +version = "2.0.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c800d9db902534d7d01728faf98e33d13c1d57bb8c57d8e4c518309172bddda" +dependencies = [ + "arrow", + "sea-query 1.0.2", + "thiserror 2.0.19", ] [[package]] @@ -4860,8 +5503,28 @@ dependencies = [ "dotenvy", "glob", "regex", - "sea-schema", - "sqlx", + "sea-schema 0.16.2", + "sqlx 0.8.6", + "tokio", + "tracing", + "tracing-subscriber", + "url", +] + +[[package]] +name = "sea-orm-cli" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a53505884d7c907bcf4f7b4ddb1b29425e62fef8b98aea9c99e17781cceb798" +dependencies = [ + "chrono", + "clap", + "dotenvy", + "glob", + "indoc", + "regex", + "sea-schema 0.18.1", + "sqlx 0.9.0", "tokio", "tracing", "tracing-subscriber", @@ -4882,6 +5545,22 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sea-orm-macros" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4039a86f9acc4d3b52747508b347dddc6fd725bbc429902ebeb6d26225fc2528" +dependencies = [ + "heck 0.5.0", + "itertools 0.14.0", + "pluralizer", + "proc-macro2", + "quote", + "sea-bae", + "syn 2.0.119", + "unicode-ident", +] + [[package]] name = "sea-orm-migration" version = "1.1.20" @@ -4891,9 +5570,25 @@ dependencies = [ "async-trait", "clap", "dotenvy", - "sea-orm", - "sea-orm-cli", - "sea-schema", + "sea-orm 1.1.20", + "sea-orm-cli 1.1.20", + "sea-schema 0.16.2", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "sea-orm-migration" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd09adbef87100d07131a60a8c5508b53d0cf2136521f654aae47af5e6a097fe" +dependencies = [ + "async-trait", + "clap", + "dotenvy", + "sea-orm 2.0.2", + "sea-orm-cli 2.0.2", + "sea-schema 0.18.1", "tracing", "tracing-subscriber", ] @@ -4907,8 +5602,23 @@ dependencies = [ "chrono", "inherent", "ordered-float", - "sea-query-derive", + "sea-query-derive 0.4.3", +] + +[[package]] +name = "sea-query" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "546040c653a705e60ec65ecd3191a809603734bebbc225775916dea9ae409b31" +dependencies = [ + "chrono", + "itoa", + "ordered-float", + "rust_decimal", + "sea-query-derive 1.0.0", "serde_json", + "time", + "uuid", ] [[package]] @@ -4918,9 +5628,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b0019f47430f7995af63deda77e238c17323359af241233ec768aba1faea7608" dependencies = [ "chrono", - "sea-query", - "serde_json", - "sqlx", + "sea-query 0.32.7", + "sqlx 0.8.6", ] [[package]] @@ -4937,6 +5646,30 @@ dependencies = [ "thiserror 2.0.19", ] +[[package]] +name = "sea-query-derive" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b0f466921cdd3cf4b89d5c3ac2173dba89a873ab395b123a645de181ec7537" +dependencies = [ + "darling 0.20.11", + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 2.0.119", + "thiserror 2.0.19", +] + +[[package]] +name = "sea-query-sqlx" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eaa419cdb9157da1361186b1959983eb2ea0dcb9a3c69dc45c449ecb2af8fef" +dependencies = [ + "sea-query 1.0.2", + "sqlx 0.9.0", +] + [[package]] name = "sea-schema" version = "0.16.2" @@ -4944,10 +5677,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2239ff574c04858ca77485f112afea1a15e53135d3097d0c86509cef1def1338" dependencies = [ "futures", - "sea-query", + "sea-query 0.32.7", "sea-query-binder", "sea-schema-derive", - "sqlx", + "sqlx 0.8.6", +] + +[[package]] +name = "sea-schema" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3553c77dceed56e95bece9ea876c4dd67ca879ef51055a0b97a7bb89a8ae4fed" +dependencies = [ + "async-trait", + "sea-query 1.0.2", + "sea-query-sqlx", + "sea-schema-derive", + "sqlx 0.9.0", ] [[package]] @@ -4962,6 +5708,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + [[package]] name = "sec1" version = "0.7.3" @@ -4993,12 +5745,35 @@ version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ - "bitcoin_hashes 0.12.0", + "bitcoin_hashes 0.14.101", "rand 0.8.7", "secp256k1-sys 0.10.1", "serde", ] +[[package]] +name = "secp256k1" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2" +dependencies = [ + "bitcoin_hashes 0.14.101", + "rand 0.9.5", + "secp256k1-sys 0.11.0", + "serde", +] + +[[package]] +name = "secp256k1" +version = "0.32.0-beta.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5fdc7d6e800869d3fd60ff857c479bf0a83ea7bf44b389e64461e844204994" +dependencies = [ + "rand 0.9.5", + "secp256k1-sys 0.12.0", + "serde", +] + [[package]] name = "secp256k1-sys" version = "0.8.2" @@ -5017,6 +5792,24 @@ dependencies = [ "cc", ] +[[package]] +name = "secp256k1-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcb913707158fadaf0d8702c2db0e857de66eb003ccfdda5924b5f5ac98efb38" +dependencies = [ + "cc", +] + +[[package]] +name = "secp256k1-sys" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d3be00697c88c00fe102af8dc316038cc2062eab8da646e7463f4c0e70ca9fd" +dependencies = [ + "cc", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -5235,6 +6028,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + [[package]] name = "sha2" version = "0.9.9" @@ -5259,6 +6063,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -5302,9 +6117,9 @@ dependencies = [ "base64 0.22.1", "bitcoin 0.32.102", "chacha20 0.9.1", - "chacha20poly1305", + "chacha20poly1305 0.10.1", "hex", - "poly1305", + "poly1305 0.8.0", "serde", "serde_json", "thiserror 2.0.19", @@ -5440,11 +6255,24 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" dependencies = [ - "sqlx-core", - "sqlx-macros", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", + "sqlx-core 0.8.6", + "sqlx-macros 0.8.6", + "sqlx-mysql 0.8.6", + "sqlx-postgres 0.8.6", + "sqlx-sqlite 0.8.6", +] + +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core 0.9.0", + "sqlx-macros 0.9.0", + "sqlx-mysql 0.9.0", + "sqlx-postgres 0.9.0", + "sqlx-sqlite 0.9.0", ] [[package]] @@ -5465,7 +6293,7 @@ dependencies = [ "futures-io", "futures-util", "hashbrown 0.15.5", - "hashlink", + "hashlink 0.10.0", "indexmap", "log", "memchr", @@ -5484,6 +6312,46 @@ dependencies = [ "webpki-roots 0.26.11", ] +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64 0.22.1", + "bytes", + "cfg-if", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink 0.11.1", + "indexmap", + "log", + "memchr", + "percent-encoding", + "rust_decimal", + "rustls 0.23.42", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror 2.0.19", + "time", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 1.0.9", +] + [[package]] name = "sqlx-macros" version = "0.8.6" @@ -5492,8 +6360,21 @@ checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" dependencies = [ "proc-macro2", "quote", - "sqlx-core", - "sqlx-macros-core", + "sqlx-core 0.8.6", + "sqlx-macros-core 0.8.6", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core 0.9.0", + "sqlx-macros-core 0.9.0", "syn 2.0.119", ] @@ -5513,10 +6394,35 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "sqlx-core", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", + "sqlx-core 0.8.6", + "sqlx-mysql 0.8.6", + "sqlx-postgres 0.8.6", + "sqlx-sqlite 0.8.6", + "syn 2.0.119", + "tokio", + "url", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core 0.9.0", + "sqlx-mysql 0.9.0", + "sqlx-postgres 0.9.0", + "sqlx-sqlite 0.9.0", "syn 2.0.119", "tokio", "url", @@ -5544,25 +6450,55 @@ dependencies = [ "futures-util", "generic-array", "hex", - "hkdf", - "hmac", + "hkdf 0.12.4", + "hmac 0.12.1", "itoa", "log", - "md-5", + "md-5 0.10.6", "memchr", "once_cell", "percent-encoding", "rand 0.8.7", "rsa", "serde", - "sha1", + "sha1 0.10.7", "sha2 0.10.9", "smallvec", - "sqlx-core", + "sqlx-core 0.8.6", "stringprep", "thiserror 2.0.19", "tracing", - "whoami", + "whoami 1.6.1", +] + +[[package]] +name = "sqlx-mysql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" +dependencies = [ + "bitflags 2.13.1", + "byteorder", + "bytes", + "chrono", + "crc", + "digest 0.11.3", + "dotenvy", + "either", + "futures-core", + "futures-util", + "generic-array", + "log", + "percent-encoding", + "rust_decimal", + "serde", + "sha1 0.11.0", + "sha2 0.11.0", + "sqlx-core 0.9.0", + "thiserror 2.0.19", + "time", + "tracing", + "uuid", ] [[package]] @@ -5578,17 +6514,17 @@ dependencies = [ "chrono", "crc", "dotenvy", - "etcetera", + "etcetera 0.8.0", "futures-channel", "futures-core", "futures-util", "hex", - "hkdf", - "hmac", + "hkdf 0.12.4", + "hmac 0.12.1", "home", "itoa", "log", - "md-5", + "md-5 0.10.6", "memchr", "once_cell", "rand 0.8.7", @@ -5596,11 +6532,50 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smallvec", - "sqlx-core", + "sqlx-core 0.8.6", + "stringprep", + "thiserror 2.0.19", + "tracing", + "whoami 1.6.1", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.13.1", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera 0.11.0", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf 0.13.0", + "hmac 0.13.0", + "itoa", + "log", + "md-5 0.11.0", + "memchr", + "rand 0.10.2", + "rust_decimal", + "serde", + "serde_json", + "sha2 0.11.0", + "smallvec", + "sqlx-core 0.9.0", "stringprep", "thiserror 2.0.19", + "time", "tracing", - "whoami", + "uuid", + "whoami 2.1.3", ] [[package]] @@ -5611,7 +6586,7 @@ checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ "atoi", "chrono", - "flume", + "flume 0.11.1", "futures-channel", "futures-core", "futures-executor", @@ -5622,10 +6597,37 @@ dependencies = [ "percent-encoding", "serde", "serde_urlencoded", - "sqlx-core", + "sqlx-core 0.8.6", + "thiserror 2.0.19", + "tracing", + "url", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +dependencies = [ + "atoi", + "chrono", + "flume 0.12.0", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core 0.9.0", "thiserror 2.0.19", + "time", "tracing", "url", + "uuid", ] [[package]] @@ -5673,6 +6675,12 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" + [[package]] name = "subtle" version = "2.6.1" @@ -5765,6 +6773,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60" +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tempfile" version = "3.27.0" @@ -5845,15 +6859,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "tiger" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "579abbce4ad73b04386dbeb34369c9873a8f9b749c7b99cbf479a2949ff715ed" -dependencies = [ - "digest 0.10.7", -] - [[package]] name = "time" version = "0.3.54" @@ -5884,6 +6889,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -6010,8 +7024,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime", - "toml_edit", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] @@ -6023,6 +7037,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -6032,9 +7055,30 @@ dependencies = [ "indexmap", "serde", "serde_spanned", - "toml_datetime", + "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", ] [[package]] @@ -6206,7 +7250,7 @@ dependencies = [ "httparse", "log", "rand 0.8.7", - "sha1", + "sha1 0.10.7", "thiserror 1.0.69", "utf-8", ] @@ -6409,10 +7453,20 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -6500,7 +7554,7 @@ name = "vls-core" version = "0.14.0" source = "git+https://github.com/UTEXO-Protocol/vls-core.git?branch=feat%2Frgb-compatibility#45c72edfd58620849eb486925439a76526b415ae" dependencies = [ - "ahash", + "ahash 0.8.12", "anyhow", "backtrace", "bitcoin 0.32.102", @@ -6826,6 +7880,12 @@ dependencies = [ "wasite", ] +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" + [[package]] name = "winapi" version = "0.3.9" @@ -7093,6 +8153,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.57.1" @@ -7105,6 +8174,15 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "yansi" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index 0ccba6e4..870afc25 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "rgb-lightning-node" version = "0.1.0" edition = "2021" -rust-version = "1.88.0" +rust-version = "1.94.0" [lib] name = "rgb_lightning_node" @@ -25,11 +25,40 @@ exclude = [ unexpected_cfgs = "allow" [features] -default = [] +# every fork gate (build/lint/test) runs without `--no-default-features`, so the chain-backend +# features are all on by default and the previously unconditional code keeps compiling +default = [ + "block-sync", + "transaction-sync", + "electrum", + "esplora", +] test-utils = [] vls = ["signer-external/with-vls", "dep:vls-persist"] vss = ["rgb-lib/vss", "dep:vss-client"] remote-signer = ["dep:vls-persist", "dep:tokio-rustls", "dep:rustls-pemfile", "vls"] +# sync LDK from a bitcoind instance, consuming full blocks over JSON-RPC +block-sync = [ + "dep:lightning-block-sync", +] +# sync LDK from the indexer, without requiring a bitcoind instance +transaction-sync = [ + "dep:lightning-transaction-sync", +] +# support indexers implementing the electrum protocol +electrum = [ + "rgb-lib/electrum", + "lightning/electrum", + "lightning-transaction-sync?/electrum", + "dep:electrum-client", +] +# support indexers implementing the esplora protocol +esplora = [ + "rgb-lib/esplora", + "lightning/esplora", + "lightning-transaction-sync?/esplora-blocking", + "dep:esplora-client", +] [dependencies] amplify = { version = "=4.8.1", default-features = false } @@ -48,29 +77,26 @@ chacha20 = "0.9.1" chrono = { version = "0.4", default-features = false, features = ["clock"] } clap = "4.5.20" dirs = "5.0.1" -electrum-client = "0.20.0" -esplora-client = { version = "0.12", default-features = false, features = ["blocking-https-rustls"] } +# kept on default features (adds `proxy`) so socks5 electrum URLs keep working +electrum-client = { version = "0.24.0", optional = true } +esplora-client = { version = "0.12", default-features = false, features = ["blocking-https-rustls"], optional = true } futures = "0.3" hex = { package = "hex-conservative", version = "0.3.0", default-features = false } lightning = { version = "0.2.0", features = ["dnssec"] } lightning-background-processor = { version = "0.2.0" } -lightning-block-sync = { version = "0.2.0", features = ["rpc-client", "tokio"] } +lightning-block-sync = { version = "0.2.0", features = ["rpc-client", "tokio"], optional = true } lightning-dns-resolver = { version = "0.3.0" } lightning-invoice = { version = "0.34.0", features = ["std"] } lightning-macros = { version = "0.2.0" } lightning-net-tokio = { version = "0.2.0" } lightning-persister = { version = "0.2.0", features = ["tokio"] } lightning-rapid-gossip-sync = { version = "0.2.0" } -lightning-transaction-sync = { version = "0.2.0", features = ["esplora-blocking", "electrum"] } -magic-crypt = "4.0.1" +lightning-transaction-sync = { version = "0.2.0", optional = true } rand = "0.8.5" regex = { version = "1.11", default-features = false } reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] } -rgb-lib = { git = "https://github.com/UTEXO-Protocol/rgb-lib.git", tag = "v0.3.0-beta.32", features = [ - "electrum", - "esplora", -] } -psrgbt = { package = "rgb-psbt-utils", version = "=0.11.1-rc.10", default-features = false } +rgb-lib = { git = "https://github.com/UTEXO-Protocol/rgb-lib.git", tag = "v0.3.0-beta.34", default-features = false } +psrgbt = { package = "rgb-psbt-utils", version = "=0.11.1-rc.11", default-features = false } rln-migration = { path = "migration" } # Pinned fork/branch for RGB compatibility work; CI and clones do not require a sibling checkout. signer-external = { git = "https://github.com/UTEXO-Protocol/rln-external-signer.git", branch = "main", default-features = false } @@ -97,7 +123,6 @@ tower-http = { version = "0.6.1", features = ["cors", "limit", "trace"] } tracing = "0.1" tracing-appender = "0.2.3" tracing-subscriber = { version = "0.3", features = ["env-filter"] } -typenum = "1.17.0" uuid = { version = "1.11.0", default-features = false, features = ["v4"] } # `vls-protocol` remains on crates.io; only `vls-core` and # `vls-protocol-signer` need the RGB compatibility forks. @@ -122,7 +147,7 @@ libc = "0.2" [dev-dependencies] dircmp = "0.2.0" rcgen = "0.13" -electrum-client = "0.20.0" +electrum-client = "0.24.0" http = "1.4.0" lazy_static = { version = "1.5.0", default-features = false } lightning = { version = "0.2.0", features = ["_rln_test_hooks"] } diff --git a/README.md b/README.md index 4b3104d2..f782ecd1 100644 --- a/README.md +++ b/README.md @@ -52,34 +52,59 @@ The repository supports AI-assisted pull request reviews. Setup details for Claude, Codex, and extendable provider onboarding are documented in [`/.github/ai-review-bots.md`](.github/ai-review-bots.md). +### Indexer support + +Support for the indexer protocols is behind cargo features, `electrum` and +`esplora` (both enabled by default). At least one of them needs to be enabled. + +To support electrum indexers only: +```sh +cargo install --locked --path . --no-default-features --features electrum,block-sync,transaction-sync +``` + +To support esplora indexers only: +```sh +cargo install --locked --path . --no-default-features --features esplora,block-sync,transaction-sync +``` + +### Chain sync support + +Support for the chain sync backends is behind cargo features, `block-sync` and +`transaction-sync` (both enabled by default). At least one of them needs to be +enabled. See [Sync modes](#sync-modes) for what each backend does. + +To support the block-sync backend only: +```sh +cargo install --locked --path . --no-default-features --features block-sync,electrum,esplora +``` + +To support the transaction-sync backend only: +```sh +cargo install --locked --path . --no-default-features --features transaction-sync,electrum,esplora +``` + ## Run In order to operate, the node will need: -- a chain backend, either: - - a bitcoind node (drives LDK chain sync via RPC), or - - an esplora server (drives LDK chain sync over HTTP) +- a bitcoind node (only for the `BlockSync` [sync mode](#sync-modes)) - an indexer instance for RGB (electrum or esplora — forwarded to rgb-lib) -- an [RGB proxy server] instance Once services are running, daemons can be started. Each daemon needs to be started in a separate shell with `rgb-lightning-node`, specifying: -- node data directory -- node listening port -- LN peer listening port -- network +- node data directory (positional argument) +- node listening port (`--daemon-listening-port`, default: 3001) +- LN peer listening port (`--ldk-peer-listening-port`, default: 9735) +- network (`--network`, default: testnet) Chain-backend credentials are supplied at `/unlock` time, not on the CLI. The -body must include exactly one of: -- all four `bitcoind_rpc_*` fields — LDK chain sync runs via bitcoind RPC. - An optional electrum `indexer_url` is forwarded to rgb-lib only. -- esplora `indexer_url` (no `bitcoind_rpc_*` fields) — LDK chain sync runs - over esplora, same URL forwarded to rgb-lib. -- electrum `indexer_url` (no `bitcoind_rpc_*` fields) — LDK chain sync runs - over electrum, same URL forwarded to rgb-lib. - -`bitcoind + esplora` returns `400 AmbiguousChainBackend`. No credentials at -all returns `400 MissingChainBackend`. +body must include `ldk_chain_sync`, which names the [sync mode](#sync-modes) and +carries that mode's own configuration. The RGB wallet's `indexer_url` is +independent of it: any indexer can be paired with any sync mode, so `BlockSync` +against bitcoind with an esplora `indexer_url` is a valid combination. +`indexer_url` and `proxy_endpoint` may be omitted from the body, in which case +they come from the `[chain]` section of the config file; if neither supplies an +indexer URL the unlock fails with `MissingIndexerUrl`. ### Configuration file @@ -99,9 +124,12 @@ To easily start the required services on a regtest network, run: ``` This command will create the directories needed by the services, start the -docker containers and mine some blocks. The test environment will always start -in a clean state, taking down previous running services (if any) and -re-creating data directories. +docker containers and mine some blocks. The regtest docker stack also starts +an RGB proxy on port 3000; it is only needed when using proxy-based +`transport_endpoints` (see [RGB consignment transport](#rgb-consignment-transport)). + +The test environment will always start in a clean state, taking down previous +running services (if any) and re-creating data directories. Here's an example of how to start three regtest nodes, each one using the shared regtest services provided by docker compose: @@ -166,7 +194,6 @@ When unlocking regtest nodes use the following local services: - bitcoind_rpc_host: localhost - bitcoind_rpc_port: 18443 - indexer_url: 127.0.0.1:50001 -- proxy_endpoint: rpc://127.0.0.1:3000/json-rpc To unlock a regtest nodes running in docker use the following local services: - bitcoind_rpc_username: user @@ -174,7 +201,6 @@ To unlock a regtest nodes running in docker use the following local services: - bitcoind_rpc_host: bitcoind - bitcoind_rpc_port: 18443 - indexer_url: electrs:50001 -- proxy_endpoint: rpc://proxy:3000/json-rpc ### Testnet @@ -209,7 +235,6 @@ When unlocking testnet3 nodes you can use the following services: - bitcoind_rpc_host: electrum.iriswallet.com - bitcoind_rpc_port: 18332 - indexer_url: ssl://electrum.iriswallet.com:50013 -- proxy_endpoint: rpcs://proxy.iriswallet.com/0.2/json-rpc #### Testnet4 @@ -254,6 +279,7 @@ The node currently exposes the following APIs: - `/failtransfers` (POST) - `/getassetmedia` (POST) - `/getchannelid` (POST) +- `/getconsignment` (POST) - `/getpayment` (POST) - `/getswap` (POST) - `/inflate` (POST) @@ -280,6 +306,8 @@ The node currently exposes the following APIs: - `/nodeinfo` (GET) - `/openchannel` (POST) - `/postassetmedia` (POST) +- `/provideoutofbandack` (POST) +- `/provideoutofbandconsignment` (POST) - `/refreshtransfers` (POST) - `/restore` (POST) - `/revoketoken` (POST) @@ -407,6 +435,43 @@ The node exposes a `/revoketoken` endpoint for this purpose. Internally, the node extracts the token’s revocation identifiers and adds them to its revocation list. Every request checks this list before authenticating. +### RGB consignment transport + +RGB consignments can be exchanged in three ways: + +- **Lightning P2P**: automatic during channel opening and LN RGB payments. +- **Out-of-band**: pass empty `transport_endpoints` to `/rgbinvoice` and + `/sendrgb`, then exchange the consignment and ACK manually via + `/getconsignment`, `/provideoutofbandconsignment`, and + `/provideoutofbandack`. No extra service required. +- **RGB proxy**: pass proxy URLs in `transport_endpoints`; consignments and + ACKs are relayed automatically by an [RGB proxy server]. Use + `/checkproxyendpoint` to validate a URL. + +Example proxy URLs (only when using proxy transport): + +| Environment | `transport_endpoints` | +|-------------|-----------------------| +| Local | `rpc://127.0.0.1:3000/json-rpc` | +| Public | `rpcs://proxy.iriswallet.com/0.2/json-rpc` | + +## Sync modes + +The node keeps LDK in sync with the chain in one of two ways, selected at unlock +time via the `ldk_chain_sync` field of the `/unlock` payload (see the +`UnlockRequest` schema in `openapi.yaml` for the exact shape): + +- `BlockSync`: consume full blocks from a trusted/local `bitcoind` over JSON-RPC. + The `bitcoind_rpc_*` parameters are provided under this mode's `config`. This + is the more trust-minimized option, since the node does not rely on an indexer + to tell it which transactions are relevant. +- `TransactionSync`: sync through an electrum/esplora indexer, so no `bitcoind` + is needed. The indexer LDK syncs against is given under this mode's `config` + via `indexer_url` and can differ from the one the RGB wallet uses. + +Both modes are available in a stock build. See +[Chain sync support](#chain-sync-support) to build with only one of them. + ## Test Tests for a few scenarios using the regtest network are included. The same @@ -418,6 +483,23 @@ Tests can be executed with: cargo test ``` +### Coverage + +Tests can also be run gathering code coverage, using [cargo-llvm-cov]. + +To run the tests and generate an HTML coverage report: +```sh +./coverage.sh +``` +The report path is output at the end of the run. + +To only run some test(s): +```sh +./coverage.sh -t +``` + +See `./coverage.sh --help` for the available options. + ## Projects using RLN Here is a list of projects using RLN, in alphabetical order: @@ -516,3 +598,4 @@ Replication guarantees differ per stream. Channel-monitor writes are remote-firs [Spectrum]: https://rgbspectrum.pages.dev/ [Thunderstack]: https://thunderstack.org/ [Tiramisu Wallet]: https://mainnet.tiramisuwallet.com/ +[cargo-llvm-cov]: https://github.com/taiki-e/cargo-llvm-cov diff --git a/android-e2e/app/src/androidTest/java/org/rgblightningnode/ConcurrentBtcPaymentsTest.kt b/android-e2e/app/src/androidTest/java/org/rgblightningnode/ConcurrentBtcPaymentsTest.kt index 49a6f5b2..2b9221fb 100644 --- a/android-e2e/app/src/androidTest/java/org/rgblightningnode/ConcurrentBtcPaymentsTest.kt +++ b/android-e2e/app/src/androidTest/java/org/rgblightningnode/ConcurrentBtcPaymentsTest.kt @@ -18,6 +18,7 @@ import org.utexo.rgblightningnode.PaymentType import org.utexo.rgblightningnode.RlnException import org.utexo.rgblightningnode.SdkCreateUtxosRequest import org.utexo.rgblightningnode.SdkInitRequest +import org.utexo.rgblightningnode.SdkLdkChainSync import org.utexo.rgblightningnode.SdkNode import org.utexo.rgblightningnode.SdkOpenChannelRequest import org.utexo.rgblightningnode.SdkSendPaymentRequest @@ -111,10 +112,12 @@ class ConcurrentBtcPaymentsTest { private fun unlockRequest(password: String) = SdkUnlockRequest( password = password, - bitcoindRpcUsername = bitcoindUser, - bitcoindRpcPassword = bitcoindPass, - bitcoindRpcHost = bitcoindHost, - bitcoindRpcPort = bitcoindPort.toUShort(), + ldkChainSync = SdkLdkChainSync.BlockSync( + bitcoindRpcUsername = bitcoindUser, + bitcoindRpcPassword = bitcoindPass, + bitcoindRpcHost = bitcoindHost, + bitcoindRpcPort = bitcoindPort.toUShort(), + ), indexerUrl = "$bitcoindHost:50001", proxyEndpoint = proxyEndpoint, announceAddresses = listOf(), diff --git a/android-e2e/app/src/androidTest/java/org/rgblightningnode/MultiOpenCloseTest.kt b/android-e2e/app/src/androidTest/java/org/rgblightningnode/MultiOpenCloseTest.kt index 16c9c941..c997668e 100644 --- a/android-e2e/app/src/androidTest/java/org/rgblightningnode/MultiOpenCloseTest.kt +++ b/android-e2e/app/src/androidTest/java/org/rgblightningnode/MultiOpenCloseTest.kt @@ -22,6 +22,7 @@ import org.utexo.rgblightningnode.SdkCreateUtxosRequest import org.utexo.rgblightningnode.SdkInitRequest import org.utexo.rgblightningnode.SdkIssueAssetNiaRequest import org.utexo.rgblightningnode.SdkKeysendRequest +import org.utexo.rgblightningnode.SdkLdkChainSync import org.utexo.rgblightningnode.SdkNode import org.utexo.rgblightningnode.SdkOpenChannelRequest import org.utexo.rgblightningnode.SdkRefreshTransfersRequest @@ -112,10 +113,12 @@ class MultiOpenCloseTest { private fun unlockRequest(password: String) = SdkUnlockRequest( password = password, - bitcoindRpcUsername = bitcoindUser, - bitcoindRpcPassword = bitcoindPass, - bitcoindRpcHost = bitcoindHost, - bitcoindRpcPort = bitcoindPort.toUShort(), + ldkChainSync = SdkLdkChainSync.BlockSync( + bitcoindRpcUsername = bitcoindUser, + bitcoindRpcPassword = bitcoindPass, + bitcoindRpcHost = bitcoindHost, + bitcoindRpcPort = bitcoindPort.toUShort(), + ), indexerUrl = "$bitcoindHost:50001", proxyEndpoint = proxyEndpoint, announceAddresses = listOf(), diff --git a/android-e2e/app/src/androidTest/java/org/rgblightningnode/PaymentTest.kt b/android-e2e/app/src/androidTest/java/org/rgblightningnode/PaymentTest.kt index c79d14de..4a0768fd 100644 --- a/android-e2e/app/src/androidTest/java/org/rgblightningnode/PaymentTest.kt +++ b/android-e2e/app/src/androidTest/java/org/rgblightningnode/PaymentTest.kt @@ -25,6 +25,7 @@ import org.utexo.rgblightningnode.SdkCloseChannelRequest import org.utexo.rgblightningnode.SdkCreateUtxosRequest import org.utexo.rgblightningnode.SdkInitRequest import org.utexo.rgblightningnode.SdkIssueAssetNiaRequest +import org.utexo.rgblightningnode.SdkLdkChainSync import org.utexo.rgblightningnode.SdkNode import org.utexo.rgblightningnode.SdkOpenChannelRequest import org.utexo.rgblightningnode.SdkRefreshTransfersRequest @@ -123,10 +124,12 @@ class PaymentTest { private fun unlockRequest(password: String) = SdkUnlockRequest( password = password, - bitcoindRpcUsername = bitcoindUser, - bitcoindRpcPassword = bitcoindPass, - bitcoindRpcHost = bitcoindHost, - bitcoindRpcPort = bitcoindPort.toUShort(), + ldkChainSync = SdkLdkChainSync.BlockSync( + bitcoindRpcUsername = bitcoindUser, + bitcoindRpcPassword = bitcoindPass, + bitcoindRpcHost = bitcoindHost, + bitcoindRpcPort = bitcoindPort.toUShort(), + ), indexerUrl = "$bitcoindHost:50001", proxyEndpoint = proxyEndpoint, announceAddresses = listOf(), @@ -787,8 +790,9 @@ class PaymentTest { assertNotNull(xfer2.recipientId) assertNull(xfer2.receiveUtxo) assertNotNull(xfer2.changeUtxo) - assertNull(xfer2.expiration) - assertTrue(xfer2.transportEndpoints.isNotEmpty()) + assertNotNull(xfer2.expiration) + // the channel funding consignment travels over the p2p link, so no proxy is involved + assertTrue(xfer2.transportEndpoints.isEmpty()) val xfer3 = transfers.first { it.idx == 3 } assertEquals("Settled", xfer3.status) @@ -798,8 +802,8 @@ class PaymentTest { assertNotNull(xfer3.recipientId) assertNotNull(xfer3.receiveUtxo) assertNull(xfer3.changeUtxo) - assertNull(xfer3.expiration) - assertTrue(xfer3.transportEndpoints.isNotEmpty()) + assertNotNull(xfer3.expiration) + assertTrue(xfer3.transportEndpoints.isEmpty()) log("SUCCESS: Android payment parity flow completed") } finally { diff --git a/android-e2e/app/src/androidTest/java/org/rgblightningnode/RestartTest.kt b/android-e2e/app/src/androidTest/java/org/rgblightningnode/RestartTest.kt index e687ee4a..51ccfd3e 100644 --- a/android-e2e/app/src/androidTest/java/org/rgblightningnode/RestartTest.kt +++ b/android-e2e/app/src/androidTest/java/org/rgblightningnode/RestartTest.kt @@ -22,6 +22,7 @@ import org.utexo.rgblightningnode.SdkCloseChannelRequest import org.utexo.rgblightningnode.SdkCreateUtxosRequest import org.utexo.rgblightningnode.SdkInitRequest import org.utexo.rgblightningnode.SdkIssueAssetNiaRequest +import org.utexo.rgblightningnode.SdkLdkChainSync import org.utexo.rgblightningnode.SdkNode import org.utexo.rgblightningnode.SdkOpenChannelRequest import org.utexo.rgblightningnode.SdkRefreshTransfersRequest @@ -113,10 +114,12 @@ class RestartTest { private fun unlockRequest(password: String) = SdkUnlockRequest( password = password, - bitcoindRpcUsername = bitcoindUser, - bitcoindRpcPassword = bitcoindPass, - bitcoindRpcHost = bitcoindHost, - bitcoindRpcPort = bitcoindPort.toUShort(), + ldkChainSync = SdkLdkChainSync.BlockSync( + bitcoindRpcUsername = bitcoindUser, + bitcoindRpcPassword = bitcoindPass, + bitcoindRpcHost = bitcoindHost, + bitcoindRpcPort = bitcoindPort.toUShort(), + ), indexerUrl = "$bitcoindHost:50001", proxyEndpoint = proxyEndpoint, announceAddresses = listOf(), diff --git a/android-e2e/app/src/androidTest/java/org/rgblightningnode/SwapRoundtripBuyTest.kt b/android-e2e/app/src/androidTest/java/org/rgblightningnode/SwapRoundtripBuyTest.kt index 31ad8d53..ba726f8d 100644 --- a/android-e2e/app/src/androidTest/java/org/rgblightningnode/SwapRoundtripBuyTest.kt +++ b/android-e2e/app/src/androidTest/java/org/rgblightningnode/SwapRoundtripBuyTest.kt @@ -18,6 +18,7 @@ import org.utexo.rgblightningnode.SdkInitRequest import org.utexo.rgblightningnode.SdkIssueAssetNiaRequest import org.utexo.rgblightningnode.SdkMakerExecuteRequest import org.utexo.rgblightningnode.SdkMakerInitRequest +import org.utexo.rgblightningnode.SdkLdkChainSync import org.utexo.rgblightningnode.SdkNode import org.utexo.rgblightningnode.SdkOpenChannelRequest import org.utexo.rgblightningnode.SdkRefreshTransfersRequest @@ -140,10 +141,12 @@ class SwapRoundtripBuyTest { private fun unlockRequest(password: String) = SdkUnlockRequest( password = password, - bitcoindRpcUsername = bitcoindUser, - bitcoindRpcPassword = bitcoindPass, - bitcoindRpcHost = bitcoindHost, - bitcoindRpcPort = bitcoindPort.toUShort(), + ldkChainSync = SdkLdkChainSync.BlockSync( + bitcoindRpcUsername = bitcoindUser, + bitcoindRpcPassword = bitcoindPass, + bitcoindRpcHost = bitcoindHost, + bitcoindRpcPort = bitcoindPort.toUShort(), + ), indexerUrl = "$bitcoindHost:50001", proxyEndpoint = proxyEndpoint, announceAddresses = listOf(), diff --git a/bindings/c-ffi/Cargo.lock b/bindings/c-ffi/Cargo.lock index 0ad56b68..f7824a87 100644 --- a/bindings/c-ffi/Cargo.lock +++ b/bindings/c-ffi/Cargo.lock @@ -23,19 +23,29 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] [[package]] -name = "aes" -version = "0.8.4" +name = "aead" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", + "crypto-common 0.2.2", + "inout 0.2.2", +] + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", ] [[package]] @@ -45,6 +55,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "const-random", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -218,6 +230,165 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "arrow" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cfdd0833e32a9874d2b55089333ad310c0be208aafa277385ce2461dec90be3" +dependencies = [ + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-ord", + "arrow-row", + "arrow-schema", + "arrow-select", + "arrow-string", +] + +[[package]] +name = "arrow-arith" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a41203398f0eaa6f7ec8e62c0da742a21abf282c148fc157f6c35c90e29981a" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "num-traits", +] + +[[package]] +name = "arrow-array" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae33dad492b7df00a217563a7b0ef2874df68a0deea1b1a3acf628152f7f7a69" +dependencies = [ + "ahash 0.8.12", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "half", + "hashbrown 0.17.1", + "num-complex", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-buffer" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9552f96391c005e6ab449fa941420935e7e062489b12b8b1b08879b2163f5b5" +dependencies = [ + "bytes", + "half", + "num-bigint", + "num-traits", +] + +[[package]] +name = "arrow-cast" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8a327c9649f30d8406995f27642b68df354713cca3baaaf100f076f18d5f34" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-ord", + "arrow-schema", + "arrow-select", + "atoi", + "base64 0.22.1", + "chrono", + "half", + "lexical-core", + "num-traits", + "ryu", +] + +[[package]] +name = "arrow-data" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b24852db04738907e06c04ea61e42fe7fda962a34513022dc0d0e754fb7976b" +dependencies = [ + "arrow-buffer", + "arrow-schema", + "half", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-ord" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63a083ec750f5c043f02946b4baf05fcdbb55f4560a3277055caca5cc99f3eb0" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", +] + +[[package]] +name = "arrow-row" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "514ba0ef0d4c5896202dae736251ce415abb43a950bed570fb7981b8716c0e4c" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "half", +] + +[[package]] +name = "arrow-schema" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21ca356ad6425cecb6eb7b28e4f659f1ee7880fbb1a16127de7dd62901efee9e" + +[[package]] +name = "arrow-select" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c58da39eb3d8350ad4a549e5c2bc49284dac554016c69829310350f1731b0aad" +dependencies = [ + "ahash 0.8.12", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "num-traits", +] + +[[package]] +name = "arrow-string" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6789b388467525e3271326b6b4915666ecfdf5142aef09779445c954b67543c" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "memchr", + "num-traits", + "regex", + "regex-syntax", +] + [[package]] name = "as-any" version = "0.3.2" @@ -557,12 +728,12 @@ dependencies = [ [[package]] name = "bdk_electrum" -version = "0.23.2" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b59a3f7fbe678874fa34354097644a171276e02a49934c13b3d61c54610ddf39" +checksum = "00a9846105bf6e751adbb6946b000ff919ce24a15a941cbfe68485549b552a9c" dependencies = [ "bdk_core", - "electrum-client 0.24.1", + "electrum-client 0.25.0", ] [[package]] @@ -588,9 +759,9 @@ dependencies = [ [[package]] name = "bdk_wallet" -version = "3.0.0" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67f3c4f9526d22374fca5b7ff1d6bf8d921ab56db2dac8df66a2c5561b31d4ef" +checksum = "1284fb23acc3e3022673712b55f4d5ce7e38aadc2c49bbef830dc3935f0a3289" dependencies = [ "bdk_chain", "bdk_file_store", @@ -724,7 +895,7 @@ dependencies = [ "bitcoin_hashes", "hex-conservative 0.2.2", "hex_lit", - "secp256k1", + "secp256k1 0.29.1", "serde", ] @@ -834,6 +1005,18 @@ dependencies = [ "webpki-roots 1.0.9", ] +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "blake3" version = "1.8.5" @@ -867,12 +1050,12 @@ dependencies = [ ] [[package]] -name = "block-padding" -version = "0.3.3" +name = "block-buffer" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ - "generic-array", + "hybrid-array", ] [[package]] @@ -892,6 +1075,30 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" +[[package]] +name = "borsh" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f347189c62a579b8cd5f80714efa178f52e461dc2e6d701d264f5ff22e566c" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "bs58" version = "0.5.1" @@ -907,6 +1114,28 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "byteorder" version = "1.5.0" @@ -951,15 +1180,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "cbc" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" -dependencies = [ - "cipher", -] - [[package]] name = "cbindgen" version = "0.29.2" @@ -1010,7 +1230,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.17", ] @@ -1021,6 +1241,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", + "cipher 0.5.2", "cpufeatures 0.3.0", "rand_core 0.10.1", ] @@ -1037,13 +1258,25 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ - "aead", + "aead 0.5.2", "chacha20 0.9.1", - "cipher", - "poly1305", + "cipher 0.4.4", + "poly1305 0.8.0", "zeroize", ] +[[package]] +name = "chacha20poly1305" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" +dependencies = [ + "aead 0.6.1", + "chacha20 0.10.1", + "cipher 0.5.2", + "poly1305 0.9.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -1076,11 +1309,22 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", - "inout", + "crypto-common 0.1.7", + "inout 0.1.4", "zeroize", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.2", +] + [[package]] name = "clap" version = "4.6.4" @@ -1130,6 +1374,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" version = "1.0.5" @@ -1152,6 +1402,32 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -1211,15 +1487,6 @@ dependencies = [ "crc-catalog", ] -[[package]] -name = "crc-any" -version = "2.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46db9f663dfb869b80fcf59e32d7a80fc6c464a4f6328f3f06a00f5e36d05f8c" -dependencies = [ - "debug-helper", -] - [[package]] name = "crc-catalog" version = "2.5.0" @@ -1288,6 +1555,24 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -1393,12 +1678,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "debug-helper" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80a4af69c60438a1a82af89d362f4729fd38db7b73f305a237636fad31ceb2bf" - [[package]] name = "defmt" version = "1.1.1" @@ -1447,7 +1726,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" dependencies = [ - "const-oid", + "const-oid 0.9.6", ] [[package]] @@ -1456,7 +1735,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "pem-rfc7468", "zeroize", ] @@ -1470,6 +1749,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive-where" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -1503,15 +1793,6 @@ dependencies = [ "unicode-xid", ] -[[package]] -name = "des" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" -dependencies = [ - "cipher", -] - [[package]] name = "digest" version = "0.9.0" @@ -1528,11 +1809,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "const-oid", - "crypto-common", + "const-oid 0.9.6", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "dirs" version = "5.0.1" @@ -1638,9 +1931,9 @@ dependencies = [ [[package]] name = "electrum-client" -version = "0.20.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c7b1f8783238bb18e6e137875b0a66f3dffe6c7ea84066e05d033cf180b150f" +checksum = "a5059f13888a90486e7268bbce59b175f5f76b1c55e5b9c568ceaa42d2b8507c" dependencies = [ "bitcoin", "byteorder", @@ -1655,9 +1948,9 @@ dependencies = [ [[package]] name = "electrum-client" -version = "0.24.1" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5059f13888a90486e7268bbce59b175f5f76b1c55e5b9c568ceaa42d2b8507c" +checksum = "1970c5d7bd9de6d4041cbfc3e46faa3e85fe7efcea2b0eb06750d3ae1ef577b7" dependencies = [ "bitcoin", "byteorder", @@ -1773,6 +2066,16 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + [[package]] name = "event-listener" version = "5.4.2" @@ -1856,6 +2159,17 @@ dependencies = [ "spin", ] +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1868,6 +2182,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -1907,6 +2227,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures" version = "0.3.33" @@ -2119,16 +2445,26 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", + "num-traits", "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + [[package]] name = "hashbrown" version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" dependencies = [ - "ahash", + "ahash 0.8.12", ] [[package]] @@ -2137,7 +2473,7 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ - "ahash", + "ahash 0.8.12", "serde", ] @@ -2149,7 +2485,18 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", ] [[package]] @@ -2167,6 +2514,15 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "heck" version = "0.4.1" @@ -2230,7 +2586,16 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", ] [[package]] @@ -2242,6 +2607,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "home" version = "0.5.12" @@ -2296,6 +2670,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.11.0" @@ -2519,6 +2902,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inherent" version = "1.0.14" @@ -2536,10 +2928,18 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "block-padding", "generic-array", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2703,6 +3103,63 @@ dependencies = [ "spin", ] +[[package]] +name = "lexical-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" +dependencies = [ + "lexical-parse-float", + "lexical-parse-integer", + "lexical-util", + "lexical-write-float", + "lexical-write-integer", +] + +[[package]] +name = "lexical-parse-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" +dependencies = [ + "lexical-parse-integer", + "lexical-util", +] + +[[package]] +name = "lexical-parse-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "lexical-util" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" + +[[package]] +name = "lexical-write-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361" +dependencies = [ + "lexical-util", + "lexical-write-integer", +] + +[[package]] +name = "lexical-write-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df" +dependencies = [ + "lexical-util", +] + [[package]] name = "libc" version = "0.2.189" @@ -2975,22 +3432,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "magic-crypt" -version = "4.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "844b6169eeaae32ae8a61855964331a67f12d2afba9170303fbd3e3c2a861a52" -dependencies = [ - "aes", - "base64 0.22.1", - "cbc", - "crc-any", - "des", - "md-5", - "sha2 0.10.9", - "tiger", -] - [[package]] name = "matchers" version = "0.2.0" @@ -3016,6 +3457,16 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + [[package]] name = "memchr" version = "2.8.3" @@ -3219,6 +3670,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -3425,6 +3885,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "password-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" +dependencies = [ + "phc", +] + [[package]] name = "paste" version = "1.0.15" @@ -3438,7 +3907,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest 0.10.7", - "hmac", + "hmac 0.12.1", +] + +[[package]] +name = "pbkdf2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest 0.11.3", + "hmac 0.13.0", ] [[package]] @@ -3488,6 +3967,16 @@ dependencies = [ "serde", ] +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -3537,6 +4026,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "pluralizer" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b3eba432a00a1f6c16f39147847a870e94e2e9b992759b503e330efec778cbe" +dependencies = [ + "once_cell", + "regex", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -3545,7 +4044,17 @@ checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ "cpufeatures 0.2.17", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", +] + +[[package]] +name = "poly1305" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +dependencies = [ + "cpufeatures 0.3.0", + "universal-hash 0.6.1", ] [[package]] @@ -3622,6 +4131,15 @@ dependencies = [ "elliptic-curve", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -3776,6 +4294,26 @@ dependencies = [ "prost 0.11.9", ] +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "quinn" version = "0.11.11" @@ -3854,6 +4392,12 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.8.7" @@ -4035,6 +4579,15 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -4123,15 +4676,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac", + "hmac 0.12.1", "subtle", ] [[package]] name = "rgb-aluvm" -version = "0.11.1-rc.3" +version = "0.11.1-rc.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b60686e4f1701ca4f73b77660777a49e651f3c611461ab62ef718db47a5a6caa" +checksum = "dcf4d92478fcff567ff0bd9f18684fd954f8426c74f16e5517254b0a2d34023a" dependencies = [ "amplify", "baid64", @@ -4144,28 +4697,28 @@ dependencies = [ "rgb-strict-types", "ripemd", "serde", - "sha2 0.10.9", + "sha2 0.11.0", "wasm-bindgen", ] [[package]] name = "rgb-ascii-armor" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fe59d42597231134da1a7b51e5e380b2e1cf7441c27f2a8c23c6c27ea206888" +checksum = "fcde5a129540e5911c24930fd854ea658a715b50a690d254273088f907a63516" dependencies = [ "amplify", "baid64", "base85", "rgb-strict-encoding", - "sha2 0.10.9", + "sha2 0.11.0", ] [[package]] name = "rgb-consensus" -version = "0.11.1-rc.10" +version = "0.11.1-rc.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535483ee9143782e33ddebd3eda465de0184061fc3c480e24990e42fefc58bb0" +checksum = "cee44070883ac31a112814ee635623b0bde44a2afab71ff3c591700a878a76d9" dependencies = [ "amplify", "baid64", @@ -4175,24 +4728,24 @@ dependencies = [ "daggy", "getrandom 0.2.17", "getrandom 0.3.4", - "hex-conservative 0.2.2", - "mime", + "hex-conservative 1.2.0", "rand 0.9.5", "rgb-aluvm", "rgb-strict-encoding", "rgb-strict-types", "ripemd", - "secp256k1", + "secp256k1 0.31.1", + "secp256k1 0.32.0-beta.2", "serde", - "sha2 0.10.9", + "sha2 0.11.0", "wasm-bindgen", ] [[package]] name = "rgb-invoicing" -version = "0.11.1-rc.10" +version = "0.11.1-rc.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10400124af86de579b0f13603e014512b6166ad94ea791212b71579ffb91dd8a" +checksum = "b44153a2411b2d1ddc859a011061f95ec07acf32e70c414193838244207e232e" dependencies = [ "amplify", "baid64", @@ -4200,7 +4753,6 @@ dependencies = [ "fluent-uri", "indexmap", "percent-encoding", - "rand 0.9.5", "rgb-consensus", "rgb-strict-encoding", "rgb-strict-types", @@ -4209,19 +4761,18 @@ dependencies = [ [[package]] name = "rgb-lib" -version = "0.3.0-beta.6" -source = "git+https://github.com/UTEXO-Protocol/rgb-lib.git?tag=v0.3.0-beta.32#9ce4e5f6a14450ae7d924b0c4b40185519fa5fc3" +version = "0.3.0-beta.7" +source = "git+https://github.com/dcorral/rgb-lib.git?rev=94b6221ea9bf04562d89c21c9f074f4ecfbddd5d#94b6221ea9bf04562d89c21c9f074f4ecfbddd5d" dependencies = [ "amplify", "base64 0.22.1", "bdk_electrum", "bdk_esplora", "bdk_wallet", - "chacha20poly1305", + "chacha20poly1305 0.11.0", "file-format", - "generic-array", "hex", - "hkdf", + "hkdf 0.12.4", "rand 0.10.2", "reqwest 0.13.4", "rgb-invoicing", @@ -4232,9 +4783,9 @@ dependencies = [ "rgb-strict-encoding", "rgb-strict-types", "rustls 0.23.42", - "scrypt", - "sea-orm", - "sea-query", + "scrypt 0.12.0", + "sea-orm 2.0.2", + "sea-query 1.0.2", "serde", "serde_json", "sha2 0.10.9", @@ -4245,7 +4796,6 @@ dependencies = [ "thiserror 2.0.19", "time", "tokio", - "typenum", "url", "vss-client-ng", "walkdir", @@ -4254,10 +4804,10 @@ dependencies = [ [[package]] name = "rgb-lib-migration" -version = "0.3.0-beta.4" -source = "git+https://github.com/UTEXO-Protocol/rgb-lib.git?tag=v0.3.0-beta.32#9ce4e5f6a14450ae7d924b0c4b40185519fa5fc3" +version = "0.3.0-beta.5" +source = "git+https://github.com/dcorral/rgb-lib.git?rev=94b6221ea9bf04562d89c21c9f074f4ecfbddd5d#94b6221ea9bf04562d89c21c9f074f4ecfbddd5d" dependencies = [ - "sea-orm-migration", + "sea-orm-migration 2.0.2", "tokio", ] @@ -4276,11 +4826,11 @@ dependencies = [ "bitcoin", "bitcoin-bech32", "chacha20 0.9.1", - "chacha20poly1305", + "chacha20poly1305 0.10.1", "chrono", "clap", "dirs", - "electrum-client 0.20.0", + "electrum-client 0.24.1", "esplora-client", "futures", "hex-conservative 0.3.2", @@ -4295,7 +4845,6 @@ dependencies = [ "lightning-persister", "lightning-rapid-gossip-sync", "lightning-transaction-sync", - "magic-crypt", "prost 0.13.5", "rand 0.8.7", "regex", @@ -4304,8 +4853,8 @@ dependencies = [ "rgb-psbt-utils", "rln-migration", "rustls 0.23.42", - "scrypt", - "sea-orm", + "scrypt 0.11.0", + "sea-orm 1.1.20", "serde", "serde_json", "signer-external", @@ -4319,7 +4868,6 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", - "typenum", "uniffi", "uuid", "vls-core", @@ -4334,18 +4882,16 @@ dependencies = [ [[package]] name = "rgb-ops" -version = "0.11.1-rc.10" +version = "0.11.1-rc.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3782a5ff1e5408e23c2d14fc556347684f5c8f72d96c4ca357527a3dbffb7378" +checksum = "e454f98d8ab2ef78a2e0ba924e2a5c67b5ed55ba1664f4a6ee269d2f271353bc" dependencies = [ "amplify", "baid64", - "base85", "chrono", - "electrum-client 0.24.1", + "electrum-client 0.25.0", "esplora-client", "getrandom 0.3.4", - "indexmap", "nonasync", "rand 0.9.5", "rgb-aluvm", @@ -4361,12 +4907,11 @@ dependencies = [ [[package]] name = "rgb-psbt-utils" -version = "0.11.1-rc.10" +version = "0.11.1-rc.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0107634634c424a8b268ec6549df08da676768ec6ddbcda51f12e7882daeeee6" +checksum = "9df8f48e7c851814306af91411d7d6f7e8571939d509d4a786597a5eba0efbbf" dependencies = [ "amplify", - "baid64", "getrandom 0.3.4", "rgb-ops", "rgb-strict-encoding", @@ -4376,9 +4921,9 @@ dependencies = [ [[package]] name = "rgb-schemas" -version = "0.11.1-rc.10" +version = "0.11.1-rc.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1903961c836cc3f6963b06c69cb47491a815309f265439f20c932e0a3aa16a78" +checksum = "4fa6cd396adb8e48b558c12750d8c8837aefe230ad905f1be5424b1d57b4f55b" dependencies = [ "amplify", "rgb-aluvm", @@ -4388,9 +4933,9 @@ dependencies = [ [[package]] name = "rgb-strict-encoding" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cca0a8d70dd2d5b2a218ef7fd03e88b3ea69a926f3a9139c08c82d5f49fca236" +checksum = "0f68326e14d4b627f86634ea324510135cb551e27c5583a74811fa230b0cf34d" dependencies = [ "amplify", "bitcoin", @@ -4414,9 +4959,9 @@ dependencies = [ [[package]] name = "rgb-strict-types" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dfaa85981472cf64009d0bc605891917b320b4f735644c2940c2574f525354" +checksum = "e920bdf61b662a3743572a03696b0d88992eeb7d4f7afa0902915968327b3ce6" dependencies = [ "amplify", "baid64", @@ -4426,7 +4971,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "sha2 0.10.9", + "sha2 0.11.0", "toml 0.8.23", "wasm-bindgen", ] @@ -4447,11 +4992,40 @@ dependencies = [ [[package]] name = "ripemd" -version = "0.1.3" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" +checksum = "4dd4211456b4172d7e44261920c25acf07367c4f04bb5f5d54fc21b090d9b159" dependencies = [ - "digest 0.10.7", + "digest 0.11.3", +] + +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", ] [[package]] @@ -4472,7 +5046,7 @@ dependencies = [ name = "rln-migration" version = "0.1.0" dependencies = [ - "sea-orm-migration", + "sea-orm-migration 1.1.20", "tokio", ] @@ -4482,7 +5056,7 @@ version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid", + "const-oid 0.9.6", "digest 0.10.7", "num-bigint-dig", "num-integer", @@ -4503,8 +5077,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" dependencies = [ "arrayvec", + "borsh", + "bytes", "num-traits", + "rand 0.8.7", + "rkyv", "serde", + "serde_json", "wasm-bindgen", ] @@ -4672,7 +5251,17 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" dependencies = [ - "cipher", + "cipher 0.4.4", +] + +[[package]] +name = "salsa20" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c" +dependencies = [ + "cfg-if", + "cipher 0.5.2", ] [[package]] @@ -4725,12 +5314,25 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" dependencies = [ - "password-hash", - "pbkdf2", - "salsa20", + "password-hash 0.5.0", + "pbkdf2 0.12.2", + "salsa20 0.10.2", "sha2 0.10.9", ] +[[package]] +name = "scrypt" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0" +dependencies = [ + "cfg-if", + "password-hash 0.6.1", + "pbkdf2 0.13.0", + "salsa20 0.11.0", + "sha2 0.11.0", +] + [[package]] name = "sct" version = "0.7.1" @@ -4761,42 +5363,103 @@ checksum = "2dc312fedd460a47ea563911761d254a84e7b51d8cc73ec92c929e78f33fa957" dependencies = [ "async-stream", "async-trait", - "bigdecimal", "chrono", "derive_more", "futures-util", "log", - "mac_address", "ouroboros", - "pgvector", - "rust_decimal", - "sea-orm-macros", - "sea-query", + "sea-orm-macros 1.1.20", + "sea-query 0.32.7", "sea-query-binder", "serde", - "serde_json", - "sqlx", - "strum", + "sqlx 0.8.6", + "strum 0.26.3", "thiserror 2.0.19", - "time", "tracing", "url", - "uuid", ] [[package]] -name = "sea-orm-cli" -version = "1.1.20" +name = "sea-orm" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da80ebcdb44571e86f03a2bdcb5532136a87397f366f38bbce64673fc5e6a450" +checksum = "a334e83ced3ae3ee44db0f84d1fcf8d2087a1ad9bb9036f00f9f6067156ea197" dependencies = [ + "async-stream", + "async-trait", + "bigdecimal", "chrono", - "clap", - "dotenvy", + "derive-where", + "derive_more", + "futures-util", + "itertools 0.14.0", + "log", + "mac_address", + "ouroboros", + "pgvector", + "rust_decimal", + "sea-orm-arrow", + "sea-orm-macros 2.0.2", + "sea-query 1.0.2", + "sea-query-sqlx", + "sea-schema 0.18.1", + "serde", + "serde_json", + "sqlx 0.9.0", + "sqlx-core 0.9.0", + "strum 0.28.0", + "thiserror 2.0.19", + "time", + "tracing", + "url", + "uuid", + "web-time", +] + +[[package]] +name = "sea-orm-arrow" +version = "2.0.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c800d9db902534d7d01728faf98e33d13c1d57bb8c57d8e4c518309172bddda" +dependencies = [ + "arrow", + "sea-query 1.0.2", + "thiserror 2.0.19", +] + +[[package]] +name = "sea-orm-cli" +version = "1.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da80ebcdb44571e86f03a2bdcb5532136a87397f366f38bbce64673fc5e6a450" +dependencies = [ + "chrono", + "clap", + "dotenvy", + "glob", + "regex", + "sea-schema 0.16.2", + "sqlx 0.8.6", + "tokio", + "tracing", + "tracing-subscriber", + "url", +] + +[[package]] +name = "sea-orm-cli" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a53505884d7c907bcf4f7b4ddb1b29425e62fef8b98aea9c99e17781cceb798" +dependencies = [ + "chrono", + "clap", + "dotenvy", "glob", + "indoc", "regex", - "sea-schema", - "sqlx", + "sea-schema 0.18.1", + "sqlx 0.9.0", "tokio", "tracing", "tracing-subscriber", @@ -4817,6 +5480,22 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sea-orm-macros" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4039a86f9acc4d3b52747508b347dddc6fd725bbc429902ebeb6d26225fc2528" +dependencies = [ + "heck 0.5.0", + "itertools 0.14.0", + "pluralizer", + "proc-macro2", + "quote", + "sea-bae", + "syn 2.0.119", + "unicode-ident", +] + [[package]] name = "sea-orm-migration" version = "1.1.20" @@ -4826,9 +5505,25 @@ dependencies = [ "async-trait", "clap", "dotenvy", - "sea-orm", - "sea-orm-cli", - "sea-schema", + "sea-orm 1.1.20", + "sea-orm-cli 1.1.20", + "sea-schema 0.16.2", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "sea-orm-migration" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd09adbef87100d07131a60a8c5508b53d0cf2136521f654aae47af5e6a097fe" +dependencies = [ + "async-trait", + "clap", + "dotenvy", + "sea-orm 2.0.2", + "sea-orm-cli 2.0.2", + "sea-schema 0.18.1", "tracing", "tracing-subscriber", ] @@ -4842,8 +5537,23 @@ dependencies = [ "chrono", "inherent", "ordered-float", - "sea-query-derive", + "sea-query-derive 0.4.3", +] + +[[package]] +name = "sea-query" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "546040c653a705e60ec65ecd3191a809603734bebbc225775916dea9ae409b31" +dependencies = [ + "chrono", + "itoa", + "ordered-float", + "rust_decimal", + "sea-query-derive 1.0.0", "serde_json", + "time", + "uuid", ] [[package]] @@ -4853,9 +5563,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b0019f47430f7995af63deda77e238c17323359af241233ec768aba1faea7608" dependencies = [ "chrono", - "sea-query", - "serde_json", - "sqlx", + "sea-query 0.32.7", + "sqlx 0.8.6", ] [[package]] @@ -4872,6 +5581,30 @@ dependencies = [ "thiserror 2.0.19", ] +[[package]] +name = "sea-query-derive" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b0f466921cdd3cf4b89d5c3ac2173dba89a873ab395b123a645de181ec7537" +dependencies = [ + "darling 0.20.11", + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 2.0.119", + "thiserror 2.0.19", +] + +[[package]] +name = "sea-query-sqlx" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eaa419cdb9157da1361186b1959983eb2ea0dcb9a3c69dc45c449ecb2af8fef" +dependencies = [ + "sea-query 1.0.2", + "sqlx 0.9.0", +] + [[package]] name = "sea-schema" version = "0.16.2" @@ -4879,10 +5612,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2239ff574c04858ca77485f112afea1a15e53135d3097d0c86509cef1def1338" dependencies = [ "futures", - "sea-query", + "sea-query 0.32.7", "sea-query-binder", "sea-schema-derive", - "sqlx", + "sqlx 0.8.6", +] + +[[package]] +name = "sea-schema" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3553c77dceed56e95bece9ea876c4dd67ca879ef51055a0b97a7bb89a8ae4fed" +dependencies = [ + "async-trait", + "sea-query 1.0.2", + "sea-query-sqlx", + "sea-schema-derive", + "sqlx 0.9.0", ] [[package]] @@ -4897,6 +5643,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + [[package]] name = "sec1" version = "0.7.3" @@ -4920,7 +5672,30 @@ checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ "bitcoin_hashes", "rand 0.8.7", - "secp256k1-sys", + "secp256k1-sys 0.10.1", + "serde", +] + +[[package]] +name = "secp256k1" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2" +dependencies = [ + "bitcoin_hashes", + "rand 0.9.5", + "secp256k1-sys 0.11.0", + "serde", +] + +[[package]] +name = "secp256k1" +version = "0.32.0-beta.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5fdc7d6e800869d3fd60ff857c479bf0a83ea7bf44b389e64461e844204994" +dependencies = [ + "rand 0.9.5", + "secp256k1-sys 0.12.0", "serde", ] @@ -4933,6 +5708,24 @@ dependencies = [ "cc", ] +[[package]] +name = "secp256k1-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcb913707158fadaf0d8702c2db0e857de66eb003ccfdda5924b5f5ac98efb38" +dependencies = [ + "cc", +] + +[[package]] +name = "secp256k1-sys" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d3be00697c88c00fe102af8dc316038cc2062eab8da646e7463f4c0e70ca9fd" +dependencies = [ + "cc", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -5135,6 +5928,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + [[package]] name = "sha2" version = "0.9.9" @@ -5159,6 +5963,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -5202,9 +6017,9 @@ dependencies = [ "base64 0.22.1", "bitcoin", "chacha20 0.9.1", - "chacha20poly1305", + "chacha20poly1305 0.10.1", "hex", - "poly1305", + "poly1305 0.8.0", "serde", "serde_json", "thiserror 2.0.19", @@ -5340,11 +6155,24 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" dependencies = [ - "sqlx-core", - "sqlx-macros", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", + "sqlx-core 0.8.6", + "sqlx-macros 0.8.6", + "sqlx-mysql 0.8.6", + "sqlx-postgres 0.8.6", + "sqlx-sqlite 0.8.6", +] + +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core 0.9.0", + "sqlx-macros 0.9.0", + "sqlx-mysql 0.9.0", + "sqlx-postgres 0.9.0", + "sqlx-sqlite 0.9.0", ] [[package]] @@ -5365,7 +6193,7 @@ dependencies = [ "futures-io", "futures-util", "hashbrown 0.15.5", - "hashlink", + "hashlink 0.10.0", "indexmap", "log", "memchr", @@ -5384,6 +6212,46 @@ dependencies = [ "webpki-roots 0.26.11", ] +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64 0.22.1", + "bytes", + "cfg-if", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink 0.11.1", + "indexmap", + "log", + "memchr", + "percent-encoding", + "rust_decimal", + "rustls 0.23.42", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror 2.0.19", + "time", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 1.0.9", +] + [[package]] name = "sqlx-macros" version = "0.8.6" @@ -5392,8 +6260,21 @@ checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" dependencies = [ "proc-macro2", "quote", - "sqlx-core", - "sqlx-macros-core", + "sqlx-core 0.8.6", + "sqlx-macros-core 0.8.6", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core 0.9.0", + "sqlx-macros-core 0.9.0", "syn 2.0.119", ] @@ -5413,10 +6294,35 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "sqlx-core", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", + "sqlx-core 0.8.6", + "sqlx-mysql 0.8.6", + "sqlx-postgres 0.8.6", + "sqlx-sqlite 0.8.6", + "syn 2.0.119", + "tokio", + "url", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core 0.9.0", + "sqlx-mysql 0.9.0", + "sqlx-postgres 0.9.0", + "sqlx-sqlite 0.9.0", "syn 2.0.119", "tokio", "url", @@ -5444,25 +6350,55 @@ dependencies = [ "futures-util", "generic-array", "hex", - "hkdf", - "hmac", + "hkdf 0.12.4", + "hmac 0.12.1", "itoa", "log", - "md-5", + "md-5 0.10.6", "memchr", "once_cell", "percent-encoding", "rand 0.8.7", "rsa", "serde", - "sha1", + "sha1 0.10.7", "sha2 0.10.9", "smallvec", - "sqlx-core", + "sqlx-core 0.8.6", "stringprep", "thiserror 2.0.19", "tracing", - "whoami", + "whoami 1.6.1", +] + +[[package]] +name = "sqlx-mysql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" +dependencies = [ + "bitflags 2.13.1", + "byteorder", + "bytes", + "chrono", + "crc", + "digest 0.11.3", + "dotenvy", + "either", + "futures-core", + "futures-util", + "generic-array", + "log", + "percent-encoding", + "rust_decimal", + "serde", + "sha1 0.11.0", + "sha2 0.11.0", + "sqlx-core 0.9.0", + "thiserror 2.0.19", + "time", + "tracing", + "uuid", ] [[package]] @@ -5478,17 +6414,17 @@ dependencies = [ "chrono", "crc", "dotenvy", - "etcetera", + "etcetera 0.8.0", "futures-channel", "futures-core", "futures-util", "hex", - "hkdf", - "hmac", + "hkdf 0.12.4", + "hmac 0.12.1", "home", "itoa", "log", - "md-5", + "md-5 0.10.6", "memchr", "once_cell", "rand 0.8.7", @@ -5496,11 +6432,50 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smallvec", - "sqlx-core", + "sqlx-core 0.8.6", + "stringprep", + "thiserror 2.0.19", + "tracing", + "whoami 1.6.1", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.13.1", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera 0.11.0", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf 0.13.0", + "hmac 0.13.0", + "itoa", + "log", + "md-5 0.11.0", + "memchr", + "rand 0.10.2", + "rust_decimal", + "serde", + "serde_json", + "sha2 0.11.0", + "smallvec", + "sqlx-core 0.9.0", "stringprep", "thiserror 2.0.19", + "time", "tracing", - "whoami", + "uuid", + "whoami 2.1.3", ] [[package]] @@ -5511,7 +6486,7 @@ checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ "atoi", "chrono", - "flume", + "flume 0.11.1", "futures-channel", "futures-core", "futures-executor", @@ -5522,10 +6497,37 @@ dependencies = [ "percent-encoding", "serde", "serde_urlencoded", - "sqlx-core", + "sqlx-core 0.8.6", + "thiserror 2.0.19", + "tracing", + "url", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +dependencies = [ + "atoi", + "chrono", + "flume 0.12.0", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core 0.9.0", "thiserror 2.0.19", + "time", "tracing", "url", + "uuid", ] [[package]] @@ -5573,6 +6575,12 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" + [[package]] name = "subtle" version = "2.6.1" @@ -5665,6 +6673,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60" +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tempfile" version = "3.27.0" @@ -5745,15 +6759,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "tiger" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "579abbce4ad73b04386dbeb34369c9873a8f9b749c7b99cbf479a2949ff715ed" -dependencies = [ - "digest 0.10.7", -] - [[package]] name = "time" version = "0.3.54" @@ -5784,6 +6789,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -5899,7 +6913,7 @@ dependencies = [ "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", - "toml_edit", + "toml_edit 0.22.27", ] [[package]] @@ -5935,6 +6949,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -5949,6 +6972,18 @@ dependencies = [ "winnow 0.7.15", ] +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" @@ -6297,10 +7332,20 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -6382,7 +7427,7 @@ name = "vls-core" version = "0.14.0" source = "git+https://github.com/UTEXO-Protocol/vls-core.git?branch=feat%2Frgb-compatibility#45c72edfd58620849eb486925439a76526b415ae" dependencies = [ - "ahash", + "ahash 0.8.12", "anyhow", "backtrace", "bitcoin", @@ -6677,6 +7722,12 @@ dependencies = [ "wasite", ] +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" + [[package]] name = "winapi" version = "0.3.9" @@ -6949,6 +8000,9 @@ name = "winnow" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] [[package]] name = "wit-bindgen" @@ -6962,6 +8016,15 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "yansi" version = "1.0.1" diff --git a/bindings/c-ffi/Cargo.toml b/bindings/c-ffi/Cargo.toml index de835da5..597659c5 100644 --- a/bindings/c-ffi/Cargo.toml +++ b/bindings/c-ffi/Cargo.toml @@ -2,7 +2,7 @@ name = "rln-c-ffi" version = "0.1.0" edition = "2021" -rust-version = "1.88.0" +rust-version = "1.94.0" build = "build.rs" publish = false diff --git a/bindings/c-ffi/src/api.rs b/bindings/c-ffi/src/api.rs index 548b5740..98d23a61 100644 --- a/bindings/c-ffi/src/api.rs +++ b/bindings/c-ffi/src/api.rs @@ -344,8 +344,8 @@ pub(crate) fn refresh_transfers( ) -> Result { let node = require_handle(node)?; let req: JsonRefreshTransfersRequest = parse_req(request_json)?; - node.refreshtransfers(req.into())?; - ok_void() + let resp = node.refreshtransfers(req.into())?; + json(JsonRefreshTransfersResponse::from(resp)) } pub(crate) fn fail_transfers( @@ -752,10 +752,7 @@ pub(crate) fn sdk_node_unlock_with_native_external_signer( let r: JsonSdkExternalUnlockRequest = parse_req(request_json)?; node.unlock_with_native_external_signer( Arc::clone(signer), - r.bitcoind_rpc_username, - r.bitcoind_rpc_password, - r.bitcoind_rpc_host, - r.bitcoind_rpc_port, + r.ldk_chain_sync.into(), r.indexer_url, r.proxy_endpoint, r.announce_addresses, @@ -787,10 +784,7 @@ pub(crate) fn sdk_node_unlock_with_attached_external_signer( let node = require_handle(node)?; let r: JsonSdkExternalUnlockRequest = parse_req(request_json)?; node.unlock_with_attached_external_signer( - r.bitcoind_rpc_username, - r.bitcoind_rpc_password, - r.bitcoind_rpc_host, - r.bitcoind_rpc_port, + r.ldk_chain_sync.into(), r.indexer_url, r.proxy_endpoint, r.announce_addresses, diff --git a/bindings/c-ffi/src/json_types.rs b/bindings/c-ffi/src/json_types.rs index aba9a35f..6da35572 100644 --- a/bindings/c-ffi/src/json_types.rs +++ b/bindings/c-ffi/src/json_types.rs @@ -26,15 +26,15 @@ use rgb_lightning_node::{ SdkCreateUtxosRequest, SdkDisconnectPeerRequest, SdkExternalSignerBootstrap, SdkFailTransfersRequest, SdkFailTransfersResponse, SdkInitRequest, SdkIssueAssetCfaRequest, SdkIssueAssetIfaRequest, SdkIssueAssetNiaRequest, SdkIssueAssetUdaRequest, SdkKeysendRequest, - SdkKeysendResponse, SdkMakerExecuteRequest, SdkMakerInitRequest, SdkMakerInitResponse, - SdkOpenChannelRequest, SdkOpenChannelResponse, SdkPostAssetMediaRequest, - SdkPostAssetMediaResponse, SdkRefreshTransfersRequest, SdkRgbInvoiceRequest, - SdkRgbInvoiceResponse, SdkSendBtcRequest, SdkSendBtcResponse, SdkSendOnionMessageRequest, - SdkSendPaymentRequest, SdkSendPaymentResponse, SdkTakerRequest, SdkUnlockRequest, - SdkVssClearFenceRequest, SendRgbRequest, SendRgbResponse, SignMessageResponse, Swap, SwapList, - SwapStatus, Token, TokenLight, Transaction, TransactionType, Transfer, - TransferTransportEndpoint, TransportEndpoint, Txid, Unspent, Utxo, VerifyMessageResponse, - WitnessData, + SdkKeysendResponse, SdkLdkChainSync, SdkMakerExecuteRequest, SdkMakerInitRequest, + SdkMakerInitResponse, SdkOpenChannelRequest, SdkOpenChannelResponse, SdkPostAssetMediaRequest, + SdkPostAssetMediaResponse, SdkRefreshTransfersRequest, SdkRefreshTransfersResponse, + SdkRgbInvoiceRequest, SdkRgbInvoiceResponse, SdkSendBtcRequest, SdkSendBtcResponse, + SdkSendOnionMessageRequest, SdkSendPaymentRequest, SdkSendPaymentResponse, SdkTakerRequest, + SdkUnlockRequest, SdkVssClearFenceRequest, SendRgbRequest, SendRgbResponse, + SignMessageResponse, Swap, SwapList, SwapStatus, Token, TokenLight, Transaction, + TransactionType, Transfer, TransferTransportEndpoint, TransportEndpoint, Txid, Unspent, Utxo, + VerifyMessageResponse, WitnessData, }; use serde::{Deserialize, Serialize}; @@ -167,17 +167,47 @@ impl TryFrom for SdkInitRequest { } } +// How LDK follows the chain. Mirrors `SdkLdkChainSync`, tagged the same way as the daemon's +// `/unlock` payload. +#[derive(Debug, Deserialize)] +#[serde(tag = "mode", content = "config")] +pub(crate) enum JsonSdkLdkChainSync { + BlockSync { + bitcoind_rpc_username: String, + bitcoind_rpc_password: String, + bitcoind_rpc_host: String, + bitcoind_rpc_port: u16, + }, + TransactionSync { + indexer_url: String, + }, +} + +impl From for SdkLdkChainSync { + fn from(j: JsonSdkLdkChainSync) -> Self { + match j { + JsonSdkLdkChainSync::BlockSync { + bitcoind_rpc_username, + bitcoind_rpc_password, + bitcoind_rpc_host, + bitcoind_rpc_port, + } => SdkLdkChainSync::BlockSync { + bitcoind_rpc_username, + bitcoind_rpc_password, + bitcoind_rpc_host, + bitcoind_rpc_port, + }, + JsonSdkLdkChainSync::TransactionSync { indexer_url } => { + SdkLdkChainSync::TransactionSync { indexer_url } + } + } + } +} + #[derive(Debug, Deserialize)] pub(crate) struct JsonSdkUnlockRequest { pub password: String, - #[serde(default)] - pub bitcoind_rpc_username: Option, - #[serde(default)] - pub bitcoind_rpc_password: Option, - #[serde(default)] - pub bitcoind_rpc_host: Option, - #[serde(default)] - pub bitcoind_rpc_port: Option, + pub ldk_chain_sync: JsonSdkLdkChainSync, #[serde(default)] pub indexer_url: Option, #[serde(default)] @@ -197,10 +227,7 @@ impl From for SdkUnlockRequest { fn from(j: JsonSdkUnlockRequest) -> Self { SdkUnlockRequest { password: j.password, - bitcoind_rpc_username: j.bitcoind_rpc_username, - bitcoind_rpc_password: j.bitcoind_rpc_password, - bitcoind_rpc_host: j.bitcoind_rpc_host, - bitcoind_rpc_port: j.bitcoind_rpc_port, + ldk_chain_sync: j.ldk_chain_sync.into(), indexer_url: j.indexer_url, proxy_endpoint: j.proxy_endpoint, announce_addresses: j.announce_addresses, @@ -231,14 +258,7 @@ impl From for SdkVssClearFenceRequest { // External-signer mode has no password: the seed never reaches RLN. #[derive(Debug, Deserialize)] pub(crate) struct JsonSdkExternalUnlockRequest { - #[serde(default)] - pub bitcoind_rpc_username: Option, - #[serde(default)] - pub bitcoind_rpc_password: Option, - #[serde(default)] - pub bitcoind_rpc_host: Option, - #[serde(default)] - pub bitcoind_rpc_port: Option, + pub ldk_chain_sync: JsonSdkLdkChainSync, #[serde(default)] pub indexer_url: Option, #[serde(default)] @@ -769,6 +789,8 @@ pub(crate) struct JsonDecodeLnInvoiceResponse { pub timestamp: u64, pub asset_id: Option, pub asset_amount: Option, + pub description: Option, + pub description_hash: Option, pub payment_hash: String, pub payment_secret: String, pub payee_pubkey: Option, @@ -783,6 +805,8 @@ impl From for JsonDecodeLnInvoiceResponse { timestamp: r.timestamp, asset_id: r.asset_id.as_ref().map(fmt_contract_id), asset_amount: r.asset_amount, + description: r.description, + description_hash: r.description_hash, payment_hash: fmt_payment_hash(&r.payment_hash), payment_secret: r.payment_secret, payee_pubkey: r.payee_pubkey.as_ref().map(fmt_pubkey), @@ -998,6 +1022,46 @@ impl From for SdkRefreshTransfersRequest { } } +#[derive(Debug, Serialize)] +pub(crate) struct JsonRefreshFailure { + pub name: String, + pub message: String, +} + +#[derive(Debug, Serialize)] +pub(crate) struct JsonRefreshedTransfer { + pub updated_status: Option, + pub failure: Option, +} + +#[derive(Debug, Serialize)] +pub(crate) struct JsonRefreshTransfersResponse { + pub transfers: std::collections::HashMap, +} + +impl From for JsonRefreshTransfersResponse { + fn from(r: SdkRefreshTransfersResponse) -> Self { + JsonRefreshTransfersResponse { + transfers: r + .transfers + .into_iter() + .map(|(idx, t)| { + ( + idx, + JsonRefreshedTransfer { + updated_status: t.updated_status, + failure: t.failure.map(|f| JsonRefreshFailure { + name: f.name, + message: f.message, + }), + }, + ) + }) + .collect(), + } + } +} + #[derive(Debug, Deserialize)] pub(crate) struct JsonFailTransfersRequest { #[serde(default)] @@ -1942,6 +2006,7 @@ pub(crate) struct JsonUtxo { pub outpoint: String, pub btc_amount: u64, pub colorable: bool, + pub exists: bool, } impl From for JsonUtxo { @@ -1950,6 +2015,7 @@ impl From for JsonUtxo { outpoint: u.outpoint, btc_amount: u.btc_amount, colorable: u.colorable, + exists: u.exists, } } } diff --git a/bindings/rgb_lightning_node.udl b/bindings/rgb_lightning_node.udl index 90e7eea1..2f289e1a 100644 --- a/bindings/rgb_lightning_node.udl +++ b/bindings/rgb_lightning_node.udl @@ -85,7 +85,7 @@ interface SdkNode { [Throws=RlnError] SdkSendPaymentResponse sendpayment(SdkSendPaymentRequest request); [Throws=RlnError] - void refreshtransfers(SdkRefreshTransfersRequest request); + SdkRefreshTransfersResponse refreshtransfers(SdkRefreshTransfersRequest request); [Throws=RlnError] SdkFailTransfersResponse failtransfers(SdkFailTransfersRequest request); [Throws=RlnError] @@ -473,6 +473,8 @@ dictionary DecodeLnInvoiceResponse { u64 timestamp; ContractId? asset_id; u64? asset_amount; + string? description; + string? description_hash; PaymentHash payment_hash; string payment_secret; PublicKey? payee_pubkey; @@ -535,6 +537,7 @@ dictionary Utxo { string outpoint; u64 btc_amount; boolean colorable; + boolean exists; }; dictionary Unspent { @@ -598,12 +601,15 @@ dictionary SdkInitRequest { boolean reuse_addresses = false; }; +[Enum] +interface SdkLdkChainSync { + BlockSync(string bitcoind_rpc_username, string bitcoind_rpc_password, string bitcoind_rpc_host, u16 bitcoind_rpc_port); + TransactionSync(string indexer_url); +}; + dictionary SdkUnlockRequest { string password; - string? bitcoind_rpc_username; - string? bitcoind_rpc_password; - string? bitcoind_rpc_host; - u16? bitcoind_rpc_port; + SdkLdkChainSync ldk_chain_sync; string? indexer_url; string? proxy_endpoint; sequence announce_addresses; @@ -667,6 +673,20 @@ dictionary SdkFailTransfersResponse { boolean transfers_changed; }; +dictionary SdkRefreshFailure { + string name; + string message; +}; + +dictionary SdkRefreshedTransfer { + string? updated_status; + SdkRefreshFailure? failure; +}; + +dictionary SdkRefreshTransfersResponse { + record transfers; +}; + dictionary SdkCreateUtxosRequest { boolean up_to; u8? num; diff --git a/bindings/wasm-sdk/e2e-specs/README.md b/bindings/wasm-sdk/e2e-specs/README.md index 530b43a4..944734fb 100644 --- a/bindings/wasm-sdk/e2e-specs/README.md +++ b/bindings/wasm-sdk/e2e-specs/README.md @@ -126,10 +126,15 @@ From the **repository root**: -H 'content-type: application/json' \ -d '{ "password":"rln-password", - "bitcoind_rpc_username":"user", - "bitcoind_rpc_password":"password", - "bitcoind_rpc_host":"127.0.0.1", - "bitcoind_rpc_port":19443, + "ldk_chain_sync":{ + "mode":"BlockSync", + "config":{ + "bitcoind_rpc_username":"user", + "bitcoind_rpc_password":"password", + "bitcoind_rpc_host":"127.0.0.1", + "bitcoind_rpc_port":19443 + } + }, "indexer_url":"http://127.0.0.1:3002", "proxy_endpoint":"rpc://127.0.0.1:3005/json-rpc", "announce_addresses":[] diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..bfdc9877 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,8 @@ +coverage: + status: + project: + default: + informational: true + patch: + default: + informational: true diff --git a/coverage.sh b/coverage.sh new file mode 100755 index 00000000..76b401f0 --- /dev/null +++ b/coverage.sh @@ -0,0 +1,109 @@ +#!/bin/bash -e +# +# script to run project tests and report code coverage +# uses llvm-cov (https://github.com/taiki-e/cargo-llvm-cov) + +# only include lightning/src/rgb_utils from the rust-lightning submodule, as it +# contains all the RGB logic, while other changes with respect to upstream are +# short and only wire the logic in the appropriate places +# +# since llvm-cov's regex engine has no negative lookahead, this is expressed by +# listing rgb_utils' siblings +# +# note: these are in addition to common ignore patterns llvm-cov appends on its own +IGNORE_PATTERNS=( + # the node's own test module + 'src/test($|/)' + # all rust-lightning crates other than lightning + 'rust\-lightning/lightning\-' + # other rust-lightning crates, possiblyrandom being a dependency of the lightning one + 'rust\-lightning/(no\-std\-check|possiblyrandom)/' + # rust-lightning directories holding no crate code + 'rust\-lightning/(bench|ci|contrib|fuzz|ext\-functional\-test\-demo|msrv\-no\-dev\-deps\-check)/' + # upstream modules of the lightning crate, i.e. all of them but rgb_utils + 'rust\-lightning/lightning/src/(blinded_path|chain|crypto|events|io|ln|offers|onion_message)($|/)' + 'rust\-lightning/lightning/src/(routing|sign|sync|util)($|/)' + # root of the lightning crate + 'rust\-lightning/lightning/src/lib\.rs$' +) +IGNORE_PATTERN="$(IFS='|'; echo "${IGNORE_PATTERNS[*]}")" + +LLVM_COV_OPTS=() +CI=0 +CARGO_TEST_OPTS=("--" "--test-threads=1") +COV="cargo llvm-cov --ignore-filename-regex $IGNORE_PATTERN" + +_die() { + echo "err $*" + exit 1 +} + +_tit() { + echo + echo "========================================" + echo "$@" + echo "========================================" +} + +help() { + echo "$NAME [-h|--help] [-t|--test] [--skip] [--ci] [--ignore-run-fail] [--no-clean]" + echo "" + echo "options:" + echo " -h --help show this help message" + echo " -t --test only run these test(s)" + echo " --skip skip test(s) matching this filter" + echo " --ci run for the CI" + echo " --ignore-run-fail keep running regardless of failure" + echo " --no-clean don't cleanup before the run" +} + +# cmdline arguments +while [ -n "$1" ]; do + case $1 in + -h | --help) + help + exit 0 + ;; + -t | --test) + CARGO_TEST_OPTS+=("$2") + shift + ;; + --skip) + CARGO_TEST_OPTS+=("--skip" "$2") + shift + ;; + --ci) + CI=1 + ;; + --ignore-run-fail) + LLVM_COV_OPTS+=("$1") + ;; + --no-clean) + LLVM_COV_OPTS+=("$1") + ;; + *) + help + _die "unsupported argument \"$1\"" + ;; + esac + shift +done + +if [ "$CI" = 1 ]; then + # CI version + + $COV "${LLVM_COV_OPTS[@]}" --lcov --output-path coverage.lcov "${CARGO_TEST_OPTS[@]}" + exit 0 +else + # local version + + _tit "installing requirements" + rustup component add llvm-tools-preview + cargo install cargo-llvm-cov + + _tit "generating coverage report" + $COV "${LLVM_COV_OPTS[@]}" --html "${CARGO_TEST_OPTS[@]}" + + # show html report location + echo "generated html report: target/llvm-cov/html/index.html" +fi diff --git a/openapi.yaml b/openapi.yaml index 44b168b1..86031c4f 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -460,6 +460,27 @@ paths: application/json: schema: $ref: '#/components/schemas/GetChannelIdResponse' + /getconsignment: + post: + tags: + - RGB + summary: Get a send consignment + description: Get the hex string of the consignment bytes for a send transfer, + identified by its asset ID and txid. Useful to hand the consignment to + the counterparty out-of-band when the node filesystem is not accessible + from the API caller. + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/GetConsignmentRequest' + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/GetConsignmentResponse' /getpayment: post: tags: @@ -803,9 +824,10 @@ paths: - RGB summary: List transfers description: >- - List the node's RGB transfers for an asset, ordered most-recent first. - Supports index-based cursor pagination (idx) and optional filtering by - status and creation time interval. + List the node's RGB transfers, ordered most-recent first, scoped by an + asset filter and optionally by txid. Supports index-based cursor + pagination (idx) and optional filtering by status and creation time + interval. requestBody: content: application/json: @@ -973,6 +995,44 @@ paths: application/json: schema: $ref: '#/components/schemas/PostAssetMediaResponse' + /provideoutofbandack: + post: + tags: + - RGB + summary: Provide an out-of-band ACK + description: Record the out-of-band ACK received from a recipient of an outgoing transfer + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ProvideOutOfBandAckRequest' + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/ProvideOutOfBandAckResponse' + /provideoutofbandconsignment: + post: + tags: + - RGB + summary: Provide an out-of-band consignment + description: Provide a consignment received out-of-band, as a binary upload, + along with any media files it references (one `media` field per file) + The consignment is validated and the matching incoming transfer(s) are processed. + requestBody: + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/ProvideOutOfBandConsignmentRequest' + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/ProvideOutOfBandConsignmentResponse' /refreshtransfers: post: tags: @@ -990,7 +1050,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/EmptyResponse' + $ref: '#/components/schemas/RefreshResponse' /restore: post: tags: @@ -1560,6 +1620,48 @@ components: oneOf: - $ref: '#/components/schemas/Media' - type: 'null' + AssetFilter: + oneOf: + - $ref: '#/components/schemas/AssetFilterAnyOrNone' + - $ref: '#/components/schemas/AssetFilterNone' + - $ref: '#/components/schemas/AssetFilterId' + discriminator: + propertyName: type + mapping: + AnyOrNone: '#/components/schemas/AssetFilterAnyOrNone' + None: '#/components/schemas/AssetFilterNone' + Id: '#/components/schemas/AssetFilterId' + AssetFilterAnyOrNone: + type: object + required: + - type + properties: + type: + type: string + enum: [AnyOrNone] + AssetFilterId: + type: object + required: + - type + - value + properties: + type: + type: string + enum: [Id] + value: + type: string + example: rgb:CJkb4YZw-jRiz2sk-~PARPio-wtVYI1c-XAEYCqO-wTfvRZ8 + example: + type: Id + value: rgb:CJkb4YZw-jRiz2sk-~PARPio-wtVYI1c-XAEYCqO-wTfvRZ8 + AssetFilterNone: + type: object + required: + - type + properties: + type: + type: string + enum: [None] AssetIFA: type: object required: @@ -2247,6 +2349,7 @@ components: - assignment - network - transport_endpoints + - unknown_query_params properties: recipient_id: type: string @@ -2279,6 +2382,11 @@ components: items: type: string example: rpcs://proxy.iriswallet.com/0.2/json-rpc + unknown_query_params: + type: object + additionalProperties: + type: string + example: {} DecodeSwapstringRequest: type: object required: @@ -2414,6 +2522,26 @@ components: channel_id: type: string example: 8129afe1b1d7cf60d5e1bf4c04b09bec925ed4df5417ceee0484e24f816a105a + GetConsignmentRequest: + type: object + required: + - asset_id + - txid + properties: + asset_id: + type: string + example: rgb:2dkSTbr-jFhznbPmo-TQafzswCN-av4gTsJjX-ttx6CNou5-M98k8Zt + txid: + type: string + example: 33cf3e42fb1c6faa0d47e6a498a9d4e5c56e8a06ea0d295c6d90e2b6c85f7a1e + GetConsignmentResponse: + type: object + required: + - bytes_hex + properties: + bytes_hex: + type: string + example: 52474200 GetPaymentRequest: type: object required: @@ -2754,6 +2882,61 @@ components: example: 89d28bd306aa9bb906fd0ac31092d04c37c919a171b343083167e2a3cdc60578 status: $ref: '#/components/schemas/HTLCStatus' + LdkChainSync: + oneOf: + - $ref: '#/components/schemas/LdkChainSyncBlockSync' + - $ref: '#/components/schemas/LdkChainSyncTransactionSync' + discriminator: + propertyName: mode + mapping: + BlockSync: '#/components/schemas/LdkChainSyncBlockSync' + TransactionSync: '#/components/schemas/LdkChainSyncTransactionSync' + LdkChainSyncBlockSync: + type: object + required: + - mode + - config + properties: + mode: + type: string + enum: [BlockSync] + config: + type: object + required: + - bitcoind_rpc_username + - bitcoind_rpc_password + - bitcoind_rpc_host + - bitcoind_rpc_port + properties: + bitcoind_rpc_username: + type: string + example: user + bitcoind_rpc_password: + type: string + example: password + bitcoind_rpc_host: + type: string + example: localhost + bitcoind_rpc_port: + type: integer + example: 18443 + LdkChainSyncTransactionSync: + type: object + required: + - mode + - config + properties: + mode: + type: string + enum: [TransactionSync] + config: + type: object + required: + - indexer_url + properties: + indexer_url: + type: string + example: 127.0.0.1:50001 ListAssetsRequest: type: object required: @@ -2905,22 +3088,25 @@ components: ListTransfersRequest: type: object description: >- - Provide asset_id (list that asset's transfers), txid (list the - transfers committed by that on-chain transaction, across all assets), - or both (their intersection). At least one is required. + asset_filter selects the asset scope: Id (that asset's transfers), None + (only transfers not tied to an asset) or AnyOrNone (no asset + restriction). txid further restricts the result to the transfers + committed by that on-chain transaction, across all assets; combined + with an Id filter it acts as an intersection. AnyOrNone without a txid + is rejected, so narrow by asset_filter, by txid, or by both. + required: + - asset_filter properties: - asset_id: - type: - - string - - 'null' - example: rgb:CJkb4YZw-jRiz2sk-~PARPio-wtVYI1c-XAEYCqO-wTfvRZ8 + asset_filter: + $ref: '#/components/schemas/AssetFilter' txid: type: - string - 'null' + example: 47ee0f5b7bd5b0dd7f10ce54a94fee1b5cd54e5241b0f70f9f373d10e7a3c3e2 description: >- Return the transfers committed by the on-chain transaction with this - txid. Combined with asset_id it acts as an intersection. + txid. Combined with an Id asset_filter it acts as an intersection. index_offset: type: - integer @@ -3312,6 +3498,23 @@ components: temporary_channel_id: type: string example: a8b60c8ce3067b5fc881d4831323e24751daec3b64353c8df3205ec5d838f1c5 + OperationResult: + type: object + required: + - txid + - batch_transfer_idx + - entropy + properties: + txid: + type: string + example: 33cf3e42fb1c6faa0d47e6a498a9d4e5c56e8a06ea0d295c6d90e2b6c85f7a1e + batch_transfer_idx: + type: integer + example: 3 + entropy: + type: integer + format: int64 + example: 12345678901234567890 PaymentType: type: string enum: @@ -3412,6 +3615,43 @@ components: items: type: integer example: [6, 36, 87, 13, 5, 17] + ProvideOutOfBandAckRequest: + type: object + required: + - recipient_id + properties: + recipient_id: + type: string + example: utxob:2Bwv2Vx-t7VD8XjTh-9EMbpBFKh-J5HDFY4pj-eywu4tKKk-hjTvzJa + ProvideOutOfBandAckResponse: + type: object + properties: + operation: + nullable: true + allOf: + - $ref: '#/components/schemas/OperationResult' + ProvideOutOfBandConsignmentRequest: + type: object + required: + - file + properties: + file: + type: string + format: binary + media: + type: array + items: + type: string + format: binary + ProvideOutOfBandConsignmentResponse: + type: object + required: + - transfers + properties: + transfers: + type: object + additionalProperties: + $ref: '#/components/schemas/RefreshedTransfer' Recipient: type: object required: @@ -3438,6 +3678,37 @@ components: enum: - Blind - Witness + RefreshedTransfer: + type: object + properties: + updated_status: + nullable: true + type: string + enum: + - Initiated + - WaitingCounterparty + - WaitingSafeHeight + - WaitingConfirmations + - WaitingBroadcast + - Settled + - Failed + example: WaitingBroadcast + failure: + nullable: true + allOf: + - $ref: '#/components/schemas/RefreshFailure' + RefreshFailure: + type: object + required: + - name + - message + properties: + name: + type: string + example: InvalidConsignment + message: + type: string + example: Invalid consignment RefreshFilter: type: object required: @@ -3466,6 +3737,15 @@ components: skip_sync: type: boolean example: false + RefreshResponse: + type: object + required: + - transfers + properties: + transfers: + type: object + additionalProperties: + $ref: '#/components/schemas/RefreshedTransfer' RefreshTransferStatus: type: string enum: @@ -3512,6 +3792,7 @@ components: required: - min_confirmations - witness + - transport_endpoints properties: min_confirmations: type: integer @@ -3527,19 +3808,24 @@ components: - type: 'null' example: null expiration_timestamp: - type: - - integer - - 'null' - example: null + type: integer + example: 1695811760 witness: type: boolean example: false + transport_endpoints: + type: array + items: + type: string + example: + - rpc://127.0.0.1:3000/json-rpc RgbInvoiceResponse: type: object required: - recipient_id - invoice - batch_transfer_idx + - expiration_timestamp properties: recipient_id: type: string @@ -3548,9 +3834,7 @@ components: type: string example: rgb:~/~/~/bcrt:utxob:cbgHUJ4e-7QyKY4U-Jsj5AZw-oI0gxZh-7fxQY2_-tFFUAZN-4CgpX?expiry=1695811760&endpoints=rpc://127.0.0.1:3000/json-rpc expiration_timestamp: - type: - - integer - - 'null' + type: integer example: 1695811760 batch_transfer_idx: type: integer @@ -3663,10 +3947,8 @@ components: type: integer example: 1 expiration_timestamp: - type: - - integer - - 'null' - example: null + type: integer + example: 1695811760 recipient_map: type: object additionalProperties: @@ -4028,6 +4310,7 @@ components: - WaitingCounterparty - WaitingSafeHeight - WaitingConfirmations + - WaitingBroadcast - Settled - Failed TransferTransportEndpoint: @@ -4053,31 +4336,14 @@ components: type: object required: - password + - ldk_chain_sync - announce_addresses properties: password: type: string example: nodepassword - bitcoind_rpc_username: - type: - - string - - 'null' - example: user - bitcoind_rpc_password: - type: - - string - - 'null' - example: password - bitcoind_rpc_host: - type: - - string - - 'null' - example: localhost - bitcoind_rpc_port: - type: - - integer - - 'null' - example: 18443 + ldk_chain_sync: + $ref: '#/components/schemas/LdkChainSync' indexer_url: type: - string @@ -4154,6 +4420,8 @@ components: - outpoint - btc_amount - colorable + - exists + - derivation_index properties: outpoint: type: string @@ -4164,6 +4432,14 @@ components: colorable: type: boolean example: true + exists: + type: boolean + example: true + derivation_index: + type: + - integer + - 'null' + example: 42 WitnessData: type: object required: diff --git a/rust-lightning b/rust-lightning index 8b59f280..38d73bc9 160000 --- a/rust-lightning +++ b/rust-lightning @@ -1 +1 @@ -Subproject commit 8b59f28041a3363f6a86464e4ee4495472257250 +Subproject commit 38d73bc918f27956590585d2bb83c86f059679b0 diff --git a/sample-config.toml b/sample-config.toml index af5a4ad6..0442c96b 100644 --- a/sample-config.toml +++ b/sample-config.toml @@ -35,7 +35,7 @@ [chain] # Default indexer URL used when /unlock does not provide one -# (defaults to a per-network public electrum server) +# (there is no built-in default: unlock fails if neither supplies one) #indexer_url = "" # Default RGB proxy endpoint used when /unlock does not provide one # (defaults to a per-network public proxy) diff --git a/src/args.rs b/src/args.rs index 39657af2..a18387ca 100644 --- a/src/args.rs +++ b/src/args.rs @@ -37,6 +37,18 @@ struct Args { #[arg(long, default_value_t = 5)] max_media_upload_size_mb: u16, + /// Max aggregate size of RGB media accepted over p2p per channel-open (in MB) + #[arg(long, default_value_t = crate::rgb_file_transfer::MAX_MEDIA_MB_PER_CHANNEL)] + max_aggregated_media_size_per_channel_mb: u16, + + /// Max number of pending channel-open consignments buffered over p2p at once + #[arg(long, default_value_t = crate::rgb_file_transfer::MAX_PENDING_CONSIGNMENTS)] + max_pending_consignments: usize, + + /// Max number of RGB media files accepted over p2p per channel-open + #[arg(long, default_value_t = crate::rgb_file_transfer::MAX_MEDIA_FILES_PER_CHANNEL)] + max_media_files_per_channel: usize, + /// Root public key for biscuit token authentication (hex-encoded) #[arg(long)] root_public_key: Option, @@ -108,6 +120,9 @@ pub(crate) struct UserArgs { pub(crate) ldk_peer_listening_port: u16, pub(crate) network: BitcoinNetwork, pub(crate) max_media_upload_size_mb: u16, + pub(crate) max_aggregated_media_size_per_channel_mb: u16, + pub(crate) max_pending_consignments: usize, + pub(crate) max_media_files_per_channel: usize, pub(crate) root_public_key: Option, pub(crate) enable_virtual_channels_v0: bool, pub(crate) virtual_peer_pubkeys: Vec, @@ -199,6 +214,28 @@ fn resolve_user_args( .unwrap_or(args.max_media_upload_size_mb) }; + let max_aggregated_media_size_per_channel_mb = + if from_cli("max_aggregated_media_size_per_channel_mb") { + args.max_aggregated_media_size_per_channel_mb + } else { + api.max_aggregated_media_size_per_channel_mb + .unwrap_or(args.max_aggregated_media_size_per_channel_mb) + }; + + let max_pending_consignments = if from_cli("max_pending_consignments") { + args.max_pending_consignments + } else { + api.max_pending_consignments + .unwrap_or(args.max_pending_consignments) + }; + + let max_media_files_per_channel = if from_cli("max_media_files_per_channel") { + args.max_media_files_per_channel + } else { + api.max_media_files_per_channel + .unwrap_or(args.max_media_files_per_channel) + }; + let disable_authentication = args.disable_authentication || auth.disable_authentication.unwrap_or(false); let root_public_key_hex = args.root_public_key.or(auth.root_public_key); @@ -246,6 +283,9 @@ fn resolve_user_args( ldk_peer_listening_port, network, max_media_upload_size_mb, + max_aggregated_media_size_per_channel_mb, + max_pending_consignments, + max_media_files_per_channel, root_public_key, enable_virtual_channels_v0, virtual_peer_pubkeys, @@ -419,6 +459,25 @@ mod tests { assert_eq!(ua.config.rgb.fee_rate_sat_vb, 12); } + #[test] + fn p2p_transfer_limits_from_file_cli_wins() { + let ua = resolve( + &base(&[]), + "[api]\nmax_aggregated_media_size_per_channel_mb = 7\nmax_pending_consignments = 25\nmax_media_files_per_channel = 3\n", + ) + .unwrap(); + assert_eq!(ua.max_aggregated_media_size_per_channel_mb, 7); + assert_eq!(ua.max_pending_consignments, 25); + assert_eq!(ua.max_media_files_per_channel, 3); + + let ua = resolve( + &base(&["--max-pending-consignments", "40"]), + "[api]\nmax_pending_consignments = 25\n", + ) + .unwrap(); + assert_eq!(ua.max_pending_consignments, 40); + } + #[test] fn invalid_policy_in_file_rejected() { let res = resolve(&base(&[]), "[rgb]\nfee_rate_sat_vb = 0\n"); diff --git a/src/async_kv_store.rs b/src/async_kv_store.rs index d55d844b..1466e4f7 100644 --- a/src/async_kv_store.rs +++ b/src/async_kv_store.rs @@ -60,6 +60,11 @@ impl RemoteFirstKvStore { let _ = self.shutdown.send(true); } + #[cfg(test)] + pub(crate) fn subscribe_shutdown(&self) -> tokio::sync::watch::Receiver { + self.shutdown.subscribe() + } + /// Probes the configured VSS server. `true` when VSS is not configured or /// answered (a missing probe key still proves the server responded). pub async fn remote_reachable(&self) -> bool { diff --git a/src/async_order.rs b/src/async_order.rs index d310906f..397825d2 100644 --- a/src/async_order.rs +++ b/src/async_order.rs @@ -135,7 +135,8 @@ fn apay_decode_hex_fixed( ))); } let mut out = [0u8; N]; - for (slot, pair) in out.iter_mut().zip(s.as_bytes().chunks_exact(2)) { + let (pairs, _) = s.as_bytes().as_chunks::<2>(); + for (slot, pair) in out.iter_mut().zip(pairs) { let high = apay_hex_nibble(pair[0]).ok_or_else(|| { JsonRpcErrorWire::invalid_params(format!("{field} must be {N}-byte hex")) })?; diff --git a/src/auth.rs b/src/auth.rs index 62e0b7a0..3c3c2a55 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -7,7 +7,7 @@ use crate::{ utils::{hex_str, hex_str_to_vec, AppState}, }; -const READ_ONLY_OPS: [&str; 25] = [ +const READ_ONLY_OPS: [&str; 26] = [ "/assetbalance", "/assetmetadata", "/btcbalance", @@ -19,6 +19,7 @@ const READ_ONLY_OPS: [&str; 25] = [ "/estimatefee", "/getassetmedia", "/getchannelid", + "/getconsignment", "/getpayment", "/getswap", "/invoicestatus", diff --git a/src/backup.rs b/src/backup.rs index ceef6712..235f2eb3 100644 --- a/src/backup.rs +++ b/src/backup.rs @@ -1,11 +1,8 @@ use amplify::s; use chacha20poly1305::aead::{generic_array::GenericArray, stream}; -use chacha20poly1305::{Key, KeyInit, XChaCha20Poly1305}; use rand::{distributions::Alphanumeric, Rng}; -use scrypt::password_hash::{PasswordHasher, Salt}; -use scrypt::Scrypt; +use scrypt::password_hash::Salt; use tempfile::TempDir; -use typenum::consts::U32; use walkdir::WalkDir; use zip::write::SimpleFileOptions; @@ -13,14 +10,16 @@ use std::fs::{create_dir_all, read_to_string, remove_file, write, File}; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; +use crate::crypto::{aead_from_key, derive_key, KdfParams, KEY_LEN}; use crate::error::APIError; use crate::utils::LOGS_DIR; const BACKUP_BUFFER_LEN_ENCRYPT: usize = 239; // 255 max, leaving 16 for the checksum const BACKUP_BUFFER_LEN_DECRYPT: usize = BACKUP_BUFFER_LEN_ENCRYPT + 16; -const BACKUP_KEY_LENGTH: usize = 32; +const BACKUP_SALT_LENGTH: usize = 32; const BACKUP_NONCE_LENGTH: usize = 19; const BACKUP_VERSION: u8 = 1; +const RESTORE_STAGING_DIR: &str = "restored"; struct BackupPaths { encrypted: PathBuf, @@ -32,7 +31,7 @@ struct BackupPaths { } struct CypherSecrets { - key: GenericArray, + key: [u8; KEY_LEN], nonce: [u8; BACKUP_NONCE_LENGTH], } @@ -56,7 +55,7 @@ pub(crate) fn do_backup( let files = get_backup_paths(&tmp_base_path)?; let salt: String = rand::thread_rng() .sample_iter(&Alphanumeric) - .take(BACKUP_KEY_LENGTH) + .take(BACKUP_SALT_LENGTH) .map(char::from) .collect(); tracing::debug!("using generated salt: {}", &salt); @@ -86,18 +85,36 @@ pub(crate) fn do_backup( Ok(()) } -/// Restore a backup from the given file and password to the provided target directory. -pub(crate) fn restore_backup( +/// A decrypted backup, extracted to a temporary directory. +/// +/// Restoring is split in two steps so the caller can check the backup before the target directory +/// is touched: a backup that turns out to be unusable must not leave the node holding data it +/// cannot open, since both `init` and `restore` refuse to run on an initialized node. +pub(crate) struct UnpackedBackup { + dir: PathBuf, + zip: PathBuf, + // extraction happens under this directory, removed when the backup is dropped + _tempdir: TempDir, +} + +impl UnpackedBackup { + /// The directory holding the extracted backup contents. + pub(crate) fn dir(&self) -> &Path { + &self.dir + } +} + +/// Decrypt the backup at the given path with the given password and extract it to a temporary +/// directory, leaving the node's storage directory untouched. +pub(crate) fn unpack_backup( backup_path: &Path, password: &str, - target_dir: &Path, -) -> Result<(), APIError> { +) -> Result { // setup tracing::info!("starting restore..."); let backup_file = PathBuf::from(backup_path); let tmp_base_path = get_parent_path(&backup_file)?; let files = get_backup_paths(&tmp_base_path)?; - let target_dir_path = PathBuf::from(&target_dir); // unpack given zip file and retrieve backup data tracing::info!("unzipping {:?}", backup_file); @@ -116,11 +133,24 @@ pub(crate) fn restore_backup( }); } - // decrypt backup and restore files + // decrypt the backup and extract it out of the way of the target directory tracing::info!("decrypting {:?} to {:?}", files.encrypted, files.zip); decrypt_file(&files.encrypted, &files.zip, password, &salt, &nonce)?; - tracing::info!("unzipping {:?} to {:?}", &files.zip, &target_dir_path); - unzip(&files.zip, &target_dir_path)?; + let dir = files.tempdir.path().join(RESTORE_STAGING_DIR); + tracing::info!("unzipping {:?} to {:?}", &files.zip, &dir); + unzip(&files.zip, &dir)?; + + Ok(UnpackedBackup { + dir, + zip: files.zip, + _tempdir: files.tempdir, + }) +} + +/// Install a previously unpacked backup into the provided target directory. +pub(crate) fn install_backup(backup: &UnpackedBackup, target_dir: &Path) -> Result<(), APIError> { + tracing::info!("unzipping {:?} to {:?}", &backup.zip, target_dir); + unzip(&backup.zip, target_dir)?; tracing::info!("restore completed"); Ok(()) @@ -251,20 +281,14 @@ fn get_cypher_secrets( salt_str: &str, nonce_str: &str, ) -> Result { - // hash password using scrypt with the provided salt - let password_bytes = password.as_bytes(); + // derive the key from the password, hashing it with scrypt and the provided salt let salt = Salt::from_b64(salt_str) .map_err(|e| APIError::Unexpected(format!("Failed to create salt: {e}")))?; - let password_hash = Scrypt - .hash_password(password_bytes, salt) - .map_err(|e| APIError::Unexpected(format!("Failed to hash password: {e}")))?; - let hash_output = password_hash - .hash - .ok_or_else(|| APIError::Unexpected(s!("Failed to hash password")))?; - let hash = hash_output.as_bytes(); - - // get key from password hash - let key = Key::clone_from_slice(&hash[..BACKUP_KEY_LENGTH]); + let mut salt_buf = [0u8; Salt::MAX_LENGTH]; + let salt_bytes = salt + .decode_b64(&mut salt_buf) + .map_err(|e| APIError::Unexpected(format!("Failed to decode salt: {e}")))?; + let key = derive_key(password, salt_bytes, KdfParams::BACKUP_V1)?; // get nonce from provided str let nonce_bytes = nonce_str.as_bytes(); @@ -288,7 +312,7 @@ fn encrypt_file( // - stream mode required as files to encrypt may be big, so avoiding a memory buffer // setup - let aead = XChaCha20Poly1305::new(&cypher_secrets.key); + let aead = aead_from_key(&cypher_secrets.key); let nonce = GenericArray::from_slice(&cypher_secrets.nonce); let mut stream_encryptor = stream::EncryptorBE32::from_aead(aead, nonce); let mut buffer = [0u8; BACKUP_BUFFER_LEN_ENCRYPT]; @@ -328,7 +352,7 @@ fn decrypt_file( let cypher_secrets = get_cypher_secrets(password, salt_str, nonce_str)?; // setup - let aead = XChaCha20Poly1305::new(&cypher_secrets.key); + let aead = aead_from_key(&cypher_secrets.key); let nonce = GenericArray::from_slice(&cypher_secrets.nonce); let mut stream_decryptor = stream::DecryptorBE32::from_aead(aead, nonce); let mut buffer = [0u8; BACKUP_BUFFER_LEN_DECRYPT]; diff --git a/src/chain_backend.rs b/src/chain_backend.rs deleted file mode 100644 index 3e76bff3..00000000 --- a/src/chain_backend.rs +++ /dev/null @@ -1,33 +0,0 @@ -use std::sync::Arc; - -use bitcoin::blockdata::transaction::Transaction; -use lightning::chain::chaininterface::{BroadcasterInterface, ConfirmationTarget, FeeEstimator}; - -use crate::bitcoind::BitcoindClient; -use crate::indexer::{ElectrumIndexerClient, EsploraIndexerClient}; - -pub(crate) enum ChainBackend { - Bitcoind(Arc), - Esplora(Arc), - Electrum(Arc), -} - -impl FeeEstimator for ChainBackend { - fn get_est_sat_per_1000_weight(&self, target: ConfirmationTarget) -> u32 { - match self { - ChainBackend::Bitcoind(c) => c.get_est_sat_per_1000_weight(target), - ChainBackend::Esplora(c) => c.get_est_sat_per_1000_weight(target), - ChainBackend::Electrum(c) => c.get_est_sat_per_1000_weight(target), - } - } -} - -impl BroadcasterInterface for ChainBackend { - fn broadcast_transactions(&self, txs: &[&Transaction]) { - match self { - ChainBackend::Bitcoind(c) => c.broadcast_transactions(txs), - ChainBackend::Esplora(c) => c.broadcast_transactions(txs), - ChainBackend::Electrum(c) => c.broadcast_transactions(txs), - } - } -} diff --git a/src/config/file.rs b/src/config/file.rs index 13175d06..fe19f10b 100644 --- a/src/config/file.rs +++ b/src/config/file.rs @@ -127,6 +127,9 @@ pub(crate) struct TomlVss { #[serde(deny_unknown_fields)] pub(crate) struct TomlApi { pub(crate) max_media_upload_size_mb: Option, + pub(crate) max_aggregated_media_size_per_channel_mb: Option, + pub(crate) max_pending_consignments: Option, + pub(crate) max_media_files_per_channel: Option, pub(crate) default_page_size: Option, } diff --git a/src/config/tests.rs b/src/config/tests.rs index 01ceb997..6cd2c142 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -146,6 +146,9 @@ allow_empty_restore = true [api] max_media_upload_size_mb = 10 +max_aggregated_media_size_per_channel_mb = 7 +max_pending_consignments = 25 +max_media_files_per_channel = 3 "#, ) .unwrap(); @@ -168,6 +171,9 @@ max_media_upload_size_mb = 10 assert_eq!(vss.allow_empty_restore, Some(true)); let api = t.api.unwrap(); assert_eq!(api.max_media_upload_size_mb, Some(10)); + assert_eq!(api.max_aggregated_media_size_per_channel_mb, Some(7)); + assert_eq!(api.max_pending_consignments, Some(25)); + assert_eq!(api.max_media_files_per_channel, Some(3)); } #[test] diff --git a/src/core_types.rs b/src/core_types.rs index 30910282..2b754096 100644 --- a/src/core_types.rs +++ b/src/core_types.rs @@ -127,12 +127,24 @@ impl_writeable_tlv_based_enum!(SwapStatus, (4, Failed) => {}, ); +/// How LDK follows the chain: full blocks from bitcoind, or the indexer. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(tag = "mode", content = "config")] +pub(crate) enum LdkChainSync { + #[cfg(feature = "block-sync")] + BlockSync { + bitcoind_rpc_username: String, + bitcoind_rpc_password: String, + bitcoind_rpc_host: String, + bitcoind_rpc_port: u16, + }, + #[cfg(feature = "transaction-sync")] + TransactionSync { indexer_url: String }, +} + #[derive(Clone, Debug)] pub(crate) struct UnlockRequest { - pub(crate) bitcoind_rpc_username: Option, - pub(crate) bitcoind_rpc_password: Option, - pub(crate) bitcoind_rpc_host: Option, - pub(crate) bitcoind_rpc_port: Option, + pub(crate) ldk_chain_sync: LdkChainSync, pub(crate) indexer_url: Option, pub(crate) proxy_endpoint: Option, pub(crate) announce_addresses: Vec, diff --git a/src/crypto.rs b/src/crypto.rs new file mode 100644 index 00000000..aab88d55 --- /dev/null +++ b/src/crypto.rs @@ -0,0 +1,355 @@ +//! Encrypted mnemonic file format, plus the password-based key derivation and cipher setup it +//! shares with the streaming backup encryption in [`crate::backup`]. +//! +//! Keys are derived with scrypt and data is encrypted with XChaCha20Poly1305. + +use amplify::s; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use chacha20poly1305::aead::Aead; +use chacha20poly1305::{Key, KeyInit, XChaCha20Poly1305, XNonce}; +use rand::RngCore; +use scrypt::{scrypt, Params as ScryptParams}; + +use crate::error::APIError; + +// Length of the keys derived from a password. +pub(crate) const KEY_LEN: usize = 32; + +const MNEMONIC_VERSION: u8 = 1; +const MNEMONIC_SALT_LEN: usize = 16; +const MNEMONIC_NONCE_LEN: usize = 24; +// Bytes taken by the version and the work factors, which precede the salt and the nonce in a +// mnemonic header. +const MNEMONIC_HEADER_PREFIX_LEN: usize = 1 + 1 + 4 + 4; +const MNEMONIC_HEADER_LEN: usize = + MNEMONIC_HEADER_PREFIX_LEN + MNEMONIC_SALT_LEN + MNEMONIC_NONCE_LEN; + +// The scrypt CPU/memory cost of the work factors. +const CURRENT_LOG_N: u8 = 17; +const BACKUP_V1_LOG_N: u8 = 17; + +// The test build is unoptimized, which makes scrypt about 30 times slower, +// therefore the work factors are lowered for tests. +const TEST_LOG_N: u8 = 10; + +// The scrypt work factors. +// +// Values are pinned here instead of being taken from `ScryptParams::recommended()`, whose +// defaults can change between crate releases and would then leave already encrypted data +// undecryptable. +#[derive(Clone, Copy)] +pub(crate) struct KdfParams { + log_n: u8, + r: u32, + p: u32, +} + +impl KdfParams { + // Work factors used for newly encrypted data. + pub(crate) const CURRENT: Self = Self { + log_n: if cfg!(test) { + TEST_LOG_N + } else { + CURRENT_LOG_N + }, + r: 8, + p: 1, + }; + + // Work factors of backup format version 1, which has no field to record them and therefore + // requires these values to stay unchanged. Only `cfg!(test)` may lower them: cargo features + // unify across a build graph, so a non-test build that happened to enable `test-utils` would + // write v1 backups a stock build cannot decrypt, with no version field to diagnose it. + pub(crate) const BACKUP_V1: Self = Self { + log_n: if cfg!(test) { + TEST_LOG_N + } else { + BACKUP_V1_LOG_N + }, + r: 8, + p: 1, + }; + + // Highest accepted work factors: scrypt allocates `128 * r * 2^log_n` bytes, so reading back + // unbounded values would let a damaged file exhaust the available memory + const MAX_LOG_N: u8 = 20; + const MAX_R: u32 = 32; + const MAX_P: u32 = 16; + + // Returns `None` if the given work factors are out of range or rejected by scrypt. + fn checked(log_n: u8, r: u32, p: u32) -> Option { + let in_range = log_n <= Self::MAX_LOG_N + && (1..=Self::MAX_R).contains(&r) + && (1..=Self::MAX_P).contains(&p); + (in_range && ScryptParams::new(log_n, r, p, KEY_LEN).is_ok()).then_some(Self { + log_n, + r, + p, + }) + } + + fn to_scrypt(self) -> ScryptParams { + ScryptParams::new(self.log_n, self.r, self.p, KEY_LEN).expect("checked work factors") + } +} + +// Derive an encryption key from the given password and salt. +pub(crate) fn derive_key( + password: &str, + salt: &[u8], + params: KdfParams, +) -> Result<[u8; KEY_LEN], APIError> { + let mut key = [0u8; KEY_LEN]; + scrypt(password.as_bytes(), salt, ¶ms.to_scrypt(), &mut key) + .map_err(|e| APIError::Unexpected(format!("Failed to derive key: {e}")))?; + Ok(key) +} + +// Build an XChaCha20Poly1305 AEAD from a key returned by [`derive_key`]. +pub(crate) fn aead_from_key(key: &[u8; KEY_LEN]) -> XChaCha20Poly1305 { + XChaCha20Poly1305::new(Key::from_slice(key)) +} + +// Data preceding the ciphertext of an encrypted mnemonic. +// +// It is serialized as: version (1 byte), scrypt `log_n` (1 byte), scrypt `r` (4 bytes, big +// endian), scrypt `p` (4 bytes, big endian), salt, nonce. +struct MnemonicHeader { + params: KdfParams, + salt: [u8; MNEMONIC_SALT_LEN], + nonce: [u8; MNEMONIC_NONCE_LEN], +} + +impl MnemonicHeader { + fn encode(&self) -> Vec { + let mut encoded = Vec::with_capacity(MNEMONIC_HEADER_LEN); + encoded.push(MNEMONIC_VERSION); + encoded.push(self.params.log_n); + encoded.extend_from_slice(&self.params.r.to_be_bytes()); + encoded.extend_from_slice(&self.params.p.to_be_bytes()); + encoded.extend_from_slice(&self.salt); + encoded.extend_from_slice(&self.nonce); + encoded + } + + fn decode(encoded: &[u8; MNEMONIC_HEADER_LEN]) -> Result { + let version = encoded[0]; + if version != MNEMONIC_VERSION { + return Err(APIError::CorruptedMnemonic(format!( + "unsupported version {version}" + ))); + } + let log_n = encoded[1]; + let r = u32::from_be_bytes(encoded[2..6].try_into().expect("4 bytes")); + let p = u32::from_be_bytes(encoded[6..10].try_into().expect("4 bytes")); + let params = KdfParams::checked(log_n, r, p).ok_or_else(|| { + APIError::CorruptedMnemonic(format!( + "unsupported scrypt work factors (log_n {log_n}, r {r}, p {p})" + )) + })?; + let salt_end = MNEMONIC_HEADER_PREFIX_LEN + MNEMONIC_SALT_LEN; + + Ok(Self { + params, + salt: encoded[MNEMONIC_HEADER_PREFIX_LEN..salt_end] + .try_into() + .expect("salt length"), + nonce: encoded[salt_end..].try_into().expect("nonce length"), + }) + } +} + +// Encrypt a mnemonic with the given password. +// +// The work factors are stored along with the ciphertext, so raising the ones used for new data +// keeps previously encrypted mnemonics readable. +pub(crate) fn encrypt_mnemonic(password: &str, mnemonic: &str) -> Result { + let mut salt = [0u8; MNEMONIC_SALT_LEN]; + let mut nonce = [0u8; MNEMONIC_NONCE_LEN]; + let mut rng = rand::thread_rng(); + rng.fill_bytes(&mut salt); + rng.fill_bytes(&mut nonce); + + encrypt_with( + password, + KdfParams::CURRENT, + salt, + nonce, + mnemonic.as_bytes(), + ) +} + +// Encrypt a mnemonic with the given password, work factors, salt and nonce. +// +// Split out of [`encrypt_mnemonic`] so that tests can encrypt deterministically, and with work +// factors cheaper than the current ones, without reimplementing the payload layout. +fn encrypt_with( + password: &str, + params: KdfParams, + salt: [u8; MNEMONIC_SALT_LEN], + nonce: [u8; MNEMONIC_NONCE_LEN], + plaintext: &[u8], +) -> Result { + let key = derive_key(password, &salt, params)?; + let ciphertext = aead_from_key(&key) + .encrypt(XNonce::from_slice(&nonce), plaintext) + .map_err(|e| APIError::Unexpected(format!("Failed to encrypt mnemonic: {e}")))?; + + let mut payload = MnemonicHeader { + params, + salt, + nonce, + } + .encode(); + payload.extend_from_slice(&ciphertext); + + Ok(BASE64.encode(payload)) +} + +// Decrypt a mnemonic encrypted with [`encrypt_mnemonic`]. +// +// A wrong password is the only cause for [`APIError::WrongPassword`], data that cannot be +// interpreted is reported as [`APIError::CorruptedMnemonic`] instead. +pub(crate) fn decrypt_mnemonic(password: &str, encrypted: &str) -> Result { + let payload = BASE64 + .decode(encrypted) + .map_err(|e| APIError::CorruptedMnemonic(format!("invalid base64: {e}")))?; + let Some((header, ciphertext)) = payload.split_first_chunk::() else { + return Err(APIError::CorruptedMnemonic(format!( + "got {} bytes, expected more than {MNEMONIC_HEADER_LEN}", + payload.len() + ))); + }; + if ciphertext.is_empty() { + return Err(APIError::CorruptedMnemonic(s!("no ciphertext"))); + } + let header = MnemonicHeader::decode(header)?; + + let key = derive_key(password, &header.salt, header.params)?; + let plaintext = aead_from_key(&key) + .decrypt(XNonce::from_slice(&header.nonce), ciphertext) + .map_err(|_| APIError::WrongPassword)?; + + String::from_utf8(plaintext).map_err(|_| APIError::CorruptedMnemonic(s!("invalid UTF-8"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + const PASSWORD: &str = "password123"; + const MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + // Work factors differing from the ones used for new data, so that data encrypted with them can + // only be decrypted if the work factors stored along with it are honored. + const OTHER_PARAMS: KdfParams = KdfParams { + log_n: TEST_LOG_N + 1, + r: 8, + p: 1, + }; + + fn encrypt_with_other_params(plaintext: &[u8]) -> String { + let salt = [7u8; MNEMONIC_SALT_LEN]; + let nonce = [9u8; MNEMONIC_NONCE_LEN]; + encrypt_with(PASSWORD, OTHER_PARAMS, salt, nonce, plaintext).unwrap() + } + + #[test] + fn encrypt_decrypt_roundtrip() { + let encrypted = encrypt_mnemonic(PASSWORD, MNEMONIC).unwrap(); + assert_eq!(decrypt_mnemonic(PASSWORD, &encrypted).unwrap(), MNEMONIC); + } + + #[test] + fn real_work_factors_are_usable() { + // everywhere else in this module a test build swaps the work factors of a real build for a + // much cheaper cost, so this is the only place checking the real ones are usable + let current = KdfParams { + log_n: CURRENT_LOG_N, + ..KdfParams::CURRENT + }; + let backup_v1 = KdfParams { + log_n: BACKUP_V1_LOG_N, + ..KdfParams::BACKUP_V1 + }; + + // backups derive a key without going through the mnemonic format + assert!(KdfParams::checked(backup_v1.log_n, backup_v1.r, backup_v1.p).is_some()); + derive_key(PASSWORD, &[7u8; MNEMONIC_SALT_LEN], backup_v1).unwrap(); + + // mnemonics store their work factors, so a roundtrip also covers reading them back + assert!(KdfParams::checked(current.log_n, current.r, current.p).is_some()); + let encrypted = encrypt_with( + PASSWORD, + current, + [7u8; MNEMONIC_SALT_LEN], + [9u8; MNEMONIC_NONCE_LEN], + MNEMONIC.as_bytes(), + ) + .unwrap(); + assert_eq!(decrypt_mnemonic(PASSWORD, &encrypted).unwrap(), MNEMONIC); + } + + #[test] + fn backup_v1_key_is_pinned() { + // backup format version 1 records no work factors, so any change to `BACKUP_V1` or to the + // scrypt crate would silently make every existing backup undecryptable + let backup_v1 = KdfParams { + log_n: BACKUP_V1_LOG_N, + ..KdfParams::BACKUP_V1 + }; + assert_eq!( + derive_key(PASSWORD, &[7u8; MNEMONIC_SALT_LEN], backup_v1).unwrap(), + [ + 0x90, 0xdb, 0x59, 0xef, 0xba, 0x0f, 0x16, 0x67, 0x43, 0xc9, 0x94, 0xa8, 0x2a, 0x7c, + 0x2d, 0x35, 0x4d, 0x90, 0x4c, 0x43, 0x3b, 0x7c, 0x42, 0xc2, 0x33, 0xe9, 0xab, 0x42, + 0x9a, 0xfc, 0xd2, 0x03 + ] + ); + } + + #[test] + fn stored_work_factors_are_used() { + // decrypting data encrypted with work factors other than the current ones can only + // succeed if the ones stored along with it are being used + let encrypted = encrypt_with_other_params(MNEMONIC.as_bytes()); + assert_eq!(decrypt_mnemonic(PASSWORD, &encrypted).unwrap(), MNEMONIC); + } + + #[test] + fn wrong_password_is_detected() { + let encrypted = encrypt_with_other_params(MNEMONIC.as_bytes()); + assert!(matches!( + decrypt_mnemonic("wrong-password", &encrypted), + Err(APIError::WrongPassword) + )); + } + + #[test] + fn corrupted_data_is_not_a_wrong_password() { + let with_header = |version: u8, log_n: u8, r: u32, p: u32| { + let mut payload = vec![version, log_n]; + payload.extend_from_slice(&r.to_be_bytes()); + payload.extend_from_slice(&p.to_be_bytes()); + payload.extend_from_slice(&[0u8; MNEMONIC_SALT_LEN + MNEMONIC_NONCE_LEN]); + payload.extend_from_slice(b"ciphertext"); + BASE64.encode(payload) + }; + + for encrypted in [ + s!("not base64 at all"), + // truncated, then complete but without any ciphertext + BASE64.encode([0u8; MNEMONIC_HEADER_LEN - 1]), + BASE64.encode([0u8; MNEMONIC_HEADER_LEN]), + encrypt_with_other_params(b"\xff not valid UTF-8"), + with_header(MNEMONIC_VERSION + 1, 17, 8, 1), + with_header(MNEMONIC_VERSION, KdfParams::MAX_LOG_N + 1, 8, 1), + with_header(MNEMONIC_VERSION, 17, 0, 1), + with_header(MNEMONIC_VERSION, 17, 8, 0), + ] { + assert!(matches!( + decrypt_mnemonic(PASSWORD, &encrypted), + Err(APIError::CorruptedMnemonic(_)) + )); + } + } +} diff --git a/src/custom_msg_rpc.rs b/src/custom_msg_rpc.rs index c3e36552..0ce1338e 100644 --- a/src/custom_msg_rpc.rs +++ b/src/custom_msg_rpc.rs @@ -17,6 +17,7 @@ use tracing::warn; use crate::asset_link::{AssetLinkMessage, AssetLinkMessageHandler}; use crate::async_order::{AsyncOrderMessage, AsyncOrderMessageHandler}; +use crate::rgb_file_transfer::{RgbFileMessage, RgbFileTransferHandler}; pub(crate) const JSONRPC_INTERNAL_ERROR: i64 = -32603; pub(crate) const JSONRPC_INVALID_PARAMS: i64 = -32602; @@ -148,6 +149,7 @@ pub(crate) trait CustomMsgPeerAccessControl: Send + Sync { pub(crate) enum NodeCustomMessage { AsyncOrder(AsyncOrderMessage), AssetLink(AssetLinkMessage), + RgbFileTransfer(RgbFileMessage), } impl Type for NodeCustomMessage { @@ -155,6 +157,7 @@ impl Type for NodeCustomMessage { match self { NodeCustomMessage::AsyncOrder(msg) => msg.type_id(), NodeCustomMessage::AssetLink(msg) => msg.type_id(), + NodeCustomMessage::RgbFileTransfer(msg) => msg.type_id(), } } } @@ -164,6 +167,7 @@ impl Writeable for NodeCustomMessage { match self { NodeCustomMessage::AsyncOrder(msg) => msg.write(w), NodeCustomMessage::AssetLink(msg) => msg.write(w), + NodeCustomMessage::RgbFileTransfer(msg) => msg.write(w), } } } @@ -171,6 +175,7 @@ impl Writeable for NodeCustomMessage { pub(crate) struct CustomMessenger { pub(crate) async_order: Arc, pub(crate) asset_link: Arc, + pub(crate) rgb_file_transfer: Arc, } impl CustomMessageReader for CustomMessenger { @@ -187,6 +192,9 @@ impl CustomMessageReader for CustomMessenger { if let Some(msg) = self.asset_link.read(message_type, buffer)? { return Ok(Some(NodeCustomMessage::AssetLink(msg))); } + if let Some(msg) = self.rgb_file_transfer.read(message_type, buffer)? { + return Ok(Some(NodeCustomMessage::RgbFileTransfer(msg))); + } Ok(None) } } @@ -204,6 +212,9 @@ impl CustomMessageHandler for CustomMessenger { NodeCustomMessage::AssetLink(msg) => { self.asset_link.handle_custom_message(msg, sender_node_id) } + NodeCustomMessage::RgbFileTransfer(msg) => self + .rgb_file_transfer + .handle_custom_message(msg, sender_node_id), } } @@ -215,12 +226,16 @@ impl CustomMessageHandler for CustomMessenger { for (peer, msg) in self.asset_link.get_and_clear_pending_msg() { pending.push((peer, NodeCustomMessage::AssetLink(msg))); } + for (peer, msg) in self.rgb_file_transfer.get_and_clear_pending_msg() { + pending.push((peer, NodeCustomMessage::RgbFileTransfer(msg))); + } pending } fn peer_disconnected(&self, their_node_id: PublicKey) { self.async_order.peer_disconnected(their_node_id); self.asset_link.peer_disconnected(their_node_id); + self.rgb_file_transfer.peer_disconnected(their_node_id); } fn peer_connected( @@ -231,15 +246,21 @@ impl CustomMessageHandler for CustomMessenger { ) -> Result<(), ()> { self.async_order .peer_connected(their_node_id, msg, inbound)?; - self.asset_link.peer_connected(their_node_id, msg, inbound) + self.asset_link + .peer_connected(their_node_id, msg, inbound)?; + self.rgb_file_transfer + .peer_connected(their_node_id, msg, inbound) } fn provided_node_features(&self) -> NodeFeatures { - self.async_order.provided_node_features() | self.asset_link.provided_node_features() + self.async_order.provided_node_features() + | self.asset_link.provided_node_features() + | self.rgb_file_transfer.provided_node_features() } fn provided_init_features(&self, their_node_id: PublicKey) -> InitFeatures { self.async_order.provided_init_features(their_node_id) | self.asset_link.provided_init_features(their_node_id) + | self.rgb_file_transfer.provided_init_features(their_node_id) } } diff --git a/src/error.rs b/src/error.rs index f9915585..761de43f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -4,7 +4,9 @@ use axum::{ response::{IntoResponse, Response}, Json, }; -use rgb_lib::{BitcoinNetwork, Error as RgbLibError}; +#[cfg(feature = "block-sync")] +use rgb_lib::BitcoinNetwork; +use rgb_lib::Error as RgbLibError; use serde::{Deserialize, Serialize}; #[derive(Debug, Deserialize, Serialize)] @@ -26,9 +28,6 @@ pub enum APIError { #[error("Node has already been initialized")] AlreadyInitialized, - #[error("Provide either bitcoind RPC credentials (all four fields) or an esplora indexer_url, not both")] - AmbiguousChainBackend, - #[error("Anchor outputs are required for RGB channels")] AnchorsRequired, @@ -50,9 +49,27 @@ pub enum APIError { #[error("Batch transfer cannot be set to failed status")] CannotFailBatchTransfer, + #[error("Cannot provide out-of-band ACK: {0}")] + CannotProvideOutOfBandAck(String), + + #[error("Cannot provide out-of-band consignment: {0}")] + CannotProvideOutOfBandConsignment(String), + #[error("Cannot call other APIs while node is changing state")] ChangingState, + #[error("Consignment file is empty")] + ConsignmentFileEmpty, + + #[error("Consignment file has not been provided")] + ConsignmentFileNotProvided, + + #[error("Consignment not found")] + ConsignmentNotFound, + + #[error("The stored mnemonic is corrupted: {0}")] + CorruptedMnemonic(String), + #[error("External signer is required for this operation")] ExternalSignerRequired, @@ -81,6 +98,7 @@ pub enum APIError { #[error("Failed to sync BDK: {0}")] FailedBdkSync(String), + #[cfg(feature = "block-sync")] #[error("Failed to connect to bitcoind client: {0}")] FailedBitcoindConnection(String), @@ -170,6 +188,9 @@ pub enum APIError { #[error("Invalid channel ID")] InvalidChannelID, + #[error("Invalid consignment")] + InvalidConsignment, + #[error("Invalid contract link: {0}")] InvalidContractLink(String), @@ -233,9 +254,6 @@ pub enum APIError { #[error("Invalid precision: {0}")] InvalidPrecision(String), - #[error("Invalid proxy endpoint")] - InvalidProxyEndpoint, - #[error("Invalid proxy protocol version: {0}")] InvalidProxyProtocol(String), @@ -311,8 +329,8 @@ pub enum APIError { #[error("Min fee not met for transfer with TXID: {0}")] MinFeeNotMet(String), - #[error("Provide either bitcoind RPC credentials (all four fields) or an esplora indexer_url; none were supplied")] - MissingChainBackend, + #[error("No indexer_url was supplied, in the unlock request or in the config file")] + MissingIndexerUrl, #[error("Unable to find payment preimage, be sure you've provided the correct swap info")] MissingSwapPaymentPreimage, @@ -320,6 +338,7 @@ pub enum APIError { #[error("Network error: {0}")] Network(String), + #[cfg(feature = "block-sync")] #[error("The network of the given bitcoind ({0}) doesn't match the node's chain ({1})")] NetworkMismatch(String, BitcoinNetwork), @@ -393,16 +412,16 @@ pub enum APIError { WrongPassword, } +pub(crate) fn error_name(e: &impl std::error::Error) -> String { + format!("{e:?}") + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect() +} + impl APIError { - fn name(&self) -> String { - format!("{self:?}") - .split('(') - .next() - .unwrap() - .split(" {") - .next() - .unwrap() - .to_string() + pub(crate) fn name(&self) -> String { + error_name(self) } } @@ -433,6 +452,12 @@ impl From for APIError { RgbLibError::BatchTransferNotFound { .. } => APIError::BatchTransferNotFound, RgbLibError::CannotEstimateFees => APIError::CannotEstimateFees, RgbLibError::CannotFailBatchTransfer => APIError::CannotFailBatchTransfer, + RgbLibError::CannotProvideOutOfBandAck { details } => { + APIError::CannotProvideOutOfBandAck(details) + } + RgbLibError::CannotProvideOutOfBandConsignment { details } => { + APIError::CannotProvideOutOfBandConsignment(details) + } RgbLibError::EmptyFile { .. } => APIError::MediaFileEmpty, RgbLibError::FailedBdkSync { details } => APIError::FailedBdkSync(details), RgbLibError::FailedBroadcast { details } => APIError::FailedBroadcast(details), @@ -526,7 +551,8 @@ impl From for APIError { impl IntoResponse for APIError { fn into_response(self) -> Response { let (status, error, name) = match self { - APIError::FailedClosingChannel(_) + APIError::CorruptedMnemonic(_) + | APIError::FailedClosingChannel(_) | APIError::FailedInvoiceCreation(_) | APIError::FailedIssuingAsset(_) | APIError::FailedLoadingChannelState(_) @@ -542,6 +568,11 @@ impl IntoResponse for APIError { self.name(), ), APIError::AnchorsRequired + | APIError::CannotProvideOutOfBandAck(_) + | APIError::CannotProvideOutOfBandConsignment(_) + | APIError::ConsignmentFileEmpty + | APIError::ConsignmentFileNotProvided + | APIError::ConsignmentNotFound | APIError::ExpiredSwapOffer | APIError::IncompleteRGBInfo | APIError::InvalidAddress(_) @@ -555,6 +586,7 @@ impl IntoResponse for APIError { | APIError::InvalidBackupPath | APIError::InvalidBiscuitToken | APIError::InvalidChannelID + | APIError::InvalidConsignment | APIError::InvalidContractLink(_) | APIError::InvalidRightOutpoint(_) | APIError::InvalidDescription(_) @@ -601,7 +633,6 @@ impl IntoResponse for APIError { APIError::AllocationsAlreadyAvailable | APIError::AlreadyInitialized | APIError::AlreadyUnlocked - | APIError::AmbiguousChainBackend | APIError::AuthenticationDisabled | APIError::BatchTransferNotFound | APIError::CannotCloseChannel(_) @@ -612,22 +643,19 @@ impl IntoResponse for APIError { | APIError::ExternalSignerRequiresAuthentication | APIError::DuplicatePayment(_) | APIError::FailedBdkSync(_) - | APIError::FailedBitcoindConnection(_) | APIError::FailedBroadcast(_) | APIError::FailedPeerConnection | APIError::InsufficientAssets | APIError::InsufficientCapacity(_) | APIError::InsufficientFunds(_) | APIError::InvalidIndexer(_) - | APIError::InvalidProxyEndpoint | APIError::InvalidProxyProtocol(_) | APIError::InvoiceNotHodl | APIError::InvoiceSettlingInProgress | APIError::LockedNode | APIError::MaxFeeExceeded(_) | APIError::MinFeeNotMet(_) - | APIError::MissingChainBackend - | APIError::NetworkMismatch(_, _) + | APIError::MissingIndexerUrl | APIError::NoAvailableUtxos | APIError::NoRoute | APIError::NotInitialized @@ -647,6 +675,10 @@ impl IntoResponse for APIError { | APIError::UnsupportedInExternalSignerMode(_) => { (StatusCode::FORBIDDEN, self.to_string(), self.name()) } + #[cfg(feature = "block-sync")] + APIError::FailedBitcoindConnection(_) | APIError::NetworkMismatch(_, _) => { + (StatusCode::FORBIDDEN, self.to_string(), self.name()) + } APIError::InvoiceAlreadyClaimed => { (StatusCode::CONFLICT, self.to_string(), self.name()) } diff --git a/src/ffi/types.rs b/src/ffi/types.rs index 76c16d4e..792808a7 100644 --- a/src/ffi/types.rs +++ b/src/ffi/types.rs @@ -135,7 +135,7 @@ impl UniffiCustomTypeConverter for crate::TransportEndpoint { type Builtin = String; fn into_custom(val: Self::Builtin) -> uniffi::Result { - rgb_lib::RgbTransport::from_str(&val).map_err(|e| { + rgb_lib::wallet::TransportEndpoint::new(val.clone()).map_err(|e| { crate::RlnError::InvalidRequest(format!("invalid transport endpoint: {e}")) })?; Ok(crate::TransportEndpoint(val)) diff --git a/src/gossip.rs b/src/gossip.rs index cf966c99..0cd85b70 100644 --- a/src/gossip.rs +++ b/src/gossip.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use tokio::sync::Notify; use crate::disk::FilesystemLogger; -use crate::ldk::{GossipVerifier, NetworkGraph, P2PGossipSync, RapidGossipSync}; +use crate::ldk::{NetworkGraph, P2PGossipSync, RapidGossipSync}; pub(crate) const RGS_SNAPSHOT_MAX_SIZE: usize = 15 * 1024 * 1024; pub(crate) const RGS_CONNECT_TIMEOUT_SECS: u64 = 5; @@ -56,7 +56,7 @@ pub(crate) enum GossipSource { impl GossipSource { pub(crate) fn new_p2p( network_graph: Arc, - utxo_lookup: Option>, + utxo_lookup: Option>, logger: Arc, ) -> Self { let gossip_sync = Arc::new(P2PGossipSync::new(network_graph, utxo_lookup, logger)); diff --git a/src/indexer.rs b/src/indexer.rs deleted file mode 100644 index a7602997..00000000 --- a/src/indexer.rs +++ /dev/null @@ -1,476 +0,0 @@ -use std::collections::{BTreeMap, HashMap}; -use std::io; -use std::str::FromStr; -use std::sync::atomic::{AtomicU32, Ordering}; -use std::sync::Arc; -use std::time::Duration; - -use bitcoin::blockdata::transaction::Transaction; -use bitcoin::consensus::encode; -use bitcoin::constants::ChainHash; -use bitcoin::{Network, TxOut, Txid}; -use electrum_client::{Client as ElectrumClient, ElectrumApi, Param}; -use esplora_client::blocking::BlockingClient as EsploraBlockingClient; -use esplora_client::Builder as EsploraBuilder; -use lightning::chain::chaininterface::{BroadcasterInterface, ConfirmationTarget, FeeEstimator}; -use lightning::log_warn; -use lightning::routing::utxo::{UtxoLookup, UtxoLookupError, UtxoResult}; -use lightning::util::logger::Logger; - -use crate::disk::FilesystemLogger; -#[cfg(test)] -use crate::fee_mock::mock_fee; - -pub(crate) const MIN_FEERATE: u32 = 253; - -pub(crate) fn default_fee_buckets() -> HashMap { - let mut fees = HashMap::new(); - fees.insert( - ConfirmationTarget::MaximumFeeEstimate, - AtomicU32::new(50000), - ); - fees.insert(ConfirmationTarget::UrgentOnChainSweep, AtomicU32::new(5000)); - fees.insert( - ConfirmationTarget::MinAllowedAnchorChannelRemoteFee, - AtomicU32::new(MIN_FEERATE), - ); - fees.insert( - ConfirmationTarget::MinAllowedNonAnchorChannelRemoteFee, - AtomicU32::new(MIN_FEERATE), - ); - fees.insert( - ConfirmationTarget::AnchorChannelFee, - AtomicU32::new(MIN_FEERATE), - ); - fees.insert( - ConfirmationTarget::NonAnchorChannelFee, - AtomicU32::new(2000), - ); - fees.insert( - ConfirmationTarget::ChannelCloseMinimum, - AtomicU32::new(MIN_FEERATE), - ); - fees.insert( - ConfirmationTarget::OutputSpendingFee, - AtomicU32::new(MIN_FEERATE), - ); - fees -} - -pub(crate) struct EsploraIndexerClient { - pub(crate) client: Arc, - fees: Arc>, - network: Network, - pub(crate) handle: tokio::runtime::Handle, - logger: Arc, -} - -impl EsploraIndexerClient { - pub(crate) fn new( - server_url: String, - network: Network, - handle: tokio::runtime::Handle, - logger: Arc, - timeout_secs: u64, - fee_refresh_interval_secs: u64, - ) -> io::Result { - // Bounded socket timeout so a hung endpoint doesn't block runtime shutdown. - let client = Arc::new( - EsploraBuilder::new(&server_url) - .timeout(timeout_secs) - .build_blocking(), - ); - client - .get_tip_hash() - .map_err(|e| io::Error::other(format!("failed to connect to esplora server: {e}")))?; - client - .get_height() - .map_err(|e| io::Error::other(format!("failed to query esplora tip height: {e}")))?; - let fees = Arc::new(default_fee_buckets()); - poll_esplora_fee_estimates( - fees.clone(), - client.clone(), - logger.clone(), - handle.clone(), - fee_refresh_interval_secs, - ); - Ok(Self { - client, - fees, - network, - handle, - logger, - }) - } -} - -fn poll_esplora_fee_estimates( - fees: Arc>, - client: Arc, - logger: Arc, - handle: tokio::runtime::Handle, - refresh_interval_secs: u64, -) { - handle.spawn(async move { - loop { - let res = tokio::task::spawn_blocking({ - let client = client.clone(); - move || client.get_fee_estimates() - }) - .await; - - match res { - Ok(Ok(estimate_map)) => { - let background_estimate = - estimate_fee_rate_sat_per_kw(&estimate_map, 144, MIN_FEERATE); - let normal_estimate = estimate_fee_rate_sat_per_kw(&estimate_map, 18, 2000); - let high_prio_estimate = estimate_fee_rate_sat_per_kw(&estimate_map, 6, 5000); - let very_high_prio_estimate = - estimate_fee_rate_sat_per_kw(&estimate_map, 2, 50000); - - fees.get(&ConfirmationTarget::MaximumFeeEstimate) - .unwrap() - .store(very_high_prio_estimate, Ordering::Release); - fees.get(&ConfirmationTarget::UrgentOnChainSweep) - .unwrap() - .store(high_prio_estimate, Ordering::Release); - fees.get(&ConfirmationTarget::MinAllowedAnchorChannelRemoteFee) - .unwrap() - .store(MIN_FEERATE, Ordering::Release); - fees.get(&ConfirmationTarget::MinAllowedNonAnchorChannelRemoteFee) - .unwrap() - .store(background_estimate.saturating_sub(250), Ordering::Release); - fees.get(&ConfirmationTarget::AnchorChannelFee) - .unwrap() - .store(background_estimate, Ordering::Release); - fees.get(&ConfirmationTarget::NonAnchorChannelFee) - .unwrap() - .store(normal_estimate, Ordering::Release); - fees.get(&ConfirmationTarget::ChannelCloseMinimum) - .unwrap() - .store(background_estimate, Ordering::Release); - fees.get(&ConfirmationTarget::OutputSpendingFee) - .unwrap() - .store(background_estimate, Ordering::Release); - } - Ok(Err(e)) => { - log_warn!(logger, "Error getting fee estimate from esplora: {}", e); - } - Err(e) => { - log_warn!(logger, "Error polling esplora fee estimates: {}", e); - } - } - - tokio::time::sleep(Duration::from_secs(refresh_interval_secs)).await; - } - }); -} - -pub(crate) fn estimate_fee_rate_sat_per_kw( - fee_estimates: &HashMap, - blocks: u16, - default: u32, -) -> u32 { - let Some(sat_per_vb) = interpolate_fee_rate(fee_estimates, blocks) else { - return default; - }; - std::cmp::max((sat_per_vb * 250.0).round() as u32, MIN_FEERATE) -} - -pub(crate) fn interpolate_fee_rate(fee_estimates: &HashMap, blocks: u16) -> Option { - if blocks == 0 || fee_estimates.is_empty() { - return None; - } - - let estimate_map = BTreeMap::from_iter(fee_estimates.iter().map(|(k, v)| (*k, *v))); - if let Some(estimate) = estimate_map.get(&blocks) { - return Some(*estimate); - } - - let lower_key = estimate_map.range(..blocks).next_back().map(|(k, _)| *k); - let upper_key = estimate_map.range(blocks..).next().map(|(k, _)| *k); - - match (lower_key, upper_key) { - (Some(x1), Some(x2)) if x1 != x2 => { - let y1 = estimate_map[&x1]; - let y2 = estimate_map[&x2]; - Some(y1 + (blocks as f64 - x1 as f64) / (x2 as f64 - x1 as f64) * (y2 - y1)) - } - (Some(x), _) | (_, Some(x)) => estimate_map.get(&x).copied(), - _ => None, - } -} - -impl FeeEstimator for EsploraIndexerClient { - fn get_est_sat_per_1000_weight(&self, target: ConfirmationTarget) -> u32 { - let fee = self.fees.get(&target).unwrap().load(Ordering::Acquire); - #[cfg(test)] - let fee = mock_fee(fee); - fee - } -} - -impl BroadcasterInterface for EsploraIndexerClient { - fn broadcast_transactions(&self, txs: &[&Transaction]) { - let txs = txs.iter().map(|tx| (*tx).clone()).collect::>(); - let client = self.client.clone(); - let logger = self.logger.clone(); - self.handle.spawn(async move { - let res = tokio::task::spawn_blocking(move || { - let mut last_error = None; - for tx in txs { - if let Err(e) = client.broadcast(&tx) { - last_error = Some(e.to_string()); - } - } - last_error.map_or(Ok(()), Err) - }) - .await; - match res { - Ok(Ok(())) => {} - Ok(Err(e)) => { - log_warn!(logger, "esplora broadcast failed: {}", e); - } - Err(e) => { - log_warn!(logger, "esplora broadcast task spawn failed: {}", e); - } - } - }); - } -} - -impl EsploraIndexerClient { - pub(crate) fn lookup_utxo( - &self, - chain_hash: ChainHash, - short_channel_id: u64, - ) -> Result { - if chain_hash != ChainHash::using_genesis_block(self.network) { - return Err(UtxoLookupError::UnknownChain); - } - let height = (short_channel_id >> 40) as u32; - let tx_index = ((short_channel_id >> 16) & 0x00ff_ffff) as usize; - let vout = (short_channel_id & 0xffff) as usize; - let txout = self - .client - .get_block_hash(height) - .and_then(|block_hash| self.client.get_txid_at_block_index(&block_hash, tx_index)) - .and_then(|txid| match txid { - Some(txid) => self.client.get_tx_no_opt(&txid).map(Some), - None => Ok(None), - }) - .ok() - .flatten() - .and_then(|tx| tx.output.get(vout).cloned()); - txout.ok_or(UtxoLookupError::UnknownTx) - } -} - -impl UtxoLookup for EsploraIndexerClient { - fn get_utxo(&self, chain_hash: &ChainHash, short_channel_id: u64) -> UtxoResult { - UtxoResult::Sync(self.lookup_utxo(*chain_hash, short_channel_id)) - } -} - -pub(crate) struct ElectrumIndexerClient { - pub(crate) client: Arc, - fees: Arc>, - network: Network, - pub(crate) handle: tokio::runtime::Handle, - logger: Arc, -} - -impl ElectrumIndexerClient { - pub(crate) fn new( - server_url: String, - network: Network, - handle: tokio::runtime::Handle, - logger: Arc, - fee_refresh_interval_secs: u64, - ) -> io::Result { - let client = - Arc::new(ElectrumClient::new(&server_url).map_err(|e| { - io::Error::other(format!("failed to connect to electrum server: {e}")) - })?); - client.server_features().map_err(|e| { - io::Error::other(format!("failed to query electrum server features: {e}")) - })?; - let fees = Arc::new(default_fee_buckets()); - poll_electrum_fee_estimates( - fees.clone(), - client.clone(), - logger.clone(), - handle.clone(), - fee_refresh_interval_secs, - ); - Ok(Self { - client, - fees, - network, - handle, - logger, - }) - } - - pub(crate) fn lookup_utxo( - &self, - chain_hash: ChainHash, - short_channel_id: u64, - ) -> Result { - if chain_hash != ChainHash::using_genesis_block(self.network) { - return Err(UtxoLookupError::UnknownChain); - } - let height = (short_channel_id >> 40) as usize; - let tx_index = ((short_channel_id >> 16) & 0x00ff_ffff) as usize; - let vout = (short_channel_id & 0xffff) as usize; - let txout = electrum_txid_from_pos(&self.client, height, tx_index) - .and_then(|txid| self.client.transaction_get(&txid)) - .ok() - .and_then(|tx| tx.output.get(vout).cloned()); - txout.ok_or(UtxoLookupError::UnknownTx) - } -} - -impl FeeEstimator for ElectrumIndexerClient { - fn get_est_sat_per_1000_weight(&self, target: ConfirmationTarget) -> u32 { - let fee = self.fees.get(&target).unwrap().load(Ordering::Acquire); - #[cfg(test)] - let fee = mock_fee(fee); - fee - } -} - -impl BroadcasterInterface for ElectrumIndexerClient { - fn broadcast_transactions(&self, txs: &[&Transaction]) { - let txs = txs - .iter() - .map(|tx| encode::serialize(*tx)) - .collect::>(); - let client = self.client.clone(); - let logger = self.logger.clone(); - self.handle.spawn(async move { - let res = tokio::task::spawn_blocking(move || { - let mut last_error = None; - for tx in txs { - if let Err(e) = client.transaction_broadcast_raw(&tx) { - last_error = Some(e.to_string()); - } - } - last_error.map_or(Ok(()), Err) - }) - .await; - match res { - Ok(Ok(())) => {} - Ok(Err(e)) => { - log_warn!(logger, "electrum broadcast failed: {}", e); - } - Err(e) => { - log_warn!(logger, "electrum broadcast task spawn failed: {}", e); - } - } - }); - } -} - -impl UtxoLookup for ElectrumIndexerClient { - fn get_utxo(&self, chain_hash: &ChainHash, short_channel_id: u64) -> UtxoResult { - UtxoResult::Sync(self.lookup_utxo(*chain_hash, short_channel_id)) - } -} - -fn electrum_txid_from_pos( - client: &ElectrumClient, - height: usize, - tx_pos: usize, -) -> Result { - let value = client.raw_call( - "blockchain.transaction.id_from_pos", - [ - Param::Usize(height), - Param::Usize(tx_pos), - Param::Bool(true), - ], - )?; - let txid = value - .as_str() - .or_else(|| value.get("tx_hash").and_then(serde_json::Value::as_str)) - .or_else(|| value.get("txid").and_then(serde_json::Value::as_str)) - .or_else(|| value.get("tx_id").and_then(serde_json::Value::as_str)) - .map(str::to_owned) - .ok_or_else(|| electrum_client::Error::InvalidResponse(value.clone()))?; - Txid::from_str(&txid).map_err(|_| electrum_client::Error::InvalidResponse(value)) -} - -fn poll_electrum_fee_estimates( - fees: Arc>, - client: Arc, - logger: Arc, - handle: tokio::runtime::Handle, - refresh_interval_secs: u64, -) { - handle.spawn(async move { - loop { - let res = tokio::task::spawn_blocking({ - let client = client.clone(); - move || { - Ok::<_, electrum_client::Error>(( - client.estimate_fee(144)?, - client.estimate_fee(18)?, - client.estimate_fee(6)?, - client.estimate_fee(2)?, - )) - } - }) - .await; - match res { - Ok(Ok((bg, normal, high, very_high))) => { - let bg_e = fee_rate_from_btc_per_kb(bg, MIN_FEERATE).unwrap_or(MIN_FEERATE); - let normal_e = fee_rate_from_btc_per_kb(normal, 2000).unwrap_or(2000); - let high_e = fee_rate_from_btc_per_kb(high, 5000).unwrap_or(5000); - let vhigh_e = fee_rate_from_btc_per_kb(very_high, 50000).unwrap_or(50000); - fees.get(&ConfirmationTarget::MaximumFeeEstimate) - .unwrap() - .store(vhigh_e, Ordering::Release); - fees.get(&ConfirmationTarget::UrgentOnChainSweep) - .unwrap() - .store(high_e, Ordering::Release); - fees.get(&ConfirmationTarget::MinAllowedAnchorChannelRemoteFee) - .unwrap() - .store(MIN_FEERATE, Ordering::Release); - fees.get(&ConfirmationTarget::MinAllowedNonAnchorChannelRemoteFee) - .unwrap() - .store(bg_e.saturating_sub(250), Ordering::Release); - fees.get(&ConfirmationTarget::AnchorChannelFee) - .unwrap() - .store(bg_e, Ordering::Release); - fees.get(&ConfirmationTarget::NonAnchorChannelFee) - .unwrap() - .store(normal_e, Ordering::Release); - fees.get(&ConfirmationTarget::ChannelCloseMinimum) - .unwrap() - .store(bg_e, Ordering::Release); - fees.get(&ConfirmationTarget::OutputSpendingFee) - .unwrap() - .store(bg_e, Ordering::Release); - } - Ok(Err(e)) => { - log_warn!(logger, "Error getting fee estimate from electrum: {}", e); - } - Err(e) => { - log_warn!(logger, "Error polling electrum fee estimates: {}", e); - } - } - tokio::time::sleep(Duration::from_secs(refresh_interval_secs)).await; - } - }); -} - -fn fee_rate_from_btc_per_kb(feerate_btc_per_kb: f64, default: u32) -> Option { - if !feerate_btc_per_kb.is_finite() || feerate_btc_per_kb.is_sign_negative() { - return Some(default); - } - Some(std::cmp::max( - (feerate_btc_per_kb * 100_000_000.0 / 4.0).round() as u32, - MIN_FEERATE, - )) -} diff --git a/src/ldk.rs b/src/ldk.rs index 005b8eeb..5fabb26c 100644 --- a/src/ldk.rs +++ b/src/ldk.rs @@ -20,8 +20,10 @@ use bitcoin::{io, Amount, Network}; use bitcoin::{BlockHash, TxOut}; use bitcoin_bech32::WitnessProgram; use hex::DisplayHex; +#[cfg(feature = "transaction-sync")] +use lightning::chain::Confirm; use lightning::chain::{chainmonitor, transaction::OutPoint, ChannelMonitorUpdateStatus}; -use lightning::chain::{BestBlock, Confirm, Filter}; +use lightning::chain::{BestBlock, Filter}; use lightning::events::bump_transaction::{BumpTransactionEventHandler, Wallet}; use lightning::events::{Event, PaymentFailureReason, PaymentPurpose, ReplayEvent}; use lightning::ln::channel_state::ChannelDetails; @@ -42,13 +44,16 @@ use lightning::rgb_utils::{ RgbKvStoreExt, RGB_COMMITMENT_FASCIA_NS, RGB_PAYMENT_INFO_INBOUND_NS, RGB_PAYMENT_INFO_OUTBOUND_NS, RGB_PRIMARY_NS, }; -use lightning::rgb_utils::{RgbPaymentInfo, STATIC_BLINDING}; +use lightning::rgb_utils::{RgbPaymentInfo, TransferInfo, STATIC_BLINDING}; use lightning::routing::gossip; use lightning::routing::gossip::NodeId; use lightning::routing::router::DefaultRouter; use lightning::routing::scoring::{ProbabilisticScorer, ProbabilisticScoringFeeParameters}; +use lightning::routing::utxo::UtxoLookup; use lightning::sign::{KeysManager, OutputSpender, SpendableOutputDescriptor}; // Used by the non-VSS ChainMonitor encryptor closure and the signer unit tests. +#[cfg(feature = "block-sync")] +use lightning::chain; #[cfg(feature = "vss")] use lightning::chain::chainmonitor::AsyncPersister; #[cfg(any(not(feature = "vss"), test))] @@ -74,17 +79,13 @@ use lightning::util::persist::{ }; use lightning::util::ser::{Readable, ReadableArgs, Writeable}; use lightning::util::sweep as ldk_sweep; -use lightning::{chain, impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; +use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; use lightning_background_processor::{process_events_async, NO_LIQUIDITY_MANAGER}; -use lightning_block_sync::gossip::TokioSpawner; -use lightning_block_sync::init; -use lightning_block_sync::poll; -use lightning_block_sync::SpvClient; -use lightning_block_sync::UnboundedCache; +#[cfg(feature = "block-sync")] +use lightning_block_sync::{init, poll, SpvClient, UnboundedCache}; use lightning_dns_resolver::OMDomainResolver; use lightning_invoice::{Bolt11InvoiceDescription, PaymentSecret}; use lightning_net_tokio::SocketDescriptor; -use lightning_transaction_sync::{ElectrumSyncClient, EsploraSyncClient}; use rand::RngCore; use rgb_lib::{ bdk_wallet::keys::{DerivableKey, ExtendedKey}, @@ -98,8 +99,8 @@ use rgb_lib::{ utils::{get_account_data, recipient_id_from_script_buf, script_buf_from_recipient_id}, wallet::{ rust_only::{check_indexer_url, AssetColoringInfo, ColoringInfo}, - DatabaseType, OnlineOptions, Recipient, SinglesigKeys, TransportEndpoint, - Wallet as RgbLibWallet, WalletData, WitnessData, + DatabaseType, OnlineOptions, Recipient, SinglesigKeys, Wallet as RgbLibWallet, WalletData, + WitnessData, }, AssetSchema, Assignment, BitcoinNetwork, ConsignmentExt, ContractId, Error as RgbLibError, Fascia, FileContent, RgbTransfer, RgbTxid, TransferStatus, WitnessOrd, @@ -113,25 +114,27 @@ use std::net::ToSocketAddrs; use std::net::{SocketAddr, TcpListener}; use std::path::{Path, PathBuf}; use std::str::FromStr; +#[cfg(test)] +use std::sync::atomic::AtomicUsize; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, MutexGuard, RwLock, Weak}; +#[cfg(any(test, feature = "vss"))] +use std::time::Instant; use std::time::{Duration, SystemTime}; use time::OffsetDateTime; use tokio::runtime::Handle; use tokio::sync::watch::Sender; use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; #[cfg(feature = "vss")] use crate::async_kv_store::RemoteFirstKvStore; -use crate::bitcoind::BitcoindClient; -use crate::chain_backend::ChainBackend; use crate::core_types::{ - HTLCStatus, NodeKeySource, SwapStatus, UnlockRequest, PENDING_SWAP_TIMEOUT_SECS, + HTLCStatus, LdkChainSync, NodeKeySource, SwapStatus, UnlockRequest, PENDING_SWAP_TIMEOUT_SECS, }; use crate::database::RlnDatabase; use crate::disk::{self, FilesystemLogger}; use crate::gossip::{GossipSource, GossipSourceConfig}; -use crate::indexer::{ElectrumIndexerClient, EsploraIndexerClient}; pub(crate) const INBOUND_PAYMENTS_KEY: &str = "inbound_payments"; const OUTBOUND_PAYMENTS_KEY: &str = "outbound_payments"; @@ -158,10 +161,22 @@ const CONFIG_WALLET_MASTER_FINGERPRINT: &str = "wallet_master_fingerprint"; const VIRTUAL_CHANNEL_DRAFTS_KEY: &str = "virtual_channel_drafts"; const VIRTUAL_CHANNEL_SESSIONS_KEY: &str = "virtual_channel_sessions"; use crate::error::APIError; +#[cfg(feature = "block-sync")] +use crate::ldk_chain_backend::block_sync::{BitcoindClient, BlockSyncGossipVerifier}; +#[cfg(feature = "transaction-sync")] +use crate::ldk_chain_backend::sync_chain_data; +#[cfg(feature = "transaction-sync")] +use crate::ldk_chain_backend::transaction_sync::{ + IndexerClient, IndexerGossipVerifier, IndexerSyncClient, +}; +use crate::ldk_chain_backend::{ChainBackend, ChainSetup, DynBroadcaster, DynFeeEstimator}; use crate::rgb::{ check_rgb_proxy_endpoint, get_rgb_channel_info_optional, RgbBumpWalletSource, RgbLibWalletWrapper, }; +use crate::rgb_file_transfer::{ + PeerChannelGate, RgbFileTransferHandler, REASSEMBLY_SWEEP_INTERVAL, +}; use crate::signer::vls_adapter::{ExternalSignerBackend, VlsSignerAdapter}; use crate::signer::{ read_key_source_file, validate_bootstrap_payload, validate_key_source_matches_bootstrap, @@ -176,13 +191,31 @@ use crate::utils::{ check_port_is_available, connect_peer_if_necessary, description_from_invoice, description_hash_from_invoice, do_connect_peer, get_current_timestamp, get_max_local_rgb_amount, hex_str, validate_and_parse_payment_hash, - validate_and_parse_payment_preimage, AppState, StaticState, UnlockedAppState, - ELECTRUM_URL_MAINNET, ELECTRUM_URL_REGTEST, ELECTRUM_URL_SIGNET, ELECTRUM_URL_TESTNET, - ELECTRUM_URL_TESTNET4, PROXY_ENDPOINT_LOCAL, PROXY_ENDPOINT_PUBLIC, + validate_and_parse_payment_preimage, AppState, StaticState, UnlockedAppState, FATAL_ERROR, + PROXY_ENDPOINT_LOCAL, PROXY_ENDPOINT_PUBLIC, }; +const RGB_TRANSFER_CHAN_EXPIRATION_SECS: u64 = 86400; +// don't reuse a cached sweep receive this close to its expiration +const RGB_RECEIVE_REUSE_MARGIN_SECS: u64 = 3600; +// smaller margin when addresses are reused, where reissuing is harmful: still enough for the +// receive to outlast the sweep that uses it +const RGB_RECEIVE_REUSE_MARGIN_ADDR_REUSE_SECS: u64 = 300; const VIRTUAL_CHANNEL_DOMAIN_SEPARATOR: &[u8] = b"rln_virtual_channels_v0"; +// A reissued receive under address reuse gets the same recipient id as the cached one (rgb-lib +// rotates only the invoice nonce), so the sweep's provide_out_of_band_consignment later fails with +// an ambiguous-recipient error. Hold the cached entry longer in that case, keeping enough margin +// for it to outlast the sweep it is used by. +fn sweep_receive_is_reusable(now: u64, expiration: u64, reuse_addresses: bool) -> bool { + let margin = if reuse_addresses { + RGB_RECEIVE_REUSE_MARGIN_ADDR_REUSE_SECS + } else { + RGB_RECEIVE_REUSE_MARGIN_SECS + }; + now + margin < expiration +} + pub(crate) fn virtual_channel_synthetic_outpoint( network: BitcoinNetwork, local_node_id: &PublicKey, @@ -261,9 +294,54 @@ fn sync_config_to_kvstore( Ok(()) } +// Test-only: while set, the node with this pubkey defers claiming incoming payments; handling of +// the PaymentClaimable event is suspended until the gate is cleared +#[cfg(test)] +pub(crate) static DEFER_PAYMENT_CLAIMABLE_ON_NODE: Mutex> = Mutex::new(None); + +// Test-only: whether a payment has been deferred via DEFER_PAYMENT_CLAIMABLE_ON_NODE since the +// gate was set. This is a flag rather than a count because a node handles its events sequentially: +// while a PaymentClaimable is being deferred no further event is handled, so at most one payment +// can be deferred at a time +#[cfg(test)] +pub(crate) static PAYMENT_CLAIMABLE_DEFERRED: AtomicBool = AtomicBool::new(false); + +// Test-only: a payment is never deferred for longer than this, so that a test failing to release +// the gate fails on its own assertions instead of hanging the node's event handling +#[cfg(test)] +const MAX_PAYMENT_DEFERRAL: Duration = Duration::from_secs(60); + #[cfg(test)] pub(crate) static IGNORE_INBOUND_CHANNELS_ON_NODE: Mutex> = Mutex::new(None); +// Test-only: the node with this pubkey holds incoming payments instead of claiming them, keeping +// their HTLCs pending +#[cfg(test)] +pub(crate) static HOLD_PAYMENT_CLAIMABLE_ON_NODE: Mutex> = Mutex::new(None); + +// Test-only: number of payments held via HOLD_PAYMENT_CLAIMABLE_ON_NODE +#[cfg(test)] +pub(crate) static HELD_PAYMENT_CLAIMABLE_COUNT: AtomicUsize = AtomicUsize::new(0); + +// Test-only: the node with this pubkey emits a `push_asset_amount` greater than the channel asset +// amount on the wire in `open_channel`, regardless of the value validated by its REST layer. Used +// to model a channel counterparty whose wire client is not bound by the sender-side clamp. +#[cfg(test)] +pub(crate) static FORCE_PUSH_ASSET_AMOUNT_ON_NODE: Mutex> = Mutex::new(None); + +// Test-only: whether the given override targets the node we are running as +#[cfg(test)] +pub(crate) fn node_override_matches( + target: &Mutex>, + our_node_id: PublicKey, +) -> bool { + target + .lock() + .unwrap() + .as_ref() + .is_some_and(|id| *id == our_node_id) +} + pub(crate) struct LdkBackgroundServices { stop_processing: Arc, gossip_shutdown: Arc, @@ -1045,8 +1123,8 @@ pub(crate) type MonitorPersister = AsyncPersister< Arc, ActiveSignerRef, ActiveSignerRef, - Arc, - Arc, + Arc, + Arc, >; #[cfg(not(feature = "vss"))] @@ -1056,27 +1134,21 @@ pub(crate) type MonitorPersister = Arc< Arc, ActiveSignerRef, ActiveSignerRef, - Arc, - Arc, + Arc, + Arc, >, >; pub(crate) type ChainMonitor = chainmonitor::ChainMonitor< DynRlnChannelSigner, Arc, - Arc, - Arc, + Arc, + Arc, Arc, MonitorPersister, ActiveSignerRef, >; -pub(crate) type GossipVerifier = lightning_block_sync::gossip::GossipVerifier< - TokioSpawner, - Arc, - Arc, ->; - pub(crate) type RoutingMessageHandler = dyn lightning::ln::msgs::RoutingMessageHandler + Send + Sync; @@ -1104,11 +1176,11 @@ pub(crate) type Router = DefaultRouter< pub(crate) type ChannelManager = channelmanager::ChannelManager< Arc, - Arc, + Arc, Arc, ActiveSignerRef, ActiveSignerRef, - Arc, + Arc, Arc, Arc< DefaultMessageRouter, Arc, Arc>, @@ -1116,11 +1188,26 @@ pub(crate) type ChannelManager = channelmanager::ChannelManager< Arc, >; +impl PeerChannelGate for ChannelManager { + fn channel_count_with(&self, peer: &PublicKey) -> usize { + // unlike list_channels, this doesn't filter out unfunded channels + self.list_channels_with_counterparty(peer).len() + } + + fn has_channel_funded_by(&self, funding_txid: &str) -> bool { + self.list_channels().iter().any(|chan| { + chan.funding_txo + .is_some_and(|txo| txo.txid.to_string() == funding_txid) + }) + } +} + pub(crate) type NetworkGraph = gossip::NetworkGraph>; +// the UTXO lookup is a trait object so a single gossip type serves both sync backends pub(crate) type P2PGossipSync = lightning::routing::gossip::P2PGossipSync< Arc, - Arc, + Arc, Arc, >; @@ -1131,7 +1218,7 @@ pub(crate) type GossipSync = lightning_background_processor::GossipSync< Arc, Arc, Arc, - Arc, + Arc, Arc, >; @@ -1150,13 +1237,15 @@ pub(crate) type OnionMessenger = LdkOnionMessenger< >; pub(crate) type BumpTxEventHandler = BumpTransactionEventHandler< - Arc, + Arc, Arc, Arc>>, ActiveSignerRef, Arc, >; pub(crate) type OutputSpenderTxes = LdkHashMap; +// (descriptors hash, contract) -> (recipient id, expiration) +type SweepRecipients = HashMap<(u64, ContractId), (String, u64)>; pub(crate) struct RgbOutputSpender { static_state: Arc, @@ -1164,7 +1253,9 @@ pub(crate) struct RgbOutputSpender { signer: Arc>, kv_store: Arc, txes: Arc>, - proxy_endpoint: String, + // receives issued for an in-flight sweep, reused across retries so a repeatedly failing sweep + // does not leave a new receive slot behind on every attempt + sweep_recipients: Arc>, } // The sweeper store type is shared with the background processor's persister @@ -1175,9 +1266,9 @@ pub(crate) type BpKvStore = Arc; pub(crate) type BpKvStore = KVStoreSyncWrapper>; pub(crate) type OutputSweeper = ldk_sweep::OutputSweeper< - Arc, + Arc, Arc, - Arc, + Arc, Arc, BpKvStore, Arc, @@ -1760,20 +1851,34 @@ fn handle_funding_prepare_err( tracing::error!("Network error during channel opening: {details}"); Err(ReplayEvent()) } - e => { - tracing::error!("Cannot open channel: {e}"); - if let Err(close_err) = channel_manager.force_close_broadcasting_latest_txn( - temporary_channel_id, - counterparty_node_id, - e.to_string(), - ) { - tracing::error!( - "Failed to force-close channel {temporary_channel_id} after error: {close_err:?}" - ); - } - Ok(()) - } + e => abort_funding( + e.to_string(), + channel_manager, + temporary_channel_id, + counterparty_node_id, + ), + } +} + +// Give up on a channel funding for a reason retrying cannot fix, closing the channel rather than +// leaving the peer waiting on a funding that will never come. +fn abort_funding( + reason: String, + channel_manager: &ChannelManager, + temporary_channel_id: &ChannelId, + counterparty_node_id: &PublicKey, +) -> Result<(), ReplayEvent> { + tracing::error!("Cannot open channel: {reason}"); + if let Err(close_err) = channel_manager.force_close_broadcasting_latest_txn( + temporary_channel_id, + counterparty_node_id, + reason, + ) { + tracing::error!( + "Failed to abort funding by force-closing the channel {temporary_channel_id} after error: {close_err:?}" + ); } + Ok(()) } /// Release the funds locked for a channel open that failed before the funding @@ -1997,41 +2102,58 @@ async fn handle_ldk_events( }]}; let fee_rate_sat_vb = unlocked_state.config.rgb.fee_rate_sat_vb; let unlocked_state_copy = unlocked_state.clone(); - let res = tokio::task::spawn_blocking(move || -> Result { - let res = unlocked_state_copy - .rgb_send_begin( - recipient_map, - true, - fee_rate_sat_vb, - 0, - None, - false, - Some(0), - ) - .map_err(|e| e.to_string())?; - let fascia_str = fs::read_to_string(&res.details.fascia_path) - .map_err(|e| e.to_string())?; - let fascia: Fascia = - serde_json::from_str(&fascia_str).map_err(|e| e.to_string())?; - unlocked_state_copy - .rgb_consume_fascia(fascia, None) - .map_err(|e| e.to_string())?; - unlocked_state_copy - .rgb_create_consignments(res.psbt.clone()) - .map_err(|e| e.to_string())?; - Ok(res.psbt) - }) + let res = tokio::task::spawn_blocking( + move || -> Result<(String, Option), String> { + let res = unlocked_state_copy + .rgb_send_begin( + recipient_map, + true, + fee_rate_sat_vb, + 0, + get_current_timestamp() + RGB_TRANSFER_CHAN_EXPIRATION_SECS, + false, + Some(0), + ) + .map_err(|e| e.to_string())?; + let fascia_str = fs::read_to_string(&res.details.fascia_path) + .map_err(|e| e.to_string())?; + let fascia: Fascia = + serde_json::from_str(&fascia_str).map_err(|e| e.to_string())?; + unlocked_state_copy + .rgb_consume_fascia(fascia, None) + .map_err(|e| e.to_string())?; + unlocked_state_copy + .rgb_create_consignments(res.psbt.clone()) + .map_err(|e| e.to_string())?; + Ok((res.psbt, res.batch_transfer_idx)) + }, + ) .await .unwrap(); - let unsigned_psbt = match res { - Ok(psbt) => psbt, + let (unsigned_psbt, batch_transfer_idx) = match res { + Ok(result) => result, Err(e) => { tracing::error!("cannot prepare virtual funding transfer: {e}"); return Err(ReplayEvent()); } }; + // Record the batch transfer index so a failed open can fail the pending + // transfer and release the locked assets (see handle_open_chan_fail). + if let Some(mut rgb_info) = get_rgb_channel_info_optional( + &temporary_channel_id, + true, + unlocked_state.kv_store.as_ref(), + ) { + rgb_info.batch_transfer_idx = batch_transfer_idx; + unlocked_state.kv_store.write_rgb_channel_info( + &temporary_channel_id.0.as_hex().to_string(), + &rgb_info, + true, + ); + } + let signed_psbt = match unlocked_state.rgb_sign_psbt(unsigned_psbt) { Ok(psbt) => psbt, Err(e) => { @@ -2106,27 +2228,94 @@ async fn handle_ldk_events( let consignment_path = unlocked_state.rgb_get_send_consignment_path(&asset_id, &witness_id); - let proxy_url = TransportEndpoint::new(unlocked_state.proxy_endpoint.clone()) - .unwrap() - .endpoint; - let consignment_path_copy = consignment_path.clone(); - let unlocked_state_copy = unlocked_state.clone(); - let res = tokio::task::spawn_blocking(move || { - unlocked_state_copy.rgb_post_consignment( - &proxy_url, + let consignment_bytes = match fs::read(&consignment_path) { + Ok(bytes) => bytes, + Err(e) => { + return abort_funding( + format!("cannot read funding consignment: {e}"), + &unlocked_state.channel_manager, + &temporary_channel_id, + &counterparty_node_id, + ); + } + }; + if unlocked_state + .rgb_file_transfer_handler + .queue_consignment( + counterparty_node_id, witness_id.clone(), - &consignment_path_copy, - witness_id, - None, + consignment_bytes, ) - }) - .await - .unwrap(); + .is_err() + { + let _ = fs::remove_file(&consignment_path); + return abort_funding( + s!("consignment is too large to send over p2p"), + &unlocked_state.channel_manager, + &temporary_channel_id, + &counterparty_node_id, + ); + } - if let Err(e) = res { - tracing::error!("cannot post virtual funding consignment: {e}"); - return Err(ReplayEvent()); + // send the asset's media files over the same p2p link + if rgb_info.counterparty_knows_asset { + tracing::info!( + "counterparty already knows asset {asset_id}, not sending its media" + ); + } else { + let unlocked_state_copy = unlocked_state.clone(); + let medias = match tokio::task::spawn_blocking(move || { + unlocked_state_copy.rgb_list_asset_media(asset_id) + }) + .await + .unwrap() + { + Ok(medias) => medias, + Err(e) => { + let _ = fs::remove_file(&consignment_path); + return handle_funding_prepare_err( + e, + &unlocked_state.channel_manager, + &temporary_channel_id, + &counterparty_node_id, + ); + } + }; + for media in medias { + let media_bytes = match fs::read(&media.file_path) { + Ok(bytes) => bytes, + Err(e) => { + let _ = fs::remove_file(&consignment_path); + return abort_funding( + format!("cannot read asset media file: {e}"), + &unlocked_state.channel_manager, + &temporary_channel_id, + &counterparty_node_id, + ); + } + }; + if unlocked_state + .rgb_file_transfer_handler + .queue_media( + counterparty_node_id, + witness_id.clone(), + media.digest, + media_bytes, + ) + .is_err() + { + let _ = fs::remove_file(&consignment_path); + return abort_funding( + s!("asset media is too large to send over p2p"), + &unlocked_state.channel_manager, + &temporary_channel_id, + &counterparty_node_id, + ); + } + } } + + unlocked_state.peer_manager.process_events(); let _ = fs::remove_file(&consignment_path); } @@ -2208,7 +2397,7 @@ async fn handle_ldk_events( blinding: Some(STATIC_BLINDING), }), assignment, - transport_endpoints: vec![unlocked_state.proxy_endpoint.clone()] + transport_endpoints: vec![] }]}; let fee_rate_sat_vb = unlocked_state.config.rgb.fee_rate_sat_vb; @@ -2221,7 +2410,7 @@ async fn handle_ldk_events( true, fee_rate_sat_vb, min_channel_confirmations, - None, + get_current_timestamp() + RGB_TRANSFER_CHAN_EXPIRATION_SECS, false, // Final locktime: this colored tx funds an LN channel. Some(0), @@ -2387,41 +2576,110 @@ async fn handle_ldk_events( .await .unwrap(); + // send the consignment to the channel counterparty over the encrypted p2p link let consignment_path = unlocked_state.rgb_get_send_consignment_path(&asset_id, &funding_txid_str); - match fs::read(&consignment_path) { - Ok(data) => unlocked_state - .kv_store - .write(FUNDING_CONSIGNMENT_NAMESPACE, "", &funding_txid_str, data) - .unwrap(), - Err(e) => tracing::error!("cannot store funding consignment: {e}"), + let consignment_bytes = match fs::read(&consignment_path) { + Ok(data) => data, + Err(e) => { + return abort_funding( + format!("cannot read funding consignment: {e}"), + &unlocked_state.channel_manager, + &temporary_channel_id, + &counterparty_node_id, + ); + } + }; + if let Err(e) = unlocked_state.kv_store.write( + FUNDING_CONSIGNMENT_NAMESPACE, + "", + &funding_txid_str, + consignment_bytes.clone(), + ) { + tracing::error!("cannot store funding consignment: {e}"); } - let proxy_url = TransportEndpoint::new(unlocked_state.proxy_endpoint.clone()) - .unwrap() - .endpoint; - let consignment_path_copy = consignment_path.clone(); - let unlocked_state_copy = unlocked_state.clone(); - let res = tokio::task::spawn_blocking(move || { - unlocked_state_copy.rgb_post_consignment( - &proxy_url, + if unlocked_state + .rgb_file_transfer_handler + .queue_consignment( + counterparty_node_id, funding_txid_str.clone(), - &consignment_path_copy, - funding_txid_str, - None, + consignment_bytes, ) - }) - .await - .unwrap(); - - if let Err(e) = res { - tracing::error!("cannot post consignment: {e}"); - return Err(ReplayEvent()); + .is_err() + { + return abort_funding( + s!("consignment is too large to send over p2p"), + &unlocked_state.channel_manager, + &temporary_channel_id, + &counterparty_node_id, + ); } tracing::debug!( asset_id, consignment_path = %consignment_path.display(), "Preserving consignment_out for rgb_send_end" ); + + // send the asset's media files over the same p2p link + let rgb_info = get_rgb_channel_info_pending( + &temporary_channel_id, + unlocked_state.kv_store.as_ref(), + ); + if rgb_info.counterparty_knows_asset { + tracing::info!( + "counterparty already knows asset {asset_id}, not sending its media" + ); + } else { + let unlocked_state_copy = unlocked_state.clone(); + let medias = match tokio::task::spawn_blocking(move || { + unlocked_state_copy.rgb_list_asset_media(asset_id) + }) + .await + .unwrap() + { + Ok(medias) => medias, + Err(e) => { + return handle_funding_prepare_err( + e, + &unlocked_state.channel_manager, + &temporary_channel_id, + &counterparty_node_id, + ); + } + }; + for media in medias { + let media_bytes = match fs::read(&media.file_path) { + Ok(bytes) => bytes, + Err(e) => { + return abort_funding( + format!("cannot read asset media file: {e}"), + &unlocked_state.channel_manager, + &temporary_channel_id, + &counterparty_node_id, + ); + } + }; + if unlocked_state + .rgb_file_transfer_handler + .queue_media( + counterparty_node_id, + funding_txid_str.clone(), + media.digest, + media_bytes, + ) + .is_err() + { + return abort_funding( + s!("asset media is too large to send over p2p"), + &unlocked_state.channel_manager, + &temporary_channel_id, + &counterparty_node_id, + ); + } + } + } + + unlocked_state.peer_manager.process_events(); } let channel_manager_copy = unlocked_state.channel_manager.clone(); @@ -2459,6 +2717,33 @@ async fn handle_ldk_events( payment_hash, amount_msat, ); + #[cfg(test)] + if node_override_matches( + &HOLD_PAYMENT_CLAIMABLE_ON_NODE, + unlocked_state.channel_manager.get_our_node_id(), + ) { + tracing::info!("TEST: holding PaymentClaimable for {}", payment_hash); + HELD_PAYMENT_CLAIMABLE_COUNT.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + return Ok(()); + } + #[cfg(test)] + { + let our_node_id = unlocked_state.channel_manager.get_our_node_id(); + if node_override_matches(&DEFER_PAYMENT_CLAIMABLE_ON_NODE, our_node_id) { + tracing::info!("TEST: deferring PaymentClaimable for {}", payment_hash); + PAYMENT_CLAIMABLE_DEFERRED.store(true, Ordering::SeqCst); + let deferred_at = Instant::now(); + while node_override_matches(&DEFER_PAYMENT_CLAIMABLE_ON_NODE, our_node_id) { + if deferred_at.elapsed() > MAX_PAYMENT_DEFERRAL { + panic!( + "TEST: PaymentClaimable for {payment_hash} deferred for too long" + ) + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + tracing::info!("TEST: resuming PaymentClaimable for {}", payment_hash); + } + } // `color_commitment` writes the authoritative per-HTLC record under // `chan_id || payment_hash` but never under the bare `` key — that would @@ -2782,12 +3067,10 @@ async fn handle_ldk_events( .. } => { #[cfg(test)] - if IGNORE_INBOUND_CHANNELS_ON_NODE - .lock() - .unwrap() - .as_ref() - .is_some_and(|id| *id == unlocked_state.channel_manager.get_our_node_id()) - { + if node_override_matches( + &IGNORE_INBOUND_CHANNELS_ON_NODE, + unlocked_state.channel_manager.get_our_node_id(), + ) { tracing::info!( "TEST: ignoring inbound channel {} from {}", temporary_channel_id, @@ -3087,6 +3370,10 @@ async fn handle_ldk_events( "EVENT: virtual channel {} is pending in trusted no-broadcast mode", channel_id, ); + // reclaim the staged-funding slot now instead of waiting for the sweeper + unlocked_state + .rgb_file_transfer_handler + .forget_staged_funding(&funding_txo.txid.to_string()); return Ok(()); } @@ -3109,7 +3396,11 @@ async fn handle_ldk_events( let join_result = tokio::task::spawn_blocking(move || { if is_chan_colored { - state_copy.rgb_send_end(psbt_str_copy).map(|r| r.txid) + // the consignment already went to the peer over p2p at funding time, + // so only the local DB bookkeeping is left to do here + state_copy + .rgb_send_end_db_update_only(psbt_str_copy) + .map(|r| r.txid) } else { state_copy.rgb_send_btc_end(psbt_str_copy) } @@ -3161,11 +3452,16 @@ async fn handle_ldk_events( .kv_store .remove_rgb_consignment(&funding_txid); - match unlocked_state.rgb_save_new_asset(consignment, funding_txid) { + match unlocked_state.rgb_save_new_asset(consignment, funding_txid.clone()) { Ok(_) => {} Err(e) if e.to_string().contains("UNIQUE constraint failed") => {} Err(e) => panic!("Failed saving asset: {e}"), } + + // the consignment record can stop counting against the node-wide cap + unlocked_state + .rgb_file_transfer_handler + .forget_staged_funding(&funding_txid); } Err(e) => panic!("Failed to read PSBT from KVStore: {e}"), } @@ -3196,7 +3492,7 @@ async fn handle_ldk_events( user_channel_id: _, counterparty_node_id, channel_capacity_sats: _, - channel_funding_txo: _, + channel_funding_txo, last_local_balance_msat: _, } => { tracing::info!( @@ -3208,6 +3504,18 @@ async fn handle_ldk_events( reason ); + // we can drop the funding consignment now that the channel has been closed + if let Some(funding_txo) = channel_funding_txo { + let funding_txid = funding_txo.txid.to_string(); + unlocked_state + .kv_store + .remove_rgb_consignment(&funding_txid); + // drop the in-memory record too, so it stops counting against the node-wide cap + unlocked_state + .rgb_file_transfer_handler + .forget_staged_funding(&funding_txid); + } + // Release any funds locked for a funding tx that was never broadcast. handle_open_chan_fail(&channel_id, unlocked_state.clone()).await; @@ -3492,8 +3800,25 @@ async fn handle_ldk_events( Ok(()) } -impl OutputSpender for RgbOutputSpender { - fn spend_spendable_outputs( +// Resolves the RGB amount a spendable output carries. An empty map is a truly vanilla tx (0); +// a non-empty map that lacks the output is an invariant violation and must error so the sweep +// retries rather than paying the colored output out as vanilla BTC, stranding the allocation. +fn rgb_amount_for_spendable_output( + output_map: &HashMap, + vout: u32, + txid: &str, +) -> Result { + match output_map.get(&vout) { + Some(amt) => Ok(*amt), + None if output_map.is_empty() => Ok(0), + None => Err(format!( + "spendable output {txid}:{vout} absent from a non-empty transfer info map" + )), + } +} + +impl RgbOutputSpender { + fn try_spend_spendable_outputs( &self, descriptors: &[&SpendableOutputDescriptor], outputs: Vec, @@ -3501,11 +3826,11 @@ impl OutputSpender for RgbOutputSpender { feerate_sat_per_1000_weight: u32, locktime: Option, secp_ctx: &Secp256k1, - ) -> Result { + ) -> Result { let mut hasher = DefaultHasher::new(); descriptors.hash(&mut hasher); let descriptors_hash = hasher.finish(); - let mut txes = self.txes.lock().unwrap(); + let mut txes = self.txes.lock().unwrap_or_else(|e| e.into_inner()); if let Some(tx) = txes.get(&descriptors_hash) { return Ok(tx.clone()); } @@ -3526,19 +3851,23 @@ impl OutputSpender for RgbOutputSpender { let txid = outpoint.txid; let txid_str = txid.to_string(); - let transfer_info_exists = self - .kv_store - .read( - RGB_PRIMARY_NS, - lightning::rgb_utils::RGB_TRANSFER_INFO_NS, - &txid_str, - ) - .is_ok(); - if !transfer_info_exists { + let Ok(transfer_info_bytes) = self.kv_store.read( + RGB_PRIMARY_NS, + lightning::rgb_utils::RGB_TRANSFER_INFO_NS, + &txid_str, + ) else { continue; - } - let transfer_info = self.kv_store.read_rgb_transfer_info(&txid_str); - if transfer_info.rgb_amount == 0 { + }; + // decode here rather than via read_rgb_transfer_info: that one panics, and we hold + // the txes lock, so a bad record would poison it and brick every later sweep + let transfer_info: TransferInfo = bincode::deserialize(&transfer_info_bytes) + .map_err(|e| format!("cannot decode transfer info for {txid_str}: {e}"))?; + let amt_rgb = rgb_amount_for_spendable_output( + &transfer_info.output_map, + outpoint.index.into(), + &txid_str, + )?; + if amt_rgb == 0 { continue; } @@ -3547,22 +3876,16 @@ impl OutputSpender for RgbOutputSpender { let closing_height = self .rgb_wallet_wrapper .get_tx_height(txid_str.clone()) - .map_err(|_| ())?; - let Some(closing_height) = closing_height else { - tracing::warn!( - txid = txid_str, - "closing tx not confirmed yet; deferring sweep" - ); - return Err(()); - }; + .map_err(|e| format!("cannot get height of {txid_str}: {e}"))? + .ok_or_else(|| format!("transaction {txid_str} is not confirmed yet"))?; let update_res = self .rgb_wallet_wrapper .update_witnesses(closing_height, vec![RgbTxid::from_str(&txid_str).unwrap()]) - .map_err(|e| { - tracing::error!(error = %e, txid = txid_str, "update_witnesses failed; deferring sweep"); - })?; + .map_err(|e| format!("error while updating witnesses for {txid_str}: {e}"))?; if !update_res.failed.is_empty() { - return Err(()); + return Err(format!( + "failed to update witnesses for {txid_str}: {update_res:?}" + )); } let contract_id = transfer_info.contract_id; @@ -3572,36 +3895,58 @@ impl OutputSpender for RgbOutputSpender { recipient_id.clone() } else { new_asset = true; - let receive_data = self - .rgb_wallet_wrapper - .witness_receive( - None, - Assignment::Any, - None, - vec![self.proxy_endpoint.clone()], - 0, - ) - .map_err(|e| { - tracing::error!(error = %e, "witness_receive failed; deferring sweep"); - })?; - let script_pubkey = script_buf_from_recipient_id(receive_data.recipient_id.clone()) - .map_err(|e| { - tracing::error!(error = %e, "invalid sweep recipient id; deferring sweep"); - })? - .ok_or_else(|| { - tracing::error!("sweep recipient id has no script; deferring sweep"); - })?; + let cache_key = (descriptors_hash, contract_id); + let cached = { + let mut recipients = self + .sweep_recipients + .lock() + .unwrap_or_else(|e| e.into_inner()); + match recipients.get(&cache_key) { + Some((recipient_id, expiration)) + if sweep_receive_is_reusable( + get_current_timestamp(), + *expiration, + self.static_state.reuse_addresses, + ) => + { + Some(recipient_id.clone()) + } + // too close to expiry to be used again, don't keep retrying against it + Some(_) => { + recipients.remove(&cache_key); + None + } + None => None, + } + }; + let recipient_id = match cached { + Some(recipient_id) => recipient_id, + None => { + let expiration = + get_current_timestamp() + RGB_TRANSFER_CHAN_EXPIRATION_SECS; + let receive_data = self + .rgb_wallet_wrapper + .witness_receive(None, Assignment::Any, expiration, vec![], 0) + .map_err(|e| format!("cannot get a witness receive script: {e}"))?; + self.sweep_recipients + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(cache_key, (receive_data.recipient_id.clone(), expiration)); + receive_data.recipient_id + } + }; + let script_pubkey = script_buf_from_recipient_id(recipient_id.clone()) + .map_err(|e| format!("invalid sweep recipient id: {e}"))? + .ok_or_else(|| s!("sweep recipient id has no script"))?; txouts.push(TxOut { value: Amount::from_sat( self.static_state.config.channels.dust_limit_msat / 1000, ), script_pubkey, }); - receive_data.recipient_id + recipient_id }; - let amt_rgb = transfer_info.rgb_amount; - asset_info .entry(contract_id) .and_modify(|(_, a, _)| { @@ -3615,14 +3960,17 @@ impl OutputSpender for RgbOutputSpender { } if vanilla_descriptor { - return self.signer.spend_spendable_outputs( - descriptors.as_ref(), - txouts, - change_destination_script, - feerate_sat_per_1000_weight, - locktime, - secp_ctx, - ); + return self + .signer + .spend_spendable_outputs( + descriptors.as_ref(), + txouts, + change_destination_script, + feerate_sat_per_1000_weight, + locktime, + secp_ctx, + ) + .map_err(|()| s!("cannot spend vanilla spendable outputs")); } let feerate_sat_per_1000_weight = self.static_state.config.rgb.fee_rate_sat_vb as u32 * 250; // 1 sat/vB = 250 sat/kw @@ -3635,9 +3983,7 @@ impl OutputSpender for RgbOutputSpender { feerate_sat_per_1000_weight, locktime, ) - .map_err(|_| { - tracing::error!("failed to build sweep PSBT; deferring sweep"); - })?; + .map_err(|()| s!("cannot create the spendable outputs PSBT"))?; let mut asset_info_map = map![]; for (contract_id, (vout, amt_rgb, _)) in asset_info.clone() { @@ -3656,22 +4002,18 @@ impl OutputSpender for RgbOutputSpender { nonce: None, }; - let mut psbt = RgbLibPsbt::from_str(&psbt.to_string()).unwrap(); + let mut psbt = RgbLibPsbt::from_str(&psbt.to_string()).expect("valid PSBT"); let consignments = self .rgb_wallet_wrapper .color_psbt_and_consume(&mut psbt, coloring_info) - .map_err(|e| { - tracing::error!(error = %e, "failed to color sweep PSBT; deferring sweep"); - })?; + .map_err(|e| format!("cannot color the sweep PSBT: {e}"))?; let mut psbt = Psbt::from_str(&psbt.to_string()).expect("valid transaction"); psbt = self .signer .sign_spendable_outputs_psbt(descriptors, psbt, secp_ctx) - .map_err(|e| { - tracing::error!(error = ?e, "failed to sign sweep PSBT; deferring sweep"); - })?; + .map_err(|e| format!("cannot sign the sweep PSBT: {e:?}"))?; let spending_tx = match psbt.extract_tx() { Ok(tx) => tx, @@ -3687,54 +4029,70 @@ impl OutputSpender for RgbOutputSpender { for consignment in consignments { let contract_id = consignment.contract_id(); - let (mut vout, _, recipient_id) = asset_info[&contract_id].clone(); - vout += 1; - + // persist consignment and hand it to rgb-lib (out-of-band) let consignment_path = self .static_state .ldk_data_dir - .join(format!("consignment_{}", closing_txid.clone())); + .join(format!("consignment_{closing_txid}_{contract_id}")); consignment .save_file(&consignment_path) - .expect("successful save"); - let proxy_url = TransportEndpoint::new(self.proxy_endpoint.clone()) - .unwrap() - .endpoint; + .map_err(|e| format!("cannot save consignment: {e}"))?; + let consignment_path_str = consignment_path.to_string_lossy().to_string(); let rgb_wallet_wrapper_copy = self.rgb_wallet_wrapper.clone(); - let closing_txid_copy = closing_txid.clone(); - let consignment_path_copy = consignment_path.clone(); - let res = crate::runtime::block_on(tokio::task::spawn_blocking(move || { - rgb_wallet_wrapper_copy.post_consignment( - &proxy_url, - recipient_id, - &consignment_path_copy, - closing_txid_copy, - Some(vout), - ) - })); - match res { - Ok(Ok(())) => {} - Ok(Err(e)) => { - tracing::error!("cannot post consignment: {e}"); - return Err(()); - } - Err(e) => { - tracing::error!("cannot post consignment task: {e}"); - return Err(()); - } + futures::executor::block_on(tokio::task::spawn_blocking(move || { + rgb_wallet_wrapper_copy + .provide_out_of_band_consignment(consignment_path_str, vec![]) + })) + .map_err(|e| format!("consignment task failed: {e}"))? + .map_err(|e| format!("cannot provide consignment: {e}"))?; + if let Err(e) = fs::remove_file(&consignment_path) { + tracing::warn!(error = %e, "cannot remove consignment file, leaving it behind"); } - fs::remove_file(&consignment_path).unwrap(); } + // insert so the encoded write includes this entry; roll back if the write fails, or the + // early return above would hand back a broadcast tx that was never persisted txes.insert(descriptors_hash, spending_tx.clone()); - self.kv_store + if let Err(e) = self + .kv_store .write("", "", OUTPUT_SPENDER_TXES_KEY, txes.encode()) - .unwrap(); + { + txes.remove(&descriptors_hash); + return Err(format!("cannot persist output spender txes: {e}")); + } + self.sweep_recipients + .lock() + .unwrap_or_else(|e| e.into_inner()) + .retain(|(hash, _), _| *hash != descriptors_hash); Ok(spending_tx) } } +impl OutputSpender for RgbOutputSpender { + fn spend_spendable_outputs( + &self, + descriptors: &[&SpendableOutputDescriptor], + outputs: Vec, + change_destination_script: ScriptBuf, + feerate_sat_per_1000_weight: u32, + locktime: Option, + secp_ctx: &Secp256k1, + ) -> Result { + self.try_spend_spendable_outputs( + descriptors, + outputs, + change_destination_script, + feerate_sat_per_1000_weight, + locktime, + secp_ctx, + ) + .map_err(|e| { + tracing::error!("cannot spend spendable outputs, will retry: {e}"); + }) + } +} + /// VSS identity derived from the wallet mnemonic. /// /// `signing_key` is used both for sigs-auth against the VSS server and for @@ -4001,113 +4359,35 @@ pub(crate) async fn maybe_restore_rgb_from_vss( } } -pub(crate) enum ChainBackendSelection { - Bitcoind { - username: String, - password: String, - host: String, - port: u16, - }, - Esplora { - url: String, - }, - Electrum { - url: String, - }, +// rgb-lib rejects wallets supporting IFA on mainnet +fn supported_asset_schemas(bitcoin_network: BitcoinNetwork) -> Vec { + let mut schemas = vec![AssetSchema::Nia, AssetSchema::Cfa, AssetSchema::Uda]; + if bitcoin_network != BitcoinNetwork::Mainnet { + schemas.push(AssetSchema::Ifa); + } + schemas } -pub(crate) fn select_chain_backend( - unlock_request: &UnlockRequest, - bitcoin_network: BitcoinNetwork, -) -> Result { - let bitcoind_all_set = unlock_request.bitcoind_rpc_username.is_some() - && unlock_request.bitcoind_rpc_password.is_some() - && unlock_request.bitcoind_rpc_host.is_some() - && unlock_request.bitcoind_rpc_port.is_some(); - let bitcoind_any_set = unlock_request.bitcoind_rpc_username.is_some() - || unlock_request.bitcoind_rpc_password.is_some() - || unlock_request.bitcoind_rpc_host.is_some() - || unlock_request.bitcoind_rpc_port.is_some(); - if bitcoind_any_set && !bitcoind_all_set { - return Err(APIError::InvalidIndexer(s!( - "bitcoind_rpc_* fields must all be set or all be omitted" - ))); - } - let indexer_url = unlock_request.indexer_url.as_deref(); - match (bitcoind_all_set, indexer_url) { - (true, Some(url)) => { - let proto = check_indexer_url(url, bitcoin_network) - .map_err(|e| APIError::InvalidIndexer(e.to_string()))?; - match proto { - rgb_lib::wallet::rust_only::IndexerProtocol::Esplora => { - Err(APIError::AmbiguousChainBackend) - } - rgb_lib::wallet::rust_only::IndexerProtocol::Electrum => { - Ok(ChainBackendSelection::Bitcoind { - username: unlock_request.bitcoind_rpc_username.clone().unwrap(), - password: unlock_request.bitcoind_rpc_password.clone().unwrap(), - host: unlock_request.bitcoind_rpc_host.clone().unwrap(), - port: unlock_request.bitcoind_rpc_port.unwrap(), - }) - } - } - } - (true, None) => Ok(ChainBackendSelection::Bitcoind { - username: unlock_request.bitcoind_rpc_username.clone().unwrap(), - password: unlock_request.bitcoind_rpc_password.clone().unwrap(), - host: unlock_request.bitcoind_rpc_host.clone().unwrap(), - port: unlock_request.bitcoind_rpc_port.unwrap(), - }), - (false, Some(url)) => { - let proto = check_indexer_url(url, bitcoin_network) - .map_err(|e| APIError::InvalidIndexer(e.to_string()))?; - match proto { - rgb_lib::wallet::rust_only::IndexerProtocol::Esplora => { - Ok(ChainBackendSelection::Esplora { - url: url.to_string(), - }) - } - rgb_lib::wallet::rust_only::IndexerProtocol::Electrum => { - Ok(ChainBackendSelection::Electrum { - url: url.to_string(), - }) - } - } - } - (false, None) => Err(APIError::MissingChainBackend), - } -} - -// rgb-lib rejects wallets supporting IFA on mainnet -fn supported_asset_schemas(bitcoin_network: BitcoinNetwork) -> Vec { - let mut schemas = vec![AssetSchema::Nia, AssetSchema::Cfa, AssetSchema::Uda]; - if bitcoin_network != BitcoinNetwork::Mainnet { - schemas.push(AssetSchema::Ifa); - } - schemas -} - -// A dead background processor must exit the node, not leave it serving without -// event processing; only `stop_processing` termination is expected. +// A dead background processor must take the node down, not leave it serving without event +// processing; only `stop_processing` termination is expected. The shutdown is requested rather +// than forced, so the VSS teardown still runs; `main` turns `FATAL_ERROR` into exit code 70. async fn supervise_background_processor( bp_future: impl std::future::Future> + Send, stop_flag: Arc, + cancel_token: CancellationToken, ) -> Result<(), io::Error> { let result = futures::FutureExt::catch_unwind(std::panic::AssertUnwindSafe(bp_future)).await; let stopping = stop_flag.load(Ordering::Acquire); match result { Ok(res) => { if !stopping { - match &res { - Ok(()) => { - tracing::error!("background processor exited unexpectedly; shutting down") - } - Err(e) => tracing::error!( - error = %e, - "background processor failed unexpectedly; shutting down" - ), - } - std::process::exit(70); + let msg = match &res { + Ok(()) => "background processor exited unexpectedly".to_string(), + Err(e) => format!("background processor failed unexpectedly: {e}"), + }; + tracing::error!("{msg}; shutting down"); + let _ = FATAL_ERROR.set(msg); + cancel_token.cancel(); } res } @@ -4122,7 +4402,12 @@ async fn supervise_background_processor( "background processor panicked; shutting down instead of running without \ event processing" ); - std::process::exit(70); + let _ = FATAL_ERROR.set(format!("background processor panicked: {msg}")); + cancel_token.cancel(); + Err(io::Error::new( + io::ErrorKind::Other, + format!("background processor panicked: {msg}"), + )) } } } @@ -4131,6 +4416,12 @@ async fn supervise_background_processor( mod watchdog_tests { use super::*; + // Exits with the same code `main` would once the server future has returned, via the shared + // decision so this test cannot drift from the real one. + fn exit_as_main_would() -> ! { + std::process::exit(crate::utils::fatal_exit_code()); + } + // Child mode re-runs this test in a subprocess so the exit code is observable. #[test] fn exits_with_code_70_on_bp_panic() { @@ -4140,11 +4431,15 @@ mod watchdog_tests { .build() .unwrap(); let stop = Arc::new(AtomicBool::new(false)); + let cancel = CancellationToken::new(); let _ = rt.block_on(supervise_background_processor( async { panic!("test panic") }, stop, + cancel.clone(), )); - std::process::exit(0); + // The shutdown has to be requested, not forced: the VSS teardown runs on it. + assert!(cancel.is_cancelled()); + exit_as_main_would(); } let status = std::process::Command::new(std::env::current_exe().unwrap()) .args([ @@ -4159,6 +4454,38 @@ mod watchdog_tests { assert_eq!(status.code(), Some(70)); } + // An unexpected clean return is as fatal as a panic: the node would keep serving without + // event processing. + #[test] + fn exits_with_code_70_on_unexpected_bp_return() { + if std::env::var("BP_WATCHDOG_CHILD").is_ok() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let stop = Arc::new(AtomicBool::new(false)); + let cancel = CancellationToken::new(); + let _ = rt.block_on(supervise_background_processor( + async { Ok(()) }, + stop, + cancel.clone(), + )); + assert!(cancel.is_cancelled()); + exit_as_main_would(); + } + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "ldk::watchdog_tests::exits_with_code_70_on_unexpected_bp_return", + ]) + .env("BP_WATCHDOG_CHILD", "1") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .unwrap(); + assert_eq!(status.code(), Some(70)); + } + #[test] fn returns_without_exiting_when_stop_requested() { let rt = tokio::runtime::Builder::new_current_thread() @@ -4166,11 +4493,37 @@ mod watchdog_tests { .build() .unwrap(); let stop = Arc::new(AtomicBool::new(true)); + let cancel = CancellationToken::new(); let res = rt.block_on(supervise_background_processor( async { Err(io::Error::new(io::ErrorKind::Other, "aborted at teardown")) }, stop, + cancel.clone(), )); assert!(res.is_err()); + assert!(!cancel.is_cancelled()); + } +} + +#[cfg(test)] +mod sweeper_predicate_tests { + use super::*; + + #[test] + fn rgb_amount_empty_map_is_vanilla() { + let map = HashMap::new(); + assert_eq!(rgb_amount_for_spendable_output(&map, 0, "tx"), Ok(0)); + } + + #[test] + fn rgb_amount_present_output_returns_amount() { + let map = HashMap::from_iter([(1u32, 42u64)]); + assert_eq!(rgb_amount_for_spendable_output(&map, 1, "tx"), Ok(42)); + } + + #[test] + fn rgb_amount_missing_from_non_empty_map_errors() { + let map = HashMap::from_iter([(1u32, 42u64)]); + assert!(rgb_amount_for_spendable_output(&map, 0, "tx").is_err()); } } @@ -4180,7 +4533,6 @@ mod watchdog_tests { async fn reimport_funding_consignments( rgb_wallet_wrapper: &Arc, kv_store: &Arc, - proxy_endpoint: &str, ldk_data_dir: &Path, ) { let mark_replay_done = || { @@ -4255,29 +4607,33 @@ async fn reimport_funding_consignments( } let wrapper = Arc::clone(rgb_wallet_wrapper); let txid_copy = txid.clone(); - let proxy_endpoint = proxy_endpoint.to_string(); let consignment_path = ldk_data_dir.join(format!("reimport_consignment_{txid}")); - // Re-post our stored copy so the proxy can serve it, then accept it - // like the funding-time acceptor flow does: this consumes the - // consignment into the RGB runtime, which save_new_asset requires. + // Accept our stored copy straight from disk: this consumes the consignment into + // the RGB runtime, which save_new_asset requires. Unlike the funding-time acceptor + // flow there is no media staging dir to promote from -- only consignment bytes are + // persisted -- so any media the contract declares must already be in the wallet. let res = tokio::task::spawn_blocking(move || -> Result<(), String> { fs::write(&consignment_path, &data).map_err(|e| e.to_string())?; - let proxy_url = TransportEndpoint::new(proxy_endpoint.clone()) - .map_err(|e| e.to_string())? - .endpoint; - if let Err(e) = wrapper.post_consignment( - &proxy_url, - txid_copy.clone(), - &consignment_path, + let accept_res = wrapper.accept_transfer_consignment( + consignment_path.clone(), txid_copy.clone(), - None, - ) { - tracing::debug!("re-posting funding consignment for {txid_copy}: {e}"); - } + 1, + STATIC_BLINDING, + ); let _ = fs::remove_file(&consignment_path); - let (consignment, _) = wrapper - .accept_transfer(txid_copy.clone(), 1, &proxy_endpoint, STATIC_BLINDING) - .map_err(|e| e.to_string())?; + let (consignment, _, media_digests) = accept_res.map_err(|e| e.to_string())?; + let media_dir = wrapper.get_media_dir(); + let missing: Vec = media_digests + .into_iter() + .filter(|digest| !media_dir.join(digest).exists()) + .collect(); + if !missing.is_empty() { + tracing::warn!( + "re-imported asset for {txid_copy} is missing {} media file(s) locally: {}", + missing.len(), + missing.join(", "), + ); + } match wrapper.save_new_asset(consignment, txid_copy) { Ok(()) => Ok(()), Err(e) if e.to_string().contains("UNIQUE constraint failed") => Ok(()), @@ -4336,6 +4692,15 @@ async fn reimport_funding_consignments( mark_replay_done(); } +// The unlock request wins, then the `[chain]` config section. There is no built-in default any +// more, so an indexer that resolves from neither is a hard error rather than a silent fallback. +fn resolve_indexer_url<'a>( + request: Option<&'a str>, + config: Option<&'a str>, +) -> Result<&'a str, APIError> { + request.or(config).ok_or(APIError::MissingIndexerUrl) +} + pub(crate) async fn start_ldk( app_state: Arc, key_source: NodeKeySource, @@ -4346,9 +4711,6 @@ pub(crate) async fn start_ldk( // Unlock request params take precedence, the config file provides defaults. let file_config = &static_state.config; - unlock_request.indexer_url = unlock_request - .indexer_url - .or_else(|| file_config.chain.indexer_url.clone()); unlock_request.proxy_endpoint = unlock_request .proxy_endpoint .or_else(|| file_config.chain.proxy_endpoint.clone()); @@ -4549,42 +4911,77 @@ pub(crate) async fn start_ldk( let network: Network = bitcoin_network.into(); let ldk_peer_listening_port = static_state.ldk_peer_listening_port; - // Pick the chain backend from caller-provided inputs. - let chain_selection = select_chain_backend(&unlock_request, bitcoin_network)?; - - // Bitcoind path retains the SpvClient/Listen flow; esplora path uses the - // EsploraSyncClient/Confirm flow. We populate the same locals from either - // branch so the rest of start_ldk is shared. - let bitcoind_client_opt: Option>; - let tx_sync_opt: Option>>>; - let electrum_tx_sync_opt: Option>>>; - let chain_source: Option>; - let chain_backend: Arc; - let seed_best_block: BestBlock; - let polled_chain_tip_opt: Option; - - match chain_selection { - ChainBackendSelection::Bitcoind { - username, - password, - host, - port, + // RGB setup + let indexer_url = resolve_indexer_url( + unlock_request.indexer_url.as_deref(), + static_state.config.chain.indexer_url.as_deref(), + )?; + let indexer_protocol = check_indexer_url(indexer_url, bitcoin_network)?; + tracing::info!( + "Connected to an indexer with the {} protocol", + indexer_protocol + ); + let proxy_endpoint = if let Some(proxy_endpoint) = &unlock_request.proxy_endpoint { + check_rgb_proxy_endpoint(proxy_endpoint).await?; + tracing::info!("Using a custom proxy"); + proxy_endpoint + } else { + tracing::info!("Using the default proxy"); + match bitcoin_network { + BitcoinNetwork::Signet + | BitcoinNetwork::SignetCustom + | BitcoinNetwork::Testnet + | BitcoinNetwork::Testnet4 + | BitcoinNetwork::Mainnet => PROXY_ENDPOINT_PUBLIC, + BitcoinNetwork::Regtest => PROXY_ENDPOINT_LOCAL, + } + }; + save_config( + &app_state.db(), + kv_store.as_ref(), + CONFIG_INDEXER_URL, + indexer_url, + )?; + save_config( + &app_state.db(), + kv_store.as_ref(), + CONFIG_BITCOIN_NETWORK, + &bitcoin_network.to_string(), + )?; + + // Initialize the chain backend for the requested sync mode + let handle = tokio::runtime::Handle::current(); + let ChainSetup { + backend, + fee_estimator, + broadcaster, + chain_filter, + initial_best_block, + } = match &unlock_request.ldk_chain_sync { + #[cfg(feature = "block-sync")] + LdkChainSync::BlockSync { + bitcoind_rpc_username, + bitcoind_rpc_password, + bitcoind_rpc_host, + bitcoind_rpc_port, } => { - let client = match BitcoindClient::new( - host, - port, - username, - password, - tokio::runtime::Handle::current(), + let bitcoind_client = match BitcoindClient::new( + bitcoind_rpc_host.clone(), + *bitcoind_rpc_port, + bitcoind_rpc_username.clone(), + bitcoind_rpc_password.clone(), + handle.clone(), Arc::clone(&logger), static_state.config.chain.fee_refresh_interval_secs, ) .await { - Ok(c) => Arc::new(c), + Ok(client) => Arc::new(client), Err(e) => return Err(APIError::FailedBitcoindConnection(e.to_string())), }; - let bitcoind_chain = client.get_blockchain_info().await.chain; + + // Check that the bitcoind we've connected to is running the network we expect + let bitcoind_chain = bitcoind_client.get_blockchain_info().await.chain; if bitcoind_chain != match bitcoin_network { BitcoinNetwork::Mainnet => "main", @@ -4596,129 +4993,71 @@ pub(crate) async fn start_ldk( { return Err(APIError::NetworkMismatch(bitcoind_chain, bitcoin_network)); } - let polled = init::validate_best_block_header(client.as_ref()) + + // Poll for the best chain tip, used by the channel manager & spv client + let polled_chain_tip = init::validate_best_block_header(bitcoind_client.as_ref()) .await .expect("Failed to fetch best block header and best block"); - seed_best_block = polled.to_best_block(); - chain_backend = Arc::new(ChainBackend::Bitcoind(client.clone())); - bitcoind_client_opt = Some(client); - tx_sync_opt = None; - electrum_tx_sync_opt = None; - chain_source = None; - polled_chain_tip_opt = Some(polled); - } - ChainBackendSelection::Esplora { url } => { - let esplora = Arc::new( - EsploraIndexerClient::new( - url.clone(), - network, - tokio::runtime::Handle::current(), + let initial_best_block = polled_chain_tip.to_best_block(); + + ChainSetup { + fee_estimator: bitcoind_client.clone(), + broadcaster: bitcoind_client.clone(), + backend: ChainBackend::BlockSync { + client: bitcoind_client, + polled_chain_tip, + }, + chain_filter: None, + initial_best_block, + } + } + #[cfg(feature = "transaction-sync")] + LdkChainSync::TransactionSync { + indexer_url: ln_indexer_url, + } => { + // LDK can sync against a different indexer than the RGB wallet, but when the two + // match the URL has already been checked above + let ln_indexer_protocol = if ln_indexer_url == indexer_url { + indexer_protocol.clone() + } else { + check_indexer_url(ln_indexer_url, bitcoin_network)? + }; + let indexer_client = Arc::new( + IndexerClient::new( + ln_indexer_url.to_string(), + ln_indexer_protocol.clone(), + handle.clone(), Arc::clone(&logger), static_state.config.chain.indexer_timeout_secs, static_state.config.chain.fee_refresh_interval_secs, ) .map_err(|e| APIError::InvalidIndexer(e.to_string()))?, ); - let tip_hash = esplora - .client - .get_tip_hash() - .map_err(|e| APIError::InvalidIndexer(e.to_string()))?; - let tip_height = esplora - .client - .get_height() - .map_err(|e| APIError::InvalidIndexer(e.to_string()))?; - let tx_sync = Arc::new(EsploraSyncClient::new(url, Arc::clone(&logger))); - seed_best_block = BestBlock::new(tip_hash, tip_height); - chain_backend = Arc::new(ChainBackend::Esplora(esplora)); - chain_source = Some(Arc::clone(&tx_sync) as Arc); - tx_sync_opt = Some(tx_sync); - electrum_tx_sync_opt = None; - bitcoind_client_opt = None; - polled_chain_tip_opt = None; - } - ChainBackendSelection::Electrum { url } => { - use electrum_client::ElectrumApi; - let electrum = Arc::new( - ElectrumIndexerClient::new( - url.clone(), - network, - tokio::runtime::Handle::current(), + let tx_sync = Arc::new( + IndexerSyncClient::new( + ln_indexer_url.to_string(), + ln_indexer_protocol, Arc::clone(&logger), - static_state.config.chain.fee_refresh_interval_secs, ) .map_err(|e| APIError::InvalidIndexer(e.to_string()))?, ); - let tip = electrum - .client - .block_headers_subscribe() + let initial_best_block = indexer_client + .get_best_block() .map_err(|e| APIError::InvalidIndexer(e.to_string()))?; - let tx_sync = Arc::new( - ElectrumSyncClient::new(url, Arc::clone(&logger)) - .map_err(|e| APIError::InvalidIndexer(e.to_string()))?, - ); - seed_best_block = BestBlock::new(tip.header.block_hash(), tip.height as u32); - chain_backend = Arc::new(ChainBackend::Electrum(electrum)); - chain_source = Some(Arc::clone(&tx_sync) as Arc); - electrum_tx_sync_opt = Some(tx_sync); - tx_sync_opt = None; - bitcoind_client_opt = None; - polled_chain_tip_opt = None; - } - } - // RGB setup - let indexer_url = if let Some(indexer_url) = &unlock_request.indexer_url { - let indexer_protocol = check_indexer_url(indexer_url, bitcoin_network)?; - tracing::info!( - "Connected to an indexer with the {} protocol", - indexer_protocol - ); - indexer_url - } else { - tracing::info!("Using the default indexer"); - match bitcoin_network { - BitcoinNetwork::Regtest => ELECTRUM_URL_REGTEST, - BitcoinNetwork::Signet => ELECTRUM_URL_SIGNET, - BitcoinNetwork::Testnet => ELECTRUM_URL_TESTNET, - BitcoinNetwork::Testnet4 => ELECTRUM_URL_TESTNET4, - BitcoinNetwork::Mainnet => ELECTRUM_URL_MAINNET, - BitcoinNetwork::SignetCustom => { - return Err(APIError::InvalidIndexer(s!( - "with custom signet indexer must be provided" - ))) + let chain_filter: Arc = tx_sync.clone(); + ChainSetup { + fee_estimator: indexer_client.clone(), + broadcaster: indexer_client.clone(), + backend: ChainBackend::TransactionSync { + client: indexer_client, + tx_sync, + }, + chain_filter: Some(chain_filter), + initial_best_block, } } }; - let proxy_endpoint = if let Some(proxy_endpoint) = &unlock_request.proxy_endpoint { - check_rgb_proxy_endpoint(proxy_endpoint).await?; - tracing::info!("Using a custom proxy"); - proxy_endpoint - } else { - tracing::info!("Using the default proxy"); - match bitcoin_network { - BitcoinNetwork::Signet - | BitcoinNetwork::SignetCustom - | BitcoinNetwork::Testnet - | BitcoinNetwork::Testnet4 - | BitcoinNetwork::Mainnet => PROXY_ENDPOINT_PUBLIC, - BitcoinNetwork::Regtest => PROXY_ENDPOINT_LOCAL, - } - }; - save_config( - &app_state.db(), - kv_store.as_ref(), - CONFIG_INDEXER_URL, - indexer_url, - )?; - save_config( - &app_state.db(), - kv_store.as_ref(), - CONFIG_BITCOIN_NETWORK, - &bitcoin_network.to_string(), - )?; - - let fee_estimator = chain_backend.clone(); - let broadcaster = chain_backend.clone(); // LDK signing: internal mode uses `KeysManager` from the mnemonic-derived LDK seed (BIP32 child // 535 of the master xpriv). External mode uses `ExternalSigner` only; inbound / peer_storage / @@ -4776,8 +5115,8 @@ pub(crate) async fn start_ldk( 1000, Arc::clone(&keys_manager), Arc::clone(&keys_manager), - Arc::clone(&chain_backend), - Arc::clone(&chain_backend), + Arc::clone(&broadcaster), + Arc::clone(&fee_estimator), ); // Read before moving the persister into the ChainMonitor. let channelmonitors = persister @@ -4785,7 +5124,7 @@ pub(crate) async fn start_ldk( .await .unwrap(); let chain_monitor = Arc::new(chainmonitor::ChainMonitor::new_async_beta( - chain_source.clone(), + chain_filter.clone(), Arc::clone(&broadcaster), Arc::clone(&logger), Arc::clone(&fee_estimator), @@ -4807,12 +5146,12 @@ pub(crate) async fn start_ldk( 1000, Arc::clone(&keys_manager), Arc::clone(&keys_manager), - Arc::clone(&chain_backend), - Arc::clone(&chain_backend), + Arc::clone(&broadcaster), + Arc::clone(&fee_estimator), )); let peer_storage_signer = Arc::clone(&keys_manager); let chain_monitor = Arc::new(chainmonitor::ChainMonitor::new_with_peer_storage_encryptor( - chain_source.clone(), + chain_filter.clone(), Arc::clone(&broadcaster), Arc::clone(&logger), Arc::clone(&fee_estimator), @@ -4878,13 +5217,18 @@ pub(crate) async fn start_ldk( user_config.accept_forwards_to_priv_channels = channels_config.accept_forwards_to_priv_channels || static_state.enable_virtual_channels_v0; user_config.manually_accept_inbound_channels = true; - let mut restarting_node = true; + let persisted_manager = kv_store.read( + CHANNEL_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, + CHANNEL_MANAGER_PERSISTENCE_SECONDARY_NAMESPACE, + CHANNEL_MANAGER_PERSISTENCE_KEY, + ); + // `restarting_node` and `channel_manager_blockhash` are only consumed by the block-sync + // restart path + #[cfg_attr(not(feature = "block-sync"), allow(unused_variables))] + let restarting_node = persisted_manager.is_ok(); + #[cfg_attr(not(feature = "block-sync"), allow(unused_variables))] let (channel_manager_blockhash, channel_manager) = { - match kv_store.read( - CHANNEL_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, - CHANNEL_MANAGER_PERSISTENCE_SECONDARY_NAMESPACE, - CHANNEL_MANAGER_PERSISTENCE_KEY, - ) { + match persisted_manager { Ok(bytes) => { let mut channel_monitor_references = Vec::new(); for (_, channel_monitor) in channelmonitors.iter() { @@ -4918,9 +5262,7 @@ pub(crate) async fn start_ldk( } Err(e) if e.kind() == io::ErrorKind::NotFound => { // We're starting a fresh node. - restarting_node = false; - - let polled_best_block = seed_best_block; + let polled_best_block = initial_best_block; let polled_best_block_hash = polled_best_block.block_hash; let chain_params = ChainParameters { network, @@ -5160,13 +5502,7 @@ pub(crate) async fn start_ldk( rgb_online, )); - reimport_funding_consignments( - &rgb_wallet_wrapper, - &kv_store, - proxy_endpoint, - &ldk_data_dir, - ) - .await; + reimport_funding_consignments(&rgb_wallet_wrapper, &kv_store, &ldk_data_dir).await; // Initialize the OutputSweeper. let txes: OutputSpenderTxes = match kv_store.read("", "", OUTPUT_SPENDER_TXES_KEY) { @@ -5183,8 +5519,10 @@ pub(crate) async fn start_ldk( signer: signer_for_output_spender, kv_store: kv_store.clone(), txes, - proxy_endpoint: proxy_endpoint.to_string(), + sweep_recipients: Arc::new(Mutex::new(HashMap::new())), }); + // `sweeper_best_block` is only used by the block-sync restart path. + #[cfg_attr(not(feature = "block-sync"), allow(unused_variables))] let (sweeper_best_block, output_sweeper) = match kv_store.read( OUTPUT_SWEEPER_PERSISTENCE_PRIMARY_NAMESPACE, OUTPUT_SWEEPER_PERSISTENCE_SECONDARY_NAMESPACE, @@ -5195,7 +5533,7 @@ pub(crate) async fn start_ldk( channel_manager.current_best_block(), broadcaster.clone(), fee_estimator.clone(), - chain_source.clone(), + chain_filter.clone(), rgb_output_spender, rgb_wallet_wrapper.clone(), Clone::clone(&bp_kv_store), @@ -5207,7 +5545,7 @@ pub(crate) async fn start_ldk( let read_args = ( broadcaster.clone(), fee_estimator.clone(), - chain_source.clone(), + chain_filter.clone(), rgb_output_spender.clone(), rgb_wallet_wrapper.clone(), Clone::clone(&bp_kv_store), @@ -5221,10 +5559,16 @@ pub(crate) async fn start_ldk( }; // Sync ChannelMonitors, ChannelManager and OutputSweeper to chain tip. - // For bitcoind we drive Listen via synchronize_listeners + SpvClient. For - // esplora we'll drive Confirm via EsploraSyncClient::sync below. + // block-sync replays blocks from bitcoind before the SPV client takes over, while + // transaction-sync relies on the indexer via the `Confirm` interface. let mut chain_listener_channel_monitors = Vec::new(); - let mut cache = UnboundedCache::new(); + #[cfg(feature = "block-sync")] + let mut block_sync_cache = UnboundedCache::new(); + // with only block-sync built this is always set below, hence the allow + #[cfg(feature = "block-sync")] + #[cfg_attr(not(feature = "transaction-sync"), allow(unused_assignments))] + let mut block_sync_chain_tip: Option = None; + for (blockhash, channel_monitor) in channelmonitors.drain(..) { let outpoint = channel_monitor.get_funding_txo(); chain_listener_channel_monitors.push(( @@ -5238,10 +5582,13 @@ pub(crate) async fn start_ldk( outpoint, )); } - let chain_tip_opt: Option = - if let Some(bc) = bitcoind_client_opt.as_ref() { - let polled_chain_tip = - polled_chain_tip_opt.expect("bitcoind branch populates polled_chain_tip_opt"); + + match &backend { + #[cfg(feature = "block-sync")] + ChainBackend::BlockSync { + client, + polled_chain_tip, + } => { let chain_tip = if restarting_node { let mut chain_listeners = vec![ ( @@ -5262,9 +5609,9 @@ pub(crate) async fn start_ldk( let mut attempts = 3; loop { match init::synchronize_listeners( - bc.as_ref(), + client.as_ref(), network, - &mut cache, + &mut block_sync_cache, chain_listeners.clone(), ) .await @@ -5283,12 +5630,13 @@ pub(crate) async fn start_ldk( } } } else { - polled_chain_tip + *polled_chain_tip }; - Some(chain_tip) - } else { - None - }; + block_sync_chain_tip = Some(chain_tip); + } + #[cfg(feature = "transaction-sync")] + ChainBackend::TransactionSync { .. } => {} + } // Give ChannelMonitors to ChainMonitor for (_, (channel_monitor, _, _, _), _) in chain_listener_channel_monitors { @@ -5384,9 +5732,21 @@ pub(crate) async fn start_ldk( None => Arc::new(AsyncOrderMessageHandler::new(live_channel_access.clone())), }; let asset_link_handler = Arc::new(AssetLinkMessageHandler::new(live_channel_access)); + let max_aggregated_media_size_per_channel_mb = + static_state.max_aggregated_media_size_per_channel_mb as usize * 1024 * 1024; + let rgb_file_transfer_handler: Arc = + Arc::new(RgbFileTransferHandler::new( + ldk_data_dir_path.clone(), + Arc::clone(&channel_manager) as Arc, + static_state.max_pending_consignments, + max_aggregated_media_size_per_channel_mb, + static_state.max_media_files_per_channel, + )); + rgb_file_transfer_handler.cleanup_orphans_from_previous_run(); let custom_messenger = Arc::new(CustomMessenger { async_order: Arc::clone(&async_order_handler), asset_link: Arc::clone(&asset_link_handler), + rgb_file_transfer: Arc::clone(&rgb_file_transfer_handler), }); let async_payments_preimage_root = Arc::new( match internal_mnemonic.as_ref() { @@ -5425,17 +5785,29 @@ pub(crate) async fn start_ldk( Arc::clone(&keys_manager), )); - // GossipVerifier needs both bitcoind (UtxoSource) and P2P gossip mode. - // On esplora/electrum or RGS modes the UtxoLookup stays unset — gossip - // routing still works but channel-announcement UTXOs aren't verified P2P. - if let (Some(bc), Some(p2p)) = (bitcoind_client_opt.as_ref(), &p2p_gossip_sync_for_verifier) { - let utxo_lookup = GossipVerifier::new( - Arc::clone(&bc.bitcoind_rpc_client), - TokioSpawner, - Arc::clone(p2p), - Arc::clone(&peer_manager), - ); - p2p.add_utxo_lookup(Some(Arc::new(utxo_lookup))); + // The UTXO lookup can only attach to a P2P sync; RGS mode skips it. Both chain backends + // provide a verifier, so announcements are checked whatever the sync mode is. + if let Some(p2p) = &p2p_gossip_sync_for_verifier { + let peer_manager_wake = Arc::new({ + let peer_manager = Arc::clone(&peer_manager); + move || peer_manager.process_events() + }); + let utxo_lookup: Arc = match &backend { + #[cfg(feature = "block-sync")] + ChainBackend::BlockSync { client, .. } => Arc::new(BlockSyncGossipVerifier::new( + Arc::clone(&client.bitcoind_rpc_client), + Arc::clone(p2p), + peer_manager_wake, + handle.clone(), + )), + #[cfg(feature = "transaction-sync")] + ChainBackend::TransactionSync { client, .. } => Arc::new(IndexerGossipVerifier::new( + Arc::clone(client), + Arc::clone(p2p), + peer_manager_wake, + )), + }; + p2p.add_utxo_lookup(Some(utxo_lookup)); } // ## Running LDK @@ -5472,75 +5844,57 @@ pub(crate) async fn start_ldk( // Connect and Disconnect Blocks let output_sweeper: Arc = Arc::new(output_sweeper); let stop_listen = Arc::clone(&stop_processing); - if let Some(bitcoind_client) = bitcoind_client_opt.clone() { - let chain_tip = chain_tip_opt.expect("bitcoind branch populates chain_tip_opt"); - let channel_manager_listener = channel_manager.clone(); - let chain_monitor_listener = chain_monitor.clone(); - let output_sweeper_listener = output_sweeper.clone(); - let bitcoind_block_source = bitcoind_client.clone(); - tokio::spawn(async move { - let chain_poller = poll::ChainPoller::new(bitcoind_block_source.as_ref(), network); - let chain_listener = ( - chain_monitor_listener, - &(channel_manager_listener, output_sweeper_listener), - ); - let mut spv_client = - SpvClient::new(chain_tip, chain_poller, &mut cache, &chain_listener); - loop { - if stop_listen.load(Ordering::Acquire) { - return; - } - if let Err(e) = spv_client.poll_best_tip().await { - tracing::error!("Error while polling best tip: {:?}", e); - } - tokio::time::sleep(Duration::from_secs(1)).await; - } - }); - } else if let Some(tx_sync) = tx_sync_opt.clone() { - let confirmables: Vec> = vec![ - channel_manager.clone(), - chain_monitor.clone(), - output_sweeper.clone(), - ]; - sync_chain_data(tx_sync.clone(), confirmables.clone()) - .await - .map_err(|e| APIError::InvalidIndexer(e.to_string()))?; - tokio::spawn(async move { - loop { - if stop_listen.load(Ordering::Acquire) { - return; - } - if let Err(e) = sync_chain_data(tx_sync.clone(), confirmables.clone()).await { - tracing::error!("Error while syncing via esplora: {:?}", e); - } - tokio::time::sleep(Duration::from_secs(1)).await; - } - }); - } else { - let tx_sync = electrum_tx_sync_opt - .clone() - .expect("electrum branch populates electrum_tx_sync_opt"); - let confirmables: Vec> = vec![ - channel_manager.clone(), - chain_monitor.clone(), - output_sweeper.clone(), - ]; - sync_chain_data_electrum(tx_sync.clone(), confirmables.clone()) - .await - .map_err(|e| APIError::InvalidIndexer(e.to_string()))?; - tokio::spawn(async move { - loop { - if stop_listen.load(Ordering::Acquire) { - return; + match backend { + #[cfg(feature = "block-sync")] + ChainBackend::BlockSync { client, .. } => { + let channel_manager_listener = channel_manager.clone(); + let chain_monitor_listener = chain_monitor.clone(); + let output_sweeper_listener = output_sweeper.clone(); + let chain_tip = + block_sync_chain_tip.expect("block-sync chain tip is set while syncing listeners"); + let mut cache = block_sync_cache; + tokio::spawn(async move { + let chain_poller = poll::ChainPoller::new(client.as_ref(), network); + let chain_listener = ( + chain_monitor_listener, + &(channel_manager_listener, output_sweeper_listener), + ); + let mut spv_client = + SpvClient::new(chain_tip, chain_poller, &mut cache, &chain_listener); + loop { + if stop_listen.load(Ordering::Acquire) { + return; + } + if let Err(e) = spv_client.poll_best_tip().await { + tracing::error!("Error while polling best tip: {:?}", e); + } + tokio::time::sleep(Duration::from_secs(1)).await; } - if let Err(e) = - sync_chain_data_electrum(tx_sync.clone(), confirmables.clone()).await - { - tracing::error!("Error while syncing via electrum: {:?}", e); + }); + } + #[cfg(feature = "transaction-sync")] + ChainBackend::TransactionSync { tx_sync, .. } => { + let confirmables: Vec> = vec![ + channel_manager.clone(), + chain_monitor.clone(), + output_sweeper.clone(), + ]; + // bring everything up to the current tip before starting to serve + sync_chain_data(tx_sync.clone(), confirmables.clone()) + .await + .map_err(|e| APIError::InvalidIndexer(e.to_string()))?; + tokio::spawn(async move { + loop { + if stop_listen.load(Ordering::Acquire) { + return; + } + if let Err(e) = sync_chain_data(tx_sync.clone(), confirmables.clone()).await { + tracing::error!("Error while syncing via indexer: {:?}", e); + } + tokio::time::sleep(Duration::from_secs(1)).await; } - tokio::time::sleep(Duration::from_secs(1)).await; - } - }); + }); + } } // Read payment info from KVStore @@ -5754,6 +6108,7 @@ pub(crate) async fn start_ldk( kv_store: Arc::clone(&kv_store), #[cfg(feature = "vss")] monitor_kv_store: Arc::clone(&monitor_kv_store), + rgb_file_transfer_handler: Arc::clone(&rgb_file_transfer_handler), bump_tx_event_handler, rgb_wallet_wrapper, maker_swaps, @@ -5845,6 +6200,7 @@ pub(crate) async fn start_ldk( let background_processor = tokio::spawn(supervise_background_processor( bp_future, Arc::clone(&stop_processing), + app_state.cancel_token.clone(), )); // Periodically drain queued VSS replications so an idle node still heals @@ -5862,7 +6218,9 @@ pub(crate) async fn start_ldk( break; } let store = Arc::clone(&drain_store); - let _ = tokio::task::spawn_blocking(move || store.drain_pending()).await; + if let Err(e) = tokio::task::spawn_blocking(move || store.drain_pending()).await { + tracing::error!(error = %e, "periodic VSS drain task failed"); + } } }); } @@ -5878,6 +6236,12 @@ pub(crate) async fn start_ldk( interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); loop { interval.tick().await; + // checked here and not only per peer: with no channels to reconnect, or once the read + // below starts failing, the inner check is unreachable and the task would outlive the + // node it belongs to, polling its database by path forever + if stop_connect.load(Ordering::Acquire) { + return; + } let db = RlnDatabase::new((*connect_db).clone()); match db.read_channel_peer_data() { Ok(info) => { @@ -6001,6 +6365,22 @@ pub(crate) async fn start_ldk( } None => [0; 32], }; + + // cleanup the buffers of RGB file transfers a peer started and never finished + let sweep_handler = Arc::clone(&rgb_file_transfer_handler); + let stop_sweep = Arc::clone(&stop_processing); + tokio::spawn(async move { + let mut interval = tokio::time::interval(REASSEMBLY_SWEEP_INTERVAL); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + interval.tick().await; + if stop_sweep.load(Ordering::Acquire) { + return; + } + sweep_handler.sweep_stale_state(); + } + }); + let peer_man = Arc::clone(&peer_manager); let chan_man = Arc::clone(&channel_manager); let announce_initial_delay_secs = static_state.config.node.announce_initial_delay_secs; @@ -6071,26 +6451,6 @@ pub(crate) fn attach_external_signer_transport( }) } -async fn sync_chain_data( - tx_sync: Arc>>, - confirmables: Vec>, -) -> Result<(), Box> { - tokio::task::spawn_blocking(move || tx_sync.sync(confirmables)) - .await - .map_err(|e| -> Box { Box::new(e) })? - .map_err(|e| -> Box { Box::new(e) }) -} - -async fn sync_chain_data_electrum( - tx_sync: Arc>>, - confirmables: Vec>, -) -> Result<(), Box> { - tokio::task::spawn_blocking(move || tx_sync.sync(confirmables)) - .await - .map_err(|e| -> Box { Box::new(e) })? - .map_err(|e| -> Box { Box::new(e) }) -} - impl AppState { fn stop_ldk(&self) -> Option>> { let mut ldk_background_services = self.get_ldk_background_services(); @@ -6111,9 +6471,11 @@ impl AppState { ldk_background_services.gossip_shutdown.notify_one(); ldk_background_services.peer_manager.disconnect_all_peers(); - // Stop the background processor. + // Stop the background processor. Its `bp_exit` receiver lives inside the + // `process_events_async` future, so nothing to signal if the background processor is + // already gone. Also, send can find no receiver during a panic (racy). if !ldk_background_services.bp_exit.is_closed() { - ldk_background_services.bp_exit.send(()).unwrap(); + let _ = ldk_background_services.bp_exit.send(()); ldk_background_services.background_processor.take() } else { None @@ -6124,7 +6486,114 @@ impl AppState { #[cfg(feature = "vss")] const BP_SHUTDOWN_FLUSH_TIMEOUT: Duration = Duration::from_secs(30); +/// Budget for draining and stopping the VSS-backed stores at teardown. Same order as the +/// background-processor join above: it covers the flush window plus a stuck remote request being +/// given up on, and keeps a shutdown terminating even when VSS never answers. +#[cfg(feature = "vss")] +const VSS_TEARDOWN_TIMEOUT: Duration = Duration::from_secs(30); + +/// Window for the final drain of queued replications, inside the teardown budget. +#[cfg(feature = "vss")] +const VSS_TEARDOWN_FLUSH_WINDOW: Duration = Duration::from_secs(10); + +/// Budget for the single VSS round-trip that hands the fence over. +#[cfg(feature = "vss")] +const VSS_FENCE_RELEASE_TIMEOUT: Duration = Duration::from_secs(10); + +#[cfg(feature = "vss")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum VssTeardown { + /// Flush and stops finished: no further remote mutation can begin. + Complete, + /// A step was abandoned at the deadline: an in-flight write may still land. + Abandoned, +} + +/// Drains queued replications and stops both stores, bounding every step by what is left of +/// `deadline`. `SyncedKvStore::stop` waits on the drain gate, so a hung remote write would +/// otherwise block the shutdown forever. +#[cfg(feature = "vss")] +async fn stop_vss_stores( + kv_store: &Arc, + monitor_kv_store: &Arc, + deadline: Instant, +) -> VssTeardown { + let remaining = || deadline.saturating_duration_since(Instant::now()); + + let flush_store = Arc::clone(kv_store); + let flush_deadline = std::cmp::min(deadline, Instant::now() + VSS_TEARDOWN_FLUSH_WINDOW); + let flush = + tokio::task::spawn_blocking(move || flush_store.flush_pending_until(flush_deadline)); + match tokio::time::timeout(remaining(), flush).await { + Ok(Ok(0)) => {} + Ok(Ok(n)) => tracing::error!( + pending = n, + "VSS replications still queued at shutdown; they persist locally and \ + will retry on next unlock" + ), + Ok(Err(e)) => { + tracing::error!(error = %e, "pending-queue flush task failed"); + monitor_kv_store.stop(); + return VssTeardown::Abandoned; + } + Err(_) => { + tracing::error!("pending-queue flush did not finish within the teardown budget"); + monitor_kv_store.stop(); + return VssTeardown::Abandoned; + } + } + + // Stop drains and abort outage-pending writes: once both stores are stopped no remote + // mutation can begin, which is what makes giving up the fence safe. + let stop_store = Arc::clone(kv_store); + let stop = tokio::task::spawn_blocking(move || stop_store.stop()); + match tokio::time::timeout(remaining(), stop).await { + Ok(Ok(())) => {} + Ok(Err(e)) => { + tracing::error!(error = %e, "pending-queue stop task failed"); + monitor_kv_store.stop(); + return VssTeardown::Abandoned; + } + Err(_) => { + tracing::error!("pending-queue stop did not finish within the teardown budget"); + monitor_kv_store.stop(); + return VssTeardown::Abandoned; + } + } + // Only signals the retry loops to abort, so it cannot block. Idempotent: the abandoned + // paths above may have already called it. + monitor_kv_store.stop(); + VssTeardown::Complete +} + +/// Releases the VSS fence, but only after a complete teardown: a write still in flight could +/// otherwise land on a store another instance has already taken over. Returns whether the +/// release was attempted. #[cfg(feature = "vss")] +async fn release_vss_fence(kv_store: Arc, teardown: VssTeardown) -> bool { + if teardown != VssTeardown::Complete { + tracing::error!( + "VSS teardown did not complete within {:?}; keeping the fence, the next \ + instance needs an explicit fence clear to take over", + VSS_TEARDOWN_TIMEOUT + ); + return false; + } + let release = tokio::task::spawn_blocking(move || kv_store.release_vss_fence_if_owned()); + match tokio::time::timeout(VSS_FENCE_RELEASE_TIMEOUT, release).await { + Ok(Ok(Ok(()))) => {} + Ok(Ok(Err(e))) => tracing::warn!(error = %e, "failed to release VSS fence during shutdown"), + Ok(Err(e)) => tracing::warn!(error = %e, "VSS fence release task failed"), + Err(_) => tracing::warn!( + "VSS fence release did not finish within {:?}", + VSS_FENCE_RELEASE_TIMEOUT + ), + } + true +} + +// Runs while shutting down, possibly because the background processor itself +// died, so its outcome is reported instead of unwrapped. fn log_bp_shutdown_result(res: Result, tokio::task::JoinError>) { match res { Ok(Ok(())) => {} @@ -6135,6 +6604,58 @@ fn log_bp_shutdown_result(res: Result, tokio::task::JoinEr } } +#[cfg(all(test, feature = "vss"))] +mod vss_teardown_tests { + use super::*; + use crate::kv_store::SeaOrmKvStore; + + fn local_stores() -> (Arc, Arc) { + let connection = crate::runtime::block_on(sea_orm::Database::connect("sqlite::memory:")) + .expect("in-memory database"); + let local = Arc::new(SeaOrmKvStore::from_connection(Arc::new(connection))); + ( + Arc::new(SyncedKvStore::local_only(Arc::clone(&local))), + Arc::new(RemoteFirstKvStore::new(local, None)), + ) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn completed_teardown_releases_the_fence() { + let (kv_store, monitor_kv_store) = local_stores(); + + let teardown = stop_vss_stores( + &kv_store, + &monitor_kv_store, + Instant::now() + Duration::from_secs(5), + ) + .await; + + assert_eq!(teardown, VssTeardown::Complete); + assert!(release_vss_fence(kv_store, teardown).await); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn abandoned_teardown_keeps_the_fence() { + let (kv_store, monitor_kv_store) = local_stores(); + // `stop` blocks on the drain gate; a hung remote write must not hold the shutdown. + kv_store.set_before_stop_gate_hook(Arc::new(|| std::thread::sleep(Duration::from_secs(1)))); + // Stand in for a live retry loop so the shutdown signal has a receiver to observe. + let shutdown_rx = monitor_kv_store.subscribe_shutdown(); + + let teardown = stop_vss_stores( + &kv_store, + &monitor_kv_store, + Instant::now() + Duration::from_millis(100), + ) + .await; + + assert_eq!(teardown, VssTeardown::Abandoned); + // The abandoned path must still abort the monitor retries before giving up. + assert!(*shutdown_rx.borrow()); + assert!(!release_vss_fence(kv_store, teardown).await); + } +} + pub(crate) async fn stop_ldk(app_state: Arc) { tracing::info!("Stopping LDK"); @@ -6171,44 +6692,19 @@ pub(crate) async fn stop_ldk(app_state: Arc) { } #[cfg(not(feature = "vss"))] if let Some(join_handle) = app_state.stop_ldk() { - join_handle.await.unwrap().unwrap(); + log_bp_shutdown_result(join_handle.await); } - // Graceful teardown (lock, shutdown, signal): release the VSS fence so - // the next unlock — a fresh instance id — takes over without an explicit - // /vssclearfence. Hard kills still leave the fence behind by design. + // Any shutdown that reaches here (lock, /shutdown, signal, fatal panic) hands the VSS fence + // over so the next unlock — a fresh instance id — takes over without an explicit + // /vssclearfence. The teardown is bounded, and the fence is only released once it provably + // completed; a hard kill, or a teardown abandoned at its deadline, leaves the fence behind. #[cfg(feature = "vss")] { if let Some((kv_store, monitor_kv_store)) = stores { - // Best-effort flush of queued replications before the fence goes. - let flush_store = Arc::clone(&kv_store); - let flush = tokio::task::spawn_blocking(move || { - flush_store.flush_pending_until(std::time::Instant::now() + Duration::from_secs(10)) - }); - match flush.await { - Ok(0) => {} - Ok(n) => tracing::error!( - pending = n, - "VSS replications still queued at shutdown; they persist locally and \ - will retry on next unlock" - ), - Err(e) => tracing::warn!(error = %e, "pending-queue flush task failed"), - } - // Stop drains and abort outage-pending monitor writes before - // giving up the fence: a write landing after another instance - // owns the store would corrupt its state. - let stop_store = Arc::clone(&kv_store); - if let Err(e) = tokio::task::spawn_blocking(move || stop_store.stop()).await { - tracing::warn!(error = %e, "pending-queue stop task failed"); - } - monitor_kv_store.stop(); - match tokio::task::spawn_blocking(move || kv_store.release_vss_fence_if_owned()).await { - Ok(Ok(())) => {} - Ok(Err(e)) => { - tracing::warn!(error = %e, "failed to release VSS fence during shutdown") - } - Err(e) => tracing::warn!(error = %e, "VSS fence release task failed"), - } + let deadline = Instant::now() + VSS_TEARDOWN_TIMEOUT; + let teardown = stop_vss_stores(&kv_store, &monitor_kv_store, deadline).await; + release_vss_fence(kv_store, teardown).await; } } @@ -6224,7 +6720,8 @@ pub(crate) async fn stop_ldk(app_state: Arc) { break; } if (OffsetDateTime::now_utc() - t_0).as_seconds_f32() > 10.0 { - panic!("LDK peer port not being released") + tracing::error!("LDK peer port {peer_port} was not released within 10s"); + break; } } @@ -6283,6 +6780,33 @@ pub(crate) fn clear_rgb_payment_pending( #[cfg(test)] mod tests { use super::*; + + // `chain.indexer_url` is the whole reason the unlock request keeps `indexer_url` optional + // instead of adopting upstream's mandatory field, so the layering itself is pinned here. + #[test] + fn indexer_url_falls_back_to_the_config_file() { + assert_eq!( + resolve_indexer_url(None, Some("127.0.0.1:50001")).unwrap(), + "127.0.0.1:50001" + ); + } + + #[test] + fn indexer_url_from_the_request_wins_over_the_config_file() { + assert_eq!( + resolve_indexer_url(Some("from-request:50001"), Some("from-config:50001")).unwrap(), + "from-request:50001" + ); + } + + // the per-network default indexer is gone: neither source means the unlock fails outright + #[test] + fn indexer_url_missing_from_both_sources_errors() { + assert!(matches!( + resolve_indexer_url(None, None), + Err(APIError::MissingIndexerUrl) + )); + } use crate::kv_store::SeaOrmKvStore; use lightning::rgb_utils::RgbInfo; use rln_migration::{Migrator, MigratorTrait}; @@ -6364,6 +6888,7 @@ mod tests { local_rgb_amount, remote_rgb_amount, batch_transfer_idx: None, + counterparty_knows_asset: false, }; kv_store.write_rgb_channel_info(channel_id, &info, false); } @@ -6575,4 +7100,64 @@ mod tests { assert!(schemas.contains(&AssetSchema::Uda)); } } + + #[test] + fn sweep_receive_reuse_margin_is_smaller_under_address_reuse() { + let now = 1_000_000; + let expiration = now + RGB_TRANSFER_CHAN_EXPIRATION_SECS; + + // at t+23h the 1h margin has been reached, but the reuse margin has not + let late = expiration - RGB_RECEIVE_REUSE_MARGIN_SECS; + assert!(!sweep_receive_is_reusable(late, expiration, false)); + assert!(sweep_receive_is_reusable(late, expiration, true)); + } + + #[test] + fn sweep_receive_reuse_respects_both_margin_boundaries() { + let expiration = 1_000_000; + + for (reuse, margin) in [ + (false, RGB_RECEIVE_REUSE_MARGIN_SECS), + (true, RGB_RECEIVE_REUSE_MARGIN_ADDR_REUSE_SECS), + ] { + // strictly inside the margin is reusable, the boundary itself is not + assert!(sweep_receive_is_reusable( + expiration - margin - 1, + expiration, + reuse + )); + assert!(!sweep_receive_is_reusable( + expiration - margin, + expiration, + reuse + )); + } + } + + #[test] + fn sweep_receive_past_expiry_is_never_reusable() { + let expiration = 1_000_000; + for reuse in [false, true] { + assert!(!sweep_receive_is_reusable(expiration, expiration, reuse)); + assert!(!sweep_receive_is_reusable( + expiration + 1, + expiration, + reuse + )); + } + // pins the reuse margin itself: a receive with a minute of life must never be handed out, + // otherwise it can expire mid-sweep. Asserted without reference to the constant, so + // shrinking it back towards zero fails here. + assert!(!sweep_receive_is_reusable( + expiration - 60, + expiration, + true + )); + // same for the non-reuse margin, which must stay far larger than a sweep's duration + assert!(!sweep_receive_is_reusable( + expiration - 1800, + expiration, + false + )); + } } diff --git a/src/bitcoind.rs b/src/ldk_chain_backend/block_sync.rs similarity index 63% rename from src/bitcoind.rs rename to src/ldk_chain_backend/block_sync.rs index 870fbd93..5c6701e3 100644 --- a/src/bitcoind.rs +++ b/src/ldk_chain_backend/block_sync.rs @@ -1,24 +1,30 @@ use base64::{engine::general_purpose, Engine as _}; +use bitcoin::block::Block; use bitcoin::blockdata::transaction::Transaction; use bitcoin::consensus::encode; +use bitcoin::constants::ChainHash; use bitcoin::hash_types::BlockHash; +use bitcoin::transaction::{OutPoint, TxOut}; use lightning::chain::chaininterface::{BroadcasterInterface, ConfirmationTarget, FeeEstimator}; use lightning::log_warn; +use lightning::routing::utxo::{UtxoFuture, UtxoLookup, UtxoLookupError, UtxoResult}; use lightning::util::logger::Logger; +use lightning_block_sync::gossip::UtxoSource; use lightning_block_sync::http::HttpEndpoint; use lightning_block_sync::http::JsonResponse; use lightning_block_sync::rpc::RpcClient; use lightning_block_sync::{AsyncBlockSourceResult, BlockData, BlockHeaderData, BlockSource}; -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::convert::TryInto; use std::str::FromStr; -use std::sync::atomic::{AtomicU32, Ordering}; -use std::sync::Arc; +use std::sync::atomic::AtomicU32; +use std::sync::{Arc, Mutex}; use std::time::Duration; use crate::disk::FilesystemLogger; -#[cfg(test)] -use crate::fee_mock::mock_fee; +use crate::ldk::P2PGossipSync; + +use super::{default_fee_buckets, fee_from_bucket, store_fee_estimates, MIN_FEERATE}; pub struct BitcoindClient { pub(crate) bitcoind_rpc_client: Arc, @@ -109,9 +115,6 @@ impl TryInto for JsonResponse { } } -/// The minimum feerate we are allowed to send, as specify by LDK. -const MIN_FEERATE: u32 = 253; - impl BitcoindClient { pub(crate) async fn new( host: String, @@ -136,40 +139,9 @@ impl BitcoindClient { std::io::Error::new(std::io::ErrorKind::PermissionDenied, "failed to make initial call to bitcoind - please check your RPC user/password and access settings") })?; - let mut fees: HashMap = HashMap::new(); - fees.insert( - ConfirmationTarget::MaximumFeeEstimate, - AtomicU32::new(50000), - ); - fees.insert(ConfirmationTarget::UrgentOnChainSweep, AtomicU32::new(5000)); - fees.insert( - ConfirmationTarget::MinAllowedAnchorChannelRemoteFee, - AtomicU32::new(MIN_FEERATE), - ); - fees.insert( - ConfirmationTarget::MinAllowedNonAnchorChannelRemoteFee, - AtomicU32::new(MIN_FEERATE), - ); - fees.insert( - ConfirmationTarget::AnchorChannelFee, - AtomicU32::new(MIN_FEERATE), - ); - fees.insert( - ConfirmationTarget::NonAnchorChannelFee, - AtomicU32::new(2000), - ); - fees.insert( - ConfirmationTarget::ChannelCloseMinimum, - AtomicU32::new(MIN_FEERATE), - ); - fees.insert( - ConfirmationTarget::OutputSpendingFee, - AtomicU32::new(MIN_FEERATE), - ); - let client = Self { bitcoind_rpc_client: Arc::new(bitcoind_rpc_client), - fees: Arc::new(fees), + fees: Arc::new(default_fee_buckets()), handle: handle.clone(), logger, }; @@ -268,30 +240,14 @@ impl BitcoindClient { ) .await; - fees.get(&ConfirmationTarget::MaximumFeeEstimate) - .unwrap() - .store(very_high_prio_estimate, Ordering::Release); - fees.get(&ConfirmationTarget::UrgentOnChainSweep) - .unwrap() - .store(high_prio_estimate, Ordering::Release); - fees.get(&ConfirmationTarget::MinAllowedAnchorChannelRemoteFee) - .unwrap() - .store(mempoolmin_estimate, Ordering::Release); - fees.get(&ConfirmationTarget::MinAllowedNonAnchorChannelRemoteFee) - .unwrap() - .store(background_estimate - 250, Ordering::Release); - fees.get(&ConfirmationTarget::AnchorChannelFee) - .unwrap() - .store(background_estimate, Ordering::Release); - fees.get(&ConfirmationTarget::NonAnchorChannelFee) - .unwrap() - .store(normal_estimate, Ordering::Release); - fees.get(&ConfirmationTarget::ChannelCloseMinimum) - .unwrap() - .store(background_estimate, Ordering::Release); - fees.get(&ConfirmationTarget::OutputSpendingFee) - .unwrap() - .store(background_estimate, Ordering::Release); + store_fee_estimates( + &fees, + background_estimate, + normal_estimate, + high_prio_estimate, + very_high_prio_estimate, + mempoolmin_estimate, + ); tokio::time::sleep(Duration::from_secs(refresh_interval_secs)).await; } @@ -308,14 +264,7 @@ impl BitcoindClient { impl FeeEstimator for BitcoindClient { fn get_est_sat_per_1000_weight(&self, confirmation_target: ConfirmationTarget) -> u32 { - let fee = self - .fees - .get(&confirmation_target) - .unwrap() - .load(Ordering::Acquire); - #[cfg(test)] - let fee = mock_fee(fee); - fee + fee_from_bucket(&self.fees, confirmation_target) } } @@ -359,3 +308,136 @@ impl BroadcasterInterface for BitcoindClient { }); } } + +// `lightning-block-sync`'s own `GossipVerifier` requires the `P2PGossipSync` to be typed with +// `Arc` as its UTXO lookup, which is incompatible with the trait-object lookup that lets a +// single `PeerManager` type serve both sync backends +pub(crate) struct BlockSyncGossipVerifier { + source: Arc, + gossiper: Arc, + peer_manager_wake: Arc, + handle: tokio::runtime::Handle, + block_cache: Arc>>, +} + +const BLOCK_CACHE_SIZE: usize = 5; + +impl BlockSyncGossipVerifier { + pub(crate) fn new( + source: Arc, + gossiper: Arc, + peer_manager_wake: Arc, + handle: tokio::runtime::Handle, + ) -> Self { + Self { + source, + gossiper, + peer_manager_wake, + handle, + block_cache: Arc::new(Mutex::new(VecDeque::with_capacity(BLOCK_CACHE_SIZE))), + } + } + + async fn retrieve_utxo( + source: Arc, + block_cache: Arc>>, + short_channel_id: u64, + ) -> Result { + let block_height = (short_channel_id >> (5 * 8)) as u32; // most significant three bytes + let transaction_index = ((short_channel_id >> (2 * 8)) & 0x00ff_ffff) as u32; + let output_index = (short_channel_id & 0xffff) as u16; + + let (outpoint, output); + + 'tx_found: { + macro_rules! process_block { + ($block: expr) => {{ + if transaction_index as usize >= $block.txdata.len() { + return Err(UtxoLookupError::UnknownTx); + } + let transaction = &$block.txdata[transaction_index as usize]; + if output_index as usize >= transaction.output.len() { + return Err(UtxoLookupError::UnknownTx); + } + outpoint = OutPoint::new(transaction.compute_txid(), output_index.into()); + output = transaction.output[output_index as usize].clone(); + }}; + } + // Serve the funding output from a recently-fetched block when possible, so a burst of + // announcements referencing the same block only fetches it once + { + let recent_blocks = block_cache.lock().unwrap(); + for (height, block) in recent_blocks.iter() { + if *height == block_height { + process_block!(block); + break 'tx_found; + } + } + } + + let (_, tip_height_opt) = source + .get_best_block() + .await + .map_err(|_| UtxoLookupError::UnknownTx)?; + let block_hash = source + .get_block_hash_by_height(block_height) + .await + .map_err(|_| UtxoLookupError::UnknownTx)?; + if let Some(tip_height) = tip_height_opt { + // The BOLT spec requires nodes to wait for six confirmations before + // announcing a channel; give one block of headroom. + if block_height + 5 > tip_height { + return Err(UtxoLookupError::UnknownTx); + } + } + let block = match source + .get_block(&block_hash) + .await + .map_err(|_| UtxoLookupError::UnknownTx)? + { + BlockData::HeaderOnly(_) => return Err(UtxoLookupError::UnknownTx), + BlockData::FullBlock(block) => block, + }; + process_block!(block); + { + let mut recent_blocks = block_cache.lock().unwrap(); + if !recent_blocks + .iter() + .any(|(height, _)| *height == block_height) + { + if recent_blocks.len() >= BLOCK_CACHE_SIZE { + recent_blocks.pop_front(); + } + recent_blocks.push_back((block_height, block)); + } + } + } + + if source + .is_output_unspent(outpoint) + .await + .map_err(|_| UtxoLookupError::UnknownTx)? + { + Ok(output) + } else { + Err(UtxoLookupError::UnknownTx) + } + } +} + +impl UtxoLookup for BlockSyncGossipVerifier { + fn get_utxo(&self, _chain_hash: &ChainHash, short_channel_id: u64) -> UtxoResult { + let res = UtxoFuture::new(); + let fut = res.clone(); + let source = Arc::clone(&self.source); + let gossiper = Arc::clone(&self.gossiper); + let peer_manager_wake = Arc::clone(&self.peer_manager_wake); + let block_cache = Arc::clone(&self.block_cache); + self.handle.spawn(async move { + let lookup = Self::retrieve_utxo(source, block_cache, short_channel_id).await; + fut.resolve(gossiper.network_graph(), &*gossiper, lookup); + peer_manager_wake(); + }); + UtxoResult::Async(res) + } +} diff --git a/src/ldk_chain_backend/mod.rs b/src/ldk_chain_backend/mod.rs new file mode 100644 index 00000000..1d8543ac --- /dev/null +++ b/src/ldk_chain_backend/mod.rs @@ -0,0 +1,128 @@ +#[cfg(feature = "block-sync")] +pub(crate) mod block_sync; +#[cfg(feature = "transaction-sync")] +pub(crate) mod transaction_sync; + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Arc; + +use lightning::chain::chaininterface::ConfirmationTarget; +#[cfg(feature = "transaction-sync")] +use lightning::chain::Confirm; +use lightning::chain::{BestBlock, Filter}; + +// the chain backends are used as trait objects so a single set of LDK type aliases works +// regardless of the selected sync mode +pub(crate) type DynFeeEstimator = dyn lightning::chain::chaininterface::FeeEstimator + Send + Sync; +pub(crate) type DynBroadcaster = + dyn lightning::chain::chaininterface::BroadcasterInterface + Send + Sync; + +pub(crate) const MIN_FEERATE: u32 = 253; + +pub(crate) enum ChainBackend { + #[cfg(feature = "block-sync")] + BlockSync { + client: Arc, + polled_chain_tip: lightning_block_sync::poll::ValidatedBlockHeader, + }, + #[cfg(feature = "transaction-sync")] + TransactionSync { + client: Arc, + tx_sync: Arc, + }, +} + +pub(crate) struct ChainSetup { + pub(crate) backend: ChainBackend, + pub(crate) fee_estimator: Arc, + pub(crate) broadcaster: Arc, + pub(crate) chain_filter: Option>, + pub(crate) initial_best_block: BestBlock, +} + +#[cfg(feature = "transaction-sync")] +pub(crate) async fn sync_chain_data( + tx_sync: Arc, + confirmables: Vec>, +) -> Result<(), Box> { + tokio::task::spawn_blocking(move || tx_sync.sync(confirmables)) + .await + .map_err(|e| -> Box { Box::new(e) })? +} + +pub(crate) fn default_fee_buckets() -> HashMap { + let mut fees = HashMap::new(); + fees.insert( + ConfirmationTarget::MaximumFeeEstimate, + AtomicU32::new(50000), + ); + fees.insert(ConfirmationTarget::UrgentOnChainSweep, AtomicU32::new(5000)); + fees.insert( + ConfirmationTarget::MinAllowedAnchorChannelRemoteFee, + AtomicU32::new(MIN_FEERATE), + ); + fees.insert( + ConfirmationTarget::MinAllowedNonAnchorChannelRemoteFee, + AtomicU32::new(MIN_FEERATE), + ); + fees.insert( + ConfirmationTarget::AnchorChannelFee, + AtomicU32::new(MIN_FEERATE), + ); + fees.insert( + ConfirmationTarget::NonAnchorChannelFee, + AtomicU32::new(2000), + ); + fees.insert( + ConfirmationTarget::ChannelCloseMinimum, + AtomicU32::new(MIN_FEERATE), + ); + fees.insert( + ConfirmationTarget::OutputSpendingFee, + AtomicU32::new(MIN_FEERATE), + ); + fees +} + +fn fee_from_bucket( + fees: &HashMap, + confirmation_target: ConfirmationTarget, +) -> u32 { + let fee = fees + .get(&confirmation_target) + .unwrap() + .load(Ordering::Acquire); + #[cfg(test)] + let fee = crate::fee_mock::mock_fee(fee); + fee +} + +// both backends map their four priority estimates onto the confirmation targets the same way, +// they differ only in the value used for `MinAllowedAnchorChannelRemoteFee` +fn store_fee_estimates( + fees: &HashMap, + background: u32, + normal: u32, + high_prio: u32, + very_high_prio: u32, + min_allowed_anchor: u32, +) { + let set = |target: ConfirmationTarget, value: u32| { + fees.get(&target).unwrap().store(value, Ordering::Release); + }; + set(ConfirmationTarget::MaximumFeeEstimate, very_high_prio); + set(ConfirmationTarget::UrgentOnChainSweep, high_prio); + set( + ConfirmationTarget::MinAllowedAnchorChannelRemoteFee, + min_allowed_anchor, + ); + set( + ConfirmationTarget::MinAllowedNonAnchorChannelRemoteFee, + background.saturating_sub(250), + ); + set(ConfirmationTarget::AnchorChannelFee, background); + set(ConfirmationTarget::NonAnchorChannelFee, normal); + set(ConfirmationTarget::ChannelCloseMinimum, background); + set(ConfirmationTarget::OutputSpendingFee, background); +} diff --git a/src/ldk_chain_backend/transaction_sync.rs b/src/ldk_chain_backend/transaction_sync.rs new file mode 100644 index 00000000..bf62c67d --- /dev/null +++ b/src/ldk_chain_backend/transaction_sync.rs @@ -0,0 +1,596 @@ +use bitcoin::blockdata::transaction::Transaction; +use bitcoin::constants::ChainHash; +use bitcoin::{Script, TxOut, Txid}; +use lightning::chain::chaininterface::{BroadcasterInterface, ConfirmationTarget, FeeEstimator}; +use lightning::chain::{BestBlock, Confirm, Filter, WatchedOutput}; +use lightning::log_warn; +use lightning::routing::utxo::{UtxoFuture, UtxoLookup, UtxoLookupError, UtxoResult}; +use lightning::util::logger::Logger; +use rgb_lib::wallet::rust_only::IndexerProtocol as RgbLibIndexerProtocol; +use std::collections::HashMap; +use std::io; +use std::sync::atomic::AtomicU32; +use std::sync::Arc; +use std::time::Duration; + +#[cfg(feature = "electrum")] +use { + bitcoin::consensus::encode, + electrum_client::{Client as ElectrumClient, ElectrumApi, Param}, + lightning_transaction_sync::ElectrumSyncClient, + std::str::FromStr, +}; + +#[cfg(feature = "esplora")] +use { + esplora_client::blocking::BlockingClient as EsploraBlockingClient, + esplora_client::Builder as EsploraBuilder, lightning_transaction_sync::EsploraSyncClient, + std::collections::BTreeMap, +}; + +use crate::disk::FilesystemLogger; +use crate::ldk::P2PGossipSync; + +use super::{default_fee_buckets, fee_from_bucket, store_fee_estimates, MIN_FEERATE}; + +type Confirmable = Arc; + +enum IndexerBackend { + #[cfg(feature = "electrum")] + Electrum(Arc), + #[cfg(feature = "esplora")] + Esplora(Arc), +} + +pub(crate) struct IndexerClient { + backend: IndexerBackend, + fees: Arc>, + handle: tokio::runtime::Handle, + logger: Arc, +} + +pub(crate) struct IndexerGossipVerifier { + client: Arc, + gossiper: Arc, + peer_manager_wake: Arc, +} + +pub(crate) enum IndexerSyncClient { + #[cfg(feature = "electrum")] + Electrum(ElectrumSyncClient>), + #[cfg(feature = "esplora")] + Esplora(EsploraSyncClient>), +} + +// `check_indexer_url` only ever returns a protocol whose feature is enabled, so this is just a +// safety net for the single-protocol builds +#[cfg(not(all(feature = "electrum", feature = "esplora")))] +fn unsupported_protocol(protocol: RgbLibIndexerProtocol) -> io::Error { + io::Error::other(format!("{protocol} support is not enabled")) +} + +impl IndexerClient { + #[cfg_attr(not(feature = "esplora"), allow(unused_variables))] + pub(crate) fn new( + server_url: String, + protocol: RgbLibIndexerProtocol, + handle: tokio::runtime::Handle, + logger: Arc, + timeout_secs: u64, + fee_refresh_interval_secs: u64, + ) -> io::Result { + let fees = Arc::new(default_fee_buckets()); + let backend = match protocol { + #[cfg(feature = "electrum")] + RgbLibIndexerProtocol::Electrum => { + let client = Arc::new(ElectrumClient::new(&server_url).map_err(|e| { + io::Error::other(format!("failed to connect to electrum server: {e}")) + })?); + client.server_features().map_err(|e| { + io::Error::other(format!("failed to query electrum server features: {e}")) + })?; + poll_electrum_fee_estimates( + fees.clone(), + client.clone(), + logger.clone(), + handle.clone(), + fee_refresh_interval_secs, + ); + IndexerBackend::Electrum(client) + } + #[cfg(feature = "esplora")] + RgbLibIndexerProtocol::Esplora => { + // bounded socket timeout so a hung endpoint doesn't block runtime shutdown + let client = Arc::new( + EsploraBuilder::new(&server_url) + .timeout(timeout_secs) + .build_blocking(), + ); + client.get_tip_hash().map_err(|e| { + io::Error::other(format!("failed to connect to esplora server: {e}")) + })?; + poll_esplora_fee_estimates( + fees.clone(), + client.clone(), + logger.clone(), + handle.clone(), + fee_refresh_interval_secs, + ); + IndexerBackend::Esplora(client) + } + #[cfg(not(all(feature = "electrum", feature = "esplora")))] + protocol => return Err(unsupported_protocol(protocol)), + }; + + Ok(Self { + backend, + fees, + handle, + logger, + }) + } + + pub(crate) fn get_best_block(&self) -> io::Result { + match &self.backend { + #[cfg(feature = "electrum")] + IndexerBackend::Electrum(client) => { + let tip = client.block_headers_subscribe().map_err(|e| { + io::Error::other(format!("failed to fetch electrum tip header: {e}")) + })?; + Ok(BestBlock::new(tip.header.block_hash(), tip.height as u32)) + } + #[cfg(feature = "esplora")] + IndexerBackend::Esplora(client) => { + let tip_hash = client.get_tip_hash().map_err(|e| { + io::Error::other(format!("failed to fetch esplora tip hash: {e}")) + })?; + let tip_height = client + .get_block_status(&tip_hash) + .map_err(|e| { + io::Error::other(format!("failed to fetch esplora tip status: {e}")) + })? + .height + .ok_or_else(|| io::Error::other("esplora tip block has no height"))?; + Ok(BestBlock::new(tip_hash, tip_height)) + } + } + } + + fn tip_height(&self) -> io::Result { + Ok(self.get_best_block()?.height) + } + + // whether `txid`'s output at `vout` has not been spent yet. electrum has no way to query an + // outpoint directly, so its unspent set is queried by script and filtered down to the outpoint + fn is_output_unspent(&self, txid: &Txid, vout: usize, txout: &TxOut) -> io::Result { + match &self.backend { + #[cfg(feature = "electrum")] + IndexerBackend::Electrum(client) => { + let unspents = client + .script_list_unspent(&txout.script_pubkey) + .map_err(|e| { + io::Error::other(format!("failed to fetch electrum unspents: {e}")) + })?; + Ok(unspents + .iter() + .any(|unspent| unspent.tx_hash == *txid && unspent.tx_pos == vout)) + } + #[cfg(feature = "esplora")] + IndexerBackend::Esplora(client) => { + // esplora queries the outpoint directly, so the output itself is not needed + let _ = txout; + let status = client.get_output_status(txid, vout as u64).map_err(|e| { + io::Error::other(format!("failed to fetch esplora output status: {e}")) + })?; + // an unknown output is treated as spent, so an announcement is never resolved + // against an output the indexer cannot vouch for + Ok(status.is_some_and(|status| !status.spent)) + } + } + } +} + +impl IndexerGossipVerifier { + pub(crate) fn new( + client: Arc, + gossiper: Arc, + peer_manager_wake: Arc, + ) -> Self { + Self { + client, + gossiper, + peer_manager_wake, + } + } +} + +impl UtxoLookup for IndexerGossipVerifier { + fn get_utxo(&self, _chain_hash: &ChainHash, short_channel_id: u64) -> UtxoResult { + let result = UtxoFuture::new(); + let future = result.clone(); + let client = self.client.clone(); + let gossiper = self.gossiper.clone(); + let peer_manager_wake = self.peer_manager_wake.clone(); + self.client.handle.spawn(async move { + let lookup = tokio::task::spawn_blocking(move || { + let height = (short_channel_id >> 40) as u32; + let tx_index = ((short_channel_id >> 16) & 0x00ff_ffff) as usize; + let vout = (short_channel_id & 0xffff) as usize; + + // like the block-sync gossip verifier, require the funding output to be buried by + // at least six confirmations (with one block of headroom) before resolving it + let tip_height = client + .tip_height() + .map_err(|_| UtxoLookupError::UnknownTx)?; + if height + 5 > tip_height { + return Err(UtxoLookupError::UnknownTx); + } + + let funding = match &client.backend { + #[cfg(feature = "electrum")] + IndexerBackend::Electrum(c) => { + match electrum_txid_from_pos(c, height as usize, tx_index) + .and_then(|txid| Ok((txid, c.transaction_get(&txid)?))) + { + Ok((txid, tx)) => { + tx.output.get(vout).cloned().map(|txout| (txid, txout)) + } + Err(_) => None, + } + } + #[cfg(feature = "esplora")] + IndexerBackend::Esplora(c) => c + .get_block_hash(height) + .and_then(|block_hash| c.get_txid_at_block_index(&block_hash, tx_index)) + .and_then(|txid| match txid { + Some(txid) => c.get_tx_no_opt(&txid).map(|tx| Some((txid, tx))), + None => Ok(None), + }) + .ok() + .flatten() + .and_then(|(txid, tx)| { + tx.output.get(vout).cloned().map(|txout| (txid, txout)) + }), + }; + + let (txid, txout) = funding.ok_or(UtxoLookupError::UnknownTx)?; + + // like the block-sync gossip verifier, only resolve the announcement if the + // funding output is still unspent, so closed channels don't enter the graph + if !client + .is_output_unspent(&txid, vout, &txout) + .map_err(|_| UtxoLookupError::UnknownTx)? + { + return Err(UtxoLookupError::UnknownTx); + } + + Ok(txout) + }) + .await + .unwrap_or(Err(UtxoLookupError::UnknownTx)); + future.resolve(gossiper.network_graph(), &*gossiper, lookup); + peer_manager_wake(); + }); + UtxoResult::Async(result) + } +} + +#[cfg(feature = "electrum")] +fn electrum_txid_from_pos( + client: &ElectrumClient, + height: usize, + tx_pos: usize, +) -> Result { + let value = client.raw_call( + "blockchain.transaction.id_from_pos", + [ + Param::Usize(height), + Param::Usize(tx_pos), + Param::Bool(true), + ], + )?; + let txid = value + .as_str() + .or_else(|| value.get("tx_hash").and_then(serde_json::Value::as_str)) + .or_else(|| value.get("txid").and_then(serde_json::Value::as_str)) + .or_else(|| value.get("tx_id").and_then(serde_json::Value::as_str)) + .map(str::to_owned) + .ok_or_else(|| electrum_client::Error::InvalidResponse(value.clone()))?; + + Txid::from_str(&txid).map_err(|_| electrum_client::Error::InvalidResponse(value)) +} + +impl IndexerSyncClient { + pub(crate) fn new( + server_url: String, + protocol: RgbLibIndexerProtocol, + logger: Arc, + ) -> io::Result { + match protocol { + #[cfg(feature = "electrum")] + RgbLibIndexerProtocol::Electrum => { + let client = ElectrumSyncClient::new(server_url, logger).map_err(|e| { + io::Error::other(format!("failed to initialize electrum sync client: {e}")) + })?; + Ok(Self::Electrum(client)) + } + #[cfg(feature = "esplora")] + RgbLibIndexerProtocol::Esplora => { + Ok(Self::Esplora(EsploraSyncClient::new(server_url, logger))) + } + #[cfg(not(all(feature = "electrum", feature = "esplora")))] + protocol => Err(unsupported_protocol(protocol)), + } + } + + pub(crate) fn sync( + &self, + confirmables: Vec, + ) -> Result<(), Box> { + match self { + #[cfg(feature = "electrum")] + Self::Electrum(client) => client + .sync(confirmables) + .map_err(|e| -> Box { Box::new(e) }), + #[cfg(feature = "esplora")] + Self::Esplora(client) => client + .sync(confirmables) + .map_err(|e| -> Box { Box::new(e) }), + } + } +} + +impl Filter for IndexerSyncClient { + fn register_tx(&self, txid: &Txid, script_pubkey: &Script) { + match self { + #[cfg(feature = "electrum")] + Self::Electrum(client) => client.register_tx(txid, script_pubkey), + #[cfg(feature = "esplora")] + Self::Esplora(client) => client.register_tx(txid, script_pubkey), + } + } + + fn register_output(&self, output: WatchedOutput) { + match self { + #[cfg(feature = "electrum")] + Self::Electrum(client) => client.register_output(output), + #[cfg(feature = "esplora")] + Self::Esplora(client) => client.register_output(output), + } + } +} + +impl FeeEstimator for IndexerClient { + fn get_est_sat_per_1000_weight(&self, confirmation_target: ConfirmationTarget) -> u32 { + fee_from_bucket(&self.fees, confirmation_target) + } +} + +impl BroadcasterInterface for IndexerClient { + fn broadcast_transactions(&self, txs: &[&Transaction]) { + match &self.backend { + #[cfg(feature = "electrum")] + IndexerBackend::Electrum(client) => { + let txs = txs + .iter() + .map(|tx| encode::serialize(*tx)) + .collect::>(); + let client = client.clone(); + let logger = self.logger.clone(); + self.handle.spawn(async move { + let res = tokio::task::spawn_blocking(move || { + let mut last_error = None; + for tx in txs { + if let Err(e) = client.transaction_broadcast_raw(&tx) { + last_error = Some(e.to_string()); + } + } + last_error.map_or(Ok(()), Err) + }) + .await; + + match res { + Ok(Ok(())) => {} + Ok(Err(e)) => { + log_warn!( + logger, + "Warning, failed to broadcast transaction(s) via electrum: {}", + e + ); + } + Err(e) => { + log_warn!( + logger, + "Warning, failed to spawn electrum broadcaster task: {}", + e + ); + } + } + }); + } + #[cfg(feature = "esplora")] + IndexerBackend::Esplora(client) => { + let txs = txs.iter().map(|tx| (*tx).clone()).collect::>(); + let client = client.clone(); + let logger = self.logger.clone(); + self.handle.spawn(async move { + let res = tokio::task::spawn_blocking(move || { + let mut last_error = None; + for tx in txs { + if let Err(e) = client.broadcast(&tx) { + last_error = Some(e.to_string()); + } + } + last_error.map_or(Ok(()), Err) + }) + .await; + + match res { + Ok(Ok(())) => {} + Ok(Err(e)) => { + log_warn!( + logger, + "Warning, failed to broadcast transaction(s) via esplora: {}", + e + ); + } + Err(e) => { + log_warn!( + logger, + "Warning, failed to spawn esplora broadcaster task: {}", + e + ); + } + } + }); + } + } + } +} + +#[cfg(feature = "electrum")] +fn poll_electrum_fee_estimates( + fees: Arc>, + client: Arc, + logger: Arc, + handle: tokio::runtime::Handle, + refresh_interval_secs: u64, +) { + handle.spawn(async move { + loop { + let res = tokio::task::spawn_blocking({ + let client = client.clone(); + move || { + Ok::<_, electrum_client::Error>(( + client.estimate_fee(144)?, + client.estimate_fee(18)?, + client.estimate_fee(6)?, + client.estimate_fee(2)?, + )) + } + }) + .await; + + match res { + Ok(Ok((background, normal, high_prio, very_high_prio))) => { + let background_estimate = fee_rate_from_btc_per_kb(background, MIN_FEERATE); + let normal_estimate = fee_rate_from_btc_per_kb(normal, 2000); + let high_prio_estimate = fee_rate_from_btc_per_kb(high_prio, 5000); + let very_high_prio_estimate = fee_rate_from_btc_per_kb(very_high_prio, 50000); + + store_fee_estimates( + &fees, + background_estimate, + normal_estimate, + high_prio_estimate, + very_high_prio_estimate, + MIN_FEERATE, + ); + } + Ok(Err(e)) => { + log_warn!(logger, "Error getting fee estimate from electrum: {}", e); + } + Err(e) => { + log_warn!(logger, "Error polling electrum fee estimates: {}", e); + } + } + + tokio::time::sleep(Duration::from_secs(refresh_interval_secs)).await; + } + }); +} + +#[cfg(feature = "esplora")] +fn poll_esplora_fee_estimates( + fees: Arc>, + client: Arc, + logger: Arc, + handle: tokio::runtime::Handle, + refresh_interval_secs: u64, +) { + handle.spawn(async move { + loop { + let res = tokio::task::spawn_blocking({ + let client = client.clone(); + move || client.get_fee_estimates() + }) + .await; + + match res { + Ok(Ok(estimate_map)) => { + let estimate_map = + BTreeMap::from_iter(estimate_map.iter().map(|(k, v)| (*k, *v))); + let background_estimate = + estimate_fee_rate_sat_per_kw(&estimate_map, 144, MIN_FEERATE); + let normal_estimate = estimate_fee_rate_sat_per_kw(&estimate_map, 18, 2000); + let high_prio_estimate = estimate_fee_rate_sat_per_kw(&estimate_map, 6, 5000); + let very_high_prio_estimate = + estimate_fee_rate_sat_per_kw(&estimate_map, 2, 50000); + + store_fee_estimates( + &fees, + background_estimate, + normal_estimate, + high_prio_estimate, + very_high_prio_estimate, + MIN_FEERATE, + ); + } + Ok(Err(e)) => { + log_warn!(logger, "Error getting fee estimate from esplora: {}", e) + } + Err(e) => log_warn!(logger, "Error polling esplora fee estimates: {}", e), + } + + tokio::time::sleep(Duration::from_secs(refresh_interval_secs)).await; + } + }); +} + +#[cfg(feature = "esplora")] +pub(crate) fn estimate_fee_rate_sat_per_kw( + estimate_map: &BTreeMap, + blocks: u16, + default: u32, +) -> u32 { + let Some(sat_per_vb) = interpolate_fee_rate(estimate_map, blocks) else { + return default; + }; + std::cmp::max((sat_per_vb * 250.0).round() as u32, MIN_FEERATE) +} + +#[cfg(feature = "esplora")] +pub(crate) fn interpolate_fee_rate(estimate_map: &BTreeMap, blocks: u16) -> Option { + if blocks == 0 || estimate_map.is_empty() { + return None; + } + + if let Some(estimate) = estimate_map.get(&blocks) { + return Some(*estimate); + } + + let lower_key = estimate_map.range(..blocks).next_back().map(|(k, _)| *k); + let upper_key = estimate_map.range(blocks..).next().map(|(k, _)| *k); + + match (lower_key, upper_key) { + (Some(x1), Some(x2)) if x1 != x2 => { + let y1 = estimate_map[&x1]; + let y2 = estimate_map[&x2]; + Some(y1 + (blocks as f64 - x1 as f64) / (x2 as f64 - x1 as f64) * (y2 - y1)) + } + (Some(x), _) | (_, Some(x)) => estimate_map.get(&x).copied(), + _ => None, + } +} + +#[cfg(feature = "electrum")] +// electrum reports a negative feerate when it has no estimate available +fn fee_rate_from_btc_per_kb(feerate_btc_per_kb: f64, default: u32) -> u32 { + if !feerate_btc_per_kb.is_finite() || feerate_btc_per_kb.is_sign_negative() { + return default; + } + std::cmp::max( + (feerate_btc_per_kb * 100_000_000.0 / 4.0).round() as u32, + MIN_FEERATE, + ) +} diff --git a/src/lib.rs b/src/lib.rs index e22f71c0..8912c2b1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,22 @@ #![allow(dead_code)] #![allow(unused_imports)] +#[cfg(not(any(feature = "electrum", feature = "esplora")))] +compile_error!("at least one of the `electrum` and `esplora` features needs to be enabled"); + +#[cfg(not(any(feature = "block-sync", feature = "transaction-sync")))] +compile_error!( + "at least one of the `block-sync` and `transaction-sync` features needs to be enabled" +); + +// the generated bindings cannot express cargo-feature gating on `LdkChainSync`, so they need +// both sync backends compiled in +#[cfg(all( + feature = "uniffi", + not(all(feature = "block-sync", feature = "transaction-sync")) +))] +compile_error!("the `uniffi` bindings require both `block-sync` and `transaction-sync`"); + mod apay_merkle; mod args; mod asset_link; @@ -10,10 +26,9 @@ mod async_kv_store; mod async_order; mod auth; mod backup; -mod bitcoind; -mod chain_backend; mod config; mod core_types; +mod crypto; mod custom_msg_rpc; mod database; mod disk; @@ -24,11 +39,12 @@ mod fee_mock; #[cfg(feature = "uniffi")] pub mod ffi; mod gossip; -mod indexer; mod kv_store; mod ldk; +mod ldk_chain_backend; mod node; mod rgb; +mod rgb_file_transfer; mod routes; mod runtime; mod sdk; diff --git a/src/main.rs b/src/main.rs index 21df52ca..10feb717 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,11 @@ +#[cfg(not(any(feature = "electrum", feature = "esplora")))] +compile_error!("at least one of the `electrum` and `esplora` features needs to be enabled"); + +#[cfg(not(any(feature = "block-sync", feature = "transaction-sync")))] +compile_error!( + "at least one of the `block-sync` and `transaction-sync` features needs to be enabled" +); + mod apay_merkle; mod args; mod asset_link; @@ -6,10 +14,9 @@ mod async_kv_store; mod async_order; mod auth; mod backup; -mod bitcoind; -mod chain_backend; mod config; mod core_types; +mod crypto; mod custom_msg_rpc; mod database; mod disk; @@ -18,10 +25,11 @@ mod error; #[path = "test/fee_mock.rs"] mod fee_mock; mod gossip; -mod indexer; mod kv_store; mod ldk; +mod ldk_chain_backend; mod rgb; +mod rgb_file_transfer; mod routes; mod runtime; mod signer; @@ -31,7 +39,9 @@ mod utils; #[cfg(feature = "vss")] mod vss_kv_store; -#[cfg(test)] +// the test suite calls into `electrum_client` to wait for electrs to catch up with bitcoind, and +// that crate is only pulled in by the `electrum` feature +#[cfg(all(test, feature = "electrum"))] mod test; use anyhow::Result; @@ -43,7 +53,11 @@ use axum::{ routing::{get, post}, Router, }; -use std::{net::SocketAddr, sync::Arc, time::Duration}; +use std::{ + net::SocketAddr, + sync::Arc, + time::{Duration, Instant}, +}; use tokio::signal; use tower_http::cors::CorsLayer; use tower_http::limit::RequestBodyLimitLayer; @@ -62,6 +76,7 @@ use crate::args::UserArgs; use crate::auth::conditional_auth_middleware; use crate::error::AppError; use crate::ldk::stop_ldk; +use crate::rgb_file_transfer::MAX_CONSIGNMENT_SIZE; #[cfg(feature = "remote-signer")] use crate::routes::init_external_signer; use crate::routes::{ @@ -69,17 +84,21 @@ use crate::routes::{ async_order_outbound_invoice, backup, btc_balance, cancel_hodl_invoice, change_password, check_indexer_url, check_proxy_endpoint, claim_hodl_invoice, close_channel, connect_peer, create_utxos, decode_ln_invoice, decode_rgb_invoice, decode_swapstring, disconnect_peer, - estimate_fee, fail_transfers, get_asset_media, get_channel_id, get_payment, get_swap, inflate, - init, invoice_status, issue_asset_cfa, issue_asset_ifa, issue_asset_nia, issue_asset_uda, - keysend, list_assets, list_channels, list_payments, list_peers, list_swaps, list_transactions, - list_transfers, list_unspents, ln_invoice, lock, maker_execute, maker_init, network_info, - node_info, open_channel, post_asset_media, refresh_transfers, restore, revoke_token, - rgb_invoice, rotate_address, send_btc, send_onion_message, send_payment, send_rgb, shutdown, - sign_message, sync, taker, unlock, + estimate_fee, fail_transfers, get_asset_media, get_channel_id, get_consignment, get_payment, + get_swap, inflate, init, invoice_status, issue_asset_cfa, issue_asset_ifa, issue_asset_nia, + issue_asset_uda, keysend, list_assets, list_channels, list_payments, list_peers, list_swaps, + list_transactions, list_transfers, list_unspents, ln_invoice, lock, maker_execute, maker_init, + network_info, node_info, open_channel, post_asset_media, provide_out_of_band_ack, + provide_out_of_band_consignment, refresh_transfers, restore, revoke_token, rgb_invoice, + rotate_address, send_btc, send_onion_message, send_payment, send_rgb, shutdown, sign_message, + sync, taker, unlock, }; #[cfg(feature = "vss")] use crate::routes::{vss_backup, vss_backup_info, vss_clear_fence}; -use crate::utils::{start_daemon, AppState, LOGS_DIR}; +use crate::utils::{fatal_exit_code, start_daemon, AppState, FATAL_ERROR, LOGS_DIR}; + +// how long a fatal shutdown waits for an in-progress state change (unlock or lock) +const STATE_CHANGE_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(60); #[tokio::main] async fn main() -> Result<()> { @@ -109,6 +128,17 @@ async fn main() -> Result<()> { let (router, app_state) = app(args).await?; + // The default hook only writes to stderr, which never reaches the file logger, and a panic on + // any thread has to start the shutdown so the node does not keep serving half-dead. + let default_panic_hook = std::panic::take_hook(); + let cancel_token = app_state.cancel_token.clone(); + std::panic::set_hook(Box::new(move |panic_info| { + tracing::error!("{panic_info}"); + let _ = FATAL_ERROR.set(panic_info.to_string()); + cancel_token.cancel(); + default_panic_hook(panic_info); + })); + tracing::info!("Listening on {}", addr); let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); axum::serve(listener, router) @@ -116,6 +146,18 @@ async fn main() -> Result<()> { .await .unwrap(); + let exit_code = fatal_exit_code(); + if exit_code != 0 { + tracing::error!( + "Shutting down due to fatal error: {}", + FATAL_ERROR.get().map(String::as_str).unwrap_or_default() + ); + // `process::exit` runs no destructors, so the file logger has to be flushed by hand: + // dropping the guard waits for the appender to write out what is still buffered + drop(_guard); + std::process::exit(exit_code); + } + Ok(()) } @@ -129,6 +171,13 @@ pub(crate) async fn app(args: UserArgs) -> Result<(Router, Arc), AppEr args.max_media_upload_size_mb as usize * 1024 * 1024, )), ) + .route( + "/provideoutofbandconsignment", + post(provide_out_of_band_consignment).layer(RequestBodyLimitLayer::new( + args.max_aggregated_media_size_per_channel_mb as usize * 1024 * 1024 + + MAX_CONSIGNMENT_SIZE, + )), + ) // all routes before this will have the default body limit disabled .layer(DefaultBodyLimit::disable()) .route("/address", post(address)) @@ -155,6 +204,7 @@ pub(crate) async fn app(args: UserArgs) -> Result<(Router, Arc), AppEr .route("/failtransfers", post(fail_transfers)) .route("/getassetmedia", post(get_asset_media)) .route("/getchannelid", post(get_channel_id)) + .route("/getconsignment", post(get_consignment)) .route("/getpayment", post(get_payment)) .route("/getswap", post(get_swap)) .route("/inflate", post(inflate)) @@ -180,6 +230,7 @@ pub(crate) async fn app(args: UserArgs) -> Result<(Router, Arc), AppEr .route("/networkinfo", get(network_info)) .route("/nodeinfo", get(node_info)) .route("/openchannel", post(open_channel)) + .route("/provideoutofbandack", post(provide_out_of_band_ack)) .route("/refreshtransfers", post(refresh_transfers)) .route("/restore", post(restore)) .route("/revoketoken", post(revoke_token)) @@ -275,12 +326,24 @@ async fn shutdown_signal(app_state: Arc) { tracing::info!("Received a shutdown signal"); let app_state_copy = app_state.clone(); + // only a fatal shutdown gives up on an in-progress state change: nobody is waiting for the + // node and the exit code still has to be reported, so it cannot wait forever + let deadline = FATAL_ERROR + .get() + .map(|_| Instant::now() + STATE_CHANGE_SHUTDOWN_TIMEOUT); loop { { if app_state_copy.wait_state_change() { break; } } + if deadline.is_some_and(|deadline| Instant::now() > deadline) { + tracing::warn!( + "State change did not complete within {}s, shutting down anyway", + STATE_CHANGE_SHUTDOWN_TIMEOUT.as_secs() + ); + break; + } tracing::info!("Will shutdown after change state is complete"); tokio::time::sleep(Duration::from_millis(300)).await; } diff --git a/src/node.rs b/src/node.rs index c67c2eff..c05dbc7f 100644 --- a/src/node.rs +++ b/src/node.rs @@ -13,6 +13,13 @@ pub struct NodeConfig { pub ldk_peer_listening_port: u16, pub network: BitcoinNetwork, pub max_media_upload_size_mb: u16, + /// Max aggregate size of RGB media accepted over p2p per channel-open (in MB). + pub max_aggregated_media_size_per_channel_mb: u16, + /// Max number of pending channel-open consignments buffered over p2p at once. This is a + /// node-wide cap counted across all peers. + pub max_pending_consignments: usize, + /// Max number of RGB media files accepted over p2p per channel-open. + pub max_media_files_per_channel: usize, pub root_public_key: Option, pub enable_virtual_channels_v0: bool, pub virtual_peer_pubkeys: Vec, @@ -51,6 +58,10 @@ impl NodeHandle { ldk_peer_listening_port: config.ldk_peer_listening_port, network: config.network, max_media_upload_size_mb: config.max_media_upload_size_mb, + max_aggregated_media_size_per_channel_mb: config + .max_aggregated_media_size_per_channel_mb, + max_pending_consignments: config.max_pending_consignments, + max_media_files_per_channel: config.max_media_files_per_channel, root_public_key: config.root_public_key, enable_virtual_channels_v0: config.enable_virtual_channels_v0, virtual_peer_pubkeys: config.virtual_peer_pubkeys, @@ -103,6 +114,10 @@ mod tests { ldk_peer_listening_port: 0, network: BitcoinNetwork::Regtest, max_media_upload_size_mb: 1, + max_aggregated_media_size_per_channel_mb: + crate::rgb_file_transfer::MAX_MEDIA_MB_PER_CHANNEL, + max_pending_consignments: crate::rgb_file_transfer::MAX_PENDING_CONSIGNMENTS, + max_media_files_per_channel: crate::rgb_file_transfer::MAX_MEDIA_FILES_PER_CHANNEL, root_public_key: None, enable_virtual_channels_v0: false, virtual_peer_pubkeys: vec![], diff --git a/src/rgb.rs b/src/rgb.rs index da103cb9..573b417e 100644 --- a/src/rgb.rs +++ b/src/rgb.rs @@ -19,16 +19,17 @@ use rgb_lib::{ wallet::{ rust_only::{check_proxy_url, ColoringInfo}, AssetCFA, AssetFilter, AssetIFA, AssetNIA, AssetUDA, Assets, Balance, BtcBalance, - IfaIssuanceType, Metadata, Online, OperationResult, Outpoint, ReceiveData, Recipient, - RefreshFilter, RefreshResult, RgbWalletOpsOffline, RgbWalletOpsOnline, SendBeginResult, - SinglesigKeys, SyncOptions, Transaction as RgbLibTransaction, Transfer, TransferKind, - TransportEndpoint, Unspent, Wallet as RgbLibWallet, + IfaIssuanceType, Media, Metadata, Online, OperationResult, Outpoint, ReceiveData, + Recipient, RefreshFilter, RefreshResult, RefreshedTransfer, RgbWalletOpsOffline, + RgbWalletOpsOnline, SendBeginResult, SinglesigKeys, SyncOptions, + Transaction as RgbLibTransaction, Transfer, TransferKind, TransportEndpoint, Unspent, + Wallet as RgbLibWallet, }, AssetSchema, Assignment, BitcoinNetwork, ContractId, Error as RgbLibError, Fascia, RgbTransfer, - RgbTransport, RgbTxid, UpdateRes, WitnessOrd, + RgbTxid, UpdateRes, WitnessOrd, }; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; use std::str::FromStr; use std::sync::{Arc, Mutex, MutexGuard}; @@ -129,7 +130,7 @@ impl UnlockedAppState { &self, asset_id: Option, assignment: Assignment, - expiration_timestamp: Option, + expiration_timestamp: u64, transport_endpoints: Vec, min_confirmations: u8, ) -> Result { @@ -364,6 +365,13 @@ impl UnlockedAppState { self.rgb_wallet_wrapper.list_assets(filter_asset_schemas) } + pub(crate) fn rgb_list_asset_media( + &self, + asset_id: String, + ) -> Result, RgbLibError> { + self.rgb_wallet_wrapper.list_asset_media(asset_id) + } + pub(crate) fn rgb_list_transactions( &self, skip_sync: bool, @@ -388,21 +396,21 @@ impl UnlockedAppState { .list_unspents(settled_only, skip_sync) } - pub(crate) fn rgb_post_consignment>( + pub(crate) fn rgb_provide_out_of_band_ack( &self, - proxy_url: &str, recipient_id: String, - consignment_path: P, - txid: String, - vout: Option, - ) -> Result<(), RgbLibError> { - self.rgb_wallet_wrapper.post_consignment( - proxy_url, - recipient_id, - consignment_path, - txid, - vout, - ) + ) -> Result, RgbLibError> { + self.rgb_wallet_wrapper + .provide_out_of_band_ack(recipient_id) + } + + pub(crate) fn rgb_provide_out_of_band_consignment( + &self, + consignment_path: String, + media_file_paths: Vec, + ) -> Result { + self.rgb_wallet_wrapper + .provide_out_of_band_consignment(consignment_path, media_file_paths) } pub(crate) fn rgb_refresh( @@ -429,7 +437,7 @@ impl UnlockedAppState { donation: bool, fee_rate: u64, min_confirmations: u8, - expiration_timestamp: Option, + expiration_timestamp: u64, ) -> Result { self.rgb_wallet_wrapper.send( recipient_map, @@ -447,7 +455,7 @@ impl UnlockedAppState { donation: bool, fee_rate: u64, min_confirmations: u8, - expiration_timestamp: Option, + expiration_timestamp: u64, dry_run: bool, lock_time: Option, ) -> Result { @@ -491,6 +499,15 @@ impl UnlockedAppState { self.rgb_wallet_wrapper.send_end(signed_psbt) } + /// Broadcast + DB bookkeeping only, without generating or posting consignments. Only for + /// channel funding, where the consignment has already been sent to the peer over p2p. + pub(crate) fn rgb_send_end_db_update_only( + &self, + signed_psbt: String, + ) -> Result { + self.rgb_wallet_wrapper.send_end_db_update_only(signed_psbt) + } + pub(crate) fn rgb_sign_psbt(&self, unsigned_psbt: String) -> Result { let signer_descriptors = if self.external_signer_mode { self.rgb_signer_descriptors_for_psbt(unsigned_psbt.as_str())? @@ -529,7 +546,7 @@ impl UnlockedAppState { &self, asset_id: Option, assignment: Assignment, - expiration_timestamp: Option, + expiration_timestamp: u64, transport_endpoints: Vec, min_confirmations: u8, ) -> Result { @@ -598,7 +615,7 @@ impl RgbLibWalletWrapper { &self, asset_id: Option, assignment: Assignment, - expiration_timestamp: Option, + expiration_timestamp: u64, transport_endpoints: Vec, min_confirmations: u8, ) -> Result { @@ -740,7 +757,7 @@ impl RgbLibWalletWrapper { } pub(crate) fn get_tx_height(&self, txid: String) -> Result, RgbLibError> { - self.get_rgb_wallet().get_tx_height(txid) + self.get_rgb_wallet().get_tx_height(self.online, txid) } pub(crate) fn inflate( @@ -849,6 +866,10 @@ impl RgbLibWalletWrapper { self.get_rgb_wallet().list_assets(filter_asset_schemas) } + pub(crate) fn list_asset_media(&self, asset_id: String) -> Result, RgbLibError> { + self.get_rgb_wallet().list_asset_media(asset_id) + } + pub(crate) fn list_transactions( &self, skip_sync: bool, @@ -875,36 +896,39 @@ impl RgbLibWalletWrapper { .list_unspents(online, settled_only, skip_sync) } - pub(crate) fn accept_transfer( + pub(crate) fn accept_transfer_consignment( &self, + consignment_path: PathBuf, txid: String, vout: u32, - proxy_endpoint: &str, blinding: u64, - ) -> Result<(RgbTransfer, Vec), RgbLibError> { - let consignment_endpoint = RgbTransport::from_str(proxy_endpoint).map_err(|e| { - RgbLibError::InvalidTransportEndpoint { - details: e.to_string(), - } - })?; - self.get_rgb_wallet() - .accept_transfer(txid, vout, consignment_endpoint, blinding) + ) -> Result<(RgbTransfer, Vec, HashSet), RgbLibError> { + self.get_rgb_wallet().accept_transfer_consignment( + self.online, + consignment_path, + txid, + vout, + blinding, + ) } - pub(crate) fn post_consignment>( + pub(crate) fn provide_out_of_band_ack( &self, - proxy_url: &str, recipient_id: String, - consignment_path: P, - txid: String, - vout: Option, - ) -> Result<(), RgbLibError> { - self.get_rgb_wallet().post_consignment( - proxy_url, - recipient_id, + ) -> Result, RgbLibError> { + self.get_rgb_wallet() + .provide_out_of_band_ack(self.online, recipient_id) + } + + pub(crate) fn provide_out_of_band_consignment( + &self, + consignment_path: String, + media_file_paths: Vec, + ) -> Result, RgbLibError> { + self.get_rgb_wallet().provide_out_of_band_consignment( + self.online, consignment_path, - txid, - vout, + media_file_paths, ) } @@ -924,7 +948,7 @@ impl RgbLibWalletWrapper { offchain_txid: String, ) -> Result<(), RgbLibError> { self.get_rgb_wallet() - .save_new_asset(consignment, offchain_txid) + .save_new_asset(self.online, consignment, offchain_txid) } pub(crate) fn send( @@ -933,7 +957,7 @@ impl RgbLibWalletWrapper { donation: bool, fee_rate: u64, min_confirmations: u8, - expiration_timestamp: Option, + expiration_timestamp: u64, ) -> Result { self.get_rgb_wallet().send( self.online, @@ -953,7 +977,7 @@ impl RgbLibWalletWrapper { donation: bool, fee_rate: u64, min_confirmations: u8, - expiration_timestamp: Option, + expiration_timestamp: u64, dry_run: bool, lock_time: Option, ) -> Result { @@ -1008,6 +1032,14 @@ impl RgbLibWalletWrapper { self.get_rgb_wallet().send_end(self.online, signed_psbt) } + pub(crate) fn send_end_db_update_only( + &self, + signed_psbt: String, + ) -> Result { + self.get_rgb_wallet() + .send_end_db_update_only(self.online, signed_psbt) + } + pub(crate) fn sign_psbt(&self, unsigned_psbt: String) -> Result { self.get_rgb_wallet().sign_psbt(unsigned_psbt, None) } @@ -1022,7 +1054,7 @@ impl RgbLibWalletWrapper { force_witnesses: Vec, ) -> Result { self.get_rgb_wallet() - .update_witnesses(after_height, force_witnesses) + .update_witnesses(self.online, after_height, force_witnesses) } pub(crate) fn upsert_witness( @@ -1038,7 +1070,7 @@ impl RgbLibWalletWrapper { &self, asset_id: Option, assignment: Assignment, - expiration_timestamp: Option, + expiration_timestamp: u64, transport_endpoints: Vec, min_confirmations: u8, ) -> Result { @@ -1126,10 +1158,13 @@ impl WalletSource for RgbBumpWalletSource { impl ChangeDestinationSource for RgbLibWalletWrapper { fn get_change_destination_script<'a>(&'a self) -> AsyncResult<'a, ScriptBuf, ()> { Box::pin(async move { - Ok(Address::from_str(&self.get_address().unwrap()) - .unwrap() - .assume_checked() - .script_pubkey()) + let address = self.get_address().map_err(|e| { + tracing::error!("cannot get a change address to sweep outputs, will retry: {e}"); + })?; + let parsed = Address::from_str(&address).map_err(|e| { + tracing::error!(error = %e, "invalid change address to sweep outputs"); + })?; + Ok(parsed.assume_checked().script_pubkey()) }) } } @@ -1148,7 +1183,10 @@ impl WalletSource for RgbLibWalletWrapper { Ok(unspents.iter().filter_map(|u| { let script = u.txout.script_pubkey.clone().into_boxed_script(); let address = Address::from_script(&script, network).ok()?; - let outpoint = OutPoint::from_str(&u.outpoint.to_string()).ok()?; + // a format mismatch between rgb-lib and bitcoin would be a bug, not a runtime + // condition, and skipping the utxo would hide it + let outpoint = OutPoint::from_str(&u.outpoint.to_string()) + .expect("rgb-lib formats outpoints as txid:vout"); let value = u.txout.value; match address.witness_program() { Some(prog) if prog.is_p2wpkh() => { @@ -1213,9 +1251,7 @@ impl WalletSource for RgbLibWalletWrapper { } pub(crate) async fn check_rgb_proxy_endpoint(proxy_endpoint: &str) -> Result<(), APIError> { - let rgb_transport = - RgbTransport::from_str(proxy_endpoint).map_err(|_| APIError::InvalidProxyEndpoint)?; - let proxy_url = TransportEndpoint::try_from(rgb_transport)?.endpoint; + let proxy_url = TransportEndpoint::new(proxy_endpoint.to_string())?.endpoint; tokio::task::spawn_blocking(move || check_proxy_url(&proxy_url)) .await .unwrap()?; diff --git a/src/rgb_file_transfer.rs b/src/rgb_file_transfer.rs new file mode 100644 index 00000000..d759bf0b --- /dev/null +++ b/src/rgb_file_transfer.rs @@ -0,0 +1,2044 @@ +//! Peer-to-peer transport of the RGB files exchanged during channel opening. +//! +//! Instead of uploading the channel-funding consignment (and the asset's media files) to an HTTP +//! proxy, the initiator sends them directly to the channel counterparty over the encrypted (BOLT 8) +//! Lightning p2p connection as custom messages. Two kinds of file travel over this transport: +//! +//! * the funding **consignment**, written to `consignment_{funding_txid}`; +//! * zero or more **media** files (content-addressed by their SHA-256 digest), written to the RGB +//! wallet media directory so the acceptor can serve `/getassetmedia` for the newly learned asset. +//! +//! The acceptor learns which media files to expect from the validated contract itself (rgb-lib +//! returns their digests when accepting the consignment), so nothing the sender claims about the +//! media set is trusted. +//! +//! Because Lightning wire messages are capped at `LN_MAX_MSG_LEN` (65535 bytes), every file is split +//! into chunks and reassembled on the receiving side. Only channel counterparties may send us files +//! (see [`PeerChannelGate`]); a peer's in-flight chunks are capped, dropped when it disconnects, and +//! swept once a transfer stops making progress (see [`REASSEMBLY_TTL`]). + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::fs; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant, SystemTime}; + +use bitcoin::hashes::{sha256, Hash}; +use bitcoin::secp256k1::PublicKey; +use lightning::ln::msgs::{DecodeError, Init, LightningError}; +use lightning::ln::peer_handler::CustomMessageHandler; +use lightning::ln::wire::{CustomMessageReader, Type}; +use lightning::rgb_utils::get_media_staging_dir; +use lightning::types::features::{InitFeatures, NodeFeatures}; +use lightning::util::ser::{LengthLimitedRead, Readable, Writeable, Writer}; + +// Custom Lightning message type id used for RGB file-transfer chunks. +// +// Must be odd (so peers that don't understand it ignore it instead of disconnecting) and live in +// the custom message range (>= 32768) defined by BOLT 1. +pub(crate) const RGB_FILE_TRANSFER_TYPE: u16 = 33333; + +// Version of the on-wire framing. Bumped only on a breaking layout change; the receiver rejects +// messages carrying an unknown version. +const WIRE_VERSION: u8 = 1; + +// File-kind discriminators carried in each chunk. +const FILE_KIND_CONSIGNMENT: u8 = 0; +const FILE_KIND_MEDIA: u8 = 1; + +// Maximum number of file payload bytes carried in a single chunk. +// +// Kept well below `LN_MAX_MSG_LEN` (65535) to leave room for the 2-byte message type and the chunk +// framing (version, kind, funding txid, file id, indices and length prefixes). +const CHUNK_SIZE: usize = 60_000; + +// How long a transfer may make no progress before its buffered chunks are dropped. +// +// The sender queues a funding's files in one go, so a transfer that stalls mid-file has either been +// abandoned by a peer that never disconnected or is a peer holding memory on purpose. Generous +// enough not to interrupt a large consignment crawling over a slow link. +pub(crate) const REASSEMBLY_TTL: Duration = Duration::from_secs(600); + +// How often stale transfers are swept. Only bounds how long dead chunks outlive [`REASSEMBLY_TTL`], +// so it can be coarse. +pub(crate) const REASSEMBLY_SWEEP_INTERVAL: Duration = Duration::from_secs(60); + +// How long a fully-written consignment file lingers before the sweep reclaims it as belonging to a +// funding that never arrived. The clock starts when the file lands on disk (`written_at`), not when +// its download began. The download is bounded separately by [`REASSEMBLY_TTL`]. `funding_created` +// follows the last chunk within seconds, so this is generous slack, not a tight deadline. +const CONSIGNMENT_TTL: Duration = Duration::from_secs(600); + +// Node-wide cap on the number of consignments we hold at once: one per pending channel we're +// receiving funding files for. +// +// This is the node-wide bound that matters: peer identities are free (LDK's +// `MAX_UNFUNDED_CHANNEL_PEERS` limit is bypassed under manual accept, which this node uses), so a +// per-peer bound gives no guarantee. Capping the total means an attacker sending invalid +// consignments can make us hold at most `MAX_PENDING_CONSIGNMENTS * MAX_CONSIGNMENT_SIZE` of trash +// before we discard the rest. Well above the handful of channels a node opens concurrently. The +// operator overrides this default at startup with `--max-pending-consignments`. +pub(crate) const MAX_PENDING_CONSIGNMENTS: usize = 10; + +// Cap on the size of a single funding consignment. +pub(crate) const MAX_CONSIGNMENT_SIZE: usize = 16 * 1024 * 1024; + +// Default cap on the total media bytes staged for one channel, across any number of files. +// +// The acceptor cannot know which media a contract expects until it accepts the consignment at +// funding time, so until then it takes what it is given; media is content-addressed, so a peer can +// mint unlimited distinct files by varying the bytes. This bounds the aggregate per funding. The +// operator overrides it at startup with `--max-aggregated-media-size-per-channel-mb`. +pub(crate) const MAX_MEDIA_MB_PER_CHANNEL: u16 = 24; + +#[cfg(test)] +const MAX_MEDIA_BYTES_PER_CHANNEL: usize = MAX_MEDIA_MB_PER_CHANNEL as usize * 1024 * 1024; + +// Default cap on the number of media files staged for one channel, in flight plus already written. +// +// The byte budget bounds only the summed size. Without a count cap a peer could mint a huge number +// of tiny (even 1-byte) media files, each cheap in bytes but each costing a reassembly map entry in +// memory and an inode on disk, so the object count, not the byte total, becomes the exhaustion +// vector. A real asset has a handful of media files, so this sits far above any honest use. The +// operator overrides it at startup with `--max-media-files-per-channel`. +pub(crate) const MAX_MEDIA_FILES_PER_CHANNEL: usize = 42; + +// How many chunks `len` bytes is split into, or `None` if the framing cannot number them. +// +// The chunk count and index are `u16`, so a file over `CHUNK_SIZE * u16::MAX` (~3.9GB) cannot be +// described. That is beyond any real consignment or media file, but a cast would wrap in silence +// rather than fail, so the count is derived through a checked conversion instead. +fn chunk_count(len: usize) -> Option { + // callers reject empty files, so `len` is at least 1 + u16::try_from(len.div_ceil(CHUNK_SIZE)).ok() +} + +// Whether `s` is the canonical hex form of a 32-byte hash. +// +// Both reach us as claims from the sender and go on to name files and directories, so anything but +// the canonical form is refused rather than sanitised. It also bounds what a peer can make us hold +// per in-flight file, since these strings end up in the reassembly key. +fn is_hash_hex(s: &str) -> bool { + s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit()) +} + +// A single chunk of an RGB file being transferred over the p2p link. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct RgbFileMessage { + // Which kind of file this chunk belongs to. + pub(crate) file_kind: u8, + // The funding transaction id (canonical hex string) this transfer belongs to. + pub(crate) funding_txid: String, + // Per-file identifier. For media it is the claimed SHA-256 digest; empty for the consignment + // (there is only one per funding txid). + pub(crate) file_id: String, + // Index of this chunk (0-based). + pub(crate) chunk_index: u16, + // Total number of chunks composing the file. + pub(crate) total_chunks: u16, + // The raw file bytes carried by this chunk. + pub(crate) data: Vec, +} + +impl Type for RgbFileMessage { + fn type_id(&self) -> u16 { + RGB_FILE_TRANSFER_TYPE + } +} + +impl Writeable for RgbFileMessage { + fn write(&self, w: &mut W) -> Result<(), bitcoin::io::Error> { + WIRE_VERSION.write(w)?; + self.file_kind.write(w)?; + let txid_bytes = self.funding_txid.as_bytes(); + (txid_bytes.len() as u16).write(w)?; + w.write_all(txid_bytes)?; + let file_id_bytes = self.file_id.as_bytes(); + (file_id_bytes.len() as u16).write(w)?; + w.write_all(file_id_bytes)?; + self.chunk_index.write(w)?; + self.total_chunks.write(w)?; + (self.data.len() as u32).write(w)?; + w.write_all(&self.data)?; + Ok(()) + } +} + +impl RgbFileMessage { + fn read_from(buffer: &mut R) -> Result { + let version: u8 = Readable::read(buffer)?; + if version != WIRE_VERSION { + return Err(DecodeError::InvalidValue); + } + let file_kind: u8 = Readable::read(buffer)?; + if !matches!(file_kind, FILE_KIND_CONSIGNMENT | FILE_KIND_MEDIA) { + return Err(DecodeError::InvalidValue); + } + let txid_len: u16 = Readable::read(buffer)?; + let mut txid_bytes = vec![0u8; txid_len as usize]; + buffer.read_exact(&mut txid_bytes)?; + let funding_txid = String::from_utf8(txid_bytes).map_err(|_| DecodeError::InvalidValue)?; + if !is_hash_hex(&funding_txid) { + return Err(DecodeError::InvalidValue); + } + let file_id_len: u16 = Readable::read(buffer)?; + let mut file_id_bytes = vec![0u8; file_id_len as usize]; + buffer.read_exact(&mut file_id_bytes)?; + let file_id = String::from_utf8(file_id_bytes).map_err(|_| DecodeError::InvalidValue)?; + let file_id_ok = match file_kind { + FILE_KIND_CONSIGNMENT => file_id.is_empty(), + FILE_KIND_MEDIA => is_hash_hex(&file_id), + _ => false, + }; + if !file_id_ok { + return Err(DecodeError::InvalidValue); + } + let chunk_index: u16 = Readable::read(buffer)?; + let total_chunks: u16 = Readable::read(buffer)?; + let data_len: u32 = Readable::read(buffer)?; + // the whole message is bounded by the transport to LN_MAX_MSG_LEN, so a larger length is + // bogus; reject it instead of attempting a huge allocation + if data_len as usize > u16::MAX as usize { + return Err(DecodeError::InvalidValue); + } + let mut data = vec![0u8; data_len as usize]; + buffer.read_exact(&mut data)?; + Ok(RgbFileMessage { + file_kind, + funding_txid, + file_id, + chunk_index, + total_chunks, + data, + }) + } +} + +// Reassembly key for a single in-flight file: sender + kind + funding txid + per-file id. +// +// Keeping the kind and file id in the key lets the different files of the same funding reassemble +// concurrently without colliding. Keying by sender too keeps peers from interfering with each +// other's transfers (only the channel counterparty legitimately sends chunks for a given funding +// txid) and lets us drop a peer's state when it disconnects. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct ReassemblyKey { + peer: PublicKey, + file_kind: u8, + funding_txid: String, + file_id: String, +} + +// What the transport needs to know about our channels to tell legitimate senders from abuse. +// +// Files are only ever legitimately sent by a channel counterparty, while funding a colored channel. +// Any peer that completes the BOLT 8 handshake reaches +// [`CustomMessageHandler::handle_custom_message`], with no channel and no other relationship with +// us, so without these checks anyone could make us buffer chunks and write files to disk. +// +// Kept as a trait so the transport depends on these two questions rather than on the whole channel +// manager, and so it can be unit tested. +pub(crate) trait PeerChannelGate: Send + Sync { + // How many channels, including ones still being funded, we have with `peer`. + fn channel_count_with(&self, peer: &PublicKey) -> usize; + + // Whether any of our channels is funded by `funding_txid`. + fn has_channel_funded_by(&self, funding_txid: &str) -> bool; +} + +// Identifies one funding's staged state. +// +// The funding txid is the sender's claim, not something we can check on arrival: we only learn the +// real one from `funding_created`, which by design arrives *after* the consignment. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct FundingStagingKey { + peer: PublicKey, + funding_txid: String, +} + +// Per-funding bookkeeping: when the consignment landed and the media staged with it. +struct FundingStagingRecord { + written_at: Instant, + // Media bytes staged against this funding, bounding one channel's media aggregate. + staged_media_bytes: usize, + // Number of media files staged against this funding, bounding one channel's media file count. + staged_media_count: usize, +} + +// Reassembly state for a single in-flight file. +struct ReassemblyState { + // The sender's claimed total number of chunks, which the receiver verifies. + total_chunks: u16, + // Received chunks, keyed by index. A map rather than a pre-sized `Vec` so it holds only the + // chunks actually received, never the sender's claimed `total_chunks`: a peer can't force a + // large allocation with a single chunk. Out-of-order arrival is fine and re-sends are refused. + chunks: BTreeMap>, + // Bytes currently held in `chunks`, kept alongside it to bound memory without re-summing. + buffered: usize, + // When this transfer last accepted a chunk, so a stalled one can be swept. + last_progress: Instant, +} + +impl ReassemblyState { + fn new(now: Instant) -> Self { + ReassemblyState { + total_chunks: 0, + chunks: BTreeMap::new(), + buffered: 0, + last_progress: now, + } + } +} + +// Handles sending and receiving RGB files over the Lightning p2p connection. +pub(crate) struct RgbFileTransferHandler { + ldk_data_dir: PathBuf, + // Node-wide cap on the number of pending consignments; set from `--max-pending-consignments` at + // startup, defaulting to [`MAX_PENDING_CONSIGNMENTS`]. + max_pending_fundings: usize, + // Cap on one channel's aggregate staged media, in bytes; set from + // `--max-aggregated-media-size-per-channel-mb` at startup. + max_media_bytes_per_channel: usize, + // Cap on one channel's media file count, in flight plus staged; set from + // `--max-media-files-per-channel` at startup, defaulting to [`MAX_MEDIA_FILES_PER_CHANNEL`]. + max_media_files_per_channel: usize, + channel_gate: Arc, + reassembly: Mutex>, + // Per-funding staged state we have written and not yet reclaimed: one entry per funding, + // tracking when its consignment landed, gating the media staged against that funding, and + // driving cleanup once the funding settles or ages out. + staged_fundings: Mutex>, + outbound: Mutex>, + // Test-only override for a single consignment's size cap; production always uses + // [`MAX_CONSIGNMENT_SIZE`]. Read through [`Self::max_consignment_size`], never directly. + #[cfg(test)] + max_consignment_size: usize, + // Test-only override for the size every chunk but the last must carry; production always uses + // [`CHUNK_SIZE`]. Read through [`Self::chunk_size`], never directly. + #[cfg(test)] + chunk_size: usize, +} + +impl RgbFileTransferHandler { + pub(crate) fn new( + ldk_data_dir: PathBuf, + channel_gate: Arc, + max_pending_fundings: usize, + max_media_bytes_per_channel: usize, + max_media_files_per_channel: usize, + ) -> Self { + RgbFileTransferHandler { + ldk_data_dir, + max_pending_fundings, + max_media_bytes_per_channel, + max_media_files_per_channel, + channel_gate, + reassembly: Mutex::new(HashMap::new()), + staged_fundings: Mutex::new(HashMap::new()), + outbound: Mutex::new(Vec::new()), + #[cfg(test)] + max_consignment_size: MAX_CONSIGNMENT_SIZE, + #[cfg(test)] + chunk_size: CHUNK_SIZE, + } + } + + // The size every chunk but the last must carry. Fixed at [`CHUNK_SIZE`] in production. + #[cfg(not(test))] + fn chunk_size(&self) -> usize { + CHUNK_SIZE + } + + #[cfg(test)] + fn chunk_size(&self) -> usize { + self.chunk_size + } + + // Cap on a single consignment's size. Fixed at [`MAX_CONSIGNMENT_SIZE`] in production. + #[cfg(not(test))] + fn max_consignment_size(&self) -> usize { + MAX_CONSIGNMENT_SIZE + } + + #[cfg(test)] + fn max_consignment_size(&self) -> usize { + self.max_consignment_size + } + + #[cfg(test)] + fn with_chunk_size(mut self, size: usize) -> Self { + self.chunk_size = size; + self + } + + #[cfg(test)] + fn with_media_limit(mut self, limit: usize) -> Self { + self.max_media_bytes_per_channel = limit; + self + } + + #[cfg(test)] + fn with_media_files_limit(mut self, limit: usize) -> Self { + self.max_media_files_per_channel = limit; + self + } + + #[cfg(test)] + fn with_consignment_size_limit(mut self, limit: usize) -> Self { + self.max_consignment_size = limit; + self + } + + #[cfg(test)] + fn with_pending_consignments_limit(mut self, limit: usize) -> Self { + self.max_pending_fundings = limit; + self + } + + // Split `bytes` into chunks and queue them for delivery to `peer`. + // + // Fails only if the file is too large for the framing to describe. + fn queue_file( + &self, + peer: PublicKey, + file_kind: u8, + funding_txid: String, + file_id: String, + bytes: Vec, + ) -> Result<(), ()> { + // no real file is empty, and the receiver rejects empty chunks, so an empty file has + // nothing to send; refuse it here rather than emit an undeliverable chunk + if bytes.is_empty() { + tracing::error!("Refusing to send an empty RGB file for funding txid {funding_txid}"); + return Err(()); + } + let Some(total_chunks) = chunk_count(bytes.len()) else { + tracing::error!( + "Refusing to send a {} byte RGB file for funding txid {funding_txid}: more than the {} chunks the framing can number", + bytes.len(), + u16::MAX, + ); + return Err(()); + }; + let chunks: Vec<&[u8]> = bytes.chunks(CHUNK_SIZE).collect(); + debug_assert_eq!(chunks.len(), total_chunks as usize); + let mut outbound = self.outbound.lock().unwrap(); + for (idx, chunk) in chunks.into_iter().enumerate() { + outbound.push(( + peer, + RgbFileMessage { + file_kind, + funding_txid: funding_txid.clone(), + file_id: file_id.clone(), + // bounded by `total_chunks`, which `chunk_count` proved fits + chunk_index: idx as u16, + total_chunks, + data: chunk.to_vec(), + }, + )); + } + Ok(()) + } + + // Split the funding consignment into chunks and queue them for delivery to `peer`. + // + // After calling this the caller must trigger `PeerManager::process_events` to flush the queued + // messages onto the wire. + pub(crate) fn queue_consignment( + &self, + peer: PublicKey, + funding_txid: String, + bytes: Vec, + ) -> Result<(), ()> { + self.queue_file( + peer, + FILE_KIND_CONSIGNMENT, + funding_txid, + String::new(), + bytes, + ) + } + + // Queue a single media file (identified by its SHA-256 `digest`) for delivery to `peer`. + // + // After calling this the caller must trigger `PeerManager::process_events` to flush the queued + // messages onto the wire. + pub(crate) fn queue_media( + &self, + peer: PublicKey, + funding_txid: String, + digest: String, + bytes: Vec, + ) -> Result<(), ()> { + self.queue_file(peer, FILE_KIND_MEDIA, funding_txid, digest, bytes) + } + + // On startup, delete the consignment and staged-media files of channels that never funded. + // + // A file is an orphan when no funded channel claims its funding txid: LDK drops unfunded + // channels across a restart, so a channel that still hasn't funded by the time this runs is + // gone for good and its leftover files can be removed. + // + // This can't be left to the periodic sweep. The sweep only reclaims files tracked in the + // in-memory map, and a restart wipes that map, so these files would otherwise sit on disk for + // good (and, the node-wide cap being in-memory too, wouldn't even count against it). This pass + // rediscovers them by scanning the data dir directly. + // + // Recent files are kept even without a funded channel yet: the age check guards the crash + // window where a channel funded but the manager's state hadn't been persisted. On restart its + // replayed `ChannelPending` still needs the file, and removing it would make the acceptor read + // that colored channel as vanilla and drop the asset. + pub(crate) fn cleanup_orphans_from_previous_run(&self) { + self.cleanup_orphans_at(SystemTime::now()) + } + + fn cleanup_orphans_at(&self, at: SystemTime) { + let entries = fs::read_dir(&self.ldk_data_dir).expect("ldk_data_dir exists at startup"); + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + // `consignment__`, the transient closing consignment, is not a + // canonical txid once the prefix is stripped, so it is left alone here. + let funding_txid = name + .strip_prefix("consignment_") + .or_else(|| name.strip_prefix("media_staging_")) + .filter(|txid| is_hash_hex(txid)); + let Some(funding_txid) = funding_txid else { + continue; + }; + let Ok(modified) = entry.metadata().and_then(|m| m.modified()) else { + continue; + }; + // a clock that moved backwards reads as "not old enough", which errs towards keeping + let Ok(age) = at.duration_since(modified) else { + continue; + }; + if age < CONSIGNMENT_TTL || self.channel_gate.has_channel_funded_by(funding_txid) { + continue; + } + let path = entry.path(); + let res = if path.is_dir() { + fs::remove_dir_all(&path) + } else { + fs::remove_file(&path) + }; + match res { + Ok(()) => { + tracing::info!("Removed orphaned RGB file {name} from a previous run") + } + Err(e) => tracing::warn!("Failed to remove orphaned RGB file {name}: {e}"), + } + } + } + + // Forget the staged funding record and delete the consignment file it staged. + // + // A staged funding record only needs to count against the node-wide cap while its channel is + // still being funded; once the funding happens (or the channel goes away) the slot should be + // freed. The periodic sweep does this eventually: it drops a record as soon as the funding is + // observed, but two handlers call this to do it immediately instead of waiting up to a sweep + // interval: the acceptor's `ChannelPending` path once the funding locks in, and `ChannelClosed`. + // The latter is essential rather than just prompt: a channel that funds and closes within a + // single sweep interval is never seen as funded by the sweep, so without this its record would + // linger for the whole `CONSIGNMENT_TTL` and refuse the peer's next channel. + // + // Both callers run after `handle_funding` has promoted the consignment into the KVStore, so the + // staged file is redundant by then. Neither sweep touches a funded channel's file, so without + // this removal it would be left in the LDK data dir for the rest of the process' life. + pub(crate) fn forget_staged_funding(&self, funding_txid: &str) { + self.staged_fundings + .lock() + .unwrap() + .retain(|key, _| key.funding_txid != funding_txid); + match fs::remove_file(self.consignment_path(funding_txid)) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => tracing::warn!( + "Failed to remove staged RGB consignment for funding txid {funding_txid}: {e}" + ), + } + } + + // Delete what abandoned transfers left behind, in memory and on disk. + pub(crate) fn sweep_stale_state(&self) { + let now = Instant::now(); + self.sweep_stale_transfers_at(now); + self.sweep_unfunded_stagings_at(now); + } + + // Drop the state staged for a funding that never happened. + // + // A peer can open a channel, send files naming any funding txid it likes, and simply never fund + // it, costing it nothing. Those files are deleted here. + // + // The age check is what keeps this safe. A consignment file's *absence* is how the acceptor + // decides a channel is vanilla, so removing one that a funding still needs would silently drop + // the asset. Only a funding whose transaction never arrived, long after it would have, is + // touched. + fn sweep_unfunded_stagings_at(&self, now: Instant) { + let mut persisted = self.staged_fundings.lock().unwrap(); + persisted.retain(|key, record| { + // the funding happened, so this file belongs to a real channel: leave it alone and stop + // holding it against the node-wide cap. It is removed when that channel closes, and its + // staged media was promoted into the wallet by `handle_funding` + if self.channel_gate.has_channel_funded_by(&key.funding_txid) { + return false; + } + if now.duration_since(record.written_at) < CONSIGNMENT_TTL { + return true; + } + // media staged against a funding that never happened is unvouched-for bytes + let _ = + fs::remove_dir_all(get_media_staging_dir(&self.ldk_data_dir, &key.funding_txid)); + match fs::remove_file(self.consignment_path(&key.funding_txid)) { + Ok(()) => tracing::debug!( + "Removed unfunded RGB consignment from peer {} for funding txid {}", + key.peer, + key.funding_txid + ), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => tracing::warn!( + "Failed to remove unfunded RGB consignment for funding txid {}: {e}", + key.funding_txid + ), + } + false + }); + } + + // Drop transfers that haven't accepted a chunk within [`REASSEMBLY_TTL`]. + // + // [`CustomMessageHandler::peer_disconnected`] already deletes a peer's buffers when it goes + // away; this covers the peer that stays connected but never finishes what it started. + fn sweep_stale_transfers_at(&self, now: Instant) { + self.reassembly.lock().unwrap().retain(|key, state| { + // returns zero, not a panic, if `now` precedes the last progress: a backwards clock + // reads as not-yet-stale, so the transfer is kept rather than dropped + let stale = now.duration_since(state.last_progress) >= REASSEMBLY_TTL; + if stale { + tracing::debug!( + "Dropping stalled RGB file transfer from peer {}: {} bytes buffered for funding txid {}", + key.peer, + state.buffered, + key.funding_txid + ); + } + !stale + }); + } + + fn consignment_path(&self, funding_txid: &str) -> PathBuf { + self.ldk_data_dir + .join(format!("consignment_{funding_txid}")) + } + + // Persist a fully reassembled file to disk according to its kind. + fn persist_file(&self, key: &ReassemblyKey, bytes: Vec, now: Instant) { + match key.file_kind { + FILE_KIND_CONSIGNMENT => { + // peer identities are free, so the count of held consignments is bounded node-wide + // rather than per peer: the funding txid is a string the sender could invent, so + // any peer could otherwise write unlimited files without ever funding anything, and + // more peers cost nothing. Beyond the cap, further consignments are simply + // discarded + let mut persisted = self.staged_fundings.lock().unwrap(); + let staging_key = FundingStagingKey { + peer: key.peer, + funding_txid: key.funding_txid.clone(), + }; + // a funding's consignment is written once. A second one for a funding we already + // hold is a re-send: ignore it. Overwriting would reset the record's + // `staged_media_bytes` to 0 while its media stays on disk, letting the peer stage + // another full budget's worth of files and, by repeating, fill the disk unbounded + if persisted.contains_key(&staging_key) { + tracing::debug!( + "Ignoring resent RGB consignment from peer {} for funding txid {}", + key.peer, + key.funding_txid, + ); + return; + } + let count = persisted.len(); + if count >= self.max_pending_fundings { + tracing::debug!( + "Refusing RGB consignment from peer {}: {count} consignments already held", + key.peer + ); + return; + } + let path = self.consignment_path(&key.funding_txid); + if let Err(e) = fs::write(&path, &bytes) { + tracing::error!( + "Failed to persist RGB consignment for funding txid {}: {e}", + key.funding_txid + ); + } else { + persisted.insert( + staging_key, + FundingStagingRecord { + written_at: now, + staged_media_bytes: 0, + staged_media_count: 0, + }, + ); + tracing::info!( + "Received RGB consignment ({} bytes) over p2p for funding txid {}", + bytes.len(), + key.funding_txid + ); + } + } + FILE_KIND_MEDIA => { + // media files are content-addressed, so the digest is recomputed from the received + // bytes (identical hashing to `post_asset_media`) rather than taking the sender's + // `file_id` on trust. The two disagreeing means these are not the bytes we were + // promised: the transport is authenticated, so it is a sender that lied or a file + // that changed under it, never corruption in flight. Drop them instead of spending + // disk and this funding's staging budget on bytes already known to be wrong + let digest = sha256::Hash::hash(&bytes).to_string(); + if digest != key.file_id { + tracing::warn!( + "Refusing RGB media from peer {} for funding txid {}: claimed digest {}, content hashes to {digest}", + key.peer, + key.funding_txid, + key.file_id, + ); + return; + } + + // media belongs to a funding, and the sender queues its consignment first, so on an + // ordered connection one is always outstanding by now. Requiring it ties media to a + // real channel, instead of letting a peer stage media against funding txids it + // invents + let mut persisted = self.staged_fundings.lock().unwrap(); + let staging_key = FundingStagingKey { + peer: key.peer, + funding_txid: key.funding_txid.clone(), + }; + let Some(record) = persisted.get_mut(&staging_key) else { + tracing::debug!( + "Refusing RGB media from peer {}: no consignment outstanding for funding txid {}", + key.peer, + key.funding_txid + ); + return; + }; + + let staging_dir = get_media_staging_dir(&self.ldk_data_dir, &key.funding_txid); + let path = staging_dir.join(&digest); + if path.exists() { + // identical bytes already staged, nothing to do + return; + } + if record.staged_media_bytes + bytes.len() > self.max_media_bytes_per_channel { + tracing::debug!( + "Refusing RGB media file {digest} from peer {}: funding txid {} has staged {} bytes, over the {} byte per-channel limit", + key.peer, + key.funding_txid, + record.staged_media_bytes, + self.max_media_bytes_per_channel, + ); + return; + } + if record.staged_media_count >= self.max_media_files_per_channel { + tracing::debug!( + "Refusing RGB media file {digest} from peer {}: funding txid {} already has {} staged media files, the per-channel cap", + key.peer, + key.funding_txid, + record.staged_media_count, + ); + return; + } + if let Err(e) = fs::create_dir_all(&staging_dir) { + tracing::error!( + "Failed to create RGB media staging dir for funding txid {}: {e}", + key.funding_txid + ); + return; + } + if let Err(e) = fs::write(&path, &bytes) { + tracing::error!("Failed to stage RGB media file {digest}: {e}"); + } else { + record.staged_media_bytes += bytes.len(); + record.staged_media_count += 1; + tracing::info!( + "Received RGB media file {digest} ({} bytes) over p2p for funding txid {}", + bytes.len(), + key.funding_txid + ); + } + } + _ => unreachable!("unknown file kind is rejected while decoding"), + } + } +} + +impl CustomMessageReader for RgbFileTransferHandler { + type CustomMessage = RgbFileMessage; + + fn read( + &self, + message_type: u16, + buffer: &mut R, + ) -> Result, DecodeError> { + if message_type != RGB_FILE_TRANSFER_TYPE { + return Ok(None); + } + Ok(Some(RgbFileMessage::read_from(buffer)?)) + } +} + +impl RgbFileTransferHandler { + // Processes one received chunk. The [`CustomMessageHandler::handle_custom_message`] trait + // method is a thin wrapper that just calls this with the current time; `now` is a parameter, + // rather than read from the clock here, so tests can drive the reassembly TTL and sweep + // deadlines with a synthetic clock instead of wall-clock time. + fn handle_chunk( + &self, + msg: RgbFileMessage, + sender_node_id: PublicKey, + now: Instant, + ) -> Result<(), LightningError> { + // refuse anyone who isn't a channel counterparty before buffering or persisting anything. + // Logged at debug level because any peer on the internet can trigger this at will, so it + // must not be able to flood the logs + if self.channel_gate.channel_count_with(&sender_node_id) == 0 { + tracing::debug!( + "Ignoring RGB file chunk from peer {sender_node_id}: no channel with this node" + ); + return Ok(()); + } + + // files are only ever sent while a channel is being funded, before `funding_created`, so a + // chunk naming a funding we already have open is never legitimate: that transfer completed + // when the channel funded. Reject it, or a peer that knows the (public) funding txid of a + // live channel could overwrite its consignment, re-stage media against it, or burn a + // pending slot, none of which the staged-state guards catch once the sweep drops the funded + // record + if self.channel_gate.has_channel_funded_by(&msg.funding_txid) { + tracing::debug!( + "Ignoring RGB file chunk from peer {sender_node_id}: channel with funding txid {} is already open", + msg.funding_txid + ); + return Ok(()); + } + + let key = ReassemblyKey { + peer: sender_node_id, + file_kind: msg.file_kind, + funding_txid: msg.funding_txid, + file_id: msg.file_id, + }; + let mut reassembly = self.reassembly.lock().unwrap(); + + // malformed-stream checks. A correct sender on this reliable, ordered transport emits each + // chunk once, in range, at the fixed size, under one consistent total; anything else is a bad + // or malicious sender. Drop the whole transfer rather than tolerate it. + // Only a transfer's own counterparty can send chunks for its key, so a peer can only ever + // abort its own transfer this way + + // an in-range index (and non-zero total). Completing on `chunks.len() == total_chunks` alone + // would accept e.g. indices {0, 1, 5} for total 3 and assemble garbage + if msg.total_chunks == 0 || msg.chunk_index >= msg.total_chunks { + tracing::warn!( + "Dropping RGB file transfer from peer {}: invalid indices (index {}, total {}) for funding txid {}", + key.peer, + msg.chunk_index, + msg.total_chunks, + key.funding_txid + ); + reassembly.remove(&key); + return Ok(()); + } + + // the sender splits a file into fixed-size chunks, so every chunk but the last carries + // exactly `chunk_size` bytes and the last carries 1..=chunk_size, which also rejects empty + // and undersized chunks + let is_last_chunk = msg.chunk_index == msg.total_chunks - 1; + let chunk_size = self.chunk_size(); + let valid_len = if is_last_chunk { + (1..=chunk_size).contains(&msg.data.len()) + } else { + msg.data.len() == chunk_size + }; + if !valid_len { + tracing::warn!( + "Dropping RGB file transfer from peer {}: chunk {} of {} carries {} bytes for funding txid {}", + key.peer, + msg.chunk_index, + msg.total_chunks, + msg.data.len(), + key.funding_txid + ); + reassembly.remove(&key); + return Ok(()); + } + + // against an existing in-flight entry: a resent index, or a total_chunks that disagrees with + // the one this transfer locked in on its first chunk, are both malformed + let (resent, inconsistent_total) = { + let existing = reassembly.get(&key); + ( + existing.is_some_and(|s| s.chunks.contains_key(&msg.chunk_index)), + existing.is_some_and(|s| s.total_chunks != msg.total_chunks), + ) + }; + if resent { + tracing::warn!( + "Dropping RGB file transfer from peer {}: chunk {} resent for funding txid {}", + key.peer, + msg.chunk_index, + key.funding_txid, + ); + reassembly.remove(&key); + return Ok(()); + } + if inconsistent_total { + tracing::warn!( + "Dropping RGB file transfer from peer {}: total_chunks {} disagrees with the transfer's for funding txid {}", + key.peer, + msg.total_chunks, + key.funding_txid, + ); + reassembly.remove(&key); + return Ok(()); + } + + // bytes already buffered for this file, or 0 when this is its first chunk (no state yet) + let entry_buffered = reassembly.get(&key).map_or(0, |state| state.buffered); + // what this file would occupy after accepting the chunk. Every index is new (re-sends aborted + // above), so the chunk only ever adds + let file_prospective = entry_buffered + msg.data.len(); + + { + let persisted = self.staged_fundings.lock().unwrap(); + + // count a funding as pending from its first chunk, in flight or persisted, so the cap + // bounds memory, not just completed files on disk: a peer can otherwise open endless + // never-completing transfers under invented funding txids, none of which ever get counted + // because none ever complete + let funding_known = reassembly + .keys() + .any(|k| k.funding_txid == key.funding_txid) + || persisted.keys().any(|k| k.funding_txid == key.funding_txid); + if !funding_known { + let mut fundings: HashSet<&str> = + reassembly.keys().map(|k| k.funding_txid.as_str()).collect(); + fundings.extend(persisted.keys().map(|k| k.funding_txid.as_str())); + if fundings.len() >= self.max_pending_fundings { + tracing::debug!( + "Ignoring RGB file chunk from peer {}: {} fundings already pending, new funding txid {} refused", + key.peer, + fundings.len(), + key.funding_txid, + ); + return Ok(()); + } + } + + // a consignment is one file, capped at its max size; media is any number of files, but + // their total for one channel, in flight plus already staged, is capped, so memory + // can't grow with the file count + if key.file_kind == FILE_KIND_MEDIA { + let staging_key = FundingStagingKey { + peer: key.peer, + funding_txid: key.funding_txid.clone(), + }; + let record = persisted.get(&staging_key); + let other_media: usize = reassembly + .iter() + .filter(|(k, _)| { + **k != key + && k.file_kind == FILE_KIND_MEDIA + && k.funding_txid == key.funding_txid + }) + .map(|(_, s)| s.buffered) + .sum(); + let staged = record.map_or(0, |r| r.staged_media_bytes); + if other_media + file_prospective + staged > self.max_media_bytes_per_channel { + tracing::warn!( + "Ignoring RGB media chunk from peer {}: funding txid {} would exceed its {} byte media budget", + key.peer, + key.funding_txid, + self.max_media_bytes_per_channel, + ); + return Ok(()); + } + // bytes alone don't bound the object count: 1-byte files are cheap in bytes but each + // costs a map entry and an inode. A chunk starting a new file (none in flight for it + // yet) must not push the funding's media file count, in flight plus staged, over the + // cap. A chunk continuing a file already in flight is already counted, so it is let by + if !reassembly.contains_key(&key) { + let in_flight_files = reassembly + .keys() + .filter(|k| { + k.file_kind == FILE_KIND_MEDIA && k.funding_txid == key.funding_txid + }) + .count(); + let staged_files = record.map_or(0, |r| r.staged_media_count); + if in_flight_files + staged_files >= self.max_media_files_per_channel { + tracing::warn!( + "Ignoring RGB media chunk from peer {}: funding txid {} already has {} media files, the per-channel cap", + key.peer, + key.funding_txid, + in_flight_files + staged_files, + ); + return Ok(()); + } + } + } else if file_prospective > self.max_consignment_size() { + tracing::warn!( + "Ignoring RGB consignment chunk from peer {}: funding txid {} would exceed its {} byte cap", + key.peer, + key.funding_txid, + self.max_consignment_size(), + ); + return Ok(()); + } + } + + let state = reassembly + .entry(key.clone()) + .or_insert_with(|| ReassemblyState::new(now)); + // first chunk locks in total_chunks; later chunks were checked to agree with it above + if state.total_chunks == 0 { + state.total_chunks = msg.total_chunks; + } + // only a chunk we actually accept counts as progress, so a peer can't keep a transfer alive + // by dribbling out chunks we reject (malformed chunks abort the transfer above, so they can't + // either) + state.last_progress = now; + state.buffered += msg.data.len(); + state.chunks.insert(msg.chunk_index, msg.data); + + let complete = (0..state.total_chunks).all(|i| state.chunks.contains_key(&i)); + if complete { + // `buffered` is the exact summed length of every chunk, so this is the final file size + let mut bytes = Vec::with_capacity(state.buffered); + for i in 0..state.total_chunks { + bytes.extend_from_slice(&state.chunks[&i]); + } + reassembly.remove(&key); + drop(reassembly); + // written synchronously so the files are in place before `funding_created` (which + // arrives after the chunks on the same ordered connection) is processed + self.persist_file(&key, bytes, now); + } + Ok(()) + } +} + +impl CustomMessageHandler for RgbFileTransferHandler { + fn handle_custom_message( + &self, + msg: Self::CustomMessage, + sender_node_id: PublicKey, + ) -> Result<(), LightningError> { + self.handle_chunk(msg, sender_node_id, Instant::now()) + } + + fn get_and_clear_pending_msg(&self) -> Vec<(PublicKey, Self::CustomMessage)> { + std::mem::take(&mut *self.outbound.lock().unwrap()) + } + + fn peer_disconnected(&self, their_node_id: PublicKey) { + // drop whatever the peer left half-transferred, so incomplete transfers don't accumulate + self.reassembly + .lock() + .unwrap() + .retain(|key, _| key.peer != their_node_id); + } + + fn peer_connected( + &self, + _their_node_id: PublicKey, + _msg: &Init, + _inbound: bool, + ) -> Result<(), ()> { + Ok(()) + } + + fn provided_node_features(&self) -> NodeFeatures { + NodeFeatures::empty() + } + + fn provided_init_features(&self, _their_node_id: PublicKey) -> InitFeatures { + InitFeatures::empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::secp256k1::{Secp256k1, SecretKey}; + + fn peer_id(byte: u8) -> PublicKey { + let sk = SecretKey::from_slice(&[byte; 32]).unwrap(); + PublicKey::from_secret_key(&Secp256k1::new(), &sk) + } + + fn dummy_peer() -> PublicKey { + peer_id(0x01) + } + + // stands in for the channel manager + #[derive(Default)] + struct TestGate { + // holds one entry per channel, so a peer appearing twice has two channels with us + channels: Vec, + funded_txids: Vec, + } + + impl TestGate { + fn with_channels(channels: Vec) -> Self { + TestGate { + channels, + ..Default::default() + } + } + } + + impl PeerChannelGate for TestGate { + fn channel_count_with(&self, peer: &PublicKey) -> usize { + self.channels.iter().filter(|p| *p == peer).count() + } + + fn has_channel_funded_by(&self, funding_txid: &str) -> bool { + self.funded_txids.iter().any(|t| t == funding_txid) + } + } + + // handler that accepts files from the peers the tests use + fn handler(dir: &tempfile::TempDir) -> RgbFileTransferHandler { + handler_gated( + dir, + TestGate::with_channels(vec![peer_id(0x01), peer_id(0x02)]), + ) + } + + fn handler_gated(dir: &tempfile::TempDir, gate: TestGate) -> RgbFileTransferHandler { + RgbFileTransferHandler::new( + dir.path().to_path_buf(), + Arc::new(gate), + MAX_PENDING_CONSIGNMENTS, + MAX_MEDIA_BYTES_PER_CHANNEL, + MAX_MEDIA_FILES_PER_CHANNEL, + ) + } + + fn media_chunk(funding_txid: &str, data: Vec) -> RgbFileMessage { + RgbFileMessage { + file_kind: FILE_KIND_MEDIA, + funding_txid: funding_txid.to_string(), + file_id: sha256::Hash::hash(&data).to_string(), + chunk_index: 0, + total_chunks: 1, + data, + } + } + + fn staged_path(handler: &RgbFileTransferHandler, funding_txid: &str, data: &[u8]) -> PathBuf { + get_media_staging_dir(&handler.ldk_data_dir, funding_txid) + .join(sha256::Hash::hash(data).to_string()) + } + + fn consignment_chunk( + funding_txid: &str, + chunk_index: u16, + total_chunks: u16, + data: Vec, + ) -> RgbFileMessage { + RgbFileMessage { + file_kind: FILE_KIND_CONSIGNMENT, + funding_txid: funding_txid.to_string(), + file_id: String::new(), + chunk_index, + total_chunks, + data, + } + } + + #[test] + fn reassembly_completes_with_scrambled_chunks() { + let dir = tempfile::tempdir().unwrap(); + let handler = handler(&dir).with_chunk_size(3); + let peer = dummy_peer(); + let txid = "aa".repeat(32); + + handler + .handle_custom_message(consignment_chunk(&txid, 0, 3, b"aaa".to_vec()), peer) + .unwrap(); + handler + .handle_custom_message(consignment_chunk(&txid, 2, 3, b"ccc".to_vec()), peer) + .unwrap(); + assert!(!handler.consignment_path(&txid).exists()); + + handler + .handle_custom_message(consignment_chunk(&txid, 1, 3, b"bbb".to_vec()), peer) + .unwrap(); + assert_eq!( + fs::read(handler.consignment_path(&txid)).unwrap(), + b"aaabbbccc" + ); + } + + #[test] + fn a_peer_without_a_channel_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let stranger = peer_id(0x03); + let handler = handler_gated(&dir, TestGate::with_channels(vec![dummy_peer()])); + let txid = "ac".repeat(32); + + // a complete, otherwise valid transfer from a peer we have no channel with + handler + .handle_custom_message(consignment_chunk(&txid, 0, 1, b"aaa".to_vec()), stranger) + .unwrap(); + + // nothing buffered, nothing written to disk + assert!(handler.reassembly.lock().unwrap().is_empty()); + assert!(!handler.consignment_path(&txid).exists()); + + // while the counterparty we do have a channel with is served + handler + .handle_custom_message( + consignment_chunk(&txid, 0, 1, b"aaa".to_vec()), + dummy_peer(), + ) + .unwrap(); + assert_eq!(fs::read(handler.consignment_path(&txid)).unwrap(), b"aaa"); + } + + #[test] + fn a_chunk_for_an_already_funded_channel_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let peer = dummy_peer(); + let txid = "0a".repeat(32); + // the peer has a channel with us, and its funding is already open + let handler = handler_gated( + &dir, + TestGate { + channels: vec![peer], + funded_txids: vec![txid.clone()], + }, + ); + let t0 = Instant::now(); + + // a consignment naming that live channel's funding is rejected before it can overwrite the + // consignment file the funded channel still relies on + handler + .handle_chunk(consignment_chunk(&txid, 0, 1, b"evil".to_vec()), peer, t0) + .unwrap(); + assert!(handler.reassembly.lock().unwrap().is_empty()); + assert!(!handler.consignment_path(&txid).exists()); + } + + #[test] + fn a_stalled_transfer_is_swept_once_it_stops_making_progress() { + let dir = tempfile::tempdir().unwrap(); + let handler = handler(&dir).with_chunk_size(2); + let peer = dummy_peer(); + let txid = "ad".repeat(32); + let t0 = Instant::now(); + + // a transfer left half-finished by a peer that stays connected + handler + .handle_chunk(consignment_chunk(&txid, 0, 2, b"aa".to_vec()), peer, t0) + .unwrap(); + assert_eq!(handler.reassembly.lock().unwrap().len(), 1); + + // still within the TTL: kept, since a slow transfer is not a dead one + handler.sweep_stale_transfers_at(t0 + REASSEMBLY_TTL / 2); + assert_eq!(handler.reassembly.lock().unwrap().len(), 1); + + // past the TTL with no progress: the buffer is reclaimed + handler.sweep_stale_transfers_at(t0 + REASSEMBLY_TTL); + assert!(handler.reassembly.lock().unwrap().is_empty()); + } + + #[test] + fn a_chunk_refreshes_the_deadline_of_a_slow_transfer() { + let dir = tempfile::tempdir().unwrap(); + let handler = handler(&dir).with_chunk_size(2); + let peer = dummy_peer(); + let txid = "ae".repeat(32); + let t0 = Instant::now(); + let second_chunk = t0 + REASSEMBLY_TTL / 2; + + handler + .handle_chunk(consignment_chunk(&txid, 0, 3, b"aa".to_vec()), peer, t0) + .unwrap(); + // a chunk arriving halfway through the TTL restarts the clock + handler + .handle_chunk( + consignment_chunk(&txid, 1, 3, b"bb".to_vec()), + peer, + second_chunk, + ) + .unwrap(); + + // so a sweep past the first chunk's deadline leaves the transfer alone + handler.sweep_stale_transfers_at(t0 + REASSEMBLY_TTL); + assert_eq!(handler.reassembly.lock().unwrap().len(), 1); + + // and it is only dropped once the refreshed deadline passes too + handler.sweep_stale_transfers_at(second_chunk + REASSEMBLY_TTL); + assert!(handler.reassembly.lock().unwrap().is_empty()); + } + + #[test] + fn consignments_are_capped_node_wide() { + let dir = tempfile::tempdir().unwrap(); + let peer = dummy_peer(); + let other = peer_id(0x02); + // node-wide limit for one consignment + let handler = handler_gated(&dir, TestGate::with_channels(vec![peer, other])) + .with_pending_consignments_limit(1); + let t0 = Instant::now(); + let txid1 = "01".repeat(32); + let txid2 = "02".repeat(32); + let txid3 = "03".repeat(32); + + handler + .handle_chunk(consignment_chunk(&txid1, 0, 1, b"aaa".to_vec()), peer, t0) + .unwrap(); + assert_eq!(fs::read(handler.consignment_path(&txid1)).unwrap(), b"aaa"); + + // funding txids can be invented by a sender, so without the cap a peer could fill the disk + // with invalid consignments it never funds + handler + .handle_chunk(consignment_chunk(&txid2, 0, 1, b"bbb".to_vec()), peer, t0) + .unwrap(); + assert!(!handler.consignment_path(&txid2).exists()); + + // the cap is node-wide, so a second node (a Sybil) gets no fresh allowance + handler + .handle_chunk(consignment_chunk(&txid3, 0, 1, b"ccc".to_vec()), other, t0) + .unwrap(); + assert!(!handler.consignment_path(&txid3).exists()); + + // resending a consignment we already hold is ignored, not overwritten: the first content + // stands and the funding is not counted twice + handler + .handle_chunk(consignment_chunk(&txid1, 0, 1, b"ddd".to_vec()), peer, t0) + .unwrap(); + assert_eq!(fs::read(handler.consignment_path(&txid1)).unwrap(), b"aaa"); + } + + #[test] + fn a_resent_consignment_does_not_reset_the_media_budget() { + let dir = tempfile::tempdir().unwrap(); + let peer = dummy_peer(); + const LIMIT: usize = 6; + let handler = + handler_gated(&dir, TestGate::with_channels(vec![peer])).with_media_limit(LIMIT); + let t0 = Instant::now(); + let txid = "07".repeat(32); + + // a consignment, then media that fills the per-channel budget + handler + .handle_chunk(consignment_chunk(&txid, 0, 1, b"con".to_vec()), peer, t0) + .unwrap(); + let max_size_media = vec![0u8; LIMIT]; + handler + .handle_chunk(media_chunk(&txid, max_size_media.clone()), peer, t0) + .unwrap(); + assert!(staged_path(&handler, &txid, &max_size_media).exists()); + + // resend the consignment with distinct bytes: it is ignored, not re-persisted, so the file + // keeps its original content rather than being overwritten + handler + .handle_chunk(consignment_chunk(&txid, 0, 1, b"resent".to_vec()), peer, t0) + .unwrap(); + assert_eq!(fs::read(handler.consignment_path(&txid)).unwrap(), b"con"); + + // and because it was ignored the staged-media accounting stands, so more media, which would + // fit only if the budget had been reset, is still refused + let more_media = vec![1u8]; + handler + .handle_chunk(media_chunk(&txid, more_media.clone()), peer, t0) + .unwrap(); + assert!(!staged_path(&handler, &txid, &more_media).exists()); + } + + #[test] + fn in_flight_media_files_are_capped_per_funding() { + let dir = tempfile::tempdir().unwrap(); + let peer = dummy_peer(); + const MAX_FILES: usize = 3; + let handler = handler_gated(&dir, TestGate::with_channels(vec![peer])) + .with_media_files_limit(MAX_FILES); + let t0 = Instant::now(); + let txid = "08".repeat(32); + + // each message is the short last chunk of a distinct two-chunk media file, so it stays in + // flight holding a single byte: the object-count amplification the byte budget can't stop + let incomplete = |seed: u8| RgbFileMessage { + file_kind: FILE_KIND_MEDIA, + funding_txid: txid.clone(), + file_id: sha256::Hash::hash(&[seed; 8]).to_string(), + chunk_index: 1, + total_chunks: 2, + data: vec![seed], + }; + + for seed in 0..MAX_FILES as u8 { + handler.handle_chunk(incomplete(seed), peer, t0).unwrap(); + } + assert_eq!(handler.reassembly.lock().unwrap().len(), MAX_FILES); + + // one more distinct media file is refused once the funding is at its file cap, even though + // its single byte is nowhere near the byte budget + handler.handle_chunk(incomplete(0xff), peer, t0).unwrap(); + assert_eq!(handler.reassembly.lock().unwrap().len(), MAX_FILES); + } + + #[test] + fn a_consignment_larger_than_the_cap_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let peer = dummy_peer(); + // a small per-consignment size cap, so the test needn't build 16 MB + let handler = + handler_gated(&dir, TestGate::with_channels(vec![peer])).with_consignment_size_limit(4); + let t0 = Instant::now(); + let txid = "0f".repeat(32); + + // a chunk that would push the file past its size cap is refused before it is buffered, so + // the oversized file never lands in memory or on disk + handler + .handle_chunk(consignment_chunk(&txid, 0, 1, b"aaaaa".to_vec()), peer, t0) + .unwrap(); + assert!(!handler.consignment_path(&txid).exists()); + assert!(handler.reassembly.lock().unwrap().is_empty()); + } + + #[test] + fn an_empty_chunk_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let handler = handler(&dir); + let peer = dummy_peer(); + let txid = "0e".repeat(32); + + // an empty chunk carries no data and would let an incomplete transfer sit in memory, + // costing nothing against any byte budget + handler + .handle_chunk(consignment_chunk(&txid, 0, 2, vec![]), peer, Instant::now()) + .unwrap(); + assert!(handler.reassembly.lock().unwrap().is_empty()); + } + + #[test] + fn only_the_last_chunk_may_be_short() { + let dir = tempfile::tempdir().unwrap(); + let handler = handler(&dir).with_chunk_size(4); + let peer = dummy_peer(); + let txid1 = "0f".repeat(32); + let t0 = Instant::now(); + + // a non-final chunk shorter than chunk_size is refused + handler + .handle_chunk(consignment_chunk(&txid1, 0, 2, b"ab".to_vec()), peer, t0) + .unwrap(); + assert!(handler.reassembly.lock().unwrap().is_empty()); + + // a full non-final chunk is accepted, and a shorter last chunk completes the file + handler + .handle_chunk(consignment_chunk(&txid1, 0, 2, b"abcd".to_vec()), peer, t0) + .unwrap(); + handler + .handle_chunk(consignment_chunk(&txid1, 1, 2, b"ef".to_vec()), peer, t0) + .unwrap(); + assert_eq!( + fs::read(handler.consignment_path(&txid1)).unwrap(), + b"abcdef" + ); + + // same with chunk order inverted + let txid2 = "0f".repeat(32); + handler + .handle_chunk(consignment_chunk(&txid2, 1, 2, b"ef".to_vec()), peer, t0) + .unwrap(); + handler + .handle_chunk(consignment_chunk(&txid2, 0, 2, b"abcd".to_vec()), peer, t0) + .unwrap(); + assert_eq!( + fs::read(handler.consignment_path(&txid2)).unwrap(), + b"abcdef" + ); + } + + #[test] + fn incomplete_transfers_count_against_the_pending_cap() { + let dir = tempfile::tempdir().unwrap(); + let peer = dummy_peer(); + // room for one pending funding + let handler = handler_gated(&dir, TestGate::with_channels(vec![peer])) + .with_pending_consignments_limit(1) + .with_chunk_size(2); + let t0 = Instant::now(); + let stuck_txid = "01".repeat(32); + let fresh_txid = "02".repeat(32); + + // a transfer that arrives but never completes still occupies one pending slot + handler + .handle_chunk( + consignment_chunk(&stuck_txid, 0, 2, b"aa".to_vec()), + peer, + t0, + ) + .unwrap(); + assert_eq!(handler.reassembly.lock().unwrap().len(), 1); + + // so a new funding is refused at its first chunk, not only once something completes + handler + .handle_chunk( + consignment_chunk(&fresh_txid, 0, 2, b"bb".to_vec()), + peer, + t0, + ) + .unwrap(); + let reassembly = handler.reassembly.lock().unwrap(); + assert_eq!(reassembly.len(), 1); + assert!(reassembly.keys().all(|k| k.funding_txid == stuck_txid)); + } + + #[test] + fn media_aggregate_counts_bytes_still_in_flight() { + let dir = tempfile::tempdir().unwrap(); + let peer = dummy_peer(); + // budget with room for one full chunk but not two + const CHUNK: usize = 50; + let handler = handler_gated(&dir, TestGate::with_channels(vec![peer])) + .with_media_limit(CHUNK + CHUNK / 2) + .with_chunk_size(CHUNK); + let t0 = Instant::now(); + let txid = "03".repeat(32); + + // a consignment, so media is accepted for this funding + handler + .handle_chunk(consignment_chunk(&txid, 0, 1, b"aaa".to_vec()), peer, t0) + .unwrap(); + + // one media file holding a full chunk in flight (2 chunks, only the first sent) + let media = |file_id: &str| RgbFileMessage { + file_kind: FILE_KIND_MEDIA, + funding_txid: txid.clone(), + file_id: file_id.to_string(), + chunk_index: 0, + total_chunks: 2, + data: vec![0u8; CHUNK], + }; + handler + .handle_chunk(media(&"aa".repeat(32)), peer, t0) + .unwrap(); + + // a second media file whose first chunk, added to the first's in-flight bytes, would exceed + // the channel's media budget is refused, even though neither file alone exceeds it, and + // nothing has been staged yet + handler + .handle_chunk(media(&"bb".repeat(32)), peer, t0) + .unwrap(); + let media_entries = handler + .reassembly + .lock() + .unwrap() + .keys() + .filter(|k| k.file_kind == FILE_KIND_MEDIA) + .count(); + assert_eq!(media_entries, 1); + } + + #[test] + fn an_unfunded_consignment_is_reclaimed_and_a_funded_one_is_kept() { + let dir = tempfile::tempdir().unwrap(); + let peer = dummy_peer(); + let funded_txid = "03".repeat(32); + let never_funded_txid = "04".repeat(32); + let handler = handler_gated( + &dir, + TestGate { + channels: vec![peer, peer], + funded_txids: vec![funded_txid.clone()], + }, + ); + let t0 = Instant::now(); + + // the never-funded consignment arrives normally over p2p + handler + .handle_chunk( + consignment_chunk(&never_funded_txid, 0, 1, b"aaa".to_vec()), + peer, + t0, + ) + .unwrap(); + // the funded one was written before its channel funded, so put that pre-funding state in + // place directly + fs::write(handler.consignment_path(&funded_txid), b"aaa").unwrap(); + handler.staged_fundings.lock().unwrap().insert( + FundingStagingKey { + peer, + funding_txid: funded_txid.clone(), + }, + FundingStagingRecord { + written_at: t0, + staged_media_bytes: 0, + staged_media_count: 0, + }, + ); + + // before the TTL nothing is touched, however the funding turned out + handler.sweep_unfunded_stagings_at(t0); + assert!(handler.consignment_path(&never_funded_txid).exists()); + + handler.sweep_unfunded_stagings_at(t0 + CONSIGNMENT_TTL); + // the channel that opened keeps its consignment: deleting it would make the acceptor read + // the colored channel as vanilla. It is removed when that channel closes. + assert!(handler.consignment_path(&funded_txid).exists()); + // the one that never funded is deleted + assert!(!handler.consignment_path(&never_funded_txid).exists()); + } + + #[test] + fn a_funded_consignment_stops_counting_against_the_cap() { + let dir = tempfile::tempdir().unwrap(); + let peer = dummy_peer(); + let funded_txid = "05".repeat(32); + // node-wide room for one consignment + let handler = handler_gated( + &dir, + TestGate { + channels: vec![peer], + funded_txids: vec![funded_txid.clone()], + }, + ) + .with_pending_consignments_limit(1); + let t0 = Instant::now(); + + handler + .handle_chunk( + consignment_chunk(&funded_txid, 0, 1, b"aaa".to_vec()), + peer, + t0, + ) + .unwrap(); + // once the funding happens the file belongs to a real channel, so it must stop occupying a + // slot, otherwise the next channel's consignment would be refused + handler.sweep_unfunded_stagings_at(t0); + assert!(handler.staged_fundings.lock().unwrap().is_empty()); + + let next_txid = "06".repeat(32); + handler + .handle_chunk( + consignment_chunk(&next_txid, 0, 1, b"bbb".to_vec()), + peer, + t0, + ) + .unwrap(); + assert_eq!( + fs::read(handler.consignment_path(&next_txid)).unwrap(), + b"bbb" + ); + } + + #[test] + fn forgetting_a_closed_channels_consignment_frees_a_slot() { + let dir = tempfile::tempdir().unwrap(); + let peer = dummy_peer(); + // node-wide room for one consignment + let handler = handler_gated(&dir, TestGate::with_channels(vec![peer])) + .with_pending_consignments_limit(1); + let t0 = Instant::now(); + let txid1 = "07".repeat(32); + let txid2 = "08".repeat(32); + + handler + .handle_chunk(consignment_chunk(&txid1, 0, 1, b"aaa".to_vec()), peer, t0) + .unwrap(); + assert_eq!(fs::read(handler.consignment_path(&txid1)).unwrap(), b"aaa"); + + // simulate a channel that funds and closes within a sweep interval, so the sweep never saw + // it as funded and its record would otherwise linger; the ChannelClosed handler forgets it + // instead + handler.forget_staged_funding(&txid1); + assert!(handler.staged_fundings.lock().unwrap().is_empty()); + // the consignment now lives in the KVStore, so the staged file must not be left behind + assert!(!handler.consignment_path(&txid1).exists()); + + // with the slot freed, the next channel's consignment is accepted rather than refused + handler + .handle_chunk(consignment_chunk(&txid2, 0, 1, b"bbb".to_vec()), peer, t0) + .unwrap(); + assert_eq!(fs::read(handler.consignment_path(&txid2)).unwrap(), b"bbb"); + } + + #[test] + fn media_is_staged_and_needs_an_outstanding_consignment() { + let dir = tempfile::tempdir().unwrap(); + let peer = dummy_peer(); + let handler = handler_gated(&dir, TestGate::with_channels(vec![peer])); + let t0 = Instant::now(); + let txid = "07".repeat(32); + + // media for a funding we hold no consignment for is refused: otherwise a peer could stage + // media against any funding txid it cared to invent + handler + .handle_chunk(media_chunk(&txid, b"picture".to_vec()), peer, t0) + .unwrap(); + assert!(!staged_path(&handler, &txid, b"picture").exists()); + + // with a consignment outstanding it is staged, not placed among the wallet's real media: + // nothing has vouched for these bytes until the consignment is accepted at funding + handler + .handle_chunk(consignment_chunk(&txid, 0, 1, b"aaa".to_vec()), peer, t0) + .unwrap(); + handler + .handle_chunk(media_chunk(&txid, b"picture".to_vec()), peer, t0) + .unwrap(); + assert_eq!( + fs::read(staged_path(&handler, &txid, b"picture")).unwrap(), + b"picture" + ); + } + + #[test] + fn media_that_does_not_match_its_claimed_digest_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let peer = dummy_peer(); + let handler = handler_gated(&dir, TestGate::with_channels(vec![peer])); + let t0 = Instant::now(); + let txid = "0e".repeat(32); + handler + .handle_chunk(consignment_chunk(&txid, 0, 1, b"aaa".to_vec()), peer, t0) + .unwrap(); + + // the sender says one thing and sends another: known-bad bytes, so nothing is written and + // the funding's staging budget is not spent on them + let mut msg = media_chunk(&txid, b"picture".to_vec()); + msg.file_id = "cd".repeat(32); + handler.handle_chunk(msg, peer, t0).unwrap(); + + assert!(!get_media_staging_dir(&handler.ldk_data_dir, &txid).exists()); + let persisted = handler.staged_fundings.lock().unwrap(); + assert_eq!(persisted.values().next().unwrap().staged_media_bytes, 0); + } + + #[test] + fn staged_media_is_capped_per_funding() { + let dir = tempfile::tempdir().unwrap(); + let peer = dummy_peer(); + const LIMIT: usize = 400; + let handler = + handler_gated(&dir, TestGate::with_channels(vec![peer])).with_media_limit(LIMIT); + let t0 = Instant::now(); + let txid = "08".repeat(32); + handler + .handle_chunk(consignment_chunk(&txid, 0, 1, b"aaa".to_vec()), peer, t0) + .unwrap(); + + // media is content-addressed, so distinct bytes mean distinct files: without a cap one + // funding would let a peer write as many as it likes. Four files fill the byte budget, so the + // fifth is the one that must be refused + let mut accepted = 0; + for i in 0..5u8 { + let data = vec![i; LIMIT / 4]; + handler + .handle_chunk(media_chunk(&txid, data.clone()), peer, t0) + .unwrap(); + if staged_path(&handler, &txid, &data).exists() { + accepted += 1; + } + } + assert_eq!(accepted, 4); + + let staged = handler.staged_fundings.lock().unwrap(); + let record = staged.values().next().unwrap(); + assert!(record.staged_media_bytes <= LIMIT); + } + + #[test] + fn staged_media_is_discarded_when_the_funding_never_happens() { + let dir = tempfile::tempdir().unwrap(); + let peer = dummy_peer(); + let handler = handler_gated(&dir, TestGate::with_channels(vec![peer])); + let t0 = Instant::now(); + let txid = "09".repeat(32); + + handler + .handle_chunk(consignment_chunk(&txid, 0, 1, b"aaa".to_vec()), peer, t0) + .unwrap(); + handler + .handle_chunk(media_chunk(&txid, b"picture".to_vec()), peer, t0) + .unwrap(); + assert!(staged_path(&handler, &txid, b"picture").exists()); + + handler.sweep_unfunded_stagings_at(t0 + CONSIGNMENT_TTL); + assert!(!get_media_staging_dir(&handler.ldk_data_dir, &txid).exists()); + } + + #[test] + fn orphans_from_a_previous_run_are_deleted() { + let dir = tempfile::tempdir().unwrap(); + let peer = dummy_peer(); + let funded_txid = "0a".repeat(32); + let never_funded_txid = "0b".repeat(32); + let t0 = Instant::now(); + + // before the restart both files were written while their channels were still being funded, + // so nothing was funded yet + { + let handler = handler_gated( + &dir, + TestGate { + channels: vec![peer, peer], + funded_txids: vec![], + }, + ); + for txid in [&funded_txid, &never_funded_txid] { + handler + .handle_chunk(consignment_chunk(txid, 0, 1, b"aaa".to_vec()), peer, t0) + .unwrap(); + handler + .handle_chunk(media_chunk(txid, b"picture".to_vec()), peer, t0) + .unwrap(); + } + } + + // restart: `funded` has since funded, `never_funded` never did. The record of what was + // written is gone, so only the directory is left to go on + let handler = handler_gated( + &dir, + TestGate { + channels: vec![peer, peer], + funded_txids: vec![funded_txid.clone()], + }, + ); + assert!(handler.staged_fundings.lock().unwrap().is_empty()); + + // nothing old enough yet + handler.cleanup_orphans_at(SystemTime::now()); + assert!(handler.consignment_path(&never_funded_txid).exists()); + + handler.cleanup_orphans_at(SystemTime::now() + CONSIGNMENT_TTL); + // the funding that never happened takes its media with it + assert!(!handler.consignment_path(&never_funded_txid).exists()); + assert!(!get_media_staging_dir(&handler.ldk_data_dir, &never_funded_txid).exists()); + // the channel that opened keeps its consignment, whatever run wrote it + assert!(handler.consignment_path(&funded_txid).exists()); + } + + #[test] + fn reclaiming_orphans_leaves_the_closing_consignment_alone() { + let dir = tempfile::tempdir().unwrap(); + let handler = handler_gated(&dir, TestGate::default()); + + // the transient closing consignment shares the prefix but is owned elsewhere, as + // denoted by its suffix + let closing = dir + .path() + .join(format!("consignment_{}_rgb:contract-id", "0c".repeat(32))); + fs::write(&closing, b"x").unwrap(); + + handler.cleanup_orphans_at(SystemTime::now() + CONSIGNMENT_TTL); + assert!(closing.exists()); + } + + #[test] + fn a_funding_txid_that_is_not_a_txid_is_rejected() { + // the funding txid names a file and a staging directory, so a peer must not be able to + // steer those paths + let encode = |msg: &RgbFileMessage| { + let mut encoded = Vec::new(); + msg.write(&mut encoded).unwrap(); + encoded + }; + + for txid in [ + "../../etc/passwd", + "", + &"ab".repeat(64), + "zz".repeat(32).as_str(), + ] { + let encoded = encode(&consignment_chunk(txid, 0, 1, b"aaa".to_vec())); + assert!( + RgbFileMessage::read_from(&mut &encoded[..]).is_err(), + "accepted bogus funding txid {txid:?}" + ); + } + + // a canonical one still round-trips + let msg = consignment_chunk(&"ab".repeat(32), 0, 1, b"aaa".to_vec()); + let encoded = encode(&msg); + assert_eq!(RgbFileMessage::read_from(&mut &encoded[..]).unwrap(), msg); + } + + #[test] + fn an_out_of_range_index_aborts_the_transfer() { + let dir = tempfile::tempdir().unwrap(); + let handler = handler(&dir).with_chunk_size(1); + let peer = dummy_peer(); + let txid = "bb".repeat(32); + + // two valid chunks buffer, leaving the transfer in flight + handler + .handle_custom_message(consignment_chunk(&txid, 0, 3, b"a".to_vec()), peer) + .unwrap(); + handler + .handle_custom_message(consignment_chunk(&txid, 1, 3, b"b".to_vec()), peer) + .unwrap(); + assert_eq!(handler.reassembly.lock().unwrap().len(), 1); + + // a chunk whose index is out of range (5 of 3) is malformed: the whole transfer is dropped, + // so it can never assemble garbage from a set like {0, 1, 5} + handler + .handle_custom_message(consignment_chunk(&txid, 5, 3, b"c".to_vec()), peer) + .unwrap(); + assert!(handler.reassembly.lock().unwrap().is_empty()); + assert!(!handler.consignment_path(&txid).exists()); + } + + #[test] + fn an_inconsistent_total_chunks_aborts_the_transfer() { + let dir = tempfile::tempdir().unwrap(); + let handler = handler(&dir).with_chunk_size(1); + let peer = dummy_peer(); + let txid = "bd".repeat(32); + + // a chunk locks the transfer in at total_chunks = 3 + handler + .handle_custom_message(consignment_chunk(&txid, 0, 3, b"a".to_vec()), peer) + .unwrap(); + assert_eq!(handler.reassembly.lock().unwrap().len(), 1); + + // a later chunk claiming a different total is malformed: the whole transfer is dropped + handler + .handle_custom_message(consignment_chunk(&txid, 1, 5, b"b".to_vec()), peer) + .unwrap(); + assert!(handler.reassembly.lock().unwrap().is_empty()); + } + + #[test] + fn reassembly_state_is_dropped_when_the_peer_disconnects() { + let dir = tempfile::tempdir().unwrap(); + let handler = handler(&dir).with_chunk_size(1); + let peer = dummy_peer(); + let other = peer_id(0x02); + let txid = "cc".repeat(32); + + // both peers leave a transfer half-finished + handler + .handle_custom_message(consignment_chunk(&txid, 0, 2, b"a".to_vec()), peer) + .unwrap(); + handler + .handle_custom_message(consignment_chunk(&txid, 0, 2, b"a".to_vec()), other) + .unwrap(); + assert_eq!(handler.reassembly.lock().unwrap().len(), 2); + + // only the disconnecting peer's state goes away + handler.peer_disconnected(peer); + let reassembly = handler.reassembly.lock().unwrap(); + assert_eq!(reassembly.len(), 1); + assert!(reassembly.keys().all(|k| k.peer == other)); + } + + #[test] + fn peers_cannot_interfere_with_each_other() { + let dir = tempfile::tempdir().unwrap(); + let handler = handler(&dir).with_chunk_size(2); + let txid = "dd".repeat(32); + + // two peers each send one half of the same funding txid: keyed by sender, neither completes, + // rather than assembling one file out of two peers' chunks + handler + .handle_custom_message( + consignment_chunk(&txid, 0, 2, b"aa".to_vec()), + peer_id(0x01), + ) + .unwrap(); + handler + .handle_custom_message( + consignment_chunk(&txid, 1, 2, b"bb".to_vec()), + peer_id(0x02), + ) + .unwrap(); + + assert!(!handler.consignment_path(&txid).exists()); + assert_eq!(handler.reassembly.lock().unwrap().len(), 2); + } + + #[test] + fn chunk_count_refuses_what_the_framing_cannot_number() { + // callers reject empty files, so the smallest input is one byte: a single chunk + assert_eq!(chunk_count(1), Some(1)); + assert_eq!(chunk_count(CHUNK_SIZE), Some(1)); + assert_eq!(chunk_count(CHUNK_SIZE + 1), Some(2)); + + // the largest file the u16 chunk count can describe, and the first one it cannot: a cast + // would wrap these to 0 and 1 respectively, and the receiver would either drop every chunk + // or complete on the first one and write a truncated file + let max = CHUNK_SIZE * u16::MAX as usize; + assert_eq!(chunk_count(max), Some(u16::MAX)); + assert_eq!(chunk_count(max + 1), None); + assert_eq!(chunk_count(usize::MAX), None); + } + + #[test] + fn a_file_id_that_is_not_a_digest_is_rejected() { + let encode = |msg: &RgbFileMessage| { + let mut encoded = Vec::new(); + msg.write(&mut encoded).unwrap(); + encoded + }; + let txid = "ab".repeat(32); + let media = |file_id: &str| RgbFileMessage { + file_kind: FILE_KIND_MEDIA, + funding_txid: txid.clone(), + file_id: file_id.to_string(), + chunk_index: 0, + total_chunks: 1, + data: b"x".to_vec(), + }; + + // the file id keys the reassembly map, so an arbitrary-length string is refused + for file_id in ["", &"z".repeat(65_000), "not-a-digest"] { + let encoded = encode(&media(file_id)); + assert!( + RgbFileMessage::read_from(&mut &encoded[..]).is_err(), + "accepted bogus media file id {file_id:?}" + ); + } + let good = media(&"cd".repeat(32)); + let encoded = encode(&good); + assert_eq!(RgbFileMessage::read_from(&mut &encoded[..]).unwrap(), good); + + // a consignment carries no file id, there being one per funding + let mut consignment = consignment_chunk(&txid, 0, 1, b"x".to_vec()); + let encoded = encode(&consignment); + assert_eq!( + RgbFileMessage::read_from(&mut &encoded[..]).unwrap(), + consignment + ); + assert_eq!(consignment.file_id, ""); + consignment.file_id = "cd".repeat(32); + let encoded = encode(&consignment); + assert!(RgbFileMessage::read_from(&mut &encoded[..]).is_err()); + } + + #[test] + fn resent_chunk_aborts_the_transfer() { + let dir = tempfile::tempdir().unwrap(); + let handler = handler(&dir).with_chunk_size(3); + let peer = dummy_peer(); + let txid = "ba".repeat(32); + + // a non-final chunk of a two-chunk file leaves the transfer in flight + handler + .handle_custom_message(consignment_chunk(&txid, 0, 2, b"aaa".to_vec()), peer) + .unwrap(); + assert_eq!(handler.reassembly.lock().unwrap().len(), 1); + + // re-sending an index we already hold is a malformed stream: the whole transfer is dropped + handler + .handle_custom_message(consignment_chunk(&txid, 0, 2, b"aaa".to_vec()), peer) + .unwrap(); + assert!(handler.reassembly.lock().unwrap().is_empty()); + assert!(!handler.consignment_path(&txid).exists()); + } +} diff --git a/src/routes.rs b/src/routes.rs index cfc34965..78e0a341 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -46,23 +46,25 @@ use rgb_lib::{ check_indexer_url as rgb_lib_check_indexer_url, IndexerProtocol as RgbLibIndexerProtocol, }, - AssetCFA as RgbLibAssetCFA, AssetIFA as RgbLibAssetIFA, AssetNIA as RgbLibAssetNIA, - AssetUDA as RgbLibAssetUDA, Balance as RgbLibBalance, EmbeddedMedia as RgbLibEmbeddedMedia, - IfaIssuanceType as RgbLibIfaIssuanceType, Invoice as RgbLibInvoice, Media as RgbLibMedia, + AssetCFA as RgbLibAssetCFA, AssetFilter as RgbLibAssetFilter, AssetIFA as RgbLibAssetIFA, + AssetNIA as RgbLibAssetNIA, AssetUDA as RgbLibAssetUDA, Balance as RgbLibBalance, + EmbeddedMedia as RgbLibEmbeddedMedia, IfaIssuanceType as RgbLibIfaIssuanceType, + Invoice as RgbLibInvoice, Media as RgbLibMedia, OperationResult as RgbLibOperationResult, Outpoint as RgbLibOutpoint, ProofOfReserves as RgbLibProofOfReserves, Recipient as RgbLibRecipient, RecipientInfo, RecipientType as RgbLibRecipientType, RefreshFilter as RgbLibRefreshFilter, RefreshTransferStatus as RgbLibRefreshTransferStatus, - SyncKeychain as RgbLibSyncKeychain, SyncOptions as RgbLibSyncOptions, - SyncStrategy as RgbLibSyncStrategy, Token as RgbLibToken, TokenLight as RgbLibTokenLight, - WitnessData as RgbLibWitnessData, + RefreshedTransfer as RgbLibRefreshedTransfer, SyncKeychain as RgbLibSyncKeychain, + SyncOptions as RgbLibSyncOptions, SyncStrategy as RgbLibSyncStrategy, Token as RgbLibToken, + TokenLight as RgbLibTokenLight, WitnessData as RgbLibWitnessData, }, AssetSchema as RgbLibAssetSchema, Assignment as RgbLibAssignment, - BitcoinNetwork as RgbLibNetwork, ContractId, RgbTransport, + BitcoinNetwork as RgbLibNetwork, ContractId, Error as RgbLibError, }; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::{ - collections::HashMap, net::ToSocketAddrs, path::Path, str::FromStr, sync::Arc, time::Duration, + collections::HashMap, io::Write, net::ToSocketAddrs, path::Path, str::FromStr, sync::Arc, + time::Duration, }; use tokio::{ fs::File, @@ -84,12 +86,15 @@ use crate::core_types::async_order::{ AsyncOrderNewRequest, AsyncOrderNewResponse, AsyncOrderOutboundInvoiceRequest, AsyncOrderOutboundInvoiceResponse, }; +use crate::error::error_name; use crate::ldk::{ clear_rgb_payment_pending, peer_has_live_channel, start_ldk, stop_ldk, LdkBackgroundServices, VirtualChannelSessionStatus, }; #[cfg(feature = "vss")] use crate::ldk::{derive_vss_identity, derive_vss_identity_from_key_source}; +#[cfg(test)] +use crate::ldk::{node_override_matches, FORCE_PUSH_ASSET_AMOUNT_ON_NODE}; #[cfg(feature = "vss")] use crate::signer::read_key_source_file; use crate::swap::{SwapData, SwapInfo, SwapString}; @@ -97,14 +102,15 @@ use crate::utils::{ check_already_initialized, check_channel_id, check_password_strength, check_password_validity, description_from_invoice, description_hash_from_invoice, encrypt_and_save_mnemonic, get_max_local_rgb_amount, get_route, hex_str, hex_str_to_compressed_pubkey, hex_str_to_vec, - is_external_signer_mode_configured, new_jsonrpc_request_id, open_database_pool, - parse_invoice_description, validate_and_parse_payment_hash, - validate_and_parse_payment_preimage, UnlockedAppState, UserOnionMessageContents, + invoice_description_from_request, is_external_signer_mode_configured, new_jsonrpc_request_id, + open_database_pool, validate_and_parse_payment_hash, validate_and_parse_payment_preimage, + UnlockedAppState, UserOnionMessageContents, }; use crate::{ - backup::{do_backup, restore_backup}, + backup::{do_backup, install_backup, unpack_backup}, core_types::{ - HTLCStatus, SwapStatus, UnlockRequest as CoreUnlockRequest, PENDING_SWAP_TIMEOUT_SECS, + HTLCStatus, LdkChainSync, SwapStatus, UnlockRequest as CoreUnlockRequest, + PENDING_SWAP_TIMEOUT_SECS, }, rgb::{check_rgb_proxy_endpoint, get_rgb_channel_info_optional}, }; @@ -117,6 +123,12 @@ use crate::{ }; const VIRTUAL_OPEN_MODE_TRUSTED_NO_BROADCAST: &str = "trusted_no_broadcast"; +/// Expiry applied when the caller does not specify one (rgb-lib no longer accepts "no expiry"). +pub(crate) const DEFAULT_RGB_TRANSFER_EXPIRATION_SECS: u64 = 86400; + +fn default_expiration_timestamp() -> u64 { + get_current_timestamp() + DEFAULT_RGB_TRANSFER_EXPIRATION_SECS +} #[derive(Deserialize, Serialize)] pub(crate) struct AddressResponse { @@ -178,6 +190,24 @@ impl From for AssetCFA { } } +#[derive(Debug, Deserialize, Serialize)] +#[serde(tag = "type", content = "value")] +pub(crate) enum AssetFilter { + AnyOrNone, + None, + Id(String), +} + +impl From for RgbLibAssetFilter { + fn from(x: AssetFilter) -> Self { + match x { + AssetFilter::AnyOrNone => Self::AnyOrNone, + AssetFilter::None => Self::None, + AssetFilter::Id(asset_id) => Self::Id(asset_id), + } + } +} + #[derive(Deserialize, Serialize)] pub(crate) struct AssetIFA { pub(crate) asset_id: String, @@ -558,6 +588,7 @@ pub(crate) struct DecodeRGBInvoiceResponse { pub(crate) network: BitcoinNetwork, pub(crate) expiration_timestamp: Option, pub(crate) transport_endpoints: Vec, + pub(crate) unknown_query_params: HashMap, } #[derive(Deserialize, Serialize)] @@ -640,6 +671,17 @@ pub(crate) struct GetChannelIdResponse { pub(crate) channel_id: String, } +#[derive(Deserialize, Serialize)] +pub(crate) struct GetConsignmentRequest { + pub(crate) asset_id: String, + pub(crate) txid: String, +} + +#[derive(Deserialize, Serialize)] +pub(crate) struct GetConsignmentResponse { + pub(crate) bytes_hex: String, +} + #[derive(Deserialize, Serialize)] pub(crate) struct GetPaymentRequest { pub(crate) payment_hash: String, @@ -891,7 +933,7 @@ pub(crate) struct ListTransactionsResponse { #[derive(Deserialize, Serialize)] pub(crate) struct ListTransfersRequest { - pub(crate) asset_id: Option, + pub(crate) asset_filter: AssetFilter, pub(crate) txid: Option, pub(crate) index_offset: Option, pub(crate) max_transfers: Option, @@ -1033,6 +1075,23 @@ pub(crate) struct OpenChannelResponse { pub(crate) temporary_channel_id: String, } +#[derive(Deserialize, Serialize)] +pub(crate) struct OperationResult { + pub(crate) txid: String, + pub(crate) batch_transfer_idx: i32, + pub(crate) entropy: u64, +} + +impl From for OperationResult { + fn from(value: RgbLibOperationResult) -> Self { + Self { + txid: value.txid, + batch_transfer_idx: value.batch_transfer_idx, + entropy: value.entropy, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] pub(crate) enum PaymentType { Outbound, @@ -1103,6 +1162,21 @@ impl From for ProofOfReserves { } } +#[derive(Deserialize, Serialize)] +pub(crate) struct ProvideOutOfBandAckRequest { + pub(crate) recipient_id: String, +} + +#[derive(Deserialize, Serialize)] +pub(crate) struct ProvideOutOfBandAckResponse { + pub(crate) operation: Option, +} + +#[derive(Deserialize, Serialize)] +pub(crate) struct ProvideOutOfBandConsignmentResponse { + pub(crate) transfers: HashMap, +} + #[derive(Deserialize, Serialize)] pub(crate) struct Recipient { pub(crate) recipient_id: String, @@ -1137,6 +1211,36 @@ impl From for RecipientType { } } +#[derive(Debug, Deserialize, Serialize)] +pub(crate) struct RefreshedTransfer { + pub(crate) updated_status: Option, + pub(crate) failure: Option, +} + +impl From for RefreshedTransfer { + fn from(value: RgbLibRefreshedTransfer) -> Self { + Self { + updated_status: value.updated_status.map(|s| s.into()), + failure: value.failure.map(Into::into), + } + } +} + +#[derive(Debug, Deserialize, Serialize)] +pub(crate) struct RefreshFailure { + pub(crate) name: String, + pub(crate) message: String, +} + +impl From for RefreshFailure { + fn from(error: RgbLibError) -> Self { + Self { + name: error_name(&error), + message: error.to_string(), + } + } +} + #[derive(Deserialize, Serialize)] pub(crate) struct RefreshFilter { pub(crate) status: RefreshTransferStatus, @@ -1152,6 +1256,18 @@ impl From for RgbLibRefreshFilter { } } +#[derive(Deserialize, Serialize)] +pub(crate) struct RefreshRequest { + pub(crate) asset_id: Option, + pub(crate) filter: Vec, + pub(crate) skip_sync: bool, +} + +#[derive(Deserialize, Serialize)] +pub(crate) struct RefreshResponse { + pub(crate) transfers: HashMap, +} + #[derive(Deserialize, Serialize)] pub(crate) enum RefreshTransferStatus { WaitingCounterparty, @@ -1167,13 +1283,6 @@ impl From for RgbLibRefreshTransferStatus { } } -#[derive(Deserialize, Serialize)] -pub(crate) struct RefreshRequest { - pub(crate) asset_id: Option, - pub(crate) filter: Vec, - pub(crate) skip_sync: bool, -} - #[derive(Deserialize, Serialize)] pub(crate) struct RestoreRequest { pub(crate) backup_path: String, @@ -1196,16 +1305,18 @@ pub(crate) struct RgbAllocation { pub(crate) struct RgbInvoiceRequest { pub(crate) asset_id: Option, pub(crate) assignment: Option, - pub(crate) expiration_timestamp: Option, + #[serde(default = "default_expiration_timestamp")] + pub(crate) expiration_timestamp: u64, pub(crate) min_confirmations: u8, pub(crate) witness: bool, + pub(crate) transport_endpoints: Vec, } #[derive(Deserialize, Serialize)] pub(crate) struct RgbInvoiceResponse { pub(crate) recipient_id: String, pub(crate) invoice: String, - pub(crate) expiration_timestamp: Option, + pub(crate) expiration_timestamp: u64, pub(crate) batch_transfer_idx: i32, } @@ -1250,7 +1361,8 @@ pub(crate) struct SendRgbRequest { pub(crate) donation: bool, pub(crate) fee_rate: u64, pub(crate) min_confirmations: u8, - pub(crate) expiration_timestamp: Option, + #[serde(default = "default_expiration_timestamp")] + pub(crate) expiration_timestamp: u64, pub(crate) recipient_map: HashMap>, } @@ -1456,10 +1568,25 @@ pub(crate) enum TransferStatus { WaitingCounterparty, WaitingSafeHeight, WaitingConfirmations, + WaitingBroadcast, Settled, Failed, } +impl From for TransferStatus { + fn from(value: rgb_lib::TransferStatus) -> Self { + match value { + rgb_lib::TransferStatus::Initiated => TransferStatus::Initiated, + rgb_lib::TransferStatus::WaitingCounterparty => TransferStatus::WaitingCounterparty, + rgb_lib::TransferStatus::WaitingSafeHeight => TransferStatus::WaitingSafeHeight, + rgb_lib::TransferStatus::WaitingConfirmations => TransferStatus::WaitingConfirmations, + rgb_lib::TransferStatus::WaitingBroadcast => TransferStatus::WaitingBroadcast, + rgb_lib::TransferStatus::Settled => TransferStatus::Settled, + rgb_lib::TransferStatus::Failed => TransferStatus::Failed, + } + } +} + #[derive(Debug, Deserialize, Serialize)] pub(crate) struct TransferTransportEndpoint { pub(crate) endpoint: String, @@ -1475,14 +1602,8 @@ pub(crate) enum TransportType { #[derive(Deserialize, Serialize)] pub(crate) struct UnlockRequest { pub(crate) password: String, - #[serde(default)] - pub(crate) bitcoind_rpc_username: Option, - #[serde(default)] - pub(crate) bitcoind_rpc_password: Option, - #[serde(default)] - pub(crate) bitcoind_rpc_host: Option, - #[serde(default)] - pub(crate) bitcoind_rpc_port: Option, + pub(crate) ldk_chain_sync: LdkChainSync, + // both fall back to the `[chain]` config section when omitted pub(crate) indexer_url: Option, pub(crate) proxy_endpoint: Option, pub(crate) announce_addresses: Vec, @@ -1500,10 +1621,7 @@ pub(crate) struct VssClearFenceRequest { impl From for CoreUnlockRequest { fn from(value: UnlockRequest) -> Self { Self { - bitcoind_rpc_username: value.bitcoind_rpc_username, - bitcoind_rpc_password: value.bitcoind_rpc_password, - bitcoind_rpc_host: value.bitcoind_rpc_host, - bitcoind_rpc_port: value.bitcoind_rpc_port, + ldk_chain_sync: value.ldk_chain_sync, indexer_url: value.indexer_url, proxy_endpoint: value.proxy_endpoint, announce_addresses: value.announce_addresses, @@ -1528,6 +1646,8 @@ pub(crate) struct Utxo { pub(crate) outpoint: String, pub(crate) btc_amount: u64, pub(crate) colorable: bool, + pub(crate) exists: bool, + pub(crate) derivation_index: Option, } #[derive(Deserialize, Serialize)] @@ -1593,6 +1713,26 @@ impl AppState { } } +/// Marks the node as changing state for as long as it is alive. +/// +/// The flag has to be cleared on every exit path, including an unwind: shutdown waits for the +/// state change to complete, so a flag left set by a panic would hang the shutdown instead of +/// letting the node exit. +struct ChangingStateGuard(Arc); + +impl ChangingStateGuard { + fn new(app_state: Arc) -> Self { + app_state.update_changing_state(true); + Self(app_state) + } +} + +impl Drop for ChangingStateGuard { + fn drop(&mut self) { + self.0.update_changing_state(false); + } +} + pub(crate) async fn address( State(state): State>, ) -> Result, APIError> { @@ -2394,6 +2534,7 @@ pub(crate) async fn decode_rgb_invoice( network: invoice_data.network.into(), expiration_timestamp: invoice_data.expiration_timestamp, transport_endpoints: invoice_data.transport_endpoints, + unknown_query_params: invoice_data.unknown_query_params, })) } @@ -2535,6 +2676,38 @@ pub(crate) async fn get_channel_id( Ok(Json(GetChannelIdResponse { channel_id })) } +// Both fields index a filesystem path (`///…`); validate their shapes +// so a `..`/separator/absolute value cannot traverse out of the consignment dir. +fn validate_consignment_lookup(asset_id: &str, txid: &str) -> Result<(), APIError> { + ContractId::from_str(asset_id).map_err(|_| APIError::InvalidAssetID(asset_id.to_string()))?; + bitcoin::Txid::from_str(txid) + .map_err(|_| APIError::InvalidRequest(format!("invalid txid: {txid}")))?; + Ok(()) +} + +pub(crate) async fn get_consignment( + State(state): State>, + WithRejection(Json(payload), _): WithRejection, APIError>, +) -> Result, APIError> { + validate_consignment_lookup(&payload.asset_id, &payload.txid)?; + let file_path = state + .check_unlocked() + .await? + .clone() + .unwrap() + .rgb_get_send_consignment_path(&payload.asset_id, &payload.txid); + if !file_path.exists() { + return Err(APIError::ConsignmentNotFound); + } + + let mut buf_reader = BufReader::new(File::open(file_path).await?); + let mut file_bytes = Vec::new(); + buf_reader.read_to_end(&mut file_bytes).await?; + let bytes_hex = hex_str(&file_bytes); + + Ok(Json(GetConsignmentResponse { bytes_hex })) +} + pub(crate) async fn get_payment( State(state): State>, WithRejection(Json(payload), _): WithRejection, APIError>, @@ -2728,6 +2901,7 @@ pub(crate) async fn init( }; encrypt_and_save_mnemonic(payload.password, mnemonic.clone(), &state.db())?; + tracing::info!("Created a new wallet"); Ok(Json(InitResponse { mnemonic })) }) @@ -3475,16 +3649,13 @@ pub(crate) async fn list_transfers( let guard = state.check_unlocked().await?; let unlocked_state = guard.as_ref().unwrap(); - if payload.txid.is_none() && payload.asset_id.is_none() { + if payload.txid.is_none() && matches!(payload.asset_filter, AssetFilter::AnyOrNone) { return Err(APIError::InvalidRequest(s!( - "either asset_id or txid must be provided" + "either a narrowing asset_filter (Id or None) or a txid must be provided" ))); } - let filter = match payload.asset_id { - Some(asset_id) => rgb_lib::wallet::AssetFilter::Id(asset_id), - None => rgb_lib::wallet::AssetFilter::Any, - }; - let raw_transfers = unlocked_state.rgb_list_transfers(filter, payload.txid)?; + let raw_transfers = + unlocked_state.rgb_list_transfers(payload.asset_filter.into(), payload.txid)?; let mut transfers = vec![]; for transfer in raw_transfers { @@ -3492,16 +3663,7 @@ pub(crate) async fn list_transfers( idx: transfer.idx, created_at: transfer.created_at, updated_at: transfer.updated_at, - status: match transfer.status { - rgb_lib::TransferStatus::Initiated => TransferStatus::Initiated, - rgb_lib::TransferStatus::WaitingCounterparty => TransferStatus::WaitingCounterparty, - rgb_lib::TransferStatus::WaitingSafeHeight => TransferStatus::WaitingSafeHeight, - rgb_lib::TransferStatus::WaitingConfirmations => { - TransferStatus::WaitingConfirmations - } - rgb_lib::TransferStatus::Settled => TransferStatus::Settled, - rgb_lib::TransferStatus::Failed => TransferStatus::Failed, - }, + status: transfer.status.into(), requested_assignment: transfer.requested_assignment.map(|a| a.into()), assignments: transfer.assignments.into_iter().map(|a| a.into()).collect(), kind: match transfer.kind { @@ -3573,6 +3735,8 @@ pub(crate) async fn list_unspents( outpoint: unspent.utxo.outpoint.to_string(), btc_amount: unspent.utxo.btc_amount, colorable: unspent.utxo.colorable, + exists: unspent.utxo.exists, + derivation_index: unspent.utxo.derivation_index, }, rgb_allocations: unspent .rgb_allocations @@ -3654,7 +3818,7 @@ pub(crate) async fn ln_invoice( } None => None, }; - let description = parse_invoice_description( + let description = invoice_description_from_request( payload.description.as_deref(), payload.description_hash.as_deref(), )?; @@ -3720,16 +3884,16 @@ pub(crate) async fn lock( ) -> Result, APIError> { tracing::info!("Lock started"); no_cancel(async move { - match state.check_unlocked().await { + let _changing_state = match state.check_unlocked().await { Ok(unlocked_state) => { - state.update_changing_state(true); + let guard = ChangingStateGuard::new(state.clone()); drop(unlocked_state); + guard } Err(e) => { - state.update_changing_state(false); return Err(e); } - } + }; tracing::debug!("Stopping LDK..."); stop_ldk(state.clone()).await; @@ -3739,8 +3903,6 @@ pub(crate) async fn lock( state.update_ldk_background_services(None); - state.update_changing_state(false); - tracing::info!("Lock completed"); Ok(Json(EmptyResponse {})) }) @@ -4365,7 +4527,7 @@ pub(crate) async fn open_channel( }; // checks on balances here are not precise since they do not take fees into account - let consignment_endpoint = if let Some((contract_id, asset_amount)) = &colored_info { + let (rgb_asset, schema) = if let Some((contract_id, asset_amount)) = &colored_info { let balance = unlocked_state.rgb_get_btc_balance(true)?; if payload.capacity_sat > balance.colored.spendable { return Err(APIError::InsufficientFunds(payload.capacity_sat - balance.colored.spendable)); @@ -4374,13 +4536,6 @@ pub(crate) async fn open_channel( if *asset_amount > balance.spendable { return Err(APIError::InsufficientAssets); } - - Some(RgbTransport::from_str(&unlocked_state.proxy_endpoint).unwrap()) - } else { - None - }; - - let schema = if let Some((contract_id, asset_amount)) = &colored_info { let schema = unlocked_state .rgb_get_asset_metadata(*contract_id)? .asset_schema; @@ -4419,7 +4574,7 @@ pub(crate) async fn open_channel( true, fee_rate_sat_vb, min_channel_confirmations, - None, + get_current_timestamp() + DEFAULT_RGB_TRANSFER_EXPIRATION_SECS, true, // Channel-funding dry run: mirror the real funding tx's final locktime. Some(0), @@ -4428,13 +4583,24 @@ pub(crate) async fn open_channel( .await .unwrap()?; } - Some(schema) + #[cfg(not(test))] + let wire_push_asset_amount = payload.push_asset_amount; + #[cfg(test)] + let wire_push_asset_amount = if node_override_matches( + &FORCE_PUSH_ASSET_AMOUNT_ON_NODE, + unlocked_state.channel_manager.get_our_node_id(), + ) { + Some(*asset_amount + 1) + } else { + payload.push_asset_amount + }; + (Some((*contract_id, wire_push_asset_amount)), Some(schema)) } else { let balance = unlocked_state.rgb_get_btc_balance(true)?; if payload.capacity_sat > balance.vanilla.spendable { return Err(APIError::InsufficientFunds(payload.capacity_sat - balance.vanilla.spendable)); } - None + (None, None) }; // Persist RGB channel_info before create_channel so funding @@ -4466,6 +4632,8 @@ pub(crate) async fn open_channel( local_rgb_amount: *asset_amount - push_amount, remote_rgb_amount: push_amount, batch_transfer_idx: None, + // set when the acceptor's accept_channel says it already knows the asset + counterparty_knows_asset: false, }; unlocked_state .kv_store @@ -4487,8 +4655,7 @@ pub(crate) async fn open_channel( 0, temporary_channel_id, Some(config), - consignment_endpoint, - payload.push_asset_amount, + rgb_asset, is_virtual_open, ) .map_err(|e| { @@ -4580,24 +4747,136 @@ pub(crate) async fn post_asset_media( .await } +pub(crate) async fn provide_out_of_band_ack( + State(state): State>, + WithRejection(Json(payload), _): WithRejection, APIError>, +) -> Result, APIError> { + no_cancel(async move { + let guard = state.check_unlocked().await?; + let unlocked_state = guard.as_ref().unwrap(); + + let unlocked_state_copy = unlocked_state.clone(); + let operation = tokio::task::spawn_blocking(move || { + unlocked_state_copy.rgb_provide_out_of_band_ack(payload.recipient_id) + }) + .await + .unwrap()?; + + Ok(Json(ProvideOutOfBandAckResponse { + operation: operation.map(|o| o.into()), + })) + }) + .await +} + +pub(crate) async fn provide_out_of_band_consignment( + State(state): State>, + WithRejection(mut multipart, _): WithRejection, +) -> Result, APIError> { + no_cancel(async move { + let guard = state.check_unlocked().await?; + let unlocked_state = guard.as_ref().unwrap(); + + let mut consignment_bytes = None; + let mut media_files_bytes = Vec::new(); + while let Some(field) = multipart + .next_field() + .await + .map_err(|_| APIError::ConsignmentFileNotProvided)? + { + let field_name = field.name().map(|n| n.to_string()); + let field_bytes = field + .bytes() + .await + .map_err(|e| APIError::Unexpected(format!("Failed to read bytes: {e}")))?; + match field_name.as_deref() { + Some("media") => { + if field_bytes.is_empty() { + return Err(APIError::MediaFileEmpty); + } + media_files_bytes.push(field_bytes); + } + _ => { + if field_bytes.is_empty() { + return Err(APIError::ConsignmentFileEmpty); + } + consignment_bytes = Some(field_bytes); + } + } + } + let consignment_bytes = consignment_bytes.ok_or(APIError::ConsignmentFileNotProvided)?; + + // persist the received consignment and media to temp files and hand their paths to rgb-lib + let unlocked_state_copy = unlocked_state.clone(); + let ldk_data_dir = state.static_state.ldk_data_dir.clone(); + let refresh_result = tokio::task::spawn_blocking( + move || -> Result, APIError> { + let write_temp = |prefix: &str, bytes: &[u8]| -> Result<_, APIError> { + let mut file = tempfile::Builder::new() + .prefix(prefix) + .tempfile_in(&ldk_data_dir)?; + file.write_all(bytes)?; + file.flush()?; + Ok(file) + }; + + let consignment_file = write_temp("consignment_oob_", &consignment_bytes)?; + let consignment_path = consignment_file.path().to_string_lossy().to_string(); + + // the temp files must stay alive until rgb-lib has read them: a NamedTempFile + // deletes its file on drop, so hold the handles and derive the paths from them + let media_files = media_files_bytes + .iter() + .map(|bytes| write_temp("media_oob_", bytes)) + .collect::, _>>()?; + let media_file_paths = media_files + .iter() + .map(|f| f.path().to_string_lossy().to_string()) + .collect(); + + unlocked_state_copy + .rgb_provide_out_of_band_consignment(consignment_path, media_file_paths) + .map_err(|e| match e { + RgbLibError::InvalidFilePath { .. } => APIError::InvalidConsignment, + other => other.into(), + }) + }, + ) + .await + .unwrap()?; + + let transfers = refresh_result + .into_iter() + .map(|(idx, t)| (idx, t.into())) + .collect(); + + Ok(Json(ProvideOutOfBandConsignmentResponse { transfers })) + }) + .await +} + pub(crate) async fn refresh_transfers( State(state): State>, WithRejection(Json(payload), _): WithRejection, APIError>, -) -> Result, APIError> { +) -> Result, APIError> { no_cancel(async move { let guard = state.check_unlocked().await?; let unlocked_state = guard.as_ref().unwrap(); let unlocked_state_copy = unlocked_state.clone(); let filter = payload.filter.into_iter().map(|f| f.into()).collect(); - tokio::task::spawn_blocking(move || { + let refresh_result = tokio::task::spawn_blocking(move || { unlocked_state_copy.rgb_refresh(payload.asset_id, filter, payload.skip_sync) }) .await .unwrap()?; tracing::info!("Refresh complete"); - Ok(Json(EmptyResponse {})) + let transfers = refresh_result + .into_iter() + .map(|(idx, t)| (idx, t.into())) + .collect(); + Ok(Json(RefreshResponse { transfers })) }) .await } @@ -4616,13 +4895,23 @@ pub(crate) async fn restore( check_already_initialized(&state.db())?; - restore_backup( - Path::new(&payload.backup_path), - &payload.password, - &state.static_state.storage_dir_path, - )?; + let unpacked = unpack_backup(Path::new(&payload.backup_path), &payload.password)?; + + // Check the backup can be unlocked while the storage dir is still untouched: installing a + // backup whose mnemonic cannot be read would initialize the node with data it can never + // open, and both init and restore then refuse to run. + let staged_db = open_database_pool(unpacked.dir()) + .await + .map_err(|e| APIError::Unexpected(e.to_string()))?; + let staged_check = check_password_validity(&payload.password, &staged_db); + // drop, never close: the query above ran on the database runtime, so awaiting a close + // here would wait on a wakeup that runtime no longer delivers + drop(staged_db); + staged_check?; - // restore_backup overwrote the SQLite file under the pre-restore pool; + install_backup(&unpacked, &state.static_state.storage_dir_path)?; + + // install_backup overwrote the SQLite file under the pre-restore pool; // reopen so subsequent queries (including unlock) see the restored data. let new_pool = open_database_pool(&state.static_state.storage_dir_path) .await @@ -4669,7 +4958,7 @@ pub(crate) async fn rgb_invoice( payload.asset_id, assignment, payload.expiration_timestamp, - vec![unlocked_state.proxy_endpoint.clone()], + payload.transport_endpoints, payload.min_confirmations, )? } else { @@ -4677,7 +4966,7 @@ pub(crate) async fn rgb_invoice( payload.asset_id, assignment, payload.expiration_timestamp, - vec![unlocked_state.proxy_endpoint.clone()], + payload.transport_endpoints, payload.min_confirmations, )? }; @@ -5236,10 +5525,11 @@ pub(crate) async fn unlock( return Err(APIError::ExternalSignerRequiresAuthentication); } - match state.check_locked().await { + let _changing_state = match state.check_locked().await { Ok(unlocked_state) => { - state.update_changing_state(true); + let guard = ChangingStateGuard::new(state.clone()); drop(unlocked_state); + guard } Err(e) => { return Err(match e { @@ -5247,14 +5537,7 @@ pub(crate) async fn unlock( _ => e, }); } - } - - // Clear the changing-state flag on any exit — including a panic during - // startup — so a failed unlock can't wedge the node in ChangingState. - let _changing_state_guard = crate::utils::CallOnDrop::new({ - let state = state.clone(); - move || state.update_changing_state(false) - }); + }; let key_source = if external_configured { external_signer_key_source(&state).await? @@ -5398,14 +5681,43 @@ mod request_tests { use super::*; use crate::gossip::GossipSourceConfig; + const VALID_ASSET_ID: &str = "rgb:EIkAVQvq-WbAb5JG-CYxbUER-oqDNwne-ZNxBDID-p0cpf9U"; + const VALID_TXID: &str = "0000000000000000000000000000000000000000000000000000000000000000"; + + #[test] + fn consignment_lookup_accepts_valid_ids() { + assert!(validate_consignment_lookup(VALID_ASSET_ID, VALID_TXID).is_ok()); + } + + #[test] + fn consignment_lookup_rejects_traversal_asset_id() { + assert!(validate_consignment_lookup("../../../etc/passwd", VALID_TXID).is_err()); + } + + #[test] + fn consignment_lookup_rejects_traversal_txid() { + assert!(validate_consignment_lookup(VALID_ASSET_ID, "../../secret").is_err()); + } + + #[test] + fn consignment_lookup_rejects_separators() { + assert!(validate_consignment_lookup(VALID_ASSET_ID, "abc/def").is_err()); + assert!(validate_consignment_lookup("rgb:a/b", VALID_TXID).is_err()); + } + #[test] fn unlock_request_with_gossip_source_deserializes() { let json = r#"{ "password": "x", - "bitcoind_rpc_username": "u", - "bitcoind_rpc_password": "p", - "bitcoind_rpc_host": "127.0.0.1", - "bitcoind_rpc_port": 18443, + "ldk_chain_sync": { + "mode": "BlockSync", + "config": { + "bitcoind_rpc_username": "u", + "bitcoind_rpc_password": "p", + "bitcoind_rpc_host": "127.0.0.1", + "bitcoind_rpc_port": 18443 + } + }, "announce_addresses": [], "gossip_source": { "type": "rgs", "server_url": "https://example.invalid" } }"#; @@ -5420,10 +5732,15 @@ mod request_tests { fn unlock_request_without_gossip_source_defaults_to_none() { let json = r#"{ "password": "x", - "bitcoind_rpc_username": "u", - "bitcoind_rpc_password": "p", - "bitcoind_rpc_host": "127.0.0.1", - "bitcoind_rpc_port": 18443, + "ldk_chain_sync": { + "mode": "BlockSync", + "config": { + "bitcoind_rpc_username": "u", + "bitcoind_rpc_password": "p", + "bitcoind_rpc_host": "127.0.0.1", + "bitcoind_rpc_port": 18443 + } + }, "announce_addresses": [] }"#; let req: UnlockRequest = serde_json::from_str(json).unwrap(); @@ -5522,29 +5839,24 @@ mod request_tests { } } -/// External-signer mode holds no mnemonic, so `/unlock` never checks a password on that path — the -/// biscuit token is the only credential guarding it. These tests pin down that both HTTP entry points -/// that can leave a node running in external-signer mode refuse to do so when authentication is -/// disabled, rather than silently leaving `/unlock` passwordless. -#[cfg(all(test, feature = "remote-signer"))] -mod external_signer_auth_tests { +#[cfg(test)] +mod state_mocks { use super::*; use crate::disk::FilesystemLogger; use crate::utils::{open_database_pool, StaticState}; use rln_migration::{Migrator, MigratorTrait}; use std::collections::HashSet; - use std::marker::PhantomData; use std::sync::{Mutex, RwLock}; use tokio::sync::Mutex as TokioMutex; use tokio_util::sync::CancellationToken; - async fn mock_state_with_auth( + pub(super) async fn mock_state_with_auth( root_public_key: Option, ) -> Arc { mock_state(root_public_key, None).await } - async fn mock_state( + pub(super) async fn mock_state( root_public_key: Option, remote_signer_listen_addr: Option, ) -> Arc { @@ -5564,6 +5876,10 @@ mod external_signer_auth_tests { ldk_data_dir: path.join(".ldk"), logger: Arc::new(FilesystemLogger::new(path)), max_media_upload_size_mb: 1, + max_aggregated_media_size_per_channel_mb: + crate::rgb_file_transfer::MAX_MEDIA_MB_PER_CHANNEL, + max_pending_consignments: crate::rgb_file_transfer::MAX_PENDING_CONSIGNMENTS, + max_media_files_per_channel: crate::rgb_file_transfer::MAX_MEDIA_FILES_PER_CHANNEL, enable_virtual_channels_v0: false, virtual_peer_pubkeys: vec![], database: RwLock::new(Arc::new(database)), @@ -5583,6 +5899,50 @@ mod external_signer_auth_tests { revoked_tokens: Arc::new(Mutex::new(HashSet::new())), }) } +} + +#[cfg(test)] +mod changing_state_guard_tests { + use super::state_mocks::mock_state_with_auth; + use super::ChangingStateGuard; + + #[tokio::test] + async fn sets_and_clears_the_flag() { + let state = mock_state_with_auth(None).await; + assert!(!*state.get_changing_state()); + { + let _guard = ChangingStateGuard::new(state.clone()); + assert!(*state.get_changing_state()); + } + assert!(!*state.get_changing_state()); + } + + // A flag left set by a panicking lock/unlock makes `shutdown_signal` wait forever. + #[tokio::test] + async fn clears_the_flag_on_panic_unwind() { + let state = mock_state_with_auth(None).await; + let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _guard = ChangingStateGuard::new(state.clone()); + assert!(*state.get_changing_state()); + panic!("boom"); + })); + assert!(panicked.is_err(), "closure should have panicked"); + assert!( + !*state.get_changing_state(), + "the flag must be cleared while unwinding a panic" + ); + } +} + +/// External-signer mode holds no mnemonic, so `/unlock` never checks a password on that path — the +/// biscuit token is the only credential guarding it. These tests pin down that both HTTP entry points +/// that can leave a node running in external-signer mode refuse to do so when authentication is +/// disabled, rather than silently leaving `/unlock` passwordless. +#[cfg(all(test, feature = "remote-signer"))] +mod external_signer_auth_tests { + use super::state_mocks::{mock_state, mock_state_with_auth}; + use super::*; + use std::marker::PhantomData; /// A biscuit keypair for tests that need authentication *enabled* (root_public_key = Some). fn test_root_public_key() -> biscuit_auth::PublicKey { @@ -5652,6 +6012,15 @@ mod external_signer_auth_tests { let payload: UnlockRequest = serde_json::from_str( r#"{ "password": "whatever", + "ldk_chain_sync": { + "mode": "BlockSync", + "config": { + "bitcoind_rpc_username": "u", + "bitcoind_rpc_password": "p", + "bitcoind_rpc_host": "127.0.0.1", + "bitcoind_rpc_port": 18443 + } + }, "announce_addresses": [] }"#, ) diff --git a/src/sdk/mod.rs b/src/sdk/mod.rs index 14f863d5..ff342707 100644 --- a/src/sdk/mod.rs +++ b/src/sdk/mod.rs @@ -23,6 +23,7 @@ use crate::ldk::{ #[cfg(feature = "vss")] use crate::ldk::{derive_vss_identity, derive_vss_identity_from_key_source}; use crate::rgb::{check_rgb_proxy_endpoint, get_rgb_channel_info_optional}; +use crate::routes::DEFAULT_RGB_TRANSFER_EXPIRATION_SECS; use crate::signer::{ read_key_source_file, validate_bootstrap_payload, validate_key_source_matches_bootstrap, write_key_source_file, BootstrapData, KeySourceFile, SUPPORTED_SIGNER_API_LEVEL, @@ -32,8 +33,8 @@ use crate::utils::{ check_already_initialized, check_channel_id, check_password_strength, check_password_validity, connect_peer_if_necessary, description_from_invoice, description_hash_from_invoice, encrypt_and_save_mnemonic, get_current_timestamp, get_max_local_rgb_amount, get_route, hex_str, - hex_str_to_compressed_pubkey, hex_str_to_vec, is_external_signer_mode_configured, - new_jsonrpc_request_id, parse_invoice_description, parse_peer_info, + hex_str_to_compressed_pubkey, hex_str_to_vec, invoice_description_from_request, + is_external_signer_mode_configured, new_jsonrpc_request_id, parse_peer_info, validate_and_parse_payment_hash, validate_and_parse_payment_preimage, AppState, UserOnionMessageContents, }; @@ -81,7 +82,7 @@ use rgb_lib::wallet::{ use rgb_lib::{ bdk_wallet::keys::bip39::Mnemonic, keys::{generate_keys, WitnessVersion}, - ContractId, RgbTransport, + ContractId, }; use std::collections::HashMap; use std::net::ToSocketAddrs; @@ -261,6 +262,8 @@ pub(crate) struct DecodeLnInvoiceData { pub(crate) timestamp: u64, pub(crate) asset_id: Option, pub(crate) asset_amount: Option, + pub(crate) description: Option, + pub(crate) description_hash: Option, pub(crate) payment_hash: String, pub(crate) payment_secret: String, pub(crate) payee_pubkey: Option, @@ -363,10 +366,7 @@ pub(crate) struct InitData { pub(crate) struct UnlockRequest { pub(crate) password: String, - pub(crate) bitcoind_rpc_username: Option, - pub(crate) bitcoind_rpc_password: Option, - pub(crate) bitcoind_rpc_host: Option, - pub(crate) bitcoind_rpc_port: Option, + pub(crate) ldk_chain_sync: crate::core_types::LdkChainSync, pub(crate) indexer_url: Option, pub(crate) proxy_endpoint: Option, pub(crate) announce_addresses: Vec, @@ -447,6 +447,20 @@ pub(crate) struct FailTransfersData { pub(crate) transfers_changed: bool, } +pub(crate) struct RefreshFailureData { + pub(crate) name: String, + pub(crate) message: String, +} + +pub(crate) struct RefreshedTransferData { + pub(crate) updated_status: Option, + pub(crate) failure: Option, +} + +pub(crate) struct RefreshTransfersData { + pub(crate) transfers: HashMap, +} + pub(crate) struct CreateUtxosRequestData { pub(crate) up_to: bool, pub(crate) num: Option, @@ -677,6 +691,7 @@ pub(crate) struct UtxoData { pub(crate) outpoint: String, pub(crate) btc_amount: u64, pub(crate) colorable: bool, + pub(crate) exists: bool, } pub(crate) struct UnspentData { @@ -772,6 +787,7 @@ pub(crate) enum TransferStatus { WaitingCounterparty, WaitingSafeHeight, WaitingConfirmations, + WaitingBroadcast, Settled, Failed, } @@ -1751,7 +1767,7 @@ pub(crate) async fn send_rgb( donation, fee_rate, min_confirmations, - None, + get_current_timestamp() + DEFAULT_RGB_TRANSFER_EXPIRATION_SECS, false, None, ) @@ -1775,7 +1791,13 @@ pub(crate) async fn send_rgb( } else { let unlocked_state_copy = unlocked_state.clone(); tokio::task::spawn_blocking(move || { - unlocked_state_copy.rgb_send(recipient_map, donation, fee_rate, min_confirmations, None) + unlocked_state_copy.rgb_send( + recipient_map, + donation, + fee_rate, + min_confirmations, + get_current_timestamp() + DEFAULT_RGB_TRANSFER_EXPIRATION_SECS, + ) }) .await .unwrap()? @@ -2008,10 +2030,7 @@ pub(crate) async fn unlock(state: Arc, request: UnlockRequest) -> Resu .gossip_rgs_server_url .map(|server_url| crate::gossip::GossipSourceConfig::RapidGossipSync { server_url }); let unlock_request = crate::core_types::UnlockRequest { - bitcoind_rpc_username: request.bitcoind_rpc_username, - bitcoind_rpc_password: request.bitcoind_rpc_password, - bitcoind_rpc_host: request.bitcoind_rpc_host, - bitcoind_rpc_port: request.bitcoind_rpc_port, + ldk_chain_sync: request.ldk_chain_sync, indexer_url: request.indexer_url, proxy_endpoint: request.proxy_endpoint, announce_addresses: request.announce_addresses, @@ -2108,10 +2127,7 @@ pub(crate) async fn unlock_with_attached_external_signer( .gossip_rgs_server_url .map(|server_url| crate::gossip::GossipSourceConfig::RapidGossipSync { server_url }); let unlock_request = crate::core_types::UnlockRequest { - bitcoind_rpc_username: request.bitcoind_rpc_username, - bitcoind_rpc_password: request.bitcoind_rpc_password, - bitcoind_rpc_host: request.bitcoind_rpc_host, - bitcoind_rpc_port: request.bitcoind_rpc_port, + ldk_chain_sync: request.ldk_chain_sync, indexer_url: request.indexer_url, proxy_endpoint: request.proxy_endpoint, announce_addresses: request.announce_addresses, @@ -2734,9 +2750,11 @@ pub(crate) async fn rgb_invoice( None => RgbLibAssignment::Any, }; - let expiration_timestamp = request - .duration_seconds - .map(|duration| get_current_timestamp() + u64::from(duration)); + let expiration_timestamp = get_current_timestamp() + + request + .duration_seconds + .map(u64::from) + .unwrap_or(DEFAULT_RGB_TRANSFER_EXPIRATION_SECS); let receive_data = if request.witness { unlocked_state.rgb_witness_receive( request.asset_id, @@ -2758,7 +2776,7 @@ pub(crate) async fn rgb_invoice( Ok(RgbInvoiceData { recipient_id: receive_data.recipient_id, invoice: receive_data.invoice, - expiration_timestamp: receive_data.expiration_timestamp.map(|t| t as i64), + expiration_timestamp: Some(receive_data.expiration_timestamp as i64), batch_transfer_idx: receive_data.batch_transfer_idx, }) } @@ -2958,13 +2976,13 @@ pub(crate) async fn open_channel( ..Default::default() }; - let consignment_endpoint = if let Some((contract_id, asset_amount)) = &colored_info { + let rgb_asset = if let Some((contract_id, asset_amount)) = &colored_info { let balance = unlocked_state.rgb_get_asset_balance(*contract_id)?; let spendable_rgb_amount = balance.spendable; if *asset_amount > spendable_rgb_amount { return Err(APIError::InsufficientAssets); } - Some(RgbTransport::from_str(&unlocked_state.proxy_endpoint).unwrap()) + Some((*contract_id, request.push_asset_amount)) } else { None }; @@ -3005,7 +3023,7 @@ pub(crate) async fn open_channel( true, fee_rate_sat_vb, min_channel_confirmations, - None, + get_current_timestamp() + DEFAULT_RGB_TRANSFER_EXPIRATION_SECS, true, Some(0), ) @@ -3044,6 +3062,7 @@ pub(crate) async fn open_channel( local_rgb_amount: *asset_amount - push_amount, remote_rgb_amount: push_amount, batch_transfer_idx: None, + counterparty_knows_asset: false, }; unlocked_state .kv_store @@ -3065,8 +3084,7 @@ pub(crate) async fn open_channel( 0, temporary_channel_id, Some(config), - consignment_endpoint, - request.push_asset_amount, + rgb_asset, is_virtual_open, ) .map_err(|e| { @@ -3372,17 +3390,34 @@ pub(crate) async fn fail_transfers( pub(crate) async fn refresh_transfers( state: Arc, request: RefreshTransfersRequestData, -) -> Result<(), APIError> { +) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); let unlocked_state_copy = unlocked_state.clone(); - tokio::task::spawn_blocking(move || { + let refresh_result = tokio::task::spawn_blocking(move || { unlocked_state_copy.rgb_refresh(None, vec![], request.skip_sync) }) .await .unwrap()?; - Ok(()) + let transfers = refresh_result + .into_iter() + .map(|(idx, transfer)| { + ( + idx, + RefreshedTransferData { + updated_status: transfer + .updated_status + .map(|s| format!("{:?}", to_transfer_status(s))), + failure: transfer.failure.map(|e| RefreshFailureData { + name: crate::error::error_name(&e), + message: e.to_string(), + }), + }, + ) + }) + .collect(); + Ok(RefreshTransfersData { transfers }) } pub(crate) async fn maker_execute( @@ -3750,6 +3785,8 @@ pub(crate) async fn decode_ln_invoice( timestamp: invoice.duration_since_epoch().as_secs(), asset_id: invoice.rgb_contract_id().map(|c| c.to_string()), asset_amount: invoice.rgb_amount(), + description: description_from_invoice(&invoice), + description_hash: description_hash_from_invoice(&invoice).map(|h| hex_str(&h)), payment_hash: hex_str(&invoice.payment_hash().to_byte_array()), payment_secret: hex_str(&invoice.payment_secret().0), payee_pubkey: Some(invoice.get_payee_pub_key().to_string()), @@ -3866,7 +3903,7 @@ pub(crate) async fn create_ln_invoice( None => None, }; let description = - parse_invoice_description(description.as_deref(), description_hash.as_deref())?; + invoice_description_from_request(description.as_deref(), description_hash.as_deref())?; let invoice_params = Bolt11InvoiceParameters { amount_msats: amt_msat, @@ -4313,19 +4350,24 @@ fn to_transaction_data(tx: rgb_lib::wallet::Transaction) -> TransactionData { } } +fn to_transfer_status(status: rgb_lib::TransferStatus) -> TransferStatus { + match status { + rgb_lib::TransferStatus::Initiated => TransferStatus::Initiated, + rgb_lib::TransferStatus::WaitingCounterparty => TransferStatus::WaitingCounterparty, + rgb_lib::TransferStatus::WaitingSafeHeight => TransferStatus::WaitingSafeHeight, + rgb_lib::TransferStatus::WaitingConfirmations => TransferStatus::WaitingConfirmations, + rgb_lib::TransferStatus::WaitingBroadcast => TransferStatus::WaitingBroadcast, + rgb_lib::TransferStatus::Settled => TransferStatus::Settled, + rgb_lib::TransferStatus::Failed => TransferStatus::Failed, + } +} + fn to_transfer_data(transfer: rgb_lib::wallet::Transfer) -> TransferData { TransferData { idx: transfer.idx, created_at: transfer.created_at, updated_at: transfer.updated_at, - status: match transfer.status { - rgb_lib::TransferStatus::Initiated => TransferStatus::Initiated, - rgb_lib::TransferStatus::WaitingCounterparty => TransferStatus::WaitingCounterparty, - rgb_lib::TransferStatus::WaitingSafeHeight => TransferStatus::WaitingSafeHeight, - rgb_lib::TransferStatus::WaitingConfirmations => TransferStatus::WaitingConfirmations, - rgb_lib::TransferStatus::Settled => TransferStatus::Settled, - rgb_lib::TransferStatus::Failed => TransferStatus::Failed, - }, + status: to_transfer_status(transfer.status), requested_assignment: transfer.requested_assignment, assignments: transfer.assignments, kind: match transfer.kind { @@ -4388,7 +4430,7 @@ pub(crate) async fn list_transfers( } let filter = match asset_id { Some(asset_id) => rgb_lib::wallet::AssetFilter::Id(asset_id), - None => rgb_lib::wallet::AssetFilter::Any, + None => rgb_lib::wallet::AssetFilter::AnyOrNone, }; Ok(unlocked_state .rgb_list_transfers(filter, txid)? @@ -4411,6 +4453,7 @@ pub(crate) async fn list_unspents( outpoint: unspent.utxo.outpoint.to_string(), btc_amount: unspent.utxo.btc_amount, colorable: unspent.utxo.colorable, + exists: unspent.utxo.exists, }, rgb_allocations: unspent .rgb_allocations @@ -4535,6 +4578,10 @@ mod tests { ldk_data_dir: storage_dir.join(".ldk"), logger: Arc::new(FilesystemLogger::new(storage_dir)), max_media_upload_size_mb: 1, + max_aggregated_media_size_per_channel_mb: + crate::rgb_file_transfer::MAX_MEDIA_MB_PER_CHANNEL, + max_pending_consignments: crate::rgb_file_transfer::MAX_PENDING_CONSIGNMENTS, + max_media_files_per_channel: crate::rgb_file_transfer::MAX_MEDIA_FILES_PER_CHANNEL, enable_virtual_channels_v0: false, virtual_peer_pubkeys: vec![], lsp_base_url: None, @@ -4569,13 +4616,24 @@ mod tests { } } + fn sample_ldk_chain_sync() -> crate::core_types::LdkChainSync { + #[cfg(feature = "block-sync")] + return crate::core_types::LdkChainSync::BlockSync { + bitcoind_rpc_username: "user".to_string(), + bitcoind_rpc_password: "pass".to_string(), + bitcoind_rpc_host: "127.0.0.1".to_string(), + bitcoind_rpc_port: 18443, + }; + #[cfg(not(feature = "block-sync"))] + return crate::core_types::LdkChainSync::TransactionSync { + indexer_url: "127.0.0.1:50001".to_string(), + }; + } + fn sample_unlock_request() -> UnlockRequest { UnlockRequest { password: "unused-in-external-mode".to_string(), - bitcoind_rpc_username: Some("user".to_string()), - bitcoind_rpc_password: Some("pass".to_string()), - bitcoind_rpc_host: Some("127.0.0.1".to_string()), - bitcoind_rpc_port: Some(18443), + ldk_chain_sync: sample_ldk_chain_sync(), indexer_url: Some("127.0.0.1:50001".to_string()), proxy_endpoint: Some("rpc://127.0.0.1:3000/json-rpc".to_string()), announce_addresses: vec![], diff --git a/src/synced_kv_store.rs b/src/synced_kv_store.rs index 86d3f623..6a3089dd 100644 --- a/src/synced_kv_store.rs +++ b/src/synced_kv_store.rs @@ -244,7 +244,13 @@ impl SyncedKvStore { .store(true, std::sync::atomic::Ordering::Release); #[cfg(test)] self.run_before_stop_gate_hook(); - drop(self.drain_gate.lock().unwrap()); + // A write path can panic (e.g. a broken VSS fence) while holding the gate; teardown must + // still reach the fence release instead of panicking on the poison. + drop( + self.drain_gate + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()), + ); } #[cfg(feature = "vss")] @@ -729,3 +735,32 @@ impl KVStoreSync for SyncedKvStore { self.local.list(primary_namespace, secondary_namespace) } } + +#[cfg(all(test, feature = "vss"))] +mod stop_tests { + use super::*; + + // A write path can panic (broken VSS fence) while holding the drain gate; `stop()` must still + // return so teardown reaches the fence release instead of dying on the poison. + #[test] + fn stop_tolerates_a_poisoned_drain_gate() { + let connection = crate::runtime::block_on(sea_orm::Database::connect("sqlite::memory:")) + .expect("in-memory database"); + let store = Arc::new(SyncedKvStore::local_only(Arc::new( + SeaOrmKvStore::from_connection(Arc::new(connection)), + ))); + + let poisoner = Arc::clone(&store); + let previous_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); + let _ = std::thread::spawn(move || { + let _gate = poisoner.drain_gate.lock().unwrap(); + panic!("poison the gate"); + }) + .join(); + std::panic::set_hook(previous_hook); + assert!(store.drain_gate.is_poisoned()); + + store.stop(); + } +} diff --git a/src/test/auth_db_persistence.rs b/src/test/auth_db_persistence.rs index 53cbcaf7..535a698e 100644 --- a/src/test/auth_db_persistence.rs +++ b/src/test/auth_db_persistence.rs @@ -25,6 +25,10 @@ fn build_state(storage_dir_path: PathBuf, database: DatabaseConnection) -> AppSt ldk_data_dir: storage_dir_path.join(".ldk"), logger: Arc::new(FilesystemLogger::new(storage_dir_path)), max_media_upload_size_mb: 1, + max_aggregated_media_size_per_channel_mb: + crate::rgb_file_transfer::MAX_MEDIA_MB_PER_CHANNEL, + max_pending_consignments: crate::rgb_file_transfer::MAX_PENDING_CONSIGNMENTS, + max_media_files_per_channel: crate::rgb_file_transfer::MAX_MEDIA_FILES_PER_CHANNEL, enable_virtual_channels_v0: false, virtual_peer_pubkeys: vec![], database: RwLock::new(Arc::new(database)), diff --git a/src/test/chain_backend_bitcoind_dispatch.rs b/src/test/chain_backend_bitcoind_dispatch.rs deleted file mode 100644 index 0b4f2ddc..00000000 --- a/src/test/chain_backend_bitcoind_dispatch.rs +++ /dev/null @@ -1,22 +0,0 @@ -use std::sync::Arc; - -use lightning::chain::chaininterface::{BroadcasterInterface, ConfirmationTarget, FeeEstimator}; - -use crate::chain_backend::ChainBackend; - -fn _assert_fee_estimator(_: &T) {} -fn _assert_broadcaster(_: &T) {} - -#[test] -fn chain_backend_implements_required_traits() { - fn _accepts(b: Arc) { - _assert_fee_estimator(&*b); - _assert_broadcaster(&*b); - let _ = b.get_est_sat_per_1000_weight(ConfirmationTarget::AnchorChannelFee); - } -} - -#[test] -fn chain_backend_esplora_variant_exists() { - fn _accept(_: crate::chain_backend::ChainBackend) {} -} diff --git a/src/test/chain_backend_dispatch.rs b/src/test/chain_backend_dispatch.rs new file mode 100644 index 00000000..7ada51bf --- /dev/null +++ b/src/test/chain_backend_dispatch.rs @@ -0,0 +1,36 @@ +use std::sync::Arc; + +use lightning::chain::chaininterface::{BroadcasterInterface, ConfirmationTarget, FeeEstimator}; + +use crate::ldk_chain_backend::{DynBroadcaster, DynFeeEstimator}; + +fn _assert_fee_estimator(_: &T) {} +fn _assert_broadcaster(_: &T) {} + +// the LDK type aliases are built on trait objects, so every backend must be usable through them +#[test] +fn dyn_chain_backend_implements_required_traits() { + fn _accepts(fee_estimator: Arc, broadcaster: Arc) { + _assert_fee_estimator(&*fee_estimator); + _assert_broadcaster(&*broadcaster); + let _ = fee_estimator.get_est_sat_per_1000_weight(ConfirmationTarget::AnchorChannelFee); + } +} + +#[cfg(feature = "block-sync")] +#[test] +fn block_sync_backend_coerces_to_dyn() { + fn _accepts(client: Arc) { + let _: Arc = client.clone(); + let _: Arc = client; + } +} + +#[cfg(feature = "transaction-sync")] +#[test] +fn transaction_sync_backend_coerces_to_dyn() { + fn _accepts(client: Arc) { + let _: Arc = client.clone(); + let _: Arc = client; + } +} diff --git a/src/test/close_force_pending_htlc.rs b/src/test/close_force_pending_htlc.rs new file mode 100644 index 00000000..1a8a4865 --- /dev/null +++ b/src/test/close_force_pending_htlc.rs @@ -0,0 +1,142 @@ +use super::*; + +const TEST_DIR_BASE: &str = "tmp/close_force_pending_htlc/"; + +/// Force close with a pending RGB HTLC: both nodes must still recover their +/// BTC and node1 its assets. The HTLC is held pending by construction: node2 +/// is set to hold incoming payments (HOLD_PAYMENT_CLAIMABLE_ON_NODE), so the +/// commitment provably carries the asset HTLC when node2 force-closes. +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn close_force_pending_htlc() { + initialize(); + + let test_dir_node1 = format!("{TEST_DIR_BASE}node1"); + let test_dir_node2 = format!("{TEST_DIR_BASE}node2"); + let (node1_addr, _) = start_node(&test_dir_node1, NODE1_PEER_PORT, false).await; + let (node2_addr, _) = start_node(&test_dir_node2, NODE2_PEER_PORT, false).await; + + fund_and_create_utxos(node1_addr, None).await; + fund_and_create_utxos(node2_addr, None).await; + + let asset_id = issue_asset_nia(node1_addr).await.asset_id; + + let node1_pubkey = node_info(node1_addr).await.pubkey; + let node2_pubkey = node_info(node2_addr).await.pubkey; + + let channel = open_channel( + node1_addr, + &node2_pubkey, + Some(NODE2_PEER_PORT), + Some(100000), + Some(50000000), + Some(600), + Some(&asset_id), + ) + .await; + + // Baselines before the close: sweeps can confirm while close_channel is + // still mining the 144 maturity blocks. + let node1_spendable_before = spendable_sats(node1_addr).await; + let node2_spendable_before = spendable_sats(node2_addr).await; + + // node2 holds the incoming payment: the 10-asset HTLC stays pending. + HELD_PAYMENT_CLAIMABLE_COUNT.store(0, Ordering::SeqCst); + let _hold_guard = NodeOverrideGuard::set(&HOLD_PAYMENT_CLAIMABLE_ON_NODE, &node2_pubkey); + + let LNInvoiceResponse { invoice } = ln_invoice( + node2_addr, + Some(HTLC_MIN_MSAT), + Some(&asset_id), + Some(10), + 900, + ) + .await; + send_payment_raw(node1_addr, invoice).await; + let t_0 = OffsetDateTime::now_utc(); + while HELD_PAYMENT_CLAIMABLE_COUNT.load(Ordering::SeqCst) == 0 { + if (OffsetDateTime::now_utc() - t_0).as_seconds_f32() > 40.0 { + panic!("node2 did not receive the payment to hold"); + } + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + + // Restart the payer with the colored HTLC still pending: its channel must + // round-trip through persistence with the HTLC in flight. + shutdown(&[node1_addr]).await; + let (node1_addr, _) = start_node(&test_dir_node1, NODE1_PEER_PORT, true).await; + let t_0 = OffsetDateTime::now_utc(); + loop { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let channels = list_channels(node1_addr).await; + if channels + .iter() + .any(|c| c.channel_id == channel.channel_id && c.ready) + { + break; + } + if (OffsetDateTime::now_utc() - t_0).as_seconds_f32() > 30.0 { + panic!("channel not re-established after restart"); + } + } + + // Restart the payee as well: its side holds the same HTLC as pending + // inbound, covering the inbound deserialization path. + shutdown(&[node2_addr]).await; + let (node2_addr, _) = start_node(&test_dir_node2, NODE2_PEER_PORT, true).await; + let t_0 = OffsetDateTime::now_utc(); + loop { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let channels = list_channels(node2_addr).await; + if channels + .iter() + .any(|c| c.channel_id == channel.channel_id && c.ready) + { + break; + } + if (OffsetDateTime::now_utc() - t_0).as_seconds_f32() > 30.0 { + panic!("channel not re-established after payee restart"); + } + } + + // Force close from node2 with the HTLC held: its commitment carries it. + close_channel(node2_addr, &channel.channel_id, &node1_pubkey, true).await; + let commitment_txid = wait_for_funding_spend_txid(&test_dir_node1, &channel.channel_id).await; + assert!( + tx_output_sats(&commitment_txid).contains(&(HTLC_MIN_MSAT / 1000)), + "confirmed commitment must carry the pending HTLC output" + ); + + let mut node1_btc_ok = false; + let mut node2_btc_ok = false; + let mut node1_assets_ok = false; + for _ in 0..60 { + mine_n_blocks(false, 10); + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + refresh_transfers_tolerant(node1_addr).await; + refresh_transfers_tolerant(node2_addr).await; + if !node1_btc_ok && spendable_sats(node1_addr).await > node1_spendable_before + 30_000 { + node1_btc_ok = true; + } + if !node2_btc_ok && spendable_sats(node2_addr).await > node2_spendable_before + 30_000 { + node2_btc_ok = true; + } + let node1_assets = asset_balance_spendable(node1_addr, &asset_id).await; + if node1_assets == 990 { + node1_assets_ok = true; + } + println!( + "recovery: node1_btc_ok={node1_btc_ok} node2_btc_ok={node2_btc_ok} node1_assets={node1_assets}" + ); + if node1_btc_ok && node2_btc_ok && node1_assets_ok { + break; + } + } + assert!( + node1_btc_ok && node2_btc_ok && node1_assets_ok, + "recovery failed: node1_btc_ok={node1_btc_ok} node2_btc_ok={node2_btc_ok} node1_assets_ok={node1_assets_ok}" + ); + // The payment was never claimed: node2 must have no assets. + assert_eq!(asset_balance_spendable(node2_addr, &asset_id).await, 0); +} diff --git a/src/test/colored_channel_electrum.rs b/src/test/colored_channel_electrum.rs index 732b1288..e528836a 100644 --- a/src/test/colored_channel_electrum.rs +++ b/src/test/colored_channel_electrum.rs @@ -11,10 +11,9 @@ async fn start_node_electrum_only(node_test_dir: &str, node_peer_port: u16) -> S let payload = UnlockRequest { password, - bitcoind_rpc_username: None, - bitcoind_rpc_password: None, - bitcoind_rpc_host: None, - bitcoind_rpc_port: None, + ldk_chain_sync: LdkChainSync::TransactionSync { + indexer_url: ELECTRUM_URL_REGTEST.to_string(), + }, indexer_url: Some(ELECTRUM_URL_REGTEST.to_string()), proxy_endpoint: Some(PROXY_ENDPOINT_LOCAL.to_string()), announce_addresses: vec![], diff --git a/src/test/concurrent_btc_payments.rs b/src/test/concurrent_btc_payments.rs index 907b20d9..9ed8d9a2 100644 --- a/src/test/concurrent_btc_payments.rs +++ b/src/test/concurrent_btc_payments.rs @@ -69,6 +69,9 @@ async fn concurrent_btc_payments() { let LNInvoiceResponse { invoice: invoice_2 } = ln_invoice(node1_addr, Some(amt_msat_2), None, None, 900).await; + // node1 defers claiming, so the payments cannot settle before they are checked below + let defer_guard = defer_payment_claimable(&node1_pubkey); + // send payments let payload_1 = SendPaymentRequest { invoice: invoice_1.clone(), @@ -102,10 +105,14 @@ async fn concurrent_btc_payments() { .unwrap(); // check there are 2 concurrent pending payments + wait_for_deferred_payment().await; let payments_1 = list_payments(node1_addr).await; assert_eq!(payments_1.len(), 2); assert!(payments_1.iter().all(|p| p.status == HTLCStatus::Pending)); + // let node1 claim, so the payments can settle + drop(defer_guard); + // wait for payments to have succeeded let t_0 = OffsetDateTime::now_utc(); loop { diff --git a/src/test/electrum_opret_confirm.rs b/src/test/electrum_opret_confirm.rs new file mode 100644 index 00000000..48c28fea --- /dev/null +++ b/src/test/electrum_opret_confirm.rs @@ -0,0 +1,203 @@ +use super::*; + +// Regression test for a `lightning-transaction-sync` bug: its electrum client ignores the +// `script_pubkey` passed to `Filter::register_tx` and instead derives the script whose history it +// queries from the transaction's *first* output. Indexers do not track provably-unspendable +// outputs, so a transaction whose first output is an OP_RETURN -- which is what an RGB `opret` +// commitment in a channel funding transaction looks like -- is never reported as confirmed, and +// the channel never reaches `channel_ready`. + +#[derive(Default)] +struct ConfirmSpy { + confirmed: Mutex>, +} + +impl Confirm for ConfirmSpy { + fn transactions_confirmed(&self, _header: &Header, txdata: &TransactionData, _height: u32) { + let mut confirmed = self.confirmed.lock().unwrap(); + for (_, tx) in txdata { + confirmed.push(tx.compute_txid()); + } + } + fn transaction_unconfirmed(&self, _txid: &Txid) {} + fn best_block_updated(&self, _header: &Header, _height: u32) {} + fn get_relevant_txids(&self) -> Vec<(Txid, u32, Option)> { + vec![] + } +} + +// broadcasts and confirms a transaction whose first output is an OP_RETURN, returning its txid and +// the scriptPubKey of that first output +fn send_opret_tx() -> (Txid, ScriptBuf) { + let address = bitcoind(&["-rpcwallet=miner", "getnewaddress"]); + let outputs = format!( + r#"[{{"data":"{}"}},{{"{address}":0.001}}]"#, + "de".repeat(32) + ); + let funded = bitcoind(&[ + "-rpcwallet=miner", + "walletcreatefundedpsbt", + "[]", + &outputs, + "0", + // bitcoind inserts the change output at a random position by default, which would leave + // the OP_RETURN somewhere other than the first output: pin change last instead + r#"{"fee_rate":5,"changePosition":2}"#, + ]); + let psbt = serde_json::from_str::(&funded).unwrap()["psbt"] + .as_str() + .unwrap() + .to_string(); + let processed = bitcoind(&["-rpcwallet=miner", "walletprocesspsbt", &psbt]); + let processed_psbt = serde_json::from_str::(&processed).unwrap()["psbt"] + .as_str() + .unwrap() + .to_string(); + let finalized = bitcoind(&["-rpcwallet=miner", "finalizepsbt", &processed_psbt]); + let raw = serde_json::from_str::(&finalized).unwrap()["hex"] + .as_str() + .unwrap() + .to_string(); + let txid = + Txid::from_str(&bitcoind(&["-rpcwallet=miner", "sendrawtransaction", &raw])).unwrap(); + bitcoind(&["-rpcwallet=miner", "-generate", "6"]); + + let tx: BitcoinTransaction = encode::deserialize(&hex_str_to_vec(&raw).unwrap()).unwrap(); + let first_script = tx.output.first().unwrap().script_pubkey.clone(); + assert!( + first_script.is_op_return(), + "the first output must be the OP_RETURN for this test to mean anything" + ); + (txid, first_script) +} + +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn opret_first_output_still_confirms() { + initialize(); + + let (txid, first_script) = send_opret_tx(); + + // the indexer does not track the OP_RETURN, so resolving the transaction through that output + // cannot work -- this is the precondition that makes the bug bite + let probe = electrum_client::Client::new(ELECTRUM_URL_REGTEST).unwrap(); + let history = probe.script_get_history(&first_script).unwrap(); + assert!( + history.is_empty(), + "expected the indexer to have no history for the OP_RETURN output, got {history:?}" + ); + + let logger = Arc::new(FilesystemLogger::new(PathBuf::from( + "tmp/electrum_opret_confirm", + ))); + let sync_client = ElectrumSyncClient::new(ELECTRUM_URL_REGTEST.to_string(), logger).unwrap(); + sync_client.register_tx(&txid, &first_script); + + let spy = Arc::new(ConfirmSpy::default()); + let confirmable: Arc = spy.clone(); + tokio::task::spawn_blocking(move || sync_client.sync(vec![confirmable]).unwrap()) + .await + .unwrap(); + + let confirmed = spy.confirmed.lock().unwrap().clone(); + assert!( + confirmed.contains(&txid), + "transaction with an OP_RETURN first output was never reported as confirmed" + ); +} + +// broadcasts and confirms a transaction whose outputs are *all* OP_RETURNs, returning its txid and +// the scriptPubKey of the first output. The whole input value is paid as fee, which is what lets +// the transaction have no spendable output at all. +fn send_all_opret_tx() -> (Txid, ScriptBuf) { + // a dedicated small UTXO to burn as fee, so the transaction can have no change output + let address = bitcoind(&["-rpcwallet=miner", "getnewaddress"]); + let funding_txid = bitcoind(&["-rpcwallet=miner", "sendtoaddress", &address, "0.0001"]); + bitcoind(&["-rpcwallet=miner", "-generate", "1"]); + + let unspents = bitcoind(&[ + "-rpcwallet=miner", + "listunspent", + "1", + "9999999", + &format!(r#"["{address}"]"#), + ]); + let unspent = serde_json::from_str::(&unspents).unwrap()[0].clone(); + assert_eq!(unspent["txid"].as_str().unwrap(), funding_txid); + let inputs = format!( + r#"[{{"txid":"{}","vout":{}}}]"#, + funding_txid, + unspent["vout"].as_u64().unwrap() + ); + // bitcoind rejects more than one `data` entry, so the transaction gets a single OP_RETURN + let outputs = format!(r#"[{{"data":"{}"}}]"#, "de".repeat(32)); + let raw_unsigned = bitcoind(&[ + "-rpcwallet=miner", + "createrawtransaction", + &inputs, + &outputs, + ]); + let signed = bitcoind(&[ + "-rpcwallet=miner", + "signrawtransactionwithwallet", + &raw_unsigned, + ]); + let raw = serde_json::from_str::(&signed).unwrap()["hex"] + .as_str() + .unwrap() + .to_string(); + // the whole input is fee, so the default max-feerate guard has to be disabled + let txid = Txid::from_str(&bitcoind(&[ + "-rpcwallet=miner", + "sendrawtransaction", + &raw, + "0", + ])) + .unwrap(); + bitcoind(&["-rpcwallet=miner", "-generate", "6"]); + + let tx: BitcoinTransaction = encode::deserialize(&hex_str_to_vec(&raw).unwrap()).unwrap(); + assert!( + tx.output.iter().all(|txo| txo.script_pubkey.is_op_return()), + "every output must be an OP_RETURN for this test to reach the input fallback" + ); + (txid, tx.output[0].script_pubkey.clone()) +} + +// The OP_RETURN filter alone is not enough when *no* output is indexable: the sync client then has +// to fall back to walking the inputs and querying the history of a previous output's script. That +// fallback is what this covers; `opret_first_output_still_confirms` only reaches the filter. +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn all_opret_outputs_confirm_via_input_fallback() { + initialize(); + + let (txid, first_script) = send_all_opret_tx(); + + let probe = electrum_client::Client::new(ELECTRUM_URL_REGTEST).unwrap(); + let history = probe.script_get_history(&first_script).unwrap(); + assert!( + history.is_empty(), + "expected the indexer to have no history for the OP_RETURN output, got {history:?}" + ); + + let logger = Arc::new(FilesystemLogger::new(PathBuf::from( + "tmp/electrum_opret_confirm_fallback", + ))); + let sync_client = ElectrumSyncClient::new(ELECTRUM_URL_REGTEST.to_string(), logger).unwrap(); + sync_client.register_tx(&txid, &first_script); + + let spy = Arc::new(ConfirmSpy::default()); + let confirmable: Arc = spy.clone(); + tokio::task::spawn_blocking(move || sync_client.sync(vec![confirmable]).unwrap()) + .await + .unwrap(); + + let confirmed = spy.confirmed.lock().unwrap().clone(); + assert!( + confirmed.contains(&txid), + "transaction with only OP_RETURN outputs was never reported as confirmed" + ); +} diff --git a/src/test/esplora_indexer_defaults.rs b/src/test/esplora_indexer_defaults.rs index 35bac7ae..55c0d841 100644 --- a/src/test/esplora_indexer_defaults.rs +++ b/src/test/esplora_indexer_defaults.rs @@ -1,8 +1,13 @@ -use std::collections::HashMap; +#[cfg(feature = "esplora")] +use std::collections::BTreeMap; use lightning::chain::chaininterface::ConfirmationTarget; -use crate::indexer::{default_fee_buckets, estimate_fee_rate_sat_per_kw, interpolate_fee_rate}; +use crate::ldk_chain_backend::default_fee_buckets; +#[cfg(feature = "esplora")] +use crate::ldk_chain_backend::transaction_sync::{ + estimate_fee_rate_sat_per_kw, interpolate_fee_rate, +}; #[test] fn default_fee_buckets_populates_all_targets() { @@ -24,24 +29,27 @@ fn default_fee_buckets_populates_all_targets() { } } +#[cfg(feature = "esplora")] #[test] fn interpolation_handles_exact_match() { - let mut m = HashMap::new(); + let mut m = BTreeMap::new(); m.insert(6u16, 12.0); assert_eq!(interpolate_fee_rate(&m, 6), Some(12.0)); } +#[cfg(feature = "esplora")] #[test] fn interpolation_linearly_interpolates_between_buckets() { - let mut m = HashMap::new(); + let mut m = BTreeMap::new(); m.insert(2u16, 100.0); m.insert(10u16, 20.0); let v = interpolate_fee_rate(&m, 6).unwrap(); assert!((v - 60.0).abs() < 0.001, "got {v}"); } +#[cfg(feature = "esplora")] #[test] fn interpolation_falls_back_to_default_when_empty() { - let m: HashMap = HashMap::new(); + let m: BTreeMap = BTreeMap::new(); assert_eq!(estimate_fee_rate_sat_per_kw(&m, 6, 5000), 5000); } diff --git a/src/test/init_electrum.rs b/src/test/init_electrum.rs index dccbe7bb..b5f778e0 100644 --- a/src/test/init_electrum.rs +++ b/src/test/init_electrum.rs @@ -20,10 +20,9 @@ async fn init_electrum_path_unlocks_without_bitcoind() { let payload = UnlockRequest { password: s!("password123"), - bitcoind_rpc_username: None, - bitcoind_rpc_password: None, - bitcoind_rpc_host: None, - bitcoind_rpc_port: None, + ldk_chain_sync: LdkChainSync::TransactionSync { + indexer_url: ELECTRUM_URL_REGTEST.to_string(), + }, indexer_url: Some(ELECTRUM_URL_REGTEST.to_string()), proxy_endpoint: Some(PROXY_ENDPOINT_LOCAL.to_string()), announce_addresses: vec![], diff --git a/src/test/init_esplora.rs b/src/test/init_esplora.rs index 6b1c28dc..44247863 100644 --- a/src/test/init_esplora.rs +++ b/src/test/init_esplora.rs @@ -1,47 +1,7 @@ -use std::process::Command; - use super::*; const TEST_DIR_BASE: &str = "tmp/init_esplora/"; -async fn start_esplora_profile() { - // initialize() recreated the network — drop the stale esplora container. - let _ = Command::new("docker") - .args(["rm", "-f", "optional-bitcoind-esplora-sync-esplora-1"]) - .status(); - let status = Command::new("docker") - .args(["compose", "--profile", "esplora", "up", "-d", "esplora"]) - .status() - .expect("failed to start esplora service"); - assert!(status.success(), "docker compose esplora up failed"); - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(2)) - .build() - .unwrap(); - let t_0 = OffsetDateTime::now_utc(); - loop { - let ready = client - .get(format!( - "{}/blocks/tip/hash", - crate::utils::ESPLORA_URL_REGTEST - )) - .send() - .await - .ok(); - if let Some(resp) = ready { - if let Ok(body) = resp.text().await { - if body.trim().len() == 64 { - return; - } - } - } - if (OffsetDateTime::now_utc() - t_0).as_seconds_f32() > 60.0 { - panic!("esplora REST never became ready"); - } - tokio::time::sleep(std::time::Duration::from_secs(2)).await; - } -} - /// Happy path for the esplora chain-sync mode: unlock without bitcoind creds, fund the wallet, confirm sync via esplora. #[serial_test::serial] #[tokio::test(flavor = "multi_thread", worker_threads = 1)] @@ -61,10 +21,9 @@ async fn init_esplora_path_unlocks_without_bitcoind() { let payload = UnlockRequest { password: s!("password123"), - bitcoind_rpc_username: None, - bitcoind_rpc_password: None, - bitcoind_rpc_host: None, - bitcoind_rpc_port: None, + ldk_chain_sync: LdkChainSync::TransactionSync { + indexer_url: crate::utils::ESPLORA_URL_REGTEST.to_string(), + }, indexer_url: Some(crate::utils::ESPLORA_URL_REGTEST.to_string()), proxy_endpoint: Some(PROXY_ENDPOINT_LOCAL.to_string()), announce_addresses: vec![], diff --git a/src/test/invoice.rs b/src/test/invoice.rs index e83e6698..858e9fbf 100644 --- a/src/test/invoice.rs +++ b/src/test/invoice.rs @@ -119,6 +119,24 @@ async fn description_hash_invoice() { invoice.min_final_cltv_expiry_delta(), u64::from(inbound_min_final_cltv) + 3 ); + + let decoded = decode_ln_invoice(node1_addr, &res.invoice).await; + assert_eq!(decoded.description, None); + assert_eq!( + decoded.description_hash.as_deref(), + Some(description_hash.0.to_string().as_str()) + ); + + let payment = list_payments(node1_addr) + .await + .into_iter() + .find(|p| p.payment_hash == decoded.payment_hash) + .unwrap(); + assert_eq!(payment.description, None); + assert_eq!( + payment.description_hash.as_deref(), + Some(description_hash.0.to_string().as_str()) + ); } #[serial_test::serial] @@ -199,6 +217,14 @@ async fn description_invoice() { let decoded = decode_ln_invoice(node1_addr, &res.invoice).await; assert_eq!(decoded.description.as_deref(), Some(description)); assert_eq!(decoded.description_hash, None); + + let payment = list_payments(node1_addr) + .await + .into_iter() + .find(|p| p.payment_hash == decoded.payment_hash) + .unwrap(); + assert_eq!(payment.description.as_deref(), Some(description)); + assert_eq!(payment.description_hash, None); } #[serial_test::serial] diff --git a/src/test/lib_sdk/external_signer.rs b/src/test/lib_sdk/external_signer.rs index 5d08e024..6814ac3b 100644 --- a/src/test/lib_sdk/external_signer.rs +++ b/src/test/lib_sdk/external_signer.rs @@ -78,10 +78,12 @@ fn attach_external_signer_host( fn unlock_with_attached_external_signer(node: &SdkNode, announce_alias: &str) { node.unlock_with_attached_external_signer( - Some("user".to_string()), - Some("password".to_string()), - Some("localhost".to_string()), - Some(18443), + SdkLdkChainSync::BlockSync { + bitcoind_rpc_username: "user".to_string(), + bitcoind_rpc_password: "password".to_string(), + bitcoind_rpc_host: "localhost".to_string(), + bitcoind_rpc_port: 18443, + }, Some("127.0.0.1:50001".to_string()), Some(PROXY_ENDPOINT_LOCAL.to_string()), vec![], @@ -171,10 +173,12 @@ fn external_init_unlock_and_restart_same_signer() { .expect("external init"); node.unlock_with_native_external_signer( signer.clone(), - Some("user".to_string()), - Some("password".to_string()), - Some("localhost".to_string()), - Some(18443), + SdkLdkChainSync::BlockSync { + bitcoind_rpc_username: "user".to_string(), + bitcoind_rpc_password: "password".to_string(), + bitcoind_rpc_host: "localhost".to_string(), + bitcoind_rpc_port: 18443, + }, Some("127.0.0.1:50001".to_string()), Some(PROXY_ENDPOINT_LOCAL.to_string()), vec![], @@ -192,10 +196,12 @@ fn external_init_unlock_and_restart_same_signer() { restarted .unlock_with_native_external_signer( signer.clone(), - Some("user".to_string()), - Some("password".to_string()), - Some("localhost".to_string()), - Some(18443), + SdkLdkChainSync::BlockSync { + bitcoind_rpc_username: "user".to_string(), + bitcoind_rpc_password: "password".to_string(), + bitcoind_rpc_host: "localhost".to_string(), + bitcoind_rpc_port: 18443, + }, Some("127.0.0.1:50001".to_string()), Some(PROXY_ENDPOINT_LOCAL.to_string()), vec![], @@ -234,10 +240,12 @@ fn external_restart_with_mismatched_signer_fails_unlock() { .expect("external init"); node.unlock_with_native_external_signer( signer_a.clone(), - Some("user".to_string()), - Some("password".to_string()), - Some("localhost".to_string()), - Some(18443), + SdkLdkChainSync::BlockSync { + bitcoind_rpc_username: "user".to_string(), + bitcoind_rpc_password: "password".to_string(), + bitcoind_rpc_host: "localhost".to_string(), + bitcoind_rpc_port: 18443, + }, Some("127.0.0.1:50001".to_string()), Some(PROXY_ENDPOINT_LOCAL.to_string()), vec![], @@ -253,10 +261,12 @@ fn external_restart_with_mismatched_signer_fails_unlock() { let err = restarted .unlock_with_native_external_signer( signer_b, - Some("user".to_string()), - Some("password".to_string()), - Some("localhost".to_string()), - Some(18443), + SdkLdkChainSync::BlockSync { + bitcoind_rpc_username: "user".to_string(), + bitcoind_rpc_password: "password".to_string(), + bitcoind_rpc_host: "localhost".to_string(), + bitcoind_rpc_port: 18443, + }, Some("127.0.0.1:50001".to_string()), Some(PROXY_ENDPOINT_LOCAL.to_string()), vec![], @@ -598,10 +608,12 @@ fn rgb_native_external_signer_mixed_one_hop_payment_quick() { node_b .unlock_with_native_external_signer( signer_b.clone(), - Some("user".to_string()), - Some("password".to_string()), - Some("localhost".to_string()), - Some(18443), + SdkLdkChainSync::BlockSync { + bitcoind_rpc_username: "user".to_string(), + bitcoind_rpc_password: "password".to_string(), + bitcoind_rpc_host: "localhost".to_string(), + bitcoind_rpc_port: 18443, + }, Some("127.0.0.1:50001".to_string()), Some(PROXY_ENDPOINT_LOCAL.to_string()), vec![], @@ -744,10 +756,12 @@ fn rgb_native_external_signer_mixed_one_hop_payment_roundtrip() { node_b .unlock_with_native_external_signer( signer_b.clone(), - Some("user".to_string()), - Some("password".to_string()), - Some("localhost".to_string()), - Some(18443), + SdkLdkChainSync::BlockSync { + bitcoind_rpc_username: "user".to_string(), + bitcoind_rpc_password: "password".to_string(), + bitcoind_rpc_host: "localhost".to_string(), + bitcoind_rpc_port: 18443, + }, Some("127.0.0.1:50001".to_string()), Some(PROXY_ENDPOINT_LOCAL.to_string()), vec![], @@ -908,10 +922,12 @@ fn rgb_native_external_signer_mixed_one_hop_payment_coop_close_settles_to_chain( node_b .unlock_with_native_external_signer( signer_b.clone(), - Some("user".to_string()), - Some("password".to_string()), - Some("localhost".to_string()), - Some(18443), + SdkLdkChainSync::BlockSync { + bitcoind_rpc_username: "user".to_string(), + bitcoind_rpc_password: "password".to_string(), + bitcoind_rpc_host: "localhost".to_string(), + bitcoind_rpc_port: 18443, + }, Some("127.0.0.1:50001".to_string()), Some(PROXY_ENDPOINT_LOCAL.to_string()), vec![], @@ -1083,10 +1099,12 @@ fn external_signer_virtual_channel_survives_restart() { let unlock_device = |node: &SdkNode, signer: &Arc| { node.unlock_with_native_external_signer( signer.clone(), - Some("user".to_string()), - Some("password".to_string()), - Some("localhost".to_string()), - Some(18443), + SdkLdkChainSync::BlockSync { + bitcoind_rpc_username: "user".to_string(), + bitcoind_rpc_password: "password".to_string(), + bitcoind_rpc_host: "localhost".to_string(), + bitcoind_rpc_port: 18443, + }, Some("127.0.0.1:50001".to_string()), Some(PROXY_ENDPOINT_LOCAL.to_string()), vec![], @@ -1256,3 +1274,179 @@ fn external_signer_virtual_channel_survives_restart() { panic!("external_signer_virtual_channel_survives_restart failed after restart"); } } + +/// `/sendrgb` in external-signer mode goes through the begin/sign/end split, so the `send_end` +/// variant it calls must still generate and post the consignment. Guards against regressing it to +/// `send_end_db_update_only`, which broadcasts and updates the DB but produces no consignment: the +/// sender would look fine while the recipient could never receive the asset. +#[test] +#[serial] +fn external_signer_send_rgb_delivers_consignment_to_recipient() { + ensure_regtest_available(); + let _guard = env_lock().lock().unwrap_or_else(|e| e.into_inner()); + + const PORT_OFF: u16 = 270; + const SEED_AMOUNT: u64 = 500; + const SEND_AMOUNT: u64 = 200; + let da = NODE_A_DAEMON_PORT + PORT_OFF; + let pa = NODE_A_PEER_PORT + PORT_OFF; + let db = NODE_B_DAEMON_PORT + PORT_OFF; + let pb = NODE_B_PEER_PORT + PORT_OFF; + + let test_dir = test_dir("sdk_external_signer_send_rgb"); + if test_dir.exists() { + fs::remove_dir_all(&test_dir).expect("remove previous lib_sdk test dir"); + } + fs::create_dir_all(&test_dir).expect("create lib_sdk test dir"); + let node_a_dir = test_dir.join("node_a"); + let node_b_dir = test_dir.join("node_b"); + let signer_a_dir = test_dir.join("signer_a"); + + // node A (the sender) runs in external-signer mode, node B receives normally + let signer_a = make_native_signer(&signer_a_dir, None); + let node_a = make_node(&node_a_dir, da, pa); + let node_b = make_node(&node_b_dir, db, pb); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + node_a + .init_with_native_external_signer(signer_a.clone()) + .expect("node A init native external signer"); + node_b + .init("nodeBpass".to_string(), None) + .expect("node B init"); + + node_a + .unlock_with_native_external_signer( + signer_a.clone(), + SdkLdkChainSync::BlockSync { + bitcoind_rpc_username: "user".to_string(), + bitcoind_rpc_password: "password".to_string(), + bitcoind_rpc_host: "localhost".to_string(), + bitcoind_rpc_port: 18443, + }, + Some("127.0.0.1:50001".to_string()), + Some(PROXY_ENDPOINT_LOCAL.to_string()), + vec![], + Some("RLN_external_send_rgb".to_string()), + ) + .expect("node A unlock native external signer"); + node_b + .unlock(unlock_request("nodeBpass")) + .expect("node B unlock"); + + fund_and_create_utxos(&node_a, "node A external send_rgb"); + fund_and_create_utxos(&node_b, "node B external send_rgb"); + mine(1); + node_a.sync().expect("node A sync after fund"); + node_b.sync().expect("node B sync after fund"); + + // asset issuance is not supported in external-signer mode, so node B issues and seeds + // node A; the send under test is the one node A (external signer) makes back to node B + let asset_id = node_b + .issueassetnia(SdkIssueAssetNiaRequest { + amounts: vec![1_000], + ticker: "EXTS".to_string(), + name: "ExternalSend".to_string(), + precision: 0, + }) + .expect("node B issueassetnia") + .asset_id; + + let seed_recipient_id = node_a + .rgbinvoice(SdkRgbInvoiceRequest { + asset_id: None, + assignment_kind: None, + assignment_amount: None, + duration_seconds: Some(3600), + min_confirmations: 1, + witness: false, + }) + .expect("node A rgbinvoice (seed)") + .recipient_id; + node_b + .send_rgb(SendRgbRequest { + donation: true, + fee_rate: CREATE_UTXOS_FEE_RATE, + min_confirmations: 1, + recipient_groups: vec![AssetRecipients { + asset_id: asset_id.clone(), + recipients: vec![RgbRecipient { + recipient_id: RecipientId(seed_recipient_id.0), + witness_data: None, + assignment_kind: AssignmentKind::Fungible, + assignment_amount: Some(SEED_AMOUNT), + transport_endpoints: vec![TransportEndpoint( + PROXY_ENDPOINT_LOCAL.to_string(), + )], + }], + }], + }) + .expect("node B send_rgb (seed)"); + mine(1); + refresh_transfers(&node_a); + refresh_transfers(&node_a); + refresh_transfers(&node_b); + assert_eq!( + asset_balance_spendable(&node_a, &asset_id), + SEED_AMOUNT, + "external-signer node must first receive the asset" + ); + + // the send under test: node A runs in external-signer mode, so /sendrgb goes through the + // begin/sign/end split and must still generate and post a consignment + let recipient_id = node_b + .rgbinvoice(SdkRgbInvoiceRequest { + asset_id: None, + assignment_kind: None, + assignment_amount: None, + duration_seconds: Some(3600), + min_confirmations: 1, + witness: false, + }) + .expect("node B rgbinvoice") + .recipient_id; + node_a + .send_rgb(SendRgbRequest { + donation: true, + fee_rate: CREATE_UTXOS_FEE_RATE, + min_confirmations: 1, + recipient_groups: vec![AssetRecipients { + asset_id: asset_id.clone(), + recipients: vec![RgbRecipient { + recipient_id: RecipientId(recipient_id.0), + witness_data: None, + assignment_kind: AssignmentKind::Fungible, + assignment_amount: Some(SEND_AMOUNT), + transport_endpoints: vec![TransportEndpoint( + PROXY_ENDPOINT_LOCAL.to_string(), + )], + }], + }], + }) + .expect("node A (external signer) send_rgb"); + + mine(1); + refresh_transfers(&node_b); + refresh_transfers(&node_b); + refresh_transfers(&node_a); + + // the recipient can only settle this if the consignment was generated and posted + assert_eq!( + asset_balance_spendable(&node_b, &asset_id), + 1_000 - SEED_AMOUNT + SEND_AMOUNT, + "recipient must receive the asset sent by an external-signer node" + ); + assert_eq!( + asset_balance_spendable(&node_a, &asset_id), + SEED_AMOUNT - SEND_AMOUNT, + ); + + node_a.shutdown(); + node_b.shutdown(); + thread::sleep(Duration::from_millis(300)); + })); + + if result.is_err() { + panic!("external_signer_send_rgb_delivers_consignment_to_recipient failed"); + } +} diff --git a/src/test/lib_sdk/helpers.rs b/src/test/lib_sdk/helpers.rs index a20ac6d0..7c19dcce 100644 --- a/src/test/lib_sdk/helpers.rs +++ b/src/test/lib_sdk/helpers.rs @@ -4,7 +4,7 @@ pub(crate) use rgb_lightning_node::{ AssetBalanceInfo, AssetRecipients, AssignmentKind, Channel, ContractId, HtlcStatus, InvoiceStatus, LnInvoiceRequest, Payment, PaymentHash, RecipientId, RgbRecipient, SdkCloseChannelRequest, SdkCreateUtxosRequest, SdkExternalSignerBootstrap, SdkInitRequest, - SdkIssueAssetCfaRequest, SdkIssueAssetNiaRequest, SdkKeysendRequest, SdkNode, + SdkIssueAssetCfaRequest, SdkIssueAssetNiaRequest, SdkKeysendRequest, SdkLdkChainSync, SdkNode, SdkOpenChannelRequest, SdkRefreshTransfersRequest, SdkRgbInvoiceRequest, SdkSendBtcRequest, SdkSendPaymentRequest, SdkUnlockRequest, SdkVssClearFenceRequest, SendRgbRequest, TransactionType, TransportEndpoint, WitnessData, @@ -332,10 +332,12 @@ fn make_node_inner( pub(crate) fn unlock_request(password: &str) -> SdkUnlockRequest { SdkUnlockRequest { password: password.to_string(), - bitcoind_rpc_username: Some("user".to_string()), - bitcoind_rpc_password: Some("password".to_string()), - bitcoind_rpc_host: Some("localhost".to_string()), - bitcoind_rpc_port: Some(18443), + ldk_chain_sync: SdkLdkChainSync::BlockSync { + bitcoind_rpc_username: "user".to_string(), + bitcoind_rpc_password: "password".to_string(), + bitcoind_rpc_host: "localhost".to_string(), + bitcoind_rpc_port: 18443, + }, indexer_url: Some("127.0.0.1:50001".to_string()), proxy_endpoint: Some(PROXY_ENDPOINT_LOCAL.to_string()), announce_addresses: vec![], diff --git a/src/test/lib_sdk/payment.rs b/src/test/lib_sdk/payment.rs index b2697af8..e041bf76 100644 --- a/src/test/lib_sdk/payment.rs +++ b/src/test/lib_sdk/payment.rs @@ -508,8 +508,9 @@ fn success() { assert!(xfer_2.recipient_id.is_some()); assert!(xfer_2.receive_utxo.is_none()); assert!(xfer_2.change_utxo.is_some()); - assert!(xfer_2.expiration.is_none()); - assert!(!xfer_2.transport_endpoints.is_empty()); + assert!(xfer_2.expiration.is_some()); + // the channel funding consignment travels over the p2p link, so no proxy is involved + assert!(xfer_2.transport_endpoints.is_empty()); let xfer_3 = transfers .iter() @@ -522,8 +523,8 @@ fn success() { assert!(xfer_3.recipient_id.is_some()); assert!(xfer_3.receive_utxo.is_some()); assert!(xfer_3.change_utxo.is_none()); - assert!(xfer_3.expiration.is_none()); - assert!(!xfer_3.transport_endpoints.is_empty()); + assert!(xfer_3.expiration.is_some()); + assert!(xfer_3.transport_endpoints.is_empty()); // txid filters resolve the same on-chain transaction let send_txid = xfer_2.txid.as_ref().expect("send txid").to_string(); diff --git a/src/test/missing_acceptor.rs b/src/test/missing_acceptor.rs index a9814d01..961820ee 100644 --- a/src/test/missing_acceptor.rs +++ b/src/test/missing_acceptor.rs @@ -25,8 +25,7 @@ async fn missing_acceptor() { let node3_info = node_info(node3_addr).await; let node3_pubkey = node3_info.pubkey; - *IGNORE_INBOUND_CHANNELS_ON_NODE.lock().unwrap() = - Some(PublicKey::from_str(&node2_pubkey).unwrap()); + let ignore_guard = NodeOverrideGuard::set(&IGNORE_INBOUND_CHANNELS_ON_NODE, &node2_pubkey); // opening a channel where the acceptor is missing should not lock the funds let stuck_channel = open_channel_request_raw( @@ -68,7 +67,7 @@ async fn missing_acceptor() { ) .await; - *IGNORE_INBOUND_CHANNELS_ON_NODE.lock().unwrap() = None; + drop(ignore_guard); assert_eq!(list_channels(node1_addr).await.len(), 2); diff --git a/src/test/mnemonic_crypto.rs b/src/test/mnemonic_crypto.rs new file mode 100644 index 00000000..27c13bf3 --- /dev/null +++ b/src/test/mnemonic_crypto.rs @@ -0,0 +1,76 @@ +use rln_migration::{Migrator, MigratorTrait}; +use sea_orm::{ConnectOptions, Database, DatabaseConnection}; + +use crate::crypto::encrypt_mnemonic; +use crate::database::RlnDatabase; +use crate::error::APIError; +use crate::utils::{check_password_validity, encrypt_and_save_mnemonic}; + +const PASSWORD: &str = "password123"; +const MNEMONIC: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; +// Record written by the magic-crypt encryption used before scrypt and XChaCha20Poly1305, as found +// in wallets and backups created by earlier versions. +const LEGACY_RECORD: &str = "m6g98F3pqJ49njHK+XWpIBUKEuxv2Gy6Qlt8S900rkc7FA4aMG3hfRAUYYEOJfdUtwqDnImV8W7Rdy6zLcY8oBFbdRGdz9kXb5iRY1BPf81gPX0OEa7B4Cn/dsNNCMJ1"; + +fn setup_db() -> (tempfile::TempDir, DatabaseConnection) { + let tmp_dir = tempfile::tempdir().expect("tempdir"); + let db_path = tmp_dir.path().join("rln_db"); + let connection_string = format!("sqlite:{}?mode=rwc", db_path.display()); + let db = crate::runtime::block_on(Database::connect(ConnectOptions::new(connection_string))) + .expect("db connection"); + crate::runtime::block_on(Migrator::up(&db, None)).expect("run migrations"); + (tmp_dir, db) +} + +#[test] +fn mnemonic_roundtrips_through_the_database() { + let (_tmp_dir, db) = setup_db(); + encrypt_and_save_mnemonic(PASSWORD.to_string(), MNEMONIC.to_string(), &db).expect("save"); + let mnemonic = check_password_validity(PASSWORD, &db).expect("read back"); + assert_eq!(mnemonic.to_string(), MNEMONIC); +} + +#[test] +fn wrong_password_is_reported_as_such() { + let (_tmp_dir, db) = setup_db(); + encrypt_and_save_mnemonic(PASSWORD.to_string(), MNEMONIC.to_string(), &db).expect("save"); + assert!(matches!( + check_password_validity("wrong-password", &db), + Err(APIError::WrongPassword) + )); +} + +#[test] +fn legacy_record_is_reported_as_corrupted() { + let (_tmp_dir, db) = setup_db(); + RlnDatabase::new(db.clone()) + .save_mnemonic(LEGACY_RECORD.to_string()) + .expect("save"); + assert!(matches!( + check_password_validity(PASSWORD, &db), + Err(APIError::CorruptedMnemonic(_)) + )); +} + +#[test] +fn decryptable_but_invalid_mnemonic_is_reported_as_corrupted() { + let (_tmp_dir, db) = setup_db(); + let encrypted = encrypt_mnemonic(PASSWORD, "not a bip39 mnemonic").expect("encrypt"); + RlnDatabase::new(db.clone()) + .save_mnemonic(encrypted) + .expect("save"); + assert!(matches!( + check_password_validity(PASSWORD, &db), + Err(APIError::CorruptedMnemonic(_)) + )); +} + +#[test] +fn missing_record_is_not_initialized() { + let (_tmp_dir, db) = setup_db(); + assert!(matches!( + check_password_validity(PASSWORD, &db), + Err(APIError::NotInitialized) + )); +} diff --git a/src/test/mod.rs b/src/test/mod.rs index e9ae664c..04af8c44 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -1,12 +1,24 @@ +#[cfg(feature = "esplora")] +use crate::utils::ESPLORA_URL_REGTEST; use amplify::s; use biscuit_auth::{builder::date, macros::*, KeyPair}; +#[cfg(all(feature = "transaction-sync", feature = "electrum"))] +use bitcoin::block::Header; +#[cfg(all(feature = "transaction-sync", feature = "electrum"))] +use bitcoin::consensus::encode; use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::Hash; use bitcoin::secp256k1::PublicKey; use bitcoin::{Amount, Denomination}; +#[cfg(all(feature = "transaction-sync", feature = "electrum"))] +use bitcoin::{BlockHash, ScriptBuf, Transaction as BitcoinTransaction, Txid}; use chrono::{DateTime, Local, Utc}; use electrum_client::ElectrumApi; use http::response::Builder; +#[cfg(all(feature = "transaction-sync", feature = "electrum"))] +use lightning::chain::transaction::TransactionData; +#[cfg(all(feature = "transaction-sync", feature = "electrum"))] +use lightning::chain::{Confirm, Filter}; use lightning::ln::channelmanager::DROP_FUNDING_SIGNED_ON_NODE; use lightning::rgb_utils::{ RgbPaymentInfo, RGB_PAYMENT_INFO_INBOUND_NS, RGB_PAYMENT_INFO_OUTBOUND_NS, RGB_PRIMARY_NS, @@ -15,6 +27,8 @@ use lightning::util::hash_tables::new_hash_map; use lightning::util::persist::KVStoreSync; use lightning::util::ser::Readable; use lightning_invoice::Bolt11Invoice; +#[cfg(all(feature = "transaction-sync", feature = "electrum"))] +use lightning_transaction_sync::ElectrumSyncClient; use once_cell::sync::Lazy; use rand::RngCore; use reqwest::{Response, StatusCode}; @@ -27,24 +41,32 @@ use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::str::FromStr; -use std::sync::{Arc, Mutex, Once, OnceLock, RwLock}; +#[cfg(all(feature = "esplora", feature = "transaction-sync"))] +use std::sync::atomic::AtomicBool; +use std::sync::{atomic::Ordering, Arc, Mutex, Once, OnceLock, RwLock}; use time::OffsetDateTime; use tokio::io::AsyncReadExt; use tokio::net::{TcpListener, TcpStream}; use tracing_test::traced_test; use crate::core_types::asset_link::{AssetLinkRequest, AssetLinkResponse}; -use crate::core_types::{HTLCStatus, SwapStatus, FEE_RATE, HTLC_MIN_MSAT, VIRTUAL_HTLC_MIN_MSAT}; +use crate::core_types::{ + HTLCStatus, LdkChainSync, SwapStatus, FEE_RATE, HTLC_MIN_MSAT, VIRTUAL_HTLC_MIN_MSAT, +}; +#[cfg(all(feature = "transaction-sync", feature = "electrum"))] +use crate::disk::FilesystemLogger; use crate::disk::LDK_LOGS_FILE; use crate::error::{APIError, APIErrorResponse}; use crate::kv_store::SeaOrmKvStore; use crate::ldk::{ - InboundPaymentInfoStorage, InvoiceType, IGNORE_INBOUND_CHANNELS_ON_NODE, INBOUND_PAYMENTS_KEY, + InboundPaymentInfoStorage, InvoiceType, DEFER_PAYMENT_CLAIMABLE_ON_NODE, + FORCE_PUSH_ASSET_AMOUNT_ON_NODE, HELD_PAYMENT_CLAIMABLE_COUNT, HOLD_PAYMENT_CLAIMABLE_ON_NODE, + IGNORE_INBOUND_CHANNELS_ON_NODE, INBOUND_PAYMENTS_KEY, PAYMENT_CLAIMABLE_DEFERRED, }; #[cfg(feature = "vss")] use crate::routes::VssClearFenceRequest; use crate::routes::{ - AddressResponse, AssetBalanceRequest, AssetBalanceResponse, AssetCFA, AssetIFA, + AddressResponse, AssetBalanceRequest, AssetBalanceResponse, AssetCFA, AssetFilter, AssetIFA, AssetMetadataRequest, AssetMetadataResponse, AssetNIA, AssetUDA, Assignment, BackupRequest, BtcBalanceRequest, BtcBalanceResponse, CancelHodlInvoiceRequest, ChangePasswordRequest, Channel, ChannelStatus, ClaimHodlInvoiceRequest, ClaimHodlInvoiceResponse, CloseChannelRequest, @@ -52,21 +74,22 @@ use crate::routes::{ DecodeRGBInvoiceRequest, DecodeRGBInvoiceResponse, DecodeSwapstringRequest, DecodeSwapstringResponse, DisconnectPeerRequest, EmptyResponse, FailTransfersRequest, FailTransfersResponse, GetAssetMediaRequest, GetAssetMediaResponse, GetChannelIdRequest, - GetChannelIdResponse, GetPaymentRequest, GetPaymentResponse, GetSwapRequest, GetSwapResponse, - InflateRequest, InflateResponse, InitRequest, InitResponse, InvoiceStatus, - InvoiceStatusRequest, InvoiceStatusResponse, IssueAssetCFARequest, IssueAssetCFAResponse, - IssueAssetIFARequest, IssueAssetIFAResponse, IssueAssetNIARequest, IssueAssetNIAResponse, - IssueAssetUDARequest, IssueAssetUDAResponse, KeysendRequest, KeysendResponse, LNInvoiceRequest, - LNInvoiceResponse, ListAssetsRequest, ListAssetsResponse, ListChannelsResponse, - ListPaymentsResponse, ListPeersResponse, ListSwapsResponse, ListTransactionsRequest, - ListTransactionsResponse, ListTransfersRequest, ListTransfersResponse, ListUnspentsRequest, - ListUnspentsResponse, MakerExecuteRequest, MakerInitRequest, MakerInitResponse, - NetworkInfoResponse, NodeInfoResponse, OpenChannelRequest, OpenChannelResponse, Payment, - PaymentDirection, PaymentType, Peer, PostAssetMediaResponse, Recipient, RefreshRequest, - RestoreRequest, RevokeTokenRequest, RgbInvoiceRequest, RgbInvoiceResponse, SendBtcRequest, - SendBtcResponse, SendPaymentRequest, SendPaymentResponse, SendRgbRequest, SendRgbResponse, - Swap, TakerRequest, Transaction, Transfer, TransferKind, TransferStatus, UnlockRequest, - Unspent, WitnessData, + GetChannelIdResponse, GetConsignmentRequest, GetConsignmentResponse, GetPaymentRequest, + GetPaymentResponse, GetSwapRequest, GetSwapResponse, InflateRequest, InflateResponse, + InitRequest, InitResponse, InvoiceStatus, InvoiceStatusRequest, InvoiceStatusResponse, + IssueAssetCFARequest, IssueAssetCFAResponse, IssueAssetIFARequest, IssueAssetIFAResponse, + IssueAssetNIARequest, IssueAssetNIAResponse, IssueAssetUDARequest, IssueAssetUDAResponse, + KeysendRequest, KeysendResponse, LNInvoiceRequest, LNInvoiceResponse, ListAssetsRequest, + ListAssetsResponse, ListChannelsResponse, ListPaymentsResponse, ListPeersResponse, + ListSwapsResponse, ListTransactionsRequest, ListTransactionsResponse, ListTransfersRequest, + ListTransfersResponse, ListUnspentsRequest, ListUnspentsResponse, MakerExecuteRequest, + MakerInitRequest, MakerInitResponse, NetworkInfoResponse, NodeInfoResponse, OpenChannelRequest, + OpenChannelResponse, Payment, PaymentDirection, PaymentType, Peer, PostAssetMediaResponse, + ProvideOutOfBandAckRequest, ProvideOutOfBandAckResponse, ProvideOutOfBandConsignmentResponse, + Recipient, RefreshRequest, RefreshResponse, RestoreRequest, RevokeTokenRequest, + RgbInvoiceRequest, RgbInvoiceResponse, SendBtcRequest, SendBtcResponse, SendPaymentRequest, + SendPaymentResponse, SendRgbRequest, SendRgbResponse, Swap, TakerRequest, Transaction, + Transfer, TransferKind, TransferStatus, UnlockRequest, Unspent, WitnessData, }; use crate::utils::{ get_db_path, hex_str, hex_str_to_vec, validate_and_parse_payment_hash, AppState, @@ -75,7 +98,6 @@ use crate::utils::{ use super::*; -const ELECTRUM_URL: &str = "127.0.0.1:50001"; const NODE1_PEER_PORT: u16 = 9801; const NODE2_PEER_PORT: u16 = 9802; const NODE3_PEER_PORT: u16 = 9803; @@ -108,6 +130,9 @@ impl Default for UserArgs { daemon_listening_port: 3001, ldk_peer_listening_port: 9735, max_media_upload_size_mb: 3, + max_aggregated_media_size_per_channel_mb: 24, + max_pending_consignments: 10, + max_media_files_per_channel: 42, root_public_key: None, enable_virtual_channels_v0: false, virtual_peer_pubkeys: vec![], @@ -149,8 +174,114 @@ impl Drop for ElectrsRestartGuard { .arg("start") .arg("electrs") .status() - .expect("failed to stop electrs"); - assert!(status.success(), "failed to stop electrs"); + .expect("failed to start electrs"); + assert!(status.success(), "failed to start electrs"); + wait_electrs_sync(); + } +} + +// Makes `mine` also wait for esplora to catch up with bitcoind, for the duration of a test that +// syncs a node through it. Scoped to a guard so the rest of the suite, which only queries electrs, +// doesn't pay for an indexer it never reads, and so a panicking test cannot leak the setting. +#[cfg(all(feature = "esplora", feature = "transaction-sync"))] +static WAIT_ESPLORA_SYNC: AtomicBool = AtomicBool::new(false); + +#[cfg(all(feature = "esplora", feature = "transaction-sync"))] +struct EsploraSyncGuard; + +#[cfg(all(feature = "esplora", feature = "transaction-sync"))] +impl EsploraSyncGuard { + fn set() -> Self { + WAIT_ESPLORA_SYNC.store(true, Ordering::SeqCst); + Self + } +} + +#[cfg(all(feature = "esplora", feature = "transaction-sync"))] +impl Drop for EsploraSyncGuard { + fn drop(&mut self) { + WAIT_ESPLORA_SYNC.store(false, Ordering::SeqCst); + } +} + +#[cfg(all(feature = "esplora", feature = "transaction-sync"))] +async fn start_esplora_profile() { + // initialize() recreated the network — drop the stale esplora container. + let _ = Command::new("docker") + .args(["rm", "-f", "optional-bitcoind-esplora-sync-esplora-1"]) + .status(); + let status = Command::new("docker") + .args(["compose", "--profile", "esplora", "up", "-d", "esplora"]) + .status() + .expect("failed to start esplora service"); + assert!(status.success(), "docker compose esplora up failed"); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(2)) + .build() + .unwrap(); + let t_0 = OffsetDateTime::now_utc(); + loop { + let ready = client + .get(format!( + "{}/blocks/tip/hash", + crate::utils::ESPLORA_URL_REGTEST + )) + .send() + .await + .ok(); + if let Some(resp) = ready { + if let Ok(body) = resp.text().await { + if body.trim().len() == 64 { + return; + } + } + } + if (OffsetDateTime::now_utc() - t_0).as_seconds_f32() > 60.0 { + panic!("esplora REST never became ready"); + } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } +} + +// Sets a test-override static to a node's pubkey and clears it on drop, so a +// panicking test cannot leak the override into the next one +struct NodeOverrideGuard(&'static Mutex>); + +impl NodeOverrideGuard { + fn set(target: &'static Mutex>, node_pubkey: &str) -> Self { + *target.lock().unwrap() = Some(PublicKey::from_str(node_pubkey).unwrap()); + Self(target) + } +} + +impl Drop for NodeOverrideGuard { + fn drop(&mut self) { + *self.0.lock().unwrap_or_else(|e| e.into_inner()) = None; + } +} + +// Makes the payee defer claiming incoming payments, so the payer's HTLC (and any swap it is part +// of) stays pending until the returned guard is dropped. +// +// Must be set before the payment is sent; call `wait_for_deferred_payment` afterwards to know the +// HTLC has actually reached the payee. +fn defer_payment_claimable(payee_pubkey: &str) -> NodeOverrideGuard { + PAYMENT_CLAIMABLE_DEFERRED.store(false, Ordering::SeqCst); + NodeOverrideGuard::set(&DEFER_PAYMENT_CLAIMABLE_ON_NODE, payee_pubkey) +} + +// Waits for a payment deferred via `defer_payment_claimable` to have reached the payee. +// +// Note that only one payment at a time can be deferred on a node, as a node handles its events +// sequentially. What the gate guarantees is that no payment to that node settles while it is held, +// not that every in-flight payment has reached it. +async fn wait_for_deferred_payment() { + let t_0 = OffsetDateTime::now_utc(); + while !PAYMENT_CLAIMABLE_DEFERRED.load(Ordering::SeqCst) { + if (OffsetDateTime::now_utc() - t_0).as_seconds_f32() > 40.0 { + panic!("no payment has been deferred"); + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; } } @@ -166,6 +297,27 @@ fn bitcoin_cli() -> [String; 7] { ] } +// runs a bitcoin-cli command against the regtest bitcoind, returning its trimmed stdout. wallet +// commands need an explicit `-rpcwallet=` as their first argument +fn bitcoind(args: &[&str]) -> String { + let output = Command::new("docker") + .stdin(Stdio::null()) + .arg("compose") + .args(bitcoin_cli()) + .args(args) + .output() + .expect("failed to call bitcoin-cli"); + assert!( + output.status.success(), + "bitcoin-cli {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout) + .expect("bitcoin-cli output is not valid UTF-8") + .trim() + .to_string() +} + fn check_preimage_matches_hash(payment: &Payment, expected_payment_hash: &str) { let payment_preimage = payment.preimage.as_ref().unwrap(); let payment_preimage_hash = @@ -196,36 +348,11 @@ async fn check_response_is_nok( fn fund_wallet(address: String, sats: u64) { let amt = Amount::from_sat(sats); let btc_str = amt.to_string_in(Denomination::Bitcoin); - let status = Command::new("docker") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .arg("compose") - .args(bitcoin_cli()) - .arg("-rpcwallet=miner") - .arg("sendtoaddress") - .arg(address) - .arg(btc_str) - .status() - .expect("failed to fund wallet"); - assert!(status.success()); + bitcoind(&["-rpcwallet=miner", "sendtoaddress", &address, &btc_str]); } fn get_txout(txid: &str) -> String { - String::from_utf8( - Command::new("docker") - .stdin(Stdio::null()) - .arg("compose") - .args(bitcoin_cli()) - .arg("-rpcwallet=miner") - .arg("gettxout") - .arg(txid) - .arg("0") - .output() - .expect("failed get txout") - .stdout, - ) - .unwrap() + bitcoind(&["-rpcwallet=miner", "gettxout", txid, "0"]) } async fn start_daemon( @@ -333,6 +460,32 @@ async fn start_node( .await } +async fn start_node_with( + node_test_dir: &str, + node_peer_port: u16, + keep_node_dir: bool, + ldk_chain_sync: LdkChainSync, +) -> (SocketAddr, String) { + println!("starting node with peer port {node_peer_port}"); + let node_address = start_daemon_with_virtual_options( + node_test_dir, + node_peer_port, + None, + keep_node_dir, + false, + vec![], + ) + .await; + let password = format!("{node_test_dir}.{node_peer_port}"); + if !keep_node_dir { + init(node_address, &password, None).await; + } + unlock_with(node_address, &password, ldk_chain_sync).await; + wait_for_peer_port_ready(node_peer_port).await; + println!("node on peer port {node_peer_port} started with address {node_address:?}"); + (node_address, password) +} + async fn start_node_with_virtual_options( node_test_dir: &str, node_peer_port: u16, @@ -781,6 +934,28 @@ async fn close_channel(node_address: SocketAddr, channel_id: &str, peer_pubkey: } } +// Waits until the channel's funding output is spent by a confirmed tx +// (commitment or cooperative close) and returns the spending txid +async fn wait_for_funding_spend_txid(node_test_dir: &str, channel_id: &str) -> String { + let needle = format!("Channel {channel_id} closed by funding output spend in txid "); + let t_0 = OffsetDateTime::now_utc(); + loop { + let txid = ldk_log_lines(node_test_dir) + .iter() + .find_map(|l| l.split_once(&needle).map(|(_, rest)| rest.to_string())); + // defensive: a partially-flushed line would be shorter than a txid; retry rather than panic + if let Some(txid) = txid { + if txid.len() >= 64 { + return txid[..64].to_string(); + } + } + if (OffsetDateTime::now_utc() - t_0).as_seconds_f32() > 30.0 { + panic!("confirmed commitment for channel {channel_id} not seen in logs"); + } + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } +} + async fn connect_peer(node_address: SocketAddr, peer_pubkey: &str, peer_addr: &str) { println!("connecting peer {peer_pubkey} from node {node_address}"); let payload = ConnectPeerRequest { @@ -966,6 +1141,26 @@ async fn get_asset_media(node_address: SocketAddr, digest: &str) -> String { .bytes_hex } +async fn get_consignment(node_address: SocketAddr, asset_id: &str, txid: &str) -> String { + println!("requesting consignment for asset {asset_id} txid {txid} from node {node_address}"); + let payload = GetConsignmentRequest { + asset_id: asset_id.to_string(), + txid: txid.to_string(), + }; + let res = reqwest::Client::new() + .post(format!("http://{node_address}/getconsignment")) + .json(&payload) + .send() + .await + .unwrap(); + check_response_is_ok(res) + .await + .json::() + .await + .unwrap() + .bytes_hex +} + async fn get_channel_id(node_address: SocketAddr, temp_chan_id: &str) -> String { println!("requesting channel ID for temporary ID {temp_chan_id} from node {node_address}"); let payload = GetChannelIdRequest { @@ -1156,6 +1351,7 @@ async fn issue_asset_uda(node_address: SocketAddr, file_path: Option<&str>) -> A .asset } +#[allow(clippy::too_many_arguments)] async fn with_ln_balance_checks( node_address: SocketAddr, counterparty_node_address: SocketAddr, @@ -1164,11 +1360,18 @@ async fn with_ln_balance_checks( initial_ln_balance_rgb: Option, counterparty_initial_ln_balance_rgb: Option, payment_hash: &str, + defer_guard: NodeOverrideGuard, ) { + // the payee is deferring the claim, so the payment is provably still pending here: without + // that gate it could have settled before we get to look at it, making this check racy + wait_for_deferred_payment().await; check_payment_status(node_address, payment_hash, HTLCStatus::Pending) .await .unwrap(); + // let the payee claim, so the payment can settle + drop(defer_guard); + if let Some(asset_id) = &asset_id { let final_ln_balance_rgb = initial_ln_balance_rgb.unwrap() - asset_amount.unwrap(); wait_for_ln_balance(node_address, asset_id, final_ln_balance_rgb).await; @@ -1245,6 +1448,7 @@ async fn keysend_with_ln_balance( initial_ln_balance_rgb: Option, counterparty_initial_ln_balance_rgb: Option, ) { + let defer_guard = defer_payment_claimable(dest_pubkey); let res = keysend_raw(node_address, dest_pubkey, amt_msat, asset_id, asset_amount).await; with_ln_balance_checks( @@ -1255,10 +1459,23 @@ async fn keysend_with_ln_balance( initial_ln_balance_rgb, counterparty_initial_ln_balance_rgb, &res.payment_hash, + defer_guard, ) .await; } +// Lines of a node's LDK log file (empty if the log doesn't exist yet) +fn ldk_log_lines(node_test_dir: &str) -> Vec { + let log_path = PathBuf::from(node_test_dir) + .join(LDK_DIR) + .join(LOGS_DIR) + .join(LDK_LOGS_FILE); + let Ok(file) = File::open(log_path) else { + return vec![]; + }; + BufReader::new(file).lines().map_while(Result::ok).collect() +} + async fn list_assets(node_address: SocketAddr) -> ListAssetsResponse { println!("listing assets for node {node_address}"); let payload = ListAssetsRequest { @@ -1482,7 +1699,7 @@ async fn list_transfers_full( ) -> ListTransfersResponse { println!("listing transfers for asset {asset_id} on node {node_address}"); let payload = ListTransfersRequest { - asset_id: Some(asset_id.to_string()), + asset_filter: AssetFilter::Id(asset_id.to_string()), txid: None, index_offset: filter.index_offset, max_transfers: filter.max_transfers, @@ -1506,7 +1723,7 @@ async fn list_transfers_full( async fn list_transfers_by_txid(node_address: SocketAddr, txid: &str) -> Vec { println!("listing transfers for txid {txid} on node {node_address}"); let payload = ListTransfersRequest { - asset_id: None, + asset_filter: AssetFilter::AnyOrNone, txid: Some(txid.to_string()), index_offset: None, max_transfers: None, @@ -1528,6 +1745,31 @@ async fn list_transfers_by_txid(node_address: SocketAddr, txid: &str) -> Vec Vec { + println!("listing asset-less transfers on node {node_address}"); + let payload = ListTransfersRequest { + asset_filter: AssetFilter::None, + txid: None, + index_offset: None, + max_transfers: None, + status: None, + created_after: None, + created_before: None, + }; + let res = reqwest::Client::new() + .post(format!("http://{node_address}/listtransfers")) + .json(&payload) + .send() + .await + .unwrap(); + check_response_is_ok(res) + .await + .json::() + .await + .unwrap() + .transfers +} + async fn list_transfers_by_asset_and_txid( node_address: SocketAddr, asset_id: &str, @@ -1535,7 +1777,7 @@ async fn list_transfers_by_asset_and_txid( ) -> Vec { println!("listing transfers for asset {asset_id} and txid {txid} on node {node_address}"); let payload = ListTransfersRequest { - asset_id: Some(asset_id.to_string()), + asset_filter: AssetFilter::Id(asset_id.to_string()), txid: Some(txid.to_string()), index_offset: None, max_transfers: None, @@ -1952,6 +2194,9 @@ async fn open_channel_with_retry( } } +/// NOT the upstream helper of the same name: this one waits for the channel to get funded and +/// returns a retryable FORBIDDEN if it doesn't. Upstream's `open_channel_raw` is our +/// `open_channel_request_raw`, which is what tests expecting a failed open must use. #[allow(clippy::too_many_arguments)] async fn open_channel_raw( node_address: SocketAddr, @@ -2116,6 +2361,7 @@ async fn open_channel_raw( /// Low-level open-channel helper: POSTs the request and returns the raw response /// without waiting for the channel to become ready. Used by tests that assert on /// the immediate open result or that deliberately exercise stuck/failed opens. +#[allow(clippy::result_large_err)] #[allow(clippy::too_many_arguments)] async fn open_channel_request_raw( node_address: SocketAddr, @@ -2184,6 +2430,7 @@ async fn open_channel_request_raw( Ok(res.json::().await.unwrap()) } +#[allow(clippy::result_large_err)] #[allow(clippy::too_many_arguments)] async fn open_channel_funded_raw( node_address: SocketAddr, @@ -2368,24 +2615,84 @@ fn random_preimage_and_hash() -> (String, String) { (preimage_hex, payment_hash) } -async fn refresh_transfers(node_address: SocketAddr) { +async fn provide_out_of_band_ack( + node_address: SocketAddr, + recipient_id: &str, +) -> ProvideOutOfBandAckResponse { + check_response_is_ok(provide_out_of_band_ack_res(node_address, recipient_id).await) + .await + .json::() + .await + .unwrap() +} + +async fn provide_out_of_band_ack_res(node_address: SocketAddr, recipient_id: &str) -> Response { + println!("providing out-of-band ACK for recipient {recipient_id} on node {node_address}"); + let payload = ProvideOutOfBandAckRequest { + recipient_id: recipient_id.to_string(), + }; + reqwest::Client::new() + .post(format!("http://{node_address}/provideoutofbandack")) + .json(&payload) + .send() + .await + .unwrap() +} + +async fn provide_out_of_band_consignment( + node_address: SocketAddr, + consignment_bytes: Vec, + media_files_bytes: Vec>, +) -> ProvideOutOfBandConsignmentResponse { + println!( + "providing out-of-band consignment ({} bytes, {} media files) on node {node_address}", + consignment_bytes.len(), + media_files_bytes.len(), + ); + let mut form = reqwest::multipart::Form::new() + .part("file", reqwest::multipart::Part::bytes(consignment_bytes)); + for media_bytes in media_files_bytes { + form = form.part("media", reqwest::multipart::Part::bytes(media_bytes)); + } + let res = reqwest::Client::new() + .post(format!("http://{node_address}/provideoutofbandconsignment")) + .multipart(form) + .send() + .await + .unwrap(); + check_response_is_ok(res) + .await + .json::() + .await + .unwrap() +} + +async fn refresh_transfers_raw(node_address: SocketAddr) -> Result { println!("refreshing transfers for node {node_address}"); let payload = RefreshRequest { asset_id: None, filter: vec![], skip_sync: false, }; - let res = reqwest::Client::new() + reqwest::Client::new() .post(format!("http://{node_address}/refreshtransfers")) .json(&payload) .send() .await - .unwrap(); +} + +async fn refresh_transfers(node_address: SocketAddr) -> RefreshResponse { + let res = refresh_transfers_raw(node_address).await.unwrap(); check_response_is_ok(res) .await - .json::() + .json::() .await - .unwrap(); + .unwrap() +} + +// Best-effort refresh for nodes that may not be able to serve it yet +async fn refresh_transfers_tolerant(node_address: SocketAddr) { + let _ = refresh_transfers_raw(node_address).await; } async fn restore(node_address: SocketAddr, backup_path: &str, password: &str) { @@ -2420,23 +2727,61 @@ async fn rgb_invoice_with_assignment( asset_id: Option, assignment: Option, witness: bool, +) -> RgbInvoiceResponse { + rgb_invoice_raw( + node_address, + asset_id, + assignment, + witness, + vec![PROXY_ENDPOINT_LOCAL.to_string()], + ) + .await +} + +async fn rgb_invoice_oob( + node_address: SocketAddr, + asset_id: Option, + assignment: Option, + witness: bool, +) -> RgbInvoiceResponse { + rgb_invoice_raw(node_address, asset_id, assignment, witness, vec![]).await +} + +async fn rgb_invoice_raw( + node_address: SocketAddr, + asset_id: Option, + assignment: Option, + witness: bool, + transport_endpoints: Vec, ) -> RgbInvoiceResponse { println!( - "generating RGB invoice{} for node {node_address}", + "generating RGB invoice{}{}{} for node {node_address}", if let Some(id) = asset_id.as_ref() { format!(" for asset {id}") } else { s!("") + }, + if let Some(assignment) = &assignment { + format!(" with assignment {assignment:?}") + } else { + s!("") + }, + if transport_endpoints.is_empty() { + s!("") + } else { + format!( + " with transport endpoints {}", + transport_endpoints.join(", ") + ) } ); let payload = RgbInvoiceRequest { min_confirmations: 1, asset_id, assignment, - expiration_timestamp: Some( - OffsetDateTime::now_utc().unix_timestamp() as u64 + DURATION_SECONDS, - ), + expiration_timestamp: OffsetDateTime::now_utc().unix_timestamp() as u64 + DURATION_SECONDS, witness, + transport_endpoints, }; let res = reqwest::Client::new() .post(format!("http://{node_address}/rgbinvoice")) @@ -2477,7 +2822,7 @@ async fn send_assets( node_address: SocketAddr, recipient_map: HashMap>, donation: bool, -) { +) -> String { println!( "batch sending {} asset(s) from node {node_address}", recipient_map.len() @@ -2486,9 +2831,7 @@ async fn send_assets( donation, fee_rate: FEE_RATE, min_confirmations: 1, - expiration_timestamp: Some( - OffsetDateTime::now_utc().unix_timestamp() as u64 + DURATION_SECONDS, - ), + expiration_timestamp: OffsetDateTime::now_utc().unix_timestamp() as u64 + DURATION_SECONDS, recipient_map, }; let res = reqwest::Client::new() @@ -2501,7 +2844,8 @@ async fn send_assets( .await .json::() .await - .unwrap(); + .unwrap() + .txid } async fn send_btc(node_address: SocketAddr, amount: u64, address: &str) -> String { @@ -2560,6 +2904,7 @@ async fn send_payment_with_ln_balance( ) { let bolt11_invoice = Bolt11Invoice::from_str(&invoice).unwrap(); + let defer_guard = defer_payment_claimable(&bolt11_invoice.recover_payee_pub_key().to_string()); let res = send_payment_raw(node_address, invoice).await; with_ln_balance_checks( @@ -2571,6 +2916,7 @@ async fn send_payment_with_ln_balance( counterparty_initial_ln_balance_rgb, // TODO: remove unwrap once RGB offers are enabled &res.payment_hash.unwrap(), + defer_guard, ) .await; } @@ -2646,6 +2992,12 @@ async fn shutdown(node_sockets: &[SocketAddr]) { } } +// Total spendable BTC across the vanilla and colored wallets +async fn spendable_sats(node_address: SocketAddr) -> u64 { + let balance = btc_balance(node_address).await; + balance.vanilla.spendable + balance.colored.spendable +} + async fn taker(node_address: SocketAddr, swapstring: String) -> EmptyResponse { println!("taking swap {swapstring} on node {node_address}"); let payload = TakerRequest { swapstring }; @@ -2662,13 +3014,30 @@ async fn taker(node_address: SocketAddr, swapstring: String) -> EmptyResponse { .unwrap() } +// the sync mode the suite unlocks its nodes with: block-sync against the local bitcoind when that +// backend is available, falling back to transaction-sync against the local electrs otherwise +fn default_ldk_chain_sync() -> LdkChainSync { + #[cfg(feature = "block-sync")] + return LdkChainSync::BlockSync { + bitcoind_rpc_username: s!("user"), + bitcoind_rpc_password: s!("password"), + bitcoind_rpc_host: s!("localhost"), + bitcoind_rpc_port: 18443, + }; + #[cfg(not(feature = "block-sync"))] + return LdkChainSync::TransactionSync { + indexer_url: ELECTRUM_URL_REGTEST.to_string(), + }; +} + fn unlock_req(password: &str) -> UnlockRequest { + unlock_req_with(password, default_ldk_chain_sync()) +} + +fn unlock_req_with(password: &str, ldk_chain_sync: LdkChainSync) -> UnlockRequest { UnlockRequest { password: password.to_string(), - bitcoind_rpc_username: Some(s!("user")), - bitcoind_rpc_password: Some(s!("password")), - bitcoind_rpc_host: Some(s!("localhost")), - bitcoind_rpc_port: Some(18443), + ldk_chain_sync, indexer_url: Some(ELECTRUM_URL_REGTEST.to_string()), proxy_endpoint: Some(PROXY_ENDPOINT_LOCAL.to_string()), announce_addresses: vec![], @@ -2678,8 +3047,16 @@ fn unlock_req(password: &str) -> UnlockRequest { } async fn unlock_res(node_address: SocketAddr, password: &str) -> Response { + unlock_res_with(node_address, password, default_ldk_chain_sync()).await +} + +async fn unlock_res_with( + node_address: SocketAddr, + password: &str, + ldk_chain_sync: LdkChainSync, +) -> Response { println!("unlocking node {node_address}"); - let payload = unlock_req(password); + let payload = unlock_req_with(password, ldk_chain_sync); reqwest::Client::new() .post(format!("http://{node_address}/unlock")) .json(&payload) @@ -2709,9 +3086,29 @@ async fn unlock_with_gossip_source( .unwrap(); } +// Output values (in sats) of an on-chain transaction +fn tx_output_sats(txid: &str) -> Vec { + let raw_tx = bitcoind(&["getrawtransaction", txid, "true"]); + let tx: serde_json::Value = serde_json::from_str(&raw_tx).expect("valid tx JSON"); + tx["vout"] + .as_array() + .expect("vout array") + .iter() + .map(|v| { + Amount::from_btc(v["value"].as_f64().expect("output value")) + .expect("valid amount") + .to_sat() + }) + .collect() +} + async fn unlock(node_address: SocketAddr, password: &str) { + unlock_with(node_address, password, default_ldk_chain_sync()).await +} + +async fn unlock_with(node_address: SocketAddr, password: &str, ldk_chain_sync: LdkChainSync) { println!("unlocking node {node_address}"); - let res = unlock_res(node_address, password).await; + let res = unlock_res_with(node_address, password, ldk_chain_sync).await; check_response_is_ok(res) .await .json::() @@ -2920,18 +3317,7 @@ impl Miner { if self.no_mine_count > 0 { return false; } - let status = Command::new("docker") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .arg("compose") - .args(bitcoin_cli()) - .arg("-rpcwallet=miner") - .arg("-generate") - .arg(num_blocks.to_string()) - .status() - .expect("failed to mine"); - assert!(status.success()); + bitcoind(&["-rpcwallet=miner", "-generate", &num_blocks.to_string()]); true } @@ -2972,6 +3358,10 @@ fn mine_n_blocks(resume: bool, num_blocks: u16) { } } wait_electrs_sync(); + #[cfg(all(feature = "esplora", feature = "transaction-sync"))] + if WAIT_ESPLORA_SYNC.load(Ordering::SeqCst) { + wait_esplora_sync(); + } } fn stop_mining() { @@ -2989,38 +3379,40 @@ fn resume_mining() { } fn get_block_count() -> u32 { - let output = Command::new("docker") - .stdin(Stdio::null()) - .stderr(Stdio::null()) - .arg("compose") - .args(bitcoin_cli()) - .arg("getblockcount") - .output() - .expect("failed to call getblockcount"); - assert!(output.status.success()); - let blockcount_str = - std::str::from_utf8(&output.stdout).expect("could not parse blockcount output"); - blockcount_str - .trim() + bitcoind(&["getblockcount"]) .parse::() .expect("could not parse blockcount") } +// the esplora indexer catches up with bitcoind independently of electrs, so a node syncing +// through it needs its own wait after mining +#[cfg(all(feature = "esplora", feature = "transaction-sync"))] +fn wait_esplora_sync() { + let t_0 = OffsetDateTime::now_utc(); + let blockcount = get_block_count(); + let client = esplora_client::Builder::new(ESPLORA_URL_REGTEST).build_blocking(); + loop { + if client.get_height().is_ok_and(|height| height >= blockcount) { + break; + }; + if (OffsetDateTime::now_utc() - t_0).as_seconds_f32() > 30.0 { + panic!("esplora not syncing with bitcoind"); + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } +} + fn wait_electrs_sync() { let t_0 = OffsetDateTime::now_utc(); let blockcount = get_block_count(); loop { std::thread::sleep(std::time::Duration::from_millis(100)); - let mut all_synced = true; - let electrum = - electrum_client::Client::new(ELECTRUM_URL).expect("cannot get electrum client"); - if electrum.block_header(blockcount as usize).is_err() { - all_synced = false; - } - if all_synced { + let synced = electrum_client::Client::new(ELECTRUM_URL_REGTEST) + .is_ok_and(|electrum| electrum.block_header(blockcount as usize).is_ok()); + if synced { break; }; - if (OffsetDateTime::now_utc() - t_0).as_seconds_f32() > 10.0 { + if (OffsetDateTime::now_utc() - t_0).as_seconds_f32() > 30.0 { panic!("electrs not syncing with bitcoind"); } } @@ -3071,7 +3463,7 @@ mod asset_link; mod auth_db_persistence; mod authentication; mod backup_and_restore; -mod chain_backend_bitcoind_dispatch; +mod chain_backend_dispatch; mod close_coop_nobtc_acceptor; mod close_coop_other_side; mod close_coop_standard; @@ -3079,11 +3471,15 @@ mod close_coop_vanilla; mod close_coop_zero_balance; mod close_force_nobtc_acceptor; mod close_force_other_side; +mod close_force_pending_htlc; mod close_force_standard; +#[cfg(feature = "transaction-sync")] mod colored_channel_electrum; mod concurrent_btc_payments; mod concurrent_openchannel; mod drop_funding_signed; +#[cfg(all(feature = "transaction-sync", feature = "electrum"))] +mod electrum_opret_confirm; mod esplora_indexer_defaults; mod fail_transfers; mod getchannelid; @@ -3094,27 +3490,34 @@ mod htlc_amount_checks; mod ifa_channel; mod inflate; mod init; +#[cfg(feature = "transaction-sync")] mod init_electrum; +#[cfg(all(feature = "transaction-sync", feature = "esplora"))] mod init_esplora; mod invoice; mod issue; mod lock_unlock_changepassword; mod missing_acceptor; +mod mnemonic_crypto; mod multi_hop; mod multi_open_close; mod open_after_double_send; mod openchannel_fail; +mod openchannel_media; mod openchannel_no_indexer; mod openchannel_optional_addr; mod openchannel_push_asset_amount; +mod out_of_band; mod pagination_filters; mod payment; +mod push_asset_amount_above_chan_amt; mod refuse_high_fees; #[cfg(feature = "vss")] mod remote_first_kv; #[cfg(feature = "vss")] mod remote_first_recovery; mod restart; +mod restore_legacy_backup; mod restore_swaps_db_pool; mod rgb_payment_htlc_persistence; mod send_receive; @@ -3135,9 +3538,12 @@ mod swap_roundtrip_multihop_asset_asset; mod swap_roundtrip_multihop_buy; mod swap_roundtrip_multihop_sell; mod swap_roundtrip_sell; +#[cfg(feature = "transaction-sync")] +mod transaction_sync; +mod tripwire_legacy_colored_channel; #[cfg(feature = "vss")] mod unlock_missing_monitor; -mod unlock_request_optional_bitcoind; +mod unlock_request_ldk_chain_sync; mod upload_asset_media; mod vanilla_payment_on_rgb_channel; mod virtual_channels; @@ -3147,5 +3553,5 @@ mod vss; mod vss_durability_gaps; #[cfg(feature = "vss")] mod vss_offline_force_close; -#[cfg(feature = "vss")] +#[cfg(all(feature = "vss", feature = "transaction-sync"))] mod vss_unreachable_openchannel; diff --git a/src/test/openchannel_media.rs b/src/test/openchannel_media.rs new file mode 100644 index 00000000..cd76aa1c --- /dev/null +++ b/src/test/openchannel_media.rs @@ -0,0 +1,102 @@ +use super::*; + +use lightning::rgb_utils::RgbKvStoreExt; + +const TEST_DIR_BASE: &str = "tmp/openchannel_media/"; + +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn openchannel_media() { + initialize(); + + let file_path = "README.md"; + + let test_dir_node1 = format!("{TEST_DIR_BASE}node1"); + let test_dir_node2 = format!("{TEST_DIR_BASE}node2"); + let (node1_addr, _) = start_node(&test_dir_node1, NODE1_PEER_PORT, false).await; + let (node2_addr, _) = start_node(&test_dir_node2, NODE2_PEER_PORT, false).await; + + let node2_pubkey = node_info(node2_addr).await.pubkey; + + fund_and_create_utxos(node1_addr, None).await; + fund_and_create_utxos(node2_addr, None).await; + + // node1 issues a CFA asset with a media file attached + let asset = issue_asset_cfa(node1_addr, Some(file_path)).await; + let digest = asset.media.unwrap().digest; + + // sanity: node2 does not know the media yet + let payload = GetAssetMediaRequest { + digest: digest.clone(), + }; + let res = reqwest::Client::new() + .post(format!("http://{node2_addr}/getassetmedia")) + .json(&payload) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::BAD_REQUEST); + + // open a colored channel node1 -> node2 (this sends the consignment + media over p2p) + let channel = open_channel( + node1_addr, + &node2_pubkey, + Some(NODE2_PEER_PORT), + None, + None, + Some(600), + Some(&asset.asset_id), + ) + .await; + + // node2 didn't know the asset, so it asked for the media (accept_channel known_asset = false) + assert!(!counterparty_knows_asset( + &test_dir_node1, + &channel.channel_id + )); + + // the acceptor now has the media + let media_hex = get_asset_media(node2_addr, &digest).await; + let media_bytes = hex_str_to_vec(&media_hex).unwrap(); + let mut buf_reader = tokio::io::BufReader::new(tokio::fs::File::open(file_path).await.unwrap()); + let mut file_bytes = Vec::new(); + buf_reader.read_to_end(&mut file_bytes).await.unwrap(); + assert_eq!(media_bytes, file_bytes); + + // node2 now knows the asset, so on a second channel for the same asset it reports known_asset + // in accept_channel and node1 skips sending the media again + let channel = open_channel( + node1_addr, + &node2_pubkey, + Some(NODE2_PEER_PORT), + None, + None, + Some(600), + Some(&asset.asset_id), + ) + .await; + assert!(counterparty_knows_asset( + &test_dir_node1, + &channel.channel_id + )); + + // and the media is still there and intact + let media_hex = get_asset_media(node2_addr, &digest).await; + assert_eq!(hex_str_to_vec(&media_hex).unwrap(), file_bytes); +} + +// read the counterparty_knows_asset flag the node recorded in the channel's RgbInfo when it +// received accept_channel +fn counterparty_knows_asset(test_dir_node: &str, channel_id: &str) -> bool { + let db_path = get_db_path(&PathBuf::from(test_dir_node)); + let connection_string = format!("sqlite:{}?mode=rwc", db_path.display()); + let mut opt = sea_orm::ConnectOptions::new(connection_string); + opt.max_connections(1); + let db = crate::runtime::block_on(sea_orm::Database::connect(opt)).expect("connect to test db"); + let kv_store = crate::kv_store::SeaOrmKvStore::from_connection(Arc::new(db)); + kv_store + .read_rgb_channel_info(channel_id, true) + .expect("channel info in KVStore") + .counterparty_knows_asset +} diff --git a/src/test/openchannel_no_indexer.rs b/src/test/openchannel_no_indexer.rs index 302f0838..8e1a18fb 100644 --- a/src/test/openchannel_no_indexer.rs +++ b/src/test/openchannel_no_indexer.rs @@ -45,16 +45,8 @@ async fn openchannel_no_indexer() { let t_0 = OffsetDateTime::now_utc(); 'outer: loop { tokio::time::sleep(std::time::Duration::from_secs(1)).await; - let file = File::open( - PathBuf::from(node_dir) - .join(LDK_DIR) - .join(LOGS_DIR) - .join(LDK_LOGS_FILE), - ) - .unwrap(); - let reader = BufReader::new(file); - for line in reader.lines() { - if line.unwrap().contains("Failed to connect to indexer") { + for line in ldk_log_lines(node_dir) { + if line.contains("Failed to connect to indexer") { break 'outer; } } diff --git a/src/test/out_of_band.rs b/src/test/out_of_band.rs new file mode 100644 index 00000000..048d57cd --- /dev/null +++ b/src/test/out_of_band.rs @@ -0,0 +1,276 @@ +use super::*; + +const TEST_DIR_BASE: &str = "tmp/out_of_band/"; + +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn out_of_band() { + initialize(); + + let test_dir_node1 = format!("{TEST_DIR_BASE}node1"); + let test_dir_node2 = format!("{TEST_DIR_BASE}node2"); + let (node1_addr, _) = start_node(&test_dir_node1, NODE1_PEER_PORT, false).await; + let (node2_addr, _) = start_node(&test_dir_node2, NODE2_PEER_PORT, false).await; + + fund_and_create_utxos(node1_addr, None).await; + fund_and_create_utxos(node2_addr, None).await; + + let asset_id = issue_asset_nia(node1_addr).await.asset_id; + + // receiver creates a blind invoice with no transport endpoints (out-of-band exchange) + let recipient_id = rgb_invoice_oob(node2_addr, None, Some(Assignment::Fungible(400)), false) + .await + .recipient_id; + + // sender pays the invoice out-of-band (empty transport endpoints); donation is false so the + // batch waits for the counterparty ACK before being broadcast + let recipient_map = HashMap::from([( + asset_id.clone(), + vec![Recipient { + recipient_id: recipient_id.clone(), + witness_data: None, + assignment: Assignment::Fungible(400), + transport_endpoints: vec![], + }], + )]); + let txid = send_assets(node1_addr, recipient_map, false).await; + assert!(!txid.is_empty()); + + // sender fetches the consignment it wrote, to hand it to the receiver out-of-band + let consignment_hex = get_consignment(node1_addr, &asset_id, &txid).await; + let consignment_bytes = hex_str_to_vec(&consignment_hex).unwrap(); + assert!(!consignment_bytes.is_empty()); + + // receiver processes the out-of-band consignment: the transfer moves to WaitingBroadcast, + // leaving the ACK to be communicated out-of-band + let refreshed = provide_out_of_band_consignment(node2_addr, consignment_bytes, vec![]).await; + assert_eq!(refreshed.transfers.len(), 1); + assert!(refreshed.transfers.values().all(|t| { + matches!(t.updated_status, Some(TransferStatus::WaitingBroadcast)) && t.failure.is_none() + })); + + // the sender's outgoing transfer waits for the counterparty ACK + let sender_send_transfer = list_transfers(node1_addr, &asset_id) + .await + .into_iter() + .find(|t| t.recipient_id.as_deref() == Some(recipient_id.as_str())) + .expect("sender should have a send transfer for the recipient"); + assert_eq!( + sender_send_transfer.status, + TransferStatus::WaitingCounterparty + ); + + // sender records the ACK: being the only recipient, this completes the batch and broadcasts it + let ack = provide_out_of_band_ack(node1_addr, &recipient_id).await; + let operation = ack + .operation + .expect("recording the last recipient's ACK should complete and broadcast the batch"); + assert_eq!(operation.txid, txid); + + // mine and refresh both sides to settle + mine(false); + refresh_transfers(node2_addr).await; + refresh_transfers(node1_addr).await; + + assert_eq!(asset_balance_spendable(node1_addr, &asset_id).await, 600); + assert_eq!(asset_balance_spendable(node2_addr, &asset_id).await, 400); + + // ACKing a transfer that is no longer WaitingCounterparty is a client error + let res = provide_out_of_band_ack_res(node1_addr, &recipient_id).await; + check_response_is_nok( + res, + reqwest::StatusCode::BAD_REQUEST, + "Cannot provide out-of-band ACK", + "CannotProvideOutOfBandAck", + ) + .await; +} + +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn out_of_band_media() { + initialize(); + + let file_path = "README.md"; + + let test_dir_node1 = format!("{TEST_DIR_BASE}media/node1"); + let test_dir_node2 = format!("{TEST_DIR_BASE}media/node2"); + let (node1_addr, _) = start_node(&test_dir_node1, NODE1_PEER_PORT, false).await; + let (node2_addr, _) = start_node(&test_dir_node2, NODE2_PEER_PORT, false).await; + + fund_and_create_utxos(node1_addr, None).await; + fund_and_create_utxos(node2_addr, None).await; + + // issue a CFA asset with media on the sender + let asset = issue_asset_cfa(node1_addr, Some(file_path)).await; + let asset_id = asset.asset_id; + let media_digest = asset.media.unwrap().digest; + + // receiver creates a blind invoice with no transport endpoints (out-of-band exchange) + let recipient_id = rgb_invoice_oob(node2_addr, None, Some(Assignment::Fungible(400)), false) + .await + .recipient_id; + + // sender pays the invoice out-of-band (empty transport endpoints) + let recipient_map = HashMap::from([( + asset_id.clone(), + vec![Recipient { + recipient_id: recipient_id.clone(), + witness_data: None, + assignment: Assignment::Fungible(400), + transport_endpoints: vec![], + }], + )]); + let txid = send_assets(node1_addr, recipient_map, false).await; + assert!(!txid.is_empty()); + + // sender fetches the consignment and the media bytes to hand to the receiver out-of-band + let consignment_hex = get_consignment(node1_addr, &asset_id, &txid).await; + let consignment_bytes = hex_str_to_vec(&consignment_hex).unwrap(); + let media_hex = get_asset_media(node1_addr, &media_digest).await; + let media_bytes = hex_str_to_vec(&media_hex).unwrap(); + assert!(!media_bytes.is_empty()); + + // receiver processes the out-of-band consignment together with the media file + let refreshed = + provide_out_of_band_consignment(node2_addr, consignment_bytes, vec![media_bytes.clone()]) + .await; + assert_eq!(refreshed.transfers.len(), 1); + assert!(refreshed.transfers.values().all(|t| { + matches!(t.updated_status, Some(TransferStatus::WaitingBroadcast)) && t.failure.is_none() + })); + + // the receiver has resolved the media from the provided file (same digest and bytes) + let received_media_hex = get_asset_media(node2_addr, &media_digest).await; + assert_eq!(hex_str_to_vec(&received_media_hex).unwrap(), media_bytes); + + // sender records the ACK to complete and broadcast the batch + let ack = provide_out_of_band_ack(node1_addr, &recipient_id).await; + let operation = ack + .operation + .expect("recording the last recipient's ACK should complete and broadcast the batch"); + assert_eq!(operation.txid, txid); + + // mine and refresh both sides to settle + mine(false); + refresh_transfers(node2_addr).await; + refresh_transfers(node1_addr).await; + + assert_eq!(asset_balance_spendable(node1_addr, &asset_id).await, 1600); + assert_eq!(asset_balance_spendable(node2_addr, &asset_id).await, 400); +} + +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn out_of_band_fail() { + initialize(); + + let test_dir_node1 = format!("{TEST_DIR_BASE}fail/node1"); + let (node1_addr, _) = start_node(&test_dir_node1, NODE1_PEER_PORT, false).await; + + let post_form = |form: reqwest::multipart::Form| async move { + reqwest::Client::new() + .post(format!("http://{node1_addr}/provideoutofbandconsignment")) + .multipart(form) + .send() + .await + .unwrap() + }; + + let post_raw = |content_type: &'static str, body: &'static str| async move { + reqwest::Client::new() + .post(format!("http://{node1_addr}/provideoutofbandconsignment")) + .header(reqwest::header::CONTENT_TYPE, content_type) + .body(body) + .send() + .await + .unwrap() + }; + + // body is not multipart at all: the Multipart extractor rejects it before the handler runs + let res = post_raw("application/json", "{}").await; + check_response_is_nok( + res, + reqwest::StatusCode::BAD_REQUEST, + "Invalid request", + "InvalidRequest", + ) + .await; + + // content-type declares a multipart boundary the body doesn't honor: parsing the (corrupt) + // stream fails while reading fields, which is reported as a missing consignment + let res = post_raw( + "multipart/form-data; boundary=boundary", + "not a valid multipart body", + ) + .await; + check_response_is_nok( + res, + reqwest::StatusCode::BAD_REQUEST, + "Consignment file has not been provided", + "ConsignmentFileNotProvided", + ) + .await; + + // no multipart field at all: the consignment is missing + let res = post_form(reqwest::multipart::Form::new()).await; + check_response_is_nok( + res, + reqwest::StatusCode::BAD_REQUEST, + "Consignment file has not been provided", + "ConsignmentFileNotProvided", + ) + .await; + + // only media fields, no consignment field + let form = reqwest::multipart::Form::new() + .part("media", reqwest::multipart::Part::bytes(vec![1, 2, 3])); + let res = post_form(form).await; + check_response_is_nok( + res, + reqwest::StatusCode::BAD_REQUEST, + "Consignment file has not been provided", + "ConsignmentFileNotProvided", + ) + .await; + + // empty consignment field + let form = reqwest::multipart::Form::new() + .part("file", reqwest::multipart::Part::bytes(Vec::::new())); + let res = post_form(form).await; + check_response_is_nok( + res, + reqwest::StatusCode::BAD_REQUEST, + "Consignment file is empty", + "ConsignmentFileEmpty", + ) + .await; + + // non-empty consignment but an empty media file + let form = reqwest::multipart::Form::new() + .part("file", reqwest::multipart::Part::bytes(vec![1, 2, 3])) + .part("media", reqwest::multipart::Part::bytes(Vec::::new())); + let res = post_form(form).await; + check_response_is_nok( + res, + reqwest::StatusCode::BAD_REQUEST, + "Media file is empty", + "MediaFileEmpty", + ) + .await; + + // non-empty consignment that rgb-lib cannot parse: reported as an invalid consignment + let form = reqwest::multipart::Form::new() + .part("file", reqwest::multipart::Part::bytes(vec![1, 2, 3])); + let res = post_form(form).await; + check_response_is_nok( + res, + reqwest::StatusCode::BAD_REQUEST, + "Invalid consignment", + "InvalidConsignment", + ) + .await; +} diff --git a/src/test/pagination_filters.rs b/src/test/pagination_filters.rs index 47066b7c..3b759762 100644 --- a/src/test/pagination_filters.rs +++ b/src/test/pagination_filters.rs @@ -305,9 +305,18 @@ async fn by_txid() { .await .is_empty()); - // neither asset_id nor txid is a bad request + // an asset-less filter returns only transfers not tied to an asset. A blind receive naming no + // asset stays untied, so the filter has something to find and the check below is not vacuous + let untied = rgb_invoice(node1_addr, None, false).await; + let no_asset = list_transfers_no_asset(node1_addr).await; + assert!(no_asset + .iter() + .any(|t| t.recipient_id.as_deref() == Some(untied.recipient_id.as_str()))); + assert!(no_asset.iter().all(|t| !all.iter().any(|a| a.idx == t.idx))); + + // an unnarrowed filter with no txid is a bad request let payload = ListTransfersRequest { - asset_id: None, + asset_filter: AssetFilter::AnyOrNone, txid: None, index_offset: None, max_transfers: None, @@ -324,7 +333,7 @@ async fn by_txid() { check_response_is_nok( res, reqwest::StatusCode::BAD_REQUEST, - "either asset_id or txid", + "either a narrowing asset_filter", "InvalidRequest", ) .await; diff --git a/src/test/payment.rs b/src/test/payment.rs index 632a03ac..1b0f8458 100644 --- a/src/test/payment.rs +++ b/src/test/payment.rs @@ -236,8 +236,9 @@ async fn success() { assert!(xfer_2.recipient_id.is_some()); assert!(xfer_2.receive_utxo.is_none()); assert!(xfer_2.change_utxo.is_some()); - assert!(xfer_2.expiration_timestamp.is_none()); - assert!(!xfer_2.transport_endpoints.is_empty()); + assert!(xfer_2.expiration_timestamp.is_some()); + // the channel funding consignment travels over the p2p link, so no proxy is involved + assert!(xfer_2.transport_endpoints.is_empty()); let xfer_3 = transfers.iter().find(|t| t.idx == 3).unwrap(); assert_eq!(xfer_3.status, TransferStatus::Settled); assert_eq!(xfer_3.kind, TransferKind::ReceiveWitness); @@ -246,8 +247,8 @@ async fn success() { assert!(xfer_3.recipient_id.is_some()); assert!(xfer_3.receive_utxo.is_some()); assert!(xfer_3.change_utxo.is_none()); - assert!(xfer_3.expiration_timestamp.is_none()); - assert!(!xfer_3.transport_endpoints.is_empty()); + assert!(xfer_3.expiration_timestamp.is_some()); + assert!(xfer_3.transport_endpoints.is_empty()); } #[serial_test::serial] diff --git a/src/test/push_asset_amount_above_chan_amt.rs b/src/test/push_asset_amount_above_chan_amt.rs new file mode 100644 index 00000000..cc33090c --- /dev/null +++ b/src/test/push_asset_amount_above_chan_amt.rs @@ -0,0 +1,65 @@ +use super::*; + +const TEST_DIR_BASE: &str = "tmp/push_asset_amount_above_chan_amt/"; + +/// A counterparty sending a `push_asset_amount` greater than the channel asset amount used to +/// underflow `remote_rgb_amount` on the acceptor, panicking its event handler. The acceptor must +/// reject the funding and stay alive. +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn push_asset_amount_above_chan_amt() { + initialize(); + + let test_dir_node1 = format!("{TEST_DIR_BASE}node1"); + let test_dir_node2 = format!("{TEST_DIR_BASE}node2"); + let (node1_addr, _) = start_node(&test_dir_node1, NODE1_PEER_PORT, false).await; + let (node2_addr, _) = start_node(&test_dir_node2, NODE2_PEER_PORT, false).await; + + fund_and_create_utxos(node1_addr, None).await; + fund_and_create_utxos(node2_addr, None).await; + + let node1_pubkey = node_info(node1_addr).await.pubkey; + let node2_pubkey = node_info(node2_addr).await.pubkey; + + let asset_id = issue_asset_nia(node1_addr).await.asset_id; + + // node1 puts more than the channel asset amount on the wire, bypassing the REST clamp that the + // push_asset_amount below satisfies + let _force_guard = NodeOverrideGuard::set(&FORCE_PUSH_ASSET_AMOUNT_ON_NODE, &node1_pubkey); + + // the open is expected to fail, so don't wait for the channel to get funded + open_channel_request_raw( + node1_addr, + &node2_pubkey, + Some(NODE2_PEER_PORT), + Some(100_000), + None, + Some(100), + Some(&asset_id), + Some(0), + None, + None, + None, + true, + true, + ) + .await + .unwrap(); + + // node2 rejects the funding, so node1's pending channel is discarded + let t_0 = OffsetDateTime::now_utc(); + loop { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + if list_channels(node1_addr).await.is_empty() { + break; + } + if (OffsetDateTime::now_utc() - t_0).as_seconds_f32() > 30.0 { + panic!("initiator channel was not discarded"); + } + } + + // with the underflow node2 would have panicked in its event handler + node_info(node2_addr).await; + assert!(list_channels(node2_addr).await.is_empty()); +} diff --git a/src/test/refuse_high_fees.rs b/src/test/refuse_high_fees.rs index 40c7259d..28e0021f 100644 --- a/src/test/refuse_high_fees.rs +++ b/src/test/refuse_high_fees.rs @@ -1,11 +1,3 @@ -use crate::disk::LDK_LOGS_FILE; -use crate::utils::LDK_DIR; -use std::{ - fs::File, - io::{BufRead, BufReader}, - path::PathBuf, -}; - use super::*; const TEST_DIR_BASE: &str = "tmp/refuse_high_fees/"; @@ -80,21 +72,9 @@ async fn refuse_high_fees() { ln_invoice(node3_addr, None, Some(&asset_id), Some(50), 900).await; let _ = send_payment_with_status(node1_addr, invoice, HTLCStatus::Failed).await; - let file = File::open( - PathBuf::from(test_dir_node1) - .join(LDK_DIR) - .join(LOGS_DIR) - .join(LDK_LOGS_FILE), - ) - .unwrap(); - let reader = BufReader::new(file); - let mut found_log = false; - for line in reader.lines() { - if line - .unwrap() - .contains("due to exceeding max total routing fee limit") - { + for line in ldk_log_lines(&test_dir_node1) { + if line.contains("due to exceeding max total routing fee limit") { found_log = true; break; } diff --git a/src/test/restore_legacy_backup.rs b/src/test/restore_legacy_backup.rs new file mode 100644 index 00000000..6d9c74fb --- /dev/null +++ b/src/test/restore_legacy_backup.rs @@ -0,0 +1,71 @@ +use super::*; + +use rln_migration::{Migrator, MigratorTrait}; + +use crate::backup::do_backup; +use crate::database::RlnDatabase; + +const TEST_DIR_BASE: &str = "tmp/restore_legacy_backup/"; + +const PASSWORD: &str = "password123"; +// Mnemonic record written by the magic-crypt encryption used before scrypt and +// XChaCha20Poly1305, encrypted with PASSWORD. +const LEGACY_MNEMONIC_RECORD: &str = "m6g98F3pqJ49njHK+XWpIBUKEuxv2Gy6Qlt8S900rkc7FA4aMG3hfRAUYYEOJfdUtwqDnImV8W7Rdy6zLcY8oBFbdRGdz9kXb5iRY1BPf81gPX0OEa7B4Cn/dsNNCMJ1"; + +// Build a wallet dir holding a mnemonic this version can no longer decrypt, then back it up. +fn legacy_backup(wallet_dir: &str, backup_path: &str) { + if Path::new(wallet_dir).exists() { + std::fs::remove_dir_all(wallet_dir).unwrap(); + } + std::fs::create_dir_all(wallet_dir).unwrap(); + let connection_string = format!("sqlite:{wallet_dir}/rln_db?mode=rwc"); + let db = crate::runtime::block_on(Database::connect(ConnectOptions::new(connection_string))) + .expect("db connection"); + crate::runtime::block_on(Migrator::up(&db, None)).expect("run migrations"); + RlnDatabase::new(db.clone()) + .save_mnemonic(LEGACY_MNEMONIC_RECORD.to_string()) + .expect("save legacy mnemonic"); + // flush any WAL, so the backed up files hold the record + crate::runtime::block_on(db.close()).expect("close db"); + + if Path::new(backup_path).exists() { + std::fs::remove_file(backup_path).unwrap(); + } + do_backup(Path::new(wallet_dir), Path::new(backup_path), PASSWORD).expect("backup"); +} + +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn restore_legacy_backup_leaves_the_node_initializable() { + initialize(); + + let wallet_dir = format!("{TEST_DIR_BASE}legacy_wallet"); + let backup_path = format!("{TEST_DIR_BASE}legacy_backup"); + legacy_backup(&wallet_dir, &backup_path); + + let test_dir_node1 = format!("{TEST_DIR_BASE}node1"); + let node1_addr = start_daemon(&test_dir_node1, NODE1_PEER_PORT, None, false).await; + + // the backup decrypts with the given password, but its mnemonic record does not + let payload = RestoreRequest { + backup_path: backup_path.clone(), + password: PASSWORD.to_string(), + }; + let res = reqwest::Client::new() + .post(format!("http://{node1_addr}/restore")) + .json(&payload) + .send() + .await + .unwrap(); + check_response_is_nok( + res, + reqwest::StatusCode::INTERNAL_SERVER_ERROR, + "The stored mnemonic is corrupted", + "CorruptedMnemonic", + ) + .await; + + // the failed restore must leave the node initializable through the API alone + init(node1_addr, PASSWORD, None).await; +} diff --git a/src/test/swap_assets_liquidity_both_ways.rs b/src/test/swap_assets_liquidity_both_ways.rs index e22674ae..247610fd 100644 --- a/src/test/swap_assets_liquidity_both_ways.rs +++ b/src/test/swap_assets_liquidity_both_ways.rs @@ -83,12 +83,10 @@ async fn swap_assets_liquidity_both_ways() { ) .await; - let swap_maker = get_swap(maker_addr, &maker_init_response.payment_hash, false).await; - assert_eq!(swap_maker.status, SwapStatus::Pending); wait_for_swap_status( taker_addr, &maker_init_response.payment_hash, - SwapStatus::Pending, + SwapStatus::Succeeded, ) .await; @@ -132,12 +130,10 @@ async fn swap_assets_liquidity_both_ways() { ) .await; - let swap_maker = get_swap(maker_addr, &maker_init_response.payment_hash, false).await; - assert_eq!(swap_maker.status, SwapStatus::Pending); wait_for_swap_status( taker_addr, &maker_init_response.payment_hash, - SwapStatus::Pending, + SwapStatus::Succeeded, ) .await; @@ -188,12 +184,10 @@ async fn swap_assets_liquidity_both_ways() { ) .await; - let swap_maker = get_swap(maker_addr, &maker_init_response.payment_hash, false).await; - assert_eq!(swap_maker.status, SwapStatus::Pending); wait_for_swap_status( taker_addr, &maker_init_response.payment_hash, - SwapStatus::Pending, + SwapStatus::Succeeded, ) .await; diff --git a/src/test/swap_reverse_same_channel.rs b/src/test/swap_reverse_same_channel.rs index c931a37e..9a053177 100644 --- a/src/test/swap_reverse_same_channel.rs +++ b/src/test/swap_reverse_same_channel.rs @@ -86,12 +86,10 @@ async fn swap_reverse_same_channel() { let swaps_maker = list_swaps(maker_addr).await; assert!(swaps_maker.taker.is_empty()); assert_eq!(swaps_maker.maker.len(), 1); - let swap_maker = swaps_maker.maker.first().unwrap(); - assert_eq!(swap_maker.status, SwapStatus::Pending); wait_for_swap_status( taker_addr, &maker_init_response.payment_hash, - SwapStatus::Pending, + SwapStatus::Succeeded, ) .await; @@ -166,12 +164,10 @@ async fn swap_reverse_same_channel() { let swaps_maker = list_swaps(maker_addr).await; assert_eq!(swaps_maker.maker.len(), 1); - let swap_maker = swaps_maker.maker.first().unwrap(); - assert_eq!(swap_maker.status, SwapStatus::Pending); wait_for_swap_status( taker_addr, &maker_init_response.payment_hash, - SwapStatus::Pending, + SwapStatus::Succeeded, ) .await; @@ -233,16 +229,10 @@ async fn swap_reverse_same_channel() { let swaps_maker = list_swaps(maker_addr).await; assert_eq!(swaps_maker.maker.len(), 2); - let swap_maker = swaps_maker - .maker - .iter() - .find(|s| s.payment_hash == maker_init_response.payment_hash) - .unwrap(); - assert_eq!(swap_maker.status, SwapStatus::Pending); wait_for_swap_status( taker_addr, &maker_init_response.payment_hash, - SwapStatus::Pending, + SwapStatus::Succeeded, ) .await; diff --git a/src/test/swap_roundtrip_assets.rs b/src/test/swap_roundtrip_assets.rs index 3ee93ed6..be1afa39 100644 --- a/src/test/swap_roundtrip_assets.rs +++ b/src/test/swap_roundtrip_assets.rs @@ -147,12 +147,10 @@ async fn swap_roundtrip_assets() { let swaps_maker = list_swaps(maker_addr).await; assert_eq!(swaps_maker.maker.len(), 1); - let swap_maker = swaps_maker.maker.first().unwrap(); - assert_eq!(swap_maker.status, SwapStatus::Pending); wait_for_swap_status( taker_addr, &maker_init_response.payment_hash, - SwapStatus::Pending, + SwapStatus::Succeeded, ) .await; diff --git a/src/test/swap_roundtrip_buy.rs b/src/test/swap_roundtrip_buy.rs index bef0a872..85c2b8ee 100644 --- a/src/test/swap_roundtrip_buy.rs +++ b/src/test/swap_roundtrip_buy.rs @@ -97,6 +97,11 @@ async fn swap_roundtrip_buy() { assert_eq!(swap_taker.status, SwapStatus::Waiting); println!("\nexecute swap"); + // the swap payment is routed in a circle, so the maker is also its final recipient: deferring + // the claim keeps both sides of the swap pending until the guard is dropped, instead of + // racing against a swap that can settle before the statuses below are checked + let maker_pubkey = node_info(maker_addr).await.pubkey; + let defer_guard = defer_payment_claimable(&maker_pubkey); maker_execute( maker_addr, maker_init_response.swapstring, @@ -104,11 +109,22 @@ async fn swap_roundtrip_buy() { node2_pubkey.clone(), ) .await; + wait_for_deferred_payment().await; let swaps_maker = list_swaps(maker_addr).await; assert_eq!(swaps_maker.maker.len(), 1); let swap_maker = swaps_maker.maker.first().unwrap(); assert_eq!(swap_maker.status, SwapStatus::Pending); + wait_for_swap_status( + taker_addr, + &maker_init_response.payment_hash, + SwapStatus::Pending, + ) + .await; + + // both sides of the swap have been observed pending: let the swap settle + drop(defer_guard); + wait_for_swap_status( taker_addr, &maker_init_response.payment_hash, diff --git a/src/test/swap_roundtrip_buy_same_channel.rs b/src/test/swap_roundtrip_buy_same_channel.rs index 77d83e51..f21ca2bb 100644 --- a/src/test/swap_roundtrip_buy_same_channel.rs +++ b/src/test/swap_roundtrip_buy_same_channel.rs @@ -86,12 +86,10 @@ async fn swap_roundtrip_buy_same_channel() { let swaps_maker = list_swaps(maker_addr).await; assert_eq!(swaps_maker.maker.len(), 1); - let swap_maker = swaps_maker.maker.first().unwrap(); - assert_eq!(swap_maker.status, SwapStatus::Pending); wait_for_swap_status( taker_addr, &maker_init_response.payment_hash, - SwapStatus::Pending, + SwapStatus::Succeeded, ) .await; diff --git a/src/test/swap_roundtrip_fail_whitelist.rs b/src/test/swap_roundtrip_fail_whitelist.rs index cbd2d331..c1e88ebd 100644 --- a/src/test/swap_roundtrip_fail_whitelist.rs +++ b/src/test/swap_roundtrip_fail_whitelist.rs @@ -90,17 +90,9 @@ async fn swap_fail_whitelist() { } // check the payment failed for the correct reason - let file = File::open( - PathBuf::from(test_dir_node1) - .join(LDK_DIR) - .join(LOGS_DIR) - .join(LDK_LOGS_FILE), - ) - .unwrap(); - let reader = BufReader::new(file); let mut found_log = false; - for line in reader.lines() { - if line.unwrap().contains("rejecting non-Waiting swap") { + for line in ldk_log_lines(&test_dir_node1) { + if line.contains("rejecting non-Waiting swap") { found_log = true; break; } diff --git a/src/test/swap_roundtrip_multihop_asset_asset.rs b/src/test/swap_roundtrip_multihop_asset_asset.rs index d992a756..e15ea2b6 100644 --- a/src/test/swap_roundtrip_multihop_asset_asset.rs +++ b/src/test/swap_roundtrip_multihop_asset_asset.rs @@ -187,12 +187,10 @@ async fn swap_roundtrip_multihop_asset_asset() { let swaps_maker = list_swaps(maker_addr).await; assert_eq!(swaps_maker.maker.len(), 1); - let swap_maker = swaps_maker.maker.first().unwrap(); - assert_eq!(swap_maker.status, SwapStatus::Pending); wait_for_swap_status( taker_addr, &maker_init_response.payment_hash, - SwapStatus::Pending, + SwapStatus::Succeeded, ) .await; diff --git a/src/test/swap_roundtrip_multihop_buy.rs b/src/test/swap_roundtrip_multihop_buy.rs index a3b13770..e899ae89 100644 --- a/src/test/swap_roundtrip_multihop_buy.rs +++ b/src/test/swap_roundtrip_multihop_buy.rs @@ -162,8 +162,6 @@ async fn swap_roundtrip_multihop_buy() { let swaps_maker = list_swaps(maker_addr).await; assert_eq!(swaps_maker.maker.len(), 1); - let swap_maker = swaps_maker.maker.first().unwrap(); - assert_eq!(swap_maker.status, SwapStatus::Pending); wait_for_swap_status( taker_addr, &maker_init_response.payment_hash, diff --git a/src/test/swap_roundtrip_multihop_sell.rs b/src/test/swap_roundtrip_multihop_sell.rs index a327f7fa..56c84a99 100644 --- a/src/test/swap_roundtrip_multihop_sell.rs +++ b/src/test/swap_roundtrip_multihop_sell.rs @@ -163,12 +163,10 @@ async fn swap_roundtrip_multihop_sell() { let swaps_maker = list_swaps(maker_addr).await; assert_eq!(swaps_maker.maker.len(), 1); - let swap_maker = swaps_maker.maker.first().unwrap(); - assert_eq!(swap_maker.status, SwapStatus::Pending); wait_for_swap_status( taker_addr, &maker_init_response.payment_hash, - SwapStatus::Pending, + SwapStatus::Succeeded, ) .await; diff --git a/src/test/swap_roundtrip_sell.rs b/src/test/swap_roundtrip_sell.rs index 55116015..b0dd0186 100644 --- a/src/test/swap_roundtrip_sell.rs +++ b/src/test/swap_roundtrip_sell.rs @@ -109,12 +109,10 @@ async fn swap_roundtrip_sell() { let swaps_maker = list_swaps(maker_addr).await; assert_eq!(swaps_maker.maker.len(), 1); - let swap_maker = swaps_maker.maker.first().unwrap(); - assert_eq!(swap_maker.status, SwapStatus::Pending); wait_for_swap_status( taker_addr, &maker_init_response.payment_hash, - SwapStatus::Pending, + SwapStatus::Succeeded, ) .await; diff --git a/src/test/transaction_sync.rs b/src/test/transaction_sync.rs new file mode 100644 index 00000000..8cf7f6f9 --- /dev/null +++ b/src/test/transaction_sync.rs @@ -0,0 +1,157 @@ +use super::*; + +#[cfg(feature = "electrum")] +const TEST_DIR_BASE_ELECTRUM: &str = "tmp/transaction_sync_electrum/"; +#[cfg(feature = "esplora")] +const TEST_DIR_BASE_ESPLORA: &str = "tmp/transaction_sync_esplora/"; + +// send `invoice` from `node_address`, retrying while the payer has not yet found a route: the only +// route to the payee is multihop and is discovered through gossip, whose channel-announcement UTXO +// lookup goes through the indexer +async fn pay_retrying_route(node_address: SocketAddr, invoice: String) -> String { + let t_0 = OffsetDateTime::now_utc(); + loop { + let payload = SendPaymentRequest { + invoice: invoice.clone(), + amt_msat: None, + asset_id: None, + asset_amount: None, + }; + let res = reqwest::Client::new() + .post(format!("http://{node_address}/sendpayment")) + .json(&payload) + .send() + .await + .unwrap(); + if res.status().is_success() { + let resp: SendPaymentResponse = res.json().await.unwrap(); + // TODO: remove unwrap once RGB offers are enabled + return resp.payment_hash.unwrap(); + } + if (OffsetDateTime::now_utc() - t_0).as_seconds_f32() > 60.0 { + panic!("multihop route to the payee never became available"); + } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } +} + +// `ln_indexer_url` selects the indexer LDK syncs against, which can differ from the one the RGB +// wallet uses +async fn transaction_sync_roundtrip(test_dir_base: &str, ln_indexer_url: String) { + initialize(); + + let test_dir_node1 = format!("{test_dir_base}node1"); + let test_dir_node2 = format!("{test_dir_base}node2"); + let test_dir_node3 = format!("{test_dir_base}node3"); + + let ldk_chain_sync = || LdkChainSync::TransactionSync { + indexer_url: ln_indexer_url.clone(), + }; + + let (node1_addr, _) = + start_node_with(&test_dir_node1, NODE1_PEER_PORT, false, ldk_chain_sync()).await; + let (node2_addr, _) = + start_node_with(&test_dir_node2, NODE2_PEER_PORT, false, ldk_chain_sync()).await; + let (node3_addr, _) = + start_node_with(&test_dir_node3, NODE3_PEER_PORT, false, ldk_chain_sync()).await; + + fund_and_create_utxos(node1_addr, None).await; + fund_and_create_utxos(node2_addr, None).await; + fund_and_create_utxos(node3_addr, None).await; + + let asset_id = issue_asset_nia(node1_addr).await.asset_id; + + let node1_pubkey = node_info(node1_addr).await.pubkey; + let node2_pubkey = node_info(node2_addr).await.pubkey; + let node3_pubkey = node_info(node3_addr).await.pubkey; + + // give node2 some asset so it can fund the second channel + let recipient_id = rgb_invoice(node2_addr, None, false).await.recipient_id; + send_asset( + node1_addr, + &asset_id, + Assignment::Fungible(400), + recipient_id, + None, + ) + .await; + mine(false); + refresh_transfers(node2_addr).await; + refresh_transfers(node1_addr).await; + assert_eq!(asset_balance_spendable(node1_addr, &asset_id).await, 600); + + // open two announced asset channels forming the node1 -> node2 -> node3 path + let channel_12 = open_channel( + node1_addr, + &node2_pubkey, + Some(NODE2_PEER_PORT), + None, + Some(3500000), + Some(500), + Some(&asset_id), + ) + .await; + let _channel_23 = open_channel( + node2_addr, + &node3_pubkey, + Some(NODE3_PEER_PORT), + None, + Some(3500000), + Some(300), + Some(&asset_id), + ) + .await; + + // multihop RGB payment node1 -> node3, routed through node2: as the far channel is public, + // node1 has no route hint for it and must resolve the route from gossip, verifying + // node2 -> node3's funding output through the indexer + let LNInvoiceResponse { invoice } = + ln_invoice(node3_addr, None, Some(&asset_id), Some(50), 900).await; + let payment_hash = pay_retrying_route(node1_addr, invoice).await; + wait_for_ln_payment(node1_addr, &payment_hash, HTLCStatus::Succeeded).await; + + wait_for_ln_balance(node1_addr, &asset_id, 450).await; + wait_for_ln_balance(node3_addr, &asset_id, 50).await; + + // restart all nodes: they must sync to the chain tip via the indexer and re-establish their + // channels + shutdown(&[node1_addr, node2_addr, node3_addr]).await; + let (node1_addr, _) = + start_node_with(&test_dir_node1, NODE1_PEER_PORT, true, ldk_chain_sync()).await; + let (node2_addr, _) = + start_node_with(&test_dir_node2, NODE2_PEER_PORT, true, ldk_chain_sync()).await; + let (node3_addr, _) = + start_node_with(&test_dir_node3, NODE3_PEER_PORT, true, ldk_chain_sync()).await; + + wait_for_usable_channels(node1_addr, 1).await; + wait_for_usable_channels(node2_addr, 2).await; + wait_for_usable_channels(node3_addr, 1).await; + wait_for_ln_balance(node1_addr, &asset_id, 450).await; + wait_for_ln_balance(node3_addr, &asset_id, 50).await; + + // cooperatively close the node1 -> node2 channel and check the asset returns on-chain to both + // the initiating and the counterparty node + close_channel(node2_addr, &channel_12.channel_id, &node1_pubkey, false).await; + wait_for_balance(node1_addr, &asset_id, 550).await; + wait_for_balance(node2_addr, &asset_id, 150).await; +} + +#[cfg(feature = "electrum")] +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn transaction_sync_electrum() { + transaction_sync_roundtrip(TEST_DIR_BASE_ELECTRUM, ELECTRUM_URL_REGTEST.to_string()).await; +} + +// point LDK at a dedicated esplora source while the RGB wallet keeps using electrum +#[cfg(feature = "esplora")] +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn transaction_sync_esplora() { + let _esplora_sync = EsploraSyncGuard::set(); + initialize(); + start_esplora_profile().await; + transaction_sync_roundtrip(TEST_DIR_BASE_ESPLORA, ESPLORA_URL_REGTEST.to_string()).await; +} diff --git a/src/test/tripwire_legacy_colored_channel.rs b/src/test/tripwire_legacy_colored_channel.rs new file mode 100644 index 00000000..6daae61a --- /dev/null +++ b/src/test/tripwire_legacy_colored_channel.rs @@ -0,0 +1,242 @@ +use super::*; + +use lightning::util::ser::{BigSize, Readable, Writeable}; + +const TEST_DIR_BASE: &str = "tmp/tripwire_legacy_colored_channel/"; + +fn read_bigsize(data: &[u8]) -> Option<(u64, usize)> { + let mut cursor = data; + let before = cursor.len(); + let v = BigSize::read(&mut cursor).ok()?; + Some((v.0, before - cursor.len())) +} + +// (type, start offset, end offset) of a TLV record within a stream's records region. +type Record = (u64, usize, usize); + +fn parse_records(data: &[u8]) -> Option> { + let mut records = Vec::new(); + let mut pos = 0usize; + let mut last_type: Option = None; + while pos < data.len() { + let start = pos; + let (typ, tn) = read_bigsize(&data[pos..])?; + pos += tn; + let (len, ln) = read_bigsize(&data[pos..])?; + pos += ln; + pos = pos.checked_add(len as usize)?; + if pos > data.len() || typ > 255 { + return None; + } + if let Some(lt) = last_type { + if typ <= lt { + return None; + } + } + last_type = Some(typ); + records.push((typ, start, pos)); + } + (pos == data.len()).then_some(records) +} + +fn bigsize_bytes(v: u64) -> Vec { + let mut out = Vec::new(); + BigSize(v).write(&mut out).unwrap(); + out +} + +/// Rewrite the persisted `ChannelManager` blob so its single channel carries the pre-sync +/// colored-channel marker: a legacy TLV record at type 71 in the `ChannelContext` stream, with no +/// `rgb_asset` (type 73). This reproduces exactly what a pre-sync build wrote for a colored +/// channel; reading it must now hit the tripwire (`DangerousValue`) instead of silently dropping +/// the asset. The base channel is non-colored, so it has neither 71 nor 73 to begin with. +fn splice_legacy_marker(blob: &[u8], counterparty_pubkey: &[u8]) -> Vec { + // Manager layout up to the channels: [ver:2][chain_hash:32][height:4][block_hash:32] + // [num_channels:u64:8]. First (only) channel begins at 78. + const FIRST_CHANNEL: usize = 78; + + // After the channels come forward_htlcs(u64=0), claimable_payments(u64=0), + // serializable_peer_count(u64=1), then the peer's node_id (33 bytes). Anchor on that whole + // pattern; its start is the end of the channel serialization. + let mut anchor = vec![0u8; 23]; + anchor.push(1); + anchor.extend_from_slice(counterparty_pubkey); + let channel_end = (FIRST_CHANNEL..=blob.len().saturating_sub(anchor.len())) + .find(|&i| &blob[i..i + anchor.len()] == anchor.as_slice()) + .unwrap_or_else(|| { + panic!("post-channel anchor (fwd=0,claimable=0,peers=1,node_id) not found in manager") + }); + + // Locate the ChannelContext TLV stream: the suffix [s..channel_end] framed as + // BigSize(len) + len bytes of ascending TLV records, ending exactly at channel_end. Any + // record-boundary suffix of an ascending stream is itself a valid framing, so pick the + // outermost one (the most records) — that is the real stream start. + let mut found: Option<(usize, Vec)> = None; + for s in FIRST_CHANNEL..channel_end { + let (len, plen) = match read_bigsize(&blob[s..channel_end]) { + Some(v) => v, + None => continue, + }; + if s + plen + len as usize != channel_end { + continue; + } + if let Some(records) = parse_records(&blob[s + plen..channel_end]) { + let better = found + .as_ref() + .map(|(_, r)| records.len() > r.len()) + .unwrap_or(true); + if better { + found = Some((s, records)); + } + } + } + let (stream_start, records) = + found.expect("ChannelContext TLV stream not located in manager blob"); + let (len, plen) = read_bigsize(&blob[stream_start..channel_end]).unwrap(); + let records_bytes = blob[stream_start + plen..channel_end].to_vec(); + + assert!( + records.iter().all(|&(t, _, _)| t != 71 && t != 73), + "base channel must be non-colored (no legacy marker, no rgb_asset)" + ); + + // Legacy marker record: type 71, value is a u16-length-prefixed UTF-8 endpoint, matching how + // the pre-sync `RgbTransport` serialized `consignment_endpoint`. + let endpoint = b"rpc://127.0.0.1:3000/json-rpc"; + let mut value = Vec::new(); + (endpoint.len() as u16).write(&mut value).unwrap(); + value.extend_from_slice(endpoint); + let mut record = bigsize_bytes(71); + record.extend_from_slice(&bigsize_bytes(value.len() as u64)); + record.extend_from_slice(&value); + + // Insert in ascending type order (before the first record of type > 71, else append). + let insert_at = records + .iter() + .find(|&&(t, _, _)| t > 71) + .map(|&(_, start, _)| start) + .unwrap_or(records_bytes.len()); + + let mut new_records = Vec::with_capacity(records_bytes.len() + record.len()); + new_records.extend_from_slice(&records_bytes[..insert_at]); + new_records.extend_from_slice(&record); + new_records.extend_from_slice(&records_bytes[insert_at..]); + + let new_len = len as usize + record.len(); + let mut out = Vec::with_capacity(blob.len() + record.len() + 4); + out.extend_from_slice(&blob[..stream_start]); + out.extend_from_slice(&bigsize_bytes(new_len as u64)); + out.extend_from_slice(&new_records); + out.extend_from_slice(&blob[channel_end..]); + out +} + +async fn rewrite_manager_with_legacy_marker(node_test_dir: &str, counterparty_pubkey: &[u8]) { + use sea_orm::{ConnectionTrait, Database, Statement}; + + let db = Database::connect(format!("sqlite:{node_test_dir}/rln_db?mode=rw")) + .await + .expect("open node db"); + let row = db + .query_one(Statement::from_string( + sea_orm::DatabaseBackend::Sqlite, + "SELECT value FROM kv_store WHERE primary_namespace = '' AND \ + secondary_namespace = '' AND key = 'manager'", + )) + .await + .expect("query manager row") + .expect("manager row must exist"); + let blob: Vec = row.try_get("", "value").expect("read manager value"); + + let spliced = splice_legacy_marker(&blob, counterparty_pubkey); + assert!(spliced.len() > blob.len(), "splice must grow the blob"); + + db.execute(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Sqlite, + "UPDATE kv_store SET value = ? WHERE primary_namespace = '' AND \ + secondary_namespace = '' AND key = 'manager'", + [spliced.into()], + )) + .await + .expect("rewrite manager row"); +} + +/// A channel persisted by a pre-sync build (colored marker at TLV 71, no `rgb_asset` at 73) must +/// refuse to deserialize on unlock, surfacing the `DangerousValue` tripwire, instead of silently +/// downgrading to a non-colored channel and stranding the RGB asset. +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 8)] +#[traced_test] +async fn pre_sync_colored_channel_unlock_is_refused() { + tokio::time::timeout( + std::time::Duration::from_secs(300), + pre_sync_colored_channel_unlock_is_refused_inner(), + ) + .await + .expect("pre_sync_colored_channel_unlock_is_refused timed out"); +} + +async fn pre_sync_colored_channel_unlock_is_refused_inner() { + initialize(); + + let test_dir_node1 = format!("{TEST_DIR_BASE}node1"); + let test_dir_node2 = format!("{TEST_DIR_BASE}node2"); + + let (node1_addr, node1_password) = start_node(&test_dir_node1, NODE1_PEER_PORT, false).await; + let (node2_addr, _) = start_node(&test_dir_node2, NODE2_PEER_PORT, false).await; + + fund_and_create_utxos(node1_addr, None).await; + fund_and_create_utxos(node2_addr, None).await; + + let node2_pubkey = node_info(node2_addr).await.pubkey; + connect_peer( + node1_addr, + &node2_pubkey, + &format!("127.0.0.1:{NODE2_PEER_PORT}"), + ) + .await; + + open_channel( + node1_addr, + &node2_pubkey, + Some(NODE2_PEER_PORT), + Some(600_000), + Some(100_000_000), + None, + None, + ) + .await; + wait_for_usable_channels(node1_addr, 1).await; + + shutdown(&[node1_addr, node2_addr]).await; + + let counterparty_pubkey = PublicKey::from_str(&node2_pubkey).unwrap().serialize(); + rewrite_manager_with_legacy_marker(&test_dir_node1, &counterparty_pubkey).await; + + let node1_addr = start_daemon_with_virtual_options( + &test_dir_node1, + NODE1_PEER_PORT, + None, + true, + false, + vec![], + ) + .await; + + let res = reqwest::Client::new() + .post(format!("http://{node1_addr}/unlock")) + .json(&unlock_req(&node1_password)) + .send() + .await + .expect("unlock must answer"); + assert_eq!(res.status(), reqwest::StatusCode::INTERNAL_SERVER_ERROR); + let body = res.json::().await.unwrap(); + assert_eq!(body.name, "FailedLoadingChannelState"); + assert!( + body.error.contains("DangerousValue"), + "error must trace to the tripwire: {}", + body.error + ); + + shutdown(&[node1_addr]).await; +} diff --git a/src/test/unlock_request_ldk_chain_sync.rs b/src/test/unlock_request_ldk_chain_sync.rs new file mode 100644 index 00000000..4f9e6f04 --- /dev/null +++ b/src/test/unlock_request_ldk_chain_sync.rs @@ -0,0 +1,136 @@ +use crate::core_types::LdkChainSync; +use crate::routes::UnlockRequest; + +#[cfg(feature = "transaction-sync")] +#[test] +fn deserialize_transaction_sync_mode() { + let json = serde_json::json!({ + "password": "p", + "ldk_chain_sync": { + "mode": "TransactionSync", + "config": { "indexer_url": "https://blockstream.info/testnet/api" }, + }, + "indexer_url": "https://blockstream.info/testnet/api", + "proxy_endpoint": "rpc://127.0.0.1:3000/json-rpc", + "announce_addresses": [], + }); + let req: UnlockRequest = serde_json::from_value(json).unwrap(); + assert!(matches!( + req.ldk_chain_sync, + LdkChainSync::TransactionSync { ref indexer_url } + if indexer_url == "https://blockstream.info/testnet/api" + )); + assert_eq!( + req.indexer_url.as_deref(), + Some("https://blockstream.info/testnet/api") + ); +} + +#[cfg(feature = "block-sync")] +#[test] +fn deserialize_block_sync_mode() { + let json = serde_json::json!({ + "password": "p", + "ldk_chain_sync": { + "mode": "BlockSync", + "config": { + "bitcoind_rpc_username": "user", + "bitcoind_rpc_password": "password", + "bitcoind_rpc_host": "localhost", + "bitcoind_rpc_port": 18443, + }, + }, + "indexer_url": "ssl://electrum.iriswallet.com:50013", + "announce_addresses": [], + }); + let req: UnlockRequest = serde_json::from_value(json).unwrap(); + assert!(matches!( + req.ldk_chain_sync, + LdkChainSync::BlockSync { + ref bitcoind_rpc_username, + bitcoind_rpc_port, + .. + } if bitcoind_rpc_username == "user" && bitcoind_rpc_port == 18443 + )); +} + +// bitcoind for LDK chain data and esplora for the RGB wallet is expressible: the sync mode and +// the wallet indexer are independent, so neither can make the other ambiguous +#[cfg(feature = "block-sync")] +#[test] +fn block_sync_with_esplora_indexer_is_accepted() { + let json = serde_json::json!({ + "password": "p", + "ldk_chain_sync": { + "mode": "BlockSync", + "config": { + "bitcoind_rpc_username": "user", + "bitcoind_rpc_password": "password", + "bitcoind_rpc_host": "localhost", + "bitcoind_rpc_port": 18443, + }, + }, + "indexer_url": "https://blockstream.info/testnet/api", + "announce_addresses": [], + }); + let req: UnlockRequest = serde_json::from_value(json).unwrap(); + assert!(matches!(req.ldk_chain_sync, LdkChainSync::BlockSync { .. })); + assert_eq!( + req.indexer_url.as_deref(), + Some("https://blockstream.info/testnet/api") + ); +} + +// the sync mode is mandatory: there is no implicit selection left to get wrong +#[cfg(feature = "block-sync")] +#[test] +fn deserialize_without_chain_sync_errors() { + let json = serde_json::json!({ + "password": "p", + "indexer_url": "ssl://electrum.iriswallet.com:50013", + "announce_addresses": [], + }); + assert!(serde_json::from_value::(json).is_err()); +} + +// a partially specified block-sync config is rejected by serde, not by a runtime check +#[cfg(feature = "block-sync")] +#[test] +fn deserialize_partial_block_sync_errors() { + let json = serde_json::json!({ + "password": "p", + "ldk_chain_sync": { + "mode": "BlockSync", + "config": { + "bitcoind_rpc_username": "user", + "bitcoind_rpc_password": "password", + "bitcoind_rpc_host": "localhost", + }, + }, + "indexer_url": "ssl://electrum.iriswallet.com:50013", + "announce_addresses": [], + }); + assert!(serde_json::from_value::(json).is_err()); +} + +// the indexer_url may be omitted from the request; it then comes from the `[chain]` config section +#[cfg(feature = "block-sync")] +#[test] +fn deserialize_without_indexer_url() { + let json = serde_json::json!({ + "password": "p", + "ldk_chain_sync": { + "mode": "BlockSync", + "config": { + "bitcoind_rpc_username": "user", + "bitcoind_rpc_password": "password", + "bitcoind_rpc_host": "localhost", + "bitcoind_rpc_port": 18443, + }, + }, + "indexer_url": null, + "announce_addresses": [], + }); + let req: UnlockRequest = serde_json::from_value(json).unwrap(); + assert!(req.indexer_url.is_none()); +} diff --git a/src/test/unlock_request_optional_bitcoind.rs b/src/test/unlock_request_optional_bitcoind.rs deleted file mode 100644 index 7594fcbd..00000000 --- a/src/test/unlock_request_optional_bitcoind.rs +++ /dev/null @@ -1,121 +0,0 @@ -use amplify::s; -use rgb_lib::BitcoinNetwork; - -use crate::core_types::UnlockRequest as CoreUnlockRequest; -use crate::error::APIError; -use crate::ldk::{select_chain_backend, ChainBackendSelection}; -use crate::routes::UnlockRequest; - -#[test] -fn deserialize_without_bitcoind_fields() { - let json = serde_json::json!({ - "password": "p", - "indexer_url": "https://blockstream.info/testnet/api", - "proxy_endpoint": "rpc://127.0.0.1:3000/json-rpc", - "announce_addresses": [], - }); - let req: UnlockRequest = serde_json::from_value(json).unwrap(); - assert!(req.bitcoind_rpc_username.is_none()); - assert!(req.bitcoind_rpc_password.is_none()); - assert!(req.bitcoind_rpc_host.is_none()); - assert!(req.bitcoind_rpc_port.is_none()); - assert_eq!( - req.indexer_url.as_deref(), - Some("https://blockstream.info/testnet/api") - ); -} - -#[test] -fn deserialize_with_bitcoind_fields() { - let json = serde_json::json!({ - "password": "p", - "bitcoind_rpc_username": "user", - "bitcoind_rpc_password": "password", - "bitcoind_rpc_host": "localhost", - "bitcoind_rpc_port": 18443, - "announce_addresses": [], - }); - let req: UnlockRequest = serde_json::from_value(json).unwrap(); - assert_eq!(req.bitcoind_rpc_username.as_deref(), Some("user")); - assert_eq!(req.bitcoind_rpc_port, Some(18443)); -} - -fn req(bitcoind: bool, indexer: Option<&str>) -> CoreUnlockRequest { - CoreUnlockRequest { - bitcoind_rpc_username: bitcoind.then(|| s!("u")), - bitcoind_rpc_password: bitcoind.then(|| s!("p")), - bitcoind_rpc_host: bitcoind.then(|| s!("h")), - bitcoind_rpc_port: bitcoind.then_some(18443), - indexer_url: indexer.map(str::to_string), - proxy_endpoint: None, - announce_addresses: vec![], - announce_alias: None, - gossip_source: None, - } -} - -#[test] -fn select_bitcoind_only_returns_bitcoind() { - let r = req(true, None); - assert!(matches!( - select_chain_backend(&r, BitcoinNetwork::Regtest), - Ok(ChainBackendSelection::Bitcoind { .. }) - )); -} - -#[test] -#[ignore = "rgb-lib's check_indexer_url probes the URL; needs a reachable testnet esplora endpoint"] -fn select_esplora_only_returns_esplora() { - let r = req(false, Some("https://blockstream.info/testnet/api")); - assert!(matches!( - select_chain_backend(&r, BitcoinNetwork::Testnet), - Ok(ChainBackendSelection::Esplora { .. }) - )); -} - -#[test] -fn select_neither_errors() { - let r = req(false, None); - assert!(matches!( - select_chain_backend(&r, BitcoinNetwork::Regtest), - Err(APIError::MissingChainBackend) - )); -} - -#[test] -#[ignore = "rgb-lib's check_indexer_url probes the URL; needs a reachable testnet esplora endpoint"] -fn select_both_esplora_errors() { - let r = req(true, Some("https://blockstream.info/testnet/api")); - assert!(matches!( - select_chain_backend(&r, BitcoinNetwork::Testnet), - Err(APIError::AmbiguousChainBackend) - )); -} - -#[test] -fn select_both_electrum_allowed() { - let r = req(true, Some("ssl://electrum.iriswallet.com:50013")); - assert!(matches!( - select_chain_backend(&r, BitcoinNetwork::Testnet), - Ok(ChainBackendSelection::Bitcoind { .. }) - )); -} - -#[test] -fn select_electrum_only_returns_electrum() { - let r = req(false, Some("ssl://electrum.iriswallet.com:50013")); - assert!(matches!( - select_chain_backend(&r, BitcoinNetwork::Testnet), - Ok(ChainBackendSelection::Electrum { .. }) - )); -} - -#[test] -fn select_partial_bitcoind_errors() { - let mut r = req(true, None); - r.bitcoind_rpc_host = None; - assert!(matches!( - select_chain_backend(&r, BitcoinNetwork::Regtest), - Err(APIError::InvalidIndexer(_)) - )); -} diff --git a/src/test/virtual_channels.rs b/src/test/virtual_channels.rs index fbe3c55f..8fc3fb82 100644 --- a/src/test/virtual_channels.rs +++ b/src/test/virtual_channels.rs @@ -1563,3 +1563,70 @@ async fn virtual_one_sat_htlc_routes_both_directions() { shutdown(&[host_node_address, client_node_address]).await; } + +#[serial_test::serial] +#[tokio::test] +#[traced_test] +async fn virtual_open_sends_asset_media_over_p2p() { + initialize(); + + let file_path = "README.md"; + let test_storage_root = format!("{TEST_DIR_BASE}media/"); + let host_node_peer_port = next_peer_port(); + let client_node_peer_port = next_peer_port(); + + let (host_node_address, _host_password) = start_node_with_virtual_options( + &format!("{test_storage_root}host_node"), + host_node_peer_port, + false, + true, + vec![], + ) + .await; + let host_node_info = node_info(host_node_address).await; + + fund_and_create_utxos(host_node_address, None).await; + let asset = issue_asset_cfa(host_node_address, Some(file_path)).await; + let digest = asset.media.unwrap().digest; + + let (client_node_address, _client_password) = start_node_with_virtual_options( + &format!("{test_storage_root}client_node"), + client_node_peer_port, + false, + true, + vec![bitcoin::secp256k1::PublicKey::from_str(&host_node_info.pubkey).unwrap()], + ) + .await; + let client_node_info = node_info(client_node_address).await; + + // the client does not know the media before the open + let res = reqwest::Client::new() + .post(format!("http://{client_node_address}/getassetmedia")) + .json(&GetAssetMediaRequest { + digest: digest.clone(), + }) + .send() + .await + .unwrap(); + assert_eq!(res.status(), reqwest::StatusCode::BAD_REQUEST); + + let channel = open_virtual_channel( + host_node_address, + &client_node_info.pubkey, + Some(client_node_peer_port), + Some(100_000), + Some(10_000_000), + Some(200), + Some(&asset.asset_id), + None, + ) + .await; + assert!(channel.ready); + + // the virtual open must have carried the media over the same p2p link as the consignment + let media_hex = get_asset_media(client_node_address, &digest).await; + let file_bytes = std::fs::read(file_path).unwrap(); + assert_eq!(hex_str_to_vec(&media_hex).unwrap(), file_bytes); + + shutdown(&[host_node_address, client_node_address]).await; +} diff --git a/src/test/vss.rs b/src/test/vss.rs index 88330312..aa6eba62 100644 --- a/src/test/vss.rs +++ b/src/test/vss.rs @@ -770,14 +770,48 @@ mod tests { .expect_err("fence must still be owned by b"); } + /// The "VSS fence broken" panic now takes the node down through the normal shutdown, which + /// ends in a fence release. That release must be a no-op: the fence it would delete belongs + /// to the instance that took the store over. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn vss_broken_fence_shutdown_leaves_the_new_owner_alone() { + if !vss_server_available() { + eprintln!("SKIP: VSS server not available at {VSS_URL}"); + return; + } + + let (signing_key, store_id) = generate_test_keys(); + + // A is the running node. + let store_a = + VssKvStore::new(VSS_URL.to_string(), store_id.clone(), signing_key).expect("store a"); + store_a.acquire_fence().expect("a acquires fence"); + + // An operator moves the store to B while A is still up: A's next fence check panics. + let store_b = + VssKvStore::new(VSS_URL.to_string(), store_id.clone(), signing_key).expect("store b"); + store_b.delete_fence().expect("operator clears the fence"); + store_b.acquire_fence().expect("b takes over"); + + // A's panic-driven shutdown reaches the fence release. + store_a + .release_fence_if_owned() + .expect("dispossessed release must not error"); + + // B still owns the store. + let store_c = VssKvStore::new(VSS_URL.to_string(), store_id, signing_key).expect("store c"); + store_c + .acquire_fence() + .expect_err("fence must still be owned by b"); + } + /// A failed unlock must roll back what it acquired: the VSS fence is /// released and the changing-state flag is cleared, so a retry (failed or /// successful) is never wedged behind a stranded fence. fn select_electrum_backend(payload: &mut crate::routes::UnlockRequest) { - payload.bitcoind_rpc_username = None; - payload.bitcoind_rpc_password = None; - payload.bitcoind_rpc_host = None; - payload.bitcoind_rpc_port = None; + payload.ldk_chain_sync = crate::core_types::LdkChainSync::TransactionSync { + indexer_url: crate::utils::ELECTRUM_URL_REGTEST.to_string(), + }; } async fn unlock_with_electrum_backend(node_address: std::net::SocketAddr, password: &str) { diff --git a/src/test/vss_unreachable_openchannel.rs b/src/test/vss_unreachable_openchannel.rs index 85e159f9..e2c8c4cf 100644 --- a/src/test/vss_unreachable_openchannel.rs +++ b/src/test/vss_unreachable_openchannel.rs @@ -5,10 +5,9 @@ const TEST_DIR_BASE: &str = "tmp/vss_unreachable_openchannel/"; async fn unlock_electrum_only(node_address: SocketAddr, password: &str) { let payload = UnlockRequest { password: password.to_string(), - bitcoind_rpc_username: None, - bitcoind_rpc_password: None, - bitcoind_rpc_host: None, - bitcoind_rpc_port: None, + ldk_chain_sync: LdkChainSync::TransactionSync { + indexer_url: ELECTRUM_URL_REGTEST.to_string(), + }, indexer_url: Some(ELECTRUM_URL_REGTEST.to_string()), proxy_endpoint: Some(PROXY_ENDPOINT_LOCAL.to_string()), announce_addresses: vec![], diff --git a/src/test_utils.rs b/src/test_utils.rs index 40da334e..22816191 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -31,6 +31,10 @@ pub fn mock_locked_app_state() -> TestAppState { ldk_data_dir: path.join(".ldk"), logger: Arc::new(FilesystemLogger::new(path)), max_media_upload_size_mb: 1, + max_aggregated_media_size_per_channel_mb: + crate::rgb_file_transfer::MAX_MEDIA_MB_PER_CHANNEL, + max_pending_consignments: crate::rgb_file_transfer::MAX_PENDING_CONSIGNMENTS, + max_media_files_per_channel: crate::rgb_file_transfer::MAX_MEDIA_FILES_PER_CHANNEL, enable_virtual_channels_v0: false, virtual_peer_pubkeys: vec![], database: RwLock::new(Arc::new(database)), diff --git a/src/uniffi_api/examples/python-interop/manual_py_external_signer_e2e.py b/src/uniffi_api/examples/python-interop/manual_py_external_signer_e2e.py index 633d44c4..a9a69c0b 100644 --- a/src/uniffi_api/examples/python-interop/manual_py_external_signer_e2e.py +++ b/src/uniffi_api/examples/python-interop/manual_py_external_signer_e2e.py @@ -112,10 +112,12 @@ def make_node(storage_dir: Path, daemon_port: int, peer_port: int) -> rln.SdkNod def unlock_request(password: str) -> rln.SdkUnlockRequest: return rln.SdkUnlockRequest( password=password, - bitcoind_rpc_username="user", - bitcoind_rpc_password="password", - bitcoind_rpc_host="localhost", - bitcoind_rpc_port=18443, + ldk_chain_sync=rln.SdkLdkChainSync.BLOCK_SYNC( + bitcoind_rpc_username="user", + bitcoind_rpc_password="password", + bitcoind_rpc_host="localhost", + bitcoind_rpc_port=18443, + ), indexer_url="127.0.0.1:50001", proxy_endpoint=PROXY_ENDPOINT_LOCAL, announce_addresses=[], @@ -126,10 +128,7 @@ def unlock_with_attached_signer( node: rln.SdkNode, ): node.unlock_with_attached_external_signer( - "user", - "password", - "localhost", - 18443, + rln.SdkLdkChainSync.BLOCK_SYNC("user", "password", "localhost", 18443), "127.0.0.1:50001", PROXY_ENDPOINT_LOCAL, [], @@ -586,10 +585,7 @@ def _setup_mixed_asset_channel_with_payment( node_a.unlock(unlock_request(NODE_A_PASSWORD)) node_b.unlock_with_native_external_signer( signer, - "user", - "password", - "localhost", - 18443, + rln.SdkLdkChainSync.BLOCK_SYNC("user", "password", "localhost", 18443), "127.0.0.1:50001", PROXY_ENDPOINT_LOCAL, [], @@ -697,10 +693,7 @@ def run_regular_channel_flow_external_real(): node_b.init(NODE_B_PASSWORD, None) node_a.unlock_with_native_external_signer( signer, - "user", - "password", - "localhost", - 18443, + rln.SdkLdkChainSync.BLOCK_SYNC("user", "password", "localhost", 18443), "127.0.0.1:50001", PROXY_ENDPOINT_LOCAL, [], @@ -781,10 +774,7 @@ def run_regular_channel_flow_external_real(): # can lose enforcement state needed for subsequent commitment validation. node_a.unlock_with_native_external_signer( signer, - "user", - "password", - "localhost", - 18443, + rln.SdkLdkChainSync.BLOCK_SYNC("user", "password", "localhost", 18443), "127.0.0.1:50001", PROXY_ENDPOINT_LOCAL, [], @@ -1049,10 +1039,7 @@ def run_connection_loss_restore_real(): node_a.init_with_native_external_signer(signer) node_a.unlock_with_native_external_signer( signer, - "user", - "password", - "localhost", - 18443, + rln.SdkLdkChainSync.BLOCK_SYNC("user", "password", "localhost", 18443), "127.0.0.1:50001", PROXY_ENDPOINT_LOCAL, [], @@ -1187,10 +1174,7 @@ def run_restart_with_mismatched_signer_real(): node.init_with_native_external_signer(signer_a) node.unlock_with_native_external_signer( signer_a, - "user", - "password", - "localhost", - 18443, + rln.SdkLdkChainSync.BLOCK_SYNC("user", "password", "localhost", 18443), "127.0.0.1:50001", PROXY_ENDPOINT_LOCAL, [], @@ -1211,10 +1195,7 @@ def run_restart_with_mismatched_signer_real(): try: restarted.unlock_with_native_external_signer( signer_b, - "user", - "password", - "localhost", - 18443, + rln.SdkLdkChainSync.BLOCK_SYNC("user", "password", "localhost", 18443), "127.0.0.1:50001", PROXY_ENDPOINT_LOCAL, [], diff --git a/src/uniffi_api/examples/python-interop/manual_py_full_n2n.py b/src/uniffi_api/examples/python-interop/manual_py_full_n2n.py index a152853a..f77a4e3c 100755 --- a/src/uniffi_api/examples/python-interop/manual_py_full_n2n.py +++ b/src/uniffi_api/examples/python-interop/manual_py_full_n2n.py @@ -86,10 +86,12 @@ def init_if_needed(node: rln.SdkNode, password: str, name: str): def unlock_if_needed(node: rln.SdkNode, password: str, name: str): req = rln.SdkUnlockRequest( password=password, - bitcoind_rpc_username="user", - bitcoind_rpc_password="password", - bitcoind_rpc_host="localhost", - bitcoind_rpc_port=18443, + ldk_chain_sync=rln.SdkLdkChainSync.BLOCK_SYNC( + bitcoind_rpc_username="user", + bitcoind_rpc_password="password", + bitcoind_rpc_host="localhost", + bitcoind_rpc_port=18443, + ), indexer_url="127.0.0.1:50001", proxy_endpoint="rpc://127.0.0.1:3000/json-rpc", announce_addresses=[], diff --git a/src/uniffi_api/examples/python-interop/manual_py_virtual_channels_sdk.py b/src/uniffi_api/examples/python-interop/manual_py_virtual_channels_sdk.py index bdc8dc98..027b80f9 100755 --- a/src/uniffi_api/examples/python-interop/manual_py_virtual_channels_sdk.py +++ b/src/uniffi_api/examples/python-interop/manual_py_virtual_channels_sdk.py @@ -79,10 +79,12 @@ def init_if_needed(node: rln.SdkNode, password: str, name: str): def unlock_if_needed(node: rln.SdkNode, password: str, name: str): req = rln.SdkUnlockRequest( password=password, - bitcoind_rpc_username="user", - bitcoind_rpc_password="password", - bitcoind_rpc_host="localhost", - bitcoind_rpc_port=18443, + ldk_chain_sync=rln.SdkLdkChainSync.BLOCK_SYNC( + bitcoind_rpc_username="user", + bitcoind_rpc_password="password", + bitcoind_rpc_host="localhost", + bitcoind_rpc_port=18443, + ), indexer_url="127.0.0.1:50001", proxy_endpoint="rpc://127.0.0.1:3000/json-rpc", announce_addresses=[], diff --git a/src/uniffi_api/mod.rs b/src/uniffi_api/mod.rs index 8b5149d3..a9348754 100644 --- a/src/uniffi_api/mod.rs +++ b/src/uniffi_api/mod.rs @@ -101,6 +101,12 @@ fn handle_from_request(request: SdkInitRequest) -> Result ldk_peer_listening_port: request.ldk_peer_listening_port, network, max_media_upload_size_mb: request.max_media_upload_size_mb, + // `SdkInitRequest` doesn't expose the p2p transfer limits yet; extending the FFI surface + // (and the mobile bindings) is a separate change, so embedders get the defaults. + max_aggregated_media_size_per_channel_mb: + crate::rgb_file_transfer::MAX_MEDIA_MB_PER_CHANNEL, + max_pending_consignments: crate::rgb_file_transfer::MAX_PENDING_CONSIGNMENTS, + max_media_files_per_channel: crate::rgb_file_transfer::MAX_MEDIA_FILES_PER_CHANNEL, root_public_key: None, enable_virtual_channels_v0: request.enable_virtual_channels_v0.unwrap_or(false), virtual_peer_pubkeys: request.virtual_peer_pubkeys.unwrap_or_default(), @@ -425,10 +431,7 @@ impl SdkNode { state, sdk::UnlockRequest { password: request.password, - bitcoind_rpc_username: request.bitcoind_rpc_username, - bitcoind_rpc_password: request.bitcoind_rpc_password, - bitcoind_rpc_host: request.bitcoind_rpc_host, - bitcoind_rpc_port: request.bitcoind_rpc_port, + ldk_chain_sync: request.ldk_chain_sync.into(), indexer_url: request.indexer_url, proxy_endpoint: request.proxy_endpoint, announce_addresses: request.announce_addresses, @@ -874,15 +877,35 @@ impl SdkNode { }) } - pub fn refreshtransfers(&self, request: SdkRefreshTransfersRequest) -> Result<(), RlnError> { + pub fn refreshtransfers( + &self, + request: SdkRefreshTransfersRequest, + ) -> Result { let state = self.handle.app_state(); - block_on_sdk(sdk::refresh_transfers( + let response = block_on_sdk(sdk::refresh_transfers( state, sdk::RefreshTransfersRequestData { skip_sync: request.skip_sync, }, ))?; - Ok(()) + Ok(SdkRefreshTransfersResponse { + transfers: response + .transfers + .into_iter() + .map(|(idx, t)| { + ( + idx, + SdkRefreshedTransfer { + updated_status: t.updated_status, + failure: t.failure.map(|f| SdkRefreshFailure { + name: f.name, + message: f.message, + }), + }, + ) + }) + .collect(), + }) } pub fn failtransfers( @@ -1350,6 +1373,8 @@ impl SdkNode { timestamp: resp.timestamp, asset_id, asset_amount: resp.asset_amount, + description: resp.description, + description_hash: resp.description_hash, payment_hash, payment_secret: resp.payment_secret, payee_pubkey, @@ -1419,6 +1444,7 @@ impl SdkNode { outpoint: u.utxo.outpoint, btc_amount: u.utxo.btc_amount, colorable: u.utxo.colorable, + exists: u.utxo.exists, }, rgb_allocations: u .rgb_allocations @@ -1561,10 +1587,7 @@ impl SdkNode { #[allow(clippy::too_many_arguments)] // Mirrors `UnlockRequest`; UniFFI keeps a flat argument list. pub fn unlock_with_attached_external_signer( &self, - bitcoind_rpc_username: Option, - bitcoind_rpc_password: Option, - bitcoind_rpc_host: Option, - bitcoind_rpc_port: Option, + ldk_chain_sync: SdkLdkChainSync, indexer_url: Option, proxy_endpoint: Option, announce_addresses: Vec, @@ -1575,10 +1598,7 @@ impl SdkNode { state, sdk::UnlockRequest { password: String::new(), - bitcoind_rpc_username, - bitcoind_rpc_password, - bitcoind_rpc_host, - bitcoind_rpc_port, + ldk_chain_sync: ldk_chain_sync.into(), indexer_url, proxy_endpoint, announce_addresses, @@ -1614,10 +1634,7 @@ impl SdkNode { pub fn unlock_with_native_external_signer( &self, signer: Arc, - bitcoind_rpc_username: Option, - bitcoind_rpc_password: Option, - bitcoind_rpc_host: Option, - bitcoind_rpc_port: Option, + ldk_chain_sync: SdkLdkChainSync, indexer_url: Option, proxy_endpoint: Option, announce_addresses: Vec, @@ -1625,10 +1642,7 @@ impl SdkNode { ) -> Result<(), RlnError> { self.attach_native_external_signer(signer.clone())?; self.unlock_with_attached_external_signer( - bitcoind_rpc_username, - bitcoind_rpc_password, - bitcoind_rpc_host, - bitcoind_rpc_port, + ldk_chain_sync, indexer_url, proxy_endpoint, announce_addresses, diff --git a/src/uniffi_api/state.rs b/src/uniffi_api/state.rs index 1afb443e..b6f10eaf 100644 --- a/src/uniffi_api/state.rs +++ b/src/uniffi_api/state.rs @@ -147,7 +147,6 @@ pub(crate) fn map_api_error(err: APIError) -> RlnError { | APIError::ChangingState | APIError::InsufficientAssets | APIError::InvalidIndexer(_) - | APIError::InvalidProxyEndpoint | APIError::InvalidProxyProtocol(_) | APIError::MaxFeeExceeded(_) | APIError::MinFeeNotMet(_) diff --git a/src/uniffi_api/tests.rs b/src/uniffi_api/tests.rs index 7860e386..326e493c 100644 --- a/src/uniffi_api/tests.rs +++ b/src/uniffi_api/tests.rs @@ -105,6 +105,10 @@ mod uniffi_smoke_tests { ldk_data_dir: tmp.path().join(".ldk"), logger: Arc::new(FilesystemLogger::new(tmp.path().to_path_buf())), max_media_upload_size_mb: 1, + max_aggregated_media_size_per_channel_mb: + crate::rgb_file_transfer::MAX_MEDIA_MB_PER_CHANNEL, + max_pending_consignments: crate::rgb_file_transfer::MAX_PENDING_CONSIGNMENTS, + max_media_files_per_channel: crate::rgb_file_transfer::MAX_MEDIA_FILES_PER_CHANNEL, enable_virtual_channels_v0: false, virtual_peer_pubkeys: vec![], database: RwLock::new(Arc::new(database)), diff --git a/src/uniffi_api/types.rs b/src/uniffi_api/types.rs index 2370c6c0..183087e1 100644 --- a/src/uniffi_api/types.rs +++ b/src/uniffi_api/types.rs @@ -390,6 +390,8 @@ pub struct DecodeLnInvoiceResponse { pub timestamp: u64, pub asset_id: Option, pub asset_amount: Option, + pub description: Option, + pub description_hash: Option, pub payment_hash: PaymentHash, pub payment_secret: String, pub payee_pubkey: Option, @@ -452,6 +454,7 @@ pub struct Utxo { pub outpoint: String, pub btc_amount: u64, pub colorable: bool, + pub exists: bool, } pub struct Unspent { @@ -495,12 +498,44 @@ pub struct InflateResponse { pub txid: Txid, } +/// How LDK follows the chain. Mirrors `core_types::LdkChainSync`, minus the cargo-feature +/// gating the generated bindings cannot express. +pub enum SdkLdkChainSync { + BlockSync { + bitcoind_rpc_username: String, + bitcoind_rpc_password: String, + bitcoind_rpc_host: String, + bitcoind_rpc_port: u16, + }, + TransactionSync { + indexer_url: String, + }, +} + +impl From for crate::core_types::LdkChainSync { + fn from(value: SdkLdkChainSync) -> Self { + match value { + SdkLdkChainSync::BlockSync { + bitcoind_rpc_username, + bitcoind_rpc_password, + bitcoind_rpc_host, + bitcoind_rpc_port, + } => Self::BlockSync { + bitcoind_rpc_username, + bitcoind_rpc_password, + bitcoind_rpc_host, + bitcoind_rpc_port, + }, + SdkLdkChainSync::TransactionSync { indexer_url } => { + Self::TransactionSync { indexer_url } + } + } + } +} + pub struct SdkUnlockRequest { pub password: String, - pub bitcoind_rpc_username: Option, - pub bitcoind_rpc_password: Option, - pub bitcoind_rpc_host: Option, - pub bitcoind_rpc_port: Option, + pub ldk_chain_sync: SdkLdkChainSync, pub indexer_url: Option, pub proxy_endpoint: Option, pub announce_addresses: Vec, @@ -579,6 +614,20 @@ pub struct SdkFailTransfersResponse { pub transfers_changed: bool, } +pub struct SdkRefreshFailure { + pub name: String, + pub message: String, +} + +pub struct SdkRefreshedTransfer { + pub updated_status: Option, + pub failure: Option, +} + +pub struct SdkRefreshTransfersResponse { + pub transfers: std::collections::HashMap, +} + pub struct SdkCreateUtxosRequest { pub up_to: bool, pub num: Option, diff --git a/src/utils.rs b/src/utils.rs index 327f0f53..68091897 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -20,7 +20,6 @@ use lightning::{ util::ser::{Writeable, Writer}, }; use lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription, Description}; -use magic_crypt::{new_magic_crypt, MagicCryptTrait}; use rgb_lib::{bdk_wallet::keys::bip39::Mnemonic, BitcoinNetwork, ContractId}; use rln_migration::{Migrator, MigratorTrait}; use sea_orm::{ConnectOptions, Database, DatabaseConnection}; @@ -31,7 +30,7 @@ use std::{ path::Path, path::PathBuf, str::FromStr, - sync::{Arc, Mutex, MutexGuard, RwLock}, + sync::{Arc, Mutex, MutexGuard, OnceLock, RwLock}, time::{Duration, SystemTime}, }; use tokio::sync::{Mutex as TokioMutex, MutexGuard as TokioMutexGuard}; @@ -39,8 +38,10 @@ use tokio_util::sync::CancellationToken; use crate::asset_link::AssetLinkMessageHandler; use crate::async_order::{AsyncOrderMessageHandler, AsyncPaymentsPreimageRoot}; +use crate::crypto::{decrypt_mnemonic, encrypt_mnemonic}; use crate::ldk::{ChannelIdsMap, Router, VirtualChannelDraftStore, VirtualChannelSessionStore}; use crate::rgb::{get_rgb_channel_info_optional, RgbLibWalletWrapper}; +use crate::rgb_file_transfer::RgbFileTransferHandler; use crate::signer::{ read_key_source_file, ActiveSignerRef, ExternalSigner, ExternalSignerAttachment, RlnEntropySource, @@ -58,16 +59,29 @@ use crate::{ pub(crate) const LDK_DIR: &str = ".ldk"; pub(crate) const LOGS_DIR: &str = "logs"; +// the test suite drives local electrs/esplora instances +#[cfg(test)] pub(crate) const ELECTRUM_URL_REGTEST: &str = "127.0.0.1:50001"; #[cfg(test)] pub(crate) const ESPLORA_URL_REGTEST: &str = "http://127.0.0.1:3002"; -pub(crate) const ELECTRUM_URL_SIGNET: &str = "ssl://electrum.iriswallet.com:50033"; -pub(crate) const ELECTRUM_URL_TESTNET: &str = "ssl://electrum.iriswallet.com:50013"; -pub(crate) const ELECTRUM_URL_TESTNET4: &str = "ssl://electrum.iriswallet.com:50053"; -pub(crate) const ELECTRUM_URL_MAINNET: &str = "ssl://electrum.iriswallet.com:50003"; pub(crate) const PROXY_ENDPOINT_LOCAL: &str = "rpc://127.0.0.1:3000/json-rpc"; pub(crate) const PROXY_ENDPOINT_PUBLIC: &str = "rpcs://proxy.iriswallet.com/0.2/json-rpc"; +/// Set by the panic hook and by the background-processor watchdog. Once set, the shutdown is +/// fatal: it stops waiting for an in-progress state change and the process exits non-zero. +pub(crate) static FATAL_ERROR: OnceLock = OnceLock::new(); + +/// Process exit code `main` returns once the server future is done: `70` (sysexits +/// `EX_SOFTWARE`) when a fatal error was recorded, `0` otherwise. Single source of truth so the +/// watchdog tests exercise the real decision rather than a copy. +pub(crate) fn fatal_exit_code() -> i32 { + if FATAL_ERROR.get().is_some() { + 70 + } else { + 0 + } +} + pub(crate) struct AppState { pub(crate) static_state: Arc, pub(crate) cancel_token: CancellationToken, @@ -90,13 +104,17 @@ impl AppState { } pub(crate) fn get_changing_state(&self) -> MutexGuard<'_, bool> { - self.changing_state.lock().unwrap() + self.changing_state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) // ignore the poison: a wedged flag must not block shutdown } pub(crate) fn get_ldk_background_services( &self, ) -> MutexGuard<'_, Option> { - self.ldk_background_services.lock().unwrap() + self.ldk_background_services + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) // ignore the poison: a wedged flag must not block shutdown } #[allow(dead_code)] @@ -156,6 +174,9 @@ pub(crate) struct StaticState { /// `remote-signer`-gated unlock path, so it is dead code when that feature is off. #[cfg_attr(not(feature = "remote-signer"), allow(dead_code))] pub(crate) remote_signer_listen_addr: Option, + pub(crate) max_aggregated_media_size_per_channel_mb: u16, + pub(crate) max_pending_consignments: usize, + pub(crate) max_media_files_per_channel: usize, } impl StaticState { @@ -182,6 +203,7 @@ pub(crate) struct UnlockedAppState { pub(crate) kv_store: Arc, #[cfg(feature = "vss")] pub(crate) monitor_kv_store: Arc, + pub(crate) rgb_file_transfer_handler: Arc, pub(crate) bump_tx_event_handler: Arc, pub(crate) maker_swaps: Arc>, pub(crate) taker_swaps: Arc>, @@ -452,15 +474,12 @@ pub(crate) fn check_password_validity( database: &DatabaseConnection, ) -> Result { let db = crate::database::RlnDatabase::new(database.clone()); - if let Some(mnemonic_record) = db.get_mnemonic()? { - let mcrypt = new_magic_crypt!(password, 256); - let mnemonic_str = mcrypt - .decrypt_base64_to_string(mnemonic_record.encrypted_mnemonic) - .map_err(|_| APIError::WrongPassword)?; - Ok(Mnemonic::from_str(&mnemonic_str).expect("valid mnemonic")) - } else { - Err(APIError::NotInitialized) - } + let Some(mnemonic_record) = db.get_mnemonic()? else { + return Err(APIError::NotInitialized); + }; + let mnemonic_str = decrypt_mnemonic(password, &mnemonic_record.encrypted_mnemonic)?; + Mnemonic::from_str(&mnemonic_str) + .map_err(|e| APIError::CorruptedMnemonic(format!("invalid mnemonic: {e}"))) } pub(crate) fn check_channel_id(channel_id_str: &str) -> Result { @@ -528,8 +547,7 @@ pub(crate) fn encrypt_and_save_mnemonic( mnemonic: String, database: &DatabaseConnection, ) -> Result<(), APIError> { - let mcrypt = new_magic_crypt!(password, 256); - let encrypted_mnemonic = mcrypt.encrypt_str_to_base64(mnemonic); + let encrypted_mnemonic = encrypt_mnemonic(&password, &mnemonic)?; let db = crate::database::RlnDatabase::new(database.clone()); db.save_mnemonic(encrypted_mnemonic)?; tracing::info!("Saved wallet mnemonic"); @@ -633,25 +651,6 @@ pub(crate) fn hex_str_to_vec(hex: &str) -> Option> { Some(out) } -/// Runs a closure on drop, including during panic unwinding, so cleanup (e.g. -/// clearing the changing-state flag) still happens if the guarded work returns -/// early or panics. -pub(crate) struct CallOnDrop { - action: F, -} - -impl CallOnDrop { - pub(crate) fn new(action: F) -> Self { - Self { action } - } -} - -impl Drop for CallOnDrop { - fn drop(&mut self) { - (self.action)(); - } -} - pub(crate) async fn no_cancel(fut: Fut) -> Fut::Output where Fut: 'static + Future + Send, @@ -662,7 +661,10 @@ where let result = fut.await; let _ = tx.send(result); }); - rx.await.unwrap() + // the sender is only dropped without sending if the spawned task panicked, which the default + // panic hook has already reported + rx.await + .expect("request task panicked, see the preceding panic for the cause") } pub(crate) fn parse_peer_info( @@ -753,6 +755,9 @@ pub(crate) async fn start_daemon(args: &UserArgs) -> Result, AppEr vss_allow_empty_restore: args.vss_allow_empty_restore, reuse_addresses: args.reuse_addresses, remote_signer_listen_addr: args.remote_signer_listen_addr, + max_aggregated_media_size_per_channel_mb: args.max_aggregated_media_size_per_channel_mb, + max_pending_consignments: args.max_pending_consignments, + max_media_files_per_channel: args.max_media_files_per_channel, }); let app_state = Arc::new(AppState { @@ -882,6 +887,16 @@ pub(crate) fn validate_and_parse_description( .map_err(|e| APIError::InvalidDescription(e.to_string())) } +// Builds the invoice description from request fields, treating an explicit empty description as +// "none": a caller may send an empty `description` alongside a `description_hash`, which must +// mint an h-tag invoice rather than be rejected as "both provided". +pub(crate) fn invoice_description_from_request( + description: Option<&str>, + description_hash: Option<&str>, +) -> Result { + parse_invoice_description(description.filter(|d| !d.is_empty()), description_hash) +} + pub(crate) fn parse_invoice_description( description: Option<&str>, description_hash: Option<&str>, @@ -947,6 +962,21 @@ pub(crate) async fn bind_first_available( #[cfg(test)] mod utils_tests { + use super::*; + + const HASH_HEX: &str = "0000000000000000000000000000000000000000000000000000000000000000"; + + #[test] + fn empty_description_with_hash_mints_hash_invoice() { + let d = invoice_description_from_request(Some(""), Some(HASH_HEX)).unwrap(); + assert!(matches!(d, Bolt11InvoiceDescription::Hash(_))); + } + + #[test] + fn present_description_with_hash_is_rejected() { + assert!(invoice_description_from_request(Some("hi"), Some(HASH_HEX)).is_err()); + } + #[tokio::test] async fn bind_first_available_falls_back_when_first_addr_fails() { let occupied = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -991,35 +1021,3 @@ mod utils_tests { assert!(validate_vss_url("example.com/vss", true).is_err()); } } - -#[cfg(test)] -mod call_on_drop_tests { - use super::CallOnDrop; - use std::sync::atomic::{AtomicBool, Ordering}; - use std::sync::Arc; - - #[test] - fn runs_action_on_scope_exit() { - let ran = Arc::new(AtomicBool::new(false)); - let flag = Arc::clone(&ran); - { - let _g = CallOnDrop::new(move || flag.store(true, Ordering::SeqCst)); - } - assert!(ran.load(Ordering::SeqCst)); - } - - #[test] - fn runs_action_during_panic_unwind() { - let ran = Arc::new(AtomicBool::new(false)); - let flag = Arc::clone(&ran); - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let _g = CallOnDrop::new(move || flag.store(true, Ordering::SeqCst)); - panic!("boom"); - })); - assert!(result.is_err(), "closure should have panicked"); - assert!( - ran.load(Ordering::SeqCst), - "action must run while unwinding a panic" - ); - } -} diff --git a/test/kotlin-e2e/KotlinUniffiE2e.kt b/test/kotlin-e2e/KotlinUniffiE2e.kt index 2fee44b9..e49c60d2 100644 --- a/test/kotlin-e2e/KotlinUniffiE2e.kt +++ b/test/kotlin-e2e/KotlinUniffiE2e.kt @@ -14,6 +14,7 @@ import org.utexo.rgblightningnode.SdkCreateUtxosRequest import org.utexo.rgblightningnode.SdkInitRequest import org.utexo.rgblightningnode.SdkIssueAssetNiaRequest import org.utexo.rgblightningnode.SdkKeysendRequest +import org.utexo.rgblightningnode.SdkLdkChainSync import org.utexo.rgblightningnode.SdkNode import org.utexo.rgblightningnode.SdkOpenChannelRequest import org.utexo.rgblightningnode.SdkRefreshTransfersRequest @@ -114,10 +115,12 @@ private fun makeNode(storageDir: Path, daemonPort: UShort, peerPort: UShort): Sd private fun unlockRequest(password: String): SdkUnlockRequest { return SdkUnlockRequest( password = password, - bitcoindRpcUsername = "user", - bitcoindRpcPassword = "password", - bitcoindRpcHost = "localhost", - bitcoindRpcPort = 18443u, + ldkChainSync = SdkLdkChainSync.BlockSync( + bitcoindRpcUsername = "user", + bitcoindRpcPassword = "password", + bitcoindRpcHost = "localhost", + bitcoindRpcPort = 18443u, + ), indexerUrl = "127.0.0.1:50001", proxyEndpoint = PROXY_ENDPOINT_LOCAL, announceAddresses = emptyList(), diff --git a/test/kotlin-external-signer-smoke/ExternalSignerSmoke.kt b/test/kotlin-external-signer-smoke/ExternalSignerSmoke.kt index 89bbe253..3b9a2059 100644 --- a/test/kotlin-external-signer-smoke/ExternalSignerSmoke.kt +++ b/test/kotlin-external-signer-smoke/ExternalSignerSmoke.kt @@ -4,6 +4,7 @@ import java.nio.file.Files import java.nio.file.Paths import org.utexo.rgblightningnode.NativeExternalSigner import org.utexo.rgblightningnode.SdkInitRequest +import org.utexo.rgblightningnode.SdkLdkChainSync import org.utexo.rgblightningnode.SdkNode private const val PROXY_ENDPOINT_LOCAL = "rpc://127.0.0.1:3000/json-rpc" @@ -53,10 +54,12 @@ fun main() { node.initWithNativeExternalSigner(signer) node.unlockWithNativeExternalSigner( signer, - bitcoindUser, - bitcoindPassword, - bitcoindHost, - bitcoindPort, + SdkLdkChainSync.BlockSync( + bitcoindUser, + bitcoindPassword, + bitcoindHost, + bitcoindPort, + ), indexerUrl, proxyEndpoint, emptyList(), diff --git a/test/python-e2e/PythonUniffiE2e.py b/test/python-e2e/PythonUniffiE2e.py index c2b421dd..f902a14c 100644 --- a/test/python-e2e/PythonUniffiE2e.py +++ b/test/python-e2e/PythonUniffiE2e.py @@ -65,10 +65,12 @@ def make_node(storage_dir: Path, daemon_port: int, peer_port: int) -> rln.SdkNod def unlock_request(password: str) -> rln.SdkUnlockRequest: return rln.SdkUnlockRequest( password=password, - bitcoind_rpc_username="user", - bitcoind_rpc_password="password", - bitcoind_rpc_host="localhost", - bitcoind_rpc_port=18443, + ldk_chain_sync=rln.SdkLdkChainSync.BLOCK_SYNC( + bitcoind_rpc_username="user", + bitcoind_rpc_password="password", + bitcoind_rpc_host="localhost", + bitcoind_rpc_port=18443, + ), indexer_url="127.0.0.1:50001", proxy_endpoint=PROXY_ENDPOINT_LOCAL, announce_addresses=[], diff --git a/test/python-e2e/harness.py b/test/python-e2e/harness.py index 8587284f..84be40c3 100644 --- a/test/python-e2e/harness.py +++ b/test/python-e2e/harness.py @@ -79,10 +79,12 @@ def make_node(storage_dir: Path, daemon_port: int, peer_port: int) -> rln.SdkNod def unlock_request(password: str) -> rln.SdkUnlockRequest: return rln.SdkUnlockRequest( password=password, - bitcoind_rpc_username="user", - bitcoind_rpc_password="password", - bitcoind_rpc_host="localhost", - bitcoind_rpc_port=18443, + ldk_chain_sync=rln.SdkLdkChainSync.BLOCK_SYNC( + bitcoind_rpc_username="user", + bitcoind_rpc_password="password", + bitcoind_rpc_host="localhost", + bitcoind_rpc_port=18443, + ), indexer_url="127.0.0.1:50001", proxy_endpoint=PROXY_ENDPOINT_LOCAL, announce_addresses=[], diff --git a/test/swift-e2e/Tests/SwiftUniffiE2ETests/SwiftExternalSignerSmokeTests.swift b/test/swift-e2e/Tests/SwiftUniffiE2ETests/SwiftExternalSignerSmokeTests.swift index 66b62607..5e9c2caf 100644 --- a/test/swift-e2e/Tests/SwiftUniffiE2ETests/SwiftExternalSignerSmokeTests.swift +++ b/test/swift-e2e/Tests/SwiftUniffiE2ETests/SwiftExternalSignerSmokeTests.swift @@ -50,10 +50,12 @@ final class SwiftExternalSignerSmokeTests: XCTestCase { try node.initWithNativeExternalSigner(signer: signer) try node.unlockWithNativeExternalSigner( signer: signer, - bitcoindRpcUsername: bitcoindUser, - bitcoindRpcPassword: bitcoindPassword, - bitcoindRpcHost: bitcoindHost, - bitcoindRpcPort: bitcoindPort, + ldkChainSync: .blockSync( + bitcoindRpcUsername: bitcoindUser, + bitcoindRpcPassword: bitcoindPassword, + bitcoindRpcHost: bitcoindHost, + bitcoindRpcPort: bitcoindPort + ), indexerUrl: indexerUrl, proxyEndpoint: proxyEndpoint, announceAddresses: [], diff --git a/test/swift-e2e/Tests/SwiftUniffiE2ETests/SwiftUniffiE2ESmokeTests.swift b/test/swift-e2e/Tests/SwiftUniffiE2ETests/SwiftUniffiE2ESmokeTests.swift index cb0ad374..c444057d 100644 --- a/test/swift-e2e/Tests/SwiftUniffiE2ETests/SwiftUniffiE2ESmokeTests.swift +++ b/test/swift-e2e/Tests/SwiftUniffiE2ETests/SwiftUniffiE2ESmokeTests.swift @@ -37,10 +37,12 @@ final class SwiftUniffiE2ESmokeTests: XCTestCase { try node.unlock( request: SdkUnlockRequest( password: "swift-e2e-pass", - bitcoindRpcUsername: bitcoindUser, - bitcoindRpcPassword: bitcoindPassword, - bitcoindRpcHost: bitcoindHost, - bitcoindRpcPort: bitcoindPort, + ldkChainSync: .blockSync( + bitcoindRpcUsername: bitcoindUser, + bitcoindRpcPassword: bitcoindPassword, + bitcoindRpcHost: bitcoindHost, + bitcoindRpcPort: bitcoindPort + ), indexerUrl: env["INDEXER_URL"], proxyEndpoint: env["PROXY_ENDPOINT"], announceAddresses: [], @@ -96,10 +98,12 @@ final class SwiftUniffiE2ESmokeTests: XCTestCase { try node.unlock( request: SdkUnlockRequest( password: "swift-rgs-pass", - bitcoindRpcUsername: bitcoindUser, - bitcoindRpcPassword: bitcoindPassword, - bitcoindRpcHost: bitcoindHost, - bitcoindRpcPort: bitcoindPort, + ldkChainSync: .blockSync( + bitcoindRpcUsername: bitcoindUser, + bitcoindRpcPassword: bitcoindPassword, + bitcoindRpcHost: bitcoindHost, + bitcoindRpcPort: bitcoindPort + ), indexerUrl: env["INDEXER_URL"], proxyEndpoint: env["PROXY_ENDPOINT"], announceAddresses: [], From 0ed09b13a51afc4cda32e6e809f68ab3205cf0f7 Mon Sep 17 00:00:00 2001 From: Jainakin Date: Fri, 28 Aug 2026 02:03:33 +0800 Subject: [PATCH 2/4] Make RGB channel funding restart safe --- .github/workflows/test.yaml | 7 + Cargo.lock | 24 +- Cargo.toml | 7 +- .../java/org/rgblightningnode/PaymentTest.kt | 123 +- bindings/c-ffi/Cargo.lock | 20 +- rust-lightning | 2 +- src/async_order.rs | 3 +- src/error.rs | 5 + src/ldk.rs | 3618 +++++++++++++++-- src/rgb.rs | 296 +- src/sdk/mod.rs | 54 +- src/synced_kv_store.rs | 161 +- src/test/funding_crash_recovery.rs | 373 ++ src/test/funding_crash_sender.rs | 384 ++ src/test/lib_sdk/close_force_standard.rs | 58 +- src/test/lib_sdk/helpers.rs | 380 +- .../lib_sdk/openchannel_push_asset_amount.rs | 36 + src/test/lib_sdk/vss_consignment_reimport.rs | 191 +- src/test/lib_sdk/vss_manager_lag.rs | 55 +- src/test/mod.rs | 19 +- src/test/openchannel_fail.rs | 6 +- src/test/vss_durability_gaps.rs | 242 +- src/test_utils.rs | 16 + src/uniffi_api/mod.rs | 13 + src/utils.rs | 14 +- src/vss_kv_store.rs | 15 +- test/kotlin-e2e/KotlinUniffiE2e.kt | 26 +- 27 files changed, 5575 insertions(+), 573 deletions(-) create mode 100644 src/test/funding_crash_recovery.rs create mode 100644 src/test/funding_crash_sender.rs diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index b6bda0a3..e1d44661 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -89,6 +89,8 @@ jobs: components: llvm-tools-preview toolchain: stable rustflags: "" + - name: Build daemon binary for process-crash tests + run: cargo build --bin rgb-lightning-node - name: Install llvm-cov env: LLVM_COV_RELEASES: https://github.com/taiki-e/cargo-llvm-cov/releases @@ -160,6 +162,11 @@ jobs: run: cargo test --features "uniffi,test-utils,vls,vss" --bin rgb-lightning-node ldk::vss_teardown_tests -- --test-threads=1 - name: Start regtest and VSS services run: VSS=1 ./regtest.sh start + - name: Run RGB channel-info device-loss durability regression + run: >- + cargo test --features vss + "test::vss_durability_gaps::tests::acknowledged_rgb_channel_info" + -- --test-threads=1 - name: Run VSS tests (e2e against regtest + VSS server) run: SKIP_INIT=1 cargo test --features vss "test::vss::tests" -- --test-threads=1 - name: Run VSS unreachable openchannel e2e test diff --git a/Cargo.lock b/Cargo.lock index ab4fa7f1..4405dbac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -817,7 +817,7 @@ version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ - "bitcoin_hashes 0.14.101", + "bitcoin_hashes 0.12.0", "serde", "unicode-normalization", ] @@ -2252,6 +2252,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -4823,7 +4833,7 @@ dependencies = [ [[package]] name = "rgb-lib" version = "0.3.0-beta.7" -source = "git+https://github.com/UTEXO-Protocol/rgb-lib.git?tag=v0.3.0-beta.34#62a8c3a045901147b3b06aed9f1e61f345695dce" +source = "git+https://github.com/Jainakin/rgb-lib.git?rev=22c76737894db67caa2b0743e4c258ba8c2422f0#22c76737894db67caa2b0743e4c258ba8c2422f0" dependencies = [ "amplify", "base64 0.22.1", @@ -4832,8 +4842,10 @@ dependencies = [ "bdk_wallet", "chacha20poly1305 0.11.0", "file-format", + "fs2", "hex", "hkdf 0.12.4", + "nonasync", "rand 0.10.2", "reqwest 0.13.4", "rgb-invoicing", @@ -4866,7 +4878,7 @@ dependencies = [ [[package]] name = "rgb-lib-migration" version = "0.3.0-beta.5" -source = "git+https://github.com/UTEXO-Protocol/rgb-lib.git?tag=v0.3.0-beta.34#62a8c3a045901147b3b06aed9f1e61f345695dce" +source = "git+https://github.com/Jainakin/rgb-lib.git?rev=22c76737894db67caa2b0743e4c258ba8c2422f0#22c76737894db67caa2b0743e4c258ba8c2422f0" dependencies = [ "sea-orm-migration 2.0.2", "tokio", @@ -5745,7 +5757,7 @@ version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ - "bitcoin_hashes 0.14.101", + "bitcoin_hashes 0.12.0", "rand 0.8.7", "secp256k1-sys 0.10.1", "serde", @@ -6112,7 +6124,7 @@ dependencies = [ [[package]] name = "signer-external" version = "0.1.0-alpha" -source = "git+https://github.com/UTEXO-Protocol/rln-external-signer.git?branch=main#0fb005ec4b927ddbe13e1646d247b5bb11e8ffed" +source = "git+https://github.com/UTEXO-Protocol/rln-external-signer.git?rev=0fb005ec4b927ddbe13e1646d247b5bb11e8ffed#0fb005ec4b927ddbe13e1646d247b5bb11e8ffed" dependencies = [ "base64 0.22.1", "bitcoin 0.32.102", @@ -7908,7 +7920,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 870afc25..a9443ce0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -95,11 +95,12 @@ lightning-transaction-sync = { version = "0.2.0", optional = true } rand = "0.8.5" regex = { version = "1.11", default-features = false } reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] } -rgb-lib = { git = "https://github.com/UTEXO-Protocol/rgb-lib.git", tag = "v0.3.0-beta.34", default-features = false } +rgb-lib = { git = "https://github.com/Jainakin/rgb-lib.git", rev = "22c76737894db67caa2b0743e4c258ba8c2422f0", default-features = false } psrgbt = { package = "rgb-psbt-utils", version = "=0.11.1-rc.11", default-features = false } rln-migration = { path = "migration" } -# Pinned fork/branch for RGB compatibility work; CI and clones do not require a sibling checkout. -signer-external = { git = "https://github.com/UTEXO-Protocol/rln-external-signer.git", branch = "main", default-features = false } +# Pin the exact external-signer API consumed by this release. Tracking `main` made independent +# binding workspaces resolve a newer, incompatible constructor on clean machines. +signer-external = { git = "https://github.com/UTEXO-Protocol/rln-external-signer.git", rev = "0fb005ec4b927ddbe13e1646d247b5bb11e8ffed", default-features = false } rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std"] } tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc_rs"], optional = true } rustls-pemfile = { version = "2", optional = true } diff --git a/android-e2e/app/src/androidTest/java/org/rgblightningnode/PaymentTest.kt b/android-e2e/app/src/androidTest/java/org/rgblightningnode/PaymentTest.kt index 4a0768fd..391959c3 100644 --- a/android-e2e/app/src/androidTest/java/org/rgblightningnode/PaymentTest.kt +++ b/android-e2e/app/src/androidTest/java/org/rgblightningnode/PaymentTest.kt @@ -69,6 +69,18 @@ class PaymentTest { private val channelAssetAmount: ULong = 600u private val channelReadyTimeoutSec: Long = 120L + private val changingStateMessage = "Cannot call other APIs while node is changing state" + + private inline fun pollWhileNodeStable(label: String, operation: () -> T): Result { + return try { + Result.success(operation()) + } catch (error: RlnException.Conflict) { + if (error.message != changingStateMessage) throw error + log("$label deferred while node is changing state") + Result.failure(error) + } + } + // ── Bitcoin RPC ────────────────────────────────────────────────────────── private fun bitcoindRpc(method: String, vararg params: Any): JSONObject { @@ -186,7 +198,14 @@ class PaymentTest { val deadline = System.currentTimeMillis() + timeoutSec * 1_000L var lastBalance = 0uL while (System.currentTimeMillis() < deadline) { - val balance = assetBalanceOffchainOutbound(node, assetId) + val attempt = pollWhileNodeStable("off-chain balance poll") { + assetBalanceOffchainOutbound(node, assetId) + } + if (attempt.isFailure) { + Thread.sleep(250L) + continue + } + val balance = attempt.getOrThrow() lastBalance = balance if (balance == expected) { return @@ -200,12 +219,22 @@ class PaymentTest { val deadline = System.currentTimeMillis() + timeoutSec * 1_000L var lastBalance = 0uL while (System.currentTimeMillis() < deadline) { - val balance = assetBalanceSpendable(node, assetId) + val attempt = pollWhileNodeStable("on-chain balance poll") { + val balance = assetBalanceSpendable(node, assetId) + if (balance != expected) { + node.refreshtransfers(SdkRefreshTransfersRequest(skipSync = false)) + } + balance + } + if (attempt.isFailure) { + Thread.sleep(250L) + continue + } + val balance = attempt.getOrThrow() lastBalance = balance if (balance == expected) { return } - node.refreshtransfers(SdkRefreshTransfersRequest(skipSync = false)) Thread.sleep(1_000L) } error("spendable balance did not become expected=$expected actual=$lastBalance after ${timeoutSec}s") @@ -214,8 +243,16 @@ class PaymentTest { private fun waitForChannelFundingTx(nodeA: SdkNode, nodeB: SdkNode, assetId: ContractId, timeoutSec: Long): Txid { val deadline = System.currentTimeMillis() + timeoutSec * 1_000L while (System.currentTimeMillis() < deadline) { - nodeA.sync(); nodeB.sync() - val opening = nodeA.listChannels().firstOrNull { it.assetId == assetId && it.fundingTxid != null } + val attempt = pollWhileNodeStable("channel funding poll") { + nodeA.sync() + nodeB.sync() + nodeA.listChannels().firstOrNull { it.assetId == assetId && it.fundingTxid != null } + } + if (attempt.isFailure) { + Thread.sleep(250L) + continue + } + val opening = attempt.getOrThrow() if (opening != null) { log("channel funding tx found: ${opening.fundingTxid}") return requireNotNull(opening.fundingTxid) @@ -229,8 +266,15 @@ class PaymentTest { private fun mineUntilTxConfirmed(node: SdkNode, txid: Txid, timeoutSec: Long = 180L) { val deadline = System.currentTimeMillis() + timeoutSec * 1_000L while (System.currentTimeMillis() < deadline) { - node.sync() - val tx = node.listTransactions(false, null).firstOrNull { it.txid == txid } + val attempt = pollWhileNodeStable("funding confirmation poll") { + node.sync() + node.listTransactions(false, null).firstOrNull { it.txid == txid } + } + if (attempt.isFailure) { + Thread.sleep(250L) + continue + } + val tx = attempt.getOrThrow() if (tx != null && tx.confirmationTime != null) { log("funding tx confirmed in block: $txid") return @@ -246,9 +290,17 @@ class PaymentTest { val deadline = System.currentTimeMillis() + timeoutSec * 1_000L var polls = 0 while (System.currentTimeMillis() < deadline) { + val attempt = pollWhileNodeStable("usable channel poll") { + nodeA.sync() + nodeB.sync() + nodeA.listChannels().any { it.isUsable && it.assetId == assetId } + } + if (attempt.isFailure) { + Thread.sleep(250L) + continue + } polls++ - nodeA.sync(); nodeB.sync() - val usable = nodeA.listChannels().any { it.isUsable && it.assetId == assetId } + val usable = attempt.getOrThrow() if (usable) { log("channel is usable"); return } if (polls % 5 == 0) { log("mining 1 block..."); mine(1) } log("waiting for usable channel... (poll $polls)") @@ -269,10 +321,19 @@ class PaymentTest { var lastNodeABalance: ULong? = null var lastNodeBBalance: ULong? = null while (System.currentTimeMillis() < deadline) { - nodeA.sync() - nodeB.sync() - val channelA = nodeA.listChannels().firstOrNull { it.channelId == channelId } - val channelB = nodeB.listChannels().firstOrNull { it.channelId == channelId } + val attempt = pollWhileNodeStable("channel balance poll") { + nodeA.sync() + nodeB.sync() + Pair( + nodeA.listChannels().firstOrNull { it.channelId == channelId }, + nodeB.listChannels().firstOrNull { it.channelId == channelId }, + ) + } + if (attempt.isFailure) { + Thread.sleep(250L) + continue + } + val (channelA, channelB) = attempt.getOrThrow() lastNodeABalance = channelA?.localBalanceSat lastNodeBBalance = channelB?.localBalanceSat if (lastNodeABalance == expectedNodeABalance && lastNodeBBalance == expectedNodeBBalance) { @@ -291,8 +352,15 @@ class PaymentTest { val deadline = System.currentTimeMillis() + timeoutSec * 1_000L var last = InvoiceStatus.PENDING while (System.currentTimeMillis() < deadline) { - node.sync() - val status = node.invoiceStatus(invoice) + val attempt = pollWhileNodeStable("invoice status poll") { + node.sync() + node.invoiceStatus(invoice) + } + if (attempt.isFailure) { + Thread.sleep(250L) + continue + } + val status = attempt.getOrThrow() last = status if (status == InvoiceStatus.SUCCEEDED || status == InvoiceStatus.FAILED || status == InvoiceStatus.EXPIRED) { return status @@ -311,9 +379,16 @@ class PaymentTest { val deadline = System.currentTimeMillis() + timeoutSec * 1_000L var last = "not found" while (System.currentTimeMillis() < deadline) { - val payment = node.listPayments().firstOrNull { - it.paymentHash == paymentHash && it.paymentType == paymentType + val attempt = pollWhileNodeStable("payment status poll") { + node.listPayments().firstOrNull { + it.paymentHash == paymentHash && it.paymentType == paymentType + } } + if (attempt.isFailure) { + Thread.sleep(250L) + continue + } + val payment = attempt.getOrThrow() if (payment != null) { last = payment.status.name if (payment.status == HtlcStatus.SUCCEEDED) { @@ -334,7 +409,12 @@ class PaymentTest { val deadline = System.currentTimeMillis() + timeoutSec * 1_000L var lastCount = 0 while (System.currentTimeMillis() < deadline) { - val payments = node.listPayments() + val attempt = pollWhileNodeStable("payment list poll") { node.listPayments() } + if (attempt.isFailure) { + Thread.sleep(250L) + continue + } + val payments = attempt.getOrThrow() lastCount = payments.size val payment = payments.firstOrNull { it.paymentHash == paymentHash && it.paymentType == paymentType @@ -383,7 +463,12 @@ class PaymentTest { val deadline = System.currentTimeMillis() + 30_000L var lastChannels = "no channels" while (System.currentTimeMillis() < deadline) { - val channels = node.listChannels() + val attempt = pollWhileNodeStable("channel close poll") { node.listChannels() } + if (attempt.isFailure) { + Thread.sleep(250L) + continue + } + val channels = attempt.getOrThrow() lastChannels = channels.joinToString { it.channelId }.ifEmpty { "no channels" } if (channels.none { it.channelId == channelId }) { mine(if (force) 144 else 6) diff --git a/bindings/c-ffi/Cargo.lock b/bindings/c-ffi/Cargo.lock index f7824a87..2f70235e 100644 --- a/bindings/c-ffi/Cargo.lock +++ b/bindings/c-ffi/Cargo.lock @@ -2221,6 +2221,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -4762,7 +4772,7 @@ dependencies = [ [[package]] name = "rgb-lib" version = "0.3.0-beta.7" -source = "git+https://github.com/dcorral/rgb-lib.git?rev=94b6221ea9bf04562d89c21c9f074f4ecfbddd5d#94b6221ea9bf04562d89c21c9f074f4ecfbddd5d" +source = "git+https://github.com/Jainakin/rgb-lib.git?rev=22c76737894db67caa2b0743e4c258ba8c2422f0#22c76737894db67caa2b0743e4c258ba8c2422f0" dependencies = [ "amplify", "base64 0.22.1", @@ -4771,8 +4781,10 @@ dependencies = [ "bdk_wallet", "chacha20poly1305 0.11.0", "file-format", + "fs2", "hex", "hkdf 0.12.4", + "nonasync", "rand 0.10.2", "reqwest 0.13.4", "rgb-invoicing", @@ -4805,7 +4817,7 @@ dependencies = [ [[package]] name = "rgb-lib-migration" version = "0.3.0-beta.5" -source = "git+https://github.com/dcorral/rgb-lib.git?rev=94b6221ea9bf04562d89c21c9f074f4ecfbddd5d#94b6221ea9bf04562d89c21c9f074f4ecfbddd5d" +source = "git+https://github.com/Jainakin/rgb-lib.git?rev=22c76737894db67caa2b0743e4c258ba8c2422f0#22c76737894db67caa2b0743e4c258ba8c2422f0" dependencies = [ "sea-orm-migration 2.0.2", "tokio", @@ -6012,7 +6024,7 @@ dependencies = [ [[package]] name = "signer-external" version = "0.1.0-alpha" -source = "git+https://github.com/UTEXO-Protocol/rln-external-signer.git?branch=main#0fb005ec4b927ddbe13e1646d247b5bb11e8ffed" +source = "git+https://github.com/UTEXO-Protocol/rln-external-signer.git?rev=0fb005ec4b927ddbe13e1646d247b5bb11e8ffed#0fb005ec4b927ddbe13e1646d247b5bb11e8ffed" dependencies = [ "base64 0.22.1", "bitcoin", @@ -7750,7 +7762,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/rust-lightning b/rust-lightning index 38d73bc9..32b8c6b6 160000 --- a/rust-lightning +++ b/rust-lightning @@ -1 +1 @@ -Subproject commit 38d73bc918f27956590585d2bb83c86f059679b0 +Subproject commit 32b8c6b6046c3bfac44ee94549cadf9916e234ae diff --git a/src/async_order.rs b/src/async_order.rs index 397825d2..7d1cd612 100644 --- a/src/async_order.rs +++ b/src/async_order.rs @@ -135,7 +135,8 @@ fn apay_decode_hex_fixed( ))); } let mut out = [0u8; N]; - let (pairs, _) = s.as_bytes().as_chunks::<2>(); + let (pairs, remainder) = s.as_bytes().as_chunks::<2>(); + debug_assert!(remainder.is_empty()); for (slot, pair) in out.iter_mut().zip(pairs) { let high = apay_hex_nibble(pair[0]).ok_or_else(|| { JsonRpcErrorWire::invalid_params(format!("{field} must be {N}-byte hex")) diff --git a/src/error.rs b/src/error.rs index 761de43f..ed7f40d1 100644 --- a/src/error.rs +++ b/src/error.rs @@ -366,6 +366,9 @@ pub enum APIError { #[error("Recipient ID already used")] RecipientIDAlreadyUsed, + #[error("RGB funding recovery is required before financial operations can continue: {0}")] + RgbFundingRecoveryRequired(String), + #[error("Swap not found: {0}")] SwapNotFound(String), @@ -476,6 +479,7 @@ impl From for APIError { RgbLibError::InsufficientBitcoins { needed, available } => { APIError::InsufficientFunds(needed - available) } + RgbLibError::RgbOperationInProgress { .. } => APIError::ChangingState, RgbLibError::InvalidAddress { details } => APIError::InvalidAddress(details), RgbLibError::InvalidAmountZero => APIError::InvalidAmount(s!("0")), RgbLibError::InvalidAssignment => APIError::InvalidAssignment, @@ -661,6 +665,7 @@ impl IntoResponse for APIError { | APIError::NotInitialized | APIError::PaymentNotFound(_) | APIError::RecipientIDAlreadyUsed + | APIError::RgbFundingRecoveryRequired(_) | APIError::SwapNotFound(_) | APIError::TemporaryChannelIdAlreadyUsed | APIError::UnknownChannelId diff --git a/src/ldk.rs b/src/ldk.rs index 5fabb26c..bed396d7 100644 --- a/src/ldk.rs +++ b/src/ldk.rs @@ -16,7 +16,7 @@ use bitcoin::hashes::{sha256, Hash as BitcoinHash}; use bitcoin::psbt::{ExtractTxError, Psbt}; use bitcoin::secp256k1::{All, PublicKey, Secp256k1}; use bitcoin::Sequence; -use bitcoin::{io, Amount, Network}; +use bitcoin::{io, Amount, Network, Txid}; use bitcoin::{BlockHash, TxOut}; use bitcoin_bech32::WitnessProgram; use hex::DisplayHex; @@ -40,11 +40,14 @@ use lightning::onion_message::messenger::{ DefaultMessageRouter, OnionMessenger as LdkOnionMessenger, }; use lightning::rgb_utils::{ - deserialize_fascia, get_rgb_channel_info_pending, is_channel_rgb, update_rgb_channel_amount, - RgbKvStoreExt, RGB_COMMITMENT_FASCIA_NS, RGB_PAYMENT_INFO_INBOUND_NS, + deserialize_fascia, get_rgb_channel_info_pending, is_channel_rgb, + read_pending_funding_acceptance, remove_pending_funding_acceptance, update_rgb_channel_amount, + write_pending_funding_acceptance, FundingAcceptanceStage, PendingFundingAcceptance, + RgbKvStoreExt, RGB_CHANNEL_INFO_NS, RGB_CHANNEL_INFO_PENDING_NS, RGB_COMMITMENT_FASCIA_NS, + RGB_CONSIGNMENT_NS, RGB_FUNDING_ACCEPTANCE_NS, RGB_PAYMENT_INFO_INBOUND_NS, RGB_PAYMENT_INFO_OUTBOUND_NS, RGB_PRIMARY_NS, }; -use lightning::rgb_utils::{RgbPaymentInfo, TransferInfo, STATIC_BLINDING}; +use lightning::rgb_utils::{RgbInfo, RgbPaymentInfo, TransferInfo, STATIC_BLINDING}; use lightning::routing::gossip; use lightning::routing::gossip::NodeId; use lightning::routing::router::DefaultRouter; @@ -105,8 +108,8 @@ use rgb_lib::{ AssetSchema, Assignment, BitcoinNetwork, ConsignmentExt, ContractId, Error as RgbLibError, Fascia, FileContent, RgbTransfer, RgbTxid, TransferStatus, WitnessOrd, }; -use std::collections::HashMap; -use std::collections::HashSet; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeSet, HashMap, HashSet}; use std::convert::TryInto; use std::fs; use std::hash::{DefaultHasher, Hash, Hasher}; @@ -117,6 +120,8 @@ use std::str::FromStr; #[cfg(test)] use std::sync::atomic::AtomicUsize; use std::sync::atomic::{AtomicBool, Ordering}; +#[cfg(feature = "test-utils")] +use std::sync::OnceLock; use std::sync::{Arc, Mutex, MutexGuard, RwLock, Weak}; #[cfg(any(test, feature = "vss"))] use std::time::Instant; @@ -143,8 +148,9 @@ const MAKER_SWAPS_KEY: &str = "maker_swaps"; const TAKER_SWAPS_KEY: &str = "taker_swaps"; const ASSET_LINK_SWAP_AUTHORIZATION_MAX_EXPIRY_SECS: u64 = 24 * 60 * 60; const OUTPUT_SPENDER_TXES_KEY: &str = "output_spender_txes"; -const PSBT_NAMESPACE: &str = "psbt"; -const PENDING_FUNDING_NAMESPACE: &str = "pending_funding"; +pub(crate) const PSBT_NAMESPACE: &str = crate::synced_kv_store::PSBT_NAMESPACE; +pub(crate) const PENDING_FUNDING_NAMESPACE: &str = + crate::synced_kv_store::PENDING_FUNDING_NAMESPACE; /// Funding consignments keyed by funding txid, kept for wallet re-seeding /// after a restore without an RGB backup (issue #111). const FUNDING_CONSIGNMENT_NAMESPACE: &str = "funding_consignment"; @@ -152,6 +158,8 @@ const FUNDING_CONSIGNMENT_NAMESPACE: &str = "funding_consignment"; /// replay reruns until it completes once. const REIMPORT_MARKER_NAMESPACE: &str = "reimport_marker"; const REIMPORT_MARKER_KEY: &str = "fascia_replay"; +pub(crate) const RGB_SENDER_FUNDING_NAMESPACE: &str = + crate::synced_kv_store::RGB_SENDER_FUNDING_NAMESPACE; const CONFIG_INDEXER_URL: &str = "indexer_url"; const CONFIG_BITCOIN_NETWORK: &str = "bitcoin_network"; const CONFIG_WALLET_FINGERPRINT: &str = "wallet_fingerprint"; @@ -160,6 +168,477 @@ const CONFIG_WALLET_ACCOUNT_XPUB_COLORED: &str = "wallet_account_xpub_colored"; const CONFIG_WALLET_MASTER_FINGERPRINT: &str = "wallet_master_fingerprint"; const VIRTUAL_CHANNEL_DRAFTS_KEY: &str = "virtual_channel_drafts"; const VIRTUAL_CHANNEL_SESSIONS_KEY: &str = "virtual_channel_sessions"; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum RgbSenderFundingStage { + Preparing, + StockPromoted, + HandoffReady, + HandedToLdk, + BroadcastSafeObserved, + Broadcasting, + BroadcastCommitted, + Finalized, + DurablyCompleted, + RollingBack, + RetryRequired, +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +enum RgbSenderConsignmentDelivery { + #[default] + Proxy, + P2p, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +struct RgbSenderFundingRecord { + version: u8, + #[serde(default)] + manual_broadcast: bool, + temporary_channel_id: String, + final_channel_id: Option, + funding_txid: String, + batch_transfer_idx: i32, + #[serde(default)] + rgb_info: Option, + #[serde(default)] + consignment_delivery: RgbSenderConsignmentDelivery, + stage: RgbSenderFundingStage, +} + +impl RgbSenderFundingRecord { + const LEGACY_VERSION: u8 = 1; + const MANUAL_BROADCAST_VERSION: u8 = 2; + const RGB_INFO_VERSION: u8 = 3; + const VERSION: u8 = 4; + + fn validate(&self) -> Result<(), RgbLibError> { + let is_fixed_hex = |value: &str, byte_len: usize| { + value.len() == byte_len * 2 + && value.as_bytes().iter().all(|byte| byte.is_ascii_hexdigit()) + }; + if !matches!( + self.version, + Self::LEGACY_VERSION + | Self::MANUAL_BROADCAST_VERSION + | Self::RGB_INFO_VERSION + | Self::VERSION + ) || (self.version == Self::LEGACY_VERSION && self.manual_broadcast) + || (self.version != Self::LEGACY_VERSION && !self.manual_broadcast) + || !is_fixed_hex(&self.temporary_channel_id, 32) + || !is_fixed_hex(&self.funding_txid, 32) + || self + .final_channel_id + .as_ref() + .is_some_and(|channel_id| !is_fixed_hex(channel_id, 32)) + || self.batch_transfer_idx < 0 + || (self.version >= Self::RGB_INFO_VERSION && self.rgb_info.is_none()) + || (self.version < Self::VERSION + && self.consignment_delivery != RgbSenderConsignmentDelivery::Proxy) + || (self.version == Self::VERSION + && self.consignment_delivery != RgbSenderConsignmentDelivery::P2p) + { + return Err(RgbLibError::Internal { + details: "invalid RGB sender funding journal".to_owned(), + }); + } + if matches!( + self.stage, + RgbSenderFundingStage::HandoffReady + | RgbSenderFundingStage::HandedToLdk + | RgbSenderFundingStage::BroadcastSafeObserved + | RgbSenderFundingStage::Broadcasting + | RgbSenderFundingStage::BroadcastCommitted + | RgbSenderFundingStage::Finalized + | RgbSenderFundingStage::DurablyCompleted + ) && self.final_channel_id.is_none() + { + return Err(RgbLibError::Internal { + details: "RGB sender funding journal stage requires a final channel ID".to_owned(), + }); + } + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RgbSenderRecoveryAction { + Finalize, + ResumeBroadcast, + Rollback, + FailClosed, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct RgbFundingRecoveryState { + pub(crate) funding_txid: String, + pub(crate) temporary_channel_id: String, + pub(crate) final_channel_id: Option, + pub(crate) stage: RgbFundingRecoveryStage, + pub(crate) channel_is_durable: bool, + pub(crate) transaction_is_known: Option, + pub(crate) error: Option, + pub(crate) action: RgbFundingRecoveryAction, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum RgbFundingRecoveryStage { + Sender(RgbSenderFundingStage), + Receiver(FundingAcceptanceStage), +} + +impl RgbFundingRecoveryStage { + const fn sort_key(self) -> (u8, u8) { + match self { + Self::Sender(stage) => ( + 0, + match stage { + RgbSenderFundingStage::Preparing => 0, + RgbSenderFundingStage::StockPromoted => 1, + RgbSenderFundingStage::HandoffReady => 2, + RgbSenderFundingStage::HandedToLdk => 3, + RgbSenderFundingStage::BroadcastSafeObserved => 4, + RgbSenderFundingStage::Broadcasting => 5, + RgbSenderFundingStage::BroadcastCommitted => 6, + RgbSenderFundingStage::Finalized => 7, + RgbSenderFundingStage::DurablyCompleted => 8, + RgbSenderFundingStage::RollingBack => 9, + RgbSenderFundingStage::RetryRequired => 10, + }, + ), + Self::Receiver(stage) => ( + 1, + match stage { + FundingAcceptanceStage::Validating => 0, + FundingAcceptanceStage::Prepared => 1, + FundingAcceptanceStage::Promoted => 2, + FundingAcceptanceStage::Finalizing => 3, + FundingAcceptanceStage::Finalized => 4, + FundingAcceptanceStage::RollingBack => 5, + FundingAcceptanceStage::RetryRequired => 6, + }, + ), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum RgbFundingRecoveryAction { + RetryReconciliation, + ResumeBroadcast, + RetryChainObservation, + ManualChannelStateRecovery, +} + +impl RgbFundingRecoveryAction { + const fn as_str(self) -> &'static str { + match self { + Self::RetryReconciliation => "retry_reconciliation", + Self::ResumeBroadcast => "resume_broadcast", + Self::RetryChainObservation => "retry_chain_observation", + Self::ManualChannelStateRecovery => "manual_channel_state_recovery", + } + } +} + +/// Fail-closed admission state for interrupted RGB channel funding. +/// +/// An unresolved sender or receiver journal can represent a transaction whose broadcast outcome +/// or matching LDK channel state is not yet known. Read-only inspection and recovery remain +/// available, but new financial mutations must not be admitted until every record is reconciled. +/// +/// The operation lease is intentionally wallet-wide. rgb-lib currently persists one pending RGB +/// acceptance and one rollback snapshot for the wallet, not one per funding allocation. Allowing +/// another stock mutation under a per-txid lock could overwrite the only rollback owner. This can +/// be narrowed only after the underlying stock journal identifies and isolates every allocation +/// touched by each concurrent operation. +#[derive(Debug, Default)] +pub(crate) struct RgbFundingRecoveryGuard { + funding_txids: RwLock>, + operation_lock: Arc>, +} + +pub(crate) struct RgbFundingOperationLease { + _guard: tokio::sync::OwnedMutexGuard<()>, + acquired_at: std::time::Instant, + owner: &'static str, +} + +impl RgbFundingOperationLease { + fn new(guard: tokio::sync::OwnedMutexGuard<()>, owner: &'static str) -> Self { + Self { + _guard: guard, + acquired_at: std::time::Instant::now(), + owner, + } + } +} + +impl Drop for RgbFundingOperationLease { + fn drop(&mut self) { + let held_for = self.acquired_at.elapsed(); + if held_for >= Duration::from_millis(250) { + tracing::warn!( + owner = self.owner, + held_ms = held_for.as_millis(), + "RGB wallet operation lease exceeded the latency budget" + ); + } + } +} + +impl RgbFundingRecoveryGuard { + pub(crate) async fn lock_operation(&self) -> RgbFundingOperationLease { + RgbFundingOperationLease::new( + Arc::clone(&self.operation_lock).lock_owned().await, + "funding-event", + ) + } + + pub(crate) fn blocking_lock_operation(&self) -> RgbFundingOperationLease { + RgbFundingOperationLease::new( + Arc::clone(&self.operation_lock).blocking_lock_owned(), + "startup-reconciliation", + ) + } + + pub(crate) fn replace(&self, recoveries: &[RgbFundingRecoveryState]) { + let mut funding_txids = self.funding_txids.write().unwrap_or_else(|poisoned| { + tracing::error!("RGB funding recovery guard was poisoned; preserving quarantine"); + poisoned.into_inner() + }); + *funding_txids = recoveries + .iter() + .map(|recovery| recovery.funding_txid.clone()) + .collect(); + } + + fn clear(&self, funding_txid: &str) { + self.funding_txids + .write() + .unwrap_or_else(|poisoned| { + tracing::error!("RGB funding recovery guard was poisoned; preserving quarantine"); + poisoned.into_inner() + }) + .remove(funding_txid); + } + + fn quarantine(&self, funding_txid: &str) { + self.funding_txids + .write() + .unwrap_or_else(|poisoned| { + tracing::error!("RGB funding recovery guard was poisoned; preserving quarantine"); + poisoned.into_inner() + }) + .insert(funding_txid.to_owned()); + } + + pub(crate) fn ensure_financial_operations_allowed( + &self, + ) -> Result { + // Admission and execution must be one atomic lease. A check-only gate permits a funding + // transition to start immediately after the check, allowing its rollback snapshot to + // overwrite a concurrent wallet mutation. + let operation = Arc::clone(&self.operation_lock) + .try_lock_owned() + .map_err(|_| APIError::ChangingState)?; + let funding_txids = self.funding_txids.read().unwrap_or_else(|poisoned| { + tracing::error!("RGB funding recovery guard was poisoned; preserving quarantine"); + poisoned.into_inner() + }); + if funding_txids.is_empty() { + return Ok(RgbFundingOperationLease::new(operation, "financial-api")); + } + Err(APIError::RgbFundingRecoveryRequired( + funding_txids.iter().cloned().collect::>().join(","), + )) + } + + #[cfg(test)] + fn snapshot(&self) -> Vec { + self.funding_txids + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .iter() + .cloned() + .collect() + } +} + +fn rgb_funding_recovery_view( + record: &RgbSenderFundingRecord, + channel_is_durable: bool, + transaction_observation: Result, &RgbLibError>, + reconciliation_error: Option<&RgbLibError>, +) -> RgbFundingRecoveryState { + let (transaction_is_known, observation_error, mut action) = match transaction_observation { + Err(error) => { + let action = if matches!( + record.stage, + RgbSenderFundingStage::Finalized | RgbSenderFundingStage::DurablyCompleted + ) { + if channel_is_durable { + RgbFundingRecoveryAction::RetryReconciliation + } else { + RgbFundingRecoveryAction::ManualChannelStateRecovery + } + } else { + RgbFundingRecoveryAction::RetryChainObservation + }; + (None, Some(error.to_string()), action) + } + Ok(transaction_is_known) => { + let recovery_action = rgb_sender_recovery_action( + record, + channel_is_durable, + transaction_is_known.unwrap_or(false), + ); + let action = match recovery_action { + RgbSenderRecoveryAction::Finalize | RgbSenderRecoveryAction::Rollback => { + RgbFundingRecoveryAction::RetryReconciliation + } + RgbSenderRecoveryAction::ResumeBroadcast => { + RgbFundingRecoveryAction::ResumeBroadcast + } + RgbSenderRecoveryAction::FailClosed => { + RgbFundingRecoveryAction::ManualChannelStateRecovery + } + }; + (transaction_is_known, None, action) + } + }; + if reconciliation_error.is_some() + && action != RgbFundingRecoveryAction::ManualChannelStateRecovery + { + action = RgbFundingRecoveryAction::RetryReconciliation; + } + RgbFundingRecoveryState { + funding_txid: record.funding_txid.clone(), + temporary_channel_id: record.temporary_channel_id.clone(), + final_channel_id: record.final_channel_id.clone(), + stage: RgbFundingRecoveryStage::Sender(record.stage), + channel_is_durable, + transaction_is_known, + error: reconciliation_error + .map(ToString::to_string) + .or(observation_error), + action, + } +} + +fn rgb_receiver_funding_recovery_view( + record: &PendingFundingAcceptance, + channel_is_durable: bool, + error: Option, +) -> Result { + let action = match rgb_receiver_recovery_action(record.stage, channel_is_durable) { + RgbReceiverRecoveryAction::Quarantine => { + RgbFundingRecoveryAction::ManualChannelStateRecovery + } + RgbReceiverRecoveryAction::Rollback + | RgbReceiverRecoveryAction::Finalize + | RgbReceiverRecoveryAction::Complete => RgbFundingRecoveryAction::RetryReconciliation, + }; + Ok(RgbFundingRecoveryState { + funding_txid: record.funding_txid.clone(), + temporary_channel_id: record.temporary_channel_id.clone(), + final_channel_id: Some(receiver_final_channel_id(record)?), + stage: RgbFundingRecoveryStage::Receiver(record.stage), + channel_is_durable, + transaction_is_known: None, + error, + action, + }) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RgbReceiverRecoveryAction { + Rollback, + Finalize, + Complete, + Quarantine, +} + +fn rgb_receiver_recovery_action( + stage: FundingAcceptanceStage, + channel_is_durable: bool, +) -> RgbReceiverRecoveryAction { + match (stage, channel_is_durable) { + ( + FundingAcceptanceStage::Validating + | FundingAcceptanceStage::Prepared + | FundingAcceptanceStage::RollingBack + | FundingAcceptanceStage::RetryRequired, + false, + ) => RgbReceiverRecoveryAction::Rollback, + (FundingAcceptanceStage::Promoted | FundingAcceptanceStage::Finalizing, true) => { + RgbReceiverRecoveryAction::Finalize + } + (FundingAcceptanceStage::Finalized, true) => RgbReceiverRecoveryAction::Complete, + _ => RgbReceiverRecoveryAction::Quarantine, + } +} + +fn rgb_sender_recovery_action( + record: &RgbSenderFundingRecord, + channel_is_durable: bool, + transaction_is_known: bool, +) -> RgbSenderRecoveryAction { + if matches!( + record.stage, + RgbSenderFundingStage::Finalized | RgbSenderFundingStage::DurablyCompleted + ) { + return if channel_is_durable { + RgbSenderRecoveryAction::Finalize + } else { + RgbSenderRecoveryAction::FailClosed + }; + } + if transaction_is_known { + return if channel_is_durable { + RgbSenderRecoveryAction::Finalize + } else { + RgbSenderRecoveryAction::FailClosed + }; + } + if channel_is_durable { + return if matches!( + record.stage, + RgbSenderFundingStage::Broadcasting | RgbSenderFundingStage::BroadcastCommitted + ) { + RgbSenderRecoveryAction::Finalize + } else { + RgbSenderRecoveryAction::ResumeBroadcast + }; + } + match record.stage { + RgbSenderFundingStage::Preparing | RgbSenderFundingStage::StockPromoted => { + RgbSenderRecoveryAction::Rollback + } + RgbSenderFundingStage::HandoffReady + | RgbSenderFundingStage::HandedToLdk + | RgbSenderFundingStage::BroadcastSafeObserved + if record.manual_broadcast => + { + RgbSenderRecoveryAction::Rollback + } + RgbSenderFundingStage::RollingBack | RgbSenderFundingStage::RetryRequired => { + RgbSenderRecoveryAction::Rollback + } + // Version-one journals used LDK's automatic broadcast path. Once handoff may have begun, + // absence from one indexer is not proof that the transaction was never broadcast. + RgbSenderFundingStage::HandoffReady + | RgbSenderFundingStage::HandedToLdk + | RgbSenderFundingStage::BroadcastSafeObserved + | RgbSenderFundingStage::Broadcasting + | RgbSenderFundingStage::BroadcastCommitted + | RgbSenderFundingStage::Finalized + | RgbSenderFundingStage::DurablyCompleted => RgbSenderRecoveryAction::FailClosed, + } +} use crate::error::APIError; #[cfg(feature = "block-sync")] use crate::ldk_chain_backend::block_sync::{BitcoindClient, BlockSyncGossipVerifier}; @@ -172,7 +651,7 @@ use crate::ldk_chain_backend::transaction_sync::{ use crate::ldk_chain_backend::{ChainBackend, ChainSetup, DynBroadcaster, DynFeeEstimator}; use crate::rgb::{ check_rgb_proxy_endpoint, get_rgb_channel_info_optional, RgbBumpWalletSource, - RgbLibWalletWrapper, + RgbChangeDestinationSource, RgbLibWalletWrapper, }; use crate::rgb_file_transfer::{ PeerChannelGate, RgbFileTransferHandler, REASSEMBLY_SWEEP_INTERVAL, @@ -342,6 +821,32 @@ pub(crate) fn node_override_matches( .is_some_and(|id| *id == our_node_id) } +#[cfg(feature = "test-utils")] +fn processed_channel_ready_events() -> &'static Mutex> { + static EVENTS: OnceLock>> = OnceLock::new(); + EVENTS.get_or_init(|| Mutex::new(HashSet::new())) +} + +#[cfg(feature = "test-utils")] +fn record_processed_channel_ready_event(channel_id: &ChannelId, node_id: PublicKey) { + processed_channel_ready_events() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert((channel_id.to_string(), node_id.to_string())); +} + +#[cfg(feature = "test-utils")] +#[allow(dead_code)] +pub(crate) fn processed_channel_ready_event_participants(channel_id: &ChannelId) -> usize { + let channel_id = channel_id.to_string(); + processed_channel_ready_events() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .iter() + .filter(|(processed_channel_id, _)| processed_channel_id == &channel_id) + .count() +} + pub(crate) struct LdkBackgroundServices { stop_processing: Arc, gossip_shutdown: Arc, @@ -1256,6 +1761,7 @@ pub(crate) struct RgbOutputSpender { // receives issued for an in-flight sweep, reused across retries so a repeatedly failing sweep // does not leave a new receive slot behind on every attempt sweep_recipients: Arc>, + rgb_funding_recovery_guard: Arc, } // The sweeper store type is shared with the background processor's persister @@ -1267,7 +1773,7 @@ pub(crate) type BpKvStore = KVStoreSyncWrapper>; pub(crate) type OutputSweeper = ldk_sweep::OutputSweeper< Arc, - Arc, + Arc, Arc, Arc, BpKvStore, @@ -1834,117 +2340,1451 @@ fn normalize_funding_psbt_locktime( Ok(psbt.to_string()) } -// Handle an rgb-lib error that happened while preparing a channel funding transaction in -// FundingGenerationReady. Returns the value to propagate from the event handler: `Err(ReplayEvent)` -// to retry the event (for transient network errors), or `Ok(())` after force-closing the channel -// (for terminal errors). -fn handle_funding_prepare_err( - e: RgbLibError, - channel_manager: &ChannelManager, - temporary_channel_id: &ChannelId, - counterparty_node_id: &PublicKey, -) -> Result<(), ReplayEvent> { - match e { - RgbLibError::Indexer { details } - | RgbLibError::InvalidIndexer { details } - | RgbLibError::Network { details } => { - tracing::error!("Network error during channel opening: {details}"); - Err(ReplayEvent()) +// Funding checkpoint reached after the RGB stock is promoted (fascia consumed, allocations +// swept into the batch transfer) but before the funding tx is handed to LDK. +#[cfg(debug_assertions)] +pub(crate) const FUNDING_CHECKPOINT_AFTER_COLOR: &str = "after-color-before-handoff"; +#[cfg(debug_assertions)] +pub(crate) const FUNDING_CHECKPOINT_HANDOFF_READY: &str = "handoff-ready"; +#[cfg(debug_assertions)] +pub(crate) const FUNDING_CHECKPOINT_HANDED_TO_LDK: &str = "handed-to-ldk"; +#[cfg(debug_assertions)] +pub(crate) const FUNDING_CHECKPOINT_BROADCAST_SAFE: &str = "broadcast-safe-before-broadcast"; +#[cfg(debug_assertions)] +pub(crate) const FUNDING_CHECKPOINT_BROADCASTING: &str = "broadcasting-before-send-end"; +#[cfg(debug_assertions)] +pub(crate) const FUNDING_CHECKPOINT_BROADCAST_COMMITTED: &str = "broadcast-committed"; +#[cfg(debug_assertions)] +pub(crate) const FUNDING_CHECKPOINT_FINALIZED: &str = "finalized-before-cleanup"; +#[cfg(debug_assertions)] +pub(crate) const FUNDING_CHECKPOINT_DURABLY_COMPLETED: &str = "durably-completed-before-ack"; + +// Test-only crash injection: parks the process at a named funding checkpoint when +// `RLN_FUNDING_KILL_AT` matches, so a test harness can SIGKILL it there. Debug builds only. +#[cfg(debug_assertions)] +fn funding_kill_checkpoint(name: &str) { + if std::env::var("RLN_FUNDING_KILL_AT").as_deref() == Ok(name) { + if let Ok(path) = std::env::var("RLN_FUNDING_KILL_READY_PATH") { + let _ = fs::write(path, name); + } + loop { + std::thread::park(); } - e => abort_funding( - e.to_string(), - channel_manager, - temporary_channel_id, - counterparty_node_id, - ), } } -// Give up on a channel funding for a reason retrying cannot fix, closing the channel rather than -// leaving the peer waiting on a funding that will never come. -fn abort_funding( - reason: String, - channel_manager: &ChannelManager, - temporary_channel_id: &ChannelId, - counterparty_node_id: &PublicKey, -) -> Result<(), ReplayEvent> { - tracing::error!("Cannot open channel: {reason}"); - if let Err(close_err) = channel_manager.force_close_broadcasting_latest_txn( - temporary_channel_id, - counterparty_node_id, - reason, - ) { - tracing::error!( - "Failed to abort funding by force-closing the channel {temporary_channel_id} after error: {close_err:?}" - ); +fn rgb_sender_funding_error(context: &str, error: impl std::fmt::Display) -> RgbLibError { + RgbLibError::Internal { + details: format!("{context}: {error}"), } - Ok(()) } -/// Release the funds locked for a channel open that failed before the funding -/// transaction was broadcast. For colored channels this fails the pending RGB -/// batch transfer; for vanilla channels it aborts the pending vanilla tx that -/// was created (and locked the UTXOs) during `FundingGenerationReady`. -async fn handle_open_chan_fail(channel_id: &ChannelId, unlocked_state: Arc) { - let channel_id_hex = channel_id.0.as_hex().to_string(); - if let Some(rgb_info) = - get_rgb_channel_info_optional(channel_id, true, unlocked_state.kv_store.as_ref()) - { - if let Some(batch_transfer_idx) = rgb_info.batch_transfer_idx { - let unlocked_state_copy = unlocked_state.clone(); - let failed = tokio::task::spawn_blocking(move || { - unlocked_state_copy.rgb_fail_transfers(Some(batch_transfer_idx), false, true) - }) - .await - .unwrap(); - if let Err(e) = failed { - tracing::error!( - "Error failing RGB transfer batch_transfer_idx={batch_transfer_idx} for channel {channel_id}: {e:?}" - ); +fn write_rgb_sender_funding_record( + record: &RgbSenderFundingRecord, + kv_store: &dyn KVStoreSync, +) -> Result<(), RgbLibError> { + record.validate()?; + let bytes = serde_json::to_vec(record).map_err(|error| { + rgb_sender_funding_error("cannot serialize sender funding journal", error) + })?; + kv_store + .write( + RGB_SENDER_FUNDING_NAMESPACE, + "", + &record.funding_txid, + bytes, + ) + .map_err(|error| rgb_sender_funding_error("cannot persist sender funding journal", error)) +} + +fn read_rgb_sender_funding_record( + funding_txid: &str, + kv_store: &dyn KVStoreSync, +) -> Result { + let bytes = kv_store + .read(RGB_SENDER_FUNDING_NAMESPACE, "", funding_txid) + .map_err(|error| rgb_sender_funding_error("cannot read sender funding journal", error))?; + let record: RgbSenderFundingRecord = serde_json::from_slice(&bytes) + .map_err(|error| rgb_sender_funding_error("cannot decode sender funding journal", error))?; + record.validate()?; + if record.funding_txid != funding_txid { + return Err(RgbLibError::Internal { + details: "sender funding journal key does not match its transaction ID".to_owned(), + }); + } + Ok(record) +} + +fn read_rgb_sender_funding_record_optional( + funding_txid: &str, + kv_store: &dyn KVStoreSync, +) -> Result, RgbLibError> { + match kv_store.read(RGB_SENDER_FUNDING_NAMESPACE, "", funding_txid) { + Ok(bytes) => { + let record: RgbSenderFundingRecord = + serde_json::from_slice(&bytes).map_err(|error| { + rgb_sender_funding_error("cannot decode sender funding journal", error) + })?; + record.validate()?; + if record.funding_txid != funding_txid { + return Err(RgbLibError::Internal { + details: "sender funding journal key does not match its transaction ID" + .to_owned(), + }); } + Ok(Some(record)) } - } else if let Ok(funding_txid_bytes) = - unlocked_state - .kv_store - .read(PENDING_FUNDING_NAMESPACE, "", &channel_id_hex) - { - let funding_txid = String::from_utf8(funding_txid_bytes).unwrap(); - let unlocked_state_copy = unlocked_state.clone(); - let txid_copy = funding_txid.clone(); - let result = tokio::task::spawn_blocking(move || { - unlocked_state_copy.rgb_abort_pending_vanilla_tx(txid_copy) - }) - .await - .unwrap(); - match result { - Ok(()) => { - tracing::info!("Aborted pending vanilla tx {funding_txid} for channel {channel_id}") - } - Err(e) => tracing::error!( - "Error aborting pending vanilla tx {funding_txid} for channel {channel_id}: {e:?}" + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(rgb_sender_funding_error( + "cannot read sender funding journal", + error, + )), + } +} + +fn remove_rgb_sender_funding_record( + funding_txid: &str, + kv_store: &dyn KVStoreSync, +) -> Result<(), RgbLibError> { + kv_store + .remove(RGB_SENDER_FUNDING_NAMESPACE, "", funding_txid, false) + .map_err(|error| rgb_sender_funding_error("cannot remove sender funding journal", error)) +} + +fn remove_rgb_sender_funding_entry( + namespace: &str, + key: &str, + context: &str, + kv_store: &dyn KVStoreSync, +) -> Result<(), RgbLibError> { + match kv_store.remove(namespace, "", key, false) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(rgb_sender_funding_error(context, error)), + } +} + +fn sender_transfer_status( + wallet: &RgbLibWalletWrapper, + funding_txid: &str, +) -> Result, RgbLibError> { + let transfers = wallet.list_transfers( + rgb_lib::wallet::AssetFilter::AnyOrNone, + Some(funding_txid.to_owned()), + )?; + let mut statuses = transfers.iter().map(|transfer| transfer.status); + let Some(status) = statuses.next() else { + return Ok(None); + }; + if statuses.any(|candidate| candidate != status) { + return Err(RgbLibError::Internal { + details: format!( + "RGB funding transfer '{funding_txid}' has inconsistent transfer statuses" ), - } + }); } - let _ = unlocked_state - .kv_store - .remove(PENDING_FUNDING_NAMESPACE, "", &channel_id_hex, false); + Ok(Some(status)) } -/// Undo what a standard channel's `FundingGenerationReady` preparation staged, so a failure -/// between preparation and a fully-signed funding tx (e.g. a remote external signer briefly -/// unreachable, or returning a malformed reply) can replay the event without leaking locked funds: -/// for a colored channel fail the pending RGB batch transfer (releasing the reserved allocation), -/// for a vanilla channel abort the pending vanilla tx (releasing the locked UTXOs). This runs +fn read_rgb_sender_signed_psbt( + record: &RgbSenderFundingRecord, + kv_store: &dyn KVStoreSync, +) -> Result { + let bytes = kv_store + .read(PSBT_NAMESPACE, "", &record.funding_txid) + .map_err(|error| rgb_sender_funding_error("cannot recover signed funding PSBT", error))?; + let encoded = String::from_utf8(bytes) + .map_err(|error| rgb_sender_funding_error("signed funding PSBT is not UTF-8", error))?; + let psbt = Psbt::from_str(&encoded) + .map_err(|error| rgb_sender_funding_error("signed funding PSBT is invalid", error))?; + let actual_txid = psbt.unsigned_tx.compute_txid().to_string(); + if actual_txid != record.funding_txid { + return Err(RgbLibError::Internal { + details: format!( + "signed funding PSBT transaction '{}' does not match recovery journal '{}'", + actual_txid, record.funding_txid + ), + }); + } + psbt.extract_tx().map_err(|error| { + rgb_sender_funding_error("signed funding PSBT cannot be extracted", error) + })?; + Ok(encoded) +} + +fn rollback_rgb_sender_funding( + mut record: RgbSenderFundingRecord, + wallet: &RgbLibWalletWrapper, + kv_store: &dyn KVStoreSync, +) -> Result<(), RgbLibError> { + record.stage = RgbSenderFundingStage::RollingBack; + write_rgb_sender_funding_record(&record, kv_store)?; + + if let Some((operation_id, _)) = wallet.pending_funding_fascia()? { + if operation_id != record.funding_txid { + return Err(RgbLibError::Internal { + details: format!( + "sender funding '{}' cannot roll back RGB operation '{operation_id}'", + record.funding_txid + ), + }); + } + wallet.rollback_funding_fascia_if_present(&record.funding_txid)?; + } + + match sender_transfer_status(wallet, &record.funding_txid)? { + Some(TransferStatus::Initiated) => { + if !wallet.fail_transfers(Some(record.batch_transfer_idx), false, true)? { + return Err(RgbLibError::Internal { + details: format!( + "RGB funding transfer '{}' remained initiated during rollback", + record.funding_txid + ), + }); + } + } + Some(TransferStatus::Failed) => {} + Some(status) => { + return Err(RgbLibError::Internal { + details: format!( + "refusing to roll back RGB funding '{}' in transfer status {status:?}", + record.funding_txid + ), + }); + } + None => { + return Err(RgbLibError::Internal { + details: format!( + "RGB funding transfer '{}' is missing during rollback", + record.funding_txid + ), + }); + } + } + + // A pre-handoff backup may contain the promoted stock and rollback journal. Do not remove the + // sender recovery record until VSS contains the clean rolled-back wallet state. + wallet.checked_vss_backup()?; + for channel_id in + std::iter::once(&record.temporary_channel_id).chain(record.final_channel_id.iter()) + { + for pending in [false, true] { + if let Err(error) = kv_store.remove_rgb_channel_info(channel_id, pending) { + if error.kind() != io::ErrorKind::NotFound { + return Err(rgb_sender_funding_error( + "cannot remove abandoned RGB channel metadata", + error, + )); + } + } + } + } + if let Some(final_channel_id) = record.final_channel_id.as_ref() { + remove_rgb_sender_funding_entry( + PENDING_FUNDING_NAMESPACE, + final_channel_id, + "cannot remove abandoned pending-funding mapping", + kv_store, + )?; + } + remove_rgb_sender_funding_entry( + PSBT_NAMESPACE, + &record.funding_txid, + "cannot remove abandoned signed funding PSBT", + kv_store, + )?; + + record.stage = RgbSenderFundingStage::RetryRequired; + write_rgb_sender_funding_record(&record, kv_store)?; + remove_rgb_sender_funding_record(&record.funding_txid, kv_store) +} + +fn commit_rgb_sender_broadcast( + mut record: RgbSenderFundingRecord, + wallet: &RgbLibWalletWrapper, + kv_store: &dyn KVStoreSync, +) -> Result { + if sender_transfer_status(wallet, &record.funding_txid)? == Some(TransferStatus::Initiated) { + let signed_psbt = read_rgb_sender_signed_psbt(&record, kv_store)?; + let result = match record.consignment_delivery { + RgbSenderConsignmentDelivery::Proxy => { + wallet.send_end_preconsumed_for_operation(&record.funding_txid, signed_psbt)? + } + RgbSenderConsignmentDelivery::P2p => { + wallet.send_end_db_update_only_for_operation(&record.funding_txid, signed_psbt)? + } + }; + if result.txid != record.funding_txid { + return Err(RgbLibError::Internal { + details: format!( + "RGB funding broadcast returned transaction '{}' instead of '{}'", + result.txid, record.funding_txid + ), + }); + } + } + + match sender_transfer_status(wallet, &record.funding_txid)? { + Some( + TransferStatus::WaitingConfirmations + | TransferStatus::WaitingSafeHeight + | TransferStatus::Settled, + ) => {} + status => { + return Err(RgbLibError::Internal { + details: format!( + "cannot commit RGB funding '{}' from transfer status {status:?}", + record.funding_txid + ), + }); + } + } + + record.stage = RgbSenderFundingStage::BroadcastCommitted; + write_rgb_sender_funding_record(&record, kv_store)?; + #[cfg(debug_assertions)] + funding_kill_checkpoint(FUNDING_CHECKPOINT_BROADCAST_COMMITTED); + Ok(record) +} + +fn rgb_sender_channel_is_durable( + record: &RgbSenderFundingRecord, + channel_manager: &ChannelManager, +) -> bool { + let Some(final_channel_id) = record.final_channel_id.as_deref() else { + return false; + }; + channel_manager + .list_funded_channels() + .into_iter() + .any(|channel| { + channel.channel_id.to_string() == final_channel_id + && channel + .funding_txo + .is_some_and(|outpoint| outpoint.txid.to_string() == record.funding_txid) + }) +} + +fn resume_rgb_sender_broadcast( + mut record: RgbSenderFundingRecord, + channel_manager: &ChannelManager, + wallet: &RgbLibWalletWrapper, + kv_store: &dyn KVStoreSync, +) -> Result<(), RgbLibError> { + if !rgb_sender_channel_is_durable(&record, channel_manager) { + return Err(RgbLibError::Internal { + details: format!( + "cannot resume RGB funding '{}': matching durable channel state is unavailable", + record.funding_txid + ), + }); + } + if matches!( + record.stage, + RgbSenderFundingStage::Finalized | RgbSenderFundingStage::DurablyCompleted + ) { + return Ok(()); + } + if !matches!( + record.stage, + RgbSenderFundingStage::HandoffReady + | RgbSenderFundingStage::HandedToLdk + | RgbSenderFundingStage::BroadcastSafeObserved + | RgbSenderFundingStage::Broadcasting + | RgbSenderFundingStage::BroadcastCommitted + ) { + return Err(RgbLibError::Internal { + details: format!( + "cannot resume RGB funding '{}' from stage {:?}", + record.funding_txid, record.stage + ), + }); + } + + // Validate the complete signed transaction before advancing the durable broadcast intent. + read_rgb_sender_signed_psbt(&record, kv_store)?; + if matches!( + record.stage, + RgbSenderFundingStage::HandoffReady + | RgbSenderFundingStage::HandedToLdk + | RgbSenderFundingStage::BroadcastSafeObserved + ) { + record.stage = RgbSenderFundingStage::Broadcasting; + write_rgb_sender_funding_record(&record, kv_store)?; + } + commit_and_finalize_rgb_sender_funding(record, wallet, kv_store) +} + +fn finalize_rgb_sender_funding( + mut record: RgbSenderFundingRecord, + wallet: &RgbLibWalletWrapper, + kv_store: &dyn KVStoreSync, +) -> Result<(), RgbLibError> { + match sender_transfer_status(wallet, &record.funding_txid)? { + Some( + TransferStatus::WaitingConfirmations + | TransferStatus::WaitingSafeHeight + | TransferStatus::Settled, + ) => {} + status => { + return Err(RgbLibError::Internal { + details: format!( + "cannot finalize RGB funding '{}' from transfer status {status:?}", + record.funding_txid + ), + }); + } + } + + // Persist the broadcast transfer together with the promoted acceptance journal first. If the + // process or device disappears during finalization, this snapshot can deterministically replay + // the exact operation instead of depending on the transport endpoint. + wallet.checked_vss_backup()?; + + if let Some((operation_id, _)) = wallet.pending_funding_fascia()? { + if operation_id != record.funding_txid { + return Err(RgbLibError::Internal { + details: format!( + "sender funding '{}' cannot finalize RGB operation '{operation_id}'", + record.funding_txid + ), + }); + } + wallet.finalize_funding_fascia(&record.funding_txid)?; + } + + // Finalization deletes the stock rollback snapshot. Confirm that the resulting wallet is + // remotely durable before deleting the signed PSBT or advancing the sender tombstone. + wallet.checked_vss_backup()?; + + if let Some(final_channel_id) = record.final_channel_id.as_ref() { + remove_rgb_sender_funding_entry( + PENDING_FUNDING_NAMESPACE, + final_channel_id, + "cannot remove finalized pending-funding mapping", + kv_store, + )?; + } + remove_rgb_sender_funding_entry( + PSBT_NAMESPACE, + &record.funding_txid, + "cannot remove finalized signed funding PSBT", + kv_store, + )?; + + record.stage = RgbSenderFundingStage::Finalized; + write_rgb_sender_funding_record(&record, kv_store)?; + #[cfg(debug_assertions)] + funding_kill_checkpoint(FUNDING_CHECKPOINT_FINALIZED); + Ok(()) +} + +fn commit_and_finalize_rgb_sender_funding( + record: RgbSenderFundingRecord, + wallet: &RgbLibWalletWrapper, + kv_store: &dyn KVStoreSync, +) -> Result<(), RgbLibError> { + if matches!( + record.stage, + RgbSenderFundingStage::Finalized | RgbSenderFundingStage::DurablyCompleted + ) { + return Ok(()); + } + // Always reconcile the wallet's transfer status. A device restored from the last pre-handoff + // RGB backup can have an Initiated transfer while the independently durable sender journal is + // already BroadcastCommitted; replaying the exact signed PSBT is safe and idempotent. + let record = commit_rgb_sender_broadcast(record, wallet, kv_store)?; + finalize_rgb_sender_funding(record, wallet, kv_store) +} + +fn remove_rgb_recovery_entry_if_present( + primary_namespace: &str, + secondary_namespace: &str, + key: &str, + kv_store: &dyn KVStoreSync, +) -> Result<(), RgbLibError> { + match kv_store.remove(primary_namespace, secondary_namespace, key, false) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(rgb_sender_funding_error( + "cannot clean finalized RGB receiver recovery artifact", + error, + )), + } +} + +fn persist_canonical_rgb_channel_info( + channel_id: &str, + expected: &RgbInfo, + kv_store: &SyncedKvStore, +) -> Result<(), RgbLibError> { + let total_amount = |info: &RgbInfo| { + info.local_rgb_amount + .checked_add(info.remote_rgb_amount) + .ok_or_else(|| RgbLibError::Internal { + details: format!("RGB allocation overflows for channel '{channel_id}'"), + }) + }; + let expected_total = total_amount(expected)?; + let canonical = match kv_store.read_rgb_channel_info(channel_id, false) { + Ok(existing) => { + let same_allocation = existing.contract_id == expected.contract_id + && existing.schema == expected.schema + && total_amount(&existing)? == expected_total; + if !same_allocation { + return Err(RgbLibError::Internal { + details: format!( + "refusing to overwrite conflicting RGB metadata for channel '{channel_id}'" + ), + }); + } + // LDK's canonical record is authoritative for the current local/remote balance split. + // The sender journal contains the opening allocation and a transient batch index. + existing + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + let mut reconstructed = expected.clone(); + reconstructed.batch_transfer_idx = None; + reconstructed + } + Err(error) => { + return Err(rgb_sender_funding_error( + "cannot inspect canonical RGB channel metadata", + error, + )); + } + }; + + let bytes = bincode::serialize(&canonical).map_err(|error| { + rgb_sender_funding_error("cannot serialize canonical RGB channel metadata", error) + })?; + kv_store + .write_remote_required(RGB_PRIMARY_NS, RGB_CHANNEL_INFO_NS, channel_id, bytes) + .map_err(|error| { + rgb_sender_funding_error( + "canonical RGB channel metadata was not acknowledged by VSS", + error, + ) + }) +} + +fn complete_finalized_sender_funding( + record: &RgbSenderFundingRecord, + wallet: &RgbLibWalletWrapper, + kv_store: &SyncedKvStore, +) -> Result<(), RgbLibError> { + if record.stage == RgbSenderFundingStage::DurablyCompleted { + return Ok(()); + } + if record.stage != RgbSenderFundingStage::Finalized { + return Err(RgbLibError::Internal { + details: format!( + "sender funding '{}' cannot complete from stage {:?}", + record.funding_txid, record.stage + ), + }); + } + let final_channel_id = + record + .final_channel_id + .as_deref() + .ok_or_else(|| RgbLibError::Internal { + details: format!( + "finalized sender funding '{}' is missing its channel ID", + record.funding_txid + ), + })?; + + match sender_transfer_status(wallet, &record.funding_txid)? { + Some( + TransferStatus::WaitingConfirmations + | TransferStatus::WaitingSafeHeight + | TransferStatus::Settled, + ) => {} + status => { + return Err(RgbLibError::Internal { + details: format!( + "finalized sender funding '{}' has unexpected transfer status {status:?}", + record.funding_txid + ), + }); + } + } + if wallet.pending_funding_fascia()?.is_some() { + return Err(RgbLibError::Internal { + details: format!( + "finalized sender funding '{}' still has a pending stock journal", + record.funding_txid + ), + }); + } + + let rgb_info = match record.rgb_info.as_ref() { + Some(rgb_info) => rgb_info.clone(), + None => kv_store + .read_rgb_channel_info(final_channel_id, false) + .map_err(|error| { + rgb_sender_funding_error( + "legacy finalized sender funding has no recoverable channel metadata", + error, + ) + })?, + }; + + // The stock backup and canonical metadata must both be remotely durable before the pending + // marker is removed. Keep the finalized sender journal as an acknowledgement tombstone: after + // a restart, LDK may still replay FundingTxBroadcastSafe even though startup reconciliation + // has already finalized the exact transaction. Removing the journal here would make that + // replay fail forever. ChannelClosed prunes the tombstone after LDK event ordering proves the + // funding event has been acknowledged. + wallet.checked_vss_backup()?; + persist_canonical_rgb_channel_info(final_channel_id, &rgb_info, kv_store)?; + remove_rgb_sender_funding_entry( + PENDING_FUNDING_NAMESPACE, + final_channel_id, + "cannot remove finalized RGB pending-funding marker", + kv_store, + )?; + + let mut completed = record.clone(); + completed.stage = RgbSenderFundingStage::DurablyCompleted; + write_rgb_sender_funding_record(&completed, kv_store)?; + #[cfg(debug_assertions)] + funding_kill_checkpoint(FUNDING_CHECKPOINT_DURABLY_COMPLETED); + Ok(()) +} + +fn remove_finalized_sender_tombstone_for_channel( + channel_id: &ChannelId, + kv_store: &dyn KVStoreSync, +) { + let channel_id = channel_id.0.as_hex().to_string(); + let keys = match kv_store.list(RGB_SENDER_FUNDING_NAMESPACE, "") { + Ok(keys) => keys, + Err(error) => { + tracing::warn!( + channel_id, + error = %error, + "cannot list finalized RGB funding tombstones after channel close" + ); + return; + } + }; + for key in keys { + let record = match read_rgb_sender_funding_record(&key, kv_store) { + Ok(record) => record, + Err(error) => { + tracing::warn!( + funding_txid = key, + error = %error, + "cannot inspect RGB funding tombstone after channel close" + ); + continue; + } + }; + if record.stage != RgbSenderFundingStage::DurablyCompleted + || record.final_channel_id.as_deref() != Some(channel_id.as_str()) + { + continue; + } + if let Err(error) = remove_rgb_sender_funding_record(&record.funding_txid, kv_store) { + tracing::warn!( + funding_txid = %record.funding_txid, + channel_id, + error = %error, + "cannot remove finalized RGB funding tombstone after channel close" + ); + } + } +} + +fn receiver_final_channel_id(record: &PendingFundingAcceptance) -> Result { + let funding_txid = Txid::from_str(&record.funding_txid).map_err(|error| { + rgb_sender_funding_error("invalid finalized receiver funding transaction ID", error) + })?; + Ok( + ChannelId::v1_from_funding_txid(funding_txid.as_byte_array(), record.funding_output_index) + .0 + .as_hex() + .to_string(), + ) +} + +fn complete_finalized_receiver_funding( + record: &PendingFundingAcceptance, + wallet: &RgbLibWalletWrapper, + kv_store: &SyncedKvStore, +) -> Result<(), RgbLibError> { + if record.stage != FundingAcceptanceStage::Finalized { + return Err(RgbLibError::Internal { + details: format!( + "receiver funding '{}' cannot complete from stage {:?}", + record.funding_txid, record.stage + ), + }); + } + let consignment = record + .consignment + .clone() + .ok_or_else(|| RgbLibError::Internal { + details: format!( + "finalized receiver funding '{}' is missing its consignment", + record.funding_txid + ), + })?; + let rgb_info = record + .rgb_info + .as_ref() + .ok_or_else(|| RgbLibError::Internal { + details: format!( + "finalized receiver funding '{}' is missing channel metadata", + record.funding_txid + ), + })?; + + kv_store + .write( + FUNDING_CONSIGNMENT_NAMESPACE, + "", + &record.funding_txid, + consignment.clone(), + ) + .map_err(|error| { + rgb_sender_funding_error( + "cannot retain finalized receiver consignment for wallet recovery", + error, + ) + })?; + + wallet.ensure_finalized_funding_transfer( + &record.funding_txid, + record.funding_output_index as u32, + consignment, + rgb_info, + STATIC_BLINDING, + )?; + wallet.checked_vss_backup()?; + + let final_channel_id = receiver_final_channel_id(record)?; + persist_canonical_rgb_channel_info(&final_channel_id, rgb_info, kv_store)?; + + for (namespace, key) in [ + (RGB_CHANNEL_INFO_NS, record.temporary_channel_id.as_str()), + ( + RGB_CHANNEL_INFO_PENDING_NS, + record.temporary_channel_id.as_str(), + ), + (RGB_CONSIGNMENT_NS, record.temporary_channel_id.as_str()), + (RGB_CONSIGNMENT_NS, record.funding_txid.as_str()), + (RGB_CONSIGNMENT_NS, final_channel_id.as_str()), + ] { + remove_rgb_recovery_entry_if_present(RGB_PRIMARY_NS, namespace, key, kv_store)?; + } + remove_pending_funding_acceptance(&record.temporary_channel_id, kv_store).map_err(|error| { + rgb_sender_funding_error("cannot remove finalized RGB receiver journal", error) + }) +} + +fn write_receiver_funding_stage( + record: &PendingFundingAcceptance, + stage: FundingAcceptanceStage, + kv_store: &dyn KVStoreSync, +) -> Result { + let mut updated = record.clone(); + updated.stage = stage; + write_pending_funding_acceptance(&updated, kv_store).map_err(|error| { + rgb_sender_funding_error("cannot persist RGB receiver funding stage", error) + })?; + Ok(updated) +} + +fn remove_receiver_funding_journal( + temporary_channel_id: &str, + kv_store: &dyn KVStoreSync, +) -> Result<(), RgbLibError> { + match remove_pending_funding_acceptance(temporary_channel_id, kv_store) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(rgb_sender_funding_error( + "cannot remove resolved RGB receiver journal", + error, + )), + } +} + +fn remove_rolled_back_receiver_artifacts( + record: &PendingFundingAcceptance, + kv_store: &dyn KVStoreSync, +) -> Result<(), RgbLibError> { + let final_channel_id = receiver_final_channel_id(record)?; + for (namespace, key) in [ + (RGB_CHANNEL_INFO_NS, record.temporary_channel_id.as_str()), + ( + RGB_CHANNEL_INFO_PENDING_NS, + record.temporary_channel_id.as_str(), + ), + (RGB_CHANNEL_INFO_NS, final_channel_id.as_str()), + (RGB_CHANNEL_INFO_PENDING_NS, final_channel_id.as_str()), + (RGB_CONSIGNMENT_NS, record.temporary_channel_id.as_str()), + (RGB_CONSIGNMENT_NS, record.funding_txid.as_str()), + (RGB_CONSIGNMENT_NS, final_channel_id.as_str()), + ] { + remove_rgb_recovery_entry_if_present(RGB_PRIMARY_NS, namespace, key, kv_store)?; + } + Ok(()) +} + +fn rollback_receiver_funding( + record: &PendingFundingAcceptance, + wallet: &RgbLibWalletWrapper, + kv_store: &SyncedKvStore, +) -> Result<(), RgbLibError> { + let rolling_back = if record.stage == FundingAcceptanceStage::RollingBack { + record.clone() + } else { + write_receiver_funding_stage(record, FundingAcceptanceStage::RollingBack, kv_store)? + }; + + if let Some((operation_id, _)) = wallet.pending_funding_fascia()? { + if operation_id != rolling_back.funding_txid { + return Err(RgbLibError::Internal { + details: format!( + "receiver funding '{}' cannot roll back RGB operation '{operation_id}'", + rolling_back.funding_txid + ), + }); + } + wallet.rollback_funding_fascia_if_present(&rolling_back.funding_txid)?; + } + + // A backup may have captured the staged or promoted stock. Keep the recovery journal until + // the restored stock is remotely durable, then remove all temporary and derived artifacts. + wallet.checked_vss_backup()?; + remove_rolled_back_receiver_artifacts(&rolling_back, kv_store)?; + let retry_required = write_receiver_funding_stage( + &rolling_back, + FundingAcceptanceStage::RetryRequired, + kv_store, + )?; + remove_receiver_funding_journal(&retry_required.temporary_channel_id, kv_store) +} + +fn finalize_receiver_funding( + record: &PendingFundingAcceptance, + wallet: &RgbLibWalletWrapper, + kv_store: &SyncedKvStore, +) -> Result<(), RgbLibError> { + let finalizing = if record.stage == FundingAcceptanceStage::Finalizing { + record.clone() + } else { + write_receiver_funding_stage(record, FundingAcceptanceStage::Finalizing, kv_store)? + }; + let consignment = finalizing + .consignment + .clone() + .ok_or_else(|| RgbLibError::Internal { + details: format!( + "receiver funding '{}' is missing its durable consignment", + finalizing.funding_txid + ), + })?; + let rgb_info = finalizing + .rgb_info + .as_ref() + .ok_or_else(|| RgbLibError::Internal { + details: format!( + "receiver funding '{}' is missing durable channel metadata", + finalizing.funding_txid + ), + })?; + + wallet.ensure_finalized_funding_transfer( + &finalizing.funding_txid, + finalizing.funding_output_index as u32, + consignment, + rgb_info, + STATIC_BLINDING, + )?; + let finalized = + write_receiver_funding_stage(&finalizing, FundingAcceptanceStage::Finalized, kv_store)?; + complete_finalized_receiver_funding(&finalized, wallet, kv_store) +} + +fn reconcile_receiver_funding_record( + record: &PendingFundingAcceptance, + funded_channel_ids: &BTreeSet, + wallet: &RgbLibWalletWrapper, + kv_store: &SyncedKvStore, +) -> Result, RgbLibError> { + let final_channel_id = receiver_final_channel_id(record)?; + let channel_is_durable = funded_channel_ids.contains(&final_channel_id); + + match rgb_receiver_recovery_action(record.stage, channel_is_durable) { + RgbReceiverRecoveryAction::Rollback => { + rollback_receiver_funding(record, wallet, kv_store)?; + Ok(None) + } + RgbReceiverRecoveryAction::Finalize => { + finalize_receiver_funding(record, wallet, kv_store)?; + Ok(None) + } + RgbReceiverRecoveryAction::Complete => { + complete_finalized_receiver_funding(record, wallet, kv_store)?; + Ok(None) + } + RgbReceiverRecoveryAction::Quarantine => { + rgb_receiver_funding_recovery_view(record, channel_is_durable, None).map(Some) + } + } +} + +fn funded_channel_ids(channel_manager: &ChannelManager) -> BTreeSet { + channel_manager + .list_funded_channels() + .into_iter() + .map(|channel| channel.channel_id.0.as_hex().to_string()) + .collect() +} + +fn reconcile_rgb_receiver_funding( + channel_manager: &ChannelManager, + wallet: &RgbLibWalletWrapper, + kv_store: &SyncedKvStore, +) -> Result<(usize, Vec), RgbLibError> { + let keys = kv_store + .list(RGB_PRIMARY_NS, RGB_FUNDING_ACCEPTANCE_NS) + .map_err(|error| { + rgb_sender_funding_error("cannot list RGB receiver funding journals", error) + })?; + let funded_channel_ids = funded_channel_ids(channel_manager); + let mut completed = 0; + let mut unresolved = Vec::new(); + for key in keys { + let record = read_pending_funding_acceptance(&key, kv_store).map_err(|error| { + rgb_sender_funding_error("cannot read RGB receiver funding journal", error) + })?; + match reconcile_receiver_funding_record(&record, &funded_channel_ids, wallet, kv_store) { + Ok(None) => completed += 1, + Ok(Some(recovery)) => { + tracing::warn!( + funding_txid = %record.funding_txid, + temporary_channel_id = %record.temporary_channel_id, + final_channel_id = ?recovery.final_channel_id, + stage = ?record.stage, + "quarantining RGB receiver funding until matching LDK channel state is durable" + ); + unresolved.push(recovery); + } + Err(error) => { + let final_channel_id = receiver_final_channel_id(&record)?; + let channel_is_durable = funded_channel_ids.contains(&final_channel_id); + tracing::error!( + funding_txid = %record.funding_txid, + temporary_channel_id = %record.temporary_channel_id, + stage = ?record.stage, + error = %error, + "RGB receiver funding reconciliation failed; preserving recovery evidence" + ); + unresolved.push(rgb_receiver_funding_recovery_view( + &record, + channel_is_durable, + Some(error.to_string()), + )?); + } + } + } + Ok((completed, unresolved)) +} + +pub(crate) fn reconcile_rgb_sender_funding( + channel_manager: &ChannelManager, + wallet: &RgbLibWalletWrapper, + kv_store: &SyncedKvStore, +) -> Result, RgbLibError> { + let keys = kv_store + .list(RGB_SENDER_FUNDING_NAMESPACE, "") + .map_err(|error| rgb_sender_funding_error("cannot list sender funding journals", error))?; + + let mut unresolved = Vec::new(); + for key in keys { + let record = read_rgb_sender_funding_record(&key, kv_store)?; + if record.stage == RgbSenderFundingStage::RetryRequired { + if let Err(error) = remove_rgb_sender_funding_record(&record.funding_txid, kv_store) { + tracing::error!( + funding_txid = %record.funding_txid, + error = %error, + "cannot remove completed RGB sender recovery evidence; continuing startup in quarantine" + ); + unresolved.push(rgb_funding_recovery_view( + &record, + false, + Ok(None), + Some(&error), + )); + } + continue; + } + let channel_is_durable = rgb_sender_channel_is_durable(&record, channel_manager); + if record.stage == RgbSenderFundingStage::DurablyCompleted && channel_is_durable { + continue; + } + if record.stage == RgbSenderFundingStage::Finalized && channel_is_durable { + if let Err(error) = complete_finalized_sender_funding(&record, wallet, kv_store) { + tracing::error!( + funding_txid = %record.funding_txid, + error = %error, + "retaining finalized RGB sender journal after recovery completion failed" + ); + unresolved.push(rgb_funding_recovery_view( + &record, + true, + Ok(None), + Some(&error), + )); + } + continue; + } + + let deterministic_action = rgb_sender_recovery_action(&record, channel_is_durable, false); + let must_check_chain = deterministic_action == RgbSenderRecoveryAction::FailClosed; + let transaction_observation = if must_check_chain { + wallet.is_tx_known(record.funding_txid.clone()).map(Some) + } else { + Ok(None) + }; + let transaction_is_known = match transaction_observation.as_ref() { + Ok(value) => *value, + Err(error) => { + tracing::warn!( + funding_txid = %record.funding_txid, + error = %error, + "deferring RGB sender recovery until chain evidence is available" + ); + unresolved.push(rgb_funding_recovery_view( + &record, + channel_is_durable, + Err(error), + None, + )); + continue; + } + }; + + let recovery_action = rgb_sender_recovery_action( + &record, + channel_is_durable, + transaction_is_known.unwrap_or(false), + ); + let recovery_record = record.clone(); + let recovery_result = match recovery_action { + RgbSenderRecoveryAction::Finalize => { + let funding_txid = record.funding_txid.clone(); + commit_and_finalize_rgb_sender_funding(record, wallet, kv_store) + .and_then(|()| read_rgb_sender_funding_record(&funding_txid, kv_store)) + .and_then(|finalized| { + complete_finalized_sender_funding(&finalized, wallet, kv_store) + }) + } + RgbSenderRecoveryAction::ResumeBroadcast => { + let funding_txid = record.funding_txid.clone(); + tracing::info!( + funding_txid, + stage = ?record.stage, + "resuming exact RGB funding transaction from durable LDK state" + ); + resume_rgb_sender_broadcast(record, channel_manager, wallet, kv_store) + .and_then(|()| read_rgb_sender_funding_record(&funding_txid, kv_store)) + .and_then(|finalized| { + complete_finalized_sender_funding(&finalized, wallet, kv_store) + }) + } + RgbSenderRecoveryAction::Rollback => { + rollback_rgb_sender_funding(record, wallet, kv_store) + } + RgbSenderRecoveryAction::FailClosed => { + let recovery = rgb_funding_recovery_view( + &record, + channel_is_durable, + Ok(transaction_is_known), + None, + ); + tracing::error!( + funding_txid = %record.funding_txid, + required_action = recovery.action.as_str(), + "RGB funding requires explicit recovery; automatic mutation is disabled" + ); + unresolved.push(recovery); + Ok(()) + } + }; + if let Err(error) = recovery_result { + tracing::error!( + funding_txid = %recovery_record.funding_txid, + stage = ?recovery_record.stage, + action = ?recovery_action, + error = %error, + "RGB sender reconciliation failed; preserving recovery evidence and continuing startup" + ); + unresolved.push(rgb_funding_recovery_view( + &recovery_record, + channel_is_durable, + Ok(transaction_is_known), + Some(&error), + )); + } + } + Ok(unresolved) +} + +fn should_complete_deferred_rgb_consistency_check( + was_deferred: bool, + pending_stock_operation: Option<&str>, + unresolved: &[RgbFundingRecoveryState], +) -> Result { + match (was_deferred, pending_stock_operation) { + (false, None) => Ok(false), + (true, None) => Ok(true), + (true, Some(operation_id)) + if unresolved + .iter() + .any(|recovery| recovery.funding_txid == operation_id) => + { + Ok(false) + } + (true, Some(operation_id)) => Err(RgbLibError::Internal { + details: format!( + "RGB stock operation '{operation_id}' has no matching durable funding recovery record" + ), + }), + (false, Some(operation_id)) => Err(RgbLibError::Internal { + details: format!( + "RGB stock operation '{operation_id}' appeared after the startup consistency check" + ), + }), + } +} + +// Handle an rgb-lib error that happened while preparing a channel funding transaction in +// FundingGenerationReady. Returns the value to propagate from the event handler: `Err(ReplayEvent)` +// to retry the event (for transient network errors), or `Ok(())` after force-closing the channel +// (for terminal errors). +fn handle_funding_prepare_err( + e: RgbLibError, + channel_manager: &ChannelManager, + temporary_channel_id: &ChannelId, + counterparty_node_id: &PublicKey, +) -> Result<(), ReplayEvent> { + match e { + RgbLibError::Indexer { details } + | RgbLibError::InvalidIndexer { details } + | RgbLibError::Network { details } => { + tracing::error!("Network error during channel opening: {details}"); + Err(ReplayEvent()) + } + e => abort_funding( + e.to_string(), + channel_manager, + temporary_channel_id, + counterparty_node_id, + ), + } +} + +// Give up on a channel funding for a reason retrying cannot fix, closing the channel rather than +// leaving the peer waiting on a funding that will never come. +fn abort_funding( + reason: String, + channel_manager: &ChannelManager, + temporary_channel_id: &ChannelId, + counterparty_node_id: &PublicKey, +) -> Result<(), ReplayEvent> { + tracing::error!("Cannot open channel: {reason}"); + if let Err(close_err) = channel_manager.force_close_broadcasting_latest_txn( + temporary_channel_id, + counterparty_node_id, + reason, + ) { + tracing::error!( + "Failed to abort funding by force-closing the channel {temporary_channel_id} after error: {close_err:?}" + ); + } + Ok(()) +} + +/// Release the funds locked for a channel open that failed before the funding +/// transaction was broadcast. For colored channels this fails the pending RGB +/// batch transfer; for vanilla channels it aborts the pending vanilla tx that +/// was created (and locked the UTXOs) during `FundingGenerationReady`. +async fn handle_open_chan_fail(channel_id: &ChannelId, unlocked_state: Arc) { + let channel_id_hex = channel_id.0.as_hex().to_string(); + if let Some(mut rgb_info) = + get_rgb_channel_info_optional(channel_id, true, unlocked_state.kv_store.as_ref()) + { + let _rgb_funding_operation = unlocked_state + .rgb_funding_recovery_guard + .lock_operation() + .await; + let funding_txid_bytes = + match unlocked_state + .kv_store + .read(PENDING_FUNDING_NAMESPACE, "", &channel_id_hex) + { + Ok(bytes) => bytes, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + tracing::debug!( + channel_id = %channel_id, + "channel has no pending funding marker; skipping pre-broadcast RGB cleanup" + ); + return; + } + Err(error) => { + tracing::error!( + channel_id = %channel_id, + error = %error, + "cannot determine whether RGB channel funding is still pending" + ); + return; + } + }; + let funding_txid = match String::from_utf8(funding_txid_bytes) { + Ok(txid) => txid, + Err(error) => { + tracing::error!( + channel_id = %channel_id, + error = %error, + "pending RGB funding marker is not valid UTF-8" + ); + return; + } + }; + match read_rgb_sender_funding_record_optional( + &funding_txid, + unlocked_state.kv_store.as_ref(), + ) { + Ok(Some(record)) => { + let unlocked_state_copy = unlocked_state.clone(); + let resolved = match tokio::task::spawn_blocking(move || { + match record.stage { + RgbSenderFundingStage::Broadcasting + | RgbSenderFundingStage::BroadcastCommitted + | RgbSenderFundingStage::Finalized + | RgbSenderFundingStage::DurablyCompleted => { + commit_and_finalize_rgb_sender_funding( + record, + unlocked_state_copy.rgb_wallet_wrapper.as_ref(), + unlocked_state_copy.kv_store.as_ref(), + ) + } + RgbSenderFundingStage::Preparing + | RgbSenderFundingStage::StockPromoted => rollback_rgb_sender_funding( + record, + unlocked_state_copy.rgb_wallet_wrapper.as_ref(), + unlocked_state_copy.kv_store.as_ref(), + ), + RgbSenderFundingStage::HandoffReady + | RgbSenderFundingStage::HandedToLdk + | RgbSenderFundingStage::BroadcastSafeObserved + if record.manual_broadcast => + { + rollback_rgb_sender_funding( + record, + unlocked_state_copy.rgb_wallet_wrapper.as_ref(), + unlocked_state_copy.kv_store.as_ref(), + ) + } + _ => Err(RgbLibError::Internal { + details: "legacy RGB funding crossed the automatic-broadcast handoff; retaining its recovery journal" + .to_owned(), + }), + } + }) + .await + { + Ok(resolved) => resolved, + Err(error) => { + tracing::error!( + channel_id = %channel_id, + error = %error, + "RGB channel cleanup worker failed; retaining recovery state" + ); + return; + } + }; + if let Err(error) = resolved { + tracing::error!( + "Refusing to release RGB transfer state for channel {channel_id}: {error:?}" + ); + return; + } + let _ = unlocked_state.kv_store.remove( + PENDING_FUNDING_NAMESPACE, + "", + &channel_id_hex, + false, + ); + return; + } + Ok(None) => {} + Err(error) => { + tracing::error!( + "Cannot inspect RGB sender funding journal for channel {channel_id}: {error:?}" + ); + return; + } + } + let unlocked_state_copy = unlocked_state.clone(); + let rollback_funding_txid = funding_txid.clone(); + let rollback = match tokio::task::spawn_blocking(move || { + unlocked_state_copy.rgb_rollback_funding_fascia_if_present(&rollback_funding_txid) + }) + .await + { + Ok(rollback) => rollback, + Err(error) => { + tracing::error!( + channel_id = %channel_id, + error = %error, + "RGB stock rollback worker failed; retaining recovery state" + ); + return; + } + }; + if let Err(error) = rollback { + tracing::error!( + "Refusing to release RGB transfer state for channel {channel_id} after stock rollback failed: {error:?}" + ); + return; + } + if let Some(batch_transfer_idx) = rgb_info.batch_transfer_idx { + let unlocked_state_copy = unlocked_state.clone(); + let failed = match tokio::task::spawn_blocking(move || { + unlocked_state_copy.rgb_fail_transfers(Some(batch_transfer_idx), false, true) + }) + .await + { + Ok(failed) => failed, + Err(error) => { + tracing::error!( + channel_id = %channel_id, + batch_transfer_idx, + error = %error, + "RGB transfer cleanup worker failed; retaining recovery state" + ); + return; + } + }; + match failed { + Ok(_) => { + rgb_info.batch_transfer_idx = None; + unlocked_state.kv_store.write_rgb_channel_info( + &channel_id_hex, + &rgb_info, + true, + ); + } + Err(e) => { + tracing::error!( + "Error failing RGB transfer batch_transfer_idx={batch_transfer_idx} for channel {channel_id}: {e:?}" + ); + return; + } + } + } + } else if let Ok(funding_txid_bytes) = + unlocked_state + .kv_store + .read(PENDING_FUNDING_NAMESPACE, "", &channel_id_hex) + { + let funding_txid = match String::from_utf8(funding_txid_bytes) { + Ok(funding_txid) => funding_txid, + Err(error) => { + tracing::error!( + channel_id = %channel_id, + error = %error, + "pending vanilla funding marker is not valid UTF-8" + ); + return; + } + }; + let unlocked_state_copy = unlocked_state.clone(); + let txid_copy = funding_txid.clone(); + let result = match tokio::task::spawn_blocking(move || { + unlocked_state_copy.rgb_abort_pending_vanilla_tx(txid_copy) + }) + .await + { + Ok(result) => result, + Err(error) => { + tracing::error!( + channel_id = %channel_id, + error = %error, + "vanilla funding cleanup worker failed; retaining recovery state" + ); + return; + } + }; + match result { + Ok(()) => { + tracing::info!("Aborted pending vanilla tx {funding_txid} for channel {channel_id}") + } + Err(e) => { + tracing::error!( + "Error aborting pending vanilla tx {funding_txid} for channel {channel_id}: {e:?}" + ); + return; + } + } + } + let _ = unlocked_state + .kv_store + .remove(PENDING_FUNDING_NAMESPACE, "", &channel_id_hex, false); +} + +/// Undo what a standard channel's `FundingGenerationReady` preparation staged, so a failure +/// between preparation and a fully-signed funding tx (e.g. a remote external signer briefly +/// unreachable, or returning a malformed reply) can replay the event without leaking locked funds: +/// for a colored channel fail the pending RGB batch transfer (releasing the reserved allocation), +/// for a vanilla channel abort the pending vanilla tx (releasing the locked UTXOs). This runs /// *before* the `PENDING_FUNDING_NAMESPACE` mapping exists, so `handle_open_chan_fail` could not do -/// the vanilla cleanup later — the staged tx would be unabortable. Best-effort: errors are logged -/// and the caller still replays the event. +/// the vanilla cleanup later — the staged tx would be unabortable. Cleanup failure is terminal: +/// replay is unsafe until the staged stock and transfer reservation are both released. async fn abort_staged_standard_funding( unlocked_state: Arc, temporary_channel_id: &ChannelId, unsigned_psbt: &str, is_colored: bool, -) { +) -> Result<(), RgbLibError> { if is_colored { + let psbt = RgbLibPsbt::from_str(unsigned_psbt).map_err(|error| RgbLibError::Internal { + details: format!("cannot parse staged RGB funding PSBT: {error}"), + })?; + let funding_txid = psbt.unsigned_tx.compute_txid().to_string(); + if let Some(record) = read_rgb_sender_funding_record_optional( + &funding_txid, + unlocked_state.kv_store.as_ref(), + )? { + let unlocked_state_copy = unlocked_state.clone(); + return tokio::task::spawn_blocking(move || { + rollback_rgb_sender_funding( + record, + unlocked_state_copy.rgb_wallet_wrapper.as_ref(), + unlocked_state_copy.kv_store.as_ref(), + ) + }) + .await + .map_err(|error| { + rgb_sender_funding_error("RGB sender rollback worker failed", error) + })?; + } + + // Legacy fallback for an in-flight open created before sender journals were introduced. + let unlocked_state_copy = unlocked_state.clone(); + tokio::task::spawn_blocking(move || { + unlocked_state_copy.rgb_rollback_funding_fascia_if_present(&funding_txid) + }) + .await + .map_err(|error| rgb_sender_funding_error("RGB stock rollback worker failed", error))??; + if let Some(mut rgb_info) = get_rgb_channel_info_optional( temporary_channel_id, true, @@ -1952,27 +3792,21 @@ async fn abort_staged_standard_funding( ) { if let Some(batch_transfer_idx) = rgb_info.batch_transfer_idx { let unlocked_state_copy = unlocked_state.clone(); - let failed = tokio::task::spawn_blocking(move || { + tokio::task::spawn_blocking(move || { unlocked_state_copy.rgb_fail_transfers(Some(batch_transfer_idx), false, true) }) .await - .unwrap(); - match failed { - Ok(_) => { - // Clear the recorded idx: the transfer is already failed, and the replayed - // event will stage a fresh transfer and record its own idx. - rgb_info.batch_transfer_idx = None; - unlocked_state.kv_store.write_rgb_channel_info( - &temporary_channel_id.0.as_hex().to_string(), - &rgb_info, - true, - ); - } - Err(e) => tracing::error!( - "Error failing staged RGB transfer batch_transfer_idx={batch_transfer_idx} \ - for channel {temporary_channel_id}: {e:?}" - ), - } + .map_err(|error| { + rgb_sender_funding_error("RGB transfer cleanup worker failed", error) + })??; + // Clear the recorded idx: the transfer is already failed, and the replayed event + // will stage a fresh transfer and record its own idx. + rgb_info.batch_transfer_idx = None; + unlocked_state.kv_store.write_rgb_channel_info( + &temporary_channel_id.0.as_hex().to_string(), + &rgb_info, + true, + ); } } } else { @@ -1987,23 +3821,26 @@ async fn abort_staged_standard_funding( unlocked_state_copy.rgb_abort_pending_vanilla_tx(txid_copy) }) .await - .unwrap(); + .map_err(|error| { + rgb_sender_funding_error("vanilla funding cleanup worker failed", error) + })?; match result { Ok(()) => tracing::info!( "Aborted staged vanilla funding tx {txid} for channel {temporary_channel_id}" ), - Err(e) => tracing::error!( - "Error aborting staged vanilla funding tx {txid} for channel \ - {temporary_channel_id}: {e:?}" - ), + Err(e) => return Err(e), } } - Err(e) => tracing::error!( - "cannot parse staged funding PSBT while cleaning up channel \ - {temporary_channel_id}: {e}" - ), + Err(e) => { + return Err(RgbLibError::Internal { + details: format!( + "cannot parse staged funding PSBT for channel {temporary_channel_id}: {e}" + ), + }); + } } } + Ok(()) } async fn handle_ldk_events( @@ -2021,6 +3858,16 @@ async fn handle_ldk_events( } => { let is_colored = is_channel_rgb(&temporary_channel_id, unlocked_state.kv_store.as_ref()); + let _rgb_funding_operation = if is_colored { + Some( + unlocked_state + .rgb_funding_recovery_guard + .lock_operation() + .await, + ) + } else { + None + }; let addr = WitnessProgram::from_scriptpubkey( output_script.as_bytes(), @@ -2371,7 +4218,7 @@ async fn handle_ldk_events( return Ok(()); } - let (unsigned_psbt, asset_id) = if is_colored { + let (unsigned_psbt, asset_id, mut sender_record) = if is_colored { let rgb_info = get_rgb_channel_info_pending( &temporary_channel_id, unlocked_state.kv_store.as_ref(), @@ -2403,8 +4250,9 @@ async fn handle_ldk_events( let fee_rate_sat_vb = unlocked_state.config.rgb.fee_rate_sat_vb; let min_channel_confirmations = unlocked_state.config.rgb.min_channel_confirmations; let unlocked_state_copy = unlocked_state.clone(); - let res = tokio::task::spawn_blocking( - move || -> Result<(String, Option), RgbLibError> { + let temporary_channel_id_hex = temporary_channel_id.0.as_hex().to_string(); + let res = match tokio::task::spawn_blocking( + move || -> Result<(String, Option, RgbSenderFundingRecord), RgbLibError> { let res = unlocked_state_copy.rgb_send_begin( recipient_map, true, @@ -2415,16 +4263,127 @@ async fn handle_ldk_events( // Final locktime: this colored tx funds an LN channel. Some(0), )?; - let fascia_str = fs::read_to_string(&res.details.fascia_path).unwrap(); - let fascia: Fascia = serde_json::from_str(&fascia_str).unwrap(); - unlocked_state_copy.rgb_consume_fascia(fascia, None)?; + let fascia_str = fs::read_to_string(&res.details.fascia_path)?; + let fascia: Fascia = + serde_json::from_str(&fascia_str).map_err(|error| { + RgbLibError::Internal { + details: format!("invalid funding fascia: {error}"), + } + })?; unlocked_state_copy.rgb_create_consignments(res.psbt.clone())?; - Ok((res.psbt, res.batch_transfer_idx)) + let funding_psbt = RgbLibPsbt::from_str(&res.psbt).map_err(|error| { + RgbLibError::Internal { + details: format!("invalid funding PSBT: {error}"), + } + })?; + let funding_txid = funding_psbt.unsigned_tx.compute_txid().to_string(); + let batch_transfer_idx = res.batch_transfer_idx.ok_or_else(|| { + RgbLibError::Internal { + details: "RGB funding transfer has no batch transfer ID".to_owned(), + } + })?; + let mut journal_rgb_info = rgb_info.clone(); + journal_rgb_info.batch_transfer_idx = Some(batch_transfer_idx); + let mut sender_record = RgbSenderFundingRecord { + version: RgbSenderFundingRecord::VERSION, + manual_broadcast: true, + temporary_channel_id: temporary_channel_id_hex, + final_channel_id: None, + funding_txid: funding_txid.clone(), + batch_transfer_idx, + rgb_info: Some(journal_rgb_info), + consignment_delivery: RgbSenderConsignmentDelivery::P2p, + stage: RgbSenderFundingStage::Preparing, + }; + if let Err(error) = write_rgb_sender_funding_record( + &sender_record, + unlocked_state_copy.kv_store.as_ref(), + ) { + let cleanup = unlocked_state_copy.rgb_fail_transfers( + Some(batch_transfer_idx), + false, + true, + ); + return match cleanup { + Ok(true) => Err(error), + Ok(false) => Err(RgbLibError::Internal { + details: format!( + "cannot persist RGB sender funding journal ({error}); the newly created transfer could not be released" + ), + }), + Err(cleanup_error) => Err(RgbLibError::Internal { + details: format!( + "cannot persist RGB sender funding journal ({error}); transfer cleanup also failed: {cleanup_error}" + ), + }), + }; + } + if let Err(error) = unlocked_state_copy + .rgb_prepare_funding_fascia(funding_txid, fascia) + { + let cleanup = rollback_rgb_sender_funding( + sender_record, + unlocked_state_copy.rgb_wallet_wrapper.as_ref(), + unlocked_state_copy.kv_store.as_ref(), + ); + return match cleanup { + Ok(()) => Err(error), + Err(cleanup_error) => Err(cleanup_error), + }; + } + sender_record.stage = RgbSenderFundingStage::StockPromoted; + if let Err(error) = write_rgb_sender_funding_record( + &sender_record, + unlocked_state_copy.kv_store.as_ref(), + ) { + let cleanup = rollback_rgb_sender_funding( + sender_record.clone(), + unlocked_state_copy.rgb_wallet_wrapper.as_ref(), + unlocked_state_copy.kv_store.as_ref(), + ); + return match cleanup { + Ok(()) => Err(error), + Err(cleanup_error) => Err(RgbLibError::Internal { + details: format!( + "cannot persist promoted RGB sender funding state ({error}); rollback also failed: {cleanup_error}" + ), + }), + }; + } + if let Err(error) = unlocked_state_copy + .rgb_wallet_wrapper + .checked_vss_backup() + { + let cleanup = rollback_rgb_sender_funding( + sender_record, + unlocked_state_copy.rgb_wallet_wrapper.as_ref(), + unlocked_state_copy.kv_store.as_ref(), + ); + return match cleanup { + Ok(()) => Err(error), + Err(cleanup_error) => Err(cleanup_error), + }; + } + Ok((res.psbt, Some(batch_transfer_idx), sender_record)) }, ) .await - .unwrap(); - let (unsigned_psbt, batch_transfer_idx) = match res { + { + Ok(result) => result, + Err(error) => { + return handle_funding_prepare_err( + RgbLibError::Internal { + details: format!( + "RGB channel funding preparation worker failed: {error}" + ), + }, + &unlocked_state.channel_manager, + &temporary_channel_id, + &counterparty_node_id, + ); + } + }; + let (unsigned_psbt, batch_transfer_idx, sender_record) = match res { Ok(result) => result, // A failed funding preparation (e.g. the asset allocation is // momentarily reserved by a concurrent open) must fail the @@ -2455,7 +4414,7 @@ async fn handle_ldk_events( true, ); } - (unsigned_psbt, Some(asset_id)) + (unsigned_psbt, Some(asset_id), Some(sender_record)) } else { // Mirror the colored path: a failed funding preparation must fail // the channel (so the caller can retry) rather than panic the event @@ -2488,8 +4447,10 @@ async fn handle_ldk_events( return Err(ReplayEvent()); } }; - (unsigned_psbt, None) + (unsigned_psbt, None, None) }; + #[cfg(debug_assertions)] + funding_kill_checkpoint(FUNDING_CHECKPOINT_AFTER_COLOR); // With a remote external signer this call crosses the network: a transient transport // failure or a malformed reply must not panic the event task. Take the same @@ -2513,13 +4474,24 @@ async fn handle_ldk_events( Ok(result) => result, Err(e) => { tracing::error!("cannot sign channel funding transaction: {e}"); - abort_staged_standard_funding( + if let Err(cleanup_error) = abort_staged_standard_funding( unlocked_state.clone(), &temporary_channel_id, &unsigned_psbt, asset_id.is_some(), ) - .await; + .await + { + tracing::error!( + "cannot safely retry channel funding after cleanup failed: {cleanup_error}" + ); + return handle_funding_prepare_err( + cleanup_error, + &unlocked_state.channel_manager, + &temporary_channel_id, + &counterparty_node_id, + ); + } return Err(ReplayEvent()); } }; @@ -2529,52 +4501,158 @@ async fn handle_ldk_events( // persist the funding TXID keyed by the final channel ID so handle_open_chan_fail can // find it - let funding_output_index = funding_tx + let funding_output_index = match funding_tx .output .iter() .position(|o| o.script_pubkey == script_buf) - .expect("funding TX must contain the expected output script") - as u16; + .and_then(|index| u16::try_from(index).ok()) + { + Some(index) => index, + None => { + let error = RgbLibError::Internal { + details: + "signed funding transaction does not contain a valid expected output" + .to_owned(), + }; + let cleanup_error = abort_staged_standard_funding( + unlocked_state.clone(), + &temporary_channel_id, + &unsigned_psbt, + is_colored, + ) + .await + .err(); + return handle_funding_prepare_err( + cleanup_error.unwrap_or(error), + &unlocked_state.channel_manager, + &temporary_channel_id, + &counterparty_node_id, + ); + } + }; let final_channel_id = ChannelId::v1_from_funding_txid( bitcoin::hashes::Hash::as_byte_array(&funding_txid), funding_output_index, ); - // Persist the channel -> funding txid mapping so a failed open can - // release the locked funds (see handle_open_chan_fail). - unlocked_state + let final_channel_id_hex = final_channel_id.0.as_hex().to_string(); + if let Some(record) = sender_record.as_mut() { + if record.funding_txid != funding_txid_str { + let error = RgbLibError::Internal { + details: "signed RGB funding transaction ID changed after preparation" + .to_owned(), + }; + let cleanup_error = abort_staged_standard_funding( + unlocked_state.clone(), + &temporary_channel_id, + &unsigned_psbt, + true, + ) + .await + .err(); + return handle_funding_prepare_err( + cleanup_error.unwrap_or(error), + &unlocked_state.channel_manager, + &temporary_channel_id, + &counterparty_node_id, + ); + } + record.final_channel_id = Some(final_channel_id_hex.clone()); + record.stage = RgbSenderFundingStage::HandoffReady; + if let Err(error) = + write_rgb_sender_funding_record(record, unlocked_state.kv_store.as_ref()) + { + let cleanup = abort_staged_standard_funding( + unlocked_state.clone(), + &temporary_channel_id, + &unsigned_psbt, + true, + ) + .await; + return handle_funding_prepare_err( + cleanup.err().unwrap_or(error), + &unlocked_state.channel_manager, + &temporary_channel_id, + &counterparty_node_id, + ); + } + #[cfg(debug_assertions)] + funding_kill_checkpoint(FUNDING_CHECKPOINT_HANDOFF_READY); + } + + let persistence_result = unlocked_state .kv_store .write( PENDING_FUNDING_NAMESPACE, "", - &final_channel_id.0.as_hex().to_string(), + &final_channel_id_hex, funding_txid_str.clone().into_bytes(), ) - .unwrap(); - - // Store PSBT in database for later use when channel is funded - unlocked_state - .kv_store - .write( - PSBT_NAMESPACE, - "", - &funding_txid_str, - psbt.to_string().into_bytes(), + .and_then(|_| { + unlocked_state.kv_store.write( + PSBT_NAMESPACE, + "", + &funding_txid_str, + psbt.to_string().into_bytes(), + ) + }); + if let Err(error) = persistence_result { + let error = rgb_sender_funding_error( + "cannot persist prepared channel funding transaction", + error, + ); + let cleanup = abort_staged_standard_funding( + unlocked_state.clone(), + &temporary_channel_id, + &unsigned_psbt, + is_colored, ) - .unwrap(); + .await; + return handle_funding_prepare_err( + cleanup.err().unwrap_or(error), + &unlocked_state.channel_manager, + &temporary_channel_id, + &counterparty_node_id, + ); + } if let Some(asset_id) = asset_id { - let unlocked_state_copy = unlocked_state.clone(); - let witness_id = funding_txid_str.clone(); - tokio::task::spawn_blocking(move || { - unlocked_state_copy - .rgb_upsert_witness( - RgbTxid::from_str(&witness_id).unwrap(), - WitnessOrd::Tentative, - ) - .unwrap() - }) - .await - .unwrap(); + let witness_result = match RgbTxid::from_str(&funding_txid_str) { + Ok(witness_id) => { + let unlocked_state_copy = unlocked_state.clone(); + let operation_id = funding_txid_str.clone(); + tokio::task::spawn_blocking(move || { + unlocked_state_copy.rgb_upsert_witness_for_operation( + &operation_id, + witness_id, + WitnessOrd::Tentative, + ) + }) + .await + .map_err(|error| { + rgb_sender_funding_error("RGB witness worker failed", error) + }) + .and_then(|result| result) + } + Err(error) => Err(rgb_sender_funding_error( + "cannot parse RGB funding witness transaction ID", + error, + )), + }; + if let Err(error) = witness_result { + let cleanup = abort_staged_standard_funding( + unlocked_state.clone(), + &temporary_channel_id, + &unsigned_psbt, + true, + ) + .await; + return handle_funding_prepare_err( + cleanup.err().unwrap_or(error), + &unlocked_state.channel_manager, + &temporary_channel_id, + &counterparty_node_id, + ); + } // send the consignment to the channel counterparty over the encrypted p2p link let consignment_path = @@ -2684,22 +4762,204 @@ async fn handle_ldk_events( let channel_manager_copy = unlocked_state.channel_manager.clone(); - // Give the funding transaction back to LDK for opening the channel. - if channel_manager_copy - .funding_transaction_generated( + // Colored funding is handed to LDK in checked manual-broadcast mode. LDK first + // persists the counterparty signature and channel monitor, then emits the replayable + // FundingTxBroadcastSafe event. The RGB transaction is never broadcast before that + // durable recovery boundary. Vanilla channels retain LDK's automatic broadcaster. + let handoff_result = if is_colored { + channel_manager_copy.funding_transaction_generated_manual_broadcast( temporary_channel_id, counterparty_node_id, funding_tx, ) - .is_err() - { + } else { + channel_manager_copy.funding_transaction_generated( + temporary_channel_id, + counterparty_node_id, + funding_tx, + ) + }; + if handoff_result.is_err() { tracing::error!( "ERROR: Channel went away before we could fund it. The peer disconnected or refused the channel.", ); + if let Err(cleanup_error) = abort_staged_standard_funding( + unlocked_state.clone(), + &temporary_channel_id, + &unsigned_psbt, + is_colored, + ) + .await + { + tracing::error!( + "Failed to roll back rejected channel funding: {cleanup_error}" + ); + } + } else if let Some(record) = sender_record.as_mut() { + record.stage = RgbSenderFundingStage::HandedToLdk; + if let Err(error) = + write_rgb_sender_funding_record(record, unlocked_state.kv_store.as_ref()) + { + // HandoffReady was persisted before the LDK call. Do not replay or roll back + // after LDK accepted the transaction; startup reconciliation uses the durable + // channel state to resolve this boundary. + tracing::error!( + funding_txid = %record.funding_txid, + error = %error, + "failed to advance RGB sender journal after LDK handoff" + ); + } + #[cfg(debug_assertions)] + funding_kill_checkpoint(FUNDING_CHECKPOINT_HANDED_TO_LDK); } } - Event::FundingTxBroadcastSafe { .. } => { - // We don't use the manual broadcasting feature, so this event should never be seen. + Event::FundingTxBroadcastSafe { + channel_id, + funding_txo, + former_temporary_channel_id, + .. + } => { + let _rgb_funding_operation = unlocked_state + .rgb_funding_recovery_guard + .lock_operation() + .await; + let funding_txid = funding_txo.txid.to_string(); + let mut record = + read_rgb_sender_funding_record(&funding_txid, unlocked_state.kv_store.as_ref()) + .map_err(|error| { + tracing::error!( + funding_txid, + error = %error, + "cannot load RGB sender journal at the broadcast-safe boundary" + ); + ReplayEvent() + })?; + let expected_temporary_channel_id = former_temporary_channel_id.0.as_hex().to_string(); + let expected_final_channel_id = channel_id.0.as_hex().to_string(); + if !record.manual_broadcast + || record.temporary_channel_id != expected_temporary_channel_id + || record.final_channel_id.as_deref() != Some(expected_final_channel_id.as_str()) + { + tracing::error!( + funding_txid, + channel_id = %channel_id, + former_temporary_channel_id = %former_temporary_channel_id, + "RGB sender journal does not match the manual-broadcast event" + ); + return Err(ReplayEvent()); + } + + unlocked_state.add_channel_id(former_temporary_channel_id, channel_id); + match record.stage { + RgbSenderFundingStage::HandoffReady | RgbSenderFundingStage::HandedToLdk => { + // Keep the event pending for one complete background-processor cycle. That + // cycle persists the funded ChannelManager state before a subsequent replay is + // allowed to publish the transaction. If persistence fails, the event remains + // pending and the exact PSBT is never broadcast. + record.stage = RgbSenderFundingStage::BroadcastSafeObserved; + write_rgb_sender_funding_record(&record, unlocked_state.kv_store.as_ref()) + .map_err(|error| { + tracing::error!( + funding_txid, + error = %error, + "cannot persist RGB sender broadcast-safe observation" + ); + ReplayEvent() + })?; + return Err(ReplayEvent()); + } + RgbSenderFundingStage::BroadcastSafeObserved => { + #[cfg(debug_assertions)] + funding_kill_checkpoint(FUNDING_CHECKPOINT_BROADCAST_SAFE); + // This intent is durable before the first call that may publish the exact PSBT. + // A crash from this point onward always retries the same transaction and never + // releases its RGB allocation as though it had not been broadcast. + record.stage = RgbSenderFundingStage::Broadcasting; + write_rgb_sender_funding_record(&record, unlocked_state.kv_store.as_ref()) + .map_err(|error| { + tracing::error!( + funding_txid, + error = %error, + "cannot persist RGB sender broadcast intent" + ); + ReplayEvent() + })?; + #[cfg(debug_assertions)] + funding_kill_checkpoint(FUNDING_CHECKPOINT_BROADCASTING); + } + RgbSenderFundingStage::Broadcasting | RgbSenderFundingStage::BroadcastCommitted => { + } + RgbSenderFundingStage::Finalized | RgbSenderFundingStage::DurablyCompleted => { + let wallet = Arc::clone(&unlocked_state.rgb_wallet_wrapper); + let kv_store = Arc::clone(&unlocked_state.kv_store); + let finalized = record.clone(); + tokio::task::spawn_blocking(move || { + complete_finalized_sender_funding( + &finalized, + wallet.as_ref(), + kv_store.as_ref(), + ) + }) + .await + .map_err(|error| { + tracing::error!( + funding_txid, + error = %error, + "RGB sender completion task failed during event replay" + ); + ReplayEvent() + })? + .map_err(|error| { + tracing::error!( + funding_txid, + error = %error, + "finalized RGB sender funding is not durably complete" + ); + ReplayEvent() + })?; + unlocked_state + .rgb_funding_recovery_guard + .clear(&funding_txid); + return Ok(()); + } + stage => { + tracing::error!( + funding_txid, + ?stage, + "RGB sender journal reached broadcast-safe in an invalid stage" + ); + return Err(ReplayEvent()); + } + } + + let wallet = Arc::clone(&unlocked_state.rgb_wallet_wrapper); + let kv_store = Arc::clone(&unlocked_state.kv_store); + tokio::task::spawn_blocking(move || { + let funding_txid = record.funding_txid.clone(); + commit_and_finalize_rgb_sender_funding(record, wallet.as_ref(), kv_store.as_ref())?; + let finalized = read_rgb_sender_funding_record(&funding_txid, kv_store.as_ref())?; + complete_finalized_sender_funding(&finalized, wallet.as_ref(), kv_store.as_ref()) + }) + .await + .map_err(|error| { + tracing::error!( + funding_txid, + error = %error, + "RGB sender broadcast task failed" + ); + ReplayEvent() + })? + .map_err(|error| { + tracing::error!( + funding_txid, + error = %error, + "RGB sender broadcast could not be committed" + ); + ReplayEvent() + })?; + unlocked_state + .rgb_funding_recovery_guard + .clear(&funding_txid); } Event::PaymentClaimable { payment_hash, @@ -3354,13 +5614,19 @@ async fn handle_ldk_events( former_temporary_channel_id, .. } => { + let _rgb_funding_operation = unlocked_state + .rgb_funding_recovery_guard + .lock_operation() + .await; tracing::info!( "EVENT: Channel {} with peer {} is pending awaiting funding lock-in!", channel_id, hex_str(&counterparty_node_id.serialize()), ); - unlocked_state.add_channel_id(former_temporary_channel_id.unwrap(), channel_id); + if let Some(temporary_channel_id) = former_temporary_channel_id { + unlocked_state.add_channel_id(temporary_channel_id, channel_id); + } if unlocked_state .virtual_channel_session_store() @@ -3378,6 +5644,78 @@ async fn handle_ldk_events( } let funding_txid = funding_txo.txid.to_string(); + let channel_pending_sender_record = read_rgb_sender_funding_record_optional( + &funding_txid, + unlocked_state.kv_store.as_ref(), + ) + .map_err(|error| { + tracing::error!( + funding_txid, + error = %error, + "cannot inspect RGB sender journal at ChannelPending" + ); + ReplayEvent() + })?; + if let Some(record) = channel_pending_sender_record.as_ref() { + let expected_channel_id = channel_id.to_string(); + if record.final_channel_id.as_deref() != Some(expected_channel_id.as_str()) { + tracing::error!( + funding_txid, + channel_id = %channel_id, + journal_channel_id = ?record.final_channel_id, + "RGB sender journal does not match ChannelPending" + ); + return Err(ReplayEvent()); + } + if matches!( + record.stage, + RgbSenderFundingStage::Finalized | RgbSenderFundingStage::DurablyCompleted + ) { + let finalized_record = record.clone(); + let backup_wallet = Arc::clone(&unlocked_state.rgb_wallet_wrapper); + let recovery_kv_store = Arc::clone(&unlocked_state.kv_store); + tokio::task::spawn_blocking(move || { + complete_finalized_sender_funding( + &finalized_record, + backup_wallet.as_ref(), + recovery_kv_store.as_ref(), + ) + }) + .await + .map_err(|error| { + tracing::error!( + funding_txid, + error = %error, + "finalized RGB sender backup task failed" + ); + ReplayEvent() + })? + .map_err(|error| { + tracing::error!( + funding_txid, + error = %error, + "cannot complete finalized RGB sender funding" + ); + ReplayEvent() + })?; + unlocked_state + .rgb_funding_recovery_guard + .clear(&funding_txid); + return Ok(()); + } + if record.manual_broadcast { + // The FundingTxBroadcastSafe event and sender journal exclusively own the + // manual-broadcast transaction. ChannelPending may be delivered first or may + // survive an interrupted broadcast event; acknowledging it here avoids a hot + // replay loop without mutating or discarding the recovery state. + tracing::info!( + funding_txid, + stage = ?record.stage, + "deferring RGB ChannelPending finalization to the manual-broadcast journal" + ); + return Ok(()); + } + } // Check if we have a stored PSBT (initiator case) match unlocked_state @@ -3385,7 +5723,14 @@ async fn handle_ldk_events( .read(PSBT_NAMESPACE, "", &funding_txid) { Ok(psbt_bytes) => { - let psbt_str = String::from_utf8(psbt_bytes).unwrap(); + let psbt_str = String::from_utf8(psbt_bytes).map_err(|error| { + tracing::error!( + funding_txid, + error = %error, + "persisted channel funding PSBT is not valid UTF-8" + ); + ReplayEvent() + })?; let state_copy = unlocked_state.clone(); let psbt_str_copy = psbt_str.clone(); @@ -3394,76 +5739,197 @@ async fn handle_ldk_events( is_channel_rgb(&channel_id, unlocked_state.kv_store.as_ref()); tracing::info!("Initiator of the channel (colored: {})", is_chan_colored); - let join_result = tokio::task::spawn_blocking(move || { - if is_chan_colored { - // the consignment already went to the peer over p2p at funding time, - // so only the local DB bookkeeping is left to do here - state_copy - .rgb_send_end_db_update_only(psbt_str_copy) - .map(|r| r.txid) - } else { - state_copy.rgb_send_btc_end(psbt_str_copy) + let mut sender_record = if is_chan_colored { + channel_pending_sender_record + } else { + None + }; + if let Some(record) = sender_record.as_mut() { + record.stage = RgbSenderFundingStage::Broadcasting; + if let Err(error) = write_rgb_sender_funding_record( + record, + unlocked_state.kv_store.as_ref(), + ) { + tracing::error!("Cannot persist RGB sender broadcast intent: {error}"); + return Err(ReplayEvent()); } - }) - .await; + } - let finalize_result = join_result.map_err(|join_err| { - tracing::error!("Channel opening finalization task failed: {join_err:?}"); - ReplayEvent() - })?; + if let Some(record) = sender_record { + let recovery_wallet = Arc::clone(&unlocked_state.rgb_wallet_wrapper); + let recovery_kv_store = Arc::clone(&unlocked_state.kv_store); + let legacy_funding_txid = funding_txid.clone(); + tokio::task::spawn_blocking(move || { + commit_and_finalize_rgb_sender_funding( + record, + recovery_wallet.as_ref(), + recovery_kv_store.as_ref(), + )?; + let finalized = read_rgb_sender_funding_record( + &legacy_funding_txid, + recovery_kv_store.as_ref(), + )?; + complete_finalized_sender_funding( + &finalized, + recovery_wallet.as_ref(), + recovery_kv_store.as_ref(), + ) + }) + .await + .map_err(|error| { + tracing::error!("Legacy RGB sender finalization task failed: {error}"); + ReplayEvent() + })? + .map_err(|error| { + tracing::error!("Legacy RGB sender finalization failed: {error}"); + ReplayEvent() + })?; + unlocked_state + .rgb_funding_recovery_guard + .clear(&funding_txid); + } else { + let join_result = tokio::task::spawn_blocking(move || { + if is_chan_colored { + // The consignment already went to the peer over P2P at funding + // time, so only local broadcast and DB bookkeeping remain. + state_copy + .rgb_send_end_db_update_only(psbt_str_copy) + .map(|result| result.txid) + } else { + state_copy.rgb_send_btc_end(psbt_str_copy) + } + }) + .await; - let _txid = finalize_result.map_err(|e| { - tracing::error!("Error completing channel opening: {e:?}"); - ReplayEvent() - })?; + let finalize_result = join_result.map_err(|join_err| { + tracing::error!( + "Channel opening finalization task failed: {join_err:?}" + ); + ReplayEvent() + })?; + + let _txid = finalize_result.map_err(|error| { + tracing::error!("Error completing channel opening: {error:?}"); + ReplayEvent() + })?; + } - // Channel funded successfully; drop the pending-funding marker so a - // later close does not attempt to abort the already-broadcast tx. - let _ = unlocked_state.kv_store.remove( + // RGB finalization removes this marker before its recovery tombstone. This + // idempotent removal also covers vanilla funding and legacy records. + remove_rgb_sender_funding_entry( PENDING_FUNDING_NAMESPACE, - "", &channel_id.0.as_hex().to_string(), - false, - ); + "cannot remove finalized pending-funding marker", + unlocked_state.kv_store.as_ref(), + ) + .map_err(|error| { + tracing::error!( + channel_id = %channel_id, + error = %error, + "cannot remove finalized pending-funding marker" + ); + ReplayEvent() + })?; } Err(e) if e.kind() == io::ErrorKind::NotFound => { - // acceptor — read consignment from KVStore - let consignment_data = - match unlocked_state.kv_store.read_rgb_consignment(&funding_txid) { - Ok(data) => data, - Err(_) => { - // vanilla channel — no consignment - return Ok(()); - } - }; - unlocked_state - .kv_store - .write( - FUNDING_CONSIGNMENT_NAMESPACE, - "", - &funding_txid, - consignment_data.clone(), - ) - .unwrap(); - let consignment = - RgbTransfer::load(&mut std::io::Cursor::new(consignment_data)) - .expect("successful consignment load"); - unlocked_state + // The receiver's validated asset metadata is committed with the durable RGB + // funding acceptance. This event only releases the consignment copy retained + // for LDK event replay; validating it again here would repeat the full history + // walk and can take minutes for mature contracts. + if unlocked_state .kv_store - .remove_rgb_consignment(&funding_txid); - - match unlocked_state.rgb_save_new_asset(consignment, funding_txid.clone()) { - Ok(_) => {} - Err(e) if e.to_string().contains("UNIQUE constraint failed") => {} - Err(e) => panic!("Failed saving asset: {e}"), + .read_rgb_consignment(&funding_txid) + .is_ok() + { + unlocked_state + .kv_store + .remove_rgb_consignment(&funding_txid); } + } + Err(error) => { + tracing::error!( + funding_txid, + error = %error, + "cannot read persisted channel funding PSBT" + ); + return Err(ReplayEvent()); + } + } - // the consignment record can stop counting against the node-wide cap - unlocked_state - .rgb_file_transfer_handler - .forget_staged_funding(&funding_txid); + if let Some(temporary_channel_id) = former_temporary_channel_id { + let temporary_channel_id = temporary_channel_id.0.as_hex().to_string(); + match read_pending_funding_acceptance( + &temporary_channel_id, + unlocked_state.kv_store.as_ref(), + ) { + Ok(record) => { + let recovery_channel_manager = Arc::clone(&unlocked_state.channel_manager); + let recovery_wallet = Arc::clone(&unlocked_state.rgb_wallet_wrapper); + let recovery_kv_store = Arc::clone(&unlocked_state.kv_store); + let recovery_funding_txid = record.funding_txid.clone(); + tokio::task::spawn_blocking(move || { + let funded_channel_ids = + funded_channel_ids(recovery_channel_manager.as_ref()); + match reconcile_receiver_funding_record( + &record, + &funded_channel_ids, + recovery_wallet.as_ref(), + recovery_kv_store.as_ref(), + )? { + None => Ok(()), + Some(recovery) => Err(RgbLibError::Internal { + details: format!( + "receiver funding '{}' remains quarantined in {:?}", + recovery.funding_txid, record.stage + ), + }), + } + }) + .await + .map_err(|error| { + unlocked_state + .rgb_funding_recovery_guard + .quarantine(&recovery_funding_txid); + tracing::error!( + temporary_channel_id, + error = %error, + "RGB receiver reconciliation task failed" + ); + ReplayEvent() + })? + .map_err(|error| { + unlocked_state + .rgb_funding_recovery_guard + .quarantine(&recovery_funding_txid); + tracing::error!( + temporary_channel_id, + error = %error, + "cannot reconcile RGB receiver funding at ChannelPending" + ); + ReplayEvent() + })?; + unlocked_state + .rgb_funding_recovery_guard + .clear(&recovery_funding_txid); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + unlocked_state + .rgb_funding_recovery_guard + .quarantine(&funding_txid); + tracing::error!( + temporary_channel_id, + error = %error, + "cannot inspect RGB receiver funding journal at ChannelPending" + ); + return Err(ReplayEvent()); + } } - Err(e) => panic!("Failed to read PSBT from KVStore: {e}"), + + // The consignment record can stop counting against the node-wide cap. + unlocked_state + .rgb_file_transfer_handler + .forget_staged_funding(&funding_txid); } } Event::ChannelReady { @@ -3479,12 +5945,38 @@ async fn handle_ldk_events( hex_str(&counterparty_node_id.serialize()), ); - tokio::task::spawn_blocking(move || { - unlocked_state.rgb_refresh(None, vec![], false).unwrap(); - unlocked_state.rgb_refresh(None, vec![], true).unwrap() + #[cfg(feature = "test-utils")] + let our_node_id = unlocked_state.channel_manager.get_our_node_id(); + + let _rgb_wallet_operation = unlocked_state + .rgb_funding_recovery_guard + .lock_operation() + .await; + match tokio::task::spawn_blocking(move || { + unlocked_state.rgb_refresh(None, vec![], false)?; + unlocked_state.rgb_refresh(None, vec![], true).map(|_| ()) }) .await - .unwrap(); + { + Ok(Ok(())) => {} + Ok(Err(error)) => { + tracing::warn!( + channel_id = %channel_id, + error = %error, + "channel became ready but wallet refresh did not complete" + ); + } + Err(error) => { + tracing::error!( + channel_id = %channel_id, + error = %error, + "channel-ready refresh worker failed" + ); + } + } + + #[cfg(feature = "test-utils")] + record_processed_channel_ready_event(channel_id, our_node_id); } Event::ChannelClosed { channel_id, @@ -3518,6 +6010,10 @@ async fn handle_ldk_events( // Release any funds locked for a funding tx that was never broadcast. handle_open_chan_fail(&channel_id, unlocked_state.clone()).await; + remove_finalized_sender_tombstone_for_channel( + &channel_id, + unlocked_state.kv_store.as_ref(), + ); let former_temporary_channel_id = unlocked_state.delete_channel_id(channel_id); let virtual_draft_temporary_channel_id = if unlocked_state @@ -3762,6 +6258,10 @@ async fn handle_ldk_events( // event. } Event::BumpTransaction(event) => { + let _rgb_wallet_operation = unlocked_state + .rgb_funding_recovery_guard + .lock_operation() + .await; unlocked_state .bump_tx_event_handler .handle_event(&event) @@ -3827,6 +6327,13 @@ impl RgbOutputSpender { locktime: Option, secp_ctx: &Secp256k1, ) -> Result { + let _financial_operation = self + .rgb_funding_recovery_guard + .ensure_financial_operations_allowed() + .map_err(|error| { + tracing::debug!(%error, "deferring RGB output sweep during funding transition"); + error.to_string() + })?; let mut hasher = DefaultHasher::new(); descriptors.hash(&mut hasher); let descriptors_hash = hasher.finish(); @@ -3878,9 +6385,11 @@ impl RgbOutputSpender { .get_tx_height(txid_str.clone()) .map_err(|e| format!("cannot get height of {txid_str}: {e}"))? .ok_or_else(|| format!("transaction {txid_str} is not confirmed yet"))?; + let witness_id = RgbTxid::from_str(&txid_str) + .map_err(|error| format!("invalid sweep witness transaction ID: {error}"))?; let update_res = self .rgb_wallet_wrapper - .update_witnesses(closing_height, vec![RgbTxid::from_str(&txid_str).unwrap()]) + .update_witnesses(closing_height, vec![witness_id]) .map_err(|e| format!("error while updating witnesses for {txid_str}: {e}"))?; if !update_res.failed.is_empty() { return Err(format!( @@ -4002,13 +6511,16 @@ impl RgbOutputSpender { nonce: None, }; - let mut psbt = RgbLibPsbt::from_str(&psbt.to_string()).expect("valid PSBT"); + let mut psbt = RgbLibPsbt::from_str(&psbt.to_string()) + .map_err(|error| format!("failed to convert sweep PSBT for RGB coloring: {error}"))?; let consignments = self .rgb_wallet_wrapper .color_psbt_and_consume(&mut psbt, coloring_info) .map_err(|e| format!("cannot color the sweep PSBT: {e}"))?; - let mut psbt = Psbt::from_str(&psbt.to_string()).expect("valid transaction"); + let mut psbt = Psbt::from_str(&psbt.to_string()).map_err(|error| { + format!("failed to convert colored sweep PSBT for signing: {error}") + })?; psbt = self .signer @@ -4018,12 +6530,18 @@ impl RgbOutputSpender { let spending_tx = match psbt.extract_tx() { Ok(tx) => tx, Err(ExtractTxError::MissingInputValue { tx }) => tx, - Err(e) => panic!("should never happen: {e}"), + Err(error) => { + tracing::error!(%error, "failed to extract signed sweep transaction"); + return Err(format!( + "failed to extract signed sweep transaction: {error}" + )); + } }; let closing_txid = spending_tx.compute_txid().to_string(); - let handle = Handle::current(); + let handle = Handle::try_current() + .map_err(|error| format!("RGB output sweep has no Tokio runtime: {error}"))?; let _ = handle.enter(); for consignment in consignments { @@ -5298,6 +7816,7 @@ pub(crate) async fn start_ldk( #[cfg(feature = "vss")] if vss_restored_keys > 0 { use lightning::chain::channelmonitor::Balance; + use std::collections::HashSet; let manager_channel_ids: HashSet = channel_manager .list_channels() .iter() @@ -5429,43 +7948,50 @@ pub(crate) async fn start_ldk( }; // go_online and configure_vss_backup drive blocking rgb-lib HTTP clients; // run them off the async runtime so they don't fail on a single-vCPU host. - let (rgb_wallet, rgb_online) = tokio::task::spawn_blocking(move || { - let mut rgb_wallet = RgbLibWallet::new( - WalletData { - data_dir, - bitcoin_network, - database_type: DatabaseType::Sqlite, - max_allocations_per_utxo: 1, - supported_schemas: supported_asset_schemas(bitcoin_network), - reuse_addresses, - }, - keys, - ) - .expect("valid rgb-lib wallet"); - let rgb_online = rgb_wallet.go_online(OnlineOptions { - indexer_url: indexer_url_owned, - skip_consistency_check: false, - vanilla_sync_lookback: 20, - })?; - #[cfg(feature = "vss")] - if let Some((vss_url, rgb_store_id, signing_key)) = rgb_vss_backup { - let vss_config = - rgb_lib::wallet::vss::VssBackupConfig::new(vss_url, rgb_store_id, signing_key) - .with_encryption(true) - .with_auto_backup(true) - .with_backup_mode(rgb_lib::wallet::vss::VssBackupMode::Blocking); - // Fail closed: a misconfigured backup must not silently run local-only. - rgb_wallet.configure_vss_backup(vss_config).map_err(|e| { - APIError::FailedVssInit(format!( - "Failed to configure VSS backup for RGB wallet: {e}" - )) + let (rgb_wallet, rgb_online, deferred_rgb_consistency_check) = + tokio::task::spawn_blocking(move || { + let mut rgb_wallet = RgbLibWallet::new( + WalletData { + data_dir, + bitcoin_network, + database_type: DatabaseType::Sqlite, + max_allocations_per_utxo: 1, + supported_schemas: supported_asset_schemas(bitcoin_network), + reuse_addresses, + }, + keys, + ) + .expect("valid rgb-lib wallet"); + let deferred_rgb_consistency_check = rgb_wallet.pending_rgb_acceptance()?.is_some(); + let rgb_online = rgb_wallet.go_online(OnlineOptions { + indexer_url: indexer_url_owned, + skip_consistency_check: deferred_rgb_consistency_check, + vanilla_sync_lookback: 20, })?; - tracing::info!("VSS auto-backup (blocking) enabled for RGB wallet"); - } - Ok::<_, APIError>((rgb_wallet, rgb_online)) - }) - .await - .map_err(|e| APIError::Unexpected(format!("rgb-lib wallet setup task failed: {e}")))??; + if deferred_rgb_consistency_check { + tracing::info!( + "deferred RGB consistency check until durable funding recovery completes" + ); + } + #[cfg(feature = "vss")] + if let Some((vss_url, rgb_store_id, signing_key)) = rgb_vss_backup { + let vss_config = + rgb_lib::wallet::vss::VssBackupConfig::new(vss_url, rgb_store_id, signing_key) + .with_encryption(true) + .with_auto_backup(true) + .with_backup_mode(rgb_lib::wallet::vss::VssBackupMode::Blocking); + // Fail closed: a misconfigured backup must not silently run local-only. + rgb_wallet.configure_vss_backup(vss_config).map_err(|e| { + APIError::FailedVssInit(format!( + "Failed to configure VSS backup for RGB wallet: {e}" + )) + })?; + tracing::info!("VSS auto-backup (blocking) enabled for RGB wallet"); + } + Ok::<_, APIError>((rgb_wallet, rgb_online, deferred_rgb_consistency_check)) + }) + .await + .map_err(|e| APIError::Unexpected(format!("rgb-lib wallet setup task failed: {e}")))??; save_config( &static_state.db(), kv_store.as_ref(), @@ -5501,6 +8027,11 @@ pub(crate) async fn start_ldk( Arc::new(Mutex::new(rgb_wallet)), rgb_online, )); + let rgb_funding_recovery_guard = Arc::new(RgbFundingRecoveryGuard::default()); + let rgb_change_destination_source = Arc::new(RgbChangeDestinationSource { + inner: Arc::clone(&rgb_wallet_wrapper), + funding_guard: Arc::clone(&rgb_funding_recovery_guard), + }); reimport_funding_consignments(&rgb_wallet_wrapper, &kv_store, &ldk_data_dir).await; @@ -5520,6 +8051,7 @@ pub(crate) async fn start_ldk( kv_store: kv_store.clone(), txes, sweep_recipients: Arc::new(Mutex::new(HashMap::new())), + rgb_funding_recovery_guard: Arc::clone(&rgb_funding_recovery_guard), }); // `sweeper_best_block` is only used by the block-sync restart path. #[cfg_attr(not(feature = "block-sync"), allow(unused_variables))] @@ -5535,7 +8067,7 @@ pub(crate) async fn start_ldk( fee_estimator.clone(), chain_filter.clone(), rgb_output_spender, - rgb_wallet_wrapper.clone(), + Arc::clone(&rgb_change_destination_source), Clone::clone(&bp_kv_store), logger.clone(), ); @@ -5547,7 +8079,7 @@ pub(crate) async fn start_ldk( fee_estimator.clone(), chain_filter.clone(), rgb_output_spender.clone(), - rgb_wallet_wrapper.clone(), + Arc::clone(&rgb_change_destination_source), Clone::clone(&bp_kv_store), logger.clone(), ); @@ -5695,6 +8227,66 @@ pub(crate) async fn start_ldk( // messages. Doing this only makes sense for an always-online public routing node, and doesn't // provide you any direct value, but it's nice to offer the service for others. let channel_manager: Arc = Arc::new(channel_manager); + { + let recovery_channel_manager = Arc::clone(&channel_manager); + let recovery_wallet = Arc::clone(&rgb_wallet_wrapper); + let recovery_kv_store = Arc::clone(&kv_store); + let recovery_operation_guard = Arc::clone(&rgb_funding_recovery_guard); + let recovery_indexer_url = indexer_url.to_owned(); + let unresolved = tokio::task::spawn_blocking(move || { + let _operation = recovery_operation_guard.blocking_lock_operation(); + let (recovered_receivers, mut unresolved_receivers) = reconcile_rgb_receiver_funding( + recovery_channel_manager.as_ref(), + recovery_wallet.as_ref(), + recovery_kv_store.as_ref(), + )?; + if recovered_receivers > 0 { + tracing::info!( + recovered_receivers, + "completed durable RGB receiver recovery before peer startup" + ); + } + let mut unresolved = reconcile_rgb_sender_funding( + recovery_channel_manager.as_ref(), + recovery_wallet.as_ref(), + recovery_kv_store.as_ref(), + )?; + unresolved.append(&mut unresolved_receivers); + unresolved.sort_by(|a, b| { + a.funding_txid + .cmp(&b.funding_txid) + .then_with(|| a.stage.sort_key().cmp(&b.stage.sort_key())) + }); + let pending_stock = recovery_wallet.pending_funding_fascia()?; + let should_check_consistency = should_complete_deferred_rgb_consistency_check( + deferred_rgb_consistency_check, + pending_stock + .as_ref() + .map(|(operation_id, _)| operation_id.as_str()), + &unresolved, + )?; + if should_check_consistency { + recovery_wallet.complete_deferred_consistency_check(recovery_indexer_url, 20)?; + tracing::info!("completed deferred RGB consistency check after funding recovery"); + } + Ok::<_, RgbLibError>(unresolved) + }) + .await + .map_err(|error| { + APIError::Unexpected(format!("RGB funding recovery task failed: {error}")) + })? + .map_err(|error| APIError::Unexpected(format!("RGB funding recovery failed: {error}")))?; + rgb_funding_recovery_guard.replace(&unresolved); + if !unresolved.is_empty() { + tracing::error!( + funding_txids = ?unresolved + .iter() + .map(|recovery| recovery.funding_txid.as_str()) + .collect::>(), + "financial operations are quarantined pending RGB funding recovery" + ); + } + } let resolver = "8.8.8.8:53".to_socket_addrs().unwrap().next().unwrap(); let domain_resolver = Arc::new(OMDomainResolver::new( resolver, @@ -6123,6 +8715,7 @@ pub(crate) async fn start_ldk( virtual_channel_draft_store, virtual_channel_session_store, next_payment_idx, + rgb_funding_recovery_guard, }); asset_link_handler.set_authorizer(Arc::new(NodeAssetLinkAuthorizer { @@ -6866,14 +9459,85 @@ mod tests { assert!(!access.allows_peer(&peer)); } - fn build_kv_store() -> Arc { + fn build_synced_kv_store() -> Arc { let db_path = std::env::temp_dir().join(format!("rln-ldk-unit-{}", uuid::Uuid::new_v4())); let connection_string = format!("sqlite:{}?mode=rwc", db_path.display()); let db = crate::runtime::block_on(Database::connect(ConnectOptions::new(connection_string))) .expect("db connection"); crate::runtime::block_on(Migrator::up(&db, None)).expect("run migrations"); - Arc::new(SeaOrmKvStore::from_connection(Arc::new(db))) + Arc::new(SyncedKvStore::local_only(Arc::new( + SeaOrmKvStore::from_connection(Arc::new(db)), + ))) + } + + fn build_kv_store() -> Arc { + build_synced_kv_store() + } + + #[test] + fn canonical_rgb_channel_metadata_is_idempotent_and_conflict_safe() { + let kv_store = build_synced_kv_store(); + let channel_id = "02".repeat(32); + let expected = RgbInfo { + contract_id: test_contract_id(), + schema: AssetSchema::Nia, + local_rgb_amount: 600, + remote_rgb_amount: 0, + batch_transfer_idx: Some(7), + counterparty_knows_asset: false, + }; + + persist_canonical_rgb_channel_info(&channel_id, &expected, kv_store.as_ref()) + .expect("initial canonical write"); + persist_canonical_rgb_channel_info(&channel_id, &expected, kv_store.as_ref()) + .expect("idempotent canonical replay"); + + let shifted = RgbInfo { + local_rgb_amount: 250, + remote_rgb_amount: 350, + batch_transfer_idx: None, + ..expected.clone() + }; + kv_store.write_rgb_channel_info(&channel_id, &shifted, false); + persist_canonical_rgb_channel_info(&channel_id, &expected, kv_store.as_ref()) + .expect("same channel allocation with a live balance split"); + + let mut conflicting = expected.clone(); + conflicting.local_rgb_amount = 599; + persist_canonical_rgb_channel_info(&channel_id, &conflicting, kv_store.as_ref()) + .expect_err("conflicting recovery metadata must fail closed"); + assert_eq!( + kv_store + .read_rgb_channel_info(&channel_id, false) + .expect("canonical metadata"), + shifted, + ); + } + + #[test] + fn finalized_sender_pending_marker_cleanup_is_idempotent() { + let kv_store = build_synced_kv_store(); + let channel_id = "03".repeat(32); + + kv_store + .write( + PENDING_FUNDING_NAMESPACE, + "", + &channel_id, + b"funding-txid".to_vec(), + ) + .expect("seed pending-funding marker"); + + for _ in 0..2 { + remove_rgb_sender_funding_entry( + PENDING_FUNDING_NAMESPACE, + &channel_id, + "cannot remove finalized RGB pending-funding marker", + kv_store.as_ref(), + ) + .expect("finalized cleanup must be replay-safe"); + } } fn seed_channel_info( @@ -7134,6 +9798,102 @@ mod tests { } } + #[test] + fn rgb_sender_recovery_matrix_is_fail_closed_at_broadcast_boundary() { + use RgbSenderFundingStage::*; + use RgbSenderRecoveryAction::*; + + let record = |stage, manual_broadcast| RgbSenderFundingRecord { + version: if manual_broadcast { + RgbSenderFundingRecord::MANUAL_BROADCAST_VERSION + } else { + RgbSenderFundingRecord::LEGACY_VERSION + }, + manual_broadcast, + temporary_channel_id: "01".repeat(32), + final_channel_id: Some("02".repeat(32)), + funding_txid: "03".repeat(32), + batch_transfer_idx: 7, + rgb_info: None, + consignment_delivery: RgbSenderConsignmentDelivery::Proxy, + stage, + }; + + for stage in [ + Preparing, + StockPromoted, + HandoffReady, + HandedToLdk, + BroadcastSafeObserved, + ] { + assert_eq!( + rgb_sender_recovery_action(&record(stage, true), false, false), + Rollback + ); + assert_eq!( + rgb_sender_recovery_action(&record(stage, true), true, false), + ResumeBroadcast + ); + } + for stage in [Broadcasting, BroadcastCommitted] { + assert_eq!( + rgb_sender_recovery_action(&record(stage, true), false, false), + FailClosed + ); + assert_eq!( + rgb_sender_recovery_action(&record(stage, true), true, false), + Finalize + ); + } + for stage in [ + HandoffReady, + HandedToLdk, + BroadcastSafeObserved, + Broadcasting, + BroadcastCommitted, + ] { + assert_eq!( + rgb_sender_recovery_action(&record(stage, false), false, false), + FailClosed + ); + } + for stage in [ + Preparing, + StockPromoted, + HandoffReady, + HandedToLdk, + BroadcastSafeObserved, + Broadcasting, + ] { + assert_eq!( + rgb_sender_recovery_action(&record(stage, true), true, true), + Finalize + ); + assert_eq!( + rgb_sender_recovery_action(&record(stage, true), false, true), + FailClosed + ); + } + for stage in [Finalized, DurablyCompleted] { + assert_eq!( + rgb_sender_recovery_action(&record(stage, true), true, false), + Finalize + ); + assert_eq!( + rgb_sender_recovery_action(&record(stage, true), true, true), + Finalize + ); + assert_eq!( + rgb_sender_recovery_action(&record(stage, true), false, false), + FailClosed + ); + assert_eq!( + rgb_sender_recovery_action(&record(stage, true), false, true), + FailClosed + ); + } + } + #[test] fn sweep_receive_past_expiry_is_never_reusable() { let expiration = 1_000_000; @@ -7160,4 +9920,288 @@ mod tests { false )); } + + #[test] + fn legacy_sender_handoff_never_uses_negative_observation_as_rollback_proof() { + let record = RgbSenderFundingRecord { + version: RgbSenderFundingRecord::LEGACY_VERSION, + manual_broadcast: false, + temporary_channel_id: "01".repeat(32), + final_channel_id: Some("02".repeat(32)), + funding_txid: "03".repeat(32), + batch_transfer_idx: 7, + rgb_info: None, + consignment_delivery: RgbSenderConsignmentDelivery::Proxy, + stage: RgbSenderFundingStage::HandedToLdk, + }; + assert_eq!( + rgb_sender_recovery_action(&record, false, false), + RgbSenderRecoveryAction::FailClosed + ); + + let recovery = rgb_funding_recovery_view(&record, false, Ok(Some(false)), None); + assert_eq!( + recovery.action, + RgbFundingRecoveryAction::ManualChannelStateRecovery + ); + let guard = RgbFundingRecoveryGuard::default(); + guard.replace(&[recovery]); + assert!(matches!( + guard.ensure_financial_operations_allowed(), + Err(APIError::RgbFundingRecoveryRequired(ref txid)) + if txid == &record.funding_txid + )); + } + + #[test] + fn transient_sender_reconciliation_failure_preserves_retryable_evidence() { + let record = RgbSenderFundingRecord { + version: RgbSenderFundingRecord::VERSION, + manual_broadcast: true, + temporary_channel_id: "01".repeat(32), + final_channel_id: Some("02".repeat(32)), + funding_txid: "03".repeat(32), + batch_transfer_idx: 7, + rgb_info: Some(RgbInfo { + contract_id: test_contract_id(), + schema: AssetSchema::Nia, + local_rgb_amount: 1, + remote_rgb_amount: 2, + batch_transfer_idx: Some(7), + counterparty_knows_asset: false, + }), + consignment_delivery: RgbSenderConsignmentDelivery::P2p, + stage: RgbSenderFundingStage::Broadcasting, + }; + let error = RgbLibError::Network { + details: "VSS temporarily unavailable".to_owned(), + }; + + let recovery = rgb_funding_recovery_view(&record, true, Ok(None), Some(&error)); + assert_eq!( + recovery.stage, + RgbFundingRecoveryStage::Sender(RgbSenderFundingStage::Broadcasting) + ); + assert_eq!( + recovery.action, + RgbFundingRecoveryAction::RetryReconciliation + ); + assert_eq!(recovery.error, Some(error.to_string())); + } + + #[test] + fn receiver_recovery_decisions_are_exhaustive_and_fail_closed() { + use FundingAcceptanceStage::*; + use RgbReceiverRecoveryAction::*; + + for stage in [Validating, Prepared, RollingBack, RetryRequired] { + assert_eq!(rgb_receiver_recovery_action(stage, false), Rollback); + assert_eq!(rgb_receiver_recovery_action(stage, true), Quarantine); + } + for stage in [Promoted, Finalizing] { + assert_eq!(rgb_receiver_recovery_action(stage, false), Quarantine); + assert_eq!(rgb_receiver_recovery_action(stage, true), Finalize); + } + assert_eq!(rgb_receiver_recovery_action(Finalized, false), Quarantine); + assert_eq!(rgb_receiver_recovery_action(Finalized, true), Complete); + } + + #[test] + fn finalized_receiver_recovery_is_typed_and_fail_closed() { + let record = PendingFundingAcceptance { + version: 3, + temporary_channel_id: "01".repeat(32), + counterparty_node_id: format!("02{}", "02".repeat(32)), + funding_txid: "03".repeat(32), + funding_output_index: 1, + push_asset_amount: Some(1), + stage: FundingAcceptanceStage::Finalized, + consignment: Some(vec![1]), + rgb_info: None, + }; + + let unresolved = rgb_receiver_funding_recovery_view(&record, false, None).unwrap(); + assert_eq!( + unresolved.stage, + RgbFundingRecoveryStage::Receiver(FundingAcceptanceStage::Finalized) + ); + assert!(!unresolved.channel_is_durable); + assert_eq!(unresolved.transaction_is_known, None); + assert_eq!( + unresolved.action, + RgbFundingRecoveryAction::ManualChannelStateRecovery + ); + + let durable = rgb_receiver_funding_recovery_view(&record, true, None).unwrap(); + assert_eq!( + durable.action, + RgbFundingRecoveryAction::RetryReconciliation + ); + } + + #[test] + fn transient_receiver_reconciliation_failure_preserves_retryable_evidence() { + let record = PendingFundingAcceptance { + version: 3, + temporary_channel_id: "01".repeat(32), + counterparty_node_id: format!("02{}", "02".repeat(32)), + funding_txid: "03".repeat(32), + funding_output_index: 1, + push_asset_amount: Some(1), + stage: FundingAcceptanceStage::Prepared, + consignment: Some(vec![1]), + rgb_info: None, + }; + let error = "VSS temporarily unavailable".to_owned(); + + let recovery = + rgb_receiver_funding_recovery_view(&record, false, Some(error.clone())).unwrap(); + assert_eq!( + recovery.stage, + RgbFundingRecoveryStage::Receiver(FundingAcceptanceStage::Prepared) + ); + assert_eq!( + recovery.action, + RgbFundingRecoveryAction::RetryReconciliation + ); + assert_eq!(recovery.error, Some(error)); + } + + #[test] + fn rgb_funding_recovery_guard_is_fail_closed_and_deterministic() { + let guard = RgbFundingRecoveryGuard::default(); + let recovery = |funding_txid: &str| RgbFundingRecoveryState { + funding_txid: funding_txid.to_owned(), + temporary_channel_id: "01".repeat(32), + final_channel_id: Some("02".repeat(32)), + stage: RgbFundingRecoveryStage::Sender(RgbSenderFundingStage::Broadcasting), + channel_is_durable: false, + transaction_is_known: None, + error: Some("indexer unavailable".to_owned()), + action: RgbFundingRecoveryAction::RetryChainObservation, + }; + let first = "11".repeat(32); + let second = "22".repeat(32); + guard.replace(&[recovery(&second), recovery(&first)]); + + assert_eq!(guard.snapshot(), vec![first.clone(), second.clone()]); + assert!(matches!( + guard.ensure_financial_operations_allowed(), + Err(APIError::RgbFundingRecoveryRequired(ref txids)) + if txids == &format!("{first},{second}") + )); + + guard.clear(&first); + assert!(guard.ensure_financial_operations_allowed().is_err()); + guard.clear(&second); + assert!(guard.ensure_financial_operations_allowed().is_ok()); + } + + #[test] + fn financial_operation_admission_holds_an_exclusive_lease() { + let guard = RgbFundingRecoveryGuard::default(); + + let operation = guard.ensure_financial_operations_allowed().unwrap(); + assert!(matches!( + guard.ensure_financial_operations_allowed(), + Err(APIError::ChangingState) + )); + + drop(operation); + assert!(guard.ensure_financial_operations_allowed().is_ok()); + } + + #[test] + fn deferred_rgb_consistency_requires_a_durable_owner_or_a_resolved_stock() { + let operation_id = "03".repeat(32); + let recovery = RgbFundingRecoveryState { + funding_txid: operation_id.clone(), + temporary_channel_id: "01".repeat(32), + final_channel_id: Some("02".repeat(32)), + stage: RgbFundingRecoveryStage::Sender(RgbSenderFundingStage::Broadcasting), + channel_is_durable: true, + transaction_is_known: None, + error: None, + action: RgbFundingRecoveryAction::ResumeBroadcast, + }; + + assert!(!should_complete_deferred_rgb_consistency_check(false, None, &[]).unwrap()); + assert!(should_complete_deferred_rgb_consistency_check(true, None, &[]).unwrap()); + assert!(!should_complete_deferred_rgb_consistency_check( + true, + Some(&operation_id), + &[recovery] + ) + .unwrap()); + assert!( + should_complete_deferred_rgb_consistency_check(true, Some(&"04".repeat(32)), &[]) + .is_err() + ); + assert!( + should_complete_deferred_rgb_consistency_check(false, Some(&operation_id), &[]) + .is_err() + ); + } + + #[test] + fn rgb_sender_funding_journal_round_trips_with_version() { + let record = RgbSenderFundingRecord { + version: RgbSenderFundingRecord::VERSION, + manual_broadcast: true, + temporary_channel_id: "01".repeat(32), + final_channel_id: Some("02".repeat(32)), + funding_txid: "03".repeat(32), + batch_transfer_idx: 7, + rgb_info: Some(RgbInfo { + contract_id: test_contract_id(), + schema: AssetSchema::Nia, + local_rgb_amount: 1, + remote_rgb_amount: 2, + batch_transfer_idx: Some(7), + counterparty_knows_asset: false, + }), + consignment_delivery: RgbSenderConsignmentDelivery::P2p, + stage: RgbSenderFundingStage::HandedToLdk, + }; + record.validate().unwrap(); + let encoded = serde_json::to_vec(&record).unwrap(); + assert_eq!( + serde_json::from_slice::(&encoded).unwrap(), + record + ); + + let mut unsupported = record.clone(); + unsupported.version += 1; + assert!(unsupported.validate().is_err()); + + let mut malformed = unsupported; + malformed.version = RgbSenderFundingRecord::VERSION; + malformed.funding_txid = "zz".repeat(32); + assert!(malformed.validate().is_err()); + + let legacy_json = serde_json::json!({ + "version": RgbSenderFundingRecord::LEGACY_VERSION, + "temporary_channel_id": "01".repeat(32), + "final_channel_id": "02".repeat(32), + "funding_txid": "03".repeat(32), + "batch_transfer_idx": 7, + "stage": "handed_to_ldk" + }); + let legacy: RgbSenderFundingRecord = serde_json::from_value(legacy_json).unwrap(); + assert!(!legacy.manual_broadcast); + assert!(legacy.rgb_info.is_none()); + assert_eq!( + legacy.consignment_delivery, + RgbSenderConsignmentDelivery::Proxy + ); + legacy.validate().unwrap(); + + let mut wrong_delivery = record; + wrong_delivery.consignment_delivery = RgbSenderConsignmentDelivery::Proxy; + assert!(wrong_delivery.validate().is_err()); + + wrong_delivery.version = RgbSenderFundingRecord::RGB_INFO_VERSION; + wrong_delivery.consignment_delivery = RgbSenderConsignmentDelivery::P2p; + assert!(wrong_delivery.validate().is_err()); + } } diff --git a/src/rgb.rs b/src/rgb.rs index 573b417e..235700ad 100644 --- a/src/rgb.rs +++ b/src/rgb.rs @@ -17,23 +17,23 @@ use rgb_lib::{ bdk_wallet::{KeychainKind, SignOptions}, bitcoin::psbt::Psbt as BitcoinPsbt, wallet::{ - rust_only::{check_proxy_url, ColoringInfo}, + rust_only::{check_proxy_url, ColoringInfo, RgbAcceptanceResolution}, AssetCFA, AssetFilter, AssetIFA, AssetNIA, AssetUDA, Assets, Balance, BtcBalance, - IfaIssuanceType, Media, Metadata, Online, OperationResult, Outpoint, ReceiveData, - Recipient, RefreshFilter, RefreshResult, RefreshedTransfer, RgbWalletOpsOffline, - RgbWalletOpsOnline, SendBeginResult, SinglesigKeys, SyncOptions, + IfaIssuanceType, Media, Metadata, Online, OnlineOptions, OperationResult, Outpoint, + ReceiveData, Recipient, RefreshFilter, RefreshResult, RefreshedTransfer, + RgbWalletOpsOffline, RgbWalletOpsOnline, SendBeginResult, SinglesigKeys, SyncOptions, Transaction as RgbLibTransaction, Transfer, TransferKind, TransportEndpoint, Unspent, Wallet as RgbLibWallet, }, - AssetSchema, Assignment, BitcoinNetwork, ContractId, Error as RgbLibError, Fascia, RgbTransfer, - RgbTxid, UpdateRes, WitnessOrd, + AssetSchema, Assignment, BitcoinNetwork, ConsignmentExt, ContractId, Error as RgbLibError, + Fascia, RgbTransfer, RgbTxid, UpdateRes, WitnessOrd, }; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::str::FromStr; use std::sync::{Arc, Mutex, MutexGuard}; -use crate::{error::APIError, utils::UnlockedAppState}; +use crate::{error::APIError, ldk::RgbFundingRecoveryGuard, utils::UnlockedAppState}; /// When `sign_rgb_psbt` fails, internal mode falls back to the local RGB wallet; external mode does not. fn resolve_rgb_psbt_signer_failure( @@ -151,6 +151,23 @@ impl UnlockedAppState { self.rgb_wallet_wrapper.consume_fascia(fascia, witness_ord) } + pub(crate) fn rgb_prepare_funding_fascia( + &self, + operation_id: String, + fascia: Fascia, + ) -> Result<(), RgbLibError> { + self.rgb_wallet_wrapper + .prepare_funding_fascia(operation_id, fascia) + } + + pub(crate) fn rgb_rollback_funding_fascia_if_present( + &self, + operation_id: &str, + ) -> Result<(), RgbLibError> { + self.rgb_wallet_wrapper + .rollback_funding_fascia_if_present(operation_id) + } + pub(crate) fn rgb_create_consignments(&self, psbt: String) -> Result<(), RgbLibError> { self.rgb_wallet_wrapper.create_consigments(psbt) } @@ -422,15 +439,6 @@ impl UnlockedAppState { self.rgb_wallet_wrapper.refresh(asset_id, filter, skip_sync) } - pub(crate) fn rgb_save_new_asset( - &self, - consignment: RgbTransfer, - offchain_txid: String, - ) -> Result<(), RgbLibError> { - self.rgb_wallet_wrapper - .save_new_asset(consignment, offchain_txid) - } - pub(crate) fn rgb_send( &self, recipient_map: HashMap>, @@ -542,6 +550,16 @@ impl UnlockedAppState { .upsert_witness(witness_id, witness_ord) } + pub(crate) fn rgb_upsert_witness_for_operation( + &self, + operation_id: &str, + witness_id: RgbTxid, + witness_ord: WitnessOrd, + ) -> Result<(), RgbLibError> { + self.rgb_wallet_wrapper + .upsert_witness_for_operation(operation_id, witness_id, witness_ord) + } + pub(crate) fn rgb_witness_receive( &self, asset_id: Option, @@ -592,6 +610,26 @@ impl RgbLibWalletWrapper { self.wallet.lock().unwrap() } + pub(crate) fn complete_deferred_consistency_check( + &self, + indexer_url: String, + vanilla_sync_lookback: u32, + ) -> Result<(), RgbLibError> { + let mut wallet = self.get_rgb_wallet(); + let online = wallet.go_online(OnlineOptions { + indexer_url, + skip_consistency_check: false, + vanilla_sync_lookback, + })?; + if online != self.online { + return Err(RgbLibError::Internal { + details: "RGB recovery consistency check replaced the active online session" + .to_owned(), + }); + } + Ok(()) + } + /// Returns the wallet's configured `VssBackupClient`, if any. This is the /// client constructed by `configure_vss_backup` in `start_ldk`; callers /// (e.g. the manual `/vssbackup` route) reuse it instead of building a @@ -601,6 +639,27 @@ impl RgbLibWalletWrapper { self.get_rgb_wallet().vss_client() } + /// Uploads the complete RGB wallet and propagates any VSS failure. + /// + /// Callers must execute this method on a blocking worker. Protocol commit paths use it before + /// deleting recovery journals; best-effort auto-backup is not a durability boundary. + pub(crate) fn checked_vss_backup(&self) -> Result, RgbLibError> { + #[cfg(feature = "vss")] + { + let wallet = self.get_rgb_wallet(); + let Some(client) = wallet.vss_client() else { + return Ok(None); + }; + client + .handle() + .block_on(wallet.vss_backup(&client)) + .map(Some) + } + + #[cfg(not(feature = "vss"))] + Ok(None) + } + // Upstream API retained through the merge; not yet wired into utexo's flow. #[allow(dead_code)] pub(crate) fn abort_pending_vanilla_tx(&self, txid: String) -> Result<(), RgbLibError> { @@ -636,6 +695,154 @@ impl RgbLibWalletWrapper { self.get_rgb_wallet().consume_fascia(fascia, witness_ord) } + pub(crate) fn prepare_funding_fascia( + &self, + operation_id: String, + fascia: Fascia, + ) -> Result<(), RgbLibError> { + let witness_id = fascia.witness_id().to_string(); + if witness_id != operation_id { + return Err(RgbLibError::Internal { + details: format!( + "funding fascia witness '{witness_id}' does not match operation '{operation_id}'" + ), + }); + } + self.get_rgb_wallet() + .prepare_consume_fascia(operation_id, fascia, Some(WitnessOrd::Tentative))? + .promote()?; + Ok(()) + } + + pub(crate) fn rollback_funding_fascia_if_present( + &self, + operation_id: &str, + ) -> Result<(), RgbLibError> { + let wallet = self.get_rgb_wallet(); + let Some(pending) = wallet.pending_rgb_acceptance()? else { + return Ok(()); + }; + if pending.operation_id() != operation_id { + return Err(RgbLibError::Internal { + details: format!( + "pending RGB operation '{}' does not match funding operation '{operation_id}'", + pending.operation_id() + ), + }); + } + wallet.resolve_pending_rgb_acceptance(operation_id, RgbAcceptanceResolution::Rollback) + } + + pub(crate) fn finalize_funding_fascia(&self, operation_id: &str) -> Result<(), RgbLibError> { + let wallet = self.get_rgb_wallet(); + let Some(pending) = wallet.pending_rgb_acceptance()? else { + return Ok(()); + }; + if pending.operation_id() != operation_id { + return Err(RgbLibError::Internal { + details: format!( + "pending RGB operation '{}' does not match funding operation '{operation_id}'", + pending.operation_id() + ), + }); + } + wallet.resolve_pending_rgb_acceptance(operation_id, RgbAcceptanceResolution::Finalize) + } + + pub(crate) fn pending_funding_fascia(&self) -> Result, RgbLibError> { + Ok(self + .get_rgb_wallet() + .pending_rgb_acceptance()? + .map(|pending| (pending.operation_id().to_owned(), pending.promoted()))) + } + + /// Repairs a finalized inbound channel transfer from its durable funding journal when the + /// restored RGB wallet snapshot predates that transfer. + /// + /// The persisted consignment is validated through rgb-lib's normal isolated acceptance path. + /// Contract identity and received amount are checked before the staged stock is promoted. + pub(crate) fn ensure_finalized_funding_transfer( + &self, + funding_txid: &str, + funding_output_index: u32, + consignment: Vec, + rgb_info: &RgbInfo, + blinding: u64, + ) -> Result { + let mut wallet = self.get_rgb_wallet(); + if let Some(pending) = wallet.pending_rgb_acceptance()? { + if pending.operation_id() != funding_txid { + return Err(RgbLibError::Internal { + details: format!( + "finalized funding '{funding_txid}' cannot reconcile RGB operation '{}'", + pending.operation_id() + ), + }); + } + let resolution = if pending.promoted() { + RgbAcceptanceResolution::Finalize + } else { + RgbAcceptanceResolution::Rollback + }; + wallet.resolve_pending_rgb_acceptance(funding_txid, resolution)?; + } + + let asset_id = rgb_info.contract_id.to_string(); + if wallet.has_accepted_transfer(asset_id.clone(), funding_txid.to_owned())? { + return Ok(false); + } + + let prepared = wallet.prepare_accept_transfer_from_consignment( + funding_txid.to_owned(), + funding_txid.to_owned(), + funding_output_index, + consignment, + blinding, + )?; + if prepared.consignment().contract_id() != rgb_info.contract_id { + return Err(RgbLibError::Internal { + details: format!( + "persisted funding consignment contract '{}' does not match channel contract '{}'", + prepared.consignment().contract_id(), + rgb_info.contract_id + ), + }); + } + let expected_amount = rgb_info + .local_rgb_amount + .checked_add(rgb_info.remote_rgb_amount) + .ok_or_else(|| RgbLibError::Internal { + details: format!("RGB amount overflow for finalized funding '{funding_txid}'"), + })?; + let assignment_matches = match prepared.assignments() { + [Assignment::Fungible(amount)] => *amount == expected_amount, + [Assignment::NonFungible] => expected_amount == 1, + _ => false, + }; + if !assignment_matches { + return Err(RgbLibError::Internal { + details: format!( + "persisted funding consignment assignments do not match channel amount {expected_amount}" + ), + }); + } + + prepared.promote()?; + wallet.resolve_pending_rgb_acceptance(funding_txid, RgbAcceptanceResolution::Finalize)?; + if !wallet.has_accepted_transfer(asset_id, funding_txid.to_owned())? { + return Err(RgbLibError::Internal { + details: format!( + "RGB funding '{funding_txid}' is absent after deterministic recovery" + ), + }); + } + Ok(true) + } + + pub(crate) fn is_tx_known(&self, txid: String) -> Result { + self.get_rgb_wallet().is_tx_known(txid) + } + pub(crate) fn color_psbt_and_consume( &self, psbt_to_color: &mut BitcoinPsbt, @@ -942,6 +1149,8 @@ impl RgbLibWalletWrapper { .refresh(self.online, asset_id, filter, skip_sync) } + /// Imports legacy channel metadata when restoring a wallet whose RGB stock predates the + /// durable funding journal. Normal channel funding uses transactional acceptance instead. pub(crate) fn save_new_asset( &self, consignment: RgbTransfer, @@ -1040,6 +1249,27 @@ impl RgbLibWalletWrapper { .send_end_db_update_only(self.online, signed_psbt) } + pub(crate) fn send_end_db_update_only_for_operation( + &self, + operation_id: &str, + signed_psbt: String, + ) -> Result { + self.get_rgb_wallet().send_end_db_update_only_for_operation( + self.online, + operation_id, + signed_psbt, + ) + } + + pub(crate) fn send_end_preconsumed_for_operation( + &self, + operation_id: &str, + signed_psbt: String, + ) -> Result { + let mut wallet = self.get_rgb_wallet(); + wallet.send_end_preconsumed_for_operation(self.online, operation_id, signed_psbt) + } + pub(crate) fn sign_psbt(&self, unsigned_psbt: String) -> Result { self.get_rgb_wallet().sign_psbt(unsigned_psbt, None) } @@ -1066,6 +1296,16 @@ impl RgbLibWalletWrapper { .upsert_witness(witness_id, witness_ord) } + pub(crate) fn upsert_witness_for_operation( + &self, + operation_id: &str, + witness_id: RgbTxid, + witness_ord: WitnessOrd, + ) -> Result<(), RgbLibError> { + self.get_rgb_wallet() + .upsert_witness_for_operation(operation_id, witness_id, witness_ord) + } + pub(crate) fn witness_receive( &self, asset_id: Option, @@ -1093,6 +1333,12 @@ pub(crate) struct RgbBumpWalletSource { pub(crate) external_signer_mode: bool, } +/// Wallet-backed change destination protected from an in-flight RGB funding transition. +pub(crate) struct RgbChangeDestinationSource { + pub(crate) inner: Arc, + pub(crate) funding_guard: Arc, +} + impl WalletSource for RgbBumpWalletSource { fn list_confirmed_utxos<'a>(&'a self) -> AsyncResult<'a, Vec, ()> { self.inner.as_ref().list_confirmed_utxos() @@ -1155,16 +1401,22 @@ impl WalletSource for RgbBumpWalletSource { } } -impl ChangeDestinationSource for RgbLibWalletWrapper { +impl ChangeDestinationSource for RgbChangeDestinationSource { fn get_change_destination_script<'a>(&'a self) -> AsyncResult<'a, ScriptBuf, ()> { Box::pin(async move { - let address = self.get_address().map_err(|e| { - tracing::error!("cannot get a change address to sweep outputs, will retry: {e}"); + let _financial_operation = self + .funding_guard + .ensure_financial_operations_allowed() + .map_err(|error| { + tracing::debug!(%error, "deferring change-address allocation during RGB funding"); + })?; + let address = self.inner.get_address().map_err(|error| { + tracing::error!(%error, "failed to allocate a wallet change address"); })?; - let parsed = Address::from_str(&address).map_err(|e| { - tracing::error!(error = %e, "invalid change address to sweep outputs"); + let address = Address::from_str(&address).map_err(|error| { + tracing::error!(%error, "wallet returned an invalid change address"); })?; - Ok(parsed.assume_checked().script_pubkey()) + Ok(address.assume_checked().script_pubkey()) }) } } diff --git a/src/sdk/mod.rs b/src/sdk/mod.rs index ff342707..1ce0aeec 100644 --- a/src/sdk/mod.rs +++ b/src/sdk/mod.rs @@ -642,6 +642,7 @@ pub(crate) struct ChannelData { pub(crate) next_outbound_htlc_limit_msat: u64, pub(crate) next_outbound_htlc_minimum_msat: u64, pub(crate) is_usable: bool, + pub(crate) has_inflight_htlcs: bool, pub(crate) public: bool, pub(crate) asset_id: Option, pub(crate) asset_local_amount: Option, @@ -1074,11 +1075,10 @@ pub(crate) async fn estimate_fee( state: Arc, blocks: u16, ) -> Result { - let fee_rate = check_unlocked(&state) - .await? - .clone() - .unwrap() - .rgb_get_fee_estimation(blocks)?; + let guard = check_unlocked(&state).await?; + let unlocked_state = guard.as_ref().unwrap(); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; + let fee_rate = unlocked_state.rgb_get_fee_estimation(blocks)?; Ok(EstimateFeeData { fee_rate }) } @@ -1098,6 +1098,7 @@ pub(crate) async fn check_proxy_endpoint(proxy_endpoint: String) -> Result<(), A pub(crate) async fn node_info(state: Arc) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; let chans = unlocked_state.channel_manager.list_channels(); @@ -1174,6 +1175,7 @@ pub(crate) async fn network_info(state: Arc) -> Result) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; Ok(AddressData { address: unlocked_state.rgb_get_address()?, @@ -1183,6 +1185,7 @@ pub(crate) async fn address(state: Arc) -> Result) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; Ok(AddressData { address: unlocked_state.rgb_rotate_address()?, @@ -1196,6 +1199,7 @@ pub(crate) async fn async_order_new( let guard = check_unlocked(&state).await?; let unlocked_state = Arc::clone(guard.as_ref().unwrap()); drop(guard); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; let host_node_id = hex_str_to_compressed_pubkey(&request.host_node_id).ok_or(APIError::InvalidPubkey)?; @@ -1302,6 +1306,7 @@ pub(crate) async fn async_order_outbound_invoice( let guard = check_unlocked(&state).await?; let unlocked_state = Arc::clone(guard.as_ref().unwrap()); drop(guard); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; let peer_node_id = hex_str_to_compressed_pubkey(&request.client_node_id).ok_or(APIError::InvalidPubkey)?; @@ -1376,6 +1381,7 @@ pub(crate) async fn btc_balance( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; let btc_balance = unlocked_state.rgb_get_btc_balance(skip_sync)?; Ok(BtcBalanceData { @@ -1495,6 +1501,7 @@ pub(crate) async fn list_channels(state: Arc) -> Result Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; let contract_id = ContractId::from_str(&asset_id).map_err(|_| APIError::InvalidAssetID(asset_id))?; @@ -1619,11 +1627,10 @@ pub(crate) async fn asset_metadata( ) -> Result { let contract_id = ContractId::from_str(&asset_id).map_err(|_| APIError::InvalidAssetID(asset_id))?; - let metadata = check_unlocked(&state) - .await? - .clone() - .unwrap() - .rgb_get_asset_metadata(contract_id)?; + let guard = check_unlocked(&state).await?; + let unlocked_state = guard.as_ref().unwrap(); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; + let metadata = unlocked_state.rgb_get_asset_metadata(contract_id)?; Ok(AssetMetadataData { asset_schema: metadata.asset_schema, @@ -1671,6 +1678,7 @@ pub(crate) async fn list_assets( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; let rgb_assets = unlocked_state.rgb_list_assets(filter_asset_schemas)?; @@ -1758,6 +1766,7 @@ pub(crate) async fn send_rgb( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; let send_result = if unlocked_state.external_signer_mode { let unlocked_state_copy = unlocked_state.clone(); @@ -1903,6 +1912,7 @@ pub(crate) async fn init_with_external_signer( pub(crate) async fn vss_backup(state: Arc) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap().clone(); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; drop(guard); #[cfg(not(feature = "vss"))] @@ -2221,6 +2231,7 @@ pub(crate) async fn close_channel( ) -> Result<(), APIError> { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; let channel_id_vec = hex_str_to_vec(&request.channel_id); if channel_id_vec.is_none() || channel_id_vec.as_ref().unwrap().len() != 32 { @@ -2412,6 +2423,7 @@ pub(crate) async fn create_utxos( ) -> Result<(), APIError> { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; let num = request.num.unwrap_or(unlocked_state.config.rgb.utxo_num); let size = request @@ -2460,6 +2472,7 @@ pub(crate) async fn issue_asset_nia( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; if unlocked_state.external_signer_mode { return Err(APIError::UnsupportedInExternalSignerMode( "asset issuance is not supported in external signer mode".to_string(), @@ -2482,6 +2495,7 @@ pub(crate) async fn issue_asset_cfa( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; if unlocked_state.external_signer_mode { return Err(APIError::UnsupportedInExternalSignerMode( "asset issuance is not supported in external signer mode".to_string(), @@ -2513,6 +2527,7 @@ pub(crate) async fn issue_asset_ifa( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; if unlocked_state.external_signer_mode { return Err(APIError::UnsupportedInExternalSignerMode( "asset issuance is not supported in external signer mode".to_string(), @@ -2538,6 +2553,7 @@ pub(crate) async fn issue_asset_uda( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; if unlocked_state.external_signer_mode { return Err(APIError::UnsupportedInExternalSignerMode( "asset issuance is not supported in external signer mode".to_string(), @@ -2576,6 +2592,7 @@ pub(crate) async fn keysend( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; let dest_pubkey_vec = match hex_str_to_vec(&request.dest_pubkey) { Some(peer_pubkey_vec) => peer_pubkey_vec, @@ -2684,6 +2701,7 @@ pub(crate) async fn send_btc( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; let txid = if unlocked_state.external_signer_mode { let unsigned_psbt = @@ -2744,6 +2762,7 @@ pub(crate) async fn rgb_invoice( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; let assignment = match request.assignment_kind { Some(kind) => rgb_assignment_from_kind(kind, request.assignment_amount)?, @@ -2787,6 +2806,7 @@ pub(crate) async fn open_channel( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; let is_virtual_open = match request.virtual_open_mode.as_deref() { None => false, @@ -3131,6 +3151,7 @@ pub(crate) async fn send_payment( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; let mut status = HtlcStatus::Pending; let created_at = get_current_timestamp(); @@ -3372,6 +3393,7 @@ pub(crate) async fn fail_transfers( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; let unlocked_state_copy = unlocked_state.clone(); let transfers_changed = tokio::task::spawn_blocking(move || { @@ -3393,6 +3415,7 @@ pub(crate) async fn refresh_transfers( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; let unlocked_state_copy = unlocked_state.clone(); let refresh_result = tokio::task::spawn_blocking(move || { @@ -3426,6 +3449,7 @@ pub(crate) async fn maker_execute( ) -> Result<(), APIError> { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; let swapstring = SwapString::from_str(&request.swapstring) .map_err(|e| APIError::InvalidSwapString(request.swapstring.clone(), e.to_string()))?; @@ -3624,6 +3648,7 @@ pub(crate) async fn maker_init( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; let from_asset = match &request.from_asset { None => None, @@ -3688,6 +3713,7 @@ pub(crate) async fn maker_init( pub(crate) async fn taker(state: Arc, request: TakerRequestData) -> Result<(), APIError> { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; let swapstring = SwapString::from_str(&request.swapstring) .map_err(|e| APIError::InvalidSwapString(request.swapstring.clone(), e.to_string()))?; @@ -3764,6 +3790,7 @@ pub(crate) async fn send_onion_message( pub(crate) async fn sync(state: Arc) -> Result<(), APIError> { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; unlocked_state.rgb_sync(rgb_lib::wallet::SyncOptions { keychain: rgb_lib::wallet::SyncKeychain::Colored, strategy: rgb_lib::wallet::SyncStrategy::FastSync, @@ -3869,6 +3896,7 @@ pub(crate) async fn create_ln_invoice( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; let contract_id = if let Some(asset_id) = asset_id { Some(ContractId::from_str(&asset_id).map_err(|_| APIError::InvalidAssetID(asset_id))?) @@ -4115,6 +4143,7 @@ pub(crate) async fn cancel_hodl_invoice( ) -> Result<(), APIError> { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; let payment_hash = validate_and_parse_payment_hash(&request.payment_hash)?; let payment_info = unlocked_state @@ -4144,6 +4173,7 @@ pub(crate) async fn claim_hodl_invoice( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; let payment_hash = validate_and_parse_payment_hash(&request.payment_hash)?; let preimage = validate_and_parse_payment_preimage(&request.payment_preimage, &payment_hash)?; @@ -4222,6 +4252,7 @@ pub(crate) async fn inflate( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; if unlocked_state.external_signer_mode { return Err(APIError::UnsupportedInExternalSignerMode( "inflate is not supported in external signer mode".to_string(), @@ -4406,6 +4437,7 @@ pub(crate) async fn list_transactions( ) -> Result, APIError> { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; Ok(unlocked_state .rgb_list_transactions(skip_sync)? @@ -4422,6 +4454,7 @@ pub(crate) async fn list_transfers( ) -> Result, APIError> { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; if asset_id.is_none() && txid.is_none() { return Err(APIError::InvalidRequest(s!( @@ -4445,6 +4478,7 @@ pub(crate) async fn list_unspents( ) -> Result, APIError> { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; let mut unspents = vec![]; for unspent in unlocked_state.rgb_list_unspents(false, skip_sync)? { diff --git a/src/synced_kv_store.rs b/src/synced_kv_store.rs index 6a3089dd..ff4a399d 100644 --- a/src/synced_kv_store.rs +++ b/src/synced_kv_store.rs @@ -1,5 +1,8 @@ use std::sync::Arc; +#[cfg(feature = "vss")] +use std::time::Duration; + use bitcoin::io; use lightning::util::persist::KVStoreSync; @@ -53,12 +56,30 @@ const PENDING_DRAIN_BATCH: usize = 16; // Protocol records that must be remotely acknowledged before channel funding may advance. // These names are persisted storage contracts and intentionally live with the durability policy. -#[cfg(feature = "vss")] pub(crate) const RGB_SENDER_FUNDING_NAMESPACE: &str = "rgb_sender_funding"; +pub(crate) const PSBT_NAMESPACE: &str = "psbt"; +pub(crate) const PENDING_FUNDING_NAMESPACE: &str = "pending_funding"; #[cfg(feature = "vss")] pub(crate) const RGB_PRIMARY_NAMESPACE: &str = "rgb"; #[cfg(feature = "vss")] pub(crate) const RGB_FUNDING_ACCEPTANCE_NAMESPACE: &str = "funding_acceptance"; +#[cfg(feature = "vss")] +const RGB_CHANNEL_INFO_NAMESPACE: &str = "channel_info"; +#[cfg(feature = "vss")] +const RGB_CHANNEL_INFO_PENDING_NAMESPACE: &str = "channel_info_pending"; + +#[cfg(feature = "vss")] +const CRITICAL_RETRY_INITIAL_DELAY: Duration = Duration::from_millis(250); +#[cfg(feature = "vss")] +const CRITICAL_RETRY_MAX_DELAY: Duration = Duration::from_secs(5); + +#[cfg(feature = "vss")] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RemoteDurability { + BestEffort, + FailClosed, + WaitForAcknowledgement, +} /// Local-only namespace persisting the pending queue across restarts. Target mutations and rows in /// this namespace are committed in one SQLite transaction. @@ -311,18 +332,84 @@ impl SyncedKvStore { Ok(()) } - /// These records define channel-funding recovery boundaries. With VSS configured, callers may - /// not advance the protocol after only a local acknowledgement. Other keys remain locally - /// authoritative and use the durable retry queue for eventual VSS convergence. + /// Returns the remote durability contract for a key. /// - /// `psbt` and `pending_funding` stay best-effort for now: their current writers unwrap the - /// result, so failing closed would panic the event handler during a VSS outage. They join - /// this set together with the funding state machine that handles the errors. + /// Funding records fail immediately when VSS does not acknowledge them because their callers + /// can retain the current recovery stage and return a typed error. RGB channel metadata is + /// written from LDK paths whose legacy storage API is infallible, so a transient outage must + /// pause that transition until VSS recovers. Returning success with only a local copy would let + /// a device-loss restore combine durable LDK state with a stale RGB balance split. #[cfg(feature = "vss")] - fn requires_remote_durability(primary_namespace: &str, secondary_namespace: &str) -> bool { - (primary_namespace == RGB_SENDER_FUNDING_NAMESPACE && secondary_namespace.is_empty()) + fn remote_durability( + primary_namespace: &str, + secondary_namespace: &str, + explicitly_required: bool, + ) -> RemoteDurability { + if primary_namespace == RGB_PRIMARY_NAMESPACE + && matches!( + secondary_namespace, + RGB_CHANNEL_INFO_NAMESPACE | RGB_CHANNEL_INFO_PENDING_NAMESPACE + ) + { + return RemoteDurability::WaitForAcknowledgement; + } + if explicitly_required + || (primary_namespace == RGB_SENDER_FUNDING_NAMESPACE && secondary_namespace.is_empty()) + || (primary_namespace == PSBT_NAMESPACE && secondary_namespace.is_empty()) + || (primary_namespace == PENDING_FUNDING_NAMESPACE && secondary_namespace.is_empty()) || (primary_namespace == RGB_PRIMARY_NAMESPACE && secondary_namespace == RGB_FUNDING_ACCEPTANCE_NAMESPACE) + { + RemoteDurability::FailClosed + } else { + RemoteDurability::BestEffort + } + } + + #[cfg(feature = "vss")] + fn retry_critical_remote_mutation( + &self, + primary_namespace: &str, + secondary_namespace: &str, + key: &str, + mut operation: F, + ) -> Result<(), io::Error> + where + F: FnMut() -> Result<(), io::Error>, + { + let mut delay = CRITICAL_RETRY_INITIAL_DELAY; + let mut attempts = 0_u64; + loop { + match operation() { + Ok(()) => { + if attempts > 0 { + tracing::info!( + primary_namespace, + secondary_namespace, + key, + attempts, + "VSS acknowledgement recovered for critical RGB metadata" + ); + } + return Ok(()); + } + Err(error) if crate::vss_kv_store::is_transient_io_error(&error) => { + attempts += 1; + tracing::warn!( + primary_namespace, + secondary_namespace, + key, + attempts, + retry_in = ?delay, + error = %error, + "VSS unavailable; pausing critical RGB metadata transition" + ); + std::thread::sleep(delay); + delay = (delay * 2).min(CRITICAL_RETRY_MAX_DELAY); + } + Err(error) => return Err(error), + } + } } /// Releases the VSS single-writer fence if this instance owns it. No-op @@ -538,12 +625,26 @@ impl SyncedKvStore { } } + /// Persists protocol state locally and requires a VSS acknowledgement when remote backup is + /// configured. The atomic local mutation and retry intent remain durable if VSS is unavailable, + /// but the error is returned so the caller cannot advance its state machine prematurely. + pub(crate) fn write_remote_required( + &self, + primary_namespace: &str, + secondary_namespace: &str, + key: &str, + buf: Vec, + ) -> Result<(), io::Error> { + self.write_with_durability(primary_namespace, secondary_namespace, key, buf, true) + } + fn write_with_durability( &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + require_remote: bool, ) -> Result<(), io::Error> { #[cfg(feature = "vss")] if let Some(ref remote) = self.remote { @@ -555,8 +656,8 @@ impl SyncedKvStore { )); } let vss_key = crate::vss_kv_store::vss_key(primary_namespace, secondary_namespace, key); - let remote_required = - Self::requires_remote_durability(primary_namespace, secondary_namespace); + let durability = + Self::remote_durability(primary_namespace, secondary_namespace, require_remote); let (replicated, remote_error) = { let lock = self.key_lock(&vss_key); let _guard = lock.lock().unwrap(); @@ -574,7 +675,17 @@ impl SyncedKvStore { synced_persistence_checkpoint("synced-write-after-local-commit"); synced_persistence_checkpoint("synced-write-before-remote"); - match remote.write(primary_namespace, secondary_namespace, key, buf.clone()) { + let remote_result = if durability == RemoteDurability::WaitForAcknowledgement { + self.retry_critical_remote_mutation( + primary_namespace, + secondary_namespace, + key, + || remote.write(primary_namespace, secondary_namespace, key, buf.clone()), + ) + } else { + remote.write(primary_namespace, secondary_namespace, key, buf.clone()) + }; + match remote_result { Ok(()) => { synced_persistence_checkpoint("synced-write-after-remote"); synced_persistence_checkpoint("synced-write-before-pending-clear"); @@ -598,7 +709,7 @@ impl SyncedKvStore { secondary_namespace, key, error = %error, - remote_required, + ?durability, "VSS replication write failed; durable retry intent retained" ); (false, Some(error)) @@ -609,7 +720,7 @@ impl SyncedKvStore { if replicated { self.drain_pending(); } - if remote_required { + if durability != RemoteDurability::BestEffort { if let Some(error) = remote_error { return Err(error); } @@ -617,6 +728,7 @@ impl SyncedKvStore { return Ok(()); } + let _ = require_remote; self.local .write(primary_namespace, secondary_namespace, key, buf) } @@ -640,7 +752,7 @@ impl KVStoreSync for SyncedKvStore { key: &str, buf: Vec, ) -> Result<(), io::Error> { - self.write_with_durability(primary_namespace, secondary_namespace, key, buf) + self.write_with_durability(primary_namespace, secondary_namespace, key, buf, false) } fn remove( @@ -660,8 +772,7 @@ impl KVStoreSync for SyncedKvStore { )); } let vss_key = crate::vss_kv_store::vss_key(primary_namespace, secondary_namespace, key); - let remote_required = - Self::requires_remote_durability(primary_namespace, secondary_namespace); + let durability = Self::remote_durability(primary_namespace, secondary_namespace, false); let (replicated, remote_error) = { let lock = self.key_lock(&vss_key); let _guard = lock.lock().unwrap(); @@ -679,7 +790,17 @@ impl KVStoreSync for SyncedKvStore { synced_persistence_checkpoint("synced-remove-after-local-commit"); synced_persistence_checkpoint("synced-remove-before-remote"); - match remote.remove(primary_namespace, secondary_namespace, key, lazy) { + let remote_result = if durability == RemoteDurability::WaitForAcknowledgement { + self.retry_critical_remote_mutation( + primary_namespace, + secondary_namespace, + key, + || remote.remove(primary_namespace, secondary_namespace, key, lazy), + ) + } else { + remote.remove(primary_namespace, secondary_namespace, key, lazy) + }; + match remote_result { Ok(()) => { synced_persistence_checkpoint("synced-remove-after-remote"); synced_persistence_checkpoint("synced-remove-before-pending-clear"); @@ -703,7 +824,7 @@ impl KVStoreSync for SyncedKvStore { secondary_namespace, key, error = %e, - remote_required, + ?durability, "VSS replication remove failed; durable retry intent retained" ); (false, Some(e)) @@ -714,7 +835,7 @@ impl KVStoreSync for SyncedKvStore { if replicated { self.drain_pending(); } - if remote_required { + if durability != RemoteDurability::BestEffort { if let Some(error) = remote_error { return Err(error); } diff --git a/src/test/funding_crash_recovery.rs b/src/test/funding_crash_recovery.rs new file mode 100644 index 00000000..0bb2d178 --- /dev/null +++ b/src/test/funding_crash_recovery.rs @@ -0,0 +1,373 @@ +use super::*; + +use std::collections::BTreeMap; +use std::net::{SocketAddrV4, TcpListener as StdTcpListener}; +use std::process::Child; + +const TEST_DIR_BASE: &str = "tmp/funding_crash_recovery"; +const CHILD_MODE_ENV: &str = "RLN_TEST_DAEMON_CHILD"; +const CHILD_STORAGE_ENV: &str = "RLN_TEST_DAEMON_STORAGE"; +const CHILD_DAEMON_PORT_ENV: &str = "RLN_TEST_DAEMON_PORT"; +const CHILD_PEER_PORT_ENV: &str = "RLN_TEST_DAEMON_PEER_PORT"; +const PREPARED_CHECKPOINT_ENV: &str = "RLN_TEST_RGB_FUNDING_PREPARED_CHECKPOINT"; +const PROMOTED_CHECKPOINT_ENV: &str = "RLN_TEST_RGB_FUNDING_PROMOTED_CHECKPOINT"; + +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn promoted_funding_crash_is_quarantined_without_mutating_stock() { + initialize(); + + let test_dir_node1 = format!("{TEST_DIR_BASE}/node1"); + let test_dir_node2 = format!("{TEST_DIR_BASE}/node2"); + if Path::new(&test_dir_node2).is_dir() { + std::fs::remove_dir_all(&test_dir_node2).unwrap(); + } + let (node1_addr, _) = start_node(&test_dir_node1, NODE1_PEER_PORT, false).await; + let node2_addr = child_daemon_addr(); + + let checkpoint_listener = StdTcpListener::bind("127.0.0.1:0").unwrap(); + let checkpoint_addr = checkpoint_listener.local_addr().unwrap().to_string(); + let checkpoint = tokio::task::spawn_blocking(move || { + let (stream, _) = checkpoint_listener.accept().unwrap(); + let mut line = String::new(); + BufReader::new(stream.try_clone().unwrap()) + .read_line(&mut line) + .unwrap(); + (line, stream) + }); + + let mut node2_child = spawn_child_daemon( + &test_dir_node2, + NODE2_PEER_PORT, + Some((PROMOTED_CHECKPOINT_ENV, checkpoint_addr)), + ); + wait_for_api(node2_addr).await; + + let node2_password = format!("{test_dir_node2}.{NODE2_PEER_PORT}"); + init(node2_addr, &node2_password, None).await; + unlock(node2_addr, &node2_password).await; + + fund_and_create_utxos(node1_addr, None).await; + fund_and_create_utxos(node2_addr, None).await; + + let asset_id = issue_asset_nia(node1_addr).await.asset_id; + let node2_pubkey = node_info(node2_addr).await.pubkey; + let receiver_rgb_before = rgb_stock_snapshot(&test_dir_node2); + + let open_node2_pubkey = node2_pubkey.clone(); + let open_asset_id = asset_id.clone(); + let open_channel_task = tokio::spawn(async move { + open_channel_raw( + node1_addr, + &open_node2_pubkey, + Some(NODE2_PEER_PORT), + Some(100_000), + None, + Some(600), + Some(&open_asset_id), + Some(250), + None, + None, + None, + true, + true, + None, + ) + .await + }); + + let (checkpoint_line, _checkpoint_stream) = + tokio::time::timeout(std::time::Duration::from_secs(30), checkpoint) + .await + .expect("receiver should report the post-promotion checkpoint") + .expect("checkpoint task should complete"); + assert!( + checkpoint_line.contains(' '), + "checkpoint should include temporary channel id and funding txid" + ); + + node2_child.kill().expect("receiver child should be killed"); + let _ = node2_child.wait(); + open_channel_task.abort(); + let _ = open_channel_task.await; + + let receiver_rgb_at_crash = rgb_stock_snapshot(&test_dir_node2); + assert_ne!( + receiver_rgb_before, receiver_rgb_at_crash, + "the promoted checkpoint must expose the accepted RGB stock" + ); + + let mut node2_child = spawn_child_daemon(&test_dir_node2, NODE2_PEER_PORT, None); + wait_for_api(node2_addr).await; + unlock(node2_addr, &node2_password).await; + + let records = receiver_funding_records(&test_dir_node2); + assert_eq!(records.len(), 1, "the crash journal must remain durable"); + assert_eq!(records[0].stage, FundingAcceptanceStage::Promoted); + + let create_utxos = reqwest::Client::new() + .post(format!("http://{node2_addr}/createutxos")) + .json(&CreateUtxosRequest { + up_to: true, + num: Some(1), + size: Some(1_000), + fee_rate: FEE_RATE, + skip_sync: true, + }) + .send() + .await + .unwrap(); + check_response_is_nok( + create_utxos, + reqwest::StatusCode::FORBIDDEN, + "RGB funding recovery is required", + "RgbFundingRecoveryRequired", + ) + .await; + + let receiver_rgb_after = rgb_stock_snapshot(&test_dir_node2); + + shutdown(&[node1_addr, node2_addr]).await; + let _ = node2_child.wait(); + + assert_eq!( + receiver_rgb_at_crash, receiver_rgb_after, + "ambiguous promoted-state recovery must not mutate the RGB stock" + ); +} + +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn prepared_funding_crash_rolls_back_on_restart() { + initialize(); + + let test_dir_node1 = format!("{TEST_DIR_BASE}_prepared/node1"); + let test_dir_node2 = format!("{TEST_DIR_BASE}_prepared/node2"); + for test_dir in [&test_dir_node1, &test_dir_node2] { + if Path::new(test_dir).is_dir() { + std::fs::remove_dir_all(test_dir).unwrap(); + } + } + let (node1_addr, _) = start_node(&test_dir_node1, NODE1_PEER_PORT, false).await; + let node2_addr = child_daemon_addr(); + + let checkpoint_listener = StdTcpListener::bind("127.0.0.1:0").unwrap(); + let checkpoint_addr = checkpoint_listener.local_addr().unwrap().to_string(); + let checkpoint = tokio::task::spawn_blocking(move || { + let (stream, _) = checkpoint_listener.accept().unwrap(); + let mut line = String::new(); + BufReader::new(stream.try_clone().unwrap()) + .read_line(&mut line) + .unwrap(); + (line, stream) + }); + + let mut node2_child = spawn_child_daemon( + &test_dir_node2, + NODE2_PEER_PORT, + Some((PREPARED_CHECKPOINT_ENV, checkpoint_addr)), + ); + wait_for_api(node2_addr).await; + + let node2_password = format!("{test_dir_node2}.{NODE2_PEER_PORT}"); + init(node2_addr, &node2_password, None).await; + unlock(node2_addr, &node2_password).await; + + fund_and_create_utxos(node1_addr, None).await; + fund_and_create_utxos(node2_addr, None).await; + + let asset_id = issue_asset_nia(node1_addr).await.asset_id; + let node2_pubkey = node_info(node2_addr).await.pubkey; + let receiver_rgb_before = rgb_stock_snapshot(&test_dir_node2); + + let open_node2_pubkey = node2_pubkey.clone(); + let open_asset_id = asset_id.clone(); + let open_channel_task = tokio::spawn(async move { + open_channel_raw( + node1_addr, + &open_node2_pubkey, + Some(NODE2_PEER_PORT), + Some(100_000), + None, + Some(600), + Some(&open_asset_id), + Some(250), + None, + None, + None, + true, + true, + None, + ) + .await + }); + + let (checkpoint_line, _checkpoint_stream) = + tokio::time::timeout(std::time::Duration::from_secs(30), checkpoint) + .await + .expect("receiver should report the post-preparation checkpoint") + .expect("checkpoint task should complete"); + assert!( + checkpoint_line.contains(' '), + "checkpoint should include temporary channel id and funding txid" + ); + + node2_child.kill().expect("receiver child should be killed"); + let _ = node2_child.wait(); + open_channel_task.abort(); + let _ = open_channel_task.await; + + let mut node2_child = spawn_child_daemon(&test_dir_node2, NODE2_PEER_PORT, None); + wait_for_api(node2_addr).await; + unlock(node2_addr, &node2_password).await; + + assert!( + receiver_funding_records(&test_dir_node2).is_empty(), + "startup reconciliation must remove the rolled-back prepared journal" + ); + assert!( + list_channels(node2_addr).await.is_empty(), + "a channel must not survive a crash before RGB stock promotion" + ); + + shutdown(&[node1_addr, node2_addr]).await; + let _ = node2_child.wait(); + + assert_eq!( + receiver_rgb_before, + rgb_stock_snapshot(&test_dir_node2), + "prepared-state recovery must restore the receiver's exact pre-funding RGB stock" + ); +} + +fn receiver_funding_records(storage_dir: &str) -> Vec { + let db_path = get_db_path(Path::new(storage_dir)); + let connection_string = format!("sqlite:{}?mode=rw", db_path.display()); + let mut options = ConnectOptions::new(connection_string); + options.max_connections(1); + let database = crate::runtime::block_on(Database::connect(options)) + .expect("connect to receiver recovery database"); + let kv_store = SeaOrmKvStore::from_connection(Arc::new(database)); + kv_store + .list(RGB_PRIMARY_NS, RGB_FUNDING_ACCEPTANCE_NS) + .expect("list receiver funding journals") + .into_iter() + .map(|key| { + read_pending_funding_acceptance(&key, &kv_store).expect("read receiver funding journal") + }) + .collect() +} + +fn rgb_stock_snapshot(storage_dir: &str) -> BTreeMap> { + let mut snapshot = BTreeMap::new(); + let entries = std::fs::read_dir(storage_dir).expect("receiver storage must be readable"); + for entry in entries { + let entry = entry.expect("receiver storage entry must be readable"); + if !entry + .file_type() + .expect("receiver entry type must be readable") + .is_dir() + || entry.file_name() == ".ldk" + { + continue; + } + let rgb_dir = entry.path().join("rgb"); + if !rgb_dir.is_dir() { + continue; + } + for file in std::fs::read_dir(&rgb_dir).expect("RGB stock directory must be readable") { + let file = file.expect("RGB stock entry must be readable"); + if file + .file_type() + .expect("RGB stock entry type must be readable") + .is_file() + { + let name = file.file_name().to_string_lossy().into_owned(); + let bytes = std::fs::read(file.path()).expect("RGB stock file must be readable"); + snapshot.insert(name, bytes); + } + } + } + assert!( + !snapshot.is_empty(), + "receiver RGB stock must exist before funding" + ); + snapshot +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn daemon_child_process() { + if std::env::var(CHILD_MODE_ENV).is_err() { + return; + } + + let storage_dir_path = PathBuf::from(std::env::var(CHILD_STORAGE_ENV).unwrap()); + let daemon_port = std::env::var(CHILD_DAEMON_PORT_ENV) + .unwrap() + .parse::() + .unwrap(); + let peer_port = std::env::var(CHILD_PEER_PORT_ENV) + .unwrap() + .parse::() + .unwrap(); + + let args = UserArgs { + storage_dir_path, + daemon_listening_port: daemon_port, + ldk_peer_listening_port: peer_port, + ..Default::default() + }; + let (router, app_state) = app(args).await.unwrap(); + let listener = tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], daemon_port))) + .await + .unwrap(); + axum::serve(listener, router) + .with_graceful_shutdown(shutdown_signal(app_state)) + .await + .unwrap(); +} + +fn child_daemon_addr() -> SocketAddr { + SocketAddr::V4(SocketAddrV4::new([127, 0, 0, 1].into(), 31_202)) +} + +fn spawn_child_daemon( + storage_dir: &str, + peer_port: u16, + checkpoint: Option<(&'static str, String)>, +) -> Child { + let exe = std::env::current_exe().unwrap(); + let mut command = Command::new(exe); + command + .arg("--exact") + .arg("test::funding_crash_recovery::daemon_child_process") + .arg("--nocapture") + .env(CHILD_MODE_ENV, "1") + .env(CHILD_STORAGE_ENV, storage_dir) + .env( + CHILD_DAEMON_PORT_ENV, + child_daemon_addr().port().to_string(), + ) + .env(CHILD_PEER_PORT_ENV, peer_port.to_string()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + if let Some((checkpoint_env, checkpoint_addr)) = checkpoint { + command.env(checkpoint_env, checkpoint_addr); + } + command.spawn().expect("child daemon should spawn") +} + +async fn wait_for_api(node_address: SocketAddr) { + let started_at = OffsetDateTime::now_utc(); + loop { + if tokio::net::TcpStream::connect(node_address).await.is_ok() { + return; + } + if (OffsetDateTime::now_utc() - started_at).as_seconds_f32() > 20.0 { + panic!("child daemon did not bind {node_address}"); + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } +} diff --git a/src/test/funding_crash_sender.rs b/src/test/funding_crash_sender.rs new file mode 100644 index 00000000..01b0f366 --- /dev/null +++ b/src/test/funding_crash_sender.rs @@ -0,0 +1,384 @@ +use super::*; + +use crate::ldk::{ + FUNDING_CHECKPOINT_AFTER_COLOR, FUNDING_CHECKPOINT_BROADCASTING, + FUNDING_CHECKPOINT_BROADCAST_COMMITTED, FUNDING_CHECKPOINT_BROADCAST_SAFE, + FUNDING_CHECKPOINT_DURABLY_COMPLETED, FUNDING_CHECKPOINT_FINALIZED, + FUNDING_CHECKPOINT_HANDED_TO_LDK, FUNDING_CHECKPOINT_HANDOFF_READY, +}; + +const TEST_DIR_BASE: &str = "tmp/funding_crash_sender/"; + +/// Real daemon subprocess so the test can SIGKILL it at a funding checkpoint +/// (in-process nodes cannot model an OS crash). A debug build is required: the +/// crash checkpoint is compiled out under `--release`. +struct DaemonProcess { + child: std::process::Child, + address: SocketAddr, +} + +impl DaemonProcess { + fn kill(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +impl Drop for DaemonProcess { + fn drop(&mut self) { + self.kill(); + } +} + +fn daemon_binary() -> PathBuf { + let target_dir = std::env::var("CARGO_TARGET_DIR") + .unwrap_or_else(|_| format!("{}/target", env!("CARGO_MANIFEST_DIR"))); + let bin = PathBuf::from(target_dir) + .join("debug") + .join("rgb-lightning-node"); + assert!( + bin.exists(), + "daemon binary not found at {}; run `cargo build` first (a debug build is required)", + bin.display() + ); + bin +} + +async fn start_daemon_process( + node_test_dir: &str, + peer_port: u16, + envs: &[(&str, &str)], +) -> DaemonProcess { + let daemon_port = { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.local_addr().unwrap().port() + }; + std::fs::create_dir_all(node_test_dir).unwrap(); + let log = std::fs::File::create(format!("{node_test_dir}/daemon.log")).unwrap(); + let mut cmd = std::process::Command::new(daemon_binary()); + cmd.arg(node_test_dir) + .arg("--daemon-listening-port") + .arg(daemon_port.to_string()) + .arg("--ldk-peer-listening-port") + .arg(peer_port.to_string()) + .arg("--network") + .arg("regtest") + .arg("--disable-authentication") + .stdout(std::process::Stdio::from(log.try_clone().unwrap())) + .stderr(std::process::Stdio::from(log)); + for (key, value) in envs { + cmd.env(key, value); + } + let child = cmd.spawn().expect("spawn daemon"); + let address: SocketAddr = format!("127.0.0.1:{daemon_port}").parse().unwrap(); + + let t_0 = OffsetDateTime::now_utc(); + loop { + if std::net::TcpStream::connect_timeout(&address, std::time::Duration::from_millis(200)) + .is_ok() + { + break; + } + if (OffsetDateTime::now_utc() - t_0).as_seconds_f32() > 30.0 { + panic!("daemon did not come up on {address}"); + } + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } + DaemonProcess { child, address } +} + +/// Wait for the daemon to signal the checkpoint by writing the ready file. +/// Fails fast (rather than after the full timeout) if the daemon exits before +/// the checkpoint, so an unrelated crash reports its real cause. +async fn wait_for_checkpoint(daemon: &mut DaemonProcess, ready_path: &str, timeout_secs: f32) { + let t_0 = OffsetDateTime::now_utc(); + loop { + if Path::new(ready_path).exists() { + return; + } + if let Some(status) = daemon.child.try_wait().expect("poll daemon status") { + panic!("daemon exited ({status}) before reaching the funding checkpoint"); + } + if (OffsetDateTime::now_utc() - t_0).as_seconds_f32() > timeout_secs { + panic!("timeout waiting for the funding checkpoint at {ready_path}"); + } + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } +} + +#[derive(Clone, Copy)] +enum RecoveryExpectation { + RolledBack, + Funded, + EitherSafeOutcome, +} + +struct CrashCase { + checkpoint: &'static str, + expectation: RecoveryExpectation, +} + +async fn wait_for_recovered_balance_conservation( + node_address: SocketAddr, + asset_id: &str, + initial_supply: u64, + expected_offchain_outbound: u64, +) { + let client = reqwest::Client::new(); + let payload = AssetBalanceRequest { + asset_id: asset_id.to_owned(), + }; + let started = OffsetDateTime::now_utc(); + loop { + let response = client + .post(format!("http://{node_address}/assetbalance")) + .json(&payload) + .send() + .await + .expect("request recovered asset balance"); + if response.status().is_success() { + let balance = response + .json::() + .await + .expect("decode recovered asset balance"); + if balance.offchain_outbound == expected_offchain_outbound + && balance.future.checked_add(balance.offchain_outbound) == Some(initial_supply) + { + return; + } + + let _ = client + .post(format!("http://{node_address}/refreshtransfers")) + .send() + .await; + } else { + assert_eq!( + response.status(), + reqwest::StatusCode::FORBIDDEN, + "unexpected balance response while funding recovery is active" + ); + } + assert!( + (OffsetDateTime::now_utc() - started).as_seconds_f32() <= 120.0, + "recovered RGB balances did not conserve the original supply" + ); + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + } +} + +async fn assert_recovered_funding_state( + node_address: SocketAddr, + asset_id: &str, + initial_spendable: u64, + channel_amount: u64, + funded_before: usize, + expectation: RecoveryExpectation, +) -> usize { + let channels = list_channels(node_address).await; + let funded_after = channels + .iter() + .filter(|channel| channel.asset_id.as_deref() == Some(asset_id)) + .count(); + let expected_if_funded = funded_before + 1; + + match expectation { + RecoveryExpectation::RolledBack => assert_eq!( + funded_after, funded_before, + "a pre-handoff crash must not restore a phantom RGB channel: {channels:?}" + ), + RecoveryExpectation::Funded => assert_eq!( + funded_after, expected_if_funded, + "a post-durable-handoff crash must restore exactly one RGB channel: {channels:?}" + ), + RecoveryExpectation::EitherSafeOutcome => assert!( + funded_after == funded_before || funded_after == expected_if_funded, + "the handoff boundary must either roll back or restore exactly one channel: {channels:?}" + ), + } + + wait_for_recovered_balance_conservation( + node_address, + asset_id, + initial_spendable, + channel_amount * funded_after as u64, + ) + .await; + for channel in channels + .iter() + .filter(|channel| channel.asset_id.as_deref() == Some(asset_id)) + { + assert_eq!(channel.asset_local_amount, Some(channel_amount)); + assert_eq!(channel.asset_remote_amount, Some(0)); + } + funded_after +} + +async fn open_channel_after_recovery( + node_address: SocketAddr, + node2_pubkey: &str, + asset_id: &str, + channel_amount: u64, +) { + let started = OffsetDateTime::now_utc(); + loop { + match open_channel_request_raw( + node_address, + node2_pubkey, + Some(NODE2_PEER_PORT), + None, + None, + Some(channel_amount), + Some(asset_id), + None, + None, + None, + None, + true, + true, + ) + .await + { + Ok(_) => return, + Err(response) => { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("cannot read response body: {error}")); + if status == reqwest::StatusCode::FORBIDDEN + && body.contains("\"name\":\"ChangingState\"") + { + assert!( + (OffsetDateTime::now_utc() - started).as_seconds_f32() <= 30.0, + "funding recovery did not release financial-operation admission" + ); + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + continue; + } + panic!("unexpected open-channel response after funding recovery: {status} {body}"); + } + } + } +} + +/// Exercise every sender funding persistence boundary in real daemon processes. Each child is +/// SIGKILLed immediately after the selected journal checkpoint, then restarted over the same data +/// directory. Recovery must preserve exact asset conservation and either roll back a pre-durable +/// handoff or resume/finalize the exact transaction once the LDK channel is durable. +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn sender_funding_crash_matrix_preserves_assets_and_channels() { + initialize(); + + let test_dir_node1 = format!("{TEST_DIR_BASE}sender_node1"); + let test_dir_node2 = format!("{TEST_DIR_BASE}sender_node2"); + let ready_path = format!("{TEST_DIR_BASE}sender_kill_ready"); + let _ = std::fs::remove_dir_all(&test_dir_node1); + let _ = std::fs::remove_dir_all(&test_dir_node2); + let _ = std::fs::remove_file(&ready_path); + + let password = "funding_crash_sender"; + let mut node1 = start_daemon_process(&test_dir_node1, NODE1_PEER_PORT, &[]).await; + init(node1.address, password, None).await; + unlock(node1.address, password).await; + + let (node2_addr, _) = start_node(&test_dir_node2, NODE2_PEER_PORT, false).await; + + fund_and_create_utxos(node1.address, None).await; + fund_and_create_utxos(node2_addr, None).await; + + let channel_amount = 100; + let asset = issue_asset_nia_with_amounts(node1.address, vec![channel_amount; 10]).await; + create_utxos(node1.address, false, Some(20), Some(32_000)).await; + let initial_spendable = asset_balance_spendable(node1.address, &asset.asset_id).await; + assert!( + initial_spendable > 0, + "test setup must start with spendable RGB assets" + ); + let node2_pubkey = node_info(node2_addr).await.pubkey; + let cases = [ + CrashCase { + checkpoint: FUNDING_CHECKPOINT_AFTER_COLOR, + expectation: RecoveryExpectation::RolledBack, + }, + CrashCase { + checkpoint: FUNDING_CHECKPOINT_HANDOFF_READY, + expectation: RecoveryExpectation::RolledBack, + }, + CrashCase { + checkpoint: FUNDING_CHECKPOINT_HANDED_TO_LDK, + expectation: RecoveryExpectation::EitherSafeOutcome, + }, + CrashCase { + checkpoint: FUNDING_CHECKPOINT_BROADCAST_SAFE, + expectation: RecoveryExpectation::Funded, + }, + CrashCase { + checkpoint: FUNDING_CHECKPOINT_BROADCASTING, + expectation: RecoveryExpectation::Funded, + }, + CrashCase { + checkpoint: FUNDING_CHECKPOINT_BROADCAST_COMMITTED, + expectation: RecoveryExpectation::Funded, + }, + CrashCase { + checkpoint: FUNDING_CHECKPOINT_FINALIZED, + expectation: RecoveryExpectation::Funded, + }, + CrashCase { + checkpoint: FUNDING_CHECKPOINT_DURABLY_COMPLETED, + expectation: RecoveryExpectation::Funded, + }, + ]; + let mut funded_channels = 0; + + for case in cases { + node1.kill(); + let _ = std::fs::remove_file(&ready_path); + node1 = start_daemon_process( + &test_dir_node1, + NODE1_PEER_PORT, + &[ + ("RLN_FUNDING_KILL_AT", case.checkpoint), + ("RLN_FUNDING_KILL_READY_PATH", &ready_path), + ], + ) + .await; + unlock(node1.address, password).await; + + open_channel_after_recovery( + node1.address, + &node2_pubkey, + &asset.asset_id, + channel_amount, + ) + .await; + + wait_for_checkpoint(&mut node1, &ready_path, 120.0).await; + assert_eq!( + std::fs::read_to_string(&ready_path).expect("read ready file"), + case.checkpoint, + "the crash must fire at the requested funding boundary" + ); + node1.kill(); + + node1 = start_daemon_process(&test_dir_node1, NODE1_PEER_PORT, &[]).await; + unlock(node1.address, password).await; + funded_channels = assert_recovered_funding_state( + node1.address, + &asset.asset_id, + initial_spendable, + channel_amount, + funded_channels, + case.expectation, + ) + .await; + if funded_channels > 0 { + mine_n_blocks(false, 6); + wait_for_usable_channels(node1.address, funded_channels).await; + wait_for_usable_channels(node2_addr, funded_channels).await; + } + } + + shutdown(&[node1.address, node2_addr]).await; +} diff --git a/src/test/lib_sdk/close_force_standard.rs b/src/test/lib_sdk/close_force_standard.rs index d36c002e..38c262af 100644 --- a/src/test/lib_sdk/close_force_standard.rs +++ b/src/test/lib_sdk/close_force_standard.rs @@ -2,6 +2,10 @@ use crate::helpers::*; use serial_test::serial; use std::{fs, thread::sleep, time::Duration}; +const CHANNEL_ASSET_AMOUNT: u64 = 600; +const ASSET_SEND_A_TO_B: u64 = 150; +const ASSET_SEND_B_TO_A: u64 = 50; + #[test] #[serial] fn close_force_standard() { @@ -74,7 +78,7 @@ fn close_force_standard() { fee_proportional_millionths: None, temporary_channel_id: None, asset_id: Some(asset_id.clone()), - asset_amount: Some(600), + asset_amount: Some(CHANNEL_ASSET_AMOUNT), push_asset_amount: None, virtual_open_mode: None, }) @@ -89,8 +93,56 @@ fn close_force_standard() { .get_channel_id(open_channel.temporary_channel_id) .expect("node A get_channel_id"); - keysend(&node_a, node_b_pubkey, None, Some(&asset_id), Some(150)); - keysend(&node_b, node_a_pubkey, None, Some(&asset_id), Some(50)); + keysend( + &node_a, + node_b_pubkey, + None, + Some(&asset_id), + Some(ASSET_SEND_A_TO_B), + ); + wait_for_channel_asset_state( + "node A after outbound RGB keysend", + &node_a, + channel_id, + Some(CHANNEL_ASSET_AMOUNT - ASSET_SEND_A_TO_B), + Some(ASSET_SEND_A_TO_B), + None, + Duration::from_secs(60), + ); + wait_for_channel_asset_state( + "node B before return RGB keysend", + &node_b, + channel_id, + Some(ASSET_SEND_A_TO_B), + Some(CHANNEL_ASSET_AMOUNT - ASSET_SEND_A_TO_B), + Some(PAYMENT_MSAT), + Duration::from_secs(60), + ); + keysend( + &node_b, + node_a_pubkey, + None, + Some(&asset_id), + Some(ASSET_SEND_B_TO_A), + ); + wait_for_channel_asset_state( + "node A after return RGB keysend", + &node_a, + channel_id, + Some(CHANNEL_ASSET_AMOUNT - ASSET_SEND_A_TO_B + ASSET_SEND_B_TO_A), + Some(ASSET_SEND_A_TO_B - ASSET_SEND_B_TO_A), + None, + Duration::from_secs(60), + ); + wait_for_channel_asset_state( + "node B after return RGB keysend", + &node_b, + channel_id, + Some(ASSET_SEND_A_TO_B - ASSET_SEND_B_TO_A), + Some(CHANNEL_ASSET_AMOUNT - ASSET_SEND_A_TO_B + ASSET_SEND_B_TO_A), + None, + Duration::from_secs(60), + ); // Mirrors the original test to avoid racing an outdated commitment TX. sleep(Duration::from_secs(5)); diff --git a/src/test/lib_sdk/helpers.rs b/src/test/lib_sdk/helpers.rs index 7c19dcce..1fe56195 100644 --- a/src/test/lib_sdk/helpers.rs +++ b/src/test/lib_sdk/helpers.rs @@ -1,13 +1,15 @@ use electrum_client::ElectrumApi; use once_cell::sync::Lazy; +#[cfg(feature = "vss")] +pub(crate) use rgb_lightning_node::SdkVssClearFenceRequest; pub(crate) use rgb_lightning_node::{ AssetBalanceInfo, AssetRecipients, AssignmentKind, Channel, ContractId, HtlcStatus, - InvoiceStatus, LnInvoiceRequest, Payment, PaymentHash, RecipientId, RgbRecipient, + InvoiceStatus, LnInvoiceRequest, Payment, PaymentHash, RecipientId, RgbRecipient, RlnError, SdkCloseChannelRequest, SdkCreateUtxosRequest, SdkExternalSignerBootstrap, SdkInitRequest, SdkIssueAssetCfaRequest, SdkIssueAssetNiaRequest, SdkKeysendRequest, SdkLdkChainSync, SdkNode, SdkOpenChannelRequest, SdkRefreshTransfersRequest, SdkRgbInvoiceRequest, SdkSendBtcRequest, - SdkSendPaymentRequest, SdkUnlockRequest, SdkVssClearFenceRequest, SendRgbRequest, - TransactionType, TransportEndpoint, WitnessData, + SdkSendPaymentRequest, SdkUnlockRequest, SendRgbRequest, TransactionType, TransportEndpoint, + WitnessData, }; use std::fs; use std::path::{Path, PathBuf}; @@ -335,7 +337,7 @@ pub(crate) fn unlock_request(password: &str) -> SdkUnlockRequest { ldk_chain_sync: SdkLdkChainSync::BlockSync { bitcoind_rpc_username: "user".to_string(), bitcoind_rpc_password: "password".to_string(), - bitcoind_rpc_host: "localhost".to_string(), + bitcoind_rpc_host: "127.0.0.1".to_string(), bitcoind_rpc_port: 18443, }, indexer_url: Some("127.0.0.1:50001".to_string()), @@ -393,15 +395,35 @@ pub(crate) fn fund_and_create_utxos(node: &SdkNode, node_name: &str) { } pub(crate) fn asset_balance_spendable(node: &SdkNode, asset_id: &ContractId) -> u64 { - node.asset_balance(asset_id.clone()) - .expect("asset_balance spendable") - .spendable + retry_while_node_is_changing_state_until( + "asset_balance spendable", + Instant::now() + Duration::from_secs(30), + || node.asset_balance(asset_id.clone()), + ) + .spendable } -pub(crate) fn asset_balance_offchain_outbound(node: &SdkNode, asset_id: &ContractId) -> u64 { - node.asset_balance(asset_id.clone()) - .expect("asset_balance offchain_outbound") - .offchain_outbound +pub(crate) fn retry_while_node_is_changing_state_until( + operation_name: &str, + deadline: Instant, + mut operation: impl FnMut() -> Result, +) -> T { + const CHANGING_STATE_MESSAGE: &str = "Cannot call other APIs while node is changing state"; + + loop { + match operation() { + Ok(value) => return value, + Err(RlnError::Conflict(message)) if message == CHANGING_STATE_MESSAGE => { + assert!( + Instant::now() < deadline, + "{operation_name} remained blocked by a node state transition until the \ + operation deadline" + ); + sleep(Duration::from_millis(25)); + } + Err(error) => panic!("{operation_name} failed: {error}"), + } + } } pub(crate) fn wait_for_asset_balance( @@ -411,8 +433,11 @@ pub(crate) fn wait_for_asset_balance( ) -> AssetBalanceInfo { let deadline = Instant::now() + timeout; loop { - node.sync() - .expect("node sync while waiting for asset_balance"); + retry_while_node_is_changing_state_until( + "node sync while waiting for asset_balance", + deadline, + || node.sync(), + ); if let Ok(balance) = node.asset_balance(asset_id.clone()) { return balance; } @@ -452,26 +477,23 @@ pub(crate) fn wait_for_synced_to_tip(node: &SdkNode, node_name: &str) { pub(crate) fn wait_for_channel_funding_tx( node_a: &SdkNode, - node_b: &SdkNode, + _node_b: &SdkNode, asset_id: &ContractId, timeout: Duration, ) { let deadline = Instant::now() + timeout; loop { - node_a - .sync() - .expect("node A sync while waiting for funding tx"); - node_b - .sync() - .expect("node B sync while waiting for funding tx"); - - let funding_seen = node_a - .list_channels() - .expect("node A list_channels while waiting for funding tx") - .into_iter() - .any(|channel| { - channel.asset_id.as_ref() == Some(asset_id) && channel.funding_txid.is_some() - }); + // Funding construction and broadcast are driven by LDK's background + // processor. Full RGB wallet syncs only compete with that transition. + let funding_seen = retry_while_node_is_changing_state_until( + "node A list_channels while waiting for funding tx", + deadline, + || node_a.list_channels(), + ) + .into_iter() + .any(|channel| { + channel.asset_id.as_ref() == Some(asset_id) && channel.funding_txid.is_some() + }); if funding_seen { return; @@ -495,11 +517,16 @@ where { let deadline = Instant::now() + timeout; let channel_id = loop { - node.sync() - .expect("node sync while waiting for channel open"); - let channels = node - .list_channels() - .expect("list_channels while waiting for channel open"); + retry_while_node_is_changing_state_until( + "node sync while waiting for channel open", + deadline, + || node.sync(), + ); + let channels = retry_while_node_is_changing_state_until( + "list_channels while waiting for channel open", + deadline, + || node.list_channels(), + ); if let Some(channel) = channels .iter() @@ -532,18 +559,39 @@ pub(crate) fn wait_for_usable_channel( loop { polls += 1; - node_a - .sync() - .expect("node A sync while waiting for usable channel"); - node_b - .sync() - .expect("node B sync while waiting for usable channel"); - - let ready = node_a - .list_channels() - .expect("node A list_channels while waiting for usable channel") - .into_iter() - .any(|channel| channel.asset_id.as_ref() == Some(asset_id) && channel.is_usable); + retry_while_node_is_changing_state_until( + "node A sync while waiting for usable channel", + deadline, + || node_a.sync(), + ); + retry_while_node_is_changing_state_until( + "node B sync while waiting for usable channel", + deadline, + || node_b.sync(), + ); + + let node_a_channels = retry_while_node_is_changing_state_until( + "node A list_channels while waiting for usable channel", + deadline, + || node_a.list_channels(), + ); + let node_b_channels = retry_while_node_is_changing_state_until( + "node B list_channels while waiting for usable channel", + deadline, + || node_b.list_channels(), + ); + + let ready = node_a_channels.iter().any(|node_a_channel| { + node_a_channel.asset_id.as_ref() == Some(asset_id) + && node_a_channel.ready + && node_a_channel.is_usable + && node_b_channels.iter().any(|node_b_channel| { + node_b_channel.channel_id == node_a_channel.channel_id + && node_b_channel.asset_id.as_ref() == Some(asset_id) + && node_b_channel.ready + && node_b_channel.is_usable + }) + }); if ready { return; @@ -566,25 +614,40 @@ pub(crate) fn wait_for_channel_asset_state( channel_id: lightning::ln::types::ChannelId, expected_asset_local: Option, expected_asset_remote: Option, - min_outbound_msat: Option, + routable_outbound_msat: Option, timeout: Duration, ) { let deadline = Instant::now() + timeout; loop { - node.sync() - .unwrap_or_else(|_| panic!("{label}: node sync while waiting for channel state")); - let channel = node - .list_channels() - .unwrap_or_else(|_| panic!("{label}: list_channels while waiting for channel state")) - .into_iter() - .find(|channel| channel.channel_id == channel_id) - .unwrap_or_else(|| panic!("{label}: expected channel {channel_id}")); + retry_while_node_is_changing_state_until( + &format!("{label}: node sync while waiting for channel state"), + deadline, + || node.sync(), + ); + let channel = retry_while_node_is_changing_state_until( + &format!("{label}: list_channels while waiting for channel state"), + deadline, + || node.list_channels(), + ) + .into_iter() + .find(|channel| channel.channel_id == channel_id) + .unwrap_or_else(|| panic!("{label}: expected channel {channel_id}")); + let has_inflight_htlcs = retry_while_node_is_changing_state_until( + &format!("{label}: inspect in-flight HTLCs while waiting for channel state"), + deadline, + || rgb_lightning_node::test_utils::channel_has_inflight_htlcs(node, channel_id), + ); if channel.ready && channel.is_usable + && !has_inflight_htlcs && channel.asset_local_amount == expected_asset_local && channel.asset_remote_amount == expected_asset_remote - && min_outbound_msat - .map(|min_outbound_msat| channel.outbound_balance_msat >= min_outbound_msat) + && routable_outbound_msat + .map(|amount_msat| { + channel.outbound_balance_msat >= amount_msat + && channel.next_outbound_htlc_minimum_msat <= amount_msat + && channel.next_outbound_htlc_limit_msat >= amount_msat + }) .unwrap_or(true) { return; @@ -604,11 +667,16 @@ pub(crate) fn wait_for_channel_ready( ) { let deadline = Instant::now() + timeout; loop { - node.sync() - .expect("node sync while waiting for re-established channel"); - let channels = node - .list_channels() - .expect("list_channels while waiting for re-established channel"); + retry_while_node_is_changing_state_until( + "node sync while waiting for re-established channel", + deadline, + || node.sync(), + ); + let channels = retry_while_node_is_changing_state_until( + "list_channels while waiting for re-established channel", + deadline, + || node.list_channels(), + ); if let Some(channel) = channels.iter().find(|c| c.channel_id == channel_id) { if channel.ready { return; @@ -629,10 +697,12 @@ pub(crate) fn wait_for_usable_channels( ) { let deadline = Instant::now() + timeout; loop { - let usable = node - .node_info() - .expect("node_info while waiting for usable channels") - .num_usable_channels as usize; + let usable = retry_while_node_is_changing_state_until( + "node_info while waiting for usable channels", + deadline, + || node.node_info(), + ) + .num_usable_channels as usize; if usable == expected_num_usable_channels { return; } @@ -651,14 +721,19 @@ pub(crate) fn wait_for_usable_channel_counts(nodes: &[(&SdkNode, usize)], timeou polls += 1; let mut all_ready = true; for (node, expected) in nodes { - node.sync() - .expect("node sync while waiting for usable channel counts"); - let usable = node - .list_channels() - .expect("list_channels while waiting for usable channel counts") - .into_iter() - .filter(|channel| channel.ready && channel.is_usable) - .count(); + retry_while_node_is_changing_state_until( + "node sync while waiting for usable channel counts", + deadline, + || node.sync(), + ); + let usable = retry_while_node_is_changing_state_until( + "list_channels while waiting for usable channel counts", + deadline, + || node.list_channels(), + ) + .into_iter() + .filter(|channel| channel.ready && channel.is_usable) + .count(); if usable != *expected { all_ready = false; } @@ -677,12 +752,35 @@ pub(crate) fn wait_for_usable_channel_counts(nodes: &[(&SdkNode, usize)], timeou } } +pub(crate) fn wait_for_processed_channel_ready_events( + channel_ids: &[lightning::ln::types::ChannelId], + expected_participants: usize, + timeout: Duration, +) { + let deadline = Instant::now() + timeout; + loop { + if channel_ids.iter().all(|channel_id| { + rgb_lightning_node::test_utils::processed_channel_ready_event_participants(*channel_id) + == expected_participants + }) { + return; + } + assert!( + Instant::now() < deadline, + "channel-ready event handling did not complete for every participant" + ); + sleep(Duration::from_millis(25)); + } +} + pub(crate) fn wait_for_num_peers(node: &SdkNode, expected_num_peers: u64, timeout: Duration) { let deadline = Instant::now() + timeout; loop { - let node_info = node - .node_info() - .expect("node_info while waiting for num_peers"); + let node_info = retry_while_node_is_changing_state_until( + "node_info while waiting for num_peers", + deadline, + || node.node_info(), + ); if node_info.num_peers == expected_num_peers { return; } @@ -703,15 +801,15 @@ pub(crate) fn wait_for_payment_status( ) -> Payment { let deadline = Instant::now() + timeout; loop { - if let Some(payment) = node - .list_payments() - .expect("list_payments while waiting for payment success") - .into_iter() - .find(|payment| { - payment.payment_hash == *payment_hash - && matches!(payment.status, HtlcStatus::Succeeded) - }) - { + if let Some(payment) = retry_while_node_is_changing_state_until( + "list_payments while waiting for payment success", + deadline, + || node.list_payments(), + ) + .into_iter() + .find(|payment| { + payment.payment_hash == *payment_hash && matches!(payment.status, HtlcStatus::Succeeded) + }) { return payment; } @@ -731,7 +829,12 @@ pub(crate) fn wait_for_ln_balance( ) { let deadline = Instant::now() + timeout; loop { - let balance = asset_balance_offchain_outbound(node, asset_id); + let balance = retry_while_node_is_changing_state_until( + "asset_balance while waiting for offchain_outbound balance", + deadline, + || node.asset_balance(asset_id.clone()), + ) + .offchain_outbound; if balance == expected_balance { return; } @@ -751,12 +854,20 @@ pub(crate) fn wait_for_balance( ) { let deadline = Instant::now() + timeout; loop { - let balance = asset_balance_spendable(node, asset_id); + let balance = retry_while_node_is_changing_state_until( + "asset_balance while waiting for spendable balance", + deadline, + || node.asset_balance(asset_id.clone()), + ) + .spendable; if balance == expected_balance { return; } - node.refreshtransfers(SdkRefreshTransfersRequest { skip_sync: false }) - .expect("refreshtransfers while waiting for balance"); + retry_while_node_is_changing_state_until( + "refreshtransfers while waiting for balance", + deadline, + || node.refreshtransfers(SdkRefreshTransfersRequest { skip_sync: false }), + ); assert!( Instant::now() < deadline, "spendable balance ({balance}) did not become {expected_balance}" @@ -772,7 +883,11 @@ pub(crate) fn wait_for_payment_present_in_list( ) -> Payment { let deadline = Instant::now() + timeout; loop { - let payments = node.list_payments().expect("list_payments"); + let payments = retry_while_node_is_changing_state_until( + "list_payments while waiting for payment", + deadline, + || node.list_payments(), + ); if let Some(payment) = payments .into_iter() .find(|payment| payment.payment_hash == *payment_hash) @@ -793,21 +908,36 @@ pub(crate) fn wait_for_succeeded_payment_in_list( timeout: Duration, ) -> Payment { let deadline = Instant::now() + timeout; + let mut last_status = "missing"; loop { - node.sync() - .expect("node sync while waiting for succeeded payment in list"); - let payments = node.list_payments().expect("list_payments"); + retry_while_node_is_changing_state_until( + "node sync while waiting for succeeded payment in list", + deadline, + || node.sync(), + ); + let payments = retry_while_node_is_changing_state_until( + "list_payments while waiting for succeeded payment", + deadline, + || node.list_payments(), + ); if let Some(payment) = payments .into_iter() .find(|payment| payment.payment_hash == *payment_hash) { - if matches!(payment.status, HtlcStatus::Succeeded) { - return payment; - } + last_status = match &payment.status { + HtlcStatus::Pending => "pending", + HtlcStatus::Claimable => "claimable", + HtlcStatus::Claiming => "claiming", + HtlcStatus::Succeeded => return payment, + HtlcStatus::Cancelled => { + panic!("payment became cancelled before succeeding") + } + HtlcStatus::Failed => panic!("payment failed before succeeding"), + }; } assert!( Instant::now() < deadline, - "payment did not become succeeded in list_payments" + "payment did not become succeeded in list_payments; last observed status: {last_status}" ); sleep(Duration::from_secs(1)); } @@ -895,12 +1025,13 @@ pub(crate) fn keysend_with_ln_balance( wait_for_payment_status(receiver, &keysend.payment_hash, Duration::from_secs(60)); } -pub(crate) fn keysend( +pub(crate) fn keysend_with_timeout( sender: &SdkNode, dest_pubkey: bitcoin::secp256k1::PublicKey, amt_msat: Option, asset_id: Option<&ContractId>, asset_amount: Option, + timeout: Duration, ) -> Payment { let keysend = sender .keysend(SdkKeysendRequest { @@ -910,7 +1041,28 @@ pub(crate) fn keysend( asset_amount, }) .expect("keysend"); - wait_for_succeeded_payment_in_list(sender, &keysend.payment_hash, Duration::from_secs(60)) + assert!( + matches!(keysend.status, HtlcStatus::Pending | HtlcStatus::Succeeded), + "keysend to {dest_pubkey} for RGB amount {asset_amount:?} failed before settlement" + ); + wait_for_succeeded_payment_in_list(sender, &keysend.payment_hash, timeout) +} + +pub(crate) fn keysend( + sender: &SdkNode, + dest_pubkey: bitcoin::secp256k1::PublicKey, + amt_msat: Option, + asset_id: Option<&ContractId>, + asset_amount: Option, +) -> Payment { + keysend_with_timeout( + sender, + dest_pubkey, + amt_msat, + asset_id, + asset_amount, + Duration::from_secs(60), + ) } pub(crate) fn close_channel( @@ -928,18 +1080,21 @@ pub(crate) fn close_channel_with_force( force: bool, ) { stop_mining(); - node.closechannel(SdkCloseChannelRequest { - channel_id, - peer_pubkey, - force, - }) - .expect("closechannel"); - let deadline = Instant::now() + Duration::from_secs(30); + retry_while_node_is_changing_state_until("closechannel", deadline, || { + node.closechannel(SdkCloseChannelRequest { + channel_id, + peer_pubkey, + force, + }) + }); + loop { - let channels = node - .list_channels() - .expect("list_channels while waiting for close"); + let channels = retry_while_node_is_changing_state_until( + "list_channels while waiting for close", + deadline, + || node.list_channels(), + ); if !channels .iter() .any(|channel| channel.channel_id == channel_id) @@ -953,6 +1108,9 @@ pub(crate) fn close_channel_with_force( } pub(crate) fn refresh_transfers(node: &SdkNode) { - node.refreshtransfers(SdkRefreshTransfersRequest { skip_sync: false }) - .expect("refreshtransfers"); + retry_while_node_is_changing_state_until( + "refreshtransfers", + Instant::now() + Duration::from_secs(30), + || node.refreshtransfers(SdkRefreshTransfersRequest { skip_sync: false }), + ); } diff --git a/src/test/lib_sdk/openchannel_push_asset_amount.rs b/src/test/lib_sdk/openchannel_push_asset_amount.rs index afbe0d56..95a0b12a 100644 --- a/src/test/lib_sdk/openchannel_push_asset_amount.rs +++ b/src/test/lib_sdk/openchannel_push_asset_amount.rs @@ -169,6 +169,24 @@ fn openchannel_push_asset_amount() { 350, 250, ); + wait_for_channel_asset_state( + "node A partial push before cooperative close", + &node_a, + partial_channel_id, + Some(300), + Some(300), + None, + Duration::from_secs(30), + ); + wait_for_channel_asset_state( + "node B partial push before cooperative close", + &node_b, + partial_channel_id, + Some(300), + Some(300), + None, + Duration::from_secs(30), + ); let node_a_channel = node_a .list_channels() @@ -289,6 +307,24 @@ fn openchannel_push_asset_amount() { 600, 0, ); + wait_for_channel_asset_state( + "node A full push before cooperative close", + &node_a, + full_channel_id, + Some(100), + Some(500), + None, + Duration::from_secs(30), + ); + wait_for_channel_asset_state( + "node B full push before cooperative close", + &node_b, + full_channel_id, + Some(500), + Some(100), + None, + Duration::from_secs(30), + ); let node_a_channel = node_a .list_channels() diff --git a/src/test/lib_sdk/vss_consignment_reimport.rs b/src/test/lib_sdk/vss_consignment_reimport.rs index 5068d510..569e7b44 100644 --- a/src/test/lib_sdk/vss_consignment_reimport.rs +++ b/src/test/lib_sdk/vss_consignment_reimport.rs @@ -7,7 +7,10 @@ use crate::helpers::*; use crate::vss_manager_lag::{vss_server_available, ManagerFilterProxy}; use serial_test::serial; -use std::{fs, time::Duration}; +use std::{ + fs, + time::{Duration, Instant}, +}; const NODE_A_PORT_OFFSET: u16 = 150; const NODE_B_PORT_OFFSET: u16 = 150; @@ -16,15 +19,23 @@ const PASSWORD_B: &str = "nodeBpass"; const CHANNEL_ASSET_AMOUNT: u64 = 600; const ASSET_SEND_A_TO_B: u64 = 150; const ASSET_SEND_B_TO_A: u64 = 50; +const PAYMENT_SETTLEMENT_TIMEOUT: Duration = Duration::from_secs(180); // The recovery must reflect the latest commitment, not the funding split. const NODE_A_FINAL_ASSET_AMOUNT: u64 = CHANNEL_ASSET_AMOUNT - ASSET_SEND_A_TO_B + ASSET_SEND_B_TO_A; const NODE_B_FINAL_ASSET_AMOUNT: u64 = ASSET_SEND_A_TO_B - ASSET_SEND_B_TO_A; -fn spendable_sats(node: &SdkNode) -> u64 { - let balance = node.btc_balance(false).expect("btc_balance"); +fn spendable_sats_until(node: &SdkNode, label: &str, deadline: Instant) -> u64 { + let balance = + retry_while_node_is_changing_state_until(label, deadline, || node.btc_balance(false)); balance.vanilla.spendable + balance.colored.spendable } +fn refresh_transfers_until(node: &SdkNode, label: &str, deadline: Instant) { + retry_while_node_is_changing_state_until(label, deadline, || { + node.refreshtransfers(SdkRefreshTransfersRequest { skip_sync: false }) + }); +} + #[test] #[serial] fn restored_node_recovers_force_closed_channel_funds() { @@ -44,8 +55,8 @@ fn restored_node_recovers_force_closed_channel_funds() { let proxy = ManagerFilterProxy::start(); - // Phase 1: build the incomplete backup through the product path — the - // node replicates normally except that no RGB backup ever reaches VSS. + // Phase 1: build a fully durable channel through the product path. Funding + // must not bypass the VSS acknowledgement required by the recovery journal. let node_a = make_node_with_vss( &node_a_dir, NODE_A_DAEMON_PORT + NODE_A_PORT_OFFSET, @@ -71,8 +82,6 @@ fn restored_node_recovers_force_closed_channel_funds() { .unlock(unlock_request(PASSWORD_B)) .expect("node B initial unlock"); - proxy.block_rgb_backup_writes(); - fund_and_create_utxos(&node_a, "node A"); fund_and_create_utxos(&node_b, "node B"); @@ -117,12 +126,14 @@ fn restored_node_recovers_force_closed_channel_funds() { wait_for_channel_funding_tx(&node_a, &node_b, &asset_id, Duration::from_secs(120)); mine(OPEN_CHANNEL_CONFIRM_BLOCKS); wait_for_usable_channel(&node_a, &node_b, &asset_id, Duration::from_secs(300)); - let _colored_channel_id = node_a - .get_channel_id(open_channel.temporary_channel_id) - .expect("node A get_channel_id"); + let colored_channel_id = retry_while_node_is_changing_state_until( + "node A get_channel_id", + Instant::now() + Duration::from_secs(30), + || node_a.get_channel_id(open_channel.temporary_channel_id), + ); // A second, vanilla channel: its force-close sweep must also recover. - node_a + let vanilla_open_channel = node_a .openchannel(SdkOpenChannelRequest { peer_pubkey_and_opt_addr: peer_uri.clone(), capacity_sat: OPEN_CHANNEL_CAPACITY_SAT, @@ -140,29 +151,79 @@ fn restored_node_recovers_force_closed_channel_funds() { .expect("node A openchannel vanilla"); mine(OPEN_CHANNEL_CONFIRM_BLOCKS); wait_for_usable_channel_counts(&[(&node_a, 2), (&node_b, 2)], Duration::from_secs(300)); + let vanilla_channel_id = retry_while_node_is_changing_state_until( + "node A get vanilla channel_id", + Instant::now() + Duration::from_secs(30), + || node_a.get_channel_id(vanilla_open_channel.temporary_channel_id), + ); + wait_for_processed_channel_ready_events( + &[colored_channel_id, vanilla_channel_id], + 2, + Duration::from_secs(120), + ); // Several settled payments so the stored fascia is overwritten across // commitment updates and the restore recovers the latest balance split. - keysend( + keysend_with_timeout( &node_a, node_b_pubkey, None, Some(&asset_id), Some(ASSET_SEND_A_TO_B), + PAYMENT_SETTLEMENT_TIMEOUT, ); - keysend( + wait_for_channel_asset_state( + "node A after outbound RGB keysend", + &node_a, + colored_channel_id, + Some(CHANNEL_ASSET_AMOUNT - ASSET_SEND_A_TO_B), + Some(ASSET_SEND_A_TO_B), + None, + Duration::from_secs(60), + ); + wait_for_channel_asset_state( + "node B before return RGB keysend", + &node_b, + colored_channel_id, + Some(ASSET_SEND_A_TO_B), + Some(CHANNEL_ASSET_AMOUNT - ASSET_SEND_A_TO_B), + Some(PAYMENT_MSAT), + Duration::from_secs(60), + ); + keysend_with_timeout( &node_b, node_a_pubkey, None, Some(&asset_id), Some(ASSET_SEND_B_TO_A), + PAYMENT_SETTLEMENT_TIMEOUT, + ); + wait_for_channel_asset_state( + "node A after return RGB keysend", + &node_a, + colored_channel_id, + Some(NODE_A_FINAL_ASSET_AMOUNT), + Some(NODE_B_FINAL_ASSET_AMOUNT), + None, + Duration::from_secs(60), + ); + wait_for_channel_asset_state( + "node B after return RGB keysend", + &node_b, + colored_channel_id, + Some(NODE_B_FINAL_ASSET_AMOUNT), + Some(NODE_A_FINAL_ASSET_AMOUNT), + None, + Duration::from_secs(60), ); std::thread::sleep(Duration::from_secs(5)); - // Phase 2: graceful shutdown, device wiped, restore from VSS + seed. + // Phase 2: graceful shutdown, device wiped, restore from VSS + seed. Hide + // only RGB backup reads during restore to model a legacy/missing backup; + // all funding-critical writes above were acknowledged normally. node_a.shutdown(); drop(node_a); - proxy.allow_all(); + proxy.hide_rgb_backup_reads(); fs::remove_dir_all(&node_a_dir).expect("wipe node A storage"); let node_a = make_node_with_vss( @@ -183,14 +244,21 @@ fn restored_node_recovers_force_closed_channel_funds() { node_a .unlock(unlock_request(PASSWORD_A)) .expect("node A unlock after restore"); + assert!( + proxy.blocked_count() > 0, + "legacy-restore fixture must hide at least one RGB backup read" + ); + proxy.allow_all(); // Phase 3: right after unlock the wallet must know the channel asset // again, re-imported from the consignment in the replicated KV data. - let assets = node_a - .list_assets(vec![]) - .expect("node A list_assets") - .nia - .unwrap_or_default(); + let assets = retry_while_node_is_changing_state_until( + "node A list_assets after restore", + Instant::now() + Duration::from_secs(30), + || node_a.list_assets(vec![]), + ) + .nia + .unwrap_or_default(); assert!( assets.iter().any(|a| a.asset_id == asset_id), "restored node must re-import the channel asset from the stored consignment" @@ -200,33 +268,80 @@ fn restored_node_recovers_force_closed_channel_funds() { .connectpeer(peer_uri) .expect("node A connectpeer after restore"); wait_for_usable_channels(&node_a, 2, Duration::from_secs(120)); - let btc_at_restore = spendable_sats(&node_a); + let btc_at_restore = spendable_sats_until( + &node_a, + "node A btc_balance before force close", + Instant::now() + Duration::from_secs(30), + ); // Phase 4: force-close both channels; the sweeps must return the BTC of // both to_self outputs and make the channel's asset amount spendable. - let channels = node_a.list_channels().expect("node A list_channels"); + let channels = retry_while_node_is_changing_state_until( + "node A list_channels before force close", + Instant::now() + Duration::from_secs(30), + || node_a.list_channels(), + ); assert_eq!(channels.len(), 2); for channel in channels { close_channel_with_force(&node_a, channel.channel_id, node_b_pubkey, true); } + // The close helper waits for the broadcaster to remove each channel and + // mines its CSV delay. The intact peer discovers confirmed closes on its + // own chain-sync loop, however, and may publish its sweep only after that + // mining has completed. Wait for that peer to observe both closes before + // mining the confirmations needed by any resulting sweep transaction. + let counterparty_close_deadline = Instant::now() + Duration::from_secs(120); + loop { + let channels = retry_while_node_is_changing_state_until( + "node B list_channels while waiting for force closes", + counterparty_close_deadline, + || node_b.list_channels(), + ); + if channels.is_empty() { + break; + } + assert!( + Instant::now() < counterparty_close_deadline, + "node B did not observe both force-closed channels" + ); + std::thread::sleep(Duration::from_secs(1)); + } + let mut btc_recovered = false; - let mut assets_recovered = false; + let mut node_a_asset_balance = 0; + let mut node_b_asset_balance = 0; + let recovery_deadline = Instant::now() + Duration::from_secs(180); for _ in 0..40 { mine(10); std::thread::sleep(Duration::from_secs(3)); - let _ = node_a.refreshtransfers(SdkRefreshTransfersRequest { skip_sync: false }); + refresh_transfers_until( + &node_a, + "node A refreshtransfers during force-close recovery", + recovery_deadline, + ); + refresh_transfers_until( + &node_b, + "node B refreshtransfers during force-close recovery", + recovery_deadline, + ); // > 120k sat proves both ~97k to_self outputs (vanilla and colored) // were swept; either alone cannot reach it. - if !btc_recovered && spendable_sats(&node_a) > btc_at_restore + 120_000 { + if !btc_recovered + && spendable_sats_until( + &node_a, + "node A btc_balance during force-close recovery", + recovery_deadline, + ) > btc_at_restore + 120_000 + { btc_recovered = true; } - if !assets_recovered - && asset_balance_spendable(&node_a, &asset_id) >= NODE_A_FINAL_ASSET_AMOUNT + node_a_asset_balance = asset_balance_spendable(&node_a, &asset_id); + node_b_asset_balance = asset_balance_spendable(&node_b, &asset_id); + if btc_recovered + && node_a_asset_balance == NODE_A_FINAL_ASSET_AMOUNT + && node_b_asset_balance == NODE_B_FINAL_ASSET_AMOUNT { - assets_recovered = true; - } - if btc_recovered && assets_recovered { break; } } @@ -234,17 +349,13 @@ fn restored_node_recovers_force_closed_channel_funds() { btc_recovered, "the force-closed channels' BTC (vanilla and colored) must return to the spendable balance" ); - assert!( - assets_recovered, - "the channel's asset amount must become spendable again" + assert_eq!( + node_a_asset_balance, NODE_A_FINAL_ASSET_AMOUNT, + "the restored node must recover its exact latest-state asset balance" ); - - // The intact counterparty must also claim its latest-state amount. - wait_for_balance( - &node_b, - &asset_id, - NODE_B_FINAL_ASSET_AMOUNT, - Duration::from_secs(120), + assert_eq!( + node_b_asset_balance, NODE_B_FINAL_ASSET_AMOUNT, + "the intact counterparty must recover its exact latest-state asset balance" ); node_a.shutdown(); diff --git a/src/test/lib_sdk/vss_manager_lag.rs b/src/test/lib_sdk/vss_manager_lag.rs index b2af0ab8..56dc6826 100644 --- a/src/test/lib_sdk/vss_manager_lag.rs +++ b/src/test/lib_sdk/vss_manager_lag.rs @@ -9,6 +9,8 @@ use std::io::{Read, Write}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::{fs, time::Duration}; +use vss_client::prost::Message; +use vss_client::types::{ErrorCode, ErrorResponse}; const VSS_SERVER_ADDR: &str = "127.0.0.1:8081"; const NODE_A_PORT_OFFSET: u16 = 110; @@ -22,12 +24,12 @@ pub(crate) fn vss_server_available() -> bool { .is_ok() } -/// VSS proxy that can reject channel-manager-key and/or RGB-backup writes, -/// passing all else through. +/// VSS proxy that can reject channel-manager writes or hide RGB backup reads, +/// passing all other requests through. pub(crate) struct ManagerFilterProxy { port: u16, filter_manager: Arc, - filter_rgb_backup: Arc, + hide_rgb_backup_reads: Arc, blocked: Arc, } @@ -36,31 +38,31 @@ impl ManagerFilterProxy { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); let filter_manager = Arc::new(AtomicBool::new(false)); - let filter_rgb_backup = Arc::new(AtomicBool::new(false)); + let hide_rgb_backup_reads = Arc::new(AtomicBool::new(false)); let blocked = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let manager_flag = Arc::clone(&filter_manager); - let rgb_flag = Arc::clone(&filter_rgb_backup); + let rgb_read_flag = Arc::clone(&hide_rgb_backup_reads); let hits = Arc::clone(&blocked); std::thread::spawn(move || { for conn in listener.incoming() { let Ok(stream) = conn else { break }; let manager_flag = Arc::clone(&manager_flag); - let rgb_flag = Arc::clone(&rgb_flag); + let rgb_read_flag = Arc::clone(&rgb_read_flag); let hits = Arc::clone(&hits); std::thread::spawn(move || { - let _ = handle_conn(stream, manager_flag, rgb_flag, hits); + let _ = handle_conn(stream, manager_flag, rgb_read_flag, hits); }); } }); Self { port, filter_manager, - filter_rgb_backup, + hide_rgb_backup_reads, blocked, } } - fn blocked_count(&self) -> usize { + pub(crate) fn blocked_count(&self) -> usize { self.blocked.load(Ordering::SeqCst) } @@ -72,13 +74,13 @@ impl ManagerFilterProxy { self.filter_manager.store(true, Ordering::SeqCst); } - pub(crate) fn block_rgb_backup_writes(&self) { - self.filter_rgb_backup.store(true, Ordering::SeqCst); + pub(crate) fn hide_rgb_backup_reads(&self) { + self.hide_rgb_backup_reads.store(true, Ordering::SeqCst); } pub(crate) fn allow_all(&self) { self.filter_manager.store(false, Ordering::SeqCst); - self.filter_rgb_backup.store(false, Ordering::SeqCst); + self.hide_rgb_backup_reads.store(false, Ordering::SeqCst); } } @@ -124,7 +126,7 @@ fn body_contains(body: &[u8], needle: &[u8]) -> bool { fn handle_conn( mut client: std::net::TcpStream, filter_manager: Arc, - filter_rgb_backup: Arc, + hide_rgb_backup_reads: Arc, blocked: Arc, ) -> std::io::Result<()> { while let Some((head, body)) = read_http_request(&mut client)? { @@ -133,9 +135,32 @@ fn handle_conn( .lines() .next() .is_some_and(|l| l.contains("putObject")); + let is_get = head_str + .lines() + .next() + .is_some_and(|l| l.contains("getObject")); + let hide_rgb_backup = is_get + && hide_rgb_backup_reads.load(Ordering::SeqCst) + && body_contains(&body, b"backup/"); + if hide_rgb_backup { + blocked.fetch_add(1, Ordering::SeqCst); + let payload = ErrorResponse { + error_code: ErrorCode::NoSuchKeyException as i32, + message: "RGB backup hidden by legacy-restore fixture".to_string(), + } + .encode_to_vec(); + let response = format!( + "HTTP/1.1 404 Not Found\r\ncontent-type: application/octet-stream\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", + payload.len() + ); + client.write_all(response.as_bytes())?; + client.write_all(&payload)?; + return Ok(()); + } + let is_blocked = is_put - && ((filter_manager.load(Ordering::SeqCst) && body_contains(&body, MANAGER_VSS_KEY)) - || (filter_rgb_backup.load(Ordering::SeqCst) && body_contains(&body, b"backup/"))); + && filter_manager.load(Ordering::SeqCst) + && body_contains(&body, MANAGER_VSS_KEY); if is_blocked { blocked.fetch_add(1, Ordering::SeqCst); client.write_all( diff --git a/src/test/mod.rs b/src/test/mod.rs index 04af8c44..ff3ab720 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -21,7 +21,9 @@ use lightning::chain::transaction::TransactionData; use lightning::chain::{Confirm, Filter}; use lightning::ln::channelmanager::DROP_FUNDING_SIGNED_ON_NODE; use lightning::rgb_utils::{ - RgbPaymentInfo, RGB_PAYMENT_INFO_INBOUND_NS, RGB_PAYMENT_INFO_OUTBOUND_NS, RGB_PRIMARY_NS, + read_pending_funding_acceptance, FundingAcceptanceStage, PendingFundingAcceptance, + RgbPaymentInfo, RGB_FUNDING_ACCEPTANCE_NS, RGB_PAYMENT_INFO_INBOUND_NS, + RGB_PAYMENT_INFO_OUTBOUND_NS, RGB_PRIMARY_NS, }; use lightning::util::hash_tables::new_hash_map; use lightning::util::persist::KVStoreSync; @@ -2377,7 +2379,7 @@ async fn open_channel_request_raw( temporary_channel_id: Option<&str>, with_anchors: bool, public: bool, -) -> Result { +) -> Result> { println!( "opening channel with {asset_amount:?} of asset {asset_id:?} from node {node_address} \ to {dest_peer_pubkey}" @@ -2424,7 +2426,7 @@ async fn open_channel_request_raw( let status = res.status(); if !status.is_success() { - return Err(res); + return Err(Box::new(res)); } Ok(res.json::().await.unwrap()) @@ -2446,7 +2448,7 @@ async fn open_channel_funded_raw( temporary_channel_id: Option<&str>, with_anchors: bool, public: bool, -) -> Result { +) -> Result> { open_channel_request_raw( node_address, dest_peer_pubkey, @@ -2496,12 +2498,12 @@ async fn open_channel_funded_raw( } if (OffsetDateTime::now_utc() - t_0).as_seconds_f32() > 50.0 { println!("cannot find funding TX for channel to {dest_peer_pubkey}"); - return Err(Response::from( + return Err(Box::new(Response::from( Builder::new() .status(reqwest::StatusCode::FORBIDDEN) .body("") .unwrap(), - )); + ))); } } let channel_id = channel_id.unwrap(); @@ -3021,7 +3023,7 @@ fn default_ldk_chain_sync() -> LdkChainSync { return LdkChainSync::BlockSync { bitcoind_rpc_username: s!("user"), bitcoind_rpc_password: s!("password"), - bitcoind_rpc_host: s!("localhost"), + bitcoind_rpc_host: s!("127.0.0.1"), bitcoind_rpc_port: 18443, }; #[cfg(not(feature = "block-sync"))] @@ -3482,6 +3484,9 @@ mod drop_funding_signed; mod electrum_opret_confirm; mod esplora_indexer_defaults; mod fail_transfers; +mod funding_crash_recovery; +#[cfg(debug_assertions)] +mod funding_crash_sender; mod getchannelid; mod gossip_p2p; mod gossip_rgs; diff --git a/src/test/openchannel_fail.rs b/src/test/openchannel_fail.rs index 0f5ac7d7..5229958f 100644 --- a/src/test/openchannel_fail.rs +++ b/src/test/openchannel_fail.rs @@ -74,7 +74,7 @@ async fn openchannel_fail() { ) .await; check_response_is_nok( - res.unwrap_err(), + *res.unwrap_err(), reqwest::StatusCode::FORBIDDEN, "Not enough funds", "InsufficientFunds", @@ -105,7 +105,7 @@ async fn openchannel_fail() { ) .await; check_response_is_nok( - res.unwrap_err(), + *res.unwrap_err(), reqwest::StatusCode::FORBIDDEN, "Not enough assets", "InsufficientAssets", @@ -480,7 +480,7 @@ async fn openchannel_fail() { check_response_is_nok( res, reqwest::StatusCode::FORBIDDEN, - "Insufficient capacity to cover the commitment transaction fees (9920 sat)", + "Insufficient capacity to cover the commitment transaction fees", "InsufficientCapacity", ) .await; diff --git a/src/test/vss_durability_gaps.rs b/src/test/vss_durability_gaps.rs index ef2be1aa..9e0f82d8 100644 --- a/src/test/vss_durability_gaps.rs +++ b/src/test/vss_durability_gaps.rs @@ -14,7 +14,12 @@ mod tests { use bitcoin::secp256k1::{rand::rngs::OsRng, Secp256k1, SecretKey}; use hex::DisplayHex; - use lightning::util::persist::KVStoreSync; + use lightning::rgb_utils::{RGB_CHANNEL_INFO_NS, RGB_CHANNEL_INFO_PENDING_NS, RGB_PRIMARY_NS}; + use lightning::util::persist::{ + KVStoreSync, CHANNEL_MANAGER_PERSISTENCE_KEY, + CHANNEL_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, + CHANNEL_MANAGER_PERSISTENCE_SECONDARY_NAMESPACE, + }; use sea_orm::{ConnectOptions, Database}; use crate::kv_store::SeaOrmKvStore; @@ -46,10 +51,17 @@ mod tests { } fn unreachable_vss() -> Arc { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("reserve VSS test port"); + let port = listener.local_addr().expect("VSS test address").port(); + drop(listener); let (signing_key, store_id) = generate_test_keys(); Arc::new( - VssKvStore::new("http://127.0.0.1:5/vss".to_string(), store_id, signing_key) - .expect("vss store"), + VssKvStore::new( + format!("http://127.0.0.1:{port}/vss"), + store_id, + signing_key, + ) + .expect("vss store"), ) } @@ -142,6 +154,7 @@ mod tests { /// the remote attempt fails, so a kill while the VSS request is in flight /// leaves a crash image whose value will never be replicated: a later /// device-loss restore is silently stale. + #[serial_test::serial] #[test] fn crash_image_must_retain_replication_intent() { let dir = tempfile::tempdir().expect("tempdir").keep(); @@ -187,6 +200,7 @@ mod tests { /// mutation VSS has not acknowledged needs a durable retry intent. On /// `dev` a new distinct mutation at cap evicts an arbitrary queued entry, /// so that entry's key silently stops replicating. + #[serial_test::serial] #[test] fn pending_queue_cap_must_not_discard_recovery_evidence() { let dir = tempfile::tempdir().expect("tempdir").keep(); @@ -239,6 +253,7 @@ mod tests { /// only return after the connection is cut; a `stop()` that ignores the /// in-flight put acquires the free gate and returns inside the /// observation window. + #[serial_test::serial] #[test] fn stop_must_wait_for_inflight_remote_mutation() { let dir = tempfile::tempdir().expect("tempdir").keep(); @@ -291,6 +306,7 @@ mod tests { /// A retry drain that passed its initial admission check before shutdown /// must not start a remote mutation after `stop()` has returned. + #[serial_test::serial] #[test] fn queued_drain_must_not_run_after_stop() { let dir = tempfile::tempdir().expect("tempdir").keep(); @@ -363,21 +379,229 @@ mod tests { assert_eq!(synced.pending_remote_writes(), 1); } - /// `psbt` and `pending_funding` writers currently unwrap the write result, - /// so these namespaces must keep acking during a VSS outage until the - /// funding state machine handles the errors. + /// The funding state machine handles `psbt` and `pending_funding` write + /// errors, so neither record may be acknowledged without remote durability. + #[serial_test::serial] #[test] - fn psbt_and_pending_funding_stay_best_effort_during_outage() { + fn psbt_and_pending_funding_fail_closed_during_outage() { let dir = tempfile::tempdir().expect("tempdir").keep(); let local = Arc::new(SeaOrmKvStore::from_connection(open_sqlite(&dir))); let synced = SyncedKvStore::with_vss(local, unreachable_vss()); synced .write("psbt", "", "funding_txid", b"psbt".to_vec()) - .expect("psbt write must ack during an outage"); + .expect_err("psbt write must fail closed during an outage"); synced .write("pending_funding", "", "channel_id", b"txid".to_vec()) - .expect("pending_funding write must ack during an outage"); + .expect_err("pending_funding write must fail closed during an outage"); assert_eq!(synced.pending_remote_writes(), 2); + assert_eq!(synced.read("psbt", "", "funding_txid").unwrap(), b"psbt"); + assert_eq!( + synced.read("pending_funding", "", "channel_id").unwrap(), + b"txid" + ); + } + + /// A protocol transition may explicitly require remote acknowledgement without making every + /// writer in the namespace fail closed. This keeps recovery writes strict while the broader + /// RGB channel-info settlement policy remains a separate change. + #[serial_test::serial] + #[test] + fn explicit_remote_required_write_fails_closed_during_outage() { + let dir = tempfile::tempdir().expect("tempdir").keep(); + let local = Arc::new(SeaOrmKvStore::from_connection(open_sqlite(&dir))); + let synced = SyncedKvStore::with_vss(local, unreachable_vss()); + + synced + .write_remote_required("protocol", "recovery", "operation_id", b"metadata".to_vec()) + .expect_err("protocol state must not advance without remote acknowledgement"); + + assert_eq!(synced.pending_remote_writes(), 1); + assert_eq!( + synced.read("protocol", "recovery", "operation_id").unwrap(), + b"metadata" + ); + } + + /// An acknowledged canonical or pending RGB channel balance must survive loss of the local + /// device. Because the legacy LDK persistence interface cannot return a typed retryable error, + /// the writer waits for VSS recovery and must never return success while the only current value + /// exists in the local retry queue. + #[serial_test::serial] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn acknowledged_rgb_channel_info_survives_device_loss() { + if std::net::TcpStream::connect_timeout( + &"127.0.0.1:8081".parse().unwrap(), + Duration::from_secs(2), + ) + .is_err() + { + eprintln!("SKIP: VSS server not available at http://127.0.0.1:8081/vss"); + return; + } + + for secondary_namespace in [RGB_CHANNEL_INFO_NS, RGB_CHANNEL_INFO_PENDING_NS] { + let proxy = super::super::vss_offline_force_close::VssProxy::start(); + let (signing_key, store_id) = generate_test_keys(); + let local_dir = tempfile::tempdir().expect("local tempdir"); + let local = Arc::new(SeaOrmKvStore::from_connection(open_sqlite( + local_dir.path(), + ))); + let remote = Arc::new( + VssKvStore::new(proxy.url(), store_id.clone(), signing_key).expect("vss store"), + ); + let synced = Arc::new(SyncedKvStore::with_vss(local, remote)); + + synced + .write( + CHANNEL_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, + CHANNEL_MANAGER_PERSISTENCE_SECONDARY_NAMESPACE, + CHANNEL_MANAGER_PERSISTENCE_KEY, + vec![0xCA; 64], + ) + .expect("baseline manager write"); + assert_eq!(synced.pending_remote_writes(), 0); + + proxy.go_offline(); + let (result_tx, result_rx) = mpsc::sync_channel(1); + let writer = { + let synced = Arc::clone(&synced); + std::thread::spawn(move || { + let result = synced.write( + RGB_PRIMARY_NS, + secondary_namespace, + "channel_id", + b"rgb_info".to_vec(), + ); + result_tx.send(result).expect("send writer result"); + }) + }; + + assert!(matches!( + result_rx.recv_timeout(Duration::from_secs(2)), + Err(mpsc::RecvTimeoutError::Timeout) + )); + proxy.go_online(); + result_rx + .recv_timeout(Duration::from_secs(30)) + .expect("critical RGB metadata write did not resume after VSS recovery") + .expect("critical RGB metadata write failed after VSS recovery"); + writer.join().expect("writer thread"); + drop(synced); + + let restored_dir = tempfile::tempdir().expect("restored tempdir"); + let restored_local = Arc::new(SeaOrmKvStore::from_connection(open_sqlite( + restored_dir.path(), + ))); + let restored_remote = Arc::new( + VssKvStore::new( + "http://127.0.0.1:8081/vss".to_owned(), + store_id, + signing_key, + ) + .expect("restored vss store"), + ); + let restored = SyncedKvStore::with_vss(restored_local, restored_remote); + restored.restore_from_vss(true).expect("restore from VSS"); + + assert_eq!( + restored + .read( + CHANNEL_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, + CHANNEL_MANAGER_PERSISTENCE_SECONDARY_NAMESPACE, + CHANNEL_MANAGER_PERSISTENCE_KEY, + ) + .expect("manager must be restored"), + vec![0xCA; 64] + ); + assert_eq!( + restored + .read(RGB_PRIMARY_NS, secondary_namespace, "channel_id") + .expect("acknowledged RGB metadata must survive device loss"), + b"rgb_info" + ); + } + } + + /// An acknowledged channel-metadata removal must not be undone by a device-loss restore. + /// Otherwise a closed channel can reappear with a stale RGB balance split after recovery. + #[serial_test::serial] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn acknowledged_rgb_channel_info_removal_survives_device_loss() { + if std::net::TcpStream::connect_timeout( + &"127.0.0.1:8081".parse().unwrap(), + Duration::from_secs(2), + ) + .is_err() + { + eprintln!("SKIP: VSS server not available at http://127.0.0.1:8081/vss"); + return; + } + + for secondary_namespace in [RGB_CHANNEL_INFO_NS, RGB_CHANNEL_INFO_PENDING_NS] { + let proxy = super::super::vss_offline_force_close::VssProxy::start(); + let (signing_key, store_id) = generate_test_keys(); + let local_dir = tempfile::tempdir().expect("local tempdir"); + let local = Arc::new(SeaOrmKvStore::from_connection(open_sqlite( + local_dir.path(), + ))); + let remote = Arc::new( + VssKvStore::new(proxy.url(), store_id.clone(), signing_key).expect("vss store"), + ); + let synced = Arc::new(SyncedKvStore::with_vss(local, remote)); + + synced + .write( + RGB_PRIMARY_NS, + secondary_namespace, + "channel_id", + b"rgb_info".to_vec(), + ) + .expect("seed RGB metadata"); + assert_eq!(synced.pending_remote_writes(), 0); + + proxy.go_offline(); + let (result_tx, result_rx) = mpsc::sync_channel(1); + let remover = { + let synced = Arc::clone(&synced); + std::thread::spawn(move || { + let result = + synced.remove(RGB_PRIMARY_NS, secondary_namespace, "channel_id", false); + result_tx.send(result).expect("send remover result"); + }) + }; + + assert!(matches!( + result_rx.recv_timeout(Duration::from_secs(2)), + Err(mpsc::RecvTimeoutError::Timeout) + )); + proxy.go_online(); + result_rx + .recv_timeout(Duration::from_secs(30)) + .expect("critical RGB metadata removal did not resume after VSS recovery") + .expect("critical RGB metadata removal failed after VSS recovery"); + remover.join().expect("remover thread"); + drop(synced); + + let restored_dir = tempfile::tempdir().expect("restored tempdir"); + let restored_local = Arc::new(SeaOrmKvStore::from_connection(open_sqlite( + restored_dir.path(), + ))); + let restored_remote = Arc::new( + VssKvStore::new( + "http://127.0.0.1:8081/vss".to_owned(), + store_id, + signing_key, + ) + .expect("restored vss store"), + ); + let restored = SyncedKvStore::with_vss(restored_local, restored_remote); + restored.restore_from_vss(true).expect("restore from VSS"); + + assert!(matches!( + restored.read(RGB_PRIMARY_NS, secondary_namespace, "channel_id"), + Err(error) if error.kind() == bitcoin::io::ErrorKind::NotFound + )); + } } } diff --git a/src/test_utils.rs b/src/test_utils.rs index 22816191..f138da9e 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -10,6 +10,9 @@ use crate::error::APIError; use crate::utils::{AppState, StaticState}; use crate::{NodeHandle, RlnError}; +#[cfg(feature = "uniffi")] +use bitcoin::hex::DisplayHex; + pub struct TestAppState(Arc); pub fn mock_locked_app_state() -> TestAppState { @@ -67,6 +70,19 @@ pub fn node_handle_from_mock_state_for_tests(state: &TestAppState) -> NodeHandle NodeHandle::from_app_state(state.0.clone()) } +#[cfg(feature = "uniffi")] +pub fn channel_has_inflight_htlcs( + node: &crate::SdkNode, + channel_id: crate::ChannelId, +) -> Result { + let channel_id = channel_id.0.as_hex().to_string(); + crate::uniffi_api::channel_has_inflight_htlcs_for_tests(node, &channel_id) +} + +pub fn processed_channel_ready_event_participants(channel_id: crate::ChannelId) -> usize { + crate::ldk::processed_channel_ready_event_participants(&channel_id) +} + pub struct ErrorMappingSnapshot { pub locked_node: RlnError, pub payment_not_found: RlnError, diff --git a/src/uniffi_api/mod.rs b/src/uniffi_api/mod.rs index a9348754..73f6e003 100644 --- a/src/uniffi_api/mod.rs +++ b/src/uniffi_api/mod.rs @@ -393,6 +393,19 @@ fn map_transfer(t: crate::sdk::TransferData) -> Result { }) } +#[cfg(feature = "test-utils")] +pub(crate) fn channel_has_inflight_htlcs_for_tests( + node: &SdkNode, + channel_id: &str, +) -> Result { + let channels = block_on_sdk(sdk::list_channels(node.handle.app_state()))?; + channels + .into_iter() + .find(|channel| channel.channel_id == channel_id) + .map(|channel| channel.has_inflight_htlcs) + .ok_or_else(|| RlnError::NotFound(format!("channel not found: {channel_id}"))) +} + impl SdkNode { pub fn create(request: SdkInitRequest) -> Result { let handle = handle_from_request(request)?; diff --git a/src/utils.rs b/src/utils.rs index 68091897..c49119a0 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -39,7 +39,10 @@ use tokio_util::sync::CancellationToken; use crate::asset_link::AssetLinkMessageHandler; use crate::async_order::{AsyncOrderMessageHandler, AsyncPaymentsPreimageRoot}; use crate::crypto::{decrypt_mnemonic, encrypt_mnemonic}; -use crate::ldk::{ChannelIdsMap, Router, VirtualChannelDraftStore, VirtualChannelSessionStore}; +use crate::ldk::{ + ChannelIdsMap, RgbFundingOperationLease, RgbFundingRecoveryGuard, Router, + VirtualChannelDraftStore, VirtualChannelSessionStore, +}; use crate::rgb::{get_rgb_channel_info_optional, RgbLibWalletWrapper}; use crate::rgb_file_transfer::RgbFileTransferHandler; use crate::signer::{ @@ -218,9 +221,18 @@ pub(crate) struct UnlockedAppState { pub(crate) virtual_channel_draft_store: Arc>, pub(crate) virtual_channel_session_store: Arc>, pub(crate) next_payment_idx: Arc, + pub(crate) rgb_funding_recovery_guard: Arc, } impl UnlockedAppState { + #[allow(dead_code)] + pub(crate) fn ensure_financial_operations_allowed( + &self, + ) -> Result { + self.rgb_funding_recovery_guard + .ensure_financial_operations_allowed() + } + pub(crate) fn attach_apay_signatures( &self, mut params: crate::async_order::AsyncOrderNewParamsWire, diff --git a/src/vss_kv_store.rs b/src/vss_kv_store.rs index 108667d7..186ad95b 100644 --- a/src/vss_kv_store.rs +++ b/src/vss_kv_store.rs @@ -754,7 +754,8 @@ impl KVStoreSync for VssKvStore { self.block_on(self.client.put_object(&request)) .map_err(|e| { tracing::error!(vss_key, error = %e, "VssKvStore write failed"); - io::Error::new(io::ErrorKind::Other, format!("VSS write failed: {e}")) + let msg = format!("VSS write failed: {e}"); + vss_err_to_io(e, msg) })?; Ok(()) } @@ -788,10 +789,8 @@ impl KVStoreSync for VssKvStore { Err(VssError::NoSuchKeyError(_)) => return Ok(()), Err(e) => { tracing::error!(vss_key, error = %e, "VssKvStore remove read failed"); - return Err(io::Error::new( - io::ErrorKind::Other, - format!("VSS remove read failed: {e}"), - )); + let msg = format!("VSS remove read failed: {e}"); + return Err(vss_err_to_io(e, msg)); } }; @@ -810,10 +809,8 @@ impl KVStoreSync for VssKvStore { Ok(_) | Err(VssError::NoSuchKeyError(_)) => Ok(()), Err(e) => { tracing::error!(vss_key, error = %e, "VssKvStore remove failed"); - Err(io::Error::new( - io::ErrorKind::Other, - format!("VSS remove failed: {e}"), - )) + let msg = format!("VSS remove failed: {e}"); + Err(vss_err_to_io(e, msg)) } } } diff --git a/test/kotlin-e2e/KotlinUniffiE2e.kt b/test/kotlin-e2e/KotlinUniffiE2e.kt index e49c60d2..1b59f9f5 100644 --- a/test/kotlin-e2e/KotlinUniffiE2e.kt +++ b/test/kotlin-e2e/KotlinUniffiE2e.kt @@ -228,6 +228,18 @@ private fun assetBalanceSpendable(node: SdkNode, assetId: ContractId): ULong = private fun assetBalanceOffchainOutbound(node: SdkNode, assetId: ContractId): ULong = node.assetBalance(assetId).offchainOutbound +private const val CHANGING_STATE_MESSAGE = "Cannot call other APIs while node is changing state" + +private inline fun pollWhileNodeStable(label: String, operation: () -> T): Result { + return try { + Result.success(operation()) + } catch (error: RlnException.Conflict) { + if (error.message != CHANGING_STATE_MESSAGE) throw error + println("$label deferred while node is changing state") + Result.failure(error) + } +} + private fun channelMatchesAsset(channelAssetId: ContractId?, expectedAssetId: ContractId?): Boolean { return if (expectedAssetId != null) { channelAssetId == expectedAssetId @@ -359,12 +371,22 @@ private fun waitForBalance(node: SdkNode, assetId: ContractId, expected: ULong, val deadline = System.currentTimeMillis() + timeoutSec * 1000L var lastBalance = 0uL while (System.currentTimeMillis() < deadline) { - val balance = assetBalanceSpendable(node, assetId) + val attempt = pollWhileNodeStable("on-chain balance poll") { + val balance = assetBalanceSpendable(node, assetId) + if (balance != expected) { + node.refreshtransfers(SdkRefreshTransfersRequest(skipSync = false)) + } + balance + } + if (attempt.isFailure) { + Thread.sleep(250L) + continue + } + val balance = attempt.getOrThrow() lastBalance = balance if (balance == expected) { return } - node.refreshtransfers(SdkRefreshTransfersRequest(skipSync = false)) Thread.sleep(1000L) } error("spendable balance did not become expected=$expected actual=$lastBalance assetId=$assetId after ${timeoutSec}s") From f31ea354b5cfcbe512b4d301dfde3cb05d742730 Mon Sep 17 00:00:00 2001 From: Jainakin Date: Fri, 28 Aug 2026 02:04:02 +0800 Subject: [PATCH 3/4] Enforce RGB recovery admission across wallet mutations --- src/ldk.rs | 164 ++++++++++++++++++++--- src/rgb.rs | 5 +- src/routes.rs | 167 +++++++++++++++--------- src/sdk/mod.rs | 109 +++++++++------- src/test/vss_unreachable_openchannel.rs | 2 +- src/uniffi_api/state.rs | 1 + src/utils.rs | 11 +- 7 files changed, 327 insertions(+), 132 deletions(-) diff --git a/src/ldk.rs b/src/ldk.rs index bed396d7..8bb0926f 100644 --- a/src/ldk.rs +++ b/src/ldk.rs @@ -148,6 +148,7 @@ const MAKER_SWAPS_KEY: &str = "maker_swaps"; const TAKER_SWAPS_KEY: &str = "taker_swaps"; const ASSET_LINK_SWAP_AUTHORIZATION_MAX_EXPIRY_SECS: u64 = 24 * 60 * 60; const OUTPUT_SPENDER_TXES_KEY: &str = "output_spender_txes"; +const OUTPUT_SWEEPER_WALLET_OPERATION_WAIT: Duration = Duration::from_secs(1); pub(crate) const PSBT_NAMESPACE: &str = crate::synced_kv_store::PSBT_NAMESPACE; pub(crate) const PENDING_FUNDING_NAMESPACE: &str = crate::synced_kv_store::PENDING_FUNDING_NAMESPACE; @@ -348,7 +349,7 @@ impl RgbFundingRecoveryAction { /// /// An unresolved sender or receiver journal can represent a transaction whose broadcast outcome /// or matching LDK channel state is not yet known. Read-only inspection and recovery remain -/// available, but new financial mutations must not be admitted until every record is reconciled. +/// available, but new RGB-wallet mutations must not be admitted until every record is reconciled. /// /// The operation lease is intentionally wallet-wide. rgb-lib currently persists one pending RGB /// acceptance and one rollback snapshot for the wallet, not one per funding allocation. Allowing @@ -436,27 +437,65 @@ impl RgbFundingRecoveryGuard { .insert(funding_txid.to_owned()); } - pub(crate) fn ensure_financial_operations_allowed( - &self, - ) -> Result { + pub(crate) fn lock_rgb_wallet_mutation(&self) -> Result { // Admission and execution must be one atomic lease. A check-only gate permits a funding // transition to start immediately after the check, allowing its rollback snapshot to // overwrite a concurrent wallet mutation. let operation = Arc::clone(&self.operation_lock) .try_lock_owned() .map_err(|_| APIError::ChangingState)?; + self.ensure_wallet_mutation_is_admitted()?; + Ok(RgbFundingOperationLease::new(operation, "rgb-wallet-api")) + } + + async fn lock_rgb_wallet_mutation_for( + &self, + wait: Duration, + owner: &'static str, + ) -> Result { + self.ensure_wallet_mutation_is_admitted()?; + let operation = tokio::time::timeout(wait, Arc::clone(&self.operation_lock).lock_owned()) + .await + .map_err(|_| APIError::ChangingState)?; + self.ensure_wallet_mutation_is_admitted()?; + Ok(RgbFundingOperationLease::new(operation, owner)) + } + + /// Give LDK's infrequent output sweep a bounded, fair chance to follow a short wallet API + /// mutation. The bound keeps the background processor responsive during long funding work. + pub(crate) async fn lock_output_sweeper_wallet_mutation( + &self, + ) -> Result { + self.lock_rgb_wallet_mutation_for(OUTPUT_SWEEPER_WALLET_OPERATION_WAIT, "output-sweeper") + .await + } + + fn ensure_wallet_mutation_is_admitted(&self) -> Result<(), APIError> { let funding_txids = self.funding_txids.read().unwrap_or_else(|poisoned| { tracing::error!("RGB funding recovery guard was poisoned; preserving quarantine"); poisoned.into_inner() }); if funding_txids.is_empty() { - return Ok(RgbFundingOperationLease::new(operation, "financial-api")); + return Ok(()); } Err(APIError::RgbFundingRecoveryRequired( funding_txids.iter().cloned().collect::>().join(","), )) } + /// Existing BTC-only Lightning traffic does not touch rgb-lib's stock or rollback snapshot and + /// must remain available while an unrelated RGB funding record is quarantined. RGB channel + /// payments retain the wallet-wide lease until rgb-lib can isolate concurrent stock journals + /// by allocation. + pub(crate) fn lock_channel_payment( + &self, + carries_rgb: bool, + ) -> Result, APIError> { + carries_rgb + .then(|| self.lock_rgb_wallet_mutation()) + .transpose() + } + #[cfg(test)] fn snapshot(&self) -> Vec { self.funding_txids @@ -6327,9 +6366,9 @@ impl RgbOutputSpender { locktime: Option, secp_ctx: &Secp256k1, ) -> Result { - let _financial_operation = self + let _rgb_wallet_operation = self .rgb_funding_recovery_guard - .ensure_financial_operations_allowed() + .lock_rgb_wallet_mutation() .map_err(|error| { tracing::debug!(%error, "deferring RGB output sweep during funding transition"); error.to_string() @@ -8283,7 +8322,7 @@ pub(crate) async fn start_ldk( .iter() .map(|recovery| recovery.funding_txid.as_str()) .collect::>(), - "financial operations are quarantined pending RGB funding recovery" + "RGB wallet mutations are quarantined pending funding recovery" ); } } @@ -9947,7 +9986,7 @@ mod tests { let guard = RgbFundingRecoveryGuard::default(); guard.replace(&[recovery]); assert!(matches!( - guard.ensure_financial_operations_allowed(), + guard.lock_rgb_wallet_mutation(), Err(APIError::RgbFundingRecoveryRequired(ref txid)) if txid == &record.funding_txid )); @@ -10086,29 +10125,122 @@ mod tests { assert_eq!(guard.snapshot(), vec![first.clone(), second.clone()]); assert!(matches!( - guard.ensure_financial_operations_allowed(), + guard.lock_rgb_wallet_mutation(), Err(APIError::RgbFundingRecoveryRequired(ref txids)) if txids == &format!("{first},{second}") )); guard.clear(&first); - assert!(guard.ensure_financial_operations_allowed().is_err()); + assert!(guard.lock_rgb_wallet_mutation().is_err()); guard.clear(&second); - assert!(guard.ensure_financial_operations_allowed().is_ok()); + assert!(guard.lock_rgb_wallet_mutation().is_ok()); } #[test] - fn financial_operation_admission_holds_an_exclusive_lease() { + fn rgb_wallet_mutation_admission_holds_an_exclusive_lease() { let guard = RgbFundingRecoveryGuard::default(); - let operation = guard.ensure_financial_operations_allowed().unwrap(); + let operation = guard.lock_rgb_wallet_mutation().unwrap(); assert!(matches!( - guard.ensure_financial_operations_allowed(), + guard.lock_rgb_wallet_mutation(), Err(APIError::ChangingState) )); drop(operation); - assert!(guard.ensure_financial_operations_allowed().is_ok()); + assert!(guard.lock_rgb_wallet_mutation().is_ok()); + } + + #[tokio::test] + async fn output_sweeper_waits_for_a_short_wallet_mutation() { + let guard = Arc::new(RgbFundingRecoveryGuard::default()); + let operation = guard.lock_rgb_wallet_mutation().unwrap(); + let waiter_guard = Arc::clone(&guard); + let waiter = tokio::spawn(async move { + waiter_guard + .lock_rgb_wallet_mutation_for(Duration::from_secs(1), "test-sweeper") + .await + }); + + tokio::task::yield_now().await; + drop(operation); + + let sweep_operation = tokio::time::timeout(Duration::from_secs(1), waiter) + .await + .expect("sweeper admission should not remain blocked") + .expect("sweeper admission task should not panic") + .expect("sweeper should acquire the released wallet lease"); + drop(sweep_operation); + assert!(guard.lock_rgb_wallet_mutation().is_ok()); + } + + #[tokio::test] + async fn output_sweeper_wait_is_bounded() { + let guard = RgbFundingRecoveryGuard::default(); + let _operation = guard.lock_rgb_wallet_mutation().unwrap(); + + assert!(matches!( + guard + .lock_rgb_wallet_mutation_for(Duration::from_millis(10), "test-sweeper") + .await, + Err(APIError::ChangingState) + )); + } + + #[tokio::test] + async fn output_sweeper_remains_blocked_by_recovery_quarantine() { + let guard = RgbFundingRecoveryGuard::default(); + let funding_txid = "11".repeat(32); + guard.replace(&[RgbFundingRecoveryState { + funding_txid: funding_txid.clone(), + temporary_channel_id: "01".repeat(32), + final_channel_id: Some("02".repeat(32)), + stage: RgbFundingRecoveryStage::Sender(RgbSenderFundingStage::Broadcasting), + channel_is_durable: false, + transaction_is_known: None, + error: Some("indexer unavailable".to_owned()), + action: RgbFundingRecoveryAction::RetryChainObservation, + }]); + + assert!(matches!( + guard.lock_output_sweeper_wallet_mutation().await, + Err(APIError::RgbFundingRecoveryRequired(ref blocked_txid)) + if blocked_txid == &funding_txid + )); + } + + #[test] + fn btc_channel_payments_bypass_rgb_recovery_quarantine() { + let guard = RgbFundingRecoveryGuard::default(); + let funding_txid = "11".repeat(32); + guard.replace(&[RgbFundingRecoveryState { + funding_txid: funding_txid.clone(), + temporary_channel_id: "01".repeat(32), + final_channel_id: Some("02".repeat(32)), + stage: RgbFundingRecoveryStage::Sender(RgbSenderFundingStage::Broadcasting), + channel_is_durable: false, + transaction_is_known: None, + error: Some("indexer unavailable".to_owned()), + action: RgbFundingRecoveryAction::RetryChainObservation, + }]); + + assert!(matches!(guard.lock_channel_payment(false), Ok(None))); + assert!(matches!( + guard.lock_channel_payment(true), + Err(APIError::RgbFundingRecoveryRequired(ref blocked_txid)) + if blocked_txid == &funding_txid + )); + } + + #[test] + fn btc_channel_payments_bypass_an_active_rgb_wallet_mutation() { + let guard = RgbFundingRecoveryGuard::default(); + let _rgb_wallet_operation = guard.lock_rgb_wallet_mutation().unwrap(); + + assert!(matches!(guard.lock_channel_payment(false), Ok(None))); + assert!(matches!( + guard.lock_channel_payment(true), + Err(APIError::ChangingState) + )); } #[test] diff --git a/src/rgb.rs b/src/rgb.rs index 235700ad..6f1f0a39 100644 --- a/src/rgb.rs +++ b/src/rgb.rs @@ -1404,9 +1404,10 @@ impl WalletSource for RgbBumpWalletSource { impl ChangeDestinationSource for RgbChangeDestinationSource { fn get_change_destination_script<'a>(&'a self) -> AsyncResult<'a, ScriptBuf, ()> { Box::pin(async move { - let _financial_operation = self + let _rgb_wallet_operation = self .funding_guard - .ensure_financial_operations_allowed() + .lock_output_sweeper_wallet_mutation() + .await .map_err(|error| { tracing::debug!(%error, "deferring change-address allocation during RGB funding"); })?; diff --git a/src/routes.rs b/src/routes.rs index 78e0a341..97b1f19b 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -15,7 +15,7 @@ use lightning::chain::channelmonitor::Balance; use lightning::ln::{channelmanager::OptionalOfferPaymentParams, types::ChannelId}; use lightning::offers::offer::{self, Offer}; use lightning::onion_message::messenger::Destination; -use lightning::rgb_utils::{RgbInfo, RgbKvStoreExt, STATIC_BLINDING}; +use lightning::rgb_utils::{is_channel_rgb, RgbInfo, RgbKvStoreExt, STATIC_BLINDING}; use lightning::routing::gossip::RoutingFees; use lightning::routing::router::{Path as LnPath, Route, RouteHint, RouteHintHop}; use lightning::{ @@ -1738,6 +1738,7 @@ pub(crate) async fn address( ) -> Result, APIError> { let guard = state.check_unlocked().await?; let unlocked_state = guard.as_ref().unwrap(); + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; let address = unlocked_state.rgb_get_address()?; @@ -1749,6 +1750,7 @@ pub(crate) async fn rotate_address( ) -> Result, APIError> { let guard = state.check_unlocked().await?; let unlocked_state = guard.as_ref().unwrap(); + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; let address = unlocked_state.rgb_rotate_address()?; @@ -1762,6 +1764,7 @@ pub(crate) async fn async_order_new( let guard = state.check_unlocked().await?; let unlocked_state = Arc::clone(guard.as_ref().unwrap()); drop(guard); + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; let host_node_id = hex_str_to_compressed_pubkey(&payload.host_node_id).ok_or(APIError::InvalidPubkey)?; @@ -1871,6 +1874,7 @@ pub(crate) async fn async_order_outbound_invoice( let guard = state.check_unlocked().await?; let unlocked_state = Arc::clone(guard.as_ref().unwrap()); drop(guard); + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; let peer_node_id = hex_str_to_compressed_pubkey(&payload.client_node_id).ok_or(APIError::InvalidPubkey)?; @@ -1986,6 +1990,7 @@ pub(crate) async fn asset_link( no_cancel(async move { let guard = state.check_unlocked().await?; let unlocked_state = guard.as_ref().unwrap(); + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; let asset_link = create_asset_link(unlocked_state, payload)?; Ok(Json(asset_link)) @@ -2050,6 +2055,11 @@ pub(crate) async fn btc_balance( ) -> Result, APIError> { let guard = state.check_unlocked().await?; let unlocked_state = guard.as_ref().unwrap(); + let _rgb_wallet_operation = if payload.skip_sync { + None + } else { + Some(unlocked_state.lock_rgb_wallet_mutation()?) + }; let btc_balance = unlocked_state.rgb_get_btc_balance(payload.skip_sync)?; @@ -2077,6 +2087,8 @@ pub(crate) async fn cancel_hodl_invoice( let unlocked_state = guard.as_ref().unwrap(); let payment_hash = validate_and_parse_payment_hash(&payload.payment_hash)?; + let _rgb_payment_operation = unlocked_state + .lock_channel_payment(unlocked_state.kv_store.is_payment_rgb(&payment_hash))?; let payment_info = unlocked_state .get_inbound_payments() .payments @@ -2154,6 +2166,8 @@ pub(crate) async fn claim_hodl_invoice( let unlocked_state = guard.as_ref().unwrap(); let payment_hash = validate_and_parse_payment_hash(&payload.payment_hash)?; + let _rgb_payment_operation = unlocked_state + .lock_channel_payment(unlocked_state.kv_store.is_payment_rgb(&payment_hash))?; let preimage = validate_and_parse_payment_preimage(&payload.payment_preimage, &payment_hash)?; @@ -2242,6 +2256,10 @@ pub(crate) async fn close_channel( return Err(APIError::InvalidChannelID); } let requested_cid = ChannelId(channel_id_vec.unwrap().try_into().unwrap()); + let _rgb_payment_operation = unlocked_state.lock_channel_payment(is_channel_rgb( + &requested_cid, + unlocked_state.kv_store.as_ref(), + ))?; let peer_pubkey_vec = match hex_str_to_vec(&payload.peer_pubkey) { Some(peer_pubkey_vec) => peer_pubkey_vec, @@ -2448,6 +2466,7 @@ pub(crate) async fn create_utxos( no_cancel(async move { let guard = state.check_unlocked().await?; let unlocked_state = guard.as_ref().unwrap(); + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; let num = payload.num.unwrap_or(unlocked_state.config.rgb.utxo_num); let size = payload @@ -2621,6 +2640,7 @@ pub(crate) async fn fail_transfers( no_cancel(async move { let guard = state.check_unlocked().await?; let unlocked_state = guard.as_ref().unwrap(); + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; let unlocked_state_copy = unlocked_state.clone(); let transfers_changed = tokio::task::spawn_blocking(move || { @@ -2786,6 +2806,54 @@ pub(crate) async fn get_payment( Err(APIError::PaymentNotFound(payload.payment_hash)) } +fn map_swap( + payment_hash: &PaymentHash, + swap_data: &SwapData, + taker: bool, + unlocked_state: &UnlockedAppState, +) -> Swap { + let mut status = swap_data.status; + if status == SwapStatus::Waiting && get_current_timestamp() > swap_data.swap_info.expiry { + status = SwapStatus::Expired; + } else if status == SwapStatus::Pending + && get_current_timestamp() > swap_data.initiated_at.unwrap() + PENDING_SWAP_TIMEOUT_SECS + { + status = SwapStatus::Failed; + } + + if status != swap_data.status { + match unlocked_state.lock_rgb_wallet_mutation() { + Ok(_rgb_wallet_operation) => { + if taker { + unlocked_state.update_taker_swap_status(payment_hash, status); + } else { + unlocked_state.update_maker_swap_status(payment_hash, status); + } + } + Err(error) => { + tracing::debug!( + %error, + %payment_hash, + "returning derived swap status without persisting it" + ); + } + } + } + + Swap { + payment_hash: payment_hash.to_string(), + qty_from: swap_data.swap_info.qty_from, + qty_to: swap_data.swap_info.qty_to, + from_asset: swap_data.swap_info.from_asset.map(|c| c.to_string()), + to_asset: swap_data.swap_info.to_asset.map(|c| c.to_string()), + status, + requested_at: swap_data.requested_at, + initiated_at: swap_data.initiated_at, + expires_at: swap_data.swap_info.expiry, + completed_at: swap_data.completed_at, + } +} + pub(crate) async fn get_swap( State(state): State>, WithRejection(Json(payload), _): WithRejection, APIError>, @@ -2795,48 +2863,18 @@ pub(crate) async fn get_swap( let requested_ph = validate_and_parse_payment_hash(&payload.payment_hash)?; - let map_swap = |payment_hash: &PaymentHash, swap_data: &SwapData, taker: bool| { - let mut status = swap_data.status; - if status == SwapStatus::Waiting && get_current_timestamp() > swap_data.swap_info.expiry { - status = SwapStatus::Expired; - } else if status == SwapStatus::Pending - && get_current_timestamp() > swap_data.initiated_at.unwrap() + PENDING_SWAP_TIMEOUT_SECS - { - status = SwapStatus::Failed; - } - if status != swap_data.status { - if taker { - unlocked_state.update_taker_swap_status(payment_hash, status); - } else { - unlocked_state.update_maker_swap_status(payment_hash, status); - } - } - Swap { - payment_hash: payment_hash.to_string(), - qty_from: swap_data.swap_info.qty_from, - qty_to: swap_data.swap_info.qty_to, - from_asset: swap_data.swap_info.from_asset.map(|c| c.to_string()), - to_asset: swap_data.swap_info.to_asset.map(|c| c.to_string()), - status, - requested_at: swap_data.requested_at, - initiated_at: swap_data.initiated_at, - expires_at: swap_data.swap_info.expiry, - completed_at: swap_data.completed_at, - } - }; - if payload.taker { let taker_swaps = unlocked_state.taker_swaps(); if let Some(sd) = taker_swaps.get(&requested_ph) { return Ok(Json(GetSwapResponse { - swap: map_swap(&requested_ph, sd, true), + swap: map_swap(&requested_ph, sd, true, unlocked_state), })); } } else { let maker_swaps = unlocked_state.maker_swaps(); if let Some(sd) = maker_swaps.get(&requested_ph) { return Ok(Json(GetSwapResponse { - swap: map_swap(&requested_ph, sd, false), + swap: map_swap(&requested_ph, sd, false, unlocked_state), })); } } @@ -2851,6 +2889,7 @@ pub(crate) async fn inflate( no_cancel(async move { let guard = state.check_unlocked().await?; let unlocked_state = guard.as_ref().unwrap(); + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; if unlocked_state.external_signer_mode { return Err(APIError::UnsupportedInExternalSignerMode( "inflate is not supported in external signer mode".to_string(), @@ -3005,6 +3044,7 @@ pub(crate) async fn issue_asset_cfa( no_cancel(async move { let guard = state.check_unlocked().await?; let unlocked_state = guard.as_ref().unwrap(); + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; if unlocked_state.external_signer_mode { return Err(APIError::UnsupportedInExternalSignerMode( "asset issuance is not supported in external signer mode".to_string(), @@ -3041,6 +3081,7 @@ pub(crate) async fn issue_asset_ifa( no_cancel(async move { let guard = state.check_unlocked().await?; let unlocked_state = guard.as_ref().unwrap(); + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; if unlocked_state.external_signer_mode { return Err(APIError::UnsupportedInExternalSignerMode( "asset issuance is not supported in external signer mode".to_string(), @@ -3071,6 +3112,7 @@ pub(crate) async fn issue_asset_nia( no_cancel(async move { let guard = state.check_unlocked().await?; let unlocked_state = guard.as_ref().unwrap(); + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; if unlocked_state.external_signer_mode { return Err(APIError::UnsupportedInExternalSignerMode( "asset issuance is not supported in external signer mode".to_string(), @@ -3098,6 +3140,7 @@ pub(crate) async fn issue_asset_uda( no_cancel(async move { let guard = state.check_unlocked().await?; let unlocked_state = guard.as_ref().unwrap(); + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; if unlocked_state.external_signer_mode { return Err(APIError::UnsupportedInExternalSignerMode( "asset issuance is not supported in external signer mode".to_string(), @@ -3172,6 +3215,7 @@ pub(crate) async fn keysend( return Err(APIError::IncompleteRGBInfo); } }; + let _rgb_payment_operation = unlocked_state.lock_channel_payment(rgb_payment.is_some())?; let route_params = RouteParameters::from_payment_params_and_value( PaymentParameters::for_keysend(dest_pubkey, 40, false), @@ -3536,47 +3580,17 @@ pub(crate) async fn list_swaps( let guard = state.check_unlocked().await?; let unlocked_state = guard.as_ref().unwrap(); - let map_swap = |payment_hash: &PaymentHash, swap_data: &SwapData, taker: bool| { - let mut status = swap_data.status; - if status == SwapStatus::Waiting && get_current_timestamp() > swap_data.swap_info.expiry { - status = SwapStatus::Expired; - } else if status == SwapStatus::Pending - && get_current_timestamp() > swap_data.initiated_at.unwrap() + PENDING_SWAP_TIMEOUT_SECS - { - status = SwapStatus::Failed; - } - if status != swap_data.status { - if taker { - unlocked_state.update_taker_swap_status(payment_hash, status); - } else { - unlocked_state.update_maker_swap_status(payment_hash, status); - } - } - Swap { - payment_hash: payment_hash.to_string(), - qty_from: swap_data.swap_info.qty_from, - qty_to: swap_data.swap_info.qty_to, - from_asset: swap_data.swap_info.from_asset.map(|c| c.to_string()), - to_asset: swap_data.swap_info.to_asset.map(|c| c.to_string()), - status, - requested_at: swap_data.requested_at, - initiated_at: swap_data.initiated_at, - expires_at: swap_data.swap_info.expiry, - completed_at: swap_data.completed_at, - } - }; - let taker_swaps = unlocked_state.taker_swaps(); let maker_swaps = unlocked_state.maker_swaps(); Ok(Json(ListSwapsResponse { taker: taker_swaps .iter() - .map(|(ph, sd)| map_swap(ph, sd, true)) + .map(|(ph, sd)| map_swap(ph, sd, true, unlocked_state)) .collect(), maker: maker_swaps .iter() - .map(|(ph, sd)| map_swap(ph, sd, false)) + .map(|(ph, sd)| map_swap(ph, sd, false, unlocked_state)) .collect(), })) } @@ -3587,6 +3601,11 @@ pub(crate) async fn list_transactions( ) -> Result, APIError> { let guard = state.check_unlocked().await?; let unlocked_state = guard.as_ref().unwrap(); + let _rgb_wallet_operation = if payload.skip_sync { + None + } else { + Some(unlocked_state.lock_rgb_wallet_mutation()?) + }; let mut transactions = vec![]; for tx in unlocked_state.rgb_list_transactions(payload.skip_sync)? { @@ -3727,6 +3746,11 @@ pub(crate) async fn list_unspents( ) -> Result, APIError> { let guard = state.check_unlocked().await?; let unlocked_state = guard.as_ref().unwrap(); + let _rgb_wallet_operation = if payload.skip_sync { + None + } else { + Some(unlocked_state.lock_rgb_wallet_mutation()?) + }; let mut unspents = vec![]; for unspent in unlocked_state.rgb_list_unspents(payload.settled_only, payload.skip_sync)? { @@ -3792,6 +3816,7 @@ pub(crate) async fn ln_invoice( } else { None }; + let _rgb_payment_operation = unlocked_state.lock_channel_payment(contract_id.is_some())?; if let Some(contract_id) = &contract_id { // Only lower the floor when the asset is held in a virtual channel; a regular channel @@ -3916,6 +3941,7 @@ pub(crate) async fn maker_execute( no_cancel(async move { let guard = state.check_unlocked().await?; let unlocked_state = guard.as_ref().unwrap(); + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; let swapstring = SwapString::from_str(&payload.swapstring) .map_err(|e| APIError::InvalidSwapString(payload.swapstring.clone(), e.to_string()))?; @@ -4137,6 +4163,7 @@ pub(crate) async fn maker_init( no_cancel(async move { let guard = state.check_unlocked().await?; let unlocked_state = guard.as_ref().unwrap(); + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; let from_asset = match &payload.from_asset { None => None, @@ -4324,6 +4351,7 @@ pub(crate) async fn open_channel( no_cancel(async move { let guard = state.check_unlocked().await?; let unlocked_state = guard.as_ref().unwrap(); + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; // Channel persistence is remote-first: without VSS the open would // accept and then stall silently, so refuse it up front. @@ -4862,6 +4890,7 @@ pub(crate) async fn refresh_transfers( no_cancel(async move { let guard = state.check_unlocked().await?; let unlocked_state = guard.as_ref().unwrap(); + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; let unlocked_state_copy = unlocked_state.clone(); let filter = payload.filter.into_iter().map(|f| f.into()).collect(); @@ -4950,6 +4979,7 @@ pub(crate) async fn rgb_invoice( no_cancel(async move { let guard = state.check_unlocked().await?; let unlocked_state = guard.as_ref().unwrap(); + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; let assignment = payload.assignment.unwrap_or(Assignment::Any).into(); @@ -4988,6 +5018,7 @@ pub(crate) async fn send_btc( no_cancel(async move { let guard = state.check_unlocked().await?; let unlocked_state = guard.as_ref().unwrap(); + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; let txid = if unlocked_state.external_signer_mode { let unsigned_psbt = unlocked_state.rgb_send_btc_begin( @@ -5219,6 +5250,8 @@ pub(crate) async fn send_payment( ))) } }; + let _rgb_payment_operation = + unlocked_state.lock_channel_payment(rgb_payment.is_some())?; if let Some((contract_id, asset_amount)) = rgb_payment { if !has_sufficient_asset_channel( @@ -5345,6 +5378,7 @@ pub(crate) async fn send_rgb( no_cancel(async move { let guard = state.check_unlocked().await?; let unlocked_state = guard.as_ref().unwrap(); + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; let recipient_map: HashMap> = payload .recipient_map @@ -5438,6 +5472,7 @@ pub(crate) async fn sync( no_cancel(async move { let guard = state.check_unlocked().await?; let unlocked_state = guard.as_ref().unwrap(); + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; unlocked_state.rgb_sync(payload.options.into())?; @@ -5453,6 +5488,7 @@ pub(crate) async fn taker( no_cancel(async move { let guard = state.check_unlocked().await?; let unlocked_state = guard.as_ref().unwrap(); + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; let swapstring = SwapString::from_str(&payload.swapstring) .map_err(|e| APIError::InvalidSwapString(payload.swapstring.clone(), e.to_string()))?; @@ -5569,6 +5605,7 @@ pub(crate) async fn vss_backup( ) -> Result, APIError> { let guard = state.check_unlocked().await?; let unlocked_state = guard.as_ref().unwrap().clone(); + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; drop(guard); let vss_client = unlocked_state diff --git a/src/sdk/mod.rs b/src/sdk/mod.rs index 1ce0aeec..86419175 100644 --- a/src/sdk/mod.rs +++ b/src/sdk/mod.rs @@ -53,7 +53,7 @@ use lightning::ln::channelmanager::{ use lightning::ln::types::ChannelId; use lightning::offers::offer::{self, Offer}; use lightning::rgb_utils::RgbKvStoreExt; -use lightning::rgb_utils::{RgbInfo, STATIC_BLINDING}; +use lightning::rgb_utils::{is_channel_rgb, RgbInfo, STATIC_BLINDING}; use lightning::routing::gossip::NodeId; use lightning::routing::gossip::RoutingFees; use lightning::routing::router::{ @@ -1077,7 +1077,6 @@ pub(crate) async fn estimate_fee( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; let fee_rate = unlocked_state.rgb_get_fee_estimation(blocks)?; Ok(EstimateFeeData { fee_rate }) } @@ -1098,8 +1097,6 @@ pub(crate) async fn check_proxy_endpoint(proxy_endpoint: String) -> Result<(), A pub(crate) async fn node_info(state: Arc) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; - let chans = unlocked_state.channel_manager.list_channels(); let balances = unlocked_state.chain_monitor.get_claimable_balances(&[]); @@ -1175,7 +1172,7 @@ pub(crate) async fn network_info(state: Arc) -> Result) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; Ok(AddressData { address: unlocked_state.rgb_get_address()?, @@ -1185,7 +1182,7 @@ pub(crate) async fn address(state: Arc) -> Result) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; Ok(AddressData { address: unlocked_state.rgb_rotate_address()?, @@ -1199,7 +1196,7 @@ pub(crate) async fn async_order_new( let guard = check_unlocked(&state).await?; let unlocked_state = Arc::clone(guard.as_ref().unwrap()); drop(guard); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; let host_node_id = hex_str_to_compressed_pubkey(&request.host_node_id).ok_or(APIError::InvalidPubkey)?; @@ -1306,7 +1303,7 @@ pub(crate) async fn async_order_outbound_invoice( let guard = check_unlocked(&state).await?; let unlocked_state = Arc::clone(guard.as_ref().unwrap()); drop(guard); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; let peer_node_id = hex_str_to_compressed_pubkey(&request.client_node_id).ok_or(APIError::InvalidPubkey)?; @@ -1381,7 +1378,11 @@ pub(crate) async fn btc_balance( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; + let _rgb_wallet_operation = if skip_sync { + None + } else { + Some(unlocked_state.lock_rgb_wallet_mutation()?) + }; let btc_balance = unlocked_state.rgb_get_btc_balance(skip_sync)?; Ok(BtcBalanceData { @@ -1580,8 +1581,6 @@ pub(crate) async fn asset_balance( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; - let contract_id = ContractId::from_str(&asset_id).map_err(|_| APIError::InvalidAssetID(asset_id))?; let balance = unlocked_state.rgb_get_asset_balance(contract_id)?; @@ -1618,6 +1617,7 @@ pub(crate) async fn asset_link( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; create_asset_link(unlocked_state, params) } @@ -1629,7 +1629,6 @@ pub(crate) async fn asset_metadata( ContractId::from_str(&asset_id).map_err(|_| APIError::InvalidAssetID(asset_id))?; let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; let metadata = unlocked_state.rgb_get_asset_metadata(contract_id)?; Ok(AssetMetadataData { @@ -1678,8 +1677,6 @@ pub(crate) async fn list_assets( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; - let rgb_assets = unlocked_state.rgb_list_assets(filter_asset_schemas)?; let mut offchain_balances = HashMap::new(); @@ -1766,7 +1763,7 @@ pub(crate) async fn send_rgb( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; let send_result = if unlocked_state.external_signer_mode { let unlocked_state_copy = unlocked_state.clone(); @@ -1912,7 +1909,7 @@ pub(crate) async fn init_with_external_signer( pub(crate) async fn vss_backup(state: Arc) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap().clone(); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; drop(guard); #[cfg(not(feature = "vss"))] @@ -2231,13 +2228,16 @@ pub(crate) async fn close_channel( ) -> Result<(), APIError> { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; let channel_id_vec = hex_str_to_vec(&request.channel_id); if channel_id_vec.is_none() || channel_id_vec.as_ref().unwrap().len() != 32 { return Err(APIError::InvalidChannelID); } let requested_cid = ChannelId(channel_id_vec.unwrap().try_into().unwrap()); + let _rgb_payment_operation = unlocked_state.lock_channel_payment(is_channel_rgb( + &requested_cid, + unlocked_state.kv_store.as_ref(), + ))?; let peer_pubkey_vec = match hex_str_to_vec(&request.peer_pubkey) { Some(peer_pubkey_vec) => peer_pubkey_vec, @@ -2423,7 +2423,7 @@ pub(crate) async fn create_utxos( ) -> Result<(), APIError> { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; let num = request.num.unwrap_or(unlocked_state.config.rgb.utxo_num); let size = request @@ -2472,7 +2472,7 @@ pub(crate) async fn issue_asset_nia( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; if unlocked_state.external_signer_mode { return Err(APIError::UnsupportedInExternalSignerMode( "asset issuance is not supported in external signer mode".to_string(), @@ -2495,7 +2495,7 @@ pub(crate) async fn issue_asset_cfa( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; if unlocked_state.external_signer_mode { return Err(APIError::UnsupportedInExternalSignerMode( "asset issuance is not supported in external signer mode".to_string(), @@ -2527,7 +2527,7 @@ pub(crate) async fn issue_asset_ifa( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; if unlocked_state.external_signer_mode { return Err(APIError::UnsupportedInExternalSignerMode( "asset issuance is not supported in external signer mode".to_string(), @@ -2553,7 +2553,7 @@ pub(crate) async fn issue_asset_uda( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; if unlocked_state.external_signer_mode { return Err(APIError::UnsupportedInExternalSignerMode( "asset issuance is not supported in external signer mode".to_string(), @@ -2592,7 +2592,6 @@ pub(crate) async fn keysend( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; let dest_pubkey_vec = match hex_str_to_vec(&request.dest_pubkey) { Some(peer_pubkey_vec) => peer_pubkey_vec, @@ -2627,6 +2626,7 @@ pub(crate) async fn keysend( return Err(APIError::IncompleteRGBInfo); } }; + let _rgb_payment_operation = unlocked_state.lock_channel_payment(rgb_payment.is_some())?; let route_params = RouteParameters::from_payment_params_and_value( PaymentParameters::for_keysend(dest_pubkey, 40, false), @@ -2701,7 +2701,7 @@ pub(crate) async fn send_btc( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; let txid = if unlocked_state.external_signer_mode { let unsigned_psbt = @@ -2762,7 +2762,7 @@ pub(crate) async fn rgb_invoice( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; let assignment = match request.assignment_kind { Some(kind) => rgb_assignment_from_kind(kind, request.assignment_amount)?, @@ -2806,7 +2806,7 @@ pub(crate) async fn open_channel( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; let is_virtual_open = match request.virtual_open_mode.as_deref() { None => false, @@ -3151,7 +3151,6 @@ pub(crate) async fn send_payment( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; let mut status = HtlcStatus::Pending; let created_at = get_current_timestamp(); @@ -3284,6 +3283,7 @@ pub(crate) async fn send_payment( ))); } }; + let _rgb_payment_operation = unlocked_state.lock_channel_payment(rgb_payment.is_some())?; if let Some((contract_id, asset_amount)) = rgb_payment { if !has_sufficient_asset_channel(unlocked_state, contract_id, asset_amount, amt_msat) { @@ -3393,7 +3393,7 @@ pub(crate) async fn fail_transfers( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; let unlocked_state_copy = unlocked_state.clone(); let transfers_changed = tokio::task::spawn_blocking(move || { @@ -3415,7 +3415,7 @@ pub(crate) async fn refresh_transfers( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; let unlocked_state_copy = unlocked_state.clone(); let refresh_result = tokio::task::spawn_blocking(move || { @@ -3449,7 +3449,7 @@ pub(crate) async fn maker_execute( ) -> Result<(), APIError> { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; let swapstring = SwapString::from_str(&request.swapstring) .map_err(|e| APIError::InvalidSwapString(request.swapstring.clone(), e.to_string()))?; @@ -3648,7 +3648,7 @@ pub(crate) async fn maker_init( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; let from_asset = match &request.from_asset { None => None, @@ -3713,7 +3713,7 @@ pub(crate) async fn maker_init( pub(crate) async fn taker(state: Arc, request: TakerRequestData) -> Result<(), APIError> { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; let swapstring = SwapString::from_str(&request.swapstring) .map_err(|e| APIError::InvalidSwapString(request.swapstring.clone(), e.to_string()))?; @@ -3790,7 +3790,7 @@ pub(crate) async fn send_onion_message( pub(crate) async fn sync(state: Arc) -> Result<(), APIError> { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; unlocked_state.rgb_sync(rgb_lib::wallet::SyncOptions { keychain: rgb_lib::wallet::SyncKeychain::Colored, strategy: rgb_lib::wallet::SyncStrategy::FastSync, @@ -3896,13 +3896,13 @@ pub(crate) async fn create_ln_invoice( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; let contract_id = if let Some(asset_id) = asset_id { Some(ContractId::from_str(&asset_id).map_err(|_| APIError::InvalidAssetID(asset_id))?) } else { None }; + let _rgb_payment_operation = unlocked_state.lock_channel_payment(contract_id.is_some())?; if let Some(contract_id) = &contract_id { // Only lower the floor when the asset is held in a virtual channel; a regular channel @@ -4143,9 +4143,10 @@ pub(crate) async fn cancel_hodl_invoice( ) -> Result<(), APIError> { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; let payment_hash = validate_and_parse_payment_hash(&request.payment_hash)?; + let _rgb_payment_operation = unlocked_state + .lock_channel_payment(unlocked_state.kv_store.is_payment_rgb(&payment_hash))?; let payment_info = unlocked_state .get_inbound_payments() .payments @@ -4173,9 +4174,10 @@ pub(crate) async fn claim_hodl_invoice( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; let payment_hash = validate_and_parse_payment_hash(&request.payment_hash)?; + let _rgb_payment_operation = unlocked_state + .lock_channel_payment(unlocked_state.kv_store.is_payment_rgb(&payment_hash))?; let preimage = validate_and_parse_payment_preimage(&request.payment_preimage, &payment_hash)?; let terminal_error = { @@ -4252,7 +4254,7 @@ pub(crate) async fn inflate( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; if unlocked_state.external_signer_mode { return Err(APIError::UnsupportedInExternalSignerMode( "inflate is not supported in external signer mode".to_string(), @@ -4292,10 +4294,21 @@ fn map_swap( } let current_status: SwapStatus = swap_data.status; if status != current_status { - if taker { - state.update_taker_swap_status(payment_hash, status); - } else { - state.update_maker_swap_status(payment_hash, status); + match state.lock_rgb_wallet_mutation() { + Ok(_rgb_wallet_operation) => { + if taker { + state.update_taker_swap_status(payment_hash, status); + } else { + state.update_maker_swap_status(payment_hash, status); + } + } + Err(error) => { + tracing::debug!( + %error, + %payment_hash, + "returning derived swap status without persisting it" + ); + } } } @@ -4437,7 +4450,11 @@ pub(crate) async fn list_transactions( ) -> Result, APIError> { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; + let _rgb_wallet_operation = if skip_sync { + None + } else { + Some(unlocked_state.lock_rgb_wallet_mutation()?) + }; Ok(unlocked_state .rgb_list_transactions(skip_sync)? @@ -4454,8 +4471,6 @@ pub(crate) async fn list_transfers( ) -> Result, APIError> { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; - if asset_id.is_none() && txid.is_none() { return Err(APIError::InvalidRequest(s!( "either asset_id or txid must be provided" @@ -4478,7 +4493,11 @@ pub(crate) async fn list_unspents( ) -> Result, APIError> { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - let _financial_operation = unlocked_state.ensure_financial_operations_allowed()?; + let _rgb_wallet_operation = if skip_sync { + None + } else { + Some(unlocked_state.lock_rgb_wallet_mutation()?) + }; let mut unspents = vec![]; for unspent in unlocked_state.rgb_list_unspents(false, skip_sync)? { diff --git a/src/test/vss_unreachable_openchannel.rs b/src/test/vss_unreachable_openchannel.rs index e2c8c4cf..c0bf0888 100644 --- a/src/test/vss_unreachable_openchannel.rs +++ b/src/test/vss_unreachable_openchannel.rs @@ -119,7 +119,7 @@ async fn openchannel_refused_while_vss_unreachable_inner() { .await .expect_err("openchannel must be refused while VSS is unreachable"); check_response_is_nok( - res, + *res, reqwest::StatusCode::SERVICE_UNAVAILABLE, "VSS server is unreachable", "VssUnreachable", diff --git a/src/uniffi_api/state.rs b/src/uniffi_api/state.rs index b6f10eaf..30313062 100644 --- a/src/uniffi_api/state.rs +++ b/src/uniffi_api/state.rs @@ -153,6 +153,7 @@ pub(crate) fn map_api_error(err: APIError) -> RlnError { | APIError::NetworkMismatch(_, _) | APIError::DuplicatePayment(_) | APIError::RecipientIDAlreadyUsed + | APIError::RgbFundingRecoveryRequired(_) | APIError::TemporaryChannelIdAlreadyUsed | APIError::UnsupportedLayer1(_) | APIError::UnsupportedTransportType diff --git a/src/utils.rs b/src/utils.rs index c49119a0..c0b50df5 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -226,11 +226,16 @@ pub(crate) struct UnlockedAppState { impl UnlockedAppState { #[allow(dead_code)] - pub(crate) fn ensure_financial_operations_allowed( + pub(crate) fn lock_rgb_wallet_mutation(&self) -> Result { + self.rgb_funding_recovery_guard.lock_rgb_wallet_mutation() + } + + pub(crate) fn lock_channel_payment( &self, - ) -> Result { + carries_rgb: bool, + ) -> Result, APIError> { self.rgb_funding_recovery_guard - .ensure_financial_operations_allowed() + .lock_channel_payment(carries_rgb) } pub(crate) fn attach_apay_signatures( From 7f6708c51e6182e272c3550fada17304e988a793 Mon Sep 17 00:00:00 2001 From: Jainakin Date: Fri, 28 Aug 2026 02:04:09 +0800 Subject: [PATCH 4/4] Keep payment reads pure during funding recovery --- src/ldk.rs | 115 +++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 89 insertions(+), 26 deletions(-) diff --git a/src/ldk.rs b/src/ldk.rs index 8bb0926f..59a21ca4 100644 --- a/src/ldk.rs +++ b/src/ldk.rs @@ -1061,6 +1061,25 @@ fn persist_staged_inbound_payment( Ok(()) } +fn effective_inbound_payment_status( + status: HTLCStatus, + expires_at: Option, + claim_deadline_height: Option, + now: u64, + height: u32, +) -> HTLCStatus { + match status { + HTLCStatus::Pending if expires_at.is_some_and(|expiry| now > expiry) => HTLCStatus::Failed, + HTLCStatus::Claimable + if claim_deadline_height.is_some_and(|deadline| height >= deadline) + || expires_at.is_some_and(|expiry| now >= expiry) => + { + HTLCStatus::Failed + } + _ => status, + } +} + impl UnlockedAppState { pub(crate) fn add_maker_swap(&self, payment_hash: PaymentHash, swap: SwapData) { let mut maker_swaps = self.get_maker_swaps(); @@ -1227,39 +1246,59 @@ impl UnlockedAppState { pub(crate) fn list_updated_inbound_payments(&self) -> LdkHashMap { let now = get_current_timestamp(); let height = self.channel_manager.current_best_block().height; + + let _rgb_wallet_operation = match self.lock_rgb_wallet_mutation() { + Ok(operation) => operation, + Err(error) => { + let mut payments = self.inbound_payments(); + for payment_info in payments.values_mut() { + let effective_status = effective_inbound_payment_status( + payment_info.status, + payment_info.expires_at, + payment_info.claim_deadline_height, + now, + height, + ); + if effective_status != payment_info.status { + payment_info.status = effective_status; + payment_info.updated_at = now; + } + } + tracing::debug!( + %error, + "returning effective inbound payment statuses without persisting them" + ); + return payments; + } + }; + let mut inbound = self.get_inbound_payments(); let mut failed = false; let mut claimables_to_fail = vec![]; for (payment_hash, payment_info) in inbound.payments.iter_mut() { + let effective_status = effective_inbound_payment_status( + payment_info.status, + payment_info.expires_at, + payment_info.claim_deadline_height, + now, + height, + ); + if effective_status == payment_info.status { + continue; + } + match payment_info.status { HTLCStatus::Pending => { - if let Some(expires_at) = payment_info.expires_at { - if now > expires_at { - payment_info.status = HTLCStatus::Failed; - payment_info.updated_at = now; - failed = true; - } - } - } - HTLCStatus::Claimable => { - let deadline_passed = payment_info - .claim_deadline_height - .map(|h| height >= h) - .unwrap_or(false); - let invoice_expired = payment_info - .expires_at - .map(|expires_at| now >= expires_at) - .unwrap_or(false); - - if deadline_passed || invoice_expired { - claimables_to_fail.push(( - *payment_hash, - payment_info.claim_deadline_height, - payment_info.expires_at, - )); - } + payment_info.status = effective_status; + payment_info.updated_at = now; + failed = true; } - _ => {} + HTLCStatus::Claimable => claimables_to_fail.push(( + *payment_hash, + payment_info.claim_deadline_height, + payment_info.expires_at, + )), + _ => unreachable!("only pending and claimable payments can expire"), } } @@ -9837,6 +9876,30 @@ mod tests { } } + #[test] + fn inbound_payment_expiry_projection_preserves_boundary_semantics() { + assert_eq!( + effective_inbound_payment_status(HTLCStatus::Pending, Some(100), None, 100, 10), + HTLCStatus::Pending + ); + assert_eq!( + effective_inbound_payment_status(HTLCStatus::Pending, Some(100), None, 101, 10), + HTLCStatus::Failed + ); + assert_eq!( + effective_inbound_payment_status(HTLCStatus::Claimable, Some(100), None, 100, 10), + HTLCStatus::Failed + ); + assert_eq!( + effective_inbound_payment_status(HTLCStatus::Claimable, None, Some(10), 99, 10), + HTLCStatus::Failed + ); + assert_eq!( + effective_inbound_payment_status(HTLCStatus::Succeeded, Some(1), Some(1), 100, 100), + HTLCStatus::Succeeded + ); + } + #[test] fn rgb_sender_recovery_matrix_is_fail_closed_at_broadcast_boundary() { use RgbSenderFundingStage::*;