137 lines
5.2 KiB
Python
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"]
|