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..e1d44661 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,26 @@ 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: 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 + 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,8 +158,15 @@ 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 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/.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..4405dbac 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", @@ -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" @@ -1933,10 +2253,26 @@ dependencies = [ ] [[package]] -name = "fs_extra" -version = "1.3.0" +name = "fs2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "fs_extra" +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" @@ -2150,16 +2486,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 +2514,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 +2526,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 +2555,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 +2627,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 +2648,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 +2711,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 +2944,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 +2970,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 +3144,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 +3471,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 +3496,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 +3709,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 +3924,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 +3946,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 +4016,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 +4075,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 +4093,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 +4180,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 +4343,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 +4441,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 +4641,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 +4747,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 +4768,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 +4799,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 +4824,6 @@ dependencies = [ "fluent-uri", "indexmap", "percent-encoding", - "rand 0.9.5", "rgb-consensus", "rgb-strict-encoding", "rgb-strict-types", @@ -4270,19 +4832,20 @@ 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/Jainakin/rgb-lib.git?rev=22c76737894db67caa2b0743e4c258ba8c2422f0#22c76737894db67caa2b0743e4c258ba8c2422f0" dependencies = [ "amplify", "base64 0.22.1", "bdk_electrum", "bdk_esplora", "bdk_wallet", - "chacha20poly1305", + "chacha20poly1305 0.11.0", "file-format", - "generic-array", + "fs2", "hex", - "hkdf", + "hkdf 0.12.4", + "nonasync", "rand 0.10.2", "reqwest 0.13.4", "rgb-invoicing", @@ -4293,9 +4856,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 +4869,6 @@ dependencies = [ "thiserror 2.0.19", "time", "tokio", - "typenum", "url", "vss-client-ng", "walkdir", @@ -4315,10 +4877,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/Jainakin/rgb-lib.git?rev=22c76737894db67caa2b0743e4c258ba8c2422f0#22c76737894db67caa2b0743e4c258ba8c2422f0" dependencies = [ - "sea-orm-migration", + "sea-orm-migration 2.0.2", "tokio", ] @@ -4337,12 +4899,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 +4921,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 +4932,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 +4950,6 @@ dependencies = [ "tracing-appender", "tracing-subscriber", "tracing-test", - "typenum", "uniffi", "uuid", "vls-core", @@ -4404,18 +4964,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 +4989,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 +5003,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 +5015,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 +5041,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 +5053,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "sha2 0.10.9", + "sha2 0.11.0", "toml 0.8.23", "wasm-bindgen", ] @@ -4517,18 +5074,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 +5124,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 +5145,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 +5328,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 +5391,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 +5437,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 +5515,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 +5557,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 +5582,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 +5614,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 +5640,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 +5658,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 +5689,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 +5720,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" @@ -4999,6 +5763,29 @@ dependencies = [ "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 +5804,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 +6040,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 +6075,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" @@ -5297,14 +6124,14 @@ 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", "chacha20 0.9.1", - "chacha20poly1305", + "chacha20poly1305 0.10.1", "hex", - "poly1305", + "poly1305 0.8.0", "serde", "serde_json", "thiserror 2.0.19", @@ -5440,11 +6267,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 +6305,7 @@ dependencies = [ "futures-io", "futures-util", "hashbrown 0.15.5", - "hashlink", + "hashlink 0.10.0", "indexmap", "log", "memchr", @@ -5484,6 +6324,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 +6372,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 +6406,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 +6462,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 +6526,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 +6544,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 +6598,7 @@ checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ "atoi", "chrono", - "flume", + "flume 0.11.1", "futures-channel", "futures-core", "futures-executor", @@ -5622,10 +6609,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 +6687,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 +6785,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 +6871,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 +6901,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 +7036,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime", - "toml_edit", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] @@ -6023,6 +7049,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 +7067,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 +7262,7 @@ dependencies = [ "httparse", "log", "rand 0.8.7", - "sha1", + "sha1 0.10.7", "thiserror 1.0.69", "utf-8", ] @@ -6409,10 +7465,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 +7566,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 +7892,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" @@ -6848,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]] @@ -7093,6 +8165,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 +8186,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..a9443ce0 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,32 +77,30 @@ 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/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 } @@ -97,7 +124,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 +148,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..391959c3 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 @@ -68,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 { @@ -123,10 +136,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(), @@ -183,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 @@ -197,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") @@ -211,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) @@ -226,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 @@ -243,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)") @@ -266,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) { @@ -288,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 @@ -308,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) { @@ -331,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 @@ -380,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) @@ -787,8 +875,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 +887,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..2f70235e 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" @@ -1902,11 +2222,27 @@ dependencies = [ ] [[package]] -name = "fs_extra" -version = "1.3.0" +name = "fs2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "fs_extra" +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 +2455,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 +2483,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 +2495,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 +2524,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 +2596,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 +2617,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 +2680,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 +2912,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 +2938,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 +3113,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 +3442,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 +3467,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 +3680,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 +3895,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 +3917,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 +3977,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 +4036,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 +4054,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 +4141,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 +4304,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 +4402,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 +4589,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 +4686,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 +4707,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 +4738,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 +4763,6 @@ dependencies = [ "fluent-uri", "indexmap", "percent-encoding", - "rand 0.9.5", "rgb-consensus", "rgb-strict-encoding", "rgb-strict-types", @@ -4209,19 +4771,20 @@ 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/Jainakin/rgb-lib.git?rev=22c76737894db67caa2b0743e4c258ba8c2422f0#22c76737894db67caa2b0743e4c258ba8c2422f0" dependencies = [ "amplify", "base64 0.22.1", "bdk_electrum", "bdk_esplora", "bdk_wallet", - "chacha20poly1305", + "chacha20poly1305 0.11.0", "file-format", - "generic-array", + "fs2", "hex", - "hkdf", + "hkdf 0.12.4", + "nonasync", "rand 0.10.2", "reqwest 0.13.4", "rgb-invoicing", @@ -4232,9 +4795,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 +4808,6 @@ dependencies = [ "thiserror 2.0.19", "time", "tokio", - "typenum", "url", "vss-client-ng", "walkdir", @@ -4254,10 +4816,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/Jainakin/rgb-lib.git?rev=22c76737894db67caa2b0743e4c258ba8c2422f0#22c76737894db67caa2b0743e4c258ba8c2422f0" dependencies = [ - "sea-orm-migration", + "sea-orm-migration 2.0.2", "tokio", ] @@ -4276,11 +4838,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 +4857,6 @@ dependencies = [ "lightning-persister", "lightning-rapid-gossip-sync", "lightning-transaction-sync", - "magic-crypt", "prost 0.13.5", "rand 0.8.7", "regex", @@ -4304,8 +4865,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 +4880,6 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", - "typenum", "uniffi", "uuid", "vls-core", @@ -4334,18 +4894,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 +4919,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 +4933,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 +4945,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 +4971,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 +4983,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "sha2 0.10.9", + "sha2 0.11.0", "toml 0.8.23", "wasm-bindgen", ] @@ -4447,11 +5004,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 +5058,7 @@ dependencies = [ name = "rln-migration" version = "0.1.0" dependencies = [ - "sea-orm-migration", + "sea-orm-migration 1.1.20", "tokio", ] @@ -4482,7 +5068,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 +5089,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 +5263,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 +5326,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" @@ -4758,30 +5372,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]] @@ -4795,8 +5450,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", @@ -4817,6 +5492,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 +5517,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 +5549,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 +5575,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 +5593,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 +5624,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 +5655,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" @@ -4914,13 +5678,36 @@ dependencies = [ [[package]] name = "secp256k1" -version = "0.29.1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "bitcoin_hashes", + "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", + "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 = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +checksum = "3c5fdc7d6e800869d3fd60ff857c479bf0a83ea7bf44b389e64461e844204994" dependencies = [ - "bitcoin_hashes", - "rand 0.8.7", - "secp256k1-sys", + "rand 0.9.5", + "secp256k1-sys 0.12.0", "serde", ] @@ -4933,6 +5720,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 +5940,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 +5975,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" @@ -5197,14 +6024,14 @@ 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", "chacha20 0.9.1", - "chacha20poly1305", + "chacha20poly1305 0.10.1", "hex", - "poly1305", + "poly1305 0.8.0", "serde", "serde_json", "thiserror 2.0.19", @@ -5340,11 +6167,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 +6205,7 @@ dependencies = [ "futures-io", "futures-util", "hashbrown 0.15.5", - "hashlink", + "hashlink 0.10.0", "indexmap", "log", "memchr", @@ -5384,6 +6224,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 +6272,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 +6306,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 +6362,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 +6426,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 +6444,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 +6498,7 @@ checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ "atoi", "chrono", - "flume", + "flume 0.11.1", "futures-channel", "futures-core", "futures-executor", @@ -5522,10 +6509,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 +6587,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 +6685,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 +6771,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 +6801,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 +6925,7 @@ dependencies = [ "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", - "toml_edit", + "toml_edit 0.22.27", ] [[package]] @@ -5935,6 +6961,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 +6984,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 +7344,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 +7439,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 +7734,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" @@ -6699,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]] @@ -6949,6 +8012,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 +8028,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..32b8c6b6 160000 --- a/rust-lightning +++ b/rust-lightning @@ -1 +1 @@ -Subproject commit 8b59f28041a3363f6a86464e4ee4495472257250 +Subproject commit 32b8c6b6046c3bfac44ee94549cadf9916e234ae 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..7d1cd612 100644 --- a/src/async_order.rs +++ b/src/async_order.rs @@ -135,7 +135,9 @@ 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, 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/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..ed7f40d1 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), @@ -347,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), @@ -393,16 +415,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 +455,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), @@ -451,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, @@ -526,7 +555,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 +572,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 +590,7 @@ impl IntoResponse for APIError { | APIError::InvalidBackupPath | APIError::InvalidBiscuitToken | APIError::InvalidChannelID + | APIError::InvalidConsignment | APIError::InvalidContractLink(_) | APIError::InvalidRightOutpoint(_) | APIError::InvalidDescription(_) @@ -601,7 +637,6 @@ impl IntoResponse for APIError { APIError::AllocationsAlreadyAvailable | APIError::AlreadyInitialized | APIError::AlreadyUnlocked - | APIError::AmbiguousChainBackend | APIError::AuthenticationDisabled | APIError::BatchTransferNotFound | APIError::CannotCloseChannel(_) @@ -612,27 +647,25 @@ 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 | APIError::PaymentNotFound(_) | APIError::RecipientIDAlreadyUsed + | APIError::RgbFundingRecoveryRequired(_) | APIError::SwapNotFound(_) | APIError::TemporaryChannelIdAlreadyUsed | APIError::UnknownChannelId @@ -647,6 +680,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..59a21ca4 100644 --- a/src/ldk.rs +++ b/src/ldk.rs @@ -16,12 +16,14 @@ 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; +#[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; @@ -38,17 +40,23 @@ 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, 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; 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 +82,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,14 +102,14 @@ 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, }; -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}; @@ -113,25 +117,29 @@ 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}; +#[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; 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"; @@ -140,8 +148,10 @@ 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"; +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; /// 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"; @@ -149,6 +159,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"; @@ -157,10 +169,531 @@ 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 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 +/// 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 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(()); + } + 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 + .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}; +#[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, + RgbChangeDestinationSource, RgbLibWalletWrapper, +}; +use crate::rgb_file_transfer::{ + PeerChannelGate, RgbFileTransferHandler, REASSEMBLY_SWEEP_INTERVAL, }; use crate::signer::vls_adapter::{ExternalSignerBackend, VlsSignerAdapter}; use crate::signer::{ @@ -176,13 +709,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 +812,80 @@ 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) +} + +#[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, @@ -439,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(); @@ -605,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"), } } @@ -1045,8 +1706,8 @@ pub(crate) type MonitorPersister = AsyncPersister< Arc, ActiveSignerRef, ActiveSignerRef, - Arc, - Arc, + Arc, + Arc, >; #[cfg(not(feature = "vss"))] @@ -1056,27 +1717,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 +1759,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 +1771,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 +1801,7 @@ pub(crate) type GossipSync = lightning_background_processor::GossipSync< Arc, Arc, Arc, - Arc, + Arc, Arc, >; @@ -1150,13 +1820,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 +1836,10 @@ 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>, + rgb_funding_recovery_guard: Arc, } // The sweeper store type is shared with the background processor's persister @@ -1175,9 +1850,9 @@ pub(crate) type BpKvStore = Arc; pub(crate) type BpKvStore = KVStoreSyncWrapper>; pub(crate) type OutputSweeper = ldk_sweep::OutputSweeper< - Arc, - Arc, - Arc, + Arc, + Arc, + Arc, Arc, BpKvStore, Arc, @@ -1743,59 +2418,1354 @@ 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); } - 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:?}" - ); + loop { + std::thread::park(); + } + } +} + +fn rgb_sender_funding_error(context: &str, error: impl std::fmt::Display) -> RgbLibError { + RgbLibError::Internal { + details: format!("{context}: {error}"), + } +} + +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(()) + Ok(Some(record)) } + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(rgb_sender_funding_error( + "cannot read sender funding journal", + error, + )), } } -/// 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) = +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" + ), + }); + } + Ok(Some(status)) +} + +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 = tokio::task::spawn_blocking(move || { + let failed = match 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:?}" - ); + { + 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) = @@ -1803,21 +3773,44 @@ async fn handle_open_chan_fail(channel_id: &ChannelId, unlocked_state: Arc 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 = tokio::task::spawn_blocking(move || { + let result = match tokio::task::spawn_blocking(move || { unlocked_state_copy.rgb_abort_pending_vanilla_tx(txid_copy) }) .await - .unwrap(); + { + 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:?}" - ), + Err(e) => { + tracing::error!( + "Error aborting pending vanilla tx {funding_txid} for channel {channel_id}: {e:?}" + ); + return; + } } } let _ = unlocked_state @@ -1831,15 +3824,45 @@ async fn handle_open_chan_fail(channel_id: &ChannelId, 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, @@ -1847,27 +3870,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 { @@ -1882,23 +3899,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( @@ -1916,6 +3936,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(), @@ -1997,41 +4027,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 +4153,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); } @@ -2182,7 +4296,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(), @@ -2208,34 +4322,146 @@ 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; 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, 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), )?; - 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 @@ -2266,7 +4492,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 @@ -2299,8 +4525,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 @@ -2324,13 +4552,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()); } }; @@ -2340,108 +4579,465 @@ 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 = 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(); - // 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, @@ -2459,6 +5055,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 +5405,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, @@ -3071,13 +5692,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() @@ -3087,10 +5714,86 @@ 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(()); } 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 @@ -3098,7 +5801,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(); @@ -3107,67 +5817,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 { - state_copy.rgb_send_end(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() + })?; - // 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( + let _txid = finalize_result.map_err(|error| { + tracing::error!("Error completing channel opening: {error:?}"); + ReplayEvent() + })?; + } + + // 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); + .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()); + } + } - match unlocked_state.rgb_save_new_asset(consignment, funding_txid) { - Ok(_) => {} - Err(e) if e.to_string().contains("UNIQUE constraint failed") => {} - Err(e) => panic!("Failed saving asset: {e}"), + 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 { @@ -3183,12 +6023,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, @@ -3196,7 +6062,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,8 +6074,24 @@ 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; + 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 @@ -3454,6 +6336,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) @@ -3492,8 +6378,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 +6404,18 @@ impl OutputSpender for RgbOutputSpender { feerate_sat_per_1000_weight: u32, locktime: Option, secp_ctx: &Secp256k1, - ) -> Result { + ) -> Result { + let _rgb_wallet_operation = self + .rgb_funding_recovery_guard + .lock_rgb_wallet_mutation() + .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(); - 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 +6436,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 +6461,18 @@ 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 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()]) - .map_err(|e| { - tracing::error!(error = %e, txid = txid_str, "update_witnesses failed; deferring sweep"); - })?; + .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(()); + return Err(format!( + "failed to update witnesses for {txid_str}: {update_res:?}" + )); } let contract_id = transfer_info.contract_id; @@ -3572,36 +6482,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 +6547,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 +6570,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,85 +6589,106 @@ 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()) + .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| { - 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"); + 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 .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, 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 { 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,83 +6955,6 @@ 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, - }, -} - -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]; @@ -4087,27 +6964,26 @@ fn supported_asset_schemas(bitcoin_network: BitcoinNetwork) -> Vec 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 +6998,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,25 +7012,67 @@ 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() { + fn exits_with_code_70_on_bp_panic() { + 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 { panic!("test panic") }, + stop, + cancel.clone(), + )); + // 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([ + "--exact", + "ldk::watchdog_tests::exits_with_code_70_on_bp_panic", + ]) + .env("BP_WATCHDOG_CHILD", "1") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .unwrap(); + 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 { panic!("test panic") }, + async { Ok(()) }, stop, + cancel.clone(), )); - std::process::exit(0); + 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_bp_panic", + "ldk::watchdog_tests::exits_with_code_70_on_unexpected_bp_return", ]) .env("BP_WATCHDOG_CHILD", "1") .stdout(std::process::Stdio::null()) @@ -4166,11 +7089,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 +7129,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 +7203,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, + let accept_res = wrapper.accept_transfer_consignment( + consignment_path.clone(), txid_copy.clone(), - &consignment_path, - 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 +7288,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 +7307,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 +7507,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 +7589,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 +7711,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 +7720,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 +7742,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 +7813,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 +7858,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, @@ -4956,6 +7894,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() @@ -5087,43 +8026,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(), @@ -5159,14 +8105,13 @@ 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, - 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 +8128,11 @@ 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())), + 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))] let (sweeper_best_block, output_sweeper) = match kv_store.read( OUTPUT_SWEEPER_PERSISTENCE_PRIMARY_NAMESPACE, OUTPUT_SWEEPER_PERSISTENCE_SECONDARY_NAMESPACE, @@ -5195,9 +8143,9 @@ 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(), + Arc::clone(&rgb_change_destination_source), Clone::clone(&bp_kv_store), logger.clone(), ); @@ -5207,9 +8155,9 @@ 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(), + Arc::clone(&rgb_change_destination_source), Clone::clone(&bp_kv_store), logger.clone(), ); @@ -5221,10 +8169,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 +8192,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 +8219,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 +8240,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 { @@ -5347,6 +8305,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::>(), + "RGB wallet mutations are quarantined pending funding recovery" + ); + } + } let resolver = "8.8.8.8:53".to_socket_addrs().unwrap().next().unwrap(); let domain_resolver = Arc::new(OMDomainResolver::new( resolver, @@ -5384,9 +8402,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 +8455,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 +8514,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 +8778,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, @@ -5768,6 +8793,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 { @@ -5845,6 +8871,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 +8889,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 +8907,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 +9036,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 +9122,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 +9142,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 @@ -6122,9 +9155,116 @@ impl AppState { } #[cfg(feature = "vss")] -const BP_SHUTDOWN_FLUSH_TIMEOUT: Duration = Duration::from_secs(30); +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 +} -#[cfg(feature = "vss")] +// 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 +9275,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 +9363,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 +9391,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 +9451,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}; @@ -6342,14 +9537,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( @@ -6364,6 +9630,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 +9842,561 @@ 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 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::*; + 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; + 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 + )); + } + + #[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.lock_rgb_wallet_mutation(), + 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.lock_rgb_wallet_mutation(), + Err(APIError::RgbFundingRecoveryRequired(ref txids)) + if txids == &format!("{first},{second}") + )); + + guard.clear(&first); + assert!(guard.lock_rgb_wallet_mutation().is_err()); + guard.clear(&second); + assert!(guard.lock_rgb_wallet_mutation().is_ok()); + } + + #[test] + fn rgb_wallet_mutation_admission_holds_an_exclusive_lease() { + let guard = RgbFundingRecoveryGuard::default(); + + let operation = guard.lock_rgb_wallet_mutation().unwrap(); + assert!(matches!( + guard.lock_rgb_wallet_mutation(), + Err(APIError::ChangingState) + )); + + drop(operation); + 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] + 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/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..6f1f0a39 100644 --- a/src/rgb.rs +++ b/src/rgb.rs @@ -17,22 +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, 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, 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, - RgbTransport, RgbTxid, UpdateRes, WitnessOrd, + AssetSchema, Assignment, BitcoinNetwork, ConsignmentExt, ContractId, Error as RgbLibError, + Fascia, RgbTransfer, 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}; -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( @@ -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 { @@ -150,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) } @@ -364,6 +382,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 +413,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( @@ -414,22 +439,13 @@ 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>, donation: bool, fee_rate: u64, min_confirmations: u8, - expiration_timestamp: Option, + expiration_timestamp: u64, ) -> Result { self.rgb_wallet_wrapper.send( recipient_map, @@ -447,7 +463,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 +507,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())? @@ -525,11 +550,21 @@ 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, assignment: Assignment, - expiration_timestamp: Option, + expiration_timestamp: u64, transport_endpoints: Vec, min_confirmations: u8, ) -> Result { @@ -575,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 @@ -584,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> { @@ -598,7 +674,7 @@ impl RgbLibWalletWrapper { &self, asset_id: Option, assignment: Assignment, - expiration_timestamp: Option, + expiration_timestamp: u64, transport_endpoints: Vec, min_confirmations: u8, ) -> Result { @@ -619,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, @@ -740,7 +964,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 +1073,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 +1103,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, ) } @@ -918,13 +1149,15 @@ 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, 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 +1166,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 +1186,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 +1241,35 @@ 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 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) } @@ -1022,7 +1284,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( @@ -1034,11 +1296,21 @@ 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, assignment: Assignment, - expiration_timestamp: Option, + expiration_timestamp: u64, transport_endpoints: Vec, min_confirmations: u8, ) -> Result { @@ -1061,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() @@ -1123,13 +1401,23 @@ 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 { - Ok(Address::from_str(&self.get_address().unwrap()) - .unwrap() - .assume_checked() - .script_pubkey()) + let _rgb_wallet_operation = self + .funding_guard + .lock_output_sweeper_wallet_mutation() + .await + .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 address = Address::from_str(&address).map_err(|error| { + tracing::error!(%error, "wallet returned an invalid change address"); + })?; + Ok(address.assume_checked().script_pubkey()) }) } } @@ -1148,7 +1436,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 +1504,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..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::{ @@ -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,11 +1713,32 @@ 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> { 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()?; @@ -1609,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()?; @@ -1622,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)?; @@ -1731,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)?; @@ -1846,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)) @@ -1910,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)?; @@ -1937,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 @@ -2014,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)?; @@ -2102,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, @@ -2308,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 @@ -2394,6 +2553,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, })) } @@ -2480,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 || { @@ -2535,6 +2696,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>, @@ -2613,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>, @@ -2622,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), })); } } @@ -2678,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(), @@ -2728,6 +2940,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 })) }) @@ -2831,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(), @@ -2867,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(), @@ -2897,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(), @@ -2924,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(), @@ -2998,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), @@ -3362,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(), })) } @@ -3413,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)? { @@ -3475,16 +3668,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 +3682,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 { @@ -3565,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)? { @@ -3573,6 +3759,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 @@ -3628,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 @@ -3654,7 +3843,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 +3909,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 +3928,6 @@ pub(crate) async fn lock( state.update_ldk_background_services(None); - state.update_changing_state(false); - tracing::info!("Lock completed"); Ok(Json(EmptyResponse {})) }) @@ -3754,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()))?; @@ -3975,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, @@ -4162,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. @@ -4365,7 +4555,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 +4564,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 +4602,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 +4611,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 +4660,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 +4683,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 +4775,137 @@ 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 _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(); - 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 +4924,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)?; - // restore_backup overwrote the SQLite file under the pre-restore pool; + // 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?; + + 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 @@ -4661,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(); @@ -4669,7 +4988,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 +4996,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, )? }; @@ -4699,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( @@ -4930,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( @@ -5056,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 @@ -5149,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())?; @@ -5164,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()))?; @@ -5236,10 +5561,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 +5573,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? @@ -5286,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 @@ -5398,14 +5718,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 +5769,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 +5876,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 +5913,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 +5936,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 +6049,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..86419175 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, }; @@ -52,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::{ @@ -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, @@ -628,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, @@ -677,6 +692,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 +788,7 @@ pub(crate) enum TransferStatus { WaitingCounterparty, WaitingSafeHeight, WaitingConfirmations, + WaitingBroadcast, Settled, Failed, } @@ -1058,11 +1075,9 @@ 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 fee_rate = unlocked_state.rgb_get_fee_estimation(blocks)?; Ok(EstimateFeeData { fee_rate }) } @@ -1082,7 +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 chans = unlocked_state.channel_manager.list_channels(); let balances = unlocked_state.chain_monitor.get_claimable_balances(&[]); @@ -1158,6 +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 _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; Ok(AddressData { address: unlocked_state.rgb_get_address()?, @@ -1167,6 +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 _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; Ok(AddressData { address: unlocked_state.rgb_rotate_address()?, @@ -1180,6 +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 _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)?; @@ -1286,6 +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 _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)?; @@ -1360,6 +1378,11 @@ pub(crate) async fn btc_balance( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + 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 { @@ -1479,6 +1502,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 contract_id = ContractId::from_str(&asset_id).map_err(|_| APIError::InvalidAssetID(asset_id))?; let balance = unlocked_state.rgb_get_asset_balance(contract_id)?; @@ -1594,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) } @@ -1603,11 +1627,9 @@ 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 metadata = unlocked_state.rgb_get_asset_metadata(contract_id)?; Ok(AssetMetadataData { asset_schema: metadata.asset_schema, @@ -1655,7 +1677,6 @@ pub(crate) async fn list_assets( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - let rgb_assets = unlocked_state.rgb_list_assets(filter_asset_schemas)?; let mut offchain_balances = HashMap::new(); @@ -1742,6 +1763,7 @@ pub(crate) async fn send_rgb( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + 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(); @@ -1751,7 +1773,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 +1797,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()? @@ -1881,6 +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 _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; drop(guard); #[cfg(not(feature = "vss"))] @@ -2008,10 +2037,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 +2134,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, @@ -2211,6 +2234,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(&request.peer_pubkey) { Some(peer_pubkey_vec) => peer_pubkey_vec, @@ -2396,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 _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; let num = request.num.unwrap_or(unlocked_state.config.rgb.utxo_num); let size = request @@ -2444,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 _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(), @@ -2466,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 _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(), @@ -2497,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 _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(), @@ -2522,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 _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(), @@ -2594,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), @@ -2668,6 +2701,7 @@ pub(crate) async fn send_btc( ) -> Result { let guard = check_unlocked(&state).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 = @@ -2728,15 +2762,18 @@ pub(crate) async fn rgb_invoice( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + 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)?, 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 +2795,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, }) } @@ -2769,6 +2806,7 @@ pub(crate) async fn open_channel( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; let is_virtual_open = match request.virtual_open_mode.as_deref() { None => false, @@ -2958,13 +2996,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 +3043,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 +3082,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 +3104,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| { @@ -3245,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) { @@ -3354,6 +3393,7 @@ pub(crate) async fn fail_transfers( ) -> Result { let guard = check_unlocked(&state).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 || { @@ -3372,17 +3412,35 @@ 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 _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; 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( @@ -3391,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 _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()))?; @@ -3589,6 +3648,7 @@ pub(crate) async fn maker_init( ) -> Result { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + let _rgb_wallet_operation = unlocked_state.lock_rgb_wallet_mutation()?; let from_asset = match &request.from_asset { None => None, @@ -3653,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 _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()))?; @@ -3729,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 _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, @@ -3750,6 +3812,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()), @@ -3838,6 +3902,7 @@ pub(crate) async fn create_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 @@ -3866,7 +3931,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, @@ -4080,6 +4145,8 @@ pub(crate) async fn cancel_hodl_invoice( let unlocked_state = guard.as_ref().unwrap(); 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 @@ -4109,6 +4176,8 @@ pub(crate) async fn claim_hodl_invoice( let unlocked_state = guard.as_ref().unwrap(); 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 = { @@ -4185,6 +4254,7 @@ pub(crate) async fn inflate( ) -> Result { let guard = check_unlocked(&state).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(), @@ -4224,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" + ); + } } } @@ -4313,19 +4394,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 { @@ -4364,6 +4450,11 @@ pub(crate) async fn list_transactions( ) -> Result, APIError> { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + let _rgb_wallet_operation = if skip_sync { + None + } else { + Some(unlocked_state.lock_rgb_wallet_mutation()?) + }; Ok(unlocked_state .rgb_list_transactions(skip_sync)? @@ -4380,7 +4471,6 @@ pub(crate) async fn list_transfers( ) -> Result, APIError> { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); - if asset_id.is_none() && txid.is_none() { return Err(APIError::InvalidRequest(s!( "either asset_id or txid must be provided" @@ -4388,7 +4478,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)? @@ -4403,6 +4493,11 @@ pub(crate) async fn list_unspents( ) -> Result, APIError> { let guard = check_unlocked(&state).await?; let unlocked_state = guard.as_ref().unwrap(); + 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)? { @@ -4411,6 +4506,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 +4631,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 +4669,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..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. @@ -244,7 +265,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")] @@ -305,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 @@ -532,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 { @@ -549,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(); @@ -568,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"); @@ -592,7 +709,7 @@ impl SyncedKvStore { secondary_namespace, key, error = %error, - remote_required, + ?durability, "VSS replication write failed; durable retry intent retained" ); (false, Some(error)) @@ -603,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); } @@ -611,6 +728,7 @@ impl SyncedKvStore { return Ok(()); } + let _ = require_remote; self.local .write(primary_namespace, secondary_namespace, key, buf) } @@ -634,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( @@ -654,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(); @@ -673,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"); @@ -697,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)) @@ -708,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); } @@ -729,3 +856,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/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/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/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/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..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, SdkNode, + 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}; @@ -332,10 +334,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: "127.0.0.1".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![], @@ -391,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( @@ -409,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; } @@ -450,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; @@ -493,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() @@ -530,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; @@ -564,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; @@ -602,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; @@ -627,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; } @@ -649,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; } @@ -675,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; } @@ -701,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; } @@ -729,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; } @@ -749,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}" @@ -770,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) @@ -791,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)); } @@ -893,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 { @@ -908,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( @@ -926,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) @@ -951,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/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/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/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..ff3ab720 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -1,20 +1,36 @@ +#[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, + 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; 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 +43,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 +76,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 +100,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 +132,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 +176,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 +299,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 +350,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 +462,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 +936,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 +1143,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 +1353,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 +1362,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 +1450,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 +1461,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 +1701,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 +1725,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 +1747,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 +1779,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 +2196,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 +2363,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, @@ -2131,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}" @@ -2178,12 +2426,13 @@ 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()) } +#[allow(clippy::result_large_err)] #[allow(clippy::too_many_arguments)] async fn open_channel_funded_raw( node_address: SocketAddr, @@ -2199,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, @@ -2249,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(); @@ -2368,24 +2617,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 +2729,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 +2824,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 +2833,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 +2846,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 +2906,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 +2918,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 +2994,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 +3016,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!("127.0.0.1"), + 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 +3049,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 +3088,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 +3319,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 +3360,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 +3381,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 +3465,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,13 +3473,20 @@ 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 funding_crash_recovery; +#[cfg(debug_assertions)] +mod funding_crash_sender; mod getchannelid; mod gossip_p2p; mod gossip_rgs; @@ -3094,27 +3495,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 +3543,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 +3558,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_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/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_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/vss_unreachable_openchannel.rs b/src/test/vss_unreachable_openchannel.rs index 85e159f9..c0bf0888 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![], @@ -120,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/test_utils.rs b/src/test_utils.rs index 40da334e..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 { @@ -31,6 +34,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)), @@ -63,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/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..73f6e003 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(), @@ -387,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)?; @@ -425,10 +444,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 +890,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 +1386,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 +1457,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 +1600,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 +1611,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 +1647,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 +1655,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..30313062 100644 --- a/src/uniffi_api/state.rs +++ b/src/uniffi_api/state.rs @@ -147,13 +147,13 @@ pub(crate) fn map_api_error(err: APIError) -> RlnError { | APIError::ChangingState | APIError::InsufficientAssets | APIError::InvalidIndexer(_) - | APIError::InvalidProxyEndpoint | APIError::InvalidProxyProtocol(_) | APIError::MaxFeeExceeded(_) | APIError::MinFeeNotMet(_) | APIError::NetworkMismatch(_, _) | APIError::DuplicatePayment(_) | APIError::RecipientIDAlreadyUsed + | APIError::RgbFundingRecoveryRequired(_) | APIError::TemporaryChannelIdAlreadyUsed | APIError::UnsupportedLayer1(_) | APIError::UnsupportedTransportType 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..c0b50df5 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,13 @@ use tokio_util::sync::CancellationToken; use crate::asset_link::AssetLinkMessageHandler; use crate::async_order::{AsyncOrderMessageHandler, AsyncPaymentsPreimageRoot}; -use crate::ldk::{ChannelIdsMap, Router, VirtualChannelDraftStore, VirtualChannelSessionStore}; +use crate::crypto::{decrypt_mnemonic, encrypt_mnemonic}; +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::{ read_key_source_file, ActiveSignerRef, ExternalSigner, ExternalSignerAttachment, RlnEntropySource, @@ -58,16 +62,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 +107,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 +177,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 +206,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>, @@ -196,9 +221,23 @@ 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 lock_rgb_wallet_mutation(&self) -> Result { + self.rgb_funding_recovery_guard.lock_rgb_wallet_mutation() + } + + pub(crate) fn lock_channel_payment( + &self, + carries_rgb: bool, + ) -> Result, APIError> { + self.rgb_funding_recovery_guard + .lock_channel_payment(carries_rgb) + } + pub(crate) fn attach_apay_signatures( &self, mut params: crate::async_order::AsyncOrderNewParamsWire, @@ -452,15 +491,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 +564,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 +668,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 +678,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 +772,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 +904,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 +979,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 +1038,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/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 2fee44b9..1b59f9f5 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(), @@ -225,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 @@ -356,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") 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: [],