feat: rastro de procedencia paso 1 - seguir inputs hacia atras bajo demanda
This commit is contained in:
+166
-3
@@ -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 tú, 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"
|
||||
|
||||
Reference in New Issue
Block a user