Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions e2e-tests/tests/e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ async fn test_cli_decode_invoice() {
assert!(decoded["timestamp"].as_u64().unwrap() > 0);
assert!(decoded["min_final_cltv_expiry_delta"].as_u64().unwrap() > 0);
assert_eq!(decoded["is_expired"], false);
assert_eq!(decoded["kind"], "bolt11");

// Verify features — LDK BOLT11 invoices always set VariableLengthOnion, PaymentSecret,
// and BasicMPP.
Expand Down
1 change: 1 addition & 0 deletions e2e-tests/tests/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,5 @@ async fn test_mcp_live_tool_calls() {
assert_eq!(decode_invoice_json["destination"], server.node_id());
assert_eq!(decode_invoice_json["description"], "mcp decode");
assert_eq!(decode_invoice_json["amount_msat"], 50_000_000u64);
assert_eq!(decode_invoice_json["kind"], "bolt11");
}
4 changes: 2 additions & 2 deletions ldk-server-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -341,9 +341,9 @@ enum Commands {
)]
max_channel_saturation_power_of_half: Option<u32>,
},
#[command(about = "Decode a BOLT11 invoice and display its fields")]
#[command(about = "Decode a BOLT11 or BOLT12 invoice and display its fields")]
DecodeInvoice {
#[arg(help = "The BOLT11 invoice string to decode")]
#[arg(help = "A BOLT11 invoice string or a hex-encoded BOLT12 invoice to decode")]
invoice: String,
},
#[command(about = "Decode a BOLT12 offer and display its fields")]
Expand Down
2 changes: 1 addition & 1 deletion ldk-server-client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ impl LdkServerClient {
self.grpc_unary(&request, UNIFIED_SEND_PATH).await
}

/// Decode a BOLT11 invoice and return its parsed fields.
/// Decode a BOLT11 or BOLT12 invoice and return its parsed fields.
pub async fn decode_invoice(
&self, request: DecodeInvoiceRequest,
) -> Result<DecodeInvoiceResponse, LdkServerError> {
Expand Down
11 changes: 8 additions & 3 deletions ldk-server-grpc/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1109,19 +1109,21 @@ pub struct GraphGetNodeResponse {
#[prost(message, optional, tag = "1")]
pub node: ::core::option::Option<super::types::GraphNode>,
}
/// Decode a BOLT11 invoice and return its parsed fields.
/// This does not require a running node — it only parses the invoice string.
/// Decode a BOLT11 or BOLT12 invoice and return its parsed fields.
/// This does not require a running node — it only parses the invoice.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[cfg_attr(feature = "serde", serde(default))]
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct DecodeInvoiceRequest {
/// The BOLT11 invoice string to decode.
/// The invoice to decode: either a BOLT11 invoice string or a hex-encoded BOLT12 invoice.
#[prost(string, tag = "1")]
pub invoice: ::prost::alloc::string::String,
}
/// The response for the `DecodeInvoice` RPC. On failure, a gRPC error status is returned.
/// `kind` indicates which invoice type was decoded; fields that do not apply to that type
/// are left empty (e.g. `payment_secret` and `route_hints` are BOLT11-only).
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[cfg_attr(feature = "serde", serde(default))]
Expand Down Expand Up @@ -1173,6 +1175,9 @@ pub struct DecodeInvoiceResponse {
/// Whether the invoice has expired.
#[prost(bool, tag = "15")]
pub is_expired: bool,
/// The kind of decoded invoice: "bolt11" or "bolt12".
#[prost(string, tag = "16")]
pub kind: ::prost::alloc::string::String,
}
/// Decode a BOLT12 offer and return its parsed fields.
/// This does not require a running node — it only parses the offer string.
Expand Down
13 changes: 9 additions & 4 deletions ldk-server-grpc/src/proto/api.proto
Original file line number Diff line number Diff line change
Expand Up @@ -795,14 +795,16 @@ message GraphGetNodeResponse {
types.GraphNode node = 1;
}

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

// The response for the `DecodeInvoice` RPC. On failure, a gRPC error status is returned.
// `kind` indicates which invoice type was decoded; fields that do not apply to that type
// are left empty (e.g. `payment_secret` and `route_hints` are BOLT11-only).
message DecodeInvoiceResponse {
// The hex-encoded public key of the destination node.
string destination = 1;
Expand Down Expand Up @@ -848,6 +850,9 @@ message DecodeInvoiceResponse {

// Whether the invoice has expired.
bool is_expired = 15;

// The kind of decoded invoice: "bolt11" or "bolt12".
string kind = 16;
}

// Decode a BOLT12 offer and return its parsed fields.
Expand Down Expand Up @@ -962,7 +967,7 @@ service LightningNode {
rpc ExportPathfindingScores(ExportPathfindingScoresRequest) returns (ExportPathfindingScoresResponse);
// Send a payment given a BIP 21 URI or BIP 353 Human-Readable Name.
rpc UnifiedSend(UnifiedSendRequest) returns (UnifiedSendResponse);
// Decode a BOLT11 invoice and return its parsed fields.
// Decode a BOLT11 or BOLT12 invoice and return its parsed fields.
rpc DecodeInvoice(DecodeInvoiceRequest) returns (DecodeInvoiceResponse);
// Decode a BOLT12 offer and return its parsed fields.
rpc DecodeOffer(DecodeOfferRequest) returns (DecodeOfferResponse);
Expand Down
2 changes: 1 addition & 1 deletion ldk-server-mcp/src/tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ pub fn build_tool_registry() -> ToolRegistry {
),
tool_spec(
"decode_invoice",
"Decode a BOLT11 invoice and return its parsed fields",
"Decode a BOLT11 or BOLT12 invoice and return its parsed fields",
schema::decode_invoice_schema,
|client, args| Box::pin(handlers::handle_decode_invoice(client, args)),
),
Expand Down
2 changes: 1 addition & 1 deletion ldk-server-mcp/src/tools/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -615,7 +615,7 @@ pub fn decode_invoice_schema() -> Value {
"properties": {
"invoice": {
"type": "string",
"description": "The BOLT11 invoice string to decode"
"description": "A BOLT11 invoice string or a hex-encoded BOLT12 invoice to decode"
}
},
"required": ["invoice"]
Expand Down
146 changes: 141 additions & 5 deletions ldk-server/src/api/decode_invoice.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,68 @@ use std::str::FromStr;
use std::sync::Arc;

use hex::prelude::*;
use ldk_node::lightning::offers::invoice::Bolt12Invoice;
use ldk_node::lightning_invoice::Bolt11Invoice;
use ldk_node::lightning_types::features::Bolt11InvoiceFeatures;
use ldk_node::lightning_types::features::{Bolt11InvoiceFeatures, Bolt12InvoiceFeatures};
use ldk_server_grpc::api::{DecodeInvoiceRequest, DecodeInvoiceResponse};
use ldk_server_grpc::types::{Bolt11HopHint, Bolt11RouteHint};

use crate::api::decode_features;
use crate::api::error::LdkServerError;
use crate::service::Context;

const INVOICE_KIND_BOLT11: &str = "bolt11";
const INVOICE_KIND_BOLT12: &str = "bolt12";

pub(crate) async fn handle_decode_invoice_request(
_context: Arc<Context>, request: DecodeInvoiceRequest,
) -> Result<DecodeInvoiceResponse, LdkServerError> {
let invoice = Bolt11Invoice::from_str(request.invoice.as_str())
.map_err(|_| ldk_node::NodeError::InvalidInvoice)?;
decode_invoice(request.invoice.as_str())
}

/// Decodes either a BOLT11 invoice string or a hex-encoded BOLT12 invoice.
fn decode_invoice(invoice: &str) -> Result<DecodeInvoiceResponse, LdkServerError> {
if let Ok(bolt11_invoice) = Bolt11Invoice::from_str(invoice) {
return Ok(decode_bolt11_invoice(&bolt11_invoice));
}

if let Some(response) = decode_bolt12_invoice(invoice) {
return Ok(response);
}

Err(ldk_node::NodeError::InvalidInvoice.into())
}

/// Attempts to decode `invoice` as a hex-encoded BOLT12 invoice.
///
/// Unlike offers and BOLT11 invoices, a BOLT12 invoice has no human-readable string
/// encoding — it is exchanged as raw bytes — so the input is expected to be hex-encoded.
/// Fields that do not apply to BOLT12 invoices (e.g. `payment_secret`, `route_hints`) are
/// left at their default empty values.
fn decode_bolt12_invoice(invoice: &str) -> Option<DecodeInvoiceResponse> {
let bytes = Vec::<u8>::from_hex(invoice).ok()?;
let invoice = Bolt12Invoice::try_from(bytes).ok()?;

let features = decode_features(invoice.invoice_features().le_flags(), |bytes| {
Bolt12InvoiceFeatures::from_le_bytes(bytes).to_string()
});

Some(DecodeInvoiceResponse {
destination: invoice.signing_pubkey().to_string(),
payment_hash: invoice.payment_hash().0.to_lower_hex_string(),
amount_msat: Some(invoice.amount_msats()),
timestamp: invoice.created_at().as_secs(),
expiry: invoice.relative_expiry().as_secs(),
description: invoice.description().map(|d| d.to_string()),
fallback_address: invoice.fallbacks().into_iter().next().map(|a| a.to_string()),
features,
is_expired: invoice.is_expired(),
kind: INVOICE_KIND_BOLT12.to_string(),
..Default::default()
})
}

fn decode_bolt11_invoice(invoice: &Bolt11Invoice) -> DecodeInvoiceResponse {
let destination = invoice.get_payee_pub_key().to_string();
let payment_hash = invoice.payment_hash().0.to_lower_hex_string();
let amount_msat = invoice.amount_milli_satoshis();
Expand Down Expand Up @@ -85,7 +132,7 @@ pub(crate) async fn handle_decode_invoice_request(

let is_expired = invoice.is_expired();

Ok(DecodeInvoiceResponse {
DecodeInvoiceResponse {
destination,
payment_hash,
amount_msat,
Expand All @@ -101,5 +148,94 @@ pub(crate) async fn handle_decode_invoice_request(
currency,
payment_metadata,
is_expired,
})
kind: INVOICE_KIND_BOLT11.to_string(),
}
}

#[cfg(test)]
mod tests {
use ldk_node::lightning::bitcoin::secp256k1::{Keypair, PublicKey, Secp256k1, SecretKey};
use ldk_node::lightning::blinded_path::payment::{BlindedPayInfo, BlindedPaymentPath};
use ldk_node::lightning::blinded_path::BlindedHop;
use ldk_node::lightning::offers::invoice::UnsignedBolt12Invoice;
use ldk_node::lightning::offers::refund::RefundBuilder;
use ldk_node::lightning::types::features::BlindedHopFeatures;
use ldk_node::lightning::types::payment::PaymentHash;
use ldk_node::lightning::util::ser::Writeable;

use super::*;

fn pubkey(byte: u8) -> PublicKey {
let secp = Secp256k1::new();
PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[byte; 32]).unwrap())
}

/// The keypair the sample BOLT12 invoice is signed with; its public key is the
/// invoice's `signing_pubkey`.
fn signing_keypair() -> Keypair {
let secp = Secp256k1::new();
Keypair::from_secret_key(&secp, &SecretKey::from_slice(&[43; 32]).unwrap())
}

/// Builds a signed BOLT12 invoice and returns it hex-encoded, matching how a BOLT12
/// invoice would be supplied to `DecodeInvoice`.
fn sample_bolt12_invoice_hex() -> String {
let secp = Secp256k1::new();
let keys = signing_keypair();

let payment_paths = vec![BlindedPaymentPath::from_blinded_path_and_payinfo(
pubkey(40),
pubkey(41),
vec![
BlindedHop { blinded_node_id: pubkey(43), encrypted_payload: vec![0; 43] },
BlindedHop { blinded_node_id: pubkey(44), encrypted_payload: vec![0; 44] },
],
BlindedPayInfo {
fee_base_msat: 1,
fee_proportional_millionths: 1_000,
cltv_expiry_delta: 42,
htlc_minimum_msat: 100,
htlc_maximum_msat: 1_000_000_000_000,
features: BlindedHopFeatures::empty(),
},
)];

let refund = RefundBuilder::new(vec![1; 32], pubkey(42), 1_000).unwrap().build().unwrap();
let invoice = refund
.respond_with(payment_paths, PaymentHash([42; 32]), keys.public_key())
.unwrap()
.relative_expiry(3600)
.build()
.unwrap()
.sign(|message: &UnsignedBolt12Invoice| {
Ok::<_, ()>(secp.sign_schnorr_no_aux_rand(message.as_ref().as_digest(), &keys))
})
.unwrap();

let mut buffer = Vec::new();
invoice.write(&mut buffer).unwrap();
buffer.to_lower_hex_string()
}

#[test]
fn rejects_unparseable_input() {
assert!(decode_invoice("not an invoice").is_err());
}

#[test]
fn rejects_hex_that_is_not_a_bolt12_invoice() {
// Valid hex, but not a BOLT12 invoice TLV stream.
assert!(decode_invoice("00010203").is_err());
}

#[test]
fn decodes_bolt12_invoice_and_populates_fields() {
let response = decode_invoice(&sample_bolt12_invoice_hex()).unwrap();
assert_eq!(response.kind, INVOICE_KIND_BOLT12);
assert_eq!(response.destination, signing_keypair().public_key().to_string());
assert_eq!(response.payment_hash, "2a".repeat(32));
assert_eq!(response.amount_msat, Some(1_000));
assert_eq!(response.expiry, 3600);
assert!(!response.is_expired);
}
}