fix: pasada de calidad sobre el módulo Peritaje forense

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.
This commit is contained in:
Aitor
2026-07-16 15:03:00 +02:00
parent a5d58dadd4
commit 50c19ebc9f
+43 -20
View File
@@ -3784,7 +3784,7 @@
// backend Mempool self-hosted no implementa /outspend ni /outspends // backend Mempool self-hosted no implementa /outspend ni /outspends
// (verificado contra el nodo real — ver TRASPASO.md), así que no hay forma // (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 // 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) { async function findSpendingTx(get, addr, txid, vout, maxPages) {
maxPages = maxPages || 8; maxPages = maxPages || 8;
let page = await get(`/api/address/${addr}/txs`, []).catch(()=>[]); let page = await get(`/api/address/${addr}/txs`, []).catch(()=>[]);
@@ -3847,22 +3847,24 @@
return chains; return chains;
} }
// Motor de rastreo forense — hacia adelante, salto a salto, desde // ── Motor de rastreo forense ─────────────────────────────────────────
// {txid,vout}. Sigue TODOS los outputs de cada salto (no solo "el // Hacia adelante, salto a salto, desde {txid,vout}. Sigue TODOS los
// cambio"): un actor puede repartir los fondos en más de una rama, y cada // outputs de cada salto (no solo "el cambio"): un actor puede repartir
// rama se detiene de forma independiente (spec: "cualquiera detiene esa // los fondos en más de una rama, y cada rama se detiene de forma
// rama, no todo el rastreo"). Qué output es "cambio" se calcula por señal // 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 // estructural+conductual y sirve para el informe (atribución, peeling
// chain), no para podar ramas. // chain), no para podar ramas.
// //
// Throttling: mismo patrón que scanWallet — lotes con pausa, no ráfaga. // A demanda: el estado del rastreo (initForensicTrace) y el avance de
// MAX_NODES es una red de seguridad aparte de maxHops: protege el nodo del // un salto (advanceForensicHop) están separados a propósito, para que
// usuario si un salto desemboca en una tx con muchísimos outputs (p.ej. // la UI pueda pausar entre saltos y sea el usuario quien decida cuándo
// consolidación de un servicio de pagos). // 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 // Crea el estado de un rastreo forense nuevo, sin ejecutar ningún salto
// todavía. Separado de advanceForensicHop para poder pausar entre saltos: // todavía.
// 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 }) { async function initForensicTrace({ get, originTxid, originVout, amountStolen }) {
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.");
@@ -3892,6 +3894,11 @@
async function advanceForensicHop(trace, opts) { async function advanceForensicHop(trace, opts) {
const maxHops = (opts && opts.maxHops) || 8; const maxHops = (opts && opts.maxHops) || 8;
const onProgress = opts && opts.onProgress; 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; const BATCH = 5, PAUSE = 120, MAX_NODES = 80;
// Cinturón de seguridad independiente del heurístico de perfil: una // Cinturón de seguridad independiente del heurístico de perfil: una
// dirección con un volumen de transacciones así de alto es, con // dirección con un volumen de transacciones así de alto es, con
@@ -4013,7 +4020,7 @@
if (likelyCJ) stopReason = "mixer"; if (likelyCJ) stopReason = "mixer";
else if (diluted) stopReason = "dilution"; else if (diluted) stopReason = "dilution";
else if (custodyStop) stopReason = "exchange"; 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 parentNode = parentTxid === originTxid ? { fingerprint: originFingerprint } : nodes.get(parentTxid);
const fingerprintComparison = parentNode ? compareFingerprints(parentNode.fingerprint, fingerprint) : null; const fingerprintComparison = parentNode ? compareFingerprints(parentNode.fingerprint, fingerprint) : null;
@@ -4039,7 +4046,7 @@
// límite global) — ahí sí se corta la transacción entera. Un // límite global) — ahí sí se corta la transacción entera. Un
// custodio, en cambio, es propiedad de UNA dirección concreta: // custodio, en cambio, es propiedad de UNA dirección concreta:
// solo esa rama se corta, las demás siguen su curso normal. // 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) { if (!blockAllOutputs) {
spendTx.vout.forEach((v, i) => { spendTx.vout.forEach((v, i) => {
if (!v.value || v.value <= 0) return; if (!v.value || v.value <= 0) return;
@@ -4079,11 +4086,18 @@
} }
const { clusters, linkReasons } = unionFindCluster([...txCache.values()], actorAddrSet); 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 { return {
origin: { origin: {
txid: originTxid, vout: originVout, address: originOut.scriptpubkey_address, amount: originOut.value, 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, 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, amountStolen: amountStolen || null,
nodes, edges, unspentTerminals, clusters, linkReasons, nodes, edges, unspentTerminals, clusters, linkReasons,
@@ -4132,12 +4146,12 @@
// ── 3. Cronología ──────────────────────────────────────────────────── // ── 3. Cronología ────────────────────────────────────────────────────
const chronologyRow = (txid, hop, blockTime, blockHeight, amount, analysis, fingerprint, entityMarks) => ({ const chronologyRow = (txid, hop, blockTime, blockHeight, amount, analysis, fingerprint, entityMarks) => ({
level: "HECHO", txid, hop, blockTime, blockHeight, amount, refs:[txid], 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" }, fingerprint: { level:"INFERENCIA", certainty: fingerprint?.[0]?.confidence || null, value: fingerprint?.[0]?.name || "sin huella clara" },
entityMarks: entityMarks || [], entityMarks: entityMarks || [],
}); });
const chronology = [ 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)), ...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") { 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] }); 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) { 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) }); 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`, ``, `## Direcciones atribuidas al actor`, ``,
...(attributed.length===0 ? ["No se atribuyen direcciones adicionales con fundamento suficiente."] : ...(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`, ``, `## Fondos localizados sin gastar`, ``,
...(unspentFunds.length===0 ? ["Ninguno detectado en las ramas exploradas."] : ...(unspentFunds.length===0 ? ["Ninguno detectado en las ramas exploradas."] :
@@ -5215,6 +5232,12 @@
</div> </div>
</div> </div>
{trace&&!trace.done&&trace.hop>report.summary.hops&&(
<div style={{marginBottom:14,padding:"8px 12px",background:C.amberMuted,border:`1px solid ${C.amber}30`,borderRadius:6,fontSize:"0.62rem",color:C.amber,fontFamily:"monospace"}}>
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.
</div>
)}
{/* Resumen */} {/* Resumen */}
<div style={{display:"flex",gap:16,flexWrap:"wrap",padding:"12px 14px",background:C.bgCard,borderRadius:8,marginBottom:14,border:`1px solid ${C.border}`}}> <div style={{display:"flex",gap:16,flexWrap:"wrap",padding:"12px 14px",background:C.bgCard,borderRadius:8,marginBottom:14,border:`1px solid ${C.border}`}}>
{[ {[
@@ -5278,7 +5301,7 @@
<div style={{display:"flex",flexDirection:"column",gap:2}}> <div style={{display:"flex",flexDirection:"column",gap:2}}>
{a.fundamentos.map((f,j)=>( {a.fundamentos.map((f,j)=>(
<div key={j} style={{fontSize:"0.56rem",color:C.t2,fontFamily:"monospace"}}> <div key={j} style={{fontSize:"0.56rem",color:C.t2,fontFamily:"monospace"}}>
<span style={{color:C.amber}}>[{f.certainty}]</span> {f.basis} <span style={{color:C.amber}}>[{f.certainty}]</span> {f.basis}{f.refTxid?` — tx ${f.refTxid.slice(0,16)}…`:""}
</div> </div>
))} ))}
</div> </div>