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)))); }, _ => {}, },