Compare commits

...
9 Commits
Author SHA1 Message Date
Aitor 2b483e0df9 fix: no atribuir al actor las direcciones de un custodio
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.
2026-07-27 13:37:22 +02:00
Aitor d0cd9ef587 fix: no atribuir al actor direcciones del otro lado de un CoinJoin
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.
2026-07-27 13:29:08 +02:00
Aitor 7c12f63f7a feat: cero dependencias externas y caché de peticiones al nodo
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).
2026-07-27 13:22:08 +02:00
Aitor 1b6904d2e3 chore: ignorar MEJORAS.md (documento de trabajo privado) 2026-07-27 13:01:14 +02:00
Aitor 14a094f941 feat: tope de ramificación en el peritaje — no seguir repartos masivos
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.
2026-07-27 12:59:48 +02:00
Aitor 8c2725fb31 docs: documentar los límites reales del peritaje y afinar el aviso de consulta fallida
- 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)
2026-07-27 12:42:31 +02:00
Aitor c2fb4f1388 fix: el peritaje ya no presenta un fallo de consulta como rastro completo
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
2026-07-27 12:38:07 +02:00
Aitor 4d242a6ac6 chore: ignorar HALLAZGOS-PERITAJE.md (documento de trabajo privado) 2026-07-27 12:25:00 +02:00
Aitor 251981e383 chore: ignorar PRUEBA-PERITAJE.md (documento de trabajo privado) 2026-07-27 12:04:53 +02:00
6 changed files with 555 additions and 42 deletions
+3
View File
@@ -23,3 +23,6 @@ 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
+112
View File
@@ -10,6 +10,118 @@ 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.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**.
### 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.
- **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 ## [1.10.0] — 2026-07-16
### Añadido ### Añadido
+7 -3
View File
@@ -39,9 +39,11 @@ El código es un único archivo HTML autocontenido. Puedes auditarlo tú mismo:
grep -E "fetch\(|XMLHttpRequest|src=\"http|href=\"http" dashboard.html grep -E "fetch\(|XMLHttpRequest|src=\"http|href=\"http" dashboard.html
``` ```
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. 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.
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. 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ó.
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).
--- ---
@@ -203,7 +205,9 @@ El frontend es un único archivo HTML autocontenido. Sin bundler, sin npm, sin p
- **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, 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 - **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
--- ---
+79
View File
@@ -159,6 +159,85 @@ Cuatro comandos después de la descarga. Siempre los mismos.
--- ---
## Librerías del frontend (obligatorio)
El dashboard usa React y Babel. **Se sirven desde tu propio nodo, no desde un
CDN.** El motivo es de privacidad, no de comodidad: un CDN externo no ve qué
transacciones analizas, pero sí ve que usas Txoko, cuándo y desde qué IP
pública — exactamente la clase de metadato que esta herramienta enseña a
proteger. Sirviéndolas en local, *"ninguna consulta sale de tu red"* pasa a ser
literal, y el dashboard funciona sin conexión a internet.
Estas librerías **no están en el repo** (son de terceros y pesan ~3 MB). Se
descargan una vez, en el nodo:
```bash
sudo mkdir -p /usr/share/nginx/html/vendor
cd /usr/share/nginx/html/vendor
sudo curl -sSLO https://unpkg.com/react@18.3.1/umd/react.production.min.js
sudo curl -sSLO https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js
sudo curl -sSL -o babel.min.js https://unpkg.com/@babel/standalone@7.23.10/babel.min.js
```
### Verifica lo que has descargado
No te fíes: comprueba que los archivos son los que deben ser.
```bash
sha256sum *.js
```
Debe dar exactamente esto:
```
85ba0c7207cf1b1850e40372f26a7e69a481457a29649b7ca19bbfce8604f30c babel.min.js
35f4f974f4b2bcd44da73963347f8952e341f83909e4498227d4e26b98f66f0d react-dom.production.min.js
d949f1c3687aedadcedac85261865f29b17cd273997e7f6b2bfc53b2f9d4c4dd react.production.min.js
```
Si algún hash no coincide, **no uses esos archivos**: significa que has
recibido algo distinto a lo esperado.
Las versiones están fijadas a propósito (`18.3.1`, `7.23.10`). Usar un rango
como `react@18` dejaría que el servidor decidiera qué versión te entrega, y
cambiaría con el tiempo sin que te enteres.
### Dónde deben quedar los archivos
El dashboard las busca en `vendor/` con **ruta relativa**, es decir, en un
subdirectorio junto al propio `dashboard.html`. Así funciona tanto si sirves el
dashboard en la raíz como bajo un prefijo, sin tocar nginx.
Con la configuración típica de Mempool self-hosted:
```nginx
location /dashboard {
alias /usr/share/nginx/html/;
}
```
…el `dashboard.html` vive en `/usr/share/nginx/html/` y las librerías deben ir
en `/usr/share/nginx/html/vendor/` — que es justo donde las deja el comando de
arriba.
**Importante:** abre el dashboard **con la barra final** (`.../dashboard/`, no
`.../dashboard`). Sin ella, el navegador resuelve las rutas relativas un nivel
por encima y no encuentra las librerías.
Para comprobar que nginx las sirve bien, lo que importa no es solo el código
200 sino el tipo de contenido:
```bash
curl -sk -o /dev/null -w '%{http_code} %{content_type}\n' \
https://localhost:4081/dashboard/vendor/react.production.min.js
```
Debe responder `200 application/javascript`. Si devuelve `200 text/html`,
nginx está entregando otra cosa (por ejemplo el index de Mempool) y las rutas
no son las correctas.
---
## HTTPS para watch-only (opcional) ## 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
+271 -39
View File
@@ -4,9 +4,19 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Txoko Node Dashboard</title> <title>Txoko Node Dashboard</title>
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script> <!-- Librerías servidas desde TU nodo, no desde un CDN. Un CDN externo no ve
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script> qué transacciones analizas, pero sí que usas Txoko, cuándo y desde qué
<script src="https://unpkg.com/@babel/standalone@7.23.10/babel.min.js"></script> IP — justo la clase de 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.
Cómo obtenerlas y verificarlas: ver SETUP.md. -->
<!-- Rutas RELATIVAS a propósito: el dashboard puede servirse en la raíz o
bajo un prefijo (con Mempool self-hosted suele ser /dashboard/), y así
funciona en ambos casos sin tocar nginx. Requiere abrirlo con la barra
final: .../dashboard/ y no .../dashboard -->
<script src="vendor/react.production.min.js"></script>
<script src="vendor/react-dom.production.min.js"></script>
<script src="vendor/babel.min.js"></script>
<style> <style>
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #05080d; color: #dde6f0; font-family: 'IBM Plex Sans', 'Helvetica Neue', sans-serif; } body { background: #05080d; color: #dde6f0; font-family: 'IBM Plex Sans', 'Helvetica Neue', sans-serif; }
@@ -17,8 +27,10 @@
button:disabled { opacity:0.4; cursor:not-allowed !important; } button:disabled { opacity:0.4; cursor:not-allowed !important; }
input::placeholder { color: #4a6380; } input::placeholder { color: #4a6380; }
</style> </style>
<link rel="preconnect" href="https://fonts.googleapis.com"> <!-- Fuentes servidas desde TU nodo. Antes venían de fonts.googleapis.com,
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600;700&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet"> lo que le daba a Google tu IP y la hora en cada apertura del dashboard.
Instalación: instalar-fuentes.sh (ver SETUP.md). -->
<link href="vendor/fonts/ibm-plex.css" rel="stylesheet">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
@@ -2054,16 +2066,76 @@
} }
} }
// ── Caché de respuestas del nodo ──────────────────────────────────────
// Vive FUERA del hook a propósito. Las pestañas se montan y desmontan al
// navegar ({tab==="node" && <NodeOverview/>}), así que sin esto ir a
// BLOQUES y volver a NODO vuelve a pedirlo todo aunque hayan pasado dos
// segundos. Mismo patrón que ya usa system-metrics.js en el servidor
// (caché con TTL + coalescencia), aplicado ahora también en el navegador.
//
// La coalescencia importa tanto como el TTL: dos componentes que piden lo
// mismo a la vez generan UNA petición al nodo, no dos.
const apiCache = new Map(); // clave -> { at, data, ttl }
const apiInflight = new Map(); // clave -> Promise en vuelo
const API_CACHE_MAX = 400; // tope de entradas, para no crecer sin fin
// Cuánto vale una respuesta depende de lo que sea. Una transacción ya
// confirmada es INMUTABLE: no hay ningún motivo para volver a pedirla en
// toda la sesión. Es el caso más frecuente en el analizador, el rastro de
// procedencia y el peritaje, que hoy piden las mismas txs por separado.
function apiCacheTtl(path, data) {
if (path.startsWith("/system/")) return 2000; // estado vivo del nodo: casi sin caché
if (/^\/api\/tx\/[0-9a-f]{64}$/i.test(path)) {
return data && data.status && data.status.confirmed ? Infinity : 15000;
}
if (path.startsWith("/api/address/")) return 60000; // historial: cambia poco
if (path.startsWith("/api/block")) return 60000;
return 20000; // mempool, fees y demás
}
async function apiGetJson(baseUrl, path) {
const key = baseUrl + path;
const hit = apiCache.get(key);
if (hit && (hit.ttl === Infinity || Date.now() - hit.at < hit.ttl)) return hit.data;
const flying = apiInflight.get(key);
if (flying) return flying; // ya hay una petición idéntica en curso
const p = (async () => {
const res = await fetchWithTimeout(key);
if (!res.ok) throw new Error(`el nodo respondió HTTP ${res.status}`);
const data = await res.json();
const ttl = apiCacheTtl(path, data);
if (ttl > 0) {
// FIFO simple: al llenarse, cae la entrada más antigua.
if (apiCache.size >= API_CACHE_MAX) apiCache.delete(apiCache.keys().next().value);
apiCache.set(key, { at: Date.now(), data, ttl });
}
return data;
})();
apiInflight.set(key, p);
try { return await p; } finally { apiInflight.delete(key); }
}
function useApi(baseUrl) { function useApi(baseUrl) {
const get = useCallback(async (path, mockData) => { const get = useCallback(async (path, mockData) => {
if (!baseUrl) return mockData; if (!baseUrl) return mockData;
try { try {
const res = await fetchWithTimeout(`${baseUrl}${path}`); return await apiGetJson(baseUrl, path);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
} catch { return mockData; } } catch { return mockData; }
}, [baseUrl]); }, [baseUrl]);
return { get }; // Variante estricta: propaga el fallo en vez de disfrazarlo de dato
// vacío. `get` devuelve mockData ante cualquier error, lo que en las
// vistas de exploración es aceptable (se ve el modo demo), pero en el
// peritaje haría indistinguible "el nodo no respondió" de "aquí no hay
// nada" — y eso convierte un fallo de consulta en una afirmación
// pericial falsa. Quien use getStrict debe capturar y decir qué pasó.
const getStrict = useCallback(async (path) => {
if (!baseUrl) throw new Error("no hay nodo configurado");
return apiGetJson(baseUrl, path); // misma caché; los errores sí suben
}, [baseUrl]);
return { get, getStrict };
} }
const fmt = { const fmt = {
@@ -3785,16 +3857,22 @@
// (verificado contra el nodo real — ver TRASPASO.md), así que no hay forma // (verificado contra el nodo real — ver TRASPASO.md), así que no hay forma
// directa de preguntar "¿quién gasta esto?". Mismo patrón de paginación // directa de preguntar "¿quién gasta esto?". Mismo patrón de paginación
// que scanWallet: página de 25 + /txs/chain/{last}. // que scanWallet: página de 25 + /txs/chain/{last}.
async function findSpendingTx(get, addr, txid, vout, maxPages) { // Usa la variante ESTRICTA de fetch a propósito: si el nodo falla, la
// excepción sube al motor, que registra esa rama como "no se pudo
// comprobar". Tragar el error aquí (devolver []) haría que un 503 o un
// timeout fuese indistinguible de "este output sigue sin gastar" — la
// conclusión más accionable del informe, afirmada sobre una consulta que
// nunca llegó a responder.
async function findSpendingTx(getStrict, addr, txid, vout, maxPages) {
maxPages = maxPages || 8; maxPages = maxPages || 8;
let page = await get(`/api/address/${addr}/txs`, []).catch(()=>[]); let page = await getStrict(`/api/address/${addr}/txs`);
let guard = 0; let guard = 0;
while (Array.isArray(page) && page.length > 0 && guard < maxPages) { while (Array.isArray(page) && page.length > 0 && guard < maxPages) {
const found = page.find(t => (t.vin||[]).some(v => v.txid === txid && v.vout === vout)); const found = page.find(t => (t.vin||[]).some(v => v.txid === txid && v.vout === vout));
if (found) return found; if (found) return found;
if (page.length < 25) break; // última página, no hay más que mirar if (page.length < 25) break; // última página, no hay más que mirar
const last = page[page.length-1].txid; const last = page[page.length-1].txid;
page = await get(`/api/address/${addr}/txs/chain/${last}`, []).catch(()=>[]); page = await getStrict(`/api/address/${addr}/txs/chain/${last}`);
guard++; guard++;
} }
return null; return null;
@@ -3865,20 +3943,29 @@
// //
// Crea el estado de un rastreo forense nuevo, sin ejecutar ningún salto // Crea el estado de un rastreo forense nuevo, sin ejecutar ningún salto
// todavía. // todavía.
async function initForensicTrace({ get, originTxid, originVout, amountStolen }) { async function initForensicTrace({ get, getStrict, originTxid, originVout, amountStolen }) {
const originTx = await get(`/api/tx/${originTxid}`, null).catch(()=>null); // Distinguir "txid inexistente" de "el nodo no respondió": culpar al
// txid cuando el fallo es del nodo manda al usuario a buscar un error
// que no existe.
let originTx;
try {
originTx = await getStrict(`/api/tx/${originTxid}`);
} catch (e) {
throw new Error(`No se pudo consultar la transacción de origen: ${e.message}. No es (necesariamente) un problema del txid — la petición al nodo falló.`);
}
if (!originTx) throw new Error("No se pudo obtener la transacción de origen. Comprueba el txid."); if (!originTx) throw new Error("No se pudo obtener la transacción de origen. Comprueba el txid.");
const originOut = originTx.vout?.[originVout]; const originOut = originTx.vout?.[originVout];
if (!originOut) throw new Error("El vout indicado no existe en la transacción de origen."); if (!originOut) throw new Error("El vout indicado no existe en la transacción de origen.");
const originFingerprint = detectWallets(originTx); const originFingerprint = detectWallets(originTx);
return { return {
get, originTxid, originVout, amountStolen: amountStolen || null, get, getStrict, originTxid, originVout, amountStolen: amountStolen || null,
originTx, originOut, originFingerprint, originTx, originOut, originFingerprint,
txCache: new Map([[originTxid, originTx]]), txCache: new Map([[originTxid, originTx]]),
nodes: new Map(), // txid -> ForensicNode nodes: new Map(), // txid -> ForensicNode
edges: [], // ForensicEdge[] edges: [], // ForensicEdge[]
unspentTerminals: [], // ramas terminales: UTXO sin gastar, sin dirección, o truncadas unspentTerminals: [], // ramas terminales: UTXO sin gastar, sin dirección, truncadas o con consulta fallida
queryErrors: [], // consultas al nodo que fallaron — NUNCA se leen como "no hay nada"
frontier: [{ txid: originTxid, vout: originVout, amount: originOut.value, hop: 0, parentTxid: originTxid }], frontier: [{ txid: originTxid, vout: originVout, amount: originOut.value, hop: 0, parentTxid: originTxid }],
enqueuedKeys: new Set(), enqueuedKeys: new Set(),
hop: 0, done: false, hop: 0, done: false,
@@ -3900,6 +3987,11 @@
// salto desemboca en una tx con muchísimos outputs (p.ej. // salto desemboca en una tx con muchísimos outputs (p.ej.
// consolidación de un servicio de pagos). // consolidación de un servicio de pagos).
const BATCH = 5, PAUSE = 120, MAX_NODES = 80; const BATCH = 5, PAUSE = 120, MAX_NODES = 80;
// Tope de ramificación por transacción: por encima de estas salidas
// distintas, la tx es un reparto masivo (dust attack, batch de exchange,
// airdrop) y se deja de seguir. Ver el comentario en el punto de uso —
// se aplica antes de perfilar direcciones, que es donde está el coste.
const MAX_FANOUT = 25;
// Cinturón de seguridad independiente del heurístico de perfil: una // Cinturón de seguridad independiente del heurístico de perfil: una
// dirección con un volumen de transacciones así de alto es, con // dirección con un volumen de transacciones así de alto es, con
// altísima probabilidad, infraestructura de un servicio (exchange, // altísima probabilidad, infraestructura de un servicio (exchange,
@@ -3912,7 +4004,7 @@
// cuesta peticiones extra: chain_stats.tx_count ya viene en el perfil // cuesta peticiones extra: chain_stats.tx_count ya viene en el perfil
// que se pide de todos modos para cada dirección de salida nueva. // que se pide de todos modos para cada dirección de salida nueva.
const LARGE_ADDR_TX_COUNT = 5000; const LARGE_ADDR_TX_COUNT = 5000;
const { get, originTxid, originFingerprint, nodes, edges, unspentTerminals, txCache, enqueuedKeys } = trace; const { get, getStrict, originTxid, originFingerprint, nodes, edges, unspentTerminals, txCache, enqueuedKeys, queryErrors } = trace;
if (trace.frontier.length === 0) { trace.done = true; return trace; } if (trace.frontier.length === 0) { trace.done = true; return trace; }
@@ -3938,7 +4030,17 @@
return; return;
} }
const spendTx = await findSpendingTx(get, addr, txid, vout); // "No se pudo consultar" y "no hay gasto" son cosas distintas y se
// registran distinto. Solo la segunda es una conclusión.
let spendTx;
try {
spendTx = await findSpendingTx(getStrict, addr, txid, vout);
} catch (e) {
queryErrors.push({ txid, vout, address:addr, message:e.message, context:"búsqueda del gasto" });
unspentTerminals.push({ txid, vout, address:addr, amount, queryFailed:true,
note:`No se pudo comprobar si este output fue gastado: ${e.message}. La consulta al nodo falló — no es una conclusión sobre el estado de los fondos.` });
return;
}
if (!spendTx) { if (!spendTx) {
unspentTerminals.push({ txid, vout, address:addr, amount, note:"UTXO sin gastar." }); unspentTerminals.push({ txid, vout, address:addr, amount, note:"UTXO sin gastar." });
return; return;
@@ -3961,15 +4063,33 @@
const inAddrs = [...new Set((spendTx.vin||[]).map(v=>v.prevout?.scriptpubkey_address).filter(Boolean))]; const inAddrs = [...new Set((spendTx.vin||[]).map(v=>v.prevout?.scriptpubkey_address).filter(Boolean))];
const outAddrs = [...new Set((spendTx.vout||[]).map(v=>v.scriptpubkey_address).filter(Boolean))]; const outAddrs = [...new Set((spendTx.vout||[]).map(v=>v.scriptpubkey_address).filter(Boolean))];
// Tope de ramificación: una transacción que se abre en abanico
// (dust attack, batch de exchange, faucet, airdrop) no aporta nada
// forense rama a rama y sí machaca el nodo. El coste NO está solo en
// encolar: perfilar cada dirección de salida cuesta 2 peticiones, así
// que con cientos de salidas son miles de peticiones antes siquiera
// de llegar al frontier. Por eso se corta AQUÍ, antes de perfilar
// nada, y no más abajo junto a los demás stopReason. Mismo espíritu
// que LARGE_ADDR_TX_COUNT, aplicado a la anchura en vez de al fondo.
const fannedOut = outAddrs.length > MAX_FANOUT;
// Perfil + historial de las direcciones de salida — reutilizamos ese // Perfil + historial de las direcciones de salida — reutilizamos ese
// historial para la señal conductual (spendInfo) sin pedirlo dos veces. // historial para la señal conductual (spendInfo) sin pedirlo dos veces.
const outAddrTxs = {}; const outAddrTxs = {};
const addressProfiles = {}; const addressProfiles = {};
await Promise.all(outAddrs.map(async a => { await Promise.all((fannedOut ? [] : outAddrs).map(async a => {
const [info, txs] = await Promise.all([ let info = null, txs = [];
get(`/api/address/${a}`, null).catch(()=>null), try {
get(`/api/address/${a}/txs`, []).catch(()=>[]), [info, txs] = await Promise.all([
]); getStrict(`/api/address/${a}`),
getStrict(`/api/address/${a}/txs`),
]);
} catch (e) {
// Sin perfil no hay señal conductual NI cinturón de volumen
// para esta dirección: queda anotado para que el informe no
// presente su ausencia como si se hubiera comprobado.
queryErrors.push({ txid:spendTx.txid, address:a, message:e.message, context:"perfil de dirección" });
}
outAddrTxs[a] = txs || []; outAddrTxs[a] = txs || [];
if (info) addressProfiles[a] = addressProfile(a, info, txs||[]); if (info) addressProfiles[a] = addressProfile(a, info, txs||[]);
})); }));
@@ -4019,6 +4139,7 @@
let stopReason = null; let stopReason = null;
if (likelyCJ) stopReason = "mixer"; if (likelyCJ) stopReason = "mixer";
else if (diluted) stopReason = "dilution"; else if (diluted) stopReason = "dilution";
else if (fannedOut) stopReason = "fanOut";
else if (custodyStop) stopReason = "exchange"; else if (custodyStop) stopReason = "exchange";
else if (nodes.size + 1 >= MAX_NODES) stopReason = "nodeLimit"; else if (nodes.size + 1 >= MAX_NODES) stopReason = "nodeLimit";
@@ -4038,6 +4159,7 @@
changeGuess: { structural, behavioral, combined }, changeGuess: { structural, behavioral, combined },
changeAddress: structural.index != null ? (spendTx.vout[structural.index]?.scriptpubkey_address ?? null) : null, changeAddress: structural.index != null ? (spendTx.vout[structural.index]?.scriptpubkey_address ?? null) : null,
diluted, tracedShare, custodyStop, custodyAddrs, diluted, tracedShare, custodyStop, custodyAddrs,
fanOut: fannedOut ? { outputs: outAddrs.length, limit: MAX_FANOUT } : null,
stopReason, stopReason,
}); });
@@ -4046,7 +4168,7 @@
// límite global) — ahí sí se corta la transacción entera. Un // límite global) — ahí sí se corta la transacción entera. Un
// custodio, en cambio, es propiedad de UNA dirección concreta: // custodio, en cambio, es propiedad de UNA dirección concreta:
// solo esa rama se corta, las demás siguen su curso normal. // solo esa rama se corta, las demás siguen su curso normal.
const blockAllOutputs = stopReason === "mixer" || stopReason === "dilution" || stopReason === "nodeLimit"; const blockAllOutputs = stopReason === "mixer" || stopReason === "dilution" || stopReason === "nodeLimit" || stopReason === "fanOut";
if (!blockAllOutputs) { if (!blockAllOutputs) {
spendTx.vout.forEach((v, i) => { spendTx.vout.forEach((v, i) => {
if (!v.value || v.value <= 0) return; if (!v.value || v.value <= 0) return;
@@ -4071,7 +4193,7 @@
// partir de un `trace`, completo o parcial — se puede llamar en // partir de un `trace`, completo o parcial — se puede llamar en
// cualquier momento del rastreo a demanda, no solo al terminar. // cualquier momento del rastreo a demanda, no solo al terminar.
function finalizeForensicGraph(trace) { function finalizeForensicGraph(trace) {
const { originTxid, originVout, originTx, originOut, originFingerprint, amountStolen, nodes, edges, unspentTerminals, txCache } = trace; const { originTxid, originVout, originTx, originOut, originFingerprint, amountStolen, nodes, edges, unspentTerminals, txCache, queryErrors } = trace;
// CIOH: semilla = origen + toda dirección de entrada de cualquier // CIOH: semilla = origen + toda dirección de entrada de cualquier
// salto (por construcción, llegó ahí siguiendo los fondos) + direcciones // salto (por construcción, llegó ahí siguiendo los fondos) + direcciones
@@ -4079,12 +4201,24 @@
// pertenecen al servicio, no al mismo actor rastreado). Exclusión por // pertenecen al servicio, no al mismo actor rastreado). Exclusión por
// dirección, no por nodo entero — un pago normal junto a un depósito // dirección, no por nodo entero — un pago normal junto a un depósito
// de exchange en la misma tx sí cuenta como del actor. // de exchange en la misma tx sí cuenta como del actor.
// Los CoinJoin quedan FUERA por completo, entradas y salidas. CIOH
// asume que quien gasta varios inputs juntos los controla, y un CoinJoin
// existe precisamente para romper esa suposición: es la excepción
// clásica a la heurística. Sus entradas y salidas son de participantes
// distintos, así que meterlas aquí produce un falso positivo garantizado
// y contradice la propia conclusión del informe ("no se puede atribuir
// con honestidad más allá de este punto").
const actorAddrSet = new Set([originOut.scriptpubkey_address].filter(Boolean)); const actorAddrSet = new Set([originOut.scriptpubkey_address].filter(Boolean));
const mixerTxids = new Set();
for (const n of nodes.values()) { for (const n of nodes.values()) {
if (n.stopReason === "mixer") { mixerTxids.add(n.txid); continue; }
for (const a of n.addresses.in) actorAddrSet.add(a); for (const a of n.addresses.in) actorAddrSet.add(a);
for (const a of n.addresses.out) { if (!n.custodyAddrs.includes(a)) actorAddrSet.add(a); } for (const a of n.addresses.out) { if (!n.custodyAddrs.includes(a)) actorAddrSet.add(a); }
} }
const { clusters, linkReasons } = unionFindCluster([...txCache.values()], actorAddrSet); // El union-find tampoco debe ver las transacciones de mezcla: agruparía
// por inputs comunes a gente que no tiene nada que ver entre sí.
const txsForCluster = [...txCache.values()].filter(t => !mixerTxids.has(t.txid));
const { clusters, linkReasons } = unionFindCluster(txsForCluster, actorAddrSet);
// marcasDeTx necesita vin/vout crudos (coinbase, OFAC, minería, // marcasDeTx necesita vin/vout crudos (coinbase, OFAC, minería,
// exchange, CoinJoin) — se calcula aquí, sobre originTx de verdad, y se // exchange, CoinJoin) — se calcula aquí, sobre originTx de verdad, y se
@@ -4101,6 +4235,7 @@
}, },
amountStolen: amountStolen || null, amountStolen: amountStolen || null,
nodes, edges, unspentTerminals, clusters, linkReasons, nodes, edges, unspentTerminals, clusters, linkReasons,
queryErrors: queryErrors || [],
peelingChains: detectPeelingChains(nodes, edges), peelingChains: detectPeelingChains(nodes, edges),
}; };
} }
@@ -4110,8 +4245,8 @@
// Se mantiene por compatibilidad — la UI a demanda usa // Se mantiene por compatibilidad — la UI a demanda usa
// initForensicTrace + advanceForensicHop directamente, con el usuario // initForensicTrace + advanceForensicHop directamente, con el usuario
// decidiendo cuándo llamar a cada salto en vez de este bucle. // decidiendo cuándo llamar a cada salto en vez de este bucle.
async function buildForensicGraph({ get, originTxid, originVout, amountStolen, maxHops, onProgress }) { async function buildForensicGraph({ get, getStrict, originTxid, originVout, amountStolen, maxHops, onProgress }) {
const trace = await initForensicTrace({ get, originTxid, originVout, amountStolen }); const trace = await initForensicTrace({ get, getStrict, originTxid, originVout, amountStolen });
while (!trace.done) { while (!trace.done) {
await advanceForensicHop(trace, { maxHops, onProgress }); await advanceForensicHop(trace, { maxHops, onProgress });
} }
@@ -4125,6 +4260,7 @@
// afectado sin corroborar — nunca se mezcla con las otras dos. // afectado sin corroborar — nunca se mezcla con las otras dos.
function buildForensicReport(graph, declaracionText) { function buildForensicReport(graph, declaracionText) {
const { origin, nodes, unspentTerminals, clusters, peelingChains, amountStolen } = graph; const { origin, nodes, unspentTerminals, clusters, peelingChains, amountStolen } = graph;
const queryErrors = graph.queryErrors || [];
const allNodes = [...nodes.values()].sort((a,b)=> a.hop - b.hop || (a.blockTime||0) - (b.blockTime||0)); const allNodes = [...nodes.values()].sort((a,b)=> a.hop - b.hop || (a.blockTime||0) - (b.blockTime||0));
const maxHop = allNodes.reduce((m,n)=>Math.max(m,n.hop), 0); const maxHop = allNodes.reduce((m,n)=>Math.max(m,n.hop), 0);
const addressesTouched = new Set([origin.address].filter(Boolean)); const addressesTouched = new Set([origin.address].filter(Boolean));
@@ -4170,9 +4306,30 @@
agreement: n.changeGuess?.combined?.agreement || null, agreement: n.changeGuess?.combined?.agreement || null,
}); });
} }
// Igual que con CIOH, la huella de software no vale nada dentro de una
// mezcla: en un CoinJoin todos los participantes usan el mismo programa,
// así que la huella sale "estable" por construcción, no porque sea el
// mismo actor. Atribuir salidas de un CoinJoin por esa señal sería
// señalar a terceros que solo coincidieron en la misma transacción.
// Dos exclusiones, por el mismo motivo de fondo: la huella de software
// solo dice algo del actor cuando la dirección PUEDE ser suya.
// - Mezclas: todos los participantes usan el mismo programa, así que la
// huella sale estable por construcción, no por ser el mismo actor.
// - Custodios: la dirección es del servicio, no de quien deposita.
// Atribuir una hot wallet de exchange "al actor" en un informe que
// puede acabar en una denuncia es señalar al mensajero. Además
// contradice la conclusión del propio informe, que la identifica como
// custodio.
// El cluster CIOH ya excluía los custodios (ver actorAddrSet); esto
// alinea la atribución por huella con ese mismo criterio.
const fingerprintStableAddrs = new Set(); const fingerprintStableAddrs = new Set();
for (const n of allNodes) { for (const n of allNodes) {
if (n.fingerprintComparison?.changed === false) n.addresses.out.forEach(a=>fingerprintStableAddrs.add(a)); if (n.stopReason === "mixer") continue;
if (n.fingerprintComparison?.changed === false) {
n.addresses.out.forEach(a => {
if (!(n.custodyAddrs || []).includes(a)) fingerprintStableAddrs.add(a);
});
}
} }
const attributed = []; const attributed = [];
@@ -4192,14 +4349,32 @@
} }
// ── 5. Fondos localizados sin gastar ──────────────────────────────── // ── 5. Fondos localizados sin gastar ────────────────────────────────
// Solo entra aquí lo COMPROBADO sin gastar. Las ramas con consulta
// fallida (queryFailed) se quedan fuera a propósito: afirmar que unos
// fondos siguen sin gastar porque el nodo devolvió un 503 sería la peor
// clase de error en un informe pericial.
const unspentFunds = unspentTerminals const unspentFunds = unspentTerminals
.filter(u => u.address && !u.truncated && u.note === "UTXO sin gastar.") .filter(u => u.address && !u.truncated && !u.queryFailed && u.note === "UTXO sin gastar.")
.map(u => ({ level:"HECHO", txid:u.txid, vout:u.vout, address:u.address, amount:u.amount, refs:[u.txid, u.address] })); .map(u => ({ level:"HECHO", txid:u.txid, vout:u.vout, address:u.address, amount:u.amount, refs:[u.txid, u.address] }));
const truncatedBranches = unspentTerminals.filter(u => u.truncated); const truncatedBranches = unspentTerminals.filter(u => u.truncated);
const failedBranches = unspentTerminals.filter(u => u.queryFailed);
// ── 6. Conclusiones numeradas ──────────────────────────────────────── // ── 6. Conclusiones numeradas ────────────────────────────────────────
const conclusions = []; const conclusions = [];
conclusions.push({ level:"HECHO", text:`${origin.amount} sats rastreados desde ${origin.txid}:${origin.vout}, a través de ${allNodes.length} transacción(es) en ${maxHop} salto(s).`, refs:[origin.txid] }); conclusions.push({ level:"HECHO", text:`${origin.amount} sats rastreados desde ${origin.txid}:${origin.vout}, a través de ${allNodes.length} transacción(es) en ${maxHop} salto(s).`, refs:[origin.txid] });
// Va lo primero, antes que cualquier conclusión: si parte del rastreo no
// se pudo consultar, el lector debe saberlo antes de leer nada más.
if (failedBranches.length > 0) {
conclusions.push({ level:"HECHO",
text:`ATENCIÓN — rastreo incompleto por fallos de consulta: ${failedBranches.length} rama(s) quedaron sin comprobar porque el nodo no respondió (${[...new Set(failedBranches.map(f=>f.address))].join(", ")}). NO se sabe si esos fondos siguen sin gastar o se movieron: la consulta falló, no se comprobó nada. Si el fallo se repite sobre la misma dirección, suele ser porque su historial es demasiado pesado para servirlo dentro del tiempo de espera — hay que comprobarla a mano en un explorador. Este informe no cubre esas ramas.`,
refs: failedBranches.map(f=>f.txid) });
}
if (queryErrors.some(q => q.context === "perfil de dirección")) {
const affected = [...new Set(queryErrors.filter(q=>q.context==="perfil de dirección").map(q=>q.address))];
conclusions.push({ level:"HECHO",
text:`No se pudo obtener el perfil de ${affected.length} dirección(es) (${affected.join(", ")}). Para esas direcciones no hay señal conductual ni comprobación de volumen, así que su ausencia en las conclusiones no significa que se hayan descartado — no se pudieron examinar.`,
refs: affected });
}
if (clusters.length > 0) { if (clusters.length > 0) {
conclusions.push({ level:"INFERENCIA", certainty:"PROBABLE", text:`Se detectaron ${clusters.length} cluster(es) de direcciones vinculadas por CIOH — con alta probabilidad pertenecen al mismo actor.`, refs: clusters.flat() }); conclusions.push({ level:"INFERENCIA", certainty:"PROBABLE", text:`Se detectaron ${clusters.length} cluster(es) de direcciones vinculadas por CIOH — con alta probabilidad pertenecen al mismo actor.`, refs: clusters.flat() });
} }
@@ -4224,6 +4399,11 @@
if (n.stopReason === "dilution") { if (n.stopReason === "dilution") {
conclusions.push({ level:"INFERENCIA", certainty:"POSIBLE", text:`Dilución en ${n.txid}: el monto rastreado deja de ser una fracción identificable del total de la transacción (${(n.tracedShare*100).toFixed(1)}%).`, refs:[n.txid] }); conclusions.push({ level:"INFERENCIA", certainty:"POSIBLE", text:`Dilución en ${n.txid}: el monto rastreado deja de ser una fracción identificable del total de la transacción (${(n.tracedShare*100).toFixed(1)}%).`, refs:[n.txid] });
} }
if (n.stopReason === "fanOut" && n.fanOut) {
conclusions.push({ level:"HECHO",
text:`El rastro se detiene en ${n.txid}: esa transacción reparte a ${n.fanOut.outputs} direcciones distintas (el tope es ${n.fanOut.limit}). Un abanico así es un reparto masivo — dust attack, lote de retiradas de un servicio, airdrop — donde seguir cada rama no aporta nada forense y sí supondría miles de peticiones al nodo. No es una conclusión sobre quién controla esas direcciones: es un límite deliberado. Si te interesa una salida concreta, rastréala aparte usando esta transacción como nuevo origen.`,
refs:[n.txid] });
}
if (n.stopReason === "nodeLimit") { if (n.stopReason === "nodeLimit") {
conclusions.push({ level:"HECHO", text:`El rastro se detiene en ${n.txid} por el tope de transacciones exploradas — una red de seguridad aparte del límite de saltos, para no sobrecargar el nodo si un salto desemboca en una consolidación con muchísimas ramas. No es un punto de parada natural; se puede seguir rastreando manualmente desde aquí si hace falta.`, refs:[n.txid] }); conclusions.push({ level:"HECHO", text:`El rastro se detiene en ${n.txid} por el tope de transacciones exploradas — una red de seguridad aparte del límite de saltos, para no sobrecargar el nodo si un salto desemboca en una consolidación con muchísimas ramas. No es un punto de parada natural; se puede seguir rastreando manualmente desde aquí si hace falta.`, refs:[n.txid] });
} }
@@ -4252,7 +4432,7 @@
const methodology = { const methodology = {
levels: "Cada afirmación del informe lleva una etiqueta: HECHO (dato on-chain verificable directamente, sin interpretación), INFERENCIA (conclusión de una heurística, con su propia sub-etiqueta de certeza CERTEZA/PROBABLE/POSIBLE) o DECLARACION (lo que dice el afectado, sin corroborar).", levels: "Cada afirmación del informe lleva una etiqueta: HECHO (dato on-chain verificable directamente, sin interpretación), INFERENCIA (conclusión de una heurística, con su propia sub-etiqueta de certeza CERTEZA/PROBABLE/POSIBLE) o DECLARACION (lo que dice el afectado, sin corroborar).",
heuristics: "Se reutilizan las heurísticas del analizador de transacciones y del informe de wallet de Txoko (CIOH, detección de cambio estructural, huella de software, CoinJoin, entidades conocidas), y se añaden: perfil de dirección (personal vs hot wallet), cambio conductual (se gasta rápido vs queda quieto), comparación de huella entre saltos, y confirmación de cadenas de peeling multi-salto.", heuristics: "Se reutilizan las heurísticas del analizador de transacciones y del informe de wallet de Txoko (CIOH, detección de cambio estructural, huella de software, CoinJoin, entidades conocidas), y se añaden: perfil de dirección (personal vs hot wallet), cambio conductual (se gasta rápido vs queda quieto), comparación de huella entre saltos, y confirmación de cadenas de peeling multi-salto.",
limits: "El rastreo se detiene ante un CoinJoin real, dilución excesiva, un custodio identificado o presunto, un UTXO sin gastar, o el límite de saltos configurado. Ninguna heurística identifica la identidad real de una persona: como mucho llega a \"hot wallet de [servicio]\" o \"custodio no identificado\".", limits: "El rastreo se detiene ante un CoinJoin real, dilución excesiva, un custodio identificado o presunto, una transacción que reparte a demasiadas direcciones (reparto masivo), un UTXO sin gastar, o el límite de saltos configurado. También se detiene, sin concluir nada, cuando una consulta al nodo falla: esas ramas se marcan como no comprobadas y quedan fuera de los fondos localizados. Ninguna heurística identifica la identidad real de una persona: como mucho llega a \"hot wallet de [servicio]\" o \"custodio no identificado\".",
}; };
// ── 9. Anexo de verificación ──────────────────────────────────────── // ── 9. Anexo de verificación ────────────────────────────────────────
@@ -4261,7 +4441,7 @@
addresses: [...addressesTouched], addresses: [...addressesTouched],
}; };
return { summary, declaracion, chronology, attributed, unspentFunds, truncatedBranches, conclusions, recommendations, methodology, verificationAppendix }; return { summary, declaracion, chronology, attributed, unspentFunds, truncatedBranches, failedBranches, queryErrors, conclusions, recommendations, methodology, verificationAppendix };
} }
// Eslabón recursivo: muestra una tx de origen y permite seguir SUS inputs. // Eslabón recursivo: muestra una tx de origen y permite seguir SUS inputs.
@@ -5014,7 +5194,7 @@
// ── PERITAJE ───────────────────────────────────────────────────────── // ── PERITAJE ─────────────────────────────────────────────────────────
function PeritajeForense({base}) { function PeritajeForense({base}) {
const {get} = useApi(base); const {get, getStrict} = useApi(base);
const [txidInput, setTxidInput] = useState(""); const [txidInput, setTxidInput] = useState("");
const [voutInput, setVoutInput] = useState("0"); const [voutInput, setVoutInput] = useState("0");
const [amountInput, setAmountInput] = useState(""); const [amountInput, setAmountInput] = useState("");
@@ -5051,7 +5231,7 @@
setError(null); setBusy(true); setReport(null); setError(null); setBusy(true); setReport(null);
try { try {
const amountStolen = amountInput.trim() ? Math.round(parseFloat(amountInput)*1e8) : null; const amountStolen = amountInput.trim() ? Math.round(parseFloat(amountInput)*1e8) : null;
const trace = await initForensicTrace({ get, originTxid: txid, originVout: vout, amountStolen }); const trace = await initForensicTrace({ get, getStrict, originTxid: txid, originVout: vout, amountStolen });
traceRef.current = trace; traceRef.current = trace;
setTick(t => t+1); setTick(t => t+1);
await avanzarSalto(trace); // "iniciar" ya explora el primer salto — un solo clic para ver algo await avanzarSalto(trace); // "iniciar" ya explora el primer salto — un solo clic para ver algo
@@ -5083,6 +5263,7 @@
summary: report.summary, declaracion: report.declaracion, summary: report.summary, declaracion: report.declaracion,
chronology: report.chronology, attributed: report.attributed, chronology: report.chronology, attributed: report.attributed,
unspentFunds: report.unspentFunds, conclusions: report.conclusions, unspentFunds: report.unspentFunds, conclusions: report.conclusions,
failedBranches: report.failedBranches, queryErrors: report.queryErrors,
recommendations: report.recommendations, methodology: report.methodology, recommendations: report.recommendations, methodology: report.methodology,
verificationAppendix: report.verificationAppendix, verificationAppendix: report.verificationAppendix,
}; };
@@ -5091,7 +5272,7 @@
}; };
const exportMD = () => { const exportMD = () => {
const { summary, declaracion:decl, chronology, attributed, unspentFunds, conclusions, recommendations, methodology, verificationAppendix } = report; const { summary, declaracion:decl, chronology, attributed, unspentFunds, failedBranches, conclusions, recommendations, methodology, verificationAppendix } = report;
const lines = [ const lines = [
`# Peritaje forense — Txoko`, ``, `# Peritaje forense — Txoko`, ``,
`## Resumen`, ``, `## Resumen`, ``,
@@ -5113,6 +5294,12 @@
...(unspentFunds.length===0 ? ["Ninguno detectado en las ramas exploradas."] : ...(unspentFunds.length===0 ? ["Ninguno detectado en las ramas exploradas."] :
unspentFunds.map(u => `- [HECHO] ${u.address} — ${u.amount} sats (tx ${u.txid.slice(0,16)}…, vout ${u.vout})`)), unspentFunds.map(u => `- [HECHO] ${u.address} — ${u.amount} sats (tx ${u.txid.slice(0,16)}…, vout ${u.vout})`)),
``, ``,
...((failedBranches && failedBranches.length>0) ? [
`## Ramas NO comprobadas (fallo de consulta)`, ``,
`Estas ramas no pudieron consultarse. **No** son fondos sin gastar: simplemente no se sabe qué pasó con ellas. El alcance del informe es incompleto hasta que se repita el rastreo sobre ellas.`, ``,
...failedBranches.map(f => `- [HECHO] ${f.address} — consulta fallida en tx ${f.txid.slice(0,16)}…, vout ${f.vout} (${f.amount} sats en juego)`),
``,
] : []),
`## Conclusiones`, ``, `## Conclusiones`, ``,
...conclusions.map((c,i) => `${i+1}. [${c.level}${c.certainty?"/"+c.certainty:""}] ${c.text}`), ...conclusions.map((c,i) => `${i+1}. [${c.level}${c.certainty?"/"+c.certainty:""}] ${c.text}`),
``, ``,
@@ -5188,14 +5375,35 @@
{label:"Transacciones exploradas", v: trace.nodes.size}, {label:"Transacciones exploradas", v: trace.nodes.size},
{label:"Ramas pendientes", v: trace.frontier.length}, {label:"Ramas pendientes", v: trace.frontier.length},
{label:"Ramas terminales", v: trace.unspentTerminals.length}, {label:"Ramas terminales", v: trace.unspentTerminals.length},
].map(({label,v})=>( {label:"Consultas fallidas", v: (trace.queryErrors||[]).length, alert:(trace.queryErrors||[]).length>0},
].map(({label,v,alert})=>(
<div key={label}> <div key={label}>
<div style={{fontSize:"0.55rem",color:C.t2,fontFamily:"monospace"}}>{label}</div> <div style={{fontSize:"0.55rem",color:C.t2,fontFamily:"monospace"}}>{label}</div>
<div style={{fontSize:"0.8rem",fontFamily:"monospace",fontWeight:700,color:C.t1}}>{v}</div> <div style={{fontSize:"0.8rem",fontFamily:"monospace",fontWeight:700,color:alert?C.red:C.t1}}>{v}</div>
</div> </div>
))} ))}
</div> </div>
{trace.done?( {/* Un rastreo con consultas fallidas NO está completo: decir
"no quedan ramas" cuando el nodo falló convierte un error en
una conclusión. */}
{trace.done&&(trace.queryErrors||[]).length>0?(
<div style={{padding:"9px 12px",background:C.redMuted,border:`1px solid ${C.red}40`,borderRadius:6,marginBottom:10}}>
<div style={{fontSize:"0.65rem",color:C.red,fontFamily:"monospace",fontWeight:700,marginBottom:4}}>
⚠ Rastro incompleto — {trace.queryErrors.length} consulta(s) al nodo fallaron
</div>
<div style={{fontSize:"0.62rem",color:C.t1,fontFamily:"monospace",lineHeight:1.6}}>
No quedan ramas por explorar, pero algunas no se pudieron comprobar: no se sabe si esos fondos siguen sin gastar o se movieron. Si el nodo estaba ocupado, repetir el rastreo ("Limpiar") suele bastar. Si vuelve a fallar en la misma dirección, no es casualidad: esa dirección tiene un historial demasiado pesado para servirlo dentro del tiempo de espera, y el corte es la protección del nodo haciendo su trabajo. Compruébala a mano en el explorador antes de dar el rastro por cerrado.
</div>
<div style={{marginTop:6,display:"flex",flexDirection:"column",gap:2}}>
{trace.queryErrors.slice(0,5).map((q,i)=>(
<div key={i} style={{fontSize:"0.56rem",color:C.t2,fontFamily:"monospace",wordBreak:"break-all"}}>
{q.address} — {q.message} ({q.context})
</div>
))}
{trace.queryErrors.length>5&&<div style={{fontSize:"0.56rem",color:C.t2,fontFamily:"monospace"}}>…y {trace.queryErrors.length-5} más</div>}
</div>
</div>
):trace.done?(
<div style={{fontSize:"0.65rem",color:C.green,fontFamily:"monospace",marginBottom:10}}>✓ Rastro completo — no quedan ramas por explorar.</div> <div style={{fontSize:"0.65rem",color:C.green,fontFamily:"monospace",marginBottom:10}}>✓ Rastro completo — no quedan ramas por explorar.</div>
):( ):(
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",marginBottom:10}}> <div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",marginBottom:10}}>
@@ -5210,7 +5418,8 @@
</button> </button>
)} )}
<button onClick={generarInforme} disabled={busy||trace.nodes.size===0} <button onClick={generarInforme} disabled={busy||trace.nodes.size===0}
style={{padding:"8px 16px",background:C.purpleMuted,border:`1px solid ${C.purple}50`,borderRadius:6,color:C.purple,fontFamily:"monospace",fontSize:"0.68rem",cursor:"pointer",fontWeight:700}}> title={trace.nodes.size===0?"Aún no hay ninguna transacción explorada que incluir en el informe":""}
style={{padding:"8px 16px",background:C.purpleMuted,border:`1px solid ${C.purple}50`,borderRadius:6,color:C.purple,fontFamily:"monospace",fontSize:"0.68rem",cursor:trace.nodes.size===0?"not-allowed":"pointer",fontWeight:700,opacity:trace.nodes.size===0?0.45:1}}>
{trace.done?"Generar informe":"Generar informe con lo explorado hasta ahora"} {trace.done?"Generar informe":"Generar informe con lo explorado hasta ahora"}
</button> </button>
<button onClick={limpiar} disabled={busy} <button onClick={limpiar} disabled={busy}
@@ -5218,6 +5427,11 @@
Limpiar Limpiar
</button> </button>
</div> </div>
{trace.nodes.size===0&&!busy&&(
<div style={{marginTop:8,fontSize:"0.6rem",color:C.t2,fontFamily:"monospace"}}>
El informe se activa en cuanto haya al menos una transacción explorada.
</div>
)}
{busy&&progress&&<div style={{marginTop:8,fontSize:"0.62rem",color:C.t2,fontFamily:"monospace"}}>{progress}</div>} {busy&&progress&&<div style={{marginTop:8,fontSize:"0.62rem",color:C.t2,fontFamily:"monospace"}}>{progress}</div>}
</Card> </Card>
)} )}
@@ -5328,6 +5542,24 @@
)} )}
</div> </div>
{report.failedBranches&&report.failedBranches.length>0&&(
<div style={{marginBottom:14,padding:"10px 12px",background:C.redMuted,border:`1px solid ${C.red}40`,borderRadius:6}}>
<div style={{fontSize:"0.62rem",color:C.red,fontFamily:"monospace",fontWeight:700,marginBottom:6,letterSpacing:"0.1em"}}>
RAMAS NO COMPROBADAS — {report.failedBranches.length}
</div>
<div style={{fontSize:"0.63rem",color:C.t1,fontFamily:"monospace",lineHeight:1.6,marginBottom:8}}>
La consulta al nodo falló en estas ramas. <strong>No son fondos sin gastar</strong> — no se sabe qué pasó con ellas. El alcance de este informe es incompleto hasta repetir el rastreo sobre ellas.
</div>
<div style={{display:"flex",flexDirection:"column",gap:4}}>
{report.failedBranches.map((f,i)=>(
<div key={i} style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace",wordBreak:"break-all"}}>
{f.address} · {fmt.num(f.amount)} sat · tx {f.txid.slice(0,16)}…:{f.vout}
</div>
))}
</div>
</div>
)}
{report.truncatedBranches.length>0&&( {report.truncatedBranches.length>0&&(
<div style={{marginBottom:14,padding:"10px 12px",background:C.amberMuted,border:`1px solid ${C.amber}30`,borderRadius:6}}> <div style={{marginBottom:14,padding:"10px 12px",background:C.amberMuted,border:`1px solid ${C.amber}30`,borderRadius:6}}>
<div style={{fontSize:"0.65rem",color:C.amber,fontFamily:"monospace"}}> <div style={{fontSize:"0.65rem",color:C.amber,fontFamily:"monospace"}}>
+83
View File
@@ -0,0 +1,83 @@
#!/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.
#
# Ejecutar EN EL NODO:
# chmod +x instalar-fuentes.sh && sudo ./instalar-fuentes.sh
# ─────────────────────────────────────────────────────────────────────────────
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)
tam=$(stat -c%s "$archivo" 2>/dev/null || echo 0)
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."