diff --git a/CHANGELOG.md b/CHANGELOG.md
index fb26d7f..4c3c31a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,52 @@ 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
diff --git a/dashboard.html b/dashboard.html
index 9b3b875..158e627 100644
--- a/dashboard.html
+++ b/dashboard.html
@@ -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 {children}; };
- const Card = ({children, style, glow}) =>
;
+
+ // ── 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 (
+ {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 ↗"}
+
+ );
+ };
const Pulse = ({color}) => ;
const Spinner = () =>
◌ cargando
;
const DemoBanner = () =>
Datos de demo — configura tu nodo en CONFIG para ver datos reales
;
@@ -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;
+
return (
-
+
{!base&&}
- Bloques Recientes
+
+ {/* Comisiones — el contexto para decidir */}
+ {fees&&(
+
+
+ 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&&(
+
+ {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.`}
+
+ )}
+
+
+ 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.
+
+
+
+ )}
+
+ {/* Línea temporal: lo que viene ─ ahora ─ lo que pasó */}
+ Por minar — mempool
+ {mempool&&(
+
}
{loading&&}
{result&&result.type==="tx"&&{window.__setTab&&window.__setTab("auditoria",q);}}/>}
- {result&&result.type==="address"&&}
+ {result&&result.type==="address"&&}
{result&&result.type==="block"&&}
@@ -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 @@
}}>