mirror of
https://github.com/aljazceru/nutshell.git
synced 2026-01-04 09:24:22 +01:00
Mint: Talk to LND via gRPC (#595)
* protos + lnd_grpc.py + __init__ + status method * `create_invoice` * `pay_invoice`, `pay_partial_invoice` and router pb2 * `get_invoice_status` * channel keep-alive options * Update lnd_grpc.py * `get_payment_status` + make format * `get_payment_quote` and `paid_invoices_stream`. This was suspiciously easy... * download_and_build script modified to fix the imports on generated code * pyproject with new dependencies * update poetry.lock * fixed errors in `pay_partial_invoice` * update .env.example * make format * enable regtest * update .env.example * suggested fixes * suggested changes pt.2 * Update cashu/core/settings.py Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com> --------- Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
This commit is contained in:
372
cashu/lightning/lnd_grpc/lnd_grpc.py
Normal file
372
cashu/lightning/lnd_grpc/lnd_grpc.py
Normal file
@@ -0,0 +1,372 @@
|
||||
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,
|
||||
PaymentStatus,
|
||||
PostMeltQuoteRequest,
|
||||
StatusResponse,
|
||||
)
|
||||
|
||||
# maps statuses to None, False, True:
|
||||
# https://api.lightning.community/?python=#paymentpaymentstatus
|
||||
PAYMENT_STATUSES = {
|
||||
lnrpc.Payment.PaymentStatus.UNKNOWN: None,
|
||||
lnrpc.Payment.PaymentStatus.IN_FLIGHT: None,
|
||||
lnrpc.Payment.PaymentStatus.INITIATED: None,
|
||||
lnrpc.Payment.PaymentStatus.SUCCEEDED: True,
|
||||
lnrpc.Payment.PaymentStatus.FAILED: False,
|
||||
}
|
||||
INVOICE_STATUSES = {
|
||||
lnrpc.Invoice.InvoiceState.OPEN: None,
|
||||
lnrpc.Invoice.InvoiceState.SETTLED: True,
|
||||
lnrpc.Invoice.InvoiceState.CANCELED: None,
|
||||
lnrpc.Invoice.InvoiceState.ACCEPTED: None,
|
||||
}
|
||||
|
||||
class LndRPCWallet(LightningBackend):
|
||||
|
||||
supports_mpp = settings.mint_lnd_enable_mpp
|
||||
supports_incoming_payment_stream = True
|
||||
supported_units = set([Unit.sat, Unit.msat])
|
||||
unit = Unit.sat
|
||||
|
||||
def __init__(self, unit: Unit = Unit.sat, **kwargs):
|
||||
self.assert_unit_supported(unit)
|
||||
self.unit = unit
|
||||
self.endpoint = settings.mint_lnd_rpc_endpoint
|
||||
cert_path = settings.mint_lnd_rpc_cert
|
||||
|
||||
macaroon_path = settings.mint_lnd_rpc_macaroon
|
||||
|
||||
if not self.endpoint:
|
||||
raise Exception("cannot initialize LndRPCWallet: no 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(
|
||||
ok=False,
|
||||
error_message=error_message,
|
||||
)
|
||||
|
||||
if r.payment_error:
|
||||
return PaymentResponse(
|
||||
ok=False,
|
||||
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(
|
||||
ok=True,
|
||||
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(payer_addr)
|
||||
r.routes[route_nr].hops[-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],
|
||||
)
|
||||
)
|
||||
except AioRpcError as e:
|
||||
logger.error(f"QueryRoute or SendToRouteV2 failed: {e}")
|
||||
return PaymentResponse(
|
||||
ok=False,
|
||||
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(
|
||||
ok=False,
|
||||
error_message=error_message,
|
||||
)
|
||||
|
||||
ok = r.status == lnrpc.HTLCAttempt.HTLCStatus.SUCCEEDED
|
||||
checking_id = invoice.payment_hash
|
||||
fee_msat = r.route.total_fees_msat
|
||||
preimage = r.preimage.hex()
|
||||
return PaymentResponse(
|
||||
ok=ok,
|
||||
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(paid=None)
|
||||
|
||||
return PaymentStatus(paid=INVOICE_STATUSES[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
|
||||
try:
|
||||
checking_id_bytes = bytes.fromhex(checking_id)
|
||||
except ValueError:
|
||||
logger.error(f"Couldn't convert {checking_id = } to bytes")
|
||||
return PaymentStatus(paid=None)
|
||||
|
||||
request = routerrpc.TrackPaymentRequest(payment_hash=checking_id_bytes)
|
||||
|
||||
try:
|
||||
async with grpc.aio.secure_channel(self.endpoint, self.combined_creds) as channel:
|
||||
router_stub = routerstub.RouterStub(channel)
|
||||
async for payment in router_stub.TrackPaymentV2(request):
|
||||
if payment is not None and payment.status:
|
||||
return PaymentStatus(
|
||||
paid=PAYMENT_STATUSES[payment.status],
|
||||
fee=(
|
||||
Amount(unit=Unit.msat, amount=payment.fee_msat)
|
||||
if payment.fee_msat
|
||||
else None
|
||||
),
|
||||
preimage=payment.payment_preimage,
|
||||
)
|
||||
except AioRpcError as e:
|
||||
error_message = f"TrackPaymentV2 failed: {e}"
|
||||
logger.error(error_message)
|
||||
|
||||
return PaymentStatus(paid=None)
|
||||
|
||||
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 = (
|
||||
Amount(Unit[melt_quote.unit], 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:
|
||||
amount_msat = amount.to(Unit.msat).amount
|
||||
else:
|
||||
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"),
|
||||
)
|
||||
Reference in New Issue
Block a user