feat: adds blockexplorer api, frontend via electrum

- settings
- frontend page component
- api
- models

models
This commit is contained in:
dni
2026-07-13 09:09:41 +02:00
parent cabb58f8fe
commit 730d1e2aaf
15 changed files with 695 additions and 4 deletions
+2
View File
@@ -3,6 +3,7 @@ from fastapi import APIRouter, FastAPI
from .db import core_app_extra, db from .db import core_app_extra, db
from .views.admin_api import admin_router from .views.admin_api import admin_router
from .views.api import api_router from .views.api import api_router
from .views.blockexplorer_api import blockexplorer_router
from .views.asset_api import asset_router from .views.asset_api import asset_router
from .views.audit_api import audit_router from .views.audit_api import audit_router
from .views.auth_api import auth_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(asset_router)
app.include_router(fiat_router) app.include_router(fiat_router)
app.include_router(lnurl_router) app.include_router(lnurl_router)
app.include_router(blockexplorer_router)
__all__ = ["core_app", "core_app_extra", "db"] __all__ = ["core_app", "core_app_extra", "db"]
+94
View File
@@ -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))
+1
View File
@@ -209,6 +209,7 @@ async def index(
@generic_router.get("/") @generic_router.get("/")
@generic_router.get("/node/public") @generic_router.get("/node/public")
@generic_router.get("/blockexplorer")
@generic_router.get("/first_install", dependencies=[Depends(check_first_install)]) @generic_router.get("/first_install", dependencies=[Depends(check_first_install)])
async def index_public(request: Request) -> HTMLResponse: async def index_public(request: Request) -> HTMLResponse:
return template_renderer().TemplateResponse(request, "base.html", {"public": True}) return template_renderer().TemplateResponse(request, "base.html", {"public": True})
+10
View File
@@ -845,6 +845,13 @@ class NodeUISettings(LNbitsSettings):
lnbits_node_ui_transactions: bool = Field(default=False) 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): class AuthMethods(Enum):
user_id_only = "user-id-only" user_id_only = "user-id-only"
username_and_password = "username-password" # noqa: S105 username_and_password = "username-password" # noqa: S105
@@ -1018,6 +1025,7 @@ class EditableSettings(
LightningSettings, LightningSettings,
WebPushSettings, WebPushSettings,
NodeUISettings, NodeUISettings,
BlockExplorerSettings,
AuditSettings, AuditSettings,
AuthSettings, AuthSettings,
NostrAuthSettings, NostrAuthSettings,
@@ -1268,6 +1276,7 @@ class PublicSettings(BaseModel):
webpush_pubkey: str | None = Field(alias="webpushPubkey") webpush_pubkey: str | None = Field(alias="webpushPubkey")
show_extensions: bool = Field(alias="showExtensions") show_extensions: bool = Field(alias="showExtensions")
show_audit: bool = Field(alias="showAudit") show_audit: bool = Field(alias="showAudit")
show_block_explorer: bool = Field(alias="showBlockExplorer")
show_admin: bool = Field(alias="showAdmin") show_admin: bool = Field(alias="showAdmin")
ad_space: list[list[str]] = Field(alias="adSpace") ad_space: list[list[str]] = Field(alias="adSpace")
ad_space_title: str = Field(alias="adSpaceTitle") ad_space_title: str = Field(alias="adSpaceTitle")
@@ -1335,6 +1344,7 @@ class PublicSettings(BaseModel):
webpushPubkey=settings.lnbits_webpush_pubkey, webpushPubkey=settings.lnbits_webpush_pubkey,
showExtensions=not settings.lnbits_extensions_deactivate_all, showExtensions=not settings.lnbits_extensions_deactivate_all,
showAudit=settings.lnbits_audit_enabled, showAudit=settings.lnbits_audit_enabled,
showBlockExplorer=settings.lnbits_blockexplorer_enabled,
showAdmin=settings.lnbits_admin_ui, showAdmin=settings.lnbits_admin_ui,
customImage=settings.lnbits_custom_image, customImage=settings.lnbits_custom_image,
customBadge=settings.lnbits_custom_badge, customBadge=settings.lnbits_custom_badge,
+22 -1
View File
@@ -841,5 +841,26 @@ window.localisation.en = {
payment_labels_updated: 'Payment labels updated', payment_labels_updated: 'Payment labels updated',
color: 'Color', color: 'Color',
sort: 'Sort', 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'
})
+5
View File
@@ -66,6 +66,11 @@ const routes = [
name: 'NodePublic', name: 'NodePublic',
component: PageNodePublic component: PageNodePublic
}, },
{
path: '/blockexplorer',
name: 'BlockExplorer',
component: PageBlockExplorer
},
{ {
path: '/payments', path: '/payments',
name: 'Payments', name: 'Payments',
+82
View File
@@ -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)
}
}
}
}
+2
View File
@@ -52,6 +52,7 @@
"js/pages/payments.js", "js/pages/payments.js",
"js/pages/node.js", "js/pages/node.js",
"js/pages/node-public.js", "js/pages/node-public.js",
"js/pages/blockexplorer.js",
"js/pages/audit.js", "js/pages/audit.js",
"js/pages/wallet.js", "js/pages/wallet.js",
"js/pages/wallets.js", "js/pages/wallets.js",
@@ -71,6 +72,7 @@
"js/components/admin/lnbits-admin-site-customisation.js", "js/components/admin/lnbits-admin-site-customisation.js",
"js/components/admin/lnbits-admin-assets-config.js", "js/components/admin/lnbits-admin-assets-config.js",
"js/components/admin/lnbits-admin-audit.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-charts.js",
"js/components/lnbits-wallet-api-docs.js", "js/components/lnbits-wallet-api-docs.js",
"js/components/lnbits-wallet-icon.js", "js/components/lnbits-wallet-icon.js",
+16
View File
@@ -11,6 +11,7 @@ include('components/admin/extensions.vue') %} {%
include('components/admin/assets-config.vue') %} {% include('components/admin/assets-config.vue') %} {%
include('components/admin/notifications.vue') %} {% include('components/admin/notifications.vue') %} {%
include('components/admin/server.vue') %} {% include('components/admin/server.vue') %} {%
include('components/admin/blockexplorer.vue') %} {%
include('components/lnbits-qrcode.vue') %} {% include('components/lnbits-qrcode.vue') %} {%
include('components/lnbits-qrcode-scanner.vue') %} {% include('components/lnbits-qrcode-scanner.vue') %} {%
include('components/lnbits-disclaimer.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-icon name="chevron_right" color="grey-5" size="md"></q-icon>
</q-item-section> </q-item-section>
</q-item> </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> </div>
<q-item to="/payments"> <q-item to="/payments">
<q-item-section side> <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>
+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/account.vue') %} {% include('pages/extensions_builder.vue') %} {%
include('pages/extensions.vue') %} {% include('pages/first-install.vue') %} {% include('pages/extensions.vue') %} {% include('pages/first-install.vue') %} {%
include('pages/home.vue') %} {% include('pages/wallet.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: 'notifications', label: $t('notifications')},
{value: 'audit', label: $t('audit')}, {value: 'audit', label: $t('audit')},
{value: 'assets-config', label: $t('assets')}, {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-value="value"
option-label="label" option-label="label"
@@ -183,6 +184,13 @@
><q-tooltip v-if="!$q.screen.gt.sm" ><q-tooltip v-if="!$q.screen.gt.sm"
><span v-text="$t('site_customisation')"></span></q-tooltip ><span v-text="$t('site_customisation')"></span></q-tooltip
></q-tab> ></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> </q-tabs>
</template> </template>
@@ -235,6 +243,9 @@
<q-tab-panel name="assets-config"> <q-tab-panel name="assets-config">
<lnbits-admin-assets-config :form-data="formData" /> <lnbits-admin-assets-config :form-data="formData" />
</q-tab-panel> </q-tab-panel>
<q-tab-panel name="blockexplorer">
<lnbits-admin-blockexplorer :form-data="formData" />
</q-tab-panel>
</q-tab-panels> </q-tab-panels>
</q-scroll-area> </q-scroll-area>
</q-form> </q-form>
+160
View File
@@ -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
View File
@@ -11,12 +11,15 @@ import hashlib
import itertools import itertools
import json import json
import ssl import ssl
import struct
from collections.abc import Callable from collections.abc import Callable
from typing import Any from typing import Any
from urllib.parse import urlparse from urllib.parse import urlparse
from bech32 import bech32_decode, convertbits
from bech32 import encode as bech32_segwit_encode
from loguru import logger from loguru import logger
from pydantic import BaseModel from pydantic import BaseModel, Field
class ElectrumError(Exception): class ElectrumError(Exception):
@@ -28,6 +31,100 @@ def scripthash_from_scriptpubkey(scriptpubkey: bytes) -> str:
return hashlib.sha256(scriptpubkey).digest()[::-1].hex() 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 # Response models
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -105,6 +202,155 @@ class ServerFeatures(BaseModel):
hosts: dict[str, Any] = {} 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 # Client
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------