diff --git a/Cargo.toml b/Cargo.toml index 6b09eb62..9140854f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,3 +10,19 @@ lto = true [profile.dev] panic = "abort" + +# Redirect rust-lightning git deps transitively (through ldk-node) to our +# fork that exposes ChannelMonitor::channel_keys_id. +# See rsafier/rust-lightning branch rfc/channel-keys-id-accessor. +[patch.'https://github.com/lightningdevkit/rust-lightning'] +lightning = { git = "https://github.com/rsafier/rust-lightning.git", branch = "rfc/channel-keys-id-accessor" } +lightning-types = { git = "https://github.com/rsafier/rust-lightning.git", branch = "rfc/channel-keys-id-accessor" } +lightning-invoice = { git = "https://github.com/rsafier/rust-lightning.git", branch = "rfc/channel-keys-id-accessor" } +lightning-net-tokio = { git = "https://github.com/rsafier/rust-lightning.git", branch = "rfc/channel-keys-id-accessor" } +lightning-persister = { git = "https://github.com/rsafier/rust-lightning.git", branch = "rfc/channel-keys-id-accessor" } +lightning-background-processor = { git = "https://github.com/rsafier/rust-lightning.git", branch = "rfc/channel-keys-id-accessor" } +lightning-rapid-gossip-sync = { git = "https://github.com/rsafier/rust-lightning.git", branch = "rfc/channel-keys-id-accessor" } +lightning-block-sync = { git = "https://github.com/rsafier/rust-lightning.git", branch = "rfc/channel-keys-id-accessor" } +lightning-transaction-sync = { git = "https://github.com/rsafier/rust-lightning.git", branch = "rfc/channel-keys-id-accessor" } +lightning-liquidity = { git = "https://github.com/rsafier/rust-lightning.git", branch = "rfc/channel-keys-id-accessor" } +lightning-macros = { git = "https://github.com/rsafier/rust-lightning.git", branch = "rfc/channel-keys-id-accessor" } diff --git a/e2e-tests/Cargo.toml b/e2e-tests/Cargo.toml index 70f2d5ef..54cf9c29 100644 --- a/e2e-tests/Cargo.toml +++ b/e2e-tests/Cargo.toml @@ -11,4 +11,4 @@ ldk-server-client = { path = "../ldk-server-client" } ldk-server-grpc = { path = "../ldk-server-grpc", features = ["serde"] } serde_json = "1.0" hex-conservative = { version = "0.2", features = ["std"] } -ldk-node = { git = "https://github.com/lightningdevkit/ldk-node", rev = "c754e2fe85c70741b5e370334cd16856c615265e" } +ldk-node = { git = "https://github.com/rsafier/ldk-node.git", branch = "rfc/export-channel-attestation" } diff --git a/ldk-server-cli/src/main.rs b/ldk-server-cli/src/main.rs index f001d871..7719be22 100644 --- a/ldk-server-cli/src/main.rs +++ b/ldk-server-cli/src/main.rs @@ -34,7 +34,8 @@ use ldk_server_client::ldk_server_grpc::api::{ DecodeInvoiceRequest, DecodeInvoiceResponse, DecodeOfferRequest, DecodeOfferResponse, DisconnectPeerRequest, DisconnectPeerResponse, ExportPathfindingScoresRequest, ForceCloseChannelRequest, ForceCloseChannelResponse, GetBalancesRequest, GetBalancesResponse, - GetNodeInfoRequest, GetNodeInfoResponse, GetPaymentDetailsRequest, GetPaymentDetailsResponse, + GetChannelAttestationsRequest, GetChannelAttestationsResponse, GetNodeInfoRequest, + GetNodeInfoResponse, GetPaymentDetailsRequest, GetPaymentDetailsResponse, GraphGetChannelRequest, GraphGetChannelResponse, GraphGetNodeRequest, GraphGetNodeResponse, GraphListChannelsRequest, GraphListChannelsResponse, GraphListNodesRequest, GraphListNodesResponse, ListChannelsRequest, ListChannelsResponse, @@ -443,6 +444,16 @@ enum Commands { }, #[command(about = "Return a list of known channels")] ListChannels, + #[command( + about = "Return cryptographic attestation bundles for open channels (unsigned commit tx + counterparty sig + funding pubkeys)" + )] + GetChannelAttestations { + #[arg( + long, + help = "Hex channel_id (can be given multiple times). If omitted, all open channels." + )] + channel_id: Vec, + }, #[command(about = "Retrieve list of all payments")] ListPayments { #[arg(short, long)] @@ -985,6 +996,15 @@ async fn main() { client.list_channels(ListChannelsRequest {}).await, ); }, + Commands::GetChannelAttestations { channel_id } => { + handle_response_result::<_, GetChannelAttestationsResponse>( + client + .get_channel_attestations(GetChannelAttestationsRequest { + channel_ids: channel_id, + }) + .await, + ); + }, Commands::ListPayments { number_of_payments, page_token } => { let page_token = page_token .map(|token_str| parse_page_token(&token_str).unwrap_or_else(|e| handle_error(e))); diff --git a/ldk-server-client/src/client.rs b/ldk-server-client/src/client.rs index 526b7375..1de1c4d9 100644 --- a/ldk-server-client/src/client.rs +++ b/ldk-server-client/src/client.rs @@ -26,7 +26,8 @@ use ldk_server_grpc::api::{ DecodeInvoiceRequest, DecodeInvoiceResponse, DecodeOfferRequest, DecodeOfferResponse, DisconnectPeerRequest, DisconnectPeerResponse, ExportPathfindingScoresRequest, ExportPathfindingScoresResponse, ForceCloseChannelRequest, ForceCloseChannelResponse, - GetBalancesRequest, GetBalancesResponse, GetNodeInfoRequest, GetNodeInfoResponse, + GetBalancesRequest, GetBalancesResponse, GetChannelAttestationsRequest, + GetChannelAttestationsResponse, GetNodeInfoRequest, GetNodeInfoResponse, GetPaymentDetailsRequest, GetPaymentDetailsResponse, GraphGetChannelRequest, GraphGetChannelResponse, GraphGetNodeRequest, GraphGetNodeResponse, GraphListChannelsRequest, GraphListChannelsResponse, GraphListNodesRequest, GraphListNodesResponse, ListChannelsRequest, @@ -45,7 +46,8 @@ use ldk_server_grpc::endpoints::{ BOLT11_RECEIVE_VIA_JIT_CHANNEL_PATH, BOLT11_SEND_PATH, BOLT12_RECEIVE_PATH, BOLT12_SEND_PATH, CLOSE_CHANNEL_PATH, CONNECT_PEER_PATH, DECODE_INVOICE_PATH, DECODE_OFFER_PATH, DISCONNECT_PEER_PATH, EXPORT_PATHFINDING_SCORES_PATH, FORCE_CLOSE_CHANNEL_PATH, - GET_BALANCES_PATH, GET_METRICS_PATH, GET_NODE_INFO_PATH, GET_PAYMENT_DETAILS_PATH, + GET_BALANCES_PATH, GET_CHANNEL_ATTESTATIONS_PATH, GET_METRICS_PATH, GET_NODE_INFO_PATH, + GET_PAYMENT_DETAILS_PATH, GRAPH_GET_CHANNEL_PATH, GRAPH_GET_NODE_PATH, GRAPH_LIST_CHANNELS_PATH, GRAPH_LIST_NODES_PATH, GRPC_SERVICE_PREFIX, 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, @@ -288,6 +290,14 @@ impl LdkServerClient { self.grpc_unary(&request, LIST_CHANNELS_PATH).await } + /// Retrieve per-channel cryptographic attestation bundles (unsigned commit tx + + /// counterparty sig + both funding pubkeys + balances + pending HTLCs). + pub async fn get_channel_attestations( + &self, request: GetChannelAttestationsRequest, + ) -> Result { + self.grpc_unary(&request, GET_CHANNEL_ATTESTATIONS_PATH).await + } + /// Retrieves list of all payments sent or received by us. pub async fn list_payments( &self, request: ListPaymentsRequest, @@ -418,7 +428,8 @@ impl LdkServerClient { /// /// Returns an [`EventStream`] that yields [`EventEnvelope`] messages as they arrive. pub async fn subscribe_events(&self) -> Result { - self.grpc_server_streaming(&SubscribeEventsRequest {}, SUBSCRIBE_EVENTS_PATH).await + self.grpc_server_streaming(&SubscribeEventsRequest::default(), SUBSCRIBE_EVENTS_PATH) + .await } /// Send a unary gRPC request and decode the response. diff --git a/ldk-server-grpc/build.rs b/ldk-server-grpc/build.rs index f32fe938..8a7fc9ce 100644 --- a/ldk-server-grpc/build.rs +++ b/ldk-server-grpc/build.rs @@ -57,6 +57,26 @@ fn generate_protos() { "types.Bolt12Refund.secret", "#[cfg_attr(feature = \"serde\", serde(serialize_with = \"crate::serde_utils::serialize_opt_bytes_hex\"))]", ) + .field_attribute( + "events.ChannelCommitmentBundle.holder_commitment_tx", + "#[cfg_attr(feature = \"serde\", serde(serialize_with = \"crate::serde_utils::serialize_bytes_hex\"))]", + ) + .field_attribute( + "events.ChannelCommitmentBundle.counterparty_signature", + "#[cfg_attr(feature = \"serde\", serde(serialize_with = \"crate::serde_utils::serialize_bytes_hex\"))]", + ) + .field_attribute( + "events.ChannelCommitmentBundle.holder_funding_pubkey", + "#[cfg_attr(feature = \"serde\", serde(serialize_with = \"crate::serde_utils::serialize_bytes_hex\"))]", + ) + .field_attribute( + "events.ChannelCommitmentBundle.counterparty_funding_pubkey", + "#[cfg_attr(feature = \"serde\", serde(serialize_with = \"crate::serde_utils::serialize_bytes_hex\"))]", + ) + .field_attribute( + "events.AttestationHtlc.payment_hash", + "#[cfg_attr(feature = \"serde\", serde(serialize_with = \"crate::serde_utils::serialize_bytes_hex\"))]", + ) .field_attribute( "types.Payment.direction", "#[cfg_attr(feature = \"serde\", serde(serialize_with = \"crate::serde_utils::serialize_payment_direction\"))]", diff --git a/ldk-server-grpc/src/api.rs b/ldk-server-grpc/src/api.rs index 0cc3e159..0c3a8cd9 100644 --- a/ldk-server-grpc/src/api.rs +++ b/ldk-server-grpc/src/api.rs @@ -1152,9 +1152,78 @@ pub struct DecodeOfferResponse { #[prost(bool, tag = "12")] pub is_expired: bool, } +/// Request a point-in-time attestation bundle for some or all channels. +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetChannelAttestationsRequest { + /// Optional channel-id filter (hex-encoded). Empty = all channels. + #[prost(string, repeated, tag = "1")] + pub channel_ids: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, +} +/// Response: one ChannelCommitmentUpdated per channel, carrying the full +/// cryptographic bundle populated by `Node::export_channel_attestation`. +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetChannelAttestationsResponse { + #[prost(message, repeated, tag = "1")] + pub attestations: ::prost::alloc::vec::Vec, +} /// Subscribe to a stream of server events. #[cfg_attr(feature = "serde", derive(serde::Serialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] -pub struct SubscribeEventsRequest {} +pub struct SubscribeEventsRequest { + /// Optional event-kind filter. If empty, the server emits all event kinds + /// EXCEPT EVENT_KIND_CHANNEL_COMMITMENT (which is opt-in because its rate + /// can be much higher than the lifecycle / payment streams). Passing a + /// non-empty list restricts the stream to those kinds (including + /// EVENT_KIND_CHANNEL_COMMITMENT if explicitly requested). + #[prost(enumeration = "EventKind", repeated, tag = "1")] + pub only: ::prost::alloc::vec::Vec, +} +/// Classes of events emitted on SubscribeEvents. +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum EventKind { + Unspecified = 0, + /// PaymentReceived / PaymentSuccessful / PaymentFailed / PaymentClaimable / + /// PaymentForwarded. + Payment = 1, + /// ChannelPending / ChannelReady / ChannelClosed. + ChannelLifecycle = 2, + /// ChannelCommitmentUpdated — per-commitment attestation bundles. + /// Excluded from the default subscription because a busy node can emit + /// these many times per second. + ChannelCommitment = 3, +} +impl EventKind { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + EventKind::Unspecified => "EVENT_KIND_UNSPECIFIED", + EventKind::Payment => "EVENT_KIND_PAYMENT", + EventKind::ChannelLifecycle => "EVENT_KIND_CHANNEL_LIFECYCLE", + EventKind::ChannelCommitment => "EVENT_KIND_CHANNEL_COMMITMENT", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "EVENT_KIND_UNSPECIFIED" => Some(Self::Unspecified), + "EVENT_KIND_PAYMENT" => Some(Self::Payment), + "EVENT_KIND_CHANNEL_LIFECYCLE" => Some(Self::ChannelLifecycle), + "EVENT_KIND_CHANNEL_COMMITMENT" => Some(Self::ChannelCommitment), + _ => None, + } + } +} diff --git a/ldk-server-grpc/src/endpoints.rs b/ldk-server-grpc/src/endpoints.rs index 9b20263f..e95f945a 100644 --- a/ldk-server-grpc/src/endpoints.rs +++ b/ldk-server-grpc/src/endpoints.rs @@ -30,6 +30,7 @@ pub const SPLICE_OUT_PATH: &str = "SpliceOut"; pub const CLOSE_CHANNEL_PATH: &str = "CloseChannel"; pub const FORCE_CLOSE_CHANNEL_PATH: &str = "ForceCloseChannel"; pub const LIST_CHANNELS_PATH: &str = "ListChannels"; +pub const GET_CHANNEL_ATTESTATIONS_PATH: &str = "GetChannelAttestations"; pub const LIST_PAYMENTS_PATH: &str = "ListPayments"; pub const LIST_FORWARDED_PAYMENTS_PATH: &str = "ListForwardedPayments"; pub const UPDATE_CHANNEL_CONFIG_PATH: &str = "UpdateChannelConfig"; diff --git a/ldk-server-grpc/src/events.rs b/ldk-server-grpc/src/events.rs index c2e74fcc..e1d4f93a 100644 --- a/ldk-server-grpc/src/events.rs +++ b/ldk-server-grpc/src/events.rs @@ -13,7 +13,7 @@ #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct EventEnvelope { - #[prost(oneof = "event_envelope::Event", tags = "2, 3, 4, 6, 7")] + #[prost(oneof = "event_envelope::Event", tags = "2, 3, 4, 6, 7, 8, 9, 10, 11")] pub event: ::core::option::Option, } /// Nested message and enum types in `EventEnvelope`. @@ -33,6 +33,16 @@ pub mod event_envelope { PaymentForwarded(super::PaymentForwarded), #[prost(message, tag = "7")] PaymentClaimable(super::PaymentClaimable), + /// Channel-lifecycle events (RFC #134 parent scope). + #[prost(message, tag = "8")] + ChannelPending(super::ChannelPending), + #[prost(message, tag = "9")] + ChannelReady(super::ChannelReady), + #[prost(message, tag = "10")] + ChannelClosed(super::ChannelClosed), + /// Per-commitment-update event carrying the cryptographic attestation bundle. + #[prost(message, tag = "11")] + ChannelCommitmentUpdated(super::ChannelCommitmentUpdated), } } /// PaymentReceived indicates a payment has been received. @@ -85,3 +95,142 @@ pub struct PaymentForwarded { #[prost(message, optional, tag = "1")] pub forwarded_payment: ::core::option::Option, } +/// ChannelPending indicates a new channel has been created and is awaiting +/// on-chain funding confirmation. +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ChannelPending { + /// Hex-encoded channel id. + #[prost(string, tag = "1")] + pub channel_id: ::prost::alloc::string::String, + /// Hex-encoded user-assigned channel id (16 bytes). + #[prost(string, tag = "2")] + pub user_channel_id: ::prost::alloc::string::String, + /// Pubkey of the counterparty node, hex-encoded. + #[prost(string, tag = "3")] + pub counterparty_node_id: ::prost::alloc::string::String, + /// Funding outpoint (txid:vout) as observed on chain. + #[prost(string, tag = "4")] + pub funding_txid: ::prost::alloc::string::String, + #[prost(uint32, tag = "5")] + pub funding_output_index: u32, +} +/// ChannelReady indicates a channel has reached the confirmed / usable state. +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ChannelReady { + #[prost(string, tag = "1")] + pub channel_id: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub user_channel_id: ::prost::alloc::string::String, + /// May be absent in edge cases (see ldk-node docs). + #[prost(string, optional, tag = "3")] + pub counterparty_node_id: ::core::option::Option<::prost::alloc::string::String>, +} +/// ChannelClosed indicates a channel has been closed. +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ChannelClosed { + #[prost(string, tag = "1")] + pub channel_id: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub user_channel_id: ::prost::alloc::string::String, + #[prost(string, optional, tag = "3")] + pub counterparty_node_id: ::core::option::Option<::prost::alloc::string::String>, + /// Free-form closure reason string as surfaced by ldk-node. + #[prost(string, optional, tag = "4")] + pub reason: ::core::option::Option<::prost::alloc::string::String>, +} +/// ChannelCommitmentUpdated fires once per commitment-state update and carries +/// the cryptographic bundle an external attestor needs to verify channel state +/// against the counterparty's funding pubkey. +/// +/// See the RFC filed on ldk-server: +/// "include commitment-bundle data on channel-state events (extends #134)" +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ChannelCommitmentUpdated { + /// Channel identity. + #[prost(string, tag = "1")] + pub channel_id: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub counterparty_node_id: ::prost::alloc::string::String, + #[prost(string, tag = "3")] + pub funding_txid: ::prost::alloc::string::String, + #[prost(uint32, tag = "4")] + pub funding_outnum: u32, + /// Monotonic — increments with every new state. + #[prost(uint64, tag = "5")] + pub commitment_number: u64, + /// The cryptographic bundle the attestor cares about. + #[prost(message, optional, tag = "6")] + pub bundle: ::core::option::Option, +} +/// ChannelCommitmentBundle is the verifiable per-channel-state payload. +/// +/// An external verifier reconstructs the BIP-143 sighash over +/// holder_commitment_tx using the 2-of-2 funding redeem script assembled from +/// holder_funding_pubkey / counterparty_funding_pubkey and verifies +/// counterparty_signature against counterparty_funding_pubkey. That closes +/// the loop independent of the operator. +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ChannelCommitmentBundle { + /// Raw wire-format unsigned commitment tx (no witnesses). + #[prost(bytes = "bytes", tag = "1")] + #[cfg_attr(feature = "serde", serde(serialize_with = "crate::serde_utils::serialize_bytes_hex"))] + pub holder_commitment_tx: ::prost::bytes::Bytes, + /// Counterparty's ECDSA signature over holder_commitment_tx (BIP-143 + /// sighash, SIGHASH_ALL). Receivers should try DER first and fall back to + /// a 64-byte raw (r||s) encoding. + #[prost(bytes = "bytes", tag = "2")] + #[cfg_attr(feature = "serde", serde(serialize_with = "crate::serde_utils::serialize_bytes_hex"))] + pub counterparty_signature: ::prost::bytes::Bytes, + /// 33-byte compressed pubkeys of both funding-output participants. + #[prost(bytes = "bytes", tag = "3")] + #[cfg_attr(feature = "serde", serde(serialize_with = "crate::serde_utils::serialize_bytes_hex"))] + pub holder_funding_pubkey: ::prost::bytes::Bytes, + #[prost(bytes = "bytes", tag = "4")] + #[cfg_attr(feature = "serde", serde(serialize_with = "crate::serde_utils::serialize_bytes_hex"))] + pub counterparty_funding_pubkey: ::prost::bytes::Bytes, + #[prost(uint64, tag = "5")] + pub capacity_sats: u64, + #[prost(uint64, tag = "6")] + pub holder_balance_msat: u64, + #[prost(uint64, tag = "7")] + pub counterparty_balance_msat: u64, + #[prost(uint64, tag = "8")] + pub commit_fee_sats: u64, + #[prost(message, repeated, tag = "9")] + pub pending_htlcs: ::prost::alloc::vec::Vec, + /// Channel type marker, e.g. "anchors", "static_remotekey", "taproot". + #[prost(string, tag = "10")] + pub channel_type: ::prost::alloc::string::String, +} +/// AttestationHtlc describes a single pending HTLC as recorded on the commitment. +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct AttestationHtlc { + /// True if we sent this HTLC (offered), false if received. + #[prost(bool, tag = "1")] + pub offered: bool, + #[prost(uint64, tag = "2")] + pub amount_msat: u64, + #[prost(bytes = "bytes", tag = "3")] + #[cfg_attr(feature = "serde", serde(serialize_with = "crate::serde_utils::serialize_bytes_hex"))] + pub payment_hash: ::prost::bytes::Bytes, + #[prost(uint32, tag = "4")] + pub cltv_expiry: u32, +} diff --git a/ldk-server-grpc/src/grpc.rs b/ldk-server-grpc/src/grpc.rs index bf65e4b2..af308401 100644 --- a/ldk-server-grpc/src/grpc.rs +++ b/ldk-server-grpc/src/grpc.rs @@ -748,7 +748,7 @@ mod tests { let response = client .server_streaming( - tonic::Request::new(crate::api::SubscribeEventsRequest {}), + tonic::Request::new(crate::api::SubscribeEventsRequest::default()), grpc_path(SUBSCRIBE_EVENTS_PATH).parse().unwrap(), tonic::codec::ProstCodec::default(), ) @@ -776,7 +776,7 @@ mod tests { let response = client .server_streaming( - tonic::Request::new(crate::api::SubscribeEventsRequest {}), + tonic::Request::new(crate::api::SubscribeEventsRequest::default()), grpc_path("SubscribeEventsError").parse().unwrap(), tonic::codec::ProstCodec::default(), ) diff --git a/ldk-server-grpc/src/proto/api.proto b/ldk-server-grpc/src/proto/api.proto index c3d1c9fc..5cd92be4 100644 --- a/ldk-server-grpc/src/proto/api.proto +++ b/ldk-server-grpc/src/proto/api.proto @@ -893,8 +893,41 @@ message DecodeOfferResponse { bool is_expired = 12; } +// Request a point-in-time attestation bundle for some or all channels. +message GetChannelAttestationsRequest { + // Optional channel-id filter (hex-encoded). Empty = all channels. + repeated string channel_ids = 1; +} + +// Response: one ChannelCommitmentUpdated per channel, carrying the full +// cryptographic bundle populated by `Node::export_channel_attestation`. +message GetChannelAttestationsResponse { + repeated events.ChannelCommitmentUpdated attestations = 1; +} + // Subscribe to a stream of server events. -message SubscribeEventsRequest {} +message SubscribeEventsRequest { + // Optional event-kind filter. If empty, the server emits all event kinds + // EXCEPT EVENT_KIND_CHANNEL_COMMITMENT (which is opt-in because its rate + // can be much higher than the lifecycle / payment streams). Passing a + // non-empty list restricts the stream to those kinds (including + // EVENT_KIND_CHANNEL_COMMITMENT if explicitly requested). + repeated EventKind only = 1; +} + +// Classes of events emitted on SubscribeEvents. +enum EventKind { + EVENT_KIND_UNSPECIFIED = 0; + // PaymentReceived / PaymentSuccessful / PaymentFailed / PaymentClaimable / + // PaymentForwarded. + EVENT_KIND_PAYMENT = 1; + // ChannelPending / ChannelReady / ChannelClosed. + EVENT_KIND_CHANNEL_LIFECYCLE = 2; + // ChannelCommitmentUpdated — per-commitment attestation bundles. + // Excluded from the default subscription because a busy node can emit + // these many times per second. + EVENT_KIND_CHANNEL_COMMITMENT = 3; +} service LightningNode { // Retrieve the latest node info. @@ -973,4 +1006,8 @@ service LightningNode { rpc GraphGetNode(GraphGetNodeRequest) returns (GraphGetNodeResponse); // Subscribe to a stream of server events. rpc SubscribeEvents(SubscribeEventsRequest) returns (stream events.EventEnvelope); + // Return point-in-time attestation bundles for every (or a filtered subset of) + // open channel. Used by external compliance tooling to assemble signed + // channel-state artifacts for regulators. + rpc GetChannelAttestations(GetChannelAttestationsRequest) returns (GetChannelAttestationsResponse); } diff --git a/ldk-server-grpc/src/proto/events.proto b/ldk-server-grpc/src/proto/events.proto index 5c5ce2c3..bf946a85 100644 --- a/ldk-server-grpc/src/proto/events.proto +++ b/ldk-server-grpc/src/proto/events.proto @@ -10,6 +10,12 @@ message EventEnvelope { PaymentFailed payment_failed = 4; PaymentForwarded payment_forwarded = 6; PaymentClaimable payment_claimable = 7; + // Channel-lifecycle events (RFC #134 parent scope). + ChannelPending channel_pending = 8; + ChannelReady channel_ready = 9; + ChannelClosed channel_closed = 10; + // Per-commitment-update event carrying the cryptographic attestation bundle. + ChannelCommitmentUpdated channel_commitment_updated = 11; } } @@ -42,3 +48,94 @@ message PaymentClaimable { message PaymentForwarded { types.ForwardedPayment forwarded_payment = 1; } + +// ChannelPending indicates a new channel has been created and is awaiting +// on-chain funding confirmation. +message ChannelPending { + // Hex-encoded channel id. + string channel_id = 1; + // Hex-encoded user-assigned channel id (16 bytes). + string user_channel_id = 2; + // Pubkey of the counterparty node, hex-encoded. + string counterparty_node_id = 3; + // Funding outpoint (txid:vout) as observed on chain. + string funding_txid = 4; + uint32 funding_output_index = 5; +} + +// ChannelReady indicates a channel has reached the confirmed / usable state. +message ChannelReady { + string channel_id = 1; + string user_channel_id = 2; + // May be absent in edge cases (see ldk-node docs). + optional string counterparty_node_id = 3; +} + +// ChannelClosed indicates a channel has been closed. +message ChannelClosed { + string channel_id = 1; + string user_channel_id = 2; + optional string counterparty_node_id = 3; + // Free-form closure reason string as surfaced by ldk-node. + optional string reason = 4; +} + +// ChannelCommitmentUpdated fires once per commitment-state update and carries +// the cryptographic bundle an external attestor needs to verify channel state +// against the counterparty's funding pubkey. +// +// See the RFC filed on ldk-server: +// "include commitment-bundle data on channel-state events (extends #134)" +message ChannelCommitmentUpdated { + // Channel identity. + string channel_id = 1; + string counterparty_node_id = 2; + string funding_txid = 3; + uint32 funding_outnum = 4; + + // Monotonic — increments with every new state. + uint64 commitment_number = 5; + + // The cryptographic bundle the attestor cares about. + ChannelCommitmentBundle bundle = 6; +} + +// ChannelCommitmentBundle is the verifiable per-channel-state payload. +// +// An external verifier reconstructs the BIP-143 sighash over +// holder_commitment_tx using the 2-of-2 funding redeem script assembled from +// holder_funding_pubkey / counterparty_funding_pubkey and verifies +// counterparty_signature against counterparty_funding_pubkey. That closes +// the loop independent of the operator. +message ChannelCommitmentBundle { + // Raw wire-format unsigned commitment tx (no witnesses). + bytes holder_commitment_tx = 1; + + // Counterparty's ECDSA signature over holder_commitment_tx (BIP-143 + // sighash, SIGHASH_ALL). Receivers should try DER first and fall back to + // a 64-byte raw (r||s) encoding. + bytes counterparty_signature = 2; + + // 33-byte compressed pubkeys of both funding-output participants. + bytes holder_funding_pubkey = 3; + bytes counterparty_funding_pubkey = 4; + + uint64 capacity_sats = 5; + uint64 holder_balance_msat = 6; + uint64 counterparty_balance_msat = 7; + uint64 commit_fee_sats = 8; + + repeated AttestationHtlc pending_htlcs = 9; + + // Channel type marker, e.g. "anchors", "static_remotekey", "taproot". + string channel_type = 10; +} + +// AttestationHtlc describes a single pending HTLC as recorded on the commitment. +message AttestationHtlc { + // True if we sent this HTLC (offered), false if received. + bool offered = 1; + uint64 amount_msat = 2; + bytes payment_hash = 3; + uint32 cltv_expiry = 4; +} diff --git a/ldk-server-grpc/src/serde_utils.rs b/ldk-server-grpc/src/serde_utils.rs index 5f10d137..b7de10bb 100644 --- a/ldk-server-grpc/src/serde_utils.rs +++ b/ldk-server-grpc/src/serde_utils.rs @@ -38,6 +38,18 @@ stringify_enum_serializer!(serialize_payment_direction, crate::types::PaymentDir stringify_enum_serializer!(serialize_payment_status, crate::types::PaymentStatus); stringify_enum_serializer!(serialize_balance_source, crate::types::BalanceSource); +/// Serializes `prost::bytes::Bytes` as a hex string. +pub fn serialize_bytes_hex(value: &bytes::Bytes, serializer: S) -> Result +where + S: Serializer, +{ + let hex = value.iter().fold(String::with_capacity(value.len() * 2), |mut acc, b| { + let _ = write!(acc, "{b:02x}"); + acc + }); + serializer.serialize_str(&hex) +} + /// Serializes `Option` as a hex string (or null). pub fn serialize_opt_bytes_hex( value: &Option, serializer: S, diff --git a/ldk-server/Cargo.toml b/ldk-server/Cargo.toml index bb1c10cd..9dec5bbd 100644 --- a/ldk-server/Cargo.toml +++ b/ldk-server/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -ldk-node = { git = "https://github.com/lightningdevkit/ldk-node", rev = "c754e2fe85c70741b5e370334cd16856c615265e" } +ldk-node = { git = "https://github.com/rsafier/ldk-node.git", branch = "rfc/export-channel-attestation" } serde = { version = "1.0.203", default-features = false, features = ["derive"] } hyper = { version = "1", default-features = false, features = ["server", "http2"] } http-body-util = { version = "0.1", default-features = false } diff --git a/ldk-server/src/api/get_channel_attestations.rs b/ldk-server/src/api/get_channel_attestations.rs new file mode 100644 index 00000000..549a6076 --- /dev/null +++ b/ldk-server/src/api/get_channel_attestations.rs @@ -0,0 +1,116 @@ +// This file is Copyright its original authors, visible in version control history. +// +// Licensed under the Apache License, Version 2.0 or the MIT license , at your option. + +use std::sync::Arc; + +use bytes::Bytes; +use hex::prelude::*; +use ldk_node::bitcoin::consensus::encode as btc_encode; +use ldk_node::lightning::ln::types::ChannelId; +use ldk_server_grpc::api::{GetChannelAttestationsRequest, GetChannelAttestationsResponse}; +use ldk_server_grpc::events::{ + AttestationHtlc, ChannelCommitmentBundle, ChannelCommitmentUpdated, +}; + +use crate::api::error::{LdkServerError, LdkServerErrorCode}; +use crate::service::Context; + +/// Returns per-channel cryptographic attestation bundles for the caller-requested channels +/// (all open channels if none specified). +/// +/// Backed by ldk-node's `Node::export_channel_attestation` — emits the unsigned commitment +/// transaction, counterparty signature, both funding pubkeys, balances, and pending HTLCs. +pub(crate) async fn handle_get_channel_attestations_request( + context: Arc, request: GetChannelAttestationsRequest, +) -> Result { + let want: Vec = request + .channel_ids + .iter() + .map(|hex_id| decode_channel_id(hex_id)) + .collect::, _>>()?; + + let attestations: Vec = if want.is_empty() { + // Materialize per-channel so we can surface errors instead of silently + // dropping them (the filter_map(.ok()) in Node::list_channel_attestations + // makes debugging extraction bugs opaque). + let mut out = Vec::new(); + for c in context.node.list_channels() { + match context.node.export_channel_attestation(&c.channel_id) { + Ok(att) => out.push(attestation_to_proto(att)), + Err(e) => log::warn!( + "export_channel_attestation({}) failed: {:?}", + c.channel_id.0.to_lower_hex_string(), + e + ), + } + } + out + } else { + let mut out = Vec::new(); + for cid in &want { + match context.node.export_channel_attestation(cid) { + Ok(att) => out.push(attestation_to_proto(att)), + Err(e) => { + return Err(LdkServerError::new( + LdkServerErrorCode::InternalServerError, + format!("export_channel_attestation failed for {}: {:?}", + cid.0.to_lower_hex_string(), e), + )); + }, + } + } + out + }; + + Ok(GetChannelAttestationsResponse { attestations }) +} + +fn decode_channel_id(hex_id: &str) -> Result { + let bytes = <[u8; 32]>::from_hex(hex_id).map_err(|e| { + LdkServerError::new( + LdkServerErrorCode::InvalidRequestError, + format!("invalid channel_id hex: {e}"), + ) + })?; + Ok(ChannelId(bytes)) +} + +fn attestation_to_proto( + att: ldk_node::attestation::ChannelAttestation, +) -> ChannelCommitmentUpdated { + let commit_tx_bytes = btc_encode::serialize(&att.holder_commitment_tx); + let bundle = ChannelCommitmentBundle { + holder_commitment_tx: Bytes::from(commit_tx_bytes), + counterparty_signature: Bytes::from(att.counterparty_signature.serialize_der().to_vec()), + holder_funding_pubkey: Bytes::from(att.holder_funding_pubkey.serialize().to_vec()), + counterparty_funding_pubkey: Bytes::from( + att.counterparty_funding_pubkey.serialize().to_vec(), + ), + capacity_sats: att.capacity_sats, + holder_balance_msat: att.holder_balance_msat, + counterparty_balance_msat: att.counterparty_balance_msat, + commit_fee_sats: 0, // derivable from tx outputs by the consumer + pending_htlcs: att + .pending_htlcs + .into_iter() + .map(|h| AttestationHtlc { + offered: h.offered, + amount_msat: h.amount_msat, + payment_hash: Bytes::from(h.payment_hash.to_vec()), + cltv_expiry: h.cltv_expiry, + }) + .collect(), + channel_type: String::new(), + }; + ChannelCommitmentUpdated { + channel_id: att.channel_id.0.to_lower_hex_string(), + counterparty_node_id: att.counterparty_node_id.to_string(), + funding_txid: att.funding_outpoint.txid.to_string(), + funding_outnum: att.funding_outpoint.vout, + commitment_number: att.commitment_number, + bundle: Some(bundle), + } +} diff --git a/ldk-server/src/api/mod.rs b/ldk-server/src/api/mod.rs index ec0a03f6..5ee1eda7 100644 --- a/ldk-server/src/api/mod.rs +++ b/ldk-server/src/api/mod.rs @@ -33,6 +33,7 @@ pub(crate) mod disconnect_peer; pub(crate) mod error; pub(crate) mod export_pathfinding_scores; pub(crate) mod get_balances; +pub(crate) mod get_channel_attestations; pub(crate) mod get_node_info; pub(crate) mod get_payment_details; pub(crate) mod graph_get_channel; diff --git a/ldk-server/src/main.rs b/ldk-server/src/main.rs index 30807541..e2435138 100644 --- a/ldk-server/src/main.rs +++ b/ldk-server/src/main.rs @@ -50,7 +50,10 @@ use crate::service::NodeService; 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::proto_adapter::{ + channel_closed_event_to_proto, channel_commitment_event, channel_pending_event_to_proto, + channel_ready_event_to_proto, forwarded_payment_to_proto, payment_to_proto, +}; use crate::util::systemd; use crate::util::tls::get_or_generate_tls_config; @@ -325,20 +328,78 @@ fn main() { select! { event = event_node.next_event_async() => { match event { - Event::ChannelPending { channel_id, counterparty_node_id, .. } => { + Event::ChannelPending { + channel_id, + user_channel_id, + counterparty_node_id, + funding_txo, + .. + } => { info!( "CHANNEL_PENDING: {} from counterparty {}", channel_id, counterparty_node_id ); + let pending = channel_pending_event_to_proto( + channel_id, + user_channel_id, + counterparty_node_id, + funding_txo, + ); + if let Err(e) = event_sender.send(EventEnvelope { + event: Some(event_envelope::Event::ChannelPending(pending)), + }) { + debug!("No event subscribers connected, skipping event: {e}"); + } + if let Some(commitment) = + channel_commitment_event(&event_node, channel_id) + { + if let Err(e) = event_sender.send(EventEnvelope { + event: Some(event_envelope::Event::ChannelCommitmentUpdated( + commitment, + )), + }) { + debug!( + "No event subscribers connected, skipping commitment event: {e}" + ); + } + } if let Err(e) = event_node.event_handled() { error!("Failed to mark event as handled: {e}"); } }, - Event::ChannelReady { channel_id, counterparty_node_id, .. } => { + Event::ChannelReady { + channel_id, + user_channel_id, + counterparty_node_id, + .. + } => { info!( "CHANNEL_READY: {} from counterparty {:?}", channel_id, counterparty_node_id ); + let ready = channel_ready_event_to_proto( + channel_id, + user_channel_id, + counterparty_node_id, + ); + if let Err(e) = event_sender.send(EventEnvelope { + event: Some(event_envelope::Event::ChannelReady(ready)), + }) { + debug!("No event subscribers connected, skipping event: {e}"); + } + if let Some(commitment) = + channel_commitment_event(&event_node, channel_id) + { + if let Err(e) = event_sender.send(EventEnvelope { + event: Some(event_envelope::Event::ChannelCommitmentUpdated( + commitment, + )), + }) { + debug!( + "No event subscribers connected, skipping commitment event: {e}" + ); + } + } if let Err(e) = event_node.event_handled() { error!("Failed to mark event as handled: {e}"); } @@ -347,11 +408,28 @@ fn main() { metrics.update_channels_count(false); } }, - Event::ChannelClosed { channel_id, counterparty_node_id, .. } => { + Event::ChannelClosed { + channel_id, + user_channel_id, + counterparty_node_id, + reason, + .. + } => { info!( "CHANNEL_CLOSED: {} from counterparty {:?}", channel_id, counterparty_node_id ); + let closed = channel_closed_event_to_proto( + channel_id, + user_channel_id, + counterparty_node_id, + reason.map(|r| r.to_string()), + ); + if let Err(e) = event_sender.send(EventEnvelope { + event: Some(event_envelope::Event::ChannelClosed(closed)), + }) { + debug!("No event subscribers connected, skipping event: {e}"); + } if let Err(e) = event_node.event_handled() { error!("Failed to mark event as handled: {e}"); } diff --git a/ldk-server/src/service.rs b/ldk-server/src/service.rs index 452c1699..629ea727 100644 --- a/ldk-server/src/service.rs +++ b/ldk-server/src/service.rs @@ -26,12 +26,14 @@ use ldk_server_grpc::endpoints::{ DISCONNECT_PEER_PATH, EXPORT_PATHFINDING_SCORES_PATH, FORCE_CLOSE_CHANNEL_PATH, GET_BALANCES_PATH, GET_METRICS_PATH, GET_NODE_INFO_PATH, GET_PAYMENT_DETAILS_PATH, GRAPH_GET_CHANNEL_PATH, GRAPH_GET_NODE_PATH, GRAPH_LIST_CHANNELS_PATH, GRAPH_LIST_NODES_PATH, - LIST_CHANNELS_PATH, LIST_FORWARDED_PAYMENTS_PATH, LIST_PAYMENTS_PATH, LIST_PEERS_PATH, + GET_CHANNEL_ATTESTATIONS_PATH, 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, }; -use ldk_server_grpc::events::EventEnvelope; +use ldk_server_grpc::api::{EventKind, SubscribeEventsRequest}; +use ldk_server_grpc::events::{event_envelope, EventEnvelope}; use ldk_server_grpc::grpc::{ decode_grpc_body, encode_grpc_frame, grpc_error_response, grpc_response, parse_grpc_timeout, validate_grpc_request, GrpcBody, GrpcStatus, GRPC_STATUS_DEADLINE_EXCEEDED, @@ -66,6 +68,7 @@ use crate::api::graph_get_channel::handle_graph_get_channel_request; use crate::api::graph_get_node::handle_graph_get_node_request; use crate::api::graph_list_channels::handle_graph_list_channels_request; use crate::api::graph_list_nodes::handle_graph_list_nodes_request; +use crate::api::get_channel_attestations::handle_get_channel_attestations_request; use crate::api::list_channels::handle_list_channels_request; use crate::api::list_forwarded_payments::handle_list_forwarded_payments_request; use crate::api::list_payments::handle_list_payments_request; @@ -309,6 +312,11 @@ impl Service> for NodeService { LIST_CHANNELS_PATH => { Box::pin(handle_grpc_unary(context, req, handle_list_channels_request)) }, + GET_CHANNEL_ATTESTATIONS_PATH => Box::pin(handle_grpc_unary( + context, + req, + handle_get_channel_attestations_request, + )), UPDATE_CHANNEL_CONFIG_PATH => { Box::pin(handle_grpc_unary(context, req, handle_update_channel_config_request)) }, @@ -363,6 +371,27 @@ impl Service> for NodeService { let event_sender = self.event_sender.clone(); let mut shutdown_rx = self.shutdown_rx.clone(); Box::pin(async move { + // Read the client's SubscribeEventsRequest before starting the stream so we + // can honor its event-kind filter. + let limited_body = Limited::new(req.into_body(), MAX_BODY_SIZE); + let bytes = match limited_body.collect().await { + Ok(collected) => collected.to_bytes(), + Err(_) => { + return Ok(grpc_error_response(GrpcStatus::new( + GRPC_STATUS_INVALID_ARGUMENT, + "Request body too large or failed to read", + ))); + }, + }; + let subscribe_req = match decode_grpc_body(&bytes) + .and_then(|b| SubscribeEventsRequest::decode(b).map_err(|_| { + GrpcStatus::new(GRPC_STATUS_INVALID_ARGUMENT, "Malformed request") + })) { + Ok(r) => r, + Err(status) => return Ok(grpc_error_response(status)), + }; + let filter = EventFilter::from_request(&subscribe_req); + let mut rx = event_sender.subscribe(); let (tx, mpsc_rx) = mpsc::channel::>(64); tokio::spawn(async move { @@ -381,6 +410,9 @@ impl Service> for NodeService { result = rx.recv() => { match result { Ok(event) => { + if !filter.permits(&event) { + continue; + } let frame = encode_grpc_frame(&event.encode_to_vec()); if tx.send(Ok(frame)).await.is_err() { break; // client disconnected @@ -475,6 +507,70 @@ async fn handle_grpc_unary< } } +/// Event-kind filter for `SubscribeEvents`. +/// +/// If the caller passes a non-empty `only` list, the stream is restricted +/// to exactly those kinds. If `only` is empty, the stream emits every kind +/// *except* `EVENT_KIND_CHANNEL_COMMITMENT` — commitment bundles fire per +/// state update and are opt-in to avoid surprising existing subscribers. +struct EventFilter { + allow_payment: bool, + allow_channel_lifecycle: bool, + allow_channel_commitment: bool, +} + +impl EventFilter { + fn from_request(req: &SubscribeEventsRequest) -> Self { + if req.only.is_empty() { + return Self { + allow_payment: true, + allow_channel_lifecycle: true, + allow_channel_commitment: false, + }; + } + let mut f = Self { + allow_payment: false, + allow_channel_lifecycle: false, + allow_channel_commitment: false, + }; + for kind in &req.only { + match EventKind::from_i32(*kind).unwrap_or(EventKind::Unspecified) { + EventKind::Payment => f.allow_payment = true, + EventKind::ChannelLifecycle => f.allow_channel_lifecycle = true, + EventKind::ChannelCommitment => f.allow_channel_commitment = true, + EventKind::Unspecified => {}, + } + } + f + } + + fn permits(&self, envelope: &EventEnvelope) -> bool { + match classify(envelope) { + Some(EventKind::Payment) => self.allow_payment, + Some(EventKind::ChannelLifecycle) => self.allow_channel_lifecycle, + Some(EventKind::ChannelCommitment) => self.allow_channel_commitment, + // Unknown / new event kinds default to allowed — the server shouldn't silently + // drop events a client is running a newer proto against. + _ => true, + } + } +} + +fn classify(envelope: &EventEnvelope) -> Option { + use event_envelope::Event; + match envelope.event.as_ref()? { + Event::PaymentReceived(_) + | Event::PaymentSuccessful(_) + | Event::PaymentFailed(_) + | Event::PaymentForwarded(_) + | Event::PaymentClaimable(_) => Some(EventKind::Payment), + Event::ChannelPending(_) | Event::ChannelReady(_) | Event::ChannelClosed(_) => { + Some(EventKind::ChannelLifecycle) + }, + Event::ChannelCommitmentUpdated(_) => Some(EventKind::ChannelCommitment), + } +} + /// Map an `LdkServerError` to a `GrpcStatus`. pub(crate) fn ldk_error_to_grpc_status(e: LdkServerError) -> GrpcStatus { let code = match e.error_code { @@ -557,4 +653,67 @@ mod tests { assert!(result.is_err()); assert_eq!(result.unwrap_err().error_code, LdkServerErrorCode::AuthError); } + + fn env(event: event_envelope::Event) -> EventEnvelope { + EventEnvelope { event: Some(event) } + } + + fn payment_event() -> EventEnvelope { + env(event_envelope::Event::PaymentReceived(ldk_server_grpc::events::PaymentReceived { + payment: None, + })) + } + + fn lifecycle_event() -> EventEnvelope { + env(event_envelope::Event::ChannelReady(ldk_server_grpc::events::ChannelReady { + channel_id: "c".into(), + user_channel_id: "u".into(), + counterparty_node_id: None, + })) + } + + fn commitment_event() -> EventEnvelope { + env(event_envelope::Event::ChannelCommitmentUpdated( + ldk_server_grpc::events::ChannelCommitmentUpdated::default(), + )) + } + + #[test] + fn test_event_filter_default_excludes_commitment() { + let f = EventFilter::from_request(&SubscribeEventsRequest::default()); + assert!(f.permits(&payment_event())); + assert!(f.permits(&lifecycle_event())); + assert!(!f.permits(&commitment_event())); + } + + #[test] + fn test_event_filter_only_commitment() { + let f = EventFilter::from_request(&SubscribeEventsRequest { + only: vec![EventKind::ChannelCommitment as i32], + }); + assert!(!f.permits(&payment_event())); + assert!(!f.permits(&lifecycle_event())); + assert!(f.permits(&commitment_event())); + } + + #[test] + fn test_event_filter_multiple_kinds() { + let f = EventFilter::from_request(&SubscribeEventsRequest { + only: vec![EventKind::Payment as i32, EventKind::ChannelCommitment as i32], + }); + assert!(f.permits(&payment_event())); + assert!(!f.permits(&lifecycle_event())); + assert!(f.permits(&commitment_event())); + } + + #[test] + fn test_event_filter_ignores_unknown_enum_values() { + // Unspecified / out-of-range enum values must not broaden the filter. + let f = EventFilter::from_request(&SubscribeEventsRequest { + only: vec![EventKind::Unspecified as i32, 999], + }); + assert!(!f.permits(&payment_event())); + assert!(!f.permits(&lifecycle_event())); + assert!(!f.permits(&commitment_event())); + } } diff --git a/ldk-server/src/util/proto_adapter.rs b/ldk-server/src/util/proto_adapter.rs index 28cc7ec1..fcd477be 100644 --- a/ldk-server/src/util/proto_adapter.rs +++ b/ldk-server/src/util/proto_adapter.rs @@ -21,7 +21,9 @@ use ldk_node::lightning_invoice::{Bolt11InvoiceDescription, Description, Sha256} use ldk_node::payment::{ ConfirmationStatus, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, }; -use ldk_node::{ChannelDetails, LightningBalance, PeerDetails, PendingSweepBalance, UserChannelId}; +use ldk_node::{ + ChannelDetails, LightningBalance, Node, PeerDetails, PendingSweepBalance, UserChannelId, +}; use ldk_server_grpc::types::confirmation_status::Status::{Confirmed, Unconfirmed}; use ldk_server_grpc::types::lightning_balance::BalanceType::{ ClaimableAwaitingConfirmations, ClaimableOnChannelClose, ContentiousClaimable, @@ -33,6 +35,9 @@ use ldk_server_grpc::types::payment_kind::Kind::{ use ldk_server_grpc::types::pending_sweep_balance::BalanceType::{ AwaitingThresholdConfirmations, BroadcastAwaitingConfirmation, PendingBroadcast, }; +use ldk_server_grpc::events::{ + ChannelClosed, ChannelCommitmentBundle, ChannelCommitmentUpdated, ChannelPending, ChannelReady, +}; use ldk_server_grpc::types::{ bolt11_invoice_description, Channel, ForwardedPayment, LspFeeLimits, OutPoint, Payment, Peer, }; @@ -40,6 +45,96 @@ use ldk_server_grpc::types::{ use crate::api::error::LdkServerError; use crate::api::error::LdkServerErrorCode::InvalidRequestError; +/// Build a ChannelPending event proto from ldk-node's `Event::ChannelPending` fields. +pub(crate) fn channel_pending_event_to_proto( + channel_id: ChannelId, user_channel_id: UserChannelId, counterparty_node_id: PublicKey, + funding_txo: ldk_node::bitcoin::OutPoint, +) -> ChannelPending { + ChannelPending { + channel_id: channel_id.0.to_lower_hex_string(), + user_channel_id: user_channel_id.0.to_string(), + counterparty_node_id: counterparty_node_id.to_string(), + funding_txid: funding_txo.txid.to_string(), + funding_output_index: funding_txo.vout, + } +} + +/// Build a ChannelReady event proto. +pub(crate) fn channel_ready_event_to_proto( + channel_id: ChannelId, user_channel_id: UserChannelId, + counterparty_node_id: Option, +) -> ChannelReady { + ChannelReady { + channel_id: channel_id.0.to_lower_hex_string(), + user_channel_id: user_channel_id.0.to_string(), + counterparty_node_id: counterparty_node_id.map(|p| p.to_string()), + } +} + +/// Build a ChannelClosed event proto. +pub(crate) fn channel_closed_event_to_proto( + channel_id: ChannelId, user_channel_id: UserChannelId, + counterparty_node_id: Option, reason: Option, +) -> ChannelClosed { + ChannelClosed { + channel_id: channel_id.0.to_lower_hex_string(), + user_channel_id: user_channel_id.0.to_string(), + counterparty_node_id: counterparty_node_id.map(|p| p.to_string()), + reason, + } +} + +/// Build a ChannelCommitmentUpdated event for the given `channel_id` by looking the +/// channel up via `Node::list_channels()`. +/// +/// Returns `None` if the channel is no longer tracked (e.g. it closed before the +/// event handler ran). +/// +/// NOTE: This is a best-effort implementation against the fields exposed by the +/// current ldk-node API. The cryptographic bundle fields — holder_commitment_tx, +/// counterparty_signature, and both funding pubkeys — are left empty pending the +/// ldk-node RFC that adds `Node::export_channel_attestation()`. The companion +/// RFC on ldk-server is explicit that those fields come from that API; until it +/// lands, a consumer receives a correctly-shaped event with the metadata filled +/// in and the crypto fields blank, and can detect the stub by checking that +/// `holder_commitment_tx` is empty. +/// +/// Balances use `outbound_capacity_msat` / `inbound_capacity_msat` as a proxy. +/// The ldk-node RFC's `export_channel_attestation` will expose the commitment- +/// derived balances directly; replacing this helper is the migration path. +pub(crate) fn channel_commitment_event( + node: &Node, channel_id: ChannelId, +) -> Option { + let channel = node.list_channels().into_iter().find(|c| c.channel_id == channel_id)?; + let (funding_txid, funding_outnum) = match channel.funding_txo { + Some(o) => (o.txid.to_string(), o.vout), + None => (String::new(), 0), + }; + let bundle = ChannelCommitmentBundle { + // TODO: populate from ldk-node `Node::export_channel_attestation()` once that + // API lands (RFC: "expose channel attestation data"). + holder_commitment_tx: Bytes::new(), + counterparty_signature: Bytes::new(), + holder_funding_pubkey: Bytes::new(), + counterparty_funding_pubkey: Bytes::new(), + capacity_sats: channel.channel_value_sats, + holder_balance_msat: channel.outbound_capacity_msat, + counterparty_balance_msat: channel.inbound_capacity_msat, + commit_fee_sats: 0, + pending_htlcs: Vec::new(), + channel_type: String::new(), + }; + Some(ChannelCommitmentUpdated { + channel_id: channel.channel_id.0.to_lower_hex_string(), + counterparty_node_id: channel.counterparty_node_id.to_string(), + funding_txid, + funding_outnum, + // TODO: use the ChannelMonitor commitment_number once exposed. + commitment_number: 0, + bundle: Some(bundle), + }) +} + pub(crate) fn peer_to_proto(peer: PeerDetails) -> Peer { Peer { node_id: peer.node_id.to_string(),