feat: peritaje forense — pestaña UI y componente PeritajeForense

Nueva pestaña "PERITAJE" junto a las existentes. Formulario de entrada
(txid:vout, importe estimado robado opcional, declaración en texto
libre), botón "Iniciar rastreo forense" con progreso por salto, y
render completo del informe (resumen, declaración, cronología,
direcciones atribuidas, fondos sin gastar, aviso de ramas truncadas
con botón "seguir más saltos", conclusiones, recomendaciones,
metodología, anexo de verificación) con export JSON/MD inline, mismo
patrón que el informe de wallet.

Verificado en navegador (servidor estático local):
- Balance de sintaxis del bloque Babel correcto, sin errores de
  transpilación JSX en consola.
- Validación del formulario (txid vacío/inválido, nodo no conectado)
  funciona.
- Motor completo probado con datos sintéticos vía consola: caso simple
  (1 salto, cambio detectado correctamente, 2 ramas terminan en UTXO
  sin gastar) y caso de cadena de peeling de 3 saltos (detectada con
  certeza CERTEZA, direcciones de cambio atribuidas con fundamento
  correcto, 4 fondos sin gastar localizados en las hojas).

Cierra la implementación del módulo Peritaje descrito en TRASPASO.md.
This commit is contained in:
Aitor
2026-07-16 12:43:32 +02:00
parent 6f3ca4ed7a
commit fcc19c31e1
+310 -1
View File
@@ -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 (
<div style={{display:"flex",flexDirection:"column",gap:12}}>
{!base&&<DemoBanner/>}
<SectionTitle accent={C.red} icon="🔍">PERITAJE FORENSE</SectionTitle>
<Card>
<div style={{fontSize:"0.65rem",color:C.t2,fontFamily:"monospace",marginBottom:10,lineHeight:1.6}}>
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.
</div>
<div style={{display:"flex",flexDirection:"column",gap:8}}>
<div style={{display:"flex",gap:8}}>
<input value={txidInput} onChange={e=>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"}}
/>
<input value={voutInput} onChange={e=>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"}}
/>
</div>
<input value={amountInput} onChange={e=>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"}}
/>
<textarea value={declaracion} onChange={e=>setDeclaracion(e.target.value)} rows={3}
placeholder="Declaración del afectado (opcional): qué pasó, cuándo lo notaste, cómo crees que ocurrió. Se muestra aparte en el informe, etiquetada como DECLARACIÓN — nunca se mezcla con los hechos verificados."
style={{background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"10px 12px",color:C.t1,fontFamily:"monospace",fontSize:"0.68rem",outline:"none",resize:"vertical"}}
/>
<div style={{display:"flex",gap:8,alignItems:"center"}}>
<button onClick={()=>iniciar()} disabled={scanning}
style={{padding:"9px 18px",background:C.redMuted,border:`1px solid ${C.red}50`,borderRadius:6,color:C.red,fontFamily:"monospace",fontSize:"0.72rem",cursor:"pointer",fontWeight:700}}>
{scanning?"···":"Iniciar rastreo forense"}
</button>
{(graph||report)&&!scanning&&(
<button onClick={limpiar} style={{padding:"9px 14px",background:"none",border:`1px solid ${C.border}`,borderRadius:6,color:C.t2,fontFamily:"monospace",fontSize:"0.68rem",cursor:"pointer"}}>
Limpiar
</button>
)}
</div>
{scanning&&progress&&<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace"}}>{progress}</div>}
{error&&<div style={{padding:"8px 12px",background:C.redMuted,border:`1px solid ${C.red}30`,borderRadius:6,color:C.red,fontFamily:"monospace",fontSize:"0.7rem"}}>{error}</div>}
</div>
</Card>
{report&&graph&&(
<Card glow={C.red}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:14,flexWrap:"wrap",gap:8}}>
<div style={{fontSize:"0.65rem",color:C.red,fontFamily:"monospace",fontWeight:700}}>INFORME DE PERITAJE</div>
<div style={{display:"flex",gap:6}}>
<button onClick={exportJSON} style={{padding:"3px 10px",background:"none",border:`1px solid ${C.border}`,borderRadius:4,color:C.t2,fontFamily:"monospace",fontSize:"0.6rem",cursor:"pointer"}}>↓ JSON</button>
<button onClick={exportMD} style={{padding:"3px 10px",background:"none",border:`1px solid ${C.border}`,borderRadius:4,color:C.t2,fontFamily:"monospace",fontSize:"0.6rem",cursor:"pointer"}}>↓ MD</button>
</div>
</div>
{/* Resumen */}
<div style={{display:"flex",gap:16,flexWrap:"wrap",padding:"12px 14px",background:C.bgCard,borderRadius:8,marginBottom:14,border:`1px solid ${C.border}`}}>
{[
{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})=>(
<div key={label}>
<div style={{fontSize:"0.55rem",color:C.t2,fontFamily:"monospace"}}>{label}</div>
<div style={{fontSize:"0.8rem",fontFamily:"monospace",fontWeight:700,color:C.t1}}>{v}</div>
</div>
))}
</div>
{/* Declaración */}
{report.declaracion&&(
<div style={{marginBottom:14,padding:"10px 12px",background:C.blueMuted,border:`1px solid ${C.blue}40`,borderRadius:6}}>
<div style={{fontSize:"0.58rem",color:C.blue,fontFamily:"monospace",fontWeight:700,marginBottom:6,letterSpacing:"0.1em"}}>DECLARACIÓN DEL AFECTADO</div>
<div style={{fontSize:"0.68rem",color:C.t1,lineHeight:1.6,whiteSpace:"pre-wrap"}}>{report.declaracion.text}</div>
</div>
)}
{/* Cronología */}
<div style={{marginBottom:14}}>
<div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",fontWeight:700,marginBottom:8,letterSpacing:"0.15em"}}>CRONOLOGÍA — {report.chronology.length} TRANSACCIONES</div>
<div style={{display:"flex",flexDirection:"column",gap:4,maxHeight:320,overflowY:"auto"}}>
{report.chronology.map((c,i)=>{
const bColor = bandColorRastro(c.band.value);
return (
<div key={i} style={{padding:"7px 10px",background:C.bgCard,borderRadius:5,border:`1px solid ${C.border}`}}>
<div style={{display:"flex",alignItems:"center",gap:8,flexWrap:"wrap"}}>
<span style={{fontSize:"0.56rem",color:C.t2,fontFamily:"monospace",minWidth:50}}>salto {c.hop}</span>
<span style={{fontSize:"0.6rem",color:C.t1,fontFamily:"monospace",flex:1,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{c.txid.slice(0,20)}…</span>
<span style={{fontSize:"0.58rem",color:bColor,fontFamily:"monospace",fontWeight:600}}>{c.band.value}</span>
</div>
<div style={{fontSize:"0.56rem",color:C.t2,fontFamily:"monospace",marginTop:3}}>
{fmt.num(c.amount)} sat · {c.blockTime?fmt.date(c.blockTime):"sin confirmar"} · huella: {c.fingerprint.value}{c.fingerprint.certainty?` (${c.fingerprint.certainty})`:""}
</div>
{c.entityMarks.length>0&&(
<div style={{display:"flex",gap:6,flexWrap:"wrap",marginTop:4}}>
{c.entityMarks.map((m,j)=>(<span key={j} style={{fontSize:"0.55rem",fontFamily:"monospace",fontWeight:700,color:m.color,background:`${m.color}1a`,border:`1px solid ${m.color}55`,borderRadius:4,padding:"1px 6px"}}>{m.txt}</span>))}
</div>
)}
</div>
);
})}
</div>
</div>
{/* Direcciones atribuidas */}
<div style={{marginBottom:14}}>
<div style={{fontSize:"0.6rem",color:C.amber,fontFamily:"monospace",fontWeight:700,marginBottom:8,letterSpacing:"0.15em"}}>DIRECCIONES ATRIBUIDAS AL ACTOR — {report.attributed.length}</div>
{report.attributed.length===0?(
<div style={{fontSize:"0.65rem",color:C.t2,fontFamily:"monospace"}}>No se atribuyen direcciones adicionales con fundamento suficiente.</div>
):(
<div style={{display:"flex",flexDirection:"column",gap:6}}>
{report.attributed.map((a,i)=>(
<div key={i} style={{padding:"8px 10px",background:C.bgCard,border:`1px solid ${C.amber}30`,borderRadius:6}}>
<div style={{fontSize:"0.6rem",color:C.t1,fontFamily:"monospace",marginBottom:4,wordBreak:"break-all"}}>{a.address}</div>
<div style={{display:"flex",flexDirection:"column",gap:2}}>
{a.fundamentos.map((f,j)=>(
<div key={j} style={{fontSize:"0.56rem",color:C.t2,fontFamily:"monospace"}}>
<span style={{color:C.amber}}>[{f.certainty}]</span> {f.basis}
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
{/* Fondos sin gastar */}
<div style={{marginBottom:14}}>
<div style={{fontSize:"0.6rem",color:C.green,fontFamily:"monospace",fontWeight:700,marginBottom:8,letterSpacing:"0.15em"}}>FONDOS LOCALIZADOS SIN GASTAR — {report.unspentFunds.length}</div>
{report.unspentFunds.length===0?(
<div style={{fontSize:"0.65rem",color:C.t2,fontFamily:"monospace"}}>Ninguno detectado en las ramas exploradas.</div>
):(
<div style={{display:"flex",flexDirection:"column",gap:4}}>
{report.unspentFunds.map((u,i)=>(
<div key={i} style={{padding:"7px 10px",background:C.greenMuted,border:`1px solid ${C.green}30`,borderRadius:6}}>
<div style={{fontSize:"0.6rem",color:C.t1,fontFamily:"monospace",wordBreak:"break-all"}}>{u.address}</div>
<div style={{fontSize:"0.58rem",color:C.green,fontFamily:"monospace",fontWeight:700}}>{fmt.num(u.amount)} sat</div>
</div>
))}
</div>
)}
</div>
{report.truncatedBranches.length>0&&(
<div style={{marginBottom:14,padding:"10px 12px",background:C.amberMuted,border:`1px solid ${C.amber}30`,borderRadius:6}}>
<div style={{fontSize:"0.65rem",color:C.amber,fontFamily:"monospace",marginBottom:6}}>
{report.truncatedBranches.length} rama(s) cortada(s) por el límite de saltos ({maxHops}) — no es un punto de parada natural, se pueden ampliar.
</div>
<button onClick={seguirMasSaltos} disabled={scanning}
style={{padding:"6px 14px",background:C.amberMuted,border:`1px solid ${C.amber}60`,borderRadius:6,color:C.amber,fontFamily:"monospace",fontSize:"0.65rem",cursor:"pointer",fontWeight:700}}>
{scanning?"···":`Seguir más saltos (+4, hasta ${maxHops+4})`}
</button>
</div>
)}
{/* Conclusiones */}
<div style={{marginBottom:14}}>
<div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",fontWeight:700,marginBottom:8,letterSpacing:"0.15em"}}>CONCLUSIONES</div>
<div style={{display:"flex",flexDirection:"column",gap:6}}>
{report.conclusions.map((c,i)=>(
<div key={i} style={{display:"flex",gap:8,fontSize:"0.66rem",color:C.t1,lineHeight:1.6}}>
<span style={{fontFamily:"monospace",color:C.t2,flexShrink:0}}>{i+1}.</span>
<span>
<span style={{fontFamily:"monospace",fontSize:"0.56rem",fontWeight:700,color:c.level==="HECHO"?C.green:c.level==="DECLARACION"?C.blue:C.amber,border:`1px solid ${c.level==="HECHO"?C.green:c.level==="DECLARACION"?C.blue:C.amber}40`,borderRadius:3,padding:"1px 5px",marginRight:6}}>
{c.level}{c.certainty?`/${c.certainty}`:""}
</span>
{c.text}
</span>
</div>
))}
</div>
</div>
{/* Recomendaciones */}
<div style={{marginBottom:14}}>
<div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",fontWeight:700,marginBottom:8,letterSpacing:"0.15em"}}>RECOMENDACIONES</div>
<div style={{display:"flex",flexDirection:"column",gap:8}}>
{report.recommendations.map((r,i)=>(
<div key={i} style={{padding:"8px 10px",background:r.fixed?C.redMuted:C.bgCard,border:`1px solid ${r.fixed?C.red:C.border}`,borderRadius:6,fontSize:"0.65rem",color:r.fixed?C.red:C.t2,lineHeight:1.6}}>
{r.text}
</div>
))}
</div>
</div>
{/* Metodología */}
<div style={{marginBottom:14}}>
<div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",fontWeight:700,marginBottom:8,letterSpacing:"0.15em"}}>METODOLOGÍA</div>
<div style={{fontSize:"0.64rem",color:C.t2,lineHeight:1.7,display:"flex",flexDirection:"column",gap:8}}>
<div><strong style={{color:C.t1}}>Niveles de certeza:</strong> {report.methodology.levels}</div>
<div><strong style={{color:C.t1}}>Heurísticas usadas:</strong> {report.methodology.heuristics}</div>
<div><strong style={{color:C.t1}}>Límites:</strong> {report.methodology.limits}</div>
</div>
</div>
{/* Anexo de verificación */}
<div>
<div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",fontWeight:700,marginBottom:8,letterSpacing:"0.15em"}}>ANEXO DE VERIFICACIÓN</div>
<div style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace",lineHeight:1.6}}>
{report.verificationAppendix.txids.length} txid(s) · {report.verificationAppendix.addresses.length} dirección(es). Compruébalos en tu propio nodo o en cualquier explorador.
</div>
</div>
</Card>
)}
</div>
);
}
function TxResult({tx, onAnalyze}) {
return (
<div style={{display:"flex",flexDirection:"column",gap:10}}>
@@ -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 @@
</div>
<div style={{display:"flex",borderBottom:`1px solid ${C.border}`,padding:"0 10px",overflowX:"auto"}}>
{TABS.map(t=>(
<button key={t.id} onClick={()=>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}</button>
<button key={t.id} onClick={()=>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}</button>
))}
</div>
<div style={{padding:"18px 14px",maxWidth:860,margin:"0 auto",width:"100%",flex:1}}>
@@ -5676,6 +5984,7 @@
{tab==="mempool" && <MempoolView base={config.url}/>}
<div style={{display:tab==="lab"?"block":"none"}}><Lab base={config.url}/></div>
<div style={{display:tab==="auditoria"?"block":"none"}}><Auditoria base={config.url} initialQuery={auditQuery}/></div>
{tab==="peritaje" && <PeritajeForense base={config.url}/>}
{tab==="tools" && <Tools base={config.url}/>}
{tab==="docs" && <Docs/>}
</div>