add blocks
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user