From 4ecdf23bd4116bf18a7dcd508236beedb1df4ef6 Mon Sep 17 00:00:00 2001 From: Hardeezah Date: Tue, 11 Aug 2026 22:26:12 +0100 Subject: [PATCH 1/2] fix(lightning): add note to error for unsupported LND partial self-payments --- cashu/lightning/lnd_grpc/lnd_grpc.py | 7 ++++++- cashu/lightning/lndrest.py | 5 +++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/cashu/lightning/lnd_grpc/lnd_grpc.py b/cashu/lightning/lnd_grpc/lnd_grpc.py index fbbcc38d..8cb4f122 100644 --- a/cashu/lightning/lnd_grpc/lnd_grpc.py +++ b/cashu/lightning/lnd_grpc/lnd_grpc.py @@ -306,9 +306,14 @@ async def pay_partial_invoice( break except AioRpcError as e: logger.error(f"QueryRoute or SendToRouteV2 failed: {e}") + err_msg = str(e) + if settings.mint_lnd_allow_self_payment and ( + "target not found" in err_msg.lower() or "no route" in err_msg.lower() + ): + err_msg += " (Note: partial self-payments are unsupported by LND's QueryRoutes API)" return PaymentResponse( result=PaymentResult.FAILED, - error_message=str(e), + error_message=err_msg, ) assert route and response diff --git a/cashu/lightning/lndrest.py b/cashu/lightning/lndrest.py index f31b9436..d1903b45 100644 --- a/cashu/lightning/lndrest.py +++ b/cashu/lightning/lndrest.py @@ -323,6 +323,11 @@ async def pay_partial_invoice( route_data = route.json() if route.is_error or route_data.get("message"): error_message = route_data.get("message") or route.text + if settings.mint_lnd_allow_self_payment and ( + "target not found" in error_message.lower() + or "no route" in error_message.lower() + ): + error_message += " (Note: partial self-payments are unsupported by LND's QueryRoutes API)" return PaymentResponse( result=PaymentResult.FAILED, error_message=error_message ) From efd6ce1844128884d7038757871cf14d146161b2 Mon Sep 17 00:00:00 2001 From: Hardeezah Date: Tue, 11 Aug 2026 23:00:19 +0100 Subject: [PATCH 2/2] test: add coverage for unsupported LND partial self-payments --- tests/lightning/test_lnd_partial_mocked.py | 131 +++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 tests/lightning/test_lnd_partial_mocked.py diff --git a/tests/lightning/test_lnd_partial_mocked.py b/tests/lightning/test_lnd_partial_mocked.py new file mode 100644 index 00000000..6cc8543f --- /dev/null +++ b/tests/lightning/test_lnd_partial_mocked.py @@ -0,0 +1,131 @@ +from types import SimpleNamespace + +import grpc.aio +import pytest +from httpx import Response + +from cashu.core.base import Amount, MeltQuote, MeltQuoteState, Unit +from cashu.lightning.base import PaymentResult +from cashu.lightning.lnd_grpc.lnd_grpc import LndRPCWallet +from cashu.lightning.lndrest import LndRestWallet + + +def _quote(request: str, amount: int = 1) -> MeltQuote: + return MeltQuote( + quote="q1", + method="bolt11", + request=request, + checking_id="checking-1", + unit="sat", + amount=amount, + fee_reserve=1, + state=MeltQuoteState.unpaid, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "enabled, error_contains", [(True, "QueryRoutes API"), (False, "target not found")] +) +async def test_lndrest_pay_partial_invoice_self_payment( + monkeypatch, enabled, error_contains +): + wallet = object.__new__(LndRestWallet) + wallet.unit = Unit.sat + wallet.supports_mpp = True + wallet.endpoint = "http://localhost:8080" + wallet.macaroon = "macaroon" + wallet.cert = None + + class MockClient: + async def post(self, url, **kwargs): + if "/v1/graph/routes/" in url: + return Response(status_code=500, json={"message": "target not found"}) + return Response(status_code=200, json={}) + + wallet.client = MockClient() + + monkeypatch.setattr( + "cashu.lightning.lndrest.bolt11.decode", + lambda request: SimpleNamespace( + amount_msat=2000, + tags=SimpleNamespace(get=lambda x: SimpleNamespace(data="fake_pubkey")), + ), + ) + monkeypatch.setattr( + "cashu.lightning.lndrest.settings.mint_lnd_allow_self_payment", + enabled, + ) + + result = await wallet.pay_partial_invoice( + _quote("lnbc1fake", amount=1), + amount=Amount(unit=Unit.sat, amount=1), + fee_limit_msat=1000, + ) + assert result.result == PaymentResult.FAILED + assert error_contains in result.error_message + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "enabled, error_contains", [(True, "QueryRoutes API"), (False, "target not found")] +) +async def test_lndrpc_pay_partial_invoice_self_payment( + monkeypatch, enabled, error_contains +): + wallet = object.__new__(LndRPCWallet) + wallet.unit = Unit.sat + wallet.supports_mpp = True + wallet.endpoint = "localhost:10009" + wallet.combined_creds = None + + class MockStub: + def __init__(self, channel): + pass + + async def QueryRoutes(self, request): + raise grpc.aio.AioRpcError( + code=grpc.StatusCode.UNKNOWN, + initial_metadata=None, + trailing_metadata=None, + details="target not found", + ) + + class MockChannel: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + monkeypatch.setattr( + "cashu.lightning.lnd_grpc.lnd_grpc.grpc.aio.secure_channel", + lambda *args, **kwargs: MockChannel(), + ) + monkeypatch.setattr( + "cashu.lightning.lnd_grpc.lnd_grpc.lightningstub.LightningStub", + MockStub, + ) + monkeypatch.setattr( + "cashu.lightning.lnd_grpc.lnd_grpc.routerstub.RouterStub", + MockStub, + ) + monkeypatch.setattr( + "cashu.lightning.lnd_grpc.lnd_grpc.bolt11.decode", + lambda request: SimpleNamespace( + amount_msat=2000, + tags=SimpleNamespace(get=lambda x: SimpleNamespace(data="fake_pubkey")), + ), + ) + monkeypatch.setattr( + "cashu.lightning.lnd_grpc.lnd_grpc.settings.mint_lnd_allow_self_payment", + enabled, + ) + + result = await wallet.pay_partial_invoice( + _quote("lnbc1fake", amount=1), + amount=Amount(unit=Unit.sat, amount=1), + fee_limit_msat=1000, + ) + assert result.result == PaymentResult.FAILED + assert error_contains in result.error_message