caudal/programa/cartera/core.py
esetheyers 4b158e662d Caudal 0.3.0
Link another Caudal (phone, USB) with a code or a QR instead of the password, choosing the server's root certificate as a file; read Bitcoin straight from Bitcoin Core or Knots, without an Electrum server; Android app (beta) and a Windows build script. Fixed: Save did nothing for expenses, refunds and dividends entered by hand.
2026-10-16 18:12:09 -05:00

447 lines
21 KiB
Python

"""
core.py — Preguntarle a tu nodo directamente (Bitcoin Core o Knots), sin
servidor Electrum de por medio.
Un nodo pelado no sabe contestar «¿qué ha pasado en esta dirección?»: solo
sigue las direcciones de sus monederos. Así que Caudal crea en el nodo un
monedero suyo, llamado «caudal», que NO tiene claves privadas (no puede gastar
nada: solo mira), le da tus claves públicas o tus direcciones y le pide que
repase la cadena una vez. A partir de ahí el nodo lleva la cuenta él solo.
Ese primer repaso es lo lento: de unos minutos (con «blockfilterindex=1») a
varias horas. Mientras dura, Caudal lo dice y no enseña cifras a medias. Para
saber si un repaso terminó de verdad —y no se quedó a medias porque el nodo se
reinició— cada descriptor deja una marca en el propio monedero del nodo: una
etiqueta en su primera dirección, «caudal» cuando está repasado y
«caudal-repasando-<arranque del nodo>» mientras tanto.
Hacia fuera esta clase se usa igual que electrum.Cliente (historiales, saldos,
transacciones, cabeceras, altura), para que el resto del programa no distinga
de dónde vienen los datos.
Las credenciales RPC de un nodo pueden mucho. Lo prudente es un usuario propio
(«rpcauth») limitado con «rpcwhitelist» a lo que aquí se usa: ver METODOS.
"""
from __future__ import annotations
import base64
import http.client
import json
import socket
import ssl
import threading
import time
from . import bitcoin
from .electrum import ErrorElectrum, TIEMPO_ESPERA, _socks5
MONEDERO = "caudal"
ETIQUETA = "caudal"
REPASANDO = "caudal-repasando-"
RANGO = 1000 # direcciones por rama que se le dan al nodo
LOTE = 50
HOLGURA_ARRANQUE = 120 # segundos de margen al comparar el arranque del nodo
# Lo que hay que permitir en «rpcwhitelist» para el usuario de Caudal
METODOS = ("getblockchaininfo", "getnetworkinfo", "uptime", "getblockcount", "getblockhash",
"getblockheader", "getrawtransaction", "listwallets", "loadwallet", "createwallet",
"getwalletinfo", "importdescriptors", "rescanblockchain", "listlabels",
"getaddressesbylabel", "setlabel", "listsinceblock", "gettransaction")
class ErrorCore(ErrorElectrum):
"""El nodo ha contestado un error (lleva su código)."""
def __init__(self, mensaje: str, codigo: int = 0):
super().__init__(mensaje)
self.codigo = codigo
class Repasando(ErrorElectrum):
"""El nodo está repasando la cadena: todavía no se le puede preguntar."""
class Cortada(ErrorElectrum):
"""Se ha perdido la conexión a mitad (el nodo puede seguir con lo suyo)."""
# ── Suma de control de los descriptores (BIP 380) ────────────────────────────
_ENTRADA = ("0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~"
"ijklmnopqrstuvwxyzABCDEFGH`#\"\\ ")
_SALIDA = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
_GENERADOR = (0xF5DEE51989, 0xA9FDCA3312, 0x1BAB10E32D, 0x3706B1677A, 0x644D626FFD)
def _polymod(simbolos: list[int]) -> int:
suma = 1
for valor in simbolos:
alto = suma >> 35
suma = ((suma & 0x7FFFFFFFF) << 5) ^ valor
for i, g in enumerate(_GENERADOR):
if (alto >> i) & 1:
suma ^= g
return suma
def con_suma(descriptor: str) -> str:
"""El descriptor con su «#suma», que el nodo exige al importarlo."""
simbolos, grupo = [], []
for caracter in descriptor:
valor = _ENTRADA.find(caracter)
if valor < 0:
raise ValueError("carácter no admitido en un descriptor")
simbolos.append(valor & 31)
grupo.append(valor >> 5)
if len(grupo) == 3:
simbolos.append(grupo[0] * 9 + grupo[1] * 3 + grupo[2])
grupo = []
if len(grupo) == 1:
simbolos.append(grupo[0])
elif len(grupo) == 2:
simbolos.append(grupo[0] * 3 + grupo[1])
suma = _polymod(simbolos + [0] * 8) ^ 1
return descriptor + "#" + "".join(_SALIDA[(suma >> (5 * (7 - i))) & 31] for i in range(8))
# ── De un monedero de Caudal a lo que hay que darle al nodo ──────────────────
_PLANTILLAS = {"p2wpkh": "wpkh({})", "p2tr": "tr({})", "p2sh-p2wpkh": "sh(wpkh({}))", "p2pkh": "pkh({})"}
def clave_estandar(texto: str) -> str:
"""La misma clave escrita como «xpub» (o «tpub»): el nodo no entiende ypub ni zpub."""
extendida = bitcoin.ClaveExtendida(texto)
bruto = bitcoin.b58check_descodificar(texto.strip())
version = 0x0488B21E if extendida.red == "mainnet" else 0x043587CF
return bitcoin.b58check_codificar(version.to_bytes(4, "big") + bruto[4:])
def requeridos(monedero: dict) -> list[dict]:
"""Los descriptores que el nodo tiene que seguir para este monedero, cada
uno con la dirección que llevará su marca.
Con clave extendida se dan los cuatro tipos de dirección y las dos ramas de
una vez: así un solo repaso sirve para adivinar el tipo, para revisar el
monedero y para leerlo."""
if not monedero.get("clave"):
return [{"desc": con_suma(f"addr({d})"), "rango": None, "marca": d}
for d in monedero.get("direcciones") or []]
clave = str(monedero["clave"]).strip()
extendida = bitcoin.ClaveExtendida(clave)
estandar = clave_estandar(clave)
salida = []
for tipo in bitcoin.TIPOS:
for cambio in (0, 1):
salida.append({"desc": con_suma(_PLANTILLAS[tipo].format(f"{estandar}/{cambio}/*")),
"rango": RANGO,
"marca": bitcoin.direcciones(extendida, tipo, cambio, 0, 1)[0]["direccion"]})
return salida
# ── El cliente ───────────────────────────────────────────────────────────────
_TAREAS: dict[tuple, dict] = {} # (host, puerto) → {"hilo", "error"}
_TAREAS_LOCK = threading.Lock()
class Cliente:
"""Tu nodo, preguntado por RPC. Misma cara que electrum.Cliente."""
tope_rama = RANGO
def __init__(self, host: str, puerto: int, tls: bool = False, tor: bool | None = None,
proxy: tuple[str, int] = ("127.0.0.1", 9050), espera: float = TIEMPO_ESPERA,
usuario: str = "", clave: str = ""):
self.host, self.puerto, self.tls = host, puerto, tls
self.tor = host.endswith(".onion") if tor is None else tor
self.proxy, self.espera = proxy, espera
self._credencial = base64.b64encode(f"{usuario}:{clave}".encode()).decode()
self._hay_monedero = False
self._datos = None
cadena, red = self._lote([("getblockchaininfo", []), ("getnetworkinfo", [])])
self.podado = bool(cadena.get("pruned"))
self._altura = int(cadena.get("blocks") or 0)
self.version = str(red.get("subversion") or "").strip("/") or "Bitcoin Core"
# ── envío y recepción ──
def _conexion(self, espera) -> http.client.HTTPConnection:
if self.tor:
sock = _socks5(self.host, self.puerto, self.proxy[0], self.proxy[1], self.espera)
else:
try:
sock = socket.create_connection((self.host, self.puerto), timeout=self.espera)
except OSError as e:
raise ErrorElectrum(f"no contesta {self.host}:{self.puerto} ({e})") from None
if self.tls:
# Como en electrum.py: los nodos caseros usan certificados suyos
try:
sock = ssl._create_unverified_context().wrap_socket(
sock, server_hostname=None if self.tor else self.host)
except OSError as e:
sock.close()
raise ErrorElectrum(f"error de cifrado con {self.host}:{self.puerto} ({e})") from None
sock.settimeout(espera)
conexion = http.client.HTTPConnection(self.host, self.puerto)
conexion.sock = sock
return conexion
def _enviar(self, cuerpo, monedero: bool, espera):
conexion = self._conexion(espera)
try:
conexion.request("POST", f"/wallet/{MONEDERO}" if monedero else "/", json.dumps(cuerpo).encode(),
{"Authorization": f"Basic {self._credencial}", "Content-Type": "application/json",
"Connection": "close"})
respuesta = conexion.getresponse()
estado, texto = respuesta.status, respuesta.read()
except socket.timeout:
raise Cortada(f"{self.host} no responde (más de {int(espera or 0)} s)") from None
except (OSError, http.client.HTTPException) as e:
raise Cortada(f"conexión perdida con {self.host}: {e}") from None
finally:
conexion.close()
if estado == 401:
raise ErrorElectrum("tu nodo no acepta ese usuario y esa contraseña de RPC")
if estado == 403:
raise ErrorElectrum("tu nodo no admite conexiones desde este equipo (mira «rpcallowip» "
"y, si usas «rpcwhitelist», que estén permitidas las órdenes de Caudal)")
try:
return json.loads(texto)
except ValueError:
raise ErrorElectrum(f"{self.host}:{self.puerto} no contesta como un nodo de Bitcoin "
f"(¿es el puerto de RPC, normalmente el 8332?)") from None
@staticmethod
def _resultado(r):
if not isinstance(r, dict):
raise ErrorElectrum("el nodo ha contestado algo inesperado")
error = r.get("error")
if error:
codigo = int(error.get("code") or 0) if isinstance(error, dict) else 0
mensaje = error.get("message") if isinstance(error, dict) else str(error)
if codigo == -32601:
raise ErrorCore("tu nodo no tiene la parte de monedero (está arrancado con «disablewallet» "
"o compilado sin ella): sin eso no puede seguir tus direcciones", codigo)
if codigo == -28:
raise ErrorCore("tu nodo todavía está arrancando; prueba dentro de un rato", codigo)
raise ErrorCore(f"el nodo rechaza la consulta: {mensaje}", codigo)
return r.get("result")
def _rpc(self, metodo: str, params=None, monedero: bool = False, espera="igual"):
espera = self.espera if espera == "igual" else espera
return self._resultado(self._enviar({"jsonrpc": "1.0", "id": 1, "method": metodo,
"params": params if params is not None else []}, monedero, espera))
def _lote_crudo(self, peticiones: list[tuple[str, list]], monedero: bool = False) -> list:
salida = []
for i in range(0, len(peticiones), LOTE):
tanda = peticiones[i:i + LOTE]
respuestas = self._enviar([{"jsonrpc": "1.0", "id": n, "method": m, "params": p}
for n, (m, p) in enumerate(tanda)], monedero, self.espera)
if not isinstance(respuestas, list):
self._resultado(respuestas) # un error para todo el lote
raise ErrorElectrum("el nodo ha contestado algo inesperado")
por_id = {r.get("id"): r for r in respuestas if isinstance(r, dict)}
salida += [por_id.get(n) for n in range(len(tanda))]
return salida
def _lote(self, peticiones: list[tuple[str, list]], monedero: bool = False) -> list:
return [self._resultado(r) for r in self._lote_crudo(peticiones, monedero)]
def cerrar(self) -> None: # cada consulta abre y cierra su conexión
pass
def __enter__(self):
return self
def __exit__(self, *_):
self.cerrar()
# ── el monedero «caudal» del nodo ──
def _monedero(self) -> None:
if self._hay_monedero:
return
if MONEDERO not in (self._rpc("listwallets") or []):
try:
self._rpc("loadwallet", [MONEDERO])
except ErrorCore as e:
if e.codigo == -18: # no existe: se crea, sin claves privadas y vacío
self._rpc("createwallet", {"wallet_name": MONEDERO, "disable_private_keys": True,
"blank": True, "descriptors": True, "load_on_startup": True})
elif e.codigo != -35 and "already loaded" not in str(e):
raise
self._hay_monedero = True
def _arranque(self) -> int:
"""Cuándo arrancó el nodo: si cambia, es que se ha reiniciado."""
return int(time.time()) - int(self._rpc("uptime") or 0)
def _marcas(self) -> tuple[set[str], dict[str, int]]:
"""Direcciones ya repasadas y direcciones a medio repasar (con el
arranque del nodo que había cuando se empezó)."""
etiquetas = [e for e in self._rpc("listlabels", monedero=True) or []
if e == ETIQUETA or str(e).startswith(REPASANDO)]
hechas, a_medias = set(), {}
for etiqueta, direcciones in zip(etiquetas, self._lote(
[("getaddressesbylabel", [e]) for e in etiquetas], monedero=True)):
if etiqueta == ETIQUETA:
hechas |= set(direcciones or {})
continue
try:
arranque = int(etiqueta[len(REPASANDO):])
except ValueError:
continue
a_medias.update({d: arranque for d in direcciones or {}})
return hechas, a_medias
def _progreso(self) -> float | None:
repaso = (self._rpc("getwalletinfo", monedero=True) or {}).get("scanning")
if not repaso:
return None
return float(repaso.get("progress") or 0) if isinstance(repaso, dict) else 0.0
def _etiquetar(self, direcciones: list[str], etiqueta: str) -> None:
self._lote([("setlabel", [d, etiqueta]) for d in direcciones], monedero=True)
def preparar(self, monedero: dict) -> None:
"""Se asegura de que el nodo sigue este monedero y de que ya lo ha
repasado entero. Si no, pone en marcha el repaso y lo dice (Repasando)."""
pedidos = requeridos(monedero)
if not pedidos:
return
self._monedero()
llave = (self.host, self.puerto)
with _TAREAS_LOCK:
tarea = _TAREAS.get(llave)
if tarea and tarea["hilo"].is_alive():
raise Repasando(_texto_repaso(self._progreso()))
hechas, a_medias = self._marcas()
faltan = [p for p in pedidos if p["marca"] not in hechas]
if not faltan:
return
progreso = self._progreso()
if progreso is not None: # el nodo sigue en ello (aunque Caudal se cerrara)
raise Repasando(_texto_repaso(progreso))
# Lo que quedó a medias y el nodo no se ha reiniciado desde entonces, terminó bien
arranque = self._arranque()
terminadas = [p["marca"] for p in faltan
if abs(a_medias.get(p["marca"], -10**12) - arranque) <= HOLGURA_ARRANQUE]
if terminadas:
self._etiquetar(terminadas, ETIQUETA)
faltan = [p for p in faltan if p["marca"] not in set(terminadas)]
if not faltan:
return
if tarea and tarea.get("error"):
_TAREAS.pop(llave, None)
raise ErrorElectrum(tarea["error"])
if self.podado:
raise ErrorElectrum("tu nodo está podado (no guarda los bloques antiguos) y no puede repasar "
"el pasado de un monedero; hace falta un nodo completo o un servidor Electrum")
tarea = {"error": None}
tarea["hilo"] = threading.Thread(target=self._repasar, args=(faltan, arranque, tarea), daemon=True)
_TAREAS[llave] = tarea
tarea["hilo"].start()
raise Repasando(_texto_repaso(None))
def _repasar(self, faltan: list[dict], arranque: int, tarea: dict) -> None:
marcas = [p["marca"] for p in faltan]
try:
for i in range(0, len(faltan), 500):
pedido = [{"desc": p["desc"], "timestamp": "now", "active": False}
| ({"range": [0, p["rango"] - 1]} if p["rango"] else {})
for p in faltan[i:i + 500]]
for r in self._rpc("importdescriptors", [pedido], monedero=True, espera=600) or []:
if not r.get("success"):
raise ErrorElectrum("el nodo no admite el descriptor: "
+ str((r.get("error") or {}).get("message") or "sin motivo"))
self._etiquetar(marcas, f"{REPASANDO}{arranque}")
try:
self._rpc("rescanblockchain", [0], monedero=True, espera=None)
except ErrorCore:
self._etiquetar(marcas, "") # no ha repasado: que no parezca que sí
raise
self._etiquetar(marcas, ETIQUETA)
except Cortada:
# El nodo sigue repasando aunque se corte la conexión: las marcas
# «a medias» lo resolverán la próxima vez (ver preparar)
pass
except ErrorElectrum as e:
tarea["error"] = f"El repaso de la cadena no ha terminado: {e}"
# ── consultas (la misma cara que electrum.Cliente) ──
def _leer(self) -> dict:
"""Todo lo que el monedero del nodo sabe, ordenado por huella de script."""
if self._datos is not None:
return self._datos
self._monedero()
lista = (self._rpc("listsinceblock", {}, monedero=True) or {}).get("transactions") or []
txids = list(dict.fromkeys(t["txid"] for t in lista if t.get("txid")
and int(t.get("confirmations") or 0) >= 0 and t.get("category") != "orphan"))
txs = {}
for txid, r in zip(txids, self._lote([("gettransaction", {"txid": t}) for t in txids], monedero=True)):
if int(r.get("confirmations") or 0) < 0:
continue
try:
tx = bitcoin.leer_transaccion(r["hex"])
except (ValueError, TypeError, KeyError):
continue
altura = int(r.get("blockheight") or 0) if int(r.get("confirmations") or 0) > 0 else 0
txs[txid] = {"hex": r["hex"], "altura": altura, **tx}
salidas = {(txid, s["indice"]): s for txid, tx in txs.items() for s in tx["salidas"]}
gastadas = {(e["txid"], e["indice"]) for tx in txs.values() if not tx.get("coinbase") for e in tx["entradas"]}
historial: dict[str, list] = {}
saldos: dict[str, dict] = {}
for txid, tx in txs.items():
tocadas = set()
for s in tx["salidas"]:
huella = bitcoin.huella_script(bytes.fromhex(s["script"]))
tocadas.add(huella)
if (txid, s["indice"]) not in gastadas:
saldo = saldos.setdefault(huella, {"confirmed": 0, "unconfirmed": 0})
saldo["confirmed" if tx["altura"] > 0 else "unconfirmed"] += s["satoshis"]
if not tx.get("coinbase"):
for e in tx["entradas"]:
origen = salidas.get((e["txid"], e["indice"]))
if origen:
tocadas.add(bitcoin.huella_script(bytes.fromhex(origen["script"])))
for huella in tocadas:
historial.setdefault(huella, []).append({"tx_hash": txid, "height": tx["altura"]})
for lista_h in historial.values():
# Como Electrum: lo confirmado por altura y lo que está en espera, al final
lista_h.sort(key=lambda t: (t["height"] <= 0, t["height"], t["tx_hash"]))
self._datos = {"txs": txs, "historial": historial, "saldos": saldos}
return self._datos
def historiales(self, huellas: list[str]) -> list[list[dict]]:
historial = self._leer()["historial"]
return [list(historial.get(h, [])) for h in huellas]
def saldos(self, huellas: list[str]) -> list[dict]:
saldos = self._leer()["saldos"]
return [dict(saldos.get(h, {"confirmed": 0, "unconfirmed": 0})) for h in huellas]
def transacciones(self, txids: list[str]) -> list[str]:
txs = self._leer()["txs"]
fuera = [t for t in txids if t not in txs]
traidas = {}
for txid, r in zip(fuera, self._lote_crudo([("getrawtransaction", [t]) for t in fuera])):
if not isinstance(r, dict) or r.get("error"):
raise ErrorElectrum("tu nodo no guarda una transacción que hace falta y que no es de tus "
"monederos; para eso tiene que llevar el índice «txindex=1»")
traidas[txid] = r.get("result")
return [txs[t]["hex"] if t in txs else traidas[t] for t in txids]
def cabeceras(self, alturas: list[int]) -> list[str]:
resumenes = self._lote([("getblockhash", [int(a)]) for a in alturas])
return self._lote([("getblockheader", [r, False]) for r in resumenes])
def altura(self) -> int:
return self._altura
def _texto_repaso(progreso: float | None) -> str:
por_donde = f" (va por el {round(progreso * 100)} %)" if progreso else ""
return ("Tu nodo está repasando la cadena para encontrar todo lo de este monedero" + por_donde + ". "
"Solo pasa la primera vez y tarda de unos minutos a varias horas, según el aparato. "
"No hace falta esperar con esto abierto: vuelve a intentarlo más tarde.")