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)}…`:""}
))}