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:
2026-07-16 13:52:02 +02:00
parent ebc1a8017f
commit c86134cc24
+64 -18
View File
@@ -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