diff --git a/cashu/lightning/lnd_grpc/lnd_grpc.py b/cashu/lightning/lnd_grpc/lnd_grpc.py index 8cb4f122..6c31af5f 100644 --- a/cashu/lightning/lnd_grpc/lnd_grpc.py +++ b/cashu/lightning/lnd_grpc/lnd_grpc.py @@ -48,6 +48,7 @@ MAX_ROUTE_RETRIES = 50 PAYMENT_TIMEOUT_SECONDS = 60 +FEE_PROBE_TIMEOUT_SECONDS = 5 class LndRPCWallet(LightningBackend): @@ -446,6 +447,33 @@ async def get_payment_quote( amount_msat = int(invoice_obj.amount_msat) fees_msat = fee_reserve(amount_msat) + if not melt_quote.is_mpp: + try: + async with grpc.aio.secure_channel( + self.endpoint, self.combined_creds + ) as channel: + router_stub = routerstub.RouterStub(channel) + response = await router_stub.EstimateRouteFee( + routerrpc.RouteFeeRequest( + payment_request=melt_quote.request, + timeout=FEE_PROBE_TIMEOUT_SECONDS, + ), + timeout=FEE_PROBE_TIMEOUT_SECONDS, + ) + if response.failure_reason == lnrpc.FAILURE_REASON_NONE: + # The probe is a lower bound; add the configured base reserve + # to allow for a more expensive route at payment time. + fees_msat = ( + settings.lightning_reserve_fee_min + response.routing_fee_msat + ) + else: + logger.debug( + "LND fee probe failed: " + f"{lnrpc.PaymentFailureReason.Name(response.failure_reason)}" + ) + except AioRpcError as exc: + logger.debug(f"LND fee probe failed, using configured reserve: {exc}") + fees = Amount(unit=Unit.msat, amount=fees_msat) amount = Amount(unit=Unit.msat, amount=amount_msat) diff --git a/cashu/lightning/lndrest.py b/cashu/lightning/lndrest.py index d1903b45..f2e08ccc 100644 --- a/cashu/lightning/lndrest.py +++ b/cashu/lightning/lndrest.py @@ -44,6 +44,7 @@ MAX_ROUTE_RETRIES = 50 TEMPORARY_CHANNEL_FAILURE_ERROR = "TEMPORARY_CHANNEL_FAILURE" PAYMENT_TIMEOUT_SECONDS = 60 +FEE_PROBE_TIMEOUT_SECONDS = 5 class LndRestWallet(LightningBackend): @@ -229,9 +230,7 @@ async def pay_invoice( # ledger re-check the real state with TrackPaymentV2. if line.get("error"): error = line["error"] - message = ( - error["message"] if "message" in error else str(error) - ) + message = error["message"] if "message" in error else str(error) return PaymentResponse( result=PaymentResult.UNKNOWN, error_message=message ) @@ -520,6 +519,30 @@ async def get_payment_quote( amount_msat = int(invoice_obj.amount_msat) fees_msat = fee_reserve(amount_msat) + if not melt_quote.is_mpp: + try: + response = await self.client.post( + "/v2/router/route/estimatefee", + json={ + "payment_request": melt_quote.request, + "timeout": FEE_PROBE_TIMEOUT_SECONDS, + }, + timeout=FEE_PROBE_TIMEOUT_SECONDS, + ) + response.raise_for_status() + data = response.json() + failure_reason = data.get("failure_reason") + if failure_reason in (None, 0, "FAILURE_REASON_NONE"): + # The probe is a lower bound; add the configured base reserve + # to allow for a more expensive route at payment time. + fees_msat = settings.lightning_reserve_fee_min + int( + data["routing_fee_msat"] + ) + else: + logger.debug(f"LND fee probe failed: {failure_reason}") + except Exception as exc: + logger.debug(f"LND fee probe failed, using configured reserve: {exc}") + fees = Amount(unit=Unit.msat, amount=fees_msat) amount = Amount(unit=Unit.msat, amount=amount_msat) diff --git a/tests/helpers.py b/tests/helpers.py index e3ad7051..056179e5 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -188,8 +188,8 @@ def partial_pay_real_invoice(invoice: str, amount: int, node: int) -> str: return run_cmd(cmd) -def get_real_invoice_cln(sats: int) -> str: - cmd = docker_clightning_cli(1) +def get_real_invoice_cln(sats: int, node: int = 1) -> str: + cmd = docker_clightning_cli(node) cmd.extend( [ "invoice", diff --git a/tests/lightning/test_lightning_backends_mocked.py b/tests/lightning/test_lightning_backends_mocked.py index 0b20a2e3..8439245d 100644 --- a/tests/lightning/test_lightning_backends_mocked.py +++ b/tests/lightning/test_lightning_backends_mocked.py @@ -20,6 +20,10 @@ CLN_PAYMENT_STATUS_PENDING, CLNRestWallet, ) +from cashu.lightning.lnd_grpc.lnd_grpc import ( + FEE_PROBE_TIMEOUT_SECONDS, + LndRPCWallet, +) from cashu.lightning.lndrest import LndRestWallet from cashu.lightning.strike import StrikeWallet @@ -671,6 +675,87 @@ async def test_lndrest_get_payment_quote_uses_mpp_amount(monkeypatch): assert quote.fee == Amount(Unit.sat, fee_reserve(1500) // 1000) +@pytest.mark.asyncio +async def test_lndrest_get_payment_quote_adds_base_reserve(monkeypatch): + wallet = object.__new__(LndRestWallet) + wallet.unit = Unit.sat + monkeypatch.setattr( + "cashu.lightning.lndrest.decode", + lambda request: SimpleNamespace(amount_msat=2000, payment_hash="ph"), + ) + monkeypatch.setattr( + "cashu.lightning.lndrest.settings.lightning_reserve_fee_min", 2000 + ) + + class Client: + async def post(self, *args, **kwargs): + return _response( + 200, + { + "failure_reason": "FAILURE_REASON_NONE", + "routing_fee_msat": "1001", + }, + ) + + cast(Any, wallet).client = Client() + quote = await wallet.get_payment_quote( + PostMeltQuoteRequest(unit="sat", request="lnbc1") + ) + + assert quote.fee == Amount(Unit.sat, 4) + + +@pytest.mark.asyncio +async def test_lndgrpc_get_payment_quote_sets_rpc_deadline(monkeypatch): + wallet = object.__new__(LndRPCWallet) + wallet.unit = Unit.sat + wallet.endpoint = "lnd.test" + wallet.combined_creds = object() + monkeypatch.setattr( + "cashu.lightning.lnd_grpc.lnd_grpc.bolt11.decode", + lambda request: SimpleNamespace(amount_msat=2000, payment_hash="ph"), + ) + monkeypatch.setattr( + "cashu.lightning.lnd_grpc.lnd_grpc.settings.lightning_reserve_fee_min", + 2000, + ) + + class Channel: + 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: Channel(), + ) + rpc_timeouts = [] + + class RouterStub: + def __init__(self, channel): + pass + + async def EstimateRouteFee(self, request, timeout=None): + rpc_timeouts.append(timeout) + return SimpleNamespace( + failure_reason=0, + routing_fee_msat=1000, + ) + + monkeypatch.setattr( + "cashu.lightning.lnd_grpc.lnd_grpc.routerstub.RouterStub", RouterStub + ) + + quote = await wallet.get_payment_quote( + PostMeltQuoteRequest(unit="sat", request="lnbc1") + ) + + assert quote.fee == Amount(Unit.sat, 3) + assert rpc_timeouts == [FEE_PROBE_TIMEOUT_SECONDS] + + @pytest.mark.asyncio async def test_spark_pay_invoice_rejects_non_bolt11(): from cashu.lightning.sparkl2 import SparkL2Wallet diff --git a/tests/mint/test_mint_api.py b/tests/mint/test_mint_api.py index 754da030..3c417577 100644 --- a/tests/mint/test_mint_api.py +++ b/tests/mint/test_mint_api.py @@ -497,24 +497,26 @@ async def test_melt_external(ledger: Ledger, wallet: Wallet): reason="only works on regtest", ) async def test_melt_external_with_routing_fee(ledger: Ledger, wallet: Wallet): - mint_quote = await wallet.request_mint(64) + mint_quote = await wallet.request_mint(128) await pay_if_regtest(mint_quote.request) - await wallet.mint(64, quote_id=mint_quote.quote) - assert wallet.balance == 64 + await wallet.mint(128, quote_id=mint_quote.quote) + assert wallet.balance == 128 # external invoice that the mint can only pay through a routing node invoice_payment_request = get_real_invoice_routed(62) quote = await wallet.melt_quote(invoice_payment_request) assert quote.amount == 62 - assert quote.fee_reserve == 2 + assert quote.fee_reserve > 0 - keep, send = await wallet.swap_to_send(wallet.proofs, 64) + keep, send = await wallet.swap_to_send( + wallet.proofs, quote.amount + quote.fee_reserve + ) inputs_payload = [p.to_dict() for p in send] # outputs for change - secrets, rs, derivation_paths = await wallet.generate_n_secrets(1) - outputs, rs = wallet._construct_outputs([2], secrets, rs) + secrets, rs, derivation_paths = await wallet.generate_n_secrets(3) + outputs, rs = wallet._construct_outputs([1, 1, 1], secrets, rs) outputs_payload = [o.model_dump() for o in outputs] response = httpx.post( @@ -540,9 +542,9 @@ async def test_melt_external_with_routing_fee(ledger: Ledger, wallet: Wallet): # change must compensate exactly for the unspent part of the reserve change_sat = sum([c.amount for c in resp_quote.change or []]) - assert change_sat == quote.fee_reserve - melt_quote.fee_paid, ( - "Wrong change returned" - ) + assert ( + change_sat == quote.fee_reserve - melt_quote.fee_paid + ), "Wrong change returned" @pytest.mark.asyncio @@ -565,13 +567,14 @@ async def test_melt_external_routing_fee_rounding(ledger: Ledger, wallet: Wallet quote = await wallet.melt_quote(invoice_payment_request) assert quote.amount == 1000 - # fee reserve is 2% of the amount - assert quote.fee_reserve == 20 + assert quote.fee_reserve >= 2 - keep, send = await wallet.swap_to_send(wallet.proofs, 1020) + keep, send = await wallet.swap_to_send( + wallet.proofs, quote.amount + quote.fee_reserve + ) inputs_payload = [p.to_dict() for p in send] - # 5 blank outputs for the change of the 20 sat fee reserve + # Blank outputs for the unspent part of the fee reserve. secrets, rs, derivation_paths = await wallet.generate_n_secrets(5) outputs, rs = wallet._construct_outputs([1, 1, 1, 1, 1], secrets, rs) outputs_payload = [o.model_dump() for o in outputs] @@ -598,7 +601,9 @@ async def test_melt_external_routing_fee_rounding(ledger: Ledger, wallet: Wallet # we get back the fee reserve minus the rounded up fee change_sat = sum([c.amount for c in resp_quote.change or []]) - assert change_sat == 18, "Wrong change returned" + assert ( + change_sat == quote.fee_reserve - melt_quote.fee_paid + ), "Wrong change returned" @pytest.mark.asyncio @@ -706,9 +711,9 @@ async def test_mint_batch_success(ledger: Ledger, wallet: Wallet): timeout=None, ) - assert response.status_code == 200, ( - f"{response.url} {response.status_code} {response.text}" - ) + assert ( + response.status_code == 200 + ), f"{response.url} {response.status_code} {response.text}" result = response.json() assert len(result["signatures"]) == 2 assert result["signatures"][0]["amount"] == 64 diff --git a/tests/mint/test_mint_regtest.py b/tests/mint/test_mint_regtest.py index 55684323..3acf1241 100644 --- a/tests/mint/test_mint_regtest.py +++ b/tests/mint/test_mint_regtest.py @@ -5,8 +5,11 @@ import pytest_asyncio from cashu.core.base import Amount, MeltQuote, MeltQuoteState, Method, Unit +from cashu.core.helpers import fee_reserve from cashu.core.models import PostMeltQuoteRequest from cashu.lightning.base import PaymentResponse +from cashu.lightning.lnd_grpc.lnd_grpc import LndRPCWallet +from cashu.lightning.lndrest import LndRestWallet from cashu.mint.ledger import Ledger from cashu.wallet.wallet import Wallet from tests.conftest import SERVER_ENDPOINT @@ -110,6 +113,44 @@ async def test_lightning_get_payment_quote(ledger: Ledger): assert payment_quote.checking_id +@pytest.mark.asyncio +@pytest.mark.skipif(is_fake, reason="only regtest") +async def test_lnd_payment_quote_uses_probed_fee(ledger: Ledger): + backend = ledger.backends[Method.bolt11][Unit.sat] + if not isinstance(backend, (LndRestWallet, LndRPCWallet)): + pytest.skip("only LND") + + # CLN node 2 is not directly connected to the backend's LND node 3, so + # this invoice requires a multi-hop route with a non-zero fee. + request = get_real_invoice_cln(100_000, node=2) + payment_quote = await backend.get_payment_quote( + PostMeltQuoteRequest(request=request, unit=Unit.sat.name) + ) + + assert payment_quote.amount == Amount(Unit.sat, 100_000) + assert payment_quote.fee.amount > 0 + assert payment_quote.fee < Amount(Unit.msat, fee_reserve(100_000 * 1000)).to( + Unit.sat, round="up" + ) + + quote = MeltQuote( + quote="test", + method=Method.bolt11.name, + unit=Unit.sat.name, + state=MeltQuoteState.unpaid, + request=request, + checking_id=payment_quote.checking_id, + amount=payment_quote.amount.amount, + fee_reserve=payment_quote.fee.amount, + ) + quoted_fee_limit_msat = payment_quote.fee.to(Unit.msat).amount + payment = await backend.pay_invoice(quote, quoted_fee_limit_msat) + + assert payment.settled + assert payment.fee is not None + assert payment.fee <= Amount(Unit.msat, quoted_fee_limit_msat) + + @pytest.mark.asyncio @pytest.mark.skipif(is_fake, reason="only regtest") async def test_lightning_pay_invoice(ledger: Ledger): diff --git a/tests/wallet/test_wallet.py b/tests/wallet/test_wallet.py index 7d7329ba..e64c81a1 100644 --- a/tests/wallet/test_wallet.py +++ b/tests/wallet/test_wallet.py @@ -385,9 +385,9 @@ async def test_melt_routed_invoice(wallet1: Wallet): # external invoice that the mint can only pay through a routing node invoice_payment_request = get_real_invoice_routed(64) - quote = await wallet1.melt_quote(invoice_payment_request) - total_amount = quote.amount + quote.fee_reserve - assert quote.fee_reserve == 2 + quote = await wallet1.melt_quote(invoice_payment_request) + total_amount = quote.amount + quote.fee_reserve + assert quote.fee_reserve > 0 _, send_proofs = await wallet1.swap_to_send(wallet1.proofs, total_amount)