Skip to content

Commit 73d676b

Browse files
authored
Merge pull request #99 from bitwalt/inbound-expired-payments-test
expired inbound payments stuck as Pending: test and fix
2 parents fa667a6 + 3aa646d commit 73d676b

File tree

4 files changed

+121
-3
lines changed

4 files changed

+121
-3
lines changed

openapi.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1971,6 +1971,9 @@ components:
19711971
updated_at:
19721972
type: integer
19731973
example: 1691162674
1974+
expires_at:
1975+
type: integer
1976+
example: 1691162674
19741977
payee_pubkey:
19751978
type: string
19761979
example: 03b79a4bc1ec365524b4fab9a39eb133753646babb5a1da5c4bc94c53110b7795d

src/ldk.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ pub(crate) struct PaymentInfo {
127127
pub(crate) created_at: u64,
128128
pub(crate) updated_at: u64,
129129
pub(crate) payee_pubkey: PublicKey,
130+
pub(crate) expires_at: Option<u64>,
130131
}
131132

132133
impl_writeable_tlv_based!(PaymentInfo, {
@@ -137,6 +138,7 @@ impl_writeable_tlv_based!(PaymentInfo, {
137138
(8, created_at, required),
138139
(10, updated_at, required),
139140
(12, payee_pubkey, required),
141+
(14, expires_at, option),
140142
});
141143

142144
pub(crate) struct InboundPaymentInfoStorage {
@@ -283,6 +285,30 @@ impl UnlockedAppState {
283285
}
284286
}
285287

288+
pub(crate) fn list_updated_inbound_payments(&self) -> LdkHashMap<PaymentHash, PaymentInfo> {
289+
let now = get_current_timestamp();
290+
let mut inbound = self.get_inbound_payments();
291+
let mut failed = false;
292+
for (_, payment_info) in inbound
293+
.payments
294+
.iter_mut()
295+
.filter(|(_, i)| matches!(i.status, HTLCStatus::Pending))
296+
{
297+
if let Some(expires_at) = payment_info.expires_at {
298+
if now > expires_at {
299+
payment_info.status = HTLCStatus::Failed;
300+
payment_info.updated_at = now;
301+
failed = true;
302+
}
303+
}
304+
}
305+
let payments = inbound.payments.clone();
306+
if failed {
307+
self.save_inbound_payments(inbound);
308+
}
309+
payments
310+
}
311+
286312
pub(crate) fn inbound_payments(&self) -> LdkHashMap<PaymentHash, PaymentInfo> {
287313
self.get_inbound_payments().payments.clone()
288314
}
@@ -334,6 +360,7 @@ impl UnlockedAppState {
334360
created_at,
335361
updated_at: created_at,
336362
payee_pubkey,
363+
expires_at: None,
337364
});
338365
}
339366
}

src/routes.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1743,7 +1743,7 @@ pub(crate) async fn get_payment(
17431743
}
17441744
let requested_ph = PaymentHash(payment_hash_vec.unwrap().try_into().unwrap());
17451745

1746-
let inbound_payments = unlocked_state.inbound_payments();
1746+
let inbound_payments = unlocked_state.list_updated_inbound_payments();
17471747
let outbound_payments = unlocked_state.outbound_payments();
17481748

17491749
for (payment_hash, payment_info) in &inbound_payments {
@@ -2074,6 +2074,7 @@ pub(crate) async fn keysend(
20742074
created_at,
20752075
updated_at: created_at,
20762076
payee_pubkey: dest_pubkey,
2077+
expires_at: None,
20772078
},
20782079
)?;
20792080
if let Some((contract_id, rgb_amount)) = rgb_payment {
@@ -2282,7 +2283,7 @@ pub(crate) async fn list_payments(
22822283
let guard = state.check_unlocked().await?;
22832284
let unlocked_state = guard.as_ref().unwrap();
22842285

2285-
let inbound_payments = unlocked_state.inbound_payments();
2286+
let inbound_payments = unlocked_state.list_updated_inbound_payments();
22862287
let outbound_payments = unlocked_state.outbound_payments();
22872288
let mut payments = vec![];
22882289

@@ -2564,6 +2565,7 @@ pub(crate) async fn ln_invoice(
25642565
created_at,
25652566
updated_at: created_at,
25662567
payee_pubkey: unlocked_state.channel_manager.get_our_node_id(),
2568+
expires_at: Some(created_at + payload.expiry_sec as u64),
25672569
},
25682570
);
25692571

@@ -3501,6 +3503,7 @@ pub(crate) async fn send_payment(
35013503
created_at,
35023504
updated_at: created_at,
35033505
payee_pubkey: offer.issuer_signing_pubkey().ok_or(APIError::InvalidInvoice(s!("missing signing pubkey")))?,
3506+
expires_at: None,
35043507
},
35053508
)?;
35063509

@@ -3579,6 +3582,7 @@ pub(crate) async fn send_payment(
35793582
created_at,
35803583
updated_at: created_at,
35813584
payee_pubkey: invoice.get_payee_pub_key(),
3585+
expires_at: None,
35823586
},
35833587
)?;
35843588
let payment_hash = PaymentHash(invoice.payment_hash().to_byte_array());

src/test/payment.rs

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use crate::routes::{BitcoinNetwork, TransactionType, TransferKind, TransferStatu
33
use super::*;
44

55
const TEST_DIR_BASE: &str = "tmp/payment/";
6+
const SHORT_EXPIRY_SEC: u32 = 1;
67

78
#[serial_test::serial]
89
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
@@ -212,7 +213,7 @@ async fn success() {
212213
#[serial_test::serial]
213214
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
214215
#[traced_test]
215-
async fn same_invoice_twice() {
216+
async fn same_invoice_twice_and_expired_inbound_payments() {
216217
initialize();
217218

218219
let test_dir_base = format!("{TEST_DIR_BASE}same_invoice_twice/");
@@ -273,4 +274,87 @@ async fn same_invoice_twice() {
273274

274275
let decoded = decode_ln_invoice(node1_addr, &invoice).await;
275276
wait_for_ln_payment(node1_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await;
277+
// create several invoices with a very short expiry that will NOT be paid
278+
let LNInvoiceResponse { invoice: invoice1 } =
279+
ln_invoice(node2_addr, Some(50000), None, None, SHORT_EXPIRY_SEC).await;
280+
let LNInvoiceResponse { invoice: invoice2 } =
281+
ln_invoice(node2_addr, Some(100000), None, None, SHORT_EXPIRY_SEC).await;
282+
let LNInvoiceResponse { invoice: invoice3 } =
283+
ln_invoice(node2_addr, None, None, None, SHORT_EXPIRY_SEC).await;
284+
285+
let decoded1 = decode_ln_invoice(node2_addr, &invoice1).await;
286+
let decoded2 = decode_ln_invoice(node2_addr, &invoice2).await;
287+
let decoded3 = decode_ln_invoice(node2_addr, &invoice3).await;
288+
289+
// verify all three start as Pending on the receiver node
290+
let payments_before = list_payments(node2_addr).await;
291+
let pending_before: Vec<_> = payments_before
292+
.iter()
293+
.filter(|p| {
294+
p.inbound
295+
&& matches!(p.status, HTLCStatus::Pending)
296+
&& [
297+
decoded1.payment_hash.as_str(),
298+
decoded2.payment_hash.as_str(),
299+
decoded3.payment_hash.as_str(),
300+
]
301+
.contains(&p.payment_hash.as_str())
302+
})
303+
.collect();
304+
assert_eq!(
305+
pending_before.len(),
306+
3,
307+
"expected all 3 unpaid invoices to be Pending"
308+
);
309+
310+
// wait for the invoices to expire
311+
tokio::time::sleep(std::time::Duration::from_secs(SHORT_EXPIRY_SEC as u64 + 1)).await;
312+
313+
// getting a payment should trigger expiration-based status transition
314+
let payment = get_payment(node2_addr, &decoded1.payment_hash).await;
315+
assert_eq!(
316+
payment.status,
317+
HTLCStatus::Failed,
318+
"expected expired inbound payment {} to be Failed via getpayment, got {:?}",
319+
decoded1.payment_hash,
320+
payment.status
321+
);
322+
323+
// listing payments should trigger expiration-based status transition
324+
let payments_after = list_payments(node2_addr).await;
325+
326+
for hash in [
327+
decoded2.payment_hash.as_str(),
328+
decoded3.payment_hash.as_str(),
329+
] {
330+
let payment = payments_after
331+
.iter()
332+
.find(|p| p.payment_hash == hash)
333+
.unwrap_or_else(|| panic!("payment {hash} not found"));
334+
assert_eq!(
335+
payment.status,
336+
HTLCStatus::Failed,
337+
"expected expired inbound payment {hash} to be Failed, got {:?}",
338+
payment.status
339+
);
340+
}
341+
342+
// sanity: no new Pending inbound payments should have appeared
343+
let still_pending: Vec<_> = payments_after
344+
.iter()
345+
.filter(|p| {
346+
p.inbound
347+
&& matches!(p.status, HTLCStatus::Pending)
348+
&& [
349+
decoded1.payment_hash.as_str(),
350+
decoded2.payment_hash.as_str(),
351+
decoded3.payment_hash.as_str(),
352+
]
353+
.contains(&p.payment_hash.as_str())
354+
})
355+
.collect();
356+
assert!(
357+
still_pending.is_empty(),
358+
"found expired inbound payments still Pending: {still_pending:?}"
359+
);
276360
}

0 commit comments

Comments
 (0)