From d9b9916601cf688dabb0ad4dcddcb98c4c8467e5 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 30 Sep 2021 11:30:24 -0700 Subject: [PATCH] Fail payment retry if Invoice is expired According to BOLT 11: - after the `timestamp` plus `expiry` has passed - SHOULD NOT attempt a payment Add a convenience method for checking if an Invoice has expired, and use it to short-circuit payment retries. --- lightning-invoice/src/lib.rs | 41 +++++++++++++++++++ lightning-invoice/src/payment.rs | 67 +++++++++++++++++++++++++++++++- lightning/src/routing/router.rs | 10 +++++ 3 files changed, 117 insertions(+), 1 deletion(-) diff --git a/lightning-invoice/src/lib.rs b/lightning-invoice/src/lib.rs index 538832169..3492b74f4 100644 --- a/lightning-invoice/src/lib.rs +++ b/lightning-invoice/src/lib.rs @@ -1188,6 +1188,19 @@ impl Invoice { .unwrap_or(Duration::from_secs(DEFAULT_EXPIRY_TIME)) } + /// Returns whether the invoice has expired. + pub fn is_expired(&self) -> bool { + Self::is_expired_from_epoch(self.timestamp(), self.expiry_time()) + } + + /// Returns whether the expiry time from the given epoch has passed. + pub(crate) fn is_expired_from_epoch(epoch: &SystemTime, expiry_time: Duration) -> bool { + match epoch.elapsed() { + Ok(elapsed) => elapsed > expiry_time, + Err(_) => false, + } + } + /// Returns the invoice's `min_final_cltv_expiry` time, if present, otherwise /// [`DEFAULT_MIN_FINAL_CLTV_EXPIRY`]. pub fn min_final_cltv_expiry(&self) -> u64 { @@ -1920,5 +1933,33 @@ mod test { assert_eq!(invoice.min_final_cltv_expiry(), DEFAULT_MIN_FINAL_CLTV_EXPIRY); assert_eq!(invoice.expiry_time(), Duration::from_secs(DEFAULT_EXPIRY_TIME)); + assert!(!invoice.is_expired()); + } + + #[test] + fn test_expiration() { + use ::*; + use secp256k1::Secp256k1; + use secp256k1::key::SecretKey; + + let timestamp = SystemTime::now() + .checked_sub(Duration::from_secs(DEFAULT_EXPIRY_TIME * 2)) + .unwrap(); + let signed_invoice = InvoiceBuilder::new(Currency::Bitcoin) + .description("Test".into()) + .payment_hash(sha256::Hash::from_slice(&[0;32][..]).unwrap()) + .payment_secret(PaymentSecret([0; 32])) + .timestamp(timestamp) + .build_raw() + .unwrap() + .sign::<_, ()>(|hash| { + let privkey = SecretKey::from_slice(&[41; 32]).unwrap(); + let secp_ctx = Secp256k1::new(); + Ok(secp_ctx.sign_recoverable(hash, &privkey)) + }) + .unwrap(); + let invoice = Invoice::from_signed(signed_invoice).unwrap(); + + assert!(invoice.is_expired()); } } diff --git a/lightning-invoice/src/payment.rs b/lightning-invoice/src/payment.rs index e639b6799..7e931d66f 100644 --- a/lightning-invoice/src/payment.rs +++ b/lightning-invoice/src/payment.rs @@ -114,6 +114,7 @@ use secp256k1::key::PublicKey; use std::collections::hash_map::{self, HashMap}; use std::ops::Deref; use std::sync::Mutex; +use std::time::{Duration, SystemTime}; /// A utility for paying [`Invoice]`s. pub struct InvoicePayer @@ -226,6 +227,7 @@ where hash_map::Entry::Vacant(entry) => { let payer = self.payer.node_id(); let mut payee = Payee::new(invoice.recover_payee_pub_key()) + .with_expiry_time(expiry_time_from_unix_epoch(&invoice).as_secs()) .with_route_hints(invoice.route_hints()); if let Some(features) = invoice.features() { payee = payee.with_features(features.clone()); @@ -273,6 +275,15 @@ where } } +fn expiry_time_from_unix_epoch(invoice: &Invoice) -> Duration { + invoice.timestamp().duration_since(SystemTime::UNIX_EPOCH).unwrap() + invoice.expiry_time() +} + +fn has_expired(params: &RouteParameters) -> bool { + let expiry_time = Duration::from_secs(params.payee.expiry_time.unwrap()); + Invoice::is_expired_from_epoch(&SystemTime::UNIX_EPOCH, expiry_time) +} + impl EventHandler for InvoicePayer where P::Target: Payer, @@ -304,6 +315,8 @@ where log_trace!(self.logger, "Payment {} exceeded maximum attempts; not retrying (attempts: {})", log_bytes!(payment_hash.0), attempts); } else if retry.is_none() { log_trace!(self.logger, "Payment {} missing retry params; not retrying (attempts: {})", log_bytes!(payment_hash.0), attempts); + } else if has_expired(retry.as_ref().unwrap()) { + log_trace!(self.logger, "Invoice expired for payment {}; not retrying (attempts: {})", log_bytes!(payment_hash.0), attempts); } else if self.retry_payment(*payment_id.as_ref().unwrap(), retry.as_ref().unwrap()).is_err() { log_trace!(self.logger, "Error retrying payment {}; not retrying (attempts: {})", log_bytes!(payment_hash.0), attempts); } else { @@ -336,7 +349,7 @@ where #[cfg(test)] mod tests { use super::*; - use crate::{InvoiceBuilder, Currency}; + use crate::{DEFAULT_EXPIRY_TIME, InvoiceBuilder, Currency}; use bitcoin_hashes::sha256::Hash as Sha256; use lightning::ln::PaymentPreimage; use lightning::ln::features::{ChannelFeatures, NodeFeatures}; @@ -346,6 +359,7 @@ mod tests { use lightning::util::errors::APIError; use lightning::util::events::Event; use secp256k1::{SecretKey, PublicKey, Secp256k1}; + use std::time::{SystemTime, Duration}; fn invoice(payment_preimage: PaymentPreimage) -> Invoice { let payment_hash = Sha256::hash(&payment_preimage.0); @@ -378,6 +392,25 @@ mod tests { .unwrap() } + fn expired_invoice(payment_preimage: PaymentPreimage) -> Invoice { + let payment_hash = Sha256::hash(&payment_preimage.0); + let private_key = SecretKey::from_slice(&[42; 32]).unwrap(); + let timestamp = SystemTime::now() + .checked_sub(Duration::from_secs(DEFAULT_EXPIRY_TIME * 2)) + .unwrap(); + InvoiceBuilder::new(Currency::Bitcoin) + .description("test".into()) + .payment_hash(payment_hash) + .payment_secret(PaymentSecret([0; 32])) + .timestamp(timestamp) + .min_final_cltv_expiry(144) + .amount_milli_satoshis(128) + .build_signed(|hash| { + Secp256k1::new().sign_recoverable(hash, &private_key) + }) + .unwrap() + } + #[test] fn pays_invoice_on_first_attempt() { let event_handled = core::cell::RefCell::new(false); @@ -574,6 +607,37 @@ mod tests { assert_eq!(*payer.attempts.borrow(), 1); } + #[test] + fn fails_paying_invoice_after_expiration() { + let event_handled = core::cell::RefCell::new(false); + let event_handler = |_: &_| { *event_handled.borrow_mut() = true; }; + + let payer = TestPayer::new(); + let router = TestRouter {}; + let logger = TestLogger::new(); + let invoice_payer = + InvoicePayer::new(&payer, router, &logger, event_handler, RetryAttempts(2)); + + let payment_preimage = PaymentPreimage([1; 32]); + let invoice = expired_invoice(payment_preimage); + let payment_id = Some(invoice_payer.pay_invoice(&invoice).unwrap()); + assert_eq!(*payer.attempts.borrow(), 1); + + let event = Event::PaymentPathFailed { + payment_id, + payment_hash: PaymentHash(invoice.payment_hash().clone().into_inner()), + network_update: None, + rejected_by_dest: false, + all_paths_failed: false, + path: vec![], + short_channel_id: None, + retry: Some(TestRouter::retry_for_invoice(&invoice)), + }; + invoice_payer.handle_event(&event); + assert_eq!(*event_handled.borrow(), true); + assert_eq!(*payer.attempts.borrow(), 1); + } + #[test] fn fails_paying_invoice_after_retry_error() { let event_handled = core::cell::RefCell::new(false); @@ -795,6 +859,7 @@ mod tests { fn retry_for_invoice(invoice: &Invoice) -> RouteParameters { let mut payee = Payee::new(invoice.recover_payee_pub_key()) + .with_expiry_time(expiry_time_from_unix_epoch(invoice).as_secs()) .with_route_hints(invoice.route_hints()); if let Some(features) = invoice.features() { payee = payee.with_features(features.clone()); diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index ec49b2105..545ff9f24 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -180,12 +180,16 @@ pub struct Payee { /// Hints for routing to the payee, containing channels connecting the payee to public nodes. pub route_hints: Vec, + + /// Expiration of a payment to the payee, in seconds relative to the UNIX epoch. + pub expiry_time: Option, } impl_writeable_tlv_based!(Payee, { (0, pubkey, required), (2, features, option), (4, route_hints, vec_type), + (6, expiry_time, option), }); impl Payee { @@ -195,6 +199,7 @@ impl Payee { pubkey, features: None, route_hints: vec![], + expiry_time: None, } } @@ -216,6 +221,11 @@ impl Payee { pub fn with_route_hints(self, route_hints: Vec) -> Self { Self { route_hints, ..self } } + + /// Includes a payment expiration in seconds relative to the UNIX epoch. + pub fn with_expiry_time(self, expiry_time: u64) -> Self { + Self { expiry_time: Some(expiry_time), ..self } + } } /// A list of hops along a payment path terminating with a channel to the recipient. -- 2.39.5