caudal/programa/cartera/cache_btc.py
2026-10-13 08:01:06 -05:00

137 lines
5.2 KiB
Python

"""
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"]