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.
447 lines
21 KiB
Python
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.")
|