feat: rastro de procedencia paso 2 - recursivo, encadenar saltos hacia atras

This commit is contained in:
Aitor
2026-06-02 19:01:32 +02:00
parent b101ca4210
commit a7c65ed7ec
2 changed files with 147 additions and 117 deletions
+132 -117
View File
@@ -23,7 +23,7 @@
<body>
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useCallback } = React;
const { useState, useEffect, useCallback, useMemo, useRef } = React;
// Detecta pantalla estrecha (móvil) y se reajusta al rotar/redimensionar.
// Reutilizable para adaptar cualquier vista a móvil sin media queries CSS.
@@ -2381,98 +2381,146 @@
// ── RASTRO DE PROCEDENCIA ──────────────────────────────────────────────
// Sigue una transacción hacia atrás, eslabón a eslabón, BAJO DEMANDA.
// El usuario pulsa "seguir rastro" en un input y se trae UNA transacción
// de origen. Caché en memoria de sesión para no repetir consultas.
// Paso 1: un solo salto hacia atrás por input. La base de todo lo demás.
// Paso 2: RECURSIVO — cada eslabón puede a su vez seguir sus propios inputs,
// encadenando saltos hacia atrás. La recursión NO dispara consultas solas:
// solo se expande cuando el usuario pulsa. Caché compartida en memoria.
function bandColorRastro(b){ return b==="ALTA"?C.green : b==="MEDIA"?C.amber : b==="BAJA"?C.red : C.t2; }
function abrevTxid(t){ return t ? `${t.slice(0,10)}${t.slice(-6)}` : "—"; }
// Marcas baratas de una tx (lookup local ENTITY_INDEX + estructura)
function marcasDeTx(txData, ana){
const marcas=[];
const addrs=[
...(txData.vin||[]).map(v=>v.prevout?.scriptpubkey_address),
...(txData.vout||[]).map(o=>o.scriptpubkey_address),
].filter(Boolean);
let ofac=false, mining=false;
for(const a of addrs){
const hit=ENTITY_INDEX.get(a);
if(hit?.cat==="ofac") ofac=true;
if(hit?.cat==="mining") mining=true;
}
const esCoinbase=(txData.vin||[]).some(v=>v.is_coinbase||v.coinbase)||(txData.vin||[]).length===0;
if(ana?.band==="ALTA"&&/coinjoin|whirlpool|mezcla/i.test(JSON.stringify(ana.checks||[]))) marcas.push({txt:"🔀 CoinJoin",color:C.blue});
if(ofac) marcas.push({txt:"⚠️ OFAC",color:C.red});
if(mining) marcas.push({txt:"⛏️ minería",color:C.green});
if(esCoinbase) marcas.push({txt:"⛏️ coinbase (origen)",color:C.green});
return {marcas, esCoinbase};
}
// 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}) {
const [hijos,setHijos]=useState({}); // {vinIndex: {loading, child:{txData,fluyo,vout}, err}}
const ana = useMemo(()=>analyzeTx(txData),[txData]);
const {marcas, esCoinbase} = useMemo(()=>marcasDeTx(txData,ana),[txData,ana]);
const mempoolUrl = base ? `${base}/tx/${txData.txid}` : `https://mempool.space/tx/${txData.txid}`;
const inputs = txData.vin || [];
const seguirInput = async (i) => {
const vin=inputs[i];
const origenTxid=vin?.txid;
if(!origenTxid){ setHijos(s=>({...s,[i]:{err:"Este input no indica su transacción de origen."}})); return; }
setHijos(s=>({...s,[i]:{loading:true}}));
try{
const child=await getTx(origenTxid);
if(!child) throw new Error("No se pudo traer la transacción de origen");
setHijos(s=>({...s,[i]:{child:{txData:child, fluyo:vin.prevout?.value||0, vout:vin.vout}}}));
}catch(e){ setHijos(s=>({...s,[i]:{err:e.message}})); }
};
return (
<div style={{padding:"10px 12px",background:C.bg,borderRadius:6,border:`1px solid ${C.border}`,marginTop:8}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",gap:8,flexWrap:"wrap",marginBottom:6}}>
<a href={mempoolUrl} target="_blank" rel="noopener noreferrer"
style={{fontSize:"0.64rem",fontFamily:"monospace",color:C.blue,textDecoration:"none",wordBreak:"break-all"}}>
{abrevTxid(txData.txid)}
</a>
{ana?.band&&(
<span style={{fontSize:"0.58rem",fontFamily:"monospace",color:bandColorRastro(ana.band),border:`1px solid ${bandColorRastro(ana.band)}40`,borderRadius:4,padding:"1px 7px"}}>{ana.band}</span>
)}
</div>
<div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",marginBottom:6}}>
Fluyó {fmt.num(fluyo)} sat hacia el eslabón anterior
{txData.status?.block_time&&<span> · {fmt.date(txData.status.block_time)}</span>}
</div>
{marcas.length>0&&(
<div style={{display:"flex",gap:6,flexWrap:"wrap",marginBottom:6}}>
{marcas.map((m,j)=>(<span key={j} style={{fontSize:"0.56rem",fontFamily:"monospace",color:m.color,border:`1px solid ${m.color}40`,borderRadius:4,padding:"1px 6px"}}>{m.txt}</span>))}
</div>
)}
{esCoinbase&&(
<div style={{fontSize:"0.58rem",color:C.t3,marginTop:2}}>Fin de la rama: estas monedas se crearon aquí (recompensa de minado).</div>
)}
{/* Seguir más atrás desde este eslabón (recursivo, bajo demanda) */}
{!esCoinbase&&depth<8&&(
<div style={{marginTop:8,paddingLeft:10,borderLeft:`2px solid ${C.border}`}}>
{inputs.map((vin,i)=>{
const h=hijos[i]||{};
return (
<div key={i} style={{marginBottom:6}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",gap:8,flexWrap:"wrap"}}>
<span style={{fontSize:"0.6rem",fontFamily:"monospace",color:C.t2}}> origen del input #{i+1} · {fmt.num(vin.prevout?.value||0)} sat</span>
{!h.child&&!h.loading&&(
<button onClick={()=>seguirInput(i)}
style={{padding:"3px 9px",background:C.purpleMuted,border:`1px solid ${C.purple}40`,borderRadius:5,color:C.purple,fontFamily:"monospace",fontSize:"0.56rem",cursor:"pointer"}}>
seguir
</button>
)}
{h.loading&&<span style={{fontSize:"0.56rem",color:C.t2,fontFamily:"monospace"}}>consultando</span>}
</div>
{h.err&&<div style={{fontSize:"0.56rem",color:C.amber,fontFamily:"monospace",marginTop:4}}>{h.err}</div>}
{h.child&&<Eslabon txData={h.child.txData} fluyo={h.child.fluyo} vout={h.child.vout} depth={depth+1} getTx={getTx} base={base}/>}
</div>
);
})}
</div>
)}
{!esCoinbase&&depth>=8&&(
<div style={{fontSize:"0.56rem",color:C.t3,marginTop:6}}>Límite de profundidad alcanzado. Abre el txid en Mempool para seguir manualmente.</div>
)}
</div>
);
}
function RastroProcedencia({tx, base}) {
const {get}=useApi(base);
// Caché en memoria: txid -> datos de esa tx ya consultada. Solo RAM.
const [cache,setCache]=useState({});
// Estado por input: { [vinIndex]: {loading, eslabon, err} }
const [eslabones,setEslabones]=useState({});
const cacheRef = useRef({}); // txid -> txData, compartida
const [raiz,setRaiz]=useState({}); // {vinIndex: {loading, child, err}}
// Trae una tx (de caché o del nodo). Compartida por todos los eslabones.
const getTx = useCallback(async (txid)=>{
if(cacheRef.current[txid]) return cacheRef.current[txid];
let data;
if(!base){ data={...MOCK_TX, txid}; }
else { data=await get(`/api/tx/${txid}`, null); }
if(data) cacheRef.current[txid]=data;
return data;
},[base,get]);
const inputs = tx.vin || [];
// Marcas baratas de un eslabón (lookup local + estructura, sin consultas extra)
const marcasDe = (txData) => {
const marcas = [];
// Cruce con ENTITY_INDEX (OFAC / mining) sobre inputs y outputs
const addrs = [
...(txData.vin||[]).map(v=>v.prevout?.scriptpubkey_address),
...(txData.vout||[]).map(o=>o.scriptpubkey_address),
].filter(Boolean);
let ofac=false, mining=false;
for(const a of addrs){
const hit=ENTITY_INDEX.get(a);
if(hit?.cat==="ofac") ofac=true;
if(hit?.cat==="mining") mining=true;
}
if(ofac) marcas.push({txt:"⚠️ OFAC", color:C.red});
if(mining) marcas.push({txt:"⛏️ minería", color:C.green});
// ¿Es coinbase? (input sin prevout / con campo coinbase) -> fin de rama
const esCoinbase = (txData.vin||[]).some(v=>v.is_coinbase||v.coinbase) || (txData.vin||[]).length===0;
if(esCoinbase) marcas.push({txt:"⛏️ coinbase (origen)", color:C.green});
// CoinJoin: lo detecta analyzeTx, lo añadimos abajo con la banda
return {marcas, esCoinbase};
};
const seguir = async (i) => {
const vin = inputs[i];
const origenTxid = vin?.txid;
if(!origenTxid){
setEslabones(s=>({...s,[i]:{err:"Este input no indica su transacción de origen."}}));
return;
}
setEslabones(s=>({...s,[i]:{loading:true}}));
const vin=inputs[i];
const origenTxid=vin?.txid;
if(!origenTxid){ setRaiz(s=>({...s,[i]:{err:"Este input no indica su transacción de origen."}})); return; }
setRaiz(s=>({...s,[i]:{loading:true}}));
try{
// ¿En caché? No repetir consulta al nodo.
let txData = cache[origenTxid];
if(!txData){
if(!base){
// Modo demo: devolvemos el propio MOCK como "origen" simulado
txData = {...MOCK_TX, txid:origenTxid};
} else {
txData = await get(`/api/tx/${origenTxid}`, null);
if(!txData) throw new Error("No se pudo traer la transacción de origen");
}
setCache(c=>({...c,[origenTxid]:txData}));
}
// Info barata del eslabón
const ana = analyzeTx(txData);
const {marcas, esCoinbase} = marcasDe(txData);
if(ana?.band==="ALTA" && /coinjoin|whirlpool|mezcla/i.test(JSON.stringify(ana.checks||[]))){
marcas.unshift({txt:"🔀 CoinJoin", color:C.blue});
}
const fluyo = vin.prevout?.value || 0;
setEslabones(s=>({...s,[i]:{
eslabon:{
txid: origenTxid,
vout: vin.vout,
fluyo,
fecha: txData.status?.block_time || null,
band: ana?.band || null,
score: ana?.score ?? null,
marcas,
esCoinbase,
}
}}));
}catch(e){
setEslabones(s=>({...s,[i]:{err:e.message}}));
}
const child=await getTx(origenTxid);
if(!child) throw new Error("No se pudo traer la transacción de origen");
setRaiz(s=>({...s,[i]:{child:{txData:child, fluyo:vin.prevout?.value||0, vout:vin.vout}}}));
}catch(e){ setRaiz(s=>({...s,[i]:{err:e.message}})); }
};
const bandColor = (b) => b==="ALTA"?C.green : b==="MEDIA"?C.amber : b==="BAJA"?C.red : C.t2;
const abrev = (t) => t ? `${t.slice(0,10)}${t.slice(-6)}` : "—";
const mempoolUrl = (t) => base ? `${base}/tx/${t}` : `https://mempool.space/tx/${t}`;
const limpiar = () => { setCache({}); setEslabones({}); };
const limpiar = () => { cacheRef.current={}; setRaiz({}); };
return (
<Card>
<div style={{display:"flex",alignItems:"center",gap:8,marginBottom:10}}>
<span style={{fontSize:"0.62rem",color:C.purple,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em"}}>Rastro de procedencia</span>
<div style={{flex:1,height:1,background:`linear-gradient(to right,${C.purple}40,transparent)`}}/>
{Object.keys(eslabones).length>0&&(
{Object.keys(raiz).length>0&&(
<button onClick={limpiar} title="Vaciar el rastro y la caché de esta sesión"
style={{padding:"3px 9px",background:C.bg,border:`1px solid ${C.border}`,borderRadius:5,color:C.t2,fontFamily:"monospace",fontSize:"0.58rem",cursor:"pointer"}}>
🗑 limpiar
@@ -2480,19 +2528,18 @@
)}
</div>
<div style={{fontSize:"0.62rem",color:C.t2,marginBottom:12,lineHeight:1.5}}>
Sigue cada input hacia atrás, un salto a la vez. Cada consulta la pides , para no sobrecargar el nodo.
Sigue cada input hacia atrás, un salto a la vez. Cada consulta la pides , para no sobrecargar el nodo. Desde cada eslabón puedes seguir bajando.
</div>
{inputs.map((vin,i)=>{
const est = eslabones[i] || {};
const est=raiz[i]||{};
return (
<div key={i} style={{marginBottom:10,paddingLeft:10,borderLeft:`2px solid ${C.border}`}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",gap:8,flexWrap:"wrap"}}>
<div style={{fontSize:"0.65rem",fontFamily:"monospace",color:C.t1}}>
Input #{i+1}
<span style={{color:C.t2,marginLeft:8}}>{fmt.num(vin.prevout?.value||0)} sat</span>
Input #{i+1}<span style={{color:C.t2,marginLeft:8}}>{fmt.num(vin.prevout?.value||0)} sat</span>
</div>
{!est.eslabon&&!est.loading&&(
{!est.child&&!est.loading&&(
<button onClick={()=>seguir(i)}
style={{padding:"4px 10px",background:C.purpleMuted,border:`1px solid ${C.purple}40`,borderRadius:5,color:C.purple,fontFamily:"monospace",fontSize:"0.6rem",cursor:"pointer"}}>
seguir rastro
@@ -2500,40 +2547,8 @@
)}
{est.loading&&<span style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace"}}>consultando</span>}
</div>
{est.err&&(
<div style={{marginTop:6,fontSize:"0.6rem",color:C.amber,fontFamily:"monospace"}}>{est.err}</div>
)}
{est.eslabon&&(
<div style={{marginTop:8,padding:"10px 12px",background:C.bg,borderRadius:6,border:`1px solid ${C.border}`}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",gap:8,flexWrap:"wrap",marginBottom:6}}>
<a href={mempoolUrl(est.eslabon.txid)} target="_blank" rel="noopener noreferrer"
style={{fontSize:"0.64rem",fontFamily:"monospace",color:C.blue,textDecoration:"none",wordBreak:"break-all"}}>
{abrev(est.eslabon.txid)}
</a>
{est.eslabon.band&&(
<span style={{fontSize:"0.58rem",fontFamily:"monospace",color:bandColor(est.eslabon.band),border:`1px solid ${bandColor(est.eslabon.band)}40`,borderRadius:4,padding:"1px 7px"}}>
{est.eslabon.band}
</span>
)}
</div>
<div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",marginBottom:6}}>
Fluyó {fmt.num(est.eslabon.fluyo)} sat hacia esta tx
{est.eslabon.fecha&&<span> · {fmt.date(est.eslabon.fecha)}</span>}
</div>
{est.eslabon.marcas.length>0&&(
<div style={{display:"flex",gap:6,flexWrap:"wrap"}}>
{est.eslabon.marcas.map((m,j)=>(
<span key={j} style={{fontSize:"0.56rem",fontFamily:"monospace",color:m.color,border:`1px solid ${m.color}40`,borderRadius:4,padding:"1px 6px"}}>{m.txt}</span>
))}
</div>
)}
{est.eslabon.esCoinbase&&(
<div style={{fontSize:"0.58rem",color:C.t3,marginTop:6}}>Fin de la rama: estas monedas se crearon aquí (recompensa de minado).</div>
)}
</div>
)}
{est.err&&<div style={{marginTop:6,fontSize:"0.6rem",color:C.amber,fontFamily:"monospace"}}>{est.err}</div>}
{est.child&&<Eslabon txData={est.child.txData} fluyo={est.child.fluyo} vout={est.child.vout} depth={1} getTx={getTx} base={base}/>}
</div>
);
})}