feat: pestaña Cadena unificada, aviso de polvo y enlaces a la instancia propia

Mempool y Bloques eran el mismo eje partido: los proyectados en una pestaña y
los confirmados en otra. Ahora es una línea temporal con el ahora en medio, y
responde la pregunta que ninguna de las dos respondía — a qué tarifa entra tu
transacción y en qué bloque.

Aviso de polvo recibido en el informe de wallet: el analizador sabía
reconocerlo al mirar una transacción, pero no avisaba cuando te pasaba a ti.
El consejo es no gastarlo, porque el daño solo ocurre al mezclarlo.

Enlaces a la instancia propia de Mempool en nueve puntos, construidos siempre
desde la URL configurada.

Y un fallo preexistente: el rastro de procedencia caía en mempool.space
público si no había nodo configurado. Sin nodo ya no hay enlace.
This commit is contained in:
2026-08-08 18:26:01 +02:00
parent 387af724ac
commit 643236e47b
3 changed files with 418 additions and 116 deletions
+345 -113
View File
@@ -1460,7 +1460,51 @@
healthMsg = "Tus monedas presentan vinculación significativa o reutilización frecuente. Un observador puede agrupar buena parte de tu actividad.";
}
// 4) Polvo recibido — monedas ínfimas que han llegado a tus direcciones.
// El analizador ya sabe reconocer el patrón cuando miras una transacción
// suelta, pero eso no sirve de nada si no te avisa cuando te pasa a TI.
// Aquí se recorren las salidas de todas las transacciones del wallet
// buscando importes minúsculos hacia direcciones propias.
//
// Por qué importa: el polvo no vale nada, pero si algún día lo gastas
// junto a otras monedas, las enlazas todas con quien te lo mandó. Ese es
// exactamente el objetivo de un ataque de dusting. Mientras no lo gastes,
// no hace nada — la defensa es dejarlo quieto, no moverlo.
//
// Se distingue el polvo TÉCNICO (por debajo del mínimo económico de la
// red) del sospechoso (por encima del mínimo pero ridículo igualmente,
// que es lo que manda quien quiere que sí puedas gastarlo).
const umbralPolvo = (type) => {
if (!type) return 546;
if (type.includes("p2tr") || type.includes("v1")) return 330;
if (type.includes("p2wpkh") || type.includes("v0_p2wpkh")) return 294;
if (type.includes("p2wsh") || type.includes("v0_p2wsh")) return 330;
return 546;
};
const TECHO_SOSPECHA = 1000;
const polvoRecibido = [];
for (const tx of txs) {
(tx.vout || []).forEach((v, i) => {
const a = v.scriptpubkey_address;
if (!a || !myAddrSet.has(a) || !v.value) return;
const th = umbralPolvo(v.scriptpubkey_type);
if (v.value < TECHO_SOSPECHA) {
polvoRecibido.push({
txid: tx.txid, vout: i, address: a, value: v.value,
tecnico: v.value < th,
time: tx.status?.block_time || 0,
// Se marca si ya se gastó: si el daño está hecho, el consejo
// cambia y decirlo tarde no ayuda.
gastado: txs.some(t => (t.vin||[]).some(vin => vin.txid === tx.txid && vin.vout === i)),
});
}
});
}
polvoRecibido.sort((a,b) => b.time - a.time);
const polvoSinGastar = polvoRecibido.filter(p => !p.gastado);
return { totalTxs, activeCount: activeAddrs.length, reusedAddrs, clusters, linkReasons, history,
polvoRecibido, polvoSinGastar,
health: { band: healthBand, color: healthColor, msg: healthMsg } };
}
@@ -2293,9 +2337,32 @@
};
const Badge = ({children, color}) => { const c=color||C.green; return <span style={{fontSize:"0.62rem",fontFamily:"monospace",letterSpacing:"0.07em",padding:"2px 6px",borderRadius:3,fontWeight:700,background:`${c}18`,color:c,border:`1px solid ${c}35`,whiteSpace:"nowrap"}}>{children}</span>; };
const Card = ({children, style, glow}) => <div style={{background:C.bgCard,border:`1px solid ${C.border}`,borderRadius:8,padding:16,boxShadow:glow?`0 0 28px ${glow}12`:"none",...style}}>{children}</div>;
const Card = ({children, style, glow, innerRef}) => <div ref={innerRef} style={{background:C.bgCard,border:`1px solid ${C.border}`,borderRadius:8,padding:16,boxShadow:glow?`0 0 28px ${glow}12`:"none",...style}}>{children}</div>;
const SectionTitle = ({children, accent, icon}) => { const a=accent||C.green; return <div style={{display:"flex",alignItems:"center",gap:8,marginBottom:16}}>{icon&&<span style={{color:a,fontFamily:"monospace",fontSize:"0.8rem"}}>{icon}</span>}<h2 style={{margin:0,fontSize:"0.68rem",fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.2em",color:a}}>{children}</h2><div style={{flex:1,height:1,background:`linear-gradient(to right,${a}40,transparent)`}}/></div>; };
const Tag = ({label,value,color}) => <div style={{display:"flex",flexDirection:"column",gap:3}}><span style={{fontSize:"0.58rem",color:C.t2,textTransform:"uppercase",letterSpacing:"0.1em",fontFamily:"monospace"}}>{label}</span><span style={{fontSize:"0.8rem",color:color||C.t1,fontFamily:"monospace",fontWeight:600}}>{value!=null?value:"-"}</span></div>;
// ── Enlace a TU instancia de Mempool ────────────────────────────────
// Se construye siempre desde la URL que has configurado, nunca desde una
// constante. Enlazar a mempool.space público sería regalar exactamente lo
// que esta herramienta protege: le diría a un tercero qué transacciones y
// qué direcciones te interesan. Si no hay nodo configurado, no hay enlace:
// antes ninguno que uno que salga fuera.
const VerEnMempool = ({base, tipo, id, texto}) => {
if (!base || !id) return null;
const rutas = { tx:"tx", direccion:"address", bloque:"block" };
const ruta = rutas[tipo];
if (!ruta) return null;
const url = `${String(base).replace(/\/$/,"")}/${ruta}/${id}`;
return (
<a href={url} target="_blank" rel="noopener noreferrer"
title="Abrir en tu propia instancia de Mempool — no sale de tu red"
style={{fontSize:"0.58rem",fontFamily:"monospace",color:C.t2,textDecoration:"none",border:`1px solid ${C.border}`,borderRadius:4,padding:"2px 7px",whiteSpace:"nowrap"}}
onMouseEnter={e=>{e.currentTarget.style.color=C.blue;e.currentTarget.style.borderColor=C.blue+"60";}}
onMouseLeave={e=>{e.currentTarget.style.color=C.t2;e.currentTarget.style.borderColor=C.border;}}>
{texto || "ver en Mempool ↗"}
</a>
);
};
const Pulse = ({color}) => <span style={{display:"inline-block",width:7,height:7,borderRadius:"50%",background:color||C.green,boxShadow:`0 0 7px ${color||C.green}`,animation:"blink 2s ease-in-out infinite"}}/>;
const Spinner = () => <div style={{display:"flex",alignItems:"center",justifyContent:"center",padding:40,color:C.t2,fontFamily:"monospace",fontSize:"0.75rem",gap:8}}><span style={{animation:"spin 1s linear infinite",display:"inline-block"}}></span> cargando</div>;
const DemoBanner = () => <div style={{padding:"8px 14px",background:C.amberMuted,border:`1px solid ${C.amber}30`,borderRadius:6,color:C.amber,fontFamily:"monospace",fontSize:"0.7rem",marginBottom:14}}>Datos de demo — configura tu nodo en CONFIG para ver datos reales</div>;
@@ -2980,15 +3047,45 @@
}
// ── 2. Peers entrantes ────────────────────────────────────────
// Antes esto avisaba en cuanto había UNA conexión entrante, diciendo
// que la IP "puede ser visible". Es un falso positivo en el caso que
// más merece lo contrario: un nodo que solo acepta entrantes por Tor e
// I2P no expone nada, y encima devuelve a la red lo que consume. El
// aviso lo marcaba como "revisar" cuando es la configuración correcta.
//
// Lo que importa no es cuántos entran, sino por dónde entran y si el
// nodo anuncia alguna dirección de clearnet. Ambas cosas se pueden
// comprobar, así que se comprueban en vez de suponerlas.
if (btcData?.inbound !== undefined) {
const inbound = btcData.inbound || 0;
results.push({
id:"inbound_peers", label:"Peers entrantes",
status: inbound > 0 ? "warn" : "ok",
detail: inbound > 0
? `${inbound} peers entrantes detectados. Tu IP puede ser visible en la red Bitcoin. Considera usar solo conexiones salientes o enrutar por Tor/I2P.`
: "Sin peers entrantes. Tu IP no está expuesta directamente en la red Bitcoin.",
});
const nets = btcData.inboundNets; // {onion:4, i2p:4, ipv4:…}
const clearnet = btcData.clearnetLocal; // direcciones públicas anunciadas
const ANON = ["onion", "i2p", "cjdns"];
const porRed = nets ? Object.entries(nets).map(([n,c]) => `${c} por ${n}`).join(", ") : null;
const entrantesClearnet = nets
? Object.entries(nets).filter(([n]) => !ANON.includes(n)).reduce((s,[,c]) => s+c, 0)
: null;
const anunciaIp = Array.isArray(clearnet) && clearnet.length > 0;
let status, detail;
if (inbound === 0) {
status = "ok";
detail = "Sin peers entrantes: tu IP no se expone por esta vía. Ten en cuenta que un nodo sin entrantes consume de la red sin servir a nadie — aceptarlas por Tor o I2P es mejor para todos y no te expone.";
} else if (nets === null || nets === undefined) {
// No se pudo mirar. Se dice, no se supone.
status = "warn";
detail = `${inbound} conexiones entrantes. No se ha podido comprobar por qué red entran, así que no se puede decir si tu IP queda expuesta. Compruébalo con: bitcoin-cli getpeerinfo | jq -r '.[] | select(.inbound) | .network' | sort | uniq -c`;
} else if (entrantesClearnet === 0 && !anunciaIp) {
status = "ok";
detail = `${inbound} conexiones entrantes, todas por redes anónimas (${porRed}), y tu nodo no anuncia ninguna dirección de clearnet. Nadie ve tu IP: ni quien se conecta ni la red en general. Es la configuración que conviene tener, y además devuelves a la red lo que consumes.`;
} else if (entrantesClearnet === 0 && anunciaIp) {
status = "warn";
detail = `Las ${inbound} conexiones entrantes llegan por redes anónimas (${porRed}), pero tu nodo anuncia ${clearnet.length} dirección(es) de clearnet: ${clearnet.join(", ")}. Quien las lea puede relacionar tu nodo con esa IP aunque nadie se conecte por ahí. Revisa \`discover\`, \`externalip\` y \`listen\` en bitcoin.conf.`;
} else {
status = "warn";
detail = `${inbound} conexiones entrantes, de las cuales ${entrantesClearnet} llegan por clearnet (${porRed}). Esos peers ven tu IP. No es un fallo si lo has decidido tú —ayuda a la red—, pero si prefieres no exponerla, acepta entrantes solo por Tor o I2P: sigues sirviendo a la red sin enseñar de dónde vienes.`;
}
results.push({ id:"inbound_peers", label:"Peers entrantes", status, detail });
}
// ── 3. Mempool accesible solo desde origen correcto ──────────
@@ -3234,19 +3331,149 @@
);
}
function BlockExplorer({base}) {
// ── Vista de la cadena: mempool y bloques en una sola línea ─────────
// Antes esto eran dos pestañas, y era el mismo eje partido por la mitad:
// "bloques proyectados" (los que se van a minar) vivían en Mempool, y los
// confirmados en Bloques. Es una línea temporal con el ahora en el corte,
// así que se cuenta como una sola historia: lo que viene, el momento
// actual, y lo que ya pasó.
function RedView({base}) {
const {get}=useApi(base);
const isMobile=useIsMobile();
const [blocks,setBlocks]=useState([]);
const [selected,setSelected]=useState(null);
const [blockTxs,setBlockTxs]=useState(null);
const [mempool,setMempool]=useState(null);
const [fees,setFees]=useState(null);
const [proyectados,setProyectados]=useState(null);
const [feeHistory,setFeeHistory]=useState(null);
const [tarifa,setTarifa]=useState("");
const [loading,setLoading]=useState(true);
useEffect(()=>{ setLoading(true); get("/api/v1/blocks",MOCK_BLOCKS).then(b=>{setBlocks(b);setLoading(false);}); },[base]);
useEffect(()=>{
setLoading(true);
Promise.all([
get("/api/v1/blocks",MOCK_BLOCKS),
get("/api/mempool",MOCK_MEMPOOL),
get("/api/v1/fees/recommended",MOCK_FEES),
get("/api/v1/fees/mempool-blocks",MOCK_MEMPOOLBLOCKS),
]).then(([b,m,f,p])=>{ setBlocks(b||[]); setMempool(m); setFees(f); setProyectados(p); setLoading(false); });
get("/api/v1/mining/blocks/fee-rates/1w", MOCK_FEE_HISTORY).then(d=>setFeeHistory(d));
},[base]);
const selectBlock=async(b)=>{ setSelected(b); setBlockTxs(null); const txs=await get(`/api/block/${b.id}/txs/0`,[MOCK_TX]); setBlockTxs(Array.isArray(txs)?txs:[txs]); };
// El detalle se dibuja al final de la lista, así que al pinchar en un
// bloque de arriba se abría a quince filas de distancia y parecía que el
// clic no hacía nada. Se lleva la vista hasta él.
const detalleRef = useRef(null);
useEffect(()=>{
if (selected && detalleRef.current) {
detalleRef.current.scrollIntoView({ behavior:"smooth", block:"center" });
}
},[selected]);
// ¿En qué bloque entraría una transacción a esta tarifa? Es la pregunta
// que de verdad se hace quien mira las comisiones, y ni la vista de
// mempool ni la de bloques la respondían: daban los datos por separado
// y dejaban el cálculo al ojo.
const tarifaNum = parseFloat(String(tarifa).replace(",", "."));
const estimacion = (() => {
if (!Number.isFinite(tarifaNum) || tarifaNum <= 0 || !proyectados || !proyectados.length) return null;
const idx = proyectados.findIndex(b => {
const r = b.feeRange || [];
return r.length ? tarifaNum >= Math.min(...r) : false;
});
if (idx === -1) {
const ultimo = proyectados[proyectados.length-1];
const minUlt = (ultimo.feeRange||[]).length ? Math.min(...ultimo.feeRange) : null;
return { fuera:true, minUlt, bloques:proyectados.length };
}
return { fuera:false, posicion: idx+1, minutos: (idx+1)*10 };
})();
const feeStats = feeHistory ? (() => {
const fs = feeHistory.map(d=>d.avgFee_50||d.avgFee||0).filter(v=>v>0);
if (!fs.length) return null;
return { min:Math.min(...fs), max:Math.max(...fs), avg:Math.round(fs.reduce((a,b)=>a+b,0)/fs.length) };
})() : null;
if(loading)return<Spinner/>;
return (
<div style={{display:"flex",flexDirection:"column",gap:6}}>
<div style={{display:"flex",flexDirection:"column",gap:12}}>
{!base&&<DemoBanner/>}
<SectionTitle accent={C.purple} icon="◈">Bloques Recientes</SectionTitle>
{/* Comisiones — el contexto para decidir */}
{fees&&(
<Card>
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:12}}>Comisiones recomendadas</div>
<div style={{display:"grid",gridTemplateColumns:`repeat(auto-fill,minmax(${isMobile?"105px":"130px"},1fr))`,gap:10}}>
{[{label:"Próx. bloque",val:`${fees.fastestFee} sat/vB`,color:C.red},{label:"30 min",val:`${fees.halfHourFee} sat/vB`,color:C.amber},{label:"1 hora",val:`${fees.hourFee} sat/vB`,color:C.green},{label:"Económico",val:`${fees.economyFee} sat/vB`,color:C.t2},{label:"Mínimo",val:`${fees.minimumFee} sat/vB`,color:C.t3}].map(f=>(
<div key={f.label} style={{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:4}}>{f.label}</div><div style={{fontSize:"0.88rem",color:f.color,fontFamily:"monospace",fontWeight:700}}>{f.val}</div></div>
))}
</div>
{/* La pregunta real */}
<div style={{marginTop:14,paddingTop:12,borderTop:`1px solid ${C.border}`}}>
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",marginBottom:8}}>Si pagas… ¿cuándo entra?</div>
<div style={{display:"flex",gap:8,alignItems:"center",flexWrap:"wrap"}}>
<input value={tarifa} onChange={e=>setTarifa(e.target.value)} type="number" min="0" step="0.1" placeholder="sat/vB"
style={{width:110,background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"8px 10px",color:C.t1,fontFamily:"monospace",fontSize:"0.72rem",outline:"none"}}/>
{estimacion&&(
<span style={{fontSize:"0.68rem",fontFamily:"monospace",color:estimacion.fuera?C.amber:C.green}}>
{estimacion.fuera
? `No entra en los próximos ${estimacion.bloques} bloques${estimacion.minUlt!=null?` — el último proyectado admite desde ${estimacion.minUlt} sat/vB`:""}. Tendría que esperar a que baje la demanda.`
: `Entraría en el bloque +${estimacion.posicion} — unos ${estimacion.minutos} minutos de media.`}
</span>
)}
</div>
<div style={{fontSize:"0.58rem",color:C.t3,fontFamily:"monospace",marginTop:6,lineHeight:1.5}}>
Estimación sobre la mempool de ahora mismo: si llegan transacciones con más comisión, tu sitio se retrasa. Y diez minutos es la media entre bloques, no una promesa.
</div>
</div>
</Card>
)}
{/* Línea temporal: lo que viene ─ ahora ─ lo que pasó */}
<SectionTitle accent={C.amber} icon="◉">Por minar — mempool</SectionTitle>
{mempool&&(
<div style={{display:"grid",gridTemplateColumns:`repeat(auto-fill,minmax(${isMobile?"110px":"140px"},1fr))`,gap:8}}>
{[{label:"Transacciones",val:fmt.num(mempool.count),color:C.amber},{label:"vSize total",val:fmt.mb(mempool.vsize),color:C.blue},{label:"Comisiones",val:fmt.sbtc(mempool.total_fee),color:C.green}].map(s=>(
<Card key={s.label} style={{padding:"12px 14px"}}><div style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:5}}>{s.label}</div><div style={{fontSize:"0.95rem",color:s.color,fontFamily:"monospace",fontWeight:700}}>{s.val}</div></Card>
))}
</div>
)}
{proyectados&&proyectados.length>0&&(
<Card>
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:12}}>Bloques proyectados</div>
{proyectados.slice(0,4).map((block,i)=>{
const feeRange = block.feeRange || [];
const maxFee = Math.round(Math.max(...feeRange, 1));
const minFee = Math.round(Math.min(...feeRange, 1));
const esElTuyo = estimacion && !estimacion.fuera && estimacion.posicion === i+1;
return (
<div key={i} style={{display:"flex",justifyContent:"space-between",alignItems:"center",flexWrap:"wrap",gap:6,padding:"8px 10px",marginBottom:4,borderRadius:6,background:esElTuyo?C.greenMuted:"transparent",border:`1px solid ${esElTuyo?C.green+"40":"transparent"}`}}>
<div>
<span style={{fontSize:"0.7rem",color:C.purple,fontFamily:"monospace",fontWeight:700}}>+{i+1} bloque{i>0?"s":""}</span>
<span style={{fontSize:"0.62rem",color:C.t2,marginLeft:8,fontFamily:"monospace"}}>{fmt.num(block.nTx)} txs</span>
{esElTuyo&&<span style={{fontSize:"0.6rem",color:C.green,marginLeft:8,fontFamily:"monospace",fontWeight:700}}>← tu transacción</span>}
</div>
<div style={{display:"flex",gap:6,flexWrap:"wrap"}}><Badge color={C.amber}>{minFee}{maxFee} sat/vB</Badge><Badge color={C.t3}>{fmt.mb(block.blockVSize)}</Badge></div>
</div>
);
})}
</Card>
)}
{/* El corte: aquí acaba lo que se espera y empieza lo que ya está */}
<div style={{display:"flex",alignItems:"center",gap:10,margin:"2px 0"}}>
<div style={{flex:1,height:1,background:`linear-gradient(to right,transparent,${C.green}60)`}}/>
<span style={{fontSize:"0.58rem",color:C.green,fontFamily:"monospace",letterSpacing:"0.2em",textTransform:"uppercase"}}>ahora</span>
<div style={{flex:1,height:1,background:`linear-gradient(to left,transparent,${C.green}60)`}}/>
</div>
<SectionTitle accent={C.purple} icon="◈">Ya minado — últimos bloques</SectionTitle>
{blocks.map((b,i)=>(
<div key={b.id||i} onClick={()=>selectBlock(b)} style={{display:"grid",gridTemplateColumns:"90px 1fr auto",alignItems:"center",gap:12,padding:"10px 14px",borderRadius:6,cursor:"pointer",background:selected&&selected.id===b.id?C.purpleMuted:C.bgCard,border:`1px solid ${selected&&selected.id===b.id?C.purple+"50":C.border}`}}
onMouseEnter={e=>e.currentTarget.style.background=C.bgHover} onMouseLeave={e=>e.currentTarget.style.background=selected&&selected.id===b.id?C.purpleMuted:C.bgCard}>
@@ -3256,9 +3483,12 @@
</div>
))}
{selected&&(
<Card style={{marginTop:8}} glow={C.purple}>
<Card style={{marginTop:8}} glow={C.purple} innerRef={detalleRef}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:12}}>
<span style={{fontFamily:"monospace",color:C.purple,fontWeight:700}}>Bloque #{fmt.num(selected.height)}</span>
<div style={{display:"flex",alignItems:"center",gap:10,flexWrap:"wrap"}}>
<span style={{fontFamily:"monospace",color:C.purple,fontWeight:700}}>Bloque #{fmt.num(selected.height)}</span>
<VerEnMempool base={base} tipo="bloque" id={selected.id}/>
</div>
<button onClick={()=>{setSelected(null);setBlockTxs(null);}} style={{background:"none",border:"none",color:C.t2,cursor:"pointer",fontSize:"1rem",fontFamily:"monospace"}}>✕</button>
</div>
<div style={{fontSize:"0.68rem",fontFamily:"monospace",color:C.t2,wordBreak:"break-all",marginBottom:12}}>{selected.id}</div>
@@ -3266,7 +3496,7 @@
<Tag label="Transacciones" value={fmt.num(selected.tx_count)} color={C.blue}/>
<Tag label="Tamaño" value={fmt.mb(selected.size)} color={C.t1}/>
<Tag label="Fees totales" value={selected.extras&&selected.extras.totalFees!=null?fmt.sbtc(selected.extras.totalFees):"-"} color={C.amber}/>
<Tag label="Fee mediana" value={selected.extras&&selected.extras.medianFee!=null?`${selected.extras.medianFee} sat/vB`:"-"} color={C.amber}/>
<Tag label="Fee mediana" value={selected.extras&&selected.extras.medianFee!=null?`${(Math.round(selected.extras.medianFee*100)/100)} sat/vB`:"-"} color={C.amber}/>
<Tag label="Minero" value={selected.extras&&selected.extras.pool?selected.extras.pool.name:"-"} color={C.green}/>
<Tag label="Minado" value={fmt.date(selected.timestamp)} color={C.t1}/>
</div>
@@ -3275,11 +3505,31 @@
{blockTxs&&blockTxs.slice(0,5).map((tx,i)=>(
<div key={i} style={{display:"flex",justifyContent:"space-between",alignItems:"center",padding:"6px 0",borderBottom:`1px solid ${C.border}`}}>
<span style={{fontSize:"0.68rem",fontFamily:"monospace",color:C.blue}}>{fmt.hash(tx.txid)}</span>
<div style={{display:"flex",gap:6}}><Badge color={C.amber}>{tx.fee} sat</Badge><Badge color={C.t3}>{tx.size}B</Badge></div>
<div style={{display:"flex",gap:6,alignItems:"center"}}><Badge color={C.amber}>{tx.fee} sat</Badge><Badge color={C.t3}>{tx.size}B</Badge><VerEnMempool base={base} tipo="tx" id={tx.txid} texto="↗"/></div>
</div>
))}
</Card>
)}
{/* Histórico de comisiones — para saber si hoy es caro o barato */}
<Card>
<div style={{display:"flex",flexDirection:isMobile?"column":"row",alignItems:isMobile?"flex-start":"center",justifyContent:"space-between",gap:isMobile?8:0,marginBottom:12}}>
<div style={{fontSize:"0.62rem",color:C.amber,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:isMobile?"0.1em":"0.2em",display:"flex",alignItems:"center",gap:8}}>
<span></span> Comisiones de la última semana
</div>
{feeStats&&(
<div style={{display:"flex",gap:8,flexWrap:"wrap"}}>
<Badge color={C.green}>mín {feeStats.min} sat/vB</Badge>
<Badge color={C.amber}>media {feeStats.avg} sat/vB</Badge>
<Badge color={C.red}>máx {feeStats.max} sat/vB</Badge>
</div>
)}
</div>
{!feeHistory ? <Spinner/> : <FeeChart data={feeHistory} isMobile={isMobile}/>}
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",marginTop:8}}>
sat/vB por hora · los fines de semana suelen ser más baratos
</div>
</Card>
</div>
);
}
@@ -3341,86 +3591,6 @@
);
}
function MempoolView({base}) {
const {get}=useApi(base);
const isMobile=useIsMobile();
const [data,setData]=useState(null);
const [fees,setFees]=useState(null);
const [histo,setHisto]=useState(null);
const [feeHistory,setFeeHistory]=useState(null);
const [period,setPeriod]=useState("1w");
useEffect(()=>{
Promise.all([get("/api/mempool",MOCK_MEMPOOL),get("/api/v1/fees/recommended",MOCK_FEES),get("/api/v1/fees/mempool-blocks",MOCK_MEMPOOLBLOCKS)])
.then(([m,f,h])=>{setData(m);setFees(f);setHisto(h);});
get("/api/v1/mining/blocks/fee-rates/1w", MOCK_FEE_HISTORY).then(d=>setFeeHistory(d));
},[base]);
if(!data||!fees)return<Spinner/>;
// Stats from fee history
const feeStats = feeHistory ? (() => {
const fs = feeHistory.map(d=>d.avgFee_50||d.avgFee||0).filter(v=>v>0);
if (fs.length===0) return null;
return {
min: Math.min(...fs),
max: Math.max(...fs),
avg: Math.round(fs.reduce((a,b)=>a+b,0)/fs.length),
};
})() : null;
return (
<div style={{display:"flex",flexDirection:"column",gap:12}}>
{!base&&<DemoBanner/>}
<SectionTitle accent={C.amber} icon="◉">Mempool</SectionTitle>
<div style={{display:"grid",gridTemplateColumns:`repeat(auto-fill,minmax(${isMobile?"110px":"140px"},1fr))`,gap:8}}>
{[{label:"Transacciones",val:fmt.num(data.count),color:C.amber},{label:"vSize total",val:fmt.mb(data.vsize),color:C.blue},{label:"Fee total",val:fmt.sbtc(data.total_fee),color:C.green}].map(s=>(
<Card key={s.label} style={{padding:"12px 14px"}}><div style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:5}}>{s.label}</div><div style={{fontSize:"0.95rem",color:s.color,fontFamily:"monospace",fontWeight:700}}>{s.val}</div></Card>
))}
</div>
<Card>
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:12}}>Fees Recomendados</div>
<div style={{display:"grid",gridTemplateColumns:`repeat(auto-fill,minmax(${isMobile?"105px":"130px"},1fr))`,gap:10}}>
{[{label:"Próx. bloque",val:`${fees.fastestFee} sat/vB`,color:C.red},{label:"30 min",val:`${fees.halfHourFee} sat/vB`,color:C.amber},{label:"1 hora",val:`${fees.hourFee} sat/vB`,color:C.green},{label:"Económico",val:`${fees.economyFee} sat/vB`,color:C.t2},{label:"Mínimo",val:`${fees.minimumFee} sat/vB`,color:C.t3}].map(f=>(
<div key={f.label} style={{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:4}}>{f.label}</div><div style={{fontSize:"0.88rem",color:f.color,fontFamily:"monospace",fontWeight:700}}>{f.val}</div></div>
))}
</div>
</Card>
{/* Fee Intelligence */}
<Card>
<div style={{display:"flex",flexDirection:isMobile?"column":"row",alignItems:isMobile?"flex-start":"center",justifyContent:"space-between",gap:isMobile?8:0,marginBottom:12}}>
<div style={{fontSize:"0.62rem",color:C.amber,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:isMobile?"0.1em":"0.2em",display:"flex",alignItems:"center",gap:8}}>
<span></span> Fee Intelligence — Última semana
</div>
{feeStats&&(
<div style={{display:"flex",gap:8,flexWrap:"wrap"}}>
<Badge color={C.green}>mín {feeStats.min} sat/vB</Badge>
<Badge color={C.amber}>media {feeStats.avg} sat/vB</Badge>
<Badge color={C.red}>máx {feeStats.max} sat/vB</Badge>
</div>
)}
</div>
{!feeHistory ? <Spinner/> : <FeeChart data={feeHistory} isMobile={isMobile}/>}
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",marginTop:8}}>
sat/vB por hora · Los fines de semana suelen tener fees más bajos
</div>
</Card>
{histo&&histo.length>0&&(
<Card>
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:12}}>Bloques Proyectados</div>
{histo.slice(0,4).map((block,i)=>{
const feeRange = block.feeRange || [];
const maxFee = Math.round(Math.max(...feeRange, 1));
const minFee = Math.round(Math.min(...feeRange, 1));
return <div key={i} style={{display:"flex",justifyContent:"space-between",alignItems:"center",flexWrap:"wrap",gap:6,padding:"8px 0",borderBottom:i<3?`1px solid ${C.border}`:"none"}}><div><span style={{fontSize:"0.7rem",color:C.purple,fontFamily:"monospace",fontWeight:700}}>+{i+1} bloque{i>0?"s":""}</span><span style={{fontSize:"0.62rem",color:C.t2,marginLeft:8,fontFamily:"monospace"}}>{fmt.num(block.nTx)} txs</span></div><div style={{display:"flex",gap:6,flexWrap:"wrap"}}><Badge color={C.amber}>{minFee}{maxFee} sat/vB</Badge><Badge color={C.t3}>{fmt.mb(block.blockVSize)}</Badge></div></div>;
})}
</Card>
)}
</div>
);
}
function Lab({base}) {
const {get}=useApi(base);
@@ -3474,7 +3644,7 @@
{err&&<div style={{padding:"10px 14px",background:C.redMuted,border:`1px solid ${C.red}30`,borderRadius:6,color:C.red,fontFamily:"monospace",fontSize:"0.75rem"}}>Error: {err}</div>}
{loading&&<Spinner/>}
{result&&result.type==="tx"&&<TxResult tx={result.data} onAnalyze={q=>{window.__setTab&&window.__setTab("auditoria",q);}}/>}
{result&&result.type==="address"&&<AddressResult addr={result.data} analysis={analysis}/>}
{result&&result.type==="address"&&<AddressResult addr={result.data} analysis={analysis} base={base}/>}
{result&&result.type==="block"&&<BlockResult block={result.data}/>}
<UTXOMap base={base}/>
</div>
@@ -4635,7 +4805,12 @@
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}`;
// Sin nodo configurado NO hay enlace. Antes esta línea caía en
// mempool.space público como respaldo, que es precisamente lo que esta
// herramienta existe para evitar: un clic ahí le dice a un tercero qué
// transacción estás mirando. Mejor no ofrecer el enlace que ofrecer uno
// que filtre.
const mempoolUrl = base ? `${String(base).replace(/\/$/,"")}/tx/${txData.txid}` : null;
const inputs = txData.vin || [];
const [hover,setHover]=useState(false);
@@ -4694,10 +4869,17 @@
}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",gap:8,flexWrap:"wrap",marginBottom:6}}>
<div style={{display:"flex",alignItems:"center",gap:8,position:"relative"}}>
<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>
{mempoolUrl ? (
<a href={mempoolUrl} target="_blank" rel="noopener noreferrer"
title="Abrir en tu propia instancia de Mempool — no sale de tu red"
style={{fontSize:"0.64rem",fontFamily:"monospace",color:C.blue,textDecoration:"none",wordBreak:"break-all"}}>
{abrevTxid(txData.txid)} ↗
</a>
) : (
<span style={{fontSize:"0.64rem",fontFamily:"monospace",color:C.blue,wordBreak:"break-all"}}>
{abrevTxid(txData.txid)}
</span>
)}
<button
onMouseEnter={()=>setHover(true)}
onMouseLeave={()=>setHover(false)}
@@ -4891,8 +5073,10 @@
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 style={{fontSize:"0.65rem",fontFamily:"monospace",color:C.t1,display:"flex",alignItems:"center",gap:8,flexWrap:"wrap"}}>
<span>Input #{i+1}<span style={{color:C.t2,marginLeft:8}}>{fmt.num(vin.prevout?.value||0)} sat</span></span>
{vin.prevout?.scriptpubkey_address&&<VerEnMempool base={base} tipo="direccion" id={vin.prevout.scriptpubkey_address} texto="↗ dirección"/>}
{vin.txid&&<VerEnMempool base={base} tipo="tx" id={vin.txid} texto="↗ tx origen"/>}
</div>
{!est.child&&!est.loading&&(
<button onClick={()=>seguir(i)}
@@ -5215,6 +5399,35 @@
</div>
)}
{/* Polvo recibido — va arriba porque es accionable AHORA: lo
único que hay que hacer es no gastarlo, y para eso hay que
saber que está ahí. */}
{walletReport.polvoSinGastar&&walletReport.polvoSinGastar.length>0&&(
<div style={{padding:"10px 14px",background:C.amberMuted,border:`1px solid ${C.amber}50`,borderRadius:8,marginBottom:14}}>
<div style={{fontSize:"0.66rem",color:C.amber,fontFamily:"monospace",fontWeight:700,marginBottom:6}}>
⚠ Has recibido {walletReport.polvoSinGastar.length} moneda(s) de polvo sin gastar
</div>
<div style={{fontSize:"0.64rem",color:C.t1,lineHeight:1.7,marginBottom:8}}>
Son importes ridículos que alguien envió a tus direcciones. Por sí solos no hacen nada y no valen nada. <strong>El riesgo aparece si los gastas junto a otras monedas</strong>: en ese momento quedan todas enlazadas con quien te mandó el polvo, que es justo lo que persigue quien lo hace.
<br/><strong style={{color:C.green}}>Qué hacer: nada.</strong> Déjalas quietas. Si tu monedero permite congelar monedas (Sparrow: clic derecho → <em>Freeze UTXO</em>), márcalas para no gastarlas por accidente al construir una transacción.
</div>
<div style={{display:"flex",flexDirection:"column",gap:3}}>
{walletReport.polvoSinGastar.slice(0,6).map((p,i)=>(
<div key={i} style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace",wordBreak:"break-all"}}>
{fmt.num(p.value)} sat · {p.address} · {p.txid.slice(0,16)}…:{p.vout}
{p.tecnico&&<span style={{color:C.t3}}> · por debajo del mínimo económico</span>}
</div>
))}
{walletReport.polvoSinGastar.length>6&&<div style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace"}}>…y {walletReport.polvoSinGastar.length-6} más</div>}
</div>
</div>
)}
{walletReport.polvoRecibido&&walletReport.polvoSinGastar&&walletReport.polvoRecibido.length>walletReport.polvoSinGastar.length&&(
<div style={{padding:"9px 14px",background:C.bgCard,border:`1px solid ${C.border}`,borderRadius:8,marginBottom:14,fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",lineHeight:1.6}}>
Además, {walletReport.polvoRecibido.length - walletReport.polvoSinGastar.length} moneda(s) de polvo que recibiste <strong>ya se gastaron</strong>. Si se mezclaron con otras monedas tuyas, esa vinculación ya está hecha y no se puede deshacer — figura en los clusters de abajo si es el caso. Se indica para que sepas de dónde viene, no para preocuparte por algo que ya pasó.
</div>
)}
{/* Bloque 1: Salud general */}
<div style={{display:"flex",alignItems:"center",gap:14,padding:"12px 14px",background:C.bgCard,borderRadius:8,marginBottom:14,border:`1px solid ${walletReport.health.color}30`}}>
<div style={{width:48,height:48,borderRadius:"50%",border:`3px solid ${walletReport.health.color}`,display:"flex",alignItems:"center",justifyContent:"center",flexShrink:0}}>
@@ -5306,6 +5519,12 @@
{t.txid.slice(0,20)}…
</div>
<div style={{fontSize:"0.58rem",color:bandColor,fontFamily:"monospace",fontWeight:600,flexShrink:0}}>{t.band||"—"}</div>
{/* El clic en la fila abre el análisis interno; este
enlace lleva al explorador, y por eso corta la
propagación: son dos destinos distintos. */}
<span onClick={e=>e.stopPropagation()} style={{flexShrink:0}}>
<VerEnMempool base={base} tipo="tx" id={t.txid} texto="↗"/>
</span>
</div>
);
})}
@@ -5378,7 +5597,10 @@
<Card glow={C.purple}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"flex-start",marginBottom:12}}>
<div>
<div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",marginBottom:4}}>TRANSACCIÓN ANALIZADA</div>
<div style={{display:"flex",alignItems:"center",gap:8,marginBottom:4,flexWrap:"wrap"}}>
<span style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace"}}>TRANSACCIÓN ANALIZADA</span>
<VerEnMempool base={base} tipo="tx" id={tx.txid}/>
</div>
<div style={{fontSize:"0.68rem",fontFamily:"monospace",color:C.blue,wordBreak:"break-all"}}>{tx.txid}</div>
{labels&&labels.get(tx.txid)&&(
<div style={{marginTop:8,display:"flex",alignItems:"center",gap:8,padding:"6px 10px",background:C.purpleMuted,border:`1px solid ${C.purple}60`,borderRadius:6}}>
@@ -5753,7 +5975,10 @@
<div style={{display:"flex",flexDirection:"column",gap:6}}>
{report.attributed.map((a,i)=>(
<div key={i} style={{padding:"8px 10px",background:C.bgCard,border:`1px solid ${C.amber}30`,borderRadius:6}}>
<div style={{fontSize:"0.6rem",color:C.t1,fontFamily:"monospace",marginBottom:4,wordBreak:"break-all"}}>{a.address}</div>
<div style={{display:"flex",alignItems:"center",gap:8,marginBottom:4,flexWrap:"wrap"}}>
<span style={{fontSize:"0.6rem",color:C.t1,fontFamily:"monospace",wordBreak:"break-all"}}>{a.address}</span>
<VerEnMempool base={base} tipo="direccion" id={a.address} texto="↗"/>
</div>
<div style={{display:"flex",flexDirection:"column",gap:2}}>
{a.fundamentos.map((f,j)=>(
<div key={j} style={{fontSize:"0.56rem",color:C.t2,fontFamily:"monospace"}}>
@@ -5776,7 +6001,10 @@
<div style={{display:"flex",flexDirection:"column",gap:4}}>
{report.unspentFunds.map((u,i)=>(
<div key={i} style={{padding:"7px 10px",background:C.greenMuted,border:`1px solid ${C.green}30`,borderRadius:6}}>
<div style={{fontSize:"0.6rem",color:C.t1,fontFamily:"monospace",wordBreak:"break-all"}}>{u.address}</div>
<div style={{display:"flex",alignItems:"center",gap:8,flexWrap:"wrap"}}>
<span style={{fontSize:"0.6rem",color:C.t1,fontFamily:"monospace",wordBreak:"break-all"}}>{u.address}</span>
<VerEnMempool base={base} tipo="direccion" id={u.address} texto="↗"/>
</div>
<div style={{fontSize:"0.58rem",color:C.green,fontFamily:"monospace",fontWeight:700}}>{fmt.num(u.amount)} sat</div>
</div>
))}
@@ -5894,7 +6122,7 @@
);
}
function AddressResult({addr, analysis}) {
function AddressResult({addr, analysis, base}) {
const c=addr.chain_stats||{funded_txo_sum:0,spent_txo_sum:0,tx_count:0};
const m=addr.mempool_stats||{funded_txo_sum:0,spent_txo_sum:0};
const balance=c.funded_txo_sum-c.spent_txo_sum;
@@ -5909,7 +6137,13 @@
return (
<div style={{display:"flex",flexDirection:"column",gap:10}}>
<Card glow={C.green}>
<div style={{marginBottom:12}}><div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",marginBottom:4}}>DIRECCIÓN</div><div style={{fontSize:"0.72rem",fontFamily:"monospace",color:C.green,wordBreak:"break-all"}}>{addr.address}</div></div>
<div style={{marginBottom:12}}>
<div style={{display:"flex",alignItems:"center",gap:8,marginBottom:4,flexWrap:"wrap"}}>
<span style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace"}}>DIRECCIÓN</span>
<VerEnMempool base={base} tipo="direccion" id={addr.address}/>
</div>
<div style={{fontSize:"0.72rem",fontFamily:"monospace",color:C.green,wordBreak:"break-all"}}>{addr.address}</div>
</div>
<div style={{display:"grid",gridTemplateColumns:"repeat(auto-fill,minmax(130px,1fr))",gap:12,paddingTop:12,borderTop:`1px solid ${C.border}`}}>
<Tag label="Balance" value={importesIncompletos?noDisponible:fmt.btc(balance)} color={importesIncompletos?C.t3:C.green}/>
<Tag label="No confirmado" value={unconf!==0?fmt.btc(unconf):"0"} color={unconf>0?C.amber:C.t2}/>
@@ -6645,8 +6879,7 @@
const TABS=[
{id:"node", label:"Nodo", icon:"◉"},
{id:"blocks", label:"Bloques", icon:"◈"},
{id:"mempool", label:"Mempool", icon:"≋"},
{id:"red", label:"Cadena", icon:"◈"},
{id:"lab", label:"LAB", icon:"⌕"},
{id:"auditoria", label:"AUDITORÍA", icon:"⌖"},
{id:"peritaje", label:"PERITAJE", icon:"🔍"},
@@ -6898,8 +7131,7 @@
</div>
<div style={{padding:"18px 14px",maxWidth:860,margin:"0 auto",width:"100%",flex:1}}>
{tab==="node" && <NodeOverview base={config.url}/>}
{tab==="blocks" && <BlockExplorer base={config.url}/>}
{tab==="mempool" && <MempoolView base={config.url}/>}
{tab==="red" && <RedView base={config.url}/>}
<div style={{display:tab==="lab"?"block":"none"}}><Lab base={config.url}/></div>
<div style={{display:tab==="auditoria"?"block":"none"}}><Auditoria base={config.url} initialQuery={auditQuery}/></div>
{tab==="peritaje" && <PeritajeForense base={config.url}/>}