Skip to content

Commit bd95e18

Browse files
authored
Merge pull request #293 from vincenzopalazzo/feat/cli-pay-wait
[RFC] Add pay --wait for Lightning payment terminal state
2 parents dbe22c5 + 74c78dd commit bd95e18

3 files changed

Lines changed: 221 additions & 26 deletions

File tree

‎ldk-server-cli/Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ ldk-server-client = { path = "../ldk-server-client", features = ["serde"] }
1717
clap = { version = "4.0.5", default-features = false, features = ["derive", "std", "error-context", "suggestions", "help"] }
1818
clap_complete = { version = "4.0", default-features = false }
1919
hex-conservative = { version = "0.2", default-features = false, features = ["std"] }
20-
tokio = { version = "1.38.0", default-features = false, features = ["rt-multi-thread", "macros"] }
20+
tokio = { version = "1.38.0", default-features = false, features = ["rt-multi-thread", "macros", "time"] }
2121
serde = "1.0"
2222
serde_json = "1.0"
2323
toml = { version = "0.8", default-features = false, features = ["parse"] }

‎ldk-server-cli/src/main.rs‎

Lines changed: 49 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ use types::{
6767
CliPaginatedResponse, Preimage,
6868
};
6969

70+
mod pay_wait;
7071
mod types;
7172

7273
const FULL_VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), " (", env!("GIT_HASH"), ")");
@@ -439,6 +440,24 @@ enum Commands {
439440
help = "Maximum share of a channel's total capacity to send over a channel, as a power of 1/2 (default: 2)"
440441
)]
441442
max_channel_saturation_power_of_half: Option<u32>,
443+
/// Wait for a Lightning payment to reach a terminal state.
444+
///
445+
/// With no `--wait-timeout`, waits until the payment succeeds or fails.
446+
/// On-chain payments already return a transaction id and are not waited on.
447+
#[arg(
448+
long,
449+
help = "Wait until a Lightning payment succeeds or fails. On-chain payments are not waited on"
450+
)]
451+
wait: bool,
452+
/// Optional timeout in seconds for `--wait`. Omit to wait indefinitely.
453+
#[arg(
454+
long,
455+
value_name = "SECS",
456+
requires = "wait",
457+
value_parser = clap::value_parser!(u64).range(1..),
458+
help = "Seconds to wait when --wait is set. Omit to wait until the payment finishes (minimum: 1)"
459+
)]
460+
wait_timeout: Option<u64>,
442461
},
443462
#[command(about = "Decode a BOLT11 invoice and display its fields")]
444463
DecodeInvoice {
@@ -1083,6 +1102,8 @@ async fn main() {
10831102
max_total_cltv_expiry_delta,
10841103
max_path_count,
10851104
max_channel_saturation_power_of_half,
1105+
wait,
1106+
wait_timeout,
10861107
} => {
10871108
let amount_msat = amount.map(|a| a.to_msat());
10881109
let max_total_routing_fee_msat = max_total_routing_fee.map(|a| a.to_msat());
@@ -1094,15 +1115,16 @@ async fn main() {
10941115
max_channel_saturation_power_of_half: max_channel_saturation_power_of_half
10951116
.unwrap_or(DEFAULT_MAX_CHANNEL_SATURATION_POWER_OF_HALF),
10961117
};
1097-
handle_response_result::<_, UnifiedSendResponse>(
1098-
client
1099-
.unified_send(UnifiedSendRequest {
1100-
uri,
1101-
amount_msat,
1102-
route_parameters: Some(route_parameters),
1103-
})
1104-
.await,
1105-
);
1118+
let request =
1119+
UnifiedSendRequest { uri, amount_msat, route_parameters: Some(route_parameters) };
1120+
if wait {
1121+
let timeout = wait_timeout.map(std::time::Duration::from_secs);
1122+
pay_wait::pay_and_wait(&client, request, timeout).await;
1123+
} else {
1124+
handle_response_result::<_, UnifiedSendResponse>(
1125+
client.unified_send(request).await,
1126+
);
1127+
}
11061128
},
11071129
Commands::DecodeInvoice { invoice } => {
11081130
handle_response_result::<_, DecodeInvoiceResponse>(
@@ -1467,7 +1489,7 @@ where
14671489
/// in terminal output rather than silently reordering displayed text.
14681490
/// serde_json already escapes ASCII control characters (U+0000–U+001F), but bidi
14691491
/// overrides (U+200E–U+2069) pass through unescaped.
1470-
fn sanitize_for_terminal(s: String) -> String {
1492+
pub(crate) fn sanitize_for_terminal(s: String) -> String {
14711493
fn is_bidi_control(c: char) -> bool {
14721494
matches!(
14731495
c,
@@ -1498,21 +1520,25 @@ fn sanitize_for_terminal(s: String) -> String {
14981520
out
14991521
}
15001522

1501-
fn handle_response_result<Rs, Js>(response: Result<Rs, LdkServerError>)
1523+
pub(crate) fn print_response<T: Serialize + std::fmt::Debug>(value: &T) {
1524+
match serde_json::to_string_pretty(value) {
1525+
Ok(json) => println!("{}", sanitize_for_terminal(json)),
1526+
Err(e) => {
1527+
eprintln!("Error serializing response ({value:?}) to JSON: {e}");
1528+
std::process::exit(1);
1529+
},
1530+
}
1531+
}
1532+
1533+
pub(crate) fn handle_response_result<Rs, Js>(response: Result<Rs, LdkServerError>)
15021534
where
15031535
Rs: Into<Js>,
15041536
Js: Serialize + std::fmt::Debug,
15051537
{
15061538
match response {
15071539
Ok(response) => {
15081540
let json_response: Js = response.into();
1509-
match serde_json::to_string_pretty(&json_response) {
1510-
Ok(json) => println!("{}", sanitize_for_terminal(json)),
1511-
Err(e) => {
1512-
eprintln!("Error serializing response ({json_response:?}) to JSON: {e}");
1513-
std::process::exit(1);
1514-
},
1515-
}
1541+
print_response(&json_response);
15161542
},
15171543
Err(e) => {
15181544
handle_error(e);
@@ -1530,12 +1556,10 @@ fn parse_bolt11_invoice_description(
15301556
(None, Some(hash)) => Some(Bolt11InvoiceDescription {
15311557
kind: Some(bolt11_invoice_description::Kind::Hash(hash)),
15321558
}),
1533-
(Some(_), Some(_)) => {
1534-
handle_error(LdkServerError::new(
1535-
InvalidRequestError,
1536-
"Only one of description or description_hash can be set.".to_string(),
1537-
));
1538-
},
1559+
(Some(_), Some(_)) => handle_error(LdkServerError::new(
1560+
InvalidRequestError,
1561+
"Only one of description or description_hash can be set.".to_string(),
1562+
)),
15391563
(None, None) => None,
15401564
}
15411565
}
@@ -1558,7 +1582,7 @@ fn handle_error_msg(msg: String) -> ! {
15581582
std::process::exit(1);
15591583
}
15601584

1561-
fn handle_error(e: LdkServerError) -> ! {
1585+
pub(crate) fn handle_error(e: LdkServerError) -> ! {
15621586
let error_type = match e.error_code {
15631587
InvalidRequestError => "Invalid Request",
15641588
AuthError => "Authentication Error",

‎ldk-server-cli/src/pay_wait.rs‎

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
// This file is Copyright its original authors, visible in version control
2+
// history.
3+
//
4+
// This file is licensed under the Apache License, Version 2.0 <LICENSE-APACHE
5+
// or http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
6+
// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your option.
7+
// You may not use this file except in accordance with one or both of these
8+
// licenses.
9+
10+
use std::time::Duration;
11+
12+
use ldk_server_client::client::{EventStream, LdkServerClient};
13+
use ldk_server_client::error::LdkServerError;
14+
use ldk_server_client::error::LdkServerErrorCode::InternalError;
15+
use ldk_server_client::ldk_server_grpc::api::unified_send_response;
16+
use ldk_server_client::ldk_server_grpc::api::{
17+
GetPaymentDetailsRequest, GetPaymentDetailsResponse, UnifiedSendRequest, UnifiedSendResponse,
18+
};
19+
use ldk_server_client::ldk_server_grpc::events::event_envelope::Event;
20+
21+
use crate::{handle_error, handle_response_result, print_response, sanitize_for_terminal};
22+
23+
const EXIT_PAYMENT_FAILED: i32 = 2;
24+
const EXIT_PAYMENT_TIMEOUT: i32 = 3;
25+
26+
fn lightning_payment_id(response: &UnifiedSendResponse) -> Option<&str> {
27+
match response.payment_result.as_ref() {
28+
Some(unified_send_response::PaymentResult::Bolt11PaymentId(id))
29+
| Some(unified_send_response::PaymentResult::Bolt12PaymentId(id)) => Some(id.as_str()),
30+
_ => None,
31+
}
32+
}
33+
34+
fn exit_with_payment(code: i32, message: &str, payment: &GetPaymentDetailsResponse) -> ! {
35+
eprintln!("{}", sanitize_for_terminal(message.to_string()));
36+
print_response(payment);
37+
std::process::exit(code);
38+
}
39+
40+
/// Subscribe before paying, then wait for `PaymentSuccessful` / `PaymentFailed`.
41+
///
42+
/// The event stream is live-only, so the subscription is opened before `unified_send`.
43+
/// A dropped stream is an error; it is not replayed. `timeout` of `None` waits until
44+
/// the payment finishes. On-chain results are printed immediately.
45+
/// Exit codes: 0 succeeded, 2 failed, 3 timed out.
46+
pub(crate) async fn pay_and_wait(
47+
client: &LdkServerClient, request: UnifiedSendRequest, timeout: Option<Duration>,
48+
) {
49+
let mut events = client.subscribe_events().await.map_err(handle_error).unwrap();
50+
51+
let response = client.unified_send(request).await.map_err(handle_error).unwrap();
52+
53+
let Some(payment_id) = lightning_payment_id(&response).map(str::to_string) else {
54+
handle_response_result::<_, UnifiedSendResponse>(Ok(response));
55+
return;
56+
};
57+
58+
match timeout {
59+
Some(timeout) => eprintln!(
60+
"Payment initiated: {payment_id} (waiting up to {}s for terminal state)",
61+
timeout.as_secs()
62+
),
63+
None => eprintln!("Payment initiated: {payment_id} (waiting for terminal state)"),
64+
}
65+
66+
match wait_for_terminal_event(client, &mut events, &payment_id, timeout).await {
67+
Ok(payment) => print_response(&payment),
68+
Err(WaitError::Failed(payment)) => {
69+
exit_with_payment(
70+
EXIT_PAYMENT_FAILED,
71+
&format!("Payment {payment_id} failed"),
72+
&payment,
73+
);
74+
},
75+
Err(WaitError::TimedOut(payment)) => {
76+
let secs = timeout.map(|timeout| timeout.as_secs()).unwrap_or(0);
77+
eprintln!(
78+
"{}",
79+
sanitize_for_terminal(format!(
80+
"Timed out after {secs}s waiting for payment {payment_id}"
81+
))
82+
);
83+
if let Some(payment) = payment {
84+
print_response(&payment);
85+
}
86+
std::process::exit(EXIT_PAYMENT_TIMEOUT);
87+
},
88+
Err(WaitError::Transport(e)) => handle_error(e),
89+
}
90+
}
91+
92+
enum WaitError {
93+
Failed(GetPaymentDetailsResponse),
94+
TimedOut(Option<GetPaymentDetailsResponse>),
95+
Transport(LdkServerError),
96+
}
97+
98+
async fn wait_for_terminal_event(
99+
client: &LdkServerClient, events: &mut EventStream, payment_id: &str, timeout: Option<Duration>,
100+
) -> Result<GetPaymentDetailsResponse, WaitError> {
101+
let deadline = timeout.map(|timeout| tokio::time::Instant::now() + timeout);
102+
103+
match next_matching_event(events, payment_id, deadline).await? {
104+
Some(Ok(payment)) => Ok(payment),
105+
Some(Err(WaitError::TimedOut(_))) => {
106+
Err(WaitError::TimedOut(fetch_latest(client, payment_id).await))
107+
},
108+
Some(Err(error)) => Err(error),
109+
None => Err(WaitError::Transport(LdkServerError::new(
110+
InternalError,
111+
format!("event stream ended before payment {payment_id} reached a terminal state"),
112+
))),
113+
}
114+
}
115+
116+
/// `Ok(None)` means the stream ended without a matching terminal event.
117+
async fn next_matching_event(
118+
events: &mut EventStream, payment_id: &str, deadline: Option<tokio::time::Instant>,
119+
) -> Result<Option<Result<GetPaymentDetailsResponse, WaitError>>, WaitError> {
120+
loop {
121+
let next = events.next_message();
122+
let message = match deadline {
123+
Some(deadline) => {
124+
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
125+
if remaining.is_zero() {
126+
return Ok(Some(Err(WaitError::TimedOut(None))));
127+
}
128+
match tokio::time::timeout(remaining, next).await {
129+
Ok(message) => message,
130+
Err(_) => return Ok(Some(Err(WaitError::TimedOut(None)))),
131+
}
132+
},
133+
None => next.await,
134+
};
135+
136+
match message {
137+
Some(Ok(envelope)) => match envelope.event {
138+
Some(Event::PaymentSuccessful(event)) if event.payment_id == payment_id => {
139+
return Ok(Some(Ok(GetPaymentDetailsResponse { payment: event.payment })));
140+
},
141+
Some(Event::PaymentFailed(event)) if event.payment_id == payment_id => {
142+
return Ok(Some(Err(WaitError::Failed(GetPaymentDetailsResponse {
143+
payment: event.payment,
144+
}))));
145+
},
146+
_ => {},
147+
},
148+
Some(Err(e)) => return Err(WaitError::Transport(e)),
149+
None => return Ok(None),
150+
}
151+
}
152+
}
153+
154+
async fn fetch_latest(
155+
client: &LdkServerClient, payment_id: &str,
156+
) -> Option<GetPaymentDetailsResponse> {
157+
match client
158+
.get_payment_details(GetPaymentDetailsRequest { payment_id: payment_id.to_string() })
159+
.await
160+
{
161+
Ok(details) if details.payment.is_some() => Some(details),
162+
Ok(_) => {
163+
eprintln!("Payment {payment_id} was not found. It may not be persisted yet.");
164+
None
165+
},
166+
Err(e) => {
167+
eprintln!("Warning: failed to fetch payment {payment_id}: {}", e.message);
168+
None
169+
},
170+
}
171+
}

0 commit comments

Comments
 (0)