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
+15
View File
@@ -12,6 +12,21 @@ y el versionado sigue [Versionado Semántico](https://semver.org/lang/es/):
---
## [1.3.0] — 2026-06-02
### Añadido
- Rastro de procedencia (primera versión). En la pestaña Auditoría, debajo
del informe de privacidad, cada input de la transacción tiene un botón
"seguir rastro" que trae la transacción de origen de ese input y muestra un
eslabón con: el txid de origen (enlace a tu propio Mempool), cuánto fluyó,
la fecha y la banda de privacidad de esa transacción. Funciona bajo demanda
—cada consulta la pide el usuario, para no sobrecargar el nodo— y cachea en
memoria las transacciones ya consultadas para no repetirlas. Incluye botón
para limpiar el rastro y la caché de la sesión.
---
## [1.2.1] — 2026-06-02
### Cambiado
+117 -102
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,22 +2381,15 @@
// ── 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.
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({});
// 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)}` : "—"; }
const inputs = tx.vin || [];
// Marcas baratas de un eslabón (lookup local + estructura, sin consultas extra)
const marcasDe = (txData) => {
// Marcas baratas de una tx (lookup local ENTITY_INDEX + estructura)
function marcasDeTx(txData, ana){
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),
@@ -2407,72 +2400,127 @@
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});
// ¿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};
}
// 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);
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 || [];
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}}));
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>
);
})}