55 Commits
Author SHA1 Message Date
Aitor 2b483e0df9 fix: no atribuir al actor las direcciones de un custodio
Misma raíz que el fallo del CoinJoin, en otra variante. El cluster CIOH ya
excluía los custodios (actorAddrSet), pero la atribución por huella de
software no: la hot wallet de Bitfinex aparecía como 'dirección atribuida al
actor' mientras la conclusión la identificaba como exchange dos líneas más
abajo. La dirección donde el ladrón deposita es del exchange, no suya.

Verificado contra el nodo con un depósito real: de 2 atribuciones a 1.
2026-07-27 13:37:22 +02:00
Aitor d0cd9ef587 fix: no atribuir al actor direcciones del otro lado de un CoinJoin
El informe decía 'el rastro se rompe en un CoinJoin, no se puede atribuir con
honestidad más allá de este punto' y acto seguido listaba 10 direcciones
atribuidas al actor, cinco de ellas salidas de esa misma mezcla — es decir,
de otros participantes, señalados en un documento para una denuncia.

CIOH no aplica dentro de un CoinJoin (es su excepción clásica: la mezcla
existe para romper la suposición de dueño común) y la huella de software sale
estable por construcción, porque todos usan el mismo programa.

finalizeForensicGraph excluye las tx marcadas como mixer del conjunto del
actor y del union-find; la atribución por huella salta esos nodos.

Verificado contra el nodo con un Whirlpool real: de 10 atribuciones a 0.
2026-07-27 13:29:08 +02:00
Aitor 7c12f63f7a feat: cero dependencias externas y caché de peticiones al nodo
Las librerías (React, Babel) venían de unpkg.com y las fuentes de Google.
Ninguno veía qué transacciones analizabas, pero ambos recibían tu IP y la hora
en cada apertura: sabían que usabas Txoko, cuándo y desde dónde — el metadato
que la propia herramienta enseña a proteger. Ahora se sirven desde el nodo,
con verificación por hash y versiones fijadas. El dashboard funciona sin
internet.

Además, caché con TTL y coalescencia en useApi: las transacciones confirmadas
son inmutables y se cachean toda la sesión, así que repetir un análisis ya
explorado no cuesta ninguna petición al nodo (medido).
2026-07-27 13:22:08 +02:00
Aitor 1b6904d2e3 chore: ignorar MEJORAS.md (documento de trabajo privado) 2026-07-27 13:01:14 +02:00
Aitor 14a094f941 feat: tope de ramificación en el peritaje — no seguir repartos masivos
Una tx con cientos de salidas (dust attack, lote de retiradas, airdrop) hacía
que el motor perfilara cada dirección de salida (2 peticiones cada una) antes
de encolar las ramas: ~286 peticiones para una tx de 143 salidas, con la
pestaña congelada. El tope corta antes de perfilar, que es donde está el coste.

Verificado contra el nodo con una tx real de 143 salidas: 2 peticiones.
2026-07-27 12:59:48 +02:00
Aitor 8c2725fb31 docs: documentar los límites reales del peritaje y afinar el aviso de consulta fallida
- el aviso ya no promete que el fallo es transitorio: cuando se repite sobre
  la misma dirección no lo es, y ahora dice por qué y qué hacer
- README: las ramas no comprobadas quedan fuera de 'fondos sin gastar' a
  propósito — cortar protege el nodo y una consulta fallida no es una
  conclusión
- README: el tope de volumen mide número de tx, no peso; una dirección con
  pocas transacciones muy grandes puede agotar el tiempo de espera igual
  (visto con una de 59 tx de ~15 KB)
2026-07-27 12:42:31 +02:00
Aitor c2fb4f1388 fix: el peritaje ya no presenta un fallo de consulta como rastro completo
Detectado probando contra el nodo real: un 503 del backend se mostraba como
'Rastro completo' y contaba la rama como terminal, porque get() devolvía
mockData ante cualquier error y findSpendingTx remataba con .catch(()=>[]).
Un error de red quedaba así indistinguible de 'output sin gastar' — la
conclusión más accionable de un informe pericial, afirmada sobre una consulta
que nunca respondió.

- getStrict en useApi propaga el fallo; get se mantiene igual para el resto
- el motor registra los fallos en trace.queryErrors y no los cuenta como
  fondos sin gastar
- el informe los lista aparte y abre las conclusiones avisando del alcance
  incompleto (UI, JSON y MD)
- la UI muestra el detalle del fallo en vez del falso rastro completo
2026-07-27 12:38:07 +02:00
Aitor 4d242a6ac6 chore: ignorar HALLAZGOS-PERITAJE.md (documento de trabajo privado) 2026-07-27 12:25:00 +02:00
Aitor 251981e383 chore: ignorar PRUEBA-PERITAJE.md (documento de trabajo privado) 2026-07-27 12:04:53 +02:00
Aitor 2abcdee144 fix: pinear versión de Babel standalone en el CDN (7.23.10) 2026-07-16 22:24:51 +02:00
Aitor 50c19ebc9f fix: pasada de calidad sobre el módulo Peritaje forense
Repaso crítico de los 11 commits del módulo (motor, tope de
seguridad, UI a demanda, generador de informe), buscando bugs e
inconsistencias más allá de la poda de ramas ya arreglada. Encontrado
y corregido:

- **Certeza de banda inalcanzable:** en la cronología del informe, el
  ternario que decidía CERTEZA/PROBABLE/POSIBLE para la banda de cada
  tx comparaba `analysis.score>=75` con `analysis.band==="ALTA"` —
  la misma condición dos veces, porque analyzeTx define ALTA como
  score>=75. La rama PROBABLE nunca podía darse. Ahora es
  score>=90→CERTEZA / score>=45→PROBABLE / resto→POSIBLE, las tres
  alcanzables.

- **entityMarks del origen siempre vacío:** buildForensicReport
  llamaba marcasDeTx sobre `origin` (el resumen que devuelve
  finalizeForensicGraph), que no lleva vin/vout — así que la fila de
  origen en la cronología nunca podía mostrar coinbase/OFAC/minería/
  exchange, aunque la tx real sí los tuviera. Ahora
  finalizeForensicGraph calcula entityMarks sobre la tx cruda y lo
  guarda en graph.origin.entityMarks. Verificado con una tx coinbase
  sintética: antes daba [], ahora marca correctamente "⛏️ coinbase
  (origen)".

- **`custodyStop.byVolume`/tx_count usaba el string "maxHops" como
  stopReason** — el mismo valor que ya significaba "truncado por el
  parámetro maxHops" en otro sitio (unspentTerminals), pero refería a
  MAX_NODES (cinturón de seguridad distinto). Además nunca tenía
  cobertura en el informe: un nodo detenido así no generaba ninguna
  conclusión, desaparecía en silencio. Renombrado a "nodeLimit" y
  añadida su conclusión en el informe.

- **`refTxid` calculado y nunca usado:** el fundamento "cambio
  detectado" de cada dirección atribuida guardaba a qué tx se refería
  pero ni la UI ni el export Markdown lo mostraban. Ahora ambos lo
  citan.

- Comentario de cabecera huérfano tras el refactor a saltos (la
  documentación general de "Motor de rastreo forense" quedó pegada
  sin fusionar al comentario específico de initForensicTrace) —
  consolidado en un único bloque coherente. Referencia de línea
  obsoleta a scanWallet, quitada.

- UI: aviso cuando el informe mostrado quedó desactualizado (el
  usuario siguió explorando más saltos después de generarlo).

Verificado en navegador: los tres casos de regresión ya usados dan
resultado idéntico (salto simple, cadena de peeling de 3 saltos,
custodio con rama normal continuando), más un caso nuevo con
`stopReason:"nodeLimit"` sintético que confirma que el informe genera
su conclusión sin errores, y el caso coinbase que confirma el fix de
entityMarks.
2026-07-16 15:03:00 +02:00
Aitor a5d58dadd4 fix: podar solo la rama de custodio, no la transacción entera
Cuando un salto se detenía por custodyStop (entidad conocida, hot
wallet, o tope de tx_count), se descartaban los DOS outputs de esa
transacción, no solo el que disparó la parada — un pago normal junto
a un depósito de exchange en la misma tx se perdía igual. Mixer,
dilución y el tope global de nodos siguen bloqueando la transacción
entera (afectan a todos sus outputs por igual, por construcción); un
custodio es propiedad de UNA dirección concreta y ahora se evalúa por
dirección: `custodyByAddr` sustituye al `custodyStop` único, y solo
esa dirección se excluye del encolado y de la semilla CIOH. El nodo
sigue guardando `custodyStop` (primera coincidencia, para el texto
del informe) y el nuevo `custodyAddrs` (todas), y el texto de las
conclusiones pasa a nombrar la dirección concreta en vez de "la
transacción", que ya no es preciso cuando otra rama sigue su curso.

Verificado en navegador: los dos casos de regresión ya usados
(salto simple, cadena de peeling de 3 saltos) dan resultado idéntico
entre el modo automático y el modo por saltos, sin cambios. Caso
nuevo dedicado — un salto con un output de custodio (79.828 tx) junto
a un output normal — confirma que la rama normal se sigue explorando
un salto más, sus fondos aparecen correctamente como localizados,
la dirección de custodio recibe una sola petición (nunca se pagina),
y el CIOH atribuye la rama continuada sin incluir la del custodio.
Caso de dilución confirma que ese stop sigue bloqueando ambos
outputs, sin regresión.
2026-07-16 14:42:45 +02:00
Aitor 4280274239 docs: actualizar CHANGELOG/README para el rastreo a demanda
Refleja el cambio de modelo del peritaje forense: de "automático
acotado con seguir más saltos" a "a demanda, salto a salto, con
control manual del usuario", más el tope de tx_count como cinturón
de seguridad adicional. El mecanismo antiguo "seguir más saltos (+4)"
ya no existe en el código — se documenta el que lo reemplaza.
2026-07-16 14:26:05 +02:00
Aitor 7f3e2b5abc feat: rastreo forense a demanda — salto a salto, botón "seguir el rastro"
PeritajeForense pasa de "un clic, rastreo automático completo" a "un
clic por salto": el usuario ve el progreso (salto actual, tx
exploradas, ramas pendientes, ramas terminales) y decide cuándo
avanzar, igual que ya funciona el rastro de procedencia hacia atrás
(RastroProcedencia). El rastreo se guarda en un ref mutable
(traceRef) entre clics — initForensicTrace + advanceForensicHop del
commit anterior encajan directamente, sin cambios.

"Iniciar rastreo forense" ahora también ejecuta el primer salto (un
clic para ver algo). "Generar informe" está disponible desde el
primer salto, no solo al terminar, y puede volver a pulsarse en
cualquier momento — incluida la declaración del afectado, editable
mientras el rastreo sigue en marcha. Los campos txid/vout/importe se
deshabilitan una vez iniciado (no tienen efecto a mitad de rastreo);
el tope de saltos y la declaración siguen editables.

Quita el mecanismo antiguo "seguir más saltos (+4)": ya no tiene
sentido con control manual salto a salto, y el texto de ramas
truncadas por el tope se reescribe para no prometer una reanudación
que el motor no soporta (una rama ya truncada no se puede retomar
suelta).

Verificado en navegador con un servidor HTTP local que sirve la
cadena de peeling de 3 saltos ya usada en pruebas anteriores,
pulsando "seguir el rastro" cuatro veces manualmente: el progreso
avanza correctamente salto a salto (1→2→3→4), termina con "rastro
completo", y el informe generado es idéntico en contenido al que
produce el modo automático (misma cadena de peeling CERTEZA, mismas
direcciones atribuidas, mismos fondos sin gastar). Export JSON/MD y
"Limpiar" verificados sin errores de consola.
2026-07-16 14:24:29 +02:00
Aitor b9477c6139 feat: tope de tx_count como cinturón de seguridad en el peritaje
LARGE_ADDR_TX_COUNT=5000: si el perfil de una dirección de salida
tiene chain_stats.tx_count por encima del umbral, se trata como
custodio presunto (stopReason="exchange", byVolume:true) aunque el
heurístico de perfil no la clasifique "hot_wallet" (p.ej. residual
momentáneamente alto en el momento de la consulta). No cuesta
peticiones extra — chain_stats ya se pide para el perfil de toda
dirección de salida nueva.

Cierra el hueco identificado en la auditoría previa: sin este tope,
una dirección enorme no detectada por el heurístico se encolaría para
el siguiente salto, y findSpendingTx intentaría paginar hasta 200 de
sus transacciones (8 páginas × 8s de timeout, ~64s en el peor caso)
buscando una entre decenas de miles, sin ninguna posibilidad realista
de encontrarla.

El texto del informe distingue este caso: es HECHO (medida de
protección, con el tx_count real citado), no INFERENCIA sobre quién
controla la dirección — a diferencia de una parada por ENTITY_INDEX o
por el heurístico de hot wallet, aquí no se afirma nada sobre la
naturaleza de la dirección.

Verificado en navegador con un caso sintético diseñado para que el
heurístico de perfil NO dispare (residual del 100%, fuera del umbral
<0.05) pero con tx_count=79.828 (el caso real que se va a probar): el
tope se activa correctamente y la dirección enorme recibe EXACTAMENTE
1 petición (el perfil barato) — nunca se llega a paginar su historial.
2026-07-16 13:57:09 +02:00
Aitor 039c916f55 refactor: buildForensicGraph a avance por saltos (initForensicTrace + advanceForensicHop)
Separa el motor en tres piezas para poder pausar entre saltos: crear
el estado del rastreo (initForensicTrace), avanzar UN salto completo
(advanceForensicHop, con el mismo throttling por lotes BATCH=5/
PAUSE=120ms de siempre dentro de ese salto) y ensamblar el
ForensicGraph desde el estado en cualquier momento, completo o
parcial (finalizeForensicGraph). buildForensicGraph se mantiene como
caso trivial que llama advanceForensicHop en bucle — modo automático
de una sola pasada, sin cambio de comportamiento.

Prepara el terreno para que la UI (pestaña Peritaje) deje que el
usuario decida cuándo seguir al siguiente salto, en vez de que el
motor drene todas las ramas sin vigilancia. Es la primera de las
protecciones acordadas contra el estrés al nodo con direcciones de
volumen enorme (hot wallets de exchange).

Verificado: balance de sintaxis + node --check sobre el fragmento
puro. En navegador, los dos casos sintéticos ya usados (salto simple,
cadena de peeling de 3 saltos) dan resultado IDÉNTICO byte a byte
entre el modo automático (buildForensicGraph) y el modo por saltos
manual (initForensicTrace + advanceForensicHop en bucle) — grafo,
clusters, cadenas de peeling y conclusiones del informe coinciden.
También verificado que finalizeForensicGraph + buildForensicReport
funcionan correctamente sobre estado PARCIAL (tras un solo salto, sin
terminar el rastreo), que es justo lo que necesitará la UI a demanda.
2026-07-16 13:52:02 +02:00
Aitor d8c1e845b3 docs: documentar módulo Peritaje forense implementado
CHANGELOG (1.10.0) y README reflejan que el peritaje forense ya está
implementado, no solo especificado: qué hace, qué heurísticas reutiliza
y cuáles son nuevas, el marco HECHO/INFERENCIA/DECLARACION, y la
limitación conocida de depender de /api/address/{addr}/txs en vez de
/outspend (el backend Mempool self-hosted no lo expone).
2026-07-16 12:51:30 +02:00
Aitor fcc19c31e1 feat: peritaje forense — pestaña UI y componente PeritajeForense
Nueva pestaña "PERITAJE" junto a las existentes. Formulario de entrada
(txid:vout, importe estimado robado opcional, declaración en texto
libre), botón "Iniciar rastreo forense" con progreso por salto, y
render completo del informe (resumen, declaración, cronología,
direcciones atribuidas, fondos sin gastar, aviso de ramas truncadas
con botón "seguir más saltos", conclusiones, recomendaciones,
metodología, anexo de verificación) con export JSON/MD inline, mismo
patrón que el informe de wallet.

Verificado en navegador (servidor estático local):
- Balance de sintaxis del bloque Babel correcto, sin errores de
  transpilación JSX en consola.
- Validación del formulario (txid vacío/inválido, nodo no conectado)
  funciona.
- Motor completo probado con datos sintéticos vía consola: caso simple
  (1 salto, cambio detectado correctamente, 2 ramas terminan en UTXO
  sin gastar) y caso de cadena de peeling de 3 saltos (detectada con
  certeza CERTEZA, direcciones de cambio atribuidas con fundamento
  correcto, 4 fondos sin gastar localizados en las hojas).

Cierra la implementación del módulo Peritaje descrito en TRASPASO.md.
2026-07-16 12:43:32 +02:00
Aitor 6f3ca4ed7a feat: peritaje forense — generador de informe (buildForensicReport)
Ensambla las secciones de la plantilla (spec en TRASPASO.md) sobre el
grafo de buildForensicGraph: resumen, declaración del afectado,
cronología, direcciones atribuidas (CIOH + cambio detectado + huella
estable), fondos sin gastar, conclusiones numeradas, recomendaciones
(con el aviso anti-estafa de "recuperación" fijo cuando hay
declaración), metodología y anexo de verificación.

Marco HECHO/INFERENCIA/DECLARACION aplicado en cada afirmación; el
export a JSON/MD queda para la UI (mismo patrón inline que el informe
de wallet, sin función compartida hoy).

También: buildForensicGraph ahora guarda origin.analysis y
node.changeAddress (dirección resuelta, no el índice) para que el
informe no tenga que reindexar en addresses.out, que al deduplicar
podría desalinearse con el orden real de vout.
2026-07-16 12:27:18 +02:00
Aitor 6fcffb2096 feat: peritaje forense — motor de rastreo multi-salto (buildForensicGraph)
Rastreo hacia adelante desde {txid,vout} siguiendo TODOS los outputs de
cada salto (no solo el presunto cambio) porque los fondos pueden
repartirse en varias ramas; cada rama se detiene de forma
independiente. Condiciones de parada: CoinJoin real, dilución (3+
direcciones de entrada no relacionadas), entidad conocida o perfil de
hot wallet no indexado, UTXO sin gastar, límite de saltos.

findSpendingTx implementa el fallback ya verificado en la sesión
anterior (TRASPASO.md): este backend Mempool no expone /outspend, así
que se recorre /api/address/{addr}/txs buscando la tx cuyo vin
referencia el txid:vout de origen — mismo patrón de paginación y
throttling por lotes que scanWallet.

detectPeelingChains cierra el hueco que el check `peeling` de
analyzeTx ya señalaba (confirmar una cadena requiere mirar hacia
adelante): reconstruye tramos de nodos 1-in/2-out conectados por la
señal de cambio combinada, sube a CERTEZA con 3+ saltos y huella de
wallet estable.

MAX_NODES=80 como red de seguridad aparte de maxHops, para no hammer
el nodo del usuario si un salto desemboca en una tx con muchos
outputs. Verificado: balance de sintaxis del bloque Babel y
node --check sobre el fragmento JS puro del motor.
2026-07-16 12:22:28 +02:00
Aitor ee817a8c9b feat: peritaje forense — cambio conductual y comparación de huella
behavioralChangeGuess: para cada output, si se gasta rápido (≤6
bloques) o queda quieto (no gastado todavía). combineChangeSignals
cruza esto con la señal estructural de guessChangeOutput — sube la
certeza cuando coinciden, reporta la discrepancia en vez de forzar
una conclusión cuando no.

compareFingerprints compara la huella de detectWallets entre dos
saltos consecutivos del rastro: un cambio de huella es
INFERENCIA/POSIBLE de cambio de actor o entrada en infraestructura
de un servicio, nunca CERTEZA.
2026-07-16 12:13:05 +02:00
Aitor 2e97a6b822 feat: peritaje forense — heurística de perfil de dirección
addressProfile(addr, addrInfo, addrTxs) clasifica personal vs hot
wallet usando chain_stats (residual/tx_count) y detección de barrido
automático (recibe y reenvía 1-in/1-out sin cambio, pocos bloques
después). No identifica al custodio, solo el patrón de comportamiento;
la atribución a un exchange concreto sigue viniendo de ENTITY_INDEX.

Primera pieza nueva del módulo Peritaje (spec en TRASPASO.md), sobre
la base de los refactors de unionFindCluster y guessChangeOutput.
2026-07-16 12:11:53 +02:00
Aitor 133e292aab refactor: extraer guessChangeOutput de analyzeTx (señales A-F de cambio)
Autocontenida para poder llamarse por salto desde el futuro motor de
rastreo forense, que necesita saber qué output concreto seguir (no solo
si el cambio es identificable). analyzeTx delega en ella sin cambio de
comportamiento — mismas señales, mismos umbrales, mismo texto.
2026-07-16 12:10:09 +02:00
Aitor e89fea499c refactor: extraer unionFindCluster de buildWalletReport a utilidad compartida
Prepara la reutilización del union-find CIOH en el módulo de peritaje
forense (semilla = direcciones del actor rastreado en vez de mis
direcciones). Sin cambio de comportamiento en el informe de wallet.
2026-07-16 12:06:25 +02:00
Aitor a6fe438b06 feat: informe de wallet completo (salud, clusters CIOH, historial); refactor: monitor sin dependencias (fuera express), nombres de proceso limpios; mejora: presentación de checks agrupada con didáctica directa 2026-06-12 22:35:09 +02:00
Aitor f757a01b9b feat: informe de wallet (vinculación CIOH, reutilización, historial); fix: monitor v2 con caché TTL (resuelve CPU alta); mejora presentación de checks 2026-06-10 20:50:47 +02:00
Aitor 2966e79578 docs: documentar watch-only y etiquetas BIP-329 (v1.8.0), guía HTTPS en SETUP 2026-06-04 19:51:16 +02:00
Aitor 3816b0426e feat: watch-only por xpub — derivación BIP32 local, identifica outputs propios (recepción/cambio), selector 20/50/100 direcciones 2026-06-04 19:38:15 +02:00
Aitor 57291a381b feat: etiquetas BIP-329 — importar, listar, mostrar en análisis 2026-06-04 13:28:55 +02:00
Aitor 0333f15be5 docs: changelog v1.7.0 2026-06-04 11:36:34 +02:00
Aitor 82b27977eb docs: README v2 — qué no hace, cómo verificar, funcionalidades actualizadas 2026-06-04 11:22:18 +02:00
Aitor f690e1783b feat: check legacy P2PKH/P2SH; fixtures #2 #6 #7 #8 #9 #10 validados 2026-06-04 11:03:54 +02:00
Aitor 2874f7c933 feat: check tipo script legacy (P2PKH/P2SH), peso -15 2026-06-04 10:47:31 +02:00
Aitor 46636bdba9 fix: check OP_RETURN en motor de análisis, banda BAJA forzada 2026-06-04 10:37:25 +02:00
Aitor dca3758c68 fix: CoinJoin estructural (WabiSabi), checks neutros en mezcla 2026-06-04 10:21:38 +02:00
Aitor d232f0bc0d fix: analisis de privacidad de direcciones se muestra en Lab (no callejon sin salida); texto adaptado a direccion/tx 2026-06-03 20:33:45 +02:00
Aitor 8d0c2620d3 feat: deteccion de dusting de privacidad (POSIBLE) ademas del dust tecnico; fix: tono neutro en dust 2026-06-03 20:03:14 +02:00
Aitor d8a35d94cf fix: checks positivos (CoinJoin) se marcan en verde, no como advertencia; docs: fixtures verificados 2026-06-03 19:29:06 +02:00
Aitor b77d21fd84 fix: batch payment penaliza correctamente + textos coherentes; docs: fixtures verificados 2026-06-03 19:18:47 +02:00
Aitor 889ffeb8dd feat: deteccion de exchanges (4a categoria de entidad) con honestidad sobre la fuente 2026-06-03 10:06:20 +02:00
Aitor f9acd12548 feat: legibilidad del rastro + mejora de contraste de texto en toda la app 2026-06-03 09:33:41 +02:00
Aitor b5f0c81b88 feat: mejoras de legibilidad del rastro - veredicto, jerarquia visual, barras y tooltip de detalle 2026-06-03 09:23:23 +02:00
Aitor 2950559203 feat: distancia a entidad en el rastro + manejo correcto de coinbase como origen 2026-06-02 19:32:07 +02:00
Aitor d3971de3a8 feat: rastro de procedencia recursivo + arreglo falso positivo CoinJoin 2026-06-02 19:17:58 +02:00
Aitor a7c65ed7ec feat: rastro de procedencia paso 2 - recursivo, encadenar saltos hacia atras 2026-06-02 19:01:32 +02:00
Aitor b101ca4210 feat: rastro de procedencia paso 1 - seguir inputs hacia atras bajo demanda 2026-06-02 18:48:16 +02:00
Aitor aa5838c010 cambio: OP_RETURN solo detecta presencia y tamaño, no muestra contenido 2026-06-02 18:24:24 +02:00
Aitor 788ed3bada feat: exportar informe de tx en JSON y Markdown + arreglo atajo Lab→Auditoría 2026-06-01 22:38:34 +02:00
Aitor 0110e84c49 feat: exportar UTXO Map a CSV (local, sin salir del nodo) 2026-06-01 20:58:49 +02:00
Aitor b3dedc1865 docs: añadir CHANGELOG v1.0.0 y limpiar README 2026-06-01 20:37:46 +02:00
Aitor 43829fb570 feat: fase 1 (heuristicas) + entidades OFAC/mining + persistencia de pestañas 2026-05-31 08:19:11 +02:00
Aitor 89766e7fdc feat: detección de entidades OFAC y mining pools (fase 1.5) 2026-05-30 19:56:59 +02:00
Aitor 8f5bb0d2ba feat: mejoras fase 1 - peeling, batch, JoinMarket, Whirlpool OP_RETURN, wallet inferido 2026-05-30 19:40:49 +02:00
Aitor c2b7c37c1e docs: quitar tabla comparativa del README 2026-05-30 19:26:50 +02:00
Aitor 9ee860a78a inicio: dashboard de privacidad Bitcoin con análisis on-chain 2026-05-30 19:12:24 +02:00
16 changed files with 589 additions and 3047 deletions
-14
View File
@@ -26,17 +26,3 @@ TRASPASO.md
PRUEBA-PERITAJE.md PRUEBA-PERITAJE.md
HALLAZGOS-PERITAJE.md HALLAZGOS-PERITAJE.md
MEJORAS.md MEJORAS.md
COHERENCIA.md
GIT.md
REPASO.md
HEURISTICAS.md
# Librerías de terceros (React, Babel, IBM Plex). No van al repositorio: son
# ~3,5 MB de código ajeno y cada quien se las descarga verificando el hash,
# que es justo lo que enseña a hacer SETUP.md. Se ignora también aquí para que
# una copia local del dashboard no las cuele en un commit por descuido.
vendor/
# Ajustes locales de Claude: son de esta maquina y llevan rutas absolutas
.claude/
-461
View File
@@ -10,409 +10,6 @@ y el versionado sigue [Versionado Semántico](https://semver.org/lang/es/):
- **MENOR** — características nuevas que no rompen lo anterior - **MENOR** — características nuevas que no rompen lo anterior
- **PARCHE** — arreglos de errores - **PARCHE** — arreglos de errores
---
## [1.18.2] — 2026-08-08
### Corregido
- **`instalar-fuentes.sh` daba por fallidas descargas que habían ido bien, en
macOS.** Comprobaba el tamaño con `stat -c%s`, que es sintaxis GNU. macOS
trae la versión BSD de `stat`, que usa `-f%z`, así que el comando fallaba,
caía en el `|| echo 0` y el script informaba **«FALLÓ (HTTP 200, 0 bytes)»**.
El `HTTP 200` decía que la descarga había funcionado; lo roto era la
comprobación.
- El mensaje además engañaba: se lee como un problema de red o del servidor
de fuentes, y era una incompatibilidad de shell entre dos sistemas.
- Ahora usa `wc -c`, que se comporta igual en Linux y en macOS, y comprueba
antes que el archivo exista.
- La cabecera del script decía «Ejecutar EN EL NODO». Ya no: si el dashboard
puede vivir en una carpeta cualquiera, su instalador de fuentes tiene que
funcionar ahí también.
---
## [1.18.1] — 2026-08-08
### Corregido
- **El aviso del xpub decía una media verdad.** El panel de wallet watch-only
decía «Txoko deriva tus direcciones en local — el xpub no sale del
navegador». Es cierto, y por eso mismo engaña: el xpub no sale, pero **las
direcciones derivadas sí**, una consulta a la API del nodo por cada una. Quien
opere ese nodo puede ver en sus registros el monedero completo que estás
analizando: cuántas direcciones tienes, cuáles, su historial y tu saldo.
- Con el nodo propio no hay problema, es tu información en tu máquina. Con el
nodo de otra persona —aunque sea de confianza— le estás enseñando tus
finanzas enteras.
- El aviso lo dice ahora, y distingue los dos casos. En una herramienta que
existe para no afirmar de más, ese era el peor sitio posible para tener una
frase técnicamente cierta que se lee como una garantía que no es.
### Añadido
- `PRUEBALA.md` — guía para quien recibe la aplicación para probarla: el aviso
del xpub por delante de todo, qué merece la pena intentar romper, qué no
hacer con el peritaje, y qué tipo de fallo interesa que reporte.
---
## [1.18.0] — 2026-08-08
### Añadido
- **La comisión como huella.** El sat/vB que eliges dice algo del software y de
quien lo maneja, y el dato estaba en cada transacción sin que nadie lo
mirase. Se detectan dos firmas: una comisión **absoluta** redonda (10.000
sats clavados — un estimador nunca da eso, calcula tarifa por tamaño y
devuelve números feos) y una **tarifa** prácticamente entera (20,00 sat/vB,
que sale de teclear un número redondo en la casilla).
- Informativo y sin penalización, por el mismo motivo que se decidió con RBF:
la señal es débil, y bajar la nota por ella empujaría a elegir comisiones
peores solo para disimular. Mal negocio.
- **El cambio ya se identifica en transacciones de más de dos salidas, cuando
el caso es inequívoco.** Antes la detección se plantaba en seco por encima de
dos salidas. Estaba bien argumentado, pero se tiraba información sólida: si
de N salidas **exactamente una** comparte tipo de script con las entradas, no
hay nada que adivinar — y con más salidas la coincidencia por azar es aún más
improbable que con dos. También cuenta el caso de una salida que vuelve a una
dirección ya gastada. Se marca POSIBLE y no PROBABLE: es una sola señal, y
hay monederos que devuelven el cambio a un tipo distinto del que gastan.
- **Aviso de autoenvíos que ataron direcciones**, en el informe de wallet. Es
una comprobación que solo cabe ahí: para saber que *todas* las salidas son
tuyas hace falta conocer tu wallet entero, cosa que el analizador de una
transacción suelta no puede saber.
- Detecta las transacciones donde entradas y salidas son todas tuyas, y avisa
solo cuando hubo daño real: más de una dirección de entrada. Mover una
dirección a otra no vincula nada nuevo y no merece alarma.
- Es un gesto que suele hacerse creyendo que despista y hace lo contrario: no
hubo ningún pago, pero al firmar varias direcciones a la vez quedaron
enlazadas en público. Se paga una comisión por empeorar la propia
privacidad.
Con esto quedan cerradas las cinco heurísticas que el repaso del 2026-08-08
señaló como ausentes.
---
## [1.17.0] — 2026-08-08
### Cambiado
- **La nota de privacidad ya solo mide lo que dependía de ti.** Hasta ahora
mezclaba dos cosas distintas en un número: los errores propios (reutilizar
direcciones, consolidar, dejar el cambio a la vista) y la exposición que
provoca un tercero (que te paguen desde un lote, recibir polvo, que la otra
parte use un tipo de dirección distinto). La consecuencia era absurda:
**alguien impecable que cobró de un exchange sacaba peor nota que un
descuidado que cobró de un particular**, y no había nada que pudiera hacer
para mejorarla. Recibir de un lote costaba 23 puntos y estaba marcado, con
razón, como "no corregible".
- La nota se calcula ahora solo con los checks sobre los que tienes decisión.
Denominador 142.
- Lo demás pasa a un bloque propio, **«lo que hicieron otros»**, con su
propio nivel de exposición (ninguna / moderada / alta). Se informa, se
explica, y no baja una nota que no podrías subir.
- Una nota que no puedes mejorar no es una evaluación, es un reproche. Y una
herramienta que reprocha lo que no elegiste enseña a ignorarla.
- **Un fallo grave impide la banda ALTA aunque el número dé de sobra.** Con la
nota restringida a lo propio, una transacción con el cambio identificable
—una fuga real y concreta— sacaba 93 sobre 100 y la etiqueta "privacidad
aceptable". Ahora cualquier check propio que falle con peso ≥10 impide la
banda alta. Un promedio bueno no borra un fallo concreto.
- **OP_RETURN deja de forzar banda BAJA por decreto.** Sigue penalizando, pero
el tope duro condenaba por igual a una inscripción y a un sello de tiempo de
OpenTimestamps, que es privacidad neutra. Como además OP_RETURN suele venir
del protocolo que usaste y no de tu descuido, vive ahora en la exposición
heredada.
- Los cortes de banda pasan a 85 y 55: con la nota midiendo solo lo propio, el
listón puede ser más exigente porque ya no hay dentro nada que no puedas
arreglar.
- La exportación en Markdown y en JSON incluyen ambos bloques por separado.
---
## [1.16.0] — 2026-08-08
### Corregido
- **El denominador de la nota de privacidad no cuadraba con los pesos.** La
nota se calcula como `100 - (deducciones / maxPossible) * 100`, donde
`maxPossible` es la suma del objeto `weights`. Ese objeto tenía dos
desajustes, en direcciones opuestas y ninguno intencionado:
- Incluía `rbf` (5) y `peeling` (8), que son informativos y **nunca restan**.
Trece puntos de denominador imposibles de alcanzar, que inflaban
sistemáticamente todas las notas.
- **No** incluía `input_linkage`, que resta hasta 30 desde una variable
suelta. Las deducciones podían superar al denominador en 17 puntos y dar
una nota negativa, tapada solo por el `Math.max(0, …)`.
- Ahora todo lo que puede restar está en `weights` y nada más lo está. Los
cortes de banda pasan de 75/45 a 79/54, que son los valores que hacen que
una transacción reciba **exactamente la misma banda que antes** con el
denominador corregido. Queda anotado en el código que normalizar por la
suma de los pesos obliga a recalibrar cada vez que se añade un check —
porque cada check nuevo hace parecer menos graves a los anteriores.
- El texto explicativo bajo la nota derivaba la banda por su cuenta con los
cortes antiguos escritos a mano. Ahora usa la banda ya calculada.
- **"Cifra redonda" estaba mal definido.** La condición era
`v % 1000000 === 0 || v % 100000 === 0 || v % 10000000 === 0`, donde la
primera y la tercera sobran: todo múltiplo de un millón lo es de cien mil.
Equivalía a "múltiplo de 0,001 BTC", así que pagos tan redondos como 10.000
o 50.000 sats —de lo más común con las comisiones de hoy— no se veían. La
heurística estaba infrautilizada, no equivocada.
- Ahora se mide por ceros finales y la señal se gradúa: pagar 0,5 BTC
clavados delata más que pagar 0,0123, y la penalización lo refleja.
- El didáctico dice ahora el punto ciego: si pagas una cantidad redonda **en
euros**, en BTC sale un número con todos sus decimales y esta heurística no
ve nada.
### Añadido
- **Nuevo aviso: se gasta polvo junto a otras monedas.** El check de polvo
advertía de que una salida diminuta "quedará vinculada con las demás si se
gasta junto a ellas" — y la aplicación nunca miraba si eso estaba ocurriendo
delante de ella, pese a tener el dato en la mano. Ahora lo mira: si entre las
entradas hay una moneda de tamaño polvo gastada junto a otras de importe
normal, el ataque ya no es una hipótesis, se acaba de consumar, y se dice con
CERTEZA. No aplica en CoinJoin ni cuando solo se gasta polvo, que no revela
vinculación nueva.
- **La consolidación pura ya no pasa desapercibida.** Una transacción de N
entradas a **una sola salida** —el acto que más privacidad destruye de una
vez— no disparaba el check de inputs innecesarios: su condición exige que una
entrada cubra el pago *y sobre*, y en una consolidación la salida vale casi
lo mismo que la suma de entradas. Comprobado con 20 entradas a 1 salida.
Ahora se nombra como lo que es, con certeza. No suma penalización propia a
propósito: la vinculación ya la cobra `input_linkage`, y cobrarla dos veces
sería repetir el error que este mismo repaso acaba de corregir.
- `tests/test6.js` — el analizador completo contra transacciones construidas a
mano: coherencia de la nota, los dos checks nuevos y la guardia de CoinJoin.
- `tests/test5.js` gana la comprobación de los niveles de redondez.
- `tests/README.md` explica ahora las dos familias de prueba y deja escrito que
las heurísticas se comprueban contra transacciones fabricadas, no contra la
cadena real: eso demuestra que la lógica hace lo que dice, pero no cuántas
veces acierta ahí fuera.
---
## [1.15.0] — 2026-08-08
### Corregido
- **La detección del cambio contaba una misma señal dos veces.** La "señal A"
(solo una salida comparte tipo de script con las entradas) y la "señal E"
(el tipo de output difiere del de las entradas) eran la misma condición
escrita dos veces —el mismo `filter`, la misma comparación, el mismo `=== 1`
y cada una incrementaba el contador. Como el umbral para declarar el cambio
identificable es de dos señales, **ese único hecho bastaba por sí solo**.
- Consecuencia práctica: pagar desde una dirección bech32 a una taproot —de
lo más común que hay— salía marcado como "cambio identificable" con certeza
PROBABLE, sin ninguna otra evidencia.
- Y el informe mostraba las dos señales juntas, que se contradicen entre sí:
"tipo de script idéntico a inputs" y "tipo de output distinto al de inputs".
El mismo hecho descrito con palabras opuestas.
- Ahora es una sola señal. Esa transacción pasa a una señal y a **no
identificable**, que es lo correcto. Cuando queda una sola señal el check
lo dice en vez de callarse: existe, no basta, y conviene que se sepa que
otro analista menos escrupuloso la daría por buena ella sola.
- **Se podía señalar el pago como si fuera el cambio.** Cuando ninguna señal
fuerte apuntaba a un output concreto, el índice caía en `indexOf(smaller)`:
se asumía que el cambio es siempre la salida menor. Es falso en el caso más
común de todos —pagar poco desde una moneda grande—, donde el cambio es la
salida *mayor*.
- Comprobado con un pago de 0,001 BTC desde 1 BTC: la app decía en el detalle
"el output redondo es el pago" y acto seguido señalaba ese mismo output
como cambio. La información para acertar estaba delante y se descartaba.
- Esto no se quedaba en el analizador: el motor de peritaje usa ese índice
para detectar cadenas de peeling y para redactar el informe. Un cambio mal
identificado significa presentar como rastro del actor **la dirección del
destinatario del pago**, una persona ajena.
- Ahora cada señal apunta a un output o reconoce que no puede. El orden de
resolución es: reutilización de dirección de entrada → tipo de script →
la salida NO redonda → posición. Si ninguna apunta, el índice queda vacío
y se dice que hay señales pero no cuál — preferible a señalar mal.
### Añadido
- `tests/test5.js` — seis casos de detección de cambio con el pago y el cambio
conocidos de antemano, incluidos los dos fallos anteriores como regresión.
---
## [1.14.0] — 2026-08-08
### Añadido
- **Mempool y Bloques se fusionan en una sola pestaña, «Cadena».** Eran el
mismo eje partido por la mitad: los "bloques proyectados" vivían en Mempool y
los confirmados en Bloques, cuando es una línea temporal con el ahora en el
corte. Ahora se lee de arriba abajo: comisiones, lo que está por minar, un
separador **AHORA**, y los bloques ya minados.
- Nuevo: **«si pagas X sat/vB, ¿cuándo entra?»**. Ni la vista de mempool ni la
de bloques respondían la pregunta que de verdad se hace quien mira las
comisiones — daban los datos por separado y dejaban el cálculo al ojo.
Escribes una tarifa y te dice en qué bloque proyectado caería, marcándolo
en la lista. Con dos avisos escritos en la propia interfaz: el cálculo se
hace sobre la mempool de este momento, y diez minutos es la media entre
bloques, no una promesa.
- El detalle de un bloque se abría al final de la lista, a quince filas del
bloque pulsado, así que parecía que el clic no hacía nada. Ahora la vista
se desplaza hasta él.
- **Aviso de polvo recibido** en el informe de wallet. El analizador ya sabía
reconocer el patrón al mirar una transacción suelta, pero eso no sirve si no
te avisa cuando te pasa a ti. Recorre las salidas de importe ínfimo hacia
direcciones propias y distingue las que siguen sin gastar —donde el consejo
todavía sirve— de las ya gastadas, donde la vinculación ya está hecha.
El consejo es el contrario del instinto: **no hagas nada**, déjalas quietas y
congélalas en el monedero si puedes. El daño solo ocurre al mezclarlas.
- **Enlaces «ver en Mempool»** en nueve puntos: transacción analizada, detalle
de bloque y sus transacciones, dirección del explorador, historial del informe
de wallet, direcciones atribuidas y fondos localizados del peritaje, y en el
rastro de procedencia la dirección de cada entrada y su transacción de origen.
Se construyen **siempre** desde la URL configurada; sin nodo, no hay enlace.
### Corregido
- **El rastro de procedencia enlazaba a mempool.space público** cuando no había
nodo configurado (`base ? … : "https://mempool.space/tx/…"`). Un clic ahí le
dice a un tercero qué transacción estás investigando, que es exactamente lo
contrario de para lo que existe esta herramienta. Sin nodo ya no hay enlace:
se muestra el txid en texto plano. Auditado que no queda ningún enlace
saliente en toda la aplicación.
- La fee mediana del detalle de bloque se mostraba con dieciséis decimales.
### Nota de diseño
- Se descartó hacer persistentes las etiquetas BIP-329. Vinculan direcciones con
descripciones en lenguaje natural ("ahorro", "pago a…") y guardarlas dejaría
en disco justo el mapa que un atacante querría. Que vivan solo en memoria no
es una carencia, es la misma decisión que ya se aplica a la URL del nodo.
---
## [1.13.0] — 2026-08-08
### Cambiado
- **El checklist decía que un swap alto era "normal en nodos con índice
completo".** No lo es, y decirlo hace que se ignore durante semanas un
síntoma con causa y con arreglo. El caso que lo cambió: swap al 99% con 18 GB
de RAM libre, porque Fulcrum tenía `db_mem` a 16 GB — más de lo que cabe en
la máquina junto al resto de servicios. Con el swap lleno el sistema se queda
sin margen y, ante un pico, el OOM killer elige él a quién mata.
- El umbral baja del 95% al 50%, que es cuando conviene mirarlo y no cuando
ya es tarde, y pasa a estado de error por encima del 80%.
- Si el swap sube **teniendo RAM libre**, el aviso lo señala: es la firma de
un servicio con más memoria reservada de la que cabe, no de presión real.
- Apunta al sospechoso habitual (`db_mem` de Fulcrum, que se sube para
acelerar la sincronización inicial y es fácil olvidarse de bajar) e incluye
el comando para ver qué proceso ocupa el swap.
- Misma corrección en la explicación de SWAP de los DOCS.
### Añadido
- **Nueva comprobación: direcciones que quedan vinculadas entre sí.** Era el
hueco de fondo del analizador: medía la privacidad desde el lado de quien
*construye* la transacción (¿se ve mi cambio?, ¿pago cifras redondas?) y no
desde el lado de los dueños de las monedas gastadas, que es donde el daño es
irreversible. Gastar N direcciones distintas a la vez las enlaza para siempre
por CIOH, y eso no se decía en ningún sitio. El peso sube con el número de
direcciones y no aplica en CoinJoin, donde romper esa vinculación es el
objetivo.
### Corregido
- **El explorador mostraba saldo cero en direcciones con mucho historial.** El
backend de Mempool sobre Fulcrum devuelve los importes a cero cuando la
dirección tiene muchas transacciones, aunque el contador venga bien — y la app
los presentaba como ciertos. Una dirección con 492 transacciones aparecía con
"Balance: 0,00000000 BTC". Ahora se detecta la inconsistencia (tener
transacciones y no haber recibido nada es imposible en la cadena), los
importes se muestran como "no disponible" y se explica por qué. El contador de
transacciones y el historial, que sí son correctos, se mantienen.
- Mismo arreglo en el perfil de dirección del peritaje: antes concluía "no es
hot wallet" cuando en realidad no había podido evaluar la señal de flujo.
- **El fingerprinting atribuía transacciones a Electrum por AUSENCIA de rasgos**
(locktime 0, sin RBF, sequence por defecto). Es justo al revés: Electrum
moderno pone locktime a la altura actual y señaliza RBF. Lo que esas señales
describen no es un monedero concreto, sino software que no configura nada.
Ahora Electrum exige señales positivas y aparece un resultado nuevo, **"sin
rasgos distintivos"**, que además no penaliza: no dejar huella es lo contrario
de tener una huella identificable.
---
## [1.12.1] — 2026-07-27
### Corregido
- **El monitor mostraba una CPU por proceso que engañaba.** La columna `%CPU`
de `ps aux` no es el consumo actual sino la **media desde que el proceso
arrancó** (tiempo de CPU dividido por tiempo de vida). Un proceso que trabajó
mucho hace tres días seguía apareciendo alto para siempre, y se leía como si
estuviera saturando la máquina. Se detectó porque los porcentajes por proceso
no cambiaban NUNCA entre lecturas mientras la CPU global sí variaba.
- Ahora se mide de verdad: dos lecturas de `/proc/PID/stat` separadas 500 ms,
el mismo método que ya usaba `getCpuUsage` para el total. Las dos esperas
corren en paralelo dentro del mismo `Promise.all`, así que no cuesta tiempo.
- La interfaz muestra el **porcentaje sobre el total de la máquina**, que es
lo que suele querer saberse, con el porcentaje de un núcleo y la media de
`ps` en el tooltip para poder comparar con `top`.
- Comprobado con carga artificial: el proceso ocupado marcaba 100% de un
núcleo (25% de un sistema de 4) mientras `ps` daba 152%, imposible para un
proceso de un solo hilo.
### Cambiado
- El UTXO Map pedía las transacciones de contexto con `fetchWithTimeout`
directo, saltándose la caché añadida en la 1.11.0. Era el único punto que no
la aprovechaba. Ahora pasa por `get()`, así que esas transacciones —ya
confirmadas, e inmutables— se guardan toda la sesión.
### Documentación
- **Reescrita la guía de instalación.** `SETUP.md` no era una guía: más de la
mitad eran los pasos personales de git y Gitea del autor ("PASO 1 — Crear el
repo en Gitea"), que se han movido a un documento privado. Ahora es una guía
real, con una comprobación tras cada paso para saber si vas bien sin
descubrirlo al final con una pantalla en blanco.
- **Corregida la configuración de nginx documentada, que estaba rota.** El
README indicaba `alias /var/www/txoko/dashboard.html` — un alias a un
ARCHIVO. Desde la 1.11.0, con las librerías en `vendor/` y rutas relativas,
eso hace que el navegador no las encuentre y la página quede en blanco sin
ningún error visible. Debe apuntar al **directorio**. Es el fallo más común
de esta instalación y ahora está avisado en tres sitios, con una comprobación
concreta (`curl` mirando el `content-type`, no solo el código 200) para
descartarlo.
- Los pasos de instalación no mencionaban las librerías del frontend, sin las
cuales la aplicación no arranca. Ya están integradas en el orden correcto.
- Eliminados del repositorio los datos personales del autor: rutas de su
máquina en la documentación y, sobre todo, su nombre de usuario del sistema
y su ruta de instalación dentro de `txoko-metrics.service`, que llevaban ahí
desde el primer commit. El servicio usa ahora marcadores que hay que ajustar
antes de instalarlo.
- README: estructura del repositorio actualizada (faltaban `tests/`,
`instalar-fuentes.sh` y `FIXTURES.md`) y sección de instalación reducida a un
resumen que remite a SETUP.md, para que haya una sola fuente de verdad.
- SETUP.md incluye ahora tabla de problemas frecuentes y cómo actualizar.
---
## [1.12.0] — 2026-07-27
### Añadido
- **Auditoría de PSBT: revisar una transacción ANTES de firmarla.** Hasta ahora
toda la app era diagnóstico *a posteriori* — te contaba con detalle lo que ya
había pasado y no podías cambiar. Esta es la primera pieza que llega a tiempo.
Sustituye al antiguo "validador", que solo comprobaba que el archivo empezara
por `psbt` y decía su tamaño.
- **Parser completo de BIP174 escrito desde cero**, sin librerías, como el
resto del proyecto. Validado contra los cinco vectores inválidos que publica
el propio estándar: los rechaza los cinco, cada uno con un mensaje que
explica en castellano qué está mal (truncada, sin salidas, ya firmada,
sin transacción interna, con claves repetidas).
- **Todo el análisis es offline.** Una PSBT bien formada ya lleva dentro los
importes y scripts de sus entradas, así que no hace falta consultar el nodo:
funciona con el nodo sincronizando, y el nodo ni se entera.
- Acepta base64, hexadecimal o abrir el archivo `.psbt` directamente.
- **Aviso crítico sobre xpubs incrustados.** Las PSBT suelen llevar dentro las
claves públicas maestras de la cartera, y están hechas para compartirse — se
mandan por correo o chat para que el resto firme. Quien reciba el archivo
puede derivar todas las direcciones, presentes y futuras, y ver el saldo y
el historial completos. No puede gastar, pero lo ve todo. Ningún wallet
avisa de esto.
- Detecta además: cambio identificable por tipo de dirección, importes
redondos que delatan cuál es el pago, envíos a la propia cartera, carteras
multifirma (con su M-de-N) y PSBTs incompletas sin los importes de entrada.
- Cada aviso mantiene el formato del resto de la app — Hecho, Consecuencia y
**Qué puedes hacer** —, que aquí cobra sentido literal: todavía estás a
tiempo de cambiar la transacción.
### Corregido
- **Revisión de la criptografía watch-only.** Se verificó la derivación completa
contra los vectores oficiales de los estándares: RIPEMD-160 (los seis del
estándar, incluido el de un millón de caracteres), secp256k1, BIP32 (vectores
1 y 2) y bech32/BIP173. **La matemática es correcta y no se tocó.** Los fallos
estaban en la validación de la entrada:
- **No se comprobaba la suma de verificación del xpub.** Un carácter mal
copiado se aceptaba sin protestar y generaba 200 direcciones ajenas: el
usuario habría visto su cartera "sin actividad" y se habría quedado
tranquilo. Falso negativo silencioso, el mismo patrón que el resto de fallos
de esta jornada. Nuevo `decodeBase58Check`.
- **No se validaba la longitud** del material decodificado (deben ser 78
bytes) ni que la clave pública fuese comprimida (0x02/0x03).
- **`tpub` se trataba como mainnet.** La red se detectaba mirando si el texto
empezaba por "tb", "u" o "v" — y un `tpub`, el formato más común de testnet,
empieza por "t" pero no por "tb". Generaba direcciones `bc1…` a partir de
claves de testnet. Ahora se detecta por bytes de versión, que son
inequívocos, con las diez variantes (x/y/z/Y/Z y t/u/v/U/V).
- **`deriveChildPubkey` aceptaba índices endurecidos**, matemáticamente
imposibles desde una clave pública. No era alcanzable desde la interfaz,
pero una función criptográfica debe defenderse sola.
- Añadida la carpeta `tests/` con las cuatro baterías y su documentación,
incluido qué **no** cubren: no sustituyen una auditoría externa.
--- ---
## [1.11.0] — 2026-07-27 ## [1.11.0] — 2026-07-27
### Añadido ### Añadido
@@ -445,50 +42,6 @@ señaló como ausentes.
`system-metrics.js` en el servidor. `system-metrics.js` en el servidor.
- Medido: repetir un rastreo forense ya explorado cuesta **cero peticiones**. - Medido: repetir un rastreo forense ya explorado cuesta **cero peticiones**.
### Cambiado
- **Un hit de OFAC ya no penaliza la banda de privacidad.** Era un error
conceptual con consecuencias ideológicas: que tus monedas hayan pasado por una
dirección sancionada no revela nada más sobre ti — tu privacidad es idéntica
antes y después. Lo que cambia es la probabilidad de que un servicio regulado
te bloquee un depósito, que es otro eje: censurabilidad, no privacidad.
Restar puntos ahí equivalía a dar por buena la idea de "monedas contaminadas",
justo la que la fungibilidad de Bitcoin niega. Se sigue informando del riesgo
real, ahora como aviso de censura y con el contexto que faltaba: la lista OFAC
es una decisión política de un gobierno concreto, no una determinación
judicial, y fuera de su jurisdicción solo llega a través de intermediarios que
la aplican.
- **RBF pasa a informativo, sin penalización.** Es buena práctica —permite
desatascar una transacción sin sobrepagar de entrada— y hoy lo activan casi
todos los wallets, así que distingue poco. Restar puntos empujaba al usuario
hacia una decisión peor para esconder una señal débil.
- Checks con inferencia fuerte (`round_numbers`, `unnecessary_input`,
`output_type_mismatch`) reescritos con el patrón Hecho / Interpretación /
Consecuencia que ya usaban `dust` e `input_type_mixing`. Antes afirmaban de
más ("un analista puede identificarlo sin ambigüedad"); ahora explican cuándo
la señal falla y por qué son PROBABLE y no CERTEZA.
- `timing`: el texto didáctico describía patrones entre varias transacciones
cuando el check solo ve una. Además se aclara que la hora mostrada es la del
bloque, no la de la firma — entre ambas puede haber horas de mempool.
### Documentación
- README: nueva sección **"Sí, esto es chain analysis"**. El peritaje usa las
mismas técnicas que las empresas de vigilancia de cadena y negarlo restaba
credibilidad ante quien lee el código. Se explica qué cambia —quién lo
ejecuta, sobre qué, dónde se detiene, quién se queda el informe— y el motivo
de fondo: la misma herramienta que sigue el rastro de un ladrón demuestra lo
fácil que es seguir el tuyo.
- El informe forense advierte del coste de denunciar: entregarlo vincula tu
identidad legal con esas direcciones de forma permanente, ante una autoridad
que puede compartir el expediente con empresas de análisis. Antes se
recomendaba denunciar sin mencionar el precio.
- Aviso junto a los botones de exportación: el archivo lleva tus direcciones y
tu declaración dentro. Nada ha salido de tu red hasta ese punto; a partir de
ahí depende de quien lo descarga.
- Corregido "un único archivo HTML autocontenido" (aparecía cuatro veces): desde
la 1.11.0 las librerías viven en `vendor/`. Se sustituye por una descripción
exacta que además dice más — lo que lees en el archivo es lo que se ejecuta,
no hay versión compilada que auditar por separado.
### Corregido ### Corregido
- **El informe ya no atribuye al actor direcciones del otro lado de un - **El informe ya no atribuye al actor direcciones del otro lado de un
CoinJoin.** Detectado probando un rastro que atraviesa un Whirlpool real: el CoinJoin.** Detectado probando un rastro que atraviesa un Whirlpool real: el
@@ -506,20 +59,6 @@ señaló como ausentes.
`mixer` del conjunto de direcciones del actor y del union-find, y la `mixer` del conjunto de direcciones del actor y del union-find, y la
atribución por huella salta esos nodos. atribución por huella salta esos nodos.
- Verificado con el mismo caso: de 10 direcciones atribuidas a 0. - Verificado con el mismo caso: de 10 direcciones atribuidas a 0.
- **El informe de wallet ya no da un aprobado optimista cuando faltan datos.**
Mismo patrón que el fallo del peritaje, en la pieza central del proyecto:
`scanWallet` usaba `.catch(()=>[])` en las tres consultas del escaneo, así que
una dirección que el nodo no pudo servir quedaba indistinguible de una
dirección sin actividad. Sus transacciones no se traían, no contaban para la
reutilización, no entraban en el union-find y no aparecían en el historial —
y el informe daba su valoración de salud sin mencionar que le faltaban datos.
El sesgo iba siempre hacia el optimismo: menos actividad vista es mejor nota.
- Las tres consultas usan ahora `getStrict` y registran los fallos en
`report.scanErrors`, con la dirección, la rama y en qué fase ocurrió.
- Aviso en rojo **antes** de la banda de salud (una valoración leída sin saber
que faltan datos es peor que ninguna valoración), y bloque equivalente al
principio del export Markdown.
- **Tampoco atribuye al actor las direcciones de un custodio.** Misma raíz que - **Tampoco atribuye al actor las direcciones de un custodio.** Misma raíz que
el fallo anterior, descubierta probando la poda por rama con un depósito real el fallo anterior, descubierta probando la poda por rama con un depósito real
en Bitfinex: el cluster CIOH ya excluía las direcciones de custodio, pero la en Bitfinex: el cluster CIOH ya excluía las direcciones de custodio, pero la
-91
View File
@@ -1,91 +0,0 @@
# Txoko Node Dashboard — pruébala y rómpela
Gracias por dedicarle un rato. Esto no es una demo pulida: es una herramienta
joven que necesita ojos que no sean los míos. Lo más útil que puedes hacer es
**intentar que diga una tontería**.
Léete esto antes de empezar. Son tres minutos y evita el único problema serio.
---
## ⚠️ Lo único importante: cuidado con el xpub
La aplicación tiene una función que analiza un monedero entero a partir de su
**xpub** (la clave pública maestra). Deriva tus direcciones en tu navegador —el
xpub no se envía a ninguna parte— pero para saber qué ha pasado con cada
dirección **tiene que preguntárselo al nodo, una consulta por dirección**.
Eso significa que **quien opere el nodo puede ver tu monedero completo**:
cuántas direcciones tienes, cuáles, su historial y tu saldo.
- Si estás usando **mi nodo**: no metas tu xpub real. Yo vería tus finanzas.
No porque vaya a mirar, sino porque no deberías tener que fiarte de eso — es
exactamente el problema contra el que existe esta herramienta.
- Si quieres probar esa función, usa un **xpub de un monedero de prueba** sin
fondos, o monta tu propio nodo (`SETUP.md`).
Todo lo demás —analizar una transacción, una dirección suelta, un bloque— no
tiene este problema.
---
## Qué merece la pena probar
**Analiza transacciones tuyas de las que sepas la verdad.** Es lo más valioso
que puedes hacer, porque tú sabes cuál era el pago y cuál el cambio, y la
aplicación no. Si acierta, bien. Si se equivoca, **eso es oro**: dímelo.
**Fíjate en cómo dice las cosas.** Cada señal está etiquetada con su nivel de
certeza: CERTEZA es un hecho comprobable en la cadena, PROBABLE e INFERENCIA
son interpretaciones. Si en algún sitio te parece que afirma más de lo que
puede saber, quiero saberlo — es el fallo que más me importa de todos.
**Rompe cosas.** Pega un txid mal copiado, una dirección inventada, un xpub con
un carácter cambiado. Debería decirte que algo no cuadra, no inventarse una
respuesta.
**Prueba el peritaje** (rastro de fondos robados) con cualquier transacción
pública conocida y mira si lo que concluye se sostiene.
---
## Qué NO hacer
**No la uses para acusar a nadie.** El peritaje aplica las mismas técnicas que
las empresas de vigilancia de cadena, y comete los mismos errores. Hoy mismo
salieron dos fallos que hacían que señalara al destinatario de un pago como si
fuera el rastro del ladrón. Están arreglados, y por eso mismo doy por hecho que
quedan más.
**No te tomes la nota como un veredicto.** Es una guía para aprender dónde
filtras información, no una calificación.
---
## Qué me sirve que me cuentes
Por orden de utilidad:
1. **Algo que afirma y es falso.** Lo más grave y lo más valioso.
2. **Algo que no entiendes.** Si un texto no se entiende, el texto está mal, no
tú. La aplicación explica o no sirve para nada.
3. **Algo que se rompe**: página en blanco, número imposible, carga infinita.
Dime qué estabas haciendo.
4. **Algo que echas en falta.**
No hace falta que sea formal. Un "esto me ha parecido raro" ya me vale.
---
## Contexto, por si te interesa
Es un archivo HTML que corre entero en tu navegador. **Cero dependencias
externas**: no hay Google, ni CDN, ni telemetría, ni un solo recurso que se
descargue de internet. Se puede comprobar buscando `https://` en el código —
no hay ninguno.
Todo lo que ves sale de un nodo Bitcoin propio, no de un servicio de terceros.
Esa es la idea entera: auditar tu privacidad sin regalársela a nadie en el
proceso.
El código está en `git.bitcointxoko.org/pikaro/txoko-dashboard`.
+67 -92
View File
@@ -22,51 +22,17 @@ Tan importante como saber qué hace es saber qué no hace:
- **No envía datos a ningún servidor externo** — ni analíticas, ni telemetría, ni consultas a APIs de terceros - **No envía datos a ningún servidor externo** — ni analíticas, ni telemetría, ni consultas a APIs de terceros
- **No toca claves privadas** — el watch-only usa solo tu clave pública (xpub); no puede gastar fondos ni conoce tu semilla - **No toca claves privadas** — el watch-only usa solo tu clave pública (xpub); no puede gastar fondos ni conoce tu semilla
- **No llega nunca a una identidad** — se detiene en "hot wallet de [servicio]" o "custodio no identificado", y lo dice explícitamente. Ver la sección siguiente - **No identifica a otras personas** — es una herramienta de auto-auditoría, no de vigilancia
- **No da consejos de inversión** — analiza privacidad on-chain, nada más
- **No afirma más de lo que sabe** — distingue siempre entre CERTEZA, PROBABLE e POSIBLE - **No afirma más de lo que sabe** — distingue siempre entre CERTEZA, PROBABLE e POSIBLE
- **No tiene número de score exacto** — muestra bandas (ALTA/MEDIA/BAJA); la precisión numérica sería falsa - **No tiene número de score exacto** — muestra bandas (ALTA/MEDIA/BAJA); la precisión numérica sería falsa
- **No muestra el contenido de OP_RETURN** — solo informa de su presencia y tamaño - **No muestra el contenido de OP_RETURN** — solo informa de su presencia y tamaño
--- ---
## Sí, esto es chain analysis
Conviene decirlo claro, porque el código está a la vista y cualquiera puede
comprobarlo: el peritaje forense aplica **las mismas técnicas que las empresas
de vigilancia de cadena**. Rastreo hacia adelante salto a salto, clustering por
CIOH, perfilado de direcciones, huella de software, un índice de direcciones de
exchanges. Es el arsenal de Chainalysis, técnica por técnica.
Fingir lo contrario sería insultar tu inteligencia. Lo que cambia no es el
método, es todo lo demás:
- **Quién lo ejecuta.** Corre en tu nodo, con tus datos, bajo tu control. No
hay un tercero que acumule los resultados ni los venda a quien pague.
- **Sobre quién.** Tu propia actividad, o el rastro de unas monedas que te
quitaron a ti. No es un servicio que perfile a desconocidos por encargo.
- **Dónde se detiene.** En un servicio, nunca en una persona. Y cuando una
heurística no llega, el informe dice que no llega en vez de rellenar el hueco.
- **Quién se queda el informe.** Tú. No sale de tu red y decides qué hacer con
él — incluido no hacer nada.
Y hay una razón más, la que de verdad justifica que esto exista:
> **La misma herramienta que sigue el rastro de un ladrón demuestra lo fácil
> que es seguir el tuyo.**
No hay forma más honesta de entender qué puede deducir un observador de tus
monedas que ejecutar sus heurísticas sobre ellas y leer el resultado. Un
adversario con estas capacidades ya existe, tenga o no tú una herramienta para
verlo. Conocer el arma no es adoptarla: es dejar de estar ciego ante ella.
Si esto te parece una línea demasiado fina, es una objeción razonable. El
código está entero en un archivo para que puedas juzgarlo tú.
---
## Cómo verificar que nada sale de tu red ## Cómo verificar que nada sale de tu red
Todo el código de análisis vive en un único archivo HTML, sin minificar y sin proceso de compilación. Puedes auditarlo tú mismo: El código es un único archivo HTML autocontenido. Puedes auditarlo tú mismo:
```bash ```bash
# Buscar cualquier llamada a dominios externos # Buscar cualquier llamada a dominios externos
@@ -84,16 +50,8 @@ Consecuencia práctica: **el dashboard funciona sin conexión a internet**. Solo
## Funcionalidades ## Funcionalidades
**Auditoría de privacidad** **Auditoría de privacidad**
- Análisis de transacción con más de 25 heurísticas ponderadas - Análisis de transacción con más de 20 heurísticas ponderadas
- La nota mide **solo lo que dependía de ti**. Lo que te expone por decisión de
un tercero (recibir de un lote, recibir polvo) se informa aparte, porque una
nota que no puedes mejorar no evalúa nada
- Detección del output de cambio por señales combinadas, que se callan cuando
no coinciden en lugar de señalar la salida equivocada
- Detección de CoinJoin: Whirlpool (denominaciones fijas), WabiSabi (estructura de mezcla), CoinJoin genérico - Detección de CoinJoin: Whirlpool (denominaciones fijas), WabiSabi (estructura de mezcla), CoinJoin genérico
- Aviso cuando el polvo recibido **se gasta** junto a otras monedas: ahí el
ataque de dusting deja de ser hipótesis
- Consolidación y vinculación CIOH: cuántas direcciones tuyas quedan atadas
- Detección de OP_RETURN: presencia y tamaño, sin mostrar contenido - Detección de OP_RETURN: presencia y tamaño, sin mostrar contenido
- Detección de tipo legacy (P2PKH/P2SH) vs SegWit/Taproot - Detección de tipo legacy (P2PKH/P2SH) vs SegWit/Taproot
- Wallet fingerprinting: Bitcoin Core, Sparrow, BlueWallet, Taproot nativo y otros - Wallet fingerprinting: Bitcoin Core, Sparrow, BlueWallet, Taproot nativo y otros
@@ -104,17 +62,6 @@ Consecuencia práctica: **el dashboard funciona sin conexión a internet**. Solo
- Bandas de privacidad con distinción explícita CERTEZA / PROBABLE / POSIBLE - Bandas de privacidad con distinción explícita CERTEZA / PROBABLE / POSIBLE
- Exportación del informe en JSON y Markdown - Exportación del informe en JSON y Markdown
**Auditoría de PSBT — antes de firmar**
La única función de la app que llega a tiempo: el resto te cuenta lo que ya
pasó, esta te avisa cuando todavía puedes cambiar la transacción.
- Parser completo de BIP174 escrito desde cero, validado contra los cinco vectores inválidos del propio estándar
- **Todo offline**: una PSBT ya lleva dentro los importes y scripts de sus entradas, así que no se consulta el nodo. Funciona con el nodo sincronizando
- Avisa de las **claves públicas maestras incrustadas**: las PSBT suelen llevarlas dentro y están hechas para compartirse — quien reciba el archivo puede ver todas tus direcciones, tu saldo y tu historial. No puede gastar, pero lo ve todo
- Detecta cambio identificable por tipo de dirección, importes redondos que delatan cuál es el pago, envíos a tu propia cartera, carteras multifirma y PSBTs incompletas
- Acepta base64, hexadecimal o el archivo `.psbt` que exporta Sparrow
**Peritaje forense** **Peritaje forense**
- Rastreo de fondos robados o perdidos hacia adelante, salto a salto, desde una transacción de origen hasta un punto de parada natural (custodio identificado, dilución, CoinJoin, o fondos aún sin gastar) - Rastreo de fondos robados o perdidos hacia adelante, salto a salto, desde una transacción de origen hasta un punto de parada natural (custodio identificado, dilución, CoinJoin, o fondos aún sin gastar)
- A demanda: tú decides cuándo se explora cada salto con el botón "Seguir el rastro" — nada corre sin que lo pidas, igual que el rastro de procedencia hacia atrás. Puedes generar el informe con lo explorado hasta ese momento, sin terminar el rastreo entero - A demanda: tú decides cuándo se explora cada salto con el botón "Seguir el rastro" — nada corre sin que lo pidas, igual que el rastro de procedencia hacia atrás. Puedes generar el informe con lo explorado hasta ese momento, sin terminar el rastreo entero
@@ -142,7 +89,7 @@ pasó, esta te avisa cuando todavía puedes cambiar la transacción.
- Conversor sat/BTC/fiat - Conversor sat/BTC/fiat
- Validador de dirección - Validador de dirección
- Detector OP_RETURN - Detector OP_RETURN
- Decodificador de transacción raw - Decodificador PSBT y transacción raw
--- ---
@@ -170,29 +117,66 @@ Probado sobre Ubuntu Server 24.04 con HP EliteDesk (i5, 32GB RAM, 2TB NVMe).
## Instalación ## Instalación
**La guía completa está en [SETUP.md](SETUP.md)** — paso a paso, con una ### 1. Clonar el repositorio
comprobación después de cada uno para que sepas si vas bien sin tener que
descubrirlo al final.
Resumen de lo que implica: ```bash
git clone https://git.bitcointxoko.org/pikaro/txoko-dashboard.git
cd txoko-dashboard
```
1. Clonar el repositorio. ### 2. Copiar el dashboard
2. Copiar `dashboard.html` a un **directorio** propio servido por nginx.
3. Descargar las librerías (React, Babel y las fuentes) a `vendor/`, junto al
dashboard, y verificarlas por hash. No están en el repo: son de terceros y
ocupan ~3 MB.
4. Opcionalmente, instalar el monitor del sistema como servicio systemd.
5. Configurar nginx: el dashboard, un proxy a la API de Mempool y otro al
monitor.
Un aviso que ahorra disgustos: en nginx, el `alias` del dashboard debe apuntar ```bash
al **directorio** (con barra final), no al archivo `dashboard.html`. Si apunta cp dashboard.html /var/www/txoko/dashboard.html
al archivo, el navegador no encuentra `vendor/` y verás una página en blanco # o donde lo sirvas con nginx
sin ningún error visible. Es el fallo más común, y en SETUP.md hay una ```
comprobación concreta para descartarlo.
Cuando termines, abre `http://TU-IP:4080/dashboard/`**con la barra final** —, ### 3. Copiar el backend de métricas
pulsa CONFIG e introduce la URL de tu Mempool.
```bash
cp system-metrics.js /home/armg/txoko/system-metrics.js
```
Editar `system-metrics.js` y añadir tus credenciales RPC de Bitcoin Core
(el archivo del repo usa placeholders).
### 4. Configurar el servicio systemd
```bash
sudo cp txoko-metrics.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable txoko-metrics
sudo systemctl start txoko-metrics
```
### 5. Configurar nginx
Añadir a tu configuración de nginx:
```nginx
# Dashboard
location /dashboard {
alias /var/www/txoko/dashboard.html;
}
# API Mempool (ajusta el puerto según tu instalación)
location /api/ {
proxy_pass http://localhost:8999;
}
# Métricas del sistema
location /system/ {
proxy_pass http://127.0.0.1:4082;
}
```
### 6. Abrir en el navegador
```
http://TU-IP-TAILSCALE:4080/dashboard
```
Introduce la URL de tu Mempool en el modal de configuración y empieza a analizar.
--- ---
@@ -200,25 +184,16 @@ pulsa CONFIG e introduce la URL de tu Mempool.
``` ```
txoko-dashboard/ txoko-dashboard/
├── dashboard.html # La aplicación entera (HTML + CSS + JS en un archivo) ├── dashboard.html # Frontend completo (HTML + CSS + JS en un solo archivo)
├── system-metrics.js # Monitor del nodo (Node.js) — opcional ├── system-metrics.js # Backend de métricas del nodo (Node.js)
├── txoko-metrics.service # Servicio systemd para el monitor ├── txoko-metrics.service # Servicio systemd
├── instalar-fuentes.sh # Descarga IBM Plex al nodo y genera su CSS
├── tests/ # Pruebas de la criptografía contra vectores oficiales
├── SETUP.md # Guía de instalación
├── FIXTURES.md # Transacciones reales para probar las heurísticas
├── CHANGELOG.md
├── README.md ├── README.md
├── SETUP.md
├── CHANGELOG.md
└── LICENSE └── LICENSE
``` ```
Tras la instalación, junto al `dashboard.html` queda además un directorio El frontend es un único archivo HTML autocontenido. Sin bundler, sin npm, sin proceso de build. Babel transpila el JSX en el navegador. Puedes auditarlo todo abriendo el archivo.
`vendor/` con las librerías y las fuentes. No está en el repositorio: se
descarga y se verifica durante la instalación (ver [SETUP.md](SETUP.md)).
El frontend es un único archivo HTML. Sin bundler, sin npm, sin proceso de build: Babel transpila el JSX en el navegador, así que lo que lees en el archivo es exactamente lo que se ejecuta — no hay una versión compilada que auditar por separado.
Las librerías (React, Babel, las fuentes) viven aparte, en `vendor/`, servidas desde tu propio nodo y verificadas por hash durante la instalación. Se dejan fuera del repo a propósito: son código de terceros, y mezclarlas con el tuyo haría más difícil auditar lo que de verdad importa.
--- ---
+223 -229
View File
@@ -1,93 +1,187 @@
# Instalación de Txoko Node Dashboard # Cómo subir Txoko a Gitea — paso a paso
Guía completa, de principio a fin. Sigue los pasos en orden y comprueba cada Instrucciones exactas. Copiar y pegar en la terminal del Mac.
uno antes de pasar al siguiente: cada comprobación te dice si vas bien, en vez No hace falta entender git para seguir esto.
de dejarte descubrirlo al final con una pantalla en blanco.
Todo se hace **en el nodo**, salvo donde se indique lo contrario.
--- ---
## Antes de empezar ## PASO 1 — Crear el repo en Gitea (una sola vez)
Txoko no habla con la red Bitcoin directamente: se apoya en cosas que ya 1. Abre tu instancia de Gitea en el navegador
tienes montadas. Necesitas: 2. Clic en el **+** (arriba a la derecha) → "New Repository"
3. Rellena:
- **Repository Name:** `txoko-dashboard`
- **Description:** `Suite de auditoría de privacidad Bitcoin para nodos propios`
- **Visibility:** Private (o Public si quieres compartirlo con la comunidad)
- **Initialize repository:** NO marcar (ya traemos nuestros archivos)
4. Clic en "Create Repository"
5. Gitea te muestra una página con instrucciones — copia la URL del repo,
será algo como: `https://gitea.tu-comunidad/tu-usuario/txoko-dashboard.git`
| Requisito | Para qué | ---
|---|---|
| **Bitcoin Core** con `txindex=1` | consultar cualquier transacción, no solo las tuyas |
| **Mempool self-hosted** ([mempool/mempool](https://github.com/mempool/mempool)) | la API que Txoko consulta |
| **Fulcrum** ([cculianu/Fulcrum](https://github.com/cculianu/Fulcrum)) | índice de direcciones; Mempool lo usa por debajo |
| **nginx** | sirve el dashboard y hace de proxy hacia lo anterior |
| **Node.js ≥ 18** | solo para el monitor del sistema (paso 4) |
Si Mempool self-hosted ya te funciona en el navegador, tienes todo lo demás. ## PASO 2 — Configurar git en el Mac (una sola vez, si no lo tienes)
Comprueba que la API responde antes de seguir. Ajusta el puerto al tuyo:
```bash ```bash
curl -s http://127.0.0.1:8999/api/v1/fees/recommended git config --global user.name "tu nombre"
git config --global user.email "tu@email.com"
``` ```
Debe devolver un JSON con comisiones. Si no responde, arregla eso primero: Comprueba que git está instalado:
Txoko no puede funcionar sin ello. ```bash
git --version
> **Nunca expongas estos puertos a internet.** Txoko está pensado para ```
> accederse por Tailscale, VPN o red local. Si no está: `brew install git`
--- ---
## Paso 1 — Descargar los archivos ## PASO 3 — Crear el repo local y primer commit (una sola vez)
```bash ```bash
git clone https://git.bitcointxoko.org/pikaro/txoko-dashboard.git # Crear carpeta del proyecto en el Mac
cd txoko-dashboard mkdir ~/txoko-dashboard
cd ~/txoko-dashboard
# Copiar los archivos del repo que te he preparado
# (descarga los archivos de esta conversación y cópialos aquí)
# Inicializar git
git init
git branch -M main
# Añadir todos los archivos
git add .
# Primer commit — el historial empieza aquí
git commit -m "inicio: dashboard de privacidad Bitcoin con análisis on-chain"
``` ```
--- ---
## Paso 2 — Elegir dónde vivirá el dashboard ## PASO 4 — Conectar con Gitea y subir (una sola vez)
Esta decisión condiciona el resto, así que conviene hacerla a conciencia.
Necesitas un **directorio** propio para Txoko. No vale colocar el
`dashboard.html` suelto en cualquier sitio: la aplicación carga sus librerías
desde un subdirectorio `vendor/` **junto al propio archivo**, así que ambos
tienen que convivir.
```bash ```bash
sudo mkdir -p /var/www/txoko # Sustituye la URL por la de tu repo de Gitea
sudo cp dashboard.html /var/www/txoko/ git remote add origin https://gitea.tu-comunidad/tu-usuario/txoko-dashboard.git
# Subir
git push -u origin main
``` ```
Puedes usar otra ruta; solo recuerda cuál es, porque aparece en los pasos 3 y 5. Gitea te pedirá usuario y contraseña la primera vez.
Si quieres evitar introducirlos cada vez, crea un token en
Gitea → Settings → Applications → "Generate Token" y úsalo como contraseña.
--- ---
## Paso 3 — Instalar las librerías del frontend ## PASO 5 — Flujo de trabajo normal (cada vez que yo te dé un archivo nuevo)
Txoko usa React y Babel, y la tipografía IBM Plex. **Se sirven desde tu propio
nodo, nunca desde un CDN externo.**
El motivo es de privacidad, no de comodidad: un CDN no vería qué transacciones
analizas, pero sí recibiría tu IP y la hora cada vez que abres el dashboard —
sabría que usas Txoko, cuándo y desde dónde. Justo el metadato que esta
herramienta enseña a proteger. Sirviéndolas en local, *"ninguna consulta sale
de tu red"* es literal, y el dashboard funciona sin conexión a internet.
No están en el repositorio porque son código de terceros y ocupan unos 3 MB.
### React y Babel
```bash ```bash
sudo mkdir -p /var/www/txoko/vendor cd ~/txoko-dashboard
cd /var/www/txoko/vendor
# Copiar el archivo actualizado descargado de esta conversación
cp ~/Downloads/txoko-dashboard.html ./dashboard.html
# Ver qué cambió (opcional pero útil)
git diff dashboard.html
# Registrar el cambio con un mensaje descriptivo
git add dashboard.html
git commit -m "fix: descripción breve de lo que se arregló"
# Subir a Gitea
git push
# Copiar al nodo (igual que antes)
scp dashboard.html armg@100.116.19.86:/home/armg/txoko/dashboard.html
```
---
## Mensajes de commit — ejemplos
El mensaje va después de `-m` y describe QUÉ cambiaste.
No tiene que ser perfecto, solo útil para ti en el futuro.
```
"fix: timeout en consultas al nodo"
"feat: score por bandas ALTA/MEDIA/BAJA"
"fix: responsive pestaña Mempool en móvil"
"fix: detección Whirlpool con tolerancia 2%"
"feat: fingerprinting wallet completo"
"fix: umbral dust por tipo de salida"
"docs: actualizar README con nuevas funcionalidades"
```
Convención (opcional pero ordenada):
- `fix:` — corrige algo que no funcionaba bien
- `feat:` — añade algo nuevo
- `docs:` — solo documentación
---
## Si algo sale mal
**Subiste algo que no querías:**
```bash
git revert HEAD
git push
```
**Quieres volver a una versión anterior:**
```bash
git log --oneline # ver el historial
git checkout HASH_DEL_COMMIT -- dashboard.html # recuperar ese archivo
```
**Ver el historial:**
```bash
git log --oneline
```
---
## Resumen del flujo
```
Claude te da dashboard.html
cp ~/Downloads/dashboard.html ./dashboard.html
git add . && git commit -m "descripción"
git push
scp dashboard.html armg@100.116.19.86:/home/armg/txoko/
```
Cuatro comandos después de la descarga. Siempre los mismos.
---
## Librerías del frontend (obligatorio)
El dashboard usa React y Babel. **Se sirven desde tu propio nodo, no desde un
CDN.** El motivo es de privacidad, no de comodidad: un CDN externo no ve qué
transacciones analizas, pero sí ve que usas Txoko, cuándo y desde qué IP
pública — exactamente la clase de metadato que esta herramienta enseña a
proteger. Sirviéndolas en local, *"ninguna consulta sale de tu red"* pasa a ser
literal, y el dashboard funciona sin conexión a internet.
Estas librerías **no están en el repo** (son de terceros y pesan ~3 MB). Se
descargan una vez, en el nodo:
```bash
sudo mkdir -p /usr/share/nginx/html/vendor
cd /usr/share/nginx/html/vendor
sudo curl -sSLO https://unpkg.com/react@18.3.1/umd/react.production.min.js sudo curl -sSLO https://unpkg.com/react@18.3.1/umd/react.production.min.js
sudo curl -sSLO https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js sudo curl -sSLO https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js
sudo curl -sSL -o babel.min.js https://unpkg.com/@babel/standalone@7.23.10/babel.min.js sudo curl -sSL -o babel.min.js https://unpkg.com/@babel/standalone@7.23.10/babel.min.js
``` ```
**Comprueba que has recibido lo que esperabas.** No te fíes: verifícalo. ### Verifica lo que has descargado
No te fíes: comprueba que los archivos son los que deben ser.
```bash ```bash
sha256sum *.js sha256sum *.js
@@ -101,218 +195,118 @@ Debe dar exactamente esto:
d949f1c3687aedadcedac85261865f29b17cd273997e7f6b2bfc53b2f9d4c4dd react.production.min.js d949f1c3687aedadcedac85261865f29b17cd273997e7f6b2bfc53b2f9d4c4dd react.production.min.js
``` ```
Si alguno no coincide, **para aquí**: has recibido algo distinto de lo esperado. Si algún hash no coincide, **no uses esos archivos**: significa que has
recibido algo distinto a lo esperado.
Las versiones están fijadas a propósito. Usar un rango como `react@18` dejaría Las versiones están fijadas a propósito (`18.3.1`, `7.23.10`). Usar un rango
que el servidor decidiera qué versión te entrega, y cambiaría con el tiempo sin como `react@18` dejaría que el servidor decidiera qué versión te entrega, y
que te enteres. cambiaría con el tiempo sin que te enteres.
### Las fuentes ### Dónde deben quedar los archivos
```bash El dashboard las busca en `vendor/` con **ruta relativa**, es decir, en un
sudo /ruta/al/repo/instalar-fuentes.sh /var/www/txoko/vendor/fonts subdirectorio junto al propio `dashboard.html`. Así funciona tanto si sirves el
``` dashboard en la raíz como bajo un prefijo, sin tocar nginx.
El script descarga IBM Plex (licencia libre OFL), comprueba cada archivo y Con la configuración típica de Mempool self-hosted:
aborta si algo falla. Son unos 350 KB.
---
## Paso 4 — El monitor del sistema (opcional)
Alimenta la pestaña NODO: CPU, RAM, disco, estado de Bitcoin Core y logs. Sin
esto el resto de la aplicación funciona igual, solo que esa pestaña queda vacía.
```bash
mkdir -p ~/txoko
cp system-metrics.js ~/txoko/
```
Edita `~/txoko/system-metrics.js` y sustituye los dos marcadores por tus
credenciales RPC de Bitcoin Core (las de tu `bitcoin.conf`):
```js
const RPC_USER = "TU_RPC_USER";
const RPC_PASS = "TU_RPC_PASSWORD";
```
Instala el servicio. Antes revisa el `.service`, porque trae rutas y usuario
que probablemente tengas que ajustar a los tuyos:
```bash
nano txoko-metrics.service
sudo cp txoko-metrics.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now txoko-metrics
```
**Comprueba que arrancó:**
```bash
systemctl is-active txoko-metrics # debe decir: active
curl -s http://127.0.0.1:4082/system/info | head -c 200
```
> Si `systemctl status` muestra un punto que no es verde pero pone
> `active (running)`, está bien. Lo que importa es el texto.
---
## Paso 5 — Configurar nginx
Aquí es donde más gente se atasca, así que léelo con calma.
Añade esto a tu configuración de nginx (si usas Mempool self-hosted, dentro del
mismo bloque `server`):
```nginx ```nginx
# Dashboard — OJO: alias a un DIRECTORIO, con barra final location /dashboard {
location /dashboard/ { alias /usr/share/nginx/html/;
alias /var/www/txoko/;
index dashboard.html;
try_files $uri $uri/ /dashboard/dashboard.html;
}
# API de Mempool — ajusta el puerto al de tu instalación
location /api/ {
proxy_pass http://127.0.0.1:8999;
}
# Monitor del sistema (solo si hiciste el paso 4)
location /system/ {
proxy_pass http://127.0.0.1:4082;
} }
``` ```
**El detalle que rompe la instalación:** el `alias` tiene que apuntar al …el `dashboard.html` vive en `/usr/share/nginx/html/` y las librerías deben ir
**directorio**, con barra final, no al archivo `dashboard.html`. Si apunta al en `/usr/share/nginx/html/vendor/` — que es justo donde las deja el comando de
archivo, el navegador no encontrará `vendor/` y verás una **página en blanco** arriba.
sin ningún mensaje de error. Es el fallo más común de esta instalación.
Recarga nginx: **Importante:** abre el dashboard **con la barra final** (`.../dashboard/`, no
`.../dashboard`). Sin ella, el navegador resuelve las rutas relativas un nivel
por encima y no encuentra las librerías.
Para comprobar que nginx las sirve bien, lo que importa no es solo el código
200 sino el tipo de contenido:
```bash ```bash
sudo nginx -t && sudo systemctl reload nginx curl -sk -o /dev/null -w '%{http_code} %{content_type}\n' \
https://localhost:4081/dashboard/vendor/react.production.min.js
``` ```
Debe responder `200 application/javascript`. Si devuelve `200 text/html`,
nginx está entregando otra cosa (por ejemplo el index de Mempool) y las rutas
no son las correctas.
--- ---
## Paso 6 — Comprobar que funciona ## HTTPS para watch-only (opcional)
Antes de abrir el navegador, verifica desde el propio nodo. Ajusta el puerto:
```bash
# El dashboard llega
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:4080/dashboard/
# Y las librerías TAMBIÉN — esto es lo que suele fallar
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' \
http://127.0.0.1:4080/dashboard/vendor/react.production.min.js
```
La segunda debe responder **`200 application/javascript`**.
Si devuelve `200 text/html` la instalación **no** está bien: nginx te está
sirviendo otra cosa (normalmente el index de Mempool) en lugar del archivo.
Revisa el `alias` del paso 5. Fíjate en que aquí no basta con mirar el código
200 — hay que mirar el tipo de contenido.
Ahora sí, abre en el navegador:
```
http://TU-IP:4080/dashboard/
```
**Con la barra final.** Sin ella, el navegador busca las librerías un nivel por
encima y no las encuentra.
Pulsa **CONFIG** e introduce la URL de tu Mempool (por ejemplo
`http://TU-IP:4080`). Dale a PROBAR: si dice "Conexión exitosa", ya está.
### Si ves una página en blanco
Abre la consola del navegador (F12). Si aparece `SyntaxError: Unexpected
token '<'`, es exactamente el problema del `alias` del paso 5: nginx está
devolviendo HTML donde debería devolver JavaScript.
---
## HTTPS — necesario para el watch-only (opcional)
La función watch-only (derivar tus direcciones desde el xpub) usa la Web Crypto La función watch-only (derivar tus direcciones desde el xpub) usa la Web Crypto
API del navegador, que **solo funciona sobre HTTPS**. Por HTTP el resto de la API del navegador, que **solo funciona sobre HTTPS**. Si sirves el dashboard por
aplicación funciona igual; solo esa función queda deshabilitada. Las etiquetas HTTP, watch-only no estará disponible — el resto de la app funciona igual. Las
BIP-329 no necesitan HTTPS. etiquetas BIP-329 no necesitan HTTPS.
Si accedes por Tailscale o red local no tendrás un certificado válido, así que Si quieres usar watch-only y tu nodo va por HTTP (por ejemplo, acceso por
toca generar uno autofirmado. Tailscale sin certificado), puedes generar un certificado autofirmado. Es lo que
sigue. Todo se hace en el nodo.
### 1. Generar el certificado ### 1. Generar el certificado autofirmado
Sustituye la IP por la de tu nodo: Sustituye la IP por la de tu nodo (Tailscale, local, etc.):
```bash ```bash
sudo openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \ sudo openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
-keyout /etc/ssl/private/txoko.key \ -keyout /etc/ssl/private/txoko.key \
-out /etc/ssl/certs/txoko.crt \ -out /etc/ssl/certs/txoko.crt \
-subj "/CN=100.64.0.5" \ -subj "/CN=100.116.19.86" \
-addext "subjectAltName=IP:100.64.0.5" -addext "subjectAltName=IP:100.116.19.86"
``` ```
El `subjectAltName` no es opcional: sin él los navegadores modernos rechazan el ### 2. Configurar nginx para servir HTTPS
certificado aunque el `CN` sea correcto.
### 2. Servirlo en nginx Añade un bloque `server` que escuche en un puerto con SSL (por ejemplo 4081),
apuntando al certificado recién creado e incluyendo la misma configuración que
Duplica tu bloque `server` en otro puerto (4081 en este ejemplo) añadiendo: tu servidor HTTP:
```nginx ```nginx
listen 4081 ssl; server {
ssl_certificate /etc/ssl/certs/txoko.crt; listen 4081 ssl;
ssl_certificate_key /etc/ssl/private/txoko.key; listen [::]:4081 ssl;
server_name _;
ssl_certificate /etc/ssl/certs/txoko.crt;
ssl_certificate_key /etc/ssl/private/txoko.key;
ssl_session_timeout 4h;
ssl_protocols TLSv1.3;
ssl_prefer_server_ciphers on;
# Incluye aquí tu misma config (location /dashboard, /api/, etc.)
# Si ya tienes esos location en un snippet, basta con incluirlo:
# include /etc/nginx/snippets/tu-config.conf;
}
``` ```
Los `location` son los mismos del paso 5. Recarga con `sudo nginx -t && Si el bloque `location /dashboard` ya viene de un snippet que incluyes, **no lo
sudo systemctl reload nginx`. dupliques** dentro del server SSL: nginx dará error `duplicate location`.
### 3. Aceptar el certificado la primera vez Comprueba y recarga:
Al entrar en `https://TU-IP:4081/dashboard/` el navegador avisará de que el
certificado no es de confianza. Es lo esperado: lo has firmado tú. Acepta la
excepción una vez.
Que sea autofirmado no lo hace menos seguro **para este uso**: cifra igual, y
como el certificado lo has generado tú en tu propia máquina, nadie externo
puede suplantarlo. Lo que no tienes es el respaldo de una autoridad
certificadora, que aquí no aporta nada porque el servidor y el cliente son
tuyos.
---
## Actualizar a una versión nueva
```bash ```bash
git pull sudo nginx -t && sudo systemctl reload nginx
sudo cp dashboard.html /var/www/txoko/
``` ```
Y recarga el navegador con **Ctrl+Shift+R** (o Cmd+Shift+R en Mac) para saltarte ### 3. Confiar el certificado la primera vez
la caché.
Las librerías de `vendor/` no hace falta volver a descargarlas salvo que el Abre `https://TU-IP:4081/dashboard/` en el navegador. Como el certificado es
CHANGELOG diga lo contrario. Si además cambia `system-metrics.js`, cópialo de autofirmado, el navegador avisará de que la conexión no es privada. Es esperado
nuevo (conservando tus credenciales) y reinicia con —lo creaste tú— y es seguro en tu propia red:
`sudo systemctl restart txoko-metrics`.
--- - **Safari:** clic en "visitar este sitio web" (abajo del aviso) y confirma
- **Chrome/Brave:** "Configuración avanzada" → "Acceder a TU-IP (no seguro)"
## Problemas frecuentes A partir de ahí el navegador recuerda la excepción y la Web Crypto API queda
disponible, así que watch-only funcionará.
> Nota: un certificado autofirmado es perfectamente válido para uso personal en
> tu propia red. El aviso del navegador existe porque no hay una autoridad
> certificadora de por medio, no porque la conexión sea insegura — el tráfico va
> cifrado igual.
| Síntoma | Causa habitual |
|---|---|
| Página en blanco, consola con `Unexpected token '<'` | El `alias` de nginx apunta al archivo y no al directorio (paso 5) |
| Página en blanco al entrar sin barra final | Entra en `/dashboard/`, con barra |
| Aparece "DEMO · SIN NODO" | Falta configurar la URL del Mempool en CONFIG |
| La pestaña NODO está vacía | El servicio `txoko-metrics` no corre, o falta el `location /system/` |
| Watch-only no aparece | Estás entrando por HTTP; requiere HTTPS |
| La app dice que el nodo no responde | Comprueba la API con el `curl` de "Antes de empezar" |
+290 -1428
View File
File diff suppressed because it is too large Load Diff
+2 -13
View File
@@ -13,14 +13,8 @@
# Fuente: paquetes npm oficiales de IBM servidos por unpkg — el mismo origen # Fuente: paquetes npm oficiales de IBM servidos por unpkg — el mismo origen
# del que ya se descargan React y Babel durante la instalación. # del que ya se descargan React y Babel durante la instalación.
# #
# Funciona igual en Linux (el nodo) y en macOS, por si quieres tener una copia # Ejecutar EN EL NODO:
# del dashboard en tu propio ordenador.
#
# En el nodo:
# chmod +x instalar-fuentes.sh && sudo ./instalar-fuentes.sh # chmod +x instalar-fuentes.sh && sudo ./instalar-fuentes.sh
#
# En otra carpeta cualquiera (sin sudo si es tuya):
# ./instalar-fuentes.sh ~/donde-sea/vendor/fonts
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
set -uo pipefail set -uo pipefail
@@ -49,12 +43,7 @@ for url in $URLS; do
printf " %-32s" "$archivo" printf " %-32s" "$archivo"
# -w escribe el código HTTP para poder diagnosticar si algo va mal # -w escribe el código HTTP para poder diagnosticar si algo va mal
codigo=$(curl -sSL -o "$archivo" -w "%{http_code}" "$url" 2>/dev/null) codigo=$(curl -sSL -o "$archivo" -w "%{http_code}" "$url" 2>/dev/null)
# `wc -c` en vez de `stat`: stat -c%s es sintaxis GNU y macOS trae la versión tam=$(stat -c%s "$archivo" 2>/dev/null || echo 0)
# BSD, que usa -f%z. En un Mac el stat fallaba, caía en el `|| echo 0` y el
# script daba por fallida una descarga que había ido bien — "FALLÓ (HTTP 200,
# 0 bytes)", que se lee como un problema de red y era una incompatibilidad de
# shell. `wc -c` se comporta igual en los dos sistemas.
if [ -f "$archivo" ]; then tam=$(wc -c < "$archivo" | tr -d ' '); else tam=0; fi
if [ "$codigo" = "200" ] && [ "$tam" -gt 10000 ]; then if [ "$codigo" = "200" ] && [ "$tam" -gt 10000 ]; then
echo "ok ($((tam/1024)) KB)" echo "ok ($((tam/1024)) KB)"
else else
+5 -73
View File
@@ -168,33 +168,10 @@ async function getDisk() {
} }
// ── Procesos destacados (nombre limpio del binario) ──────────────────────── // ── Procesos destacados (nombre limpio del binario) ────────────────────────
// Tiempo de CPU consumido por un proceso, en ticks, desde /proc/PID/stat.
// Campos 14 (utime) y 15 (stime). El nombre del proceso va entre paréntesis y
// puede contener espacios, así que se corta por el ÚLTIMO ')' antes de partir.
function readProcCpuTicks(pid) {
try {
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
const resto = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
// resto[0] es el campo 3 (estado), así que utime=campo14 → resto[11]
const utime = parseInt(resto[11], 10);
const stime = parseInt(resto[12], 10);
if (Number.isNaN(utime) || Number.isNaN(stime)) return null;
return utime + stime;
} catch { return null; }
}
// OJO con la columna %CPU de `ps`: NO es el consumo actual, sino la media del
// proceso desde que arrancó (tiempo de CPU / tiempo de vida). Un proceso que
// trabajó mucho al principio y ahora está ocioso sigue mostrando un número
// alto días después, y se lee como si estuviera saturando la máquina.
// Aquí se mide de verdad: dos lecturas de /proc separadas 500 ms, el mismo
// método que ya usa getCpuUsage para el total del sistema. Las dos esperas
// corren en paralelo (van dentro del mismo Promise.all), así que no cuesta
// tiempo extra.
async function getProcesses() { async function getProcesses() {
const result = await sh("ps aux --no-headers --sort=-%mem | head -8"); const result = await sh("ps aux --no-headers --sort=-%mem | head -8");
if (!result) return []; if (!result) return [];
const filas = result.split("\n").map(line => { return result.split("\n").map(line => {
const parts = line.trim().split(/\s+/); const parts = line.trim().split(/\s+/);
// Nombre limpio: basename del ejecutable; si es un intérprete (node, // Nombre limpio: basename del ejecutable; si es un intérprete (node,
// python...), añade el basename del script que ejecuta. // python...), añade el basename del script que ejecuta.
@@ -213,33 +190,12 @@ async function getProcesses() {
} }
command = command.slice(0, 24); command = command.slice(0, 24);
return { return {
pid: parts[1],
user: parts[0], user: parts[0],
cpuMedia: parseFloat(parts[2]), // media desde el arranque (lo que da ps) cpu: parseFloat(parts[2]),
mem: parseFloat(parts[3]), mem: parseFloat(parts[3]),
command, command,
}; };
}).filter(p => p.mem > 0.5); }).filter(p => p.mem > 0.5);
const ticks1 = filas.map(p => readProcCpuTicks(p.pid));
await sleep(500);
const ticks2 = filas.map(p => readProcCpuTicks(p.pid));
const USER_HZ = 100; // estándar en Linux
const VENTANA_S = 0.5;
const nucleos = os.cpus().length || 1;
return filas.map((p, i) => {
let cpu = null, cpuSistema = null;
if (ticks1[i] !== null && ticks2[i] !== null) {
const seg = (ticks2[i] - ticks1[i]) / USER_HZ;
// % de UN núcleo (criterio de top/ps: puede pasar de 100 si va en varios)
cpu = Math.max(0, Math.round((seg / VENTANA_S) * 1000) / 10);
// % del total de la máquina, que es lo que suele querer saberse
cpuSistema = Math.round((cpu / nucleos) * 10) / 10;
}
return { user: p.user, command: p.command, mem: p.mem, cpu, cpuSistema, cpuMedia: p.cpuMedia };
});
} }
// ── Load / Uptime ─────────────────────────────────────────────────────────── // ── Load / Uptime ───────────────────────────────────────────────────────────
@@ -290,36 +246,12 @@ async function getBitcoinInfo() {
// connections_in/out existen desde Core 0.21; fallback a getpeerinfo si no // connections_in/out existen desde Core 0.21; fallback a getpeerinfo si no
let inbound = netinfo.connections_in; let inbound = netinfo.connections_in;
let outbound = netinfo.connections_out; let outbound = netinfo.connections_out;
let peerList = null;
if (inbound === undefined || outbound === undefined) { if (inbound === undefined || outbound === undefined) {
peerList = await rpcCall("getpeerinfo").catch(() => null); const peers = await rpcCall("getpeerinfo").catch(() => null);
inbound = peerList ? peerList.filter(p => p.inbound).length : 0; inbound = peers ? peers.filter(p => p.inbound).length : 0;
outbound = peerList ? peerList.filter(p => !p.inbound).length : 0; outbound = peers ? peers.filter(p => !p.inbound).length : 0;
} }
// Por qué red entra cada peer, y qué direcciones anuncia el nodo.
// Hace falta para no dar por hecho que tener conexiones entrantes expone
// la IP: si entran por Tor o I2P no la ven, y si el nodo no anuncia
// ninguna dirección de clearnet, tampoco la publica a la red.
let inboundNets = null, clearnetLocal = null;
try {
if (!peerList) peerList = await rpcCall("getpeerinfo");
if (Array.isArray(peerList)) {
inboundNets = {};
for (const p of peerList.filter(p => p.inbound)) {
const n = p.network || "desconocida";
inboundNets[n] = (inboundNets[n] || 0) + 1;
}
}
const locales = netinfo.localaddresses || [];
clearnetLocal = locales
.filter(a => !/\.onion$|\.i2p$/i.test(a.address || ""))
.map(a => a.address);
} catch { /* si falla, se informa como no comprobado, no como ausencia */ }
return { return {
inboundNets, clearnetLocal,
localAddrCount: (netinfo.localaddresses || []).length,
version: `v${Math.floor(netinfo.version / 10000)}.${Math.floor((netinfo.version % 10000) / 100)}.${netinfo.version % 100}`, version: `v${Math.floor(netinfo.version / 10000)}.${Math.floor((netinfo.version % 10000) / 100)}.${netinfo.version % 100}`,
blocks: info.blocks, blocks: info.blocks,
headers: info.headers, headers: info.headers,
-79
View File
@@ -1,79 +0,0 @@
# Pruebas
Dos familias. Las de **criptografía** (test1test4) verifican la derivación
watch-only —BIP32, secp256k1, RIPEMD-160, bech32— contra los **vectores
oficiales de los estándares**, no contra resultados propios. Las de
**heurísticas** (test5test6) comprueban el analizador de privacidad contra
transacciones donde la respuesta se conoce de antemano.
Si un cambio rompe algo, estas pruebas lo dicen.
## Cómo ejecutarlas
Las de heurísticas se ejecutan directamente — se extraen solas del
`dashboard.html`, así que no se desactualizan:
```bash
node tests/test5.js
node tests/test6.js
```
Las de criptografía necesitan una preparación manual (pendiente de
automatizar igual que las otras dos):
Extraer el bloque criptográfico de `dashboard.html` a `crypto.js` (las líneas
que van desde `const B32 = {` hasta el final de `deriveAddresses`), añadir al
principio `const { webcrypto } = require("crypto"); const crypto = webcrypto;`
y al final la exportación:
module.exports = { B32, SECP, deriveChildPubkey, ripemd160, hash160, toBech32, deriveAddresses };
Después:
node test1.js # RIPEMD-160 y aritmética de curva
node test2.js # BIP32 y bech32 contra vectores oficiales
node test3.js # búsqueda de casos borde (diagnóstico)
node test4.js # regresión de los fallos ya corregidos
## Qué cubren
- **test1** — RIPEMD-160 con los seis vectores del estándar (incluido el de un
millón de caracteres), generador de secp256k1, múltiplos conocidos, y que
comprimir y descomprimir un punto sea reversible.
- **test2** — BIP32: clave pública y chain code de la raíz, y derivación no
endurecida `m/0`, contra los vectores 1 y 2 del propio BIP32. bech32: la
dirección P2WPKH del generador, en mainnet y testnet (BIP173).
- **test3** — sondeo de casos borde. Fue el que encontró los cuatro fallos de
validación corregidos el 2026-07-27.
- **test4** — comprueba que esos cuatro siguen cerrados: checksum rota, xpub
truncado, índice endurecido, índice negativo. Y que un `tpub` genera
direcciones de testnet, no de mainnet.
- **test5** — detección del output de cambio. Transacciones donde se sabe de
antemano cuál es el pago y cuál el cambio, más los niveles de "redondez" de
una cifra. Nació de dos fallos de la v1.15.0: una señal que se contaba dos
veces y un índice que podía señalar el pago como si fuera el cambio.
- **test6** — el analizador completo. Coherencia de la nota (que ninguna
transacción pueda puntuar negativo antes del clamp), los dos checks nuevos
—polvo gastado junto a otras monedas y consolidación pura— y que la guardia
de CoinJoin desactive las heurísticas que no aplican dentro de una mezcla.
Las cuatro primeras prueban criptografía; las dos últimas, heurísticas. Se
ejecutan igual: `node tests/testN.js`.
## Lo que estas pruebas NO cubren
Las heurísticas (test5, test6) se comprueban contra transacciones construidas
a mano, no contra la cadena real. Eso demuestra que la lógica hace lo que dice
—y ha bastado para encontrar fallos reales— pero no dice nada sobre cuántas
veces acierta ahí fuera. Medir eso exigiría un conjunto de transacciones reales
con la respuesta conocida de antemano, que es un trabajo distinto y pendiente.
La matemática es correcta, pero eso no es una auditoría. No cubren análisis
formal ni una revisión independiente: quien las escribió conoce la
implementación y comparte sus supuestos, que es justo el sesgo que rompe un
revisor externo. Siguen haciendo falta ojos de fuera antes de difundir el
proyecto ampliamente.
Tampoco aplican aquí los ataques de canal lateral: todo esto maneja **solo
claves públicas**. No hay secreto que filtrar; lo único que importa es que el
resultado sea correcto, y eso es lo que se comprueba.
-44
View File
@@ -1,44 +0,0 @@
// WebCrypto en Node
const { webcrypto } = require('crypto');
global.crypto = webcrypto;
const fs=require("fs");
const { B32, SECP, deriveChildPubkey, ripemd160, hash160, toBech32, deriveAddresses } = require('/tmp/cripto/crypto.js');
const hex = a => Array.from(a).map(b=>b.toString(16).padStart(2,'0')).join('');
let pass=0, fail=0;
const check=(nombre, got, want)=>{
const ok = got===want;
console.log(` ${ok?'✓':'✗'} ${nombre}`);
if(!ok){ console.log(` obtenido: ${got}`); console.log(` esperado: ${want}`); fail++; } else pass++;
};
(async () => {
console.log("=== 1. RIPEMD-160 (vectores del estándar) ===");
const enc = s => new TextEncoder().encode(s);
check('""', hex(ripemd160(enc(""))), "9c1185a5c5e9fc54612808977ee8f548b2258d31");
check('"a"', hex(ripemd160(enc("a"))), "0bdc9d2d256b3ee9daae347be6f4dc835a467ffe");
check('"abc"', hex(ripemd160(enc("abc"))), "8eb208f7e05d987a9b044a8e98c6b087f15a0bfc");
check('"message digest"', hex(ripemd160(enc("message digest"))), "5d0689ef49d2fae572b881b123a85ffa21595f36");
check('abcdefghijklmnopqrstuvwxyz', hex(ripemd160(enc("abcdefghijklmnopqrstuvwxyz"))), "f71c27109c692c1b56bbdceb5b9d2865b3708dbc");
check('1M x "a"', hex(ripemd160(enc("a".repeat(1000000)))), "52783243c1697bdbe16d37f97f68f08325dc1528");
console.log("\n=== 2. secp256k1: G y múltiplos conocidos ===");
const G = SECP.G;
check('G comprimido', hex(SECP.compress(G)), "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798");
check('2G', hex(SECP.compress(SECP.mulPoint(2n, G))), "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5");
check('3G', hex(SECP.compress(SECP.mulPoint(3n, G))), "02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9");
// n-1 * G = -G (mismo x, y opuesta)
const nm1 = SECP.mulPoint(SECP.N - 1n, G);
check('(n-1)G tiene la x de G', nm1[0].toString(16), G[0].toString(16));
check('(n-1)G tiene y opuesta', ((nm1[1] + G[1]) % SECP.P).toString(), "0");
console.log("\n=== 3. compress → decompress (ida y vuelta) ===");
for (const k of [1n, 2n, 7n, 12345n, 0xdeadbeefn]) {
const pt = SECP.mulPoint(k, G);
const c = SECP.compress(pt);
const d = SECP.decompress(c);
check(`k=${k}`, hex(SECP.compress(d)), hex(c));
}
console.log(`\nRESULTADO: ${pass} correctas, ${fail} incorrectas`);
})();
-40
View File
@@ -1,40 +0,0 @@
const { B32, SECP, deriveChildPubkey, ripemd160, hash160, toBech32, deriveAddresses } = require('/tmp/cripto/crypto.js');
const hex = a => Array.from(a).map(b=>b.toString(16).padStart(2,'0')).join('');
let pass=0, fail=0;
const check=(n,g,w)=>{ const ok=g===w; console.log(` ${ok?'✓':'✗'} ${n}`); if(!ok){console.log(` obtenido: ${g}`);console.log(` esperado: ${w}`);fail++;}else pass++; };
(async () => {
// ── BIP32, vectores oficiales del estándar ──
// Vector 1: seed 000102...0e0f
// m/0/1 derivado SOLO con clave pública (derivación no endurecida)
console.log("=== 4. BIP32 — vector 1 oficial, derivación pública ===");
// xpub de m (raíz) del vector 1
const M = "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8";
const raw = B32.decodeBase58(M);
const chain = raw.slice(13,45), pub = raw.slice(45,78);
check("clave pública de m", hex(pub), "0339a36013301597daef41fbe593a02cc513d0b55527ec2df1050e2e8ff49c85c2");
check("chain code de m", hex(chain), "873dff81c02f525623fd1fe5167eac3a55a049de3d314bb42ee227ffed37d508");
// m/0 → xpub esperado del estándar
const m0 = await deriveChildPubkey(pub, chain, 0);
// del vector oficial: xpub de m/0'/1 ... usamos m/0 no endurecido del vector 2
console.log("\n=== 5. BIP32 — vector 2 oficial (m/0, no endurecida) ===");
const M2 = "xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB";
const r2 = B32.decodeBase58(M2);
const c2 = r2.slice(13,45), p2 = r2.slice(45,78);
const d2 = await deriveChildPubkey(p2, c2, 0);
check("m/0 clave pública", hex(d2.pub), "02fc9e5af0ac8d9b3cecfe2a888e2117ba3d089d8585886c9c826b6b22a98d12ea");
check("m/0 chain code", hex(d2.chain), "f0909affaa7ee7abe5dd4e100598d4dc53cd709d5a5c2cac40e7412f232f7c9c");
// m/0/2147483647 no se puede (endurecida). Probamos m/0/1 del vector 2:
const d3 = await deriveChildPubkey(d2.pub, d2.chain, 1);
console.log("\n=== 6. bech32 — vectores oficiales BIP173 ===");
// P2WPKH conocido: pubkey 0279be66... → bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4
const pk = Uint8Array.from(Buffer.from("0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","hex"));
const h = await hash160(pk);
check("hash160 de G", hex(h), "751e76e8199196d454941c45d1b3a323f1433bd6");
check("dirección P2WPKH", toBech32("bc", Array.from(h)), "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4");
check("misma en testnet", toBech32("tb", Array.from(h)), "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx");
console.log(`\nRESULTADO: ${pass} correctas, ${fail} incorrectas`);
})();
-63
View File
@@ -1,63 +0,0 @@
const { B32, SECP, deriveChildPubkey, ripemd160, hash160, toBech32, deriveAddresses } = require('/tmp/cripto/crypto.js');
const hex = a => Array.from(a).map(b=>b.toString(16).padStart(2,'0')).join('');
let hallazgos=[];
(async () => {
console.log("=== 7. ¿Se verifica la checksum del xpub? ===");
// xpub válido con UN carácter cambiado al final (checksum rota)
const bueno = "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8";
const malo = bueno.slice(0,-1) + (bueno.slice(-1)==="8" ? "9" : "8");
try {
const r = B32.decodeBase58(malo);
console.log(" ✗ ACEPTA un xpub con checksum inválida — no la comprueba");
hallazgos.push({sev:"medio", t:"No se verifica la checksum base58 del xpub", d:"decodeBase58 descarta los 4 bytes de checksum sin comprobarlos. Un xpub mal copiado (un carácter cambiado) se acepta y genera direcciones que no son las del usuario."});
} catch(e) { console.log(" ✓ rechaza:", e.message); }
console.log("\n=== 8. ¿Se valida la longitud del xpub? ===");
try {
const r = B32.decodeBase58("xpub661MyMwAqRbcFtXgS5sYJ"); // truncado
console.log(` ✗ ACEPTA un xpub truncado → ${r.length} bytes (deberían ser 78)`);
hallazgos.push({sev:"medio", t:"No se valida la longitud del xpub decodificado", d:"Un xpub truncado produce un array corto; chainCode y pubKey salen vacíos o parciales y la derivación falla de forma confusa o produce basura."});
} catch(e){ console.log(" ✓ rechaza:", e.message); }
console.log("\n=== 9. ¿Se valida que la clave pública esté en la curva? ===");
// Punto que NO está en secp256k1: x=1 no tiene y entera para y²=x³+7 → 8 no es residuo
try {
const falso = Uint8Array.from([2, ...new Array(31).fill(0), 1]); // x=1
const pt = SECP.decompress(falso);
const enCurva = (pt[1]*pt[1] - (pt[0]**3n + 7n)) % SECP.P === 0n;
if (!enCurva) {
console.log(" ✗ decompress DEVUELVE un punto que no está en la curva (no valida)");
hallazgos.push({sev:"medio", t:"decompress no comprueba que el punto esté en la curva", d:"Con una x que no corresponde a ningún punto de secp256k1, devuelve un par (x,y) inválido en vez de fallar. La derivación seguiría y produciría direcciones sin sentido. Solo alcanzable con un xpub manipulado."});
} else console.log(" ✓ el punto resultante sí está en la curva");
} catch(e){ console.log(" ✓ rechaza:", e.message); }
console.log("\n=== 10. Índices endurecidos (no derivables desde xpub) ===");
const raw=B32.decodeBase58(bueno), ch=raw.slice(13,45), pb=raw.slice(45,78);
try {
await deriveChildPubkey(pb, ch, 0x80000000);
console.log(" ✗ ACEPTA un índice endurecido — matemáticamente imposible desde una clave pública");
hallazgos.push({sev:"bajo", t:"No se rechaza el índice endurecido en deriveChildPubkey", d:"Un índice ≥ 0x80000000 no se puede derivar desde una clave pública. Hoy no se llama nunca con esos valores (deriveAddresses usa 0 y 1), así que no es explotable, pero la función no se defiende sola."});
} catch(e){ console.log(" ✓ rechaza:", e.message); }
console.log("\n=== 11. Consistencia: 100 direcciones seguidas ===");
const a = await deriveAddresses(bueno, 100);
const todas = [...a.receive, ...a.change];
const unicas = new Set(todas);
console.log(` direcciones generadas: ${todas.length}, únicas: ${unicas.size}`);
console.log(` todas empiezan por bc1q: ${todas.every(x=>x.startsWith("bc1q"))}`);
console.log(` longitud correcta (42): ${todas.every(x=>x.length===42)}`);
if (unicas.size !== todas.length) hallazgos.push({sev:"alto", t:"Direcciones duplicadas en la derivación", d:"Dos índices distintos producen la misma dirección."});
console.log("\n=== 12. Detección de red (mainnet vs testnet) ===");
for (const [p,esperado] of [["xpub","bc"],["zpub","bc"],["ypub","bc"],["tpub","?"],["vpub","tb"],["upub","tb"]]) {
const hrp = p.startsWith("tb")||p.startsWith("u")||p.startsWith("v") ? "tb":"bc";
const marca = (p==="tpub" && hrp==="bc") ? " ✗" : " ·";
console.log(`${marca} ${p}${hrp}`);
}
hallazgos.push({sev:"medio", t:"tpub (testnet) se trata como mainnet", d:"La detección mira si empieza por 'tb', 'u' o 'v'. Un tpub —el formato más común de testnet— empieza por 't' y NO por 'tb', así que cae en la rama de mainnet y genera direcciones bc1... a partir de claves de testnet."});
console.log("\n\n════ HALLAZGOS ════");
for (const h of hallazgos) console.log(`\n[${h.sev.toUpperCase()}] ${h.t}\n ${h.d}`);
if (!hallazgos.length) console.log("ninguno");
})();
-42
View File
@@ -1,42 +0,0 @@
const { B32, SECP, deriveChildPubkey, deriveAddresses } = require('/tmp/cripto/crypto.js');
let ok=0, ko=0;
const debeFallar = async (nombre, fn) => {
try { await fn(); console.log(`${nombre} — NO falló`); ko++; }
catch(e){ console.log(`${nombre}\n → "${e.message}"`); ok++; }
};
(async () => {
const bueno = "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8";
console.log("=== los cuatro hallazgos, revisados ===\n");
await debeFallar("checksum rota (un carácter cambiado)", () =>
deriveAddresses(bueno.slice(0,-1) + (bueno.slice(-1)==="8"?"9":"8"), 2));
await debeFallar("xpub truncado", () => deriveAddresses("xpub661MyMwAqRbcFtXgS5sYJ", 2));
await debeFallar("índice endurecido", async () => {
const raw = await B32.decodeBase58Check(bueno);
return deriveChildPubkey(raw.slice(45,78), raw.slice(13,45), 0x80000000);
});
await debeFallar("índice negativo", async () => {
const raw = await B32.decodeBase58Check(bueno);
return deriveChildPubkey(raw.slice(45,78), raw.slice(13,45), -1);
});
console.log("\n=== el xpub bueno sigue funcionando ===");
const a = await deriveAddresses(bueno, 3);
console.log(" recepción:", a.receive.join(", "));
console.log(" huella:", a.fingerprint);
const bien = a.receive.every(x=>x.startsWith("bc1q")&&x.length===42);
console.log(` ${bien?'✓':'✗'} formato correcto`); bien?ok++:ko++;
console.log("\n=== testnet: tpub ahora se detecta bien ===");
// vector 1 del BIP32 con los bytes de versión de testnet — dato público, no de nadie
const tp = "tpubD6NzVbkrYhZ4XgiXtGrdW5XDAPFCL9h7we1vwNCpn8tGbBcgfVYjXyhWo4E1xkh56hjod1RhGjxbaTLV3X4FyWuejifB9jusQ46QzG87VKp";
try {
const t = await deriveAddresses(tp, 2);
const esTb = t.receive.every(x=>x.startsWith("tb1q"));
console.log(` ${esTb?'✓':'✗'} genera direcciones de testnet: ${t.receive[0]}`);
esTb?ok++:ko++;
} catch(e){ console.log(" ✗ falló:", e.message); ko++; }
console.log(`\nRESULTADO: ${ok} correctas, ${ko} incorrectas`);
})();
-151
View File
@@ -1,151 +0,0 @@
// Detección del output de cambio (guessChangeOutput)
//
// Extrae la función del dashboard.html y la ejecuta contra transacciones
// construidas a mano, donde sabemos de antemano cuál es el pago y cuál el
// cambio. Cubre los dos fallos corregidos en la v1.15.0:
//
// 1. Una misma condición se contaba como dos señales distintas, así que
// un pago corriente entre tipos de dirección distintos ya bastaba para
// declarar el cambio "identificable" con certeza PROBABLE.
// 2. Cuando ninguna señal fuerte apuntaba a un output, el índice caía en
// "el más pequeño". En un pago pequeño desde una moneda grande, el más
// pequeño es el PAGO: la app señalaba la dirección del destinatario
// como si fuera el cambio del emisor.
//
// Uso: node tests/test5.js
const fs = require("fs");
const path = require("path");
const htmlPath = path.join(__dirname, "..", "dashboard.html");
const lines = fs.readFileSync(htmlPath, "utf8").split("\n");
// Se extrae desde roundness() porque guessChangeOutput depende de ella y de
// ROUND_MIN. El corte de abajo es analyzeTx, la siguiente función del archivo.
const start = lines.findIndex(l => l.includes("function roundness"));
if (start === -1) { console.error("No se encuentra roundness en dashboard.html"); process.exit(1); }
const end = lines.findIndex((l, i) => i > start && l.includes("function analyzeTx"));
const src = lines.slice(start, end).join("\n");
const { guessChangeOutput, roundness } = new Function(src + "\nreturn { guessChangeOutput, roundness };")();
const IN = (v,t,a) => ({ prevout:{ value:v, scriptpubkey_type:t, scriptpubkey_address:a }, txid:"aa", vout:0 });
const OUT = (v,t,a) => ({ value:v, scriptpubkey_type:t, scriptpubkey_address:a, scriptpubkey:"00" });
let pass = 0, fail = 0;
function T(nombre, tx, esperado, esCoinJoin) {
const r = guessChangeOutput(tx, !!esCoinJoin);
const real = r.index == null ? null : tx.vout[r.index].scriptpubkey_address;
const ok = real === esperado;
console.log(` ${ok ? "✓" : "✗"} ${nombre}`);
if (!ok) {
console.log(` señaló como cambio: ${real === null ? "ninguno" : real}`);
console.log(` esperado: ${esperado === null ? "ninguno" : esperado}`);
r.details.forEach(d => console.log(` · ${d}`));
fail++;
} else pass++;
}
console.log("=== Detección del output de cambio ===\n");
// Regresión del fallo 1. Pagar desde bech32 a una dirección taproot es de lo
// más común que hay. Que los tipos difieran es UNA señal, no dos: sola no
// basta para señalar nada.
T("pago bech32 -> taproot, importes no redondos: una sola señal, no se afirma",
{ vin:[IN(5000000,"v0_p2wpkh","bc1qA")],
vout:[OUT(1234567,"v1_p2tr","bc1pPAGO"), OUT(3765000,"v0_p2wpkh","bc1qCAMBIO")] },
null);
// Regresión del fallo 2. La salida redonda es el pago, luego el cambio es la
// otra — aunque la otra sea la grande.
T("pago redondo pequeño desde moneda grande: el cambio es el output GRANDE",
{ vin:[IN(100000000,"v0_p2wpkh","bc1qA")],
vout:[OUT(100000,"v0_p2wpkh","bc1qPAGO"), OUT(99895000,"v0_p2wpkh","bc1qCAMBIO")] },
"bc1qCAMBIO");
// La señal más fuerte que existe: el cambio vuelve a una dirección ya gastada.
T("el cambio reutiliza una dirección de las entradas",
{ vin:[IN(5000000,"v0_p2wpkh","bc1qA")],
vout:[OUT(1234567,"v1_p2tr","bc1pPAGO"), OUT(3765000,"v0_p2wpkh","bc1qA")] },
"bc1qA");
// Sin ninguna señal no se inventa nada.
T("mismo tipo en ambas salidas, sin redondos ni reuso: no se afirma nada",
{ vin:[IN(5000000,"v0_p2wpkh","bc1qA")],
vout:[OUT(1234567,"v0_p2wpkh","bc1qB"), OUT(3765000,"v0_p2wpkh","bc1qC")] },
null);
// Caso clásico y correcto desde siempre: pago redondo grande, cambio pequeño.
T("pago redondo grande y cambio pequeño (caso clásico)",
{ vin:[IN(11000000,"v0_p2wpkh","bc1qA")],
vout:[OUT(10000000,"v0_p2wpkh","bc1qPAGO"), OUT(985000,"v0_p2wpkh","bc1qCAMBIO")] },
"bc1qCAMBIO");
// En un CoinJoin la heurística no aplica: aunque las señales estructurales
// existan, las entradas son de personas distintas. Esta tx dispararía señales
// si no fuera por la guardia.
T("CoinJoin: la guardia desactiva la heurística aunque haya señales",
{ vin:[IN(100000000,"v0_p2wpkh","bc1qA")],
vout:[OUT(100000,"v1_p2tr","bc1pX"), OUT(99895000,"v0_p2wpkh","bc1qY")] },
null, true);
// ── Más de dos salidas: solo el caso inequívoco ─────────────────────────
// Antes la detección se plantaba en seco con más de 2 salidas. Ahora se
// pronuncia solo cuando no hay nada que adivinar.
console.log("\n=== Cambio con más de dos salidas ===\n");
T("3 salidas, solo una comparte tipo con las entradas: esa es el cambio",
{ vin:[IN(10000000,"v0_p2wpkh","bc1qA")],
vout:[OUT(3000000,"v1_p2tr","bc1pX"), OUT(2000000,"p2pkh","1Y"),
OUT(4990000,"v0_p2wpkh","bc1qCAMBIO")] },
"bc1qCAMBIO");
T("3 salidas, dos comparten tipo con las entradas: ambiguo, no se afirma",
{ vin:[IN(10000000,"v0_p2wpkh","bc1qA")],
vout:[OUT(3000000,"v0_p2wpkh","bc1qX"), OUT(2000000,"p2pkh","1Y"),
OUT(4990000,"v0_p2wpkh","bc1qZ")] },
null);
T("4 salidas, una vuelve a una dirección de las entradas: señal más fuerte",
{ vin:[IN(10000000,"v0_p2wpkh","bc1qA")],
vout:[OUT(3000000,"v0_p2wpkh","bc1qX"), OUT(2000000,"v0_p2wpkh","bc1qY"),
OUT(1000000,"v0_p2wpkh","bc1qZ"), OUT(3990000,"v0_p2wpkh","bc1qA")] },
"bc1qA");
T("CoinJoin con muchas salidas: la guardia sigue mandando",
{ vin:[IN(10000000,"v0_p2wpkh","bc1qA")],
vout:[OUT(3000000,"v1_p2tr","bc1pX"), OUT(2000000,"p2pkh","1Y"),
OUT(4990000,"v0_p2wpkh","bc1qCAMBIO")] },
null, true);
// ── Redondez ────────────────────────────────────────────────────────────
// La versión anterior era `v%1000000===0 || v%100000===0 || v%10000000===0`,
// donde la primera y la tercera condición sobran (todo múltiplo de un millón
// lo es de cien mil). Equivalía a "múltiplo de 0,001 BTC" y se le escapaban
// pagos tan redondos como 10.000 o 50.000 sats.
console.log("\n=== Redondez de las cifras ===\n");
function R(sats, minimo) {
const nivel = roundness(sats);
const ok = nivel >= minimo;
console.log(` ${ok ? "✓" : "✗"} ${String(sats).padStart(9)} sat = ${(sats/1e8).toFixed(8)} BTC -> nivel ${nivel} (mínimo esperado ${minimo})`);
ok ? pass++ : fail++;
}
R(100000000, 4); // 1 BTC
R( 10000000, 4); // 0,1 BTC
R( 1000000, 3); // 0,01 BTC
R( 100000, 3); // 0,001 BTC
R( 250000, 2); // 0,0025 BTC — se escapaba antes
R( 50000, 2); // 0,0005 BTC — se escapaba antes
R( 10000, 2); // 0,0001 BTC — se escapaba antes
// Y lo que NO debe considerarse redondo.
function NR(sats) {
const nivel = roundness(sats);
const ok = nivel < 2;
console.log(` ${ok ? "✓" : "✗"} ${String(sats).padStart(9)} sat -> nivel ${nivel} (no debe llegar a 2)`);
ok ? pass++ : fail++;
}
NR(123456);
NR(99895000);
NR(1234567);
console.log(`\n${pass} correctos, ${fail} fallos`);
process.exit(fail === 0 ? 0 : 1);
-218
View File
@@ -1,218 +0,0 @@
// Analizador de transacciones completo (analyzeTx)
//
// Extrae el analizador entero del dashboard.html y lo ejecuta contra
// transacciones construidas a mano. Comprueba tres cosas:
//
// · Coherencia del score: que el denominador y las deducciones midan lo
// mismo, es decir que ninguna transacción pueda puntuar negativo antes
// del clamp. Ese desajuste existía (deducciones hasta 213 contra un
// denominador de 196) y lo tapaba un Math.max(0, …).
// · Que la guardia de CoinJoin desactive las heurísticas que no aplican.
// · Los dos checks nuevos: polvo gastado junto a otras monedas, y
// consolidación pura.
//
// Uso: node tests/test6.js
const fs = require("fs");
const path = require("path");
const htmlPath = path.join(__dirname, "..", "dashboard.html");
const lines = fs.readFileSync(htmlPath, "utf8").split("\n");
const start = lines.findIndex(l => l.includes("function detectWallets"));
const end = lines.findIndex((l, i) => i > start && l.includes("function analyzeAddress"));
if (start === -1 || end === -1) { console.error("No se localiza el analizador en dashboard.html"); process.exit(1); }
// El analizador vive dentro del dashboard y usa la paleta y el índice de
// entidades. Aquí se sustituyen por lo mínimo: colores de mentira y un índice
// vacío, para que lo que se pruebe sean las heurísticas y no los datos.
const preludio = `
const C = { green:"g", amber:"a", red:"r", t2:"t", blue:"b" };
const ENTITY_INDEX = new Map();
const OFAC_SET = new Set();
`;
const src = lines.slice(start, end).join("\n");
const analyzeTx = new Function(preludio + src + "\nreturn analyzeTx;")();
const IN = (v,t,a) => ({ prevout:{ value:v, scriptpubkey_type:t, scriptpubkey_address:a }, txid:"aa", vout:0, sequence:0xffffffff });
const OUT = (v,t,a) => ({ value:v, scriptpubkey_type:t, scriptpubkey_address:a, scriptpubkey:"0014"+"11".repeat(20) });
const TX = (vin, vout, extra={}) => ({ txid:"ff".repeat(32), vin, vout, weight:800, fee:2000, locktime:0, status:{confirmed:true}, ...extra });
let pass = 0, fail = 0;
function ok(nombre, cond, extra) {
console.log(` ${cond ? "✓" : "✗"} ${nombre}`);
if (!cond && extra) console.log(` ${extra}`);
cond ? pass++ : fail++;
}
const check = (r, id) => r.checks.find(c => c.id === id);
console.log("=== Coherencia de la nota ===\n");
// El peor caso imaginable: todo lo que puede restar, restando a la vez.
// Muchas entradas de direcciones distintas y tipos mezclados, salidas legacy,
// polvo entre las entradas, OP_RETURN, y un lote de destinatarios.
const vinPeor = [];
for (let i = 0; i < 25; i++) {
vinPeor.push(IN(i === 0 ? 600 : 5000000, i % 2 ? "p2pkh" : "v0_p2wpkh", "addr" + i));
}
vinPeor.push(IN(5000000, "p2pkh", "addr0")); // reutilización
const voutPeor = [];
for (let i = 0; i < 8; i++) voutPeor.push(OUT(1000000 + i * 7777, "p2pkh", "out" + i));
voutPeor.push({ value:0, scriptpubkey_type:"op_return", scriptpubkey:"6a24" + "ab".repeat(36), scriptpubkey_address:null });
const peor = analyzeTx(TX(vinPeor, voutPeor));
ok("la peor transacción posible no puntúa por debajo de 0",
peor.score >= 0, `score = ${peor.score}`);
ok("la peor transacción posible cae en banda BAJA",
peor.band === "BAJA", `banda = ${peor.band}`);
ok("la exposición heredada se reporta aparte y no toca la nota",
peor.exposicion && peor.exposicion.items.length > 0,
`items = ${JSON.stringify(peor.exposicion?.items?.map(i=>i.id))}`);
// Suma de penalizaciones declaradas frente al máximo teórico: si el
// denominador fuera menor que la suma de lo que puede restar, existiría una
// transacción con score negativo antes del clamp.
const sumaPenalizaciones = peor.checks
.filter(c => !c.informational && c.pass === false)
.reduce((s, c) => s + (c.penalty || 0), 0);
ok("las deducciones reales no superan el 100% de la escala",
100 - peor.score <= 100, `deducciones equivalentes = ${100 - peor.score}`);
console.log(` (penalizaciones declaradas en esta tx: ${sumaPenalizaciones})`);
// Una transacción limpia: una entrada, dos salidas del mismo tipo, sin
// redondos, sin polvo, sin OP_RETURN.
const limpia = analyzeTx(TX(
[IN(5000000, "v1_p2tr", "bc1pA")],
[OUT(1234567, "v1_p2tr", "bc1pB"), OUT(3763000, "v1_p2tr", "bc1pC")]
));
ok("una transacción limpia alcanza banda ALTA",
limpia.band === "ALTA", `score = ${limpia.score}, banda = ${limpia.band}`);
console.log("\n=== La nota mide solo lo que dependía de ti ===\n");
// Un cobro impecable desde un exchange que agrupa pagos. El destinatario no
// eligió nada de esto: hasta la v1.17 le costaba 23 puntos de nota y no había
// forma de mejorarla.
const vinBatch = [IN(500000000, "v0_p2wpkh", "bc1qEXCHANGE")];
const voutBatch = [];
for (let i = 0; i < 9; i++) voutBatch.push(OUT(1000000 + i * 31337, "v0_p2wpkh", "bc1qDEST" + i));
const batch = analyzeTx(TX(vinBatch, voutBatch));
ok("recibir de un lote se detecta",
check(batch, "batch_payment")?.pass === false);
ok("...pero no baja la nota: aparece como exposición heredada",
batch.exposicion.items.some(i => i.id === "batch_payment"),
`items = ${JSON.stringify(batch.exposicion.items.map(i=>i.id))}`);
ok("...y la nota sigue siendo ALTA, porque quien cobra no hizo nada mal",
batch.band === "ALTA", `score = ${batch.score}, banda = ${batch.band}`);
// OP_RETURN ya no fuerza banda BAJA por decreto.
const conOpReturn = analyzeTx(TX(
[IN(5000000, "v1_p2tr", "bc1pA")],
[OUT(4990000, "v1_p2tr", "bc1pB"),
{ value:0, scriptpubkey_type:"op_return", scriptpubkey:"6a0a"+"ab".repeat(10), scriptpubkey_address:null }]
));
ok("OP_RETURN se detecta", check(conOpReturn, "op_return")?.pass === false);
ok("...pero ya no fuerza banda BAJA por decreto",
conOpReturn.band !== "BAJA", `banda = ${conOpReturn.band}`);
// Un fallo grave impide ALTA aunque el número dé de sobra.
const cambioVisible = analyzeTx(TX(
[IN(100000000, "v0_p2wpkh", "bc1qA")],
[OUT(100000, "v0_p2wpkh", "bc1qPAGO"), OUT(99895000, "v0_p2wpkh", "bc1qA")]
));
ok("un fallo grave impide la banda ALTA aunque el número dé",
cambioVisible.band !== "ALTA",
`score = ${cambioVisible.score}, banda = ${cambioVisible.band}`);
console.log("\n=== Polvo gastado junto a otras monedas (check nuevo) ===\n");
const conPolvo = analyzeTx(TX(
[IN(600, "v0_p2wpkh", "bc1qPOLVO"), IN(5000000, "v0_p2wpkh", "bc1qMIA")],
[OUT(4990000, "v0_p2wpkh", "bc1qDESTINO")]
));
ok("detecta el polvo gastado junto a una moneda normal",
check(conPolvo, "dust_spent")?.pass === false);
const soloPolvo = analyzeTx(TX(
[IN(600, "v0_p2wpkh", "bc1qA"), IN(700, "v0_p2wpkh", "bc1qB")],
[OUT(900, "v0_p2wpkh", "bc1qC")]
));
ok("no avisa si solo se gasta polvo (no revela vinculación nueva)",
soloPolvo.checks.find(c => c.id === "dust_spent")?.pass === true);
const sinPolvo = analyzeTx(TX(
[IN(5000000, "v0_p2wpkh", "bc1qA"), IN(3000000, "v0_p2wpkh", "bc1qB")],
[OUT(7990000, "v0_p2wpkh", "bc1qC")]
));
ok("no avisa cuando ninguna entrada es polvo",
sinPolvo.checks.find(c => c.id === "dust_spent")?.pass === true);
console.log("\n=== Consolidación pura (hueco cerrado) ===\n");
const vinConsol = [];
for (let i = 0; i < 20; i++) vinConsol.push(IN(5000000, "v0_p2wpkh", "bc1qA" + i));
const consol = analyzeTx(TX(vinConsol, [OUT(99900000, "v0_p2wpkh", "bc1qDESTINO")]));
const cu = check(consol, "unnecessary_input");
ok("una consolidación de 20 entradas a 1 salida ya no pasa desapercibida",
cu?.pass === false, `pass = ${cu?.pass}`);
ok("se etiqueta como consolidación, con certeza y no como probable",
cu?.label === "Consolidación de UTXOs" && cu?.certainty === "CERTEZA",
`label = ${cu?.label}, certeza = ${cu?.certainty}`);
ok("no se cobra dos veces: la penalización la lleva input_linkage",
cu?.penalty === 0 && check(consol, "input_linkage")?.pass === false);
console.log("\n=== La comisión como huella (check nuevo) ===\n");
// Comisión absoluta redonda: nadie llega a 10.000 sats clavados con un
// estimador, que calcula tarifa por tamaño y devuelve números feos.
const feeRedonda = analyzeTx(TX(
[IN(5000000, "v1_p2tr", "bc1pA")],
[OUT(4990000, "v1_p2tr", "bc1pB")],
{ fee: 10000, weight: 600 }
));
ok("detecta una comisión absoluta redonda",
check(feeRedonda, "fee_fingerprint")?.pass === true &&
/10000 sats exactos/.test(check(feeRedonda, "fee_fingerprint")?.detail || ""),
check(feeRedonda, "fee_fingerprint")?.detail?.slice(0, 90));
// Tarifa entera: 20,00 sat/vB sale de teclear "20" en la casilla.
const feeEntera = analyzeTx(TX(
[IN(5000000, "v1_p2tr", "bc1pA")],
[OUT(4996000, "v1_p2tr", "bc1pB")],
{ fee: 3000, weight: 600 } // vsize 150 -> 20,00 sat/vB
));
ok("detecta una tarifa prácticamente entera",
/prácticamente un número entero|sats exactos/.test(check(feeEntera, "fee_fingerprint")?.detail || ""),
check(feeEntera, "fee_fingerprint")?.detail?.slice(0, 90));
// Un estimador automático deja números feos.
const feeEstimada = analyzeTx(TX(
[IN(5000000, "v1_p2tr", "bc1pA")],
[OUT(4996873, "v1_p2tr", "bc1pB")],
{ fee: 3127, weight: 601 }
));
ok("no ve huella donde hay un número feo de estimador",
/sin forma de cifra elegida a mano/.test(check(feeEstimada, "fee_fingerprint")?.detail || ""),
check(feeEstimada, "fee_fingerprint")?.detail?.slice(0, 90));
ok("la huella de comisión no penaliza (misma decisión que con RBF)",
check(feeRedonda, "fee_fingerprint")?.penalty === 0 &&
check(feeRedonda, "fee_fingerprint")?.informational === true);
console.log("\n=== Guardia de CoinJoin ===\n");
// Whirlpool: 5 entradas, 5 salidas de la misma denominación.
const vinCJ = [], voutCJ = [];
for (let i = 0; i < 6; i++) {
vinCJ.push(IN(1100000, "v0_p2wpkh", "bc1qIN" + i));
voutCJ.push(OUT(1000000, "v0_p2wpkh", "bc1qOUT" + i));
}
const cj = analyzeTx(TX(vinCJ, voutCJ));
ok("se reconoce como CoinJoin", check(cj, "coinjoin")?.pass === true);
for (const id of ["input_linkage", "unnecessary_input", "input_type_mixing", "round_numbers", "dust_spent"]) {
const c = check(cj, id);
ok(`la guardia neutraliza ${id}`, c ? c.pass === true : true,
`pass = ${c?.pass}`);
}
console.log(`\n${pass} correctos, ${fail} fallos`);
process.exit(fail === 0 ? 0 : 1);
+2 -9
View File
@@ -4,15 +4,8 @@ After=network.target bitcoind.service
[Service] [Service]
Type=simple Type=simple
User=armg
# ── AJUSTA ESTAS DOS LÍNEAS ANTES DE INSTALARLO ────────────────────────── ExecStart=/usr/bin/node /home/armg/txoko/system-metrics.js
# User: el usuario con el que corre tu nodo (el mismo que ejecuta bitcoind
# suele ser buena elección). No lo dejes en root.
# ExecStart: la ruta donde hayas copiado system-metrics.js.
User=TU_USUARIO
ExecStart=/usr/bin/node /home/TU_USUARIO/txoko/system-metrics.js
# ─────────────────────────────────────────────────────────────────────────
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
StandardOutput=journal StandardOutput=journal