Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions docs/api-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
18 changes: 18 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
79 changes: 52 additions & 27 deletions e2e-tests/tests/e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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::<u8>::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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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)]
Expand Down
29 changes: 11 additions & 18 deletions ldk-server-cli/src/pay_wait.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand All @@ -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<Duration>,
Expand All @@ -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);
Expand All @@ -90,14 +85,14 @@ pub(crate) async fn pay_and_wait(
}

enum WaitError {
Failed(GetPaymentDetailsResponse),
Failed(PaymentFailed),
TimedOut(Option<GetPaymentDetailsResponse>),
Transport(LdkServerError),
}

async fn wait_for_terminal_event(
client: &LdkServerClient, events: &mut EventStream, payment_id: &str, timeout: Option<Duration>,
) -> Result<GetPaymentDetailsResponse, WaitError> {
) -> Result<PaymentSuccessful, WaitError> {
let deadline = timeout.map(|timeout| tokio::time::Instant::now() + timeout);

match next_matching_event(events, payment_id, deadline).await? {
Expand All @@ -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<tokio::time::Instant>,
) -> Result<Option<Result<GetPaymentDetailsResponse, WaitError>>, WaitError> {
) -> Result<Option<Result<PaymentSuccessful, WaitError>>, WaitError> {
loop {
let next = events.next_message();
let message = match deadline {
Expand All @@ -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))));
},
_ => {},
},
Expand Down
1 change: 1 addition & 0 deletions ldk-server-grpc/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions ldk-server-grpc/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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\"))]",
Expand Down
4 changes: 4 additions & 0 deletions ldk-server-grpc/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<i32>,
}
/// PaymentClaimable indicates a payment has arrived and is waiting to be manually claimed or failed.
Expand Down
52 changes: 52 additions & 0 deletions ldk-server-grpc/src/serde_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<S>(value: &Option<i32>, serializer: S) -> Result<S::Ok, S::Error>
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
Expand All @@ -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<prost::bytes::Bytes>` as a hex string (or null).
pub fn serialize_opt_bytes_hex<S>(
Expand All @@ -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);
}
}
}
Loading