Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4deb711d5 | ||
|
|
af49ea630b | ||
|
|
98621f956e | ||
|
|
d3caaf6186 | ||
|
|
96517894c5 | ||
|
|
0acfb35bf8 | ||
|
|
bb9b92d9b9 | ||
|
|
fd9127880b | ||
|
|
0223bb16ef | ||
|
|
a696e55bef | ||
|
|
8a5efad2ed | ||
|
|
6f721a535c | ||
|
|
020f9318aa | ||
|
|
fd2e38fd45 | ||
|
|
924b9069aa | ||
|
|
e0bc42c34e | ||
|
|
eac6208405 | ||
|
|
fdd80b689a | ||
|
|
f15d82a352 | ||
|
|
826b9cb8e2 | ||
|
|
29e8e184e8 | ||
|
|
2f5cc9a0ec | ||
|
|
5f4f891d1f | ||
|
|
27f23cc967 | ||
|
|
b37b9a125a | ||
|
|
c86134cc24 | ||
|
|
ebc1a8017f | ||
|
|
0ecd54eb1d | ||
|
|
84e16cb3fd | ||
|
|
d6dc8f6a90 | ||
|
|
14a33ed29a | ||
|
|
f5cffa3f8a | ||
|
|
de3ab5cc7e | ||
|
|
55297ecae6 | ||
|
|
2c46ceda31 | ||
|
|
1c25622b0e | ||
|
|
031c55714f | ||
|
|
35c74ea8f2 | ||
|
|
1ddacdf86e | ||
|
|
8ae135e739 | ||
|
|
4354e661c2 | ||
|
|
68ef8f2179 | ||
|
|
26a8650583 | ||
|
|
2b68a7b7c7 | ||
|
|
22ea8b5add | ||
|
|
aa7f23de33 | ||
|
|
0fc9cb8980 | ||
|
|
625fd9e989 | ||
|
|
d0b10d4a99 | ||
|
|
01fe01d39d | ||
|
|
57f9576860 | ||
|
|
dd78d35bdb | ||
|
|
19211af4c8 | ||
|
|
55a8cf456b | ||
|
|
a260e376bb | ||
|
|
37ae3aef70 | ||
|
|
ee67c2164e | ||
|
|
11b74c92f5 | ||
|
|
5aedec0cb3 | ||
|
|
fe2aa0597c | ||
|
|
6626cd1325 | ||
|
|
cb4535c35d | ||
|
|
e71bd08f76 | ||
|
|
8f902ba06e | ||
|
|
e64292531e |
@@ -26,3 +26,7 @@ TRASPASO.md
|
|||||||
PRUEBA-PERITAJE.md
|
PRUEBA-PERITAJE.md
|
||||||
HALLAZGOS-PERITAJE.md
|
HALLAZGOS-PERITAJE.md
|
||||||
MEJORAS.md
|
MEJORAS.md
|
||||||
|
COHERENCIA.md
|
||||||
|
|
||||||
|
GIT.md
|
||||||
|
REPASO.md
|
||||||
|
|||||||
+194
@@ -10,6 +10,142 @@ y el versionado sigue [Versionado Semántico](https://semver.org/lang/es/):
|
|||||||
- **MENOR** — características nuevas que no rompen lo anterior
|
- **MENOR** — características nuevas que no rompen lo anterior
|
||||||
- **PARCHE** — arreglos de errores
|
- **PARCHE** — arreglos de errores
|
||||||
|
|
||||||
|
---
|
||||||
|
## [1.13.0] — 2026-08-08
|
||||||
|
### Añadido
|
||||||
|
- **Nueva comprobación: direcciones que quedan vinculadas entre sí.** Era el
|
||||||
|
hueco de fondo del analizador: medía la privacidad desde el lado de quien
|
||||||
|
*construye* la transacción (¿se ve mi cambio?, ¿pago cifras redondas?) y no
|
||||||
|
desde el lado de los dueños de las monedas gastadas, que es donde el daño es
|
||||||
|
irreversible. Gastar N direcciones distintas a la vez las enlaza para siempre
|
||||||
|
por CIOH, y eso no se decía en ningún sitio. El peso sube con el número de
|
||||||
|
direcciones y no aplica en CoinJoin, donde romper esa vinculación es el
|
||||||
|
objetivo.
|
||||||
|
|
||||||
|
### Corregido
|
||||||
|
- **El explorador mostraba saldo cero en direcciones con mucho historial.** El
|
||||||
|
backend de Mempool sobre Fulcrum devuelve los importes a cero cuando la
|
||||||
|
dirección tiene muchas transacciones, aunque el contador venga bien — y la app
|
||||||
|
los presentaba como ciertos. Una dirección con 492 transacciones aparecía con
|
||||||
|
"Balance: 0,00000000 BTC". Ahora se detecta la inconsistencia (tener
|
||||||
|
transacciones y no haber recibido nada es imposible en la cadena), los
|
||||||
|
importes se muestran como "no disponible" y se explica por qué. El contador de
|
||||||
|
transacciones y el historial, que sí son correctos, se mantienen.
|
||||||
|
- Mismo arreglo en el perfil de dirección del peritaje: antes concluía "no es
|
||||||
|
hot wallet" cuando en realidad no había podido evaluar la señal de flujo.
|
||||||
|
- **El fingerprinting atribuía transacciones a Electrum por AUSENCIA de rasgos**
|
||||||
|
(locktime 0, sin RBF, sequence por defecto). Es justo al revés: Electrum
|
||||||
|
moderno pone locktime a la altura actual y señaliza RBF. Lo que esas señales
|
||||||
|
describen no es un monedero concreto, sino software que no configura nada.
|
||||||
|
Ahora Electrum exige señales positivas y aparece un resultado nuevo, **"sin
|
||||||
|
rasgos distintivos"**, que además no penaliza: no dejar huella es lo contrario
|
||||||
|
de tener una huella identificable.
|
||||||
|
|
||||||
|
---
|
||||||
|
## [1.12.1] — 2026-07-27
|
||||||
|
### Corregido
|
||||||
|
- **El monitor mostraba una CPU por proceso que engañaba.** La columna `%CPU`
|
||||||
|
de `ps aux` no es el consumo actual sino la **media desde que el proceso
|
||||||
|
arrancó** (tiempo de CPU dividido por tiempo de vida). Un proceso que trabajó
|
||||||
|
mucho hace tres días seguía apareciendo alto para siempre, y se leía como si
|
||||||
|
estuviera saturando la máquina. Se detectó porque los porcentajes por proceso
|
||||||
|
no cambiaban NUNCA entre lecturas mientras la CPU global sí variaba.
|
||||||
|
- Ahora se mide de verdad: dos lecturas de `/proc/PID/stat` separadas 500 ms,
|
||||||
|
el mismo método que ya usaba `getCpuUsage` para el total. Las dos esperas
|
||||||
|
corren en paralelo dentro del mismo `Promise.all`, así que no cuesta tiempo.
|
||||||
|
- La interfaz muestra el **porcentaje sobre el total de la máquina**, que es
|
||||||
|
lo que suele querer saberse, con el porcentaje de un núcleo y la media de
|
||||||
|
`ps` en el tooltip para poder comparar con `top`.
|
||||||
|
- Comprobado con carga artificial: el proceso ocupado marcaba 100% de un
|
||||||
|
núcleo (25% de un sistema de 4) mientras `ps` daba 152%, imposible para un
|
||||||
|
proceso de un solo hilo.
|
||||||
|
|
||||||
|
### Cambiado
|
||||||
|
- El UTXO Map pedía las transacciones de contexto con `fetchWithTimeout`
|
||||||
|
directo, saltándose la caché añadida en la 1.11.0. Era el único punto que no
|
||||||
|
la aprovechaba. Ahora pasa por `get()`, así que esas transacciones —ya
|
||||||
|
confirmadas, e inmutables— se guardan toda la sesión.
|
||||||
|
|
||||||
|
### Documentación
|
||||||
|
- **Reescrita la guía de instalación.** `SETUP.md` no era una guía: más de la
|
||||||
|
mitad eran los pasos personales de git y Gitea del autor ("PASO 1 — Crear el
|
||||||
|
repo en Gitea"), que se han movido a un documento privado. Ahora es una guía
|
||||||
|
real, con una comprobación tras cada paso para saber si vas bien sin
|
||||||
|
descubrirlo al final con una pantalla en blanco.
|
||||||
|
- **Corregida la configuración de nginx documentada, que estaba rota.** El
|
||||||
|
README indicaba `alias /var/www/txoko/dashboard.html` — un alias a un
|
||||||
|
ARCHIVO. Desde la 1.11.0, con las librerías en `vendor/` y rutas relativas,
|
||||||
|
eso hace que el navegador no las encuentre y la página quede en blanco sin
|
||||||
|
ningún error visible. Debe apuntar al **directorio**. Es el fallo más común
|
||||||
|
de esta instalación y ahora está avisado en tres sitios, con una comprobación
|
||||||
|
concreta (`curl` mirando el `content-type`, no solo el código 200) para
|
||||||
|
descartarlo.
|
||||||
|
- Los pasos de instalación no mencionaban las librerías del frontend, sin las
|
||||||
|
cuales la aplicación no arranca. Ya están integradas en el orden correcto.
|
||||||
|
- Eliminados del repositorio los datos personales del autor: rutas de su
|
||||||
|
máquina en la documentación y, sobre todo, su nombre de usuario del sistema
|
||||||
|
y su ruta de instalación dentro de `txoko-metrics.service`, que llevaban ahí
|
||||||
|
desde el primer commit. El servicio usa ahora marcadores que hay que ajustar
|
||||||
|
antes de instalarlo.
|
||||||
|
- README: estructura del repositorio actualizada (faltaban `tests/`,
|
||||||
|
`instalar-fuentes.sh` y `FIXTURES.md`) y sección de instalación reducida a un
|
||||||
|
resumen que remite a SETUP.md, para que haya una sola fuente de verdad.
|
||||||
|
- SETUP.md incluye ahora tabla de problemas frecuentes y cómo actualizar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## [1.12.0] — 2026-07-27
|
||||||
|
### Añadido
|
||||||
|
- **Auditoría de PSBT: revisar una transacción ANTES de firmarla.** Hasta ahora
|
||||||
|
toda la app era diagnóstico *a posteriori* — te contaba con detalle lo que ya
|
||||||
|
había pasado y no podías cambiar. Esta es la primera pieza que llega a tiempo.
|
||||||
|
Sustituye al antiguo "validador", que solo comprobaba que el archivo empezara
|
||||||
|
por `psbt` y decía su tamaño.
|
||||||
|
- **Parser completo de BIP174 escrito desde cero**, sin librerías, como el
|
||||||
|
resto del proyecto. Validado contra los cinco vectores inválidos que publica
|
||||||
|
el propio estándar: los rechaza los cinco, cada uno con un mensaje que
|
||||||
|
explica en castellano qué está mal (truncada, sin salidas, ya firmada,
|
||||||
|
sin transacción interna, con claves repetidas).
|
||||||
|
- **Todo el análisis es offline.** Una PSBT bien formada ya lleva dentro los
|
||||||
|
importes y scripts de sus entradas, así que no hace falta consultar el nodo:
|
||||||
|
funciona con el nodo sincronizando, y el nodo ni se entera.
|
||||||
|
- Acepta base64, hexadecimal o abrir el archivo `.psbt` directamente.
|
||||||
|
- **Aviso crítico sobre xpubs incrustados.** Las PSBT suelen llevar dentro las
|
||||||
|
claves públicas maestras de la cartera, y están hechas para compartirse — se
|
||||||
|
mandan por correo o chat para que el resto firme. Quien reciba el archivo
|
||||||
|
puede derivar todas las direcciones, presentes y futuras, y ver el saldo y
|
||||||
|
el historial completos. No puede gastar, pero lo ve todo. Ningún wallet
|
||||||
|
avisa de esto.
|
||||||
|
- Detecta además: cambio identificable por tipo de dirección, importes
|
||||||
|
redondos que delatan cuál es el pago, envíos a la propia cartera, carteras
|
||||||
|
multifirma (con su M-de-N) y PSBTs incompletas sin los importes de entrada.
|
||||||
|
- Cada aviso mantiene el formato del resto de la app — Hecho, Consecuencia y
|
||||||
|
**Qué puedes hacer** —, que aquí cobra sentido literal: todavía estás a
|
||||||
|
tiempo de cambiar la transacción.
|
||||||
|
|
||||||
|
### Corregido
|
||||||
|
- **Revisión de la criptografía watch-only.** Se verificó la derivación completa
|
||||||
|
contra los vectores oficiales de los estándares: RIPEMD-160 (los seis del
|
||||||
|
estándar, incluido el de un millón de caracteres), secp256k1, BIP32 (vectores
|
||||||
|
1 y 2) y bech32/BIP173. **La matemática es correcta y no se tocó.** Los fallos
|
||||||
|
estaban en la validación de la entrada:
|
||||||
|
- **No se comprobaba la suma de verificación del xpub.** Un carácter mal
|
||||||
|
copiado se aceptaba sin protestar y generaba 200 direcciones ajenas: el
|
||||||
|
usuario habría visto su cartera "sin actividad" y se habría quedado
|
||||||
|
tranquilo. Falso negativo silencioso, el mismo patrón que el resto de fallos
|
||||||
|
de esta jornada. Nuevo `decodeBase58Check`.
|
||||||
|
- **No se validaba la longitud** del material decodificado (deben ser 78
|
||||||
|
bytes) ni que la clave pública fuese comprimida (0x02/0x03).
|
||||||
|
- **`tpub` se trataba como mainnet.** La red se detectaba mirando si el texto
|
||||||
|
empezaba por "tb", "u" o "v" — y un `tpub`, el formato más común de testnet,
|
||||||
|
empieza por "t" pero no por "tb". Generaba direcciones `bc1…` a partir de
|
||||||
|
claves de testnet. Ahora se detecta por bytes de versión, que son
|
||||||
|
inequívocos, con las diez variantes (x/y/z/Y/Z y t/u/v/U/V).
|
||||||
|
- **`deriveChildPubkey` aceptaba índices endurecidos**, matemáticamente
|
||||||
|
imposibles desde una clave pública. No era alcanzable desde la interfaz,
|
||||||
|
pero una función criptográfica debe defenderse sola.
|
||||||
|
- Añadida la carpeta `tests/` con las cuatro baterías y su documentación,
|
||||||
|
incluido qué **no** cubren: no sustituyen una auditoría externa.
|
||||||
|
|
||||||
---
|
---
|
||||||
## [1.11.0] — 2026-07-27
|
## [1.11.0] — 2026-07-27
|
||||||
### Añadido
|
### Añadido
|
||||||
@@ -42,6 +178,50 @@ y el versionado sigue [Versionado Semántico](https://semver.org/lang/es/):
|
|||||||
`system-metrics.js` en el servidor.
|
`system-metrics.js` en el servidor.
|
||||||
- Medido: repetir un rastreo forense ya explorado cuesta **cero peticiones**.
|
- Medido: repetir un rastreo forense ya explorado cuesta **cero peticiones**.
|
||||||
|
|
||||||
|
### Cambiado
|
||||||
|
- **Un hit de OFAC ya no penaliza la banda de privacidad.** Era un error
|
||||||
|
conceptual con consecuencias ideológicas: que tus monedas hayan pasado por una
|
||||||
|
dirección sancionada no revela nada más sobre ti — tu privacidad es idéntica
|
||||||
|
antes y después. Lo que cambia es la probabilidad de que un servicio regulado
|
||||||
|
te bloquee un depósito, que es otro eje: censurabilidad, no privacidad.
|
||||||
|
Restar puntos ahí equivalía a dar por buena la idea de "monedas contaminadas",
|
||||||
|
justo la que la fungibilidad de Bitcoin niega. Se sigue informando del riesgo
|
||||||
|
real, ahora como aviso de censura y con el contexto que faltaba: la lista OFAC
|
||||||
|
es una decisión política de un gobierno concreto, no una determinación
|
||||||
|
judicial, y fuera de su jurisdicción solo llega a través de intermediarios que
|
||||||
|
la aplican.
|
||||||
|
- **RBF pasa a informativo, sin penalización.** Es buena práctica —permite
|
||||||
|
desatascar una transacción sin sobrepagar de entrada— y hoy lo activan casi
|
||||||
|
todos los wallets, así que distingue poco. Restar puntos empujaba al usuario
|
||||||
|
hacia una decisión peor para esconder una señal débil.
|
||||||
|
- Checks con inferencia fuerte (`round_numbers`, `unnecessary_input`,
|
||||||
|
`output_type_mismatch`) reescritos con el patrón Hecho / Interpretación /
|
||||||
|
Consecuencia que ya usaban `dust` e `input_type_mixing`. Antes afirmaban de
|
||||||
|
más ("un analista puede identificarlo sin ambigüedad"); ahora explican cuándo
|
||||||
|
la señal falla y por qué son PROBABLE y no CERTEZA.
|
||||||
|
- `timing`: el texto didáctico describía patrones entre varias transacciones
|
||||||
|
cuando el check solo ve una. Además se aclara que la hora mostrada es la del
|
||||||
|
bloque, no la de la firma — entre ambas puede haber horas de mempool.
|
||||||
|
|
||||||
|
### Documentación
|
||||||
|
- README: nueva sección **"Sí, esto es chain analysis"**. El peritaje usa las
|
||||||
|
mismas técnicas que las empresas de vigilancia de cadena y negarlo restaba
|
||||||
|
credibilidad ante quien lee el código. Se explica qué cambia —quién lo
|
||||||
|
ejecuta, sobre qué, dónde se detiene, quién se queda el informe— y el motivo
|
||||||
|
de fondo: la misma herramienta que sigue el rastro de un ladrón demuestra lo
|
||||||
|
fácil que es seguir el tuyo.
|
||||||
|
- El informe forense advierte del coste de denunciar: entregarlo vincula tu
|
||||||
|
identidad legal con esas direcciones de forma permanente, ante una autoridad
|
||||||
|
que puede compartir el expediente con empresas de análisis. Antes se
|
||||||
|
recomendaba denunciar sin mencionar el precio.
|
||||||
|
- Aviso junto a los botones de exportación: el archivo lleva tus direcciones y
|
||||||
|
tu declaración dentro. Nada ha salido de tu red hasta ese punto; a partir de
|
||||||
|
ahí depende de quien lo descarga.
|
||||||
|
- Corregido "un único archivo HTML autocontenido" (aparecía cuatro veces): desde
|
||||||
|
la 1.11.0 las librerías viven en `vendor/`. Se sustituye por una descripción
|
||||||
|
exacta que además dice más — lo que lees en el archivo es lo que se ejecuta,
|
||||||
|
no hay versión compilada que auditar por separado.
|
||||||
|
|
||||||
### Corregido
|
### Corregido
|
||||||
- **El informe ya no atribuye al actor direcciones del otro lado de un
|
- **El informe ya no atribuye al actor direcciones del otro lado de un
|
||||||
CoinJoin.** Detectado probando un rastro que atraviesa un Whirlpool real: el
|
CoinJoin.** Detectado probando un rastro que atraviesa un Whirlpool real: el
|
||||||
@@ -59,6 +239,20 @@ 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
|
`mixer` del conjunto de direcciones del actor y del union-find, y la
|
||||||
atribución por huella salta esos nodos.
|
atribución por huella salta esos nodos.
|
||||||
- Verificado con el mismo caso: de 10 direcciones atribuidas a 0.
|
- Verificado con el mismo caso: de 10 direcciones atribuidas a 0.
|
||||||
|
- **El informe de wallet ya no da un aprobado optimista cuando faltan datos.**
|
||||||
|
Mismo patrón que el fallo del peritaje, en la pieza central del proyecto:
|
||||||
|
`scanWallet` usaba `.catch(()=>[])` en las tres consultas del escaneo, así que
|
||||||
|
una dirección que el nodo no pudo servir quedaba indistinguible de una
|
||||||
|
dirección sin actividad. Sus transacciones no se traían, no contaban para la
|
||||||
|
reutilización, no entraban en el union-find y no aparecían en el historial —
|
||||||
|
y el informe daba su valoración de salud sin mencionar que le faltaban datos.
|
||||||
|
El sesgo iba siempre hacia el optimismo: menos actividad vista es mejor nota.
|
||||||
|
- Las tres consultas usan ahora `getStrict` y registran los fallos en
|
||||||
|
`report.scanErrors`, con la dirección, la rama y en qué fase ocurrió.
|
||||||
|
- Aviso en rojo **antes** de la banda de salud (una valoración leída sin saber
|
||||||
|
que faltan datos es peor que ninguna valoración), y bloque equivalente al
|
||||||
|
principio del export Markdown.
|
||||||
|
|
||||||
- **Tampoco atribuye al actor las direcciones de un custodio.** Misma raíz que
|
- **Tampoco atribuye al actor las direcciones de un custodio.** Misma raíz que
|
||||||
el fallo anterior, descubierta probando la poda por rama con un depósito real
|
el fallo anterior, descubierta probando la poda por rama con un depósito real
|
||||||
en Bitfinex: el cluster CIOH ya excluía las direcciones de custodio, pero la
|
en Bitfinex: el cluster CIOH ya excluía las direcciones de custodio, pero la
|
||||||
|
|||||||
@@ -22,17 +22,51 @@ Tan importante como saber qué hace es saber qué no hace:
|
|||||||
|
|
||||||
- **No envía datos a ningún servidor externo** — ni analíticas, ni telemetría, ni consultas a APIs de terceros
|
- **No envía datos a ningún servidor externo** — ni analíticas, ni telemetría, ni consultas a APIs de terceros
|
||||||
- **No toca claves privadas** — el watch-only usa solo tu clave pública (xpub); no puede gastar fondos ni conoce tu semilla
|
- **No toca claves privadas** — el watch-only usa solo tu clave pública (xpub); no puede gastar fondos ni conoce tu semilla
|
||||||
- **No identifica a otras personas** — es una herramienta de auto-auditoría, no de vigilancia
|
- **No llega nunca a una identidad** — se detiene en "hot wallet de [servicio]" o "custodio no identificado", y lo dice explícitamente. Ver la sección siguiente
|
||||||
- **No da consejos de inversión** — analiza privacidad on-chain, nada más
|
|
||||||
- **No afirma más de lo que sabe** — distingue siempre entre CERTEZA, PROBABLE e POSIBLE
|
- **No afirma más de lo que sabe** — distingue siempre entre CERTEZA, PROBABLE e POSIBLE
|
||||||
- **No tiene número de score exacto** — muestra bandas (ALTA/MEDIA/BAJA); la precisión numérica sería falsa
|
- **No tiene número de score exacto** — muestra bandas (ALTA/MEDIA/BAJA); la precisión numérica sería falsa
|
||||||
- **No muestra el contenido de OP_RETURN** — solo informa de su presencia y tamaño
|
- **No muestra el contenido de OP_RETURN** — solo informa de su presencia y tamaño
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Sí, esto es chain analysis
|
||||||
|
|
||||||
|
Conviene decirlo claro, porque el código está a la vista y cualquiera puede
|
||||||
|
comprobarlo: el peritaje forense aplica **las mismas técnicas que las empresas
|
||||||
|
de vigilancia de cadena**. Rastreo hacia adelante salto a salto, clustering por
|
||||||
|
CIOH, perfilado de direcciones, huella de software, un índice de direcciones de
|
||||||
|
exchanges. Es el arsenal de Chainalysis, técnica por técnica.
|
||||||
|
|
||||||
|
Fingir lo contrario sería insultar tu inteligencia. Lo que cambia no es el
|
||||||
|
método, es todo lo demás:
|
||||||
|
|
||||||
|
- **Quién lo ejecuta.** Corre en tu nodo, con tus datos, bajo tu control. No
|
||||||
|
hay un tercero que acumule los resultados ni los venda a quien pague.
|
||||||
|
- **Sobre quién.** Tu propia actividad, o el rastro de unas monedas que te
|
||||||
|
quitaron a ti. No es un servicio que perfile a desconocidos por encargo.
|
||||||
|
- **Dónde se detiene.** En un servicio, nunca en una persona. Y cuando una
|
||||||
|
heurística no llega, el informe dice que no llega en vez de rellenar el hueco.
|
||||||
|
- **Quién se queda el informe.** Tú. No sale de tu red y decides qué hacer con
|
||||||
|
él — incluido no hacer nada.
|
||||||
|
|
||||||
|
Y hay una razón más, la que de verdad justifica que esto exista:
|
||||||
|
|
||||||
|
> **La misma herramienta que sigue el rastro de un ladrón demuestra lo fácil
|
||||||
|
> que es seguir el tuyo.**
|
||||||
|
|
||||||
|
No hay forma más honesta de entender qué puede deducir un observador de tus
|
||||||
|
monedas que ejecutar sus heurísticas sobre ellas y leer el resultado. Un
|
||||||
|
adversario con estas capacidades ya existe, tenga o no tú una herramienta para
|
||||||
|
verlo. Conocer el arma no es adoptarla: es dejar de estar ciego ante ella.
|
||||||
|
|
||||||
|
Si esto te parece una línea demasiado fina, es una objeción razonable. El
|
||||||
|
código está entero en un archivo para que puedas juzgarlo tú.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Cómo verificar que nada sale de tu red
|
## Cómo verificar que nada sale de tu red
|
||||||
|
|
||||||
El código es un único archivo HTML autocontenido. Puedes auditarlo tú mismo:
|
Todo el código de análisis vive en un único archivo HTML, sin minificar y sin proceso de compilación. Puedes auditarlo tú mismo:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Buscar cualquier llamada a dominios externos
|
# Buscar cualquier llamada a dominios externos
|
||||||
@@ -62,6 +96,17 @@ Consecuencia práctica: **el dashboard funciona sin conexión a internet**. Solo
|
|||||||
- Bandas de privacidad con distinción explícita CERTEZA / PROBABLE / POSIBLE
|
- Bandas de privacidad con distinción explícita CERTEZA / PROBABLE / POSIBLE
|
||||||
- Exportación del informe en JSON y Markdown
|
- Exportación del informe en JSON y Markdown
|
||||||
|
|
||||||
|
**Auditoría de PSBT — antes de firmar**
|
||||||
|
|
||||||
|
La única función de la app que llega a tiempo: el resto te cuenta lo que ya
|
||||||
|
pasó, esta te avisa cuando todavía puedes cambiar la transacción.
|
||||||
|
|
||||||
|
- Parser completo de BIP174 escrito desde cero, validado contra los cinco vectores inválidos del propio estándar
|
||||||
|
- **Todo offline**: una PSBT ya lleva dentro los importes y scripts de sus entradas, así que no se consulta el nodo. Funciona con el nodo sincronizando
|
||||||
|
- Avisa de las **claves públicas maestras incrustadas**: las PSBT suelen llevarlas dentro y están hechas para compartirse — quien reciba el archivo puede ver todas tus direcciones, tu saldo y tu historial. No puede gastar, pero lo ve todo
|
||||||
|
- Detecta cambio identificable por tipo de dirección, importes redondos que delatan cuál es el pago, envíos a tu propia cartera, carteras multifirma y PSBTs incompletas
|
||||||
|
- Acepta base64, hexadecimal o el archivo `.psbt` que exporta Sparrow
|
||||||
|
|
||||||
**Peritaje forense**
|
**Peritaje forense**
|
||||||
- Rastreo de fondos robados o perdidos hacia adelante, salto a salto, desde una transacción de origen hasta un punto de parada natural (custodio identificado, dilución, CoinJoin, o fondos aún sin gastar)
|
- Rastreo de fondos robados o perdidos hacia adelante, salto a salto, desde una transacción de origen hasta un punto de parada natural (custodio identificado, dilución, CoinJoin, o fondos aún sin gastar)
|
||||||
- A demanda: tú decides cuándo se explora cada salto con el botón "Seguir el rastro" — nada corre sin que lo pidas, igual que el rastro de procedencia hacia atrás. Puedes generar el informe con lo explorado hasta ese momento, sin terminar el rastreo entero
|
- A demanda: tú decides cuándo se explora cada salto con el botón "Seguir el rastro" — nada corre sin que lo pidas, igual que el rastro de procedencia hacia atrás. Puedes generar el informe con lo explorado hasta ese momento, sin terminar el rastreo entero
|
||||||
@@ -89,7 +134,7 @@ Consecuencia práctica: **el dashboard funciona sin conexión a internet**. Solo
|
|||||||
- Conversor sat/BTC/fiat
|
- Conversor sat/BTC/fiat
|
||||||
- Validador de dirección
|
- Validador de dirección
|
||||||
- Detector OP_RETURN
|
- Detector OP_RETURN
|
||||||
- Decodificador PSBT y transacción raw
|
- Decodificador de transacción raw
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -117,66 +162,29 @@ Probado sobre Ubuntu Server 24.04 con HP EliteDesk (i5, 32GB RAM, 2TB NVMe).
|
|||||||
|
|
||||||
## Instalación
|
## Instalación
|
||||||
|
|
||||||
### 1. Clonar el repositorio
|
**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.
|
||||||
|
|
||||||
```bash
|
Resumen de lo que implica:
|
||||||
git clone https://git.bitcointxoko.org/pikaro/txoko-dashboard.git
|
|
||||||
cd txoko-dashboard
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Copiar el 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.
|
||||||
|
|
||||||
```bash
|
Un aviso que ahorra disgustos: en nginx, el `alias` del dashboard debe apuntar
|
||||||
cp dashboard.html /var/www/txoko/dashboard.html
|
al **directorio** (con barra final), no al archivo `dashboard.html`. Si apunta
|
||||||
# o donde lo sirvas con nginx
|
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.
|
||||||
|
|
||||||
### 3. Copiar el backend de métricas
|
Cuando termines, abre `http://TU-IP:4080/dashboard/` — **con la barra final** —,
|
||||||
|
pulsa CONFIG e introduce la URL de tu Mempool.
|
||||||
```bash
|
|
||||||
cp system-metrics.js /home/armg/txoko/system-metrics.js
|
|
||||||
```
|
|
||||||
|
|
||||||
Editar `system-metrics.js` y añadir tus credenciales RPC de Bitcoin Core
|
|
||||||
(el archivo del repo usa placeholders).
|
|
||||||
|
|
||||||
### 4. Configurar el servicio systemd
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo cp txoko-metrics.service /etc/systemd/system/
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
sudo systemctl enable txoko-metrics
|
|
||||||
sudo systemctl start txoko-metrics
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Configurar nginx
|
|
||||||
|
|
||||||
Añadir a tu configuración de nginx:
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
# Dashboard
|
|
||||||
location /dashboard {
|
|
||||||
alias /var/www/txoko/dashboard.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
# API Mempool (ajusta el puerto según tu instalación)
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass http://localhost:8999;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Métricas del sistema
|
|
||||||
location /system/ {
|
|
||||||
proxy_pass http://127.0.0.1:4082;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Abrir en el navegador
|
|
||||||
|
|
||||||
```
|
|
||||||
http://TU-IP-TAILSCALE:4080/dashboard
|
|
||||||
```
|
|
||||||
|
|
||||||
Introduce la URL de tu Mempool en el modal de configuración y empieza a analizar.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -184,16 +192,25 @@ Introduce la URL de tu Mempool en el modal de configuración y empieza a analiza
|
|||||||
|
|
||||||
```
|
```
|
||||||
txoko-dashboard/
|
txoko-dashboard/
|
||||||
├── dashboard.html # Frontend completo (HTML + CSS + JS en un solo archivo)
|
├── dashboard.html # La aplicación entera (HTML + CSS + JS en un archivo)
|
||||||
├── system-metrics.js # Backend de métricas del nodo (Node.js)
|
├── system-metrics.js # Monitor del nodo (Node.js) — opcional
|
||||||
├── txoko-metrics.service # Servicio systemd
|
├── txoko-metrics.service # Servicio systemd para el monitor
|
||||||
├── README.md
|
├── instalar-fuentes.sh # Descarga IBM Plex al nodo y genera su CSS
|
||||||
├── SETUP.md
|
├── 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
|
├── CHANGELOG.md
|
||||||
|
├── README.md
|
||||||
└── LICENSE
|
└── LICENSE
|
||||||
```
|
```
|
||||||
|
|
||||||
El frontend es un único archivo HTML autocontenido. Sin bundler, sin npm, sin proceso de build. Babel transpila el JSX en el navegador. Puedes auditarlo todo abriendo el archivo.
|
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,187 +1,93 @@
|
|||||||
# Cómo subir Txoko a Gitea — paso a paso
|
# Instalación de Txoko Node Dashboard
|
||||||
|
|
||||||
Instrucciones exactas. Copiar y pegar en la terminal del Mac.
|
Guía completa, de principio a fin. Sigue los pasos en orden y comprueba cada
|
||||||
No hace falta entender git para seguir esto.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## PASO 1 — Crear el repo en Gitea (una sola vez)
|
## Antes de empezar
|
||||||
|
|
||||||
1. Abre tu instancia de Gitea en el navegador
|
Txoko no habla con la red Bitcoin directamente: se apoya en cosas que ya
|
||||||
2. Clic en el **+** (arriba a la derecha) → "New Repository"
|
tienes montadas. Necesitas:
|
||||||
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) |
|
||||||
|
|
||||||
## PASO 2 — Configurar git en el Mac (una sola vez, si no lo tienes)
|
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:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git config --global user.name "tu nombre"
|
curl -s http://127.0.0.1:8999/api/v1/fees/recommended
|
||||||
git config --global user.email "tu@email.com"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Comprueba que git está instalado:
|
Debe devolver un JSON con comisiones. Si no responde, arregla eso primero:
|
||||||
```bash
|
Txoko no puede funcionar sin ello.
|
||||||
git --version
|
|
||||||
```
|
> **Nunca expongas estos puertos a internet.** Txoko está pensado para
|
||||||
Si no está: `brew install git`
|
> accederse por Tailscale, VPN o red local.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## PASO 3 — Crear el repo local y primer commit (una sola vez)
|
## Paso 1 — Descargar los archivos
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Crear carpeta del proyecto en el Mac
|
git clone https://git.bitcointxoko.org/pikaro/txoko-dashboard.git
|
||||||
mkdir ~/txoko-dashboard
|
cd 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 4 — Conectar con Gitea y subir (una sola vez)
|
## 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.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Sustituye la URL por la de tu repo de Gitea
|
sudo mkdir -p /var/www/txoko
|
||||||
git remote add origin https://gitea.tu-comunidad/tu-usuario/txoko-dashboard.git
|
sudo cp dashboard.html /var/www/txoko/
|
||||||
|
|
||||||
# Subir
|
|
||||||
git push -u origin main
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Gitea te pedirá usuario y contraseña la primera vez.
|
Puedes usar otra ruta; solo recuerda cuál es, porque aparece en los pasos 3 y 5.
|
||||||
Si quieres evitar introducirlos cada vez, crea un token en
|
|
||||||
Gitea → Settings → Applications → "Generate Token" y úsalo como contraseña.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## PASO 5 — Flujo de trabajo normal (cada vez que yo te dé un archivo nuevo)
|
## 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
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/txoko-dashboard
|
sudo mkdir -p /var/www/txoko/vendor
|
||||||
|
cd /var/www/txoko/vendor
|
||||||
# Copiar el archivo actualizado descargado de esta conversación
|
|
||||||
cp ~/Downloads/txoko-dashboard.html ./dashboard.html
|
|
||||||
|
|
||||||
# Ver qué cambió (opcional pero útil)
|
|
||||||
git diff dashboard.html
|
|
||||||
|
|
||||||
# Registrar el cambio con un mensaje descriptivo
|
|
||||||
git add dashboard.html
|
|
||||||
git commit -m "fix: descripción breve de lo que se arregló"
|
|
||||||
|
|
||||||
# Subir a Gitea
|
|
||||||
git push
|
|
||||||
|
|
||||||
# Copiar al nodo (igual que antes)
|
|
||||||
scp dashboard.html armg@100.116.19.86:/home/armg/txoko/dashboard.html
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Mensajes de commit — ejemplos
|
|
||||||
|
|
||||||
El mensaje va después de `-m` y describe QUÉ cambiaste.
|
|
||||||
No tiene que ser perfecto, solo útil para ti en el futuro.
|
|
||||||
|
|
||||||
```
|
|
||||||
"fix: timeout en consultas al nodo"
|
|
||||||
"feat: score por bandas ALTA/MEDIA/BAJA"
|
|
||||||
"fix: responsive pestaña Mempool en móvil"
|
|
||||||
"fix: detección Whirlpool con tolerancia 2%"
|
|
||||||
"feat: fingerprinting wallet completo"
|
|
||||||
"fix: umbral dust por tipo de salida"
|
|
||||||
"docs: actualizar README con nuevas funcionalidades"
|
|
||||||
```
|
|
||||||
|
|
||||||
Convención (opcional pero ordenada):
|
|
||||||
- `fix:` — corrige algo que no funcionaba bien
|
|
||||||
- `feat:` — añade algo nuevo
|
|
||||||
- `docs:` — solo documentación
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Si algo sale mal
|
|
||||||
|
|
||||||
**Subiste algo que no querías:**
|
|
||||||
```bash
|
|
||||||
git revert HEAD
|
|
||||||
git push
|
|
||||||
```
|
|
||||||
|
|
||||||
**Quieres volver a una versión anterior:**
|
|
||||||
```bash
|
|
||||||
git log --oneline # ver el historial
|
|
||||||
git checkout HASH_DEL_COMMIT -- dashboard.html # recuperar ese archivo
|
|
||||||
```
|
|
||||||
|
|
||||||
**Ver el historial:**
|
|
||||||
```bash
|
|
||||||
git log --oneline
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Resumen del flujo
|
|
||||||
|
|
||||||
```
|
|
||||||
Claude te da dashboard.html
|
|
||||||
↓
|
|
||||||
cp ~/Downloads/dashboard.html ./dashboard.html
|
|
||||||
↓
|
|
||||||
git add . && git commit -m "descripción"
|
|
||||||
↓
|
|
||||||
git push
|
|
||||||
↓
|
|
||||||
scp dashboard.html armg@100.116.19.86:/home/armg/txoko/
|
|
||||||
```
|
|
||||||
|
|
||||||
Cuatro comandos después de la descarga. Siempre los mismos.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Librerías del frontend (obligatorio)
|
|
||||||
|
|
||||||
El dashboard usa React y Babel. **Se sirven desde tu propio nodo, no desde un
|
|
||||||
CDN.** El motivo es de privacidad, no de comodidad: un CDN externo no ve qué
|
|
||||||
transacciones analizas, pero sí ve que usas Txoko, cuándo y desde qué IP
|
|
||||||
pública — exactamente la clase de metadato que esta herramienta enseña a
|
|
||||||
proteger. Sirviéndolas en local, *"ninguna consulta sale de tu red"* pasa a ser
|
|
||||||
literal, y el dashboard funciona sin conexión a internet.
|
|
||||||
|
|
||||||
Estas librerías **no están en el repo** (son de terceros y pesan ~3 MB). Se
|
|
||||||
descargan una vez, en el nodo:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo mkdir -p /usr/share/nginx/html/vendor
|
|
||||||
cd /usr/share/nginx/html/vendor
|
|
||||||
sudo curl -sSLO https://unpkg.com/react@18.3.1/umd/react.production.min.js
|
sudo curl -sSLO https://unpkg.com/react@18.3.1/umd/react.production.min.js
|
||||||
sudo curl -sSLO https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js
|
sudo curl -sSLO https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js
|
||||||
sudo curl -sSL -o babel.min.js https://unpkg.com/@babel/standalone@7.23.10/babel.min.js
|
sudo curl -sSL -o babel.min.js https://unpkg.com/@babel/standalone@7.23.10/babel.min.js
|
||||||
```
|
```
|
||||||
|
|
||||||
### Verifica lo que has descargado
|
**Comprueba que has recibido lo que esperabas.** No te fíes: verifícalo.
|
||||||
|
|
||||||
No te fíes: comprueba que los archivos son los que deben ser.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sha256sum *.js
|
sha256sum *.js
|
||||||
@@ -195,118 +101,218 @@ Debe dar exactamente esto:
|
|||||||
d949f1c3687aedadcedac85261865f29b17cd273997e7f6b2bfc53b2f9d4c4dd react.production.min.js
|
d949f1c3687aedadcedac85261865f29b17cd273997e7f6b2bfc53b2f9d4c4dd react.production.min.js
|
||||||
```
|
```
|
||||||
|
|
||||||
Si algún hash no coincide, **no uses esos archivos**: significa que has
|
Si alguno no coincide, **para aquí**: has recibido algo distinto de lo esperado.
|
||||||
recibido algo distinto a lo esperado.
|
|
||||||
|
|
||||||
Las versiones están fijadas a propósito (`18.3.1`, `7.23.10`). Usar un rango
|
Las versiones están fijadas a propósito. Usar un rango como `react@18` dejaría
|
||||||
como `react@18` dejaría que el servidor decidiera qué versión te entrega, y
|
que el servidor decidiera qué versión te entrega, y cambiaría con el tiempo sin
|
||||||
cambiaría con el tiempo sin que te enteres.
|
que te enteres.
|
||||||
|
|
||||||
### Dónde deben quedar los archivos
|
### Las fuentes
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Con la configuración típica de Mempool self-hosted:
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
location /dashboard {
|
|
||||||
alias /usr/share/nginx/html/;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
…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.
|
|
||||||
|
|
||||||
**Importante:** abre el dashboard **con la barra final** (`.../dashboard/`, no
|
|
||||||
`.../dashboard`). Sin ella, el navegador resuelve las rutas relativas un nivel
|
|
||||||
por encima y no encuentra las librerías.
|
|
||||||
|
|
||||||
Para comprobar que nginx las sirve bien, lo que importa no es solo el código
|
|
||||||
200 sino el tipo de contenido:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -sk -o /dev/null -w '%{http_code} %{content_type}\n' \
|
sudo /ruta/al/repo/instalar-fuentes.sh /var/www/txoko/vendor/fonts
|
||||||
https://localhost:4081/dashboard/vendor/react.production.min.js
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Debe responder `200 application/javascript`. Si devuelve `200 text/html`,
|
El script descarga IBM Plex (licencia libre OFL), comprueba cada archivo y
|
||||||
nginx está entregando otra cosa (por ejemplo el index de Mempool) y las rutas
|
aborta si algo falla. Son unos 350 KB.
|
||||||
no son las correctas.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## HTTPS para watch-only (opcional)
|
## Paso 4 — El monitor del sistema (opcional)
|
||||||
|
|
||||||
La función watch-only (derivar tus direcciones desde el xpub) usa la Web Crypto
|
Alimenta la pestaña NODO: CPU, RAM, disco, estado de Bitcoin Core y logs. Sin
|
||||||
API del navegador, que **solo funciona sobre HTTPS**. Si sirves el dashboard por
|
esto el resto de la aplicación funciona igual, solo que esa pestaña queda vacía.
|
||||||
HTTP, watch-only no estará disponible — el resto de la app funciona igual. Las
|
|
||||||
etiquetas BIP-329 no necesitan HTTPS.
|
|
||||||
|
|
||||||
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 autofirmado
|
|
||||||
|
|
||||||
Sustituye la IP por la de tu nodo (Tailscale, local, etc.):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
|
mkdir -p ~/txoko
|
||||||
-keyout /etc/ssl/private/txoko.key \
|
cp system-metrics.js ~/txoko/
|
||||||
-out /etc/ssl/certs/txoko.crt \
|
|
||||||
-subj "/CN=100.116.19.86" \
|
|
||||||
-addext "subjectAltName=IP:100.116.19.86"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Configurar nginx para servir HTTPS
|
Edita `~/txoko/system-metrics.js` y sustituye los dos marcadores por tus
|
||||||
|
credenciales RPC de Bitcoin Core (las de tu `bitcoin.conf`):
|
||||||
|
|
||||||
Añade un bloque `server` que escuche en un puerto con SSL (por ejemplo 4081),
|
```js
|
||||||
apuntando al certificado recién creado e incluyendo la misma configuración que
|
const RPC_USER = "TU_RPC_USER";
|
||||||
tu servidor HTTP:
|
const RPC_PASS = "TU_RPC_PASSWORD";
|
||||||
|
```
|
||||||
|
|
||||||
|
Instala el servicio. Antes revisa el `.service`, porque trae rutas y usuario
|
||||||
|
que probablemente tengas que ajustar a los tuyos:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nano txoko-metrics.service
|
||||||
|
sudo cp txoko-metrics.service /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now txoko-metrics
|
||||||
|
```
|
||||||
|
|
||||||
|
**Comprueba que arrancó:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl is-active txoko-metrics # debe decir: active
|
||||||
|
curl -s http://127.0.0.1:4082/system/info | head -c 200
|
||||||
|
```
|
||||||
|
|
||||||
|
> Si `systemctl status` muestra un punto que no es verde pero pone
|
||||||
|
> `active (running)`, está bien. Lo que importa es el texto.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Paso 5 — Configurar nginx
|
||||||
|
|
||||||
|
Aquí es donde más gente se atasca, así que léelo con calma.
|
||||||
|
|
||||||
|
Añade esto a tu configuración de nginx (si usas Mempool self-hosted, dentro del
|
||||||
|
mismo bloque `server`):
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
server {
|
# Dashboard — OJO: alias a un DIRECTORIO, con barra final
|
||||||
listen 4081 ssl;
|
location /dashboard/ {
|
||||||
listen [::]:4081 ssl;
|
alias /var/www/txoko/;
|
||||||
server_name _;
|
index dashboard.html;
|
||||||
ssl_certificate /etc/ssl/certs/txoko.crt;
|
try_files $uri $uri/ /dashboard/dashboard.html;
|
||||||
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.)
|
# API de Mempool — ajusta el puerto al de tu instalación
|
||||||
# Si ya tienes esos location en un snippet, basta con incluirlo:
|
location /api/ {
|
||||||
# include /etc/nginx/snippets/tu-config.conf;
|
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;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Si el bloque `location /dashboard` ya viene de un snippet que incluyes, **no lo
|
**El detalle que rompe la instalación:** el `alias` tiene que apuntar al
|
||||||
dupliques** dentro del server SSL: nginx dará error `duplicate location`.
|
**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.
|
||||||
|
|
||||||
Comprueba y recarga:
|
Recarga nginx:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo nginx -t && sudo systemctl reload nginx
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Confiar el certificado la primera vez
|
---
|
||||||
|
|
||||||
Abre `https://TU-IP:4081/dashboard/` en el navegador. Como el certificado es
|
## Paso 6 — Comprobar que funciona
|
||||||
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
|
Antes de abrir el navegador, verifica desde el propio nodo. Ajusta el puerto:
|
||||||
- **Chrome/Brave:** "Configuración avanzada" → "Acceder a TU-IP (no seguro)"
|
|
||||||
|
|
||||||
A partir de ahí el navegador recuerda la excepción y la Web Crypto API queda
|
```bash
|
||||||
disponible, así que watch-only funcionará.
|
# El dashboard llega
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:4080/dashboard/
|
||||||
|
|
||||||
> Nota: un certificado autofirmado es perfectamente válido para uso personal en
|
# Y las librerías TAMBIÉN — esto es lo que suele fallar
|
||||||
> tu propia red. El aviso del navegador existe porque no hay una autoridad
|
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' \
|
||||||
> certificadora de por medio, no porque la conexión sea insegura — el tráfico va
|
http://127.0.0.1:4080/dashboard/vendor/react.production.min.js
|
||||||
> cifrado igual.
|
```
|
||||||
|
|
||||||
|
La segunda debe responder **`200 application/javascript`**.
|
||||||
|
|
||||||
|
Si devuelve `200 text/html` la instalación **no** está bien: nginx te está
|
||||||
|
sirviendo otra cosa (normalmente el index de Mempool) en lugar del archivo.
|
||||||
|
Revisa el `alias` del paso 5. Fíjate en que aquí no basta con mirar el código
|
||||||
|
200 — hay que mirar el tipo de contenido.
|
||||||
|
|
||||||
|
Ahora sí, abre en el navegador:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://TU-IP:4080/dashboard/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Con la barra final.** Sin ella, el navegador busca las librerías un nivel por
|
||||||
|
encima y no las encuentra.
|
||||||
|
|
||||||
|
Pulsa **CONFIG** e introduce la URL de tu Mempool (por ejemplo
|
||||||
|
`http://TU-IP:4080`). Dale a PROBAR: si dice "Conexión exitosa", ya está.
|
||||||
|
|
||||||
|
### Si ves una página en blanco
|
||||||
|
|
||||||
|
Abre la consola del navegador (F12). Si aparece `SyntaxError: Unexpected
|
||||||
|
token '<'`, es exactamente el problema del `alias` del paso 5: nginx está
|
||||||
|
devolviendo HTML donde debería devolver JavaScript.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HTTPS — necesario para el watch-only (opcional)
|
||||||
|
|
||||||
|
La función watch-only (derivar tus direcciones desde el xpub) usa la Web Crypto
|
||||||
|
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.
|
||||||
|
|
||||||
|
Si accedes por Tailscale o red local no tendrás un certificado válido, así que
|
||||||
|
toca generar uno autofirmado.
|
||||||
|
|
||||||
|
### 1. Generar el certificado
|
||||||
|
|
||||||
|
Sustituye la IP por la de tu nodo:
|
||||||
|
|
||||||
|
```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"
|
||||||
|
```
|
||||||
|
|
||||||
|
El `subjectAltName` no es opcional: sin él los navegadores modernos rechazan el
|
||||||
|
certificado aunque el `CN` sea correcto.
|
||||||
|
|
||||||
|
### 2. Servirlo en nginx
|
||||||
|
|
||||||
|
Duplica tu bloque `server` en otro puerto (4081 en este ejemplo) añadiendo:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
listen 4081 ssl;
|
||||||
|
ssl_certificate /etc/ssl/certs/txoko.crt;
|
||||||
|
ssl_certificate_key /etc/ssl/private/txoko.key;
|
||||||
|
```
|
||||||
|
|
||||||
|
Los `location` son los mismos del paso 5. Recarga con `sudo nginx -t &&
|
||||||
|
sudo systemctl reload nginx`.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
sudo cp dashboard.html /var/www/txoko/
|
||||||
|
```
|
||||||
|
|
||||||
|
Y recarga el navegador con **Ctrl+Shift+R** (o Cmd+Shift+R en Mac) para saltarte
|
||||||
|
la caché.
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problemas frecuentes
|
||||||
|
|
||||||
|
| 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" |
|
||||||
|
|||||||
+585
-79
@@ -967,18 +967,47 @@
|
|||||||
// ─── Electrum ──────────────────────────────────────────────────
|
// ─── Electrum ──────────────────────────────────────────────────
|
||||||
// Guardia: Electrum usa P2WPKH por defecto. Si no hay inputs P2WPKH,
|
// Guardia: Electrum usa P2WPKH por defecto. Si no hay inputs P2WPKH,
|
||||||
// no tiene sentido puntuar Electrum (evita falsos positivos en P2TR/P2PKH).
|
// no tiene sentido puntuar Electrum (evita falsos positivos en P2TR/P2PKH).
|
||||||
|
// Antes se puntuaba Electrum por AUSENCIA de rasgos: locktime=0, sin
|
||||||
|
// RBF, sequence por defecto. Eso es exactamente al revés — Electrum
|
||||||
|
// moderno (4.x, desde 2020) pone locktime a la altura actual para
|
||||||
|
// protegerse del fee sniping y señaliza RBF por defecto. Lo que aquellas
|
||||||
|
// señales describen no es un wallet concreto, sino software que no
|
||||||
|
// configura nada: un script hecho a medida.
|
||||||
|
//
|
||||||
|
// El fallo salió analizando la transacción de un robo real: la app la
|
||||||
|
// atribuía a "Electrum (PROBABLE)" cuando sus rasgos eran justo los
|
||||||
|
// contrarios a los de Electrum. Un silencio leído como una afirmación.
|
||||||
if (allP2wpkh) {
|
if (allP2wpkh) {
|
||||||
let score = 2, sigs = ["inputs P2WPKH uniformes"]; // incluido por la guardia
|
let score = 0, sigs = [];
|
||||||
if (locktime === 0) { score+=1; sigs.push("locktime=0"); }
|
if (locktimeIsBlock) { score+=3; sigs.push("locktime=altura actual (anti-fee-sniping)"); }
|
||||||
if (!hasRbfOptin) { score+=1; sigs.push("sin RBF"); }
|
if (hasRbfOptin) { score+=2; sigs.push("RBF señalizado"); }
|
||||||
if (allInputs.every(v=>v.sequence===0xffffffff)) { score+=2; sigs.push("sequence=0xFFFFFFFF"); }
|
|
||||||
const allOutSameTypeAsIn = allOutputs.length > 0 &&
|
const allOutSameTypeAsIn = allOutputs.length > 0 &&
|
||||||
allOutputs.every(o => o.scriptpubkey_type === inTypes[0]);
|
allOutputs.every(o => o.scriptpubkey_type === inTypes[0]);
|
||||||
if (allOutSameTypeAsIn) { score+=1; sigs.push("outputs del mismo tipo que inputs"); }
|
if (allOutSameTypeAsIn) { score+=1; sigs.push("outputs del mismo tipo que inputs"); }
|
||||||
if (allOutputs.length === 2) { score+=1; sigs.push("2 outputs"); }
|
if (allOutputs.length === 2) { score+=1; sigs.push("2 outputs"); }
|
||||||
|
if (score >= 2) sigs.unshift("inputs P2WPKH uniformes");
|
||||||
|
// Hace falta al menos una señal POSITIVA fuerte, no solo ausencias.
|
||||||
if (score >= 5) results.push({ name:"Electrum", score, confidence: score>=6?"PROBABLE":"POSIBLE", signals:sigs });
|
if (score >= 5) results.push({ name:"Electrum", score, confidence: score>=6?"PROBABLE":"POSIBLE", signals:sigs });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Sin rasgos distintivos ────────────────────────────────────
|
||||||
|
// Una transacción sin locktime, sin RBF y con sequence al máximo no
|
||||||
|
// lleva ninguna de las marcas que dejan los monederos actuales. Decirlo
|
||||||
|
// es más honesto que forzar un nombre: es lo que produce una librería
|
||||||
|
// en crudo o un script propio, y también algún wallet antiguo.
|
||||||
|
{
|
||||||
|
const sinLocktime = locktime === 0;
|
||||||
|
const sinRbf = !hasRbfOptin;
|
||||||
|
const seqPorDefecto = allInputs.length > 0 && allInputs.every(v=>v.sequence===0xffffffff);
|
||||||
|
if (sinLocktime && sinRbf && seqPorDefecto && results.length === 0) {
|
||||||
|
results.push({
|
||||||
|
name: "sin rasgos distintivos", score: 3, confidence: "POSIBLE",
|
||||||
|
signals: ["locktime=0", "sin RBF", "sequence=0xFFFFFFFF"],
|
||||||
|
note: "No lleva las marcas que dejan los monederos habituales. Compatible con una librería usada en crudo, un script propio o software antiguo — pero no identifica a ninguno en concreto.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── BlueWallet / mobile ───────────────────────────────────────
|
// ─── BlueWallet / mobile ───────────────────────────────────────
|
||||||
// Corregido: eliminado doble-conteo de "1 input P2WPKH" (antes sumaba 4 pts solo).
|
// Corregido: eliminado doble-conteo de "1 input P2WPKH" (antes sumaba 4 pts solo).
|
||||||
// Umbral subido a 6 para no dispararse con cualquier tx simple genérica.
|
// Umbral subido a 6 para no dispararse con cualquier tx simple genérica.
|
||||||
@@ -1055,8 +1084,27 @@
|
|||||||
for (const c of str) { if (c === "1") leadingZeros++; else break; }
|
for (const c of str) { if (c === "1") leadingZeros++; else break; }
|
||||||
const full = new Uint8Array(leadingZeros + body.length);
|
const full = new Uint8Array(leadingZeros + body.length);
|
||||||
full.set(body, leadingZeros);
|
full.set(body, leadingZeros);
|
||||||
// full = 82 bytes (78 payload + 4 checksum); devolvemos el payload
|
return full; // completo: payload + 4 bytes de checksum
|
||||||
return full.slice(0, full.length - 4);
|
},
|
||||||
|
|
||||||
|
// 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;
|
||||||
},
|
},
|
||||||
|
|
||||||
// SHA256 doble (para checksum base58)
|
// SHA256 doble (para checksum base58)
|
||||||
@@ -1153,7 +1201,13 @@
|
|||||||
|
|
||||||
// Derivación BIP32 de clave pública hija (solo clave pública, sin hardened)
|
// Derivación BIP32 de clave pública hija (solo clave pública, sin hardened)
|
||||||
const deriveChildPubkey = async (parentPub, parentChain, index) => {
|
const deriveChildPubkey = async (parentPub, parentChain, index) => {
|
||||||
const indexBytes = B32.u32be(index); // index < 0x80000000 (no hardened)
|
// 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 data = B32.concat(parentPub, indexBytes);
|
const data = B32.concat(parentPub, indexBytes);
|
||||||
const I = await B32.hmac512(parentChain, data);
|
const I = await B32.hmac512(parentChain, data);
|
||||||
const IL = I.slice(0, 32);
|
const IL = I.slice(0, 32);
|
||||||
@@ -1272,17 +1326,42 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Derivar N direcciones desde xpub (rama 0=recepción, 1=cambio)
|
// 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) => {
|
const deriveAddresses = async (xpubStr, count=20) => {
|
||||||
// Decodificar xpub/zpub
|
// decodeBase58Check comprueba la suma de verificación: un carácter mal
|
||||||
const raw = B32.decodeBase58(xpubStr.trim());
|
// copiado se detecta aquí y no acaba generando direcciones ajenas.
|
||||||
|
const raw = await B32.decodeBase58Check(xpubStr.trim());
|
||||||
// raw[0..3]=version, [4]=depth, [5..8]=fingerprint, [9..12]=childIndex
|
// raw[0..3]=version, [4]=depth, [5..8]=fingerprint, [9..12]=childIndex
|
||||||
// [13..44]=chainCode, [45..77]=pubKey
|
// [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 chainCode = raw.slice(13, 45);
|
||||||
const pubKey = raw.slice(45, 78);
|
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
|
// 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 fingerprint = Array.from(raw.slice(5, 9)).map(b => b.toString(16).padStart(2,"0")).join("");
|
||||||
|
|
||||||
const hrp = xpubStr.startsWith("tb") || xpubStr.startsWith("u") || xpubStr.startsWith("v") ? "tb" : "bc";
|
const hrp = info.red;
|
||||||
|
|
||||||
const result = { receive: [], change: [], fingerprint };
|
const result = { receive: [], change: [], fingerprint };
|
||||||
for (const [branch, label] of [[0,"receive"],[1,"change"]]) {
|
for (const [branch, label] of [[0,"receive"],[1,"change"]]) {
|
||||||
@@ -1480,8 +1559,10 @@
|
|||||||
input_reuse: 25, input_type_mixing: 14, output_type_mismatch: 8,
|
input_reuse: 25, input_type_mixing: 14, output_type_mismatch: 8,
|
||||||
round_numbers: 10, rbf: 5, peeling: 8,
|
round_numbers: 10, rbf: 5, peeling: 8,
|
||||||
unnecessary_input: 12, dust: 8, change_detection: 10,
|
unnecessary_input: 12, dust: 8, change_detection: 10,
|
||||||
wallet_fingerprint: 6, batch_payment: 45, entity_ofac: 20, op_return: 30, legacy_type: 15,
|
wallet_fingerprint: 6, batch_payment: 45, op_return: 30, legacy_type: 15,
|
||||||
};
|
};
|
||||||
|
// entity_ofac y rbf no aparecen aquí a propósito: ninguno de los dos
|
||||||
|
// reduce tu privacidad. Ver sus checks para el razonamiento.
|
||||||
let deductions = 0;
|
let deductions = 0;
|
||||||
|
|
||||||
const totalIn = tx.vin.reduce((s,v)=>s+(v.prevout?v.prevout.value:0),0);
|
const totalIn = tx.vin.reduce((s,v)=>s+(v.prevout?v.prevout.value:0),0);
|
||||||
@@ -1579,6 +1660,48 @@
|
|||||||
});
|
});
|
||||||
if (hasInputReuse) deductions += weights.input_reuse;
|
if (hasInputReuse) deductions += weights.input_reuse;
|
||||||
|
|
||||||
|
// ── 1b. Vinculación de direcciones al gastarlas juntas (CIOH) ──────
|
||||||
|
// Faltaba, y es el hueco más grande que tenía el analizador: se medía
|
||||||
|
// la privacidad desde el lado de quien CONSTRUYE la transacción (¿se ve
|
||||||
|
// mi cambio?, ¿pago cifras redondas?) y no desde el lado de los dueños
|
||||||
|
// de las monedas gastadas, que es donde ocurre el daño irreversible.
|
||||||
|
//
|
||||||
|
// Gastar N direcciones distintas en una misma transacción las enlaza
|
||||||
|
// para siempre a ojos de cualquiera. Es la heurística CIOH, la más
|
||||||
|
// fiable del análisis de cadena, y aquí no se aplicaba a la propia
|
||||||
|
// transacción que se está mirando — solo al conjunto del wallet.
|
||||||
|
//
|
||||||
|
// Se detectó analizando una consolidación real de 491 direcciones: la
|
||||||
|
// app la calificaba de privacidad ALTA porque el cambio no se
|
||||||
|
// distinguía, sin mencionar que 491 direcciones acababan de quedar
|
||||||
|
// vinculadas en público.
|
||||||
|
//
|
||||||
|
// No aplica en CoinJoin: ahí las entradas son de personas distintas y
|
||||||
|
// esa es justamente la excepción clásica a CIOH.
|
||||||
|
const nVinculadas = uniqueInputAddrs.size;
|
||||||
|
const hayVinculacion = nVinculadas > 1 && !likelyCJ;
|
||||||
|
// Escalonado: consolidar dos monedas es rutina; juntar decenas es otra
|
||||||
|
// cosa. El peso sube con el número de direcciones que quedan atadas.
|
||||||
|
const pesoVinculacion = !hayVinculacion ? 0
|
||||||
|
: nVinculadas >= 20 ? 30
|
||||||
|
: nVinculadas >= 10 ? 22
|
||||||
|
: nVinculadas >= 5 ? 15
|
||||||
|
: 8;
|
||||||
|
checks.push({
|
||||||
|
id:"input_linkage", label:"Direcciones que quedan vinculadas entre sí", certainty:"CERTEZA",
|
||||||
|
pass: !hayVinculacion,
|
||||||
|
informational: likelyCJ && nVinculadas > 1,
|
||||||
|
actionability: "evitable",
|
||||||
|
detail: !hayVinculacion
|
||||||
|
? (likelyCJ && nVinculadas > 1
|
||||||
|
? `Se gastan ${nVinculadas} direcciones distintas, pero en un CoinJoin las entradas son de participantes diferentes: la vinculación por CIOH no aplica aquí. De hecho, romperla es el objetivo de la mezcla.`
|
||||||
|
: "Solo se gasta una dirección, así que esta transacción no enlaza direcciones entre sí.")
|
||||||
|
: `Hecho (certeza): esta transacción gasta ${nVinculadas} direcciones distintas a la vez. Interpretación (certeza para un observador): quien mira la cadena asume que las ${nVinculadas} pertenecen al mismo dueño, porque hizo falta la clave de todas para firmar. Consecuencia: quedan enlazadas de forma permanente y pública. Si alguna de ellas se asocia alguna vez a una identidad, arrastra a las demás — y esto no se puede deshacer.`,
|
||||||
|
didactic: "Es la heurística CIOH (common input ownership), la más fiable que existe en el análisis de cadena y la primera que aplica cualquiera que observe. Cada vez que gastas varias monedas juntas, le dices al mundo que son de la misma persona. Consolidar cuando las comisiones están baratas es tentador y sale caro en privacidad: media docena de consolidaciones descuidadas bastan para reconstruir un wallet entero. La forma de evitarlo es gastar monedas de una en una cuando su origen no debe relacionarse — que es justo lo contrario de lo que invita a hacer una comisión baja.",
|
||||||
|
penalty: pesoVinculacion,
|
||||||
|
});
|
||||||
|
if (hayVinculacion) deductions += pesoVinculacion;
|
||||||
|
|
||||||
// ── 2. Mezcla de tipos en inputs ──────────────────────────────────
|
// ── 2. Mezcla de tipos en inputs ──────────────────────────────────
|
||||||
const inTypes = [...new Set(tx.vin.map(v=>v.prevout?.scriptpubkey_type).filter(Boolean))];
|
const inTypes = [...new Set(tx.vin.map(v=>v.prevout?.scriptpubkey_type).filter(Boolean))];
|
||||||
const outTypes = [...new Set(tx.vout.map(v=>v.scriptpubkey_type).filter(Boolean))];
|
const outTypes = [...new Set(tx.vout.map(v=>v.scriptpubkey_type).filter(Boolean))];
|
||||||
@@ -1616,7 +1739,7 @@
|
|||||||
id:"output_type_mismatch", label:"Tipo de output revela el cambio", certainty:"PROBABLE", pass:!hasOutputMismatch,
|
id:"output_type_mismatch", label:"Tipo de output revela el cambio", certainty:"PROBABLE", pass:!hasOutputMismatch,
|
||||||
actionability: "no_corregible",
|
actionability: "no_corregible",
|
||||||
detail: hasOutputMismatch ? mismatchDetail : "Los outputs son del mismo tipo que los inputs — no se puede distinguir cuál es el cambio por tipo de script.",
|
detail: hasOutputMismatch ? mismatchDetail : "Los outputs son del mismo tipo que los inputs — no se puede distinguir cuál es el cambio por tipo de script.",
|
||||||
didactic: "Cuando pagas a alguien que usa un tipo de script distinto al tuyo, tu output de cambio queda identificado porque es el único del mismo tipo que tus inputs. Un analista puede identificarlo sin ambigüedad.",
|
didactic: "Tu wallet devuelve el cambio a una dirección del mismo tipo que las que gasta. Así que si pagas a alguien con un formato distinto al tuyo, el cambio se delata solo: es la salida que coincide con tus entradas. La señal es fuerte, pero no infalible — si quien cobra usa tu mismo formato, o si te pagas a ti mismo, deja de distinguirse. Por eso es probable y no certeza.",
|
||||||
penalty: weights.output_type_mismatch,
|
penalty: weights.output_type_mismatch,
|
||||||
});
|
});
|
||||||
if (hasOutputMismatch) deductions += weights.output_type_mismatch;
|
if (hasOutputMismatch) deductions += weights.output_type_mismatch;
|
||||||
@@ -1642,25 +1765,28 @@
|
|||||||
id:"round_numbers", label:"Output de valor redondo", certainty:"PROBABLE", pass:!hasRound,
|
id:"round_numbers", label:"Output de valor redondo", certainty:"PROBABLE", pass:!hasRound,
|
||||||
actionability: "evitable",
|
actionability: "evitable",
|
||||||
detail: hasRound
|
detail: hasRound
|
||||||
? `Output de ${(roundOutputs[0].value/1e8).toFixed(6)} BTC es valor redondo — con alta probabilidad ese es el pago y el otro output es el cambio.`
|
? `Hecho (certeza): uno de los dos outputs vale exactamente ${(roundOutputs[0].value/1e8).toFixed(6)} BTC, una cifra redonda. Interpretación (probable): las personas pagan cantidades redondas y el cambio sale de la resta, así que ese suele ser el pago y el otro el cambio. No siempre: una consolidación o un pago calculado al céntimo rompen la regla. Consecuencia: si acierta, queda señalada la dirección de cambio del emisor — y con ella, por dónde seguir sus gastos futuros.`
|
||||||
: "No se detectan outputs de valor redondo en una estructura de 2 outputs.",
|
: "No se detectan outputs de valor redondo en una estructura de 2 outputs.",
|
||||||
didactic: "Las personas pagan cantidades redondas (0.001, 0.01, 0.1 BTC). Con dos outputs, el redondo es el pago y el irregular es el cambio. Esto identifica la dirección de cambio del emisor y permite rastrear sus futuros gastos.",
|
didactic: "Las personas pagan cantidades redondas (0,001, 0,01, 0,1 BTC) y el cambio es lo que sobra, con todos sus decimales. De ahí la regla: en una transacción de dos salidas, la redonda suele ser el pago y la irregular el cambio. Suele, no siempre — hay consolidaciones y pagos calculados al céntimo que la desmienten. Cuando acierta, identifica la dirección de cambio del emisor, que es la puerta para seguir sus gastos posteriores.",
|
||||||
penalty: weights.round_numbers,
|
penalty: weights.round_numbers,
|
||||||
});
|
});
|
||||||
if (hasRound) deductions += weights.round_numbers;
|
if (hasRound) deductions += weights.round_numbers;
|
||||||
|
|
||||||
// ── 6. RBF ────────────────────────────────────────────────────────
|
// ── 6. RBF ────────────────────────────────────────────────────────
|
||||||
const rbfEnabled = tx.vin.some(v=>typeof v.sequence==="number" && v.sequence < 0xfffffffe);
|
const rbfEnabled = tx.vin.some(v=>typeof v.sequence==="number" && v.sequence < 0xfffffffe);
|
||||||
|
// Informativo, NO penaliza: RBF es buena práctica (te deja desatascar
|
||||||
|
// una transacción sin sobrepagar de entrada) y hoy es tan común que
|
||||||
|
// distingue poco. Restar puntos por usarlo empujaría al usuario hacia
|
||||||
|
// una decisión peor para ahorrar una señal débil — mal negocio.
|
||||||
checks.push({
|
checks.push({
|
||||||
id:"rbf", label:"RBF activado (Replace-by-Fee)", certainty:"CERTEZA", pass:!rbfEnabled,
|
id:"rbf", label:"RBF activado (Replace-by-Fee)", certainty:"CERTEZA", pass:true,
|
||||||
|
informational: true,
|
||||||
actionability: "wallet",
|
actionability: "wallet",
|
||||||
detail: rbfEnabled
|
detail: rbfEnabled
|
||||||
? "RBF opt-in activado (sequence < 0xFFFFFFFE). Revela software de wallet y permite correlacionar transacciones reemplazadas en la mempool."
|
? "Hecho (certeza): RBF opt-in activado (sequence < 0xFFFFFFFE). Interpretación (débil): es un rasgo del wallet que lo firmó, pero hoy lo activan por defecto la mayoría, así que distingue poco. Consecuencia: si llegaste a reemplazar la transacción, un observador con vista a la mempool pudo ver ambas versiones y relacionarlas."
|
||||||
: "RBF no activado.",
|
: "Hecho (certeza): RBF no activado — la transacción no era reemplazable.",
|
||||||
didactic: "Replace-by-Fee permite sustituir una transacción pendiente por otra con mayor fee. Bitcoin Core lo activa por defecto desde v0.12. Su presencia es una firma de wallet que los analistas usan para vincular transacciones relacionadas.",
|
didactic: "Replace-by-Fee permite sustituir una transacción pendiente por otra con más comisión. Bitcoin Core lo activa por defecto desde la v0.12. No resta privacidad y sí evita que te quedes atascado pagando de más «por si acaso», así que aquí es informativo y no penaliza: no tiene sentido empujarte a gastar peor para esconder una señal que casi todo el mundo emite igual.",
|
||||||
penalty: weights.rbf,
|
|
||||||
});
|
});
|
||||||
if (rbfEnabled) deductions += weights.rbf;
|
|
||||||
|
|
||||||
// ── 7. Unnecessary input ─────────────────────────────────────────
|
// ── 7. Unnecessary input ─────────────────────────────────────────
|
||||||
let hasUnnecessaryInput = false;
|
let hasUnnecessaryInput = false;
|
||||||
@@ -1692,7 +1818,7 @@
|
|||||||
detail: likelyCJ
|
detail: likelyCJ
|
||||||
? `En un CoinJoin todos los inputs son necesarios por diseño del protocolo — cada participante aporta los suyos. Esta heurística no aplica.`
|
? `En un CoinJoin todos los inputs son necesarios por diseño del protocolo — cada participante aporta los suyos. Esta heurística no aplica.`
|
||||||
: (hasUnnecessaryInput ? unnecessaryDetail : "No se detectan inputs claramente innecesarios para cubrir el pago."),
|
: (hasUnnecessaryInput ? unnecessaryDetail : "No se detectan inputs claramente innecesarios para cubrir el pago."),
|
||||||
didactic: "Si una transacción tiene más inputs de los necesarios para cubrir el pago, el emisor estaba consolidando UTXOs. Esto revela que esos UTXOs pertenecen al mismo propietario y es una de las heurísticas más usadas en chain analysis real.",
|
didactic: "Si una transacción aporta más entradas de las que hacían falta para cubrir el pago, lo habitual es que el emisor estuviera consolidando monedas sueltas. Gastarlas juntas es lo que las delata: cualquiera que mire asume que son del mismo dueño. Esa suposición —la heurística CIOH— es la más usada del análisis de cadena y acierta casi siempre, pero no es una ley: un CoinJoin o un pago colaborativo la desmienten por diseño. Por eso aquí se marca como probable y no como certeza.",
|
||||||
penalty: likelyCJ ? 0 : weights.unnecessary_input,
|
penalty: likelyCJ ? 0 : weights.unnecessary_input,
|
||||||
});
|
});
|
||||||
// En CoinJoin múltiples inputs son la estructura del protocolo — no penaliza.
|
// En CoinJoin múltiples inputs son la estructura del protocolo — no penaliza.
|
||||||
@@ -1829,20 +1955,25 @@
|
|||||||
checks.push({
|
checks.push({
|
||||||
id:"wallet_fingerprint", label:"Fingerprinting de wallet",
|
id:"wallet_fingerprint", label:"Fingerprinting de wallet",
|
||||||
certainty: detectedWallets[0]?.confidence||"POSIBLE",
|
certainty: detectedWallets[0]?.confidence||"POSIBLE",
|
||||||
pass: likelyCJ ? true : detectedWallets.length===0,
|
pass: likelyCJ ? true : !detectedWallets.some(w => w.name !== "sin rasgos distintivos"),
|
||||||
informational: likelyCJ && detectedWallets.length > 0,
|
informational: (likelyCJ && detectedWallets.length > 0) ||
|
||||||
|
(detectedWallets.length > 0 && !detectedWallets.some(w => w.name !== "sin rasgos distintivos")),
|
||||||
actionability: likelyCJ ? null : "wallet",
|
actionability: likelyCJ ? null : "wallet",
|
||||||
detail: detectedWallets.length > 0
|
detail: detectedWallets.length > 0
|
||||||
? (likelyCJ
|
? (likelyCJ
|
||||||
? `En un CoinJoin identificar el software es informativo, no un problema. ${detectedWallets.map(w=>`${w.name} (${w.confidence}): ${w.signals.join(", ")}`).join(" · ")}`
|
? `En un CoinJoin identificar el software es informativo, no un problema. ${detectedWallets.map(w=>`${w.name} (${w.confidence}): ${w.signals.join(", ")}`).join(" · ")}`
|
||||||
: detectedWallets.map(w=>`${w.name} (${w.confidence}): ${w.signals.join(", ")}`).join(" · "))
|
: detectedWallets.map(w=>`${w.name} (${w.confidence}): ${w.signals.join(", ")}${w.note?` — ${w.note}`:""}`).join(" · "))
|
||||||
: "No se detecta un patrón de wallet específico con suficiente certeza.",
|
: "No se detecta un patrón de wallet específico con suficiente certeza.",
|
||||||
didactic: "El objetivo no es ser invisible sino ser indistinguible de millones de usuarios del mismo wallet. Un fingerprint de Bitcoin Core lo comparten millones de transacciones — no revela nada útil. Un fingerprint de Exodus o un wallet minoritario pertenece a un conjunto mucho menor y es más revelador. En un CoinJoin, el tipo de mezcla ya sugiere el software usado.",
|
didactic: "El objetivo no es ser invisible sino ser indistinguible de millones de usuarios del mismo wallet. Un fingerprint de Bitcoin Core lo comparten millones de transacciones — no revela nada útil. Un fingerprint de Exodus o un wallet minoritario pertenece a un conjunto mucho menor y es más revelador. En un CoinJoin, el tipo de mezcla ya sugiere el software usado.",
|
||||||
penalty: likelyCJ ? 0 : weights.wallet_fingerprint,
|
penalty: likelyCJ ? 0 : weights.wallet_fingerprint,
|
||||||
});
|
});
|
||||||
// En CoinJoin no penaliza (identificar el wallet es esperado, no un fallo).
|
// En CoinJoin no penaliza (identificar el wallet es esperado, no un fallo).
|
||||||
// Fuera de CoinJoin, solo penaliza si se detectó un wallet estructural real.
|
// Fuera de CoinJoin, solo penaliza si se identificó un software concreto:
|
||||||
if (!likelyCJ && detectedWallets.length > 0) deductions += weights.wallet_fingerprint;
|
// "sin rasgos distintivos" es lo contrario de una huella — significa que
|
||||||
|
// la transacción no se puede atribuir a ningún monedero, que si acaso
|
||||||
|
// juega a favor. Penalizarlo sería cobrar por no dejar rastro.
|
||||||
|
const huellaReal = detectedWallets.filter(w => w.name !== "sin rasgos distintivos");
|
||||||
|
if (!likelyCJ && huellaReal.length > 0) deductions += weights.wallet_fingerprint;
|
||||||
|
|
||||||
// ── 13. PayJoin — informativo ─────────────────────────────────────
|
// ── 13. PayJoin — informativo ─────────────────────────────────────
|
||||||
checks.push({
|
checks.push({
|
||||||
@@ -1905,13 +2036,13 @@
|
|||||||
const zone = hour >= 6 && hour <= 10 ? "mañana temprana (Europa/África)" :
|
const zone = hour >= 6 && hour <= 10 ? "mañana temprana (Europa/África)" :
|
||||||
hour >= 14 && hour <= 18 ? "tarde europea / mañana americana" :
|
hour >= 14 && hour <= 18 ? "tarde europea / mañana americana" :
|
||||||
hour >= 22 || hour <= 2 ? "madrugada Europa / tarde América" : "horario sin patrón claro";
|
hour >= 22 || hour <= 2 ? "madrugada Europa / tarde América" : "horario sin patrón claro";
|
||||||
timingDetail = `Confirmada el ${dayNames[day]} a las ${hour}:00 UTC — ${zone}.${day===0||day===6?" · fin de semana":""}`;
|
timingDetail = `Hecho (certeza): el bloque que la confirmó se minó el ${dayNames[day]} a las ${hour}:00 UTC${day===0||day===6?" (fin de semana)":""}. Interpretación (posible): compatible con ${zone}, pero con una sola transacción no se puede afirmar nada — y el bloque se mina cuando se mina, no cuando se firmó la transacción, que pudo esperar minutos u horas en la mempool. Consecuencia: ninguna por sí sola; el horario solo sirve a quien acumula muchas de tus transacciones y busca un patrón.`;
|
||||||
}
|
}
|
||||||
checks.push({
|
checks.push({
|
||||||
id:"timing", label:"Análisis temporal", certainty:"POSIBLE", pass:true,
|
id:"timing", label:"Análisis temporal", certainty:"POSIBLE", pass:true,
|
||||||
actionability: "evitable",
|
actionability: "evitable",
|
||||||
detail: timingDetail,
|
detail: timingDetail,
|
||||||
didactic: "El horario de las transacciones revela la zona horaria y rutinas del usuario. Transacciones consistentemente a las 8-10 UTC apuntan a Europa. Es una señal débil en aislamiento pero útil combinada con otras.",
|
didactic: "Acumulando muchas transacciones de la misma persona, el horario acaba dibujando su huso horario y sus rutinas: cuándo duerme, si opera en horario laboral, si descansa los fines de semana. Aquí solo ves una, así que no hay patrón que leer — este dato solo cobra sentido para quien ya ha reunido un historial tuyo. Ojo además con la hora que se muestra: es la del bloque, no la de tu firma. Entre una y otra puede haber esperado en la mempool.",
|
||||||
penalty: 0, informational: true,
|
penalty: 0, informational: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1952,18 +2083,31 @@
|
|||||||
if (hit.cat === "exchange") exchHits.push({ ...hit, addr, side });
|
if (hit.cat === "exchange") exchHits.push({ ...hit, addr, side });
|
||||||
}
|
}
|
||||||
|
|
||||||
// OFAC — crítico, penaliza
|
// OFAC — informativo, NO penaliza la banda de privacidad.
|
||||||
|
//
|
||||||
|
// Es deliberado y conviene entender por qué: un hit de OFAC no revela
|
||||||
|
// absolutamente nada más sobre ti. Tu privacidad es idéntica antes y
|
||||||
|
// después de saberlo. Lo que cambia es otra cosa —la probabilidad de que
|
||||||
|
// un servicio regulado te bloquee un depósito—, y eso es un eje distinto:
|
||||||
|
// censurabilidad, no privacidad.
|
||||||
|
//
|
||||||
|
// Restar puntos aquí sería, además, dar por buena la idea de "monedas
|
||||||
|
// contaminadas": que una moneda vale menos según por dónde ha pasado.
|
||||||
|
// Bitcoin es fungible por diseño; la contaminación no es una propiedad
|
||||||
|
// de las monedas, es una construcción de la industria de cumplimiento
|
||||||
|
// normativo. Una herramienta que audita privacidad no debería
|
||||||
|
// internalizar la lógica del adversario. Se informa del riesgo real —que
|
||||||
|
// existe— sin fingir que es un problema de privacidad.
|
||||||
if (ofacHits.length > 0) {
|
if (ofacHits.length > 0) {
|
||||||
const names = [...new Set(ofacHits.map(h => h.name))].join(", ");
|
const names = [...new Set(ofacHits.map(h => h.name))].join(", ");
|
||||||
const sides = [...new Set(ofacHits.map(h => h.side))];
|
const sides = [...new Set(ofacHits.map(h => h.side))];
|
||||||
checks.push({
|
checks.push({
|
||||||
id:"entity_ofac", label:"Dirección en lista de sanciones OFAC", certainty:"CERTEZA", pass:false,
|
id:"entity_ofac", label:"Dirección en lista de sanciones OFAC", certainty:"CERTEZA", pass:true,
|
||||||
|
informational: true,
|
||||||
actionability: "no_corregible",
|
actionability: "no_corregible",
|
||||||
detail: `Una o más direcciones de esta transacción (${sides.join(" y ")}) aparecen en la lista de sanciones OFAC: ${names}. Esto no implica que hayas actuado ilegalmente, pero tus fondos han estado en contacto con una entidad sancionada internacionalmente. Los exchanges con KYC pueden usar este dato para bloquear o congelar depósitos relacionados.`,
|
detail: `Hecho (certeza): una o más direcciones de esta transacción (${sides.join(" y ")}) figuran en la lista OFAC: ${names}. Interpretación: ninguna — la coincidencia no dice nada sobre tu conducta ni sobre la de nadie; solo que esa dirección está en una lista. Consecuencia: un servicio regulado que consulte esa lista puede bloquear o congelar depósitos que relacione con estas direcciones. No afecta a tu privacidad: nadie sabe más de ti por esto, y por eso no resta puntos a la banda.`,
|
||||||
didactic: "La OFAC (Office of Foreign Assets Control, EE.UU.) mantiene una lista de direcciones de entidades sancionadas: mixers como Blender.io o Sinbad, exchanges como Garantex o Suex, y grupos como Lazarus. La lista es de dominio público y la usan los servicios regulados. Una coincidencia es un hecho verificable, no una probabilidad.",
|
didactic: "La OFAC es la oficina de control de activos del Tesoro de EE.UU. Su lista es una decisión política de un gobierno concreto, no una determinación judicial de criminalidad: incluye mixers (Blender.io, Sinbad), exchanges (Garantex, Suex) y grupos como Lazarus, pero también ha incluido software libre — las sanciones a Tornado Cash fueron revertidas en parte por un tribunal federal tras ser recurridas. Si no estás bajo jurisdicción estadounidense, esa lista no te obliga a nada; el problema práctico llega por los intermediarios que sí la aplican. Aquí se informa como riesgo de censura, no como defecto de privacidad, porque son cosas distintas: tus monedas no valen menos por dónde hayan pasado. Esa es justamente la idea que la fungibilidad de Bitcoin niega.",
|
||||||
penalty: weights.entity_ofac,
|
|
||||||
});
|
});
|
||||||
deductions += weights.entity_ofac;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mining pools — informativo, cierra el rastro
|
// Mining pools — informativo, cierra el rastro
|
||||||
@@ -2769,11 +2913,22 @@
|
|||||||
<div key={i} style={{display:"flex",alignItems:"center",justifyContent:"space-between",padding:"5px 0",borderBottom:i<4?`1px solid ${C.border}`:"none"}}>
|
<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>
|
<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}}>
|
<div style={{display:"flex",gap:8}}>
|
||||||
<Badge color={C.amber}>CPU {p.cpu}%</Badge>
|
{/* 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={p.mem>30?C.red:p.mem>15?C.amber:C.t2}>RAM {p.mem}%</Badge>
|
<Badge color={p.mem>30?C.red:p.mem>15?C.amber:C.t2}>RAM {p.mem}%</Badge>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -3503,13 +3658,15 @@
|
|||||||
if(sorted.length>MAX_UTXOS) setErr(`Mostrando ${MAX_UTXOS} de ${sorted.length} UTXOs — se muestran los de mayor valor.`);
|
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)
|
// 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 cache={};
|
||||||
const toFetch=limited.slice(0,10);
|
const toFetch=limited.slice(0,10);
|
||||||
await Promise.all(toFetch.map(async u=>{
|
await Promise.all(toFetch.map(async u=>{
|
||||||
try{
|
const t = await get(`/api/tx/${u.txid}`, null);
|
||||||
const res=await fetchWithTimeout(`${base}/api/tx/${u.txid}`);
|
if (t) cache[u.txid] = t;
|
||||||
if(res.ok) cache[u.txid]=await res.json();
|
|
||||||
}catch{}
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setTxCache(cache);
|
setTxCache(cache);
|
||||||
@@ -3732,6 +3889,13 @@
|
|||||||
const funded = stats.funded_txo_sum || 0;
|
const funded = stats.funded_txo_sum || 0;
|
||||||
const spent = stats.spent_txo_sum || 0;
|
const spent = stats.spent_txo_sum || 0;
|
||||||
const txCount = stats.tx_count || 0;
|
const txCount = stats.tx_count || 0;
|
||||||
|
// El backend de Mempool sobre Fulcrum devuelve los importes a cero en
|
||||||
|
// direcciones con mucho historial, aunque el contador de transacciones
|
||||||
|
// sí venga bien. Una dirección con transacciones pero sin nada recibido
|
||||||
|
// es imposible en la cadena: si se da, los datos están incompletos.
|
||||||
|
// Sin esta comprobación, el perfil concluye "no es hot wallet" cuando en
|
||||||
|
// realidad no ha podido mirarlo — otro silencio leído como respuesta.
|
||||||
|
const statsIncompletas = txCount > 0 && (stats.funded_txo_count || 0) === 0;
|
||||||
const residual = funded - spent;
|
const residual = funded - spent;
|
||||||
const residualRatio = funded > 0 ? residual / funded : 0;
|
const residualRatio = funded > 0 ? residual / funded : 0;
|
||||||
|
|
||||||
@@ -3741,7 +3905,9 @@
|
|||||||
// Señal de flujo: saldo residual casi nulo + volumen y tx_count altos ->
|
// Señal de flujo: saldo residual casi nulo + volumen y tx_count altos ->
|
||||||
// la dirección funciona como paso de caudal, no como ahorro (típico de
|
// la dirección funciona como paso de caudal, no como ahorro (típico de
|
||||||
// hot wallet de exchange). Lo contrario -> retención típica de uso personal.
|
// hot wallet de exchange). Lo contrario -> retención típica de uso personal.
|
||||||
if (funded > 0 && residualRatio < 0.05 && txCount >= 20) {
|
if (statsIncompletas) {
|
||||||
|
signals.push(`el nodo no devolvió los importes de esta dirección (${txCount} transacciones registradas, 0 recibido — imposible en la cadena). La señal de flujo no se ha podido evaluar: no es que se haya descartado, es que no se ha podido mirar`);
|
||||||
|
} else if (funded > 0 && residualRatio < 0.05 && txCount >= 20) {
|
||||||
hotScore += 2;
|
hotScore += 2;
|
||||||
signals.push(`saldo residual ≈0 (${(residualRatio*100).toFixed(1)}% de lo recibido) con ${txCount} transacciones — patrón de flujo, no de ahorro`);
|
signals.push(`saldo residual ≈0 (${(residualRatio*100).toFixed(1)}% de lo recibido) con ${txCount} transacciones — patrón de flujo, no de ahorro`);
|
||||||
} else if (funded > 0 && residualRatio > 0.3 && txCount < 10) {
|
} else if (funded > 0 && residualRatio > 0.3 && txCount < 10) {
|
||||||
@@ -4419,7 +4585,9 @@
|
|||||||
const recommendations = [
|
const recommendations = [
|
||||||
{ text:"Si las claves del wallet de origen pudieron verse comprometidas, genera una semilla nueva y traslada cualquier fondo restante — no reutilices el material comprometido." },
|
{ text:"Si las claves del wallet de origen pudieron verse comprometidas, genera una semilla nueva y traslada cualquier fondo restante — no reutilices el material comprometido." },
|
||||||
{ text:"Revisa el dispositivo donde vivía el wallet (malware, acceso físico no autorizado, backups expuestos) antes de asumir que el vector de entrada está cerrado." },
|
{ text:"Revisa el dispositivo donde vivía el wallet (malware, acceso físico no autorizado, backups expuestos) antes de asumir que el vector de entrada está cerrado." },
|
||||||
{ text:"Para una denuncia: aporta este informe completo junto con los txids listados en el anexo de verificación. Cualquiera puede comprobarlos de forma independiente." },
|
{ text:"Si decides denunciar, este informe completo y los txids del anexo son lo que hace falta: cualquiera puede comprobarlos de forma independiente, sin depender de Txoko ni de ningún servicio." },
|
||||||
|
{ text:"Antes de denunciar, ten claro el precio. Entregar este informe vincula tu identidad legal con esas direcciones de forma permanente, ante una autoridad que puede compartir el expediente con empresas de análisis de cadena. Esa vinculación no se deshace y alcanza también a las direcciones que solo aparecen de refilón. Puede compensar — recuperar fondos, frenar a quien lo hizo — o puede no compensar. Es tu decisión, y para tomarla hace falta saber las dos caras." },
|
||||||
|
{ text:"Este informe contiene tus direcciones y tu declaración. Trátalo como material sensible: no lo subas a foros, chats de soporte, grupos ni servicios en la nube para pedir ayuda. Todo el cuidado de no filtrar nada a la red se pierde en el momento en que este archivo sale de tu equipo." },
|
||||||
];
|
];
|
||||||
if (declaracion) {
|
if (declaracion) {
|
||||||
recommendations.push({
|
recommendations.push({
|
||||||
@@ -4728,7 +4896,7 @@
|
|||||||
|
|
||||||
// ── AUDITORÍA ──────────────────────────────────────────────────────────
|
// ── AUDITORÍA ──────────────────────────────────────────────────────────
|
||||||
function Auditoria({base, initialQuery}) {
|
function Auditoria({base, initialQuery}) {
|
||||||
const {get}=useApi(base);
|
const {get, getStrict}=useApi(base);
|
||||||
const [query,setQuery]=useState(initialQuery||"");
|
const [query,setQuery]=useState(initialQuery||"");
|
||||||
const [tx,setTx]=useState(null);
|
const [tx,setTx]=useState(null);
|
||||||
const [analysis,setAnalysis]=useState(null);
|
const [analysis,setAnalysis]=useState(null);
|
||||||
@@ -4786,14 +4954,22 @@
|
|||||||
const BATCH = 5; // direcciones por lote
|
const BATCH = 5; // direcciones por lote
|
||||||
const PAUSE = 120; // ms entre lotes
|
const PAUSE = 120; // ms entre lotes
|
||||||
const active = []; // direcciones con actividad
|
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
|
// Fase 1: estado ligero de cada dirección
|
||||||
for (let i = 0; i < all.length; i += BATCH) {
|
for (let i = 0; i < all.length; i += BATCH) {
|
||||||
const slice = all.slice(i, i + BATCH);
|
const slice = all.slice(i, i + BATCH);
|
||||||
setWalletProgress(`Explorando direcciones ${Math.min(i+BATCH,all.length)}/${all.length}`);
|
setWalletProgress(`Explorando direcciones ${Math.min(i+BATCH,all.length)}/${all.length}`);
|
||||||
const infos = await Promise.all(slice.map(s =>
|
const infos = await Promise.all(slice.map(async s => {
|
||||||
get(`/api/address/${s.addr}`, null).catch(()=>null)
|
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; }
|
||||||
|
}));
|
||||||
infos.forEach((info, j) => {
|
infos.forEach((info, j) => {
|
||||||
const txc = info?.chain_stats?.tx_count || 0;
|
const txc = info?.chain_stats?.tx_count || 0;
|
||||||
if (txc > 0) active.push({ ...slice[j], info });
|
if (txc > 0) active.push({ ...slice[j], info });
|
||||||
@@ -4809,21 +4985,30 @@
|
|||||||
const slice = active.slice(i, i + BATCH);
|
const slice = active.slice(i, i + BATCH);
|
||||||
setWalletProgress(`Trayendo transacciones ${Math.min(i+BATCH,active.length)}/${active.length}`);
|
setWalletProgress(`Trayendo transacciones ${Math.min(i+BATCH,active.length)}/${active.length}`);
|
||||||
await Promise.all(slice.map(async s => {
|
await Promise.all(slice.map(async s => {
|
||||||
let page = await get(`/api/address/${s.addr}/txs`, []).catch(()=>[]);
|
try {
|
||||||
|
let page = await getStrict(`/api/address/${s.addr}/txs`);
|
||||||
let guard = 0;
|
let guard = 0;
|
||||||
while (Array.isArray(page) && page.length > 0 && guard < 8) {
|
while (Array.isArray(page) && page.length > 0 && guard < 8) {
|
||||||
page.forEach(t => { if (t && t.txid) txMap.set(t.txid, t); });
|
page.forEach(t => { if (t && t.txid) txMap.set(t.txid, t); });
|
||||||
if (page.length < 25) break; // última página
|
if (page.length < 25) break; // última página
|
||||||
const last = page[page.length-1].txid;
|
const last = page[page.length-1].txid;
|
||||||
page = await get(`/api/address/${s.addr}/txs/chain/${last}`, []).catch(()=>[]);
|
page = await getStrict(`/api/address/${s.addr}/txs/chain/${last}`);
|
||||||
guard++;
|
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 });
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
if (i + BATCH < active.length) await new Promise(r => setTimeout(r, PAUSE));
|
if (i + BATCH < active.length) await new Promise(r => setTimeout(r, PAUSE));
|
||||||
}
|
}
|
||||||
|
|
||||||
setWalletProgress("Analizando vinculación…");
|
setWalletProgress("Analizando vinculación…");
|
||||||
const report = buildWalletReport(active, [...txMap.values()], myAddrSet);
|
const report = buildWalletReport(active, [...txMap.values()], myAddrSet);
|
||||||
|
report.scanErrors = scanErrors;
|
||||||
|
report.scanComplete = scanErrors.length === 0;
|
||||||
setWalletReport(report);
|
setWalletReport(report);
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
setWalletReport({ error: e.message });
|
setWalletReport({ error: e.message });
|
||||||
@@ -4958,9 +5143,16 @@
|
|||||||
const a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download="wallet-report.json";a.click();
|
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>
|
}} 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={()=>{
|
<button onClick={()=>{
|
||||||
const {health,totalTxs,activeCount,reusedAddrs,clusters,history}=walletReport;
|
const {health,totalTxs,activeCount,reusedAddrs,clusters,history,scanErrors}=walletReport;
|
||||||
const lines=[
|
const lines=[
|
||||||
`# Informe de Wallet — Txoko`,``,
|
`# 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,``,
|
`## Salud general: ${health.band}`,``,health.msg,``,
|
||||||
`- Transacciones analizadas: ${totalTxs}`,
|
`- Transacciones analizadas: ${totalTxs}`,
|
||||||
`- Direcciones con actividad: ${activeCount}`,
|
`- Direcciones con actividad: ${activeCount}`,
|
||||||
@@ -4980,6 +5172,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* 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={{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}}>
|
<div style={{width:48,height:48,borderRadius:"50%",border:`3px solid ${walletReport.health.color}`,display:"flex",alignItems:"center",justifyContent:"center",flexShrink:0}}>
|
||||||
@@ -5446,6 +5664,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* El análisis no sale de la red del usuario, pero el archivo
|
||||||
|
exportado sí puede salir — y lleva sus direcciones dentro.
|
||||||
|
Avisar aquí, junto a los botones, no en la letra pequeña. */}
|
||||||
|
<div style={{marginBottom:14,padding:"8px 12px",background:C.amberMuted,border:`1px solid ${C.amber}30`,borderRadius:6,fontSize:"0.62rem",color:C.amber,fontFamily:"monospace",lineHeight:1.6}}>
|
||||||
|
Lo que descargues lleva dentro tus direcciones y tu declaración. Nada de esto ha salido de tu red hasta ahora; a partir de aquí depende de ti. No lo subas a foros, chats de soporte ni servicios en la nube para pedir ayuda.
|
||||||
|
</div>
|
||||||
|
|
||||||
{trace&&!trace.done&&trace.hop>report.summary.hops&&(
|
{trace&&!trace.done&&trace.hop>report.summary.hops&&(
|
||||||
<div style={{marginBottom:14,padding:"8px 12px",background:C.amberMuted,border:`1px solid ${C.amber}30`,borderRadius:6,fontSize:"0.62rem",color:C.amber,fontFamily:"monospace"}}>
|
<div style={{marginBottom:14,padding:"8px 12px",background:C.amberMuted,border:`1px solid ${C.amber}30`,borderRadius:6,fontSize:"0.62rem",color:C.amber,fontFamily:"monospace"}}>
|
||||||
Este informe se generó en el salto {report.summary.hops} — el rastreo ya lleva {trace.hop}. Genera de nuevo para incluir lo explorado desde entonces.
|
Este informe se generó en el salto {report.summary.hops} — el rastreo ya lleva {trace.hop}. Genera de nuevo para incluir lo explorado desde entonces.
|
||||||
@@ -5657,18 +5882,30 @@
|
|||||||
const m=addr.mempool_stats||{funded_txo_sum:0,spent_txo_sum:0};
|
const m=addr.mempool_stats||{funded_txo_sum:0,spent_txo_sum:0};
|
||||||
const balance=c.funded_txo_sum-c.spent_txo_sum;
|
const balance=c.funded_txo_sum-c.spent_txo_sum;
|
||||||
const unconf=(m.funded_txo_sum||0)-(m.spent_txo_sum||0);
|
const unconf=(m.funded_txo_sum||0)-(m.spent_txo_sum||0);
|
||||||
|
// Con Fulcrum por debajo, el backend devuelve los importes a cero en
|
||||||
|
// direcciones con mucho historial, aunque el contador de transacciones
|
||||||
|
// venga bien. Tener transacciones y no haber recibido nada es imposible
|
||||||
|
// en la cadena, así que cuando se da, los importes no son reales: son un
|
||||||
|
// hueco. Mostrar "Balance 0" tal cual sería mentir con seguridad.
|
||||||
|
const importesIncompletos = c.tx_count > 0 && (c.funded_txo_count||0) === 0;
|
||||||
|
const noDisponible = "no disponible";
|
||||||
return (
|
return (
|
||||||
<div style={{display:"flex",flexDirection:"column",gap:10}}>
|
<div style={{display:"flex",flexDirection:"column",gap:10}}>
|
||||||
<Card glow={C.green}>
|
<Card glow={C.green}>
|
||||||
<div style={{marginBottom:12}}><div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",marginBottom:4}}>DIRECCIÓN</div><div style={{fontSize:"0.72rem",fontFamily:"monospace",color:C.green,wordBreak:"break-all"}}>{addr.address}</div></div>
|
<div style={{marginBottom:12}}><div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",marginBottom:4}}>DIRECCIÓN</div><div style={{fontSize:"0.72rem",fontFamily:"monospace",color:C.green,wordBreak:"break-all"}}>{addr.address}</div></div>
|
||||||
<div style={{display:"grid",gridTemplateColumns:"repeat(auto-fill,minmax(130px,1fr))",gap:12,paddingTop:12,borderTop:`1px solid ${C.border}`}}>
|
<div style={{display:"grid",gridTemplateColumns:"repeat(auto-fill,minmax(130px,1fr))",gap:12,paddingTop:12,borderTop:`1px solid ${C.border}`}}>
|
||||||
<Tag label="Balance" value={fmt.btc(balance)} color={C.green}/>
|
<Tag label="Balance" value={importesIncompletos?noDisponible:fmt.btc(balance)} color={importesIncompletos?C.t3:C.green}/>
|
||||||
<Tag label="No confirmado" value={unconf!==0?fmt.btc(unconf):"0"} color={unconf>0?C.amber:C.t2}/>
|
<Tag label="No confirmado" value={unconf!==0?fmt.btc(unconf):"0"} color={unconf>0?C.amber:C.t2}/>
|
||||||
<Tag label="Total recibido" value={fmt.btc(c.funded_txo_sum)} color={C.blue}/>
|
<Tag label="Total recibido" value={importesIncompletos?noDisponible:fmt.btc(c.funded_txo_sum)} color={importesIncompletos?C.t3:C.blue}/>
|
||||||
<Tag label="Total enviado" value={fmt.btc(c.spent_txo_sum)} color={C.red}/>
|
<Tag label="Total enviado" value={importesIncompletos?noDisponible:fmt.btc(c.spent_txo_sum)} color={importesIncompletos?C.t3:C.red}/>
|
||||||
<Tag label="Transacciones" value={fmt.num(c.tx_count)} color={C.t1}/>
|
<Tag label="Transacciones" value={fmt.num(c.tx_count)} color={C.t1}/>
|
||||||
<Tag label="UTXOs" value={fmt.num(addr.utxos?addr.utxos.length:0)} color={C.amber}/>
|
<Tag label="UTXOs" value={fmt.num(addr.utxos?addr.utxos.length:0)} color={C.amber}/>
|
||||||
</div>
|
</div>
|
||||||
|
{importesIncompletos&&(
|
||||||
|
<div style={{marginTop:12,padding:"9px 12px",background:C.amberMuted,border:`1px solid ${C.amber}40`,borderRadius:6,fontSize:"0.63rem",color:C.t1,fontFamily:"monospace",lineHeight:1.6}}>
|
||||||
|
<strong style={{color:C.amber}}>Importes no disponibles.</strong> El nodo informa de {fmt.num(c.tx_count)} transacciones pero devuelve cero recibido, y eso no puede ocurrir en la cadena. Es una limitación conocida del backend de Mempool sobre Fulcrum con direcciones de mucho historial. Los importes se ocultan en vez de mostrarse a cero, porque un cero aquí sería un dato falso. El número de transacciones y la lista de abajo sí son correctos.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
{analysis&&<PrivacyLab analysis={analysis}/>}
|
{analysis&&<PrivacyLab analysis={analysis}/>}
|
||||||
{addr.utxos&&addr.utxos.length>0&&<Card><div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:10}}>UTXOs</div>{addr.utxos.slice(0,6).map((u,i)=><div key={i} style={{display:"flex",justifyContent:"space-between",alignItems:"center",padding:"6px 0",borderBottom:i<Math.min(addr.utxos.length,6)-1?`1px solid ${C.border}`:"none"}}><span style={{fontSize:"0.68rem",fontFamily:"monospace",color:C.blue}}>{fmt.hash(u.txid)}:{u.vout}</span><div style={{display:"flex",gap:6}}><Badge color={C.green}>{fmt.btc(u.value)}</Badge><Badge color={u.status&&u.status.confirmed?C.t2:C.amber}>{u.status&&u.status.confirmed?`#${fmt.num(u.status.block_height)}`:"mempool"}</Badge></div></div>)}</Card>}
|
{addr.utxos&&addr.utxos.length>0&&<Card><div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:10}}>UTXOs</div>{addr.utxos.slice(0,6).map((u,i)=><div key={i} style={{display:"flex",justifyContent:"space-between",alignItems:"center",padding:"6px 0",borderBottom:i<Math.min(addr.utxos.length,6)-1?`1px solid ${C.border}`:"none"}}><span style={{fontSize:"0.68rem",fontFamily:"monospace",color:C.blue}}>{fmt.hash(u.txid)}:{u.vout}</span><div style={{display:"flex",gap:6}}><Badge color={C.green}>{fmt.btc(u.value)}</Badge><Badge color={u.status&&u.status.confirmed?C.t2:C.amber}>{u.status&&u.status.confirmed?`#${fmt.num(u.status.block_height)}`:"mempool"}</Badge></div></div>)}</Card>}
|
||||||
@@ -5961,51 +6198,319 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Decodificador PSBT ─────────────────────────────────────────────────
|
// ── 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 ──────────────────────────────────────────────────
|
||||||
function ToolPsbt() {
|
function ToolPsbt() {
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [result, setResult] = useState(null);
|
const [result, setResult] = useState(null);
|
||||||
const [err, setErr] = useState(null);
|
const [err, setErr] = useState(null);
|
||||||
|
|
||||||
|
// Acepta base64 (lo habitual al copiar) o hexadecimal.
|
||||||
const decode = () => {
|
const decode = () => {
|
||||||
const raw = input.trim();
|
const raw = input.trim().replace(/\s/g,"");
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
setErr(null); setResult(null);
|
setErr(null); setResult(null);
|
||||||
try {
|
try {
|
||||||
// Basic PSBT validation — starts with magic bytes "psbt" in base64
|
let bytes;
|
||||||
const bytes = atob(raw.replace(/\s/g,""));
|
if (/^[0-9a-fA-F]+$/.test(raw) && raw.length % 2 === 0) {
|
||||||
if (!bytes.startsWith("psbt")) throw new Error("No es una PSBT válida — debe empezar por 'psbt'");
|
bytes = new Uint8Array(raw.match(/../g).map(h=>parseInt(h,16)));
|
||||||
// Extract basic info from magic bytes
|
} else {
|
||||||
setResult({
|
const bin = atob(raw);
|
||||||
valid: true,
|
bytes = new Uint8Array(bin.length);
|
||||||
size: bytes.length,
|
for (let i=0;i<bin.length;i++) bytes[i] = bin.charCodeAt(i);
|
||||||
note: "PSBT válida. Para un análisis completo necesitas un wallet como Sparrow o Coldcard. Esta herramienta verifica el formato.",
|
}
|
||||||
});
|
const psbt = parsePsbt(bytes);
|
||||||
|
setResult(analyzePsbt(psbt));
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
setErr(`Error al decodificar: ${e.message}`);
|
setErr(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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<div style={{fontSize:"0.62rem",color:C.blue,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:8}}>Validador PSBT</div>
|
<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}}>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>
|
<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>
|
||||||
<textarea value={input} onChange={e=>setInput(e.target.value)}
|
<textarea value={input} onChange={e=>setInput(e.target.value)}
|
||||||
placeholder="cHNidP8BAH..."
|
placeholder="Pega aquí la PSBT en base64 (cHNidP8BAH…) o en hexadecimal"
|
||||||
rows={4}
|
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}}
|
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}
|
onFocus={e=>e.target.style.borderColor=C.blue} onBlur={e=>e.target.style.borderColor=C.borderBright}
|
||||||
/>
|
/>
|
||||||
<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>
|
<div style={{display:"flex",gap:8,alignItems:"center",flexWrap:"wrap",marginBottom:10}}>
|
||||||
{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>}
|
<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>
|
||||||
{result&&(
|
<label style={{padding:"9px 16px",background:"none",border:`1px solid ${C.border}`,borderRadius:6,color:C.t2,fontFamily:"monospace",fontSize:"0.7rem",cursor:"pointer"}}>
|
||||||
<div style={{padding:"12px 14px",background:C.bg,borderRadius:6,border:`1px solid ${C.green}40`}}>
|
…o abrir archivo .psbt
|
||||||
<div style={{display:"flex",alignItems:"center",gap:8,marginBottom:8}}>
|
<input type="file" accept=".psbt,.txn,application/octet-stream" style={{display:"none"}} onChange={e=>cargarArchivo(e.target.files[0])}/>
|
||||||
<span style={{color:C.green,fontSize:"1rem"}}>✓</span>
|
</label>
|
||||||
<span style={{color:C.green,fontFamily:"monospace",fontSize:"0.78rem",fontWeight:700}}>PSBT válida</span>
|
</div>
|
||||||
<Badge color={C.t2}>{result.size} bytes</Badge>
|
|
||||||
|
{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>}
|
||||||
|
|
||||||
|
{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>
|
</div>
|
||||||
<div style={{fontSize:"0.68rem",color:C.t2,lineHeight:1.6}}>{result.note}</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
@@ -6172,12 +6677,13 @@
|
|||||||
|
|
||||||
{/* Intro */}
|
{/* Intro */}
|
||||||
<Section id="intro" title="¿Qué es Txoko Node Dashboard?" accent={C.green}>
|
<Section id="intro" title="¿Qué es Txoko Node Dashboard?" accent={C.green}>
|
||||||
<P>Txoko Node Dashboard es una herramienta de análisis Bitcoin diseñada para usuarios que gestionan su propio nodo completo. Permite explorar la blockchain, analizar la privacidad de transacciones y monitorizar el estado del nodo, todo desde tu propia infraestructura.</P>
|
<P>Txoko audita tu privacidad Bitcoin sobre tu propio nodo. Te dice qué puede deducir un observador externo de tus transacciones — y lo hace sin convertirse él mismo en ese observador.</P>
|
||||||
<P>A diferencia de exploradores públicos como Mempool.space o Blockchair, <strong style={{color:C.t1}}>ninguna consulta sale de tu red privada</strong>. Tu nodo responde directamente: nadie sabe qué direcciones o transacciones estás investigando.</P>
|
<P>Cuando consultas una dirección en un explorador público, ese explorador aprende qué te interesa. Aquí <strong style={{color:C.t1}}>ninguna consulta sale de tu red</strong>: responde tu nodo, y nadie —tampoco nosotros— sabe qué estás investigando.</P>
|
||||||
<H color={C.amber}>Principios de diseño</H>
|
<H color={C.amber}>Principios de diseño</H>
|
||||||
<Li><strong style={{color:C.t1}}>Privacidad total</strong> — todas las consultas van a tu nodo. Sin telemetría, sin logs externos, sin CDNs de terceros para datos sensibles.</Li>
|
<Li><strong style={{color:C.t1}}>Privacidad total</strong> — todas las consultas van a tu nodo. Sin telemetría, sin logs externos y sin CDNs: hasta las librerías y las fuentes se sirven desde tu propia máquina, así que ni siquiera se filtra <em>que</em> estás usando Txoko. El dashboard funciona sin conexión a internet.</Li>
|
||||||
<Li><strong style={{color:C.t1}}>Datos fiables</strong> — cada análisis indica explícitamente su nivel de certeza: CERTEZA, PROBABLE o POSIBLE. Nunca presentamos heurísticas como hechos.</Li>
|
<Li><strong style={{color:C.t1}}>Datos fiables</strong> — cada análisis indica explícitamente su nivel de certeza: CERTEZA, PROBABLE o POSIBLE. Nunca presentamos heurísticas como hechos.</Li>
|
||||||
<Li><strong style={{color:C.t1}}>Soberanía</strong> — el dashboard es un único archivo HTML estático. Sin dependencias de servidor, sin base de datos, sin cuentas.</Li>
|
<Li><strong style={{color:C.t1}}>Soberanía</strong> — todo el código de análisis cabe en un archivo HTML sin compilar: lo que lees es lo que se ejecuta. Sin base de datos, sin cuentas, sin servidor que dependa de nadie.</Li>
|
||||||
|
<Li><strong style={{color:C.t1}}>Sí, esto es chain analysis</strong> — el peritaje usa las mismas técnicas que las empresas de vigilancia de cadena. Lo que cambia es quién las ejecuta (tú), sobre qué (lo tuyo), dónde se detiene (en un servicio, nunca en una persona) y quién se queda el informe (tú). Y hay una razón de más peso: la misma herramienta que sigue el rastro de un ladrón demuestra lo fácil que es seguir el tuyo. Conocer el arma no es adoptarla.</Li>
|
||||||
<Callout color={C.green}>Este proyecto es software libre desarrollado por y para la comunidad Bitcoin Txoko. Contribuciones y mejoras son bienvenidas en nuestro Gitea.</Callout>
|
<Callout color={C.green}>Este proyecto es software libre desarrollado por y para la comunidad Bitcoin Txoko. Contribuciones y mejoras son bienvenidas en nuestro Gitea.</Callout>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
|||||||
+46
-2
@@ -168,10 +168,33 @@ async function getDisk() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Procesos destacados (nombre limpio del binario) ────────────────────────
|
// ── Procesos destacados (nombre limpio del binario) ────────────────────────
|
||||||
|
// Tiempo de CPU consumido por un proceso, en ticks, desde /proc/PID/stat.
|
||||||
|
// Campos 14 (utime) y 15 (stime). El nombre del proceso va entre paréntesis y
|
||||||
|
// puede contener espacios, así que se corta por el ÚLTIMO ')' antes de partir.
|
||||||
|
function readProcCpuTicks(pid) {
|
||||||
|
try {
|
||||||
|
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
|
||||||
|
const resto = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
||||||
|
// resto[0] es el campo 3 (estado), así que utime=campo14 → resto[11]
|
||||||
|
const utime = parseInt(resto[11], 10);
|
||||||
|
const stime = parseInt(resto[12], 10);
|
||||||
|
if (Number.isNaN(utime) || Number.isNaN(stime)) return null;
|
||||||
|
return utime + stime;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// OJO con la columna %CPU de `ps`: NO es el consumo actual, sino la media del
|
||||||
|
// proceso desde que arrancó (tiempo de CPU / tiempo de vida). Un proceso que
|
||||||
|
// trabajó mucho al principio y ahora está ocioso sigue mostrando un número
|
||||||
|
// alto días después, y se lee como si estuviera saturando la máquina.
|
||||||
|
// Aquí se mide de verdad: dos lecturas de /proc separadas 500 ms, el mismo
|
||||||
|
// método que ya usa getCpuUsage para el total del sistema. Las dos esperas
|
||||||
|
// corren en paralelo (van dentro del mismo Promise.all), así que no cuesta
|
||||||
|
// tiempo extra.
|
||||||
async function getProcesses() {
|
async function getProcesses() {
|
||||||
const result = await sh("ps aux --no-headers --sort=-%mem | head -8");
|
const result = await sh("ps aux --no-headers --sort=-%mem | head -8");
|
||||||
if (!result) return [];
|
if (!result) return [];
|
||||||
return result.split("\n").map(line => {
|
const filas = result.split("\n").map(line => {
|
||||||
const parts = line.trim().split(/\s+/);
|
const parts = line.trim().split(/\s+/);
|
||||||
// Nombre limpio: basename del ejecutable; si es un intérprete (node,
|
// Nombre limpio: basename del ejecutable; si es un intérprete (node,
|
||||||
// python...), añade el basename del script que ejecuta.
|
// python...), añade el basename del script que ejecuta.
|
||||||
@@ -190,12 +213,33 @@ async function getProcesses() {
|
|||||||
}
|
}
|
||||||
command = command.slice(0, 24);
|
command = command.slice(0, 24);
|
||||||
return {
|
return {
|
||||||
|
pid: parts[1],
|
||||||
user: parts[0],
|
user: parts[0],
|
||||||
cpu: parseFloat(parts[2]),
|
cpuMedia: parseFloat(parts[2]), // media desde el arranque (lo que da ps)
|
||||||
mem: parseFloat(parts[3]),
|
mem: parseFloat(parts[3]),
|
||||||
command,
|
command,
|
||||||
};
|
};
|
||||||
}).filter(p => p.mem > 0.5);
|
}).filter(p => p.mem > 0.5);
|
||||||
|
|
||||||
|
const ticks1 = filas.map(p => readProcCpuTicks(p.pid));
|
||||||
|
await sleep(500);
|
||||||
|
const ticks2 = filas.map(p => readProcCpuTicks(p.pid));
|
||||||
|
|
||||||
|
const USER_HZ = 100; // estándar en Linux
|
||||||
|
const VENTANA_S = 0.5;
|
||||||
|
const nucleos = os.cpus().length || 1;
|
||||||
|
|
||||||
|
return filas.map((p, i) => {
|
||||||
|
let cpu = null, cpuSistema = null;
|
||||||
|
if (ticks1[i] !== null && ticks2[i] !== null) {
|
||||||
|
const seg = (ticks2[i] - ticks1[i]) / USER_HZ;
|
||||||
|
// % de UN núcleo (criterio de top/ps: puede pasar de 100 si va en varios)
|
||||||
|
cpu = Math.max(0, Math.round((seg / VENTANA_S) * 1000) / 10);
|
||||||
|
// % del total de la máquina, que es lo que suele querer saberse
|
||||||
|
cpuSistema = Math.round((cpu / nucleos) * 10) / 10;
|
||||||
|
}
|
||||||
|
return { user: p.user, command: p.command, mem: p.mem, cpu, cpuSistema, cpuMedia: p.cpuMedia };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Load / Uptime ───────────────────────────────────────────────────────────
|
// ── Load / Uptime ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// 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`);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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`);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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");
|
||||||
|
})();
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
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,8 +4,15 @@ After=network.target bitcoind.service
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=armg
|
|
||||||
ExecStart=/usr/bin/node /home/armg/txoko/system-metrics.js
|
# ── 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
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
StandardOutput=journal
|
StandardOutput=journal
|
||||||
|
|||||||
Reference in New Issue
Block a user