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.
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.
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.
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.
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.
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.
SETUP.md no era una guía de instalación: más de la mitad eran los pasos
personales de git y Gitea del autor. Se mueven a un documento privado.
Y lo importante: la configuración de nginx documentada en el README estaba
rota desde la 1.11.0. Indicaba un alias a dashboard.html (un ARCHIVO), pero
con las librerías en vendor/ y rutas relativas eso deja al navegador sin
encontrarlas — página en blanco y ningún error visible. Debe ser un alias al
DIRECTORIO. Quien siguiera el README al pie de la letra no conseguía arrancar.
Ahora hay una guía real con comprobación tras cada paso, incluida la que
descarta ese fallo concreto: curl mirando el content-type, no solo el 200.
Se añaden las librerías al flujo (faltaban, y sin ellas no arranca), se quitan
las rutas personales y se actualiza la estructura del repo.
La columna %CPU de ps aux es la media desde que arrancó el proceso, no el
consumo actual: un proceso que trabajó mucho hace días seguía apareciendo
alto para siempre. Se detectó porque los porcentajes no cambiaban nunca entre
lecturas mientras la CPU global sí variaba.
Ahora se mide con dos lecturas de /proc/PID/stat separadas 500 ms, el mismo
método que ya usaba getCpuUsage para el total. La UI muestra el % sobre el
total de la máquina, con el % por núcleo y la media de ps en el tooltip.
Verificado con carga artificial: 100% de un núcleo (25% de 4) frente al 152%
que reportaba ps, imposible para un proceso de un solo hilo.
Además, el UTXO Map era el único punto que se saltaba la caché usando
fetchWithTimeout directo. Ahora pasa por get().
La matemática pasa todos los vectores oficiales (RIPEMD-160, secp256k1, BIP32
vectores 1 y 2, bech32/BIP173) y no se ha tocado. Los fallos estaban en la
validación de la entrada:
- No se comprobaba la checksum del xpub: un carácter mal copiado generaba 200
direcciones ajenas y el usuario veía su cartera 'sin actividad'. Mismo
patrón de falso negativo silencioso que el resto de fallos de hoy.
- No se validaba la longitud (78 bytes) ni el formato de la clave pública.
- tpub se trataba como mainnet: la red se detectaba por prefijo de texto
('tb'/'u'/'v') y un tpub empieza por 't' pero no por 'tb'. Ahora se detecta
por bytes de versión, con las diez variantes.
- deriveChildPubkey aceptaba índices endurecidos, imposibles desde una clave
pública. No alcanzable desde la UI, pero debe defenderse sola.
Se añade tests/ con las cuatro baterías, documentando también qué NO cubren:
no sustituyen una auditoría externa.
Primera pieza de la app que llega a tiempo: todo lo demás es diagnóstico de lo
que ya pasó. Sustituye al validador anterior, que solo comprobaba que el
archivo empezara por 'psbt'.
Parser BIP174 completo escrito desde cero, validado contra los cinco vectores
inválidos del estándar (los rechaza los cinco con mensajes en castellano) y
contra PSBTs reales de Sparrow.
Todo offline: la PSBT ya trae los importes y scripts de sus entradas, así que
no hace falta el nodo — funciona con él sincronizando y sin que se entere.
El aviso más valioso es el de los xpubs incrustados: las PSBT llevan dentro las
claves maestras de la cartera y están hechas para compartirse, así que quien
reciba el archivo puede ver todas las direcciones, el saldo y el historial.
No he encontrado ningún wallet que avise de esto.
Mismo patrón que el fallo del peritaje, en la pieza central del proyecto.
scanWallet usaba .catch(()=>[]) en sus tres consultas, así que una dirección
que el nodo no pudo servir era indistinguible de una sin actividad: sus txs no
se traían, no contaban para reutilización ni clusters, y el informe daba su
valoración sin mencionar que faltaban datos.
El sesgo iba siempre al optimismo — menos actividad vista, mejor nota. Ahora
se registran los fallos y se avisa antes de la banda de salud, en la UI y en
el export MD.
Un hit de OFAC restaba 20 puntos de la banda de privacidad, pero no revela
nada más sobre el usuario: su privacidad es idéntica antes y después. Lo que
cambia es la exposición a que un servicio regulado le bloquee un depósito —
otro eje distinto. Penalizarlo era además asumir la lógica de las 'monedas
contaminadas', la que la fungibilidad de Bitcoin niega. Ahora es informativo,
con el contexto que faltaba sobre qué es y qué no es esa lista.
RBF igual: es buena práctica y casi universal; restar por usarlo empujaba a
gastar peor para esconder una señal débil.
Checks con inferencia fuerte reescritos con Hecho/Interpretación/Consecuencia:
ya no dicen 'sin ambigüedad' donde la señal puede fallar.
README: nueva sección 'Sí, esto es chain analysis' — negarlo restaba
credibilidad ante quien lee el código. Y el informe advierte ahora del coste
de denunciar y de manejar el archivo exportado.
Misma raíz que el fallo del CoinJoin, en otra variante. El cluster CIOH ya
excluía los custodios (actorAddrSet), pero la atribución por huella de
software no: la hot wallet de Bitfinex aparecía como 'dirección atribuida al
actor' mientras la conclusión la identificaba como exchange dos líneas más
abajo. La dirección donde el ladrón deposita es del exchange, no suya.
Verificado contra el nodo con un depósito real: de 2 atribuciones a 1.
El informe decía 'el rastro se rompe en un CoinJoin, no se puede atribuir con
honestidad más allá de este punto' y acto seguido listaba 10 direcciones
atribuidas al actor, cinco de ellas salidas de esa misma mezcla — es decir,
de otros participantes, señalados en un documento para una denuncia.
CIOH no aplica dentro de un CoinJoin (es su excepción clásica: la mezcla
existe para romper la suposición de dueño común) y la huella de software sale
estable por construcción, porque todos usan el mismo programa.
finalizeForensicGraph excluye las tx marcadas como mixer del conjunto del
actor y del union-find; la atribución por huella salta esos nodos.
Verificado contra el nodo con un Whirlpool real: de 10 atribuciones a 0.
Las librerías (React, Babel) venían de unpkg.com y las fuentes de Google.
Ninguno veía qué transacciones analizabas, pero ambos recibían tu IP y la hora
en cada apertura: sabían que usabas Txoko, cuándo y desde dónde — el metadato
que la propia herramienta enseña a proteger. Ahora se sirven desde el nodo,
con verificación por hash y versiones fijadas. El dashboard funciona sin
internet.
Además, caché con TTL y coalescencia en useApi: las transacciones confirmadas
son inmutables y se cachean toda la sesión, así que repetir un análisis ya
explorado no cuesta ninguna petición al nodo (medido).
Una tx con cientos de salidas (dust attack, lote de retiradas, airdrop) hacía
que el motor perfilara cada dirección de salida (2 peticiones cada una) antes
de encolar las ramas: ~286 peticiones para una tx de 143 salidas, con la
pestaña congelada. El tope corta antes de perfilar, que es donde está el coste.
Verificado contra el nodo con una tx real de 143 salidas: 2 peticiones.
- el aviso ya no promete que el fallo es transitorio: cuando se repite sobre
la misma dirección no lo es, y ahora dice por qué y qué hacer
- README: las ramas no comprobadas quedan fuera de 'fondos sin gastar' a
propósito — cortar protege el nodo y una consulta fallida no es una
conclusión
- README: el tope de volumen mide número de tx, no peso; una dirección con
pocas transacciones muy grandes puede agotar el tiempo de espera igual
(visto con una de 59 tx de ~15 KB)
Detectado probando contra el nodo real: un 503 del backend se mostraba como
'Rastro completo' y contaba la rama como terminal, porque get() devolvía
mockData ante cualquier error y findSpendingTx remataba con .catch(()=>[]).
Un error de red quedaba así indistinguible de 'output sin gastar' — la
conclusión más accionable de un informe pericial, afirmada sobre una consulta
que nunca respondió.
- getStrict en useApi propaga el fallo; get se mantiene igual para el resto
- el motor registra los fallos en trace.queryErrors y no los cuenta como
fondos sin gastar
- el informe los lista aparte y abre las conclusiones avisando del alcance
incompleto (UI, JSON y MD)
- la UI muestra el detalle del fallo en vez del falso rastro completo
Refleja el cambio de modelo del peritaje forense: de "automático
acotado con seguir más saltos" a "a demanda, salto a salto, con
control manual del usuario", más el tope de tx_count como cinturón
de seguridad adicional. El mecanismo antiguo "seguir más saltos (+4)"
ya no existe en el código — se documenta el que lo reemplaza.
CHANGELOG (1.10.0) y README reflejan que el peritaje forense ya está
implementado, no solo especificado: qué hace, qué heurísticas reutiliza
y cuáles son nuevas, el marco HECHO/INFERENCIA/DECLARACION, y la
limitación conocida de depender de /api/address/{addr}/txs en vez de
/outspend (el backend Mempool self-hosted no lo expone).