Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions cashu/lightning/lnd_grpc/lnd_grpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@

MAX_ROUTE_RETRIES = 50
PAYMENT_TIMEOUT_SECONDS = 60
FEE_PROBE_TIMEOUT_SECONDS = 5


class LndRPCWallet(LightningBackend):
Expand Down Expand Up @@ -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)
Expand Down
29 changes: 26 additions & 3 deletions cashu/lightning/lndrest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
85 changes: 85 additions & 0 deletions tests/lightning/test_lightning_backends_mocked.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
41 changes: 23 additions & 18 deletions tests/mint/test_mint_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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]
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions tests/mint/test_mint_regtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
6 changes: 3 additions & 3 deletions tests/wallet/test_wallet.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading