Skip to content

Commit ee77107

Browse files
Expose blinded payment paths in decoded BOLT12 invoices
`DecodeInvoiceResponse` previously dropped the blinded payment paths of a decoded BOLT12 invoice. It now carries them in a new `paths` field, mirroring the `paths` already exposed for decoded BOLT12 offers. BOLT11 invoices leave the field empty, as they carry route hints instead. The blinded-path-to-proto conversion is extracted from `decode_offer` into a shared `blinded_path_to_proto` helper, so offer decoding (blinded message paths) and invoice decoding (blinded payment paths) build the same `BlindedPath` representation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bcdb005 commit ee77107

5 files changed

Lines changed: 75 additions & 37 deletions

File tree

ldk-server-grpc/src/api.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1123,7 +1123,8 @@ pub struct DecodeInvoiceRequest {
11231123
}
11241124
/// The response for the `DecodeInvoice` RPC. On failure, a gRPC error status is returned.
11251125
/// `kind` indicates which invoice type was decoded; fields that do not apply to that type
1126-
/// are left empty (e.g. `payment_secret` and `route_hints` are BOLT11-only).
1126+
/// are left empty (e.g. `payment_secret` and `route_hints` are BOLT11-only, `paths` is
1127+
/// BOLT12-only).
11271128
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
11281129
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
11291130
#[cfg_attr(feature = "serde", serde(default))]
@@ -1178,6 +1179,9 @@ pub struct DecodeInvoiceResponse {
11781179
/// The kind of decoded invoice: "bolt11" or "bolt12".
11791180
#[prost(string, tag = "16")]
11801181
pub kind: ::prost::alloc::string::String,
1182+
/// Blinded payment paths to the recipient. Only present for BOLT12 invoices.
1183+
#[prost(message, repeated, tag = "17")]
1184+
pub paths: ::prost::alloc::vec::Vec<super::types::BlindedPath>,
11811185
}
11821186
/// Decode a BOLT12 offer and return its parsed fields.
11831187
/// This does not require a running node — it only parses the offer string.

ldk-server-grpc/src/proto/api.proto

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -804,7 +804,8 @@ message DecodeInvoiceRequest {
804804

805805
// The response for the `DecodeInvoice` RPC. On failure, a gRPC error status is returned.
806806
// `kind` indicates which invoice type was decoded; fields that do not apply to that type
807-
// are left empty (e.g. `payment_secret` and `route_hints` are BOLT11-only).
807+
// are left empty (e.g. `payment_secret` and `route_hints` are BOLT11-only, `paths` is
808+
// BOLT12-only).
808809
message DecodeInvoiceResponse {
809810
// The hex-encoded public key of the destination node.
810811
string destination = 1;
@@ -853,6 +854,9 @@ message DecodeInvoiceResponse {
853854

854855
// The kind of decoded invoice: "bolt11" or "bolt12".
855856
string kind = 16;
857+
858+
// Blinded payment paths to the recipient. Only present for BOLT12 invoices.
859+
repeated types.BlindedPath paths = 17;
856860
}
857861

858862
// Decode a BOLT12 offer and return its parsed fields.

ldk-server/src/api/decode_invoice.rs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ use ldk_node::lightning_types::features::{Bolt11InvoiceFeatures, Bolt12InvoiceFe
1717
use ldk_server_grpc::api::{DecodeInvoiceRequest, DecodeInvoiceResponse};
1818
use ldk_server_grpc::types::{Bolt11HopHint, Bolt11RouteHint};
1919

20-
use crate::api::decode_features;
2120
use crate::api::error::LdkServerError;
21+
use crate::api::{blinded_path_to_proto, decode_features};
2222
use crate::service::Context;
2323

2424
const INVOICE_KIND_BOLT11: &str = "bolt11";
@@ -57,6 +57,18 @@ fn decode_bolt12_invoice(invoice: &str) -> Option<DecodeInvoiceResponse> {
5757
Bolt12InvoiceFeatures::from_le_bytes(bytes).to_string()
5858
});
5959

60+
let paths = invoice
61+
.payment_paths()
62+
.iter()
63+
.map(|path| {
64+
blinded_path_to_proto(
65+
path.introduction_node(),
66+
path.blinding_point(),
67+
path.blinded_hops().len(),
68+
)
69+
})
70+
.collect();
71+
6072
Some(DecodeInvoiceResponse {
6173
destination: invoice.signing_pubkey().to_string(),
6274
payment_hash: invoice.payment_hash().0.to_lower_hex_string(),
@@ -68,6 +80,7 @@ fn decode_bolt12_invoice(invoice: &str) -> Option<DecodeInvoiceResponse> {
6880
features,
6981
is_expired: invoice.is_expired(),
7082
kind: INVOICE_KIND_BOLT12.to_string(),
83+
paths,
7184
..Default::default()
7285
})
7386
}
@@ -149,6 +162,8 @@ fn decode_bolt11_invoice(invoice: &Bolt11Invoice) -> DecodeInvoiceResponse {
149162
payment_metadata,
150163
is_expired,
151164
kind: INVOICE_KIND_BOLT11.to_string(),
165+
// BOLT11 invoices carry route hints rather than blinded paths.
166+
paths: Vec::new(),
152167
}
153168
}
154169

@@ -162,6 +177,7 @@ mod tests {
162177
use ldk_node::lightning::types::features::BlindedHopFeatures;
163178
use ldk_node::lightning::types::payment::PaymentHash;
164179
use ldk_node::lightning::util::ser::Writeable;
180+
use ldk_server_grpc::types::blinded_path::IntroductionNode;
165181

166182
use super::*;
167183

@@ -237,5 +253,13 @@ mod tests {
237253
assert_eq!(response.amount_msat, Some(1_000));
238254
assert_eq!(response.expiry, 3600);
239255
assert!(!response.is_expired);
256+
257+
// The sample invoice carries a single blinded payment path with two hops,
258+
// introduced by `pubkey(40)` and blinded with `pubkey(41)`.
259+
assert_eq!(response.paths.len(), 1);
260+
let path = &response.paths[0];
261+
assert_eq!(path.num_hops, 2);
262+
assert_eq!(path.blinding_point, pubkey(41).to_string());
263+
assert_eq!(path.introduction_node, Some(IntroductionNode::NodeId(pubkey(40).to_string())));
240264
}
241265
}

ldk-server/src/api/decode_offer.rs

Lines changed: 7 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,12 @@ use ldk_node::lightning::bitcoin::Network;
1616
use ldk_node::lightning::offers::offer::Offer;
1717
use ldk_node::lightning_types::features::OfferFeatures;
1818
use ldk_server_grpc::api::{DecodeOfferRequest, DecodeOfferResponse};
19-
use ldk_server_grpc::types::blinded_path::IntroductionNode;
2019
use ldk_server_grpc::types::offer_amount::Amount;
2120
use ldk_server_grpc::types::offer_quantity::Quantity;
22-
use ldk_server_grpc::types::{
23-
BlindedPath, ChannelDirection, CurrencyAmount, DirectedShortChannelId, OfferAmount,
24-
OfferQuantity,
25-
};
21+
use ldk_server_grpc::types::{CurrencyAmount, OfferAmount, OfferQuantity};
2622

27-
use crate::api::decode_features;
2823
use crate::api::error::LdkServerError;
24+
use crate::api::{blinded_path_to_proto, decode_features};
2925
use crate::service::Context;
3026

3127
pub(crate) async fn handle_decode_offer_request(
@@ -74,33 +70,11 @@ pub(crate) async fn handle_decode_offer_request(
7470
.paths()
7571
.iter()
7672
.map(|path| {
77-
let introduction_node = match path.introduction_node() {
78-
ldk_node::lightning::blinded_path::IntroductionNode::NodeId(pk) => {
79-
IntroductionNode::NodeId(pk.to_string())
80-
},
81-
ldk_node::lightning::blinded_path::IntroductionNode::DirectedShortChannelId(
82-
dir,
83-
scid,
84-
) => {
85-
let direction = match dir {
86-
ldk_node::lightning::blinded_path::Direction::NodeOne => {
87-
ChannelDirection::NodeOne
88-
},
89-
ldk_node::lightning::blinded_path::Direction::NodeTwo => {
90-
ChannelDirection::NodeTwo
91-
},
92-
};
93-
IntroductionNode::DirectedScid(DirectedShortChannelId {
94-
scid: *scid,
95-
direction: direction as i32,
96-
})
97-
},
98-
};
99-
BlindedPath {
100-
introduction_node: Some(introduction_node),
101-
blinding_point: path.blinding_point().to_string(),
102-
num_hops: path.blinded_hops().len() as u32,
103-
}
73+
blinded_path_to_proto(
74+
path.introduction_node(),
75+
path.blinding_point(),
76+
path.blinded_hops().len(),
77+
)
10478
})
10579
.collect();
10680

ldk-server/src/api/mod.rs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,14 @@
1010
use std::collections::HashMap;
1111

1212
use ldk_node::config::{ChannelConfig, MaxDustHTLCExposure};
13+
use ldk_node::lightning::bitcoin::secp256k1::PublicKey;
14+
use ldk_node::lightning::blinded_path::{Direction, IntroductionNode};
1315
use ldk_node::lightning::routing::router::RouteParametersConfig;
16+
use ldk_server_grpc::types::blinded_path::IntroductionNode as ProtoIntroductionNode;
1417
use ldk_server_grpc::types::channel_config::MaxDustHtlcExposure;
15-
use ldk_server_grpc::types::Bolt11Feature;
18+
use ldk_server_grpc::types::{
19+
BlindedPath, Bolt11Feature, ChannelDirection, DirectedShortChannelId,
20+
};
1621

1722
use crate::api::error::LdkServerError;
1823
use crate::api::error::LdkServerErrorCode::InvalidRequestError;
@@ -129,6 +134,33 @@ pub(crate) fn build_route_parameters_config_from_proto(
129134
}
130135
}
131136

137+
/// Converts a blinded path into its proto representation. Shared by BOLT12 offer and
138+
/// invoice decoding: offers expose blinded message paths and invoices expose blinded
139+
/// payment paths, but both carry the same introduction node, blinding point, and hop
140+
/// count surfaced here.
141+
pub(crate) fn blinded_path_to_proto(
142+
introduction_node: &IntroductionNode, blinding_point: PublicKey, num_hops: usize,
143+
) -> BlindedPath {
144+
let introduction_node = match introduction_node {
145+
IntroductionNode::NodeId(node_id) => ProtoIntroductionNode::NodeId(node_id.to_string()),
146+
IntroductionNode::DirectedShortChannelId(direction, scid) => {
147+
let direction = match direction {
148+
Direction::NodeOne => ChannelDirection::NodeOne,
149+
Direction::NodeTwo => ChannelDirection::NodeTwo,
150+
};
151+
ProtoIntroductionNode::DirectedScid(DirectedShortChannelId {
152+
scid: *scid,
153+
direction: direction as i32,
154+
})
155+
},
156+
};
157+
BlindedPath {
158+
introduction_node: Some(introduction_node),
159+
blinding_point: blinding_point.to_string(),
160+
num_hops: num_hops as u32,
161+
}
162+
}
163+
132164
/// Decodes feature flags into a map keyed by bit number. Feature names are derived
133165
/// from LDK's `Features::Display` impl, so they stay in sync automatically.
134166
///

0 commit comments

Comments
 (0)