diff --git a/dashboard.html b/dashboard.html
index 7b90cd8..54d6338 100644
--- a/dashboard.html
+++ b/dashboard.html
@@ -3990,6 +3990,7 @@
analysis, fingerprint, fingerprintComparison, entityMarks,
addressProfiles,
changeGuess: { structural, behavioral, combined },
+ changeAddress: structural.index != null ? (spendTx.vout[structural.index]?.scriptpubkey_address ?? null) : null,
diluted, tracedShare, custodyStop,
stopReason,
});
@@ -4020,13 +4021,156 @@
const { clusters, linkReasons } = unionFindCluster([...txCache.values()], actorAddrSet);
return {
- origin: { txid: originTxid, vout: originVout, address: originOut.scriptpubkey_address, amount: originOut.value, fingerprint: originFingerprint },
+ 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),
+ },
amountStolen: amountStolen || null,
nodes, edges, unspentTerminals, clusters, linkReasons,
peelingChains: detectPeelingChains(nodes, edges),
};
}
+ // 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
+ // del check/heurística que la produjo, DECLARACION es lo que dice el
+ // afectado sin corroborar — nunca se mezcla con las otras dos.
+ function buildForensicReport(graph, declaracionText) {
+ const { origin, nodes, unspentTerminals, clusters, peelingChains, amountStolen } = graph;
+ const allNodes = [...nodes.values()].sort((a,b)=> a.hop - b.hop || (a.blockTime||0) - (b.blockTime||0));
+ const maxHop = allNodes.reduce((m,n)=>Math.max(m,n.hop), 0);
+ const addressesTouched = new Set([origin.address].filter(Boolean));
+ for (const n of allNodes) { n.addresses.in.forEach(a=>addressesTouched.add(a)); n.addresses.out.forEach(a=>addressesTouched.add(a)); }
+
+ // ── 1. Resumen ──────────────────────────────────────────────────────
+ const summary = {
+ originTxid: origin.txid, originVout: origin.vout, originAddress: origin.address,
+ amountTraced: origin.amount, amountStolen,
+ generatedAt: Math.floor(Date.now()/1000),
+ hops: maxHop, txCount: allNodes.length + 1, addressesTouched: addressesTouched.size,
+ };
+
+ // ── 2. Declaración del afectado ──────────────────────────────────────
+ const declaracion = declaracionText && declaracionText.trim()
+ ? { level:"DECLARACION", text: declaracionText.trim() }
+ : null;
+
+ // ── 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 || "—" },
+ 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),
+ ...allNodes.map(n => chronologyRow(n.txid, n.hop, n.blockTime, n.blockHeight, n.amountTraced, n.analysis, n.fingerprint, n.entityMarks)),
+ ];
+
+ // ── 4. Direcciones atribuidas al actor ──────────────────────────────
+ const clusterOf = new Map();
+ clusters.forEach((c,i) => c.forEach(a => clusterOf.set(a, i)));
+ // Dirección de cambio por salto (ya resuelta a texto en buildForensicGraph,
+ // no se recalcula el índice aquí — evita desalinearse si addresses.out
+ // llegara a deduplicar en un orden distinto al de vout).
+ const changeAddrByHop = new Map(); // addr -> {txid, certainty, agreement}
+ for (const n of allNodes) {
+ if (!n.changeAddress) continue;
+ changeAddrByHop.set(n.changeAddress, {
+ txid: n.txid,
+ certainty: n.changeGuess?.combined?.certainty || n.changeGuess?.structural?.certainty || "POSIBLE",
+ agreement: n.changeGuess?.combined?.agreement || null,
+ });
+ }
+ const fingerprintStableAddrs = new Set();
+ for (const n of allNodes) {
+ if (n.fingerprintComparison?.changed === false) n.addresses.out.forEach(a=>fingerprintStableAddrs.add(a));
+ }
+
+ const attributed = [];
+ for (const a of addressesTouched) {
+ const fundamentos = [];
+ if (clusterOf.has(a)) fundamentos.push({ basis:"CIOH — comparte inputs con otra dirección del rastro", certainty:"PROBABLE" });
+ if (changeAddrByHop.has(a)) {
+ const c = changeAddrByHop.get(a);
+ fundamentos.push({
+ basis: c.agreement === "coincide" ? "cambio detectado (señal estructural + conductual coinciden)" : "cambio detectado (señal estructural)",
+ certainty: c.certainty, refTxid: c.txid,
+ });
+ }
+ if (fingerprintStableAddrs.has(a)) fundamentos.push({ basis:"huella de wallet estable con el salto anterior", certainty:"POSIBLE" });
+ if (fundamentos.length === 0) continue;
+ attributed.push({ level:"INFERENCIA", address:a, fundamentos, refs:[a] });
+ }
+
+ // ── 5. Fondos localizados sin gastar ────────────────────────────────
+ const unspentFunds = unspentTerminals
+ .filter(u => u.address && !u.truncated && u.note === "UTXO sin gastar.")
+ .map(u => ({ level:"HECHO", txid:u.txid, vout:u.vout, address:u.address, amount:u.amount, refs:[u.txid, u.address] }));
+ const truncatedBranches = unspentTerminals.filter(u => u.truncated);
+
+ // ── 6. Conclusiones numeradas ────────────────────────────────────────
+ const conclusions = [];
+ conclusions.push({ level:"HECHO", text:`${origin.amount} sats rastreados desde ${origin.txid}:${origin.vout}, a través de ${allNodes.length} transacción(es) en ${maxHop} salto(s).`, refs:[origin.txid] });
+ if (clusters.length > 0) {
+ conclusions.push({ level:"INFERENCIA", certainty:"PROBABLE", text:`Se detectaron ${clusters.length} cluster(es) de direcciones vinculadas por CIOH — con alta probabilidad pertenecen al mismo actor.`, refs: clusters.flat() });
+ }
+ if (peelingChains.length > 0) {
+ for (const pc of peelingChains) {
+ conclusions.push({ level:"INFERENCIA", certainty:pc.certainty, text:`Cadena de peeling confirmada de ${pc.length} salto(s) consecutivos${pc.fingerprintStable?", con huella de wallet estable":""}.`, refs: pc.txids });
+ }
+ }
+ for (const n of allNodes) {
+ if (n.stopReason === "exchange" && n.custodyStop) {
+ const label = n.custodyStop.known ? `${n.custodyStop.name} (fuente: índice de entidades)` : "hot wallet no identificada — posible custodio";
+ conclusions.push({ level:"INFERENCIA", certainty: n.custodyStop.known ? "PROBABLE" : "POSIBLE",
+ text:`El rastro se detiene en ${label} en la transacción ${n.txid}.`, refs:[n.txid, n.custodyStop.addr] });
+ }
+ if (n.stopReason === "mixer") {
+ conclusions.push({ level:"INFERENCIA", certainty:"CERTEZA", text:`El rastro se rompe en un CoinJoin real en ${n.txid} — no se puede atribuir con honestidad más allá de este punto.`, refs:[n.txid] });
+ }
+ 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 (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) });
+ }
+ if (truncatedBranches.length > 0) {
+ conclusions.push({ level:"HECHO", text:`${truncatedBranches.length} rama(s) del rastro se cortaron por el límite de saltos configurado, no por un punto de parada natural — pueden ampliarse.`, refs: truncatedBranches.map(u=>u.txid) });
+ }
+
+ // ── 7. Recomendaciones ───────────────────────────────────────────────
+ const recommendations = [
+ { text:"Si las claves del wallet de origen pudieron verse comprometidas, genera una semilla nueva y traslada cualquier fondo restante — no reutilices el material comprometido." },
+ { text:"Revisa el dispositivo donde vivía el wallet (malware, acceso físico no autorizado, backups expuestos) antes de asumir que el vector de entrada está cerrado." },
+ { text:"Para una denuncia: aporta este informe completo junto con los txids listados en el anexo de verificación. Cualquiera puede comprobarlos de forma independiente." },
+ ];
+ if (declaracion) {
+ recommendations.push({
+ fixed: true,
+ text:"⚠ Aviso importante: Txoko no ofrece ni recomienda servicios de \"recuperación de fondos\". Cualquiera que se ofrezca a cobrar por adelantado para \"recuperar\" bitcoin robado es, con altísima probabilidad, una segunda estafa sobre la misma víctima. No compartas claves, semillas ni accesos con nadie que te contacte ofreciendo recuperar tus fondos.",
+ });
+ }
+
+ // ── 8. Metodología ────────────────────────────────────────────────────
+ const methodology = {
+ levels: "Cada afirmación del informe lleva una etiqueta: HECHO (dato on-chain verificable directamente, sin interpretación), INFERENCIA (conclusión de una heurística, con su propia sub-etiqueta de certeza CERTEZA/PROBABLE/POSIBLE) o DECLARACION (lo que dice el afectado, sin corroborar).",
+ heuristics: "Se reutilizan las heurísticas del analizador de transacciones y del informe de wallet de Txoko (CIOH, detección de cambio estructural, huella de software, CoinJoin, entidades conocidas), y se añaden: perfil de dirección (personal vs hot wallet), cambio conductual (se gasta rápido vs queda quieto), comparación de huella entre saltos, y confirmación de cadenas de peeling multi-salto.",
+ limits: "El rastreo se detiene ante un CoinJoin real, dilución excesiva, un custodio identificado o presunto, un UTXO sin gastar, o el límite de saltos configurado. Ninguna heurística identifica la identidad real de una persona: como mucho llega a \"hot wallet de [servicio]\" o \"custodio no identificado\".",
+ };
+
+ // ── 9. Anexo de verificación ────────────────────────────────────────
+ const verificationAppendix = {
+ txids: [origin.txid, ...allNodes.map(n=>n.txid)],
+ addresses: [...addressesTouched],
+ };
+
+ return { summary, declaracion, chronology, attributed, unspentFunds, truncatedBranches, conclusions, recommendations, methodology, verificationAppendix };
+ }
+
// Eslabón recursivo: muestra una tx de origen y permite seguir SUS inputs.
// depth limita la profundidad visual; cache y getTx se comparten desde arriba.
function Eslabon({txData, fluyo, vout, depth, getTx, base}) {