Files
txoko-dashboard/dashboard.html
T

3238 lines
217 KiB
HTML
Raw 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>
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/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: #243040; }
</style>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600;700&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useCallback } = 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:"#4a6380", t3:"#243040",
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:"#7a6020", t3:"#3d3000",
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:"#4a6a8a", t3:"#1e2d3d",
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:"#64748b", t3:"#cbd5e1",
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, prevout: { value:5000000, scriptpubkey_address:"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", scriptpubkey_type:"v0_p2wpkh" } },
{ sequence: 0xffffffff, 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"}],
]);
// ── 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;
}
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: 4, entity_ofac: 20,
};
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;
const likelyCJ = isWhirlpool || isGenericCJ;
const cjVariant = isWhirlpool ? "Whirlpool" : "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:!hasInputTypeMixing,
actionability: "evitable",
detail: hasInputTypeMixing
? `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: weights.input_type_mixing,
});
if (hasInputTypeMixing) 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: "Cuando pagas a alguien que usa un tipo de script distinto al tuyo, tu output de cambio queda identificado porque es el único del mismo tipo que tus inputs. Un analista puede identificarlo sin ambigüedad.",
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, informational: true, positive: 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
? `Output de ${(roundOutputs[0].value/1e8).toFixed(6)} BTC es valor redondo — con alta probabilidad ese es el pago y el otro output es el cambio.`
: "No se detectan outputs de valor redondo en una estructura de 2 outputs.",
didactic: "Las personas pagan cantidades redondas (0.001, 0.01, 0.1 BTC). Con dos outputs, el redondo es el pago y el irregular es el cambio. Esto identifica la dirección de cambio del emisor y permite rastrear sus futuros gastos.",
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);
checks.push({
id:"rbf", label:"RBF activado (Replace-by-Fee)", certainty:"CERTEZA", pass:!rbfEnabled,
actionability: "wallet",
detail: rbfEnabled
? "RBF opt-in activado (sequence < 0xFFFFFFFE). Revela software de wallet y permite correlacionar transacciones reemplazadas en la mempool."
: "RBF no activado.",
didactic: "Replace-by-Fee permite sustituir una transacción pendiente por otra con mayor fee. Bitcoin Core lo activa por defecto desde v0.12. Su presencia es una firma de wallet que los analistas usan para vincular transacciones relacionadas.",
penalty: weights.rbf,
});
if (rbfEnabled) deductions += weights.rbf;
// ── 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:!hasUnnecessaryInput,
actionability: "evitable",
detail: hasUnnecessaryInput ? unnecessaryDetail : "No se detectan inputs claramente innecesarios para cubrir el pago.",
didactic: "Si una transacción tiene más inputs de los necesarios para cubrir el pago, el emisor estaba consolidando UTXOs. Esto revela que esos UTXOs pertenecen al mismo propietario y es una de las heurísticas más usadas en chain analysis real.",
penalty: weights.unnecessary_input,
});
if (hasUnnecessaryInput) 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 (1 input, 2 outputs)", 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));
const hasDust = dustOutputs.length > 0;
checks.push({
id:"dust", label:"Outputs de dust detectados", certainty:"CERTEZA", pass:!hasDust,
actionability: "no_corregible",
detail: hasDust
? `${dustOutputs.length} output(s) por debajo del umbral de dust (${dustOutputs.map(d=>d.value+"sat").join(", ")}). Hecho: hay salidas diminutas. 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 — por precaución, trátalo como dust y no lo gastes junto a otros UTXOs.`
: "Sin outputs de dust (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 — pero no toda salida diminuta es un ataque: también las hay legítimas (cambios pequeños, salidas de protocolo). Lo prudente es no gastar dust sin querer.",
penalty: weights.dust,
});
if (hasDust) deductions += weights.dust;
// ── 10. Change detection — señales combinadas ─────────────────────
let changeSignals = 0;
let changeDetails = [];
let changeBonus = 0;
if (tx.vout.length === 2 && !likelyCJ) {
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) { changeSignals++; signalA = true; changeDetails.push("tipo de script idéntico a inputs"); }
}
// Señal B + C: "pago redondo" y "cambio pequeño" SOLO son independientes si
// apuntan a outputs distintos. Si el output redondo es el mayor (el pago),
// entonces "el menor es el cambio" es la MISMA observación → cuenta una sola vez.
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) {
// El redondo es el pequeño: dos pistas genuinamente distintas
changeSignals += 2; changeDetails.push("el output redondo es el pago"); changeDetails.push("output menor < 15% del total");
} else if (roundOutputs.length === 1) {
// Pago redondo grande + cambio pequeño = una sola observación
changeSignals++; changeDetails.push("pago redondo y cambio pequeño (misma señal)");
} else if (smallerIsSmall) {
// Solo la pista del tamaño, sin output redondo
changeSignals++; changeDetails.push("output menor < 15% del total");
}
// Señal D: output reutiliza dirección de input
const inputAddrSet = new Set(inputAddrs);
const outputToKnownAddr = tx.vout.some(v=>v.scriptpubkey_address && inputAddrSet.has(v.scriptpubkey_address));
if (outputToKnownAddr) { changeSignals+=2; changeDetails.push("output reutiliza dirección de input — cambio casi seguro"); changeBonus += 10; }
// Señal E: mismatch tipo input/output
if (hasOutputMismatch) { changeSignals++; changeDetails.push("tipo de output distinto al de inputs"); }
// Señal F: posición del cambio — solo aporta si la señal A (tipo) no se disparó ya,
// para no contar el mismo output dos veces (por tipo y por posición).
if (!isBip69 && !signalA) {
const lastOut = tx.vout[tx.vout.length - 1];
if (dominantInType && lastOut.scriptpubkey_type === dominantInType) {
changeSignals++; changeDetails.push("posición fija del cambio (índice 1, sin BIP69)");
}
}
// Bonus correlación: ≥3 señales independientes
if (changeSignals >= 3) changeBonus += 5;
}
const changeIdentifiable = changeSignals >= 2 && tx.vout.length === 2;
const changeCertainty = changeBonus >= 10 ? "PROBABLE" : changeSignals >= 3 ? "PROBABLE" : changeSignals >= 2 ? "PROBABLE" : "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"]
});
}
checks.push({
id:"wallet_fingerprint", label:"Fingerprinting de wallet", certainty: detectedWallets[0]?.confidence||"POSIBLE", pass:detectedWallets.length===0,
actionability: "wallet",
detail: detectedWallets.length > 0
? 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:"informativo", pass:!isBatch,
actionability: "no_corregible",
detail: isBatch
? `${tx.vout.length} outputs, casi todos con valores distintos. Compatible con un exchange o servicio enviando a múltiples destinatarios en una sola transacción. Si eres uno de los receptores, esto no es un fallo tuyo.`
: "No se detecta patrón de batch payment.",
didactic: "Exchanges y custodios agrupan múltiples pagos en una transacción para ahorrar fees. Si recibes fondos de un batch, otros destinatarios podrían correlacionar sus pagos con los tuyos porque comparten los mismos inputs — y por tanto el mismo origen. No es algo que puedas controlar como receptor, pero conviene saberlo.",
penalty: 0, informational: true,
});
// ── 15. 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 = `Confirmada el ${dayNames[day]} a las ${hour}:00 UTC — ${zone}.${day===0||day===6?" · fin de semana":""}`;
}
checks.push({
id:"timing", label:"Análisis temporal", certainty:"POSIBLE", pass:true,
actionability: "evitable",
detail: timingDetail,
didactic: "El horario de las transacciones revela la zona horaria y rutinas del usuario. Transacciones consistentemente a las 8-10 UTC apuntan a Europa. Es una señal débil en aislamiento pero útil combinada con otras.",
penalty: 0, informational: true,
});
// ── 16. 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 = [];
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 });
}
// OFAC — crítico, penaliza
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:false,
actionability: "no_corregible",
detail: `Una o más direcciones de esta transacción (${sides.join(" y ")}) aparecen en la lista de sanciones OFAC: ${names}. Esto no implica que hayas actuado ilegalmente, pero tus fondos han estado en contacto con una entidad sancionada internacionalmente. Los exchanges con KYC pueden usar este dato para bloquear o congelar depósitos relacionados.`,
didactic: "La OFAC (Office of Foreign Assets Control, EE.UU.) mantiene una lista de direcciones de entidades sancionadas: mixers como Blender.io o Sinbad, exchanges como Garantex o Suex, y grupos como Lazarus. La lista es de dominio público y la usan los servicios regulados. Una coincidencia es un hecho verificable, no una probabilidad.",
penalty: weights.entity_ofac,
});
deductions += weights.entity_ofac;
}
// 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,
});
}
// ── Score compuesto ───────────────────────────────────────────────
const maxPossible = Object.values(weights).reduce((a,b)=>a+b,0);
const score = Math.max(0, Math.min(100, Math.round(100 - (deductions / maxPossible) * 100)));
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;
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, summary: score >= 70 ? "Privacidad aceptable" : score >= 40 ? "Privacidad mejorable" : "Privacidad baja", summaryColor: score >= 70 ? C.green : score >= 40 ? C.amber : C.red };
}
// ── 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);
}
}
function useApi(baseUrl) {
const get = useCallback(async (path, mockData) => {
if (!baseUrl) return mockData;
try {
const res = await fetchWithTimeout(`${baseUrl}${path}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
} catch { return mockData; }
}, [baseUrl]);
return { get };
}
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;
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 = [];
// ── 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 transacción 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 transacción." });
}
return sections;
}
function PrivacyLab({analysis, tx}) {
const [expanded,setExpanded] = useState(null);
const [showDidactic,setShowDidactic] = useState(null);
const [showReport,setShowReport] = useState(false);
if (!analysis) return null;
const {score,checks,summary,summaryColor,wallets,band} = analysis;
const report = generateReport(analysis, tx);
// ── 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}}>
{score>=70?"La transacción presenta buenas propiedades de privacidad.":score>=40?"Se detectaron características que reducen la privacidad.":"Esta transacció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"?"cambia wallet":"evitable"}
</span>
</div>
<div style={{fontSize:"0.65rem",color:C.t2,lineHeight:1.6,paddingLeft:18}}>{rec.why}</div>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
<div style={{display:"flex",flexDirection:"column",gap:6}}>
{checks.map(check => (
<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.pass?C.green:C.red,flexShrink:0,fontWeight:700}}>
{check.informational?"·":check.pass?"✓":"✗"}
</span>
<span style={{flex:1,fontSize:"0.73rem",color:C.t1,fontFamily:"monospace"}}>{check.label}</span>
{!check.pass&&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`,
}}>
{check.actionability==="no_corregible"?"ya en chain":check.actionability==="wallet"?"cambia wallet":"evitable"}
</span>
)}
<CertaintyBadge level={check.certainty}/>
{!check.informational&&!check.pass&&check.penalty>0&&<span style={{fontSize:"0.62rem",color:C.red,fontFamily:"monospace",marginLeft:4}}>{check.penalty}</span>}
</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>
{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>
))}
</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:"No corregible"},
{label:"cambia wallet", color:C.purple, desc:"Depende del software"},
{label:"evitable", color:C.amber, desc:"Puedes evitarlo en el futuro"},
].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}}>
<Badge color={C.amber}>CPU {p.cpu}%</Badge>
<Badge color={p.mem>30?C.red:p.mem>15?C.amber:C.t2}>RAM {p.mem}%</Badge>
</div>
</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} onAnalyze={q=>{window.__setTab&&window.__setTab("auditoria",q);}}/>}
{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)
const cache={};
const toFetch=limited.slice(0,10);
await Promise.all(toFetch.map(async u=>{
try{
const res=await fetchWithTimeout(`${base}/api/tx/${u.txid}`);
if(res.ok) cache[u.txid]=await res.json();
}catch{}
}));
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>
);
}
// ── AUDITORÍA ──────────────────────────────────────────────────────────
function Auditoria({base, initialQuery}) {
const {get}=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 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/>}
<SectionTitle accent={C.purple} icon="⌖">AUDITORÍA</SectionTitle>
<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>
</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>
</Card>
<PrivacyLab analysis={analysis} tx={tx}/>
<TxVerifier base={base}/>
</div>
)}
</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, onAnalyze}) {
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>
{onAnalyze&&<div style={{marginTop:12,paddingTop:12,borderTop:`1px solid ${C.border}`}}>
<button onClick={()=>onAnalyze(addr.address)} 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>
{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. No muestra el contenido: para auditar privacidad lo relevante es que existan, no qué dicen.</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>
);
}
// ── Decodificador PSBT ─────────────────────────────────────────────────
function ToolPsbt() {
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 {
// Basic PSBT validation — starts with magic bytes "psbt" in base64
const bytes = atob(raw.replace(/\s/g,""));
if (!bytes.startsWith("psbt")) throw new Error("No es una PSBT válida — debe empezar por 'psbt'");
// Extract basic info from magic bytes
setResult({
valid: true,
size: bytes.length,
note: "PSBT válida. Para un análisis completo necesitas un wallet como Sparrow o Coldcard. Esta herramienta verifica el formato.",
});
} catch(e) {
setErr(`Error al decodificar: ${e.message}`);
}
};
return (
<Card>
<div style={{fontSize:"0.62rem",color:C.blue,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:8}}>Validador PSBT</div>
<div style={{fontSize:"0.65rem",color:C.t2,marginBottom:12}}>Pega una PSBT en base64 para verificar que es válida antes de firmarla. Nunca se envía a ningún servidor todo es local.</div>
<textarea value={input} onChange={e=>setInput(e.target.value)}
placeholder="cHNidP8BAH..."
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}}>VERIFICAR</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:8}}>
<span style={{color:C.green,fontSize:"1rem"}}></span>
<span style={{color:C.green,fontFamily:"monospace",fontSize:"0.78rem",fontWeight:700}}>PSBT válida</span>
<Badge color={C.t2}>{result.size} bytes</Badge>
</div>
<div style={{fontSize:"0.68rem",color:C.t2,lineHeight:1.6}}>{result.note}</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:"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 Node Dashboard es una herramienta de análisis Bitcoin diseñada para usuarios que gestionan su propio nodo completo. Permite explorar la blockchain, analizar la privacidad de transacciones y monitorizar el estado del nodo, todo desde tu propia infraestructura.</P>
<P>A diferencia de exploradores públicos como Mempool.space o Blockchair, <strong style={{color:C.t1}}>ninguna consulta sale de tu red privada</strong>. Tu nodo responde directamente: nadie sabe qué direcciones o transacciones estás investigando.</P>
<H color={C.amber}>Principios de diseño</H>
<Li><strong style={{color:C.t1}}>Privacidad total</strong> todas las consultas van a tu nodo. Sin telemetría, sin logs externos, sin CDNs de terceros para datos sensibles.</Li>
<Li><strong style={{color:C.t1}}>Datos fiables</strong> cada análisis indica explícitamente su nivel de certeza: CERTEZA, PROBABLE o POSIBLE. Nunca presentamos heurísticas como hechos.</Li>
<Li><strong style={{color:C.t1}}>Soberanía</strong> el dashboard es un único archivo HTML estático. Sin dependencias de servidor, sin base de datos, sin cuentas.</Li>
<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==="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==="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==="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==="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.t3,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>