Skip to content

Commit 1d3edeb

Browse files
committed
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.
1 parent b769a2f commit 1d3edeb

File tree

4 files changed

+131
-1
lines changed

4 files changed

+131
-1
lines changed

lightning-invoice/src/lib.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1188,6 +1188,19 @@ impl Invoice {
11881188
.unwrap_or(Duration::from_secs(DEFAULT_EXPIRY_TIME))
11891189
}
11901190

1191+
/// Returns whether the invoice has expired.
1192+
pub fn is_expired(&self) -> bool {
1193+
Self::is_expired_from_epoch(self.timestamp(), self.expiry_time())
1194+
}
1195+
1196+
/// Returns whether the expiry time from the given epoch has passed.
1197+
pub(crate) fn is_expired_from_epoch(epoch: &SystemTime, expiry_time: Duration) -> bool {
1198+
match epoch.elapsed() {
1199+
Ok(elapsed) => elapsed > expiry_time,
1200+
Err(_) => false,
1201+
}
1202+
}
1203+
11911204
/// Returns the invoice's `min_final_cltv_expiry` time, if present, otherwise
11921205
/// [`DEFAULT_MIN_FINAL_CLTV_EXPIRY`].
11931206
pub fn min_final_cltv_expiry(&self) -> u64 {
@@ -1920,5 +1933,33 @@ mod test {
19201933

19211934
assert_eq!(invoice.min_final_cltv_expiry(), DEFAULT_MIN_FINAL_CLTV_EXPIRY);
19221935
assert_eq!(invoice.expiry_time(), Duration::from_secs(DEFAULT_EXPIRY_TIME));
1936+
assert!(!invoice.is_expired());
1937+
}
1938+
1939+
#[test]
1940+
fn test_expiration() {
1941+
use ::*;
1942+
use secp256k1::Secp256k1;
1943+
use secp256k1::key::SecretKey;
1944+
1945+
let timestamp = SystemTime::now()
1946+
.checked_sub(Duration::from_secs(DEFAULT_EXPIRY_TIME * 2))
1947+
.unwrap();
1948+
let signed_invoice = InvoiceBuilder::new(Currency::Bitcoin)
1949+
.description("Test".into())
1950+
.payment_hash(sha256::Hash::from_slice(&[0;32][..]).unwrap())
1951+
.payment_secret(PaymentSecret([0; 32]))
1952+
.timestamp(timestamp)
1953+
.build_raw()
1954+
.unwrap()
1955+
.sign::<_, ()>(|hash| {
1956+
let privkey = SecretKey::from_slice(&[41; 32]).unwrap();
1957+
let secp_ctx = Secp256k1::new();
1958+
Ok(secp_ctx.sign_recoverable(hash, &privkey))
1959+
})
1960+
.unwrap();
1961+
let invoice = Invoice::from_signed(signed_invoice).unwrap();
1962+
1963+
assert!(invoice.is_expired());
19231964
}
19241965
}

lightning-invoice/src/payment.rs

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ use secp256k1::key::PublicKey;
114114
use std::collections::hash_map::{self, HashMap};
115115
use std::ops::Deref;
116116
use std::sync::Mutex;
117+
use std::time::{Duration, SystemTime};
117118

118119
/// A utility for paying [`Invoice]`s.
119120
pub struct InvoicePayer<P: Deref, R, L: Deref, E>
@@ -225,6 +226,7 @@ where
225226
hash_map::Entry::Vacant(entry) => {
226227
let payer = self.payer.node_id();
227228
let mut payee = Payee::new(invoice.recover_payee_pub_key())
229+
.with_expiry_time(expiry_time_from_unix_epoch(&invoice))
228230
.with_route_hints(invoice.route_hints());
229231
if let Some(features) = invoice.features() {
230232
payee = payee.with_features(features.clone());
@@ -272,6 +274,14 @@ where
272274
}
273275
}
274276

277+
fn expiry_time_from_unix_epoch(invoice: &Invoice) -> Duration {
278+
invoice.timestamp().duration_since(SystemTime::UNIX_EPOCH).unwrap() + invoice.expiry_time()
279+
}
280+
281+
fn has_expired(params: &RouteParameters) -> bool {
282+
Invoice::is_expired_from_epoch(&SystemTime::UNIX_EPOCH, params.payee.expiry_time.unwrap())
283+
}
284+
275285
impl<P: Deref, R, L: Deref, E> EventHandler for InvoicePayer<P, R, L, E>
276286
where
277287
P::Target: Payer,
@@ -303,6 +313,8 @@ where
303313
log_trace!(self.logger, "Payment {} exceeded maximum attempts; not retrying (attempts: {})", log_bytes!(payment_hash.0), attempts);
304314
} else if retry.is_none() {
305315
log_trace!(self.logger, "Payment {} missing retry params; not retrying (attempts: {})", log_bytes!(payment_hash.0), attempts);
316+
} else if has_expired(retry.as_ref().unwrap()) {
317+
log_trace!(self.logger, "Invoice expired for payment {}; not retrying (attempts: {})", log_bytes!(payment_hash.0), attempts);
306318
} else if self.retry_payment(*payment_id.as_ref().unwrap(), retry.as_ref().unwrap()).is_err() {
307319
log_trace!(self.logger, "Error retrying payment {}; not retrying (attempts: {})", log_bytes!(payment_hash.0), attempts);
308320
} else {
@@ -335,7 +347,7 @@ where
335347
#[cfg(test)]
336348
mod tests {
337349
use super::*;
338-
use crate::{InvoiceBuilder, Currency};
350+
use crate::{DEFAULT_EXPIRY_TIME, InvoiceBuilder, Currency};
339351
use bitcoin_hashes::sha256::Hash as Sha256;
340352
use lightning::ln::PaymentPreimage;
341353
use lightning::ln::features::{ChannelFeatures, NodeFeatures};
@@ -345,6 +357,7 @@ mod tests {
345357
use lightning::util::errors::APIError;
346358
use lightning::util::events::Event;
347359
use secp256k1::{SecretKey, PublicKey, Secp256k1};
360+
use std::time::{SystemTime, Duration};
348361

349362
fn invoice(payment_preimage: PaymentPreimage) -> Invoice {
350363
let payment_hash = Sha256::hash(&payment_preimage.0);
@@ -377,6 +390,25 @@ mod tests {
377390
.unwrap()
378391
}
379392

393+
fn expired_invoice(payment_preimage: PaymentPreimage) -> Invoice {
394+
let payment_hash = Sha256::hash(&payment_preimage.0);
395+
let private_key = SecretKey::from_slice(&[42; 32]).unwrap();
396+
let timestamp = SystemTime::now()
397+
.checked_sub(Duration::from_secs(DEFAULT_EXPIRY_TIME * 2))
398+
.unwrap();
399+
InvoiceBuilder::new(Currency::Bitcoin)
400+
.description("test".into())
401+
.payment_hash(payment_hash)
402+
.payment_secret(PaymentSecret([0; 32]))
403+
.timestamp(timestamp)
404+
.min_final_cltv_expiry(144)
405+
.amount_milli_satoshis(128)
406+
.build_signed(|hash| {
407+
Secp256k1::new().sign_recoverable(hash, &private_key)
408+
})
409+
.unwrap()
410+
}
411+
380412
#[test]
381413
fn pays_invoice_on_first_attempt() {
382414
let event_handled = core::cell::RefCell::new(false);
@@ -573,6 +605,37 @@ mod tests {
573605
assert_eq!(*payer.attempts.borrow(), 1);
574606
}
575607

608+
#[test]
609+
fn fails_paying_invoice_after_expiration() {
610+
let event_handled = core::cell::RefCell::new(false);
611+
let event_handler = |_: &_| { *event_handled.borrow_mut() = true; };
612+
613+
let payer = TestPayer::new();
614+
let router = TestRouter {};
615+
let logger = TestLogger::new();
616+
let invoice_payer =
617+
InvoicePayer::new(&payer, router, &logger, event_handler, RetryAttempts(2));
618+
619+
let payment_preimage = PaymentPreimage([1; 32]);
620+
let invoice = expired_invoice(payment_preimage);
621+
let payment_id = Some(invoice_payer.pay_invoice(&invoice).unwrap());
622+
assert_eq!(*payer.attempts.borrow(), 1);
623+
624+
let event = Event::PaymentPathFailed {
625+
payment_id,
626+
payment_hash: PaymentHash(invoice.payment_hash().clone().into_inner()),
627+
network_update: None,
628+
rejected_by_dest: false,
629+
all_paths_failed: false,
630+
path: vec![],
631+
short_channel_id: None,
632+
retry: Some(TestRouter::retry_for_invoice(&invoice)),
633+
};
634+
invoice_payer.handle_event(&event);
635+
assert_eq!(*event_handled.borrow(), true);
636+
assert_eq!(*payer.attempts.borrow(), 1);
637+
}
638+
576639
#[test]
577640
fn fails_paying_invoice_after_retry_error() {
578641
let event_handled = core::cell::RefCell::new(false);
@@ -794,6 +857,7 @@ mod tests {
794857

795858
fn retry_for_invoice(invoice: &Invoice) -> RouteParameters {
796859
let mut payee = Payee::new(invoice.recover_payee_pub_key())
860+
.with_expiry_time(expiry_time_from_unix_epoch(invoice))
797861
.with_route_hints(invoice.route_hints());
798862
if let Some(features) = invoice.features() {
799863
payee = payee.with_features(features.clone());

lightning/src/routing/router.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ use prelude::*;
2727
use alloc::collections::BinaryHeap;
2828
use core::cmp;
2929
use core::ops::Deref;
30+
use core::time::Duration;
3031

3132
/// A hop in a route
3233
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
@@ -180,12 +181,16 @@ pub struct Payee {
180181

181182
/// Hints for routing to the payee, containing channels connecting the payee to public nodes.
182183
pub route_hints: Vec<RouteHint>,
184+
185+
/// Expiration of a payment to the payee, relative to a user-defined epoch.
186+
pub expiry_time: Option<Duration>,
183187
}
184188

185189
impl_writeable_tlv_based!(Payee, {
186190
(0, pubkey, required),
187191
(2, features, option),
188192
(4, route_hints, vec_type),
193+
(6, expiry_time, option),
189194
});
190195

191196
impl Payee {
@@ -195,6 +200,7 @@ impl Payee {
195200
pubkey,
196201
features: None,
197202
route_hints: vec![],
203+
expiry_time: None,
198204
}
199205
}
200206

@@ -216,6 +222,11 @@ impl Payee {
216222
pub fn with_route_hints(self, route_hints: Vec<RouteHint>) -> Self {
217223
Self { route_hints, ..self }
218224
}
225+
226+
/// Includes a payment expiration relative to a user-defined epoch.
227+
pub fn with_expiry_time(self, expiry_time: Duration) -> Self {
228+
Self { expiry_time: Some(expiry_time), ..self }
229+
}
219230
}
220231

221232
/// A list of hops along a payment path terminating with a channel to the recipient.

lightning/src/util/ser.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ use bitcoin::consensus::Encodable;
2727
use bitcoin::hashes::sha256d::Hash as Sha256dHash;
2828
use bitcoin::hash_types::{Txid, BlockHash};
2929
use core::marker::Sized;
30+
use core::time::Duration;
3031
use ln::msgs::DecodeError;
3132
use ln::{PaymentPreimage, PaymentHash, PaymentSecret};
3233

@@ -911,3 +912,16 @@ impl Readable for String {
911912
Ok(ret)
912913
}
913914
}
915+
916+
impl Writeable for Duration {
917+
#[inline]
918+
fn write<W: Writer>(&self, w: &mut W) -> Result<(), io::Error> {
919+
self.as_secs().write(w)
920+
}
921+
}
922+
impl Readable for Duration {
923+
#[inline]
924+
fn read<R: Read>(r: &mut R) -> Result<Self, DecodeError> {
925+
Ok(Duration::from_secs(Readable::read(r)?))
926+
}
927+
}

0 commit comments

Comments
 (0)