Skip to content

Commit d782e4a

Browse files
Decode BOLT12 invoices in the DecodeInvoice API
The `DecodeInvoice` RPC previously accepted only a BOLT11 invoice string. It now also accepts a hex-encoded BOLT12 invoice, and the response carries a new `kind` field ("bolt11" or "bolt12") identifying which was decoded. Unlike offers and BOLT11 invoices, a BOLT12 invoice has no human-readable string encoding -- it is exchanged as raw bytes over onion messages -- so the input is expected to be hex-encoded, and LDK's `Bolt12Invoice` is parsed via `TryFrom<Vec<u8>>` accordingly. Per the minimal scope here, only `kind` is populated for a BOLT12 invoice; the remaining fields continue to apply to BOLT11 invoices. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 56be35f commit d782e4a

9 files changed

Lines changed: 131 additions & 16 deletions

File tree

e2e-tests/tests/e2e.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ async fn test_cli_decode_invoice() {
193193
assert!(decoded["timestamp"].as_u64().unwrap() > 0);
194194
assert!(decoded["min_final_cltv_expiry_delta"].as_u64().unwrap() > 0);
195195
assert_eq!(decoded["is_expired"], false);
196+
assert_eq!(decoded["kind"], "bolt11");
196197

197198
// Verify features — LDK BOLT11 invoices always set VariableLengthOnion, PaymentSecret,
198199
// and BasicMPP.

e2e-tests/tests/mcp.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,5 @@ async fn test_mcp_live_tool_calls() {
8484
assert_eq!(decode_invoice_json["destination"], server.node_id());
8585
assert_eq!(decode_invoice_json["description"], "mcp decode");
8686
assert_eq!(decode_invoice_json["amount_msat"], 50_000_000u64);
87+
assert_eq!(decode_invoice_json["kind"], "bolt11");
8788
}

ldk-server-cli/src/main.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -341,9 +341,9 @@ enum Commands {
341341
)]
342342
max_channel_saturation_power_of_half: Option<u32>,
343343
},
344-
#[command(about = "Decode a BOLT11 invoice and display its fields")]
344+
#[command(about = "Decode a BOLT11 or BOLT12 invoice and display its fields")]
345345
DecodeInvoice {
346-
#[arg(help = "The BOLT11 invoice string to decode")]
346+
#[arg(help = "A BOLT11 invoice string or a hex-encoded BOLT12 invoice to decode")]
347347
invoice: String,
348348
},
349349
#[command(about = "Decode a BOLT12 offer and display its fields")]

ldk-server-client/src/client.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,7 @@ impl LdkServerClient {
351351
self.grpc_unary(&request, UNIFIED_SEND_PATH).await
352352
}
353353

354-
/// Decode a BOLT11 invoice and return its parsed fields.
354+
/// Decode a BOLT11 or BOLT12 invoice and return its parsed fields.
355355
pub async fn decode_invoice(
356356
&self, request: DecodeInvoiceRequest,
357357
) -> Result<DecodeInvoiceResponse, LdkServerError> {

ldk-server-grpc/src/api.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1109,19 +1109,20 @@ pub struct GraphGetNodeResponse {
11091109
#[prost(message, optional, tag = "1")]
11101110
pub node: ::core::option::Option<super::types::GraphNode>,
11111111
}
1112-
/// Decode a BOLT11 invoice and return its parsed fields.
1113-
/// This does not require a running node — it only parses the invoice string.
1112+
/// Decode a BOLT11 or BOLT12 invoice and return its parsed fields.
1113+
/// This does not require a running node — it only parses the invoice.
11141114
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
11151115
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
11161116
#[cfg_attr(feature = "serde", serde(default))]
11171117
#[allow(clippy::derive_partial_eq_without_eq)]
11181118
#[derive(Clone, PartialEq, ::prost::Message)]
11191119
pub struct DecodeInvoiceRequest {
1120-
/// The BOLT11 invoice string to decode.
1120+
/// The invoice to decode: either a BOLT11 invoice string or a hex-encoded BOLT12 invoice.
11211121
#[prost(string, tag = "1")]
11221122
pub invoice: ::prost::alloc::string::String,
11231123
}
11241124
/// The response for the `DecodeInvoice` RPC. On failure, a gRPC error status is returned.
1125+
/// For a BOLT12 invoice only `kind` is populated; all other fields apply to BOLT11 invoices.
11251126
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
11261127
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
11271128
#[cfg_attr(feature = "serde", serde(default))]
@@ -1173,6 +1174,9 @@ pub struct DecodeInvoiceResponse {
11731174
/// Whether the invoice has expired.
11741175
#[prost(bool, tag = "15")]
11751176
pub is_expired: bool,
1177+
/// The kind of decoded invoice: "bolt11" or "bolt12".
1178+
#[prost(string, tag = "16")]
1179+
pub kind: ::prost::alloc::string::String,
11761180
}
11771181
/// Decode a BOLT12 offer and return its parsed fields.
11781182
/// This does not require a running node — it only parses the offer string.

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

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -795,14 +795,15 @@ message GraphGetNodeResponse {
795795
types.GraphNode node = 1;
796796
}
797797

798-
// Decode a BOLT11 invoice and return its parsed fields.
799-
// This does not require a running node — it only parses the invoice string.
798+
// Decode a BOLT11 or BOLT12 invoice and return its parsed fields.
799+
// This does not require a running node — it only parses the invoice.
800800
message DecodeInvoiceRequest {
801-
// The BOLT11 invoice string to decode.
801+
// The invoice to decode: either a BOLT11 invoice string or a hex-encoded BOLT12 invoice.
802802
string invoice = 1;
803803
}
804804

805805
// The response for the `DecodeInvoice` RPC. On failure, a gRPC error status is returned.
806+
// For a BOLT12 invoice only `kind` is populated; all other fields apply to BOLT11 invoices.
806807
message DecodeInvoiceResponse {
807808
// The hex-encoded public key of the destination node.
808809
string destination = 1;
@@ -848,6 +849,9 @@ message DecodeInvoiceResponse {
848849

849850
// Whether the invoice has expired.
850851
bool is_expired = 15;
852+
853+
// The kind of decoded invoice: "bolt11" or "bolt12".
854+
string kind = 16;
851855
}
852856

853857
// Decode a BOLT12 offer and return its parsed fields.
@@ -962,7 +966,7 @@ service LightningNode {
962966
rpc ExportPathfindingScores(ExportPathfindingScoresRequest) returns (ExportPathfindingScoresResponse);
963967
// Send a payment given a BIP 21 URI or BIP 353 Human-Readable Name.
964968
rpc UnifiedSend(UnifiedSendRequest) returns (UnifiedSendResponse);
965-
// Decode a BOLT11 invoice and return its parsed fields.
969+
// Decode a BOLT11 or BOLT12 invoice and return its parsed fields.
966970
rpc DecodeInvoice(DecodeInvoiceRequest) returns (DecodeInvoiceResponse);
967971
// Decode a BOLT12 offer and return its parsed fields.
968972
rpc DecodeOffer(DecodeOfferRequest) returns (DecodeOfferResponse);

ldk-server-mcp/src/tools/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ pub fn build_tool_registry() -> ToolRegistry {
243243
),
244244
tool_spec(
245245
"decode_invoice",
246-
"Decode a BOLT11 invoice and return its parsed fields",
246+
"Decode a BOLT11 or BOLT12 invoice and return its parsed fields",
247247
schema::decode_invoice_schema,
248248
|client, args| Box::pin(handlers::handle_decode_invoice(client, args)),
249249
),

ldk-server-mcp/src/tools/schema.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -615,7 +615,7 @@ pub fn decode_invoice_schema() -> Value {
615615
"properties": {
616616
"invoice": {
617617
"type": "string",
618-
"description": "The BOLT11 invoice string to decode"
618+
"description": "A BOLT11 invoice string or a hex-encoded BOLT12 invoice to decode"
619619
}
620620
},
621621
"required": ["invoice"]

ldk-server/src/api/decode_invoice.rs

Lines changed: 109 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use std::str::FromStr;
1111
use std::sync::Arc;
1212

1313
use hex::prelude::*;
14+
use ldk_node::lightning::offers::invoice::Bolt12Invoice;
1415
use ldk_node::lightning_invoice::Bolt11Invoice;
1516
use ldk_node::lightning_types::features::Bolt11InvoiceFeatures;
1617
use ldk_server_grpc::api::{DecodeInvoiceRequest, DecodeInvoiceResponse};
@@ -20,12 +21,40 @@ use crate::api::decode_features;
2021
use crate::api::error::LdkServerError;
2122
use crate::service::Context;
2223

24+
const INVOICE_KIND_BOLT11: &str = "bolt11";
25+
const INVOICE_KIND_BOLT12: &str = "bolt12";
26+
2327
pub(crate) async fn handle_decode_invoice_request(
2428
_context: Arc<Context>, request: DecodeInvoiceRequest,
2529
) -> Result<DecodeInvoiceResponse, LdkServerError> {
26-
let invoice = Bolt11Invoice::from_str(request.invoice.as_str())
27-
.map_err(|_| ldk_node::NodeError::InvalidInvoice)?;
30+
decode_invoice(request.invoice.as_str())
31+
}
32+
33+
/// Decodes either a BOLT11 invoice string or a hex-encoded BOLT12 invoice.
34+
fn decode_invoice(invoice: &str) -> Result<DecodeInvoiceResponse, LdkServerError> {
35+
if let Ok(bolt11_invoice) = Bolt11Invoice::from_str(invoice) {
36+
return Ok(decode_bolt11_invoice(&bolt11_invoice));
37+
}
38+
39+
if let Some(response) = decode_bolt12_invoice(invoice) {
40+
return Ok(response);
41+
}
42+
43+
Err(ldk_node::NodeError::InvalidInvoice.into())
44+
}
45+
46+
/// Attempts to decode `invoice` as a hex-encoded BOLT12 invoice.
47+
///
48+
/// Unlike offers and BOLT11 invoices, a BOLT12 invoice has no human-readable string
49+
/// encoding — it is exchanged as raw bytes — so the input is expected to be hex-encoded.
50+
/// Only the `kind` field is populated for a BOLT12 invoice.
51+
fn decode_bolt12_invoice(invoice: &str) -> Option<DecodeInvoiceResponse> {
52+
let bytes = Vec::<u8>::from_hex(invoice).ok()?;
53+
Bolt12Invoice::try_from(bytes).ok()?;
54+
Some(DecodeInvoiceResponse { kind: INVOICE_KIND_BOLT12.to_string(), ..Default::default() })
55+
}
2856

57+
fn decode_bolt11_invoice(invoice: &Bolt11Invoice) -> DecodeInvoiceResponse {
2958
let destination = invoice.get_payee_pub_key().to_string();
3059
let payment_hash = invoice.payment_hash().0.to_lower_hex_string();
3160
let amount_msat = invoice.amount_milli_satoshis();
@@ -85,7 +114,7 @@ pub(crate) async fn handle_decode_invoice_request(
85114

86115
let is_expired = invoice.is_expired();
87116

88-
Ok(DecodeInvoiceResponse {
117+
DecodeInvoiceResponse {
89118
destination,
90119
payment_hash,
91120
amount_msat,
@@ -101,5 +130,81 @@ pub(crate) async fn handle_decode_invoice_request(
101130
currency,
102131
payment_metadata,
103132
is_expired,
104-
})
133+
kind: INVOICE_KIND_BOLT11.to_string(),
134+
}
135+
}
136+
137+
#[cfg(test)]
138+
mod tests {
139+
use ldk_node::lightning::bitcoin::secp256k1::{Keypair, PublicKey, Secp256k1, SecretKey};
140+
use ldk_node::lightning::blinded_path::payment::{BlindedPayInfo, BlindedPaymentPath};
141+
use ldk_node::lightning::blinded_path::BlindedHop;
142+
use ldk_node::lightning::offers::invoice::UnsignedBolt12Invoice;
143+
use ldk_node::lightning::offers::refund::RefundBuilder;
144+
use ldk_node::lightning::types::features::BlindedHopFeatures;
145+
use ldk_node::lightning::types::payment::PaymentHash;
146+
use ldk_node::lightning::util::ser::Writeable;
147+
148+
use super::*;
149+
150+
fn pubkey(byte: u8) -> PublicKey {
151+
let secp = Secp256k1::new();
152+
PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[byte; 32]).unwrap())
153+
}
154+
155+
/// Builds a signed BOLT12 invoice and returns it hex-encoded, matching how a BOLT12
156+
/// invoice would be supplied to `DecodeInvoice`.
157+
fn sample_bolt12_invoice_hex() -> String {
158+
let secp = Secp256k1::new();
159+
let keys = Keypair::from_secret_key(&secp, &SecretKey::from_slice(&[43; 32]).unwrap());
160+
161+
let payment_paths = vec![BlindedPaymentPath::from_blinded_path_and_payinfo(
162+
pubkey(40),
163+
pubkey(41),
164+
vec![
165+
BlindedHop { blinded_node_id: pubkey(43), encrypted_payload: vec![0; 43] },
166+
BlindedHop { blinded_node_id: pubkey(44), encrypted_payload: vec![0; 44] },
167+
],
168+
BlindedPayInfo {
169+
fee_base_msat: 1,
170+
fee_proportional_millionths: 1_000,
171+
cltv_expiry_delta: 42,
172+
htlc_minimum_msat: 100,
173+
htlc_maximum_msat: 1_000_000_000_000,
174+
features: BlindedHopFeatures::empty(),
175+
},
176+
)];
177+
178+
let refund = RefundBuilder::new(vec![1; 32], pubkey(42), 1_000).unwrap().build().unwrap();
179+
let invoice = refund
180+
.respond_with(payment_paths, PaymentHash([42; 32]), keys.public_key())
181+
.unwrap()
182+
.build()
183+
.unwrap()
184+
.sign(|message: &UnsignedBolt12Invoice| {
185+
Ok::<_, ()>(secp.sign_schnorr_no_aux_rand(message.as_ref().as_digest(), &keys))
186+
})
187+
.unwrap();
188+
189+
let mut buffer = Vec::new();
190+
invoice.write(&mut buffer).unwrap();
191+
buffer.to_lower_hex_string()
192+
}
193+
194+
#[test]
195+
fn rejects_unparseable_input() {
196+
assert!(decode_invoice("not an invoice").is_err());
197+
}
198+
199+
#[test]
200+
fn rejects_hex_that_is_not_a_bolt12_invoice() {
201+
// Valid hex, but not a BOLT12 invoice TLV stream.
202+
assert!(decode_invoice("00010203").is_err());
203+
}
204+
205+
#[test]
206+
fn decodes_bolt12_invoice_with_bolt12_kind() {
207+
let response = decode_invoice(&sample_bolt12_invoice_hex()).unwrap();
208+
assert_eq!(response.kind, INVOICE_KIND_BOLT12);
209+
}
105210
}

0 commit comments

Comments
 (0)