diff --git a/e2e-tests/tests/e2e.rs b/e2e-tests/tests/e2e.rs index 5051bd49..2b93369f 100644 --- a/e2e-tests/tests/e2e.rs +++ b/e2e-tests/tests/e2e.rs @@ -311,6 +311,43 @@ async fn test_cli_bolt11_send() { ); } +#[tokio::test] +async fn test_cli_pay() { + let bitcoind = TestBitcoind::new(); + let server_a = LdkServerHandle::start(&bitcoind).await; + 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 a BOLT12 offer via unified `pay` command + let offer_resp = server_b + .client() + .bolt12_receive(Bolt12ReceiveRequest { + description: "test offer".to_string(), + amount_msat: None, + expiry_secs: None, + quantity: None, + }) + .await + .unwrap(); + let output = run_cli(&server_a, &["pay", &offer_resp.offer, "10000sat"]); + assert!(output.get("bolt12_payment_id").is_some()); +} + #[tokio::test] async fn test_cli_bolt12_send() { let bitcoind = TestBitcoind::new(); diff --git a/ldk-server-cli/src/main.rs b/ldk-server-cli/src/main.rs index 66332cff..65210701 100644 --- a/ldk-server-cli/src/main.rs +++ b/ldk-server-cli/src/main.rs @@ -37,8 +37,8 @@ use ldk_server_client::ldk_server_protos::api::{ OnchainReceiveRequest, OnchainReceiveResponse, OnchainSendRequest, OnchainSendResponse, OpenChannelRequest, OpenChannelResponse, SignMessageRequest, SignMessageResponse, SpliceInRequest, SpliceInResponse, SpliceOutRequest, SpliceOutResponse, SpontaneousSendRequest, - SpontaneousSendResponse, UpdateChannelConfigRequest, UpdateChannelConfigResponse, - VerifySignatureRequest, VerifySignatureResponse, + SpontaneousSendResponse, UnifiedSendRequest, UnifiedSendResponse, UpdateChannelConfigRequest, + UpdateChannelConfigResponse, VerifySignatureRequest, VerifySignatureResponse, }; use ldk_server_client::ldk_server_protos::types::{ bolt11_invoice_description, Bolt11InvoiceDescription, ChannelConfig, PageToken, @@ -275,6 +275,32 @@ enum Commands { )] max_channel_saturation_power_of_half: Option, }, + #[command( + about = "Pay a BIP 21 URI, BIP 353 Human-Readable Name, BOLT11 invoice, or BOLT12 offer" + )] + Pay { + #[arg(help = "A BIP 21 URI, BIP 353 Human-Readable Name, BOLT11 invoice, or BOLT12 offer")] + uri: String, + #[arg(help = "Amount to send, e.g. 50sat or 50000msat. Required for variable-amount URIs")] + amount: Option, + #[arg( + long, + help = "Maximum total routing fee, e.g. 50sat or 50000msat. Defaults to 1% of payment + 50 sats" + )] + max_total_routing_fee: Option, + #[arg(long, help = "Maximum total CLTV delta we accept for the route (default: 1008)")] + max_total_cltv_expiry_delta: Option, + #[arg( + long, + help = "Maximum number of paths that may be used by MPP payments (default: 10)" + )] + max_path_count: Option, + #[arg( + long, + help = "Maximum share of a channel's total capacity to send over a channel, as a power of 1/2 (default: 2)" + )] + max_channel_saturation_power_of_half: Option, + }, #[command(about = "Cooperatively close the channel specified by the given channel ID")] CloseChannel { #[arg(help = "The local user_channel_id of this channel")] @@ -750,6 +776,34 @@ async fn main() { .await, ); }, + Commands::Pay { + uri, + amount, + max_total_routing_fee, + max_total_cltv_expiry_delta, + max_path_count, + max_channel_saturation_power_of_half, + } => { + let amount_msat = amount.map(|a| a.to_msat()); + let max_total_routing_fee_msat = max_total_routing_fee.map(|a| a.to_msat()); + let route_parameters = RouteParametersConfig { + max_total_routing_fee_msat, + max_total_cltv_expiry_delta: max_total_cltv_expiry_delta + .unwrap_or(DEFAULT_MAX_TOTAL_CLTV_EXPIRY_DELTA), + max_path_count: max_path_count.unwrap_or(DEFAULT_MAX_PATH_COUNT), + max_channel_saturation_power_of_half: max_channel_saturation_power_of_half + .unwrap_or(DEFAULT_MAX_CHANNEL_SATURATION_POWER_OF_HALF), + }; + handle_response_result::<_, UnifiedSendResponse>( + client + .unified_send(UnifiedSendRequest { + uri, + amount_msat, + route_parameters: Some(route_parameters), + }) + .await, + ); + }, Commands::CloseChannel { user_channel_id, counterparty_node_id } => { handle_response_result::<_, CloseChannelResponse>( client diff --git a/ldk-server-client/src/client.rs b/ldk-server-client/src/client.rs index 18042b31..f50f046f 100644 --- a/ldk-server-client/src/client.rs +++ b/ldk-server-client/src/client.rs @@ -28,8 +28,8 @@ use ldk_server_protos::api::{ OnchainReceiveRequest, OnchainReceiveResponse, OnchainSendRequest, OnchainSendResponse, OpenChannelRequest, OpenChannelResponse, SignMessageRequest, SignMessageResponse, SpliceInRequest, SpliceInResponse, SpliceOutRequest, SpliceOutResponse, SpontaneousSendRequest, - SpontaneousSendResponse, UpdateChannelConfigRequest, UpdateChannelConfigResponse, - VerifySignatureRequest, VerifySignatureResponse, + SpontaneousSendResponse, UnifiedSendRequest, UnifiedSendResponse, UpdateChannelConfigRequest, + UpdateChannelConfigResponse, VerifySignatureRequest, VerifySignatureResponse, }; use ldk_server_protos::endpoints::{ BOLT11_CLAIM_FOR_HASH_PATH, BOLT11_FAIL_FOR_HASH_PATH, BOLT11_RECEIVE_FOR_HASH_PATH, @@ -39,7 +39,8 @@ use ldk_server_protos::endpoints::{ GRAPH_GET_CHANNEL_PATH, GRAPH_GET_NODE_PATH, GRAPH_LIST_CHANNELS_PATH, GRAPH_LIST_NODES_PATH, LIST_CHANNELS_PATH, LIST_FORWARDED_PAYMENTS_PATH, LIST_PAYMENTS_PATH, LIST_PEERS_PATH, ONCHAIN_RECEIVE_PATH, ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH, - SPLICE_OUT_PATH, SPONTANEOUS_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH, VERIFY_SIGNATURE_PATH, + SPLICE_OUT_PATH, SPONTANEOUS_SEND_PATH, UNIFIED_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH, + VERIFY_SIGNATURE_PATH, }; use ldk_server_protos::error::{ErrorCode, ErrorResponse}; use prost::Message; @@ -327,6 +328,15 @@ impl LdkServerClient { self.post_request(&request, &url).await } + /// Send a payment given a BIP 21 URI or BIP 353 Human-Readable Name. + /// For API contract/usage, refer to docs for [`UnifiedSendRequest`] and [`UnifiedSendResponse`]. + pub async fn unified_send( + &self, request: UnifiedSendRequest, + ) -> Result { + let url = format!("https://{}/{UNIFIED_SEND_PATH}", self.base_url); + self.post_request(&request, &url).await + } + /// Sign a message with the node's secret key. /// For API contract/usage, refer to docs for [`SignMessageRequest`] and [`SignMessageResponse`]. pub async fn sign_message( diff --git a/ldk-server-protos/build.rs b/ldk-server-protos/build.rs index f110a4e0..13e54d53 100644 --- a/ldk-server-protos/build.rs +++ b/ldk-server-protos/build.rs @@ -69,6 +69,10 @@ fn generate_protos() { "types.ClaimableAwaitingConfirmations.source", "#[cfg_attr(feature = \"serde\", serde(serialize_with = \"crate::serde_utils::serialize_balance_source\"))]", ) + .field_attribute( + "api.UnifiedSendResponse.payment_result", + "#[cfg_attr(feature = \"serde\", serde(flatten))]", + ) .compile_protos( &[ "src/proto/api.proto", diff --git a/ldk-server-protos/src/api.rs b/ldk-server-protos/src/api.rs index 08de4704..f92e4787 100644 --- a/ldk-server-protos/src/api.rs +++ b/ldk-server-protos/src/api.rs @@ -922,6 +922,56 @@ pub struct GraphListNodesResponse { #[prost(string, repeated, tag = "1")] pub node_ids: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, } +/// Send a payment given a BIP 21 URI or BIP 353 Human-Readable Name. +/// +/// This method parses the provided URI string and attempts to send the payment. If the URI +/// has an offer and/or invoice, it will try to pay the offer first followed by the invoice. +/// If they both fail, the on-chain payment will be paid. +/// See more: +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct UnifiedSendRequest { + /// A BIP 21 URI or BIP 353 Human-Readable Name to pay. + #[prost(string, tag = "1")] + pub uri: ::prost::alloc::string::String, + /// The amount in millisatoshis to send. Required for "zero-amount" or variable-amount URIs. + #[prost(uint64, optional, tag = "2")] + pub amount_msat: ::core::option::Option, + /// Configuration options for payment routing and pathfinding. + #[prost(message, optional, tag = "3")] + pub route_parameters: ::core::option::Option, +} +/// The response `content` for the `UnifiedSend` API, when HttpStatusCode is OK (200). +/// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct UnifiedSendResponse { + #[prost(oneof = "unified_send_response::PaymentResult", tags = "1, 2, 3")] + #[cfg_attr(feature = "serde", serde(flatten))] + pub payment_result: ::core::option::Option, +} +/// Nested message and enum types in `UnifiedSendResponse`. +pub mod unified_send_response { + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] + #[allow(clippy::derive_partial_eq_without_eq)] + #[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum PaymentResult { + /// An on-chain payment was made. Contains the transaction ID. + #[prost(string, tag = "1")] + Txid(::prost::alloc::string::String), + /// A BOLT11 payment was made. Contains the payment ID in hex-encoded form. + #[prost(string, tag = "2")] + Bolt11PaymentId(::prost::alloc::string::String), + /// A BOLT12 payment was made. Contains the payment ID in hex-encoded form. + #[prost(string, tag = "3")] + Bolt12PaymentId(::prost::alloc::string::String), + } +} /// Returns information on a node with the given ID from the network graph. /// See more: #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] diff --git a/ldk-server-protos/src/endpoints.rs b/ldk-server-protos/src/endpoints.rs index 249e19d2..abdb77b4 100644 --- a/ldk-server-protos/src/endpoints.rs +++ b/ldk-server-protos/src/endpoints.rs @@ -35,6 +35,7 @@ pub const SPONTANEOUS_SEND_PATH: &str = "SpontaneousSend"; pub const SIGN_MESSAGE_PATH: &str = "SignMessage"; pub const VERIFY_SIGNATURE_PATH: &str = "VerifySignature"; pub const EXPORT_PATHFINDING_SCORES_PATH: &str = "ExportPathfindingScores"; +pub const UNIFIED_SEND_PATH: &str = "UnifiedSend"; pub const GRAPH_LIST_CHANNELS_PATH: &str = "GraphListChannels"; pub const GRAPH_GET_CHANNEL_PATH: &str = "GraphGetChannel"; pub const GRAPH_LIST_NODES_PATH: &str = "GraphListNodes"; diff --git a/ldk-server-protos/src/proto/api.proto b/ldk-server-protos/src/proto/api.proto index a69f3a0c..8280b4e4 100644 --- a/ldk-server-protos/src/proto/api.proto +++ b/ldk-server-protos/src/proto/api.proto @@ -712,6 +712,41 @@ message GraphListNodesResponse { repeated string node_ids = 1; } +// Send a payment given a BIP 21 URI or BIP 353 Human-Readable Name. +// +// This method parses the provided URI string and attempts to send the payment. If the URI +// has an offer and/or invoice, it will try to pay the offer first followed by the invoice. +// If they both fail, the on-chain payment will be paid. +// See more: https://docs.rs/ldk-node/latest/ldk_node/payment/struct.UnifiedPayment.html#method.send +message UnifiedSendRequest { + + // A BIP 21 URI or BIP 353 Human-Readable Name to pay. + string uri = 1; + + // The amount in millisatoshis to send. Required for "zero-amount" or variable-amount URIs. + optional uint64 amount_msat = 2; + + // Configuration options for payment routing and pathfinding. + optional types.RouteParametersConfig route_parameters = 3; +} + +// The response `content` for the `UnifiedSend` API, when HttpStatusCode is OK (200). +// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +message UnifiedSendResponse { + + oneof payment_result { + + // An on-chain payment was made. Contains the transaction ID. + string txid = 1; + + // A BOLT11 payment was made. Contains the payment ID in hex-encoded form. + string bolt11_payment_id = 2; + + // A BOLT12 payment was made. Contains the payment ID in hex-encoded form. + string bolt12_payment_id = 3; + } +} + // Returns information on a node with the given ID from the network graph. // See more: https://docs.rs/ldk-node/latest/ldk_node/graph/struct.NetworkGraph.html#method.node message GraphGetNodeRequest { diff --git a/ldk-server/src/api/mod.rs b/ldk-server/src/api/mod.rs index 1c4d489a..c185055f 100644 --- a/ldk-server/src/api/mod.rs +++ b/ldk-server/src/api/mod.rs @@ -43,6 +43,7 @@ pub(crate) mod open_channel; pub(crate) mod sign_message; pub(crate) mod splice_channel; pub(crate) mod spontaneous_send; +pub(crate) mod unified_send; pub(crate) mod update_channel_config; pub(crate) mod verify_signature; diff --git a/ldk-server/src/api/unified_send.rs b/ldk-server/src/api/unified_send.rs new file mode 100644 index 00000000..3f7807b4 --- /dev/null +++ b/ldk-server/src/api/unified_send.rs @@ -0,0 +1,43 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use ldk_node::payment::UnifiedPaymentResult; +use ldk_server_protos::api::unified_send_response::PaymentResult; +use ldk_server_protos::api::{UnifiedSendRequest, UnifiedSendResponse}; +use tokio::runtime::Handle; + +use crate::api::build_route_parameters_config_from_proto; +use crate::api::error::LdkServerError; +use crate::service::Context; + +pub(crate) fn handle_unified_send_request( + context: Context, request: UnifiedSendRequest, +) -> Result { + let route_parameters = build_route_parameters_config_from_proto(request.route_parameters)?; + + let result = tokio::task::block_in_place(|| { + Handle::current().block_on(context.node.unified_payment().send( + &request.uri, + request.amount_msat, + route_parameters, + )) + })?; + + let payment_result = match result { + UnifiedPaymentResult::Onchain { txid } => PaymentResult::Txid(txid.to_string()), + UnifiedPaymentResult::Bolt11 { payment_id } => { + PaymentResult::Bolt11PaymentId(payment_id.to_string()) + }, + UnifiedPaymentResult::Bolt12 { payment_id } => { + PaymentResult::Bolt12PaymentId(payment_id.to_string()) + }, + }; + + Ok(UnifiedSendResponse { payment_result: Some(payment_result) }) +} diff --git a/ldk-server/src/service.rs b/ldk-server/src/service.rs index ebfc6492..b74dda92 100644 --- a/ldk-server/src/service.rs +++ b/ldk-server/src/service.rs @@ -26,7 +26,8 @@ use ldk_server_protos::endpoints::{ GRAPH_GET_CHANNEL_PATH, GRAPH_GET_NODE_PATH, GRAPH_LIST_CHANNELS_PATH, GRAPH_LIST_NODES_PATH, LIST_CHANNELS_PATH, LIST_FORWARDED_PAYMENTS_PATH, LIST_PAYMENTS_PATH, LIST_PEERS_PATH, ONCHAIN_RECEIVE_PATH, ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH, - SPLICE_OUT_PATH, SPONTANEOUS_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH, VERIFY_SIGNATURE_PATH, + SPLICE_OUT_PATH, SPONTANEOUS_SEND_PATH, UNIFIED_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH, + VERIFY_SIGNATURE_PATH, }; use prost::Message; @@ -60,6 +61,7 @@ use crate::api::open_channel::handle_open_channel; use crate::api::sign_message::handle_sign_message_request; use crate::api::splice_channel::{handle_splice_in_request, handle_splice_out_request}; use crate::api::spontaneous_send::handle_spontaneous_send_request; +use crate::api::unified_send::handle_unified_send_request; use crate::api::update_channel_config::handle_update_channel_config_request; use crate::api::verify_signature::handle_verify_signature_request; use crate::io::persist::paginated_kv_store::PaginatedKVStore; @@ -350,6 +352,13 @@ impl Service> for NodeService { api_key, handle_spontaneous_send_request, )), + UNIFIED_SEND_PATH => Box::pin(handle_request( + context, + req, + auth_params, + api_key, + handle_unified_send_request, + )), SIGN_MESSAGE_PATH => Box::pin(handle_request( context, req,