""" cache_btc.py — Lo que ya se ha leído del nodo, guardado en este equipo. Para poder revisar el historial a ratos sin esperar cada vez a que el nodo conteste por Tor: • Las transacciones confirmadas no cambian nunca: se guardan ya leídas y no se vuelven a pedir. • El último escaneo (qué direcciones son tuyas, en qué bloque está cada transacción, saldos) también se guarda, así que la revisión se puede abrir al instante «con lo último que se leyó». El fichero (bitcoin_cache.json, junto a bitcoin.json) delata todo tu historial igual que las claves: permisos 600, fuera del repositorio y NO viaja a tu servidor git (otro equipo lo reconstruye preguntando al nodo). De las recompensas de bloque (coinbase) solo se guardan TUS salidas: las de los demás mineros de la pool no le importan a nadie y ocupan mucho. """ from __future__ import annotations import datetime as dt import hashlib import json import os import tempfile from pathlib import Path from . import bitcoin, btc from .electrum import ErrorElectrum from . import almacen FICHERO = "bitcoin_cache.json" VERSION = 1 def ruta(carpeta_datos: Path) -> Path: return Path(carpeta_datos).parent / FICHERO def cargar(carpeta_datos: Path) -> dict: try: c = json.loads(almacen.leer_texto(ruta(carpeta_datos))) except (OSError, ValueError): c = {} if not isinstance(c, dict) or c.get("version") != VERSION: c = {} c.setdefault("version", VERSION) c.setdefault("txs", {}) c.setdefault("escaneo", None) return c def guardar(carpeta_datos: Path, cache: dict) -> None: destino = ruta(carpeta_datos) tmp = tempfile.NamedTemporaryFile("wb", dir=destino.parent, prefix=".bitcoin_cache-", suffix=".tmp", delete=False) try: tmp.write(almacen.sellar(json.dumps(cache, ensure_ascii=False, separators=(",", ":")).encode("utf-8"))) tmp.flush() os.fsync(tmp.fileno()) tmp.close() os.chmod(tmp.name, 0o600) os.replace(tmp.name, destino) except OSError: try: os.unlink(tmp.name) except OSError: pass raise def firma(monederos: list[dict]) -> str: """Cambia si se añade, quita o cambia un monedero: entonces hay que volver a leer el nodo.""" datos = sorted((m.get("id"), m.get("clave"), m.get("tipo"), m.get("activo"), m.get("hueco"), tuple(m.get("direcciones") or ())) for m in monederos) return hashlib.sha256(json.dumps(datos).encode()).hexdigest()[:16] def _compacta(tx: dict, propios: dict) -> dict: if not tx["coinbase"]: return tx mias = [s for s in tx["salidas"] if s["script"] in propios] return {**tx, "salidas": mias, "recortada": True} def _fechas(cliente, alturas: list[int]) -> dict[int, str]: return btc._fechas(cliente, alturas) def leer_nodo(cliente, monederos: list[dict], cache: dict, escanear_todo) -> dict: """Escanea los monederos y trae SOLO las transacciones que faltan en la caché (o que aún no estaban confirmadas). Actualiza la caché en el sitio y devuelve el escaneo recién hecho.""" esc = escanear_todo(cliente, monederos) txs = cache["txs"] alturas = esc["alturas"] faltan = sorted(t for t, h in alturas.items() if t not in txs or txs[t].get("altura", 0) <= 0 or txs[t].get("altura") != h) if faltan: brutas = cliente.transacciones(faltan) fechas = _fechas(cliente, [alturas[t] for t in faltan]) for txid, bruto in zip(faltan, brutas): try: tx = bitcoin.leer_transaccion(bruto) except (ValueError, TypeError, AttributeError): continue altura = alturas[txid] txs[txid] = {**_compacta(tx, esc["propios"]), "altura": altura, "fecha": fechas.get(altura) if altura > 0 else None} # Lo que ya no aparece en el historial (una transacción sin confirmar que se cayó) sobra for t in [t for t, tx in txs.items() if t not in alturas and tx.get("altura", 0) <= 0]: txs.pop(t) cache["escaneo"] = { "fecha": dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds"), "firma": firma(monederos), "propios": {s: [p["monedero"], p["activo"]] for s, p in esc["propios"].items()}, "alturas": alturas, "saldos": esc["saldos"], "nombres": esc["nombres"], "cortados": esc["cortados"], } return esc def escaneo_guardado(cache: dict, monederos: list[dict]) -> dict: """El último escaneo, listo para usar; error claro si no vale.""" e = cache.get("escaneo") if not e: raise btc.ErrorBitcoin("Todavía no he leído el nodo en este equipo: pulsa «Leer el nodo».") if e.get("firma") != firma(monederos): raise btc.ErrorBitcoin("Has cambiado los monederos desde la última lectura: hay que volver a leer el nodo.") return {"propios": {s: {"monedero": v[0], "activo": v[1]} for s, v in e["propios"].items()}, "alturas": e["alturas"], "saldos": e["saldos"], "nombres": e["nombres"], "cortados": e.get("cortados") or [], "fecha": e.get("fecha")} __all__ = ["cargar", "guardar", "leer_nodo", "escaneo_guardado", "firma", "ruta", "ErrorElectrum"]