mirror of
https://github.com/lnbits/lnbits.git
synced 2026-08-13 12:42:47 +02:00
feat: Bark backend support (using barkd) (#4007)
Co-authored-by: blackcoffeexbt <87530449+blackcoffeexbt@users.noreply.github.com> Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
This commit is contained in:
parent
653924a6dc
commit
afa7e1a2bd
9 changed files with 909 additions and 3 deletions
|
|
@ -69,7 +69,7 @@ LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
|
|||
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
|
||||
|
||||
# which fundingsources are allowed in the admin ui
|
||||
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet, CLNRestWallet, SparkWallet, SparkL2Wallet"
|
||||
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, BarkWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet, CLNRestWallet, SparkWallet, SparkL2Wallet"
|
||||
|
||||
# uvicorn variable, allow https behind a proxy
|
||||
# IMPORTANT: this also needs the webserver to be configured to forward the headers
|
||||
|
|
@ -144,6 +144,10 @@ CLICHE_ENDPOINT=ws://127.0.0.1:12000
|
|||
SPARK_URL=http://localhost:9737/rpc
|
||||
SPARK_TOKEN=myaccesstoken
|
||||
|
||||
# BarkWallet
|
||||
BARK_API_ENDPOINT=http://localhost:3000
|
||||
BARK_API_TOKEN=auth_token
|
||||
|
||||
#CLNRest (using runes)
|
||||
CLNREST_URL=https://127.0.0.1:3010
|
||||
CLNREST_CA=/home/lightningd/.lightning/bitcoin/ca.pem
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ A backend wallet is selected and configured entirely through LNbits environment
|
|||
| [CoreLightning REST](#corelightning-rest) | [LNbits](#lnbits) | [Alby](#alby) |
|
||||
| [Spark (Core Lightning)](#spark-core-lightning) | [LNPay](#lnpay) | [Boltz](#boltz) |
|
||||
| [Spark L2](#spark-l2) | [ZBD](#zbd) | [Phoenixd](#phoenixd) |
|
||||
| [Cliche Wallet](#cliche-wallet) | | |
|
||||
| [Cliche Wallet](#cliche-wallet) | [Bark](#bark) | |
|
||||
| [Breez SDK](#breez-sdk) | [Breez Liquid SDK](#breez-liquid-sdk) | [Nostr Wallet Connect](#nostr-wallet-connect-nwc) |
|
||||
| [Strike](#strike) | [Eclair (ACINQ)](#eclair-acinq) | [LN.tips](#lntips) |
|
||||
| [Fake Wallet](#fake-wallet) | | |
|
||||
|
|
@ -127,6 +127,22 @@ Old REST interface using [RTL c-lightning-REST](https://github.com/Ride-The-Ligh
|
|||
- `SPARK_URL`: `http://10.147.17.230:9737/rpc`
|
||||
- `SPARK_TOKEN`: `secret_access_key`
|
||||
|
||||
## Bark
|
||||
|
||||
This connects LNbits to an external [barkd](https://second.tech/docs/barkd) REST daemon. Initialize the Bark wallet before starting LNbits using `bark create`, then run `barkd` separately, keep its data directory persistent, and set `BARK_API_TOKEN` to the auth token from `~/.bark/auth_token`.
|
||||
|
||||
**Required env vars**
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: `BarkWallet`
|
||||
- `BARK_API_ENDPOINT`: `http://localhost:3000`
|
||||
- `BARK_API_TOKEN`: `auth_token`
|
||||
|
||||
Bark fee estimates can be higher than LNbits' default minimum routing fee reserve. If small outgoing payments fail with an error like `fee of 20000 msat exceeds limit of 5000 msat`, raise the minimum reserve fee under **Settings → Funding** or set:
|
||||
|
||||
```bash
|
||||
LNBITS_RESERVE_FEE_MIN=20000
|
||||
```
|
||||
|
||||
## Spark L2
|
||||
|
||||
Self-custodial funding source using the [Spark L2](https://docs.spark.money/start/overview) network. Requires a Node.js [sidecar](https://github.com/lnbits/spark_sidecar) that bridges lnbits talking to Spark. Works in addition with any Spark-compatible seed (Wallet of Satoshi, BuhoGO, BlitzWallet).
|
||||
|
|
|
|||
|
|
@ -642,6 +642,11 @@ class SparkFundingSource(LNbitsSettings):
|
|||
spark_token: str | None = Field(default=None)
|
||||
|
||||
|
||||
class BarkFundingSource(LNbitsSettings):
|
||||
bark_api_endpoint: str | None = Field(default="http://localhost:3000")
|
||||
bark_api_token: str | None = Field(default=None)
|
||||
|
||||
|
||||
class SparkL2FundingSource(LNbitsSettings):
|
||||
spark_l2_network: str = Field(default="MAINNET")
|
||||
spark_l2_external_endpoint: str | None = Field(default="http://localhost:8765")
|
||||
|
|
@ -788,6 +793,7 @@ class FundingSourcesSettings(
|
|||
PhoenixdFundingSource,
|
||||
OpenNodeFundingSource,
|
||||
SparkFundingSource,
|
||||
BarkFundingSource,
|
||||
SparkL2FundingSource,
|
||||
LnTipsFundingSource,
|
||||
NWCFundingSource,
|
||||
|
|
@ -1153,6 +1159,7 @@ class SuperUserSettings(LNbitsSettings):
|
|||
lnbits_allowed_funding_sources: list[str] = Field(
|
||||
default=[
|
||||
"AlbyWallet",
|
||||
"BarkWallet",
|
||||
"BoltzWallet",
|
||||
"BlinkWallet",
|
||||
"BreezSdkWallet",
|
||||
|
|
|
|||
2
lnbits/static/bundle-components.min.js
vendored
2
lnbits/static/bundle-components.min.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -161,6 +161,17 @@ window.app.component('lnbits-admin-funding-sources', {
|
|||
alby_access_token: 'Key'
|
||||
}
|
||||
],
|
||||
[
|
||||
'BarkWallet',
|
||||
'Bark',
|
||||
{
|
||||
bark_api_endpoint: {
|
||||
label: 'Endpoint',
|
||||
value: 'http://localhost:3000'
|
||||
},
|
||||
bark_api_token: 'auth_token'
|
||||
}
|
||||
],
|
||||
[
|
||||
'BoltzWallet',
|
||||
'Boltz',
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from lnbits.settings import settings
|
|||
from lnbits.wallets.base import Feature, Wallet
|
||||
|
||||
from .alby import AlbyWallet
|
||||
from .bark import BarkWallet
|
||||
from .blink import BlinkWallet
|
||||
from .boltz import BoltzWallet
|
||||
from .breez import BreezSdkWallet
|
||||
|
|
@ -57,6 +58,7 @@ funding_source: Wallet = fake_wallet
|
|||
|
||||
__all__ = [
|
||||
"AlbyWallet",
|
||||
"BarkWallet",
|
||||
"BlinkWallet",
|
||||
"BoltzWallet",
|
||||
"BreezLiquidSdkWallet",
|
||||
|
|
|
|||
574
lnbits/wallets/bark.py
Normal file
574
lnbits/wallets/bark.py
Normal file
|
|
@ -0,0 +1,574 @@
|
|||
import asyncio
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
from urllib.parse import quote, urlencode, urlsplit, urlunsplit
|
||||
|
||||
import httpx
|
||||
from bolt11 import decode as bolt11_decode
|
||||
from loguru import logger
|
||||
from websockets import connect
|
||||
|
||||
from lnbits.helpers import normalize_endpoint
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
InvoiceResponse,
|
||||
PaymentFailedStatus,
|
||||
PaymentPendingStatus,
|
||||
PaymentResponse,
|
||||
PaymentStatus,
|
||||
PaymentSuccessStatus,
|
||||
StatusResponse,
|
||||
Wallet,
|
||||
)
|
||||
|
||||
|
||||
class BarkError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class BarkHTTPError(BarkError):
|
||||
def __init__(self, message: str, status_code: int):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class BarkWallet(Wallet):
|
||||
"""https://second.tech/docs/barkd"""
|
||||
|
||||
def __init__(self):
|
||||
if not settings.bark_api_endpoint:
|
||||
raise ValueError("cannot initialize BarkWallet: missing bark_api_endpoint")
|
||||
if not settings.bark_api_token:
|
||||
raise ValueError("cannot initialize BarkWallet: missing bark_api_token")
|
||||
|
||||
super().__init__()
|
||||
self.endpoint = normalize_endpoint(settings.bark_api_endpoint)
|
||||
parsed_endpoint = urlsplit(self.endpoint)
|
||||
ws_scheme = "wss" if parsed_endpoint.scheme == "https" else "ws"
|
||||
self.ws_endpoint = urlunsplit(
|
||||
(
|
||||
ws_scheme,
|
||||
parsed_endpoint.netloc,
|
||||
"/api/v1/notifications/ws",
|
||||
"",
|
||||
"",
|
||||
)
|
||||
)
|
||||
self.headers = {
|
||||
"Authorization": f"Bearer {settings.bark_api_token}",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": settings.user_agent,
|
||||
}
|
||||
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers)
|
||||
self.pending_payments: dict[str, str] = {}
|
||||
self.outgoing_payment_waiters: dict[str, asyncio.Future[PaymentStatus]] = {}
|
||||
self.notified_paid_invoice: str | None = None
|
||||
|
||||
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:
|
||||
connected = await self._request_json(
|
||||
"GET", "/api/v1/wallet/connected", timeout=10
|
||||
)
|
||||
if not connected.get("connected"):
|
||||
return StatusResponse("Bark wallet is not connected to Ark server.", 0)
|
||||
|
||||
data = await self._request_json("GET", "/api/v1/wallet/balance", timeout=10)
|
||||
if "spendable_sat" not in data:
|
||||
return StatusResponse("Server error: 'missing required fields'", 0)
|
||||
|
||||
return StatusResponse(None, int(data["spendable_sat"]) * 1000)
|
||||
except BarkError as exc:
|
||||
return StatusResponse(str(exc), 0)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return StatusResponse(f"Unable to connect to {self.endpoint}.", 0)
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
amount: int,
|
||||
memo: str | None = None,
|
||||
description_hash: bytes | None = None,
|
||||
unhashed_description: bytes | None = None,
|
||||
**_,
|
||||
) -> InvoiceResponse:
|
||||
if description_hash or unhashed_description:
|
||||
return InvoiceResponse(
|
||||
ok=False,
|
||||
error_message="Bark does not support description-hash invoices.",
|
||||
)
|
||||
|
||||
payload: dict[str, Any] = {"amount_sat": int(amount)}
|
||||
if memo is not None:
|
||||
payload["description"] = memo
|
||||
|
||||
try:
|
||||
data = await self._request_json(
|
||||
"POST",
|
||||
"/api/v1/lightning/receives/invoice",
|
||||
json=payload,
|
||||
timeout=40,
|
||||
)
|
||||
payment_request = data["invoice"]
|
||||
checking_id = bolt11_decode(payment_request).payment_hash
|
||||
|
||||
return InvoiceResponse(
|
||||
ok=True,
|
||||
checking_id=checking_id,
|
||||
payment_request=payment_request,
|
||||
)
|
||||
except KeyError as exc:
|
||||
logger.warning(exc)
|
||||
return InvoiceResponse(
|
||||
ok=False, error_message="Server error: 'missing required fields'"
|
||||
)
|
||||
except BarkError as exc:
|
||||
return InvoiceResponse(ok=False, error_message=str(exc))
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return InvoiceResponse(
|
||||
ok=False, error_message=f"Unable to connect to {self.endpoint}."
|
||||
)
|
||||
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
decoded = self._decode_invoice_for_payment(bolt11)
|
||||
if isinstance(decoded, PaymentResponse):
|
||||
return decoded
|
||||
|
||||
checking_id, amount_sat = decoded
|
||||
fee_response = await self._check_fee_limit(
|
||||
checking_id, amount_sat, fee_limit_msat
|
||||
)
|
||||
if fee_response:
|
||||
return fee_response
|
||||
|
||||
return await self._send_payment(bolt11, checking_id)
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
try:
|
||||
identifier = quote(checking_id, safe="")
|
||||
data = await self._request_json(
|
||||
"GET", f"/api/v1/lightning/receives/{identifier}"
|
||||
)
|
||||
except BarkHTTPError as exc:
|
||||
notification_status = self._consume_paid_invoice_notification(checking_id)
|
||||
if notification_status:
|
||||
return notification_status
|
||||
if exc.status_code == 404:
|
||||
return PaymentFailedStatus()
|
||||
logger.warning(exc)
|
||||
return PaymentPendingStatus()
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
notification_status = self._consume_paid_invoice_notification(checking_id)
|
||||
if notification_status:
|
||||
return notification_status
|
||||
return PaymentPendingStatus()
|
||||
|
||||
return self._invoice_status_from_response(checking_id, data)
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
try:
|
||||
data = await self._request_json("GET", "/api/v1/history")
|
||||
if not isinstance(data, list):
|
||||
return PaymentPendingStatus()
|
||||
|
||||
for movement in data:
|
||||
if self._movement_matches_payment_hash(movement, checking_id):
|
||||
return self._movement_to_payment_status(movement)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
|
||||
return PaymentPendingStatus()
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
while settings.lnbits_running:
|
||||
try:
|
||||
async for checking_id in self._listen_paid_invoices():
|
||||
yield checking_id
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Bark invoices stream unavailable "
|
||||
f"({type(exc).__name__}); retrying in 5 seconds."
|
||||
)
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def _listen_paid_invoices(self) -> AsyncGenerator[str, None]:
|
||||
ticket = await self._request_json(
|
||||
"GET", "/api/v1/notifications/ws/ticket", timeout=10
|
||||
)
|
||||
if not isinstance(ticket, str) or not ticket:
|
||||
raise BarkError("Server error: 'invalid websocket ticket'")
|
||||
|
||||
ws_url = f"{self.ws_endpoint}?{urlencode({'ticket': ticket})}"
|
||||
async with connect(ws_url) as ws:
|
||||
logger.info("Connected to Bark invoices stream.")
|
||||
|
||||
while settings.lnbits_running:
|
||||
notification = self._parse_notification(await ws.recv())
|
||||
if not notification:
|
||||
continue
|
||||
if notification.get("type") == "channel-lagging":
|
||||
logger.warning(
|
||||
"Bark invoice notifications were lost; pending payments "
|
||||
"will be reconciled by the scheduled check."
|
||||
)
|
||||
continue
|
||||
|
||||
self._notify_outgoing_payment(notification)
|
||||
checking_id = self._incoming_payment_hash(notification)
|
||||
if checking_id:
|
||||
self.notified_paid_invoice = checking_id
|
||||
yield checking_id
|
||||
|
||||
def _parse_notification(self, message: str | bytes) -> dict[str, Any] | None:
|
||||
try:
|
||||
notification = json.loads(message)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
logger.warning("Invalid message from Bark invoices stream.")
|
||||
return None
|
||||
return notification if isinstance(notification, dict) else None
|
||||
|
||||
def _incoming_payment_hash(self, notification: dict[str, Any]) -> str | None:
|
||||
if notification.get("type") not in {
|
||||
"movement-created",
|
||||
"movement-updated",
|
||||
}:
|
||||
return None
|
||||
|
||||
movement = notification.get("movement")
|
||||
if not isinstance(movement, dict) or movement.get("status") != "successful":
|
||||
return None
|
||||
|
||||
for destination in movement.get("received_on") or []:
|
||||
if not isinstance(destination, dict):
|
||||
continue
|
||||
method = destination.get("destination")
|
||||
if not isinstance(method, dict) or method.get("type") != "invoice":
|
||||
continue
|
||||
invoice = method.get("value")
|
||||
if not isinstance(invoice, str):
|
||||
continue
|
||||
try:
|
||||
return bolt11_decode(invoice).payment_hash
|
||||
except Exception as exc:
|
||||
logger.debug(f"Unable to decode Bark notification invoice: {exc}")
|
||||
return None
|
||||
|
||||
def _consume_paid_invoice_notification(
|
||||
self, checking_id: str, preimage: str | None = None
|
||||
) -> PaymentStatus | None:
|
||||
if checking_id != self.notified_paid_invoice:
|
||||
return None
|
||||
self.notified_paid_invoice = None
|
||||
return PaymentSuccessStatus(preimage=preimage)
|
||||
|
||||
def _invoice_status_from_response(
|
||||
self, checking_id: str, data: Any
|
||||
) -> PaymentStatus:
|
||||
preimage = data.get("payment_preimage") if isinstance(data, dict) else None
|
||||
notification_status = self._consume_paid_invoice_notification(
|
||||
checking_id, preimage
|
||||
)
|
||||
if notification_status:
|
||||
return notification_status
|
||||
if not isinstance(data, dict):
|
||||
return PaymentPendingStatus()
|
||||
if data.get("state") == "settled" or data.get("settled_at"):
|
||||
return PaymentSuccessStatus(preimage=preimage)
|
||||
if not data.get("finished_at"):
|
||||
return PaymentPendingStatus()
|
||||
if data.get("preimage_revealed_at"):
|
||||
return PaymentSuccessStatus(preimage=preimage)
|
||||
return PaymentFailedStatus()
|
||||
|
||||
def _decode_invoice_for_payment(
|
||||
self, bolt11: str
|
||||
) -> tuple[str, int] | PaymentResponse:
|
||||
try:
|
||||
invoice = bolt11_decode(bolt11)
|
||||
checking_id = invoice.payment_hash
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(ok=False, error_message=f"Invalid invoice: {exc!s}")
|
||||
|
||||
if not invoice.amount_msat or invoice.amount_msat <= 0:
|
||||
return PaymentResponse(
|
||||
ok=False,
|
||||
checking_id=checking_id,
|
||||
error_message="Bark 0 amount invoice not supported.",
|
||||
)
|
||||
|
||||
amount_sat = (int(invoice.amount_msat) + 999) // 1000
|
||||
return checking_id, amount_sat
|
||||
|
||||
async def _check_fee_limit(
|
||||
self, checking_id: str, amount_sat: int, fee_limit_msat: int
|
||||
) -> PaymentResponse | None:
|
||||
try:
|
||||
fee_estimate = await self._request_json(
|
||||
"GET",
|
||||
"/api/v1/fees/lightning/pay",
|
||||
params={"amount_sat": amount_sat},
|
||||
timeout=30,
|
||||
)
|
||||
fee_msat = int(fee_estimate["fee_sat"]) * 1000
|
||||
if fee_msat > fee_limit_msat:
|
||||
return PaymentResponse(
|
||||
ok=False,
|
||||
checking_id=checking_id,
|
||||
fee_msat=fee_msat,
|
||||
error_message=(
|
||||
f"fee of {fee_msat} msat exceeds limit of "
|
||||
f"{fee_limit_msat} msat"
|
||||
),
|
||||
)
|
||||
except KeyError as exc:
|
||||
logger.warning(exc)
|
||||
return PaymentResponse(
|
||||
ok=False,
|
||||
checking_id=checking_id,
|
||||
error_message="Server error: 'missing required fields'",
|
||||
)
|
||||
except BarkError as exc:
|
||||
return PaymentResponse(
|
||||
ok=False, checking_id=checking_id, error_message=str(exc)
|
||||
)
|
||||
return None
|
||||
|
||||
async def _send_payment(self, bolt11: str, checking_id: str) -> PaymentResponse:
|
||||
waiter = asyncio.get_running_loop().create_future()
|
||||
self.outgoing_payment_waiters[checking_id] = waiter
|
||||
try:
|
||||
initiation_error = await self._initiate_payment(bolt11, checking_id)
|
||||
if initiation_error is not None:
|
||||
return initiation_error
|
||||
|
||||
self.pending_payments[checking_id] = bolt11
|
||||
response = await self._payment_response_from_status(checking_id)
|
||||
if not response.pending:
|
||||
return response
|
||||
|
||||
wait_seconds = max(
|
||||
0, settings.lnbits_funding_source_pay_invoice_wait_seconds - 1
|
||||
)
|
||||
if not wait_seconds:
|
||||
return response
|
||||
try:
|
||||
status = await asyncio.wait_for(waiter, timeout=wait_seconds)
|
||||
except TimeoutError:
|
||||
return response
|
||||
return self._payment_response(checking_id, status)
|
||||
finally:
|
||||
current_waiter = self.outgoing_payment_waiters.pop(checking_id, None)
|
||||
if current_waiter and not current_waiter.done():
|
||||
current_waiter.cancel()
|
||||
|
||||
async def _initiate_payment(
|
||||
self, bolt11: str, checking_id: str
|
||||
) -> PaymentResponse | None:
|
||||
try:
|
||||
r = await self.client.post(
|
||||
"/api/v1/lightning/pay",
|
||||
json={"destination": bolt11},
|
||||
timeout=40,
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if not isinstance(data, dict) or not isinstance(data.get("message"), str):
|
||||
return self._pending_payment_response(
|
||||
bolt11,
|
||||
checking_id,
|
||||
"Server error: 'invalid payment response'",
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
message = f"Timeout connecting to {self.endpoint}. keep pending..."
|
||||
logger.warning(message)
|
||||
return self._pending_payment_response(bolt11, checking_id, message)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response.is_client_error:
|
||||
return PaymentResponse(
|
||||
ok=False,
|
||||
checking_id=checking_id,
|
||||
error_message=self._http_error_message(exc.response),
|
||||
)
|
||||
message = self._http_error_message(exc.response)
|
||||
logger.warning(message)
|
||||
return self._pending_payment_response(bolt11, checking_id, message)
|
||||
except httpx.RequestError as exc:
|
||||
message = f"Unable to connect to {self.endpoint}. keep pending..."
|
||||
logger.warning(message)
|
||||
logger.warning(exc)
|
||||
return self._pending_payment_response(bolt11, checking_id, message)
|
||||
except json.JSONDecodeError:
|
||||
return self._pending_payment_response(
|
||||
bolt11,
|
||||
checking_id,
|
||||
"Server error: 'invalid json response'",
|
||||
)
|
||||
except Exception as exc:
|
||||
message = f"Unable to connect to {self.endpoint}. keep pending..."
|
||||
logger.warning(exc)
|
||||
return self._pending_payment_response(bolt11, checking_id, message)
|
||||
return None
|
||||
|
||||
def _pending_payment_response(
|
||||
self, bolt11: str, checking_id: str, error_message: str
|
||||
) -> PaymentResponse:
|
||||
self.pending_payments[checking_id] = bolt11
|
||||
return PaymentResponse(
|
||||
ok=None,
|
||||
checking_id=checking_id,
|
||||
error_message=error_message,
|
||||
)
|
||||
|
||||
async def _payment_response_from_status(self, checking_id: str) -> PaymentResponse:
|
||||
status = await self.get_payment_status(checking_id)
|
||||
return self._payment_response(checking_id, status)
|
||||
|
||||
def _payment_response(
|
||||
self, checking_id: str, status: PaymentStatus
|
||||
) -> PaymentResponse:
|
||||
if status.success:
|
||||
return PaymentResponse(
|
||||
ok=True,
|
||||
checking_id=checking_id,
|
||||
fee_msat=status.fee_msat,
|
||||
preimage=status.preimage,
|
||||
)
|
||||
if status.failed:
|
||||
return PaymentResponse(ok=False, checking_id=checking_id)
|
||||
return PaymentResponse(ok=None, checking_id=checking_id)
|
||||
|
||||
def _notify_outgoing_payment(self, notification: dict[str, Any]) -> None:
|
||||
if notification.get("type") not in {
|
||||
"movement-created",
|
||||
"movement-updated",
|
||||
}:
|
||||
return
|
||||
|
||||
movement = notification.get("movement")
|
||||
if not isinstance(movement, dict):
|
||||
return
|
||||
status = self._movement_to_payment_status(movement)
|
||||
if status.pending:
|
||||
return
|
||||
|
||||
for destination in movement.get("sent_to") or []:
|
||||
if not isinstance(destination, dict):
|
||||
continue
|
||||
method = destination.get("destination")
|
||||
if not isinstance(method, dict) or method.get("type") != "invoice":
|
||||
continue
|
||||
invoice = method.get("value")
|
||||
if not isinstance(invoice, str):
|
||||
continue
|
||||
try:
|
||||
checking_id = bolt11_decode(invoice).payment_hash
|
||||
except Exception as exc:
|
||||
logger.debug(f"Unable to decode Bark notification invoice: {exc}")
|
||||
continue
|
||||
|
||||
waiter = self.outgoing_payment_waiters.get(checking_id)
|
||||
if waiter and not waiter.done():
|
||||
waiter.set_result(status)
|
||||
return
|
||||
|
||||
async def _request_json(self, method: str, path: str, **kwargs) -> Any:
|
||||
try:
|
||||
r = await self.client.request(method, path, **kwargs)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise BarkHTTPError(
|
||||
self._http_error_message(exc.response), exc.response.status_code
|
||||
) from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise BarkError("Server error: 'invalid json response'") from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise BarkError(f"Unable to connect to {self.endpoint}.") from exc
|
||||
|
||||
def _http_error_message(self, response: httpx.Response) -> str:
|
||||
try:
|
||||
data = response.json()
|
||||
except json.JSONDecodeError:
|
||||
return response.text or f"HTTP {response.status_code}"
|
||||
|
||||
if isinstance(data, dict):
|
||||
for key in ("message", "detail", "error"):
|
||||
if key in data:
|
||||
return f"Server error: '{data[key]}'"
|
||||
return response.text or f"HTTP {response.status_code}"
|
||||
|
||||
def _movement_matches_payment_hash(self, movement: Any, checking_id: str) -> bool:
|
||||
if not isinstance(movement, dict):
|
||||
return False
|
||||
|
||||
metadata_hash = self._find_value(
|
||||
movement.get("metadata"), {"payment_hash", "paymentHash"}
|
||||
)
|
||||
if metadata_hash == checking_id:
|
||||
return True
|
||||
|
||||
for destination in movement.get("sent_to") or []:
|
||||
if not isinstance(destination, dict):
|
||||
continue
|
||||
method = destination.get("destination")
|
||||
if not isinstance(method, dict) or method.get("type") != "invoice":
|
||||
continue
|
||||
|
||||
invoice = method.get("value")
|
||||
if not isinstance(invoice, str):
|
||||
continue
|
||||
|
||||
if invoice == self.pending_payments.get(checking_id):
|
||||
return True
|
||||
|
||||
try:
|
||||
if bolt11_decode(invoice).payment_hash == checking_id:
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.debug(f"Unable to decode Bark history invoice: {exc}")
|
||||
continue
|
||||
|
||||
return False
|
||||
|
||||
def _movement_to_payment_status(self, movement: dict[str, Any]) -> PaymentStatus:
|
||||
status = movement.get("status")
|
||||
if status == "successful":
|
||||
fee_sat = movement.get("offchain_fee_sat")
|
||||
fee_msat = int(fee_sat) * 1000 if fee_sat is not None else None
|
||||
preimage = self._find_value(
|
||||
movement.get("metadata"),
|
||||
{"preimage", "payment_preimage", "paymentPreimage"},
|
||||
)
|
||||
return PaymentSuccessStatus(fee_msat=fee_msat, preimage=preimage)
|
||||
if status in {"failed", "canceled"}:
|
||||
return PaymentFailedStatus()
|
||||
return PaymentPendingStatus()
|
||||
|
||||
def _find_value(self, data: Any, keys: set[str]) -> str | None:
|
||||
if isinstance(data, dict):
|
||||
for key, value in data.items():
|
||||
if key in keys and isinstance(value, str):
|
||||
return value
|
||||
for value in data.values():
|
||||
found = self._find_value(value, keys)
|
||||
if found:
|
||||
return found
|
||||
if isinstance(data, list):
|
||||
for value in data:
|
||||
found = self._find_value(value, keys)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
|
@ -18,6 +18,7 @@ if TYPE_CHECKING:
|
|||
class Feature(Enum):
|
||||
nodemanager = "nodemanager"
|
||||
holdinvoice = "holdinvoice"
|
||||
descriptionhash = "descriptionhash"
|
||||
# bolt12 = "bolt12"
|
||||
|
||||
|
||||
|
|
|
|||
291
tests/wallets/test_bark.py
Normal file
291
tests/wallets/test_bark.py
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
import asyncio
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from bolt11 import decode as bolt11_decode
|
||||
|
||||
from lnbits.wallets.bark import BarkWallet
|
||||
from lnbits.wallets.base import PaymentResponse
|
||||
|
||||
BOLT11 = (
|
||||
"lnbc1u1pjl0uhypp5yxvdqq923atm9ywkpgtu3yxv9w2n44ensrkwfyagvmzqhml2x9gq"
|
||||
"dpv2phhwetjv4jzqcneypqyc6t8dp6xu6twva2xjuzzda6qcqzzsxqrrsssp5h3qlnnlfq"
|
||||
"ekquacwwj9yu7fhujyzxhzqegpxenscw45pgv6xakfq9qyyssqqjruygw0jrcg3365jksxn"
|
||||
"6yhsxx7c5pdjrjdlyvuhs7xh8r409h4e3kucc54kgh34pscaq3mg7hn55l8a0qszgzex80"
|
||||
"amwrp4gkdgqcpkse88y"
|
||||
)
|
||||
|
||||
|
||||
class FakeWebSocket:
|
||||
def __init__(self, messages: list[dict]):
|
||||
self.messages = messages
|
||||
|
||||
async def recv(self):
|
||||
return json.dumps(self.messages.pop(0))
|
||||
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self, websocket: FakeWebSocket):
|
||||
self.websocket = websocket
|
||||
|
||||
async def __aenter__(self):
|
||||
return self.websocket
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bark_wallet(settings):
|
||||
settings.bark_api_endpoint = "http://localhost:3000"
|
||||
settings.bark_api_token = "test-token"
|
||||
return BarkWallet()
|
||||
|
||||
|
||||
def payment_response(status_code: int, **kwargs) -> httpx.Response:
|
||||
request = httpx.Request("POST", "http://localhost:3000/api/v1/lightning/pay")
|
||||
return httpx.Response(status_code, request=request, **kwargs)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_paid_invoices_stream_yields_successful_receive(
|
||||
bark_wallet: BarkWallet, mocker
|
||||
):
|
||||
checking_id = bolt11_decode(BOLT11).payment_hash
|
||||
notification = {
|
||||
"type": "movement-updated",
|
||||
"movement": {
|
||||
"status": "successful",
|
||||
"received_on": [
|
||||
{
|
||||
"destination": {"type": "invoice", "value": BOLT11},
|
||||
"amount_sat": 100,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
websocket = FakeWebSocket([notification])
|
||||
connect = mocker.patch(
|
||||
"lnbits.wallets.bark.connect", return_value=FakeConnection(websocket)
|
||||
)
|
||||
request = mocker.patch.object(
|
||||
bark_wallet,
|
||||
"_request_json",
|
||||
side_effect=[
|
||||
"websocket-ticket",
|
||||
{"state": "preimage-revealed", "payment_preimage": "preimage"},
|
||||
],
|
||||
)
|
||||
|
||||
stream = bark_wallet.paid_invoices_stream()
|
||||
try:
|
||||
assert await anext(stream) == checking_id
|
||||
status = await bark_wallet.get_invoice_status(checking_id)
|
||||
assert status.success
|
||||
assert status.preimage == "preimage"
|
||||
finally:
|
||||
await stream.aclose()
|
||||
await bark_wallet.cleanup()
|
||||
|
||||
assert request.await_args_list == [
|
||||
mocker.call("GET", "/api/v1/notifications/ws/ticket", timeout=10),
|
||||
mocker.call("GET", f"/api/v1/lightning/receives/{checking_id}"),
|
||||
]
|
||||
connect.assert_called_once_with(
|
||||
"ws://localhost:3000/api/v1/notifications/ws?ticket=websocket-ticket"
|
||||
)
|
||||
|
||||
|
||||
def test_incoming_payment_hash_ignores_non_receive_movements(bark_wallet: BarkWallet):
|
||||
notification = {
|
||||
"type": "movement-updated",
|
||||
"movement": {
|
||||
"status": "successful",
|
||||
"received_on": [],
|
||||
"sent_to": [{"destination": {"type": "invoice", "value": BOLT11}}],
|
||||
},
|
||||
}
|
||||
|
||||
assert bark_wallet._incoming_payment_hash(notification) is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
("data", "expected_paid"),
|
||||
[
|
||||
(
|
||||
{
|
||||
"state": "settled",
|
||||
"settled_at": "2026-07-16T12:00:00Z",
|
||||
"payment_preimage": "preimage",
|
||||
},
|
||||
True,
|
||||
),
|
||||
(
|
||||
{
|
||||
"finished_at": "2026-07-16T12:00:00Z",
|
||||
"preimage_revealed_at": "2026-07-16T12:00:00Z",
|
||||
"payment_preimage": "preimage",
|
||||
},
|
||||
True,
|
||||
),
|
||||
({"state": "awaiting-payment"}, None),
|
||||
({"finished_at": "2026-07-16T12:00:00Z"}, False),
|
||||
],
|
||||
ids=["settled", "legacy-settled", "pending", "failed"],
|
||||
)
|
||||
async def test_get_invoice_status_maps_receive_state(
|
||||
bark_wallet: BarkWallet, mocker, data: dict, expected_paid: bool | None
|
||||
):
|
||||
checking_id = bolt11_decode(BOLT11).payment_hash
|
||||
request = mocker.patch.object(bark_wallet, "_request_json", return_value=data)
|
||||
|
||||
status = await bark_wallet.get_invoice_status(checking_id)
|
||||
|
||||
assert status.paid is expected_paid
|
||||
assert status.preimage == ("preimage" if expected_paid else None)
|
||||
request.assert_awaited_once_with("GET", f"/api/v1/lightning/receives/{checking_id}")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("ok", [True, None], ids=["settled", "pending"])
|
||||
async def test_send_payment_checks_status_after_payment_is_initiated(
|
||||
bark_wallet: BarkWallet, mocker, settings, ok: bool | None
|
||||
):
|
||||
settings.lnbits_funding_source_pay_invoice_wait_seconds = 0
|
||||
checking_id = bolt11_decode(BOLT11).payment_hash
|
||||
expected = PaymentResponse(ok=ok, checking_id=checking_id)
|
||||
mocker.patch.object(
|
||||
bark_wallet.client,
|
||||
"post",
|
||||
return_value=payment_response(
|
||||
200, json={"message": "Payment initiated successfully"}
|
||||
),
|
||||
)
|
||||
get_status = mocker.patch.object(
|
||||
bark_wallet, "_payment_response_from_status", return_value=expected
|
||||
)
|
||||
|
||||
response = await bark_wallet._send_payment(BOLT11, checking_id)
|
||||
|
||||
assert response == expected
|
||||
assert bark_wallet.pending_payments[checking_id] == BOLT11
|
||||
get_status.assert_awaited_once_with(checking_id)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_send_payment_waits_for_successful_movement_notification(
|
||||
bark_wallet: BarkWallet, mocker, settings
|
||||
):
|
||||
settings.lnbits_funding_source_pay_invoice_wait_seconds = 5
|
||||
checking_id = bolt11_decode(BOLT11).payment_hash
|
||||
mocker.patch.object(
|
||||
bark_wallet.client,
|
||||
"post",
|
||||
return_value=payment_response(
|
||||
200, json={"message": "Payment initiated successfully"}
|
||||
),
|
||||
)
|
||||
mocker.patch.object(
|
||||
bark_wallet,
|
||||
"_payment_response_from_status",
|
||||
return_value=PaymentResponse(ok=None, checking_id=checking_id),
|
||||
)
|
||||
|
||||
payment_task = asyncio.create_task(bark_wallet._send_payment(BOLT11, checking_id))
|
||||
await asyncio.sleep(0)
|
||||
bark_wallet._notify_outgoing_payment(
|
||||
{
|
||||
"type": "movement-updated",
|
||||
"movement": {
|
||||
"status": "successful",
|
||||
"offchain_fee_sat": 2,
|
||||
"metadata": {"payment_preimage": "preimage"},
|
||||
"sent_to": [
|
||||
{
|
||||
"destination": {"type": "invoice", "value": BOLT11},
|
||||
"amount_sat": 100,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
response = await payment_task
|
||||
|
||||
assert response.ok is True
|
||||
assert response.checking_id == checking_id
|
||||
assert response.fee_msat == 2000
|
||||
assert response.preimage == "preimage"
|
||||
assert checking_id not in bark_wallet.outgoing_payment_waiters
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
httpx.TimeoutException("timeout"),
|
||||
httpx.ReadError("connection lost"),
|
||||
],
|
||||
ids=["timeout", "read-error"],
|
||||
)
|
||||
async def test_send_payment_keeps_transport_errors_pending(
|
||||
bark_wallet: BarkWallet, mocker, error: httpx.RequestError
|
||||
):
|
||||
checking_id = bolt11_decode(BOLT11).payment_hash
|
||||
mocker.patch.object(bark_wallet.client, "post", side_effect=error)
|
||||
|
||||
response = await bark_wallet._send_payment(BOLT11, checking_id)
|
||||
|
||||
assert response.pending
|
||||
assert response.checking_id == checking_id
|
||||
assert bark_wallet.pending_payments[checking_id] == BOLT11
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
("status_code", "expected_ok"),
|
||||
[(400, False), (500, None)],
|
||||
ids=["client-error", "server-error"],
|
||||
)
|
||||
async def test_send_payment_maps_http_errors(
|
||||
bark_wallet: BarkWallet,
|
||||
mocker,
|
||||
status_code: int,
|
||||
expected_ok: bool | None,
|
||||
):
|
||||
checking_id = bolt11_decode(BOLT11).payment_hash
|
||||
mocker.patch.object(
|
||||
bark_wallet.client,
|
||||
"post",
|
||||
return_value=payment_response(status_code, json={"message": "payment error"}),
|
||||
)
|
||||
|
||||
response = await bark_wallet._send_payment(BOLT11, checking_id)
|
||||
|
||||
assert response.ok is expected_ok
|
||||
assert response.checking_id == checking_id
|
||||
assert (checking_id in bark_wallet.pending_payments) is (expected_ok is None)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"response",
|
||||
[
|
||||
payment_response(200, content=b"not json"),
|
||||
payment_response(200, json={"unexpected": "response"}),
|
||||
],
|
||||
ids=["invalid-json", "missing-message"],
|
||||
)
|
||||
async def test_send_payment_keeps_invalid_responses_pending(
|
||||
bark_wallet: BarkWallet, mocker, response: httpx.Response
|
||||
):
|
||||
checking_id = bolt11_decode(BOLT11).payment_hash
|
||||
mocker.patch.object(bark_wallet.client, "post", return_value=response)
|
||||
|
||||
payment = await bark_wallet._send_payment(BOLT11, checking_id)
|
||||
|
||||
assert payment.pending
|
||||
assert payment.checking_id == checking_id
|
||||
assert bark_wallet.pending_payments[checking_id] == BOLT11
|
||||
Loading…
Add table
Add a link
Reference in a new issue