add blocks

This commit is contained in:
dni
2026-07-13 09:09:41 +02:00
parent 55b97b6e4f
commit e63effbcb3
5 changed files with 216 additions and 107 deletions
+20
View File
@@ -7,10 +7,12 @@ from lnbits.settings import settings
from lnbits.utils.electrum import (
AddressResponse,
BlockHeader,
BlockInfo,
ElectrumClient,
ElectrumError,
FeeResponse,
Transaction,
parse_block_header,
parse_raw_tx,
scripthash_from_address,
)
@@ -33,6 +35,24 @@ def _client() -> ElectrumClient:
return ElectrumClient(settings.lnbits_blockexplorer_electrum_url)
@blockexplorer_router.get("/blocks")
async def api_blocks() -> list[BlockInfo]:
_check_enabled()
try:
async with _client() as c:
tip = await c.get_tip()
start = max(0, tip.height - 4)
headers = await c.get_block_headers(start, tip.height - start + 1)
raw = bytes.fromhex(headers.hex)
blocks = [
parse_block_header(raw[i * 80 : (i + 1) * 80].hex(), start + i)
for i in range(headers.count)
]
return list(reversed(blocks))
except ElectrumError as e:
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e)) from e
@blockexplorer_router.get("/tip")
async def api_tip() -> BlockHeader:
_check_enabled()
+1
View File
@@ -852,6 +852,7 @@ window.localisation.en = {
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',
+41 -5
View File
@@ -6,6 +6,9 @@ window.PageBlockExplorer = {
loading: false,
tip: null,
fees: null,
blocks: [],
selectedBlock: null,
blockDialog: false,
txResult: null,
addressResult: null,
currentAddress: ''
@@ -18,12 +21,48 @@ window.PageBlockExplorer = {
label: blocks + '-block fee',
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()])
await Promise.all([this.loadTip(), this.loadFees(), this.loadBlocks()])
},
methods: {
_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')
@@ -56,10 +95,7 @@ window.PageBlockExplorer = {
},
async loadTx(txid) {
try {
const r = await LNbits.api.request(
'GET',
'/blockexplorer/api/v1/tx/' + txid
)
const r = await LNbits.api.request('GET', '/blockexplorer/api/v1/tx/' + txid)
this.txResult = r.data
this.addressResult = null
this.query = txid
+121 -102
View File
@@ -1,6 +1,78 @@
<template id="page-blockexplorer">
<div class="row q-col-gutter-md justify-center">
<div class="col-12 col-md-8 q-gutter-y-md">
<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="'diff ' + 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="'Block #' + 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">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">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">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">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">Bits</div>
<div v-text="selectedBlock.bits"></div>
</div>
<div class="col-6 col-sm-3">
<div class="text-caption text-grey">Difficulty</div>
<div v-text="selectedBlock.difficulty"></div>
</div>
<div class="col-6 col-sm-3">
<div class="text-caption text-grey">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>
@@ -14,68 +86,29 @@
@keyup.enter="search"
>
<template v-slot:append>
<q-btn
flat
round
dense
icon="search"
:loading="loading"
@click="search"
/>
<q-btn flat round dense icon="search" :loading="loading" @click="search" />
</template>
</q-input>
</q-card-section>
</q-card>
<!-- Chain tip + fee summary -->
<q-card v-if="tip">
<q-card-section>
<div class="text-subtitle1 q-mb-sm" v-text="$t('chain_tip')"></div>
<div class="row q-col-gutter-md">
<div class="col-auto">
<div
class="text-caption text-grey"
v-text="$t('block_height')"
></div>
<div class="text-h6" v-text="tip.height.toLocaleString()"></div>
</div>
<template v-if="feeList.length">
<div class="col-auto" v-for="f in feeList" :key="f.label">
<div class="text-caption text-grey" v-text="f.label"></div>
<div class="text-body2" v-text="f.rate"></div>
</div>
</template>
</div>
</q-card-section>
</q-card>
<!-- Transaction result -->
<q-card v-if="txResult">
<q-card-section>
<div class="text-subtitle1 q-mb-sm" v-text="$t('transaction')"></div>
<div class="q-mb-xs">
<span class="text-caption text-grey">txid: </span>
<code style="word-break: break-all" v-text="txResult.txid"></code>
<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="txResult.blockheight">
<div
class="text-caption text-grey"
v-text="$t('block_height')"
></div>
<div class="text-caption text-grey" v-text="$t('block_height')"></div>
<div v-text="txResult.blockheight.toLocaleString()"></div>
</div>
<div class="col-auto" v-if="txResult.confirmations !== undefined">
<div class="text-caption text-grey" v-text="$t('confirmations')"></div>
<div
class="text-caption text-grey"
v-text="$t('confirmations')"
></div>
<div
v-text="
txResult.confirmations > 0
? txResult.confirmations
: $t('unconfirmed')
"
v-text="txResult.confirmations > 0 ? txResult.confirmations : $t('unconfirmed')"
></div>
</div>
<div class="col-auto" v-if="txResult.vsize || txResult.size">
@@ -96,13 +129,9 @@
<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 v-if="vin.coinbase" class="text-grey" v-text="$t('coinbase')">
</q-item-label>
<q-item-label v-else style="word-break: break-all">
<q-item-label v-else class="be-wrap">
<a
href="#"
@click.prevent="loadTx(vin.txid)"
@@ -123,9 +152,7 @@
<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"
>
<template v-if="vout.scriptPubKey && vout.scriptPubKey.address">
<a
href="#"
@click.prevent="loadAddress(vout.scriptPubKey.address)"
@@ -137,10 +164,7 @@
<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-label caption v-text="vout.value + ' BTC'"></q-item-label>
</q-item-section>
</q-item>
</q-list>
@@ -152,46 +176,20 @@
<q-card v-if="addressResult">
<q-card-section>
<div class="text-subtitle1 q-mb-xs" v-text="$t('address')"></div>
<code
class="q-mb-sm"
style="word-break: break-all; display: block"
v-text="currentAddress"
></code>
<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 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 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 +
')'
"
v-text="$t('transaction_history') + ' (' + addressResult.history.length + ')'"
></div>
<q-list dense separator>
<q-item
@@ -202,18 +200,10 @@
@click="loadTx(h.tx_hash)"
>
<q-item-section>
<q-item-label
class="text-primary"
style="word-break: break-all"
v-text="h.tx_hash"
></q-item-label>
<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')
"
v-text="h.height > 0 ? $t('block_height') + ': ' + h.height.toLocaleString() : $t('unconfirmed')"
></q-item-label>
</q-item-section>
<q-item-section side>
@@ -229,5 +219,34 @@
</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>
+33
View File
@@ -251,6 +251,39 @@ class AddressResponse(BaseModel):
history: list[HistoryEntry]
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) -> Transaction:
"""Parse a raw transaction hex string into a Transaction model."""
data = bytes.fromhex(hex_str)