feat: peritaje forense — generador de informe (buildForensicReport)
Ensambla las secciones de la plantilla (spec en TRASPASO.md) sobre el grafo de buildForensicGraph: resumen, declaración del afectado, cronología, direcciones atribuidas (CIOH + cambio detectado + huella estable), fondos sin gastar, conclusiones numeradas, recomendaciones (con el aviso anti-estafa de "recuperación" fijo cuando hay declaración), metodología y anexo de verificación. Marco HECHO/INFERENCIA/DECLARACION aplicado en cada afirmación; el export a JSON/MD queda para la UI (mismo patrón inline que el informe de wallet, sin función compartida hoy). También: buildForensicGraph ahora guarda origin.analysis y node.changeAddress (dirección resuelta, no el índice) para que el informe no tenga que reindexar en addresses.out, que al deduplicar podría desalinearse con el orden real de vout.
This commit is contained in:
+145
-1
@@ -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}) {
|
||||
|
||||
Reference in New Issue
Block a user