Compare commits

...
45 Commits
Author SHA1 Message Date
alan 1d8db72517 chore: make bundle [skip ci] 2026-07-14 08:51:37 +00:00
dni 424a558a10 fulcrum port 2026-07-14 10:40:45 +02:00
dni 610db9b9b8 while running 2026-07-13 09:37:19 +02:00
dni 3571f96979 fix: revoluts test timestamp exceeded 2026-07-13 09:09:42 +02:00
dni d754efd1e0 add into api.js 2026-07-13 09:09:42 +02:00
dni e9943edd2b add utxos endpoint 2026-07-13 09:09:42 +02:00
dni 4337e62c10 fix lint 2026-07-13 09:09:42 +02:00
dni 4dc2c779a2 add service 2026-07-13 09:09:42 +02:00
dni 3f28d7a31a fix only have address updates sent if tracked 2026-07-13 09:09:42 +02:00
dni fe02f54021 electrum server select 2026-07-13 09:09:42 +02:00
dni 65d809a66c type 2026-07-13 09:09:42 +02:00
dni c3ac6b7a55 add settings to frontend and more embit 2026-07-13 09:09:42 +02:00
dni e1c427ddf7 add listeners 2026-07-13 09:09:42 +02:00
dni 062246b577 trackers 2026-07-13 09:09:42 +02:00
dni 5514e35e18 addresstracker 2026-07-13 09:09:42 +02:00
dni e61dab83a9 relay_ws_queue 2026-07-13 09:09:42 +02:00
dni da2f4abe10 blocktracker 2026-07-13 09:09:42 +02:00
dni e35204c253 fixup! 2026-07-13 09:09:42 +02:00
dni b2380a8dc4 address tracker 2026-07-13 09:09:42 +02:00
dni 1e4f19fd1c fix closing 2026-07-13 09:09:42 +02:00
dni 0a1a578988 fixes 2026-07-13 09:09:42 +02:00
dni 04e748c089 add back tx tracking and clean blockexplorer api 2026-07-13 09:09:42 +02:00
dni fbea97d187 remove track transaction 2026-07-13 09:09:42 +02:00
dni 93e33f6cf9 feat: task manager onchain listeners 2026-07-13 09:09:42 +02:00
dni 1e3ab2d6d0 mypy 2026-07-13 09:09:42 +02:00
dni 5452de5b60 fix 2026-07-13 09:09:42 +02:00
dni c76cb627d8 fix pyright 2026-07-13 09:09:42 +02:00
dni 824bc14f26 prettier 2026-07-13 09:09:42 +02:00
dni c466d85ae7 add languages 2026-07-13 09:09:42 +02:00
dni 2941157658 add history error 2026-07-13 09:09:42 +02:00
dni d0bc63d114 add to package 2026-07-13 09:09:42 +02:00
dni 2c422d3fd0 fix explorer 2026-07-13 09:09:42 +02:00
dni 69c9be1993 prettier 2026-07-13 09:09:42 +02:00
dni f78e82954f format 2026-07-13 09:09:42 +02:00
dni 5d9f13e71e fixup! 2026-07-13 09:09:42 +02:00
dni 46f19148a8 add optional public access setting 2026-07-13 09:09:42 +02:00
dni 2a96512e55 move into logged in template 2026-07-13 09:09:42 +02:00
dni d7797cc303 fix jinja 2026-07-13 09:09:41 +02:00
dni 843afd33e5 dynamically update blockexplorer compoenent with websockets 2026-07-13 09:09:41 +02:00
dni e3ce6b9fef add websocket endpoints 2026-07-13 09:09:41 +02:00
dni e63effbcb3 add blocks 2026-07-13 09:09:41 +02:00
dni 55b97b6e4f ruff and prettier 2026-07-13 09:09:41 +02:00
dni d559d5d278 fix pyright 2026-07-13 09:09:41 +02:00
dni 69c72e1d86 fix ruff 2026-07-13 09:09:41 +02:00
dni 730d1e2aaf feat: adds blockexplorer api, frontend via electrum
- settings
- frontend page component
- api
- models

models
2026-07-13 09:09:41 +02:00
40 changed files with 2796 additions and 44 deletions
-1
View File
@@ -477,7 +477,6 @@ async def check_and_register_extensions(app: FastAPI) -> None:
def register_async_tasks() -> None:
task_manager.init()
# listen to all incoming payments and dispatch payment notifications
+2
View File
@@ -6,6 +6,7 @@ from .views.api import api_router
from .views.asset_api import asset_router
from .views.audit_api import audit_router
from .views.auth_api import auth_router
from .views.blockexplorer_api import blockexplorer_router
from .views.callback_api import callback_router
from .views.extension_api import extension_router
from .views.extensions_builder_api import extension_builder_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"]
+12
View File
@@ -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",
+97
View File
@@ -0,0 +1,97 @@
import asyncio
from lnbits.settings import settings
from lnbits.task_manager import OnchainAddressEvent
from lnbits.utils.electrum import (
UTXO,
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
)
async def fetch_utxos(onchain_address: str) -> list[UTXO]:
scripthash = scripthash_from_address(onchain_address)
async with _client() as client:
return await client.listunspent(scripthash)
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,
)
+170
View File
@@ -0,0 +1,170 @@
import asyncio
from http import HTTPStatus
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,
fetch_utxos,
)
from lnbits.decorators import check_access_token, check_user_exists
from lnbits.settings import settings
from lnbits.task_manager import (
OnchainAddressEvent,
OnchainTxEvent,
relay_ws_queue,
task_manager,
)
from lnbits.utils.electrum import (
UTXO,
AddressResponse,
BlockHeader,
BlockInfo,
ElectrumError,
FeeResponse,
Transaction,
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.",
)
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)
# ---- REST ----
@blockexplorer_router.get("/blocks", dependencies=[Depends(_check_api_access)])
async def api_blocks() -> list[BlockInfo]:
try:
return await fetch_recent_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:
return await fetch_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:
return await fetch_fee_estimates()
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:
return await fetch_transaction(txid)
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_from_address(address)
except ValueError as e:
raise HTTPException(HTTPStatus.BAD_REQUEST, detail=str(e)) from e
try:
return await fetch_onchain_balance(address)
except ElectrumError as e:
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e
@blockexplorer_router.get("/utxos/{address}", dependencies=[Depends(_check_api_access)])
async def api_utxos(address: str) -> list[UTXO]:
try:
scripthash_from_address(address)
except ValueError as e:
raise HTTPException(HTTPStatus.BAD_REQUEST, detail=str(e)) from e
try:
return await fetch_utxos(address)
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()
queue: asyncio.Queue[BlockInfo] = asyncio.Queue()
task_manager.register_ws_block_queue(queue)
try:
await relay_ws_queue(websocket, queue)
finally:
task_manager.unregister_ws_block_queue(queue)
@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
await websocket.accept()
queue: asyncio.Queue[OnchainAddressEvent] = asyncio.Queue()
try:
task_manager.register_ws_address_queue(address, queue)
except ValueError as e:
await websocket.close(code=1008, reason=str(e))
return
try:
await relay_ws_queue(websocket, queue, serialize=address_event_to_response)
finally:
task_manager.unregister_ws_address_queue(address, queue)
@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()
queue: asyncio.Queue[OnchainTxEvent] = asyncio.Queue()
task_manager.register_ws_tx_queue(txid, queue)
try:
await relay_ws_queue(websocket, queue, stop_after=lambda e: e.confirmed)
finally:
task_manager.unregister_ws_tx_queue(txid, queue)
+1
View File
@@ -185,6 +185,7 @@ admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
@generic_router.get("/wallets")
@generic_router.get("/account")
@generic_router.get("/extensions")
@generic_router.get("/blockexplorer")
@generic_router.get("/users", dependencies=admin_ui_checks)
@generic_router.get("/audit", dependencies=admin_ui_checks)
@generic_router.get("/node", dependencies=admin_ui_checks)
+13
View File
@@ -845,6 +845,16 @@ class NodeUISettings(LNbitsSettings):
lnbits_node_ui_transactions: bool = Field(default=False)
class BlockExplorerSettings(LNbitsSettings):
lnbits_blockexplorer_enabled: bool = Field(default=False)
lnbits_blockexplorer_public_api: bool = Field(default=False)
lnbits_blockexplorer_electrum_url: str = Field(
default="ssl://electrum.blockstream.info:50002"
)
# one of: main, test, regtest, signet (see embit.networks.NETWORKS)
lnbits_blockexplorer_network: str = Field(default="main")
class AuthMethods(Enum):
user_id_only = "user-id-only"
username_and_password = "username-password" # noqa: S105
@@ -1018,6 +1028,7 @@ class EditableSettings(
LightningSettings,
WebPushSettings,
NodeUISettings,
BlockExplorerSettings,
AuditSettings,
AuthSettings,
NostrAuthSettings,
@@ -1268,6 +1279,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 +1347,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,
File diff suppressed because one or more lines are too long
+10 -10
View File
File diff suppressed because one or more lines are too long
+44 -1
View File
@@ -824,5 +824,48 @@ window.localisation.br = {
payment_labels_updated: 'Rótulos de pagamento atualizados',
color: 'Cor',
sort: 'Ordenar',
sort_by: 'Ordenar por'
sort_by: 'Ordenar por',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Ativar Block Explorer',
block_explorer_desc:
'Permite aos usuários explorar transações e endereços Bitcoin via Electrum.',
blockexplorer_public_api: 'Acesso à API pública',
blockexplorer_public_api_desc:
'Permitir acesso não autenticado aos endpoints da API do explorador de blocos.',
electrum_server_url: 'URL do servidor Electrum',
electrum_server_url_hint:
'ex. ssl://electrum.blockstream.info:50002 ou tcp://localhost:50001',
blockexplorer_search_label: 'Pesquisar por TXID ou endereço',
blockexplorer_search_hint:
'Hex de 64 caracteres = transação · qualquer outra coisa = endereço Bitcoin',
recent_blocks: 'Blocos recentes',
chain_tip: 'Ponta da cadeia',
block_height: 'Altura do bloco',
block_fee: 'taxa de bloco',
fee_estimates: 'Estimativas de taxa',
confirmed_balance: 'Saldo confirmado',
unconfirmed_balance: 'Saldo não confirmado',
transaction_history: 'Histórico de transações',
coinbase: 'Coinbase',
inputs: 'Entradas',
outputs: 'Saídas',
confirmations: 'Confirmações',
confirmed: 'Confirmado',
unconfirmed: 'Não confirmado',
history_unavailable:
'Histórico de transações indisponível (endereço tem transações demais)',
address: 'Endereço',
block_number: 'Bloco #{height}',
block_diff: 'diff {value}',
block_hash: 'Hash',
previous_block: 'Bloco anterior',
merkle_root: 'Raiz de Merkle',
version: 'Versão',
bits: 'Bits',
difficulty: 'Dificuldade',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Tamanho virtual',
weight: 'Peso',
n_block_fee: 'taxa {n} blocos'
}
+40 -1
View File
@@ -419,5 +419,44 @@ window.localisation.cn = {
http_request_methods: 'HTTP请求方法',
http_response_codes: 'HTTP响应代码',
request_details: '请求详情',
http_request_details: 'HTTP请求详细信息'
http_request_details: 'HTTP请求详细信息',
block_explorer: '区块浏览器',
enable_block_explorer: '启用区块浏览器',
block_explorer_desc: '允许用户通过 Electrum 浏览比特币交易和地址。',
blockexplorer_public_api: '公开 API 访问',
blockexplorer_public_api_desc: '允许对区块浏览器 API 端点的未认证访问。',
electrum_server_url: 'Electrum 服务器 URL',
electrum_server_url_hint:
'例如 ssl://electrum.blockstream.info:50002 或 tcp://localhost:50001',
blockexplorer_search_label: '按 TXID 或地址搜索',
blockexplorer_search_hint: '64位十六进制 = 交易 · 其他 = 比特币地址',
recent_blocks: '最新区块',
chain_tip: '链尖',
block_height: '区块高度',
block_fee: '区块手续费',
fee_estimates: '手续费估算',
confirmed_balance: '已确认余额',
unconfirmed_balance: '未确认余额',
transaction_history: '交易历史',
coinbase: 'Coinbase',
inputs: '输入',
outputs: '输出',
confirmations: '确认数',
confirmed: '已确认',
unconfirmed: '未确认',
history_unavailable: '交易历史不可用(地址交易过多)',
address: '地址',
block_number: '区块 #{height}',
block_diff: '难度 {value}',
block_hash: '哈希',
previous_block: '上一区块',
merkle_root: 'Merkle 根',
version: '版本',
bits: 'Bits',
difficulty: '难度',
nonce: 'Nonce',
txid: 'TXID',
vsize: '虚拟大小',
weight: '权重',
n_block_fee: '{n} 区块手续费'
}
+44 -1
View File
@@ -439,5 +439,48 @@ window.localisation.cs = {
http_request_methods: 'Metody HTTP požadavků',
http_response_codes: 'Kódy HTTP odpovědí',
request_details: 'Podrobnosti žádosti',
http_request_details: 'Podrobnosti HTTP žádosti'
http_request_details: 'Podrobnosti HTTP žádosti',
block_explorer: 'Průzkumník bloků',
enable_block_explorer: 'Povolit průzkumník bloků',
block_explorer_desc:
'Umožňuje uživatelům procházet bitcoinové transakce a adresy přes Electrum.',
blockexplorer_public_api: 'Veřejný přístup k API',
blockexplorer_public_api_desc:
'Povolit neověřený přístup k API koncovým bodům průzkumníku bloků.',
electrum_server_url: 'URL Electrum serveru',
electrum_server_url_hint:
'např. ssl://electrum.blockstream.info:50002 nebo tcp://localhost:50001',
blockexplorer_search_label: 'Hledat podle TXID nebo adresy',
blockexplorer_search_hint:
'64-znakový hex = transakce · cokoli jiného = bitcoinová adresa',
recent_blocks: 'Nedávné bloky',
chain_tip: 'Vrchol řetězu',
block_height: 'Výška bloku',
block_fee: 'poplatek bloku',
fee_estimates: 'Odhady poplatků',
confirmed_balance: 'Potvrzený zůstatek',
unconfirmed_balance: 'Nepotvrzený zůstatek',
transaction_history: 'Historie transakcí',
coinbase: 'Coinbase',
inputs: 'Vstupy',
outputs: 'Výstupy',
confirmations: 'Potvrzení',
confirmed: 'Potvrzeno',
unconfirmed: 'Nepotvrzeno',
history_unavailable:
'Historie transakcí nedostupná (adresa má příliš mnoho transakcí)',
address: 'Adresa',
block_number: 'Blok #{height}',
block_diff: 'obth. {value}',
block_hash: 'Hash',
previous_block: 'Předchozí blok',
merkle_root: 'Merkle kořen',
version: 'Verze',
bits: 'Bity',
difficulty: 'Obtížnost',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Virtuální velikost',
weight: 'Váha',
n_block_fee: 'poplatek {n} bloků'
}
+44 -1
View File
@@ -452,5 +452,48 @@ window.localisation.de = {
http_request_methods: 'HTTP-Anfragemethoden',
http_response_codes: 'HTTP-Antwortcodes',
request_details: 'Anfragedetails',
http_request_details: 'HTTP-Anfragedetails'
http_request_details: 'HTTP-Anfragedetails',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Block Explorer aktivieren',
block_explorer_desc:
'Ermöglicht Nutzern das Durchsuchen von Bitcoin-Transaktionen und -Adressen über Electrum.',
blockexplorer_public_api: 'Öffentlicher API-Zugang',
blockexplorer_public_api_desc:
'Nicht-authentifizierten Zugriff auf die Block-Explorer-API-Endpunkte erlauben.',
electrum_server_url: 'Electrum-Server-URL',
electrum_server_url_hint:
'z.B. ssl://electrum.blockstream.info:50002 oder tcp://localhost:50001',
blockexplorer_search_label: 'Nach TXID oder Adresse suchen',
blockexplorer_search_hint:
'64-Zeichen-Hex = Transaktion · Alles andere = Bitcoin-Adresse',
recent_blocks: 'Aktuelle Blöcke',
chain_tip: 'Kettenspitze',
block_height: 'Blockhöhe',
block_fee: 'Blockgebühr',
fee_estimates: 'Gebührenschätzungen',
confirmed_balance: 'Bestätigtes Guthaben',
unconfirmed_balance: 'Unbestätigtes Guthaben',
transaction_history: 'Transaktionsverlauf',
coinbase: 'Coinbase',
inputs: 'Eingaben',
outputs: 'Ausgaben',
confirmations: 'Bestätigungen',
confirmed: 'Bestätigt',
unconfirmed: 'Unbestätigt',
history_unavailable:
'Transaktionsverlauf nicht verfügbar (Adresse hat zu viele Transaktionen)',
address: 'Adresse',
block_number: 'Block #{height}',
block_diff: 'Schw. {value}',
block_hash: 'Hash',
previous_block: 'Vorheriger Block',
merkle_root: 'Merkle-Wurzel',
version: 'Version',
bits: 'Bits',
difficulty: 'Schwierigkeit',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Virtuelle Größe',
weight: 'Gewicht',
n_block_fee: '{n}-Block-Gebühr'
}
+51 -1
View File
@@ -841,5 +841,55 @@ 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.',
blockexplorer_public_api: 'Public API Access',
blockexplorer_public_api_desc:
'Allow unauthenticated access to the block explorer API endpoints.',
electrum_compatible_server: 'Electrum compatible server',
electrum_server_url: 'Electrum Server URL',
electrum_server_url_hint:
'Choose a public Electrum server or enter your own.',
electrum_server_url_custom: 'Custom Electrum Server URL',
view_public_electrum_servers: 'View public Electrum servers',
blockexplorer_network: 'Bitcoin Network',
blockexplorer_network_hint:
'The network the Electrum server is connected to, used to render addresses correctly.',
blockexplorer_search_label: 'Search by TXID or Address',
blockexplorer_search_hint:
'64-char hex = transaction · anything else = Bitcoin address',
recent_blocks: 'Recent Blocks',
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',
confirmed: 'Confirmed',
unconfirmed: 'Unconfirmed',
no_transactions: 'No transactions found',
history_unavailable:
'Transaction history unavailable (address has too many transactions)',
address: 'Address',
block_number: 'Block #{height}',
block_diff: 'diff {value}',
block_hash: 'Hash',
previous_block: 'Previous Block',
merkle_root: 'Merkle Root',
version: 'Version',
bits: 'Bits',
difficulty: 'Difficulty',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Virtual Size',
weight: 'Weight',
n_block_fee: '{n}-block fee'
}
+44 -1
View File
@@ -453,5 +453,48 @@ window.localisation.es = {
http_request_methods: 'Métodos de solicitud HTTP',
http_response_codes: 'Códigos de Respuesta HTTP',
request_details: 'Detalles de la solicitud',
http_request_details: 'Detalles de la Solicitud HTTP'
http_request_details: 'Detalles de la Solicitud HTTP',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Activar Block Explorer',
block_explorer_desc:
'Permite a los usuarios explorar transacciones y direcciones de Bitcoin a través de Electrum.',
blockexplorer_public_api: 'Acceso a la API pública',
blockexplorer_public_api_desc:
'Permitir acceso no autenticado a los endpoints de la API del explorador de bloques.',
electrum_server_url: 'URL del servidor Electrum',
electrum_server_url_hint:
'p.ej. ssl://electrum.blockstream.info:50002 o tcp://localhost:50001',
blockexplorer_search_label: 'Buscar por TXID o dirección',
blockexplorer_search_hint:
'Hex de 64 caracteres = transacción · cualquier otra cosa = dirección Bitcoin',
recent_blocks: 'Bloques recientes',
chain_tip: 'Punta de cadena',
block_height: 'Altura de bloque',
block_fee: 'tarifa de bloque',
fee_estimates: 'Estimaciones de tarifa',
confirmed_balance: 'Saldo confirmado',
unconfirmed_balance: 'Saldo no confirmado',
transaction_history: 'Historial de transacciones',
coinbase: 'Coinbase',
inputs: 'Entradas',
outputs: 'Salidas',
confirmations: 'Confirmaciones',
confirmed: 'Confirmado',
unconfirmed: 'No confirmado',
history_unavailable:
'Historial de transacciones no disponible (la dirección tiene demasiadas transacciones)',
address: 'Dirección',
block_number: 'Bloque #{height}',
block_diff: 'dif {value}',
block_hash: 'Hash',
previous_block: 'Bloque anterior',
merkle_root: 'Raíz de Merkle',
version: 'Versión',
bits: 'Bits',
difficulty: 'Dificultad',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Tamaño virtual',
weight: 'Peso',
n_block_fee: 'tarifa {n} bloques'
}
+44 -1
View File
@@ -647,5 +647,48 @@ window.localisation.fi = {
'On the PayPal side configure a webhook pointing to your LNbits server.',
callback_success_url: 'Callback Success URL',
callback_success_url_hint:
'The user will be redirected to this URL after the payment is successful'
'The user will be redirected to this URL after the payment is successful',
block_explorer: 'Lohkoselain',
enable_block_explorer: 'Ota lohkoselain käyttöön',
block_explorer_desc:
'Salli käyttäjien tutkia Bitcoin-transaktioita ja -osoitteita Electrumin kautta.',
blockexplorer_public_api: 'Julkinen API-pääsy',
blockexplorer_public_api_desc:
'Salli todentamaton pääsy lohkoselain API-päätteisiin.',
electrum_server_url: 'Electrum-palvelimen URL',
electrum_server_url_hint:
'esim. ssl://electrum.blockstream.info:50002 tai tcp://localhost:50001',
blockexplorer_search_label: 'Hae TXID:llä tai osoitteella',
blockexplorer_search_hint:
'64 merkin heksa = transaktio · muu = Bitcoin-osoite',
recent_blocks: 'Viimeisimmät lohkot',
chain_tip: 'Ketjun kärki',
block_height: 'Lohkokorkeus',
block_fee: 'lohkomaksu',
fee_estimates: 'Maksuarviot',
confirmed_balance: 'Vahvistettu saldo',
unconfirmed_balance: 'Vahvistamaton saldo',
transaction_history: 'Tapahtumahistoria',
coinbase: 'Coinbase',
inputs: 'Syötteet',
outputs: 'Tulosteet',
confirmations: 'Vahvistukset',
confirmed: 'Vahvistettu',
unconfirmed: 'Vahvistamaton',
history_unavailable:
'Tapahtumahistoria ei saatavilla (osoitteella on liikaa tapahtumia)',
address: 'Osoite',
block_number: 'Lohko #{height}',
block_diff: 'vaikeus {value}',
block_hash: 'Hash',
previous_block: 'Edellinen lohko',
merkle_root: 'Merkle-juuri',
version: 'Versio',
bits: 'Bitit',
difficulty: 'Vaikeus',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Virtuaalikoko',
weight: 'Paino',
n_block_fee: '{n} lohkon maksu'
}
+44 -1
View File
@@ -456,5 +456,48 @@ window.localisation.fr = {
http_request_methods: 'Méthodes de requête HTTP',
http_response_codes: 'Codes de réponse HTTP',
request_details: 'Détails de la demande',
http_request_details: 'Détails de la requête HTTP'
http_request_details: 'Détails de la requête HTTP',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Activer le Block Explorer',
block_explorer_desc:
"Permet aux utilisateurs d'explorer les transactions et adresses Bitcoin via Electrum.",
blockexplorer_public_api: 'Accès API public',
blockexplorer_public_api_desc:
"Autoriser l'accès non authentifié aux endpoints de l'API de l'explorateur de blocs.",
electrum_server_url: 'URL du serveur Electrum',
electrum_server_url_hint:
'p.ex. ssl://electrum.blockstream.info:50002 ou tcp://localhost:50001',
blockexplorer_search_label: 'Rechercher par TXID ou adresse',
blockexplorer_search_hint:
'Hex 64 caractères = transaction · autre chose = adresse Bitcoin',
recent_blocks: 'Blocs récents',
chain_tip: 'Sommet de chaîne',
block_height: 'Hauteur de bloc',
block_fee: 'frais de bloc',
fee_estimates: 'Estimations de frais',
confirmed_balance: 'Solde confirmé',
unconfirmed_balance: 'Solde non confirmé',
transaction_history: 'Historique des transactions',
coinbase: 'Coinbase',
inputs: 'Entrées',
outputs: 'Sorties',
confirmations: 'Confirmations',
confirmed: 'Confirmé',
unconfirmed: 'Non confirmé',
history_unavailable:
'Historique des transactions indisponible (adresse avec trop de transactions)',
address: 'Adresse',
block_number: 'Bloc #{height}',
block_diff: 'diff {value}',
block_hash: 'Hash',
previous_block: 'Bloc précédent',
merkle_root: 'Racine de Merkle',
version: 'Version',
bits: 'Bits',
difficulty: 'Difficulté',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Taille virtuelle',
weight: 'Poids',
n_block_fee: 'frais {n} blocs'
}
+44 -1
View File
@@ -450,5 +450,48 @@ window.localisation.it = {
http_request_methods: 'Metodi di richiesta HTTP',
http_response_codes: 'Codici di risposta HTTP',
request_details: 'Dettagli della richiesta',
http_request_details: 'Dettagli della richiesta HTTP'
http_request_details: 'Dettagli della richiesta HTTP',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Abilita Block Explorer',
block_explorer_desc:
'Consenti agli utenti di esplorare transazioni e indirizzi Bitcoin tramite Electrum.',
blockexplorer_public_api: 'Accesso API pubblico',
blockexplorer_public_api_desc:
"Consenti accesso non autenticato agli endpoint API dell'esploratore di blocchi.",
electrum_server_url: 'URL server Electrum',
electrum_server_url_hint:
'es. ssl://electrum.blockstream.info:50002 o tcp://localhost:50001',
blockexplorer_search_label: 'Cerca per TXID o indirizzo',
blockexplorer_search_hint:
'Hex 64 caratteri = transazione · altro = indirizzo Bitcoin',
recent_blocks: 'Blocchi recenti',
chain_tip: 'Punta della catena',
block_height: 'Altezza blocco',
block_fee: 'commissione blocco',
fee_estimates: 'Stime delle commissioni',
confirmed_balance: 'Saldo confermato',
unconfirmed_balance: 'Saldo non confermato',
transaction_history: 'Storico transazioni',
coinbase: 'Coinbase',
inputs: 'Input',
outputs: 'Output',
confirmations: 'Conferme',
confirmed: 'Confermato',
unconfirmed: 'Non confermato',
history_unavailable:
"Storico transazioni non disponibile (l'indirizzo ha troppe transazioni)",
address: 'Indirizzo',
block_number: 'Blocco #{height}',
block_diff: 'diff {value}',
block_hash: 'Hash',
previous_block: 'Blocco precedente',
merkle_root: 'Radice di Merkle',
version: 'Versione',
bits: 'Bit',
difficulty: 'Difficoltà',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Dimensione virtuale',
weight: 'Peso',
n_block_fee: 'commissione {n} blocchi'
}
+44 -1
View File
@@ -440,5 +440,48 @@ window.localisation.jp = {
http_request_methods: 'HTTPリクエストメソッド',
http_response_codes: 'HTTPレスポンスコード',
request_details: 'リクエストの詳細',
http_request_details: 'HTTPリクエストの詳細'
http_request_details: 'HTTPリクエストの詳細',
block_explorer: 'ブロックエクスプローラー',
enable_block_explorer: 'ブロックエクスプローラーを有効化',
block_explorer_desc:
'Electrumを介してビットコインのトランザクションとアドレスを探索できます。',
blockexplorer_public_api: 'パブリックAPIアクセス',
blockexplorer_public_api_desc:
'ブロックエクスプローラーAPIエンドポイントへの非認証アクセスを許可します。',
electrum_server_url: 'ElectrumサーバーURL',
electrum_server_url_hint:
'例: ssl://electrum.blockstream.info:50002 または tcp://localhost:50001',
blockexplorer_search_label: 'TXIDまたはアドレスで検索',
blockexplorer_search_hint:
'64文字の16進数 = トランザクション · それ以外 = ビットコインアドレス',
recent_blocks: '最新ブロック',
chain_tip: 'チェーン先端',
block_height: 'ブロック高さ',
block_fee: 'ブロック手数料',
fee_estimates: '手数料見積もり',
confirmed_balance: '確認済み残高',
unconfirmed_balance: '未確認残高',
transaction_history: 'トランザクション履歴',
coinbase: 'コインベース',
inputs: 'インプット',
outputs: 'アウトプット',
confirmations: '確認数',
confirmed: '確認済み',
unconfirmed: '未確認',
history_unavailable:
'トランザクション履歴が取得できません(アドレスのトランザクションが多すぎます)',
address: 'アドレス',
block_number: 'ブロック #{height}',
block_diff: 'diff {value}',
block_hash: 'ハッシュ',
previous_block: '前のブロック',
merkle_root: 'マークルルート',
version: 'バージョン',
bits: 'Bits',
difficulty: '難易度',
nonce: 'Nonce',
txid: 'TXID',
vsize: '仮想サイズ',
weight: '重量',
n_block_fee: '{n}ブロック手数料'
}
+43 -1
View File
@@ -435,5 +435,47 @@ window.localisation.kr = {
http_request_methods: 'HTTP 요청 메서드',
http_response_codes: 'HTTP 응답 코드',
request_details: '요청 세부사항',
http_request_details: 'HTTP 요청 세부사항'
http_request_details: 'HTTP 요청 세부사항',
block_explorer: '블록 탐색기',
enable_block_explorer: '블록 탐색기 활성화',
block_explorer_desc:
'Electrum을 통해 비트코인 거래 및 주소를 탐색할 수 있습니다.',
blockexplorer_public_api: '공개 API 접근',
blockexplorer_public_api_desc:
'블록 탐색기 API 엔드포인트에 대한 비인증 접근을 허용합니다.',
electrum_server_url: 'Electrum 서버 URL',
electrum_server_url_hint:
'예: ssl://electrum.blockstream.info:50002 또는 tcp://localhost:50001',
blockexplorer_search_label: 'TXID 또는 주소로 검색',
blockexplorer_search_hint: '64자 16진수 = 거래 · 그 외 = 비트코인 주소',
recent_blocks: '최근 블록',
chain_tip: '체인 끝',
block_height: '블록 높이',
block_fee: '블록 수수료',
fee_estimates: '수수료 추정',
confirmed_balance: '확인된 잔액',
unconfirmed_balance: '미확인 잔액',
transaction_history: '거래 내역',
coinbase: 'Coinbase',
inputs: '입력',
outputs: '출력',
confirmations: '확인 수',
confirmed: '확인됨',
unconfirmed: '미확인',
history_unavailable:
'거래 내역을 불러올 수 없습니다 (주소의 거래가 너무 많음)',
address: '주소',
block_number: '블록 #{height}',
block_diff: 'diff {value}',
block_hash: '해시',
previous_block: '이전 블록',
merkle_root: '머클 루트',
version: '버전',
bits: 'Bits',
difficulty: '난이도',
nonce: 'Nonce',
txid: 'TXID',
vsize: '가상 크기',
weight: '무게',
n_block_fee: '{n}블록 수수료'
}
+44 -1
View File
@@ -450,5 +450,48 @@ window.localisation.nl = {
http_request_methods: 'HTTP-aanvraagmethoden',
http_response_codes: 'HTTP-responscodes',
request_details: 'Aanvraagdetails',
http_request_details: 'HTTP-verzoekdetails'
http_request_details: 'HTTP-verzoekdetails',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Block Explorer inschakelen',
block_explorer_desc:
'Laat gebruikers Bitcoin-transacties en -adressen verkennen via Electrum.',
blockexplorer_public_api: 'Publieke API-toegang',
blockexplorer_public_api_desc:
'Niet-geauthenticeerde toegang tot de block explorer API-eindpunten toestaan.',
electrum_server_url: 'Electrum-server-URL',
electrum_server_url_hint:
'bijv. ssl://electrum.blockstream.info:50002 of tcp://localhost:50001',
blockexplorer_search_label: 'Zoeken op TXID of adres',
blockexplorer_search_hint:
'64-karakter hex = transactie · alles anders = Bitcoin-adres',
recent_blocks: 'Recente blokken',
chain_tip: 'Kettingtop',
block_height: 'Blokhoogte',
block_fee: 'blokvergoeding',
fee_estimates: 'Vergoedingsschattingen',
confirmed_balance: 'Bevestigd saldo',
unconfirmed_balance: 'Onbevestigd saldo',
transaction_history: 'Transactiegeschiedenis',
coinbase: 'Coinbase',
inputs: 'Invoer',
outputs: 'Uitvoer',
confirmations: 'Bevestigingen',
confirmed: 'Bevestigd',
unconfirmed: 'Onbevestigd',
history_unavailable:
'Transactiegeschiedenis niet beschikbaar (adres heeft te veel transacties)',
address: 'Adres',
block_number: 'Blok #{height}',
block_diff: 'moeil. {value}',
block_hash: 'Hash',
previous_block: 'Vorig blok',
merkle_root: 'Merkle-wortel',
version: 'Versie',
bits: 'Bits',
difficulty: 'Moeilijkheid',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Virtuele grootte',
weight: 'Gewicht',
n_block_fee: '{n}-blok vergoeding'
}
+44 -1
View File
@@ -441,5 +441,48 @@ window.localisation.pi = {
http_request_methods: 'HTTP Request Methods',
http_response_codes: 'HTTP Response Codes',
request_details: 'Request Details',
http_request_details: 'HTTP Request Details'
http_request_details: 'HTTP Request Details',
block_explorer: 'Treasure Map',
enable_block_explorer: 'Hoist the Treasure Map',
block_explorer_desc:
"Let scallywags spy on Bitcoin doubloons an' addresses via Electrum.",
blockexplorer_public_api: 'Open Seas API',
blockexplorer_public_api_desc:
'Allow any landlubber access to the block explorer API ports.',
electrum_server_url: 'Electrum Port URL',
electrum_server_url_hint:
'e.g. ssl://electrum.blockstream.info:50002 or tcp://localhost:50001',
blockexplorer_search_label: 'Search by TXID or Port',
blockexplorer_search_hint:
'64-char hex = plunder · anything else = Bitcoin port',
recent_blocks: 'Recent Plunder',
chain_tip: "Tip o' the Anchor Chain",
block_height: 'Plunder Height',
block_fee: 'plunder fee',
fee_estimates: 'Booty Estimates',
confirmed_balance: 'Confirmed Booty',
unconfirmed_balance: 'Unconfirmed Booty',
transaction_history: 'Plunder History',
coinbase: 'Coinbase',
inputs: 'Inbound Plunder',
outputs: 'Outbound Plunder',
confirmations: 'Confirmations, arr',
confirmed: 'Confirmed, arr',
unconfirmed: 'Unconfirmed, arr',
history_unavailable:
'Plunder history lost at sea (too many transactions, matey!)',
address: 'Port',
block_number: 'Block #{height}',
block_diff: 'diff {value}',
block_hash: 'Hash',
previous_block: 'Previous Plunder Block',
merkle_root: 'Merkle Root',
version: 'Version',
bits: 'Bits',
difficulty: 'Difficulty',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Virtual Size',
weight: 'Weight',
n_block_fee: '{n}-block booty'
}
+44 -1
View File
@@ -444,5 +444,48 @@ window.localisation.pl = {
http_request_methods: 'Metody żądań HTTP',
http_response_codes: 'Kody Odpowiedzi HTTP',
request_details: 'Szczegóły żądania',
http_request_details: 'Szczegóły żądania HTTP'
http_request_details: 'Szczegóły żądania HTTP',
block_explorer: 'Przeglądarka bloków',
enable_block_explorer: 'Włącz przeglądarkę bloków',
block_explorer_desc:
'Umożliwia użytkownikom przeglądanie transakcji i adresów Bitcoin przez Electrum.',
blockexplorer_public_api: 'Publiczny dostęp do API',
blockexplorer_public_api_desc:
'Zezwól na nieuwierzytelniony dostęp do punktów końcowych API przeglądarki bloków.',
electrum_server_url: 'URL serwera Electrum',
electrum_server_url_hint:
'np. ssl://electrum.blockstream.info:50002 lub tcp://localhost:50001',
blockexplorer_search_label: 'Szukaj po TXID lub adresie',
blockexplorer_search_hint:
'64-znakowy hex = transakcja · cokolwiek innego = adres Bitcoin',
recent_blocks: 'Ostatnie bloki',
chain_tip: 'Wierzchołek łańcucha',
block_height: 'Wysokość bloku',
block_fee: 'opłata bloku',
fee_estimates: 'Szacunki opłat',
confirmed_balance: 'Potwierdzony saldo',
unconfirmed_balance: 'Niepotwierdzony saldo',
transaction_history: 'Historia transakcji',
coinbase: 'Coinbase',
inputs: 'Wejścia',
outputs: 'Wyjścia',
confirmations: 'Potwierdzenia',
confirmed: 'Potwierdzone',
unconfirmed: 'Niepotwierdzone',
history_unavailable:
'Historia transakcji niedostępna (adres ma zbyt wiele transakcji)',
address: 'Adres',
block_number: 'Blok #{height}',
block_diff: 'trud. {value}',
block_hash: 'Hash',
previous_block: 'Poprzedni blok',
merkle_root: 'Korzeń Merkle',
version: 'Wersja',
bits: 'Bity',
difficulty: 'Trudność',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Rozmiar wirtualny',
weight: 'Waga',
n_block_fee: 'opłata {n} bloków'
}
+44 -1
View File
@@ -446,5 +446,48 @@ window.localisation.pt = {
http_request_methods: 'Métodos de Requisição HTTP',
http_response_codes: 'Códigos de Resposta HTTP',
request_details: 'Detalhes da solicitação',
http_request_details: 'Detalhes da Solicitação HTTP'
http_request_details: 'Detalhes da Solicitação HTTP',
block_explorer: 'Block Explorer',
enable_block_explorer: 'Ativar Block Explorer',
block_explorer_desc:
'Permite aos utilizadores explorar transações e endereços Bitcoin via Electrum.',
blockexplorer_public_api: 'Acesso à API pública',
blockexplorer_public_api_desc:
'Permitir acesso não autenticado aos endpoints da API do explorador de blocos.',
electrum_server_url: 'URL do servidor Electrum',
electrum_server_url_hint:
'ex. ssl://electrum.blockstream.info:50002 ou tcp://localhost:50001',
blockexplorer_search_label: 'Pesquisar por TXID ou endereço',
blockexplorer_search_hint:
'Hex de 64 caracteres = transação · qualquer outra coisa = endereço Bitcoin',
recent_blocks: 'Blocos recentes',
chain_tip: 'Ponta da cadeia',
block_height: 'Altura do bloco',
block_fee: 'taxa de bloco',
fee_estimates: 'Estimativas de taxa',
confirmed_balance: 'Saldo confirmado',
unconfirmed_balance: 'Saldo não confirmado',
transaction_history: 'Histórico de transações',
coinbase: 'Coinbase',
inputs: 'Entradas',
outputs: 'Saídas',
confirmations: 'Confirmações',
confirmed: 'Confirmado',
unconfirmed: 'Não confirmado',
history_unavailable:
'Histórico de transações indisponível (endereço tem demasiadas transações)',
address: 'Endereço',
block_number: 'Bloco #{height}',
block_diff: 'diff {value}',
block_hash: 'Hash',
previous_block: 'Bloco anterior',
merkle_root: 'Raiz de Merkle',
version: 'Versão',
bits: 'Bits',
difficulty: 'Dificuldade',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Tamanho virtual',
weight: 'Peso',
n_block_fee: 'taxa {n} blocos'
}
+44 -1
View File
@@ -444,5 +444,48 @@ window.localisation.sk = {
http_request_methods: 'Metódy HTTP žiadostí',
http_response_codes: 'Kódy odpovedí HTTP',
request_details: 'Podrobnosti žiadosti',
http_request_details: 'Podrobnosti požiadavky HTTP'
http_request_details: 'Podrobnosti požiadavky HTTP',
block_explorer: 'Prehliadač blokov',
enable_block_explorer: 'Povoliť prehliadač blokov',
block_explorer_desc:
'Umožňuje používateľom prehliadať bitcoinové transakcie a adresy cez Electrum.',
blockexplorer_public_api: 'Verejný prístup k API',
blockexplorer_public_api_desc:
'Povoliť neoverený prístup k API koncovým bodom prieskumníka blokov.',
electrum_server_url: 'URL Electrum servera',
electrum_server_url_hint:
'napr. ssl://electrum.blockstream.info:50002 alebo tcp://localhost:50001',
blockexplorer_search_label: 'Hľadať podľa TXID alebo adresy',
blockexplorer_search_hint:
'64-znakový hex = transakcia · čokoľvek iné = bitcoinová adresa',
recent_blocks: 'Nedávne bloky',
chain_tip: 'Vrchol reťaze',
block_height: 'Výška bloku',
block_fee: 'poplatok bloku',
fee_estimates: 'Odhady poplatkov',
confirmed_balance: 'Potvrdený zostatok',
unconfirmed_balance: 'Nepotvrdený zostatok',
transaction_history: 'História transakcií',
coinbase: 'Coinbase',
inputs: 'Vstupy',
outputs: 'Výstupy',
confirmations: 'Potvrdenia',
confirmed: 'Potvrdené',
unconfirmed: 'Nepotvrdené',
history_unavailable:
'História transakcií nedostupná (adresa má príliš veľa transakcií)',
address: 'Adresa',
block_number: 'Blok #{height}',
block_diff: 'obth. {value}',
block_hash: 'Hash',
previous_block: 'Predchádzajúci blok',
merkle_root: 'Merkle koreň',
version: 'Verzia',
bits: 'Bity',
difficulty: 'Obťažnosť',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Virtuálna veľkosť',
weight: 'Váha',
n_block_fee: 'poplatok {n} blokov'
}
+44 -1
View File
@@ -442,5 +442,48 @@ window.localisation.we = {
http_request_methods: 'Dulliau Cais HTTP',
http_response_codes: 'Codau Ymateb HTTP',
request_details: 'Manylion y Cais',
http_request_details: 'Manylion Cais HTTP'
http_request_details: 'Manylion Cais HTTP',
block_explorer: 'Archwiliwr Bloc',
enable_block_explorer: "Galluogi'r Archwiliwr Bloc",
block_explorer_desc:
'Caniatáu i ddefnyddwyr archwilio trafodion a chyfeiriadau Bitcoin drwy Electrum.',
blockexplorer_public_api: 'Mynediad API Cyhoeddus',
blockexplorer_public_api_desc:
'Caniatáu mynediad heb ddilysu i bwyntiau terfyn API yr archwiliwr bloc.',
electrum_server_url: 'URL Gweinydd Electrum',
electrum_server_url_hint:
'e.e. ssl://electrum.blockstream.info:50002 neu tcp://localhost:50001',
blockexplorer_search_label: 'Chwilio yn ôl TXID neu Gyfeiriad',
blockexplorer_search_hint:
'Hex 64 nod = trafodiad · unrhyw beth arall = cyfeiriad Bitcoin',
recent_blocks: 'Blociau Diweddar',
chain_tip: 'Blaen y Gadwyn',
block_height: 'Uchder Bloc',
block_fee: 'ffi bloc',
fee_estimates: 'Amcangyfrifon Ffi',
confirmed_balance: 'Balans Cadarnhawyd',
unconfirmed_balance: 'Balans Heb ei Gadarnhau',
transaction_history: 'Hanes Trafodion',
coinbase: 'Coinbase',
inputs: 'Mewnbynnau',
outputs: 'Allbynnau',
confirmations: 'Cadarnhadau',
confirmed: 'Cadarnhawyd',
unconfirmed: 'Heb ei Gadarnhau',
history_unavailable:
'Hanes trafodion ar goll (mae cyfeiriad â gormod o drafodion)',
address: 'Cyfeiriad',
block_number: 'Bloc #{height}',
block_diff: 'anhawster {value}',
block_hash: 'Hash',
previous_block: 'Bloc Blaenorol',
merkle_root: 'Gwreiddyn Merkle',
version: 'Fersiwn',
bits: 'Bits',
difficulty: 'Anhawster',
nonce: 'Nonce',
txid: 'TXID',
vsize: 'Maint Rhithwir',
weight: 'Pwysau',
n_block_fee: 'ffi {n} bloc'
}
+9
View File
@@ -193,5 +193,14 @@ window._lnbitsApi = {
return LNbits.api
.request('GET', `/admin/api/v1/settings/default?field_name=${fieldName}`)
.catch(LNbits.utils.notifyApiError)
},
getBlockexplorerAddress(address) {
return this.request('get', `/blockexplorer/api/v1/address/${address}`)
},
getBlockexplorerTransaction(txid) {
return this.request('get', `/blockexplorer/api/v1/tx/${txid}`)
},
getBlockexplorerUtxos(address) {
return this.request('get', `/blockexplorer/api/v1/utxos/${address}`)
}
}
@@ -0,0 +1,40 @@
window.app.component('lnbits-admin-blockexplorer', {
props: ['form-data'],
template: '#lnbits-admin-blockexplorer',
data() {
return {
electrumServers: [
'ssl://fulcrum.lnbits.com:50002',
'ssl://mainnet.nunchuk.io:52002',
'ssl://fulcrum.grey.pw:50002',
'ssl://electrum2.bluewallet.io:443',
'ssl://electrum.acinq.co:50002',
'ssl://electrum.blockstream.info:50002',
'ssl://bitcoin.mullvad.net:5010'
]
}
},
computed: {
electrumServerOptions() {
return [...this.electrumServers, 'Custom']
},
electrumServerPreset: {
get() {
return this.electrumServers.includes(
this.formData.lnbits_blockexplorer_electrum_url
)
? this.formData.lnbits_blockexplorer_electrum_url
: 'Custom'
},
set(value) {
if (value === 'Custom') {
if (this.electrumServerPreset !== 'Custom') {
this.formData.lnbits_blockexplorer_electrum_url = ''
}
return
}
this.formData.lnbits_blockexplorer_electrum_url = value
}
}
}
})
+5
View File
@@ -66,6 +66,11 @@ const routes = [
name: 'NodePublic',
component: PageNodePublic
},
{
path: '/blockexplorer',
name: 'BlockExplorer',
component: PageBlockExplorer
},
{
path: '/payments',
name: 'Payments',
+185
View File
@@ -0,0 +1,185 @@
window.PageBlockExplorer = {
template: '#page-blockexplorer',
data() {
return {
query: '',
loading: false,
tip: null,
fees: null,
blocks: [],
selectedBlock: null,
blockDialog: false,
txResult: null,
txStatus: null,
addressResult: null,
currentAddress: ''
}
},
computed: {
feeList() {
if (!this.fees || !this.fees.estimates) return []
return Object.entries(this.fees.estimates).map(([blocks, rate]) => ({
label: this.$t('n_block_fee', {n: blocks}),
rate: (rate * 100000).toFixed(1) + ' sat/vB'
}))
},
formattedBlocks() {
const now = Math.floor(Date.now() / 1000)
return this.blocks.map(b => ({
...b,
shortHash: b.hash.slice(0, 8) + '...' + b.hash.slice(-4),
timeAgo: this._timeAgo(now - b.timestamp),
utcTime: new Date(b.timestamp * 1000).toUTCString(),
difficulty: this._difficulty(b.bits)
}))
}
},
async created() {
await Promise.all([this.loadTip(), this.loadFees(), this.loadBlocks()])
this._blockWsActive = true
this._connectBlocksWs()
},
beforeUnmount() {
this._blockWsActive = false
if (this._blockWs) this._blockWs.close()
if (this._searchWs) this._searchWs.close()
},
methods: {
_wsUrl(path) {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${proto}//${window.location.host}/blockexplorer/api/v1${path}`
},
_connectBlocksWs() {
const ws = new WebSocket(this._wsUrl('/ws/blocks'))
ws.onmessage = e => {
const block = JSON.parse(e.data)
const rest = this.blocks.filter(b => b.height !== block.height)
this.blocks = [block, ...rest].slice(0, 5)
}
ws.onerror = () => ws.close()
ws.onclose = () => {
if (this._blockWsActive) setTimeout(() => this._connectBlocksWs(), 5000)
}
this._blockWs = ws
},
_connectSearchWs(path, onMessage) {
if (this._searchWs) {
this._searchWs.close()
this._searchWs = null
}
const ws = new WebSocket(this._wsUrl(path))
ws.onmessage = e => {
try {
onMessage(JSON.parse(e.data))
} catch (_) {}
}
ws.onerror = () => ws.close()
this._searchWs = ws
},
_timeAgo(seconds) {
if (seconds < 60) return seconds + 's ago'
if (seconds < 3600) return Math.floor(seconds / 60) + 'm ago'
return Math.floor(seconds / 3600) + 'h ago'
},
_difficulty(bitsHex) {
const exp = parseInt(bitsHex.slice(0, 2), 16)
const mantissa = parseInt(bitsHex.slice(2), 16)
const diff1 = 0xffff * Math.pow(2, 208)
const target = mantissa * Math.pow(2, 8 * (exp - 3))
const d = diff1 / target
if (d >= 1e12) return (d / 1e12).toFixed(2) + 'T'
if (d >= 1e9) return (d / 1e9).toFixed(2) + 'G'
if (d >= 1e6) return (d / 1e6).toFixed(2) + 'M'
return d.toFixed(0)
},
openBlock(b) {
this.selectedBlock = b
this.blockDialog = true
},
async loadBlocks() {
try {
const r = await LNbits.api.request(
'GET',
'/blockexplorer/api/v1/blocks'
)
this.blocks = r.data
} catch (_) {}
},
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 (_) {}
},
clearResult() {
this.txResult = null
this.txStatus = null
this.addressResult = null
this.query = ''
if (this._searchWs) {
this._searchWs.close()
this._searchWs = null
}
},
async search() {
const q = this.query.trim()
if (!q) return
this.txResult = null
this.txStatus = 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.txStatus = null
this.addressResult = null
this.query = txid
this._connectSearchWs(`/ws/tx/${txid}`, data => {
if (!data.error) this.txStatus = data
})
} 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.txStatus = null
this.currentAddress = address
this.query = address
this._connectSearchWs(`/ws/address/${address}`, data => {
if (!data.error) this.addressResult = data
})
} catch (e) {
LNbits.utils.notifyApiError(e)
}
}
}
}
+2
View File
@@ -71,6 +71,8 @@
"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/pages/blockexplorer.js",
"js/components/lnbits-wallet-charts.js",
"js/components/lnbits-wallet-api-docs.js",
"js/components/lnbits-wallet-icon.js",
+360 -5
View File
@@ -3,12 +3,23 @@ import traceback
import uuid
from collections.abc import Callable, Coroutine
from datetime import datetime, timezone
from typing import TypeVar
from fastapi import WebSocket
from loguru import logger
from pydantic import BaseModel
from lnbits.core.models import Payment
from lnbits.settings import settings
from lnbits.utils.electrum import (
AddressTracker,
BlockInfo,
BlockTracker,
OnchainAddressEvent,
OnchainTxEvent,
TransactionTracker,
scripthash_from_address,
)
class PublicTask(BaseModel):
@@ -26,26 +37,41 @@ class Task:
created_at: datetime
task: asyncio.Task
invoice_queue: asyncio.Queue[Payment] | None = None
onchain_address_queue: asyncio.Queue[OnchainAddressEvent] | None = None
onchain_tx_queue: asyncio.Queue[OnchainTxEvent] | None = None
block_queue: asyncio.Queue[BlockInfo] | None = None
def __init__(
self,
coro: Coroutine,
name: str | None = None,
invoice_queue: asyncio.Queue | None = None,
onchain_address_queue: asyncio.Queue | None = None,
onchain_tx_queue: asyncio.Queue | None = None,
block_queue: asyncio.Queue | None = None,
) -> None:
self.coro = coro
self.name = name or f"task_{uuid.uuid4()}"
self.created_at = datetime.now(timezone.utc)
self.task = asyncio.create_task(self.coro, name=self.name)
self.invoice_queue = invoice_queue
self.onchain_address_queue = onchain_address_queue
self.onchain_tx_queue = onchain_tx_queue
self.block_queue = block_queue
class TaskManager:
"""Singleton class to manage background tasks."""
ONCHAIN_ADDRESS_LISTENER_SUFFIX = "_onchain_address_listener"
tasks: list[Task] = []
invoice_queue: asyncio.Queue[Payment] = asyncio.Queue()
internal_invoice_queue: asyncio.Queue[Payment] = asyncio.Queue()
_address_tracker: "AddressTracker | None" = None
_block_tracker: "BlockTracker | None" = None
_tx_trackers: dict[str, "TransactionTracker"] = {}
_tracked_addresses_by_listener: dict[str, set[str]] = {}
def init(self) -> None:
self.create_permanent_task(
@@ -84,13 +110,23 @@ class TaskManager:
coro: Coroutine,
name: str | None = None,
invoice_queue: asyncio.Queue | None = None,
onchain_address_queue: asyncio.Queue | None = None,
onchain_tx_queue: asyncio.Queue | None = None,
block_queue: asyncio.Queue | None = None,
) -> Task:
"""Create a task. If a task with the same name exists, it will be cancelled."""
if name:
task = self.get_task(name)
if task:
self.cancel_task(task)
task = Task(coro=coro, name=name, invoice_queue=invoice_queue)
task = Task(
coro=coro,
name=name,
invoice_queue=invoice_queue,
onchain_address_queue=onchain_address_queue,
onchain_tx_queue=onchain_tx_queue,
block_queue=block_queue,
)
self.tasks.append(task)
return task
@@ -98,6 +134,9 @@ class TaskManager:
self,
func: Callable[[], Coroutine],
invoice_queue: asyncio.Queue | None = None,
onchain_address_queue: asyncio.Queue | None = None,
onchain_tx_queue: asyncio.Queue | None = None,
block_queue: asyncio.Queue | None = None,
name: str | None = None,
interval: int = 0,
) -> Task:
@@ -110,7 +149,12 @@ class TaskManager:
await asyncio.sleep(interval)
return self.create_task(
coro=wrapper(), name=name or func.__name__, invoice_queue=invoice_queue
coro=wrapper(),
name=name or func.__name__,
invoice_queue=invoice_queue,
onchain_address_queue=onchain_address_queue,
onchain_tx_queue=onchain_tx_queue,
block_queue=block_queue,
)
def register_invoice_listener(
@@ -130,6 +174,185 @@ class TaskManager:
invoice_queue=queue,
)
def register_onchain_listener(
self,
func: Callable[[OnchainAddressEvent], Coroutine],
name: str | None = None,
) -> Task:
"""
Register a callback for onchain address events. Only dispatches events
for addresses tracked under the same `name` via track_address, e.g. an
extension registering as "ext_satspay" only sees events for addresses
it tracked with that same name. Defaults to the shared "core" listener
if no name is given.
"""
name = name or "core"
queue: asyncio.Queue[OnchainAddressEvent] = asyncio.Queue()
return self.create_permanent_task(
self._onchain_address_listener_worker(func, queue),
name=f"{name}{self.ONCHAIN_ADDRESS_LISTENER_SUFFIX}",
onchain_address_queue=queue,
)
def register_onchain_tx_listener(
self,
func: Callable[[OnchainTxEvent], Coroutine],
name: str | None = None,
) -> Task:
"""
Register a callback for onchain transaction events dispatched for any
transaction currently tracked via register_ws_tx_queue.
Will call the provided coroutine with an OnchainTxEvent on each update.
"""
name = f"{name or uuid.uuid4()}_onchain_tx_listener"
queue: asyncio.Queue[OnchainTxEvent] = asyncio.Queue()
return self.create_permanent_task(
self._onchain_tx_listener_worker(func, queue),
name=name,
onchain_tx_queue=queue,
)
def register_block_listener(
self,
func: Callable[[BlockInfo], Coroutine],
name: str | None = None,
) -> Task:
"""
Register a callback for new block events dispatched while the shared
block tracker is running (i.e. while a websocket or other consumer has
requested block updates via register_ws_block_queue).
Will call the provided coroutine with a BlockInfo on each new block.
"""
name = f"{name or uuid.uuid4()}_block_listener"
queue: asyncio.Queue[BlockInfo] = asyncio.Queue()
return self.create_permanent_task(
self._block_listener_worker(func, queue),
name=name,
block_queue=queue,
)
def track_address(self, address: str, name: str) -> None:
"""Start tracking a Bitcoin address via Electrum (ref-counted).
`name` identifies the listener (see register_onchain_listener) that
should receive events for this address.
"""
self._get_address_tracker().add(address)
self._tracked_addresses_by_listener.setdefault(name, set()).add(address)
def untrack_address(self, address: str, name: str) -> None:
"""Decrement ref count; remove from shared tracker when last caller leaves."""
if self._address_tracker:
self._address_tracker.remove(address)
tracked = self._tracked_addresses_by_listener.get(name)
if tracked:
tracked.discard(address)
if not tracked:
self._tracked_addresses_by_listener.pop(name, None)
def _get_address_tracker(self) -> "AddressTracker":
if self._address_tracker is None:
self._address_tracker = AddressTracker(
settings.lnbits_blockexplorer_electrum_url
)
if not self.get_task("address_tracker"):
self.create_task(
self._address_tracker.run(
self._dispatch_onchain_event,
lambda: settings.lnbits_running,
),
name="address_tracker",
)
return self._address_tracker
def register_ws_address_queue(
self, address: str, queue: asyncio.Queue[OnchainAddressEvent]
) -> None:
"""Register a per-connection queue for a watched address.
Raises ValueError if the address is invalid.
"""
scripthash_from_address(address)
self._get_address_tracker().register_queue(address, queue)
def unregister_ws_address_queue(
self, address: str, queue: asyncio.Queue[OnchainAddressEvent]
) -> None:
"""Deregister a per-connection queue and decrement the address ref count."""
if self._address_tracker:
self._address_tracker.unregister_queue(address, queue)
def register_ws_tx_queue(
self, txid: str, queue: asyncio.Queue[OnchainTxEvent]
) -> None:
"""Register a per-connection queue for a watched transaction."""
self._get_tx_tracker(txid).register_queue(queue)
def unregister_ws_tx_queue(
self, txid: str, queue: asyncio.Queue[OnchainTxEvent]
) -> None:
"""Deregister a per-connection queue; cancel tracker when last one leaves."""
tracker = self._tx_trackers.get(txid)
if not tracker:
return
tracker.unregister_queue(queue)
if not tracker.has_queues():
self._tx_trackers.pop(txid, None)
task = self.get_task(f"ws_tx_{txid}")
if task:
self.cancel_task(task)
def _get_tx_tracker(self, txid: str) -> "TransactionTracker":
tracker = self._tx_trackers.get(txid)
if tracker is None:
tracker = TransactionTracker(settings.lnbits_blockexplorer_electrum_url)
self._tx_trackers[txid] = tracker
if not self.get_task(f"ws_tx_{txid}"):
self.create_task(
self._transaction_tracker_dispatch(txid, tracker),
name=f"ws_tx_{txid}",
)
return tracker
def register_ws_block_queue(self, queue: asyncio.Queue[BlockInfo]) -> None:
"""Register a per-connection queue for new block events."""
if self._block_tracker is None:
self._block_tracker = BlockTracker(
settings.lnbits_blockexplorer_electrum_url
)
tracker = self._block_tracker
was_empty = not tracker.has_queues()
tracker.register_queue(queue)
if was_empty:
self.create_task(
tracker.run(
self._dispatch_block_event,
lambda: tracker.has_queues() and settings.lnbits_running,
),
name="block_tracker",
)
def unregister_ws_block_queue(self, queue: asyncio.Queue[BlockInfo]) -> None:
"""Deregister a per-connection queue; cancel tracker when last one leaves."""
if not self._block_tracker:
return
self._block_tracker.unregister_queue(queue)
if not self._block_tracker.has_queues():
task = self.get_task("block_tracker")
if task:
self.cancel_task(task)
def track_transaction(
self,
txid: str,
callback: Callable[[OnchainTxEvent], Coroutine],
) -> Task:
"""Track a transaction until confirmed, calling callback on each change."""
return self.create_task(
self._transaction_tracker(txid, callback),
name=f"onchain_tx_{txid}",
)
async def _heart_beat(self) -> None:
"""A heartbeat that removes done tasks logs the number of tasks."""
for task in self.tasks:
@@ -142,10 +365,17 @@ class TaskManager:
if task.task and task.task.done():
logger.debug(f"Task Manager: task `{task.name}` is done.")
self.cancel_task(task)
listeners_count = sum(1 for task in self.tasks if task.invoice_queue)
invoice_listeners = sum(1 for task in self.tasks if task.invoice_queue)
onchain_listeners = sum(
1
for task in self.tasks
if task.onchain_address_queue or task.onchain_tx_queue or task.block_queue
)
other_tasks = len(self.tasks) - invoice_listeners - onchain_listeners
logger.debug(
f"Task Manager: {len(self.tasks) - listeners_count} tasks "
f"and {listeners_count} invoice listeners."
f"Task Manager: {other_tasks} tasks, "
f"{invoice_listeners} invoice listeners, "
f"{onchain_listeners} onchain listeners."
)
async def _catch_everything_and_restart(
@@ -178,6 +408,39 @@ class TaskManager:
return wrapper
def _onchain_address_listener_worker(
self,
func: Callable[[OnchainAddressEvent], Coroutine],
queue: asyncio.Queue[OnchainAddressEvent],
) -> Callable:
async def wrapper() -> None:
event: OnchainAddressEvent = await queue.get()
await func(event)
return wrapper
def _onchain_tx_listener_worker(
self,
func: Callable[[OnchainTxEvent], Coroutine],
queue: asyncio.Queue[OnchainTxEvent],
) -> Callable:
async def wrapper() -> None:
event: OnchainTxEvent = await queue.get()
await func(event)
return wrapper
def _block_listener_worker(
self,
func: Callable[[BlockInfo], Coroutine],
queue: asyncio.Queue[BlockInfo],
) -> Callable:
async def wrapper() -> None:
event: BlockInfo = await queue.get()
await func(event)
return wrapper
def _invoice_dispatcher(self, payment: Payment) -> None:
"""Dispatches a payment to all registered invoice listeners."""
for task in self.tasks:
@@ -186,6 +449,39 @@ class TaskManager:
logger.debug(f"Enqueing payment to task {task.name}")
task.invoice_queue.put_nowait(payment)
async def _dispatch_onchain_event(self, event: OnchainAddressEvent) -> None:
"""Dispatches an onchain address event to listeners tracking that
address under their own name (see track_address).
Per-address WS queue fan-out is handled by AddressTracker itself.
"""
for task in self.tasks:
if not task.onchain_address_queue:
continue
if not task.name.endswith(self.ONCHAIN_ADDRESS_LISTENER_SUFFIX):
continue
name = task.name[: -len(self.ONCHAIN_ADDRESS_LISTENER_SUFFIX)]
if event.address in self._tracked_addresses_by_listener.get(name, ()):
task.onchain_address_queue.put_nowait(event)
async def _dispatch_onchain_tx_event(self, event: OnchainTxEvent) -> None:
"""Dispatches an onchain tx event to registered listeners.
Per-tx WS queue fan-out is handled by TransactionTracker itself.
"""
for task in self.tasks:
if task.onchain_tx_queue:
task.onchain_tx_queue.put_nowait(event)
async def _dispatch_block_event(self, event: BlockInfo) -> None:
"""Dispatches a new block event to registered listeners.
Per-connection WS queue fan-out is handled by BlockTracker itself.
"""
for task in self.tasks:
if task.block_queue:
task.block_queue.put_nowait(event)
async def _invoice_listener_consumer(self) -> None:
payment = await self.invoice_queue.get()
logger.info(f"got a payment notification {payment.checking_id}")
@@ -196,5 +492,64 @@ class TaskManager:
logger.info(f"got an internal payment notification {payment.checking_id}")
self._invoice_dispatcher(payment)
async def _transaction_tracker(
self, txid: str, callback: Callable[[OnchainTxEvent], Coroutine]
) -> None:
await TransactionTracker(settings.lnbits_blockexplorer_electrum_url).track(
txid,
callback,
lambda: settings.lnbits_running,
)
async def _transaction_tracker_dispatch(
self, txid: str, tracker: "TransactionTracker"
) -> None:
await tracker.track(
txid,
self._dispatch_onchain_tx_event,
lambda: tracker.has_queues() and settings.lnbits_running,
)
T = TypeVar("T", bound=BaseModel)
async def relay_ws_queue(
websocket: WebSocket,
queue: "asyncio.Queue[T]",
serialize: Callable[[T], BaseModel] = lambda e: e,
stop_after: Callable[[T], bool] = lambda _: False,
) -> None:
"""
Pumps events from `queue` to `websocket` as JSON until the client
disconnects, sending fails, or `stop_after` returns True for an event.
Shared by the blockexplorer address/tx/block websocket endpoints.
"""
try:
while settings.lnbits_running:
recv_task = asyncio.create_task(websocket.receive())
event_task = asyncio.create_task(queue.get())
done, pending = await asyncio.wait(
[recv_task, event_task], return_when=asyncio.FIRST_COMPLETED
)
for t in pending:
t.cancel()
disconnect = recv_task in done and (
recv_task.result().get("type") == "websocket.disconnect"
)
if disconnect:
break
if event_task in done:
event = event_task.result()
try:
await websocket.send_json(serialize(event).dict())
except Exception as exc:
logger.debug(f"ws relay send error: {exc}")
break
if stop_after(event):
break
except Exception as exc:
logger.debug(f"ws relay error: {exc}")
task_manager = TaskManager()
+16
View File
@@ -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') %}
<q-icon name="chevron_right" color="grey-5" size="md"></q-icon>
</q-item-section>
</q-item>
<q-item v-if="g.settings.showBlockExplorer" to="/blockexplorer">
<q-item-section side>
<q-icon
name="travel_explore"
:color="isActive('/blockexplorer') ? 'primary' : 'grey-5'"
size="md"
></q-icon>
</q-item-section>
<q-item-section>
<q-item-label lines="1" v-text="$t('block_explorer')"></q-item-label>
</q-item-section>
<q-item-section side v-show="isActive('/blockexplorer')">
<q-icon name="chevron_right" color="grey-5" size="md"></q-icon>
</q-item-section>
</q-item>
</div>
<q-item to="/payments">
<q-item-section side>
@@ -0,0 +1,90 @@
<template id="lnbits-admin-blockexplorer">
<q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('block_explorer')"></span>
</h6>
<div class="row q-mb-lg">
<div class="col-md-6 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_blockexplorer_enabled"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
<q-item-section>
<q-item-label v-text="$t('enable_block_explorer')"></q-item-label>
<q-item-label
caption
v-text="$t('block_explorer_desc')"
></q-item-label>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_blockexplorer_public_api"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
<q-item-section>
<q-item-label
v-text="$t('blockexplorer_public_api')"
></q-item-label>
<q-item-label
caption
v-text="$t('blockexplorer_public_api_desc')"
></q-item-label>
</q-item-section>
</q-item>
</div>
</div>
<q-separator class="q-mb-lg q-mt-sm"></q-separator>
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('electrum_compatible_server')"></span>
</h6>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-4">
<q-select
filled
v-model="electrumServerPreset"
:options="electrumServerOptions"
:label="$t('electrum_server_url')"
:hint="$t('electrum_server_url_hint')"
></q-select>
</div>
<div class="col-12 col-md-4" v-if="electrumServerPreset === 'Custom'">
<q-input
filled
v-model="formData.lnbits_blockexplorer_electrum_url"
:label="$t('electrum_server_url_custom')"
:hint="$t('electrum_server_url_hint')"
></q-input>
</div>
<div class="col-12 col-md-4">
<q-select
filled
v-model="formData.lnbits_blockexplorer_network"
:options="['main', 'test', 'regtest', 'signet']"
:label="$t('blockexplorer_network')"
:hint="$t('blockexplorer_network_hint')"
></q-select>
</div>
<div class="col-12">
<a
href="https://1209k.com/bitcoin-eye/ele.php?chain=btc"
target="_blank"
rel="noopener noreferrer"
>
<span v-text="$t('view_public_electrum_servers')"></span>
</a>
</div>
</div>
</q-card-section>
</template>
+1 -1
View File
@@ -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') %}
+12 -1
View File
@@ -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 @@
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('site_customisation')"></span></q-tooltip
></q-tab>
<q-tab
name="blockexplorer"
icon="travel_explore"
:label="$q.screen.gt.sm ? $t('block_explorer') : null"
><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('block_explorer')"></span></q-tooltip
></q-tab>
</q-tabs>
</template>
@@ -235,6 +243,9 @@
<q-tab-panel name="assets-config">
<lnbits-admin-assets-config :form-data="formData" />
</q-tab-panel>
<q-tab-panel name="blockexplorer">
<lnbits-admin-blockexplorer :form-data="formData" />
</q-tab-panel>
</q-tab-panels>
</q-scroll-area>
</q-form>
+385
View File
@@ -0,0 +1,385 @@
<template id="page-blockexplorer">
<div class="row q-col-gutter-md">
<!-- Left column: blocks + search + results -->
<div class="col-12 col-md-7 q-gutter-y-md">
<!-- Recent blocks (mempool-style squares) -->
<q-card v-if="blocks.length">
<q-card-section>
<div
class="text-subtitle1 q-mb-md"
v-text="$t('recent_blocks')"
></div>
<div class="row q-gutter-sm">
<q-card
v-for="b in formattedBlocks"
:key="b.height"
flat
class="bg-primary text-white cursor-pointer"
v-ripple
@click="openBlock(b)"
>
<q-card-section class="q-pa-sm">
<div
class="text-subtitle1 text-weight-bold"
v-text="'#' + b.height.toLocaleString()"
></div>
<div class="text-caption q-mt-xs" v-text="b.timeAgo"></div>
<div class="text-caption q-mt-sm">
<code v-text="b.shortHash"></code>
</div>
<div
class="text-caption"
v-text="$t('block_diff', {value: b.difficulty})"
></div>
</q-card-section>
</q-card>
</div>
</q-card-section>
</q-card>
<!-- Block detail dialog -->
<q-dialog v-model="blockDialog">
<q-card v-if="selectedBlock">
<q-card-section class="bg-primary text-white q-pb-sm">
<div
class="text-h6"
v-text="
$t('block_number', {
height: selectedBlock.height.toLocaleString()
})
"
></div>
<div class="text-caption" v-text="selectedBlock.utcTime"></div>
</q-card-section>
<q-card-section>
<div class="q-mb-md">
<div
class="text-caption text-grey q-mb-xs"
v-text="$t('block_hash')"
></div>
<code
class="text-caption be-wrap"
v-text="selectedBlock.hash"
></code>
</div>
<div class="q-mb-md">
<div
class="text-caption text-grey q-mb-xs"
v-text="$t('previous_block')"
></div>
<code
class="text-caption be-wrap"
v-text="selectedBlock.prev_hash"
></code>
</div>
<div class="q-mb-lg">
<div
class="text-caption text-grey q-mb-xs"
v-text="$t('merkle_root')"
></div>
<code
class="text-caption be-wrap"
v-text="selectedBlock.merkle_root"
></code>
</div>
<div class="row q-col-gutter-md">
<div class="col-6 col-sm-3">
<div
class="text-caption text-grey"
v-text="$t('version')"
></div>
<div v-text="'0x' + selectedBlock.version.toString(16)"></div>
</div>
<div class="col-6 col-sm-3">
<div class="text-caption text-grey" v-text="$t('bits')"></div>
<div v-text="selectedBlock.bits"></div>
</div>
<div class="col-6 col-sm-3">
<div
class="text-caption text-grey"
v-text="$t('difficulty')"
></div>
<div v-text="selectedBlock.difficulty"></div>
</div>
<div class="col-6 col-sm-3">
<div class="text-caption text-grey" v-text="$t('nonce')"></div>
<div v-text="selectedBlock.nonce.toLocaleString()"></div>
</div>
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat v-close-popup v-text="$t('close')"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<q-card>
<q-card-section>
<div class="text-h6" v-text="$t('block_explorer')"></div>
</q-card-section>
<q-card-section class="q-pt-none">
<q-input
filled
v-model="query"
:label="$t('blockexplorer_search_label')"
:hint="$t('blockexplorer_search_hint')"
@keyup.enter="search"
>
<template v-slot:append>
<q-btn
flat
round
dense
icon="search"
:loading="loading"
@click="search"
/>
</template>
</q-input>
</q-card-section>
</q-card>
<!-- Transaction result -->
<q-card v-if="txResult">
<q-card-section>
<div class="row items-center justify-between q-mb-sm">
<div class="row items-center q-gutter-sm">
<div class="text-subtitle1" v-text="$t('transaction')"></div>
<q-badge
v-if="txStatus"
:color="txStatus.confirmed ? 'positive' : 'orange'"
:label="
txStatus.confirmed ? $t('confirmed') : $t('unconfirmed')
"
></q-badge>
<q-spinner v-if="!txStatus" size="1em" color="grey" />
</div>
<q-btn flat round dense icon="close" @click="clearResult" />
</div>
<div class="q-mb-sm">
<span
class="text-caption text-grey"
v-text="$t('txid') + ': '"
></span>
<code class="text-caption be-wrap" v-text="txResult.txid"></code>
</div>
<div class="row q-col-gutter-md q-mb-sm">
<div class="col-auto" v-if="txStatus && txStatus.height">
<div
class="text-caption text-grey"
v-text="$t('block_height')"
></div>
<div v-text="txStatus.height.toLocaleString()"></div>
</div>
<div class="col-auto" v-if="txStatus && txStatus.fee !== null">
<div class="text-caption text-grey" v-text="$t('fee')"></div>
<div v-text="txStatus.fee + ' sat'"></div>
</div>
<div class="col-auto" v-if="txResult.vsize || txResult.size">
<div class="text-caption text-grey" v-text="$t('vsize')"></div>
<div v-text="(txResult.vsize || txResult.size) + ' vB'"></div>
</div>
<div class="col-auto" v-if="txResult.weight">
<div class="text-caption text-grey" v-text="$t('weight')"></div>
<div v-text="txResult.weight + ' WU'"></div>
</div>
</div>
<q-expansion-item
icon="login"
:label="$t('inputs') + ' (' + txResult.vin.length + ')'"
dense
class="q-mb-xs"
>
<q-list dense separator>
<q-item v-for="(vin, i) in txResult.vin" :key="i">
<q-item-section>
<q-item-label
v-if="vin.coinbase"
class="text-grey"
v-text="$t('coinbase')"
>
</q-item-label>
<q-item-label v-else class="be-wrap">
<a
href="#"
@click.prevent="loadTx(vin.txid)"
class="text-primary"
v-text="vin.txid + ':' + vin.vout"
></a>
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-expansion-item>
<q-expansion-item
icon="logout"
:label="$t('outputs') + ' (' + txResult.vout.length + ')'"
dense
>
<q-list dense separator>
<q-item v-for="(vout, i) in txResult.vout" :key="i">
<q-item-section>
<q-item-label>
<template
v-if="vout.scriptPubKey && vout.scriptPubKey.address"
>
<a
href="#"
@click.prevent="loadAddress(vout.scriptPubKey.address)"
class="text-primary"
v-text="vout.scriptPubKey.address"
></a>
</template>
<template
v-else-if="
vout.scriptPubKey &&
vout.scriptPubKey.type === 'nulldata'
"
>
<span class="text-grey">OP_RETURN</span>
</template>
<template v-else-if="vout.scriptPubKey">
<span v-text="vout.scriptPubKey.type"></span>
</template>
</q-item-label>
<q-item-label
caption
v-text="vout.value + ' BTC'"
></q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-expansion-item>
</q-card-section>
</q-card>
<!-- Address result -->
<q-card v-if="addressResult">
<q-card-section>
<div class="row items-center justify-between q-mb-xs">
<div class="text-subtitle1" v-text="$t('address')"></div>
<q-btn flat round dense icon="close" @click="clearResult" />
</div>
<div class="text-caption q-mb-sm">
<code class="be-wrap" v-text="currentAddress"></code>
</div>
<div class="row q-col-gutter-md q-mb-md">
<div class="col-auto">
<div
class="text-caption text-grey"
v-text="$t('confirmed_balance')"
></div>
<div
v-text="
addressResult.balance.confirmed.toLocaleString() + ' sat'
"
></div>
</div>
<div
class="col-auto"
v-if="addressResult.balance.unconfirmed !== 0"
>
<div
class="text-caption text-grey"
v-text="$t('unconfirmed_balance')"
></div>
<div
v-text="
addressResult.balance.unconfirmed.toLocaleString() + ' sat'
"
></div>
</div>
</div>
<div
class="text-subtitle2 q-mb-xs"
v-text="
$t('transaction_history') +
' (' +
addressResult.history.length +
')'
"
></div>
<q-list dense separator>
<q-item
v-for="h in addressResult.history"
:key="h.tx_hash"
clickable
v-ripple
@click="loadTx(h.tx_hash)"
>
<q-item-section>
<q-item-label
class="text-primary be-wrap"
v-text="h.tx_hash"
></q-item-label>
<q-item-label
caption
v-text="
h.height > 0
? $t('block_height') + ': ' + h.height.toLocaleString()
: $t('unconfirmed')
"
></q-item-label>
</q-item-section>
<q-item-section side>
<q-icon name="chevron_right" color="grey-5"></q-icon>
</q-item-section>
</q-item>
</q-list>
<div
v-if="addressResult.history_error"
class="text-warning q-mt-sm text-caption"
v-text="$t('history_unavailable')"
></div>
<div
v-else-if="addressResult.history.length === 0"
class="text-grey q-mt-sm"
v-text="$t('no_transactions')"
></div>
</q-card-section>
</q-card>
</div>
<!-- Right column: chain tip + fees -->
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card v-if="tip">
<q-card-section>
<div class="text-subtitle1 q-mb-sm" v-text="$t('chain_tip')"></div>
<div class="text-caption text-grey" v-text="$t('block_height')"></div>
<div
class="text-h6 q-mb-md"
v-text="tip.height.toLocaleString()"
></div>
<template v-if="feeList.length">
<div
class="text-subtitle2 q-mb-sm"
v-text="$t('fee_estimates')"
></div>
<q-list dense>
<q-item v-for="f in feeList" :key="f.label" class="q-px-none">
<q-item-section>
<q-item-label
class="text-caption text-grey"
v-text="f.label"
></q-item-label>
</q-item-section>
<q-item-section side>
<q-item-label
class="text-body2"
v-text="f.rate"
></q-item-label>
</q-item-section>
</q-item>
</q-list>
</template>
</q-card-section>
</q-card>
</div>
</div>
</template>
<style>
.be-wrap {
word-break: break-all;
}
</style>
+625 -5
View File
@@ -11,13 +11,29 @@ import hashlib
import itertools
import json
import ssl
from collections.abc import Callable
import struct
from collections.abc import Callable, Coroutine
from typing import Any
from urllib.parse import urlparse
from embit.networks import NETWORKS
from embit.script import Script
from embit.transaction import Transaction as EmbitTransaction
from loguru import logger
from pydantic import BaseModel
DEFAULT_NETWORK = NETWORKS["main"]
def network_from_name(name: str) -> dict:
"""Look up an embit network dict (see embit.networks.NETWORKS) by name."""
try:
return NETWORKS[name]
except KeyError as exc:
raise ValueError(
f"Unknown network {name!r}, expected one of {list(NETWORKS)}"
) from exc
class ElectrumError(Exception):
pass
@@ -28,6 +44,47 @@ def scripthash_from_scriptpubkey(scriptpubkey: bytes) -> str:
return hashlib.sha256(scriptpubkey).digest()[::-1].hex()
def address_to_scriptpubkey(address: str) -> bytes:
"""Convert a Bitcoin address (P2PKH/P2SH/P2WPKH/P2WSH/P2TR) to scriptPubKey."""
try:
script = Script.from_address(address)
except Exception as exc:
raise ValueError(f"Invalid address: {address!r}") from exc
if script is None:
raise ValueError(f"Invalid address: {address!r}")
return script.data
def scripthash_from_address(address: str) -> str:
return scripthash_from_scriptpubkey(address_to_scriptpubkey(address))
_SCRIPT_TYPE_NAMES = {
"p2pkh": "pubkeyhash",
"p2sh": "scripthash",
"p2wpkh": "witness_v0_keyhash",
"p2wsh": "witness_v0_scripthash",
"p2tr": "witness_v1_taproot",
}
def _scriptpubkey_info(spk: bytes, network: dict) -> tuple[str, str | None]:
"""Return (type, address_or_None) for a scriptPubKey."""
n = len(spk)
# P2PK (not classified by embit)
if n in (35, 67) and spk[-1] == 0xAC:
return "pubkey", None
# OP_RETURN (not classified by embit)
if n >= 1 and spk[0] == 0x6A:
return "nulldata", None
script = Script(spk)
script_type = script.script_type()
if script_type is None:
return "nonstandard", None
return _SCRIPT_TYPE_NAMES[script_type], script.address(network)
# ---------------------------------------------------------------------------
# Response models
# ---------------------------------------------------------------------------
@@ -105,6 +162,145 @@ 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
scriptSig: ScriptSig | None = None # noqa: N815
sequence: int
coinbase: str | None = None
class TxOutput(BaseModel):
value: float
n: int
scriptPubKey: ScriptPubKey # noqa: N815
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]
history_error: str | None = None
class BlockInfo(BaseModel):
height: int
hash: str
timestamp: int
version: int
bits: str
nonce: int
prev_hash: str
merkle_root: str
def parse_block_header(header_hex: str, height: int) -> BlockInfo:
"""Parse an 80-byte block header hex string into a BlockInfo model."""
data = bytes.fromhex(header_hex)
version = struct.unpack_from("<I", data, 0)[0]
prev_hash = data[4:36][::-1].hex()
merkle_root = data[36:68][::-1].hex()
timestamp = struct.unpack_from("<I", data, 68)[0]
bits = format(struct.unpack_from("<I", data, 72)[0], "08x")
nonce = struct.unpack_from("<I", data, 76)[0]
block_hash = hashlib.sha256(hashlib.sha256(data).digest()).digest()[::-1].hex()
return BlockInfo(
height=height,
hash=block_hash,
timestamp=timestamp,
version=version,
bits=bits,
nonce=nonce,
prev_hash=prev_hash,
merkle_root=merkle_root,
)
def parse_raw_tx(hex_str: str, network: dict | None = None) -> Transaction:
"""Parse a raw transaction hex string into a Transaction model."""
network = network or DEFAULT_NETWORK
data = bytes.fromhex(hex_str)
tx = EmbitTransaction.parse(data)
vin: list[TxInput] = []
for inp in tx.vin:
if inp.txid == b"\x00" * 32 and inp.vout == 0xFFFFFFFF:
vin.append(
TxInput(sequence=inp.sequence, coinbase=inp.script_sig.data.hex())
)
else:
vin.append(
TxInput(
txid=inp.txid.hex(),
vout=inp.vout,
scriptSig=ScriptSig(hex=inp.script_sig.data.hex()),
sequence=inp.sequence,
)
)
vout: list[TxOutput] = []
for n_out, out in enumerate(tx.vout):
spk_type, address = _scriptpubkey_info(out.script_pubkey.data, network)
vout.append(
TxOutput(
value=round(out.value / 1e8, 8),
n=n_out,
scriptPubKey=ScriptPubKey(
hex=out.script_pubkey.data.hex(), type=spk_type, address=address
),
)
)
if tx.is_segwit:
# base (non-witness) size = full size minus the segwit marker/flag
# (2 bytes) and each input's witness stack
witness_bytes = sum(len(inp.witness.serialize()) for inp in tx.vin)
base_size = len(data) - 2 - witness_bytes
weight = base_size * 3 + len(data)
vsize = (weight + 3) // 4
else:
weight = len(data) * 4
vsize = len(data)
return Transaction(
txid=tx.txid().hex(),
version=tx.version,
locktime=tx.locktime,
vin=vin,
vout=vout,
size=len(data),
vsize=vsize,
weight=weight,
hex=hex_str,
)
# ---------------------------------------------------------------------------
# Client
# ---------------------------------------------------------------------------
@@ -135,6 +331,7 @@ class ElectrumClient:
client_name: str = "lnbits",
protocol_version: str = "1.4",
ping_interval: float = 60.0,
network: dict | None = None,
) -> None:
parsed = urlparse(url)
self.host = parsed.hostname or ""
@@ -145,6 +342,7 @@ class ElectrumClient:
self.client_name = client_name
self.protocol_version = protocol_version
self.ping_interval = ping_interval
self.network = network or DEFAULT_NETWORK
self._counter = itertools.count(1)
self._pending: dict[int, asyncio.Future[Any]] = {}
self._subscriptions: dict[str, list[Callable[[list[Any]], Any]]] = {}
@@ -152,6 +350,7 @@ class ElectrumClient:
self._ping_task: asyncio.Task[None] | None = None
self._reader: asyncio.StreamReader | None = None
self._writer: asyncio.StreamWriter | None = None
self.closed: asyncio.Event = asyncio.Event()
self.server_version: str = ""
self.negotiated_protocol: str = ""
@@ -286,6 +485,7 @@ class ElectrumClient:
except Exception:
logger.exception("Electrum: recv loop error")
finally:
self.closed.set()
for fut in self._pending.values():
if not fut.done():
fut.set_exception(ElectrumError("Connection closed"))
@@ -381,10 +581,9 @@ class ElectrumClient:
"""Broadcast a raw transaction hex; returns txid on success."""
return await self._call("blockchain.transaction.broadcast", [raw_tx])
async def get_transaction(
self, txid: str, verbose: bool = False
) -> str | dict[str, Any]:
return await self._call("blockchain.transaction.get", [txid, verbose])
async def get_transaction(self, txid: str) -> str:
"""Fetch raw transaction hex by txid."""
return await self._call("blockchain.transaction.get", [txid])
async def get_merkle(self, txid: str, height: int) -> MerkleProof:
data = await self._call("blockchain.transaction.get_merkle", [txid, height])
@@ -437,3 +636,424 @@ class ElectrumClient:
"""Returns mempool fee histogram as FeeHistogramEntry(fee_rate, vsize) list."""
data = await self._call("mempool.get_fee_histogram")
return [FeeHistogramEntry(fee_rate=r[0], vsize=r[1]) for r in data]
# ---------------------------------------------------------------------------
# Address tracking
# ---------------------------------------------------------------------------
class OnchainAddressEvent(BaseModel):
address: str
confirmed: int # satoshis
unconfirmed: int # satoshis
history: list[HistoryEntry] = []
history_error: str | None = None
@property
def txids(self) -> list[str]:
return [e.tx_hash for e in self.history]
class AddressTracker:
"""
Subscribes to a set of Bitcoin addresses over a single shared Electrum
connection and calls a callback on every balance/history change.
Addresses can be added/removed at runtime via :meth:`add`/:meth:`remove`,
and per-connection queues can be attached via :meth:`register_queue` for
consumers (e.g. websockets) that want events for one specific address.
Reconnects automatically on failure.
Args:
url: Electrum server URL (e.g. ``ssl://electrum.blockstream.info:50002``).
"""
def __init__(self, url: str) -> None:
self.url = url
self._ref_counts: dict[str, int] = {}
self._queues: dict[str, list[asyncio.Queue[OnchainAddressEvent]]] = {}
self._updated = asyncio.Event()
def add(self, address: str) -> None:
"""Start tracking an address on the shared connection (ref-counted)."""
count = self._ref_counts.get(address, 0)
self._ref_counts[address] = count + 1
if count == 0:
self._updated.set()
def remove(self, address: str) -> None:
"""Decrement ref count; drop the subscription once the last caller leaves."""
count = self._ref_counts.get(address, 0)
if count <= 1:
self._ref_counts.pop(address, None)
self._updated.set()
else:
self._ref_counts[address] = count - 1
def register_queue(
self, address: str, queue: "asyncio.Queue[OnchainAddressEvent]"
) -> None:
"""Register a per-connection queue to receive events for `address`."""
self._queues.setdefault(address, []).append(queue)
self.add(address)
def unregister_queue(
self, address: str, queue: "asyncio.Queue[OnchainAddressEvent]"
) -> None:
"""Deregister a per-connection queue for `address`."""
queues = self._queues.get(address, [])
if queue in queues:
queues.remove(queue)
if not queues:
self._queues.pop(address, None)
self.remove(address)
async def run(
self,
callback: Callable[[OnchainAddressEvent], Coroutine[Any, Any, None]],
is_active: Callable[[], bool],
) -> None:
while is_active():
try:
await self._run_once(callback, is_active)
except asyncio.CancelledError:
raise
except Exception as exc:
if not is_active():
return
logger.warning(f"AddressTracker: {exc!s}, retrying in 5s")
await asyncio.sleep(5)
async def _run_once(
self,
callback: Callable[[OnchainAddressEvent], Coroutine[Any, Any, None]],
is_active: Callable[[], bool],
) -> None:
async with ElectrumClient(self.url) as client:
subscribed: dict[str, str] = {} # scripthash -> address
async def on_status_change(params: list[Any]) -> None:
if not params:
return
address = subscribed.get(params[0])
if address:
await self._fetch_and_dispatch(client, address, params[0], callback)
client.on("blockchain.scripthash.subscribe", on_status_change)
while is_active():
self._updated.clear()
await self._sync_subscriptions(client, subscribed, callback)
if await self._wait_for_change_or_close(client):
break # connection closed; reconnect
async def _sync_subscriptions(
self,
client: ElectrumClient,
subscribed: dict[str, str],
callback: Callable[[OnchainAddressEvent], Coroutine[Any, Any, None]],
) -> None:
wanted = {a: scripthash_from_address(a) for a in self._ref_counts}
for address, scripthash in wanted.items():
if scripthash not in subscribed:
subscribed[scripthash] = address
await client.subscribe_scripthash(scripthash)
await self._fetch_and_dispatch(client, address, scripthash, callback)
still_wanted = set(wanted.values())
for scripthash, address in list(subscribed.items()):
if address not in still_wanted:
del subscribed[scripthash]
try:
await client.unsubscribe_scripthash(scripthash)
except ElectrumError:
# blockchain.scripthash.unsubscribe is part of the spec but
# many ElectrumX deployments don't implement it, returning
# "unknown method". This is expected and harmless: we've
# already dropped the scripthash from `subscribed` above,
# so if the server keeps pushing notifications for it
# anyway, on_status_change() looks it up, finds nothing,
# and drops them. Not logged since it fires on every
# untrack against these servers.
pass
async def _wait_for_change_or_close(self, client: ElectrumClient) -> bool:
"""Waits until addresses change or the connection closes; returns True
if it was the connection that closed."""
wait_task = asyncio.create_task(self._updated.wait())
closed_task = asyncio.create_task(client.closed.wait())
try:
done, _ = await asyncio.wait(
[wait_task, closed_task],
timeout=30,
return_when=asyncio.FIRST_COMPLETED,
)
finally:
for t in (wait_task, closed_task):
if not t.done():
t.cancel()
return closed_task in done
async def _fetch_and_dispatch(
self,
client: ElectrumClient,
address: str,
scripthash: str,
callback: Callable[[OnchainAddressEvent], Coroutine[Any, Any, None]],
) -> None:
balance_r, history_r, mempool_r = await asyncio.gather(
client.get_balance(scripthash),
client.get_history(scripthash),
client.get_mempool(scripthash),
return_exceptions=True,
)
if isinstance(balance_r, BaseException):
raise balance_r
history: list[HistoryEntry] = (
[] if isinstance(history_r, BaseException) else history_r
)
history_error: str | None = (
str(history_r) if isinstance(history_r, BaseException) else None
)
if not isinstance(mempool_r, BaseException):
seen = {e.tx_hash for e in history}
for m in mempool_r:
if m.tx_hash not in seen:
history.append(HistoryEntry(tx_hash=m.tx_hash, height=0, fee=m.fee))
event = OnchainAddressEvent(
address=address,
confirmed=balance_r.confirmed,
unconfirmed=balance_r.unconfirmed,
history=history,
history_error=history_error,
)
for q in list(self._queues.get(address, [])):
q.put_nowait(event)
await callback(event)
# ---------------------------------------------------------------------------
# Transaction tracking
# ---------------------------------------------------------------------------
class OnchainTxEvent(BaseModel):
txid: str
confirmed: bool
height: int | None = None
fee: int | None = None
def tx_watch_scripthash(tx: Transaction) -> str | None:
"""Return the scripthash of the first spendable output, used to subscribe
for confirmation notifications."""
for out in tx.vout:
if out.scriptPubKey.type != "nulldata":
return scripthash_from_scriptpubkey(bytes.fromhex(out.scriptPubKey.hex))
return None
class TransactionTracker:
"""
Subscribes to a Bitcoin transaction via Electrum and calls a callback on
each status change (unconfirmed confirmed). Stops automatically once
the transaction is confirmed or ``is_active()`` returns ``False``.
Per-connection queues can be attached via :meth:`register_queue` for
consumers (e.g. websockets) that want events for this transaction.
Args:
url: Electrum server URL (e.g. ``ssl://electrum.blockstream.info:50002``).
"""
def __init__(self, url: str) -> None:
self.url = url
self._queues: list[asyncio.Queue[OnchainTxEvent]] = []
def register_queue(self, queue: asyncio.Queue[OnchainTxEvent]) -> None:
"""Register a per-connection queue to receive events for this tx."""
self._queues.append(queue)
def unregister_queue(self, queue: asyncio.Queue[OnchainTxEvent]) -> None:
"""Deregister a per-connection queue."""
if queue in self._queues:
self._queues.remove(queue)
def has_queues(self) -> bool:
return bool(self._queues)
async def track(
self,
txid: str,
callback: Callable[[OnchainTxEvent], Coroutine[Any, Any, None]],
is_active: Callable[[], bool],
) -> None:
while is_active():
try:
confirmed = await self._track_once(txid, callback, is_active)
if confirmed:
return
except asyncio.CancelledError:
raise
except Exception as exc:
if not is_active():
return
logger.warning(
f"TransactionTracker {txid[:8]}: {exc!s}, retrying in 5s"
)
await asyncio.sleep(5)
async def _track_once(
self,
txid: str,
callback: Callable[[OnchainTxEvent], Coroutine[Any, Any, None]],
is_active: Callable[[], bool],
) -> bool:
"""One connection attempt; returns True if the tx is confirmed."""
async with ElectrumClient(self.url) as client:
try:
raw = await client.get_transaction(txid)
except ElectrumError as exc:
logger.warning(f"TransactionTracker {txid[:8]}: {exc!s}")
await asyncio.sleep(10)
return False
scripthash = tx_watch_scripthash(parse_raw_tx(raw))
confirmed_event = asyncio.Event()
async def on_change(
params: list[Any],
_sh: str | None = scripthash,
_done: asyncio.Event = confirmed_event,
) -> None:
if params and params[0] == _sh:
ev = await self._fetch_status(client, txid, _sh)
await self._dispatch(ev, callback)
if ev.confirmed:
_done.set()
if scripthash:
await client.subscribe_scripthash(scripthash, on_change)
event = await self._fetch_status(client, txid, scripthash)
await self._dispatch(event, callback)
if event.confirmed:
return True
while is_active() and not confirmed_event.is_set():
try:
await asyncio.wait_for(client.closed.wait(), timeout=30)
break # connection closed; reconnect
except asyncio.TimeoutError:
pass
return confirmed_event.is_set()
async def _dispatch(
self,
event: OnchainTxEvent,
callback: Callable[[OnchainTxEvent], Coroutine[Any, Any, None]],
) -> None:
for q in list(self._queues):
q.put_nowait(event)
await callback(event)
@staticmethod
async def _fetch_status(
client: ElectrumClient, txid: str, scripthash: str | None
) -> OnchainTxEvent:
if scripthash:
try:
for entry in await client.get_history(scripthash):
if entry.tx_hash == txid:
return OnchainTxEvent(
txid=txid,
confirmed=entry.height > 0,
height=entry.height if entry.height > 0 else None,
fee=entry.fee,
)
except ElectrumError:
try:
for m in await client.get_mempool(scripthash):
if m.tx_hash == txid:
return OnchainTxEvent(txid=txid, confirmed=False, fee=m.fee)
return OnchainTxEvent(txid=txid, confirmed=True)
except ElectrumError:
pass
return OnchainTxEvent(txid=txid, confirmed=False)
# ---------------------------------------------------------------------------
# Block tracking
# ---------------------------------------------------------------------------
class BlockTracker:
"""
Subscribes to new block headers via Electrum and dispatches them to
registered queues. Per-connection queues can be attached via
:meth:`register_queue`. Reconnects automatically on failure.
Args:
url: Electrum server URL (e.g. ``ssl://electrum.blockstream.info:50002``).
"""
def __init__(self, url: str) -> None:
self.url = url
self._queues: list[asyncio.Queue[BlockInfo]] = []
def register_queue(self, queue: "asyncio.Queue[BlockInfo]") -> None:
"""Register a per-connection queue to receive new block events."""
self._queues.append(queue)
def unregister_queue(self, queue: "asyncio.Queue[BlockInfo]") -> None:
"""Deregister a per-connection queue."""
if queue in self._queues:
self._queues.remove(queue)
def has_queues(self) -> bool:
return bool(self._queues)
async def run(
self,
callback: Callable[[BlockInfo], Coroutine[Any, Any, None]],
is_active: Callable[[], bool],
) -> None:
while is_active():
try:
await self._run_once(callback, is_active)
except asyncio.CancelledError:
raise
except Exception as exc:
if not is_active():
return
logger.warning(f"BlockTracker: {exc!s}, retrying in 5s")
await asyncio.sleep(5)
async def _run_once(
self,
callback: Callable[[BlockInfo], Coroutine[Any, Any, None]],
is_active: Callable[[], bool],
) -> None:
async with ElectrumClient(self.url) as client:
async def on_header(params: list[Any]) -> None:
h = params[0]
event = parse_block_header(h["hex"], h["height"])
await self._dispatch(event, callback)
tip = await client.subscribe_headers(on_header)
await self._dispatch(parse_block_header(tip.hex, tip.height), callback)
while is_active():
try:
await asyncio.wait_for(client.closed.wait(), timeout=30)
break # connection closed; reconnect
except asyncio.TimeoutError:
pass
async def _dispatch(
self,
event: BlockInfo,
callback: Callable[[BlockInfo], Coroutine[Any, Any, None]],
) -> None:
for q in list(self._queues):
q.put_nowait(event)
await callback(event)
+2
View File
@@ -124,6 +124,8 @@
"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/pages/blockexplorer.js",
"js/components/lnbits-wallet-charts.js",
"js/components/lnbits-wallet-api-docs.js",
"js/components/lnbits-wallet-icon.js",
+8 -3
View File
@@ -1433,7 +1433,7 @@ def test_check_revolut_signature_multiple_v1_headers():
check_revolut_signature(payload, sig_header, timestamp, secret)
def test_check_revolut_signature_docs_vector():
def test_check_revolut_signature_docs_vector(mocker: MockerFixture):
payload = (
b'{"data":{"id":"645a7696-22f3-aa47-9c74-cbae0449cc46",'
b'"new_state":"completed","old_state":"pending",'
@@ -1445,9 +1445,14 @@ def test_check_revolut_signature_docs_vector():
secret = "wsk_r59a4HfWVAKycbCaNO1RvgCJec02gRd8"
sig = "v1=bca326fb378d0da7f7c490ad584a8106bab9723d8d9cdd0d50b4c5b3be3837c0"
check_revolut_signature(
payload, sig, timestamp, secret, tolerance_seconds=100000000
# This is a fixed vector straight from Revolut's docs, so its timestamp is
# necessarily in the past. Freeze time to it instead of growing
# tolerance_seconds indefinitely as real time marches on.
mocker.patch(
"lnbits.core.services.fiat_providers.time.time",
return_value=int(timestamp) / 1000,
)
check_revolut_signature(payload, sig, timestamp, secret)
@pytest.mark.anyio