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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ txid:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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("