From 3820ffdf84873b002fe329d1cba4185e2fbc189e Mon Sep 17 00:00:00 2001 From: benthecarman Date: Sat, 14 Mar 2026 19:44:40 -0500 Subject: [PATCH 1/2] Keep RabbitMQ consumer alive across multiple consume_events calls Create the basic_consume consumer once during RabbitMqEventConsumer::new() and store it persistently, rather than recreating it on each consume_events() call. Before, the queue was dropped and we'd need to create a new consumer everytime we want to verify which events had came in. --- e2e-tests/src/lib.rs | 36 ++++++++++++++++++------------------ e2e-tests/tests/e2e.rs | 10 +++++----- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/e2e-tests/src/lib.rs b/e2e-tests/src/lib.rs index 8b34fd2..083c08e 100644 --- a/e2e-tests/src/lib.rs +++ b/e2e-tests/src/lib.rs @@ -432,13 +432,15 @@ pub async fn setup_funded_channel( pub struct RabbitMqEventConsumer { _connection: lapin::Connection, channel: lapin::Channel, - queue_name: String, + consumer: lapin::Consumer, } impl RabbitMqEventConsumer { /// Connect to RabbitMQ and create an exclusive queue bound to the given exchange. pub async fn new(exchange_name: &str) -> Self { - use lapin::options::{ExchangeDeclareOptions, QueueBindOptions, QueueDeclareOptions}; + use lapin::options::{ + BasicConsumeOptions, ExchangeDeclareOptions, QueueBindOptions, QueueDeclareOptions, + }; use lapin::types::FieldTable; use lapin::{ConnectionProperties, ExchangeKind}; @@ -484,32 +486,30 @@ impl RabbitMqEventConsumer { .await .expect("Failed to bind queue"); - Self { _connection: connection, channel, queue_name } + let consumer = channel + .basic_consume( + &queue_name, + &format!("consumer_{}", queue_name), + BasicConsumeOptions::default(), + FieldTable::default(), + ) + .await + .expect("Failed to start consumer"); + + Self { _connection: connection, channel, consumer } } /// Consume up to `count` events, waiting up to `timeout` for each. pub async fn consume_events( - &self, count: usize, timeout: Duration, + &mut self, count: usize, timeout: Duration, ) -> Vec { use futures_util::StreamExt; - use lapin::options::{BasicAckOptions, BasicConsumeOptions}; - use lapin::types::FieldTable; + use lapin::options::BasicAckOptions; use prost::Message; - let mut consumer = self - .channel - .basic_consume( - &self.queue_name, - &format!("consumer_{}", self.queue_name), - BasicConsumeOptions::default(), - FieldTable::default(), - ) - .await - .expect("Failed to start consumer"); - let mut events = Vec::new(); for _ in 0..count { - match tokio::time::timeout(timeout, consumer.next()).await { + match tokio::time::timeout(timeout, self.consumer.next()).await { Ok(Some(Ok(delivery))) => { let event = ldk_server_protos::events::EventEnvelope::decode(&*delivery.data) .expect("Failed to decode event"); diff --git a/e2e-tests/tests/e2e.rs b/e2e-tests/tests/e2e.rs index 7744a0d..7dd34d0 100644 --- a/e2e-tests/tests/e2e.rs +++ b/e2e-tests/tests/e2e.rs @@ -271,8 +271,8 @@ async fn test_cli_bolt11_send() { let server_b = LdkServerHandle::start(&bitcoind).await; // Set up event consumers before any payments - let consumer_a = RabbitMqEventConsumer::new(&server_a.exchange_name).await; - let consumer_b = RabbitMqEventConsumer::new(&server_b.exchange_name).await; + let mut consumer_a = RabbitMqEventConsumer::new(&server_a.exchange_name).await; + let mut consumer_b = RabbitMqEventConsumer::new(&server_b.exchange_name).await; setup_funded_channel(&bitcoind, &server_a, &server_b, 100_000).await; @@ -339,8 +339,8 @@ async fn test_cli_spontaneous_send() { let server_a = LdkServerHandle::start(&bitcoind).await; let server_b = LdkServerHandle::start(&bitcoind).await; - let consumer_a = RabbitMqEventConsumer::new(&server_a.exchange_name).await; - let consumer_b = RabbitMqEventConsumer::new(&server_b.exchange_name).await; + let mut consumer_a = RabbitMqEventConsumer::new(&server_a.exchange_name).await; + let mut consumer_b = RabbitMqEventConsumer::new(&server_b.exchange_name).await; setup_funded_channel(&bitcoind, &server_a, &server_b, 100_000).await; @@ -564,7 +564,7 @@ async fn test_forwarded_payment_event() { let server_b = LdkServerHandle::start(&bitcoind).await; // Set up RabbitMQ consumer on B before any payments - let consumer_b = RabbitMqEventConsumer::new(&server_b.exchange_name).await; + let mut consumer_b = RabbitMqEventConsumer::new(&server_b.exchange_name).await; // Open channel A -> B (1M sats, larger for JIT forwarding) setup_funded_channel(&bitcoind, &server_a, &server_b, 1_000_000).await; From e055d375061604970217a79771575acbc7b94ab7 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Wed, 11 Mar 2026 09:26:19 -0400 Subject: [PATCH 2/2] Add hodl invoice support Adds three new API endpoints for hodl invoices, which allow the receiver to inspect incoming payments before deciding whether to claim or reject them. Also add a `PaymentClaimable` event that is published when a hodl invoice payment arrives and is waiting to be manually claimed or failed. This is was needed when getting ldk-server to work with loop. --- e2e-tests/tests/e2e.rs | 144 ++++++++++++++++++ ldk-server-cli/src/main.rs | 96 ++++++++++++ ldk-server-client/src/client.rs | 31 ++++ ldk-server-protos/src/api.rs | 85 +++++++++++ ldk-server-protos/src/endpoints.rs | 3 + ldk-server-protos/src/events.rs | 15 +- ldk-server-protos/src/proto/api.proto | 67 ++++++++ ldk-server-protos/src/proto/events.proto | 8 + ldk-server/src/api/bolt11_claim_for_hash.rs | 47 ++++++ ldk-server/src/api/bolt11_fail_for_hash.rs | 32 ++++ ldk-server/src/api/bolt11_receive_for_hash.rs | 46 ++++++ ldk-server/src/api/mod.rs | 3 + ldk-server/src/io/events/mod.rs | 1 + ldk-server/src/main.rs | 13 +- ldk-server/src/service.rs | 25 +++ 15 files changed, 609 insertions(+), 7 deletions(-) create mode 100644 ldk-server/src/api/bolt11_claim_for_hash.rs create mode 100644 ldk-server/src/api/bolt11_fail_for_hash.rs create mode 100644 ldk-server/src/api/bolt11_receive_for_hash.rs diff --git a/e2e-tests/tests/e2e.rs b/e2e-tests/tests/e2e.rs index 7dd34d0..5051bd4 100644 --- a/e2e-tests/tests/e2e.rs +++ b/e2e-tests/tests/e2e.rs @@ -14,6 +14,8 @@ use e2e_tests::{ find_available_port, mine_and_sync, run_cli, run_cli_raw, setup_funded_channel, wait_for_onchain_balance, LdkServerHandle, RabbitMqEventConsumer, TestBitcoind, }; +use hex_conservative::DisplayHex; +use ldk_node::bitcoin::hashes::{sha256, Hash}; use ldk_node::lightning::ln::msgs::SocketAddress; use ldk_server_client::ldk_server_protos::api::{ Bolt11ReceiveRequest, Bolt12ReceiveRequest, OnchainReceiveRequest, @@ -634,3 +636,145 @@ async fn test_forwarded_payment_event() { node_c.stop().unwrap(); } + +#[tokio::test] +async fn test_hodl_invoice_claim() { + let bitcoind = TestBitcoind::new(); + let server_a = LdkServerHandle::start(&bitcoind).await; + let server_b = LdkServerHandle::start(&bitcoind).await; + + let mut consumer_a = RabbitMqEventConsumer::new(&server_a.exchange_name).await; + let mut consumer_b = RabbitMqEventConsumer::new(&server_b.exchange_name).await; + + setup_funded_channel(&bitcoind, &server_a, &server_b, 100_000).await; + + // Test three claim variants: (preimage, amount, hash) + let test_cases: Vec<([u8; 32], Option<&str>, bool)> = vec![ + ([42u8; 32], Some("10000000msat"), true), // all args + ([44u8; 32], Some("10000000msat"), false), // preimage + amount + ([45u8; 32], None, true), // preimage + hash + ([46u8; 32], None, false), // preimage only + ]; + + for (preimage_bytes, amount, include_hash) in &test_cases { + let preimage_hex = preimage_bytes.to_lower_hex_string(); + let payment_hash_hex = + sha256::Hash::hash(preimage_bytes).to_byte_array().to_lower_hex_string(); + + // Create hodl invoice on B + let invoice_resp = run_cli( + &server_b, + &[ + "bolt11-receive-for-hash", + &payment_hash_hex, + "10000000msat", + "-d", + "hodl test", + "-e", + "3600", + ], + ); + let invoice = invoice_resp["invoice"].as_str().unwrap(); + + // Pay the hodl invoice from A + run_cli(&server_a, &["bolt11-send", invoice]); + + // Verify PaymentClaimable event on B + let events_b = consumer_b.consume_events(1, Duration::from_secs(10)).await; + assert!( + events_b.iter().any(|e| matches!(&e.event, Some(Event::PaymentClaimable(_)))), + "Expected PaymentClaimable on receiver, got events: {:?}", + events_b.iter().map(|e| &e.event).collect::>() + ); + + // Claim the payment on B + let mut args: Vec<&str> = vec!["bolt11-claim-for-hash", &preimage_hex]; + if let Some(amt) = amount { + args.extend(["-c", amt]); + } + if *include_hash { + args.extend(["-p", &payment_hash_hex]); + } + run_cli(&server_b, &args); + + // Verify PaymentReceived event on B + let events_b = consumer_b.consume_events(1, Duration::from_secs(10)).await; + assert!( + events_b.iter().any(|e| matches!(&e.event, Some(Event::PaymentReceived(_)))), + "Expected PaymentReceived on receiver after claim, got events: {:?}", + events_b.iter().map(|e| &e.event).collect::>() + ); + + // Verify PaymentSuccessful on A + let events_a = consumer_a.consume_events(1, Duration::from_secs(10)).await; + assert!( + events_a.iter().any(|e| matches!(&e.event, Some(Event::PaymentSuccessful(_)))), + "Expected PaymentSuccessful on sender, got events: {:?}", + events_a.iter().map(|e| &e.event).collect::>() + ); + } +} + +#[tokio::test] +async fn test_hodl_invoice_fail() { + use hex_conservative::DisplayHex; + use ldk_node::bitcoin::hashes::{sha256, Hash}; + + let bitcoind = TestBitcoind::new(); + let server_a = LdkServerHandle::start(&bitcoind).await; + let server_b = LdkServerHandle::start(&bitcoind).await; + + // Set up event consumers before any payments + let mut consumer_a = RabbitMqEventConsumer::new(&server_a.exchange_name).await; + let mut consumer_b = RabbitMqEventConsumer::new(&server_b.exchange_name).await; + + 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 payment_hash = sha256::Hash::hash(&preimage_bytes); + let payment_hash_hex = payment_hash.to_byte_array().to_lower_hex_string(); + + // Create hodl invoice on B + let invoice_resp = run_cli( + &server_b, + &[ + "bolt11-receive-for-hash", + &payment_hash_hex, + "10000000msat", + "-d", + "hodl fail test", + "-e", + "3600", + ], + ); + let invoice = invoice_resp["invoice"].as_str().unwrap(); + + // Pay the hodl invoice from A + run_cli(&server_a, &["bolt11-send", invoice]); + + // Wait for payment to arrive at B + tokio::time::sleep(Duration::from_secs(5)).await; + + // Verify PaymentClaimable event on B + let events_b = consumer_b.consume_events(5, Duration::from_secs(10)).await; + assert!( + events_b.iter().any(|e| matches!(&e.event, Some(Event::PaymentClaimable(_)))), + "Expected PaymentClaimable on receiver, got events: {:?}", + events_b.iter().map(|e| &e.event).collect::>() + ); + + // Fail the payment on B using CLI + run_cli(&server_b, &["bolt11-fail-for-hash", &payment_hash_hex]); + + // Wait for failure to propagate + tokio::time::sleep(Duration::from_secs(5)).await; + + // Verify PaymentFailed on A + let events_a = consumer_a.consume_events(10, Duration::from_secs(10)).await; + assert!( + events_a.iter().any(|e| matches!(&e.event, Some(Event::PaymentFailed(_)))), + "Expected PaymentFailed on sender after hodl rejection, got events: {:?}", + events_a.iter().map(|e| &e.event).collect::>() + ); +} diff --git a/ldk-server-cli/src/main.rs b/ldk-server-cli/src/main.rs index 8cfa087..66332cf 100644 --- a/ldk-server-cli/src/main.rs +++ b/ldk-server-cli/src/main.rs @@ -22,6 +22,8 @@ use ldk_server_client::error::LdkServerErrorCode::{ AuthError, InternalError, InternalServerError, InvalidRequestError, LightningError, }; use ldk_server_client::ldk_server_protos::api::{ + Bolt11ClaimForHashRequest, Bolt11ClaimForHashResponse, Bolt11FailForHashRequest, + Bolt11FailForHashResponse, Bolt11ReceiveForHashRequest, Bolt11ReceiveForHashResponse, Bolt11ReceiveRequest, Bolt11ReceiveResponse, Bolt11SendRequest, Bolt11SendResponse, Bolt12ReceiveRequest, Bolt12ReceiveResponse, Bolt12SendRequest, Bolt12SendResponse, CloseChannelRequest, CloseChannelResponse, ConnectPeerRequest, ConnectPeerResponse, @@ -134,6 +136,48 @@ enum Commands { #[arg(short, long, help = "Invoice expiry time in seconds (default: 86400)")] expiry_secs: Option, }, + #[command( + about = "Create a BOLT11 hodl invoice for a given payment hash (manual claim required)" + )] + Bolt11ReceiveForHash { + #[arg(help = "The hex-encoded 32-byte payment hash")] + payment_hash: String, + #[arg( + help = "Amount to request, e.g. 50sat or 50000msat. If unset, a variable-amount invoice is returned" + )] + amount: Option, + #[arg(short, long, help = "Description to attach along with the invoice")] + description: Option, + #[arg( + long, + help = "SHA-256 hash of the description (hex). Use instead of description for longer text" + )] + description_hash: Option, + #[arg(short, long, help = "Invoice expiry time in seconds (default: 86400)")] + expiry_secs: Option, + }, + #[command(about = "Claim a held payment by providing the preimage")] + Bolt11ClaimForHash { + #[arg(help = "The hex-encoded 32-byte payment preimage")] + preimage: String, + #[arg( + short, + long, + help = "The claimable amount, e.g. 50sat or 50000msat, only used for verifying we are claiming the expected amount" + )] + claimable_amount: Option, + #[arg( + short, + long, + help = "The hex-encoded 32-byte payment hash, used to verify the preimage matches" + )] + payment_hash: Option, + }, + #[command(about = "Fail/reject a held payment")] + Bolt11FailForHash { + #[arg(help = "The hex-encoded 32-byte payment hash")] + payment_hash: String, + }, #[command(about = "Pay a BOLT11 invoice")] Bolt11Send { #[arg(help = "A BOLT11 invoice for a payment within the Lightning Network")] @@ -551,6 +595,58 @@ async fn main() { client.bolt11_receive(request).await, ); }, + Commands::Bolt11ReceiveForHash { + payment_hash, + amount, + description, + description_hash, + expiry_secs, + } => { + let amount_msat = amount.map(|a| a.to_msat()); + let invoice_description = match (description, description_hash) { + (Some(desc), None) => Some(Bolt11InvoiceDescription { + kind: Some(bolt11_invoice_description::Kind::Direct(desc)), + }), + (None, Some(hash)) => Some(Bolt11InvoiceDescription { + kind: Some(bolt11_invoice_description::Kind::Hash(hash)), + }), + (Some(_), Some(_)) => { + handle_error(LdkServerError::new( + InternalError, + "Only one of description or description_hash can be set.".to_string(), + )); + }, + (None, None) => None, + }; + + let expiry_secs = expiry_secs.unwrap_or(DEFAULT_EXPIRY_SECS); + let request = Bolt11ReceiveForHashRequest { + description: invoice_description, + expiry_secs, + amount_msat, + payment_hash, + }; + + handle_response_result::<_, Bolt11ReceiveForHashResponse>( + client.bolt11_receive_for_hash(request).await, + ); + }, + Commands::Bolt11ClaimForHash { preimage, claimable_amount, payment_hash } => { + handle_response_result::<_, Bolt11ClaimForHashResponse>( + client + .bolt11_claim_for_hash(Bolt11ClaimForHashRequest { + payment_hash, + claimable_amount_msat: claimable_amount.map(|a| a.to_msat()), + preimage, + }) + .await, + ); + }, + Commands::Bolt11FailForHash { payment_hash } => { + handle_response_result::<_, Bolt11FailForHashResponse>( + client.bolt11_fail_for_hash(Bolt11FailForHashRequest { payment_hash }).await, + ); + }, Commands::Bolt11Send { invoice, amount, diff --git a/ldk-server-client/src/client.rs b/ldk-server-client/src/client.rs index 427274f..18042b3 100644 --- a/ldk-server-client/src/client.rs +++ b/ldk-server-client/src/client.rs @@ -12,6 +12,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; use bitcoin_hashes::hmac::{Hmac, HmacEngine}; use bitcoin_hashes::{sha256, Hash, HashEngine}; use ldk_server_protos::api::{ + Bolt11ClaimForHashRequest, Bolt11ClaimForHashResponse, Bolt11FailForHashRequest, + Bolt11FailForHashResponse, Bolt11ReceiveForHashRequest, Bolt11ReceiveForHashResponse, Bolt11ReceiveRequest, Bolt11ReceiveResponse, Bolt11SendRequest, Bolt11SendResponse, Bolt12ReceiveRequest, Bolt12ReceiveResponse, Bolt12SendRequest, Bolt12SendResponse, CloseChannelRequest, CloseChannelResponse, ConnectPeerRequest, ConnectPeerResponse, @@ -30,6 +32,7 @@ use ldk_server_protos::api::{ VerifySignatureRequest, VerifySignatureResponse, }; use ldk_server_protos::endpoints::{ + BOLT11_CLAIM_FOR_HASH_PATH, BOLT11_FAIL_FOR_HASH_PATH, BOLT11_RECEIVE_FOR_HASH_PATH, BOLT11_RECEIVE_PATH, BOLT11_SEND_PATH, BOLT12_RECEIVE_PATH, BOLT12_SEND_PATH, CLOSE_CHANNEL_PATH, CONNECT_PEER_PATH, DISCONNECT_PEER_PATH, EXPORT_PATHFINDING_SCORES_PATH, FORCE_CLOSE_CHANNEL_PATH, GET_BALANCES_PATH, GET_NODE_INFO_PATH, GET_PAYMENT_DETAILS_PATH, @@ -143,6 +146,34 @@ impl LdkServerClient { self.post_request(&request, &url).await } + /// Retrieve a new BOLT11 payable invoice for a given payment hash. + /// The inbound payment will NOT be automatically claimed upon arrival. + /// For API contract/usage, refer to docs for [`Bolt11ReceiveForHashRequest`] and [`Bolt11ReceiveForHashResponse`]. + pub async fn bolt11_receive_for_hash( + &self, request: Bolt11ReceiveForHashRequest, + ) -> Result { + let url = format!("https://{}/{BOLT11_RECEIVE_FOR_HASH_PATH}", self.base_url); + self.post_request(&request, &url).await + } + + /// Manually claim a payment for a given payment hash with the corresponding preimage. + /// For API contract/usage, refer to docs for [`Bolt11ClaimForHashRequest`] and [`Bolt11ClaimForHashResponse`]. + pub async fn bolt11_claim_for_hash( + &self, request: Bolt11ClaimForHashRequest, + ) -> Result { + let url = format!("https://{}/{BOLT11_CLAIM_FOR_HASH_PATH}", self.base_url); + self.post_request(&request, &url).await + } + + /// Manually fail a payment for a given payment hash. + /// For API contract/usage, refer to docs for [`Bolt11FailForHashRequest`] and [`Bolt11FailForHashResponse`]. + pub async fn bolt11_fail_for_hash( + &self, request: Bolt11FailForHashRequest, + ) -> Result { + let url = format!("https://{}/{BOLT11_FAIL_FOR_HASH_PATH}", self.base_url); + self.post_request(&request, &url).await + } + /// Send a payment for a BOLT11 invoice. /// For API contract/usage, refer to docs for [`Bolt11SendRequest`] and [`Bolt11SendResponse`]. pub async fn bolt11_send( diff --git a/ldk-server-protos/src/api.rs b/ldk-server-protos/src/api.rs index 4dbdb66..08de470 100644 --- a/ldk-server-protos/src/api.rs +++ b/ldk-server-protos/src/api.rs @@ -174,6 +174,91 @@ pub struct Bolt11ReceiveResponse { #[prost(string, tag = "1")] pub invoice: ::prost::alloc::string::String, } +/// Return a BOLT11 payable invoice for a given payment hash. +/// The inbound payment will NOT be automatically claimed upon arrival. +/// Instead, the payment will need to be manually claimed by calling `Bolt11ClaimForHash` +/// or manually failed by calling `Bolt11FailForHash`. +/// 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 Bolt11ReceiveForHashRequest { + /// The amount in millisatoshi to receive. If unset, a "zero-amount" or variable-amount invoice is returned. + #[prost(uint64, optional, tag = "1")] + pub amount_msat: ::core::option::Option, + /// An optional description to attach along with the invoice. + /// Will be set in the description field of the encoded payment request. + #[prost(message, optional, tag = "2")] + pub description: ::core::option::Option, + /// Invoice expiry time in seconds. + #[prost(uint32, tag = "3")] + pub expiry_secs: u32, + /// The hex-encoded 32-byte payment hash to use for the invoice. + #[prost(string, tag = "4")] + pub payment_hash: ::prost::alloc::string::String, +} +/// The response `content` for the `Bolt11ReceiveForHash` 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 Bolt11ReceiveForHashResponse { + /// An invoice for a payment within the Lightning Network. + /// With the details of the invoice, the sender has all the data necessary to send a payment + /// to the recipient. + #[prost(string, tag = "1")] + pub invoice: ::prost::alloc::string::String, +} +/// Manually claim a payment for a given payment hash with the corresponding preimage. +/// This should be used to claim payments created via `Bolt11ReceiveForHash`. +/// 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 Bolt11ClaimForHashRequest { + /// The hex-encoded 32-byte payment hash. + /// If provided, it will be used to verify that the preimage matches. + #[prost(string, optional, tag = "1")] + pub payment_hash: ::core::option::Option<::prost::alloc::string::String>, + /// The amount in millisatoshi that is claimable. + /// If not provided, skips amount verification. + #[prost(uint64, optional, tag = "2")] + pub claimable_amount_msat: ::core::option::Option, + /// The hex-encoded 32-byte payment preimage. + #[prost(string, tag = "3")] + pub preimage: ::prost::alloc::string::String, +} +/// The response `content` for the `Bolt11ClaimForHash` 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 Bolt11ClaimForHashResponse {} +/// Manually fail a payment for a given payment hash. +/// This should be used to reject payments created via `Bolt11ReceiveForHash`. +/// 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 Bolt11FailForHashRequest { + /// The hex-encoded 32-byte payment hash. + #[prost(string, tag = "1")] + pub payment_hash: ::prost::alloc::string::String, +} +/// The response `content` for the `Bolt11FailForHash` 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 Bolt11FailForHashResponse {} /// Send a payment for a BOLT11 invoice. /// 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 d47f5c5..249e19d 100644 --- a/ldk-server-protos/src/endpoints.rs +++ b/ldk-server-protos/src/endpoints.rs @@ -12,6 +12,9 @@ pub const GET_BALANCES_PATH: &str = "GetBalances"; pub const ONCHAIN_RECEIVE_PATH: &str = "OnchainReceive"; pub const ONCHAIN_SEND_PATH: &str = "OnchainSend"; pub const BOLT11_RECEIVE_PATH: &str = "Bolt11Receive"; +pub const BOLT11_RECEIVE_FOR_HASH_PATH: &str = "Bolt11ReceiveForHash"; +pub const BOLT11_CLAIM_FOR_HASH_PATH: &str = "Bolt11ClaimForHash"; +pub const BOLT11_FAIL_FOR_HASH_PATH: &str = "Bolt11FailForHash"; pub const BOLT11_SEND_PATH: &str = "Bolt11Send"; pub const BOLT12_RECEIVE_PATH: &str = "Bolt12Receive"; pub const BOLT12_SEND_PATH: &str = "Bolt12Send"; diff --git a/ldk-server-protos/src/events.rs b/ldk-server-protos/src/events.rs index 08a605b..a41446a 100644 --- a/ldk-server-protos/src/events.rs +++ b/ldk-server-protos/src/events.rs @@ -13,7 +13,7 @@ #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct EventEnvelope { - #[prost(oneof = "event_envelope::Event", tags = "2, 3, 4, 6")] + #[prost(oneof = "event_envelope::Event", tags = "2, 3, 4, 6, 7")] pub event: ::core::option::Option, } /// Nested message and enum types in `EventEnvelope`. @@ -31,6 +31,8 @@ pub mod event_envelope { PaymentFailed(super::PaymentFailed), #[prost(message, tag = "6")] PaymentForwarded(super::PaymentForwarded), + #[prost(message, tag = "7")] + PaymentClaimable(super::PaymentClaimable), } } /// PaymentReceived indicates a payment has been received. @@ -63,6 +65,17 @@ pub struct PaymentFailed { #[prost(message, optional, tag = "1")] pub payment: ::core::option::Option, } +/// PaymentClaimable indicates a payment has arrived and is waiting to be manually claimed or failed. +/// This event is only emitted for payments created via `Bolt11ReceiveForHash`. +#[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 PaymentClaimable { + /// The payment details for the claimable payment. + #[prost(message, optional, tag = "1")] + pub payment: ::core::option::Option, +} /// PaymentForwarded indicates a payment was forwarded through the node. #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] diff --git a/ldk-server-protos/src/proto/api.proto b/ldk-server-protos/src/proto/api.proto index a841bc0..a69f3a0 100644 --- a/ldk-server-protos/src/proto/api.proto +++ b/ldk-server-protos/src/proto/api.proto @@ -148,6 +148,73 @@ message Bolt11ReceiveResponse { string invoice = 1; } +// Return a BOLT11 payable invoice for a given payment hash. +// The inbound payment will NOT be automatically claimed upon arrival. +// Instead, the payment will need to be manually claimed by calling `Bolt11ClaimForHash` +// or manually failed by calling `Bolt11FailForHash`. +// See more: +// - https://docs.rs/ldk-node/latest/ldk_node/payment/struct.Bolt11Payment.html#method.receive_for_hash +// - https://docs.rs/ldk-node/latest/ldk_node/payment/struct.Bolt11Payment.html#method.receive_variable_amount_for_hash +message Bolt11ReceiveForHashRequest { + + // The amount in millisatoshi to receive. If unset, a "zero-amount" or variable-amount invoice is returned. + optional uint64 amount_msat = 1; + + // An optional description to attach along with the invoice. + // Will be set in the description field of the encoded payment request. + types.Bolt11InvoiceDescription description = 2; + + // Invoice expiry time in seconds. + uint32 expiry_secs = 3; + + // The hex-encoded 32-byte payment hash to use for the invoice. + string payment_hash = 4; +} + +// The response `content` for the `Bolt11ReceiveForHash` API, when HttpStatusCode is OK (200). +// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +message Bolt11ReceiveForHashResponse { + + // An invoice for a payment within the Lightning Network. + // With the details of the invoice, the sender has all the data necessary to send a payment + // to the recipient. + string invoice = 1; +} + +// Manually claim a payment for a given payment hash with the corresponding preimage. +// This should be used to claim payments created via `Bolt11ReceiveForHash`. +// See more: https://docs.rs/ldk-node/latest/ldk_node/payment/struct.Bolt11Payment.html#method.claim_for_hash +message Bolt11ClaimForHashRequest { + + // The hex-encoded 32-byte payment hash. + // If provided, it will be used to verify that the preimage matches. + optional string payment_hash = 1; + + // The amount in millisatoshi that is claimable. + // If not provided, skips amount verification. + optional uint64 claimable_amount_msat = 2; + + // The hex-encoded 32-byte payment preimage. + string preimage = 3; +} + +// The response `content` for the `Bolt11ClaimForHash` API, when HttpStatusCode is OK (200). +// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +message Bolt11ClaimForHashResponse {} + +// Manually fail a payment for a given payment hash. +// This should be used to reject payments created via `Bolt11ReceiveForHash`. +// See more: https://docs.rs/ldk-node/latest/ldk_node/payment/struct.Bolt11Payment.html#method.fail_for_hash +message Bolt11FailForHashRequest { + + // The hex-encoded 32-byte payment hash. + string payment_hash = 1; +} + +// The response `content` for the `Bolt11FailForHash` API, when HttpStatusCode is OK (200). +// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +message Bolt11FailForHashResponse {} + // Send a payment for a BOLT11 invoice. // See more: https://docs.rs/ldk-node/latest/ldk_node/payment/struct.Bolt11Payment.html#method.send message Bolt11SendRequest { diff --git a/ldk-server-protos/src/proto/events.proto b/ldk-server-protos/src/proto/events.proto index 19d2f5c..5c5ce2c 100644 --- a/ldk-server-protos/src/proto/events.proto +++ b/ldk-server-protos/src/proto/events.proto @@ -9,6 +9,7 @@ message EventEnvelope { PaymentSuccessful payment_successful = 3; PaymentFailed payment_failed = 4; PaymentForwarded payment_forwarded = 6; + PaymentClaimable payment_claimable = 7; } } @@ -30,6 +31,13 @@ message PaymentFailed { types.Payment payment = 1; } +// PaymentClaimable indicates a payment has arrived and is waiting to be manually claimed or failed. +// This event is only emitted for payments created via `Bolt11ReceiveForHash`. +message PaymentClaimable { + // The payment details for the claimable payment. + types.Payment payment = 1; +} + // PaymentForwarded indicates a payment was forwarded through the node. message PaymentForwarded { types.ForwardedPayment forwarded_payment = 1; diff --git a/ldk-server/src/api/bolt11_claim_for_hash.rs b/ldk-server/src/api/bolt11_claim_for_hash.rs new file mode 100644 index 0000000..e1e6228 --- /dev/null +++ b/ldk-server/src/api/bolt11_claim_for_hash.rs @@ -0,0 +1,47 @@ +// 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 hex::FromHex; +use ldk_node::bitcoin::hashes::{sha256, Hash}; +use ldk_node::lightning_types::payment::{PaymentHash, PaymentPreimage}; +use ldk_server_protos::api::{Bolt11ClaimForHashRequest, Bolt11ClaimForHashResponse}; + +use crate::api::error::LdkServerError; +use crate::api::error::LdkServerErrorCode::InvalidRequestError; +use crate::service::Context; + +pub(crate) fn handle_bolt11_claim_for_hash_request( + context: Context, request: Bolt11ClaimForHashRequest, +) -> Result { + let preimage_bytes = <[u8; 32]>::from_hex(&request.preimage).map_err(|_| { + LdkServerError::new( + InvalidRequestError, + "Invalid preimage, must be a 32-byte hex string.".to_string(), + ) + })?; + let preimage = PaymentPreimage(preimage_bytes); + + let payment_hash = if let Some(hash_hex) = &request.payment_hash { + let hash_bytes = <[u8; 32]>::from_hex(hash_hex).map_err(|_| { + LdkServerError::new( + InvalidRequestError, + "Invalid payment_hash, must be a 32-byte hex string.".to_string(), + ) + })?; + PaymentHash(hash_bytes) + } else { + PaymentHash(sha256::Hash::hash(&preimage.0).to_byte_array()) + }; + + let claimable_amount_msat = request.claimable_amount_msat.unwrap_or(u64::MAX); + + context.node.bolt11_payment().claim_for_hash(payment_hash, claimable_amount_msat, preimage)?; + + Ok(Bolt11ClaimForHashResponse {}) +} diff --git a/ldk-server/src/api/bolt11_fail_for_hash.rs b/ldk-server/src/api/bolt11_fail_for_hash.rs new file mode 100644 index 0000000..9f34916 --- /dev/null +++ b/ldk-server/src/api/bolt11_fail_for_hash.rs @@ -0,0 +1,32 @@ +// 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 hex::FromHex; +use ldk_node::lightning_types::payment::PaymentHash; +use ldk_server_protos::api::{Bolt11FailForHashRequest, Bolt11FailForHashResponse}; + +use crate::api::error::LdkServerError; +use crate::api::error::LdkServerErrorCode::InvalidRequestError; +use crate::service::Context; + +pub(crate) fn handle_bolt11_fail_for_hash_request( + context: Context, request: Bolt11FailForHashRequest, +) -> Result { + let hash_bytes = <[u8; 32]>::from_hex(&request.payment_hash).map_err(|_| { + LdkServerError::new( + InvalidRequestError, + "Invalid payment_hash, must be a 32-byte hex string.".to_string(), + ) + })?; + let payment_hash = PaymentHash(hash_bytes); + + context.node.bolt11_payment().fail_for_hash(payment_hash)?; + + Ok(Bolt11FailForHashResponse {}) +} diff --git a/ldk-server/src/api/bolt11_receive_for_hash.rs b/ldk-server/src/api/bolt11_receive_for_hash.rs new file mode 100644 index 0000000..8f68706 --- /dev/null +++ b/ldk-server/src/api/bolt11_receive_for_hash.rs @@ -0,0 +1,46 @@ +// 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 hex::FromHex; +use ldk_node::lightning_types::payment::PaymentHash; +use ldk_server_protos::api::{Bolt11ReceiveForHashRequest, Bolt11ReceiveForHashResponse}; + +use crate::api::error::LdkServerError; +use crate::api::error::LdkServerErrorCode::InvalidRequestError; +use crate::service::Context; +use crate::util::proto_adapter::proto_to_bolt11_description; + +pub(crate) fn handle_bolt11_receive_for_hash_request( + context: Context, request: Bolt11ReceiveForHashRequest, +) -> Result { + let description = proto_to_bolt11_description(request.description)?; + let hash_bytes = <[u8; 32]>::from_hex(&request.payment_hash).map_err(|_| { + LdkServerError::new( + InvalidRequestError, + "Invalid payment_hash, must be a 32-byte hex string.".to_string(), + ) + })?; + let payment_hash = PaymentHash(hash_bytes); + + let invoice = match request.amount_msat { + Some(amount_msat) => context.node.bolt11_payment().receive_for_hash( + amount_msat, + &description, + request.expiry_secs, + payment_hash, + )?, + None => context.node.bolt11_payment().receive_variable_amount_for_hash( + &description, + request.expiry_secs, + payment_hash, + )?, + }; + + Ok(Bolt11ReceiveForHashResponse { invoice: invoice.to_string() }) +} diff --git a/ldk-server/src/api/mod.rs b/ldk-server/src/api/mod.rs index f2ec6f9..1c4d489 100644 --- a/ldk-server/src/api/mod.rs +++ b/ldk-server/src/api/mod.rs @@ -14,7 +14,10 @@ use ldk_server_protos::types::channel_config::MaxDustHtlcExposure; use crate::api::error::LdkServerError; use crate::api::error::LdkServerErrorCode::InvalidRequestError; +pub(crate) mod bolt11_claim_for_hash; +pub(crate) mod bolt11_fail_for_hash; pub(crate) mod bolt11_receive; +pub(crate) mod bolt11_receive_for_hash; pub(crate) mod bolt11_send; pub(crate) mod bolt12_receive; pub(crate) mod bolt12_send; diff --git a/ldk-server/src/io/events/mod.rs b/ldk-server/src/io/events/mod.rs index f944b7e..4ec9a65 100644 --- a/ldk-server/src/io/events/mod.rs +++ b/ldk-server/src/io/events/mod.rs @@ -21,5 +21,6 @@ pub(crate) fn get_event_name(event: &event_envelope::Event) -> &'static str { event_envelope::Event::PaymentSuccessful(_) => "PaymentSuccessful", event_envelope::Event::PaymentFailed(_) => "PaymentFailed", event_envelope::Event::PaymentForwarded(_) => "PaymentForwarded", + event_envelope::Event::PaymentClaimable(_) => "PaymentClaimable", } } diff --git a/ldk-server/src/main.rs b/ldk-server/src/main.rs index fc5c429..589bca8 100644 --- a/ldk-server/src/main.rs +++ b/ldk-server/src/main.rs @@ -340,12 +340,13 @@ fn main() { Arc::clone(&paginated_store)).await; }, Event::PaymentClaimable {payment_id, ..} => { - if let Some(payment_details) = event_node.payment(&payment_id) { - let payment = payment_to_proto(payment_details); - upsert_payment_details(&event_node, Arc::clone(&paginated_store), &payment); - } else { - error!("Unable to find payment with paymentId: {payment_id}"); - } + publish_event_and_upsert_payment(&payment_id, + |payment_ref| event_envelope::Event::PaymentClaimable(events::PaymentClaimable { + payment: Some(payment_ref.clone()), + }), + &event_node, + Arc::clone(&event_publisher), + Arc::clone(&paginated_store)).await; }, Event::PaymentForwarded { prev_channel_id, diff --git a/ldk-server/src/service.rs b/ldk-server/src/service.rs index 3a43b63..ebfc649 100644 --- a/ldk-server/src/service.rs +++ b/ldk-server/src/service.rs @@ -19,6 +19,7 @@ use ldk_node::bitcoin::hashes::hmac::{Hmac, HmacEngine}; use ldk_node::bitcoin::hashes::{sha256, Hash, HashEngine}; use ldk_node::Node; use ldk_server_protos::endpoints::{ + BOLT11_CLAIM_FOR_HASH_PATH, BOLT11_FAIL_FOR_HASH_PATH, BOLT11_RECEIVE_FOR_HASH_PATH, BOLT11_RECEIVE_PATH, BOLT11_SEND_PATH, BOLT12_RECEIVE_PATH, BOLT12_SEND_PATH, CLOSE_CHANNEL_PATH, CONNECT_PEER_PATH, DISCONNECT_PEER_PATH, EXPORT_PATHFINDING_SCORES_PATH, FORCE_CLOSE_CHANNEL_PATH, GET_BALANCES_PATH, GET_NODE_INFO_PATH, GET_PAYMENT_DETAILS_PATH, @@ -29,7 +30,10 @@ use ldk_server_protos::endpoints::{ }; use prost::Message; +use crate::api::bolt11_claim_for_hash::handle_bolt11_claim_for_hash_request; +use crate::api::bolt11_fail_for_hash::handle_bolt11_fail_for_hash_request; use crate::api::bolt11_receive::handle_bolt11_receive_request; +use crate::api::bolt11_receive_for_hash::handle_bolt11_receive_for_hash_request; use crate::api::bolt11_send::handle_bolt11_send_request; use crate::api::bolt12_receive::handle_bolt12_receive_request; use crate::api::bolt12_send::handle_bolt12_send_request; @@ -218,6 +222,27 @@ impl Service> for NodeService { api_key, handle_bolt11_receive_request, )), + BOLT11_RECEIVE_FOR_HASH_PATH => Box::pin(handle_request( + context, + req, + auth_params, + api_key, + handle_bolt11_receive_for_hash_request, + )), + BOLT11_CLAIM_FOR_HASH_PATH => Box::pin(handle_request( + context, + req, + auth_params, + api_key, + handle_bolt11_claim_for_hash_request, + )), + BOLT11_FAIL_FOR_HASH_PATH => Box::pin(handle_request( + context, + req, + auth_params, + api_key, + handle_bolt11_fail_for_hash_request, + )), BOLT11_SEND_PATH => Box::pin(handle_request( context, req,