diff --git a/.env.example b/.env.example index 08a72fff4..59b5ac6aa 100644 --- a/.env.example +++ b/.env.example @@ -28,7 +28,7 @@ PORT=5000 ###################################### # which fundingsources are allowed in the admin ui -LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, AlbyWallet, OpenNodeWallet" +LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, AlbyWallet, ZBDWallet, OpenNodeWallet" LNBITS_BACKEND_WALLET_CLASS=VoidWallet # VoidWallet is just a fallback that works without any actual Lightning capabilities, @@ -84,6 +84,10 @@ LNPAY_WALLET_KEY=LNPAY_ADMIN_KEY ALBY_API_ENDPOINT=https://api.getalby.com/ ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN +# ZBDWallet +ZBD_API_ENDPOINT=https://api.zebedee.io/v0/ +ZBD_API_KEY=ZBD_ACCESS_TOKEN + # OpenNodeWallet OPENNODE_API_ENDPOINT=https://api.opennode.com/ OPENNODE_KEY=OPENNODE_ADMIN_KEY diff --git a/README.md b/README.md index 86c5e50a8..c09b7d542 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ LNbits can run on top of any Lightning funding source. It currently supports the - LNbits - OpenNode - Alby +- ZBD - LightningTipBot See [LNbits manual](https://docs.lnbits.org/guide/wallets.html) for more detailed documentation about each funding source. diff --git a/docs/guide/wallets.md b/docs/guide/wallets.md index d86668e2b..9b17e0ba5 100644 --- a/docs/guide/wallets.md +++ b/docs/guide/wallets.md @@ -87,6 +87,14 @@ For the invoice to work you must have a publicly accessible URL in your LNbits. - `ALBY_API_ENDPOINT`: https://api.getalby.com/ - `ALBY_ACCESS_TOKEN`: AlbyAccessToken +### ZBD + +For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary. You can generate an ZBD API Key here: https://zbd.dev/docs/dashboard/projects/api + +- `LNBITS_BACKEND_WALLET_CLASS`: **ZBDWallet** +- `ZBD_API_ENDPOINT`: https://api.zebedee.io/v0/ +- `ZBD_API_KEY`: ZBDApiKey + ### Cliche Wallet - `CLICHE_ENDPOINT`: ws://127.0.0.1:12000 diff --git a/lnbits/settings.py b/lnbits/settings.py index 1cbc667d7..976718f37 100644 --- a/lnbits/settings.py +++ b/lnbits/settings.py @@ -206,6 +206,10 @@ class LnPayFundingSource(LNbitsSettings): lnpay_admin_key: Optional[str] = Field(default=None) +class ZBDFundingSource(LNbitsSettings): + zbd_api_endpoint: Optional[str] = Field(default="https://api.zebedee.io/v0/") + zbd_api_key: Optional[str] = Field(default=None) + class AlbyFundingSource(LNbitsSettings): alby_api_endpoint: Optional[str] = Field(default="https://api.getalby.com/") alby_access_token: Optional[str] = Field(default=None) diff --git a/lnbits/static/js/components/lnbits-funding-sources.js b/lnbits/static/js/components/lnbits-funding-sources.js index 9168b7827..668518a30 100644 --- a/lnbits/static/js/components/lnbits-funding-sources.js +++ b/lnbits/static/js/components/lnbits-funding-sources.js @@ -105,6 +105,14 @@ Vue.component('lnbits-funding-sources', { alby_access_token: 'Key' } ], + [ + 'ZBDWallet', + 'ZBD', + { + zbd_api_endpoint: 'Endpoint', + zbd_access_token: 'Key' + } + ], [ 'OpenNodeWallet', 'OpenNode', diff --git a/lnbits/wallets/__init__.py b/lnbits/wallets/__init__.py index f4b6dbb0b..3ac7d8491 100644 --- a/lnbits/wallets/__init__.py +++ b/lnbits/wallets/__init__.py @@ -8,6 +8,7 @@ from lnbits.settings import settings from lnbits.wallets.base import Wallet from .alby import AlbyWallet +from .zbd import ZBDWallet from .cliche import ClicheWallet from .corelightning import CoreLightningWallet diff --git a/lnbits/wallets/zbd.py b/lnbits/wallets/zbd.py new file mode 100644 index 000000000..2e108762a --- /dev/null +++ b/lnbits/wallets/zbd.py @@ -0,0 +1,130 @@ +import asyncio +import hashlib +from typing import AsyncGenerator, Dict, Optional + +import httpx +from loguru import logger + +from lnbits.settings import settings + +from .base import ( + InvoiceResponse, + PaymentResponse, + PaymentStatus, + StatusResponse, + Wallet, +) + + +class ZBDWallet(Wallet): + """https://zbd.dev/api-reference/""" + + def __init__(self): + if not settings.zbd_api_endpoint: + raise ValueError("cannot initialize ZBDWallet: missing zbd_api_endpoint") + if not settings.zbd_api_key: + raise ValueError("cannot initialize ZBDWallet: missing zbd_api_key") + + self.endpoint = self.normalize_endpoint(settings.zbd_api_endpoint) + self.auth = { + "Authorization": "Bearer " + settings.zbd_api_key, + "User-Agent": settings.user_agent, + } + self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.auth) + + async def cleanup(self): + try: + await self.client.aclose() + except RuntimeError as e: + logger.warning(f"Error closing wallet connection: {e}") + + async def status(self) -> StatusResponse: + try: + r = await self.client.get("/balance", timeout=10) + except (httpx.ConnectError, httpx.RequestError): + return StatusResponse(f"Unable to connect to '{self.endpoint}'", 0) + + if r.is_error: + error_message = r.json()["message"] + return StatusResponse(error_message, 0) + data = r.json()["balance"] + # if no error, multiply balance by 1000 for msats representation in lnbits + return StatusResponse(None, data * 1000) + + async def create_invoice( + self, + amount: int, + memo: Optional[str] = None, + description_hash: Optional[bytes] = None, + unhashed_description: Optional[bytes] = None, + **kwargs, + ) -> InvoiceResponse: + # https://api.zebedee.io/v0/charges + data: Dict = {"amount": f"{amount}"} + if description_hash: + data["description_hash"] = description_hash.hex() + elif unhashed_description: + data["description_hash"] = hashlib.sha256(unhashed_description).hexdigest() + else: + data["memo"] = memo or "" + + r = await self.client.post( + "/invoices", + json=data, + timeout=40, + ) + + if r.is_error: + error_message = r.json()["message"] + return InvoiceResponse(False, None, None, error_message) + + data = r.json() + checking_id = data["payment_hash"] + payment_request = data["payment_request"] + return InvoiceResponse(True, checking_id, payment_request, None) + + async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: + # https://api.zebedee.io/v0/payments + r = await self.client.post( + "/payments/bolt11", + json={"invoice": bolt11}, # assume never need amount in body + timeout=None, + ) + + if r.is_error: + error_message = r.json()["message"] + return PaymentResponse(False, None, None, None, error_message) + + data = r.json() + checking_id = data["payment_hash"] + fee_msat = -data["fee"] + preimage = data["payment_preimage"] + + return PaymentResponse(True, checking_id, fee_msat, preimage, None) + + async def get_invoice_status(self, checking_id: str) -> PaymentStatus: + return await self.get_payment_status(checking_id) + + async def get_payment_status(self, checking_id: str) -> PaymentStatus: + r = await self.client.get(f"/invoices/{checking_id}") + + if r.is_error: + return PaymentStatus(None) + + data = r.json() + + statuses = { + "CREATED": None, + "SETTLED": True, + } + return PaymentStatus(statuses[data.get("state")], fee_msat=None, preimage=None) + + async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: + self.queue: asyncio.Queue = asyncio.Queue(0) + while True: + value = await self.queue.get() + yield value + + async def webhook_listener(self): + logger.error("ZBD webhook listener disabled") + return