diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index b9bab61e8..9f0ef697e 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -13,6 +13,7 @@ dictionary Config { u64 probing_liquidity_limit_multiplier; AnchorChannelsConfig? anchor_channels_config; RouteParametersConfig? route_parameters; + boolean async_payment_services_enabled; }; dictionary AnchorChannelsConfig { @@ -209,6 +210,12 @@ interface Bolt12Payment { Bolt12Invoice request_refund_payment([ByRef]Refund refund); [Throws=NodeError] Refund initiate_refund(u64 amount_msat, u32 expiry_secs, u64? quantity, string? payer_note); + [Throws=NodeError] + Offer receive_async(); + [Throws=NodeError] + void set_paths_to_static_invoice_server(bytes paths); + [Throws=NodeError] + bytes blinded_paths_for_async_recipient(bytes recipient_id); }; interface SpontaneousPayment { @@ -311,6 +318,8 @@ enum NodeError { "InsufficientFunds", "LiquiditySourceUnavailable", "LiquidityFeeTooHigh", + "InvalidBlindedPaths", + "AsyncPaymentServicesDisabled", }; dictionary NodeStatus { diff --git a/src/builder.rs b/src/builder.rs index a46b182e1..d330597ee 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1455,7 +1455,7 @@ fn build_with_store_internal( Arc::clone(&channel_manager), message_router, Arc::clone(&channel_manager), - IgnoringMessageHandler {}, + Arc::clone(&channel_manager), IgnoringMessageHandler {}, IgnoringMessageHandler {}, )); diff --git a/src/config.rs b/src/config.rs index 84f62d220..bb0bd56ba 100644 --- a/src/config.rs +++ b/src/config.rs @@ -179,6 +179,8 @@ pub struct Config { /// **Note:** If unset, default parameters will be used, and you will be able to override the /// parameters on a per-payment basis in the corresponding method calls. pub route_parameters: Option, + /// Whether to enable the static invoice service to support async payment reception for clients. + pub async_payment_services_enabled: bool, } impl Default for Config { @@ -193,6 +195,7 @@ impl Default for Config { anchor_channels_config: Some(AnchorChannelsConfig::default()), route_parameters: None, node_alias: None, + async_payment_services_enabled: false, } } } diff --git a/src/error.rs b/src/error.rs index 2cb71186d..eaa022e56 100644 --- a/src/error.rs +++ b/src/error.rs @@ -120,6 +120,10 @@ pub enum Error { LiquiditySourceUnavailable, /// The given operation failed due to the LSP's required opening fee being too high. LiquidityFeeTooHigh, + /// The given blinded paths are invalid. + InvalidBlindedPaths, + /// Asynchronous payment services are disabled. + AsyncPaymentServicesDisabled, } impl fmt::Display for Error { @@ -193,6 +197,10 @@ impl fmt::Display for Error { Self::LiquidityFeeTooHigh => { write!(f, "The given operation failed due to the LSP's required opening fee being too high.") }, + Self::InvalidBlindedPaths => write!(f, "The given blinded paths are invalid."), + Self::AsyncPaymentServicesDisabled => { + write!(f, "Asynchronous payment services are disabled.") + }, } } } diff --git a/src/event.rs b/src/event.rs index bad1b84ab..7a6dc4832 100644 --- a/src/event.rs +++ b/src/event.rs @@ -6,7 +6,6 @@ // accordance with one or both of these licenses. use crate::types::{CustomTlvRecord, DynStore, PaymentStore, Sweeper, Wallet}; - use crate::{ hex_utils, BumpTransactionEventHandler, ChannelManager, Error, Graph, PeerInfo, PeerStore, UserChannelId, @@ -19,6 +18,7 @@ use crate::fee_estimator::ConfirmationTarget; use crate::liquidity::LiquiditySource; use crate::logger::Logger; +use crate::payment::asynchronous::static_invoice_store::StaticInvoiceStore; use crate::payment::store::{ PaymentDetails, PaymentDetailsUpdate, PaymentDirection, PaymentKind, PaymentStatus, }; @@ -27,7 +27,7 @@ use crate::io::{ EVENT_QUEUE_PERSISTENCE_KEY, EVENT_QUEUE_PERSISTENCE_PRIMARY_NAMESPACE, EVENT_QUEUE_PERSISTENCE_SECONDARY_NAMESPACE, }; -use crate::logger::{log_debug, log_error, log_info, LdkLogger}; +use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger}; use crate::runtime::Runtime; @@ -458,6 +458,7 @@ where runtime: Arc, logger: L, config: Arc, + static_invoice_store: Option, } impl EventHandler @@ -470,8 +471,9 @@ where channel_manager: Arc, connection_manager: Arc>, output_sweeper: Arc, network_graph: Arc, liquidity_source: Option>>>, - payment_store: Arc, peer_store: Arc>, runtime: Arc, - logger: L, config: Arc, + payment_store: Arc, peer_store: Arc>, + static_invoice_store: Option, runtime: Arc, logger: L, + config: Arc, ) -> Self { Self { event_queue, @@ -487,6 +489,7 @@ where logger, runtime, config, + static_invoice_store, } } @@ -1494,11 +1497,55 @@ where LdkEvent::OnionMessagePeerConnected { .. } => { debug_assert!(false, "We currently don't support onion message interception, so this event should never be emitted."); }, - LdkEvent::PersistStaticInvoice { .. } => { - debug_assert!(false, "We currently don't support static invoice persistence, so this event should never be emitted."); + + LdkEvent::PersistStaticInvoice { + invoice, + invoice_slot, + recipient_id, + invoice_persisted_path, + } => { + if let Some(store) = self.static_invoice_store.as_ref() { + match store + .handle_persist_static_invoice(invoice, invoice_slot, recipient_id) + .await + { + Ok(_) => { + self.channel_manager.static_invoice_persisted(invoice_persisted_path); + }, + Err(e) => { + log_error!(self.logger, "Failed to persist static invoice: {}", e); + return Err(ReplayEvent()); + }, + }; + } }, - LdkEvent::StaticInvoiceRequested { .. } => { - debug_assert!(false, "We currently don't support static invoice persistence, so this event should never be emitted."); + LdkEvent::StaticInvoiceRequested { recipient_id, invoice_slot, reply_path } => { + if let Some(store) = self.static_invoice_store.as_ref() { + let invoice = + store.handle_static_invoice_requested(&recipient_id, invoice_slot).await; + + match invoice { + Ok(Some(invoice)) => { + if let Err(e) = + self.channel_manager.send_static_invoice(invoice, reply_path) + { + log_error!(self.logger, "Failed to send static invoice: {:?}", e); + } + }, + Ok(None) => { + log_trace!( + self.logger, + "No static invoice found for recipient {} and slot {}", + hex_utils::to_string(&recipient_id), + invoice_slot + ); + }, + Err(e) => { + log_error!(self.logger, "Failed to retrieve static invoice: {}", e); + return Err(ReplayEvent()); + }, + } + } }, LdkEvent::FundingTransactionReadyForSigning { .. } => { debug_assert!(false, "We currently don't support interactive-tx, so this event should never be emitted."); diff --git a/src/io/mod.rs b/src/io/mod.rs index 7a52a5c98..38fba5114 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -73,3 +73,8 @@ pub(crate) const BDK_WALLET_TX_GRAPH_KEY: &str = "tx_graph"; pub(crate) const BDK_WALLET_INDEXER_PRIMARY_NAMESPACE: &str = "bdk_wallet"; pub(crate) const BDK_WALLET_INDEXER_SECONDARY_NAMESPACE: &str = ""; pub(crate) const BDK_WALLET_INDEXER_KEY: &str = "indexer"; + +/// [`StaticInvoice`]s will be persisted under this key. +/// +/// [`StaticInvoice`]: lightning::offers::static_invoice::StaticInvoice +pub(crate) const STATIC_INVOICE_STORE_PRIMARY_NAMESPACE: &str = "static_invoices"; diff --git a/src/lib.rs b/src/lib.rs index 160762dd2..e7e27273b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -136,6 +136,7 @@ use gossip::GossipSource; use graph::NetworkGraph; use io::utils::write_node_metrics; use liquidity::{LSPS1Liquidity, LiquiditySource}; +use payment::asynchronous::static_invoice_store::StaticInvoiceStore; use payment::{ Bolt11Payment, Bolt12Payment, OnchainPayment, PaymentDetails, SpontaneousPayment, UnifiedQrPayment, @@ -498,6 +499,12 @@ impl Node { Arc::clone(&self.logger), )); + let static_invoice_store = if self.config.async_payment_services_enabled { + Some(StaticInvoiceStore::new(Arc::clone(&self.kv_store))) + } else { + None + }; + let event_handler = Arc::new(EventHandler::new( Arc::clone(&self.event_queue), Arc::clone(&self.wallet), @@ -509,6 +516,7 @@ impl Node { self.liquidity_source.clone(), Arc::clone(&self.payment_store), Arc::clone(&self.peer_store), + static_invoice_store, Arc::clone(&self.runtime), Arc::clone(&self.logger), Arc::clone(&self.config), @@ -818,6 +826,7 @@ impl Node { Bolt12Payment::new( Arc::clone(&self.channel_manager), Arc::clone(&self.payment_store), + Arc::clone(&self.config), Arc::clone(&self.is_running), Arc::clone(&self.logger), ) @@ -831,6 +840,7 @@ impl Node { Arc::new(Bolt12Payment::new( Arc::clone(&self.channel_manager), Arc::clone(&self.payment_store), + Arc::clone(&self.config), Arc::clone(&self.is_running), Arc::clone(&self.logger), )) diff --git a/src/payment/asynchronous/mod.rs b/src/payment/asynchronous/mod.rs new file mode 100644 index 000000000..ebb7a4bd3 --- /dev/null +++ b/src/payment/asynchronous/mod.rs @@ -0,0 +1,9 @@ +// 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. + +mod rate_limiter; +pub(crate) mod static_invoice_store; diff --git a/src/payment/asynchronous/rate_limiter.rs b/src/payment/asynchronous/rate_limiter.rs new file mode 100644 index 000000000..153577b16 --- /dev/null +++ b/src/payment/asynchronous/rate_limiter.rs @@ -0,0 +1,96 @@ +// 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. + +//! [`RateLimiter`] to control the rate of requests from users. + +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +/// Implements a leaky-bucket style rate limiter parameterized by the max capacity of the bucket, the refill interval, +/// and the max idle duration. +/// +/// For every passing of the refill interval, one token is added to the bucket, up to the maximum capacity. When the +/// bucket has remained at the maximum capacity for longer than the max idle duration, it is removed to prevent memory +/// leakage. +pub(crate) struct RateLimiter { + users: HashMap, Bucket>, + capacity: u32, + refill_interval: Duration, + max_idle: Duration, +} + +struct Bucket { + tokens: u32, + last_refill: Instant, +} + +impl RateLimiter { + pub(crate) fn new(capacity: u32, refill_interval: Duration, max_idle: Duration) -> Self { + Self { users: HashMap::new(), capacity, refill_interval, max_idle } + } + + pub(crate) fn allow(&mut self, user_id: &[u8]) -> bool { + let now = Instant::now(); + + let entry = self.users.entry(user_id.to_vec()); + let is_new_user = matches!(entry, std::collections::hash_map::Entry::Vacant(_)); + + let bucket = entry.or_insert(Bucket { tokens: self.capacity, last_refill: now }); + + let elapsed = now.duration_since(bucket.last_refill); + let tokens_to_add = (elapsed.as_secs_f64() / self.refill_interval.as_secs_f64()) as u32; + + if tokens_to_add > 0 { + bucket.tokens = (bucket.tokens + tokens_to_add).min(self.capacity); + bucket.last_refill = now; + } + + let allow = if bucket.tokens > 0 { + bucket.tokens -= 1; + true + } else { + false + }; + + // Each time a new user is added, we take the opportunity to clean up old rate limits. + if is_new_user { + self.garbage_collect(self.max_idle); + } + + allow + } + + fn garbage_collect(&mut self, max_idle: Duration) { + let now = Instant::now(); + self.users.retain(|_, bucket| now.duration_since(bucket.last_refill) < max_idle); + } +} + +#[cfg(test)] +mod tests { + use crate::payment::asynchronous::rate_limiter::RateLimiter; + + use std::time::Duration; + + #[test] + fn rate_limiter_test() { + // Test + let mut rate_limiter = + RateLimiter::new(3, Duration::from_millis(100), Duration::from_secs(1)); + + assert!(rate_limiter.allow(b"user1")); + assert!(rate_limiter.allow(b"user1")); + assert!(rate_limiter.allow(b"user1")); + assert!(!rate_limiter.allow(b"user1")); + assert!(rate_limiter.allow(b"user2")); + + std::thread::sleep(Duration::from_millis(150)); + + assert!(rate_limiter.allow(b"user1")); + assert!(rate_limiter.allow(b"user2")); + } +} diff --git a/src/payment/asynchronous/static_invoice_store.rs b/src/payment/asynchronous/static_invoice_store.rs new file mode 100644 index 000000000..eed6720e5 --- /dev/null +++ b/src/payment/asynchronous/static_invoice_store.rs @@ -0,0 +1,277 @@ +// 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. + +//! Store implementation for [`StaticInvoice`]s. + +use crate::hex_utils; +use crate::io::STATIC_INVOICE_STORE_PRIMARY_NAMESPACE; +use crate::payment::asynchronous::rate_limiter::RateLimiter; +use crate::types::DynStore; + +use bitcoin::hashes::sha256::Hash as Sha256; +use bitcoin::hashes::Hash; + +use lightning::{offers::static_invoice::StaticInvoice, util::ser::Writeable}; + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +pub(crate) struct StaticInvoiceStore { + kv_store: Arc, + request_rate_limiter: Mutex, + persist_rate_limiter: Mutex, +} + +impl StaticInvoiceStore { + const RATE_LIMITER_BUCKET_CAPACITY: u32 = 5; + const RATE_LIMITER_REFILL_INTERVAL: Duration = Duration::from_millis(100); + const RATE_LIMITER_MAX_IDLE: Duration = Duration::from_secs(600); + + pub(crate) fn new(kv_store: Arc) -> Self { + Self { + kv_store, + request_rate_limiter: Mutex::new(RateLimiter::new( + Self::RATE_LIMITER_BUCKET_CAPACITY, + Self::RATE_LIMITER_REFILL_INTERVAL, + Self::RATE_LIMITER_MAX_IDLE, + )), + persist_rate_limiter: Mutex::new(RateLimiter::new( + Self::RATE_LIMITER_BUCKET_CAPACITY, + Self::RATE_LIMITER_REFILL_INTERVAL, + Self::RATE_LIMITER_MAX_IDLE, + )), + } + } + + fn check_rate_limit( + limiter: &Mutex, recipient_id: &[u8], + ) -> Result<(), lightning::io::Error> { + let mut limiter = limiter.lock().unwrap(); + if !limiter.allow(recipient_id) { + Err(lightning::io::Error::new(lightning::io::ErrorKind::Other, "Rate limit exceeded")) + } else { + Ok(()) + } + } + + pub(crate) async fn handle_static_invoice_requested( + &self, recipient_id: &[u8], invoice_slot: u16, + ) -> Result, lightning::io::Error> { + Self::check_rate_limit(&self.request_rate_limiter, &recipient_id)?; + + let (secondary_namespace, key) = Self::get_storage_location(invoice_slot, recipient_id); + + self.kv_store + .read(STATIC_INVOICE_STORE_PRIMARY_NAMESPACE, &secondary_namespace, &key) + .and_then(|data| { + data.try_into().map(Some).map_err(|e| { + lightning::io::Error::new( + lightning::io::ErrorKind::InvalidData, + format!("Failed to parse static invoice: {:?}", e), + ) + }) + }) + .or_else( + |e| { + if e.kind() == lightning::io::ErrorKind::NotFound { + Ok(None) + } else { + Err(e) + } + }, + ) + } + + pub(crate) async fn handle_persist_static_invoice( + &self, invoice: StaticInvoice, invoice_slot: u16, recipient_id: Vec, + ) -> Result<(), lightning::io::Error> { + Self::check_rate_limit(&self.persist_rate_limiter, &recipient_id)?; + + let (secondary_namespace, key) = Self::get_storage_location(invoice_slot, &recipient_id); + + let mut buf = Vec::new(); + invoice.write(&mut buf)?; + + // Static invoices will be persisted at "static_invoices//". + // + // Example: static_invoices/039058c6f2c0cb492c533b0a4d14ef77cc0f78abccced5287d84a1a2011cfb81/00001 + self.kv_store.write(STATIC_INVOICE_STORE_PRIMARY_NAMESPACE, &secondary_namespace, &key, buf) + } + + fn get_storage_location(invoice_slot: u16, recipient_id: &[u8]) -> (String, String) { + let hash = Sha256::hash(recipient_id).to_byte_array(); + let secondary_namespace = hex_utils::to_string(&hash); + + let key = format!("{:05}", invoice_slot); + (secondary_namespace, key) + } +} + +#[cfg(test)] +mod tests { + use std::{sync::Arc, time::Duration}; + + use bitcoin::{ + key::{Keypair, Secp256k1}, + secp256k1::{PublicKey, SecretKey}, + }; + use lightning::blinded_path::{ + message::BlindedMessagePath, + payment::{BlindedPayInfo, BlindedPaymentPath}, + BlindedHop, + }; + use lightning::ln::inbound_payment::ExpandedKey; + use lightning::offers::{ + nonce::Nonce, + offer::OfferBuilder, + static_invoice::{StaticInvoice, StaticInvoiceBuilder}, + }; + use lightning::sign::EntropySource; + use lightning::util::test_utils::TestStore; + use lightning_types::features::BlindedHopFeatures; + + use crate::payment::asynchronous::static_invoice_store::StaticInvoiceStore; + use crate::types::DynStore; + + #[tokio::test] + async fn static_invoice_store_test() { + let store: Arc = Arc::new(TestStore::new(false)); + let static_invoice_store = StaticInvoiceStore::new(Arc::clone(&store)); + + let static_invoice = invoice(); + let recipient_id = vec![1, 1, 1]; + assert!(static_invoice_store + .handle_persist_static_invoice(static_invoice.clone(), 0, recipient_id.clone()) + .await + .is_ok()); + + let requested_invoice = + static_invoice_store.handle_static_invoice_requested(&recipient_id, 0).await.unwrap(); + + assert_eq!(requested_invoice.unwrap(), static_invoice); + + assert!(static_invoice_store + .handle_static_invoice_requested(&recipient_id, 1) + .await + .unwrap() + .is_none()); + + assert!(static_invoice_store + .handle_static_invoice_requested(&[2, 2, 2], 0) + .await + .unwrap() + .is_none()); + } + + fn invoice() -> StaticInvoice { + let node_id = recipient_pubkey(); + let payment_paths = payment_paths(); + let now = now(); + let expanded_key = ExpandedKey::new([42; 32]); + let entropy = FixedEntropy {}; + let nonce = Nonce::from_entropy_source(&entropy); + let secp_ctx = Secp256k1::new(); + + let offer = OfferBuilder::deriving_signing_pubkey(node_id, &expanded_key, nonce, &secp_ctx) + .path(blinded_path()) + .build() + .unwrap(); + + StaticInvoiceBuilder::for_offer_using_derived_keys( + &offer, + payment_paths.clone(), + vec![blinded_path()], + now, + &expanded_key, + nonce, + &secp_ctx, + ) + .unwrap() + .build_and_sign(&secp_ctx) + .unwrap() + } + + fn now() -> Duration { + std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .expect("SystemTime::now() should come after SystemTime::UNIX_EPOCH") + } + + fn payment_paths() -> Vec { + vec![ + BlindedPaymentPath::from_blinded_path_and_payinfo( + pubkey(40), + pubkey(41), + vec![ + BlindedHop { blinded_node_id: pubkey(43), encrypted_payload: vec![0; 43] }, + BlindedHop { blinded_node_id: pubkey(44), encrypted_payload: vec![0; 44] }, + ], + BlindedPayInfo { + fee_base_msat: 1, + fee_proportional_millionths: 1_000, + cltv_expiry_delta: 42, + htlc_minimum_msat: 100, + htlc_maximum_msat: 1_000_000_000_000, + features: BlindedHopFeatures::empty(), + }, + ), + BlindedPaymentPath::from_blinded_path_and_payinfo( + pubkey(40), + pubkey(41), + vec![ + BlindedHop { blinded_node_id: pubkey(45), encrypted_payload: vec![0; 45] }, + BlindedHop { blinded_node_id: pubkey(46), encrypted_payload: vec![0; 46] }, + ], + BlindedPayInfo { + fee_base_msat: 1, + fee_proportional_millionths: 1_000, + cltv_expiry_delta: 42, + htlc_minimum_msat: 100, + htlc_maximum_msat: 1_000_000_000_000, + features: BlindedHopFeatures::empty(), + }, + ), + ] + } + + fn blinded_path() -> BlindedMessagePath { + BlindedMessagePath::from_blinded_path( + pubkey(40), + pubkey(41), + vec![ + BlindedHop { blinded_node_id: pubkey(42), encrypted_payload: vec![0; 43] }, + BlindedHop { blinded_node_id: pubkey(43), encrypted_payload: vec![0; 44] }, + ], + ) + } + + fn pubkey(byte: u8) -> PublicKey { + let secp_ctx = Secp256k1::new(); + PublicKey::from_secret_key(&secp_ctx, &privkey(byte)) + } + + fn privkey(byte: u8) -> SecretKey { + SecretKey::from_slice(&[byte; 32]).unwrap() + } + + fn recipient_keys() -> Keypair { + let secp_ctx = Secp256k1::new(); + Keypair::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[43; 32]).unwrap()) + } + + fn recipient_pubkey() -> PublicKey { + recipient_keys().public_key() + } + + struct FixedEntropy; + + impl EntropySource for FixedEntropy { + fn get_secure_random_bytes(&self) -> [u8; 32] { + [42; 32] + } + } +} diff --git a/src/payment/bolt12.rs b/src/payment/bolt12.rs index 4e968deb7..81349e2bd 100644 --- a/src/payment/bolt12.rs +++ b/src/payment/bolt12.rs @@ -9,18 +9,21 @@ //! //! [BOLT 12]: https://github.com/lightning/bolts/blob/master/12-offer-encoding.md -use crate::config::LDK_PAYMENT_RETRY_TIMEOUT; +use crate::config::{Config, LDK_PAYMENT_RETRY_TIMEOUT}; use crate::error::Error; use crate::ffi::{maybe_deref, maybe_wrap}; use crate::logger::{log_error, log_info, LdkLogger, Logger}; use crate::payment::store::{PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus}; use crate::types::{ChannelManager, PaymentStore}; +use lightning::blinded_path::message::BlindedMessagePath; use lightning::ln::channelmanager::{PaymentId, Retry}; use lightning::offers::offer::{Amount, Offer as LdkOffer, Quantity}; use lightning::offers::parse::Bolt12SemanticError; use lightning::routing::router::RouteParametersConfig; +#[cfg(feature = "uniffi")] +use lightning::util::ser::{Readable, Writeable}; use lightning_types::string::UntrustedString; use rand::RngCore; @@ -54,15 +57,16 @@ pub struct Bolt12Payment { channel_manager: Arc, payment_store: Arc, is_running: Arc>, + config: Arc, logger: Arc, } impl Bolt12Payment { pub(crate) fn new( channel_manager: Arc, payment_store: Arc, - is_running: Arc>, logger: Arc, + config: Arc, is_running: Arc>, logger: Arc, ) -> Self { - Self { channel_manager, payment_store, is_running, logger } + Self { channel_manager, payment_store, config, is_running, logger } } /// Send a payment given an offer. @@ -450,4 +454,99 @@ impl Bolt12Payment { Ok(maybe_wrap(refund)) } + + /// Retrieve an [`Offer`] for receiving async payments as an often-offline recipient. + /// + /// Will only return an offer if [`Bolt12Payment::set_paths_to_static_invoice_server`] was called and we succeeded + /// in interactively building a [`StaticInvoice`] with the static invoice server. + /// + /// Useful for posting offers to receive payments later, such as posting an offer on a website. + /// + /// **Caution**: Async payments support is considered experimental. + /// + /// [`StaticInvoice`]: lightning::offers::static_invoice::StaticInvoice + /// [`Offer`]: lightning::offers::offer::Offer + pub fn receive_async(&self) -> Result { + self.channel_manager + .get_async_receive_offer() + .map(maybe_wrap) + .or(Err(Error::OfferCreationFailed)) + } + + /// Sets the [`BlindedMessagePath`]s that we will use as an async recipient to interactively build [`Offer`]s with a + /// static invoice server, so the server can serve [`StaticInvoice`]s to payers on our behalf when we're offline. + /// + /// **Caution**: Async payments support is considered experimental. + /// + /// [`Offer`]: lightning::offers::offer::Offer + /// [`StaticInvoice`]: lightning::offers::static_invoice::StaticInvoice + #[cfg(not(feature = "uniffi"))] + pub fn set_paths_to_static_invoice_server( + &self, paths: Vec, + ) -> Result<(), Error> { + self.channel_manager + .set_paths_to_static_invoice_server(paths) + .or(Err(Error::InvalidBlindedPaths)) + } + + /// Sets the [`BlindedMessagePath`]s that we will use as an async recipient to interactively build [`Offer`]s with a + /// static invoice server, so the server can serve [`StaticInvoice`]s to payers on our behalf when we're offline. + /// + /// **Caution**: Async payments support is considered experimental. + /// + /// [`Offer`]: lightning::offers::offer::Offer + /// [`StaticInvoice`]: lightning::offers::static_invoice::StaticInvoice + #[cfg(feature = "uniffi")] + pub fn set_paths_to_static_invoice_server(&self, paths: Vec) -> Result<(), Error> { + let decoded_paths = as Readable>::read(&mut &paths[..]) + .or(Err(Error::InvalidBlindedPaths))?; + + self.channel_manager + .set_paths_to_static_invoice_server(decoded_paths) + .or(Err(Error::InvalidBlindedPaths)) + } + + /// [`BlindedMessagePath`]s for an async recipient to communicate with this node and interactively + /// build [`Offer`]s and [`StaticInvoice`]s for receiving async payments. + /// + /// **Caution**: Async payments support is considered experimental. + /// + /// [`Offer`]: lightning::offers::offer::Offer + /// [`StaticInvoice`]: lightning::offers::static_invoice::StaticInvoice + #[cfg(not(feature = "uniffi"))] + pub fn blinded_paths_for_async_recipient( + &self, recipient_id: Vec, + ) -> Result, Error> { + self.blinded_paths_for_async_recipient_internal(recipient_id) + } + + /// [`BlindedMessagePath`]s for an async recipient to communicate with this node and interactively + /// build [`Offer`]s and [`StaticInvoice`]s for receiving async payments. + /// + /// **Caution**: Async payments support is considered experimental. + /// + /// [`Offer`]: lightning::offers::offer::Offer + /// [`StaticInvoice`]: lightning::offers::static_invoice::StaticInvoice + #[cfg(feature = "uniffi")] + pub fn blinded_paths_for_async_recipient( + &self, recipient_id: Vec, + ) -> Result, Error> { + let paths = self.blinded_paths_for_async_recipient_internal(recipient_id)?; + + let mut bytes = Vec::new(); + paths.write(&mut bytes).or(Err(Error::InvalidBlindedPaths))?; + Ok(bytes) + } + + fn blinded_paths_for_async_recipient_internal( + &self, recipient_id: Vec, + ) -> Result, Error> { + if !self.config.async_payment_services_enabled { + return Err(Error::AsyncPaymentServicesDisabled); + } + + self.channel_manager + .blinded_paths_for_async_recipient(recipient_id, None) + .or(Err(Error::InvalidBlindedPaths)) + } } diff --git a/src/payment/mod.rs b/src/payment/mod.rs index 54f7894dc..f629960e1 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -7,6 +7,7 @@ //! Objects for different types of payments. +pub(crate) mod asynchronous; mod bolt11; mod bolt12; mod onchain; diff --git a/src/types.rs b/src/types.rs index b9bc1c317..3635badff 100644 --- a/src/types.rs +++ b/src/types.rs @@ -123,7 +123,7 @@ pub(crate) type OnionMessenger = lightning::onion_message::messenger::OnionMesse Arc, Arc, Arc, - IgnoringMessageHandler, + Arc, IgnoringMessageHandler, IgnoringMessageHandler, >; diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index fa88fe0cc..77f46091d 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -1130,6 +1130,101 @@ fn simple_bolt12_send_receive() { assert_eq!(node_a_payments.first().unwrap().amount_msat, Some(overpaid_amount)); } +#[test] +fn static_invoice_server() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + let config_sender = random_config(true); + let node_sender = setup_node(&chain_source, config_sender, None); + + let config_sender_lsp = random_config(true); + let node_sender_lsp = setup_node(&chain_source, config_sender_lsp, None); + + let mut config_receiver_lsp = random_config(true); + config_receiver_lsp.node_config.async_payment_services_enabled = true; + let node_receiver_lsp = setup_node(&chain_source, config_receiver_lsp, None); + + let config_receiver = random_config(true); + let node_receiver = setup_node(&chain_source, config_receiver, None); + + let address_sender = node_sender.onchain_payment().new_address().unwrap(); + let address_sender_lsp = node_sender_lsp.onchain_payment().new_address().unwrap(); + let address_receiver_lsp = node_receiver_lsp.onchain_payment().new_address().unwrap(); + let address_receiver = node_receiver.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 4_000_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_sender, address_sender_lsp, address_receiver_lsp, address_receiver], + Amount::from_sat(premine_amount_sat), + ); + + node_sender.sync_wallets().unwrap(); + node_sender_lsp.sync_wallets().unwrap(); + node_receiver_lsp.sync_wallets().unwrap(); + node_receiver.sync_wallets().unwrap(); + + open_channel(&node_sender, &node_sender_lsp, 400_000, true, &electrsd); + open_channel(&node_sender_lsp, &node_receiver_lsp, 400_000, true, &electrsd); + open_channel(&node_receiver_lsp, &node_receiver, 400_000, true, &electrsd); + + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6); + + node_sender.sync_wallets().unwrap(); + node_sender_lsp.sync_wallets().unwrap(); + node_receiver_lsp.sync_wallets().unwrap(); + node_receiver.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_sender, node_sender_lsp.node_id()); + expect_channel_ready_event!(node_sender_lsp, node_sender.node_id()); + expect_channel_ready_event!(node_sender_lsp, node_receiver_lsp.node_id()); + expect_channel_ready_event!(node_receiver_lsp, node_sender_lsp.node_id()); + expect_channel_ready_event!(node_receiver_lsp, node_receiver.node_id()); + expect_channel_ready_event!(node_receiver, node_receiver_lsp.node_id()); + + let has_node_announcements = |node: &ldk_node::Node| { + node.network_graph() + .list_nodes() + .iter() + .filter(|n| { + node.network_graph().node(n).map_or(false, |info| info.announcement_info.is_some()) + }) + .count() >= 4 + }; + + // Wait for everyone to see all channels and node announcements. + while node_sender.network_graph().list_channels().len() < 3 + || node_sender_lsp.network_graph().list_channels().len() < 3 + || node_receiver_lsp.network_graph().list_channels().len() < 3 + || node_receiver.network_graph().list_channels().len() < 3 + || !has_node_announcements(&node_sender) + || !has_node_announcements(&node_sender_lsp) + || !has_node_announcements(&node_receiver_lsp) + || !has_node_announcements(&node_receiver) + { + std::thread::sleep(std::time::Duration::from_millis(100)); + } + + let recipient_id = vec![1, 2, 3]; + let blinded_paths = + node_receiver_lsp.bolt12_payment().blinded_paths_for_async_recipient(recipient_id).unwrap(); + node_receiver.bolt12_payment().set_paths_to_static_invoice_server(blinded_paths).unwrap(); + + let offer = loop { + if let Ok(offer) = node_receiver.bolt12_payment().receive_async() { + break offer; + } + + std::thread::sleep(std::time::Duration::from_millis(100)); + }; + + let payment_id = + node_sender.bolt12_payment().send_using_amount(&offer, 5_000, None, None).unwrap(); + + expect_payment_successful_event!(node_sender, Some(payment_id), None); +} + #[test] fn test_node_announcement_propagation() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();