diff --git a/lnbits/core/__init__.py b/lnbits/core/__init__.py index ff2587aed..535863fd5 100644 --- a/lnbits/core/__init__.py +++ b/lnbits/core/__init__.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, FastAPI from .db import core_app_extra, db from .views.admin_api import admin_router from .views.api import api_router +from .views.blockexplorer_api import blockexplorer_router from .views.asset_api import asset_router from .views.audit_api import audit_router from .views.auth_api import auth_router @@ -48,6 +49,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"] diff --git a/lnbits/core/views/blockexplorer_api.py b/lnbits/core/views/blockexplorer_api.py new file mode 100644 index 000000000..512c36f7a --- /dev/null +++ b/lnbits/core/views/blockexplorer_api.py @@ -0,0 +1,94 @@ +import asyncio +from http import HTTPStatus + +from fastapi import APIRouter, HTTPException + +from lnbits.settings import settings +from lnbits.utils.electrum import ( + AddressResponse, + BlockHeader, + ElectrumClient, + ElectrumError, + FeeResponse, + Transaction, + parse_raw_tx, + 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.", + ) + + +def _client() -> ElectrumClient: + return ElectrumClient(settings.lnbits_blockexplorer_electrum_url) + + +@blockexplorer_router.get("/tip") +async def api_tip() -> BlockHeader: + _check_enabled() + try: + async with _client() as c: + return await c.get_tip() + except ElectrumError as e: + raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) + + +@blockexplorer_router.get("/fees") +async def api_fees() -> FeeResponse: + _check_enabled() + 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) + if fee >= 0 + } + return FeeResponse(estimates=estimates, histogram=histogram) + except ElectrumError as e: + raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) + + +@blockexplorer_router.get("/tx/{txid}") +async def api_tx(txid: str) -> Transaction: + _check_enabled() + try: + async with _client() as c: + raw_hex = await c.get_transaction(txid, verbose=False) + return parse_raw_tx(raw_hex) + except ElectrumError as e: + raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) + + +@blockexplorer_router.get("/address/{address}") +async def api_address(address: str) -> AddressResponse: + _check_enabled() + try: + scripthash = scripthash_from_address(address) + except ValueError as e: + raise HTTPException(HTTPStatus.BAD_REQUEST, detail=str(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)) diff --git a/lnbits/core/views/generic.py b/lnbits/core/views/generic.py index e073a5c4f..1d92175fe 100644 --- a/lnbits/core/views/generic.py +++ b/lnbits/core/views/generic.py @@ -209,6 +209,7 @@ async def index( @generic_router.get("/") @generic_router.get("/node/public") +@generic_router.get("/blockexplorer") @generic_router.get("/first_install", dependencies=[Depends(check_first_install)]) async def index_public(request: Request) -> HTMLResponse: return template_renderer().TemplateResponse(request, "base.html", {"public": True}) diff --git a/lnbits/settings.py b/lnbits/settings.py index 8d9bf2404..90d1b30ab 100644 --- a/lnbits/settings.py +++ b/lnbits/settings.py @@ -845,6 +845,13 @@ class NodeUISettings(LNbitsSettings): lnbits_node_ui_transactions: bool = Field(default=False) +class BlockExplorerSettings(LNbitsSettings): + lnbits_blockexplorer_enabled: bool = Field(default=False) + lnbits_blockexplorer_electrum_url: str = Field( + default="ssl://electrum.blockstream.info:50002" + ) + + class AuthMethods(Enum): user_id_only = "user-id-only" username_and_password = "username-password" # noqa: S105 @@ -1018,6 +1025,7 @@ class EditableSettings( LightningSettings, WebPushSettings, NodeUISettings, + BlockExplorerSettings, AuditSettings, AuthSettings, NostrAuthSettings, @@ -1268,6 +1276,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") @@ -1335,6 +1344,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, diff --git a/lnbits/static/i18n/en.js b/lnbits/static/i18n/en.js index be3d57f3b..0cce87eaf 100644 --- a/lnbits/static/i18n/en.js +++ b/lnbits/static/i18n/en.js @@ -841,5 +841,26 @@ window.localisation.en = { payment_labels_updated: 'Payment labels updated', color: 'Color', sort: 'Sort', - sort_by: 'Sort by' + sort_by: 'Sort by', + block_explorer: 'Block Explorer', + enable_block_explorer: 'Enable Block Explorer', + block_explorer_desc: 'Allow users to explore Bitcoin transactions and addresses via Electrum.', + electrum_server_url: 'Electrum Server URL', + electrum_server_url_hint: 'e.g. ssl://electrum.blockstream.info:50002 or tcp://localhost:50001', + blockexplorer_search_label: 'Search by TXID or Address', + blockexplorer_search_hint: '64-char hex = transaction ยท anything else = Bitcoin address', + 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', + unconfirmed: 'Unconfirmed', + no_transactions: 'No transactions found', + address: 'Address' } diff --git a/lnbits/static/js/components/admin/lnbits-admin-blockexplorer.js b/lnbits/static/js/components/admin/lnbits-admin-blockexplorer.js new file mode 100644 index 000000000..f8d8a086f --- /dev/null +++ b/lnbits/static/js/components/admin/lnbits-admin-blockexplorer.js @@ -0,0 +1,4 @@ +window.app.component('lnbits-admin-blockexplorer', { + props: ['form-data'], + template: '#lnbits-admin-blockexplorer' +}) diff --git a/lnbits/static/js/init-app.js b/lnbits/static/js/init-app.js index 1f0dfe327..f29936a67 100644 --- a/lnbits/static/js/init-app.js +++ b/lnbits/static/js/init-app.js @@ -66,6 +66,11 @@ const routes = [ name: 'NodePublic', component: PageNodePublic }, + { + path: '/blockexplorer', + name: 'BlockExplorer', + component: PageBlockExplorer + }, { path: '/payments', name: 'Payments', diff --git a/lnbits/static/js/pages/blockexplorer.js b/lnbits/static/js/pages/blockexplorer.js new file mode 100644 index 000000000..a2bc01ce9 --- /dev/null +++ b/lnbits/static/js/pages/blockexplorer.js @@ -0,0 +1,82 @@ +window.PageBlockExplorer = { + template: '#page-blockexplorer', + data() { + return { + query: '', + loading: false, + tip: null, + fees: null, + txResult: null, + addressResult: null, + currentAddress: '' + } + }, + computed: { + feeList() { + if (!this.fees || !this.fees.estimates) return [] + return Object.entries(this.fees.estimates).map(([blocks, rate]) => ({ + label: blocks + '-block fee', + rate: (rate * 100000).toFixed(1) + ' sat/vB' + })) + } + }, + async created() { + await Promise.all([this.loadTip(), this.loadFees()]) + }, + methods: { + 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 (_) {} + }, + async search() { + const q = this.query.trim() + if (!q) return + this.txResult = null + this.addressResult = null + this.loading = true + try { + if (/^[0-9a-fA-F]{64}$/.test(q)) { + await this.loadTx(q) + } else { + await this.loadAddress(q) + } + } finally { + this.loading = false + } + }, + async loadTx(txid) { + try { + const r = await LNbits.api.request('GET', '/blockexplorer/api/v1/tx/' + txid) + this.txResult = r.data + this.addressResult = null + this.query = txid + } catch (e) { + LNbits.utils.notifyApiError(e) + } + }, + async loadAddress(address) { + try { + const r = await LNbits.api.request( + 'GET', + '/blockexplorer/api/v1/address/' + address + ) + this.addressResult = r.data + this.txResult = null + this.currentAddress = address + this.query = address + } catch (e) { + LNbits.utils.notifyApiError(e) + } + } + } +} diff --git a/lnbits/static/vendor.json b/lnbits/static/vendor.json index 3f7849a48..548ebb78d 100644 --- a/lnbits/static/vendor.json +++ b/lnbits/static/vendor.json @@ -52,6 +52,7 @@ "js/pages/payments.js", "js/pages/node.js", "js/pages/node-public.js", + "js/pages/blockexplorer.js", "js/pages/audit.js", "js/pages/wallet.js", "js/pages/wallets.js", @@ -71,6 +72,7 @@ "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/components/lnbits-wallet-charts.js", "js/components/lnbits-wallet-api-docs.js", "js/components/lnbits-wallet-icon.js", diff --git a/lnbits/templates/components.vue b/lnbits/templates/components.vue index 62b6f43f0..73b4a2b85 100644 --- a/lnbits/templates/components.vue +++ b/lnbits/templates/components.vue @@ -11,6 +11,7 @@ include('components/admin/extensions.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') %} {% @@ -97,6 +98,21 @@ include('components/lnbits-error.vue') %} + + + + + + + + + + + diff --git a/lnbits/templates/components/admin/blockexplorer.vue b/lnbits/templates/components/admin/blockexplorer.vue new file mode 100644 index 000000000..756c747c3 --- /dev/null +++ b/lnbits/templates/components/admin/blockexplorer.vue @@ -0,0 +1,37 @@ + diff --git a/lnbits/templates/pages.vue b/lnbits/templates/pages.vue index 344fccb65..b50d3b6b4 100644 --- a/lnbits/templates/pages.vue +++ b/lnbits/templates/pages.vue @@ -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') %} diff --git a/lnbits/templates/pages/admin.vue b/lnbits/templates/pages/admin.vue index 896d6ddb0..7d71b260c 100644 --- a/lnbits/templates/pages/admin.vue +++ b/lnbits/templates/pages/admin.vue @@ -88,7 +88,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" @@ -183,6 +184,13 @@ > + @@ -235,6 +243,9 @@ + + + diff --git a/lnbits/templates/pages/blockexplorer.vue b/lnbits/templates/pages/blockexplorer.vue new file mode 100644 index 000000000..025c8afdf --- /dev/null +++ b/lnbits/templates/pages/blockexplorer.vue @@ -0,0 +1,160 @@ + diff --git a/lnbits/utils/electrum.py b/lnbits/utils/electrum.py index fd9a4b196..0ba1e1d09 100644 --- a/lnbits/utils/electrum.py +++ b/lnbits/utils/electrum.py @@ -11,12 +11,15 @@ import hashlib import itertools import json import ssl +import struct from collections.abc import Callable from typing import Any from urllib.parse import urlparse +from bech32 import bech32_decode, convertbits +from bech32 import encode as bech32_segwit_encode from loguru import logger -from pydantic import BaseModel +from pydantic import BaseModel, Field class ElectrumError(Exception): @@ -28,6 +31,100 @@ def scripthash_from_scriptpubkey(scriptpubkey: bytes) -> str: return hashlib.sha256(scriptpubkey).digest()[::-1].hex() +_B58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + + +def _b58decode_check(s: str) -> bytes: + n = 0 + for c in s: + n = n * 58 + _B58_ALPHABET.index(c) + nz = len(s) - len(s.lstrip("1")) + buf: list[int] = [] + while n: + n, rem = divmod(n, 256) + buf.insert(0, rem) + raw = bytes([0] * nz + buf) + payload, chk = raw[:-4], raw[-4:] + expected = hashlib.sha256(hashlib.sha256(payload).digest()).digest()[:4] + if chk != expected: + raise ValueError(f"Bad base58check checksum: {s!r}") + return payload # byte 0 = version, bytes 1:21 = hash160 + + +def address_to_scriptpubkey(address: str) -> bytes: + """Convert a Bitcoin address (P2PKH/P2SH/P2WPKH/P2WSH/P2TR) to scriptPubKey bytes.""" + lower = address.lower() + if lower.startswith(("bc1", "tb1", "bcrt1")): + _, data = bech32_decode(address) + if data is None: + raise ValueError(f"Invalid bech32 address: {address!r}") + witness_version = data[0] + witness_prog = bytes(convertbits(data[1:], 5, 8, False)) + ver_op = 0x00 if witness_version == 0 else (0x50 + witness_version) + return bytes([ver_op, len(witness_prog)]) + witness_prog + else: + payload = _b58decode_check(address) + version, hash160 = payload[0], payload[1:] + if version in (0x00, 0x6F, 0x41): # P2PKH mainnet/testnet/regtest + return bytes([0x76, 0xA9, 0x14]) + hash160 + bytes([0x88, 0xAC]) + if version in (0x05, 0xC4, 0x3A): # P2SH mainnet/testnet/regtest + return bytes([0xA9, 0x14]) + hash160 + bytes([0x87]) + raise ValueError(f"Unknown address version byte: {version:#04x}") + + +def scripthash_from_address(address: str) -> str: + return scripthash_from_scriptpubkey(address_to_scriptpubkey(address)) + + +def _b58encode_check(payload: bytes) -> str: + chk = hashlib.sha256(hashlib.sha256(payload).digest()).digest()[:4] + n = int.from_bytes(payload + chk, "big") + chars: list[str] = [] + while n: + n, rem = divmod(n, 58) + chars.insert(0, _B58_ALPHABET[rem]) + nz = len(payload) - len(payload.lstrip(b"\x00")) + return _B58_ALPHABET[0] * nz + "".join(chars) + + +def _read_varint(data: bytes, i: int) -> tuple[int, int]: + b = data[i] + if b < 0xFD: + return b, i + 1 + if b == 0xFD: + return struct.unpack_from(" tuple[str, str | None]: + """Return (type, address_or_None) for a scriptPubKey.""" + n = len(script) + # P2PKH + if n == 25 and script[:3] == b"\x76\xa9\x14" and script[23:] == b"\x88\xac": + return "pubkeyhash", _b58encode_check(b"\x00" + script[3:23]) + # P2SH + if n == 23 and script[0] == 0xA9 and script[1] == 0x14 and script[22] == 0x87: + return "scripthash", _b58encode_check(b"\x05" + script[2:22]) + # P2WPKH + if n == 22 and script[0] == 0x00 and script[1] == 0x14: + return "witness_v0_keyhash", bech32_segwit_encode("bc", 0, list(script[2:])) + # P2WSH + if n == 34 and script[0] == 0x00 and script[1] == 0x20: + return "witness_v0_scripthash", bech32_segwit_encode("bc", 0, list(script[2:])) + # P2TR + if n == 34 and script[0] == 0x51 and script[1] == 0x20: + return "witness_v1_taproot", bech32_segwit_encode("bc", 1, list(script[2:])) + # P2PK + if n in (35, 67) and script[-1] == 0xAC: + return "pubkey", None + # OP_RETURN + if n >= 1 and script[0] == 0x6A: + return "nulldata", None + return "nonstandard", None + + # --------------------------------------------------------------------------- # Response models # --------------------------------------------------------------------------- @@ -105,6 +202,155 @@ 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 + script_sig: ScriptSig | None = Field(None, alias="scriptSig") + sequence: int + coinbase: str | None = None + + class Config: + allow_population_by_field_name = True + + +class TxOutput(BaseModel): + value: float + n: int + script_pub_key: ScriptPubKey = Field(alias="scriptPubKey") + + class Config: + allow_population_by_field_name = True + + +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] + + +def parse_raw_tx(hex_str: str) -> Transaction: + """Parse a raw transaction hex string into a Transaction model.""" + data = bytes.fromhex(hex_str) + i = 0 + + version = struct.unpack_from(" i + 1 and data[i] == 0x00 and data[i + 1] == 0x01 + if segwit: + i += 2 + + vin_start = i + vin_count, i = _read_varint(data, i) + vin: list[TxInput] = [] + for _ in range(vin_count): + prev_txid = data[i : i + 32][::-1].hex() + i += 32 + prev_vout = struct.unpack_from("