feat: rastro de procedencia paso 2 - recursivo, encadenar saltos hacia atras
This commit is contained in:
@@ -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
@@ -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 tú, para no sobrecargar el nodo.
|
||||
Sigue cada input hacia atrás, un salto a la vez. Cada consulta la pides tú, 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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user