@@ -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]
152246async 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]
169337async fn test_cli_onchain_send ( ) {
170338 let bitcoind = TestBitcoind :: new ( ) ;
0 commit comments