Compare commits

..
2 Commits
Author SHA1 Message Date
Aitor 2a1c7254e2 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.
2026-08-08 18:26:01 +02:00
Aitor d3e204d396 fix: el checklist normalizaba un swap alto que no era normal
Decía 'Normal en nodos con índice completo' ante un swap al 99%. En el caso
real que lo destapó no lo era: Fulcrum tenía db_mem a 16 GB, más de lo que
cabe en la máquina, y el sistema llevaba quince días sin margen ante un pico
de memoria. Ese mensaje es la razón de que se ignorara tanto tiempo.

Umbral del 95% al 50%, error por encima del 80%, detección del caso 'swap
alto con RAM libre' (firma de memoria sobrerreservada), y el aviso apunta a
db_mem e incluye el comando para ver quién ocupa el swap.
2026-08-08 15:58:26 +02:00
3 changed files with 458 additions and 123 deletions
+62
View File
@@ -10,8 +10,70 @@ y el versionado sigue [Versionado Semántico](https://semver.org/lang/es/):
- **MENOR** — características nuevas que no rompen lo anterior
- **PARCHE** — arreglos de errores
---
## [1.14.0] — 2026-08-08
### Añadido
- **Mempool y Bloques se fusionan en una sola pestaña, «Cadena».** Eran el
mismo eje partido por la mitad: los "bloques proyectados" vivían en Mempool y
los confirmados en Bloques, cuando es una línea temporal con el ahora en el
corte. Ahora se lee de arriba abajo: comisiones, lo que está por minar, un
separador **AHORA**, y los bloques ya minados.
- Nuevo: **«si pagas X sat/vB, ¿cuándo entra?»**. Ni la vista de mempool ni la
de bloques respondían la pregunta que de verdad se hace quien mira las
comisiones — daban los datos por separado y dejaban el cálculo al ojo.
Escribes una tarifa y te dice en qué bloque proyectado caería, marcándolo
en la lista. Con dos avisos escritos en la propia interfaz: el cálculo se
hace sobre la mempool de este momento, y diez minutos es la media entre
bloques, no una promesa.
- El detalle de un bloque se abría al final de la lista, a quince filas del
bloque pulsado, así que parecía que el clic no hacía nada. Ahora la vista
se desplaza hasta él.
- **Aviso de polvo recibido** en el informe de wallet. El analizador ya sabía
reconocer el patrón al mirar una transacción suelta, pero eso no sirve si no
te avisa cuando te pasa a ti. Recorre las salidas de importe ínfimo hacia
direcciones propias y distingue las que siguen sin gastar —donde el consejo
todavía sirve— de las ya gastadas, donde la vinculación ya está hecha.
El consejo es el contrario del instinto: **no hagas nada**, déjalas quietas y
congélalas en el monedero si puedes. El daño solo ocurre al mezclarlas.
- **Enlaces «ver en Mempool»** en nueve puntos: transacción analizada, detalle
de bloque y sus transacciones, dirección del explorador, historial del informe
de wallet, direcciones atribuidas y fondos localizados del peritaje, y en el
rastro de procedencia la dirección de cada entrada y su transacción de origen.
Se construyen **siempre** desde la URL configurada; sin nodo, no hay enlace.
### Corregido
- **El rastro de procedencia enlazaba a mempool.space público** cuando no había
nodo configurado (`base ? … : "https://mempool.space/tx/…"`). Un clic ahí le
dice a un tercero qué transacción estás investigando, que es exactamente lo
contrario de para lo que existe esta herramienta. Sin nodo ya no hay enlace:
se muestra el txid en texto plano. Auditado que no queda ningún enlace
saliente en toda la aplicación.
- La fee mediana del detalle de bloque se mostraba con dieciséis decimales.
### Nota de diseño
- Se descartó hacer persistentes las etiquetas BIP-329. Vinculan direcciones con
descripciones en lenguaje natural ("ahorro", "pago a…") y guardarlas dejaría
en disco justo el mapa que un atacante querría. Que vivan solo en memoria no
es una carencia, es la misma decisión que ya se aplica a la URL del nodo.
---
## [1.13.0] — 2026-08-08
### Cambiado
- **El checklist decía que un swap alto era "normal en nodos con índice
completo".** No lo es, y decirlo hace que se ignore durante semanas un
síntoma con causa y con arreglo. El caso que lo cambió: swap al 99% con 18 GB
de RAM libre, porque Fulcrum tenía `db_mem` a 16 GB — más de lo que cabe en
la máquina junto al resto de servicios. Con el swap lleno el sistema se queda
sin margen y, ante un pico, el OOM killer elige él a quién mata.
- El umbral baja del 95% al 50%, que es cuando conviene mirarlo y no cuando
ya es tarde, y pasa a estado de error por encima del 80%.
- Si el swap sube **teniendo RAM libre**, el aviso lo señala: es la firma de
un servicio con más memoria reservada de la que cabe, no de presión real.
- Apunta al sospechoso habitual (`db_mem` de Fulcrum, que se sube para
acelerar la sincronización inicial y es fácil olvidarse de bajar) e incluye
el comando para ver qué proceso ocupa el swap.
- Misma corrección en la explicación de SWAP de los DOCS.
### Añadido
- **Nueva comprobación: direcciones que quedan vinculadas entre sí.** Era el
hueco de fondo del analizador: medía la privacidad desde el lado de quien
+364 -115
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 ──────────
@@ -3030,14 +3127,31 @@
}
// ── 5. Swap alto ──────────────────────────────────────────────
if (sysData?.swap) {
const pct = sysData.swap.percent || 0;
// Este aviso decía antes que un swap alto era "normal en nodos con
// índice completo". No lo es, y decirlo hace que se ignore durante
// semanas un síntoma que sí tiene causa y sí tiene arreglo. El caso
// real que lo cambió: swap al 99% con 18 GB de RAM libre, porque
// Fulcrum tenía db_mem configurado a 16 GB — más de lo que cabe en la
// máquina junto al resto de servicios. Con el swap lleno, el sistema
// se queda sin margen y ante un pico actúa el OOM killer, que elige
// él a quién mata.
//
// El umbral baja a 50% porque a partir de ahí ya conviene mirarlo, no
// cuando queda un 5% libre y es tarde.
const swapStats = sysData?.swap || (sysData?.ram ? { percent: sysData.ram.swap_percent, used_gb: sysData.ram.swap_used_gb } : null);
if (swapStats) {
const pct = swapStats.percent || 0;
const ramLibre = sysData?.ram?.free_gb;
// Swap ocupado teniendo RAM libre es la firma de un servicio con más
// memoria reservada de la que cabe: el kernel apartó páginas y no las
// devuelve por su cuenta.
const conRamLibre = typeof ramLibre === "number" && ramLibre > 2 && pct > 50;
results.push({
id:"swap_usage", label:"Uso de SWAP",
status: pct > 95 ? "warn" : "ok",
detail: pct > 95
? `SWAP al ${pct}%. Fulcrum está usando casi toda la memoria de intercambio. Normal en nodos con índice completo, pero monitoriza que el OOM Killer no actúe.`
: `SWAP al ${pct}%. Uso normal.`,
status: pct > 80 ? "error" : pct > 50 ? "warn" : "ok",
detail: pct > 50
? `SWAP al ${pct}% (${swapStats.used_gb || "?"} GB).${conRamLibre ? ` Y eso ocurriendo con ${ramLibre} GB de RAM libre, que es la señal típica de un servicio con más memoria reservada de la que cabe en la máquina.` : ""} Con el swap lleno el sistema se queda sin margen: ante un pico de memoria actúa el OOM killer, y elige él qué proceso mata — puede tocarle a bitcoind o a Fulcrum en mitad de una escritura. El sospechoso habitual es \`db_mem\` en la configuración de Fulcrum: acelera la sincronización inicial y mucha gente lo sube para eso y no lo vuelve a bajar. Para ver quién ocupa el swap: for p in /proc/[0-9]*; do s=$(awk '/VmSwap/{print $2}' $p/status 2>/dev/null); [ -n "$s" ] && [ "$s" -gt 0 ] && echo "$s KB $(cat $p/comm)"; done | sort -rn | head`
: `SWAP al ${pct}%. Sin presión de memoria.`,
});
}
@@ -3217,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}>
@@ -3239,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}}>
<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>
@@ -3249,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>
@@ -3258,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>
);
}
@@ -3324,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);
@@ -3457,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>
@@ -4618,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);
@@ -4677,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"}}>
{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)}
@@ -4874,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)}
@@ -5198,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}}>
@@ -5289,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>
);
})}
@@ -5361,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}}>
@@ -5736,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"}}>
@@ -5759,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>
))}
@@ -5877,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;
@@ -5892,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}/>
@@ -6628,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:"🔍"},
@@ -6714,7 +6964,7 @@
<H>Sistema</H>
<Li><strong style={{color:C.t1}}>CPU por núcleo</strong> — uso individual de cada núcleo. Calculado desde <Code>/proc/stat</Code>, equivalente a htop.</Li>
<Li><strong style={{color:C.t1}}>RAM</strong> — memoria realmente usada por procesos, excluyendo caché del kernel (mismo cálculo que htop).</Li>
<Li><strong style={{color:C.t1}}>SWAP</strong> — memoria de intercambio en uso. Un valor alto sostenido (como el de Fulcrum) es normal pero conviene monitorizarlo.</Li>
<Li><strong style={{color:C.t1}}>SWAP</strong> — memoria de intercambio en uso. Debería estar cerca de cero. Un valor alto <em>no</em> es normal aunque se repita: significa que algún servicio tiene reservada más memoria de la que cabe en la máquina, y deja al sistema sin margen ante un pico. Si sube teniendo RAM libre, el sospechoso habitual es <Code>db_mem</Code> en la configuración de Fulcrum — se sube para acelerar la sincronización inicial y es fácil olvidarse de bajarlo.</Li>
<Li><strong style={{color:C.t1}}>Procesos destacados</strong> — los procesos que más memoria consumen en tiempo real.</Li>
<H>Logs en tiempo real</H>
<P>Panel de logs de Bitcoin Core y Fulcrum. Muestra las últimas 80 entradas con filtrado por nivel (todos / warn / error). Pulsa ↻ para actualizar.</P>
@@ -6881,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}/>}
+27 -3
View File
@@ -290,12 +290,36 @@ async function getBitcoinInfo() {
// connections_in/out existen desde Core 0.21; fallback a getpeerinfo si no
let inbound = netinfo.connections_in;
let outbound = netinfo.connections_out;
let peerList = null;
if (inbound === undefined || outbound === undefined) {
const peers = await rpcCall("getpeerinfo").catch(() => null);
inbound = peers ? peers.filter(p => p.inbound).length : 0;
outbound = peers ? peers.filter(p => !p.inbound).length : 0;
peerList = await rpcCall("getpeerinfo").catch(() => null);
inbound = peerList ? peerList.filter(p => p.inbound).length : 0;
outbound = peerList ? peerList.filter(p => !p.inbound).length : 0;
}
// Por qué red entra cada peer, y qué direcciones anuncia el nodo.
// Hace falta para no dar por hecho que tener conexiones entrantes expone
// la IP: si entran por Tor o I2P no la ven, y si el nodo no anuncia
// ninguna dirección de clearnet, tampoco la publica a la red.
let inboundNets = null, clearnetLocal = null;
try {
if (!peerList) peerList = await rpcCall("getpeerinfo");
if (Array.isArray(peerList)) {
inboundNets = {};
for (const p of peerList.filter(p => p.inbound)) {
const n = p.network || "desconocida";
inboundNets[n] = (inboundNets[n] || 0) + 1;
}
}
const locales = netinfo.localaddresses || [];
clearnetLocal = locales
.filter(a => !/\.onion$|\.i2p$/i.test(a.address || ""))
.map(a => a.address);
} catch { /* si falla, se informa como no comprobado, no como ausencia */ }
return {
inboundNets, clearnetLocal,
localAddrCount: (netinfo.localaddresses || []).length,
version: `v${Math.floor(netinfo.version / 10000)}.${Math.floor((netinfo.version % 10000) / 100)}.${netinfo.version % 100}`,
blocks: info.blocks,
headers: info.headers,