refactor: buildForensicGraph a avance por saltos (initForensicTrace + advanceForensicHop)
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.
This commit is contained in:
+64
-18
@@ -3859,28 +3859,50 @@
|
|||||||
// MAX_NODES es una red de seguridad aparte de maxHops: protege el nodo del
|
// 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.
|
// usuario si un salto desemboca en una tx con muchísimos outputs (p.ej.
|
||||||
// consolidación de un servicio de pagos).
|
// consolidación de un servicio de pagos).
|
||||||
async function buildForensicGraph({ get, originTxid, originVout, amountStolen, maxHops, onProgress }) {
|
// Crea el estado de un rastreo forense nuevo, sin ejecutar ningún salto
|
||||||
maxHops = maxHops || 8;
|
// todavía. Separado de advanceForensicHop para poder pausar entre saltos:
|
||||||
const BATCH = 5, PAUSE = 120, MAX_NODES = 80;
|
// el usuario decide cuándo seguir (pestaña Peritaje), en vez de que el
|
||||||
|
// motor drene el frontier entero sin vigilancia.
|
||||||
const nodes = new Map(); // txid -> ForensicNode
|
async function initForensicTrace({ get, originTxid, originVout, amountStolen }) {
|
||||||
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)
|
|
||||||
|
|
||||||
const originTx = await get(`/api/tx/${originTxid}`, null).catch(()=>null);
|
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.");
|
if (!originTx) throw new Error("No se pudo obtener la transacción de origen. Comprueba el txid.");
|
||||||
const originOut = originTx.vout?.[originVout];
|
const originOut = originTx.vout?.[originVout];
|
||||||
if (!originOut) throw new Error("El vout indicado no existe en la transacción de origen.");
|
if (!originOut) throw new Error("El vout indicado no existe en la transacción de origen.");
|
||||||
txCache.set(originTxid, originTx);
|
|
||||||
const originFingerprint = detectWallets(originTx);
|
const originFingerprint = detectWallets(originTx);
|
||||||
|
|
||||||
let frontier = [{ txid: originTxid, vout: originVout, amount: originOut.value, hop: 0, parentTxid: originTxid }];
|
return {
|
||||||
const enqueuedKeys = new Set();
|
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) {
|
// Ejecuta UN salto completo del rastreo: procesa TODO el frontier actual
|
||||||
const batch = frontier.splice(0, BATCH);
|
// (mismo throttling por lotes de siempre — BATCH/PAUSE — dentro de ese
|
||||||
if (onProgress) onProgress(`Explorando ${nodes.size} tx del rastro (${frontier.length + batch.length} rama(s) en cola)…`);
|
// 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) => {
|
await Promise.all(batch.map(async (item) => {
|
||||||
const { txid, vout, amount, hop, parentTxid } = item;
|
const { txid, vout, amount, hop, parentTxid } = item;
|
||||||
@@ -4001,15 +4023,26 @@
|
|||||||
const key = `${spendTx.txid}:${i}`;
|
const key = `${spendTx.txid}:${i}`;
|
||||||
if (enqueuedKeys.has(key)) return;
|
if (enqueuedKeys.has(key)) return;
|
||||||
enqueuedKeys.add(key);
|
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
|
// salto (por construcción, llegó ahí siguiendo los fondos) + direcciones
|
||||||
// de salida que NO son la puerta de un custodio identificado (esas
|
// de salida que NO son la puerta de un custodio identificado (esas
|
||||||
// pertenecen al servicio, no al mismo actor rastreado).
|
// 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
|
// Genera el informe de peritaje a partir del grafo ya construido. Marco
|
||||||
// HECHO/INFERENCIA/DECLARACION (ver TRASPASO.md): HECHO son datos on-chain
|
// HECHO/INFERENCIA/DECLARACION (ver TRASPASO.md): HECHO son datos on-chain
|
||||||
// verificables directamente, INFERENCIA hereda la sub-etiqueta de certeza
|
// verificables directamente, INFERENCIA hereda la sub-etiqueta de certeza
|
||||||
|
|||||||
Reference in New Issue
Block a user