-
Notifications
You must be signed in to change notification settings - Fork 417
Add liquidity checks and improve payment error handling #3175
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b2c3ddb
e7edc86
2f029a9
61f7348
5c8be84
6f9ac11
d38acd3
7dfcbc5
f6f752f
15653e8
b22a843
7b465f9
f15db05
35e3daa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -67,7 +67,7 @@ use crate::offers::invoice::{Bolt12Invoice, DEFAULT_RELATIVE_EXPIRY, DerivedSign | |
use crate::offers::invoice_error::InvoiceError; | ||
use crate::offers::invoice_request::{DerivedPayerSigningPubkey, InvoiceRequest, InvoiceRequestBuilder}; | ||
use crate::offers::nonce::Nonce; | ||
use crate::offers::offer::{Offer, OfferBuilder}; | ||
use crate::offers::offer::{Amount, Offer, OfferBuilder}; | ||
use crate::offers::parse::Bolt12SemanticError; | ||
use crate::offers::refund::{Refund, RefundBuilder}; | ||
use crate::offers::signer; | ||
|
@@ -1844,10 +1844,9 @@ where | |
/// | ||
/// ``` | ||
/// # use lightning::events::{Event, EventsProvider, PaymentPurpose}; | ||
/// # use lightning::ln::channelmanager::AChannelManager; | ||
/// # use lightning::offers::parse::Bolt12SemanticError; | ||
/// # use lightning::ln::channelmanager::{AChannelManager, Bolt12CreationError}; | ||
/// # | ||
/// # fn example<T: AChannelManager>(channel_manager: T) -> Result<(), Bolt12SemanticError> { | ||
/// # fn example<T: AChannelManager>(channel_manager: T) -> Result<(), Bolt12CreationError> { | ||
/// # let channel_manager = channel_manager.get_cm(); | ||
/// # let absolute_expiry = None; | ||
/// let offer = channel_manager | ||
|
@@ -1947,13 +1946,12 @@ where | |
/// ``` | ||
/// # use core::time::Duration; | ||
/// # use lightning::events::{Event, EventsProvider}; | ||
/// # use lightning::ln::channelmanager::{AChannelManager, PaymentId, RecentPaymentDetails, Retry}; | ||
/// # use lightning::offers::parse::Bolt12SemanticError; | ||
/// # use lightning::ln::channelmanager::{AChannelManager, Bolt12RequestError, PaymentId, RecentPaymentDetails, Retry}; | ||
/// # | ||
/// # fn example<T: AChannelManager>( | ||
/// # channel_manager: T, amount_msats: u64, absolute_expiry: Duration, retry: Retry, | ||
/// # max_total_routing_fee_msat: Option<u64> | ||
/// # ) -> Result<(), Bolt12SemanticError> { | ||
/// # ) -> Result<(), Bolt12RequestError> { | ||
/// # let channel_manager = channel_manager.get_cm(); | ||
/// let payment_id = PaymentId([42; 32]); | ||
/// let refund = channel_manager | ||
|
@@ -2685,6 +2683,40 @@ pub enum RecentPaymentDetails { | |
}, | ||
} | ||
|
||
/// Error during creation and handling of BOLT 12 related payments. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The roles of Bolt12CreationError, and Bolt12Response feels a little confusing to me. For exampleIt reads during creation and handling of BOLT 12 related payments but technically we are not creating any payment when we are creating an offer. Maybe we can simplify the enums, by introducing only one new enum called |
||
#[derive(Debug, Clone, PartialEq)] | ||
pub enum Bolt12CreationError { | ||
/// Error from BOLT 12 semantic checks. | ||
InvalidSemantics(Bolt12SemanticError), | ||
/// Failed to create a blinded path. | ||
BlindedPathCreationFailed, | ||
} | ||
|
||
impl From<Bolt12SemanticError> for Bolt12CreationError { | ||
fn from(err: Bolt12SemanticError) -> Self { | ||
Bolt12CreationError::InvalidSemantics(err) | ||
} | ||
} | ||
|
||
/// Error during requesting a BOLT 12 related payment. | ||
#[derive(Debug, Clone, PartialEq)] | ||
pub enum Bolt12RequestError { | ||
/// Error from BOLT 12 semantic checks. | ||
InvalidSemantics(Bolt12SemanticError), | ||
/// The payment id for a refund or request is already in use. | ||
DuplicatePaymentId, | ||
/// There is insufficient liquidity to complete the payment. | ||
InsufficientLiquidity, | ||
/// Failed to create a blinded path. | ||
BlindedPathCreationFailed, | ||
} | ||
|
||
impl From<Bolt12SemanticError> for Bolt12RequestError { | ||
fn from(err: Bolt12SemanticError) -> Self { | ||
Bolt12RequestError::InvalidSemantics(err) | ||
} | ||
} | ||
|
||
/// Route hints used in constructing invoices for [phantom node payents]. | ||
/// | ||
/// [phantom node payments]: crate::sign::PhantomKeysManager | ||
|
@@ -9114,7 +9146,7 @@ macro_rules! create_offer_builder { ($self: ident, $builder: ty) => { | |
/// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest | ||
pub fn create_offer_builder( | ||
&$self, absolute_expiry: Option<Duration> | ||
) -> Result<$builder, Bolt12SemanticError> { | ||
) -> Result<$builder, Bolt12CreationError> { | ||
let node_id = $self.get_our_node_id(); | ||
let expanded_key = &$self.inbound_payment_key; | ||
let entropy = &*$self.entropy_source; | ||
|
@@ -9124,7 +9156,7 @@ macro_rules! create_offer_builder { ($self: ident, $builder: ty) => { | |
let context = OffersContext::InvoiceRequest { nonce }; | ||
let path = $self.create_blinded_paths_using_absolute_expiry(context, absolute_expiry) | ||
.and_then(|paths| paths.into_iter().next().ok_or(())) | ||
.map_err(|_| Bolt12SemanticError::MissingPaths)?; | ||
.map_err(|()| Bolt12CreationError::BlindedPathCreationFailed)?; | ||
let builder = OfferBuilder::deriving_signing_pubkey(node_id, expanded_key, nonce, secp_ctx) | ||
.chain_hash($self.chain_hash) | ||
.path(path); | ||
|
@@ -9187,7 +9219,7 @@ macro_rules! create_refund_builder { ($self: ident, $builder: ty) => { | |
pub fn create_refund_builder( | ||
&$self, amount_msats: u64, absolute_expiry: Duration, payment_id: PaymentId, | ||
retry_strategy: Retry, max_total_routing_fee_msat: Option<u64> | ||
) -> Result<$builder, Bolt12SemanticError> { | ||
) -> Result<$builder, Bolt12RequestError> { | ||
let node_id = $self.get_our_node_id(); | ||
let expanded_key = &$self.inbound_payment_key; | ||
let entropy = &*$self.entropy_source; | ||
|
@@ -9197,7 +9229,28 @@ macro_rules! create_refund_builder { ($self: ident, $builder: ty) => { | |
let context = OffersContext::OutboundPayment { payment_id, nonce, hmac: None }; | ||
let path = $self.create_blinded_paths_using_absolute_expiry(context, Some(absolute_expiry)) | ||
.and_then(|paths| paths.into_iter().next().ok_or(())) | ||
.map_err(|_| Bolt12SemanticError::MissingPaths)?; | ||
.map_err(|()| Bolt12RequestError::BlindedPathCreationFailed)?; | ||
|
||
let total_liquidity: u64 = { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we add a similar liquidity check for inbound liquidity? This would be helpful when we are creating an offer, or calling There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Offers can be long-lived and be paid more than once. So it would be only be accurate for short-lived, single-use offers. For those and refund payment request, I wonder if that approach is preferable as it allows us to give early indication of a failure to the user. Drawback is it would prevent creating an offer / sending an invoice even if liquidity is soon-but-not-yet available (e.g., future JIT channel work). @tnull Thoughts? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ISTM we should consider it for refunds since those are also outbound liquidity (and likely to be claimed ~immediately), but for inbound liquidity yea I'd be kinda skeptical of it given JIT channels can come in many forms. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So IIUC, the outbound liquidity check for refunds makes sense since they’re likely to be claimed quickly. But for inbound liquidity, JIT channels could complicate things. Should we hold off on adding an inbound liquidity check in this PR? I’d appreciate any thoughts on how to proceed! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, let's hold off. |
||
let per_peer_state = &$self.per_peer_state.read().unwrap(); | ||
let mut sum: u64 = 0; | ||
|
||
per_peer_state.iter().for_each(|(_, peer_state_mutex)| { | ||
let peer_state_lock = peer_state_mutex.lock().unwrap(); | ||
let peer_state = &*peer_state_lock; | ||
|
||
sum += peer_state.channel_by_id | ||
.iter() | ||
.map(|(_, phase)| phase.context().get_available_balances(&$self.fee_estimator).outbound_capacity_msat) | ||
.sum::<u64>(); | ||
}); | ||
sum | ||
}; | ||
|
||
if amount_msats > total_liquidity { | ||
log_error!($self.logger, "Insufficient liquidity for payment with payment id: {}", payment_id); | ||
return Err(Bolt12RequestError::InsufficientLiquidity); | ||
} | ||
|
||
let builder = RefundBuilder::deriving_signing_pubkey( | ||
node_id, expanded_key, nonce, secp_ctx, amount_msats, payment_id | ||
|
@@ -9213,7 +9266,7 @@ macro_rules! create_refund_builder { ($self: ident, $builder: ty) => { | |
.add_new_awaiting_invoice( | ||
payment_id, expiration, retry_strategy, max_total_routing_fee_msat, None, | ||
) | ||
.map_err(|_| Bolt12SemanticError::DuplicatePaymentId)?; | ||
.map_err(|()| Bolt12RequestError::DuplicatePaymentId)?; | ||
|
||
Ok(builder.into()) | ||
} | ||
|
@@ -9305,7 +9358,7 @@ where | |
&self, offer: &Offer, quantity: Option<u64>, amount_msats: Option<u64>, | ||
payer_note: Option<String>, payment_id: PaymentId, retry_strategy: Retry, | ||
max_total_routing_fee_msat: Option<u64> | ||
) -> Result<(), Bolt12SemanticError> { | ||
) -> Result<(), Bolt12RequestError> { | ||
let expanded_key = &self.inbound_payment_key; | ||
let entropy = &*self.entropy_source; | ||
let secp_ctx = &self.secp_ctx; | ||
|
@@ -9335,7 +9388,38 @@ where | |
OffersContext::OutboundPayment { payment_id, nonce, hmac: Some(hmac) } | ||
); | ||
let reply_paths = self.create_blinded_paths(context) | ||
.map_err(|_| Bolt12SemanticError::MissingPaths)?; | ||
.map_err(|()| Bolt12RequestError::BlindedPathCreationFailed)?; | ||
|
||
let total_liquidity: u64 = { | ||
let per_peer_state = &self.per_peer_state.read().unwrap(); | ||
let mut sum: u64 = 0; | ||
|
||
per_peer_state.iter().for_each(|(_, peer_state_mutex)| { | ||
let peer_state_lock = peer_state_mutex.lock().unwrap(); | ||
let peer_state = &*peer_state_lock; | ||
|
||
sum += peer_state.channel_by_id | ||
.iter() | ||
.map(|(_, phase)| phase.context().get_available_balances(&self.fee_estimator).outbound_capacity_msat) | ||
.sum::<u64>(); | ||
}); | ||
sum | ||
}; | ||
|
||
let requested_amount_msats = match invoice_request.amount_msats() { | ||
Some(amount_msats) => Some(amount_msats), | ||
None => match offer.amount() { | ||
Some(Amount::Bitcoin { amount_msats }) => Some(amount_msats), | ||
_ => None, | ||
}, | ||
}; | ||
|
||
if let Some(amount) = requested_amount_msats { | ||
if amount > total_liquidity { | ||
log_error!(self.logger, "Insufficient liquidity for payment with payment id: {}", payment_id); | ||
return Err(Bolt12RequestError::InsufficientLiquidity); | ||
} | ||
} | ||
Comment on lines
+9417
to
+9422
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @tnull Should we consider outstanding invoice requests / refunds? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, remind me, what would be the timeout for pending invoice requests, i.e., when would we abort and clear them? Is it one timer tick? Given that we wouldn't consider them for other payment types (BOLT11), it might come as a surprise that you're able to send legacy payments but BOLT12 fails? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Invoice requests are one tick (so max ~2 ticks if called right after a tick), while refunds are based on the refund's expiration. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, no super strong opinion, but maybe we should roughly align the failure cases across different payment types, so maybe not worth? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure I understand the point. For BOLT11, if we initiate a bunch of payments, each subsequent one will have less liquidity to consider, IIUC. But for BOLT12, we can similarly initiate more than one payment but each subsequent one will consider the same liquidity unless an invoice is received for a prior one. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry for the late reply! Since outstanding invoice requests and refunds can tie up liquidity, do you think we should check for any outstanding invoices and see how they affect available liquidity before moving forward with the payment? And should I address it in this PR? I want to make sure to cover all our bases. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So they can potentially use liquidity if an invoice is received in response. But they don't tie up liquidity since a payment hasn't been made yet. That said, we can consider a request for payment as an intention to tie up liquidity in the hopefully near future. I'd lean towards including it but will defer to @tnull. FWIW, there's also some edge cases to consider. If we have a fiat-denominated offer and the users doesn't give an amount, how much liquidity should we considered unavailable? |
||
|
||
let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); | ||
|
||
|
@@ -9349,16 +9433,17 @@ where | |
payment_id, expiration, retry_strategy, max_total_routing_fee_msat, | ||
Some(retryable_invoice_request) | ||
) | ||
.map_err(|_| Bolt12SemanticError::DuplicatePaymentId)?; | ||
.map_err(|()| Bolt12RequestError::DuplicatePaymentId)?; | ||
|
||
self.enqueue_invoice_request(invoice_request, reply_paths) | ||
self.enqueue_invoice_request(invoice_request, reply_paths)?; | ||
Ok(()) | ||
} | ||
|
||
fn enqueue_invoice_request( | ||
&self, | ||
invoice_request: InvoiceRequest, | ||
reply_paths: Vec<BlindedMessagePath>, | ||
) -> Result<(), Bolt12SemanticError> { | ||
) -> Result<(), Bolt12RequestError> { | ||
let mut pending_offers_messages = self.pending_offers_messages.lock().unwrap(); | ||
if !invoice_request.paths().is_empty() { | ||
reply_paths | ||
|
@@ -9384,7 +9469,7 @@ where | |
} | ||
} else { | ||
debug_assert!(false); | ||
return Err(Bolt12SemanticError::MissingIssuerSigningPubkey); | ||
return Err(Bolt12RequestError::InvalidSemantics(Bolt12SemanticError::MissingIssuerSigningPubkey)); | ||
} | ||
|
||
Ok(()) | ||
|
@@ -9414,7 +9499,7 @@ where | |
/// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice | ||
pub fn request_refund_payment( | ||
&self, refund: &Refund | ||
) -> Result<Bolt12Invoice, Bolt12SemanticError> { | ||
) -> Result<Bolt12Invoice, Bolt12CreationError> { | ||
let expanded_key = &self.inbound_payment_key; | ||
let entropy = &*self.entropy_source; | ||
let secp_ctx = &self.secp_ctx; | ||
|
@@ -9423,7 +9508,7 @@ where | |
let relative_expiry = DEFAULT_RELATIVE_EXPIRY.as_secs() as u32; | ||
|
||
if refund.chain() != self.chain_hash { | ||
return Err(Bolt12SemanticError::UnsupportedChain); | ||
return Err(Bolt12CreationError::InvalidSemantics(Bolt12SemanticError::UnsupportedChain)); | ||
} | ||
|
||
let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); | ||
|
@@ -9434,7 +9519,7 @@ where | |
let payment_paths = self.create_blinded_payment_paths( | ||
amount_msats, payment_secret, payment_context | ||
) | ||
.map_err(|_| Bolt12SemanticError::MissingPaths)?; | ||
.map_err(|()| Bolt12CreationError::BlindedPathCreationFailed)?; | ||
|
||
#[cfg(feature = "std")] | ||
let builder = refund.respond_using_derived_keys( | ||
|
@@ -9457,7 +9542,7 @@ where | |
payment_hash: invoice.payment_hash(), nonce, hmac | ||
}); | ||
let reply_paths = self.create_blinded_paths(context) | ||
.map_err(|_| Bolt12SemanticError::MissingPaths)?; | ||
.map_err(|()| Bolt12CreationError::BlindedPathCreationFailed)?; | ||
|
||
let mut pending_offers_messages = self.pending_offers_messages.lock().unwrap(); | ||
if refund.paths().is_empty() { | ||
|
@@ -9486,7 +9571,7 @@ where | |
|
||
Ok(invoice) | ||
}, | ||
Err(()) => Err(Bolt12SemanticError::InvalidAmount), | ||
Err(()) => Err(Bolt12CreationError::InvalidSemantics(Bolt12SemanticError::InvalidAmount)), | ||
} | ||
} | ||
|
||
|
@@ -11135,6 +11220,13 @@ where | |
None => return None, | ||
}; | ||
|
||
if let Some(amount) = invoice_request.amount() { | ||
if let Amount::Currency { .. } = amount { | ||
let error = Bolt12SemanticError::UnsupportedCurrency; | ||
return Some((OffersMessage::InvoiceError(error.into()), responder.respond())); | ||
} | ||
} | ||
|
||
let nonce = match context { | ||
None if invoice_request.metadata().is_some() => None, | ||
Some(OffersContext::InvoiceRequest { nonce }) => Some(nonce), | ||
|
Uh oh!
There was an error while loading. Please reload this page.