|
40 | 40 | from electrum.logging import console_stderr_handler, Logger |
41 | 41 | from electrum.lnworker import PaymentInfo, RECEIVED |
42 | 42 | from electrum.lnonion import OnionFailureCode, OnionRoutingFailure |
43 | | -from electrum.lnutil import UpdateAddHtlc |
44 | | -from electrum.lnutil import LOCAL, REMOTE |
| 43 | +from electrum.lnutil import LOCAL, REMOTE, UpdateAddHtlc, RecvMPPResolution |
45 | 44 | from electrum.invoices import PR_PAID, PR_UNPAID, Invoice, LN_EXPIRY_NEVER |
46 | 45 | from electrum.interface import GracefulDisconnect |
47 | 46 | from electrum.simple_config import SimpleConfig |
@@ -1420,6 +1419,105 @@ async def on_htlc_failed(*args): |
1420 | 1419 | util.unregister_callback(on_htlc_fulfilled) |
1421 | 1420 | util.unregister_callback(on_htlc_failed) |
1422 | 1421 |
|
| 1422 | + async def test_mpp_cleanup_after_expiry(self): |
| 1423 | + """ |
| 1424 | + 1. Alice sends two HTLCs to Bob, not reaching total_msat, and eventually they MPP_TIMEOUT |
| 1425 | + 2. Bob fails both HTLCs |
| 1426 | + 3. Alice then retries and sends HTLCs again to Bob, for the same RHASH, |
| 1427 | + this time reaching total_msat, and the payment succeeds |
| 1428 | +
|
| 1429 | + Test that the sets are properly cleaned up after MPP_TIMEOUT |
| 1430 | + and the sender gets a second chance to pay the same invoice. |
| 1431 | + """ |
| 1432 | + async def run_test(test_trampoline: bool): |
| 1433 | + alice_channel, bob_channel = create_test_channels() |
| 1434 | + alice_peer, bob_peer, alice_wallet, bob_wallet, _q1, _q2 = self.prepare_peers(alice_channel, bob_channel) |
| 1435 | + lnaddr1, pay_req1 = self.prepare_invoice(bob_wallet, amount_msat=10_000) |
| 1436 | + |
| 1437 | + if test_trampoline: |
| 1438 | + await self._activate_trampoline(alice_wallet) |
| 1439 | + # declare bob as trampoline node |
| 1440 | + electrum.trampoline._TRAMPOLINE_NODES_UNITTESTS = { |
| 1441 | + 'bob': LNPeerAddr(host="127.0.0.1", port=9735, pubkey=bob_wallet.node_keypair.pubkey), |
| 1442 | + } |
| 1443 | + |
| 1444 | + async def _test(): |
| 1445 | + route = (await alice_wallet.create_routes_from_invoice(amount_msat=10_000, decoded_invoice=lnaddr1))[0][0].route |
| 1446 | + assert len(bob_wallet.received_mpp_htlcs) == 0 |
| 1447 | + # now alice sends two small htlcs, so the set stays incomplete |
| 1448 | + alice_peer.pay( # htlc 1 |
| 1449 | + route=route, |
| 1450 | + chan=alice_channel, |
| 1451 | + amount_msat=lnaddr1.get_amount_msat() // 4, |
| 1452 | + total_msat=lnaddr1.get_amount_msat(), |
| 1453 | + payment_hash=lnaddr1.paymenthash, |
| 1454 | + min_final_cltv_delta=400, |
| 1455 | + payment_secret=lnaddr1.payment_secret, |
| 1456 | + ) |
| 1457 | + alice_peer.pay( # htlc 2 |
| 1458 | + route=route, |
| 1459 | + chan=alice_channel, |
| 1460 | + amount_msat=lnaddr1.get_amount_msat() // 4, |
| 1461 | + total_msat=lnaddr1.get_amount_msat(), |
| 1462 | + payment_hash=lnaddr1.paymenthash, |
| 1463 | + min_final_cltv_delta=400, |
| 1464 | + payment_secret=lnaddr1.payment_secret, |
| 1465 | + ) |
| 1466 | + await asyncio.sleep(bob_wallet.MPP_EXPIRY // 2) # give bob time to receive the htlc |
| 1467 | + bob_payment_key = bob_wallet._get_payment_key(lnaddr1.paymenthash).hex() |
| 1468 | + assert bob_wallet.received_mpp_htlcs[bob_payment_key].resolution == RecvMPPResolution.WAITING |
| 1469 | + assert len(bob_wallet.received_mpp_htlcs[bob_payment_key].htlcs) == 2 |
| 1470 | + # now wait until bob expires the mpp (set) |
| 1471 | + await asyncio.wait_for(alice_htlc_resolved.wait(), bob_wallet.MPP_EXPIRY * 3) # this can take some time, esp. on CI |
| 1472 | + # check that bob failed the htlc |
| 1473 | + assert nhtlc_success == 0 and nhtlc_failed == 2 |
| 1474 | + # check that bob deleted the mpp set as it should be expired and resolved now |
| 1475 | + assert bob_payment_key not in bob_wallet.received_mpp_htlcs |
| 1476 | + alice_wallet._paysessions.clear() |
| 1477 | + assert alice_wallet.get_preimage(lnaddr1.paymenthash) is None # bob didn't preimage |
| 1478 | + # now try to pay again, this time the full amount |
| 1479 | + result, log = await alice_wallet.pay_invoice(pay_req1) |
| 1480 | + assert result is True |
| 1481 | + assert alice_wallet.get_preimage(lnaddr1.paymenthash) is not None # bob revealed preimage |
| 1482 | + assert len(bob_wallet.received_mpp_htlcs) == 0 # bob should also clean up a successful set |
| 1483 | + raise SuccessfulTest() |
| 1484 | + |
| 1485 | + async def f(): |
| 1486 | + async with OldTaskGroup() as group: |
| 1487 | + await group.spawn(alice_peer._message_loop()) |
| 1488 | + await group.spawn(alice_peer.htlc_switch()) |
| 1489 | + await group.spawn(bob_peer._message_loop()) |
| 1490 | + await group.spawn(bob_peer.htlc_switch()) |
| 1491 | + await asyncio.sleep(0.01) |
| 1492 | + await group.spawn(_test()) |
| 1493 | + |
| 1494 | + alice_htlc_resolved = asyncio.Event() |
| 1495 | + nhtlc_success = 0 |
| 1496 | + nhtlc_failed = 0 |
| 1497 | + async def on_sender_htlc_fulfilled(*args): |
| 1498 | + alice_htlc_resolved.set() |
| 1499 | + alice_htlc_resolved.clear() |
| 1500 | + nonlocal nhtlc_success |
| 1501 | + nhtlc_success += 1 |
| 1502 | + async def on_sender_htlc_failed(*args): |
| 1503 | + alice_htlc_resolved.set() |
| 1504 | + alice_htlc_resolved.clear() |
| 1505 | + nonlocal nhtlc_failed |
| 1506 | + nhtlc_failed += 1 |
| 1507 | + util.register_callback(on_sender_htlc_fulfilled, ["htlc_fulfilled"]) |
| 1508 | + util.register_callback(on_sender_htlc_failed, ["htlc_failed"]) |
| 1509 | + |
| 1510 | + try: |
| 1511 | + with self.assertRaises(SuccessfulTest): |
| 1512 | + await f() |
| 1513 | + finally: |
| 1514 | + util.unregister_callback(on_sender_htlc_fulfilled) |
| 1515 | + util.unregister_callback(on_sender_htlc_failed) |
| 1516 | + |
| 1517 | + for use_trampoline in [True, False]: |
| 1518 | + self.logger.debug(f"test_mpp_cleanup_after_expiry: {use_trampoline=}") |
| 1519 | + await run_test(use_trampoline) |
| 1520 | + |
1423 | 1521 | async def test_legacy_shutdown_low(self): |
1424 | 1522 | await self._test_shutdown(alice_fee=100, bob_fee=150) |
1425 | 1523 |
|
|
0 commit comments