feat: distancia a entidad en el rastro + manejo correcto de coinbase como origen

This commit is contained in:
Aitor
2026-06-02 19:32:07 +02:00
parent d3971de3a8
commit 2950559203
2 changed files with 83 additions and 10 deletions
+20
View File
@@ -12,6 +12,26 @@ y el versionado sigue [Versionado Semántico](https://semver.org/lang/es/):
--- ---
## [1.4.0] — 2026-06-02
### Añadido
- Distancia a una entidad en el rastro de procedencia. A medida que se exploran
eslabones, un resumen arriba del rastro indica a cuántos saltos está lo más
cercano de cada tipo marcado (OFAC, minería, CoinJoin) — por ejemplo "minería
· a 2 saltos". Es honesto sobre su alcance: solo cuenta las ramas que se han
seguido y avisa de que puede haber más sin explorar.
### Corregido
- Transacciones coinbase como origen del rastro. Antes mostraban un aviso
confuso ("este input no indica su transacción de origen"), como si fuera un
error. Ahora se reconocen como el principio natural del rastro: un mensaje
claro indica que esas monedas se crearon ahí como recompensa de minado y no
proceden de ninguna transacción anterior.
---
## [1.3.0] — 2026-06-02 ## [1.3.0] — 2026-06-02
### Añadido ### Añadido
+62 -9
View File
@@ -2427,7 +2427,7 @@
if(!origenTxid){ setHijos(s=>({...s,[i]:{err:"Este input no indica su transacción de origen."}})); return; } if(!origenTxid){ setHijos(s=>({...s,[i]:{err:"Este input no indica su transacción de origen."}})); return; }
setHijos(s=>({...s,[i]:{loading:true}})); setHijos(s=>({...s,[i]:{loading:true}}));
try{ try{
const child=await getTx(origenTxid); const child=await getTx(origenTxid, depth+1);
if(!child) throw new Error("No se pudo traer la transacción de origen"); 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}}})); setHijos(s=>({...s,[i]:{child:{txData:child, fluyo:vin.prevout?.value||0, vout:vin.vout}}}));
}catch(e){ setHijos(s=>({...s,[i]:{err:e.message}})); } }catch(e){ setHijos(s=>({...s,[i]:{err:e.message}})); }
@@ -2490,19 +2490,47 @@
function RastroProcedencia({tx, base}) { function RastroProcedencia({tx, base}) {
const {get}=useApi(base); const {get}=useApi(base);
const cacheRef = useRef({}); // txid -> txData, compartida const cacheRef = useRef({}); // txid -> {txData, depth}, compartida
const [raiz,setRaiz]=useState({}); // {vinIndex: {loading, child, err}} const [raiz,setRaiz]=useState({}); // {vinIndex: {loading, child, err}}
const [explorados,setExplorados]=useState(0); // contador: sube al explorar, refresca resumen
// Trae una tx (de caché o del nodo). Compartida por todos los eslabones. // Trae una tx (de caché o del nodo) y registra a qué profundidad se exploró.
const getTx = useCallback(async (txid)=>{ // Compartida por todos los eslabones.
if(cacheRef.current[txid]) return cacheRef.current[txid]; const getTx = useCallback(async (txid, depth)=>{
const cached = cacheRef.current[txid];
if(cached){
// Si ya estaba pero a más profundidad, nos quedamos con la menor (más cercana)
if(depth!=null && depth < cached.depth){ cached.depth = depth; setExplorados(n=>n+1); }
return cached.txData;
}
let data; let data;
if(!base){ data={...MOCK_TX, txid}; } if(!base){ data={...MOCK_TX, txid}; }
else { data=await get(`/api/tx/${txid}`, null); } else { data=await get(`/api/tx/${txid}`, null); }
if(data) cacheRef.current[txid]=data; if(data){ cacheRef.current[txid]={txData:data, depth:depth??1}; setExplorados(n=>n+1); }
return data; return data;
},[base,get]); },[base,get]);
// Resumen de distancia a entidades: recorre lo YA explorado en la caché
// y busca la marca más cercana de cada tipo. Honesto: solo sabe de lo
// que se ha mirado, no afirma sobre ramas sin explorar.
const resumenDistancia = useMemo(()=>{
void explorados; // dependencia: recalcular cuando se explora algo nuevo
const minDist = {}; // cat -> profundidad mínima
for(const txid in cacheRef.current){
const {txData, depth} = cacheRef.current[txid];
const ana = analyzeTx(txData);
const {marcas} = marcasDeTx(txData, ana);
for(const m of marcas){
let cat=null;
if(m.txt.includes("OFAC")) cat="⚠️ OFAC";
else if(m.txt.includes("minería")||m.txt.includes("coinbase")) cat="⛏️ minería";
else if(m.txt.includes("CoinJoin")) cat="🔀 CoinJoin";
if(cat && (minDist[cat]==null || depth<minDist[cat])) minDist[cat]=depth;
}
}
return Object.entries(minDist).map(([cat,d])=>({cat,dist:d}));
},[explorados]);
const inputs = tx.vin || []; const inputs = tx.vin || [];
const seguir = async (i) => { const seguir = async (i) => {
@@ -2511,13 +2539,13 @@
if(!origenTxid){ setRaiz(s=>({...s,[i]:{err:"Este input no indica su transacción de origen."}})); return; } if(!origenTxid){ setRaiz(s=>({...s,[i]:{err:"Este input no indica su transacción de origen."}})); return; }
setRaiz(s=>({...s,[i]:{loading:true}})); setRaiz(s=>({...s,[i]:{loading:true}}));
try{ try{
const child=await getTx(origenTxid); const child=await getTx(origenTxid, 1);
if(!child) throw new Error("No se pudo traer la transacción de origen"); 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}}})); setRaiz(s=>({...s,[i]:{child:{txData:child, fluyo:vin.prevout?.value||0, vout:vin.vout}}}));
}catch(e){ setRaiz(s=>({...s,[i]:{err:e.message}})); } }catch(e){ setRaiz(s=>({...s,[i]:{err:e.message}})); }
}; };
const limpiar = () => { cacheRef.current={}; setRaiz({}); }; const limpiar = () => { cacheRef.current={}; setRaiz({}); setExplorados(0); };
return ( return (
<Card> <Card>
@@ -2535,7 +2563,32 @@
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. 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> </div>
{inputs.map((vin,i)=>{ {resumenDistancia.length>0&&(
<div style={{marginBottom:12,padding:"10px 12px",background:C.bg,borderRadius:6,border:`1px solid ${C.border}`}}>
<div style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:6}}>De lo explorado, lo más cercano</div>
<div style={{display:"flex",gap:8,flexWrap:"wrap"}}>
{resumenDistancia.map((r,j)=>(
<span key={j} style={{fontSize:"0.6rem",fontFamily:"monospace",color:C.t1,border:`1px solid ${C.border}`,borderRadius:4,padding:"2px 8px"}}>
{r.cat} · a {r.dist} {r.dist===1?"salto":"saltos"}
</span>
))}
</div>
<div style={{fontSize:"0.55rem",color:C.t3,marginTop:6,lineHeight:1.4}}>
Solo cuenta las ramas que has seguido. Puede haber más sin explorar.
</div>
</div>
)}
{/* ¿La propia tx analizada es coinbase? Entonces es el origen: no hay
nada hacia atrás, estas monedas se crearon aquí. */}
{((tx.vin||[]).some(v=>v.is_coinbase||v.coinbase) || (tx.vin||[]).length===0 || (tx.vin||[]).every(v=>!v.txid)) ? (
<div style={{padding:"12px 14px",background:C.bg,borderRadius:6,border:`1px solid ${C.green}30`}}>
<div style={{fontSize:"0.64rem",color:C.green,fontFamily:"monospace",marginBottom:4}}> Origen del rastro: transacción coinbase</div>
<div style={{fontSize:"0.6rem",color:C.t2,lineHeight:1.5}}>
Estas monedas se crearon aquí, como recompensa de minado. No vienen de ninguna transacción anterior es el principio de su historia en la cadena.
</div>
</div>
) : inputs.map((vin,i)=>{
const est=raiz[i]||{}; const est=raiz[i]||{};
return ( return (
<div key={i} style={{marginBottom:10,paddingLeft:10,borderLeft:`2px solid ${C.border}`}}> <div key={i} style={{marginBottom:10,paddingLeft:10,borderLeft:`2px solid ${C.border}`}}>