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/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..6ae5bedb 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,13 @@ 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["reason"], "PAYMENT_FAILURE_REASON_RECIPIENT_REJECTED"); + 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)))); }, _ => {}, }, 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); + } + } +}