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

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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
15 changes: 12 additions & 3 deletions ldk-server-grpc/src/api.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1109,19 +1109,22 @@ 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, `paths` is
/// BOLT12-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 DownExpand Up@@ -1173,6 +1176,12 @@ 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

/// Blinded payment paths to the recipient. Only present for BOLT12 invoices.
#[prost(message, repeated, tag = "17")]
pub paths: ::prost::alloc::vec::Vec<super::types::BlindedPath>,
}
/// Decode a BOLT12 offer and return its parsed fields.
/// This does not require a running node — it only parses the offer string.
Expand Down
17 changes: 13 additions & 4 deletions ldk-server-grpc/src/proto/api.proto
Original file line numberDiff line numberDiff line change
Expand Up@@ -795,14 +795,17 @@ 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, `paths` is
// BOLT12-only).
message DecodeInvoiceResponse {
// The hex-encoded public key of the destination node.
string destination = 1;
Expand DownExpand Up@@ -848,6 +851,12 @@ message DecodeInvoiceResponse {

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

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

// Blinded payment paths to the recipient. Only present for BOLT12 invoices.
repeated types.BlindedPath paths = 17;
}

// Decode a BOLT12 offer and return its parsed fields.
Expand DownExpand Up@@ -962,7 +971,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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
172 changes: 166 additions & 6 deletions ldk-server/src/api/decode_invoice.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,21 +11,81 @@ 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::api::{blinded_path_to_proto, decode_features};
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()
});

let paths = invoice
.payment_paths()
.iter()
.map(|path| {
blinded_path_to_proto(
path.introduction_node(),
path.blinding_point(),
path.blinded_hops().len(),
)
})
.collect();

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(),
paths,
..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 DownExpand Up@@ -85,7 +145,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 +161,105 @@ pub(crate) async fn handle_decode_invoice_request(
currency,
payment_metadata,
is_expired,
})
kind: INVOICE_KIND_BOLT11.to_string(),
// BOLT11 invoices carry route hints rather than blinded paths.
paths: Vec::new(),
}
}

#[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 ldk_server_grpc::types::blinded_path::IntroductionNode;

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);

// The sample invoice carries a single blinded payment path with two hops,
// introduced by `pubkey(40)` and blinded with `pubkey(41)`.
assert_eq!(response.paths.len(), 1);
let path = &response.paths[0];
assert_eq!(path.num_hops, 2);
assert_eq!(path.blinding_point, pubkey(41).to_string());
assert_eq!(path.introduction_node, Some(IntroductionNode::NodeId(pubkey(40).to_string())));
}
}
40 changes: 7 additions & 33 deletions ldk-server/src/api/decode_offer.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,16 +16,12 @@ use ldk_node::lightning::bitcoin::Network;
use ldk_node::lightning::offers::offer::Offer;
use ldk_node::lightning_types::features::OfferFeatures;
use ldk_server_grpc::api::{DecodeOfferRequest, DecodeOfferResponse};
use ldk_server_grpc::types::blinded_path::IntroductionNode;
use ldk_server_grpc::types::offer_amount::Amount;
use ldk_server_grpc::types::offer_quantity::Quantity;
use ldk_server_grpc::types::{
BlindedPath, ChannelDirection, CurrencyAmount, DirectedShortChannelId, OfferAmount,
OfferQuantity,
};
use ldk_server_grpc::types::{CurrencyAmount, OfferAmount, OfferQuantity};

use crate::api::decode_features;
use crate::api::error::LdkServerError;
use crate::api::{blinded_path_to_proto, decode_features};
use crate::service::Context;

pub(crate) async fn handle_decode_offer_request(
Expand DownExpand Up@@ -74,33 +70,11 @@ pub(crate) async fn handle_decode_offer_request(
.paths()
.iter()
.map(|path| {
let introduction_node = match path.introduction_node() {
ldk_node::lightning::blinded_path::IntroductionNode::NodeId(pk) => {
IntroductionNode::NodeId(pk.to_string())
},
ldk_node::lightning::blinded_path::IntroductionNode::DirectedShortChannelId(
dir,
scid,
) => {
let direction = match dir {
ldk_node::lightning::blinded_path::Direction::NodeOne => {
ChannelDirection::NodeOne
},
ldk_node::lightning::blinded_path::Direction::NodeTwo => {
ChannelDirection::NodeTwo
},
};
IntroductionNode::DirectedScid(DirectedShortChannelId {
scid: *scid,
direction: direction as i32,
})
},
};
BlindedPath {
introduction_node: Some(introduction_node),
blinding_point: path.blinding_point().to_string(),
num_hops: path.blinded_hops().len() as u32,
}
blinded_path_to_proto(
path.introduction_node(),
path.blinding_point(),
path.blinded_hops().len(),
)
})
.collect();

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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

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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
15 changes: 12 additions & 3 deletions ldk-server-grpc/src/api.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1109,19 +1109,22 @@ 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, `paths` is
/// BOLT12-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 DownExpand Up@@ -1173,6 +1176,12 @@ 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

/// Blinded payment paths to the recipient. Only present for BOLT12 invoices.
#[prost(message, repeated, tag = "17")]
pub paths: ::prost::alloc::vec::Vec<super::types::BlindedPath>,
}
/// Decode a BOLT12 offer and return its parsed fields.
/// This does not require a running node — it only parses the offer string.
Expand Down
17 changes: 13 additions & 4 deletions ldk-server-grpc/src/proto/api.proto
Original file line numberDiff line numberDiff line change
Expand Up@@ -795,14 +795,17 @@ 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, `paths` is
// BOLT12-only).
message DecodeInvoiceResponse {
// The hex-encoded public key of the destination node.
string destination = 1;
Expand DownExpand Up@@ -848,6 +851,12 @@ message DecodeInvoiceResponse {

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

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

// Blinded payment paths to the recipient. Only present for BOLT12 invoices.
repeated types.BlindedPath paths = 17;
}

// Decode a BOLT12 offer and return its parsed fields.
Expand DownExpand Up@@ -962,7 +971,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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
172 changes: 166 additions & 6 deletions ldk-server/src/api/decode_invoice.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,21 +11,81 @@ 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::api::{blinded_path_to_proto, decode_features};
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()
});

let paths = invoice
.payment_paths()
.iter()
.map(|path| {
blinded_path_to_proto(
path.introduction_node(),
path.blinding_point(),
path.blinded_hops().len(),
)
})
.collect();

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(),
paths,
..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 DownExpand Up@@ -85,7 +145,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 +161,105 @@ pub(crate) async fn handle_decode_invoice_request(
currency,
payment_metadata,
is_expired,
})
kind: INVOICE_KIND_BOLT11.to_string(),
// BOLT11 invoices carry route hints rather than blinded paths.
paths: Vec::new(),
}
}

#[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 ldk_server_grpc::types::blinded_path::IntroductionNode;

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);

// The sample invoice carries a single blinded payment path with two hops,
// introduced by `pubkey(40)` and blinded with `pubkey(41)`.
assert_eq!(response.paths.len(), 1);
let path = &response.paths[0];
assert_eq!(path.num_hops, 2);
assert_eq!(path.blinding_point, pubkey(41).to_string());
assert_eq!(path.introduction_node, Some(IntroductionNode::NodeId(pubkey(40).to_string())));
}
}
40 changes: 7 additions & 33 deletions ldk-server/src/api/decode_offer.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,16 +16,12 @@ use ldk_node::lightning::bitcoin::Network;
use ldk_node::lightning::offers::offer::Offer;
use ldk_node::lightning_types::features::OfferFeatures;
use ldk_server_grpc::api::{DecodeOfferRequest, DecodeOfferResponse};
use ldk_server_grpc::types::blinded_path::IntroductionNode;
use ldk_server_grpc::types::offer_amount::Amount;
use ldk_server_grpc::types::offer_quantity::Quantity;
use ldk_server_grpc::types::{
BlindedPath, ChannelDirection, CurrencyAmount, DirectedShortChannelId, OfferAmount,
OfferQuantity,
};
use ldk_server_grpc::types::{CurrencyAmount, OfferAmount, OfferQuantity};

use crate::api::decode_features;
use crate::api::error::LdkServerError;
use crate::api::{blinded_path_to_proto, decode_features};
use crate::service::Context;

pub(crate) async fn handle_decode_offer_request(
Expand DownExpand Up@@ -74,33 +70,11 @@ pub(crate) async fn handle_decode_offer_request(
.paths()
.iter()
.map(|path| {
let introduction_node = match path.introduction_node() {
ldk_node::lightning::blinded_path::IntroductionNode::NodeId(pk) => {
IntroductionNode::NodeId(pk.to_string())
},
ldk_node::lightning::blinded_path::IntroductionNode::DirectedShortChannelId(
dir,
scid,
) => {
let direction = match dir {
ldk_node::lightning::blinded_path::Direction::NodeOne => {
ChannelDirection::NodeOne
},
ldk_node::lightning::blinded_path::Direction::NodeTwo => {
ChannelDirection::NodeTwo
},
};
IntroductionNode::DirectedScid(DirectedShortChannelId {
scid: *scid,
direction: direction as i32,
})
},
};
BlindedPath {
introduction_node: Some(introduction_node),
blinding_point: path.blinding_point().to_string(),
num_hops: path.blinded_hops().len() as u32,
}
blinded_path_to_proto(
path.introduction_node(),
path.blinding_point(),
path.blinded_hops().len(),
)
})
.collect();

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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

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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
15 changes: 12 additions & 3 deletions ldk-server-grpc/src/api.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1109,19 +1109,22 @@ 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, `paths` is
/// BOLT12-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 DownExpand Up@@ -1173,6 +1176,12 @@ 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

/// Blinded payment paths to the recipient. Only present for BOLT12 invoices.
#[prost(message, repeated, tag = "17")]
pub paths: ::prost::alloc::vec::Vec<super::types::BlindedPath>,
}
/// Decode a BOLT12 offer and return its parsed fields.
/// This does not require a running node — it only parses the offer string.
Expand Down
17 changes: 13 additions & 4 deletions ldk-server-grpc/src/proto/api.proto
Original file line numberDiff line numberDiff line change
Expand Up@@ -795,14 +795,17 @@ 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, `paths` is
// BOLT12-only).
message DecodeInvoiceResponse {
// The hex-encoded public key of the destination node.
string destination = 1;
Expand DownExpand Up@@ -848,6 +851,12 @@ message DecodeInvoiceResponse {

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

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

// Blinded payment paths to the recipient. Only present for BOLT12 invoices.
repeated types.BlindedPath paths = 17;
}

// Decode a BOLT12 offer and return its parsed fields.
Expand DownExpand Up@@ -962,7 +971,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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
172 changes: 166 additions & 6 deletions ldk-server/src/api/decode_invoice.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,21 +11,81 @@ 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::api::{blinded_path_to_proto, decode_features};
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()
});

let paths = invoice
.payment_paths()
.iter()
.map(|path| {
blinded_path_to_proto(
path.introduction_node(),
path.blinding_point(),
path.blinded_hops().len(),
)
})
.collect();

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(),
paths,
..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 DownExpand Up@@ -85,7 +145,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 +161,105 @@ pub(crate) async fn handle_decode_invoice_request(
currency,
payment_metadata,
is_expired,
})
kind: INVOICE_KIND_BOLT11.to_string(),
// BOLT11 invoices carry route hints rather than blinded paths.
paths: Vec::new(),
}
}

#[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 ldk_server_grpc::types::blinded_path::IntroductionNode;

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);

// The sample invoice carries a single blinded payment path with two hops,
// introduced by `pubkey(40)` and blinded with `pubkey(41)`.
assert_eq!(response.paths.len(), 1);
let path = &response.paths[0];
assert_eq!(path.num_hops, 2);
assert_eq!(path.blinding_point, pubkey(41).to_string());
assert_eq!(path.introduction_node, Some(IntroductionNode::NodeId(pubkey(40).to_string())));
}
}
40 changes: 7 additions & 33 deletions ldk-server/src/api/decode_offer.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,16 +16,12 @@ use ldk_node::lightning::bitcoin::Network;
use ldk_node::lightning::offers::offer::Offer;
use ldk_node::lightning_types::features::OfferFeatures;
use ldk_server_grpc::api::{DecodeOfferRequest, DecodeOfferResponse};
use ldk_server_grpc::types::blinded_path::IntroductionNode;
use ldk_server_grpc::types::offer_amount::Amount;
use ldk_server_grpc::types::offer_quantity::Quantity;
use ldk_server_grpc::types::{
BlindedPath, ChannelDirection, CurrencyAmount, DirectedShortChannelId, OfferAmount,
OfferQuantity,
};
use ldk_server_grpc::types::{CurrencyAmount, OfferAmount, OfferQuantity};

use crate::api::decode_features;
use crate::api::error::LdkServerError;
use crate::api::{blinded_path_to_proto, decode_features};
use crate::service::Context;

pub(crate) async fn handle_decode_offer_request(
Expand DownExpand Up@@ -74,33 +70,11 @@ pub(crate) async fn handle_decode_offer_request(
.paths()
.iter()
.map(|path| {
let introduction_node = match path.introduction_node() {
ldk_node::lightning::blinded_path::IntroductionNode::NodeId(pk) => {
IntroductionNode::NodeId(pk.to_string())
},
ldk_node::lightning::blinded_path::IntroductionNode::DirectedShortChannelId(
dir,
scid,
) => {
let direction = match dir {
ldk_node::lightning::blinded_path::Direction::NodeOne => {
ChannelDirection::NodeOne
},
ldk_node::lightning::blinded_path::Direction::NodeTwo => {
ChannelDirection::NodeTwo
},
};
IntroductionNode::DirectedScid(DirectedShortChannelId {
scid: *scid,
direction: direction as i32,
})
},
};
BlindedPath {
introduction_node: Some(introduction_node),
blinding_point: path.blinding_point().to_string(),
num_hops: path.blinded_hops().len() as u32,
}
blinded_path_to_proto(
path.introduction_node(),
path.blinding_point(),
path.blinded_hops().len(),
)
})
.collect();

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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

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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
15 changes: 12 additions & 3 deletions ldk-server-grpc/src/api.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1109,19 +1109,22 @@ 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, `paths` is
/// BOLT12-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 DownExpand Up@@ -1173,6 +1176,12 @@ 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

/// Blinded payment paths to the recipient. Only present for BOLT12 invoices.
#[prost(message, repeated, tag = "17")]
pub paths: ::prost::alloc::vec::Vec<super::types::BlindedPath>,
}
/// Decode a BOLT12 offer and return its parsed fields.
/// This does not require a running node — it only parses the offer string.
Expand Down
17 changes: 13 additions & 4 deletions ldk-server-grpc/src/proto/api.proto
Original file line numberDiff line numberDiff line change
Expand Up@@ -795,14 +795,17 @@ 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, `paths` is
// BOLT12-only).
message DecodeInvoiceResponse {
// The hex-encoded public key of the destination node.
string destination = 1;
Expand DownExpand Up@@ -848,6 +851,12 @@ message DecodeInvoiceResponse {

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

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

// Blinded payment paths to the recipient. Only present for BOLT12 invoices.
repeated types.BlindedPath paths = 17;
}

// Decode a BOLT12 offer and return its parsed fields.
Expand DownExpand Up@@ -962,7 +971,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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
172 changes: 166 additions & 6 deletions ldk-server/src/api/decode_invoice.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,21 +11,81 @@ 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::api::{blinded_path_to_proto, decode_features};
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()
});

let paths = invoice
.payment_paths()
.iter()
.map(|path| {
blinded_path_to_proto(
path.introduction_node(),
path.blinding_point(),
path.blinded_hops().len(),
)
})
.collect();

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(),
paths,
..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 DownExpand Up@@ -85,7 +145,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 +161,105 @@ pub(crate) async fn handle_decode_invoice_request(
currency,
payment_metadata,
is_expired,
})
kind: INVOICE_KIND_BOLT11.to_string(),
// BOLT11 invoices carry route hints rather than blinded paths.
paths: Vec::new(),
}
}

#[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 ldk_server_grpc::types::blinded_path::IntroductionNode;

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);

// The sample invoice carries a single blinded payment path with two hops,
// introduced by `pubkey(40)` and blinded with `pubkey(41)`.
assert_eq!(response.paths.len(), 1);
let path = &response.paths[0];
assert_eq!(path.num_hops, 2);
assert_eq!(path.blinding_point, pubkey(41).to_string());
assert_eq!(path.introduction_node, Some(IntroductionNode::NodeId(pubkey(40).to_string())));
}
}
40 changes: 7 additions & 33 deletions ldk-server/src/api/decode_offer.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,16 +16,12 @@ use ldk_node::lightning::bitcoin::Network;
use ldk_node::lightning::offers::offer::Offer;
use ldk_node::lightning_types::features::OfferFeatures;
use ldk_server_grpc::api::{DecodeOfferRequest, DecodeOfferResponse};
use ldk_server_grpc::types::blinded_path::IntroductionNode;
use ldk_server_grpc::types::offer_amount::Amount;
use ldk_server_grpc::types::offer_quantity::Quantity;
use ldk_server_grpc::types::{
BlindedPath, ChannelDirection, CurrencyAmount, DirectedShortChannelId, OfferAmount,
OfferQuantity,
};
use ldk_server_grpc::types::{CurrencyAmount, OfferAmount, OfferQuantity};

use crate::api::decode_features;
use crate::api::error::LdkServerError;
use crate::api::{blinded_path_to_proto, decode_features};
use crate::service::Context;

pub(crate) async fn handle_decode_offer_request(
Expand DownExpand Up@@ -74,33 +70,11 @@ pub(crate) async fn handle_decode_offer_request(
.paths()
.iter()
.map(|path| {
let introduction_node = match path.introduction_node() {
ldk_node::lightning::blinded_path::IntroductionNode::NodeId(pk) => {
IntroductionNode::NodeId(pk.to_string())
},
ldk_node::lightning::blinded_path::IntroductionNode::DirectedShortChannelId(
dir,
scid,
) => {
let direction = match dir {
ldk_node::lightning::blinded_path::Direction::NodeOne => {
ChannelDirection::NodeOne
},
ldk_node::lightning::blinded_path::Direction::NodeTwo => {
ChannelDirection::NodeTwo
},
};
IntroductionNode::DirectedScid(DirectedShortChannelId {
scid: *scid,
direction: direction as i32,
})
},
};
BlindedPath {
introduction_node: Some(introduction_node),
blinding_point: path.blinding_point().to_string(),
num_hops: path.blinded_hops().len() as u32,
}
blinded_path_to_proto(
path.introduction_node(),
path.blinding_point(),
path.blinded_hops().len(),
)
})
.collect();

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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

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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
15 changes: 12 additions & 3 deletions ldk-server-grpc/src/api.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1109,19 +1109,22 @@ 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, `paths` is
/// BOLT12-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 DownExpand Up@@ -1173,6 +1176,12 @@ 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

/// Blinded payment paths to the recipient. Only present for BOLT12 invoices.
#[prost(message, repeated, tag = "17")]
pub paths: ::prost::alloc::vec::Vec<super::types::BlindedPath>,
}
/// Decode a BOLT12 offer and return its parsed fields.
/// This does not require a running node — it only parses the offer string.
Expand Down
17 changes: 13 additions & 4 deletions ldk-server-grpc/src/proto/api.proto
Original file line numberDiff line numberDiff line change
Expand Up@@ -795,14 +795,17 @@ 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, `paths` is
// BOLT12-only).
message DecodeInvoiceResponse {
// The hex-encoded public key of the destination node.
string destination = 1;
Expand DownExpand Up@@ -848,6 +851,12 @@ message DecodeInvoiceResponse {

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

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

// Blinded payment paths to the recipient. Only present for BOLT12 invoices.
repeated types.BlindedPath paths = 17;
}

// Decode a BOLT12 offer and return its parsed fields.
Expand DownExpand Up@@ -962,7 +971,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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
172 changes: 166 additions & 6 deletions ldk-server/src/api/decode_invoice.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,21 +11,81 @@ 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::api::{blinded_path_to_proto, decode_features};
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()
});

let paths = invoice
.payment_paths()
.iter()
.map(|path| {
blinded_path_to_proto(
path.introduction_node(),
path.blinding_point(),
path.blinded_hops().len(),
)
})
.collect();

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(),
paths,
..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 DownExpand Up@@ -85,7 +145,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 +161,105 @@ pub(crate) async fn handle_decode_invoice_request(
currency,
payment_metadata,
is_expired,
})
kind: INVOICE_KIND_BOLT11.to_string(),
// BOLT11 invoices carry route hints rather than blinded paths.
paths: Vec::new(),
}
}

#[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 ldk_server_grpc::types::blinded_path::IntroductionNode;

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);

// The sample invoice carries a single blinded payment path with two hops,
// introduced by `pubkey(40)` and blinded with `pubkey(41)`.
assert_eq!(response.paths.len(), 1);
let path = &response.paths[0];
assert_eq!(path.num_hops, 2);
assert_eq!(path.blinding_point, pubkey(41).to_string());
assert_eq!(path.introduction_node, Some(IntroductionNode::NodeId(pubkey(40).to_string())));
}
}
40 changes: 7 additions & 33 deletions ldk-server/src/api/decode_offer.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,16 +16,12 @@ use ldk_node::lightning::bitcoin::Network;
use ldk_node::lightning::offers::offer::Offer;
use ldk_node::lightning_types::features::OfferFeatures;
use ldk_server_grpc::api::{DecodeOfferRequest, DecodeOfferResponse};
use ldk_server_grpc::types::blinded_path::IntroductionNode;
use ldk_server_grpc::types::offer_amount::Amount;
use ldk_server_grpc::types::offer_quantity::Quantity;
use ldk_server_grpc::types::{
BlindedPath, ChannelDirection, CurrencyAmount, DirectedShortChannelId, OfferAmount,
OfferQuantity,
};
use ldk_server_grpc::types::{CurrencyAmount, OfferAmount, OfferQuantity};

use crate::api::decode_features;
use crate::api::error::LdkServerError;
use crate::api::{blinded_path_to_proto, decode_features};
use crate::service::Context;

pub(crate) async fn handle_decode_offer_request(
Expand DownExpand Up@@ -74,33 +70,11 @@ pub(crate) async fn handle_decode_offer_request(
.paths()
.iter()
.map(|path| {
let introduction_node = match path.introduction_node() {
ldk_node::lightning::blinded_path::IntroductionNode::NodeId(pk) => {
IntroductionNode::NodeId(pk.to_string())
},
ldk_node::lightning::blinded_path::IntroductionNode::DirectedShortChannelId(
dir,
scid,
) => {
let direction = match dir {
ldk_node::lightning::blinded_path::Direction::NodeOne => {
ChannelDirection::NodeOne
},
ldk_node::lightning::blinded_path::Direction::NodeTwo => {
ChannelDirection::NodeTwo
},
};
IntroductionNode::DirectedScid(DirectedShortChannelId {
scid: *scid,
direction: direction as i32,
})
},
};
BlindedPath {
introduction_node: Some(introduction_node),
blinding_point: path.blinding_point().to_string(),
num_hops: path.blinded_hops().len() as u32,
}
blinded_path_to_proto(
path.introduction_node(),
path.blinding_point(),
path.blinded_hops().len(),
)
})
.collect();

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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

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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
15 changes: 12 additions & 3 deletions ldk-server-grpc/src/api.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1109,19 +1109,22 @@ 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, `paths` is
/// BOLT12-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 DownExpand Up@@ -1173,6 +1176,12 @@ 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

/// Blinded payment paths to the recipient. Only present for BOLT12 invoices.
#[prost(message, repeated, tag = "17")]
pub paths: ::prost::alloc::vec::Vec<super::types::BlindedPath>,
}
/// Decode a BOLT12 offer and return its parsed fields.
/// This does not require a running node — it only parses the offer string.
Expand Down
17 changes: 13 additions & 4 deletions ldk-server-grpc/src/proto/api.proto
Original file line numberDiff line numberDiff line change
Expand Up@@ -795,14 +795,17 @@ 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, `paths` is
// BOLT12-only).
message DecodeInvoiceResponse {
// The hex-encoded public key of the destination node.
string destination = 1;
Expand DownExpand Up@@ -848,6 +851,12 @@ message DecodeInvoiceResponse {

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

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

// Blinded payment paths to the recipient. Only present for BOLT12 invoices.
repeated types.BlindedPath paths = 17;
}

// Decode a BOLT12 offer and return its parsed fields.
Expand DownExpand Up@@ -962,7 +971,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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
172 changes: 166 additions & 6 deletions ldk-server/src/api/decode_invoice.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,21 +11,81 @@ 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::api::{blinded_path_to_proto, decode_features};
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()
});

let paths = invoice
.payment_paths()
.iter()
.map(|path| {
blinded_path_to_proto(
path.introduction_node(),
path.blinding_point(),
path.blinded_hops().len(),
)
})
.collect();

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(),
paths,
..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 DownExpand Up@@ -85,7 +145,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 +161,105 @@ pub(crate) async fn handle_decode_invoice_request(
currency,
payment_metadata,
is_expired,
})
kind: INVOICE_KIND_BOLT11.to_string(),
// BOLT11 invoices carry route hints rather than blinded paths.
paths: Vec::new(),
}
}

#[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 ldk_server_grpc::types::blinded_path::IntroductionNode;

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);

// The sample invoice carries a single blinded payment path with two hops,
// introduced by `pubkey(40)` and blinded with `pubkey(41)`.
assert_eq!(response.paths.len(), 1);
let path = &response.paths[0];
assert_eq!(path.num_hops, 2);
assert_eq!(path.blinding_point, pubkey(41).to_string());
assert_eq!(path.introduction_node, Some(IntroductionNode::NodeId(pubkey(40).to_string())));
}
}
40 changes: 7 additions & 33 deletions ldk-server/src/api/decode_offer.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,16 +16,12 @@ use ldk_node::lightning::bitcoin::Network;
use ldk_node::lightning::offers::offer::Offer;
use ldk_node::lightning_types::features::OfferFeatures;
use ldk_server_grpc::api::{DecodeOfferRequest, DecodeOfferResponse};
use ldk_server_grpc::types::blinded_path::IntroductionNode;
use ldk_server_grpc::types::offer_amount::Amount;
use ldk_server_grpc::types::offer_quantity::Quantity;
use ldk_server_grpc::types::{
BlindedPath, ChannelDirection, CurrencyAmount, DirectedShortChannelId, OfferAmount,
OfferQuantity,
};
use ldk_server_grpc::types::{CurrencyAmount, OfferAmount, OfferQuantity};

use crate::api::decode_features;
use crate::api::error::LdkServerError;
use crate::api::{blinded_path_to_proto, decode_features};
use crate::service::Context;

pub(crate) async fn handle_decode_offer_request(
Expand DownExpand Up@@ -74,33 +70,11 @@ pub(crate) async fn handle_decode_offer_request(
.paths()
.iter()
.map(|path| {
let introduction_node = match path.introduction_node() {
ldk_node::lightning::blinded_path::IntroductionNode::NodeId(pk) => {
IntroductionNode::NodeId(pk.to_string())
},
ldk_node::lightning::blinded_path::IntroductionNode::DirectedShortChannelId(
dir,
scid,
) => {
let direction = match dir {
ldk_node::lightning::blinded_path::Direction::NodeOne => {
ChannelDirection::NodeOne
},
ldk_node::lightning::blinded_path::Direction::NodeTwo => {
ChannelDirection::NodeTwo
},
};
IntroductionNode::DirectedScid(DirectedShortChannelId {
scid: *scid,
direction: direction as i32,
})
},
};
BlindedPath {
introduction_node: Some(introduction_node),
blinding_point: path.blinding_point().to_string(),
num_hops: path.blinded_hops().len() as u32,
}
blinded_path_to_proto(
path.introduction_node(),
path.blinding_point(),
path.blinded_hops().len(),
)
})
.collect();

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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

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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
15 changes: 12 additions & 3 deletions ldk-server-grpc/src/api.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1109,19 +1109,22 @@ 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, `paths` is
/// BOLT12-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 DownExpand Up@@ -1173,6 +1176,12 @@ 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

/// Blinded payment paths to the recipient. Only present for BOLT12 invoices.
#[prost(message, repeated, tag = "17")]
pub paths: ::prost::alloc::vec::Vec<super::types::BlindedPath>,
}
/// Decode a BOLT12 offer and return its parsed fields.
/// This does not require a running node — it only parses the offer string.
Expand Down
17 changes: 13 additions & 4 deletions ldk-server-grpc/src/proto/api.proto
Original file line numberDiff line numberDiff line change
Expand Up@@ -795,14 +795,17 @@ 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, `paths` is
// BOLT12-only).
message DecodeInvoiceResponse {
// The hex-encoded public key of the destination node.
string destination = 1;
Expand DownExpand Up@@ -848,6 +851,12 @@ message DecodeInvoiceResponse {

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

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

// Blinded payment paths to the recipient. Only present for BOLT12 invoices.
repeated types.BlindedPath paths = 17;
}

// Decode a BOLT12 offer and return its parsed fields.
Expand DownExpand Up@@ -962,7 +971,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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
172 changes: 166 additions & 6 deletions ldk-server/src/api/decode_invoice.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,21 +11,81 @@ 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::api::{blinded_path_to_proto, decode_features};
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()
});

let paths = invoice
.payment_paths()
.iter()
.map(|path| {
blinded_path_to_proto(
path.introduction_node(),
path.blinding_point(),
path.blinded_hops().len(),
)
})
.collect();

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(),
paths,
..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 DownExpand Up@@ -85,7 +145,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 +161,105 @@ pub(crate) async fn handle_decode_invoice_request(
currency,
payment_metadata,
is_expired,
})
kind: INVOICE_KIND_BOLT11.to_string(),
// BOLT11 invoices carry route hints rather than blinded paths.
paths: Vec::new(),
}
}

#[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 ldk_server_grpc::types::blinded_path::IntroductionNode;

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);

// The sample invoice carries a single blinded payment path with two hops,
// introduced by `pubkey(40)` and blinded with `pubkey(41)`.
assert_eq!(response.paths.len(), 1);
let path = &response.paths[0];
assert_eq!(path.num_hops, 2);
assert_eq!(path.blinding_point, pubkey(41).to_string());
assert_eq!(path.introduction_node, Some(IntroductionNode::NodeId(pubkey(40).to_string())));
}
}
40 changes: 7 additions & 33 deletions ldk-server/src/api/decode_offer.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,16 +16,12 @@ use ldk_node::lightning::bitcoin::Network;
use ldk_node::lightning::offers::offer::Offer;
use ldk_node::lightning_types::features::OfferFeatures;
use ldk_server_grpc::api::{DecodeOfferRequest, DecodeOfferResponse};
use ldk_server_grpc::types::blinded_path::IntroductionNode;
use ldk_server_grpc::types::offer_amount::Amount;
use ldk_server_grpc::types::offer_quantity::Quantity;
use ldk_server_grpc::types::{
BlindedPath, ChannelDirection, CurrencyAmount, DirectedShortChannelId, OfferAmount,
OfferQuantity,
};
use ldk_server_grpc::types::{CurrencyAmount, OfferAmount, OfferQuantity};

use crate::api::decode_features;
use crate::api::error::LdkServerError;
use crate::api::{blinded_path_to_proto, decode_features};
use crate::service::Context;

pub(crate) async fn handle_decode_offer_request(
Expand DownExpand Up@@ -74,33 +70,11 @@ pub(crate) async fn handle_decode_offer_request(
.paths()
.iter()
.map(|path| {
let introduction_node = match path.introduction_node() {
ldk_node::lightning::blinded_path::IntroductionNode::NodeId(pk) => {
IntroductionNode::NodeId(pk.to_string())
},
ldk_node::lightning::blinded_path::IntroductionNode::DirectedShortChannelId(
dir,
scid,
) => {
let direction = match dir {
ldk_node::lightning::blinded_path::Direction::NodeOne => {
ChannelDirection::NodeOne
},
ldk_node::lightning::blinded_path::Direction::NodeTwo => {
ChannelDirection::NodeTwo
},
};
IntroductionNode::DirectedScid(DirectedShortChannelId {
scid: *scid,
direction: direction as i32,
})
},
};
BlindedPath {
introduction_node: Some(introduction_node),
blinding_point: path.blinding_point().to_string(),
num_hops: path.blinded_hops().len() as u32,
}
blinded_path_to_proto(
path.introduction_node(),
path.blinding_point(),
path.blinded_hops().len(),
)
})
.collect();

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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

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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
15 changes: 12 additions & 3 deletions ldk-server-grpc/src/api.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1109,19 +1109,22 @@ 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, `paths` is
/// BOLT12-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 DownExpand Up@@ -1173,6 +1176,12 @@ 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

/// Blinded payment paths to the recipient. Only present for BOLT12 invoices.
#[prost(message, repeated, tag = "17")]
pub paths: ::prost::alloc::vec::Vec<super::types::BlindedPath>,
}
/// Decode a BOLT12 offer and return its parsed fields.
/// This does not require a running node — it only parses the offer string.
Expand Down
17 changes: 13 additions & 4 deletions ldk-server-grpc/src/proto/api.proto
Original file line numberDiff line numberDiff line change
Expand Up@@ -795,14 +795,17 @@ 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, `paths` is
// BOLT12-only).
message DecodeInvoiceResponse {
// The hex-encoded public key of the destination node.
string destination = 1;
Expand DownExpand Up@@ -848,6 +851,12 @@ message DecodeInvoiceResponse {

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

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

// Blinded payment paths to the recipient. Only present for BOLT12 invoices.
repeated types.BlindedPath paths = 17;
}

// Decode a BOLT12 offer and return its parsed fields.
Expand DownExpand Up@@ -962,7 +971,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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
172 changes: 166 additions & 6 deletions ldk-server/src/api/decode_invoice.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,21 +11,81 @@ 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::api::{blinded_path_to_proto, decode_features};
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()
});

let paths = invoice
.payment_paths()
.iter()
.map(|path| {
blinded_path_to_proto(
path.introduction_node(),
path.blinding_point(),
path.blinded_hops().len(),
)
})
.collect();

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(),
paths,
..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 DownExpand Up@@ -85,7 +145,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 +161,105 @@ pub(crate) async fn handle_decode_invoice_request(
currency,
payment_metadata,
is_expired,
})
kind: INVOICE_KIND_BOLT11.to_string(),
// BOLT11 invoices carry route hints rather than blinded paths.
paths: Vec::new(),
}
}

#[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 ldk_server_grpc::types::blinded_path::IntroductionNode;

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);

// The sample invoice carries a single blinded payment path with two hops,
// introduced by `pubkey(40)` and blinded with `pubkey(41)`.
assert_eq!(response.paths.len(), 1);
let path = &response.paths[0];
assert_eq!(path.num_hops, 2);
assert_eq!(path.blinding_point, pubkey(41).to_string());
assert_eq!(path.introduction_node, Some(IntroductionNode::NodeId(pubkey(40).to_string())));
}
}
40 changes: 7 additions & 33 deletions ldk-server/src/api/decode_offer.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,16 +16,12 @@ use ldk_node::lightning::bitcoin::Network;
use ldk_node::lightning::offers::offer::Offer;
use ldk_node::lightning_types::features::OfferFeatures;
use ldk_server_grpc::api::{DecodeOfferRequest, DecodeOfferResponse};
use ldk_server_grpc::types::blinded_path::IntroductionNode;
use ldk_server_grpc::types::offer_amount::Amount;
use ldk_server_grpc::types::offer_quantity::Quantity;
use ldk_server_grpc::types::{
BlindedPath, ChannelDirection, CurrencyAmount, DirectedShortChannelId, OfferAmount,
OfferQuantity,
};
use ldk_server_grpc::types::{CurrencyAmount, OfferAmount, OfferQuantity};

use crate::api::decode_features;
use crate::api::error::LdkServerError;
use crate::api::{blinded_path_to_proto, decode_features};
use crate::service::Context;

pub(crate) async fn handle_decode_offer_request(
Expand DownExpand Up@@ -74,33 +70,11 @@ pub(crate) async fn handle_decode_offer_request(
.paths()
.iter()
.map(|path| {
let introduction_node = match path.introduction_node() {
ldk_node::lightning::blinded_path::IntroductionNode::NodeId(pk) => {
IntroductionNode::NodeId(pk.to_string())
},
ldk_node::lightning::blinded_path::IntroductionNode::DirectedShortChannelId(
dir,
scid,
) => {
let direction = match dir {
ldk_node::lightning::blinded_path::Direction::NodeOne => {
ChannelDirection::NodeOne
},
ldk_node::lightning::blinded_path::Direction::NodeTwo => {
ChannelDirection::NodeTwo
},
};
IntroductionNode::DirectedScid(DirectedShortChannelId {
scid: *scid,
direction: direction as i32,
})
},
};
BlindedPath {
introduction_node: Some(introduction_node),
blinding_point: path.blinding_point().to_string(),
num_hops: path.blinded_hops().len() as u32,
}
blinded_path_to_proto(
path.introduction_node(),
path.blinding_point(),
path.blinded_hops().len(),
)
})
.collect();

Expand Down
Loading