Fail payment retry if Invoice is expired
authorJeffrey Czyz <jkczyz@gmail.com>
Thu, 30 Sep 2021 18:30:24 +0000 (11:30 -0700)
committerJeffrey Czyz <jkczyz@gmail.com>
Wed, 27 Oct 2021 15:54:53 +0000 (10:54 -0500)
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
lightning-invoice/src/payment.rs
lightning/src/routing/router.rs

index 5388321694d6f5d9c3fd68999629fafcc89aae03..3492b74f42371803e2dd6c10fa06cce1d94804d3 100644 (file)
@@ -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());
        }
 }
index e639b6799502c3ba7cc34780326816c3b0385571..7e931d66f153a4f530d7cbb9af1623dfb1ee4e2c 100644 (file)
@@ -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<P: Deref, R, L: Deref, E>
@@ -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<P: Deref, R, L: Deref, E> EventHandler for InvoicePayer<P, R, L, E>
 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());
index ec49b21058b3e89999586771f65c72256d1a94c8..545ff9f24ce386520ec630dab31f13b75b9c08ae 100644 (file)
@@ -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<RouteHint>,
+
+       /// Expiration of a payment to the payee, in seconds relative to the UNIX epoch.
+       pub expiry_time: Option<u64>,
 }
 
 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<RouteHint>) -> 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.