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
168 changes: 168 additions & 0 deletions e2e-tests/tests/e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,100 @@ 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");

// Test that ANSI escape sequences cannot reach the terminal via CLI output.
// serde_json escapes control chars (U+0000–U+001F) as \uXXXX in JSON.
let desc_with_ansi = "pay me\x1b[31m RED \x1b[0m";
let output_ansi = run_cli(&server, &["bolt11-receive", "-d", desc_with_ansi]);
let raw_decoded = run_cli_raw(
&server,
&["decode-invoice", output_ansi["invoice"].as_str().unwrap()],
);
assert!(
!raw_decoded.contains('\x1b'),
"Raw CLI output must not contain ANSI escape bytes"
);

// Test that Unicode bidi override characters in the description are escaped
// (sanitize_for_terminal replaces them with \uXXXX in CLI output)
let desc_with_bidi = "pay me\u{202E}evil";
let output_bidi = run_cli(&server, &["bolt11-receive", "-d", desc_with_bidi]);
let raw_bidi = run_cli_raw(
&server,
&["decode-invoice", output_bidi["invoice"].as_str().unwrap()],
);
assert!(
!raw_bidi.contains('\u{202E}'),
"Raw CLI output must not contain bidi override characters"
);
assert!(
raw_bidi.contains("\\u202E"),
"Bidi characters should be escaped as \\uXXXX in output"
);
}

#[tokio::test]
async fn test_cli_bolt12_receive() {
let bitcoind = TestBitcoind::new();
Expand All @@ -165,6 +259,80 @@ 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);

// Test that ANSI escape sequences cannot reach the terminal via CLI output.
let desc_with_ansi = "offer\x1b[31m RED \x1b[0m";
let output_ansi = run_cli(&server_a, &["bolt12-receive", desc_with_ansi]);
let raw_decoded = run_cli_raw(
&server_a,
&["decode-offer", output_ansi["offer"].as_str().unwrap()],
);
assert!(
!raw_decoded.contains('\x1b'),
"Raw CLI output must not contain ANSI escape bytes"
);

// Test that Unicode bidi override characters in the description are escaped
let desc_with_bidi = "offer\u{202E}evil";
let output_bidi = run_cli(&server_a, &["bolt12-receive", desc_with_bidi]);
let raw_bidi = run_cli_raw(
&server_a,
&["decode-offer", output_bidi["offer"].as_str().unwrap()],
);
assert!(
!raw_bidi.contains('\u{202E}'),
"Raw CLI output must not contain bidi override characters"
);
assert!(
raw_bidi.contains("\\u202E"),
"Bidi characters should be escaped as \\uXXXX in output"
);
}

#[tokio::test]
async fn test_cli_onchain_send() {
let bitcoind = TestBitcoind::new();
Expand Down
60 changes: 59 additions & 1 deletion ldk-server-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
// You may not use this file except in accordance with one or both of these
// licenses.

use std::fmt::Write;
use std::path::PathBuf;

use clap::{CommandFactory, Parser, Subcommand};
Expand All @@ -29,6 +30,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 +340,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 +874,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 Expand Up @@ -1144,6 +1166,42 @@ where
}
}

/// Escapes Unicode bidirectional control characters as `\uXXXX` so they are visible
/// in terminal output rather than silently reordering displayed text.
/// serde_json already escapes ASCII control characters (U+0000–U+001F), but bidi
/// overrides (U+200E–U+2069) pass through unescaped.
fn sanitize_for_terminal(s: &str) -> String {
fn is_bidi_control(c: char) -> bool {
matches!(
c,
'\u{200E}' // LEFT-TO-RIGHT MARK
| '\u{200F}' // RIGHT-TO-LEFT MARK
| '\u{202A}' // LEFT-TO-RIGHT EMBEDDING
| '\u{202B}' // RIGHT-TO-LEFT EMBEDDING
| '\u{202C}' // POP DIRECTIONAL FORMATTING
| '\u{202D}' // LEFT-TO-RIGHT OVERRIDE
| '\u{202E}' // RIGHT-TO-LEFT OVERRIDE
| '\u{2066}' // LEFT-TO-RIGHT ISOLATE
| '\u{2067}' // RIGHT-TO-LEFT ISOLATE
| '\u{2068}' // FIRST STRONG ISOLATE
| '\u{2069}' // POP DIRECTIONAL ISOLATE
)
}
if s.chars().any(is_bidi_control) {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if is_bidi_control(c) {
write!(out, "\\u{:04X}", c as u32).unwrap();
} else {
out.push(c);
}
}
out
} else {
s.to_string()
}
}

fn handle_response_result<Rs, Js>(response: Result<Rs, LdkServerError>)
where
Rs: Into<Js>,
Expand All @@ -1153,7 +1211,7 @@ where
Ok(response) => {
let json_response: Js = response.into();
match serde_json::to_string_pretty(&json_response) {
Ok(json) => println!("{json}"),
Ok(json) => println!("{}", sanitize_for_terminal(&json)),
Err(e) => {
eprintln!("Error serializing response ({json_response:?}) to JSON: {e}");
std::process::exit(1);
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
Loading
Loading