Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f750a1327 | ||
|
|
24ca225190 | ||
|
|
2b483e0df9 | ||
|
|
d0cd9ef587 | ||
|
|
7c12f63f7a | ||
|
|
1b6904d2e3 | ||
|
|
14a094f941 | ||
|
|
8c2725fb31 | ||
|
|
c2fb4f1388 | ||
|
|
4d242a6ac6 | ||
|
|
251981e383 | ||
|
|
2abcdee144 | ||
|
|
50c19ebc9f | ||
|
|
a5d58dadd4 | ||
|
|
4280274239 | ||
|
|
7f3e2b5abc | ||
|
|
b9477c6139 | ||
|
|
039c916f55 | ||
|
|
d8c1e845b3 | ||
|
|
fcc19c31e1 | ||
|
|
6f3ca4ed7a | ||
|
|
6fcffb2096 | ||
|
|
ee817a8c9b | ||
|
|
2e97a6b822 | ||
|
|
133e292aab | ||
|
|
e89fea499c | ||
|
|
a6fe438b06 | ||
|
|
f757a01b9b | ||
|
|
2966e79578 | ||
|
|
3816b0426e | ||
|
|
57291a381b | ||
|
|
0333f15be5 | ||
|
|
82b27977eb | ||
|
|
f690e1783b | ||
|
|
2874f7c933 | ||
|
|
46636bdba9 | ||
|
|
dca3758c68 | ||
|
|
d232f0bc0d | ||
|
|
8d0c2620d3 | ||
|
|
d8a35d94cf | ||
|
|
b77d21fd84 | ||
|
|
889ffeb8dd | ||
|
|
f9acd12548 | ||
|
|
b5f0c81b88 | ||
|
|
2950559203 | ||
|
|
d3971de3a8 | ||
|
|
a7c65ed7ec | ||
|
|
b101ca4210 | ||
|
|
aa5838c010 | ||
|
|
788ed3bada | ||
|
|
0110e84c49 | ||
|
|
b3dedc1865 | ||
|
|
43829fb570 | ||
|
|
89766e7fdc | ||
|
|
8f5bb0d2ba | ||
|
|
c2b7c37c1e | ||
|
|
9ee860a78a |
@@ -27,5 +27,3 @@ PRUEBA-PERITAJE.md
|
||||
HALLAZGOS-PERITAJE.md
|
||||
MEJORAS.md
|
||||
COHERENCIA.md
|
||||
|
||||
GIT.md
|
||||
|
||||
-119
@@ -10,111 +10,6 @@ y el versionado sigue [Versionado Semántico](https://semver.org/lang/es/):
|
||||
- **MENOR** — características nuevas que no rompen lo anterior
|
||||
- **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
|
||||
@@ -208,20 +103,6 @@ y el versionado sigue [Versionado Semántico](https://semver.org/lang/es/):
|
||||
`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
|
||||
|
||||
@@ -96,17 +96,6 @@ Consecuencia práctica: **el dashboard funciona sin conexión a internet**. Solo
|
||||
- Bandas de privacidad con distinción explícita CERTEZA / PROBABLE / POSIBLE
|
||||
- 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
|
||||
@@ -134,7 +123,7 @@ pasó, esta te avisa cuando todavía puedes cambiar la transacción.
|
||||
- Conversor sat/BTC/fiat
|
||||
- Validador de dirección
|
||||
- Detector OP_RETURN
|
||||
- Decodificador de transacción raw
|
||||
- Decodificador PSBT y transacción raw
|
||||
|
||||
---
|
||||
|
||||
@@ -162,29 +151,66 @@ Probado sobre Ubuntu Server 24.04 con HP EliteDesk (i5, 32GB RAM, 2TB NVMe).
|
||||
|
||||
## Instalación
|
||||
|
||||
**La guía completa está en [SETUP.md](SETUP.md)** — paso a paso, con una
|
||||
comprobación después de cada uno para que sepas si vas bien sin tener que
|
||||
descubrirlo al final.
|
||||
### 1. Clonar el repositorio
|
||||
|
||||
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 `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.
|
||||
### 2. Copiar el dashboard
|
||||
|
||||
Un aviso que ahorra disgustos: en nginx, el `alias` del dashboard debe apuntar
|
||||
al **directorio** (con barra final), no al archivo `dashboard.html`. Si apunta
|
||||
al archivo, el navegador no encuentra `vendor/` y verás una página en blanco
|
||||
sin ningún error visible. Es el fallo más común, y en SETUP.md hay una
|
||||
comprobación concreta para descartarlo.
|
||||
```bash
|
||||
cp dashboard.html /var/www/txoko/dashboard.html
|
||||
# o donde lo sirvas con nginx
|
||||
```
|
||||
|
||||
Cuando termines, abre `http://TU-IP:4080/dashboard/` — **con la barra final** —,
|
||||
pulsa CONFIG e introduce la URL de tu Mempool.
|
||||
### 3. Copiar el backend de métricas
|
||||
|
||||
```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,22 +218,15 @@ pulsa CONFIG e introduce la URL de tu Mempool.
|
||||
|
||||
```
|
||||
txoko-dashboard/
|
||||
├── dashboard.html # La aplicación entera (HTML + CSS + JS en un archivo)
|
||||
├── system-metrics.js # Monitor del nodo (Node.js) — opcional
|
||||
├── txoko-metrics.service # Servicio systemd para el monitor
|
||||
├── 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
|
||||
├── dashboard.html # Frontend completo (HTML + CSS + JS en un solo archivo)
|
||||
├── system-metrics.js # Backend de métricas del nodo (Node.js)
|
||||
├── txoko-metrics.service # Servicio systemd
|
||||
├── README.md
|
||||
├── SETUP.md
|
||||
├── CHANGELOG.md
|
||||
└── LICENSE
|
||||
```
|
||||
|
||||
Tras la instalación, junto al `dashboard.html` queda además un directorio
|
||||
`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.
|
||||
|
||||
@@ -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
|
||||
uno antes de pasar al siguiente: cada comprobación te dice si vas bien, en vez
|
||||
de dejarte descubrirlo al final con una pantalla en blanco.
|
||||
|
||||
Todo se hace **en el nodo**, salvo donde se indique lo contrario.
|
||||
Instrucciones exactas. Copiar y pegar en la terminal del Mac.
|
||||
No hace falta entender git para seguir esto.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
tienes montadas. Necesitas:
|
||||
1. Abre tu instancia de Gitea en el navegador
|
||||
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.
|
||||
|
||||
Comprueba que la API responde antes de seguir. Ajusta el puerto al tuyo:
|
||||
## PASO 2 — Configurar git en el Mac (una sola vez, si no lo tienes)
|
||||
|
||||
```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:
|
||||
Txoko no puede funcionar sin ello.
|
||||
|
||||
> **Nunca expongas estos puertos a internet.** Txoko está pensado para
|
||||
> accederse por Tailscale, VPN o red local.
|
||||
Comprueba que git está instalado:
|
||||
```bash
|
||||
git --version
|
||||
```
|
||||
Si no está: `brew install git`
|
||||
|
||||
---
|
||||
|
||||
## Paso 1 — Descargar los archivos
|
||||
## PASO 3 — Crear el repo local y primer commit (una sola vez)
|
||||
|
||||
```bash
|
||||
git clone https://git.bitcointxoko.org/pikaro/txoko-dashboard.git
|
||||
cd txoko-dashboard
|
||||
# Crear carpeta del proyecto en el Mac
|
||||
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
|
||||
|
||||
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.
|
||||
## PASO 4 — Conectar con Gitea y subir (una sola vez)
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /var/www/txoko
|
||||
sudo cp dashboard.html /var/www/txoko/
|
||||
# Sustituye la URL por la de tu repo de Gitea
|
||||
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
|
||||
|
||||
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
|
||||
## PASO 5 — Flujo de trabajo normal (cada vez que yo te dé un archivo nuevo)
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /var/www/txoko/vendor
|
||||
cd /var/www/txoko/vendor
|
||||
cd ~/txoko-dashboard
|
||||
|
||||
# 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-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.
|
||||
### Verifica lo que has descargado
|
||||
|
||||
No te fíes: comprueba que los archivos son los que deben ser.
|
||||
|
||||
```bash
|
||||
sha256sum *.js
|
||||
@@ -101,218 +195,118 @@ Debe dar exactamente esto:
|
||||
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
|
||||
que el servidor decidiera qué versión te entrega, y cambiaría con el tiempo sin
|
||||
que te enteres.
|
||||
Las versiones están fijadas a propósito (`18.3.1`, `7.23.10`). 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
|
||||
### Dónde deben quedar los archivos
|
||||
|
||||
```bash
|
||||
sudo /ruta/al/repo/instalar-fuentes.sh /var/www/txoko/vendor/fonts
|
||||
```
|
||||
El dashboard las busca en `vendor/` con **ruta relativa**, es decir, en un
|
||||
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
|
||||
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`):
|
||||
Con la configuración típica de Mempool self-hosted:
|
||||
|
||||
```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;
|
||||
location /dashboard {
|
||||
alias /usr/share/nginx/html/;
|
||||
}
|
||||
```
|
||||
|
||||
**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.
|
||||
…el `dashboard.html` vive en `/usr/share/nginx/html/` y las librerías deben ir
|
||||
en `/usr/share/nginx/html/vendor/` — que es justo donde las deja el comando de
|
||||
arriba.
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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)
|
||||
## HTTPS para watch-only (opcional)
|
||||
|
||||
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
|
||||
aplicación funciona igual; solo esa función queda deshabilitada. Las etiquetas
|
||||
BIP-329 no necesitan HTTPS.
|
||||
API del navegador, que **solo funciona sobre HTTPS**. Si sirves el dashboard por
|
||||
HTTP, watch-only no estará disponible — el resto de la app funciona igual. Las
|
||||
etiquetas BIP-329 no necesitan HTTPS.
|
||||
|
||||
Si accedes por Tailscale o red local no tendrás un certificado válido, así que
|
||||
toca generar uno autofirmado.
|
||||
Si quieres usar watch-only y tu nodo va por HTTP (por ejemplo, acceso por
|
||||
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
|
||||
sudo openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
|
||||
-keyout /etc/ssl/private/txoko.key \
|
||||
-out /etc/ssl/certs/txoko.crt \
|
||||
-subj "/CN=100.64.0.5" \
|
||||
-addext "subjectAltName=IP:100.64.0.5"
|
||||
-subj "/CN=100.116.19.86" \
|
||||
-addext "subjectAltName=IP:100.116.19.86"
|
||||
```
|
||||
|
||||
El `subjectAltName` no es opcional: sin él los navegadores modernos rechazan el
|
||||
certificado aunque el `CN` sea correcto.
|
||||
### 2. Configurar nginx para servir HTTPS
|
||||
|
||||
### 2. Servirlo en nginx
|
||||
|
||||
Duplica tu bloque `server` en otro puerto (4081 en este ejemplo) añadiendo:
|
||||
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
|
||||
tu servidor HTTP:
|
||||
|
||||
```nginx
|
||||
listen 4081 ssl;
|
||||
ssl_certificate /etc/ssl/certs/txoko.crt;
|
||||
ssl_certificate_key /etc/ssl/private/txoko.key;
|
||||
server {
|
||||
listen 4081 ssl;
|
||||
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 &&
|
||||
sudo systemctl reload nginx`.
|
||||
Si el bloque `location /dashboard` ya viene de un snippet que incluyes, **no lo
|
||||
dupliques** dentro del server SSL: nginx dará error `duplicate location`.
|
||||
|
||||
### 3. Aceptar el certificado la primera vez
|
||||
|
||||
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
|
||||
Comprueba y recarga:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
sudo cp dashboard.html /var/www/txoko/
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
Y recarga el navegador con **Ctrl+Shift+R** (o Cmd+Shift+R en Mac) para saltarte
|
||||
la caché.
|
||||
### 3. Confiar el certificado la primera vez
|
||||
|
||||
Las librerías de `vendor/` no hace falta volver a descargarlas salvo que el
|
||||
CHANGELOG diga lo contrario. Si además cambia `system-metrics.js`, cópialo de
|
||||
nuevo (conservando tus credenciales) y reinicia con
|
||||
`sudo systemctl restart txoko-metrics`.
|
||||
Abre `https://TU-IP:4081/dashboard/` en el navegador. Como el certificado es
|
||||
autofirmado, el navegador avisará de que la conexión no es privada. Es esperado
|
||||
—lo creaste tú— y es seguro en tu propia red:
|
||||
|
||||
---
|
||||
- **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" |
|
||||
|
||||
+47
-428
@@ -1055,27 +1055,8 @@
|
||||
for (const c of str) { if (c === "1") leadingZeros++; else break; }
|
||||
const full = new Uint8Array(leadingZeros + body.length);
|
||||
full.set(body, leadingZeros);
|
||||
return full; // completo: payload + 4 bytes de checksum
|
||||
},
|
||||
|
||||
// Decodifica y COMPRUEBA la checksum. Es async porque el hash lo hace
|
||||
// Web Crypto. Sin esta comprobación, un xpub con un solo carácter mal
|
||||
// copiado se acepta sin protestar y genera direcciones que no son las
|
||||
// del usuario — que entonces vería su cartera "sin actividad" y se
|
||||
// quedaría tranquilo. Un falso negativo silencioso, justo lo que este
|
||||
// proyecto evita en todo lo demás.
|
||||
decodeBase58Check: async (str) => {
|
||||
const full = B32.decodeBase58(str);
|
||||
if (full.length < 5) throw new Error("Cadena demasiado corta para ser una clave extendida válida.");
|
||||
const payload = full.slice(0, full.length - 4);
|
||||
const checksum = full.slice(full.length - 4);
|
||||
const h = await B32.sha256d(payload);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (h[i] !== checksum[i]) {
|
||||
throw new Error("La clave no es válida: la suma de verificación no cuadra. Suele ser un carácter mal copiado — revísala y pégala entera.");
|
||||
}
|
||||
}
|
||||
return payload;
|
||||
// full = 82 bytes (78 payload + 4 checksum); devolvemos el payload
|
||||
return full.slice(0, full.length - 4);
|
||||
},
|
||||
|
||||
// SHA256 doble (para checksum base58)
|
||||
@@ -1172,13 +1153,7 @@
|
||||
|
||||
// Derivación BIP32 de clave pública hija (solo clave pública, sin hardened)
|
||||
const deriveChildPubkey = async (parentPub, parentChain, index) => {
|
||||
// Los índices endurecidos (≥ 2³¹) NO se pueden derivar desde una clave
|
||||
// pública: hace falta la privada. Hoy solo se llama con 0 y 1, así que
|
||||
// no es alcanzable, pero una función criptográfica debe defenderse sola.
|
||||
if (!Number.isInteger(index) || index < 0 || index >= 0x80000000) {
|
||||
throw new Error("Índice de derivación inválido: desde una clave pública no se pueden derivar índices endurecidos.");
|
||||
}
|
||||
const indexBytes = B32.u32be(index);
|
||||
const indexBytes = B32.u32be(index); // index < 0x80000000 (no hardened)
|
||||
const data = B32.concat(parentPub, indexBytes);
|
||||
const I = await B32.hmac512(parentChain, data);
|
||||
const IL = I.slice(0, 32);
|
||||
@@ -1297,42 +1272,17 @@
|
||||
};
|
||||
|
||||
// Derivar N direcciones desde xpub (rama 0=recepción, 1=cambio)
|
||||
// Redes por bytes de versión, no por el prefijo del texto. Mirar las
|
||||
// primeras letras parece equivalente y no lo es: un `tpub` —el formato más
|
||||
// habitual de testnet— empieza por "t" pero no por "tb", así que una
|
||||
// comprobación textual lo confunde con mainnet y genera direcciones bc1…
|
||||
// a partir de claves de testnet. Los bytes de versión son inequívocos.
|
||||
const XPUB_VERSIONS = {
|
||||
"0488b21e": { red:"bc", tipo:"xpub" }, "049d7cb2": { red:"bc", tipo:"ypub" },
|
||||
"04b24746": { red:"bc", tipo:"zpub" }, "0295b43f": { red:"bc", tipo:"Ypub" },
|
||||
"02aa7ed3": { red:"bc", tipo:"Zpub" },
|
||||
"043587cf": { red:"tb", tipo:"tpub" }, "044a5262": { red:"tb", tipo:"upub" },
|
||||
"045f1cf6": { red:"tb", tipo:"vpub" }, "024289ef": { red:"tb", tipo:"Upub" },
|
||||
"02575483": { red:"tb", tipo:"Vpub" },
|
||||
};
|
||||
|
||||
const deriveAddresses = async (xpubStr, count=20) => {
|
||||
// decodeBase58Check comprueba la suma de verificación: un carácter mal
|
||||
// copiado se detecta aquí y no acaba generando direcciones ajenas.
|
||||
const raw = await B32.decodeBase58Check(xpubStr.trim());
|
||||
// Decodificar xpub/zpub
|
||||
const raw = B32.decodeBase58(xpubStr.trim());
|
||||
// raw[0..3]=version, [4]=depth, [5..8]=fingerprint, [9..12]=childIndex
|
||||
// [13..44]=chainCode, [45..77]=pubKey
|
||||
if (raw.length !== 78) {
|
||||
throw new Error(`La clave extendida debería ocupar 78 bytes y ocupa ${raw.length}. Parece incompleta o de un formato que no reconozco.`);
|
||||
}
|
||||
const version = Array.from(raw.slice(0,4)).map(b=>b.toString(16).padStart(2,"0")).join("");
|
||||
const info = XPUB_VERSIONS[version];
|
||||
if (!info) throw new Error(`Formato de clave extendida no reconocido (versión ${version}).`);
|
||||
|
||||
const chainCode = raw.slice(13, 45);
|
||||
const pubKey = raw.slice(45, 78);
|
||||
if (pubKey[0] !== 0x02 && pubKey[0] !== 0x03) {
|
||||
throw new Error("La clave pública que contiene no tiene un formato comprimido válido.");
|
||||
}
|
||||
// Parent fingerprint (bytes 5-8): lo que Sparrow muestra junto al keystore
|
||||
const fingerprint = Array.from(raw.slice(5, 9)).map(b => b.toString(16).padStart(2,"0")).join("");
|
||||
|
||||
const hrp = info.red;
|
||||
const hrp = xpubStr.startsWith("tb") || xpubStr.startsWith("u") || xpubStr.startsWith("v") ? "tb" : "bc";
|
||||
|
||||
const result = { receive: [], change: [], fingerprint };
|
||||
for (const [branch, label] of [[0,"receive"],[1,"change"]]) {
|
||||
@@ -2837,22 +2787,11 @@
|
||||
<div key={i} style={{display:"flex",alignItems:"center",justifyContent:"space-between",padding:"5px 0",borderBottom:i<4?`1px solid ${C.border}`:"none"}}>
|
||||
<span style={{fontSize:"0.7rem",fontFamily:"monospace",color:C.blue,width:120,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{p.command}</span>
|
||||
<div style={{display:"flex",gap:8}}>
|
||||
{/* Se muestra el % del sistema (lo que de verdad se
|
||||
quiere saber: cuánto de la máquina se lleva). El
|
||||
% de un núcleo va en el tooltip, para comparar con
|
||||
top si hace falta. */}
|
||||
<Badge color={p.cpuSistema>25?C.red:p.cpuSistema>10?C.amber:C.t2}>
|
||||
<span title={p.cpu!=null?`${p.cpu}% de un núcleo · media desde que arrancó según ps: ${p.cpuMedia}%`:""}>
|
||||
CPU {p.cpuSistema!=null?p.cpuSistema:p.cpu}%
|
||||
</span>
|
||||
</Badge>
|
||||
<Badge color={C.amber}>CPU {p.cpu}%</Badge>
|
||||
<Badge color={p.mem>30?C.red:p.mem>15?C.amber:C.t2}>RAM {p.mem}%</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div style={{fontSize:"0.55rem",color:C.t3,fontFamily:"monospace",marginTop:8,lineHeight:1.5}}>
|
||||
CPU sobre el total de la máquina ({sysData.cpu?.count||"?"} núcleos), medida en una ventana de 500 ms — no es la media desde el arranque que muestra <code>ps</code>, que engaña porque un proceso que trabajó mucho hace días sigue apareciendo alto.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -3582,15 +3521,13 @@
|
||||
if(sorted.length>MAX_UTXOS) setErr(`Mostrando ${MAX_UTXOS} de ${sorted.length} UTXOs — se muestran los de mayor valor.`);
|
||||
|
||||
// Cargar txs de origen para contexto (máx 10, las más grandes)
|
||||
// Vía get() y no fetch directo: así pasa por la caché compartida.
|
||||
// Las transacciones confirmadas son inmutables y se guardan toda la
|
||||
// sesión, de modo que volver a este UTXO Map (o analizar una de estas
|
||||
// txs en otra pestaña) no cuesta ni una petición más al nodo.
|
||||
const cache={};
|
||||
const toFetch=limited.slice(0,10);
|
||||
await Promise.all(toFetch.map(async u=>{
|
||||
const t = await get(`/api/tx/${u.txid}`, null);
|
||||
if (t) cache[u.txid] = t;
|
||||
try{
|
||||
const res=await fetchWithTimeout(`${base}/api/tx/${u.txid}`);
|
||||
if(res.ok) cache[u.txid]=await res.json();
|
||||
}catch{}
|
||||
}));
|
||||
|
||||
setTxCache(cache);
|
||||
@@ -4811,7 +4748,7 @@
|
||||
|
||||
// ── AUDITORÍA ──────────────────────────────────────────────────────────
|
||||
function Auditoria({base, initialQuery}) {
|
||||
const {get, getStrict}=useApi(base);
|
||||
const {get}=useApi(base);
|
||||
const [query,setQuery]=useState(initialQuery||"");
|
||||
const [tx,setTx]=useState(null);
|
||||
const [analysis,setAnalysis]=useState(null);
|
||||
@@ -4869,22 +4806,14 @@
|
||||
const BATCH = 5; // direcciones por lote
|
||||
const PAUSE = 120; // ms entre lotes
|
||||
const active = []; // direcciones con actividad
|
||||
// Consultas que el nodo no pudo servir. Se registran porque su
|
||||
// ausencia SESGA el informe hacia el optimismo: una dirección que no
|
||||
// se pudo leer parece una dirección sin actividad, y entonces su
|
||||
// reutilización, sus vinculaciones y sus transacciones desaparecen
|
||||
// del análisis. Decir "tu wallet está limpia" cuando en realidad
|
||||
// faltan datos es peor que no decir nada.
|
||||
const scanErrors = [];
|
||||
|
||||
// Fase 1: estado ligero de cada dirección
|
||||
for (let i = 0; i < all.length; i += BATCH) {
|
||||
const slice = all.slice(i, i + BATCH);
|
||||
setWalletProgress(`Explorando direcciones ${Math.min(i+BATCH,all.length)}/${all.length}`);
|
||||
const infos = await Promise.all(slice.map(async s => {
|
||||
try { return await getStrict(`/api/address/${s.addr}`); }
|
||||
catch (e) { scanErrors.push({ addr:s.addr, branch:s.branch, message:e.message, phase:"estado de la dirección" }); return null; }
|
||||
}));
|
||||
const infos = await Promise.all(slice.map(s =>
|
||||
get(`/api/address/${s.addr}`, null).catch(()=>null)
|
||||
));
|
||||
infos.forEach((info, j) => {
|
||||
const txc = info?.chain_stats?.tx_count || 0;
|
||||
if (txc > 0) active.push({ ...slice[j], info });
|
||||
@@ -4900,21 +4829,14 @@
|
||||
const slice = active.slice(i, i + BATCH);
|
||||
setWalletProgress(`Trayendo transacciones ${Math.min(i+BATCH,active.length)}/${active.length}`);
|
||||
await Promise.all(slice.map(async s => {
|
||||
try {
|
||||
let page = await getStrict(`/api/address/${s.addr}/txs`);
|
||||
let guard = 0;
|
||||
while (Array.isArray(page) && page.length > 0 && guard < 8) {
|
||||
page.forEach(t => { if (t && t.txid) txMap.set(t.txid, t); });
|
||||
if (page.length < 25) break; // última página
|
||||
const last = page[page.length-1].txid;
|
||||
page = await getStrict(`/api/address/${s.addr}/txs/chain/${last}`);
|
||||
guard++;
|
||||
}
|
||||
} catch (e) {
|
||||
// Perder el historial de una dirección con actividad conocida
|
||||
// es lo más grave: sabemos que tiene transacciones y no hemos
|
||||
// podido verlas. El informe queda incompleto por ahí.
|
||||
scanErrors.push({ addr:s.addr, branch:s.branch, message:e.message, phase:"historial de transacciones", txCount:s.info?.chain_stats?.tx_count ?? null });
|
||||
let page = await get(`/api/address/${s.addr}/txs`, []).catch(()=>[]);
|
||||
let guard = 0;
|
||||
while (Array.isArray(page) && page.length > 0 && guard < 8) {
|
||||
page.forEach(t => { if (t && t.txid) txMap.set(t.txid, t); });
|
||||
if (page.length < 25) break; // última página
|
||||
const last = page[page.length-1].txid;
|
||||
page = await get(`/api/address/${s.addr}/txs/chain/${last}`, []).catch(()=>[]);
|
||||
guard++;
|
||||
}
|
||||
}));
|
||||
if (i + BATCH < active.length) await new Promise(r => setTimeout(r, PAUSE));
|
||||
@@ -4922,8 +4844,6 @@
|
||||
|
||||
setWalletProgress("Analizando vinculación…");
|
||||
const report = buildWalletReport(active, [...txMap.values()], myAddrSet);
|
||||
report.scanErrors = scanErrors;
|
||||
report.scanComplete = scanErrors.length === 0;
|
||||
setWalletReport(report);
|
||||
} catch(e) {
|
||||
setWalletReport({ error: e.message });
|
||||
@@ -5058,16 +4978,9 @@
|
||||
const a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download="wallet-report.json";a.click();
|
||||
}} style={{padding:"3px 10px",background:"none",border:`1px solid ${C.border}`,borderRadius:4,color:C.t2,fontFamily:"monospace",fontSize:"0.6rem",cursor:"pointer"}}>↓ JSON</button>
|
||||
<button onClick={()=>{
|
||||
const {health,totalTxs,activeCount,reusedAddrs,clusters,history,scanErrors}=walletReport;
|
||||
const {health,totalTxs,activeCount,reusedAddrs,clusters,history}=walletReport;
|
||||
const lines=[
|
||||
`# Informe de Wallet — Txoko`,``,
|
||||
...((scanErrors&&scanErrors.length>0)?[
|
||||
`> **AVISO — informe incompleto.** ${scanErrors.length} consulta(s) al nodo fallaron durante el escaneo.`,
|
||||
`> Las direcciones no leídas se comportan como si no tuvieran actividad, así que la valoración`,
|
||||
`> de abajo es **optimista**: puede faltar reutilización, vinculaciones o transacciones enteras.`,
|
||||
`> Repite el escaneo antes de darla por buena.`,``,
|
||||
...scanErrors.map(e=>`> - ${e.addr} (${e.branch}) — ${e.message} [${e.phase}]`),``,
|
||||
]:[]),
|
||||
`## Salud general: ${health.band}`,``,health.msg,``,
|
||||
`- Transacciones analizadas: ${totalTxs}`,
|
||||
`- Direcciones con actividad: ${activeCount}`,
|
||||
@@ -5087,32 +5000,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Aviso de escaneo incompleto — va ANTES de la banda a
|
||||
propósito: una valoración de salud leída sin saber que
|
||||
faltan datos es peor que no tener valoración. El sesgo
|
||||
siempre va hacia el optimismo, porque una dirección no
|
||||
leída parece una dirección sin actividad. */}
|
||||
{walletReport.scanErrors&&walletReport.scanErrors.length>0&&(
|
||||
<div style={{padding:"10px 14px",background:C.redMuted,border:`1px solid ${C.red}40`,borderRadius:8,marginBottom:14}}>
|
||||
<div style={{fontSize:"0.66rem",color:C.red,fontFamily:"monospace",fontWeight:700,marginBottom:6}}>
|
||||
⚠ Informe incompleto — {walletReport.scanErrors.length} consulta(s) al nodo fallaron
|
||||
</div>
|
||||
<div style={{fontSize:"0.64rem",color:C.t1,lineHeight:1.6,marginBottom:8}}>
|
||||
Las direcciones que no se pudieron leer se comportan aquí como si no tuvieran actividad. Eso hace que la valoración de abajo sea <strong>optimista</strong>: puede faltar reutilización, vinculaciones o transacciones enteras que sí existen. No la des por buena sin repetir el escaneo.
|
||||
</div>
|
||||
<div style={{display:"flex",flexDirection:"column",gap:2}}>
|
||||
{walletReport.scanErrors.slice(0,6).map((e,i)=>(
|
||||
<div key={i} style={{fontSize:"0.56rem",color:C.t2,fontFamily:"monospace",wordBreak:"break-all"}}>
|
||||
{e.addr} ({e.branch}) — {e.message} · {e.phase}
|
||||
</div>
|
||||
))}
|
||||
{walletReport.scanErrors.length>6&&(
|
||||
<div style={{fontSize:"0.56rem",color:C.t2,fontFamily:"monospace"}}>…y {walletReport.scanErrors.length-6} más</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bloque 1: Salud general */}
|
||||
<div style={{display:"flex",alignItems:"center",gap:14,padding:"12px 14px",background:C.bgCard,borderRadius:8,marginBottom:14,border:`1px solid ${walletReport.health.color}30`}}>
|
||||
<div style={{width:48,height:48,borderRadius:"50%",border:`3px solid ${walletReport.health.color}`,display:"flex",alignItems:"center",justifyContent:"center",flexShrink:0}}>
|
||||
@@ -6101,319 +5988,51 @@
|
||||
);
|
||||
}
|
||||
|
||||
// ── Parser PSBT (BIP174) ───────────────────────────────────────────────
|
||||
// Escrito desde cero, sin librerías, como el resto del proyecto. Validado
|
||||
// contra los cinco vectores inválidos del propio BIP174 (los rechaza los
|
||||
// cinco) y contra PSBTs reales de Sparrow.
|
||||
//
|
||||
// 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. Eso significa que puedes auditar una transacción antes de firmarla
|
||||
// aunque el nodo esté sincronizando — y que el nodo no se entera.
|
||||
function parsePsbt(bytes) {
|
||||
let p = 0;
|
||||
const need = (n) => { if (p + n > bytes.length) throw new Error("PSBT truncada: el archivo se acaba antes de tiempo."); };
|
||||
const u8 = () => { need(1); return bytes[p++]; };
|
||||
const u32 = () => { need(4); const v = bytes[p]|(bytes[p+1]<<8)|(bytes[p+2]<<16)|(bytes[p+3]<<24); p+=4; return v>>>0; };
|
||||
const u64 = () => { need(8); let v=0n; for(let i=7;i>=0;i--) v=(v<<8n)|BigInt(bytes[p+i]); p+=8; return Number(v); };
|
||||
const take = (n) => { need(n); return bytes.slice(p, p+=n); };
|
||||
const varint = () => { const v=u8(); if(v<0xfd) return v; if(v===0xfd){need(2);const r=bytes[p]|(bytes[p+1]<<8);p+=2;return r;} if(v===0xfe) return u32(); return u64(); };
|
||||
|
||||
if (bytes.length < 5 || bytes[0]!==0x70||bytes[1]!==0x73||bytes[2]!==0x62||bytes[3]!==0x74||bytes[4]!==0xff)
|
||||
throw new Error("Esto no es una PSBT: le falta la cabecera que las identifica. ¿Has pegado una transacción normal en vez de una a medio firmar?");
|
||||
p = 5;
|
||||
|
||||
// Cada sección es una lista de pares clave/valor que termina en un 0x00.
|
||||
// El estándar PROHÍBE claves repetidas: si aparecen, el archivo está
|
||||
// corrupto o manipulado, y conviene no seguir leyéndolo.
|
||||
const readMap = (donde) => {
|
||||
const out = []; const vistas = new Set();
|
||||
for (;;) {
|
||||
if (p >= bytes.length) throw new Error(`PSBT truncada: la sección de ${donde} no termina.`);
|
||||
const klen = varint();
|
||||
if (klen === 0) return out;
|
||||
const key = take(klen);
|
||||
const kh = Array.from(key).map(b=>b.toString(16).padStart(2,"0")).join("");
|
||||
if (vistas.has(kh)) throw new Error(`PSBT inválida: hay una clave repetida en ${donde}. El estándar no lo permite.`);
|
||||
vistas.add(kh);
|
||||
const vlen = varint();
|
||||
out.push({ type: key[0], key: key.slice(1), value: take(vlen) });
|
||||
}
|
||||
};
|
||||
|
||||
const global = readMap("la cabecera");
|
||||
const utxRec = global.find(e => e.type === 0x00);
|
||||
if (!utxRec) throw new Error("PSBT inválida: no contiene la transacción sin firmar.");
|
||||
|
||||
const tx = utxRec.value; let q = 0;
|
||||
const tneed=(n)=>{ if(q+n>tx.length) throw new Error("La transacción que hay dentro está truncada."); };
|
||||
const tu32=()=>{ tneed(4); const v=tx[q]|(tx[q+1]<<8)|(tx[q+2]<<16)|(tx[q+3]<<24); q+=4; return v>>>0; };
|
||||
const tu64=()=>{ tneed(8); let v=0n; for(let i=7;i>=0;i--) v=(v<<8n)|BigInt(tx[q+i]); q+=8; return Number(v); };
|
||||
const tvar=()=>{ const v=tx[q++]; if(v<0xfd) return v; if(v===0xfd){const r=tx[q]|(tx[q+1]<<8);q+=2;return r;} if(v===0xfe) return tu32(); return tu64(); };
|
||||
const ttake=(n)=>{ tneed(n); return tx.slice(q, q+=n); };
|
||||
const hx=(a)=>Array.from(a).map(b=>b.toString(16).padStart(2,"0")).join("");
|
||||
|
||||
const version = tu32();
|
||||
const nIn = tvar();
|
||||
if (nIn === 0) throw new Error("PSBT inválida: la transacción no tiene ninguna entrada.");
|
||||
const vin = [];
|
||||
for (let i=0;i<nIn;i++) {
|
||||
const txid = hx(Array.from(ttake(32)).reverse());
|
||||
const vout = tu32();
|
||||
const slen = tvar();
|
||||
ttake(slen);
|
||||
// Una PSBT contiene, por definición, una transacción SIN firmar. Si
|
||||
// trae firmas dentro, o no es una PSBT o alguien la ha manipulado.
|
||||
if (slen > 0) throw new Error("PSBT inválida: la transacción ya lleva firmas incrustadas donde no debería.");
|
||||
vin.push({ txid, vout, sequence: tu32() });
|
||||
}
|
||||
const nOut = tvar();
|
||||
if (nOut === 0) throw new Error("PSBT inválida: la transacción no tiene ninguna salida.");
|
||||
const vout = [];
|
||||
for (let i=0;i<nOut;i++) { const value=tu64(); const slen=tvar(); vout.push({ value, script: ttake(slen) }); }
|
||||
const locktime = tu32();
|
||||
|
||||
const inputs = []; for (let i=0;i<nIn;i++) inputs.push(readMap(`la entrada ${i+1}`));
|
||||
const outputs = []; for (let i=0;i<nOut;i++) outputs.push(readMap(`la salida ${i+1}`));
|
||||
if (p !== bytes.length) throw new Error("PSBT inválida: sobran bytes al final del archivo.");
|
||||
|
||||
return { version, locktime, vin, vout, global, inputs, outputs, size: bytes.length };
|
||||
}
|
||||
|
||||
const PSBT_TIPOS = { v0_p2wpkh:"SegWit (bc1q)", v0_p2wsh:"SegWit multifirma (bc1q)", v1_p2tr:"Taproot (bc1p)", p2pkh:"Legacy (1…)", p2sh:"Script legacy (3…)", op_return:"OP_RETURN", desconocido:"desconocido" };
|
||||
function psbtScriptType(s) {
|
||||
if (!s || !s.length) return "desconocido";
|
||||
if (s.length===22 && s[0]===0x00 && s[1]===0x14) return "v0_p2wpkh";
|
||||
if (s.length===34 && s[0]===0x00 && s[1]===0x20) return "v0_p2wsh";
|
||||
if (s.length===34 && s[0]===0x51 && s[1]===0x20) return "v1_p2tr";
|
||||
if (s.length===25 && s[0]===0x76 && s[1]===0xa9) return "p2pkh";
|
||||
if (s.length===23 && s[0]===0xa9 && s[1]===0x14) return "p2sh";
|
||||
if (s[0]===0x6a) return "op_return";
|
||||
return "desconocido";
|
||||
}
|
||||
|
||||
// Auditoría de privacidad ANTES de firmar. La diferencia con el resto de
|
||||
// la app: aquí todavía estás a tiempo de cambiar la transacción.
|
||||
function analyzePsbt(psbt) {
|
||||
const avisos = [];
|
||||
const hx = a => Array.from(a).map(b=>b.toString(16).padStart(2,"0")).join("");
|
||||
|
||||
const xpubs = psbt.global.filter(e => e.type === 0x01);
|
||||
if (xpubs.length > 0) {
|
||||
avisos.push({
|
||||
nivel: "critico", id: "xpub_expuesto",
|
||||
titulo: xpubs.length === 1 ? "El archivo lleva dentro una clave pública maestra" : `El archivo lleva dentro ${xpubs.length} claves públicas maestras`,
|
||||
hecho: `Hecho (certeza): además de lo necesario para firmar, la PSBT incluye ${xpubs.length} xpub de nivel cuenta.`,
|
||||
consecuencia: "Quien reciba este archivo puede derivar TODAS las direcciones de la cartera, las que ya has usado y las que usarás, y ver su saldo e historial completos. No puede gastar nada, pero lo ve todo. Y las PSBT están hechas para compartirse: acaban en un correo o un chat para que el resto firme.",
|
||||
quePuedesHacer: "Compártela solo con los cosignatarios y por un canal cifrado. Si los demás firmantes ya tienen cargada la configuración de la cartera, no necesitan estos xpubs: Sparrow permite excluirlos al exportar.",
|
||||
});
|
||||
}
|
||||
|
||||
const inVals = psbt.inputs.map(m => {
|
||||
const w = m.find(e => e.type === 0x01);
|
||||
if (w) { let v=0n; for(let i=7;i>=0;i--) v=(v<<8n)|BigInt(w.value[i]); return Number(v); }
|
||||
return null;
|
||||
});
|
||||
const conocidos = inVals.every(v => v !== null);
|
||||
const totalIn = conocidos ? inVals.reduce((a,b)=>a+b,0) : null;
|
||||
const totalOut = psbt.vout.reduce((a,o)=>a+o.value,0);
|
||||
const fee = totalIn !== null ? totalIn - totalOut : null;
|
||||
|
||||
const salidasPropias = psbt.outputs.map(m => m.some(e=>e.type===0x02));
|
||||
const todasPropias = salidasPropias.length > 0 && salidasPropias.every(Boolean);
|
||||
|
||||
if (todasPropias && psbt.vout.length === 1) {
|
||||
avisos.push({
|
||||
nivel: "info", id: "autoenvio",
|
||||
titulo: "Todo el importe vuelve a tu propia cartera",
|
||||
hecho: "Hecho (certeza): la única salida lleva rutas de derivación de tu cartera, así que no estás pagando a nadie.",
|
||||
consecuencia: "Es un movimiento interno: consolidar monedas, acelerar una transacción atascada o cambiar de dirección. No revela un pago, pero deja un patrón reconocible en la cadena — una entrada, una salida, sin cambio.",
|
||||
quePuedesHacer: null,
|
||||
});
|
||||
}
|
||||
|
||||
const tiposIn = new Set(psbt.inputs.map(m => {
|
||||
const w = m.find(e=>e.type===0x01);
|
||||
if (!w) return null;
|
||||
const l = w.value[8];
|
||||
return psbtScriptType(w.value.slice(9, 9+l));
|
||||
}).filter(Boolean));
|
||||
const tiposOut = psbt.vout.map(o => psbtScriptType(o.script));
|
||||
|
||||
if (psbt.vout.length === 2 && tiposIn.size === 1 && tiposOut[0] !== tiposOut[1]) {
|
||||
const t = [...tiposIn][0];
|
||||
if (tiposOut.filter(x => x === t).length === 1) {
|
||||
avisos.push({
|
||||
nivel: "aviso", id: "cambio_por_tipo",
|
||||
titulo: "Tu cambio se distingue por el tipo de dirección",
|
||||
hecho: `Hecho (certeza): gastas monedas de tipo ${PSBT_TIPOS[t]} y solo una de las dos salidas usa ese mismo tipo.`,
|
||||
consecuencia: "Cualquiera que mire la transacción deduce cuál de las dos salidas es tu cambio — y por ahí puede seguir tus gastos posteriores.",
|
||||
quePuedesHacer: "Cuando puedas elegir, gasta monedas del mismo tipo que la dirección a la que pagas. No siempre depende de ti: manda el formato que use quien cobra.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (psbt.vout.length === 2) {
|
||||
const redondos = psbt.vout.map((o,i)=>({i, v:o.value})).filter(o => o.v % 100000 === 0);
|
||||
if (redondos.length === 1) {
|
||||
avisos.push({
|
||||
nivel: "aviso", id: "valor_redondo",
|
||||
titulo: "Una de las salidas tiene un importe redondo",
|
||||
hecho: `Hecho (certeza): la salida ${redondos[0].i+1} vale exactamente ${(redondos[0].v/1e8).toFixed(8)} BTC.`,
|
||||
consecuencia: "Las personas pagan cifras redondas y el cambio es lo que sobra, con todos sus decimales. Refuerza la deducción de cuál salida es el pago y cuál el cambio.",
|
||||
quePuedesHacer: "Si el importe lo decides tú, añadir unos satoshis sueltos rompe la señal sin coste real.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const conWitnessScript = psbt.inputs.find(m => m.some(e=>e.type===0x05));
|
||||
if (conWitnessScript) {
|
||||
const ws = conWitnessScript.find(e=>e.type===0x05).value;
|
||||
const m = ws[0]>=0x51&&ws[0]<=0x60 ? ws[0]-0x50 : null;
|
||||
const n = ws[ws.length-2]>=0x51&&ws[ws.length-2]<=0x60 ? ws[ws.length-2]-0x50 : null;
|
||||
avisos.push({
|
||||
nivel: "info", id: "multisig",
|
||||
titulo: (m&&n) ? `Cartera multifirma de ${m} de ${n}` : "Cartera multifirma",
|
||||
hecho: `Hecho (certeza): las entradas se gastan con un script de firma múltiple${(m&&n)?`, de tipo ${m} de ${n}`:""}.`,
|
||||
consecuencia: "Al gastar, ese script queda escrito en la cadena para siempre. El multisig es minoritario, así que te coloca en un grupo pequeño y reconocible: alguien que guarda cantidades con la custodia repartida.",
|
||||
quePuedesHacer: "Es el precio de esta forma de custodia y con este esquema no se puede evitar. Taproot permite gastos multifirma que por fuera parecen pagos corrientes, pero implica cambiar de configuración.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!conocidos) {
|
||||
avisos.push({
|
||||
nivel: "aviso", id: "sin_importes",
|
||||
titulo: "No se puede calcular la comisión",
|
||||
hecho: "Hecho (certeza): alguna entrada no incluye el importe de la moneda que gasta.",
|
||||
consecuencia: "Sin ese dato no se sabe cuánto se paga de comisión. Una PSBT completa debería traerlo; que falte suele indicar que la generó una herramienta incompleta.",
|
||||
quePuedesHacer: "Ábrela en el wallet que la creó y vuelve a exportarla.",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
resumen: {
|
||||
entradas: psbt.vin.length, salidas: psbt.vout.length,
|
||||
totalIn, totalOut, fee, locktime: psbt.locktime,
|
||||
rbf: psbt.vin.some(i => i.sequence < 0xfffffffe),
|
||||
tiposEntrada: [...tiposIn].map(t=>PSBT_TIPOS[t]),
|
||||
tiposSalida: tiposOut.map(t=>PSBT_TIPOS[t]),
|
||||
xpubs: xpubs.length, multisig: !!conWitnessScript,
|
||||
firmasPresentes: psbt.inputs.reduce((a,m)=>a+m.filter(e=>e.type===0x02).length,0),
|
||||
size: psbt.size,
|
||||
},
|
||||
avisos,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Auditoría de PSBT ──────────────────────────────────────────────────
|
||||
// ── Decodificador PSBT ─────────────────────────────────────────────────
|
||||
function ToolPsbt() {
|
||||
const [input, setInput] = useState("");
|
||||
const [result, setResult] = useState(null);
|
||||
const [err, setErr] = useState(null);
|
||||
|
||||
// Acepta base64 (lo habitual al copiar) o hexadecimal.
|
||||
const decode = () => {
|
||||
const raw = input.trim().replace(/\s/g,"");
|
||||
const raw = input.trim();
|
||||
if (!raw) return;
|
||||
setErr(null); setResult(null);
|
||||
try {
|
||||
let bytes;
|
||||
if (/^[0-9a-fA-F]+$/.test(raw) && raw.length % 2 === 0) {
|
||||
bytes = new Uint8Array(raw.match(/../g).map(h=>parseInt(h,16)));
|
||||
} else {
|
||||
const bin = atob(raw);
|
||||
bytes = new Uint8Array(bin.length);
|
||||
for (let i=0;i<bin.length;i++) bytes[i] = bin.charCodeAt(i);
|
||||
}
|
||||
const psbt = parsePsbt(bytes);
|
||||
setResult(analyzePsbt(psbt));
|
||||
// Basic PSBT validation — starts with magic bytes "psbt" in base64
|
||||
const bytes = atob(raw.replace(/\s/g,""));
|
||||
if (!bytes.startsWith("psbt")) throw new Error("No es una PSBT válida — debe empezar por 'psbt'");
|
||||
// Extract basic info from magic bytes
|
||||
setResult({
|
||||
valid: true,
|
||||
size: bytes.length,
|
||||
note: "PSBT válida. Para un análisis completo necesitas un wallet como Sparrow o Coldcard. Esta herramienta verifica el formato.",
|
||||
});
|
||||
} catch(e) {
|
||||
setErr(e.message);
|
||||
setErr(`Error al decodificar: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const cargarArchivo = (file) => {
|
||||
if (!file) return;
|
||||
const r = new FileReader();
|
||||
r.onload = (e) => {
|
||||
const bytes = new Uint8Array(e.target.result);
|
||||
setErr(null); setResult(null);
|
||||
try { setResult(analyzePsbt(parsePsbt(bytes))); }
|
||||
catch(err2) { setErr(err2.message); }
|
||||
};
|
||||
r.readAsArrayBuffer(file);
|
||||
};
|
||||
|
||||
const colorNivel = (n) => n==="critico"?C.red : n==="aviso"?C.amber : C.blue;
|
||||
const etiquetaNivel = (n) => n==="critico"?"CRÍTICO" : n==="aviso"?"AVISO" : "INFORMATIVO";
|
||||
const R = result?.resumen;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div style={{fontSize:"0.62rem",color:C.blue,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:8}}>Auditoría de PSBT</div>
|
||||
<div style={{fontSize:"0.65rem",color:C.t2,marginBottom:12,lineHeight:1.6}}>
|
||||
Revisa una transacción <strong style={{color:C.t1}}>antes de firmarla</strong>, cuando todavía puedes cambiarla. Todo se analiza en tu navegador: el archivo no se envía a ningún sitio, ni siquiera a tu nodo.
|
||||
</div>
|
||||
<div style={{fontSize:"0.62rem",color:C.blue,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:8}}>Validador PSBT</div>
|
||||
<div style={{fontSize:"0.65rem",color:C.t2,marginBottom:12}}>Pega una PSBT en base64 para verificar que es válida antes de firmarla. Nunca se envía a ningún servidor — todo es local.</div>
|
||||
<textarea value={input} onChange={e=>setInput(e.target.value)}
|
||||
placeholder="Pega aquí la PSBT en base64 (cHNidP8BAH…) o en hexadecimal"
|
||||
placeholder="cHNidP8BAH..."
|
||||
rows={4}
|
||||
style={{width:"100%",background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"10px 12px",color:C.t1,fontFamily:"monospace",fontSize:"0.72rem",outline:"none",resize:"vertical",boxSizing:"border-box",marginBottom:8}}
|
||||
onFocus={e=>e.target.style.borderColor=C.blue} onBlur={e=>e.target.style.borderColor=C.borderBright}
|
||||
/>
|
||||
<div style={{display:"flex",gap:8,alignItems:"center",flexWrap:"wrap",marginBottom:10}}>
|
||||
<button onClick={decode} style={{padding:"9px 16px",background:C.blueMuted,border:`1px solid ${C.blue}40`,borderRadius:6,color:C.blue,fontFamily:"monospace",fontSize:"0.72rem",cursor:"pointer",fontWeight:700}}>ANALIZAR</button>
|
||||
<label style={{padding:"9px 16px",background:"none",border:`1px solid ${C.border}`,borderRadius:6,color:C.t2,fontFamily:"monospace",fontSize:"0.7rem",cursor:"pointer"}}>
|
||||
…o abrir archivo .psbt
|
||||
<input type="file" accept=".psbt,.txn,application/octet-stream" style={{display:"none"}} onChange={e=>cargarArchivo(e.target.files[0])}/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{err&&<div style={{padding:"10px 12px",background:C.redMuted,border:`1px solid ${C.red}40`,borderRadius:6,color:C.red,fontFamily:"monospace",fontSize:"0.7rem",lineHeight:1.6}}>{err}</div>}
|
||||
|
||||
<button onClick={decode} style={{padding:"9px 16px",background:C.blueMuted,border:`1px solid ${C.blue}40`,borderRadius:6,color:C.blue,fontFamily:"monospace",fontSize:"0.72rem",cursor:"pointer",fontWeight:700,marginBottom:10}}>VERIFICAR</button>
|
||||
{err&&<div style={{padding:"8px 12px",background:C.redMuted,border:`1px solid ${C.red}30`,borderRadius:6,color:C.red,fontFamily:"monospace",fontSize:"0.72rem"}}>{err}</div>}
|
||||
{result&&(
|
||||
<div style={{display:"flex",flexDirection:"column",gap:12}}>
|
||||
{/* Resumen de la transacción */}
|
||||
<div style={{padding:"12px 14px",background:C.bg,borderRadius:6,border:`1px solid ${C.border}`}}>
|
||||
<div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",fontWeight:700,letterSpacing:"0.12em",marginBottom:10}}>QUÉ HACE ESTA TRANSACCIÓN</div>
|
||||
<div style={{display:"flex",gap:18,flexWrap:"wrap"}}>
|
||||
<Tag label="Entradas" value={R.entradas}/>
|
||||
<Tag label="Salidas" value={R.salidas}/>
|
||||
{R.totalIn!=null&&<Tag label="Gasta" value={fmt.btc(R.totalIn)}/>}
|
||||
<Tag label="Envía" value={fmt.btc(R.totalOut)}/>
|
||||
{R.fee!=null&&<Tag label="Comisión" value={`${fmt.num(R.fee)} sat`} color={C.amber}/>}
|
||||
<Tag label="Firmas ya puestas" value={R.firmasPresentes}/>
|
||||
</div>
|
||||
<div style={{marginTop:10,fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",lineHeight:1.7}}>
|
||||
Gasta monedas de tipo {R.tiposEntrada.join(", ")||"desconocido"} · envía a {R.tiposSalida.join(", ")}<br/>
|
||||
{R.rbf ? "Reemplazable (RBF): podrás subir la comisión si se atasca." : "No reemplazable: si se atasca, no podrás acelerarla con RBF."}
|
||||
{R.locktime>0 && ` · locktime ${R.locktime}: no puede minarse antes de ese bloque (protección anti-fee-sniping, buena señal).`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Avisos */}
|
||||
{result.avisos.length===0 ? (
|
||||
<div style={{padding:"12px 14px",background:C.greenMuted,border:`1px solid ${C.green}40`,borderRadius:6,fontSize:"0.68rem",color:C.green,fontFamily:"monospace"}}>
|
||||
✓ No se detectan problemas de privacidad en la estructura de esta transacción.
|
||||
</div>
|
||||
) : result.avisos.map((a,i)=>(
|
||||
<div key={i} style={{padding:"12px 14px",background:C.bgCard,borderRadius:6,border:`1px solid ${colorNivel(a.nivel)}40`}}>
|
||||
<div style={{display:"flex",alignItems:"center",gap:8,marginBottom:8,flexWrap:"wrap"}}>
|
||||
<span style={{fontSize:"0.55rem",fontFamily:"monospace",fontWeight:700,color:colorNivel(a.nivel),border:`1px solid ${colorNivel(a.nivel)}50`,borderRadius:3,padding:"2px 6px"}}>{etiquetaNivel(a.nivel)}</span>
|
||||
<span style={{fontSize:"0.72rem",color:C.t1,fontWeight:600}}>{a.titulo}</span>
|
||||
</div>
|
||||
<div style={{fontSize:"0.65rem",color:C.t2,lineHeight:1.7,marginBottom:6}}>{a.hecho}</div>
|
||||
<div style={{fontSize:"0.65rem",color:C.t1,lineHeight:1.7}}>{a.consecuencia}</div>
|
||||
{a.quePuedesHacer&&(
|
||||
<div style={{marginTop:8,paddingTop:8,borderTop:`1px solid ${C.border}`,fontSize:"0.64rem",color:C.green,lineHeight:1.7}}>
|
||||
<strong>Qué puedes hacer:</strong> {a.quePuedesHacer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={{fontSize:"0.58rem",color:C.t3,fontFamily:"monospace",lineHeight:1.6}}>
|
||||
{R.size} bytes analizados · sin consultar el nodo · esta herramienta no firma ni modifica nada
|
||||
<div style={{padding:"12px 14px",background:C.bg,borderRadius:6,border:`1px solid ${C.green}40`}}>
|
||||
<div style={{display:"flex",alignItems:"center",gap:8,marginBottom:8}}>
|
||||
<span style={{color:C.green,fontSize:"1rem"}}>✓</span>
|
||||
<span style={{color:C.green,fontFamily:"monospace",fontSize:"0.78rem",fontWeight:700}}>PSBT válida</span>
|
||||
<Badge color={C.t2}>{result.size} bytes</Badge>
|
||||
</div>
|
||||
<div style={{fontSize:"0.68rem",color:C.t2,lineHeight:1.6}}>{result.note}</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
+2
-46
@@ -168,33 +168,10 @@ async function getDisk() {
|
||||
}
|
||||
|
||||
// ── 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() {
|
||||
const result = await sh("ps aux --no-headers --sort=-%mem | head -8");
|
||||
if (!result) return [];
|
||||
const filas = result.split("\n").map(line => {
|
||||
return result.split("\n").map(line => {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
// Nombre limpio: basename del ejecutable; si es un intérprete (node,
|
||||
// python...), añade el basename del script que ejecuta.
|
||||
@@ -213,33 +190,12 @@ async function getProcesses() {
|
||||
}
|
||||
command = command.slice(0, 24);
|
||||
return {
|
||||
pid: parts[1],
|
||||
user: parts[0],
|
||||
cpuMedia: parseFloat(parts[2]), // media desde el arranque (lo que da ps)
|
||||
cpu: parseFloat(parts[2]),
|
||||
mem: parseFloat(parts[3]),
|
||||
command,
|
||||
};
|
||||
}).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 ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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.
|
||||
@@ -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`);
|
||||
})();
|
||||
@@ -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`);
|
||||
})();
|
||||
@@ -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");
|
||||
})();
|
||||
@@ -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`);
|
||||
})();
|
||||
@@ -4,15 +4,8 @@ After=network.target bitcoind.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
# ── AJUSTA ESTAS DOS LÍNEAS ANTES DE INSTALARLO ──────────────────────────
|
||||
# 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
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
User=armg
|
||||
ExecStart=/usr/bin/node /home/armg/txoko/system-metrics.js
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
|
||||
Reference in New Issue
Block a user