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