Skip to content

Commit bcdb005

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. For a decoded BOLT12 invoice the fields that map onto `DecodeInvoiceResponse` are populated: `destination` (signing pubkey), `payment_hash`, `amount_msat`, `timestamp` (`created_at`), `expiry` (`relative_expiry`), `description`, `fallback_address`, `features`, and `is_expired`. BOLT11-only fields (`payment_secret`, `route_hints`, `currency`, ...) are left empty. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 56be35f commit bcdb005

9 files changed

Lines changed: 165 additions & 17 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: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1109,19 +1109,21 @@ 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+
/// `kind` indicates which invoice type was decoded; fields that do not apply to that type
1126+
/// are left empty (e.g. `payment_secret` and `route_hints` are BOLT11-only).
11251127
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
11261128
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
11271129
#[cfg_attr(feature = "serde", serde(default))]
@@ -1173,6 +1175,9 @@ pub struct DecodeInvoiceResponse {
11731175
/// Whether the invoice has expired.
11741176
#[prost(bool, tag = "15")]
11751177
pub is_expired: bool,
1178+
/// The kind of decoded invoice: "bolt11" or "bolt12".
1179+
#[prost(string, tag = "16")]
1180+
pub kind: ::prost::alloc::string::String,
11761181
}
11771182
/// Decode a BOLT12 offer and return its parsed fields.
11781183
/// This does not require a running node — it only parses the offer string.

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

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -795,14 +795,16 @@ 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+
// `kind` indicates which invoice type was decoded; fields that do not apply to that type
807+
// are left empty (e.g. `payment_secret` and `route_hints` are BOLT11-only).
806808
message DecodeInvoiceResponse {
807809
// The hex-encoded public key of the destination node.
808810
string destination = 1;
@@ -848,6 +850,9 @@ message DecodeInvoiceResponse {
848850

849851
// Whether the invoice has expired.
850852
bool is_expired = 15;
853+
854+
// The kind of decoded invoice: "bolt11" or "bolt12".
855+
string kind = 16;
851856
}
852857

853858
// Decode a BOLT12 offer and return its parsed fields.
@@ -962,7 +967,7 @@ service LightningNode {
962967
rpc ExportPathfindingScores(ExportPathfindingScoresRequest) returns (ExportPathfindingScoresResponse);
963968
// Send a payment given a BIP 21 URI or BIP 353 Human-Readable Name.
964969
rpc UnifiedSend(UnifiedSendRequest) returns (UnifiedSendResponse);
965-
// Decode a BOLT11 invoice and return its parsed fields.
970+
// Decode a BOLT11 or BOLT12 invoice and return its parsed fields.
966971
rpc DecodeInvoice(DecodeInvoiceRequest) returns (DecodeInvoiceResponse);
967972
// Decode a BOLT12 offer and return its parsed fields.
968973
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: 141 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,68 @@ 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;
15-
use ldk_node::lightning_types::features::Bolt11InvoiceFeatures;
16+
use ldk_node::lightning_types::features::{Bolt11InvoiceFeatures, Bolt12InvoiceFeatures};
1617
use ldk_server_grpc::api::{DecodeInvoiceRequest, DecodeInvoiceResponse};
1718
use ldk_server_grpc::types::{Bolt11HopHint, Bolt11RouteHint};
1819

1920
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+
/// Fields that do not apply to BOLT12 invoices (e.g. `payment_secret`, `route_hints`) are
51+
/// left at their default empty values.
52+
fn decode_bolt12_invoice(invoice: &str) -> Option<DecodeInvoiceResponse> {
53+
let bytes = Vec::<u8>::from_hex(invoice).ok()?;
54+
let invoice = Bolt12Invoice::try_from(bytes).ok()?;
2855

56+
let features = decode_features(invoice.invoice_features().le_flags(), |bytes| {
57+
Bolt12InvoiceFeatures::from_le_bytes(bytes).to_string()
58+
});
59+
60+
Some(DecodeInvoiceResponse {
61+
destination: invoice.signing_pubkey().to_string(),
62+
payment_hash: invoice.payment_hash().0.to_lower_hex_string(),
63+
amount_msat: Some(invoice.amount_msats()),
64+
timestamp: invoice.created_at().as_secs(),
65+
expiry: invoice.relative_expiry().as_secs(),
66+
description: invoice.description().map(|d| d.to_string()),
67+
fallback_address: invoice.fallbacks().into_iter().next().map(|a| a.to_string()),
68+
features,
69+
is_expired: invoice.is_expired(),
70+
kind: INVOICE_KIND_BOLT12.to_string(),
71+
..Default::default()
72+
})
73+
}
74+
75+
fn decode_bolt11_invoice(invoice: &Bolt11Invoice) -> DecodeInvoiceResponse {
2976
let destination = invoice.get_payee_pub_key().to_string();
3077
let payment_hash = invoice.payment_hash().0.to_lower_hex_string();
3178
let amount_msat = invoice.amount_milli_satoshis();
@@ -85,7 +132,7 @@ pub(crate) async fn handle_decode_invoice_request(
85132

86133
let is_expired = invoice.is_expired();
87134

88-
Ok(DecodeInvoiceResponse {
135+
DecodeInvoiceResponse {
89136
destination,
90137
payment_hash,
91138
amount_msat,
@@ -101,5 +148,94 @@ pub(crate) async fn handle_decode_invoice_request(
101148
currency,
102149
payment_metadata,
103150
is_expired,
104-
})
151+
kind: INVOICE_KIND_BOLT11.to_string(),
152+
}
153+
}
154+
155+
#[cfg(test)]
156+
mod tests {
157+
use ldk_node::lightning::bitcoin::secp256k1::{Keypair, PublicKey, Secp256k1, SecretKey};
158+
use ldk_node::lightning::blinded_path::payment::{BlindedPayInfo, BlindedPaymentPath};
159+
use ldk_node::lightning::blinded_path::BlindedHop;
160+
use ldk_node::lightning::offers::invoice::UnsignedBolt12Invoice;
161+
use ldk_node::lightning::offers::refund::RefundBuilder;
162+
use ldk_node::lightning::types::features::BlindedHopFeatures;
163+
use ldk_node::lightning::types::payment::PaymentHash;
164+
use ldk_node::lightning::util::ser::Writeable;
165+
166+
use super::*;
167+
168+
fn pubkey(byte: u8) -> PublicKey {
169+
let secp = Secp256k1::new();
170+
PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[byte; 32]).unwrap())
171+
}
172+
173+
/// The keypair the sample BOLT12 invoice is signed with; its public key is the
174+
/// invoice's `signing_pubkey`.
175+
fn signing_keypair() -> Keypair {
176+
let secp = Secp256k1::new();
177+
Keypair::from_secret_key(&secp, &SecretKey::from_slice(&[43; 32]).unwrap())
178+
}
179+
180+
/// Builds a signed BOLT12 invoice and returns it hex-encoded, matching how a BOLT12
181+
/// invoice would be supplied to `DecodeInvoice`.
182+
fn sample_bolt12_invoice_hex() -> String {
183+
let secp = Secp256k1::new();
184+
let keys = signing_keypair();
185+
186+
let payment_paths = vec![BlindedPaymentPath::from_blinded_path_and_payinfo(
187+
pubkey(40),
188+
pubkey(41),
189+
vec![
190+
BlindedHop { blinded_node_id: pubkey(43), encrypted_payload: vec![0; 43] },
191+
BlindedHop { blinded_node_id: pubkey(44), encrypted_payload: vec![0; 44] },
192+
],
193+
BlindedPayInfo {
194+
fee_base_msat: 1,
195+
fee_proportional_millionths: 1_000,
196+
cltv_expiry_delta: 42,
197+
htlc_minimum_msat: 100,
198+
htlc_maximum_msat: 1_000_000_000_000,
199+
features: BlindedHopFeatures::empty(),
200+
},
201+
)];
202+
203+
let refund = RefundBuilder::new(vec![1; 32], pubkey(42), 1_000).unwrap().build().unwrap();
204+
let invoice = refund
205+
.respond_with(payment_paths, PaymentHash([42; 32]), keys.public_key())
206+
.unwrap()
207+
.relative_expiry(3600)
208+
.build()
209+
.unwrap()
210+
.sign(|message: &UnsignedBolt12Invoice| {
211+
Ok::<_, ()>(secp.sign_schnorr_no_aux_rand(message.as_ref().as_digest(), &keys))
212+
})
213+
.unwrap();
214+
215+
let mut buffer = Vec::new();
216+
invoice.write(&mut buffer).unwrap();
217+
buffer.to_lower_hex_string()
218+
}
219+
220+
#[test]
221+
fn rejects_unparseable_input() {
222+
assert!(decode_invoice("not an invoice").is_err());
223+
}
224+
225+
#[test]
226+
fn rejects_hex_that_is_not_a_bolt12_invoice() {
227+
// Valid hex, but not a BOLT12 invoice TLV stream.
228+
assert!(decode_invoice("00010203").is_err());
229+
}
230+
231+
#[test]
232+
fn decodes_bolt12_invoice_and_populates_fields() {
233+
let response = decode_invoice(&sample_bolt12_invoice_hex()).unwrap();
234+
assert_eq!(response.kind, INVOICE_KIND_BOLT12);
235+
assert_eq!(response.destination, signing_keypair().public_key().to_string());
236+
assert_eq!(response.payment_hash, "2a".repeat(32));
237+
assert_eq!(response.amount_msat, Some(1_000));
238+
assert_eq!(response.expiry, 3600);
239+
assert!(!response.is_expired);
240+
}
105241
}

0 commit comments

Comments
 (0)