""" 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-» 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.")