diff --git a/dashboard.html b/dashboard.html
index 805f8b9..e799dfa 100644
--- a/dashboard.html
+++ b/dashboard.html
@@ -3859,28 +3859,50 @@
// MAX_NODES es una red de seguridad aparte de maxHops: protege el nodo del
// usuario si un salto desemboca en una tx con muchísimos outputs (p.ej.
// consolidación de un servicio de pagos).
- async function buildForensicGraph({ get, originTxid, originVout, amountStolen, maxHops, onProgress }) {
- maxHops = maxHops || 8;
- const BATCH = 5, PAUSE = 120, MAX_NODES = 80;
-
- const nodes = new Map(); // txid -> ForensicNode
- const edges = []; // ForensicEdge[]
- const unspentTerminals = []; // ramas terminales: UTXO sin gastar, sin dirección, o truncadas
- const txCache = new Map(); // txid -> tx cruda (para el CIOH final)
-
+ // Crea el estado de un rastreo forense nuevo, sin ejecutar ningún salto
+ // todavía. Separado de advanceForensicHop para poder pausar entre saltos:
+ // el usuario decide cuándo seguir (pestaña Peritaje), en vez de que el
+ // motor drene el frontier entero sin vigilancia.
+ async function initForensicTrace({ get, originTxid, originVout, amountStolen }) {
const originTx = await get(`/api/tx/${originTxid}`, null).catch(()=>null);
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.");
- txCache.set(originTxid, originTx);
const originFingerprint = detectWallets(originTx);
- let frontier = [{ txid: originTxid, vout: originVout, amount: originOut.value, hop: 0, parentTxid: originTxid }];
- const enqueuedKeys = new Set();
+ return {
+ get, 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
+ frontier: [{ txid: originTxid, vout: originVout, amount: originOut.value, hop: 0, parentTxid: originTxid }],
+ enqueuedKeys: new Set(),
+ hop: 0, done: false,
+ };
+ }
- while (frontier.length > 0) {
- const batch = frontier.splice(0, BATCH);
- if (onProgress) onProgress(`Explorando ${nodes.size} tx del rastro (${frontier.length + batch.length} rama(s) en cola)…`);
+ // Ejecuta UN salto completo del rastreo: procesa TODO el frontier actual
+ // (mismo throttling por lotes de siempre — BATCH/PAUSE — dentro de ese
+ // salto) y deja preparado, sin arrancarlo, el frontier del salto
+ // siguiente. Muta `trace` en el sitio y lo devuelve. trace.done=true
+ // cuando ya no queda nada que explorar (o todo lo que queda se trunca
+ // por maxHops sin necesitar más peticiones).
+ async function advanceForensicHop(trace, opts) {
+ const maxHops = (opts && opts.maxHops) || 8;
+ const onProgress = opts && opts.onProgress;
+ const BATCH = 5, PAUSE = 120, MAX_NODES = 80;
+ const { get, originTxid, originFingerprint, nodes, edges, unspentTerminals, txCache, enqueuedKeys } = trace;
+
+ if (trace.frontier.length === 0) { trace.done = true; return trace; }
+
+ const hopFrontier = trace.frontier;
+ trace.frontier = []; // aquí se acumula el frontier del salto siguiente
+
+ for (let i = 0; i < hopFrontier.length; i += BATCH) {
+ const batch = hopFrontier.slice(i, i + BATCH);
+ if (onProgress) onProgress(`Salto ${trace.hop+1}: explorando rama ${Math.min(i+BATCH,hopFrontier.length)}/${hopFrontier.length} (${nodes.size} tx en el rastro)…`);
await Promise.all(batch.map(async (item) => {
const { txid, vout, amount, hop, parentTxid } = item;
@@ -4001,15 +4023,26 @@
const key = `${spendTx.txid}:${i}`;
if (enqueuedKeys.has(key)) return;
enqueuedKeys.add(key);
- frontier.push({ txid: spendTx.txid, vout:i, amount:v.value, hop:hop+1, parentTxid: txid });
+ trace.frontier.push({ txid: spendTx.txid, vout:i, amount:v.value, hop:hop+1, parentTxid: txid });
});
}
}));
- if (frontier.length > 0) await new Promise(r => setTimeout(r, PAUSE));
+ if (i + BATCH < hopFrontier.length) await new Promise(r => setTimeout(r, PAUSE));
}
- // CIOH final: semilla = origen + toda dirección de entrada de cualquier
+ trace.hop += 1;
+ if (trace.frontier.length === 0) trace.done = true;
+ return trace;
+ }
+
+ // Ensambla el ForensicGraph (forma que espera buildForensicReport) a
+ // 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;
+
+ // CIOH: semilla = origen + toda dirección de entrada de cualquier
// salto (por construcción, llegó ahí siguiendo los fondos) + direcciones
// de salida que NO son la puerta de un custodio identificado (esas
// pertenecen al servicio, no al mismo actor rastreado).
@@ -4032,6 +4065,19 @@
};
}
+ // Punto de entrada equivalente al motor original de una sola pasada:
+ // crea el estado y lo agota salto a salto sin pausas (modo automático).
+ // 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 });
+ while (!trace.done) {
+ await advanceForensicHop(trace, { maxHops, onProgress });
+ }
+ return finalizeForensicGraph(trace);
+ }
+
// Genera el informe de peritaje a partir del grafo ya construido. Marco
// HECHO/INFERENCIA/DECLARACION (ver TRASPASO.md): HECHO son datos on-chain
// verificables directamente, INFERENCIA hereda la sub-etiqueta de certeza