Skip to content

Commit cdaeb98

Browse files
benthecarmanclaude
andcommitted
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) <noreply@anthropic.com>
1 parent f09a262 commit cdaeb98

11 files changed

Lines changed: 497 additions & 37 deletions

File tree

e2e-tests/tests/e2e.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,70 @@ async fn test_cli_bolt11_receive() {
148148
assert_eq!(invoice.payment_secret().0, payment_secret);
149149
}
150150

151+
#[tokio::test]
152+
async fn test_cli_decode_invoice() {
153+
let bitcoind = TestBitcoind::new();
154+
let server = LdkServerHandle::start(&bitcoind).await;
155+
156+
// Create a BOLT11 invoice with known parameters
157+
let output =
158+
run_cli(&server, &["bolt11-receive", "50000sat", "-d", "decode test", "-e", "3600"]);
159+
let invoice_str = output["invoice"].as_str().unwrap();
160+
161+
// Decode it
162+
let decoded = run_cli(&server, &["decode-invoice", invoice_str]);
163+
164+
// Verify fields match
165+
assert_eq!(decoded["destination"], server.node_id());
166+
assert_eq!(decoded["payment_hash"], output["payment_hash"]);
167+
assert_eq!(decoded["amount_msat"], 50_000_000);
168+
assert_eq!(decoded["description"], "decode test");
169+
assert!(decoded.get("description_hash").is_none() || decoded["description_hash"].is_null());
170+
assert_eq!(decoded["expiry"], 3600);
171+
assert_eq!(decoded["currency"], "regtest");
172+
assert_eq!(decoded["payment_secret"], output["payment_secret"]);
173+
assert!(decoded["timestamp"].as_u64().unwrap() > 0);
174+
assert!(decoded["min_final_cltv_expiry_delta"].as_u64().unwrap() > 0);
175+
assert_eq!(decoded["is_expired"], false);
176+
177+
// Verify features — LDK BOLT11 invoices always set VariableLengthOnion, PaymentSecret,
178+
// and BasicMPP.
179+
let features = decoded["features"].as_object().unwrap();
180+
assert!(!features.is_empty(), "Expected at least one feature");
181+
182+
let feature_names: Vec<&str> = features.values().filter_map(|f| f["name"].as_str()).collect();
183+
assert!(
184+
feature_names.contains(&"VariableLengthOnion"),
185+
"Expected VariableLengthOnion in features: {:?}",
186+
feature_names
187+
);
188+
assert!(
189+
feature_names.contains(&"PaymentSecret"),
190+
"Expected PaymentSecret in features: {:?}",
191+
feature_names
192+
);
193+
assert!(
194+
feature_names.contains(&"BasicMPP"),
195+
"Expected BasicMPP in features: {:?}",
196+
feature_names
197+
);
198+
199+
// Every entry should have the expected structure
200+
for (bit, feature) in features {
201+
assert!(bit.parse::<u32>().is_ok(), "Feature key should be a bit number: {}", bit);
202+
assert!(feature.get("name").is_some(), "Feature missing name field");
203+
assert!(feature.get("is_required").is_some(), "Feature missing is_required field");
204+
assert!(feature.get("is_known").is_some(), "Feature missing is_known field");
205+
}
206+
207+
// Also test a variable-amount invoice
208+
let output_var = run_cli(&server, &["bolt11-receive", "-d", "no amount"]);
209+
let decoded_var =
210+
run_cli(&server, &["decode-invoice", output_var["invoice"].as_str().unwrap()]);
211+
assert!(decoded_var.get("amount_msat").is_none() || decoded_var["amount_msat"].is_null());
212+
assert_eq!(decoded_var["description"], "no amount");
213+
}
214+
151215
#[tokio::test]
152216
async fn test_cli_bolt12_receive() {
153217
let bitcoind = TestBitcoind::new();

ldk-server-cli/src/main.rs

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,18 +29,19 @@ use ldk_server_client::ldk_server_protos::api::{
2929
Bolt11ReceiveViaJitChannelResponse, Bolt11SendRequest, Bolt11SendResponse,
3030
Bolt12ReceiveRequest, Bolt12ReceiveResponse, Bolt12SendRequest, Bolt12SendResponse,
3131
CloseChannelRequest, CloseChannelResponse, ConnectPeerRequest, ConnectPeerResponse,
32-
DisconnectPeerRequest, DisconnectPeerResponse, ExportPathfindingScoresRequest,
33-
ForceCloseChannelRequest, ForceCloseChannelResponse, GetBalancesRequest, GetBalancesResponse,
34-
GetNodeInfoRequest, GetNodeInfoResponse, GetPaymentDetailsRequest, GetPaymentDetailsResponse,
35-
GraphGetChannelRequest, GraphGetChannelResponse, GraphGetNodeRequest, GraphGetNodeResponse,
36-
GraphListChannelsRequest, GraphListChannelsResponse, GraphListNodesRequest,
37-
GraphListNodesResponse, ListChannelsRequest, ListChannelsResponse,
38-
ListForwardedPaymentsRequest, ListPaymentsRequest, ListPeersRequest, ListPeersResponse,
39-
OnchainReceiveRequest, OnchainReceiveResponse, OnchainSendRequest, OnchainSendResponse,
40-
OpenChannelRequest, OpenChannelResponse, SignMessageRequest, SignMessageResponse,
41-
SpliceInRequest, SpliceInResponse, SpliceOutRequest, SpliceOutResponse, SpontaneousSendRequest,
42-
SpontaneousSendResponse, UnifiedSendRequest, UnifiedSendResponse, UpdateChannelConfigRequest,
43-
UpdateChannelConfigResponse, VerifySignatureRequest, VerifySignatureResponse,
32+
DecodeInvoiceRequest, DecodeInvoiceResponse, DisconnectPeerRequest, DisconnectPeerResponse,
33+
ExportPathfindingScoresRequest, ForceCloseChannelRequest, ForceCloseChannelResponse,
34+
GetBalancesRequest, GetBalancesResponse, GetNodeInfoRequest, GetNodeInfoResponse,
35+
GetPaymentDetailsRequest, GetPaymentDetailsResponse, GraphGetChannelRequest,
36+
GraphGetChannelResponse, GraphGetNodeRequest, GraphGetNodeResponse, GraphListChannelsRequest,
37+
GraphListChannelsResponse, GraphListNodesRequest, GraphListNodesResponse, ListChannelsRequest,
38+
ListChannelsResponse, ListForwardedPaymentsRequest, ListPaymentsRequest, ListPeersRequest,
39+
ListPeersResponse, OnchainReceiveRequest, OnchainReceiveResponse, OnchainSendRequest,
40+
OnchainSendResponse, OpenChannelRequest, OpenChannelResponse, SignMessageRequest,
41+
SignMessageResponse, SpliceInRequest, SpliceInResponse, SpliceOutRequest, SpliceOutResponse,
42+
SpontaneousSendRequest, SpontaneousSendResponse, UnifiedSendRequest, UnifiedSendResponse,
43+
UpdateChannelConfigRequest, UpdateChannelConfigResponse, VerifySignatureRequest,
44+
VerifySignatureResponse,
4445
};
4546
use ldk_server_client::ldk_server_protos::types::{
4647
bolt11_invoice_description, Bolt11InvoiceDescription, ChannelConfig, PageToken,
@@ -338,6 +339,11 @@ enum Commands {
338339
)]
339340
max_channel_saturation_power_of_half: Option<u32>,
340341
},
342+
#[command(about = "Decode a BOLT11 invoice and display its fields")]
343+
DecodeInvoice {
344+
#[arg(help = "The BOLT11 invoice string to decode")]
345+
invoice: String,
346+
},
341347
#[command(about = "Cooperatively close the channel specified by the given channel ID")]
342348
CloseChannel {
343349
#[arg(help = "The local user_channel_id of this channel")]
@@ -862,6 +868,11 @@ async fn main() {
862868
.await,
863869
);
864870
},
871+
Commands::DecodeInvoice { invoice } => {
872+
handle_response_result::<_, DecodeInvoiceResponse>(
873+
client.decode_invoice(DecodeInvoiceRequest { invoice }).await,
874+
);
875+
},
865876
Commands::CloseChannel { user_channel_id, counterparty_node_id } => {
866877
handle_response_result::<_, CloseChannelResponse>(
867878
client

ldk-server-client/src/client.rs

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,31 +19,32 @@ use ldk_server_protos::api::{
1919
Bolt11ReceiveViaJitChannelResponse, Bolt11SendRequest, Bolt11SendResponse,
2020
Bolt12ReceiveRequest, Bolt12ReceiveResponse, Bolt12SendRequest, Bolt12SendResponse,
2121
CloseChannelRequest, CloseChannelResponse, ConnectPeerRequest, ConnectPeerResponse,
22-
DisconnectPeerRequest, DisconnectPeerResponse, ExportPathfindingScoresRequest,
23-
ExportPathfindingScoresResponse, ForceCloseChannelRequest, ForceCloseChannelResponse,
24-
GetBalancesRequest, GetBalancesResponse, GetNodeInfoRequest, GetNodeInfoResponse,
25-
GetPaymentDetailsRequest, GetPaymentDetailsResponse, GraphGetChannelRequest,
26-
GraphGetChannelResponse, GraphGetNodeRequest, GraphGetNodeResponse, GraphListChannelsRequest,
27-
GraphListChannelsResponse, GraphListNodesRequest, GraphListNodesResponse, ListChannelsRequest,
28-
ListChannelsResponse, ListForwardedPaymentsRequest, ListForwardedPaymentsResponse,
29-
ListPaymentsRequest, ListPaymentsResponse, ListPeersRequest, ListPeersResponse,
30-
OnchainReceiveRequest, OnchainReceiveResponse, OnchainSendRequest, OnchainSendResponse,
31-
OpenChannelRequest, OpenChannelResponse, SignMessageRequest, SignMessageResponse,
32-
SpliceInRequest, SpliceInResponse, SpliceOutRequest, SpliceOutResponse, SpontaneousSendRequest,
22+
DecodeInvoiceRequest, DecodeInvoiceResponse, DisconnectPeerRequest, DisconnectPeerResponse,
23+
ExportPathfindingScoresRequest, ExportPathfindingScoresResponse, ForceCloseChannelRequest,
24+
ForceCloseChannelResponse, GetBalancesRequest, GetBalancesResponse, GetNodeInfoRequest,
25+
GetNodeInfoResponse, GetPaymentDetailsRequest, GetPaymentDetailsResponse,
26+
GraphGetChannelRequest, GraphGetChannelResponse, GraphGetNodeRequest, GraphGetNodeResponse,
27+
GraphListChannelsRequest, GraphListChannelsResponse, GraphListNodesRequest,
28+
GraphListNodesResponse, ListChannelsRequest, ListChannelsResponse,
29+
ListForwardedPaymentsRequest, ListForwardedPaymentsResponse, ListPaymentsRequest,
30+
ListPaymentsResponse, ListPeersRequest, ListPeersResponse, OnchainReceiveRequest,
31+
OnchainReceiveResponse, OnchainSendRequest, OnchainSendResponse, OpenChannelRequest,
32+
OpenChannelResponse, SignMessageRequest, SignMessageResponse, SpliceInRequest,
33+
SpliceInResponse, SpliceOutRequest, SpliceOutResponse, SpontaneousSendRequest,
3334
SpontaneousSendResponse, UnifiedSendRequest, UnifiedSendResponse, UpdateChannelConfigRequest,
3435
UpdateChannelConfigResponse, VerifySignatureRequest, VerifySignatureResponse,
3536
};
3637
use ldk_server_protos::endpoints::{
3738
BOLT11_CLAIM_FOR_HASH_PATH, BOLT11_FAIL_FOR_HASH_PATH, BOLT11_RECEIVE_FOR_HASH_PATH,
3839
BOLT11_RECEIVE_PATH, BOLT11_RECEIVE_VARIABLE_AMOUNT_VIA_JIT_CHANNEL_PATH,
3940
BOLT11_RECEIVE_VIA_JIT_CHANNEL_PATH, BOLT11_SEND_PATH, BOLT12_RECEIVE_PATH, BOLT12_SEND_PATH,
40-
CLOSE_CHANNEL_PATH, CONNECT_PEER_PATH, DISCONNECT_PEER_PATH, EXPORT_PATHFINDING_SCORES_PATH,
41-
FORCE_CLOSE_CHANNEL_PATH, GET_BALANCES_PATH, GET_NODE_INFO_PATH, GET_PAYMENT_DETAILS_PATH,
42-
GRAPH_GET_CHANNEL_PATH, GRAPH_GET_NODE_PATH, GRAPH_LIST_CHANNELS_PATH, GRAPH_LIST_NODES_PATH,
43-
LIST_CHANNELS_PATH, LIST_FORWARDED_PAYMENTS_PATH, LIST_PAYMENTS_PATH, LIST_PEERS_PATH,
44-
ONCHAIN_RECEIVE_PATH, ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH,
45-
SPLICE_OUT_PATH, SPONTANEOUS_SEND_PATH, UNIFIED_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH,
46-
VERIFY_SIGNATURE_PATH,
41+
CLOSE_CHANNEL_PATH, CONNECT_PEER_PATH, DECODE_INVOICE_PATH, DISCONNECT_PEER_PATH,
42+
EXPORT_PATHFINDING_SCORES_PATH, FORCE_CLOSE_CHANNEL_PATH, GET_BALANCES_PATH,
43+
GET_NODE_INFO_PATH, GET_PAYMENT_DETAILS_PATH, GRAPH_GET_CHANNEL_PATH, GRAPH_GET_NODE_PATH,
44+
GRAPH_LIST_CHANNELS_PATH, GRAPH_LIST_NODES_PATH, LIST_CHANNELS_PATH,
45+
LIST_FORWARDED_PAYMENTS_PATH, LIST_PAYMENTS_PATH, LIST_PEERS_PATH, ONCHAIN_RECEIVE_PATH,
46+
ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH, SPLICE_OUT_PATH,
47+
SPONTANEOUS_SEND_PATH, UNIFIED_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH, VERIFY_SIGNATURE_PATH,
4748
};
4849
use ldk_server_protos::error::{ErrorCode, ErrorResponse};
4950
use prost::Message;
@@ -364,6 +365,15 @@ impl LdkServerClient {
364365
self.post_request(&request, &url).await
365366
}
366367

368+
/// Decode a BOLT11 invoice and return its parsed fields.
369+
/// For API contract/usage, refer to docs for [`DecodeInvoiceRequest`] and [`DecodeInvoiceResponse`].
370+
pub async fn decode_invoice(
371+
&self, request: DecodeInvoiceRequest,
372+
) -> Result<DecodeInvoiceResponse, LdkServerError> {
373+
let url = format!("https://{}/{DECODE_INVOICE_PATH}", self.base_url);
374+
self.post_request(&request, &url).await
375+
}
376+
367377
/// Sign a message with the node's secret key.
368378
/// For API contract/usage, refer to docs for [`SignMessageRequest`] and [`SignMessageResponse`].
369379
pub async fn sign_message(

ldk-server-protos/src/api.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1067,3 +1067,67 @@ pub struct GraphGetNodeResponse {
10671067
#[prost(message, optional, tag = "1")]
10681068
pub node: ::core::option::Option<super::types::GraphNode>,
10691069
}
1070+
/// Decode a BOLT11 invoice and return its parsed fields.
1071+
/// This does not require a running node — it only parses the invoice string.
1072+
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1073+
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
1074+
#[allow(clippy::derive_partial_eq_without_eq)]
1075+
#[derive(Clone, PartialEq, ::prost::Message)]
1076+
pub struct DecodeInvoiceRequest {
1077+
/// The BOLT11 invoice string to decode.
1078+
#[prost(string, tag = "1")]
1079+
pub invoice: ::prost::alloc::string::String,
1080+
}
1081+
/// The response `content` for the `DecodeInvoice` API, when HttpStatusCode is OK (200).
1082+
/// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`.
1083+
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1084+
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
1085+
#[allow(clippy::derive_partial_eq_without_eq)]
1086+
#[derive(Clone, PartialEq, ::prost::Message)]
1087+
pub struct DecodeInvoiceResponse {
1088+
/// The hex-encoded public key of the destination node.
1089+
#[prost(string, tag = "1")]
1090+
pub destination: ::prost::alloc::string::String,
1091+
/// The hex-encoded 32-byte payment hash.
1092+
#[prost(string, tag = "2")]
1093+
pub payment_hash: ::prost::alloc::string::String,
1094+
/// The amount in millisatoshis, if specified in the invoice.
1095+
#[prost(uint64, optional, tag = "3")]
1096+
pub amount_msat: ::core::option::Option<u64>,
1097+
/// The creation timestamp in seconds since the UNIX epoch.
1098+
#[prost(uint64, tag = "4")]
1099+
pub timestamp: u64,
1100+
/// The invoice expiry time in seconds.
1101+
#[prost(uint64, tag = "5")]
1102+
pub expiry: u64,
1103+
/// The invoice description, if a direct description was provided.
1104+
#[prost(string, optional, tag = "6")]
1105+
pub description: ::core::option::Option<::prost::alloc::string::String>,
1106+
/// The hex-encoded SHA-256 hash of the description, if a description hash was used.
1107+
#[prost(string, optional, tag = "14")]
1108+
pub description_hash: ::core::option::Option<::prost::alloc::string::String>,
1109+
/// The fallback on-chain address, if any.
1110+
#[prost(string, optional, tag = "7")]
1111+
pub fallback_address: ::core::option::Option<::prost::alloc::string::String>,
1112+
/// The minimum final CLTV expiry delta.
1113+
#[prost(uint64, tag = "8")]
1114+
pub min_final_cltv_expiry_delta: u64,
1115+
/// The hex-encoded 32-byte payment secret.
1116+
#[prost(string, tag = "9")]
1117+
pub payment_secret: ::prost::alloc::string::String,
1118+
/// Route hints for finding a path to the payee.
1119+
#[prost(message, repeated, tag = "10")]
1120+
pub route_hints: ::prost::alloc::vec::Vec<super::types::Bolt11RouteHint>,
1121+
/// Feature bits advertised in the invoice, keyed by bit number.
1122+
#[prost(map = "uint32, message", tag = "11")]
1123+
pub features: ::std::collections::HashMap<u32, super::types::Bolt11Feature>,
1124+
/// The currency or network (e.g., "bitcoin", "testnet", "signet", "regtest").
1125+
#[prost(string, tag = "12")]
1126+
pub currency: ::prost::alloc::string::String,
1127+
/// The payment metadata, hex-encoded. Only present if the invoice includes payment metadata.
1128+
#[prost(string, optional, tag = "13")]
1129+
pub payment_metadata: ::core::option::Option<::prost::alloc::string::String>,
1130+
/// Whether the invoice has expired.
1131+
#[prost(bool, tag = "15")]
1132+
pub is_expired: bool,
1133+
}

ldk-server-protos/src/endpoints.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,4 @@ pub const GRAPH_LIST_CHANNELS_PATH: &str = "GraphListChannels";
4343
pub const GRAPH_GET_CHANNEL_PATH: &str = "GraphGetChannel";
4444
pub const GRAPH_LIST_NODES_PATH: &str = "GraphListNodes";
4545
pub const GRAPH_GET_NODE_PATH: &str = "GraphGetNode";
46+
pub const DECODE_INVOICE_PATH: &str = "DecodeInvoice";

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

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -822,3 +822,59 @@ message GraphGetNodeResponse {
822822
// The node information.
823823
types.GraphNode node = 1;
824824
}
825+
826+
// Decode a BOLT11 invoice and return its parsed fields.
827+
// This does not require a running node — it only parses the invoice string.
828+
message DecodeInvoiceRequest {
829+
// The BOLT11 invoice string to decode.
830+
string invoice = 1;
831+
}
832+
833+
// The response `content` for the `DecodeInvoice` API, when HttpStatusCode is OK (200).
834+
// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`.
835+
message DecodeInvoiceResponse {
836+
// The hex-encoded public key of the destination node.
837+
string destination = 1;
838+
839+
// The hex-encoded 32-byte payment hash.
840+
string payment_hash = 2;
841+
842+
// The amount in millisatoshis, if specified in the invoice.
843+
optional uint64 amount_msat = 3;
844+
845+
// The creation timestamp in seconds since the UNIX epoch.
846+
uint64 timestamp = 4;
847+
848+
// The invoice expiry time in seconds.
849+
uint64 expiry = 5;
850+
851+
// The invoice description, if a direct description was provided.
852+
optional string description = 6;
853+
854+
// The hex-encoded SHA-256 hash of the description, if a description hash was used.
855+
optional string description_hash = 14;
856+
857+
// The fallback on-chain address, if any.
858+
optional string fallback_address = 7;
859+
860+
// The minimum final CLTV expiry delta.
861+
uint64 min_final_cltv_expiry_delta = 8;
862+
863+
// The hex-encoded 32-byte payment secret.
864+
string payment_secret = 9;
865+
866+
// Route hints for finding a path to the payee.
867+
repeated types.Bolt11RouteHint route_hints = 10;
868+
869+
// Feature bits advertised in the invoice, keyed by bit number.
870+
map<uint32, types.Bolt11Feature> features = 11;
871+
872+
// The currency or network (e.g., "bitcoin", "testnet", "signet", "regtest").
873+
string currency = 12;
874+
875+
// The payment metadata, hex-encoded. Only present if the invoice includes payment metadata.
876+
optional string payment_metadata = 13;
877+
878+
// Whether the invoice has expired.
879+
bool is_expired = 15;
880+
}

ldk-server-protos/src/proto/types.proto

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -818,3 +818,39 @@ message GraphNode {
818818
// a channel announcement, but before receiving a node announcement.
819819
GraphNodeAnnouncement announcement_info = 2;
820820
}
821+
822+
// Route hint for finding a path to the payee in a BOLT11 invoice.
823+
message Bolt11RouteHint {
824+
// The hops in this route hint.
825+
repeated Bolt11HopHint hop_hints = 1;
826+
}
827+
828+
// A hop in a BOLT11 route hint.
829+
message Bolt11HopHint {
830+
// The hex-encoded public key of the node at this hop.
831+
string node_id = 1;
832+
833+
// The short channel ID.
834+
uint64 short_channel_id = 2;
835+
836+
// The base fee in millisatoshis charged for routing through this hop.
837+
uint32 fee_base_msat = 3;
838+
839+
// Fee proportional millionths charged for routing through this hop.
840+
uint32 fee_proportional_millionths = 4;
841+
842+
// The CLTV expiry delta for this hop.
843+
uint32 cltv_expiry_delta = 5;
844+
}
845+
846+
// A feature bit advertised in a BOLT11 invoice.
847+
message Bolt11Feature {
848+
// Human-readable feature name.
849+
string name = 1;
850+
851+
// Whether this feature is required.
852+
bool is_required = 2;
853+
854+
// Whether this feature is known.
855+
bool is_known = 3;
856+
}

0 commit comments

Comments
 (0)