From 499a6c7248ccd391f4063689d4a6564a5cb58f39 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 19 May 2026 11:56:47 -0500 Subject: [PATCH] Expose introduction node direction in decoded offers A BOLT12 blinded path's introduction node may be identified by a directed short channel ID (SCID plus a direction byte selecting one of the channel's two endpoints), not just by node id. Previously the decoded offer dropped the direction byte, leaving callers unable to tell which endpoint was the introduction node. The two identification forms were also represented as independent optional fields, so the schema did not enforce that exactly one was set; mutual exclusivity relied on server convention. Restructure `BlindedPath` so the introduction node is a `oneof` between a `node_id` string and a new `DirectedShortChannelId` message carrying both the SCID and a `ChannelDirection` enum. Populate it from the LDK `IntroductionNode::DirectedShortChannelId` variant. Co-Authored-By: Claude --- ldk-server-grpc/build.rs | 4 ++ ldk-server-grpc/src/proto/types.proto | 38 +++++++++--- ldk-server-grpc/src/serde_utils.rs | 1 + ldk-server-grpc/src/types.rs | 84 +++++++++++++++++++++++---- ldk-server/src/api/decode_offer.rs | 30 +++++++--- 5 files changed, 132 insertions(+), 25 deletions(-) diff --git a/ldk-server-grpc/build.rs b/ldk-server-grpc/build.rs index 726d3b4f..14a35806 100644 --- a/ldk-server-grpc/build.rs +++ b/ldk-server-grpc/build.rs @@ -74,6 +74,10 @@ fn generate_protos() { "api.GetNodeInfoResponse.network", "#[cfg_attr(feature = \"serde\", serde(serialize_with = \"crate::serde_utils::serialize_network\"))]", ) + .field_attribute( + "types.DirectedShortChannelId.direction", + "#[cfg_attr(feature = \"serde\", serde(serialize_with = \"crate::serde_utils::serialize_channel_direction\"))]", + ) .field_attribute( "api.UnifiedSendResponse.payment_result", "#[cfg_attr(feature = \"serde\", serde(flatten))]", diff --git a/ldk-server-grpc/src/proto/types.proto b/ldk-server-grpc/src/proto/types.proto index ae1f8e2e..83be8be8 100644 --- a/ldk-server-grpc/src/proto/types.proto +++ b/ldk-server-grpc/src/proto/types.proto @@ -897,19 +897,41 @@ message OfferQuantity { // A blinded path to the offer recipient. message BlindedPath { - // The hex-encoded public key of the introduction node, if available. - // If the introduction node is a directed short channel ID, this will be empty - // and `introduction_scid` will be set instead. - optional string introduction_node_id = 1; + // Identifies the introduction node of the blinded path, either directly by + // node id or indirectly via a directed short channel ID. + oneof introduction_node { + // The hex-encoded public key of the introduction node. + string node_id = 1; + + // The directed short channel ID identifying the introduction node. + DirectedShortChannelId directed_scid = 2; + } // The hex-encoded blinding point. - string blinding_point = 2; + string blinding_point = 3; // The number of blinded hops in the path. - uint32 num_hops = 3; + uint32 num_hops = 4; +} + +// A short channel ID together with a direction byte identifying one of the +// channel's two endpoints. +message DirectedShortChannelId { + // The short channel ID. + uint64 scid = 1; + + // Which endpoint of the channel is being referred to. + ChannelDirection direction = 2; +} + +// Identifies one of the two endpoints of a channel, by lexicographic order of +// node ids. +enum ChannelDirection { + // The endpoint whose node id is lexicographically smaller. + NODE_ONE = 0; - // If the introduction node is a directed short channel ID rather than a node ID. - optional uint64 introduction_scid = 4; + // The endpoint whose node id is lexicographically greater. + NODE_TWO = 1; } // A feature bit advertised in a BOLT11 invoice. diff --git a/ldk-server-grpc/src/serde_utils.rs b/ldk-server-grpc/src/serde_utils.rs index 588bfc49..d4daed34 100644 --- a/ldk-server-grpc/src/serde_utils.rs +++ b/ldk-server-grpc/src/serde_utils.rs @@ -38,6 +38,7 @@ 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); stringify_enum_serializer!(serialize_network, crate::types::Network); +stringify_enum_serializer!(serialize_channel_direction, crate::types::ChannelDirection); /// Serializes `Option` as a hex string (or null). pub fn serialize_opt_bytes_hex( diff --git a/ldk-server-grpc/src/types.rs b/ldk-server-grpc/src/types.rs index 417fe391..67e52ca8 100644 --- a/ldk-server-grpc/src/types.rs +++ b/ldk-server-grpc/src/types.rs @@ -1180,20 +1180,52 @@ pub mod offer_quantity { #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct BlindedPath { - /// The hex-encoded public key of the introduction node, if available. - /// If the introduction node is a directed short channel ID, this will be empty - /// and `introduction_scid` will be set instead. - #[prost(string, optional, tag = "1")] - pub introduction_node_id: ::core::option::Option<::prost::alloc::string::String>, /// The hex-encoded blinding point. - #[prost(string, tag = "2")] + #[prost(string, tag = "3")] pub blinding_point: ::prost::alloc::string::String, /// The number of blinded hops in the path. - #[prost(uint32, tag = "3")] + #[prost(uint32, tag = "4")] pub num_hops: u32, - /// If the introduction node is a directed short channel ID rather than a node ID. - #[prost(uint64, optional, tag = "4")] - pub introduction_scid: ::core::option::Option, + /// Identifies the introduction node of the blinded path, either directly by + /// node id or indirectly via a directed short channel ID. + #[prost(oneof = "blinded_path::IntroductionNode", tags = "1, 2")] + pub introduction_node: ::core::option::Option, +} +/// Nested message and enum types in `BlindedPath`. +pub mod blinded_path { + /// Identifies the introduction node of the blinded path, either directly by + /// node id or indirectly via a directed short channel ID. + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] + #[allow(clippy::derive_partial_eq_without_eq)] + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum IntroductionNode { + /// The hex-encoded public key of the introduction node. + #[prost(string, tag = "1")] + NodeId(::prost::alloc::string::String), + /// The directed short channel ID identifying the introduction node. + #[prost(message, tag = "2")] + DirectedScid(super::DirectedShortChannelId), + } +} +/// A short channel ID together with a direction byte identifying one of the +/// channel's two endpoints. +#[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 DirectedShortChannelId { + /// The short channel ID. + #[prost(uint64, tag = "1")] + pub scid: u64, + /// Which endpoint of the channel is being referred to. + #[prost(enumeration = "ChannelDirection", tag = "2")] + #[cfg_attr( + feature = "serde", + serde(serialize_with = "crate::serde_utils::serialize_channel_direction") + )] + pub direction: i32, } /// A feature bit advertised in a BOLT11 invoice. #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -1361,3 +1393,35 @@ impl BalanceSource { } } } +/// Identifies one of the two endpoints of a channel, by lexicographic order of +/// node ids. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum ChannelDirection { + /// The endpoint whose node id is lexicographically smaller. + NodeOne = 0, + /// The endpoint whose node id is lexicographically greater. + NodeTwo = 1, +} +impl ChannelDirection { + /// 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 { + ChannelDirection::NodeOne => "NODE_ONE", + ChannelDirection::NodeTwo => "NODE_TWO", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "NODE_ONE" => Some(Self::NodeOne), + "NODE_TWO" => Some(Self::NodeTwo), + _ => None, + } + } +} diff --git a/ldk-server/src/api/decode_offer.rs b/ldk-server/src/api/decode_offer.rs index 6d341004..7669ba5d 100644 --- a/ldk-server/src/api/decode_offer.rs +++ b/ldk-server/src/api/decode_offer.rs @@ -16,9 +16,13 @@ use ldk_node::lightning::bitcoin::Network; use ldk_node::lightning::offers::offer::Offer; use ldk_node::lightning_types::features::OfferFeatures; use ldk_server_grpc::api::{DecodeOfferRequest, DecodeOfferResponse}; +use ldk_server_grpc::types::blinded_path::IntroductionNode; use ldk_server_grpc::types::offer_amount::Amount; use ldk_server_grpc::types::offer_quantity::Quantity; -use ldk_server_grpc::types::{BlindedPath, CurrencyAmount, OfferAmount, OfferQuantity}; +use ldk_server_grpc::types::{ + BlindedPath, ChannelDirection, CurrencyAmount, DirectedShortChannelId, OfferAmount, + OfferQuantity, +}; use crate::api::decode_features; use crate::api::error::LdkServerError; @@ -70,20 +74,32 @@ pub(crate) async fn handle_decode_offer_request( .paths() .iter() .map(|path| { - let (introduction_node_id, introduction_scid) = match path.introduction_node() { + let introduction_node = match path.introduction_node() { ldk_node::lightning::blinded_path::IntroductionNode::NodeId(pk) => { - (Some(pk.to_string()), None) + IntroductionNode::NodeId(pk.to_string()) }, ldk_node::lightning::blinded_path::IntroductionNode::DirectedShortChannelId( - _dir, + dir, scid, - ) => (None, Some(*scid)), + ) => { + let direction = match dir { + ldk_node::lightning::blinded_path::Direction::NodeOne => { + ChannelDirection::NodeOne + }, + ldk_node::lightning::blinded_path::Direction::NodeTwo => { + ChannelDirection::NodeTwo + }, + }; + IntroductionNode::DirectedScid(DirectedShortChannelId { + scid: *scid, + direction: direction as i32, + }) + }, }; BlindedPath { - introduction_node_id, + introduction_node: Some(introduction_node), blinding_point: path.blinding_point().to_string(), num_hops: path.blinded_hops().len() as u32, - introduction_scid, } }) .collect();