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

259 lines
8.7 KiB
Python

import asyncio
from http import HTTPStatus
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, WebSocket
from loguru import logger
from lnbits.decorators import optional_user_id
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(
user_id: str | None = Depends(optional_user_id),
) -> None:
_check_enabled()
if not settings.lnbits_blockexplorer_public_api and not user_id:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail="Authentication required.",
)
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:
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,
}
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, history = await asyncio.gather(
c.get_balance(scripthash),
c.get_history(scripthash),
)
return AddressResponse(balance=balance, history=history)
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, history = await asyncio.gather(
c.get_balance(scripthash), c.get_history(scripthash)
)
resp = AddressResponse(balance=balance, history=history)
await websocket.send_json(resp.dict())
async def on_address_change(params: list[Any]) -> None:
try:
bal, hist = await asyncio.gather(
c.get_balance(scripthash), c.get_history(scripthash)
)
await websocket.send_json(
AddressResponse(balance=bal, history=hist).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}")