From 43b8d9f33532a48e22177d17ea7e923772591808 Mon Sep 17 00:00:00 2001 From: m1sterc001guy Date: Wed, 18 Mar 2026 10:54:22 -0500 Subject: [PATCH] feat: manually handle BOLT12 invoices --- src/builder.rs | 3 + src/config.rs | 16 ++++ src/event.rs | 59 ++++++++++++- src/lib.rs | 5 ++ src/payment/bolt12.rs | 144 +++++++++++++++++++++++++++++++- src/payment/mod.rs | 1 + tests/integration_tests_rust.rs | 97 +++++++++++++++++++++ 7 files changed, 321 insertions(+), 4 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 4a49212b8..037e6147c 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1883,6 +1883,8 @@ fn build_with_store_internal( let pathfinding_scores_sync_url = pathfinding_scores_sync_config.map(|c| c.url.clone()); + let pending_bolt12_invoice_contexts = Arc::new(Mutex::new(HashMap::new())); + #[cfg(cycle_tests)] let mut _leak_checker = crate::LeakChecker(Vec::new()); #[cfg(cycle_tests)] @@ -1930,6 +1932,7 @@ fn build_with_store_internal( hrn_resolver, #[cfg(cycle_tests)] _leak_checker, + pending_bolt12_invoice_contexts, }) } diff --git a/src/config.rs b/src/config.rs index 9dd5be5a5..60bad63ad 100644 --- a/src/config.rs +++ b/src/config.rs @@ -204,6 +204,20 @@ pub struct Config { /// /// **Note**: If unset, connecting to peer OnionV3 addresses will fail. pub tor_config: Option, + /// If set to `true`, BOLT12 invoices will not be paid automatically when received. Instead, an + /// [`Event::Bolt12InvoiceReceived`] event will be emitted, allowing inspection of the invoice + /// before explicitly paying via [`Bolt12Payment::send_payment_for_bolt12_invoice`] or + /// abandoning via [`Bolt12Payment::abandon_bolt12_invoice`]. + /// + /// **Note:** If the invoice is not paid or abandoned before the next LDK timer tick, the + /// payment will be timed out automatically. + /// + /// Default value: `false` + /// + /// [`Event::Bolt12InvoiceReceived`]: crate::Event::Bolt12InvoiceReceived + /// [`Bolt12Payment::send_payment_for_bolt12_invoice`]: crate::payment::Bolt12Payment::send_payment_for_bolt12_invoice + /// [`Bolt12Payment::abandon_bolt12_invoice`]: crate::payment::Bolt12Payment::abandon_bolt12_invoice + pub manually_handle_bolt12_invoices: bool, } impl Default for Config { @@ -219,6 +233,7 @@ impl Default for Config { tor_config: None, route_parameters: None, node_alias: None, + manually_handle_bolt12_invoices: false, } } } @@ -347,6 +362,7 @@ pub(crate) fn default_user_config(config: &Config) -> UserConfig { user_config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = config.anchor_channels_config.is_some(); user_config.reject_inbound_splices = false; + user_config.manually_handle_bolt12_invoices = config.manually_handle_bolt12_invoices; if may_announce_channel(config).is_err() { user_config.accept_forwards_to_priv_channels = false; diff --git a/src/event.rs b/src/event.rs index c4949a5ac..5930dac40 100644 --- a/src/event.rs +++ b/src/event.rs @@ -52,6 +52,7 @@ use crate::payment::asynchronous::static_invoice_store::StaticInvoiceStore; use crate::payment::store::{ PaymentDetails, PaymentDetailsUpdate, PaymentDirection, PaymentKind, PaymentStatus, }; +use crate::payment::PendingBolt12InvoiceContexts; use crate::runtime::Runtime; use crate::types::{ CustomTlvRecord, DynStore, KeysManager, OnionMessenger, PaymentStore, Sweeper, Wallet, @@ -275,6 +276,25 @@ pub enum Event { /// The outpoint of the channel's splice funding transaction, if one was created. abandoned_funding_txo: Option, }, + /// A BOLT12 invoice has been received, and is waiting to be paid or abandoned. + /// + /// This event will only be generated if [`Config::manually_handle_bolt12_invoices`] is set + /// to `true`. + /// + /// Call [`Bolt12Payment::send_payment_for_bolt12_invoice`] to pay the invoice or + /// [`Bolt12Payment::abandon_bolt12_invoice`] to abandon it. + /// + /// [`Config::manually_handle_bolt12_invoices`]: crate::config::Config::manually_handle_bolt12_invoices + /// [`Bolt12Payment::send_payment_for_bolt12_invoice`]: crate::payment::Bolt12Payment::send_payment_for_bolt12_invoice + /// [`Bolt12Payment::abandon_bolt12_invoice`]: crate::payment::Bolt12Payment::abandon_bolt12_invoice + Bolt12InvoiceReceived { + /// A local identifier used to track the payment. + payment_id: PaymentId, + /// The hash of the payment as specified in the invoice. + payment_hash: PaymentHash, + /// The amount in millisatoshis specified in the invoice. + amount_msat: u64, + }, } impl_writeable_tlv_based_enum!(Event, @@ -346,6 +366,11 @@ impl_writeable_tlv_based_enum!(Event, (5, user_channel_id, required), (7, abandoned_funding_txo, option), }, + (10, Bolt12InvoiceReceived) => { + (0, payment_id, required), + (2, payment_hash, required), + (4, amount_msat, required), + }, ); pub struct EventQueue @@ -515,6 +540,7 @@ where static_invoice_store: Option, onion_messenger: Arc, om_mailbox: Option>, + pending_bolt12_invoice_contexts: PendingBolt12InvoiceContexts, } impl EventHandler @@ -531,6 +557,7 @@ where keys_manager: Arc, static_invoice_store: Option, onion_messenger: Arc, om_mailbox: Option>, runtime: Arc, logger: L, config: Arc, + pending_bolt12_invoice_contexts: PendingBolt12InvoiceContexts, ) -> Self { Self { event_queue, @@ -550,6 +577,7 @@ where static_invoice_store, onion_messenger, om_mailbox, + pending_bolt12_invoice_contexts, } } @@ -1568,8 +1596,35 @@ where .await; } }, - LdkEvent::InvoiceReceived { .. } => { - debug_assert!(false, "We currently don't handle BOLT12 invoices manually, so this event should never be emitted."); + LdkEvent::InvoiceReceived { payment_id, invoice, context, .. } => { + let amount_msat = invoice.amount_msats(); + let payment_hash = invoice.payment_hash(); + log_info!( + self.logger, + "Received BOLT12 invoice for payment_id {} with amount {}msat for manual handling", + payment_id, + amount_msat, + ); + + self.pending_bolt12_invoice_contexts + .lock() + .unwrap() + .insert(payment_id, (invoice, context)); + + self.event_queue + .add_event(Event::Bolt12InvoiceReceived { + payment_id, + payment_hash, + amount_msat, + }) + .await + .unwrap_or_else(|e| { + log_error!( + self.logger, + "Failed to push Bolt12InvoiceReceived event: {}", + e + ); + }); }, LdkEvent::ConnectionNeeded { node_id, addresses } => { let spawn_logger = self.logger.clone(); diff --git a/src/lib.rs b/src/lib.rs index e3beb44db..09e333931 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -180,6 +180,7 @@ use types::{ pub use types::{ChannelDetails, CustomTlvRecord, PeerDetails, SyncAndAsyncKVStore, UserChannelId}; pub use vss_client; +use crate::payment::PendingBolt12InvoiceContexts; use crate::scoring::setup_background_pathfinding_scores_sync; use crate::wallet::FundingAmount; @@ -239,6 +240,7 @@ pub struct Node { om_mailbox: Option>, async_payments_role: Option, hrn_resolver: Arc, + pending_bolt12_invoice_contexts: PendingBolt12InvoiceContexts, #[cfg(cycle_tests)] _leak_checker: LeakChecker, } @@ -593,6 +595,7 @@ impl Node { Arc::clone(&self.runtime), Arc::clone(&self.logger), Arc::clone(&self.config), + Arc::clone(&self.pending_bolt12_invoice_contexts), )); // Setup background processing @@ -908,6 +911,7 @@ impl Node { Arc::clone(&self.is_running), Arc::clone(&self.logger), self.async_payments_role, + Arc::clone(&self.pending_bolt12_invoice_contexts), ) } @@ -924,6 +928,7 @@ impl Node { Arc::clone(&self.is_running), Arc::clone(&self.logger), self.async_payments_role, + Arc::clone(&self.pending_bolt12_invoice_contexts), )) } diff --git a/src/payment/bolt12.rs b/src/payment/bolt12.rs index 980e20696..b42a4b11c 100644 --- a/src/payment/bolt12.rs +++ b/src/payment/bolt12.rs @@ -9,11 +9,12 @@ //! //! [BOLT 12]: https://github.com/lightning/bolts/blob/master/12-offer-encoding.md +use std::collections::HashMap; use std::num::NonZeroU64; -use std::sync::{Arc, RwLock}; +use std::sync::{Arc, Mutex, RwLock}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use lightning::blinded_path::message::BlindedMessagePath; +use lightning::blinded_path::message::{BlindedMessagePath, OffersContext}; use lightning::ln::channelmanager::{OptionalOfferPaymentParams, PaymentId}; use lightning::ln::outbound_payment::Retry; use lightning::offers::offer::{Amount, Offer as LdkOffer, OfferFromHrn, Quantity}; @@ -51,6 +52,14 @@ type HumanReadableName = lightning::onion_message::dns_resolution::HumanReadable #[cfg(feature = "uniffi")] type HumanReadableName = Arc; +/// Holds a pending BOLT12 invoice and its associated context, for use with manual invoice +/// handling. See [`Config::manually_handle_bolt12_invoices`]. +/// +/// [`Config::manually_handle_bolt12_invoices`]: crate::config::Config::manually_handle_bolt12_invoices +pub(crate) type PendingBolt12InvoiceContexts = Arc< + Mutex)>>, +>; + /// A payment handler allowing to create and pay [BOLT 12] offers and refunds. /// /// Should be retrieved by calling [`Node::bolt12_payment`]. @@ -66,6 +75,7 @@ pub struct Bolt12Payment { is_running: Arc>, logger: Arc, async_payments_role: Option, + pending_bolt12_invoice_contexts: PendingBolt12InvoiceContexts, } impl Bolt12Payment { @@ -73,6 +83,7 @@ impl Bolt12Payment { channel_manager: Arc, keys_manager: Arc, payment_store: Arc, config: Arc, is_running: Arc>, logger: Arc, async_payments_role: Option, + pending_bolt12_invoice_contexts: PendingBolt12InvoiceContexts, ) -> Self { Self { channel_manager, @@ -82,6 +93,7 @@ impl Bolt12Payment { is_running, logger, async_payments_role, + pending_bolt12_invoice_contexts, } } @@ -577,6 +589,70 @@ impl Bolt12Payment { ) -> Result, Error> { self.blinded_paths_for_async_recipient_internal(recipient_id) } + + /// Pays a BOLT12 invoice that was previously received via an + /// [`Event::Bolt12InvoiceReceived`] event. + /// + /// This is only relevant when [`Config::manually_handle_bolt12_invoices`] is set to `true`. + /// + /// Returns an [`Error::InvalidPaymentId`] if no pending invoice is found for the given + /// `payment_id`. + /// + /// [`Event::Bolt12InvoiceReceived`]: crate::Event::Bolt12InvoiceReceived + /// [`Config::manually_handle_bolt12_invoices`]: crate::config::Config::manually_handle_bolt12_invoices + pub fn send_payment_for_bolt12_invoice(&self, payment_id: PaymentId) -> Result<(), Error> { + let (invoice, context) = self + .pending_bolt12_invoice_contexts + .lock() + .unwrap() + .remove(&payment_id) + .ok_or(Error::InvalidPaymentId)?; + + self.channel_manager.send_payment_for_bolt12_invoice(&invoice, context.as_ref()).map_err( + |e| { + log_error!(self.logger, "Failed to send payment for BOLT12 invoice: {:?}", e); + Error::PaymentSendingFailed + }, + )?; + + log_info!( + self.logger, + "Initiated payment for manually-handled BOLT12 invoice with payment_id {}", + payment_id + ); + Ok(()) + } + + /// Abandons a BOLT12 invoice that was previously received via an + /// [`Event::Bolt12InvoiceReceived`] event. + /// + /// This is only relevant when [`Config::manually_handle_bolt12_invoices`] is set to `true`. + /// Use this to reject an invoice you don't want to pay. This will result in an + /// [`Event::PaymentFailed`] being emitted. + /// + /// Returns an [`Error::InvalidPaymentId`] if no pending invoice is found for the given + /// `payment_id`. + /// + /// [`Event::Bolt12InvoiceReceived`]: crate::Event::Bolt12InvoiceReceived + /// [`Event::PaymentFailed`]: crate::Event::PaymentFailed + /// [`Config::manually_handle_bolt12_invoices`]: crate::config::Config::manually_handle_bolt12_invoices + pub fn abandon_bolt12_invoice(&self, payment_id: PaymentId) -> Result<(), Error> { + let _removed = self + .pending_bolt12_invoice_contexts + .lock() + .unwrap() + .remove(&payment_id) + .ok_or(Error::InvalidPaymentId)?; + + self.channel_manager.abandon_payment(payment_id); + + log_info!( + self.logger, + "Abandoned manually-handled BOLT12 invoice with payment_id {}", + payment_id + ); + Ok(()) + } } #[cfg(feature = "uniffi")] @@ -614,4 +690,68 @@ impl Bolt12Payment { paths.write(&mut bytes).or(Err(Error::InvalidBlindedPaths))?; Ok(bytes) } + + /// Pays a BOLT12 invoice that was previously received via an + /// [`Event::Bolt12InvoiceReceived`] event. + /// + /// This is only relevant when [`Config::manually_handle_bolt12_invoices`] is set to `true`. + /// + /// Returns an [`Error::InvalidPaymentId`] if no pending invoice is found for the given + /// `payment_id`. + /// + /// [`Event::Bolt12InvoiceReceived`]: crate::Event::Bolt12InvoiceReceived + /// [`Config::manually_handle_bolt12_invoices`]: crate::config::Config::manually_handle_bolt12_invoices + pub fn send_payment_for_bolt12_invoice(&self, payment_id: PaymentId) -> Result<(), Error> { + let (invoice, context) = self + .pending_bolt12_invoice_contexts + .lock() + .unwrap() + .remove(&payment_id) + .ok_or(Error::InvalidPaymentId)?; + + self.channel_manager.send_payment_for_bolt12_invoice(&invoice, context.as_ref()).map_err( + |e| { + log_error!(self.logger, "Failed to send payment for BOLT12 invoice: {:?}", e); + Error::PaymentSendingFailed + }, + )?; + + log_info!( + self.logger, + "Initiated payment for manually-handled BOLT12 invoice with payment_id {}", + payment_id + ); + Ok(()) + } + + /// Abandons a BOLT12 invoice that was previously received via an + /// [`Event::Bolt12InvoiceReceived`] event. + /// + /// This is only relevant when [`Config::manually_handle_bolt12_invoices`] is set to `true`. + /// Use this to reject an invoice you don't want to pay. This will result in an + /// [`Event::PaymentFailed`] being emitted. + /// + /// Returns an [`Error::InvalidPaymentId`] if no pending invoice is found for the given + /// `payment_id`. + /// + /// [`Event::Bolt12InvoiceReceived`]: crate::Event::Bolt12InvoiceReceived + /// [`Event::PaymentFailed`]: crate::Event::PaymentFailed + /// [`Config::manually_handle_bolt12_invoices`]: crate::config::Config::manually_handle_bolt12_invoices + pub fn abandon_bolt12_invoice(&self, payment_id: PaymentId) -> Result<(), Error> { + let _removed = self + .pending_bolt12_invoice_contexts + .lock() + .unwrap() + .remove(&payment_id) + .ok_or(Error::InvalidPaymentId)?; + + self.channel_manager.abandon_payment(payment_id); + + log_info!( + self.logger, + "Abandoned manually-handled BOLT12 invoice with payment_id {}", + payment_id + ); + Ok(()) + } } diff --git a/src/payment/mod.rs b/src/payment/mod.rs index 42b5aff3b..788d3e106 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -18,6 +18,7 @@ mod unified; pub use bolt11::Bolt11Payment; pub use bolt12::Bolt12Payment; +pub(crate) use bolt12::PendingBolt12InvoiceContexts; pub use onchain::OnchainPayment; pub use pending_payment_store::PendingPaymentDetails; pub use spontaneous::SpontaneousPayment; diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 3fde52dc4..9def2475a 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -2805,3 +2805,100 @@ async fn splice_in_with_all_balance() { node_a.stop().unwrap(); node_b.stop().unwrap(); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn bolt12_manual_invoice_handling() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + + // Node A: sender, with manually_handle_bolt12_invoices enabled + let mut config_a = random_config(true); + config_a.node_config.manually_handle_bolt12_invoices = true; + let node_a = setup_node(&chain_source, config_a); + + // Node B: receiver, normal config + let config_b = random_config(true); + let node_b = setup_node(&chain_source, config_b); + + let address_a = node_a.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 5_000_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a], + Amount::from_sat(premine_amount_sat), + ) + .await; + + node_a.sync_wallets().unwrap(); + open_channel(&node_a, &node_b, 4_000_000, true, &electrsd).await; + + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + // Wait for node announcement propagation + while node_b.status().latest_node_announcement_broadcast_timestamp.is_none() { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + // Test 1: Manual pay flow + let expected_amount_msat = 100_000_000; + let offer = + node_b.bolt12_payment().receive(expected_amount_msat, "manual test", None, None).unwrap(); + + let payment_id = node_a.bolt12_payment().send(&offer, None, None, None).unwrap(); + + // Should get Bolt12InvoiceReceived instead of PaymentSuccessful + let event = node_a.next_event_async().await; + match event { + Event::Bolt12InvoiceReceived { payment_id: evt_id, payment_hash, amount_msat } => { + assert_eq!(evt_id, payment_id); + assert_eq!(amount_msat, expected_amount_msat); + assert_ne!(payment_hash, PaymentHash([0u8; 32])); + node_a.event_handled().unwrap(); + }, + ref e => panic!("Expected Bolt12InvoiceReceived, got: {:?}", e), + } + + // Now explicitly pay the invoice + node_a.bolt12_payment().send_payment_for_bolt12_invoice(payment_id).unwrap(); + + // Should now get PaymentSuccessful + expect_payment_successful_event!(node_a, Some(payment_id), None); + + // Receiver should get the payment + expect_payment_received_event!(node_b, expected_amount_msat); + + // Test 2: Abandon flow + let offer2 = + node_b.bolt12_payment().receive(expected_amount_msat, "abandon test", None, None).unwrap(); + let payment_id2 = node_a.bolt12_payment().send(&offer2, None, None, None).unwrap(); + + let event = node_a.next_event_async().await; + match event { + Event::Bolt12InvoiceReceived { payment_id: evt_id, .. } => { + assert_eq!(evt_id, payment_id2); + node_a.event_handled().unwrap(); + }, + ref e => panic!("Expected Bolt12InvoiceReceived, got: {:?}", e), + } + + // Abandon instead of paying + node_a.bolt12_payment().abandon_bolt12_invoice(payment_id2).unwrap(); + + // Should get PaymentFailed + let event = node_a.next_event_async().await; + match event { + Event::PaymentFailed { payment_id: ref evt_id, .. } => { + assert_eq!(*evt_id, Some(payment_id2)); + node_a.event_handled().unwrap(); + }, + ref e => panic!("Expected PaymentFailed, got: {:?}", e), + } +}