From 6635d919ee53d6f2faa34c388edbb4f48fe24c7e Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 11 Nov 2026 23:14:06 +0000 Subject: [PATCH 1/2] Preserve full pay --wait events Users need BOLT12 invoices to generate payer proofs later and failure reasons to diagnose unsuccessful payments. Neither can currently be recovered from payment history, making the completion event the opportunity to retain this information. Co-Authored-By: Codex --- docs/api-guide.md | 15 +++++++ docs/getting-started.md | 18 ++++++++ e2e-tests/tests/e2e.rs | 78 ++++++++++++++++++++++------------ ldk-server-cli/src/pay_wait.rs | 29 +++++-------- 4 files changed, 95 insertions(+), 45 deletions(-) diff --git a/docs/api-guide.md b/docs/api-guide.md index 335f6617..f9046c52 100644 --- a/docs/api-guide.md +++ b/docs/api-guide.md @@ -383,6 +383,21 @@ optional invoice fields that the proof discloses. Payment history APIs cannot re inputs required to create a proof if this event is missed. Save these values before processing other events. +With the CLI, `pay --wait` subscribes before sending and prints the full `PaymentSuccessful` +event on success. Save its JSON output to retain the proof inputs: + +```bash +ldk-server-cli pay --wait "$OFFER" > payment-receipt.json +ldk-server-cli bolt12-create-payer-proof \ + "$(jq -r '.payment_id' payment-receipt.json)" \ + "$(jq -r '.payment_preimage' payment-receipt.json)" \ + "$(jq -r '.bolt12_invoice' payment-receipt.json)" \ + --include-offer-description --include-invoice-amount +``` + +Run proof creation against the same node that made the payment. The receipt file is saved by +shell redirection; the CLI does not automatically store it. + The `bolt12_invoice` field is absent for static-invoice payments. These asynchronous payments cannot produce payer proofs. diff --git a/docs/getting-started.md b/docs/getting-started.md index bebb0321..acc44c2b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -131,6 +131,24 @@ ldk-server-cli bolt11-receive --amount 50000sat ldk-server-cli bolt11-receive --amount 50000000msat # same as above ``` +### Waiting for Payments + +Use `pay --wait` to wait for a Lightning payment to complete: + +```bash +ldk-server-cli pay --wait --wait-timeout 30 "$INVOICE_OR_OFFER" +``` + +For both BOLT11 and BOLT12, stdout contains the full shared `PaymentSuccessful` or +`PaymentFailed` event. Success includes the payment preimage and, for standard BOLT12 +payments, the invoice needed to create a payer proof. Failure includes the failure reason +if known. Progress messages go to stderr, so you can redirect stdout to save the result as JSON. + +The exit code is 0 on success, 2 on payment failure, or 3 on timeout. On timeout, stdout +contains the latest payment details if available; the payment may still complete later. +On-chain payments return a transaction ID immediately, without waiting for confirmation. +Without `--wait`, `pay` returns the payment ID or transaction ID immediately. + ### Shell Completions Generate completions for your shell: diff --git a/e2e-tests/tests/e2e.rs b/e2e-tests/tests/e2e.rs index 30c8ad1c..d89dfee6 100644 --- a/e2e-tests/tests/e2e.rs +++ b/e2e-tests/tests/e2e.rs @@ -8,6 +8,7 @@ // licenses. use std::collections::HashMap; +use std::process::{Command, Stdio}; use std::str::FromStr; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -1276,20 +1277,36 @@ async fn test_cli_pay() { let server_b = LdkServerHandle::start(&bitcoind).await; setup_funded_channel(&bitcoind, &server_a, &server_b, 100_000).await; - // Pay a BOLT11 invoice via unified `pay` command - let invoice_resp = server_b - .client() - .bolt11_receive(Bolt11ReceiveRequest { - amount_msat: Some(10_000_000), - description: Some(Bolt11InvoiceDescription { - kind: Some(bolt11_invoice_description::Kind::Direct("test".to_string())), - }), - expiry_secs: 3600, - }) - .await - .unwrap(); - let output = run_cli(&server_a, &["pay", &invoice_resp.invoice]); - assert!(output.get("bolt11_payment_id").is_some()); + // Pay BOLT11 invoices with and without waiting for completion. + for wait in [false, true] { + let invoice_resp = server_b + .client() + .bolt11_receive(Bolt11ReceiveRequest { + amount_msat: Some(10_000_000), + description: Some(Bolt11InvoiceDescription { + kind: Some(bolt11_invoice_description::Kind::Direct("test".to_string())), + }), + expiry_secs: 3600, + }) + .await + .unwrap(); + if wait { + let output = run_cli( + &server_a, + &["pay", "--wait", "--wait-timeout", "30", &invoice_resp.invoice], + ); + assert_eq!(output["payment_id"], output["payment"]["payment_id"]); + assert_eq!(output["payment"]["status"], "SUCCEEDED"); + let preimage = + Vec::::from_hex(output["payment_preimage"].as_str().unwrap()).unwrap(); + let invoice = Bolt11Invoice::from_str(&invoice_resp.invoice).unwrap(); + assert_eq!(sha256::Hash::hash(&preimage).to_byte_array(), invoice.payment_hash().0); + assert!(output["bolt12_invoice"].is_null()); + } else { + let output = run_cli(&server_a, &["pay", &invoice_resp.invoice]); + assert!(output.get("bolt11_payment_id").is_some()); + } + } // Pay a BOLT12 offer via unified `pay` command let offer_resp = server_b @@ -1385,8 +1402,6 @@ async fn test_cli_bolt12_create_payer_proof() { let server_a = LdkServerHandle::start(&bitcoind).await; let server_b = LdkServerHandle::start(&bitcoind).await; - let mut events_a = server_a.client().subscribe_events().await.unwrap(); - setup_funded_channel(&bitcoind, &server_a, &server_b, 100_000).await; let offer_resp = server_b @@ -1400,17 +1415,13 @@ async fn test_cli_bolt12_create_payer_proof() { .await .unwrap(); - let send_output = run_cli(&server_a, &["bolt12-send", &offer_resp.offer]); + let send_output = + run_cli(&server_a, &["pay", "--wait", "--wait-timeout", "30", &offer_resp.offer]); let send_payment_id = send_output["payment_id"].as_str().unwrap(); assert!(!send_payment_id.is_empty()); - - let event_a = wait_for_event(&mut events_a, |e| matches!(e, Event::PaymentSuccessful(_))).await; - let Some(Event::PaymentSuccessful(successful)) = &event_a.event else { - panic!("expected PaymentSuccessful"); - }; - assert_eq!(successful.payment_id, send_payment_id); - let payment_preimage = successful.payment_preimage.as_ref().expect("preimage"); - let invoice = successful.bolt12_invoice.as_ref().expect("bolt12 invoice"); + assert_eq!(send_output["payment"]["payment_id"].as_str().unwrap(), send_payment_id); + let payment_preimage = send_output["payment_preimage"].as_str().expect("preimage"); + let invoice = send_output["bolt12_invoice"].as_str().expect("bolt12 invoice"); let proof_output = run_cli( &server_a, @@ -2204,8 +2215,15 @@ async fn test_hodl_invoice_fail() { ); let invoice = invoice_resp["invoice"].as_str().unwrap(); - // Pay the hodl invoice from A - run_cli(&server_a, &["bolt11-send", invoice]); + // Keep `pay --wait` running while B decides whether to accept the payment. + let payer = Command::new(e2e_tests::cli_binary_path()) + .arg("--config") + .arg(&server_a.config_path) + .args(["pay", "--wait", "--wait-timeout", "30", invoice]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); // Verify PaymentClaimable event on B let event_b = wait_for_event(&mut events_b, |e| matches!(e, Event::PaymentClaimable(_))).await; @@ -2232,6 +2250,12 @@ async fn test_hodl_invoice_fail() { }; assert!(!failed.payment.as_ref().unwrap().payment_id.is_empty()); assert_eq!(failed.reason, Some(PaymentFailureReason::RecipientRejected as i32)); + + // The CLI must preserve the same failure details as the event API. + let output = payer.wait_with_output().unwrap(); + assert_eq!(output.status.code(), Some(2), "{}", String::from_utf8_lossy(&output.stderr)); + let receipt: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(receipt, serde_json::to_value(failed).unwrap()); } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] diff --git a/ldk-server-cli/src/pay_wait.rs b/ldk-server-cli/src/pay_wait.rs index f5032a03..9f88127f 100644 --- a/ldk-server-cli/src/pay_wait.rs +++ b/ldk-server-cli/src/pay_wait.rs @@ -17,6 +17,7 @@ use ldk_server_client::ldk_server_grpc::api::{ GetPaymentDetailsRequest, GetPaymentDetailsResponse, UnifiedSendRequest, UnifiedSendResponse, }; use ldk_server_client::ldk_server_grpc::events::event_envelope::Event; +use ldk_server_client::ldk_server_grpc::events::{PaymentFailed, PaymentSuccessful}; use crate::{handle_error, handle_response_result, print_response, sanitize_for_terminal}; @@ -31,17 +32,13 @@ fn lightning_payment_id(response: &UnifiedSendResponse) -> Option<&str> { } } -fn exit_with_payment(code: i32, message: &str, payment: &GetPaymentDetailsResponse) -> ! { - eprintln!("{}", sanitize_for_terminal(message.to_string())); - print_response(payment); - std::process::exit(code); -} - /// Subscribe before paying, then wait for `PaymentSuccessful` / `PaymentFailed`. /// /// The event stream is live-only, so the subscription is opened before `unified_send`. /// A dropped stream is an error; it is not replayed. `timeout` of `None` waits until /// the payment finishes. On-chain results are printed immediately. +/// Success prints the full event, including the inputs needed for BOLT12 payer proofs. +/// Failure prints the full event, including the failure reason. /// Exit codes: 0 succeeded, 2 failed, 3 timed out. pub(crate) async fn pay_and_wait( client: &LdkServerClient, request: UnifiedSendRequest, timeout: Option, @@ -66,11 +63,9 @@ pub(crate) async fn pay_and_wait( match wait_for_terminal_event(client, &mut events, &payment_id, timeout).await { Ok(payment) => print_response(&payment), Err(WaitError::Failed(payment)) => { - exit_with_payment( - EXIT_PAYMENT_FAILED, - &format!("Payment {payment_id} failed"), - &payment, - ); + eprintln!("{}", sanitize_for_terminal(format!("Payment {payment_id} failed"))); + print_response(&payment); + std::process::exit(EXIT_PAYMENT_FAILED); }, Err(WaitError::TimedOut(payment)) => { let secs = timeout.map(|timeout| timeout.as_secs()).unwrap_or(0); @@ -90,14 +85,14 @@ pub(crate) async fn pay_and_wait( } enum WaitError { - Failed(GetPaymentDetailsResponse), + Failed(PaymentFailed), TimedOut(Option), Transport(LdkServerError), } async fn wait_for_terminal_event( client: &LdkServerClient, events: &mut EventStream, payment_id: &str, timeout: Option, -) -> Result { +) -> Result { let deadline = timeout.map(|timeout| tokio::time::Instant::now() + timeout); match next_matching_event(events, payment_id, deadline).await? { @@ -116,7 +111,7 @@ async fn wait_for_terminal_event( /// `Ok(None)` means the stream ended without a matching terminal event. async fn next_matching_event( events: &mut EventStream, payment_id: &str, deadline: Option, -) -> Result>, WaitError> { +) -> Result>, WaitError> { loop { let next = events.next_message(); let message = match deadline { @@ -136,12 +131,10 @@ async fn next_matching_event( match message { Some(Ok(envelope)) => match envelope.event { Some(Event::PaymentSuccessful(event)) if event.payment_id == payment_id => { - return Ok(Some(Ok(GetPaymentDetailsResponse { payment: event.payment }))); + return Ok(Some(Ok(event))); }, Some(Event::PaymentFailed(event)) if event.payment_id == payment_id => { - return Ok(Some(Err(WaitError::Failed(GetPaymentDetailsResponse { - payment: event.payment, - })))); + return Ok(Some(Err(WaitError::Failed(event)))); }, _ => {}, }, From 155b7b4b0aefb47d12d313a4413663eed1edeb3b Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 11 Nov 2026 23:49:01 +0000 Subject: [PATCH 2/2] Serialize payment failure reasons as enum names Numeric failure reasons make payment failures difficult to diagnose. Co-Authored-By: Codex --- Cargo.lock | 1 + e2e-tests/tests/e2e.rs | 1 + ldk-server-grpc/Cargo.toml | 1 + ldk-server-grpc/build.rs | 4 +++ ldk-server-grpc/src/events.rs | 4 +++ ldk-server-grpc/src/serde_utils.rs | 52 ++++++++++++++++++++++++++++++ 6 files changed, 63 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 2f34d10b..6f2ecc56 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1505,6 +1505,7 @@ dependencies = [ "prost", "prost-build", "serde", + "serde_json", "tokio", "tokio-stream", "tonic", diff --git a/e2e-tests/tests/e2e.rs b/e2e-tests/tests/e2e.rs index d89dfee6..6ae5bedb 100644 --- a/e2e-tests/tests/e2e.rs +++ b/e2e-tests/tests/e2e.rs @@ -2255,6 +2255,7 @@ async fn test_hodl_invoice_fail() { let output = payer.wait_with_output().unwrap(); assert_eq!(output.status.code(), Some(2), "{}", String::from_utf8_lossy(&output.stderr)); let receipt: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(receipt["reason"], "PAYMENT_FAILURE_REASON_RECIPIENT_REJECTED"); assert_eq!(receipt, serde_json::to_value(failed).unwrap()); } diff --git a/ldk-server-grpc/Cargo.toml b/ldk-server-grpc/Cargo.toml index fe3ac3c2..4949de58 100644 --- a/ldk-server-grpc/Cargo.toml +++ b/ldk-server-grpc/Cargo.toml @@ -35,6 +35,7 @@ http-body = { version = "1", default-features = false } tokio = { version = "1", default-features = false, features = ["sync"] } [dev-dependencies] +serde_json = "1.0" tonic = { version = "0.9", default-features = false, features = ["transport", "codegen", "prost"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] } tokio-stream = "0.1" diff --git a/ldk-server-grpc/build.rs b/ldk-server-grpc/build.rs index fe2439d9..f526d26b 100644 --- a/ldk-server-grpc/build.rs +++ b/ldk-server-grpc/build.rs @@ -70,6 +70,10 @@ fn generate_protos() { "types.Payment.status", "#[cfg_attr(feature = \"serde\", serde(serialize_with = \"crate::serde_utils::serialize_payment_status\"))]", ) + .field_attribute( + "events.PaymentFailed.reason", + "#[cfg_attr(feature = \"serde\", serde(serialize_with = \"crate::serde_utils::serialize_payment_failure_reason\"))]", + ) .field_attribute( "types.ClaimableAwaitingConfirmations.source", "#[cfg_attr(feature = \"serde\", serde(serialize_with = \"crate::serde_utils::serialize_balance_source\"))]", diff --git a/ldk-server-grpc/src/events.rs b/ldk-server-grpc/src/events.rs index 7a3822e2..8c6662e5 100644 --- a/ldk-server-grpc/src/events.rs +++ b/ldk-server-grpc/src/events.rs @@ -238,6 +238,10 @@ pub struct PaymentFailed { /// recover it as LDK Node does not currently persist the failure reason in /// `PaymentDetails`. #[prost(enumeration = "PaymentFailureReason", optional, tag = "3")] + #[cfg_attr( + feature = "serde", + serde(serialize_with = "crate::serde_utils::serialize_payment_failure_reason") + )] pub reason: ::core::option::Option, } /// PaymentClaimable indicates a payment has arrived and is waiting to be manually claimed or failed. diff --git a/ldk-server-grpc/src/serde_utils.rs b/ldk-server-grpc/src/serde_utils.rs index 125649d2..638dff8b 100644 --- a/ldk-server-grpc/src/serde_utils.rs +++ b/ldk-server-grpc/src/serde_utils.rs @@ -34,6 +34,27 @@ macro_rules! stringify_enum_serializer { }; } +/// Generates a serde serializer for an optional proto enum field (or null). +macro_rules! stringify_opt_enum_serializer { + ($fn_name:ident, $enum_type:ty) => { + pub fn $fn_name(value: &Option, serializer: S) -> Result + where + S: serde::Serializer, + { + match value { + Some(value) => { + let name = match <$enum_type>::from_i32(*value) { + Some(v) => v.as_str_name(), + None => "UNKNOWN", + }; + serializer.serialize_some(name) + }, + None => serializer.serialize_none(), + } + } + }; +} + stringify_enum_serializer!( serialize_forwarded_payment_tracking_mode, crate::types::ForwardedPaymentTrackingMode @@ -43,6 +64,10 @@ stringify_enum_serializer!(serialize_payment_status, crate::types::PaymentStatus stringify_enum_serializer!(serialize_balance_source, crate::types::BalanceSource); stringify_enum_serializer!(serialize_network, crate::types::Network); stringify_enum_serializer!(serialize_channel_direction, crate::types::ChannelDirection); +stringify_opt_enum_serializer!( + serialize_payment_failure_reason, + crate::events::PaymentFailureReason +); /// Serializes `Option` as a hex string (or null). pub fn serialize_opt_bytes_hex( @@ -62,3 +87,30 @@ where None => serializer.serialize_none(), } } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::events::{PaymentFailed, PaymentFailureReason}; + + #[test] + fn payment_failure_reason_json() { + for (reason, expected) in [ + (None, json!(null)), + ( + Some(PaymentFailureReason::Unspecified as i32), + json!("PAYMENT_FAILURE_REASON_UNSPECIFIED"), + ), + ( + Some(PaymentFailureReason::RecipientRejected as i32), + json!("PAYMENT_FAILURE_REASON_RECIPIENT_REJECTED"), + ), + (Some(-1), json!("UNKNOWN")), + (Some(i32::MAX), json!("UNKNOWN")), + ] { + let event = PaymentFailed { reason, ..Default::default() }; + assert_eq!(serde_json::to_value(event).unwrap()["reason"], expected); + } + } +}