From 29e8e184e8c70201bd20b90615eba7e51403827f Mon Sep 17 00:00:00 2001 From: pikaro Date: Thu, 16 Jul 2026 15:03:00 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20pasada=20de=20calidad=20sobre=20el=20m?= =?UTF-8?q?=C3=B3dulo=20Peritaje=20forense?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repaso crítico de los 11 commits del módulo (motor, tope de seguridad, UI a demanda, generador de informe), buscando bugs e inconsistencias más allá de la poda de ramas ya arreglada. Encontrado y corregido: - **Certeza de banda inalcanzable:** en la cronología del informe, el ternario que decidía CERTEZA/PROBABLE/POSIBLE para la banda de cada tx comparaba `analysis.score>=75` con `analysis.band==="ALTA"` — la misma condición dos veces, porque analyzeTx define ALTA como score>=75. La rama PROBABLE nunca podía darse. Ahora es score>=90→CERTEZA / score>=45→PROBABLE / resto→POSIBLE, las tres alcanzables. - **entityMarks del origen siempre vacío:** buildForensicReport llamaba marcasDeTx sobre `origin` (el resumen que devuelve finalizeForensicGraph), que no lleva vin/vout — así que la fila de origen en la cronología nunca podía mostrar coinbase/OFAC/minería/ exchange, aunque la tx real sí los tuviera. Ahora finalizeForensicGraph calcula entityMarks sobre la tx cruda y lo guarda en graph.origin.entityMarks. Verificado con una tx coinbase sintética: antes daba [], ahora marca correctamente "⛏️ coinbase (origen)". - **`custodyStop.byVolume`/tx_count usaba el string "maxHops" como stopReason** — el mismo valor que ya significaba "truncado por el parámetro maxHops" en otro sitio (unspentTerminals), pero refería a MAX_NODES (cinturón de seguridad distinto). Además nunca tenía cobertura en el informe: un nodo detenido así no generaba ninguna conclusión, desaparecía en silencio. Renombrado a "nodeLimit" y añadida su conclusión en el informe. - **`refTxid` calculado y nunca usado:** el fundamento "cambio detectado" de cada dirección atribuida guardaba a qué tx se refería pero ni la UI ni el export Markdown lo mostraban. Ahora ambos lo citan. - Comentario de cabecera huérfano tras el refactor a saltos (la documentación general de "Motor de rastreo forense" quedó pegada sin fusionar al comentario específico de initForensicTrace) — consolidado en un único bloque coherente. Referencia de línea obsoleta a scanWallet, quitada. - UI: aviso cuando el informe mostrado quedó desactualizado (el usuario siguió explorando más saltos después de generarlo). Verificado en navegador: los tres casos de regresión ya usados dan resultado idéntico (salto simple, cadena de peeling de 3 saltos, custodio con rama normal continuando), más un caso nuevo con `stopReason:"nodeLimit"` sintético que confirma que el informe genera su conclusión sin errores, y el caso coinbase que confirma el fix de entityMarks. --- dashboard.html | 63 ++++++++++++++++++++++++++++++++++---------------- 1 file changed, 43 insertions(+), 20 deletions(-) diff --git a/dashboard.html b/dashboard.html index d3690e0..5c3756f 100644 --- a/dashboard.html +++ b/dashboard.html @@ -3784,7 +3784,7 @@ // backend Mempool self-hosted no implementa /outspend ni /outspends // (verificado contra el nodo real — ver TRASPASO.md), así que no hay forma // directa de preguntar "¿quién gasta esto?". Mismo patrón de paginación - // que scanWallet (~línea 3929): página de 25 + /txs/chain/{last}. + // que scanWallet: página de 25 + /txs/chain/{last}. async function findSpendingTx(get, addr, txid, vout, maxPages) { maxPages = maxPages || 8; let page = await get(`/api/address/${addr}/txs`, []).catch(()=>[]); @@ -3847,22 +3847,24 @@ return chains; } - // Motor de rastreo forense — hacia adelante, salto a salto, desde - // {txid,vout}. Sigue TODOS los outputs de cada salto (no solo "el - // cambio"): un actor puede repartir los fondos en más de una rama, y cada - // rama se detiene de forma independiente (spec: "cualquiera detiene esa - // rama, no todo el rastreo"). Qué output es "cambio" se calcula por señal + // ── Motor de rastreo forense ───────────────────────────────────────── + // Hacia adelante, salto a salto, desde {txid,vout}. Sigue TODOS los + // outputs de cada salto (no solo "el cambio"): un actor puede repartir + // los fondos en más de una rama, y cada rama se detiene de forma + // independiente (spec: "cualquiera detiene esa rama, no todo el + // rastreo"). Qué output es "cambio" se calcula por señal // estructural+conductual y sirve para el informe (atribución, peeling // chain), no para podar ramas. // - // Throttling: mismo patrón que scanWallet — lotes con pausa, no ráfaga. - // 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). + // A demanda: el estado del rastreo (initForensicTrace) y el avance de + // un salto (advanceForensicHop) están separados a propósito, para que + // la UI pueda pausar entre saltos y sea el usuario quien decida cuándo + // seguir — en vez de que el motor drene el frontier entero sin + // vigilancia. buildForensicGraph, más abajo, es el caso trivial que los + // encadena en bucle (modo automático de una sola pasada). + // // 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. + // todavía. 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."); @@ -3892,6 +3894,11 @@ async function advanceForensicHop(trace, opts) { const maxHops = (opts && opts.maxHops) || 8; const onProgress = opts && opts.onProgress; + // BATCH/PAUSE: mismo patrón de throttling que scanWallet, lotes con + // pausa, no ráfaga. MAX_NODES es una red de seguridad aparte de + // maxHops y del control manual del usuario: protege el nodo si un + // salto desemboca en una tx con muchísimos outputs (p.ej. + // consolidación de un servicio de pagos). 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 @@ -4013,7 +4020,7 @@ if (likelyCJ) stopReason = "mixer"; else if (diluted) stopReason = "dilution"; else if (custodyStop) stopReason = "exchange"; - else if (nodes.size + 1 >= MAX_NODES) stopReason = "maxHops"; + else if (nodes.size + 1 >= MAX_NODES) stopReason = "nodeLimit"; const parentNode = parentTxid === originTxid ? { fingerprint: originFingerprint } : nodes.get(parentTxid); const fingerprintComparison = parentNode ? compareFingerprints(parentNode.fingerprint, fingerprint) : null; @@ -4039,7 +4046,7 @@ // límite global) — ahí sí se corta la transacción entera. Un // custodio, en cambio, es propiedad de UNA dirección concreta: // solo esa rama se corta, las demás siguen su curso normal. - const blockAllOutputs = stopReason === "mixer" || stopReason === "dilution" || stopReason === "maxHops"; + const blockAllOutputs = stopReason === "mixer" || stopReason === "dilution" || stopReason === "nodeLimit"; if (!blockAllOutputs) { spendTx.vout.forEach((v, i) => { if (!v.value || v.value <= 0) return; @@ -4079,11 +4086,18 @@ } const { clusters, linkReasons } = unionFindCluster([...txCache.values()], actorAddrSet); + // marcasDeTx necesita vin/vout crudos (coinbase, OFAC, minería, + // exchange, CoinJoin) — se calcula aquí, sobre originTx de verdad, y se + // guarda ya resuelto en graph.origin porque ese objeto solo lleva los + // campos resumidos que necesita el informe, no la tx cruda. + const originAnalysis = analyzeTx(originTx); + const originEntityMarks = marcasDeTx(originTx, originAnalysis).marcas; + return { origin: { txid: originTxid, vout: originVout, address: originOut.scriptpubkey_address, amount: originOut.value, blockTime: originTx.status?.block_time ?? null, blockHeight: originTx.status?.confirmed ? (originTx.status.block_height ?? null) : null, - fingerprint: originFingerprint, analysis: analyzeTx(originTx), + fingerprint: originFingerprint, analysis: originAnalysis, entityMarks: originEntityMarks, }, amountStolen: amountStolen || null, nodes, edges, unspentTerminals, clusters, linkReasons, @@ -4132,12 +4146,12 @@ // ── 3. Cronología ──────────────────────────────────────────────────── const chronologyRow = (txid, hop, blockTime, blockHeight, amount, analysis, fingerprint, entityMarks) => ({ level: "HECHO", txid, hop, blockTime, blockHeight, amount, refs:[txid], - band: { level:"INFERENCIA", certainty: analysis?.band ? (analysis.score>=75?"CERTEZA":analysis.band==="ALTA"?"PROBABLE":"POSIBLE") : null, value: analysis?.band || "—" }, + band: { level:"INFERENCIA", certainty: analysis?.band ? (analysis.score>=90?"CERTEZA":analysis.score>=45?"PROBABLE":"POSIBLE") : null, value: analysis?.band || "—" }, fingerprint: { level:"INFERENCIA", certainty: fingerprint?.[0]?.confidence || null, value: fingerprint?.[0]?.name || "sin huella clara" }, entityMarks: entityMarks || [], }); const chronology = [ - chronologyRow(origin.txid, 0, origin.blockTime, origin.blockHeight, origin.amount, origin.analysis, origin.fingerprint, marcasDeTx(origin, origin.analysis).marcas), + chronologyRow(origin.txid, 0, origin.blockTime, origin.blockHeight, origin.amount, origin.analysis, origin.fingerprint, origin.entityMarks), ...allNodes.map(n => chronologyRow(n.txid, n.hop, n.blockTime, n.blockHeight, n.amountTraced, n.analysis, n.fingerprint, n.entityMarks)), ]; @@ -4210,6 +4224,9 @@ if (n.stopReason === "dilution") { conclusions.push({ level:"INFERENCIA", certainty:"POSIBLE", text:`Dilución en ${n.txid}: el monto rastreado deja de ser una fracción identificable del total de la transacción (${(n.tracedShare*100).toFixed(1)}%).`, refs:[n.txid] }); } + if (n.stopReason === "nodeLimit") { + conclusions.push({ level:"HECHO", text:`El rastro se detiene en ${n.txid} por el tope de transacciones exploradas — una red de seguridad aparte del límite de saltos, para no sobrecargar el nodo si un salto desemboca en una consolidación con muchísimas ramas. No es un punto de parada natural; se puede seguir rastreando manualmente desde aquí si hace falta.`, refs:[n.txid] }); + } } if (unspentFunds.length > 0) { conclusions.push({ level:"HECHO", text:`${unspentFunds.length} salida(s) con fondos localizados sin gastar, sumando ${unspentFunds.reduce((s,u)=>s+u.amount,0)} sats.`, refs: unspentFunds.map(u=>u.txid) }); @@ -5090,7 +5107,7 @@ ``, `## Direcciones atribuidas al actor`, ``, ...(attributed.length===0 ? ["No se atribuyen direcciones adicionales con fundamento suficiente."] : - attributed.map(a => `- [INFERENCIA] ${a.address} — ${a.fundamentos.map(f=>`${f.basis} (${f.certainty})`).join("; ")}`)), + attributed.map(a => `- [INFERENCIA] ${a.address} — ${a.fundamentos.map(f=>`${f.basis} (${f.certainty})${f.refTxid?` [tx ${f.refTxid}]`:""}`).join("; ")}`)), ``, `## Fondos localizados sin gastar`, ``, ...(unspentFunds.length===0 ? ["Ninguno detectado en las ramas exploradas."] : @@ -5215,6 +5232,12 @@ + {trace&&!trace.done&&trace.hop>report.summary.hops&&( +
+ Este informe se generó en el salto {report.summary.hops} — el rastreo ya lleva {trace.hop}. Genera de nuevo para incluir lo explorado desde entonces. +
+ )} + {/* Resumen */}
{[ @@ -5278,7 +5301,7 @@
{a.fundamentos.map((f,j)=>(
- [{f.certainty}] {f.basis} + [{f.certainty}] {f.basis}{f.refTxid?` — tx ${f.refTxid.slice(0,16)}…`:""}
))}