Skip to content

Commit 12ce2d1

Browse files
authored
Merge pull request #164 from benthecarman/decode-inv
Add decode invoice and offer RPCs
2 parents 2a5405f + 3588e03 commit 12ce2d1

File tree

12 files changed

+1010
-15
lines changed

12 files changed

+1010
-15
lines changed

e2e-tests/tests/e2e.rs

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,100 @@ async fn test_cli_bolt11_receive() {
148148
assert_eq!(invoice.payment_secret().0, payment_secret);
149149
}
150150

151+
#[tokio::test]
152+
async fn test_cli_decode_invoice() {
153+
let bitcoind = TestBitcoind::new();
154+
let server = LdkServerHandle::start(&bitcoind).await;
155+
156+
// Create a BOLT11 invoice with known parameters
157+
let output =
158+
run_cli(&server, &["bolt11-receive", "50000sat", "-d", "decode test", "-e", "3600"]);
159+
let invoice_str = output["invoice"].as_str().unwrap();
160+
161+
// Decode it
162+
let decoded = run_cli(&server, &["decode-invoice", invoice_str]);
163+
164+
// Verify fields match
165+
assert_eq!(decoded["destination"], server.node_id());
166+
assert_eq!(decoded["payment_hash"], output["payment_hash"]);
167+
assert_eq!(decoded["amount_msat"], 50_000_000);
168+
assert_eq!(decoded["description"], "decode test");
169+
assert!(decoded.get("description_hash").is_none() || decoded["description_hash"].is_null());
170+
assert_eq!(decoded["expiry"], 3600);
171+
assert_eq!(decoded["currency"], "regtest");
172+
assert_eq!(decoded["payment_secret"], output["payment_secret"]);
173+
assert!(decoded["timestamp"].as_u64().unwrap() > 0);
174+
assert!(decoded["min_final_cltv_expiry_delta"].as_u64().unwrap() > 0);
175+
assert_eq!(decoded["is_expired"], false);
176+
177+
// Verify features — LDK BOLT11 invoices always set VariableLengthOnion, PaymentSecret,
178+
// and BasicMPP.
179+
let features = decoded["features"].as_object().unwrap();
180+
assert!(!features.is_empty(), "Expected at least one feature");
181+
182+
let feature_names: Vec<&str> = features.values().filter_map(|f| f["name"].as_str()).collect();
183+
assert!(
184+
feature_names.contains(&"VariableLengthOnion"),
185+
"Expected VariableLengthOnion in features: {:?}",
186+
feature_names
187+
);
188+
assert!(
189+
feature_names.contains(&"PaymentSecret"),
190+
"Expected PaymentSecret in features: {:?}",
191+
feature_names
192+
);
193+
assert!(
194+
feature_names.contains(&"BasicMPP"),
195+
"Expected BasicMPP in features: {:?}",
196+
feature_names
197+
);
198+
199+
// Every entry should have the expected structure
200+
for (bit, feature) in features {
201+
assert!(bit.parse::<u32>().is_ok(), "Feature key should be a bit number: {}", bit);
202+
assert!(feature.get("name").is_some(), "Feature missing name field");
203+
assert!(feature.get("is_required").is_some(), "Feature missing is_required field");
204+
assert!(feature.get("is_known").is_some(), "Feature missing is_known field");
205+
}
206+
207+
// Also test a variable-amount invoice
208+
let output_var = run_cli(&server, &["bolt11-receive", "-d", "no amount"]);
209+
let decoded_var =
210+
run_cli(&server, &["decode-invoice", output_var["invoice"].as_str().unwrap()]);
211+
assert!(decoded_var.get("amount_msat").is_none() || decoded_var["amount_msat"].is_null());
212+
assert_eq!(decoded_var["description"], "no amount");
213+
214+
// Test that ANSI escape sequences cannot reach the terminal via CLI output.
215+
// serde_json escapes control chars (U+0000–U+001F) as \uXXXX in JSON.
216+
let desc_with_ansi = "pay me\x1b[31m RED \x1b[0m";
217+
let output_ansi = run_cli(&server, &["bolt11-receive", "-d", desc_with_ansi]);
218+
let raw_decoded = run_cli_raw(
219+
&server,
220+
&["decode-invoice", output_ansi["invoice"].as_str().unwrap()],
221+
);
222+
assert!(
223+
!raw_decoded.contains('\x1b'),
224+
"Raw CLI output must not contain ANSI escape bytes"
225+
);
226+
227+
// Test that Unicode bidi override characters in the description are escaped
228+
// (sanitize_for_terminal replaces them with \uXXXX in CLI output)
229+
let desc_with_bidi = "pay me\u{202E}evil";
230+
let output_bidi = run_cli(&server, &["bolt11-receive", "-d", desc_with_bidi]);
231+
let raw_bidi = run_cli_raw(
232+
&server,
233+
&["decode-invoice", output_bidi["invoice"].as_str().unwrap()],
234+
);
235+
assert!(
236+
!raw_bidi.contains('\u{202E}'),
237+
"Raw CLI output must not contain bidi override characters"
238+
);
239+
assert!(
240+
raw_bidi.contains("\\u202E"),
241+
"Bidi characters should be escaped as \\uXXXX in output"
242+
);
243+
}
244+
151245
#[tokio::test]
152246
async fn test_cli_bolt12_receive() {
153247
let bitcoind = TestBitcoind::new();
@@ -165,6 +259,80 @@ async fn test_cli_bolt12_receive() {
165259
assert_eq!(offer.id().0, offer_id);
166260
}
167261

262+
#[tokio::test]
263+
async fn test_cli_decode_offer() {
264+
let bitcoind = TestBitcoind::new();
265+
let server_a = LdkServerHandle::start(&bitcoind).await;
266+
let server_b = LdkServerHandle::start(&bitcoind).await;
267+
// BOLT12 offers need announced channels for blinded reply paths
268+
setup_funded_channel(&bitcoind, &server_a, &server_b, 100_000).await;
269+
270+
// Create a BOLT12 offer with known parameters
271+
let output = run_cli(&server_a, &["bolt12-receive", "decode offer test"]);
272+
let offer_str = output["offer"].as_str().unwrap();
273+
274+
// Decode it
275+
let decoded = run_cli(&server_a, &["decode-offer", offer_str]);
276+
277+
// Verify fields match
278+
assert_eq!(decoded["offer_id"], output["offer_id"]);
279+
assert_eq!(decoded["description"], "decode offer test");
280+
assert_eq!(decoded["is_expired"], false);
281+
282+
// Chains should include regtest
283+
let chains = decoded["chains"].as_array().unwrap();
284+
assert!(chains.iter().any(|c| c == "regtest"), "Expected regtest in chains: {:?}", chains);
285+
286+
// Paths should be present (BOLT12 offers with blinded paths)
287+
let paths = decoded["paths"].as_array().unwrap();
288+
assert!(!paths.is_empty(), "Expected at least one blinded path");
289+
for path in paths {
290+
assert!(path["num_hops"].as_u64().unwrap() > 0);
291+
assert!(!path["blinding_point"].as_str().unwrap().is_empty());
292+
}
293+
294+
// Features — OfferContext has no known features in LDK, so this should be empty
295+
let features = decoded["features"].as_object().unwrap();
296+
assert!(features.is_empty(), "Expected empty offer features, got: {:?}", features);
297+
298+
// Variable-amount offer should have no amount
299+
assert!(decoded.get("amount").is_none() || decoded["amount"].is_null());
300+
301+
// Test a fixed-amount offer
302+
let output_fixed = run_cli(&server_a, &["bolt12-receive", "fixed amount", "50000sat"]);
303+
let decoded_fixed =
304+
run_cli(&server_a, &["decode-offer", output_fixed["offer"].as_str().unwrap()]);
305+
assert_eq!(decoded_fixed["amount"]["amount"]["bitcoin_amount_msats"], 50_000_000);
306+
307+
// Test that ANSI escape sequences cannot reach the terminal via CLI output.
308+
let desc_with_ansi = "offer\x1b[31m RED \x1b[0m";
309+
let output_ansi = run_cli(&server_a, &["bolt12-receive", desc_with_ansi]);
310+
let raw_decoded = run_cli_raw(
311+
&server_a,
312+
&["decode-offer", output_ansi["offer"].as_str().unwrap()],
313+
);
314+
assert!(
315+
!raw_decoded.contains('\x1b'),
316+
"Raw CLI output must not contain ANSI escape bytes"
317+
);
318+
319+
// Test that Unicode bidi override characters in the description are escaped
320+
let desc_with_bidi = "offer\u{202E}evil";
321+
let output_bidi = run_cli(&server_a, &["bolt12-receive", desc_with_bidi]);
322+
let raw_bidi = run_cli_raw(
323+
&server_a,
324+
&["decode-offer", output_bidi["offer"].as_str().unwrap()],
325+
);
326+
assert!(
327+
!raw_bidi.contains('\u{202E}'),
328+
"Raw CLI output must not contain bidi override characters"
329+
);
330+
assert!(
331+
raw_bidi.contains("\\u202E"),
332+
"Bidi characters should be escaped as \\uXXXX in output"
333+
);
334+
}
335+
168336
#[tokio::test]
169337
async fn test_cli_onchain_send() {
170338
let bitcoind = TestBitcoind::new();

ldk-server-cli/src/main.rs

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
// You may not use this file except in accordance with one or both of these
88
// licenses.
99

10+
use std::fmt::Write;
1011
use std::path::PathBuf;
1112

1213
use clap::{CommandFactory, Parser, Subcommand};
@@ -30,6 +31,7 @@ use ldk_server_client::ldk_server_protos::api::{
3031
Bolt11ReceiveViaJitChannelResponse, Bolt11SendRequest, Bolt11SendResponse,
3132
Bolt12ReceiveRequest, Bolt12ReceiveResponse, Bolt12SendRequest, Bolt12SendResponse,
3233
CloseChannelRequest, CloseChannelResponse, ConnectPeerRequest, ConnectPeerResponse,
34+
DecodeInvoiceRequest, DecodeInvoiceResponse, DecodeOfferRequest, DecodeOfferResponse,
3335
DisconnectPeerRequest, DisconnectPeerResponse, ExportPathfindingScoresRequest,
3436
ForceCloseChannelRequest, ForceCloseChannelResponse, GetBalancesRequest, GetBalancesResponse,
3537
GetNodeInfoRequest, GetNodeInfoResponse, GetPaymentDetailsRequest, GetPaymentDetailsResponse,
@@ -345,6 +347,16 @@ enum Commands {
345347
)]
346348
max_channel_saturation_power_of_half: Option<u32>,
347349
},
350+
#[command(about = "Decode a BOLT11 invoice and display its fields")]
351+
DecodeInvoice {
352+
#[arg(help = "The BOLT11 invoice string to decode")]
353+
invoice: String,
354+
},
355+
#[command(about = "Decode a BOLT12 offer and display its fields")]
356+
DecodeOffer {
357+
#[arg(help = "The BOLT12 offer string to decode")]
358+
offer: String,
359+
},
348360
#[command(about = "Cooperatively close the channel specified by the given channel ID")]
349361
CloseChannel {
350362
#[arg(help = "The local user_channel_id of this channel")]
@@ -864,6 +876,16 @@ async fn main() {
864876
.await,
865877
);
866878
},
879+
Commands::DecodeInvoice { invoice } => {
880+
handle_response_result::<_, DecodeInvoiceResponse>(
881+
client.decode_invoice(DecodeInvoiceRequest { invoice }).await,
882+
);
883+
},
884+
Commands::DecodeOffer { offer } => {
885+
handle_response_result::<_, DecodeOfferResponse>(
886+
client.decode_offer(DecodeOfferRequest { offer }).await,
887+
);
888+
},
867889
Commands::CloseChannel { user_channel_id, counterparty_node_id } => {
868890
handle_response_result::<_, CloseChannelResponse>(
869891
client
@@ -1146,6 +1168,41 @@ where
11461168
}
11471169
}
11481170

1171+
/// Escapes Unicode bidirectional control characters as `\uXXXX` so they are visible
1172+
/// in terminal output rather than silently reordering displayed text.
1173+
/// serde_json already escapes ASCII control characters (U+0000–U+001F), but bidi
1174+
/// overrides (U+200E–U+2069) pass through unescaped.
1175+
fn sanitize_for_terminal(s: String) -> String {
1176+
fn is_bidi_control(c: char) -> bool {
1177+
matches!(
1178+
c,
1179+
'\u{200E}' // LEFT-TO-RIGHT MARK
1180+
| '\u{200F}' // RIGHT-TO-LEFT MARK
1181+
| '\u{202A}' // LEFT-TO-RIGHT EMBEDDING
1182+
| '\u{202B}' // RIGHT-TO-LEFT EMBEDDING
1183+
| '\u{202C}' // POP DIRECTIONAL FORMATTING
1184+
| '\u{202D}' // LEFT-TO-RIGHT OVERRIDE
1185+
| '\u{202E}' // RIGHT-TO-LEFT OVERRIDE
1186+
| '\u{2066}' // LEFT-TO-RIGHT ISOLATE
1187+
| '\u{2067}' // RIGHT-TO-LEFT ISOLATE
1188+
| '\u{2068}' // FIRST STRONG ISOLATE
1189+
| '\u{2069}' // POP DIRECTIONAL ISOLATE
1190+
)
1191+
}
1192+
if !s.chars().any(is_bidi_control) {
1193+
return s;
1194+
}
1195+
let mut out = String::with_capacity(s.len());
1196+
for c in s.chars() {
1197+
if is_bidi_control(c) {
1198+
write!(out, "\\u{:04X}", c as u32).unwrap();
1199+
} else {
1200+
out.push(c);
1201+
}
1202+
}
1203+
out
1204+
}
1205+
11491206
fn handle_response_result<Rs, Js>(response: Result<Rs, LdkServerError>)
11501207
where
11511208
Rs: Into<Js>,
@@ -1155,7 +1212,7 @@ where
11551212
Ok(response) => {
11561213
let json_response: Js = response.into();
11571214
match serde_json::to_string_pretty(&json_response) {
1158-
Ok(json) => println!("{json}"),
1215+
Ok(json) => println!("{}", sanitize_for_terminal(json)),
11591216
Err(e) => {
11601217
eprintln!("Error serializing response ({json_response:?}) to JSON: {e}");
11611218
std::process::exit(1);

ldk-server-client/src/client.rs

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use ldk_server_protos::api::{
1919
Bolt11ReceiveViaJitChannelResponse, Bolt11SendRequest, Bolt11SendResponse,
2020
Bolt12ReceiveRequest, Bolt12ReceiveResponse, Bolt12SendRequest, Bolt12SendResponse,
2121
CloseChannelRequest, CloseChannelResponse, ConnectPeerRequest, ConnectPeerResponse,
22+
DecodeInvoiceRequest, DecodeInvoiceResponse, DecodeOfferRequest, DecodeOfferResponse,
2223
DisconnectPeerRequest, DisconnectPeerResponse, ExportPathfindingScoresRequest,
2324
ExportPathfindingScoresResponse, ForceCloseChannelRequest, ForceCloseChannelResponse,
2425
GetBalancesRequest, GetBalancesResponse, GetNodeInfoRequest, GetNodeInfoResponse,
@@ -37,13 +38,13 @@ use ldk_server_protos::endpoints::{
3738
BOLT11_CLAIM_FOR_HASH_PATH, BOLT11_FAIL_FOR_HASH_PATH, BOLT11_RECEIVE_FOR_HASH_PATH,
3839
BOLT11_RECEIVE_PATH, BOLT11_RECEIVE_VARIABLE_AMOUNT_VIA_JIT_CHANNEL_PATH,
3940
BOLT11_RECEIVE_VIA_JIT_CHANNEL_PATH, BOLT11_SEND_PATH, BOLT12_RECEIVE_PATH, BOLT12_SEND_PATH,
40-
CLOSE_CHANNEL_PATH, CONNECT_PEER_PATH, DISCONNECT_PEER_PATH, EXPORT_PATHFINDING_SCORES_PATH,
41-
FORCE_CLOSE_CHANNEL_PATH, GET_BALANCES_PATH, GET_NODE_INFO_PATH, GET_PAYMENT_DETAILS_PATH,
42-
GRAPH_GET_CHANNEL_PATH, GRAPH_GET_NODE_PATH, GRAPH_LIST_CHANNELS_PATH, GRAPH_LIST_NODES_PATH,
43-
LIST_CHANNELS_PATH, LIST_FORWARDED_PAYMENTS_PATH, LIST_PAYMENTS_PATH, LIST_PEERS_PATH,
44-
ONCHAIN_RECEIVE_PATH, ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH,
45-
SPLICE_OUT_PATH, SPONTANEOUS_SEND_PATH, UNIFIED_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH,
46-
VERIFY_SIGNATURE_PATH,
41+
CLOSE_CHANNEL_PATH, CONNECT_PEER_PATH, DECODE_INVOICE_PATH, DECODE_OFFER_PATH,
42+
DISCONNECT_PEER_PATH, EXPORT_PATHFINDING_SCORES_PATH, FORCE_CLOSE_CHANNEL_PATH,
43+
GET_BALANCES_PATH, GET_NODE_INFO_PATH, GET_PAYMENT_DETAILS_PATH, GRAPH_GET_CHANNEL_PATH,
44+
GRAPH_GET_NODE_PATH, GRAPH_LIST_CHANNELS_PATH, GRAPH_LIST_NODES_PATH, LIST_CHANNELS_PATH,
45+
LIST_FORWARDED_PAYMENTS_PATH, LIST_PAYMENTS_PATH, LIST_PEERS_PATH, ONCHAIN_RECEIVE_PATH,
46+
ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH, SPLICE_OUT_PATH,
47+
SPONTANEOUS_SEND_PATH, UNIFIED_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH, VERIFY_SIGNATURE_PATH,
4748
};
4849
use ldk_server_protos::error::{ErrorCode, ErrorResponse};
4950
use prost::Message;
@@ -364,6 +365,24 @@ impl LdkServerClient {
364365
self.post_request(&request, &url).await
365366
}
366367

368+
/// Decode a BOLT11 invoice and return its parsed fields.
369+
/// For API contract/usage, refer to docs for [`DecodeInvoiceRequest`] and [`DecodeInvoiceResponse`].
370+
pub async fn decode_invoice(
371+
&self, request: DecodeInvoiceRequest,
372+
) -> Result<DecodeInvoiceResponse, LdkServerError> {
373+
let url = format!("https://{}/{DECODE_INVOICE_PATH}", self.base_url);
374+
self.post_request(&request, &url).await
375+
}
376+
377+
/// Decode a BOLT12 offer and return its parsed fields.
378+
/// For API contract/usage, refer to docs for [`DecodeOfferRequest`] and [`DecodeOfferResponse`].
379+
pub async fn decode_offer(
380+
&self, request: DecodeOfferRequest,
381+
) -> Result<DecodeOfferResponse, LdkServerError> {
382+
let url = format!("https://{}/{DECODE_OFFER_PATH}", self.base_url);
383+
self.post_request(&request, &url).await
384+
}
385+
367386
/// Sign a message with the node's secret key.
368387
/// For API contract/usage, refer to docs for [`SignMessageRequest`] and [`SignMessageResponse`].
369388
pub async fn sign_message(

0 commit comments

Comments
 (0)