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:
Aitor
2026-07-16 14:24:29 +02:00
parent b9477c6139
commit 7f3e2b5abc
+105 -41
View File
@@ -4998,42 +4998,63 @@
const [amountInput, setAmountInput] = useState(""); const [amountInput, setAmountInput] = useState("");
const [declaracion, setDeclaracion] = useState(""); const [declaracion, setDeclaracion] = useState("");
const [maxHops, setMaxHops] = useState(8); 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 [progress, setProgress] = useState("");
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [graph, setGraph] = useState(null);
const [report, setReport] = 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(); const txid = txidInput.trim();
if (!/^[0-9a-f]{64}$/i.test(txid)) { setError("Introduce un txid de origen válido (64 caracteres hex)."); return; } 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); const vout = parseInt(voutInput, 10);
if (!Number.isInteger(vout) || vout < 0) { setError("El vout debe ser un número entero 0."); return; } 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; } 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); setError(null); setBusy(true); setReport(null);
const effectiveHops = hops || maxHops;
try { try {
const amountStolen = amountInput.trim() ? Math.round(parseFloat(amountInput)*1e8) : null; const amountStolen = amountInput.trim() ? Math.round(parseFloat(amountInput)*1e8) : null;
const g = await buildForensicGraph({ const trace = await initForensicTrace({ get, originTxid: txid, originVout: vout, amountStolen });
get, originTxid: txid, originVout: vout, amountStolen, traceRef.current = trace;
maxHops: effectiveHops, setTick(t => t+1);
onProgress: (msg) => setProgress(msg), await avanzarSalto(trace); // "iniciar" ya explora el primer salto — un solo clic para ver algo
});
setGraph(g);
setReport(buildForensicReport(g, declaracion));
} catch(e) { } catch(e) {
setError(e.message); setError(e.message);
setBusy(false);
} }
setScanning(false); setProgress("");
}; };
const seguirMasSaltos = () => { const seguir = () => {
const next = maxHops + 4; if (traceRef.current && !traceRef.current.done && !busy) avanzarSalto(traceRef.current);
setMaxHops(next);
iniciar(next);
}; };
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 exportJSON = () => {
const exportData = { const exportData = {
@@ -5095,44 +5116,91 @@
<Card> <Card>
<div style={{fontSize:"0.65rem",color:C.t2,fontFamily:"monospace",marginBottom:10,lineHeight:1.6}}> <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>
<div style={{display:"flex",flexDirection:"column",gap:8}}> <div style={{display:"flex",flexDirection:"column",gap:8}}>
<div style={{display:"flex",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)" 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" 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> </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)" 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} <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"}} 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"}}
/> />
{!trace&&(
<div style={{display:"flex",gap:8,alignItems:"center"}}> <div style={{display:"flex",gap:8,alignItems:"center"}}>
<button onClick={()=>iniciar()} disabled={scanning} <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}}> 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"} {busy?"···":"Iniciar rastreo forense"}
</button> </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> </div>
{scanning&&progress&&<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace"}}>{progress}</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>} {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> </div>
</Card> </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}> <Card glow={C.red}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:14,flexWrap:"wrap",gap:8}}> <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={{fontSize:"0.65rem",color:C.red,fontFamily:"monospace",fontWeight:700}}>INFORME DE PERITAJE</div>
@@ -5234,13 +5302,9 @@
{report.truncatedBranches.length>0&&( {report.truncatedBranches.length>0&&(
<div style={{marginBottom:14,padding:"10px 12px",background:C.amberMuted,border:`1px solid ${C.amber}30`,borderRadius:6}}> <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}}> <div style={{fontSize:"0.65rem",color:C.amber,fontFamily:"monospace"}}>
{report.truncatedBranches.length} rama(s) cortada(s) por el límite de saltos ({maxHops}) — no es un punto de parada natural, se pueden ampliar. {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> </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> </div>
)} )}