13 Commits
Author SHA1 Message Date
pikaroandClaude Opus 5 e82b5fc944 Ignorar .claude/: ajustes locales de maquina, no de proyecto
settings.local.json guarda permisos de esta maquina y rutas absolutas
que ademas quedaron obsoletas al mover el repo a ~/proyectos.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011GtYP8BWsrg9G1u7nD7xGT
2026-09-09 12:21:11 +00:00
pikaro f4e814c5ec chore: ignorar vendor/ para que no se cuele en un commit
Al probar el dashboard como archivo local hay que crear vendor/ en la propia
carpeta del repositorio, y quedaba sin rastrear a la espera de que alguien
hiciera 'git add .'. Son 3,5 MB de código de terceros que no deben ir al
repositorio.
2026-08-08 19:28:42 +02:00
pikaro e1589f1d68 fix: instalar-fuentes.sh fallaba en macOS por usar stat de GNU
Comprobaba el tamaño con 'stat -c%s'. macOS trae la versión BSD, que usa -f%z,
así que el comando fallaba, caía en el '|| echo 0' y el script daba por fallida
una descarga correcta: 'FALLÓ (HTTP 200, 0 bytes)'. El 200 decía que había ido
bien; lo roto era la comprobación.

Ahora usa wc -c, igual en los dos sistemas, y comprueba que el archivo exista.
La cabecera ya no dice 'ejecutar en el nodo': si el dashboard puede vivir en
una carpeta cualquiera, su instalador de fuentes también.
2026-08-08 19:27:23 +02:00
pikaro a9cb9da3ac fix: el aviso del xpub decía una media verdad
Decía 'el xpub no sale del navegador'. Cierto, y por eso engaña: las
direcciones derivadas sí salen, una consulta al nodo por cada una, y quien
opere ese nodo ve el monedero entero. Con nodo propio da igual; con el nodo de
otro le estás enseñando tus finanzas.

El aviso lo dice ahora y distingue los dos casos. Añade PRUEBALA.md para quien
reciba la app a probarla.
2026-08-08 19:11:55 +02:00
pikaro 0c7f81d168 docs: el README refleja las heurísticas nuevas y el criterio de la nota 2026-08-08 19:05:37 +02:00
pikaro 7d05dae8cf feat: cierra las cinco heurísticas ausentes del repaso
La comisión como huella: una comisión absoluta redonda o una tarifa entera no
salen de un estimador, salen de alguien que las escribió a mano. Informativo y
sin penalizar, igual que RBF — castigarlo empujaría a elegir comisiones peores
para disimular.

Cambio con más de dos salidas, solo el caso inequívoco: si exactamente una
salida comparte tipo con las entradas no hay nada que adivinar, y con más
salidas la coincidencia por azar es todavía más improbable. POSIBLE, no
PROBABLE, porque es una sola señal.

Autoenvíos en el informe de wallet: solo cabe ahí, porque saber que todas las
salidas son tuyas exige conocer el wallet entero. Avisa solo cuando ataron más
de una dirección — mover una a otra no vincula nada nuevo.
2026-08-08 19:03:08 +02:00
pikaro b957fb626d feat: la nota mide solo lo que dependía de ti
Antes mezclaba dos cosas en un número: tus errores y la exposición que provoca
un tercero. Alguien impecable que cobró de un exchange sacaba peor nota que un
descuidado que cobró de un particular, y no tenía forma de mejorarla. Una nota
que no puedes subir no es una evaluación, es un reproche.

La nota se queda con los checks donde hay decisión tuya. El resto pasa a un
bloque propio, 'lo que hicieron otros', con su nivel de exposición.

Con la nota restringida a lo propio aparecía otro problema: una transacción con
el cambio identificable sacaba 93 y la etiqueta 'privacidad aceptable'. Ahora
cualquier fallo propio de peso >=10 impide la banda alta.

Y OP_RETURN deja de forzar banda BAJA por decreto: condenaba igual a una
inscripción y a un sello de OpenTimestamps.
2026-08-08 18:57:23 +02:00
pikaro 9e7fec873a fix: cuadrar el denominador de la nota y ampliar la redondez; dos checks nuevos
El denominador de la nota incluía dos pesos que nunca restan (rbf, peeling) y
excluía uno que sí (input_linkage, hasta 30), así que las deducciones podían
superarlo en 17 puntos y dar negativo. Corregido en ambas direcciones, con los
cortes de banda recalibrados a 79/54 para que ninguna transacción cambie de
banda por el arreglo.

'Redondo' equivalía a 'múltiplo de 0,001 BTC' por dos condiciones redundantes,
y se le escapaban pagos de 10.000 o 50.000 sats. Ahora se mide por ceros
finales y la señal se gradúa.

Dos checks nuevos. El de polvo avisaba de una vinculación futura y nunca
miraba si estaba ocurriendo delante: ahora la detecta cuando el polvo se gasta
junto a monedas normales. Y la consolidación pura (N entradas, 1 salida) no
disparaba el check de inputs innecesarios; ahora se nombra, sin cobrarla dos
veces porque input_linkage ya la penaliza.

tests/test6.js cubre lo nuevo y la coherencia de la nota.
2026-08-08 18:52:40 +02:00
pikaro 0bfe6dca14 fix: dos fallos en la detección del output de cambio
Uno: la señal A y la señal E eran la misma condición escrita dos veces, así que
un solo hecho llegaba al umbral de dos señales. Un pago corriente de bech32 a
taproot salía como cambio identificable con certeza PROBABLE, y el informe
mostraba las dos señales contradiciéndose entre sí.

Dos: sin señal fuerte, el índice caía en la salida menor. Pagando poco desde
una moneda grande el cambio es la mayor, así que la app señalaba el pago. El
detalle llegaba a decir 'el output redondo es el pago' y a continuación lo
marcaba como cambio. El peritaje usa ese índice, de modo que podía presentar
la dirección del destinatario como rastro del actor.

Ahora cada señal apunta a un output o admite que no puede, y si ninguna apunta
el índice queda vacío. tests/test5.js deja los dos casos como regresión.
2026-08-08 18:42:41 +02:00
pikaro 643236e47b feat: pestaña Cadena unificada, aviso de polvo y enlaces a la instancia propia
Mempool y Bloques eran el mismo eje partido: los proyectados en una pestaña y
los confirmados en otra. Ahora es una línea temporal con el ahora en medio, y
responde la pregunta que ninguna de las dos respondía — a qué tarifa entra tu
transacción y en qué bloque.

Aviso de polvo recibido en el informe de wallet: el analizador sabía
reconocerlo al mirar una transacción, pero no avisaba cuando te pasaba a ti.
El consejo es no gastarlo, porque el daño solo ocurre al mezclarlo.

Enlaces a la instancia propia de Mempool en nueve puntos, construidos siempre
desde la URL configurada.

Y un fallo preexistente: el rastro de procedencia caía en mempool.space
público si no había nodo configurado. Sin nodo ya no hay enlace.
2026-08-08 18:26:01 +02:00
pikaro 387af724ac fix: el checklist normalizaba un swap alto que no era normal
Decía 'Normal en nodos con índice completo' ante un swap al 99%. En el caso
real que lo destapó no lo era: Fulcrum tenía db_mem a 16 GB, más de lo que
cabe en la máquina, y el sistema llevaba quince días sin margen ante un pico
de memoria. Ese mensaje es la razón de que se ignorara tanto tiempo.

Umbral del 95% al 50%, error por encima del 80%, detección del caso 'swap
alto con RAM libre' (firma de memoria sobrerreservada), y el aviso apunta a
db_mem e incluye el comando para ver quién ocupa el swap.
2026-08-08 15:58:26 +02:00
pikaro e4deb711d5 feat: check de vinculación CIOH; fix: saldos falsos y fingerprint por ausencia
Tres cosas encontradas analizando transacciones reales del incidente Coldcard:

- Faltaba avisar de que gastar N direcciones juntas las vincula para siempre.
  El analizador medía la privacidad desde el lado de quien construye la
  transacción, no desde el de los dueños de las monedas gastadas.

- El explorador mostraba 'Balance: 0 BTC' en direcciones con mucho historial:
  Mempool sobre Fulcrum no devuelve los importes y la app los daba por buenos.
  Ahora dice 'no disponible' y explica por qué — tener 492 transacciones y cero
  recibido es imposible en la cadena.

- El fingerprint decía 'Electrum' por AUSENCIA de rasgos (locktime 0, sin RBF),
  cuando Electrum moderno hace lo contrario. Ahora exige señales positivas y
  existe 'sin rasgos distintivos', que no penaliza.
2026-08-08 15:34:48 +02:00
pikaro af49ea630b chore: ignorar REPASO.md (documento de trabajo privado) 2026-08-08 15:21:42 +02:00
10 changed files with 1810 additions and 237 deletions
+11
View File
@@ -29,3 +29,14 @@ MEJORAS.md
COHERENCIA.md COHERENCIA.md
GIT.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/
+298
View File
@@ -10,6 +10,304 @@ 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 ## [1.12.1] — 2026-07-27
### Corregido ### Corregido
+91
View File
@@ -0,0 +1,91 @@
# 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`.
+9 -1
View File
@@ -84,8 +84,16 @@ Consecuencia práctica: **el dashboard funciona sin conexión a internet**. Solo
## Funcionalidades ## Funcionalidades
**Auditoría de privacidad** **Auditoría de privacidad**
- Análisis de transacción con más de 20 heurísticas ponderadas - Análisis de transacción con más de 25 heurísticas ponderadas
- La nota mide **solo lo que dependía de ti**. Lo que te expone por decisión de
un tercero (recibir de un lote, recibir polvo) se informa aparte, porque una
nota que no puedes mejorar no evalúa nada
- Detección del output de cambio por señales combinadas, que se callan cuando
no coinciden en lugar de señalar la salida equivocada
- 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
+946 -217
View File
File diff suppressed because it is too large Load Diff
+13 -2
View File
@@ -13,8 +13,14 @@
# Fuente: paquetes npm oficiales de IBM servidos por unpkg — el mismo origen # Fuente: paquetes npm oficiales de IBM servidos por unpkg — el mismo origen
# del que ya se descargan React y Babel durante la instalación. # del que ya se descargan React y Babel durante la instalación.
# #
# Ejecutar EN EL NODO: # 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 # 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 set -uo pipefail
@@ -43,7 +49,12 @@ for url in $URLS; do
printf " %-32s" "$archivo" printf " %-32s" "$archivo"
# -w escribe el código HTTP para poder diagnosticar si algo va mal # -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) codigo=$(curl -sSL -o "$archivo" -w "%{http_code}" "$url" 2>/dev/null)
tam=$(stat -c%s "$archivo" 2>/dev/null || echo 0) # `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 if [ "$codigo" = "200" ] && [ "$tam" -gt 10000 ]; then
echo "ok ($((tam/1024)) KB)" echo "ok ($((tam/1024)) KB)"
else else
+27 -3
View File
@@ -290,12 +290,36 @@ 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) {
const peers = await rpcCall("getpeerinfo").catch(() => null); peerList = await rpcCall("getpeerinfo").catch(() => null);
inbound = peers ? peers.filter(p => p.inbound).length : 0; inbound = peerList ? peerList.filter(p => p.inbound).length : 0;
outbound = peers ? peers.filter(p => !p.inbound).length : 0; outbound = peerList ? peerList.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,
+36 -4
View File
@@ -1,11 +1,26 @@
# Pruebas de la criptografía # Pruebas
Verifican la derivación watch-only (BIP32, secp256k1, RIPEMD-160, bech32) Dos familias. Las de **criptografía** (test1test4) verifican la derivación
contra los **vectores oficiales de los estándares**, no contra resultados watch-only —BIP32, secp256k1, RIPEMD-160, bech32— contra los **vectores
propios. Si un cambio rompe algo, estas pruebas lo dicen. oficiales de los estándares**, no contra resultados propios. Las de
**heurísticas** (test5test6) 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 ## 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 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 que van desde `const B32 = {` hasta el final de `deriveAddresses`), añadir al
principio `const { webcrypto } = require("crypto"); const crypto = webcrypto;` principio `const { webcrypto } = require("crypto"); const crypto = webcrypto;`
@@ -33,9 +48,26 @@ Después:
- **test4** — comprueba que esos cuatro siguen cerrados: checksum rota, xpub - **test4** — comprueba que esos cuatro siguen cerrados: checksum rota, xpub
truncado, índice endurecido, índice negativo. Y que un `tpub` genera truncado, índice endurecido, índice negativo. Y que un `tpub` genera
direcciones de testnet, no de mainnet. 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 ## 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 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 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 implementación y comparte sus supuestos, que es justo el sesgo que rompe un
+151
View File
@@ -0,0 +1,151 @@
// 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
View File
@@ -0,0 +1,218 @@
// 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);