feat: adds blockexplorer api, frontend via electrum
- settings - frontend page component - api - models models
This commit is contained in:
@@ -3,6 +3,7 @@ from fastapi import APIRouter, FastAPI
|
||||
from .db import core_app_extra, db
|
||||
from .views.admin_api import admin_router
|
||||
from .views.api import api_router
|
||||
from .views.blockexplorer_api import blockexplorer_router
|
||||
from .views.asset_api import asset_router
|
||||
from .views.audit_api import audit_router
|
||||
from .views.auth_api import auth_router
|
||||
@@ -48,6 +49,7 @@ def init_core_routers(app: FastAPI):
|
||||
app.include_router(asset_router)
|
||||
app.include_router(fiat_router)
|
||||
app.include_router(lnurl_router)
|
||||
app.include_router(blockexplorer_router)
|
||||
|
||||
|
||||
__all__ = ["core_app", "core_app_extra", "db"]
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import asyncio
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.electrum import (
|
||||
AddressResponse,
|
||||
BlockHeader,
|
||||
ElectrumClient,
|
||||
ElectrumError,
|
||||
FeeResponse,
|
||||
Transaction,
|
||||
parse_raw_tx,
|
||||
scripthash_from_address,
|
||||
)
|
||||
|
||||
blockexplorer_router = APIRouter(
|
||||
tags=["Block Explorer"],
|
||||
prefix="/blockexplorer/api/v1",
|
||||
)
|
||||
|
||||
|
||||
def _check_enabled() -> None:
|
||||
if not settings.lnbits_blockexplorer_enabled:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
detail="Block explorer is not enabled.",
|
||||
)
|
||||
|
||||
|
||||
def _client() -> ElectrumClient:
|
||||
return ElectrumClient(settings.lnbits_blockexplorer_electrum_url)
|
||||
|
||||
|
||||
@blockexplorer_router.get("/tip")
|
||||
async def api_tip() -> BlockHeader:
|
||||
_check_enabled()
|
||||
try:
|
||||
async with _client() as c:
|
||||
return await c.get_tip()
|
||||
except ElectrumError as e:
|
||||
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e))
|
||||
|
||||
|
||||
@blockexplorer_router.get("/fees")
|
||||
async def api_fees() -> FeeResponse:
|
||||
_check_enabled()
|
||||
try:
|
||||
async with _client() as c:
|
||||
estimates_raw = await asyncio.gather(
|
||||
c.estimate_fee(1),
|
||||
c.estimate_fee(3),
|
||||
c.estimate_fee(6),
|
||||
c.estimate_fee(144),
|
||||
)
|
||||
histogram = await c.fee_histogram()
|
||||
estimates = {
|
||||
str(blocks): fee
|
||||
for blocks, fee in zip([1, 3, 6, 144], estimates_raw)
|
||||
if fee >= 0
|
||||
}
|
||||
return FeeResponse(estimates=estimates, histogram=histogram)
|
||||
except ElectrumError as e:
|
||||
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e))
|
||||
|
||||
|
||||
@blockexplorer_router.get("/tx/{txid}")
|
||||
async def api_tx(txid: str) -> Transaction:
|
||||
_check_enabled()
|
||||
try:
|
||||
async with _client() as c:
|
||||
raw_hex = await c.get_transaction(txid, verbose=False)
|
||||
return parse_raw_tx(raw_hex)
|
||||
except ElectrumError as e:
|
||||
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e))
|
||||
|
||||
|
||||
@blockexplorer_router.get("/address/{address}")
|
||||
async def api_address(address: str) -> AddressResponse:
|
||||
_check_enabled()
|
||||
try:
|
||||
scripthash = scripthash_from_address(address)
|
||||
except ValueError as e:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, detail=str(e))
|
||||
try:
|
||||
async with _client() as c:
|
||||
balance, history = await asyncio.gather(
|
||||
c.get_balance(scripthash),
|
||||
c.get_history(scripthash),
|
||||
)
|
||||
return AddressResponse(balance=balance, history=history)
|
||||
except ElectrumError as e:
|
||||
raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, detail=str(e))
|
||||
@@ -209,6 +209,7 @@ async def index(
|
||||
|
||||
@generic_router.get("/")
|
||||
@generic_router.get("/node/public")
|
||||
@generic_router.get("/blockexplorer")
|
||||
@generic_router.get("/first_install", dependencies=[Depends(check_first_install)])
|
||||
async def index_public(request: Request) -> HTMLResponse:
|
||||
return template_renderer().TemplateResponse(request, "base.html", {"public": True})
|
||||
|
||||
@@ -845,6 +845,13 @@ class NodeUISettings(LNbitsSettings):
|
||||
lnbits_node_ui_transactions: bool = Field(default=False)
|
||||
|
||||
|
||||
class BlockExplorerSettings(LNbitsSettings):
|
||||
lnbits_blockexplorer_enabled: bool = Field(default=False)
|
||||
lnbits_blockexplorer_electrum_url: str = Field(
|
||||
default="ssl://electrum.blockstream.info:50002"
|
||||
)
|
||||
|
||||
|
||||
class AuthMethods(Enum):
|
||||
user_id_only = "user-id-only"
|
||||
username_and_password = "username-password" # noqa: S105
|
||||
@@ -1018,6 +1025,7 @@ class EditableSettings(
|
||||
LightningSettings,
|
||||
WebPushSettings,
|
||||
NodeUISettings,
|
||||
BlockExplorerSettings,
|
||||
AuditSettings,
|
||||
AuthSettings,
|
||||
NostrAuthSettings,
|
||||
@@ -1268,6 +1276,7 @@ class PublicSettings(BaseModel):
|
||||
webpush_pubkey: str | None = Field(alias="webpushPubkey")
|
||||
show_extensions: bool = Field(alias="showExtensions")
|
||||
show_audit: bool = Field(alias="showAudit")
|
||||
show_block_explorer: bool = Field(alias="showBlockExplorer")
|
||||
show_admin: bool = Field(alias="showAdmin")
|
||||
ad_space: list[list[str]] = Field(alias="adSpace")
|
||||
ad_space_title: str = Field(alias="adSpaceTitle")
|
||||
@@ -1335,6 +1344,7 @@ class PublicSettings(BaseModel):
|
||||
webpushPubkey=settings.lnbits_webpush_pubkey,
|
||||
showExtensions=not settings.lnbits_extensions_deactivate_all,
|
||||
showAudit=settings.lnbits_audit_enabled,
|
||||
showBlockExplorer=settings.lnbits_blockexplorer_enabled,
|
||||
showAdmin=settings.lnbits_admin_ui,
|
||||
customImage=settings.lnbits_custom_image,
|
||||
customBadge=settings.lnbits_custom_badge,
|
||||
|
||||
@@ -841,5 +841,26 @@ window.localisation.en = {
|
||||
payment_labels_updated: 'Payment labels updated',
|
||||
color: 'Color',
|
||||
sort: 'Sort',
|
||||
sort_by: 'Sort by'
|
||||
sort_by: 'Sort by',
|
||||
block_explorer: 'Block Explorer',
|
||||
enable_block_explorer: 'Enable Block Explorer',
|
||||
block_explorer_desc: 'Allow users to explore Bitcoin transactions and addresses via Electrum.',
|
||||
electrum_server_url: 'Electrum Server URL',
|
||||
electrum_server_url_hint: 'e.g. ssl://electrum.blockstream.info:50002 or tcp://localhost:50001',
|
||||
blockexplorer_search_label: 'Search by TXID or Address',
|
||||
blockexplorer_search_hint: '64-char hex = transaction · anything else = Bitcoin address',
|
||||
chain_tip: 'Chain Tip',
|
||||
block_height: 'Block Height',
|
||||
block_fee: 'block fee',
|
||||
fee_estimates: 'Fee Estimates',
|
||||
confirmed_balance: 'Confirmed Balance',
|
||||
unconfirmed_balance: 'Unconfirmed Balance',
|
||||
transaction_history: 'Transaction History',
|
||||
coinbase: 'Coinbase',
|
||||
inputs: 'Inputs',
|
||||
outputs: 'Outputs',
|
||||
confirmations: 'Confirmations',
|
||||
unconfirmed: 'Unconfirmed',
|
||||
no_transactions: 'No transactions found',
|
||||
address: 'Address'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
window.app.component('lnbits-admin-blockexplorer', {
|
||||
props: ['form-data'],
|
||||
template: '#lnbits-admin-blockexplorer'
|
||||
})
|
||||
@@ -66,6 +66,11 @@ const routes = [
|
||||
name: 'NodePublic',
|
||||
component: PageNodePublic
|
||||
},
|
||||
{
|
||||
path: '/blockexplorer',
|
||||
name: 'BlockExplorer',
|
||||
component: PageBlockExplorer
|
||||
},
|
||||
{
|
||||
path: '/payments',
|
||||
name: 'Payments',
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
window.PageBlockExplorer = {
|
||||
template: '#page-blockexplorer',
|
||||
data() {
|
||||
return {
|
||||
query: '',
|
||||
loading: false,
|
||||
tip: null,
|
||||
fees: null,
|
||||
txResult: null,
|
||||
addressResult: null,
|
||||
currentAddress: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
feeList() {
|
||||
if (!this.fees || !this.fees.estimates) return []
|
||||
return Object.entries(this.fees.estimates).map(([blocks, rate]) => ({
|
||||
label: blocks + '-block fee',
|
||||
rate: (rate * 100000).toFixed(1) + ' sat/vB'
|
||||
}))
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
await Promise.all([this.loadTip(), this.loadFees()])
|
||||
},
|
||||
methods: {
|
||||
async loadTip() {
|
||||
try {
|
||||
const r = await LNbits.api.request('GET', '/blockexplorer/api/v1/tip')
|
||||
this.tip = r.data
|
||||
} catch (e) {
|
||||
LNbits.utils.notifyApiError(e)
|
||||
}
|
||||
},
|
||||
async loadFees() {
|
||||
try {
|
||||
const r = await LNbits.api.request('GET', '/blockexplorer/api/v1/fees')
|
||||
this.fees = r.data
|
||||
} catch (_) {}
|
||||
},
|
||||
async search() {
|
||||
const q = this.query.trim()
|
||||
if (!q) return
|
||||
this.txResult = null
|
||||
this.addressResult = null
|
||||
this.loading = true
|
||||
try {
|
||||
if (/^[0-9a-fA-F]{64}$/.test(q)) {
|
||||
await this.loadTx(q)
|
||||
} else {
|
||||
await this.loadAddress(q)
|
||||
}
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async loadTx(txid) {
|
||||
try {
|
||||
const r = await LNbits.api.request('GET', '/blockexplorer/api/v1/tx/' + txid)
|
||||
this.txResult = r.data
|
||||
this.addressResult = null
|
||||
this.query = txid
|
||||
} catch (e) {
|
||||
LNbits.utils.notifyApiError(e)
|
||||
}
|
||||
},
|
||||
async loadAddress(address) {
|
||||
try {
|
||||
const r = await LNbits.api.request(
|
||||
'GET',
|
||||
'/blockexplorer/api/v1/address/' + address
|
||||
)
|
||||
this.addressResult = r.data
|
||||
this.txResult = null
|
||||
this.currentAddress = address
|
||||
this.query = address
|
||||
} catch (e) {
|
||||
LNbits.utils.notifyApiError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@
|
||||
"js/pages/payments.js",
|
||||
"js/pages/node.js",
|
||||
"js/pages/node-public.js",
|
||||
"js/pages/blockexplorer.js",
|
||||
"js/pages/audit.js",
|
||||
"js/pages/wallet.js",
|
||||
"js/pages/wallets.js",
|
||||
@@ -71,6 +72,7 @@
|
||||
"js/components/admin/lnbits-admin-site-customisation.js",
|
||||
"js/components/admin/lnbits-admin-assets-config.js",
|
||||
"js/components/admin/lnbits-admin-audit.js",
|
||||
"js/components/admin/lnbits-admin-blockexplorer.js",
|
||||
"js/components/lnbits-wallet-charts.js",
|
||||
"js/components/lnbits-wallet-api-docs.js",
|
||||
"js/components/lnbits-wallet-icon.js",
|
||||
|
||||
@@ -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,37 @@
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<q-separator class="q-mb-lg q-mt-sm"></q-separator>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-8">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_blockexplorer_electrum_url"
|
||||
:label="$t('electrum_server_url')"
|
||||
:hint="$t('electrum_server_url_hint')"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</template>
|
||||
@@ -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') %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
<template id="page-blockexplorer">
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col-12 col-md-8 q-gutter-y-md">
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
</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 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 v-text="txResult.confirmations > 0 ? txResult.confirmations : $t('unconfirmed')"></div>
|
||||
</div>
|
||||
<div class="col-auto" v-if="txResult.vsize || txResult.size">
|
||||
<div class="text-caption text-grey">vsize</div>
|
||||
<div v-text="(txResult.vsize || txResult.size) + ' vB'"></div>
|
||||
</div>
|
||||
<div class="col-auto" v-if="txResult.fee !== undefined">
|
||||
<div class="text-caption text-grey">fee</div>
|
||||
<div v-text="txResult.fee + ' sat'"></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 style="word-break: break-all">
|
||||
<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">
|
||||
<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="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="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" style="word-break: break-all" 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.length === 0" class="text-grey q-mt-sm" v-text="$t('no_transactions')"></div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+247
-1
@@ -11,12 +11,15 @@ import hashlib
|
||||
import itertools
|
||||
import json
|
||||
import ssl
|
||||
import struct
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from bech32 import bech32_decode, convertbits
|
||||
from bech32 import encode as bech32_segwit_encode
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ElectrumError(Exception):
|
||||
@@ -28,6 +31,100 @@ def scripthash_from_scriptpubkey(scriptpubkey: bytes) -> str:
|
||||
return hashlib.sha256(scriptpubkey).digest()[::-1].hex()
|
||||
|
||||
|
||||
_B58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
||||
|
||||
|
||||
def _b58decode_check(s: str) -> bytes:
|
||||
n = 0
|
||||
for c in s:
|
||||
n = n * 58 + _B58_ALPHABET.index(c)
|
||||
nz = len(s) - len(s.lstrip("1"))
|
||||
buf: list[int] = []
|
||||
while n:
|
||||
n, rem = divmod(n, 256)
|
||||
buf.insert(0, rem)
|
||||
raw = bytes([0] * nz + buf)
|
||||
payload, chk = raw[:-4], raw[-4:]
|
||||
expected = hashlib.sha256(hashlib.sha256(payload).digest()).digest()[:4]
|
||||
if chk != expected:
|
||||
raise ValueError(f"Bad base58check checksum: {s!r}")
|
||||
return payload # byte 0 = version, bytes 1:21 = hash160
|
||||
|
||||
|
||||
def address_to_scriptpubkey(address: str) -> bytes:
|
||||
"""Convert a Bitcoin address (P2PKH/P2SH/P2WPKH/P2WSH/P2TR) to scriptPubKey bytes."""
|
||||
lower = address.lower()
|
||||
if lower.startswith(("bc1", "tb1", "bcrt1")):
|
||||
_, data = bech32_decode(address)
|
||||
if data is None:
|
||||
raise ValueError(f"Invalid bech32 address: {address!r}")
|
||||
witness_version = data[0]
|
||||
witness_prog = bytes(convertbits(data[1:], 5, 8, False))
|
||||
ver_op = 0x00 if witness_version == 0 else (0x50 + witness_version)
|
||||
return bytes([ver_op, len(witness_prog)]) + witness_prog
|
||||
else:
|
||||
payload = _b58decode_check(address)
|
||||
version, hash160 = payload[0], payload[1:]
|
||||
if version in (0x00, 0x6F, 0x41): # P2PKH mainnet/testnet/regtest
|
||||
return bytes([0x76, 0xA9, 0x14]) + hash160 + bytes([0x88, 0xAC])
|
||||
if version in (0x05, 0xC4, 0x3A): # P2SH mainnet/testnet/regtest
|
||||
return bytes([0xA9, 0x14]) + hash160 + bytes([0x87])
|
||||
raise ValueError(f"Unknown address version byte: {version:#04x}")
|
||||
|
||||
|
||||
def scripthash_from_address(address: str) -> str:
|
||||
return scripthash_from_scriptpubkey(address_to_scriptpubkey(address))
|
||||
|
||||
|
||||
def _b58encode_check(payload: bytes) -> str:
|
||||
chk = hashlib.sha256(hashlib.sha256(payload).digest()).digest()[:4]
|
||||
n = int.from_bytes(payload + chk, "big")
|
||||
chars: list[str] = []
|
||||
while n:
|
||||
n, rem = divmod(n, 58)
|
||||
chars.insert(0, _B58_ALPHABET[rem])
|
||||
nz = len(payload) - len(payload.lstrip(b"\x00"))
|
||||
return _B58_ALPHABET[0] * nz + "".join(chars)
|
||||
|
||||
|
||||
def _read_varint(data: bytes, i: int) -> tuple[int, int]:
|
||||
b = data[i]
|
||||
if b < 0xFD:
|
||||
return b, i + 1
|
||||
if b == 0xFD:
|
||||
return struct.unpack_from("<H", data, i + 1)[0], i + 3
|
||||
if b == 0xFE:
|
||||
return struct.unpack_from("<I", data, i + 1)[0], i + 5
|
||||
return struct.unpack_from("<Q", data, i + 1)[0], i + 9
|
||||
|
||||
|
||||
def _scriptpubkey_info(script: bytes) -> tuple[str, str | None]:
|
||||
"""Return (type, address_or_None) for a scriptPubKey."""
|
||||
n = len(script)
|
||||
# P2PKH
|
||||
if n == 25 and script[:3] == b"\x76\xa9\x14" and script[23:] == b"\x88\xac":
|
||||
return "pubkeyhash", _b58encode_check(b"\x00" + script[3:23])
|
||||
# P2SH
|
||||
if n == 23 and script[0] == 0xA9 and script[1] == 0x14 and script[22] == 0x87:
|
||||
return "scripthash", _b58encode_check(b"\x05" + script[2:22])
|
||||
# P2WPKH
|
||||
if n == 22 and script[0] == 0x00 and script[1] == 0x14:
|
||||
return "witness_v0_keyhash", bech32_segwit_encode("bc", 0, list(script[2:]))
|
||||
# P2WSH
|
||||
if n == 34 and script[0] == 0x00 and script[1] == 0x20:
|
||||
return "witness_v0_scripthash", bech32_segwit_encode("bc", 0, list(script[2:]))
|
||||
# P2TR
|
||||
if n == 34 and script[0] == 0x51 and script[1] == 0x20:
|
||||
return "witness_v1_taproot", bech32_segwit_encode("bc", 1, list(script[2:]))
|
||||
# P2PK
|
||||
if n in (35, 67) and script[-1] == 0xAC:
|
||||
return "pubkey", None
|
||||
# OP_RETURN
|
||||
if n >= 1 and script[0] == 0x6A:
|
||||
return "nulldata", None
|
||||
return "nonstandard", None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response models
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -105,6 +202,155 @@ class ServerFeatures(BaseModel):
|
||||
hosts: dict[str, Any] = {}
|
||||
|
||||
|
||||
class ScriptSig(BaseModel):
|
||||
hex: str
|
||||
|
||||
|
||||
class ScriptPubKey(BaseModel):
|
||||
hex: str
|
||||
type: str
|
||||
address: str | None = None
|
||||
|
||||
|
||||
class TxInput(BaseModel):
|
||||
txid: str | None = None
|
||||
vout: int | None = None
|
||||
script_sig: ScriptSig | None = Field(None, alias="scriptSig")
|
||||
sequence: int
|
||||
coinbase: str | None = None
|
||||
|
||||
class Config:
|
||||
allow_population_by_field_name = True
|
||||
|
||||
|
||||
class TxOutput(BaseModel):
|
||||
value: float
|
||||
n: int
|
||||
script_pub_key: ScriptPubKey = Field(alias="scriptPubKey")
|
||||
|
||||
class Config:
|
||||
allow_population_by_field_name = True
|
||||
|
||||
|
||||
class Transaction(BaseModel):
|
||||
txid: str
|
||||
version: int
|
||||
locktime: int
|
||||
vin: list[TxInput]
|
||||
vout: list[TxOutput]
|
||||
size: int
|
||||
vsize: int
|
||||
weight: int
|
||||
hex: str
|
||||
|
||||
|
||||
class FeeResponse(BaseModel):
|
||||
estimates: dict[str, float]
|
||||
histogram: list[FeeHistogramEntry]
|
||||
|
||||
|
||||
class AddressResponse(BaseModel):
|
||||
balance: Balance
|
||||
history: list[HistoryEntry]
|
||||
|
||||
|
||||
def parse_raw_tx(hex_str: str) -> Transaction:
|
||||
"""Parse a raw transaction hex string into a Transaction model."""
|
||||
data = bytes.fromhex(hex_str)
|
||||
i = 0
|
||||
|
||||
version = struct.unpack_from("<I", data, i)[0]
|
||||
i += 4
|
||||
|
||||
segwit = len(data) > i + 1 and data[i] == 0x00 and data[i + 1] == 0x01
|
||||
if segwit:
|
||||
i += 2
|
||||
|
||||
vin_start = i
|
||||
vin_count, i = _read_varint(data, i)
|
||||
vin: list[TxInput] = []
|
||||
for _ in range(vin_count):
|
||||
prev_txid = data[i : i + 32][::-1].hex()
|
||||
i += 32
|
||||
prev_vout = struct.unpack_from("<I", data, i)[0]
|
||||
i += 4
|
||||
script_len, i = _read_varint(data, i)
|
||||
script_sig_hex = data[i : i + script_len].hex()
|
||||
i += script_len
|
||||
sequence = struct.unpack_from("<I", data, i)[0]
|
||||
i += 4
|
||||
if prev_txid == "0" * 64 and prev_vout == 0xFFFFFFFF:
|
||||
vin.append(TxInput(sequence=sequence, coinbase=script_sig_hex))
|
||||
else:
|
||||
vin.append(
|
||||
TxInput(
|
||||
txid=prev_txid,
|
||||
vout=prev_vout,
|
||||
scriptSig=ScriptSig(hex=script_sig_hex),
|
||||
sequence=sequence,
|
||||
)
|
||||
)
|
||||
|
||||
vout_start = i
|
||||
vout_count, i = _read_varint(data, i)
|
||||
vout: list[TxOutput] = []
|
||||
for n_out in range(vout_count):
|
||||
value_sat = struct.unpack_from("<Q", data, i)[0]
|
||||
i += 8
|
||||
script_len, i = _read_varint(data, i)
|
||||
spk_bytes = data[i : i + script_len]
|
||||
i += script_len
|
||||
spk_type, address = _scriptpubkey_info(spk_bytes)
|
||||
vout.append(
|
||||
TxOutput(
|
||||
value=round(value_sat / 1e8, 8),
|
||||
n=n_out,
|
||||
scriptPubKey=ScriptPubKey(
|
||||
hex=spk_bytes.hex(), type=spk_type, address=address
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
vout_end = i
|
||||
|
||||
if segwit:
|
||||
for _ in range(vin_count):
|
||||
items, i = _read_varint(data, i)
|
||||
for _ in range(items):
|
||||
item_len, i = _read_varint(data, i)
|
||||
i += item_len
|
||||
|
||||
locktime_start = i
|
||||
locktime = struct.unpack_from("<I", data, i)[0]
|
||||
|
||||
if segwit:
|
||||
non_witness = (
|
||||
data[:4]
|
||||
+ data[vin_start:vout_end]
|
||||
+ data[locktime_start : locktime_start + 4]
|
||||
)
|
||||
txid = hashlib.sha256(hashlib.sha256(non_witness).digest()).digest()[::-1].hex()
|
||||
base_size = 4 + (vout_end - vin_start) + 4
|
||||
weight = base_size * 3 + len(data)
|
||||
vsize = (weight + 3) // 4
|
||||
else:
|
||||
txid = hashlib.sha256(hashlib.sha256(data).digest()).digest()[::-1].hex()
|
||||
weight = len(data) * 4
|
||||
vsize = len(data)
|
||||
|
||||
return Transaction(
|
||||
txid=txid,
|
||||
version=version,
|
||||
locktime=locktime,
|
||||
vin=vin,
|
||||
vout=vout,
|
||||
size=len(data),
|
||||
vsize=vsize,
|
||||
weight=weight,
|
||||
hex=hex_str,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user