63 Commits
Author SHA1 Message Date
pikaro 98621f956e 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
pikaro d3caaf6186 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
pikaro 96517894c5 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
pikaro 0acfb35bf8 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
pikaro bb9b92d9b9 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
pikaro fd9127880b 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
pikaro 0223bb16ef 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
pikaro a696e55bef chore: ignorar COHERENCIA.md (documento de trabajo privado) 2026-07-27 13:40:21 +02:00
pikaro 8a5efad2ed 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
pikaro 6f721a535c 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
pikaro 020f9318aa 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
pikaro fd2e38fd45 chore: ignorar MEJORAS.md (documento de trabajo privado) 2026-07-27 13:01:14 +02:00
pikaro 924b9069aa 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
pikaro e0bc42c34e 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
pikaro eac6208405 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
pikaro fdd80b689a chore: ignorar HALLAZGOS-PERITAJE.md (documento de trabajo privado) 2026-07-27 12:25:00 +02:00
pikaro f15d82a352 chore: ignorar PRUEBA-PERITAJE.md (documento de trabajo privado) 2026-07-27 12:04:53 +02:00
pikaro 826b9cb8e2 fix: pinear versión de Babel standalone en el CDN (7.23.10) 2026-07-16 22:24:51 +02:00
pikaro 29e8e184e8 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
pikaro 2f5cc9a0ec 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
pikaro 5f4f891d1f 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
pikaro 27f23cc967 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
pikaro b37b9a125a 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
pikaro c86134cc24 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
pikaro ebc1a8017f 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
pikaro 0ecd54eb1d 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
pikaro 84e16cb3fd 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
pikaro d6dc8f6a90 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
pikaro 14a33ed29a 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
pikaro f5cffa3f8a 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
pikaro de3ab5cc7e 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
pikaro 55297ecae6 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
pikaro 2c46ceda31 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
pikaro 1c25622b0e 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
pikaro 031c55714f docs: documentar watch-only y etiquetas BIP-329 (v1.8.0), guía HTTPS en SETUP 2026-06-04 19:51:16 +02:00
pikaro 35c74ea8f2 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
pikaro 1ddacdf86e feat: etiquetas BIP-329 — importar, listar, mostrar en análisis 2026-06-04 13:28:55 +02:00
pikaro 8ae135e739 docs: changelog v1.7.0 2026-06-04 11:36:34 +02:00
pikaro 4354e661c2 docs: README v2 — qué no hace, cómo verificar, funcionalidades actualizadas 2026-06-04 11:22:18 +02:00
pikaro 68ef8f2179 feat: check legacy P2PKH/P2SH; fixtures #2 #6 #7 #8 #9 #10 validados 2026-06-04 11:03:54 +02:00
pikaro 26a8650583 feat: check tipo script legacy (P2PKH/P2SH), peso -15 2026-06-04 10:47:31 +02:00
pikaro 2b68a7b7c7 fix: check OP_RETURN en motor de análisis, banda BAJA forzada 2026-06-04 10:37:25 +02:00
pikaro 22ea8b5add fix: CoinJoin estructural (WabiSabi), checks neutros en mezcla 2026-06-04 10:21:38 +02:00
pikaro aa7f23de33 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
pikaro 0fc9cb8980 feat: deteccion de dusting de privacidad (POSIBLE) ademas del dust tecnico; fix: tono neutro en dust 2026-06-03 20:03:14 +02:00
pikaro 625fd9e989 fix: checks positivos (CoinJoin) se marcan en verde, no como advertencia; docs: fixtures verificados 2026-06-03 19:29:06 +02:00
pikaro d0b10d4a99 fix: batch payment penaliza correctamente + textos coherentes; docs: fixtures verificados 2026-06-03 19:18:47 +02:00
pikaro 01fe01d39d feat: deteccion de exchanges (4a categoria de entidad) con honestidad sobre la fuente 2026-06-03 10:06:20 +02:00
pikaro 57f9576860 feat: legibilidad del rastro + mejora de contraste de texto en toda la app 2026-06-03 09:33:41 +02:00
pikaro dd78d35bdb feat: mejoras de legibilidad del rastro - veredicto, jerarquia visual, barras y tooltip de detalle 2026-06-03 09:23:23 +02:00
pikaro 19211af4c8 feat: distancia a entidad en el rastro + manejo correcto de coinbase como origen 2026-06-02 19:32:07 +02:00
pikaro 55a8cf456b feat: rastro de procedencia recursivo + arreglo falso positivo CoinJoin 2026-06-02 19:17:58 +02:00
pikaro a260e376bb feat: rastro de procedencia paso 2 - recursivo, encadenar saltos hacia atras 2026-06-02 19:01:32 +02:00
pikaro 37ae3aef70 feat: rastro de procedencia paso 1 - seguir inputs hacia atras bajo demanda 2026-06-02 18:48:16 +02:00
pikaro ee67c2164e cambio: OP_RETURN solo detecta presencia y tamaño, no muestra contenido 2026-06-02 18:24:24 +02:00
pikaro 11b74c92f5 feat: exportar informe de tx en JSON y Markdown + arreglo atajo Lab→Auditoría 2026-06-01 22:38:34 +02:00
pikaro 5aedec0cb3 feat: exportar UTXO Map a CSV (local, sin salir del nodo) 2026-06-01 20:58:49 +02:00
pikaro fe2aa0597c docs: añadir CHANGELOG v1.0.0 y limpiar README 2026-06-01 20:37:46 +02:00
pikaro 6626cd1325 feat: fase 1 (heuristicas) + entidades OFAC/mining + persistencia de pestañas 2026-05-31 08:19:11 +02:00
pikaro cb4535c35d feat: detección de entidades OFAC y mining pools (fase 1.5) 2026-05-30 19:56:59 +02:00
pikaro e71bd08f76 feat: mejoras fase 1 - peeling, batch, JoinMarket, Whirlpool OP_RETURN, wallet inferido 2026-05-30 19:40:49 +02:00
pikaro 8f902ba06e docs: quitar tabla comparativa del README 2026-05-30 19:26:50 +02:00
pikaro e64292531e inicio: dashboard de privacidad Bitcoin con análisis on-chain 2026-05-30 19:12:24 +02:00
12 changed files with 1259 additions and 374 deletions
+3
View File
@@ -26,3 +26,6 @@ TRASPASO.md
PRUEBA-PERITAJE.md PRUEBA-PERITAJE.md
HALLAZGOS-PERITAJE.md HALLAZGOS-PERITAJE.md
MEJORAS.md MEJORAS.md
COHERENCIA.md
GIT.md
+163
View File
@@ -10,6 +10,111 @@ 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.12.1] — 2026-07-27
### Corregido
- **El monitor mostraba una CPU por proceso que engañaba.** La columna `%CPU`
de `ps aux` no es el consumo actual sino la **media desde que el proceso
arrancó** (tiempo de CPU dividido por tiempo de vida). Un proceso que trabajó
mucho hace tres días seguía apareciendo alto para siempre, y se leía como si
estuviera saturando la máquina. Se detectó porque los porcentajes por proceso
no cambiaban NUNCA entre lecturas mientras la CPU global sí variaba.
- Ahora se mide de verdad: dos lecturas de `/proc/PID/stat` separadas 500 ms,
el mismo método que ya usaba `getCpuUsage` para el total. Las dos esperas
corren en paralelo dentro del mismo `Promise.all`, así que no cuesta tiempo.
- La interfaz muestra el **porcentaje sobre el total de la máquina**, que es
lo que suele querer saberse, con el porcentaje de un núcleo y la media de
`ps` en el tooltip para poder comparar con `top`.
- Comprobado con carga artificial: el proceso ocupado marcaba 100% de un
núcleo (25% de un sistema de 4) mientras `ps` daba 152%, imposible para un
proceso de un solo hilo.
### Cambiado
- El UTXO Map pedía las transacciones de contexto con `fetchWithTimeout`
directo, saltándose la caché añadida en la 1.11.0. Era el único punto que no
la aprovechaba. Ahora pasa por `get()`, así que esas transacciones —ya
confirmadas, e inmutables— se guardan toda la sesión.
### Documentación
- **Reescrita la guía de instalación.** `SETUP.md` no era una guía: más de la
mitad eran los pasos personales de git y Gitea del autor ("PASO 1 — Crear el
repo en Gitea"), que se han movido a un documento privado. Ahora es una guía
real, con una comprobación tras cada paso para saber si vas bien sin
descubrirlo al final con una pantalla en blanco.
- **Corregida la configuración de nginx documentada, que estaba rota.** El
README indicaba `alias /var/www/txoko/dashboard.html` — un alias a un
ARCHIVO. Desde la 1.11.0, con las librerías en `vendor/` y rutas relativas,
eso hace que el navegador no las encuentre y la página quede en blanco sin
ningún error visible. Debe apuntar al **directorio**. Es el fallo más común
de esta instalación y ahora está avisado en tres sitios, con una comprobación
concreta (`curl` mirando el `content-type`, no solo el código 200) para
descartarlo.
- Los pasos de instalación no mencionaban las librerías del frontend, sin las
cuales la aplicación no arranca. Ya están integradas en el orden correcto.
- Eliminados del repositorio los datos personales del autor: rutas de su
máquina en la documentación y, sobre todo, su nombre de usuario del sistema
y su ruta de instalación dentro de `txoko-metrics.service`, que llevaban ahí
desde el primer commit. El servicio usa ahora marcadores que hay que ajustar
antes de instalarlo.
- README: estructura del repositorio actualizada (faltaban `tests/`,
`instalar-fuentes.sh` y `FIXTURES.md`) y sección de instalación reducida a un
resumen que remite a SETUP.md, para que haya una sola fuente de verdad.
- SETUP.md incluye ahora tabla de problemas frecuentes y cómo actualizar.
---
## [1.12.0] — 2026-07-27
### Añadido
- **Auditoría de PSBT: revisar una transacción ANTES de firmarla.** Hasta ahora
toda la app era diagnóstico *a posteriori* — te contaba con detalle lo que ya
había pasado y no podías cambiar. Esta es la primera pieza que llega a tiempo.
Sustituye al antiguo "validador", que solo comprobaba que el archivo empezara
por `psbt` y decía su tamaño.
- **Parser completo de BIP174 escrito desde cero**, sin librerías, como el
resto del proyecto. Validado contra los cinco vectores inválidos que publica
el propio estándar: los rechaza los cinco, cada uno con un mensaje que
explica en castellano qué está mal (truncada, sin salidas, ya firmada,
sin transacción interna, con claves repetidas).
- **Todo el análisis es offline.** Una PSBT bien formada ya lleva dentro los
importes y scripts de sus entradas, así que no hace falta consultar el nodo:
funciona con el nodo sincronizando, y el nodo ni se entera.
- Acepta base64, hexadecimal o abrir el archivo `.psbt` directamente.
- **Aviso crítico sobre xpubs incrustados.** Las PSBT suelen llevar dentro las
claves públicas maestras de la cartera, y están hechas para compartirse — se
mandan por correo o chat para que el resto firme. Quien reciba el archivo
puede derivar todas las direcciones, presentes y futuras, y ver el saldo y
el historial completos. No puede gastar, pero lo ve todo. Ningún wallet
avisa de esto.
- Detecta además: cambio identificable por tipo de dirección, importes
redondos que delatan cuál es el pago, envíos a la propia cartera, carteras
multifirma (con su M-de-N) y PSBTs incompletas sin los importes de entrada.
- Cada aviso mantiene el formato del resto de la app — Hecho, Consecuencia y
**Qué puedes hacer** —, que aquí cobra sentido literal: todavía estás a
tiempo de cambiar la transacción.
### Corregido
- **Revisión de la criptografía watch-only.** Se verificó la derivación completa
contra los vectores oficiales de los estándares: RIPEMD-160 (los seis del
estándar, incluido el de un millón de caracteres), secp256k1, BIP32 (vectores
1 y 2) y bech32/BIP173. **La matemática es correcta y no se tocó.** Los fallos
estaban en la validación de la entrada:
- **No se comprobaba la suma de verificación del xpub.** Un carácter mal
copiado se aceptaba sin protestar y generaba 200 direcciones ajenas: el
usuario habría visto su cartera "sin actividad" y se habría quedado
tranquilo. Falso negativo silencioso, el mismo patrón que el resto de fallos
de esta jornada. Nuevo `decodeBase58Check`.
- **No se validaba la longitud** del material decodificado (deben ser 78
bytes) ni que la clave pública fuese comprimida (0x02/0x03).
- **`tpub` se trataba como mainnet.** La red se detectaba mirando si el texto
empezaba por "tb", "u" o "v" — y un `tpub`, el formato más común de testnet,
empieza por "t" pero no por "tb". Generaba direcciones `bc1…` a partir de
claves de testnet. Ahora se detecta por bytes de versión, que son
inequívocos, con las diez variantes (x/y/z/Y/Z y t/u/v/U/V).
- **`deriveChildPubkey` aceptaba índices endurecidos**, matemáticamente
imposibles desde una clave pública. No era alcanzable desde la interfaz,
pero una función criptográfica debe defenderse sola.
- Añadida la carpeta `tests/` con las cuatro baterías y su documentación,
incluido qué **no** cubren: no sustituyen una auditoría externa.
--- ---
## [1.11.0] — 2026-07-27 ## [1.11.0] — 2026-07-27
### Añadido ### Añadido
@@ -42,6 +147,50 @@ y el versionado sigue [Versionado Semántico](https://semver.org/lang/es/):
`system-metrics.js` en el servidor. `system-metrics.js` en el servidor.
- Medido: repetir un rastreo forense ya explorado cuesta **cero peticiones**. - Medido: repetir un rastreo forense ya explorado cuesta **cero peticiones**.
### Cambiado
- **Un hit de OFAC ya no penaliza la banda de privacidad.** Era un error
conceptual con consecuencias ideológicas: que tus monedas hayan pasado por una
dirección sancionada no revela nada más sobre ti — tu privacidad es idéntica
antes y después. Lo que cambia es la probabilidad de que un servicio regulado
te bloquee un depósito, que es otro eje: censurabilidad, no privacidad.
Restar puntos ahí equivalía a dar por buena la idea de "monedas contaminadas",
justo la que la fungibilidad de Bitcoin niega. Se sigue informando del riesgo
real, ahora como aviso de censura y con el contexto que faltaba: la lista OFAC
es una decisión política de un gobierno concreto, no una determinación
judicial, y fuera de su jurisdicción solo llega a través de intermediarios que
la aplican.
- **RBF pasa a informativo, sin penalización.** Es buena práctica —permite
desatascar una transacción sin sobrepagar de entrada— y hoy lo activan casi
todos los wallets, así que distingue poco. Restar puntos empujaba al usuario
hacia una decisión peor para esconder una señal débil.
- Checks con inferencia fuerte (`round_numbers`, `unnecessary_input`,
`output_type_mismatch`) reescritos con el patrón Hecho / Interpretación /
Consecuencia que ya usaban `dust` e `input_type_mixing`. Antes afirmaban de
más ("un analista puede identificarlo sin ambigüedad"); ahora explican cuándo
la señal falla y por qué son PROBABLE y no CERTEZA.
- `timing`: el texto didáctico describía patrones entre varias transacciones
cuando el check solo ve una. Además se aclara que la hora mostrada es la del
bloque, no la de la firma — entre ambas puede haber horas de mempool.
### Documentación
- README: nueva sección **"Sí, esto es chain analysis"**. El peritaje usa las
mismas técnicas que las empresas de vigilancia de cadena y negarlo restaba
credibilidad ante quien lee el código. Se explica qué cambia —quién lo
ejecuta, sobre qué, dónde se detiene, quién se queda el informe— y el motivo
de fondo: la misma herramienta que sigue el rastro de un ladrón demuestra lo
fácil que es seguir el tuyo.
- El informe forense advierte del coste de denunciar: entregarlo vincula tu
identidad legal con esas direcciones de forma permanente, ante una autoridad
que puede compartir el expediente con empresas de análisis. Antes se
recomendaba denunciar sin mencionar el precio.
- Aviso junto a los botones de exportación: el archivo lleva tus direcciones y
tu declaración dentro. Nada ha salido de tu red hasta ese punto; a partir de
ahí depende de quien lo descarga.
- Corregido "un único archivo HTML autocontenido" (aparecía cuatro veces): desde
la 1.11.0 las librerías viven en `vendor/`. Se sustituye por una descripción
exacta que además dice más — lo que lees en el archivo es lo que se ejecuta,
no hay versión compilada que auditar por separado.
### Corregido ### Corregido
- **El informe ya no atribuye al actor direcciones del otro lado de un - **El informe ya no atribuye al actor direcciones del otro lado de un
CoinJoin.** Detectado probando un rastro que atraviesa un Whirlpool real: el CoinJoin.** Detectado probando un rastro que atraviesa un Whirlpool real: el
@@ -59,6 +208,20 @@ y el versionado sigue [Versionado Semántico](https://semver.org/lang/es/):
`mixer` del conjunto de direcciones del actor y del union-find, y la `mixer` del conjunto de direcciones del actor y del union-find, y la
atribución por huella salta esos nodos. atribución por huella salta esos nodos.
- Verificado con el mismo caso: de 10 direcciones atribuidas a 0. - Verificado con el mismo caso: de 10 direcciones atribuidas a 0.
- **El informe de wallet ya no da un aprobado optimista cuando faltan datos.**
Mismo patrón que el fallo del peritaje, en la pieza central del proyecto:
`scanWallet` usaba `.catch(()=>[])` en las tres consultas del escaneo, así que
una dirección que el nodo no pudo servir quedaba indistinguible de una
dirección sin actividad. Sus transacciones no se traían, no contaban para la
reutilización, no entraban en el union-find y no aparecían en el historial —
y el informe daba su valoración de salud sin mencionar que le faltaban datos.
El sesgo iba siempre hacia el optimismo: menos actividad vista es mejor nota.
- Las tres consultas usan ahora `getStrict` y registran los fallos en
`report.scanErrors`, con la dirección, la rama y en qué fase ocurrió.
- Aviso en rojo **antes** de la banda de salud (una valoración leída sin saber
que faltan datos es peor que ninguna valoración), y bloque equivalente al
principio del export Markdown.
- **Tampoco atribuye al actor las direcciones de un custodio.** Misma raíz que - **Tampoco atribuye al actor las direcciones de un custodio.** Misma raíz que
el fallo anterior, descubierta probando la poda por rama con un depósito real el fallo anterior, descubierta probando la poda por rama con un depósito real
en Bitfinex: el cluster CIOH ya excluía las direcciones de custodio, pero la en Bitfinex: el cluster CIOH ya excluía las direcciones de custodio, pero la
+83 -66
View File
@@ -22,17 +22,51 @@ Tan importante como saber qué hace es saber qué no hace:
- **No envía datos a ningún servidor externo** — ni analíticas, ni telemetría, ni consultas a APIs de terceros - **No envía datos a ningún servidor externo** — ni analíticas, ni telemetría, ni consultas a APIs de terceros
- **No toca claves privadas** — el watch-only usa solo tu clave pública (xpub); no puede gastar fondos ni conoce tu semilla - **No toca claves privadas** — el watch-only usa solo tu clave pública (xpub); no puede gastar fondos ni conoce tu semilla
- **No identifica a otras personas** — es una herramienta de auto-auditoría, no de vigilancia - **No llega nunca a una identidad** — se detiene en "hot wallet de [servicio]" o "custodio no identificado", y lo dice explícitamente. Ver la sección siguiente
- **No da consejos de inversión** — analiza privacidad on-chain, nada más
- **No afirma más de lo que sabe** — distingue siempre entre CERTEZA, PROBABLE e POSIBLE - **No afirma más de lo que sabe** — distingue siempre entre CERTEZA, PROBABLE e POSIBLE
- **No tiene número de score exacto** — muestra bandas (ALTA/MEDIA/BAJA); la precisión numérica sería falsa - **No tiene número de score exacto** — muestra bandas (ALTA/MEDIA/BAJA); la precisión numérica sería falsa
- **No muestra el contenido de OP_RETURN** — solo informa de su presencia y tamaño - **No muestra el contenido de OP_RETURN** — solo informa de su presencia y tamaño
--- ---
## Sí, esto es chain analysis
Conviene decirlo claro, porque el código está a la vista y cualquiera puede
comprobarlo: el peritaje forense aplica **las mismas técnicas que las empresas
de vigilancia de cadena**. Rastreo hacia adelante salto a salto, clustering por
CIOH, perfilado de direcciones, huella de software, un índice de direcciones de
exchanges. Es el arsenal de Chainalysis, técnica por técnica.
Fingir lo contrario sería insultar tu inteligencia. Lo que cambia no es el
método, es todo lo demás:
- **Quién lo ejecuta.** Corre en tu nodo, con tus datos, bajo tu control. No
hay un tercero que acumule los resultados ni los venda a quien pague.
- **Sobre quién.** Tu propia actividad, o el rastro de unas monedas que te
quitaron a ti. No es un servicio que perfile a desconocidos por encargo.
- **Dónde se detiene.** En un servicio, nunca en una persona. Y cuando una
heurística no llega, el informe dice que no llega en vez de rellenar el hueco.
- **Quién se queda el informe.** Tú. No sale de tu red y decides qué hacer con
él — incluido no hacer nada.
Y hay una razón más, la que de verdad justifica que esto exista:
> **La misma herramienta que sigue el rastro de un ladrón demuestra lo fácil
> que es seguir el tuyo.**
No hay forma más honesta de entender qué puede deducir un observador de tus
monedas que ejecutar sus heurísticas sobre ellas y leer el resultado. Un
adversario con estas capacidades ya existe, tenga o no tú una herramienta para
verlo. Conocer el arma no es adoptarla: es dejar de estar ciego ante ella.
Si esto te parece una línea demasiado fina, es una objeción razonable. El
código está entero en un archivo para que puedas juzgarlo tú.
---
## Cómo verificar que nada sale de tu red ## Cómo verificar que nada sale de tu red
El código es un único archivo HTML autocontenido. Puedes auditarlo tú mismo: Todo el código de análisis vive en un único archivo HTML, sin minificar y sin proceso de compilación. Puedes auditarlo tú mismo:
```bash ```bash
# Buscar cualquier llamada a dominios externos # Buscar cualquier llamada a dominios externos
@@ -62,6 +96,17 @@ Consecuencia práctica: **el dashboard funciona sin conexión a internet**. Solo
- Bandas de privacidad con distinción explícita CERTEZA / PROBABLE / POSIBLE - Bandas de privacidad con distinción explícita CERTEZA / PROBABLE / POSIBLE
- Exportación del informe en JSON y Markdown - Exportación del informe en JSON y Markdown
**Auditoría de PSBT — antes de firmar**
La única función de la app que llega a tiempo: el resto te cuenta lo que ya
pasó, esta te avisa cuando todavía puedes cambiar la transacción.
- Parser completo de BIP174 escrito desde cero, validado contra los cinco vectores inválidos del propio estándar
- **Todo offline**: una PSBT ya lleva dentro los importes y scripts de sus entradas, así que no se consulta el nodo. Funciona con el nodo sincronizando
- Avisa de las **claves públicas maestras incrustadas**: las PSBT suelen llevarlas dentro y están hechas para compartirse — quien reciba el archivo puede ver todas tus direcciones, tu saldo y tu historial. No puede gastar, pero lo ve todo
- Detecta cambio identificable por tipo de dirección, importes redondos que delatan cuál es el pago, envíos a tu propia cartera, carteras multifirma y PSBTs incompletas
- Acepta base64, hexadecimal o el archivo `.psbt` que exporta Sparrow
**Peritaje forense** **Peritaje forense**
- Rastreo de fondos robados o perdidos hacia adelante, salto a salto, desde una transacción de origen hasta un punto de parada natural (custodio identificado, dilución, CoinJoin, o fondos aún sin gastar) - Rastreo de fondos robados o perdidos hacia adelante, salto a salto, desde una transacción de origen hasta un punto de parada natural (custodio identificado, dilución, CoinJoin, o fondos aún sin gastar)
- A demanda: tú decides cuándo se explora cada salto con el botón "Seguir el rastro" — nada corre sin que lo pidas, igual que el rastro de procedencia hacia atrás. Puedes generar el informe con lo explorado hasta ese momento, sin terminar el rastreo entero - A demanda: tú decides cuándo se explora cada salto con el botón "Seguir el rastro" — nada corre sin que lo pidas, igual que el rastro de procedencia hacia atrás. Puedes generar el informe con lo explorado hasta ese momento, sin terminar el rastreo entero
@@ -89,7 +134,7 @@ Consecuencia práctica: **el dashboard funciona sin conexión a internet**. Solo
- Conversor sat/BTC/fiat - Conversor sat/BTC/fiat
- Validador de dirección - Validador de dirección
- Detector OP_RETURN - Detector OP_RETURN
- Decodificador PSBT y transacción raw - Decodificador de transacción raw
--- ---
@@ -117,66 +162,29 @@ Probado sobre Ubuntu Server 24.04 con HP EliteDesk (i5, 32GB RAM, 2TB NVMe).
## Instalación ## Instalación
### 1. Clonar el repositorio **La guía completa está en [SETUP.md](SETUP.md)** — paso a paso, con una
comprobación después de cada uno para que sepas si vas bien sin tener que
descubrirlo al final.
```bash Resumen de lo que implica:
git clone https://git.bitcointxoko.org/pikaro/txoko-dashboard.git
cd txoko-dashboard
```
### 2. Copiar el dashboard 1. Clonar el repositorio.
2. Copiar `dashboard.html` a un **directorio** propio servido por nginx.
3. Descargar las librerías (React, Babel y las fuentes) a `vendor/`, junto al
dashboard, y verificarlas por hash. No están en el repo: son de terceros y
ocupan ~3 MB.
4. Opcionalmente, instalar el monitor del sistema como servicio systemd.
5. Configurar nginx: el dashboard, un proxy a la API de Mempool y otro al
monitor.
```bash Un aviso que ahorra disgustos: en nginx, el `alias` del dashboard debe apuntar
cp dashboard.html /var/www/txoko/dashboard.html al **directorio** (con barra final), no al archivo `dashboard.html`. Si apunta
# o donde lo sirvas con nginx al archivo, el navegador no encuentra `vendor/` y verás una página en blanco
``` sin ningún error visible. Es el fallo más común, y en SETUP.md hay una
comprobación concreta para descartarlo.
### 3. Copiar el backend de métricas Cuando termines, abre `http://TU-IP:4080/dashboard/`**con la barra final** —,
pulsa CONFIG e introduce la URL de tu Mempool.
```bash
cp system-metrics.js /home/armg/txoko/system-metrics.js
```
Editar `system-metrics.js` y añadir tus credenciales RPC de Bitcoin Core
(el archivo del repo usa placeholders).
### 4. Configurar el servicio systemd
```bash
sudo cp txoko-metrics.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable txoko-metrics
sudo systemctl start txoko-metrics
```
### 5. Configurar nginx
Añadir a tu configuración de nginx:
```nginx
# Dashboard
location /dashboard {
alias /var/www/txoko/dashboard.html;
}
# API Mempool (ajusta el puerto según tu instalación)
location /api/ {
proxy_pass http://localhost:8999;
}
# Métricas del sistema
location /system/ {
proxy_pass http://127.0.0.1:4082;
}
```
### 6. Abrir en el navegador
```
http://TU-IP-TAILSCALE:4080/dashboard
```
Introduce la URL de tu Mempool en el modal de configuración y empieza a analizar.
--- ---
@@ -184,16 +192,25 @@ Introduce la URL de tu Mempool en el modal de configuración y empieza a analiza
``` ```
txoko-dashboard/ txoko-dashboard/
├── dashboard.html # Frontend completo (HTML + CSS + JS en un solo archivo) ├── dashboard.html # La aplicación entera (HTML + CSS + JS en un archivo)
├── system-metrics.js # Backend de métricas del nodo (Node.js) ├── system-metrics.js # Monitor del nodo (Node.js) — opcional
├── txoko-metrics.service # Servicio systemd ├── txoko-metrics.service # Servicio systemd para el monitor
├── README.md ├── instalar-fuentes.sh # Descarga IBM Plex al nodo y genera su CSS
├── SETUP.md ├── tests/ # Pruebas de la criptografía contra vectores oficiales
├── SETUP.md # Guía de instalación
├── FIXTURES.md # Transacciones reales para probar las heurísticas
├── CHANGELOG.md ├── CHANGELOG.md
├── README.md
└── LICENSE └── LICENSE
``` ```
El frontend es un único archivo HTML autocontenido. Sin bundler, sin npm, sin proceso de build. Babel transpila el JSX en el navegador. Puedes auditarlo todo abriendo el archivo. Tras la instalación, junto al `dashboard.html` queda además un directorio
`vendor/` con las librerías y las fuentes. No está en el repositorio: se
descarga y se verifica durante la instalación (ver [SETUP.md](SETUP.md)).
El frontend es un único archivo HTML. Sin bundler, sin npm, sin proceso de build: Babel transpila el JSX en el navegador, así que lo que lees en el archivo es exactamente lo que se ejecuta — no hay una versión compilada que auditar por separado.
Las librerías (React, Babel, las fuentes) viven aparte, en `vendor/`, servidas desde tu propio nodo y verificadas por hash durante la instalación. Se dejan fuera del repo a propósito: son código de terceros, y mezclarlas con el tuyo haría más difícil auditar lo que de verdad importa.
--- ---
+239 -233
View File
@@ -1,187 +1,93 @@
# Cómo subir Txoko a Gitea — paso a paso # Instalación de Txoko Node Dashboard
Instrucciones exactas. Copiar y pegar en la terminal del Mac. Guía completa, de principio a fin. Sigue los pasos en orden y comprueba cada
No hace falta entender git para seguir esto. uno antes de pasar al siguiente: cada comprobación te dice si vas bien, en vez
de dejarte descubrirlo al final con una pantalla en blanco.
Todo se hace **en el nodo**, salvo donde se indique lo contrario.
--- ---
## PASO 1 — Crear el repo en Gitea (una sola vez) ## Antes de empezar
1. Abre tu instancia de Gitea en el navegador Txoko no habla con la red Bitcoin directamente: se apoya en cosas que ya
2. Clic en el **+** (arriba a la derecha) → "New Repository" tienes montadas. Necesitas:
3. Rellena:
- **Repository Name:** `txoko-dashboard`
- **Description:** `Suite de auditoría de privacidad Bitcoin para nodos propios`
- **Visibility:** Private (o Public si quieres compartirlo con la comunidad)
- **Initialize repository:** NO marcar (ya traemos nuestros archivos)
4. Clic en "Create Repository"
5. Gitea te muestra una página con instrucciones — copia la URL del repo,
será algo como: `https://gitea.tu-comunidad/tu-usuario/txoko-dashboard.git`
--- | Requisito | Para qué |
|---|---|
| **Bitcoin Core** con `txindex=1` | consultar cualquier transacción, no solo las tuyas |
| **Mempool self-hosted** ([mempool/mempool](https://github.com/mempool/mempool)) | la API que Txoko consulta |
| **Fulcrum** ([cculianu/Fulcrum](https://github.com/cculianu/Fulcrum)) | índice de direcciones; Mempool lo usa por debajo |
| **nginx** | sirve el dashboard y hace de proxy hacia lo anterior |
| **Node.js ≥ 18** | solo para el monitor del sistema (paso 4) |
## PASO 2 — Configurar git en el Mac (una sola vez, si no lo tienes) Si Mempool self-hosted ya te funciona en el navegador, tienes todo lo demás.
Comprueba que la API responde antes de seguir. Ajusta el puerto al tuyo:
```bash ```bash
git config --global user.name "tu nombre" curl -s http://127.0.0.1:8999/api/v1/fees/recommended
git config --global user.email "tu@email.com"
``` ```
Comprueba que git está instalado: Debe devolver un JSON con comisiones. Si no responde, arregla eso primero:
```bash Txoko no puede funcionar sin ello.
git --version
``` > **Nunca expongas estos puertos a internet.** Txoko está pensado para
Si no está: `brew install git` > accederse por Tailscale, VPN o red local.
--- ---
## PASO 3 — Crear el repo local y primer commit (una sola vez) ## Paso 1 — Descargar los archivos
```bash ```bash
# Crear carpeta del proyecto en el Mac git clone https://git.bitcointxoko.org/pikaro/txoko-dashboard.git
mkdir ~/txoko-dashboard cd txoko-dashboard
cd ~/txoko-dashboard
# Copiar los archivos del repo que te he preparado
# (descarga los archivos de esta conversación y cópialos aquí)
# Inicializar git
git init
git branch -M main
# Añadir todos los archivos
git add .
# Primer commit — el historial empieza aquí
git commit -m "inicio: dashboard de privacidad Bitcoin con análisis on-chain"
``` ```
--- ---
## PASO 4 — Conectar con Gitea y subir (una sola vez) ## Paso 2 — Elegir dónde vivirá el dashboard
Esta decisión condiciona el resto, así que conviene hacerla a conciencia.
Necesitas un **directorio** propio para Txoko. No vale colocar el
`dashboard.html` suelto en cualquier sitio: la aplicación carga sus librerías
desde un subdirectorio `vendor/` **junto al propio archivo**, así que ambos
tienen que convivir.
```bash ```bash
# Sustituye la URL por la de tu repo de Gitea sudo mkdir -p /var/www/txoko
git remote add origin https://gitea.tu-comunidad/tu-usuario/txoko-dashboard.git sudo cp dashboard.html /var/www/txoko/
# Subir
git push -u origin main
``` ```
Gitea te pedirá usuario y contraseña la primera vez. Puedes usar otra ruta; solo recuerda cuál es, porque aparece en los pasos 3 y 5.
Si quieres evitar introducirlos cada vez, crea un token en
Gitea → Settings → Applications → "Generate Token" y úsalo como contraseña.
--- ---
## PASO 5 — Flujo de trabajo normal (cada vez que yo te dé un archivo nuevo) ## Paso 3 — Instalar las librerías del frontend
Txoko usa React y Babel, y la tipografía IBM Plex. **Se sirven desde tu propio
nodo, nunca desde un CDN externo.**
El motivo es de privacidad, no de comodidad: un CDN no vería qué transacciones
analizas, pero sí recibiría tu IP y la hora cada vez que abres el dashboard —
sabría que usas Txoko, cuándo y desde dónde. Justo el metadato que esta
herramienta enseña a proteger. Sirviéndolas en local, *"ninguna consulta sale
de tu red"* es literal, y el dashboard funciona sin conexión a internet.
No están en el repositorio porque son código de terceros y ocupan unos 3 MB.
### React y Babel
```bash ```bash
cd ~/txoko-dashboard sudo mkdir -p /var/www/txoko/vendor
cd /var/www/txoko/vendor
# Copiar el archivo actualizado descargado de esta conversación
cp ~/Downloads/txoko-dashboard.html ./dashboard.html
# Ver qué cambió (opcional pero útil)
git diff dashboard.html
# Registrar el cambio con un mensaje descriptivo
git add dashboard.html
git commit -m "fix: descripción breve de lo que se arregló"
# Subir a Gitea
git push
# Copiar al nodo (igual que antes)
scp dashboard.html armg@100.116.19.86:/home/armg/txoko/dashboard.html
```
---
## Mensajes de commit — ejemplos
El mensaje va después de `-m` y describe QUÉ cambiaste.
No tiene que ser perfecto, solo útil para ti en el futuro.
```
"fix: timeout en consultas al nodo"
"feat: score por bandas ALTA/MEDIA/BAJA"
"fix: responsive pestaña Mempool en móvil"
"fix: detección Whirlpool con tolerancia 2%"
"feat: fingerprinting wallet completo"
"fix: umbral dust por tipo de salida"
"docs: actualizar README con nuevas funcionalidades"
```
Convención (opcional pero ordenada):
- `fix:` — corrige algo que no funcionaba bien
- `feat:` — añade algo nuevo
- `docs:` — solo documentación
---
## Si algo sale mal
**Subiste algo que no querías:**
```bash
git revert HEAD
git push
```
**Quieres volver a una versión anterior:**
```bash
git log --oneline # ver el historial
git checkout HASH_DEL_COMMIT -- dashboard.html # recuperar ese archivo
```
**Ver el historial:**
```bash
git log --oneline
```
---
## Resumen del flujo
```
Claude te da dashboard.html
cp ~/Downloads/dashboard.html ./dashboard.html
git add . && git commit -m "descripción"
git push
scp dashboard.html armg@100.116.19.86:/home/armg/txoko/
```
Cuatro comandos después de la descarga. Siempre los mismos.
---
## Librerías del frontend (obligatorio)
El dashboard usa React y Babel. **Se sirven desde tu propio nodo, no desde un
CDN.** El motivo es de privacidad, no de comodidad: un CDN externo no ve qué
transacciones analizas, pero sí ve que usas Txoko, cuándo y desde qué IP
pública — exactamente la clase de metadato que esta herramienta enseña a
proteger. Sirviéndolas en local, *"ninguna consulta sale de tu red"* pasa a ser
literal, y el dashboard funciona sin conexión a internet.
Estas librerías **no están en el repo** (son de terceros y pesan ~3 MB). Se
descargan una vez, en el nodo:
```bash
sudo mkdir -p /usr/share/nginx/html/vendor
cd /usr/share/nginx/html/vendor
sudo curl -sSLO https://unpkg.com/react@18.3.1/umd/react.production.min.js sudo curl -sSLO https://unpkg.com/react@18.3.1/umd/react.production.min.js
sudo curl -sSLO https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js sudo curl -sSLO https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js
sudo curl -sSL -o babel.min.js https://unpkg.com/@babel/standalone@7.23.10/babel.min.js sudo curl -sSL -o babel.min.js https://unpkg.com/@babel/standalone@7.23.10/babel.min.js
``` ```
### Verifica lo que has descargado **Comprueba que has recibido lo que esperabas.** No te fíes: verifícalo.
No te fíes: comprueba que los archivos son los que deben ser.
```bash ```bash
sha256sum *.js sha256sum *.js
@@ -195,118 +101,218 @@ Debe dar exactamente esto:
d949f1c3687aedadcedac85261865f29b17cd273997e7f6b2bfc53b2f9d4c4dd react.production.min.js d949f1c3687aedadcedac85261865f29b17cd273997e7f6b2bfc53b2f9d4c4dd react.production.min.js
``` ```
Si algún hash no coincide, **no uses esos archivos**: significa que has Si alguno no coincide, **para aquí**: has recibido algo distinto de lo esperado.
recibido algo distinto a lo esperado.
Las versiones están fijadas a propósito (`18.3.1`, `7.23.10`). Usar un rango Las versiones están fijadas a propósito. Usar un rango como `react@18` dejaría
como `react@18` dejaría que el servidor decidiera qué versión te entrega, y que el servidor decidiera qué versión te entrega, y cambiaría con el tiempo sin
cambiaría con el tiempo sin que te enteres. que te enteres.
### Dónde deben quedar los archivos ### Las fuentes
El dashboard las busca en `vendor/` con **ruta relativa**, es decir, en un
subdirectorio junto al propio `dashboard.html`. Así funciona tanto si sirves el
dashboard en la raíz como bajo un prefijo, sin tocar nginx.
Con la configuración típica de Mempool self-hosted:
```nginx
location /dashboard {
alias /usr/share/nginx/html/;
}
```
…el `dashboard.html` vive en `/usr/share/nginx/html/` y las librerías deben ir
en `/usr/share/nginx/html/vendor/` — que es justo donde las deja el comando de
arriba.
**Importante:** abre el dashboard **con la barra final** (`.../dashboard/`, no
`.../dashboard`). Sin ella, el navegador resuelve las rutas relativas un nivel
por encima y no encuentra las librerías.
Para comprobar que nginx las sirve bien, lo que importa no es solo el código
200 sino el tipo de contenido:
```bash ```bash
curl -sk -o /dev/null -w '%{http_code} %{content_type}\n' \ sudo /ruta/al/repo/instalar-fuentes.sh /var/www/txoko/vendor/fonts
https://localhost:4081/dashboard/vendor/react.production.min.js
``` ```
Debe responder `200 application/javascript`. Si devuelve `200 text/html`, El script descarga IBM Plex (licencia libre OFL), comprueba cada archivo y
nginx está entregando otra cosa (por ejemplo el index de Mempool) y las rutas aborta si algo falla. Son unos 350 KB.
no son las correctas.
--- ---
## HTTPS para watch-only (opcional) ## Paso 4 — El monitor del sistema (opcional)
La función watch-only (derivar tus direcciones desde el xpub) usa la Web Crypto Alimenta la pestaña NODO: CPU, RAM, disco, estado de Bitcoin Core y logs. Sin
API del navegador, que **solo funciona sobre HTTPS**. Si sirves el dashboard por esto el resto de la aplicación funciona igual, solo que esa pestaña queda vacía.
HTTP, watch-only no estará disponible — el resto de la app funciona igual. Las
etiquetas BIP-329 no necesitan HTTPS.
Si quieres usar watch-only y tu nodo va por HTTP (por ejemplo, acceso por
Tailscale sin certificado), puedes generar un certificado autofirmado. Es lo que
sigue. Todo se hace en el nodo.
### 1. Generar el certificado autofirmado
Sustituye la IP por la de tu nodo (Tailscale, local, etc.):
```bash ```bash
sudo openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \ mkdir -p ~/txoko
-keyout /etc/ssl/private/txoko.key \ cp system-metrics.js ~/txoko/
-out /etc/ssl/certs/txoko.crt \
-subj "/CN=100.116.19.86" \
-addext "subjectAltName=IP:100.116.19.86"
``` ```
### 2. Configurar nginx para servir HTTPS Edita `~/txoko/system-metrics.js` y sustituye los dos marcadores por tus
credenciales RPC de Bitcoin Core (las de tu `bitcoin.conf`):
Añade un bloque `server` que escuche en un puerto con SSL (por ejemplo 4081), ```js
apuntando al certificado recién creado e incluyendo la misma configuración que const RPC_USER = "TU_RPC_USER";
tu servidor HTTP: const RPC_PASS = "TU_RPC_PASSWORD";
```
Instala el servicio. Antes revisa el `.service`, porque trae rutas y usuario
que probablemente tengas que ajustar a los tuyos:
```bash
nano txoko-metrics.service
sudo cp txoko-metrics.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now txoko-metrics
```
**Comprueba que arrancó:**
```bash
systemctl is-active txoko-metrics # debe decir: active
curl -s http://127.0.0.1:4082/system/info | head -c 200
```
> Si `systemctl status` muestra un punto que no es verde pero pone
> `active (running)`, está bien. Lo que importa es el texto.
---
## Paso 5 — Configurar nginx
Aquí es donde más gente se atasca, así que léelo con calma.
Añade esto a tu configuración de nginx (si usas Mempool self-hosted, dentro del
mismo bloque `server`):
```nginx ```nginx
server { # Dashboard — OJO: alias a un DIRECTORIO, con barra final
listen 4081 ssl; location /dashboard/ {
listen [::]:4081 ssl; alias /var/www/txoko/;
server_name _; index dashboard.html;
ssl_certificate /etc/ssl/certs/txoko.crt; try_files $uri $uri/ /dashboard/dashboard.html;
ssl_certificate_key /etc/ssl/private/txoko.key; }
ssl_session_timeout 4h;
ssl_protocols TLSv1.3;
ssl_prefer_server_ciphers on;
# Incluye aquí tu misma config (location /dashboard, /api/, etc.) # API de Mempool — ajusta el puerto al de tu instalación
# Si ya tienes esos location en un snippet, basta con incluirlo: location /api/ {
# include /etc/nginx/snippets/tu-config.conf; proxy_pass http://127.0.0.1:8999;
}
# Monitor del sistema (solo si hiciste el paso 4)
location /system/ {
proxy_pass http://127.0.0.1:4082;
} }
``` ```
Si el bloque `location /dashboard` ya viene de un snippet que incluyes, **no lo **El detalle que rompe la instalación:** el `alias` tiene que apuntar al
dupliques** dentro del server SSL: nginx dará error `duplicate location`. **directorio**, con barra final, no al archivo `dashboard.html`. Si apunta al
archivo, el navegador no encontrará `vendor/` y verás una **página en blanco**
sin ningún mensaje de error. Es el fallo más común de esta instalación.
Comprueba y recarga: Recarga nginx:
```bash ```bash
sudo nginx -t && sudo systemctl reload nginx sudo nginx -t && sudo systemctl reload nginx
``` ```
### 3. Confiar el certificado la primera vez ---
Abre `https://TU-IP:4081/dashboard/` en el navegador. Como el certificado es ## Paso 6 — Comprobar que funciona
autofirmado, el navegador avisará de que la conexión no es privada. Es esperado
—lo creaste tú— y es seguro en tu propia red:
- **Safari:** clic en "visitar este sitio web" (abajo del aviso) y confirma Antes de abrir el navegador, verifica desde el propio nodo. Ajusta el puerto:
- **Chrome/Brave:** "Configuración avanzada" → "Acceder a TU-IP (no seguro)"
A partir de ahí el navegador recuerda la excepción y la Web Crypto API queda ```bash
disponible, así que watch-only funcionará. # El dashboard llega
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:4080/dashboard/
> Nota: un certificado autofirmado es perfectamente válido para uso personal en # Y las librerías TAMBIÉN — esto es lo que suele fallar
> tu propia red. El aviso del navegador existe porque no hay una autoridad curl -s -o /dev/null -w '%{http_code} %{content_type}\n' \
> certificadora de por medio, no porque la conexión sea insegura — el tráfico va http://127.0.0.1:4080/dashboard/vendor/react.production.min.js
> cifrado igual. ```
La segunda debe responder **`200 application/javascript`**.
Si devuelve `200 text/html` la instalación **no** está bien: nginx te está
sirviendo otra cosa (normalmente el index de Mempool) en lugar del archivo.
Revisa el `alias` del paso 5. Fíjate en que aquí no basta con mirar el código
200 — hay que mirar el tipo de contenido.
Ahora sí, abre en el navegador:
```
http://TU-IP:4080/dashboard/
```
**Con la barra final.** Sin ella, el navegador busca las librerías un nivel por
encima y no las encuentra.
Pulsa **CONFIG** e introduce la URL de tu Mempool (por ejemplo
`http://TU-IP:4080`). Dale a PROBAR: si dice "Conexión exitosa", ya está.
### Si ves una página en blanco
Abre la consola del navegador (F12). Si aparece `SyntaxError: Unexpected
token '<'`, es exactamente el problema del `alias` del paso 5: nginx está
devolviendo HTML donde debería devolver JavaScript.
---
## HTTPS — necesario para el watch-only (opcional)
La función watch-only (derivar tus direcciones desde el xpub) usa la Web Crypto
API del navegador, que **solo funciona sobre HTTPS**. Por HTTP el resto de la
aplicación funciona igual; solo esa función queda deshabilitada. Las etiquetas
BIP-329 no necesitan HTTPS.
Si accedes por Tailscale o red local no tendrás un certificado válido, así que
toca generar uno autofirmado.
### 1. Generar el certificado
Sustituye la IP por la de tu nodo:
```bash
sudo openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
-keyout /etc/ssl/private/txoko.key \
-out /etc/ssl/certs/txoko.crt \
-subj "/CN=100.64.0.5" \
-addext "subjectAltName=IP:100.64.0.5"
```
El `subjectAltName` no es opcional: sin él los navegadores modernos rechazan el
certificado aunque el `CN` sea correcto.
### 2. Servirlo en nginx
Duplica tu bloque `server` en otro puerto (4081 en este ejemplo) añadiendo:
```nginx
listen 4081 ssl;
ssl_certificate /etc/ssl/certs/txoko.crt;
ssl_certificate_key /etc/ssl/private/txoko.key;
```
Los `location` son los mismos del paso 5. Recarga con `sudo nginx -t &&
sudo systemctl reload nginx`.
### 3. Aceptar el certificado la primera vez
Al entrar en `https://TU-IP:4081/dashboard/` el navegador avisará de que el
certificado no es de confianza. Es lo esperado: lo has firmado tú. Acepta la
excepción una vez.
Que sea autofirmado no lo hace menos seguro **para este uso**: cifra igual, y
como el certificado lo has generado tú en tu propia máquina, nadie externo
puede suplantarlo. Lo que no tienes es el respaldo de una autoridad
certificadora, que aquí no aporta nada porque el servidor y el cliente son
tuyos.
---
## Actualizar a una versión nueva
```bash
git pull
sudo cp dashboard.html /var/www/txoko/
```
Y recarga el navegador con **Ctrl+Shift+R** (o Cmd+Shift+R en Mac) para saltarte
la caché.
Las librerías de `vendor/` no hace falta volver a descargarlas salvo que el
CHANGELOG diga lo contrario. Si además cambia `system-metrics.js`, cópialo de
nuevo (conservando tus credenciales) y reinicia con
`sudo systemctl restart txoko-metrics`.
---
## Problemas frecuentes
| Síntoma | Causa habitual |
|---|---|
| Página en blanco, consola con `Unexpected token '<'` | El `alias` de nginx apunta al archivo y no al directorio (paso 5) |
| Página en blanco al entrar sin barra final | Entra en `/dashboard/`, con barra |
| Aparece "DEMO · SIN NODO" | Falta configurar la URL del Mempool en CONFIG |
| La pestaña NODO está vacía | El servicio `txoko-metrics` no corre, o falta el `location /system/` |
| Watch-only no aparece | Estás entrando por HTTP; requiere HTTPS |
| La app dice que el nodo no responde | Comprueba la API con el `curl` de "Antes de empezar" |
+480 -71
View File
@@ -1055,8 +1055,27 @@
for (const c of str) { if (c === "1") leadingZeros++; else break; } for (const c of str) { if (c === "1") leadingZeros++; else break; }
const full = new Uint8Array(leadingZeros + body.length); const full = new Uint8Array(leadingZeros + body.length);
full.set(body, leadingZeros); full.set(body, leadingZeros);
// full = 82 bytes (78 payload + 4 checksum); devolvemos el payload return full; // completo: payload + 4 bytes de checksum
return full.slice(0, full.length - 4); },
// Decodifica y COMPRUEBA la checksum. Es async porque el hash lo hace
// Web Crypto. Sin esta comprobación, un xpub con un solo carácter mal
// copiado se acepta sin protestar y genera direcciones que no son las
// del usuario — que entonces vería su cartera "sin actividad" y se
// quedaría tranquilo. Un falso negativo silencioso, justo lo que este
// proyecto evita en todo lo demás.
decodeBase58Check: async (str) => {
const full = B32.decodeBase58(str);
if (full.length < 5) throw new Error("Cadena demasiado corta para ser una clave extendida válida.");
const payload = full.slice(0, full.length - 4);
const checksum = full.slice(full.length - 4);
const h = await B32.sha256d(payload);
for (let i = 0; i < 4; i++) {
if (h[i] !== checksum[i]) {
throw new Error("La clave no es válida: la suma de verificación no cuadra. Suele ser un carácter mal copiado — revísala y pégala entera.");
}
}
return payload;
}, },
// SHA256 doble (para checksum base58) // SHA256 doble (para checksum base58)
@@ -1153,7 +1172,13 @@
// Derivación BIP32 de clave pública hija (solo clave pública, sin hardened) // Derivación BIP32 de clave pública hija (solo clave pública, sin hardened)
const deriveChildPubkey = async (parentPub, parentChain, index) => { const deriveChildPubkey = async (parentPub, parentChain, index) => {
const indexBytes = B32.u32be(index); // index < 0x80000000 (no hardened) // Los índices endurecidos (≥ 2³¹) NO se pueden derivar desde una clave
// pública: hace falta la privada. Hoy solo se llama con 0 y 1, así que
// no es alcanzable, pero una función criptográfica debe defenderse sola.
if (!Number.isInteger(index) || index < 0 || index >= 0x80000000) {
throw new Error("Índice de derivación inválido: desde una clave pública no se pueden derivar índices endurecidos.");
}
const indexBytes = B32.u32be(index);
const data = B32.concat(parentPub, indexBytes); const data = B32.concat(parentPub, indexBytes);
const I = await B32.hmac512(parentChain, data); const I = await B32.hmac512(parentChain, data);
const IL = I.slice(0, 32); const IL = I.slice(0, 32);
@@ -1272,17 +1297,42 @@
}; };
// Derivar N direcciones desde xpub (rama 0=recepción, 1=cambio) // Derivar N direcciones desde xpub (rama 0=recepción, 1=cambio)
// Redes por bytes de versión, no por el prefijo del texto. Mirar las
// primeras letras parece equivalente y no lo es: un `tpub` —el formato más
// habitual de testnet— empieza por "t" pero no por "tb", así que una
// comprobación textual lo confunde con mainnet y genera direcciones bc1…
// a partir de claves de testnet. Los bytes de versión son inequívocos.
const XPUB_VERSIONS = {
"0488b21e": { red:"bc", tipo:"xpub" }, "049d7cb2": { red:"bc", tipo:"ypub" },
"04b24746": { red:"bc", tipo:"zpub" }, "0295b43f": { red:"bc", tipo:"Ypub" },
"02aa7ed3": { red:"bc", tipo:"Zpub" },
"043587cf": { red:"tb", tipo:"tpub" }, "044a5262": { red:"tb", tipo:"upub" },
"045f1cf6": { red:"tb", tipo:"vpub" }, "024289ef": { red:"tb", tipo:"Upub" },
"02575483": { red:"tb", tipo:"Vpub" },
};
const deriveAddresses = async (xpubStr, count=20) => { const deriveAddresses = async (xpubStr, count=20) => {
// Decodificar xpub/zpub // decodeBase58Check comprueba la suma de verificación: un carácter mal
const raw = B32.decodeBase58(xpubStr.trim()); // copiado se detecta aquí y no acaba generando direcciones ajenas.
const raw = await B32.decodeBase58Check(xpubStr.trim());
// raw[0..3]=version, [4]=depth, [5..8]=fingerprint, [9..12]=childIndex // raw[0..3]=version, [4]=depth, [5..8]=fingerprint, [9..12]=childIndex
// [13..44]=chainCode, [45..77]=pubKey // [13..44]=chainCode, [45..77]=pubKey
if (raw.length !== 78) {
throw new Error(`La clave extendida debería ocupar 78 bytes y ocupa ${raw.length}. Parece incompleta o de un formato que no reconozco.`);
}
const version = Array.from(raw.slice(0,4)).map(b=>b.toString(16).padStart(2,"0")).join("");
const info = XPUB_VERSIONS[version];
if (!info) throw new Error(`Formato de clave extendida no reconocido (versión ${version}).`);
const chainCode = raw.slice(13, 45); const chainCode = raw.slice(13, 45);
const pubKey = raw.slice(45, 78); const pubKey = raw.slice(45, 78);
if (pubKey[0] !== 0x02 && pubKey[0] !== 0x03) {
throw new Error("La clave pública que contiene no tiene un formato comprimido válido.");
}
// Parent fingerprint (bytes 5-8): lo que Sparrow muestra junto al keystore // Parent fingerprint (bytes 5-8): lo que Sparrow muestra junto al keystore
const fingerprint = Array.from(raw.slice(5, 9)).map(b => b.toString(16).padStart(2,"0")).join(""); const fingerprint = Array.from(raw.slice(5, 9)).map(b => b.toString(16).padStart(2,"0")).join("");
const hrp = xpubStr.startsWith("tb") || xpubStr.startsWith("u") || xpubStr.startsWith("v") ? "tb" : "bc"; const hrp = info.red;
const result = { receive: [], change: [], fingerprint }; const result = { receive: [], change: [], fingerprint };
for (const [branch, label] of [[0,"receive"],[1,"change"]]) { for (const [branch, label] of [[0,"receive"],[1,"change"]]) {
@@ -1480,8 +1530,10 @@
input_reuse: 25, input_type_mixing: 14, output_type_mismatch: 8, input_reuse: 25, input_type_mixing: 14, output_type_mismatch: 8,
round_numbers: 10, rbf: 5, peeling: 8, round_numbers: 10, rbf: 5, peeling: 8,
unnecessary_input: 12, dust: 8, change_detection: 10, unnecessary_input: 12, dust: 8, change_detection: 10,
wallet_fingerprint: 6, batch_payment: 45, entity_ofac: 20, op_return: 30, legacy_type: 15, wallet_fingerprint: 6, batch_payment: 45, op_return: 30, legacy_type: 15,
}; };
// entity_ofac y rbf no aparecen aquí a propósito: ninguno de los dos
// reduce tu privacidad. Ver sus checks para el razonamiento.
let deductions = 0; let deductions = 0;
const totalIn = tx.vin.reduce((s,v)=>s+(v.prevout?v.prevout.value:0),0); const totalIn = tx.vin.reduce((s,v)=>s+(v.prevout?v.prevout.value:0),0);
@@ -1616,7 +1668,7 @@
id:"output_type_mismatch", label:"Tipo de output revela el cambio", certainty:"PROBABLE", pass:!hasOutputMismatch, id:"output_type_mismatch", label:"Tipo de output revela el cambio", certainty:"PROBABLE", pass:!hasOutputMismatch,
actionability: "no_corregible", actionability: "no_corregible",
detail: hasOutputMismatch ? mismatchDetail : "Los outputs son del mismo tipo que los inputs — no se puede distinguir cuál es el cambio por tipo de script.", detail: hasOutputMismatch ? mismatchDetail : "Los outputs son del mismo tipo que los inputs — no se puede distinguir cuál es el cambio por tipo de script.",
didactic: "Cuando pagas a alguien que usa un tipo de script distinto al tuyo, tu output de cambio queda identificado porque es el único del mismo tipo que tus inputs. Un analista puede identificarlo sin ambigüedad.", didactic: "Tu wallet devuelve el cambio a una dirección del mismo tipo que las que gasta. Así que si pagas a alguien con un formato distinto al tuyo, el cambio se delata solo: es la salida que coincide con tus entradas. La señal es fuerte, pero no infalible — si quien cobra usa tu mismo formato, o si te pagas a ti mismo, deja de distinguirse. Por eso es probable y no certeza.",
penalty: weights.output_type_mismatch, penalty: weights.output_type_mismatch,
}); });
if (hasOutputMismatch) deductions += weights.output_type_mismatch; if (hasOutputMismatch) deductions += weights.output_type_mismatch;
@@ -1642,25 +1694,28 @@
id:"round_numbers", label:"Output de valor redondo", certainty:"PROBABLE", pass:!hasRound, id:"round_numbers", label:"Output de valor redondo", certainty:"PROBABLE", pass:!hasRound,
actionability: "evitable", actionability: "evitable",
detail: hasRound detail: hasRound
? `Output de ${(roundOutputs[0].value/1e8).toFixed(6)} BTC es valor redondo — con alta probabilidad ese es el pago y el otro output es el cambio.` ? `Hecho (certeza): uno de los dos outputs vale exactamente ${(roundOutputs[0].value/1e8).toFixed(6)} BTC, una cifra redonda. Interpretación (probable): las personas pagan cantidades redondas y el cambio sale de la resta, así que ese suele ser el pago y el otro el cambio. No siempre: una consolidación o un pago calculado al céntimo rompen la regla. Consecuencia: si acierta, queda señalada la dirección de cambio del emisor — y con ella, por dónde seguir sus gastos futuros.`
: "No se detectan outputs de valor redondo en una estructura de 2 outputs.", : "No se detectan outputs de valor redondo en una estructura de 2 outputs.",
didactic: "Las personas pagan cantidades redondas (0.001, 0.01, 0.1 BTC). Con dos outputs, el redondo es el pago y el irregular es el cambio. Esto identifica la dirección de cambio del emisor y permite rastrear sus futuros gastos.", didactic: "Las personas pagan cantidades redondas (0,001, 0,01, 0,1 BTC) y el cambio es lo que sobra, con todos sus decimales. De ahí la regla: en una transacción de dos salidas, la redonda suele ser el pago y la irregular el cambio. Suele, no siempre — hay consolidaciones y pagos calculados al céntimo que la desmienten. Cuando acierta, identifica la dirección de cambio del emisor, que es la puerta para seguir sus gastos posteriores.",
penalty: weights.round_numbers, penalty: weights.round_numbers,
}); });
if (hasRound) deductions += weights.round_numbers; if (hasRound) deductions += weights.round_numbers;
// ── 6. RBF ──────────────────────────────────────────────────────── // ── 6. RBF ────────────────────────────────────────────────────────
const rbfEnabled = tx.vin.some(v=>typeof v.sequence==="number" && v.sequence < 0xfffffffe); const rbfEnabled = tx.vin.some(v=>typeof v.sequence==="number" && v.sequence < 0xfffffffe);
// Informativo, NO penaliza: RBF es buena práctica (te deja desatascar
// una transacción sin sobrepagar de entrada) y hoy es tan común que
// distingue poco. Restar puntos por usarlo empujaría al usuario hacia
// una decisión peor para ahorrar una señal débil — mal negocio.
checks.push({ checks.push({
id:"rbf", label:"RBF activado (Replace-by-Fee)", certainty:"CERTEZA", pass:!rbfEnabled, id:"rbf", label:"RBF activado (Replace-by-Fee)", certainty:"CERTEZA", pass:true,
informational: true,
actionability: "wallet", actionability: "wallet",
detail: rbfEnabled detail: rbfEnabled
? "RBF opt-in activado (sequence < 0xFFFFFFFE). Revela software de wallet y permite correlacionar transacciones reemplazadas en la mempool." ? "Hecho (certeza): RBF opt-in activado (sequence < 0xFFFFFFFE). Interpretación (débil): es un rasgo del wallet que lo firmó, pero hoy lo activan por defecto la mayoría, así que distingue poco. Consecuencia: si llegaste a reemplazar la transacción, un observador con vista a la mempool pudo ver ambas versiones y relacionarlas."
: "RBF no activado.", : "Hecho (certeza): RBF no activado — la transacción no era reemplazable.",
didactic: "Replace-by-Fee permite sustituir una transacción pendiente por otra con mayor fee. Bitcoin Core lo activa por defecto desde v0.12. Su presencia es una firma de wallet que los analistas usan para vincular transacciones relacionadas.", didactic: "Replace-by-Fee permite sustituir una transacción pendiente por otra con más comisión. Bitcoin Core lo activa por defecto desde la v0.12. No resta privacidad y sí evita que te quedes atascado pagando de más «por si acaso», así que aquí es informativo y no penaliza: no tiene sentido empujarte a gastar peor para esconder una señal que casi todo el mundo emite igual.",
penalty: weights.rbf,
}); });
if (rbfEnabled) deductions += weights.rbf;
// ── 7. Unnecessary input ───────────────────────────────────────── // ── 7. Unnecessary input ─────────────────────────────────────────
let hasUnnecessaryInput = false; let hasUnnecessaryInput = false;
@@ -1692,7 +1747,7 @@
detail: likelyCJ detail: likelyCJ
? `En un CoinJoin todos los inputs son necesarios por diseño del protocolo — cada participante aporta los suyos. Esta heurística no aplica.` ? `En un CoinJoin todos los inputs son necesarios por diseño del protocolo — cada participante aporta los suyos. Esta heurística no aplica.`
: (hasUnnecessaryInput ? unnecessaryDetail : "No se detectan inputs claramente innecesarios para cubrir el pago."), : (hasUnnecessaryInput ? unnecessaryDetail : "No se detectan inputs claramente innecesarios para cubrir el pago."),
didactic: "Si una transacción tiene más inputs de los necesarios para cubrir el pago, el emisor estaba consolidando UTXOs. Esto revela que esos UTXOs pertenecen al mismo propietario y es una de las heurísticas más usadas en chain analysis real.", didactic: "Si una transacción aporta más entradas de las que hacían falta para cubrir el pago, lo habitual es que el emisor estuviera consolidando monedas sueltas. Gastarlas juntas es lo que las delata: cualquiera que mire asume que son del mismo dueño. Esa suposición —la heurística CIOH— es la más usada del análisis de cadena y acierta casi siempre, pero no es una ley: un CoinJoin o un pago colaborativo la desmienten por diseño. Por eso aquí se marca como probable y no como certeza.",
penalty: likelyCJ ? 0 : weights.unnecessary_input, penalty: likelyCJ ? 0 : weights.unnecessary_input,
}); });
// En CoinJoin múltiples inputs son la estructura del protocolo — no penaliza. // En CoinJoin múltiples inputs son la estructura del protocolo — no penaliza.
@@ -1905,13 +1960,13 @@
const zone = hour >= 6 && hour <= 10 ? "mañana temprana (Europa/África)" : const zone = hour >= 6 && hour <= 10 ? "mañana temprana (Europa/África)" :
hour >= 14 && hour <= 18 ? "tarde europea / mañana americana" : hour >= 14 && hour <= 18 ? "tarde europea / mañana americana" :
hour >= 22 || hour <= 2 ? "madrugada Europa / tarde América" : "horario sin patrón claro"; hour >= 22 || hour <= 2 ? "madrugada Europa / tarde América" : "horario sin patrón claro";
timingDetail = `Confirmada el ${dayNames[day]} a las ${hour}:00 UTC — ${zone}.${day===0||day===6?" · fin de semana":""}`; timingDetail = `Hecho (certeza): el bloque que la confirmó se minó el ${dayNames[day]} a las ${hour}:00 UTC${day===0||day===6?" (fin de semana)":""}. Interpretación (posible): compatible con ${zone}, pero con una sola transacción no se puede afirmar nada — y el bloque se mina cuando se mina, no cuando se firmó la transacción, que pudo esperar minutos u horas en la mempool. Consecuencia: ninguna por sí sola; el horario solo sirve a quien acumula muchas de tus transacciones y busca un patrón.`;
} }
checks.push({ checks.push({
id:"timing", label:"Análisis temporal", certainty:"POSIBLE", pass:true, id:"timing", label:"Análisis temporal", certainty:"POSIBLE", pass:true,
actionability: "evitable", actionability: "evitable",
detail: timingDetail, detail: timingDetail,
didactic: "El horario de las transacciones revela la zona horaria y rutinas del usuario. Transacciones consistentemente a las 8-10 UTC apuntan a Europa. Es una señal débil en aislamiento pero útil combinada con otras.", didactic: "Acumulando muchas transacciones de la misma persona, el horario acaba dibujando su huso horario y sus rutinas: cuándo duerme, si opera en horario laboral, si descansa los fines de semana. Aquí solo ves una, así que no hay patrón que leer — este dato solo cobra sentido para quien ya ha reunido un historial tuyo. Ojo además con la hora que se muestra: es la del bloque, no la de tu firma. Entre una y otra puede haber esperado en la mempool.",
penalty: 0, informational: true, penalty: 0, informational: true,
}); });
@@ -1952,18 +2007,31 @@
if (hit.cat === "exchange") exchHits.push({ ...hit, addr, side }); if (hit.cat === "exchange") exchHits.push({ ...hit, addr, side });
} }
// OFAC — crítico, penaliza // OFAC — informativo, NO penaliza la banda de privacidad.
//
// Es deliberado y conviene entender por qué: un hit de OFAC no revela
// absolutamente nada más sobre ti. Tu privacidad es idéntica antes y
// después de saberlo. Lo que cambia es otra cosa —la probabilidad de que
// un servicio regulado te bloquee un depósito—, y eso es un eje distinto:
// censurabilidad, no privacidad.
//
// Restar puntos aquí sería, además, dar por buena la idea de "monedas
// contaminadas": que una moneda vale menos según por dónde ha pasado.
// Bitcoin es fungible por diseño; la contaminación no es una propiedad
// de las monedas, es una construcción de la industria de cumplimiento
// normativo. Una herramienta que audita privacidad no debería
// internalizar la lógica del adversario. Se informa del riesgo real —que
// existe— sin fingir que es un problema de privacidad.
if (ofacHits.length > 0) { if (ofacHits.length > 0) {
const names = [...new Set(ofacHits.map(h => h.name))].join(", "); const names = [...new Set(ofacHits.map(h => h.name))].join(", ");
const sides = [...new Set(ofacHits.map(h => h.side))]; const sides = [...new Set(ofacHits.map(h => h.side))];
checks.push({ checks.push({
id:"entity_ofac", label:"Dirección en lista de sanciones OFAC", certainty:"CERTEZA", pass:false, id:"entity_ofac", label:"Dirección en lista de sanciones OFAC", certainty:"CERTEZA", pass:true,
informational: true,
actionability: "no_corregible", actionability: "no_corregible",
detail: `Una o más direcciones de esta transacción (${sides.join(" y ")}) aparecen en la lista de sanciones OFAC: ${names}. Esto no implica que hayas actuado ilegalmente, pero tus fondos han estado en contacto con una entidad sancionada internacionalmente. Los exchanges con KYC pueden usar este dato para bloquear o congelar depósitos relacionados.`, detail: `Hecho (certeza): una o más direcciones de esta transacción (${sides.join(" y ")}) figuran en la lista OFAC: ${names}. Interpretación: ninguna — la coincidencia no dice nada sobre tu conducta ni sobre la de nadie; solo que esa dirección está en una lista. Consecuencia: un servicio regulado que consulte esa lista puede bloquear o congelar depósitos que relacione con estas direcciones. No afecta a tu privacidad: nadie sabe más de ti por esto, y por eso no resta puntos a la banda.`,
didactic: "La OFAC (Office of Foreign Assets Control, EE.UU.) mantiene una lista de direcciones de entidades sancionadas: mixers como Blender.io o Sinbad, exchanges como Garantex o Suex, y grupos como Lazarus. La lista es de dominio público y la usan los servicios regulados. Una coincidencia es un hecho verificable, no una probabilidad.", didactic: "La OFAC es la oficina de control de activos del Tesoro de EE.UU. Su lista es una decisión política de un gobierno concreto, no una determinación judicial de criminalidad: incluye mixers (Blender.io, Sinbad), exchanges (Garantex, Suex) y grupos como Lazarus, pero también ha incluido software libre — las sanciones a Tornado Cash fueron revertidas en parte por un tribunal federal tras ser recurridas. Si no estás bajo jurisdicción estadounidense, esa lista no te obliga a nada; el problema práctico llega por los intermediarios que sí la aplican. Aquí se informa como riesgo de censura, no como defecto de privacidad, porque son cosas distintas: tus monedas no valen menos por dónde hayan pasado. Esa es justamente la idea que la fungibilidad de Bitcoin niega.",
penalty: weights.entity_ofac,
}); });
deductions += weights.entity_ofac;
} }
// Mining pools — informativo, cierra el rastro // Mining pools — informativo, cierra el rastro
@@ -2769,11 +2837,22 @@
<div key={i} style={{display:"flex",alignItems:"center",justifyContent:"space-between",padding:"5px 0",borderBottom:i<4?`1px solid ${C.border}`:"none"}}> <div key={i} style={{display:"flex",alignItems:"center",justifyContent:"space-between",padding:"5px 0",borderBottom:i<4?`1px solid ${C.border}`:"none"}}>
<span style={{fontSize:"0.7rem",fontFamily:"monospace",color:C.blue,width:120,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{p.command}</span> <span style={{fontSize:"0.7rem",fontFamily:"monospace",color:C.blue,width:120,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{p.command}</span>
<div style={{display:"flex",gap:8}}> <div style={{display:"flex",gap:8}}>
<Badge color={C.amber}>CPU {p.cpu}%</Badge> {/* Se muestra el % del sistema (lo que de verdad se
quiere saber: cuánto de la máquina se lleva). El
% de un núcleo va en el tooltip, para comparar con
top si hace falta. */}
<Badge color={p.cpuSistema>25?C.red:p.cpuSistema>10?C.amber:C.t2}>
<span title={p.cpu!=null?`${p.cpu}% de un núcleo · media desde que arrancó según ps: ${p.cpuMedia}%`:""}>
CPU {p.cpuSistema!=null?p.cpuSistema:p.cpu}%
</span>
</Badge>
<Badge color={p.mem>30?C.red:p.mem>15?C.amber:C.t2}>RAM {p.mem}%</Badge> <Badge color={p.mem>30?C.red:p.mem>15?C.amber:C.t2}>RAM {p.mem}%</Badge>
</div> </div>
</div> </div>
))} ))}
<div style={{fontSize:"0.55rem",color:C.t3,fontFamily:"monospace",marginTop:8,lineHeight:1.5}}>
CPU sobre el total de la máquina ({sysData.cpu?.count||"?"} núcleos), medida en una ventana de 500 ms — no es la media desde el arranque que muestra <code>ps</code>, que engaña porque un proceso que trabajó mucho hace días sigue apareciendo alto.
</div>
</div> </div>
)} )}
</div> </div>
@@ -3503,13 +3582,15 @@
if(sorted.length>MAX_UTXOS) setErr(`Mostrando ${MAX_UTXOS} de ${sorted.length} UTXOs — se muestran los de mayor valor.`); if(sorted.length>MAX_UTXOS) setErr(`Mostrando ${MAX_UTXOS} de ${sorted.length} UTXOs — se muestran los de mayor valor.`);
// Cargar txs de origen para contexto (máx 10, las más grandes) // Cargar txs de origen para contexto (máx 10, las más grandes)
// Vía get() y no fetch directo: así pasa por la caché compartida.
// Las transacciones confirmadas son inmutables y se guardan toda la
// sesión, de modo que volver a este UTXO Map (o analizar una de estas
// txs en otra pestaña) no cuesta ni una petición más al nodo.
const cache={}; const cache={};
const toFetch=limited.slice(0,10); const toFetch=limited.slice(0,10);
await Promise.all(toFetch.map(async u=>{ await Promise.all(toFetch.map(async u=>{
try{ const t = await get(`/api/tx/${u.txid}`, null);
const res=await fetchWithTimeout(`${base}/api/tx/${u.txid}`); if (t) cache[u.txid] = t;
if(res.ok) cache[u.txid]=await res.json();
}catch{}
})); }));
setTxCache(cache); setTxCache(cache);
@@ -4419,7 +4500,9 @@
const recommendations = [ const recommendations = [
{ text:"Si las claves del wallet de origen pudieron verse comprometidas, genera una semilla nueva y traslada cualquier fondo restante — no reutilices el material comprometido." }, { text:"Si las claves del wallet de origen pudieron verse comprometidas, genera una semilla nueva y traslada cualquier fondo restante — no reutilices el material comprometido." },
{ text:"Revisa el dispositivo donde vivía el wallet (malware, acceso físico no autorizado, backups expuestos) antes de asumir que el vector de entrada está cerrado." }, { text:"Revisa el dispositivo donde vivía el wallet (malware, acceso físico no autorizado, backups expuestos) antes de asumir que el vector de entrada está cerrado." },
{ text:"Para una denuncia: aporta este informe completo junto con los txids listados en el anexo de verificación. Cualquiera puede comprobarlos de forma independiente." }, { text:"Si decides denunciar, este informe completo y los txids del anexo son lo que hace falta: cualquiera puede comprobarlos de forma independiente, sin depender de Txoko ni de ningún servicio." },
{ text:"Antes de denunciar, ten claro el precio. Entregar este informe vincula tu identidad legal con esas direcciones de forma permanente, ante una autoridad que puede compartir el expediente con empresas de análisis de cadena. Esa vinculación no se deshace y alcanza también a las direcciones que solo aparecen de refilón. Puede compensar — recuperar fondos, frenar a quien lo hizo — o puede no compensar. Es tu decisión, y para tomarla hace falta saber las dos caras." },
{ text:"Este informe contiene tus direcciones y tu declaración. Trátalo como material sensible: no lo subas a foros, chats de soporte, grupos ni servicios en la nube para pedir ayuda. Todo el cuidado de no filtrar nada a la red se pierde en el momento en que este archivo sale de tu equipo." },
]; ];
if (declaracion) { if (declaracion) {
recommendations.push({ recommendations.push({
@@ -4728,7 +4811,7 @@
// ── AUDITORÍA ────────────────────────────────────────────────────────── // ── AUDITORÍA ──────────────────────────────────────────────────────────
function Auditoria({base, initialQuery}) { function Auditoria({base, initialQuery}) {
const {get}=useApi(base); const {get, getStrict}=useApi(base);
const [query,setQuery]=useState(initialQuery||""); const [query,setQuery]=useState(initialQuery||"");
const [tx,setTx]=useState(null); const [tx,setTx]=useState(null);
const [analysis,setAnalysis]=useState(null); const [analysis,setAnalysis]=useState(null);
@@ -4786,14 +4869,22 @@
const BATCH = 5; // direcciones por lote const BATCH = 5; // direcciones por lote
const PAUSE = 120; // ms entre lotes const PAUSE = 120; // ms entre lotes
const active = []; // direcciones con actividad const active = []; // direcciones con actividad
// Consultas que el nodo no pudo servir. Se registran porque su
// ausencia SESGA el informe hacia el optimismo: una dirección que no
// se pudo leer parece una dirección sin actividad, y entonces su
// reutilización, sus vinculaciones y sus transacciones desaparecen
// del análisis. Decir "tu wallet está limpia" cuando en realidad
// faltan datos es peor que no decir nada.
const scanErrors = [];
// Fase 1: estado ligero de cada dirección // Fase 1: estado ligero de cada dirección
for (let i = 0; i < all.length; i += BATCH) { for (let i = 0; i < all.length; i += BATCH) {
const slice = all.slice(i, i + BATCH); const slice = all.slice(i, i + BATCH);
setWalletProgress(`Explorando direcciones ${Math.min(i+BATCH,all.length)}/${all.length}`); setWalletProgress(`Explorando direcciones ${Math.min(i+BATCH,all.length)}/${all.length}`);
const infos = await Promise.all(slice.map(s => const infos = await Promise.all(slice.map(async s => {
get(`/api/address/${s.addr}`, null).catch(()=>null) try { return await getStrict(`/api/address/${s.addr}`); }
)); catch (e) { scanErrors.push({ addr:s.addr, branch:s.branch, message:e.message, phase:"estado de la dirección" }); return null; }
}));
infos.forEach((info, j) => { infos.forEach((info, j) => {
const txc = info?.chain_stats?.tx_count || 0; const txc = info?.chain_stats?.tx_count || 0;
if (txc > 0) active.push({ ...slice[j], info }); if (txc > 0) active.push({ ...slice[j], info });
@@ -4809,14 +4900,21 @@
const slice = active.slice(i, i + BATCH); const slice = active.slice(i, i + BATCH);
setWalletProgress(`Trayendo transacciones ${Math.min(i+BATCH,active.length)}/${active.length}`); setWalletProgress(`Trayendo transacciones ${Math.min(i+BATCH,active.length)}/${active.length}`);
await Promise.all(slice.map(async s => { await Promise.all(slice.map(async s => {
let page = await get(`/api/address/${s.addr}/txs`, []).catch(()=>[]); try {
let guard = 0; let page = await getStrict(`/api/address/${s.addr}/txs`);
while (Array.isArray(page) && page.length > 0 && guard < 8) { let guard = 0;
page.forEach(t => { if (t && t.txid) txMap.set(t.txid, t); }); while (Array.isArray(page) && page.length > 0 && guard < 8) {
if (page.length < 25) break; // última página page.forEach(t => { if (t && t.txid) txMap.set(t.txid, t); });
const last = page[page.length-1].txid; if (page.length < 25) break; // última página
page = await get(`/api/address/${s.addr}/txs/chain/${last}`, []).catch(()=>[]); const last = page[page.length-1].txid;
guard++; page = await getStrict(`/api/address/${s.addr}/txs/chain/${last}`);
guard++;
}
} catch (e) {
// Perder el historial de una dirección con actividad conocida
// es lo más grave: sabemos que tiene transacciones y no hemos
// podido verlas. El informe queda incompleto por ahí.
scanErrors.push({ addr:s.addr, branch:s.branch, message:e.message, phase:"historial de transacciones", txCount:s.info?.chain_stats?.tx_count ?? null });
} }
})); }));
if (i + BATCH < active.length) await new Promise(r => setTimeout(r, PAUSE)); if (i + BATCH < active.length) await new Promise(r => setTimeout(r, PAUSE));
@@ -4824,6 +4922,8 @@
setWalletProgress("Analizando vinculación…"); setWalletProgress("Analizando vinculación…");
const report = buildWalletReport(active, [...txMap.values()], myAddrSet); const report = buildWalletReport(active, [...txMap.values()], myAddrSet);
report.scanErrors = scanErrors;
report.scanComplete = scanErrors.length === 0;
setWalletReport(report); setWalletReport(report);
} catch(e) { } catch(e) {
setWalletReport({ error: e.message }); setWalletReport({ error: e.message });
@@ -4958,9 +5058,16 @@
const a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download="wallet-report.json";a.click(); const a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download="wallet-report.json";a.click();
}} style={{padding:"3px 10px",background:"none",border:`1px solid ${C.border}`,borderRadius:4,color:C.t2,fontFamily:"monospace",fontSize:"0.6rem",cursor:"pointer"}}>↓ JSON</button> }} style={{padding:"3px 10px",background:"none",border:`1px solid ${C.border}`,borderRadius:4,color:C.t2,fontFamily:"monospace",fontSize:"0.6rem",cursor:"pointer"}}>↓ JSON</button>
<button onClick={()=>{ <button onClick={()=>{
const {health,totalTxs,activeCount,reusedAddrs,clusters,history}=walletReport; const {health,totalTxs,activeCount,reusedAddrs,clusters,history,scanErrors}=walletReport;
const lines=[ const lines=[
`# Informe de Wallet — Txoko`,``, `# Informe de Wallet — Txoko`,``,
...((scanErrors&&scanErrors.length>0)?[
`> **AVISO — informe incompleto.** ${scanErrors.length} consulta(s) al nodo fallaron durante el escaneo.`,
`> Las direcciones no leídas se comportan como si no tuvieran actividad, así que la valoración`,
`> de abajo es **optimista**: puede faltar reutilización, vinculaciones o transacciones enteras.`,
`> Repite el escaneo antes de darla por buena.`,``,
...scanErrors.map(e=>`> - ${e.addr} (${e.branch}) — ${e.message} [${e.phase}]`),``,
]:[]),
`## Salud general: ${health.band}`,``,health.msg,``, `## Salud general: ${health.band}`,``,health.msg,``,
`- Transacciones analizadas: ${totalTxs}`, `- Transacciones analizadas: ${totalTxs}`,
`- Direcciones con actividad: ${activeCount}`, `- Direcciones con actividad: ${activeCount}`,
@@ -4980,6 +5087,32 @@
</div> </div>
</div> </div>
{/* Aviso de escaneo incompleto — va ANTES de la banda a
propósito: una valoración de salud leída sin saber que
faltan datos es peor que no tener valoración. El sesgo
siempre va hacia el optimismo, porque una dirección no
leída parece una dirección sin actividad. */}
{walletReport.scanErrors&&walletReport.scanErrors.length>0&&(
<div style={{padding:"10px 14px",background:C.redMuted,border:`1px solid ${C.red}40`,borderRadius:8,marginBottom:14}}>
<div style={{fontSize:"0.66rem",color:C.red,fontFamily:"monospace",fontWeight:700,marginBottom:6}}>
⚠ Informe incompleto — {walletReport.scanErrors.length} consulta(s) al nodo fallaron
</div>
<div style={{fontSize:"0.64rem",color:C.t1,lineHeight:1.6,marginBottom:8}}>
Las direcciones que no se pudieron leer se comportan aquí como si no tuvieran actividad. Eso hace que la valoración de abajo sea <strong>optimista</strong>: puede faltar reutilización, vinculaciones o transacciones enteras que sí existen. No la des por buena sin repetir el escaneo.
</div>
<div style={{display:"flex",flexDirection:"column",gap:2}}>
{walletReport.scanErrors.slice(0,6).map((e,i)=>(
<div key={i} style={{fontSize:"0.56rem",color:C.t2,fontFamily:"monospace",wordBreak:"break-all"}}>
{e.addr} ({e.branch}) — {e.message} · {e.phase}
</div>
))}
{walletReport.scanErrors.length>6&&(
<div style={{fontSize:"0.56rem",color:C.t2,fontFamily:"monospace"}}>…y {walletReport.scanErrors.length-6} más</div>
)}
</div>
</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}}>
@@ -5446,6 +5579,13 @@
</div> </div>
</div> </div>
{/* El análisis no sale de la red del usuario, pero el archivo
exportado sí puede salir — y lleva sus direcciones dentro.
Avisar aquí, junto a los botones, no en la letra pequeña. */}
<div style={{marginBottom:14,padding:"8px 12px",background:C.amberMuted,border:`1px solid ${C.amber}30`,borderRadius:6,fontSize:"0.62rem",color:C.amber,fontFamily:"monospace",lineHeight:1.6}}>
Lo que descargues lleva dentro tus direcciones y tu declaración. Nada de esto ha salido de tu red hasta ahora; a partir de aquí depende de ti. No lo subas a foros, chats de soporte ni servicios en la nube para pedir ayuda.
</div>
{trace&&!trace.done&&trace.hop>report.summary.hops&&( {trace&&!trace.done&&trace.hop>report.summary.hops&&(
<div style={{marginBottom:14,padding:"8px 12px",background:C.amberMuted,border:`1px solid ${C.amber}30`,borderRadius:6,fontSize:"0.62rem",color:C.amber,fontFamily:"monospace"}}> <div style={{marginBottom:14,padding:"8px 12px",background:C.amberMuted,border:`1px solid ${C.amber}30`,borderRadius:6,fontSize:"0.62rem",color:C.amber,fontFamily:"monospace"}}>
Este informe se generó en el salto {report.summary.hops} — el rastreo ya lleva {trace.hop}. Genera de nuevo para incluir lo explorado desde entonces. Este informe se generó en el salto {report.summary.hops} — el rastreo ya lleva {trace.hop}. Genera de nuevo para incluir lo explorado desde entonces.
@@ -5961,51 +6101,319 @@
); );
} }
// ── Decodificador PSBT ───────────────────────────────────────────────── // ── Parser PSBT (BIP174) ───────────────────────────────────────────────
// Escrito desde cero, sin librerías, como el resto del proyecto. Validado
// contra los cinco vectores inválidos del propio BIP174 (los rechaza los
// cinco) y contra PSBTs reales de Sparrow.
//
// Todo el análisis es OFFLINE: una PSBT bien formada ya lleva dentro los
// importes y scripts de sus entradas, así que no hace falta consultar el
// nodo. Eso significa que puedes auditar una transacción antes de firmarla
// aunque el nodo esté sincronizando — y que el nodo no se entera.
function parsePsbt(bytes) {
let p = 0;
const need = (n) => { if (p + n > bytes.length) throw new Error("PSBT truncada: el archivo se acaba antes de tiempo."); };
const u8 = () => { need(1); return bytes[p++]; };
const u32 = () => { need(4); const v = bytes[p]|(bytes[p+1]<<8)|(bytes[p+2]<<16)|(bytes[p+3]<<24); p+=4; return v>>>0; };
const u64 = () => { need(8); let v=0n; for(let i=7;i>=0;i--) v=(v<<8n)|BigInt(bytes[p+i]); p+=8; return Number(v); };
const take = (n) => { need(n); return bytes.slice(p, p+=n); };
const varint = () => { const v=u8(); if(v<0xfd) return v; if(v===0xfd){need(2);const r=bytes[p]|(bytes[p+1]<<8);p+=2;return r;} if(v===0xfe) return u32(); return u64(); };
if (bytes.length < 5 || bytes[0]!==0x70||bytes[1]!==0x73||bytes[2]!==0x62||bytes[3]!==0x74||bytes[4]!==0xff)
throw new Error("Esto no es una PSBT: le falta la cabecera que las identifica. ¿Has pegado una transacción normal en vez de una a medio firmar?");
p = 5;
// Cada sección es una lista de pares clave/valor que termina en un 0x00.
// El estándar PROHÍBE claves repetidas: si aparecen, el archivo está
// corrupto o manipulado, y conviene no seguir leyéndolo.
const readMap = (donde) => {
const out = []; const vistas = new Set();
for (;;) {
if (p >= bytes.length) throw new Error(`PSBT truncada: la sección de ${donde} no termina.`);
const klen = varint();
if (klen === 0) return out;
const key = take(klen);
const kh = Array.from(key).map(b=>b.toString(16).padStart(2,"0")).join("");
if (vistas.has(kh)) throw new Error(`PSBT inválida: hay una clave repetida en ${donde}. El estándar no lo permite.`);
vistas.add(kh);
const vlen = varint();
out.push({ type: key[0], key: key.slice(1), value: take(vlen) });
}
};
const global = readMap("la cabecera");
const utxRec = global.find(e => e.type === 0x00);
if (!utxRec) throw new Error("PSBT inválida: no contiene la transacción sin firmar.");
const tx = utxRec.value; let q = 0;
const tneed=(n)=>{ if(q+n>tx.length) throw new Error("La transacción que hay dentro está truncada."); };
const tu32=()=>{ tneed(4); const v=tx[q]|(tx[q+1]<<8)|(tx[q+2]<<16)|(tx[q+3]<<24); q+=4; return v>>>0; };
const tu64=()=>{ tneed(8); let v=0n; for(let i=7;i>=0;i--) v=(v<<8n)|BigInt(tx[q+i]); q+=8; return Number(v); };
const tvar=()=>{ const v=tx[q++]; if(v<0xfd) return v; if(v===0xfd){const r=tx[q]|(tx[q+1]<<8);q+=2;return r;} if(v===0xfe) return tu32(); return tu64(); };
const ttake=(n)=>{ tneed(n); return tx.slice(q, q+=n); };
const hx=(a)=>Array.from(a).map(b=>b.toString(16).padStart(2,"0")).join("");
const version = tu32();
const nIn = tvar();
if (nIn === 0) throw new Error("PSBT inválida: la transacción no tiene ninguna entrada.");
const vin = [];
for (let i=0;i<nIn;i++) {
const txid = hx(Array.from(ttake(32)).reverse());
const vout = tu32();
const slen = tvar();
ttake(slen);
// Una PSBT contiene, por definición, una transacción SIN firmar. Si
// trae firmas dentro, o no es una PSBT o alguien la ha manipulado.
if (slen > 0) throw new Error("PSBT inválida: la transacción ya lleva firmas incrustadas donde no debería.");
vin.push({ txid, vout, sequence: tu32() });
}
const nOut = tvar();
if (nOut === 0) throw new Error("PSBT inválida: la transacción no tiene ninguna salida.");
const vout = [];
for (let i=0;i<nOut;i++) { const value=tu64(); const slen=tvar(); vout.push({ value, script: ttake(slen) }); }
const locktime = tu32();
const inputs = []; for (let i=0;i<nIn;i++) inputs.push(readMap(`la entrada ${i+1}`));
const outputs = []; for (let i=0;i<nOut;i++) outputs.push(readMap(`la salida ${i+1}`));
if (p !== bytes.length) throw new Error("PSBT inválida: sobran bytes al final del archivo.");
return { version, locktime, vin, vout, global, inputs, outputs, size: bytes.length };
}
const PSBT_TIPOS = { v0_p2wpkh:"SegWit (bc1q)", v0_p2wsh:"SegWit multifirma (bc1q)", v1_p2tr:"Taproot (bc1p)", p2pkh:"Legacy (1…)", p2sh:"Script legacy (3…)", op_return:"OP_RETURN", desconocido:"desconocido" };
function psbtScriptType(s) {
if (!s || !s.length) return "desconocido";
if (s.length===22 && s[0]===0x00 && s[1]===0x14) return "v0_p2wpkh";
if (s.length===34 && s[0]===0x00 && s[1]===0x20) return "v0_p2wsh";
if (s.length===34 && s[0]===0x51 && s[1]===0x20) return "v1_p2tr";
if (s.length===25 && s[0]===0x76 && s[1]===0xa9) return "p2pkh";
if (s.length===23 && s[0]===0xa9 && s[1]===0x14) return "p2sh";
if (s[0]===0x6a) return "op_return";
return "desconocido";
}
// Auditoría de privacidad ANTES de firmar. La diferencia con el resto de
// la app: aquí todavía estás a tiempo de cambiar la transacción.
function analyzePsbt(psbt) {
const avisos = [];
const hx = a => Array.from(a).map(b=>b.toString(16).padStart(2,"0")).join("");
const xpubs = psbt.global.filter(e => e.type === 0x01);
if (xpubs.length > 0) {
avisos.push({
nivel: "critico", id: "xpub_expuesto",
titulo: xpubs.length === 1 ? "El archivo lleva dentro una clave pública maestra" : `El archivo lleva dentro ${xpubs.length} claves públicas maestras`,
hecho: `Hecho (certeza): además de lo necesario para firmar, la PSBT incluye ${xpubs.length} xpub de nivel cuenta.`,
consecuencia: "Quien reciba este archivo puede derivar TODAS las direcciones de la cartera, las que ya has usado y las que usarás, y ver su saldo e historial completos. No puede gastar nada, pero lo ve todo. Y las PSBT están hechas para compartirse: acaban en un correo o un chat para que el resto firme.",
quePuedesHacer: "Compártela solo con los cosignatarios y por un canal cifrado. Si los demás firmantes ya tienen cargada la configuración de la cartera, no necesitan estos xpubs: Sparrow permite excluirlos al exportar.",
});
}
const inVals = psbt.inputs.map(m => {
const w = m.find(e => e.type === 0x01);
if (w) { let v=0n; for(let i=7;i>=0;i--) v=(v<<8n)|BigInt(w.value[i]); return Number(v); }
return null;
});
const conocidos = inVals.every(v => v !== null);
const totalIn = conocidos ? inVals.reduce((a,b)=>a+b,0) : null;
const totalOut = psbt.vout.reduce((a,o)=>a+o.value,0);
const fee = totalIn !== null ? totalIn - totalOut : null;
const salidasPropias = psbt.outputs.map(m => m.some(e=>e.type===0x02));
const todasPropias = salidasPropias.length > 0 && salidasPropias.every(Boolean);
if (todasPropias && psbt.vout.length === 1) {
avisos.push({
nivel: "info", id: "autoenvio",
titulo: "Todo el importe vuelve a tu propia cartera",
hecho: "Hecho (certeza): la única salida lleva rutas de derivación de tu cartera, así que no estás pagando a nadie.",
consecuencia: "Es un movimiento interno: consolidar monedas, acelerar una transacción atascada o cambiar de dirección. No revela un pago, pero deja un patrón reconocible en la cadena — una entrada, una salida, sin cambio.",
quePuedesHacer: null,
});
}
const tiposIn = new Set(psbt.inputs.map(m => {
const w = m.find(e=>e.type===0x01);
if (!w) return null;
const l = w.value[8];
return psbtScriptType(w.value.slice(9, 9+l));
}).filter(Boolean));
const tiposOut = psbt.vout.map(o => psbtScriptType(o.script));
if (psbt.vout.length === 2 && tiposIn.size === 1 && tiposOut[0] !== tiposOut[1]) {
const t = [...tiposIn][0];
if (tiposOut.filter(x => x === t).length === 1) {
avisos.push({
nivel: "aviso", id: "cambio_por_tipo",
titulo: "Tu cambio se distingue por el tipo de dirección",
hecho: `Hecho (certeza): gastas monedas de tipo ${PSBT_TIPOS[t]} y solo una de las dos salidas usa ese mismo tipo.`,
consecuencia: "Cualquiera que mire la transacción deduce cuál de las dos salidas es tu cambio — y por ahí puede seguir tus gastos posteriores.",
quePuedesHacer: "Cuando puedas elegir, gasta monedas del mismo tipo que la dirección a la que pagas. No siempre depende de ti: manda el formato que use quien cobra.",
});
}
}
if (psbt.vout.length === 2) {
const redondos = psbt.vout.map((o,i)=>({i, v:o.value})).filter(o => o.v % 100000 === 0);
if (redondos.length === 1) {
avisos.push({
nivel: "aviso", id: "valor_redondo",
titulo: "Una de las salidas tiene un importe redondo",
hecho: `Hecho (certeza): la salida ${redondos[0].i+1} vale exactamente ${(redondos[0].v/1e8).toFixed(8)} BTC.`,
consecuencia: "Las personas pagan cifras redondas y el cambio es lo que sobra, con todos sus decimales. Refuerza la deducción de cuál salida es el pago y cuál el cambio.",
quePuedesHacer: "Si el importe lo decides tú, añadir unos satoshis sueltos rompe la señal sin coste real.",
});
}
}
const conWitnessScript = psbt.inputs.find(m => m.some(e=>e.type===0x05));
if (conWitnessScript) {
const ws = conWitnessScript.find(e=>e.type===0x05).value;
const m = ws[0]>=0x51&&ws[0]<=0x60 ? ws[0]-0x50 : null;
const n = ws[ws.length-2]>=0x51&&ws[ws.length-2]<=0x60 ? ws[ws.length-2]-0x50 : null;
avisos.push({
nivel: "info", id: "multisig",
titulo: (m&&n) ? `Cartera multifirma de ${m} de ${n}` : "Cartera multifirma",
hecho: `Hecho (certeza): las entradas se gastan con un script de firma múltiple${(m&&n)?`, de tipo ${m} de ${n}`:""}.`,
consecuencia: "Al gastar, ese script queda escrito en la cadena para siempre. El multisig es minoritario, así que te coloca en un grupo pequeño y reconocible: alguien que guarda cantidades con la custodia repartida.",
quePuedesHacer: "Es el precio de esta forma de custodia y con este esquema no se puede evitar. Taproot permite gastos multifirma que por fuera parecen pagos corrientes, pero implica cambiar de configuración.",
});
}
if (!conocidos) {
avisos.push({
nivel: "aviso", id: "sin_importes",
titulo: "No se puede calcular la comisión",
hecho: "Hecho (certeza): alguna entrada no incluye el importe de la moneda que gasta.",
consecuencia: "Sin ese dato no se sabe cuánto se paga de comisión. Una PSBT completa debería traerlo; que falte suele indicar que la generó una herramienta incompleta.",
quePuedesHacer: "Ábrela en el wallet que la creó y vuelve a exportarla.",
});
}
return {
resumen: {
entradas: psbt.vin.length, salidas: psbt.vout.length,
totalIn, totalOut, fee, locktime: psbt.locktime,
rbf: psbt.vin.some(i => i.sequence < 0xfffffffe),
tiposEntrada: [...tiposIn].map(t=>PSBT_TIPOS[t]),
tiposSalida: tiposOut.map(t=>PSBT_TIPOS[t]),
xpubs: xpubs.length, multisig: !!conWitnessScript,
firmasPresentes: psbt.inputs.reduce((a,m)=>a+m.filter(e=>e.type===0x02).length,0),
size: psbt.size,
},
avisos,
};
}
// ── Auditoría de PSBT ──────────────────────────────────────────────────
function ToolPsbt() { function ToolPsbt() {
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [result, setResult] = useState(null); const [result, setResult] = useState(null);
const [err, setErr] = useState(null); const [err, setErr] = useState(null);
// Acepta base64 (lo habitual al copiar) o hexadecimal.
const decode = () => { const decode = () => {
const raw = input.trim(); const raw = input.trim().replace(/\s/g,"");
if (!raw) return; if (!raw) return;
setErr(null); setResult(null); setErr(null); setResult(null);
try { try {
// Basic PSBT validation — starts with magic bytes "psbt" in base64 let bytes;
const bytes = atob(raw.replace(/\s/g,"")); if (/^[0-9a-fA-F]+$/.test(raw) && raw.length % 2 === 0) {
if (!bytes.startsWith("psbt")) throw new Error("No es una PSBT válida — debe empezar por 'psbt'"); bytes = new Uint8Array(raw.match(/../g).map(h=>parseInt(h,16)));
// Extract basic info from magic bytes } else {
setResult({ const bin = atob(raw);
valid: true, bytes = new Uint8Array(bin.length);
size: bytes.length, for (let i=0;i<bin.length;i++) bytes[i] = bin.charCodeAt(i);
note: "PSBT válida. Para un análisis completo necesitas un wallet como Sparrow o Coldcard. Esta herramienta verifica el formato.", }
}); const psbt = parsePsbt(bytes);
setResult(analyzePsbt(psbt));
} catch(e) { } catch(e) {
setErr(`Error al decodificar: ${e.message}`); setErr(e.message);
} }
}; };
const cargarArchivo = (file) => {
if (!file) return;
const r = new FileReader();
r.onload = (e) => {
const bytes = new Uint8Array(e.target.result);
setErr(null); setResult(null);
try { setResult(analyzePsbt(parsePsbt(bytes))); }
catch(err2) { setErr(err2.message); }
};
r.readAsArrayBuffer(file);
};
const colorNivel = (n) => n==="critico"?C.red : n==="aviso"?C.amber : C.blue;
const etiquetaNivel = (n) => n==="critico"?"CRÍTICO" : n==="aviso"?"AVISO" : "INFORMATIVO";
const R = result?.resumen;
return ( return (
<Card> <Card>
<div style={{fontSize:"0.62rem",color:C.blue,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:8}}>Validador PSBT</div> <div style={{fontSize:"0.62rem",color:C.blue,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:8}}>Auditoría de PSBT</div>
<div style={{fontSize:"0.65rem",color:C.t2,marginBottom:12}}>Pega una PSBT en base64 para verificar que es válida antes de firmarla. Nunca se envía a ningún servidor — todo es local.</div> <div style={{fontSize:"0.65rem",color:C.t2,marginBottom:12,lineHeight:1.6}}>
Revisa una transacción <strong style={{color:C.t1}}>antes de firmarla</strong>, cuando todavía puedes cambiarla. Todo se analiza en tu navegador: el archivo no se envía a ningún sitio, ni siquiera a tu nodo.
</div>
<textarea value={input} onChange={e=>setInput(e.target.value)} <textarea value={input} onChange={e=>setInput(e.target.value)}
placeholder="cHNidP8BAH..." placeholder="Pega aquí la PSBT en base64 (cHNidP8BAH…) o en hexadecimal"
rows={4} rows={4}
style={{width:"100%",background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"10px 12px",color:C.t1,fontFamily:"monospace",fontSize:"0.72rem",outline:"none",resize:"vertical",boxSizing:"border-box",marginBottom:8}} style={{width:"100%",background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"10px 12px",color:C.t1,fontFamily:"monospace",fontSize:"0.72rem",outline:"none",resize:"vertical",boxSizing:"border-box",marginBottom:8}}
onFocus={e=>e.target.style.borderColor=C.blue} onBlur={e=>e.target.style.borderColor=C.borderBright} onFocus={e=>e.target.style.borderColor=C.blue} onBlur={e=>e.target.style.borderColor=C.borderBright}
/> />
<button onClick={decode} style={{padding:"9px 16px",background:C.blueMuted,border:`1px solid ${C.blue}40`,borderRadius:6,color:C.blue,fontFamily:"monospace",fontSize:"0.72rem",cursor:"pointer",fontWeight:700,marginBottom:10}}>VERIFICAR</button> <div style={{display:"flex",gap:8,alignItems:"center",flexWrap:"wrap",marginBottom:10}}>
{err&&<div style={{padding:"8px 12px",background:C.redMuted,border:`1px solid ${C.red}30`,borderRadius:6,color:C.red,fontFamily:"monospace",fontSize:"0.72rem"}}>{err}</div>} <button onClick={decode} style={{padding:"9px 16px",background:C.blueMuted,border:`1px solid ${C.blue}40`,borderRadius:6,color:C.blue,fontFamily:"monospace",fontSize:"0.72rem",cursor:"pointer",fontWeight:700}}>ANALIZAR</button>
<label style={{padding:"9px 16px",background:"none",border:`1px solid ${C.border}`,borderRadius:6,color:C.t2,fontFamily:"monospace",fontSize:"0.7rem",cursor:"pointer"}}>
…o abrir archivo .psbt
<input type="file" accept=".psbt,.txn,application/octet-stream" style={{display:"none"}} onChange={e=>cargarArchivo(e.target.files[0])}/>
</label>
</div>
{err&&<div style={{padding:"10px 12px",background:C.redMuted,border:`1px solid ${C.red}40`,borderRadius:6,color:C.red,fontFamily:"monospace",fontSize:"0.7rem",lineHeight:1.6}}>{err}</div>}
{result&&( {result&&(
<div style={{padding:"12px 14px",background:C.bg,borderRadius:6,border:`1px solid ${C.green}40`}}> <div style={{display:"flex",flexDirection:"column",gap:12}}>
<div style={{display:"flex",alignItems:"center",gap:8,marginBottom:8}}> {/* Resumen de la transacción */}
<span style={{color:C.green,fontSize:"1rem"}}></span> <div style={{padding:"12px 14px",background:C.bg,borderRadius:6,border:`1px solid ${C.border}`}}>
<span style={{color:C.green,fontFamily:"monospace",fontSize:"0.78rem",fontWeight:700}}>PSBT válida</span> <div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",fontWeight:700,letterSpacing:"0.12em",marginBottom:10}}>QUÉ HACE ESTA TRANSACCIÓN</div>
<Badge color={C.t2}>{result.size} bytes</Badge> <div style={{display:"flex",gap:18,flexWrap:"wrap"}}>
<Tag label="Entradas" value={R.entradas}/>
<Tag label="Salidas" value={R.salidas}/>
{R.totalIn!=null&&<Tag label="Gasta" value={fmt.btc(R.totalIn)}/>}
<Tag label="Envía" value={fmt.btc(R.totalOut)}/>
{R.fee!=null&&<Tag label="Comisión" value={`${fmt.num(R.fee)} sat`} color={C.amber}/>}
<Tag label="Firmas ya puestas" value={R.firmasPresentes}/>
</div>
<div style={{marginTop:10,fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",lineHeight:1.7}}>
Gasta monedas de tipo {R.tiposEntrada.join(", ")||"desconocido"} · envía a {R.tiposSalida.join(", ")}<br/>
{R.rbf ? "Reemplazable (RBF): podrás subir la comisión si se atasca." : "No reemplazable: si se atasca, no podrás acelerarla con RBF."}
{R.locktime>0 && ` · locktime ${R.locktime}: no puede minarse antes de ese bloque (protección anti-fee-sniping, buena señal).`}
</div>
</div>
{/* Avisos */}
{result.avisos.length===0 ? (
<div style={{padding:"12px 14px",background:C.greenMuted,border:`1px solid ${C.green}40`,borderRadius:6,fontSize:"0.68rem",color:C.green,fontFamily:"monospace"}}>
✓ No se detectan problemas de privacidad en la estructura de esta transacción.
</div>
) : result.avisos.map((a,i)=>(
<div key={i} style={{padding:"12px 14px",background:C.bgCard,borderRadius:6,border:`1px solid ${colorNivel(a.nivel)}40`}}>
<div style={{display:"flex",alignItems:"center",gap:8,marginBottom:8,flexWrap:"wrap"}}>
<span style={{fontSize:"0.55rem",fontFamily:"monospace",fontWeight:700,color:colorNivel(a.nivel),border:`1px solid ${colorNivel(a.nivel)}50`,borderRadius:3,padding:"2px 6px"}}>{etiquetaNivel(a.nivel)}</span>
<span style={{fontSize:"0.72rem",color:C.t1,fontWeight:600}}>{a.titulo}</span>
</div>
<div style={{fontSize:"0.65rem",color:C.t2,lineHeight:1.7,marginBottom:6}}>{a.hecho}</div>
<div style={{fontSize:"0.65rem",color:C.t1,lineHeight:1.7}}>{a.consecuencia}</div>
{a.quePuedesHacer&&(
<div style={{marginTop:8,paddingTop:8,borderTop:`1px solid ${C.border}`,fontSize:"0.64rem",color:C.green,lineHeight:1.7}}>
<strong>Qué puedes hacer:</strong> {a.quePuedesHacer}
</div>
)}
</div>
))}
<div style={{fontSize:"0.58rem",color:C.t3,fontFamily:"monospace",lineHeight:1.6}}>
{R.size} bytes analizados · sin consultar el nodo · esta herramienta no firma ni modifica nada
</div> </div>
<div style={{fontSize:"0.68rem",color:C.t2,lineHeight:1.6}}>{result.note}</div>
</div> </div>
)} )}
</Card> </Card>
@@ -6172,12 +6580,13 @@
{/* Intro */} {/* Intro */}
<Section id="intro" title="¿Qué es Txoko Node Dashboard?" accent={C.green}> <Section id="intro" title="¿Qué es Txoko Node Dashboard?" accent={C.green}>
<P>Txoko Node Dashboard es una herramienta de análisis Bitcoin diseñada para usuarios que gestionan su propio nodo completo. Permite explorar la blockchain, analizar la privacidad de transacciones y monitorizar el estado del nodo, todo desde tu propia infraestructura.</P> <P>Txoko audita tu privacidad Bitcoin sobre tu propio nodo. Te dice qué puede deducir un observador externo de tus transacciones — y lo hace sin convertirse él mismo en ese observador.</P>
<P>A diferencia de exploradores públicos como Mempool.space o Blockchair, <strong style={{color:C.t1}}>ninguna consulta sale de tu red privada</strong>. Tu nodo responde directamente: nadie sabe qué direcciones o transacciones estás investigando.</P> <P>Cuando consultas una dirección en un explorador público, ese explorador aprende qué te interesa. Aquí <strong style={{color:C.t1}}>ninguna consulta sale de tu red</strong>: responde tu nodo, y nadie —tampoco nosotros— sabe qué estás investigando.</P>
<H color={C.amber}>Principios de diseño</H> <H color={C.amber}>Principios de diseño</H>
<Li><strong style={{color:C.t1}}>Privacidad total</strong> — todas las consultas van a tu nodo. Sin telemetría, sin logs externos, sin CDNs de terceros para datos sensibles.</Li> <Li><strong style={{color:C.t1}}>Privacidad total</strong> — todas las consultas van a tu nodo. Sin telemetría, sin logs externos y sin CDNs: hasta las librerías y las fuentes se sirven desde tu propia máquina, así que ni siquiera se filtra <em>que</em> estás usando Txoko. El dashboard funciona sin conexión a internet.</Li>
<Li><strong style={{color:C.t1}}>Datos fiables</strong> — cada análisis indica explícitamente su nivel de certeza: CERTEZA, PROBABLE o POSIBLE. Nunca presentamos heurísticas como hechos.</Li> <Li><strong style={{color:C.t1}}>Datos fiables</strong> — cada análisis indica explícitamente su nivel de certeza: CERTEZA, PROBABLE o POSIBLE. Nunca presentamos heurísticas como hechos.</Li>
<Li><strong style={{color:C.t1}}>Soberanía</strong>el dashboard es un único archivo HTML estático. Sin dependencias de servidor, sin base de datos, sin cuentas.</Li> <Li><strong style={{color:C.t1}}>Soberanía</strong>todo el código de análisis cabe en un archivo HTML sin compilar: lo que lees es lo que se ejecuta. Sin base de datos, sin cuentas, sin servidor que dependa de nadie.</Li>
<Li><strong style={{color:C.t1}}>Sí, esto es chain analysis</strong> — el peritaje usa las mismas técnicas que las empresas de vigilancia de cadena. Lo que cambia es quién las ejecuta (tú), sobre qué (lo tuyo), dónde se detiene (en un servicio, nunca en una persona) y quién se queda el informe (tú). Y hay una razón de más peso: la misma herramienta que sigue el rastro de un ladrón demuestra lo fácil que es seguir el tuyo. Conocer el arma no es adoptarla.</Li>
<Callout color={C.green}>Este proyecto es software libre desarrollado por y para la comunidad Bitcoin Txoko. Contribuciones y mejoras son bienvenidas en nuestro Gitea.</Callout> <Callout color={C.green}>Este proyecto es software libre desarrollado por y para la comunidad Bitcoin Txoko. Contribuciones y mejoras son bienvenidas en nuestro Gitea.</Callout>
</Section> </Section>
+46 -2
View File
@@ -168,10 +168,33 @@ async function getDisk() {
} }
// ── Procesos destacados (nombre limpio del binario) ──────────────────────── // ── Procesos destacados (nombre limpio del binario) ────────────────────────
// Tiempo de CPU consumido por un proceso, en ticks, desde /proc/PID/stat.
// Campos 14 (utime) y 15 (stime). El nombre del proceso va entre paréntesis y
// puede contener espacios, así que se corta por el ÚLTIMO ')' antes de partir.
function readProcCpuTicks(pid) {
try {
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
const resto = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
// resto[0] es el campo 3 (estado), así que utime=campo14 → resto[11]
const utime = parseInt(resto[11], 10);
const stime = parseInt(resto[12], 10);
if (Number.isNaN(utime) || Number.isNaN(stime)) return null;
return utime + stime;
} catch { return null; }
}
// OJO con la columna %CPU de `ps`: NO es el consumo actual, sino la media del
// proceso desde que arrancó (tiempo de CPU / tiempo de vida). Un proceso que
// trabajó mucho al principio y ahora está ocioso sigue mostrando un número
// alto días después, y se lee como si estuviera saturando la máquina.
// Aquí se mide de verdad: dos lecturas de /proc separadas 500 ms, el mismo
// método que ya usa getCpuUsage para el total del sistema. Las dos esperas
// corren en paralelo (van dentro del mismo Promise.all), así que no cuesta
// tiempo extra.
async function getProcesses() { async function getProcesses() {
const result = await sh("ps aux --no-headers --sort=-%mem | head -8"); const result = await sh("ps aux --no-headers --sort=-%mem | head -8");
if (!result) return []; if (!result) return [];
return result.split("\n").map(line => { const filas = result.split("\n").map(line => {
const parts = line.trim().split(/\s+/); const parts = line.trim().split(/\s+/);
// Nombre limpio: basename del ejecutable; si es un intérprete (node, // Nombre limpio: basename del ejecutable; si es un intérprete (node,
// python...), añade el basename del script que ejecuta. // python...), añade el basename del script que ejecuta.
@@ -190,12 +213,33 @@ async function getProcesses() {
} }
command = command.slice(0, 24); command = command.slice(0, 24);
return { return {
pid: parts[1],
user: parts[0], user: parts[0],
cpu: parseFloat(parts[2]), cpuMedia: parseFloat(parts[2]), // media desde el arranque (lo que da ps)
mem: parseFloat(parts[3]), mem: parseFloat(parts[3]),
command, command,
}; };
}).filter(p => p.mem > 0.5); }).filter(p => p.mem > 0.5);
const ticks1 = filas.map(p => readProcCpuTicks(p.pid));
await sleep(500);
const ticks2 = filas.map(p => readProcCpuTicks(p.pid));
const USER_HZ = 100; // estándar en Linux
const VENTANA_S = 0.5;
const nucleos = os.cpus().length || 1;
return filas.map((p, i) => {
let cpu = null, cpuSistema = null;
if (ticks1[i] !== null && ticks2[i] !== null) {
const seg = (ticks2[i] - ticks1[i]) / USER_HZ;
// % de UN núcleo (criterio de top/ps: puede pasar de 100 si va en varios)
cpu = Math.max(0, Math.round((seg / VENTANA_S) * 1000) / 10);
// % del total de la máquina, que es lo que suele querer saberse
cpuSistema = Math.round((cpu / nucleos) * 10) / 10;
}
return { user: p.user, command: p.command, mem: p.mem, cpu, cpuSistema, cpuMedia: p.cpuMedia };
});
} }
// ── Load / Uptime ─────────────────────────────────────────────────────────── // ── Load / Uptime ───────────────────────────────────────────────────────────
+47
View File
@@ -0,0 +1,47 @@
# Pruebas de la criptografía
Verifican la derivación watch-only (BIP32, secp256k1, RIPEMD-160, bech32)
contra los **vectores oficiales de los estándares**, no contra resultados
propios. Si un cambio rompe algo, estas pruebas lo dicen.
## Cómo ejecutarlas
Extraer el bloque criptográfico de `dashboard.html` a `crypto.js` (las líneas
que van desde `const B32 = {` hasta el final de `deriveAddresses`), añadir al
principio `const { webcrypto } = require("crypto"); const crypto = webcrypto;`
y al final la exportación:
module.exports = { B32, SECP, deriveChildPubkey, ripemd160, hash160, toBech32, deriveAddresses };
Después:
node test1.js # RIPEMD-160 y aritmética de curva
node test2.js # BIP32 y bech32 contra vectores oficiales
node test3.js # búsqueda de casos borde (diagnóstico)
node test4.js # regresión de los fallos ya corregidos
## Qué cubren
- **test1** — RIPEMD-160 con los seis vectores del estándar (incluido el de un
millón de caracteres), generador de secp256k1, múltiplos conocidos, y que
comprimir y descomprimir un punto sea reversible.
- **test2** — BIP32: clave pública y chain code de la raíz, y derivación no
endurecida `m/0`, contra los vectores 1 y 2 del propio BIP32. bech32: la
dirección P2WPKH del generador, en mainnet y testnet (BIP173).
- **test3** — sondeo de casos borde. Fue el que encontró los cuatro fallos de
validación corregidos el 2026-07-27.
- **test4** — comprueba que esos cuatro siguen cerrados: checksum rota, xpub
truncado, índice endurecido, índice negativo. Y que un `tpub` genera
direcciones de testnet, no de mainnet.
## Lo que estas pruebas NO cubren
La matemática es correcta, pero eso no es una auditoría. No cubren análisis
formal ni una revisión independiente: quien las escribió conoce la
implementación y comparte sus supuestos, que es justo el sesgo que rompe un
revisor externo. Siguen haciendo falta ojos de fuera antes de difundir el
proyecto ampliamente.
Tampoco aplican aquí los ataques de canal lateral: todo esto maneja **solo
claves públicas**. No hay secreto que filtrar; lo único que importa es que el
resultado sea correcto, y eso es lo que se comprueba.
+44
View File
@@ -0,0 +1,44 @@
// WebCrypto en Node
const { webcrypto } = require('crypto');
global.crypto = webcrypto;
const fs=require("fs");
const { B32, SECP, deriveChildPubkey, ripemd160, hash160, toBech32, deriveAddresses } = require('/tmp/cripto/crypto.js');
const hex = a => Array.from(a).map(b=>b.toString(16).padStart(2,'0')).join('');
let pass=0, fail=0;
const check=(nombre, got, want)=>{
const ok = got===want;
console.log(` ${ok?'✓':'✗'} ${nombre}`);
if(!ok){ console.log(` obtenido: ${got}`); console.log(` esperado: ${want}`); fail++; } else pass++;
};
(async () => {
console.log("=== 1. RIPEMD-160 (vectores del estándar) ===");
const enc = s => new TextEncoder().encode(s);
check('""', hex(ripemd160(enc(""))), "9c1185a5c5e9fc54612808977ee8f548b2258d31");
check('"a"', hex(ripemd160(enc("a"))), "0bdc9d2d256b3ee9daae347be6f4dc835a467ffe");
check('"abc"', hex(ripemd160(enc("abc"))), "8eb208f7e05d987a9b044a8e98c6b087f15a0bfc");
check('"message digest"', hex(ripemd160(enc("message digest"))), "5d0689ef49d2fae572b881b123a85ffa21595f36");
check('abcdefghijklmnopqrstuvwxyz', hex(ripemd160(enc("abcdefghijklmnopqrstuvwxyz"))), "f71c27109c692c1b56bbdceb5b9d2865b3708dbc");
check('1M x "a"', hex(ripemd160(enc("a".repeat(1000000)))), "52783243c1697bdbe16d37f97f68f08325dc1528");
console.log("\n=== 2. secp256k1: G y múltiplos conocidos ===");
const G = SECP.G;
check('G comprimido', hex(SECP.compress(G)), "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798");
check('2G', hex(SECP.compress(SECP.mulPoint(2n, G))), "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5");
check('3G', hex(SECP.compress(SECP.mulPoint(3n, G))), "02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9");
// n-1 * G = -G (mismo x, y opuesta)
const nm1 = SECP.mulPoint(SECP.N - 1n, G);
check('(n-1)G tiene la x de G', nm1[0].toString(16), G[0].toString(16));
check('(n-1)G tiene y opuesta', ((nm1[1] + G[1]) % SECP.P).toString(), "0");
console.log("\n=== 3. compress → decompress (ida y vuelta) ===");
for (const k of [1n, 2n, 7n, 12345n, 0xdeadbeefn]) {
const pt = SECP.mulPoint(k, G);
const c = SECP.compress(pt);
const d = SECP.decompress(c);
check(`k=${k}`, hex(SECP.compress(d)), hex(c));
}
console.log(`\nRESULTADO: ${pass} correctas, ${fail} incorrectas`);
})();
+40
View File
@@ -0,0 +1,40 @@
const { B32, SECP, deriveChildPubkey, ripemd160, hash160, toBech32, deriveAddresses } = require('/tmp/cripto/crypto.js');
const hex = a => Array.from(a).map(b=>b.toString(16).padStart(2,'0')).join('');
let pass=0, fail=0;
const check=(n,g,w)=>{ const ok=g===w; console.log(` ${ok?'✓':'✗'} ${n}`); if(!ok){console.log(` obtenido: ${g}`);console.log(` esperado: ${w}`);fail++;}else pass++; };
(async () => {
// ── BIP32, vectores oficiales del estándar ──
// Vector 1: seed 000102...0e0f
// m/0/1 derivado SOLO con clave pública (derivación no endurecida)
console.log("=== 4. BIP32 — vector 1 oficial, derivación pública ===");
// xpub de m (raíz) del vector 1
const M = "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8";
const raw = B32.decodeBase58(M);
const chain = raw.slice(13,45), pub = raw.slice(45,78);
check("clave pública de m", hex(pub), "0339a36013301597daef41fbe593a02cc513d0b55527ec2df1050e2e8ff49c85c2");
check("chain code de m", hex(chain), "873dff81c02f525623fd1fe5167eac3a55a049de3d314bb42ee227ffed37d508");
// m/0 → xpub esperado del estándar
const m0 = await deriveChildPubkey(pub, chain, 0);
// del vector oficial: xpub de m/0'/1 ... usamos m/0 no endurecido del vector 2
console.log("\n=== 5. BIP32 — vector 2 oficial (m/0, no endurecida) ===");
const M2 = "xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB";
const r2 = B32.decodeBase58(M2);
const c2 = r2.slice(13,45), p2 = r2.slice(45,78);
const d2 = await deriveChildPubkey(p2, c2, 0);
check("m/0 clave pública", hex(d2.pub), "02fc9e5af0ac8d9b3cecfe2a888e2117ba3d089d8585886c9c826b6b22a98d12ea");
check("m/0 chain code", hex(d2.chain), "f0909affaa7ee7abe5dd4e100598d4dc53cd709d5a5c2cac40e7412f232f7c9c");
// m/0/2147483647 no se puede (endurecida). Probamos m/0/1 del vector 2:
const d3 = await deriveChildPubkey(d2.pub, d2.chain, 1);
console.log("\n=== 6. bech32 — vectores oficiales BIP173 ===");
// P2WPKH conocido: pubkey 0279be66... → bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4
const pk = Uint8Array.from(Buffer.from("0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","hex"));
const h = await hash160(pk);
check("hash160 de G", hex(h), "751e76e8199196d454941c45d1b3a323f1433bd6");
check("dirección P2WPKH", toBech32("bc", Array.from(h)), "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4");
check("misma en testnet", toBech32("tb", Array.from(h)), "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx");
console.log(`\nRESULTADO: ${pass} correctas, ${fail} incorrectas`);
})();
+63
View File
@@ -0,0 +1,63 @@
const { B32, SECP, deriveChildPubkey, ripemd160, hash160, toBech32, deriveAddresses } = require('/tmp/cripto/crypto.js');
const hex = a => Array.from(a).map(b=>b.toString(16).padStart(2,'0')).join('');
let hallazgos=[];
(async () => {
console.log("=== 7. ¿Se verifica la checksum del xpub? ===");
// xpub válido con UN carácter cambiado al final (checksum rota)
const bueno = "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8";
const malo = bueno.slice(0,-1) + (bueno.slice(-1)==="8" ? "9" : "8");
try {
const r = B32.decodeBase58(malo);
console.log(" ✗ ACEPTA un xpub con checksum inválida — no la comprueba");
hallazgos.push({sev:"medio", t:"No se verifica la checksum base58 del xpub", d:"decodeBase58 descarta los 4 bytes de checksum sin comprobarlos. Un xpub mal copiado (un carácter cambiado) se acepta y genera direcciones que no son las del usuario."});
} catch(e) { console.log(" ✓ rechaza:", e.message); }
console.log("\n=== 8. ¿Se valida la longitud del xpub? ===");
try {
const r = B32.decodeBase58("xpub661MyMwAqRbcFtXgS5sYJ"); // truncado
console.log(` ✗ ACEPTA un xpub truncado → ${r.length} bytes (deberían ser 78)`);
hallazgos.push({sev:"medio", t:"No se valida la longitud del xpub decodificado", d:"Un xpub truncado produce un array corto; chainCode y pubKey salen vacíos o parciales y la derivación falla de forma confusa o produce basura."});
} catch(e){ console.log(" ✓ rechaza:", e.message); }
console.log("\n=== 9. ¿Se valida que la clave pública esté en la curva? ===");
// Punto que NO está en secp256k1: x=1 no tiene y entera para y²=x³+7 → 8 no es residuo
try {
const falso = Uint8Array.from([2, ...new Array(31).fill(0), 1]); // x=1
const pt = SECP.decompress(falso);
const enCurva = (pt[1]*pt[1] - (pt[0]**3n + 7n)) % SECP.P === 0n;
if (!enCurva) {
console.log(" ✗ decompress DEVUELVE un punto que no está en la curva (no valida)");
hallazgos.push({sev:"medio", t:"decompress no comprueba que el punto esté en la curva", d:"Con una x que no corresponde a ningún punto de secp256k1, devuelve un par (x,y) inválido en vez de fallar. La derivación seguiría y produciría direcciones sin sentido. Solo alcanzable con un xpub manipulado."});
} else console.log(" ✓ el punto resultante sí está en la curva");
} catch(e){ console.log(" ✓ rechaza:", e.message); }
console.log("\n=== 10. Índices endurecidos (no derivables desde xpub) ===");
const raw=B32.decodeBase58(bueno), ch=raw.slice(13,45), pb=raw.slice(45,78);
try {
await deriveChildPubkey(pb, ch, 0x80000000);
console.log(" ✗ ACEPTA un índice endurecido — matemáticamente imposible desde una clave pública");
hallazgos.push({sev:"bajo", t:"No se rechaza el índice endurecido en deriveChildPubkey", d:"Un índice ≥ 0x80000000 no se puede derivar desde una clave pública. Hoy no se llama nunca con esos valores (deriveAddresses usa 0 y 1), así que no es explotable, pero la función no se defiende sola."});
} catch(e){ console.log(" ✓ rechaza:", e.message); }
console.log("\n=== 11. Consistencia: 100 direcciones seguidas ===");
const a = await deriveAddresses(bueno, 100);
const todas = [...a.receive, ...a.change];
const unicas = new Set(todas);
console.log(` direcciones generadas: ${todas.length}, únicas: ${unicas.size}`);
console.log(` todas empiezan por bc1q: ${todas.every(x=>x.startsWith("bc1q"))}`);
console.log(` longitud correcta (42): ${todas.every(x=>x.length===42)}`);
if (unicas.size !== todas.length) hallazgos.push({sev:"alto", t:"Direcciones duplicadas en la derivación", d:"Dos índices distintos producen la misma dirección."});
console.log("\n=== 12. Detección de red (mainnet vs testnet) ===");
for (const [p,esperado] of [["xpub","bc"],["zpub","bc"],["ypub","bc"],["tpub","?"],["vpub","tb"],["upub","tb"]]) {
const hrp = p.startsWith("tb")||p.startsWith("u")||p.startsWith("v") ? "tb":"bc";
const marca = (p==="tpub" && hrp==="bc") ? " ✗" : " ·";
console.log(`${marca} ${p}${hrp}`);
}
hallazgos.push({sev:"medio", t:"tpub (testnet) se trata como mainnet", d:"La detección mira si empieza por 'tb', 'u' o 'v'. Un tpub —el formato más común de testnet— empieza por 't' y NO por 'tb', así que cae en la rama de mainnet y genera direcciones bc1... a partir de claves de testnet."});
console.log("\n\n════ HALLAZGOS ════");
for (const h of hallazgos) console.log(`\n[${h.sev.toUpperCase()}] ${h.t}\n ${h.d}`);
if (!hallazgos.length) console.log("ninguno");
})();
+42
View File
@@ -0,0 +1,42 @@
const { B32, SECP, deriveChildPubkey, deriveAddresses } = require('/tmp/cripto/crypto.js');
let ok=0, ko=0;
const debeFallar = async (nombre, fn) => {
try { await fn(); console.log(`${nombre} — NO falló`); ko++; }
catch(e){ console.log(`${nombre}\n → "${e.message}"`); ok++; }
};
(async () => {
const bueno = "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8";
console.log("=== los cuatro hallazgos, revisados ===\n");
await debeFallar("checksum rota (un carácter cambiado)", () =>
deriveAddresses(bueno.slice(0,-1) + (bueno.slice(-1)==="8"?"9":"8"), 2));
await debeFallar("xpub truncado", () => deriveAddresses("xpub661MyMwAqRbcFtXgS5sYJ", 2));
await debeFallar("índice endurecido", async () => {
const raw = await B32.decodeBase58Check(bueno);
return deriveChildPubkey(raw.slice(45,78), raw.slice(13,45), 0x80000000);
});
await debeFallar("índice negativo", async () => {
const raw = await B32.decodeBase58Check(bueno);
return deriveChildPubkey(raw.slice(45,78), raw.slice(13,45), -1);
});
console.log("\n=== el xpub bueno sigue funcionando ===");
const a = await deriveAddresses(bueno, 3);
console.log(" recepción:", a.receive.join(", "));
console.log(" huella:", a.fingerprint);
const bien = a.receive.every(x=>x.startsWith("bc1q")&&x.length===42);
console.log(` ${bien?'✓':'✗'} formato correcto`); bien?ok++:ko++;
console.log("\n=== testnet: tpub ahora se detecta bien ===");
// vector 1 del BIP32 con los bytes de versión de testnet — dato público, no de nadie
const tp = "tpubD6NzVbkrYhZ4XgiXtGrdW5XDAPFCL9h7we1vwNCpn8tGbBcgfVYjXyhWo4E1xkh56hjod1RhGjxbaTLV3X4FyWuejifB9jusQ46QzG87VKp";
try {
const t = await deriveAddresses(tp, 2);
const esTb = t.receive.every(x=>x.startsWith("tb1q"));
console.log(` ${esTb?'✓':'✗'} genera direcciones de testnet: ${t.receive[0]}`);
esTb?ok++:ko++;
} catch(e){ console.log(" ✗ falló:", e.message); ko++; }
console.log(`\nRESULTADO: ${ok} correctas, ${ko} incorrectas`);
})();
+9 -2
View File
@@ -4,8 +4,15 @@ After=network.target bitcoind.service
[Service] [Service]
Type=simple Type=simple
User=armg
ExecStart=/usr/bin/node /home/armg/txoko/system-metrics.js # ── AJUSTA ESTAS DOS LÍNEAS ANTES DE INSTALARLO ──────────────────────────
# User: el usuario con el que corre tu nodo (el mismo que ejecuta bitcoind
# suele ser buena elección). No lo dejes en root.
# ExecStart: la ruta donde hayas copiado system-metrics.js.
User=TU_USUARIO
ExecStart=/usr/bin/node /home/TU_USUARIO/txoko/system-metrics.js
# ─────────────────────────────────────────────────────────────────────────
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
StandardOutput=journal StandardOutput=journal