[PATCH] LND use_mission_control + exclude failing channels (#738)

* lnd_grpc multinut patch

* lndrest multinut patch

* mypy fixes

* fixes non escaped double quotes in error messages formats

* fix

* fix debug log with correct hops number

* correctly escape "hops"

* remove `ignored_pairs` constraint

* Apply suggestions from code review

change some error logs to debug

* add tests and some cleanup

---------

Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
This commit is contained in:
lollerfirst
2025-05-10 15:45:15 +02:00
committed by GitHub
parent 3d21443c3c
commit 619d06f0ab
4 changed files with 240 additions and 78 deletions

View File

@@ -46,6 +46,8 @@ INVOICE_RESULT_MAP = {
lnrpc.Invoice.InvoiceState.ACCEPTED: PaymentResult.PENDING, lnrpc.Invoice.InvoiceState.ACCEPTED: PaymentResult.PENDING,
} }
MAX_ROUTE_RETRIES = 50
class LndRPCWallet(LightningBackend): class LndRPCWallet(LightningBackend):
supports_mpp = settings.mint_lnd_enable_mpp supports_mpp = settings.mint_lnd_enable_mpp
@@ -206,6 +208,8 @@ class LndRPCWallet(LightningBackend):
async def pay_partial_invoice( async def pay_partial_invoice(
self, quote: MeltQuote, amount: Amount, fee_limit_msat: int self, quote: MeltQuote, amount: Amount, fee_limit_msat: int
) -> PaymentResponse: ) -> PaymentResponse:
total_attempts = 0
# set the fee limit for the payment # set the fee limit for the payment
feelimit = lnrpc.FeeLimit(fixed_msat=fee_limit_msat) feelimit = lnrpc.FeeLimit(fixed_msat=fee_limit_msat)
invoice = bolt11.decode(quote.request) invoice = bolt11.decode(quote.request)
@@ -223,46 +227,58 @@ class LndRPCWallet(LightningBackend):
payer_addr = str(payer_addr_tag.data) payer_addr = str(payer_addr_tag.data)
# get the route # get the route
r = None response: Optional[lnrpc.HTLCAttempt] = None # type: ignore
route: Optional[lnrpc.Rou] = None # type: ignore
try: try:
async with grpc.aio.secure_channel( async with grpc.aio.secure_channel(
self.endpoint, self.combined_creds self.endpoint, self.combined_creds
) as channel: ) as channel:
lnstub = lightningstub.LightningStub(channel) lnstub = lightningstub.LightningStub(channel)
router_stub = routerstub.RouterStub(channel) router_stub = routerstub.RouterStub(channel)
r = await lnstub.QueryRoutes(
for attempt in range(MAX_ROUTE_RETRIES):
total_attempts += 1
route = await lnstub.QueryRoutes(
lnrpc.QueryRoutesRequest( lnrpc.QueryRoutesRequest(
pub_key=pubkey, pub_key=pubkey,
amt=amount.to(Unit.sat).amount, amt=amount.to(Unit.sat).amount,
fee_limit=feelimit, fee_limit=feelimit,
use_mission_control=True,
) )
) )
""" assert route
# We need to set the mpp_record for a partial payment logger.debug(
mpp_record = lnrpc.MPPRecord( f"QueryRoutes returned {len(route.routes)} available routes."
payment_addr=bytes.fromhex(payer_addr),
total_amt_msat=total_amount_msat,
) )
""" route.routes[0].hops[-1].mpp_record.payment_addr = bytes.fromhex( # type: ignore
# modify the mpp_record in the last hop
for route_nr in range(len(r.routes)):
r.routes[route_nr].hops[-1].mpp_record.payment_addr = bytes.fromhex( # type: ignore
payer_addr payer_addr
) )
r.routes[route_nr].hops[ # type: ignore route.routes[0].hops[ # type: ignore
-1 -1
].mpp_record.total_amt_msat = total_amount_msat ].mpp_record.total_amt_msat = total_amount_msat
# Send to route request # Send to route request
r = await router_stub.SendToRouteV2( response = await router_stub.SendToRouteV2(
routerrpc.SendToRouteRequest( routerrpc.SendToRouteRequest(
payment_hash=bytes.fromhex(invoice.payment_hash), payment_hash=bytes.fromhex(invoice.payment_hash),
route=r.routes[route_nr], # type: ignore route=route.routes[0], # type: ignore
) )
) )
if r.status == lnrpc.HTLCAttempt.HTLCStatus.FAILED: assert route and response
if r.failure.code == lnrpc.Failure.FailureCode.TEMPORARY_CHANNEL_FAILURE: if response.status == lnrpc.HTLCAttempt.HTLCStatus.FAILED:
# Try a different route if (
response.failure.code
== lnrpc.Failure.FailureCode.TEMPORARY_CHANNEL_FAILURE
):
# Add the channels that failed to the excluded channels
failure_index = response.failure.failure_source_index
failed_source = (
route.routes[0].hops[failure_index - 1].pub_key
)
failed_dest = route.routes[0].hops[failure_index].pub_key
logger.debug(
f"Partial payment failed from {failed_source} to {failed_dest} at index {failure_index-1} of the route"
)
continue continue
break break
except AioRpcError as e: except AioRpcError as e:
@@ -272,25 +288,29 @@ class LndRPCWallet(LightningBackend):
error_message=str(e), error_message=str(e),
) )
if r.status == lnrpc.HTLCAttempt.HTLCStatus.FAILED: assert route and response
error_message = f"Sending to route failed with code {r.failure.code}" if response.status == lnrpc.HTLCAttempt.HTLCStatus.FAILED:
logger.error(error_message) error_message = f"Sending to route failed with code {response.failure.code} after {total_attempts} different tries."
return PaymentResponse( return PaymentResponse(
result=PaymentResult.FAILED, result=PaymentResult.FAILED,
error_message=error_message, error_message=error_message,
) )
result = PaymentResult.UNKNOWN if response.status == lnrpc.HTLCAttempt.HTLCStatus.SUCCEEDED:
if r.status == lnrpc.HTLCAttempt.HTLCStatus.SUCCEEDED:
result = PaymentResult.SETTLED result = PaymentResult.SETTLED
elif r.status == lnrpc.HTLCAttempt.HTLCStatus.IN_FLIGHT: elif response.status == lnrpc.HTLCAttempt.HTLCStatus.IN_FLIGHT:
result = PaymentResult.PENDING result = PaymentResult.PENDING
else: else:
result = PaymentResult.FAILED result = PaymentResult.UNKNOWN
checking_id = invoice.payment_hash checking_id = invoice.payment_hash
fee_msat = r.route.total_fees_msat fee_msat = response.route.total_fees_msat
preimage = r.preimage.hex() preimage = response.preimage.hex()
logger.debug(f"Partial payment succeeded after {total_attempts} tries!")
logger.debug(
f"Partial payment route length was {len(route.routes[0].hops)} hops."
)
return PaymentResponse( return PaymentResponse(
result=result, result=result,
checking_id=checking_id, checking_id=checking_id,
@@ -376,11 +396,7 @@ class LndRPCWallet(LightningBackend):
self, melt_quote: PostMeltQuoteRequest self, melt_quote: PostMeltQuoteRequest
) -> PaymentQuoteResponse: ) -> PaymentQuoteResponse:
# get amount from melt_quote or from bolt11 # get amount from melt_quote or from bolt11
amount_msat = ( amount_msat = melt_quote.mpp_amount if melt_quote.is_mpp else None
melt_quote.mpp_amount
if melt_quote.is_mpp
else None
)
invoice_obj = bolt11.decode(melt_quote.request) invoice_obj = bolt11.decode(melt_quote.request)
assert invoice_obj.amount_msat, "invoice has no amount." assert invoice_obj.amount_msat, "invoice has no amount."

View File

@@ -41,6 +41,9 @@ INVOICE_RESULT_MAP = {
"ACCEPTED": PaymentResult.PENDING, "ACCEPTED": PaymentResult.PENDING,
} }
MAX_ROUTE_RETRIES = 50
TEMPORARY_CHANNEL_FAILURE_ERROR = "TEMPORARY_CHANNEL_FAILURE"
class LndRestWallet(LightningBackend): class LndRestWallet(LightningBackend):
"""https://api.lightning.community/rest/index.html#lnd-rest-api-reference""" """https://api.lightning.community/rest/index.html#lnd-rest-api-reference"""
@@ -219,6 +222,8 @@ class LndRestWallet(LightningBackend):
async def pay_partial_invoice( async def pay_partial_invoice(
self, quote: MeltQuote, amount: Amount, fee_limit_msat: int self, quote: MeltQuote, amount: Amount, fee_limit_msat: int
) -> PaymentResponse: ) -> PaymentResponse:
attempts = 0
# set the fee limit for the payment # set the fee limit for the payment
lnrpcFeeLimit = dict() lnrpcFeeLimit = dict()
lnrpcFeeLimit["fixed_msat"] = f"{fee_limit_msat}" lnrpcFeeLimit["fixed_msat"] = f"{fee_limit_msat}"
@@ -236,16 +241,27 @@ class LndRestWallet(LightningBackend):
assert payer_addr_tag assert payer_addr_tag
payer_addr = str(payer_addr_tag.data) payer_addr = str(payer_addr_tag.data)
# add the mpp_record to the last hop
response: Optional[httpx.Response] = None
route: Optional[httpx.Response] = None
for attempt in range(MAX_ROUTE_RETRIES):
attempts += 1
# get the route # get the route
r = await self.client.post( route = await self.client.post(
url=f"/v1/graph/routes/{pubkey}/{amount.to(Unit.sat).amount}", url=f"/v1/graph/routes/{pubkey}/{amount.to(Unit.sat).amount}",
json={"fee_limit": lnrpcFeeLimit}, json={
"fee_limit": lnrpcFeeLimit,
"use_mission_control": True,
},
timeout=None, timeout=None,
) )
data = r.json() assert route
if r.is_error or data.get("message"):
error_message = data.get("message") or r.text route_data = route.json()
if route.is_error or route_data.get("message"):
error_message = route_data.get("message") or route.text
return PaymentResponse( return PaymentResponse(
result=PaymentResult.FAILED, error_message=error_message result=PaymentResult.FAILED, error_message=error_message
) )
@@ -253,39 +269,50 @@ class LndRestWallet(LightningBackend):
# We need to set the mpp_record for a partial payment # We need to set the mpp_record for a partial payment
mpp_record = { mpp_record = {
"mpp_record": { "mpp_record": {
"payment_addr": base64.b64encode(bytes.fromhex(payer_addr)).decode(), "payment_addr": base64.b64encode(
bytes.fromhex(payer_addr)
).decode(),
"total_amt_msat": total_amount_msat, "total_amt_msat": total_amount_msat,
} }
} }
route_data["routes"][0]["hops"][-1].update(mpp_record)
# add the mpp_record to the last hop
r = None # type: ignore
for route_nr in range(len(data["routes"])):
logger.debug(f"Trying to pay partial amount with route number {route_nr+1}")
data["routes"][route_nr]["hops"][-1].update(mpp_record)
# send to route # send to route
r = await self.client.post( response = await self.client.post(
url="/v2/router/route/send", url="/v2/router/route/send",
json={ json={
"payment_hash": base64.b64encode( "payment_hash": base64.b64encode(
bytes.fromhex(invoice.payment_hash) bytes.fromhex(invoice.payment_hash)
).decode(), ).decode(),
"route": data["routes"][route_nr], "route": route_data["routes"][0],
}, },
timeout=None, timeout=None,
) )
data = r.json() assert response
failure = data.get("failure")
if failure: response_data = response.json()
if failure["code"] == 15: if response_data.get("status") == "FAILED":
# Try with a different route if response_data["failure"]["code"] == TEMPORARY_CHANNEL_FAILURE_ERROR:
# Add the channels that failed to the excluded channels
failure_index = response_data["failure"]["failure_source_index"]
failed_source = route_data["routes"][0]["hops"][failure_index - 1][
"pub_key"
]
failed_dest = route_data["routes"][0]["hops"][failure_index][
"pub_key"
]
logger.debug(
f"Partial payment failed from {failed_source} to {failed_dest} at index {failure_index-1} of the route"
)
continue continue
break break
if r.is_error or data.get("message"): assert response and route
error_message = data.get("message") or r.text
data = response.json()
if response.is_error or data.get("message") or data.get("status") == "FAILED":
error_message = f"Sending to route failed with code {data.get('failure').get('code')} after {attempts} tries."
return PaymentResponse( return PaymentResponse(
result=PaymentResult.FAILED, error_message=error_message result=PaymentResult.FAILED, error_message=error_message
) )
@@ -296,6 +323,11 @@ class LndRestWallet(LightningBackend):
preimage = ( preimage = (
base64.b64decode(data["preimage"]).hex() if data.get("preimage") else None base64.b64decode(data["preimage"]).hex() if data.get("preimage") else None
) )
logger.debug(f"Partial payment succeeded after {attempts} different tries!")
logger.debug(
f"Partial payment route length was {len(route.json().get('routes'))} hops."
)
return PaymentResponse( return PaymentResponse(
result=result, result=result,
checking_id=checking_id, checking_id=checking_id,

View File

@@ -545,6 +545,7 @@ class Ledger(
melt_quote.is_mpp melt_quote.is_mpp
and melt_quote.mpp_amount != payment_quote.amount.to(Unit.msat).amount and melt_quote.mpp_amount != payment_quote.amount.to(Unit.msat).amount
): ):
logger.error(f"expected {payment_quote.amount.to(Unit.msat).amount} msat but got {melt_quote.mpp_amount}")
raise TransactionError("quote amount not as requested") raise TransactionError("quote amount not as requested")
# make sure the backend returned the amount with a correct unit # make sure the backend returned the amount with a correct unit
if not payment_quote.amount.unit == unit: if not payment_quote.amount.unit == unit:

View File

@@ -2,17 +2,21 @@ import asyncio
import threading import threading
from typing import List from typing import List
import bolt11
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from cashu.core.base import Method, Proof from cashu.core.base import MeltQuote, MeltQuoteState, Method, Proof
from cashu.lightning.base import PaymentResponse
from cashu.lightning.clnrest import CLNRestWallet from cashu.lightning.clnrest import CLNRestWallet
from cashu.mint.ledger import Ledger from cashu.mint.ledger import Ledger
from cashu.wallet.wallet import Wallet from cashu.wallet.wallet import Wallet
from tests.conftest import SERVER_ENDPOINT from tests.conftest import SERVER_ENDPOINT
from tests.helpers import ( from tests.helpers import (
SLEEP_TIME,
assert_err, assert_err,
get_real_invoice, cancel_invoice,
get_hold_invoice,
is_fake, is_fake,
partial_pay_real_invoice, partial_pay_real_invoice,
pay_if_regtest, pay_if_regtest,
@@ -47,8 +51,8 @@ async def test_regtest_pay_mpp(wallet: Wallet, ledger: Ledger):
assert wallet.balance == 128 assert wallet.balance == 128
# this is the invoice we want to pay in two parts # this is the invoice we want to pay in two parts
invoice_dict = get_real_invoice(64) preimage, invoice_dict = get_hold_invoice(64)
invoice_payment_request = invoice_dict["payment_request"] invoice_payment_request = str(invoice_dict["payment_request"])
async def _mint_pay_mpp(invoice: str, amount: int, proofs: List[Proof]): async def _mint_pay_mpp(invoice: str, amount: int, proofs: List[Proof]):
# wallet pays 32 sat of the invoice # wallet pays 32 sat of the invoice
@@ -112,13 +116,15 @@ async def test_regtest_pay_mpp_incomplete_payment(wallet: Wallet, ledger: Ledger
assert wallet.balance == 384 assert wallet.balance == 384
# this is the invoice we want to pay in two parts # this is the invoice we want to pay in two parts
invoice_dict = get_real_invoice(64) preimage, invoice_dict = get_hold_invoice(64)
invoice_payment_request = invoice_dict["payment_request"] invoice_payment_request = str(invoice_dict["payment_request"])
async def pay_mpp(amount: int, proofs: List[Proof], delay: float = 0.0): async def pay_mpp(amount: int, proofs: List[Proof], delay: float = 0.0):
await asyncio.sleep(delay) await asyncio.sleep(delay)
# wallet pays 32 sat of the invoice # wallet pays 32 sat of the invoice
quote = await wallet.melt_quote(invoice_payment_request, amount_msat=amount*1000) quote = await wallet.melt_quote(
invoice_payment_request, amount_msat=amount * 1000
)
assert quote.amount == amount assert quote.amount == amount
await wallet.melt( await wallet.melt(
proofs, proofs,
@@ -156,3 +162,110 @@ async def test_regtest_internal_mpp_melt_quotes(wallet: Wallet, ledger: Ledger):
await assert_err( await assert_err(
wallet.melt_quote(mint_quote.request, 100 * 1000), "internal mpp not allowed" wallet.melt_quote(mint_quote.request, 100 * 1000), "internal mpp not allowed"
) )
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only regtest")
async def test_regtest_pay_mpp_cancel_payment(wallet: Wallet, ledger: Ledger):
# make sure that mpp is supported by the bolt11-sat backend
if not ledger.backends[Method["bolt11"]][wallet.unit].supports_mpp:
pytest.skip("backend does not support mpp")
# make sure wallet knows the backend supports mpp
assert wallet.mint_info.supports_mpp("bolt11", wallet.unit)
# top up wallet so we have enough for the payment
topup_mint_quote = await wallet.request_mint(128)
await pay_if_regtest(topup_mint_quote.request)
proofs1 = await wallet.mint(128, quote_id=topup_mint_quote.quote)
assert wallet.balance == 128
# create a hold invoice that we can cancel
preimage, invoice_dict = get_hold_invoice(64)
invoice_payment_request = str(invoice_dict.get("payment_request", ""))
invoice_obj = bolt11.decode(invoice_payment_request)
payment_hash = invoice_obj.payment_hash
async def _mint_pay_mpp(invoice: str, amount: int, proofs: List[Proof]):
# wallet pays 32 sat of the invoice
quote = await wallet.melt_quote(invoice, amount_msat=amount * 1000)
assert quote.amount == amount
await wallet.melt(
proofs,
invoice,
fee_reserve_sat=quote.fee_reserve,
quote_id=quote.quote,
)
def mint_pay_mpp(invoice: str, amount: int, proofs: List[Proof]):
asyncio.run(_mint_pay_mpp(invoice, amount, proofs))
# start the MPP payment
t1 = threading.Thread(
target=mint_pay_mpp, args=(invoice_payment_request, 32, proofs1)
)
t1.start()
await asyncio.sleep(SLEEP_TIME)
# cancel the invoice
cancel_invoice(payment_hash)
await asyncio.sleep(SLEEP_TIME)
# check the payment status
status = await ledger.backends[Method["bolt11"]][wallet.unit].get_payment_status(
payment_hash
)
assert status.failed # some backends return unknown instead of failed
assert not status.preimage # no preimage since payment failed
# check that the proofs are unspent since payment failed
states = await wallet.check_proof_state(proofs1)
assert all([s.unspent for s in states.states])
@pytest.mark.asyncio
@pytest.mark.skipif(is_fake, reason="only regtest")
async def test_regtest_pay_mpp_cancel_payment_pay_partial_invoice(
wallet: Wallet, ledger: Ledger
):
# create a hold invoice that we can cancel
preimage, invoice_dict = get_hold_invoice(64)
invoice_payment_request = str(invoice_dict.get("payment_request", ""))
invoice_obj = bolt11.decode(invoice_payment_request)
payment_hash = invoice_obj.payment_hash
# Use a shared container to store the result
result_container = []
async def _mint_pay_mpp(invoice: str, amount: int) -> PaymentResponse:
ret = await ledger.backends[Method["bolt11"]][wallet.unit].pay_invoice(
MeltQuote(
request=invoice,
amount=amount,
fee_reserve=0,
quote="",
method="bolt11",
checking_id="",
unit=wallet.unit.name,
state=MeltQuoteState.pending,
),
0,
)
return ret
# Create a wrapper function that will store the result
def thread_func():
result = asyncio.run(_mint_pay_mpp(invoice_payment_request, 32))
result_container.append(result)
t1 = threading.Thread(target=thread_func)
t1.start()
await asyncio.sleep(SLEEP_TIME)
# cancel the invoice
cancel_invoice(payment_hash)
await asyncio.sleep(SLEEP_TIME)
t1.join()
# Get the result from the container
assert result_container[0].failed