Files
lnbits/lnbits/wallets/lndgrpc.py
T

380 lines
13 KiB
Python

import asyncio
import base64
from collections.abc import AsyncGenerator
from hashlib import sha256
from os import environ
import grpc
from loguru import logger
from lnbits.helpers import normalize_endpoint
from lnbits.settings import settings
from lnbits.utils.crypto import random_secret_and_hash
from lnbits.wallets.lnd_grpc_files.invoices_pb2 import (
AddHoldInvoiceRequest,
AddHoldInvoiceResp,
CancelInvoiceMsg,
CancelInvoiceResp,
LookupInvoiceMsg,
SettleInvoiceMsg,
SettleInvoiceResp,
)
from lnbits.wallets.lnd_grpc_files.invoices_pb2_grpc import InvoicesStub
from lnbits.wallets.lnd_grpc_files.lightning_pb2 import (
AddInvoiceResponse,
ChannelBalanceRequest,
ChannelBalanceResponse,
Invoice,
InvoiceSubscription,
Payment,
PaymentFailureReason,
)
from lnbits.wallets.lnd_grpc_files.lightning_pb2_grpc import LightningStub
from lnbits.wallets.lnd_grpc_files.router_pb2 import (
SendPaymentRequest,
TrackPaymentRequest,
)
from lnbits.wallets.lnd_grpc_files.router_pb2_grpc import RouterStub
from .base import (
Feature,
InvoiceResponse,
PaymentFailedStatus,
PaymentPendingStatus,
PaymentResponse,
PaymentStatus,
PaymentSuccessStatus,
StatusResponse,
Wallet,
)
from .macaroon import load_macaroon
def b64_to_bytes(checking_id: str) -> bytes:
return base64.b64decode(checking_id.replace("_", "/"))
def bytes_to_b64(r_hash: bytes) -> str:
return base64.b64encode(r_hash).decode().replace("/", "_")
def hex_to_b64(hex_str: str) -> str:
try:
return base64.b64encode(bytes.fromhex(hex_str)).decode()
except ValueError:
return ""
def hex_to_bytes(hex_str: str) -> bytes:
try:
return bytes.fromhex(hex_str)
except Exception:
return b""
def bytes_to_hex(b: bytes) -> str:
return b.hex()
# Due to updated ECDSA generated tls.cert we need to let gprc know that
# we need to use that cipher suite otherwise there will be a handhsake
# error when we communicate with the lnd rpc server.
environ["GRPC_SSL_CIPHER_SUITES"] = "HIGH+ECDSA"
class LndWallet(Wallet):
rpc: LightningStub
router_rpc: RouterStub
invoices_rpc: InvoicesStub
features = [Feature.holdinvoice]
def __init__(self):
if not settings.lnd_grpc_endpoint:
raise ValueError("cannot initialize LndWallet: missing lnd_grpc_endpoint")
if not settings.lnd_grpc_port:
raise ValueError("cannot initialize LndWallet: missing lnd_grpc_port")
cert_path = settings.lnd_grpc_cert or settings.lnd_cert
if not cert_path:
raise ValueError(
"cannot initialize LndWallet: missing lnd_grpc_cert or lnd_cert"
)
self.endpoint = normalize_endpoint(settings.lnd_grpc_endpoint, add_proto=False)
self.port = int(settings.lnd_grpc_port)
macaroon = (
settings.lnd_grpc_macaroon
or settings.lnd_grpc_admin_macaroon
or settings.lnd_grpc_invoice_macaroon
)
encrypted_macaroon = settings.lnd_grpc_macaroon_encrypted
try:
self.macaroon = load_macaroon(macaroon, encrypted_macaroon)
except ValueError as exc:
raise ValueError(f"cannot load macaroon for LndWallet: {exc!s}") from exc
cert = open(cert_path, "rb").read()
creds = grpc.ssl_channel_credentials(cert)
auth_creds = grpc.metadata_call_credentials(self.metadata_callback) # type: ignore[reportArgumentType]
composite_creds = grpc.composite_channel_credentials(creds, auth_creds)
channel = grpc.aio.secure_channel(
f"{self.endpoint}:{self.port}", composite_creds
)
self.rpc = LightningStub(channel)
self.router_rpc = RouterStub(channel)
self.invoices_rpc = InvoicesStub(channel)
def metadata_callback(self, _, callback):
callback([("macaroon", self.macaroon)], None)
async def cleanup(self):
pass
async def status(self) -> StatusResponse:
try:
req = ChannelBalanceRequest()
res: ChannelBalanceResponse = await self.rpc.ChannelBalance(req)
except Exception as exc:
return StatusResponse(f"Unable to connect, got: '{exc}'", 0)
return StatusResponse(None, res.balance * 1000)
async def create_invoice(
self,
amount: int,
memo: str | None = None,
description_hash: bytes | None = None,
unhashed_description: bytes | None = None,
**kwargs,
) -> InvoiceResponse:
preimage = kwargs.get("preimage")
if preimage:
payment_hash = sha256(preimage.encode()).hexdigest()
else:
preimage, payment_hash = random_secret_and_hash()
invoice = Invoice(
value=amount,
private=True,
memo=memo or "",
r_hash=bytes.fromhex(payment_hash),
r_preimage=bytes.fromhex(preimage),
)
if kwargs.get("expiry"):
invoice.expiry = kwargs.get("expiry", 3600)
if description_hash:
invoice.description_hash = description_hash
elif unhashed_description:
invoice.description_hash = sha256(unhashed_description).digest()
try:
res: AddInvoiceResponse = await self.rpc.AddInvoice(invoice)
except Exception as exc:
logger.warning(exc)
return InvoiceResponse(ok=False, error_message=str(exc))
return InvoiceResponse(
ok=True,
checking_id=bytes_to_hex(res.r_hash),
payment_request=res.payment_request,
preimage=preimage,
)
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
# fee_limit_fixed = ln.FeeLimit(fixed=fee_limit_msat // 1000)
req = SendPaymentRequest(
payment_request=bolt11,
fee_limit_msat=fee_limit_msat,
timeout_seconds=30,
no_inflight_updates=True,
max_parts=16,
time_pref=0.9,
)
try:
res: Payment = await self.router_rpc.SendPaymentV2(req).read()
except Exception as exc:
logger.warning(exc)
return PaymentResponse(error_message=str(exc))
if res.status == Payment.PaymentStatus.SUCCEEDED:
return PaymentResponse(
ok=True,
checking_id=res.payment_hash,
fee_msat=abs(res.fee_msat),
preimage=res.payment_preimage,
)
elif res.status == Payment.PaymentStatus.FAILED:
error_message = PaymentFailureReason.Name(res.failure_reason)
return PaymentResponse(
ok=False, error_message=f"Payment failed: {error_message}"
)
elif res.status == Payment.PaymentStatus.IN_FLIGHT:
return PaymentResponse(
ok=None,
checking_id=res.payment_hash,
error_message="Payment is IN_FLIGHT.",
)
else:
return PaymentResponse(
ok=None,
checking_id=res.payment_hash,
error_message="Payment is non-existant.",
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try:
r_hash = hex_to_bytes(checking_id)
if len(r_hash) != 32:
# this may happen if we switch between backend wallets
# that use different checking_id formats
raise ValueError
req = LookupInvoiceMsg(payment_hash=r_hash)
res: Invoice = await self.invoices_rpc.LookupInvoiceV2(req)
except grpc.aio.AioRpcError as exc:
logger.warning(
f"LndWallet.get_invoice_status grpc exception: {exc.details()}"
)
return PaymentPendingStatus()
except Exception as exc:
logger.warning(f"LndWallet.get_invoice_status exception: {exc}")
return PaymentPendingStatus()
if res.settled:
return PaymentSuccessStatus(preimage=res.r_preimage.hex())
if res.state == Invoice.InvoiceState.CANCELED:
return PaymentFailedStatus()
return PaymentPendingStatus()
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
"""
This routine checks the payment status using router_rpc.TrackPaymentV2.
https://lightning.engineering/api-docs/api/lnd/router/track-payment-v2/#lnrpcpayment
"""
try:
r_hash = hex_to_bytes(checking_id)
except ValueError:
logger.error(
f"LndWallet: Invalid checking_id ({checking_id}),"
" was the fundingsource changed? Returning pending status."
)
return PaymentPendingStatus()
try:
req = TrackPaymentRequest(payment_hash=r_hash)
res = self.router_rpc.TrackPaymentV2(req)
except grpc.aio.AioRpcError as exc:
logger.error(
f"Payment Status grpc exception: {exc.details() or exc.code()}"
)
return PaymentPendingStatus()
except Exception as exc: # most likely the payment wasn't found
logger.error(f"Payment Status exception: {exc}")
return PaymentPendingStatus()
try:
async for payment in res:
if payment.status == Payment.PaymentStatus.SUCCEEDED:
return PaymentSuccessStatus(
fee_msat=abs(payment.fee_msat),
preimage=payment.payment_preimage,
)
elif payment.status == Payment.PaymentStatus.FAILED:
logger.info(f"LND Payment failed: {payment.failure_reason}")
return PaymentFailedStatus()
elif payment.status == Payment.PaymentStatus.IN_FLIGHT:
logger.info(f"LND Payment in flight: {checking_id}")
return PaymentPendingStatus()
except grpc.aio.AioRpcError as exc:
logger.error(
f"Payment Status grpc exception: {exc.details() or exc.code()}"
)
return PaymentPendingStatus()
logger.info(f"LND Payment non-existent: {checking_id}")
return PaymentPendingStatus()
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while settings.lnbits_running:
try:
req = InvoiceSubscription()
async for i in self.rpc.SubscribeInvoices(req):
if not i.settled:
continue
checking_id = bytes_to_hex(i.r_hash)
yield checking_id
except Exception as exc:
logger.error(
f"lost connection to lnd invoices stream: '{exc}', "
"retrying in 5 seconds"
)
await asyncio.sleep(5)
async def create_hold_invoice(
self,
amount: int,
payment_hash: str,
memo: str | None = None,
description_hash: bytes | None = None,
unhashed_description: bytes | None = None,
**kwargs,
) -> InvoiceResponse:
hold_invoice = AddHoldInvoiceRequest(
value=amount,
hash=hex_to_bytes(payment_hash),
private=True,
memo=memo or "",
)
if kwargs.get("expiry"):
hold_invoice.expiry = kwargs.get("expiry", 3600)
if description_hash:
hold_invoice.description_hash = description_hash
elif unhashed_description:
hold_invoice.description_hash = sha256(unhashed_description).digest()
try:
res: AddHoldInvoiceResp = await self.invoices_rpc.AddHoldInvoice(
hold_invoice
)
logger.debug(f"AddHoldInvoice response: {res}")
except Exception as exc:
logger.warning(exc)
return InvoiceResponse(ok=False, error_message=str(exc))
return InvoiceResponse(
ok=True, checking_id=payment_hash, payment_request=res.payment_request
)
async def settle_hold_invoice(self, preimage: str) -> InvoiceResponse:
try:
req = SettleInvoiceMsg(preimage=hex_to_bytes(preimage))
res: SettleInvoiceResp = await self.invoices_rpc.SettleInvoice(req)
logger.debug(f"SettleInvoice response: {res}")
except grpc.aio.AioRpcError as exc:
return InvoiceResponse(
ok=False, error_message=exc.details() or "unknown grpc exception"
)
except Exception as exc:
logger.warning(exc)
return InvoiceResponse(ok=False, error_message=str(exc))
return InvoiceResponse(ok=True, preimage=preimage)
async def cancel_hold_invoice(self, payment_hash: str) -> InvoiceResponse:
try:
req = CancelInvoiceMsg(payment_hash=hex_to_bytes(payment_hash))
res: CancelInvoiceResp = await self.invoices_rpc.CancelInvoice(req)
logger.debug(f"CancelInvoice response: {res}")
except Exception as exc:
logger.warning(exc)
# If we cannot cancel the invoice, we return an error message
# and True for ok that should be ignored by the service
return InvoiceResponse(
ok=False, checking_id=payment_hash, error_message=str(exc)
)
# If we reach here, the invoice was successfully canceled and payment failed
return InvoiceResponse(True, checking_id=payment_hash)