Misma raíz que el fallo del CoinJoin, en otra variante. El cluster CIOH ya
excluía los custodios (actorAddrSet), pero la atribución por huella de
software no: la hot wallet de Bitfinex aparecía como 'dirección atribuida al
actor' mientras la conclusión la identificaba como exchange dos líneas más
abajo. La dirección donde el ladrón deposita es del exchange, no suya.
Verificado contra el nodo con un depósito real: de 2 atribuciones a 1.
El informe decía 'el rastro se rompe en un CoinJoin, no se puede atribuir con
honestidad más allá de este punto' y acto seguido listaba 10 direcciones
atribuidas al actor, cinco de ellas salidas de esa misma mezcla — es decir,
de otros participantes, señalados en un documento para una denuncia.
CIOH no aplica dentro de un CoinJoin (es su excepción clásica: la mezcla
existe para romper la suposición de dueño común) y la huella de software sale
estable por construcción, porque todos usan el mismo programa.
finalizeForensicGraph excluye las tx marcadas como mixer del conjunto del
actor y del union-find; la atribución por huella salta esos nodos.
Verificado contra el nodo con un Whirlpool real: de 10 atribuciones a 0.
Las librerías (React, Babel) venían de unpkg.com y las fuentes de Google.
Ninguno veía qué transacciones analizabas, pero ambos recibían tu IP y la hora
en cada apertura: sabían que usabas Txoko, cuándo y desde dónde — el metadato
que la propia herramienta enseña a proteger. Ahora se sirven desde el nodo,
con verificación por hash y versiones fijadas. El dashboard funciona sin
internet.
Además, caché con TTL y coalescencia en useApi: las transacciones confirmadas
son inmutables y se cachean toda la sesión, así que repetir un análisis ya
explorado no cuesta ninguna petición al nodo (medido).
Una tx con cientos de salidas (dust attack, lote de retiradas, airdrop) hacía
que el motor perfilara cada dirección de salida (2 peticiones cada una) antes
de encolar las ramas: ~286 peticiones para una tx de 143 salidas, con la
pestaña congelada. El tope corta antes de perfilar, que es donde está el coste.
Verificado contra el nodo con una tx real de 143 salidas: 2 peticiones.
- el aviso ya no promete que el fallo es transitorio: cuando se repite sobre
la misma dirección no lo es, y ahora dice por qué y qué hacer
- README: las ramas no comprobadas quedan fuera de 'fondos sin gastar' a
propósito — cortar protege el nodo y una consulta fallida no es una
conclusión
- README: el tope de volumen mide número de tx, no peso; una dirección con
pocas transacciones muy grandes puede agotar el tiempo de espera igual
(visto con una de 59 tx de ~15 KB)
Detectado probando contra el nodo real: un 503 del backend se mostraba como
'Rastro completo' y contaba la rama como terminal, porque get() devolvía
mockData ante cualquier error y findSpendingTx remataba con .catch(()=>[]).
Un error de red quedaba así indistinguible de 'output sin gastar' — la
conclusión más accionable de un informe pericial, afirmada sobre una consulta
que nunca respondió.
- getStrict en useApi propaga el fallo; get se mantiene igual para el resto
- el motor registra los fallos en trace.queryErrors y no los cuenta como
fondos sin gastar
- el informe los lista aparte y abre las conclusiones avisando del alcance
incompleto (UI, JSON y MD)
- la UI muestra el detalle del fallo en vez del falso rastro completo
Repaso crítico de los 11 commits del módulo (motor, tope de
seguridad, UI a demanda, generador de informe), buscando bugs e
inconsistencias más allá de la poda de ramas ya arreglada. Encontrado
y corregido:
- **Certeza de banda inalcanzable:** en la cronología del informe, el
ternario que decidía CERTEZA/PROBABLE/POSIBLE para la banda de cada
tx comparaba `analysis.score>=75` con `analysis.band==="ALTA"` —
la misma condición dos veces, porque analyzeTx define ALTA como
score>=75. La rama PROBABLE nunca podía darse. Ahora es
score>=90→CERTEZA / score>=45→PROBABLE / resto→POSIBLE, las tres
alcanzables.
- **entityMarks del origen siempre vacío:** buildForensicReport
llamaba marcasDeTx sobre `origin` (el resumen que devuelve
finalizeForensicGraph), que no lleva vin/vout — así que la fila de
origen en la cronología nunca podía mostrar coinbase/OFAC/minería/
exchange, aunque la tx real sí los tuviera. Ahora
finalizeForensicGraph calcula entityMarks sobre la tx cruda y lo
guarda en graph.origin.entityMarks. Verificado con una tx coinbase
sintética: antes daba [], ahora marca correctamente "⛏️ coinbase
(origen)".
- **`custodyStop.byVolume`/tx_count usaba el string "maxHops" como
stopReason** — el mismo valor que ya significaba "truncado por el
parámetro maxHops" en otro sitio (unspentTerminals), pero refería a
MAX_NODES (cinturón de seguridad distinto). Además nunca tenía
cobertura en el informe: un nodo detenido así no generaba ninguna
conclusión, desaparecía en silencio. Renombrado a "nodeLimit" y
añadida su conclusión en el informe.
- **`refTxid` calculado y nunca usado:** el fundamento "cambio
detectado" de cada dirección atribuida guardaba a qué tx se refería
pero ni la UI ni el export Markdown lo mostraban. Ahora ambos lo
citan.
- Comentario de cabecera huérfano tras el refactor a saltos (la
documentación general de "Motor de rastreo forense" quedó pegada
sin fusionar al comentario específico de initForensicTrace) —
consolidado en un único bloque coherente. Referencia de línea
obsoleta a scanWallet, quitada.
- UI: aviso cuando el informe mostrado quedó desactualizado (el
usuario siguió explorando más saltos después de generarlo).
Verificado en navegador: los tres casos de regresión ya usados dan
resultado idéntico (salto simple, cadena de peeling de 3 saltos,
custodio con rama normal continuando), más un caso nuevo con
`stopReason:"nodeLimit"` sintético que confirma que el informe genera
su conclusión sin errores, y el caso coinbase que confirma el fix de
entityMarks.
Cuando un salto se detenía por custodyStop (entidad conocida, hot
wallet, o tope de tx_count), se descartaban los DOS outputs de esa
transacción, no solo el que disparó la parada — un pago normal junto
a un depósito de exchange en la misma tx se perdía igual. Mixer,
dilución y el tope global de nodos siguen bloqueando la transacción
entera (afectan a todos sus outputs por igual, por construcción); un
custodio es propiedad de UNA dirección concreta y ahora se evalúa por
dirección: `custodyByAddr` sustituye al `custodyStop` único, y solo
esa dirección se excluye del encolado y de la semilla CIOH. El nodo
sigue guardando `custodyStop` (primera coincidencia, para el texto
del informe) y el nuevo `custodyAddrs` (todas), y el texto de las
conclusiones pasa a nombrar la dirección concreta en vez de "la
transacción", que ya no es preciso cuando otra rama sigue su curso.
Verificado en navegador: los dos casos de regresión ya usados
(salto simple, cadena de peeling de 3 saltos) dan resultado idéntico
entre el modo automático y el modo por saltos, sin cambios. Caso
nuevo dedicado — un salto con un output de custodio (79.828 tx) junto
a un output normal — confirma que la rama normal se sigue explorando
un salto más, sus fondos aparecen correctamente como localizados,
la dirección de custodio recibe una sola petición (nunca se pagina),
y el CIOH atribuye la rama continuada sin incluir la del custodio.
Caso de dilución confirma que ese stop sigue bloqueando ambos
outputs, sin regresión.
PeritajeForense pasa de "un clic, rastreo automático completo" a "un
clic por salto": el usuario ve el progreso (salto actual, tx
exploradas, ramas pendientes, ramas terminales) y decide cuándo
avanzar, igual que ya funciona el rastro de procedencia hacia atrás
(RastroProcedencia). El rastreo se guarda en un ref mutable
(traceRef) entre clics — initForensicTrace + advanceForensicHop del
commit anterior encajan directamente, sin cambios.
"Iniciar rastreo forense" ahora también ejecuta el primer salto (un
clic para ver algo). "Generar informe" está disponible desde el
primer salto, no solo al terminar, y puede volver a pulsarse en
cualquier momento — incluida la declaración del afectado, editable
mientras el rastreo sigue en marcha. Los campos txid/vout/importe se
deshabilitan una vez iniciado (no tienen efecto a mitad de rastreo);
el tope de saltos y la declaración siguen editables.
Quita el mecanismo antiguo "seguir más saltos (+4)": ya no tiene
sentido con control manual salto a salto, y el texto de ramas
truncadas por el tope se reescribe para no prometer una reanudación
que el motor no soporta (una rama ya truncada no se puede retomar
suelta).
Verificado en navegador con un servidor HTTP local que sirve la
cadena de peeling de 3 saltos ya usada en pruebas anteriores,
pulsando "seguir el rastro" cuatro veces manualmente: el progreso
avanza correctamente salto a salto (1→2→3→4), termina con "rastro
completo", y el informe generado es idéntico en contenido al que
produce el modo automático (misma cadena de peeling CERTEZA, mismas
direcciones atribuidas, mismos fondos sin gastar). Export JSON/MD y
"Limpiar" verificados sin errores de consola.
LARGE_ADDR_TX_COUNT=5000: si el perfil de una dirección de salida
tiene chain_stats.tx_count por encima del umbral, se trata como
custodio presunto (stopReason="exchange", byVolume:true) aunque el
heurístico de perfil no la clasifique "hot_wallet" (p.ej. residual
momentáneamente alto en el momento de la consulta). No cuesta
peticiones extra — chain_stats ya se pide para el perfil de toda
dirección de salida nueva.
Cierra el hueco identificado en la auditoría previa: sin este tope,
una dirección enorme no detectada por el heurístico se encolaría para
el siguiente salto, y findSpendingTx intentaría paginar hasta 200 de
sus transacciones (8 páginas × 8s de timeout, ~64s en el peor caso)
buscando una entre decenas de miles, sin ninguna posibilidad realista
de encontrarla.
El texto del informe distingue este caso: es HECHO (medida de
protección, con el tx_count real citado), no INFERENCIA sobre quién
controla la dirección — a diferencia de una parada por ENTITY_INDEX o
por el heurístico de hot wallet, aquí no se afirma nada sobre la
naturaleza de la dirección.
Verificado en navegador con un caso sintético diseñado para que el
heurístico de perfil NO dispare (residual del 100%, fuera del umbral
<0.05) pero con tx_count=79.828 (el caso real que se va a probar): el
tope se activa correctamente y la dirección enorme recibe EXACTAMENTE
1 petición (el perfil barato) — nunca se llega a paginar su historial.
Separa el motor en tres piezas para poder pausar entre saltos: crear
el estado del rastreo (initForensicTrace), avanzar UN salto completo
(advanceForensicHop, con el mismo throttling por lotes BATCH=5/
PAUSE=120ms de siempre dentro de ese salto) y ensamblar el
ForensicGraph desde el estado en cualquier momento, completo o
parcial (finalizeForensicGraph). buildForensicGraph se mantiene como
caso trivial que llama advanceForensicHop en bucle — modo automático
de una sola pasada, sin cambio de comportamiento.
Prepara el terreno para que la UI (pestaña Peritaje) deje que el
usuario decida cuándo seguir al siguiente salto, en vez de que el
motor drene todas las ramas sin vigilancia. Es la primera de las
protecciones acordadas contra el estrés al nodo con direcciones de
volumen enorme (hot wallets de exchange).
Verificado: balance de sintaxis + node --check sobre el fragmento
puro. En navegador, los dos casos sintéticos ya usados (salto simple,
cadena de peeling de 3 saltos) dan resultado IDÉNTICO byte a byte
entre el modo automático (buildForensicGraph) y el modo por saltos
manual (initForensicTrace + advanceForensicHop en bucle) — grafo,
clusters, cadenas de peeling y conclusiones del informe coinciden.
También verificado que finalizeForensicGraph + buildForensicReport
funcionan correctamente sobre estado PARCIAL (tras un solo salto, sin
terminar el rastreo), que es justo lo que necesitará la UI a demanda.
Nueva pestaña "PERITAJE" junto a las existentes. Formulario de entrada
(txid:vout, importe estimado robado opcional, declaración en texto
libre), botón "Iniciar rastreo forense" con progreso por salto, y
render completo del informe (resumen, declaración, cronología,
direcciones atribuidas, fondos sin gastar, aviso de ramas truncadas
con botón "seguir más saltos", conclusiones, recomendaciones,
metodología, anexo de verificación) con export JSON/MD inline, mismo
patrón que el informe de wallet.
Verificado en navegador (servidor estático local):
- Balance de sintaxis del bloque Babel correcto, sin errores de
transpilación JSX en consola.
- Validación del formulario (txid vacío/inválido, nodo no conectado)
funciona.
- Motor completo probado con datos sintéticos vía consola: caso simple
(1 salto, cambio detectado correctamente, 2 ramas terminan en UTXO
sin gastar) y caso de cadena de peeling de 3 saltos (detectada con
certeza CERTEZA, direcciones de cambio atribuidas con fundamento
correcto, 4 fondos sin gastar localizados en las hojas).
Cierra la implementación del módulo Peritaje descrito en TRASPASO.md.
Ensambla las secciones de la plantilla (spec en TRASPASO.md) sobre el
grafo de buildForensicGraph: resumen, declaración del afectado,
cronología, direcciones atribuidas (CIOH + cambio detectado + huella
estable), fondos sin gastar, conclusiones numeradas, recomendaciones
(con el aviso anti-estafa de "recuperación" fijo cuando hay
declaración), metodología y anexo de verificación.
Marco HECHO/INFERENCIA/DECLARACION aplicado en cada afirmación; el
export a JSON/MD queda para la UI (mismo patrón inline que el informe
de wallet, sin función compartida hoy).
También: buildForensicGraph ahora guarda origin.analysis y
node.changeAddress (dirección resuelta, no el índice) para que el
informe no tenga que reindexar en addresses.out, que al deduplicar
podría desalinearse con el orden real de vout.
Rastreo hacia adelante desde {txid,vout} siguiendo TODOS los outputs de
cada salto (no solo el presunto cambio) porque los fondos pueden
repartirse en varias ramas; cada rama se detiene de forma
independiente. Condiciones de parada: CoinJoin real, dilución (3+
direcciones de entrada no relacionadas), entidad conocida o perfil de
hot wallet no indexado, UTXO sin gastar, límite de saltos.
findSpendingTx implementa el fallback ya verificado en la sesión
anterior (TRASPASO.md): este backend Mempool no expone /outspend, así
que se recorre /api/address/{addr}/txs buscando la tx cuyo vin
referencia el txid:vout de origen — mismo patrón de paginación y
throttling por lotes que scanWallet.
detectPeelingChains cierra el hueco que el check `peeling` de
analyzeTx ya señalaba (confirmar una cadena requiere mirar hacia
adelante): reconstruye tramos de nodos 1-in/2-out conectados por la
señal de cambio combinada, sube a CERTEZA con 3+ saltos y huella de
wallet estable.
MAX_NODES=80 como red de seguridad aparte de maxHops, para no hammer
el nodo del usuario si un salto desemboca en una tx con muchos
outputs. Verificado: balance de sintaxis del bloque Babel y
node --check sobre el fragmento JS puro del motor.
behavioralChangeGuess: para cada output, si se gasta rápido (≤6
bloques) o queda quieto (no gastado todavía). combineChangeSignals
cruza esto con la señal estructural de guessChangeOutput — sube la
certeza cuando coinciden, reporta la discrepancia en vez de forzar
una conclusión cuando no.
compareFingerprints compara la huella de detectWallets entre dos
saltos consecutivos del rastro: un cambio de huella es
INFERENCIA/POSIBLE de cambio de actor o entrada en infraestructura
de un servicio, nunca CERTEZA.
addressProfile(addr, addrInfo, addrTxs) clasifica personal vs hot
wallet usando chain_stats (residual/tx_count) y detección de barrido
automático (recibe y reenvía 1-in/1-out sin cambio, pocos bloques
después). No identifica al custodio, solo el patrón de comportamiento;
la atribución a un exchange concreto sigue viniendo de ENTITY_INDEX.
Primera pieza nueva del módulo Peritaje (spec en TRASPASO.md), sobre
la base de los refactors de unionFindCluster y guessChangeOutput.
Autocontenida para poder llamarse por salto desde el futuro motor de
rastreo forense, que necesita saber qué output concreto seguir (no solo
si el cambio es identificable). analyzeTx delega en ella sin cambio de
comportamiento — mismas señales, mismos umbrales, mismo texto.
Prepara la reutilización del union-find CIOH en el módulo de peritaje
forense (semilla = direcciones del actor rastreado en vez de mis
direcciones). Sin cambio de comportamiento en el informe de wallet.