Files
txoko-dashboard/dashboard.html
T
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

6809 lines
458 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Txoko Node Dashboard</title>
<!-- Librerías servidas desde TU nodo, no desde un CDN. Un CDN externo no ve
qué transacciones analizas, pero sí que usas Txoko, cuándo y desde qué
IP — justo la clase de 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.
Cómo obtenerlas y verificarlas: ver SETUP.md. -->
<!-- Rutas RELATIVAS a propósito: el dashboard puede servirse en la raíz o
bajo un prefijo (con Mempool self-hosted suele ser /dashboard/), y así
funciona en ambos casos sin tocar nginx. Requiere abrirlo con la barra
final: .../dashboard/ y no .../dashboard -->
<script src="vendor/react.production.min.js"></script>
<script src="vendor/react-dom.production.min.js"></script>
<script src="vendor/babel.min.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #05080d; color: #dde6f0; font-family: 'IBM Plex Sans', 'Helvetica Neue', sans-serif; }
::-webkit-scrollbar { width: 3px; }
::-webkit-scrollbar-thumb { background: #1a2d42; border-radius: 2px; }
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.3} }
@keyframes spin { from{transform:rotate(0deg)} to{transform:rotate(360deg)} }
button:disabled { opacity:0.4; cursor:not-allowed !important; }
input::placeholder { color: #4a6380; }
</style>
<!-- Fuentes servidas desde TU nodo. Antes venían de fonts.googleapis.com,
lo que le daba a Google tu IP y la hora en cada apertura del dashboard.
Instalación: instalar-fuentes.sh (ver SETUP.md). -->
<link href="vendor/fonts/ibm-plex.css" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useCallback, useMemo, useRef } = React;
// Detecta pantalla estrecha (móvil) y se reajusta al rotar/redimensionar.
// Reutilizable para adaptar cualquier vista a móvil sin media queries CSS.
function useIsMobile(breakpoint) {
const bp = breakpoint || 560;
const [isMobile, setIsMobile] = useState(
typeof window !== "undefined" ? window.innerWidth <= bp : false
);
useEffect(() => {
const onResize = () => setIsMobile(window.innerWidth <= bp);
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [bp]);
return isMobile;
}
const THEMES = {
cypherpunk: {
bg:"#05080d", bgCard:"#090e16", bgHover:"#0d1520",
border:"#131d2b", borderBright:"#1e2e42",
green:"#00e87a", greenMuted:"rgba(0,232,122,0.08)",
amber:"#f5a623", amberMuted:"rgba(245,166,35,0.08)",
purple:"#b06ef3", purpleMuted:"rgba(176,110,243,0.08)",
blue:"#41c8f5", blueMuted:"rgba(65,200,245,0.08)",
red:"#f56565", redMuted:"rgba(245,101,101,0.08)",
t1:"#dde6f0", t2:"#6b88a8", t3:"#3a4d63",
headerBg:"rgba(5,8,13,0.93)",
},
amber: {
bg:"#0a0800", bgCard:"#110f00", bgHover:"#1a1500",
border:"#2a2000", borderBright:"#3d3000",
green:"#ffb300", greenMuted:"rgba(255,179,0,0.08)",
amber:"#ff8c00", amberMuted:"rgba(255,140,0,0.08)",
purple:"#ffcc44", purpleMuted:"rgba(255,204,68,0.08)",
blue:"#ffd966", blueMuted:"rgba(255,217,102,0.08)",
red:"#ff4400", redMuted:"rgba(255,68,0,0.08)",
t1:"#ffe066", t2:"#a8862e", t3:"#5c4810",
headerBg:"rgba(10,8,0,0.95)",
},
slate: {
bg:"#0e1117", bgCard:"#151b24", bgHover:"#1c2533",
border:"#1e2d3d", borderBright:"#2a3f57",
green:"#38bdf8", greenMuted:"rgba(56,189,248,0.08)",
amber:"#7dd3fc", amberMuted:"rgba(125,211,252,0.08)",
purple:"#a5b4fc", purpleMuted:"rgba(165,180,252,0.08)",
blue:"#38bdf8", blueMuted:"rgba(56,189,248,0.08)",
red:"#f87171", redMuted:"rgba(248,113,113,0.08)",
t1:"#e2eaf4", t2:"#6b8cad", t3:"#34495f",
headerBg:"rgba(14,17,23,0.95)",
},
light: {
bg:"#f4f6f9", bgCard:"#ffffff", bgHover:"#edf0f5",
border:"#dde3ec", borderBright:"#c8d2e0",
green:"#059669", greenMuted:"rgba(5,150,105,0.08)",
amber:"#d97706", amberMuted:"rgba(217,119,6,0.08)",
purple:"#7c3aed", purpleMuted:"rgba(124,58,237,0.08)",
blue:"#0284c7", blueMuted:"rgba(2,132,199,0.08)",
red:"#dc2626", redMuted:"rgba(220,38,38,0.08)",
t1:"#0f172a", t2:"#475569", t3:"#94a3b8",
headerBg:"rgba(244,246,249,0.97)",
},
};
const THEME_META = [
{ id:"cypherpunk", label:"Cypherpunk", dot:"#00e87a" },
{ id:"amber", label:"Ámbar", dot:"#ffb300" },
{ id:"slate", label:"Slate", dot:"#38bdf8" },
{ id:"light", label:"Claro", dot:"#059669" },
];
let C = THEMES.cypherpunk;
const setTheme = id => { C = THEMES[id] || THEMES.cypherpunk; };
const MOCK_FEES = { fastestFee:42, halfHourFee:28, hourFee:18, economyFee:8, minimumFee:3 };
const MOCK_MEMPOOL = { count:14821, vsize:28400000, total_fee:310000000 };
const MOCK_HEIGHT = 847293;
const MOCK_DIFF = { difficultyChange:3.42, timeAvg:596000, remainingBlocks:1204 };
const MOCK_BLOCKS = Array.from({length:10}, (_,i) => ({
id: `00000000000000000${i}a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0`,
height: 847293 - i, timestamp: Math.floor(Date.now()/1000) - i*600,
tx_count: 1500 + i*200, size: 1400000 - i*50000, weight: 3900000,
extras: { totalFees: 2000000 - i*100000, medianFee: 35 - i*2, pool: { name: ["Foundry USA","AntPool","F2Pool","ViaBTC","Binance Pool"][i%5] } }
}));
const MOCK_MEMPOOLBLOCKS = [
{nTx:2841, blockVSize:998000, feeRange:[38,45]},
{nTx:2210, blockVSize:985000, feeRange:[25,38]},
{nTx:1890, blockVSize:972000, feeRange:[16,25]},
{nTx:1420, blockVSize:910000, feeRange:[8,16]},
];
const MOCK_TX = {
txid: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
fee: 4200, feerate: 16.8, size: 374, weight: 996,
status: { confirmed:true, block_height:840811, block_time: Math.floor(Date.now()/1000) - 3891200 },
vin: [
{ sequence: 0xffffffff, txid:"d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5", vout:0, prevout: { value:5000000, scriptpubkey_address:"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", scriptpubkey_type:"v0_p2wpkh" } },
{ sequence: 0xffffffff, txid:"f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1", vout:1, prevout: { value:3000000, scriptpubkey_address:"bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", scriptpubkey_type:"v0_p2wpkh" } },
],
vout: [
{ value:7000000, scriptpubkey_address:"bc1qm34lsc65zpw79lxes69zkqmk6ee3ewf0j77s3h", scriptpubkey_type:"v0_p2wpkh" },
{ value:995800, scriptpubkey_address:"bc1qhkfq3zahaqkkzx5mjnamwjsfpq2jk7z09z4832", scriptpubkey_type:"v0_p2wpkh" },
],
};
const MOCK_ADDRESS = {
address: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
chain_stats: { funded_txo_sum:284710000, spent_txo_sum:269887000, tx_count:47 },
mempool_stats: { funded_txo_sum:0, spent_txo_sum:0 },
utxos: [
{ txid:"abc1def2abc1def2abc1def2abc1def2abc1def2abc1def2abc1def2abc1def2", vout:0, value:10000000, status:{confirmed:true, block_height:846089} },
{ txid:"123a456b123a456b123a456b123a456b123a456b123a456b123a456b123a456b", vout:1, value:4000000, status:{confirmed:true, block_height:846911} },
],
txs: Array.from({length:6}, (_,i) => ({
txid: `mock${i}txid${i}mock${i}txid${i}mock${i}txid${i}mock${i}txid${i}`,
vout: [{ value: 1000000 + i*500000 }],
status: { confirmed: i < 5, block_height: 847200 - i*100, block_time: Math.floor(Date.now()/1000) - i*86400 },
})),
};
// ── Entity Index — direcciones de entidades conocidas ────────────
// Embebido, sin consultas externas. Lookup O(1).
// OFAC: lista de sanciones (penaliza). Mining: pools (informativo).
// Fuentes: OFAC SDN list, mempool.space coinbase tags, bitcoin-data.
const ENTITY_INDEX = new Map([
// ─── OFAC — entidades sancionadas (crítico) ───
["3K35dyL85fR9ht7UgzPfd1gLRRXQtNTqE3",{name:"Blender.io",cat:"ofac"}],
["3Q5dGfLKkWqWSwYtbMUyc8xGjN5LrRviK4",{name:"Blender.io",cat:"ofac"}],
["3EPqGUw2q89pwPZ1UF8FJspE2AyojSTjdu",{name:"Blender.io",cat:"ofac"}],
["3LhnVMcBq4gsR7aDaRr9XmUo17CuYBV4FN",{name:"Blender.io",cat:"ofac"}],
["3F6bbvS1krsc1qR8FsbTDfYQyvkMm3QvmR",{name:"Blender.io",cat:"ofac"}],
["3JHMz3mTna1gVCZSPp8NgRFiY7phkv5mA8",{name:"Blender.io",cat:"ofac"}],
["32DaxSzUhLBHY2WGSWQYiBSHnRsfQZrrRp",{name:"Blender.io",cat:"ofac"}],
["3MTRvM5QrYZHKo8gh5qKcrPK3RLjxcDCZE",{name:"Blender.io",cat:"ofac"}],
["34pFGsSYbWEritXncW9unZtQQE9dKSvKku",{name:"Blender.io",cat:"ofac"}],
["38ncxqt932N9CcfNfYuHGZgCyR85hDkWBW",{name:"Blender.io",cat:"ofac"}],
["3MD3riFB6U8PykypF6qkvSj8R2SGdUDPn3",{name:"Blender.io",cat:"ofac"}],
["3JUwAS7seL3fh5hxWh9fu3HCiEzjuQLTfg",{name:"Blender.io",cat:"ofac"}],
["3EUjqe9UpmyXCFd6jeu69hoTzndMRfxw9M",{name:"Blender.io",cat:"ofac"}],
["3QEjBiPzw6WZUL4MYMmMU6DY1Y25aVbpQu",{name:"Blender.io",cat:"ofac"}],
["3N3YSDvp4cbhEgNGabQxTN39kEzJmwG8Ah",{name:"Blender.io",cat:"ofac"}],
["3J19qffPT6mxQUcV6k5yVURGZtdhpdGr4y",{name:"Blender.io",cat:"ofac"}],
["33KKjn4exdBJQkTtdWxqpdVsWxrw3LareG",{name:"Blender.io",cat:"ofac"}],
["3GSXNXzyCDoQ1Rhsc7F1jjjFe7DGcHHdcM",{name:"Blender.io",cat:"ofac"}],
["3QJyT8nThEQakbfqgX86YjCK1Sp9hfNCUW",{name:"Blender.io",cat:"ofac"}],
["35hh9dg3wSvUJz9vFk1FsezLE5Fx3Hudk2",{name:"Blender.io",cat:"ofac"}],
["3NDzzVxiLBUs1WPvVGRfCYDTAD2Ua2PvW4",{name:"Blender.io",cat:"ofac"}],
["3DCCgmyKozcZkFBzYb1A2x8abZCpAUTPPk",{name:"Blender.io",cat:"ofac"}],
["3MvQ4gThF4mmuo49p4dBNchcmFHBRZnYfx",{name:"Blender.io",cat:"ofac"}],
["3FBgeJdhiBe22UoSpp51Vd8dPHVa2A4wZX",{name:"Blender.io",cat:"ofac"}],
["3HQDRyzwm82MFmLWtmyikDM9JQEtVT6vAp",{name:"Blender.io",cat:"ofac"}],
["31t4nEpcwyQJT1VuXdAoQZTT5givRDPsNP",{name:"Blender.io",cat:"ofac"}],
["39AALn7eTjdPzLb99hHhD6F7J8QWB3R2Rd",{name:"Blender.io",cat:"ofac"}],
["3LDbNuDkKmLae5r3a5icPA5CQg2Y8F7ogW",{name:"Blender.io",cat:"ofac"}],
["3JLyyLbwciWAC6re87D7mRknXakR4YbnUd",{name:"Blender.io",cat:"ofac"}],
["3ANWhUnHujdwbw2jEuGSRH6bvFsD9BqEy9",{name:"Blender.io",cat:"ofac"}],
["32fbAZMTaQxNd2fAue1PgsiPgWfcsHBQQt",{name:"Blender.io",cat:"ofac"}],
["3HupEUfKmMhvhXqf8TMoPAyqDcRC1kpe65",{name:"Blender.io",cat:"ofac"}],
["34kEYgpijvCmjvahRXXQEnBH76UGJVx2wg",{name:"Blender.io",cat:"ofac"}],
["3GYbbYkvqvjF5oYhaKCgQYCvcVE1JENk6J",{name:"Blender.io",cat:"ofac"}],
["3BazbaTP8ELJUEfPBV9z5HXEdgBziV9p7W",{name:"Blender.io",cat:"ofac"}],
["3GMfGEDYMTq9G8dEHet1zLtUFJwYwSNa3Y",{name:"Blender.io",cat:"ofac"}],
["38LjCapRrJEW7w2zwbyS15P9D9UGPjWS44",{name:"Blender.io",cat:"ofac"}],
["36XqYWGvUQwBrYLRVuegN4pJJJSPWL1WEu",{name:"Blender.io",cat:"ofac"}],
["37g6WgqedzZx6nx51tYgssNG8Hnknyj5nL",{name:"Blender.io",cat:"ofac"}],
["3QAdoc1rDCt8dii1GVPJXvvK6CEJLzCRZw",{name:"Blender.io",cat:"ofac"}],
["32PsiT8itBrEF84ebdaF82yBUEcz5Wc6uY",{name:"Blender.io",cat:"ofac"}],
["3B4G1M8eF3cThbeMwhEWkKzczw9QoNTGak",{name:"Blender.io",cat:"ofac"}],
["34ETiHfQWEYFCCaXmEeQWVmhFH5vz2JMvd",{name:"Blender.io",cat:"ofac"}],
["3PyzSbFj3hbQQjTzDzyLSgvFVDjB7yw4Cj",{name:"Blender.io",cat:"ofac"}],
["15PggTG7YhJKiE6B16vkKzA1YDTZipXEX4",{name:"Blender.io",cat:"ofac"}],
["bc1qq7p0es3dv5hcynjjf40f2xjjr6qp5py47d2f6n847vduuq9gvnyq7y9ecd",{name:"Sinbad",cat:"ofac"}],
["1JHdQHkBZiim1cb4hyUh2PbzEbbg6z2TrF",{name:"Sinbad",cat:"ofac"}],
["3Lpoy53K625zVeE47ZasiG5jGkAxJ27kh1",{name:"Garantex",cat:"ofac"}],
["12HQDsicffSBaYdJ6BhnE22sfjTESmmzKx",{name:"Suex",cat:"ofac"}],
["1L4ncif9hh9TnUveqWq77HfWWt6CJWtrnb",{name:"Suex",cat:"ofac"}],
["13mnk8SvDGqsQTHbiGiHBXqtaQCUKfcsnP",{name:"Suex",cat:"ofac"}],
["1Edue8XZCWNoDBNZgnQkCCivDyr9GEo4x6",{name:"Suex",cat:"ofac"}],
["1ECeZBxCVJ8Wm2JSN3Cyc6rge2gnvD3W5K",{name:"Suex",cat:"ofac"}],
["1J9oGoAiHeRfeMZeUnJ9W7RpV55CdKtgYE",{name:"Suex",cat:"ofac"}],
["1295rkVyNfFpqZpXvKGhDqwhP1jZcNNDMV",{name:"Suex",cat:"ofac"}],
["1LiNmTUPSJEd92ZgVJjAV3RT9BzUjvUCkx",{name:"Suex",cat:"ofac"}],
["1LrxsRd7zNuxPJcL5rttnoeJFy1y4AffYY",{name:"Suex",cat:"ofac"}],
["1KUUJPkyDhamZXgpsyXqNGc3x1QPXtdhgz",{name:"Suex",cat:"ofac"}],
["1CF46Rfbp97absrs7zb7dFfZS6qBXUm9EP",{name:"Suex",cat:"ofac"}],
["1Df883c96LVauVsx9FEgnsourD8DELwCUQ",{name:"Suex",cat:"ofac"}],
["bc1qdt3gml5z5n50y5hm04u2yjdphefkm0fl2zdj68",{name:"Suex",cat:"ofac"}],
["1B64QRxfaa35MVkf7sDjuGUYAP5izQt7Qi",{name:"Suex",cat:"ofac"}],
["3E7YbpXuhh3CWFks1jmvWoV8y5DvsfzE6n",{name:"Chatex",cat:"ofac"}],
["3NRJ8aXdUiZdHaiFX9ePX3DhGHzcEi14Fq",{name:"Chatex",cat:"ofac"}],
["3K7PMJyMNVnxqsfpmK9r9nJDtzDw9wNwNV",{name:"Chatex",cat:"ofac"}],
["3H3rh85qPaGLy2w6618yZNaH7i8asHv46B",{name:"Chatex",cat:"ofac"}],
["3MTrJTFhYK9v1C6pjHtuweZSopfZa4b1wb",{name:"Chatex",cat:"ofac"}],
["347QFbejDBdMZFTxpmn6evvvqyXiqZTCd7",{name:"Chatex",cat:"ofac"}],
["33xWfziVZesgo83U5izdNCBVTnrtBpSwK7",{name:"Chatex",cat:"ofac"}],
["32wdqwX3zCEX3DhAVEcKwXCEGdzgBnx1R9",{name:"Chatex",cat:"ofac"}],
["3N9YcPBDky9UsMx1RTk33tL4jDkZfSnsPk",{name:"Chatex",cat:"ofac"}],
["bc1q90zrdysy4flyacw7hsury3ajs9yzwtwp6guqpypx94w0d3p58hysvz6pde",{name:"Chatex",cat:"ofac"}],
["bc1qw7vfgv3r5vnehafl0y95sclg3uqsj87wxs9ad628yjjcq33cwessr6ndyw",{name:"Chatex",cat:"ofac"}],
["bc1q86tl9255vg5wldamfymaaz36uqxzm30gs7fhkljvzdlt9t38s3lqgdwdfq",{name:"Chatex",cat:"ofac"}],
["3M7CGBPUJwXXSroWuZ6H5jiprdKCyf7V5M",{name:"Chatex",cat:"ofac"}],
["34kWCKF2wCbe6uinit2uL4ND6d8yxsuxKM",{name:"Chatex",cat:"ofac"}],
["bc1qe95l438kzjcvnsm3kn8n5augf9gpctdlhsq7f7hpnkyvlr7rc7cqupapf7",{name:"Chatex",cat:"ofac"}],
["32VgTk8kGvBsqkHhkvtNooGdtqZm46jTVo",{name:"Chatex",cat:"ofac"}],
["3NPognMSbzyA2JYW2fpkVKWyBMi2XTq2Zt",{name:"Chatex",cat:"ofac"}],
["3MzLtBQ4Lz9J6w4Qu55TktgxFKZwxYWrP6",{name:"Chatex",cat:"ofac"}],
["36YGN5dGzqrxMomTHdkT6cYVMnWBw8S7hD",{name:"Chatex",cat:"ofac"}],
["bc1q4rzdtlt0uslyw86cp29sctl6ct29g9a95cuup7pn5md9ddj7xgmqpp5m73",{name:"Chatex",cat:"ofac"}],
["39KQvziHwUe2vddbpfC5WkQEV72qbQhxuh",{name:"Chatex",cat:"ofac"}],
["3Qw9Fn19gCnga9LfHfpM99aGzuqxBNjR2i",{name:"Chatex",cat:"ofac"}],
["1EfMVkxQQuZfBdocpJu6RUsCJvenQWbQyE",{name:"Lazarus Group",cat:"ofac"}],
["17UVSMegvrzfobKC82dHXpZLtLcqzW9stF",{name:"Lazarus Group",cat:"ofac"}],
["39eboeqYNFe2VoLC3mUGx4dh6GNhLB3D2q",{name:"Lazarus Group",cat:"ofac"}],
["39fhoB2DohisGBbHvvfmkdPdShT75CNHdX",{name:"Lazarus Group",cat:"ofac"}],
["3E6rY4dSCDW6y2bzJNwrjvTtdmMQjB6yeh",{name:"Lazarus Group",cat:"ofac"}],
["3EeR8FbcPbkcGj77D6ttneJxmsr3Nu7KGV",{name:"Lazarus Group",cat:"ofac"}],
["3HQRveQzPifZorZLDXHernc5zjoZax8U9f",{name:"Lazarus Group",cat:"ofac"}],
["3JXKQ81JzBqVbB8VHdV9Jtd7auWokkdPgY",{name:"Lazarus Group",cat:"ofac"}],
["3KHfXU24Bt3YD5Ef4J7uNp2buCuhrxfGen",{name:"Lazarus Group",cat:"ofac"}],
["3LbDu1rUXHNyiz4i8eb3KwkSSBMf7C583D",{name:"Lazarus Group",cat:"ofac"}],
["3MN8nYo1tt5hLxMwMbxDkXWd7Xu522hb9P",{name:"Lazarus Group",cat:"ofac"}],
["3N6WeZ6i34taX8Ditser6LKWBcXmt2XXL4",{name:"Lazarus Group",cat:"ofac"}],
["134r8iHv69xdT6p5qVKTsHrcUEuBVZAYak",{name:"Lazarus Group",cat:"ofac"}],
["15YK647qtoZQDzNrvY6HJL6QwXduLHfT28",{name:"Lazarus Group",cat:"ofac"}],
["1PfwHNxUnkpfkK9MKjMqzR3Xq3KCtq9u17",{name:"Lazarus Group",cat:"ofac"}],
["14kqryJUxM3a7aEi117KX9hoLUw592WsMR",{name:"Lazarus Group",cat:"ofac"}],
["1F2Gdug9ib9NQMhKMGGJczzMk5SuENoqrp",{name:"Lazarus Group",cat:"ofac"}],
["3F2sZ4jbhvDKQdGbHYPC6ZxFXEau2m5Lqj",{name:"Lazarus Group",cat:"ofac"}],
["1AXUTu9y3H8w4wYx4BjyFWgRhZKDhmcMrn",{name:"Lazarus Group",cat:"ofac"}],
["1Hn9ErTCPRP6j5UDBeuXPGuq5RtRjFJxJQ",{name:"Lazarus Group",cat:"ofac"}],
// ─── Mining pools (informativo) ───
["1KFHE7w8BhaENAswwryaoccDb6qcT6DbYY",{name:"F2Pool",cat:"mining"}],
["bc1qf274x7penhcd8hsv3jcmwa5xxzjl2a6pa9pxwm",{name:"F2Pool",cat:"mining"}],
["bc1q7wedv4zdu5smt3shljpu5mgns48jn299mukymc",{name:"F2Pool",cat:"mining"}],
["1KGG9kvV5zXiqyQAMfY32sGt9eFLMmgpgX",{name:"F2Pool",cat:"mining"}],
["12KKDt4Mj7N5UAkQMN7LtPZMayenXHa8KL",{name:"Foundry USA",cat:"mining"}],
["1FFxkVijzvUPUeHgkFjBk2Qw8j3wQY2cDw",{name:"Foundry USA",cat:"mining"}],
["bc1qxhmdufsvnuaaaer4ynz88fspdsxq2h9e9cetdj",{name:"Foundry USA",cat:"mining"}],
["bc1p8k4v4xuz55dv49svzjg43qjxq2whur7ync9tm0xgl5t4wjl9ca9snxgmlt",{name:"Foundry USA",cat:"mining"}],
["bc1qwzrryqr3ja8w7hnja2spmkgfdcgvqwp5swz4af4ngsjecfz0w0pqud7k38",{name:"Foundry USA",cat:"mining"}],
["1PuJjnF476W3zXfVYmJfGnouzFDAXakkL4",{name:"ViaBTC",cat:"mining"}],
["12dRugNcdxK39288NjcDV4GX7rMsKCGn6B",{name:"Antpool",cat:"mining"}],
["15kiNKfDWsq7UsPg87UwxA8rVvWAjzRkYS",{name:"Antpool",cat:"mining"}],
["16MdTdqmXusauybtXTmFEW4GNFPPgGxQYE",{name:"Antpool",cat:"mining"}],
["16kUc5B48qnASbxeZTisCqTNx6G3DPXuKn",{name:"Antpool",cat:"mining"}],
["17gVZssumiJqYMCHozHKXGyaAvyu6NCX6V",{name:"Antpool",cat:"mining"}],
["1AJQ3jXhUF8WiisEcuVd8Xmfq4QJ7n1SdL",{name:"Antpool",cat:"mining"}],
["1BWW3pg5jb6rxebrNeo9TATarwJ1rthnoe",{name:"Antpool",cat:"mining"}],
["1CyB8GJNEsNVXtPutB36nrDY3fMXBTzXSX",{name:"Antpool",cat:"mining"}],
["1D4UZG4qo8bF1MuZHSEyBHRZaxT8inatXS",{name:"Antpool",cat:"mining"}],
["1DDXyKUT6q3H9e5QXm2Gv6BNNWgztFG55g",{name:"Antpool",cat:"mining"}],
["1Dek9ArRHb9tyWb9gaaX8SWmkfi5V7U5Y6",{name:"Antpool",cat:"mining"}],
["1DyR7HPQWjM6Zrnk7SzHVY2GEpXRGNNH9o",{name:"Antpool",cat:"mining"}],
["1FdJkPdpXtK3t5utZHJAop3saLZWfPfgak",{name:"Antpool",cat:"mining"}],
["1GRcX882sdBYCAWyG99iF2oz7j3nYzXhLM",{name:"Antpool",cat:"mining"}],
["1Gp7iCzDGMZiV55Kt8uKsux6VyoHe1aJaN",{name:"Antpool",cat:"mining"}],
["1H3u6R813MHGYhmGW6v86EYYriawRtACYD",{name:"Antpool",cat:"mining"}],
["1JBVrhSSDrZrRmm4RnoWouqgGGqJMvWHi8",{name:"Antpool",cat:"mining"}],
["1JwUDWVSbAY5NeCBJhxQk1E8AfETfZuPj4",{name:"Antpool",cat:"mining"}],
["1K8PNogxBZ6ts532DZnzxdbjgzJLjLdXqz",{name:"Antpool",cat:"mining"}],
["1LTGvTjDxiy5S9YcKEE9Lb7xSpZcPSqinw",{name:"Antpool",cat:"mining"}],
["1NS4gbx1G2D5rc9PnvVsPys12nKxGiQg72",{name:"Antpool",cat:"mining"}],
["1Nh7uHdvY6fNwtQtM1G5EZAFPLC33B59rB",{name:"Antpool",cat:"mining"}],
["1Sjj2cPC3rTWcSTEYDeu2f3BavLosog4T",{name:"Antpool",cat:"mining"}],
["1jLVpwtNMfXWaHY4eiLDmGuBxokYLgv1X",{name:"Antpool",cat:"mining"}],
["39C7fxSzEACPjM78Z7xdPxhf7mKxJwvfMJ",{name:"Antpool",cat:"mining"}],
["3FaYYQF6wCMUB9NCeRe4tUp1zZx8qqM7H1",{name:"Antpool",cat:"mining"}],
["15MdAHnkxt9TMC2Rj595hsg8Hnv693pPBB",{name:"MARA Pool",cat:"mining"}],
["1A32KFEX7JNPmU1PVjrtiXRrTQcesT3Nf1",{name:"MARA Pool",cat:"mining"}],
["3D72db1KMCnj7FL7MBsmxTw81z2bVu4UN5",{name:"MARA Pool",cat:"mining"}],
["3LC8dDKyBsrWPfzhXyt7aAyjXxGYkfDdHu",{name:"MARA Pool",cat:"mining"}],
["1AqTMY7kmHZxBuLUR5wJjPFUvqGs23sesr",{name:"Braiins Pool",cat:"mining"}],
["1CK6KHY6MHgYvmRQ4PAafKYDrg1ejbH1cE",{name:"Braiins Pool",cat:"mining"}],
["1MkCDCzHpBsYQivp8MxjY5AkTGG1f2baoe",{name:"Luxor",cat:"mining"}],
["39bitUyBcUu3y3hRTtYprKbTp712t4ZWqK",{name:"Luxor",cat:"mining"}],
["32BfKjhByDSxx3BM5vUkQ3NQq9csZR6nt6",{name:"Luxor",cat:"mining"}],
["37dvwZZoT3D7RXpTCpN2yKzMmNs2i2Fd1n",{name:"Ocean Mining",cat:"mining"}],
["bc1qvfzssz36y4gxcg9gh234rzem9k0vrdlx4kq5sg",{name:"NiceHash",cat:"mining"}],
["14yfxkcpHnju97pecpM7fjuTkVdtbkcfE6",{name:"Bitfury",cat:"mining"}],
["1AcAj9p6zJn4xLXdvmdiuPCtY7YkBPTAJo",{name:"Bitfury",cat:"mining"}],
["1FeDtFhARLxjKUPPkQqEBL78tisenc9znS",{name:"Bitfury",cat:"mining"}],
["1Nd99aNgYWpKkqcqSMgWtdtVDadewAS5F7",{name:"Bitfury",cat:"mining"}],
["13JmJMxfsmtiscVhJHHAHLRDqkeJLwDCNQ",{name:"BTCC",cat:"exchange",src:"cluster"}],
["14KBQmbsGhhsRNcYgRnj78arkzsDBDUoeH",{name:"BTCC",cat:"exchange",src:"cluster"}],
["14iykVoJbnqdqSXwL1dxd5jhrEmStjMAcm",{name:"BTCC",cat:"exchange",src:"cluster"}],
["15GZfB6BKqkzEHmvnireuD2gakuVe5rzC3",{name:"BTCC",cat:"exchange",src:"cluster"}],
["15GufGoLdDmcwHnQQKnzPabTomz4ZCZGQR",{name:"BTCC",cat:"exchange",src:"cluster"}],
["17TaKE9ST9pkZh1b6svJPiRmrK4B3FuwCy",{name:"BTCC",cat:"exchange",src:"cluster"}],
["19f4bD2FxF2NkmxJg2WsN33JmEG9pCGS5C",{name:"BTCC",cat:"exchange",src:"cluster"}],
["1E55hhTcPFzWy7jeVpLVGnvWcVryn5mtqr",{name:"BTCC",cat:"exchange",src:"cluster"}],
["1Ghy3746Ri1LeC1BXQXKxmKuT4wWPndVpH",{name:"BTCC",cat:"exchange",src:"cluster"}],
["1GtR2gL9v1vsdH4pCmPUQsqcdAbUM1NP4V",{name:"BTCC",cat:"exchange",src:"cluster"}],
["1H3T7B2DbGjheZHd4eLQVn7Sp7G6fsVjfx",{name:"BTCC",cat:"exchange",src:"cluster"}],
["1HEstph5Emkucm5nNJo5X4utj5Y9286DLE",{name:"BTCC",cat:"exchange",src:"cluster"}],
["1KBk4d4KbFyGv5bMYwNVprDFAghcBUJfn2",{name:"BTCC",cat:"exchange",src:"cluster"}],
["1KHUbrASx6ust5cns1FTmsxbwvq4rmvt1B",{name:"BTCC",cat:"exchange",src:"cluster"}],
["1LDmXvCDvV2zJfULw2T55NuVJuwvTHEHWs",{name:"BTCC",cat:"exchange",src:"cluster"}],
["1LP51sotiufxyWvmZrDKYDCsphNt6TjkUp",{name:"BTCC",cat:"exchange",src:"cluster"}],
["1LQXi8FfrsduvrwuSBD3REMjZDfxXjtZWu",{name:"BTCC",cat:"exchange",src:"cluster"}],
["1Lf5WjV1WBhK7zG7SYPDAdLM5LygU4ZYWa",{name:"BTCC",cat:"exchange",src:"cluster"}],
["1MR61zxeYSq5Rz7KfzvKJBuPLL5m8hrL5e",{name:"BTCC",cat:"exchange",src:"cluster"}],
["1NibLyFvkASQWfrHhdsBGaweUh6YJPeK8f",{name:"BTCC",cat:"exchange",src:"cluster"}],
["13EzhtK1UAJW3uBhmwcAkBN7uqEpVnAzDv",{name:"BitPay",cat:"exchange",src:"cluster"}],
["15FqFTPm3amgv2gX4ASyo5JLu9yDfvtAZz",{name:"BitPay",cat:"exchange",src:"cluster"}],
["16FprC8UUAkJReuNRqus9rvuBxMR4HWp22",{name:"BitPay",cat:"exchange",src:"cluster"}],
["17FXdktcyiyHjNF8WqTUU46qLTahfRqwTm",{name:"BitPay",cat:"exchange",src:"cluster"}],
["17qdd64x6LJT8iEN1P1BpSxasFYgYuGPJo",{name:"BitPay",cat:"exchange",src:"cluster"}],
["18mKyjyUjouG3iaoVCH4zyHEoyan6HEvWj",{name:"BitPay",cat:"exchange",src:"cluster"}],
["1BAwXXhXuEUk2n6AJMcfdi81X4mmbtx84e",{name:"BitPay",cat:"exchange",src:"cluster"}],
["1CEAcGsMrcpjxtFiKT9JKwVY628LJFcypQ",{name:"BitPay",cat:"exchange",src:"cluster"}],
["1Cnc6HdErHyxmMXn8i8k9RWQDVCoM52BNv",{name:"BitPay",cat:"exchange",src:"cluster"}],
["1D87ViVe4xXh4XNQzQPBigGKL4rTxi5hDm",{name:"BitPay",cat:"exchange",src:"cluster"}],
["1EATV1BvmSikvMnWow8LoqPbynSDmxdcZs",{name:"BitPay",cat:"exchange",src:"cluster"}],
["1EikfuSy19oV3B8sViYNvf2EDWfLXnCtGv",{name:"BitPay",cat:"exchange",src:"cluster"}],
["1FKJY6nbm9fvRoZaAkEGtR7CQp9STjvgDj",{name:"BitPay",cat:"exchange",src:"cluster"}],
["1JhBYAt3vNi3P4Jh9TDENoyDKHEVQihUmD",{name:"BitPay",cat:"exchange",src:"cluster"}],
["1Jwf6PaRo5vNdEyxjkhXqD8fMoe16Xy581",{name:"BitPay",cat:"exchange",src:"cluster"}],
["1LfAVVQV7Xt2QWgVewyyUygQ7NAVcaXZ16",{name:"BitPay",cat:"exchange",src:"cluster"}],
["1M7rYKbkthr5smABbt2SoXzJLFoTi9xj1D",{name:"BitPay",cat:"exchange",src:"cluster"}],
["1QBkeTETDomsJh5jMRbLjuy1yYey7XAug9",{name:"BitPay",cat:"exchange",src:"cluster"}],
["1QFKFV2iHXYnieHKh5bRGmKrtXHrFTKgDV",{name:"BitPay",cat:"exchange",src:"cluster"}],
["1pVmdnqXFfyq5xv2hzwGm2X8JYS6TgobH",{name:"BitPay",cat:"exchange",src:"cluster"}],
["12j8KeDLDTcmLH9L1uP7RmYqNLBk5w6gYL",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["13asmtt9g8FMmSSLHkMsuG6KXCv86kQ8hY",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["13rCNPkLp27VtdfLdbZnbSxm9NG8xw3Dc1",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["13zUF3a39eZ7BkRf3w3KTzVzTGwK8em1R3",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["143iJ6gqQ9MMbXWRqKVNXhxXm2a8yUmQqd",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["154A7yBEmbeayLoQ557hWeYcc8SSpbd5Cw",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["15SRuTm77maTfCx4ndyVuwEHgqUeK6kkZt",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["15VZsRthXqBj7knfZ2hqR7PQy1F6SqipGS",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["17qZbpiC91nmpEqbJHLyoyao21fduNmZ4c",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["187V1TN74QCFycyKkjx4s8CnTwTXnGpztJ",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["18BcFawpyHMkjDTWVLqsxqr9S1CRXo4EHM",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["18yYnkuG7Dj9akTAXYo8JkpYYUgBcbercY",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["19NWk5ouaXNW1AiYvXZJyksvn7nbrcRiXz",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["19hjC2jG8K3oAt8xqm6P2m6zb84EwhkVTK",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["1A591tkdns5SZjsujg8Fg4VkDvY5Hp8juu",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["1AWzC6FaqV95dWQHffyB9vabJAH4G9PQvN",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["1BAcYxHahsqxfrRtVYacsW6nkPvx7Rwxge",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["1BivsmQxepwTTp96ysBiT1UF7GyHu35jGp",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["1Caw4AMmX2ACztRxuP4K5dWybMSqkyQQfk",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["1FamuE3oRM6vT3rGYbYKtrTQFZF8gukBSf",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["1GNjdhN4JPhA2RfDiCEBAE9fKzE4MJZSFy",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["1HzoVgU9No93WwbNgwXHeMdD5NGjioCt7K",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["1JJCmLaMe6MZX47h25cG5cGxPTYZ8XFm5Y",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["1JbC68vpWNyXNtDGxWtqP518R5voqeTrLA",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["1JjDF8ht3VN5B5scDiWzVvmkfLeMVhTiX2",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["1K6jkrNa6FuEfXFz2Sf3pVMQRxykDEX2Ev",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["1KNVVNAfWCwijmi1X8oRuxEhNUP2QzQgCF",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["1KgQUQjqwddMdQWK87oR9KjN7Hm2t1w3vx",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["1Koy2zA93VgZT2sr2v13tLZ6HDWWcePECj",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["1MG3fJ2GFqJn4Hj4DzbZY9EMbQrxahxYWZ",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["1Nk2UNED12CFTPvzETunF4tdjiqS5HTsWv",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["1PEG84KWeSDcDBExhgo5ngEQNybohu3PDz",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["1PQYBt8VATUZiMGQw9rEVNLLJUpZGVykjw",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["1PfKVhtfKtsJC6914PP8a2HTjEKEJRbq5F",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["1PfZSv4bXEponpy99kXskKyVg7EdxQFcUG",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["1bz9Vp1vVhpH36ujjCULer5syar8AWt31",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["32Hgbz12HzPLes2L9wQNnu6epXMgDxeMvL",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["32nDpW3JxJ2hDHtFN3G9ZKneCJDqqecRSK",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["337pZwtgmm2PnAXjDpsp6nJ6PqFEuyceNV",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["339TKGwDLDdwvrR7xcYsM5GkY1HpWeGNPf",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["33HuMKdvcmzkcneV1USTcZVJqpkB2VBCd9",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["33hfnAXusAqAECmup7vz4xcduYzL4sadKK",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["33inC9M8CFH9eUhZModeNrEukSP61nWFKp",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["33jY477XPfh5JMkKhiq796yhHb97SrpFJB",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["34B9qezStgpdrcou1gSB9BVbuM1s9gHjtj",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["34QqziuZ8FZFUbhNa6MEHVwAcHLnCTXgTa",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["34eyDsZTjNJDG94Lc15DAUiEGALJbZQco3",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["34ryCaXdHBXAYNoWx9uarGe2BRv9aHKDAf",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["34yqz4xeg2rU97XZco1pxoUL58hSiWzc5e",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["36mbcJVoPwqDEdKNNAvefB6jcEGX8KYEW7",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["37g4L1sDNahPL7vQKG17YCXBinMt7QcmqL",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["37nxRAXixoS9KXqEFuhiCKYCQB89Gv2DDV",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["38wtfzWoxkr2quwSAMm18Xifygz2hWKTUn",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["39HyDsA97QjviwWHFstGkb8U9p4kZT3fZ1",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["39LAyy77En7ST9ygAXYSd2YQH7kYNWHTnq",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["39kUREkPTxfdBpJz7FGZzwEXqNZHuT2Gtx",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["39kmRMqS5BAwQii4AggoxMDJ6riwcXAqVp",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3A7mKFvogic4CP17tyy3JB9ac28Jg292pt",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3AnALWk4uDK2PJcykaZTnuENUA3jWQ3kzi",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3B372xLfWD3j3BmwTud6LdTySGSTFJ71oe",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3BZztLpXkwq7Y7tHgBuTaF3uicZQibkGiF",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3CJtkTcEaCND6qHdAwdpAFufgWcQ1AC3kE",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3CQLVeVPNvYJegEhDF79MQcWCVMA1qbZ9K",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3CeKGe2zU8PcwR5LkffQ2KVJ3DBjvxy9Um",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3CtY6YAuo4c4jvW8GAL9VgqR8wHj2ehHkB",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3D5xvErYoZwgJez3ZH6pwyKyhqxaYgDahC",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3DQ1SCNVCXf5SdXhm3WiassYLhDybfyv6S",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3DdzHGif9XUbFBFBzyngmZv84VJTdBMpnn",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3F5EBhK9aDsKdrR9iCASsNPKTEhFzrx6MD",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3FkybPjv9WJCTAc3SXR79uiAy5nDcJUB4A",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3GgyrGSjxyJM6ZtwPEnMuGWdT9pJ7tCXJE",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3H2WdfJV3J3wdhT91q68bR2QSvcuYnFygL",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3HCzPmzmqU3ZMQeP43E4uj2Rw9vSRybNCH",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3HZsgGApFEigC6b9Lj9fRX5ztJhAhyoUxR",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3HrgqQ2H54woTHC8XuGKMA7pWb1yvQZFdn",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3J3kQ6w2tA1ZFpduBKcQwLg1x4qYW4Dt5r",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3J83empZHbLGYEPX7kZyoj3siTcWVivCJQ",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3JDNp3k53E6rUYE5KM6MbS5ut9eX3zJjkE",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3JbNs6fDkBbxWWz7rzEETZq5ua26J1ycn1",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3KLSVQgg5t5n5LWJ51yMemmbzVq9bcYKB2",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3KPHQTi8Rj7YTpSip2Ex4sWdPygAs6Qmpz",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3KhAks2JFoNMeb4E7dtx1c3vqDoUEic4wb",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3KmE8f6jw4wy44xU2BPoptgf8veT4pjftv",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3KpakYqkqS6xybiwFKQVXJ5pFR717hWqqZ",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3LNmUzJik2n9o49iacagLWZ6cqfyF7t7Zi",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3LUvYcJ8AEU1N6LbfFvw1psaV94U5VhS3Q",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3Lpe5ueCKu8sWYKsDhSUdqTuF1q5oBMHAV",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3MANMYQYwpQtuRditSdhM5tgLapJQumPbZ",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3MKiiR7W7JmKU9xB1dGHZr3oTKQGzyeiEv",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3MT6Snjpxc8EvVA9Ub9NUKCw1tzGXTfhqx",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3MsZViNbeHY3NMuDGcYFSfGqDaK2UwMLdW",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3N8vFbudt8Q4ooHDPU48sDtiucsbsFpciK",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3PPHmDGLmBHKv4tWbaQKUKUfQxrCJYLnur",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3PVDFfPUy4zuANQB2sHyC36iqWhV272QdK",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3PZZGoyqZYXnDVLvwEx9pDGDB8BjUMDUdo",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3Pv16w9CF4YqixcchRZYWjd7n96XupLJAB",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3PzC6GL9h3Z7VSovxXhnWhEhDA2F3oU2Td",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3QC6KAnChVCbbV1c7sFpx5K3AZ25qzHiLz",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["3QFMq68VtDG7XX1TJqSB2poQV76So5pEiu",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
["1129A9dFqy4ABDsGQ8RGusbMWYBoZc4myc",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["122LtqxDXZuQ1fU34Gn83kr6UAHUCQP1Jz",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["126pCFmyyidpiwSEVeBfjuUWjunSexeEPV",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["12JnUK69nFQYwDpdVo26GUhNUQ2FdscbK8b",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["12JsfJ8Y6qoZvfsqu2E4nzAhNqkA2Pe4Ya",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["12d4QwCwKj2zwCVcpwwhdkrbEHjfkDtmtH",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["12mKcMszHTdYU3ya5yxBLQhp1HfgDkqbec",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["12rzdGG3HaMFsE7MkYpEX5YvB9E7qUqk1N",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["13BYmugkKw3UHgRxeKQPajurpLRjhpEE1X",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["13FPbyzf2Y5oJqUpDHfsQt8QMukPQx55c6",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["13VT9amaRZJ247qXxo3kr54CrXgdNwheiz",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["143grU7zjhQk32MFkg3i3TaUni4EUCAg4K",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["14FZML8FmmVr43YjAvVSBPdhwuur2J5new",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["14TLV7Ms7efVLUrYexYNFCZ361CnqNhWTP",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["15dMQYQqAfhNj3cUPXMuiqVF5G1oEWKmJc",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["15fYjcCadt5aZyiFveaSrCVgdyFZouyXU6",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["15ykyQD6M1vQk9PyMXJd4nsJHzEK7RHLkY",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["15zetdHgZA22s4XAVVBrrJ5YTSsTUjhuMp",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["165ERUpEs9ktjHx8ymqQ7zg8mjT8wurWVj",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["16WP5xbvVk9A5wZZaEQFBqz2Cb4ME2puoK",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["16iu8bQpmCm8aYxPEkWbaCcTrRmDC9uS5d",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["16ydh3znvBGpBmQpyTT2Kfmakz5P7W6pQN",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["17dan7s9vaBDotRXt5XfXUEvBnr6ofi8ph",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["17gZVXZ4g4dEDtiDo7Heg3mWevLcCxe61u",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["187Wtdn1NmTwq2bZMpLJ85cphGsRFNZjst",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["18FJAzqBftbXTMsaK1JYwcAifSsmsv8RCx",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["197jMxWmQgzVq3wAo3ybcwn591kDP1dZfi",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["19RvJ2MBsnWg1p6XUYi6xwVDHb5wjr85o5",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["19SEwaY9fyRL9tbyBoKXBE1p4xXFLCAFXx",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["19VNw8EQWKTmN7u1X15hqyWHrymrCCVWdK",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1AWoHw4kYqDdiBLjauL5fe74WCPWoeka9z",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1AZmTMCeUbXRvFqGpQdkDzTXoFSWugrMDS",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1AdgqFm49hPFWtCtGgd8WTpukBuhiMdiLy",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1AisCybQXq1BDhdCpNf1zfbuyi8FBJUrxT",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1AkkiPRVrYMoKBvGumQ4KA3VAeDUpAkQTT",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1AoGjCpgRNmtFF9AoJw7pyQ45V6mQNmWfC",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1As2MR7L3YVRXgg474dercw2SSFxesXMbc",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1B1CwPGfd9rETmsk9AawSr865tajqTdrt6",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1B8MuMxt3QoGU2Ngg8HXtyy9Qrkwc9Dnou",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1Bsw3B5xUrgfp3LHk9WMTSbZVLKAYcFETP",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1BycLiTsxX2SwdpHGjnbQFoNU5oySDo7nJ",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1C4nqSBnoc6KxqCgXgLqW6txCcwjwcYBpj",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1Cc5Te1MZ4dVqUKygmRwFX9GtFuQJfXWu8",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1ChSkAhsT2zCM4dnMPKVPqZvmy8CEvitu9",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1CkN6W3eFqb4j4C4xRpLcZ6Q2PTdJjRbHU",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1CmMKk4zsWk5yZReg4Ln4wPKcnFYTuBw3T",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1CtSgJSQAUHdK9VR9zv6rP2zWCD319dgu8",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1D2jvZSUvVnQbbaHvxBqyAm4cNHA1pRoaV",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1DLsHNBq6AqDR8tqkErLGaXwaoi39n3Q21",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1DU6pv3mextRtE1PdbC3YWdC2PjcESBa2o",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1DUvE32zPF73D7vAyba5yW7xxJ7u7kznoS",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1Dd52EnXoPmYiubDEBv9SDaihsuu4E8gYK",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1Dh4tZjFzneQqfYryAFcunYzbyMZEQbHZo",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1E5KQmxf71mPLrCNkuxqbgWkRxriL5oX9j",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1EF85HtkoqJLvuruuSpUt8FyoLV6kRDRkz",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1EFWMicwwC3ZyuZJSvkKrofqmwz63HXHvq",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1EgehKpUCCpZ8XkVdAA7w6WmayuVkYZLo7",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1FC1BgZbgkpqetdByyBR6GKuFi6eWYSASn",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1FQBqWvxwmkmTataSAAa9RpYMV5g9DcMph",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1FRuPupcdy3NBiBKW2VGkapJuwuf2GcaTQ",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1FVEASn821E6LVNFPQ3X3rXEvtgqmBQ7Zw",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1FcLSyBcvSpxRsATGCQxPvWS1uX6RMXUi5",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1FiYFw39bGwiYZsseMnqz25iX2sBCCfcj5",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1FqgPXZumCXKcn4dgLKnYNn4akTZuXZ911",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1G4wYZrdtqrBM2ZDvXfsPQXoxstPTceHqY",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1GR87bLeiUpQvyviq8fMf1nHUbF3BQVxeK",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1Gfab2KK5BLPv2wERkfGAm7nTP83Hvyd6m",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1GgspBiakYDHHVMFZeUKqdajTxgFYo2x4e",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1Hbm3EXdSSS5wWpYjHMefzU14avDeQEFaK",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1HiSmbVAtKMvm15dCW52JSEvM1Q8STEjTM",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1HzCmSh4iVkbme93P1e8HKvEaaGrqqvuPt",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1J6snu5ua6zhwgVKWBWTVCi4PaAkaBKxqf",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1JAtUdkwcNVcvZQhf3F3ksTQaJXS1gLBBj",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1JD5U7pmABH9dyWzwjaqPj4SfSv3Xn4d9S",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1JPkpYAuA8JQuM3iKXihGjWVrULbtedDZU",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1JTQgn6jTxFPu1G3GNREtWwXoNDhE4rd9n",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1JdjFEeVyXFFw8n3JoUwDk45BgqmTVom1s",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1JrZWWWPHh8aW6MGPBzwGP71Qk1XW16iKf",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1JyLeGv4Qg9mimJcZFVrEWxJgpBwjQcWyH",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1K4C3vvnsoZSV711UWmzQgVUyL1h9WRzt2",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1KUWA92H6RsLCT7XAWsbL7mLrEBZQnxn44",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1Kksk8XpDgP7V4GYZc3qxpo1W7ZhPyXkep",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1KukjLJtJkruuv93pYADN3nkTqyjMys3E1",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1LqaMeudj3zsvvpt57vpY7DxLUsKgtwnk3",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1M3GUnBJbabtmmSy6UFdwq62MQKThurWb1",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1M6AP7SwFGnJF7AfYzvrt9xA2GXPcdjQmK",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1MAL6Gve3QEvCcDtP2tDLYDnrGjWENU9bF",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1MRFkubNb4syprcGb8DSEoLKGnXoVJP2jr",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1MYQXUMCQC87iAE4jAhFc1NUBk2mjDA1qs",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1MbkPuxSrQeTfs2ruumGJpmGJuJriUkyR4",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1MeSGqTstY5fY7CMssPi7GR4ZuboC2QKPK",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1NBodLa7B5BREwDvAwznVn7tTauRAS7VYu",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1NpatveqWPsPxFXQhrkQTA13uyGbmDapf1",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1P1afaY63jp3HkXKafGEvycJLdjkmuetMu",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1P6ZpfSTdSCMFd8YM6TcZfehoEw1gkjZES",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1PeJQUDcCZorAPQys78Gw6nj25iqkUGPpj",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1PeqdU1NyH6coorqvehkQtRxUzDQpddD2r",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1Ps2P1w9x2tsGkUcVEpqgKBesRJEiSS5pe",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1QAZYxrS1e6zP1dnUZv2s92Jf6WJ2c6915",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["1cZkNYmu7QLtpk8ReZnMbWbURU53jfPiP",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
["bc1qazcm763858nkj2dj986etajv6wquslv8uxwczt",{name:"Bitfinex",cat:"exchange",src:"forensic"}],
["11527fVg2u2PHknTgUc1nNiYHyc9uThX7B",{name:"Bitso",cat:"exchange",src:"cluster"}],
["12L8CumUjj2VizphNeiMj14qLPqMzwYYi4",{name:"Bitso",cat:"exchange",src:"cluster"}],
["13evnY9iWZtrW7uq3twyzoo5iLXYtRbduP",{name:"Bitso",cat:"exchange",src:"cluster"}],
["151X29mMsWs8H9Js2pBj4hkAvwMaFF4wR4",{name:"Bitso",cat:"exchange",src:"cluster"}],
["171AB3bHvMCcdnPN8gQicdYKCgUXgu7rW5",{name:"Bitso",cat:"exchange",src:"cluster"}],
["17kgXTvjtdWEJTr5N25yFR4fmkStUYVXms",{name:"Bitso",cat:"exchange",src:"cluster"}],
["17qs9JKh9VtGQfQLoeWQYKZ4XuzVEq63R",{name:"Bitso",cat:"exchange",src:"cluster"}],
["17yk5J4ZVwHLzUmZu7wzMiCw3NZUZm7JCr",{name:"Bitso",cat:"exchange",src:"cluster"}],
["19GQYDJEK9inGs8hom6j7oJEzsNBqe98Ex",{name:"Bitso",cat:"exchange",src:"cluster"}],
["19zuufBYS2aekBZrK6Nh23PYovENNmGBpE",{name:"Bitso",cat:"exchange",src:"cluster"}],
["1AJAPBRWcduABiCEkvwhhWKo8cv5YbwQ1g",{name:"Bitso",cat:"exchange",src:"cluster"}],
["1CdLuS6huJQXPR8ev42dTi2WcNR3ps5QNq",{name:"Bitso",cat:"exchange",src:"cluster"}],
["1DDwrxz5MC98Pfrjp4PcQiErx7wz6FJnRJ",{name:"Bitso",cat:"exchange",src:"cluster"}],
["1DHb1MvC35K2zBnKcGKXPUtczazKCQHSx9",{name:"Bitso",cat:"exchange",src:"cluster"}],
["1Di9zmk2bE3tbb65gJzfxFD9EN8a4yRRE7",{name:"Bitso",cat:"exchange",src:"cluster"}],
["1EuHY7gPSqjoaSsudvow8FTiofVf1VtQhk",{name:"Bitso",cat:"exchange",src:"cluster"}],
["1FzUe4Pe37rD8dFWLKhbihGLGHJi1d1odv",{name:"Bitso",cat:"exchange",src:"cluster"}],
["1HMNvptGnucR8D7NBKqRXKBvX42zwjq32C",{name:"Bitso",cat:"exchange",src:"cluster"}],
["1LVgg3kTSpK3BUypbzDijvKy8a4Yu4UJWJ",{name:"Bitso",cat:"exchange",src:"cluster"}],
["1M2qdVNDuSGaajgktQXB3mroVLiPBgG1rc",{name:"Bitso",cat:"exchange",src:"cluster"}],
["12CByhtcmy3ckq4bs6B4BMjn8VNPbowenM",{name:"BleuTrade",cat:"exchange",src:"cluster"}],
["13bLpP83nLEHZxJfdJaMUapzVWznzyx3gR",{name:"BleuTrade",cat:"exchange",src:"cluster"}],
["17fFbwNdhJMJY3NVmjUEY6T8Z5awF7i3UQ",{name:"BleuTrade",cat:"exchange",src:"cluster"}],
["181tyRdHuPsSvXeTdjyRddBvhuZvURW4vy",{name:"BleuTrade",cat:"exchange",src:"cluster"}],
["18ieTfWsJqw78VHjJWamn4wt8Thb6uT2SX",{name:"BleuTrade",cat:"exchange",src:"cluster"}],
["1Mc6yWMqBDrbzdUgVoDX1D4ybJRUrWq8U",{name:"BleuTrade",cat:"exchange",src:"cluster"}],
["1Q9EVkxRhLjeQRW1rfRNueaYvzKDY3WJm",{name:"BleuTrade",cat:"exchange",src:"cluster"}],
["3FAEtHyBERwWUhijnaDjukcESm1kiWrQTL",{name:"BleuTrade",cat:"exchange",src:"cluster"}],
["3FPiAL3ZGaaW2SXfTvMTcrCjRMN1FN4pqa",{name:"BleuTrade",cat:"exchange",src:"cluster"}],
["3PUN3gNGVA4A5WArcACRZpmUDwornNeQrL",{name:"BleuTrade",cat:"exchange",src:"cluster"}],
["123xjrf3knrqCEWvdTfs8EZxKqDNafgWci",{name:"CaVirtEx",cat:"exchange",src:"cluster"}],
["13BR1SXbsqChZWeoHngNNvevtkMJ7nzsjy",{name:"CaVirtEx",cat:"exchange",src:"cluster"}],
["14MkjiiZ4SnN8bL1cJuRViAACyNsM6aUDz",{name:"CaVirtEx",cat:"exchange",src:"cluster"}],
["1ADjBN92r4stb8wsqNhr4Rbt4gi4P8TdNZ",{name:"CaVirtEx",cat:"exchange",src:"cluster"}],
["1AdVABFi77GTdPL6NBmT6LJSedwxXucB5N",{name:"CaVirtEx",cat:"exchange",src:"cluster"}],
["1KLq9XRKfFZV8iM2iCgSFQ7o9uTibXy1Yq",{name:"CaVirtEx",cat:"exchange",src:"cluster"}],
["1LLMSCG3yycYPKm5q13zBFpEGUm51Pcx1E",{name:"CaVirtEx",cat:"exchange",src:"cluster"}],
["1MQiyWm4tycqkBBATfnSPxsPvxPwmYUGcW",{name:"CaVirtEx",cat:"exchange",src:"cluster"}],
["1NjAPxynACKu2hz1eXWyeAd2nJbCYko9Gx",{name:"CaVirtEx",cat:"exchange",src:"cluster"}],
["1PjTMEST2xDzpn2PGAuThtzN1uXSBRimC2",{name:"CaVirtEx",cat:"exchange",src:"cluster"}],
["113SZQ4N7VL9okbWfTjNxMUoH24Ec95SzL",{name:"CoinJar",cat:"exchange",src:"cluster"}],
["133HUSC75QkwGWwMojhyfUf6sC84tNvfqm",{name:"CoinJar",cat:"exchange",src:"cluster"}],
["13674Yivhni7hctD1K7rqSVzMsvWLSKnj7",{name:"CoinJar",cat:"exchange",src:"cluster"}],
["14EK19jiBZP14AfDSgK6or6jxNw1WDDQSN",{name:"CoinJar",cat:"exchange",src:"cluster"}],
["14G3W1iMrWMtXMpc89czkEZBf8zuif57N9",{name:"CoinJar",cat:"exchange",src:"cluster"}],
["14fxUCq57j3yuBqUAscFN3bkXAKW93kFQA",{name:"CoinJar",cat:"exchange",src:"cluster"}],
["17Pw6Yq8FrouswmfYhWPWSPywjcYRmftHe",{name:"CoinJar",cat:"exchange",src:"cluster"}],
["1Ar71DrzLxDwbTSWCMyAYPY7rNV3bpsDAD",{name:"CoinJar",cat:"exchange",src:"cluster"}],
["1BewmHiMfYTFrwEuTEUqYjLRj6tam2w3e6",{name:"CoinJar",cat:"exchange",src:"cluster"}],
["1E5iM4SRuQraWaetjUpyRVkTQRZ8pUZxhg",{name:"CoinJar",cat:"exchange",src:"cluster"}],
["1EueKiYnkoPzdAdXNtPcGWa4w9TMMt7zZ5",{name:"CoinJar",cat:"exchange",src:"cluster"}],
["1GCSwX6ZSfGczSGhYyk6yo7ExAURp4CQvG",{name:"CoinJar",cat:"exchange",src:"cluster"}],
["1H8ateCTAubbTYwzDuTKsbXkLkgwu5tssq",{name:"CoinJar",cat:"exchange",src:"cluster"}],
["1McDpMvyztoPG7r154zjFaKQ26yGb8D9fH",{name:"CoinJar",cat:"exchange",src:"cluster"}],
["1NdgTJvVctXD9bwtEh1TrbLmRESod34frg",{name:"CoinJar",cat:"exchange",src:"cluster"}],
["1PC1XxBV3UYR3ymRXW12wKkk3u6Xm6ryV6",{name:"CoinJar",cat:"exchange",src:"cluster"}],
["1PDA7r2MuAbHAAq3zQwXxeLPCvBaB6mSY3",{name:"CoinJar",cat:"exchange",src:"cluster"}],
["1Q9YxVRVq8EWFUvDrZq8xoJUiaxCrKHLsB",{name:"CoinJar",cat:"exchange",src:"cluster"}],
["1QDVeUgAu2pokY9W2bodkFqDmbeT8mQ7Hk",{name:"CoinJar",cat:"exchange",src:"cluster"}],
["1exUP93whNTaLbMvre9uBLuTvee26MbMa",{name:"CoinJar",cat:"exchange",src:"cluster"}],
["124mkM5aVUZpdMazWVXX56DQd46hKzvhHV",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
["12YJ9uaWS4nWzBxRKSMSkY2T9CFvd8gN4M",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
["12qJQvP56NvjTPDj92bFt9bLYf9hvFXajt",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
["138cr34fNKubV19ezkXjWBhvwzv13ix4yv",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
["14LSJVAgNf8JMbvqXZ3DuknufkkAQ9Yg21",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
["14T7Xp29FhYMdNQYo2vT2RyVamLxdWYWK2",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
["15S5kQvhpGvcCbjK7j44JQUY84nJbNRysF",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
["15srHazeix36eme7nK2QKioeEV7xo8fCUV",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
["16rHJdsLwSVv3RKqeKupPo62b2w5dXbPv5",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
["17qxN7FEv8hXcwVbijEZSkm2owMqSYsAnC",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
["17roc8nRFzJ2KgvkjTAfAW4ouRYxryncBX",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
["18j1tSmbyojKTpSQp1o13w8DtVwheJfoMQ",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
["18jLrPyicWe4FVDEkWv2mUXgkBvZ84chx6",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
["19Tv2pVLTEfxaBb92iAS8ErMHkq7Mp1Ejy",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
["1DpeL4a14BwArkPcUzxPYk4bhP5pUTKuvM",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
["1EzK7T326C3eU3pi4MWFDzUptg143vV4K5",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
["1EzzqmUxGdmTsAoo5kupfE8KtiMqm9v6Cb",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
["1HLvc8yvFDDU6YKtWj7uSWV8tDd9Qw6KeD",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
["1MFZHu5KnVAdKjeNJ6JUJdk59YzYfUVsMr",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
["1MT6dBP3r9XeHMQYASRD3vNNpiLoHfHmgQ",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
["16jce8CbnyF2dYkBzrXKEdeG2wGMwShkPq",{name:"Korbit",cat:"exchange",src:"cluster"}],
["16qaBxqomiNWFon94Ymd8NJ5zFGGFVGncZ",{name:"Korbit",cat:"exchange",src:"cluster"}],
["181acE6XdV4JToqMFRNmmKDqtir84Cmaxz",{name:"Korbit",cat:"exchange",src:"cluster"}],
["18rWxfA3Qv6uFKwKexGtskPxsC6BZFPKhb",{name:"Korbit",cat:"exchange",src:"cluster"}],
["19Ls2qFMEztRVgSYyFFtFReEbhGUsuHHdX",{name:"Korbit",cat:"exchange",src:"cluster"}],
["19iGtbDzXSASmcyJFbdgCiFikZMRpVaMWp",{name:"Korbit",cat:"exchange",src:"cluster"}],
["1AvGPjBB3PcdhLYwy7twKFrPwpwJ1iv9pF",{name:"Korbit",cat:"exchange",src:"cluster"}],
["1BNSSSwGATcVHAdmSXviYYoeHnps9PhGL",{name:"Korbit",cat:"exchange",src:"cluster"}],
["1BcuvsuTQ3torbthN4vYeQPhfdPY8KM2cY",{name:"Korbit",cat:"exchange",src:"cluster"}],
["1BsFwcxmUKpSNF3o4ZwSRPF4h3Z2s8vefB",{name:"Korbit",cat:"exchange",src:"cluster"}],
["1CsTzASjqs8f63pzcw5f9LJoi2i1HLwKjd",{name:"Korbit",cat:"exchange",src:"cluster"}],
["1En5ErLPzF9RMeP8z8hjna3SMjGbBXRnL",{name:"Korbit",cat:"exchange",src:"cluster"}],
["1GoxkdmiZzFKwndzGihHcW823uoxqc7NAh",{name:"Korbit",cat:"exchange",src:"cluster"}],
["1GrCCd4mSvxLqKFzs8Pbny9qBqL65LFKJp",{name:"Korbit",cat:"exchange",src:"cluster"}],
["1Gxd9c2VuLcQjee1tubhHSJShrG6TbyUcY",{name:"Korbit",cat:"exchange",src:"cluster"}],
["1JeyZBDbJTz5d1rfSkGqywzwX2AWySUByW",{name:"Korbit",cat:"exchange",src:"cluster"}],
["1KHFeyp2Sb4xXg1rjnNRi4c8yhb3XDSTSC",{name:"Korbit",cat:"exchange",src:"cluster"}],
["1KkraRR2rqMz3R9683EjYTASFbz5tmdySv",{name:"Korbit",cat:"exchange",src:"cluster"}],
["1Nh6sF7Bgxbeh6cEt8R8hRR4vuLWdcmeoB",{name:"Korbit",cat:"exchange",src:"cluster"}],
["1QJ13PRLkWBF4s1XGKUMr9AbmtnzuBH7QX",{name:"Korbit",cat:"exchange",src:"cluster"}],
["12FACbewf5Fy9nmeaLQtm6Ugo5WS8g2Hay",{name:"KuCoin",cat:"exchange",src:"forensic"}],
["1TYyommJW3uhjhcnHhUSuTQFqSBAxBDPV",{name:"KuCoin",cat:"exchange",src:"forensic"}],
["12Guz9u47GMsnqgLxo5yC5qbtbzAX7Hqxa",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["12NnaNga66HzQp2sBPRm3kkJvAeazJCKE9",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["12fVyemsxhrsnrF9VCJbRzPtMcVanJmP4i",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["13D41vrEarK4mpmZvwUoTpo4mcjrYEU33M",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["13KvKbpJ8VDZy9x39HmdnUcRDkw6FU451d",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["13T9y8u8prqvagFypK7DpnEcG7QHgkuPKX",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["13XEC8VBNvuYMyNx5wTtpwCGEL6DW9MQCv",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["13oTXxKtwoS3GjLXysTCy4EYq1pFuoLjhb",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["13va7xTm4HQ3NMQ7GVUPFfrdffUqy39Qsc",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["13xr66DhtxtcSVDdYRUoAFf4TQojaFLJjN",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["13yPMA67HaMa2TVboTdodp1BYw3BEMEdNs",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["14Ezos8TABLoJhzGoYxrrcM5QYCobbFwiG",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["15KZFKQLRxJ4AcMTnYns2r12HAVPs3nbyH",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["15XtoasEnkPnfdwKDoNRYKN84yhRXkt2Tj",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["15uctjhfCDhbjTTPZvUteum4DrHb8vcuVV",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["15x9a1paayi8izcPsCN6NJERjfTUZscjKb",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["16ZFK6PRAtL7hhMAyP6FBXrMzS9WWodord",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["16dhND4CK52imjrvnzwboWMFnU7rnSPcCa",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["17WSNnmmiJzk2DRQYi8hksnNj2JPzp2yFg",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["17tiCS48mCPjkzzdubqkWdDwwtJ9i5soPo",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["181a8dhM3MGTzLKBHASx3Pv6mcyNbkHNYM",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["18qfweby4AxnUHkbAJSfx2W92raKw3T9Y",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1942mhPQBodHHLUvQtz5Uk82RDHyzAqLNm",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["196p4UsNjZwq5wygMAswVhboyyfpjyrhq2",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["197oJMMiKkwa2SqejrbPHSNi3V4Yr9ehU5",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["19Bvv3YbhfndQN7814pSMcm41754tWNxNs",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["19NsF6oQMdFLNREHA49o3TY13TKQk7HaEM",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["19Ucf3o2QZi4zQToF2EHa7A5N1ua3gXpq3",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["19vtVvVSbLzKTsrndadsgJcyq3RveWPG5E",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["19wHwMxumg4bQULF5mpr7jFbiozD9RUMRN",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["19wtRYkewmxsQJp4ntgfihtfmf3LUc2DqD",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1AANfZpPg3JDB8QxXJvUjaGuRUnC7hR3ML",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1AX2WGCkGEkCWzP3hBNwBhDQ6bHY3EGNH6",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1ApQfqYSypGUtDiizjEk1ZeKXnQ6E9LK7",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1B3EuSu7mTgLXeLbZ6SmU5cmr2rMLjtf6C",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1BAVHJkJMjnzHMPTgRQP5wpJyWDYVzPMdi",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1BDDrZSXhsZVPhV2JyjpugTTNgcNe3Db3c",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1BXZifc9csT8pxWH5D3TY4wxbQLir64cgo",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1BcRfca3QkPADPnizVJPUZXaaEjVTCucaQ",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1BejMo6TbAM1WeMDxPLmTmx1hdrKtZhYEe",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1BfzPRSPTd775Vt9KbtHpqXbxJFEd5fQkz",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1Bo565wEcMjuMab9mVYRfy41hv2vF3is2B",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1BoY1JjPWyy9PuFcQM3KFf9DazGxZZBuqA",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1BwRgLi1e5Yv3fpL9VZmzr27wMeey7Pc2o",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1C23yq1MkiEPTtK36v36FpySrn5yaPYNPd",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1C4T9u55LhE15ikNoN6XKwmfiqLWaBN6Pw",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1CtuQxUcMFzdsMQf6et4fjr9vcRtaaYd62",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1DAe6wXTzwzmTUNBJsnsN55KELvWoF49K2",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1DYBKHzEbw3TkbFvjZ8TerHh6R5ZuzUtxQ",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1E6SFFemL3bZYA9GuKEPhQ8mk4JSFcYPFf",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1EJ1QtVdLuoJyJxi2b7YEfup7fryuxvkGF",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1Ei8PNkWU135hqinpqCRP9ML5njHGAz8Hd",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1Eq6aeU65NgmuxZ88NPw4pMW3R8omFzsjv",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1Eq7JKdb1sx6aSLMSS4oC1SjcGmE327Jca",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1F3rKLPp75MprgtvMt9jxQELQUKfJMj2iU",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1FB1i2T72o4fxA395ZqtiMyqjanMKcdjM9",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1FcyiRqAQqporVRJ6u3ktZTNXHtcUqfqv5",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1FmFqnP5uQvkSPcRm1VY7Gsjp95wRYknBR",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1GC3JQNm6viNQESfCXoFWaXkMj4rG8FPHX",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1GHxHr2Vn7BYwzwFQY3yeuGCYDo4vnStAU",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1GZfbm1F9yPc6VfnN36JD4yHgA7s2VXc82",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1GcTvkZuxdgJgYrUVFQmNh56snzhixyUaS",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1GkYvZQmTrSFBohvvPtU1m4oLumzeC9ee7",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1GuYn3UacyeMbPxTyGem11mosNPcw6PWfW",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1HB4Z7Gk6yj3jd5vHmbeQ4h4REKdYcUhUX",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1HQLxtACiyR61ecg8DQNEL2TjVhExsAtbG",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1HRTMdx6QvMaL1BEkgYyCsvgpkbqg2xxy3",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1Hc1BRCjEyjJUYi19e3ZAn85utgpKTEbFz",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1JARMeEWXEBReVRvC13syVm12qb8L6t1Lf",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1JNgdL8WJkDnqkxKoqiJPXWGt7yaqoqaUW",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1JRN8SH1LuWnY8fgKdPupMKFgRxRtL4MTJ",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1JWd94gmBFnBoq5Fe56Ynk9ioJ5HkCac4P",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1JZKDNnv6FS3sVg57tW7XwhUthtaMYjTRJ",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1Ja8xD3Zqps9KrTGZK9GQTmc15UfpKs6Dc",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1JdXuWHRrVPTRkesF7mwBBGoHiBfvH2aiZ",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1JjUZRP3WH3opLsu9WgPDZcHLHmLbVXsoc",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1Jttrcn1nT36TWq5eWTnB94j8uwxEoPSs7",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1Ke79m6KCAt9iKHi7zzqEL6PWhmjpqWZJH",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1KeRJKd46RcZFBnqbPnxJcdo7bFUzTqoEP",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1KoNGKbDWLiA7a5YjzKi7YisqWiCZvDkMo",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1L55msvNmn3tW5WCbyzZ7CnXDhRHzgkLYq",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1LHrPzjMhCxgn49A9EDbkQEie5oqzZf68f",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1LMiov4uiHAB6zLqAX5ShQt4bpi3wBzqmM",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1LN5yqUanEeZqW8uraxc6iPQyKS8NHCodM",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1M6T2HkVfKRr6GCAseXjzUA2Z4DxN3E4T7",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1MDoQuG8umWPMBYfk5huzCTEdgT1Zx1AaJ",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1MV1w1bfTm2MStWuwfHcu3DfLczcUyypXc",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1MsuyRk9NfPFuL3aJUQziKScMkZgkLLHno",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1N4JM9J62zoiHtsyBGvfPt2t8VYSQwZfnb",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1N6TZMW3tb6Ht9mpmB8w3mcdUtzhPFQVGM",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1NUTCdeMuDDDPHYgVjtAnVCoimCp2xFSpF",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1NZ8qjspVPcPjHe2Y649QXWfFXFfo8jDt4",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1NhyQRhCavnGjgNrWBSbEjyDkjNh7tzESo",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1NjncXQibyR5Ch5Nb9NvdEZiXGTut5pVsf",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1Npr57NAZ1BL96t5fawtHJsw6JJzTY9EiN",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1NsEpn3kcpRTPB1owtMnG2VG1kHVRMyCRw",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1PRbfZ4bnnHNxvaXpPCqd2chANpmXe4Wti",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1PkVARXSgjKKUcjBaYB8dcSMULejnrR2ds",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1PkfxMRkk1Xvyx7Ca4Rg1DvradL7kQ9c3c",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["1PmorchZH8xxKBtN8Ftyio2uiRQXtNU7iD",{name:"OKCoin",cat:"exchange",src:"cluster"}],
["16rF2zwSJ9goQ9fZfYoti5LsUqqegb5RnA",{name:"OKX",cat:"exchange",src:"richlist"}],
["1CY7fykRLWXeSbKB885Kr4KjQxmDdvW923",{name:"OKX",cat:"exchange",src:"richlist"}],
["1DcT5Wij5tfb3oVViF8mA8p4WrG98ahZPT",{name:"OKX",cat:"exchange",src:"richlist"}],
["1LnoZawVFFQihU8d8ntxLMpYheZUfyeVAK",{name:"OKX",cat:"exchange",src:"richlist"}],
["39wVd42giU95ca39sEPkbPTpWygvsBDuA5",{name:"OKX",cat:"exchange",src:"richlist"}],
["3E5EPMGRL5PC6YDCLcHLVu9ayC3DysMpau",{name:"OKX",cat:"exchange",src:"richlist"}],
["3FM9vDYsN2iuMPKWjAcqgyahdwdrUxhbJ3",{name:"OKX",cat:"exchange",src:"richlist"}],
["3MgEAFWu1HKSnZ5ZsC8qf61ZW18xrP5pgd",{name:"OKX",cat:"exchange",src:"richlist"}],
["1367BSnpQZ8JagnDvSURQdooUdZApsEYgL",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["17LCpUaEaMYpRfYjHGaPcgn1tp7bsErR3d",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["1A15zRXCNcrqw1j5vZV9EG1wnsg4TBD615",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["1A8jWLjUF87oANgDre2ES6bpuARipm4q1W",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["1KQjhxZx6nAzfQ4txKrPcwkT21QBvAuMwz",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["1L4KCg6Q7gVRPwK2SgtdVrYbuTFRvWos2J",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["1Nu2K2ZWdGYWaTyTAbXLgBNWGRcyN3pY7T",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["1PQjqn9kJDTsMXQbcDYoVyQjDmKP5yGpqD",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["1Q2j3K9XTcuLtW77ujdD17qQ3ZVo1aJUHk",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["1Q3coeqkKSir24Sc5yHcWA7vWGYKi2WjLy",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["1ZoHQyPGHQdSLx99eyY6ahfsyUzEXecTt",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["1mLDuepK2WRSGHbnrdyd8mfYCRRGbqggX",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["31pwBGMkFk49vdUizoetEmVNzRVNdRMsx8",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["32CCzqFbnVGwy3f1edgotoX43QUHLDRAM8",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["32GuLFS3fiZ2WMPwpZKizoxPcbjjFZCeVh",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["32JARmUYMSxGLsEXRsY29diuuUmArfpApJ",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["32QSsYmqJejtRo8hQnAEy2cmPYhLVygKKe",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["33vBrEMZzAJixeTLPhx9JLB4Yj3wWANq9j",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3458VUxz8rqDHTbhftwt6fCPTRy2yHR3jN",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["34LJNiftNWZ6J1eZ5uRNjWxcxYTRnrbwV3",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["354BfFxLizqEPsSs1hu2opy2gnoR3PRqJH",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["354mTDg5ewFxfSAwNST1viYpt4rgp3qWcp",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["355KcSF5ABCfPu6kS278WtdiShaQTom1TS",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["35BqU1LtPYJUn9P1skm4CbWGYYxwXDXDax",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["36WVLkdtAUNc3kHV8wHmkhm3R5F9hvRf9t",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["36YNuumHfeNEihLwzemfgEMPX171fUSvCs",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["36uec6cXpKPx6akZ2nxsNyThdFLXvF8ERa",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["36zHGPpHLcXd34UWmUy4vBDPJ2GwMuNwoh",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["371q3Gq3GPEWtjL8HaknWJuwuDocJAPdAC",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["37PnurPK2cYouaD8n4XnxzkEaX8DDPSFuN",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["37VdUdEAQJAbxQ1fzRb7ojphQ6M29JbTsC",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["37fu9rmxXzoPv15bTvkmzd3vGA793VXpE5",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["37hgFVUVTNg3HxRtRVtdG6npGuLQn1afDb",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["37npTQHVEqCB1d5eZ2sUt4LPCEHdA7gnMi",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["37sHGDicfW4PdYJsnUrxidT4sxX8X75wqZ",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["38Ef6hftZ4YfAQ6DU7RP6eASsmfc32DdJo",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["38hP7pnCJjYxbsAV14xY5sL7LLDzWxtEKC",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["39hRBNmJ4MLbxNMbv7xoUXequoHWLyynyr",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3AMVL2Ht1pz1SqhtoAhNkbhJ1JRLcbWhbq",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3AVJUPtHAbdwdiKA4MaY1uLqcru13aho8S",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3AjdPhtQbucj6zSUDwCRvkcnGLBonGdQ9o",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3B7FQwyX5jS1TZQNf77PpKKQ47eJTSpTer",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3BJMMZ4NVpEEtK5ekTcnK6oBK2Q2K9gn3L",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3BQrkCaCEfdy6JoBTUrdWfGwUcZAqDbAYQ",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3BS9wPNzbhtjbfQvqNt7Zn4BXPhXevywiL",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3BYVBLAP1yZdihGHqWUagjoitgkMfXLzE4",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3BssZgYUGwnFDhY8EyFBtQoiyfn6VDGWnc",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3BtmwbfVHF2piUGwJxFHymKJpfd1ZNWqRu",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3Bu1F7dUCmezVrTgnaCN6Q6vyXS4DV37cC",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3C9zZxd5GX4tSrHZMSrBFTzeQHF9zuWMcf",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3CNGGCSuc6KFESsphMYbPdR6dNqGXGnYv2",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3D3gQz98dYq4auarRkxFoJZYQevwgcwRUQ",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3D5UfA6TJ68owWkhGnvfwkNdEDxaKaNS6r",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3DDeaeSF4ukxs2y6wsppc9zZst9JEdy1r2",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3DgLSrAXeY7N8AJ2RKjXqNTpebvSjzLzUQ",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3DqrDxbXLY8KuQyoLtqWw9GHY9Kj9X1piV",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3DvFp1inWiDPeS4NUfnXJgjuCESa5YUvof",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3DvRGkcoUFLWDJLtJugAR5DFLBfKN2eQJz",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3EDTFJo38AV4N6zM8QpS7yPHNw6LW3Qfou",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3EYyfuqH7zMPUJYymbNsLjYBG2pxGjgjDe",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3EiS4CoWnZ3uNXSnGmBBBe6BWveDp94FMK",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3Er2qGdoQF1C79uJAco8mmGb5g9n6UABxi",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3ErANKbsaAxbC7LSW4LqYXKM1sfQhEfzjc",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3EyV7SdKWiXT81bZEdVqM1xrq7e6me9pY8",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3FCYnrE5cEV5zgEtNu9cefK1foihTHt8dK",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3FDNB63njLW9UmJNYghENzwmAADpJ9g4er",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3FXTykUWe6apKuptKBxxLLLYfUgPy7jgF6",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3FgkBvucMBUM29JgLZxRFKM6i3Sm3qeuSe",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3GiZi7g9ywnrbBhojFuxUTanJFXCtLbzAs",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3GksksGsvr8JweZaZoK8mu6RgeiK3CZK2G",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3Gv53ro1QrSxRo2Bj3RdLcKk5ZLzGS64zr",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3HPF1x3g34oeiakfmrUb8Ej6mbWSj8CpZc",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3HQJxVMhEQd5vzngtzwGRccniNqBMeHQrd",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3HxnXPhArTodRDbUrV2BxfHza24ERzxGjc",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3K4dySg4x1Vup4YtSyaPynKvTBP2zWU66y",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3KD2BzXJoxwni7DtfycHSSHT9T8KGJdUfD",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3KqzFurDsi8Y5eCMLUesAr1z7PgNKX2GqD",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3KsGBGFmyQi8k7cKZeqLg7Nxtabb7kEkxK",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3KzEtNM5gmStHyFG4nxomv9HyLjzPnBh5R",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3LDft6PB3qvhqWG3JeCxPBR8bmCLau2Juw",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3LDgjfPzyhyDeFXWwEfMF4Esbjf7qe2ACq",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3LKb9K4qR7suZL5PQj2bBtaQeERpqv9sa5",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3LRrNUHUd7fURdnG1wVNtsPzd7w8v2qM8f",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3LgiJD4BzCaFBsw7hATTZWBByD9cQSQZxQ",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3MDy74qRm26WoZCde7BT6brnM74jvo5ga5",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3MYg111R7ScsUtMMR13FsVsXyP4hshVsd1",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3MzM6wZKNvV7Q5EH4r4tdVc3LVz57CU6GG",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3NMD6WsSXbuxneAvjDx2PQffP4L4XntHe7",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3NgF4YGX3nh1AK36Ewwjfh3mWy3vNbZ3k6",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3NrvF3oR6sJvwzBSyxzjuVqoh4T4EQ2oAV",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3Ns4RTpU8cjQbinxA9LqzjD1u2sgcBCNFm",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3PDYQWnHNxGwcti3AtTtgCjB42B59zYJeT",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3PE5HxqTaeovir34G2xxbHH9RdPLV3gArt",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3Q2Jk5YXqTYqmV4VcHcaHmKBvf6vzBzTse",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3Q7yzzqsxtHW2hsugkpxUD72xnpYoUVrF2",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3Q9TSBxRMRgruzmEA711gqz4MPFeZGmnCY",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3QHNZFQBnQDCzUDx7D1N9WdtSNRQht6Dqy",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3QJh4ZnSPBMgRFcADbxQoaDMByDAYRU8mF",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3QQY2jDUjRFCXSRnWydPLH5GRfniqmmbbj",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["3QuBuAYS5ferkySb1JE3VWM4ncWVpWfcyn",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
["134SMxxas6b5Ng1h9nr3cS8P8K4Pk5hKzt",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
["13qBs6J5HMx2atwtX9dCKyeKHJ7p8SCVCv",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
["13sUoM8NzksNcqvTgEhdSqxgKatfQJ3SwP",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
["14p92hXmwY9ZoHrYRTZzhDWn5gJmskaVgj",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
["161GstYqTw8vNDmvJU1t1o2nKKzJdZzwfa",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
["19Wn2EcdZRpnUZd7oVs11PH4xMoEcnHfvu",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
["1ANRDDtmMRzUDYMXS64gdp6jwkNH14CEeZ",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
["1B9EuqZHut27bhs8buANR48t1Hnk8J7JUW",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
["1DCfmNNf5XmsjCHd9vHrcaDpcz8KeNTfJw",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
["1ESEG1pyd9uSwFvgCLwPyMKMT5mxuXyJUd",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
["1FijphUT1ahUfna6DFSgA26NJyEECAt289",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
["1GBSXv1e2sNbeQo5BwF4BipWF5DqsumBs5",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
["1HSvJSwo2AVJPzhArnZdY51Fs3ntoLR2Vh",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
["1HTPzb9rVRh1m5oZkM6eRENfRCFU3PnoRw",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
["1HuNzKAAwnV8Gp2RYFxjrDPoegPiuh6gCM",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
["1JrcSPyvJ87rR4WRn2CUHvmNt86tCe3zdv",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
["1KEQVbjCaCeMUenT1LkxgiGjhcfHjpSsQT",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
["1KKPhEQVz8e6EBk69LJu4jzczeqr6szLjY",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
["1MHWB6gukQCHqbwC5bH5oYJDkc535ppjvo",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
["1NxZJ9kxguQZQCnqzeDeEGP67VL5S8hG8A",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
["13RbmLNJ2WpY1RhR6eMqM5kSmzPWJfeQmw",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
["142zj8SxAwHGwug4qCsAEGppiv6GCb1UeA",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
["183w3twnpsgN7fNv3wn6LRotJoiUykeEiM",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
["1DJiUcUW4yJYLFXkbsQPWsCM8xpA81NBSr",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
["1EwZ4s5BNKBydhEJ6NjwiFD3pqUEL2jrct",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
["1FYKSZWHV5yKeuc6jNEFv9YkmN1Nzw2Mqb",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
["1GQykdcGxvyQ7ZhpCTSNzbyLuDkExSS3Vq",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
["1HAYB6Sa6nJkB1bgGcAxVHVMnWLuGx5egi",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
["1JNKY7k93gRPxZqCrd4cAqp6oZDuvQTfPw",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
["1LE4JTHqzg7ryS6xzpKWW3TqABooGmkBYi",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
["1Nx5iKXFdKGXN8yJ9P4kzvgLRVenCayvYc",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
["1PETUYP2npdqpDxsEQwhvtDBc9kn7H8GLT",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
["1nhYMfNHSAsCXxD3KnVeAgtC4Y5Sww2hn",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
["32DzaZ8wTFZAMvcwa4DPSWRRsz7jb6CASd",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
["32idg4g9VNA9BV6cTqeDKCSRDhnCwxcUfP",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
["34E6etAYfr3SsHxDTaUFU9112CRtgzd11X",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
["35qUCmr3N8gzcP6T8DJb5TRFTsX3GLYdiC",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
["3AzxPu3bw8ZXEqNB9xkHMKkTFq8Ckmpn64",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
["3HbBH2i7sHsi687Z8pS1S137mVbjgMFpQ3",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
["3Nxa9vDXRg4NQFaToUREsLrp5sd4JamhJK",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
]);
// ── Wallet fingerprinting — señales combinadas ──────────────────────
function detectWallets(tx, isBip69Shared) {
const results = [];
const locktime = tx.locktime || 0;
const allInputs = tx.vin || [];
const allOutputs = tx.vout || [];
const inTypes = allInputs.map(v=>v.prevout?.scriptpubkey_type).filter(Boolean);
// Usar BIP69 precalculado si está disponible, si no calcularlo aquí
const isBip69 = isBip69Shared !== undefined ? isBip69Shared : (() => {
const bi = allInputs.length < 2 || allInputs.every((v,i,a) => {
if (i===0) return true;
const pidA = a[i-1].txid||"", pidB = v.txid||"";
return pidA !== pidB ? pidA < pidB : (a[i-1].vout||0) <= (v.vout||0);
});
const bo = allOutputs.length < 2 || allOutputs.every((v,i,a) => {
if (i===0) return true;
if (a[i-1].value !== v.value) return a[i-1].value < v.value;
return (a[i-1].scriptpubkey||"") <= (v.scriptpubkey||"");
});
return bi && bo;
})();
// RBF explícito (sequence = 0xFFFFFFFD)
const hasExplicitRbf = allInputs.some(v=>v.sequence===0xfffffffd);
// RBF opt-in (sequence < 0xFFFFFFFE)
const hasRbfOptin = allInputs.some(v=>typeof v.sequence==="number" && v.sequence < 0xfffffffe);
// Locktime en altura de bloque reciente (> 500000 = bloque, no timestamp)
const locktimeIsBlock = locktime > 500000 && locktime < 2000000;
// Todos inputs P2WPKH
const allP2wpkh = inTypes.length>0 && inTypes.every(t=>t==="v0_p2wpkh"||t==="p2wpkh");
// Todos inputs P2TR
const allP2tr = inTypes.length>0 && inTypes.every(t=>t==="v1_p2tr"||t==="p2tr");
// ─── Bitcoin Core ──────────────────────────────────────────────
// locktime=altura actual + RBF (activo por defecto desde v0.12)
{
let score = 0, sigs = [];
if (locktimeIsBlock) { score+=3; sigs.push("locktime=bloque actual"); }
if (hasExplicitRbf) { score+=3; sigs.push("RBF sequence 0xFFFFFFFD"); }
if (hasRbfOptin && !hasExplicitRbf) { score+=2; sigs.push("RBF opt-in"); }
if (isBip69) { score+=1; sigs.push("BIP69 ordering"); }
if (score >= 4) results.push({ name:"Bitcoin Core", score, confidence: score>=5?"PROBABLE":"POSIBLE", signals:sigs });
}
// ─── Sparrow ───────────────────────────────────────────────────
// BIP69 + locktime=bloque + RBF + inputs modernos uniformes
{
let score = 0, sigs = [];
if (isBip69) { score+=3; sigs.push("BIP69 ordering"); }
if (locktimeIsBlock) { score+=2; sigs.push("locktime=bloque actual"); }
if (hasRbfOptin) { score+=1; sigs.push("RBF opt-in"); }
if (allP2wpkh || allP2tr) { score+=2; sigs.push("inputs tipo uniforme moderno"); }
if (score >= 6) results.push({ name:"Sparrow", score, confidence: score>=7?"PROBABLE":"POSIBLE", signals:sigs });
}
// ─── Electrum ──────────────────────────────────────────────────
// Guardia: Electrum usa P2WPKH por defecto. Si no hay inputs P2WPKH,
// no tiene sentido puntuar Electrum (evita falsos positivos en P2TR/P2PKH).
if (allP2wpkh) {
let score = 2, sigs = ["inputs P2WPKH uniformes"]; // incluido por la guardia
if (locktime === 0) { score+=1; sigs.push("locktime=0"); }
if (!hasRbfOptin) { score+=1; sigs.push("sin RBF"); }
if (allInputs.every(v=>v.sequence===0xffffffff)) { score+=2; sigs.push("sequence=0xFFFFFFFF"); }
const allOutSameTypeAsIn = allOutputs.length > 0 &&
allOutputs.every(o => o.scriptpubkey_type === inTypes[0]);
if (allOutSameTypeAsIn) { score+=1; sigs.push("outputs del mismo tipo que inputs"); }
if (allOutputs.length === 2) { score+=1; sigs.push("2 outputs"); }
if (score >= 5) results.push({ name:"Electrum", score, confidence: score>=6?"PROBABLE":"POSIBLE", signals:sigs });
}
// ─── BlueWallet / mobile ───────────────────────────────────────
// Corregido: eliminado doble-conteo de "1 input P2WPKH" (antes sumaba 4 pts solo).
// Umbral subido a 6 para no dispararse con cualquier tx simple genérica.
{
let score = 0, sigs = [];
if (allInputs.length === 1) { score+=2; sigs.push("1 solo input"); }
if (allInputs.length === 2) { score+=1; sigs.push("2 inputs"); }
if (allP2wpkh) { score+=1; sigs.push("inputs P2WPKH"); }
if (allOutputs.length === 2) { score+=1; sigs.push("2 outputs"); }
if (locktime === 0) { score+=1; sigs.push("locktime=0"); }
if (!hasRbfOptin) { score+=1; sigs.push("sin RBF"); }
if (allInputs.every(v=>v.sequence===0xffffffff)) { score+=1; sigs.push("sequence=0xFFFFFFFF"); }
if (score >= 6) results.push({ name:"BlueWallet/Mobile", score, confidence:"POSIBLE", signals:sigs });
}
// ─── Taproot nativo ────────────────────────────────────────────
// Fallback cuando hay inputs P2TR y Sparrow no se ha detectado ya.
// "Taproot nativo" no es un wallet concreto — es el tipo de script.
if (allP2tr && allInputs.length > 0 && !results.find(r=>r.name==="Sparrow")) {
results.push({
name:"Wallet Taproot nativo",
score: 3,
confidence:"POSIBLE",
signals:["todos los inputs son P2TR — compatible con Sparrow, Zeus, Phoenix, Bitkey u otros wallets modernos con Taproot"]
});
}
// ─── Post-proceso: resolver ambigüedades por señales solapadas ──
// Cuando un wallet más específico ya explica las señales con PROBABLE,
// el wallet genérico que comparte esas mismas señales como POSIBLE es ruido.
// Sparrow PROBABLE → Bitcoin Core (cualquier confianza) es redundante:
// las señales de Core (locktime=bloque + RBF) son subconjunto estricto de Sparrow.
// Sparrow añade BIP69 que Core no requiere — si Sparrow encaja mejor, Core sobra.
const sparrowProbable = results.find(r => r.name === "Sparrow" && r.confidence === "PROBABLE");
if (sparrowProbable) {
const idx = results.findIndex(r => r.name === "Bitcoin Core");
if (idx !== -1) results.splice(idx, 1);
}
// Electrum PROBABLE → BlueWallet POSIBLE es redundante
// (las señales de BlueWallet son un subconjunto de las de Electrum)
const electrumProbable = results.find(r => r.name === "Electrum" && r.confidence === "PROBABLE");
if (electrumProbable) {
const idx = results.findIndex(r => r.name === "BlueWallet/Mobile");
if (idx !== -1) results.splice(idx, 1);
}
return results;
}
// ── BIP32 / bech32 — derivación watch-only ────────────────────────────────
// Implementación mínima usando Web Crypto API (nativa del navegador).
// No hay dependencias externas. Todo ocurre en memoria, nada sale del nodo.
// Utilidades de bytes
const B32 = {
// Decodifica base58check → Uint8Array (sin la checksum de 4 bytes)
decodeBase58: (str) => {
const ALPHA = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
let n = 0n;
for (const c of str) {
const idx = ALPHA.indexOf(c);
if (idx < 0) throw new Error("base58 inválido: " + c);
n = n * 58n + BigInt(idx);
}
// Convertir el entero a bytes
let hex = n.toString(16);
if (hex.length % 2) hex = "0" + hex;
const body = hex.length ? hex.match(/.{2}/g).map(b => parseInt(b, 16)) : [];
// Cada '1' inicial en base58 representa un byte 0x00
let leadingZeros = 0;
for (const c of str) { if (c === "1") leadingZeros++; else break; }
const full = new Uint8Array(leadingZeros + body.length);
full.set(body, leadingZeros);
return full; // completo: payload + 4 bytes de checksum
},
// 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)
sha256d: async (data) => {
const h1 = await crypto.subtle.digest("SHA-256", data);
return new Uint8Array(await crypto.subtle.digest("SHA-256", h1));
},
// HMAC-SHA512
hmac512: async (key, data) => {
const k = await crypto.subtle.importKey("raw", key, {name:"HMAC",hash:"SHA-512"}, false, ["sign"]);
return new Uint8Array(await crypto.subtle.sign("HMAC", k, data));
},
// Serializar entero 32 bits big-endian
u32be: (n) => {
const b = new Uint8Array(4);
b[0]=(n>>>24)&0xff; b[1]=(n>>>16)&0xff; b[2]=(n>>>8)&0xff; b[3]=n&0xff;
return b;
},
// Concatenar Uint8Arrays
concat: (...arrays) => {
const len = arrays.reduce((s,a) => s + a.length, 0);
const out = new Uint8Array(len);
let off = 0;
for (const a of arrays) { out.set(a, off); off += a.length; }
return out;
},
};
// Aritmética de curva secp256k1 (solo lo necesario: punto + escalar)
const SECP = (() => {
const P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2Fn;
const N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141n;
const Gx = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798n;
const Gy = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8n;
const mod = (a, m=P) => { const r = a % m; return r < 0n ? r + m : r; };
// Inverso modular por el pequeño teorema de Fermat (robusto con BigInt)
const modPow = (b, e, m) => {
let r = 1n; b = mod(b, m);
while (e > 0n) { if (e & 1n) r = mod(r * b, m); e >>= 1n; b = mod(b * b, m); }
return r;
};
const inv = (a, m=P) => modPow(mod(a, m), m - 2n, m);
const addPoints = (P1, P2) => {
if (!P1) return P2; if (!P2) return P1;
const [x1,y1] = P1, [x2,y2] = P2;
if (x1 === x2 && mod(y1 + y2) === 0n) return null; // P + (-P) = infinito
let lam;
if (x1 === x2 && y1 === y2) {
lam = mod(3n * x1 * x1 * inv(2n * y1)); // duplicación
} else {
lam = mod((y2 - y1) * inv(mod(x2 - x1))); // suma de distintos
}
const x3 = mod(lam * lam - x1 - x2);
return [x3, mod(lam * (x1 - x3) - y1)];
};
const mulPoint = (k, pt) => {
let result = null, addend = pt;
while (k > 0n) {
if (k & 1n) result = addPoints(result, addend);
addend = addPoints(addend, addend);
k >>= 1n;
}
return result;
};
// Comprimir punto (33 bytes)
const compress = ([x, y]) => {
const xBytes = new Uint8Array(32);
let xn = x;
for (let i = 31; i >= 0; i--) { xBytes[i] = Number(xn & 0xffn); xn >>= 8n; }
const prefix = new Uint8Array([y & 1n ? 3 : 2]);
return B32.concat(prefix, xBytes);
};
// Descomprimir punto desde clave pública comprimida (33 bytes)
const decompress = (bytes) => {
const prefix = bytes[0];
let x = 0n;
for (let i = 1; i < 33; i++) x = (x << 8n) | BigInt(bytes[i]);
const y2 = mod(x * x * x + 7n);
let y = modPow(y2, (P + 1n) / 4n, P);
if ((y & 1n) !== BigInt(prefix & 1)) y = P - y;
return [x, y];
};
return { P, N, G: [Gx, Gy], addPoints, mulPoint, compress, decompress };
})();
// Derivación BIP32 de clave pública hija (solo clave pública, sin hardened)
const deriveChildPubkey = async (parentPub, parentChain, index) => {
// 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 I = await B32.hmac512(parentChain, data);
const IL = I.slice(0, 32);
const IR = I.slice(32);
// childKey = point(IL) + parentPub
let il = 0n;
for (const b of IL) il = (il << 8n) | BigInt(b);
if (il >= SECP.N) throw new Error("derivación inválida");
const ilPoint = SECP.mulPoint(il, SECP.G);
const parentPoint = SECP.decompress(parentPub);
const childPoint = SECP.addPoints(ilPoint, parentPoint);
if (!childPoint) throw new Error("punto infinito");
return { pub: SECP.compress(childPoint), chain: IR };
};
// Hash160 = RIPEMD160(SHA256(data))
// Implementamos RIPEMD160 mínimo ya que WebCrypto no lo incluye
const ripemd160 = (data) => {
// Implementación RIPEMD-160 completa
const KL = [0x00000000,0x5A827999,0x6ED9EBA1,0x8F1BBCDC,0xA953FD4E];
const KR = [0x50A28BE6,0x5C4DD124,0x6D703EF3,0x7A6D76E9,0x00000000];
const RL = [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,7,4,13,1,10,6,15,3,12,0,9,5,2,14,11,8,3,10,14,4,9,15,8,1,2,7,0,6,13,11,5,12,1,9,11,10,0,8,12,4,13,3,7,15,14,5,6,2,4,0,5,9,7,12,2,10,14,1,3,8,11,6,15,13];
const RR = [5,14,7,0,9,2,11,4,13,6,15,8,1,10,3,12,6,11,3,7,0,13,5,10,14,15,8,12,4,9,1,2,15,5,1,3,7,14,6,9,11,8,12,2,10,0,4,13,8,6,4,1,3,11,15,0,5,12,2,13,9,7,10,14,12,15,10,4,1,5,8,7,6,2,13,14,0,3,9,11];
const SL = [11,14,15,12,5,8,7,9,11,13,14,15,6,7,9,8,7,6,8,13,11,9,7,15,7,12,15,9,11,7,13,12,11,13,6,7,14,9,13,15,14,8,13,6,5,12,7,5,11,12,14,15,14,15,9,8,9,14,5,6,8,6,5,12,9,15,5,11,6,8,13,12,5,12,13,14,11,8,5,6];
const SR = [8,9,9,11,13,15,15,5,7,7,8,11,14,14,12,6,9,13,15,7,12,8,9,11,7,7,12,7,6,15,13,11,9,7,15,11,8,6,6,14,12,13,5,14,13,13,7,5,15,5,8,11,14,14,6,14,6,9,12,9,12,5,15,8,8,5,12,9,12,5,14,6,8,13,6,5,15,13,11,11];
const f = (j,x,y,z) => j<16?(x^y^z):j<32?((x&y)|(~x&z)):j<48?((x|~y)^z):j<64?((x&z)|(y&~z)):(x^(y|~z));
const rol = (x,n) => ((x<<n)|(x>>>(32-n)))>>>0;
// Padding
const msgLen = data.length;
const padLen = ((msgLen + 8) % 64 <= 55) ? 55 - (msgLen % 64) : 119 - (msgLen % 64);
const padded = new Uint8Array(msgLen + padLen + 9);
padded.set(data);
padded[msgLen] = 0x80;
const bitLen = msgLen * 8;
padded[padded.length-8] = bitLen & 0xff;
padded[padded.length-7] = (bitLen>>>8) & 0xff;
padded[padded.length-6] = (bitLen>>>16) & 0xff;
padded[padded.length-5] = (bitLen>>>24) & 0xff;
let [h0,h1,h2,h3,h4] = [0x67452301,0xEFCDAB89,0x98BADCFE,0x10325476,0xC3D2E1F0];
for (let i = 0; i < padded.length; i += 64) {
const X = new Int32Array(16);
for (let j = 0; j < 16; j++) {
X[j] = padded[i+j*4] | (padded[i+j*4+1]<<8) | (padded[i+j*4+2]<<16) | (padded[i+j*4+3]<<24);
}
let [al,bl,cl,dl,el] = [h0,h1,h2,h3,h4];
let [ar,br,cr,dr,er] = [h0,h1,h2,h3,h4];
for (let j = 0; j < 80; j++) {
const jj = Math.floor(j/16);
let T = rol((al + f(j,bl,cl,dl) + X[RL[j]] + KL[jj])>>>0, SL[j]);
T = (T + el)>>>0; al=el; el=dl; dl=rol(cl,10); cl=bl; bl=T;
T = rol((ar + f(79-j,br,cr,dr) + X[RR[j]] + KR[jj])>>>0, SR[j]);
T = (T + er)>>>0; ar=er; er=dr; dr=rol(cr,10); cr=br; br=T;
}
const T = (h1 + cl + dr)>>>0;
h1 = (h2 + dl + er)>>>0; h2 = (h3 + el + ar)>>>0;
h3 = (h4 + al + br)>>>0; h4 = (h0 + bl + cr)>>>0; h0 = T;
}
const out = new Uint8Array(20);
const view = new DataView(out.buffer);
[h0,h1,h2,h3,h4].forEach((h,i) => view.setUint32(i*4, h, true));
return out;
};
const hash160 = async (pubkey) => {
const sha = new Uint8Array(await crypto.subtle.digest("SHA-256", pubkey));
return ripemd160(sha);
};
// Bech32 — generar dirección bc1q desde hash160
const toBech32 = (hrp, data) => {
const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
const polymod = (values) => {
const GEN = [0x3b6a57b2,0x26508e6d,0x1ea119fa,0x3d4233dd,0x2a1462b3];
let chk = 1;
for (const v of values) {
const b = chk >> 25;
chk = ((chk & 0x1ffffff) << 5) ^ v;
for (let i = 0; i < 5; i++) if ((b >> i) & 1) chk ^= GEN[i];
}
return chk;
};
const hrpExpand = (hrp) => {
const ret = [];
for (const c of hrp) ret.push(c.charCodeAt(0) >> 5);
ret.push(0);
for (const c of hrp) ret.push(c.charCodeAt(0) & 31);
return ret;
};
const createChecksum = (hrp, data) => {
const values = [...hrpExpand(hrp), ...data, 0, 0, 0, 0, 0, 0];
const mod = polymod(values) ^ 1;
return Array.from({length:6}, (_,i) => (mod >> (5*(5-i))) & 31);
};
const convertbits = (data, frombits, tobits, pad=true) => {
let acc = 0, bits = 0;
const ret = [];
const maxv = (1 << tobits) - 1;
for (const v of data) {
acc = (acc << frombits) | v;
bits += frombits;
while (bits >= tobits) { bits -= tobits; ret.push((acc >> bits) & maxv); }
}
if (pad && bits > 0) ret.push((acc << (tobits - bits)) & maxv);
return ret;
};
const words = [0, ...convertbits(data, 8, 5)]; // version 0
const checksum = createChecksum(hrp, words);
return hrp + "1" + [...words, ...checksum].map(d => CHARSET[d]).join("");
};
// 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) => {
// decodeBase58Check comprueba la suma de verificación: un carácter mal
// 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
// [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 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
const fingerprint = Array.from(raw.slice(5, 9)).map(b => b.toString(16).padStart(2,"0")).join("");
const hrp = info.red;
const result = { receive: [], change: [], fingerprint };
for (const [branch, label] of [[0,"receive"],[1,"change"]]) {
// Derivar la clave de rama (m/branch)
const branchKey = await deriveChildPubkey(pubKey, chainCode, branch);
for (let i = 0; i < count; i++) {
const childKey = await deriveChildPubkey(branchKey.pub, branchKey.chain, i);
const h160 = await hash160(childKey.pub);
result[label].push(toBech32(hrp, Array.from(h160)));
}
}
return result;
};
// ── Informe de wallet completo ────────────────────────────────────────
// Analiza el conjunto de txs del wallet: vinculación (CIOH), reutilización
// y un resumen de salud. Funciona sobre datos ya traídos, en memoria.
// Clusters de vinculación por CIOH (common-input-ownership): si una tx gasta
// varias direcciones semilla como inputs, un observador asume que pertenecen
// al mismo dueño. Union-Find sobre direcciones. Compartida entre el informe
// de wallet (semilla = mis direcciones) y el peritaje forense (semilla =
// direcciones atribuidas al actor rastreado).
function unionFindCluster(txs, seedAddrSet) {
const parent = new Map();
const find = (x) => { while (parent.get(x) !== x) { parent.set(x, parent.get(parent.get(x))); x = parent.get(x); } return x; };
const union = (a, b) => { const ra=find(a), rb=find(b); if (ra!==rb) parent.set(ra, rb); };
for (const a of seedAddrSet) parent.set(a, a);
const linkReasons = [];
for (const tx of txs) {
const ownInputs = (tx.vin||[])
.map(v => v.prevout?.scriptpubkey_address)
.filter(a => a && seedAddrSet.has(a));
const uniq = [...new Set(ownInputs)];
if (uniq.length > 1) {
for (let i = 1; i < uniq.length; i++) union(uniq[0], uniq[i]);
linkReasons.push({ txid: tx.txid, addrs: uniq, reason: "compartieron inputs en una misma transacción (CIOH)" });
}
}
const clusterMap = new Map();
const linked = new Set(linkReasons.flatMap(l => l.addrs));
for (const a of linked) {
const r = find(a);
if (!clusterMap.has(r)) clusterMap.set(r, []);
clusterMap.get(r).push(a);
}
const clusters = [...clusterMap.values()].filter(c => c.length > 1)
.sort((a,b) => b.length - a.length);
return { clusters, linkReasons };
}
function buildWalletReport(activeAddrs, txs, myAddrSet) {
// 1) Reutilización: direcciones propias que aparecen en más de una tx
const addrUseCount = new Map();
for (const tx of txs) {
const touched = new Set();
for (const vin of (tx.vin||[])) {
const a = vin.prevout?.scriptpubkey_address;
if (a && myAddrSet.has(a)) touched.add(a);
}
for (const vout of (tx.vout||[])) {
const a = vout.scriptpubkey_address;
if (a && myAddrSet.has(a)) touched.add(a);
}
for (const a of touched) addrUseCount.set(a, (addrUseCount.get(a)||0) + 1);
}
const reusedAddrs = [...addrUseCount.entries()].filter(([,c]) => c > 2);
// Nota: umbral 2 porque una dirección normal aparece en 2 txs como mínimo
// (la que recibe y la que gasta). Más de 2 = reutilización real.
// 2) Clusters de vinculación por CIOH — ver unionFindCluster
const { clusters, linkReasons } = unionFindCluster(txs, myAddrSet);
// 3) Historial: cada tx con su banda
const history = txs.map(tx => {
let band = "—", score = null;
try { const a = analyzeTx(tx); band = a.band; score = a.score; } catch {}
return { txid: tx.txid, band, score, time: tx.status?.block_time || 0, confirmed: !!tx.status?.confirmed };
}).sort((a,b) => b.time - a.time);
// 4) Salud general
const totalTxs = txs.length;
const reuseRatio = activeAddrs.length ? reusedAddrs.length / activeAddrs.length : 0;
const biggestCluster = clusters.length ? clusters[0].length : 0;
let healthBand, healthColor, healthMsg;
if (reusedAddrs.length === 0 && clusters.length === 0) {
healthBand = "ALTA"; healthColor = C.green;
healthMsg = "No se detecta reutilización de direcciones ni vinculación evidente entre tus monedas.";
} else if (reuseRatio < 0.25 && biggestCluster <= 3) {
healthBand = "MEDIA"; healthColor = C.amber;
healthMsg = "Hay algo de reutilización o vinculación, pero limitada. Revisa los clusters para entender qué monedas están conectadas.";
} else {
healthBand = "BAJA"; healthColor = C.red;
healthMsg = "Tus monedas presentan vinculación significativa o reutilización frecuente. Un observador puede agrupar buena parte de tu actividad.";
}
return { totalTxs, activeCount: activeAddrs.length, reusedAddrs, clusters, linkReasons, history,
health: { band: healthBand, color: healthColor, msg: healthMsg } };
}
// Estima qué output es el cambio combinando tipo de script, valores
// relativos, posición y reutilización de direcciones (señales A-F). Solo
// se afirma nada en transacciones de 2 outputs sin CoinJoin — con más
// salidas la herramienta no adivina para no dar una certeza que los datos
// no sostienen. Autocontenida (recalcula sus propias señales desde tx) para
// poder llamarse tanto desde analyzeTx como, salto a salto, desde el motor
// de rastreo forense, que necesita saber POR CUÁL output concreto seguir.
function guessChangeOutput(tx, likelyCJ) {
if (tx.vout.length !== 2 || likelyCJ) {
return { identifiable: false, index: null, signals: 0, details: [], bonus: 0, certainty: null };
}
const inTypes = tx.vin.map(v=>v.prevout?.scriptpubkey_type).filter(Boolean);
const dominantInType = inTypes[0];
const isBip69Inputs = tx.vin.length < 2 || tx.vin.every((v,i,a) => {
if (i===0) return true;
const pidA = a[i-1].txid||"", pidB = v.txid||"";
return pidA !== pidB ? pidA < pidB : (a[i-1].vout||0) <= (v.vout||0);
});
const isBip69Outputs = tx.vout.length < 2 || tx.vout.every((v,i,a) => {
if (i===0) return true;
if (a[i-1].value !== v.value) return a[i-1].value < v.value;
return (a[i-1].scriptpubkey||"") <= (v.scriptpubkey||"");
});
const isBip69 = isBip69Inputs && isBip69Outputs;
const roundOutputs = tx.vout.filter(v =>
v.value % 1000000 === 0 || v.value % 100000 === 0 || v.value % 10000000 === 0
);
const inputAddrs = tx.vin.map(v=>v.prevout?.scriptpubkey_address).filter(Boolean);
const inputAddrSet = new Set(inputAddrs);
let hasOutputMismatch = false;
if (dominantInType) {
hasOutputMismatch = tx.vout.filter(v=>v.scriptpubkey_type===dominantInType).length === 1;
}
const totalIn = tx.vin.reduce((s,v)=>s+(v.prevout?v.prevout.value:0),0);
let signals = 0, details = [], bonus = 0;
const larger = tx.vout.reduce((a,b)=>a.value>b.value?a:b);
const smaller = tx.vout.reduce((a,b)=>a.value<b.value?a:b);
// Señal A: tipo de script coincide con inputs
let signalA = false;
if (dominantInType) {
const matchCount = tx.vout.filter(v=>v.scriptpubkey_type===dominantInType).length;
if (matchCount === 1) { signals++; signalA = true; details.push("tipo de script idéntico a inputs"); }
}
// Señal B + C: "pago redondo" y "cambio pequeño" solo cuentan por separado
// si apuntan a outputs distintos
const roundIsLarger = roundOutputs.length === 1 && roundOutputs[0].value === larger.value;
const smallerIsSmall = totalIn > 0 && smaller.value < totalIn * 0.15;
if (roundOutputs.length === 1 && smallerIsSmall && !roundIsLarger) {
signals += 2; details.push("el output redondo es el pago"); details.push("output menor < 15% del total");
} else if (roundOutputs.length === 1) {
signals++; details.push("pago redondo y cambio pequeño (misma señal)");
} else if (smallerIsSmall) {
signals++; details.push("output menor < 15% del total");
}
// Señal D: output reutiliza dirección de input
const outputToKnownAddr = tx.vout.some(v=>v.scriptpubkey_address && inputAddrSet.has(v.scriptpubkey_address));
if (outputToKnownAddr) { signals+=2; details.push("output reutiliza dirección de input — cambio casi seguro"); bonus += 10; }
// Señal E: mismatch tipo input/output
if (hasOutputMismatch) { signals++; details.push("tipo de output distinto al de inputs"); }
// Señal F: posición del cambio — solo si la señal A no se disparó ya
if (!isBip69 && !signalA) {
const lastOut = tx.vout[tx.vout.length - 1];
if (dominantInType && lastOut.scriptpubkey_type === dominantInType) {
signals++; details.push("posición fija del cambio (índice 1, sin BIP69)");
}
}
// Bonus correlación: ≥3 señales independientes
if (signals >= 3) bonus += 5;
const identifiable = signals >= 2;
const certainty = bonus >= 10 ? "PROBABLE" : signals >= 3 ? "PROBABLE" : signals >= 2 ? "PROBABLE" : "POSIBLE";
// Índice del output de cambio, por orden de fuerza de señal: reutilización
// de dirección de input > tipo idéntico a inputs > el output menor.
let index = null;
if (identifiable) {
if (outputToKnownAddr) {
index = tx.vout.findIndex(v=>v.scriptpubkey_address && inputAddrSet.has(v.scriptpubkey_address));
} else if (signalA) {
index = tx.vout.findIndex(v=>v.scriptpubkey_type===dominantInType);
} else {
index = tx.vout.indexOf(smaller);
}
}
return { identifiable, index, signals, details, bonus, certainty };
}
function analyzeTx(tx) {
const checks = [];
const weights = {
input_reuse: 25, input_type_mixing: 14, output_type_mismatch: 8,
round_numbers: 10, rbf: 5, peeling: 8,
unnecessary_input: 12, dust: 8, change_detection: 10,
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;
const totalIn = tx.vin.reduce((s,v)=>s+(v.prevout?v.prevout.value:0),0);
const totalOut = tx.vout.reduce((s,v)=>s+v.value,0);
const fee = tx.fee || (totalIn > totalOut ? totalIn - totalOut : 0);
const outputValues = tx.vout.map(v=>v.value);
// Outputs gastables: excluimos OP_RETURN (no son monedas, son datos).
// Whirlpool puede llevar un OP_RETURN además de las salidas mezcladas,
// así que la detección de CoinJoin se hace sobre los gastables.
const isOpReturn = (v) => v.scriptpubkey_type === "op_return" || v.scriptpubkey?.startsWith("6a");
const spendableOuts = tx.vout.filter(v => !isOpReturn(v));
const spendableValues = spendableOuts.map(v => v.value);
// ── BIP69 calculado una vez — compartido con detectWallets ────────
const isBip69Inputs = tx.vin.length < 2 || tx.vin.every((v,i,a) => {
if (i===0) return true;
const pidA = a[i-1].txid||"", pidB = v.txid||"";
return pidA !== pidB ? pidA < pidB : (a[i-1].vout||0) <= (v.vout||0);
});
const isBip69Outputs = tx.vout.length < 2 || tx.vout.every((v,i,a) => {
if (i===0) return true;
if (a[i-1].value !== v.value) return a[i-1].value < v.value;
return (a[i-1].scriptpubkey||"") <= (v.scriptpubkey||"");
});
const isBip69 = isBip69Inputs && isBip69Outputs;
// ── Fee estimador dinámico por tipo de input ──────────────────────
const estimateFeeForInput = (type) => {
if (!type) return 300;
if (type.includes("p2tr") || type.includes("v1")) return 58; // Taproot key-path
if (type.includes("p2wpkh") || type.includes("v0_p2wpkh")) return 68; // SegWit native
if (type.includes("p2wsh") || type.includes("v0_p2wsh")) return 120; // SegWit script
if (type.includes("p2sh")) return 180; // P2SH wrapped
return 300; // Legacy P2PKH / desconocido
};
// ── CoinJoin — detección multi-variante ───────────────────────────
// Trabajamos sobre outputs gastables (sin OP_RETURN).
const valueCounts = spendableValues.reduce((m,v)=>{m.set(v,(m.get(v)||0)+1);return m;}, new Map());
const maxEqual = valueCounts.size > 0 ? Math.max(...valueCounts.values()) : 0;
const whirlpoolDenoms = [100000, 1000000, 5000000, 50000000];
// Whirlpool: las salidas mezcladas son todas de la misma denominación fija.
// Whirlpool descuenta una comisión, así que se tolera un margen estrecho (~2%)
// HACIA la denominación — no un cajón de salidas pequeñas arbitrarias.
const WP_TOL = 0.02;
const whirlpoolDenom = whirlpoolDenoms.find(d => {
const atDenom = spendableValues.filter(v => Math.abs(v - d) <= d * WP_TOL).length;
// Al menos 5 salidas en la denominación Y mayoría clara de los gastables
return atDenom >= 5 && atDenom >= Math.ceil(spendableOuts.length * 0.6);
});
const isWhirlpool = tx.vin.length >= 5 && spendableOuts.length >= 5 && !!whirlpoolDenom;
// CoinJoin genérico: ≥50% outputs gastables de igual valor (sin denominación Whirlpool reconocida).
// Filtro de denominación mínima: el valor repetido debe ser ≥10.000 sats.
// Mezclar polvo (p.ej. 500 sats) no tiene sentido económico — no es un CoinJoin real.
const CJ_MIN_DENOM = 10000;
const equalValue = [...valueCounts.entries()].find(([v,c]) => c === maxEqual)?.[0] ?? 0;
const isGenericCJ = tx.vin.length >= 5 && spendableOuts.length >= 5
&& maxEqual >= Math.floor(spendableOuts.length * 0.5)
&& equalValue >= CJ_MIN_DENOM;
// CoinJoin por estructura de mezcla (WabiSabi / JoinMarket outputs variables).
// WabiSabi no usa denominaciones fijas — sus outputs son de valor variable,
// así que isGenericCJ no lo captura. Pero sí tiene firma estructural clara:
// muchos inputs Y muchos outputs, ratio cercano a 1 (no es batch ni consolidación),
// y sin un output dominante que concentre el valor (no hay "cambio" obvio).
const CJ_STRUCT_MIN = 10;
const inputOutputRatio = spendableOuts.length > 0 ? tx.vin.length / spendableOuts.length : 999;
const maxOutValue = spendableOuts.length > 0 ? Math.max(...spendableValues) : 0;
const totalOutValue = spendableValues.reduce((s,v) => s+v, 0);
const dominantOutputShare = totalOutValue > 0 ? maxOutValue / totalOutValue : 1;
const isStructuralCJ = !isWhirlpool && !isGenericCJ
&& tx.vin.length >= CJ_STRUCT_MIN && spendableOuts.length >= CJ_STRUCT_MIN
&& inputOutputRatio >= 0.5 && inputOutputRatio <= 2.0
&& dominantOutputShare < 0.3;
const likelyCJ = isWhirlpool || isGenericCJ || isStructuralCJ;
const cjVariant = isWhirlpool ? "Whirlpool" : isStructuralCJ ? "CoinJoin (mezcla estructural)" : "CoinJoin genérico";
// ── PayJoin — señal débil informativa ────────────────────────────
// Si hay exactamente 1 input "extra" que no era necesario y no es CoinJoin
// y el número de outputs es menor que inputs, puede ser PayJoin
const likelyPayJoin = !likelyCJ && tx.vin.length >= 2 && tx.vout.length < tx.vin.length
&& tx.vout.length >= 1;
// ── 1. Reutilización de direcciones en inputs ─────────────────────
const inputAddrs = tx.vin.map(v=>v.prevout?.scriptpubkey_address).filter(Boolean);
const uniqueInputAddrs = new Set(inputAddrs);
const hasInputReuse = uniqueInputAddrs.size < inputAddrs.length;
checks.push({
id:"input_reuse", label:"Reutilización de direcciones en inputs", certainty:"CERTEZA", pass:!hasInputReuse,
actionability: "evitable",
detail: hasInputReuse
? `${inputAddrs.length - uniqueInputAddrs.size} dirección(es) repetida(s) en inputs. Revela que esos UTXOs pertenecen al mismo propietario con certeza.`
: "Ningún input repite dirección dentro de esta transacción. (No se ha comprobado si estas direcciones se reutilizan en otras transacciones de la cadena.)",
didactic: "Reutilizar una dirección Bitcoin es el error de privacidad más grave. Cualquiera puede ver que todos esos UTXOs pertenecen al mismo propietario y construir un historial completo de sus transacciones.",
penalty: weights.input_reuse,
});
if (hasInputReuse) deductions += weights.input_reuse;
// ── 2. Mezcla de tipos en inputs ──────────────────────────────────
const inTypes = [...new Set(tx.vin.map(v=>v.prevout?.scriptpubkey_type).filter(Boolean))];
const outTypes = [...new Set(tx.vout.map(v=>v.scriptpubkey_type).filter(Boolean))];
const hasInputTypeMixing = inTypes.length > 1;
checks.push({
id:"input_type_mixing", label:"Mezcla de tipos de script en inputs", certainty:"CERTEZA",
pass: likelyCJ ? true : !hasInputTypeMixing,
informational: likelyCJ && hasInputTypeMixing,
actionability: likelyCJ ? null : "evitable",
detail: hasInputTypeMixing
? (likelyCJ
? `Los inputs mezclan tipos (${inTypes.join(", ")}). En un CoinJoin es normal: cada participante aporta sus propios UTXOs, que pueden ser de tipos distintos.`
: `Hecho (certeza): los inputs son de tipos distintos (${inTypes.join(", ")}). Interpretación (probable): suele indicar consolidación de UTXOs de fuentes o épocas distintas — aunque un mismo wallet moderno también puede tener UTXOs de varios tipos.`)
: `Inputs uniformes: ${inTypes[0] || "—"}.`,
didactic: "Que los inputs mezclen tipos de script es un hecho observable. Lo que se infiere de ello —que el emisor consolidaba UTXOs de wallets o épocas distintas— es probable pero no seguro: un wallet moderno puede acumular UTXOs de varios tipos con el tiempo. La señal es reveladora para el análisis, pero conviene leerla como indicio, no como prueba.",
penalty: likelyCJ ? 0 : weights.input_type_mixing,
});
// En CoinJoin la mezcla de tipos de script es esperada (participantes distintos) — no penaliza.
if (hasInputTypeMixing && !likelyCJ) deductions += weights.input_type_mixing;
// ── 3. Mismatch tipo input/output ────────────────────────────────
const dominantInType = inTypes[0];
let hasOutputMismatch = false;
let mismatchDetail = "";
if (dominantInType && tx.vout.length === 2) {
const matchCount = tx.vout.filter(v=>v.scriptpubkey_type===dominantInType).length;
if (matchCount === 1) {
hasOutputMismatch = true;
const changeOut = tx.vout.find(v=>v.scriptpubkey_type===dominantInType);
const paymentOut = tx.vout.find(v=>v.scriptpubkey_type!==dominantInType);
mismatchDetail = `Output de tipo ${paymentOut?.scriptpubkey_type} es probablemente el pago externo; el de tipo ${changeOut?.scriptpubkey_type} es el cambio del emisor.`;
}
}
checks.push({
id:"output_type_mismatch", label:"Tipo de output revela el cambio", certainty:"PROBABLE", pass:!hasOutputMismatch,
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.",
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,
});
if (hasOutputMismatch) deductions += weights.output_type_mismatch;
// ── 4. CoinJoin — informativo, sin penalización ──────────────────
checks.push({
id:"coinjoin", label:"Estructura CoinJoin / mezcla", certainty:"PROBABLE", pass:likelyCJ,
actionability: "evitable",
detail: likelyCJ
? `Detectado: ${cjVariant}. ${tx.vin.length} inputs, ${tx.vout.length} outputs, ${maxEqual} outputs de igual valor. Mejora significativa de privacidad.`
: "No se detecta estructura CoinJoin. La transacción es trazable directamente.",
didactic: "CoinJoin combina inputs de múltiples usuarios en una sola transacción con outputs de igual valor, rompiendo el enlace entre emisor y receptor. Whirlpool usa denominaciones fijas (100k, 1M, 5M, 50M sats). JoinMarket y Wasabi tienen estructuras similares.",
penalty: 0, positive: likelyCJ, informational: !likelyCJ,
});
// Sin penalización por no usar CoinJoin — es una técnica avanzada, no un error
// ── 5. Output de valor redondo ────────────────────────────────────
const roundOutputs = tx.vout.filter(v =>
v.value % 1000000 === 0 || v.value % 100000 === 0 || v.value % 10000000 === 0
);
const hasRound = roundOutputs.length > 0 && tx.vout.length === 2 && !likelyCJ;
checks.push({
id:"round_numbers", label:"Output de valor redondo", certainty:"PROBABLE", pass:!hasRound,
actionability: "evitable",
detail: hasRound
? `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.",
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,
});
if (hasRound) deductions += weights.round_numbers;
// ── 6. RBF ────────────────────────────────────────────────────────
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({
id:"rbf", label:"RBF activado (Replace-by-Fee)", certainty:"CERTEZA", pass:true,
informational: true,
actionability: "wallet",
detail: rbfEnabled
? "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."
: "Hecho (certeza): RBF no activado — la transacción no era reemplazable.",
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.",
});
// ── 7. Unnecessary input ─────────────────────────────────────────
let hasUnnecessaryInput = false;
let unnecessaryDetail = "";
if (tx.vin.length >= 2 && totalIn > 0 && tx.vout.length >= 1 && !likelyCJ) {
const maxOut = Math.max(...outputValues);
// Tarifa real de la tx en sat/vB (misma fuente que se muestra en pantalla):
// fee/vsize donde vsize ≈ weight/4. Fallbacks: feerate declarado, o 10 sat/vB.
const vsize = tx.weight ? Math.ceil(tx.weight / 4) : null;
const feeRate = (tx.fee && vsize) ? (tx.fee / vsize)
: (tx.feerate ? Number(tx.feerate) : 10);
const singleInputCovers = tx.vin.some(v => {
const val = v.prevout?.value || 0;
const type = v.prevout?.scriptpubkey_type || "";
// vbytes del input × tarifa = coste real en satoshis de incluir todos los inputs
const estFee = estimateFeeForInput(type) * feeRate * tx.vin.length;
return val >= maxOut + estFee && val < totalIn * 0.99;
});
if (singleInputCovers) {
hasUnnecessaryInput = true;
unnecessaryDetail = `Uno de los ${tx.vin.length} inputs habría sido suficiente para cubrir el pago. Los inputs adicionales revelan consolidación de UTXOs y ligan esos fondos al mismo propietario.`;
}
}
checks.push({
id:"unnecessary_input", label:"Inputs innecesarios (consolidación revelada)", certainty:"PROBABLE",
pass: likelyCJ ? true : !hasUnnecessaryInput,
informational: likelyCJ,
actionability: likelyCJ ? null : "evitable",
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.`
: (hasUnnecessaryInput ? unnecessaryDetail : "No se detectan inputs claramente innecesarios para cubrir el pago."),
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,
});
// En CoinJoin múltiples inputs son la estructura del protocolo — no penaliza.
if (hasUnnecessaryInput && !likelyCJ) deductions += weights.unnecessary_input;
// ── 8. Patrón de pago simple (posible eslabón de peeling chain) ────
// OJO: una sola tx 1-in/2-out NO es una peeling chain — es el pago más
// común de Bitcoin. Una peeling chain real es una CADENA de estas txs
// enlazadas (el cambio de una es el input de la siguiente), y detectarla
// requiere mirar las txs anterior/siguiente (futuro: vía rastro de procedencia).
// Por ahora: informativo, sin penalización, para no dar un falso positivo.
const isPeeling = tx.vin.length === 1 && tx.vout.length === 2 && !likelyCJ;
checks.push({
id:"peeling", label:"Patrón de pago simple (peeling)", certainty:"informativo", pass:!isPeeling,
actionability: "evitable",
detail: isPeeling
? "1 input y 2 outputs: el patrón de pago más común (un pago + un cambio). Por sí solo no es un problema. Solo se convierte en peeling chain si se encadena con otras transacciones iguales — eso no puede confirmarse mirando una transacción aislada."
: "La estructura no sigue el patrón de pago simple 1→2.",
didactic: "Una peeling chain es una serie de transacciones 1-input/2-outputs donde el cambio de una se convierte en el input de la siguiente. Cada eslabón es fácil de trazar. Pero una sola transacción 1→2 es simplemente un pago normal — no una cadena. Confirmar una cadena requiere seguir el rastro hacia adelante y atrás.",
penalty: 0, informational: true,
});
// Sin penalización: una tx aislada 1→2 no es trazabilidad encadenada.
// ── 9. Dust outputs ───────────────────────────────────────────────
// El umbral de dust no es fijo: depende del tipo de salida porque cuesta
// más o menos gastarla. Valores de referencia de Bitcoin Core.
const dustThreshold = (type) => {
if (!type) return 546;
if (type.includes("p2tr") || type.includes("v1")) return 330; // Taproot
if (type.includes("p2wpkh") || type.includes("v0_p2wpkh")) return 294; // SegWit nativo
if (type.includes("p2wsh") || type.includes("v0_p2wsh")) return 330; // SegWit script
return 546; // Legacy P2PKH / P2SH / desconocido
};
const dustOutputs = tx.vout.filter(v => v.value > 0 && v.value < dustThreshold(v.scriptpubkey_type));
// Dusting de privacidad: salidas pequeñas pero por ENCIMA del umbral técnico
// (un atacante manda 555, 888... gastables a propósito para que no sean
// "dust técnico" y el receptor las tenga en el wallet). No es un hecho como
// el dust técnico, sino una sospecha -> certeza POSIBLE.
const DUSTING_CEIL = 1000;
const dustingOutputs = tx.vout.filter(v => {
const th = dustThreshold(v.scriptpubkey_type);
return v.value >= th && v.value < DUSTING_CEIL;
});
const hasDust = dustOutputs.length > 0;
const hasDusting = !hasDust && dustingOutputs.length > 0;
if (hasDust) {
checks.push({
id:"dust", label:"Outputs de dust detectados", certainty:"CERTEZA", pass:false,
actionability: "no_corregible",
detail: `${dustOutputs.length} output(s) por debajo del umbral de dust (${dustOutputs.map(d=>d.value+"sat").join(", ")}). Hecho: hay salidas por debajo del mínimo económico de la red. Interpretación: podría ser un ataque de dusting, pero también un cambio diminuto de tu propio wallet o una salida de protocolo (Lightning, inscripciones). No se puede distinguir solo con esta transacción. Consecuencia: si esa salida se gasta junto a otros UTXOs, queda vinculada con ellos — que es justo lo que persigue un ataque de dusting.`,
didactic: "Un ataque de dust envía cantidades mínimas a múltiples direcciones. Cuando el receptor gasta ese dust combinándolo con otros UTXOs, revela qué UTXOs pertenecen al mismo wallet. Es una técnica de surveillance — pero no toda salida diminuta es un ataque: también las hay legítimas (cambios pequeños, salidas de protocolo). La marca se materializa solo si el dust se gasta junto a otras monedas.",
penalty: weights.dust,
});
deductions += weights.dust;
} else if (hasDusting) {
checks.push({
id:"dust", label:"Salida muy pequeña (posible dusting)", certainty:"POSIBLE", pass:false,
actionability: "no_corregible",
detail: `${dustingOutputs.length} output(s) de cantidad muy pequeña (${dustingOutputs.map(d=>d.value+"sat").join(", ")}), por encima del mínimo técnico pero inusualmente bajos. Hecho: son salidas diminutas pero gastables. Interpretación: compatible con un dusting de privacidad (cantidades pequeñas enviadas a propósito por encima del umbral de dust para que el receptor las conserve), aunque también con un pago o cambio pequeño legítimo. No se puede confirmar solo con esta transacción. Consecuencia: si esa salida se gasta junto a otros UTXOs, queda vinculada con ellos.`,
didactic: "El umbral de dust técnico (≈546 sats) marca lo que la red considera antieconómico de gastar. Pero un dusting de privacidad suele usar cantidades algo mayores —gastables a propósito— para que el receptor las mantenga en su wallet y, al gastarlas, revele qué UTXOs son suyos. Por eso una salida pequeña por encima del umbral técnico merece atención, aunque no sea concluyente.",
penalty: weights.dust,
});
deductions += weights.dust;
} else {
checks.push({
id:"dust", label:"Outputs de dust detectados", certainty:"CERTEZA", pass:true,
actionability: "no_corregible",
detail: "Sin outputs de dust ni salidas inusualmente pequeñas (según el umbral de cada tipo de salida).",
didactic: "Un ataque de dust envía cantidades mínimas a múltiples direcciones. Cuando el receptor gasta ese dust combinándolo con otros UTXOs, revela qué UTXOs pertenecen al mismo wallet. Es una técnica de surveillance.",
penalty: weights.dust, informational: true,
});
}
// ── 10. Change detection — señales combinadas (ver guessChangeOutput) ──
const changeGuess = guessChangeOutput(tx, likelyCJ);
const changeSignals = changeGuess.signals;
const changeDetails = changeGuess.details;
const changeBonus = changeGuess.bonus;
const changeIdentifiable = changeGuess.identifiable;
const changeCertainty = changeGuess.certainty || "POSIBLE";
// Scoring correlacionado: si input_reuse + unnecessary_input + change_detection
// se disparan juntos, limitamos la penalización acumulada (describen el mismo problema)
const correlatedProblem = hasInputReuse && hasUnnecessaryInput && changeIdentifiable;
const changeEffectivePenalty = correlatedProblem
? Math.round((weights.change_detection + changeBonus) * 0.5)
: weights.change_detection + changeBonus;
checks.push({
id:"change_detection", label:"Output de cambio identificable", certainty: changeCertainty, pass:!changeIdentifiable,
actionability: "evitable",
detail: changeIdentifiable
? `${changeSignals} señal(es) identifican el cambio: ${changeDetails.join(" · ")}.${correlatedProblem?" (penalización reducida por correlación con otros checks)":""}`
: tx.vout.length > 2
? `Estructura de ${tx.vout.length} outputs — la identificación del cambio es menos fiable y no se afirma aquí. La herramienta no adivina el cambio en transacciones con más de 2 salidas para no dar una certeza que los datos no sostienen.`
: "No hay suficientes señales para identificar el output de cambio.",
didactic: "Identificar el output de cambio es el objetivo central del chain analysis: quien conoce tu dirección de cambio puede seguir rastreando tus fondos en transacciones futuras. Se detecta combinando tipo de script, valores relativos, posición y reutilización de direcciones.",
penalty: changeEffectivePenalty,
});
if (changeIdentifiable) deductions += changeEffectivePenalty;
// ── 11. BIP69 ordering — informativo ─────────────────────────────
checks.push({
id:"bip69", label:"Ordenación BIP69 de inputs/outputs", certainty:"POSIBLE", pass:true,
actionability: "wallet",
detail: isBip69
? "Inputs y outputs siguen el orden canónico BIP69. Reduce la información que el orden revela, aunque identifica wallets que implementan BIP69."
: "Inputs u outputs NO siguen BIP69. El orden puede revelar cuál output es el cambio — muchos wallets lo colocan siempre en posición fija.",
didactic: "BIP69 define un orden lexicográfico para inputs y outputs. Sin él, el orden revela qué output es el cambio. Con BIP69, el orden no revela información — aunque el hecho de usarlo en sí mismo identifica el software.",
penalty: 0, informational: true,
extra: isBip69 ? null : "⚠ El orden de outputs puede revelar cuál es el cambio",
});
// ── 12. Wallet fingerprinting ─────────────────────────────────────
const detectedWallets = detectWallets(tx, isBip69);
// Si la tx es un CoinJoin, el tipo de mezcla revela el wallet con más
// fiabilidad que el fingerprinting estructural. Lo anteponemos.
if (isWhirlpool) {
detectedWallets.unshift({
name: "Samourai / Sparrow (Whirlpool)", confidence: "PROBABLE",
signals: ["estructura Whirlpool — denominación fija de pool"]
});
} else if (isGenericCJ) {
detectedWallets.unshift({
name: "Wasabi o JoinMarket (CoinJoin)", confidence: "POSIBLE",
signals: ["estructura CoinJoin genérica — outputs de igual valor"]
});
} else if (isStructuralCJ) {
detectedWallets.unshift({
name: "Wasabi o JoinMarket (CoinJoin)", confidence: "POSIBLE",
signals: ["estructura de mezcla — muchos inputs/outputs, sin output dominante"]
});
}
checks.push({
id:"wallet_fingerprint", label:"Fingerprinting de wallet",
certainty: detectedWallets[0]?.confidence||"POSIBLE",
pass: likelyCJ ? true : detectedWallets.length===0,
informational: likelyCJ && detectedWallets.length > 0,
actionability: likelyCJ ? null : "wallet",
detail: detectedWallets.length > 0
? (likelyCJ
? `En un CoinJoin identificar el software es informativo, no un problema. ${detectedWallets.map(w=>`${w.name} (${w.confidence}): ${w.signals.join(", ")}`).join(" · ")}`
: detectedWallets.map(w=>`${w.name} (${w.confidence}): ${w.signals.join(", ")}`).join(" · "))
: "No se detecta un patrón de wallet específico con suficiente certeza.",
didactic: "El objetivo no es ser invisible sino ser indistinguible de millones de usuarios del mismo wallet. Un fingerprint de Bitcoin Core lo comparten millones de transacciones — no revela nada útil. Un fingerprint de Exodus o un wallet minoritario pertenece a un conjunto mucho menor y es más revelador. En un CoinJoin, el tipo de mezcla ya sugiere el software usado.",
penalty: likelyCJ ? 0 : weights.wallet_fingerprint,
});
// En CoinJoin no penaliza (identificar el wallet es esperado, no un fallo).
// Fuera de CoinJoin, solo penaliza si se detectó un wallet estructural real.
if (!likelyCJ && detectedWallets.length > 0) deductions += weights.wallet_fingerprint;
// ── 13. PayJoin — informativo ─────────────────────────────────────
checks.push({
id:"payjoin", label:"Posible PayJoin", certainty:"POSIBLE", pass:true,
actionability: "evitable",
detail: likelyPayJoin
? `Estructura compatible con PayJoin: ${tx.vin.length} inputs, ${tx.vout.length} outputs. Si es un PayJoin, el CIOH (Common Input Ownership Heuristic) no aplica y los inputs pueden pertenecer a distintos propietarios.`
: "No se detecta estructura compatible con PayJoin.",
didactic: "PayJoin (P2EP) es una técnica donde el receptor añade un input a la transacción del pagador. Rompe la heurística CIOH porque los inputs ya no pertenecen todos al mismo propietario. Es difícil de detectar precisamente porque parece una transacción normal.",
penalty: 0, informational: true,
});
// ── 14. Batch payment ─────────────────────────────────────────────
// Un batch real (exchange/servicio pagando a muchos) tiene muchos outputs
// casi todos con valores distintos. No exigimos que TODOS sean distintos
// (dos destinatarios pueden recibir la misma cantidad), sino la mayoría.
const uniqueOutValues = new Set(outputValues);
const distinctRatio = tx.vout.length > 0 ? uniqueOutValues.size / tx.vout.length : 0;
const isBatch = tx.vout.length >= 5 && distinctRatio >= 0.8 && uniqueOutValues.size >= 5 && !likelyCJ;
checks.push({
id:"batch_payment", label:"Pago por lotes (batch payment)", certainty:"PROBABLE", pass:!isBatch,
actionability: "no_corregible",
detail: isBatch
? `${tx.vout.length} outputs, casi todos con valores distintos: compatible con un exchange o servicio que paga a muchos destinatarios en una sola transacción. Compartes los mismos inputs —y por tanto el mismo origen— con los otros destinatarios del lote, lo que permite a un observador agruparos.`
: "No se detecta patrón de batch payment.",
didactic: "Exchanges y custodios agrupan múltiples pagos en una transacción para ahorrar comisiones. Recibir de un batch reduce tu privacidad: los demás destinatarios comparten origen contigo, así que un observador puede correlacionar todos los pagos del lote. Y si el origen es un exchange con KYC, ese servicio conoce la identidad asociada. Por eso baja la valoración aunque tú no controles cómo te pagaron.",
penalty: isBatch ? weights.batch_payment : 0,
});
if (isBatch) deductions += weights.batch_payment;
// ── 15. OP_RETURN ─────────────────────────────────────────────────
// Detecta outputs OP_RETURN (nulldata): presencia y tamaño total en bytes.
// No muestra el contenido — lo relevante para privacidad es que hay datos
// arbitrarios, no qué dicen. Ver v1.2.1 del changelog.
const opReturnOuts = tx.vout.filter(v => isOpReturn(v));
const hasOpReturn = opReturnOuts.length > 0;
const opReturnBytes = opReturnOuts.reduce((s, v) => {
// scriptpubkey: "6a" + longitud (1 byte) + datos. Datos = (hex.length/2) - 2
const hex = v.scriptpubkey || "";
return s + Math.max(0, hex.length / 2 - 2);
}, 0);
checks.push({
id:"op_return", label:"Datos OP_RETURN", certainty:"CERTEZA", pass:!hasOpReturn,
actionability: "no_corregible",
detail: hasOpReturn
? `${opReturnOuts.length} output${opReturnOuts.length > 1 ? "s" : ""} OP_RETURN con ${opReturnBytes} bytes de datos arbitrarios. Revela que esta transacción usa un protocolo o servicio que escribe datos en la cadena (Stamps, Ordinals, OpenTimestamps, Omni, etc.). Se informa la presencia y el tamaño, no el contenido.`
: "No se detectan outputs OP_RETURN.",
didactic: "Un output OP_RETURN contiene datos arbitrarios que cualquiera puede leer. Su presencia revela el uso de un protocolo concreto y puede vincular esta transacción con actividad on-chain identificable. El contenido no se muestra — para la privacidad lo relevante es que existe, no qué dice.",
penalty: hasOpReturn ? weights.op_return : 0,
});
if (hasOpReturn) deductions += weights.op_return;
// ── 16. Timing analysis — informativo ────────────────────────────
let timingDetail = "No hay timestamp disponible (transacción pendiente o sin confirmar).";
if (tx.status?.block_time) {
const date = new Date(tx.status.block_time * 1000);
const hour = date.getUTCHours();
const day = date.getUTCDay();
const dayNames = ["domingo","lunes","martes","miércoles","jueves","viernes","sábado"];
const zone = hour >= 6 && hour <= 10 ? "mañana temprana (Europa/África)" :
hour >= 14 && hour <= 18 ? "tarde europea / mañana americana" :
hour >= 22 || hour <= 2 ? "madrugada Europa / tarde América" : "horario sin patrón claro";
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({
id:"timing", label:"Análisis temporal", certainty:"POSIBLE", pass:true,
actionability: "evitable",
detail: timingDetail,
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,
});
// ── 16. Tipo legacy (P2PKH / P2SH) ───────────────────────────────
// Inputs en formato legacy (P2PKH = 1..., P2SH = 3...) revelan UTXOs antiguos
// o wallets que no han migrado a SegWit/Taproot. Mayor huella on-chain y
// menor anonimato en el conjunto de usuarios modernos.
const legacyTypes = ["p2pkh", "p2sh"];
const allInputScriptTypes = [...new Set(tx.vin.map(v=>v.prevout?.scriptpubkey_type).filter(Boolean))];
const hasLegacyInputs = allInputScriptTypes.some(t => legacyTypes.includes(t));
const legacyInputTypes = allInputScriptTypes.filter(t => legacyTypes.includes(t));
checks.push({
id:"legacy_type", label:"Tipo de script legacy (P2PKH/P2SH)", certainty:"CERTEZA",
pass: !hasLegacyInputs,
actionability: "wallet",
detail: hasLegacyInputs
? `Los inputs usan tipo legacy (${legacyInputTypes.join(", ")}). Las direcciones legacy (1... para P2PKH, 3... para P2SH) tienen mayor huella on-chain que SegWit (bc1q...) o Taproot (bc1p...) y forman un conjunto de usuarios cada vez más pequeño, lo que reduce el anonimato por conjunto.`
: "Los inputs usan SegWit o Taproot — no se detecta tipo legacy.",
didactic: "P2PKH y P2SH son los formatos originales de Bitcoin. La mayoría de wallets modernos usan SegWit (bc1q) o Taproot (bc1p), que son más eficientes y tienen menor huella. Usar legacy no es un error activo, pero sí una característica del UTXO observable por cualquiera.",
penalty: hasLegacyInputs ? weights.legacy_type : 0,
});
if (hasLegacyInputs) deductions += weights.legacy_type;
// ── 17. Detección de entidades conocidas ──────────────────────────
// Cruza direcciones de inputs y outputs contra el ENTITY_INDEX embebido.
const allTxAddrs = [
...tx.vin.map(v => ({ addr: v.prevout?.scriptpubkey_address, side: "input" })),
...tx.vout.map(v => ({ addr: v.scriptpubkey_address, side: "output" })),
].filter(x => x.addr);
const ofacHits = [];
const miningHits = [];
const exchHits = [];
for (const { addr, side } of allTxAddrs) {
const hit = ENTITY_INDEX.get(addr);
if (!hit) continue;
if (hit.cat === "ofac") ofacHits.push({ ...hit, addr, side });
if (hit.cat === "mining") miningHits.push({ ...hit, addr, side });
if (hit.cat === "exchange") exchHits.push({ ...hit, addr, side });
}
// 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) {
const names = [...new Set(ofacHits.map(h => h.name))].join(", ");
const sides = [...new Set(ofacHits.map(h => h.side))];
checks.push({
id:"entity_ofac", label:"Dirección en lista de sanciones OFAC", certainty:"CERTEZA", pass:true,
informational: true,
actionability: "no_corregible",
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 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.",
});
}
// Mining pools — informativo, cierra el rastro
if (miningHits.length > 0) {
const names = [...new Set(miningHits.map(h => h.name))].join(", ");
checks.push({
id:"entity_mining", label:"Origen en pool de minería", certainty:"informativo", pass:true,
actionability: "no_corregible",
detail: `Una o más direcciones provienen de un pool de minería conocido: ${names}. Si es un input, estas monedas proceden de una recompensa de minado — no tienen historial previo que rastrear.`,
didactic: "Las direcciones de coinbase de los pools de minería son públicas. Recibir fondos directamente de un pool indica actividad de minería y cierra el rastro de procedencia: no hay transacciones anteriores porque las monedas se crearon en ese bloque.",
penalty: 0, informational: true,
});
}
// Exchange — informativo. El texto es HONESTO sobre la fiabilidad:
// las direcciones por clustering son inferidas (no confirmadas por el
// exchange); las de rich list / forense son etiquetas públicas conocidas.
if (exchHits.length > 0) {
const names = [...new Set(exchHits.map(h => h.name))].join(", ");
const sides = [...new Set(exchHits.map(h => h.side))];
const todasConfiables = exchHits.every(h => h.src==="richlist" || h.src==="forensic");
const algunaCluster = exchHits.some(h => h.src==="cluster");
const label = todasConfiables
? `Coincide con dirección conocida de exchange: ${names}`
: `Posible coincidencia con exchange (agrupación): ${names}`;
const detail = todasConfiables
? `Una o más direcciones (${sides.join(" y ")}) coinciden con direcciones públicas conocidas de ${names}. Recibir o enviar a un exchange con KYC vincula esta actividad con tu identidad verificada ante ese servicio.`
: `Una o más direcciones (${sides.join(" y ")}) coinciden con ${names} según datos de agrupación de direcciones (clustering). Importante: esto NO está confirmado por el exchange — es una inferencia estadística que asocia direcciones por cómo se mueven los fondos. Puede haber falsos positivos. Tómalo como una pista, no como un hecho.`;
checks.push({
id:"entity_exchange", label, certainty: todasConfiables ? "PROBABLE" : "POSIBLE", pass:true,
actionability: "no_corregible",
detail,
didactic: "Los exchanges con KYC conocen la identidad de sus usuarios. Si una transacción toca una dirección de exchange, ese servicio puede vincular los fondos con una persona. Las direcciones de exchange se conocen de dos formas: las que el propio exchange publica (fiables) y las que se infieren agrupando direcciones por su comportamiento (clustering, no confirmado). Txoko distingue ambas para que sepas cuánto fiarte del dato.",
penalty: 0, informational: true,
});
}
// ── Score compuesto ───────────────────────────────────────────────
const maxPossible = Object.values(weights).reduce((a,b)=>a+b,0);
const rawScore = Math.max(0, Math.min(100, Math.round(100 - (deductions / maxPossible) * 100)));
// OP_RETURN es incompatible con privacidad aceptable — fuerza banda BAJA como máximo.
const score = hasOpReturn ? Math.min(rawScore, 44) : rawScore;
return {
score, checks,
band: score>=75?"ALTA":score>=45?"MEDIA":"BAJA",
summary: score>=75?"Privacidad aceptable":score>=45?"Privacidad mejorable":"Privacidad baja",
summaryColor: score>=75?C.green:score>=45?C.amber:C.red,
wallets: detectedWallets,
};
}
function analyzeAddress(addr) {
const checks = []; let score = 100;
const txCount = addr.chain_stats ? addr.chain_stats.tx_count : 0;
const utxoCount = addr.utxos ? addr.utxos.length : 0;
// Dirección sin usar: no tiene privacidad buena ni mala, simplemente está virgen
if (txCount === 0) {
checks.push({ id:"unused", label:"Dirección sin usar", certainty:"CERTEZA", pass:true, detail:"Esta dirección no tiene historial on-chain. No ha recibido ni enviado fondos.", penalty:0, informational:true });
return { score:null, checks, band:"NUEVA", summary:"Dirección sin usar", summaryColor:C.t2, wallets:[], unused:true };
}
const reused = txCount > 1;
const penalty = txCount > 10 ? 40 : txCount > 3 ? 25 : 15;
checks.push({ id:"addr_reuse", label:"Reutilización de dirección", certainty:"CERTEZA", pass:!reused, detail: reused ? `Usada en ${txCount} transacciones. Permite vincular pagos y construir un perfil.` : "Usada una sola vez. Correcto.", penalty });
if (reused) score -= penalty;
const address = addr.address || "";
const isTaproot = address.startsWith("bc1p");
const isNative = address.startsWith("bc1q");
const isLegacy = address.startsWith("1");
const scriptScore = isTaproot ? 0 : isNative ? 5 : isLegacy ? 20 : 15;
checks.push({ id:"script_type", label:"Tipo de script", certainty:"CERTEZA", pass:isTaproot||isNative, detail: isTaproot ? "Taproot (bc1p). Mejor tipo disponible." : isNative ? "Native SegWit (bc1q). Buen tipo." : isLegacy ? "Legacy P2PKH. Tipo antiguo, menor privacidad." : "P2SH. Tipo intermedio.", penalty:scriptScore });
score -= scriptScore;
checks.push({ id:"utxo_count", label:"Fragmentación de UTXOs", certainty:"POSIBLE", pass:utxoCount<=5, detail: utxoCount > 5 ? `${utxoCount} UTXOs. Un número alto facilita el análisis de actividad.` : `${utxoCount} UTXOs. Número razonable.`, penalty:10 });
if (utxoCount > 5) score -= 10;
const funded = addr.chain_stats ? addr.chain_stats.funded_txo_sum : 0;
checks.push({ id:"volume", label:"Volumen de actividad", certainty:"CERTEZA", pass:true, detail:`Total recibido: ${(funded/1e8).toFixed(4)} BTC en ${txCount} transacciones.`, penalty:0, informational:true });
score = Math.max(0, Math.min(100, score));
return { score, checks, band: score>=75?"ALTA":score>=45?"MEDIA":"BAJA", summary: score >= 75 ? "Privacidad aceptable" : score >= 45 ? "Privacidad mejorable" : "Privacidad baja", summaryColor: score >= 75 ? C.green : score >= 45 ? C.amber : C.red, wallets: [] };
}
// ── fetch con timeout — protege el nodo y evita el spinner infinito ──
// Si Fulcrum/Mempool está ocupado (reindexando, mempool llena), la petición
// se cancela pasados timeoutMs en lugar de quedarse colgada para siempre.
const FETCH_TIMEOUT_MS = 8000;
async function fetchWithTimeout(url, timeoutMs) {
const ms = timeoutMs || FETCH_TIMEOUT_MS;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
try {
return await fetch(url, { signal: controller.signal });
} catch (e) {
if (e.name === "AbortError") {
throw new Error("el nodo tardó demasiado en responder (timeout)");
}
throw e;
} finally {
clearTimeout(timer);
}
}
// ── Caché de respuestas del nodo ──────────────────────────────────────
// Vive FUERA del hook a propósito. Las pestañas se montan y desmontan al
// navegar ({tab==="node" && <NodeOverview/>}), así que sin esto ir a
// BLOQUES y volver a NODO vuelve a pedirlo todo aunque hayan pasado dos
// segundos. Mismo patrón que ya usa system-metrics.js en el servidor
// (caché con TTL + coalescencia), aplicado ahora también en el navegador.
//
// La coalescencia importa tanto como el TTL: dos componentes que piden lo
// mismo a la vez generan UNA petición al nodo, no dos.
const apiCache = new Map(); // clave -> { at, data, ttl }
const apiInflight = new Map(); // clave -> Promise en vuelo
const API_CACHE_MAX = 400; // tope de entradas, para no crecer sin fin
// Cuánto vale una respuesta depende de lo que sea. Una transacción ya
// confirmada es INMUTABLE: no hay ningún motivo para volver a pedirla en
// toda la sesión. Es el caso más frecuente en el analizador, el rastro de
// procedencia y el peritaje, que hoy piden las mismas txs por separado.
function apiCacheTtl(path, data) {
if (path.startsWith("/system/")) return 2000; // estado vivo del nodo: casi sin caché
if (/^\/api\/tx\/[0-9a-f]{64}$/i.test(path)) {
return data && data.status && data.status.confirmed ? Infinity : 15000;
}
if (path.startsWith("/api/address/")) return 60000; // historial: cambia poco
if (path.startsWith("/api/block")) return 60000;
return 20000; // mempool, fees y demás
}
async function apiGetJson(baseUrl, path) {
const key = baseUrl + path;
const hit = apiCache.get(key);
if (hit && (hit.ttl === Infinity || Date.now() - hit.at < hit.ttl)) return hit.data;
const flying = apiInflight.get(key);
if (flying) return flying; // ya hay una petición idéntica en curso
const p = (async () => {
const res = await fetchWithTimeout(key);
if (!res.ok) throw new Error(`el nodo respondió HTTP ${res.status}`);
const data = await res.json();
const ttl = apiCacheTtl(path, data);
if (ttl > 0) {
// FIFO simple: al llenarse, cae la entrada más antigua.
if (apiCache.size >= API_CACHE_MAX) apiCache.delete(apiCache.keys().next().value);
apiCache.set(key, { at: Date.now(), data, ttl });
}
return data;
})();
apiInflight.set(key, p);
try { return await p; } finally { apiInflight.delete(key); }
}
function useApi(baseUrl) {
const get = useCallback(async (path, mockData) => {
if (!baseUrl) return mockData;
try {
return await apiGetJson(baseUrl, path);
} catch { return mockData; }
}, [baseUrl]);
// Variante estricta: propaga el fallo en vez de disfrazarlo de dato
// vacío. `get` devuelve mockData ante cualquier error, lo que en las
// vistas de exploración es aceptable (se ve el modo demo), pero en el
// peritaje haría indistinguible "el nodo no respondió" de "aquí no hay
// nada" — y eso convierte un fallo de consulta en una afirmación
// pericial falsa. Quien use getStrict debe capturar y decir qué pasó.
const getStrict = useCallback(async (path) => {
if (!baseUrl) throw new Error("no hay nodo configurado");
return apiGetJson(baseUrl, path); // misma caché; los errores sí suben
}, [baseUrl]);
return { get, getStrict };
}
const fmt = {
hash: h => h ? `${h.slice(0,10)}...${h.slice(-8)}` : "-",
ago: ts => { const s = Math.floor(Date.now()/1000-ts); if(s<60)return`${s}s`; if(s<3600)return`${Math.floor(s/60)}m`; if(s<86400)return`${Math.floor(s/3600)}h`; return`${Math.floor(s/86400)}d`; },
date: ts => new Date(ts*1000).toLocaleString("es-ES",{day:"2-digit",month:"2-digit",hour:"2-digit",minute:"2-digit"}),
btc: s => `${(s/1e8).toFixed(8)} BTC`,
sbtc: s => `${(s/1e8).toFixed(4)} BTC`,
num: n => Number(n).toLocaleString("es-ES"),
mb: b => `${(b/1e6).toFixed(2)} MB`,
};
const Badge = ({children, color}) => { const c=color||C.green; return <span style={{fontSize:"0.62rem",fontFamily:"monospace",letterSpacing:"0.07em",padding:"2px 6px",borderRadius:3,fontWeight:700,background:`${c}18`,color:c,border:`1px solid ${c}35`,whiteSpace:"nowrap"}}>{children}</span>; };
const Card = ({children, style, glow}) => <div style={{background:C.bgCard,border:`1px solid ${C.border}`,borderRadius:8,padding:16,boxShadow:glow?`0 0 28px ${glow}12`:"none",...style}}>{children}</div>;
const SectionTitle = ({children, accent, icon}) => { const a=accent||C.green; return <div style={{display:"flex",alignItems:"center",gap:8,marginBottom:16}}>{icon&&<span style={{color:a,fontFamily:"monospace",fontSize:"0.8rem"}}>{icon}</span>}<h2 style={{margin:0,fontSize:"0.68rem",fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.2em",color:a}}>{children}</h2><div style={{flex:1,height:1,background:`linear-gradient(to right,${a}40,transparent)`}}/></div>; };
const Tag = ({label,value,color}) => <div style={{display:"flex",flexDirection:"column",gap:3}}><span style={{fontSize:"0.58rem",color:C.t2,textTransform:"uppercase",letterSpacing:"0.1em",fontFamily:"monospace"}}>{label}</span><span style={{fontSize:"0.8rem",color:color||C.t1,fontFamily:"monospace",fontWeight:600}}>{value!=null?value:"-"}</span></div>;
const Pulse = ({color}) => <span style={{display:"inline-block",width:7,height:7,borderRadius:"50%",background:color||C.green,boxShadow:`0 0 7px ${color||C.green}`,animation:"blink 2s ease-in-out infinite"}}/>;
const Spinner = () => <div style={{display:"flex",alignItems:"center",justifyContent:"center",padding:40,color:C.t2,fontFamily:"monospace",fontSize:"0.75rem",gap:8}}><span style={{animation:"spin 1s linear infinite",display:"inline-block"}}></span> cargando</div>;
const DemoBanner = () => <div style={{padding:"8px 14px",background:C.amberMuted,border:`1px solid ${C.amber}30`,borderRadius:6,color:C.amber,fontFamily:"monospace",fontSize:"0.7rem",marginBottom:14}}>Datos de demo configura tu nodo en CONFIG para ver datos reales</div>;
function ScoreRing({score,color,size,label}) {
// El anillo se rellena con el score real (medidor visual), pero el centro
// puede mostrar una etiqueta de banda (ALTA/MEDIA/BAJA) en vez de un número
// exacto — evita sugerir una precisión de dos cifras que no existe.
const s=size||80, r=s/2-8, circ=2*Math.PI*r, fill=circ*(score/100);
const centerText = label != null ? label : score;
const centerSize = label != null ? s*0.16 : s*0.22;
return <svg width={s} height={s} style={{flexShrink:0}}><circle cx={s/2} cy={s/2} r={r} fill="none" stroke={C.border} strokeWidth={6}/><circle cx={s/2} cy={s/2} r={r} fill="none" stroke={color} strokeWidth={6} strokeDasharray={`${fill} ${circ}`} strokeLinecap="round" transform={`rotate(-90 ${s/2} ${s/2})`}/><text x={s/2} y={s/2+1} textAnchor="middle" dominantBaseline="middle" fill={color} fontSize={centerSize} fontFamily="monospace" fontWeight="700" letterSpacing="0.05em">{centerText}</text></svg>;
}
const CertaintyBadge = ({level}) => { const map={CERTEZA:C.green,PROBABLE:C.amber,POSIBLE:C.blue}; return <Badge color={map[level]||C.t2}>{level}</Badge>; };
function generateReport(analysis, tx) {
const {score, checks} = analysis;
// Detectar si el objeto analizado es una dirección en vez de una transacción
const isAddr = !!(tx && (tx.address || tx.chain_stats));
const noun = isAddr ? "dirección" : "transacción";
const failed = checks.filter(c => !c.pass && !c.informational);
const critical = failed.filter(c => c.certainty === "CERTEZA");
const probable = failed.filter(c => c.certainty === "PROBABLE");
// Checks individuales
const hasInputReuse = failed.find(c=>c.id==="input_reuse");
const hasTypeMixing = failed.find(c=>c.id==="input_type_mixing");
const hasChangeMismatch= failed.find(c=>c.id==="output_type_mismatch");
const hasRound = failed.find(c=>c.id==="round_numbers");
const hasRbf = failed.find(c=>c.id==="rbf");
const hasUnnecessary = failed.find(c=>c.id==="unnecessary_input");
const hasPeeling = failed.find(c=>c.id==="peeling");
const hasDust = failed.find(c=>c.id==="dust");
const hasChange = failed.find(c=>c.id==="change_detection");
const hasFingerprint = failed.find(c=>c.id==="wallet_fingerprint");
const likelyCJ = checks.find(c=>c.id==="coinjoin")?.pass;
const walletName = analysis.wallets?.[0]?.name;
const sections = [];
// Caso especial: dirección sin usar
if (analysis.unused) {
sections.push({ title:"Diagnóstico", text:"Esta dirección no tiene historial on-chain — no ha recibido ni enviado fondos. No hay nada que analizar todavía." });
sections.push({ title:"Qué puede saber un analista", text:"Nada. Una dirección sin usar no revela información hasta que recibe su primer pago." });
sections.push({ title:"Recomendaciones", text:"Cuando recibas fondos en esta dirección, úsala una sola vez para mantener una buena privacidad." });
return sections;
}
// ── 1. DIAGNÓSTICO PRINCIPAL ─────────────────────────────────────
let diag = "";
if (hasInputReuse && hasChange) {
diag = "El problema más grave es la combinación de reutilización de direcciones con un output de cambio identificable. Un analista puede vincular todos los UTXOs de los inputs al mismo propietario y además rastrear a dónde van los fondos tras esta transacción.";
} else if (hasInputReuse) {
diag = "El problema principal es la reutilización de direcciones en los inputs. Esto permite vincular con certeza todos esos UTXOs al mismo propietario y construir un historial de sus fondos.";
} else if (hasChange && (hasChangeMismatch || hasRound)) {
const signals = [];
if (hasChangeMismatch) signals.push("el tipo de script");
if (hasRound) signals.push("el valor redondo");
const signalsText = signals.length > 0 ? signals.join(" y ") : "múltiples señales";
diag = `El output de cambio queda identificado por ${signalsText}. Cualquier analista puede determinar qué output es el cambio y seguir el rastro de tus fondos en transacciones futuras.`;
} else if (hasTypeMixing) {
diag = "La mezcla de tipos de script en los inputs revela que se están consolidando UTXOs de fuentes distintas. Esto permite vincular esos fondos a un mismo propietario con alto grado de certeza.";
} else if (hasDust) {
diag = "Se detectan outputs de dust que podrían ser parte de un ataque de dust linking. Si gastas esos outputs combinándolos con otros UTXOs, revelarás qué UTXOs pertenecen al mismo wallet.";
} else if (failed.length === 0) {
diag = `No se detectan problemas graves de privacidad en esta ${noun} con los datos disponibles.`;
} else {
diag = `Se detectan ${failed.length} característica${failed.length>1?"s":""} que reducen la privacidad, ninguna de gravedad crítica por sí sola.`;
}
sections.push({ title:"Diagnóstico", text: diag });
// ── 2. QUÉ SABE UN ANALISTA ──────────────────────────────────────
const knows = [];
if (hasInputReuse) knows.push("que los inputs reutilizados pertenecen al mismo propietario");
if (hasTypeMixing) knows.push("que los inputs proceden de wallets o épocas distintas");
if (hasChangeMismatch || (hasChange && hasRound)) knows.push("cuál de los dos outputs es probablemente el cambio");
if (hasUnnecessary) knows.push("que el emisor estaba consolidando UTXOs — los inputs adicionales no eran necesarios para el pago");
if (hasFingerprint && walletName) knows.push(`que el software usado es probablemente ${walletName}`);
if (hasRbf) knows.push("qué software de wallet usa el emisor (RBF activado)");
if (hasPeeling) knows.push("que el cambio puede rastrearse a la siguiente transacción de la cadena");
let knowsText = "";
if (knows.length === 0) {
knowsText = "Con los datos on-chain disponibles, un analista externo no puede extraer conclusiones definitivas sobre el propietario de estos fondos.";
} else if (knows.length === 1) {
knowsText = `Un analista externo puede determinar ${knows[0]}.`;
} else {
const last = knows.pop();
knowsText = `Un analista externo puede determinar ${knows.join(", ")} y ${last}.`;
}
sections.push({ title:"Qué puede saber un analista", text: knowsText });
// ── 3. LIMITACIONES DEL ANÁLISIS ─────────────────────────────────
const limits = [];
limits.push("Este análisis se basa únicamente en los datos on-chain de esta transacción. Sin acceso al historial completo de las direcciones involucradas, algunas conclusiones son probabilísticas.");
if (!hasInputReuse && !hasChange) limits.push("No se puede determinar si los fondos tienen origen KYC o provienen de exchanges.");
if (likelyCJ) limits.push("La estructura CoinJoin rompe el vínculo entre inputs y outputs — el análisis de trazabilidad tiene límites significativos.");
sections.push({ title:"Limitaciones", text: limits.join(" ") });
// ── 4. RECOMENDACIONES ────────────────────────────────────────────
const recs = [];
if (hasInputReuse) recs.push({ priority:1, action:"Usa una dirección nueva en cada transacción", why:"La reutilización de direcciones es el error más grave y más fácil de evitar.", tag:"evitable" });
if (hasTypeMixing) recs.push({ priority:2, action:"Consolida UTXOs de tipos distintos en una transacción dedicada, no mezclada con pagos", why:"Mezclar tipos en un pago revela el origen de los fondos. Si necesitas consolidar, hazlo en una tx separada cuando la mempool esté tranquila.", tag:"evitable" });
if (hasChange && (hasChangeMismatch || hasRound)) recs.push({ priority:3, action:"Usa Taproot (bc1p) para que el tipo de script del cambio no te identifique", why:"Si emisor y receptor usan el mismo tipo de script, el cambio no queda identificado por tipo.", tag:"wallet" });
if (hasRbf) recs.push({ priority:4, action:"Desactiva RBF si no lo necesitas, o usa un wallet que no lo active por defecto", why:"RBF es una firma de wallet identificable. Bitcoin Core lo activa por defecto; wallets como Electrum o Sparrow permiten desactivarlo.", tag:"wallet" });
if (hasDust) recs.push({ priority:1, action:"No gastes los outputs de dust — márcalos como no gastables en tu wallet", why:"Gastar dust vincula tus UTXOs. La mayoría de wallets modernos permiten congelar UTXOs específicos.", tag:"evitable" });
if (hasUnnecessary && !hasInputReuse) recs.push({ priority:5, action:"Evita consolidar UTXOs en el mismo momento que realizas un pago", why:"Añadir inputs innecesarios revela que esos UTXOs son tuyos. Consolida en transacciones separadas.", tag:"evitable" });
// Ordenar por prioridad y limitar a 3
recs.sort((a,b)=>a.priority-b.priority);
const topRecs = recs.slice(0,3);
if (topRecs.length > 0) {
sections.push({ title:"Recomendaciones", recs: topRecs });
} else {
sections.push({ title:"Recomendaciones", text:`No hay acciones correctivas prioritarias para esta ${noun}.` });
}
return sections;
}
function PrivacyLab({analysis, tx, labels, myAddr}) {
const [expanded,setExpanded] = useState(null);
const [showDidactic,setShowDidactic] = useState(null);
const [showReport,setShowReport] = useState(false);
const [showOkGroup,setShowOkGroup] = useState(false);
const [showInfoGroup,setShowInfoGroup] = useState(false);
if (!analysis) return null;
const {score,checks,summary,summaryColor,wallets,band} = analysis;
const report = generateReport(analysis, tx);
// Texto de acción según accionabilidad — orienta sin sonar a reproche
const actionText = (a) =>
a==="no_corregible" ? "Esto ya quedó registrado en la cadena y no se puede cambiar. Tenlo en cuenta para próximas transacciones."
: a==="wallet" ? "Qué puedes hacer: esto depende de tu software de wallet. Otro wallet (o configuración) lo evitaría."
: "Qué puedes hacer: este patrón depende de cómo se construye la transacción. Puedes evitarlo en próximos envíos.";
const actionLabel = (a) =>
a==="no_corregible" ? "ya en chain" : a==="wallet" ? "depende del wallet" : "puedes evitarlo";
// Render de un check. isProblem=true → didáctica visible directamente al
// expandir (sin segundo clic); en correctas/informativas queda tras botón.
const renderCheck = (check, isProblem) => (
<div key={check.id} style={{background:C.bgCard,border:`1px solid ${expanded===check.id?C.purple+"60":C.border}`,borderRadius:6,overflow:"hidden"}}>
<div onClick={()=>setExpanded(expanded===check.id?null:check.id)}
style={{display:"flex",alignItems:"center",gap:8,padding:"10px 14px",cursor:"pointer"}}>
<span style={{fontSize:"0.8rem",color:check.informational?C.blue:(check.positive&&!check.pass)?C.green:check.pass?C.green:C.red,flexShrink:0,fontWeight:700}}>
{check.informational?"·":(check.positive&&!check.pass)?"✓":check.pass?"✓":"✗"}
</span>
<span style={{flex:1,fontSize:"0.73rem",color:C.t1,fontFamily:"monospace"}}>{check.label}</span>
{!check.pass&&!check.positive&&check.actionability&&(
<span style={{fontSize:"0.55rem",fontFamily:"monospace",padding:"2px 6px",borderRadius:3,flexShrink:0,
background: check.actionability==="no_corregible"?C.redMuted:check.actionability==="wallet"?C.purpleMuted:C.amberMuted,
color: check.actionability==="no_corregible"?C.red:check.actionability==="wallet"?C.purple:C.amber,
border: `1px solid ${check.actionability==="no_corregible"?C.red:check.actionability==="wallet"?C.purple:C.amber}30`,
}}>
{actionLabel(check.actionability)}
</span>
)}
<CertaintyBadge level={check.certainty}/>
</div>
{expanded===check.id&&(
<div style={{padding:"0 14px 12px 14px",borderTop:`1px solid ${C.border}`}}>
<div style={{fontSize:"0.7rem",color:C.t2,lineHeight:1.6,paddingTop:10}}>{check.detail}</div>
{isProblem&&check.didactic&&(
<div style={{marginTop:8,padding:"10px 12px",background:C.purpleMuted,borderRadius:6,border:`1px solid ${C.purple}20`,fontSize:"0.68rem",color:C.t1,lineHeight:1.7}}>
{check.didactic}
</div>
)}
{isProblem&&check.actionability&&(
<div style={{marginTop:8,fontSize:"0.66rem",color:check.actionability==="no_corregible"?C.t2:C.amber,lineHeight:1.5}}>
{actionText(check.actionability)}
</div>
)}
{!check.informational&&!check.pass&&check.penalty>0&&(
<div style={{marginTop:6,fontSize:"0.62rem",color:C.t2,fontFamily:"monospace"}}>
Peso en la banda: {check.penalty>=40?"alto":check.penalty>=20?"medio":"bajo"}
</div>
)}
{!isProblem&&check.didactic&&(
<div style={{marginTop:8}}>
<button onClick={e=>{e.stopPropagation();setShowDidactic(showDidactic===check.id?null:check.id);}}
style={{background:"none",border:`1px solid ${C.purple}40`,borderRadius:4,padding:"3px 8px",color:C.purple,fontFamily:"monospace",fontSize:"0.62rem",cursor:"pointer"}}>
{showDidactic===check.id?"▲ ocultar":"▼ ¿qué significa esto?"}
</button>
{showDidactic===check.id&&(
<div style={{marginTop:8,padding:"10px 12px",background:C.purpleMuted,borderRadius:6,border:`1px solid ${C.purple}20`,fontSize:"0.68rem",color:C.t1,lineHeight:1.7}}>
{check.didactic}
</div>
)}
</div>
)}
</div>
)}
</div>
);
// ── Exportar informe ──────────────────────────────────────────────
// Genera el archivo en el navegador y lo descarga en local.
// No pasa por ningún servidor, no sale nada del nodo.
const descargar = (contenido, nombre, tipo) => {
const blob = new Blob(["\uFEFF" + contenido], {type:tipo});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = nombre;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const nombreBase = () => {
const fecha = new Date().toISOString().slice(0,10);
const txCorto = (tx?.txid || "tx").slice(0,12);
return `txoko-informe-${txCorto}-${fecha}`;
};
const exportJSON = () => {
const datos = {
generado: new Date().toISOString(),
herramienta: "Txoko Node Dashboard",
txid: tx?.txid || null,
privacidad: { score, banda: band, resumen: summary },
wallets_detectados: (wallets||[]).map(w => ({
nombre: w.name || w, confianza: w.confidence || null, señales: w.signals || []
})),
señales: checks.map(c => ({
id: c.id, etiqueta: c.label, certeza: c.certainty,
pasa: c.pass, informativo: !!c.informational,
accionabilidad: c.actionability || null,
penalizacion: c.penalty || 0, detalle: c.detail,
})),
};
descargar(JSON.stringify(datos, null, 2), nombreBase()+".json", "application/json;charset=utf-8");
};
const exportMarkdown = () => {
const L = [];
L.push(`# Informe de privacidad — Txoko`);
L.push("");
L.push(`**Transacción:** \`${tx?.txid || "—"}\` `);
L.push(`**Generado:** ${new Date().toLocaleString("es-ES")} `);
L.push(`**Nivel de privacidad:** ${band} (${score}/100) — ${summary}`);
L.push("");
if (wallets && wallets.length > 0) {
L.push(`**Wallet posible:** ${wallets.map(w=>w.name||w).join(", ")}`);
L.push("");
}
// Informe narrativo (reutiliza generateReport)
report.forEach(sec => {
L.push(`## ${sec.title}`);
L.push("");
if (sec.text) { L.push(sec.text); L.push(""); }
if (sec.recs) {
sec.recs.forEach(r => { L.push(`- **${r.action}** — ${r.why} _(${r.tag})_`); });
L.push("");
}
});
// Tabla de señales detectadas
L.push(`## Señales detectadas`);
L.push("");
L.push(`| Señal | Certeza | Estado | Detalle |`);
L.push(`| --- | --- | --- | --- |`);
checks.forEach(c => {
const estado = c.informational ? "informativo" : (c.pass ? "ok" : "detectado");
const detalle = (c.detail||"").replace(/\|/g,"\\|").replace(/\n/g," ");
L.push(`| ${c.label} | ${c.certainty} | ${estado} | ${detalle} |`);
});
L.push("");
L.push(`---`);
L.push(`_Generado por Txoko Node Dashboard — your node, your rules. Análisis local, ningún dato salió de tu nodo._`);
descargar(L.join("\n"), nombreBase()+".md", "text/markdown;charset=utf-8");
};
return (
<div style={{marginTop:8}}>
<div style={{display:"flex",alignItems:"center",gap:8,marginBottom:14,paddingTop:14,borderTop:`1px solid ${C.border}`}}>
<span style={{fontSize:"0.65rem",fontFamily:"monospace",color:C.purple,textTransform:"uppercase",letterSpacing:"0.2em",fontWeight:700}}>Privacy Lab</span>
<div style={{flex:1,height:1,background:`linear-gradient(to right,${C.purple}40,transparent)`}}/>
<button onClick={exportJSON} title="Descargar el informe en JSON (local, no sale del nodo)"
style={{padding:"4px 10px",background:C.bg,border:`1px solid ${C.purple}40`,borderRadius:5,color:C.purple,fontFamily:"monospace",fontSize:"0.6rem",letterSpacing:"0.08em",cursor:"pointer",whiteSpace:"nowrap"}}
onMouseEnter={e=>e.currentTarget.style.borderColor=C.purple}
onMouseLeave={e=>e.currentTarget.style.borderColor=C.purple+"40"}>
JSON
</button>
<button onClick={exportMarkdown} title="Descargar el informe en Markdown (local, no sale del nodo)"
style={{padding:"4px 10px",background:C.bg,border:`1px solid ${C.purple}40`,borderRadius:5,color:C.purple,fontFamily:"monospace",fontSize:"0.6rem",letterSpacing:"0.08em",cursor:"pointer",whiteSpace:"nowrap"}}
onMouseEnter={e=>e.currentTarget.style.borderColor=C.purple}
onMouseLeave={e=>e.currentTarget.style.borderColor=C.purple+"40"}>
MD
</button>
</div>
<Card glow={summaryColor} style={{display:"flex",alignItems:"center",gap:16,padding:"14px 18px",marginBottom:10}}>
<ScoreRing score={score} color={summaryColor} size={72} label={band}/>
<div style={{flex:1}}>
<div style={{fontSize:"0.7rem",color:C.t2,fontFamily:"monospace",marginBottom:4}}>NIVEL DE PRIVACIDAD</div>
<div style={{fontSize:"1rem",color:summaryColor,fontFamily:"monospace",fontWeight:700,marginBottom:4}}>{summary}</div>
<div style={{fontSize:"0.65rem",color:C.t2,lineHeight:1.5}}>
{analysis.unused
? "Esta dirección no se ha usado todavía. No tiene historial on-chain que analizar."
: tx
? (score>=75?"La transacción presenta buenas propiedades de privacidad.":score>=45?"La transacción es funcional, pero tiene características que reducen su privacidad.":"Esta transacción tiene características que facilitan su rastreo.")
: (score>=75?"La dirección presenta buenas propiedades de privacidad.":score>=45?"La dirección es funcional, pero tiene características que reducen su privacidad.":"Esta dirección tiene características que facilitan su rastreo.")}
</div>
{wallets&&wallets.length>0&&(
<div style={{marginTop:6,fontSize:"0.62rem",color:C.t2,fontFamily:"monospace"}}>
Wallet posible: <span style={{color:C.amber}}>{wallets.map(w=>w.name||w).join(", ")}</span>
</div>
)}
</div>
</Card>
{/* Informe narrativo — colapsable */}
<div style={{marginBottom:10}}>
<button onClick={()=>setShowReport(!showReport)} style={{
width:"100%",display:"flex",alignItems:"center",justifyContent:"space-between",
padding:"10px 14px",background:C.bgCard,border:`1px solid ${showReport?C.purple+"60":C.border}`,
borderRadius:6,cursor:"pointer",color:C.purple,fontFamily:"monospace",fontSize:"0.68rem",fontWeight:700,
letterSpacing:"0.1em",textTransform:"uppercase",
}}>
<span> Informe de privacidad</span>
<span style={{fontSize:"0.7rem"}}>{showReport?"▲":"▼"}</span>
</button>
{showReport&&(
<div style={{background:C.bgCard,border:`1px solid ${C.purple}30`,borderTop:"none",borderRadius:"0 0 6px 6px",padding:"14px 16px"}}>
{report.map((section,i) => (
<div key={i} style={{marginBottom: i<report.length-1?16:0}}>
<div style={{fontSize:"0.6rem",color:C.purple,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.15em",marginBottom:6,fontWeight:700}}>
{section.title}
</div>
{section.text&&(
<div style={{fontSize:"0.7rem",color:C.t1,lineHeight:1.8}}>{section.text}</div>
)}
{section.recs&&(
<div style={{display:"flex",flexDirection:"column",gap:8}}>
{section.recs.map((rec,j)=>(
<div key={j} style={{padding:"10px 12px",background:C.bg,borderRadius:6,border:`1px solid ${C.border}`}}>
<div style={{display:"flex",alignItems:"center",gap:8,marginBottom:5}}>
<span style={{fontSize:"0.72rem",color:C.green,fontFamily:"monospace",fontWeight:700}}>{j+1}.</span>
<span style={{fontSize:"0.7rem",color:C.t1,fontWeight:600,flex:1}}>{rec.action}</span>
<span style={{fontSize:"0.55rem",fontFamily:"monospace",padding:"2px 6px",borderRadius:3,flexShrink:0,
background: rec.tag==="wallet"?C.purpleMuted:C.amberMuted,
color: rec.tag==="wallet"?C.purple:C.amber,
border: `1px solid ${rec.tag==="wallet"?C.purple:C.amber}30`,
}}>
{rec.tag==="wallet"?"depende del wallet":"puedes evitarlo"}
</span>
</div>
<div style={{fontSize:"0.65rem",color:C.t2,lineHeight:1.6,paddingLeft:18}}>{rec.why}</div>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
{(() => {
const problems = checks.filter(c => !c.informational && !c.pass && !c.positive);
const oks = checks.filter(c => !c.informational && (c.pass || c.positive));
const infos = checks.filter(c => c.informational);
return (
<div style={{display:"flex",flexDirection:"column",gap:6}}>
{/* Resumen de un vistazo */}
<div style={{display:"flex",gap:14,alignItems:"center",padding:"8px 4px",flexWrap:"wrap"}}>
{problems.length>0
? <span style={{fontSize:"0.7rem",color:C.red,fontFamily:"monospace",fontWeight:700}}> {problems.length} {problems.length===1?"señal a revisar":"señales a revisar"}</span>
: <span style={{fontSize:"0.7rem",color:C.green,fontFamily:"monospace",fontWeight:700}}> Sin señales que revisar</span>}
<span style={{fontSize:"0.66rem",color:C.t2,fontFamily:"monospace"}}> {oks.length} correctas</span>
{infos.length>0&&<span style={{fontSize:"0.66rem",color:C.t2,fontFamily:"monospace"}}>· {infos.length} informativas</span>}
</div>
{/* Problemas: siempre visibles, lo primero */}
{problems.map(c => renderCheck(c, true))}
{/* Correctas: agrupadas, colapsadas por defecto */}
{oks.length>0&&(
<div style={{background:C.bgCard,border:`1px solid ${C.border}`,borderRadius:6,overflow:"hidden"}}>
<div onClick={()=>setShowOkGroup(!showOkGroup)}
style={{display:"flex",alignItems:"center",gap:8,padding:"10px 14px",cursor:"pointer"}}>
<span style={{fontSize:"0.8rem",color:C.green,fontWeight:700}}></span>
<span style={{flex:1,fontSize:"0.7rem",color:C.t2,fontFamily:"monospace"}}>{oks.length} señales correctas</span>
<span style={{color:C.t2,fontSize:"0.7rem"}}>{showOkGroup?"▲":"▼"}</span>
</div>
{showOkGroup&&(
<div style={{display:"flex",flexDirection:"column",gap:6,padding:"0 8px 8px 8px"}}>
{oks.map(c => renderCheck(c, false))}
</div>
)}
</div>
)}
{/* Informativas: agrupadas, colapsadas por defecto */}
{infos.length>0&&(
<div style={{background:C.bgCard,border:`1px solid ${C.border}`,borderRadius:6,overflow:"hidden"}}>
<div onClick={()=>setShowInfoGroup(!showInfoGroup)}
style={{display:"flex",alignItems:"center",gap:8,padding:"10px 14px",cursor:"pointer"}}>
<span style={{fontSize:"0.8rem",color:C.blue,fontWeight:700}}>·</span>
<span style={{flex:1,fontSize:"0.7rem",color:C.t2,fontFamily:"monospace"}}>{infos.length} informativas esperadas en este tipo de transacción</span>
<span style={{color:C.t2,fontSize:"0.7rem"}}>{showInfoGroup?"▲":"▼"}</span>
</div>
{showInfoGroup&&(
<div style={{display:"flex",flexDirection:"column",gap:6,padding:"0 8px 8px 8px"}}>
{infos.map(c => renderCheck(c, false))}
</div>
)}
</div>
)}
</div>
);
})()}
<div style={{display:"flex",gap:12,marginTop:12,flexWrap:"wrap"}}>
{[{label:"CERTEZA",color:C.green,desc:"Hecho verificable on-chain"},{label:"PROBABLE",color:C.amber,desc:"Alta probabilidad"},{label:"POSIBLE",color:C.blue,desc:"Inferencia"}].map(l=>(
<div key={l.label} style={{display:"flex",alignItems:"center",gap:5}}><Badge color={l.color}>{l.label}</Badge><span style={{fontSize:"0.6rem",color:C.t2}}>{l.desc}</span></div>
))}
</div>
<div style={{display:"flex",gap:10,marginTop:8,flexWrap:"wrap"}}>
{[
{label:"ya en chain", color:C.red, desc:"Registrado en la cadena, no se puede cambiar"},
{label:"depende del wallet", color:C.purple, desc:"Lo determina tu software de wallet"},
{label:"puedes evitarlo", color:C.amber, desc:"Depende de cómo construyes la transacción"},
].map(l=>(
<div key={l.label} style={{display:"flex",alignItems:"center",gap:5}}>
<span style={{fontSize:"0.55rem",fontFamily:"monospace",padding:"2px 6px",borderRadius:3,background:"transparent",color:l.color,border:`1px solid ${l.color}40`}}>{l.label}</span>
<span style={{fontSize:"0.6rem",color:C.t2}}>{l.desc}</span>
</div>
))}
</div>
</div>
);
}
const MOCK_SYSTEM = {
cpu:{usage:14,temp:"53.0",cores:4,model:"Intel(R) Core(TM) i5-6500T CPU @ 2.50GHz"},
ram:{total_gb:"33.5",used_gb:"17.4",free_gb:"16.1",percent:52},
disk:{total:"1.8T",used:"1.1T",free:"653G",percent:"63%"},
load:{"1m":"1.80","5m":"1.84","15m":"2.01"},
uptime:"46d 0h 57m",
os:"Linux 6.8.0-107-generic"
};
const MOCK_BITCOIN = {
version:"/Satoshi:27.1.0/",blocks:951283,headers:951283,synced:true,progress:"99.99",
size_gb:"634.1",peers:12,inbound:4,outbound:8,uptime_sec:3974520,chain:"main"
};
function UsageBar({percent, color}) {
const c = color || (percent > 80 ? C.red : percent > 60 ? C.amber : C.green);
return (
<div style={{height:4,background:C.border,borderRadius:2,overflow:"hidden",marginTop:4}}>
<div style={{width:`${percent}%`,height:"100%",background:c,borderRadius:2,transition:"width 0.5s"}}/>
</div>
);
}
function NodeOverview({base}) {
const {get}=useApi(base);
const [mempoolData,setMempoolData]=useState(null);
const [sysData,setSysData]=useState(null);
const [btcData,setBtcData]=useState(null);
useEffect(()=>{
Promise.all([
get("/api/v1/fees/recommended",MOCK_FEES),
get("/api/mempool",MOCK_MEMPOOL),
get("/api/blocks/tip/height",MOCK_HEIGHT),
get("/api/v1/difficulty-adjustment",MOCK_DIFF),
]).then(([fees,mempool,height,diff])=>setMempoolData({fees,mempool,height,diff}));
get("/system/info",MOCK_SYSTEM).then(d=>setSysData(d));
get("/system/bitcoin",MOCK_BITCOIN).then(d=>setBtcData(d));
},[base]);
if(!mempoolData)return<Spinner/>;
const{fees,mempool,height,diff}=mempoolData;
return (
<div style={{display:"flex",flexDirection:"column",gap:12}}>
{!base&&<DemoBanner/>}
{/* Status bar */}
<Card glow={C.green} style={{padding:"14px 18px"}}>
<div style={{display:"flex",alignItems:"center",justifyContent:"space-between",marginBottom:12}}>
<div style={{display:"flex",alignItems:"center",gap:8}}>
<Pulse color={base?C.green:C.amber}/>
<span style={{fontFamily:"monospace",color:base?C.green:C.amber,fontSize:"0.78rem",fontWeight:700}}>{base?"NODO ACTIVO":"DEMO MODE"}</span>
</div>
<Badge color={C.green}>MAINNET</Badge>
</div>
<div style={{display:"grid",gridTemplateColumns:"repeat(auto-fill,minmax(130px,1fr))",gap:14,paddingTop:12,borderTop:`1px solid ${C.border}`}}>
<Tag label="Bloque actual" value={`#${fmt.num(height)}`} color={C.purple}/>
<Tag label="Txs mempool" value={fmt.num(mempool.count)} color={C.amber}/>
<Tag label="Tamaño mempool" value={fmt.mb(mempool.vsize)} color={C.t1}/>
<Tag label="Fee mínimo" value={`${fees.minimumFee} sat/vB`} color={C.blue}/>
<Tag label="Próx. bloque" value={`${fees.fastestFee} sat/vB`} color={C.amber}/>
<Tag label="Próx. ajuste" value={`${diff.remainingBlocks} bloques`} color={C.t1}/>
</div>
</Card>
{/* Fee cards */}
<div style={{display:"grid",gridTemplateColumns:"repeat(auto-fill,minmax(150px,1fr))",gap:8}}>
{[{label:"Próximo bloque",val:`${fees.fastestFee} sat/vB`,color:C.red},{label:"30 minutos",val:`${fees.halfHourFee} sat/vB`,color:C.amber},{label:"1 hora",val:`${fees.hourFee} sat/vB`,color:C.green},{label:"Sin urgencia",val:`${fees.economyFee} sat/vB`,color:C.t2}].map(f=>(
<Card key={f.label} style={{padding:"12px 14px"}}>
<div style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:5}}>{f.label}</div>
<div style={{fontSize:"1.1rem",color:f.color,fontFamily:"monospace",fontWeight:700}}>{f.val}</div>
</Card>
))}
</div>
{/* Bitcoin Core */}
<Card>
<div style={{fontSize:"0.62rem",color:C.amber,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.2em",marginBottom:12,display:"flex",alignItems:"center",gap:8}}>
<span></span> Bitcoin Core
<div style={{flex:1,height:1,background:`linear-gradient(to right,${C.amber}40,transparent)`}}/>
</div>
{!btcData ? <Spinner/> : (
<div style={{display:"grid",gridTemplateColumns:"repeat(auto-fill,minmax(130px,1fr))",gap:14}}>
<Tag label="Versión" value={btcData.version?.replace(/[/:]/g,"").replace("Satoshi","").trim()} color={C.green}/>
<Tag label="Red" value={btcData.chain==="main"?"mainnet":btcData.chain} color={C.green}/>
<Tag label="Peers" value={btcData.peers} color={C.blue}/>
<Tag label="Entrantes" value={btcData.inbound} color={C.t1}/>
<Tag label="Salientes" value={btcData.outbound} color={C.t1}/>
<Tag label="Sincronizado" value={btcData.synced?"Sí ✓":"No (" + btcData.progress + "%)"} color={btcData.synced?C.green:C.amber}/>
<Tag label="Blockchain" value={`${btcData.size_gb} GB`} color={C.t1}/>
<Tag label="Uptime" value={btcData.uptime_sec ? `${Math.floor(btcData.uptime_sec/86400)}d ${Math.floor((btcData.uptime_sec%86400)/3600)}h` : "—"} color={C.t1}/>
</div>
)}
</Card>
{/* Sistema */}
<Card>
<div style={{fontSize:"0.62rem",color:C.blue,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.2em",marginBottom:12,display:"flex",alignItems:"center",gap:8}}>
<span></span> Sistema
<div style={{flex:1,height:1,background:`linear-gradient(to right,${C.blue}40,transparent)`}}/>
</div>
{!sysData ? <Spinner/> : (
<div style={{display:"flex",flexDirection:"column",gap:12}}>
{/* CPU */}
<div>
<div style={{display:"flex",justifyContent:"space-between",marginBottom:6}}>
<span style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em"}}>CPU {sysData.cpu.model||""}</span>
<span style={{fontSize:"0.72rem",color:C.green,fontFamily:"monospace",fontWeight:600}}>{sysData.cpu.usage}% promedio · {sysData.cpu.temp?`${sysData.cpu.temp}°C`:""}</span>
</div>
{/* Per-core bars */}
{sysData.cpu.cores&&sysData.cpu.cores.length>0 ? (
<div style={{display:"grid",gridTemplateColumns:`repeat(${sysData.cpu.cores.length},1fr)`,gap:4}}>
{sysData.cpu.cores.map((c,i)=>(
<div key={i}>
<div style={{height:28,background:C.border,borderRadius:4,overflow:"hidden",position:"relative"}}>
<div style={{position:"absolute",bottom:0,left:0,right:0,height:`${c.usage}%`,background:`linear-gradient(to top,${c.usage>80?C.red:c.usage>60?C.amber:C.green}80,${c.usage>80?C.red:c.usage>60?C.amber:C.green})`,transition:"height 0.5s"}}/>
</div>
<div style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace",textAlign:"center",marginTop:2}}>{c.usage}%</div>
</div>
))}
</div>
) : (
<div style={{height:6,background:C.border,borderRadius:3,overflow:"hidden"}}>
<div style={{width:`${sysData.cpu.usage}%`,height:"100%",background:C.green,borderRadius:3}}/>
</div>
)}
</div>
{/* RAM + Swap */}
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:12}}>
<div>
<div style={{display:"flex",justifyContent:"space-between",marginBottom:3}}>
<span style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em"}}>RAM</span>
<span style={{fontSize:"0.72rem",color:sysData.ram.percent>80?C.red:sysData.ram.percent>60?C.amber:C.green,fontFamily:"monospace",fontWeight:600}}>{sysData.ram.percent}%</span>
</div>
<div style={{height:6,background:C.border,borderRadius:3,overflow:"hidden",marginBottom:4}}>
<div style={{width:`${sysData.ram.percent}%`,height:"100%",background:sysData.ram.percent>80?C.red:sysData.ram.percent>60?C.amber:C.green,borderRadius:3}}/>
</div>
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace"}}>{sysData.ram.used_gb} / {sysData.ram.total_gb} GB</div>
{sysData.ram.cached_gb&&<div style={{fontSize:"0.58rem",color:C.t3,fontFamily:"monospace"}}>caché: {sysData.ram.cached_gb} GB</div>}
</div>
<div>
<div style={{display:"flex",justifyContent:"space-between",marginBottom:3}}>
<span style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em"}}>SWAP</span>
<span style={{fontSize:"0.72rem",color:sysData.ram.swap_percent>80?C.red:sysData.ram.swap_percent>50?C.amber:C.t2,fontFamily:"monospace",fontWeight:600}}>{sysData.ram.swap_percent||0}%</span>
</div>
<div style={{height:6,background:C.border,borderRadius:3,overflow:"hidden",marginBottom:4}}>
<div style={{width:`${sysData.ram.swap_percent||0}%`,height:"100%",background:sysData.ram.swap_percent>80?C.red:C.amber,borderRadius:3}}/>
</div>
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace"}}>{sysData.ram.swap_used_gb||0} / {sysData.ram.swap_total_gb||0} GB</div>
</div>
</div>
{/* Disco */}
<div>
<div style={{display:"flex",justifyContent:"space-between",marginBottom:3}}>
<span style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em"}}>Disco</span>
<span style={{fontSize:"0.72rem",color:parseInt(sysData.disk?.percent)>80?C.red:parseInt(sysData.disk?.percent)>60?C.amber:C.green,fontFamily:"monospace",fontWeight:600}}>{sysData.disk?.percent}</span>
</div>
<div style={{height:6,background:C.border,borderRadius:3,overflow:"hidden",marginBottom:4}}>
<div style={{width:`${parseInt(sysData.disk?.percent)||0}%`,height:"100%",background:parseInt(sysData.disk?.percent)>80?C.red:parseInt(sysData.disk?.percent)>60?C.amber:C.green,borderRadius:3}}/>
</div>
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace"}}>{sysData.disk?.used} / {sysData.disk?.total}</div>
</div>
{/* Load + Uptime */}
<div style={{display:"grid",gridTemplateColumns:"repeat(auto-fill,minmax(130px,1fr))",gap:12,paddingTop:10,borderTop:`1px solid ${C.border}`}}>
<Tag label="Uptime sistema" value={sysData.uptime} color={C.green}/>
<Tag label="Carga 1m" value={sysData.load["1m"]} color={C.t1}/>
<Tag label="Carga 5m" value={sysData.load["5m"]} color={C.t1}/>
<Tag label="SO" value={sysData.os?.split(" ").slice(0,2).join(" ")} color={C.t2}/>
</div>
{/* Top processes */}
{sysData.processes&&sysData.processes.length>0&&(
<div style={{paddingTop:10,borderTop:`1px solid ${C.border}`}}>
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:8}}>Procesos destacados</div>
{sysData.processes.slice(0,5).map((p,i)=>(
<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>
<div style={{display:"flex",gap:8}}>
{/* 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>
</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>
)}
</Card>
{/* Dificultad */}
<Card>
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:10}}>Ajuste de Dificultad</div>
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:12}}>
<Tag label="Cambio estimado" value={`${diff.difficultyChange>0?"+":""}${Number(diff.difficultyChange).toFixed(2)}%`} color={diff.difficultyChange>0?C.red:C.green}/>
<Tag label="Tiempo promedio" value={`${diff.timeAvg?(diff.timeAvg/60000).toFixed(1):"-"} min`} color={C.blue}/>
<Tag label="Bloques restantes" value={fmt.num(diff.remainingBlocks)} color={C.t1}/>
</div>
</Card>
{/* Seguridad del nodo */}
{base && <NodeSecurityChecklist base={base} btcData={btcData} sysData={sysData}/>}
{/* Logs */}
{base && <LogsPanel base={base}/>}
</div>
);
}
function NodeSecurityChecklist({base, btcData, sysData}) {
const [open, setOpen] = useState(false);
const [checks, setChecks] = useState(null);
const [loading, setLoading] = useState(false);
const KNOWN_CORE_VERSION = "29.1.0"; // actualizar con cada release
const runChecks = async () => {
setLoading(true);
const results = [];
// ── 1. Versión de Bitcoin Core ────────────────────────────────
if (btcData?.version) {
const vMatch = btcData.version.match(/(?:Satoshi:|v)([\d.]+)/);
const version = vMatch ? vMatch[1] : btcData.version.replace(/[/v:]/g,"").replace("Satoshi","").trim();
const isLatest = version === KNOWN_CORE_VERSION;
results.push({
id:"core_version", label:"Versión de Bitcoin Core",
status: isLatest?"ok": "warn",
detail: isLatest
? `v${version} — versión actualizada.`
: `v${version} detectada. La última versión conocida es v${KNOWN_CORE_VERSION}. Considera actualizar.`,
});
}
// ── 2. Peers entrantes ────────────────────────────────────────
if (btcData?.inbound !== undefined) {
const inbound = btcData.inbound || 0;
results.push({
id:"inbound_peers", label:"Peers entrantes",
status: inbound > 0 ? "warn" : "ok",
detail: inbound > 0
? `${inbound} peers entrantes detectados. Tu IP puede ser visible en la red Bitcoin. Considera usar solo conexiones salientes o enrutar por Tor/I2P.`
: "Sin peers entrantes. Tu IP no está expuesta directamente en la red Bitcoin.",
});
}
// ── 3. Mempool accesible solo desde origen correcto ──────────
try {
// Intentar acceder a la API desde el mismo origen — si responde, está bien
const res = await fetchWithTimeout(`${base}/api/v1/fees/recommended`);
results.push({
id:"mempool_api", label:"API Mempool accesible",
status: res.ok ? "ok" : "warn",
detail: res.ok
? "La API de Mempool responde correctamente desde el dashboard."
: "La API de Mempool no responde como se esperaba.",
});
} catch(e) {
results.push({
id:"mempool_api", label:"API Mempool accesible",
status:"error",
detail:`No se puede conectar a la API: ${e.message}`,
});
}
// ── 4. txoko-metrics accesible ────────────────────────────────
try {
const res = await fetchWithTimeout(`${base}/system/health`);
const data = res.ok ? await res.json() : null;
results.push({
id:"metrics_service", label:"Servicio txoko-metrics",
status: res.ok ? "ok" : "warn",
detail: res.ok
? "txoko-metrics responde en localhost:4082 y está correctamente proxificado por nginx."
: "txoko-metrics no responde. Comprueba el servicio con: sudo systemctl status txoko-metrics",
});
} catch(e) {
results.push({
id:"metrics_service", label:"Servicio txoko-metrics",
status:"warn",
detail:"No se puede verificar el estado de txoko-metrics.",
});
}
// ── 5. Swap alto ──────────────────────────────────────────────
if (sysData?.swap) {
const pct = sysData.swap.percent || 0;
results.push({
id:"swap_usage", label:"Uso de SWAP",
status: pct > 95 ? "warn" : "ok",
detail: pct > 95
? `SWAP al ${pct}%. Fulcrum está usando casi toda la memoria de intercambio. Normal en nodos con índice completo, pero monitoriza que el OOM Killer no actúe.`
: `SWAP al ${pct}%. Uso normal.`,
});
}
// ── 6. Sincronización de Bitcoin Core ─────────────────────────
if (btcData) {
const synced = btcData.synced;
results.push({
id:"core_sync", label:"Sincronización de Bitcoin Core",
status: synced ? "ok" : "warn",
detail: synced
? "Bitcoin Core está completamente sincronizado con la red."
: `Sincronizando... ${btcData.progress || ""}% — el nodo no está operativo al 100% hasta completar la sincronización.`,
});
}
// ── 7. Acceso solo via Tailscale ─────────────────────────────
results.push({
id:"tailscale", label:"Acceso via Tailscale",
status:"ok",
detail:"El dashboard solo es accesible desde tu red Tailscale. No hay autenticación adicional — Tailscale es el perímetro de seguridad.",
});
setChecks(results);
setLoading(false);
};
useEffect(()=>{ if(btcData||sysData) runChecks(); },[btcData,sysData]);
const statusColor = s => s==="ok"?C.green:s==="warn"?C.amber:C.red;
const statusIcon = s => s==="ok"?"✓":s==="warn"?"⚠":"✗";
const overallStatus = checks
? checks.some(c=>c.status==="error")?"error":checks.some(c=>c.status==="warn")?"warn":"ok"
: null;
return (
<Card>
<div style={{display:"flex",alignItems:"center",justifyContent:"space-between",marginBottom: open?12:0,cursor:"pointer"}} onClick={()=>setOpen(!open)}>
<div style={{display:"flex",alignItems:"center",gap:8}}>
<span style={{fontSize:"0.62rem",color:C.blue,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.2em",fontWeight:700}}> Seguridad del nodo</span>
{overallStatus&&(
<span style={{fontSize:"0.62rem",fontFamily:"monospace",color:statusColor(overallStatus),fontWeight:700}}>
{overallStatus==="ok"?" — todo correcto":overallStatus==="warn"?" — revisar":" — error"}
</span>
)}
</div>
<div style={{display:"flex",alignItems:"center",gap:8}}>
{loading&&<span style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace"}}>verificando···</span>}
<span style={{color:C.t2,fontSize:"0.7rem"}}>{open?"▲":"▼"}</span>
</div>
</div>
{open&&(
<div style={{display:"flex",flexDirection:"column",gap:6}}>
{!checks&&!loading&&(
<div style={{fontSize:"0.68rem",color:C.t2,fontFamily:"monospace",padding:"8px 0"}}>Cargando verificaciones···</div>
)}
{checks&&checks.map(check=>(
<div key={check.id} style={{display:"flex",alignItems:"flex-start",gap:10,padding:"8px 10px",background:C.bg,borderRadius:6,border:`1px solid ${statusColor(check.status)}20`}}>
<span style={{fontSize:"0.8rem",color:statusColor(check.status),fontFamily:"monospace",fontWeight:700,flexShrink:0,marginTop:1}}>
{statusIcon(check.status)}
</span>
<div style={{flex:1}}>
<div style={{fontSize:"0.7rem",color:C.t1,fontFamily:"monospace",marginBottom:3}}>{check.label}</div>
<div style={{fontSize:"0.65rem",color:C.t2,lineHeight:1.6}}>{check.detail}</div>
</div>
<Badge color={statusColor(check.status)}>
{check.status==="ok"?"OK":check.status==="warn"?"REVISAR":"ERROR"}
</Badge>
</div>
))}
{checks&&(
<div style={{fontSize:"0.6rem",color:C.t3,fontFamily:"monospace",marginTop:4,paddingTop:8,borderTop:`1px solid ${C.border}`}}>
Este checklist verifica la configuración visible desde el dashboard. No sustituye una auditoría de seguridad completa del sistema.
</div>
)}
</div>
)}
</Card>
);
}
function LogsPanel({base}) {
const [service, setService] = useState("bitcoin");
const [filter, setFilter] = useState("all");
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(false);
const [lastUpdated, setLastUpdated] = useState(null);
const logsEndRef = React.useRef(null);
const fetchLogs = useCallback(async (svc) => {
const target = svc || service;
setLoading(true);
try {
const res = await fetchWithTimeout(`${base}/system/logs/${target}?lines=80`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setLogs(data.logs || []);
setLastUpdated(new Date().toLocaleTimeString("es-ES",{hour:"2-digit",minute:"2-digit",second:"2-digit"}));
} catch(e) {
setLogs([{line:`Error al cargar logs: ${e.message}`, level:"error"}]);
}
setLoading(false);
}, [base, service]);
// Carga inicial y al cambiar servicio — NO borramos logs antes de tener los nuevos
// para evitar la pantalla en blanco durante el fetch (hasta 8s de timeout)
useEffect(() => { fetchLogs(service); }, [service]);
// Auto-refresh cada 30s mientras estás en la pestaña NODO.
// El intervalo se cancela solo al cambiar de pestaña (componente desmontado).
useEffect(() => {
const interval = setInterval(() => {
if (!document.hidden) fetchLogs(service);
}, 30000);
return () => clearInterval(interval);
}, [service]);
const levelColor = l => l==="error"?C.red:l==="warn"?C.amber:C.t2;
const filtered = filter==="all" ? logs : logs.filter(l=>l.level===filter);
return (
<Card>
<div style={{display:"flex",alignItems:"center",justifyContent:"space-between",marginBottom:12,flexWrap:"wrap",gap:8}}>
<div style={{fontSize:"0.62rem",color:C.green,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.2em",display:"flex",alignItems:"center",gap:8}}>
<span></span> Logs en tiempo real
{loading && <span style={{color:C.t2,letterSpacing:"0.05em"}}>actualizando</span>}
</div>
<div style={{display:"flex",gap:6,flexWrap:"wrap"}}>
{/* Service selector */}
{["bitcoin","fulcrum"].map(s=>(
<button key={s} onClick={()=>setService(s)} style={{
padding:"4px 10px",borderRadius:4,border:`1px solid ${service===s?C.green+"60":C.border}`,
background:service===s?C.greenMuted:C.bgCard,
color:service===s?C.green:C.t2,
fontFamily:"monospace",fontSize:"0.68rem",cursor:"pointer",
}}>{s}</button>
))}
{/* Level filter */}
{["all","warn","error"].map(f=>(
<button key={f} onClick={()=>setFilter(f)} style={{
padding:"4px 10px",borderRadius:4,
border:`1px solid ${filter===f?(f==="error"?C.red:f==="warn"?C.amber:C.blue)+"60":C.border}`,
background:filter===f?(f==="error"?C.redMuted:f==="warn"?C.amberMuted:C.blueMuted):"none",
color:filter===f?(f==="error"?C.red:f==="warn"?C.amber:C.blue):C.t2,
fontFamily:"monospace",fontSize:"0.68rem",cursor:"pointer",
}}>{f==="all"?"todos":f}</button>
))}
<button onClick={()=>fetchLogs(service)} style={{
padding:"4px 10px",borderRadius:4,border:`1px solid ${C.border}`,
background:"none",color:C.t2,fontFamily:"monospace",fontSize:"0.68rem",cursor:"pointer",
}}>{loading?"···":"↻"}</button>
</div>
</div>
{/* Log terminal */}
<div style={{
background:C.bg,borderRadius:6,border:`1px solid ${C.border}`,
height:280,overflowY:"auto",padding:"10px 12px",
fontFamily:"monospace",fontSize:"0.65rem",lineHeight:1.7,
opacity: loading ? 0.6 : 1, transition:"opacity 0.2s",
}}>
{!loading && filtered.length===0 && <div style={{color:C.t2}}>Sin entradas{filter!=="all"?` de nivel "${filter}"`:""}.</div>}
{filtered.map((entry,i)=>(
<div key={i} style={{color:levelColor(entry.level),borderBottom:`1px solid ${C.border}20`,paddingBottom:2,marginBottom:2,wordBreak:"break-all"}}>
{entry.level==="error"&&<span style={{color:C.red,marginRight:6}}></span>}
{entry.level==="warn"&&<span style={{color:C.amber,marginRight:6}}></span>}
{entry.line}
</div>
))}
<div ref={logsEndRef}/>
</div>
<div style={{fontSize:"0.6rem",color:C.t3,fontFamily:"monospace",marginTop:6,display:"flex",justifyContent:"space-between",flexWrap:"wrap",gap:4}}>
<span>{filtered.length} entradas · auto-refresh 30s</span>
{lastUpdated && <span>última actualización: {lastUpdated}</span>}
</div>
</Card>
);
}
function BlockExplorer({base}) {
const {get}=useApi(base);
const [blocks,setBlocks]=useState([]);
const [selected,setSelected]=useState(null);
const [blockTxs,setBlockTxs]=useState(null);
const [loading,setLoading]=useState(true);
useEffect(()=>{ setLoading(true); get("/api/v1/blocks",MOCK_BLOCKS).then(b=>{setBlocks(b);setLoading(false);}); },[base]);
const selectBlock=async(b)=>{ setSelected(b); setBlockTxs(null); const txs=await get(`/api/block/${b.id}/txs/0`,[MOCK_TX]); setBlockTxs(Array.isArray(txs)?txs:[txs]); };
if(loading)return<Spinner/>;
return (
<div style={{display:"flex",flexDirection:"column",gap:6}}>
{!base&&<DemoBanner/>}
<SectionTitle accent={C.purple} icon="◈">Bloques Recientes</SectionTitle>
{blocks.map((b,i)=>(
<div key={b.id||i} onClick={()=>selectBlock(b)} style={{display:"grid",gridTemplateColumns:"90px 1fr auto",alignItems:"center",gap:12,padding:"10px 14px",borderRadius:6,cursor:"pointer",background:selected&&selected.id===b.id?C.purpleMuted:C.bgCard,border:`1px solid ${selected&&selected.id===b.id?C.purple+"50":C.border}`}}
onMouseEnter={e=>e.currentTarget.style.background=C.bgHover} onMouseLeave={e=>e.currentTarget.style.background=selected&&selected.id===b.id?C.purpleMuted:C.bgCard}>
<div><div style={{fontSize:"0.88rem",fontFamily:"monospace",color:C.purple,fontWeight:700}}>#{fmt.num(b.height)}</div><div style={{fontSize:"0.62rem",color:C.t2,marginTop:2}}>{fmt.ago(b.timestamp)}</div></div>
<div><div style={{fontSize:"0.68rem",fontFamily:"monospace",color:C.t2,marginBottom:4}}>{fmt.hash(b.id)}</div><div style={{display:"flex",gap:6,flexWrap:"wrap",alignItems:"center"}}><Badge color={C.blue}>{fmt.num(b.tx_count)} txs</Badge><Badge color={C.t3}>{fmt.mb(b.size)}</Badge>{b.extras&&b.extras.pool&&<span style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace"}}>{b.extras.pool.name}</span>}</div></div>
<div style={{textAlign:"right"}}>{b.extras&&b.extras.totalFees!=null&&<><div style={{fontSize:"0.7rem",color:C.amber,fontFamily:"monospace"}}>{fmt.sbtc(b.extras.totalFees)}</div><div style={{fontSize:"0.58rem",color:C.t2}}>fees</div></>}</div>
</div>
))}
{selected&&(
<Card style={{marginTop:8}} glow={C.purple}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:12}}>
<span style={{fontFamily:"monospace",color:C.purple,fontWeight:700}}>Bloque #{fmt.num(selected.height)}</span>
<button onClick={()=>{setSelected(null);setBlockTxs(null);}} style={{background:"none",border:"none",color:C.t2,cursor:"pointer",fontSize:"1rem",fontFamily:"monospace"}}></button>
</div>
<div style={{fontSize:"0.68rem",fontFamily:"monospace",color:C.t2,wordBreak:"break-all",marginBottom:12}}>{selected.id}</div>
<div style={{display:"grid",gridTemplateColumns:"repeat(auto-fill,minmax(130px,1fr))",gap:12,paddingTop:12,borderTop:`1px solid ${C.border}`,marginBottom:16}}>
<Tag label="Transacciones" value={fmt.num(selected.tx_count)} color={C.blue}/>
<Tag label="Tamaño" value={fmt.mb(selected.size)} color={C.t1}/>
<Tag label="Fees totales" value={selected.extras&&selected.extras.totalFees!=null?fmt.sbtc(selected.extras.totalFees):"-"} color={C.amber}/>
<Tag label="Fee mediana" value={selected.extras&&selected.extras.medianFee!=null?`${selected.extras.medianFee} sat/vB`:"-"} color={C.amber}/>
<Tag label="Minero" value={selected.extras&&selected.extras.pool?selected.extras.pool.name:"-"} color={C.green}/>
<Tag label="Minado" value={fmt.date(selected.timestamp)} color={C.t1}/>
</div>
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",marginBottom:8}}>Primeras Transacciones</div>
{!blockTxs&&<Spinner/>}
{blockTxs&&blockTxs.slice(0,5).map((tx,i)=>(
<div key={i} style={{display:"flex",justifyContent:"space-between",alignItems:"center",padding:"6px 0",borderBottom:`1px solid ${C.border}`}}>
<span style={{fontSize:"0.68rem",fontFamily:"monospace",color:C.blue}}>{fmt.hash(tx.txid)}</span>
<div style={{display:"flex",gap:6}}><Badge color={C.amber}>{tx.fee} sat</Badge><Badge color={C.t3}>{tx.size}B</Badge></div>
</div>
))}
</Card>
)}
</div>
);
}
const MOCK_FEE_HISTORY = Array.from({length:168}, (_,i) => ({
timestamp: Math.floor(Date.now()/1000) - (167-i)*3600,
avgFee_50: Math.max(1, Math.round(2 + Math.sin(i/8)*3 + Math.sin(i/24)*8 + Math.random()*2)),
}));
function FeeChart({data, isMobile}) {
if (!data || data.length === 0) return null;
const W = 600, H = 120, PAD = {top:10, right:10, bottom:24, left:32};
const plotW = W - PAD.left - PAD.right;
const plotH = H - PAD.top - PAD.bottom;
// Use avgFee_50 (median) as the fee value
const fees = data.map(d => d.avgFee_50 || d.avgFee || 0).filter(v => v > 0);
if (fees.length === 0) return null;
const maxFee = Math.max(...fees);
const minFee = Math.min(...fees);
const range = maxFee - minFee || 1;
const x = i => PAD.left + (i / (data.length-1)) * plotW;
const y = v => PAD.top + plotH - ((v - minFee) / range) * plotH;
const points = data.map((d,i) => `${x(i)},${y(d.avgFee_50||d.avgFee||minFee)}`);
const linePath = `M${points.join("L")}`;
const areaPath = `M${PAD.left},${PAD.top+plotH}L${points.join("L")}L${PAD.left+plotW},${PAD.top+plotH}Z`;
// X labels — menos etiquetas en móvil para que no se solapen
const labelCount = isMobile ? 4 : 7;
const step = Math.max(1, Math.floor(data.length/labelCount));
const xLabels = [];
for (let i=0; i<data.length; i+=step) {
const d = new Date(data[i].timestamp*1000);
xLabels.push({ i, label:`${d.getDate()}/${d.getMonth()+1}` });
}
const yLabels = [minFee, Math.round((minFee+maxFee)/2), maxFee];
return (
<svg viewBox={`0 0 ${W} ${H}`} style={{width:"100%",height:"auto",display:"block"}}>
<defs>
<linearGradient id="feeGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={C.amber} stopOpacity="0.3"/>
<stop offset="100%" stopColor={C.amber} stopOpacity="0"/>
</linearGradient>
</defs>
{yLabels.map((v,i) => (
<line key={i} x1={PAD.left} y1={y(v)} x2={PAD.left+plotW} y2={y(v)} stroke={C.border} strokeWidth="1"/>
))}
<path d={areaPath} fill="url(#feeGrad)"/>
<path d={linePath} fill="none" stroke={C.amber} strokeWidth="1.5" strokeLinejoin="round"/>
{yLabels.map((v,i) => (
<text key={i} x={PAD.left-4} y={y(v)+4} textAnchor="end" fill={C.t2} fontSize="9" fontFamily="monospace">{v}</text>
))}
{xLabels.map(({i,label}) => (
<text key={i} x={x(i)} y={H-4} textAnchor="middle" fill={C.t2} fontSize="9" fontFamily="monospace">{label}</text>
))}
</svg>
);
}
function MempoolView({base}) {
const {get}=useApi(base);
const isMobile=useIsMobile();
const [data,setData]=useState(null);
const [fees,setFees]=useState(null);
const [histo,setHisto]=useState(null);
const [feeHistory,setFeeHistory]=useState(null);
const [period,setPeriod]=useState("1w");
useEffect(()=>{
Promise.all([get("/api/mempool",MOCK_MEMPOOL),get("/api/v1/fees/recommended",MOCK_FEES),get("/api/v1/fees/mempool-blocks",MOCK_MEMPOOLBLOCKS)])
.then(([m,f,h])=>{setData(m);setFees(f);setHisto(h);});
get("/api/v1/mining/blocks/fee-rates/1w", MOCK_FEE_HISTORY).then(d=>setFeeHistory(d));
},[base]);
if(!data||!fees)return<Spinner/>;
// Stats from fee history
const feeStats = feeHistory ? (() => {
const fs = feeHistory.map(d=>d.avgFee_50||d.avgFee||0).filter(v=>v>0);
if (fs.length===0) return null;
return {
min: Math.min(...fs),
max: Math.max(...fs),
avg: Math.round(fs.reduce((a,b)=>a+b,0)/fs.length),
};
})() : null;
return (
<div style={{display:"flex",flexDirection:"column",gap:12}}>
{!base&&<DemoBanner/>}
<SectionTitle accent={C.amber} icon="◉">Mempool</SectionTitle>
<div style={{display:"grid",gridTemplateColumns:`repeat(auto-fill,minmax(${isMobile?"110px":"140px"},1fr))`,gap:8}}>
{[{label:"Transacciones",val:fmt.num(data.count),color:C.amber},{label:"vSize total",val:fmt.mb(data.vsize),color:C.blue},{label:"Fee total",val:fmt.sbtc(data.total_fee),color:C.green}].map(s=>(
<Card key={s.label} style={{padding:"12px 14px"}}><div style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:5}}>{s.label}</div><div style={{fontSize:"0.95rem",color:s.color,fontFamily:"monospace",fontWeight:700}}>{s.val}</div></Card>
))}
</div>
<Card>
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:12}}>Fees Recomendados</div>
<div style={{display:"grid",gridTemplateColumns:`repeat(auto-fill,minmax(${isMobile?"105px":"130px"},1fr))`,gap:10}}>
{[{label:"Próx. bloque",val:`${fees.fastestFee} sat/vB`,color:C.red},{label:"30 min",val:`${fees.halfHourFee} sat/vB`,color:C.amber},{label:"1 hora",val:`${fees.hourFee} sat/vB`,color:C.green},{label:"Económico",val:`${fees.economyFee} sat/vB`,color:C.t2},{label:"Mínimo",val:`${fees.minimumFee} sat/vB`,color:C.t3}].map(f=>(
<div key={f.label} style={{padding:"10px 12px",background:C.bg,borderRadius:6,border:`1px solid ${C.border}`}}><div style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:4}}>{f.label}</div><div style={{fontSize:"0.88rem",color:f.color,fontFamily:"monospace",fontWeight:700}}>{f.val}</div></div>
))}
</div>
</Card>
{/* Fee Intelligence */}
<Card>
<div style={{display:"flex",flexDirection:isMobile?"column":"row",alignItems:isMobile?"flex-start":"center",justifyContent:"space-between",gap:isMobile?8:0,marginBottom:12}}>
<div style={{fontSize:"0.62rem",color:C.amber,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:isMobile?"0.1em":"0.2em",display:"flex",alignItems:"center",gap:8}}>
<span></span> Fee Intelligence — Última semana
</div>
{feeStats&&(
<div style={{display:"flex",gap:8,flexWrap:"wrap"}}>
<Badge color={C.green}>mín {feeStats.min} sat/vB</Badge>
<Badge color={C.amber}>media {feeStats.avg} sat/vB</Badge>
<Badge color={C.red}>máx {feeStats.max} sat/vB</Badge>
</div>
)}
</div>
{!feeHistory ? <Spinner/> : <FeeChart data={feeHistory} isMobile={isMobile}/>}
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",marginTop:8}}>
sat/vB por hora · Los fines de semana suelen tener fees más bajos
</div>
</Card>
{histo&&histo.length>0&&(
<Card>
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:12}}>Bloques Proyectados</div>
{histo.slice(0,4).map((block,i)=>{
const feeRange = block.feeRange || [];
const maxFee = Math.round(Math.max(...feeRange, 1));
const minFee = Math.round(Math.min(...feeRange, 1));
return <div key={i} style={{display:"flex",justifyContent:"space-between",alignItems:"center",flexWrap:"wrap",gap:6,padding:"8px 0",borderBottom:i<3?`1px solid ${C.border}`:"none"}}><div><span style={{fontSize:"0.7rem",color:C.purple,fontFamily:"monospace",fontWeight:700}}>+{i+1} bloque{i>0?"s":""}</span><span style={{fontSize:"0.62rem",color:C.t2,marginLeft:8,fontFamily:"monospace"}}>{fmt.num(block.nTx)} txs</span></div><div style={{display:"flex",gap:6,flexWrap:"wrap"}}><Badge color={C.amber}>{minFee}{maxFee} sat/vB</Badge><Badge color={C.t3}>{fmt.mb(block.blockVSize)}</Badge></div></div>;
})}
</Card>
)}
</div>
);
}
function Lab({base}) {
const {get}=useApi(base);
const [query,setQuery]=useState("");
const [result,setResult]=useState({type:"tx",data:MOCK_TX});
const [analysis,setAnalysis]=useState(null);
const [loading,setLoading]=useState(false);
const [err,setErr]=useState(null);
useEffect(()=>{setAnalysis(analyzeTx(MOCK_TX));},[]);
const search=useCallback(async(q)=>{
q=(q||query).trim(); if(!q)return;
setLoading(true);setResult(null);setAnalysis(null);setErr(null);
if(!base){
setTimeout(()=>{
if(/^[0-9a-f]{64}$/i.test(q)){setResult({type:"tx",data:MOCK_TX});setAnalysis(analyzeTx(MOCK_TX));}
else if(/^\d+$/.test(q)){setResult({type:"block",data:MOCK_BLOCKS[0]});}
else{setResult({type:"address",data:MOCK_ADDRESS});setAnalysis(analyzeAddress(MOCK_ADDRESS));}
setLoading(false);
},400); return;
}
try{
if(/^[0-9a-f]{64}$/i.test(q)){
try{ const tx=await get(`/api/tx/${q}`,null); if(tx){setResult({type:"tx",data:tx});setAnalysis(analyzeTx(tx));} else throw new Error(); }
catch{ const block=await get(`/api/block/${q}`,null); setResult({type:"block",data:block}); }
} else if(/^\d+$/.test(q)&&parseInt(q)<1000000){
const hash=await get(`/api/block-height/${q}`,null);
const block=await get(`/api/block/${hash}`,null);
setResult({type:"block",data:block});
} else {
const [info,txs,utxos]=await Promise.all([get(`/api/address/${q}`,null),get(`/api/address/${q}/txs`,[]),get(`/api/address/${q}/utxo`,[])]);
const addrData={...info,txs,utxos}; setResult({type:"address",data:addrData}); setAnalysis(analyzeAddress(addrData));
}
}catch(e){setErr(`No encontrado: ${e.message}`);}
setLoading(false);
},[query,base,get]);
return (
<div style={{display:"flex",flexDirection:"column",gap:12}}>
{!base&&<DemoBanner/>}
<SectionTitle accent={C.purple} icon="⌕">LAB Explorador on-chain</SectionTitle>
<div style={{display:"flex",gap:8}}>
<input value={query} onChange={e=>setQuery(e.target.value)} onKeyDown={e=>e.key==="Enter"&&search()}
placeholder="txid · dirección · altura · block hash"
style={{flex:1,background:C.bgCard,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"11px 14px",color:C.t1,fontFamily:"monospace",fontSize:"0.78rem",outline:"none"}}
onFocus={e=>e.target.style.borderColor=C.purple} onBlur={e=>e.target.style.borderColor=C.borderBright}
/>
<button onClick={()=>search()} style={{padding:"11px 20px",background:C.purpleMuted,border:`1px solid ${C.purple}40`,borderRadius:6,color:C.purple,fontFamily:"monospace",fontSize:"0.75rem",cursor:"pointer",fontWeight:700}}>
{loading?"···":"BUSCAR"}
</button>
</div>
{err&&<div style={{padding:"10px 14px",background:C.redMuted,border:`1px solid ${C.red}30`,borderRadius:6,color:C.red,fontFamily:"monospace",fontSize:"0.75rem"}}>Error: {err}</div>}
{loading&&<Spinner/>}
{result&&result.type==="tx"&&<TxResult tx={result.data} onAnalyze={q=>{window.__setTab&&window.__setTab("auditoria",q);}}/>}
{result&&result.type==="address"&&<AddressResult addr={result.data} analysis={analysis}/>}
{result&&result.type==="block"&&<BlockResult block={result.data}/>}
<UTXOMap base={base}/>
</div>
);
}
function TxVerifier({base}) {
const {get}=useApi(base);
const [txid,setTxid]=useState("");
const [result,setResult]=useState(null);
const [loading,setLoading]=useState(false);
const [err,setErr]=useState(null);
const verify=async()=>{
const q=txid.trim();
if(!q||!/^[0-9a-f]{64}$/i.test(q)){setErr("Introduce un txid válido (64 caracteres hex)");return;}
setLoading(true);setResult(null);setErr(null);
try{
const tx=await get(`/api/tx/${q}`,null);
if(!tx)throw new Error("No encontrada");
const height=await get("/api/blocks/tip/height",MOCK_HEIGHT);
const confs=tx.status?.confirmed?(height-tx.status.block_height+1):0;
setResult({tx,confs,height});
}catch(e){setErr(`No encontrada en el nodo: ${e.message}`);}
setLoading(false);
};
const statusColor = result
? result.confs===0 ? C.amber : result.confs<6 ? C.blue : C.green
: C.t2;
const statusLabel = result
? result.confs===0 ? "EN MEMPOOL" : result.confs<6 ? `${result.confs} CONFIRMACIONES` : `${fmt.num(result.confs)} CONFIRMACIONES ✓`
: "";
return (
<Card style={{marginBottom:4}}>
<div style={{fontSize:"0.62rem",color:C.green,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.2em",marginBottom:12,display:"flex",alignItems:"center",gap:8}}>
<span></span> Verificar Transacción Propia
<div style={{flex:1,height:1,background:`linear-gradient(to right,${C.green}40,transparent)`}}/>
</div>
<div style={{display:"flex",gap:8,marginBottom:err||result?"10px":"0"}}>
<input value={txid} onChange={e=>setTxid(e.target.value)} onKeyDown={e=>e.key==="Enter"&&verify()}
placeholder="Pega aquí tu txid para verificar"
style={{flex:1,background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"9px 12px",color:C.t1,fontFamily:"monospace",fontSize:"0.75rem",outline:"none"}}
onFocus={e=>e.target.style.borderColor=C.green} onBlur={e=>e.target.style.borderColor=C.borderBright}
/>
<button onClick={verify} style={{padding:"9px 16px",background:C.greenMuted,border:`1px solid ${C.green}40`,borderRadius:6,color:C.green,fontFamily:"monospace",fontSize:"0.72rem",cursor:"pointer",fontWeight:700}}>
{loading?"···":"VERIFICAR"}
</button>
</div>
{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>}
{result&&(
<div style={{padding:"12px 14px",background:C.bg,borderRadius:6,border:`1px solid ${statusColor}40`}}>
<div style={{display:"flex",alignItems:"center",justifyContent:"space-between",marginBottom:10}}>
<span style={{fontSize:"0.68rem",fontFamily:"monospace",color:C.t2,wordBreak:"break-all"}}>{fmt.hash(result.tx.txid)}</span>
<Badge color={statusColor}>{statusLabel}</Badge>
</div>
<div style={{display:"grid",gridTemplateColumns:"repeat(auto-fill,minmax(120px,1fr))",gap:10}}>
<Tag label="Estado" value={result.confs===0?"Pendiente":"Confirmada"} color={statusColor}/>
<Tag label="Bloque" value={result.tx.status?.block_height?`#${fmt.num(result.tx.status.block_height)}`:"mempool"} color={C.purple}/>
<Tag label="Fee pagado" value={result.tx.fee?`${fmt.num(result.tx.fee)} sat`:"-"} color={C.amber}/>
<Tag label="Tasa fee" value={result.tx.fee&&result.tx.weight?`${(result.tx.fee/Math.ceil(result.tx.weight/4)).toFixed(1)} sat/vB`:result.tx.feerate?`${Number(result.tx.feerate).toFixed(1)} sat/vB`:"-"} color={C.amber}/>
<Tag label="Confirmado" value={result.tx.status?.block_time?fmt.date(result.tx.status.block_time):"-"} color={C.t1}/>
<Tag label="vSize" value={result.tx.weight?`${Math.ceil(result.tx.weight/4)} vB`:"-"} color={C.t1}/>
</div>
</div>
)}
</Card>
);
}
function UTXOMap({base}) {
const {get}=useApi(base);
const [addr,setAddr]=useState("");
const [utxos,setUtxos]=useState(null);
const [txCache,setTxCache]=useState({});
const [loading,setLoading]=useState(false);
const [err,setErr]=useState(null);
const MAX_UTXOS = 25; // límite para no estresar el nodo
const MOCK_UTXOS=[
{txid:"abc1def2abc1def2abc1def2abc1def2abc1def2abc1def2abc1def2abc1def2",vout:0,value:10000000,status:{confirmed:true,block_height:846089,block_time:1713000000}},
{txid:"123a456b123a456b123a456b123a456b123a456b123a456b123a456b123a456b",vout:1,value:4000000,status:{confirmed:true,block_height:846911,block_time:1713200000}},
{txid:"789c012d789c012d789c012d789c012d789c012d789c012d789c012d789c012d",vout:0,value:823000,status:{confirmed:false}},
{txid:"aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd",vout:2,value:50000000,status:{confirmed:true,block_height:840000,block_time:1710000000}},
{txid:"11223344112233441122334411223344112233441122334411223344aabbcc11",vout:0,value:1500000,status:{confirmed:true,block_height:950000,block_time:1716000000}},
];
// Calcular score de privacidad por UTXO
const getUtxoPrivacy = (utxo, originTx) => {
const issues = [];
let score = 100;
// Sin confirmar
if (!utxo.status?.confirmed) {
issues.push({label:"Sin confirmar", color:C.amber, weight:10});
score -= 10;
}
// Dust — umbral conservador (546): un UTXO futuro podría gastarse como cualquier tipo
if (utxo.value < 546) {
issues.push({label:"Dust — trátalo con cuidado (posible linking)", color:C.red, weight:30});
score -= 30;
}
// Alto valor — más visible on-chain
if (utxo.value > 10000000) {
issues.push({label:"Alto valor — destacado on-chain", color:C.blue, weight:5});
score -= 5;
}
if (originTx) {
// Origen: batch payment (exchange/custodio)
const outCount = originTx.vout?.length || 0;
const inCount = originTx.vin?.length || 0;
if (outCount >= 5) {
issues.push({label:"Origen: batch payment (exchange?)", color:C.amber, weight:15});
score -= 15;
}
// Origen: consolidación de muchos inputs
if (inCount >= 5) {
issues.push({label:"Origen: consolidación de inputs", color:C.amber, weight:10});
score -= 10;
}
// Origen: CoinJoin
const vals = originTx.vout?.map(v=>v.value)||[];
const maxEq = vals.length>0 ? Math.max(...Object.values(vals.reduce((m,v)=>{m[v]=(m[v]||0)+1;return m;},{}))) : 0;
if (inCount>=5 && outCount>=5 && maxEq>=Math.floor(outCount*0.5)) {
issues.push({label:"Origen: posible CoinJoin ✓", color:C.green, weight:-20});
score += 20; // positivo
}
// RBF en tx de origen
const hasRbf = originTx.vin?.some(v=>typeof v.sequence==="number"&&v.sequence<0xfffffffe);
if (hasRbf) {
issues.push({label:"Tx origen con RBF", color:C.amber, weight:5});
score -= 5;
}
}
score = Math.max(0, Math.min(100, score));
const color = score >= 70 ? C.green : score >= 40 ? C.amber : C.red;
return { score, issues, color };
};
// Detectar patrones temporales del conjunto de UTXOs
const getTemporalPattern = (utxoList) => {
const times = utxoList
.filter(u=>u.status?.block_time)
.map(u=>new Date(u.status.block_time*1000).getUTCHours());
if (times.length < 3) return null;
const avg = times.reduce((a,b)=>a+b,0)/times.length;
const zone = avg>=6&&avg<=10?"mañana europea (6-10 UTC)":
avg>=14&&avg<=18?"tarde europea / mañana americana (14-18 UTC)":
avg>=22||avg<=2?"madrugada Europa / tarde América":null;
return zone ? `Patrón horario detectado: la mayoría de transacciones ocurren en ${zone}.` : null;
};
const fetchUtxos=async()=>{
const q=addr.trim();
if(!q){setErr("Introduce una dirección Bitcoin");return;}
setLoading(true);setUtxos(null);setErr(null);setTxCache({});
try{
if(!base){
setTimeout(()=>{setUtxos(MOCK_UTXOS);setLoading(false);},400); return;
}
// Obtener UTXOs
let data=null;
try{
const res=await fetchWithTimeout(`${base}/api/address/${q}/utxo`);
if(res.ok){ const json=await res.json(); if(Array.isArray(json)&&json.length>0) data=json; }
}catch{}
if(!data){
const txRes=await fetchWithTimeout(`${base}/api/v1/address/${q}/txs`);
if(!txRes.ok) throw new Error(`HTTP ${txRes.status}`);
const txs=await txRes.json();
const spentTxids=new Set();
txs.forEach(tx=>tx.vin.forEach(v=>{ if(v.txid) spentTxids.add(`${v.txid}:${v.vout}`); }));
const outputs=[];
txs.forEach(tx=>{
tx.vout.forEach((out,i)=>{
if(out.scriptpubkey_address===q&&!spentTxids.has(`${tx.txid}:${i}`)){
outputs.push({txid:tx.txid,vout:i,value:out.value,status:tx.status});
}
});
});
data=outputs;
}
if(!data||data.length===0){
setErr("No se encontraron UTXOs para esta dirección — puede que todos estén gastados");
setLoading(false);return;
}
const sorted=[...data].sort((a,b)=>b.value-a.value);
const limited=sorted.slice(0,MAX_UTXOS);
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)
// 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 toFetch=limited.slice(0,10);
await Promise.all(toFetch.map(async u=>{
const t = await get(`/api/tx/${u.txid}`, null);
if (t) cache[u.txid] = t;
}));
setTxCache(cache);
setUtxos(limited);
}catch(e){ const msg = e.name==="AbortError" ? "El nodo tardó demasiado en responder (timeout). Puede estar ocupado — inténtalo de nuevo en un momento." : `Error: ${e.message}`; setErr(msg); }
setLoading(false);
};
const totalBtc = utxos ? utxos.reduce((s,u)=>s+u.value,0) : 0;
const maxVal = utxos ? Math.max(...utxos.map(u=>u.value)) : 1;
const temporalPattern = utxos ? getTemporalPattern(utxos) : null;
const privacyScores = utxos ? utxos.map(u=>getUtxoPrivacy(u,txCache[u.txid])) : [];
const avgPrivacy = privacyScores.length>0
? Math.round(privacyScores.reduce((s,p)=>s+p.score,0)/privacyScores.length) : null;
// ── Exportar UTXOs a CSV ──────────────────────────────────────────
// Genera el archivo en el navegador y lo descarga en local.
// No pasa por ningún servidor, no sale nada del nodo.
const exportUtxosCSV = () => {
if(!utxos || utxos.length===0) return;
// Escapar campos: si llevan coma, comilla o salto, van entre comillas
const esc = (val) => {
const s = String(val ?? "");
return /[",\n]/.test(s) ? '"' + s.replace(/"/g,'""') + '"' : s;
};
const cabeceras = ["txid","vout","valor_sats","valor_btc","confirmado","altura_bloque","privacidad_score","problemas"];
const filas = utxos.map((u,i) => {
const priv = privacyScores[i] || {score:"", issues:[]};
return [
u.txid,
u.vout,
u.value,
(u.value/1e8).toFixed(8),
u.status?.confirmed ? "si" : "no",
u.status?.confirmed ? (u.status.block_height ?? "") : "mempool",
priv.score,
(priv.issues || []).join("; "),
].map(esc).join(",");
});
const csv = [cabeceras.join(","), ...filas].join("\n");
// BOM para que Excel abra los acentos bien + descarga vía Blob
const blob = new Blob(["\uFEFF" + csv], {type:"text/csv;charset=utf-8"});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
const fecha = new Date().toISOString().slice(0,10);
const addrCorto = (addr || "utxos").slice(0,12);
a.href = url;
a.download = `txoko-utxos-${addrCorto}-${fecha}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
return(
<Card>
<div style={{fontSize:"0.62rem",color:C.purple,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.2em",marginBottom:8,display:"flex",alignItems:"center",gap:8}}>
<span></span> UTXO Map
<div style={{flex:1,height:1,background:`linear-gradient(to right,${C.purple}40,transparent)`}}/>
{utxos&&utxos.length>0&&(
<button onClick={exportUtxosCSV} title="Descargar los UTXOs en CSV (local, no sale del nodo)"
style={{padding:"4px 10px",background:C.bg,border:`1px solid ${C.purple}40`,borderRadius:5,color:C.purple,fontFamily:"monospace",fontSize:"0.6rem",letterSpacing:"0.08em",cursor:"pointer",whiteSpace:"nowrap"}}
onMouseEnter={e=>e.currentTarget.style.borderColor=C.purple}
onMouseLeave={e=>e.currentTarget.style.borderColor=C.purple+"40"}>
CSV
</button>
)}
</div>
<div style={{fontSize:"0.65rem",color:C.t2,marginBottom:12,lineHeight:1.5}}>
Analiza los UTXOs de una dirección con score de privacidad por UTXO y contexto de origen. Máx. {MAX_UTXOS} UTXOs.
</div>
<div style={{display:"flex",gap:8,marginBottom:10}}>
<input value={addr} onChange={e=>setAddr(e.target.value)} onKeyDown={e=>e.key==="Enter"&&fetchUtxos()}
placeholder="Dirección Bitcoin (bc1q..., bc1p..., 1..., 3...)"
style={{flex:1,background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"9px 12px",color:C.t1,fontFamily:"monospace",fontSize:"0.75rem",outline:"none"}}
onFocus={e=>e.target.style.borderColor=C.purple} onBlur={e=>e.target.style.borderColor=C.borderBright}
/>
<button onClick={fetchUtxos} style={{padding:"9px 16px",background:C.purpleMuted,border:`1px solid ${C.purple}40`,borderRadius:6,color:C.purple,fontFamily:"monospace",fontSize:"0.72rem",cursor:"pointer",fontWeight:700}}>
{loading?"···":"MAPEAR"}
</button>
</div>
{err&&<div style={{padding:"8px 12px",background:C.amberMuted,border:`1px solid ${C.amber}30`,borderRadius:6,color:C.amber,fontFamily:"monospace",fontSize:"0.72rem",marginBottom:8}}>{err}</div>}
{loading&&<Spinner/>}
{utxos&&utxos.length>0&&(
<div style={{display:"flex",flexDirection:"column",gap:10}}>
{/* Summary */}
<div style={{display:"grid",gridTemplateColumns:"repeat(auto-fill,minmax(120px,1fr))",gap:8,padding:"10px 14px",background:C.bg,borderRadius:6,border:`1px solid ${C.border}`}}>
<Tag label="UTXOs" value={utxos.length} color={C.purple}/>
<Tag label="Balance" value={fmt.sbtc(totalBtc)} color={C.green}/>
<Tag label="Sin confirmar" value={utxos.filter(u=>!u.status?.confirmed).length} color={C.amber}/>
{avgPrivacy!==null&&<Tag label="Privacidad media" value={`${avgPrivacy}/100`} color={avgPrivacy>=70?C.green:avgPrivacy>=40?C.amber:C.red}/>}
</div>
{/* Patrón temporal */}
{temporalPattern&&(
<div style={{padding:"8px 12px",background:C.purpleMuted,border:`1px solid ${C.purple}30`,borderRadius:6,fontSize:"0.65rem",color:C.t1,lineHeight:1.6}}>
<span style={{color:C.purple,fontFamily:"monospace",fontWeight:700}}> </span>{temporalPattern}
</div>
)}
{/* UTXO list */}
<div style={{display:"flex",flexDirection:"column",gap:8}}>
{utxos.map((u,i)=>{
const priv=privacyScores[i]||{score:100,issues:[],color:C.green};
const pct=Math.max(4,(u.value/maxVal)*100);
const sharePct=((u.value/totalBtc)*100).toFixed(1);
return(
<div key={i} style={{background:C.bg,borderRadius:8,border:`1px solid ${C.border}`,padding:"12px 14px",transition:"border-color 0.15s"}}
onMouseEnter={e=>e.currentTarget.style.borderColor=priv.color+"60"}
onMouseLeave={e=>e.currentTarget.style.borderColor=C.border}
>
{/* Top row */}
<div style={{display:"flex",alignItems:"center",justifyContent:"space-between",marginBottom:6}}>
<div style={{display:"flex",alignItems:"center",gap:8}}>
<div style={{width:8,height:8,borderRadius:"50%",background:priv.color,boxShadow:`0 0 6px ${priv.color}`,flexShrink:0}}/>
<span style={{fontSize:"0.68rem",fontFamily:"monospace",color:C.t2}}>{fmt.hash(u.txid)}:{u.vout}</span>
</div>
<div style={{display:"flex",alignItems:"center",gap:8}}>
<span style={{fontSize:"0.7rem",fontFamily:"monospace",color:priv.color,fontWeight:700}}>{priv.score}/100</span>
<span style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace"}}>{sharePct}%</span>
</div>
</div>
{/* Bar */}
<div style={{height:5,background:C.border,borderRadius:3,overflow:"hidden",marginBottom:8}}>
<div style={{width:`${pct}%`,height:"100%",background:`linear-gradient(to right,${priv.color}80,${priv.color})`,borderRadius:3}}/>
</div>
{/* Value + block */}
<div style={{display:"flex",alignItems:"center",justifyContent:"space-between",marginBottom: priv.issues.length>0?6:0}}>
<span style={{fontSize:"0.88rem",color:priv.color,fontFamily:"monospace",fontWeight:700}}>{fmt.btc(u.value)}</span>
<span style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace"}}>
{u.status?.confirmed?`bloque #${fmt.num(u.status.block_height)}`:"⏳ pendiente"}
</span>
</div>
{/* Issues */}
{priv.issues.length>0&&(
<div style={{display:"flex",flexWrap:"wrap",gap:4,marginTop:4}}>
{priv.issues.map((issue,j)=>(
<span key={j} style={{fontSize:"0.58rem",fontFamily:"monospace",padding:"2px 6px",borderRadius:3,
background:"transparent",color:issue.color,border:`1px solid ${issue.color}40`}}>
{issue.label}
</span>
))}
</div>
)}
</div>
);
})}
</div>
{/* Legend */}
<div style={{display:"flex",gap:16,flexWrap:"wrap",paddingTop:4}}>
{[{color:C.green,label:"Buena privacidad"},{color:C.amber,label:"Revisar"},{color:C.red,label:"Problema grave"}].map(l=>(
<div key={l.label} style={{display:"flex",alignItems:"center",gap:5}}>
<div style={{width:7,height:7,borderRadius:"50%",background:l.color,flexShrink:0}}/>
<span style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace"}}>{l.label}</span>
</div>
))}
</div>
</div>
)}
</Card>
);
}
// ── RASTRO DE PROCEDENCIA ──────────────────────────────────────────────
// Sigue una transacción hacia atrás, eslabón a eslabón, BAJO DEMANDA.
// Paso 2: RECURSIVO — cada eslabón puede a su vez seguir sus propios inputs,
// encadenando saltos hacia atrás. La recursión NO dispara consultas solas:
// solo se expande cuando el usuario pulsa. Caché compartida en memoria.
function bandColorRastro(b){ return b==="ALTA"?C.green : b==="MEDIA"?C.amber : b==="BAJA"?C.red : C.t2; }
function abrevTxid(t){ return t ? `${t.slice(0,10)}${t.slice(-6)}` : "—"; }
// Marcas baratas de una tx (lookup local ENTITY_INDEX + estructura)
function marcasDeTx(txData, ana){
const marcas=[];
const addrs=[
...(txData.vin||[]).map(v=>v.prevout?.scriptpubkey_address),
...(txData.vout||[]).map(o=>o.scriptpubkey_address),
].filter(Boolean);
let ofac=false, mining=false, exch=null;
for(const a of addrs){
const hit=ENTITY_INDEX.get(a);
if(hit?.cat==="ofac") ofac=true;
if(hit?.cat==="mining") mining=true;
if(hit?.cat==="exchange" && !exch) exch={name:hit.name, src:hit.src};
}
const esCoinbase=(txData.vin||[]).some(v=>v.is_coinbase||v.coinbase)||(txData.vin||[]).length===0;
// CoinJoin real: el check con id "coinjoin" dio pass:true (5+ in, 5+ out
// con valores iguales). NO buscar la palabra en el texto: el check existe
// siempre, incluso cuando dice que NO es CoinJoin -> daría falso positivo.
const esCoinJoin = (ana?.checks||[]).find(c=>c.id==="coinjoin")?.pass === true;
if(esCoinJoin) marcas.push({txt:"🔀 CoinJoin",color:C.blue});
if(ofac) marcas.push({txt:"⚠️ OFAC",color:C.red});
if(mining) marcas.push({txt:"⛏️ minería",color:C.green});
if(esCoinbase) marcas.push({txt:"⛏️ coinbase (origen)",color:C.green});
// Exchange: el texto es HONESTO sobre la fiabilidad de la fuente.
// cluster = agrupación de direcciones (inferido, no confirmado por el exchange).
// richlist/forensic = etiqueta pública conocida.
if(exch){
const confiable = exch.src==="richlist" || exch.src==="forensic";
const txt = confiable
? `🏦 ${exch.name}`
: `🏦 posible ${exch.name}`;
marcas.push({txt, color:C.amber, exchSrc:exch.src});
}
return {marcas, esCoinbase};
}
// ── Peritaje forense — heurísticas nuevas ───────────────────────────────
// Perfil de dirección: distingue wallet personal de hot wallet de exchange/
// custodio a partir de chain_stats (funded/spent/tx_count, ya disponibles
// en /api/address/{addr}) y del patrón de gasto en addrTxs
// (/api/address/{addr}/txs). No identifica el custodio — eso es el trabajo
// de ENTITY_INDEX/marcasDeTx; esto solo dice "se comporta como" uno.
function addressProfile(addr, addrInfo, addrTxs) {
const stats = addrInfo?.chain_stats || { funded_txo_sum:0, spent_txo_sum:0, tx_count:0 };
const funded = stats.funded_txo_sum || 0;
const spent = stats.spent_txo_sum || 0;
const txCount = stats.tx_count || 0;
const residual = funded - spent;
const residualRatio = funded > 0 ? residual / funded : 0;
const signals = [];
let hotScore = 0, personalScore = 0;
// Señal de flujo: saldo residual casi nulo + volumen y tx_count altos ->
// la dirección funciona como paso de caudal, no como ahorro (típico de
// hot wallet de exchange). Lo contrario -> retención típica de uso personal.
if (funded > 0 && residualRatio < 0.05 && txCount >= 20) {
hotScore += 2;
signals.push(`saldo residual ≈0 (${(residualRatio*100).toFixed(1)}% de lo recibido) con ${txCount} transacciones — patrón de flujo, no de ahorro`);
} else if (funded > 0 && residualRatio > 0.3 && txCount < 10) {
personalScore += 2;
signals.push(`retiene ${(residualRatio*100).toFixed(0)}% de lo recibido en solo ${txCount} transacción(es) — patrón de retención, no de custodio`);
}
// Barrido automático: recibe y reenvía en una tx 1-entrada/1-salida sin
// cambio, pocos bloques después. "Pocos bloques", nunca minutos: la
// confirmación on-chain resuelve en bloques (~10 min de media), no da
// precisión de reloj.
const spendingTxOf = new Map(); // "txid:vout" -> tx que lo gasta
for (const t of addrTxs) {
for (const vin of (t.vin||[])) {
if (vin.prevout?.scriptpubkey_address === addr && vin.txid != null) {
spendingTxOf.set(`${vin.txid}:${vin.vout}`, t);
}
}
}
let sweepCount = 0;
for (const t of addrTxs) {
(t.vout||[]).forEach((vout, i) => {
if (vout.scriptpubkey_address !== addr) return;
const spendingTx = spendingTxOf.get(`${t.txid}:${i}`);
if (!spendingTx) return;
const oneInOneOut = (spendingTx.vin||[]).length === 1 && (spendingTx.vout||[]).length === 1;
if (!oneInOneOut) return;
const h1 = t.status?.block_height, h2 = spendingTx.status?.block_height;
if (h1 == null || h2 == null) return;
const hopBlocks = h2 - h1;
if (hopBlocks >= 0 && hopBlocks <= 6) sweepCount++;
});
}
if (sweepCount > 0) {
hotScore += 2;
signals.push(`barrido automático: ${sweepCount} vez(es) reenviado en una tx 1-entrada/1-salida sin cambio, pocos bloques después de recibir`);
}
let kind, certainty;
if (hotScore >= 3) { kind = "hot_wallet"; certainty = "PROBABLE"; }
else if (hotScore >= 2) { kind = "hot_wallet"; certainty = "POSIBLE"; }
else if (personalScore >= 2) { kind = "personal"; certainty = "POSIBLE"; }
else { kind = "indeterminado"; certainty = null; }
return { kind, certainty, signals, funded, spent, residual, txCount, sweepCount };
}
// Cambio conductual: para cada output de una tx, ¿se gasta rápido (pocos
// bloques después) o queda quieto (no gastado todavía)? spendInfo ya viene
// resuelto por el motor de rastreo desde /api/address/{addr}/txs — esta
// función no hace peticiones, solo interpreta.
// Umbral de "rápido" = 6 bloques, igual que el barrido automático de
// addressProfile, por coherencia dentro del módulo.
function behavioralChangeGuess(tx, spendInfo) {
const outputs = (tx.vout||[]).map((v,i) => ({ index:i, ...(spendInfo.get(i)||{spent:false,blocksLater:null}) }));
const quiet = outputs.filter(o => !o.spent).map(o=>o.index);
const fast = outputs.filter(o => o.spent && o.blocksLater != null && o.blocksLater <= 6).map(o=>o.index);
return { outputs, quiet, fast };
}
// Cruza la señal estructural (guessChangeOutput) con la conductual: cuando
// coinciden —el output señalado como cambio queda quieto mientras el otro
// se mueve rápido— la certeza sube un nivel. Cuando discrepan, se reporta
// la discrepancia en vez de forzar una conclusión. "Queda quieto" solo
// significa "no gastado todavía", nunca una prueba por sí sola.
function combineChangeSignals(structural, behavioral) {
if (!structural.identifiable) {
return { index: structural.index, certainty: null, agreement: null,
note: "Sin señal estructural suficiente para cruzar con el comportamiento." };
}
const idx = structural.index;
const otherIdx = idx === 0 ? 1 : 0;
const idxQuiet = behavioral.quiet.includes(idx);
const idxFast = behavioral.fast.includes(idx);
const otherQuiet = behavioral.quiet.includes(otherIdx);
const otherFast = behavioral.fast.includes(otherIdx);
if (idxQuiet && otherFast) {
return { index: idx, certainty: "PROBABLE", agreement: "coincide",
note: `El output señalado como cambio (#${idx}) sigue sin gastar mientras el otro (#${otherIdx}) se movió pocos bloques después de esta tx — coincide con lo esperado de un cambio.` };
}
if (idxFast && otherQuiet) {
return { index: idx, certainty: structural.certainty, agreement: "discrepa",
note: `El output señalado estructuralmente como cambio (#${idx}) se movió rápido mientras el otro (#${otherIdx}) sigue quieto — discrepa con la señal estructural. No se fuerza una conclusión: se deja constancia de la discrepancia.` };
}
return { index: idx, certainty: structural.certainty, agreement: "sin_dato",
note: "Sin datos de gasto suficientes (ambos gastados o ambos quietos) para cruzar con la señal estructural." };
}
// Compara la huella de software (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, porque una misma persona puede cambiar de wallet sin cambiar
// de dueño de los fondos.
function compareFingerprints(prevFp, nextFp) {
const prevTop = prevFp?.[0]?.name || null;
const nextTop = nextFp?.[0]?.name || null;
if (!prevTop || !nextTop) {
return { changed: null, certainty: null,
note: "Huella insuficiente en uno de los dos saltos para comparar." };
}
if (prevTop === nextTop) {
return { changed: false, certainty: null,
note: `Misma huella de software en ambos saltos (${prevTop}) — compatible con el mismo actor.` };
}
return { changed: true, certainty: "POSIBLE",
note: `La huella cambia de ${prevTop} a ${nextTop} entre saltos — posible cambio de actor o entrada en infraestructura de un servicio (custodio, exchange). No es una prueba por sí sola.` };
}
// Busca la tx que gasta un output concreto (txid:vout) recorriendo el
// historial de la dirección que lo recibió. Fallback obligado: este
// backend Mempool self-hosted no implementa /outspend ni /outspends
// (verificado contra el nodo real — ver TRASPASO.md), así que no hay forma
// directa de preguntar "¿quién gasta esto?". Mismo patrón de paginación
// que scanWallet: página de 25 + /txs/chain/{last}.
// Usa la variante ESTRICTA de fetch a propósito: si el nodo falla, la
// excepción sube al motor, que registra esa rama como "no se pudo
// comprobar". Tragar el error aquí (devolver []) haría que un 503 o un
// timeout fuese indistinguible de "este output sigue sin gastar" — la
// conclusión más accionable del informe, afirmada sobre una consulta que
// nunca llegó a responder.
async function findSpendingTx(getStrict, addr, txid, vout, maxPages) {
maxPages = maxPages || 8;
let page = await getStrict(`/api/address/${addr}/txs`);
let guard = 0;
while (Array.isArray(page) && page.length > 0 && guard < maxPages) {
const found = page.find(t => (t.vin||[]).some(v => v.txid === txid && v.vout === vout));
if (found) return found;
if (page.length < 25) break; // última página, no hay más que mirar
const last = page[page.length-1].txid;
page = await getStrict(`/api/address/${addr}/txs/chain/${last}`);
guard++;
}
return null;
}
// Reconstruye tramos de peeling chain confirmada: cadenas de nodos 1-in/
// 2-out donde el output de cambio (señal combinada estructural+conductual)
// de cada uno alimenta exactamente al siguiente. El check `peeling` de
// analyzeTx (informativo, un solo salto) ya dice explícitamente que
// confirmar una cadena requiere mirar hacia adelante — esto es lo que
// cierra ese hueco, sobre el grafo ya construido.
function detectPeelingChains(nodes, edges) {
const childVia = (node) => {
const idx = node.changeGuess?.combined?.index;
if (idx == null) return null;
const e = edges.find(e => e.fromTxid === node.txid && e.fromVout === idx);
return e ? e.toTxid : null;
};
const isPeelHop = (n) => n && n.vinCount === 1 && n.voutCount === 2;
const nonStart = new Set();
for (const n of nodes.values()) {
if (!isPeelHop(n)) continue;
const childTxid = childVia(n);
const child = childTxid && nodes.get(childTxid);
if (isPeelHop(child)) nonStart.add(child.txid);
}
const chains = [];
for (const n of nodes.values()) {
if (!isPeelHop(n) || nonStart.has(n.txid)) continue;
const segment = [n];
let fingerprintStable = true;
let cur = n;
while (true) {
const childTxid = childVia(cur);
const child = childTxid && nodes.get(childTxid);
if (!isPeelHop(child)) break;
if (child.fingerprintComparison?.changed === true) fingerprintStable = false;
segment.push(child);
cur = child;
}
if (segment.length >= 2) {
chains.push({
txids: segment.map(s=>s.txid),
length: segment.length,
fingerprintStable,
certainty: segment.length >= 3 && fingerprintStable ? "CERTEZA" : "PROBABLE",
});
}
}
return chains;
}
// ── Motor de rastreo forense ─────────────────────────────────────────
// Hacia adelante, salto a salto, desde {txid,vout}. Sigue TODOS los
// outputs de cada salto (no solo "el cambio"): un actor puede repartir
// los fondos en más de una rama, y cada rama se detiene de forma
// independiente (spec: "cualquiera detiene esa rama, no todo el
// rastreo"). Qué output es "cambio" se calcula por señal
// estructural+conductual y sirve para el informe (atribución, peeling
// chain), no para podar ramas.
//
// A demanda: el estado del rastreo (initForensicTrace) y el avance de
// un salto (advanceForensicHop) están separados a propósito, para que
// la UI pueda pausar entre saltos y sea el usuario quien decida cuándo
// seguir — en vez de que el motor drene el frontier entero sin
// vigilancia. buildForensicGraph, más abajo, es el caso trivial que los
// encadena en bucle (modo automático de una sola pasada).
//
// Crea el estado de un rastreo forense nuevo, sin ejecutar ningún salto
// todavía.
async function initForensicTrace({ get, getStrict, originTxid, originVout, amountStolen }) {
// Distinguir "txid inexistente" de "el nodo no respondió": culpar al
// txid cuando el fallo es del nodo manda al usuario a buscar un error
// que no existe.
let originTx;
try {
originTx = await getStrict(`/api/tx/${originTxid}`);
} catch (e) {
throw new Error(`No se pudo consultar la transacción de origen: ${e.message}. No es (necesariamente) un problema del txid — la petición al nodo falló.`);
}
if (!originTx) throw new Error("No se pudo obtener la transacción de origen. Comprueba el txid.");
const originOut = originTx.vout?.[originVout];
if (!originOut) throw new Error("El vout indicado no existe en la transacción de origen.");
const originFingerprint = detectWallets(originTx);
return {
get, getStrict, originTxid, originVout, amountStolen: amountStolen || null,
originTx, originOut, originFingerprint,
txCache: new Map([[originTxid, originTx]]),
nodes: new Map(), // txid -> ForensicNode
edges: [], // ForensicEdge[]
unspentTerminals: [], // ramas terminales: UTXO sin gastar, sin dirección, truncadas o con consulta fallida
queryErrors: [], // consultas al nodo que fallaron — NUNCA se leen como "no hay nada"
frontier: [{ txid: originTxid, vout: originVout, amount: originOut.value, hop: 0, parentTxid: originTxid }],
enqueuedKeys: new Set(),
hop: 0, done: false,
};
}
// Ejecuta UN salto completo del rastreo: procesa TODO el frontier actual
// (mismo throttling por lotes de siempre — BATCH/PAUSE — dentro de ese
// salto) y deja preparado, sin arrancarlo, el frontier del salto
// siguiente. Muta `trace` en el sitio y lo devuelve. trace.done=true
// cuando ya no queda nada que explorar (o todo lo que queda se trunca
// por maxHops sin necesitar más peticiones).
async function advanceForensicHop(trace, opts) {
const maxHops = (opts && opts.maxHops) || 8;
const onProgress = opts && opts.onProgress;
// BATCH/PAUSE: mismo patrón de throttling que scanWallet, lotes con
// pausa, no ráfaga. MAX_NODES es una red de seguridad aparte de
// maxHops y del control manual del usuario: protege el nodo si un
// salto desemboca en una tx con muchísimos outputs (p.ej.
// consolidación de un servicio de pagos).
const BATCH = 5, PAUSE = 120, MAX_NODES = 80;
// Tope de ramificación por transacción: por encima de estas salidas
// distintas, la tx es un reparto masivo (dust attack, batch de exchange,
// airdrop) y se deja de seguir. Ver el comentario en el punto de uso —
// se aplica antes de perfilar direcciones, que es donde está el coste.
const MAX_FANOUT = 25;
// Cinturón de seguridad independiente del heurístico de perfil: una
// dirección con un volumen de transacciones así de alto es, con
// altísima probabilidad, infraestructura de un servicio (exchange,
// custodio, procesador de pagos) aunque el perfil no llegue a
// clasificarla "hot_wallet" (residual momentáneamente alto, etc.). Sin
// este tope, seguir esa dirección dispararía en findSpendingTx una
// paginación de hasta 200 tx (8 páginas × 8s de timeout) buscando una
// aguja en un pajar de decenas de miles — coste real para el nodo del
// usuario sin ninguna posibilidad realista de encontrar el gasto. No
// cuesta peticiones extra: chain_stats.tx_count ya viene en el perfil
// que se pide de todos modos para cada dirección de salida nueva.
const LARGE_ADDR_TX_COUNT = 5000;
const { get, getStrict, originTxid, originFingerprint, nodes, edges, unspentTerminals, txCache, enqueuedKeys, queryErrors } = trace;
if (trace.frontier.length === 0) { trace.done = true; return trace; }
const hopFrontier = trace.frontier;
trace.frontier = []; // aquí se acumula el frontier del salto siguiente
for (let i = 0; i < hopFrontier.length; i += BATCH) {
const batch = hopFrontier.slice(i, i + BATCH);
if (onProgress) onProgress(`Salto ${trace.hop+1}: explorando rama ${Math.min(i+BATCH,hopFrontier.length)}/${hopFrontier.length} (${nodes.size} tx en el rastro)…`);
await Promise.all(batch.map(async (item) => {
const { txid, vout, amount, hop, parentTxid } = item;
const parentTx = txCache.get(txid);
const out = parentTx?.vout?.[vout];
const addr = out?.scriptpubkey_address;
if (!addr) {
unspentTerminals.push({ txid, vout, address:null, amount, note:"Sin dirección estándar (OP_RETURN u otro script) — no rastreable." });
return;
}
if (hop >= maxHops) {
unspentTerminals.push({ txid, vout, address:addr, amount, note:"Límite de saltos alcanzado.", truncated:true });
return;
}
// "No se pudo consultar" y "no hay gasto" son cosas distintas y se
// registran distinto. Solo la segunda es una conclusión.
let spendTx;
try {
spendTx = await findSpendingTx(getStrict, addr, txid, vout);
} catch (e) {
queryErrors.push({ txid, vout, address:addr, message:e.message, context:"búsqueda del gasto" });
unspentTerminals.push({ txid, vout, address:addr, amount, queryFailed:true,
note:`No se pudo comprobar si este output fue gastado: ${e.message}. La consulta al nodo falló — no es una conclusión sobre el estado de los fondos.` });
return;
}
if (!spendTx) {
unspentTerminals.push({ txid, vout, address:addr, amount, note:"UTXO sin gastar." });
return;
}
edges.push({
fromTxid: txid, fromVout: vout, toTxid: spendTx.txid,
toVin: (spendTx.vin||[]).findIndex(v=>v.txid===txid && v.vout===vout), amount,
});
if (nodes.has(spendTx.txid)) return; // ya construido por otra rama — la arista basta (CIOH implícito)
txCache.set(spendTx.txid, spendTx);
const analysis = analyzeTx(spendTx);
const likelyCJ = analysis.checks.find(c=>c.id==="coinjoin")?.pass === true;
const { marcas: entityMarks } = marcasDeTx(spendTx, analysis);
const fingerprint = detectWallets(spendTx);
const structural = guessChangeOutput(spendTx, likelyCJ);
const inAddrs = [...new Set((spendTx.vin||[]).map(v=>v.prevout?.scriptpubkey_address).filter(Boolean))];
const outAddrs = [...new Set((spendTx.vout||[]).map(v=>v.scriptpubkey_address).filter(Boolean))];
// Tope de ramificación: una transacción que se abre en abanico
// (dust attack, batch de exchange, faucet, airdrop) no aporta nada
// forense rama a rama y sí machaca el nodo. El coste NO está solo en
// encolar: perfilar cada dirección de salida cuesta 2 peticiones, así
// que con cientos de salidas son miles de peticiones antes siquiera
// de llegar al frontier. Por eso se corta AQUÍ, antes de perfilar
// nada, y no más abajo junto a los demás stopReason. Mismo espíritu
// que LARGE_ADDR_TX_COUNT, aplicado a la anchura en vez de al fondo.
const fannedOut = outAddrs.length > MAX_FANOUT;
// Perfil + historial de las direcciones de salida — reutilizamos ese
// historial para la señal conductual (spendInfo) sin pedirlo dos veces.
const outAddrTxs = {};
const addressProfiles = {};
await Promise.all((fannedOut ? [] : outAddrs).map(async a => {
let info = null, txs = [];
try {
[info, txs] = await Promise.all([
getStrict(`/api/address/${a}`),
getStrict(`/api/address/${a}/txs`),
]);
} catch (e) {
// Sin perfil no hay señal conductual NI cinturón de volumen
// para esta dirección: queda anotado para que el informe no
// presente su ausencia como si se hubiera comprobado.
queryErrors.push({ txid:spendTx.txid, address:a, message:e.message, context:"perfil de dirección" });
}
outAddrTxs[a] = txs || [];
if (info) addressProfiles[a] = addressProfile(a, info, txs||[]);
}));
const spendInfo = new Map();
(spendTx.vout||[]).forEach((v, i) => {
const a = v.scriptpubkey_address;
if (!a) return;
const txs = outAddrTxs[a] || [];
const spender = txs.find(t => (t.vin||[]).some(vin => vin.txid===spendTx.txid && vin.vout===i));
if (spender && spender.status?.block_height != null && spendTx.status?.block_height != null) {
spendInfo.set(i, { spent:true, blocksLater: spender.status.block_height - spendTx.status.block_height });
} else if (spender) {
spendInfo.set(i, { spent:true, blocksLater:null });
} else {
spendInfo.set(i, { spent:false, blocksLater:null });
}
});
const behavioral = likelyCJ ? null : behavioralChangeGuess(spendTx, spendInfo);
const combined = behavioral ? combineChangeSignals(structural, behavioral) : null;
// Dilución: 3+ direcciones de entrada y el monto rastreado deja de
// ser una fracción identificable del total del salto.
const totalInHop = (spendTx.vin||[]).reduce((s,v)=>s+(v.prevout?.value||0),0);
const tracedShare = totalInHop > 0 ? amount / totalInHop : 1;
const diluted = inAddrs.length >= 3 && tracedShare < 0.5;
// Parada por custodio: entidad conocida (ENTITY_INDEX), perfil de
// hot wallet no indexado ("posible custodio no identificado"), o
// — cinturón de seguridad — volumen de tx tan alto que seguir esa
// dirección sería, con seguridad práctica, hammer al nodo sin
// esperanza real de encontrar el gasto (ver LARGE_ADDR_TX_COUNT).
// Se evalúa POR DIRECCIÓN: un pago normal junto a un depósito de
// exchange en la misma tx no se descarta solo porque su vecino
// sea un custodio — cada output es su propia rama.
const custodyByAddr = {};
for (const a of outAddrs) {
const hit = ENTITY_INDEX.get(a);
if (hit?.cat === "exchange") { custodyByAddr[a] = { addr:a, name:hit.name, known:true, byVolume:false }; continue; }
const prof = addressProfiles[a];
if (prof && prof.kind === "hot_wallet") { custodyByAddr[a] = { addr:a, name:null, known:false, byVolume:false }; continue; }
if (prof && prof.txCount >= LARGE_ADDR_TX_COUNT) { custodyByAddr[a] = { addr:a, name:null, known:false, byVolume:true, txCount:prof.txCount }; }
}
const custodyAddrs = outAddrs.filter(a => custodyByAddr[a]);
const custodyStop = custodyAddrs.length ? custodyByAddr[custodyAddrs[0]] : null;
let stopReason = null;
if (likelyCJ) stopReason = "mixer";
else if (diluted) stopReason = "dilution";
else if (fannedOut) stopReason = "fanOut";
else if (custodyStop) stopReason = "exchange";
else if (nodes.size + 1 >= MAX_NODES) stopReason = "nodeLimit";
const parentNode = parentTxid === originTxid ? { fingerprint: originFingerprint } : nodes.get(parentTxid);
const fingerprintComparison = parentNode ? compareFingerprints(parentNode.fingerprint, fingerprint) : null;
nodes.set(spendTx.txid, {
txid: spendTx.txid, hop: hop+1,
blockTime: spendTx.status?.block_time ?? null,
blockHeight: spendTx.status?.confirmed ? (spendTx.status.block_height ?? null) : null,
confirmed: !!spendTx.status?.confirmed,
vinCount: (spendTx.vin||[]).length, voutCount: (spendTx.vout||[]).length,
addresses: { in: inAddrs, out: outAddrs },
amountTraced: amount,
analysis, fingerprint, fingerprintComparison, entityMarks,
addressProfiles,
changeGuess: { structural, behavioral, combined },
changeAddress: structural.index != null ? (spendTx.vout[structural.index]?.scriptpubkey_address ?? null) : null,
diluted, tracedShare, custodyStop, custodyAddrs,
fanOut: fannedOut ? { outputs: outAddrs.length, limit: MAX_FANOUT } : null,
stopReason,
});
// Mixer/dilución/tope de nodos afectan a TODOS los outputs por
// igual (se originan en el lado de entrada de la tx, o son un
// límite global) — ahí sí se corta la transacción entera. Un
// custodio, en cambio, es propiedad de UNA dirección concreta:
// solo esa rama se corta, las demás siguen su curso normal.
const blockAllOutputs = stopReason === "mixer" || stopReason === "dilution" || stopReason === "nodeLimit" || stopReason === "fanOut";
if (!blockAllOutputs) {
spendTx.vout.forEach((v, i) => {
if (!v.value || v.value <= 0) return;
if (v.scriptpubkey_address && custodyByAddr[v.scriptpubkey_address]) return;
const key = `${spendTx.txid}:${i}`;
if (enqueuedKeys.has(key)) return;
enqueuedKeys.add(key);
trace.frontier.push({ txid: spendTx.txid, vout:i, amount:v.value, hop:hop+1, parentTxid: txid });
});
}
}));
if (i + BATCH < hopFrontier.length) await new Promise(r => setTimeout(r, PAUSE));
}
trace.hop += 1;
if (trace.frontier.length === 0) trace.done = true;
return trace;
}
// Ensambla el ForensicGraph (forma que espera buildForensicReport) a
// partir de un `trace`, completo o parcial — se puede llamar en
// cualquier momento del rastreo a demanda, no solo al terminar.
function finalizeForensicGraph(trace) {
const { originTxid, originVout, originTx, originOut, originFingerprint, amountStolen, nodes, edges, unspentTerminals, txCache, queryErrors } = trace;
// CIOH: semilla = origen + toda dirección de entrada de cualquier
// salto (por construcción, llegó ahí siguiendo los fondos) + direcciones
// de salida que NO son la puerta de un custodio identificado (esas
// pertenecen al servicio, no al mismo actor rastreado). Exclusión por
// dirección, no por nodo entero — un pago normal junto a un depósito
// de exchange en la misma tx sí cuenta como del actor.
// Los CoinJoin quedan FUERA por completo, entradas y salidas. CIOH
// asume que quien gasta varios inputs juntos los controla, y un CoinJoin
// existe precisamente para romper esa suposición: es la excepción
// clásica a la heurística. Sus entradas y salidas son de participantes
// distintos, así que meterlas aquí produce un falso positivo garantizado
// y contradice la propia conclusión del informe ("no se puede atribuir
// con honestidad más allá de este punto").
const actorAddrSet = new Set([originOut.scriptpubkey_address].filter(Boolean));
const mixerTxids = new Set();
for (const n of nodes.values()) {
if (n.stopReason === "mixer") { mixerTxids.add(n.txid); continue; }
for (const a of n.addresses.in) actorAddrSet.add(a);
for (const a of n.addresses.out) { if (!n.custodyAddrs.includes(a)) actorAddrSet.add(a); }
}
// El union-find tampoco debe ver las transacciones de mezcla: agruparía
// por inputs comunes a gente que no tiene nada que ver entre sí.
const txsForCluster = [...txCache.values()].filter(t => !mixerTxids.has(t.txid));
const { clusters, linkReasons } = unionFindCluster(txsForCluster, actorAddrSet);
// marcasDeTx necesita vin/vout crudos (coinbase, OFAC, minería,
// exchange, CoinJoin) — se calcula aquí, sobre originTx de verdad, y se
// guarda ya resuelto en graph.origin porque ese objeto solo lleva los
// campos resumidos que necesita el informe, no la tx cruda.
const originAnalysis = analyzeTx(originTx);
const originEntityMarks = marcasDeTx(originTx, originAnalysis).marcas;
return {
origin: {
txid: originTxid, vout: originVout, address: originOut.scriptpubkey_address, amount: originOut.value,
blockTime: originTx.status?.block_time ?? null, blockHeight: originTx.status?.confirmed ? (originTx.status.block_height ?? null) : null,
fingerprint: originFingerprint, analysis: originAnalysis, entityMarks: originEntityMarks,
},
amountStolen: amountStolen || null,
nodes, edges, unspentTerminals, clusters, linkReasons,
queryErrors: queryErrors || [],
peelingChains: detectPeelingChains(nodes, edges),
};
}
// Punto de entrada equivalente al motor original de una sola pasada:
// crea el estado y lo agota salto a salto sin pausas (modo automático).
// Se mantiene por compatibilidad — la UI a demanda usa
// initForensicTrace + advanceForensicHop directamente, con el usuario
// decidiendo cuándo llamar a cada salto en vez de este bucle.
async function buildForensicGraph({ get, getStrict, originTxid, originVout, amountStolen, maxHops, onProgress }) {
const trace = await initForensicTrace({ get, getStrict, originTxid, originVout, amountStolen });
while (!trace.done) {
await advanceForensicHop(trace, { maxHops, onProgress });
}
return finalizeForensicGraph(trace);
}
// Genera el informe de peritaje a partir del grafo ya construido. Marco
// HECHO/INFERENCIA/DECLARACION (ver TRASPASO.md): HECHO son datos on-chain
// verificables directamente, INFERENCIA hereda la sub-etiqueta de certeza
// del check/heurística que la produjo, DECLARACION es lo que dice el
// afectado sin corroborar — nunca se mezcla con las otras dos.
function buildForensicReport(graph, declaracionText) {
const { origin, nodes, unspentTerminals, clusters, peelingChains, amountStolen } = graph;
const queryErrors = graph.queryErrors || [];
const allNodes = [...nodes.values()].sort((a,b)=> a.hop - b.hop || (a.blockTime||0) - (b.blockTime||0));
const maxHop = allNodes.reduce((m,n)=>Math.max(m,n.hop), 0);
const addressesTouched = new Set([origin.address].filter(Boolean));
for (const n of allNodes) { n.addresses.in.forEach(a=>addressesTouched.add(a)); n.addresses.out.forEach(a=>addressesTouched.add(a)); }
// ── 1. Resumen ──────────────────────────────────────────────────────
const summary = {
originTxid: origin.txid, originVout: origin.vout, originAddress: origin.address,
amountTraced: origin.amount, amountStolen,
generatedAt: Math.floor(Date.now()/1000),
hops: maxHop, txCount: allNodes.length + 1, addressesTouched: addressesTouched.size,
};
// ── 2. Declaración del afectado ──────────────────────────────────────
const declaracion = declaracionText && declaracionText.trim()
? { level:"DECLARACION", text: declaracionText.trim() }
: null;
// ── 3. Cronología ────────────────────────────────────────────────────
const chronologyRow = (txid, hop, blockTime, blockHeight, amount, analysis, fingerprint, entityMarks) => ({
level: "HECHO", txid, hop, blockTime, blockHeight, amount, refs:[txid],
band: { level:"INFERENCIA", certainty: analysis?.band ? (analysis.score>=90?"CERTEZA":analysis.score>=45?"PROBABLE":"POSIBLE") : null, value: analysis?.band || "—" },
fingerprint: { level:"INFERENCIA", certainty: fingerprint?.[0]?.confidence || null, value: fingerprint?.[0]?.name || "sin huella clara" },
entityMarks: entityMarks || [],
});
const chronology = [
chronologyRow(origin.txid, 0, origin.blockTime, origin.blockHeight, origin.amount, origin.analysis, origin.fingerprint, origin.entityMarks),
...allNodes.map(n => chronologyRow(n.txid, n.hop, n.blockTime, n.blockHeight, n.amountTraced, n.analysis, n.fingerprint, n.entityMarks)),
];
// ── 4. Direcciones atribuidas al actor ──────────────────────────────
const clusterOf = new Map();
clusters.forEach((c,i) => c.forEach(a => clusterOf.set(a, i)));
// Dirección de cambio por salto (ya resuelta a texto en buildForensicGraph,
// no se recalcula el índice aquí — evita desalinearse si addresses.out
// llegara a deduplicar en un orden distinto al de vout).
const changeAddrByHop = new Map(); // addr -> {txid, certainty, agreement}
for (const n of allNodes) {
if (!n.changeAddress) continue;
changeAddrByHop.set(n.changeAddress, {
txid: n.txid,
certainty: n.changeGuess?.combined?.certainty || n.changeGuess?.structural?.certainty || "POSIBLE",
agreement: n.changeGuess?.combined?.agreement || null,
});
}
// Igual que con CIOH, la huella de software no vale nada dentro de una
// mezcla: en un CoinJoin todos los participantes usan el mismo programa,
// así que la huella sale "estable" por construcción, no porque sea el
// mismo actor. Atribuir salidas de un CoinJoin por esa señal sería
// señalar a terceros que solo coincidieron en la misma transacción.
// Dos exclusiones, por el mismo motivo de fondo: la huella de software
// solo dice algo del actor cuando la dirección PUEDE ser suya.
// - Mezclas: todos los participantes usan el mismo programa, así que la
// huella sale estable por construcción, no por ser el mismo actor.
// - Custodios: la dirección es del servicio, no de quien deposita.
// Atribuir una hot wallet de exchange "al actor" en un informe que
// puede acabar en una denuncia es señalar al mensajero. Además
// contradice la conclusión del propio informe, que la identifica como
// custodio.
// El cluster CIOH ya excluía los custodios (ver actorAddrSet); esto
// alinea la atribución por huella con ese mismo criterio.
const fingerprintStableAddrs = new Set();
for (const n of allNodes) {
if (n.stopReason === "mixer") continue;
if (n.fingerprintComparison?.changed === false) {
n.addresses.out.forEach(a => {
if (!(n.custodyAddrs || []).includes(a)) fingerprintStableAddrs.add(a);
});
}
}
const attributed = [];
for (const a of addressesTouched) {
const fundamentos = [];
if (clusterOf.has(a)) fundamentos.push({ basis:"CIOH — comparte inputs con otra dirección del rastro", certainty:"PROBABLE" });
if (changeAddrByHop.has(a)) {
const c = changeAddrByHop.get(a);
fundamentos.push({
basis: c.agreement === "coincide" ? "cambio detectado (señal estructural + conductual coinciden)" : "cambio detectado (señal estructural)",
certainty: c.certainty, refTxid: c.txid,
});
}
if (fingerprintStableAddrs.has(a)) fundamentos.push({ basis:"huella de wallet estable con el salto anterior", certainty:"POSIBLE" });
if (fundamentos.length === 0) continue;
attributed.push({ level:"INFERENCIA", address:a, fundamentos, refs:[a] });
}
// ── 5. Fondos localizados sin gastar ────────────────────────────────
// Solo entra aquí lo COMPROBADO sin gastar. Las ramas con consulta
// fallida (queryFailed) se quedan fuera a propósito: afirmar que unos
// fondos siguen sin gastar porque el nodo devolvió un 503 sería la peor
// clase de error en un informe pericial.
const unspentFunds = unspentTerminals
.filter(u => u.address && !u.truncated && !u.queryFailed && u.note === "UTXO sin gastar.")
.map(u => ({ level:"HECHO", txid:u.txid, vout:u.vout, address:u.address, amount:u.amount, refs:[u.txid, u.address] }));
const truncatedBranches = unspentTerminals.filter(u => u.truncated);
const failedBranches = unspentTerminals.filter(u => u.queryFailed);
// ── 6. Conclusiones numeradas ────────────────────────────────────────
const conclusions = [];
conclusions.push({ level:"HECHO", text:`${origin.amount} sats rastreados desde ${origin.txid}:${origin.vout}, a través de ${allNodes.length} transacción(es) en ${maxHop} salto(s).`, refs:[origin.txid] });
// Va lo primero, antes que cualquier conclusión: si parte del rastreo no
// se pudo consultar, el lector debe saberlo antes de leer nada más.
if (failedBranches.length > 0) {
conclusions.push({ level:"HECHO",
text:`ATENCIÓN — rastreo incompleto por fallos de consulta: ${failedBranches.length} rama(s) quedaron sin comprobar porque el nodo no respondió (${[...new Set(failedBranches.map(f=>f.address))].join(", ")}). NO se sabe si esos fondos siguen sin gastar o se movieron: la consulta falló, no se comprobó nada. Si el fallo se repite sobre la misma dirección, suele ser porque su historial es demasiado pesado para servirlo dentro del tiempo de espera — hay que comprobarla a mano en un explorador. Este informe no cubre esas ramas.`,
refs: failedBranches.map(f=>f.txid) });
}
if (queryErrors.some(q => q.context === "perfil de dirección")) {
const affected = [...new Set(queryErrors.filter(q=>q.context==="perfil de dirección").map(q=>q.address))];
conclusions.push({ level:"HECHO",
text:`No se pudo obtener el perfil de ${affected.length} dirección(es) (${affected.join(", ")}). Para esas direcciones no hay señal conductual ni comprobación de volumen, así que su ausencia en las conclusiones no significa que se hayan descartado — no se pudieron examinar.`,
refs: affected });
}
if (clusters.length > 0) {
conclusions.push({ level:"INFERENCIA", certainty:"PROBABLE", text:`Se detectaron ${clusters.length} cluster(es) de direcciones vinculadas por CIOH — con alta probabilidad pertenecen al mismo actor.`, refs: clusters.flat() });
}
if (peelingChains.length > 0) {
for (const pc of peelingChains) {
conclusions.push({ level:"INFERENCIA", certainty:pc.certainty, text:`Cadena de peeling confirmada de ${pc.length} salto(s) consecutivos${pc.fingerprintStable?", con huella de wallet estable":""}.`, refs: pc.txids });
}
}
for (const n of allNodes) {
if (n.stopReason === "exchange" && n.custodyStop && n.custodyStop.byVolume) {
conclusions.push({ level:"HECHO",
text:`La rama hacia ${n.custodyStop.addr} (tx ${n.txid}) se detiene por precaución: esa dirección tiene ${fmt.num(n.custodyStop.txCount)} transacciones registradas, un volumen que hace inviable buscar el gasto concreto sin sobrecargar el nodo. No es una conclusión sobre quién la controla — solo una medida de protección. Otras ramas de la misma transacción, si las hay, siguen su curso normal.`,
refs:[n.txid, n.custodyStop.addr] });
} else if (n.stopReason === "exchange" && n.custodyStop) {
const label = n.custodyStop.known ? `${n.custodyStop.name} (fuente: índice de entidades)` : "hot wallet no identificada — posible custodio";
conclusions.push({ level:"INFERENCIA", certainty: n.custodyStop.known ? "PROBABLE" : "POSIBLE",
text:`La rama hacia ${n.custodyStop.addr} (tx ${n.txid}) se detiene en ${label}. Otras ramas de la misma transacción, si las hay, siguen su curso normal.`, refs:[n.txid, n.custodyStop.addr] });
}
if (n.stopReason === "mixer") {
conclusions.push({ level:"INFERENCIA", certainty:"CERTEZA", text:`El rastro se rompe en un CoinJoin real en ${n.txid} — no se puede atribuir con honestidad más allá de este punto.`, refs:[n.txid] });
}
if (n.stopReason === "dilution") {
conclusions.push({ level:"INFERENCIA", certainty:"POSIBLE", text:`Dilución en ${n.txid}: el monto rastreado deja de ser una fracción identificable del total de la transacción (${(n.tracedShare*100).toFixed(1)}%).`, refs:[n.txid] });
}
if (n.stopReason === "fanOut" && n.fanOut) {
conclusions.push({ level:"HECHO",
text:`El rastro se detiene en ${n.txid}: esa transacción reparte a ${n.fanOut.outputs} direcciones distintas (el tope es ${n.fanOut.limit}). Un abanico así es un reparto masivo — dust attack, lote de retiradas de un servicio, airdrop — donde seguir cada rama no aporta nada forense y sí supondría miles de peticiones al nodo. No es una conclusión sobre quién controla esas direcciones: es un límite deliberado. Si te interesa una salida concreta, rastréala aparte usando esta transacción como nuevo origen.`,
refs:[n.txid] });
}
if (n.stopReason === "nodeLimit") {
conclusions.push({ level:"HECHO", text:`El rastro se detiene en ${n.txid} por el tope de transacciones exploradas — una red de seguridad aparte del límite de saltos, para no sobrecargar el nodo si un salto desemboca en una consolidación con muchísimas ramas. No es un punto de parada natural; se puede seguir rastreando manualmente desde aquí si hace falta.`, refs:[n.txid] });
}
}
if (unspentFunds.length > 0) {
conclusions.push({ level:"HECHO", text:`${unspentFunds.length} salida(s) con fondos localizados sin gastar, sumando ${unspentFunds.reduce((s,u)=>s+u.amount,0)} sats.`, refs: unspentFunds.map(u=>u.txid) });
}
if (truncatedBranches.length > 0) {
conclusions.push({ level:"HECHO", text:`${truncatedBranches.length} rama(s) del rastro se cortaron por el límite de saltos configurado, no por un punto de parada natural — pueden ampliarse.`, refs: truncatedBranches.map(u=>u.txid) });
}
// ── 7. Recomendaciones ───────────────────────────────────────────────
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:"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:"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) {
recommendations.push({
fixed: true,
text:"⚠ Aviso importante: Txoko no ofrece ni recomienda servicios de \"recuperación de fondos\". Cualquiera que se ofrezca a cobrar por adelantado para \"recuperar\" bitcoin robado es, con altísima probabilidad, una segunda estafa sobre la misma víctima. No compartas claves, semillas ni accesos con nadie que te contacte ofreciendo recuperar tus fondos.",
});
}
// ── 8. Metodología ────────────────────────────────────────────────────
const methodology = {
levels: "Cada afirmación del informe lleva una etiqueta: HECHO (dato on-chain verificable directamente, sin interpretación), INFERENCIA (conclusión de una heurística, con su propia sub-etiqueta de certeza CERTEZA/PROBABLE/POSIBLE) o DECLARACION (lo que dice el afectado, sin corroborar).",
heuristics: "Se reutilizan las heurísticas del analizador de transacciones y del informe de wallet de Txoko (CIOH, detección de cambio estructural, huella de software, CoinJoin, entidades conocidas), y se añaden: perfil de dirección (personal vs hot wallet), cambio conductual (se gasta rápido vs queda quieto), comparación de huella entre saltos, y confirmación de cadenas de peeling multi-salto.",
limits: "El rastreo se detiene ante un CoinJoin real, dilución excesiva, un custodio identificado o presunto, una transacción que reparte a demasiadas direcciones (reparto masivo), un UTXO sin gastar, o el límite de saltos configurado. También se detiene, sin concluir nada, cuando una consulta al nodo falla: esas ramas se marcan como no comprobadas y quedan fuera de los fondos localizados. Ninguna heurística identifica la identidad real de una persona: como mucho llega a \"hot wallet de [servicio]\" o \"custodio no identificado\".",
};
// ── 9. Anexo de verificación ────────────────────────────────────────
const verificationAppendix = {
txids: [origin.txid, ...allNodes.map(n=>n.txid)],
addresses: [...addressesTouched],
};
return { summary, declaracion, chronology, attributed, unspentFunds, truncatedBranches, failedBranches, queryErrors, conclusions, recommendations, methodology, verificationAppendix };
}
// Eslabón recursivo: muestra una tx de origen y permite seguir SUS inputs.
// depth limita la profundidad visual; cache y getTx se comparten desde arriba.
function Eslabon({txData, fluyo, vout, depth, getTx, base}) {
const [hijos,setHijos]=useState({}); // {vinIndex: {loading, child:{txData,fluyo,vout}, err}}
const ana = useMemo(()=>analyzeTx(txData),[txData]);
const {marcas, esCoinbase} = useMemo(()=>marcasDeTx(txData,ana),[txData,ana]);
const mempoolUrl = base ? `${base}/tx/${txData.txid}` : `https://mempool.space/tx/${txData.txid}`;
const inputs = txData.vin || [];
const [hover,setHover]=useState(false);
// Detalle para el tooltip: lo que un noderunner querría ver de un eslabón
const detalle = useMemo(()=>{
const nIn = (txData.vin||[]).length;
const nOut = (txData.vout||[]).length;
const totalOut = (txData.vout||[]).reduce((s,o)=>s+(o.value||0),0);
const totalIn = (txData.vin||[]).reduce((s,v)=>s+(v.prevout?.value||0),0);
const fee = txData.fee || (totalIn>totalOut ? totalIn-totalOut : null);
const vsize = txData.weight ? Math.ceil(txData.weight/4) : null;
const feeRate = (fee && vsize) ? (fee/vsize) : (txData.feerate ? Number(txData.feerate) : null);
return {
nIn, nOut,
btc: (fluyo/1e8).toFixed(8),
bloque: txData.status?.confirmed ? txData.status.block_height : null,
fee, feeRate,
};
},[txData,fluyo]);
const seguirInput = async (i) => {
const vin=inputs[i];
const origenTxid=vin?.txid;
if(!origenTxid){ setHijos(s=>({...s,[i]:{err:"Este input no indica su transacción de origen."}})); return; }
setHijos(s=>({...s,[i]:{loading:true}}));
try{
const child=await getTx(origenTxid, depth+1);
if(!child) throw new Error("No se pudo traer la transacción de origen");
setHijos(s=>({...s,[i]:{child:{txData:child, fluyo:vin.prevout?.value||0, vout:vin.vout}}}));
}catch(e){ setHijos(s=>({...s,[i]:{err:e.message}})); }
};
// ¿Este eslabón merece atención? (tiene marca, o banda no es ALTA)
// Lo relevante se destaca; lo limpio se silencia.
const relevante = marcas.length>0 || (ana?.band && ana.band!=="ALTA");
const acento = marcas.length>0 ? marcas[0].color : bandColorRastro(ana?.band);
// Barra visual de cantidad: proporción respecto al flujo de entrada (vout origen).
// Da una idea de tamaño sin leer la cifra. Si no hay referencia, escala suave.
const pctBarra = (() => {
const ref = fluyo || 1;
const outTotal = (txData.vout||[]).reduce((s,o)=>s+(o.value||0),0) || ref;
const p = Math.min(100, Math.round((fluyo/outTotal)*100));
return isFinite(p) && p>0 ? p : 5;
})();
return (
<div
style={{
position:"relative",
padding:"10px 12px",
background: relevante ? `${acento}0d` : C.bg,
borderRadius:6,
border:`1px solid ${relevante ? acento+"55" : C.border}`,
marginTop:8,
opacity: relevante ? 1 : 0.78,
}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",gap:8,flexWrap:"wrap",marginBottom:6}}>
<div style={{display:"flex",alignItems:"center",gap:8,position:"relative"}}>
<a href={mempoolUrl} target="_blank" rel="noopener noreferrer"
style={{fontSize:"0.64rem",fontFamily:"monospace",color:C.blue,textDecoration:"none",wordBreak:"break-all"}}>
{abrevTxid(txData.txid)}
</a>
<button
onMouseEnter={()=>setHover(true)}
onMouseLeave={()=>setHover(false)}
onClick={()=>setHover(h=>!h)} title="Ver detalle"
style={{padding:"0 6px",background:hover?C.borderBright:"transparent",border:`1px solid ${C.borderBright}`,borderRadius:4,color:C.t2,fontFamily:"monospace",fontSize:"0.58rem",cursor:"pointer",lineHeight:"1.5"}}>
{detalle.nIn}{detalle.nOut}
</button>
{/* Tooltip de detalle: aparece al pasar el ratón o tocar la pastilla i→o */}
{hover&&(
<div style={{position:"absolute",zIndex:50,bottom:"100%",left:0,marginBottom:6,minWidth:250,padding:"12px 14px",background:"#0a0e14",border:`1px solid ${acento}`,borderRadius:6,boxShadow:"0 10px 36px rgba(0,0,0,0.92)"}}>
<div style={{fontSize:"0.64rem",fontFamily:"monospace",lineHeight:2}}>
<div><span style={{color:C.t1}}>Estructura </span><span style={{color:"#fff",fontWeight:700}}>{detalle.nIn} in → {detalle.nOut} out</span></div>
<div><span style={{color:C.t1}}>Fluyó </span><span style={{color:C.amber,fontWeight:700}}>{fmt.num(fluyo)} sat</span><span style={{color:C.t1}}> · </span><span style={{color:"#fff",fontWeight:700}}>{detalle.btc} BTC</span></div>
{detalle.bloque!=null
? <div><span style={{color:C.t1}}>Bloque </span><span style={{color:C.blue,fontWeight:700}}>#{fmt.num(detalle.bloque)}</span></div>
: <div><span style={{color:C.t1}}>Estado </span><span style={{color:C.amber,fontWeight:700}}>sin confirmar (mempool)</span></div>}
{detalle.fee!=null&&<div><span style={{color:C.t1}}>Fee </span><span style={{color:"#fff",fontWeight:700}}>{fmt.num(detalle.fee)} sat</span>{detalle.feeRate&&<span style={{color:C.t1}}> · {detalle.feeRate.toFixed(1)} sat/vB</span>}</div>}
{marcas.length>0&&<div style={{marginTop:4}}>{marcas.map((m,j)=>(<span key={j} style={{color:m.color,fontWeight:700,marginRight:8}}>{m.txt}</span>))}</div>}
</div>
</div>
)}
</div>
{ana?.band&&(
<span style={{fontSize:"0.58rem",fontFamily:"monospace",fontWeight:relevante?700:400,color:bandColorRastro(ana.band),border:`1px solid ${bandColorRastro(ana.band)}${relevante?"":"40"}`,borderRadius:4,padding:"1px 7px"}}>{ana.band}</span>
)}
</div>
{/* Barra visual de cantidad: el ojo capta proporción sin leer cifras */}
<div style={{display:"flex",alignItems:"center",gap:8,marginBottom:6}}>
<div style={{flex:1,height:4,background:C.border,borderRadius:2,overflow:"hidden"}}>
<div style={{width:`${pctBarra}%`,height:"100%",background:relevante?acento:C.t3,borderRadius:2}}/>
</div>
<span style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace",whiteSpace:"nowrap"}}>
<span style={{color:C.t1}}>{fmt.num(fluyo)} sat</span>{txData.status?.block_time&&` · ${fmt.date(txData.status.block_time)}`}
</span>
</div>
{marcas.length>0&&(
<div style={{display:"flex",gap:6,flexWrap:"wrap",marginBottom:6}}>
{marcas.map((m,j)=>(<span key={j} style={{fontSize:"0.58rem",fontFamily:"monospace",fontWeight:700,color:m.color,background:`${m.color}1a`,border:`1px solid ${m.color}55`,borderRadius:4,padding:"1px 7px"}}>{m.txt}</span>))}
</div>
)}
{esCoinbase&&(
<div style={{fontSize:"0.58rem",color:C.green,marginTop:2}}>⛏️ Origen: estas monedas se crearon aquí (recompensa de minado).</div>
)}
{/* Seguir más atrás desde este eslabón (recursivo, bajo demanda) */}
{!esCoinbase&&depth<8&&(
<div style={{marginTop:8,paddingLeft:10,borderLeft:`2px solid ${C.border}`}}>
{inputs.map((vin,i)=>{
const h=hijos[i]||{};
return (
<div key={i} style={{marginBottom:6}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",gap:8,flexWrap:"wrap"}}>
<span style={{fontSize:"0.6rem",fontFamily:"monospace",color:C.t2}}> origen del input #{i+1} · {fmt.num(vin.prevout?.value||0)} sat</span>
{!h.child&&!h.loading&&(
<button onClick={()=>seguirInput(i)}
style={{padding:"3px 9px",background:C.purpleMuted,border:`1px solid ${C.purple}40`,borderRadius:5,color:C.purple,fontFamily:"monospace",fontSize:"0.56rem",cursor:"pointer"}}>
seguir
</button>
)}
{h.loading&&<span style={{fontSize:"0.56rem",color:C.t2,fontFamily:"monospace"}}>consultando</span>}
</div>
{h.err&&<div style={{fontSize:"0.56rem",color:C.amber,fontFamily:"monospace",marginTop:4}}>{h.err}</div>}
{h.child&&<Eslabon txData={h.child.txData} fluyo={h.child.fluyo} vout={h.child.vout} depth={depth+1} getTx={getTx} base={base}/>}
</div>
);
})}
</div>
)}
{!esCoinbase&&depth>=8&&(
<div style={{fontSize:"0.56rem",color:C.t3,marginTop:6}}>Límite de profundidad alcanzado. Abre el txid en Mempool para seguir manualmente.</div>
)}
</div>
);
}
function RastroProcedencia({tx, base}) {
const {get}=useApi(base);
const cacheRef = useRef({}); // txid -> {txData, depth}, compartida
const [raiz,setRaiz]=useState({}); // {vinIndex: {loading, child, err}}
const [explorados,setExplorados]=useState(0); // contador: sube al explorar, refresca resumen
// Trae una tx (de caché o del nodo) y registra a qué profundidad se exploró.
// Compartida por todos los eslabones.
const getTx = useCallback(async (txid, depth)=>{
const cached = cacheRef.current[txid];
if(cached){
// Si ya estaba pero a más profundidad, nos quedamos con la menor (más cercana)
if(depth!=null && depth < cached.depth){ cached.depth = depth; setExplorados(n=>n+1); }
return cached.txData;
}
let data;
if(!base){ data={...MOCK_TX, txid}; }
else { data=await get(`/api/tx/${txid}`, null); }
if(data){ cacheRef.current[txid]={txData:data, depth:depth??1}; setExplorados(n=>n+1); }
return data;
},[base,get]);
// Resumen de distancia a entidades: recorre lo YA explorado en la caché
// y busca la marca más cercana de cada tipo. Honesto: solo sabe de lo
// que se ha mirado, no afirma sobre ramas sin explorar.
const resumenDistancia = useMemo(()=>{
void explorados; // dependencia: recalcular cuando se explora algo nuevo
const minDist = {}; // cat -> profundidad mínima
for(const txid in cacheRef.current){
const {txData, depth} = cacheRef.current[txid];
const ana = analyzeTx(txData);
const {marcas} = marcasDeTx(txData, ana);
for(const m of marcas){
let cat=null;
if(m.txt.includes("OFAC")) cat="⚠️ OFAC";
else if(m.txt.includes("minería")||m.txt.includes("coinbase")) cat="⛏️ minería";
else if(m.txt.includes("CoinJoin")) cat="🔀 CoinJoin";
else if(m.txt.includes("🏦")) cat="🏦 exchange";
if(cat && (minDist[cat]==null || depth<minDist[cat])) minDist[cat]=depth;
}
}
return Object.entries(minDist).map(([cat,d])=>({cat,dist:d}));
},[explorados]);
const inputs = tx.vin || [];
const seguir = async (i) => {
const vin=inputs[i];
const origenTxid=vin?.txid;
if(!origenTxid){ setRaiz(s=>({...s,[i]:{err:"Este input no indica su transacción de origen."}})); return; }
setRaiz(s=>({...s,[i]:{loading:true}}));
try{
const child=await getTx(origenTxid, 1);
if(!child) throw new Error("No se pudo traer la transacción de origen");
setRaiz(s=>({...s,[i]:{child:{txData:child, fluyo:vin.prevout?.value||0, vout:vin.vout}}}));
}catch(e){ setRaiz(s=>({...s,[i]:{err:e.message}})); }
};
const limpiar = () => { cacheRef.current={}; setRaiz({}); setExplorados(0); };
return (
<Card>
<div style={{display:"flex",alignItems:"center",gap:8,marginBottom:10}}>
<span style={{fontSize:"0.62rem",color:C.purple,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em"}}>Rastro de procedencia</span>
<div style={{flex:1,height:1,background:`linear-gradient(to right,${C.purple}40,transparent)`}}/>
{Object.keys(raiz).length>0&&(
<button onClick={limpiar} title="Vaciar el rastro y la caché de esta sesión"
style={{padding:"3px 9px",background:C.bg,border:`1px solid ${C.border}`,borderRadius:5,color:C.t2,fontFamily:"monospace",fontSize:"0.58rem",cursor:"pointer"}}>
🗑 limpiar
</button>
)}
</div>
<div style={{fontSize:"0.62rem",color:C.t2,marginBottom:12,lineHeight:1.5}}>
Sigue cada input hacia atrás, un salto a la vez. Cada consulta la pides , para no sobrecargar el nodo. Desde cada eslabón puedes seguir bajando.
</div>
{resumenDistancia.length>0 ? (
<div style={{marginBottom:12,padding:"10px 12px",background:`${C.amber}0d`,borderRadius:6,border:`1px solid ${C.amber}55`}}>
<div style={{fontSize:"0.66rem",color:C.amber,fontFamily:"monospace",fontWeight:700,marginBottom:6}}>
Atención: el rastro toca {resumenDistancia.map(r=>r.cat.replace(/^.. /,"")).join(", ")}
</div>
<div style={{display:"flex",gap:8,flexWrap:"wrap"}}>
{resumenDistancia.map((r,j)=>(
<span key={j} style={{fontSize:"0.6rem",fontFamily:"monospace",color:C.t1,border:`1px solid ${C.border}`,borderRadius:4,padding:"2px 8px"}}>
{r.cat} · a {r.dist} {r.dist===1?"salto":"saltos"}
</span>
))}
</div>
<div style={{fontSize:"0.55rem",color:C.t3,marginTop:6,lineHeight:1.4}}>
Solo cuenta las ramas que has seguido. Puede haber más sin explorar.
</div>
</div>
) : explorados>0 ? (
<div style={{marginBottom:12,padding:"10px 12px",background:`${C.green}0d`,borderRadius:6,border:`1px solid ${C.green}40`}}>
<div style={{fontSize:"0.66rem",color:C.green,fontFamily:"monospace",fontWeight:700}}>
Camino limpio
</div>
<div style={{fontSize:"0.55rem",color:C.t3,marginTop:4,lineHeight:1.4}}>
De lo explorado, nada toca entidades marcadas. Puede haber más ramas sin seguir.
</div>
</div>
) : null}
{/* ¿La propia tx analizada es coinbase? Entonces es el origen: no hay
nada hacia atrás, estas monedas se crearon aquí. */}
{((tx.vin||[]).some(v=>v.is_coinbase||v.coinbase) || (tx.vin||[]).length===0 || (tx.vin||[]).every(v=>!v.txid)) ? (
<div style={{padding:"12px 14px",background:C.bg,borderRadius:6,border:`1px solid ${C.green}30`}}>
<div style={{fontSize:"0.64rem",color:C.green,fontFamily:"monospace",marginBottom:4}}>⛏️ Origen del rastro: transacción coinbase</div>
<div style={{fontSize:"0.6rem",color:C.t2,lineHeight:1.5}}>
Estas monedas se crearon aquí, como recompensa de minado. No vienen de ninguna transacción anterior es el principio de su historia en la cadena.
</div>
</div>
) : inputs.map((vin,i)=>{
const est=raiz[i]||{};
return (
<div key={i} style={{marginBottom:10,paddingLeft:10,borderLeft:`2px solid ${C.border}`}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",gap:8,flexWrap:"wrap"}}>
<div style={{fontSize:"0.65rem",fontFamily:"monospace",color:C.t1}}>
Input #{i+1}<span style={{color:C.t2,marginLeft:8}}>{fmt.num(vin.prevout?.value||0)} sat</span>
</div>
{!est.child&&!est.loading&&(
<button onClick={()=>seguir(i)}
style={{padding:"4px 10px",background:C.purpleMuted,border:`1px solid ${C.purple}40`,borderRadius:5,color:C.purple,fontFamily:"monospace",fontSize:"0.6rem",cursor:"pointer"}}>
seguir rastro
</button>
)}
{est.loading&&<span style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace"}}>consultando</span>}
</div>
{est.err&&<div style={{marginTop:6,fontSize:"0.6rem",color:C.amber,fontFamily:"monospace"}}>{est.err}</div>}
{est.child&&<Eslabon txData={est.child.txData} fluyo={est.child.fluyo} vout={est.child.vout} depth={1} getTx={getTx} base={base}/>}
</div>
);
})}
</Card>
);
}
// ── AUDITORÍA ──────────────────────────────────────────────────────────
function Auditoria({base, initialQuery}) {
const {get, getStrict}=useApi(base);
const [query,setQuery]=useState(initialQuery||"");
const [tx,setTx]=useState(null);
const [analysis,setAnalysis]=useState(null);
const [loading,setLoading]=useState(false);
const [err,setErr]=useState(null);
const [labels,setLabels]=useState(new Map()); // BIP-329: ref -> label
const [showLabelModal,setShowLabelModal]=useState(false);
const [labelInfo,setLabelInfo]=useState(null); // {count, file}
const [walletAddrs,setWalletAddrs]=useState(null); // {receive:[], change:[]}
const [xpubInput,setXpubInput]=useState("");
const [xpubCount,setXpubCount]=useState(20);
const [xpubLoading,setXpubLoading]=useState(false);
const [xpubError,setXpubError]=useState(null);
const [showWalletPanel,setShowWalletPanel]=useState(false);
const loadWallet = async () => {
const xpub = xpubInput.trim();
if (!xpub) return;
if (!/^[xyzuvt]pub[1-9A-HJ-NP-Za-km-z]{100,}$/.test(xpub)) {
setXpubError("Introduce un xpub/zpub válido"); return;
}
setXpubLoading(true); setXpubError(null);
try {
const addrs = await deriveAddresses(xpub, xpubCount);
setWalletAddrs(addrs);
setShowWalletPanel(false);
} catch(e) {
setXpubError("Error al derivar direcciones: " + e.message);
}
setXpubLoading(false);
};
const clearWallet = () => { setWalletAddrs(null); setXpubInput(""); setXpubError(null); };
// Determinar si una dirección es mía y de qué rama
const myAddr = (addr) => {
if (!walletAddrs) return null;
if (walletAddrs.receive.includes(addr)) return "recepción";
if (walletAddrs.change.includes(addr)) return "cambio";
return null;
};
// ── Análisis de wallet completo ──────────────────────────────────────
const [walletReport,setWalletReport]=useState(null);
const [walletScanning,setWalletScanning]=useState(false);
const [walletProgress,setWalletProgress]=useState("");
// Trae datos del nodo por lotes, con pausa, para no saturar Fulcrum
const scanWallet = async () => {
if (!walletAddrs || !base) return;
setWalletScanning(true); setWalletReport(null);
try {
const all = [...walletAddrs.receive.map(a=>({addr:a,branch:"recepción"})),
...walletAddrs.change.map(a=>({addr:a,branch:"cambio"}))];
const BATCH = 5; // direcciones por lote
const PAUSE = 120; // ms entre lotes
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
for (let i = 0; i < all.length; i += BATCH) {
const slice = all.slice(i, i + BATCH);
setWalletProgress(`Explorando direcciones ${Math.min(i+BATCH,all.length)}/${all.length}`);
const infos = await Promise.all(slice.map(async s => {
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) => {
const txc = info?.chain_stats?.tx_count || 0;
if (txc > 0) active.push({ ...slice[j], info });
});
if (i + BATCH < all.length) await new Promise(r => setTimeout(r, PAUSE));
}
// Fase 2: txs de las direcciones con actividad, con paginación
// (la API devuelve ~25 txs por página; seguimos con /txs/chain/{last})
const txMap = new Map();
const myAddrSet = new Set(all.map(a => a.addr));
for (let i = 0; i < active.length; i += BATCH) {
const slice = active.slice(i, i + BATCH);
setWalletProgress(`Trayendo transacciones ${Math.min(i+BATCH,active.length)}/${active.length}`);
await Promise.all(slice.map(async s => {
try {
let page = await getStrict(`/api/address/${s.addr}/txs`);
let guard = 0;
while (Array.isArray(page) && page.length > 0 && guard < 8) {
page.forEach(t => { if (t && t.txid) txMap.set(t.txid, t); });
if (page.length < 25) break; // última página
const last = page[page.length-1].txid;
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));
}
setWalletProgress("Analizando vinculación…");
const report = buildWalletReport(active, [...txMap.values()], myAddrSet);
report.scanErrors = scanErrors;
report.scanComplete = scanErrors.length === 0;
setWalletReport(report);
} catch(e) {
setWalletReport({ error: e.message });
}
setWalletScanning(false); setWalletProgress("");
};
const loadLabels = (file) => {
const reader = new FileReader();
reader.onload = (e) => {
const map = new Map();
const lines = e.target.result.split("\n").filter(l => l.trim());
for (const line of lines) {
try {
const obj = JSON.parse(line);
if (obj.ref && obj.label) map.set(obj.ref, obj.label);
} catch {}
}
setLabels(map);
setLabelInfo({count: map.size, file: file.name});
setTimeout(() => setShowLabelModal(false), 50);
};
reader.readAsText(file);
};
const analyze=useCallback(async(q)=>{
q=(q||query).trim(); if(!q)return;
setLoading(true);setTx(null);setAnalysis(null);setErr(null);
if(!base){
setTimeout(()=>{setTx(MOCK_TX);setAnalysis(analyzeTx(MOCK_TX));setLoading(false);},400); return;
}
try{
if(!/^[0-9a-f]{64}$/i.test(q)) throw new Error("Introduce un txid válido (64 caracteres hex)");
const txData=await get(`/api/tx/${q}`,null);
if(!txData) throw new Error("Transacción no encontrada");
setTx(txData); setAnalysis(analyzeTx(txData));
}catch(e){setErr(e.message);}
setLoading(false);
},[query,base,get]);
// Si llegamos desde LAB con query pre-cargado, lanzar automáticamente
useEffect(()=>{
if(initialQuery){ setQuery(initialQuery); analyze(initialQuery); }
},[initialQuery]);
return (
<div style={{display:"flex",flexDirection:"column",gap:12}}>
{!base&&<DemoBanner/>}
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",gap:8}}>
<SectionTitle accent={C.purple} icon="⌖">AUDITORÍA</SectionTitle>
<div style={{display:"flex",gap:6}}>
<button onClick={()=>{setShowWalletPanel(!showWalletPanel);setShowLabelModal(false);}}
style={{padding:"5px 12px",background:walletAddrs?C.greenMuted:C.bgCard,border:`1px solid ${walletAddrs?C.green:C.border}`,borderRadius:6,color:walletAddrs?C.green:C.t2,fontFamily:"monospace",fontSize:"0.65rem",cursor:"pointer",whiteSpace:"nowrap"}}>
{walletAddrs?"✓ Wallet cargado":"Wallet ↑"}
</button>
<button onClick={()=>{setShowLabelModal(!showLabelModal);setShowWalletPanel(false);}}
style={{padding:"5px 12px",background:labels.size>0?C.greenMuted:C.bgCard,border:`1px solid ${labels.size>0?C.green:C.border}`,borderRadius:6,color:labels.size>0?C.green:C.t2,fontFamily:"monospace",fontSize:"0.65rem",cursor:"pointer",whiteSpace:"nowrap"}}>
{labels.size>0?`✓ ${labels.size} etiquetas`:"Etiquetas ↑"}
</button>
</div>
</div>
{showWalletPanel&&(
<Card glow={C.green}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:10}}>
<div style={{fontSize:"0.65rem",color:C.green,fontFamily:"monospace",fontWeight:700}}>WALLET WATCH-ONLY (xpub)</div>
<button onClick={()=>setShowWalletPanel(false)} style={{background:"none",border:"none",color:C.t2,cursor:"pointer",fontSize:"1rem"}}>×</button>
</div>
<div style={{fontSize:"0.68rem",color:C.t2,marginBottom:12}}>
Pega tu xpub/zpub desde Sparrow: <span style={{fontFamily:"monospace",color:C.t1}}>Settings Keystore Master Public Key</span>. Txoko deriva tus direcciones en local — el xpub no sale del navegador.
</div>
{walletAddrs?(
<div>
<div style={{fontSize:"0.65rem",color:C.green,fontFamily:"monospace",marginBottom:8}}> {walletAddrs.receive.length} direcciones de recepción + {walletAddrs.change.length} de cambio derivadas</div>
<div style={{fontSize:"0.62rem",color:C.t2,marginBottom:8}}>Las direcciones de tus txs analizadas se marcan automáticamente como tuyas. Compara estas con Sparrow para verificar:</div>
<div style={{display:"flex",flexDirection:"column",gap:3,marginBottom:10}}>
{walletAddrs.receive.slice(0,3).map((a,i)=>(
<div key={i} style={{fontSize:"0.6rem",fontFamily:"monospace",color:C.t1}}>
<span style={{color:C.t2}}>recep #{i}:</span> {a}
</div>
))}
</div>
<div style={{display:"flex",gap:8,flexWrap:"wrap"}}>
<button onClick={scanWallet} disabled={walletScanning||!base}
title={!base?"Necesitas conectar tu nodo (CONFIG) para analizar el wallet":""}
style={{padding:"6px 14px",background:C.purpleMuted,border:`1px solid ${C.purple}60`,borderRadius:6,color:C.purple,fontFamily:"monospace",fontSize:"0.65rem",cursor:"pointer",fontWeight:700}}>
{walletScanning?"···":"⊙ Analizar wallet"}
</button>
<button onClick={clearWallet} style={{padding:"6px 14px",background:"none",border:`1px solid ${C.red}40`,borderRadius:6,color:C.red,fontFamily:"monospace",fontSize:"0.65rem",cursor:"pointer"}}>
Borrar wallet
</button>
</div>
{walletScanning&&walletProgress&&(
<div style={{marginTop:8,fontSize:"0.62rem",color:C.t2,fontFamily:"monospace"}}>{walletProgress}</div>
)}
</div>
):(
<div style={{display:"flex",gap:8}}>
<input value={xpubInput} onChange={e=>setXpubInput(e.target.value)}
onKeyDown={e=>e.key==="Enter"&&loadWallet()}
placeholder="xpub... o zpub..."
style={{flex:1,background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"8px 12px",color:C.t1,fontFamily:"monospace",fontSize:"0.7rem",outline:"none"}}
/>
<select value={xpubCount} onChange={e=>setXpubCount(Number(e.target.value))}
title="Direcciones a derivar por rama"
style={{background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"8px 8px",color:C.t1,fontFamily:"monospace",fontSize:"0.7rem",outline:"none",cursor:"pointer"}}>
<option value={20}>20</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
<button onClick={loadWallet} disabled={xpubLoading}
style={{padding:"8px 16px",background:C.greenMuted,border:`1px solid ${C.green}40`,borderRadius:6,color:C.green,fontFamily:"monospace",fontSize:"0.72rem",cursor:"pointer",fontWeight:700}}>
{xpubLoading?"···":"Cargar"}
</button>
</div>
)}
{!walletAddrs&&<div style={{marginTop:8,fontSize:"0.6rem",color:C.t2,fontFamily:"monospace"}}>Direcciones a derivar por rama (recepción y cambio). Más direcciones = más cobertura pero más tiempo de cálculo.</div>}
{xpubError&&<div style={{marginTop:8,fontSize:"0.65rem",color:C.red,fontFamily:"monospace"}}>{xpubError}</div>}
</Card>
)}
{walletReport&&!walletReport.error&&(
<Card glow={C.purple}>
{/* Cabecera del informe con exportación */}
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:14}}>
<div style={{fontSize:"0.65rem",color:C.purple,fontFamily:"monospace",fontWeight:700}}>INFORME DE WALLET</div>
<div style={{display:"flex",gap:6}}>
<button onClick={()=>{
const {health,...rest}=walletReport;
const exportData={...rest,health:{band:health.band,msg:health.msg}};
const blob=new Blob([JSON.stringify(exportData,null,2)],{type:"application/json"});
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>
<button onClick={()=>{
const {health,totalTxs,activeCount,reusedAddrs,clusters,history,scanErrors}=walletReport;
const lines=[
`# 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,``,
`- Transacciones analizadas: ${totalTxs}`,
`- Direcciones con actividad: ${activeCount}`,
`- Direcciones reutilizadas: ${reusedAddrs.length}`,
`- Clusters de vinculación: ${clusters.length}`,``,
`## Clusters de vinculación`,``,
...(clusters.length===0?[`No se detectan clusters.`]:clusters.flatMap((c,i)=>[
`### Cluster ${i+1}${c.length} direcciones vinculadas`,
...c.map(a=>`- ${a}`),``
])),``,
`## Historial (${history.length} transacciones)`,``,
...history.slice(0,20).map(t=>`- ${t.txid.slice(0,16)}… | ${t.band} | ${t.time?new Date(t.time*1000).toLocaleDateString("es-ES"):"sin confirmar"}`),
];
const blob=new Blob([lines.join("\n")],{type:"text/markdown"});
const a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download="wallet-report.md";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"}}> MD</button>
</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 */}
<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}}>
<span style={{fontSize:"0.6rem",fontFamily:"monospace",fontWeight:700,color:walletReport.health.color}}>{walletReport.health.band}</span>
</div>
<div>
<div style={{fontSize:"0.72rem",color:walletReport.health.color,fontWeight:700,marginBottom:4}}>
{walletReport.health.band==="ALTA"?"Wallet bien compartimentado":walletReport.health.band==="MEDIA"?"Vinculación parcial detectada":"Vinculación significativa"}
</div>
<div style={{fontSize:"0.65rem",color:C.t2,lineHeight:1.5}}>{walletReport.health.msg}</div>
<div style={{display:"flex",gap:16,marginTop:8}}>
{[
{label:"Txs",v:walletReport.totalTxs},
{label:"Activas",v:walletReport.activeCount},
{label:"Reutilizadas",v:walletReport.reusedAddrs.length,warn:walletReport.reusedAddrs.length>0},
{label:"Clusters",v:walletReport.clusters.length,warn:walletReport.clusters.length>0},
].map(({label,v,warn})=>(
<div key={label}>
<div style={{fontSize:"0.55rem",color:C.t2,fontFamily:"monospace"}}>{label}</div>
<div style={{fontSize:"0.8rem",fontFamily:"monospace",fontWeight:700,color:warn?C.amber:C.t1}}>{v}</div>
</div>
))}
</div>
</div>
</div>
{/* Bloque 2: Clusters de vinculación */}
{walletReport.clusters.length>0&&(
<div style={{marginBottom:14}}>
<div style={{fontSize:"0.6rem",color:C.amber,fontFamily:"monospace",fontWeight:700,marginBottom:8,letterSpacing:"0.15em"}}>CLUSTERS DE VINCULACIÓN</div>
<div style={{display:"flex",flexDirection:"column",gap:8}}>
{walletReport.clusters.map((cluster,i)=>{
const reasons=walletReport.linkReasons.filter(r=>r.addrs.some(a=>cluster.includes(a)));
const barW=Math.min(100,Math.round((cluster.length/Math.max(...walletReport.clusters.map(c=>c.length)))*100));
return (
<div key={i} style={{padding:"10px 12px",background:C.bgCard,border:`1px solid ${C.amber}30`,borderRadius:6}}>
<div style={{display:"flex",alignItems:"center",gap:10,marginBottom:6}}>
<div style={{fontSize:"0.62rem",color:C.amber,fontFamily:"monospace",fontWeight:600}}>Cluster {i+1}</div>
<div style={{flex:1,height:4,background:C.bg,borderRadius:2,overflow:"hidden"}}>
<div style={{width:`${barW}%`,height:"100%",background:C.amber,borderRadius:2}}/>
</div>
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace"}}>{cluster.length} dirs.</div>
</div>
{reasons.length>0&&(
<div style={{fontSize:"0.6rem",color:C.t2,marginBottom:6,lineHeight:1.4}}>
Vinculadas porque {reasons[0].reason}
{reasons.length>1&&<span> (+{reasons.length-1} más)</span>}
</div>
)}
<div style={{display:"flex",flexDirection:"column",gap:2}}>
{cluster.slice(0,4).map((a,j)=>(
<div key={j} style={{fontSize:"0.58rem",color:C.t1,fontFamily:"monospace"}}>
{labels?.get(a)&&<span style={{color:C.purple,marginRight:6}}>🏷 {labels.get(a)}</span>}
{a.slice(0,14)}{a.slice(-8)}
</div>
))}
{cluster.length>4&&<div style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace"}}>+{cluster.length-4} más</div>}
</div>
</div>
);
})}
</div>
</div>
)}
{walletReport.clusters.length===0&&(
<div style={{marginBottom:14,padding:"10px 12px",background:C.bgCard,border:`1px solid ${C.green}30`,borderRadius:6,fontSize:"0.65rem",color:C.green,fontFamily:"monospace"}}>
No se detectan clusters de vinculación entre tus monedas.
</div>
)}
{/* Bloque 3: Historial */}
{walletReport.history.length>0&&(
<div>
<div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",fontWeight:700,marginBottom:8,letterSpacing:"0.15em"}}>HISTORIAL {walletReport.history.length} TRANSACCIONES</div>
<div style={{display:"flex",flexDirection:"column",gap:4,maxHeight:320,overflowY:"auto"}}>
{walletReport.history.map((t,i)=>{
const bandColor=t.band==="ALTA"?C.green:t.band==="MEDIA"?C.amber:t.band==="BAJA"?C.red:C.t2;
return (
<div key={i} onClick={()=>{setQuery(t.txid);setShowWalletPanel(false);setTimeout(()=>analyze(t.txid),50);}}
style={{display:"flex",alignItems:"center",gap:10,padding:"7px 10px",background:C.bgCard,borderRadius:5,cursor:"pointer",border:`1px solid ${C.border}`,transition:"border-color 0.15s"}}
onMouseEnter={e=>e.currentTarget.style.borderColor=bandColor}
onMouseLeave={e=>e.currentTarget.style.borderColor=C.border}>
<div style={{width:6,height:6,borderRadius:"50%",background:bandColor,flexShrink:0}}/>
<div style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace",minWidth:70}}>
{t.time?new Date(t.time*1000).toLocaleDateString("es-ES",{day:"2-digit",month:"2-digit",year:"2-digit"}):"pendiente"}
</div>
<div style={{fontSize:"0.6rem",color:C.t1,fontFamily:"monospace",flex:1,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>
{labels?.get(t.txid)&&<span style={{color:C.purple,marginRight:6}}>🏷</span>}
{t.txid.slice(0,20)}
</div>
<div style={{fontSize:"0.58rem",color:bandColor,fontFamily:"monospace",fontWeight:600,flexShrink:0}}>{t.band||"—"}</div>
</div>
);
})}
</div>
</div>
)}
</Card>
)}
{walletReport?.error&&(
<Card glow={C.red}>
<div style={{fontSize:"0.65rem",color:C.red,fontFamily:"monospace"}}>Error al analizar el wallet: {walletReport.error}</div>
</Card>
)}
{showLabelModal&&(
<Card glow={C.purple}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:10}}>
<div style={{fontSize:"0.65rem",color:C.purple,fontFamily:"monospace",fontWeight:700}}>IMPORTAR ETIQUETAS BIP-329</div>
<button onClick={()=>setShowLabelModal(false)} style={{background:"none",border:"none",color:C.t2,cursor:"pointer",fontSize:"1rem",lineHeight:1}}>×</button>
</div>
<div style={{fontSize:"0.68rem",color:C.t2,marginBottom:12}}>
Exporta tus etiquetas desde Sparrow: <span style={{color:C.t1,fontFamily:"monospace"}}>File Export Export Labels</span>. El archivo <span style={{color:C.t1,fontFamily:"monospace"}}>.jsonl</span> se carga en memoria — no sale del navegador.
</div>
{labelInfo&&(
<div style={{marginBottom:10}}>
<div style={{fontSize:"0.65rem",color:C.green,fontFamily:"monospace",marginBottom:8}}> {labelInfo.file} {labelInfo.count} etiquetas cargadas</div>
<div style={{maxHeight:160,overflowY:"auto",display:"flex",flexDirection:"column",gap:3}}>
{[...labels.entries()].map(([ref,lbl])=>(
<div key={ref} style={{display:"flex",gap:8,padding:"3px 6px",background:C.bgCard,borderRadius:4,alignItems:"baseline"}}>
<span style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace",flexShrink:0,maxWidth:120,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{ref.length>20?ref.slice(0,10)+"…"+ref.slice(-8):ref}</span>
<span style={{fontSize:"0.65rem",color:C.t1,fontFamily:"monospace"}}>{lbl}</span>
</div>
))}
</div>
</div>
)}
<label style={{display:"flex",alignItems:"center",gap:10,padding:"10px 14px",background:C.bgCard,border:`1px dashed ${C.purple}60`,borderRadius:6,cursor:"pointer"}}>
<span style={{fontSize:"0.72rem",color:C.purple}}></span>
<span style={{fontSize:"0.7rem",color:C.t2}}>Seleccionar archivo .jsonl</span>
<input type="file" accept=".jsonl,.json" style={{display:"none"}} onChange={e=>e.target.files[0]&&loadLabels(e.target.files[0])}/>
</label>
{labels.size>0&&(
<button onClick={()=>{setLabels(new Map());setLabelInfo(null);setShowLabelModal(false);}}
style={{marginTop:8,padding:"5px 12px",background:"none",border:`1px solid ${C.red}40`,borderRadius:6,color:C.red,fontFamily:"monospace",fontSize:"0.65rem",cursor:"pointer"}}>
Borrar etiquetas
</button>
)}
</Card>
)}
<Card>
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",marginBottom:10}}>ANALIZAR TRANSACCIÓN introduce un txid</div>
<div style={{display:"flex",gap:8}}>
<input value={query} onChange={e=>setQuery(e.target.value)} onKeyDown={e=>e.key==="Enter"&&analyze()}
placeholder="txid (64 caracteres hex)"
style={{flex:1,background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"11px 14px",color:C.t1,fontFamily:"monospace",fontSize:"0.78rem",outline:"none"}}
onFocus={e=>e.target.style.borderColor=C.purple} onBlur={e=>e.target.style.borderColor=C.borderBright}
/>
<button onClick={()=>analyze()} style={{padding:"11px 20px",background:C.purpleMuted,border:`1px solid ${C.purple}40`,borderRadius:6,color:C.purple,fontFamily:"monospace",fontSize:"0.75rem",cursor:"pointer",fontWeight:700}}>
{loading?"···":"ANALIZAR"}
</button>
</div>
{err&&<div style={{marginTop:10,padding:"8px 12px",background:C.redMuted,border:`1px solid ${C.red}30`,borderRadius:6,color:C.red,fontFamily:"monospace",fontSize:"0.72rem"}}>{err}</div>}
</Card>
{loading&&<Spinner/>}
{tx&&analysis&&(
<div style={{display:"flex",flexDirection:"column",gap:10}}>
<Card glow={C.purple}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"flex-start",marginBottom:12}}>
<div>
<div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",marginBottom:4}}>TRANSACCIÓN ANALIZADA</div>
<div style={{fontSize:"0.68rem",fontFamily:"monospace",color:C.blue,wordBreak:"break-all"}}>{tx.txid}</div>
{labels&&labels.get(tx.txid)&&(
<div style={{marginTop:8,display:"flex",alignItems:"center",gap:8,padding:"6px 10px",background:C.purpleMuted,border:`1px solid ${C.purple}60`,borderRadius:6}}>
<span style={{fontSize:"0.7rem"}}>🏷</span>
<span style={{fontSize:"0.75rem",color:C.purple,fontFamily:"monospace",fontWeight:600}}>{labels.get(tx.txid)}</span>
</div>
)}
</div>
{tx.status?.confirmed?<Badge color={C.green}>#{fmt.num(tx.status.block_height)}</Badge>:<Badge color={C.amber}>MEMPOOL</Badge>}
</div>
<div style={{display:"grid",gridTemplateColumns:"repeat(auto-fill,minmax(120px,1fr))",gap:10,paddingTop:10,borderTop:`1px solid ${C.border}`}}>
<Tag label="Fee" value={tx.fee?`${fmt.num(tx.fee)} sat`:"-"} color={C.amber}/>
<Tag label="Tasa fee" value={tx.feerate?`${Number(tx.feerate).toFixed(1)} sat/vB`:"-"} color={C.amber}/>
<Tag label="vSize" value={tx.weight?`${Math.ceil(tx.weight/4)} vB`:"-"}/>
<Tag label="Inputs" value={tx.vin?.length||0} color={C.t1}/>
<Tag label="Outputs" value={tx.vout?.length||0} color={C.t1}/>
<Tag label="Fecha" value={tx.status?.block_time?fmt.date(tx.status.block_time):"-"}/>
</div>
{walletAddrs&&tx.vout&&tx.vout.some(o=>myAddr(o.scriptpubkey_address))&&(
<div style={{marginTop:10,paddingTop:10,borderTop:`1px solid ${C.border}`}}>
<div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",marginBottom:6}}>OUTPUTS DE TU WALLET</div>
<div style={{display:"flex",flexDirection:"column",gap:4}}>
{tx.vout.map((o,i)=>{
const mine = myAddr(o.scriptpubkey_address);
if (!mine) return null;
const lbl = labels?.get(o.scriptpubkey_address);
return (
<div key={i} style={{display:"flex",alignItems:"center",gap:8,padding:"5px 8px",background:mine==="cambio"?C.amberMuted:C.greenMuted,border:`1px solid ${mine==="cambio"?C.amber:C.green}40`,borderRadius:5}}>
<span style={{fontSize:"0.65rem",color:mine==="cambio"?C.amber:C.green,fontFamily:"monospace",fontWeight:600}}>{mine==="cambio"?"↺ cambio":"↓ recepción"}</span>
<span style={{fontSize:"0.68rem",color:C.t1,fontFamily:"monospace"}}>{fmt.sats(o.value)}</span>
{lbl&&<span style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace"}}>· {lbl}</span>}
<span style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace",marginLeft:"auto"}}>{o.scriptpubkey_address?.slice(0,12)}</span>
</div>
);
})}
</div>
</div>
)}
</Card>
<PrivacyLab analysis={analysis} tx={tx} labels={labels} myAddr={myAddr}/>
<RastroProcedencia tx={tx} base={base} labels={labels}/>
<TxVerifier base={base}/>
</div>
)}
</div>
);
}
// ── PERITAJE ─────────────────────────────────────────────────────────
function PeritajeForense({base}) {
const {get, getStrict} = useApi(base);
const [txidInput, setTxidInput] = useState("");
const [voutInput, setVoutInput] = useState("0");
const [amountInput, setAmountInput] = useState("");
const [declaracion, setDeclaracion] = useState("");
const [maxHops, setMaxHops] = useState(8);
const [busy, setBusy] = useState(false); // hay una petición al nodo en curso (iniciar o avanzar un salto)
const [progress, setProgress] = useState("");
const [error, setError] = useState(null);
const [report, setReport] = useState(null);
const [tick, setTick] = useState(0); // fuerza re-render al mutar traceRef.current
// El rastreo vive en un ref (no en estado) porque lo muta directamente
// advanceForensicHop entre clics del usuario — "tick" es lo que le dice
// a React que vuelva a leerlo. Así el estado (nodos, aristas, frontier)
// sobrevive entre saltos sin tener que reconstruirlo cada vez.
const traceRef = useRef(null);
const avanzarSalto = async (trace) => {
setBusy(true); setError(null);
try {
await advanceForensicHop(trace, { maxHops, onProgress: setProgress });
setTick(t => t+1);
} catch(e) {
setError(e.message);
}
setBusy(false); setProgress("");
};
const iniciar = async () => {
const txid = txidInput.trim();
if (!/^[0-9a-f]{64}$/i.test(txid)) { setError("Introduce un txid de origen válido (64 caracteres hex)."); return; }
const vout = parseInt(voutInput, 10);
if (!Number.isInteger(vout) || vout < 0) { setError("El vout debe ser un número entero ≥ 0."); return; }
if (!base) { setError("Necesitas conectar tu nodo (CONFIG) para rastrear — el peritaje consulta tu Mempool self-hosted, no funciona en modo demo."); return; }
setError(null); setBusy(true); setReport(null);
try {
const amountStolen = amountInput.trim() ? Math.round(parseFloat(amountInput)*1e8) : null;
const trace = await initForensicTrace({ get, getStrict, originTxid: txid, originVout: vout, amountStolen });
traceRef.current = trace;
setTick(t => t+1);
await avanzarSalto(trace); // "iniciar" ya explora el primer salto — un solo clic para ver algo
} catch(e) {
setError(e.message);
setBusy(false);
}
};
const seguir = () => {
if (traceRef.current && !traceRef.current.done && !busy) avanzarSalto(traceRef.current);
};
const limpiar = () => {
traceRef.current = null; setTick(t=>t+1);
setReport(null); setError(null); setBusy(false); setProgress("");
};
const generarInforme = () => {
if (!traceRef.current) return;
const g = finalizeForensicGraph(traceRef.current);
setReport(buildForensicReport(g, declaracion));
};
const trace = traceRef.current; // solo lectura, para el render — tick fuerza refrescarlo
const exportJSON = () => {
const exportData = {
summary: report.summary, declaracion: report.declaracion,
chronology: report.chronology, attributed: report.attributed,
unspentFunds: report.unspentFunds, conclusions: report.conclusions,
failedBranches: report.failedBranches, queryErrors: report.queryErrors,
recommendations: report.recommendations, methodology: report.methodology,
verificationAppendix: report.verificationAppendix,
};
const blob = new Blob([JSON.stringify(exportData,null,2)], {type:"application/json"});
const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "peritaje-forense.json"; a.click();
};
const exportMD = () => {
const { summary, declaracion:decl, chronology, attributed, unspentFunds, failedBranches, conclusions, recommendations, methodology, verificationAppendix } = report;
const lines = [
`# Peritaje forense — Txoko`, ``,
`## Resumen`, ``,
`- Origen: ${summary.originTxid}:${summary.originVout}`,
`- Dirección de origen: ${summary.originAddress}`,
`- Monto rastreado: ${summary.amountTraced} sats`,
summary.amountStolen ? `- Monto declarado como robado: ${summary.amountStolen} sats` : null,
`- Saltos: ${summary.hops} · Transacciones: ${summary.txCount} · Direcciones tocadas: ${summary.addressesTouched}`,
`- Generado: ${new Date(summary.generatedAt*1000).toLocaleString("es-ES")}`, ``,
decl ? `## Declaración del afectado (DECLARACION)\n\n${decl.text}\n` : null,
`## Cronología`, ``,
...chronology.map(c => `- [HECHO] ${c.txid.slice(0,16)}… · salto ${c.hop} · ${c.amount} sats · ${c.blockTime?new Date(c.blockTime*1000).toLocaleDateString("es-ES"):"sin confirmar"} — banda [INFERENCIA${c.band.certainty?"/"+c.band.certainty:""}] ${c.band.value}, huella [INFERENCIA${c.fingerprint.certainty?"/"+c.fingerprint.certainty:""}] ${c.fingerprint.value}`),
``,
`## Direcciones atribuidas al actor`, ``,
...(attributed.length===0 ? ["No se atribuyen direcciones adicionales con fundamento suficiente."] :
attributed.map(a => `- [INFERENCIA] ${a.address}${a.fundamentos.map(f=>`${f.basis} (${f.certainty})${f.refTxid?` [tx ${f.refTxid}]`:""}`).join("; ")}`)),
``,
`## Fondos localizados sin gastar`, ``,
...(unspentFunds.length===0 ? ["Ninguno detectado en las ramas exploradas."] :
unspentFunds.map(u => `- [HECHO] ${u.address}${u.amount} sats (tx ${u.txid.slice(0,16)}…, vout ${u.vout})`)),
``,
...((failedBranches && failedBranches.length>0) ? [
`## Ramas NO comprobadas (fallo de consulta)`, ``,
`Estas ramas no pudieron consultarse. **No** son fondos sin gastar: simplemente no se sabe qué pasó con ellas. El alcance del informe es incompleto hasta que se repita el rastreo sobre ellas.`, ``,
...failedBranches.map(f => `- [HECHO] ${f.address} — consulta fallida en tx ${f.txid.slice(0,16)}…, vout ${f.vout} (${f.amount} sats en juego)`),
``,
] : []),
`## Conclusiones`, ``,
...conclusions.map((c,i) => `${i+1}. [${c.level}${c.certainty?"/"+c.certainty:""}] ${c.text}`),
``,
`## Recomendaciones`, ``,
...recommendations.map(r => `- ${r.text}`),
``,
`## Metodología`, ``,
`**Niveles de certeza:** ${methodology.levels}`, ``,
`**Heurísticas usadas:** ${methodology.heuristics}`, ``,
`**Límites:** ${methodology.limits}`, ``,
`## Anexo de verificación`, ``,
`**Txids:**`, ...verificationAppendix.txids.map(t=>`- ${t}`), ``,
`**Direcciones:**`, ...verificationAppendix.addresses.map(a=>`- ${a}`),
].filter(l => l !== null);
const blob = new Blob([lines.join("\n")], {type:"text/markdown"});
const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "peritaje-forense.md"; a.click();
};
return (
<div style={{display:"flex",flexDirection:"column",gap:12}}>
{!base&&<DemoBanner/>}
<SectionTitle accent={C.red} icon="🔍">PERITAJE FORENSE</SectionTitle>
<Card>
<div style={{fontSize:"0.65rem",color:C.t2,fontFamily:"monospace",marginBottom:10,lineHeight:1.6}}>
Rastrea fondos robados o perdidos hacia adelante, salto a salto, desde la transacción de origen hasta un punto de parada natural (custodio identificado, dilución, CoinJoin, o fondos aún sin gastar). <strong style={{color:C.t1}}>A demanda:</strong> tú decides cuándo se explora el siguiente salto — nada corre sin que lo pidas, igual que el rastro de procedencia hacia atrás. Todo sobre tu propio nodo — nada sale de tu red.
</div>
<div style={{display:"flex",flexDirection:"column",gap:8}}>
<div style={{display:"flex",gap:8}}>
<input value={txidInput} onChange={e=>setTxidInput(e.target.value)} disabled={!!trace}
placeholder="txid de origen (64 caracteres hex)"
style={{flex:3,background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"10px 12px",color:C.t1,fontFamily:"monospace",fontSize:"0.72rem",outline:"none",opacity:trace?0.5:1}}
/>
<input value={voutInput} onChange={e=>setVoutInput(e.target.value)} type="number" min="0" disabled={!!trace}
placeholder="vout" title="Índice del output robado dentro de esa transacción"
style={{flex:1,background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"10px 12px",color:C.t1,fontFamily:"monospace",fontSize:"0.72rem",outline:"none",opacity:trace?0.5:1}}
/>
<select value={maxHops} onChange={e=>setMaxHops(Number(e.target.value))}
title="Tope de saltos — red de seguridad aparte del control manual, no debería hacer falta tocarlo"
style={{background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"10px 8px",color:C.t1,fontFamily:"monospace",fontSize:"0.7rem",outline:"none",cursor:"pointer"}}>
<option value={8}>tope 8</option>
<option value={15}>tope 15</option>
<option value={25}>tope 25</option>
</select>
</div>
<input value={amountInput} onChange={e=>setAmountInput(e.target.value)} type="number" step="0.00000001" min="0" disabled={!!trace}
placeholder="Importe estimado robado en BTC (opcional — activa la detección de dilución)"
style={{background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"10px 12px",color:C.t1,fontFamily:"monospace",fontSize:"0.72rem",outline:"none",opacity:trace?0.5:1}}
/>
<textarea value={declaracion} onChange={e=>setDeclaracion(e.target.value)} rows={3}
placeholder="Declaración del afectado (opcional): qué pasó, cuándo lo notaste, cómo crees que ocurrió. Se muestra aparte en el informe, etiquetada como DECLARACIÓN — nunca se mezcla con los hechos verificados. Puedes escribirla o editarla en cualquier momento, incluso con el rastreo en marcha."
style={{background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"10px 12px",color:C.t1,fontFamily:"monospace",fontSize:"0.68rem",outline:"none",resize:"vertical"}}
/>
{!trace&&(
<div style={{display:"flex",gap:8,alignItems:"center"}}>
<button onClick={iniciar} disabled={busy}
style={{padding:"9px 18px",background:C.redMuted,border:`1px solid ${C.red}50`,borderRadius:6,color:C.red,fontFamily:"monospace",fontSize:"0.72rem",cursor:"pointer",fontWeight:700}}>
{busy?"···":"Iniciar rastreo forense"}
</button>
</div>
)}
{busy&&progress&&<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace"}}>{progress}</div>}
{error&&<div style={{padding:"8px 12px",background:C.redMuted,border:`1px solid ${C.red}30`,borderRadius:6,color:C.red,fontFamily:"monospace",fontSize:"0.7rem"}}>{error}</div>}
</div>
</Card>
{trace&&(
<Card glow={C.red}>
<div style={{fontSize:"0.65rem",color:C.red,fontFamily:"monospace",fontWeight:700,marginBottom:10}}>PROGRESO DEL RASTREO</div>
<div style={{display:"flex",gap:16,flexWrap:"wrap",marginBottom:12}}>
{[
{label:"Salto actual", v: trace.hop},
{label:"Transacciones exploradas", v: trace.nodes.size},
{label:"Ramas pendientes", v: trace.frontier.length},
{label:"Ramas terminales", v: trace.unspentTerminals.length},
{label:"Consultas fallidas", v: (trace.queryErrors||[]).length, alert:(trace.queryErrors||[]).length>0},
].map(({label,v,alert})=>(
<div key={label}>
<div style={{fontSize:"0.55rem",color:C.t2,fontFamily:"monospace"}}>{label}</div>
<div style={{fontSize:"0.8rem",fontFamily:"monospace",fontWeight:700,color:alert?C.red:C.t1}}>{v}</div>
</div>
))}
</div>
{/* Un rastreo con consultas fallidas NO está completo: decir
"no quedan ramas" cuando el nodo falló convierte un error en
una conclusión. */}
{trace.done&&(trace.queryErrors||[]).length>0?(
<div style={{padding:"9px 12px",background:C.redMuted,border:`1px solid ${C.red}40`,borderRadius:6,marginBottom:10}}>
<div style={{fontSize:"0.65rem",color:C.red,fontFamily:"monospace",fontWeight:700,marginBottom:4}}>
Rastro incompleto {trace.queryErrors.length} consulta(s) al nodo fallaron
</div>
<div style={{fontSize:"0.62rem",color:C.t1,fontFamily:"monospace",lineHeight:1.6}}>
No quedan ramas por explorar, pero algunas no se pudieron comprobar: no se sabe si esos fondos siguen sin gastar o se movieron. Si el nodo estaba ocupado, repetir el rastreo ("Limpiar") suele bastar. Si vuelve a fallar en la misma dirección, no es casualidad: esa dirección tiene un historial demasiado pesado para servirlo dentro del tiempo de espera, y el corte es la protección del nodo haciendo su trabajo. Compruébala a mano en el explorador antes de dar el rastro por cerrado.
</div>
<div style={{marginTop:6,display:"flex",flexDirection:"column",gap:2}}>
{trace.queryErrors.slice(0,5).map((q,i)=>(
<div key={i} style={{fontSize:"0.56rem",color:C.t2,fontFamily:"monospace",wordBreak:"break-all"}}>
{q.address} {q.message} ({q.context})
</div>
))}
{trace.queryErrors.length>5&&<div style={{fontSize:"0.56rem",color:C.t2,fontFamily:"monospace"}}>y {trace.queryErrors.length-5} más</div>}
</div>
</div>
):trace.done?(
<div style={{fontSize:"0.65rem",color:C.green,fontFamily:"monospace",marginBottom:10}}> Rastro completo no quedan ramas por explorar.</div>
):(
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",marginBottom:10}}>
{trace.frontier.length} rama(s) esperando tu confirmación para seguir al salto {trace.hop+1}.
</div>
)}
<div style={{display:"flex",gap:8,flexWrap:"wrap"}}>
{!trace.done&&(
<button onClick={seguir} disabled={busy}
style={{padding:"8px 16px",background:C.redMuted,border:`1px solid ${C.red}50`,borderRadius:6,color:C.red,fontFamily:"monospace",fontSize:"0.68rem",cursor:"pointer",fontWeight:700}}>
{busy?"···":`Seguir el rastro (salto ${trace.hop}${trace.hop+1})`}
</button>
)}
<button onClick={generarInforme} disabled={busy||trace.nodes.size===0}
title={trace.nodes.size===0?"Aún no hay ninguna transacción explorada que incluir en el informe":""}
style={{padding:"8px 16px",background:C.purpleMuted,border:`1px solid ${C.purple}50`,borderRadius:6,color:C.purple,fontFamily:"monospace",fontSize:"0.68rem",cursor:trace.nodes.size===0?"not-allowed":"pointer",fontWeight:700,opacity:trace.nodes.size===0?0.45:1}}>
{trace.done?"Generar informe":"Generar informe con lo explorado hasta ahora"}
</button>
<button onClick={limpiar} disabled={busy}
style={{padding:"8px 14px",background:"none",border:`1px solid ${C.border}`,borderRadius:6,color:C.t2,fontFamily:"monospace",fontSize:"0.65rem",cursor:"pointer"}}>
Limpiar
</button>
</div>
{trace.nodes.size===0&&!busy&&(
<div style={{marginTop:8,fontSize:"0.6rem",color:C.t2,fontFamily:"monospace"}}>
El informe se activa en cuanto haya al menos una transacción explorada.
</div>
)}
{busy&&progress&&<div style={{marginTop:8,fontSize:"0.62rem",color:C.t2,fontFamily:"monospace"}}>{progress}</div>}
</Card>
)}
{report&&(
<Card glow={C.red}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:14,flexWrap:"wrap",gap:8}}>
<div style={{fontSize:"0.65rem",color:C.red,fontFamily:"monospace",fontWeight:700}}>INFORME DE PERITAJE</div>
<div style={{display:"flex",gap:6}}>
<button onClick={exportJSON} 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={exportMD} style={{padding:"3px 10px",background:"none",border:`1px solid ${C.border}`,borderRadius:4,color:C.t2,fontFamily:"monospace",fontSize:"0.6rem",cursor:"pointer"}}> MD</button>
</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&&(
<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.
</div>
)}
{/* Resumen */}
<div style={{display:"flex",gap:16,flexWrap:"wrap",padding:"12px 14px",background:C.bgCard,borderRadius:8,marginBottom:14,border:`1px solid ${C.border}`}}>
{[
{label:"Rastreado", v: fmt.sbtc(report.summary.amountTraced)},
{label:"Saltos", v: report.summary.hops},
{label:"Transacciones", v: report.summary.txCount},
{label:"Direcciones tocadas", v: report.summary.addressesTouched},
].map(({label,v})=>(
<div key={label}>
<div style={{fontSize:"0.55rem",color:C.t2,fontFamily:"monospace"}}>{label}</div>
<div style={{fontSize:"0.8rem",fontFamily:"monospace",fontWeight:700,color:C.t1}}>{v}</div>
</div>
))}
</div>
{/* Declaración */}
{report.declaracion&&(
<div style={{marginBottom:14,padding:"10px 12px",background:C.blueMuted,border:`1px solid ${C.blue}40`,borderRadius:6}}>
<div style={{fontSize:"0.58rem",color:C.blue,fontFamily:"monospace",fontWeight:700,marginBottom:6,letterSpacing:"0.1em"}}>DECLARACIÓN DEL AFECTADO</div>
<div style={{fontSize:"0.68rem",color:C.t1,lineHeight:1.6,whiteSpace:"pre-wrap"}}>{report.declaracion.text}</div>
</div>
)}
{/* Cronología */}
<div style={{marginBottom:14}}>
<div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",fontWeight:700,marginBottom:8,letterSpacing:"0.15em"}}>CRONOLOGÍA {report.chronology.length} TRANSACCIONES</div>
<div style={{display:"flex",flexDirection:"column",gap:4,maxHeight:320,overflowY:"auto"}}>
{report.chronology.map((c,i)=>{
const bColor = bandColorRastro(c.band.value);
return (
<div key={i} style={{padding:"7px 10px",background:C.bgCard,borderRadius:5,border:`1px solid ${C.border}`}}>
<div style={{display:"flex",alignItems:"center",gap:8,flexWrap:"wrap"}}>
<span style={{fontSize:"0.56rem",color:C.t2,fontFamily:"monospace",minWidth:50}}>salto {c.hop}</span>
<span style={{fontSize:"0.6rem",color:C.t1,fontFamily:"monospace",flex:1,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{c.txid.slice(0,20)}</span>
<span style={{fontSize:"0.58rem",color:bColor,fontFamily:"monospace",fontWeight:600}}>{c.band.value}</span>
</div>
<div style={{fontSize:"0.56rem",color:C.t2,fontFamily:"monospace",marginTop:3}}>
{fmt.num(c.amount)} sat · {c.blockTime?fmt.date(c.blockTime):"sin confirmar"} · huella: {c.fingerprint.value}{c.fingerprint.certainty?` (${c.fingerprint.certainty})`:""}
</div>
{c.entityMarks.length>0&&(
<div style={{display:"flex",gap:6,flexWrap:"wrap",marginTop:4}}>
{c.entityMarks.map((m,j)=>(<span key={j} style={{fontSize:"0.55rem",fontFamily:"monospace",fontWeight:700,color:m.color,background:`${m.color}1a`,border:`1px solid ${m.color}55`,borderRadius:4,padding:"1px 6px"}}>{m.txt}</span>))}
</div>
)}
</div>
);
})}
</div>
</div>
{/* Direcciones atribuidas */}
<div style={{marginBottom:14}}>
<div style={{fontSize:"0.6rem",color:C.amber,fontFamily:"monospace",fontWeight:700,marginBottom:8,letterSpacing:"0.15em"}}>DIRECCIONES ATRIBUIDAS AL ACTOR {report.attributed.length}</div>
{report.attributed.length===0?(
<div style={{fontSize:"0.65rem",color:C.t2,fontFamily:"monospace"}}>No se atribuyen direcciones adicionales con fundamento suficiente.</div>
):(
<div style={{display:"flex",flexDirection:"column",gap:6}}>
{report.attributed.map((a,i)=>(
<div key={i} style={{padding:"8px 10px",background:C.bgCard,border:`1px solid ${C.amber}30`,borderRadius:6}}>
<div style={{fontSize:"0.6rem",color:C.t1,fontFamily:"monospace",marginBottom:4,wordBreak:"break-all"}}>{a.address}</div>
<div style={{display:"flex",flexDirection:"column",gap:2}}>
{a.fundamentos.map((f,j)=>(
<div key={j} style={{fontSize:"0.56rem",color:C.t2,fontFamily:"monospace"}}>
<span style={{color:C.amber}}>[{f.certainty}]</span> {f.basis}{f.refTxid?` — tx ${f.refTxid.slice(0,16)}…`:""}
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
{/* Fondos sin gastar */}
<div style={{marginBottom:14}}>
<div style={{fontSize:"0.6rem",color:C.green,fontFamily:"monospace",fontWeight:700,marginBottom:8,letterSpacing:"0.15em"}}>FONDOS LOCALIZADOS SIN GASTAR {report.unspentFunds.length}</div>
{report.unspentFunds.length===0?(
<div style={{fontSize:"0.65rem",color:C.t2,fontFamily:"monospace"}}>Ninguno detectado en las ramas exploradas.</div>
):(
<div style={{display:"flex",flexDirection:"column",gap:4}}>
{report.unspentFunds.map((u,i)=>(
<div key={i} style={{padding:"7px 10px",background:C.greenMuted,border:`1px solid ${C.green}30`,borderRadius:6}}>
<div style={{fontSize:"0.6rem",color:C.t1,fontFamily:"monospace",wordBreak:"break-all"}}>{u.address}</div>
<div style={{fontSize:"0.58rem",color:C.green,fontFamily:"monospace",fontWeight:700}}>{fmt.num(u.amount)} sat</div>
</div>
))}
</div>
)}
</div>
{report.failedBranches&&report.failedBranches.length>0&&(
<div style={{marginBottom:14,padding:"10px 12px",background:C.redMuted,border:`1px solid ${C.red}40`,borderRadius:6}}>
<div style={{fontSize:"0.62rem",color:C.red,fontFamily:"monospace",fontWeight:700,marginBottom:6,letterSpacing:"0.1em"}}>
RAMAS NO COMPROBADAS {report.failedBranches.length}
</div>
<div style={{fontSize:"0.63rem",color:C.t1,fontFamily:"monospace",lineHeight:1.6,marginBottom:8}}>
La consulta al nodo falló en estas ramas. <strong>No son fondos sin gastar</strong> — no se sabe qué pasó con ellas. El alcance de este informe es incompleto hasta repetir el rastreo sobre ellas.
</div>
<div style={{display:"flex",flexDirection:"column",gap:4}}>
{report.failedBranches.map((f,i)=>(
<div key={i} style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace",wordBreak:"break-all"}}>
{f.address} · {fmt.num(f.amount)} sat · tx {f.txid.slice(0,16)}:{f.vout}
</div>
))}
</div>
</div>
)}
{report.truncatedBranches.length>0&&(
<div style={{marginBottom:14,padding:"10px 12px",background:C.amberMuted,border:`1px solid ${C.amber}30`,borderRadius:6}}>
<div style={{fontSize:"0.65rem",color:C.amber,fontFamily:"monospace"}}>
{report.truncatedBranches.length} rama(s) cortada(s) por el tope de saltos ({maxHops}) no es un punto de parada natural. Ese tope solo es una red de seguridad aparte del control manual; para explorarlas, sube el tope arriba y pulsa "Limpiar" para repetir el rastreo desde el principio con el tope nuevo (una rama ya cortada no se puede reanudar por separado).
</div>
</div>
)}
{/* Conclusiones */}
<div style={{marginBottom:14}}>
<div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",fontWeight:700,marginBottom:8,letterSpacing:"0.15em"}}>CONCLUSIONES</div>
<div style={{display:"flex",flexDirection:"column",gap:6}}>
{report.conclusions.map((c,i)=>(
<div key={i} style={{display:"flex",gap:8,fontSize:"0.66rem",color:C.t1,lineHeight:1.6}}>
<span style={{fontFamily:"monospace",color:C.t2,flexShrink:0}}>{i+1}.</span>
<span>
<span style={{fontFamily:"monospace",fontSize:"0.56rem",fontWeight:700,color:c.level==="HECHO"?C.green:c.level==="DECLARACION"?C.blue:C.amber,border:`1px solid ${c.level==="HECHO"?C.green:c.level==="DECLARACION"?C.blue:C.amber}40`,borderRadius:3,padding:"1px 5px",marginRight:6}}>
{c.level}{c.certainty?`/${c.certainty}`:""}
</span>
{c.text}
</span>
</div>
))}
</div>
</div>
{/* Recomendaciones */}
<div style={{marginBottom:14}}>
<div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",fontWeight:700,marginBottom:8,letterSpacing:"0.15em"}}>RECOMENDACIONES</div>
<div style={{display:"flex",flexDirection:"column",gap:8}}>
{report.recommendations.map((r,i)=>(
<div key={i} style={{padding:"8px 10px",background:r.fixed?C.redMuted:C.bgCard,border:`1px solid ${r.fixed?C.red:C.border}`,borderRadius:6,fontSize:"0.65rem",color:r.fixed?C.red:C.t2,lineHeight:1.6}}>
{r.text}
</div>
))}
</div>
</div>
{/* Metodología */}
<div style={{marginBottom:14}}>
<div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",fontWeight:700,marginBottom:8,letterSpacing:"0.15em"}}>METODOLOGÍA</div>
<div style={{fontSize:"0.64rem",color:C.t2,lineHeight:1.7,display:"flex",flexDirection:"column",gap:8}}>
<div><strong style={{color:C.t1}}>Niveles de certeza:</strong> {report.methodology.levels}</div>
<div><strong style={{color:C.t1}}>Heurísticas usadas:</strong> {report.methodology.heuristics}</div>
<div><strong style={{color:C.t1}}>Límites:</strong> {report.methodology.limits}</div>
</div>
</div>
{/* Anexo de verificación */}
<div>
<div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",fontWeight:700,marginBottom:8,letterSpacing:"0.15em"}}>ANEXO DE VERIFICACIÓN</div>
<div style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace",lineHeight:1.6}}>
{report.verificationAppendix.txids.length} txid(s) · {report.verificationAppendix.addresses.length} dirección(es). Compruébalos en tu propio nodo o en cualquier explorador.
</div>
</div>
</Card>
)}
</div>
);
}
function TxResult({tx, onAnalyze}) {
return (
<div style={{display:"flex",flexDirection:"column",gap:10}}>
<Card glow={C.blue}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"flex-start",marginBottom:12}}>
<div><div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",marginBottom:4}}>TRANSACCIÓN</div><div style={{fontSize:"0.7rem",fontFamily:"monospace",color:C.blue,wordBreak:"break-all"}}>{tx.txid}</div></div>
{tx.status&&tx.status.confirmed?<Badge color={C.green}>#{fmt.num(tx.status.block_height)} conf</Badge>:<Badge color={C.amber}>PENDIENTE</Badge>}
</div>
<div style={{display:"grid",gridTemplateColumns:"repeat(auto-fill,minmax(120px,1fr))",gap:12,paddingTop:12,borderTop:`1px solid ${C.border}`}}>
<Tag label="Bloque" value={tx.status&&tx.status.block_height?`#${fmt.num(tx.status.block_height)}`:"mempool"} color={C.purple}/>
<Tag label="Fee" value={tx.fee?`${fmt.num(tx.fee)} sat`:"-"} color={C.amber}/>
<Tag label="Tasa fee" value={tx.feerate?`${Number(tx.feerate).toFixed(1)} sat/vB`:"-"} color={C.amber}/>
<Tag label="vSize" value={tx.weight?`${Math.ceil(tx.weight/4)} vB`:"-"}/>
<Tag label="Tamaño" value={tx.size?`${tx.size} B`:"-"}/>
<Tag label="Fecha" value={tx.status&&tx.status.block_time?fmt.date(tx.status.block_time):"-"}/>
</div>
{onAnalyze&&<div style={{marginTop:12,paddingTop:12,borderTop:`1px solid ${C.border}`}}>
<button onClick={()=>onAnalyze(tx.txid)} style={{padding:"7px 16px",background:C.purpleMuted,border:`1px solid ${C.purple}40`,borderRadius:6,color:C.purple,fontFamily:"monospace",fontSize:"0.68rem",cursor:"pointer",fontWeight:700,letterSpacing:"0.1em"}}>
ANALIZAR PRIVACIDAD
</button>
</div>}
</Card>
<div style={{display:"grid",gridTemplateColumns:"1fr 20px 1fr",gap:8,alignItems:"start"}}>
<Card><div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",marginBottom:10,textTransform:"uppercase"}}>Inputs ({tx.vin.length})</div>{tx.vin.slice(0,5).map((inp,i)=><div key={i} style={{paddingBottom:8,marginBottom:8,borderBottom:i<Math.min(tx.vin.length,5)-1?`1px solid ${C.border}`:"none"}}>{inp.is_coinbase?<div style={{fontSize:"0.68rem",color:C.amber,fontFamily:"monospace"}}>coinbase</div>:<><div style={{fontSize:"0.62rem",fontFamily:"monospace",color:C.blue,marginBottom:3,wordBreak:"break-all"}}>{fmt.hash(inp.prevout&&inp.prevout.scriptpubkey_address)}</div>{inp.prevout&&inp.prevout.value!=null&&<div style={{fontSize:"0.68rem",color:C.red,fontFamily:"monospace"}}>{fmt.btc(inp.prevout.value)}</div>}</>}</div>)}</Card>
<div style={{display:"flex",alignItems:"center",justifyContent:"center",color:C.amber,paddingTop:30,fontSize:"0.8rem"}}></div>
<Card><div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",marginBottom:10,textTransform:"uppercase"}}>Outputs ({tx.vout.length})</div>{tx.vout.slice(0,5).map((out,i)=><div key={i} style={{paddingBottom:8,marginBottom:8,borderBottom:i<Math.min(tx.vout.length,5)-1?`1px solid ${C.border}`:"none"}}><div style={{fontSize:"0.62rem",fontFamily:"monospace",color:C.blue,marginBottom:3,wordBreak:"break-all"}}>{fmt.hash(out.scriptpubkey_address)}</div><div style={{fontSize:"0.68rem",color:C.green,fontFamily:"monospace"}}>{fmt.btc(out.value)}</div></div>)}</Card>
</div>
</div>
);
}
function AddressResult({addr, analysis}) {
const c=addr.chain_stats||{funded_txo_sum:0,spent_txo_sum:0,tx_count:0};
const m=addr.mempool_stats||{funded_txo_sum:0,spent_txo_sum:0};
const balance=c.funded_txo_sum-c.spent_txo_sum;
const unconf=(m.funded_txo_sum||0)-(m.spent_txo_sum||0);
return (
<div style={{display:"flex",flexDirection:"column",gap:10}}>
<Card glow={C.green}>
<div style={{marginBottom:12}}><div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",marginBottom:4}}>DIRECCIÓN</div><div style={{fontSize:"0.72rem",fontFamily:"monospace",color:C.green,wordBreak:"break-all"}}>{addr.address}</div></div>
<div style={{display:"grid",gridTemplateColumns:"repeat(auto-fill,minmax(130px,1fr))",gap:12,paddingTop:12,borderTop:`1px solid ${C.border}`}}>
<Tag label="Balance" value={fmt.btc(balance)} color={C.green}/>
<Tag label="No confirmado" value={unconf!==0?fmt.btc(unconf):"0"} color={unconf>0?C.amber:C.t2}/>
<Tag label="Total recibido" value={fmt.btc(c.funded_txo_sum)} color={C.blue}/>
<Tag label="Total enviado" value={fmt.btc(c.spent_txo_sum)} color={C.red}/>
<Tag label="Transacciones" value={fmt.num(c.tx_count)} color={C.t1}/>
<Tag label="UTXOs" value={fmt.num(addr.utxos?addr.utxos.length:0)} color={C.amber}/>
</div>
</Card>
{analysis&&<PrivacyLab analysis={analysis}/>}
{addr.utxos&&addr.utxos.length>0&&<Card><div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:10}}>UTXOs</div>{addr.utxos.slice(0,6).map((u,i)=><div key={i} style={{display:"flex",justifyContent:"space-between",alignItems:"center",padding:"6px 0",borderBottom:i<Math.min(addr.utxos.length,6)-1?`1px solid ${C.border}`:"none"}}><span style={{fontSize:"0.68rem",fontFamily:"monospace",color:C.blue}}>{fmt.hash(u.txid)}:{u.vout}</span><div style={{display:"flex",gap:6}}><Badge color={C.green}>{fmt.btc(u.value)}</Badge><Badge color={u.status&&u.status.confirmed?C.t2:C.amber}>{u.status&&u.status.confirmed?`#${fmt.num(u.status.block_height)}`:"mempool"}</Badge></div></div>)}</Card>}
{addr.txs&&addr.txs.length>0&&<Card><div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:10}}>Transacciones Recientes</div>{addr.txs.slice(0,8).map((tx,i)=>{const out=tx.vout?tx.vout.reduce((s,v)=>s+v.value,0):0;return<div key={i} style={{display:"flex",justifyContent:"space-between",alignItems:"center",padding:"7px 0",borderBottom:i<Math.min(addr.txs.length,8)-1?`1px solid ${C.border}`:"none"}}><div><div style={{fontSize:"0.68rem",fontFamily:"monospace",color:C.blue}}>{fmt.hash(tx.txid)}</div><div style={{fontSize:"0.6rem",color:C.t2,marginTop:2}}>{tx.status&&tx.status.block_time?fmt.date(tx.status.block_time):"pendiente"}</div></div><div style={{display:"flex",gap:6}}><Badge color={tx.status&&tx.status.confirmed?C.green:C.amber}>{tx.status&&tx.status.confirmed?"conf":"pend"}</Badge><Badge color={C.t2}>{fmt.btc(out)}</Badge></div></div>;})}</Card>}
</div>
);
}
function BlockResult({block}) {
return <Card glow={C.purple}><div style={{marginBottom:12}}><div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",marginBottom:4}}>BLOQUE</div><div style={{fontSize:"0.88rem",color:C.purple,fontFamily:"monospace",fontWeight:700}}>#{fmt.num(block.height)}</div><div style={{fontSize:"0.68rem",fontFamily:"monospace",color:C.t2,wordBreak:"break-all",marginTop:4}}>{block.id}</div></div><div style={{display:"grid",gridTemplateColumns:"repeat(auto-fill,minmax(130px,1fr))",gap:12,paddingTop:12,borderTop:`1px solid ${C.border}`}}><Tag label="Transacciones" value={fmt.num(block.tx_count)} color={C.blue}/><Tag label="Tamaño" value={fmt.mb(block.size)} color={C.t1}/><Tag label="Fees totales" value={block.extras&&block.extras.totalFees!=null?fmt.sbtc(block.extras.totalFees):"-"} color={C.amber}/><Tag label="Minero" value={block.extras&&block.extras.pool?block.extras.pool.name:"-"} color={C.green}/><Tag label="Fecha" value={fmt.date(block.timestamp)} color={C.t1}/></div></Card>;
}
function ConfigModal({config,onSave,onClose}) {
const [url,setUrl]=useState(config.url||"");
const [name,setName]=useState(config.name||"");
const [remember,setRemember]=useState(()=>!!localStorage.getItem("txoko-config"));
const [testing,setTesting]=useState(false);
const [testResult,setTestResult]=useState(null);
const testConn=async()=>{
setTesting(true);setTestResult(null);
try{ const res=await fetchWithTimeout(`${url.replace(/\/$/,"")}/api/v1/fees/recommended`); if(res.ok)setTestResult({ok:true,msg:"Conexión exitosa ✓"}); else setTestResult({ok:false,msg:`Error HTTP ${res.status}`}); }
catch(e){ const msg = e.name==="AbortError" ? "Sin respuesta (timeout) — ¿es correcta la URL y está el nodo accesible?" : `No se pudo conectar: ${e.message}`; setTestResult({ok:false,msg}); }
setTesting(false);
};
const handleSave=()=>{
const cfg={url:url.replace(/\/$/,""),name:name||"Mi nodo"};
if(remember){ localStorage.setItem("txoko-config",JSON.stringify(cfg)); }
else { localStorage.removeItem("txoko-config"); }
onSave(cfg);
};
return (
<div style={{position:"fixed",inset:0,background:"rgba(5,8,13,0.95)",display:"flex",alignItems:"center",justifyContent:"center",zIndex:100,padding:16}}>
<Card style={{maxWidth:440,width:"100%"}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:20}}>
<span style={{fontFamily:"monospace",color:C.green,fontSize:"0.82rem",fontWeight:700}}>CONFIGURACIÓN</span>
<button onClick={onClose} style={{background:"none",border:"none",color:C.t2,cursor:"pointer",fontSize:"1rem",fontFamily:"monospace"}}></button>
</div>
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",marginBottom:6}}>Nombre del nodo</div>
<input value={name} onChange={e=>setName(e.target.value)} placeholder="Mi nodo"
style={{width:"100%",background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"10px 12px",color:C.t1,fontFamily:"monospace",fontSize:"0.8rem",outline:"none",boxSizing:"border-box",marginBottom:14}}/>
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",marginBottom:6}}>URL de tu Mempool</div>
<input value={url} onChange={e=>setUrl(e.target.value)} placeholder="http://100.x.x.x:4080"
style={{width:"100%",background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"10px 12px",color:C.t1,fontFamily:"monospace",fontSize:"0.8rem",outline:"none",boxSizing:"border-box",marginBottom:8}}/>
<div style={{fontSize:"0.65rem",color:C.t2,marginBottom:12,lineHeight:1.6}}>IP Tailscale del mini PC + puerto 4080 · Ej: http://100.64.0.5:4080</div>
{/* Checkbox recordar */}
<label style={{display:"flex",alignItems:"flex-start",gap:10,marginBottom:16,cursor:"pointer"}}>
<input type="checkbox" checked={remember} onChange={e=>setRemember(e.target.checked)}
style={{marginTop:2,accentColor:C.amber,cursor:"pointer",flexShrink:0}}/>
<div>
<div style={{fontSize:"0.72rem",color:remember?C.amber:C.t2,fontFamily:"monospace",fontWeight:remember?700:400}}>
Recordar configuración
</div>
<div style={{fontSize:"0.62rem",color:C.t3,marginTop:3,lineHeight:1.5}}>
{remember
? "La URL y nombre se guardarán en este dispositivo. No se almacena ningún dato del nodo, solo la dirección."
: "Sin marcar, deberás introducir la URL cada vez que abras el dashboard."}
</div>
</div>
</label>
{testResult&&<div style={{padding:"8px 12px",borderRadius:6,fontFamily:"monospace",fontSize:"0.72rem",marginBottom:14,background:testResult.ok?C.greenMuted:C.redMuted,color:testResult.ok?C.green:C.red}}>{testResult.msg}</div>}
<div style={{display:"flex",gap:8}}>
<button onClick={testConn} disabled={!url||testing} style={{flex:1,padding:10,background:C.blueMuted,border:`1px solid ${C.blue}40`,borderRadius:6,color:C.blue,fontFamily:"monospace",fontSize:"0.75rem",cursor:"pointer",fontWeight:700}}>{testing?"PROBANDO···":"PROBAR"}</button>
<button onClick={handleSave} disabled={!url} style={{flex:1,padding:10,background:C.greenMuted,border:`1px solid ${C.green}40`,borderRadius:6,color:C.green,fontFamily:"monospace",fontSize:"0.75rem",cursor:"pointer",fontWeight:700}}>GUARDAR</button>
<button onClick={onClose} style={{padding:"10px 14px",background:"none",border:`1px solid ${C.border}`,borderRadius:6,color:C.t2,fontFamily:"monospace",fontSize:"0.75rem",cursor:"pointer"}}></button>
</div>
</Card>
</div>
);
}
// ── TOOLS ──────────────────────────────────────────────────────────────
function Tools({base}) {
const [activeTool, setActiveTool] = useState("converter");
const tools = [
{id:"converter", label:"Conversor", icon:"⇄"},
{id:"validator", label:"Validar dirección", icon:"✓"},
{id:"op_return", label:"OP_RETURN", icon:"🔍"},
{id:"psbt", label:"PSBT", icon:"🔍"},
{id:"rawtx", label:"Tx Raw", icon:"⬡"},
{id:"verifysig", label:"Verificar firma", icon:"🔐"},
];
return (
<div style={{display:"flex",flexDirection:"column",gap:12}}>
<SectionTitle accent={C.blue} icon="⚙">TOOLS</SectionTitle>
{/* Tool selector */}
<div style={{display:"flex",gap:6,flexWrap:"wrap"}}>
{tools.map(t=>(
<button key={t.id} onClick={()=>setActiveTool(t.id)} style={{
padding:"7px 14px", borderRadius:6, border:`1px solid ${activeTool===t.id?C.blue+"60":C.border}`,
background: activeTool===t.id ? C.blueMuted : C.bgCard,
color: activeTool===t.id ? C.blue : C.t2,
fontFamily:"monospace", fontSize:"0.7rem", cursor:"pointer",
display:"flex", alignItems:"center", gap:5,
}}>
<span>{t.icon}</span>{t.label}
</button>
))}
</div>
{activeTool==="converter" && <ToolConverter base={base}/>}
{activeTool==="validator" && <ToolValidator/>}
{activeTool==="op_return" && <ToolOpReturn base={base}/>}
{activeTool==="psbt" && <ToolPsbt/>}
{activeTool==="rawtx" && <ToolRawTx/>}
{activeTool==="verifysig" && <ToolVerifySig/>}
</div>
);
}
// ── Conversor sat/BTC/EUR/USD ──────────────────────────────────────────
function ToolConverter({base}) {
const [sat, setSat] = useState("");
const [price, setPrice] = useState(null);
const {get} = useApi(base);
useEffect(()=>{
get("/api/v1/prices", null).then(d=>{
if(d&&d.USD) setPrice(d);
}).catch(()=>{});
},[base]);
const sats = parseInt(sat.replace(/[^0-9]/g,""))||0;
const btc = sats/1e8;
const usd = price ? (btc * price.USD).toFixed(2) : null;
const eur = price ? (btc * price.EUR).toFixed(2) : null;
const handleBtc = v => { const n=parseFloat(v)||0; setSat(Math.round(n*1e8).toString()); };
const handleUsd = v => { if(!price) return; const n=parseFloat(v)||0; setSat(Math.round((n/price.USD)*1e8).toString()); };
return (
<Card>
<div style={{fontSize:"0.62rem",color:C.blue,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:14}}>Conversor sat / BTC / EUR / USD</div>
<div style={{display:"flex",flexDirection:"column",gap:10}}>
{[
{label:"Satoshis", value:sats?fmt.num(sats):"", placeholder:"100000", onChange:e=>setSat(e.target.value.replace(/[^0-9]/g,"")), color:C.amber},
{label:"BTC", value:sats?(btc.toFixed(8)):"", placeholder:"0.00100000", onChange:e=>handleBtc(e.target.value), color:C.green},
{label:"USD", value:usd&&sats?usd:"", placeholder:price?"precio real":"sin precio", onChange:e=>handleUsd(e.target.value), color:C.blue, disabled:!price},
{label:"EUR", value:eur&&sats?eur:"", placeholder:price?"precio real":"sin precio", color:C.purple, disabled:true},
].map(f=>(
<div key={f.label} style={{display:"flex",alignItems:"center",gap:10}}>
<span style={{fontSize:"0.7rem",color:C.t2,fontFamily:"monospace",width:60,textAlign:"right",flexShrink:0}}>{f.label}</span>
<input value={f.value} onChange={f.onChange||(()=>{})} placeholder={f.placeholder}
disabled={f.disabled}
style={{flex:1,background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"9px 12px",color:f.color,fontFamily:"monospace",fontSize:"0.85rem",fontWeight:700,outline:"none",opacity:f.disabled?0.5:1}}
onFocus={e=>!f.disabled&&(e.target.style.borderColor=f.color)}
onBlur={e=>e.target.style.borderColor=C.borderBright}
/>
</div>
))}
</div>
{price&&<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",marginTop:10}}>Precio BTC: ${fmt.num(price.USD)} USD · {fmt.num(price.EUR)} EUR · desde tu nodo</div>}
{!price&&base&&<div style={{fontSize:"0.62rem",color:C.amber,fontFamily:"monospace",marginTop:10}}>Sin precio de mercado activa FIAT_PRICE en tu Mempool para USD/EUR</div>}
{!base&&<div style={{fontSize:"0.62rem",color:C.amber,fontFamily:"monospace",marginTop:10}}>Conecta tu nodo para ver precios en tiempo real</div>}
</Card>
);
}
// ── Validador de dirección ─────────────────────────────────────────────
function ToolValidator() {
const [addr, setAddr] = useState("");
const [result, setResult] = useState(null);
const validate = () => {
const a = addr.trim();
if (!a) return;
let type=null, network=null, valid=false, details=[];
if (/^1[1-9A-HJ-NP-Za-km-z]{25,34}$/.test(a)) {
type="P2PKH (Legacy)"; network="mainnet"; valid=true;
details=["Formato Legacy — el más antiguo","Fees más altos que SegWit","Menor privacidad que tipos modernos"];
} else if (/^3[1-9A-HJ-NP-Za-km-z]{25,34}$/.test(a)) {
type="P2SH"; network="mainnet"; valid=true;
details=["Puede ser multisig o wrapped SegWit","Fees intermedios","Formato ampliamente compatible"];
} else if (/^bc1q[0-9a-z]{38,59}$/.test(a)) {
type="P2WPKH (Native SegWit)"; network="mainnet"; valid=true;
details=["SegWit nativo — fees reducidos","Buena privacidad","Formato recomendado para pagos"];
} else if (/^bc1p[0-9a-z]{58}$/.test(a)) {
type="P2TR (Taproot)"; network="mainnet"; valid=true;
details=["Taproot — máxima privacidad","Scripts complejos indistinguibles de pagos simples","El tipo más moderno y privado"];
} else if (/^tb1[0-9a-z]{38,}$/.test(a)) {
type="Testnet SegWit"; network="testnet"; valid=true;
details=["Dirección de testnet","No usar en mainnet"];
} else if (/^m[1-9A-HJ-NP-Za-km-z]{25,34}$/.test(a)||/^n[1-9A-HJ-NP-Za-km-z]{25,34}$/.test(a)) {
type="P2PKH Testnet"; network="testnet"; valid=true;
details=["Dirección legacy de testnet"];
} else {
valid=false;
details=["Formato no reconocido","Verifica que hayas copiado la dirección completa"];
}
setResult({addr:a, type, network, valid, details});
};
return (
<Card>
<div style={{fontSize:"0.62rem",color:C.blue,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:14}}>Validador de Dirección</div>
<div style={{display:"flex",gap:8,marginBottom:12}}>
<input value={addr} onChange={e=>setAddr(e.target.value)} onKeyDown={e=>e.key==="Enter"&&validate()}
placeholder="Pega una dirección Bitcoin para validar"
style={{flex:1,background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"9px 12px",color:C.t1,fontFamily:"monospace",fontSize:"0.75rem",outline:"none"}}
onFocus={e=>e.target.style.borderColor=C.blue} onBlur={e=>e.target.style.borderColor=C.borderBright}
/>
<button onClick={validate} 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}}>VALIDAR</button>
</div>
{result&&(
<div style={{padding:"12px 14px",background:C.bg,borderRadius:6,border:`1px solid ${result.valid?C.green:C.red}40`}}>
<div style={{display:"flex",alignItems:"center",gap:8,marginBottom:10}}>
<span style={{fontSize:"1rem",color:result.valid?C.green:C.red}}>{result.valid?"✓":"✗"}</span>
<span style={{fontSize:"0.8rem",color:result.valid?C.green:C.red,fontFamily:"monospace",fontWeight:700}}>{result.valid?"Dirección válida":"Dirección inválida"}</span>
{result.network&&<Badge color={result.network==="mainnet"?C.green:C.amber}>{result.network}</Badge>}
</div>
{result.type&&<div style={{fontSize:"0.75rem",color:C.blue,fontFamily:"monospace",marginBottom:10}}>{result.type}</div>}
<div style={{display:"flex",flexDirection:"column",gap:4}}>
{result.details.map((d,i)=>(
<div key={i} style={{display:"flex",alignItems:"center",gap:6}}>
<span style={{color:C.t2,fontSize:"0.65rem"}}>·</span>
<span style={{fontSize:"0.68rem",color:C.t2}}>{d}</span>
</div>
))}
</div>
</div>
)}
</Card>
);
}
// ── Detector OP_RETURN ─────────────────────────────────────────────────
// Indica SI hay datos arbitrarios y su tamaño, pero NO decodifica ni
// muestra el contenido. Para auditar privacidad lo relevante es QUE HAYA
// un OP_RETURN (revela uso de protocolo/servicio), no qué dice. Además,
// evita ser un visor de contenido arbitrario de la cadena.
function ToolOpReturn({base}) {
const {get} = useApi(base);
const [txid, setTxid] = useState("");
const [result, setResult] = useState(null);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState(null);
const decode = async () => {
const q = txid.trim();
if (!q) return;
setLoading(true); setResult(null); setErr(null);
try {
const tx = await get(`/api/tx/${q}`, null);
if (!tx) throw new Error("Transacción no encontrada");
const opReturns = tx.vout.filter(o=>o.scriptpubkey_type==="op_return"||o.scriptpubkey?.startsWith("6a"));
if (opReturns.length===0) { setErr("Esta transacción no contiene outputs OP_RETURN"); setLoading(false); return; }
// Solo medimos el tamaño en bytes. NO decodificamos el contenido.
const detected = opReturns.map(o=>{
const hex = o.scriptpubkey?.slice(4)||"";
const bytes = Math.floor(hex.length/2);
return { bytes };
});
setResult({ txid:tx.txid, count:opReturns.length, outputs:detected });
} catch(e) { setErr(e.message); }
setLoading(false);
};
return (
<Card>
<div style={{fontSize:"0.62rem",color:C.blue,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:8}}>Detector OP_RETURN</div>
<div style={{fontSize:"0.65rem",color:C.t2,marginBottom:12}}>Indica si una transacción contiene datos arbitrarios (OP_RETURN) y su tamaño.</div>
<div style={{display:"flex",gap:8,marginBottom:12}}>
<input value={txid} onChange={e=>setTxid(e.target.value)} onKeyDown={e=>e.key==="Enter"&&decode()}
placeholder="txid de una transacción"
style={{flex:1,background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"9px 12px",color:C.t1,fontFamily:"monospace",fontSize:"0.75rem",outline:"none"}}
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}}>{loading?"···":"DETECTAR"}</button>
</div>
{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>}
{result&&(
<div style={{padding:"12px 14px",background:C.bg,borderRadius:6,border:`1px solid ${C.border}`,marginTop:8}}>
<div style={{fontSize:"0.8rem",color:C.amber,fontFamily:"monospace",marginBottom:8}}>
Contiene {result.count} output{result.count>1?"s":""} OP_RETURN con datos arbitrarios.
</div>
{result.outputs.map((o,i)=>(
<div key={i} style={{fontSize:"0.65rem",color:C.t2,fontFamily:"monospace",marginTop:4}}>
OP_RETURN #{i+1}: {o.bytes} bytes de datos
</div>
))}
<div style={{fontSize:"0.6rem",color:C.t3,marginTop:10,lineHeight:1.5}}>
Se indica la presencia y el tamaño, no el contenido. Un OP_RETURN puede revelar qué protocolo o servicio se usó ese es el dato relevante para tu privacidad.
</div>
</div>
)}
</Card>
);
}
// ── 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() {
const [input, setInput] = useState("");
const [result, setResult] = useState(null);
const [err, setErr] = useState(null);
// Acepta base64 (lo habitual al copiar) o hexadecimal.
const decode = () => {
const raw = input.trim().replace(/\s/g,"");
if (!raw) return;
setErr(null); setResult(null);
try {
let bytes;
if (/^[0-9a-fA-F]+$/.test(raw) && raw.length % 2 === 0) {
bytes = new Uint8Array(raw.match(/../g).map(h=>parseInt(h,16)));
} else {
const bin = atob(raw);
bytes = new Uint8Array(bin.length);
for (let i=0;i<bin.length;i++) bytes[i] = bin.charCodeAt(i);
}
const psbt = parsePsbt(bytes);
setResult(analyzePsbt(psbt));
} catch(e) {
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 (
<Card>
<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,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)}
placeholder="Pega aquí la PSBT en base64 (cHNidP8BAH…) o en hexadecimal"
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}}
onFocus={e=>e.target.style.borderColor=C.blue} onBlur={e=>e.target.style.borderColor=C.borderBright}
/>
<div style={{display:"flex",gap:8,alignItems:"center",flexWrap:"wrap",marginBottom:10}}>
<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&&(
<div style={{display:"flex",flexDirection:"column",gap:12}}>
{/* Resumen de la transacción */}
<div style={{padding:"12px 14px",background:C.bg,borderRadius:6,border:`1px solid ${C.border}`}}>
<div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",fontWeight:700,letterSpacing:"0.12em",marginBottom:10}}>QUÉ HACE ESTA TRANSACCIÓN</div>
<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>
)}
</Card>
);
}
// ── Decodificador Tx Raw ───────────────────────────────────────────────
function ToolRawTx() {
const [input, setInput] = useState("");
const [result, setResult] = useState(null);
const [err, setErr] = useState(null);
const decode = () => {
const raw = input.trim();
if (!raw) return;
setErr(null); setResult(null);
try {
if (!/^[0-9a-fA-F]+$/.test(raw)) throw new Error("No es hexadecimal válido");
if (raw.length < 20) throw new Error("Demasiado corto para ser una transacción");
const bytes = raw.length / 2;
// Read version (first 4 bytes LE)
const version = parseInt(raw.slice(6,8)+raw.slice(4,6)+raw.slice(2,4)+raw.slice(0,2), 16);
setResult({ valid:true, bytes, version, hex:raw.slice(0,40)+"..." });
} catch(e) {
setErr(`Error: ${e.message}`);
}
};
return (
<Card>
<div style={{fontSize:"0.62rem",color:C.blue,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:8}}>Decodificador Transacción Raw</div>
<div style={{fontSize:"0.65rem",color:C.t2,marginBottom:12}}>Pega el hex de una transacción sin firmar para verificar su formato. Procesado localmente, sin enviar a ningún servidor.</div>
<textarea value={input} onChange={e=>setInput(e.target.value)}
placeholder="0100000001..."
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}}
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}}>DECODIFICAR</button>
{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>}
{result&&(
<div style={{padding:"12px 14px",background:C.bg,borderRadius:6,border:`1px solid ${C.green}40`}}>
<div style={{display:"flex",alignItems:"center",gap:8,marginBottom:10}}>
<span style={{color:C.green,fontSize:"1rem"}}></span>
<span style={{color:C.green,fontFamily:"monospace",fontSize:"0.78rem",fontWeight:700}}>Hex válido</span>
</div>
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:10}}>
<Tag label="Tamaño" value={`${result.bytes} bytes`} color={C.blue}/>
<Tag label="Versión" value={result.version} color={C.amber}/>
<Tag label="Inicio hex" value={result.hex} color={C.t2}/>
</div>
</div>
)}
</Card>
);
}
// ── Verificador de firma ───────────────────────────────────────────────
function ToolVerifySig() {
const [addr, setAddr] = useState("");
const [msg, setMsg] = useState("");
const [sig, setSig] = useState("");
const [result, setResult] = useState(null);
const verify = () => {
// Basic format validation — full cryptographic verification requires secp256k1
setResult(null);
if (!addr.trim()||!msg.trim()||!sig.trim()) { setResult({valid:false, note:"Rellena todos los campos"}); return; }
// Validate signature format (base64, ~88 chars)
const sigClean = sig.trim().replace(/\s/g,"");
if (sigClean.length < 80 || sigClean.length > 100) {
setResult({valid:false, note:"La firma no tiene el formato esperado (debería ser ~88 caracteres en base64)"});
return;
}
try { atob(sigClean); } catch { setResult({valid:false, note:"La firma no es base64 válido"}); return; }
setResult({
valid: null, // Can't verify without secp256k1
note: "Formato de firma correcto. Para verificación criptográfica completa usa Sparrow Wallet, Bitcoin Core (verifymessage) o Ian Coleman's tool. Esta herramienta solo valida el formato.",
addr: addr.trim(),
msgHash: msg.trim().length + " caracteres",
});
};
return (
<Card>
<div style={{fontSize:"0.62rem",color:C.blue,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:8}}>Verificador de Firma de Mensaje</div>
<div style={{fontSize:"0.65rem",color:C.t2,marginBottom:12}}>Verifica que un mensaje fue firmado por el propietario de una dirección Bitcoin.</div>
<div style={{display:"flex",flexDirection:"column",gap:8,marginBottom:10}}>
{[
{label:"Dirección", value:addr, onChange:e=>setAddr(e.target.value), placeholder:"bc1q..."},
{label:"Mensaje", value:msg, onChange:e=>setMsg(e.target.value), placeholder:"El mensaje que fue firmado"},
{label:"Firma", value:sig, onChange:e=>setSig(e.target.value), placeholder:"Base64 de la firma..."},
].map(f=>(
<div key={f.label}>
<div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",marginBottom:4}}>{f.label}</div>
<input value={f.value} onChange={f.onChange} placeholder={f.placeholder}
style={{width:"100%",background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"9px 12px",color:C.t1,fontFamily:"monospace",fontSize:"0.75rem",outline:"none",boxSizing:"border-box"}}
onFocus={e=>e.target.style.borderColor=C.blue} onBlur={e=>e.target.style.borderColor=C.borderBright}
/>
</div>
))}
</div>
<button onClick={verify} 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 FORMATO</button>
{result&&(
<div style={{padding:"12px 14px",background:C.bg,borderRadius:6,border:`1px solid ${result.valid===false?C.red:C.amber}40`}}>
<div style={{fontSize:"0.7rem",color:result.valid===false?C.red:C.amber,fontFamily:"monospace",marginBottom:6,fontWeight:700}}>
{result.valid===false?"✗ Error":"⚠ Validación parcial"}
</div>
<div style={{fontSize:"0.68rem",color:C.t2,lineHeight:1.6}}>{result.note}</div>
</div>
)}
</Card>
);
}
const TABS=[
{id:"node", label:"Nodo", icon:"◉"},
{id:"blocks", label:"Bloques", icon:"◈"},
{id:"mempool", label:"Mempool", icon:"≋"},
{id:"lab", label:"LAB", icon:"⌕"},
{id:"auditoria", label:"AUDITORÍA", icon:"⌖"},
{id:"peritaje", label:"PERITAJE", icon:"🔍"},
{id:"tools", label:"TOOLS", icon:"⚙"},
{id:"docs", label:"DOCS", icon:"📖"},
];
// ── DOCS ───────────────────────────────────────────────────────────────
function Docs() {
const [open, setOpen] = useState("intro");
const toggle = id => setOpen(open===id?null:id);
const Section = ({id, title, accent, children}) => {
const a = accent||C.green;
const isOpen = open===id;
return (
<Card style={{padding:0,overflow:"hidden"}}>
<button onClick={()=>toggle(id)} style={{
width:"100%",display:"flex",alignItems:"center",justifyContent:"space-between",
padding:"14px 18px",background:"none",border:"none",cursor:"pointer",
borderBottom:isOpen?`1px solid ${C.border}`:"none",
}}>
<span style={{fontFamily:"monospace",fontSize:"0.78rem",color:a,fontWeight:700,letterSpacing:"0.05em"}}>{title}</span>
<span style={{color:C.t2,fontSize:"0.8rem",transition:"transform 0.2s",transform:isOpen?"rotate(180deg)":"none"}}></span>
</button>
{isOpen&&<div style={{padding:"18px 20px"}}>{children}</div>}
</Card>
);
};
const P = ({children}) => <p style={{fontSize:"0.78rem",color:C.t2,lineHeight:1.8,marginBottom:12}}>{children}</p>;
const H = ({children, color}) => <div style={{fontSize:"0.7rem",color:color||C.green,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.15em",fontWeight:700,marginBottom:8,marginTop:16}}>{children}</div>;
const Li = ({children}) => <div style={{display:"flex",gap:8,marginBottom:6}}><span style={{color:C.amber,flexShrink:0}}>·</span><span style={{fontSize:"0.78rem",color:C.t2,lineHeight:1.7}}>{children}</span></div>;
const Code = ({children}) => <code style={{background:C.bg,border:`1px solid ${C.border}`,borderRadius:4,padding:"2px 6px",fontFamily:"monospace",fontSize:"0.75rem",color:C.blue}}>{children}</code>;
const Callout = ({children, color}) => <div style={{padding:"10px 14px",background:`${color||C.amber}10`,border:`1px solid ${color||C.amber}30`,borderRadius:6,fontSize:"0.75rem",color:color||C.amber,lineHeight:1.7,marginTop:10,marginBottom:10}}>{children}</div>;
const Cert = ({level}) => {
const map = {CERTEZA:{c:C.green,d:"Hecho verificable directamente en la blockchain. No hay margen de error."}, PROBABLE:{c:C.amber,d:"Alta probabilidad basada en patrones conocidos. Puede equivocarse en casos atípicos."}, POSIBLE:{c:C.blue,d:"Inferencia estadística. Útil como señal pero requiere contexto adicional."}};
const m = map[level];
return <div style={{display:"flex",gap:10,marginBottom:10,alignItems:"flex-start"}}><span style={{background:`${m.c}18`,border:`1px solid ${m.c}35`,borderRadius:3,padding:"2px 7px",fontFamily:"monospace",fontSize:"0.62rem",fontWeight:700,color:m.c,flexShrink:0,marginTop:2}}>{level}</span><span style={{fontSize:"0.75rem",color:C.t2,lineHeight:1.7}}>{m.d}</span></div>;
};
return (
<div style={{display:"flex",flexDirection:"column",gap:10}}>
<SectionTitle accent={C.green} icon="📖">Documentación</SectionTitle>
{/* Intro */}
<Section id="intro" title="¿Qué es Txoko Node Dashboard?" accent={C.green}>
<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>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>
<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}}>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}}>, 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>
</Section>
{/* Requisitos */}
<Section id="requisitos" title="Requisitos y configuración inicial" accent={C.blue}>
<H color={C.blue}>Requisitos del servidor</H>
<Li>Bitcoin Core con <Code>txindex=1</Code> activado</Li>
<Li>Mempool.space self-hosted (puerto 4080 por defecto)</Li>
<Li>Fulcrum o Electrs como servidor Electrum</Li>
<Li>Node.js 18 para el servicio de métricas del sistema</Li>
<Li>nginx como proxy inverso (configuración Minibolt estándar)</Li>
<H color={C.blue}>Acceso remoto</H>
<P>El dashboard está diseñado para accederse a través de Tailscale u otra VPN privada. Nunca expongas el puerto 4080 directamente a internet.</P>
<H color={C.blue}>Instalación rápida</H>
<P>Copia <Code>dashboard.html</Code> al directorio web de nginx y <Code>system-metrics.js</Code> a cualquier carpeta del servidor. Configura el servicio systemd incluido y añade el bloque de nginx para el endpoint <Code>/system/</Code>.</P>
<Callout color={C.blue}>Consulta el fichero <Code>docs/setup.md</Code> del repositorio para una guía de instalación paso a paso con todos los comandos.</Callout>
</Section>
{/* Pestaña Nodo */}
<Section id="nodo" title="Pestaña: Nodo" accent={C.green}>
<P>Vista principal del estado de tu infraestructura. Se actualiza cada vez que cargas la página.</P>
<H>Bitcoin Core</H>
<Li><strong style={{color:C.t1}}>Versión</strong> — versión del binario de Bitcoin Core en ejecución.</Li>
<Li><strong style={{color:C.t1}}>Peers</strong> — conexiones activas, diferenciando entrantes y salientes. Un número equilibrado indica buena conectividad.</Li>
<Li><strong style={{color:C.t1}}>Sincronizado</strong> — indica si el nodo está al día con la cadena. Si no lo está, muestra el porcentaje de progreso.</Li>
<Li><strong style={{color:C.t1}}>Blockchain</strong> — tamaño en disco del índice completo de bloques.</Li>
<Li><strong style={{color:C.t1}}>Uptime</strong> — tiempo que lleva el proceso de Bitcoin Core en ejecución sin reinicios.</Li>
<H>Sistema</H>
<Li><strong style={{color:C.t1}}>CPU por núcleo</strong> — uso individual de cada núcleo. Calculado desde <Code>/proc/stat</Code>, equivalente a htop.</Li>
<Li><strong style={{color:C.t1}}>RAM</strong> — memoria realmente usada por procesos, excluyendo caché del kernel (mismo cálculo que htop).</Li>
<Li><strong style={{color:C.t1}}>SWAP</strong> — memoria de intercambio en uso. Un valor alto sostenido (como el de Fulcrum) es normal pero conviene monitorizarlo.</Li>
<Li><strong style={{color:C.t1}}>Procesos destacados</strong> — los procesos que más memoria consumen en tiempo real.</Li>
<H>Logs en tiempo real</H>
<P>Panel de logs de Bitcoin Core y Fulcrum. Muestra las últimas 80 entradas con filtrado por nivel (todos / warn / error). Pulsa para actualizar.</P>
</Section>
{/* LAB */}
<Section id="lab" title="Pestaña: LAB" accent={C.purple}>
<P>El LAB es el núcleo analítico del dashboard. Combina búsqueda on-chain con análisis de privacidad en tiempo real.</P>
<H color={C.purple}>Verificador de transacción</H>
<P>Introduce el txid de cualquier transacción para comprobar su estado directamente en tu nodo: confirmaciones, fee pagado, bloque y fecha. Útil para confirmar pagos recibidos sin depender de exploradores externos.</P>
<H color={C.purple}>UTXO Map</H>
<P>Introduce una dirección Bitcoin para visualizar sus UTXOs (outputs no gastados) ordenados por valor. Cada UTXO muestra su valor, el bloque en que se confirmó y una valoración de exposición:</P>
<Li><strong style={{color:C.green}}>Normal</strong> — UTXO estándar sin señales de riesgo adicional.</Li>
<Li><strong style={{color:C.blue}}>Alto valor</strong> — el UTXO supera 0.1 BTC y es más visible on-chain.</Li>
<Li><strong style={{color:C.amber}}>Sin confirmar</strong> — la transacción de origen aún no ha sido incluida en un bloque.</Li>
<H color={C.purple}>Análisis on-chain (buscador)</H>
<P>Acepta txid, dirección, altura de bloque o block hash. Detecta automáticamente el tipo y muestra la información relevante junto con el análisis de privacidad cuando aplica.</P>
</Section>
{/* Privacy Lab */}
<Section id="privacy" title="Privacy Lab: guía de heurísticas" accent={C.purple}>
<P>El Privacy Lab analiza transacciones y direcciones aplicando heurísticas de chain analysis. Cada resultado incluye su nivel de certeza para que puedas interpretar los datos correctamente.</P>
<H color={C.purple}>Niveles de certeza</H>
<Cert level="CERTEZA"/>
<Cert level="PROBABLE"/>
<Cert level="POSIBLE"/>
<H color={C.purple}>Heurísticas de transacción (14 checks)</H>
<Li><strong style={{color:C.t1}}>Reutilización de direcciones en inputs</strong> — <Code>CERTEZA · peso 25</Code> Si varios inputs comparten dirección, pertenecen con certeza al mismo propietario. El error de privacidad más grave.</Li>
<Li><strong style={{color:C.t1}}>Mezcla de tipos en inputs</strong> — <Code>CERTEZA · peso 14</Code> Inputs de tipos distintos (Legacy + SegWit, SegWit + Taproot) revelan consolidación de UTXOs de wallets o épocas diferentes.</Li>
<Li><strong style={{color:C.t1}}>Tipo de output revela el cambio</strong> — <Code>PROBABLE · peso 8</Code> Si hay 2 outputs y uno es del mismo tipo que los inputs, ese es el cambio. El otro es el pago externo a un receptor de tipo distinto.</Li>
<Li><strong style={{color:C.t1}}>Estructura CoinJoin / mezcla</strong> — <Code>PROBABLE</Code> Detecta CoinJoin genérico y Whirlpool (denominaciones fijas: 100k, 1M, 5M, 50M sats). Su presencia mejora la privacidad.</Li>
<Li><strong style={{color:C.t1}}>Output de valor redondo</strong> — <Code>PROBABLE · peso 10</Code> Con 2 outputs, el de valor redondo es casi siempre el pago y el otro el cambio.</Li>
<Li><strong style={{color:C.t1}}>RBF activado</strong> — <Code>CERTEZA · peso 5</Code> Replace-by-Fee revela el software de wallet (Bitcoin Core lo activa por defecto) y permite rastrear reemplazos.</Li>
<Li><strong style={{color:C.t1}}>Inputs innecesarios (unnecessary input)</strong> — <Code>PROBABLE · peso 12</Code> Si uno de los inputs habría bastado para cubrir el pago, los demás revelan consolidación. Una de las heurísticas más usadas en chain analysis profesional.</Li>
<Li><strong style={{color:C.t1}}>Patrón de pago simple</strong> — <Code>POSIBLE · peso 8</Code> 1 input y 2 outputs es el patrón más trazable. El cambio pasa de transacción en transacción formando una cadena.</Li>
<Li><strong style={{color:C.t1}}>Dust outputs</strong> — <Code>CERTEZA · peso 8</Code> Outputs menores a 546 sat pueden ser ataques de dust linking para deanonimizar al receptor cuando gasta.</Li>
<Li><strong style={{color:C.t1}}>Output de cambio identificable</strong> — <Code>PROBABLE · peso 10+</Code> Combina 5 señales: tipo de script, valor redondo, tamaño relativo, reutilización de dirección en output, y mismatch input/output. Las señales correlacionadas aumentan el peso.</Li>
<Li><strong style={{color:C.t1}}>Ordenación BIP69</strong> — <Code>informativo</Code> Verifica si inputs y outputs siguen el orden canónico lexicográfico. Sin BIP69, el orden puede revelar cuál output es el cambio (muchos wallets lo colocan siempre en la misma posición).</Li>
<Li><strong style={{color:C.t1}}>Fingerprinting de wallet</strong> — <Code>POSIBLE/PROBABLE · peso 6</Code> Detecta Bitcoin Core, Electrum, Sparrow y BlueWallet combinando múltiples señales: locktime, sequence, BIP69, activación de RBF. Requiere mínimo 3 señales coincidentes.</Li>
<Li><strong style={{color:C.t1}}>Batch payment</strong> — <Code>POSIBLE</Code> Muchos outputs con valores distintos: patrón de exchange o servicio custodio. Los destinatarios comparten el mismo origen.</Li>
<Li><strong style={{color:C.t1}}>Análisis temporal</strong> — <Code>informativo</Code> La hora UTC de confirmación sugiere zona horaria del emisor.</Li>
<H color={C.purple}>Puntuación de privacidad</H>
<P>La puntuación (0100) es un score ponderado. Las señales que se refuerzan mutuamente (por ejemplo, 3+ indicadores apuntando al mismo output de cambio) aumentan la penalización combinada. No es absoluta: refleja qué tan analizable es la transacción por un tercero, no si el usuario ha cometido un error.</P>
<Callout color={C.purple}>El Privacy Lab analiza datos on-chain públicos de una sola transacción. Sin acceso al grafo completo (transacciones relacionadas), algunas heurísticas son necesariamente limitadas. Para auditorías profundas, combina este análisis con herramientas como OXT o Mempool.space.</Callout>
</Section>
{/* Tools */}
<Section id="tools" title="Pestaña: TOOLS" accent={C.blue}>
<P>Herramientas criptográficas que funcionan completamente en el navegador. Ningún dato se envía a servidores externos.</P>
<Li><strong style={{color:C.t1}}>Conversor sat/BTC/EUR/USD</strong> — conversión en tiempo real usando el precio de tu propio nodo. Sin APIs de terceros.</Li>
<Li><strong style={{color:C.t1}}>Validador de dirección</strong> — verifica el formato y tipo de cualquier dirección Bitcoin: Legacy, P2SH, Native SegWit o Taproot.</Li>
<Li><strong style={{color:C.t1}}>Detector OP_RETURN</strong> — indica si una transacción contiene datos arbitrarios y su tamaño, sin mostrar el contenido.</Li>
<Li><strong style={{color:C.t1}}>Validador PSBT</strong> — verifica que una PSBT tiene formato correcto antes de firmarla. Para verificación criptográfica completa usa Sparrow o Bitcoin Core.</Li>
<Li><strong style={{color:C.t1}}>Decodificador tx raw</strong> — valida el formato hexadecimal de una transacción sin firmar.</Li>
<Li><strong style={{color:C.t1}}>Verificador de firma</strong> — valida el formato de una firma de mensaje Bitcoin. Para verificación criptográfica completa usa Bitcoin Core (<Code>verifymessage</Code>).</Li>
</Section>
{/* Glosario */}
<Section id="glosario" title="Glosario" accent={C.amber}>
{[
["UTXO", "Unspent Transaction Output. La unidad básica de valor en Bitcoin. Tu balance es la suma de todos tus UTXOs no gastados."],
["sat / satoshi", "La unidad mínima de Bitcoin. 1 BTC = 100.000.000 satoshis. Los fees se expresan en sat/vB (satoshis por byte virtual)."],
["vB / vByte", "Byte virtual. Unidad de peso de una transacción que tiene en cuenta el descuento SegWit. El fee = sat/vB × vSize."],
["Mempool", "El conjunto de transacciones pendientes de confirmación. Cuando está lleno, los fees suben."],
["RBF", "Replace-by-Fee. Permite reemplazar una transacción pendiente por otra con mayor fee para acelerar su confirmación."],
["CoinJoin", "Técnica de privacidad que combina los inputs de múltiples usuarios en una sola transacción, haciendo imposible rastrear qué input corresponde a qué output."],
["Taproot (bc1p)", "El tipo de script más moderno de Bitcoin. Ofrece mayor privacidad al hacer indistinguibles las transacciones simples de las complejas (multisig, contratos)."],
["Native SegWit (bc1q)", "Segregated Witness nativo. Reduce el tamaño de las transacciones y por tanto los fees. Muy adoptado actualmente."],
["Anonset", "Conjunto de anonimato. El número de posibles propietarios indistinguibles de un UTXO. Mayor anonset = mayor privacidad."],
["Chain analysis", "El proceso de analizar la blockchain para trazar el movimiento de fondos e intentar identificar propietarios. Empresas como Chainalysis se dedican a esto profesionalmente."],
["PSBT", "Partially Signed Bitcoin Transaction. Formato estándar para transacciones parcialmente firmadas, usado en flujos multisig y con hardware wallets."],
["txindex", "Índice completo de transacciones de Bitcoin Core. Necesario para consultar cualquier transacción por su txid, no solo las relacionadas con el wallet local."],
].map(([term, def]) => (
<div key={term} style={{paddingBottom:10,marginBottom:10,borderBottom:`1px solid ${C.border}`}}>
<div style={{fontSize:"0.75rem",color:C.amber,fontFamily:"monospace",fontWeight:700,marginBottom:4}}>{term}</div>
<div style={{fontSize:"0.75rem",color:C.t2,lineHeight:1.7}}>{def}</div>
</div>
))}
</Section>
{/* FAQ */}
<Section id="faq" title="Preguntas frecuentes" accent={C.red}>
{[
["El dashboard muestra 'DEMO · SIN NODO'", "No has configurado la URL de tu nodo. Pulsa CONFIG en la cabecera e introduce la URL de tu Mempool (ej: http://100.x.x.x:4080). El nombre y la URL no se guardan entre sesiones por privacidad."],
["Error 'Failed to fetch' al conectar", "El navegador bloquea las peticiones cross-origin. Asegúrate de acceder al dashboard desde la misma IP y puerto que Mempool (http://100.x.x.x:4080/dashboard) y que tienes configurados los headers CORS en nginx."],
["La versión de Bitcoin Core es incorrecta", "Verifica que el servicio txoko-metrics está corriendo (sudo systemctl status txoko-metrics) y que las credenciales RPC en system-metrics.js son correctas."],
["La sección Sistema no muestra datos", "El servicio txoko-metrics no está arrancado o nginx no tiene configurado el bloque location /system/. Revisa ambos."],
["El UTXO Map no encuentra UTXOs", "Tu versión de Mempool puede no tener el endpoint /utxo. El dashboard intenta derivarlos desde el historial de transacciones automáticamente. Si la dirección tiene muchas transacciones puede tardar."],
["Los logs muestran 'Error al cargar logs'", "El endpoint /system/logs/ no está accesible. Verifica que nginx tiene el bloque location /system/ configurado correctamente y que txoko-metrics está corriendo."],
["¿Es seguro dejar el dashboard accesible en Tailscale?", "Sí. Tailscale cifra todo el tráfico y solo los dispositivos de tu red pueden acceder. El dashboard no tiene autenticación propia porque asume que la red Tailscale ya es privada."],
].map(([q, a]) => (
<div key={q} style={{paddingBottom:14,marginBottom:14,borderBottom:`1px solid ${C.border}`}}>
<div style={{fontSize:"0.75rem",color:C.t1,fontWeight:600,marginBottom:6}}> {q}</div>
<div style={{fontSize:"0.75rem",color:C.t2,lineHeight:1.7}}>{a}</div>
</div>
))}
</Section>
{/* Footer docs */}
<div style={{padding:"16px 0",textAlign:"center"}}>
<div style={{fontSize:"0.68rem",color:C.t2,fontFamily:"monospace",lineHeight:2}}>
Txoko Node Dashboard · Desarrollado por Bitcoin Txoko<br/>
Software libre bajo licencia MIT<br/>
<span style={{color:C.amber}}> txoko · your node, your rules</span>
</div>
</div>
</div>
);
}
function App() {
const [tab,setTab]=useState("node");
const [showConfig,setShowConfig]=useState(false);
const [config,setConfig]=useState(()=>{
try{ const saved=localStorage.getItem("txoko-config"); if(saved) return JSON.parse(saved); }catch{}
return {url:"",name:""};
});
const [themeId,setThemeId]=useState(()=>localStorage.getItem("txoko-theme")||"cypherpunk");
const [auditQuery,setAuditQuery]=useState("");
// Sync C global on every render
setTheme(themeId);
// Exponer setTab globalmente para que LAB pueda navegar a AUDITORÍA
useEffect(()=>{
window.__setTab=(id,q)=>{ if(q) setAuditQuery(q); setTab(id); };
return ()=>{ delete window.__setTab; };
},[]);
const switchTheme = id => {
localStorage.setItem("txoko-theme", id);
setThemeId(id);
};
return (
<div style={{minHeight:"100vh",background:C.bg,color:C.t1,fontFamily:"'IBM Plex Sans','Helvetica Neue',sans-serif",display:"flex",flexDirection:"column"}}>
<div style={{borderBottom:`1px solid ${C.border}`,padding:"11px 18px",display:"flex",alignItems:"center",justifyContent:"space-between",position:"sticky",top:0,background:C.headerBg,zIndex:50}}>
<div style={{display:"flex",alignItems:"center",gap:10}}>
<span style={{fontSize:"1.3rem",color:C.amber,fontWeight:700,lineHeight:1}}></span>
<div>
<div style={{fontFamily:"monospace",fontSize:"0.82rem",color:C.green,fontWeight:700,letterSpacing:"0.06em"}}>{config.name||"MI NODO"}</div>
<div style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace"}}>{config.url?config.url.replace("http://",""):"DEMO · SIN NODO"} · MAINNET</div>
</div>
</div>
<div style={{display:"flex",alignItems:"center",gap:6}}>
{/* Theme selector */}
<div style={{display:"flex",alignItems:"center",gap:4,marginRight:6}}>
{THEME_META.map(t=>(
<button key={t.id} onClick={()=>switchTheme(t.id)} title={t.label} style={{
width:18, height:18, borderRadius:"50%", border:`2px solid ${themeId===t.id?t.dot:"transparent"}`,
background:t.dot, cursor:"pointer", padding:0, flexShrink:0,
boxShadow:themeId===t.id?`0 0 6px ${t.dot}`:"none",
transition:"box-shadow 0.2s, border-color 0.2s",
}}/>
))}
</div>
<button onClick={()=>setShowConfig(true)} style={{background:C.bgCard,border:`1px solid ${C.border}`,borderRadius:6,padding:"6px 12px",color:C.t2,fontFamily:"monospace",fontSize:"0.68rem",cursor:"pointer"}}>CONFIG</button>
</div>
</div>
<div style={{display:"flex",borderBottom:`1px solid ${C.border}`,padding:"0 10px",overflowX:"auto"}}>
{TABS.map(t=>(
<button key={t.id} onClick={()=>setTab(t.id)} style={{background:"none",border:"none",borderBottom:`2px solid ${tab===t.id?(t.id==="auditoria"?C.purple:t.id==="lab"?C.purple:t.id==="peritaje"?C.red:t.id==="tools"?C.blue:t.id==="docs"?C.amber:C.green):"transparent"}`,padding:"11px 16px",color:tab===t.id?(t.id==="auditoria"?C.purple:t.id==="lab"?C.purple:t.id==="peritaje"?C.red:t.id==="tools"?C.blue:t.id==="docs"?C.amber:C.green):C.t2,fontFamily:"monospace",fontSize:"0.72rem",cursor:"pointer",letterSpacing:t.id==="lab"||t.id==="auditoria"||t.id==="peritaje"||t.id==="tools"||t.id==="docs"?"0.15em":"0.08em",fontWeight:tab===t.id?700:400,textTransform:"uppercase",whiteSpace:"nowrap"}}>{t.label}</button>
))}
</div>
<div style={{padding:"18px 14px",maxWidth:860,margin:"0 auto",width:"100%",flex:1}}>
{tab==="node" && <NodeOverview base={config.url}/>}
{tab==="blocks" && <BlockExplorer base={config.url}/>}
{tab==="mempool" && <MempoolView base={config.url}/>}
<div style={{display:tab==="lab"?"block":"none"}}><Lab base={config.url}/></div>
<div style={{display:tab==="auditoria"?"block":"none"}}><Auditoria base={config.url} initialQuery={auditQuery}/></div>
{tab==="peritaje" && <PeritajeForense base={config.url}/>}
{tab==="tools" && <Tools base={config.url}/>}
{tab==="docs" && <Docs/>}
</div>
<footer style={{borderTop:`1px solid ${C.border}`,padding:"10px 18px",display:"flex",alignItems:"center",justifyContent:"center",gap:6}}>
<span style={{fontSize:"0.95rem",color:C.amber}}></span>
<span style={{fontFamily:"monospace",fontSize:"0.62rem",color:C.amber,letterSpacing:"0.12em"}}>txoko</span>
<span style={{fontFamily:"monospace",fontSize:"0.62rem",color:C.t2,letterSpacing:"0.05em"}}>· your node, your rules</span>
</footer>
{showConfig&&<ConfigModal config={config} onSave={cfg=>{setConfig(cfg);setShowConfig(false);}} onClose={()=>setShowConfig(false)}/>}
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
</script>
</body>
</html>