From 450e5f6a3aad7aeb4864f9038f95f2b4d248fc74 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Fri, 20 Mar 2026 15:51:53 -0500 Subject: [PATCH 1/2] Add DecodeInvoice RPC Adds a new DecodeInvoice endpoint that parses a BOLT11 invoice string and returns its fields (destination, payment_hash, amount, timestamp, expiry, description, route_hints, features, currency, etc.), similar to lncli's decodepayreq needed for thunderhub and zeus. Co-Authored-By: Claude Opus 4.6 (1M context) --- e2e-tests/tests/e2e.rs | 64 +++++++++++++++ ldk-server-cli/src/main.rs | 35 +++++--- ldk-server-client/src/client.rs | 46 +++++++---- ldk-server-protos/src/api.rs | 64 +++++++++++++++ ldk-server-protos/src/endpoints.rs | 1 + ldk-server-protos/src/proto/api.proto | 56 +++++++++++++ ldk-server-protos/src/proto/types.proto | 36 ++++++++ ldk-server-protos/src/types.rs | 48 +++++++++++ ldk-server/src/api/decode_invoice.rs | 104 ++++++++++++++++++++++++ ldk-server/src/api/mod.rs | 58 +++++++++++++ ldk-server/src/service.rs | 22 +++-- 11 files changed, 497 insertions(+), 37 deletions(-) create mode 100644 ldk-server/src/api/decode_invoice.rs diff --git a/e2e-tests/tests/e2e.rs b/e2e-tests/tests/e2e.rs index 577b74c..f30b293 100644 --- a/e2e-tests/tests/e2e.rs +++ b/e2e-tests/tests/e2e.rs @@ -148,6 +148,70 @@ async fn test_cli_bolt11_receive() { assert_eq!(invoice.payment_secret().0, payment_secret); } +#[tokio::test] +async fn test_cli_decode_invoice() { + let bitcoind = TestBitcoind::new(); + let server = LdkServerHandle::start(&bitcoind).await; + + // Create a BOLT11 invoice with known parameters + let output = + run_cli(&server, &["bolt11-receive", "50000sat", "-d", "decode test", "-e", "3600"]); + let invoice_str = output["invoice"].as_str().unwrap(); + + // Decode it + let decoded = run_cli(&server, &["decode-invoice", invoice_str]); + + // Verify fields match + assert_eq!(decoded["destination"], server.node_id()); + assert_eq!(decoded["payment_hash"], output["payment_hash"]); + assert_eq!(decoded["amount_msat"], 50_000_000); + assert_eq!(decoded["description"], "decode test"); + assert!(decoded.get("description_hash").is_none() || decoded["description_hash"].is_null()); + assert_eq!(decoded["expiry"], 3600); + assert_eq!(decoded["currency"], "regtest"); + assert_eq!(decoded["payment_secret"], output["payment_secret"]); + assert!(decoded["timestamp"].as_u64().unwrap() > 0); + assert!(decoded["min_final_cltv_expiry_delta"].as_u64().unwrap() > 0); + assert_eq!(decoded["is_expired"], false); + + // Verify features — LDK BOLT11 invoices always set VariableLengthOnion, PaymentSecret, + // and BasicMPP. + let features = decoded["features"].as_object().unwrap(); + assert!(!features.is_empty(), "Expected at least one feature"); + + let feature_names: Vec<&str> = features.values().filter_map(|f| f["name"].as_str()).collect(); + assert!( + feature_names.contains(&"VariableLengthOnion"), + "Expected VariableLengthOnion in features: {:?}", + feature_names + ); + assert!( + feature_names.contains(&"PaymentSecret"), + "Expected PaymentSecret in features: {:?}", + feature_names + ); + assert!( + feature_names.contains(&"BasicMPP"), + "Expected BasicMPP in features: {:?}", + feature_names + ); + + // Every entry should have the expected structure + for (bit, feature) in features { + assert!(bit.parse::().is_ok(), "Feature key should be a bit number: {}", bit); + assert!(feature.get("name").is_some(), "Feature missing name field"); + assert!(feature.get("is_required").is_some(), "Feature missing is_required field"); + assert!(feature.get("is_known").is_some(), "Feature missing is_known field"); + } + + // Also test a variable-amount invoice + let output_var = run_cli(&server, &["bolt11-receive", "-d", "no amount"]); + let decoded_var = + run_cli(&server, &["decode-invoice", output_var["invoice"].as_str().unwrap()]); + assert!(decoded_var.get("amount_msat").is_none() || decoded_var["amount_msat"].is_null()); + assert_eq!(decoded_var["description"], "no amount"); +} + #[tokio::test] async fn test_cli_bolt12_receive() { let bitcoind = TestBitcoind::new(); diff --git a/ldk-server-cli/src/main.rs b/ldk-server-cli/src/main.rs index d960f08..c04456c 100644 --- a/ldk-server-cli/src/main.rs +++ b/ldk-server-cli/src/main.rs @@ -29,18 +29,19 @@ use ldk_server_client::ldk_server_protos::api::{ Bolt11ReceiveViaJitChannelResponse, Bolt11SendRequest, Bolt11SendResponse, Bolt12ReceiveRequest, Bolt12ReceiveResponse, Bolt12SendRequest, Bolt12SendResponse, CloseChannelRequest, CloseChannelResponse, ConnectPeerRequest, ConnectPeerResponse, - DisconnectPeerRequest, DisconnectPeerResponse, ExportPathfindingScoresRequest, - ForceCloseChannelRequest, ForceCloseChannelResponse, GetBalancesRequest, GetBalancesResponse, - GetNodeInfoRequest, GetNodeInfoResponse, GetPaymentDetailsRequest, GetPaymentDetailsResponse, - GraphGetChannelRequest, GraphGetChannelResponse, GraphGetNodeRequest, GraphGetNodeResponse, - GraphListChannelsRequest, GraphListChannelsResponse, GraphListNodesRequest, - GraphListNodesResponse, ListChannelsRequest, ListChannelsResponse, - ListForwardedPaymentsRequest, ListPaymentsRequest, ListPeersRequest, ListPeersResponse, - OnchainReceiveRequest, OnchainReceiveResponse, OnchainSendRequest, OnchainSendResponse, - OpenChannelRequest, OpenChannelResponse, SignMessageRequest, SignMessageResponse, - SpliceInRequest, SpliceInResponse, SpliceOutRequest, SpliceOutResponse, SpontaneousSendRequest, - SpontaneousSendResponse, UnifiedSendRequest, UnifiedSendResponse, UpdateChannelConfigRequest, - UpdateChannelConfigResponse, VerifySignatureRequest, VerifySignatureResponse, + DecodeInvoiceRequest, DecodeInvoiceResponse, DisconnectPeerRequest, DisconnectPeerResponse, + ExportPathfindingScoresRequest, ForceCloseChannelRequest, ForceCloseChannelResponse, + GetBalancesRequest, GetBalancesResponse, GetNodeInfoRequest, GetNodeInfoResponse, + GetPaymentDetailsRequest, GetPaymentDetailsResponse, GraphGetChannelRequest, + GraphGetChannelResponse, GraphGetNodeRequest, GraphGetNodeResponse, GraphListChannelsRequest, + GraphListChannelsResponse, GraphListNodesRequest, GraphListNodesResponse, ListChannelsRequest, + ListChannelsResponse, ListForwardedPaymentsRequest, ListPaymentsRequest, ListPeersRequest, + ListPeersResponse, OnchainReceiveRequest, OnchainReceiveResponse, OnchainSendRequest, + OnchainSendResponse, OpenChannelRequest, OpenChannelResponse, SignMessageRequest, + SignMessageResponse, SpliceInRequest, SpliceInResponse, SpliceOutRequest, SpliceOutResponse, + SpontaneousSendRequest, SpontaneousSendResponse, UnifiedSendRequest, UnifiedSendResponse, + UpdateChannelConfigRequest, UpdateChannelConfigResponse, VerifySignatureRequest, + VerifySignatureResponse, }; use ldk_server_client::ldk_server_protos::types::{ bolt11_invoice_description, Bolt11InvoiceDescription, ChannelConfig, PageToken, @@ -338,6 +339,11 @@ enum Commands { )] max_channel_saturation_power_of_half: Option, }, + #[command(about = "Decode a BOLT11 invoice and display its fields")] + DecodeInvoice { + #[arg(help = "The BOLT11 invoice string to decode")] + invoice: String, + }, #[command(about = "Cooperatively close the channel specified by the given channel ID")] CloseChannel { #[arg(help = "The local user_channel_id of this channel")] @@ -862,6 +868,11 @@ async fn main() { .await, ); }, + Commands::DecodeInvoice { invoice } => { + handle_response_result::<_, DecodeInvoiceResponse>( + client.decode_invoice(DecodeInvoiceRequest { invoice }).await, + ); + }, Commands::CloseChannel { user_channel_id, counterparty_node_id } => { handle_response_result::<_, CloseChannelResponse>( client diff --git a/ldk-server-client/src/client.rs b/ldk-server-client/src/client.rs index 75459a4..20d50ed 100644 --- a/ldk-server-client/src/client.rs +++ b/ldk-server-client/src/client.rs @@ -19,17 +19,18 @@ use ldk_server_protos::api::{ Bolt11ReceiveViaJitChannelResponse, Bolt11SendRequest, Bolt11SendResponse, Bolt12ReceiveRequest, Bolt12ReceiveResponse, Bolt12SendRequest, Bolt12SendResponse, CloseChannelRequest, CloseChannelResponse, ConnectPeerRequest, ConnectPeerResponse, - DisconnectPeerRequest, DisconnectPeerResponse, ExportPathfindingScoresRequest, - ExportPathfindingScoresResponse, ForceCloseChannelRequest, ForceCloseChannelResponse, - GetBalancesRequest, GetBalancesResponse, GetNodeInfoRequest, GetNodeInfoResponse, - GetPaymentDetailsRequest, GetPaymentDetailsResponse, GraphGetChannelRequest, - GraphGetChannelResponse, GraphGetNodeRequest, GraphGetNodeResponse, GraphListChannelsRequest, - GraphListChannelsResponse, GraphListNodesRequest, GraphListNodesResponse, ListChannelsRequest, - ListChannelsResponse, ListForwardedPaymentsRequest, ListForwardedPaymentsResponse, - ListPaymentsRequest, ListPaymentsResponse, ListPeersRequest, ListPeersResponse, - OnchainReceiveRequest, OnchainReceiveResponse, OnchainSendRequest, OnchainSendResponse, - OpenChannelRequest, OpenChannelResponse, SignMessageRequest, SignMessageResponse, - SpliceInRequest, SpliceInResponse, SpliceOutRequest, SpliceOutResponse, SpontaneousSendRequest, + DecodeInvoiceRequest, DecodeInvoiceResponse, DisconnectPeerRequest, DisconnectPeerResponse, + ExportPathfindingScoresRequest, ExportPathfindingScoresResponse, ForceCloseChannelRequest, + ForceCloseChannelResponse, GetBalancesRequest, GetBalancesResponse, GetNodeInfoRequest, + GetNodeInfoResponse, GetPaymentDetailsRequest, GetPaymentDetailsResponse, + GraphGetChannelRequest, GraphGetChannelResponse, GraphGetNodeRequest, GraphGetNodeResponse, + GraphListChannelsRequest, GraphListChannelsResponse, GraphListNodesRequest, + GraphListNodesResponse, ListChannelsRequest, ListChannelsResponse, + ListForwardedPaymentsRequest, ListForwardedPaymentsResponse, ListPaymentsRequest, + ListPaymentsResponse, ListPeersRequest, ListPeersResponse, OnchainReceiveRequest, + OnchainReceiveResponse, OnchainSendRequest, OnchainSendResponse, OpenChannelRequest, + OpenChannelResponse, SignMessageRequest, SignMessageResponse, SpliceInRequest, + SpliceInResponse, SpliceOutRequest, SpliceOutResponse, SpontaneousSendRequest, SpontaneousSendResponse, UnifiedSendRequest, UnifiedSendResponse, UpdateChannelConfigRequest, UpdateChannelConfigResponse, VerifySignatureRequest, VerifySignatureResponse, }; @@ -37,13 +38,13 @@ use ldk_server_protos::endpoints::{ BOLT11_CLAIM_FOR_HASH_PATH, BOLT11_FAIL_FOR_HASH_PATH, BOLT11_RECEIVE_FOR_HASH_PATH, BOLT11_RECEIVE_PATH, BOLT11_RECEIVE_VARIABLE_AMOUNT_VIA_JIT_CHANNEL_PATH, BOLT11_RECEIVE_VIA_JIT_CHANNEL_PATH, BOLT11_SEND_PATH, BOLT12_RECEIVE_PATH, BOLT12_SEND_PATH, - CLOSE_CHANNEL_PATH, CONNECT_PEER_PATH, DISCONNECT_PEER_PATH, EXPORT_PATHFINDING_SCORES_PATH, - FORCE_CLOSE_CHANNEL_PATH, GET_BALANCES_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, - ONCHAIN_RECEIVE_PATH, ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH, - SPLICE_OUT_PATH, SPONTANEOUS_SEND_PATH, UNIFIED_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH, - VERIFY_SIGNATURE_PATH, + CLOSE_CHANNEL_PATH, CONNECT_PEER_PATH, DECODE_INVOICE_PATH, DISCONNECT_PEER_PATH, + EXPORT_PATHFINDING_SCORES_PATH, FORCE_CLOSE_CHANNEL_PATH, GET_BALANCES_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, ONCHAIN_RECEIVE_PATH, + ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH, SPLICE_OUT_PATH, + SPONTANEOUS_SEND_PATH, UNIFIED_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH, VERIFY_SIGNATURE_PATH, }; use ldk_server_protos::error::{ErrorCode, ErrorResponse}; use prost::Message; @@ -364,6 +365,15 @@ impl LdkServerClient { self.post_request(&request, &url).await } + /// Decode a BOLT11 invoice and return its parsed fields. + /// For API contract/usage, refer to docs for [`DecodeInvoiceRequest`] and [`DecodeInvoiceResponse`]. + pub async fn decode_invoice( + &self, request: DecodeInvoiceRequest, + ) -> Result { + let url = format!("https://{}/{DECODE_INVOICE_PATH}", self.base_url); + self.post_request(&request, &url).await + } + /// Sign a message with the node's secret key. /// For API contract/usage, refer to docs for [`SignMessageRequest`] and [`SignMessageResponse`]. pub async fn sign_message( diff --git a/ldk-server-protos/src/api.rs b/ldk-server-protos/src/api.rs index f72357d..5d010ef 100644 --- a/ldk-server-protos/src/api.rs +++ b/ldk-server-protos/src/api.rs @@ -1067,3 +1067,67 @@ 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. +#[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::Message)] +pub struct DecodeInvoiceRequest { + /// The BOLT11 invoice string to decode. + #[prost(string, tag = "1")] + pub invoice: ::prost::alloc::string::String, +} +/// The response `content` for the `DecodeInvoice` API, when HttpStatusCode is OK (200). +/// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +#[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::Message)] +pub struct DecodeInvoiceResponse { + /// The hex-encoded public key of the destination node. + #[prost(string, tag = "1")] + pub destination: ::prost::alloc::string::String, + /// The hex-encoded 32-byte payment hash. + #[prost(string, tag = "2")] + pub payment_hash: ::prost::alloc::string::String, + /// The amount in millisatoshis, if specified in the invoice. + #[prost(uint64, optional, tag = "3")] + pub amount_msat: ::core::option::Option, + /// The creation timestamp in seconds since the UNIX epoch. + #[prost(uint64, tag = "4")] + pub timestamp: u64, + /// The invoice expiry time in seconds. + #[prost(uint64, tag = "5")] + pub expiry: u64, + /// The invoice description, if a direct description was provided. + #[prost(string, optional, tag = "6")] + pub description: ::core::option::Option<::prost::alloc::string::String>, + /// The hex-encoded SHA-256 hash of the description, if a description hash was used. + #[prost(string, optional, tag = "14")] + pub description_hash: ::core::option::Option<::prost::alloc::string::String>, + /// The fallback on-chain address, if any. + #[prost(string, optional, tag = "7")] + pub fallback_address: ::core::option::Option<::prost::alloc::string::String>, + /// The minimum final CLTV expiry delta. + #[prost(uint64, tag = "8")] + pub min_final_cltv_expiry_delta: u64, + /// The hex-encoded 32-byte payment secret. + #[prost(string, tag = "9")] + pub payment_secret: ::prost::alloc::string::String, + /// Route hints for finding a path to the payee. + #[prost(message, repeated, tag = "10")] + pub route_hints: ::prost::alloc::vec::Vec, + /// Feature bits advertised in the invoice, keyed by bit number. + #[prost(map = "uint32, message", tag = "11")] + pub features: ::std::collections::HashMap, + /// The currency or network (e.g., "bitcoin", "testnet", "signet", "regtest"). + #[prost(string, tag = "12")] + pub currency: ::prost::alloc::string::String, + /// The payment metadata, hex-encoded. Only present if the invoice includes payment metadata. + #[prost(string, optional, tag = "13")] + pub payment_metadata: ::core::option::Option<::prost::alloc::string::String>, + /// Whether the invoice has expired. + #[prost(bool, tag = "15")] + pub is_expired: bool, +} diff --git a/ldk-server-protos/src/endpoints.rs b/ldk-server-protos/src/endpoints.rs index c6818de..6d6c13b 100644 --- a/ldk-server-protos/src/endpoints.rs +++ b/ldk-server-protos/src/endpoints.rs @@ -43,3 +43,4 @@ pub const GRAPH_LIST_CHANNELS_PATH: &str = "GraphListChannels"; pub const GRAPH_GET_CHANNEL_PATH: &str = "GraphGetChannel"; pub const GRAPH_LIST_NODES_PATH: &str = "GraphListNodes"; pub const GRAPH_GET_NODE_PATH: &str = "GraphGetNode"; +pub const DECODE_INVOICE_PATH: &str = "DecodeInvoice"; diff --git a/ldk-server-protos/src/proto/api.proto b/ldk-server-protos/src/proto/api.proto index 3eb505d..e15846e 100644 --- a/ldk-server-protos/src/proto/api.proto +++ b/ldk-server-protos/src/proto/api.proto @@ -822,3 +822,59 @@ message GraphGetNodeResponse { // The node information. 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. +message DecodeInvoiceRequest { + // The BOLT11 invoice string to decode. + string invoice = 1; +} + +// The response `content` for the `DecodeInvoice` API, when HttpStatusCode is OK (200). +// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +message DecodeInvoiceResponse { + // The hex-encoded public key of the destination node. + string destination = 1; + + // The hex-encoded 32-byte payment hash. + string payment_hash = 2; + + // The amount in millisatoshis, if specified in the invoice. + optional uint64 amount_msat = 3; + + // The creation timestamp in seconds since the UNIX epoch. + uint64 timestamp = 4; + + // The invoice expiry time in seconds. + uint64 expiry = 5; + + // The invoice description, if a direct description was provided. + optional string description = 6; + + // The hex-encoded SHA-256 hash of the description, if a description hash was used. + optional string description_hash = 14; + + // The fallback on-chain address, if any. + optional string fallback_address = 7; + + // The minimum final CLTV expiry delta. + uint64 min_final_cltv_expiry_delta = 8; + + // The hex-encoded 32-byte payment secret. + string payment_secret = 9; + + // Route hints for finding a path to the payee. + repeated types.Bolt11RouteHint route_hints = 10; + + // Feature bits advertised in the invoice, keyed by bit number. + map features = 11; + + // The currency or network (e.g., "bitcoin", "testnet", "signet", "regtest"). + string currency = 12; + + // The payment metadata, hex-encoded. Only present if the invoice includes payment metadata. + optional string payment_metadata = 13; + + // Whether the invoice has expired. + bool is_expired = 15; +} diff --git a/ldk-server-protos/src/proto/types.proto b/ldk-server-protos/src/proto/types.proto index e48908e..5af5ec3 100644 --- a/ldk-server-protos/src/proto/types.proto +++ b/ldk-server-protos/src/proto/types.proto @@ -818,3 +818,39 @@ message GraphNode { // a channel announcement, but before receiving a node announcement. GraphNodeAnnouncement announcement_info = 2; } + +// Route hint for finding a path to the payee in a BOLT11 invoice. +message Bolt11RouteHint { + // The hops in this route hint. + repeated Bolt11HopHint hop_hints = 1; +} + +// A hop in a BOLT11 route hint. +message Bolt11HopHint { + // The hex-encoded public key of the node at this hop. + string node_id = 1; + + // The short channel ID. + uint64 short_channel_id = 2; + + // The base fee in millisatoshis charged for routing through this hop. + uint32 fee_base_msat = 3; + + // Fee proportional millionths charged for routing through this hop. + uint32 fee_proportional_millionths = 4; + + // The CLTV expiry delta for this hop. + uint32 cltv_expiry_delta = 5; +} + +// A feature bit advertised in a BOLT11 invoice. +message Bolt11Feature { + // Human-readable feature name. + string name = 1; + + // Whether this feature is required. + bool is_required = 2; + + // Whether this feature is known. + bool is_known = 3; +} diff --git a/ldk-server-protos/src/types.rs b/ldk-server-protos/src/types.rs index 280e34f..7b383cf 100644 --- a/ldk-server-protos/src/types.rs +++ b/ldk-server-protos/src/types.rs @@ -1035,6 +1035,54 @@ pub struct GraphNode { #[prost(message, optional, tag = "2")] pub announcement_info: ::core::option::Option, } +/// Route hint for finding a path to the payee in a BOLT11 invoice. +#[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::Message)] +pub struct Bolt11RouteHint { + /// The hops in this route hint. + #[prost(message, repeated, tag = "1")] + pub hop_hints: ::prost::alloc::vec::Vec, +} +/// A hop in a BOLT11 route hint. +#[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::Message)] +pub struct Bolt11HopHint { + /// The hex-encoded public key of the node at this hop. + #[prost(string, tag = "1")] + pub node_id: ::prost::alloc::string::String, + /// The short channel ID. + #[prost(uint64, tag = "2")] + pub short_channel_id: u64, + /// The base fee in millisatoshis charged for routing through this hop. + #[prost(uint32, tag = "3")] + pub fee_base_msat: u32, + /// Fee proportional millionths charged for routing through this hop. + #[prost(uint32, tag = "4")] + pub fee_proportional_millionths: u32, + /// The CLTV expiry delta for this hop. + #[prost(uint32, tag = "5")] + pub cltv_expiry_delta: u32, +} +/// A feature bit advertised in a BOLT11 invoice. +#[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::Message)] +pub struct Bolt11Feature { + /// Human-readable feature name. + #[prost(string, tag = "1")] + pub name: ::prost::alloc::string::String, + /// Whether this feature is required. + #[prost(bool, tag = "2")] + pub is_required: bool, + /// Whether this feature is known. + #[prost(bool, tag = "3")] + pub is_known: bool, +} /// 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/decode_invoice.rs b/ldk-server/src/api/decode_invoice.rs new file mode 100644 index 0000000..c2b63e6 --- /dev/null +++ b/ldk-server/src/api/decode_invoice.rs @@ -0,0 +1,104 @@ +// 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::str::FromStr; + +use hex::prelude::*; +use ldk_node::lightning_invoice::Bolt11Invoice; +use ldk_node::lightning_types::features::Bolt11InvoiceFeatures; +use ldk_server_protos::api::{DecodeInvoiceRequest, DecodeInvoiceResponse}; +use ldk_server_protos::types::{Bolt11HopHint, Bolt11RouteHint}; + +use crate::api::decode_features; +use crate::api::error::LdkServerError; +use crate::service::Context; + +pub(crate) fn handle_decode_invoice_request( + _context: Context, request: DecodeInvoiceRequest, +) -> Result { + let invoice = Bolt11Invoice::from_str(request.invoice.as_str()) + .map_err(|_| ldk_node::NodeError::InvalidInvoice)?; + + 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(); + let timestamp = invoice.duration_since_epoch().as_secs(); + let expiry = invoice.expiry_time().as_secs(); + let min_final_cltv_expiry_delta = invoice.min_final_cltv_expiry_delta(); + let payment_secret = invoice.payment_secret().0.to_lower_hex_string(); + + let (description, description_hash) = match invoice.description() { + ldk_node::lightning_invoice::Bolt11InvoiceDescriptionRef::Direct(desc) => { + (Some(desc.to_string()), None) + }, + ldk_node::lightning_invoice::Bolt11InvoiceDescriptionRef::Hash(hash) => { + (None, Some(hash.0.to_string())) + }, + }; + + let fallback_address = invoice.fallback_addresses().into_iter().next().map(|a| a.to_string()); + + let route_hints = invoice + .route_hints() + .into_iter() + .map(|hint| Bolt11RouteHint { + hop_hints: hint + .0 + .iter() + .map(|hop| Bolt11HopHint { + node_id: hop.src_node_id.to_string(), + short_channel_id: hop.short_channel_id, + fee_base_msat: hop.fees.base_msat, + fee_proportional_millionths: hop.fees.proportional_millionths, + cltv_expiry_delta: hop.cltv_expiry_delta as u32, + }) + .collect(), + }) + .collect(); + + let features = invoice + .features() + .map(|f| { + decode_features(f.le_flags(), |bytes| { + Bolt11InvoiceFeatures::from_le_bytes(bytes).to_string() + }) + }) + .unwrap_or_default(); + + let currency = match invoice.currency() { + ldk_node::lightning_invoice::Currency::Bitcoin => "bitcoin", + ldk_node::lightning_invoice::Currency::BitcoinTestnet => "testnet", + ldk_node::lightning_invoice::Currency::Regtest => "regtest", + ldk_node::lightning_invoice::Currency::Simnet => "simnet", + ldk_node::lightning_invoice::Currency::Signet => "signet", + } + .to_string(); + + let payment_metadata = invoice.payment_metadata().map(|m| m.to_lower_hex_string()); + + let is_expired = invoice.is_expired(); + + Ok(DecodeInvoiceResponse { + destination, + payment_hash, + amount_msat, + timestamp, + expiry, + description, + description_hash, + fallback_address, + min_final_cltv_expiry_delta, + payment_secret, + route_hints, + features, + currency, + payment_metadata, + is_expired, + }) +} diff --git a/ldk-server/src/api/mod.rs b/ldk-server/src/api/mod.rs index ab8d7d5..0ea5945 100644 --- a/ldk-server/src/api/mod.rs +++ b/ldk-server/src/api/mod.rs @@ -7,9 +7,12 @@ // You may not use this file except in accordance with one or both of these // licenses. +use std::collections::HashMap; + use ldk_node::config::{ChannelConfig, MaxDustHTLCExposure}; use ldk_node::lightning::routing::router::RouteParametersConfig; use ldk_server_protos::types::channel_config::MaxDustHtlcExposure; +use ldk_server_protos::types::Bolt11Feature; use crate::api::error::LdkServerError; use crate::api::error::LdkServerErrorCode::InvalidRequestError; @@ -24,6 +27,7 @@ pub(crate) mod bolt12_receive; pub(crate) mod bolt12_send; pub(crate) mod close_channel; pub(crate) mod connect_peer; +pub(crate) mod decode_invoice; pub(crate) mod disconnect_peer; pub(crate) mod error; pub(crate) mod export_pathfinding_scores; @@ -123,3 +127,57 @@ pub(crate) fn build_route_parameters_config_from_proto( None => Ok(None), } } + +/// 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. +/// +/// `make_display` should construct a `Features` from the given LE bytes and return +/// its `to_string()` output — this lets us probe LDK for the name of each set bit. +pub(crate) fn decode_features( + le_flags: &[u8], make_display: impl Fn(Vec) -> String, +) -> HashMap { + let mut features = HashMap::new(); + for (byte_idx, &byte) in le_flags.iter().enumerate() { + if byte == 0 { + continue; + } + for bit_pos in 0..8u32 { + if byte & (1 << bit_pos) != 0 { + let bit_number = (byte_idx as u32) * 8 + bit_pos; + let is_required = bit_number.is_multiple_of(2); + + // Create Features with just this bit set and use Display to get the name. + let mut single_bit = vec![0u8; byte_idx + 1]; + single_bit[byte_idx] = 1 << bit_pos; + let display = make_display(single_bit); + let (name, is_known) = parse_feature_name(&display); + + features.insert( + bit_number, + Bolt11Feature { name: name.to_string(), is_required, is_known }, + ); + } + } + } + features +} + +/// Parse the Display output of a single-bit Features to find which feature is set. +/// +/// LDK's Display format is: "Name: status, Name: status, ..., unknown flags: status" +/// where status is "required", "supported", or "not supported". +/// For a single-bit Features, exactly one entry will be "required" or "supported". +fn parse_feature_name(display: &str) -> (&str, bool) { + for entry in display.split(", ") { + if let Some((name, status)) = entry.split_once(": ") { + if name == "unknown flags" { + if status == "required" || status == "supported" { + return ("unknown", false); + } + } else if status == "required" || status == "supported" { + return (name, true); + } + } + } + ("unknown", false) +} diff --git a/ldk-server/src/service.rs b/ldk-server/src/service.rs index 05004ae..b0b4905 100644 --- a/ldk-server/src/service.rs +++ b/ldk-server/src/service.rs @@ -22,13 +22,13 @@ use ldk_server_protos::endpoints::{ BOLT11_CLAIM_FOR_HASH_PATH, BOLT11_FAIL_FOR_HASH_PATH, BOLT11_RECEIVE_FOR_HASH_PATH, BOLT11_RECEIVE_PATH, BOLT11_RECEIVE_VARIABLE_AMOUNT_VIA_JIT_CHANNEL_PATH, BOLT11_RECEIVE_VIA_JIT_CHANNEL_PATH, BOLT11_SEND_PATH, BOLT12_RECEIVE_PATH, BOLT12_SEND_PATH, - CLOSE_CHANNEL_PATH, CONNECT_PEER_PATH, DISCONNECT_PEER_PATH, EXPORT_PATHFINDING_SCORES_PATH, - FORCE_CLOSE_CHANNEL_PATH, GET_BALANCES_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, - ONCHAIN_RECEIVE_PATH, ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH, - SPLICE_OUT_PATH, SPONTANEOUS_SEND_PATH, UNIFIED_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH, - VERIFY_SIGNATURE_PATH, + CLOSE_CHANNEL_PATH, CONNECT_PEER_PATH, DECODE_INVOICE_PATH, DISCONNECT_PEER_PATH, + EXPORT_PATHFINDING_SCORES_PATH, FORCE_CLOSE_CHANNEL_PATH, GET_BALANCES_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, ONCHAIN_RECEIVE_PATH, + ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH, SPLICE_OUT_PATH, + SPONTANEOUS_SEND_PATH, UNIFIED_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH, VERIFY_SIGNATURE_PATH, }; use prost::Message; @@ -45,6 +45,7 @@ use crate::api::bolt12_receive::handle_bolt12_receive_request; use crate::api::bolt12_send::handle_bolt12_send_request; use crate::api::close_channel::{handle_close_channel_request, handle_force_close_channel_request}; use crate::api::connect_peer::handle_connect_peer; +use crate::api::decode_invoice::handle_decode_invoice_request; use crate::api::disconnect_peer::handle_disconnect_peer; use crate::api::error::LdkServerError; use crate::api::error::LdkServerErrorCode::{AuthError, InvalidRequestError}; @@ -427,6 +428,13 @@ impl Service> for NodeService { api_key, handle_graph_get_node_request, )), + DECODE_INVOICE_PATH => Box::pin(handle_request( + context, + req, + auth_params, + api_key, + handle_decode_invoice_request, + )), path => { let error = format!("Unknown request: {}", path).into_bytes(); Box::pin(async { From 01ebd598aaf045232197558c9301f749a1b9d0de Mon Sep 17 00:00:00 2001 From: benthecarman Date: Fri, 20 Mar 2026 16:12:18 -0500 Subject: [PATCH 2/2] Add DecodeOffer RPC Adds a new DecodeOffer endpoint that parses a BOLT12 offer string and returns its fields: offer_id, description, issuer, amount (bitcoin or currency), issuer_signing_pubkey, absolute_expiry, supported_quantity, blinded paths, features, chains, metadata, and expiry status. Co-Authored-By: Claude Opus 4.6 (1M context) --- e2e-tests/tests/e2e.rs | 46 +++++++++ ldk-server-cli/src/main.rs | 36 ++++--- ldk-server-client/src/client.rs | 41 +++++--- ldk-server-protos/src/api.rs | 55 ++++++++++ ldk-server-protos/src/endpoints.rs | 1 + ldk-server-protos/src/proto/api.proto | 47 +++++++++ ldk-server-protos/src/proto/types.proto | 51 ++++++++++ ldk-server-protos/src/types.rs | 85 ++++++++++++++++ ldk-server/src/api/decode_offer.rs | 128 ++++++++++++++++++++++++ ldk-server/src/api/mod.rs | 1 + ldk-server/src/service.rs | 16 ++- 11 files changed, 474 insertions(+), 33 deletions(-) create mode 100644 ldk-server/src/api/decode_offer.rs diff --git a/e2e-tests/tests/e2e.rs b/e2e-tests/tests/e2e.rs index f30b293..f1f6844 100644 --- a/e2e-tests/tests/e2e.rs +++ b/e2e-tests/tests/e2e.rs @@ -229,6 +229,52 @@ async fn test_cli_bolt12_receive() { assert_eq!(offer.id().0, offer_id); } +#[tokio::test] +async fn test_cli_decode_offer() { + let bitcoind = TestBitcoind::new(); + let server_a = LdkServerHandle::start(&bitcoind).await; + let server_b = LdkServerHandle::start(&bitcoind).await; + // BOLT12 offers need announced channels for blinded reply paths + setup_funded_channel(&bitcoind, &server_a, &server_b, 100_000).await; + + // Create a BOLT12 offer with known parameters + let output = run_cli(&server_a, &["bolt12-receive", "decode offer test"]); + let offer_str = output["offer"].as_str().unwrap(); + + // Decode it + let decoded = run_cli(&server_a, &["decode-offer", offer_str]); + + // Verify fields match + assert_eq!(decoded["offer_id"], output["offer_id"]); + assert_eq!(decoded["description"], "decode offer test"); + assert_eq!(decoded["is_expired"], false); + + // Chains should include regtest + let chains = decoded["chains"].as_array().unwrap(); + assert!(chains.iter().any(|c| c == "regtest"), "Expected regtest in chains: {:?}", chains); + + // Paths should be present (BOLT12 offers with blinded paths) + let paths = decoded["paths"].as_array().unwrap(); + assert!(!paths.is_empty(), "Expected at least one blinded path"); + for path in paths { + assert!(path["num_hops"].as_u64().unwrap() > 0); + assert!(!path["blinding_point"].as_str().unwrap().is_empty()); + } + + // Features — OfferContext has no known features in LDK, so this should be empty + let features = decoded["features"].as_object().unwrap(); + assert!(features.is_empty(), "Expected empty offer features, got: {:?}", features); + + // Variable-amount offer should have no amount + assert!(decoded.get("amount").is_none() || decoded["amount"].is_null()); + + // Test a fixed-amount offer + let output_fixed = run_cli(&server_a, &["bolt12-receive", "fixed amount", "50000sat"]); + let decoded_fixed = + run_cli(&server_a, &["decode-offer", output_fixed["offer"].as_str().unwrap()]); + assert_eq!(decoded_fixed["amount"]["amount"]["bitcoin_amount_msats"], 50_000_000); +} + #[tokio::test] async fn test_cli_onchain_send() { let bitcoind = TestBitcoind::new(); diff --git a/ldk-server-cli/src/main.rs b/ldk-server-cli/src/main.rs index c04456c..7d657c3 100644 --- a/ldk-server-cli/src/main.rs +++ b/ldk-server-cli/src/main.rs @@ -29,19 +29,19 @@ use ldk_server_client::ldk_server_protos::api::{ Bolt11ReceiveViaJitChannelResponse, Bolt11SendRequest, Bolt11SendResponse, Bolt12ReceiveRequest, Bolt12ReceiveResponse, Bolt12SendRequest, Bolt12SendResponse, CloseChannelRequest, CloseChannelResponse, ConnectPeerRequest, ConnectPeerResponse, - DecodeInvoiceRequest, DecodeInvoiceResponse, DisconnectPeerRequest, DisconnectPeerResponse, - ExportPathfindingScoresRequest, ForceCloseChannelRequest, ForceCloseChannelResponse, - GetBalancesRequest, GetBalancesResponse, GetNodeInfoRequest, GetNodeInfoResponse, - GetPaymentDetailsRequest, GetPaymentDetailsResponse, GraphGetChannelRequest, - GraphGetChannelResponse, GraphGetNodeRequest, GraphGetNodeResponse, GraphListChannelsRequest, - GraphListChannelsResponse, GraphListNodesRequest, GraphListNodesResponse, ListChannelsRequest, - ListChannelsResponse, ListForwardedPaymentsRequest, ListPaymentsRequest, ListPeersRequest, - ListPeersResponse, OnchainReceiveRequest, OnchainReceiveResponse, OnchainSendRequest, - OnchainSendResponse, OpenChannelRequest, OpenChannelResponse, SignMessageRequest, - SignMessageResponse, SpliceInRequest, SpliceInResponse, SpliceOutRequest, SpliceOutResponse, - SpontaneousSendRequest, SpontaneousSendResponse, UnifiedSendRequest, UnifiedSendResponse, - UpdateChannelConfigRequest, UpdateChannelConfigResponse, VerifySignatureRequest, - VerifySignatureResponse, + DecodeInvoiceRequest, DecodeInvoiceResponse, DecodeOfferRequest, DecodeOfferResponse, + DisconnectPeerRequest, DisconnectPeerResponse, ExportPathfindingScoresRequest, + ForceCloseChannelRequest, ForceCloseChannelResponse, GetBalancesRequest, GetBalancesResponse, + GetNodeInfoRequest, GetNodeInfoResponse, GetPaymentDetailsRequest, GetPaymentDetailsResponse, + GraphGetChannelRequest, GraphGetChannelResponse, GraphGetNodeRequest, GraphGetNodeResponse, + GraphListChannelsRequest, GraphListChannelsResponse, GraphListNodesRequest, + GraphListNodesResponse, ListChannelsRequest, ListChannelsResponse, + ListForwardedPaymentsRequest, ListPaymentsRequest, ListPeersRequest, ListPeersResponse, + OnchainReceiveRequest, OnchainReceiveResponse, OnchainSendRequest, OnchainSendResponse, + OpenChannelRequest, OpenChannelResponse, SignMessageRequest, SignMessageResponse, + SpliceInRequest, SpliceInResponse, SpliceOutRequest, SpliceOutResponse, SpontaneousSendRequest, + SpontaneousSendResponse, UnifiedSendRequest, UnifiedSendResponse, UpdateChannelConfigRequest, + UpdateChannelConfigResponse, VerifySignatureRequest, VerifySignatureResponse, }; use ldk_server_client::ldk_server_protos::types::{ bolt11_invoice_description, Bolt11InvoiceDescription, ChannelConfig, PageToken, @@ -344,6 +344,11 @@ enum Commands { #[arg(help = "The BOLT11 invoice string to decode")] invoice: String, }, + #[command(about = "Decode a BOLT12 offer and display its fields")] + DecodeOffer { + #[arg(help = "The BOLT12 offer string to decode")] + offer: String, + }, #[command(about = "Cooperatively close the channel specified by the given channel ID")] CloseChannel { #[arg(help = "The local user_channel_id of this channel")] @@ -873,6 +878,11 @@ async fn main() { client.decode_invoice(DecodeInvoiceRequest { invoice }).await, ); }, + Commands::DecodeOffer { offer } => { + handle_response_result::<_, DecodeOfferResponse>( + client.decode_offer(DecodeOfferRequest { offer }).await, + ); + }, Commands::CloseChannel { user_channel_id, counterparty_node_id } => { handle_response_result::<_, CloseChannelResponse>( client diff --git a/ldk-server-client/src/client.rs b/ldk-server-client/src/client.rs index 20d50ed..b68d2e6 100644 --- a/ldk-server-client/src/client.rs +++ b/ldk-server-client/src/client.rs @@ -19,18 +19,18 @@ use ldk_server_protos::api::{ Bolt11ReceiveViaJitChannelResponse, Bolt11SendRequest, Bolt11SendResponse, Bolt12ReceiveRequest, Bolt12ReceiveResponse, Bolt12SendRequest, Bolt12SendResponse, CloseChannelRequest, CloseChannelResponse, ConnectPeerRequest, ConnectPeerResponse, - DecodeInvoiceRequest, DecodeInvoiceResponse, DisconnectPeerRequest, DisconnectPeerResponse, - ExportPathfindingScoresRequest, ExportPathfindingScoresResponse, ForceCloseChannelRequest, - ForceCloseChannelResponse, GetBalancesRequest, GetBalancesResponse, GetNodeInfoRequest, - GetNodeInfoResponse, GetPaymentDetailsRequest, GetPaymentDetailsResponse, - GraphGetChannelRequest, GraphGetChannelResponse, GraphGetNodeRequest, GraphGetNodeResponse, - GraphListChannelsRequest, GraphListChannelsResponse, GraphListNodesRequest, - GraphListNodesResponse, ListChannelsRequest, ListChannelsResponse, - ListForwardedPaymentsRequest, ListForwardedPaymentsResponse, ListPaymentsRequest, - ListPaymentsResponse, ListPeersRequest, ListPeersResponse, OnchainReceiveRequest, - OnchainReceiveResponse, OnchainSendRequest, OnchainSendResponse, OpenChannelRequest, - OpenChannelResponse, SignMessageRequest, SignMessageResponse, SpliceInRequest, - SpliceInResponse, SpliceOutRequest, SpliceOutResponse, SpontaneousSendRequest, + DecodeInvoiceRequest, DecodeInvoiceResponse, DecodeOfferRequest, DecodeOfferResponse, + DisconnectPeerRequest, DisconnectPeerResponse, ExportPathfindingScoresRequest, + ExportPathfindingScoresResponse, ForceCloseChannelRequest, ForceCloseChannelResponse, + GetBalancesRequest, GetBalancesResponse, GetNodeInfoRequest, GetNodeInfoResponse, + GetPaymentDetailsRequest, GetPaymentDetailsResponse, GraphGetChannelRequest, + GraphGetChannelResponse, GraphGetNodeRequest, GraphGetNodeResponse, GraphListChannelsRequest, + GraphListChannelsResponse, GraphListNodesRequest, GraphListNodesResponse, ListChannelsRequest, + ListChannelsResponse, ListForwardedPaymentsRequest, ListForwardedPaymentsResponse, + ListPaymentsRequest, ListPaymentsResponse, ListPeersRequest, ListPeersResponse, + OnchainReceiveRequest, OnchainReceiveResponse, OnchainSendRequest, OnchainSendResponse, + OpenChannelRequest, OpenChannelResponse, SignMessageRequest, SignMessageResponse, + SpliceInRequest, SpliceInResponse, SpliceOutRequest, SpliceOutResponse, SpontaneousSendRequest, SpontaneousSendResponse, UnifiedSendRequest, UnifiedSendResponse, UpdateChannelConfigRequest, UpdateChannelConfigResponse, VerifySignatureRequest, VerifySignatureResponse, }; @@ -38,10 +38,10 @@ use ldk_server_protos::endpoints::{ BOLT11_CLAIM_FOR_HASH_PATH, BOLT11_FAIL_FOR_HASH_PATH, BOLT11_RECEIVE_FOR_HASH_PATH, BOLT11_RECEIVE_PATH, BOLT11_RECEIVE_VARIABLE_AMOUNT_VIA_JIT_CHANNEL_PATH, BOLT11_RECEIVE_VIA_JIT_CHANNEL_PATH, BOLT11_SEND_PATH, BOLT12_RECEIVE_PATH, BOLT12_SEND_PATH, - CLOSE_CHANNEL_PATH, CONNECT_PEER_PATH, DECODE_INVOICE_PATH, DISCONNECT_PEER_PATH, - EXPORT_PATHFINDING_SCORES_PATH, FORCE_CLOSE_CHANNEL_PATH, GET_BALANCES_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, + 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_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, ONCHAIN_RECEIVE_PATH, ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH, SPLICE_OUT_PATH, SPONTANEOUS_SEND_PATH, UNIFIED_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH, VERIFY_SIGNATURE_PATH, @@ -374,6 +374,15 @@ impl LdkServerClient { self.post_request(&request, &url).await } + /// Decode a BOLT12 offer and return its parsed fields. + /// For API contract/usage, refer to docs for [`DecodeOfferRequest`] and [`DecodeOfferResponse`]. + pub async fn decode_offer( + &self, request: DecodeOfferRequest, + ) -> Result { + let url = format!("https://{}/{DECODE_OFFER_PATH}", self.base_url); + self.post_request(&request, &url).await + } + /// Sign a message with the node's secret key. /// For API contract/usage, refer to docs for [`SignMessageRequest`] and [`SignMessageResponse`]. pub async fn sign_message( diff --git a/ldk-server-protos/src/api.rs b/ldk-server-protos/src/api.rs index 5d010ef..27d2255 100644 --- a/ldk-server-protos/src/api.rs +++ b/ldk-server-protos/src/api.rs @@ -1131,3 +1131,58 @@ pub struct DecodeInvoiceResponse { #[prost(bool, tag = "15")] pub is_expired: bool, } +/// Decode a BOLT12 offer and return its parsed fields. +/// This does not require a running node — it only parses the offer string. +#[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::Message)] +pub struct DecodeOfferRequest { + /// The BOLT12 offer string to decode. + #[prost(string, tag = "1")] + pub offer: ::prost::alloc::string::String, +} +/// The response `content` for the `DecodeOffer` API, when HttpStatusCode is OK (200). +/// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +#[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::Message)] +pub struct DecodeOfferResponse { + /// The hex-encoded offer ID. + #[prost(string, tag = "1")] + pub offer_id: ::prost::alloc::string::String, + /// The description of the offer, if any. + #[prost(string, optional, tag = "2")] + pub description: ::core::option::Option<::prost::alloc::string::String>, + /// The issuer of the offer, if any. + #[prost(string, optional, tag = "3")] + pub issuer: ::core::option::Option<::prost::alloc::string::String>, + /// The amount, if specified. + #[prost(message, optional, tag = "4")] + pub amount: ::core::option::Option, + /// The hex-encoded public key used by the issuer to sign invoices, if any. + #[prost(string, optional, tag = "5")] + pub issuer_signing_pubkey: ::core::option::Option<::prost::alloc::string::String>, + /// The absolute expiry time in seconds since the UNIX epoch, if any. + #[prost(uint64, optional, tag = "6")] + pub absolute_expiry: ::core::option::Option, + /// The supported quantity of items. + #[prost(message, optional, tag = "7")] + pub quantity: ::core::option::Option, + /// Blinded paths to the offer recipient. + #[prost(message, repeated, tag = "8")] + pub paths: ::prost::alloc::vec::Vec, + /// Feature bits advertised in the offer, keyed by bit number. + #[prost(map = "uint32, message", tag = "9")] + pub features: ::std::collections::HashMap, + /// Supported blockchain networks (e.g., "bitcoin", "testnet", "signet", "regtest"). + #[prost(string, repeated, tag = "10")] + pub chains: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + /// The metadata, hex-encoded, if any. + #[prost(string, optional, tag = "11")] + pub metadata: ::core::option::Option<::prost::alloc::string::String>, + /// Whether the offer has expired. + #[prost(bool, tag = "12")] + pub is_expired: bool, +} diff --git a/ldk-server-protos/src/endpoints.rs b/ldk-server-protos/src/endpoints.rs index 6d6c13b..a9ab7b4 100644 --- a/ldk-server-protos/src/endpoints.rs +++ b/ldk-server-protos/src/endpoints.rs @@ -44,3 +44,4 @@ pub const GRAPH_GET_CHANNEL_PATH: &str = "GraphGetChannel"; pub const GRAPH_LIST_NODES_PATH: &str = "GraphListNodes"; pub const GRAPH_GET_NODE_PATH: &str = "GraphGetNode"; pub const DECODE_INVOICE_PATH: &str = "DecodeInvoice"; +pub const DECODE_OFFER_PATH: &str = "DecodeOffer"; diff --git a/ldk-server-protos/src/proto/api.proto b/ldk-server-protos/src/proto/api.proto index e15846e..4d21c4d 100644 --- a/ldk-server-protos/src/proto/api.proto +++ b/ldk-server-protos/src/proto/api.proto @@ -878,3 +878,50 @@ message DecodeInvoiceResponse { // Whether the invoice has expired. bool is_expired = 15; } + +// Decode a BOLT12 offer and return its parsed fields. +// This does not require a running node — it only parses the offer string. +message DecodeOfferRequest { + // The BOLT12 offer string to decode. + string offer = 1; +} + +// The response `content` for the `DecodeOffer` API, when HttpStatusCode is OK (200). +// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +message DecodeOfferResponse { + // The hex-encoded offer ID. + string offer_id = 1; + + // The description of the offer, if any. + optional string description = 2; + + // The issuer of the offer, if any. + optional string issuer = 3; + + // The amount, if specified. + types.OfferAmount amount = 4; + + // The hex-encoded public key used by the issuer to sign invoices, if any. + optional string issuer_signing_pubkey = 5; + + // The absolute expiry time in seconds since the UNIX epoch, if any. + optional uint64 absolute_expiry = 6; + + // The supported quantity of items. + types.OfferQuantity quantity = 7; + + // Blinded paths to the offer recipient. + repeated types.BlindedPath paths = 8; + + // Feature bits advertised in the offer, keyed by bit number. + map features = 9; + + // Supported blockchain networks (e.g., "bitcoin", "testnet", "signet", "regtest"). + repeated string chains = 10; + + // The metadata, hex-encoded, if any. + optional string metadata = 11; + + // Whether the offer has expired. + bool is_expired = 12; +} diff --git a/ldk-server-protos/src/proto/types.proto b/ldk-server-protos/src/proto/types.proto index 5af5ec3..57c1c61 100644 --- a/ldk-server-protos/src/proto/types.proto +++ b/ldk-server-protos/src/proto/types.proto @@ -843,6 +843,57 @@ message Bolt11HopHint { uint32 cltv_expiry_delta = 5; } +// The amount specified in a BOLT12 offer. +message OfferAmount { + oneof amount { + // Amount in millisatoshis for Bitcoin payments. + uint64 bitcoin_amount_msats = 1; + + // Amount in a non-Bitcoin currency. + CurrencyAmount currency_amount = 2; + } +} + +// A non-Bitcoin currency amount. +message CurrencyAmount { + // ISO 4217 currency code (e.g., "USD", "EUR"). + string iso4217_code = 1; + + // The amount in the specified currency's minor unit. + uint64 amount = 2; +} + +// The quantity of items supported by a BOLT12 offer. +message OfferQuantity { + oneof quantity { + // Only one item may be requested. + bool one = 1; + + // Up to this many items may be requested. + uint64 bounded = 2; + + // Any number of items may be requested. + bool unbounded = 3; + } +} + +// 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; + + // The hex-encoded blinding point. + string blinding_point = 2; + + // The number of blinded hops in the path. + uint32 num_hops = 3; + + // If the introduction node is a directed short channel ID rather than a node ID. + optional uint64 introduction_scid = 4; +} + // A feature bit advertised in a BOLT11 invoice. message Bolt11Feature { // Human-readable feature name. diff --git a/ldk-server-protos/src/types.rs b/ldk-server-protos/src/types.rs index 7b383cf..75bb6ea 100644 --- a/ldk-server-protos/src/types.rs +++ b/ldk-server-protos/src/types.rs @@ -1067,6 +1067,91 @@ pub struct Bolt11HopHint { #[prost(uint32, tag = "5")] pub cltv_expiry_delta: u32, } +/// The amount specified in a BOLT12 offer. +#[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::Message)] +pub struct OfferAmount { + #[prost(oneof = "offer_amount::Amount", tags = "1, 2")] + pub amount: ::core::option::Option, +} +/// Nested message and enum types in `OfferAmount`. +pub mod offer_amount { + #[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 Amount { + /// Amount in millisatoshis for Bitcoin payments. + #[prost(uint64, tag = "1")] + BitcoinAmountMsats(u64), + /// Amount in a non-Bitcoin currency. + #[prost(message, tag = "2")] + CurrencyAmount(super::CurrencyAmount), + } +} +/// A non-Bitcoin currency amount. +#[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::Message)] +pub struct CurrencyAmount { + /// ISO 4217 currency code (e.g., "USD", "EUR"). + #[prost(string, tag = "1")] + pub iso4217_code: ::prost::alloc::string::String, + /// The amount in the specified currency's minor unit. + #[prost(uint64, tag = "2")] + pub amount: u64, +} +/// The quantity of items supported by a BOLT12 offer. +#[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::Message)] +pub struct OfferQuantity { + #[prost(oneof = "offer_quantity::Quantity", tags = "1, 2, 3")] + pub quantity: ::core::option::Option, +} +/// Nested message and enum types in `OfferQuantity`. +pub mod offer_quantity { + #[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 Quantity { + /// Only one item may be requested. + #[prost(bool, tag = "1")] + One(bool), + /// Up to this many items may be requested. + #[prost(uint64, tag = "2")] + Bounded(u64), + /// Any number of items may be requested. + #[prost(bool, tag = "3")] + Unbounded(bool), + } +} +/// A blinded path to the offer recipient. +#[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::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")] + pub blinding_point: ::prost::alloc::string::String, + /// The number of blinded hops in the path. + #[prost(uint32, tag = "3")] + 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, +} /// A feature bit advertised in a BOLT11 invoice. #[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/decode_offer.rs b/ldk-server/src/api/decode_offer.rs new file mode 100644 index 0000000..adb12dc --- /dev/null +++ b/ldk-server/src/api/decode_offer.rs @@ -0,0 +1,128 @@ +// 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::str::FromStr; + +use hex::prelude::*; +use ldk_node::lightning::bitcoin::blockdata::constants::ChainHash; +use ldk_node::lightning::bitcoin::Network; +use ldk_node::lightning::offers::offer::Offer; +use ldk_node::lightning_types::features::OfferFeatures; +use ldk_server_protos::api::{DecodeOfferRequest, DecodeOfferResponse}; +use ldk_server_protos::types::offer_amount::Amount; +use ldk_server_protos::types::offer_quantity::Quantity; +use ldk_server_protos::types::{BlindedPath, CurrencyAmount, OfferAmount, OfferQuantity}; + +use crate::api::decode_features; +use crate::api::error::LdkServerError; +use crate::service::Context; + +pub(crate) fn handle_decode_offer_request( + _context: Context, request: DecodeOfferRequest, +) -> Result { + let offer = + Offer::from_str(request.offer.as_str()).map_err(|_| ldk_node::NodeError::InvalidOffer)?; + + let offer_id = offer.id().0.to_lower_hex_string(); + + let description = offer.description().map(|d| d.to_string()); + + let issuer = offer.issuer().map(|i| i.to_string()); + + let amount = offer.amount().map(|a| match a { + ldk_node::lightning::offers::offer::Amount::Bitcoin { amount_msats } => { + OfferAmount { amount: Some(Amount::BitcoinAmountMsats(amount_msats)) } + }, + ldk_node::lightning::offers::offer::Amount::Currency { iso4217_code, amount } => { + OfferAmount { + amount: Some(Amount::CurrencyAmount(CurrencyAmount { + iso4217_code: iso4217_code.to_string(), + amount, + })), + } + }, + }); + + let issuer_signing_pubkey = offer.issuer_signing_pubkey().map(|pk| pk.to_string()); + + let absolute_expiry = offer.absolute_expiry().map(|d| d.as_secs()); + + let quantity = Some(match offer.supported_quantity() { + ldk_node::lightning::offers::offer::Quantity::One => { + OfferQuantity { quantity: Some(Quantity::One(true)) } + }, + ldk_node::lightning::offers::offer::Quantity::Bounded(max) => { + OfferQuantity { quantity: Some(Quantity::Bounded(max.get())) } + }, + ldk_node::lightning::offers::offer::Quantity::Unbounded => { + OfferQuantity { quantity: Some(Quantity::Unbounded(true)) } + }, + }); + + let paths = offer + .paths() + .iter() + .map(|path| { + let (introduction_node_id, introduction_scid) = match path.introduction_node() { + ldk_node::lightning::blinded_path::IntroductionNode::NodeId(pk) => { + (Some(pk.to_string()), None) + }, + ldk_node::lightning::blinded_path::IntroductionNode::DirectedShortChannelId( + _dir, + scid, + ) => (None, Some(*scid)), + }; + BlindedPath { + introduction_node_id, + blinding_point: path.blinding_point().to_string(), + num_hops: path.blinded_hops().len() as u32, + introduction_scid, + } + }) + .collect(); + + let features = decode_features(offer.offer_features().le_flags(), |bytes| { + OfferFeatures::from_le_bytes(bytes).to_string() + }); + + let chains = offer.chains().into_iter().map(chain_hash_to_name).collect(); + + let metadata = offer.metadata().map(|m| m.to_lower_hex_string()); + + let is_expired = offer.is_expired(); + + Ok(DecodeOfferResponse { + offer_id, + description, + issuer, + amount, + issuer_signing_pubkey, + absolute_expiry, + quantity, + paths, + features, + chains, + metadata, + is_expired, + }) +} + +fn chain_hash_to_name(chain: ChainHash) -> String { + if chain == ChainHash::using_genesis_block(Network::Bitcoin) { + "bitcoin".to_string() + } else if chain == ChainHash::using_genesis_block(Network::Testnet) { + "testnet".to_string() + } else if chain == ChainHash::using_genesis_block(Network::Regtest) { + "regtest".to_string() + } else if chain == ChainHash::using_genesis_block(Network::Signet) { + "signet".to_string() + } else { + chain.to_string() + } +} diff --git a/ldk-server/src/api/mod.rs b/ldk-server/src/api/mod.rs index 0ea5945..e43110a 100644 --- a/ldk-server/src/api/mod.rs +++ b/ldk-server/src/api/mod.rs @@ -28,6 +28,7 @@ pub(crate) mod bolt12_send; pub(crate) mod close_channel; pub(crate) mod connect_peer; pub(crate) mod decode_invoice; +pub(crate) mod decode_offer; pub(crate) mod disconnect_peer; pub(crate) mod error; pub(crate) mod export_pathfinding_scores; diff --git a/ldk-server/src/service.rs b/ldk-server/src/service.rs index b0b4905..8ba37f6 100644 --- a/ldk-server/src/service.rs +++ b/ldk-server/src/service.rs @@ -22,10 +22,10 @@ use ldk_server_protos::endpoints::{ BOLT11_CLAIM_FOR_HASH_PATH, BOLT11_FAIL_FOR_HASH_PATH, BOLT11_RECEIVE_FOR_HASH_PATH, BOLT11_RECEIVE_PATH, BOLT11_RECEIVE_VARIABLE_AMOUNT_VIA_JIT_CHANNEL_PATH, BOLT11_RECEIVE_VIA_JIT_CHANNEL_PATH, BOLT11_SEND_PATH, BOLT12_RECEIVE_PATH, BOLT12_SEND_PATH, - CLOSE_CHANNEL_PATH, CONNECT_PEER_PATH, DECODE_INVOICE_PATH, DISCONNECT_PEER_PATH, - EXPORT_PATHFINDING_SCORES_PATH, FORCE_CLOSE_CHANNEL_PATH, GET_BALANCES_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, + 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_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, ONCHAIN_RECEIVE_PATH, ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH, SPLICE_OUT_PATH, SPONTANEOUS_SEND_PATH, UNIFIED_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH, VERIFY_SIGNATURE_PATH, @@ -46,6 +46,7 @@ use crate::api::bolt12_send::handle_bolt12_send_request; use crate::api::close_channel::{handle_close_channel_request, handle_force_close_channel_request}; use crate::api::connect_peer::handle_connect_peer; use crate::api::decode_invoice::handle_decode_invoice_request; +use crate::api::decode_offer::handle_decode_offer_request; use crate::api::disconnect_peer::handle_disconnect_peer; use crate::api::error::LdkServerError; use crate::api::error::LdkServerErrorCode::{AuthError, InvalidRequestError}; @@ -435,6 +436,13 @@ impl Service> for NodeService { api_key, handle_decode_invoice_request, )), + DECODE_OFFER_PATH => Box::pin(handle_request( + context, + req, + auth_params, + api_key, + handle_decode_offer_request, + )), path => { let error = format!("Unknown request: {}", path).into_bytes(); Box::pin(async {