Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b483e0df9 | ||
|
|
d0cd9ef587 | ||
|
|
7c12f63f7a | ||
|
|
1b6904d2e3 | ||
|
|
14a094f941 | ||
|
|
8c2725fb31 | ||
|
|
c2fb4f1388 | ||
|
|
4d242a6ac6 | ||
|
|
251981e383 |
@@ -23,3 +23,6 @@ node_modules/
|
||||
npm-debug.log*
|
||||
txoko-roadmap.md
|
||||
TRASPASO.md
|
||||
PRUEBA-PERITAJE.md
|
||||
HALLAZGOS-PERITAJE.md
|
||||
MEJORAS.md
|
||||
|
||||
+112
@@ -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
|
||||
- **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
|
||||
### Añadido
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
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
|
||||
- **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
|
||||
- **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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
La función watch-only (derivar tus direcciones desde el xpub) usa la Web Crypto
|
||||
|
||||
+270
-38
@@ -4,9 +4,19 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Txoko Node Dashboard</title>
|
||||
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.23.10/babel.min.js"></script>
|
||||
<!-- Librerías servidas desde TU nodo, no desde un CDN. Un CDN externo no ve
|
||||
qué transacciones analizas, pero sí que usas Txoko, cuándo y desde qué
|
||||
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>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
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; }
|
||||
input::placeholder { color: #4a6380; }
|
||||
</style>
|
||||
<link rel="preconnect" href="https://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">
|
||||
<!-- Fuentes servidas desde TU nodo. Antes venían de fonts.googleapis.com,
|
||||
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>
|
||||
<body>
|
||||
<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) {
|
||||
const get = useCallback(async (path, mockData) => {
|
||||
if (!baseUrl) return mockData;
|
||||
try {
|
||||
const res = await fetchWithTimeout(`${baseUrl}${path}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
return await apiGetJson(baseUrl, path);
|
||||
} catch { return mockData; }
|
||||
}, [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 = {
|
||||
@@ -3785,16 +3857,22 @@
|
||||
// (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
|
||||
// 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;
|
||||
let page = await get(`/api/address/${addr}/txs`, []).catch(()=>[]);
|
||||
let page = await getStrict(`/api/address/${addr}/txs`);
|
||||
let guard = 0;
|
||||
while (Array.isArray(page) && page.length > 0 && guard < maxPages) {
|
||||
const found = page.find(t => (t.vin||[]).some(v => v.txid === txid && v.vout === vout));
|
||||
if (found) return found;
|
||||
if (page.length < 25) break; // última página, no hay más que mirar
|
||||
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++;
|
||||
}
|
||||
return null;
|
||||
@@ -3865,20 +3943,29 @@
|
||||
//
|
||||
// Crea el estado de un rastreo forense nuevo, sin ejecutar ningún salto
|
||||
// todavía.
|
||||
async function initForensicTrace({ get, originTxid, originVout, amountStolen }) {
|
||||
const originTx = await get(`/api/tx/${originTxid}`, null).catch(()=>null);
|
||||
async function initForensicTrace({ get, getStrict, originTxid, originVout, amountStolen }) {
|
||||
// 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.");
|
||||
const originOut = originTx.vout?.[originVout];
|
||||
if (!originOut) throw new Error("El vout indicado no existe en la transacción de origen.");
|
||||
const originFingerprint = detectWallets(originTx);
|
||||
|
||||
return {
|
||||
get, originTxid, originVout, amountStolen: amountStolen || null,
|
||||
get, getStrict, originTxid, originVout, amountStolen: amountStolen || null,
|
||||
originTx, originOut, originFingerprint,
|
||||
txCache: new Map([[originTxid, originTx]]),
|
||||
nodes: new Map(), // txid -> ForensicNode
|
||||
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 }],
|
||||
enqueuedKeys: new Set(),
|
||||
hop: 0, done: false,
|
||||
@@ -3900,6 +3987,11 @@
|
||||
// salto desemboca en una tx con muchísimos outputs (p.ej.
|
||||
// consolidación de un servicio de pagos).
|
||||
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
|
||||
// dirección con un volumen de transacciones así de alto es, con
|
||||
// altísima probabilidad, infraestructura de un servicio (exchange,
|
||||
@@ -3912,7 +4004,7 @@
|
||||
// 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.
|
||||
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; }
|
||||
|
||||
@@ -3938,7 +4030,17 @@
|
||||
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) {
|
||||
unspentTerminals.push({ txid, vout, address:addr, amount, note:"UTXO sin gastar." });
|
||||
return;
|
||||
@@ -3961,15 +4063,33 @@
|
||||
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))];
|
||||
|
||||
// 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
|
||||
// historial para la señal conductual (spendInfo) sin pedirlo dos veces.
|
||||
const outAddrTxs = {};
|
||||
const addressProfiles = {};
|
||||
await Promise.all(outAddrs.map(async a => {
|
||||
const [info, txs] = await Promise.all([
|
||||
get(`/api/address/${a}`, null).catch(()=>null),
|
||||
get(`/api/address/${a}/txs`, []).catch(()=>[]),
|
||||
await Promise.all((fannedOut ? [] : outAddrs).map(async a => {
|
||||
let info = null, txs = [];
|
||||
try {
|
||||
[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 || [];
|
||||
if (info) addressProfiles[a] = addressProfile(a, info, txs||[]);
|
||||
}));
|
||||
@@ -4019,6 +4139,7 @@
|
||||
let stopReason = null;
|
||||
if (likelyCJ) stopReason = "mixer";
|
||||
else if (diluted) stopReason = "dilution";
|
||||
else if (fannedOut) stopReason = "fanOut";
|
||||
else if (custodyStop) stopReason = "exchange";
|
||||
else if (nodes.size + 1 >= MAX_NODES) stopReason = "nodeLimit";
|
||||
|
||||
@@ -4038,6 +4159,7 @@
|
||||
changeGuess: { structural, behavioral, combined },
|
||||
changeAddress: structural.index != null ? (spendTx.vout[structural.index]?.scriptpubkey_address ?? null) : null,
|
||||
diluted, tracedShare, custodyStop, custodyAddrs,
|
||||
fanOut: fannedOut ? { outputs: outAddrs.length, limit: MAX_FANOUT } : null,
|
||||
stopReason,
|
||||
});
|
||||
|
||||
@@ -4046,7 +4168,7 @@
|
||||
// límite global) — ahí sí se corta la transacción entera. Un
|
||||
// custodio, en cambio, es propiedad de UNA dirección concreta:
|
||||
// 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) {
|
||||
spendTx.vout.forEach((v, i) => {
|
||||
if (!v.value || v.value <= 0) return;
|
||||
@@ -4071,7 +4193,7 @@
|
||||
// partir de un `trace`, completo o parcial — se puede llamar en
|
||||
// cualquier momento del rastreo a demanda, no solo al terminar.
|
||||
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
|
||||
// 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
|
||||
// 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.
|
||||
// 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 mixerTxids = new Set();
|
||||
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.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,
|
||||
// exchange, CoinJoin) — se calcula aquí, sobre originTx de verdad, y se
|
||||
@@ -4101,6 +4235,7 @@
|
||||
},
|
||||
amountStolen: amountStolen || null,
|
||||
nodes, edges, unspentTerminals, clusters, linkReasons,
|
||||
queryErrors: queryErrors || [],
|
||||
peelingChains: detectPeelingChains(nodes, edges),
|
||||
};
|
||||
}
|
||||
@@ -4110,8 +4245,8 @@
|
||||
// Se mantiene por compatibilidad — la UI a demanda usa
|
||||
// initForensicTrace + advanceForensicHop directamente, con el usuario
|
||||
// decidiendo cuándo llamar a cada salto en vez de este bucle.
|
||||
async function buildForensicGraph({ get, originTxid, originVout, amountStolen, maxHops, onProgress }) {
|
||||
const trace = await initForensicTrace({ get, originTxid, originVout, amountStolen });
|
||||
async function buildForensicGraph({ get, getStrict, originTxid, originVout, amountStolen, maxHops, onProgress }) {
|
||||
const trace = await initForensicTrace({ get, getStrict, originTxid, originVout, amountStolen });
|
||||
while (!trace.done) {
|
||||
await advanceForensicHop(trace, { maxHops, onProgress });
|
||||
}
|
||||
@@ -4125,6 +4260,7 @@
|
||||
// afectado sin corroborar — nunca se mezcla con las otras dos.
|
||||
function buildForensicReport(graph, declaracionText) {
|
||||
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 maxHop = allNodes.reduce((m,n)=>Math.max(m,n.hop), 0);
|
||||
const addressesTouched = new Set([origin.address].filter(Boolean));
|
||||
@@ -4170,9 +4306,30 @@
|
||||
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();
|
||||
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 = [];
|
||||
@@ -4192,14 +4349,32 @@
|
||||
}
|
||||
|
||||
// ── 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
|
||||
.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] }));
|
||||
const truncatedBranches = unspentTerminals.filter(u => u.truncated);
|
||||
const failedBranches = unspentTerminals.filter(u => u.queryFailed);
|
||||
|
||||
// ── 6. Conclusiones numeradas ────────────────────────────────────────
|
||||
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] });
|
||||
// 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) {
|
||||
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") {
|
||||
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") {
|
||||
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 = {
|
||||
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.",
|
||||
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 ────────────────────────────────────────
|
||||
@@ -4261,7 +4441,7 @@
|
||||
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.
|
||||
@@ -5014,7 +5194,7 @@
|
||||
|
||||
// ── PERITAJE ─────────────────────────────────────────────────────────
|
||||
function PeritajeForense({base}) {
|
||||
const {get} = useApi(base);
|
||||
const {get, getStrict} = useApi(base);
|
||||
const [txidInput, setTxidInput] = useState("");
|
||||
const [voutInput, setVoutInput] = useState("0");
|
||||
const [amountInput, setAmountInput] = useState("");
|
||||
@@ -5051,7 +5231,7 @@
|
||||
setError(null); setBusy(true); setReport(null);
|
||||
try {
|
||||
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;
|
||||
setTick(t => t+1);
|
||||
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,
|
||||
chronology: report.chronology, attributed: report.attributed,
|
||||
unspentFunds: report.unspentFunds, conclusions: report.conclusions,
|
||||
failedBranches: report.failedBranches, queryErrors: report.queryErrors,
|
||||
recommendations: report.recommendations, methodology: report.methodology,
|
||||
verificationAppendix: report.verificationAppendix,
|
||||
};
|
||||
@@ -5091,7 +5272,7 @@
|
||||
};
|
||||
|
||||
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 = [
|
||||
`# Peritaje forense — Txoko`, ``,
|
||||
`## Resumen`, ``,
|
||||
@@ -5113,6 +5294,12 @@
|
||||
...(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})`)),
|
||||
``,
|
||||
...((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`, ``,
|
||||
...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:"Ramas pendientes", v: trace.frontier.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 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>
|
||||
{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.62rem",color:C.t2,fontFamily:"monospace",marginBottom:10}}>
|
||||
@@ -5210,7 +5418,8 @@
|
||||
</button>
|
||||
)}
|
||||
<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"}
|
||||
</button>
|
||||
<button onClick={limpiar} disabled={busy}
|
||||
@@ -5218,6 +5427,11 @@
|
||||
Limpiar
|
||||
</button>
|
||||
</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>}
|
||||
</Card>
|
||||
)}
|
||||
@@ -5328,6 +5542,24 @@
|
||||
)}
|
||||
</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&&(
|
||||
<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"}}>
|
||||
|
||||
Executable
+83
@@ -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."
|
||||
Reference in New Issue
Block a user