diff --git a/e2e-tests/tests/e2e.rs b/e2e-tests/tests/e2e.rs index 641cf623..184dfe03 100644 --- a/e2e-tests/tests/e2e.rs +++ b/e2e-tests/tests/e2e.rs @@ -193,6 +193,7 @@ async fn test_cli_decode_invoice() { assert!(decoded["timestamp"].as_u64().unwrap() > 0); assert!(decoded["min_final_cltv_expiry_delta"].as_u64().unwrap() > 0); assert_eq!(decoded["is_expired"], false); + assert_eq!(decoded["kind"], "bolt11"); // Verify features — LDK BOLT11 invoices always set VariableLengthOnion, PaymentSecret, // and BasicMPP. diff --git a/e2e-tests/tests/mcp.rs b/e2e-tests/tests/mcp.rs index 6fe137e8..5028c4a2 100644 --- a/e2e-tests/tests/mcp.rs +++ b/e2e-tests/tests/mcp.rs @@ -84,4 +84,5 @@ async fn test_mcp_live_tool_calls() { assert_eq!(decode_invoice_json["destination"], server.node_id()); assert_eq!(decode_invoice_json["description"], "mcp decode"); assert_eq!(decode_invoice_json["amount_msat"], 50_000_000u64); + assert_eq!(decode_invoice_json["kind"], "bolt11"); } diff --git a/ldk-server-cli/src/main.rs b/ldk-server-cli/src/main.rs index b0c346df..f42e0af7 100644 --- a/ldk-server-cli/src/main.rs +++ b/ldk-server-cli/src/main.rs @@ -341,9 +341,9 @@ enum Commands { )] max_channel_saturation_power_of_half: Option, }, - #[command(about = "Decode a BOLT11 invoice and display its fields")] + #[command(about = "Decode a BOLT11 or BOLT12 invoice and display its fields")] DecodeInvoice { - #[arg(help = "The BOLT11 invoice string to decode")] + #[arg(help = "A BOLT11 invoice string or a hex-encoded BOLT12 invoice to decode")] invoice: String, }, #[command(about = "Decode a BOLT12 offer and display its fields")] diff --git a/ldk-server-client/src/client.rs b/ldk-server-client/src/client.rs index 4c08d738..d28fd4d5 100644 --- a/ldk-server-client/src/client.rs +++ b/ldk-server-client/src/client.rs @@ -351,7 +351,7 @@ impl LdkServerClient { self.grpc_unary(&request, UNIFIED_SEND_PATH).await } - /// Decode a BOLT11 invoice and return its parsed fields. + /// Decode a BOLT11 or BOLT12 invoice and return its parsed fields. pub async fn decode_invoice( &self, request: DecodeInvoiceRequest, ) -> Result { diff --git a/ldk-server-grpc/src/api.rs b/ldk-server-grpc/src/api.rs index 07f41e1f..78612bfb 100644 --- a/ldk-server-grpc/src/api.rs +++ b/ldk-server-grpc/src/api.rs @@ -1109,19 +1109,22 @@ pub struct GraphGetNodeResponse { #[prost(message, optional, tag = "1")] pub node: ::core::option::Option, } -/// Decode a BOLT11 invoice and return its parsed fields. -/// This does not require a running node — it only parses the invoice string. +/// Decode a BOLT11 or BOLT12 invoice and return its parsed fields. +/// This does not require a running node — it only parses the invoice. #[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 DecodeInvoiceRequest { - /// The BOLT11 invoice string to decode. + /// The invoice to decode: either a BOLT11 invoice string or a hex-encoded BOLT12 invoice. #[prost(string, tag = "1")] pub invoice: ::prost::alloc::string::String, } /// The response for the `DecodeInvoice` RPC. On failure, a gRPC error status is returned. +/// `kind` indicates which invoice type was decoded; fields that do not apply to that type +/// are left empty (e.g. `payment_secret` and `route_hints` are BOLT11-only, `paths` is +/// BOLT12-only). #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[cfg_attr(feature = "serde", serde(default))] @@ -1173,6 +1176,12 @@ pub struct DecodeInvoiceResponse { /// Whether the invoice has expired. #[prost(bool, tag = "15")] pub is_expired: bool, + /// The kind of decoded invoice: "bolt11" or "bolt12". + #[prost(string, tag = "16")] + pub kind: ::prost::alloc::string::String, + /// Blinded payment paths to the recipient. Only present for BOLT12 invoices. + #[prost(message, repeated, tag = "17")] + pub paths: ::prost::alloc::vec::Vec, } /// Decode a BOLT12 offer and return its parsed fields. /// This does not require a running node — it only parses the offer string. diff --git a/ldk-server-grpc/src/proto/api.proto b/ldk-server-grpc/src/proto/api.proto index ba6e5a1e..d73ff37f 100644 --- a/ldk-server-grpc/src/proto/api.proto +++ b/ldk-server-grpc/src/proto/api.proto @@ -795,14 +795,17 @@ message GraphGetNodeResponse { types.GraphNode node = 1; } -// Decode a BOLT11 invoice and return its parsed fields. -// This does not require a running node — it only parses the invoice string. +// Decode a BOLT11 or BOLT12 invoice and return its parsed fields. +// This does not require a running node — it only parses the invoice. message DecodeInvoiceRequest { - // The BOLT11 invoice string to decode. + // The invoice to decode: either a BOLT11 invoice string or a hex-encoded BOLT12 invoice. string invoice = 1; } // The response for the `DecodeInvoice` RPC. On failure, a gRPC error status is returned. +// `kind` indicates which invoice type was decoded; fields that do not apply to that type +// are left empty (e.g. `payment_secret` and `route_hints` are BOLT11-only, `paths` is +// BOLT12-only). message DecodeInvoiceResponse { // The hex-encoded public key of the destination node. string destination = 1; @@ -848,6 +851,12 @@ message DecodeInvoiceResponse { // Whether the invoice has expired. bool is_expired = 15; + + // The kind of decoded invoice: "bolt11" or "bolt12". + string kind = 16; + + // Blinded payment paths to the recipient. Only present for BOLT12 invoices. + repeated types.BlindedPath paths = 17; } // Decode a BOLT12 offer and return its parsed fields. @@ -962,7 +971,7 @@ service LightningNode { rpc ExportPathfindingScores(ExportPathfindingScoresRequest) returns (ExportPathfindingScoresResponse); // Send a payment given a BIP 21 URI or BIP 353 Human-Readable Name. rpc UnifiedSend(UnifiedSendRequest) returns (UnifiedSendResponse); - // Decode a BOLT11 invoice and return its parsed fields. + // Decode a BOLT11 or BOLT12 invoice and return its parsed fields. rpc DecodeInvoice(DecodeInvoiceRequest) returns (DecodeInvoiceResponse); // Decode a BOLT12 offer and return its parsed fields. rpc DecodeOffer(DecodeOfferRequest) returns (DecodeOfferResponse); diff --git a/ldk-server-mcp/src/tools/mod.rs b/ldk-server-mcp/src/tools/mod.rs index f689d3d9..d688771e 100644 --- a/ldk-server-mcp/src/tools/mod.rs +++ b/ldk-server-mcp/src/tools/mod.rs @@ -243,7 +243,7 @@ pub fn build_tool_registry() -> ToolRegistry { ), tool_spec( "decode_invoice", - "Decode a BOLT11 invoice and return its parsed fields", + "Decode a BOLT11 or BOLT12 invoice and return its parsed fields", schema::decode_invoice_schema, |client, args| Box::pin(handlers::handle_decode_invoice(client, args)), ), diff --git a/ldk-server-mcp/src/tools/schema.rs b/ldk-server-mcp/src/tools/schema.rs index b9c45144..eca7a102 100644 --- a/ldk-server-mcp/src/tools/schema.rs +++ b/ldk-server-mcp/src/tools/schema.rs @@ -615,7 +615,7 @@ pub fn decode_invoice_schema() -> Value { "properties": { "invoice": { "type": "string", - "description": "The BOLT11 invoice string to decode" + "description": "A BOLT11 invoice string or a hex-encoded BOLT12 invoice to decode" } }, "required": ["invoice"] diff --git a/ldk-server/src/api/decode_invoice.rs b/ldk-server/src/api/decode_invoice.rs index e12b9de1..d76dfc5c 100644 --- a/ldk-server/src/api/decode_invoice.rs +++ b/ldk-server/src/api/decode_invoice.rs @@ -11,21 +11,81 @@ use std::str::FromStr; use std::sync::Arc; use hex::prelude::*; +use ldk_node::lightning::offers::invoice::Bolt12Invoice; use ldk_node::lightning_invoice::Bolt11Invoice; -use ldk_node::lightning_types::features::Bolt11InvoiceFeatures; +use ldk_node::lightning_types::features::{Bolt11InvoiceFeatures, Bolt12InvoiceFeatures}; use ldk_server_grpc::api::{DecodeInvoiceRequest, DecodeInvoiceResponse}; use ldk_server_grpc::types::{Bolt11HopHint, Bolt11RouteHint}; -use crate::api::decode_features; use crate::api::error::LdkServerError; +use crate::api::{blinded_path_to_proto, decode_features}; use crate::service::Context; +const INVOICE_KIND_BOLT11: &str = "bolt11"; +const INVOICE_KIND_BOLT12: &str = "bolt12"; + pub(crate) async fn handle_decode_invoice_request( _context: Arc, request: DecodeInvoiceRequest, ) -> Result { - let invoice = Bolt11Invoice::from_str(request.invoice.as_str()) - .map_err(|_| ldk_node::NodeError::InvalidInvoice)?; + decode_invoice(request.invoice.as_str()) +} + +/// Decodes either a BOLT11 invoice string or a hex-encoded BOLT12 invoice. +fn decode_invoice(invoice: &str) -> Result { + if let Ok(bolt11_invoice) = Bolt11Invoice::from_str(invoice) { + return Ok(decode_bolt11_invoice(&bolt11_invoice)); + } + + if let Some(response) = decode_bolt12_invoice(invoice) { + return Ok(response); + } + + Err(ldk_node::NodeError::InvalidInvoice.into()) +} + +/// Attempts to decode `invoice` as a hex-encoded BOLT12 invoice. +/// +/// Unlike offers and BOLT11 invoices, a BOLT12 invoice has no human-readable string +/// encoding — it is exchanged as raw bytes — so the input is expected to be hex-encoded. +/// Fields that do not apply to BOLT12 invoices (e.g. `payment_secret`, `route_hints`) are +/// left at their default empty values. +fn decode_bolt12_invoice(invoice: &str) -> Option { + let bytes = Vec::::from_hex(invoice).ok()?; + let invoice = Bolt12Invoice::try_from(bytes).ok()?; + + let features = decode_features(invoice.invoice_features().le_flags(), |bytes| { + Bolt12InvoiceFeatures::from_le_bytes(bytes).to_string() + }); + let paths = invoice + .payment_paths() + .iter() + .map(|path| { + blinded_path_to_proto( + path.introduction_node(), + path.blinding_point(), + path.blinded_hops().len(), + ) + }) + .collect(); + + Some(DecodeInvoiceResponse { + destination: invoice.signing_pubkey().to_string(), + payment_hash: invoice.payment_hash().0.to_lower_hex_string(), + amount_msat: Some(invoice.amount_msats()), + timestamp: invoice.created_at().as_secs(), + expiry: invoice.relative_expiry().as_secs(), + description: invoice.description().map(|d| d.to_string()), + fallback_address: invoice.fallbacks().into_iter().next().map(|a| a.to_string()), + features, + is_expired: invoice.is_expired(), + kind: INVOICE_KIND_BOLT12.to_string(), + paths, + ..Default::default() + }) +} + +fn decode_bolt11_invoice(invoice: &Bolt11Invoice) -> DecodeInvoiceResponse { let destination = invoice.get_payee_pub_key().to_string(); let payment_hash = invoice.payment_hash().0.to_lower_hex_string(); let amount_msat = invoice.amount_milli_satoshis(); @@ -85,7 +145,7 @@ pub(crate) async fn handle_decode_invoice_request( let is_expired = invoice.is_expired(); - Ok(DecodeInvoiceResponse { + DecodeInvoiceResponse { destination, payment_hash, amount_msat, @@ -101,5 +161,105 @@ pub(crate) async fn handle_decode_invoice_request( currency, payment_metadata, is_expired, - }) + kind: INVOICE_KIND_BOLT11.to_string(), + // BOLT11 invoices carry route hints rather than blinded paths. + paths: Vec::new(), + } +} + +#[cfg(test)] +mod tests { + use ldk_node::lightning::bitcoin::secp256k1::{Keypair, PublicKey, Secp256k1, SecretKey}; + use ldk_node::lightning::blinded_path::payment::{BlindedPayInfo, BlindedPaymentPath}; + use ldk_node::lightning::blinded_path::BlindedHop; + use ldk_node::lightning::offers::invoice::UnsignedBolt12Invoice; + use ldk_node::lightning::offers::refund::RefundBuilder; + use ldk_node::lightning::types::features::BlindedHopFeatures; + use ldk_node::lightning::types::payment::PaymentHash; + use ldk_node::lightning::util::ser::Writeable; + use ldk_server_grpc::types::blinded_path::IntroductionNode; + + use super::*; + + fn pubkey(byte: u8) -> PublicKey { + let secp = Secp256k1::new(); + PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[byte; 32]).unwrap()) + } + + /// The keypair the sample BOLT12 invoice is signed with; its public key is the + /// invoice's `signing_pubkey`. + fn signing_keypair() -> Keypair { + let secp = Secp256k1::new(); + Keypair::from_secret_key(&secp, &SecretKey::from_slice(&[43; 32]).unwrap()) + } + + /// Builds a signed BOLT12 invoice and returns it hex-encoded, matching how a BOLT12 + /// invoice would be supplied to `DecodeInvoice`. + fn sample_bolt12_invoice_hex() -> String { + let secp = Secp256k1::new(); + let keys = signing_keypair(); + + let payment_paths = vec![BlindedPaymentPath::from_blinded_path_and_payinfo( + pubkey(40), + pubkey(41), + vec![ + BlindedHop { blinded_node_id: pubkey(43), encrypted_payload: vec![0; 43] }, + BlindedHop { blinded_node_id: pubkey(44), encrypted_payload: vec![0; 44] }, + ], + BlindedPayInfo { + fee_base_msat: 1, + fee_proportional_millionths: 1_000, + cltv_expiry_delta: 42, + htlc_minimum_msat: 100, + htlc_maximum_msat: 1_000_000_000_000, + features: BlindedHopFeatures::empty(), + }, + )]; + + let refund = RefundBuilder::new(vec![1; 32], pubkey(42), 1_000).unwrap().build().unwrap(); + let invoice = refund + .respond_with(payment_paths, PaymentHash([42; 32]), keys.public_key()) + .unwrap() + .relative_expiry(3600) + .build() + .unwrap() + .sign(|message: &UnsignedBolt12Invoice| { + Ok::<_, ()>(secp.sign_schnorr_no_aux_rand(message.as_ref().as_digest(), &keys)) + }) + .unwrap(); + + let mut buffer = Vec::new(); + invoice.write(&mut buffer).unwrap(); + buffer.to_lower_hex_string() + } + + #[test] + fn rejects_unparseable_input() { + assert!(decode_invoice("not an invoice").is_err()); + } + + #[test] + fn rejects_hex_that_is_not_a_bolt12_invoice() { + // Valid hex, but not a BOLT12 invoice TLV stream. + assert!(decode_invoice("00010203").is_err()); + } + + #[test] + fn decodes_bolt12_invoice_and_populates_fields() { + let response = decode_invoice(&sample_bolt12_invoice_hex()).unwrap(); + assert_eq!(response.kind, INVOICE_KIND_BOLT12); + assert_eq!(response.destination, signing_keypair().public_key().to_string()); + assert_eq!(response.payment_hash, "2a".repeat(32)); + assert_eq!(response.amount_msat, Some(1_000)); + assert_eq!(response.expiry, 3600); + assert!(!response.is_expired); + + // The sample invoice carries a single blinded payment path with two hops, + // introduced by `pubkey(40)` and blinded with `pubkey(41)`. + assert_eq!(response.paths.len(), 1); + let path = &response.paths[0]; + assert_eq!(path.num_hops, 2); + assert_eq!(path.blinding_point, pubkey(41).to_string()); + assert_eq!(path.introduction_node, Some(IntroductionNode::NodeId(pubkey(40).to_string()))); + } } diff --git a/ldk-server/src/api/decode_offer.rs b/ldk-server/src/api/decode_offer.rs index 7669ba5d..3e0d1f15 100644 --- a/ldk-server/src/api/decode_offer.rs +++ b/ldk-server/src/api/decode_offer.rs @@ -16,16 +16,12 @@ 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, ChannelDirection, CurrencyAmount, DirectedShortChannelId, OfferAmount, - OfferQuantity, -}; +use ldk_server_grpc::types::{CurrencyAmount, OfferAmount, OfferQuantity}; -use crate::api::decode_features; use crate::api::error::LdkServerError; +use crate::api::{blinded_path_to_proto, decode_features}; use crate::service::Context; pub(crate) async fn handle_decode_offer_request( @@ -74,33 +70,11 @@ pub(crate) async fn handle_decode_offer_request( .paths() .iter() .map(|path| { - let introduction_node = match path.introduction_node() { - ldk_node::lightning::blinded_path::IntroductionNode::NodeId(pk) => { - IntroductionNode::NodeId(pk.to_string()) - }, - ldk_node::lightning::blinded_path::IntroductionNode::DirectedShortChannelId( - dir, - 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: Some(introduction_node), - blinding_point: path.blinding_point().to_string(), - num_hops: path.blinded_hops().len() as u32, - } + blinded_path_to_proto( + path.introduction_node(), + path.blinding_point(), + path.blinded_hops().len(), + ) }) .collect(); diff --git a/ldk-server/src/api/mod.rs b/ldk-server/src/api/mod.rs index ec0a03f6..a1d316ab 100644 --- a/ldk-server/src/api/mod.rs +++ b/ldk-server/src/api/mod.rs @@ -10,9 +10,14 @@ use std::collections::HashMap; use ldk_node::config::{ChannelConfig, MaxDustHTLCExposure}; +use ldk_node::lightning::bitcoin::secp256k1::PublicKey; +use ldk_node::lightning::blinded_path::{Direction, IntroductionNode}; use ldk_node::lightning::routing::router::RouteParametersConfig; +use ldk_server_grpc::types::blinded_path::IntroductionNode as ProtoIntroductionNode; use ldk_server_grpc::types::channel_config::MaxDustHtlcExposure; -use ldk_server_grpc::types::Bolt11Feature; +use ldk_server_grpc::types::{ + BlindedPath, Bolt11Feature, ChannelDirection, DirectedShortChannelId, +}; use crate::api::error::LdkServerError; use crate::api::error::LdkServerErrorCode::InvalidRequestError; @@ -129,6 +134,33 @@ pub(crate) fn build_route_parameters_config_from_proto( } } +/// Converts a blinded path into its proto representation. Shared by BOLT12 offer and +/// invoice decoding: offers expose blinded message paths and invoices expose blinded +/// payment paths, but both carry the same introduction node, blinding point, and hop +/// count surfaced here. +pub(crate) fn blinded_path_to_proto( + introduction_node: &IntroductionNode, blinding_point: PublicKey, num_hops: usize, +) -> BlindedPath { + let introduction_node = match introduction_node { + IntroductionNode::NodeId(node_id) => ProtoIntroductionNode::NodeId(node_id.to_string()), + IntroductionNode::DirectedShortChannelId(direction, scid) => { + let direction = match direction { + Direction::NodeOne => ChannelDirection::NodeOne, + Direction::NodeTwo => ChannelDirection::NodeTwo, + }; + ProtoIntroductionNode::DirectedScid(DirectedShortChannelId { + scid: *scid, + direction: direction as i32, + }) + }, + }; + BlindedPath { + introduction_node: Some(introduction_node), + blinding_point: blinding_point.to_string(), + num_hops: num_hops as u32, + } +} + /// Decodes feature flags into a map keyed by bit number. Feature names are derived /// from LDK's `Features::Display` impl, so they stay in sync automatically. ///