diff --git a/dashboard.html b/dashboard.html
index 54d6338..805f8b9 100644
--- a/dashboard.html
+++ b/dashboard.html
@@ -4919,6 +4919,313 @@
);
}
+ // ── PERITAJE ─────────────────────────────────────────────────────────
+ function PeritajeForense({base}) {
+ const {get} = useApi(base);
+ const [txidInput, setTxidInput] = useState("");
+ const [voutInput, setVoutInput] = useState("0");
+ const [amountInput, setAmountInput] = useState("");
+ const [declaracion, setDeclaracion] = useState("");
+ const [maxHops, setMaxHops] = useState(8);
+ const [scanning, setScanning] = useState(false);
+ const [progress, setProgress] = useState("");
+ const [error, setError] = useState(null);
+ const [graph, setGraph] = useState(null);
+ const [report, setReport] = useState(null);
+
+ const iniciar = async (hops) => {
+ const txid = txidInput.trim();
+ if (!/^[0-9a-f]{64}$/i.test(txid)) { setError("Introduce un txid de origen válido (64 caracteres hex)."); return; }
+ const vout = parseInt(voutInput, 10);
+ if (!Number.isInteger(vout) || vout < 0) { setError("El vout debe ser un número entero ≥ 0."); return; }
+ if (!base) { setError("Necesitas conectar tu nodo (CONFIG) para rastrear — el peritaje consulta tu Mempool self-hosted, no funciona en modo demo."); return; }
+ setError(null); setScanning(true); setGraph(null); setReport(null);
+ const effectiveHops = hops || maxHops;
+ try {
+ const amountStolen = amountInput.trim() ? Math.round(parseFloat(amountInput)*1e8) : null;
+ const g = await buildForensicGraph({
+ get, originTxid: txid, originVout: vout, amountStolen,
+ maxHops: effectiveHops,
+ onProgress: (msg) => setProgress(msg),
+ });
+ setGraph(g);
+ setReport(buildForensicReport(g, declaracion));
+ } catch(e) {
+ setError(e.message);
+ }
+ setScanning(false); setProgress("");
+ };
+
+ const seguirMasSaltos = () => {
+ const next = maxHops + 4;
+ setMaxHops(next);
+ iniciar(next);
+ };
+
+ const limpiar = () => { setGraph(null); setReport(null); setError(null); };
+
+ const exportJSON = () => {
+ const exportData = {
+ summary: report.summary, declaracion: report.declaracion,
+ chronology: report.chronology, attributed: report.attributed,
+ unspentFunds: report.unspentFunds, conclusions: report.conclusions,
+ recommendations: report.recommendations, methodology: report.methodology,
+ verificationAppendix: report.verificationAppendix,
+ };
+ const blob = new Blob([JSON.stringify(exportData,null,2)], {type:"application/json"});
+ const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "peritaje-forense.json"; a.click();
+ };
+
+ const exportMD = () => {
+ const { summary, declaracion:decl, chronology, attributed, unspentFunds, conclusions, recommendations, methodology, verificationAppendix } = report;
+ const lines = [
+ `# Peritaje forense — Txoko`, ``,
+ `## Resumen`, ``,
+ `- Origen: ${summary.originTxid}:${summary.originVout}`,
+ `- Dirección de origen: ${summary.originAddress}`,
+ `- Monto rastreado: ${summary.amountTraced} sats`,
+ summary.amountStolen ? `- Monto declarado como robado: ${summary.amountStolen} sats` : null,
+ `- Saltos: ${summary.hops} · Transacciones: ${summary.txCount} · Direcciones tocadas: ${summary.addressesTouched}`,
+ `- Generado: ${new Date(summary.generatedAt*1000).toLocaleString("es-ES")}`, ``,
+ decl ? `## Declaración del afectado (DECLARACION)\n\n${decl.text}\n` : null,
+ `## Cronología`, ``,
+ ...chronology.map(c => `- [HECHO] ${c.txid.slice(0,16)}… · salto ${c.hop} · ${c.amount} sats · ${c.blockTime?new Date(c.blockTime*1000).toLocaleDateString("es-ES"):"sin confirmar"} — banda [INFERENCIA${c.band.certainty?"/"+c.band.certainty:""}] ${c.band.value}, huella [INFERENCIA${c.fingerprint.certainty?"/"+c.fingerprint.certainty:""}] ${c.fingerprint.value}`),
+ ``,
+ `## 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("; ")}`)),
+ ``,
+ `## Fondos localizados sin gastar`, ``,
+ ...(unspentFunds.length===0 ? ["Ninguno detectado en las ramas exploradas."] :
+ unspentFunds.map(u => `- [HECHO] ${u.address} — ${u.amount} sats (tx ${u.txid.slice(0,16)}…, vout ${u.vout})`)),
+ ``,
+ `## Conclusiones`, ``,
+ ...conclusions.map((c,i) => `${i+1}. [${c.level}${c.certainty?"/"+c.certainty:""}] ${c.text}`),
+ ``,
+ `## Recomendaciones`, ``,
+ ...recommendations.map(r => `- ${r.text}`),
+ ``,
+ `## Metodología`, ``,
+ `**Niveles de certeza:** ${methodology.levels}`, ``,
+ `**Heurísticas usadas:** ${methodology.heuristics}`, ``,
+ `**Límites:** ${methodology.limits}`, ``,
+ `## Anexo de verificación`, ``,
+ `**Txids:**`, ...verificationAppendix.txids.map(t=>`- ${t}`), ``,
+ `**Direcciones:**`, ...verificationAppendix.addresses.map(a=>`- ${a}`),
+ ].filter(l => l !== null);
+ const blob = new Blob([lines.join("\n")], {type:"text/markdown"});
+ const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "peritaje-forense.md"; a.click();
+ };
+
+ return (
+
+ {!base&&
}
+
PERITAJE FORENSE
+
+
+
+ Rastrea fondos robados o perdidos hacia adelante, salto a salto, desde la transacción de origen hasta un punto de parada natural (custodio identificado, dilución, CoinJoin, o fondos aún sin gastar). Todo el rastreo corre sobre tu propio nodo — nada sale de tu red.
+
+
+
+ setTxidInput(e.target.value)}
+ placeholder="txid de origen (64 caracteres hex)"
+ style={{flex:3,background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"10px 12px",color:C.t1,fontFamily:"monospace",fontSize:"0.72rem",outline:"none"}}
+ />
+ setVoutInput(e.target.value)} type="number" min="0"
+ placeholder="vout" title="Índice del output robado dentro de esa transacción"
+ style={{flex:1,background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"10px 12px",color:C.t1,fontFamily:"monospace",fontSize:"0.72rem",outline:"none"}}
+ />
+
+
setAmountInput(e.target.value)} type="number" step="0.00000001" min="0"
+ placeholder="Importe estimado robado en BTC (opcional — activa la detección de dilución)"
+ style={{background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"10px 12px",color:C.t1,fontFamily:"monospace",fontSize:"0.72rem",outline:"none"}}
+ />
+
+
+
+ {report&&graph&&(
+
+
+
INFORME DE PERITAJE
+
+ ↓ JSON
+ ↓ MD
+
+
+
+ {/* Resumen */}
+
+ {[
+ {label:"Rastreado", v: fmt.sbtc(report.summary.amountTraced)},
+ {label:"Saltos", v: report.summary.hops},
+ {label:"Transacciones", v: report.summary.txCount},
+ {label:"Direcciones tocadas", v: report.summary.addressesTouched},
+ ].map(({label,v})=>(
+
+ ))}
+
+
+ {/* Declaración */}
+ {report.declaracion&&(
+
+
DECLARACIÓN DEL AFECTADO
+
{report.declaracion.text}
+
+ )}
+
+ {/* Cronología */}
+
+
CRONOLOGÍA — {report.chronology.length} TRANSACCIONES
+
+ {report.chronology.map((c,i)=>{
+ const bColor = bandColorRastro(c.band.value);
+ return (
+
+
+ salto {c.hop}
+ {c.txid.slice(0,20)}…
+ {c.band.value}
+
+
+ {fmt.num(c.amount)} sat · {c.blockTime?fmt.date(c.blockTime):"sin confirmar"} · huella: {c.fingerprint.value}{c.fingerprint.certainty?` (${c.fingerprint.certainty})`:""}
+
+ {c.entityMarks.length>0&&(
+
+ {c.entityMarks.map((m,j)=>({m.txt} ))}
+
+ )}
+
+ );
+ })}
+
+
+
+ {/* Direcciones atribuidas */}
+
+
DIRECCIONES ATRIBUIDAS AL ACTOR — {report.attributed.length}
+ {report.attributed.length===0?(
+
No se atribuyen direcciones adicionales con fundamento suficiente.
+ ):(
+
+ {report.attributed.map((a,i)=>(
+
+
{a.address}
+
+ {a.fundamentos.map((f,j)=>(
+
+ [{f.certainty}] {f.basis}
+
+ ))}
+
+
+ ))}
+
+ )}
+
+
+ {/* Fondos sin gastar */}
+
+
FONDOS LOCALIZADOS SIN GASTAR — {report.unspentFunds.length}
+ {report.unspentFunds.length===0?(
+
Ninguno detectado en las ramas exploradas.
+ ):(
+
+ {report.unspentFunds.map((u,i)=>(
+
+
{u.address}
+
{fmt.num(u.amount)} sat
+
+ ))}
+
+ )}
+
+
+ {report.truncatedBranches.length>0&&(
+
+
+ {report.truncatedBranches.length} rama(s) cortada(s) por el límite de saltos ({maxHops}) — no es un punto de parada natural, se pueden ampliar.
+
+
+ {scanning?"···":`Seguir más saltos (+4, hasta ${maxHops+4})`}
+
+
+ )}
+
+ {/* Conclusiones */}
+
+
CONCLUSIONES
+
+ {report.conclusions.map((c,i)=>(
+
+ {i+1}.
+
+
+ {c.level}{c.certainty?`/${c.certainty}`:""}
+
+ {c.text}
+
+
+ ))}
+
+
+
+ {/* Recomendaciones */}
+
+
RECOMENDACIONES
+
+ {report.recommendations.map((r,i)=>(
+
+ {r.text}
+
+ ))}
+
+
+
+ {/* Metodología */}
+
+
METODOLOGÍA
+
+
Niveles de certeza: {report.methodology.levels}
+
Heurísticas usadas: {report.methodology.heuristics}
+
Límites: {report.methodology.limits}
+
+
+
+ {/* Anexo de verificación */}
+
+
ANEXO DE VERIFICACIÓN
+
+ {report.verificationAppendix.txids.length} txid(s) · {report.verificationAppendix.addresses.length} dirección(es). Compruébalos en tu propio nodo o en cualquier explorador.
+
+
+
+ )}
+
+ );
+ }
+
function TxResult({tx, onAnalyze}) {
return (
@@ -5425,6 +5732,7 @@
{id:"mempool", label:"Mempool", icon:"≋"},
{id:"lab", label:"LAB", icon:"⌕"},
{id:"auditoria", label:"AUDITORÍA", icon:"⌖"},
+ {id:"peritaje", label:"PERITAJE", icon:"🔍"},
{id:"tools", label:"TOOLS", icon:"⚙"},
{id:"docs", label:"DOCS", icon:"📖"},
];
@@ -5667,7 +5975,7 @@
{TABS.map(t=>(
- setTab(t.id)} style={{background:"none",border:"none",borderBottom:`2px solid ${tab===t.id?(t.id==="auditoria"?C.purple:t.id==="lab"?C.purple:t.id==="tools"?C.blue:t.id==="docs"?C.amber:C.green):"transparent"}`,padding:"11px 16px",color:tab===t.id?(t.id==="auditoria"?C.purple:t.id==="lab"?C.purple:t.id==="tools"?C.blue:t.id==="docs"?C.amber:C.green):C.t2,fontFamily:"monospace",fontSize:"0.72rem",cursor:"pointer",letterSpacing:t.id==="lab"||t.id==="auditoria"||t.id==="tools"||t.id==="docs"?"0.15em":"0.08em",fontWeight:tab===t.id?700:400,textTransform:"uppercase",whiteSpace:"nowrap"}}>{t.label}
+ setTab(t.id)} style={{background:"none",border:"none",borderBottom:`2px solid ${tab===t.id?(t.id==="auditoria"?C.purple:t.id==="lab"?C.purple:t.id==="peritaje"?C.red:t.id==="tools"?C.blue:t.id==="docs"?C.amber:C.green):"transparent"}`,padding:"11px 16px",color:tab===t.id?(t.id==="auditoria"?C.purple:t.id==="lab"?C.purple:t.id==="peritaje"?C.red:t.id==="tools"?C.blue:t.id==="docs"?C.amber:C.green):C.t2,fontFamily:"monospace",fontSize:"0.72rem",cursor:"pointer",letterSpacing:t.id==="lab"||t.id==="auditoria"||t.id==="peritaje"||t.id==="tools"||t.id==="docs"?"0.15em":"0.08em",fontWeight:tab===t.id?700:400,textTransform:"uppercase",whiteSpace:"nowrap"}}>{t.label}
))}
@@ -5676,6 +5984,7 @@
{tab==="mempool" &&
}
+ {tab==="peritaje" && }
{tab==="tools" && }
{tab==="docs" && }