Compare commits
31
Commits
main
..
a6fe438b06
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
-17
@@ -23,20 +23,3 @@ node_modules/
|
|||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
txoko-roadmap.md
|
txoko-roadmap.md
|
||||||
TRASPASO.md
|
TRASPASO.md
|
||||||
PRUEBA-PERITAJE.md
|
|
||||||
HALLAZGOS-PERITAJE.md
|
|
||||||
MEJORAS.md
|
|
||||||
COHERENCIA.md
|
|
||||||
|
|
||||||
GIT.md
|
|
||||||
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/
|
|
||||||
|
|||||||
-631
@@ -10,637 +10,6 @@ y el versionado sigue [Versionado Semántico](https://semver.org/lang/es/):
|
|||||||
- **MENOR** — características nuevas que no rompen lo anterior
|
- **MENOR** — características nuevas que no rompen lo anterior
|
||||||
- **PARCHE** — arreglos de errores
|
- **PARCHE** — arreglos de errores
|
||||||
|
|
||||||
---
|
|
||||||
## [1.18.2] — 2026-08-08
|
|
||||||
### Corregido
|
|
||||||
- **`instalar-fuentes.sh` daba por fallidas descargas que habían ido bien, en
|
|
||||||
macOS.** Comprobaba el tamaño con `stat -c%s`, que es sintaxis GNU. macOS
|
|
||||||
trae la versión BSD de `stat`, que usa `-f%z`, así que el comando fallaba,
|
|
||||||
caía en el `|| echo 0` y el script informaba **«FALLÓ (HTTP 200, 0 bytes)»**.
|
|
||||||
El `HTTP 200` decía que la descarga había funcionado; lo roto era la
|
|
||||||
comprobación.
|
|
||||||
- El mensaje además engañaba: se lee como un problema de red o del servidor
|
|
||||||
de fuentes, y era una incompatibilidad de shell entre dos sistemas.
|
|
||||||
- Ahora usa `wc -c`, que se comporta igual en Linux y en macOS, y comprueba
|
|
||||||
antes que el archivo exista.
|
|
||||||
- La cabecera del script decía «Ejecutar EN EL NODO». Ya no: si el dashboard
|
|
||||||
puede vivir en una carpeta cualquiera, su instalador de fuentes tiene que
|
|
||||||
funcionar ahí también.
|
|
||||||
|
|
||||||
---
|
|
||||||
## [1.18.1] — 2026-08-08
|
|
||||||
### Corregido
|
|
||||||
- **El aviso del xpub decía una media verdad.** El panel de wallet watch-only
|
|
||||||
decía «Txoko deriva tus direcciones en local — el xpub no sale del
|
|
||||||
navegador». Es cierto, y por eso mismo engaña: el xpub no sale, pero **las
|
|
||||||
direcciones derivadas sí**, una consulta a la API del nodo por cada una. Quien
|
|
||||||
opere ese nodo puede ver en sus registros el monedero completo que estás
|
|
||||||
analizando: cuántas direcciones tienes, cuáles, su historial y tu saldo.
|
|
||||||
- Con el nodo propio no hay problema, es tu información en tu máquina. Con el
|
|
||||||
nodo de otra persona —aunque sea de confianza— le estás enseñando tus
|
|
||||||
finanzas enteras.
|
|
||||||
- El aviso lo dice ahora, y distingue los dos casos. En una herramienta que
|
|
||||||
existe para no afirmar de más, ese era el peor sitio posible para tener una
|
|
||||||
frase técnicamente cierta que se lee como una garantía que no es.
|
|
||||||
|
|
||||||
### Añadido
|
|
||||||
- `PRUEBALA.md` — guía para quien recibe la aplicación para probarla: el aviso
|
|
||||||
del xpub por delante de todo, qué merece la pena intentar romper, qué no
|
|
||||||
hacer con el peritaje, y qué tipo de fallo interesa que reporte.
|
|
||||||
|
|
||||||
---
|
|
||||||
## [1.18.0] — 2026-08-08
|
|
||||||
### Añadido
|
|
||||||
- **La comisión como huella.** El sat/vB que eliges dice algo del software y de
|
|
||||||
quien lo maneja, y el dato estaba en cada transacción sin que nadie lo
|
|
||||||
mirase. Se detectan dos firmas: una comisión **absoluta** redonda (10.000
|
|
||||||
sats clavados — un estimador nunca da eso, calcula tarifa por tamaño y
|
|
||||||
devuelve números feos) y una **tarifa** prácticamente entera (20,00 sat/vB,
|
|
||||||
que sale de teclear un número redondo en la casilla).
|
|
||||||
- Informativo y sin penalización, por el mismo motivo que se decidió con RBF:
|
|
||||||
la señal es débil, y bajar la nota por ella empujaría a elegir comisiones
|
|
||||||
peores solo para disimular. Mal negocio.
|
|
||||||
- **El cambio ya se identifica en transacciones de más de dos salidas, cuando
|
|
||||||
el caso es inequívoco.** Antes la detección se plantaba en seco por encima de
|
|
||||||
dos salidas. Estaba bien argumentado, pero se tiraba información sólida: si
|
|
||||||
de N salidas **exactamente una** comparte tipo de script con las entradas, no
|
|
||||||
hay nada que adivinar — y con más salidas la coincidencia por azar es aún más
|
|
||||||
improbable que con dos. También cuenta el caso de una salida que vuelve a una
|
|
||||||
dirección ya gastada. Se marca POSIBLE y no PROBABLE: es una sola señal, y
|
|
||||||
hay monederos que devuelven el cambio a un tipo distinto del que gastan.
|
|
||||||
- **Aviso de autoenvíos que ataron direcciones**, en el informe de wallet. Es
|
|
||||||
una comprobación que solo cabe ahí: para saber que *todas* las salidas son
|
|
||||||
tuyas hace falta conocer tu wallet entero, cosa que el analizador de una
|
|
||||||
transacción suelta no puede saber.
|
|
||||||
- Detecta las transacciones donde entradas y salidas son todas tuyas, y avisa
|
|
||||||
solo cuando hubo daño real: más de una dirección de entrada. Mover una
|
|
||||||
dirección a otra no vincula nada nuevo y no merece alarma.
|
|
||||||
- Es un gesto que suele hacerse creyendo que despista y hace lo contrario: no
|
|
||||||
hubo ningún pago, pero al firmar varias direcciones a la vez quedaron
|
|
||||||
enlazadas en público. Se paga una comisión por empeorar la propia
|
|
||||||
privacidad.
|
|
||||||
|
|
||||||
Con esto quedan cerradas las cinco heurísticas que el repaso del 2026-08-08
|
|
||||||
señaló como ausentes.
|
|
||||||
|
|
||||||
---
|
|
||||||
## [1.17.0] — 2026-08-08
|
|
||||||
### Cambiado
|
|
||||||
- **La nota de privacidad ya solo mide lo que dependía de ti.** Hasta ahora
|
|
||||||
mezclaba dos cosas distintas en un número: los errores propios (reutilizar
|
|
||||||
direcciones, consolidar, dejar el cambio a la vista) y la exposición que
|
|
||||||
provoca un tercero (que te paguen desde un lote, recibir polvo, que la otra
|
|
||||||
parte use un tipo de dirección distinto). La consecuencia era absurda:
|
|
||||||
**alguien impecable que cobró de un exchange sacaba peor nota que un
|
|
||||||
descuidado que cobró de un particular**, y no había nada que pudiera hacer
|
|
||||||
para mejorarla. Recibir de un lote costaba 23 puntos y estaba marcado, con
|
|
||||||
razón, como "no corregible".
|
|
||||||
- La nota se calcula ahora solo con los checks sobre los que tienes decisión.
|
|
||||||
Denominador 142.
|
|
||||||
- Lo demás pasa a un bloque propio, **«lo que hicieron otros»**, con su
|
|
||||||
propio nivel de exposición (ninguna / moderada / alta). Se informa, se
|
|
||||||
explica, y no baja una nota que no podrías subir.
|
|
||||||
- Una nota que no puedes mejorar no es una evaluación, es un reproche. Y una
|
|
||||||
herramienta que reprocha lo que no elegiste enseña a ignorarla.
|
|
||||||
- **Un fallo grave impide la banda ALTA aunque el número dé de sobra.** Con la
|
|
||||||
nota restringida a lo propio, una transacción con el cambio identificable
|
|
||||||
—una fuga real y concreta— sacaba 93 sobre 100 y la etiqueta "privacidad
|
|
||||||
aceptable". Ahora cualquier check propio que falle con peso ≥10 impide la
|
|
||||||
banda alta. Un promedio bueno no borra un fallo concreto.
|
|
||||||
- **OP_RETURN deja de forzar banda BAJA por decreto.** Sigue penalizando, pero
|
|
||||||
el tope duro condenaba por igual a una inscripción y a un sello de tiempo de
|
|
||||||
OpenTimestamps, que es privacidad neutra. Como además OP_RETURN suele venir
|
|
||||||
del protocolo que usaste y no de tu descuido, vive ahora en la exposición
|
|
||||||
heredada.
|
|
||||||
- Los cortes de banda pasan a 85 y 55: con la nota midiendo solo lo propio, el
|
|
||||||
listón puede ser más exigente porque ya no hay dentro nada que no puedas
|
|
||||||
arreglar.
|
|
||||||
- La exportación en Markdown y en JSON incluyen ambos bloques por separado.
|
|
||||||
|
|
||||||
---
|
|
||||||
## [1.16.0] — 2026-08-08
|
|
||||||
### Corregido
|
|
||||||
- **El denominador de la nota de privacidad no cuadraba con los pesos.** La
|
|
||||||
nota se calcula como `100 - (deducciones / maxPossible) * 100`, donde
|
|
||||||
`maxPossible` es la suma del objeto `weights`. Ese objeto tenía dos
|
|
||||||
desajustes, en direcciones opuestas y ninguno intencionado:
|
|
||||||
- Incluía `rbf` (5) y `peeling` (8), que son informativos y **nunca restan**.
|
|
||||||
Trece puntos de denominador imposibles de alcanzar, que inflaban
|
|
||||||
sistemáticamente todas las notas.
|
|
||||||
- **No** incluía `input_linkage`, que resta hasta 30 desde una variable
|
|
||||||
suelta. Las deducciones podían superar al denominador en 17 puntos y dar
|
|
||||||
una nota negativa, tapada solo por el `Math.max(0, …)`.
|
|
||||||
- Ahora todo lo que puede restar está en `weights` y nada más lo está. Los
|
|
||||||
cortes de banda pasan de 75/45 a 79/54, que son los valores que hacen que
|
|
||||||
una transacción reciba **exactamente la misma banda que antes** con el
|
|
||||||
denominador corregido. Queda anotado en el código que normalizar por la
|
|
||||||
suma de los pesos obliga a recalibrar cada vez que se añade un check —
|
|
||||||
porque cada check nuevo hace parecer menos graves a los anteriores.
|
|
||||||
- El texto explicativo bajo la nota derivaba la banda por su cuenta con los
|
|
||||||
cortes antiguos escritos a mano. Ahora usa la banda ya calculada.
|
|
||||||
- **"Cifra redonda" estaba mal definido.** La condición era
|
|
||||||
`v % 1000000 === 0 || v % 100000 === 0 || v % 10000000 === 0`, donde la
|
|
||||||
primera y la tercera sobran: todo múltiplo de un millón lo es de cien mil.
|
|
||||||
Equivalía a "múltiplo de 0,001 BTC", así que pagos tan redondos como 10.000
|
|
||||||
o 50.000 sats —de lo más común con las comisiones de hoy— no se veían. La
|
|
||||||
heurística estaba infrautilizada, no equivocada.
|
|
||||||
- Ahora se mide por ceros finales y la señal se gradúa: pagar 0,5 BTC
|
|
||||||
clavados delata más que pagar 0,0123, y la penalización lo refleja.
|
|
||||||
- El didáctico dice ahora el punto ciego: si pagas una cantidad redonda **en
|
|
||||||
euros**, en BTC sale un número con todos sus decimales y esta heurística no
|
|
||||||
ve nada.
|
|
||||||
|
|
||||||
### Añadido
|
|
||||||
- **Nuevo aviso: se gasta polvo junto a otras monedas.** El check de polvo
|
|
||||||
advertía de que una salida diminuta "quedará vinculada con las demás si se
|
|
||||||
gasta junto a ellas" — y la aplicación nunca miraba si eso estaba ocurriendo
|
|
||||||
delante de ella, pese a tener el dato en la mano. Ahora lo mira: si entre las
|
|
||||||
entradas hay una moneda de tamaño polvo gastada junto a otras de importe
|
|
||||||
normal, el ataque ya no es una hipótesis, se acaba de consumar, y se dice con
|
|
||||||
CERTEZA. No aplica en CoinJoin ni cuando solo se gasta polvo, que no revela
|
|
||||||
vinculación nueva.
|
|
||||||
- **La consolidación pura ya no pasa desapercibida.** Una transacción de N
|
|
||||||
entradas a **una sola salida** —el acto que más privacidad destruye de una
|
|
||||||
vez— no disparaba el check de inputs innecesarios: su condición exige que una
|
|
||||||
entrada cubra el pago *y sobre*, y en una consolidación la salida vale casi
|
|
||||||
lo mismo que la suma de entradas. Comprobado con 20 entradas a 1 salida.
|
|
||||||
Ahora se nombra como lo que es, con certeza. No suma penalización propia a
|
|
||||||
propósito: la vinculación ya la cobra `input_linkage`, y cobrarla dos veces
|
|
||||||
sería repetir el error que este mismo repaso acaba de corregir.
|
|
||||||
- `tests/test6.js` — el analizador completo contra transacciones construidas a
|
|
||||||
mano: coherencia de la nota, los dos checks nuevos y la guardia de CoinJoin.
|
|
||||||
- `tests/test5.js` gana la comprobación de los niveles de redondez.
|
|
||||||
- `tests/README.md` explica ahora las dos familias de prueba y deja escrito que
|
|
||||||
las heurísticas se comprueban contra transacciones fabricadas, no contra la
|
|
||||||
cadena real: eso demuestra que la lógica hace lo que dice, pero no cuántas
|
|
||||||
veces acierta ahí fuera.
|
|
||||||
|
|
||||||
---
|
|
||||||
## [1.15.0] — 2026-08-08
|
|
||||||
### Corregido
|
|
||||||
- **La detección del cambio contaba una misma señal dos veces.** La "señal A"
|
|
||||||
(solo una salida comparte tipo de script con las entradas) y la "señal E"
|
|
||||||
(el tipo de output difiere del de las entradas) eran la misma condición
|
|
||||||
escrita dos veces —el mismo `filter`, la misma comparación, el mismo `=== 1`—
|
|
||||||
y cada una incrementaba el contador. Como el umbral para declarar el cambio
|
|
||||||
identificable es de dos señales, **ese único hecho bastaba por sí solo**.
|
|
||||||
- Consecuencia práctica: pagar desde una dirección bech32 a una taproot —de
|
|
||||||
lo más común que hay— salía marcado como "cambio identificable" con certeza
|
|
||||||
PROBABLE, sin ninguna otra evidencia.
|
|
||||||
- Y el informe mostraba las dos señales juntas, que se contradicen entre sí:
|
|
||||||
"tipo de script idéntico a inputs" y "tipo de output distinto al de inputs".
|
|
||||||
El mismo hecho descrito con palabras opuestas.
|
|
||||||
- Ahora es una sola señal. Esa transacción pasa a una señal y a **no
|
|
||||||
identificable**, que es lo correcto. Cuando queda una sola señal el check
|
|
||||||
lo dice en vez de callarse: existe, no basta, y conviene que se sepa que
|
|
||||||
otro analista menos escrupuloso la daría por buena ella sola.
|
|
||||||
- **Se podía señalar el pago como si fuera el cambio.** Cuando ninguna señal
|
|
||||||
fuerte apuntaba a un output concreto, el índice caía en `indexOf(smaller)`:
|
|
||||||
se asumía que el cambio es siempre la salida menor. Es falso en el caso más
|
|
||||||
común de todos —pagar poco desde una moneda grande—, donde el cambio es la
|
|
||||||
salida *mayor*.
|
|
||||||
- Comprobado con un pago de 0,001 BTC desde 1 BTC: la app decía en el detalle
|
|
||||||
"el output redondo es el pago" y acto seguido señalaba ese mismo output
|
|
||||||
como cambio. La información para acertar estaba delante y se descartaba.
|
|
||||||
- Esto no se quedaba en el analizador: el motor de peritaje usa ese índice
|
|
||||||
para detectar cadenas de peeling y para redactar el informe. Un cambio mal
|
|
||||||
identificado significa presentar como rastro del actor **la dirección del
|
|
||||||
destinatario del pago**, una persona ajena.
|
|
||||||
- Ahora cada señal apunta a un output o reconoce que no puede. El orden de
|
|
||||||
resolución es: reutilización de dirección de entrada → tipo de script →
|
|
||||||
la salida NO redonda → posición. Si ninguna apunta, el índice queda vacío
|
|
||||||
y se dice que hay señales pero no cuál — preferible a señalar mal.
|
|
||||||
|
|
||||||
### Añadido
|
|
||||||
- `tests/test5.js` — seis casos de detección de cambio con el pago y el cambio
|
|
||||||
conocidos de antemano, incluidos los dos fallos anteriores como regresión.
|
|
||||||
|
|
||||||
---
|
|
||||||
## [1.14.0] — 2026-08-08
|
|
||||||
### Añadido
|
|
||||||
- **Mempool y Bloques se fusionan en una sola pestaña, «Cadena».** Eran el
|
|
||||||
mismo eje partido por la mitad: los "bloques proyectados" vivían en Mempool y
|
|
||||||
los confirmados en Bloques, cuando es una línea temporal con el ahora en el
|
|
||||||
corte. Ahora se lee de arriba abajo: comisiones, lo que está por minar, un
|
|
||||||
separador **AHORA**, y los bloques ya minados.
|
|
||||||
- Nuevo: **«si pagas X sat/vB, ¿cuándo entra?»**. Ni la vista de mempool ni la
|
|
||||||
de bloques respondían la pregunta que de verdad se hace quien mira las
|
|
||||||
comisiones — daban los datos por separado y dejaban el cálculo al ojo.
|
|
||||||
Escribes una tarifa y te dice en qué bloque proyectado caería, marcándolo
|
|
||||||
en la lista. Con dos avisos escritos en la propia interfaz: el cálculo se
|
|
||||||
hace sobre la mempool de este momento, y diez minutos es la media entre
|
|
||||||
bloques, no una promesa.
|
|
||||||
- El detalle de un bloque se abría al final de la lista, a quince filas del
|
|
||||||
bloque pulsado, así que parecía que el clic no hacía nada. Ahora la vista
|
|
||||||
se desplaza hasta él.
|
|
||||||
- **Aviso de polvo recibido** en el informe de wallet. El analizador ya sabía
|
|
||||||
reconocer el patrón al mirar una transacción suelta, pero eso no sirve si no
|
|
||||||
te avisa cuando te pasa a ti. Recorre las salidas de importe ínfimo hacia
|
|
||||||
direcciones propias y distingue las que siguen sin gastar —donde el consejo
|
|
||||||
todavía sirve— de las ya gastadas, donde la vinculación ya está hecha.
|
|
||||||
El consejo es el contrario del instinto: **no hagas nada**, déjalas quietas y
|
|
||||||
congélalas en el monedero si puedes. El daño solo ocurre al mezclarlas.
|
|
||||||
- **Enlaces «ver en Mempool»** en nueve puntos: transacción analizada, detalle
|
|
||||||
de bloque y sus transacciones, dirección del explorador, historial del informe
|
|
||||||
de wallet, direcciones atribuidas y fondos localizados del peritaje, y en el
|
|
||||||
rastro de procedencia la dirección de cada entrada y su transacción de origen.
|
|
||||||
Se construyen **siempre** desde la URL configurada; sin nodo, no hay enlace.
|
|
||||||
|
|
||||||
### Corregido
|
|
||||||
- **El rastro de procedencia enlazaba a mempool.space público** cuando no había
|
|
||||||
nodo configurado (`base ? … : "https://mempool.space/tx/…"`). Un clic ahí le
|
|
||||||
dice a un tercero qué transacción estás investigando, que es exactamente lo
|
|
||||||
contrario de para lo que existe esta herramienta. Sin nodo ya no hay enlace:
|
|
||||||
se muestra el txid en texto plano. Auditado que no queda ningún enlace
|
|
||||||
saliente en toda la aplicación.
|
|
||||||
- La fee mediana del detalle de bloque se mostraba con dieciséis decimales.
|
|
||||||
|
|
||||||
### Nota de diseño
|
|
||||||
- Se descartó hacer persistentes las etiquetas BIP-329. Vinculan direcciones con
|
|
||||||
descripciones en lenguaje natural ("ahorro", "pago a…") y guardarlas dejaría
|
|
||||||
en disco justo el mapa que un atacante querría. Que vivan solo en memoria no
|
|
||||||
es una carencia, es la misma decisión que ya se aplica a la URL del nodo.
|
|
||||||
|
|
||||||
---
|
|
||||||
## [1.13.0] — 2026-08-08
|
|
||||||
### Cambiado
|
|
||||||
- **El checklist decía que un swap alto era "normal en nodos con índice
|
|
||||||
completo".** No lo es, y decirlo hace que se ignore durante semanas un
|
|
||||||
síntoma con causa y con arreglo. El caso que lo cambió: swap al 99% con 18 GB
|
|
||||||
de RAM libre, porque Fulcrum tenía `db_mem` a 16 GB — más de lo que cabe en
|
|
||||||
la máquina junto al resto de servicios. Con el swap lleno el sistema se queda
|
|
||||||
sin margen y, ante un pico, el OOM killer elige él a quién mata.
|
|
||||||
- El umbral baja del 95% al 50%, que es cuando conviene mirarlo y no cuando
|
|
||||||
ya es tarde, y pasa a estado de error por encima del 80%.
|
|
||||||
- Si el swap sube **teniendo RAM libre**, el aviso lo señala: es la firma de
|
|
||||||
un servicio con más memoria reservada de la que cabe, no de presión real.
|
|
||||||
- Apunta al sospechoso habitual (`db_mem` de Fulcrum, que se sube para
|
|
||||||
acelerar la sincronización inicial y es fácil olvidarse de bajar) e incluye
|
|
||||||
el comando para ver qué proceso ocupa el swap.
|
|
||||||
- Misma corrección en la explicación de SWAP de los DOCS.
|
|
||||||
|
|
||||||
### Añadido
|
|
||||||
- **Nueva comprobación: direcciones que quedan vinculadas entre sí.** Era el
|
|
||||||
hueco de fondo del analizador: medía la privacidad desde el lado de quien
|
|
||||||
*construye* la transacción (¿se ve mi cambio?, ¿pago cifras redondas?) y no
|
|
||||||
desde el lado de los dueños de las monedas gastadas, que es donde el daño es
|
|
||||||
irreversible. Gastar N direcciones distintas a la vez las enlaza para siempre
|
|
||||||
por CIOH, y eso no se decía en ningún sitio. El peso sube con el número de
|
|
||||||
direcciones y no aplica en CoinJoin, donde romper esa vinculación es el
|
|
||||||
objetivo.
|
|
||||||
|
|
||||||
### Corregido
|
|
||||||
- **El explorador mostraba saldo cero en direcciones con mucho historial.** El
|
|
||||||
backend de Mempool sobre Fulcrum devuelve los importes a cero cuando la
|
|
||||||
dirección tiene muchas transacciones, aunque el contador venga bien — y la app
|
|
||||||
los presentaba como ciertos. Una dirección con 492 transacciones aparecía con
|
|
||||||
"Balance: 0,00000000 BTC". Ahora se detecta la inconsistencia (tener
|
|
||||||
transacciones y no haber recibido nada es imposible en la cadena), los
|
|
||||||
importes se muestran como "no disponible" y se explica por qué. El contador de
|
|
||||||
transacciones y el historial, que sí son correctos, se mantienen.
|
|
||||||
- Mismo arreglo en el perfil de dirección del peritaje: antes concluía "no es
|
|
||||||
hot wallet" cuando en realidad no había podido evaluar la señal de flujo.
|
|
||||||
- **El fingerprinting atribuía transacciones a Electrum por AUSENCIA de rasgos**
|
|
||||||
(locktime 0, sin RBF, sequence por defecto). Es justo al revés: Electrum
|
|
||||||
moderno pone locktime a la altura actual y señaliza RBF. Lo que esas señales
|
|
||||||
describen no es un monedero concreto, sino software que no configura nada.
|
|
||||||
Ahora Electrum exige señales positivas y aparece un resultado nuevo, **"sin
|
|
||||||
rasgos distintivos"**, que además no penaliza: no dejar huella es lo contrario
|
|
||||||
de tener una huella identificable.
|
|
||||||
|
|
||||||
---
|
|
||||||
## [1.12.1] — 2026-07-27
|
|
||||||
### Corregido
|
|
||||||
- **El monitor mostraba una CPU por proceso que engañaba.** La columna `%CPU`
|
|
||||||
de `ps aux` no es el consumo actual sino la **media desde que el proceso
|
|
||||||
arrancó** (tiempo de CPU dividido por tiempo de vida). Un proceso que trabajó
|
|
||||||
mucho hace tres días seguía apareciendo alto para siempre, y se leía como si
|
|
||||||
estuviera saturando la máquina. Se detectó porque los porcentajes por proceso
|
|
||||||
no cambiaban NUNCA entre lecturas mientras la CPU global sí variaba.
|
|
||||||
- Ahora se mide de verdad: dos lecturas de `/proc/PID/stat` separadas 500 ms,
|
|
||||||
el mismo método que ya usaba `getCpuUsage` para el total. Las dos esperas
|
|
||||||
corren en paralelo dentro del mismo `Promise.all`, así que no cuesta tiempo.
|
|
||||||
- La interfaz muestra el **porcentaje sobre el total de la máquina**, que es
|
|
||||||
lo que suele querer saberse, con el porcentaje de un núcleo y la media de
|
|
||||||
`ps` en el tooltip para poder comparar con `top`.
|
|
||||||
- Comprobado con carga artificial: el proceso ocupado marcaba 100% de un
|
|
||||||
núcleo (25% de un sistema de 4) mientras `ps` daba 152%, imposible para un
|
|
||||||
proceso de un solo hilo.
|
|
||||||
|
|
||||||
### Cambiado
|
|
||||||
- El UTXO Map pedía las transacciones de contexto con `fetchWithTimeout`
|
|
||||||
directo, saltándose la caché añadida en la 1.11.0. Era el único punto que no
|
|
||||||
la aprovechaba. Ahora pasa por `get()`, así que esas transacciones —ya
|
|
||||||
confirmadas, e inmutables— se guardan toda la sesión.
|
|
||||||
|
|
||||||
### Documentación
|
|
||||||
- **Reescrita la guía de instalación.** `SETUP.md` no era una guía: más de la
|
|
||||||
mitad eran los pasos personales de git y Gitea del autor ("PASO 1 — Crear el
|
|
||||||
repo en Gitea"), que se han movido a un documento privado. Ahora es una guía
|
|
||||||
real, con una comprobación tras cada paso para saber si vas bien sin
|
|
||||||
descubrirlo al final con una pantalla en blanco.
|
|
||||||
- **Corregida la configuración de nginx documentada, que estaba rota.** El
|
|
||||||
README indicaba `alias /var/www/txoko/dashboard.html` — un alias a un
|
|
||||||
ARCHIVO. Desde la 1.11.0, con las librerías en `vendor/` y rutas relativas,
|
|
||||||
eso hace que el navegador no las encuentre y la página quede en blanco sin
|
|
||||||
ningún error visible. Debe apuntar al **directorio**. Es el fallo más común
|
|
||||||
de esta instalación y ahora está avisado en tres sitios, con una comprobación
|
|
||||||
concreta (`curl` mirando el `content-type`, no solo el código 200) para
|
|
||||||
descartarlo.
|
|
||||||
- Los pasos de instalación no mencionaban las librerías del frontend, sin las
|
|
||||||
cuales la aplicación no arranca. Ya están integradas en el orden correcto.
|
|
||||||
- Eliminados del repositorio los datos personales del autor: rutas de su
|
|
||||||
máquina en la documentación y, sobre todo, su nombre de usuario del sistema
|
|
||||||
y su ruta de instalación dentro de `txoko-metrics.service`, que llevaban ahí
|
|
||||||
desde el primer commit. El servicio usa ahora marcadores que hay que ajustar
|
|
||||||
antes de instalarlo.
|
|
||||||
- README: estructura del repositorio actualizada (faltaban `tests/`,
|
|
||||||
`instalar-fuentes.sh` y `FIXTURES.md`) y sección de instalación reducida a un
|
|
||||||
resumen que remite a SETUP.md, para que haya una sola fuente de verdad.
|
|
||||||
- SETUP.md incluye ahora tabla de problemas frecuentes y cómo actualizar.
|
|
||||||
|
|
||||||
---
|
|
||||||
## [1.12.0] — 2026-07-27
|
|
||||||
### Añadido
|
|
||||||
- **Auditoría de PSBT: revisar una transacción ANTES de firmarla.** Hasta ahora
|
|
||||||
toda la app era diagnóstico *a posteriori* — te contaba con detalle lo que ya
|
|
||||||
había pasado y no podías cambiar. Esta es la primera pieza que llega a tiempo.
|
|
||||||
Sustituye al antiguo "validador", que solo comprobaba que el archivo empezara
|
|
||||||
por `psbt` y decía su tamaño.
|
|
||||||
- **Parser completo de BIP174 escrito desde cero**, sin librerías, como el
|
|
||||||
resto del proyecto. Validado contra los cinco vectores inválidos que publica
|
|
||||||
el propio estándar: los rechaza los cinco, cada uno con un mensaje que
|
|
||||||
explica en castellano qué está mal (truncada, sin salidas, ya firmada,
|
|
||||||
sin transacción interna, con claves repetidas).
|
|
||||||
- **Todo el análisis es offline.** Una PSBT bien formada ya lleva dentro los
|
|
||||||
importes y scripts de sus entradas, así que no hace falta consultar el nodo:
|
|
||||||
funciona con el nodo sincronizando, y el nodo ni se entera.
|
|
||||||
- Acepta base64, hexadecimal o abrir el archivo `.psbt` directamente.
|
|
||||||
- **Aviso crítico sobre xpubs incrustados.** Las PSBT suelen llevar dentro las
|
|
||||||
claves públicas maestras de la cartera, y están hechas para compartirse — se
|
|
||||||
mandan por correo o chat para que el resto firme. Quien reciba el archivo
|
|
||||||
puede derivar todas las direcciones, presentes y futuras, y ver el saldo y
|
|
||||||
el historial completos. No puede gastar, pero lo ve todo. Ningún wallet
|
|
||||||
avisa de esto.
|
|
||||||
- Detecta además: cambio identificable por tipo de dirección, importes
|
|
||||||
redondos que delatan cuál es el pago, envíos a la propia cartera, carteras
|
|
||||||
multifirma (con su M-de-N) y PSBTs incompletas sin los importes de entrada.
|
|
||||||
- Cada aviso mantiene el formato del resto de la app — Hecho, Consecuencia y
|
|
||||||
**Qué puedes hacer** —, que aquí cobra sentido literal: todavía estás a
|
|
||||||
tiempo de cambiar la transacción.
|
|
||||||
|
|
||||||
### Corregido
|
|
||||||
- **Revisión de la criptografía watch-only.** Se verificó la derivación completa
|
|
||||||
contra los vectores oficiales de los estándares: RIPEMD-160 (los seis del
|
|
||||||
estándar, incluido el de un millón de caracteres), secp256k1, BIP32 (vectores
|
|
||||||
1 y 2) y bech32/BIP173. **La matemática es correcta y no se tocó.** Los fallos
|
|
||||||
estaban en la validación de la entrada:
|
|
||||||
- **No se comprobaba la suma de verificación del xpub.** Un carácter mal
|
|
||||||
copiado se aceptaba sin protestar y generaba 200 direcciones ajenas: el
|
|
||||||
usuario habría visto su cartera "sin actividad" y se habría quedado
|
|
||||||
tranquilo. Falso negativo silencioso, el mismo patrón que el resto de fallos
|
|
||||||
de esta jornada. Nuevo `decodeBase58Check`.
|
|
||||||
- **No se validaba la longitud** del material decodificado (deben ser 78
|
|
||||||
bytes) ni que la clave pública fuese comprimida (0x02/0x03).
|
|
||||||
- **`tpub` se trataba como mainnet.** La red se detectaba mirando si el texto
|
|
||||||
empezaba por "tb", "u" o "v" — y un `tpub`, el formato más común de testnet,
|
|
||||||
empieza por "t" pero no por "tb". Generaba direcciones `bc1…` a partir de
|
|
||||||
claves de testnet. Ahora se detecta por bytes de versión, que son
|
|
||||||
inequívocos, con las diez variantes (x/y/z/Y/Z y t/u/v/U/V).
|
|
||||||
- **`deriveChildPubkey` aceptaba índices endurecidos**, matemáticamente
|
|
||||||
imposibles desde una clave pública. No era alcanzable desde la interfaz,
|
|
||||||
pero una función criptográfica debe defenderse sola.
|
|
||||||
- Añadida la carpeta `tests/` con las cuatro baterías y su documentación,
|
|
||||||
incluido qué **no** cubren: no sustituyen una auditoría externa.
|
|
||||||
|
|
||||||
---
|
|
||||||
## [1.11.0] — 2026-07-27
|
|
||||||
### Añadido
|
|
||||||
- **Las librerías del frontend se sirven desde tu propio nodo.** React, ReactDOM
|
|
||||||
y Babel se cargaban desde unpkg.com, y las fuentes IBM Plex desde
|
|
||||||
fonts.googleapis.com. Ninguno de los dos veía qué transacciones analizabas —
|
|
||||||
pero ambos recibían tu IP pública, la hora y el referer en cada apertura del
|
|
||||||
dashboard: sabían que usabas Txoko, cuándo y desde dónde. Ese metadato es
|
|
||||||
justo lo que esta herramienta enseña a proteger. Ahora no sale ni una
|
|
||||||
petición fuera de tu red, verificable con
|
|
||||||
`grep -E '(src|href)="https?://' dashboard.html` (no devuelve nada).
|
|
||||||
- Consecuencia práctica: **el dashboard funciona sin conexión a internet**.
|
|
||||||
- Las librerías se descargan una vez durante la instalación y se **verifican
|
|
||||||
por hash** (SHA-256 publicados en SETUP.md).
|
|
||||||
- Versiones fijadas (`react@18.3.1`, `@babel/standalone@7.23.10`) en vez de
|
|
||||||
rangos: antes el CDN decidía qué versión te entregaba y podía cambiar sin
|
|
||||||
aviso.
|
|
||||||
- `instalar-fuentes.sh` automatiza la descarga de IBM Plex (licencia OFL) y
|
|
||||||
genera el CSS. Comprueba cada archivo y aborta si algo falla.
|
|
||||||
- **Caché de respuestas del nodo con TTL y coalescencia** en `useApi`. Las
|
|
||||||
pestañas se montan y desmontan al navegar, así que ir a otra pestaña y volver
|
|
||||||
repetía todas las peticiones. Ahora:
|
|
||||||
- Las **transacciones confirmadas se cachean toda la sesión** — son
|
|
||||||
inmutables, no hay motivo para volver a pedirlas. Analizador, rastro de
|
|
||||||
procedencia y peritaje dejan de pedir las mismas por separado.
|
|
||||||
- Historial de direcciones 60 s, bloques 60 s, mempool y comisiones 20 s,
|
|
||||||
estado del sistema 2 s (para que el monitor siga mostrando datos vivos).
|
|
||||||
- **Coalescencia:** dos componentes que piden lo mismo a la vez generan una
|
|
||||||
sola petición al nodo, no dos. Mismo patrón que ya usaba
|
|
||||||
`system-metrics.js` en el servidor.
|
|
||||||
- Medido: repetir un rastreo forense ya explorado cuesta **cero peticiones**.
|
|
||||||
|
|
||||||
### Cambiado
|
|
||||||
- **Un hit de OFAC ya no penaliza la banda de privacidad.** Era un error
|
|
||||||
conceptual con consecuencias ideológicas: que tus monedas hayan pasado por una
|
|
||||||
dirección sancionada no revela nada más sobre ti — tu privacidad es idéntica
|
|
||||||
antes y después. Lo que cambia es la probabilidad de que un servicio regulado
|
|
||||||
te bloquee un depósito, que es otro eje: censurabilidad, no privacidad.
|
|
||||||
Restar puntos ahí equivalía a dar por buena la idea de "monedas contaminadas",
|
|
||||||
justo la que la fungibilidad de Bitcoin niega. Se sigue informando del riesgo
|
|
||||||
real, ahora como aviso de censura y con el contexto que faltaba: la lista OFAC
|
|
||||||
es una decisión política de un gobierno concreto, no una determinación
|
|
||||||
judicial, y fuera de su jurisdicción solo llega a través de intermediarios que
|
|
||||||
la aplican.
|
|
||||||
- **RBF pasa a informativo, sin penalización.** Es buena práctica —permite
|
|
||||||
desatascar una transacción sin sobrepagar de entrada— y hoy lo activan casi
|
|
||||||
todos los wallets, así que distingue poco. Restar puntos empujaba al usuario
|
|
||||||
hacia una decisión peor para esconder una señal débil.
|
|
||||||
- Checks con inferencia fuerte (`round_numbers`, `unnecessary_input`,
|
|
||||||
`output_type_mismatch`) reescritos con el patrón Hecho / Interpretación /
|
|
||||||
Consecuencia que ya usaban `dust` e `input_type_mixing`. Antes afirmaban de
|
|
||||||
más ("un analista puede identificarlo sin ambigüedad"); ahora explican cuándo
|
|
||||||
la señal falla y por qué son PROBABLE y no CERTEZA.
|
|
||||||
- `timing`: el texto didáctico describía patrones entre varias transacciones
|
|
||||||
cuando el check solo ve una. Además se aclara que la hora mostrada es la del
|
|
||||||
bloque, no la de la firma — entre ambas puede haber horas de mempool.
|
|
||||||
|
|
||||||
### Documentación
|
|
||||||
- README: nueva sección **"Sí, esto es chain analysis"**. El peritaje usa las
|
|
||||||
mismas técnicas que las empresas de vigilancia de cadena y negarlo restaba
|
|
||||||
credibilidad ante quien lee el código. Se explica qué cambia —quién lo
|
|
||||||
ejecuta, sobre qué, dónde se detiene, quién se queda el informe— y el motivo
|
|
||||||
de fondo: la misma herramienta que sigue el rastro de un ladrón demuestra lo
|
|
||||||
fácil que es seguir el tuyo.
|
|
||||||
- El informe forense advierte del coste de denunciar: entregarlo vincula tu
|
|
||||||
identidad legal con esas direcciones de forma permanente, ante una autoridad
|
|
||||||
que puede compartir el expediente con empresas de análisis. Antes se
|
|
||||||
recomendaba denunciar sin mencionar el precio.
|
|
||||||
- Aviso junto a los botones de exportación: el archivo lleva tus direcciones y
|
|
||||||
tu declaración dentro. Nada ha salido de tu red hasta ese punto; a partir de
|
|
||||||
ahí depende de quien lo descarga.
|
|
||||||
- Corregido "un único archivo HTML autocontenido" (aparecía cuatro veces): desde
|
|
||||||
la 1.11.0 las librerías viven en `vendor/`. Se sustituye por una descripción
|
|
||||||
exacta que además dice más — lo que lees en el archivo es lo que se ejecuta,
|
|
||||||
no hay versión compilada que auditar por separado.
|
|
||||||
|
|
||||||
### Corregido
|
|
||||||
- **El informe ya no atribuye al actor direcciones del otro lado de un
|
|
||||||
CoinJoin.** Detectado probando un rastro que atraviesa un Whirlpool real: el
|
|
||||||
informe declaraba *"el rastro se rompe en un CoinJoin — no se puede atribuir
|
|
||||||
con honestidad más allá de este punto"* y, tres líneas más abajo, listaba
|
|
||||||
**10 direcciones atribuidas al actor**, cinco de ellas salidas de esa misma
|
|
||||||
mezcla. Eran direcciones de otros participantes, señaladas en un documento
|
|
||||||
pensado para acompañar una denuncia.
|
|
||||||
- Los dos fundamentos que las colaban fallan precisamente ahí: **CIOH** asume
|
|
||||||
que quien gasta varios inputs juntos los controla, y un CoinJoin existe para
|
|
||||||
romper esa suposición (es la excepción clásica a la heurística); y la
|
|
||||||
**huella de software** sale estable por construcción, porque todos los
|
|
||||||
participantes usan el mismo programa.
|
|
||||||
- `finalizeForensicGraph` excluye ahora las transacciones marcadas como
|
|
||||||
`mixer` del conjunto de direcciones del actor y del union-find, y la
|
|
||||||
atribución por huella salta esos nodos.
|
|
||||||
- Verificado con el mismo caso: de 10 direcciones atribuidas a 0.
|
|
||||||
- **El informe de wallet ya no da un aprobado optimista cuando faltan datos.**
|
|
||||||
Mismo patrón que el fallo del peritaje, en la pieza central del proyecto:
|
|
||||||
`scanWallet` usaba `.catch(()=>[])` en las tres consultas del escaneo, así que
|
|
||||||
una dirección que el nodo no pudo servir quedaba indistinguible de una
|
|
||||||
dirección sin actividad. Sus transacciones no se traían, no contaban para la
|
|
||||||
reutilización, no entraban en el union-find y no aparecían en el historial —
|
|
||||||
y el informe daba su valoración de salud sin mencionar que le faltaban datos.
|
|
||||||
El sesgo iba siempre hacia el optimismo: menos actividad vista es mejor nota.
|
|
||||||
- Las tres consultas usan ahora `getStrict` y registran los fallos en
|
|
||||||
`report.scanErrors`, con la dirección, la rama y en qué fase ocurrió.
|
|
||||||
- Aviso en rojo **antes** de la banda de salud (una valoración leída sin saber
|
|
||||||
que faltan datos es peor que ninguna valoración), y bloque equivalente al
|
|
||||||
principio del export Markdown.
|
|
||||||
|
|
||||||
- **Tampoco atribuye al actor las direcciones de un custodio.** Misma raíz que
|
|
||||||
el fallo anterior, descubierta probando la poda por rama con un depósito real
|
|
||||||
en Bitfinex: el cluster CIOH ya excluía las direcciones de custodio, pero la
|
|
||||||
atribución por huella de software no, así que la hot wallet del exchange
|
|
||||||
acababa listada como "dirección del actor" mientras la conclusión, dos líneas
|
|
||||||
más abajo, la identificaba correctamente como Bitfinex. La dirección donde el
|
|
||||||
ladrón deposita es del exchange, no del ladrón. Ambos criterios están ahora
|
|
||||||
alineados: ni mezclas ni custodios entran por ninguna de las dos vías.
|
|
||||||
Verificado: de 2 direcciones atribuidas a 1 (solo la rama legítima).
|
|
||||||
|
|
||||||
---
|
|
||||||
## [1.10.1] — 2026-07-27
|
|
||||||
### Añadido
|
|
||||||
- **Tope de ramificación en el peritaje (`MAX_FANOUT`, 25 salidas).** Detectado
|
|
||||||
probando el fixture de dust attack: una transacción que reparte a cientos de
|
|
||||||
direcciones hacía que el motor encolara todas sus ramas y, sobre todo, que
|
|
||||||
perfilara cada dirección de salida — 2 peticiones por dirección, así que una
|
|
||||||
tx de 143 salidas suponía ~286 peticiones al nodo antes siquiera de llegar al
|
|
||||||
frontier, y la pestaña se congelaba. Ahora, por encima del tope, la tx se
|
|
||||||
trata como reparto masivo (dust attack, lote de retiradas, airdrop): se corta
|
|
||||||
ANTES de perfilar nada, con `stopReason: "fanOut"` y su propia conclusión en
|
|
||||||
el informe, redactada como límite deliberado y no como inferencia sobre quién
|
|
||||||
controla esas direcciones. Verificado con una tx real de 143 salidas: 2
|
|
||||||
peticiones en total.
|
|
||||||
|
|
||||||
### Corregido
|
|
||||||
- **Peritaje forense: un fallo de consulta ya no se presenta como "rastro
|
|
||||||
completo".** Detectado probando contra el nodo real: cuando el backend
|
|
||||||
devolvía un 503 o agotaba el timeout, la app lo mostraba como
|
|
||||||
*"✓ Rastro completo — no quedan ramas por explorar"* y contaba esa rama como
|
|
||||||
terminal. La causa eran dos capturas silenciosas encadenadas — `get` devolvía
|
|
||||||
`mockData` ante cualquier error y `findSpendingTx` remataba con
|
|
||||||
`.catch(()=>[])` —, así que `[]` por error era indistinguible de `[]` porque
|
|
||||||
el output sigue sin gastar. En un informe pericial eso convertía una consulta
|
|
||||||
fallida en la conclusión más accionable del documento: *fondos localizados
|
|
||||||
sin gastar*.
|
|
||||||
- Nuevo `getStrict` en `useApi`: propaga el fallo en vez de disfrazarlo.
|
|
||||||
`get` no cambia, así que el resto del dashboard mantiene su comportamiento.
|
|
||||||
- El motor (`findSpendingTx`, `advanceForensicHop`, `initForensicTrace`)
|
|
||||||
distingue "no se pudo consultar" de "no hay gasto" y registra los fallos en
|
|
||||||
`trace.queryErrors`.
|
|
||||||
- El informe excluye las ramas con consulta fallida de "fondos localizados
|
|
||||||
sin gastar", las lista en su propia sección y abre las conclusiones
|
|
||||||
avisando de que el rastreo está incompleto. Incluido en los export JSON y MD.
|
|
||||||
- La UI muestra contador de consultas fallidas y sustituye el falso
|
|
||||||
"rastro completo" por un aviso con el detalle de qué falló.
|
|
||||||
- Peritaje: el error al obtener la transacción de origen ya no culpa siempre al
|
|
||||||
txid — distingue un txid inexistente de un fallo del nodo.
|
|
||||||
- Peritaje: el botón "Generar informe" explica por qué está deshabilitado en
|
|
||||||
vez de quedarse inerte sin dar motivo.
|
|
||||||
|
|
||||||
### Cambiado
|
|
||||||
- El aviso de consulta fallida ya no dice que el fallo "suele ser transitorio":
|
|
||||||
cuando se repite sobre la misma dirección no lo es, y ahora lo explica —
|
|
||||||
historial demasiado pesado para servirlo dentro del tiempo de espera, con la
|
|
||||||
indicación de comprobarla a mano antes de dar el rastro por cerrado.
|
|
||||||
|
|
||||||
### Documentación
|
|
||||||
- README: documentadas dos limitaciones reales del peritaje — las ramas que no
|
|
||||||
se pueden comprobar (y por qué cortar es deliberado, no un defecto), y que el
|
|
||||||
tope de volumen mide número de transacciones y no peso, así que una dirección
|
|
||||||
con pocas transacciones muy grandes puede agotar el tiempo de espera igual.
|
|
||||||
|
|
||||||
---
|
|
||||||
## [1.10.0] — 2026-07-16
|
|
||||||
### Añadido
|
|
||||||
- Peritaje forense: nueva pestaña que rastrea fondos robados o perdidos hacia
|
|
||||||
adelante, salto a salto, desde una transacción de origen (`txid:vout`) hasta
|
|
||||||
un punto de parada natural — custodio identificado, dilución excesiva,
|
|
||||||
CoinJoin real, o fondos aún sin gastar. Todo el rastreo corre sobre tu
|
|
||||||
propio nodo (Bitcoin Core + Fulcrum + Mempool self-hosted), como el resto
|
|
||||||
de la app: ninguna consulta sale de tu red.
|
|
||||||
- Sigue todos los outputs de cada salto (no solo el presunto cambio),
|
|
||||||
porque los fondos pueden repartirse en varias ramas; cada rama se
|
|
||||||
detiene de forma independiente.
|
|
||||||
- **A demanda, salto a salto:** el rastreo no corre solo — tú decides
|
|
||||||
cuándo se explora el siguiente salto con el botón "Seguir el rastro",
|
|
||||||
viendo en todo momento cuántas transacciones se han explorado y
|
|
||||||
cuántas ramas quedan pendientes. Mismo espíritu que el rastro de
|
|
||||||
procedencia hacia atrás (RastroProcedencia), aplicado al rastreo hacia
|
|
||||||
adelante. Puedes generar el informe con lo explorado hasta ese momento,
|
|
||||||
sin necesidad de terminar el rastreo entero.
|
|
||||||
- Tope de volumen de transacciones (`tx_count`) como cinturón de
|
|
||||||
seguridad aparte del control manual: si una dirección de salida tiene
|
|
||||||
un historial extremadamente largo (miles de transacciones — típico de
|
|
||||||
un hot wallet de exchange), el rastro se detiene ahí por precaución
|
|
||||||
aunque el heurístico de perfil no la clasifique como custodio. Evita
|
|
||||||
que una sola rama intente paginar el historial completo de una
|
|
||||||
dirección con decenas de miles de transacciones.
|
|
||||||
- Reutiliza las heurísticas existentes del analizador (CIOH, detección de
|
|
||||||
cambio estructural, huella de wallet, CoinJoin, entidades conocidas) y
|
|
||||||
añade tres nuevas: perfil de dirección (personal vs hot wallet de
|
|
||||||
exchange), cambio conductual (se gasta rápido vs queda quieto) cruzado
|
|
||||||
con la señal estructural, y comparación de huella de software entre
|
|
||||||
saltos consecutivos.
|
|
||||||
- Confirma cadenas de peeling multi-salto: el check de patrón de pago
|
|
||||||
simple ya avisaba de que una sola transacción no basta para confirmar
|
|
||||||
una cadena — este módulo es lo que cierra ese hueco, siguiendo el rastro
|
|
||||||
hacia adelante de verdad.
|
|
||||||
- Marco de tres niveles en cada afirmación del informe: HECHO (dato
|
|
||||||
on-chain verificable), INFERENCIA (conclusión de una heurística, con su
|
|
||||||
propia sub-etiqueta CERTEZA/PROBABLE/POSIBLE) y DECLARACIÓN (lo que
|
|
||||||
cuenta el afectado, en un formulario de texto libre, siempre mostrado
|
|
||||||
aparte y nunca mezclado con los hechos).
|
|
||||||
- Informe con resumen, declaración del afectado, cronología, direcciones
|
|
||||||
atribuidas con su fundamento, fondos localizados sin gastar,
|
|
||||||
conclusiones numeradas, recomendaciones (con un aviso fijo contra
|
|
||||||
estafas de "recuperación de fondos" siempre que hay declaración),
|
|
||||||
metodología y anexo de verificación. Exportable a JSON y Markdown, igual
|
|
||||||
que el informe de wallet.
|
|
||||||
- No identifica la identidad real de nadie: como mucho llega a "hot wallet
|
|
||||||
de [exchange]" o "custodio no identificado". No hace denuncias ni envíos
|
|
||||||
automáticos a terceros — el informe es un documento que decides tú a
|
|
||||||
dónde llevar.
|
|
||||||
|
|
||||||
### Cambiado
|
|
||||||
- `unionFindCluster` (vinculación por CIOH) y `guessChangeOutput` (señales de
|
|
||||||
cambio estructural) se extrajeron de `buildWalletReport` y `analyzeTx`
|
|
||||||
respectivamente a funciones compartidas, sin cambio de comportamiento, para
|
|
||||||
que el peritaje forense las reutilice salto a salto.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
## [1.9.0] — 2026-06-12
|
## [1.9.0] — 2026-06-12
|
||||||
### Añadido
|
### Añadido
|
||||||
|
|||||||
-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,78 +22,34 @@ Tan importante como saber qué hace es saber qué no hace:
|
|||||||
|
|
||||||
- **No envía datos a ningún servidor externo** — ni analíticas, ni telemetría, ni consultas a APIs de terceros
|
- **No envía datos a ningún servidor externo** — ni analíticas, ni telemetría, ni consultas a APIs de terceros
|
||||||
- **No toca claves privadas** — el watch-only usa solo tu clave pública (xpub); no puede gastar fondos ni conoce tu semilla
|
- **No toca claves privadas** — el watch-only usa solo tu clave pública (xpub); no puede gastar fondos ni conoce tu semilla
|
||||||
- **No llega nunca a una identidad** — se detiene en "hot wallet de [servicio]" o "custodio no identificado", y lo dice explícitamente. Ver la sección siguiente
|
- **No identifica a otras personas** — es una herramienta de auto-auditoría, no de vigilancia
|
||||||
|
- **No da consejos de inversión** — analiza privacidad on-chain, nada más
|
||||||
- **No afirma más de lo que sabe** — distingue siempre entre CERTEZA, PROBABLE e POSIBLE
|
- **No afirma más de lo que sabe** — distingue siempre entre CERTEZA, PROBABLE e POSIBLE
|
||||||
- **No tiene número de score exacto** — muestra bandas (ALTA/MEDIA/BAJA); la precisión numérica sería falsa
|
- **No tiene número de score exacto** — muestra bandas (ALTA/MEDIA/BAJA); la precisión numérica sería falsa
|
||||||
- **No muestra el contenido de OP_RETURN** — solo informa de su presencia y tamaño
|
- **No muestra el contenido de OP_RETURN** — solo informa de su presencia y tamaño
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Sí, esto es chain analysis
|
|
||||||
|
|
||||||
Conviene decirlo claro, porque el código está a la vista y cualquiera puede
|
|
||||||
comprobarlo: el peritaje forense aplica **las mismas técnicas que las empresas
|
|
||||||
de vigilancia de cadena**. Rastreo hacia adelante salto a salto, clustering por
|
|
||||||
CIOH, perfilado de direcciones, huella de software, un índice de direcciones de
|
|
||||||
exchanges. Es el arsenal de Chainalysis, técnica por técnica.
|
|
||||||
|
|
||||||
Fingir lo contrario sería insultar tu inteligencia. Lo que cambia no es el
|
|
||||||
método, es todo lo demás:
|
|
||||||
|
|
||||||
- **Quién lo ejecuta.** Corre en tu nodo, con tus datos, bajo tu control. No
|
|
||||||
hay un tercero que acumule los resultados ni los venda a quien pague.
|
|
||||||
- **Sobre quién.** Tu propia actividad, o el rastro de unas monedas que te
|
|
||||||
quitaron a ti. No es un servicio que perfile a desconocidos por encargo.
|
|
||||||
- **Dónde se detiene.** En un servicio, nunca en una persona. Y cuando una
|
|
||||||
heurística no llega, el informe dice que no llega en vez de rellenar el hueco.
|
|
||||||
- **Quién se queda el informe.** Tú. No sale de tu red y decides qué hacer con
|
|
||||||
él — incluido no hacer nada.
|
|
||||||
|
|
||||||
Y hay una razón más, la que de verdad justifica que esto exista:
|
|
||||||
|
|
||||||
> **La misma herramienta que sigue el rastro de un ladrón demuestra lo fácil
|
|
||||||
> que es seguir el tuyo.**
|
|
||||||
|
|
||||||
No hay forma más honesta de entender qué puede deducir un observador de tus
|
|
||||||
monedas que ejecutar sus heurísticas sobre ellas y leer el resultado. Un
|
|
||||||
adversario con estas capacidades ya existe, tenga o no tú una herramienta para
|
|
||||||
verlo. Conocer el arma no es adoptarla: es dejar de estar ciego ante ella.
|
|
||||||
|
|
||||||
Si esto te parece una línea demasiado fina, es una objeción razonable. El
|
|
||||||
código está entero en un archivo para que puedas juzgarlo tú.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Cómo verificar que nada sale de tu red
|
## Cómo verificar que nada sale de tu red
|
||||||
|
|
||||||
Todo el código de análisis vive en un único archivo HTML, sin minificar y sin proceso de compilación. Puedes auditarlo tú mismo:
|
El código es un único archivo HTML autocontenido. Puedes auditarlo tú mismo:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Buscar cualquier llamada a dominios externos
|
# Buscar cualquier llamada a dominios externos
|
||||||
grep -E "fetch\(|XMLHttpRequest|src=\"http|href=\"http" dashboard.html
|
grep -E "fetch\(|XMLHttpRequest|src=\"http|href=\"http" dashboard.html
|
||||||
```
|
```
|
||||||
|
|
||||||
El comando anterior no debe devolver **ninguna** URL externa. Todas las llamadas van a rutas relativas (`/api/`, `/system/`, `/vendor/`) que apuntan a tu propio nodo vía nginx.
|
Verás que todas las llamadas van a rutas relativas (`/api/`, `/system/`) que apuntan a tu propio nodo vía nginx. No hay ninguna llamada a dominios externos en el código de análisis.
|
||||||
|
|
||||||
Esto incluye las librerías del frontend (React y Babel): también se sirven desde tu nodo, no desde un CDN. Hasta la versión 1.10.1 se cargaban desde unpkg.com, y aunque un CDN nunca vio qué transacciones analizabas, sí recibía tu IP pública cada vez que abrías el dashboard — es decir, sabía que usabas Txoko, cuándo y desde dónde. Ese metadato es justo lo que esta herramienta enseña a proteger, así que se eliminó.
|
Las únicas dependencias externas son las librerías del frontend (React, Babel) que se cargan desde CDN al abrir la página. Estas no reciben ningún dato de tus transacciones — solo sirven el código de la interfaz.
|
||||||
|
|
||||||
Consecuencia práctica: **el dashboard funciona sin conexión a internet**. Solo necesita tu nodo. Las librerías se descargan una vez durante la instalación y se verifican por hash — ver [SETUP.md](SETUP.md).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Funcionalidades
|
## Funcionalidades
|
||||||
|
|
||||||
**Auditoría de privacidad**
|
**Auditoría de privacidad**
|
||||||
- Análisis de transacción con más de 25 heurísticas ponderadas
|
- Análisis de transacción con más de 20 heurísticas ponderadas
|
||||||
- La nota mide **solo lo que dependía de ti**. Lo que te expone por decisión de
|
|
||||||
un tercero (recibir de un lote, recibir polvo) se informa aparte, porque una
|
|
||||||
nota que no puedes mejorar no evalúa nada
|
|
||||||
- Detección del output de cambio por señales combinadas, que se callan cuando
|
|
||||||
no coinciden en lugar de señalar la salida equivocada
|
|
||||||
- Detección de CoinJoin: Whirlpool (denominaciones fijas), WabiSabi (estructura de mezcla), CoinJoin genérico
|
- Detección de CoinJoin: Whirlpool (denominaciones fijas), WabiSabi (estructura de mezcla), CoinJoin genérico
|
||||||
- Aviso cuando el polvo recibido **se gasta** junto a otras monedas: ahí el
|
|
||||||
ataque de dusting deja de ser hipótesis
|
|
||||||
- Consolidación y vinculación CIOH: cuántas direcciones tuyas quedan atadas
|
|
||||||
- Detección de OP_RETURN: presencia y tamaño, sin mostrar contenido
|
- Detección de OP_RETURN: presencia y tamaño, sin mostrar contenido
|
||||||
- Detección de tipo legacy (P2PKH/P2SH) vs SegWit/Taproot
|
- Detección de tipo legacy (P2PKH/P2SH) vs SegWit/Taproot
|
||||||
- Wallet fingerprinting: Bitcoin Core, Sparrow, BlueWallet, Taproot nativo y otros
|
- Wallet fingerprinting: Bitcoin Core, Sparrow, BlueWallet, Taproot nativo y otros
|
||||||
@@ -104,27 +60,6 @@ Consecuencia práctica: **el dashboard funciona sin conexión a internet**. Solo
|
|||||||
- Bandas de privacidad con distinción explícita CERTEZA / PROBABLE / POSIBLE
|
- Bandas de privacidad con distinción explícita CERTEZA / PROBABLE / POSIBLE
|
||||||
- Exportación del informe en JSON y Markdown
|
- Exportación del informe en JSON y Markdown
|
||||||
|
|
||||||
**Auditoría de PSBT — antes de firmar**
|
|
||||||
|
|
||||||
La única función de la app que llega a tiempo: el resto te cuenta lo que ya
|
|
||||||
pasó, esta te avisa cuando todavía puedes cambiar la transacción.
|
|
||||||
|
|
||||||
- Parser completo de BIP174 escrito desde cero, validado contra los cinco vectores inválidos del propio estándar
|
|
||||||
- **Todo offline**: una PSBT ya lleva dentro los importes y scripts de sus entradas, así que no se consulta el nodo. Funciona con el nodo sincronizando
|
|
||||||
- Avisa de las **claves públicas maestras incrustadas**: las PSBT suelen llevarlas dentro y están hechas para compartirse — quien reciba el archivo puede ver todas tus direcciones, tu saldo y tu historial. No puede gastar, pero lo ve todo
|
|
||||||
- Detecta cambio identificable por tipo de dirección, importes redondos que delatan cuál es el pago, envíos a tu propia cartera, carteras multifirma y PSBTs incompletas
|
|
||||||
- Acepta base64, hexadecimal o el archivo `.psbt` que exporta Sparrow
|
|
||||||
|
|
||||||
**Peritaje forense**
|
|
||||||
- Rastreo de fondos robados o perdidos hacia adelante, salto a salto, desde una transacción de origen hasta un punto de parada natural (custodio identificado, dilución, CoinJoin, o fondos aún sin gastar)
|
|
||||||
- A demanda: tú decides cuándo se explora cada salto con el botón "Seguir el rastro" — nada corre sin que lo pidas, igual que el rastro de procedencia hacia atrás. Puedes generar el informe con lo explorado hasta ese momento, sin terminar el rastreo entero
|
|
||||||
- Tope de volumen de transacciones como cinturón de seguridad extra: una dirección con un historial enorme (típico de un hot wallet de exchange) detiene el rastro por precaución, para no intentar paginar decenas de miles de transacciones
|
|
||||||
- Reutiliza las heurísticas del analizador (CIOH, cambio, huella de wallet, entidades) y añade perfil de dirección (personal vs hot wallet), cambio conductual y comparación de huella entre saltos
|
|
||||||
- Confirma cadenas de peeling multi-salto, algo que el análisis de una sola transacción no puede hacer por sí solo
|
|
||||||
- Cada afirmación del informe se etiqueta como HECHO, INFERENCIA (con su propia certeza) o DECLARACIÓN — nunca se mezclan
|
|
||||||
- Informe exportable a JSON y Markdown con cronología, direcciones atribuidas, fondos sin gastar, conclusiones y metodología
|
|
||||||
- No identifica la identidad real de nadie; no envía nada a terceros — el informe es tuyo para decidir qué hacer con él
|
|
||||||
|
|
||||||
**Integración con Sparrow**
|
**Integración con Sparrow**
|
||||||
- Importación de etiquetas BIP-329: carga el `.jsonl` que exporta Sparrow y muestra tus propias etiquetas junto al análisis. Se lee en memoria, no sale del navegador
|
- Importación de etiquetas BIP-329: carga el `.jsonl` que exporta Sparrow y muestra tus propias etiquetas junto al análisis. Se lee en memoria, no sale del navegador
|
||||||
- Watch-only por xpub/zpub: pega tu Master Public Key y Txoko deriva tus direcciones en local para marcar, en cada transacción, qué outputs son tuyos (recepción o cambio). La derivación BIP32 está implementada sin librerías externas; el xpub nunca sale del navegador. Requiere HTTPS (ver nota más abajo)
|
- Watch-only por xpub/zpub: pega tu Master Public Key y Txoko deriva tus direcciones en local para marcar, en cada transacción, qué outputs son tuyos (recepción o cambio). La derivación BIP32 está implementada sin librerías externas; el xpub nunca sale del navegador. Requiere HTTPS (ver nota más abajo)
|
||||||
@@ -142,7 +77,7 @@ pasó, esta te avisa cuando todavía puedes cambiar la transacción.
|
|||||||
- Conversor sat/BTC/fiat
|
- Conversor sat/BTC/fiat
|
||||||
- Validador de dirección
|
- Validador de dirección
|
||||||
- Detector OP_RETURN
|
- Detector OP_RETURN
|
||||||
- Decodificador de transacción raw
|
- Decodificador PSBT y transacción raw
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -170,29 +105,66 @@ Probado sobre Ubuntu Server 24.04 con HP EliteDesk (i5, 32GB RAM, 2TB NVMe).
|
|||||||
|
|
||||||
## Instalación
|
## Instalación
|
||||||
|
|
||||||
**La guía completa está en [SETUP.md](SETUP.md)** — paso a paso, con una
|
### 1. Clonar el repositorio
|
||||||
comprobación después de cada uno para que sepas si vas bien sin tener que
|
|
||||||
descubrirlo al final.
|
|
||||||
|
|
||||||
Resumen de lo que implica:
|
```bash
|
||||||
|
git clone https://git.bitcointxoko.org/pikaro/txoko-dashboard.git
|
||||||
|
cd txoko-dashboard
|
||||||
|
```
|
||||||
|
|
||||||
1. Clonar el repositorio.
|
### 2. Copiar el dashboard
|
||||||
2. Copiar `dashboard.html` a un **directorio** propio servido por nginx.
|
|
||||||
3. Descargar las librerías (React, Babel y las fuentes) a `vendor/`, junto al
|
|
||||||
dashboard, y verificarlas por hash. No están en el repo: son de terceros y
|
|
||||||
ocupan ~3 MB.
|
|
||||||
4. Opcionalmente, instalar el monitor del sistema como servicio systemd.
|
|
||||||
5. Configurar nginx: el dashboard, un proxy a la API de Mempool y otro al
|
|
||||||
monitor.
|
|
||||||
|
|
||||||
Un aviso que ahorra disgustos: en nginx, el `alias` del dashboard debe apuntar
|
```bash
|
||||||
al **directorio** (con barra final), no al archivo `dashboard.html`. Si apunta
|
cp dashboard.html /var/www/txoko/dashboard.html
|
||||||
al archivo, el navegador no encuentra `vendor/` y verás una página en blanco
|
# o donde lo sirvas con nginx
|
||||||
sin ningún error visible. Es el fallo más común, y en SETUP.md hay una
|
```
|
||||||
comprobación concreta para descartarlo.
|
|
||||||
|
|
||||||
Cuando termines, abre `http://TU-IP:4080/dashboard/` — **con la barra final** —,
|
### 3. Copiar el backend de métricas
|
||||||
pulsa CONFIG e introduce la URL de tu Mempool.
|
|
||||||
|
```bash
|
||||||
|
cp system-metrics.js /home/armg/txoko/system-metrics.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Editar `system-metrics.js` y añadir tus credenciales RPC de Bitcoin Core
|
||||||
|
(el archivo del repo usa placeholders).
|
||||||
|
|
||||||
|
### 4. Configurar el servicio systemd
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp txoko-metrics.service /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable txoko-metrics
|
||||||
|
sudo systemctl start txoko-metrics
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Configurar nginx
|
||||||
|
|
||||||
|
Añadir a tu configuración de nginx:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# Dashboard
|
||||||
|
location /dashboard {
|
||||||
|
alias /var/www/txoko/dashboard.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API Mempool (ajusta el puerto según tu instalación)
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://localhost:8999;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Métricas del sistema
|
||||||
|
location /system/ {
|
||||||
|
proxy_pass http://127.0.0.1:4082;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Abrir en el navegador
|
||||||
|
|
||||||
|
```
|
||||||
|
http://TU-IP-TAILSCALE:4080/dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
Introduce la URL de tu Mempool en el modal de configuración y empieza a analizar.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -200,25 +172,16 @@ pulsa CONFIG e introduce la URL de tu Mempool.
|
|||||||
|
|
||||||
```
|
```
|
||||||
txoko-dashboard/
|
txoko-dashboard/
|
||||||
├── dashboard.html # La aplicación entera (HTML + CSS + JS en un archivo)
|
├── dashboard.html # Frontend completo (HTML + CSS + JS en un solo archivo)
|
||||||
├── system-metrics.js # Monitor del nodo (Node.js) — opcional
|
├── system-metrics.js # Backend de métricas del nodo (Node.js)
|
||||||
├── txoko-metrics.service # Servicio systemd para el monitor
|
├── txoko-metrics.service # Servicio systemd
|
||||||
├── instalar-fuentes.sh # Descarga IBM Plex al nodo y genera su CSS
|
|
||||||
├── tests/ # Pruebas de la criptografía contra vectores oficiales
|
|
||||||
├── SETUP.md # Guía de instalación
|
|
||||||
├── FIXTURES.md # Transacciones reales para probar las heurísticas
|
|
||||||
├── CHANGELOG.md
|
|
||||||
├── README.md
|
├── README.md
|
||||||
|
├── SETUP.md
|
||||||
|
├── CHANGELOG.md
|
||||||
└── LICENSE
|
└── LICENSE
|
||||||
```
|
```
|
||||||
|
|
||||||
Tras la instalación, junto al `dashboard.html` queda además un directorio
|
El frontend es un único archivo HTML autocontenido. Sin bundler, sin npm, sin proceso de build. Babel transpila el JSX en el navegador. Puedes auditarlo todo abriendo el archivo.
|
||||||
`vendor/` con las librerías y las fuentes. No está en el repositorio: se
|
|
||||||
descarga y se verifica durante la instalación (ver [SETUP.md](SETUP.md)).
|
|
||||||
|
|
||||||
El frontend es un único archivo HTML. Sin bundler, sin npm, sin proceso de build: Babel transpila el JSX en el navegador, así que lo que lees en el archivo es exactamente lo que se ejecuta — no hay una versión compilada que auditar por separado.
|
|
||||||
|
|
||||||
Las librerías (React, Babel, las fuentes) viven aparte, en `vendor/`, servidas desde tu propio nodo y verificadas por hash durante la instalación. Se dejan fuera del repo a propósito: son código de terceros, y mezclarlas con el tuyo haría más difícil auditar lo que de verdad importa.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -230,9 +193,6 @@ Las librerías (React, Babel, las fuentes) viven aparte, en `vendor/`, servidas
|
|||||||
- **Base de datos de entidades parcial** — ~745 direcciones de exchanges, OFAC y minería. Cubre los casos más comunes; no es completa
|
- **Base de datos de entidades parcial** — ~745 direcciones de exchanges, OFAC y minería. Cubre los casos más comunes; no es completa
|
||||||
- **Fingerprinting conservador** — requiere varias señales coincidentes; prefiere no detectar antes que detectar mal
|
- **Fingerprinting conservador** — requiere varias señales coincidentes; prefiere no detectar antes que detectar mal
|
||||||
- **Sin análisis de red** — no cruza datos con otros nodos ni mempool distribuida
|
- **Sin análisis de red** — no cruza datos con otros nodos ni mempool distribuida
|
||||||
- **Peritaje forense sin `/outspend`** — el backend Mempool self-hosted no expone ese endpoint (ni `/utxo`), así que cada salto se resuelve recorriendo el historial de la dirección receptora. Más peticiones que un `/outspend` directo, con throttling por lotes para no saturar el nodo
|
|
||||||
- **Ramas que no se pueden comprobar** — algunas direcciones tienen un historial tan pesado que el backend no lo sirve dentro del tiempo de espera (8 s). Cuando pasa, esa rama se marca como **no comprobada** y queda fuera de "fondos localizados sin gastar": el informe dice que no se sabe, en vez de afirmar que los fondos siguen ahí. Es deliberado — cortar protege el nodo, que es la premisa de la herramienta, y una consulta fallida nunca debe leerse como una conclusión. Esas ramas hay que comprobarlas a mano
|
|
||||||
- **El tope de volumen mide número de transacciones, no peso** — el cinturón de seguridad (`LARGE_ADDR_TX_COUNT`, 5000 tx) frena las direcciones con historiales enormes, pero una dirección con pocas transacciones muy grandes puede agotar el tiempo de espera igualmente (visto con una de 59 transacciones de ~15 KB cada una). No hay forma barata de conocer el peso de la respuesta por adelantado, así que el tope no cubre ese caso; lo cubre el manejo de errores descrito arriba
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,318 +1,233 @@
|
|||||||
# Instalación de Txoko Node Dashboard
|
# Cómo subir Txoko a Gitea — paso a paso
|
||||||
|
|
||||||
Guía completa, de principio a fin. Sigue los pasos en orden y comprueba cada
|
Instrucciones exactas. Copiar y pegar en la terminal del Mac.
|
||||||
uno antes de pasar al siguiente: cada comprobación te dice si vas bien, en vez
|
No hace falta entender git para seguir esto.
|
||||||
de dejarte descubrirlo al final con una pantalla en blanco.
|
|
||||||
|
|
||||||
Todo se hace **en el nodo**, salvo donde se indique lo contrario.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Antes de empezar
|
## PASO 1 — Crear el repo en Gitea (una sola vez)
|
||||||
|
|
||||||
Txoko no habla con la red Bitcoin directamente: se apoya en cosas que ya
|
1. Abre tu instancia de Gitea en el navegador
|
||||||
tienes montadas. Necesitas:
|
2. Clic en el **+** (arriba a la derecha) → "New Repository"
|
||||||
|
3. Rellena:
|
||||||
|
- **Repository Name:** `txoko-dashboard`
|
||||||
|
- **Description:** `Suite de auditoría de privacidad Bitcoin para nodos propios`
|
||||||
|
- **Visibility:** Private (o Public si quieres compartirlo con la comunidad)
|
||||||
|
- **Initialize repository:** NO marcar (ya traemos nuestros archivos)
|
||||||
|
4. Clic en "Create Repository"
|
||||||
|
5. Gitea te muestra una página con instrucciones — copia la URL del repo,
|
||||||
|
será algo como: `https://gitea.tu-comunidad/tu-usuario/txoko-dashboard.git`
|
||||||
|
|
||||||
| Requisito | Para qué |
|
---
|
||||||
|---|---|
|
|
||||||
| **Bitcoin Core** con `txindex=1` | consultar cualquier transacción, no solo las tuyas |
|
|
||||||
| **Mempool self-hosted** ([mempool/mempool](https://github.com/mempool/mempool)) | la API que Txoko consulta |
|
|
||||||
| **Fulcrum** ([cculianu/Fulcrum](https://github.com/cculianu/Fulcrum)) | índice de direcciones; Mempool lo usa por debajo |
|
|
||||||
| **nginx** | sirve el dashboard y hace de proxy hacia lo anterior |
|
|
||||||
| **Node.js ≥ 18** | solo para el monitor del sistema (paso 4) |
|
|
||||||
|
|
||||||
Si Mempool self-hosted ya te funciona en el navegador, tienes todo lo demás.
|
## PASO 2 — Configurar git en el Mac (una sola vez, si no lo tienes)
|
||||||
|
|
||||||
Comprueba que la API responde antes de seguir. Ajusta el puerto al tuyo:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s http://127.0.0.1:8999/api/v1/fees/recommended
|
git config --global user.name "tu nombre"
|
||||||
|
git config --global user.email "tu@email.com"
|
||||||
```
|
```
|
||||||
|
|
||||||
Debe devolver un JSON con comisiones. Si no responde, arregla eso primero:
|
Comprueba que git está instalado:
|
||||||
Txoko no puede funcionar sin ello.
|
```bash
|
||||||
|
git --version
|
||||||
> **Nunca expongas estos puertos a internet.** Txoko está pensado para
|
```
|
||||||
> accederse por Tailscale, VPN o red local.
|
Si no está: `brew install git`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Paso 1 — Descargar los archivos
|
## PASO 3 — Crear el repo local y primer commit (una sola vez)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.bitcointxoko.org/pikaro/txoko-dashboard.git
|
# Crear carpeta del proyecto en el Mac
|
||||||
cd txoko-dashboard
|
mkdir ~/txoko-dashboard
|
||||||
|
cd ~/txoko-dashboard
|
||||||
|
|
||||||
|
# Copiar los archivos del repo que te he preparado
|
||||||
|
# (descarga los archivos de esta conversación y cópialos aquí)
|
||||||
|
|
||||||
|
# Inicializar git
|
||||||
|
git init
|
||||||
|
git branch -M main
|
||||||
|
|
||||||
|
# Añadir todos los archivos
|
||||||
|
git add .
|
||||||
|
|
||||||
|
# Primer commit — el historial empieza aquí
|
||||||
|
git commit -m "inicio: dashboard de privacidad Bitcoin con análisis on-chain"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Paso 2 — Elegir dónde vivirá el dashboard
|
## PASO 4 — Conectar con Gitea y subir (una sola vez)
|
||||||
|
|
||||||
Esta decisión condiciona el resto, así que conviene hacerla a conciencia.
|
|
||||||
|
|
||||||
Necesitas un **directorio** propio para Txoko. No vale colocar el
|
|
||||||
`dashboard.html` suelto en cualquier sitio: la aplicación carga sus librerías
|
|
||||||
desde un subdirectorio `vendor/` **junto al propio archivo**, así que ambos
|
|
||||||
tienen que convivir.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo mkdir -p /var/www/txoko
|
# Sustituye la URL por la de tu repo de Gitea
|
||||||
sudo cp dashboard.html /var/www/txoko/
|
git remote add origin https://gitea.tu-comunidad/tu-usuario/txoko-dashboard.git
|
||||||
|
|
||||||
|
# Subir
|
||||||
|
git push -u origin main
|
||||||
```
|
```
|
||||||
|
|
||||||
Puedes usar otra ruta; solo recuerda cuál es, porque aparece en los pasos 3 y 5.
|
Gitea te pedirá usuario y contraseña la primera vez.
|
||||||
|
Si quieres evitar introducirlos cada vez, crea un token en
|
||||||
|
Gitea → Settings → Applications → "Generate Token" y úsalo como contraseña.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Paso 3 — Instalar las librerías del frontend
|
## PASO 5 — Flujo de trabajo normal (cada vez que yo te dé un archivo nuevo)
|
||||||
|
|
||||||
Txoko usa React y Babel, y la tipografía IBM Plex. **Se sirven desde tu propio
|
|
||||||
nodo, nunca desde un CDN externo.**
|
|
||||||
|
|
||||||
El motivo es de privacidad, no de comodidad: un CDN no vería qué transacciones
|
|
||||||
analizas, pero sí recibiría tu IP y la hora cada vez que abres el dashboard —
|
|
||||||
sabría que usas Txoko, cuándo y desde dónde. Justo el metadato que esta
|
|
||||||
herramienta enseña a proteger. Sirviéndolas en local, *"ninguna consulta sale
|
|
||||||
de tu red"* es literal, y el dashboard funciona sin conexión a internet.
|
|
||||||
|
|
||||||
No están en el repositorio porque son código de terceros y ocupan unos 3 MB.
|
|
||||||
|
|
||||||
### React y Babel
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo mkdir -p /var/www/txoko/vendor
|
cd ~/txoko-dashboard
|
||||||
cd /var/www/txoko/vendor
|
|
||||||
sudo curl -sSLO https://unpkg.com/react@18.3.1/umd/react.production.min.js
|
|
||||||
sudo curl -sSLO https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js
|
|
||||||
sudo curl -sSL -o babel.min.js https://unpkg.com/@babel/standalone@7.23.10/babel.min.js
|
|
||||||
```
|
|
||||||
|
|
||||||
**Comprueba que has recibido lo que esperabas.** No te fíes: verifícalo.
|
# Copiar el archivo actualizado descargado de esta conversación
|
||||||
|
cp ~/Downloads/txoko-dashboard.html ./dashboard.html
|
||||||
|
|
||||||
```bash
|
# Ver qué cambió (opcional pero útil)
|
||||||
sha256sum *.js
|
git diff dashboard.html
|
||||||
```
|
|
||||||
|
|
||||||
Debe dar exactamente esto:
|
# Registrar el cambio con un mensaje descriptivo
|
||||||
|
git add dashboard.html
|
||||||
|
git commit -m "fix: descripción breve de lo que se arregló"
|
||||||
|
|
||||||
```
|
# Subir a Gitea
|
||||||
85ba0c7207cf1b1850e40372f26a7e69a481457a29649b7ca19bbfce8604f30c babel.min.js
|
git push
|
||||||
35f4f974f4b2bcd44da73963347f8952e341f83909e4498227d4e26b98f66f0d react-dom.production.min.js
|
|
||||||
d949f1c3687aedadcedac85261865f29b17cd273997e7f6b2bfc53b2f9d4c4dd react.production.min.js
|
|
||||||
```
|
|
||||||
|
|
||||||
Si alguno no coincide, **para aquí**: has recibido algo distinto de lo esperado.
|
# Copiar al nodo (igual que antes)
|
||||||
|
scp dashboard.html armg@100.116.19.86:/home/armg/txoko/dashboard.html
|
||||||
Las versiones están fijadas a propósito. Usar un rango como `react@18` dejaría
|
|
||||||
que el servidor decidiera qué versión te entrega, y cambiaría con el tiempo sin
|
|
||||||
que te enteres.
|
|
||||||
|
|
||||||
### Las fuentes
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo /ruta/al/repo/instalar-fuentes.sh /var/www/txoko/vendor/fonts
|
|
||||||
```
|
|
||||||
|
|
||||||
El script descarga IBM Plex (licencia libre OFL), comprueba cada archivo y
|
|
||||||
aborta si algo falla. Son unos 350 KB.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Paso 4 — El monitor del sistema (opcional)
|
|
||||||
|
|
||||||
Alimenta la pestaña NODO: CPU, RAM, disco, estado de Bitcoin Core y logs. Sin
|
|
||||||
esto el resto de la aplicación funciona igual, solo que esa pestaña queda vacía.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p ~/txoko
|
|
||||||
cp system-metrics.js ~/txoko/
|
|
||||||
```
|
|
||||||
|
|
||||||
Edita `~/txoko/system-metrics.js` y sustituye los dos marcadores por tus
|
|
||||||
credenciales RPC de Bitcoin Core (las de tu `bitcoin.conf`):
|
|
||||||
|
|
||||||
```js
|
|
||||||
const RPC_USER = "TU_RPC_USER";
|
|
||||||
const RPC_PASS = "TU_RPC_PASSWORD";
|
|
||||||
```
|
|
||||||
|
|
||||||
Instala el servicio. Antes revisa el `.service`, porque trae rutas y usuario
|
|
||||||
que probablemente tengas que ajustar a los tuyos:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nano txoko-metrics.service
|
|
||||||
sudo cp txoko-metrics.service /etc/systemd/system/
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
sudo systemctl enable --now txoko-metrics
|
|
||||||
```
|
|
||||||
|
|
||||||
**Comprueba que arrancó:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
systemctl is-active txoko-metrics # debe decir: active
|
|
||||||
curl -s http://127.0.0.1:4082/system/info | head -c 200
|
|
||||||
```
|
|
||||||
|
|
||||||
> Si `systemctl status` muestra un punto que no es verde pero pone
|
|
||||||
> `active (running)`, está bien. Lo que importa es el texto.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Paso 5 — Configurar nginx
|
|
||||||
|
|
||||||
Aquí es donde más gente se atasca, así que léelo con calma.
|
|
||||||
|
|
||||||
Añade esto a tu configuración de nginx (si usas Mempool self-hosted, dentro del
|
|
||||||
mismo bloque `server`):
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
# Dashboard — OJO: alias a un DIRECTORIO, con barra final
|
|
||||||
location /dashboard/ {
|
|
||||||
alias /var/www/txoko/;
|
|
||||||
index dashboard.html;
|
|
||||||
try_files $uri $uri/ /dashboard/dashboard.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
# API de Mempool — ajusta el puerto al de tu instalación
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass http://127.0.0.1:8999;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Monitor del sistema (solo si hiciste el paso 4)
|
|
||||||
location /system/ {
|
|
||||||
proxy_pass http://127.0.0.1:4082;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**El detalle que rompe la instalación:** el `alias` tiene que apuntar al
|
|
||||||
**directorio**, con barra final, no al archivo `dashboard.html`. Si apunta al
|
|
||||||
archivo, el navegador no encontrará `vendor/` y verás una **página en blanco**
|
|
||||||
sin ningún mensaje de error. Es el fallo más común de esta instalación.
|
|
||||||
|
|
||||||
Recarga nginx:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo nginx -t && sudo systemctl reload nginx
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Paso 6 — Comprobar que funciona
|
## Mensajes de commit — ejemplos
|
||||||
|
|
||||||
Antes de abrir el navegador, verifica desde el propio nodo. Ajusta el puerto:
|
El mensaje va después de `-m` y describe QUÉ cambiaste.
|
||||||
|
No tiene que ser perfecto, solo útil para ti en el futuro.
|
||||||
```bash
|
|
||||||
# El dashboard llega
|
|
||||||
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:4080/dashboard/
|
|
||||||
|
|
||||||
# Y las librerías TAMBIÉN — esto es lo que suele fallar
|
|
||||||
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' \
|
|
||||||
http://127.0.0.1:4080/dashboard/vendor/react.production.min.js
|
|
||||||
```
|
|
||||||
|
|
||||||
La segunda debe responder **`200 application/javascript`**.
|
|
||||||
|
|
||||||
Si devuelve `200 text/html` la instalación **no** está bien: nginx te está
|
|
||||||
sirviendo otra cosa (normalmente el index de Mempool) en lugar del archivo.
|
|
||||||
Revisa el `alias` del paso 5. Fíjate en que aquí no basta con mirar el código
|
|
||||||
200 — hay que mirar el tipo de contenido.
|
|
||||||
|
|
||||||
Ahora sí, abre en el navegador:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
http://TU-IP:4080/dashboard/
|
"fix: timeout en consultas al nodo"
|
||||||
|
"feat: score por bandas ALTA/MEDIA/BAJA"
|
||||||
|
"fix: responsive pestaña Mempool en móvil"
|
||||||
|
"fix: detección Whirlpool con tolerancia 2%"
|
||||||
|
"feat: fingerprinting wallet completo"
|
||||||
|
"fix: umbral dust por tipo de salida"
|
||||||
|
"docs: actualizar README con nuevas funcionalidades"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Con la barra final.** Sin ella, el navegador busca las librerías un nivel por
|
Convención (opcional pero ordenada):
|
||||||
encima y no las encuentra.
|
- `fix:` — corrige algo que no funcionaba bien
|
||||||
|
- `feat:` — añade algo nuevo
|
||||||
Pulsa **CONFIG** e introduce la URL de tu Mempool (por ejemplo
|
- `docs:` — solo documentación
|
||||||
`http://TU-IP:4080`). Dale a PROBAR: si dice "Conexión exitosa", ya está.
|
|
||||||
|
|
||||||
### Si ves una página en blanco
|
|
||||||
|
|
||||||
Abre la consola del navegador (F12). Si aparece `SyntaxError: Unexpected
|
|
||||||
token '<'`, es exactamente el problema del `alias` del paso 5: nginx está
|
|
||||||
devolviendo HTML donde debería devolver JavaScript.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## HTTPS — necesario para el watch-only (opcional)
|
## Si algo sale mal
|
||||||
|
|
||||||
|
**Subiste algo que no querías:**
|
||||||
|
```bash
|
||||||
|
git revert HEAD
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
**Quieres volver a una versión anterior:**
|
||||||
|
```bash
|
||||||
|
git log --oneline # ver el historial
|
||||||
|
git checkout HASH_DEL_COMMIT -- dashboard.html # recuperar ese archivo
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ver el historial:**
|
||||||
|
```bash
|
||||||
|
git log --oneline
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resumen del flujo
|
||||||
|
|
||||||
|
```
|
||||||
|
Claude te da dashboard.html
|
||||||
|
↓
|
||||||
|
cp ~/Downloads/dashboard.html ./dashboard.html
|
||||||
|
↓
|
||||||
|
git add . && git commit -m "descripción"
|
||||||
|
↓
|
||||||
|
git push
|
||||||
|
↓
|
||||||
|
scp dashboard.html armg@100.116.19.86:/home/armg/txoko/
|
||||||
|
```
|
||||||
|
|
||||||
|
Cuatro comandos después de la descarga. Siempre los mismos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HTTPS para watch-only (opcional)
|
||||||
|
|
||||||
La función watch-only (derivar tus direcciones desde el xpub) usa la Web Crypto
|
La función watch-only (derivar tus direcciones desde el xpub) usa la Web Crypto
|
||||||
API del navegador, que **solo funciona sobre HTTPS**. Por HTTP el resto de la
|
API del navegador, que **solo funciona sobre HTTPS**. Si sirves el dashboard por
|
||||||
aplicación funciona igual; solo esa función queda deshabilitada. Las etiquetas
|
HTTP, watch-only no estará disponible — el resto de la app funciona igual. Las
|
||||||
BIP-329 no necesitan HTTPS.
|
etiquetas BIP-329 no necesitan HTTPS.
|
||||||
|
|
||||||
Si accedes por Tailscale o red local no tendrás un certificado válido, así que
|
Si quieres usar watch-only y tu nodo va por HTTP (por ejemplo, acceso por
|
||||||
toca generar uno autofirmado.
|
Tailscale sin certificado), puedes generar un certificado autofirmado. Es lo que
|
||||||
|
sigue. Todo se hace en el nodo.
|
||||||
|
|
||||||
### 1. Generar el certificado
|
### 1. Generar el certificado autofirmado
|
||||||
|
|
||||||
Sustituye la IP por la de tu nodo:
|
Sustituye la IP por la de tu nodo (Tailscale, local, etc.):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
|
sudo openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
|
||||||
-keyout /etc/ssl/private/txoko.key \
|
-keyout /etc/ssl/private/txoko.key \
|
||||||
-out /etc/ssl/certs/txoko.crt \
|
-out /etc/ssl/certs/txoko.crt \
|
||||||
-subj "/CN=100.64.0.5" \
|
-subj "/CN=100.116.19.86" \
|
||||||
-addext "subjectAltName=IP:100.64.0.5"
|
-addext "subjectAltName=IP:100.116.19.86"
|
||||||
```
|
```
|
||||||
|
|
||||||
El `subjectAltName` no es opcional: sin él los navegadores modernos rechazan el
|
### 2. Configurar nginx para servir HTTPS
|
||||||
certificado aunque el `CN` sea correcto.
|
|
||||||
|
|
||||||
### 2. Servirlo en nginx
|
Añade un bloque `server` que escuche en un puerto con SSL (por ejemplo 4081),
|
||||||
|
apuntando al certificado recién creado e incluyendo la misma configuración que
|
||||||
Duplica tu bloque `server` en otro puerto (4081 en este ejemplo) añadiendo:
|
tu servidor HTTP:
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
|
server {
|
||||||
listen 4081 ssl;
|
listen 4081 ssl;
|
||||||
|
listen [::]:4081 ssl;
|
||||||
|
server_name _;
|
||||||
ssl_certificate /etc/ssl/certs/txoko.crt;
|
ssl_certificate /etc/ssl/certs/txoko.crt;
|
||||||
ssl_certificate_key /etc/ssl/private/txoko.key;
|
ssl_certificate_key /etc/ssl/private/txoko.key;
|
||||||
|
ssl_session_timeout 4h;
|
||||||
|
ssl_protocols TLSv1.3;
|
||||||
|
ssl_prefer_server_ciphers on;
|
||||||
|
|
||||||
|
# Incluye aquí tu misma config (location /dashboard, /api/, etc.)
|
||||||
|
# Si ya tienes esos location en un snippet, basta con incluirlo:
|
||||||
|
# include /etc/nginx/snippets/tu-config.conf;
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Los `location` son los mismos del paso 5. Recarga con `sudo nginx -t &&
|
Si el bloque `location /dashboard` ya viene de un snippet que incluyes, **no lo
|
||||||
sudo systemctl reload nginx`.
|
dupliques** dentro del server SSL: nginx dará error `duplicate location`.
|
||||||
|
|
||||||
### 3. Aceptar el certificado la primera vez
|
Comprueba y recarga:
|
||||||
|
|
||||||
Al entrar en `https://TU-IP:4081/dashboard/` el navegador avisará de que el
|
|
||||||
certificado no es de confianza. Es lo esperado: lo has firmado tú. Acepta la
|
|
||||||
excepción una vez.
|
|
||||||
|
|
||||||
Que sea autofirmado no lo hace menos seguro **para este uso**: cifra igual, y
|
|
||||||
como el certificado lo has generado tú en tu propia máquina, nadie externo
|
|
||||||
puede suplantarlo. Lo que no tienes es el respaldo de una autoridad
|
|
||||||
certificadora, que aquí no aporta nada porque el servidor y el cliente son
|
|
||||||
tuyos.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Actualizar a una versión nueva
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git pull
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
sudo cp dashboard.html /var/www/txoko/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Y recarga el navegador con **Ctrl+Shift+R** (o Cmd+Shift+R en Mac) para saltarte
|
### 3. Confiar el certificado la primera vez
|
||||||
la caché.
|
|
||||||
|
|
||||||
Las librerías de `vendor/` no hace falta volver a descargarlas salvo que el
|
Abre `https://TU-IP:4081/dashboard/` en el navegador. Como el certificado es
|
||||||
CHANGELOG diga lo contrario. Si además cambia `system-metrics.js`, cópialo de
|
autofirmado, el navegador avisará de que la conexión no es privada. Es esperado
|
||||||
nuevo (conservando tus credenciales) y reinicia con
|
—lo creaste tú— y es seguro en tu propia red:
|
||||||
`sudo systemctl restart txoko-metrics`.
|
|
||||||
|
|
||||||
---
|
- **Safari:** clic en "visitar este sitio web" (abajo del aviso) y confirma
|
||||||
|
- **Chrome/Brave:** "Configuración avanzada" → "Acceder a TU-IP (no seguro)"
|
||||||
|
|
||||||
## Problemas frecuentes
|
A partir de ahí el navegador recuerda la excepción y la Web Crypto API queda
|
||||||
|
disponible, así que watch-only funcionará.
|
||||||
|
|
||||||
|
> Nota: un certificado autofirmado es perfectamente válido para uso personal en
|
||||||
|
> tu propia red. El aviso del navegador existe porque no hay una autoridad
|
||||||
|
> certificadora de por medio, no porque la conexión sea insegura — el tráfico va
|
||||||
|
> cifrado igual.
|
||||||
|
|
||||||
| Síntoma | Causa habitual |
|
|
||||||
|---|---|
|
|
||||||
| Página en blanco, consola con `Unexpected token '<'` | El `alias` de nginx apunta al archivo y no al directorio (paso 5) |
|
|
||||||
| Página en blanco al entrar sin barra final | Entra en `/dashboard/`, con barra |
|
|
||||||
| Aparece "DEMO · SIN NODO" | Falta configurar la URL del Mempool en CONFIG |
|
|
||||||
| La pestaña NODO está vacía | El servicio `txoko-metrics` no corre, o falta el `location /system/` |
|
|
||||||
| Watch-only no aparece | Estás entrando por HTTP; requiere HTTPS |
|
|
||||||
| La app dice que el nodo no responde | Comprueba la API con el `curl` de "Antes de empezar" |
|
|
||||||
|
|||||||
+316
-2736
File diff suppressed because it is too large
Load Diff
@@ -1,94 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Txoko — instalar IBM Plex en local (elimina la dependencia de Google Fonts)
|
|
||||||
#
|
|
||||||
# Descarga las fuentes IBM Plex (licencia OFL, libre) y genera el CSS que las
|
|
||||||
# declara. Tras ejecutarlo, el dashboard deja de contactar con
|
|
||||||
# fonts.googleapis.com y fonts.gstatic.com.
|
|
||||||
#
|
|
||||||
# Por qué importa: Google no ve qué transacciones analizas, pero sí recibe tu
|
|
||||||
# IP, la hora y la página desde la que se pide la fuente, cada vez que abres el
|
|
||||||
# dashboard. Para una herramienta de privacidad, sobra.
|
|
||||||
#
|
|
||||||
# Fuente: paquetes npm oficiales de IBM servidos por unpkg — el mismo origen
|
|
||||||
# del que ya se descargan React y Babel durante la instalación.
|
|
||||||
#
|
|
||||||
# Funciona igual en Linux (el nodo) y en macOS, por si quieres tener una copia
|
|
||||||
# del dashboard en tu propio ordenador.
|
|
||||||
#
|
|
||||||
# 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
|
|
||||||
|
|
||||||
DEST="${1:-/usr/share/nginx/html/vendor/fonts}"
|
|
||||||
MONO="https://unpkg.com/@ibm/plex-mono@1.1.0/fonts/complete/woff2"
|
|
||||||
SANS="https://unpkg.com/@ibm/plex-sans@1.1.0/fonts/complete/woff2"
|
|
||||||
|
|
||||||
echo "→ Instalando fuentes en: $DEST"
|
|
||||||
mkdir -p "$DEST"
|
|
||||||
cd "$DEST" || { echo "✗ No se pudo entrar en $DEST"; exit 1; }
|
|
||||||
|
|
||||||
# Pesos que usa el dashboard: Mono 400/600/700, Sans 400/500/600.
|
|
||||||
# Formato woff2 (el más comprimido, soportado por todo navegador actual).
|
|
||||||
URLS="
|
|
||||||
$MONO/IBMPlexMono-Regular.woff2
|
|
||||||
$MONO/IBMPlexMono-SemiBold.woff2
|
|
||||||
$MONO/IBMPlexMono-Bold.woff2
|
|
||||||
$SANS/IBMPlexSans-Regular.woff2
|
|
||||||
$SANS/IBMPlexSans-Medium.woff2
|
|
||||||
$SANS/IBMPlexSans-SemiBold.woff2
|
|
||||||
"
|
|
||||||
|
|
||||||
fallos=0
|
|
||||||
for url in $URLS; do
|
|
||||||
archivo=$(basename "$url")
|
|
||||||
printf " %-32s" "$archivo"
|
|
||||||
# -w escribe el código HTTP para poder diagnosticar si algo va mal
|
|
||||||
codigo=$(curl -sSL -o "$archivo" -w "%{http_code}" "$url" 2>/dev/null)
|
|
||||||
# `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
|
|
||||||
if [ "$codigo" = "200" ] && [ "$tam" -gt 10000 ]; then
|
|
||||||
echo "ok ($((tam/1024)) KB)"
|
|
||||||
else
|
|
||||||
echo "FALLÓ (HTTP $codigo, $tam bytes)"
|
|
||||||
fallos=$((fallos+1))
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$fallos" -gt 0 ]; then
|
|
||||||
echo
|
|
||||||
echo "✗ $fallos archivo(s) no se descargaron correctamente."
|
|
||||||
echo " No despliegues el dashboard nuevo todavía — avísame con esta salida."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── CSS que declara las fuentes ──────────────────────────────────────────────
|
|
||||||
# font-display:swap → el texto se ve desde el primer momento con la fuente de
|
|
||||||
# respaldo y se sustituye al cargar la definitiva. Sin página en blanco.
|
|
||||||
cat > ibm-plex.css <<'CSS'
|
|
||||||
/* IBM Plex — servido desde tu propio nodo. Licencia OFL (IBM).
|
|
||||||
Sustituye a fonts.googleapis.com: ninguna petición sale de tu red. */
|
|
||||||
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url('IBMPlexMono-Regular.woff2') format('woff2')}
|
|
||||||
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:600;font-display:swap;src:url('IBMPlexMono-SemiBold.woff2') format('woff2')}
|
|
||||||
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:700;font-display:swap;src:url('IBMPlexMono-Bold.woff2') format('woff2')}
|
|
||||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url('IBMPlexSans-Regular.woff2') format('woff2')}
|
|
||||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:500;font-display:swap;src:url('IBMPlexSans-Medium.woff2') format('woff2')}
|
|
||||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:600;font-display:swap;src:url('IBMPlexSans-SemiBold.woff2') format('woff2')}
|
|
||||||
CSS
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "✓ Fuentes instaladas y CSS generado."
|
|
||||||
echo " Total: $(du -sh . | cut -f1)"
|
|
||||||
echo
|
|
||||||
echo " Comprueba que nginx las sirve (debe dar 200):"
|
|
||||||
echo " curl -sk -o /dev/null -w '%{http_code}\\n' https://localhost:4081/vendor/fonts/ibm-plex.css"
|
|
||||||
echo
|
|
||||||
echo " Si da 200, ya puedes desplegar el dashboard.html nuevo."
|
|
||||||
+5
-73
@@ -168,33 +168,10 @@ async function getDisk() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Procesos destacados (nombre limpio del binario) ────────────────────────
|
// ── Procesos destacados (nombre limpio del binario) ────────────────────────
|
||||||
// Tiempo de CPU consumido por un proceso, en ticks, desde /proc/PID/stat.
|
|
||||||
// Campos 14 (utime) y 15 (stime). El nombre del proceso va entre paréntesis y
|
|
||||||
// puede contener espacios, así que se corta por el ÚLTIMO ')' antes de partir.
|
|
||||||
function readProcCpuTicks(pid) {
|
|
||||||
try {
|
|
||||||
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
||||||
const resto = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
||||||
// resto[0] es el campo 3 (estado), así que utime=campo14 → resto[11]
|
|
||||||
const utime = parseInt(resto[11], 10);
|
|
||||||
const stime = parseInt(resto[12], 10);
|
|
||||||
if (Number.isNaN(utime) || Number.isNaN(stime)) return null;
|
|
||||||
return utime + stime;
|
|
||||||
} catch { return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// OJO con la columna %CPU de `ps`: NO es el consumo actual, sino la media del
|
|
||||||
// proceso desde que arrancó (tiempo de CPU / tiempo de vida). Un proceso que
|
|
||||||
// trabajó mucho al principio y ahora está ocioso sigue mostrando un número
|
|
||||||
// alto días después, y se lee como si estuviera saturando la máquina.
|
|
||||||
// Aquí se mide de verdad: dos lecturas de /proc separadas 500 ms, el mismo
|
|
||||||
// método que ya usa getCpuUsage para el total del sistema. Las dos esperas
|
|
||||||
// corren en paralelo (van dentro del mismo Promise.all), así que no cuesta
|
|
||||||
// tiempo extra.
|
|
||||||
async function getProcesses() {
|
async function getProcesses() {
|
||||||
const result = await sh("ps aux --no-headers --sort=-%mem | head -8");
|
const result = await sh("ps aux --no-headers --sort=-%mem | head -8");
|
||||||
if (!result) return [];
|
if (!result) return [];
|
||||||
const filas = result.split("\n").map(line => {
|
return result.split("\n").map(line => {
|
||||||
const parts = line.trim().split(/\s+/);
|
const parts = line.trim().split(/\s+/);
|
||||||
// Nombre limpio: basename del ejecutable; si es un intérprete (node,
|
// Nombre limpio: basename del ejecutable; si es un intérprete (node,
|
||||||
// python...), añade el basename del script que ejecuta.
|
// python...), añade el basename del script que ejecuta.
|
||||||
@@ -213,33 +190,12 @@ async function getProcesses() {
|
|||||||
}
|
}
|
||||||
command = command.slice(0, 24);
|
command = command.slice(0, 24);
|
||||||
return {
|
return {
|
||||||
pid: parts[1],
|
|
||||||
user: parts[0],
|
user: parts[0],
|
||||||
cpuMedia: parseFloat(parts[2]), // media desde el arranque (lo que da ps)
|
cpu: parseFloat(parts[2]),
|
||||||
mem: parseFloat(parts[3]),
|
mem: parseFloat(parts[3]),
|
||||||
command,
|
command,
|
||||||
};
|
};
|
||||||
}).filter(p => p.mem > 0.5);
|
}).filter(p => p.mem > 0.5);
|
||||||
|
|
||||||
const ticks1 = filas.map(p => readProcCpuTicks(p.pid));
|
|
||||||
await sleep(500);
|
|
||||||
const ticks2 = filas.map(p => readProcCpuTicks(p.pid));
|
|
||||||
|
|
||||||
const USER_HZ = 100; // estándar en Linux
|
|
||||||
const VENTANA_S = 0.5;
|
|
||||||
const nucleos = os.cpus().length || 1;
|
|
||||||
|
|
||||||
return filas.map((p, i) => {
|
|
||||||
let cpu = null, cpuSistema = null;
|
|
||||||
if (ticks1[i] !== null && ticks2[i] !== null) {
|
|
||||||
const seg = (ticks2[i] - ticks1[i]) / USER_HZ;
|
|
||||||
// % de UN núcleo (criterio de top/ps: puede pasar de 100 si va en varios)
|
|
||||||
cpu = Math.max(0, Math.round((seg / VENTANA_S) * 1000) / 10);
|
|
||||||
// % del total de la máquina, que es lo que suele querer saberse
|
|
||||||
cpuSistema = Math.round((cpu / nucleos) * 10) / 10;
|
|
||||||
}
|
|
||||||
return { user: p.user, command: p.command, mem: p.mem, cpu, cpuSistema, cpuMedia: p.cpuMedia };
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Load / Uptime ───────────────────────────────────────────────────────────
|
// ── Load / Uptime ───────────────────────────────────────────────────────────
|
||||||
@@ -290,36 +246,12 @@ async function getBitcoinInfo() {
|
|||||||
// connections_in/out existen desde Core 0.21; fallback a getpeerinfo si no
|
// connections_in/out existen desde Core 0.21; fallback a getpeerinfo si no
|
||||||
let inbound = netinfo.connections_in;
|
let inbound = netinfo.connections_in;
|
||||||
let outbound = netinfo.connections_out;
|
let outbound = netinfo.connections_out;
|
||||||
let peerList = null;
|
|
||||||
if (inbound === undefined || outbound === undefined) {
|
if (inbound === undefined || outbound === undefined) {
|
||||||
peerList = await rpcCall("getpeerinfo").catch(() => null);
|
const peers = await rpcCall("getpeerinfo").catch(() => null);
|
||||||
inbound = peerList ? peerList.filter(p => p.inbound).length : 0;
|
inbound = peers ? peers.filter(p => p.inbound).length : 0;
|
||||||
outbound = peerList ? peerList.filter(p => !p.inbound).length : 0;
|
outbound = peers ? peers.filter(p => !p.inbound).length : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Por qué red entra cada peer, y qué direcciones anuncia el nodo.
|
|
||||||
// Hace falta para no dar por hecho que tener conexiones entrantes expone
|
|
||||||
// la IP: si entran por Tor o I2P no la ven, y si el nodo no anuncia
|
|
||||||
// ninguna dirección de clearnet, tampoco la publica a la red.
|
|
||||||
let inboundNets = null, clearnetLocal = null;
|
|
||||||
try {
|
|
||||||
if (!peerList) peerList = await rpcCall("getpeerinfo");
|
|
||||||
if (Array.isArray(peerList)) {
|
|
||||||
inboundNets = {};
|
|
||||||
for (const p of peerList.filter(p => p.inbound)) {
|
|
||||||
const n = p.network || "desconocida";
|
|
||||||
inboundNets[n] = (inboundNets[n] || 0) + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const locales = netinfo.localaddresses || [];
|
|
||||||
clearnetLocal = locales
|
|
||||||
.filter(a => !/\.onion$|\.i2p$/i.test(a.address || ""))
|
|
||||||
.map(a => a.address);
|
|
||||||
} catch { /* si falla, se informa como no comprobado, no como ausencia */ }
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
inboundNets, clearnetLocal,
|
|
||||||
localAddrCount: (netinfo.localaddresses || []).length,
|
|
||||||
version: `v${Math.floor(netinfo.version / 10000)}.${Math.floor((netinfo.version % 10000) / 100)}.${netinfo.version % 100}`,
|
version: `v${Math.floor(netinfo.version / 10000)}.${Math.floor((netinfo.version % 10000) / 100)}.${netinfo.version % 100}`,
|
||||||
blocks: info.blocks,
|
blocks: info.blocks,
|
||||||
headers: info.headers,
|
headers: info.headers,
|
||||||
|
|||||||
@@ -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]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
|
User=armg
|
||||||
# ── AJUSTA ESTAS DOS LÍNEAS ANTES DE INSTALARLO ──────────────────────────
|
ExecStart=/usr/bin/node /home/armg/txoko/system-metrics.js
|
||||||
# User: el usuario con el que corre tu nodo (el mismo que ejecuta bitcoind
|
|
||||||
# suele ser buena elección). No lo dejes en root.
|
|
||||||
# ExecStart: la ruta donde hayas copiado system-metrics.js.
|
|
||||||
User=TU_USUARIO
|
|
||||||
ExecStart=/usr/bin/node /home/TU_USUARIO/txoko/system-metrics.js
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
StandardOutput=journal
|
StandardOutput=journal
|
||||||
|
|||||||
Reference in New Issue
Block a user