Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98621f956e | ||
|
|
d3caaf6186 | ||
|
|
96517894c5 | ||
|
|
0acfb35bf8 | ||
|
|
bb9b92d9b9 | ||
|
|
fd9127880b | ||
|
|
0223bb16ef | ||
|
|
a696e55bef | ||
|
|
8a5efad2ed | ||
|
|
6f721a535c | ||
|
|
020f9318aa | ||
|
|
fd2e38fd45 | ||
|
|
924b9069aa | ||
|
|
e0bc42c34e | ||
|
|
eac6208405 | ||
|
|
fdd80b689a | ||
|
|
f15d82a352 | ||
|
|
826b9cb8e2 | ||
|
|
29e8e184e8 | ||
|
|
2f5cc9a0ec | ||
|
|
5f4f891d1f | ||
|
|
27f23cc967 | ||
|
|
b37b9a125a | ||
|
|
c86134cc24 | ||
|
|
ebc1a8017f | ||
|
|
0ecd54eb1d | ||
|
|
84e16cb3fd | ||
|
|
d6dc8f6a90 | ||
|
|
14a33ed29a | ||
|
|
f5cffa3f8a | ||
|
|
de3ab5cc7e | ||
|
|
55297ecae6 | ||
|
|
2c46ceda31 | ||
|
|
1c25622b0e | ||
|
|
031c55714f | ||
|
|
35c74ea8f2 | ||
|
|
1ddacdf86e | ||
|
|
8ae135e739 | ||
|
|
4354e661c2 | ||
|
|
68ef8f2179 | ||
|
|
26a8650583 | ||
|
|
2b68a7b7c7 | ||
|
|
22ea8b5add | ||
|
|
aa7f23de33 | ||
|
|
0fc9cb8980 | ||
|
|
625fd9e989 | ||
|
|
d0b10d4a99 | ||
|
|
01fe01d39d | ||
|
|
57f9576860 | ||
|
|
dd78d35bdb | ||
|
|
19211af4c8 | ||
|
|
55a8cf456b | ||
|
|
a260e376bb | ||
|
|
37ae3aef70 | ||
|
|
ee67c2164e | ||
|
|
11b74c92f5 | ||
|
|
5aedec0cb3 | ||
|
|
fe2aa0597c | ||
|
|
6626cd1325 | ||
|
|
cb4535c35d | ||
|
|
e71bd08f76 | ||
|
|
8f902ba06e | ||
|
|
e64292531e |
@@ -29,4 +29,3 @@ MEJORAS.md
|
||||
COHERENCIA.md
|
||||
|
||||
GIT.md
|
||||
REPASO.md
|
||||
|
||||
@@ -10,99 +10,6 @@ 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
|
||||
*construye* la transacción (¿se ve mi cambio?, ¿pago cifras redondas?) y no
|
||||
desde el lado de los dueños de las monedas gastadas, que es donde el daño es
|
||||
irreversible. Gastar N direcciones distintas a la vez las enlaza para siempre
|
||||
por CIOH, y eso no se decía en ningún sitio. El peso sube con el número de
|
||||
direcciones y no aplica en CoinJoin, donde romper esa vinculación es el
|
||||
objetivo.
|
||||
|
||||
### Corregido
|
||||
- **El explorador mostraba saldo cero en direcciones con mucho historial.** El
|
||||
backend de Mempool sobre Fulcrum devuelve los importes a cero cuando la
|
||||
dirección tiene muchas transacciones, aunque el contador venga bien — y la app
|
||||
los presentaba como ciertos. Una dirección con 492 transacciones aparecía con
|
||||
"Balance: 0,00000000 BTC". Ahora se detecta la inconsistencia (tener
|
||||
transacciones y no haber recibido nada es imposible en la cadena), los
|
||||
importes se muestran como "no disponible" y se explica por qué. El contador de
|
||||
transacciones y el historial, que sí son correctos, se mantienen.
|
||||
- Mismo arreglo en el perfil de dirección del peritaje: antes concluía "no es
|
||||
hot wallet" cuando en realidad no había podido evaluar la señal de flujo.
|
||||
- **El fingerprinting atribuía transacciones a Electrum por AUSENCIA de rasgos**
|
||||
(locktime 0, sin RBF, sequence por defecto). Es justo al revés: Electrum
|
||||
moderno pone locktime a la altura actual y señaliza RBF. Lo que esas señales
|
||||
describen no es un monedero concreto, sino software que no configura nada.
|
||||
Ahora Electrum exige señales positivas y aparece un resultado nuevo, **"sin
|
||||
rasgos distintivos"**, que además no penaliza: no dejar huella es lo contrario
|
||||
de tener una huella identificable.
|
||||
|
||||
---
|
||||
## [1.12.1] — 2026-07-27
|
||||
### Corregido
|
||||
|
||||
+133
-479
@@ -967,47 +967,18 @@
|
||||
// ─── Electrum ──────────────────────────────────────────────────
|
||||
// Guardia: Electrum usa P2WPKH por defecto. Si no hay inputs P2WPKH,
|
||||
// no tiene sentido puntuar Electrum (evita falsos positivos en P2TR/P2PKH).
|
||||
// Antes se puntuaba Electrum por AUSENCIA de rasgos: locktime=0, sin
|
||||
// RBF, sequence por defecto. Eso es exactamente al revés — Electrum
|
||||
// moderno (4.x, desde 2020) pone locktime a la altura actual para
|
||||
// protegerse del fee sniping y señaliza RBF por defecto. Lo que aquellas
|
||||
// señales describen no es un wallet concreto, sino software que no
|
||||
// configura nada: un script hecho a medida.
|
||||
//
|
||||
// El fallo salió analizando la transacción de un robo real: la app la
|
||||
// atribuía a "Electrum (PROBABLE)" cuando sus rasgos eran justo los
|
||||
// contrarios a los de Electrum. Un silencio leído como una afirmación.
|
||||
if (allP2wpkh) {
|
||||
let score = 0, sigs = [];
|
||||
if (locktimeIsBlock) { score+=3; sigs.push("locktime=altura actual (anti-fee-sniping)"); }
|
||||
if (hasRbfOptin) { score+=2; sigs.push("RBF señalizado"); }
|
||||
let score = 2, sigs = ["inputs P2WPKH uniformes"]; // incluido por la guardia
|
||||
if (locktime === 0) { score+=1; sigs.push("locktime=0"); }
|
||||
if (!hasRbfOptin) { score+=1; sigs.push("sin RBF"); }
|
||||
if (allInputs.every(v=>v.sequence===0xffffffff)) { score+=2; sigs.push("sequence=0xFFFFFFFF"); }
|
||||
const allOutSameTypeAsIn = allOutputs.length > 0 &&
|
||||
allOutputs.every(o => o.scriptpubkey_type === inTypes[0]);
|
||||
if (allOutSameTypeAsIn) { score+=1; sigs.push("outputs del mismo tipo que inputs"); }
|
||||
if (allOutputs.length === 2) { score+=1; sigs.push("2 outputs"); }
|
||||
if (score >= 2) sigs.unshift("inputs P2WPKH uniformes");
|
||||
// Hace falta al menos una señal POSITIVA fuerte, no solo ausencias.
|
||||
if (score >= 5) results.push({ name:"Electrum", score, confidence: score>=6?"PROBABLE":"POSIBLE", signals:sigs });
|
||||
}
|
||||
|
||||
// ─── Sin rasgos distintivos ────────────────────────────────────
|
||||
// Una transacción sin locktime, sin RBF y con sequence al máximo no
|
||||
// lleva ninguna de las marcas que dejan los monederos actuales. Decirlo
|
||||
// es más honesto que forzar un nombre: es lo que produce una librería
|
||||
// en crudo o un script propio, y también algún wallet antiguo.
|
||||
{
|
||||
const sinLocktime = locktime === 0;
|
||||
const sinRbf = !hasRbfOptin;
|
||||
const seqPorDefecto = allInputs.length > 0 && allInputs.every(v=>v.sequence===0xffffffff);
|
||||
if (sinLocktime && sinRbf && seqPorDefecto && results.length === 0) {
|
||||
results.push({
|
||||
name: "sin rasgos distintivos", score: 3, confidence: "POSIBLE",
|
||||
signals: ["locktime=0", "sin RBF", "sequence=0xFFFFFFFF"],
|
||||
note: "No lleva las marcas que dejan los monederos habituales. Compatible con una librería usada en crudo, un script propio o software antiguo — pero no identifica a ninguno en concreto.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BlueWallet / mobile ───────────────────────────────────────
|
||||
// Corregido: eliminado doble-conteo de "1 input P2WPKH" (antes sumaba 4 pts solo).
|
||||
// Umbral subido a 6 para no dispararse con cualquier tx simple genérica.
|
||||
@@ -1460,51 +1431,7 @@
|
||||
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 } };
|
||||
}
|
||||
|
||||
@@ -1704,48 +1631,6 @@
|
||||
});
|
||||
if (hasInputReuse) deductions += weights.input_reuse;
|
||||
|
||||
// ── 1b. Vinculación de direcciones al gastarlas juntas (CIOH) ──────
|
||||
// Faltaba, y es el hueco más grande que tenía el analizador: se medía
|
||||
// la privacidad desde el lado de quien CONSTRUYE la transacción (¿se ve
|
||||
// mi cambio?, ¿pago cifras redondas?) y no desde el lado de los dueños
|
||||
// de las monedas gastadas, que es donde ocurre el daño irreversible.
|
||||
//
|
||||
// Gastar N direcciones distintas en una misma transacción las enlaza
|
||||
// para siempre a ojos de cualquiera. Es la heurística CIOH, la más
|
||||
// fiable del análisis de cadena, y aquí no se aplicaba a la propia
|
||||
// transacción que se está mirando — solo al conjunto del wallet.
|
||||
//
|
||||
// Se detectó analizando una consolidación real de 491 direcciones: la
|
||||
// app la calificaba de privacidad ALTA porque el cambio no se
|
||||
// distinguía, sin mencionar que 491 direcciones acababan de quedar
|
||||
// vinculadas en público.
|
||||
//
|
||||
// No aplica en CoinJoin: ahí las entradas son de personas distintas y
|
||||
// esa es justamente la excepción clásica a CIOH.
|
||||
const nVinculadas = uniqueInputAddrs.size;
|
||||
const hayVinculacion = nVinculadas > 1 && !likelyCJ;
|
||||
// Escalonado: consolidar dos monedas es rutina; juntar decenas es otra
|
||||
// cosa. El peso sube con el número de direcciones que quedan atadas.
|
||||
const pesoVinculacion = !hayVinculacion ? 0
|
||||
: nVinculadas >= 20 ? 30
|
||||
: nVinculadas >= 10 ? 22
|
||||
: nVinculadas >= 5 ? 15
|
||||
: 8;
|
||||
checks.push({
|
||||
id:"input_linkage", label:"Direcciones que quedan vinculadas entre sí", certainty:"CERTEZA",
|
||||
pass: !hayVinculacion,
|
||||
informational: likelyCJ && nVinculadas > 1,
|
||||
actionability: "evitable",
|
||||
detail: !hayVinculacion
|
||||
? (likelyCJ && nVinculadas > 1
|
||||
? `Se gastan ${nVinculadas} direcciones distintas, pero en un CoinJoin las entradas son de participantes diferentes: la vinculación por CIOH no aplica aquí. De hecho, romperla es el objetivo de la mezcla.`
|
||||
: "Solo se gasta una dirección, así que esta transacción no enlaza direcciones entre sí.")
|
||||
: `Hecho (certeza): esta transacción gasta ${nVinculadas} direcciones distintas a la vez. Interpretación (certeza para un observador): quien mira la cadena asume que las ${nVinculadas} pertenecen al mismo dueño, porque hizo falta la clave de todas para firmar. Consecuencia: quedan enlazadas de forma permanente y pública. Si alguna de ellas se asocia alguna vez a una identidad, arrastra a las demás — y esto no se puede deshacer.`,
|
||||
didactic: "Es la heurística CIOH (common input ownership), la más fiable que existe en el análisis de cadena y la primera que aplica cualquiera que observe. Cada vez que gastas varias monedas juntas, le dices al mundo que son de la misma persona. Consolidar cuando las comisiones están baratas es tentador y sale caro en privacidad: media docena de consolidaciones descuidadas bastan para reconstruir un wallet entero. La forma de evitarlo es gastar monedas de una en una cuando su origen no debe relacionarse — que es justo lo contrario de lo que invita a hacer una comisión baja.",
|
||||
penalty: pesoVinculacion,
|
||||
});
|
||||
if (hayVinculacion) deductions += pesoVinculacion;
|
||||
|
||||
// ── 2. Mezcla de tipos en inputs ──────────────────────────────────
|
||||
const inTypes = [...new Set(tx.vin.map(v=>v.prevout?.scriptpubkey_type).filter(Boolean))];
|
||||
const outTypes = [...new Set(tx.vout.map(v=>v.scriptpubkey_type).filter(Boolean))];
|
||||
@@ -1999,25 +1884,20 @@
|
||||
checks.push({
|
||||
id:"wallet_fingerprint", label:"Fingerprinting de wallet",
|
||||
certainty: detectedWallets[0]?.confidence||"POSIBLE",
|
||||
pass: likelyCJ ? true : !detectedWallets.some(w => w.name !== "sin rasgos distintivos"),
|
||||
informational: (likelyCJ && detectedWallets.length > 0) ||
|
||||
(detectedWallets.length > 0 && !detectedWallets.some(w => w.name !== "sin rasgos distintivos")),
|
||||
pass: likelyCJ ? true : detectedWallets.length===0,
|
||||
informational: likelyCJ && detectedWallets.length > 0,
|
||||
actionability: likelyCJ ? null : "wallet",
|
||||
detail: detectedWallets.length > 0
|
||||
? (likelyCJ
|
||||
? `En un CoinJoin identificar el software es informativo, no un problema. ${detectedWallets.map(w=>`${w.name} (${w.confidence}): ${w.signals.join(", ")}`).join(" · ")}`
|
||||
: detectedWallets.map(w=>`${w.name} (${w.confidence}): ${w.signals.join(", ")}${w.note?` — ${w.note}`:""}`).join(" · "))
|
||||
: detectedWallets.map(w=>`${w.name} (${w.confidence}): ${w.signals.join(", ")}`).join(" · "))
|
||||
: "No se detecta un patrón de wallet específico con suficiente certeza.",
|
||||
didactic: "El objetivo no es ser invisible sino ser indistinguible de millones de usuarios del mismo wallet. Un fingerprint de Bitcoin Core lo comparten millones de transacciones — no revela nada útil. Un fingerprint de Exodus o un wallet minoritario pertenece a un conjunto mucho menor y es más revelador. En un CoinJoin, el tipo de mezcla ya sugiere el software usado.",
|
||||
penalty: likelyCJ ? 0 : weights.wallet_fingerprint,
|
||||
});
|
||||
// En CoinJoin no penaliza (identificar el wallet es esperado, no un fallo).
|
||||
// Fuera de CoinJoin, solo penaliza si se identificó un software concreto:
|
||||
// "sin rasgos distintivos" es lo contrario de una huella — significa que
|
||||
// la transacción no se puede atribuir a ningún monedero, que si acaso
|
||||
// juega a favor. Penalizarlo sería cobrar por no dejar rastro.
|
||||
const huellaReal = detectedWallets.filter(w => w.name !== "sin rasgos distintivos");
|
||||
if (!likelyCJ && huellaReal.length > 0) deductions += weights.wallet_fingerprint;
|
||||
// Fuera de CoinJoin, solo penaliza si se detectó un wallet estructural real.
|
||||
if (!likelyCJ && detectedWallets.length > 0) deductions += weights.wallet_fingerprint;
|
||||
|
||||
// ── 13. PayJoin — informativo ─────────────────────────────────────
|
||||
checks.push({
|
||||
@@ -2337,32 +2217,9 @@
|
||||
};
|
||||
|
||||
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, 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 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 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>;
|
||||
@@ -3047,45 +2904,15 @@
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
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 });
|
||||
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.",
|
||||
});
|
||||
}
|
||||
|
||||
// ── 3. Mempool accesible solo desde origen correcto ──────────
|
||||
@@ -3127,31 +2954,14 @@
|
||||
}
|
||||
|
||||
// ── 5. Swap alto ──────────────────────────────────────────────
|
||||
// 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;
|
||||
if (sysData?.swap) {
|
||||
const pct = sysData.swap.percent || 0;
|
||||
results.push({
|
||||
id:"swap_usage", label:"Uso de SWAP",
|
||||
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.`,
|
||||
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.`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3331,149 +3141,19 @@
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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}) {
|
||||
function BlockExplorer({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);
|
||||
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]);
|
||||
|
||||
useEffect(()=>{ setLoading(true); get("/api/v1/blocks",MOCK_BLOCKS).then(b=>{setBlocks(b);setLoading(false);}); },[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:12}}>
|
||||
<div style={{display:"flex",flexDirection:"column",gap:6}}>
|
||||
{!base&&<DemoBanner/>}
|
||||
|
||||
{/* 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>
|
||||
<SectionTitle accent={C.purple} icon="◈">Bloques Recientes</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}>
|
||||
@@ -3483,12 +3163,9 @@
|
||||
</div>
|
||||
))}
|
||||
{selected&&(
|
||||
<Card style={{marginTop:8}} glow={C.purple} innerRef={detalleRef}>
|
||||
<Card style={{marginTop:8}} glow={C.purple}>
|
||||
<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>
|
||||
<span style={{fontFamily:"monospace",color:C.purple,fontWeight:700}}>Bloque #{fmt.num(selected.height)}</span>
|
||||
<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>
|
||||
@@ -3496,7 +3173,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?`${(Math.round(selected.extras.medianFee*100)/100)} sat/vB`:"-"} color={C.amber}/>
|
||||
<Tag label="Fee mediana" value={selected.extras&&selected.extras.medianFee!=null?`${selected.extras.medianFee} 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>
|
||||
@@ -3505,31 +3182,11 @@
|
||||
{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,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 style={{display:"flex",gap:6}}><Badge color={C.amber}>{tx.fee} sat</Badge><Badge color={C.t3}>{tx.size}B</Badge></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>
|
||||
);
|
||||
}
|
||||
@@ -3591,6 +3248,86 @@
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -3644,7 +3381,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} base={base}/>}
|
||||
{result&&result.type==="address"&&<AddressResult addr={result.data} analysis={analysis}/>}
|
||||
{result&&result.type==="block"&&<BlockResult block={result.data}/>}
|
||||
<UTXOMap base={base}/>
|
||||
</div>
|
||||
@@ -4076,13 +3813,6 @@
|
||||
const funded = stats.funded_txo_sum || 0;
|
||||
const spent = stats.spent_txo_sum || 0;
|
||||
const txCount = stats.tx_count || 0;
|
||||
// El backend de Mempool sobre Fulcrum devuelve los importes a cero en
|
||||
// direcciones con mucho historial, aunque el contador de transacciones
|
||||
// sí venga bien. Una dirección con transacciones pero sin nada recibido
|
||||
// es imposible en la cadena: si se da, los datos están incompletos.
|
||||
// Sin esta comprobación, el perfil concluye "no es hot wallet" cuando en
|
||||
// realidad no ha podido mirarlo — otro silencio leído como respuesta.
|
||||
const statsIncompletas = txCount > 0 && (stats.funded_txo_count || 0) === 0;
|
||||
const residual = funded - spent;
|
||||
const residualRatio = funded > 0 ? residual / funded : 0;
|
||||
|
||||
@@ -4092,9 +3822,7 @@
|
||||
// Señal de flujo: saldo residual casi nulo + volumen y tx_count altos ->
|
||||
// la dirección funciona como paso de caudal, no como ahorro (típico de
|
||||
// hot wallet de exchange). Lo contrario -> retención típica de uso personal.
|
||||
if (statsIncompletas) {
|
||||
signals.push(`el nodo no devolvió los importes de esta dirección (${txCount} transacciones registradas, 0 recibido — imposible en la cadena). La señal de flujo no se ha podido evaluar: no es que se haya descartado, es que no se ha podido mirar`);
|
||||
} else if (funded > 0 && residualRatio < 0.05 && txCount >= 20) {
|
||||
if (funded > 0 && residualRatio < 0.05 && txCount >= 20) {
|
||||
hotScore += 2;
|
||||
signals.push(`saldo residual ≈0 (${(residualRatio*100).toFixed(1)}% de lo recibido) con ${txCount} transacciones — patrón de flujo, no de ahorro`);
|
||||
} else if (funded > 0 && residualRatio > 0.3 && txCount < 10) {
|
||||
@@ -4805,12 +4533,7 @@
|
||||
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]);
|
||||
// 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 mempoolUrl = base ? `${base}/tx/${txData.txid}` : `https://mempool.space/tx/${txData.txid}`;
|
||||
const inputs = txData.vin || [];
|
||||
const [hover,setHover]=useState(false);
|
||||
|
||||
@@ -4869,17 +4592,10 @@
|
||||
}}>
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
<button
|
||||
onMouseEnter={()=>setHover(true)}
|
||||
onMouseLeave={()=>setHover(false)}
|
||||
@@ -5073,10 +4789,8 @@
|
||||
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,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 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>
|
||||
{!est.child&&!est.loading&&(
|
||||
<button onClick={()=>seguir(i)}
|
||||
@@ -5399,35 +5113,6 @@
|
||||
</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}}>
|
||||
@@ -5519,12 +5204,6 @@
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
@@ -5597,10 +5276,7 @@
|
||||
<Card glow={C.purple}>
|
||||
<div style={{display:"flex",justifyContent:"space-between",alignItems:"flex-start",marginBottom:12}}>
|
||||
<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.6rem",color:C.t2,fontFamily:"monospace",marginBottom:4}}>TRANSACCIÓN ANALIZADA</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}}>
|
||||
@@ -5975,10 +5651,7 @@
|
||||
<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={{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={{fontSize:"0.6rem",color:C.t1,fontFamily:"monospace",marginBottom:4,wordBreak:"break-all"}}>{a.address}</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"}}>
|
||||
@@ -6001,10 +5674,7 @@
|
||||
<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={{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.6rem",color:C.t1,fontFamily:"monospace",wordBreak:"break-all"}}>{u.address}</div>
|
||||
<div style={{fontSize:"0.58rem",color:C.green,fontFamily:"monospace",fontWeight:700}}>{fmt.num(u.amount)} sat</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -6122,41 +5792,23 @@
|
||||
);
|
||||
}
|
||||
|
||||
function AddressResult({addr, analysis, base}) {
|
||||
function AddressResult({addr, analysis}) {
|
||||
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;
|
||||
const unconf=(m.funded_txo_sum||0)-(m.spent_txo_sum||0);
|
||||
// Con Fulcrum por debajo, el backend devuelve los importes a cero en
|
||||
// direcciones con mucho historial, aunque el contador de transacciones
|
||||
// venga bien. Tener transacciones y no haber recibido nada es imposible
|
||||
// en la cadena, así que cuando se da, los importes no son reales: son un
|
||||
// hueco. Mostrar "Balance 0" tal cual sería mentir con seguridad.
|
||||
const importesIncompletos = c.tx_count > 0 && (c.funded_txo_count||0) === 0;
|
||||
const noDisponible = "no disponible";
|
||||
return (
|
||||
<div style={{display:"flex",flexDirection:"column",gap:10}}>
|
||||
<Card glow={C.green}>
|
||||
<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={{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={{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="Balance" value={fmt.btc(balance)} color={C.green}/>
|
||||
<Tag label="No confirmado" value={unconf!==0?fmt.btc(unconf):"0"} color={unconf>0?C.amber:C.t2}/>
|
||||
<Tag label="Total recibido" value={importesIncompletos?noDisponible:fmt.btc(c.funded_txo_sum)} color={importesIncompletos?C.t3:C.blue}/>
|
||||
<Tag label="Total enviado" value={importesIncompletos?noDisponible:fmt.btc(c.spent_txo_sum)} color={importesIncompletos?C.t3:C.red}/>
|
||||
<Tag label="Total recibido" value={fmt.btc(c.funded_txo_sum)} color={C.blue}/>
|
||||
<Tag label="Total enviado" value={fmt.btc(c.spent_txo_sum)} color={C.red}/>
|
||||
<Tag label="Transacciones" value={fmt.num(c.tx_count)} color={C.t1}/>
|
||||
<Tag label="UTXOs" value={fmt.num(addr.utxos?addr.utxos.length:0)} color={C.amber}/>
|
||||
</div>
|
||||
{importesIncompletos&&(
|
||||
<div style={{marginTop:12,padding:"9px 12px",background:C.amberMuted,border:`1px solid ${C.amber}40`,borderRadius:6,fontSize:"0.63rem",color:C.t1,fontFamily:"monospace",lineHeight:1.6}}>
|
||||
<strong style={{color:C.amber}}>Importes no disponibles.</strong> El nodo informa de {fmt.num(c.tx_count)} transacciones pero devuelve cero recibido, y eso no puede ocurrir en la cadena. Es una limitación conocida del backend de Mempool sobre Fulcrum con direcciones de mucho historial. Los importes se ocultan en vez de mostrarse a cero, porque un cero aquí sería un dato falso. El número de transacciones y la lista de abajo sí son correctos.
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
{analysis&&<PrivacyLab analysis={analysis}/>}
|
||||
{addr.utxos&&addr.utxos.length>0&&<Card><div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:10}}>UTXOs</div>{addr.utxos.slice(0,6).map((u,i)=><div key={i} style={{display:"flex",justifyContent:"space-between",alignItems:"center",padding:"6px 0",borderBottom:i<Math.min(addr.utxos.length,6)-1?`1px solid ${C.border}`:"none"}}><span style={{fontSize:"0.68rem",fontFamily:"monospace",color:C.blue}}>{fmt.hash(u.txid)}:{u.vout}</span><div style={{display:"flex",gap:6}}><Badge color={C.green}>{fmt.btc(u.value)}</Badge><Badge color={u.status&&u.status.confirmed?C.t2:C.amber}>{u.status&&u.status.confirmed?`#${fmt.num(u.status.block_height)}`:"mempool"}</Badge></div></div>)}</Card>}
|
||||
@@ -6879,7 +6531,8 @@
|
||||
|
||||
const TABS=[
|
||||
{id:"node", label:"Nodo", icon:"◉"},
|
||||
{id:"red", label:"Cadena", icon:"◈"},
|
||||
{id:"blocks", label:"Bloques", icon:"◈"},
|
||||
{id:"mempool", label:"Mempool", icon:"≋"},
|
||||
{id:"lab", label:"LAB", icon:"⌕"},
|
||||
{id:"auditoria", label:"AUDITORÍA", icon:"⌖"},
|
||||
{id:"peritaje", label:"PERITAJE", icon:"🔍"},
|
||||
@@ -6964,7 +6617,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. 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}}>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}}>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>
|
||||
@@ -7131,7 +6784,8 @@
|
||||
</div>
|
||||
<div style={{padding:"18px 14px",maxWidth:860,margin:"0 auto",width:"100%",flex:1}}>
|
||||
{tab==="node" && <NodeOverview base={config.url}/>}
|
||||
{tab==="red" && <RedView base={config.url}/>}
|
||||
{tab==="blocks" && <BlockExplorer base={config.url}/>}
|
||||
{tab==="mempool" && <MempoolView 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}/>}
|
||||
|
||||
+3
-27
@@ -290,36 +290,12 @@ 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) {
|
||||
peerList = await rpcCall("getpeerinfo").catch(() => null);
|
||||
inbound = peerList ? peerList.filter(p => p.inbound).length : 0;
|
||||
outbound = peerList ? peerList.filter(p => !p.inbound).length : 0;
|
||||
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;
|
||||
}
|
||||
|
||||
// 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,
|
||||
|
||||
Reference in New Issue
Block a user