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