diff --git a/dashboard.html b/dashboard.html
index e799dfa..2445af0 100644
--- a/dashboard.html
+++ b/dashboard.html
@@ -3893,6 +3893,18 @@
const maxHops = (opts && opts.maxHops) || 8;
const onProgress = opts && opts.onProgress;
const BATCH = 5, PAUSE = 120, MAX_NODES = 80;
+ // 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,
+ // custodio, procesador de pagos) aunque el perfil no llegue a
+ // clasificarla "hot_wallet" (residual momentáneamente alto, etc.). Sin
+ // este tope, seguir esa dirección dispararía en findSpendingTx una
+ // paginación de hasta 200 tx (8 páginas × 8s de timeout) buscando una
+ // aguja en un pajar de decenas de miles — coste real para el nodo del
+ // usuario sin ninguna posibilidad realista de encontrar el gasto. No
+ // 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;
if (trace.frontier.length === 0) { trace.done = true; return trace; }
@@ -3978,17 +3990,26 @@
const tracedShare = totalInHop > 0 ? amount / totalInHop : 1;
const diluted = inAddrs.length >= 3 && tracedShare < 0.5;
- // Parada por custodio: entidad conocida (ENTITY_INDEX) o perfil de
- // hot wallet no indexado ("posible custodio no identificado").
+ // Parada por custodio: entidad conocida (ENTITY_INDEX), perfil de
+ // hot wallet no indexado ("posible custodio no identificado"), o
+ // — cinturón de seguridad — volumen de tx tan alto que seguir esa
+ // dirección sería, con seguridad práctica, hammer al nodo sin
+ // esperanza real de encontrar el gasto (ver LARGE_ADDR_TX_COUNT).
let custodyStop = null;
for (const a of outAddrs) {
const hit = ENTITY_INDEX.get(a);
- if (hit?.cat === "exchange") { custodyStop = { addr:a, name:hit.name, known:true }; break; }
+ if (hit?.cat === "exchange") { custodyStop = { addr:a, name:hit.name, known:true, byVolume:false }; break; }
}
if (!custodyStop) {
for (const a of outAddrs) {
const prof = addressProfiles[a];
- if (prof && prof.kind === "hot_wallet") { custodyStop = { addr:a, name:null, known:false }; break; }
+ if (prof && prof.kind === "hot_wallet") { custodyStop = { addr:a, name:null, known:false, byVolume:false }; break; }
+ }
+ }
+ if (!custodyStop) {
+ for (const a of outAddrs) {
+ const prof = addressProfiles[a];
+ if (prof && prof.txCount >= LARGE_ADDR_TX_COUNT) { custodyStop = { addr:a, name:null, known:false, byVolume:true, txCount:prof.txCount }; break; }
}
}
@@ -4169,7 +4190,11 @@
}
}
for (const n of allNodes) {
- if (n.stopReason === "exchange" && n.custodyStop) {
+ if (n.stopReason === "exchange" && n.custodyStop && n.custodyStop.byVolume) {
+ conclusions.push({ level:"HECHO",
+ text:`El rastro se detiene por precaución en la transacción ${n.txid}: la dirección de destino tiene ${fmt.num(n.custodyStop.txCount)} transacciones registradas, un volumen que hace inviable buscar el gasto concreto sin sobrecargar el nodo. No es una conclusión sobre quién controla esa dirección — solo una medida de protección. Puedes intentar continuar manualmente si lo necesitas.`,
+ refs:[n.txid, n.custodyStop.addr] });
+ } else if (n.stopReason === "exchange" && n.custodyStop) {
const label = n.custodyStop.known ? `${n.custodyStop.name} (fuente: índice de entidades)` : "hot wallet no identificada — posible custodio";
conclusions.push({ level:"INFERENCIA", certainty: n.custodyStop.known ? "PROBABLE" : "POSIBLE",
text:`El rastro se detiene en ${label} en la transacción ${n.txid}.`, refs:[n.txid, n.custodyStop.addr] });