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:
+43
-20
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user