31 Commits
Author SHA1 Message Date
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
13 changed files with 391 additions and 2908 deletions
-6
View File
@@ -23,9 +23,3 @@ node_modules/
npm-debug.log* npm-debug.log*
txoko-roadmap.md txoko-roadmap.md
TRASPASO.md TRASPASO.md
PRUEBA-PERITAJE.md
HALLAZGOS-PERITAJE.md
MEJORAS.md
COHERENCIA.md
GIT.md
-333
View File
@@ -10,339 +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.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
### Añadido
- **Las librerías del frontend se sirven desde tu propio nodo.** React, ReactDOM
y Babel se cargaban desde unpkg.com, y las fuentes IBM Plex desde
fonts.googleapis.com. Ninguno de los dos veía qué transacciones analizabas —
pero ambos recibían tu IP pública, la hora y el referer en cada apertura del
dashboard: sabían que usabas Txoko, cuándo y desde dónde. Ese metadato es
justo lo que esta herramienta enseña a proteger. Ahora no sale ni una
petición fuera de tu red, verificable con
`grep -E '(src|href)="https?://' dashboard.html` (no devuelve nada).
- Consecuencia práctica: **el dashboard funciona sin conexión a internet**.
- Las librerías se descargan una vez durante la instalación y se **verifican
por hash** (SHA-256 publicados en SETUP.md).
- Versiones fijadas (`react@18.3.1`, `@babel/standalone@7.23.10`) en vez de
rangos: antes el CDN decidía qué versión te entregaba y podía cambiar sin
aviso.
- `instalar-fuentes.sh` automatiza la descarga de IBM Plex (licencia OFL) y
genera el CSS. Comprueba cada archivo y aborta si algo falla.
- **Caché de respuestas del nodo con TTL y coalescencia** en `useApi`. Las
pestañas se montan y desmontan al navegar, así que ir a otra pestaña y volver
repetía todas las peticiones. Ahora:
- Las **transacciones confirmadas se cachean toda la sesión** — son
inmutables, no hay motivo para volver a pedirlas. Analizador, rastro de
procedencia y peritaje dejan de pedir las mismas por separado.
- Historial de direcciones 60 s, bloques 60 s, mempool y comisiones 20 s,
estado del sistema 2 s (para que el monitor siga mostrando datos vivos).
- **Coalescencia:** dos componentes que piden lo mismo a la vez generan una
sola petición al nodo, no dos. Mismo patrón que ya usaba
`system-metrics.js` en el servidor.
- 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
- **El informe ya no atribuye al actor direcciones del otro lado de un
CoinJoin.** Detectado probando un rastro que atraviesa un Whirlpool real: el
informe declaraba *"el rastro se rompe en un CoinJoin — no se puede atribuir
con honestidad más allá de este punto"* y, tres líneas más abajo, listaba
**10 direcciones atribuidas al actor**, cinco de ellas salidas de esa misma
mezcla. Eran direcciones de otros participantes, señaladas en un documento
pensado para acompañar una denuncia.
- Los dos fundamentos que las colaban fallan precisamente ahí: **CIOH** asume
que quien gasta varios inputs juntos los controla, y un CoinJoin existe para
romper esa suposición (es la excepción clásica a la heurística); y la
**huella de software** sale estable por construcción, porque todos los
participantes usan el mismo programa.
- `finalizeForensicGraph` excluye ahora las transacciones marcadas como
`mixer` del conjunto de direcciones del actor y del union-find, y la
atribución por huella salta esos nodos.
- 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
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
atribución por huella de software no, así que la hot wallet del exchange
acababa listada como "dirección del actor" mientras la conclusión, dos líneas
más abajo, la identificaba correctamente como Bitfinex. La dirección donde el
ladrón deposita es del exchange, no del ladrón. Ambos criterios están ahora
alineados: ni mezclas ni custodios entran por ninguna de las dos vías.
Verificado: de 2 direcciones atribuidas a 1 (solo la rama legítima).
---
## [1.10.1] — 2026-07-27
### Añadido
- **Tope de ramificación en el peritaje (`MAX_FANOUT`, 25 salidas).** Detectado
probando el fixture de dust attack: una transacción que reparte a cientos de
direcciones hacía que el motor encolara todas sus ramas y, sobre todo, que
perfilara cada dirección de salida — 2 peticiones por dirección, así que una
tx de 143 salidas suponía ~286 peticiones al nodo antes siquiera de llegar al
frontier, y la pestaña se congelaba. Ahora, por encima del tope, la tx se
trata como reparto masivo (dust attack, lote de retiradas, airdrop): se corta
ANTES de perfilar nada, con `stopReason: "fanOut"` y su propia conclusión en
el informe, redactada como límite deliberado y no como inferencia sobre quién
controla esas direcciones. Verificado con una tx real de 143 salidas: 2
peticiones en total.
### Corregido
- **Peritaje forense: un fallo de consulta ya no se presenta como "rastro
completo".** Detectado probando contra el nodo real: cuando el backend
devolvía un 503 o agotaba el timeout, la app lo mostraba como
*"✓ Rastro completo — no quedan ramas por explorar"* y contaba esa rama como
terminal. La causa eran dos capturas silenciosas encadenadas — `get` devolvía
`mockData` ante cualquier error y `findSpendingTx` remataba con
`.catch(()=>[])` —, así que `[]` por error era indistinguible de `[]` porque
el output sigue sin gastar. En un informe pericial eso convertía una consulta
fallida en la conclusión más accionable del documento: *fondos localizados
sin gastar*.
- Nuevo `getStrict` en `useApi`: propaga el fallo en vez de disfrazarlo.
`get` no cambia, así que el resto del dashboard mantiene su comportamiento.
- El motor (`findSpendingTx`, `advanceForensicHop`, `initForensicTrace`)
distingue "no se pudo consultar" de "no hay gasto" y registra los fallos en
`trace.queryErrors`.
- El informe excluye las ramas con consulta fallida de "fondos localizados
sin gastar", las lista en su propia sección y abre las conclusiones
avisando de que el rastreo está incompleto. Incluido en los export JSON y MD.
- La UI muestra contador de consultas fallidas y sustituye el falso
"rastro completo" por un aviso con el detalle de qué falló.
- Peritaje: el error al obtener la transacción de origen ya no culpa siempre al
txid — distingue un txid inexistente de un fallo del nodo.
- Peritaje: el botón "Generar informe" explica por qué está deshabilitado en
vez de quedarse inerte sin dar motivo.
### Cambiado
- El aviso de consulta fallida ya no dice que el fallo "suele ser transitorio":
cuando se repite sobre la misma dirección no lo es, y ahora lo explica —
historial demasiado pesado para servirlo dentro del tiempo de espera, con la
indicación de comprobarla a mano antes de dar el rastro por cerrado.
### Documentación
- README: documentadas dos limitaciones reales del peritaje — las ramas que no
se pueden comprobar (y por qué cortar es deliberado, no un defecto), y que el
tope de volumen mide número de transacciones y no peso, así que una dirección
con pocas transacciones muy grandes puede agotar el tiempo de espera igual.
---
## [1.10.0] — 2026-07-16
### Añadido
- Peritaje forense: nueva pestaña que rastrea fondos robados o perdidos hacia
adelante, salto a salto, desde una transacción de origen (`txid:vout`) hasta
un punto de parada natural — custodio identificado, dilución excesiva,
CoinJoin real, o fondos aún sin gastar. Todo el rastreo corre sobre tu
propio nodo (Bitcoin Core + Fulcrum + Mempool self-hosted), como el resto
de la app: ninguna consulta sale de tu red.
- Sigue 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.
- **A demanda, salto a salto:** el rastreo no corre solo — tú decides
cuándo se explora el siguiente salto con el botón "Seguir el rastro",
viendo en todo momento cuántas transacciones se han explorado y
cuántas ramas quedan pendientes. Mismo espíritu que el rastro de
procedencia hacia atrás (RastroProcedencia), aplicado al rastreo hacia
adelante. Puedes generar el informe con lo explorado hasta ese momento,
sin necesidad de terminar el rastreo entero.
- Tope de volumen de transacciones (`tx_count`) como cinturón de
seguridad aparte del control manual: si una dirección de salida tiene
un historial extremadamente largo (miles de transacciones — típico de
un hot wallet de exchange), el rastro se detiene ahí por precaución
aunque el heurístico de perfil no la clasifique como custodio. Evita
que una sola rama intente paginar el historial completo de una
dirección con decenas de miles de transacciones.
- Reutiliza las heurísticas existentes del analizador (CIOH, detección de
cambio estructural, huella de wallet, CoinJoin, entidades conocidas) y
añade tres nuevas: perfil de dirección (personal vs hot wallet de
exchange), cambio conductual (se gasta rápido vs queda quieto) cruzado
con la señal estructural, y comparación de huella de software entre
saltos consecutivos.
- Confirma cadenas de peeling multi-salto: el check de patrón de pago
simple ya avisaba de que una sola transacción no basta para confirmar
una cadena — este módulo es lo que cierra ese hueco, siguiendo el rastro
hacia adelante de verdad.
- Marco de tres niveles en cada afirmación del informe: HECHO (dato
on-chain verificable), INFERENCIA (conclusión de una heurística, con su
propia sub-etiqueta CERTEZA/PROBABLE/POSIBLE) y DECLARACIÓN (lo que
cuenta el afectado, en un formulario de texto libre, siempre mostrado
aparte y nunca mezclado con los hechos).
- Informe con resumen, declaración del afectado, cronología, direcciones
atribuidas con su fundamento, fondos localizados sin gastar,
conclusiones numeradas, recomendaciones (con un aviso fijo contra
estafas de "recuperación de fondos" siempre que hay declaración),
metodología y anexo de verificación. Exportable a JSON y Markdown, igual
que el informe de wallet.
- No identifica la identidad real de nadie: como mucho llega a "hot wallet
de [exchange]" o "custodio no identificado". No hace denuncias ni envíos
automáticos a terceros — el informe es un documento que decides tú a
dónde llevar.
### Cambiado
- `unionFindCluster` (vinculación por CIOH) y `guessChangeOutput` (señales de
cambio estructural) se extrajeron de `buildWalletReport` y `analyzeTx`
respectivamente a funciones compartidas, sin cambio de comportamiento, para
que el peritaje forense las reutilice salto a salto.
--- ---
## [1.9.0] — 2026-06-12 ## [1.9.0] — 2026-06-12
### Añadido ### Añadido
+68 -100
View File
@@ -22,62 +22,26 @@ 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
grep -E "fetch\(|XMLHttpRequest|src=\"http|href=\"http" dashboard.html grep -E "fetch\(|XMLHttpRequest|src=\"http|href=\"http" dashboard.html
``` ```
El comando anterior no debe devolver **ninguna** URL externa. Todas las llamadas van a rutas relativas (`/api/`, `/system/`, `/vendor/`) que apuntan a tu propio nodo vía nginx. Verás que todas las llamadas van a rutas relativas (`/api/`, `/system/`) que apuntan a tu propio nodo vía nginx. No hay ninguna llamada a dominios externos en el código de análisis.
Esto incluye las librerías del frontend (React y Babel): también se sirven desde tu nodo, no desde un CDN. Hasta la versión 1.10.1 se cargaban desde unpkg.com, y aunque un CDN nunca vio qué transacciones analizabas, sí recibía tu IP pública cada vez que abrías el dashboard — es decir, sabía que usabas Txoko, cuándo y desde dónde. Ese metadato es justo lo que esta herramienta enseña a proteger, así que se eliminó. Las únicas dependencias externas son las librerías del frontend (React, Babel) que se cargan desde CDN al abrir la página. Estas no reciben ningún dato de tus transacciones — solo sirven el código de la interfaz.
Consecuencia práctica: **el dashboard funciona sin conexión a internet**. Solo necesita tu nodo. Las librerías se descargan una vez durante la instalación y se verifican por hash — ver [SETUP.md](SETUP.md).
--- ---
@@ -96,27 +60,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**
- 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
- Tope de volumen de transacciones como cinturón de seguridad extra: una dirección con un historial enorme (típico de un hot wallet de exchange) detiene el rastro por precaución, para no intentar paginar decenas de miles de transacciones
- Reutiliza las heurísticas del analizador (CIOH, cambio, huella de wallet, entidades) y añade perfil de dirección (personal vs hot wallet), cambio conductual y comparación de huella entre saltos
- Confirma cadenas de peeling multi-salto, algo que el análisis de una sola transacción no puede hacer por sí solo
- Cada afirmación del informe se etiqueta como HECHO, INFERENCIA (con su propia certeza) o DECLARACIÓN — nunca se mezclan
- Informe exportable a JSON y Markdown con cronología, direcciones atribuidas, fondos sin gastar, conclusiones y metodología
- No identifica la identidad real de nadie; no envía nada a terceros — el informe es tuyo para decidir qué hacer con él
**Integración con Sparrow** **Integración con Sparrow**
- Importación de etiquetas BIP-329: carga el `.jsonl` que exporta Sparrow y muestra tus propias etiquetas junto al análisis. Se lee en memoria, no sale del navegador - Importación de etiquetas BIP-329: carga el `.jsonl` que exporta Sparrow y muestra tus propias etiquetas junto al análisis. Se lee en memoria, no sale del navegador
- Watch-only por xpub/zpub: pega tu Master Public Key y Txoko deriva tus direcciones en local para marcar, en cada transacción, qué outputs son tuyos (recepción o cambio). La derivación BIP32 está implementada sin librerías externas; el xpub nunca sale del navegador. Requiere HTTPS (ver nota más abajo) - Watch-only por xpub/zpub: pega tu Master Public Key y Txoko deriva tus direcciones en local para marcar, en cada transacción, qué outputs son tuyos (recepción o cambio). La derivación BIP32 está implementada sin librerías externas; el xpub nunca sale del navegador. Requiere HTTPS (ver nota más abajo)
@@ -134,7 +77,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
--- ---
@@ -162,29 +105,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.
--- ---
@@ -192,25 +172,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.
--- ---
@@ -222,9 +193,6 @@ Las librerías (React, Babel, las fuentes) viven aparte, en `vendor/`, servidas
- **Base de datos de entidades parcial** — ~745 direcciones de exchanges, OFAC y minería. Cubre los casos más comunes; no es completa - **Base de datos de entidades parcial** — ~745 direcciones de exchanges, OFAC y minería. Cubre los casos más comunes; no es completa
- **Fingerprinting conservador** — requiere varias señales coincidentes; prefiere no detectar antes que detectar mal - **Fingerprinting conservador** — requiere varias señales coincidentes; prefiere no detectar antes que detectar mal
- **Sin análisis de red** — no cruza datos con otros nodos ni mempool distribuida - **Sin análisis de red** — no cruza datos con otros nodos ni mempool distribuida
- **Peritaje forense sin `/outspend`** — el backend Mempool self-hosted no expone ese endpoint (ni `/utxo`), así que cada salto se resuelve recorriendo el historial de la dirección receptora. Más peticiones que un `/outspend` directo, con throttling por lotes para no saturar el nodo
- **Ramas que no se pueden comprobar** — algunas direcciones tienen un historial tan pesado que el backend no lo sirve dentro del tiempo de espera (8 s). Cuando pasa, esa rama se marca como **no comprobada** y queda fuera de "fondos localizados sin gastar": el informe dice que no se sabe, en vez de afirmar que los fondos siguen ahí. Es deliberado — cortar protege el nodo, que es la premisa de la herramienta, y una consulta fallida nunca debe leerse como una conclusión. Esas ramas hay que comprobarlas a mano
- **El tope de volumen mide número de transacciones, no peso** — el cinturón de seguridad (`LARGE_ADDR_TX_COUNT`, 5000 tx) frena las direcciones con historiales enormes, pero una dirección con pocas transacciones muy grandes puede agotar el tiempo de espera igualmente (visto con una de 59 transacciones de ~15 KB cada una). No hay forma barata de conocer el peso de la respuesta por adelantado, así que el tope no cubre ese caso; lo cubre el manejo de errores descrito arriba
--- ---
+158 -243
View File
@@ -1,318 +1,233 @@
# 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
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 -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. # Copiar el archivo actualizado descargado de esta conversación
cp ~/Downloads/txoko-dashboard.html ./dashboard.html
```bash # Ver qué cambió (opcional pero útil)
sha256sum *.js git diff dashboard.html
```
Debe dar exactamente esto: # 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
85ba0c7207cf1b1850e40372f26a7e69a481457a29649b7ca19bbfce8604f30c babel.min.js git push
35f4f974f4b2bcd44da73963347f8952e341f83909e4498227d4e26b98f66f0d react-dom.production.min.js
d949f1c3687aedadcedac85261865f29b17cd273997e7f6b2bfc53b2f9d4c4dd react.production.min.js
```
Si alguno no coincide, **para aquí**: has recibido algo distinto de lo esperado. # Copiar al nodo (igual que antes)
scp dashboard.html armg@100.116.19.86:/home/armg/txoko/dashboard.html
Las versiones están fijadas a propósito. Usar un rango como `react@18` dejaría
que el servidor decidiera qué versión te entrega, y cambiaría con el tiempo sin
que te enteres.
### Las fuentes
```bash
sudo /ruta/al/repo/instalar-fuentes.sh /var/www/txoko/vendor/fonts
```
El script descarga IBM Plex (licencia libre OFL), comprueba cada archivo y
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
# Dashboard — OJO: alias a un DIRECTORIO, con barra final
location /dashboard/ {
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
**directorio**, con barra final, no al archivo `dashboard.html`. Si apunta al
archivo, el navegador no encontrará `vendor/` y verás una **página en blanco**
sin ningún mensaje de error. Es el fallo más común de esta instalación.
Recarga nginx:
```bash
sudo nginx -t && sudo systemctl reload nginx
``` ```
--- ---
## Paso 6 — Comprobar que funciona ## Mensajes de commit — ejemplos
Antes de abrir el navegador, verifica desde el propio nodo. Ajusta el puerto: El mensaje va después de `-m` y describe QUÉ cambiaste.
No tiene que ser perfecto, solo útil para ti en el futuro.
```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/ "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"
``` ```
**Con la barra final.** Sin ella, el navegador busca las librerías un nivel por Convención (opcional pero ordenada):
encima y no las encuentra. - `fix:` — corrige algo que no funcionaba bien
- `feat:` — añade algo nuevo
Pulsa **CONFIG** e introduce la URL de tu Mempool (por ejemplo - `docs:` — solo documentación
`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) ## 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.
---
## HTTPS para 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
server {
listen 4081 ssl; listen 4081 ssl;
listen [::]:4081 ssl;
server_name _;
ssl_certificate /etc/ssl/certs/txoko.crt; ssl_certificate /etc/ssl/certs/txoko.crt;
ssl_certificate_key /etc/ssl/private/txoko.key; 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" |
+152 -1843
View File
File diff suppressed because it is too large Load Diff
-83
View File
@@ -1,83 +0,0 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# Txoko — instalar IBM Plex en local (elimina la dependencia de Google Fonts)
#
# Descarga las fuentes IBM Plex (licencia OFL, libre) y genera el CSS que las
# declara. Tras ejecutarlo, el dashboard deja de contactar con
# fonts.googleapis.com y fonts.gstatic.com.
#
# Por qué importa: Google no ve qué transacciones analizas, pero sí recibe tu
# IP, la hora y la página desde la que se pide la fuente, cada vez que abres el
# dashboard. Para una herramienta de privacidad, sobra.
#
# Fuente: paquetes npm oficiales de IBM servidos por unpkg — el mismo origen
# del que ya se descargan React y Babel durante la instalación.
#
# Ejecutar EN EL NODO:
# chmod +x instalar-fuentes.sh && sudo ./instalar-fuentes.sh
# ─────────────────────────────────────────────────────────────────────────────
set -uo pipefail
DEST="${1:-/usr/share/nginx/html/vendor/fonts}"
MONO="https://unpkg.com/@ibm/plex-mono@1.1.0/fonts/complete/woff2"
SANS="https://unpkg.com/@ibm/plex-sans@1.1.0/fonts/complete/woff2"
echo "→ Instalando fuentes en: $DEST"
mkdir -p "$DEST"
cd "$DEST" || { echo "✗ No se pudo entrar en $DEST"; exit 1; }
# Pesos que usa el dashboard: Mono 400/600/700, Sans 400/500/600.
# Formato woff2 (el más comprimido, soportado por todo navegador actual).
URLS="
$MONO/IBMPlexMono-Regular.woff2
$MONO/IBMPlexMono-SemiBold.woff2
$MONO/IBMPlexMono-Bold.woff2
$SANS/IBMPlexSans-Regular.woff2
$SANS/IBMPlexSans-Medium.woff2
$SANS/IBMPlexSans-SemiBold.woff2
"
fallos=0
for url in $URLS; do
archivo=$(basename "$url")
printf " %-32s" "$archivo"
# -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)
tam=$(stat -c%s "$archivo" 2>/dev/null || echo 0)
if [ "$codigo" = "200" ] && [ "$tam" -gt 10000 ]; then
echo "ok ($((tam/1024)) KB)"
else
echo "FALLÓ (HTTP $codigo, $tam bytes)"
fallos=$((fallos+1))
fi
done
if [ "$fallos" -gt 0 ]; then
echo
echo "$fallos archivo(s) no se descargaron correctamente."
echo " No despliegues el dashboard nuevo todavía — avísame con esta salida."
exit 1
fi
# ── CSS que declara las fuentes ──────────────────────────────────────────────
# font-display:swap → el texto se ve desde el primer momento con la fuente de
# respaldo y se sustituye al cargar la definitiva. Sin página en blanco.
cat > ibm-plex.css <<'CSS'
/* IBM Plex — servido desde tu propio nodo. Licencia OFL (IBM).
Sustituye a fonts.googleapis.com: ninguna petición sale de tu red. */
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url('IBMPlexMono-Regular.woff2') format('woff2')}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:600;font-display:swap;src:url('IBMPlexMono-SemiBold.woff2') format('woff2')}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:700;font-display:swap;src:url('IBMPlexMono-Bold.woff2') format('woff2')}
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url('IBMPlexSans-Regular.woff2') format('woff2')}
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:500;font-display:swap;src:url('IBMPlexSans-Medium.woff2') format('woff2')}
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:600;font-display:swap;src:url('IBMPlexSans-SemiBold.woff2') format('woff2')}
CSS
echo
echo "✓ Fuentes instaladas y CSS generado."
echo " Total: $(du -sh . | cut -f1)"
echo
echo " Comprueba que nginx las sirve (debe dar 200):"
echo " curl -sk -o /dev/null -w '%{http_code}\\n' https://localhost:4081/vendor/fonts/ibm-plex.css"
echo
echo " Si da 200, ya puedes desplegar el dashboard.html nuevo."
+2 -46
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 ───────────────────────────────────────────────────────────
-47
View File
@@ -1,47 +0,0 @@
# Pruebas de la criptografía
Verifican la derivación watch-only (BIP32, secp256k1, RIPEMD-160, bech32)
contra los **vectores oficiales de los estándares**, no contra resultados
propios. Si un cambio rompe algo, estas pruebas lo dicen.
## Cómo ejecutarlas
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.
## Lo que estas pruebas NO cubren
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`);
})();
+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