Skip to content

Commit 3ce3634

Browse files
committed
lnworker: set invoice status after htlc resolved outside of pay session
- if fulfilled after session, set to PR_PAID - if failed after session, set to UNPAID
1 parent 67b56a9 commit 3ce3634

2 files changed

Lines changed: 81 additions & 16 deletions

File tree

electrum/lnworker.py

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2010,7 +2010,7 @@ async def pay_invoice(
20102010
util.trigger_callback('payment_succeeded', self.wallet, key)
20112011
elif self.has_unresolved_sent_htlcs(payment_hash):
20122012
# The invoice stays PR_INFLIGHT until the htlcs resolve.
2013-
self.logger.info(f"pay_invoice: htlcs are still unresolved. ")
2013+
self.logger.info("pay_invoice: htlcs are still unresolved.")
20142014
else:
20152015
self.set_invoice_status(key, PR_UNPAID) # allows retries
20162016
util.trigger_callback('payment_failed', self.wallet, key, reason)
@@ -3218,6 +3218,27 @@ def notify_upstream_peer(self, htlc_key: str) -> None:
32183218
upstream_peer.downstream_htlc_resolved_event.set()
32193219
upstream_peer.downstream_htlc_resolved_event.clear()
32203220

3221+
def _set_sent_payment_succeeded(self, payment_hash: bytes) -> None:
3222+
key = payment_hash.hex()
3223+
info = self.get_payment_info(payment_hash, direction=SENT)
3224+
if info is not None and info.status != PR_PAID:
3225+
self.set_invoice_status(key, PR_PAID)
3226+
util.trigger_callback('payment_succeeded', self.wallet, key)
3227+
3228+
def _set_sent_payment_failed(self, payment_hash: bytes) -> None:
3229+
key = payment_hash.hex()
3230+
if self.has_unresolved_sent_htlcs(payment_hash):
3231+
return
3232+
if self.get_preimage(payment_hash) and self.wallet.get_request(key) is None:
3233+
# if we know the preimage don't consider the payment failed (unless we pay ourselves).
3234+
# maybe another htlc of the same mpp got fulfilled, or we saw a htlc-success tx in the mempool
3235+
# before claiming a revoked htlc with a justice tx which ultimately would make LNWatcher try to fail the htlc here
3236+
return
3237+
info = self.get_payment_info(payment_hash, direction=SENT)
3238+
if info is not None and (info.status != PR_UNPAID or key in self.inflight_payments):
3239+
self.set_invoice_status(key, PR_UNPAID)
3240+
util.trigger_callback('payment_failed', self.wallet, key, '')
3241+
32213242
def htlc_fulfilled(self, chan: Channel, payment_hash: bytes, htlc_id: int):
32223243
"""Called when an HTLC *WE proposed* becomes irrevocably fulfilled."""
32233244
# note: this may be called several times for the same htlc
@@ -3249,15 +3270,13 @@ def htlc_fulfilled(self, chan: Channel, payment_hash: bytes, htlc_id: int):
32493270
paysession_active = False
32503271
else:
32513272
paysession_active = True
3273+
if not fw_key and not paysession.is_active:
3274+
self._set_sent_payment_succeeded(payment_hash)
32523275
else:
32533276
if fw_key:
32543277
paysession_active = False
32553278
else:
3256-
key = payment_hash.hex()
3257-
info = self.get_payment_info(payment_hash, direction=SENT)
3258-
if info is not None and info.status != PR_PAID:
3259-
self.set_invoice_status(key, PR_PAID)
3260-
util.trigger_callback('payment_succeeded', self.wallet, key)
3279+
self._set_sent_payment_succeeded(payment_hash)
32613280

32623281
if fw_key:
32633282
fw_htlcs = self.active_forwardings[fw_key]
@@ -3328,20 +3347,14 @@ def htlc_failed(
33283347
paysession_active = False
33293348
else:
33303349
paysession_active = True
3350+
if not fw_key and not paysession.is_active:
3351+
self._set_sent_payment_failed(payment_hash)
33313352
else:
33323353
if fw_key:
33333354
paysession_active = False
33343355
else:
33353356
self.logger.info(f"received unknown htlc_failed, probably from previous session (phash={payment_hash.hex()})")
3336-
key = payment_hash.hex()
3337-
invoice = self.wallet.get_invoice(key)
3338-
if invoice and self.get_invoice_status(invoice) != PR_UNPAID \
3339-
and (self.get_preimage(payment_hash) is None or self.wallet.get_request(key) is not None):
3340-
# if we know the preimage don't consider the payment failed (unless we pay ourselves).
3341-
# maybe another htlc of the same mpp got fulfilled, or we saw a htlc-success tx in the mempool
3342-
# before claiming a revoked htlc with a justice tx which ultimately would make LNWatcher try to fail the htlc here
3343-
self.set_invoice_status(key, PR_UNPAID)
3344-
util.trigger_callback('payment_failed', self.wallet, key, '')
3357+
self._set_sent_payment_failed(payment_hash)
33453358

33463359
if fw_key:
33473360
fw_htlcs = self.active_forwardings[fw_key]

tests/test_lnpeer.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
from electrum.logging import console_stderr_handler, Logger
4747
from electrum.lnonion import OnionFailureCode, OnionRoutingFailure, OnionHopsDataSingle, OnionPacket
4848
from electrum.lnutil import LOCAL, REMOTE, UpdateAddHtlc, RecvMPPResolution, RevocationStore
49-
from electrum.invoices import PR_PAID, PR_UNPAID, Invoice
49+
from electrum.invoices import PR_PAID, PR_UNPAID, PR_INFLIGHT, Invoice
5050
from electrum.interface import GracefulDisconnect
5151
from electrum.fee_policy import FeeTimeEstimates, FEE_ETA_TARGETS
5252
from electrum.mpp_split import split_amount_normal
@@ -915,6 +915,58 @@ async def f():
915915
for _test_trampoline in [False, True]:
916916
await run_test(_test_trampoline)
917917

918+
async def test_invoice_stays_inflight_while_htlcs_unresolved(self):
919+
"""Tests that we don't mark an invoice as failed while htlcs we sent for it are still
920+
unresolved. The receiver can still fulfill those htlcs, so telling the user the payment
921+
failed would trick them into paying twice, e.g. using a fresh invoice, which the
922+
"did not clear" check in pay_invoice cannot detect as a retry.
923+
"""
924+
graph = self.prepare_chans_and_peers_in_graph(self.GRAPH_DEFINITIONS['single_chan'])
925+
p1, p2 = graph.peers.values()
926+
w1, w2 = graph.workers.values()
927+
w2.enable_htlc_settle = False # bob holds the htlc: he neither settles nor fails it
928+
lnaddr, pay_req = self.prepare_invoice(w2)
929+
payment_hash = lnaddr.paymenthash
930+
931+
# simulate pay_to_node giving up (e.g. out of attempts) while an htlc is still unresolved
932+
orig_pay_to_node = w1.pay_to_node
933+
934+
async def pay_to_node_that_gives_up(**kwargs):
935+
task = asyncio.ensure_future(orig_pay_to_node(**kwargs))
936+
await p2.received_commitsig_event.wait()
937+
task.cancel()
938+
await asyncio.gather(task, return_exceptions=True)
939+
raise PaymentFailure('giving up while htlcs are unresolved')
940+
941+
w1.pay_to_node = pay_to_node_that_gives_up
942+
943+
async def pay():
944+
result, log = await w1.pay_invoice(pay_req)
945+
self.assertFalse(result)
946+
self.assertTrue(w1.has_unresolved_sent_htlcs(payment_hash))
947+
# the money is still at risk, so the invoice must not look failed/unpaid
948+
self.assertEqual(PR_INFLIGHT, w1.get_invoice_status(pay_req))
949+
with self.assertRaises(PaymentFailure):
950+
await w1.pay_invoice(pay_req)
951+
# now let bob fulfill the htlc. nobody is waiting for the payment anymore,
952+
# but the invoice must still end up as paid.
953+
w2.enable_htlc_settle = True
954+
while w1.get_invoice_status(pay_req) != PR_PAID:
955+
await asyncio.sleep(0.01)
956+
raise SuccessfulTest()
957+
958+
async def f():
959+
async with OldTaskGroup() as group:
960+
await group.spawn(p1._message_loop())
961+
await group.spawn(p1.htlc_switch())
962+
await group.spawn(p2._message_loop())
963+
await group.spawn(p2.htlc_switch())
964+
await asyncio.sleep(0.01)
965+
await group.spawn(pay())
966+
967+
with self.assertRaises(SuccessfulTest):
968+
await f()
969+
918970
async def test_payment_race(self):
919971
"""Alice and Bob pay each other simultaneously.
920972
They both send 'update_add_htlc' and receive each other's update

0 commit comments

Comments
 (0)