From 1eba0c8d6b07f6c3e8db1c21365b08cab25953a0 Mon Sep 17 00:00:00 2001 From: Fernando Ledesma Date: Mon, 15 Jun 2026 12:16:12 -0500 Subject: [PATCH] feat: allow setting custom preimage in spontaneous-send --- e2e-tests/tests/e2e.rs | 39 +++++++++++++++++++- ldk-server-cli/src/main.rs | 4 +++ ldk-server-grpc/src/api.rs | 4 +++ ldk-server-grpc/src/proto/api.proto | 4 +++ ldk-server-mcp/src/tools/schema.rs | 6 +++- ldk-server/src/api/spontaneous_send.rs | 49 +++++++++++++++++++++----- 6 files changed, 95 insertions(+), 11 deletions(-) diff --git a/e2e-tests/tests/e2e.rs b/e2e-tests/tests/e2e.rs index f98ef85a..77669ee2 100644 --- a/e2e-tests/tests/e2e.rs +++ b/e2e-tests/tests/e2e.rs @@ -32,7 +32,7 @@ use ldk_server_client::ldk_server_grpc::events::{ use ldk_server_client::ldk_server_grpc::types::{ bolt11_invoice_description, Bolt11InvoiceDescription, }; - +use ldk_server_grpc::types::payment_kind; const EVENT_TIMEOUT: Duration = Duration::from_secs(15); async fn wait_for_event(events: &mut EventStream, pred: impl Fn(&Event) -> bool) -> EventEnvelope { @@ -1462,3 +1462,40 @@ async fn test_metrics_endpoint_with_auth() { assert!(metrics.contains("ldk_server_total_anchor_channels_reserve_sats 0")); assert!(metrics.contains("ldk_server_total_lightning_balance_sats 0")); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_cli_spontaneous_send_with_preimage() { + let bitcoind = TestBitcoind::new(); + let server_a = LdkServerHandle::start(&bitcoind).await; + let server_b = LdkServerHandle::start(&bitcoind).await; + + let mut events_b = server_b.client().subscribe_events().await.unwrap(); + + setup_funded_channel(&bitcoind, &server_a, &server_b, 100_000).await; + + // Generate a known preimage and compute its payment hash + let preimage_bytes = [43u8; 32]; + let preimage_hex = preimage_bytes.to_lower_hex_string(); + let payment_hash = sha256::Hash::hash(&preimage_bytes); + let payment_hash_hex = payment_hash.to_byte_array().to_lower_hex_string(); + + let output = run_cli( + &server_a, + &["spontaneous-send", server_b.node_id(), "10000sat", "--preimage", &preimage_hex], + ); + + assert!(!output["payment_id"].as_str().unwrap().is_empty()); + + // The receiver must observe in PaymentReceived for checking on Spontaneous payment. + let event_b = wait_for_event(&mut events_b, |e| matches!(e, Event::PaymentReceived(_))).await; + let Some(Event::PaymentReceived(pr)) = event_b.event else { + panic!("expected PaymentReceived"); + }; + + let payment = pr.payment.unwrap(); + + let Some(payment_kind::Kind::Spontaneous(spont)) = payment.kind.unwrap().kind else { + panic!("expected spontaneous kind"); + }; + assert_eq!(spont.hash, payment_hash_hex); +} diff --git a/ldk-server-cli/src/main.rs b/ldk-server-cli/src/main.rs index 3aa6f1c8..ce7deade 100644 --- a/ldk-server-cli/src/main.rs +++ b/ldk-server-cli/src/main.rs @@ -320,6 +320,8 @@ enum Commands { help = "Custom TLV record to attach, format: :. Repeatable. type_num must be >= 65536." )] custom_tlvs: Vec<(u64, Vec)>, + #[arg(long)] + preimage: Option, }, #[command( about = "Pay a BIP 21 URI, BIP 353 Human-Readable Name, BOLT11 invoice, or BOLT12 offer" @@ -817,6 +819,7 @@ async fn main() { max_path_count, max_channel_saturation_power_of_half, custom_tlvs, + preimage, } => { let amount_msat = amount.to_msat(); let max_total_routing_fee_msat = max_total_routing_fee.map(|a| a.to_msat()); @@ -841,6 +844,7 @@ async fn main() { node_id, route_parameters: Some(route_parameters), custom_tlvs: proto_custom_tlvs, + preimage, }) .await, ); diff --git a/ldk-server-grpc/src/api.rs b/ldk-server-grpc/src/api.rs index b4eda4ed..82480332 100644 --- a/ldk-server-grpc/src/api.rs +++ b/ldk-server-grpc/src/api.rs @@ -473,6 +473,10 @@ pub struct SpontaneousSendRequest { /// Custom TLV records to attach to the outgoing payment. #[prost(message, repeated, tag = "4")] pub custom_tlvs: ::prost::alloc::vec::Vec, + /// An optional hex-encoded 32-byte payment preimage. If provided, it will be used instead of + /// generating a random one. The payment hash will be the SHA256 of this value. + #[prost(string, optional, tag = "5")] + pub preimage: ::core::option::Option<::prost::alloc::string::String>, } /// The response for the `SpontaneousSend` RPC. On failure, a gRPC error status is returned. #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] diff --git a/ldk-server-grpc/src/proto/api.proto b/ldk-server-grpc/src/proto/api.proto index 516c406b..e512d8e1 100644 --- a/ldk-server-grpc/src/proto/api.proto +++ b/ldk-server-grpc/src/proto/api.proto @@ -371,6 +371,10 @@ message SpontaneousSendRequest { // Custom TLV records to attach to the outgoing payment. repeated types.CustomTlvRecord custom_tlvs = 4; + + // An optional hex-encoded 32-byte payment preimage. If provided, it will be used instead of + // generating a random one. The payment hash will be the SHA256 of this value. + optional string preimage = 5; } // The response for the `SpontaneousSend` RPC. On failure, a gRPC error status is returned. diff --git a/ldk-server-mcp/src/tools/schema.rs b/ldk-server-mcp/src/tools/schema.rs index b9c45144..6a8a2ece 100644 --- a/ldk-server-mcp/src/tools/schema.rs +++ b/ldk-server-mcp/src/tools/schema.rs @@ -374,7 +374,11 @@ pub fn spontaneous_send_schema() -> Value { "type": "string", "description": "The hex-encoded public key of the destination node" }, - "route_parameters": route_parameters_config_schema() + "route_parameters": route_parameters_config_schema(), + "preimage": { + "type": "string", + "description": "The hex-encoded 32-byte payment preimage" + } }, "required": ["amount_msat", "node_id"] }) diff --git a/ldk-server/src/api/spontaneous_send.rs b/ldk-server/src/api/spontaneous_send.rs index 60ed6594..5c5f97c8 100644 --- a/ldk-server/src/api/spontaneous_send.rs +++ b/ldk-server/src/api/spontaneous_send.rs @@ -10,7 +10,9 @@ use std::str::FromStr; use std::sync::Arc; +use hex::FromHex; use ldk_node::bitcoin::secp256k1::PublicKey; +use ldk_node::lightning_types::payment::PaymentPreimage; use ldk_server_grpc::api::{SpontaneousSendRequest, SpontaneousSendResponse}; use crate::api::error::LdkServerError; @@ -27,19 +29,48 @@ pub(crate) async fn handle_spontaneous_send_request( let route_parameters = build_route_parameters_config_from_proto(request.route_parameters)?; - let payment_id = if request.custom_tlvs.is_empty() { - context.node.spontaneous_payment().send(request.amount_msat, node_id, route_parameters)? - } else { - let custom_tlvs: Vec<_> = - request.custom_tlvs.iter().map(proto_to_node_custom_tlv).collect(); - context.node.spontaneous_payment().send_with_custom_tlvs( + let preimage = request + .preimage + .map(|p| { + <[u8; 32]>::from_hex(&p).map(PaymentPreimage).map_err(|_| { + LdkServerError::new( + InvalidRequestError, + "Invalid preimage, must be a 32-byte hex string.".to_string(), + ) + }) + }) + .transpose()?; + + let custom_tlvs: Vec<_> = request.custom_tlvs.iter().map(proto_to_node_custom_tlv).collect(); + + let payment_id = match (preimage, custom_tlvs.is_empty()) { + (None, true) => context.node.spontaneous_payment().send( + request.amount_msat, + node_id, + route_parameters, + )?, + (None, false) => context.node.spontaneous_payment().send_with_custom_tlvs( request.amount_msat, node_id, route_parameters, custom_tlvs, - )? + )?, + (Some(preimage), true) => context.node.spontaneous_payment().send_with_preimage( + request.amount_msat, + node_id, + preimage, + route_parameters, + )?, + (Some(preimage), false) => { + context.node.spontaneous_payment().send_with_preimage_and_custom_tlvs( + request.amount_msat, + node_id, + custom_tlvs, + preimage, + route_parameters, + )? + }, }; - let response = SpontaneousSendResponse { payment_id: payment_id.to_string() }; - Ok(response) + Ok(SpontaneousSendResponse { payment_id: payment_id.to_string() }) }