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,
|
fee: 4200, feerate: 16.8, size: 374, weight: 996,
|
||||||
status: { confirmed:true, block_height:840811, block_time: Math.floor(Date.now()/1000) - 3891200 },
|
status: { confirmed:true, block_height:840811, block_time: Math.floor(Date.now()/1000) - 3891200 },
|
||||||
vin: [
|
vin: [
|
||||||
{ sequence: 0xffffffff, prevout: { value:5000000, scriptpubkey_address:"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", scriptpubkey_type:"v0_p2wpkh" } },
|
{ sequence: 0xffffffff, txid:"d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5", vout:0, 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:"f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1", vout:1, prevout: { value:3000000, scriptpubkey_address:"bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", scriptpubkey_type:"v0_p2wpkh" } },
|
||||||
],
|
],
|
||||||
vout: [
|
vout: [
|
||||||
{ value:7000000, scriptpubkey_address:"bc1qm34lsc65zpw79lxes69zkqmk6ee3ewf0j77s3h", scriptpubkey_type:"v0_p2wpkh" },
|
{ 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 ──────────────────────────────────────────────────────────
|
// ── AUDITORÍA ──────────────────────────────────────────────────────────
|
||||||
function Auditoria({base, initialQuery}) {
|
function Auditoria({base, initialQuery}) {
|
||||||
const {get}=useApi(base);
|
const {get}=useApi(base);
|
||||||
@@ -2450,6 +2612,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<PrivacyLab analysis={analysis} tx={tx}/>
|
<PrivacyLab analysis={analysis} tx={tx}/>
|
||||||
|
<RastroProcedencia tx={tx} base={base}/>
|
||||||
<TxVerifier base={base}/>
|
<TxVerifier base={base}/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -2772,7 +2935,7 @@
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<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.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}}>
|
<div style={{display:"flex",gap:8,marginBottom:12}}>
|
||||||
<input value={txid} onChange={e=>setTxid(e.target.value)} onKeyDown={e=>e.key==="Enter"&&decode()}
|
<input value={txid} onChange={e=>setTxid(e.target.value)} onKeyDown={e=>e.key==="Enter"&&decode()}
|
||||||
placeholder="txid de una transacción"
|
placeholder="txid de una transacción"
|
||||||
|
|||||||
Reference in New Issue
Block a user