From 05716d2b8d354282232884ebe7655eda1b2fcb5d Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Fri, 7 Aug 2026 01:48:52 +0200 Subject: [PATCH] feat: add WatchtowerStateExport endpoint for watchtower clients Adds an opt-in endpoint (config: [watchtower] export_enabled, default false) that exports per-channel watchtower state for external clients such as a rust-teos (Eye of Satoshi) tower client: funding outpoint, channel/user IDs, to_self_delay, and the latest counterparty commitment txids + commitment numbers (the watchtower locator source), via the new ldk-node watchtower APIs. Justice transaction assembly (to_local output detection, unsigned skeleton with conservative fee, signing via Node::sign_to_local_justice_tx) is marked TODO(justice-tx) and returns signed=false with empty payload until wired. Includes the endpoint handler, proto messages, gRPC routing, client method, CLI command, and a unit test for the disabled-by-default error path. Note: Cargo.toml carries a temporary [patch] for ldk-node pointing at vincenzopalazzo/ldk-node watchtower-apis-16eaa6f (backport of lightningdevkit/ldk-node#1031 onto the pinned rev); to be dropped once the upstream API lands in a release. --- Cargo.lock | 171 +++++++------- Cargo.toml | 4 + ldk-server-cli/src/main.rs | 50 ++++ ldk-server-client/src/client.rs | 11 +- ldk-server-grpc/src/api.rs | 29 +++ ldk-server-grpc/src/endpoints.rs | 1 + ldk-server-grpc/src/proto/api.proto | 21 ++ ldk-server-grpc/src/proto/types.proto | 53 +++++ ldk-server-grpc/src/types.rs | 63 +++++ ldk-server/src/api/error.rs | 1 + ldk-server/src/api/mod.rs | 1 + ldk-server/src/api/watchtower_state_export.rs | 217 ++++++++++++++++++ ldk-server/src/main.rs | 12 +- ldk-server/src/service.rs | 14 +- ldk-server/src/util/config.rs | 28 +++ ldk-server/src/util/mod.rs | 3 +- 16 files changed, 585 insertions(+), 94 deletions(-) create mode 100644 ldk-server/src/api/watchtower_state_export.rs diff --git a/Cargo.lock b/Cargo.lock index 6a2945d8..61d230e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -125,7 +125,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" dependencies = [ "bitcoin-internals 0.3.0", - "bitcoin_hashes 0.14.0", + "bitcoin_hashes", ] [[package]] @@ -142,9 +142,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bdk_chain" -version = "0.23.2" +version = "0.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b5d691fd092aacec7e05046b7d04897d58d6d65ed3152cb6cf65dababcfabed" +checksum = "c290eff038799a8ac0c5a82b6160a9ca456baa299a6f22b262c771342d2846c0" dependencies = [ "bdk_core", "bitcoin", @@ -154,9 +154,9 @@ dependencies = [ [[package]] name = "bdk_core" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dbbe4aad0c898bfeb5253c222be3ea3dccfb380a07e72c87e3e4ed6664a6753" +checksum = "cb3028782f6bf14a6df987244333d34e6b272b5a40a53e4879ec2dfd82275a3a" dependencies = [ "bitcoin", "hashbrown 0.14.5", @@ -175,9 +175,9 @@ dependencies = [ [[package]] name = "bdk_esplora" -version = "0.22.1" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9f5961444b5f51b9c3937e729a212363d0e4cde6390ded6e01e16292078df4" +checksum = "83986307ea92997c3d051e8c306af8115a05add601e22acb7c1903008e6b614e" dependencies = [ "async-trait", "bdk_core", @@ -187,9 +187,9 @@ dependencies = [ [[package]] name = "bdk_wallet" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b03f1e31ccc562f600981f747d2262b84428cbff52c9c9cdf14d15fb15bd2286" +checksum = "c99821af39c7df004bd411ece2ef22d9fba5a631b4489f3d9a0ac0d19637e2d0" dependencies = [ "bdk_chain", "bip39", @@ -218,11 +218,11 @@ dependencies = [ [[package]] name = "bip39" -version = "2.2.0" +version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d193de1f7487df1914d3a568b772458861d33f9c54249612cc2893d6915054" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ - "bitcoin_hashes 0.13.0", + "bitcoin_hashes", "rand 0.8.5", "rand_core 0.6.4", "serde", @@ -231,37 +231,44 @@ dependencies = [ [[package]] name = "bitcoin" -version = "0.32.7" +version = "0.32.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda569d741b895131a88ee5589a467e73e9c4718e958ac9308e4f7dc44b6945" +checksum = "7ca0f87890d2398219d15182cb6af9681722bd891758dab4ca01896e2038a510" dependencies = [ "base58ck", "base64 0.21.7", "bech32", - "bitcoin-internals 0.3.0", "bitcoin-io", "bitcoin-units", - "bitcoin_hashes 0.14.0", - "hex-conservative 0.2.1", + "bitcoin_hashes", + "hex-conservative 0.2.2", "hex_lit", "secp256k1", "serde", ] [[package]] -name = "bitcoin-internals" -version = "0.2.0" +name = "bitcoin-consensus-encoding" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb" +checksum = "207311705279250ba465076a1bac4b1ac982855fff73fc5f67e22158ac58cdc9" +dependencies = [ + "bitcoin-internals 0.6.0", + "hex-conservative 1.2.0", + "serde", +] [[package]] name = "bitcoin-internals" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2" -dependencies = [ - "serde", -] + +[[package]] +name = "bitcoin-internals" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" [[package]] name = "bitcoin-io" @@ -283,24 +290,14 @@ dependencies = [ [[package]] name = "bitcoin-units" -version = "0.1.2" +version = "0.1.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5285c8bcaa25876d07f37e3d30c303f2609179716e11d688f51e8f1fe70063e2" +checksum = "9cb95693f371d089a4b5b6fc41c6f3ea6e01ee8c15388335dfac8ea685173b51" dependencies = [ - "bitcoin-internals 0.3.0", + "bitcoin-consensus-encoding", "serde", ] -[[package]] -name = "bitcoin_hashes" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1930a4dabfebb8d7d9992db18ebe3ae2876f0a305fab206fd168df931ede293b" -dependencies = [ - "bitcoin-internals 0.2.0", - "hex-conservative 0.1.2", -] - [[package]] name = "bitcoin_hashes" version = "0.14.0" @@ -308,7 +305,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb18c03d0db0247e147a21a6faafd5a7eb851c743db062de72018b6b7e8e4d16" dependencies = [ "bitcoin-io", - "hex-conservative 0.2.1", + "hex-conservative 0.2.2", "serde", ] @@ -326,17 +323,17 @@ checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" [[package]] name = "bitreq" -version = "0.3.4" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08221cf31c5f00fb6fc8fa697cea54176b06801a518bd9d3482aa27099827a3a" +checksum = "b65c2e1ab98050c93cc9ada070d5070e014329c65196d03f6f8f1f75c2666f37" dependencies = [ - "rustls 0.21.12", - "rustls-webpki 0.101.7", + "rustls 0.23.43", + "rustls-webpki 0.103.13", "serde", "serde_json", "tokio", - "tokio-rustls 0.24.1", - "webpki-roots 0.25.4", + "tokio-rustls 0.26.4", + "webpki-roots 1.0.9", ] [[package]] @@ -362,9 +359,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.43" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "739eb0f94557554b3ca9a86d2d37bebd49c5e6d0c1d2bda35ba5bdac830befc2" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" dependencies = [ "find-msvc-tools", "shlex", @@ -481,7 +478,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec4f825369fc7134da70ca4040fddc8e03b80a46d249ae38d9c1c39b7b4476bf" dependencies = [ - "bitcoin_hashes 0.14.0", + "bitcoin_hashes", "tokio", ] @@ -493,15 +490,15 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "electrum-client" -version = "0.24.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ede7b07e2578a6df0093b101915c79dca0119d7f7810099ad9eef11341d2ae57" +checksum = "a5059f13888a90486e7268bbce59b175f5f76b1c55e5b9c568ceaa42d2b8507c" dependencies = [ "bitcoin", "byteorder", "libc", "log", - "rustls 0.23.34", + "rustls 0.23.43", "serde", "serde_json", "webpki-roots 0.25.4", @@ -535,15 +532,16 @@ dependencies = [ [[package]] name = "esplora-client" -version = "0.12.1" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0af349d96a5d9ad77ba59f1437aa6f348b03c5865d4f7d6e7a662d60aedce39" +checksum = "f19e3ea99dbfbef0c1ec26d83e69de0c579f6aa6aaac4f44597805fcc27e97af" dependencies = [ "bitcoin", - "hex-conservative 0.2.1", + "hex-conservative 0.2.2", "log", "reqwest 0.12.24", "serde", + "serde_json", "tokio", ] @@ -567,9 +565,9 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "find-msvc-tools" -version = "0.1.4" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "fixedbitset" @@ -797,15 +795,18 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hex-conservative" -version = "0.1.2" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "212ab92002354b4819390025006c897e8140934349e8635c9b077f47b4dcbd20" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] [[package]] name = "hex-conservative" -version = "0.2.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5313b072ce3c597065a808dbf612c4c8e8590bdbf8b579508bf7a762c5eae6cd" +checksum = "35431185f361ccf3ffc58254628af5f1f5d5f28531da2e02e5d6c82bbc282a10" dependencies = [ "arrayvec", ] @@ -963,12 +964,12 @@ dependencies = [ "http 1.3.1", "hyper 1.7.0", "hyper-util", - "rustls 0.23.34", + "rustls 0.23.43", "rustls-pki-types", "tokio", "tokio-rustls 0.26.4", "tower-service", - "webpki-roots 1.0.3", + "webpki-roots 1.0.9", ] [[package]] @@ -1203,7 +1204,7 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "ldk-node" version = "0.8.0+git" -source = "git+https://github.com/lightningdevkit/ldk-node?rev=16eaa6f46308965f501904506be3ceecd293f358#16eaa6f46308965f501904506be3ceecd293f358" +source = "git+https://github.com/vincenzopalazzo/ldk-node?branch=watchtower-apis-16eaa6f#534fd7b07dfae0af774dcb1270ab0f0ef3cdead2" dependencies = [ "async-trait", "base64 0.22.1", @@ -1236,7 +1237,7 @@ dependencies = [ "log", "prost", "rusqlite", - "rustls 0.23.34", + "rustls 0.23.43", "serde", "serde_json", "tokio", @@ -1254,7 +1255,7 @@ dependencies = [ "clap", "futures-util", "getrandom 0.2.16", - "hex-conservative 0.2.1", + "hex-conservative 0.2.2", "http-body-util", "hyper 1.7.0", "hyper-util", @@ -1276,7 +1277,7 @@ version = "0.1.0" dependencies = [ "clap", "clap_complete", - "hex-conservative 0.2.1", + "hex-conservative 0.2.2", "ldk-server-client", "serde", "serde_json", @@ -1288,8 +1289,8 @@ dependencies = [ name = "ldk-server-client" version = "0.1.0" dependencies = [ - "bitcoin_hashes 0.14.0", - "hex-conservative 0.2.1", + "bitcoin_hashes", + "hex-conservative 0.2.2", "hyper 0.14.32", "hyper-rustls 0.24.2", "ldk-server-grpc", @@ -1324,7 +1325,7 @@ dependencies = [ name = "ldk-server-mcp" version = "0.1.0" dependencies = [ - "hex-conservative 0.2.1", + "hex-conservative 0.2.2", "ldk-server-client", "serde", "serde_json", @@ -1377,7 +1378,7 @@ source = "git+https://github.com/lightningdevkit/rust-lightning?rev=369a2cf9c8ef dependencies = [ "bitcoin", "bitcoin-io", - "bitcoin_hashes 0.14.0", + "bitcoin_hashes", "lightning", "lightning-liquidity", "lightning-rapid-gossip-sync", @@ -1471,7 +1472,7 @@ source = "git+https://github.com/lightningdevkit/rust-lightning?rev=369a2cf9c8ef dependencies = [ "bitcoin", "bitcoin-io", - "bitcoin_hashes 0.14.0", + "bitcoin_hashes", "lightning", ] @@ -1758,7 +1759,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls 0.23.34", + "rustls 0.23.43", "socket2 0.5.10", "thiserror", "tokio", @@ -1778,7 +1779,7 @@ dependencies = [ "rand 0.9.2", "ring", "rustc-hash", - "rustls 0.23.34", + "rustls 0.23.43", "rustls-pki-types", "slab", "thiserror", @@ -1965,7 +1966,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.34", + "rustls 0.23.43", "rustls-pki-types", "serde", "serde_json", @@ -1980,7 +1981,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 1.0.3", + "webpki-roots 1.0.9", ] [[package]] @@ -2057,15 +2058,15 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.34" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "log", "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.8", + "rustls-webpki 0.103.13", "subtle", "zeroize", ] @@ -2101,9 +2102,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -2138,7 +2139,7 @@ version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ - "bitcoin_hashes 0.14.0", + "bitcoin_hashes", "rand 0.8.5", "secp256k1-sys", "serde", @@ -2219,9 +2220,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -2462,7 +2463,7 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.34", + "rustls 0.23.43", "tokio", ] @@ -2714,7 +2715,7 @@ dependencies = [ "async-trait", "base64 0.22.1", "bitcoin", - "bitcoin_hashes 0.14.0", + "bitcoin_hashes", "bitreq", "chacha20-poly1305", "log", @@ -2836,9 +2837,9 @@ checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" [[package]] name = "webpki-roots" -version = "1.0.3" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32b130c0d2d49f8b6889abc456e795e82525204f27c42cf767cf0d7734e089b8" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] diff --git a/Cargo.toml b/Cargo.toml index f9ccb552..3840b5d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,3 +10,7 @@ lto = true [profile.dev] panic = "abort" + +[patch."https://github.com/lightningdevkit/ldk-node"] +# Temporary: watchtower APIs pending upstream merge of https://github.com/lightningdevkit/ldk-node/pull/1031 +ldk-node = { git = "https://github.com/vincenzopalazzo/ldk-node", branch = "watchtower-apis-16eaa6f" } diff --git a/ldk-server-cli/src/main.rs b/ldk-server-cli/src/main.rs index d6840d71..8e7ed2ae 100644 --- a/ldk-server-cli/src/main.rs +++ b/ldk-server-cli/src/main.rs @@ -43,6 +43,7 @@ use ldk_server_client::ldk_server_grpc::api::{ SpliceInRequest, SpliceInResponse, SpliceOutRequest, SpliceOutResponse, SpontaneousSendRequest, SpontaneousSendResponse, UnifiedSendRequest, UnifiedSendResponse, UpdateChannelConfigRequest, UpdateChannelConfigResponse, VerifySignatureRequest, VerifySignatureResponse, + WatchtowerStateExportRequest, WatchtowerStateExportResponse, }; use ldk_server_client::ldk_server_grpc::types::{ bolt11_invoice_description, Bolt11InvoiceDescription, ChannelConfig, PageToken, @@ -528,6 +529,16 @@ enum Commands { }, #[command(about = "Export the pathfinding scores used by the router")] ExportPathfindingScores, + #[command( + about = "Export per-channel watchtower state (counterparty commitments and justice transactions) for an external watchtower client" + )] + WatchtowerStateExport { + #[arg( + long, + help = "Only export the state of the channel with this hex-encoded user_channel_id" + )] + user_channel_id: Option, + }, #[command(about = "List all known short channel IDs in the network graph")] GraphListChannels, #[command(about = "Get channel information from the network graph by short channel ID")] @@ -1078,6 +1089,14 @@ async fn main() { ), ); }, + Commands::WatchtowerStateExport { user_channel_id } => { + handle_response_result::<_, Value>( + client + .watchtower_state_export(WatchtowerStateExportRequest { user_channel_id }) + .await + .map(watchtower_state_export_to_json), + ); + }, Commands::GraphListChannels => { handle_response_result::<_, GraphListChannelsResponse>( client.graph_list_channels(GraphListChannelsRequest {}).await, @@ -1195,6 +1214,37 @@ fn sanitize_for_terminal(s: String) -> String { out } +fn watchtower_state_export_to_json(response: WatchtowerStateExportResponse) -> Value { + let channel_states: Vec = response + .channel_states + .into_iter() + .map(|state| { + let justice_transactions: Vec = state + .justice_transactions + .into_iter() + .map(|justice_tx| { + json!({ + "commitment_txid": justice_tx.commitment_txid, + "commitment_number": justice_tx.commitment_number, + "to_local_value_sats": justice_tx.to_local_value_sats, + "justice_tx_hex": justice_tx.justice_tx.as_hex().to_string(), + "signed": justice_tx.signed, + }) + }) + .collect(); + json!({ + "funding_txo": state.funding_txo, + "user_channel_id": state.user_channel_id, + "channel_id": state.channel_id, + "counterparty_node_id": state.counterparty_node_id, + "to_self_delay": state.to_self_delay, + "justice_transactions": justice_transactions, + }) + }) + .collect(); + json!({ "channel_states": channel_states }) +} + fn handle_response_result(response: Result) where Rs: Into, diff --git a/ldk-server-client/src/client.rs b/ldk-server-client/src/client.rs index 4c08d738..b3011187 100644 --- a/ldk-server-client/src/client.rs +++ b/ldk-server-client/src/client.rs @@ -37,7 +37,7 @@ use ldk_server_grpc::api::{ SpliceInRequest, SpliceInResponse, SpliceOutRequest, SpliceOutResponse, SpontaneousSendRequest, SpontaneousSendResponse, SubscribeEventsRequest, UnifiedSendRequest, UnifiedSendResponse, UpdateChannelConfigRequest, UpdateChannelConfigResponse, VerifySignatureRequest, - VerifySignatureResponse, + VerifySignatureResponse, WatchtowerStateExportRequest, WatchtowerStateExportResponse, }; use ldk_server_grpc::endpoints::{ BOLT11_CLAIM_FOR_HASH_PATH, BOLT11_FAIL_FOR_HASH_PATH, BOLT11_RECEIVE_FOR_HASH_PATH, @@ -51,6 +51,7 @@ use ldk_server_grpc::endpoints::{ LIST_PEERS_PATH, ONCHAIN_RECEIVE_PATH, ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH, SPLICE_OUT_PATH, SPONTANEOUS_SEND_PATH, SUBSCRIBE_EVENTS_PATH, UNIFIED_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH, VERIFY_SIGNATURE_PATH, + WATCHTOWER_STATE_EXPORT_PATH, }; use ldk_server_grpc::events::EventEnvelope; use ldk_server_grpc::grpc::{ @@ -386,6 +387,14 @@ impl LdkServerClient { self.grpc_unary(&request, EXPORT_PATHFINDING_SCORES_PATH).await } + /// Export per-channel watchtower state (latest counterparty commitment transactions + /// and corresponding justice transactions) for an external watchtower client. + pub async fn watchtower_state_export( + &self, request: WatchtowerStateExportRequest, + ) -> Result { + self.grpc_unary(&request, WATCHTOWER_STATE_EXPORT_PATH).await + } + /// Returns a list of all known short channel IDs in the network graph. pub async fn graph_list_channels( &self, request: GraphListChannelsRequest, diff --git a/ldk-server-grpc/src/api.rs b/ldk-server-grpc/src/api.rs index 07f41e1f..69e71694 100644 --- a/ldk-server-grpc/src/api.rs +++ b/ldk-server-grpc/src/api.rs @@ -1237,3 +1237,32 @@ pub struct DecodeOfferResponse { #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct SubscribeEventsRequest {} +/// Export per-channel watchtower state (latest counterparty commitment transaction(s) +/// and corresponding justice transactions) for consumption by an external watchtower +/// client such as a rust-teos (Eye of Satoshi) client. +/// +/// The endpoint is only served if `watchtower.export_enabled` is set in the server +/// configuration; otherwise a gRPC error status is returned. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct WatchtowerStateExportRequest { + /// If set, only the state of the channel with this hex-encoded `user_channel_id` + /// is exported. Otherwise the state of all channels is exported. + #[prost(string, optional, tag = "1")] + pub user_channel_id: ::core::option::Option<::prost::alloc::string::String>, +} +/// The response for the `WatchtowerStateExport` RPC. On failure, a gRPC error status +/// is returned. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct WatchtowerStateExportResponse { + /// The exported watchtower state, one entry per channel. + #[prost(message, repeated, tag = "1")] + pub channel_states: ::prost::alloc::vec::Vec, +} diff --git a/ldk-server-grpc/src/endpoints.rs b/ldk-server-grpc/src/endpoints.rs index 9b20263f..70ad2240 100644 --- a/ldk-server-grpc/src/endpoints.rs +++ b/ldk-server-grpc/src/endpoints.rs @@ -50,3 +50,4 @@ pub const DECODE_INVOICE_PATH: &str = "DecodeInvoice"; pub const DECODE_OFFER_PATH: &str = "DecodeOffer"; pub const GET_METRICS_PATH: &str = "metrics"; pub const SUBSCRIBE_EVENTS_PATH: &str = "SubscribeEvents"; +pub const WATCHTOWER_STATE_EXPORT_PATH: &str = "WatchtowerStateExport"; diff --git a/ldk-server-grpc/src/proto/api.proto b/ldk-server-grpc/src/proto/api.proto index ba6e5a1e..baa82d89 100644 --- a/ldk-server-grpc/src/proto/api.proto +++ b/ldk-server-grpc/src/proto/api.proto @@ -899,6 +899,25 @@ message DecodeOfferResponse { // Subscribe to a stream of server events. message SubscribeEventsRequest {} +// Export per-channel watchtower state (latest counterparty commitment transaction(s) +// and corresponding justice transactions) for consumption by an external watchtower +// client such as a rust-teos (Eye of Satoshi) client. +// +// The endpoint is only served if `watchtower.export_enabled` is set in the server +// configuration; otherwise a gRPC error status is returned. +message WatchtowerStateExportRequest { + // If set, only the state of the channel with this hex-encoded `user_channel_id` + // is exported. Otherwise the state of all channels is exported. + optional string user_channel_id = 1; +} + +// The response for the `WatchtowerStateExport` RPC. On failure, a gRPC error status +// is returned. +message WatchtowerStateExportResponse { + // The exported watchtower state, one entry per channel. + repeated types.WatchtowerChannelState channel_states = 1; +} + service LightningNode { // Retrieve the latest node info. rpc GetNodeInfo(GetNodeInfoRequest) returns (GetNodeInfoResponse); @@ -976,4 +995,6 @@ service LightningNode { rpc GraphGetNode(GraphGetNodeRequest) returns (GraphGetNodeResponse); // Subscribe to a stream of server events. rpc SubscribeEvents(SubscribeEventsRequest) returns (stream events.EventEnvelope); + // Export per-channel watchtower state for an external watchtower client. + rpc WatchtowerStateExport(WatchtowerStateExportRequest) returns (WatchtowerStateExportResponse); } diff --git a/ldk-server-grpc/src/proto/types.proto b/ldk-server-grpc/src/proto/types.proto index 83be8be8..90a2768c 100644 --- a/ldk-server-grpc/src/proto/types.proto +++ b/ldk-server-grpc/src/proto/types.proto @@ -945,3 +945,56 @@ message Bolt11Feature { // Whether this feature is known. bool is_known = 3; } + +// A justice (penalty) transaction claiming the `to_local` output of a counterparty +// commitment transaction via the revocation path. +message JusticeTransaction { + // The txid (hex-encoded) of the counterparty commitment transaction this justice + // transaction spends from. This is also the source of the watchtower locator + // (the first 16 bytes of the txid). + string commitment_txid = 1; + + // The commitment number of the counterparty commitment transaction. + uint64 commitment_number = 2; + + // The value, in satoshis, of the `to_local` output being claimed. + uint64 to_local_value_sats = 3; + + // The fully-built justice transaction, consensus-serialized. It pays the claimed + // funds (minus fees) to a fresh address controlled by this node's on-chain wallet. + // + // The transaction is signed iff `signed` is true. An unsigned transaction is + // returned when the revocation secret for this commitment state has not been + // received from the counterparty yet (i.e., it is their latest state); clients + // should poll again after the next state update to retrieve the signed version. + bytes justice_tx = 4; + + // Whether `justice_tx` carries a valid signature spending the commitment + // transaction's `to_local` output. + bool signed = 5; +} + +// The watchtower-relevant state of a single channel, as exported by the +// `WatchtowerStateExport` RPC. +message WatchtowerChannelState { + // The channel's funding outpoint. + OutPoint funding_txo = 1; + + // The hex-encoded local `user_channel_id` of this channel. + string user_channel_id = 2; + + // The channel ID (hex-encoded). + string channel_id = 3; + + // The node ID of the channel's remote counterparty. + string counterparty_node_id = 4; + + // The `to_self_delay` (in blocks) encumbering the counterparty's `to_local` + // output, i.e., the CSV delay the justice transactions bypass via the + // revocation path. + uint32 to_self_delay = 5; + + // The justice transactions for the latest known counterparty commitment + // state(s). More than one entry may be present while a splice is in flight. + repeated JusticeTransaction justice_transactions = 6; +} diff --git a/ldk-server-grpc/src/types.rs b/ldk-server-grpc/src/types.rs index 67e52ca8..a3257b4e 100644 --- a/ldk-server-grpc/src/types.rs +++ b/ldk-server-grpc/src/types.rs @@ -1244,6 +1244,69 @@ pub struct Bolt11Feature { #[prost(bool, tag = "3")] pub is_known: bool, } +/// A justice (penalty) transaction claiming the `to_local` output of a counterparty +/// commitment transaction via the revocation path. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct JusticeTransaction { + /// The txid (hex-encoded) of the counterparty commitment transaction this justice + /// transaction spends from. This is also the source of the watchtower locator + /// (the first 16 bytes of the txid). + #[prost(string, tag = "1")] + pub commitment_txid: ::prost::alloc::string::String, + /// The commitment number of the counterparty commitment transaction. + #[prost(uint64, tag = "2")] + pub commitment_number: u64, + /// The value, in satoshis, of the `to_local` output being claimed. + #[prost(uint64, tag = "3")] + pub to_local_value_sats: u64, + /// The fully-built justice transaction, consensus-serialized. It pays the claimed + /// funds (minus fees) to a fresh address controlled by this node's on-chain wallet. + /// + /// The transaction is signed iff `signed` is true. An unsigned transaction is + /// returned when the revocation secret for this commitment state has not been + /// received from the counterparty yet (i.e., it is their latest state); clients + /// should poll again after the next state update to retrieve the signed version. + #[prost(bytes = "bytes", tag = "4")] + pub justice_tx: ::prost::bytes::Bytes, + /// Whether `justice_tx` carries a valid signature spending the commitment + /// transaction's `to_local` output. + #[prost(bool, tag = "5")] + pub signed: bool, +} +/// The watchtower-relevant state of a single channel, as exported by the +/// `WatchtowerStateExport` RPC. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct WatchtowerChannelState { + /// The channel's funding outpoint. + #[prost(message, optional, tag = "1")] + pub funding_txo: ::core::option::Option, + /// The hex-encoded local `user_channel_id` of this channel. + #[prost(string, tag = "2")] + pub user_channel_id: ::prost::alloc::string::String, + /// The channel ID (hex-encoded). + #[prost(string, tag = "3")] + pub channel_id: ::prost::alloc::string::String, + /// The node ID of the channel's remote counterparty. + #[prost(string, tag = "4")] + pub counterparty_node_id: ::prost::alloc::string::String, + /// The `to_self_delay` (in blocks) encumbering the counterparty's `to_local` + /// output, i.e., the CSV delay the justice transactions bypass via the + /// revocation path. + #[prost(uint32, tag = "5")] + pub to_self_delay: u32, + /// The justice transactions for the latest known counterparty commitment + /// state(s). More than one entry may be present while a splice is in flight. + #[prost(message, repeated, tag = "6")] + pub justice_transactions: ::prost::alloc::vec::Vec, +} /// Represents the direction of a payment. #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] diff --git a/ldk-server/src/api/error.rs b/ldk-server/src/api/error.rs index ad1c152f..05e18f0e 100644 --- a/ldk-server/src/api/error.rs +++ b/ldk-server/src/api/error.rs @@ -83,6 +83,7 @@ impl From for LdkServerError { | NodeError::InvalidOffer | NodeError::InvalidRefund | NodeError::InvalidChannelId + | NodeError::ChannelMonitorNotFound | NodeError::InvalidNetwork | NodeError::InvalidUri | NodeError::InvalidQuantity diff --git a/ldk-server/src/api/mod.rs b/ldk-server/src/api/mod.rs index ec0a03f6..0e062b53 100644 --- a/ldk-server/src/api/mod.rs +++ b/ldk-server/src/api/mod.rs @@ -52,6 +52,7 @@ pub(crate) mod spontaneous_send; pub(crate) mod unified_send; pub(crate) mod update_channel_config; pub(crate) mod verify_signature; +pub(crate) mod watchtower_state_export; pub(crate) fn build_channel_config_from_proto( default_config: ChannelConfig, proto_channel_config: ldk_server_grpc::types::ChannelConfig, diff --git a/ldk-server/src/api/watchtower_state_export.rs b/ldk-server/src/api/watchtower_state_export.rs new file mode 100644 index 00000000..c21fd6aa --- /dev/null +++ b/ldk-server/src/api/watchtower_state_export.rs @@ -0,0 +1,217 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use std::sync::Arc; + +use hex::DisplayHex; +use ldk_node::ChannelDetails; +use ldk_server_grpc::api::{WatchtowerStateExportRequest, WatchtowerStateExportResponse}; +use ldk_server_grpc::types::{JusticeTransaction, OutPoint, WatchtowerChannelState}; + +use crate::api::error::LdkServerError; +use crate::api::error::LdkServerErrorCode::InvalidRequestError; +use crate::service::Context; + +pub(crate) async fn handle_watchtower_state_export_request( + context: Arc, request: WatchtowerStateExportRequest, +) -> Result { + if !context.watchtower_export_enabled { + return Err(LdkServerError::new( + InvalidRequestError, + "Watchtower state export is disabled. Set `watchtower.export_enabled = true` in the server configuration to enable it.", + )); + } + + let channels = context.node.list_channels(); + let channels = match &request.user_channel_id { + Some(user_channel_id) => { + let filtered: Vec = channels + .into_iter() + .filter(|channel| channel.user_channel_id.0.to_string() == *user_channel_id) + .collect(); + if filtered.is_empty() { + return Err(LdkServerError::new( + InvalidRequestError, + format!("Unknown channel with user_channel_id {}", user_channel_id), + )); + } + filtered + }, + None => channels, + }; + + let mut channel_states = Vec::new(); + for channel in &channels { + if let Some(state) = export_channel_state(&context, channel)? { + channel_states.push(state); + } + } + + Ok(WatchtowerStateExportResponse { channel_states }) +} + +/// Exports the watchtower-relevant state of a single channel, or `None` if the channel +/// has no negotiated funding output (and therefore no monitor state) yet. +fn export_channel_state( + context: &Context, channel: &ChannelDetails, +) -> Result, LdkServerError> { + let funding_txo = match channel.funding_txo { + Some(funding_txo) => funding_txo, + None => return Ok(None), + }; + + let justice_transactions = latest_justice_transactions(context, channel)?; + + Ok(Some(WatchtowerChannelState { + funding_txo: Some(OutPoint { txid: funding_txo.txid.to_string(), vout: funding_txo.vout }), + user_channel_id: channel.user_channel_id.0.to_string(), + channel_id: channel.channel_id.0.to_lower_hex_string(), + counterparty_node_id: channel.counterparty_node_id.to_string(), + to_self_delay: counterparty_to_self_delay(channel), + justice_transactions, + })) +} + +/// Returns the `to_self_delay` (in blocks) encumbering the counterparty's `to_local` +/// output, i.e., the delay our justice transactions bypass via the revocation path. +/// +/// TODO: Once the ldk-node `feat/expose-chain-monitor` API is available, derive this +/// from the channel's `ChannelMonitor` state, which is authoritative. Until then we +/// fall back to `force_close_spend_delay` from `ChannelDetails`, which is the +/// counterparty's `to_self_delay` as negotiated for *our* commitment transaction; for +/// typical configurations both sides use the same value, but this may diverge if the +/// counterparty configured a different delay for us than we did for them. +fn counterparty_to_self_delay(channel: &ChannelDetails) -> u32 { + channel.force_close_spend_delay.unwrap_or(0) as u32 +} + +/// Exports the latest counterparty commitment transaction(s) of the channel - the +/// watchtower locator source - and, where possible, the justice transactions claiming +/// their `to_local` outputs. +/// +/// What is wired today: for the newest monitor update carrying counterparty +/// commitments (or the initial commitment if no update carries one), the commitment +/// txid and commitment number are exported. +/// +/// TODO(justice-tx): detect the `to_local` output index and value, build the unsigned +/// justice transaction paying a fresh address of this node's on-chain wallet with a +/// conservative fee, and sign it via `Node::sign_to_local_justice_tx` once the state +/// has been revoked by a newer one. Until then `to_local_value_sats` is 0, +/// `justice_tx` is empty, and `signed` is false: clients can derive locators but +/// cannot finalize appointments yet. +fn latest_justice_transactions( + context: &Context, channel: &ChannelDetails, +) -> Result, LdkServerError> { + let channel_id = channel.channel_id; + + let mut commitment_txs = Vec::new(); + let updates = context.node.channel_monitor_updates(channel_id).map_err(LdkServerError::from)?; + // Walk updates newest-first: not every update carries new counterparty commitments. + for update in updates.iter().rev() { + let txs = context + .node + .counterparty_commitment_txs_from_update(channel_id, update.clone()) + .map_err(LdkServerError::from)?; + if !txs.is_empty() { + commitment_txs = txs; + break; + } + } + if commitment_txs.is_empty() { + if let Some(initial) = context + .node + .initial_counterparty_commitment_tx(channel_id) + .map_err(LdkServerError::from)? + { + commitment_txs.push(initial); + } + } + + Ok(commitment_txs + .into_iter() + .map(|tx| JusticeTransaction { + commitment_txid: tx.trust().txid().to_string(), + commitment_number: tx.commitment_number(), + // See TODO(justice-tx) above. + to_local_value_sats: 0, + justice_tx: bytes::Bytes::new(), + signed: false, + }) + .collect()) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use hex::DisplayHex; + use ldk_node::bitcoin::Network; + use ldk_node::entropy::NodeEntropy; + use ldk_node::Builder; + + use super::*; + use crate::io::persist::sqlite_store::SqliteStore; + + fn random_storage_path() -> PathBuf { + let mut temp_path = std::env::temp_dir(); + let mut bytes = [0u8; 8]; + getrandom::getrandom(&mut bytes).expect("Failed to generate random bytes"); + temp_path.push(bytes.to_lower_hex_string()); + temp_path + } + + fn build_test_context(watchtower_export_enabled: bool) -> Arc { + let storage_dir = random_storage_path(); + + let mut node_config = ldk_node::config::Config::default(); + node_config.network = Network::Regtest; + node_config.storage_dir_path = storage_dir.to_str().unwrap().to_string(); + + let mut builder = Builder::from_config(node_config); + // The chain source is never queried as the node is not started. + builder.set_chain_source_esplora("http://127.0.0.1:1".to_string(), None); + let node_entropy = + NodeEntropy::from_seed_path(storage_dir.join("seed").to_str().unwrap().to_string()) + .unwrap(); + let node = builder.build(node_entropy).unwrap(); + + let paginated_kv_store = SqliteStore::new( + storage_dir.join("paginated_kv"), + Some("test_db".to_string()), + Some("test_table".to_string()), + ) + .unwrap(); + + Arc::new(Context { + node: node.into(), + paginated_kv_store: Arc::new(paginated_kv_store), + watchtower_export_enabled, + }) + } + + #[test] + fn export_disabled_returns_error() { + let context = build_test_context(false); + + let request = WatchtowerStateExportRequest { user_channel_id: None }; + let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap(); + // Keep a second strong ref outside the async context: the handler's Arc is + // dropped inside the runtime, but the node must only be dropped after the + // runtime is gone (dropping a runtime within an async context panics). + let result = rt.block_on(handle_watchtower_state_export_request( + std::sync::Arc::clone(&context), + request, + )); + drop(rt); + + let err = result.unwrap_err(); + assert_eq!(err.error_code, InvalidRequestError); + assert!(err.message.contains("disabled")); + } +} diff --git a/ldk-server/src/main.rs b/ldk-server/src/main.rs index 2d4fb5f2..02c64fa9 100644 --- a/ldk-server/src/main.rs +++ b/ldk-server/src/main.rs @@ -53,9 +53,8 @@ use crate::util::config::{load_config, ArgsConfig, ChainSource}; use crate::util::logger::ServerLogger; use crate::util::metrics::Metrics; use crate::util::proto_adapter::{forwarded_payment_to_proto, payment_to_proto}; -use crate::util::systemd; use crate::util::tls::get_or_generate_tls_config; -use crate::util::write_new; +use crate::util::{systemd, write_new}; const API_KEY_FILE: &str = "api_key"; @@ -242,6 +241,11 @@ fn main() { let (event_sender, _) = broadcast::channel::(1024); let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); + let watchtower_export_enabled = config_file.watchtower_config.export_enabled; + if watchtower_export_enabled { + info!("Watchtower state export enabled"); + } + info!("Starting up..."); match node.start() { Ok(()) => {}, @@ -578,6 +582,7 @@ fn main() { Arc::clone(&node), Arc::clone(&paginated_store), api_key.clone(), + watchtower_export_enabled, metrics.clone(), metrics_auth_header.clone(), event_sender.clone(), @@ -843,9 +848,10 @@ fn load_or_generate_api_key(storage_dir: &Path) -> std::io::Result { #[cfg(test)] mod tests { - use super::*; use ldk_server_grpc::events::channel_state_change_reason::Details; + use super::*; + #[test] fn test_is_channel_open_failure_classification() { assert!(is_channel_open_failure(Some(&ClosureReason::FundingTimedOut))); diff --git a/ldk-server/src/service.rs b/ldk-server/src/service.rs index 2358bc9e..8c48bdf3 100644 --- a/ldk-server/src/service.rs +++ b/ldk-server/src/service.rs @@ -29,7 +29,7 @@ use ldk_server_grpc::endpoints::{ LIST_CHANNELS_PATH, LIST_FORWARDED_PAYMENTS_PATH, LIST_PAYMENTS_PATH, LIST_PEERS_PATH, ONCHAIN_RECEIVE_PATH, ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH, SPLICE_OUT_PATH, SPONTANEOUS_SEND_PATH, SUBSCRIBE_EVENTS_PATH, UNIFIED_SEND_PATH, - UPDATE_CHANNEL_CONFIG_PATH, VERIFY_SIGNATURE_PATH, + UPDATE_CHANNEL_CONFIG_PATH, VERIFY_SIGNATURE_PATH, WATCHTOWER_STATE_EXPORT_PATH, }; use ldk_server_grpc::events::EventEnvelope; use ldk_server_grpc::grpc::{ @@ -79,6 +79,7 @@ use crate::api::spontaneous_send::handle_spontaneous_send_request; use crate::api::unified_send::handle_unified_send_request; use crate::api::update_channel_config::handle_update_channel_config_request; use crate::api::verify_signature::handle_verify_signature_request; +use crate::api::watchtower_state_export::handle_watchtower_state_export_request; use crate::io::persist::paginated_kv_store::PaginatedKVStore; use crate::util::metrics::Metrics; @@ -101,11 +102,11 @@ pub(crate) struct NodeService { impl NodeService { pub(crate) fn new( node: Arc, paginated_kv_store: Arc, api_key: String, - metrics: Option>, metrics_auth_header: Option, - event_sender: broadcast::Sender, + watchtower_export_enabled: bool, metrics: Option>, + metrics_auth_header: Option, event_sender: broadcast::Sender, shutdown_rx: tokio::sync::watch::Receiver, ) -> Self { - let context = Arc::new(Context { node, paginated_kv_store }); + let context = Arc::new(Context { node, paginated_kv_store, watchtower_export_enabled }); Self { context, api_key, metrics, metrics_auth_header, event_sender, shutdown_rx } } } @@ -164,6 +165,7 @@ fn validate_auth(req: &Request, api_key: &str, body: &[u8]) -> Result<(), pub(crate) struct Context { pub(crate) node: Arc, pub(crate) paginated_kv_store: Arc, + pub(crate) watchtower_export_enabled: bool, } impl Service> for NodeService { @@ -377,6 +379,10 @@ impl Service> for NodeService { handle_grpc_unary(context, body_bytes, handle_export_pathfinding_scores_request) .await }, + WATCHTOWER_STATE_EXPORT_PATH => { + handle_grpc_unary(context, body_bytes, handle_watchtower_state_export_request) + .await + }, GRAPH_LIST_CHANNELS_PATH => { handle_grpc_unary(context, body_bytes, handle_graph_list_channels_request).await }, diff --git a/ldk-server/src/util/config.rs b/ldk-server/src/util/config.rs index 318be1fa..f68b1dc3 100644 --- a/ldk-server/src/util/config.rs +++ b/ldk-server/src/util/config.rs @@ -64,6 +64,7 @@ pub struct Config { pub metrics_password: Option, pub tor_config: Option, pub hrn_config: HumanReadableNamesConfig, + pub watchtower_config: WatchtowerConfig, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -92,6 +93,13 @@ pub struct TorConfig { pub proxy_address: SocketAddress, } +/// Configuration for watchtower (e.g. rust-teos) client support. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct WatchtowerConfig { + /// Whether the `WatchtowerStateExport` endpoint is served. Defaults to `false`. + pub export_enabled: bool, +} + /// A builder for `Config`. #[derive(Default)] struct ConfigBuilder { @@ -119,6 +127,7 @@ struct ConfigBuilder { metrics_password: Option, tor_proxy_address: Option, hrn: Option, + watchtower_export_enabled: Option, } impl ConfigBuilder { @@ -191,6 +200,11 @@ impl ConfigBuilder { if let Some(hrn) = toml.hrn { self.hrn = Some(hrn); } + + if let Some(watchtower) = toml.watchtower { + self.watchtower_export_enabled = + watchtower.export_enabled.or(self.watchtower_export_enabled); + } } fn merge_args(&mut self, args: &ArgsConfig) { @@ -432,6 +446,9 @@ impl ConfigBuilder { None => HumanReadableNamesConfig::default(), }; + let watchtower_config = + WatchtowerConfig { export_enabled: self.watchtower_export_enabled.unwrap_or(false) }; + Ok(Config { network, listening_addrs, @@ -454,6 +471,7 @@ impl ConfigBuilder { metrics_password, tor_config: tor_proxy_address.map(|proxy_address| TorConfig { proxy_address }), hrn_config, + watchtower_config, }) } } @@ -473,6 +491,7 @@ pub struct TomlConfig { metrics: Option, tor: Option, hrn: Option, + watchtower: Option, } #[derive(Deserialize, Serialize)] @@ -550,6 +569,12 @@ struct TomlTorConfig { proxy_address: String, } +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct WatchtowerTomlConfig { + export_enabled: Option, +} + #[derive(Deserialize, Serialize)] #[serde(deny_unknown_fields)] struct HrnTomlConfig { @@ -1102,6 +1127,7 @@ mod tests { proxy_address: SocketAddress::from_str("127.0.0.1:9050").unwrap(), }), hrn_config: HumanReadableNamesConfig::default(), + watchtower_config: WatchtowerConfig::default(), }; assert_eq!(config.listening_addrs, expected.listening_addrs); @@ -1477,6 +1503,7 @@ mod tests { metrics_password: None, tor_config: None, hrn_config: HumanReadableNamesConfig::default(), + watchtower_config: WatchtowerConfig::default(), }; assert_eq!(config.listening_addrs, expected.listening_addrs); @@ -1589,6 +1616,7 @@ mod tests { proxy_address: SocketAddress::from_str("127.0.0.1:9050").unwrap(), }), hrn_config: HumanReadableNamesConfig::default(), + watchtower_config: WatchtowerConfig::default(), }; assert_eq!(config.listening_addrs, expected.listening_addrs); diff --git a/ldk-server/src/util/mod.rs b/ldk-server/src/util/mod.rs index a08295e2..7ae16d62 100644 --- a/ldk-server/src/util/mod.rs +++ b/ldk-server/src/util/mod.rs @@ -29,9 +29,10 @@ pub(crate) fn write_new(path: &Path, contents: &[u8], mode: u32) -> io::Result<( #[cfg(test)] mod tests { - use super::*; use std::path::PathBuf; + use super::*; + #[test] fn write_new_sets_requested_mode_and_contents() { let dir = test_dir("mode_and_contents");