feat: rastro de procedencia paso 1 - seguir inputs hacia atras bajo demanda

This commit is contained in:
Aitor
2026-06-02 18:48:16 +02:00
parent aa5838c010
commit b101ca4210
+166 -3
View File
@@ -118,8 +118,8 @@
fee: 4200, feerate: 16.8, size: 374, weight: 996,
status: { confirmed:true, block_height:840811, block_time: Math.floor(Date.now()/1000) - 3891200 },
vin: [
{ sequence: 0xffffffff, prevout: { value:5000000, scriptpubkey_address:"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", scriptpubkey_type:"v0_p2wpkh" } },
{ sequence: 0xffffffff, prevout: { value:3000000, scriptpubkey_address:"bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", scriptpubkey_type:"v0_p2wpkh" } },
{ sequence: 0xffffffff, txid:"d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5", vout:0, prevout: { value:5000000, scriptpubkey_address:"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", scriptpubkey_type:"v0_p2wpkh" } },
{ sequence: 0xffffffff, txid:"f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1", vout:1, prevout: { value:3000000, scriptpubkey_address:"bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", scriptpubkey_type:"v0_p2wpkh" } },
],
vout: [
{ value:7000000, scriptpubkey_address:"bc1qm34lsc65zpw79lxes69zkqmk6ee3ewf0j77s3h", scriptpubkey_type:"v0_p2wpkh" },
@@ -2379,6 +2379,168 @@
);
}
// ── 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({});
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}}));
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 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({}); };
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&&(
<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
</button>
)}
</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.
</div>
{inputs.map((vin,i)=>{
const est = eslabones[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>
</div>
{!est.eslabon&&!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
</button>
)}
{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>
)}
</div>
);
})}
</Card>
);
}
// ── AUDITORÍA ──────────────────────────────────────────────────────────
function Auditoria({base, initialQuery}) {
const {get}=useApi(base);
@@ -2450,6 +2612,7 @@
</div>
</Card>
<PrivacyLab analysis={analysis} tx={tx}/>
<RastroProcedencia tx={tx} base={base}/>
<TxVerifier base={base}/>
</div>
)}
@@ -2772,7 +2935,7 @@
return (
<Card>
<div style={{fontSize:"0.62rem",color:C.blue,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:8}}>Detector OP_RETURN</div>
<div style={{fontSize:"0.65rem",color:C.t2,marginBottom:12}}>Indica si una transacción contiene datos arbitrarios (OP_RETURN) y su tamaño. No muestra el contenido: para auditar privacidad lo relevante es que existan, no qué dicen.</div>
<div style={{fontSize:"0.65rem",color:C.t2,marginBottom:12}}>Indica si una transacción contiene datos arbitrarios (OP_RETURN) y su tamaño.</div>
<div style={{display:"flex",gap:8,marginBottom:12}}>
<input value={txid} onChange={e=>setTxid(e.target.value)} onKeyDown={e=>e.key==="Enter"&&decode()}
placeholder="txid de una transacción"