67 Commits
Author SHA1 Message Date
Aitor 2a1c7254e2 feat: pestaña Cadena unificada, aviso de polvo y enlaces a la instancia propia
Mempool y Bloques eran el mismo eje partido: los proyectados en una pestaña y
los confirmados en otra. Ahora es una línea temporal con el ahora en medio, y
responde la pregunta que ninguna de las dos respondía — a qué tarifa entra tu
transacción y en qué bloque.

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

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

Y un fallo preexistente: el rastro de procedencia caía en mempool.space
público si no había nodo configurado. Sin nodo ya no hay enlace.
2026-08-08 18:26:01 +02:00
Aitor d3e204d396 fix: el checklist normalizaba un swap alto que no era normal
Decía 'Normal en nodos con índice completo' ante un swap al 99%. En el caso
real que lo destapó no lo era: Fulcrum tenía db_mem a 16 GB, más de lo que
cabe en la máquina, y el sistema llevaba quince días sin margen ante un pico
de memoria. Ese mensaje es la razón de que se ignorara tanto tiempo.

Umbral del 95% al 50%, error por encima del 80%, detección del caso 'swap
alto con RAM libre' (firma de memoria sobrerreservada), y el aviso apunta a
db_mem e incluye el comando para ver quién ocupa el swap.
2026-08-08 15:58:26 +02:00
Aitor f7891d81c4 feat: check de vinculación CIOH; fix: saldos falsos y fingerprint por ausencia
Tres cosas encontradas analizando transacciones reales del incidente Coldcard:

- Faltaba avisar de que gastar N direcciones juntas las vincula para siempre.
  El analizador medía la privacidad desde el lado de quien construye la
  transacción, no desde el de los dueños de las monedas gastadas.

- El explorador mostraba 'Balance: 0 BTC' en direcciones con mucho historial:
  Mempool sobre Fulcrum no devuelve los importes y la app los daba por buenos.
  Ahora dice 'no disponible' y explica por qué — tener 492 transacciones y cero
  recibido es imposible en la cadena.

- El fingerprint decía 'Electrum' por AUSENCIA de rasgos (locktime 0, sin RBF),
  cuando Electrum moderno hace lo contrario. Ahora exige señales positivas y
  existe 'sin rasgos distintivos', que no penaliza.
2026-08-08 15:34:48 +02:00
Aitor 9780ed63f2 chore: ignorar REPASO.md (documento de trabajo privado) 2026-08-08 15:21:42 +02:00
Aitor 8a8b5addf5 docs: el README no mencionaba la auditoría de PSBT
Seguía anunciando 'Decodificador PSBT', que era la versión antigua: solo
comprobaba que el archivo empezara por 'psbt'. Quien llegaba al repo no se
enteraba de que existe la auditoría completa — la única función de la app que
actúa antes de firmar, y por tanto la única que permite cambiar algo.
2026-08-08 14:22:28 +02:00
Aitor b579a0653a docs: reescribir la guía de instalación, que llevaba a una app rota
SETUP.md no era una guía de instalación: más de la mitad eran los pasos
personales de git y Gitea del autor. Se mueven a un documento privado.

Y lo importante: la configuración de nginx documentada en el README estaba
rota desde la 1.11.0. Indicaba un alias a dashboard.html (un ARCHIVO), pero
con las librerías en vendor/ y rutas relativas eso deja al navegador sin
encontrarlas — página en blanco y ningún error visible. Debe ser un alias al
DIRECTORIO. Quien siguiera el README al pie de la letra no conseguía arrancar.

Ahora hay una guía real con comprobación tras cada paso, incluida la que
descarta ese fallo concreto: curl mirando el content-type, no solo el 200.
Se añaden las librerías al flujo (faltaban, y sin ellas no arranca), se quitan
las rutas personales y se actualiza la estructura del repo.
2026-07-27 16:38:48 +02:00
Aitor b5db5d7e81 fix: CPU real por proceso en el monitor, y UTXO Map usando la caché
La columna %CPU de ps aux es la media desde que arrancó el proceso, no el
consumo actual: un proceso que trabajó mucho hace días seguía apareciendo
alto para siempre. Se detectó porque los porcentajes no cambiaban nunca entre
lecturas mientras la CPU global sí variaba.

Ahora se mide con dos lecturas de /proc/PID/stat separadas 500 ms, el mismo
método que ya usaba getCpuUsage para el total. La UI muestra el % sobre el
total de la máquina, con el % por núcleo y la media de ps en el tooltip.

Verificado con carga artificial: 100% de un núcleo (25% de 4) frente al 152%
que reportaba ps, imposible para un proceso de un solo hilo.

Además, el UTXO Map era el único punto que se saltaba la caché usando
fetchWithTimeout directo. Ahora pasa por get().
2026-07-27 15:38:03 +02:00
Aitor 8d58ef7ae0 fix: endurecer la validación del xpub tras revisar la criptografía
La matemática pasa todos los vectores oficiales (RIPEMD-160, secp256k1, BIP32
vectores 1 y 2, bech32/BIP173) y no se ha tocado. Los fallos estaban en la
validación de la entrada:

- No se comprobaba la checksum del xpub: un carácter mal copiado generaba 200
  direcciones ajenas y el usuario veía su cartera 'sin actividad'. Mismo
  patrón de falso negativo silencioso que el resto de fallos de hoy.
- No se validaba la longitud (78 bytes) ni el formato de la clave pública.
- tpub se trataba como mainnet: la red se detectaba por prefijo de texto
  ('tb'/'u'/'v') y un tpub empieza por 't' pero no por 'tb'. Ahora se detecta
  por bytes de versión, con las diez variantes.
- deriveChildPubkey aceptaba índices endurecidos, imposibles desde una clave
  pública. No alcanzable desde la UI, pero debe defenderse sola.

Se añade tests/ con las cuatro baterías, documentando también qué NO cubren:
no sustituyen una auditoría externa.
2026-07-27 14:59:48 +02:00
Aitor 728ba655ee feat: auditoría de PSBT — revisar una transacción antes de firmarla
Primera pieza de la app que llega a tiempo: todo lo demás es diagnóstico de lo
que ya pasó. Sustituye al validador anterior, que solo comprobaba que el
archivo empezara por 'psbt'.

Parser BIP174 completo escrito desde cero, validado contra los cinco vectores
inválidos del estándar (los rechaza los cinco con mensajes en castellano) y
contra PSBTs reales de Sparrow.

Todo offline: la PSBT ya trae los importes y scripts de sus entradas, así que
no hace falta el nodo — funciona con él sincronizando y sin que se entere.

El aviso más valioso es el de los xpubs incrustados: las PSBT llevan dentro las
claves maestras de la cartera y están hechas para compartirse, así que quien
reciba el archivo puede ver todas las direcciones, el saldo y el historial.
No he encontrado ningún wallet que avise de esto.
2026-07-27 14:44:26 +02:00
Aitor b57c44e5c0 fix: el informe de wallet avisa cuando el escaneo quedó incompleto
Mismo patrón que el fallo del peritaje, en la pieza central del proyecto.
scanWallet usaba .catch(()=>[]) en sus tres consultas, así que una dirección
que el nodo no pudo servir era indistinguible de una sin actividad: sus txs no
se traían, no contaban para reutilización ni clusters, y el informe daba su
valoración sin mencionar que faltaban datos.

El sesgo iba siempre al optimismo — menos actividad vista, mejor nota. Ahora
se registran los fallos y se avisa antes de la banda de salud, en la UI y en
el export MD.
2026-07-27 14:06:08 +02:00
Aitor 6f750a1327 fix: OFAC y RBF dejan de penalizar la privacidad; heurísticas que no afirman de más
Un hit de OFAC restaba 20 puntos de la banda de privacidad, pero no revela
nada más sobre el usuario: su privacidad es idéntica antes y después. Lo que
cambia es la exposición a que un servicio regulado le bloquee un depósito —
otro eje distinto. Penalizarlo era además asumir la lógica de las 'monedas
contaminadas', la que la fungibilidad de Bitcoin niega. Ahora es informativo,
con el contexto que faltaba sobre qué es y qué no es esa lista.

RBF igual: es buena práctica y casi universal; restar por usarlo empujaba a
gastar peor para esconder una señal débil.

Checks con inferencia fuerte reescritos con Hecho/Interpretación/Consecuencia:
ya no dicen 'sin ambigüedad' donde la señal puede fallar.

README: nueva sección 'Sí, esto es chain analysis' — negarlo restaba
credibilidad ante quien lee el código. Y el informe advierte ahora del coste
de denunciar y de manejar el archivo exportado.
2026-07-27 13:56:18 +02:00
Aitor 24ca225190 chore: ignorar COHERENCIA.md (documento de trabajo privado) 2026-07-27 13:40:21 +02:00
Aitor 2b483e0df9 fix: no atribuir al actor las direcciones de un custodio
Misma raíz que el fallo del CoinJoin, en otra variante. El cluster CIOH ya
excluía los custodios (actorAddrSet), pero la atribución por huella de
software no: la hot wallet de Bitfinex aparecía como 'dirección atribuida al
actor' mientras la conclusión la identificaba como exchange dos líneas más
abajo. La dirección donde el ladrón deposita es del exchange, no suya.

Verificado contra el nodo con un depósito real: de 2 atribuciones a 1.
2026-07-27 13:37:22 +02:00
Aitor d0cd9ef587 fix: no atribuir al actor direcciones del otro lado de un CoinJoin
El informe decía 'el rastro se rompe en un CoinJoin, no se puede atribuir con
honestidad más allá de este punto' y acto seguido listaba 10 direcciones
atribuidas al actor, cinco de ellas salidas de esa misma mezcla — es decir,
de otros participantes, señalados en un documento para una denuncia.

CIOH no aplica dentro de un CoinJoin (es su excepción clásica: la mezcla
existe para romper la suposición de dueño común) y la huella de software sale
estable por construcción, porque todos usan el mismo programa.

finalizeForensicGraph excluye las tx marcadas como mixer del conjunto del
actor y del union-find; la atribución por huella salta esos nodos.

Verificado contra el nodo con un Whirlpool real: de 10 atribuciones a 0.
2026-07-27 13:29:08 +02:00
Aitor 7c12f63f7a feat: cero dependencias externas y caché de peticiones al nodo
Las librerías (React, Babel) venían de unpkg.com y las fuentes de Google.
Ninguno veía qué transacciones analizabas, pero ambos recibían tu IP y la hora
en cada apertura: sabían que usabas Txoko, cuándo y desde dónde — el metadato
que la propia herramienta enseña a proteger. Ahora se sirven desde el nodo,
con verificación por hash y versiones fijadas. El dashboard funciona sin
internet.

Además, caché con TTL y coalescencia en useApi: las transacciones confirmadas
son inmutables y se cachean toda la sesión, así que repetir un análisis ya
explorado no cuesta ninguna petición al nodo (medido).
2026-07-27 13:22:08 +02:00
Aitor 1b6904d2e3 chore: ignorar MEJORAS.md (documento de trabajo privado) 2026-07-27 13:01:14 +02:00
Aitor 14a094f941 feat: tope de ramificación en el peritaje — no seguir repartos masivos
Una tx con cientos de salidas (dust attack, lote de retiradas, airdrop) hacía
que el motor perfilara cada dirección de salida (2 peticiones cada una) antes
de encolar las ramas: ~286 peticiones para una tx de 143 salidas, con la
pestaña congelada. El tope corta antes de perfilar, que es donde está el coste.

Verificado contra el nodo con una tx real de 143 salidas: 2 peticiones.
2026-07-27 12:59:48 +02:00
Aitor 8c2725fb31 docs: documentar los límites reales del peritaje y afinar el aviso de consulta fallida
- el aviso ya no promete que el fallo es transitorio: cuando se repite sobre
  la misma dirección no lo es, y ahora dice por qué y qué hacer
- README: las ramas no comprobadas quedan fuera de 'fondos sin gastar' a
  propósito — cortar protege el nodo y una consulta fallida no es una
  conclusión
- README: el tope de volumen mide número de tx, no peso; una dirección con
  pocas transacciones muy grandes puede agotar el tiempo de espera igual
  (visto con una de 59 tx de ~15 KB)
2026-07-27 12:42:31 +02:00
Aitor c2fb4f1388 fix: el peritaje ya no presenta un fallo de consulta como rastro completo
Detectado probando contra el nodo real: un 503 del backend se mostraba como
'Rastro completo' y contaba la rama como terminal, porque get() devolvía
mockData ante cualquier error y findSpendingTx remataba con .catch(()=>[]).
Un error de red quedaba así indistinguible de 'output sin gastar' — la
conclusión más accionable de un informe pericial, afirmada sobre una consulta
que nunca respondió.

- getStrict en useApi propaga el fallo; get se mantiene igual para el resto
- el motor registra los fallos en trace.queryErrors y no los cuenta como
  fondos sin gastar
- el informe los lista aparte y abre las conclusiones avisando del alcance
  incompleto (UI, JSON y MD)
- la UI muestra el detalle del fallo en vez del falso rastro completo
2026-07-27 12:38:07 +02:00
Aitor 4d242a6ac6 chore: ignorar HALLAZGOS-PERITAJE.md (documento de trabajo privado) 2026-07-27 12:25:00 +02:00
Aitor 251981e383 chore: ignorar PRUEBA-PERITAJE.md (documento de trabajo privado) 2026-07-27 12:04:53 +02:00
Aitor 2abcdee144 fix: pinear versión de Babel standalone en el CDN (7.23.10) 2026-07-16 22:24:51 +02:00
Aitor 50c19ebc9f fix: pasada de calidad sobre el módulo Peritaje forense
Repaso crítico de los 11 commits del módulo (motor, tope de
seguridad, UI a demanda, generador de informe), buscando bugs e
inconsistencias más allá de la poda de ramas ya arreglada. Encontrado
y corregido:

- **Certeza de banda inalcanzable:** en la cronología del informe, el
  ternario que decidía CERTEZA/PROBABLE/POSIBLE para la banda de cada
  tx comparaba `analysis.score>=75` con `analysis.band==="ALTA"` —
  la misma condición dos veces, porque analyzeTx define ALTA como
  score>=75. La rama PROBABLE nunca podía darse. Ahora es
  score>=90→CERTEZA / score>=45→PROBABLE / resto→POSIBLE, las tres
  alcanzables.

- **entityMarks del origen siempre vacío:** buildForensicReport
  llamaba marcasDeTx sobre `origin` (el resumen que devuelve
  finalizeForensicGraph), que no lleva vin/vout — así que la fila de
  origen en la cronología nunca podía mostrar coinbase/OFAC/minería/
  exchange, aunque la tx real sí los tuviera. Ahora
  finalizeForensicGraph calcula entityMarks sobre la tx cruda y lo
  guarda en graph.origin.entityMarks. Verificado con una tx coinbase
  sintética: antes daba [], ahora marca correctamente "⛏️ coinbase
  (origen)".

- **`custodyStop.byVolume`/tx_count usaba el string "maxHops" como
  stopReason** — el mismo valor que ya significaba "truncado por el
  parámetro maxHops" en otro sitio (unspentTerminals), pero refería a
  MAX_NODES (cinturón de seguridad distinto). Además nunca tenía
  cobertura en el informe: un nodo detenido así no generaba ninguna
  conclusión, desaparecía en silencio. Renombrado a "nodeLimit" y
  añadida su conclusión en el informe.

- **`refTxid` calculado y nunca usado:** el fundamento "cambio
  detectado" de cada dirección atribuida guardaba a qué tx se refería
  pero ni la UI ni el export Markdown lo mostraban. Ahora ambos lo
  citan.

- Comentario de cabecera huérfano tras el refactor a saltos (la
  documentación general de "Motor de rastreo forense" quedó pegada
  sin fusionar al comentario específico de initForensicTrace) —
  consolidado en un único bloque coherente. Referencia de línea
  obsoleta a scanWallet, quitada.

- UI: aviso cuando el informe mostrado quedó desactualizado (el
  usuario siguió explorando más saltos después de generarlo).

Verificado en navegador: los tres casos de regresión ya usados dan
resultado idéntico (salto simple, cadena de peeling de 3 saltos,
custodio con rama normal continuando), más un caso nuevo con
`stopReason:"nodeLimit"` sintético que confirma que el informe genera
su conclusión sin errores, y el caso coinbase que confirma el fix de
entityMarks.
2026-07-16 15:03:00 +02:00
Aitor a5d58dadd4 fix: podar solo la rama de custodio, no la transacción entera
Cuando un salto se detenía por custodyStop (entidad conocida, hot
wallet, o tope de tx_count), se descartaban los DOS outputs de esa
transacción, no solo el que disparó la parada — un pago normal junto
a un depósito de exchange en la misma tx se perdía igual. Mixer,
dilución y el tope global de nodos siguen bloqueando la transacción
entera (afectan a todos sus outputs por igual, por construcción); un
custodio es propiedad de UNA dirección concreta y ahora se evalúa por
dirección: `custodyByAddr` sustituye al `custodyStop` único, y solo
esa dirección se excluye del encolado y de la semilla CIOH. El nodo
sigue guardando `custodyStop` (primera coincidencia, para el texto
del informe) y el nuevo `custodyAddrs` (todas), y el texto de las
conclusiones pasa a nombrar la dirección concreta en vez de "la
transacción", que ya no es preciso cuando otra rama sigue su curso.

Verificado en navegador: los dos casos de regresión ya usados
(salto simple, cadena de peeling de 3 saltos) dan resultado idéntico
entre el modo automático y el modo por saltos, sin cambios. Caso
nuevo dedicado — un salto con un output de custodio (79.828 tx) junto
a un output normal — confirma que la rama normal se sigue explorando
un salto más, sus fondos aparecen correctamente como localizados,
la dirección de custodio recibe una sola petición (nunca se pagina),
y el CIOH atribuye la rama continuada sin incluir la del custodio.
Caso de dilución confirma que ese stop sigue bloqueando ambos
outputs, sin regresión.
2026-07-16 14:42:45 +02:00
Aitor 4280274239 docs: actualizar CHANGELOG/README para el rastreo a demanda
Refleja el cambio de modelo del peritaje forense: de "automático
acotado con seguir más saltos" a "a demanda, salto a salto, con
control manual del usuario", más el tope de tx_count como cinturón
de seguridad adicional. El mecanismo antiguo "seguir más saltos (+4)"
ya no existe en el código — se documenta el que lo reemplaza.
2026-07-16 14:26:05 +02:00
Aitor 7f3e2b5abc feat: rastreo forense a demanda — salto a salto, botón "seguir el rastro"
PeritajeForense pasa de "un clic, rastreo automático completo" a "un
clic por salto": el usuario ve el progreso (salto actual, tx
exploradas, ramas pendientes, ramas terminales) y decide cuándo
avanzar, igual que ya funciona el rastro de procedencia hacia atrás
(RastroProcedencia). El rastreo se guarda en un ref mutable
(traceRef) entre clics — initForensicTrace + advanceForensicHop del
commit anterior encajan directamente, sin cambios.

"Iniciar rastreo forense" ahora también ejecuta el primer salto (un
clic para ver algo). "Generar informe" está disponible desde el
primer salto, no solo al terminar, y puede volver a pulsarse en
cualquier momento — incluida la declaración del afectado, editable
mientras el rastreo sigue en marcha. Los campos txid/vout/importe se
deshabilitan una vez iniciado (no tienen efecto a mitad de rastreo);
el tope de saltos y la declaración siguen editables.

Quita el mecanismo antiguo "seguir más saltos (+4)": ya no tiene
sentido con control manual salto a salto, y el texto de ramas
truncadas por el tope se reescribe para no prometer una reanudación
que el motor no soporta (una rama ya truncada no se puede retomar
suelta).

Verificado en navegador con un servidor HTTP local que sirve la
cadena de peeling de 3 saltos ya usada en pruebas anteriores,
pulsando "seguir el rastro" cuatro veces manualmente: el progreso
avanza correctamente salto a salto (1→2→3→4), termina con "rastro
completo", y el informe generado es idéntico en contenido al que
produce el modo automático (misma cadena de peeling CERTEZA, mismas
direcciones atribuidas, mismos fondos sin gastar). Export JSON/MD y
"Limpiar" verificados sin errores de consola.
2026-07-16 14:24:29 +02:00
Aitor b9477c6139 feat: tope de tx_count como cinturón de seguridad en el peritaje
LARGE_ADDR_TX_COUNT=5000: si el perfil de una dirección de salida
tiene chain_stats.tx_count por encima del umbral, se trata como
custodio presunto (stopReason="exchange", byVolume:true) aunque el
heurístico de perfil no la clasifique "hot_wallet" (p.ej. residual
momentáneamente alto en el momento de la consulta). No cuesta
peticiones extra — chain_stats ya se pide para el perfil de toda
dirección de salida nueva.

Cierra el hueco identificado en la auditoría previa: sin este tope,
una dirección enorme no detectada por el heurístico se encolaría para
el siguiente salto, y findSpendingTx intentaría paginar hasta 200 de
sus transacciones (8 páginas × 8s de timeout, ~64s en el peor caso)
buscando una entre decenas de miles, sin ninguna posibilidad realista
de encontrarla.

El texto del informe distingue este caso: es HECHO (medida de
protección, con el tx_count real citado), no INFERENCIA sobre quién
controla la dirección — a diferencia de una parada por ENTITY_INDEX o
por el heurístico de hot wallet, aquí no se afirma nada sobre la
naturaleza de la dirección.

Verificado en navegador con un caso sintético diseñado para que el
heurístico de perfil NO dispare (residual del 100%, fuera del umbral
<0.05) pero con tx_count=79.828 (el caso real que se va a probar): el
tope se activa correctamente y la dirección enorme recibe EXACTAMENTE
1 petición (el perfil barato) — nunca se llega a paginar su historial.
2026-07-16 13:57:09 +02:00
Aitor 039c916f55 refactor: buildForensicGraph a avance por saltos (initForensicTrace + advanceForensicHop)
Separa el motor en tres piezas para poder pausar entre saltos: crear
el estado del rastreo (initForensicTrace), avanzar UN salto completo
(advanceForensicHop, con el mismo throttling por lotes BATCH=5/
PAUSE=120ms de siempre dentro de ese salto) y ensamblar el
ForensicGraph desde el estado en cualquier momento, completo o
parcial (finalizeForensicGraph). buildForensicGraph se mantiene como
caso trivial que llama advanceForensicHop en bucle — modo automático
de una sola pasada, sin cambio de comportamiento.

Prepara el terreno para que la UI (pestaña Peritaje) deje que el
usuario decida cuándo seguir al siguiente salto, en vez de que el
motor drene todas las ramas sin vigilancia. Es la primera de las
protecciones acordadas contra el estrés al nodo con direcciones de
volumen enorme (hot wallets de exchange).

Verificado: balance de sintaxis + node --check sobre el fragmento
puro. En navegador, los dos casos sintéticos ya usados (salto simple,
cadena de peeling de 3 saltos) dan resultado IDÉNTICO byte a byte
entre el modo automático (buildForensicGraph) y el modo por saltos
manual (initForensicTrace + advanceForensicHop en bucle) — grafo,
clusters, cadenas de peeling y conclusiones del informe coinciden.
También verificado que finalizeForensicGraph + buildForensicReport
funcionan correctamente sobre estado PARCIAL (tras un solo salto, sin
terminar el rastreo), que es justo lo que necesitará la UI a demanda.
2026-07-16 13:52:02 +02:00
Aitor d8c1e845b3 docs: documentar módulo Peritaje forense implementado
CHANGELOG (1.10.0) y README reflejan que el peritaje forense ya está
implementado, no solo especificado: qué hace, qué heurísticas reutiliza
y cuáles son nuevas, el marco HECHO/INFERENCIA/DECLARACION, y la
limitación conocida de depender de /api/address/{addr}/txs en vez de
/outspend (el backend Mempool self-hosted no lo expone).
2026-07-16 12:51:30 +02:00
Aitor fcc19c31e1 feat: peritaje forense — pestaña UI y componente PeritajeForense
Nueva pestaña "PERITAJE" junto a las existentes. Formulario de entrada
(txid:vout, importe estimado robado opcional, declaración en texto
libre), botón "Iniciar rastreo forense" con progreso por salto, y
render completo del informe (resumen, declaración, cronología,
direcciones atribuidas, fondos sin gastar, aviso de ramas truncadas
con botón "seguir más saltos", conclusiones, recomendaciones,
metodología, anexo de verificación) con export JSON/MD inline, mismo
patrón que el informe de wallet.

Verificado en navegador (servidor estático local):
- Balance de sintaxis del bloque Babel correcto, sin errores de
  transpilación JSX en consola.
- Validación del formulario (txid vacío/inválido, nodo no conectado)
  funciona.
- Motor completo probado con datos sintéticos vía consola: caso simple
  (1 salto, cambio detectado correctamente, 2 ramas terminan en UTXO
  sin gastar) y caso de cadena de peeling de 3 saltos (detectada con
  certeza CERTEZA, direcciones de cambio atribuidas con fundamento
  correcto, 4 fondos sin gastar localizados en las hojas).

Cierra la implementación del módulo Peritaje descrito en TRASPASO.md.
2026-07-16 12:43:32 +02:00
Aitor 6f3ca4ed7a feat: peritaje forense — generador de informe (buildForensicReport)
Ensambla las secciones de la plantilla (spec en TRASPASO.md) sobre el
grafo de buildForensicGraph: resumen, declaración del afectado,
cronología, direcciones atribuidas (CIOH + cambio detectado + huella
estable), fondos sin gastar, conclusiones numeradas, recomendaciones
(con el aviso anti-estafa de "recuperación" fijo cuando hay
declaración), metodología y anexo de verificación.

Marco HECHO/INFERENCIA/DECLARACION aplicado en cada afirmación; el
export a JSON/MD queda para la UI (mismo patrón inline que el informe
de wallet, sin función compartida hoy).

También: buildForensicGraph ahora guarda origin.analysis y
node.changeAddress (dirección resuelta, no el índice) para que el
informe no tenga que reindexar en addresses.out, que al deduplicar
podría desalinearse con el orden real de vout.
2026-07-16 12:27:18 +02:00
Aitor 6fcffb2096 feat: peritaje forense — motor de rastreo multi-salto (buildForensicGraph)
Rastreo hacia adelante desde {txid,vout} siguiendo TODOS los outputs de
cada salto (no solo el presunto cambio) porque los fondos pueden
repartirse en varias ramas; cada rama se detiene de forma
independiente. Condiciones de parada: CoinJoin real, dilución (3+
direcciones de entrada no relacionadas), entidad conocida o perfil de
hot wallet no indexado, UTXO sin gastar, límite de saltos.

findSpendingTx implementa el fallback ya verificado en la sesión
anterior (TRASPASO.md): este backend Mempool no expone /outspend, así
que se recorre /api/address/{addr}/txs buscando la tx cuyo vin
referencia el txid:vout de origen — mismo patrón de paginación y
throttling por lotes que scanWallet.

detectPeelingChains cierra el hueco que el check `peeling` de
analyzeTx ya señalaba (confirmar una cadena requiere mirar hacia
adelante): reconstruye tramos de nodos 1-in/2-out conectados por la
señal de cambio combinada, sube a CERTEZA con 3+ saltos y huella de
wallet estable.

MAX_NODES=80 como red de seguridad aparte de maxHops, para no hammer
el nodo del usuario si un salto desemboca en una tx con muchos
outputs. Verificado: balance de sintaxis del bloque Babel y
node --check sobre el fragmento JS puro del motor.
2026-07-16 12:22:28 +02:00
Aitor ee817a8c9b feat: peritaje forense — cambio conductual y comparación de huella
behavioralChangeGuess: para cada output, si se gasta rápido (≤6
bloques) o queda quieto (no gastado todavía). combineChangeSignals
cruza esto con la señal estructural de guessChangeOutput — sube la
certeza cuando coinciden, reporta la discrepancia en vez de forzar
una conclusión cuando no.

compareFingerprints compara la huella de detectWallets entre dos
saltos consecutivos del rastro: un cambio de huella es
INFERENCIA/POSIBLE de cambio de actor o entrada en infraestructura
de un servicio, nunca CERTEZA.
2026-07-16 12:13:05 +02:00
Aitor 2e97a6b822 feat: peritaje forense — heurística de perfil de dirección
addressProfile(addr, addrInfo, addrTxs) clasifica personal vs hot
wallet usando chain_stats (residual/tx_count) y detección de barrido
automático (recibe y reenvía 1-in/1-out sin cambio, pocos bloques
después). No identifica al custodio, solo el patrón de comportamiento;
la atribución a un exchange concreto sigue viniendo de ENTITY_INDEX.

Primera pieza nueva del módulo Peritaje (spec en TRASPASO.md), sobre
la base de los refactors de unionFindCluster y guessChangeOutput.
2026-07-16 12:11:53 +02:00
Aitor 133e292aab refactor: extraer guessChangeOutput de analyzeTx (señales A-F de cambio)
Autocontenida para poder llamarse por salto desde el futuro motor de
rastreo forense, que necesita saber qué output concreto seguir (no solo
si el cambio es identificable). analyzeTx delega en ella sin cambio de
comportamiento — mismas señales, mismos umbrales, mismo texto.
2026-07-16 12:10:09 +02:00
Aitor e89fea499c refactor: extraer unionFindCluster de buildWalletReport a utilidad compartida
Prepara la reutilización del union-find CIOH en el módulo de peritaje
forense (semilla = direcciones del actor rastreado en vez de mis
direcciones). Sin cambio de comportamiento en el informe de wallet.
2026-07-16 12:06:25 +02:00
Aitor a6fe438b06 feat: informe de wallet completo (salud, clusters CIOH, historial); refactor: monitor sin dependencias (fuera express), nombres de proceso limpios; mejora: presentación de checks agrupada con didáctica directa 2026-06-12 22:35:09 +02:00
Aitor f757a01b9b feat: informe de wallet (vinculación CIOH, reutilización, historial); fix: monitor v2 con caché TTL (resuelve CPU alta); mejora presentación de checks 2026-06-10 20:50:47 +02:00
Aitor 2966e79578 docs: documentar watch-only y etiquetas BIP-329 (v1.8.0), guía HTTPS en SETUP 2026-06-04 19:51:16 +02:00
Aitor 3816b0426e feat: watch-only por xpub — derivación BIP32 local, identifica outputs propios (recepción/cambio), selector 20/50/100 direcciones 2026-06-04 19:38:15 +02:00
Aitor 57291a381b feat: etiquetas BIP-329 — importar, listar, mostrar en análisis 2026-06-04 13:28:55 +02:00
Aitor 0333f15be5 docs: changelog v1.7.0 2026-06-04 11:36:34 +02:00
Aitor 82b27977eb docs: README v2 — qué no hace, cómo verificar, funcionalidades actualizadas 2026-06-04 11:22:18 +02:00
Aitor f690e1783b feat: check legacy P2PKH/P2SH; fixtures #2 #6 #7 #8 #9 #10 validados 2026-06-04 11:03:54 +02:00
Aitor 2874f7c933 feat: check tipo script legacy (P2PKH/P2SH), peso -15 2026-06-04 10:47:31 +02:00
Aitor 46636bdba9 fix: check OP_RETURN en motor de análisis, banda BAJA forzada 2026-06-04 10:37:25 +02:00
Aitor dca3758c68 fix: CoinJoin estructural (WabiSabi), checks neutros en mezcla 2026-06-04 10:21:38 +02:00
Aitor d232f0bc0d fix: analisis de privacidad de direcciones se muestra en Lab (no callejon sin salida); texto adaptado a direccion/tx 2026-06-03 20:33:45 +02:00
Aitor 8d0c2620d3 feat: deteccion de dusting de privacidad (POSIBLE) ademas del dust tecnico; fix: tono neutro en dust 2026-06-03 20:03:14 +02:00
Aitor d8a35d94cf fix: checks positivos (CoinJoin) se marcan en verde, no como advertencia; docs: fixtures verificados 2026-06-03 19:29:06 +02:00
Aitor b77d21fd84 fix: batch payment penaliza correctamente + textos coherentes; docs: fixtures verificados 2026-06-03 19:18:47 +02:00
Aitor 889ffeb8dd feat: deteccion de exchanges (4a categoria de entidad) con honestidad sobre la fuente 2026-06-03 10:06:20 +02:00
Aitor f9acd12548 feat: legibilidad del rastro + mejora de contraste de texto en toda la app 2026-06-03 09:33:41 +02:00
Aitor b5f0c81b88 feat: mejoras de legibilidad del rastro - veredicto, jerarquia visual, barras y tooltip de detalle 2026-06-03 09:23:23 +02:00
Aitor 2950559203 feat: distancia a entidad en el rastro + manejo correcto de coinbase como origen 2026-06-02 19:32:07 +02:00
Aitor d3971de3a8 feat: rastro de procedencia recursivo + arreglo falso positivo CoinJoin 2026-06-02 19:17:58 +02:00
Aitor a7c65ed7ec feat: rastro de procedencia paso 2 - recursivo, encadenar saltos hacia atras 2026-06-02 19:01:32 +02:00
Aitor b101ca4210 feat: rastro de procedencia paso 1 - seguir inputs hacia atras bajo demanda 2026-06-02 18:48:16 +02:00
Aitor aa5838c010 cambio: OP_RETURN solo detecta presencia y tamaño, no muestra contenido 2026-06-02 18:24:24 +02:00
Aitor 788ed3bada feat: exportar informe de tx en JSON y Markdown + arreglo atajo Lab→Auditoría 2026-06-01 22:38:34 +02:00
Aitor 0110e84c49 feat: exportar UTXO Map a CSV (local, sin salir del nodo) 2026-06-01 20:58:49 +02:00
Aitor b3dedc1865 docs: añadir CHANGELOG v1.0.0 y limpiar README 2026-06-01 20:37:46 +02:00
Aitor 43829fb570 feat: fase 1 (heuristicas) + entidades OFAC/mining + persistencia de pestañas 2026-05-31 08:19:11 +02:00
Aitor 89766e7fdc feat: detección de entidades OFAC y mining pools (fase 1.5) 2026-05-30 19:56:59 +02:00
Aitor 8f5bb0d2ba feat: mejoras fase 1 - peeling, batch, JoinMarket, Whirlpool OP_RETURN, wallet inferido 2026-05-30 19:40:49 +02:00
Aitor c2b7c37c1e docs: quitar tabla comparativa del README 2026-05-30 19:26:50 +02:00
Aitor 9ee860a78a inicio: dashboard de privacidad Bitcoin con análisis on-chain 2026-05-30 19:12:24 +02:00
3 changed files with 458 additions and 123 deletions
+62
View File
@@ -10,8 +10,70 @@ y el versionado sigue [Versionado Semántico](https://semver.org/lang/es/):
- **MENOR** — características nuevas que no rompen lo anterior - **MENOR** — características nuevas que no rompen lo anterior
- **PARCHE** — arreglos de errores - **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 ## [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 ### Añadido
- **Nueva comprobación: direcciones que quedan vinculadas entre sí.** Era el - **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 hueco de fondo del analizador: medía la privacidad desde el lado de quien
+369 -120
View File
@@ -1460,7 +1460,51 @@
healthMsg = "Tus monedas presentan vinculación significativa o reutilización frecuente. Un observador puede agrupar buena parte de tu actividad."; 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, return { totalTxs, activeCount: activeAddrs.length, reusedAddrs, clusters, linkReasons, history,
polvoRecibido, polvoSinGastar,
health: { band: healthBand, color: healthColor, msg: healthMsg } }; health: { band: healthBand, color: healthColor, msg: healthMsg } };
} }
@@ -2293,9 +2337,32 @@
}; };
const Badge = ({children, color}) => { const c=color||C.green; return <span style={{fontSize:"0.62rem",fontFamily:"monospace",letterSpacing:"0.07em",padding:"2px 6px",borderRadius:3,fontWeight:700,background:`${c}18`,color:c,border:`1px solid ${c}35`,whiteSpace:"nowrap"}}>{children}</span>; }; const Badge = ({children, color}) => { const c=color||C.green; return <span style={{fontSize:"0.62rem",fontFamily:"monospace",letterSpacing:"0.07em",padding:"2px 6px",borderRadius:3,fontWeight:700,background:`${c}18`,color:c,border:`1px solid ${c}35`,whiteSpace:"nowrap"}}>{children}</span>; };
const Card = ({children, style, glow}) => <div style={{background:C.bgCard,border:`1px solid ${C.border}`,borderRadius:8,padding:16,boxShadow:glow?`0 0 28px ${glow}12`:"none",...style}}>{children}</div>; const Card = ({children, style, glow, innerRef}) => <div ref={innerRef} style={{background:C.bgCard,border:`1px solid ${C.border}`,borderRadius:8,padding:16,boxShadow:glow?`0 0 28px ${glow}12`:"none",...style}}>{children}</div>;
const SectionTitle = ({children, accent, icon}) => { const a=accent||C.green; return <div style={{display:"flex",alignItems:"center",gap:8,marginBottom:16}}>{icon&&<span style={{color:a,fontFamily:"monospace",fontSize:"0.8rem"}}>{icon}</span>}<h2 style={{margin:0,fontSize:"0.68rem",fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.2em",color:a}}>{children}</h2><div style={{flex:1,height:1,background:`linear-gradient(to right,${a}40,transparent)`}}/></div>; }; const 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>; 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 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 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>; const DemoBanner = () => <div style={{padding:"8px 14px",background:C.amberMuted,border:`1px solid ${C.amber}30`,borderRadius:6,color:C.amber,fontFamily:"monospace",fontSize:"0.7rem",marginBottom:14}}>Datos de demo — configura tu nodo en CONFIG para ver datos reales</div>;
@@ -2980,15 +3047,45 @@
} }
// ── 2. Peers entrantes ──────────────────────────────────────── // ── 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) { if (btcData?.inbound !== undefined) {
const inbound = btcData.inbound || 0; const inbound = btcData.inbound || 0;
results.push({ const nets = btcData.inboundNets; // {onion:4, i2p:4, ipv4:…}
id:"inbound_peers", label:"Peers entrantes", const clearnet = btcData.clearnetLocal; // direcciones públicas anunciadas
status: inbound > 0 ? "warn" : "ok", const ANON = ["onion", "i2p", "cjdns"];
detail: inbound > 0 const porRed = nets ? Object.entries(nets).map(([n,c]) => `${c} por ${n}`).join(", ") : null;
? `${inbound} peers entrantes detectados. Tu IP puede ser visible en la red Bitcoin. Considera usar solo conexiones salientes o enrutar por Tor/I2P.` const entrantesClearnet = nets
: "Sin peers entrantes. Tu IP no está expuesta directamente en la red Bitcoin.", ? 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 ────────── // ── 3. Mempool accesible solo desde origen correcto ──────────
@@ -3030,14 +3127,31 @@
} }
// ── 5. Swap alto ────────────────────────────────────────────── // ── 5. Swap alto ──────────────────────────────────────────────
if (sysData?.swap) { // Este aviso decía antes que un swap alto era "normal en nodos con
const pct = sysData.swap.percent || 0; // índice completo". No lo es, y decirlo hace que se ignore durante
// semanas un síntoma que sí tiene causa y sí tiene arreglo. El caso
// real que lo cambió: swap al 99% con 18 GB de RAM libre, porque
// Fulcrum tenía db_mem configurado a 16 GB — más de lo que cabe en la
// máquina junto al resto de servicios. Con el swap lleno, el sistema
// se queda sin margen y ante un pico actúa el OOM killer, que elige
// él a quién mata.
//
// El umbral baja a 50% porque a partir de ahí ya conviene mirarlo, no
// cuando queda un 5% libre y es tarde.
const swapStats = sysData?.swap || (sysData?.ram ? { percent: sysData.ram.swap_percent, used_gb: sysData.ram.swap_used_gb } : null);
if (swapStats) {
const pct = swapStats.percent || 0;
const ramLibre = sysData?.ram?.free_gb;
// Swap ocupado teniendo RAM libre es la firma de un servicio con más
// memoria reservada de la que cabe: el kernel apartó páginas y no las
// devuelve por su cuenta.
const conRamLibre = typeof ramLibre === "number" && ramLibre > 2 && pct > 50;
results.push({ results.push({
id:"swap_usage", label:"Uso de SWAP", id:"swap_usage", label:"Uso de SWAP",
status: pct > 95 ? "warn" : "ok", status: pct > 80 ? "error" : pct > 50 ? "warn" : "ok",
detail: pct > 95 detail: pct > 50
? `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}% (${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}%. Uso normal.`, : `SWAP al ${pct}%. Sin presión de memoria.`,
}); });
} }
@@ -3217,19 +3331,149 @@
); );
} }
function BlockExplorer({base}) { // ── Vista de la cadena: mempool y bloques en una sola línea ─────────
// Antes esto eran dos pestañas, y era el mismo eje partido por la mitad:
// "bloques proyectados" (los que se van a minar) vivían en Mempool, y los
// confirmados en Bloques. Es una línea temporal con el ahora en el corte,
// así que se cuenta como una sola historia: lo que viene, el momento
// actual, y lo que ya pasó.
function RedView({base}) {
const {get}=useApi(base); const {get}=useApi(base);
const isMobile=useIsMobile();
const [blocks,setBlocks]=useState([]); const [blocks,setBlocks]=useState([]);
const [selected,setSelected]=useState(null); const [selected,setSelected]=useState(null);
const [blockTxs,setBlockTxs]=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); 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]); }; 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/>; if(loading)return<Spinner/>;
return ( return (
<div style={{display:"flex",flexDirection:"column",gap:6}}> <div style={{display:"flex",flexDirection:"column",gap:12}}>
{!base&&<DemoBanner/>} {!base&&<DemoBanner/>}
<SectionTitle accent={C.purple} icon="◈">Bloques Recientes</SectionTitle>
{/* Comisiones — el contexto para decidir */}
{fees&&(
<Card>
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:12}}>Comisiones recomendadas</div>
<div style={{display:"grid",gridTemplateColumns:`repeat(auto-fill,minmax(${isMobile?"105px":"130px"},1fr))`,gap:10}}>
{[{label:"Próx. bloque",val:`${fees.fastestFee} sat/vB`,color:C.red},{label:"30 min",val:`${fees.halfHourFee} sat/vB`,color:C.amber},{label:"1 hora",val:`${fees.hourFee} sat/vB`,color:C.green},{label:"Económico",val:`${fees.economyFee} sat/vB`,color:C.t2},{label:"Mínimo",val:`${fees.minimumFee} sat/vB`,color:C.t3}].map(f=>(
<div key={f.label} style={{padding:"10px 12px",background:C.bg,borderRadius:6,border:`1px solid ${C.border}`}}><div style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:4}}>{f.label}</div><div style={{fontSize:"0.88rem",color:f.color,fontFamily:"monospace",fontWeight:700}}>{f.val}</div></div>
))}
</div>
{/* La pregunta real */}
<div style={{marginTop:14,paddingTop:12,borderTop:`1px solid ${C.border}`}}>
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",marginBottom:8}}>Si pagas… ¿cuándo entra?</div>
<div style={{display:"flex",gap:8,alignItems:"center",flexWrap:"wrap"}}>
<input value={tarifa} onChange={e=>setTarifa(e.target.value)} type="number" min="0" step="0.1" placeholder="sat/vB"
style={{width:110,background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"8px 10px",color:C.t1,fontFamily:"monospace",fontSize:"0.72rem",outline:"none"}}/>
{estimacion&&(
<span style={{fontSize:"0.68rem",fontFamily:"monospace",color:estimacion.fuera?C.amber:C.green}}>
{estimacion.fuera
? `No entra en los próximos ${estimacion.bloques} bloques${estimacion.minUlt!=null?` — el último proyectado admite desde ${estimacion.minUlt} sat/vB`:""}. Tendría que esperar a que baje la demanda.`
: `Entraría en el bloque +${estimacion.posicion} — unos ${estimacion.minutos} minutos de media.`}
</span>
)}
</div>
<div style={{fontSize:"0.58rem",color:C.t3,fontFamily:"monospace",marginTop:6,lineHeight:1.5}}>
Estimación sobre la mempool de ahora mismo: si llegan transacciones con más comisión, tu sitio se retrasa. Y diez minutos es la media entre bloques, no una promesa.
</div>
</div>
</Card>
)}
{/* Línea temporal: lo que viene ─ ahora ─ lo que pasó */}
<SectionTitle accent={C.amber} icon="◉">Por minar — mempool</SectionTitle>
{mempool&&(
<div style={{display:"grid",gridTemplateColumns:`repeat(auto-fill,minmax(${isMobile?"110px":"140px"},1fr))`,gap:8}}>
{[{label:"Transacciones",val:fmt.num(mempool.count),color:C.amber},{label:"vSize total",val:fmt.mb(mempool.vsize),color:C.blue},{label:"Comisiones",val:fmt.sbtc(mempool.total_fee),color:C.green}].map(s=>(
<Card key={s.label} style={{padding:"12px 14px"}}><div style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:5}}>{s.label}</div><div style={{fontSize:"0.95rem",color:s.color,fontFamily:"monospace",fontWeight:700}}>{s.val}</div></Card>
))}
</div>
)}
{proyectados&&proyectados.length>0&&(
<Card>
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:12}}>Bloques proyectados</div>
{proyectados.slice(0,4).map((block,i)=>{
const feeRange = block.feeRange || [];
const maxFee = Math.round(Math.max(...feeRange, 1));
const minFee = Math.round(Math.min(...feeRange, 1));
const esElTuyo = estimacion && !estimacion.fuera && estimacion.posicion === i+1;
return (
<div key={i} style={{display:"flex",justifyContent:"space-between",alignItems:"center",flexWrap:"wrap",gap:6,padding:"8px 10px",marginBottom:4,borderRadius:6,background:esElTuyo?C.greenMuted:"transparent",border:`1px solid ${esElTuyo?C.green+"40":"transparent"}`}}>
<div>
<span style={{fontSize:"0.7rem",color:C.purple,fontFamily:"monospace",fontWeight:700}}>+{i+1} bloque{i>0?"s":""}</span>
<span style={{fontSize:"0.62rem",color:C.t2,marginLeft:8,fontFamily:"monospace"}}>{fmt.num(block.nTx)} txs</span>
{esElTuyo&&<span style={{fontSize:"0.6rem",color:C.green,marginLeft:8,fontFamily:"monospace",fontWeight:700}}>← tu transacción</span>}
</div>
<div style={{display:"flex",gap:6,flexWrap:"wrap"}}><Badge color={C.amber}>{minFee}{maxFee} sat/vB</Badge><Badge color={C.t3}>{fmt.mb(block.blockVSize)}</Badge></div>
</div>
);
})}
</Card>
)}
{/* El corte: aquí acaba lo que se espera y empieza lo que ya está */}
<div style={{display:"flex",alignItems:"center",gap:10,margin:"2px 0"}}>
<div style={{flex:1,height:1,background:`linear-gradient(to right,transparent,${C.green}60)`}}/>
<span style={{fontSize:"0.58rem",color:C.green,fontFamily:"monospace",letterSpacing:"0.2em",textTransform:"uppercase"}}>ahora</span>
<div style={{flex:1,height:1,background:`linear-gradient(to left,transparent,${C.green}60)`}}/>
</div>
<SectionTitle accent={C.purple} icon="◈">Ya minado — últimos bloques</SectionTitle>
{blocks.map((b,i)=>( {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}`}} <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}> onMouseEnter={e=>e.currentTarget.style.background=C.bgHover} onMouseLeave={e=>e.currentTarget.style.background=selected&&selected.id===b.id?C.purpleMuted:C.bgCard}>
@@ -3239,9 +3483,12 @@
</div> </div>
))} ))}
{selected&&( {selected&&(
<Card style={{marginTop:8}} glow={C.purple}> <Card style={{marginTop:8}} glow={C.purple} innerRef={detalleRef}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:12}}> <div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:12}}>
<span style={{fontFamily:"monospace",color:C.purple,fontWeight:700}}>Bloque #{fmt.num(selected.height)}</span> <div style={{display:"flex",alignItems:"center",gap:10,flexWrap:"wrap"}}>
<span style={{fontFamily:"monospace",color:C.purple,fontWeight:700}}>Bloque #{fmt.num(selected.height)}</span>
<VerEnMempool base={base} tipo="bloque" id={selected.id}/>
</div>
<button onClick={()=>{setSelected(null);setBlockTxs(null);}} style={{background:"none",border:"none",color:C.t2,cursor:"pointer",fontSize:"1rem",fontFamily:"monospace"}}>✕</button> <button onClick={()=>{setSelected(null);setBlockTxs(null);}} style={{background:"none",border:"none",color:C.t2,cursor:"pointer",fontSize:"1rem",fontFamily:"monospace"}}>✕</button>
</div> </div>
<div style={{fontSize:"0.68rem",fontFamily:"monospace",color:C.t2,wordBreak:"break-all",marginBottom:12}}>{selected.id}</div> <div style={{fontSize:"0.68rem",fontFamily:"monospace",color:C.t2,wordBreak:"break-all",marginBottom:12}}>{selected.id}</div>
@@ -3249,7 +3496,7 @@
<Tag label="Transacciones" value={fmt.num(selected.tx_count)} color={C.blue}/> <Tag label="Transacciones" value={fmt.num(selected.tx_count)} color={C.blue}/>
<Tag label="Tamaño" value={fmt.mb(selected.size)} color={C.t1}/> <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="Fees totales" value={selected.extras&&selected.extras.totalFees!=null?fmt.sbtc(selected.extras.totalFees):"-"} color={C.amber}/>
<Tag label="Fee mediana" value={selected.extras&&selected.extras.medianFee!=null?`${selected.extras.medianFee} sat/vB`:"-"} color={C.amber}/> <Tag label="Fee mediana" value={selected.extras&&selected.extras.medianFee!=null?`${(Math.round(selected.extras.medianFee*100)/100)} sat/vB`:"-"} color={C.amber}/>
<Tag label="Minero" value={selected.extras&&selected.extras.pool?selected.extras.pool.name:"-"} color={C.green}/> <Tag label="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}/> <Tag label="Minado" value={fmt.date(selected.timestamp)} color={C.t1}/>
</div> </div>
@@ -3258,11 +3505,31 @@
{blockTxs&&blockTxs.slice(0,5).map((tx,i)=>( {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}`}}> <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> <span style={{fontSize:"0.68rem",fontFamily:"monospace",color:C.blue}}>{fmt.hash(tx.txid)}</span>
<div style={{display:"flex",gap:6}}><Badge color={C.amber}>{tx.fee} sat</Badge><Badge color={C.t3}>{tx.size}B</Badge></div> <div style={{display:"flex",gap:6,alignItems:"center"}}><Badge color={C.amber}>{tx.fee} sat</Badge><Badge color={C.t3}>{tx.size}B</Badge><VerEnMempool base={base} tipo="tx" id={tx.txid} texto="↗"/></div>
</div> </div>
))} ))}
</Card> </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> </div>
); );
} }
@@ -3324,86 +3591,6 @@
); );
} }
function MempoolView({base}) {
const {get}=useApi(base);
const isMobile=useIsMobile();
const [data,setData]=useState(null);
const [fees,setFees]=useState(null);
const [histo,setHisto]=useState(null);
const [feeHistory,setFeeHistory]=useState(null);
const [period,setPeriod]=useState("1w");
useEffect(()=>{
Promise.all([get("/api/mempool",MOCK_MEMPOOL),get("/api/v1/fees/recommended",MOCK_FEES),get("/api/v1/fees/mempool-blocks",MOCK_MEMPOOLBLOCKS)])
.then(([m,f,h])=>{setData(m);setFees(f);setHisto(h);});
get("/api/v1/mining/blocks/fee-rates/1w", MOCK_FEE_HISTORY).then(d=>setFeeHistory(d));
},[base]);
if(!data||!fees)return<Spinner/>;
// Stats from fee history
const feeStats = feeHistory ? (() => {
const fs = feeHistory.map(d=>d.avgFee_50||d.avgFee||0).filter(v=>v>0);
if (fs.length===0) return null;
return {
min: Math.min(...fs),
max: Math.max(...fs),
avg: Math.round(fs.reduce((a,b)=>a+b,0)/fs.length),
};
})() : null;
return (
<div style={{display:"flex",flexDirection:"column",gap:12}}>
{!base&&<DemoBanner/>}
<SectionTitle accent={C.amber} icon="◉">Mempool</SectionTitle>
<div style={{display:"grid",gridTemplateColumns:`repeat(auto-fill,minmax(${isMobile?"110px":"140px"},1fr))`,gap:8}}>
{[{label:"Transacciones",val:fmt.num(data.count),color:C.amber},{label:"vSize total",val:fmt.mb(data.vsize),color:C.blue},{label:"Fee total",val:fmt.sbtc(data.total_fee),color:C.green}].map(s=>(
<Card key={s.label} style={{padding:"12px 14px"}}><div style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:5}}>{s.label}</div><div style={{fontSize:"0.95rem",color:s.color,fontFamily:"monospace",fontWeight:700}}>{s.val}</div></Card>
))}
</div>
<Card>
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:12}}>Fees Recomendados</div>
<div style={{display:"grid",gridTemplateColumns:`repeat(auto-fill,minmax(${isMobile?"105px":"130px"},1fr))`,gap:10}}>
{[{label:"Próx. bloque",val:`${fees.fastestFee} sat/vB`,color:C.red},{label:"30 min",val:`${fees.halfHourFee} sat/vB`,color:C.amber},{label:"1 hora",val:`${fees.hourFee} sat/vB`,color:C.green},{label:"Económico",val:`${fees.economyFee} sat/vB`,color:C.t2},{label:"Mínimo",val:`${fees.minimumFee} sat/vB`,color:C.t3}].map(f=>(
<div key={f.label} style={{padding:"10px 12px",background:C.bg,borderRadius:6,border:`1px solid ${C.border}`}}><div style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:4}}>{f.label}</div><div style={{fontSize:"0.88rem",color:f.color,fontFamily:"monospace",fontWeight:700}}>{f.val}</div></div>
))}
</div>
</Card>
{/* Fee Intelligence */}
<Card>
<div style={{display:"flex",flexDirection:isMobile?"column":"row",alignItems:isMobile?"flex-start":"center",justifyContent:"space-between",gap:isMobile?8:0,marginBottom:12}}>
<div style={{fontSize:"0.62rem",color:C.amber,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:isMobile?"0.1em":"0.2em",display:"flex",alignItems:"center",gap:8}}>
<span></span> Fee Intelligence — Última semana
</div>
{feeStats&&(
<div style={{display:"flex",gap:8,flexWrap:"wrap"}}>
<Badge color={C.green}>mín {feeStats.min} sat/vB</Badge>
<Badge color={C.amber}>media {feeStats.avg} sat/vB</Badge>
<Badge color={C.red}>máx {feeStats.max} sat/vB</Badge>
</div>
)}
</div>
{!feeHistory ? <Spinner/> : <FeeChart data={feeHistory} isMobile={isMobile}/>}
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",marginTop:8}}>
sat/vB por hora · Los fines de semana suelen tener fees más bajos
</div>
</Card>
{histo&&histo.length>0&&(
<Card>
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:12}}>Bloques Proyectados</div>
{histo.slice(0,4).map((block,i)=>{
const feeRange = block.feeRange || [];
const maxFee = Math.round(Math.max(...feeRange, 1));
const minFee = Math.round(Math.min(...feeRange, 1));
return <div key={i} style={{display:"flex",justifyContent:"space-between",alignItems:"center",flexWrap:"wrap",gap:6,padding:"8px 0",borderBottom:i<3?`1px solid ${C.border}`:"none"}}><div><span style={{fontSize:"0.7rem",color:C.purple,fontFamily:"monospace",fontWeight:700}}>+{i+1} bloque{i>0?"s":""}</span><span style={{fontSize:"0.62rem",color:C.t2,marginLeft:8,fontFamily:"monospace"}}>{fmt.num(block.nTx)} txs</span></div><div style={{display:"flex",gap:6,flexWrap:"wrap"}}><Badge color={C.amber}>{minFee}{maxFee} sat/vB</Badge><Badge color={C.t3}>{fmt.mb(block.blockVSize)}</Badge></div></div>;
})}
</Card>
)}
</div>
);
}
function Lab({base}) { function Lab({base}) {
const {get}=useApi(base); const {get}=useApi(base);
@@ -3457,7 +3644,7 @@
{err&&<div style={{padding:"10px 14px",background:C.redMuted,border:`1px solid ${C.red}30`,borderRadius:6,color:C.red,fontFamily:"monospace",fontSize:"0.75rem"}}>Error: {err}</div>} {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/>} {loading&&<Spinner/>}
{result&&result.type==="tx"&&<TxResult tx={result.data} onAnalyze={q=>{window.__setTab&&window.__setTab("auditoria",q);}}/>} {result&&result.type==="tx"&&<TxResult tx={result.data} onAnalyze={q=>{window.__setTab&&window.__setTab("auditoria",q);}}/>}
{result&&result.type==="address"&&<AddressResult addr={result.data} analysis={analysis}/>} {result&&result.type==="address"&&<AddressResult addr={result.data} analysis={analysis} base={base}/>}
{result&&result.type==="block"&&<BlockResult block={result.data}/>} {result&&result.type==="block"&&<BlockResult block={result.data}/>}
<UTXOMap base={base}/> <UTXOMap base={base}/>
</div> </div>
@@ -4618,7 +4805,12 @@
const [hijos,setHijos]=useState({}); // {vinIndex: {loading, child:{txData,fluyo,vout}, err}} const [hijos,setHijos]=useState({}); // {vinIndex: {loading, child:{txData,fluyo,vout}, err}}
const ana = useMemo(()=>analyzeTx(txData),[txData]); const ana = useMemo(()=>analyzeTx(txData),[txData]);
const {marcas, esCoinbase} = useMemo(()=>marcasDeTx(txData,ana),[txData,ana]); 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 inputs = txData.vin || [];
const [hover,setHover]=useState(false); const [hover,setHover]=useState(false);
@@ -4677,10 +4869,17 @@
}}> }}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",gap:8,flexWrap:"wrap",marginBottom:6}}> <div style={{display:"flex",justifyContent:"space-between",alignItems:"center",gap:8,flexWrap:"wrap",marginBottom:6}}>
<div style={{display:"flex",alignItems:"center",gap:8,position:"relative"}}> <div style={{display:"flex",alignItems:"center",gap:8,position:"relative"}}>
<a href={mempoolUrl} target="_blank" rel="noopener noreferrer" {mempoolUrl ? (
style={{fontSize:"0.64rem",fontFamily:"monospace",color:C.blue,textDecoration:"none",wordBreak:"break-all"}}> <a href={mempoolUrl} target="_blank" rel="noopener noreferrer"
{abrevTxid(txData.txid)} ↗ title="Abrir en tu propia instancia de Mempool — no sale de tu red"
</a> style={{fontSize:"0.64rem",fontFamily:"monospace",color:C.blue,textDecoration:"none",wordBreak:"break-all"}}>
{abrevTxid(txData.txid)} ↗
</a>
) : (
<span style={{fontSize:"0.64rem",fontFamily:"monospace",color:C.blue,wordBreak:"break-all"}}>
{abrevTxid(txData.txid)}
</span>
)}
<button <button
onMouseEnter={()=>setHover(true)} onMouseEnter={()=>setHover(true)}
onMouseLeave={()=>setHover(false)} onMouseLeave={()=>setHover(false)}
@@ -4874,8 +5073,10 @@
return ( return (
<div key={i} style={{marginBottom:10,paddingLeft:10,borderLeft:`2px solid ${C.border}`}}> <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={{display:"flex",justifyContent:"space-between",alignItems:"center",gap:8,flexWrap:"wrap"}}>
<div style={{fontSize:"0.65rem",fontFamily:"monospace",color:C.t1}}> <div style={{fontSize:"0.65rem",fontFamily:"monospace",color:C.t1,display:"flex",alignItems:"center",gap:8,flexWrap:"wrap"}}>
Input #{i+1}<span style={{color:C.t2,marginLeft:8}}>{fmt.num(vin.prevout?.value||0)} sat</span> <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> </div>
{!est.child&&!est.loading&&( {!est.child&&!est.loading&&(
<button onClick={()=>seguir(i)} <button onClick={()=>seguir(i)}
@@ -5198,6 +5399,35 @@
</div> </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 */} {/* 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={{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}}> <div style={{width:48,height:48,borderRadius:"50%",border:`3px solid ${walletReport.health.color}`,display:"flex",alignItems:"center",justifyContent:"center",flexShrink:0}}>
@@ -5289,6 +5519,12 @@
{t.txid.slice(0,20)}… {t.txid.slice(0,20)}…
</div> </div>
<div style={{fontSize:"0.58rem",color:bandColor,fontFamily:"monospace",fontWeight:600,flexShrink:0}}>{t.band||"—"}</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> </div>
); );
})} })}
@@ -5361,7 +5597,10 @@
<Card glow={C.purple}> <Card glow={C.purple}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"flex-start",marginBottom:12}}> <div style={{display:"flex",justifyContent:"space-between",alignItems:"flex-start",marginBottom:12}}>
<div> <div>
<div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",marginBottom:4}}>TRANSACCIÓN ANALIZADA</div> <div style={{display:"flex",alignItems:"center",gap:8,marginBottom:4,flexWrap:"wrap"}}>
<span style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace"}}>TRANSACCIÓN ANALIZADA</span>
<VerEnMempool base={base} tipo="tx" id={tx.txid}/>
</div>
<div style={{fontSize:"0.68rem",fontFamily:"monospace",color:C.blue,wordBreak:"break-all"}}>{tx.txid}</div> <div style={{fontSize:"0.68rem",fontFamily:"monospace",color:C.blue,wordBreak:"break-all"}}>{tx.txid}</div>
{labels&&labels.get(tx.txid)&&( {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}}> <div style={{marginTop:8,display:"flex",alignItems:"center",gap:8,padding:"6px 10px",background:C.purpleMuted,border:`1px solid ${C.purple}60`,borderRadius:6}}>
@@ -5736,7 +5975,10 @@
<div style={{display:"flex",flexDirection:"column",gap:6}}> <div style={{display:"flex",flexDirection:"column",gap:6}}>
{report.attributed.map((a,i)=>( {report.attributed.map((a,i)=>(
<div key={i} style={{padding:"8px 10px",background:C.bgCard,border:`1px solid ${C.amber}30`,borderRadius:6}}> <div key={i} style={{padding:"8px 10px",background:C.bgCard,border:`1px solid ${C.amber}30`,borderRadius:6}}>
<div style={{fontSize:"0.6rem",color:C.t1,fontFamily:"monospace",marginBottom:4,wordBreak:"break-all"}}>{a.address}</div> <div style={{display:"flex",alignItems:"center",gap:8,marginBottom:4,flexWrap:"wrap"}}>
<span style={{fontSize:"0.6rem",color:C.t1,fontFamily:"monospace",wordBreak:"break-all"}}>{a.address}</span>
<VerEnMempool base={base} tipo="direccion" id={a.address} texto="↗"/>
</div>
<div style={{display:"flex",flexDirection:"column",gap:2}}> <div style={{display:"flex",flexDirection:"column",gap:2}}>
{a.fundamentos.map((f,j)=>( {a.fundamentos.map((f,j)=>(
<div key={j} style={{fontSize:"0.56rem",color:C.t2,fontFamily:"monospace"}}> <div key={j} style={{fontSize:"0.56rem",color:C.t2,fontFamily:"monospace"}}>
@@ -5759,7 +6001,10 @@
<div style={{display:"flex",flexDirection:"column",gap:4}}> <div style={{display:"flex",flexDirection:"column",gap:4}}>
{report.unspentFunds.map((u,i)=>( {report.unspentFunds.map((u,i)=>(
<div key={i} style={{padding:"7px 10px",background:C.greenMuted,border:`1px solid ${C.green}30`,borderRadius:6}}> <div key={i} style={{padding:"7px 10px",background:C.greenMuted,border:`1px solid ${C.green}30`,borderRadius:6}}>
<div style={{fontSize:"0.6rem",color:C.t1,fontFamily:"monospace",wordBreak:"break-all"}}>{u.address}</div> <div style={{display:"flex",alignItems:"center",gap:8,flexWrap:"wrap"}}>
<span style={{fontSize:"0.6rem",color:C.t1,fontFamily:"monospace",wordBreak:"break-all"}}>{u.address}</span>
<VerEnMempool base={base} tipo="direccion" id={u.address} texto="↗"/>
</div>
<div style={{fontSize:"0.58rem",color:C.green,fontFamily:"monospace",fontWeight:700}}>{fmt.num(u.amount)} sat</div> <div style={{fontSize:"0.58rem",color:C.green,fontFamily:"monospace",fontWeight:700}}>{fmt.num(u.amount)} sat</div>
</div> </div>
))} ))}
@@ -5877,7 +6122,7 @@
); );
} }
function AddressResult({addr, analysis}) { function AddressResult({addr, analysis, base}) {
const c=addr.chain_stats||{funded_txo_sum:0,spent_txo_sum:0,tx_count:0}; const 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 m=addr.mempool_stats||{funded_txo_sum:0,spent_txo_sum:0};
const balance=c.funded_txo_sum-c.spent_txo_sum; const balance=c.funded_txo_sum-c.spent_txo_sum;
@@ -5892,7 +6137,13 @@
return ( return (
<div style={{display:"flex",flexDirection:"column",gap:10}}> <div style={{display:"flex",flexDirection:"column",gap:10}}>
<Card glow={C.green}> <Card glow={C.green}>
<div style={{marginBottom:12}}><div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",marginBottom:4}}>DIRECCIÓN</div><div style={{fontSize:"0.72rem",fontFamily:"monospace",color:C.green,wordBreak:"break-all"}}>{addr.address}</div></div> <div style={{marginBottom:12}}>
<div style={{display:"flex",alignItems:"center",gap:8,marginBottom:4,flexWrap:"wrap"}}>
<span style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace"}}>DIRECCIÓN</span>
<VerEnMempool base={base} tipo="direccion" id={addr.address}/>
</div>
<div style={{fontSize:"0.72rem",fontFamily:"monospace",color:C.green,wordBreak:"break-all"}}>{addr.address}</div>
</div>
<div style={{display:"grid",gridTemplateColumns:"repeat(auto-fill,minmax(130px,1fr))",gap:12,paddingTop:12,borderTop:`1px solid ${C.border}`}}> <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={importesIncompletos?noDisponible:fmt.btc(balance)} color={importesIncompletos?C.t3:C.green}/>
<Tag label="No confirmado" value={unconf!==0?fmt.btc(unconf):"0"} color={unconf>0?C.amber:C.t2}/> <Tag label="No confirmado" value={unconf!==0?fmt.btc(unconf):"0"} color={unconf>0?C.amber:C.t2}/>
@@ -6628,8 +6879,7 @@
const TABS=[ const TABS=[
{id:"node", label:"Nodo", icon:"◉"}, {id:"node", label:"Nodo", icon:"◉"},
{id:"blocks", label:"Bloques", icon:"◈"}, {id:"red", label:"Cadena", icon:"◈"},
{id:"mempool", label:"Mempool", icon:"≋"},
{id:"lab", label:"LAB", icon:"⌕"}, {id:"lab", label:"LAB", icon:"⌕"},
{id:"auditoria", label:"AUDITORÍA", icon:"⌖"}, {id:"auditoria", label:"AUDITORÍA", icon:"⌖"},
{id:"peritaje", label:"PERITAJE", icon:"🔍"}, {id:"peritaje", label:"PERITAJE", icon:"🔍"},
@@ -6714,7 +6964,7 @@
<H>Sistema</H> <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}}>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}}>RAM</strong> — memoria realmente usada por procesos, excluyendo caché del kernel (mismo cálculo que htop).</Li>
<Li><strong style={{color:C.t1}}>SWAP</strong> — memoria de intercambio en uso. Un valor alto sostenido (como el de Fulcrum) es normal pero conviene monitorizarlo.</Li> <Li><strong style={{color:C.t1}}>SWAP</strong> — memoria de intercambio en uso. Debería estar cerca de cero. Un valor alto <em>no</em> es normal aunque se repita: significa que algún servicio tiene reservada más memoria de la que cabe en la máquina, y deja al sistema sin margen ante un pico. Si sube teniendo RAM libre, el sospechoso habitual es <Code>db_mem</Code> en la configuración de Fulcrum — se sube para acelerar la sincronización inicial y es fácil olvidarse de bajarlo.</Li>
<Li><strong style={{color:C.t1}}>Procesos destacados</strong> — los procesos que más memoria consumen en tiempo real.</Li> <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> <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> <P>Panel de logs de Bitcoin Core y Fulcrum. Muestra las últimas 80 entradas con filtrado por nivel (todos / warn / error). Pulsa ↻ para actualizar.</P>
@@ -6881,8 +7131,7 @@
</div> </div>
<div style={{padding:"18px 14px",maxWidth:860,margin:"0 auto",width:"100%",flex:1}}> <div style={{padding:"18px 14px",maxWidth:860,margin:"0 auto",width:"100%",flex:1}}>
{tab==="node" && <NodeOverview base={config.url}/>} {tab==="node" && <NodeOverview base={config.url}/>}
{tab==="blocks" && <BlockExplorer base={config.url}/>} {tab==="red" && <RedView 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==="lab"?"block":"none"}}><Lab base={config.url}/></div>
<div style={{display:tab==="auditoria"?"block":"none"}}><Auditoria base={config.url} initialQuery={auditQuery}/></div> <div style={{display:tab==="auditoria"?"block":"none"}}><Auditoria base={config.url} initialQuery={auditQuery}/></div>
{tab==="peritaje" && <PeritajeForense base={config.url}/>} {tab==="peritaje" && <PeritajeForense base={config.url}/>}
+27 -3
View File
@@ -290,12 +290,36 @@ async function getBitcoinInfo() {
// connections_in/out existen desde Core 0.21; fallback a getpeerinfo si no // connections_in/out existen desde Core 0.21; fallback a getpeerinfo si no
let inbound = netinfo.connections_in; let inbound = netinfo.connections_in;
let outbound = netinfo.connections_out; let outbound = netinfo.connections_out;
let peerList = null;
if (inbound === undefined || outbound === undefined) { if (inbound === undefined || outbound === undefined) {
const peers = await rpcCall("getpeerinfo").catch(() => null); peerList = await rpcCall("getpeerinfo").catch(() => null);
inbound = peers ? peers.filter(p => p.inbound).length : 0; inbound = peerList ? peerList.filter(p => p.inbound).length : 0;
outbound = peers ? peers.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 { return {
inboundNets, clearnetLocal,
localAddrCount: (netinfo.localaddresses || []).length,
version: `v${Math.floor(netinfo.version / 10000)}.${Math.floor((netinfo.version % 10000) / 100)}.${netinfo.version % 100}`, version: `v${Math.floor(netinfo.version / 10000)}.${Math.floor((netinfo.version % 10000) / 100)}.${netinfo.version % 100}`,
blocks: info.blocks, blocks: info.blocks,
headers: info.headers, headers: info.headers,