Files
lnbits/lnbits/core/views/blockexplorer_api.py
T
2026-07-13 09:09:42 +02:00

307 lines
11 KiB
Python

import asyncio
from http import HTTPStatus
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket
from loguru import logger
from pydantic.types import UUID4
from lnbits.decorators import check_access_token, check_user_exists
from lnbits.settings import settings
from lnbits.utils.electrum import (
AddressResponse,
BlockHeader,
BlockInfo,
ElectrumClient,
ElectrumError,
FeeResponse,
HistoryEntry,
Transaction,
parse_block_header,
parse_raw_tx,
scripthash_from_address,
scripthash_from_scriptpubkey,
)
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)
def _client() -> ElectrumClient:
return ElectrumClient(settings.lnbits_blockexplorer_electrum_url)
def _watch_scripthash(tx: Transaction) -> str | None:
"""Return scripthash of first spendable output to watch for tx confirmation."""
for out in tx.vout:
if out.scriptPubKey.type != "nulldata":
return scripthash_from_scriptpubkey(bytes.fromhex(out.scriptPubKey.hex))
return None
async def _tx_status(
c: ElectrumClient, txid: str, scripthash: str | None
) -> dict[str, Any]:
if scripthash:
try:
history: list[HistoryEntry] = await c.get_history(scripthash)
for entry in history:
if entry.tx_hash == txid:
return {
"txid": txid,
"confirmed": entry.height > 0,
"height": entry.height if entry.height > 0 else None,
"fee": entry.fee,
}
except ElectrumError:
# History too large; check mempool to determine confirmation status.
try:
mempool = await c.get_mempool(scripthash)
for entry in mempool:
if entry.tx_hash == txid:
return {
"txid": txid,
"confirmed": False,
"height": None,
"fee": entry.fee,
}
return {"txid": txid, "confirmed": True, "height": None, "fee": None}
except ElectrumError:
pass
return {"txid": txid, "confirmed": False, "height": None, "fee": None}
# ---- REST ----
@blockexplorer_router.get("/blocks", dependencies=[Depends(_check_api_access)])
async def api_blocks() -> list[BlockInfo]:
try:
async with _client() as c:
tip = await c.get_tip()
start = max(0, tip.height - 4)
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))
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:
async with _client() as c:
return await c.get_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:
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)
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:
async with _client() as c:
raw_hex = await c.get_transaction(txid)
return parse_raw_tx(raw_hex)
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 = scripthash_from_address(address)
except ValueError as e:
raise HTTPException(HTTPStatus.BAD_REQUEST, detail=str(e)) from e
try:
async with _client() as c:
balance_res, history_res = await asyncio.gather(
c.get_balance(scripthash),
c.get_history(scripthash),
return_exceptions=True,
)
if isinstance(balance_res, Exception):
raise HTTPException(
HTTPStatus.SERVICE_UNAVAILABLE, detail=str(balance_res)
)
history = [] if isinstance(history_res, Exception) else history_res
history_error = str(history_res) if isinstance(history_res, Exception) else None
return AddressResponse(
balance=balance_res, history=history, history_error=history_error
)
except HTTPException:
raise
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()
try:
async with _client() as c:
tip = await c.subscribe_headers()
await websocket.send_json(parse_block_header(tip.hex, tip.height).dict())
async def on_header(params: list[Any]) -> None:
h = params[0]
try:
await websocket.send_json(
parse_block_header(h["hex"], h["height"]).dict()
)
except Exception as e:
logger.debug(f"ws_blocks send error: {e}")
c.on("blockchain.headers.subscribe", on_header)
while True:
msg = await websocket.receive()
if msg["type"] == "websocket.disconnect":
break
except Exception as e:
logger.debug(f"ws_blocks error: {e}")
@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
try:
scripthash = scripthash_from_address(address)
except ValueError as e:
await websocket.close(code=1008, reason=str(e))
return
await websocket.accept()
try:
async with _client() as c:
await c.subscribe_scripthash(scripthash)
balance_res, history_res = await asyncio.gather(
c.get_balance(scripthash),
c.get_history(scripthash),
return_exceptions=True,
)
if isinstance(balance_res, Exception):
await websocket.send_json({"error": str(balance_res)})
return
history = [] if isinstance(history_res, Exception) else history_res
history_error = (
str(history_res) if isinstance(history_res, Exception) else None
)
resp = AddressResponse(
balance=balance_res, history=history, history_error=history_error
)
await websocket.send_json(resp.dict())
async def on_address_change(params: list[Any]) -> None:
try:
bal_r, hist_r = await asyncio.gather(
c.get_balance(scripthash),
c.get_history(scripthash),
return_exceptions=True,
)
if isinstance(bal_r, Exception):
return
hist = [] if isinstance(hist_r, Exception) else hist_r
h_err = str(hist_r) if isinstance(hist_r, Exception) else None
await websocket.send_json(
AddressResponse(
balance=bal_r, history=hist, history_error=h_err
).dict()
)
except Exception as e:
logger.debug(f"ws_address send error: {e}")
c.on("blockchain.scripthash.subscribe", on_address_change)
while True:
msg = await websocket.receive()
if msg["type"] == "websocket.disconnect":
break
except Exception as e:
logger.debug(f"ws_address error: {e}")
@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()
try:
async with _client() as c:
try:
raw = await c.get_transaction(txid)
except ElectrumError as e:
await websocket.send_json({"error": str(e)})
return
tx = parse_raw_tx(raw)
watch = _watch_scripthash(tx)
await websocket.send_json(await _tx_status(c, txid, watch))
if watch:
await c.subscribe_scripthash(watch)
async def on_tx_change(params: list[Any]) -> None:
try:
await websocket.send_json(await _tx_status(c, txid, watch))
except Exception as e:
logger.debug(f"ws_tx send error: {e}")
c.on("blockchain.scripthash.subscribe", on_tx_change)
while True:
msg = await websocket.receive()
if msg["type"] == "websocket.disconnect":
break
except Exception as e:
logger.debug(f"ws_tx error: {e}")