mirror of
https://github.com/aljazceru/nutshell.git
synced 2025-12-21 11:04:19 +01:00
* fix lndrest * fix clnrest * fix clnrest and lndrest * lnd grpc fix * wallet * convert amount to millisats in CLI pay invoice * fix tests * format * fix kw arg in regtest test * fix payment quote validation check * clean comment * avoid overwriting variable * deprecated response with amount --------- Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
396 lines
14 KiB
Python
396 lines
14 KiB
Python
import asyncio
|
|
import codecs
|
|
import hashlib
|
|
import os
|
|
from typing import AsyncGenerator, Optional
|
|
|
|
import bolt11
|
|
import grpc
|
|
from bolt11 import (
|
|
TagChar,
|
|
)
|
|
from grpc.aio import AioRpcError
|
|
from loguru import logger
|
|
|
|
import cashu.lightning.lnd_grpc.protos.lightning_pb2 as lnrpc
|
|
import cashu.lightning.lnd_grpc.protos.lightning_pb2_grpc as lightningstub
|
|
import cashu.lightning.lnd_grpc.protos.router_pb2 as routerrpc
|
|
import cashu.lightning.lnd_grpc.protos.router_pb2_grpc as routerstub
|
|
from cashu.core.base import Amount, MeltQuote, Unit
|
|
from cashu.core.helpers import fee_reserve
|
|
from cashu.core.settings import settings
|
|
from cashu.lightning.base import (
|
|
InvoiceResponse,
|
|
LightningBackend,
|
|
PaymentQuoteResponse,
|
|
PaymentResponse,
|
|
PaymentResult,
|
|
PaymentStatus,
|
|
PostMeltQuoteRequest,
|
|
StatusResponse,
|
|
)
|
|
|
|
# maps statuses to None, False, True:
|
|
# https://api.lightning.community/?python=#paymentpaymentstatus
|
|
PAYMENT_RESULT_MAP = {
|
|
lnrpc.Payment.PaymentStatus.UNKNOWN: PaymentResult.UNKNOWN,
|
|
lnrpc.Payment.PaymentStatus.IN_FLIGHT: PaymentResult.PENDING,
|
|
lnrpc.Payment.PaymentStatus.INITIATED: PaymentResult.PENDING,
|
|
lnrpc.Payment.PaymentStatus.SUCCEEDED: PaymentResult.SETTLED,
|
|
lnrpc.Payment.PaymentStatus.FAILED: PaymentResult.FAILED,
|
|
}
|
|
INVOICE_RESULT_MAP = {
|
|
lnrpc.Invoice.InvoiceState.OPEN: PaymentResult.PENDING,
|
|
lnrpc.Invoice.InvoiceState.SETTLED: PaymentResult.SETTLED,
|
|
lnrpc.Invoice.InvoiceState.CANCELED: PaymentResult.FAILED,
|
|
lnrpc.Invoice.InvoiceState.ACCEPTED: PaymentResult.PENDING,
|
|
}
|
|
|
|
|
|
class LndRPCWallet(LightningBackend):
|
|
supports_mpp = settings.mint_lnd_enable_mpp
|
|
supports_incoming_payment_stream = True
|
|
supported_units = {Unit.sat, Unit.msat}
|
|
supports_description: bool = True
|
|
|
|
unit = Unit.sat
|
|
|
|
def __init__(self, unit: Unit = Unit.sat, **kwargs):
|
|
self.assert_unit_supported(unit)
|
|
self.unit = unit
|
|
cert_path = settings.mint_lnd_rpc_cert
|
|
|
|
macaroon_path = settings.mint_lnd_rpc_macaroon
|
|
|
|
if not settings.mint_lnd_rpc_endpoint:
|
|
raise Exception("cannot initialize LndRPCWallet: no endpoint")
|
|
|
|
self.endpoint = settings.mint_lnd_rpc_endpoint
|
|
|
|
if not macaroon_path:
|
|
raise Exception("cannot initialize LndRPCWallet: no macaroon")
|
|
|
|
if not cert_path:
|
|
raise Exception("no certificate for LndRPCWallet provided")
|
|
|
|
self.macaroon = codecs.encode(open(macaroon_path, "rb").read(), "hex")
|
|
|
|
def metadata_callback(context, callback):
|
|
callback([("macaroon", self.macaroon)], None)
|
|
|
|
auth_creds = grpc.metadata_call_credentials(metadata_callback)
|
|
|
|
# create SSL credentials
|
|
os.environ["GRPC_SSL_CIPHER_SUITES"] = "HIGH+ECDSA"
|
|
cert = open(cert_path, "rb").read()
|
|
ssl_creds = grpc.ssl_channel_credentials(cert)
|
|
|
|
# combine macaroon and SSL credentials
|
|
self.combined_creds = grpc.composite_channel_credentials(ssl_creds, auth_creds)
|
|
|
|
if self.supports_mpp:
|
|
logger.info("LndRPCWallet enabling MPP feature")
|
|
|
|
async def status(self) -> StatusResponse:
|
|
r = None
|
|
try:
|
|
async with grpc.aio.secure_channel(
|
|
self.endpoint, self.combined_creds
|
|
) as channel:
|
|
lnstub = lightningstub.LightningStub(channel)
|
|
r = await lnstub.ChannelBalance(lnrpc.ChannelBalanceRequest())
|
|
except AioRpcError as e:
|
|
return StatusResponse(
|
|
error_message=f"Error calling Lnd gRPC: {e}", balance=0
|
|
)
|
|
# NOTE: `balance` field is deprecated. Change this.
|
|
return StatusResponse(error_message=None, balance=r.balance * 1000)
|
|
|
|
async def create_invoice(
|
|
self,
|
|
amount: Amount,
|
|
memo: Optional[str] = None,
|
|
description_hash: Optional[bytes] = None,
|
|
unhashed_description: Optional[bytes] = None,
|
|
**kwargs,
|
|
) -> InvoiceResponse:
|
|
self.assert_unit_supported(amount.unit)
|
|
data = lnrpc.Invoice(
|
|
value=amount.to(Unit.sat).amount,
|
|
private=True,
|
|
memo=memo or "",
|
|
)
|
|
if kwargs.get("expiry"):
|
|
data.expiry = kwargs["expiry"]
|
|
if description_hash:
|
|
data.description_hash = description_hash
|
|
elif unhashed_description:
|
|
data.description_hash = hashlib.sha256(unhashed_description).digest()
|
|
|
|
r = None
|
|
try:
|
|
async with grpc.aio.secure_channel(
|
|
self.endpoint, self.combined_creds
|
|
) as channel:
|
|
lnstub = lightningstub.LightningStub(channel)
|
|
r = await lnstub.AddInvoice(data)
|
|
except AioRpcError as e:
|
|
logger.error(f"AddInvoice failed: {e}")
|
|
return InvoiceResponse(
|
|
ok=False,
|
|
error_message=f"AddInvoice failed: {e}",
|
|
)
|
|
|
|
payment_request = r.payment_request
|
|
payment_hash = r.r_hash.hex()
|
|
checking_id = payment_hash
|
|
|
|
return InvoiceResponse(
|
|
ok=True,
|
|
checking_id=checking_id,
|
|
payment_request=payment_request,
|
|
error_message=None,
|
|
)
|
|
|
|
async def pay_invoice(
|
|
self, quote: MeltQuote, fee_limit_msat: int
|
|
) -> PaymentResponse:
|
|
# if the amount of the melt quote is different from the request
|
|
# call pay_partial_invoice instead
|
|
invoice = bolt11.decode(quote.request)
|
|
if invoice.amount_msat:
|
|
amount_msat = int(invoice.amount_msat)
|
|
if amount_msat != quote.amount * 1000 and self.supports_mpp:
|
|
return await self.pay_partial_invoice(
|
|
quote, Amount(Unit.sat, quote.amount), fee_limit_msat
|
|
)
|
|
|
|
# set the fee limit for the payment
|
|
feelimit = lnrpc.FeeLimit(fixed_msat=fee_limit_msat)
|
|
r = None
|
|
try:
|
|
async with grpc.aio.secure_channel(
|
|
self.endpoint, self.combined_creds
|
|
) as channel:
|
|
lnstub = lightningstub.LightningStub(channel)
|
|
r = await lnstub.SendPaymentSync(
|
|
lnrpc.SendRequest(
|
|
payment_request=quote.request,
|
|
fee_limit=feelimit,
|
|
)
|
|
)
|
|
except AioRpcError as e:
|
|
error_message = f"SendPaymentSync failed: {e}"
|
|
return PaymentResponse(
|
|
result=PaymentResult.FAILED,
|
|
error_message=error_message,
|
|
)
|
|
|
|
if r.payment_error:
|
|
return PaymentResponse(
|
|
result=PaymentResult.FAILED,
|
|
error_message=r.payment_error,
|
|
)
|
|
|
|
checking_id = r.payment_hash.hex()
|
|
fee_msat = r.payment_route.total_fees_msat
|
|
preimage = r.payment_preimage.hex()
|
|
return PaymentResponse(
|
|
result=PaymentResult.SETTLED,
|
|
checking_id=checking_id,
|
|
fee=Amount(unit=Unit.msat, amount=fee_msat) if fee_msat else None,
|
|
preimage=preimage,
|
|
error_message=None,
|
|
)
|
|
|
|
async def pay_partial_invoice(
|
|
self, quote: MeltQuote, amount: Amount, fee_limit_msat: int
|
|
) -> PaymentResponse:
|
|
# set the fee limit for the payment
|
|
feelimit = lnrpc.FeeLimit(fixed_msat=fee_limit_msat)
|
|
invoice = bolt11.decode(quote.request)
|
|
|
|
invoice_amount = invoice.amount_msat
|
|
assert invoice_amount, "invoice has no amount."
|
|
total_amount_msat = int(invoice_amount)
|
|
|
|
payee = invoice.tags.get(TagChar.payee)
|
|
assert payee
|
|
pubkey = str(payee.data)
|
|
|
|
payer_addr_tag = invoice.tags.get(bolt11.TagChar("s"))
|
|
assert payer_addr_tag
|
|
payer_addr = str(payer_addr_tag.data)
|
|
|
|
# get the route
|
|
r = None
|
|
try:
|
|
async with grpc.aio.secure_channel(
|
|
self.endpoint, self.combined_creds
|
|
) as channel:
|
|
lnstub = lightningstub.LightningStub(channel)
|
|
router_stub = routerstub.RouterStub(channel)
|
|
r = await lnstub.QueryRoutes(
|
|
lnrpc.QueryRoutesRequest(
|
|
pub_key=pubkey,
|
|
amt=amount.to(Unit.sat).amount,
|
|
fee_limit=feelimit,
|
|
)
|
|
)
|
|
"""
|
|
# We need to set the mpp_record for a partial payment
|
|
mpp_record = lnrpc.MPPRecord(
|
|
payment_addr=bytes.fromhex(payer_addr),
|
|
total_amt_msat=total_amount_msat,
|
|
)
|
|
"""
|
|
# modify the mpp_record in the last hop
|
|
route_nr = 0
|
|
r.routes[route_nr].hops[-1].mpp_record.payment_addr = bytes.fromhex( # type: ignore
|
|
payer_addr
|
|
)
|
|
r.routes[route_nr].hops[ # type: ignore
|
|
-1
|
|
].mpp_record.total_amt_msat = total_amount_msat
|
|
|
|
# Send to route request
|
|
r = await router_stub.SendToRouteV2(
|
|
routerrpc.SendToRouteRequest(
|
|
payment_hash=bytes.fromhex(invoice.payment_hash),
|
|
route=r.routes[route_nr], # type: ignore
|
|
)
|
|
)
|
|
except AioRpcError as e:
|
|
logger.error(f"QueryRoute or SendToRouteV2 failed: {e}")
|
|
return PaymentResponse(
|
|
result=PaymentResult.FAILED,
|
|
error_message=str(e),
|
|
)
|
|
|
|
if r.status == lnrpc.HTLCAttempt.HTLCStatus.FAILED:
|
|
error_message = f"Sending to route failed with code {r.failure.code}"
|
|
logger.error(error_message)
|
|
return PaymentResponse(
|
|
result=PaymentResult.FAILED,
|
|
error_message=error_message,
|
|
)
|
|
|
|
result = PaymentResult.UNKNOWN
|
|
if r.status == lnrpc.HTLCAttempt.HTLCStatus.SUCCEEDED:
|
|
result = PaymentResult.SETTLED
|
|
elif r.status == lnrpc.HTLCAttempt.HTLCStatus.IN_FLIGHT:
|
|
result = PaymentResult.PENDING
|
|
else:
|
|
result = PaymentResult.FAILED
|
|
|
|
checking_id = invoice.payment_hash
|
|
fee_msat = r.route.total_fees_msat
|
|
preimage = r.preimage.hex()
|
|
return PaymentResponse(
|
|
result=result,
|
|
checking_id=checking_id,
|
|
fee=Amount(unit=Unit.msat, amount=fee_msat) if fee_msat else None,
|
|
preimage=preimage,
|
|
error_message=None,
|
|
)
|
|
|
|
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
|
r = None
|
|
try:
|
|
async with grpc.aio.secure_channel(
|
|
self.endpoint, self.combined_creds
|
|
) as channel:
|
|
lnstub = lightningstub.LightningStub(channel)
|
|
r = await lnstub.LookupInvoice(
|
|
lnrpc.PaymentHash(r_hash=bytes.fromhex(checking_id))
|
|
)
|
|
except AioRpcError as e:
|
|
error_message = f"LookupInvoice failed: {e}"
|
|
logger.error(error_message)
|
|
return PaymentStatus(result=PaymentResult.UNKNOWN)
|
|
|
|
return PaymentStatus(
|
|
result=INVOICE_RESULT_MAP[r.state],
|
|
)
|
|
|
|
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
|
"""
|
|
This routine checks the payment status using routerpc.TrackPaymentV2.
|
|
"""
|
|
# convert checking_id from hex to bytes and some LND magic
|
|
checking_id_bytes = bytes.fromhex(checking_id)
|
|
request = routerrpc.TrackPaymentRequest(payment_hash=checking_id_bytes)
|
|
|
|
async with grpc.aio.secure_channel(
|
|
self.endpoint, self.combined_creds
|
|
) as channel:
|
|
router_stub = routerstub.RouterStub(channel)
|
|
try:
|
|
async for payment in router_stub.TrackPaymentV2(request):
|
|
if payment is not None and payment.status:
|
|
preimage = (
|
|
payment.payment_preimage
|
|
if payment.payment_preimage != "0" * 64
|
|
else None
|
|
)
|
|
return PaymentStatus(
|
|
result=PAYMENT_RESULT_MAP[payment.status],
|
|
fee=(
|
|
Amount(unit=Unit.msat, amount=payment.fee_msat)
|
|
if payment.fee_msat
|
|
else None
|
|
),
|
|
preimage=preimage,
|
|
)
|
|
except AioRpcError as e:
|
|
# status = StatusCode.NOT_FOUND
|
|
if e.code() == grpc.StatusCode.NOT_FOUND:
|
|
return PaymentStatus(result=PaymentResult.UNKNOWN)
|
|
|
|
return PaymentStatus(result=PaymentResult.UNKNOWN)
|
|
|
|
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
|
while True:
|
|
try:
|
|
async with grpc.aio.secure_channel(
|
|
self.endpoint, self.combined_creds
|
|
) as channel:
|
|
lnstub = lightningstub.LightningStub(channel)
|
|
async for invoice in lnstub.SubscribeInvoices(
|
|
lnrpc.InvoiceSubscription()
|
|
):
|
|
if invoice.state != lnrpc.Invoice.InvoiceState.SETTLED:
|
|
continue
|
|
payment_hash = invoice.r_hash.hex()
|
|
yield payment_hash
|
|
except AioRpcError as exc:
|
|
logger.error(f"SubscribeInvoices failed: {exc}. Retrying in 1 sec...")
|
|
await asyncio.sleep(1)
|
|
|
|
async def get_payment_quote(
|
|
self, melt_quote: PostMeltQuoteRequest
|
|
) -> PaymentQuoteResponse:
|
|
# get amount from melt_quote or from bolt11
|
|
amount_msat = (
|
|
melt_quote.mpp_amount
|
|
if melt_quote.is_mpp
|
|
else None
|
|
)
|
|
|
|
invoice_obj = bolt11.decode(melt_quote.request)
|
|
assert invoice_obj.amount_msat, "invoice has no amount."
|
|
|
|
if amount_msat is None:
|
|
amount_msat = int(invoice_obj.amount_msat)
|
|
|
|
fees_msat = fee_reserve(amount_msat)
|
|
fees = Amount(unit=Unit.msat, amount=fees_msat)
|
|
|
|
amount = Amount(unit=Unit.msat, amount=amount_msat)
|
|
|
|
return PaymentQuoteResponse(
|
|
checking_id=invoice_obj.payment_hash,
|
|
fee=fees.to(self.unit, round="up"),
|
|
amount=amount.to(self.unit, round="up"),
|
|
)
|