Merge branch 'dev' into feat/amboss-payments-funding-source

This commit is contained in:
Bufo 2026-08-13 10:52:20 +02:00 committed by GitHub
commit ddcb88220b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
104 changed files with 6180 additions and 387 deletions

View file

@ -93,16 +93,36 @@ migration:
uv run python tools/conv.py
openapi:
@OPENAPI_SPEC_FILE=$$(mktemp); \
OPENAPI_DATA_DIR=$$(mktemp -d); \
LNBITS_ADMIN_UI=False \
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
LNBITS_DATA_FOLDER="./tests/data" \
LNBITS_DATA_FOLDER="$$OPENAPI_DATA_DIR" \
LNBITS_EXTENSIONS_DEFAULT_INSTALL='[]' \
LNBITS_EXTENSIONS_DEACTIVATE_ALL=true \
PYTHONUNBUFFERED=1 \
DEBUG=false \
HOST=0.0.0.0 \
PORT=5003 \
uv run lnbits &
sleep 15
curl -s http://0.0.0.0:5003/openapi.json | uv run openapi-spec-validator --errors=all -
# kill -9 %1
uv run lnbits & \
OPENAPI_SERVER_PID=$$!; \
trap 'kill "$$OPENAPI_SERVER_PID" 2>/dev/null || true; wait "$$OPENAPI_SERVER_PID" 2>/dev/null || true; rm -f "$$OPENAPI_SPEC_FILE"; rm -rf "$$OPENAPI_DATA_DIR"' EXIT; \
OPENAPI_ATTEMPT=0; \
while [ "$$OPENAPI_ATTEMPT" -lt 60 ]; do \
if curl --fail --silent --max-time 2 --output "$$OPENAPI_SPEC_FILE" \
http://127.0.0.1:5003/openapi.json; then \
uv run openapi-spec-validator --errors=all "$$OPENAPI_SPEC_FILE"; \
exit $$?; \
fi; \
if ! kill -0 "$$OPENAPI_SERVER_PID" 2>/dev/null; then \
echo "LNbits exited before serving the OpenAPI schema." >&2; \
exit 1; \
fi; \
OPENAPI_ATTEMPT=$$((OPENAPI_ATTEMPT + 1)); \
sleep 1; \
done; \
echo "LNbits did not serve the OpenAPI schema within 60 seconds." >&2; \
exit 1
bak:
# LNBITS_DATABASE_URL=postgres://postgres:postgres@0.0.0.0:5432/postgres

36
SECURITY.md Normal file
View file

@ -0,0 +1,36 @@
# Security Policy
## Supported Versions
Security fixes are provided for the current released version of LNbits and the
`dev` branch. Older releases and release candidates are not supported unless a
maintainer explicitly states otherwise.
| Version | Supported |
| ------------------ | --------- |
| Current release | Yes |
| `dev` branch | Yes |
| Older releases | No |
| Release candidates | No |
## Reporting a Vulnerability
Please report suspected vulnerabilities privately using [GitHub's private
vulnerability reporting](https://github.com/lnbits/lnbits/security/advisories/new).
Do not open a public issue, discussion, or pull request for a security
vulnerability.
Include enough detail for maintainers to reproduce and assess the issue, such
as the affected version or commit, configuration, steps to reproduce, impact,
and any proof of concept. Do not include credentials, API keys, wallet data, or
other sensitive information unless it is necessary and can be shared safely.
Maintainers will acknowledge the report, investigate it, and coordinate a fix
and disclosure timeline with you. Please allow time for a fix to be prepared
before publicly disclosing the vulnerability.
## Scope
This policy covers the LNbits core repository and LNbits extensions in the LNbits GitHub organisation. Vulnerabilities in third-party
funding sources, dependencies or hosted LNbits instances may need to be reported to their respective maintainers or
operators as well.

View file

@ -568,7 +568,6 @@ async def check_and_register_extensions(app: FastAPI) -> None:
def register_async_tasks() -> None:
task_manager.init()
# listen to all incoming payments and dispatch payment notifications

View file

@ -6,6 +6,7 @@ from .views.api import api_router
from .views.asset_api import asset_router
from .views.audit_api import audit_router
from .views.auth_api import auth_router
from .views.blockexplorer_api import blockexplorer_router
from .views.callback_api import callback_router
from .views.extension_api import extension_router
from .views.extensions_builder_api import extension_builder_router
@ -49,6 +50,7 @@ def init_core_routers(app: FastAPI):
app.include_router(asset_router)
app.include_router(fiat_router)
app.include_router(lnurl_router)
app.include_router(blockexplorer_router)
__all__ = ["core_app", "core_app_extra", "db"]

View file

@ -5,6 +5,7 @@ from uuid import uuid4
from lnbits.core.db import db
from lnbits.core.models.wallets import BaseWallet, WalletsFilters, WalletType
from lnbits.db import Connection, Filters, Page
from lnbits.helpers import generate_ln_address
from lnbits.settings import settings
from lnbits.utils.cache import cache
@ -30,6 +31,8 @@ async def create_wallet(
inkey=uuid4().hex,
currency=settings.lnbits_default_accounting_currency or "USD",
)
if settings.ln_address_creation_allowed and wallet.is_lightning_wallet:
wallet.lightning_address = await generate_lightning_address_local_part(conn)
await (conn or db).insert("wallets", wallet)
return wallet
@ -123,11 +126,21 @@ async def get_standalone_wallet(
"""
if deleted is not None:
query += " AND deleted = :deleted "
return await (conn or db).fetchone(
wallet = await (conn or db).fetchone(
query,
{"wallet": wallet_id, "deleted": deleted},
Wallet,
)
if not wallet:
return None
if deleted is True:
return wallet
if not wallet.lightning_address and settings.ln_address_creation_allowed:
wallet.lightning_address = await generate_lightning_address_local_part(conn)
await update_wallet(wallet, conn)
return wallet
async def get_wallet(
@ -220,6 +233,30 @@ async def get_wallets_count():
return row.get("count", 0)
async def generate_lightning_address_local_part(
conn: Connection | None = None,
) -> str:
for _ in range(100):
local_part = generate_ln_address()
if await get_wallet_id_by_ln_address(local_part, conn):
continue
return local_part
raise ValueError("Could not generate a unique wallet lightning address.")
async def get_wallet_id_by_ln_address(
local_part: str, conn: Connection | None = None
) -> str | None:
row: dict = await (conn or db).fetchone(
"""
SELECT id FROM wallets
WHERE lightning_address = :lightning_address
""",
{"lightning_address": local_part.lower()},
)
return row["id"] if row else None
async def get_wallet_for_key(
key: str,
conn: Connection | None = None,

View file

@ -890,3 +890,15 @@ async def m049_add_permissions_to_user_extensions(db: Connection):
Adds user-level extension permission grants.
"""
await db.execute("ALTER TABLE extensions ADD COLUMN permissions TEXT DEFAULT '{}'")
async def m050_add_lightning_address_to_wallets(db: Connection):
"""
Adds a LUD-16 lightning address local-part to wallets.
"""
await db.execute("ALTER TABLE wallets ADD COLUMN lightning_address TEXT")
logger.debug("Creating index idx_wallets_lightning_address...")
await db.execute("""
CREATE UNIQUE INDEX IF NOT EXISTS idx_wallets_lightning_address
ON wallets (lightning_address);
""")

View file

@ -275,6 +275,9 @@ class CreateInvoice(BaseModel):
labels: list[str] = []
external_id: str | None = Query(default=None, max_length=256)
def is_fiat_subscription(self) -> bool:
return (self.extra or {}).get("fiat_method") == "subscription"
@validator("payment_hash")
def check_hex(cls, v):
if v:

View file

@ -126,6 +126,7 @@ class Wallet(BaseWallet):
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
currency: str | None = None
lightning_address: str | None = None
balance_msat: int = Field(default=0, no_database=True)
extra: WalletExtra = WalletExtra()
stored_paylinks: StoredPayLinks = StoredPayLinks()
@ -150,6 +151,7 @@ class Wallet(BaseWallet):
if len(self.share_permissions):
self.currency = shared_wallet.currency
self.lightning_address = shared_wallet.lightning_address
self.balance_msat = shared_wallet.balance_msat
self.stored_paylinks = shared_wallet.stored_paylinks
@ -240,10 +242,18 @@ class BaseWalletTypeInfo:
class WalletsFilters(FilterModel):
__search_fields__ = ["id", "name", "currency"]
__search_fields__ = ["id", "name", "currency", "lightning_address"]
__sort_fields__ = ["id", "name", "currency", "created_at", "updated_at"]
__sort_fields__ = [
"id",
"name",
"currency",
"lightning_address",
"created_at",
"updated_at",
]
id: str | None
name: str | None
currency: str | None
lightning_address: str | None

View file

@ -1,3 +1,10 @@
from .blockexplorer import (
fetch_fee_estimates,
fetch_onchain_balance,
fetch_recent_blocks,
fetch_tip,
fetch_transaction,
)
from .fiat_providers import check_fiat_status
from .funding_source import (
get_balance_delta,
@ -56,7 +63,12 @@ __all__ = [
"enqueue_admin_notification",
"fee_reserve",
"fee_reserve_total",
"fetch_fee_estimates",
"fetch_lnurl_pay_request",
"fetch_onchain_balance",
"fetch_recent_blocks",
"fetch_tip",
"fetch_transaction",
"get_balance_delta",
"get_payments_daily_stats",
"get_pr_from_lnurl",

View file

@ -0,0 +1,97 @@
import asyncio
from lnbits.settings import settings
from lnbits.task_manager import OnchainAddressEvent
from lnbits.utils.electrum import (
UTXO,
AddressResponse,
Balance,
BlockHeader,
BlockInfo,
ElectrumClient,
FeeResponse,
Transaction,
network_from_name,
parse_block_header,
parse_raw_tx,
scripthash_from_address,
)
def _client() -> ElectrumClient:
return ElectrumClient(
settings.lnbits_blockexplorer_electrum_url,
network=network_from_name(settings.lnbits_blockexplorer_network),
)
async def fetch_recent_blocks(count: int = 5) -> list[BlockInfo]:
async with _client() as c:
tip = await c.get_tip()
start = max(0, tip.height - count + 1)
headers = await c.get_block_headers(start, tip.height - start + 1)
raw = bytes.fromhex(headers.hex)
blocks = [
parse_block_header(raw[i * 80 : (i + 1) * 80].hex(), start + i)
for i in range(headers.count)
]
return list(reversed(blocks))
async def fetch_tip() -> BlockHeader:
async with _client() as c:
return await c.get_tip()
async def fetch_fee_estimates() -> FeeResponse:
async with _client() as c:
estimates_raw = await asyncio.gather(
c.estimate_fee(1),
c.estimate_fee(3),
c.estimate_fee(6),
c.estimate_fee(144),
)
histogram = await c.fee_histogram()
estimates = {
str(blocks): fee
for blocks, fee in zip([1, 3, 6, 144], estimates_raw, strict=False)
if fee >= 0
}
return FeeResponse(estimates=estimates, histogram=histogram)
async def fetch_transaction(txid: str) -> Transaction:
async with _client() as c:
raw_hex = await c.get_transaction(txid)
return parse_raw_tx(raw_hex, network=c.network)
async def fetch_onchain_balance(onchain_address: str) -> AddressResponse:
scripthash = scripthash_from_address(onchain_address)
async with _client() as client:
balance_res, history_res = await asyncio.gather(
client.get_balance(scripthash),
client.get_history(scripthash),
return_exceptions=True,
)
if isinstance(balance_res, BaseException):
raise balance_res
history = [] if isinstance(history_res, BaseException) else history_res
history_error = str(history_res) if isinstance(history_res, BaseException) else None
return AddressResponse(
balance=balance_res, history=history, history_error=history_error
)
async def fetch_utxos(onchain_address: str) -> list[UTXO]:
scripthash = scripthash_from_address(onchain_address)
async with _client() as client:
return await client.listunspent(scripthash)
def address_event_to_response(event: OnchainAddressEvent) -> AddressResponse:
return AddressResponse(
balance=Balance(confirmed=event.confirmed, unconfirmed=event.unconfirmed),
history=event.history,
history_error=event.history_error,
)

View file

@ -0,0 +1,242 @@
import json
import re
from fastapi import Query, Request
from lnurl import (
CallbackUrl,
LightningInvoice,
LnurlErrorResponse,
LnurlPayActionResponse,
LnurlPayMetadata,
LnurlPayResponse,
MilliSatoshi,
)
from pydantic import parse_obj_as
from lnbits.core.crud.wallets import (
get_wallet,
get_wallet_id_by_ln_address,
update_wallet,
)
from lnbits.core.models.payments import CreateInvoice
from lnbits.core.models.wallets import Wallet
from lnbits.core.services.payments import (
create_invoice,
create_wallet_invoice,
pay_invoice,
)
from lnbits.db import Connection
from lnbits.exceptions import PaymentError
from lnbits.settings import settings
MAX_SENDABLE_MSAT = 2_100_000_000_000_000_000
COMMENT_ALLOWED = 799
LIGHTNING_ADDRESS_REGEX = re.compile(r"^[a-z0-9_.-]{1,210}$")
async def set_wallet_lightning_address(
*,
wallet: Wallet,
local_part: str,
allow_blacklisted: bool = False,
charge: bool = False,
conn: Connection | None = None,
) -> Wallet:
if not settings.ln_address_creation_allowed:
raise ValueError("Wallet Lightning Addresses are disabled.")
if not wallet.is_lightning_wallet or wallet.deleted:
raise ValueError("Lightning Address can only be set for active wallets.")
local_part = await _validate_local_part(
local_part, wallet.id, allow_blacklisted, conn=conn
)
if wallet.lightning_address == local_part:
return wallet
if charge:
await _charge_for_lightning_address(wallet)
wallet.lightning_address = local_part
return await update_wallet(wallet, conn=conn)
async def wallet_lightning_address_response(
username: str, request: Request
) -> LnurlPayResponse | LnurlErrorResponse:
local_part, tag = _split_tagged_local_part(username)
wallet_id = await get_wallet_id_by_ln_address(local_part)
if not wallet_id:
return LnurlErrorResponse(reason="Lightning address not found.")
tagged_local_part = local_part
if tag:
tagged_local_part = f"{tagged_local_part}+{tag}"
callback = request.url_for(
"lnurl.api_wallet_lightning_address_callback",
username=tagged_local_part,
)
identifier = _lightning_address_for_request(request, tagged_local_part)
return LnurlPayResponse(
callback=parse_obj_as(CallbackUrl, str(callback)),
minSendable=MilliSatoshi(1000),
maxSendable=MilliSatoshi(MAX_SENDABLE_MSAT),
metadata=LnurlPayMetadata(json.dumps(_metadata(identifier, tag))),
commentAllowed=COMMENT_ALLOWED,
)
async def wallet_lightning_address_callback(
username: str,
request: Request,
amount: int = Query(...),
) -> LnurlErrorResponse | LnurlPayActionResponse:
local_part, tag = _split_tagged_local_part(username)
wallet_id = await get_wallet_id_by_ln_address(local_part)
if not wallet_id:
return LnurlErrorResponse(reason="Lightning address not found.")
if amount < 1000:
return LnurlErrorResponse(reason="Amount is smaller than minimum 1000.")
if amount > MAX_SENDABLE_MSAT:
return LnurlErrorResponse(
reason=f"Amount is greater than maximum {MAX_SENDABLE_MSAT}."
)
comment = request.query_params.get("comment")
if len(comment or "") > COMMENT_ALLOWED:
return LnurlErrorResponse(
reason=(
f"Got a comment with {len(comment or '')} characters, "
f"but can only accept {COMMENT_ALLOWED}"
)
)
tagged_local_part = local_part
if tag:
tagged_local_part = f"{tagged_local_part}+{tag}"
identifier = _lightning_address_for_request(request, tagged_local_part)
extra = {
"tag": "wallet_lightning_address",
"lnaddress": identifier,
}
if tag:
extra["lnaddress_tag"] = tag
if comment:
extra["comment"] = comment
metadata = LnurlPayMetadata(json.dumps(_metadata(identifier, tag)))
payment = await create_invoice(
wallet_id=wallet_id,
amount=int(amount / 1000),
memo=f"Payment to {identifier}",
unhashed_description=metadata.encode(),
extra=extra,
)
invoice = parse_obj_as(LightningInvoice, LightningInvoice(payment.bolt11))
return LnurlPayActionResponse(pr=invoice, disposable=False)
def _lightning_address_for_request(request: Request, local_part: str) -> str:
return f"{local_part}@{request.url.netloc}"
async def _validate_local_part(
local_part: str,
wallet_id: str,
allow_blacklisted: bool = False,
conn: Connection | None = None,
) -> str:
local_part = local_part.strip().lower()
if not local_part:
raise ValueError("Lightning Address is required.")
if "+" in local_part:
raise ValueError("Lightning Address cannot include tags.")
if "@" in local_part:
raise ValueError("Enter only the Lightning Address name before @.")
if not LIGHTNING_ADDRESS_REGEX.match(local_part):
raise ValueError(
"Lightning Address can only contain lowercase letters, numbers, "
"dash, underscore, and dot."
)
if not allow_blacklisted and _uses_blacklisted_word(local_part):
raise ValueError("Lightning Address contains a reserved word.")
existing_wallet_id = await get_wallet_id_by_ln_address(local_part, conn=conn)
if existing_wallet_id and existing_wallet_id != wallet_id:
raise ValueError("Lightning Address is already taken.")
return local_part
def _split_tagged_local_part(local_part: str) -> tuple[str, str | None]:
username, separator, tag = local_part.partition("+")
if not separator or not tag:
return username.lower(), None
return username.lower(), tag
def _metadata(identifier: str, tag: str | None = None) -> list[list[str]]:
metadata = [
["text/plain", f"Payment to {identifier}"],
["text/identifier", identifier],
]
if tag:
metadata.append(["text/tag", tag])
return metadata
async def _charge_for_lightning_address(wallet: Wallet) -> None:
price_sats = settings.lnbits_wallet_lightning_address_price_sats
if not settings.lnbits_charge_wallet_lightning_addresses or price_sats <= 0:
return
if not settings.lnbits_service_fee_wallet:
raise ValueError("Lightning Address fee wallet is not configured.")
if settings.lnbits_service_fee_wallet == wallet.source_wallet_id:
raise ValueError("Lightning Address fee wallet cannot be the same wallet.")
fee_wallet = await get_wallet(settings.lnbits_service_fee_wallet)
if not fee_wallet:
raise ValueError("Lightning Address fee wallet is not configured.")
invoice = await create_wallet_invoice(
settings.lnbits_service_fee_wallet,
CreateInvoice(
out=False,
amount=price_sats,
memo="Lightning Address fee",
internal=True,
extra={
"tag": "wallet_lightning_address_fee",
"wallet": wallet.source_wallet_id,
},
),
)
try:
await pay_invoice(
wallet_id=wallet.source_wallet_id,
payment_request=invoice.bolt11,
description="Lightning Address fee",
tag="wallet_lightning_address_fee",
extra={
"tag": "wallet_lightning_address_fee",
"fee_wallet": settings.lnbits_service_fee_wallet,
},
)
except PaymentError as exc:
raise ValueError(exc.message) from exc
def _blacklist_words() -> set[str]:
return {
word.strip().lower()
for word in settings.lnbits_wallet_lightning_address_blacklist
if word.strip()
}
def _uses_blacklisted_word(local_part: str) -> bool:
words = _blacklist_words()
if not words:
return False
segments = [segment for segment in re.split(r"[._-]+", local_part) if segment]
return local_part in words or any(segment in words for segment in segments)

View file

@ -118,6 +118,8 @@ async def create_payment_request(
Create a lightning invoice or a fiat payment request.
"""
if invoice_data.fiat_provider:
if invoice_data.is_fiat_subscription():
raise ValueError("Cannot create direct fiat subscription payments.")
return await create_fiat_invoice(wallet_id, invoice_data)
return await create_wallet_invoice(wallet_id, invoice_data)
@ -848,26 +850,46 @@ async def _pay_external_invoice(
)
return payment
# IMPORTANT PAYMENT RULES!
# True -> success
# False-> failed
# None -> pending (any ambigous payment responses MUST be set as pending)
# payment failed
if (
payment_response.checking_id is None
or payment_response.ok is False
or payment_response.checking_id != checking_id
):
if payment_response.failed:
payment.status = PaymentState.FAILED
await update_payment(payment, conn=conn)
message = payment_response.error_message or "without an error message."
raise PaymentError(f"Payment failed: {message}", status="failed")
if payment_response.success:
# payment successful
elif payment_response.success:
payment = await update_payment_success_status(
payment, payment_response, conn=conn
payment,
payment_response,
conn=conn,
new_checking_id=payment_response.checking_id,
)
await _send_payment_notification_in_background(wallet.id, payment, conn=conn)
logger.success(f"payment successful {payment_response.checking_id}")
logger.success(f"payment successful {payment.checking_id}")
# payment pending
else:
if (
payment_response.checking_id
and payment_response.checking_id != payment.checking_id
):
payment = await update_payment(
payment,
new_checking_id=payment_response.checking_id,
conn=conn,
)
logger.warning(
f"payment status unknown {payment.checking_id}: "
f"{payment_response.error_message or 'no error message'}"
)
payment.checking_id = payment_response.checking_id
return payment
@ -875,13 +897,16 @@ async def update_payment_success_status(
payment: Payment,
status: PaymentStatus,
conn: Connection | None = None,
new_checking_id: str | None = None,
) -> Payment:
if status.success:
service_fee_msat = service_fee(payment.amount, internal=False)
payment.status = PaymentState.SUCCESS
payment.fee = -(abs(status.fee_msat or 0) + abs(service_fee_msat))
payment.preimage = payment.preimage or status.preimage
payment = await update_payment(payment, conn=conn)
payment = await update_payment(
payment, new_checking_id=new_checking_id, conn=conn
)
return payment

View file

@ -0,0 +1,170 @@
import asyncio
from http import HTTPStatus
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket
from pydantic.types import UUID4
from lnbits.core.services.blockexplorer import (
address_event_to_response,
fetch_fee_estimates,
fetch_onchain_balance,
fetch_recent_blocks,
fetch_tip,
fetch_transaction,
fetch_utxos,
)
from lnbits.decorators import check_access_token, check_user_exists
from lnbits.settings import settings
from lnbits.task_manager import (
OnchainAddressEvent,
OnchainTxEvent,
relay_ws_queue,
task_manager,
)
from lnbits.utils.electrum import (
UTXO,
AddressResponse,
BlockHeader,
BlockInfo,
ElectrumError,
FeeResponse,
Transaction,
scripthash_from_address,
)
blockexplorer_router = APIRouter(
tags=["Block Explorer"],
prefix="/blockexplorer/api/v1",
)
def _check_enabled() -> None:
if not settings.lnbits_blockexplorer_enabled:
raise HTTPException(
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
detail="Block explorer is not enabled.",
)
async def _check_api_access(
r: Request,
access_token: Annotated[str | None, Depends(check_access_token)],
usr: UUID4 | None = None,
) -> None:
_check_enabled()
if not settings.lnbits_blockexplorer_public_api:
await check_user_exists(r, access_token, usr)
# ---- REST ----
@blockexplorer_router.get("/blocks", dependencies=[Depends(_check_api_access)])
async def api_blocks() -> list[BlockInfo]:
try:
return await fetch_recent_blocks()
except ElectrumError as e:
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e
@blockexplorer_router.get("/tip", dependencies=[Depends(_check_api_access)])
async def api_tip() -> BlockHeader:
try:
return await fetch_tip()
except ElectrumError as e:
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e
@blockexplorer_router.get("/fees", dependencies=[Depends(_check_api_access)])
async def api_fees() -> FeeResponse:
try:
return await fetch_fee_estimates()
except ElectrumError as e:
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e
@blockexplorer_router.get("/tx/{txid}", dependencies=[Depends(_check_api_access)])
async def api_tx(txid: str) -> Transaction:
try:
return await fetch_transaction(txid)
except ElectrumError as e:
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e
@blockexplorer_router.get(
"/address/{address}", dependencies=[Depends(_check_api_access)]
)
async def api_address(address: str) -> AddressResponse:
try:
scripthash_from_address(address)
except ValueError as e:
raise HTTPException(HTTPStatus.BAD_REQUEST, detail=str(e)) from e
try:
return await fetch_onchain_balance(address)
except ElectrumError as e:
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e
@blockexplorer_router.get("/utxos/{address}", dependencies=[Depends(_check_api_access)])
async def api_utxos(address: str) -> list[UTXO]:
try:
scripthash_from_address(address)
except ValueError as e:
raise HTTPException(HTTPStatus.BAD_REQUEST, detail=str(e)) from e
try:
return await fetch_utxos(address)
except ElectrumError as e:
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e
# ---- WebSocket ----
@blockexplorer_router.websocket("/ws/blocks")
async def ws_blocks(websocket: WebSocket) -> None:
if not settings.lnbits_blockexplorer_enabled:
await websocket.close(code=1008)
return
await websocket.accept()
queue: asyncio.Queue[BlockInfo] = asyncio.Queue()
task_manager.register_ws_block_queue(queue)
try:
await relay_ws_queue(websocket, queue)
finally:
task_manager.unregister_ws_block_queue(queue)
@blockexplorer_router.websocket("/ws/address/{address}")
async def ws_address(websocket: WebSocket, address: str) -> None:
if not settings.lnbits_blockexplorer_enabled:
await websocket.close(code=1008)
return
await websocket.accept()
queue: asyncio.Queue[OnchainAddressEvent] = asyncio.Queue()
try:
task_manager.register_ws_address_queue(address, queue)
except ValueError as e:
await websocket.close(code=1008, reason=str(e))
return
try:
await relay_ws_queue(websocket, queue, serialize=address_event_to_response)
finally:
task_manager.unregister_ws_address_queue(address, queue)
@blockexplorer_router.websocket("/ws/tx/{txid}")
async def ws_tx(websocket: WebSocket, txid: str) -> None:
if not settings.lnbits_blockexplorer_enabled:
await websocket.close(code=1008)
return
await websocket.accept()
queue: asyncio.Queue[OnchainTxEvent] = asyncio.Queue()
task_manager.register_ws_tx_queue(txid, queue)
try:
await relay_ws_queue(websocket, queue, stop_after=lambda e: e.confirmed)
finally:
task_manager.unregister_ws_tx_queue(txid, queue)

View file

@ -185,6 +185,8 @@ admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
@generic_router.get("/wallets")
@generic_router.get("/account")
@generic_router.get("/extensions")
@generic_router.get("/blockexplorer")
@generic_router.get("/blockexplorer/{resource_type}/{resource}")
@generic_router.get("/users", dependencies=admin_ui_checks)
@generic_router.get("/audit", dependencies=admin_ui_checks)
@generic_router.get("/node", dependencies=admin_ui_checks)

View file

@ -1,10 +1,13 @@
from http import HTTPStatus
from typing import Any
import httpx
from fastapi import (
APIRouter,
Depends,
HTTPException,
Query,
Request,
)
from lnurl import (
LnurlAuthResponse,
@ -18,6 +21,7 @@ from lnurl import execute_login as lnurlauth
from lnurl import handle as lnurl_handle
from lnurl.models import LnurlResponseModel
from loguru import logger
from pydantic import ValidationError
from lnbits.core.models import Payment
from lnbits.core.models.lnurl import CreateLnurlPayment, LnurlScan
@ -27,13 +31,50 @@ from lnbits.decorators import (
require_base_invoice_key,
)
from lnbits.helpers import check_callback_url
from lnbits.settings import settings
from lnbits.settings import RedirectPath, settings
from ..services import fetch_lnurl_pay_request, pay_invoice
from ..services.lightning_address import (
wallet_lightning_address_callback,
wallet_lightning_address_response,
)
lnurl_router = APIRouter(tags=["LNURL"])
@lnurl_router.get(
"/.well-known/lnurlp/{username}",
name="lnurl.api_wallet_lightning_address_response",
)
async def api_wallet_lightning_address_response(
username: str, request: Request
) -> LnurlPayResponse | LnurlErrorResponse:
if settings.lnbits_ln_address_mode in ["extension_first", "extension_only"]:
req_headers = request["headers"] if "headers" in request else []
redirect = settings.find_extension_redirect(request.url.path, req_headers)
if redirect:
resp = await _check_extension_well_known(redirect, request)
if resp and resp.ok:
return resp
if settings.lnbits_ln_address_mode == "extension_only":
return LnurlErrorResponse(
reason="Lightning addresses are not supported on this instance."
)
return await wallet_lightning_address_response(username, request)
@lnurl_router.get(
"/api/v1/lnurl/wallet/{username}/cb",
name="lnurl.api_wallet_lightning_address_callback",
)
async def api_wallet_lightning_address_callback(
username: str, request: Request, amount: int = Query(...)
) -> LnurlErrorResponse | Any:
return await wallet_lightning_address_callback(username, request, amount)
async def _handle(lnurl: str) -> LnurlResponseModel:
try:
if "@" in lnurl: # lower case lightning addresses
@ -137,3 +178,32 @@ async def api_payments_pay_lnurl(
)
return payment
async def _check_extension_well_known(
redirect: RedirectPath, request: Request
) -> LnurlPayResponse | LnurlErrorResponse | None:
target_path = redirect.new_path_from(request.url.path)
transport = httpx.ASGITransport(app=request.app)
try:
async with httpx.AsyncClient(
transport=transport,
base_url=str(request.base_url),
) as client:
response = await client.get(
target_path,
headers={"accept": "application/json"},
)
response.raise_for_status()
response_data = response.json()
try:
return LnurlPayResponse.parse_obj(response_data)
except ValidationError:
return LnurlErrorResponse.parse_obj(response_data)
except Exception as exc:
logger.warning(
f"Failed to fetch LNURL response Extension redirect {target_path}: {exc}"
)
return None

View file

@ -41,6 +41,7 @@ from lnbits.core.services import (
update_user_extensions,
update_wallet_balance,
)
from lnbits.core.services.lightning_address import set_wallet_lightning_address
from lnbits.db import Filters, Page
from lnbits.decorators import check_admin, check_super_user, parse_filters
from lnbits.helpers import (
@ -280,6 +281,34 @@ async def api_users_create_user_wallet(
return wallet
@users_router.put(
"/user/{user_id}/wallet/{wallet}/lightning-address",
name="Set wallet Lightning Address",
)
async def api_users_set_wallet_lightning_address(
user_id: str,
wallet: str,
lightning_address: str = Body(..., embed=True),
) -> Wallet:
wal = await get_wallet(wallet)
if not wal:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Wallet does not exist.",
)
if user_id != wal.user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Wallet does not belong to user.",
)
return await set_wallet_lightning_address(
wallet=wal,
local_part=lightning_address,
allow_blacklisted=True,
charge=False,
)
@users_router.put(
"/user/{user_id}/wallet/{wallet}/undelete", name="Reactivate deleted wallet"
)

View file

@ -22,6 +22,7 @@ from lnbits.core.models.wallets import (
WalletSharePermission,
WalletType,
)
from lnbits.core.services.lightning_address import set_wallet_lightning_address
from lnbits.core.services.wallets import (
create_lightning_shared_wallet,
delete_wallet_share,
@ -38,6 +39,7 @@ from lnbits.decorators import (
require_invoice_key,
)
from lnbits.helpers import generate_filter_params_openapi
from lnbits.settings import settings
from ..crud import (
delete_wallet,
@ -164,6 +166,7 @@ async def api_update_wallet(
color: str | None = Body(None),
currency: str | None = Body(None),
pinned: bool | None = Body(None),
lightning_address: str | None = Body(None),
key_info: WalletTypeInfo = Depends(require_admin_key),
) -> Wallet:
wallet = await get_wallet(key_info.wallet.id)
@ -175,6 +178,20 @@ async def api_update_wallet(
wallet.extra.pinned = pinned if pinned is not None else wallet.extra.pinned
wallet.currency = currency if currency is not None else wallet.currency
if lightning_address and lightning_address != wallet.lightning_address:
if not settings.lnbits_allow_custom_wallet_lightning_addresses:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Users cannot specify Lightning Addresses.",
)
# too much logic here
wallet = await set_wallet_lightning_address(
wallet=wallet,
local_part=lightning_address,
charge=True,
)
return wallet
await update_wallet(wallet)
return wallet

View file

@ -13,6 +13,7 @@ from fastapi.routing import APIRoute
from loguru import logger
from packaging import version
from pydantic.schema import field_schema
from random_username.generate import generate_username # type: ignore[import-untyped]
from starlette.templating import Jinja2Templates
from lnbits.settings import settings
@ -22,6 +23,10 @@ from lnbits.utils.exchange_rates import currencies
from .db import FilterModel
def generate_ln_address() -> str:
return generate_username(1)[0].lower()
def get_db_vendor_name():
db_url = settings.lnbits_database_url
return (

View file

@ -104,7 +104,7 @@ class ExtensionsRedirectMiddleware:
req_headers = scope["headers"] if "headers" in scope else []
redirect = settings.find_extension_redirect(scope["path"], req_headers)
if redirect:
if redirect and not redirect.is_duplicate_well_known():
scope["path"] = redirect.new_path_from(scope["path"])
await self.app(scope, receive, send)

View file

@ -11,7 +11,7 @@ from enum import Enum
from os import path
from pathlib import Path
from time import gmtime, strftime, time
from typing import Any
from typing import Any, Literal
from uuid import uuid4
from loguru import logger
@ -45,6 +45,16 @@ class UsersSettings(LNbitsSettings):
lnbits_admin_users: list[str] = Field(default=[])
lnbits_allowed_users: list[str] = Field(default=[])
lnbits_allow_new_accounts: bool = Field(default=True)
lnbits_ln_address_mode: Literal[
"core_first", "extension_first", "extension_only"
] = Field(default="extension_first")
lnbits_allow_custom_wallet_lightning_addresses: bool = Field(default=False)
lnbits_charge_wallet_lightning_addresses: bool = Field(default=False)
lnbits_wallet_lightning_address_price_sats: int = Field(default=1000, ge=0)
lnbits_wallet_lightning_address_blacklist: list[str] = Field(
default=["admin", "info", "support", "help", "security"]
)
lnbits_require_user_activation: bool = Field(default=False)
lnbits_user_activation_by_email: bool = Field(default=False)
@ -58,6 +68,10 @@ class UsersSettings(LNbitsSettings):
def new_accounts_allowed(self) -> bool:
return self.lnbits_allow_new_accounts and len(self.lnbits_allowed_users) == 0
@property
def ln_address_creation_allowed(self) -> bool:
return self.lnbits_ln_address_mode != "extension_only"
class ExtensionsSettings(LNbitsSettings):
lnbits_admin_extensions: list[str] = Field(default=[])
@ -126,6 +140,9 @@ class RedirectPath(BaseModel):
redirect_to_path: str
header_filters: dict = {}
def is_duplicate_well_known(self) -> bool:
return self.from_path in ["/.well-known/lnurlp"]
def in_conflict(self, other: RedirectPath) -> bool:
if self.ext_id == other.ext_id:
return False
@ -603,6 +620,10 @@ class BlinkFundingSource(LNbitsSettings):
blink_api_endpoint: str | None = Field(default="https://api.blink.sv/graphql")
blink_ws_endpoint: str | None = Field(default="wss://ws.blink.sv/graphql")
blink_token: str | None = Field(default=None)
# If probing fails or is unsupported by the destination (e.g. fedimints),
# send the payment anyway. Blink reserves its max fee and reconciles any
# excess separately. If disabled, payments that cannot be probed will fail.
blink_send_without_probe: bool = Field(default=True)
class AmbossFundingSource(LNbitsSettings):
@ -889,6 +910,16 @@ class NodeUISettings(LNbitsSettings):
lnbits_node_ui_transactions: bool = Field(default=False)
class BlockExplorerSettings(LNbitsSettings):
lnbits_blockexplorer_enabled: bool = Field(default=False)
lnbits_blockexplorer_public_api: bool = Field(default=False)
lnbits_blockexplorer_electrum_url: str = Field(
default="ssl://electrum.blockstream.info:50002"
)
# one of: main, test, regtest, signet (see embit.networks.NETWORKS)
lnbits_blockexplorer_network: str = Field(default="main")
class AuthMethods(Enum):
user_id_only = "user-id-only"
username_and_password = "username-password" # noqa: S105
@ -1063,6 +1094,7 @@ class EditableSettings(
LightningSettings,
WebPushSettings,
NodeUISettings,
BlockExplorerSettings,
AuditSettings,
AuthSettings,
NostrAuthSettings,
@ -1074,6 +1106,7 @@ class EditableSettings(
@validator(
"lnbits_admin_users",
"lnbits_allowed_users",
"lnbits_wallet_lightning_address_blacklist",
"lnbits_theme_options",
"lnbits_admin_extensions",
"lnbits_extensions_manifests",
@ -1343,6 +1376,7 @@ class PublicSettings(BaseModel):
webpush_pubkey: str | None = Field(alias="webpushPubkey")
show_extensions: bool = Field(alias="showExtensions")
show_audit: bool = Field(alias="showAudit")
show_block_explorer: bool = Field(alias="showBlockExplorer")
show_admin: bool = Field(alias="showAdmin")
ad_space: list[list[str]] = Field(alias="adSpace")
ad_space_title: str = Field(alias="adSpaceTitle")
@ -1375,6 +1409,18 @@ class PublicSettings(BaseModel):
wallet_featured_button_label: str | None = Field(alias="walletFeaturedButtonLabel")
wallet_featured_button_url: str | None = Field(alias="walletFeaturedButtonUrl")
wallet_featured_button_icon: str | None = Field(alias="walletFeaturedButtonIcon")
enable_wallet_lightning_addresses: bool = Field(
alias="enableWalletLightningAddresses"
)
allow_custom_wallet_lightning_addresses: bool = Field(
alias="allowCustomWalletLightningAddresses"
)
charge_wallet_lightning_addresses: bool = Field(
alias="chargeWalletLightningAddresses"
)
wallet_lightning_address_price_sats: int = Field(
alias="walletLightningAddressPriceSats"
)
lnbits_user_activation_by_email: bool = Field(alias="userActivationByEmail")
lnbits_user_activation_by_payment: bool = Field(alias="userActivationByPayment")
lnbits_user_activation_by_invitation_code: bool = Field(
@ -1411,6 +1457,7 @@ class PublicSettings(BaseModel):
webpushPubkey=settings.lnbits_webpush_pubkey,
showExtensions=not settings.lnbits_extensions_deactivate_all,
showAudit=settings.lnbits_audit_enabled,
showBlockExplorer=settings.lnbits_blockexplorer_enabled,
showAdmin=settings.lnbits_admin_ui,
customImage=settings.lnbits_custom_image,
customBadge=settings.lnbits_custom_badge,
@ -1440,6 +1487,16 @@ class PublicSettings(BaseModel):
walletFeaturedButtonLabel=settings.lnbits_wallet_featured_button_label,
walletFeaturedButtonUrl=settings.lnbits_wallet_featured_button_url,
walletFeaturedButtonIcon=settings.lnbits_wallet_featured_button_icon,
enableWalletLightningAddresses=settings.ln_address_creation_allowed,
allowCustomWalletLightningAddresses=(
settings.lnbits_allow_custom_wallet_lightning_addresses
),
chargeWalletLightningAddresses=(
settings.lnbits_charge_wallet_lightning_addresses
),
walletLightningAddressPriceSats=(
settings.lnbits_wallet_lightning_address_price_sats
),
userActivationByEmail=settings.lnbits_user_activation_by_email,
userActivationByPayment=settings.lnbits_user_activation_by_payment,
userActivationByInvitationCode=settings.lnbits_user_activation_by_invitation_code,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -828,5 +828,48 @@ window.localisation.br = {
payment_labels_updated: 'Rótulos de pagamento atualizados',
color: 'Cor',
sort: 'Ordenar',
sort_by: 'Ordenar por'
sort_by: 'Ordenar por',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Ativar Block Explorer',
block_explorer_desc:
'Permite aos usuários explorar transações e endereços Bitcoin via Electrum.',
blockexplorer_public_api: 'Acesso à API pública',
blockexplorer_public_api_desc:
'Permitir acesso não autenticado aos endpoints da API do explorador de blocos.',
electrum_server_url: 'URL do servidor Electrum',
electrum_server_url_hint:
'ex. ssl://electrum.blockstream.info:50002 ou tcp://localhost:50001',
blockexplorer_search_label: 'Pesquisar por TXID ou endereço',
blockexplorer_search_hint:
'Hex de 64 caracteres = transação · qualquer outra coisa = endereço Bitcoin',
recent_blocks: 'Blocos recentes',
chain_tip: 'Ponta da cadeia',
block_height: 'Altura do bloco',
block_fee: 'taxa de bloco',
fee_estimates: 'Estimativas de taxa',
confirmed_balance: 'Saldo confirmado',
unconfirmed_balance: 'Saldo não confirmado',
transaction_history: 'Histórico de transações',
coinbase: 'Coinbase',
inputs: 'Entradas',
outputs: 'Saídas',
confirmations: 'Confirmações',
confirmed: 'Confirmado',
unconfirmed: 'Não confirmado',
history_unavailable:
'Histórico de transações indisponível (endereço tem transações demais)',
address: 'Endereço',
block_number: 'Bloco #{height}',
block_diff: 'diff {value}',
block_hash: 'Hash',
previous_block: 'Bloco anterior',
merkle_root: 'Raiz de Merkle',
version: 'Versão',
bits: 'Bits',
difficulty: 'Dificuldade',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Tamanho virtual',
weight: 'Peso',
n_block_fee: 'taxa {n} blocos'
}

View file

@ -422,5 +422,44 @@ window.localisation.cn = {
http_request_methods: 'HTTP请求方法',
http_response_codes: 'HTTP响应代码',
request_details: '请求详情',
http_request_details: 'HTTP请求详细信息'
http_request_details: 'HTTP请求详细信息',
block_explorer: '区块浏览器',
enable_block_explorer: '启用区块浏览器',
block_explorer_desc: '允许用户通过 Electrum 浏览比特币交易和地址。',
blockexplorer_public_api: '公开 API 访问',
blockexplorer_public_api_desc: '允许对区块浏览器 API 端点的未认证访问。',
electrum_server_url: 'Electrum 服务器 URL',
electrum_server_url_hint:
'例如 ssl://electrum.blockstream.info:50002 或 tcp://localhost:50001',
blockexplorer_search_label: '按 TXID 或地址搜索',
blockexplorer_search_hint: '64位十六进制 = 交易 · 其他 = 比特币地址',
recent_blocks: '最新区块',
chain_tip: '链尖',
block_height: '区块高度',
block_fee: '区块手续费',
fee_estimates: '手续费估算',
confirmed_balance: '已确认余额',
unconfirmed_balance: '未确认余额',
transaction_history: '交易历史',
coinbase: 'Coinbase',
inputs: '输入',
outputs: '输出',
confirmations: '确认数',
confirmed: '已确认',
unconfirmed: '未确认',
history_unavailable: '交易历史不可用(地址交易过多)',
address: '地址',
block_number: '区块 #{height}',
block_diff: '难度 {value}',
block_hash: '哈希',
previous_block: '上一区块',
merkle_root: 'Merkle 根',
version: '版本',
bits: 'Bits',
difficulty: '难度',
nonce: 'Nonce',
txid: 'TXID',
vsize: '虚拟大小',
weight: '权重',
n_block_fee: '{n} 区块手续费'
}

View file

@ -443,5 +443,48 @@ window.localisation.cs = {
http_request_methods: 'Metody HTTP požadavků',
http_response_codes: 'Kódy HTTP odpovědí',
request_details: 'Podrobnosti žádosti',
http_request_details: 'Podrobnosti HTTP žádosti'
http_request_details: 'Podrobnosti HTTP žádosti',
block_explorer: 'Průzkumník bloků',
enable_block_explorer: 'Povolit průzkumník bloků',
block_explorer_desc:
'Umožňuje uživatelům procházet bitcoinové transakce a adresy přes Electrum.',
blockexplorer_public_api: 'Veřejný přístup k API',
blockexplorer_public_api_desc:
'Povolit neověřený přístup k API koncovým bodům průzkumníku bloků.',
electrum_server_url: 'URL Electrum serveru',
electrum_server_url_hint:
'např. ssl://electrum.blockstream.info:50002 nebo tcp://localhost:50001',
blockexplorer_search_label: 'Hledat podle TXID nebo adresy',
blockexplorer_search_hint:
'64-znakový hex = transakce · cokoli jiného = bitcoinová adresa',
recent_blocks: 'Nedávné bloky',
chain_tip: 'Vrchol řetězu',
block_height: 'Výška bloku',
block_fee: 'poplatek bloku',
fee_estimates: 'Odhady poplatků',
confirmed_balance: 'Potvrzený zůstatek',
unconfirmed_balance: 'Nepotvrzený zůstatek',
transaction_history: 'Historie transakcí',
coinbase: 'Coinbase',
inputs: 'Vstupy',
outputs: 'Výstupy',
confirmations: 'Potvrzení',
confirmed: 'Potvrzeno',
unconfirmed: 'Nepotvrzeno',
history_unavailable:
'Historie transakcí nedostupná (adresa má příliš mnoho transakcí)',
address: 'Adresa',
block_number: 'Blok #{height}',
block_diff: 'obth. {value}',
block_hash: 'Hash',
previous_block: 'Předchozí blok',
merkle_root: 'Merkle kořen',
version: 'Verze',
bits: 'Bity',
difficulty: 'Obtížnost',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Virtuální velikost',
weight: 'Váha',
n_block_fee: 'poplatek {n} bloků'
}

View file

@ -456,5 +456,48 @@ window.localisation.de = {
http_request_methods: 'HTTP-Anfragemethoden',
http_response_codes: 'HTTP-Antwortcodes',
request_details: 'Anfragedetails',
http_request_details: 'HTTP-Anfragedetails'
http_request_details: 'HTTP-Anfragedetails',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Block Explorer aktivieren',
block_explorer_desc:
'Ermöglicht Nutzern das Durchsuchen von Bitcoin-Transaktionen und -Adressen über Electrum.',
blockexplorer_public_api: 'Öffentlicher API-Zugang',
blockexplorer_public_api_desc:
'Nicht-authentifizierten Zugriff auf die Block-Explorer-API-Endpunkte erlauben.',
electrum_server_url: 'Electrum-Server-URL',
electrum_server_url_hint:
'z.B. ssl://electrum.blockstream.info:50002 oder tcp://localhost:50001',
blockexplorer_search_label: 'Nach TXID oder Adresse suchen',
blockexplorer_search_hint:
'64-Zeichen-Hex = Transaktion · Alles andere = Bitcoin-Adresse',
recent_blocks: 'Aktuelle Blöcke',
chain_tip: 'Kettenspitze',
block_height: 'Blockhöhe',
block_fee: 'Blockgebühr',
fee_estimates: 'Gebührenschätzungen',
confirmed_balance: 'Bestätigtes Guthaben',
unconfirmed_balance: 'Unbestätigtes Guthaben',
transaction_history: 'Transaktionsverlauf',
coinbase: 'Coinbase',
inputs: 'Eingaben',
outputs: 'Ausgaben',
confirmations: 'Bestätigungen',
confirmed: 'Bestätigt',
unconfirmed: 'Unbestätigt',
history_unavailable:
'Transaktionsverlauf nicht verfügbar (Adresse hat zu viele Transaktionen)',
address: 'Adresse',
block_number: 'Block #{height}',
block_diff: 'Schw. {value}',
block_hash: 'Hash',
previous_block: 'Vorheriger Block',
merkle_root: 'Merkle-Wurzel',
version: 'Version',
bits: 'Bits',
difficulty: 'Schwierigkeit',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Virtuelle Größe',
weight: 'Gewicht',
n_block_fee: '{n}-Block-Gebühr'
}

View file

@ -44,7 +44,8 @@ window.localisation.en = {
reset_defaults_tooltip: 'Delete all settings and reset to defaults.',
download_backup: 'Download database backup',
name_your_wallet: 'Name your {name} wallet',
paste_invoice_label: 'Paste an invoice, payment request or lnurl code *',
paste_invoice_label:
'Paste an invoice, payment request, Lightning Address or LNURL*',
lnbits_description:
'Easy to set up and lightweight, LNbits can run on any Lightning Network funding source and even LNbits itself! You can run LNbits for yourself, or easily offer a custodian solution for others. Each wallet has its own API keys and there is no limit to the number of wallets you can make. Being able to partition funds makes LNbits a useful tool for money management and as a development tool. Extensions add extra functionality to LNbits so you can experiment with a range of cutting-edge technologies on the lightning network. We have made developing extensions as easy as possible, and as a free and open-source project, we encourage people to develop and submit their own.',
export_to_phone: 'Export to Phone with QR Code',
@ -64,6 +65,7 @@ window.localisation.en = {
wallets: 'Wallets',
exclude_wallets: 'Exclude Wallets',
add_wallet: 'Add wallet',
add_field: 'Add field',
reject_wallet: 'Reject wallet',
add_new_wallet: 'Add a new wallet',
pin_wallet: 'Pin wallet',
@ -913,5 +915,80 @@ window.localisation.en = {
payment_labels_updated: 'Payment labels updated',
color: 'Color',
sort: 'Sort',
sort_by: 'Sort by'
sort_by: 'Sort by',
lightning_address: 'Lightning Address',
lightning_addresses: 'Lightning Addresses',
lightning_address_price: 'Lightning Address price',
enable_lightning_address: 'Enable Lightning Addresses',
ln_address_mode: 'Lightning Address Resolution Mode',
ln_address_core_first: 'Resolve from LNbits Core first',
ln_address_extension_first: 'Resolve from Pay Links extension first',
ln_address_extension_only: 'Resolve from Pay Links extension only',
ln_address_mode_hint:
'Choose how LNbits should resolve Lightning Addresses. Using both LNbits Core and the Pay Links extension will have a small impact on performance.',
enable_lightning_address_for_all_wallets:
'Enable Lightning Addresses for all LNbits wallets',
allow_users_specify_lightning_addresses:
'Allow users to specify Lightning Addresses',
allow_wallet_owners_set_custom_lightning_addresses:
'Allow wallet owners to set custom Lightning Addresses',
charge_for_lightning_addresses: 'Charge for Lightning Addresses',
charge_users_set_change_lightning_address:
'Charge users when they set or change a Lightning Address.',
service_fee_wallet_id_must_be_set:
'Service Fee Wallet ID must be set in the Service Fees section below for this to work.',
lightning_address_blacklist: 'Lightning Address blacklist',
lightning_address_blacklist_instructions:
'Newline separated reserved words. Users cannot choose a Lightning Address that matches any of these words.',
set_lightning_address: 'Set Lightning Address',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Enable Block Explorer',
block_explorer_desc:
'Allow users to explore Bitcoin transactions and addresses via Electrum.',
blockexplorer_public_api: 'Public API Access',
blockexplorer_public_api_desc:
'Allow unauthenticated access to the block explorer API endpoints.',
electrum_compatible_server: 'Electrum compatible server',
electrum_server_url: 'Electrum Server URL',
electrum_server_url_hint:
'Choose a public Electrum server or enter your own.',
electrum_server_url_custom: 'Custom Electrum Server URL',
view_public_electrum_servers: 'View public Electrum servers',
blockexplorer_network: 'Bitcoin Network',
blockexplorer_network_hint:
'The network the Electrum server is connected to, used to render addresses correctly.',
blockexplorer_search_label: 'Search by TXID or Address',
blockexplorer_search_hint:
'64-char hex = transaction · anything else = Bitcoin address',
recent_blocks: 'Recent Blocks',
chain_tip: 'Chain Tip',
block_height: 'Block Height',
block_fee: 'block fee',
fee_estimates: 'Fee Estimates',
confirmed_balance: 'Confirmed Balance',
unconfirmed_balance: 'Unconfirmed Balance',
transaction_history: 'Transaction History',
coinbase: 'Coinbase',
inputs: 'Inputs',
outputs: 'Outputs',
confirmations: 'Confirmations',
confirmed: 'Confirmed',
unconfirmed: 'Unconfirmed',
no_transactions: 'No transactions found',
history_unavailable:
'Transaction history unavailable (address has too many transactions)',
address: 'Address',
block_number: 'Block #{height}',
block_diff: 'diff {value}',
block_hash: 'Hash',
previous_block: 'Previous Block',
merkle_root: 'Merkle Root',
version: 'Version',
bits: 'Bits',
difficulty: 'Difficulty',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Virtual Size',
weight: 'Weight',
n_block_fee: '{n}-block fee'
}

View file

@ -457,5 +457,48 @@ window.localisation.es = {
http_request_methods: 'Métodos de solicitud HTTP',
http_response_codes: 'Códigos de Respuesta HTTP',
request_details: 'Detalles de la solicitud',
http_request_details: 'Detalles de la Solicitud HTTP'
http_request_details: 'Detalles de la Solicitud HTTP',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Activar Block Explorer',
block_explorer_desc:
'Permite a los usuarios explorar transacciones y direcciones de Bitcoin a través de Electrum.',
blockexplorer_public_api: 'Acceso a la API pública',
blockexplorer_public_api_desc:
'Permitir acceso no autenticado a los endpoints de la API del explorador de bloques.',
electrum_server_url: 'URL del servidor Electrum',
electrum_server_url_hint:
'p.ej. ssl://electrum.blockstream.info:50002 o tcp://localhost:50001',
blockexplorer_search_label: 'Buscar por TXID o dirección',
blockexplorer_search_hint:
'Hex de 64 caracteres = transacción · cualquier otra cosa = dirección Bitcoin',
recent_blocks: 'Bloques recientes',
chain_tip: 'Punta de cadena',
block_height: 'Altura de bloque',
block_fee: 'tarifa de bloque',
fee_estimates: 'Estimaciones de tarifa',
confirmed_balance: 'Saldo confirmado',
unconfirmed_balance: 'Saldo no confirmado',
transaction_history: 'Historial de transacciones',
coinbase: 'Coinbase',
inputs: 'Entradas',
outputs: 'Salidas',
confirmations: 'Confirmaciones',
confirmed: 'Confirmado',
unconfirmed: 'No confirmado',
history_unavailable:
'Historial de transacciones no disponible (la dirección tiene demasiadas transacciones)',
address: 'Dirección',
block_number: 'Bloque #{height}',
block_diff: 'dif {value}',
block_hash: 'Hash',
previous_block: 'Bloque anterior',
merkle_root: 'Raíz de Merkle',
version: 'Versión',
bits: 'Bits',
difficulty: 'Dificultad',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Tamaño virtual',
weight: 'Peso',
n_block_fee: 'tarifa {n} bloques'
}

View file

@ -651,5 +651,48 @@ window.localisation.fi = {
'On the PayPal side configure a webhook pointing to your LNbits server.',
callback_success_url: 'Callback Success URL',
callback_success_url_hint:
'The user will be redirected to this URL after the payment is successful'
'The user will be redirected to this URL after the payment is successful',
block_explorer: 'Lohkoselain',
enable_block_explorer: 'Ota lohkoselain käyttöön',
block_explorer_desc:
'Salli käyttäjien tutkia Bitcoin-transaktioita ja -osoitteita Electrumin kautta.',
blockexplorer_public_api: 'Julkinen API-pääsy',
blockexplorer_public_api_desc:
'Salli todentamaton pääsy lohkoselain API-päätteisiin.',
electrum_server_url: 'Electrum-palvelimen URL',
electrum_server_url_hint:
'esim. ssl://electrum.blockstream.info:50002 tai tcp://localhost:50001',
blockexplorer_search_label: 'Hae TXID:llä tai osoitteella',
blockexplorer_search_hint:
'64 merkin heksa = transaktio · muu = Bitcoin-osoite',
recent_blocks: 'Viimeisimmät lohkot',
chain_tip: 'Ketjun kärki',
block_height: 'Lohkokorkeus',
block_fee: 'lohkomaksu',
fee_estimates: 'Maksuarviot',
confirmed_balance: 'Vahvistettu saldo',
unconfirmed_balance: 'Vahvistamaton saldo',
transaction_history: 'Tapahtumahistoria',
coinbase: 'Coinbase',
inputs: 'Syötteet',
outputs: 'Tulosteet',
confirmations: 'Vahvistukset',
confirmed: 'Vahvistettu',
unconfirmed: 'Vahvistamaton',
history_unavailable:
'Tapahtumahistoria ei saatavilla (osoitteella on liikaa tapahtumia)',
address: 'Osoite',
block_number: 'Lohko #{height}',
block_diff: 'vaikeus {value}',
block_hash: 'Hash',
previous_block: 'Edellinen lohko',
merkle_root: 'Merkle-juuri',
version: 'Versio',
bits: 'Bitit',
difficulty: 'Vaikeus',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Virtuaalikoko',
weight: 'Paino',
n_block_fee: '{n} lohkon maksu'
}

View file

@ -460,5 +460,48 @@ window.localisation.fr = {
http_request_methods: 'Méthodes de requête HTTP',
http_response_codes: 'Codes de réponse HTTP',
request_details: 'Détails de la demande',
http_request_details: 'Détails de la requête HTTP'
http_request_details: 'Détails de la requête HTTP',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Activer le Block Explorer',
block_explorer_desc:
"Permet aux utilisateurs d'explorer les transactions et adresses Bitcoin via Electrum.",
blockexplorer_public_api: 'Accès API public',
blockexplorer_public_api_desc:
"Autoriser l'accès non authentifié aux endpoints de l'API de l'explorateur de blocs.",
electrum_server_url: 'URL du serveur Electrum',
electrum_server_url_hint:
'p.ex. ssl://electrum.blockstream.info:50002 ou tcp://localhost:50001',
blockexplorer_search_label: 'Rechercher par TXID ou adresse',
blockexplorer_search_hint:
'Hex 64 caractères = transaction · autre chose = adresse Bitcoin',
recent_blocks: 'Blocs récents',
chain_tip: 'Sommet de chaîne',
block_height: 'Hauteur de bloc',
block_fee: 'frais de bloc',
fee_estimates: 'Estimations de frais',
confirmed_balance: 'Solde confirmé',
unconfirmed_balance: 'Solde non confirmé',
transaction_history: 'Historique des transactions',
coinbase: 'Coinbase',
inputs: 'Entrées',
outputs: 'Sorties',
confirmations: 'Confirmations',
confirmed: 'Confirmé',
unconfirmed: 'Non confirmé',
history_unavailable:
'Historique des transactions indisponible (adresse avec trop de transactions)',
address: 'Adresse',
block_number: 'Bloc #{height}',
block_diff: 'diff {value}',
block_hash: 'Hash',
previous_block: 'Bloc précédent',
merkle_root: 'Racine de Merkle',
version: 'Version',
bits: 'Bits',
difficulty: 'Difficulté',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Taille virtuelle',
weight: 'Poids',
n_block_fee: 'frais {n} blocs'
}

View file

@ -454,5 +454,48 @@ window.localisation.it = {
http_request_methods: 'Metodi di richiesta HTTP',
http_response_codes: 'Codici di risposta HTTP',
request_details: 'Dettagli della richiesta',
http_request_details: 'Dettagli della richiesta HTTP'
http_request_details: 'Dettagli della richiesta HTTP',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Abilita Block Explorer',
block_explorer_desc:
'Consenti agli utenti di esplorare transazioni e indirizzi Bitcoin tramite Electrum.',
blockexplorer_public_api: 'Accesso API pubblico',
blockexplorer_public_api_desc:
"Consenti accesso non autenticato agli endpoint API dell'esploratore di blocchi.",
electrum_server_url: 'URL server Electrum',
electrum_server_url_hint:
'es. ssl://electrum.blockstream.info:50002 o tcp://localhost:50001',
blockexplorer_search_label: 'Cerca per TXID o indirizzo',
blockexplorer_search_hint:
'Hex 64 caratteri = transazione · altro = indirizzo Bitcoin',
recent_blocks: 'Blocchi recenti',
chain_tip: 'Punta della catena',
block_height: 'Altezza blocco',
block_fee: 'commissione blocco',
fee_estimates: 'Stime delle commissioni',
confirmed_balance: 'Saldo confermato',
unconfirmed_balance: 'Saldo non confermato',
transaction_history: 'Storico transazioni',
coinbase: 'Coinbase',
inputs: 'Input',
outputs: 'Output',
confirmations: 'Conferme',
confirmed: 'Confermato',
unconfirmed: 'Non confermato',
history_unavailable:
"Storico transazioni non disponibile (l'indirizzo ha troppe transazioni)",
address: 'Indirizzo',
block_number: 'Blocco #{height}',
block_diff: 'diff {value}',
block_hash: 'Hash',
previous_block: 'Blocco precedente',
merkle_root: 'Radice di Merkle',
version: 'Versione',
bits: 'Bit',
difficulty: 'Difficoltà',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Dimensione virtuale',
weight: 'Peso',
n_block_fee: 'commissione {n} blocchi'
}

View file

@ -444,5 +444,48 @@ window.localisation.jp = {
http_request_methods: 'HTTPリクエストメソッド',
http_response_codes: 'HTTPレスポンスコード',
request_details: 'リクエストの詳細',
http_request_details: 'HTTPリクエストの詳細'
http_request_details: 'HTTPリクエストの詳細',
block_explorer: 'ブロックエクスプローラー',
enable_block_explorer: 'ブロックエクスプローラーを有効化',
block_explorer_desc:
'Electrumを介してビットコインのトランザクションとアドレスを探索できます。',
blockexplorer_public_api: 'パブリックAPIアクセス',
blockexplorer_public_api_desc:
'ブロックエクスプローラーAPIエンドポイントへの非認証アクセスを許可します。',
electrum_server_url: 'ElectrumサーバーURL',
electrum_server_url_hint:
'例: ssl://electrum.blockstream.info:50002 または tcp://localhost:50001',
blockexplorer_search_label: 'TXIDまたはアドレスで検索',
blockexplorer_search_hint:
'64文字の16進数 = トランザクション · それ以外 = ビットコインアドレス',
recent_blocks: '最新ブロック',
chain_tip: 'チェーン先端',
block_height: 'ブロック高さ',
block_fee: 'ブロック手数料',
fee_estimates: '手数料見積もり',
confirmed_balance: '確認済み残高',
unconfirmed_balance: '未確認残高',
transaction_history: 'トランザクション履歴',
coinbase: 'コインベース',
inputs: 'インプット',
outputs: 'アウトプット',
confirmations: '確認数',
confirmed: '確認済み',
unconfirmed: '未確認',
history_unavailable:
'トランザクション履歴が取得できません(アドレスのトランザクションが多すぎます)',
address: 'アドレス',
block_number: 'ブロック #{height}',
block_diff: 'diff {value}',
block_hash: 'ハッシュ',
previous_block: '前のブロック',
merkle_root: 'マークルルート',
version: 'バージョン',
bits: 'Bits',
difficulty: '難易度',
nonce: 'Nonce',
txid: 'TXID',
vsize: '仮想サイズ',
weight: '重量',
n_block_fee: '{n}ブロック手数料'
}

View file

@ -439,5 +439,47 @@ window.localisation.kr = {
http_request_methods: 'HTTP 요청 메서드',
http_response_codes: 'HTTP 응답 코드',
request_details: '요청 세부사항',
http_request_details: 'HTTP 요청 세부사항'
http_request_details: 'HTTP 요청 세부사항',
block_explorer: '블록 탐색기',
enable_block_explorer: '블록 탐색기 활성화',
block_explorer_desc:
'Electrum을 통해 비트코인 거래 및 주소를 탐색할 수 있습니다.',
blockexplorer_public_api: '공개 API 접근',
blockexplorer_public_api_desc:
'블록 탐색기 API 엔드포인트에 대한 비인증 접근을 허용합니다.',
electrum_server_url: 'Electrum 서버 URL',
electrum_server_url_hint:
'예: ssl://electrum.blockstream.info:50002 또는 tcp://localhost:50001',
blockexplorer_search_label: 'TXID 또는 주소로 검색',
blockexplorer_search_hint: '64자 16진수 = 거래 · 그 외 = 비트코인 주소',
recent_blocks: '최근 블록',
chain_tip: '체인 끝',
block_height: '블록 높이',
block_fee: '블록 수수료',
fee_estimates: '수수료 추정',
confirmed_balance: '확인된 잔액',
unconfirmed_balance: '미확인 잔액',
transaction_history: '거래 내역',
coinbase: 'Coinbase',
inputs: '입력',
outputs: '출력',
confirmations: '확인 수',
confirmed: '확인됨',
unconfirmed: '미확인',
history_unavailable:
'거래 내역을 불러올 수 없습니다 (주소의 거래가 너무 많음)',
address: '주소',
block_number: '블록 #{height}',
block_diff: 'diff {value}',
block_hash: '해시',
previous_block: '이전 블록',
merkle_root: '머클 루트',
version: '버전',
bits: 'Bits',
difficulty: '난이도',
nonce: 'Nonce',
txid: 'TXID',
vsize: '가상 크기',
weight: '무게',
n_block_fee: '{n}블록 수수료'
}

View file

@ -454,5 +454,48 @@ window.localisation.nl = {
http_request_methods: 'HTTP-aanvraagmethoden',
http_response_codes: 'HTTP-responscodes',
request_details: 'Aanvraagdetails',
http_request_details: 'HTTP-verzoekdetails'
http_request_details: 'HTTP-verzoekdetails',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Block Explorer inschakelen',
block_explorer_desc:
'Laat gebruikers Bitcoin-transacties en -adressen verkennen via Electrum.',
blockexplorer_public_api: 'Publieke API-toegang',
blockexplorer_public_api_desc:
'Niet-geauthenticeerde toegang tot de block explorer API-eindpunten toestaan.',
electrum_server_url: 'Electrum-server-URL',
electrum_server_url_hint:
'bijv. ssl://electrum.blockstream.info:50002 of tcp://localhost:50001',
blockexplorer_search_label: 'Zoeken op TXID of adres',
blockexplorer_search_hint:
'64-karakter hex = transactie · alles anders = Bitcoin-adres',
recent_blocks: 'Recente blokken',
chain_tip: 'Kettingtop',
block_height: 'Blokhoogte',
block_fee: 'blokvergoeding',
fee_estimates: 'Vergoedingsschattingen',
confirmed_balance: 'Bevestigd saldo',
unconfirmed_balance: 'Onbevestigd saldo',
transaction_history: 'Transactiegeschiedenis',
coinbase: 'Coinbase',
inputs: 'Invoer',
outputs: 'Uitvoer',
confirmations: 'Bevestigingen',
confirmed: 'Bevestigd',
unconfirmed: 'Onbevestigd',
history_unavailable:
'Transactiegeschiedenis niet beschikbaar (adres heeft te veel transacties)',
address: 'Adres',
block_number: 'Blok #{height}',
block_diff: 'moeil. {value}',
block_hash: 'Hash',
previous_block: 'Vorig blok',
merkle_root: 'Merkle-wortel',
version: 'Versie',
bits: 'Bits',
difficulty: 'Moeilijkheid',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Virtuele grootte',
weight: 'Gewicht',
n_block_fee: '{n}-blok vergoeding'
}

View file

@ -445,5 +445,48 @@ window.localisation.pi = {
http_request_methods: 'HTTP Request Methods',
http_response_codes: 'HTTP Response Codes',
request_details: 'Request Details',
http_request_details: 'HTTP Request Details'
http_request_details: 'HTTP Request Details',
block_explorer: 'Treasure Map',
enable_block_explorer: 'Hoist the Treasure Map',
block_explorer_desc:
"Let scallywags spy on Bitcoin doubloons an' addresses via Electrum.",
blockexplorer_public_api: 'Open Seas API',
blockexplorer_public_api_desc:
'Allow any landlubber access to the block explorer API ports.',
electrum_server_url: 'Electrum Port URL',
electrum_server_url_hint:
'e.g. ssl://electrum.blockstream.info:50002 or tcp://localhost:50001',
blockexplorer_search_label: 'Search by TXID or Port',
blockexplorer_search_hint:
'64-char hex = plunder · anything else = Bitcoin port',
recent_blocks: 'Recent Plunder',
chain_tip: "Tip o' the Anchor Chain",
block_height: 'Plunder Height',
block_fee: 'plunder fee',
fee_estimates: 'Booty Estimates',
confirmed_balance: 'Confirmed Booty',
unconfirmed_balance: 'Unconfirmed Booty',
transaction_history: 'Plunder History',
coinbase: 'Coinbase',
inputs: 'Inbound Plunder',
outputs: 'Outbound Plunder',
confirmations: 'Confirmations, arr',
confirmed: 'Confirmed, arr',
unconfirmed: 'Unconfirmed, arr',
history_unavailable:
'Plunder history lost at sea (too many transactions, matey!)',
address: 'Port',
block_number: 'Block #{height}',
block_diff: 'diff {value}',
block_hash: 'Hash',
previous_block: 'Previous Plunder Block',
merkle_root: 'Merkle Root',
version: 'Version',
bits: 'Bits',
difficulty: 'Difficulty',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Virtual Size',
weight: 'Weight',
n_block_fee: '{n}-block booty'
}

View file

@ -448,5 +448,48 @@ window.localisation.pl = {
http_request_methods: 'Metody żądań HTTP',
http_response_codes: 'Kody Odpowiedzi HTTP',
request_details: 'Szczegóły żądania',
http_request_details: 'Szczegóły żądania HTTP'
http_request_details: 'Szczegóły żądania HTTP',
block_explorer: 'Przeglądarka bloków',
enable_block_explorer: 'Włącz przeglądarkę bloków',
block_explorer_desc:
'Umożliwia użytkownikom przeglądanie transakcji i adresów Bitcoin przez Electrum.',
blockexplorer_public_api: 'Publiczny dostęp do API',
blockexplorer_public_api_desc:
'Zezwól na nieuwierzytelniony dostęp do punktów końcowych API przeglądarki bloków.',
electrum_server_url: 'URL serwera Electrum',
electrum_server_url_hint:
'np. ssl://electrum.blockstream.info:50002 lub tcp://localhost:50001',
blockexplorer_search_label: 'Szukaj po TXID lub adresie',
blockexplorer_search_hint:
'64-znakowy hex = transakcja · cokolwiek innego = adres Bitcoin',
recent_blocks: 'Ostatnie bloki',
chain_tip: 'Wierzchołek łańcucha',
block_height: 'Wysokość bloku',
block_fee: 'opłata bloku',
fee_estimates: 'Szacunki opłat',
confirmed_balance: 'Potwierdzony saldo',
unconfirmed_balance: 'Niepotwierdzony saldo',
transaction_history: 'Historia transakcji',
coinbase: 'Coinbase',
inputs: 'Wejścia',
outputs: 'Wyjścia',
confirmations: 'Potwierdzenia',
confirmed: 'Potwierdzone',
unconfirmed: 'Niepotwierdzone',
history_unavailable:
'Historia transakcji niedostępna (adres ma zbyt wiele transakcji)',
address: 'Adres',
block_number: 'Blok #{height}',
block_diff: 'trud. {value}',
block_hash: 'Hash',
previous_block: 'Poprzedni blok',
merkle_root: 'Korzeń Merkle',
version: 'Wersja',
bits: 'Bity',
difficulty: 'Trudność',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Rozmiar wirtualny',
weight: 'Waga',
n_block_fee: 'opłata {n} bloków'
}

View file

@ -450,5 +450,48 @@ window.localisation.pt = {
http_request_methods: 'Métodos de Requisição HTTP',
http_response_codes: 'Códigos de Resposta HTTP',
request_details: 'Detalhes da solicitação',
http_request_details: 'Detalhes da Solicitação HTTP'
http_request_details: 'Detalhes da Solicitação HTTP',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Ativar Block Explorer',
block_explorer_desc:
'Permite aos utilizadores explorar transações e endereços Bitcoin via Electrum.',
blockexplorer_public_api: 'Acesso à API pública',
blockexplorer_public_api_desc:
'Permitir acesso não autenticado aos endpoints da API do explorador de blocos.',
electrum_server_url: 'URL do servidor Electrum',
electrum_server_url_hint:
'ex. ssl://electrum.blockstream.info:50002 ou tcp://localhost:50001',
blockexplorer_search_label: 'Pesquisar por TXID ou endereço',
blockexplorer_search_hint:
'Hex de 64 caracteres = transação · qualquer outra coisa = endereço Bitcoin',
recent_blocks: 'Blocos recentes',
chain_tip: 'Ponta da cadeia',
block_height: 'Altura do bloco',
block_fee: 'taxa de bloco',
fee_estimates: 'Estimativas de taxa',
confirmed_balance: 'Saldo confirmado',
unconfirmed_balance: 'Saldo não confirmado',
transaction_history: 'Histórico de transações',
coinbase: 'Coinbase',
inputs: 'Entradas',
outputs: 'Saídas',
confirmations: 'Confirmações',
confirmed: 'Confirmado',
unconfirmed: 'Não confirmado',
history_unavailable:
'Histórico de transações indisponível (endereço tem demasiadas transações)',
address: 'Endereço',
block_number: 'Bloco #{height}',
block_diff: 'diff {value}',
block_hash: 'Hash',
previous_block: 'Bloco anterior',
merkle_root: 'Raiz de Merkle',
version: 'Versão',
bits: 'Bits',
difficulty: 'Dificuldade',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Tamanho virtual',
weight: 'Peso',
n_block_fee: 'taxa {n} blocos'
}

View file

@ -448,5 +448,48 @@ window.localisation.sk = {
http_request_methods: 'Metódy HTTP žiadostí',
http_response_codes: 'Kódy odpovedí HTTP',
request_details: 'Podrobnosti žiadosti',
http_request_details: 'Podrobnosti požiadavky HTTP'
http_request_details: 'Podrobnosti požiadavky HTTP',
block_explorer: 'Prehliadač blokov',
enable_block_explorer: 'Povoliť prehliadač blokov',
block_explorer_desc:
'Umožňuje používateľom prehliadať bitcoinové transakcie a adresy cez Electrum.',
blockexplorer_public_api: 'Verejný prístup k API',
blockexplorer_public_api_desc:
'Povoliť neoverený prístup k API koncovým bodom prieskumníka blokov.',
electrum_server_url: 'URL Electrum servera',
electrum_server_url_hint:
'napr. ssl://electrum.blockstream.info:50002 alebo tcp://localhost:50001',
blockexplorer_search_label: 'Hľadať podľa TXID alebo adresy',
blockexplorer_search_hint:
'64-znakový hex = transakcia · čokoľvek iné = bitcoinová adresa',
recent_blocks: 'Nedávne bloky',
chain_tip: 'Vrchol reťaze',
block_height: 'Výška bloku',
block_fee: 'poplatok bloku',
fee_estimates: 'Odhady poplatkov',
confirmed_balance: 'Potvrdený zostatok',
unconfirmed_balance: 'Nepotvrdený zostatok',
transaction_history: 'História transakcií',
coinbase: 'Coinbase',
inputs: 'Vstupy',
outputs: 'Výstupy',
confirmations: 'Potvrdenia',
confirmed: 'Potvrdené',
unconfirmed: 'Nepotvrdené',
history_unavailable:
'História transakcií nedostupná (adresa má príliš veľa transakcií)',
address: 'Adresa',
block_number: 'Blok #{height}',
block_diff: 'obth. {value}',
block_hash: 'Hash',
previous_block: 'Predchádzajúci blok',
merkle_root: 'Merkle koreň',
version: 'Verzia',
bits: 'Bity',
difficulty: 'Obťažnosť',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Virtuálna veľkosť',
weight: 'Váha',
n_block_fee: 'poplatok {n} blokov'
}

View file

@ -446,5 +446,48 @@ window.localisation.we = {
http_request_methods: 'Dulliau Cais HTTP',
http_response_codes: 'Codau Ymateb HTTP',
request_details: 'Manylion y Cais',
http_request_details: 'Manylion Cais HTTP'
http_request_details: 'Manylion Cais HTTP',
block_explorer: 'Archwiliwr Bloc',
enable_block_explorer: "Galluogi'r Archwiliwr Bloc",
block_explorer_desc:
'Caniatáu i ddefnyddwyr archwilio trafodion a chyfeiriadau Bitcoin drwy Electrum.',
blockexplorer_public_api: 'Mynediad API Cyhoeddus',
blockexplorer_public_api_desc:
'Caniatáu mynediad heb ddilysu i bwyntiau terfyn API yr archwiliwr bloc.',
electrum_server_url: 'URL Gweinydd Electrum',
electrum_server_url_hint:
'e.e. ssl://electrum.blockstream.info:50002 neu tcp://localhost:50001',
blockexplorer_search_label: 'Chwilio yn ôl TXID neu Gyfeiriad',
blockexplorer_search_hint:
'Hex 64 nod = trafodiad · unrhyw beth arall = cyfeiriad Bitcoin',
recent_blocks: 'Blociau Diweddar',
chain_tip: 'Blaen y Gadwyn',
block_height: 'Uchder Bloc',
block_fee: 'ffi bloc',
fee_estimates: 'Amcangyfrifon Ffi',
confirmed_balance: 'Balans Cadarnhawyd',
unconfirmed_balance: 'Balans Heb ei Gadarnhau',
transaction_history: 'Hanes Trafodion',
coinbase: 'Coinbase',
inputs: 'Mewnbynnau',
outputs: 'Allbynnau',
confirmations: 'Cadarnhadau',
confirmed: 'Cadarnhawyd',
unconfirmed: 'Heb ei Gadarnhau',
history_unavailable:
'Hanes trafodion ar goll (mae cyfeiriad â gormod o drafodion)',
address: 'Cyfeiriad',
block_number: 'Bloc #{height}',
block_diff: 'anhawster {value}',
block_hash: 'Hash',
previous_block: 'Bloc Blaenorol',
merkle_root: 'Gwreiddyn Merkle',
version: 'Fersiwn',
bits: 'Bits',
difficulty: 'Anhawster',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Maint Rhithwir',
weight: 'Pwysau',
n_block_fee: 'ffi {n} bloc'
}

View file

@ -196,5 +196,14 @@ window._lnbitsApi = {
return LNbits.api
.request('GET', `/admin/api/v1/settings/default?field_name=${fieldName}`)
.catch(LNbits.utils.notifyApiError)
},
getBlockexplorerAddress(address) {
return this.request('get', `/blockexplorer/api/v1/address/${address}`)
},
getBlockexplorerTransaction(txid) {
return this.request('get', `/blockexplorer/api/v1/tx/${txid}`)
},
getBlockexplorerUtxos(address) {
return this.request('get', `/blockexplorer/api/v1/utxos/${address}`)
}
}

View file

@ -47,6 +47,7 @@ window.LNbits = {
adminkey: data.adminkey,
inkey: data.inkey,
currency: data.currency,
lightningAddress: data.lightning_address,
extra: data.extra,
canReceivePayments: true,
canSendPayments: true
@ -59,6 +60,9 @@ window.LNbits = {
newWallet.canSendPayments = perms.includes('send-payments')
}
newWallet.url = `/wallet?&wal=${data.id}`
newWallet.lightningAddressFull = newWallet.lightningAddress
? `${newWallet.lightningAddress}@${window.location.host}`
: null
newWallet.storedPaylinks = data.stored_paylinks.links
return newWallet
}

View file

@ -0,0 +1,40 @@
window.app.component('lnbits-admin-blockexplorer', {
props: ['form-data'],
template: '#lnbits-admin-blockexplorer',
data() {
return {
electrumServers: [
'ssl://fulcrum.lnbits.com:50002',
'ssl://mainnet.nunchuk.io:52002',
'ssl://fulcrum.grey.pw:50002',
'ssl://electrum2.bluewallet.io:443',
'ssl://electrum.acinq.co:50002',
'ssl://electrum.blockstream.info:50002',
'ssl://bitcoin.mullvad.net:5010'
]
}
},
computed: {
electrumServerOptions() {
return [...this.electrumServers, 'Custom']
},
electrumServerPreset: {
get() {
return this.electrumServers.includes(
this.formData.lnbits_blockexplorer_electrum_url
)
? this.formData.lnbits_blockexplorer_electrum_url
: 'Custom'
},
set(value) {
if (value === 'Custom') {
if (this.electrumServerPreset !== 'Custom') {
this.formData.lnbits_blockexplorer_electrum_url = ''
}
return
}
this.formData.lnbits_blockexplorer_electrum_url = value
}
}
}
})

View file

@ -154,7 +154,12 @@ window.app.component('lnbits-admin-funding-sources', {
{
blink_api_endpoint: 'Endpoint',
blink_ws_endpoint: 'WebSocket',
blink_token: 'Key'
blink_token: 'Key',
blink_send_without_probe: {
advanced: true,
label: 'Send payment if fee probe fails',
hint: 'If enabled (default), payments to destinations that cannot be probed (e.g. fedimints) are still sent. If disabled, such payments fail.'
}
}
],
[

View file

@ -1,4 +1,18 @@
window.app.component('lnbits-admin-server', {
props: ['form-data'],
template: '#lnbits-admin-server'
template: '#lnbits-admin-server',
computed: {
lightningAddressBlacklistText: {
get() {
const value = this.formData.lnbits_wallet_lightning_address_blacklist
return Array.isArray(value) ? value.join('\n') : value || ''
},
set(value) {
this.formData.lnbits_wallet_lightning_address_blacklist = value
.split(/[\n,]/)
.map(word => word.trim().toLowerCase())
.filter(word => word.length)
}
}
}
})

View file

@ -31,7 +31,8 @@ window.app.component('lnbits-admin-site-customisation', {
'confettiBothSides',
'confettiFireworks',
'confettiStars',
'confettiTop'
'confettiTop',
'lightningStrike'
],
globalBorderOptions: [
'retro-border',

View file

@ -1,12 +1,46 @@
window.app.component('lnbits-wallet-extra', {
template: '#lnbits-wallet-extra',
props: ['chartConfig'],
data() {
return {
lightningAddressInput: ''
}
},
computed: {
exportUrl() {
return `${window.location.origin}/wallet?usr=${this.g.user.id}&wal=${this.g.wallet.id}`
},
canEditLightningAddress() {
return (
this.g.settings.enableWalletLightningAddresses &&
this.g.settings.allowCustomWalletLightningAddresses &&
this.g.wallet.walletType === 'lightning'
)
},
lightningAddressSuffix() {
return `@${window.location.host}`
},
lightningAddressChanged() {
return (
this.lightningAddressInput !== (this.g.wallet.lightningAddress || '')
)
},
lightningAddressFeeHint() {
if (!this.g.settings.chargeWalletLightningAddresses) return ''
return `Fee: ${this.g.settings.walletLightningAddressPriceSats} sats`
}
},
watch: {
'g.wallet.id': 'resetLightningAddressInput',
'g.wallet.lightningAddress': 'resetLightningAddressInput'
},
methods: {
resetLightningAddressInput() {
this.lightningAddressInput = this.g.wallet.lightningAddress || ''
},
saveLightningAddress() {
this.updateWallet({lightning_address: this.lightningAddressInput})
},
handleSendLnurl(lnurl) {
this.$emit('send-lnurl', lnurl)
},
@ -80,6 +114,7 @@ window.app.component('lnbits-wallet-extra', {
}
},
created() {
this.resetLightningAddressInput()
if (this.g.wallet.currency !== '' && this.g.isSatsDenomination) {
this.g.fiatTracking = true
this.updateFiatBalance()

View file

@ -154,6 +154,99 @@ function confettiStars() {
setTimeout(shoot, 100)
setTimeout(shoot, 200)
}
function lightningStrike() {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const dpr = window.devicePixelRatio || 1
canvas.style.position = 'fixed'
canvas.style.inset = '0'
canvas.style.pointerEvents = 'none'
canvas.style.zIndex = 999999
canvas.width = Math.floor(window.innerWidth * dpr)
canvas.height = Math.floor(window.innerHeight * dpr)
ctx.scale(dpr, dpr)
document.body.appendChild(canvas)
const startX = Math.random() * window.innerWidth
const endY = window.innerHeight * (0.45 + Math.random() * 0.35)
const segments = 18 + Math.floor(Math.random() * 10)
const points = [{x: startX, y: -20}]
for (let i = 1; i <= segments; i++) {
const progress = i / segments
const previous = points[i - 1]
points.push({
x: previous.x + (Math.random() - 0.5) * (34 + progress * 42),
y: progress * endY
})
}
const branches = []
for (
let i = 4;
i < points.length - 3;
i += 3 + Math.floor(Math.random() * 3)
) {
const base = points[i]
const branch = [{...base}]
const direction = Math.random() > 0.5 ? 1 : -1
const length = 3 + Math.floor(Math.random() * 4)
for (let j = 1; j <= length; j++) {
branch.push({
x: base.x + direction * j * (18 + Math.random() * 22),
y: base.y + j * (14 + Math.random() * 18)
})
}
branches.push(branch)
}
let frame = 0
const maxFrames = 48
function drawBolt(path, width, alpha) {
ctx.beginPath()
ctx.moveTo(path[0].x, path[0].y)
path.slice(1).forEach(point => ctx.lineTo(point.x, point.y))
ctx.strokeStyle = `rgba(170, 220, 255, ${alpha})`
ctx.lineWidth = width
ctx.lineJoin = 'round'
ctx.lineCap = 'round'
ctx.shadowBlur = 18
ctx.shadowColor = '#7dd3fc'
ctx.stroke()
ctx.strokeStyle = `rgba(255, 255, 255, ${Math.min(1, alpha + 0.2)})`
ctx.lineWidth = Math.max(1, width * 0.35)
ctx.shadowBlur = 4
ctx.stroke()
}
function animate() {
const alpha = 1 - frame / maxFrames
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight)
if (frame < 3) {
ctx.fillStyle = `rgba(255, 255, 255, ${0.22 - frame * 0.06})`
ctx.fillRect(0, 0, window.innerWidth, window.innerHeight)
}
drawBolt(points, 5 * alpha + 1, alpha)
branches.forEach(branch =>
drawBolt(branch, 2.5 * alpha + 0.5, alpha * 0.75)
)
frame += 1
if (frame <= maxFrames) {
requestAnimationFrame(animate)
} else {
canvas.remove()
}
}
animate()
}
!(function (t, e) {
;(!(function t(e, n, a, i) {
var o = !!(

View file

@ -66,6 +66,12 @@ const routes = [
name: 'NodePublic',
component: PageNodePublic
},
{
path: '/blockexplorer/:type(tx|address|block)?/:id?',
name: 'BlockExplorer',
component: PageBlockExplorer,
meta: {stableKey: true}
},
{
path: '/payments',
name: 'Payments',

View file

@ -51,7 +51,8 @@ window.PageAccount = {
'confettiBothSides',
'confettiFireworks',
'confettiStars',
'confettiTop'
'confettiTop',
'lightningStrike'
],
borderOptions: [
'retro-border',

View file

@ -0,0 +1,233 @@
window.PageBlockExplorer = {
template: '#page-blockexplorer',
data() {
return {
query: '',
loading: false,
tip: null,
fees: null,
blocks: [],
selectedBlock: null,
blockDialog: false,
txResult: null,
txStatus: null,
addressResult: null,
currentAddress: ''
}
},
computed: {
feeList() {
if (!this.fees || !this.fees.estimates) return []
return Object.entries(this.fees.estimates).map(([blocks, rate]) => ({
label: this.$t('n_block_fee', {n: blocks}),
rate: (rate * 100000).toFixed(1) + ' sat/vB'
}))
},
formattedBlocks() {
const now = Math.floor(Date.now() / 1000)
return this.blocks.map(b => ({
...b,
shortHash: b.hash.slice(0, 8) + '...' + b.hash.slice(-4),
timeAgo: this._timeAgo(now - b.timestamp),
utcTime: new Date(b.timestamp * 1000).toUTCString(),
difficulty: this._difficulty(b.bits)
}))
}
},
async created() {
await Promise.all([this.loadTip(), this.loadFees(), this.loadBlocks()])
this._blockWsActive = true
this._connectBlocksWs()
this._loadFromRoute()
},
beforeUnmount() {
this._blockWsActive = false
if (this._blockWs) this._blockWs.close()
if (this._searchWs) this._searchWs.close()
},
watch: {
$route(to) {
this._loadFromRoute(to)
},
blockDialog(val) {
if (!val && this.$route.params.type === 'block') {
this.$router.push('/blockexplorer')
}
}
},
methods: {
_loadFromRoute(route) {
route = route || this.$route
const {type, id} = route.params
if (type === 'tx') {
this.query = id
this._fetchTx(id)
} else if (type === 'address') {
this.query = id
this._fetchAddress(id)
} else if (type === 'block') {
this._openBlockByHeight(id)
} else {
this._resetResults()
this.blockDialog = false
}
},
_openBlockByHeight(height) {
const h = parseInt(height, 10)
const block =
this.formattedBlocks.find(b => b.height === h) ||
this.blocks.find(b => b.height === h)
if (block) {
this.selectedBlock = block
this.blockDialog = true
} else {
this.selectedBlock = null
this.blockDialog = false
}
},
_resetResults() {
this.txResult = null
this.txStatus = null
this.addressResult = null
if (this._searchWs) {
this._searchWs.close()
this._searchWs = null
}
},
_wsUrl(path) {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${proto}//${window.location.host}/blockexplorer/api/v1${path}`
},
_connectBlocksWs() {
const ws = new WebSocket(this._wsUrl('/ws/blocks'))
ws.onmessage = e => {
const block = JSON.parse(e.data)
const rest = this.blocks.filter(b => b.height !== block.height)
this.blocks = [block, ...rest].slice(0, 5)
}
ws.onerror = () => ws.close()
ws.onclose = () => {
if (this._blockWsActive) setTimeout(() => this._connectBlocksWs(), 5000)
}
this._blockWs = ws
},
_connectSearchWs(path, onMessage) {
if (this._searchWs) {
this._searchWs.close()
this._searchWs = null
}
const ws = new WebSocket(this._wsUrl(path))
ws.onmessage = e => {
try {
onMessage(JSON.parse(e.data))
} catch (_) {}
}
ws.onerror = () => ws.close()
this._searchWs = ws
},
_timeAgo(seconds) {
if (seconds < 60) return seconds + 's ago'
if (seconds < 3600) return Math.floor(seconds / 60) + 'm ago'
return Math.floor(seconds / 3600) + 'h ago'
},
_difficulty(bitsHex) {
const exp = parseInt(bitsHex.slice(0, 2), 16)
const mantissa = parseInt(bitsHex.slice(2), 16)
const diff1 = 0xffff * Math.pow(2, 208)
const target = mantissa * Math.pow(2, 8 * (exp - 3))
const d = diff1 / target
if (d >= 1e12) return (d / 1e12).toFixed(2) + 'T'
if (d >= 1e9) return (d / 1e9).toFixed(2) + 'G'
if (d >= 1e6) return (d / 1e6).toFixed(2) + 'M'
return d.toFixed(0)
},
openBlock(b) {
this.$router.push(`/blockexplorer/block/${b.height}`)
},
async loadBlocks() {
try {
const r = await LNbits.api.request(
'GET',
'/blockexplorer/api/v1/blocks'
)
this.blocks = r.data
} catch (_) {}
},
async loadTip() {
try {
const r = await LNbits.api.request('GET', '/blockexplorer/api/v1/tip')
this.tip = r.data
} catch (e) {
LNbits.utils.notifyApiError(e)
}
},
async loadFees() {
try {
const r = await LNbits.api.request('GET', '/blockexplorer/api/v1/fees')
this.fees = r.data
} catch (_) {}
},
clearResult() {
this.query = ''
if (this.$route.path !== '/blockexplorer') {
this.$router.push('/blockexplorer')
} else {
this._resetResults()
}
},
search() {
const q = this.query.trim()
if (!q) return
if (/^[0-9a-fA-F]{64}$/.test(q)) {
this.loadTx(q)
} else {
this.loadAddress(q)
}
},
loadTx(txid) {
this.$router.push(`/blockexplorer/tx/${txid}`)
},
loadAddress(address) {
this.$router.push(`/blockexplorer/address/${address}`)
},
async _fetchTx(txid) {
this.loading = true
try {
const r = await LNbits.api.request(
'GET',
'/blockexplorer/api/v1/tx/' + txid
)
this.txResult = r.data
this.txStatus = null
this.addressResult = null
this._connectSearchWs(`/ws/tx/${txid}`, data => {
if (!data.error) this.txStatus = data
})
} catch (e) {
LNbits.utils.notifyApiError(e)
} finally {
this.loading = false
}
},
async _fetchAddress(address) {
this.loading = true
try {
const r = await LNbits.api.request(
'GET',
'/blockexplorer/api/v1/address/' + address
)
this.addressResult = r.data
this.txResult = null
this.txStatus = null
this.currentAddress = address
this._connectSearchWs(`/ws/address/${address}`, data => {
if (!data.error) this.addressResult = data
})
} catch (e) {
LNbits.utils.notifyApiError(e)
} finally {
this.loading = false
}
}
}
}

View file

@ -29,6 +29,11 @@ window.PageUsers = {
data: {},
show: false
},
lightningAddressDialog: {
wallet: null,
lightningAddress: '',
show: false
},
walletTable: {
columns: [
{
@ -173,6 +178,11 @@ window.PageUsers = {
created() {
this.fetchUsers()
},
computed: {
lightningAddressSuffix() {
return `@${window.location.host}`
}
},
methods: {
formatSat(value) {
@ -348,6 +358,35 @@ window.PageUsers = {
const url = `${window.location.origin}/wallet?usr=${this.activeWallet.userId}&wal=${walletId}`
this.utils.copyText(url)
},
showLightningAddressDialog(wallet) {
this.lightningAddressDialog.wallet = wallet
this.lightningAddressDialog.lightningAddress =
wallet.lightning_address || ''
this.lightningAddressDialog.show = true
},
saveLightningAddress() {
const wallet = this.lightningAddressDialog.wallet
if (!wallet) return
LNbits.api
.request(
'PUT',
`/users/api/v1/user/${wallet.user}/wallet/${wallet.id}/lightning-address`,
null,
{
lightning_address: this.lightningAddressDialog.lightningAddress
}
)
.then(response => {
Object.assign(wallet, response.data)
this.lightningAddressDialog.show = false
Quasar.Notify.create({
type: 'positive',
message: this.$t('lightning_address_updated'),
icon: null
})
})
.catch(LNbits.utils.notifyApiError)
},
fetchUsers(props) {
this.relaxFilterForFields(['username', 'email'])
const params = LNbits.utils.prepareFilterQuery(this.usersTable, props)

View file

@ -631,14 +631,19 @@ window.PageWallet = {
LNbits.api
.request('PATCH', '/api/v1/wallet', this.g.wallet.adminkey, data)
.then(response => {
this.g.wallet = {...this.g.wallet, ...response.data}
const walletData = {...response.data}
if (walletData.lightning_address) {
walletData.lightningAddress = walletData.lightning_address
walletData.lightningAddressFull = `${walletData.lightning_address}@${window.location.host}`
}
this.g.wallet = {...this.g.wallet, ...walletData}
const walletIndex = this.g.user.wallets.findIndex(
wallet => wallet.id === response.data.id
)
if (walletIndex !== -1) {
this.g.user.wallets[walletIndex] = {
...this.g.user.wallets[walletIndex],
...response.data
...walletData
}
}
Quasar.Notify.create({

View file

@ -74,6 +74,8 @@
"js/components/admin/lnbits-admin-site-customisation.js",
"js/components/admin/lnbits-admin-assets-config.js",
"js/components/admin/lnbits-admin-audit.js",
"js/components/admin/lnbits-admin-blockexplorer.js",
"js/pages/blockexplorer.js",
"js/components/lnbits-wallet-charts.js",
"js/components/lnbits-wallet-api-docs.js",
"js/components/lnbits-wallet-icon.js",

View file

@ -3,12 +3,23 @@ import traceback
import uuid
from collections.abc import Callable, Coroutine
from datetime import datetime, timezone
from typing import TypeVar
from fastapi import WebSocket
from loguru import logger
from pydantic import BaseModel
from lnbits.core.models import Payment
from lnbits.settings import settings
from lnbits.utils.electrum import (
AddressTracker,
BlockInfo,
BlockTracker,
OnchainAddressEvent,
OnchainTxEvent,
TransactionTracker,
scripthash_from_address,
)
class PublicTask(BaseModel):
@ -26,26 +37,41 @@ class Task:
created_at: datetime
task: asyncio.Task
invoice_queue: asyncio.Queue[Payment] | None = None
onchain_address_queue: asyncio.Queue[OnchainAddressEvent] | None = None
onchain_tx_queue: asyncio.Queue[OnchainTxEvent] | None = None
block_queue: asyncio.Queue[BlockInfo] | None = None
def __init__(
self,
coro: Coroutine,
name: str | None = None,
invoice_queue: asyncio.Queue | None = None,
onchain_address_queue: asyncio.Queue | None = None,
onchain_tx_queue: asyncio.Queue | None = None,
block_queue: asyncio.Queue | None = None,
) -> None:
self.coro = coro
self.name = name or f"task_{uuid.uuid4()}"
self.created_at = datetime.now(timezone.utc)
self.task = asyncio.create_task(self.coro, name=self.name)
self.invoice_queue = invoice_queue
self.onchain_address_queue = onchain_address_queue
self.onchain_tx_queue = onchain_tx_queue
self.block_queue = block_queue
class TaskManager:
"""Singleton class to manage background tasks."""
ONCHAIN_ADDRESS_LISTENER_SUFFIX = "_onchain_address_listener"
tasks: list[Task] = []
invoice_queue: asyncio.Queue[Payment] = asyncio.Queue()
internal_invoice_queue: asyncio.Queue[Payment] = asyncio.Queue()
_address_tracker: "AddressTracker | None" = None
_block_tracker: "BlockTracker | None" = None
_tx_trackers: dict[str, "TransactionTracker"] = {}
_tracked_addresses_by_listener: dict[str, set[str]] = {}
def init(self) -> None:
self.create_permanent_task(
@ -84,13 +110,23 @@ class TaskManager:
coro: Coroutine,
name: str | None = None,
invoice_queue: asyncio.Queue | None = None,
onchain_address_queue: asyncio.Queue | None = None,
onchain_tx_queue: asyncio.Queue | None = None,
block_queue: asyncio.Queue | None = None,
) -> Task:
"""Create a task. If a task with the same name exists, it will be cancelled."""
if name:
task = self.get_task(name)
if task:
self.cancel_task(task)
task = Task(coro=coro, name=name, invoice_queue=invoice_queue)
task = Task(
coro=coro,
name=name,
invoice_queue=invoice_queue,
onchain_address_queue=onchain_address_queue,
onchain_tx_queue=onchain_tx_queue,
block_queue=block_queue,
)
self.tasks.append(task)
return task
@ -98,6 +134,9 @@ class TaskManager:
self,
func: Callable[[], Coroutine],
invoice_queue: asyncio.Queue | None = None,
onchain_address_queue: asyncio.Queue | None = None,
onchain_tx_queue: asyncio.Queue | None = None,
block_queue: asyncio.Queue | None = None,
name: str | None = None,
interval: int = 0,
) -> Task:
@ -110,7 +149,12 @@ class TaskManager:
await asyncio.sleep(interval)
return self.create_task(
coro=wrapper(), name=name or func.__name__, invoice_queue=invoice_queue
coro=wrapper(),
name=name or func.__name__,
invoice_queue=invoice_queue,
onchain_address_queue=onchain_address_queue,
onchain_tx_queue=onchain_tx_queue,
block_queue=block_queue,
)
def register_invoice_listener(
@ -130,6 +174,185 @@ class TaskManager:
invoice_queue=queue,
)
def register_onchain_listener(
self,
func: Callable[[OnchainAddressEvent], Coroutine],
name: str | None = None,
) -> Task:
"""
Register a callback for onchain address events. Only dispatches events
for addresses tracked under the same `name` via track_address, e.g. an
extension registering as "ext_satspay" only sees events for addresses
it tracked with that same name. Defaults to the shared "core" listener
if no name is given.
"""
name = name or "core"
queue: asyncio.Queue[OnchainAddressEvent] = asyncio.Queue()
return self.create_permanent_task(
self._onchain_address_listener_worker(func, queue),
name=f"{name}{self.ONCHAIN_ADDRESS_LISTENER_SUFFIX}",
onchain_address_queue=queue,
)
def register_onchain_tx_listener(
self,
func: Callable[[OnchainTxEvent], Coroutine],
name: str | None = None,
) -> Task:
"""
Register a callback for onchain transaction events dispatched for any
transaction currently tracked via register_ws_tx_queue.
Will call the provided coroutine with an OnchainTxEvent on each update.
"""
name = f"{name or uuid.uuid4()}_onchain_tx_listener"
queue: asyncio.Queue[OnchainTxEvent] = asyncio.Queue()
return self.create_permanent_task(
self._onchain_tx_listener_worker(func, queue),
name=name,
onchain_tx_queue=queue,
)
def register_block_listener(
self,
func: Callable[[BlockInfo], Coroutine],
name: str | None = None,
) -> Task:
"""
Register a callback for new block events dispatched while the shared
block tracker is running (i.e. while a websocket or other consumer has
requested block updates via register_ws_block_queue).
Will call the provided coroutine with a BlockInfo on each new block.
"""
name = f"{name or uuid.uuid4()}_block_listener"
queue: asyncio.Queue[BlockInfo] = asyncio.Queue()
return self.create_permanent_task(
self._block_listener_worker(func, queue),
name=name,
block_queue=queue,
)
def track_address(self, address: str, name: str) -> None:
"""Start tracking a Bitcoin address via Electrum (ref-counted).
`name` identifies the listener (see register_onchain_listener) that
should receive events for this address.
"""
self._get_address_tracker().add(address)
self._tracked_addresses_by_listener.setdefault(name, set()).add(address)
def untrack_address(self, address: str, name: str) -> None:
"""Decrement ref count; remove from shared tracker when last caller leaves."""
if self._address_tracker:
self._address_tracker.remove(address)
tracked = self._tracked_addresses_by_listener.get(name)
if tracked:
tracked.discard(address)
if not tracked:
self._tracked_addresses_by_listener.pop(name, None)
def _get_address_tracker(self) -> "AddressTracker":
if self._address_tracker is None:
self._address_tracker = AddressTracker(
settings.lnbits_blockexplorer_electrum_url
)
if not self.get_task("address_tracker"):
self.create_task(
self._address_tracker.run(
self._dispatch_onchain_event,
lambda: settings.lnbits_running,
),
name="address_tracker",
)
return self._address_tracker
def register_ws_address_queue(
self, address: str, queue: asyncio.Queue[OnchainAddressEvent]
) -> None:
"""Register a per-connection queue for a watched address.
Raises ValueError if the address is invalid.
"""
scripthash_from_address(address)
self._get_address_tracker().register_queue(address, queue)
def unregister_ws_address_queue(
self, address: str, queue: asyncio.Queue[OnchainAddressEvent]
) -> None:
"""Deregister a per-connection queue and decrement the address ref count."""
if self._address_tracker:
self._address_tracker.unregister_queue(address, queue)
def register_ws_tx_queue(
self, txid: str, queue: asyncio.Queue[OnchainTxEvent]
) -> None:
"""Register a per-connection queue for a watched transaction."""
self._get_tx_tracker(txid).register_queue(queue)
def unregister_ws_tx_queue(
self, txid: str, queue: asyncio.Queue[OnchainTxEvent]
) -> None:
"""Deregister a per-connection queue; cancel tracker when last one leaves."""
tracker = self._tx_trackers.get(txid)
if not tracker:
return
tracker.unregister_queue(queue)
if not tracker.has_queues():
self._tx_trackers.pop(txid, None)
task = self.get_task(f"ws_tx_{txid}")
if task:
self.cancel_task(task)
def _get_tx_tracker(self, txid: str) -> "TransactionTracker":
tracker = self._tx_trackers.get(txid)
if tracker is None:
tracker = TransactionTracker(settings.lnbits_blockexplorer_electrum_url)
self._tx_trackers[txid] = tracker
if not self.get_task(f"ws_tx_{txid}"):
self.create_task(
self._transaction_tracker_dispatch(txid, tracker),
name=f"ws_tx_{txid}",
)
return tracker
def register_ws_block_queue(self, queue: asyncio.Queue[BlockInfo]) -> None:
"""Register a per-connection queue for new block events."""
if self._block_tracker is None:
self._block_tracker = BlockTracker(
settings.lnbits_blockexplorer_electrum_url
)
tracker = self._block_tracker
was_empty = not tracker.has_queues()
tracker.register_queue(queue)
if was_empty:
self.create_task(
tracker.run(
self._dispatch_block_event,
lambda: tracker.has_queues() and settings.lnbits_running,
),
name="block_tracker",
)
def unregister_ws_block_queue(self, queue: asyncio.Queue[BlockInfo]) -> None:
"""Deregister a per-connection queue; cancel tracker when last one leaves."""
if not self._block_tracker:
return
self._block_tracker.unregister_queue(queue)
if not self._block_tracker.has_queues():
task = self.get_task("block_tracker")
if task:
self.cancel_task(task)
def track_transaction(
self,
txid: str,
callback: Callable[[OnchainTxEvent], Coroutine],
) -> Task:
"""Track a transaction until confirmed, calling callback on each change."""
return self.create_task(
self._transaction_tracker(txid, callback),
name=f"onchain_tx_{txid}",
)
async def _heart_beat(self) -> None:
"""A heartbeat that removes done tasks logs the number of tasks."""
for task in self.tasks:
@ -142,10 +365,17 @@ class TaskManager:
if task.task and task.task.done():
logger.debug(f"Task Manager: task `{task.name}` is done.")
self.cancel_task(task)
listeners_count = sum(1 for task in self.tasks if task.invoice_queue)
invoice_listeners = sum(1 for task in self.tasks if task.invoice_queue)
onchain_listeners = sum(
1
for task in self.tasks
if task.onchain_address_queue or task.onchain_tx_queue or task.block_queue
)
other_tasks = len(self.tasks) - invoice_listeners - onchain_listeners
logger.debug(
f"Task Manager: {len(self.tasks) - listeners_count} tasks "
f"and {listeners_count} invoice listeners."
f"Task Manager: {other_tasks} tasks, "
f"{invoice_listeners} invoice listeners, "
f"{onchain_listeners} onchain listeners."
)
async def _catch_everything_and_restart(
@ -178,6 +408,39 @@ class TaskManager:
return wrapper
def _onchain_address_listener_worker(
self,
func: Callable[[OnchainAddressEvent], Coroutine],
queue: asyncio.Queue[OnchainAddressEvent],
) -> Callable:
async def wrapper() -> None:
event: OnchainAddressEvent = await queue.get()
await func(event)
return wrapper
def _onchain_tx_listener_worker(
self,
func: Callable[[OnchainTxEvent], Coroutine],
queue: asyncio.Queue[OnchainTxEvent],
) -> Callable:
async def wrapper() -> None:
event: OnchainTxEvent = await queue.get()
await func(event)
return wrapper
def _block_listener_worker(
self,
func: Callable[[BlockInfo], Coroutine],
queue: asyncio.Queue[BlockInfo],
) -> Callable:
async def wrapper() -> None:
event: BlockInfo = await queue.get()
await func(event)
return wrapper
def _invoice_dispatcher(self, payment: Payment) -> None:
"""Dispatches a payment to all registered invoice listeners."""
for task in self.tasks:
@ -186,6 +449,39 @@ class TaskManager:
logger.debug(f"Enqueing payment to task {task.name}")
task.invoice_queue.put_nowait(payment)
async def _dispatch_onchain_event(self, event: OnchainAddressEvent) -> None:
"""Dispatches an onchain address event to listeners tracking that
address under their own name (see track_address).
Per-address WS queue fan-out is handled by AddressTracker itself.
"""
for task in self.tasks:
if not task.onchain_address_queue:
continue
if not task.name.endswith(self.ONCHAIN_ADDRESS_LISTENER_SUFFIX):
continue
name = task.name[: -len(self.ONCHAIN_ADDRESS_LISTENER_SUFFIX)]
if event.address in self._tracked_addresses_by_listener.get(name, ()):
task.onchain_address_queue.put_nowait(event)
async def _dispatch_onchain_tx_event(self, event: OnchainTxEvent) -> None:
"""Dispatches an onchain tx event to registered listeners.
Per-tx WS queue fan-out is handled by TransactionTracker itself.
"""
for task in self.tasks:
if task.onchain_tx_queue:
task.onchain_tx_queue.put_nowait(event)
async def _dispatch_block_event(self, event: BlockInfo) -> None:
"""Dispatches a new block event to registered listeners.
Per-connection WS queue fan-out is handled by BlockTracker itself.
"""
for task in self.tasks:
if task.block_queue:
task.block_queue.put_nowait(event)
async def _invoice_listener_consumer(self) -> None:
payment = await self.invoice_queue.get()
logger.info(f"got a payment notification {payment.checking_id}")
@ -196,5 +492,64 @@ class TaskManager:
logger.info(f"got an internal payment notification {payment.checking_id}")
self._invoice_dispatcher(payment)
async def _transaction_tracker(
self, txid: str, callback: Callable[[OnchainTxEvent], Coroutine]
) -> None:
await TransactionTracker(settings.lnbits_blockexplorer_electrum_url).track(
txid,
callback,
lambda: settings.lnbits_running,
)
async def _transaction_tracker_dispatch(
self, txid: str, tracker: "TransactionTracker"
) -> None:
await tracker.track(
txid,
self._dispatch_onchain_tx_event,
lambda: tracker.has_queues() and settings.lnbits_running,
)
T = TypeVar("T", bound=BaseModel)
async def relay_ws_queue(
websocket: WebSocket,
queue: "asyncio.Queue[T]",
serialize: Callable[[T], BaseModel] = lambda e: e,
stop_after: Callable[[T], bool] = lambda _: False,
) -> None:
"""
Pumps events from `queue` to `websocket` as JSON until the client
disconnects, sending fails, or `stop_after` returns True for an event.
Shared by the blockexplorer address/tx/block websocket endpoints.
"""
try:
while settings.lnbits_running:
recv_task = asyncio.create_task(websocket.receive())
event_task = asyncio.create_task(queue.get())
done, pending = await asyncio.wait(
[recv_task, event_task], return_when=asyncio.FIRST_COMPLETED
)
for t in pending:
t.cancel()
disconnect = recv_task in done and (
recv_task.result().get("type") == "websocket.disconnect"
)
if disconnect:
break
if event_task in done:
event = event_task.result()
try:
await websocket.send_json(serialize(event).dict())
except Exception as exc:
logger.debug(f"ws relay send error: {exc}")
break
if stop_after(event):
break
except Exception as exc:
logger.debug(f"ws relay error: {exc}")
task_manager = TaskManager()

View file

@ -72,7 +72,9 @@
{% block page %}{% endblock %}
</div>
<!-- vue router-view -->
<router-view :key="$route.path"></router-view>
<router-view
:key="$route.meta.stableKey ? $route.matched[0]?.path : $route.path"
></router-view>
</q-page>
</q-page-container>
{% endblock %}

View file

@ -13,6 +13,7 @@ include('components/admin/wasm-limit-config.vue') %} {%
include('components/admin/assets-config.vue') %} {%
include('components/admin/notifications.vue') %} {%
include('components/admin/server.vue') %} {%
include('components/admin/blockexplorer.vue') %} {%
include('components/lnbits-qrcode.vue') %} {%
include('components/lnbits-qrcode-scanner.vue') %} {%
include('components/lnbits-disclaimer.vue') %} {%
@ -100,6 +101,21 @@ include('components/lnbits-error.vue') %}
<q-icon name="chevron_right" color="grey-5" size="md"></q-icon>
</q-item-section>
</q-item>
<q-item v-if="g.settings.showBlockExplorer" to="/blockexplorer">
<q-item-section side>
<q-icon
name="travel_explore"
:color="isActive('/blockexplorer') ? 'primary' : 'grey-5'"
size="md"
></q-icon>
</q-item-section>
<q-item-section>
<q-item-label lines="1" v-text="$t('block_explorer')"></q-item-label>
</q-item-section>
<q-item-section side v-show="isActive('/blockexplorer')">
<q-icon name="chevron_right" color="grey-5" size="md"></q-icon>
</q-item-section>
</q-item>
</div>
<q-item to="/payments">
<q-item-section side>

View file

@ -0,0 +1,90 @@
<template id="lnbits-admin-blockexplorer">
<q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('block_explorer')"></span>
</h6>
<div class="row q-mb-lg">
<div class="col-md-6 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_blockexplorer_enabled"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
<q-item-section>
<q-item-label v-text="$t('enable_block_explorer')"></q-item-label>
<q-item-label
caption
v-text="$t('block_explorer_desc')"
></q-item-label>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_blockexplorer_public_api"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
<q-item-section>
<q-item-label
v-text="$t('blockexplorer_public_api')"
></q-item-label>
<q-item-label
caption
v-text="$t('blockexplorer_public_api_desc')"
></q-item-label>
</q-item-section>
</q-item>
</div>
</div>
<q-separator class="q-mb-lg q-mt-sm"></q-separator>
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('electrum_compatible_server')"></span>
</h6>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-4">
<q-select
filled
v-model="electrumServerPreset"
:options="electrumServerOptions"
:label="$t('electrum_server_url')"
:hint="$t('electrum_server_url_hint')"
></q-select>
</div>
<div class="col-12 col-md-4" v-if="electrumServerPreset === 'Custom'">
<q-input
filled
v-model="formData.lnbits_blockexplorer_electrum_url"
:label="$t('electrum_server_url_custom')"
:hint="$t('electrum_server_url_hint')"
></q-input>
</div>
<div class="col-12 col-md-4">
<q-select
filled
v-model="formData.lnbits_blockexplorer_network"
:options="['main', 'test', 'regtest', 'signet']"
:label="$t('blockexplorer_network')"
:hint="$t('blockexplorer_network_hint')"
></q-select>
</div>
<div class="col-12">
<a
href="https://1209k.com/bitcoin-eye/ele.php?chain=btc"
target="_blank"
rel="noopener noreferrer"
>
<span v-text="$t('view_public_electrum_servers')"></span>
</a>
</div>
</div>
</q-card-section>
</template>

View file

@ -107,7 +107,7 @@
</div>
<div class="col-12 col-md-6">
<p>
<span v-text="$t('miscellaneous')"></span>
<span v-text="$t('miscellanous')"></span>
</p>
<q-item tag="label" v-ripple>
<q-item-section>

View file

@ -69,6 +69,127 @@
</div>
<q-separator class="q-mb-lg q-mt-sm"></q-separator>
<h6 class="q-my-none q-mb-sm" v-text="$t('lightning_addresses')"></h6>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6 q-mt-sm">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label v-text="$t('ln_address_mode')"></q-item-label>
<q-item-label
caption
v-text="$t('ln_address_mode_hint')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-select
filled
emit-value
map-options
v-model="formData.lnbits_ln_address_mode"
:label="$t('ln_address_mode')"
:options="[
{label: $t('ln_address_core_first'), value: 'core_first'},
{
label: $t('ln_address_extension_first'),
value: 'extension_first'
},
{
label: $t('ln_address_extension_only'),
value: 'extension_only'
}
]"
></q-select>
</q-item-section>
</q-item>
</div>
<div
v-if="formData.lnbits_ln_address_mode != 'extension_only'"
class="col-12 col-md-6 q-mt-sm"
>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('allow_users_specify_lightning_addresses')"
></q-item-label>
<q-item-label
caption
v-text="
$t('allow_wallet_owners_set_custom_lightning_addresses')
"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="
formData.lnbits_allow_custom_wallet_lightning_addresses
"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
</div>
<div
v-if="formData.lnbits_ln_address_mode != 'extension_only'"
class="col-12 col-md-6 q-mt-sm"
>
<div>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('charge_for_lightning_addresses')"
></q-item-label>
<q-item-label
caption
v-text="$t('charge_users_set_change_lightning_address')"
></q-item-label>
<q-item-label
caption
v-text="$t('service_fee_wallet_id_must_be_set')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_charge_wallet_lightning_addresses"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
</div>
<q-input
v-if="formData.lnbits_charge_wallet_lightning_addresses"
class="q-mt-sm"
filled
dense
type="number"
min="0"
v-model.number="formData.lnbits_wallet_lightning_address_price_sats"
:label="$t('lightning_address_price')"
suffix="sats"
></q-input>
</div>
<div
v-if="formData.lnbits_ln_address_mode != 'extension_only'"
class="col-12 col-md-6 q-mt-sm"
>
<q-input
filled
dense
type="textarea"
autogrow
v-model="lightningAddressBlacklistText"
:label="$t('lightning_address_blacklist')"
:hint="$t('lightning_address_blacklist_instructions')"
></q-input>
</div>
</div>
<q-separator class="q-mb-lg q-mt-md"></q-separator>
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('wallet_limiter')"></span>
</h6>

View file

@ -157,6 +157,60 @@
</div>
</div>
</q-card-section>
<q-card-section
v-if="
g.settings.enableWalletLightningAddresses &&
(g.wallet.lightningAddressFull || canEditLightningAddress)
"
>
<div class="row">
<div :class="canEditLightningAddress ? 'col-8' : 'col-10'">
<q-input
v-if="canEditLightningAddress"
filled
dense
v-model.trim="lightningAddressInput"
:label="$t('lightning_address')"
:suffix="lightningAddressSuffix"
:hint="lightningAddressFeeHint"
>
</q-input>
<q-input
v-else
filled
readonly
dense
:model-value="g.wallet.lightningAddressFull"
:label="$t('lightning_address')"
>
</q-input>
</div>
<div v-if="canEditLightningAddress" class="col-2 q-pl-sm">
<q-btn
dense
color="primary"
class="q-mt-xs full-width"
:disable="
!lightningAddressInput || !lightningAddressChanged
"
label="Save"
@click="saveLightningAddress"
></q-btn>
</div>
<div class="col-2">
<q-btn
flat
round
icon="content_copy"
class="float-right q-mt-xs"
:disable="!g.wallet.lightningAddressFull"
@click="utils.copyText(g.wallet.lightningAddressFull)"
>
<q-tooltip v-text="$t('copy')"></q-tooltip>
</q-btn>
</div>
</div>
</q-card-section>
<q-card-section>
<div class="row">
<div class="col-6">

View file

@ -4,4 +4,4 @@ include('pages/users.vue') %} {% include('pages/admin.vue') %} {%
include('pages/account.vue') %} {% include('pages/extensions_builder.vue') %} {%
include('pages/extensions.vue') %} {% include('pages/first-install.vue') %} {%
include('pages/home.vue') %} {% include('pages/wallet.vue') %} {%
include('pages/error.vue') %}
include('pages/error.vue') %} {% include('pages/blockexplorer.vue') %}

View file

@ -94,7 +94,8 @@
{value: 'notifications', label: $t('notifications')},
{value: 'audit', label: $t('audit')},
{value: 'assets-config', label: $t('assets')},
{value: 'site_customisation', label: $t('site_customisation')}
{value: 'site_customisation', label: $t('site_customisation')},
{value: 'blockexplorer', label: $t('block_explorer')}
]"
option-value="value"
option-label="label"
@ -189,6 +190,13 @@
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('site_customisation')"></span></q-tooltip
></q-tab>
<q-tab
name="blockexplorer"
icon="travel_explore"
:label="$q.screen.gt.sm ? $t('block_explorer') : null"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('block_explorer')"></span></q-tooltip
></q-tab>
</q-tabs>
</template>
@ -247,6 +255,9 @@
<q-tab-panel name="assets-config">
<lnbits-admin-assets-config :form-data="formData" />
</q-tab-panel>
<q-tab-panel name="blockexplorer">
<lnbits-admin-blockexplorer :form-data="formData" />
</q-tab-panel>
</q-tab-panels>
</q-scroll-area>
</q-form>

View file

@ -0,0 +1,385 @@
<template id="page-blockexplorer">
<div class="row q-col-gutter-md">
<!-- Left column: blocks + search + results -->
<div class="col-12 col-md-7 q-gutter-y-md">
<!-- Recent blocks (mempool-style squares) -->
<q-card v-if="blocks.length">
<q-card-section>
<div
class="text-subtitle1 q-mb-md"
v-text="$t('recent_blocks')"
></div>
<div class="row q-gutter-sm">
<q-card
v-for="b in formattedBlocks"
:key="b.height"
flat
class="bg-primary text-white cursor-pointer"
v-ripple
@click="openBlock(b)"
>
<q-card-section class="q-pa-sm">
<div
class="text-subtitle1 text-weight-bold"
v-text="'#' + b.height.toLocaleString()"
></div>
<div class="text-caption q-mt-xs" v-text="b.timeAgo"></div>
<div class="text-caption q-mt-sm">
<code v-text="b.shortHash"></code>
</div>
<div
class="text-caption"
v-text="$t('block_diff', {value: b.difficulty})"
></div>
</q-card-section>
</q-card>
</div>
</q-card-section>
</q-card>
<!-- Block detail dialog -->
<q-dialog v-model="blockDialog">
<q-card v-if="selectedBlock">
<q-card-section class="bg-primary text-white q-pb-sm">
<div
class="text-h6"
v-text="
$t('block_number', {
height: selectedBlock.height.toLocaleString()
})
"
></div>
<div class="text-caption" v-text="selectedBlock.utcTime"></div>
</q-card-section>
<q-card-section>
<div class="q-mb-md">
<div
class="text-caption text-grey q-mb-xs"
v-text="$t('block_hash')"
></div>
<code
class="text-caption be-wrap"
v-text="selectedBlock.hash"
></code>
</div>
<div class="q-mb-md">
<div
class="text-caption text-grey q-mb-xs"
v-text="$t('previous_block')"
></div>
<code
class="text-caption be-wrap"
v-text="selectedBlock.prev_hash"
></code>
</div>
<div class="q-mb-lg">
<div
class="text-caption text-grey q-mb-xs"
v-text="$t('merkle_root')"
></div>
<code
class="text-caption be-wrap"
v-text="selectedBlock.merkle_root"
></code>
</div>
<div class="row q-col-gutter-md">
<div class="col-6 col-sm-3">
<div
class="text-caption text-grey"
v-text="$t('version')"
></div>
<div v-text="'0x' + selectedBlock.version.toString(16)"></div>
</div>
<div class="col-6 col-sm-3">
<div class="text-caption text-grey" v-text="$t('bits')"></div>
<div v-text="selectedBlock.bits"></div>
</div>
<div class="col-6 col-sm-3">
<div
class="text-caption text-grey"
v-text="$t('difficulty')"
></div>
<div v-text="selectedBlock.difficulty"></div>
</div>
<div class="col-6 col-sm-3">
<div class="text-caption text-grey" v-text="$t('nonce')"></div>
<div v-text="selectedBlock.nonce.toLocaleString()"></div>
</div>
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat v-close-popup v-text="$t('close')"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<q-card>
<q-card-section>
<div class="text-h6" v-text="$t('block_explorer')"></div>
</q-card-section>
<q-card-section class="q-pt-none">
<q-input
filled
v-model="query"
:label="$t('blockexplorer_search_label')"
:hint="$t('blockexplorer_search_hint')"
@keyup.enter="search"
>
<template v-slot:append>
<q-btn
flat
round
dense
icon="search"
:loading="loading"
@click="search"
/>
</template>
</q-input>
</q-card-section>
</q-card>
<!-- Transaction result -->
<q-card v-if="txResult">
<q-card-section>
<div class="row items-center justify-between q-mb-sm">
<div class="row items-center q-gutter-sm">
<div class="text-subtitle1" v-text="$t('transaction')"></div>
<q-badge
v-if="txStatus"
:color="txStatus.confirmed ? 'positive' : 'orange'"
:label="
txStatus.confirmed ? $t('confirmed') : $t('unconfirmed')
"
></q-badge>
<q-spinner v-if="!txStatus" size="1em" color="grey" />
</div>
<q-btn flat round dense icon="close" @click="clearResult" />
</div>
<div class="q-mb-sm">
<span
class="text-caption text-grey"
v-text="$t('txid') + ': '"
></span>
<code class="text-caption be-wrap" v-text="txResult.txid"></code>
</div>
<div class="row q-col-gutter-md q-mb-sm">
<div class="col-auto" v-if="txStatus && txStatus.height">
<div
class="text-caption text-grey"
v-text="$t('block_height')"
></div>
<div v-text="txStatus.height.toLocaleString()"></div>
</div>
<div class="col-auto" v-if="txStatus && txStatus.fee !== null">
<div class="text-caption text-grey" v-text="$t('fee')"></div>
<div v-text="txStatus.fee + ' sat'"></div>
</div>
<div class="col-auto" v-if="txResult.vsize || txResult.size">
<div class="text-caption text-grey" v-text="$t('vsize')"></div>
<div v-text="(txResult.vsize || txResult.size) + ' vB'"></div>
</div>
<div class="col-auto" v-if="txResult.weight">
<div class="text-caption text-grey" v-text="$t('weight')"></div>
<div v-text="txResult.weight + ' WU'"></div>
</div>
</div>
<q-expansion-item
icon="login"
:label="$t('inputs') + ' (' + txResult.vin.length + ')'"
dense
class="q-mb-xs"
>
<q-list dense separator>
<q-item v-for="(vin, i) in txResult.vin" :key="i">
<q-item-section>
<q-item-label
v-if="vin.coinbase"
class="text-grey"
v-text="$t('coinbase')"
>
</q-item-label>
<q-item-label v-else class="be-wrap">
<a
href="#"
@click.prevent="loadTx(vin.txid)"
class="text-primary"
v-text="vin.txid + ':' + vin.vout"
></a>
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-expansion-item>
<q-expansion-item
icon="logout"
:label="$t('outputs') + ' (' + txResult.vout.length + ')'"
dense
>
<q-list dense separator>
<q-item v-for="(vout, i) in txResult.vout" :key="i">
<q-item-section>
<q-item-label>
<template
v-if="vout.scriptPubKey && vout.scriptPubKey.address"
>
<a
href="#"
@click.prevent="loadAddress(vout.scriptPubKey.address)"
class="text-primary"
v-text="vout.scriptPubKey.address"
></a>
</template>
<template
v-else-if="
vout.scriptPubKey &&
vout.scriptPubKey.type === 'nulldata'
"
>
<span class="text-grey">OP_RETURN</span>
</template>
<template v-else-if="vout.scriptPubKey">
<span v-text="vout.scriptPubKey.type"></span>
</template>
</q-item-label>
<q-item-label
caption
v-text="vout.value + ' BTC'"
></q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-expansion-item>
</q-card-section>
</q-card>
<!-- Address result -->
<q-card v-if="addressResult">
<q-card-section>
<div class="row items-center justify-between q-mb-xs">
<div class="text-subtitle1" v-text="$t('address')"></div>
<q-btn flat round dense icon="close" @click="clearResult" />
</div>
<div class="text-caption q-mb-sm">
<code class="be-wrap" v-text="currentAddress"></code>
</div>
<div class="row q-col-gutter-md q-mb-md">
<div class="col-auto">
<div
class="text-caption text-grey"
v-text="$t('confirmed_balance')"
></div>
<div
v-text="
addressResult.balance.confirmed.toLocaleString() + ' sat'
"
></div>
</div>
<div
class="col-auto"
v-if="addressResult.balance.unconfirmed !== 0"
>
<div
class="text-caption text-grey"
v-text="$t('unconfirmed_balance')"
></div>
<div
v-text="
addressResult.balance.unconfirmed.toLocaleString() + ' sat'
"
></div>
</div>
</div>
<div
class="text-subtitle2 q-mb-xs"
v-text="
$t('transaction_history') +
' (' +
addressResult.history.length +
')'
"
></div>
<q-list dense separator>
<q-item
v-for="h in addressResult.history"
:key="h.tx_hash"
clickable
v-ripple
@click="loadTx(h.tx_hash)"
>
<q-item-section>
<q-item-label
class="text-primary be-wrap"
v-text="h.tx_hash"
></q-item-label>
<q-item-label
caption
v-text="
h.height > 0
? $t('block_height') + ': ' + h.height.toLocaleString()
: $t('unconfirmed')
"
></q-item-label>
</q-item-section>
<q-item-section side>
<q-icon name="chevron_right" color="grey-5"></q-icon>
</q-item-section>
</q-item>
</q-list>
<div
v-if="addressResult.history_error"
class="text-warning q-mt-sm text-caption"
v-text="$t('history_unavailable')"
></div>
<div
v-else-if="addressResult.history.length === 0"
class="text-grey q-mt-sm"
v-text="$t('no_transactions')"
></div>
</q-card-section>
</q-card>
</div>
<!-- Right column: chain tip + fees -->
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card v-if="tip">
<q-card-section>
<div class="text-subtitle1 q-mb-sm" v-text="$t('chain_tip')"></div>
<div class="text-caption text-grey" v-text="$t('block_height')"></div>
<div
class="text-h6 q-mb-md"
v-text="tip.height.toLocaleString()"
></div>
<template v-if="feeList.length">
<div
class="text-subtitle2 q-mb-sm"
v-text="$t('fee_estimates')"
></div>
<q-list dense>
<q-item v-for="f in feeList" :key="f.label" class="q-px-none">
<q-item-section>
<q-item-label
class="text-caption text-grey"
v-text="f.label"
></q-item-label>
</q-item-section>
<q-item-section side>
<q-item-label
class="text-body2"
v-text="f.rate"
></q-item-label>
</q-item-section>
</q-item>
</q-list>
</template>
</q-card-section>
</q-card>
</div>
</div>
</template>
<style>
.be-wrap {
word-break: break-all;
}
</style>

View file

@ -109,6 +109,18 @@
<q-tooltip>Copy Invoice Key</q-tooltip>
</q-btn>
<q-btn
round
v-if="g.user.super_user && !props.row.deleted"
icon="alternate_email"
size="sm"
color="accent"
class="q-ml-xs"
@click="showLightningAddressDialog(props.row)"
>
<q-tooltip v-text="$t('set_lightning_address')"></q-tooltip>
</q-btn>
<q-btn
round
icon="delete"
@ -201,6 +213,39 @@
</template>
</q-table>
</q-card>
<q-dialog v-model="lightningAddressDialog.show">
<q-card class="q-pa-md lnbits__dialog-card">
<q-card-section>
<div class="text-h6" v-text="$t('lightning_address')"></div>
<div
v-if="lightningAddressDialog.wallet"
class="text-caption q-mt-xs"
>
<span v-text="lightningAddressDialog.wallet.name"></span>
<span> · </span>
<span v-text="lightningAddressDialog.wallet.id"></span>
</div>
</q-card-section>
<q-card-section>
<q-input
filled
dense
v-model.trim="lightningAddressDialog.lightningAddress"
:label="$t('lightning_address')"
:suffix="lightningAddressSuffix"
></q-input>
</q-card-section>
<q-card-actions align="right">
<q-btn flat :label="$t('cancel')" v-close-popup></q-btn>
<q-btn
color="primary"
:label="$t('save')"
:disable="!lightningAddressDialog.lightningAddress"
@click="saveLightningAddress"
></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
</div>
<div v-if="activeUser.show" class="row">
<div class="col-12 col-md-6">

View file

@ -596,6 +596,34 @@
:value="'LIGHTNING:' + receive.paymentReq.toUpperCase()"
>
</lnbits-qrcode>
<div
v-if="
!receive.fiatPaymentReq &&
g.settings.enableWalletLightningAddresses &&
g.wallet.lightningAddressFull
"
class="q-mt-md"
>
<q-input
filled
readonly
dense
:model-value="g.wallet.lightningAddressFull"
:label="$t('lightning_address')"
>
<template v-slot:append>
<q-btn
flat
round
dense
icon="content_copy"
@click="utils.copyText(g.wallet.lightningAddressFull)"
>
<q-tooltip v-text="$t('copy')"></q-tooltip>
</q-btn>
</template>
</q-input>
</div>
<div class="text-center">
<h3 class="q-my-md">
<span v-text="formattedAmount"></span>

View file

@ -11,13 +11,29 @@ import hashlib
import itertools
import json
import ssl
from collections.abc import Callable
import struct
from collections.abc import Callable, Coroutine
from typing import Any
from urllib.parse import urlparse
from embit.networks import NETWORKS
from embit.script import Script
from embit.transaction import Transaction as EmbitTransaction
from loguru import logger
from pydantic import BaseModel
DEFAULT_NETWORK = NETWORKS["main"]
def network_from_name(name: str) -> dict:
"""Look up an embit network dict (see embit.networks.NETWORKS) by name."""
try:
return NETWORKS[name]
except KeyError as exc:
raise ValueError(
f"Unknown network {name!r}, expected one of {list(NETWORKS)}"
) from exc
class ElectrumError(Exception):
pass
@ -28,6 +44,47 @@ def scripthash_from_scriptpubkey(scriptpubkey: bytes) -> str:
return hashlib.sha256(scriptpubkey).digest()[::-1].hex()
def address_to_scriptpubkey(address: str) -> bytes:
"""Convert a Bitcoin address (P2PKH/P2SH/P2WPKH/P2WSH/P2TR) to scriptPubKey."""
try:
script = Script.from_address(address)
except Exception as exc:
raise ValueError(f"Invalid address: {address!r}") from exc
if script is None:
raise ValueError(f"Invalid address: {address!r}")
return script.data
def scripthash_from_address(address: str) -> str:
return scripthash_from_scriptpubkey(address_to_scriptpubkey(address))
_SCRIPT_TYPE_NAMES = {
"p2pkh": "pubkeyhash",
"p2sh": "scripthash",
"p2wpkh": "witness_v0_keyhash",
"p2wsh": "witness_v0_scripthash",
"p2tr": "witness_v1_taproot",
}
def _scriptpubkey_info(spk: bytes, network: dict) -> tuple[str, str | None]:
"""Return (type, address_or_None) for a scriptPubKey."""
n = len(spk)
# P2PK (not classified by embit)
if n in (35, 67) and spk[-1] == 0xAC:
return "pubkey", None
# OP_RETURN (not classified by embit)
if n >= 1 and spk[0] == 0x6A:
return "nulldata", None
script = Script(spk)
script_type = script.script_type()
if script_type is None:
return "nonstandard", None
return _SCRIPT_TYPE_NAMES[script_type], script.address(network)
# ---------------------------------------------------------------------------
# Response models
# ---------------------------------------------------------------------------
@ -105,6 +162,145 @@ class ServerFeatures(BaseModel):
hosts: dict[str, Any] = {}
class ScriptSig(BaseModel):
hex: str
class ScriptPubKey(BaseModel):
hex: str
type: str
address: str | None = None
class TxInput(BaseModel):
txid: str | None = None
vout: int | None = None
scriptSig: ScriptSig | None = None # noqa: N815
sequence: int
coinbase: str | None = None
class TxOutput(BaseModel):
value: float
n: int
scriptPubKey: ScriptPubKey # noqa: N815
class Transaction(BaseModel):
txid: str
version: int
locktime: int
vin: list[TxInput]
vout: list[TxOutput]
size: int
vsize: int
weight: int
hex: str
class FeeResponse(BaseModel):
estimates: dict[str, float]
histogram: list[FeeHistogramEntry]
class AddressResponse(BaseModel):
balance: Balance
history: list[HistoryEntry]
history_error: str | None = None
class BlockInfo(BaseModel):
height: int
hash: str
timestamp: int
version: int
bits: str
nonce: int
prev_hash: str
merkle_root: str
def parse_block_header(header_hex: str, height: int) -> BlockInfo:
"""Parse an 80-byte block header hex string into a BlockInfo model."""
data = bytes.fromhex(header_hex)
version = struct.unpack_from("<I", data, 0)[0]
prev_hash = data[4:36][::-1].hex()
merkle_root = data[36:68][::-1].hex()
timestamp = struct.unpack_from("<I", data, 68)[0]
bits = format(struct.unpack_from("<I", data, 72)[0], "08x")
nonce = struct.unpack_from("<I", data, 76)[0]
block_hash = hashlib.sha256(hashlib.sha256(data).digest()).digest()[::-1].hex()
return BlockInfo(
height=height,
hash=block_hash,
timestamp=timestamp,
version=version,
bits=bits,
nonce=nonce,
prev_hash=prev_hash,
merkle_root=merkle_root,
)
def parse_raw_tx(hex_str: str, network: dict | None = None) -> Transaction:
"""Parse a raw transaction hex string into a Transaction model."""
network = network or DEFAULT_NETWORK
data = bytes.fromhex(hex_str)
tx = EmbitTransaction.parse(data)
vin: list[TxInput] = []
for inp in tx.vin:
if inp.txid == b"\x00" * 32 and inp.vout == 0xFFFFFFFF:
vin.append(
TxInput(sequence=inp.sequence, coinbase=inp.script_sig.data.hex())
)
else:
vin.append(
TxInput(
txid=inp.txid.hex(),
vout=inp.vout,
scriptSig=ScriptSig(hex=inp.script_sig.data.hex()),
sequence=inp.sequence,
)
)
vout: list[TxOutput] = []
for n_out, out in enumerate(tx.vout):
spk_type, address = _scriptpubkey_info(out.script_pubkey.data, network)
vout.append(
TxOutput(
value=round(out.value / 1e8, 8),
n=n_out,
scriptPubKey=ScriptPubKey(
hex=out.script_pubkey.data.hex(), type=spk_type, address=address
),
)
)
if tx.is_segwit:
# base (non-witness) size = full size minus the segwit marker/flag
# (2 bytes) and each input's witness stack
witness_bytes = sum(len(inp.witness.serialize()) for inp in tx.vin)
base_size = len(data) - 2 - witness_bytes
weight = base_size * 3 + len(data)
vsize = (weight + 3) // 4
else:
weight = len(data) * 4
vsize = len(data)
return Transaction(
txid=tx.txid().hex(),
version=tx.version,
locktime=tx.locktime,
vin=vin,
vout=vout,
size=len(data),
vsize=vsize,
weight=weight,
hex=hex_str,
)
# ---------------------------------------------------------------------------
# Client
# ---------------------------------------------------------------------------
@ -135,6 +331,7 @@ class ElectrumClient:
client_name: str = "lnbits",
protocol_version: str = "1.4",
ping_interval: float = 60.0,
network: dict | None = None,
) -> None:
parsed = urlparse(url)
self.host = parsed.hostname or ""
@ -145,6 +342,7 @@ class ElectrumClient:
self.client_name = client_name
self.protocol_version = protocol_version
self.ping_interval = ping_interval
self.network = network or DEFAULT_NETWORK
self._counter = itertools.count(1)
self._pending: dict[int, asyncio.Future[Any]] = {}
self._subscriptions: dict[str, list[Callable[[list[Any]], Any]]] = {}
@ -152,6 +350,7 @@ class ElectrumClient:
self._ping_task: asyncio.Task[None] | None = None
self._reader: asyncio.StreamReader | None = None
self._writer: asyncio.StreamWriter | None = None
self.closed: asyncio.Event = asyncio.Event()
self.server_version: str = ""
self.negotiated_protocol: str = ""
@ -286,6 +485,7 @@ class ElectrumClient:
except Exception:
logger.exception("Electrum: recv loop error")
finally:
self.closed.set()
for fut in self._pending.values():
if not fut.done():
fut.set_exception(ElectrumError("Connection closed"))
@ -381,10 +581,9 @@ class ElectrumClient:
"""Broadcast a raw transaction hex; returns txid on success."""
return await self._call("blockchain.transaction.broadcast", [raw_tx])
async def get_transaction(
self, txid: str, verbose: bool = False
) -> str | dict[str, Any]:
return await self._call("blockchain.transaction.get", [txid, verbose])
async def get_transaction(self, txid: str) -> str:
"""Fetch raw transaction hex by txid."""
return await self._call("blockchain.transaction.get", [txid])
async def get_merkle(self, txid: str, height: int) -> MerkleProof:
data = await self._call("blockchain.transaction.get_merkle", [txid, height])
@ -437,3 +636,424 @@ class ElectrumClient:
"""Returns mempool fee histogram as FeeHistogramEntry(fee_rate, vsize) list."""
data = await self._call("mempool.get_fee_histogram")
return [FeeHistogramEntry(fee_rate=r[0], vsize=r[1]) for r in data]
# ---------------------------------------------------------------------------
# Address tracking
# ---------------------------------------------------------------------------
class OnchainAddressEvent(BaseModel):
address: str
confirmed: int # satoshis
unconfirmed: int # satoshis
history: list[HistoryEntry] = []
history_error: str | None = None
@property
def txids(self) -> list[str]:
return [e.tx_hash for e in self.history]
class AddressTracker:
"""
Subscribes to a set of Bitcoin addresses over a single shared Electrum
connection and calls a callback on every balance/history change.
Addresses can be added/removed at runtime via :meth:`add`/:meth:`remove`,
and per-connection queues can be attached via :meth:`register_queue` for
consumers (e.g. websockets) that want events for one specific address.
Reconnects automatically on failure.
Args:
url: Electrum server URL (e.g. ``ssl://electrum.blockstream.info:50002``).
"""
def __init__(self, url: str) -> None:
self.url = url
self._ref_counts: dict[str, int] = {}
self._queues: dict[str, list[asyncio.Queue[OnchainAddressEvent]]] = {}
self._updated = asyncio.Event()
def add(self, address: str) -> None:
"""Start tracking an address on the shared connection (ref-counted)."""
count = self._ref_counts.get(address, 0)
self._ref_counts[address] = count + 1
if count == 0:
self._updated.set()
def remove(self, address: str) -> None:
"""Decrement ref count; drop the subscription once the last caller leaves."""
count = self._ref_counts.get(address, 0)
if count <= 1:
self._ref_counts.pop(address, None)
self._updated.set()
else:
self._ref_counts[address] = count - 1
def register_queue(
self, address: str, queue: "asyncio.Queue[OnchainAddressEvent]"
) -> None:
"""Register a per-connection queue to receive events for `address`."""
self._queues.setdefault(address, []).append(queue)
self.add(address)
def unregister_queue(
self, address: str, queue: "asyncio.Queue[OnchainAddressEvent]"
) -> None:
"""Deregister a per-connection queue for `address`."""
queues = self._queues.get(address, [])
if queue in queues:
queues.remove(queue)
if not queues:
self._queues.pop(address, None)
self.remove(address)
async def run(
self,
callback: Callable[[OnchainAddressEvent], Coroutine[Any, Any, None]],
is_active: Callable[[], bool],
) -> None:
while is_active():
try:
await self._run_once(callback, is_active)
except asyncio.CancelledError:
raise
except Exception as exc:
if not is_active():
return
logger.warning(f"AddressTracker: {exc!s}, retrying in 5s")
await asyncio.sleep(5)
async def _run_once(
self,
callback: Callable[[OnchainAddressEvent], Coroutine[Any, Any, None]],
is_active: Callable[[], bool],
) -> None:
async with ElectrumClient(self.url) as client:
subscribed: dict[str, str] = {} # scripthash -> address
async def on_status_change(params: list[Any]) -> None:
if not params:
return
address = subscribed.get(params[0])
if address:
await self._fetch_and_dispatch(client, address, params[0], callback)
client.on("blockchain.scripthash.subscribe", on_status_change)
while is_active():
self._updated.clear()
await self._sync_subscriptions(client, subscribed, callback)
if await self._wait_for_change_or_close(client):
break # connection closed; reconnect
async def _sync_subscriptions(
self,
client: ElectrumClient,
subscribed: dict[str, str],
callback: Callable[[OnchainAddressEvent], Coroutine[Any, Any, None]],
) -> None:
wanted = {a: scripthash_from_address(a) for a in self._ref_counts}
for address, scripthash in wanted.items():
if scripthash not in subscribed:
subscribed[scripthash] = address
await client.subscribe_scripthash(scripthash)
await self._fetch_and_dispatch(client, address, scripthash, callback)
still_wanted = set(wanted.values())
for scripthash, address in list(subscribed.items()):
if address not in still_wanted:
del subscribed[scripthash]
try:
await client.unsubscribe_scripthash(scripthash)
except ElectrumError:
# blockchain.scripthash.unsubscribe is part of the spec but
# many ElectrumX deployments don't implement it, returning
# "unknown method". This is expected and harmless: we've
# already dropped the scripthash from `subscribed` above,
# so if the server keeps pushing notifications for it
# anyway, on_status_change() looks it up, finds nothing,
# and drops them. Not logged since it fires on every
# untrack against these servers.
pass
async def _wait_for_change_or_close(self, client: ElectrumClient) -> bool:
"""Waits until addresses change or the connection closes; returns True
if it was the connection that closed."""
wait_task = asyncio.create_task(self._updated.wait())
closed_task = asyncio.create_task(client.closed.wait())
try:
done, _ = await asyncio.wait(
[wait_task, closed_task],
timeout=30,
return_when=asyncio.FIRST_COMPLETED,
)
finally:
for t in (wait_task, closed_task):
if not t.done():
t.cancel()
return closed_task in done
async def _fetch_and_dispatch(
self,
client: ElectrumClient,
address: str,
scripthash: str,
callback: Callable[[OnchainAddressEvent], Coroutine[Any, Any, None]],
) -> None:
balance_r, history_r, mempool_r = await asyncio.gather(
client.get_balance(scripthash),
client.get_history(scripthash),
client.get_mempool(scripthash),
return_exceptions=True,
)
if isinstance(balance_r, BaseException):
raise balance_r
history: list[HistoryEntry] = (
[] if isinstance(history_r, BaseException) else history_r
)
history_error: str | None = (
str(history_r) if isinstance(history_r, BaseException) else None
)
if not isinstance(mempool_r, BaseException):
seen = {e.tx_hash for e in history}
for m in mempool_r:
if m.tx_hash not in seen:
history.append(HistoryEntry(tx_hash=m.tx_hash, height=0, fee=m.fee))
event = OnchainAddressEvent(
address=address,
confirmed=balance_r.confirmed,
unconfirmed=balance_r.unconfirmed,
history=history,
history_error=history_error,
)
for q in list(self._queues.get(address, [])):
q.put_nowait(event)
await callback(event)
# ---------------------------------------------------------------------------
# Transaction tracking
# ---------------------------------------------------------------------------
class OnchainTxEvent(BaseModel):
txid: str
confirmed: bool
height: int | None = None
fee: int | None = None
def tx_watch_scripthash(tx: Transaction) -> str | None:
"""Return the scripthash of the first spendable output, used to subscribe
for confirmation notifications."""
for out in tx.vout:
if out.scriptPubKey.type != "nulldata":
return scripthash_from_scriptpubkey(bytes.fromhex(out.scriptPubKey.hex))
return None
class TransactionTracker:
"""
Subscribes to a Bitcoin transaction via Electrum and calls a callback on
each status change (unconfirmed confirmed). Stops automatically once
the transaction is confirmed or ``is_active()`` returns ``False``.
Per-connection queues can be attached via :meth:`register_queue` for
consumers (e.g. websockets) that want events for this transaction.
Args:
url: Electrum server URL (e.g. ``ssl://electrum.blockstream.info:50002``).
"""
def __init__(self, url: str) -> None:
self.url = url
self._queues: list[asyncio.Queue[OnchainTxEvent]] = []
def register_queue(self, queue: asyncio.Queue[OnchainTxEvent]) -> None:
"""Register a per-connection queue to receive events for this tx."""
self._queues.append(queue)
def unregister_queue(self, queue: asyncio.Queue[OnchainTxEvent]) -> None:
"""Deregister a per-connection queue."""
if queue in self._queues:
self._queues.remove(queue)
def has_queues(self) -> bool:
return bool(self._queues)
async def track(
self,
txid: str,
callback: Callable[[OnchainTxEvent], Coroutine[Any, Any, None]],
is_active: Callable[[], bool],
) -> None:
while is_active():
try:
confirmed = await self._track_once(txid, callback, is_active)
if confirmed:
return
except asyncio.CancelledError:
raise
except Exception as exc:
if not is_active():
return
logger.warning(
f"TransactionTracker {txid[:8]}: {exc!s}, retrying in 5s"
)
await asyncio.sleep(5)
async def _track_once(
self,
txid: str,
callback: Callable[[OnchainTxEvent], Coroutine[Any, Any, None]],
is_active: Callable[[], bool],
) -> bool:
"""One connection attempt; returns True if the tx is confirmed."""
async with ElectrumClient(self.url) as client:
try:
raw = await client.get_transaction(txid)
except ElectrumError as exc:
logger.warning(f"TransactionTracker {txid[:8]}: {exc!s}")
await asyncio.sleep(10)
return False
scripthash = tx_watch_scripthash(parse_raw_tx(raw))
confirmed_event = asyncio.Event()
async def on_change(
params: list[Any],
_sh: str | None = scripthash,
_done: asyncio.Event = confirmed_event,
) -> None:
if params and params[0] == _sh:
ev = await self._fetch_status(client, txid, _sh)
await self._dispatch(ev, callback)
if ev.confirmed:
_done.set()
if scripthash:
await client.subscribe_scripthash(scripthash, on_change)
event = await self._fetch_status(client, txid, scripthash)
await self._dispatch(event, callback)
if event.confirmed:
return True
while is_active() and not confirmed_event.is_set():
try:
await asyncio.wait_for(client.closed.wait(), timeout=30)
break # connection closed; reconnect
except asyncio.TimeoutError:
pass
return confirmed_event.is_set()
async def _dispatch(
self,
event: OnchainTxEvent,
callback: Callable[[OnchainTxEvent], Coroutine[Any, Any, None]],
) -> None:
for q in list(self._queues):
q.put_nowait(event)
await callback(event)
@staticmethod
async def _fetch_status(
client: ElectrumClient, txid: str, scripthash: str | None
) -> OnchainTxEvent:
if scripthash:
try:
for entry in await client.get_history(scripthash):
if entry.tx_hash == txid:
return OnchainTxEvent(
txid=txid,
confirmed=entry.height > 0,
height=entry.height if entry.height > 0 else None,
fee=entry.fee,
)
except ElectrumError:
try:
for m in await client.get_mempool(scripthash):
if m.tx_hash == txid:
return OnchainTxEvent(txid=txid, confirmed=False, fee=m.fee)
return OnchainTxEvent(txid=txid, confirmed=True)
except ElectrumError:
pass
return OnchainTxEvent(txid=txid, confirmed=False)
# ---------------------------------------------------------------------------
# Block tracking
# ---------------------------------------------------------------------------
class BlockTracker:
"""
Subscribes to new block headers via Electrum and dispatches them to
registered queues. Per-connection queues can be attached via
:meth:`register_queue`. Reconnects automatically on failure.
Args:
url: Electrum server URL (e.g. ``ssl://electrum.blockstream.info:50002``).
"""
def __init__(self, url: str) -> None:
self.url = url
self._queues: list[asyncio.Queue[BlockInfo]] = []
def register_queue(self, queue: "asyncio.Queue[BlockInfo]") -> None:
"""Register a per-connection queue to receive new block events."""
self._queues.append(queue)
def unregister_queue(self, queue: "asyncio.Queue[BlockInfo]") -> None:
"""Deregister a per-connection queue."""
if queue in self._queues:
self._queues.remove(queue)
def has_queues(self) -> bool:
return bool(self._queues)
async def run(
self,
callback: Callable[[BlockInfo], Coroutine[Any, Any, None]],
is_active: Callable[[], bool],
) -> None:
while is_active():
try:
await self._run_once(callback, is_active)
except asyncio.CancelledError:
raise
except Exception as exc:
if not is_active():
return
logger.warning(f"BlockTracker: {exc!s}, retrying in 5s")
await asyncio.sleep(5)
async def _run_once(
self,
callback: Callable[[BlockInfo], Coroutine[Any, Any, None]],
is_active: Callable[[], bool],
) -> None:
async with ElectrumClient(self.url) as client:
async def on_header(params: list[Any]) -> None:
h = params[0]
event = parse_block_header(h["hex"], h["height"])
await self._dispatch(event, callback)
tip = await client.subscribe_headers(on_header)
await self._dispatch(parse_block_header(tip.hex, tip.height), callback)
while is_active():
try:
await asyncio.wait_for(client.closed.wait(), timeout=30)
break # connection closed; reconnect
except asyncio.TimeoutError:
pass
async def _dispatch(
self,
event: BlockInfo,
callback: Callable[[BlockInfo], Coroutine[Any, Any, None]],
) -> None:
for q in list(self._queues):
q.put_nowait(event)
await callback(event)

View file

@ -16,6 +16,7 @@ from .base import (
PaymentStatus,
StatusResponse,
Wallet,
payment_request_was_rejected,
)
@ -146,6 +147,21 @@ class AlbyWallet(Wallet):
return PaymentResponse(
ok=True, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage
)
except httpx.HTTPStatusError as exc:
logger.warning(exc)
rejected = payment_request_was_rejected(exc.response.status_code)
try:
response_message = exc.response.json().get("message", exc.response.text)
except Exception:
response_message = exc.response.text
return PaymentResponse(
ok=False if rejected else None,
error_message=(
response_message
if rejected
else f"Unable to connect to {self.endpoint}."
),
)
except KeyError as exc:
logger.warning(exc)
return PaymentResponse(
@ -185,7 +201,7 @@ class AlbyWallet(Wallet):
# - https://api.getalby.com/invoices/incoming
# - https://api.getalby.com/invoices/outgoing
return PaymentStatus(
statuses[data.get("state")], fee_msat=None, preimage=None
statuses.get(data.get("state")), fee_msat=None, preimage=None
)
except Exception as e:
logger.error(f"Error getting invoice status: {e}")

View file

@ -21,6 +21,7 @@ from .base import (
PaymentSuccessStatus,
StatusResponse,
Wallet,
payment_request_was_rejected,
)
@ -396,7 +397,7 @@ class BarkWallet(Wallet):
logger.warning(message)
return self._pending_payment_response(bolt11, checking_id, message)
except httpx.HTTPStatusError as exc:
if exc.response.is_client_error:
if payment_request_was_rejected(exc.response.status_code):
return PaymentResponse(
ok=False,
checking_id=checking_id,

View file

@ -15,6 +15,14 @@ if TYPE_CHECKING:
from lnbits.nodes.base import Node
def payment_request_was_rejected(status_code: int) -> bool:
"""Return whether HTTP rejected the request before payment dispatch."""
# Generic 400 and 422 responses are provider-specific. They can report an
# existing payment, so adapters must not treat them as terminal based only
# on the status code. Timeouts, conflicts and rate limits are also ambiguous.
return status_code in {401, 403, 404, 405}
class Feature(Enum):
nodemanager = "nodemanager"
holdinvoice = "holdinvoice"

View file

@ -164,9 +164,82 @@ class BlinkWallet(Wallet):
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
async def _fee_probe(self, bolt11: str) -> tuple[int | None, str | None]:
"""
Probe the route for the fee of an amount lightning invoice.
Probing caches the route on the Blink backend so that the subsequent
payment settles with the exact fee instead of Blink's max fee reserve.
Only invoices that carry an amount can be probed here, since the
pay_invoice interface does not provide a separate amount for
zero-amount invoices.
Returns a tuple of (fee_sat, error_message). On success the fee in
satoshis is returned; on failure an error message is returned.
"""
probe_input = {
"paymentRequest": bolt11,
"walletId": self.wallet_id,
}
data = {"query": q.fee_probe_query, "variables": {"input": probe_input}}
response = await self._graphql_query(data)
errors = response.get("errors") or []
if len(errors) > 0:
return None, errors[0].get("message") or "Fee probe failed."
result = (response.get("data") or {}).get("lnInvoiceFeeProbe") or {}
errors = result.get("errors") or []
if len(errors) > 0:
return None, errors[0].get("message") or "Fee probe failed."
fee_sat = result.get("amount")
if fee_sat is None:
return None, "Server error: 'missing fee probe amount'"
return fee_sat, None
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
# https://dev.blink.sv/api/btc-ln-send
# Future: add check fee estimate is < fee_limit_msat before paying invoice
invoice = bolt11_lib.decode(bolt11)
# Only amount invoices can be probed: lnInvoiceFeeProbe takes no amount,
# and zero-amount invoices have no amount to probe with (pay_invoice
# does not receive a separate amount). Zero-amount invoices skip the
# probe and fall back to the send-without-probe behaviour.
try:
if invoice.amount_msat:
fee_sat, probe_error = await self._fee_probe(bolt11)
if probe_error is not None:
if not settings.blink_send_without_probe:
logger.info(f"Fee probe failed for invoice {bolt11}")
return PaymentResponse(ok=False, error_message=probe_error)
logger.warning(
f"Fee probe failed ('{probe_error}'), "
"sending payment without probe."
)
elif fee_sat is not None and fee_sat * 1000 > fee_limit_msat:
error_message = (
f"fee of {fee_sat * 1000} msat exceeds "
f"limit of {fee_limit_msat} msat"
)
return PaymentResponse(ok=False, error_message=error_message)
elif not settings.blink_send_without_probe:
logger.info(f"Cannot probe zero-amount invoice {bolt11}")
return PaymentResponse(
ok=False,
error_message="Cannot probe fee for zero-amount invoice.",
)
except Exception as exc:
if not settings.blink_send_without_probe:
logger.info(f"Failed to probe fee for invoice {bolt11}")
logger.warning(exc)
return PaymentResponse(
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
logger.warning(f"Fee probe errored ('{exc}'), sending without probe.")
payment_variables = {
"input": {
@ -179,22 +252,26 @@ class BlinkWallet(Wallet):
try:
response = await self._graphql_query(data)
errors = (
response.get("data", {})
.get("lnInvoicePaymentSend", {})
.get("errors", {})
)
payment_result = response.get("data", {}).get("lnInvoicePaymentSend", {})
errors = payment_result.get("errors", {})
if len(errors) > 0:
error_message = errors[0].get("message")
return PaymentResponse(ok=False, error_message=error_message)
status = payment_result.get("status")
return PaymentResponse(
ok=False if status in {"FAILURE", "FAILED"} else None,
error_message=error_message,
)
checking_id = bolt11_lib.decode(bolt11).payment_hash
checking_id = invoice.payment_hash
payment_status = await self.get_payment_status(checking_id)
fee_msat = payment_status.fee_msat
preimage = payment_status.preimage
return PaymentResponse(
ok=True, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage
ok=payment_status.paid,
checking_id=checking_id,
fee_msat=fee_msat,
preimage=preimage,
)
except Exception as exc:
logger.info(f"Failed to pay invoice {bolt11}")
@ -365,6 +442,7 @@ class BlinkGrafqlQueries(BaseModel):
balance_query: str
invoice_query: str
payment_query: str
fee_probe_query: str
status_query: str
wallet_query: str
tx_query: str
@ -413,6 +491,16 @@ q = BlinkGrafqlQueries(
}
}
""",
fee_probe_query="""
mutation LnInvoiceFeeProbe($input: LnInvoiceFeeProbeInput!) {
lnInvoiceFeeProbe(input: $input) {
amount
errors {
message
}
}
}
""",
status_query="""
query InvoiceByPaymentHash($walletId: WalletId!, $paymentHash: PaymentHash!) {
me {

View file

@ -2,6 +2,8 @@ import asyncio
from collections.abc import AsyncGenerator
from bolt11.decode import decode
from bolt11.types import Bolt11
from grpc import StatusCode
from grpc.aio import AioRpcError
from loguru import logger
@ -124,26 +126,12 @@ class BoltzWallet(Wallet):
)
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
prepared = await self._prepare_payment(bolt11, fee_limit_msat)
if isinstance(prepared, PaymentResponse):
return prepared
pair, invoice = prepared
pair = boltzrpc_pb2.Pair(**{"from": boltzrpc_pb2.LBTC})
try:
pair_info: boltzrpc_pb2.PairInfo
pair_request = boltzrpc_pb2.GetPairInfoRequest(
type=boltzrpc_pb2.SUBMARINE, pair=pair
)
pair_info = await self.rpc.GetPairInfo(pair_request, metadata=self.metadata)
invoice = decode(bolt11)
if not invoice.amount_msat:
raise ValueError("amountless invoice")
service_fee: float = invoice.amount_msat * pair_info.fees.percentage / 100
estimate = int(service_fee + pair_info.fees.miner_fees * 1000)
if estimate > fee_limit_msat:
error = f"fee of {estimate} msat exceeds limit of {fee_limit_msat} msat"
return PaymentResponse(ok=False, error_message=error)
request = boltzrpc_pb2.CreateSwapRequest(
invoice=bolt11,
pair=pair,
@ -165,8 +153,13 @@ class BoltzWallet(Wallet):
)
return PaymentResponse(ok=True, checking_id=invoice.payment_hash)
except AioRpcError as exc:
return await self._resolve_create_swap_error(invoice, exc)
except Exception as exc:
logger.warning(exc)
return PaymentResponse(ok=False, error_message=exc.details())
return PaymentResponse(
checking_id=invoice.payment_hash,
error_message=str(exc),
)
try:
info_request = boltzrpc_pb2.GetSwapInfoRequest(id=response.id)
@ -186,14 +179,87 @@ class BoltzWallet(Wallet):
fee_msat=fee_msat,
preimage=info.swap.preimage,
)
elif info.swap.error != "":
return PaymentResponse(ok=False, error_message=info.swap.error)
return PaymentResponse(
ok=False, error_message="stream stopped unexpectedly"
)
if info.swap.state in {
boltzrpc_pb2.ERROR,
boltzrpc_pb2.SERVER_ERROR,
boltzrpc_pb2.REFUNDED,
boltzrpc_pb2.ABANDONED,
}:
return PaymentResponse(
ok=False,
checking_id=invoice.payment_hash,
error_message=info.swap.error or "swap failed",
)
return PaymentResponse(error_message="stream stopped unexpectedly")
except AioRpcError as exc:
logger.warning(exc)
return PaymentResponse(ok=False, error_message=exc.details())
return PaymentResponse(error_message=exc.details())
async def _resolve_create_swap_error(
self, invoice: Bolt11, exc: AioRpcError
) -> PaymentResponse:
logger.warning(exc)
if _is_pre_dispatch_create_swap_error(exc):
status: PaymentStatus = PaymentFailedStatus()
else:
try:
status = await self.get_payment_status(invoice.payment_hash)
except Exception as status_exc:
logger.warning(status_exc)
status = PaymentPendingStatus()
return PaymentResponse(
ok=status.paid,
checking_id=invoice.payment_hash,
fee_msat=status.fee_msat,
preimage=status.preimage,
error_message=exc.details(),
)
async def _prepare_payment(
self, bolt11: str, fee_limit_msat: int
) -> tuple[boltzrpc_pb2.Pair, Bolt11] | PaymentResponse:
pair = boltzrpc_pb2.Pair(**{"from": boltzrpc_pb2.LBTC})
try:
invoice = decode(bolt11)
except Exception as exc:
logger.warning(exc)
return PaymentResponse(
ok=False,
error_message=f"invalid bolt11 invoice: {exc}",
)
if not invoice.amount_msat:
return PaymentResponse(
ok=False,
checking_id=invoice.payment_hash,
error_message="amountless invoice",
)
try:
pair_info: boltzrpc_pb2.PairInfo
pair_request = boltzrpc_pb2.GetPairInfoRequest(
type=boltzrpc_pb2.SUBMARINE, pair=pair
)
pair_info = await self.rpc.GetPairInfo(pair_request, metadata=self.metadata)
except Exception as exc:
logger.warning(exc)
return PaymentResponse(
ok=False,
checking_id=invoice.payment_hash,
error_message=f"unable to get swap terms: {exc}",
)
service_fee: float = invoice.amount_msat * pair_info.fees.percentage / 100
estimate = int(service_fee + pair_info.fees.miner_fees * 1000)
if estimate > fee_limit_msat:
error = f"fee of {estimate} msat exceeds limit of {fee_limit_msat} msat"
return PaymentResponse(
ok=False,
checking_id=invoice.payment_hash,
error_message=error,
)
return pair, invoice
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try:
@ -217,10 +283,14 @@ class BoltzWallet(Wallet):
fee_msat=fee_msat,
preimage=swap.preimage,
)
elif swap.state == boltzrpc_pb2.SwapState.PENDING:
return PaymentPendingStatus()
return PaymentFailedStatus()
if swap.state in {
boltzrpc_pb2.SwapState.ERROR,
boltzrpc_pb2.SwapState.SERVER_ERROR,
boltzrpc_pb2.SwapState.REFUNDED,
boltzrpc_pb2.SwapState.ABANDONED,
}:
return PaymentFailedStatus()
return PaymentPendingStatus()
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
try:
@ -231,7 +301,7 @@ class BoltzWallet(Wallet):
metadata=self.metadata,
)
swap = response.swap
except AioRpcError as exc:
except (AioRpcError, ValueError) as exc:
logger.warning(exc)
return PaymentPendingStatus()
if swap.state == boltzrpc_pb2.SwapState.SUCCESSFUL:
@ -243,10 +313,14 @@ class BoltzWallet(Wallet):
fee_msat=fee_msat,
preimage=swap.preimage,
)
elif swap.state == boltzrpc_pb2.SwapState.PENDING:
return PaymentPendingStatus()
return PaymentFailedStatus()
if swap.state in {
boltzrpc_pb2.SwapState.ERROR,
boltzrpc_pb2.SwapState.SERVER_ERROR,
boltzrpc_pb2.SwapState.REFUNDED,
boltzrpc_pb2.SwapState.ABANDONED,
}:
return PaymentFailedStatus()
return PaymentPendingStatus()
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while settings.lnbits_running:
@ -352,3 +426,23 @@ class BoltzWallet(Wallet):
except Exception as e:
logger.error(f"❌ Failed to create Boltz wallet: {e}")
_PRE_DISPATCH_CREATE_SWAP_ERROR_CODES = {
StatusCode.INVALID_ARGUMENT,
StatusCode.PERMISSION_DENIED,
StatusCode.UNAUTHENTICATED,
}
_PRE_DISPATCH_CREATE_SWAP_ERROR_MESSAGES = (
"boltz error: could not find route to pay invoice",
)
def _is_pre_dispatch_create_swap_error(exc: AioRpcError) -> bool:
if exc.code() in _PRE_DISPATCH_CREATE_SWAP_ERROR_CODES:
return True
details = (exc.details() or "").lower()
return any(
message in details for message in _PRE_DISPATCH_CREATE_SWAP_ERROR_MESSAGES
)

View file

@ -240,6 +240,12 @@ else:
logger.info(ex)
return PaymentResponse(error_message=f"exception while payment {exc!s}")
if payment.status == BreezPaymentStatus.FAILED:
return PaymentResponse(
ok=False,
checking_id=invoice.payment_hash,
error_message="payment failed",
)
if payment.status != BreezPaymentStatus.COMPLETE:
return PaymentResponse(ok=None, error_message="payment is pending")

View file

@ -173,29 +173,44 @@ else:
async def pay_invoice(
self, bolt11: str, fee_limit_msat: int
) -> PaymentResponse:
invoice_data = bolt11_decode(bolt11)
try:
invoice_data = bolt11_decode(bolt11)
except Exception as exc:
logger.warning(exc)
return PaymentResponse(
ok=False,
error_message=f"invalid bolt11 invoice: {exc}",
)
try:
prepare_req = PrepareSendRequest(destination=bolt11)
req = self.sdk_services.prepare_send_payment(prepare_req)
fee_limit_sat = settings.breez_liquid_fee_offset_sat + int(
fee_limit_msat / 1000
except Exception as exc:
logger.warning(exc)
return PaymentResponse(
ok=False,
checking_id=invoice_data.payment_hash,
error_message=f"unable to prepare payment: {exc}",
)
if req.fees_sat and req.fees_sat > fee_limit_sat:
return PaymentResponse(
ok=False,
error_message=(
f"fee of {req.fees_sat} sat exceeds limit of "
f"{fee_limit_sat} sat"
),
)
fee_limit_sat = settings.breez_liquid_fee_offset_sat + int(
fee_limit_msat / 1000
)
if req.fees_sat and req.fees_sat > fee_limit_sat:
return PaymentResponse(
ok=False,
checking_id=invoice_data.payment_hash,
error_message=(
f"fee of {req.fees_sat} sat exceeds limit of "
f"{fee_limit_sat} sat"
),
)
try:
send_response = self.sdk_services.send_payment(
SendPaymentRequest(prepare_response=req)
)
except Exception as exc:
logger.warning(exc)
return PaymentResponse(error_message=f"Exception while payment: {exc}")
@ -206,6 +221,14 @@ else:
fees = req.fees_sat * 1000 if req.fees_sat and req.fees_sat > 0 else 0
if payment.status in {PaymentState.FAILED, PaymentState.TIMED_OUT}:
return PaymentResponse(
ok=False,
checking_id=checking_id,
fee_msat=fees,
error_message=f"payment {payment.status!s}",
)
if payment.status != PaymentState.COMPLETE:
return await self._wait_for_outgoing_payment(checking_id, fees, 10)
@ -262,7 +285,10 @@ else:
fee_msat=int(payment.fees_sat * 1000),
preimage=payment.details.preimage,
)
if payment.status == PaymentState.FAILED:
if payment.status in {
PaymentState.FAILED,
PaymentState.TIMED_OUT,
}:
return PaymentFailedStatus()
return PaymentPendingStatus()
except Exception as exc:

View file

@ -104,41 +104,42 @@ class ClicheWallet(Wallet):
)
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
ws = create_connection(self.endpoint)
ws.send(f"pay-invoice --invoice {bolt11}")
checking_id, fee_msat, preimage, payment_ok = (
None,
None,
None,
None,
)
for _ in range(2):
r = ws.recv()
data = json.loads(r)
try:
ws = create_connection(self.endpoint)
ws.send(f"pay-invoice --invoice {bolt11}")
checking_id, fee_msat, preimage, payment_ok = (
None,
None,
None,
None,
)
for _ in range(2):
r = ws.recv()
data = json.loads(r)
if data.get("error") is not None:
error_message = data["error"].get("message")
return PaymentResponse(ok=False, error_message=error_message)
if data.get("error") is not None:
error_message = data["error"].get("message")
return PaymentResponse(error_message=error_message)
if data.get("method") == "payment_succeeded":
payment_ok = True
checking_id = data["params"]["payment_hash"]
fee_msat = data["params"]["fee_msatoshi"]
preimage = data["params"]["preimage"]
continue
if data.get("method") == "payment_succeeded":
payment_ok = True
checking_id = data["params"]["payment_hash"]
fee_msat = data["params"]["fee_msatoshi"]
preimage = data["params"]["preimage"]
continue
if data.get("result") is None:
return PaymentResponse(error_message="result is None")
if data.get("result") is None:
return PaymentResponse(error_message="result is None")
return PaymentResponse(
ok=payment_ok, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage
)
return PaymentResponse(
ok=payment_ok,
checking_id=checking_id,
fee_msat=fee_msat,
preimage=preimage,
)
except Exception as exc:
logger.warning(exc)
return PaymentResponse(error_message=f"Unable to query {self.endpoint}.")
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
ws = create_connection(self.endpoint)
@ -154,21 +155,25 @@ class ClicheWallet(Wallet):
return PaymentStatus(statuses[data["result"]["status"]])
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
ws = create_connection(self.endpoint)
ws.send(f"check-payment --hash {checking_id}")
r = ws.recv()
data = json.loads(r)
try:
ws = create_connection(self.endpoint)
ws.send(f"check-payment --hash {checking_id}")
r = ws.recv()
data = json.loads(r)
if data.get("error") is not None and data["error"].get("message"):
logger.error(data["error"]["message"])
if data.get("error") is not None and data["error"].get("message"):
logger.error(data["error"]["message"])
return PaymentPendingStatus()
payment = data["result"]
statuses = {"pending": None, "complete": True, "failed": False}
return PaymentStatus(
statuses.get(payment.get("status")),
payment.get("fee_msatoshi"),
payment.get("preimage"),
)
except Exception as exc:
logger.warning(exc)
return PaymentPendingStatus()
payment = data["result"]
statuses = {"pending": None, "complete": True, "failed": False}
return PaymentStatus(
statuses[payment["status"]],
payment.get("fee_msatoshi"),
payment.get("preimage"),
)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while settings.lnbits_running:

View file

@ -19,6 +19,7 @@ from lnbits.utils.crypto import random_secret_and_hash
from .base import (
InvoiceResponse,
PaymentFailedStatus,
PaymentPendingStatus,
PaymentResponse,
PaymentStatus,
@ -379,9 +380,12 @@ class CLNRestWallet(Wallet):
pay = pays_list[-1]
if pay["status"] == "complete":
status = pay.get("status")
if status == "complete":
fee_msat = pay["amount_sent_msat"] - pay["amount_msat"]
return PaymentSuccessStatus(fee_msat=fee_msat, preimage=pay["preimage"])
if status == "failed":
return PaymentFailedStatus()
except Exception as exc:
logger.warning(f"Error getting payment status: {exc}")

View file

@ -31,6 +31,21 @@ async def run_sync(func) -> Any:
return await loop.run_in_executor(None, func)
def _all_payment_attempts_failed(error: object) -> bool:
if not isinstance(error, dict):
return False
attempts = error.get("attempts")
return (
isinstance(attempts, list)
and bool(attempts)
and all(
isinstance(attempt, dict) and attempt.get("status") == "failed"
for attempt in attempts
)
)
class CoreLightningWallet(Wallet):
"""Core Lightning RPC implementation."""
@ -184,7 +199,9 @@ class CoreLightningWallet(Wallet):
logger.warning(exc)
try:
error_code = exc.error.get("code") # type: ignore
if error_code in self.pay_failure_error_codes:
if error_code in self.pay_failure_error_codes or (
_all_payment_attempts_failed(exc.error)
):
error_message = exc.error.get("message", error_code) # type: ignore
return PaymentResponse(
ok=False, error_message=f"Payment failed: {error_message}"

View file

@ -22,6 +22,7 @@ from .base import (
PaymentStatus,
StatusResponse,
Wallet,
payment_request_was_rejected,
)
@ -165,6 +166,24 @@ class EclairWallet(Wallet):
checking_id = data["paymentHash"]
preimage = data["paymentPreimage"]
except httpx.HTTPStatusError as exc:
error_message = f"Unable to connect to {self.url}."
try:
error_data = exc.response.json()
if isinstance(error_data, dict) and error_data.get("error"):
error_message = str(error_data["error"])
except json.JSONDecodeError:
pass
# Eclair uses HTTP 400 for invoice and form validation failures,
# which happen before it dispatches the payment.
rejected = exc.response.status_code == 400 or payment_request_was_rejected(
exc.response.status_code
)
return PaymentResponse(
ok=False if rejected else None,
error_message=error_message,
)
except json.JSONDecodeError:
return PaymentResponse(
error_message="Server error: 'invalid json response'"

View file

@ -198,9 +198,15 @@ class LndWallet(Wallet):
)
try:
res: Payment = await self.router_rpc.SendPaymentV2(req).read()
except grpc.aio.AioRpcError as exc:
logger.warning(exc)
return PaymentResponse(
ok=False if _is_pre_dispatch_payment_error(exc) else None,
error_message=exc.details() or str(exc),
)
except Exception as exc:
logger.warning(exc)
return PaymentResponse(error_message=str(exc))
return PaymentResponse(ok=None, error_message=str(exc))
if res.status == Payment.PaymentStatus.SUCCEEDED:
return PaymentResponse(
@ -378,3 +384,22 @@ class LndWallet(Wallet):
)
# If we reach here, the invoice was successfully canceled and payment failed
return InvoiceResponse(True, checking_id=payment_hash)
_PRE_DISPATCH_PAYMENT_ERROR_CODES = {
grpc.StatusCode.INVALID_ARGUMENT,
grpc.StatusCode.PERMISSION_DENIED,
grpc.StatusCode.UNAUTHENTICATED,
}
_PRE_DISPATCH_PAYMENT_ERROR_MESSAGES = (
"invoice not for current active network",
"invoice expired",
)
def _is_pre_dispatch_payment_error(exc: grpc.aio.AioRpcError) -> bool:
if exc.code() in _PRE_DISPATCH_PAYMENT_ERROR_CODES:
return True
details = (exc.details() or "").lower()
return any(message in details for message in _PRE_DISPATCH_PAYMENT_ERROR_MESSAGES)

View file

@ -26,6 +26,42 @@ from .base import (
)
from .macaroon import load_macaroon
_PRE_DISPATCH_PAYMENT_ERROR_CODES = {3, 7, 16}
_PRE_DISPATCH_PAYMENT_ERROR_MESSAGES = (
"invoice not for current active network",
"invoice expired",
)
def _is_pre_dispatch_payment_error(code: int | None, message: str) -> bool:
# LND's REST gateway uses the numeric gRPC status codes. UNKNOWN (2)
# is ambiguous unless LND returned one of its request-validation errors.
if code in _PRE_DISPATCH_PAYMENT_ERROR_CODES:
return True
message = message.lower()
return any(error in message for error in _PRE_DISPATCH_PAYMENT_ERROR_MESSAGES)
def _payment_response_from_http_error(
exc: httpx.HTTPStatusError, endpoint: str
) -> PaymentResponse:
try:
error = exc.response.json()["error"]
error_code = error.get("code")
error_message = str(error.get("message") or exc)
except (json.JSONDecodeError, KeyError, TypeError, AttributeError):
error_code = None
error_message = f"Unable to connect to {endpoint}."
logger.warning(f"LndRestWallet pay_invoice POST error: {error_message}.")
return PaymentResponse(
ok=(
False if _is_pre_dispatch_payment_error(error_code, error_message) else None
),
error_message=error_message,
)
class LndRestWallet(Wallet):
"""https://api.lightning.community/#lnd-rest-api-reference"""
@ -162,6 +198,8 @@ class LndRestWallet(Wallet):
)
r.raise_for_status()
data = r.json()
except httpx.HTTPStatusError as exc:
return _payment_response_from_http_error(exc, self.endpoint)
except json.JSONDecodeError:
return PaymentResponse(
error_message="Server error: 'invalid json response'"
@ -201,7 +239,7 @@ class LndRestWallet(Wallet):
elif status == "IN_FLIGHT":
return PaymentResponse(ok=None, checking_id=checking_id)
return PaymentResponse(
ok=False,
ok=None,
checking_id=checking_id,
error_message="Server error: 'unknown payment status returned'",
)

View file

@ -15,6 +15,7 @@ from .base import (
PaymentStatus,
StatusResponse,
Wallet,
payment_request_was_rejected,
)
@ -105,44 +106,58 @@ class LNPayWallet(Wallet):
)
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
r = await self.client.post(
f"/wallet/{self.wallet_key}/withdraw",
json={"payment_request": bolt11},
timeout=None,
)
try:
r = await self.client.post(
f"/wallet/{self.wallet_key}/withdraw",
json={"payment_request": bolt11},
timeout=None,
)
except Exception as exc:
logger.warning(exc)
return PaymentResponse(error_message="Unable to connect to LNPay.")
try:
data = r.json()
except Exception:
return PaymentResponse(ok=False, error_message="Got invalid JSON.")
return PaymentResponse(error_message="Got invalid JSON.")
if r.is_error:
return PaymentResponse(ok=False, error_message=data["message"])
return PaymentResponse(
ok=False if payment_request_was_rejected(r.status_code) else None,
error_message=data.get("message", r.text),
)
checking_id = data["lnTx"]["id"]
fee_msat = 0
preimage = data["lnTx"]["payment_preimage"]
try:
checking_id = data["lnTx"]["id"]
preimage = data["lnTx"]["payment_preimage"]
except (KeyError, TypeError):
return PaymentResponse(
error_message="LNPay response is missing required payment fields."
)
return PaymentResponse(
ok=True, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage
ok=True, checking_id=checking_id, fee_msat=0, preimage=preimage
)
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(
url=f"/lntx/{checking_id}",
)
try:
r = await self.client.get(
url=f"/lntx/{checking_id}",
)
if r.is_error:
return PaymentPendingStatus()
if r.is_error:
data = r.json()
paid = {0: None, 1: True, -1: False}.get(data.get("settled"))
return PaymentStatus(
paid, data.get("fee_msat"), data.get("payment_preimage")
)
except Exception as exc:
logger.warning(exc)
return PaymentPendingStatus()
data = r.json()
preimage = data["payment_preimage"]
fee_msat = data["fee_msat"]
statuses = {0: None, 1: True, -1: False}
return PaymentStatus(statuses[data["settled"]], fee_msat, preimage)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
self.queue: asyncio.Queue = asyncio.Queue(0)
while settings.lnbits_running:

View file

@ -17,6 +17,7 @@ from .base import (
PaymentStatus,
StatusResponse,
Wallet,
payment_request_was_rejected,
)
@ -103,26 +104,43 @@ class LnTipsWallet(Wallet):
)
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
r = await self.client.post(
"/api/v1/payinvoice",
json={"pay_req": bolt11},
timeout=None,
)
try:
r = await self.client.post(
"/api/v1/payinvoice",
json={"pay_req": bolt11},
timeout=None,
)
except Exception as exc:
logger.warning(exc)
return PaymentResponse(
error_message=f"Unable to connect to {self.endpoint}."
)
if r.is_error:
return PaymentResponse(ok=False, error_message=r.text)
return PaymentResponse(
ok=False if payment_request_was_rejected(r.status_code) else None,
error_message=r.text,
)
if "error" in r.json():
try:
data = r.json()
error_message = data["error"]
except Exception:
error_message = r.text
return PaymentResponse(ok=False, error_message=error_message)
try:
response = r.json()
except json.JSONDecodeError:
return PaymentResponse(
error_message="Server error: 'invalid json response'"
)
data = r.json()["details"]
checking_id = data["payment_hash"]
fee_msat = -data["fee"]
preimage = data["preimage"]
if "error" in response:
error_message = response.get("error") or r.text
return PaymentResponse(error_message=error_message)
try:
data = response["details"]
checking_id = data["payment_hash"]
fee_msat = -data["fee"]
preimage = data["preimage"]
except (KeyError, TypeError):
return PaymentResponse(
error_message="Server error: 'missing required fields'"
)
return PaymentResponse(
ok=True, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage
)

View file

@ -625,8 +625,6 @@ class NWCWallet(Wallet):
"QUOTA_EXCEEDED",
"RESTRICTED",
"UNAUTHORIZED",
"INTERNAL",
"OTHER",
"PAYMENT_FAILED",
]
failed = e.code in failure_codes

View file

@ -15,6 +15,7 @@ from .base import (
PaymentStatus,
StatusResponse,
Wallet,
payment_request_was_rejected,
)
@ -100,24 +101,38 @@ class OpenNodeWallet(Wallet):
)
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
r = await self.client.post(
"/v2/withdrawals",
json={"type": "ln", "address": bolt11},
timeout=None,
)
try:
r = await self.client.post(
"/v2/withdrawals",
json={"type": "ln", "address": bolt11},
timeout=None,
)
if r.is_error:
error_message = r.json()["message"]
logger.warning(error_message)
return PaymentResponse(ok=None, error_message=error_message)
if r.is_error:
error_message = r.json().get("message", r.text)
logger.warning(error_message)
return PaymentResponse(
ok=(False if payment_request_was_rejected(r.status_code) else None),
error_message=error_message,
)
data = r.json()["data"]
checking_id = data["id"]
fee_msat = -data["fee"] * 1000
# pending
if data["status"] != "paid":
data = r.json()["data"]
checking_id = data.get("id")
fee = data.get("fee")
fee_msat = -fee * 1000 if fee is not None else None
status = str(data.get("status", "")).lower()
if status in {"paid", "confirmed"}:
return PaymentResponse(
ok=True, checking_id=checking_id, fee_msat=fee_msat
)
if status in {"error", "failed"}:
return PaymentResponse(
ok=False, checking_id=checking_id, fee_msat=fee_msat
)
return PaymentResponse(ok=None, checking_id=checking_id, fee_msat=fee_msat)
return PaymentResponse(ok=True, checking_id=checking_id, fee_msat=fee_msat)
except Exception as exc:
logger.warning(exc)
return PaymentResponse(error_message="Invalid OpenNode payment response.")
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
r = await self.client.get(f"/v1/charge/{checking_id}")
@ -128,22 +143,26 @@ class OpenNodeWallet(Wallet):
return PaymentStatus(statuses[data.get("status")])
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
r = await self.client.get(f"/v1/withdrawal/{checking_id}")
try:
r = await self.client.get(f"/v1/withdrawal/{checking_id}")
if r.is_error:
return PaymentPendingStatus()
if r.is_error:
data = r.json()["data"]
statuses = {
"initial": None,
"pending": None,
"confirmed": True,
"error": False,
"failed": False,
}
fee = data.get("fee")
fee_msat = -fee * 1000 if fee is not None else None
return PaymentStatus(statuses.get(data.get("status")), fee_msat)
except Exception as exc:
logger.warning(exc)
return PaymentPendingStatus()
data = r.json()["data"]
statuses = {
"initial": None,
"pending": None,
"confirmed": True,
"error": None,
"failed": False,
}
fee_msat = -data.get("fee") * 1000
return PaymentStatus(statuses[data.get("status")], fee_msat)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
self.queue: asyncio.Queue = asyncio.Queue(0)
while settings.lnbits_running:

View file

@ -208,11 +208,11 @@ class PhoenixdWallet(Wallet):
logger.warning(msg)
return PaymentResponse(ok=None, error_message=msg)
except RequestError as exc:
# RequestError is raised when the request never hit the destination server
# RequestError can also be raised after the server received the request.
msg = f"Unable to connect to {self.endpoint}."
logger.warning(msg)
logger.warning(exc)
return PaymentResponse(ok=False, error_message=msg)
return PaymentResponse(ok=None, error_message=msg)
except Exception as exc:
logger.warning(exc)
return PaymentResponse(

View file

@ -162,11 +162,16 @@ class SparkWallet(Wallet):
)
except (SparkError, UnknownError) as exc:
listpays = await self.listpays(bolt11)
try:
listpays = await self.listpays(bolt11)
except (SparkError, UnknownError):
return PaymentResponse(error_message=str(exc))
if not listpays:
return PaymentResponse(ok=False, error_message=str(exc))
return PaymentResponse(error_message=str(exc))
pays = listpays["pays"]
pays = listpays.get("pays")
if not isinstance(pays, list):
return PaymentResponse(error_message=str(exc))
if len(pays) == 0:
return PaymentResponse(ok=False, error_message=str(exc))
@ -175,10 +180,12 @@ class SparkWallet(Wallet):
payment_hash = pay["payment_hash"]
if len(pays) > 1:
raise SparkError(
f"listpays({payment_hash}) returned an unexpected response:"
f" {listpays}"
) from exc
return PaymentResponse(
error_message=(
f"listpays({payment_hash}) returned an unexpected response:"
f" {listpays}"
)
)
if pay["status"] == "failed":
return PaymentResponse(ok=False, error_message=str(exc))
@ -203,7 +210,7 @@ class SparkWallet(Wallet):
preimage=preimage,
)
else:
return PaymentResponse(ok=False, error_message=str(exc))
return PaymentResponse(error_message=str(exc))
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try:
@ -214,10 +221,12 @@ class SparkWallet(Wallet):
if not r or not r.get("invoices"):
return PaymentPendingStatus()
if r["invoices"][0]["status"] == "paid":
status = r["invoices"][0]["status"]
if status == "paid":
return PaymentSuccessStatus()
else:
if status == "expired":
return PaymentFailedStatus()
return PaymentPendingStatus()
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
# check if it's 32 bytes hex
@ -249,7 +258,8 @@ class SparkWallet(Wallet):
if status == "failed":
return PaymentFailedStatus()
return PaymentPendingStatus()
raise KeyError("supplied an invalid checking_id")
logger.warning(f"supplied an invalid checking_id: {checking_id}")
return PaymentPendingStatus()
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
url = f"/stream?access-key={self.token}"

View file

@ -162,7 +162,6 @@ class SparkL2Wallet(Wallet):
checking_id = res.get("checking_id")
if not checking_id:
return PaymentResponse(
ok=False,
error_message="Spark sidecar payment response missing checking_id.",
)
status = res.get("status")
@ -178,7 +177,7 @@ class SparkL2Wallet(Wallet):
)
except Exception as e:
return PaymentResponse(ok=False, error_message=str(e))
return PaymentResponse(error_message=str(e))
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try:

View file

@ -237,50 +237,59 @@ class StrikeWallet(Wallet):
ok=False, error_message=f"Invalid invoice: {decode_exc!s}"
)
# Creating a quote cannot make the payment. Any failure before the execute
# request is therefore a definite failure of this payment attempt.
try:
# 1) Create a payment quote
quote_id, error = await self._create_payment_quote(bolt11)
if error or not quote_id:
return PaymentResponse(ok=False, error_message=error or "Unknown error")
except Exception as exc:
logger.warning(f"Strike quote creation exception: {exc}", exc_info=True)
return PaymentResponse(
ok=False,
error_message=f"Failed to create payment quote: {exc!s}",
)
try:
# Keep the quote id while this process is running. Strike only documents
# payment status lookup by payment id, which an ambiguous execute request
# may not return.
self.pending_payments[payment_hash] = quote_id
# 2) Execute the payment quote
data, error = await self._execute_payment_quote(quote_id)
if error or not data:
return PaymentResponse(ok=False, error_message=error or "Unknown error")
return PaymentResponse(error_message=error or "Unknown error")
state = data.get("state", "").upper()
payment_id = data.get("paymentId")
checking_id = payment_id or payment_hash
# Parse fee
fee_msat = self._parse_payment_fee(data, payment_id or "")
fee_msat = self._parse_payment_fee(data, checking_id)
# Handle successful payment
if state in {"SUCCEEDED", "COMPLETED"}:
preimage = self._extract_preimage(data)
return PaymentResponse(
ok=True,
checking_id=payment_hash,
checking_id=checking_id,
fee_msat=fee_msat,
preimage=preimage,
)
# Handle failed payment
failed_states = {"CANCELED", "FAILED", "TIMED_OUT"}
if state in failed_states:
if state == "FAILED":
logger.warning(
f"Strike payment {payment_id} failed with state: {state}"
)
return PaymentResponse(
ok=False,
checking_id=payment_hash,
checking_id=checking_id,
error_message=f"Payment {state.lower()}",
)
# Store mapping for later polling
self.pending_payments[payment_hash] = quote_id
# Treat all other states as pending
return PaymentResponse(ok=None, checking_id=payment_hash)
return PaymentResponse(ok=None, checking_id=payment_id)
except httpx.HTTPStatusError as http_exc:
logger.warning(f"Strike HTTP error during payment: {http_exc}")
@ -289,7 +298,6 @@ class StrikeWallet(Wallet):
f"body: {http_exc.response.text}"
)
return PaymentResponse(
ok=False,
error_message=f"Strike API error: {http_exc.response.status_code}",
)
except Exception as e:
@ -343,7 +351,8 @@ class StrikeWallet(Wallet):
quote_id = self.pending_payments.get(checking_id)
try:
# Attempt 1: Use quote_id if available (from in-memory store)
# A quote id can only be associated with an invoice hash while this
# process is running. Persisted payment ids are checked below.
if quote_id:
status = await self._get_payment_status_by_quote_id(
checking_id, quote_id
@ -527,10 +536,8 @@ class StrikeWallet(Wallet):
return None, error_msg
data = e.json() if e.content else {}
payment_id = data.get("paymentId")
if not payment_id:
if not data.get("paymentId"):
logger.warning(f"Strike: missing paymentId in response: {data}")
return None, "Strike: missing paymentId in response"
return data, None
@ -629,7 +636,7 @@ class StrikeWallet(Wallet):
self.pending_payments.pop(checking_id, None)
return PaymentFailedStatus()
return None
return PaymentPendingStatus()
async def _get_payment_status_by_checking_id( # noqa: C901
self, checking_id: str
@ -693,16 +700,28 @@ class StrikeWallet(Wallet):
continue
logger.warning(
f"Payment '{checking_id}' not a valid Strike payment. "
f"Marked as failed. Response: {r_payment.text}"
"Keeping pending because it may be the invoice payment "
f"hash fallback. Response: {r_payment.text}"
)
self.pending_payments.pop(checking_id, None)
return PaymentFailedStatus()
return PaymentPendingStatus()
except Exception as e:
logger.warning(e)
return PaymentPendingStatus()
if r_payment.status_code == 404:
if len(checking_id) == 64:
try:
bytes.fromhex(checking_id)
logger.warning(
f"Payment '{checking_id}' not found, but the identifier may "
"be a legacy invoice payment hash. Keeping pending."
)
return PaymentPendingStatus()
except ValueError as exc:
logger.warning(
f"Payment identifier '{checking_id}' is not valid hex: {exc}"
)
logger.warning(f"Payment {checking_id} not found. Marking as failed.")
self.pending_payments.pop(checking_id, None)
return PaymentFailedStatus()

View file

@ -3,7 +3,6 @@ import hashlib
from collections.abc import AsyncGenerator
import httpx
from bolt11 import decode as bolt11_decode
from loguru import logger
from lnbits.helpers import normalize_endpoint
@ -16,6 +15,7 @@ from .base import (
PaymentStatus,
StatusResponse,
Wallet,
payment_request_was_rejected,
)
@ -105,30 +105,62 @@ class ZBDWallet(Wallet):
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
# https://api.zebedee.io/v0/payments
r = await self.client.post(
"payments",
json={
"invoice": bolt11,
"description": "",
"amount": "",
"internalId": "",
"callbackUrl": "",
},
timeout=40,
)
try:
r = await self.client.post(
"payments",
json={
"invoice": bolt11,
"description": "",
"amount": "",
"internalId": "",
"callbackUrl": "",
},
timeout=40,
)
except Exception as exc:
logger.warning(exc)
return PaymentResponse(error_message="Unable to query ZBD.")
if r.is_error:
error_message = r.json()["message"]
return PaymentResponse(ok=False, error_message=error_message)
try:
error_message = r.json().get("message", r.text)
except Exception:
error_message = r.text
return PaymentResponse(
ok=False if payment_request_was_rejected(r.status_code) else None,
error_message=error_message,
)
data = r.json()
checking_id = bolt11_decode(bolt11).payment_hash
fee_msat = -int(data["data"]["fee"])
preimage = data["data"]["preimage"]
try:
data = r.json()["data"]
checking_id = data.get("id")
fee = data.get("fee")
fee_msat = -int(fee) if fee is not None else None
preimage = data.get("preimage")
status = str(data.get("status", "")).lower()
except Exception as exc:
logger.warning(exc)
return PaymentResponse(error_message="Invalid ZBD payment response.")
if status == "completed":
return PaymentResponse(
ok=True,
checking_id=checking_id,
fee_msat=fee_msat,
preimage=preimage,
)
if status in {"failed", "expired"}:
return PaymentResponse(
ok=False,
checking_id=checking_id,
fee_msat=fee_msat,
error_message=data.get("errorMessage"),
)
return PaymentResponse(
ok=True, checking_id=checking_id, fee_msat=fee_msat, preimage=preimage
ok=None,
checking_id=checking_id,
fee_msat=fee_msat,
error_message=data.get("errorMessage"),
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
@ -147,11 +179,20 @@ class ZBDWallet(Wallet):
return PaymentStatus(paid=statuses[data.get("status")])
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
r = await self.client.get(f"payments/{checking_id}")
try:
r = await self.client.get(f"payments/{checking_id}")
except Exception as exc:
logger.warning(exc)
return PaymentPendingStatus()
if r.is_error:
return PaymentPendingStatus()
data = r.json()["data"]
try:
data = r.json()["data"]
except Exception as exc:
logger.warning(exc)
return PaymentPendingStatus()
statuses = {
"initial": None,
@ -161,8 +202,7 @@ class ZBDWallet(Wallet):
"expired": False,
"failed": False,
}
return PaymentStatus(paid=statuses[data.get("status")])
return PaymentStatus(paid=statuses.get(data.get("status")))
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
self.queue: asyncio.Queue = asyncio.Queue(0)

View file

@ -129,6 +129,8 @@
"js/components/admin/lnbits-admin-site-customisation.js",
"js/components/admin/lnbits-admin-assets-config.js",
"js/components/admin/lnbits-admin-audit.js",
"js/components/admin/lnbits-admin-blockexplorer.js",
"js/pages/blockexplorer.js",
"js/components/lnbits-wallet-charts.js",
"js/components/lnbits-wallet-api-docs.js",
"js/components/lnbits-wallet-icon.js",

14
poetry.lock generated
View file

@ -3954,6 +3954,18 @@ files = [
{file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"},
]
[[package]]
name = "random-username"
version = "1.0.2"
description = "Randomly generate compelling usernames."
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "random-username-1.0.2.tar.gz", hash = "sha256:5fdc0604b5d1bdfe4acf4cd7491a9de1caf41bbdd890f646b434e09ae2a1b7ce"},
{file = "random_username-1.0.2-py3-none-any.whl", hash = "sha256:2536feb63fecde7e01ede4a541aadb6f0b58794a7ab327ca5369d2a4b7664c06"},
]
[[package]]
name = "referencing"
version = "0.36.2"
@ -5184,4 +5196,4 @@ migration = ["psycopg2-binary"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.10,<3.13"
content-hash = "b167368cde275d9b12ea2c352a9ca2558da1aa9476ada0795cfd4f45d2ca72bc"
content-hash = "e41fd327115a1614f2e7b5dae7d7077e30f1cb723e1880a88132ec38df2b4b1a"

View file

@ -1,6 +1,6 @@
[project]
name = "lnbits"
version = "1.5.6"
version = "1.6.0-rc2"
requires-python = ">=3.10,<3.13"
description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
@ -54,6 +54,7 @@ dependencies = [
"urllib3>=2.7.0",
"pyinstrument>=5.1.2",
"wasmtime>=45.0.0",
"random-username~=1.0.2",
]
[project.scripts]

View file

@ -233,6 +233,40 @@ async def test_create_fiat_invoice(
assert invoice["extra"]["fiat_payment_request"] == fiat_payment_request
@pytest.mark.anyio
async def test_create_fiat_subscription_invoice_rejected(
client, inkey_headers_to, mocker: MockerFixture
):
fiat_mock = mocker.patch(
"lnbits.core.services.payments.create_fiat_invoice",
AsyncMock(),
)
response = await client.post(
"/api/v1/payments",
headers=inkey_headers_to,
json={
"unit": "USD",
"out": False,
"amount": 2100,
"fiat_provider": "stripe",
"extra": {
"fiat_method": "subscription",
"subscription": {
"checking_id": "fiat_stripe_cs_paid_session",
"payment_request": "",
},
},
},
)
assert response.status_code == 400
assert response.json()["detail"] == (
"Cannot create direct fiat subscription payments."
)
fiat_mock.assert_not_awaited()
@pytest.mark.anyio
@pytest.mark.parametrize("currency", ("msat", "RRR"))
async def test_create_invoice_validates_used_currency(

View file

@ -1,3 +1,6 @@
import json
import re
from types import SimpleNamespace
from typing import cast
from uuid import uuid4
@ -16,9 +19,11 @@ from lnurl.models import MessageAction
from lnurl.types import CallbackUrl, LightningInvoice
from pydantic import parse_obj_as
from lnbits.core.crud.wallets import create_wallet, get_wallet
from lnbits.core.models import Account, CreateInvoice
from lnbits.core.models.lnurl import CreateLnurlPayment, LnurlScan
from lnbits.core.models.wallets import KeyType, WalletTypeInfo
from lnbits.core.services.lightning_address import wallet_lightning_address_callback
from lnbits.core.services.payments import create_wallet_invoice
from lnbits.core.services.users import create_user_account
from lnbits.core.views.lnurl_api import (
@ -38,6 +43,90 @@ TEST_BOLT11 = (
)
@pytest.mark.anyio
async def test_wallet_lightning_address_lookup_and_callback(
client, to_user, settings, mocker
):
settings.lnbits_ln_address_mode = "core_first"
wallet = await create_wallet(user_id=to_user.id, wallet_name="ln address")
assert wallet.lightning_address
response = await client.get(f"/.well-known/lnurlp/{wallet.lightning_address}")
assert response.status_code == 200
data = response.json()
metadata = data["metadata"]
assert data["minSendable"] == 1000
assert data["maxSendable"] == 2_100_000_000_000_000_000
assert data["commentAllowed"] == 799
assert f"{wallet.lightning_address}@" in metadata
assert "text/identifier" in metadata
tagged_response = await client.get(
f"/.well-known/lnurlp/{wallet.lightning_address}+market"
)
assert tagged_response.status_code == 200
tagged_data = tagged_response.json()
tagged_metadata = json.loads(tagged_data["metadata"])
assert ["text/tag", "market"] in tagged_metadata
assert any(
entry[0] == "text/identifier"
and entry[1].startswith(f"{wallet.lightning_address}+market@")
for entry in tagged_metadata
)
create_invoice_mock = mocker.patch(
"lnbits.core.services.lightning_address.create_invoice",
mocker.AsyncMock(return_value=SimpleNamespace(bolt11=TEST_BOLT11)),
)
callback = tagged_data["callback"].split("testserver")[-1]
callback_response = await client.get(f"{callback}?amount=21000&comment=hello")
assert callback_response.status_code == 200
assert callback_response.json()["pr"] == TEST_BOLT11
create_invoice_mock.assert_awaited_once()
kwargs = create_invoice_mock.await_args.kwargs
assert kwargs["wallet_id"] == wallet.id
assert kwargs["amount"] == 21
assert kwargs["extra"]["tag"] == "wallet_lightning_address"
assert kwargs["extra"]["comment"] == "hello"
assert kwargs["extra"]["lnaddress"].startswith(f"{wallet.lightning_address}+")
assert kwargs["extra"]["lnaddress_tag"] == "market"
@pytest.mark.anyio
async def test_wallet_lightning_address_generation_settings(to_user, settings):
settings.lnbits_ln_address_mode = "core_first"
wallet = await create_wallet(user_id=to_user.id)
assert wallet.lightning_address
assert re.fullmatch(r"[a-z]+[a-z]+[0-9]", wallet.lightning_address)
settings.lnbits_ln_address_mode = "extension_only"
disabled_wallet = await create_wallet(user_id=to_user.id)
assert disabled_wallet.lightning_address is None
settings.lnbits_ln_address_mode = "core_first"
backfilled = await get_wallet(disabled_wallet.id)
assert backfilled
assert backfilled.lightning_address
@pytest.mark.anyio
async def test_wallet_lightning_address_callback_validates_comment(
to_user, settings, mocker
):
settings.lnbits_ln_address_mode = "core_first"
wallet = await create_wallet(user_id=to_user.id)
assert wallet.lightning_address
request = mocker.Mock()
request.url.netloc = "example.com"
request.query_params.get.return_value = "x" * 800
result = await wallet_lightning_address_callback(
wallet.lightning_address, request, amount=1000
)
assert isinstance(result, LnurlErrorResponse)
assert "can only accept 799" in result.reason
@pytest.mark.anyio
async def test_lnurl_api_scan_routes_validate_and_forward(mocker):
pay_response = make_lnurl_pay_response()

View file

@ -83,6 +83,45 @@ async def test_user_api_get_wallets_and_delete_all_wallets(
assert active_wallets == []
@pytest.mark.anyio
async def test_user_api_superuser_sets_wallet_lightning_address(
http_client: AsyncClient, superuser_token: str
):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = user.wallets[0]
settings.lnbits_ln_address_mode = "core_first"
settings.lnbits_wallet_lightning_address_blacklist = ["admin"]
settings.lnbits_charge_wallet_lightning_addresses = True
settings.lnbits_wallet_lightning_address_price_sats = 1_000
settings.lnbits_service_fee_wallet = None
unauthorized = await http_client.put(
f"/users/api/v1/user/{user.id}/wallet/{wallet.id}/lightning-address",
json={"lightning_address": "admin"},
)
assert unauthorized.status_code == 401
response = await http_client.put(
f"/users/api/v1/user/{user.id}/wallet/{wallet.id}/lightning-address",
headers={"Authorization": f"Bearer {superuser_token}"},
json={"lightning_address": "admin"},
)
assert response.status_code == 200
assert response.json()["lightning_address"] == "admin"
updated_wallet = await get_wallet(wallet.id)
assert updated_wallet
assert updated_wallet.lightning_address == "admin"
assert updated_wallet.balance == 0
@pytest.mark.anyio
async def test_user_api_create_wallet_validates_currency():
user = await create_user_account(

View file

@ -5,7 +5,9 @@ from httpx import AsyncClient
from lnbits.core.crud.wallets import create_wallet, get_wallet
from lnbits.core.models.users import Account
from lnbits.core.services import update_wallet_balance
from lnbits.core.services.users import create_user_account
from lnbits.settings import settings
@pytest.mark.anyio
@ -166,6 +168,127 @@ async def test_wallet_api_paginated_update_reset_and_store_paylinks(
assert updated.json()["extra"]["pinned"] is True
@pytest.mark.anyio
async def test_wallet_api_custom_lightning_address_owner_rules(
http_client: AsyncClient,
):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = user.wallets[0]
headers = _admin_headers(wallet.adminkey)
settings.lnbits_ln_address_mode = "core_first"
settings.lnbits_allow_custom_wallet_lightning_addresses = False
disabled = await http_client.patch(
"/api/v1/wallet",
headers=headers,
json={"lightning_address": "custom.name"},
)
assert disabled.status_code == 403
settings.lnbits_allow_custom_wallet_lightning_addresses = True
settings.lnbits_wallet_lightning_address_blacklist = ["admin"]
blacklisted = await http_client.patch(
"/api/v1/wallet",
headers=headers,
json={"lightning_address": "admin"},
)
assert blacklisted.status_code == 400
invalid = await http_client.patch(
"/api/v1/wallet",
headers=headers,
json={"lightning_address": "custom+tag"},
)
assert invalid.status_code == 400
existing_wallet = await create_wallet(
user_id=user.id,
wallet_name="existing lightning address",
)
existing = await http_client.patch(
"/api/v1/wallet",
headers=_admin_headers(existing_wallet.adminkey),
json={"lightning_address": "pay.link"},
)
assert existing.status_code == 200
conflict = await http_client.patch(
"/api/v1/wallet",
headers=headers,
json={"lightning_address": "pay.link"},
)
assert conflict.status_code == 400
updated = await http_client.patch(
"/api/v1/wallet",
headers=headers,
json={"lightning_address": "custom.name"},
)
assert updated.status_code == 200
assert updated.json()["lightning_address"] == "custom.name"
@pytest.mark.anyio
async def test_wallet_api_custom_lightning_address_charges_fee(
http_client: AsyncClient,
):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
fee_user = await create_user_account(
Account(
id=uuid4().hex,
username=f"fees_{uuid4().hex[:8]}",
email=f"fees_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = user.wallets[0]
fee_wallet = fee_user.wallets[0]
await update_wallet_balance(wallet=wallet, amount=2_000)
settings.lnbits_ln_address_mode = "core_first"
settings.lnbits_allow_custom_wallet_lightning_addresses = True
settings.lnbits_charge_wallet_lightning_addresses = True
settings.lnbits_wallet_lightning_address_price_sats = 1_000
settings.lnbits_service_fee_wallet = fee_wallet.id
updated = await http_client.patch(
"/api/v1/wallet",
headers=_admin_headers(wallet.adminkey),
json={"lightning_address": "paid.name"},
)
assert updated.status_code == 200
assert updated.json()["lightning_address"] == "paid.name"
charged_wallet = await get_wallet(wallet.id)
credited_wallet = await get_wallet(fee_wallet.id)
assert charged_wallet
assert credited_wallet
assert charged_wallet.balance == 1_000
assert credited_wallet.balance == 1_000
settings.lnbits_service_fee_wallet = None
missing_fee_wallet = await http_client.patch(
"/api/v1/wallet",
headers=_admin_headers(wallet.adminkey),
json={"lightning_address": "paid.other"},
)
assert missing_fee_wallet.status_code == 400
assert missing_fee_wallet.json()["detail"] == (
"Lightning Address fee wallet is not configured."
)
@pytest.mark.anyio
async def test_wallet_api_shared_wallet_requires_source_id(http_client: AsyncClient):
user = await create_user_account(

View file

@ -287,6 +287,10 @@ async def test_pay_failed(
"lnbits.wallets.FakeWallet.pay_invoice",
AsyncMock(return_value=payment_reponse_failed),
)
mocker.patch(
"lnbits.core.services.payments.get_funding_source",
return_value=external_funding_source,
)
external_invoice = await external_funding_source.create_invoice(2101)
assert external_invoice.payment_request
@ -375,25 +379,33 @@ async def test_retry_failed_invoice(
@pytest.mark.anyio
@pytest.mark.parametrize("returns_checking_id", [True, False])
async def test_pay_external_invoice_pending(
from_wallet: Wallet,
mocker: MockerFixture,
external_funding_source: FakeWallet,
settings: Settings,
returns_checking_id: bool,
):
settings.lnbits_reserve_fee_min = 1000 # msats
invoice_amount = 2103
external_invoice = await external_funding_source.create_invoice(invoice_amount)
assert external_invoice.payment_request
assert external_invoice.checking_id
payment_reponse_pending = PaymentResponse(
ok=None, checking_id=external_invoice.checking_id
backend_checking_id = (
f"backend_{external_invoice.checking_id}" if returns_checking_id else None
)
expected_checking_id = backend_checking_id or external_invoice.checking_id
payment_reponse_pending = PaymentResponse(ok=None, checking_id=backend_checking_id)
mocker.patch(
"lnbits.wallets.FakeWallet.pay_invoice",
AsyncMock(return_value=payment_reponse_pending),
)
mocker.patch(
"lnbits.core.services.payments.get_funding_source",
return_value=external_funding_source,
)
ws_notification = mocker.patch(
"lnbits.core.services.payments.send_payment_notification_in_background",
AsyncMock(return_value=None),
@ -409,7 +421,9 @@ async def test_pay_external_invoice_pending(
_payment = await get_standalone_payment(payment.payment_hash)
assert _payment
assert _payment.status == PaymentState.PENDING.value
assert _payment.checking_id == payment.payment_hash
assert _payment.checking_id == expected_checking_id
assert _payment.payment_hash == external_invoice.checking_id
assert payment.checking_id == expected_checking_id
assert _payment.amount == -2103_000
assert _payment.bolt11 == external_invoice.payment_request
@ -565,36 +579,43 @@ async def test_retry_pay_success(
@pytest.mark.anyio
async def test_pay_external_invoice_success_bad_checking_id(
async def test_pay_external_invoice_success_with_backend_checking_id(
from_wallet: Wallet, mocker: MockerFixture, external_funding_source: FakeWallet
):
invoice_amount = 2108
external_invoice = await external_funding_source.create_invoice(invoice_amount)
assert external_invoice.payment_request
assert external_invoice.checking_id
bad_checking_id = f"bad_{external_invoice.checking_id}"
backend_checking_id = f"backend_{external_invoice.checking_id}"
preimage = "0000000000000000000000000000000000000000000000000000000000002108"
payment_reponse_success = PaymentResponse(
ok=True, checking_id=bad_checking_id, preimage=preimage
ok=True, checking_id=backend_checking_id, preimage=preimage
)
mocker.patch(
"lnbits.wallets.FakeWallet.pay_invoice",
AsyncMock(return_value=payment_reponse_success),
)
mocker.patch(
"lnbits.core.services.payments.get_funding_source",
return_value=external_funding_source,
)
with pytest.raises(PaymentError):
await pay_invoice(
wallet_id=from_wallet.id,
payment_request=external_invoice.payment_request,
)
payment = await pay_invoice(
wallet_id=from_wallet.id,
payment_request=external_invoice.payment_request,
)
payment = await get_standalone_payment(bad_checking_id)
assert payment is None, "Payment should not be created with bad checking_id"
stored_payment = await get_standalone_payment(external_invoice.checking_id)
assert stored_payment
assert stored_payment.status == PaymentState.SUCCESS.value
assert stored_payment.checking_id == backend_checking_id
assert stored_payment.payment_hash == external_invoice.checking_id
assert payment.checking_id == backend_checking_id
@pytest.mark.anyio
async def test_no_checking_id(
async def test_pay_external_invoice_success_without_checking_id(
from_wallet: Wallet, mocker: MockerFixture, external_funding_source: FakeWallet
):
invoice_amount = 2110
@ -603,29 +624,33 @@ async def test_no_checking_id(
assert external_invoice.checking_id
preimage = "0000000000000000000000000000000000000000000000000000000000002110"
payment_reponse_pending = PaymentResponse(
payment_response_success = PaymentResponse(
ok=True, checking_id=None, preimage=preimage
)
mocker.patch(
"lnbits.wallets.FakeWallet.pay_invoice",
AsyncMock(return_value=payment_reponse_pending),
AsyncMock(return_value=payment_response_success),
)
mocker.patch(
"lnbits.core.services.payments.get_funding_source",
return_value=external_funding_source,
)
with pytest.raises(PaymentError):
await pay_invoice(
wallet_id=from_wallet.id,
payment_request=external_invoice.payment_request,
)
returned_payment = await pay_invoice(
wallet_id=from_wallet.id,
payment_request=external_invoice.payment_request,
)
payment = await get_standalone_payment(external_invoice.checking_id)
assert payment
assert payment.status == PaymentState.FAILED.value
assert payment.status == PaymentState.SUCCESS.value
assert payment.checking_id == external_invoice.checking_id
assert payment.payment_hash == external_invoice.checking_id
assert payment.amount == -2110_000
assert payment.preimage is None
assert payment.preimage == preimage
assert returned_payment.checking_id == external_invoice.checking_id
@pytest.mark.anyio

View file

@ -43,10 +43,38 @@ from lnbits.wallets.base import (
InvoiceResponse,
PaymentFailedStatus,
PaymentPendingStatus,
PaymentResponse,
PaymentStatus,
PaymentSuccessStatus,
)
@pytest.mark.parametrize(
("value", "expected"),
[
(True, (True, False, False)),
(None, (False, True, False)),
(False, (False, False, True)),
],
)
def test_payment_response_states_are_mutually_exclusive(value, expected):
response = PaymentResponse(ok=value)
assert (response.success, response.pending, response.failed) == expected
@pytest.mark.parametrize(
("value", "expected"),
[
(True, (True, False, False)),
(None, (False, True, False)),
(False, (False, True, True)),
],
)
def test_payment_status_properties(value, expected):
status = PaymentStatus(paid=value)
assert (status.success, status.pending, status.failed) == expected
@pytest.mark.anyio
async def test_create_payment_request_routes_by_invoice_type(mocker: MockerFixture):
wallet_payment = SimpleNamespace(checking_id="wallet")
@ -75,6 +103,39 @@ async def test_create_payment_request_routes_by_invoice_type(mocker: MockerFixtu
fiat_mock.assert_awaited_once()
@pytest.mark.anyio
@pytest.mark.parametrize("fiat_provider", ("stripe", "square", "paypal"))
async def test_create_payment_request_rejects_fiat_subscription(
fiat_provider: str, mocker: MockerFixture
):
fiat_mock = mocker.patch(
"lnbits.core.services.payments.create_fiat_invoice",
mocker.AsyncMock(),
)
with pytest.raises(
ValueError,
match="Cannot create direct fiat subscription payments.",
):
await create_payment_request(
"wallet-1",
CreateInvoice(
unit="USD",
amount=2100,
fiat_provider=fiat_provider,
extra={
"fiat_method": "subscription",
"subscription": {
"checking_id": "fiat_stripe_cs_paid_session",
"payment_request": "",
},
},
),
)
fiat_mock.assert_not_awaited()
@pytest.mark.anyio
async def test_update_pending_payment_and_bulk_pending_updates(mocker: MockerFixture):
wallet = await _create_wallet()

View file

@ -1,5 +1,5 @@
from pathlib import Path
from typing import Any
from typing import Any, Literal
import pytest
from pytest_mock.plugin import MockerFixture
@ -14,6 +14,7 @@ from lnbits.settings import (
RedirectPath,
SecuritySettings,
Settings,
UsersSettings,
list_parse_fallback,
set_cli_settings,
)
@ -40,6 +41,33 @@ nostrrelay_redirect_path: dict[str, Any] = {
}
@pytest.mark.parametrize(
("mode", "creation_allowed"),
[
("core_first", True),
("extension_first", True),
("extension_only", False),
],
)
def test_ln_address_mode(
mode: Literal["core_first", "extension_first", "extension_only"],
creation_allowed: bool,
):
users_settings = UsersSettings(lnbits_ln_address_mode=mode)
assert users_settings.lnbits_ln_address_mode == mode
assert users_settings.ln_address_creation_allowed is creation_allowed
def test_ln_address_mode_defaults_to_extension_first():
assert UsersSettings().lnbits_ln_address_mode == "extension_first"
def test_ln_address_mode_rejects_invalid_value():
with pytest.raises(ValueError):
UsersSettings.parse_obj({"lnbits_ln_address_mode": "invalid"})
@pytest.fixture()
def lnurlp():
return RedirectPath(ext_id="lnurlp", **lnurlp_redirect_path)

View file

@ -0,0 +1,890 @@
from types import SimpleNamespace
from typing import Any, cast
import grpc
import httpx
import pytest
from pyln.client import RpcError
from pytest_mock.plugin import MockerFixture
import lnbits.wallets.breez as breez_wallet_module
import lnbits.wallets.breez_liquid as breez_liquid_wallet_module
from lnbits.wallets.alby import AlbyWallet
from lnbits.wallets.base import PaymentPendingStatus
from lnbits.wallets.blink import BlinkWallet
from lnbits.wallets.boltz import BoltzWallet
from lnbits.wallets.boltz_grpc_files import boltzrpc_pb2
from lnbits.wallets.corelightning import CoreLightningWallet
from lnbits.wallets.eclair import EclairWallet
from lnbits.wallets.lnd_grpc_files.lightning_pb2 import Payment as LndPayment
from lnbits.wallets.lndgrpc import LndWallet
from lnbits.wallets.lndrest import LndRestWallet
from lnbits.wallets.lnpay import LNPayWallet
from lnbits.wallets.lntips import LnTipsWallet
from lnbits.wallets.nwc import NWCError, NWCWallet
from lnbits.wallets.opennode import OpenNodeWallet
from lnbits.wallets.phoenixd import PhoenixdWallet
from lnbits.wallets.spark import SparkWallet
from lnbits.wallets.sparkl2 import SparkL2Wallet
from lnbits.wallets.strike import StrikeWallet
from lnbits.wallets.zbd import ZBDWallet
def _response(status_code: int, **kwargs) -> httpx.Response:
request = httpx.Request("POST", "https://wallet.test/pay")
return httpx.Response(status_code, request=request, **kwargs)
@pytest.mark.anyio
@pytest.mark.parametrize(
("status_code", "expected"),
[
(400, None),
(401, False),
(403, False),
(404, False),
(405, False),
(408, None),
(409, None),
(422, None),
(429, None),
(500, None),
],
)
async def test_alby_only_treats_definite_http_rejection_as_failed(
mocker: MockerFixture, status_code: int, expected: bool | None
):
wallet = object.__new__(AlbyWallet)
wallet.endpoint = "https://wallet.test"
cast(Any, wallet).client = SimpleNamespace(
post=mocker.AsyncMock(
return_value=_response(status_code, json={"message": "error"})
)
)
response = await wallet.pay_invoice("bolt11", 1_000)
assert response.ok is expected
@pytest.mark.anyio
async def test_blink_keeps_unconfirmed_payment_pending(mocker: MockerFixture):
wallet = object.__new__(BlinkWallet)
wallet._wallet_id = "wallet-id"
wallet.endpoint = "https://wallet.test"
mocker.patch(
"lnbits.wallets.blink.bolt11_lib.decode",
return_value=SimpleNamespace(payment_hash="payment-hash"),
)
mocker.patch.object(
wallet,
"_graphql_query",
return_value={"data": {"lnInvoicePaymentSend": {"errors": []}}},
)
mocker.patch.object(
wallet, "get_payment_status", return_value=PaymentPendingStatus()
)
response = await wallet.pay_invoice("bolt11", 1_000)
assert response.ok is None
assert response.checking_id == "payment-hash"
@pytest.mark.anyio
@pytest.mark.parametrize(
("status_code", "expected"),
[
(400, False),
(401, False),
(403, False),
(404, False),
(405, False),
(408, None),
(409, None),
(422, None),
(429, None),
(500, None),
],
)
async def test_eclair_only_treats_request_rejections_as_failed(
mocker: MockerFixture, status_code: int, expected: bool | None
):
wallet = object.__new__(EclairWallet)
wallet.url = "https://wallet.test"
cast(Any, wallet).client = SimpleNamespace(
post=mocker.AsyncMock(
return_value=_response(status_code, json={"error": "invoice has expired"})
)
)
response = await wallet.pay_invoice("bolt11", 1_000)
assert response.ok is expected
assert response.error_message == "invoice has expired"
@pytest.mark.anyio
async def test_lndrest_unknown_payment_state_is_pending(
mocker: MockerFixture, settings
):
settings.lnd_rest_allow_self_payment = False
wallet = object.__new__(LndRestWallet)
cast(Any, wallet).client = SimpleNamespace(
post=mocker.AsyncMock(
return_value=_response(
200,
json={
"result": {
"status": "FUTURE_STATUS",
"payment_hash": "payment-hash",
"payment_preimage": "",
"fee_msat": "0",
}
},
)
)
)
response = await wallet.pay_invoice("bolt11", 1_000)
assert response.ok is None
assert response.checking_id == "payment-hash"
@pytest.mark.anyio
@pytest.mark.parametrize(
("error", "expected"),
[
(
{
"code": 2,
"message": "invoice not for current active network 'regtest'",
},
False,
),
({"code": 2, "message": "invoice expired"}, False),
({"code": 3, "message": "invalid payment request"}, False),
({"code": 2, "message": "payment stream interrupted"}, None),
({"code": 14, "message": "transport unavailable"}, None),
],
)
async def test_lndrest_only_pre_dispatch_rpc_errors_are_failed(
mocker: MockerFixture,
settings,
error: dict,
expected: bool | None,
):
settings.lnd_rest_allow_self_payment = False
wallet = object.__new__(LndRestWallet)
wallet.endpoint = "https://wallet.test"
cast(Any, wallet).client = SimpleNamespace(
post=mocker.AsyncMock(
return_value=_response(500, json={"error": error}),
)
)
response = await wallet.pay_invoice("bolt11", 1_000)
assert response.ok is expected
assert response.error_message == error["message"]
@pytest.mark.anyio
@pytest.mark.parametrize(
("code", "details", "expected"),
[
(
grpc.StatusCode.UNKNOWN,
"invoice not for current active network 'regtest'",
False,
),
(grpc.StatusCode.UNKNOWN, "invoice expired", False),
(grpc.StatusCode.INVALID_ARGUMENT, "invalid payment request", False),
(grpc.StatusCode.PERMISSION_DENIED, "permission denied", False),
(grpc.StatusCode.UNAUTHENTICATED, "invalid macaroon", False),
(grpc.StatusCode.UNAVAILABLE, "transport is closing", None),
(grpc.StatusCode.DEADLINE_EXCEEDED, "deadline exceeded", None),
(grpc.StatusCode.ALREADY_EXISTS, "payment is in flight", None),
(grpc.StatusCode.UNKNOWN, "payment stream interrupted", None),
],
)
async def test_lndgrpc_only_pre_dispatch_rpc_errors_are_failed(
mocker: MockerFixture,
settings,
code: grpc.StatusCode,
details: str,
expected: bool | None,
):
settings.lnd_grpc_allow_self_payment = False
metadata = grpc.aio.Metadata()
error = grpc.aio.AioRpcError(code, metadata, metadata, details=details)
wallet = object.__new__(LndWallet)
cast(Any, wallet).router_rpc = SimpleNamespace(
SendPaymentV2=mocker.Mock(
return_value=SimpleNamespace(read=mocker.AsyncMock(side_effect=error))
)
)
response = await wallet.pay_invoice("bolt11", 1_000)
assert response.ok is expected
@pytest.mark.anyio
async def test_lndgrpc_in_flight_payment_is_pending(mocker: MockerFixture, settings):
settings.lnd_grpc_allow_self_payment = False
wallet = object.__new__(LndWallet)
cast(Any, wallet).router_rpc = SimpleNamespace(
SendPaymentV2=mocker.Mock(
return_value=SimpleNamespace(
read=mocker.AsyncMock(
return_value=SimpleNamespace(
status=LndPayment.PaymentStatus.IN_FLIGHT,
payment_hash="payment-hash",
)
)
)
)
)
response = await wallet.pay_invoice("bolt11", 1_000)
assert response.ok is None
assert response.checking_id == "payment-hash"
@pytest.mark.anyio
@pytest.mark.parametrize(
("error", "expected"),
[
(
{
"code": 0,
"message": "destination is not reachable",
"attempts": [{"status": "failed"}],
},
False,
),
(
{
"code": 0,
"message": "payment is still running",
"attempts": [{"status": "pending"}],
},
None,
),
({"code": 0, "message": "unclassified RPC error"}, None),
({"code": 205, "message": "unable to find a route"}, False),
],
)
async def test_corelightning_only_terminal_rpc_errors_are_failed(
mocker: MockerFixture,
error: dict,
expected: bool | None,
):
wallet = object.__new__(CoreLightningWallet)
wallet.pay = "pay"
wallet.pay_failure_error_codes = [-32602, 201, 203, 205, 206, 207, 210]
cast(Any, wallet).ln = SimpleNamespace(
call=mocker.Mock(side_effect=RpcError("pay", {}, cast(Any, error)))
)
mocker.patch(
"lnbits.wallets.corelightning.bolt11_decode",
return_value=SimpleNamespace(
payment_hash="payment-hash",
amount_msat=1_000,
description="",
),
)
mocker.patch.object(
wallet, "get_payment_status", return_value=PaymentPendingStatus()
)
response = await wallet.pay_invoice("bolt11", 1_000)
assert response.ok is expected
@pytest.mark.anyio
@pytest.mark.parametrize(
("status_code", "expected"),
[
(400, None),
(401, False),
(403, False),
(404, False),
(405, False),
(408, None),
(409, None),
(422, None),
(429, None),
(500, None),
],
)
async def test_lnpay_only_treats_client_rejection_as_failed(
mocker: MockerFixture, status_code: int, expected: bool | None
):
wallet = object.__new__(LNPayWallet)
wallet.wallet_key = "wallet-key"
cast(Any, wallet).client = SimpleNamespace(
post=mocker.AsyncMock(
return_value=_response(status_code, json={"message": "error"})
)
)
response = await wallet.pay_invoice("bolt11", 1_000)
assert response.ok is expected
@pytest.mark.anyio
async def test_lnpay_malformed_payment_response_is_pending(mocker: MockerFixture):
wallet = object.__new__(LNPayWallet)
wallet.wallet_key = "wallet-key"
cast(Any, wallet).client = SimpleNamespace(
post=mocker.AsyncMock(return_value=_response(200, content=b"not-json"))
)
response = await wallet.pay_invoice("bolt11", 1_000)
assert response.ok is None
@pytest.mark.anyio
@pytest.mark.parametrize("wallet_class", [LnTipsWallet, OpenNodeWallet, ZBDWallet])
@pytest.mark.parametrize(
("status_code", "expected"),
[(400, None), (401, False), (422, None)],
)
async def test_http_wallets_only_fail_definite_request_rejections(
mocker: MockerFixture,
wallet_class: type[LnTipsWallet | OpenNodeWallet | ZBDWallet],
status_code: int,
expected: bool | None,
):
wallet = object.__new__(wallet_class)
wallet.endpoint = "https://wallet.test"
cast(Any, wallet).client = SimpleNamespace(
post=mocker.AsyncMock(
return_value=_response(status_code, json={"message": "error"})
)
)
response = await wallet.pay_invoice("bolt11", 1_000)
assert response.ok is expected
@pytest.mark.anyio
async def test_breez_immediate_failed_state_is_failed(mocker: MockerFixture, settings):
settings.breez_use_trampoline = False
breez_wallet = cast(Any, breez_wallet_module)
wallet = object.__new__(breez_wallet.BreezSdkWallet)
mocker.patch(
"lnbits.wallets.breez.bolt11_decode",
return_value=SimpleNamespace(payment_hash="payment-hash"),
)
cast(Any, wallet).sdk_services = SimpleNamespace(
send_payment=mocker.Mock(
return_value=SimpleNamespace(
payment=SimpleNamespace(status=breez_wallet.BreezPaymentStatus.FAILED)
)
)
)
response = await cast(Any, wallet).pay_invoice("bolt11", 1_000)
assert response.ok is False
assert response.checking_id == "payment-hash"
@pytest.mark.anyio
async def test_breez_liquid_timed_out_outgoing_payment_is_failed(
mocker: MockerFixture,
):
breez_liquid_wallet = cast(Any, breez_liquid_wallet_module)
wallet = object.__new__(breez_liquid_wallet.BreezLiquidSdkWallet)
cast(Any, wallet).sdk_services = SimpleNamespace(
get_payment=mocker.Mock(
return_value=SimpleNamespace(
payment_type=breez_liquid_wallet.PaymentType.SEND,
status=breez_liquid_wallet.PaymentState.TIMED_OUT,
)
)
)
status = await cast(Any, wallet).get_payment_status("payment-hash")
assert status.paid is False
@pytest.mark.anyio
async def test_breez_liquid_prepare_error_is_failed(mocker: MockerFixture):
breez_liquid_wallet = cast(Any, breez_liquid_wallet_module)
wallet = object.__new__(breez_liquid_wallet.BreezLiquidSdkWallet)
mocker.patch(
"lnbits.wallets.breez_liquid.bolt11_decode",
return_value=SimpleNamespace(payment_hash="payment-hash"),
)
cast(Any, wallet).sdk_services = SimpleNamespace(
prepare_send_payment=mocker.Mock(side_effect=RuntimeError("cannot prepare"))
)
response = await cast(Any, wallet).pay_invoice("bolt11", 1_000)
assert response.ok is False
assert response.checking_id == "payment-hash"
@pytest.mark.anyio
@pytest.mark.parametrize(
("code", "expected"),
[("PAYMENT_FAILED", False), ("INTERNAL", None), ("OTHER", None)],
)
async def test_nwc_only_explicit_payment_failure_is_failed(
mocker: MockerFixture, code: str, expected: bool | None
):
wallet = object.__new__(NWCWallet)
cast(Any, wallet).conn = SimpleNamespace(
call=mocker.AsyncMock(side_effect=NWCError(code, "error"))
)
response = await wallet.pay_invoice("bolt11", 1_000)
assert response.ok is expected
@pytest.mark.anyio
async def test_phoenix_request_error_is_pending(mocker: MockerFixture):
wallet = object.__new__(PhoenixdWallet)
wallet.endpoint = "https://wallet.test"
request = httpx.Request("POST", "https://wallet.test/payinvoice")
cast(Any, wallet).client = SimpleNamespace(
post=mocker.AsyncMock(
side_effect=httpx.ReadError("read failed", request=request)
)
)
response = await wallet.pay_invoice("bolt11", 1_000)
assert response.ok is None
@pytest.mark.anyio
async def test_spark_sidecar_missing_checking_id_is_pending(mocker: MockerFixture):
wallet = object.__new__(SparkL2Wallet)
mocker.patch.object(wallet, "_request", return_value={"status": "PENDING"})
response = await wallet.pay_invoice("not-a-bolt11", 1_000)
assert response.ok is None
assert response.checking_id is None
@pytest.mark.anyio
@pytest.mark.parametrize(
("provider_status", "expected"),
[("unpaid", None), ("expired", False), ("paid", True)],
)
async def test_spark_invoice_uses_exact_terminal_status(
mocker: MockerFixture,
provider_status: str,
expected: bool | None,
):
wallet = object.__new__(SparkWallet)
mocker.patch.object(
wallet,
"listinvoices",
return_value={"invoices": [{"status": provider_status}]},
)
status = await wallet.get_invoice_status("invoice-id")
assert status.paid is expected
@pytest.mark.anyio
@pytest.mark.parametrize(
("state", "expected"),
[
(boltzrpc_pb2.SwapState.ERROR, False),
(999, None),
],
)
async def test_boltz_only_known_terminal_swap_state_is_failed(
mocker: MockerFixture, state: int, expected: bool | None
):
wallet = object.__new__(BoltzWallet)
wallet.metadata = None
cast(Any, wallet).rpc = SimpleNamespace(
GetSwapInfo=mocker.AsyncMock(
return_value=SimpleNamespace(swap=SimpleNamespace(state=state))
)
)
status = await wallet.get_payment_status("00" * 32)
assert status.paid is expected
@pytest.mark.anyio
@pytest.mark.parametrize(
("code", "details", "expected"),
[
(
grpc.StatusCode.INVALID_ARGUMENT,
"invalid invoice or lnurl: invalid HRP",
False,
),
(
grpc.StatusCode.UNKNOWN,
"boltz error: could not find route to pay invoice",
False,
),
(grpc.StatusCode.UNKNOWN, "payment response interrupted", None),
(grpc.StatusCode.ALREADY_EXISTS, "swap already exists", None),
],
)
async def test_boltz_only_pre_dispatch_create_swap_errors_are_failed(
mocker: MockerFixture,
code: grpc.StatusCode,
details: str,
expected: bool | None,
):
metadata = grpc.aio.Metadata()
error = grpc.aio.AioRpcError(code, metadata, metadata, details=details)
wallet = object.__new__(BoltzWallet)
wallet.metadata = None
wallet.wallet_id = 1
cast(Any, wallet).rpc = SimpleNamespace(
GetPairInfo=mocker.AsyncMock(
return_value=SimpleNamespace(
fees=SimpleNamespace(percentage=0, miner_fees=0)
)
),
CreateSwap=mocker.AsyncMock(side_effect=error),
)
mocker.patch(
"lnbits.wallets.boltz.decode",
return_value=SimpleNamespace(
amount_msat=1_000,
payment_hash="payment-hash",
),
)
response = await wallet.pay_invoice("bolt11", 1_000)
assert response.ok is expected
assert response.checking_id == "payment-hash"
@pytest.mark.anyio
@pytest.mark.parametrize(
("state", "expected"),
[
(boltzrpc_pb2.ERROR, False),
(boltzrpc_pb2.PENDING, None),
(boltzrpc_pb2.SUCCESSFUL, True),
],
)
async def test_boltz_resolves_ambiguous_create_swap_error_from_backend_state(
mocker: MockerFixture,
state: int,
expected: bool | None,
):
metadata = grpc.aio.Metadata()
error = grpc.aio.AioRpcError(
grpc.StatusCode.UNKNOWN,
metadata,
metadata,
details='sendrawtransaction RPC error: {"message":"txn-mempool-conflict"}',
)
payment_hash = "00" * 32
wallet = object.__new__(BoltzWallet)
wallet.metadata = None
wallet.wallet_id = 1
cast(Any, wallet).rpc = SimpleNamespace(
GetPairInfo=mocker.AsyncMock(
return_value=SimpleNamespace(
fees=SimpleNamespace(percentage=0, miner_fees=0)
)
),
CreateSwap=mocker.AsyncMock(side_effect=error),
GetSwapInfo=mocker.AsyncMock(
return_value=SimpleNamespace(
swap=SimpleNamespace(
state=state,
service_fee=1,
onchain_fee=2,
status="swap status",
preimage="preimage",
)
)
),
)
mocker.patch(
"lnbits.wallets.boltz.decode",
return_value=SimpleNamespace(
amount_msat=1_000,
payment_hash=payment_hash,
),
)
response = await wallet.pay_invoice("bolt11", 1_000)
assert response.ok is expected
assert response.checking_id == payment_hash
assert response.fee_msat == (3_000 if expected is True else None)
assert response.preimage == ("preimage" if expected is True else None)
@pytest.mark.anyio
async def test_boltz_error_text_without_terminal_state_is_pending(
mocker: MockerFixture,
):
async def swap_updates():
yield SimpleNamespace(
swap=SimpleNamespace(state=999, error="unrecognized transient error")
)
wallet = object.__new__(BoltzWallet)
wallet.metadata = None
wallet.wallet_id = 1
cast(Any, wallet).rpc = SimpleNamespace(
GetPairInfo=mocker.AsyncMock(
return_value=SimpleNamespace(
fees=SimpleNamespace(percentage=0, miner_fees=0)
)
),
CreateSwap=mocker.AsyncMock(return_value=SimpleNamespace(id="swap-id")),
GetSwapInfoStream=mocker.Mock(return_value=swap_updates()),
)
mocker.patch(
"lnbits.wallets.boltz.decode",
return_value=SimpleNamespace(
amount_msat=1_000,
payment_hash="payment-hash",
),
)
response = await wallet.pay_invoice("bolt11", 1_000)
assert response.ok is None
@pytest.mark.anyio
async def test_opennode_terminal_error_status_is_failed(mocker: MockerFixture):
wallet = object.__new__(OpenNodeWallet)
cast(Any, wallet).client = SimpleNamespace(
get=mocker.AsyncMock(
return_value=_response(
200,
json={"data": {"status": "error", "fee": 1}},
)
)
)
status = await wallet.get_payment_status("withdrawal-id")
assert status.paid is False
@pytest.mark.anyio
async def test_opennode_terminal_status_does_not_require_provider_id(
mocker: MockerFixture,
):
wallet = object.__new__(OpenNodeWallet)
cast(Any, wallet).client = SimpleNamespace(
post=mocker.AsyncMock(
return_value=_response(
200,
json={"data": {"status": "failed"}},
)
)
)
response = await wallet.pay_invoice("bolt11", 1_000)
assert response.ok is False
assert response.checking_id is None
@pytest.mark.anyio
@pytest.mark.parametrize(
("provider_status", "expected"),
[("processing", None), ("completed", True), ("failed", False)],
)
async def test_zbd_preserves_provider_id_and_exact_status(
mocker: MockerFixture,
provider_status: str,
expected: bool | None,
):
wallet = object.__new__(ZBDWallet)
cast(Any, wallet).client = SimpleNamespace(
post=mocker.AsyncMock(
return_value=_response(
200,
json={
"data": {
"id": "zbd-payment-id",
"status": provider_status,
"fee": "10",
"preimage": "preimage",
}
},
)
)
)
response = await wallet.pay_invoice("bolt11", 1_000)
assert response.ok is expected
assert response.checking_id == "zbd-payment-id"
@pytest.mark.anyio
async def test_zbd_terminal_status_does_not_require_provider_id(
mocker: MockerFixture,
):
wallet = object.__new__(ZBDWallet)
cast(Any, wallet).client = SimpleNamespace(
post=mocker.AsyncMock(
return_value=_response(
200,
json={"data": {"status": "failed"}},
)
)
)
response = await wallet.pay_invoice("bolt11", 1_000)
assert response.ok is False
assert response.checking_id is None
@pytest.mark.anyio
async def test_strike_invalid_fallback_identifier_is_pending(mocker: MockerFixture):
wallet = object.__new__(StrikeWallet)
wallet.pending_payments = {}
cast(Any, wallet)._get = mocker.AsyncMock(
return_value=_response(
400,
json={
"data": {
"code": "INVALID_DATA",
"validationErrors": {
"paymentId": [
{
"code": "INVALID_DATA",
"message": "paymentId is not valid.",
}
]
},
}
},
)
)
status = await wallet._get_payment_status_by_checking_id("payment-hash")
assert status.paid is None
@pytest.mark.anyio
async def test_strike_ambiguous_execution_uses_payment_hash_fallback(
mocker: MockerFixture,
):
wallet = object.__new__(StrikeWallet)
wallet.pending_payments = {}
mocker.patch(
"lnbits.wallets.strike.bolt11_decode",
return_value=SimpleNamespace(payment_hash="payment-hash"),
)
mocker.patch.object(
wallet,
"_create_payment_quote",
return_value=("quote-id", None),
)
mocker.patch.object(
wallet,
"_execute_payment_quote",
return_value=(None, "request timed out"),
)
response = await wallet.pay_invoice("bolt11", 1_000)
assert response.ok is None
assert response.checking_id is None
@pytest.mark.anyio
async def test_strike_terminal_state_does_not_require_payment_id(
mocker: MockerFixture,
):
wallet = object.__new__(StrikeWallet)
wallet.pending_payments = {}
mocker.patch(
"lnbits.wallets.strike.bolt11_decode",
return_value=SimpleNamespace(payment_hash="payment-hash"),
)
mocker.patch.object(
wallet,
"_create_payment_quote",
return_value=("quote-id", None),
)
mocker.patch.object(
wallet,
"_execute_payment_quote",
return_value=({"state": "FAILED"}, None),
)
response = await wallet.pay_invoice("bolt11", 1_000)
assert response.ok is False
assert response.checking_id == "payment-hash"
@pytest.mark.anyio
@pytest.mark.parametrize("state", ["CANCELED", "TIMED_OUT", "UNKNOWN"])
async def test_strike_undocumented_payment_state_is_pending(
mocker: MockerFixture, state: str
):
wallet = object.__new__(StrikeWallet)
wallet.pending_payments = {}
mocker.patch(
"lnbits.wallets.strike.bolt11_decode",
return_value=SimpleNamespace(payment_hash="payment-hash"),
)
mocker.patch.object(
wallet,
"_create_payment_quote",
return_value=("quote-id", None),
)
mocker.patch.object(
wallet,
"_execute_payment_quote",
return_value=({"state": state, "paymentId": "payment-id"}, None),
)
response = await wallet.pay_invoice("bolt11", 1_000)
assert response.ok is None
assert response.checking_id == "payment-id"
@pytest.mark.anyio
async def test_strike_persisted_payment_hash_not_found_stays_pending(
mocker: MockerFixture,
):
wallet = object.__new__(StrikeWallet)
wallet.pending_payments = {}
cast(Any, wallet)._get = mocker.AsyncMock(
return_value=_response(404, text="Not Found")
)
payment_hash = "ab" * 32
status = await wallet.get_payment_status(payment_hash)
assert status.paid is None
cast(Any, wallet)._get.assert_awaited_once_with(f"/payments/{payment_hash}")

View file

@ -38,7 +38,9 @@
"wallet_class": "LNbitsWallet",
"settings": {
"lnbits_endpoint": "http://127.0.0.1:8555",
"lnbits_key": null,
"lnbits_admin_key": "f171ba022a764e679eef950b21fb1c04",
"lnbits_invoice_key": null,
"user_agent": "LNbits/Tests"
}
},
@ -1831,6 +1833,26 @@
"fee_msat": null,
"preimage": null
},
"expect_by_funding_source": {
"alby": {
"error_message": "Not Found",
"success": false,
"pending": false,
"failed": true,
"checking_id": null,
"fee_msat": null,
"preimage": null
},
"eclair": {
"error_message": "Unable to connect to http://127.0.0.1:8555.",
"success": false,
"pending": false,
"failed": true,
"checking_id": null,
"fee_msat": null,
"preimage": null
}
},
"mocks": {
"corelightningrest": {
"pay_invoice_endpoint": [

Some files were not shown because too many files have changed in this diff Show more