From 643236e47bfcf902633dee2d0fa0b51ce241ef16 Mon Sep 17 00:00:00 2001 From: pikaro Date: Sat, 8 Aug 2026 18:26:01 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20pesta=C3=B1a=20Cadena=20unificada,=20av?= =?UTF-8?q?iso=20de=20polvo=20y=20enlaces=20a=20la=20instancia=20propia?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 46 +++++ dashboard.html | 458 ++++++++++++++++++++++++++++++++++------------ system-metrics.js | 30 ++- 3 files changed, 418 insertions(+), 116 deletions(-) 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}) =>
{children}
; + const Card = ({children, style, glow, innerRef}) =>
{children}
; const SectionTitle = ({children, accent, icon}) => { const a=accent||C.green; return
{icon&&{icon}}

{children}

; }; const Tag = ({label,value,color}) =>
{label}{value!=null?value:"-"}
; + + // ── 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&&( + +
Comisiones recomendadas
+
+ {[{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=>( +
{f.label}
{f.val}
+ ))} +
+ {/* La pregunta real */} +
+
Si pagas… ¿cuándo entra?
+
+ 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&&( +
+ {[{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=>( +
{s.label}
{s.val}
+ ))} +
+ )} + + {proyectados&&proyectados.length>0&&( + +
Bloques proyectados
+ {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 ( +
+
+ +{i+1} bloque{i>0?"s":""} + {fmt.num(block.nTx)} txs + {esElTuyo&&← tu transacción} +
+
{minFee}–{maxFee} sat/vB{fmt.mb(block.blockVSize)}
+
+ ); + })} +
+ )} + + {/* El corte: aquí acaba lo que se espera y empieza lo que ya está */} +
+
+ ahora +
+
+ + Ya minado — últimos bloques {blocks.map((b,i)=>(
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 @@
))} {selected&&( - +
- Bloque #{fmt.num(selected.height)} +
+ Bloque #{fmt.num(selected.height)} + +
{selected.id}
@@ -3266,7 +3496,7 @@ - +
@@ -3275,11 +3505,31 @@ {blockTxs&&blockTxs.slice(0,5).map((tx,i)=>(
{fmt.hash(tx.txid)} -
{tx.fee} sat{tx.size}B
+
{tx.fee} sat{tx.size}B
))} )} + + {/* Histórico de comisiones — para saber si hoy es caro o barato */} + +
+
+ Comisiones de la última semana +
+ {feeStats&&( +
+ mín {feeStats.min} sat/vB + media {feeStats.avg} sat/vB + máx {feeStats.max} sat/vB +
+ )} +
+ {!feeHistory ? : } +
+ sat/vB por hora · los fines de semana suelen ser más baratos +
+
); } @@ -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; - - // 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 ( -
- {!base&&} - Mempool -
- {[{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=>( -
{s.label}
{s.val}
- ))} -
- -
Fees Recomendados
-
- {[{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=>( -
{f.label}
{f.val}
- ))} -
-
- - {/* Fee Intelligence */} - -
-
- Fee Intelligence — Última semana -
- {feeStats&&( -
- mín {feeStats.min} sat/vB - media {feeStats.avg} sat/vB - máx {feeStats.max} sat/vB -
- )} -
- {!feeHistory ? : } -
- sat/vB por hora · Los fines de semana suelen tener fees más bajos -
-
- - {histo&&histo.length>0&&( - -
Bloques Proyectados
- {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
+{i+1} bloque{i>0?"s":""}{fmt.num(block.nTx)} txs
{minFee}–{maxFee} sat/vB{fmt.mb(block.blockVSize)}
; - })} -
- )} -
- ); - } function Lab({base}) { const {get}=useApi(base); @@ -3474,7 +3644,7 @@ {err&&
Error: {err}
} {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 @@ }}>
- - {abrevTxid(txData.txid)} ↗ - + {mempoolUrl ? ( + + {abrevTxid(txData.txid)} ↗ + + ) : ( + + {abrevTxid(txData.txid)} + + )}
)} + {/* 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&&( +
+
+ ⚠ Has recibido {walletReport.polvoSinGastar.length} moneda(s) de polvo sin gastar +
+
+ Son importes ridículos que alguien envió a tus direcciones. Por sí solos no hacen nada y no valen nada. El riesgo aparece si los gastas junto a otras monedas: en ese momento quedan todas enlazadas con quien te mandó el polvo, que es justo lo que persigue quien lo hace. +
Qué hacer: nada. Déjalas quietas. Si tu monedero permite congelar monedas (Sparrow: clic derecho → Freeze UTXO), márcalas para no gastarlas por accidente al construir una transacción. +
+
+ {walletReport.polvoSinGastar.slice(0,6).map((p,i)=>( +
+ {fmt.num(p.value)} sat · {p.address} · {p.txid.slice(0,16)}…:{p.vout} + {p.tecnico&& · por debajo del mínimo económico} +
+ ))} + {walletReport.polvoSinGastar.length>6&&
…y {walletReport.polvoSinGastar.length-6} más
} +
+
+ )} + {walletReport.polvoRecibido&&walletReport.polvoSinGastar&&walletReport.polvoRecibido.length>walletReport.polvoSinGastar.length&&( +
+ Además, {walletReport.polvoRecibido.length - walletReport.polvoSinGastar.length} moneda(s) de polvo que recibiste ya se gastaron. 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ó. +
+ )} + {/* Bloque 1: Salud general */}
@@ -5306,6 +5519,12 @@ {t.txid.slice(0,20)}…
{t.band||"—"}
+ {/* 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. */} + e.stopPropagation()} style={{flexShrink:0}}> + +
); })} @@ -5378,7 +5597,10 @@
-
TRANSACCIÓN ANALIZADA
+
+ TRANSACCIÓN ANALIZADA + +
{tx.txid}
{labels&&labels.get(tx.txid)&&(
@@ -5753,7 +5975,10 @@
{report.attributed.map((a,i)=>(
-
{a.address}
+
+ {a.address} + +
{a.fundamentos.map((f,j)=>(
@@ -5776,7 +6001,10 @@
{report.unspentFunds.map((u,i)=>(
-
{u.address}
+
+ {u.address} + +
{fmt.num(u.amount)} sat
))} @@ -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 (
-
DIRECCIÓN
{addr.address}
+
+
+ DIRECCIÓN + +
+
{addr.address}
+
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 @@
{tab==="node" && } - {tab==="blocks" && } - {tab==="mempool" && } + {tab==="red" && }
{tab==="peritaje" && } diff --git a/system-metrics.js b/system-metrics.js index afc54bf..558612b 100644 --- a/system-metrics.js +++ b/system-metrics.js @@ -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,