Skip to content

Commit bbdd922

Browse files
benthecarmanclaude
andcommitted
Add unified payment send support
Exposes ldk-node's unified payment send API, supporting BIP 21 URIs, BIP 353 Human-Readable Names, BOLT11 invoices, and BOLT12 offers. Adds a `pay` CLI command as the primary way to send payments. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 733ffeb commit bbdd922

10 files changed

Lines changed: 250 additions & 6 deletions

File tree

e2e-tests/tests/e2e.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,43 @@ async fn test_cli_bolt11_send() {
311311
);
312312
}
313313

314+
#[tokio::test]
315+
async fn test_cli_pay() {
316+
let bitcoind = TestBitcoind::new();
317+
let server_a = LdkServerHandle::start(&bitcoind).await;
318+
let server_b = LdkServerHandle::start(&bitcoind).await;
319+
setup_funded_channel(&bitcoind, &server_a, &server_b, 100_000).await;
320+
321+
// Pay a BOLT11 invoice via unified `pay` command
322+
let invoice_resp = server_b
323+
.client()
324+
.bolt11_receive(Bolt11ReceiveRequest {
325+
amount_msat: Some(10_000_000),
326+
description: Some(Bolt11InvoiceDescription {
327+
kind: Some(bolt11_invoice_description::Kind::Direct("test".to_string())),
328+
}),
329+
expiry_secs: 3600,
330+
})
331+
.await
332+
.unwrap();
333+
let output = run_cli(&server_a, &["pay", &invoice_resp.invoice]);
334+
assert!(output.get("bolt11_payment_id").is_some());
335+
336+
// Pay a BOLT12 offer via unified `pay` command
337+
let offer_resp = server_b
338+
.client()
339+
.bolt12_receive(Bolt12ReceiveRequest {
340+
description: "test offer".to_string(),
341+
amount_msat: None,
342+
expiry_secs: None,
343+
quantity: None,
344+
})
345+
.await
346+
.unwrap();
347+
let output = run_cli(&server_a, &["pay", &offer_resp.offer, "10000sat"]);
348+
assert!(output.get("bolt12_payment_id").is_some());
349+
}
350+
314351
#[tokio::test]
315352
async fn test_cli_bolt12_send() {
316353
let bitcoind = TestBitcoind::new();

ldk-server-cli/src/main.rs

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ use ldk_server_client::ldk_server_protos::api::{
3737
OnchainReceiveRequest, OnchainReceiveResponse, OnchainSendRequest, OnchainSendResponse,
3838
OpenChannelRequest, OpenChannelResponse, SignMessageRequest, SignMessageResponse,
3939
SpliceInRequest, SpliceInResponse, SpliceOutRequest, SpliceOutResponse, SpontaneousSendRequest,
40-
SpontaneousSendResponse, UpdateChannelConfigRequest, UpdateChannelConfigResponse,
41-
VerifySignatureRequest, VerifySignatureResponse,
40+
SpontaneousSendResponse, UnifiedSendRequest, UnifiedSendResponse, UpdateChannelConfigRequest,
41+
UpdateChannelConfigResponse, VerifySignatureRequest, VerifySignatureResponse,
4242
};
4343
use ldk_server_client::ldk_server_protos::types::{
4444
bolt11_invoice_description, Bolt11InvoiceDescription, ChannelConfig, PageToken,
@@ -275,6 +275,32 @@ enum Commands {
275275
)]
276276
max_channel_saturation_power_of_half: Option<u32>,
277277
},
278+
#[command(
279+
about = "Pay a BIP 21 URI, BIP 353 Human-Readable Name, BOLT11 invoice, or BOLT12 offer"
280+
)]
281+
Pay {
282+
#[arg(help = "A BIP 21 URI, BIP 353 Human-Readable Name, BOLT11 invoice, or BOLT12 offer")]
283+
uri: String,
284+
#[arg(help = "Amount to send, e.g. 50sat or 50000msat. Required for variable-amount URIs")]
285+
amount: Option<Amount>,
286+
#[arg(
287+
long,
288+
help = "Maximum total routing fee, e.g. 50sat or 50000msat. Defaults to 1% of payment + 50 sats"
289+
)]
290+
max_total_routing_fee: Option<Amount>,
291+
#[arg(long, help = "Maximum total CLTV delta we accept for the route (default: 1008)")]
292+
max_total_cltv_expiry_delta: Option<u32>,
293+
#[arg(
294+
long,
295+
help = "Maximum number of paths that may be used by MPP payments (default: 10)"
296+
)]
297+
max_path_count: Option<u32>,
298+
#[arg(
299+
long,
300+
help = "Maximum share of a channel's total capacity to send over a channel, as a power of 1/2 (default: 2)"
301+
)]
302+
max_channel_saturation_power_of_half: Option<u32>,
303+
},
278304
#[command(about = "Cooperatively close the channel specified by the given channel ID")]
279305
CloseChannel {
280306
#[arg(help = "The local user_channel_id of this channel")]
@@ -750,6 +776,34 @@ async fn main() {
750776
.await,
751777
);
752778
},
779+
Commands::Pay {
780+
uri,
781+
amount,
782+
max_total_routing_fee,
783+
max_total_cltv_expiry_delta,
784+
max_path_count,
785+
max_channel_saturation_power_of_half,
786+
} => {
787+
let amount_msat = amount.map(|a| a.to_msat());
788+
let max_total_routing_fee_msat = max_total_routing_fee.map(|a| a.to_msat());
789+
let route_parameters = RouteParametersConfig {
790+
max_total_routing_fee_msat,
791+
max_total_cltv_expiry_delta: max_total_cltv_expiry_delta
792+
.unwrap_or(DEFAULT_MAX_TOTAL_CLTV_EXPIRY_DELTA),
793+
max_path_count: max_path_count.unwrap_or(DEFAULT_MAX_PATH_COUNT),
794+
max_channel_saturation_power_of_half: max_channel_saturation_power_of_half
795+
.unwrap_or(DEFAULT_MAX_CHANNEL_SATURATION_POWER_OF_HALF),
796+
};
797+
handle_response_result::<_, UnifiedSendResponse>(
798+
client
799+
.unified_send(UnifiedSendRequest {
800+
uri,
801+
amount_msat,
802+
route_parameters: Some(route_parameters),
803+
})
804+
.await,
805+
);
806+
},
753807
Commands::CloseChannel { user_channel_id, counterparty_node_id } => {
754808
handle_response_result::<_, CloseChannelResponse>(
755809
client

ldk-server-client/src/client.rs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ use ldk_server_protos::api::{
2828
OnchainReceiveRequest, OnchainReceiveResponse, OnchainSendRequest, OnchainSendResponse,
2929
OpenChannelRequest, OpenChannelResponse, SignMessageRequest, SignMessageResponse,
3030
SpliceInRequest, SpliceInResponse, SpliceOutRequest, SpliceOutResponse, SpontaneousSendRequest,
31-
SpontaneousSendResponse, UpdateChannelConfigRequest, UpdateChannelConfigResponse,
32-
VerifySignatureRequest, VerifySignatureResponse,
31+
SpontaneousSendResponse, UnifiedSendRequest, UnifiedSendResponse, UpdateChannelConfigRequest,
32+
UpdateChannelConfigResponse, VerifySignatureRequest, VerifySignatureResponse,
3333
};
3434
use ldk_server_protos::endpoints::{
3535
BOLT11_CLAIM_FOR_HASH_PATH, BOLT11_FAIL_FOR_HASH_PATH, BOLT11_RECEIVE_FOR_HASH_PATH,
@@ -39,7 +39,8 @@ use ldk_server_protos::endpoints::{
3939
GRAPH_GET_CHANNEL_PATH, GRAPH_GET_NODE_PATH, GRAPH_LIST_CHANNELS_PATH, GRAPH_LIST_NODES_PATH,
4040
LIST_CHANNELS_PATH, LIST_FORWARDED_PAYMENTS_PATH, LIST_PAYMENTS_PATH, LIST_PEERS_PATH,
4141
ONCHAIN_RECEIVE_PATH, ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH,
42-
SPLICE_OUT_PATH, SPONTANEOUS_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH, VERIFY_SIGNATURE_PATH,
42+
SPLICE_OUT_PATH, SPONTANEOUS_SEND_PATH, UNIFIED_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH,
43+
VERIFY_SIGNATURE_PATH,
4344
};
4445
use ldk_server_protos::error::{ErrorCode, ErrorResponse};
4546
use prost::Message;
@@ -327,6 +328,15 @@ impl LdkServerClient {
327328
self.post_request(&request, &url).await
328329
}
329330

331+
/// Send a payment given a BIP 21 URI or BIP 353 Human-Readable Name.
332+
/// For API contract/usage, refer to docs for [`UnifiedSendRequest`] and [`UnifiedSendResponse`].
333+
pub async fn unified_send(
334+
&self, request: UnifiedSendRequest,
335+
) -> Result<UnifiedSendResponse, LdkServerError> {
336+
let url = format!("https://{}/{UNIFIED_SEND_PATH}", self.base_url);
337+
self.post_request(&request, &url).await
338+
}
339+
330340
/// Sign a message with the node's secret key.
331341
/// For API contract/usage, refer to docs for [`SignMessageRequest`] and [`SignMessageResponse`].
332342
pub async fn sign_message(

ldk-server-protos/build.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ fn generate_protos() {
6969
"types.ClaimableAwaitingConfirmations.source",
7070
"#[cfg_attr(feature = \"serde\", serde(serialize_with = \"crate::serde_utils::serialize_balance_source\"))]",
7171
)
72+
.field_attribute(
73+
"api.UnifiedSendResponse.payment_result",
74+
"#[cfg_attr(feature = \"serde\", serde(flatten))]",
75+
)
7276
.compile_protos(
7377
&[
7478
"src/proto/api.proto",

ldk-server-protos/src/api.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -922,6 +922,56 @@ pub struct GraphListNodesResponse {
922922
#[prost(string, repeated, tag = "1")]
923923
pub node_ids: ::prost::alloc::vec::Vec<::prost::alloc::string::String>,
924924
}
925+
/// Send a payment given a BIP 21 URI or BIP 353 Human-Readable Name.
926+
///
927+
/// This method parses the provided URI string and attempts to send the payment. If the URI
928+
/// has an offer and/or invoice, it will try to pay the offer first followed by the invoice.
929+
/// If they both fail, the on-chain payment will be paid.
930+
/// See more: <https://docs.rs/ldk-node/latest/ldk_node/payment/struct.UnifiedPayment.html#method.send>
931+
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
932+
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
933+
#[allow(clippy::derive_partial_eq_without_eq)]
934+
#[derive(Clone, PartialEq, ::prost::Message)]
935+
pub struct UnifiedSendRequest {
936+
/// A BIP 21 URI or BIP 353 Human-Readable Name to pay.
937+
#[prost(string, tag = "1")]
938+
pub uri: ::prost::alloc::string::String,
939+
/// The amount in millisatoshis to send. Required for "zero-amount" or variable-amount URIs.
940+
#[prost(uint64, optional, tag = "2")]
941+
pub amount_msat: ::core::option::Option<u64>,
942+
/// Configuration options for payment routing and pathfinding.
943+
#[prost(message, optional, tag = "3")]
944+
pub route_parameters: ::core::option::Option<super::types::RouteParametersConfig>,
945+
}
946+
/// The response `content` for the `UnifiedSend` API, when HttpStatusCode is OK (200).
947+
/// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`.
948+
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
949+
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
950+
#[allow(clippy::derive_partial_eq_without_eq)]
951+
#[derive(Clone, PartialEq, ::prost::Message)]
952+
pub struct UnifiedSendResponse {
953+
#[prost(oneof = "unified_send_response::PaymentResult", tags = "1, 2, 3")]
954+
#[cfg_attr(feature = "serde", serde(flatten))]
955+
pub payment_result: ::core::option::Option<unified_send_response::PaymentResult>,
956+
}
957+
/// Nested message and enum types in `UnifiedSendResponse`.
958+
pub mod unified_send_response {
959+
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
960+
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
961+
#[allow(clippy::derive_partial_eq_without_eq)]
962+
#[derive(Clone, PartialEq, ::prost::Oneof)]
963+
pub enum PaymentResult {
964+
/// An on-chain payment was made. Contains the transaction ID.
965+
#[prost(string, tag = "1")]
966+
Txid(::prost::alloc::string::String),
967+
/// A BOLT11 payment was made. Contains the payment ID in hex-encoded form.
968+
#[prost(string, tag = "2")]
969+
Bolt11PaymentId(::prost::alloc::string::String),
970+
/// A BOLT12 payment was made. Contains the payment ID in hex-encoded form.
971+
#[prost(string, tag = "3")]
972+
Bolt12PaymentId(::prost::alloc::string::String),
973+
}
974+
}
925975
/// Returns information on a node with the given ID from the network graph.
926976
/// See more: <https://docs.rs/ldk-node/latest/ldk_node/graph/struct.NetworkGraph.html#method.node>
927977
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]

ldk-server-protos/src/endpoints.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ pub const SPONTANEOUS_SEND_PATH: &str = "SpontaneousSend";
3535
pub const SIGN_MESSAGE_PATH: &str = "SignMessage";
3636
pub const VERIFY_SIGNATURE_PATH: &str = "VerifySignature";
3737
pub const EXPORT_PATHFINDING_SCORES_PATH: &str = "ExportPathfindingScores";
38+
pub const UNIFIED_SEND_PATH: &str = "UnifiedSend";
3839
pub const GRAPH_LIST_CHANNELS_PATH: &str = "GraphListChannels";
3940
pub const GRAPH_GET_CHANNEL_PATH: &str = "GraphGetChannel";
4041
pub const GRAPH_LIST_NODES_PATH: &str = "GraphListNodes";

ldk-server-protos/src/proto/api.proto

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -712,6 +712,41 @@ message GraphListNodesResponse {
712712
repeated string node_ids = 1;
713713
}
714714

715+
// Send a payment given a BIP 21 URI or BIP 353 Human-Readable Name.
716+
//
717+
// This method parses the provided URI string and attempts to send the payment. If the URI
718+
// has an offer and/or invoice, it will try to pay the offer first followed by the invoice.
719+
// If they both fail, the on-chain payment will be paid.
720+
// See more: https://docs.rs/ldk-node/latest/ldk_node/payment/struct.UnifiedPayment.html#method.send
721+
message UnifiedSendRequest {
722+
723+
// A BIP 21 URI or BIP 353 Human-Readable Name to pay.
724+
string uri = 1;
725+
726+
// The amount in millisatoshis to send. Required for "zero-amount" or variable-amount URIs.
727+
optional uint64 amount_msat = 2;
728+
729+
// Configuration options for payment routing and pathfinding.
730+
optional types.RouteParametersConfig route_parameters = 3;
731+
}
732+
733+
// The response `content` for the `UnifiedSend` API, when HttpStatusCode is OK (200).
734+
// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`.
735+
message UnifiedSendResponse {
736+
737+
oneof payment_result {
738+
739+
// An on-chain payment was made. Contains the transaction ID.
740+
string txid = 1;
741+
742+
// A BOLT11 payment was made. Contains the payment ID in hex-encoded form.
743+
string bolt11_payment_id = 2;
744+
745+
// A BOLT12 payment was made. Contains the payment ID in hex-encoded form.
746+
string bolt12_payment_id = 3;
747+
}
748+
}
749+
715750
// Returns information on a node with the given ID from the network graph.
716751
// See more: https://docs.rs/ldk-node/latest/ldk_node/graph/struct.NetworkGraph.html#method.node
717752
message GraphGetNodeRequest {

ldk-server/src/api/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ pub(crate) mod open_channel;
4343
pub(crate) mod sign_message;
4444
pub(crate) mod splice_channel;
4545
pub(crate) mod spontaneous_send;
46+
pub(crate) mod unified_send;
4647
pub(crate) mod update_channel_config;
4748
pub(crate) mod verify_signature;
4849

ldk-server/src/api/unified_send.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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 ldk_node::payment::UnifiedPaymentResult;
11+
use ldk_server_protos::api::unified_send_response::PaymentResult;
12+
use ldk_server_protos::api::{UnifiedSendRequest, UnifiedSendResponse};
13+
use tokio::runtime::Handle;
14+
15+
use crate::api::build_route_parameters_config_from_proto;
16+
use crate::api::error::LdkServerError;
17+
use crate::service::Context;
18+
19+
pub(crate) fn handle_unified_send_request(
20+
context: Context, request: UnifiedSendRequest,
21+
) -> Result<UnifiedSendResponse, LdkServerError> {
22+
let route_parameters = build_route_parameters_config_from_proto(request.route_parameters)?;
23+
24+
let result = tokio::task::block_in_place(|| {
25+
Handle::current().block_on(context.node.unified_payment().send(
26+
&request.uri,
27+
request.amount_msat,
28+
route_parameters,
29+
))
30+
})?;
31+
32+
let payment_result = match result {
33+
UnifiedPaymentResult::Onchain { txid } => PaymentResult::Txid(txid.to_string()),
34+
UnifiedPaymentResult::Bolt11 { payment_id } => {
35+
PaymentResult::Bolt11PaymentId(payment_id.to_string())
36+
},
37+
UnifiedPaymentResult::Bolt12 { payment_id } => {
38+
PaymentResult::Bolt12PaymentId(payment_id.to_string())
39+
},
40+
};
41+
42+
Ok(UnifiedSendResponse { payment_result: Some(payment_result) })
43+
}

ldk-server/src/service.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ use ldk_server_protos::endpoints::{
2626
GRAPH_GET_CHANNEL_PATH, GRAPH_GET_NODE_PATH, GRAPH_LIST_CHANNELS_PATH, GRAPH_LIST_NODES_PATH,
2727
LIST_CHANNELS_PATH, LIST_FORWARDED_PAYMENTS_PATH, LIST_PAYMENTS_PATH, LIST_PEERS_PATH,
2828
ONCHAIN_RECEIVE_PATH, ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH,
29-
SPLICE_OUT_PATH, SPONTANEOUS_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH, VERIFY_SIGNATURE_PATH,
29+
SPLICE_OUT_PATH, SPONTANEOUS_SEND_PATH, UNIFIED_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH,
30+
VERIFY_SIGNATURE_PATH,
3031
};
3132
use prost::Message;
3233

@@ -60,6 +61,7 @@ use crate::api::open_channel::handle_open_channel;
6061
use crate::api::sign_message::handle_sign_message_request;
6162
use crate::api::splice_channel::{handle_splice_in_request, handle_splice_out_request};
6263
use crate::api::spontaneous_send::handle_spontaneous_send_request;
64+
use crate::api::unified_send::handle_unified_send_request;
6365
use crate::api::update_channel_config::handle_update_channel_config_request;
6466
use crate::api::verify_signature::handle_verify_signature_request;
6567
use crate::io::persist::paginated_kv_store::PaginatedKVStore;
@@ -350,6 +352,13 @@ impl Service<Request<Incoming>> for NodeService {
350352
api_key,
351353
handle_spontaneous_send_request,
352354
)),
355+
UNIFIED_SEND_PATH => Box::pin(handle_request(
356+
context,
357+
req,
358+
auth_params,
359+
api_key,
360+
handle_unified_send_request,
361+
)),
353362
SIGN_MESSAGE_PATH => Box::pin(handle_request(
354363
context,
355364
req,

0 commit comments

Comments
 (0)