diff --git a/lnbits/app.py b/lnbits/app.py index 9909982..7a30e15 100644 --- a/lnbits/app.py +++ b/lnbits/app.py @@ -1,4 +1,5 @@ import importlib +import warnings from quart import g from quart_trio import QuartTrio @@ -12,6 +13,7 @@ from .db import open_db, open_ext_db from .helpers import get_valid_extensions, get_js_vendored, get_css_vendored, url_for_vendored from .proxy_fix import ASGIProxyFix from .tasks import run_deferred_async, invoice_listener, webhook_handler, grab_app_for_later +from .settings import WALLET secure_headers = SecureHeaders(hsts=False) @@ -27,6 +29,7 @@ def create_app(config_object="lnbits.settings") -> QuartTrio: cors(app) Compress(app) + check_funding_source(app) register_assets(app) register_blueprints(app) register_filters(app) @@ -38,6 +41,19 @@ def create_app(config_object="lnbits.settings") -> QuartTrio: return app +def check_funding_source(app: QuartTrio) -> None: + @app.before_serving + async def check_wallet_status(): + error_message, balance = WALLET.status() + if error_message: + warnings.warn( + f" × The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'", + RuntimeWarning, + ) + else: + print(f" ✔️ {WALLET.__class__.__name__} seems to be connected and with a balance of {balance} msat.") + + def register_blueprints(app: QuartTrio) -> None: """Register Flask blueprints / LNbits extensions.""" app.register_blueprint(core_app) diff --git a/lnbits/extensions/lnurlp/tasks.py b/lnbits/extensions/lnurlp/tasks.py index 00e6da9..27bb915 100644 --- a/lnbits/extensions/lnurlp/tasks.py +++ b/lnbits/extensions/lnurlp/tasks.py @@ -39,5 +39,5 @@ async def on_invoice_paid(payment: Payment) -> None: timeout=40, ) mark_webhook_sent(payment.payment_hash, r.status_code) - except httpx.RequestError: + except (httpx.ConnectError, httpx.RequestError): mark_webhook_sent(payment.payment_hash, -1) diff --git a/lnbits/wallets/base.py b/lnbits/wallets/base.py index 126283e..d4484f3 100644 --- a/lnbits/wallets/base.py +++ b/lnbits/wallets/base.py @@ -2,6 +2,11 @@ from abc import ABC, abstractmethod from typing import NamedTuple, Optional, AsyncGenerator +class StatusResponse(NamedTuple): + error_message: Optional[str] + balance_msat: int + + class InvoiceResponse(NamedTuple): ok: bool checking_id: Optional[str] = None # payment_hash, rpc_id @@ -25,6 +30,10 @@ class PaymentStatus(NamedTuple): class Wallet(ABC): + @abstractmethod + def status() -> StatusResponse: + pass + @abstractmethod def create_invoice( self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None @@ -45,11 +54,6 @@ class Wallet(ABC): @abstractmethod def paid_invoices_stream(self) -> AsyncGenerator[str, None]: - """ - this is an async function, but here it is noted without the 'async' - prefix because mypy has a bug identifying the signature of abstract - methods. - """ pass diff --git a/lnbits/wallets/clightning.py b/lnbits/wallets/clightning.py index 5444d8d..e51455b 100644 --- a/lnbits/wallets/clightning.py +++ b/lnbits/wallets/clightning.py @@ -9,7 +9,8 @@ import json from os import getenv from typing import Optional, AsyncGenerator -from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet, Unsupported + +from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet, Unsupported class CLightningWallet(Wallet): @@ -39,6 +40,17 @@ class CLightningWallet(Wallet): self.last_pay_index = inv["pay_index"] break + def status(self) -> StatusResponse: + try: + funds = self.ln.listfunds() + return StatusResponse( + None, + sum([ch["channel_sat"] * 1000 for ch in funds["channels"]]), + ) + except RpcError as exc: + error_message = f"lightningd '{exc.method}' failed with '{exc.error}'." + return StatusResponse(error_message, 0) + def create_invoice( self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None ) -> InvoiceResponse: diff --git a/lnbits/wallets/lnbits.py b/lnbits/wallets/lnbits.py index 78e8f8c..30c62c6 100644 --- a/lnbits/wallets/lnbits.py +++ b/lnbits/wallets/lnbits.py @@ -3,7 +3,7 @@ import httpx from os import getenv from typing import Optional, Dict, AsyncGenerator -from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet +from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet class LNbitsWallet(Wallet): @@ -15,6 +15,18 @@ class LNbitsWallet(Wallet): key = getenv("LNBITS_KEY") or getenv("LNBITS_ADMIN_KEY") or getenv("LNBITS_INVOICE_KEY") self.key = {"X-Api-Key": key} + def status(self) -> StatusResponse: + r = httpx.get(url=f"{self.endpoint}/api/v1/wallet", headers=self.key) + try: + data = r.json() + except: + return StatusResponse(f"Failed to connect to {self.endpoint}, got: '{r.text[:200]}...'", 0) + + if r.is_error: + return StatusResponse(data["message"], 0) + + return StatusResponse(None, data["balance"]) + def create_invoice( self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None ) -> InvoiceResponse: diff --git a/lnbits/wallets/lndgrpc.py b/lnbits/wallets/lndgrpc.py index 62c1bbd..6dc6721 100644 --- a/lnbits/wallets/lndgrpc.py +++ b/lnbits/wallets/lndgrpc.py @@ -15,7 +15,7 @@ import hashlib from os import getenv from typing import Optional, Dict, AsyncGenerator -from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet +from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet def get_ssl_context(cert_path: str): @@ -95,19 +95,20 @@ class LndWallet(Wallet): ) network = getenv("LND_GRPC_NETWORK", "mainnet") - self.admin_rpc = lndgrpc.LNDClient( + self.rpc = lndgrpc.LNDClient( f"{self.endpoint}:{self.port}", cert_filepath=self.cert_path, network=network, macaroon_filepath=self.macaroon_path, ) - self.invoices_rpc = lndgrpc.LNDClient( - f"{self.endpoint}:{self.port}", - cert_filepath=self.cert_path, - network=network, - macaroon_filepath=self.macaroon_path, - ) + def status(self) -> StatusResponse: + try: + resp = self.rpc._ln_stub.ChannelBalance(ln.ChannelBalanceRequest()) + except Exception as exc: + return StatusResponse(str(exc), 0) + + return StatusResponse(None, resp.balance * 1000) def create_invoice( self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None @@ -121,7 +122,7 @@ class LndWallet(Wallet): try: req = ln.Invoice(**params) - resp = self.invoices_rpc._ln_stub.AddInvoice(req) + resp = self.rpc._ln_stub.AddInvoice(req) except Exception as exc: error_message = str(exc) return InvoiceResponse(False, None, None, error_message) @@ -131,7 +132,7 @@ class LndWallet(Wallet): return InvoiceResponse(True, checking_id, payment_request, None) def pay_invoice(self, bolt11: str) -> PaymentResponse: - resp = self.admin_rpc.send_payment(payment_request=bolt11) + resp = self.rpc.send_payment(payment_request=bolt11) if resp.payment_error: return PaymentResponse(False, "", 0, resp.payment_error) @@ -150,7 +151,7 @@ class LndWallet(Wallet): # that use different checking_id formats return PaymentStatus(None) - resp = self.invoices_rpc.lookup_invoice(r_hash.hex()) + resp = self.rpc.lookup_invoice(r_hash.hex()) if resp.settled: return PaymentStatus(True) diff --git a/lnbits/wallets/lndrest.py b/lnbits/wallets/lndrest.py index e3f938f..b6e22ba 100644 --- a/lnbits/wallets/lndrest.py +++ b/lnbits/wallets/lndrest.py @@ -5,7 +5,7 @@ import base64 from os import getenv from typing import Optional, Dict, AsyncGenerator -from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet +from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet class LndRestWallet(Wallet): @@ -27,6 +27,25 @@ class LndRestWallet(Wallet): self.auth = {"Grpc-Metadata-macaroon": macaroon} self.cert = getenv("LND_REST_CERT") + def status(self) -> StatusResponse: + try: + r = httpx.get( + f"{self.endpoint}/v1/balance/channels", + headers=self.auth, + verify=self.cert, + ) + except (httpx.ConnectError, httpx.RequestError): + return StatusResponse(f"Unable to connect to {self.endpoint}.", 0) + + try: + data = r.json() + if r.is_error: + raise Exception + except Exception: + return StatusResponse(r.text[:200], 0) + + return StatusResponse(None, int(data["balance"]) * 1000) + def create_invoice( self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None ) -> InvoiceResponse: @@ -139,7 +158,7 @@ class LndRestWallet(Wallet): payment_hash = base64.b64decode(inv["r_hash"]).hex() yield payment_hash - except (OSError, httpx.ReadError): + except (OSError, httpx.ConnectError, httpx.ReadError): pass print("lost connection to lnd invoices stream, retrying in 5 seconds") diff --git a/lnbits/wallets/lnpay.py b/lnbits/wallets/lnpay.py index 8543219..5073318 100644 --- a/lnbits/wallets/lnpay.py +++ b/lnbits/wallets/lnpay.py @@ -6,7 +6,7 @@ from http import HTTPStatus from typing import Optional, Dict, AsyncGenerator from quart import request -from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet +from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet class LNPayWallet(Wallet): @@ -18,6 +18,24 @@ class LNPayWallet(Wallet): self.wallet_key = getenv("LNPAY_WALLET_KEY") or getenv("LNPAY_ADMIN_KEY") self.auth = {"X-Api-Key": getenv("LNPAY_API_KEY")} + def status(self) -> StatusResponse: + url = f"{self.endpoint}/wallet/{self.wallet_key}" + try: + r = httpx.get(url, headers=self.auth) + except (httpx.ConnectError, httpx.RequestError): + return StatusResponse(f"Unable to connect to '{url}'") + + if r.is_error: + return StatusResponse(r.text[:250], 0) + + data = r.json() + if data["statusType"]["name"] != "active": + return StatusResponse( + f"Wallet {data['user_label']} (data['id']) not active, but {data['statusType']['name']}", 0 + ) + + return StatusResponse(None, data["balance"] / 1000) + def create_invoice( self, amount: int, @@ -31,7 +49,7 @@ class LNPayWallet(Wallet): data["memo"] = memo or "" r = httpx.post( - url=f"{self.endpoint}/user/wallet/{self.wallet_key}/invoice", + f"{self.endpoint}/wallet/{self.wallet_key}/invoice", headers=self.auth, json=data, ) @@ -50,7 +68,7 @@ class LNPayWallet(Wallet): def pay_invoice(self, bolt11: str) -> PaymentResponse: r = httpx.post( - url=f"{self.endpoint}/user/wallet/{self.wallet_key}/withdraw", + url=f"{self.endpoint}/wallet/{self.wallet_key}/withdraw", headers=self.auth, json={"payment_request": bolt11}, ) @@ -66,7 +84,7 @@ class LNPayWallet(Wallet): def get_payment_status(self, checking_id: str) -> PaymentStatus: r = httpx.get( - url=f"{self.endpoint}/user/lntx/{checking_id}?fields=settled", + url=f"{self.endpoint}/lntx/{checking_id}?fields=settled", headers=self.auth, ) @@ -90,7 +108,7 @@ class LNPayWallet(Wallet): lntx_id = data["data"]["wtx"]["lnTx"]["id"] async with httpx.AsyncClient() as client: r = await client.get( - f"{self.endpoint}/user/lntx/{lntx_id}?fields=settled", + f"{self.endpoint}/lntx/{lntx_id}?fields=settled", headers=self.auth, ) data = r.json() diff --git a/lnbits/wallets/lntxbot.py b/lnbits/wallets/lntxbot.py index 9ad7efa..c4ad534 100644 --- a/lnbits/wallets/lntxbot.py +++ b/lnbits/wallets/lntxbot.py @@ -3,7 +3,7 @@ import httpx from os import getenv from typing import Optional, Dict, AsyncGenerator -from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet +from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet class LntxbotWallet(Wallet): @@ -16,6 +16,18 @@ class LntxbotWallet(Wallet): key = getenv("LNTXBOT_KEY") or getenv("LNTXBOT_ADMIN_KEY") or getenv("LNTXBOT_INVOICE_KEY") self.auth = {"Authorization": f"Basic {key}"} + def status(self) -> StatusResponse: + r = httpx.get(f"{self.endpoint}/balance", headers=self.auth) + try: + data = r.json() + except: + return StatusResponse(f"Failed to connect to {self.endpoint}, got: '{r.text[:200]}...'", 0) + + if data.get("error"): + return StatusResponse(data["message"], 0) + + return StatusResponse(None, data["BTC"]["AvailableBalance"] * 1000) + def create_invoice( self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None ) -> InvoiceResponse: diff --git a/lnbits/wallets/opennode.py b/lnbits/wallets/opennode.py index 6c3c255..592adee 100644 --- a/lnbits/wallets/opennode.py +++ b/lnbits/wallets/opennode.py @@ -7,7 +7,7 @@ from os import getenv from typing import Optional, AsyncGenerator from quart import request, url_for -from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet, Unsupported +from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet, Unsupported class OpenNodeWallet(Wallet): @@ -20,6 +20,18 @@ class OpenNodeWallet(Wallet): key = getenv("OPENNODE_KEY") or getenv("OPENNODE_ADMIN_KEY") or getenv("OPENNODE_INVOICE_KEY") self.auth = {"Authorization": key} + def status(self) -> StatusResponse: + try: + r = httpx.get(f"{self.endpoint}/v1/account/balance", headers=self.auth) + except (httpx.ConnectError, httpx.RequestError): + return StatusResponse(f"Unable to connect to '{self.endpoint}'") + + data = r.json()["message"] + if r.is_error: + return StatusResponse(data["message"], 0) + + return StatusResponse(None, data["balance"]["BTC"] / 100_000_000_000) + def create_invoice( self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None ) -> InvoiceResponse: diff --git a/lnbits/wallets/spark.py b/lnbits/wallets/spark.py index d3f2ab0..ea821ec 100644 --- a/lnbits/wallets/spark.py +++ b/lnbits/wallets/spark.py @@ -5,7 +5,7 @@ import httpx from os import getenv from typing import Optional, AsyncGenerator -from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet +from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet class SparkError(Exception): @@ -35,12 +35,30 @@ class SparkWallet(Wallet): data = r.json() except: raise UnknownError(r.text) + if r.is_error: + if r.status_code == 401: + raise SparkError("Access key invalid!") + raise SparkError(data["message"]) + return data return call + def status(self) -> StatusResponse: + try: + funds = self.listfunds() + except (httpx.ConnectError, httpx.RequestError): + return StatusResponse("Couldn't connect to Spark server", 0) + except (SparkError, UnknownError) as e: + return StatusResponse(str(e), 0) + + return StatusResponse( + None, + sum([ch["channel_sat"] * 1000 for ch in funds["channels"]]), + ) + def create_invoice( self, amount: int, memo: Optional[str] = None, description_hash: Optional[bytes] = None ) -> InvoiceResponse: diff --git a/lnbits/wallets/void.py b/lnbits/wallets/void.py index 044d7ef..b661736 100644 --- a/lnbits/wallets/void.py +++ b/lnbits/wallets/void.py @@ -1,6 +1,6 @@ from typing import Optional, AsyncGenerator -from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet, Unsupported +from .base import StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, Wallet, Unsupported class VoidWallet(Wallet): @@ -9,6 +9,12 @@ class VoidWallet(Wallet): ) -> InvoiceResponse: raise Unsupported("") + def status(self) -> StatusResponse: + return StatusResponse( + "This backend does nothing, it is here just as a placeholder, you must configure an actual backend before being able to do anything useful with LNbits.", + 0, + ) + def pay_invoice(self, bolt11: str) -> PaymentResponse: raise Unsupported("")