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
110 changes: 110 additions & 0 deletions e2e-tests/tests/e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,70 @@ async fn test_cli_bolt11_receive() {
assert_eq!(invoice.payment_secret().0, payment_secret);
}

#[tokio::test]
async fn test_cli_decode_invoice() {
let bitcoind = TestBitcoind::new();
let server = LdkServerHandle::start(&bitcoind).await;

// Create a BOLT11 invoice with known parameters
let output =
run_cli(&server, &["bolt11-receive", "50000sat", "-d", "decode test", "-e", "3600"]);
let invoice_str = output["invoice"].as_str().unwrap();

// Decode it
let decoded = run_cli(&server, &["decode-invoice", invoice_str]);

// Verify fields match
assert_eq!(decoded["destination"], server.node_id());
assert_eq!(decoded["payment_hash"], output["payment_hash"]);
assert_eq!(decoded["amount_msat"], 50_000_000);
assert_eq!(decoded["description"], "decode test");
assert!(decoded.get("description_hash").is_none() || decoded["description_hash"].is_null());
assert_eq!(decoded["expiry"], 3600);
assert_eq!(decoded["currency"], "regtest");
assert_eq!(decoded["payment_secret"], output["payment_secret"]);
assert!(decoded["timestamp"].as_u64().unwrap() > 0);
assert!(decoded["min_final_cltv_expiry_delta"].as_u64().unwrap() > 0);
assert_eq!(decoded["is_expired"], false);

// Verify features — LDK BOLT11 invoices always set VariableLengthOnion, PaymentSecret,
// and BasicMPP.
let features = decoded["features"].as_object().unwrap();
assert!(!features.is_empty(), "Expected at least one feature");

let feature_names: Vec<&str> = features.values().filter_map(|f| f["name"].as_str()).collect();
assert!(
feature_names.contains(&"VariableLengthOnion"),
"Expected VariableLengthOnion in features: {:?}",
feature_names
);
assert!(
feature_names.contains(&"PaymentSecret"),
"Expected PaymentSecret in features: {:?}",
feature_names
);
assert!(
feature_names.contains(&"BasicMPP"),
"Expected BasicMPP in features: {:?}",
feature_names
);

// Every entry should have the expected structure
for (bit, feature) in features {
assert!(bit.parse::<u32>().is_ok(), "Feature key should be a bit number: {}", bit);
assert!(feature.get("name").is_some(), "Feature missing name field");
assert!(feature.get("is_required").is_some(), "Feature missing is_required field");
assert!(feature.get("is_known").is_some(), "Feature missing is_known field");
}

// Also test a variable-amount invoice
let output_var = run_cli(&server, &["bolt11-receive", "-d", "no amount"]);
let decoded_var =
run_cli(&server, &["decode-invoice", output_var["invoice"].as_str().unwrap()]);
assert!(decoded_var.get("amount_msat").is_none() || decoded_var["amount_msat"].is_null());
assert_eq!(decoded_var["description"], "no amount");
}

#[tokio::test]
async fn test_cli_bolt12_receive() {
let bitcoind = TestBitcoind::new();
Expand All @@ -165,6 +229,52 @@ async fn test_cli_bolt12_receive() {
assert_eq!(offer.id().0, offer_id);
}

#[tokio::test]
async fn test_cli_decode_offer() {
let bitcoind = TestBitcoind::new();
let server_a = LdkServerHandle::start(&bitcoind).await;
let server_b = LdkServerHandle::start(&bitcoind).await;
// BOLT12 offers need announced channels for blinded reply paths
setup_funded_channel(&bitcoind, &server_a, &server_b, 100_000).await;

// Create a BOLT12 offer with known parameters
let output = run_cli(&server_a, &["bolt12-receive", "decode offer test"]);
let offer_str = output["offer"].as_str().unwrap();

// Decode it
let decoded = run_cli(&server_a, &["decode-offer", offer_str]);

// Verify fields match
assert_eq!(decoded["offer_id"], output["offer_id"]);
assert_eq!(decoded["description"], "decode offer test");
assert_eq!(decoded["is_expired"], false);

// Chains should include regtest
let chains = decoded["chains"].as_array().unwrap();
assert!(chains.iter().any(|c| c == "regtest"), "Expected regtest in chains: {:?}", chains);

// Paths should be present (BOLT12 offers with blinded paths)
let paths = decoded["paths"].as_array().unwrap();
assert!(!paths.is_empty(), "Expected at least one blinded path");
for path in paths {
assert!(path["num_hops"].as_u64().unwrap() > 0);
assert!(!path["blinding_point"].as_str().unwrap().is_empty());
}

// Features — OfferContext has no known features in LDK, so this should be empty
let features = decoded["features"].as_object().unwrap();
assert!(features.is_empty(), "Expected empty offer features, got: {:?}", features);

// Variable-amount offer should have no amount
assert!(decoded.get("amount").is_none() || decoded["amount"].is_null());

// Test a fixed-amount offer
let output_fixed = run_cli(&server_a, &["bolt12-receive", "fixed amount", "50000sat"]);
let decoded_fixed =
run_cli(&server_a, &["decode-offer", output_fixed["offer"].as_str().unwrap()]);
assert_eq!(decoded_fixed["amount"]["amount"]["bitcoin_amount_msats"], 50_000_000);
}

#[tokio::test]
async fn test_cli_onchain_send() {
let bitcoind = TestBitcoind::new();
Expand Down
21 changes: 21 additions & 0 deletions ldk-server-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ use ldk_server_client::ldk_server_protos::api::{
Bolt11ReceiveViaJitChannelResponse, Bolt11SendRequest, Bolt11SendResponse,
Bolt12ReceiveRequest, Bolt12ReceiveResponse, Bolt12SendRequest, Bolt12SendResponse,
CloseChannelRequest, CloseChannelResponse, ConnectPeerRequest, ConnectPeerResponse,
DecodeInvoiceRequest, DecodeInvoiceResponse, DecodeOfferRequest, DecodeOfferResponse,
DisconnectPeerRequest, DisconnectPeerResponse, ExportPathfindingScoresRequest,
ForceCloseChannelRequest, ForceCloseChannelResponse, GetBalancesRequest, GetBalancesResponse,
GetNodeInfoRequest, GetNodeInfoResponse, GetPaymentDetailsRequest, GetPaymentDetailsResponse,
Expand Down Expand Up @@ -338,6 +339,16 @@ enum Commands {
)]
max_channel_saturation_power_of_half: Option<u32>,
},
#[command(about = "Decode a BOLT11 invoice and display its fields")]
DecodeInvoice {
#[arg(help = "The BOLT11 invoice string to decode")]
invoice: String,
},
#[command(about = "Decode a BOLT12 offer and display its fields")]
DecodeOffer {
#[arg(help = "The BOLT12 offer string to decode")]
offer: String,
},
#[command(about = "Cooperatively close the channel specified by the given channel ID")]
CloseChannel {
#[arg(help = "The local user_channel_id of this channel")]
Expand Down Expand Up @@ -862,6 +873,16 @@ async fn main() {
.await,
);
},
Commands::DecodeInvoice { invoice } => {
handle_response_result::<_, DecodeInvoiceResponse>(
client.decode_invoice(DecodeInvoiceRequest { invoice }).await,
);
},
Commands::DecodeOffer { offer } => {
handle_response_result::<_, DecodeOfferResponse>(
client.decode_offer(DecodeOfferRequest { offer }).await,
);
},
Commands::CloseChannel { user_channel_id, counterparty_node_id } => {
handle_response_result::<_, CloseChannelResponse>(
client
Expand Down
33 changes: 26 additions & 7 deletions ldk-server-client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ use ldk_server_protos::api::{
Bolt11ReceiveViaJitChannelResponse, Bolt11SendRequest, Bolt11SendResponse,
Bolt12ReceiveRequest, Bolt12ReceiveResponse, Bolt12SendRequest, Bolt12SendResponse,
CloseChannelRequest, CloseChannelResponse, ConnectPeerRequest, ConnectPeerResponse,
DecodeInvoiceRequest, DecodeInvoiceResponse, DecodeOfferRequest, DecodeOfferResponse,
DisconnectPeerRequest, DisconnectPeerResponse, ExportPathfindingScoresRequest,
ExportPathfindingScoresResponse, ForceCloseChannelRequest, ForceCloseChannelResponse,
GetBalancesRequest, GetBalancesResponse, GetNodeInfoRequest, GetNodeInfoResponse,
Expand All @@ -37,13 +38,13 @@ use ldk_server_protos::endpoints::{
BOLT11_CLAIM_FOR_HASH_PATH, BOLT11_FAIL_FOR_HASH_PATH, BOLT11_RECEIVE_FOR_HASH_PATH,
BOLT11_RECEIVE_PATH, BOLT11_RECEIVE_VARIABLE_AMOUNT_VIA_JIT_CHANNEL_PATH,
BOLT11_RECEIVE_VIA_JIT_CHANNEL_PATH, BOLT11_SEND_PATH, BOLT12_RECEIVE_PATH, BOLT12_SEND_PATH,
CLOSE_CHANNEL_PATH, CONNECT_PEER_PATH, DISCONNECT_PEER_PATH, EXPORT_PATHFINDING_SCORES_PATH,
FORCE_CLOSE_CHANNEL_PATH, GET_BALANCES_PATH, GET_NODE_INFO_PATH, GET_PAYMENT_DETAILS_PATH,
GRAPH_GET_CHANNEL_PATH, GRAPH_GET_NODE_PATH, GRAPH_LIST_CHANNELS_PATH, GRAPH_LIST_NODES_PATH,
LIST_CHANNELS_PATH, LIST_FORWARDED_PAYMENTS_PATH, LIST_PAYMENTS_PATH, LIST_PEERS_PATH,
ONCHAIN_RECEIVE_PATH, ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH,
SPLICE_OUT_PATH, SPONTANEOUS_SEND_PATH, UNIFIED_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH,
VERIFY_SIGNATURE_PATH,
CLOSE_CHANNEL_PATH, CONNECT_PEER_PATH, DECODE_INVOICE_PATH, DECODE_OFFER_PATH,
DISCONNECT_PEER_PATH, EXPORT_PATHFINDING_SCORES_PATH, FORCE_CLOSE_CHANNEL_PATH,
GET_BALANCES_PATH, GET_NODE_INFO_PATH, GET_PAYMENT_DETAILS_PATH, GRAPH_GET_CHANNEL_PATH,
GRAPH_GET_NODE_PATH, GRAPH_LIST_CHANNELS_PATH, GRAPH_LIST_NODES_PATH, LIST_CHANNELS_PATH,
LIST_FORWARDED_PAYMENTS_PATH, LIST_PAYMENTS_PATH, LIST_PEERS_PATH, ONCHAIN_RECEIVE_PATH,
ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH, SPLICE_OUT_PATH,
SPONTANEOUS_SEND_PATH, UNIFIED_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH, VERIFY_SIGNATURE_PATH,
};
use ldk_server_protos::error::{ErrorCode, ErrorResponse};
use prost::Message;
Expand Down Expand Up @@ -364,6 +365,24 @@ impl LdkServerClient {
self.post_request(&request, &url).await
}

/// Decode a BOLT11 invoice and return its parsed fields.
/// For API contract/usage, refer to docs for [`DecodeInvoiceRequest`] and [`DecodeInvoiceResponse`].
pub async fn decode_invoice(
&self, request: DecodeInvoiceRequest,
) -> Result<DecodeInvoiceResponse, LdkServerError> {
let url = format!("https://{}/{DECODE_INVOICE_PATH}", self.base_url);
self.post_request(&request, &url).await
}

/// Decode a BOLT12 offer and return its parsed fields.
/// For API contract/usage, refer to docs for [`DecodeOfferRequest`] and [`DecodeOfferResponse`].
pub async fn decode_offer(
&self, request: DecodeOfferRequest,
) -> Result<DecodeOfferResponse, LdkServerError> {
let url = format!("https://{}/{DECODE_OFFER_PATH}", self.base_url);
self.post_request(&request, &url).await
}

/// Sign a message with the node's secret key.
/// For API contract/usage, refer to docs for [`SignMessageRequest`] and [`SignMessageResponse`].
pub async fn sign_message(
Expand Down
119 changes: 119 additions & 0 deletions ldk-server-protos/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1067,3 +1067,122 @@ 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.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct DecodeInvoiceRequest {
/// The BOLT11 invoice string to decode.
#[prost(string, tag = "1")]
pub invoice: ::prost::alloc::string::String,
}
/// The response `content` for the `DecodeInvoice` API, when HttpStatusCode is OK (200).
/// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct DecodeInvoiceResponse {
/// The hex-encoded public key of the destination node.
#[prost(string, tag = "1")]
pub destination: ::prost::alloc::string::String,
/// The hex-encoded 32-byte payment hash.
#[prost(string, tag = "2")]
pub payment_hash: ::prost::alloc::string::String,
/// The amount in millisatoshis, if specified in the invoice.
#[prost(uint64, optional, tag = "3")]
pub amount_msat: ::core::option::Option<u64>,
/// The creation timestamp in seconds since the UNIX epoch.
#[prost(uint64, tag = "4")]
pub timestamp: u64,
/// The invoice expiry time in seconds.
#[prost(uint64, tag = "5")]
pub expiry: u64,
/// The invoice description, if a direct description was provided.
#[prost(string, optional, tag = "6")]
pub description: ::core::option::Option<::prost::alloc::string::String>,
/// The hex-encoded SHA-256 hash of the description, if a description hash was used.
#[prost(string, optional, tag = "14")]
pub description_hash: ::core::option::Option<::prost::alloc::string::String>,
/// The fallback on-chain address, if any.
#[prost(string, optional, tag = "7")]
pub fallback_address: ::core::option::Option<::prost::alloc::string::String>,
/// The minimum final CLTV expiry delta.
#[prost(uint64, tag = "8")]
pub min_final_cltv_expiry_delta: u64,
/// The hex-encoded 32-byte payment secret.
#[prost(string, tag = "9")]
pub payment_secret: ::prost::alloc::string::String,
/// Route hints for finding a path to the payee.
#[prost(message, repeated, tag = "10")]
pub route_hints: ::prost::alloc::vec::Vec<super::types::Bolt11RouteHint>,
/// Feature bits advertised in the invoice, keyed by bit number.
#[prost(map = "uint32, message", tag = "11")]
pub features: ::std::collections::HashMap<u32, super::types::Bolt11Feature>,
/// The currency or network (e.g., "bitcoin", "testnet", "signet", "regtest").
#[prost(string, tag = "12")]
pub currency: ::prost::alloc::string::String,
/// The payment metadata, hex-encoded. Only present if the invoice includes payment metadata.
#[prost(string, optional, tag = "13")]
pub payment_metadata: ::core::option::Option<::prost::alloc::string::String>,
/// Whether the invoice has expired.
#[prost(bool, tag = "15")]
pub is_expired: bool,
}
/// Decode a BOLT12 offer and return its parsed fields.
/// This does not require a running node — it only parses the offer string.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct DecodeOfferRequest {
/// The BOLT12 offer string to decode.
#[prost(string, tag = "1")]
pub offer: ::prost::alloc::string::String,
}
/// The response `content` for the `DecodeOffer` API, when HttpStatusCode is OK (200).
/// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct DecodeOfferResponse {
/// The hex-encoded offer ID.
#[prost(string, tag = "1")]
pub offer_id: ::prost::alloc::string::String,
/// The description of the offer, if any.
#[prost(string, optional, tag = "2")]
pub description: ::core::option::Option<::prost::alloc::string::String>,
/// The issuer of the offer, if any.
#[prost(string, optional, tag = "3")]
pub issuer: ::core::option::Option<::prost::alloc::string::String>,
/// The amount, if specified.
#[prost(message, optional, tag = "4")]
pub amount: ::core::option::Option<super::types::OfferAmount>,
/// The hex-encoded public key used by the issuer to sign invoices, if any.
#[prost(string, optional, tag = "5")]
pub issuer_signing_pubkey: ::core::option::Option<::prost::alloc::string::String>,
/// The absolute expiry time in seconds since the UNIX epoch, if any.
#[prost(uint64, optional, tag = "6")]
pub absolute_expiry: ::core::option::Option<u64>,
/// The supported quantity of items.
#[prost(message, optional, tag = "7")]
pub quantity: ::core::option::Option<super::types::OfferQuantity>,
/// Blinded paths to the offer recipient.
#[prost(message, repeated, tag = "8")]
pub paths: ::prost::alloc::vec::Vec<super::types::BlindedPath>,
/// Feature bits advertised in the offer, keyed by bit number.
#[prost(map = "uint32, message", tag = "9")]
pub features: ::std::collections::HashMap<u32, super::types::Bolt11Feature>,
/// Supported blockchain networks (e.g., "bitcoin", "testnet", "signet", "regtest").
#[prost(string, repeated, tag = "10")]
pub chains: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
/// The metadata, hex-encoded, if any.
#[prost(string, optional, tag = "11")]
pub metadata: ::core::option::Option<::prost::alloc::string::String>,
/// Whether the offer has expired.
#[prost(bool, tag = "12")]
pub is_expired: bool,
}
2 changes: 2 additions & 0 deletions ldk-server-protos/src/endpoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,5 @@ pub const GRAPH_LIST_CHANNELS_PATH: &str = "GraphListChannels";
pub const GRAPH_GET_CHANNEL_PATH: &str = "GraphGetChannel";
pub const GRAPH_LIST_NODES_PATH: &str = "GraphListNodes";
pub const GRAPH_GET_NODE_PATH: &str = "GraphGetNode";
pub const DECODE_INVOICE_PATH: &str = "DecodeInvoice";
pub const DECODE_OFFER_PATH: &str = "DecodeOffer";
Loading
Loading