diff --git a/lnbits/core/services/__init__.py b/lnbits/core/services/__init__.py index 82eabd3ae..f95ef4a5c 100644 --- a/lnbits/core/services/__init__.py +++ b/lnbits/core/services/__init__.py @@ -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", diff --git a/lnbits/core/services/blockexplorer.py b/lnbits/core/services/blockexplorer.py new file mode 100644 index 000000000..0b856a29e --- /dev/null +++ b/lnbits/core/services/blockexplorer.py @@ -0,0 +1,92 @@ +import asyncio + +from lnbits.settings import settings +from lnbits.task_manager import OnchainAddressEvent +from lnbits.utils.electrum import ( + 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 + ) + + +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, + ) diff --git a/lnbits/core/views/blockexplorer_api.py b/lnbits/core/views/blockexplorer_api.py index dbadfe9b4..f805d61fb 100644 --- a/lnbits/core/views/blockexplorer_api.py +++ b/lnbits/core/views/blockexplorer_api.py @@ -5,6 +5,14 @@ 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, +) from lnbits.decorators import check_access_token, check_user_exists from lnbits.settings import settings from lnbits.task_manager import ( @@ -15,16 +23,11 @@ from lnbits.task_manager import ( ) from lnbits.utils.electrum import ( AddressResponse, - Balance, BlockHeader, BlockInfo, - ElectrumClient, ElectrumError, FeeResponse, Transaction, - network_from_name, - parse_block_header, - parse_raw_tx, scripthash_from_address, ) @@ -52,29 +55,13 @@ async def _check_api_access( await check_user_exists(r, access_token, usr) -def _client() -> ElectrumClient: - return ElectrumClient( - settings.lnbits_blockexplorer_electrum_url, - network=network_from_name(settings.lnbits_blockexplorer_network), - ) - - # ---- 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)) + return await fetch_recent_blocks() except ElectrumError as e: raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e @@ -82,8 +69,7 @@ async def api_blocks() -> list[BlockInfo]: @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() + return await fetch_tip() except ElectrumError as e: raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e @@ -91,20 +77,7 @@ async def api_tip() -> BlockHeader: @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) + return await fetch_fee_estimates() except ElectrumError as e: raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e @@ -112,9 +85,7 @@ async def api_fees() -> FeeResponse: @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, network=c.network) + return await fetch_transaction(txid) except ElectrumError as e: raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e @@ -124,27 +95,11 @@ async def api_tx(txid: str) -> Transaction: ) async def api_address(address: str) -> AddressResponse: try: - scripthash = scripthash_from_address(address) + 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, BaseException): - raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(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 - ) - except HTTPException: - raise + return await fetch_onchain_balance(address) except ElectrumError as e: raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e @@ -167,14 +122,6 @@ async def ws_blocks(websocket: WebSocket) -> None: task_manager.unregister_ws_block_queue(queue) -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, - ) - - @blockexplorer_router.websocket("/ws/address/{address}") async def ws_address(websocket: WebSocket, address: str) -> None: if not settings.lnbits_blockexplorer_enabled: @@ -189,7 +136,7 @@ async def ws_address(websocket: WebSocket, address: str) -> None: await websocket.close(code=1008, reason=str(e)) return try: - await relay_ws_queue(websocket, queue, serialize=_address_event_to_response) + await relay_ws_queue(websocket, queue, serialize=address_event_to_response) finally: task_manager.unregister_ws_address_queue(address, queue)