feat: rastreo forense a demanda — salto a salto, botón "seguir el rastro"
PeritajeForense pasa de "un clic, rastreo automático completo" a "un clic por salto": el usuario ve el progreso (salto actual, tx exploradas, ramas pendientes, ramas terminales) y decide cuándo avanzar, igual que ya funciona el rastro de procedencia hacia atrás (RastroProcedencia). El rastreo se guarda en un ref mutable (traceRef) entre clics — initForensicTrace + advanceForensicHop del commit anterior encajan directamente, sin cambios. "Iniciar rastreo forense" ahora también ejecuta el primer salto (un clic para ver algo). "Generar informe" está disponible desde el primer salto, no solo al terminar, y puede volver a pulsarse en cualquier momento — incluida la declaración del afectado, editable mientras el rastreo sigue en marcha. Los campos txid/vout/importe se deshabilitan una vez iniciado (no tienen efecto a mitad de rastreo); el tope de saltos y la declaración siguen editables. Quita el mecanismo antiguo "seguir más saltos (+4)": ya no tiene sentido con control manual salto a salto, y el texto de ramas truncadas por el tope se reescribe para no prometer una reanudación que el motor no soporta (una rama ya truncada no se puede retomar suelta). Verificado en navegador con un servidor HTTP local que sirve la cadena de peeling de 3 saltos ya usada en pruebas anteriores, pulsando "seguir el rastro" cuatro veces manualmente: el progreso avanza correctamente salto a salto (1→2→3→4), termina con "rastro completo", y el informe generado es idéntico en contenido al que produce el modo automático (misma cadena de peeling CERTEZA, mismas direcciones atribuidas, mismos fondos sin gastar). Export JSON/MD y "Limpiar" verificados sin errores de consola.
This commit is contained in:
+108
-44
@@ -4998,42 +4998,63 @@
|
||||
const [amountInput, setAmountInput] = useState("");
|
||||
const [declaracion, setDeclaracion] = useState("");
|
||||
const [maxHops, setMaxHops] = useState(8);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [busy, setBusy] = useState(false); // hay una petición al nodo en curso (iniciar o avanzar un salto)
|
||||
const [progress, setProgress] = useState("");
|
||||
const [error, setError] = useState(null);
|
||||
const [graph, setGraph] = useState(null);
|
||||
const [report, setReport] = useState(null);
|
||||
const [tick, setTick] = useState(0); // fuerza re-render al mutar traceRef.current
|
||||
// El rastreo vive en un ref (no en estado) porque lo muta directamente
|
||||
// advanceForensicHop entre clics del usuario — "tick" es lo que le dice
|
||||
// a React que vuelva a leerlo. Así el estado (nodos, aristas, frontier)
|
||||
// sobrevive entre saltos sin tener que reconstruirlo cada vez.
|
||||
const traceRef = useRef(null);
|
||||
|
||||
const iniciar = async (hops) => {
|
||||
const avanzarSalto = async (trace) => {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await advanceForensicHop(trace, { maxHops, onProgress: setProgress });
|
||||
setTick(t => t+1);
|
||||
} catch(e) {
|
||||
setError(e.message);
|
||||
}
|
||||
setBusy(false); setProgress("");
|
||||
};
|
||||
|
||||
const iniciar = async () => {
|
||||
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;
|
||||
setError(null); setBusy(true); setReport(null);
|
||||
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));
|
||||
const trace = await initForensicTrace({ get, originTxid: txid, originVout: vout, amountStolen });
|
||||
traceRef.current = trace;
|
||||
setTick(t => t+1);
|
||||
await avanzarSalto(trace); // "iniciar" ya explora el primer salto — un solo clic para ver algo
|
||||
} catch(e) {
|
||||
setError(e.message);
|
||||
setBusy(false);
|
||||
}
|
||||
setScanning(false); setProgress("");
|
||||
};
|
||||
|
||||
const seguirMasSaltos = () => {
|
||||
const next = maxHops + 4;
|
||||
setMaxHops(next);
|
||||
iniciar(next);
|
||||
const seguir = () => {
|
||||
if (traceRef.current && !traceRef.current.done && !busy) avanzarSalto(traceRef.current);
|
||||
};
|
||||
|
||||
const limpiar = () => { setGraph(null); setReport(null); setError(null); };
|
||||
const limpiar = () => {
|
||||
traceRef.current = null; setTick(t=>t+1);
|
||||
setReport(null); setError(null); setBusy(false); setProgress("");
|
||||
};
|
||||
|
||||
const generarInforme = () => {
|
||||
if (!traceRef.current) return;
|
||||
const g = finalizeForensicGraph(traceRef.current);
|
||||
setReport(buildForensicReport(g, declaracion));
|
||||
};
|
||||
|
||||
const trace = traceRef.current; // solo lectura, para el render — tick fuerza refrescarlo
|
||||
|
||||
const exportJSON = () => {
|
||||
const exportData = {
|
||||
@@ -5095,44 +5116,91 @@
|
||||
|
||||
<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.
|
||||
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). <strong style={{color:C.t1}}>A demanda:</strong> tú decides cuándo se explora el siguiente salto — nada corre sin que lo pidas, igual que el rastro de procedencia hacia atrás. Todo 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)}
|
||||
<input value={txidInput} onChange={e=>setTxidInput(e.target.value)} disabled={!!trace}
|
||||
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"}}
|
||||
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",opacity:trace?0.5:1}}
|
||||
/>
|
||||
<input value={voutInput} onChange={e=>setVoutInput(e.target.value)} type="number" min="0"
|
||||
<input value={voutInput} onChange={e=>setVoutInput(e.target.value)} type="number" min="0" disabled={!!trace}
|
||||
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"}}
|
||||
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",opacity:trace?0.5:1}}
|
||||
/>
|
||||
<select value={maxHops} onChange={e=>setMaxHops(Number(e.target.value))}
|
||||
title="Tope de saltos — red de seguridad aparte del control manual, no debería hacer falta tocarlo"
|
||||
style={{background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"10px 8px",color:C.t1,fontFamily:"monospace",fontSize:"0.7rem",outline:"none",cursor:"pointer"}}>
|
||||
<option value={8}>tope 8</option>
|
||||
<option value={15}>tope 15</option>
|
||||
<option value={25}>tope 25</option>
|
||||
</select>
|
||||
</div>
|
||||
<input value={amountInput} onChange={e=>setAmountInput(e.target.value)} type="number" step="0.00000001" min="0"
|
||||
<input value={amountInput} onChange={e=>setAmountInput(e.target.value)} type="number" step="0.00000001" min="0" disabled={!!trace}
|
||||
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"}}
|
||||
style={{background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"10px 12px",color:C.t1,fontFamily:"monospace",fontSize:"0.72rem",outline:"none",opacity:trace?0.5:1}}
|
||||
/>
|
||||
<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."
|
||||
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. Puedes escribirla o editarla en cualquier momento, incluso con el rastreo en marcha."
|
||||
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
|
||||
{!trace&&(
|
||||
<div style={{display:"flex",gap:8,alignItems:"center"}}>
|
||||
<button onClick={iniciar} disabled={busy}
|
||||
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}}>
|
||||
{busy?"···":"Iniciar rastreo forense"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{scanning&&progress&&<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace"}}>{progress}</div>}
|
||||
</div>
|
||||
)}
|
||||
{busy&&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&&(
|
||||
{trace&&(
|
||||
<Card glow={C.red}>
|
||||
<div style={{fontSize:"0.65rem",color:C.red,fontFamily:"monospace",fontWeight:700,marginBottom:10}}>PROGRESO DEL RASTREO</div>
|
||||
<div style={{display:"flex",gap:16,flexWrap:"wrap",marginBottom:12}}>
|
||||
{[
|
||||
{label:"Salto actual", v: trace.hop},
|
||||
{label:"Transacciones exploradas", v: trace.nodes.size},
|
||||
{label:"Ramas pendientes", v: trace.frontier.length},
|
||||
{label:"Ramas terminales", v: trace.unspentTerminals.length},
|
||||
].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>
|
||||
{trace.done?(
|
||||
<div style={{fontSize:"0.65rem",color:C.green,fontFamily:"monospace",marginBottom:10}}>✓ Rastro completo — no quedan ramas por explorar.</div>
|
||||
):(
|
||||
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",marginBottom:10}}>
|
||||
{trace.frontier.length} rama(s) esperando tu confirmación para seguir al salto {trace.hop+1}.
|
||||
</div>
|
||||
)}
|
||||
<div style={{display:"flex",gap:8,flexWrap:"wrap"}}>
|
||||
{!trace.done&&(
|
||||
<button onClick={seguir} disabled={busy}
|
||||
style={{padding:"8px 16px",background:C.redMuted,border:`1px solid ${C.red}50`,borderRadius:6,color:C.red,fontFamily:"monospace",fontSize:"0.68rem",cursor:"pointer",fontWeight:700}}>
|
||||
{busy?"···":`Seguir el rastro (salto ${trace.hop} → ${trace.hop+1})`}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={generarInforme} disabled={busy||trace.nodes.size===0}
|
||||
style={{padding:"8px 16px",background:C.purpleMuted,border:`1px solid ${C.purple}50`,borderRadius:6,color:C.purple,fontFamily:"monospace",fontSize:"0.68rem",cursor:"pointer",fontWeight:700}}>
|
||||
{trace.done?"Generar informe":"Generar informe con lo explorado hasta ahora"}
|
||||
</button>
|
||||
<button onClick={limpiar} disabled={busy}
|
||||
style={{padding:"8px 14px",background:"none",border:`1px solid ${C.border}`,borderRadius:6,color:C.t2,fontFamily:"monospace",fontSize:"0.65rem",cursor:"pointer"}}>
|
||||
Limpiar
|
||||
</button>
|
||||
</div>
|
||||
{busy&&progress&&<div style={{marginTop:8,fontSize:"0.62rem",color:C.t2,fontFamily:"monospace"}}>{progress}</div>}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{report&&(
|
||||
<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>
|
||||
@@ -5234,13 +5302,9 @@
|
||||
|
||||
{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 style={{fontSize:"0.65rem",color:C.amber,fontFamily:"monospace"}}>
|
||||
{report.truncatedBranches.length} rama(s) cortada(s) por el tope de saltos ({maxHops}) — no es un punto de parada natural. Ese tope solo es una red de seguridad aparte del control manual; para explorarlas, sube el tope arriba y pulsa "Limpiar" para repetir el rastreo desde el principio con el tope nuevo (una rama ya cortada no se puede reanudar por separado).
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user