pyblock/pybitblock/clock/data.py

341 lines
12 KiB
Python
Raw Normal View History

"""Bitcoin data layer for the enhanced block clock.
Fetches block height, block details, fees, hashrate, and epoch info
from either a local bitcoin-cli or JSON-RPC, plus mempool.space API
for fee rates and hashrate.
"""
import json
import shlex
import subprocess
import threading
import time
import requests
# Halving constants
BLOCKS_PER_HALVING = 210_000
BLOCKS_PER_EPOCH = 2016
HALVING_BLOCKS = [BLOCKS_PER_HALVING * i for i in range(1, 65)]
# API endpoints
MEMPOOL_FEES_URL = "https://mempool.space/api/v1/fees/recommended"
MEMPOOL_HASHRATE_URL = "https://mempool.space/api/v1/mining/hashrate/3d"
MEMPOOL_HEIGHT_URL = "https://mempool.space/api/blocks/tip/height"
MEMPOOL_BLOCK_URL = "https://mempool.space/api/block/"
MEMPOOL_BLOCKS_URL = "https://mempool.space/api/v1/blocks"
class ClockData:
"""Fetches and caches Bitcoin data for the clock display."""
def __init__(self, mode, path):
"""
mode: 'local', 'remote', or 'lite'
path: dict with ip_port, rpcuser, rpcpass, bitcoincli
"""
self.mode = mode
self.path = path
# Block data
self.block_height = 0
self.block_hash = ""
self.block_time = 0
self.block_size = 0
self.block_tx_count = 0
# Epoch / halving
self.epoch_progress = 0.0
self.epoch_block = 0
self.blocks_to_halving = 0
self.next_halving_block = 0
# Fee rates (sat/vB)
self.fee_fastest = 0
self.fee_half_hour = 0
self.fee_hour = 0
# Hashrate
self.hashrate_current = 0.0
self.hashrate_history = []
self.difficulty = 0.0
# Visual features
self.miner_pool = ""
self.block_weight = 0
self.max_block_weight = 4_000_000
self.peer_count = 0
self.block_time_history = [] # last N block intervals in seconds
self.streak_type = "" # "fast", "slow", or ""
self.streak_count = 0
# Internal
self._bg_thread = None
self._last_api_fetch = 0
# --- RPC / CLI abstraction ---
def _cli(self, command):
"""Run bitcoin-cli command, return stdout string."""
cmd = shlex.split(self.path["bitcoincli"]) + shlex.split(command)
# nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit
result = subprocess.run(cmd, capture_output=True, text=True)
return result.stdout.strip()
def _rpc(self, method, params=None):
"""JSON-RPC call for remote mode."""
payload = json.dumps({
"jsonrpc": "2.0", "id": "clock",
"method": method, "params": params or []
})
resp = requests.post(
self.path['ip_port'],
auth=(self.path['rpcuser'], self.path['rpcpass']),
data=payload, timeout=10
)
return resp.json()['result']
def _get_block_count(self):
if self.mode == 'lite':
r = requests.get(MEMPOOL_HEIGHT_URL, timeout=10)
return int(r.text.strip())
elif self.mode == 'remote':
return int(self._rpc('getblockcount'))
else:
return int(self._cli('getblockcount'))
def _get_block_details(self):
"""Fetch full block details for current tip."""
if self.mode == 'lite':
tip_hash = requests.get(
"https://mempool.space/api/blocks/tip/hash", timeout=10
).text.strip()
r2 = requests.get(f"{MEMPOOL_BLOCK_URL}{tip_hash}", timeout=10)
block = r2.json()
self.block_hash = tip_hash
self.block_time = block.get('timestamp', int(time.time()))
self.block_size = block.get('size', 0)
self.block_tx_count = block.get('tx_count', 0)
self.block_weight = block.get('weight', 0)
pool = block.get('extras', {})
self.miner_pool = pool.get('pool', {}).get('name', '') if isinstance(pool, dict) else ''
else:
if self.mode == 'remote':
block_hash = self._rpc('getbestblockhash')
block = self._rpc('getblock', [block_hash])
else:
block_hash = self._cli('getbestblockhash')
raw = self._cli(f'getblock {block_hash}')
block = json.loads(raw)
self.block_hash = block_hash
self.block_time = block.get('time', int(time.time()))
self.block_size = block.get('size', 0)
self.block_tx_count = block.get('nTx', 0)
self.block_weight = block.get('weight', 0)
def _get_peer_count(self):
"""Fetch connected peer count (local/remote only)."""
try:
if self.mode == 'local':
raw = self._cli('getnetworkinfo')
info = json.loads(raw)
self.peer_count = info.get('connections', 0)
elif self.mode == 'remote':
info = self._rpc('getnetworkinfo')
self.peer_count = info.get('connections', 0)
except Exception:
pass
def _get_miner_pool_local(self):
"""Extract miner/pool name from coinbase for local/remote mode."""
try:
if self.mode == 'local':
raw = self._cli(f'getblock {self.block_hash} 2')
block = json.loads(raw)
elif self.mode == 'remote':
block = self._rpc('getblock', [self.block_hash, 2])
else:
return
coinbase_tx = block.get('tx', [{}])[0]
scriptsig_hex = coinbase_tx.get('vin', [{}])[0].get('coinbase', '')
# Decode hex to ASCII, extract readable part
try:
raw_bytes = bytes.fromhex(scriptsig_hex)
ascii_part = ''.join(
c if 32 <= ord(c) < 127 else '' for c in raw_bytes.decode('ascii', errors='replace')
)
# Common pool tags
pools = {
'Foundry': 'Foundry USA',
'AntPool': 'AntPool',
'F2Pool': 'F2Pool',
'ViaBTC': 'ViaBTC',
'Binance': 'Binance Pool',
'Mara': 'MARA Pool',
'MARA': 'MARA Pool',
'Luxor': 'Luxor',
'Ocean': 'OCEAN',
'ocean': 'OCEAN',
'OCEAN': 'OCEAN',
'SBI': 'SBI Crypto',
'Braiins': 'Braiins Pool',
'slush': 'Braiins Pool',
'SpiderPool': 'SpiderPool',
'BTC.com': 'BTC.com',
'Poolin': 'Poolin',
'Titan': 'Titan',
}
self.miner_pool = ""
for tag, name in pools.items():
if tag in ascii_part:
self.miner_pool = name
break
if not self.miner_pool and len(ascii_part) > 3:
# Use the longest readable substring
self.miner_pool = ascii_part.strip()[:20]
except Exception:
pass
except Exception:
pass
def _fetch_block_time_history(self):
"""Fetch recent block timestamps and compute intervals + streaks."""
try:
if self.mode == 'lite':
r = requests.get(MEMPOOL_BLOCKS_URL, timeout=10)
blocks = r.json()[:15]
timestamps = [b.get('timestamp', 0) for b in blocks]
elif self.mode == 'local':
timestamps = []
h = self.block_height
for i in range(15):
bh = self._cli(f'getblockhash {h - i}')
raw = self._cli(f'getblock {bh}')
block = json.loads(raw)
timestamps.append(block.get('time', 0))
elif self.mode == 'remote':
timestamps = []
h = self.block_height
for i in range(15):
bh = self._rpc('getblockhash', [h - i])
block = self._rpc('getblock', [bh])
timestamps.append(block.get('time', 0))
else:
return
# Timestamps are newest-first, compute intervals
intervals = []
for i in range(len(timestamps) - 1):
diff = abs(timestamps[i] - timestamps[i + 1])
intervals.append(diff)
self.block_time_history = intervals
# Compute streak
streak = 0
stype = ""
for iv in intervals:
if iv < 300: # <5 min = fast
if stype == "" or stype == "fast":
stype = "fast"
streak += 1
else:
break
elif iv > 900: # >15 min = slow
if stype == "" or stype == "slow":
stype = "slow"
streak += 1
else:
break
else:
break
self.streak_type = stype if streak >= 2 else ""
self.streak_count = streak if streak >= 2 else 0
except Exception:
pass
def _calc_epoch(self):
"""Calculate epoch and halving progress from block height."""
h = self.block_height
self.epoch_block = h % BLOCKS_PER_EPOCH
self.epoch_progress = self.epoch_block / BLOCKS_PER_EPOCH
for hb in HALVING_BLOCKS:
if h < hb:
self.next_halving_block = hb
self.blocks_to_halving = hb - h
break
else:
self.blocks_to_halving = 0
self.next_halving_block = 0
# --- API data (background thread) ---
def _fetch_api_data(self):
"""Fetch fee rates, hashrate, block history from APIs (non-blocking)."""
try:
r = requests.get(MEMPOOL_FEES_URL, timeout=10)
fees = r.json()
self.fee_fastest = fees.get('fastestFee', 0)
self.fee_half_hour = fees.get('halfHourFee', 0)
self.fee_hour = fees.get('hourFee', 0)
except Exception:
pass
try:
r = requests.get(MEMPOOL_HASHRATE_URL, timeout=10)
data = r.json()
self.hashrate_current = data.get('currentHashrate', 0)
self.difficulty = data.get('currentDifficulty', 0)
hashrates = data.get('hashrates', [])
self.hashrate_history = [
h.get('avgHashrate', 0) for h in hashrates[-20:]
]
except Exception:
pass
self._fetch_block_time_history()
self._get_peer_count()
if self.mode in ('local', 'remote') and not self.miner_pool:
self._get_miner_pool_local()
def _start_bg_fetch(self):
"""Fetch API data in background thread if enough time has passed."""
now = time.time()
if now - self._last_api_fetch < 30:
return
self._last_api_fetch = now
t = threading.Thread(target=self._fetch_api_data, daemon=True)
t.start()
# --- Public API ---
def refresh(self):
"""Full data fetch: block height, details, epoch, and trigger API fetch."""
self.block_height = self._get_block_count()
self._get_block_details()
self._calc_epoch()
self._start_bg_fetch()
def poll(self):
"""Quick poll: just getblockcount. Returns set of changed field names."""
changed = set()
new_height = self._get_block_count()
if new_height != self.block_height:
old_height = self.block_height
self.block_height = new_height
self._get_block_details()
self._calc_epoch()
self._start_bg_fetch()
changed.add('block_height')
return changed
@property
def seconds_since_block(self):
"""Seconds elapsed since the last block timestamp."""
if self.block_time == 0:
return 0
return max(0, int(time.time()) - self.block_time)