From 26dd31003723225521ff22426c00527cc84cd5d4 Mon Sep 17 00:00:00 2001 From: Christoph Staber Date: Mon, 25 May 2026 11:56:48 +0200 Subject: [PATCH 1/3] feat(lightning): add CLN NUT-15 MPP partial payment support --- cashu/lightning/clnrest.py | 112 +++++-- tests/lightning/conftest.py | 12 + tests/lightning/test_clnrest_mpp.py | 288 ++++++++++++++++++ .../test_lightning_backends_mocked.py | 2 +- 4 files changed, 395 insertions(+), 19 deletions(-) create mode 100644 tests/lightning/conftest.py create mode 100644 tests/lightning/test_clnrest_mpp.py diff --git a/cashu/lightning/clnrest.py b/cashu/lightning/clnrest.py index fe8c0565..561b6df1 100644 --- a/cashu/lightning/clnrest.py +++ b/cashu/lightning/clnrest.py @@ -42,6 +42,7 @@ class CLNRestWallet(LightningBackend): + supported_units = {Unit.sat, Unit.msat} unit = Unit.sat supports_mpp = settings.mint_clnrest_enable_mpp @@ -80,9 +81,14 @@ def __init__(self, unit: Unit = Unit.sat, **kwargs): self.cert = settings.mint_clnrest_cert or False self.client = httpx.AsyncClient( - base_url=self.url, verify=self.cert, headers=self.auth, timeout=None, + base_url=self.url, + verify=self.cert, + headers=self.auth, + timeout=None, ) self.last_pay_index = 0 + if self.supports_mpp: + logger.info("CLNRestWallet MPP (NUT-15) support enabled") async def cleanup(self): try: @@ -195,18 +201,20 @@ async def pay_invoice( # with fee < 5000 millisatoshi (which is default value of exemptfee) } - # Handle Multi-Mint payout where we must only pay part of the invoice amount - logger.trace(f"{quote_amount_msat = }, {invoice.amount_msat = }") - if quote_amount_msat != invoice.amount_msat: - logger.trace("Detected Multi-Nut payment") + if quote_amount_msat != int(invoice.amount_msat): if self.supports_mpp: - post_data["partial_msat"] = quote_amount_msat - else: - error_message = "mint does not support MPP" - logger.error(error_message) - return PaymentResponse( - result=PaymentResult.FAILED, error_message=error_message + logger.info( + f"NUT-15 MPP detected: {quote_amount_msat} msat of {invoice.amount_msat} msat" + ) + return await self.pay_partial_invoice( + quote, Amount(Unit.msat, quote_amount_msat), fee_limit_msat ) + error_message = "mint does not support MPP (NUT-15)" + logger.error(f"MPP required but not supported: {error_message}") + return PaymentResponse( + result=PaymentResult.FAILED, error_message=error_message + ) + r = await self.client.post("/v1/pay", data=post_data, timeout=None) if r.is_error or "message" in r.json(): @@ -215,6 +223,7 @@ async def pay_invoice( error_message = str(data["message"]) except Exception: error_message = r.text + logger.error(f"Payment failed: {error_message}") return PaymentResponse( result=PaymentResult.FAILED, error_message=error_message ) @@ -225,6 +234,63 @@ async def pay_invoice( preimage = data["payment_preimage"] fee_msat = data["amount_sent_msat"] - data["amount_msat"] + logger.info( + f"Payment successful - hash: {checking_id}, amount: {data['amount_msat']} msat, fee: {fee_msat} msat" + ) + + return PaymentResponse( + result=PAYMENT_RESULT_MAP[data["status"]], + checking_id=checking_id, + fee=Amount(unit=Unit.msat, amount=fee_msat) if fee_msat else None, + preimage=preimage, + ) + + async def pay_partial_invoice( + self, quote: MeltQuote, amount: Amount, fee_limit_msat: int + ) -> PaymentResponse: + try: + decode(quote.request) + except Bolt11Exception as exc: + return PaymentResponse( + result=PaymentResult.FAILED, + error_message=str(exc), + ) + + amount_msat = amount.to(Unit.msat).amount + fee_limit_percent = fee_limit_msat / amount_msat * 100 + post_data = { + "bolt11": quote.request, + "partial_msat": amount_msat, + "maxfeepercent": f"{fee_limit_percent:.11}", + "exemptfee": 0, + } + logger.debug( + f"Partial payment: {amount_msat} msat, fee_limit: {fee_limit_msat} msat" + ) + + r = await self.client.post("/v1/pay", data=post_data, timeout=None) + + if r.is_error or "message" in r.json(): + try: + data = r.json() + error_message = str(data["message"]) + except Exception: + error_message = r.text + logger.error(f"Partial payment failed: {error_message}") + return PaymentResponse( + result=PaymentResult.FAILED, error_message=error_message + ) + + data = r.json() + checking_id = data["payment_hash"] + preimage = data["payment_preimage"] + fee_msat = data["amount_sent_msat"] - data["amount_msat"] + + logger.info( + f"Partial payment succeeded - hash: {checking_id}, " + f"amount: {amount_msat} msat, fee: {fee_msat} msat" + ) + return PaymentResponse( result=PAYMENT_RESULT_MAP[data["status"]], checking_id=checking_id, @@ -298,10 +364,10 @@ async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: else 0 ) self.last_pay_index = last_pay_index - + retry_delay = 0 max_retry_delay = settings.mint_retry_exponential_backoff_max_delay - + while True: try: url = "/v1/waitanyinvoice" @@ -343,19 +409,29 @@ async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: " seconds" ) await asyncio.sleep(retry_delay) - + # Exponential backoff - retry_delay = max(settings.mint_retry_exponential_backoff_base_delay, min(retry_delay * 2, max_retry_delay)) + retry_delay = max( + settings.mint_retry_exponential_backoff_base_delay, + min(retry_delay * 2, max_retry_delay), + ) async def get_payment_quote( self, melt_quote: PostMeltQuoteRequest ) -> PaymentQuoteResponse: + """Get payment quote with NUT-15 MPP support.""" invoice_obj = decode(melt_quote.request) - assert invoice_obj.amount_msat, "invoice has no amount." - assert invoice_obj.amount_msat > 0, "invoice has 0 amount." + assert ( + invoice_obj.amount_msat and invoice_obj.amount_msat > 0 + ), "invalid invoice amount" amount_msat = ( - melt_quote.mpp_amount if melt_quote.is_mpp else (invoice_obj.amount_msat) + melt_quote.mpp_amount if melt_quote.is_mpp else int(invoice_obj.amount_msat) ) + if melt_quote.is_mpp: + logger.debug( + f"NUT-15 MPP quote: {amount_msat} msat of {invoice_obj.amount_msat} msat" + ) + fees_msat = fee_reserve(amount_msat) fees = Amount(unit=Unit.msat, amount=fees_msat) amount = Amount(unit=Unit.msat, amount=amount_msat) diff --git a/tests/lightning/conftest.py b/tests/lightning/conftest.py new file mode 100644 index 00000000..3b9484be --- /dev/null +++ b/tests/lightning/conftest.py @@ -0,0 +1,12 @@ +import pytest + + +# Skip the global mint_server fixture for lightning-specific tests +@pytest.fixture +def mint_server(): + yield None + + +@pytest.fixture(scope="session") +def mint(): + yield None diff --git a/tests/lightning/test_clnrest_mpp.py b/tests/lightning/test_clnrest_mpp.py new file mode 100644 index 00000000..3db68150 --- /dev/null +++ b/tests/lightning/test_clnrest_mpp.py @@ -0,0 +1,288 @@ +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from bolt11 import Bolt11 + +from cashu.core.base import Amount, MeltQuote, MeltQuoteState, Unit +from cashu.lightning.base import PaymentResult +from cashu.lightning.clnrest import CLNRestWallet + + +def create_melt_quote( + amount: int, unit: str = "msat", request: str = "lnbc10u1p0example" +) -> MeltQuote: + return MeltQuote( + quote="quote_test_123", + method="bolt11", + request=request, + checking_id="checking_id_test", + unit=unit, + amount=amount, + fee_reserve=1000, + state=MeltQuoteState.unpaid, + created_time=None, + paid_time=None, + ) + + +@pytest.fixture +def wallet(monkeypatch: pytest.MonkeyPatch) -> CLNRestWallet: + monkeypatch.setattr( + "cashu.lightning.clnrest.settings.mint_clnrest_url", "https://localhost:3010" + ) + monkeypatch.setattr( + "cashu.lightning.clnrest.settings.mint_clnrest_rune", "test_rune" + ) + monkeypatch.setattr("cashu.lightning.clnrest.settings.mint_clnrest_cert", False) + monkeypatch.setattr( + "cashu.lightning.clnrest.settings.mint_clnrest_enable_mpp", True + ) + monkeypatch.setattr( + "cashu.lightning.clnrest.settings.mint_retry_exponential_backoff_base_delay", 1 + ) + monkeypatch.setattr( + "cashu.lightning.clnrest.settings.mint_retry_exponential_backoff_max_delay", 10 + ) + + mock_client = Mock() + mock_client.post = AsyncMock() + monkeypatch.setattr( + "cashu.lightning.clnrest.httpx.AsyncClient", Mock(return_value=mock_client) + ) + + wallet = CLNRestWallet(unit=Unit.sat) + wallet.supports_mpp = True + return wallet + + +@pytest.mark.asyncio +async def test_pay_partial_invoice_success(wallet: CLNRestWallet): + with patch("cashu.lightning.clnrest.decode") as mock_decode: + mock_invoice = Mock(spec=Bolt11) + mock_invoice.payment_hash = "abc123def456" + mock_invoice.amount_msat = 1000000 + mock_decode.return_value = mock_invoice + + wallet.client.post = AsyncMock( + return_value=Mock( + is_error=False, + json=lambda: { + "payment_hash": "abc123def456", + "payment_preimage": "preimage_partial", + "amount_sent_msat": 600100, + "amount_msat": 600000, + "status": "complete", + }, + ) + ) + + quote = create_melt_quote(amount=600000, unit="msat") + amount = Amount(Unit.msat, 600000) + fee_limit = 1000 + + result = await wallet.pay_partial_invoice(quote, amount, fee_limit) + + assert result.result == PaymentResult.SETTLED + assert result.checking_id == "abc123def456" + assert result.preimage == "preimage_partial" + assert result.fee is not None + wallet.client.post.assert_called_once() # type: ignore[attr-defined] + + +@pytest.mark.asyncio +async def test_mpp_detection_routes_to_partial(wallet: CLNRestWallet): + with patch("cashu.lightning.clnrest.decode") as mock_decode: + mock_invoice = Mock(spec=Bolt11) + mock_invoice.payment_hash = "hash789" + mock_invoice.amount_msat = 1000000 + mock_decode.return_value = mock_invoice + + wallet.client.post = AsyncMock( + return_value=Mock( + is_error=False, + json=lambda: { + "payment_hash": "hash789", + "payment_preimage": "preimage_mpp", + "amount_sent_msat": 600100, + "amount_msat": 600000, + "status": "complete", + }, + ) + ) + + quote = create_melt_quote(amount=600000, unit="msat") + fee_limit_msat = 1000 + + result = await wallet.pay_invoice(quote, fee_limit_msat) + + assert result.result == PaymentResult.SETTLED + assert result.preimage == "preimage_mpp" + + +@pytest.mark.asyncio +async def test_mpp_disabled_returns_error(wallet: CLNRestWallet): + wallet.supports_mpp = False + + with patch("cashu.lightning.clnrest.decode") as mock_decode: + mock_invoice = Mock(spec=Bolt11) + mock_invoice.payment_hash = "hash123" + mock_invoice.amount_msat = 1000000 + mock_decode.return_value = mock_invoice + + quote = create_melt_quote(amount=600000, unit="msat") + + result = await wallet.pay_invoice(quote, 1000) + + assert result.result == PaymentResult.FAILED + assert result.error_message is not None + assert "does not support MPP" in result.error_message + + +@pytest.mark.asyncio +async def test_full_payment_no_mpp(wallet: CLNRestWallet): + with patch("cashu.lightning.clnrest.decode") as mock_decode: + mock_invoice = Mock(spec=Bolt11) + mock_invoice.payment_hash = "full_payment_hash" + mock_invoice.amount_msat = 1000000 + mock_decode.return_value = mock_invoice + + quote = create_melt_quote(amount=1000000, unit="msat") + + wallet.client.post = AsyncMock( + return_value=Mock( + is_error=False, + json=lambda: { + "payment_hash": "full_payment_hash", + "payment_preimage": "preimage123", + "amount_sent_msat": 1000100, + "amount_msat": 1000000, + "status": "complete", + }, + ) + ) + + result = await wallet.pay_invoice(quote, 1000) + + assert result.result == PaymentResult.SETTLED + assert result.preimage == "preimage123" + + +@pytest.mark.asyncio +async def test_invalid_invoice_decode_error(wallet: CLNRestWallet): + with patch("cashu.lightning.clnrest.decode") as mock_decode: + from bolt11 import Bolt11Exception + + mock_decode.side_effect = Bolt11Exception("Invalid invoice") + + quote = create_melt_quote(amount=600000, unit="msat") + amount = Amount(Unit.msat, 600000) + + result = await wallet.pay_partial_invoice(quote, amount, 1000) + + assert result.result == PaymentResult.FAILED + assert result.error_message is not None + assert "Invalid invoice" in result.error_message + + +@pytest.mark.asyncio +async def test_fee_limit_calculation(wallet: CLNRestWallet): + with patch("cashu.lightning.clnrest.decode") as mock_decode: + mock_invoice = Mock(spec=Bolt11) + mock_invoice.payment_hash = "fee_test_hash" + mock_invoice.amount_msat = 1000000 + mock_decode.return_value = mock_invoice + + quote = create_melt_quote(amount=600000, unit="msat") + amount = Amount(Unit.msat, 600000) + fee_limit_msat = 600 + + async def mock_post_side_effect(*args, **kwargs): + post_data = kwargs.get("data", {}) + maxfeepercent = float(post_data.get("maxfeepercent", "0")) + expected_fee_percent = (fee_limit_msat / 600000) * 100 + assert abs(maxfeepercent - expected_fee_percent) < 0.001 + + return Mock( + is_error=False, + json=lambda: { + "payment_hash": "fee_test_hash", + "payment_preimage": "preimage_fee", + "amount_sent_msat": 600100, + "amount_msat": 600000, + "status": "complete", + }, + ) + + wallet.client.post = AsyncMock(side_effect=mock_post_side_effect) + + await wallet.pay_partial_invoice(quote, amount, fee_limit_msat) + + wallet.client.post.assert_called_once() + + +@pytest.mark.asyncio +async def test_partial_msat_parameter_sent_to_cln(wallet: CLNRestWallet): + with patch("cashu.lightning.clnrest.decode") as mock_decode: + mock_invoice = Mock(spec=Bolt11) + mock_invoice.payment_hash = "cln_param_test" + mock_invoice.amount_msat = 1000000 + mock_decode.return_value = mock_invoice + + quote = create_melt_quote(amount=600000, unit="msat") + amount = Amount(Unit.msat, 600000) + + async def verify_partial_msat(*args, **kwargs): + post_data = kwargs.get("data", {}) + assert "partial_msat" in post_data + assert post_data["partial_msat"] == 600000 + assert "bolt11" in post_data + + return Mock( + is_error=False, + json=lambda: { + "payment_hash": "cln_param_test", + "payment_preimage": "preimage_param", + "amount_sent_msat": 600100, + "amount_msat": 600000, + "status": "complete", + }, + ) + + wallet.client.post = AsyncMock(side_effect=verify_partial_msat) + + await wallet.pay_partial_invoice(quote, amount, 1000) + + wallet.client.post.assert_called_once() + + +@pytest.mark.asyncio +async def test_amount_unit_conversion(wallet: CLNRestWallet): + with patch("cashu.lightning.clnrest.decode") as mock_decode: + mock_invoice = Mock(spec=Bolt11) + mock_invoice.payment_hash = "unit_test" + mock_invoice.amount_msat = 1000000 + mock_decode.return_value = mock_invoice + + wallet.client.post = AsyncMock( + return_value=Mock( + is_error=False, + json=lambda: { + "payment_hash": "unit_test", + "payment_preimage": "preimage_unit", + "amount_sent_msat": 600100, + "amount_msat": 600000, + "status": "complete", + }, + ) + ) + + amount_sat = Amount(Unit.sat, 600) + amount_msat = amount_sat.to(Unit.msat).amount + + assert amount_msat == 600000 + + quote = create_melt_quote(amount=600, unit="sat") + + result = await wallet.pay_partial_invoice(quote, amount_sat, 1000) + + assert result.result == PaymentResult.SETTLED diff --git a/tests/lightning/test_lightning_backends_mocked.py b/tests/lightning/test_lightning_backends_mocked.py index 69e0685e..76c6495d 100644 --- a/tests/lightning/test_lightning_backends_mocked.py +++ b/tests/lightning/test_lightning_backends_mocked.py @@ -275,7 +275,7 @@ async def post(self, *args, **kwargs): _quote("lnbc1fake", amount=1), fee_limit_msat=1000 ) assert result.result == PaymentResult.FAILED - assert result.error_message == "mint does not support MPP" + assert result.error_message == "mint does not support MPP (NUT-15)" @pytest.mark.asyncio From e5188397f09e3ffa33848e3691e7ca336f0eadc6 Mon Sep 17 00:00:00 2001 From: Christoph Staber Date: Fri, 29 May 2026 14:39:01 +0200 Subject: [PATCH 2/3] test(lightning): narrow PR to CLN MPP test coverage only --- cashu/lightning/clnrest.py | 112 ++---------- tests/lightning/test_clnrest_mpp.py | 161 +----------------- .../test_lightning_backends_mocked.py | 2 +- 3 files changed, 25 insertions(+), 250 deletions(-) diff --git a/cashu/lightning/clnrest.py b/cashu/lightning/clnrest.py index 561b6df1..fe8c0565 100644 --- a/cashu/lightning/clnrest.py +++ b/cashu/lightning/clnrest.py @@ -42,7 +42,6 @@ class CLNRestWallet(LightningBackend): - supported_units = {Unit.sat, Unit.msat} unit = Unit.sat supports_mpp = settings.mint_clnrest_enable_mpp @@ -81,14 +80,9 @@ def __init__(self, unit: Unit = Unit.sat, **kwargs): self.cert = settings.mint_clnrest_cert or False self.client = httpx.AsyncClient( - base_url=self.url, - verify=self.cert, - headers=self.auth, - timeout=None, + base_url=self.url, verify=self.cert, headers=self.auth, timeout=None, ) self.last_pay_index = 0 - if self.supports_mpp: - logger.info("CLNRestWallet MPP (NUT-15) support enabled") async def cleanup(self): try: @@ -201,20 +195,18 @@ async def pay_invoice( # with fee < 5000 millisatoshi (which is default value of exemptfee) } - if quote_amount_msat != int(invoice.amount_msat): + # Handle Multi-Mint payout where we must only pay part of the invoice amount + logger.trace(f"{quote_amount_msat = }, {invoice.amount_msat = }") + if quote_amount_msat != invoice.amount_msat: + logger.trace("Detected Multi-Nut payment") if self.supports_mpp: - logger.info( - f"NUT-15 MPP detected: {quote_amount_msat} msat of {invoice.amount_msat} msat" - ) - return await self.pay_partial_invoice( - quote, Amount(Unit.msat, quote_amount_msat), fee_limit_msat + post_data["partial_msat"] = quote_amount_msat + else: + error_message = "mint does not support MPP" + logger.error(error_message) + return PaymentResponse( + result=PaymentResult.FAILED, error_message=error_message ) - error_message = "mint does not support MPP (NUT-15)" - logger.error(f"MPP required but not supported: {error_message}") - return PaymentResponse( - result=PaymentResult.FAILED, error_message=error_message - ) - r = await self.client.post("/v1/pay", data=post_data, timeout=None) if r.is_error or "message" in r.json(): @@ -223,7 +215,6 @@ async def pay_invoice( error_message = str(data["message"]) except Exception: error_message = r.text - logger.error(f"Payment failed: {error_message}") return PaymentResponse( result=PaymentResult.FAILED, error_message=error_message ) @@ -234,63 +225,6 @@ async def pay_invoice( preimage = data["payment_preimage"] fee_msat = data["amount_sent_msat"] - data["amount_msat"] - logger.info( - f"Payment successful - hash: {checking_id}, amount: {data['amount_msat']} msat, fee: {fee_msat} msat" - ) - - return PaymentResponse( - result=PAYMENT_RESULT_MAP[data["status"]], - checking_id=checking_id, - fee=Amount(unit=Unit.msat, amount=fee_msat) if fee_msat else None, - preimage=preimage, - ) - - async def pay_partial_invoice( - self, quote: MeltQuote, amount: Amount, fee_limit_msat: int - ) -> PaymentResponse: - try: - decode(quote.request) - except Bolt11Exception as exc: - return PaymentResponse( - result=PaymentResult.FAILED, - error_message=str(exc), - ) - - amount_msat = amount.to(Unit.msat).amount - fee_limit_percent = fee_limit_msat / amount_msat * 100 - post_data = { - "bolt11": quote.request, - "partial_msat": amount_msat, - "maxfeepercent": f"{fee_limit_percent:.11}", - "exemptfee": 0, - } - logger.debug( - f"Partial payment: {amount_msat} msat, fee_limit: {fee_limit_msat} msat" - ) - - r = await self.client.post("/v1/pay", data=post_data, timeout=None) - - if r.is_error or "message" in r.json(): - try: - data = r.json() - error_message = str(data["message"]) - except Exception: - error_message = r.text - logger.error(f"Partial payment failed: {error_message}") - return PaymentResponse( - result=PaymentResult.FAILED, error_message=error_message - ) - - data = r.json() - checking_id = data["payment_hash"] - preimage = data["payment_preimage"] - fee_msat = data["amount_sent_msat"] - data["amount_msat"] - - logger.info( - f"Partial payment succeeded - hash: {checking_id}, " - f"amount: {amount_msat} msat, fee: {fee_msat} msat" - ) - return PaymentResponse( result=PAYMENT_RESULT_MAP[data["status"]], checking_id=checking_id, @@ -364,10 +298,10 @@ async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: else 0 ) self.last_pay_index = last_pay_index - + retry_delay = 0 max_retry_delay = settings.mint_retry_exponential_backoff_max_delay - + while True: try: url = "/v1/waitanyinvoice" @@ -409,29 +343,19 @@ async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: " seconds" ) await asyncio.sleep(retry_delay) - + # Exponential backoff - retry_delay = max( - settings.mint_retry_exponential_backoff_base_delay, - min(retry_delay * 2, max_retry_delay), - ) + retry_delay = max(settings.mint_retry_exponential_backoff_base_delay, min(retry_delay * 2, max_retry_delay)) async def get_payment_quote( self, melt_quote: PostMeltQuoteRequest ) -> PaymentQuoteResponse: - """Get payment quote with NUT-15 MPP support.""" invoice_obj = decode(melt_quote.request) - assert ( - invoice_obj.amount_msat and invoice_obj.amount_msat > 0 - ), "invalid invoice amount" + assert invoice_obj.amount_msat, "invoice has no amount." + assert invoice_obj.amount_msat > 0, "invoice has 0 amount." amount_msat = ( - melt_quote.mpp_amount if melt_quote.is_mpp else int(invoice_obj.amount_msat) + melt_quote.mpp_amount if melt_quote.is_mpp else (invoice_obj.amount_msat) ) - if melt_quote.is_mpp: - logger.debug( - f"NUT-15 MPP quote: {amount_msat} msat of {invoice_obj.amount_msat} msat" - ) - fees_msat = fee_reserve(amount_msat) fees = Amount(unit=Unit.msat, amount=fees_msat) amount = Amount(unit=Unit.msat, amount=amount_msat) diff --git a/tests/lightning/test_clnrest_mpp.py b/tests/lightning/test_clnrest_mpp.py index 3db68150..97051c1c 100644 --- a/tests/lightning/test_clnrest_mpp.py +++ b/tests/lightning/test_clnrest_mpp.py @@ -3,7 +3,7 @@ import pytest from bolt11 import Bolt11 -from cashu.core.base import Amount, MeltQuote, MeltQuoteState, Unit +from cashu.core.base import MeltQuote, MeltQuoteState, Unit from cashu.lightning.base import PaymentResult from cashu.lightning.clnrest import CLNRestWallet @@ -55,40 +55,6 @@ def wallet(monkeypatch: pytest.MonkeyPatch) -> CLNRestWallet: return wallet -@pytest.mark.asyncio -async def test_pay_partial_invoice_success(wallet: CLNRestWallet): - with patch("cashu.lightning.clnrest.decode") as mock_decode: - mock_invoice = Mock(spec=Bolt11) - mock_invoice.payment_hash = "abc123def456" - mock_invoice.amount_msat = 1000000 - mock_decode.return_value = mock_invoice - - wallet.client.post = AsyncMock( - return_value=Mock( - is_error=False, - json=lambda: { - "payment_hash": "abc123def456", - "payment_preimage": "preimage_partial", - "amount_sent_msat": 600100, - "amount_msat": 600000, - "status": "complete", - }, - ) - ) - - quote = create_melt_quote(amount=600000, unit="msat") - amount = Amount(Unit.msat, 600000) - fee_limit = 1000 - - result = await wallet.pay_partial_invoice(quote, amount, fee_limit) - - assert result.result == PaymentResult.SETTLED - assert result.checking_id == "abc123def456" - assert result.preimage == "preimage_partial" - assert result.fee is not None - wallet.client.post.assert_called_once() # type: ignore[attr-defined] - - @pytest.mark.asyncio async def test_mpp_detection_routes_to_partial(wallet: CLNRestWallet): with patch("cashu.lightning.clnrest.decode") as mock_decode: @@ -117,6 +83,9 @@ async def test_mpp_detection_routes_to_partial(wallet: CLNRestWallet): assert result.result == PaymentResult.SETTLED assert result.preimage == "preimage_mpp" + call_data = wallet.client.post.call_args.kwargs["data"] + assert "partial_msat" in call_data, "partial_msat must be sent to CLN for MPP" + assert call_data["partial_msat"] == 600000 @pytest.mark.asyncio @@ -165,124 +134,6 @@ async def test_full_payment_no_mpp(wallet: CLNRestWallet): assert result.result == PaymentResult.SETTLED assert result.preimage == "preimage123" + call_data = wallet.client.post.call_args.kwargs["data"] + assert "partial_msat" not in call_data, "partial_msat must not be sent for full payments" - -@pytest.mark.asyncio -async def test_invalid_invoice_decode_error(wallet: CLNRestWallet): - with patch("cashu.lightning.clnrest.decode") as mock_decode: - from bolt11 import Bolt11Exception - - mock_decode.side_effect = Bolt11Exception("Invalid invoice") - - quote = create_melt_quote(amount=600000, unit="msat") - amount = Amount(Unit.msat, 600000) - - result = await wallet.pay_partial_invoice(quote, amount, 1000) - - assert result.result == PaymentResult.FAILED - assert result.error_message is not None - assert "Invalid invoice" in result.error_message - - -@pytest.mark.asyncio -async def test_fee_limit_calculation(wallet: CLNRestWallet): - with patch("cashu.lightning.clnrest.decode") as mock_decode: - mock_invoice = Mock(spec=Bolt11) - mock_invoice.payment_hash = "fee_test_hash" - mock_invoice.amount_msat = 1000000 - mock_decode.return_value = mock_invoice - - quote = create_melt_quote(amount=600000, unit="msat") - amount = Amount(Unit.msat, 600000) - fee_limit_msat = 600 - - async def mock_post_side_effect(*args, **kwargs): - post_data = kwargs.get("data", {}) - maxfeepercent = float(post_data.get("maxfeepercent", "0")) - expected_fee_percent = (fee_limit_msat / 600000) * 100 - assert abs(maxfeepercent - expected_fee_percent) < 0.001 - - return Mock( - is_error=False, - json=lambda: { - "payment_hash": "fee_test_hash", - "payment_preimage": "preimage_fee", - "amount_sent_msat": 600100, - "amount_msat": 600000, - "status": "complete", - }, - ) - - wallet.client.post = AsyncMock(side_effect=mock_post_side_effect) - - await wallet.pay_partial_invoice(quote, amount, fee_limit_msat) - - wallet.client.post.assert_called_once() - - -@pytest.mark.asyncio -async def test_partial_msat_parameter_sent_to_cln(wallet: CLNRestWallet): - with patch("cashu.lightning.clnrest.decode") as mock_decode: - mock_invoice = Mock(spec=Bolt11) - mock_invoice.payment_hash = "cln_param_test" - mock_invoice.amount_msat = 1000000 - mock_decode.return_value = mock_invoice - - quote = create_melt_quote(amount=600000, unit="msat") - amount = Amount(Unit.msat, 600000) - - async def verify_partial_msat(*args, **kwargs): - post_data = kwargs.get("data", {}) - assert "partial_msat" in post_data - assert post_data["partial_msat"] == 600000 - assert "bolt11" in post_data - - return Mock( - is_error=False, - json=lambda: { - "payment_hash": "cln_param_test", - "payment_preimage": "preimage_param", - "amount_sent_msat": 600100, - "amount_msat": 600000, - "status": "complete", - }, - ) - - wallet.client.post = AsyncMock(side_effect=verify_partial_msat) - - await wallet.pay_partial_invoice(quote, amount, 1000) - - wallet.client.post.assert_called_once() - - -@pytest.mark.asyncio -async def test_amount_unit_conversion(wallet: CLNRestWallet): - with patch("cashu.lightning.clnrest.decode") as mock_decode: - mock_invoice = Mock(spec=Bolt11) - mock_invoice.payment_hash = "unit_test" - mock_invoice.amount_msat = 1000000 - mock_decode.return_value = mock_invoice - - wallet.client.post = AsyncMock( - return_value=Mock( - is_error=False, - json=lambda: { - "payment_hash": "unit_test", - "payment_preimage": "preimage_unit", - "amount_sent_msat": 600100, - "amount_msat": 600000, - "status": "complete", - }, - ) - ) - - amount_sat = Amount(Unit.sat, 600) - amount_msat = amount_sat.to(Unit.msat).amount - - assert amount_msat == 600000 - - quote = create_melt_quote(amount=600, unit="sat") - - result = await wallet.pay_partial_invoice(quote, amount_sat, 1000) - - assert result.result == PaymentResult.SETTLED diff --git a/tests/lightning/test_lightning_backends_mocked.py b/tests/lightning/test_lightning_backends_mocked.py index 76c6495d..69e0685e 100644 --- a/tests/lightning/test_lightning_backends_mocked.py +++ b/tests/lightning/test_lightning_backends_mocked.py @@ -275,7 +275,7 @@ async def post(self, *args, **kwargs): _quote("lnbc1fake", amount=1), fee_limit_msat=1000 ) assert result.result == PaymentResult.FAILED - assert result.error_message == "mint does not support MPP (NUT-15)" + assert result.error_message == "mint does not support MPP" @pytest.mark.asyncio From 7be69e6e576b285e7bd34be4dd5fde9486859cfc Mon Sep 17 00:00:00 2001 From: Christoph Staber Date: Wed, 3 Jun 2026 10:07:22 +0200 Subject: [PATCH 3/3] test(lightning): move CLN MPP tests into test_lightning_backends_mocked.py --- tests/lightning/conftest.py | 12 -- tests/lightning/test_clnrest_mpp.py | 139 ------------------ .../test_lightning_backends_mocked.py | 73 +++++++++ 3 files changed, 73 insertions(+), 151 deletions(-) delete mode 100644 tests/lightning/conftest.py delete mode 100644 tests/lightning/test_clnrest_mpp.py diff --git a/tests/lightning/conftest.py b/tests/lightning/conftest.py deleted file mode 100644 index 3b9484be..00000000 --- a/tests/lightning/conftest.py +++ /dev/null @@ -1,12 +0,0 @@ -import pytest - - -# Skip the global mint_server fixture for lightning-specific tests -@pytest.fixture -def mint_server(): - yield None - - -@pytest.fixture(scope="session") -def mint(): - yield None diff --git a/tests/lightning/test_clnrest_mpp.py b/tests/lightning/test_clnrest_mpp.py deleted file mode 100644 index 97051c1c..00000000 --- a/tests/lightning/test_clnrest_mpp.py +++ /dev/null @@ -1,139 +0,0 @@ -from unittest.mock import AsyncMock, Mock, patch - -import pytest -from bolt11 import Bolt11 - -from cashu.core.base import MeltQuote, MeltQuoteState, Unit -from cashu.lightning.base import PaymentResult -from cashu.lightning.clnrest import CLNRestWallet - - -def create_melt_quote( - amount: int, unit: str = "msat", request: str = "lnbc10u1p0example" -) -> MeltQuote: - return MeltQuote( - quote="quote_test_123", - method="bolt11", - request=request, - checking_id="checking_id_test", - unit=unit, - amount=amount, - fee_reserve=1000, - state=MeltQuoteState.unpaid, - created_time=None, - paid_time=None, - ) - - -@pytest.fixture -def wallet(monkeypatch: pytest.MonkeyPatch) -> CLNRestWallet: - monkeypatch.setattr( - "cashu.lightning.clnrest.settings.mint_clnrest_url", "https://localhost:3010" - ) - monkeypatch.setattr( - "cashu.lightning.clnrest.settings.mint_clnrest_rune", "test_rune" - ) - monkeypatch.setattr("cashu.lightning.clnrest.settings.mint_clnrest_cert", False) - monkeypatch.setattr( - "cashu.lightning.clnrest.settings.mint_clnrest_enable_mpp", True - ) - monkeypatch.setattr( - "cashu.lightning.clnrest.settings.mint_retry_exponential_backoff_base_delay", 1 - ) - monkeypatch.setattr( - "cashu.lightning.clnrest.settings.mint_retry_exponential_backoff_max_delay", 10 - ) - - mock_client = Mock() - mock_client.post = AsyncMock() - monkeypatch.setattr( - "cashu.lightning.clnrest.httpx.AsyncClient", Mock(return_value=mock_client) - ) - - wallet = CLNRestWallet(unit=Unit.sat) - wallet.supports_mpp = True - return wallet - - -@pytest.mark.asyncio -async def test_mpp_detection_routes_to_partial(wallet: CLNRestWallet): - with patch("cashu.lightning.clnrest.decode") as mock_decode: - mock_invoice = Mock(spec=Bolt11) - mock_invoice.payment_hash = "hash789" - mock_invoice.amount_msat = 1000000 - mock_decode.return_value = mock_invoice - - wallet.client.post = AsyncMock( - return_value=Mock( - is_error=False, - json=lambda: { - "payment_hash": "hash789", - "payment_preimage": "preimage_mpp", - "amount_sent_msat": 600100, - "amount_msat": 600000, - "status": "complete", - }, - ) - ) - - quote = create_melt_quote(amount=600000, unit="msat") - fee_limit_msat = 1000 - - result = await wallet.pay_invoice(quote, fee_limit_msat) - - assert result.result == PaymentResult.SETTLED - assert result.preimage == "preimage_mpp" - call_data = wallet.client.post.call_args.kwargs["data"] - assert "partial_msat" in call_data, "partial_msat must be sent to CLN for MPP" - assert call_data["partial_msat"] == 600000 - - -@pytest.mark.asyncio -async def test_mpp_disabled_returns_error(wallet: CLNRestWallet): - wallet.supports_mpp = False - - with patch("cashu.lightning.clnrest.decode") as mock_decode: - mock_invoice = Mock(spec=Bolt11) - mock_invoice.payment_hash = "hash123" - mock_invoice.amount_msat = 1000000 - mock_decode.return_value = mock_invoice - - quote = create_melt_quote(amount=600000, unit="msat") - - result = await wallet.pay_invoice(quote, 1000) - - assert result.result == PaymentResult.FAILED - assert result.error_message is not None - assert "does not support MPP" in result.error_message - - -@pytest.mark.asyncio -async def test_full_payment_no_mpp(wallet: CLNRestWallet): - with patch("cashu.lightning.clnrest.decode") as mock_decode: - mock_invoice = Mock(spec=Bolt11) - mock_invoice.payment_hash = "full_payment_hash" - mock_invoice.amount_msat = 1000000 - mock_decode.return_value = mock_invoice - - quote = create_melt_quote(amount=1000000, unit="msat") - - wallet.client.post = AsyncMock( - return_value=Mock( - is_error=False, - json=lambda: { - "payment_hash": "full_payment_hash", - "payment_preimage": "preimage123", - "amount_sent_msat": 1000100, - "amount_msat": 1000000, - "status": "complete", - }, - ) - ) - - result = await wallet.pay_invoice(quote, 1000) - - assert result.result == PaymentResult.SETTLED - assert result.preimage == "preimage123" - call_data = wallet.client.post.call_args.kwargs["data"] - assert "partial_msat" not in call_data, "partial_msat must not be sent for full payments" - diff --git a/tests/lightning/test_lightning_backends_mocked.py b/tests/lightning/test_lightning_backends_mocked.py index 69e0685e..81f64396 100644 --- a/tests/lightning/test_lightning_backends_mocked.py +++ b/tests/lightning/test_lightning_backends_mocked.py @@ -278,6 +278,79 @@ async def post(self, *args, **kwargs): assert result.error_message == "mint does not support MPP" +@pytest.mark.asyncio +async def test_clnrest_pay_invoice_mpp_sends_partial_msat(monkeypatch): + wallet = object.__new__(CLNRestWallet) + wallet.unit = Unit.sat + wallet.supports_mpp = True + + captured: dict = {} + + class Client: + async def post(self, *args, **kwargs): + captured.update(kwargs.get("data", {})) + return _response( + 200, + { + "payment_hash": "hash789", + "payment_preimage": "preimage_mpp", + "amount_sent_msat": 601, + "amount_msat": 600, + "status": "complete", + }, + ) + + cast(Any, wallet).client = Client() + monkeypatch.setattr( + "cashu.lightning.clnrest.decode", + lambda request: SimpleNamespace(amount_msat=1000, payment_hash="hash789"), + ) + + result = await wallet.pay_invoice( + _quote("lnbc1fake", amount=600, unit="msat"), fee_limit_msat=1000 + ) + assert result.result == PaymentResult.SETTLED + assert result.preimage == "preimage_mpp" + assert "partial_msat" in captured, "partial_msat must be sent to CLN for MPP" + assert captured["partial_msat"] == 600 + + +@pytest.mark.asyncio +async def test_clnrest_pay_invoice_full_amount_no_partial_msat(monkeypatch): + wallet = object.__new__(CLNRestWallet) + wallet.unit = Unit.sat + wallet.supports_mpp = True + + captured: dict = {} + + class Client: + async def post(self, *args, **kwargs): + captured.update(kwargs.get("data", {})) + return _response( + 200, + { + "payment_hash": "full_hash", + "payment_preimage": "preimage123", + "amount_sent_msat": 1001, + "amount_msat": 1000, + "status": "complete", + }, + ) + + cast(Any, wallet).client = Client() + monkeypatch.setattr( + "cashu.lightning.clnrest.decode", + lambda request: SimpleNamespace(amount_msat=1000, payment_hash="full_hash"), + ) + + result = await wallet.pay_invoice( + _quote("lnbc1fake", amount=1000, unit="msat"), fee_limit_msat=1000 + ) + assert result.result == PaymentResult.SETTLED + assert result.preimage == "preimage123" + assert "partial_msat" not in captured, "partial_msat must not be sent for full payments" + + @pytest.mark.asyncio async def test_clnrest_get_payment_status_not_found_is_unknown(): wallet = object.__new__(CLNRestWallet)