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 .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"]
+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("/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})
+10
View File
@@ -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,
+22 -1
View File
@@ -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'
})
+5
View File
@@ -66,6 +66,11 @@ const routes = [
name: 'NodePublic',
component: PageNodePublic
},
{
path: '/blockexplorer',
name: 'BlockExplorer',
component: PageBlockExplorer
},
{
path: '/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/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",
+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,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/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>
+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 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
# ---------------------------------------------------------------------------