Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you add a e2e test

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you add a mcp test

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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make this a proper enum

}
/// 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 {
Comment thread
vincenzopalazzo marked this conversation as resolved.
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);
}
}