diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d8470e..5af7d92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,19 @@ y el versionado sigue [Versionado Semántico](https://semver.org/lang/es/): --- ## [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 diff --git a/dashboard.html b/dashboard.html index 2420822..40b0c9c 100644 --- a/dashboard.html +++ b/dashboard.html @@ -3927,6 +3927,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, @@ -3998,11 +4003,21 @@ 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 => { + await Promise.all((fannedOut ? [] : outAddrs).map(async a => { let info = null, txs = []; try { [info, txs] = await Promise.all([ @@ -4064,6 +4079,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"; @@ -4083,6 +4099,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, }); @@ -4091,7 +4108,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; @@ -4289,6 +4306,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] }); } @@ -4317,7 +4339,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 ────────────────────────────────────────