4284 lines
301 KiB
HTML
4284 lines
301 KiB
HTML
<!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: #4a6380; }
|
||
</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, useMemo, useRef } = React;
|
||
|
||
// Detecta pantalla estrecha (móvil) y se reajusta al rotar/redimensionar.
|
||
// Reutilizable para adaptar cualquier vista a móvil sin media queries CSS.
|
||
function useIsMobile(breakpoint) {
|
||
const bp = breakpoint || 560;
|
||
const [isMobile, setIsMobile] = useState(
|
||
typeof window !== "undefined" ? window.innerWidth <= bp : false
|
||
);
|
||
useEffect(() => {
|
||
const onResize = () => setIsMobile(window.innerWidth <= bp);
|
||
window.addEventListener("resize", onResize);
|
||
return () => window.removeEventListener("resize", onResize);
|
||
}, [bp]);
|
||
return isMobile;
|
||
}
|
||
|
||
const THEMES = {
|
||
cypherpunk: {
|
||
bg:"#05080d", bgCard:"#090e16", bgHover:"#0d1520",
|
||
border:"#131d2b", borderBright:"#1e2e42",
|
||
green:"#00e87a", greenMuted:"rgba(0,232,122,0.08)",
|
||
amber:"#f5a623", amberMuted:"rgba(245,166,35,0.08)",
|
||
purple:"#b06ef3", purpleMuted:"rgba(176,110,243,0.08)",
|
||
blue:"#41c8f5", blueMuted:"rgba(65,200,245,0.08)",
|
||
red:"#f56565", redMuted:"rgba(245,101,101,0.08)",
|
||
t1:"#dde6f0", t2:"#6b88a8", t3:"#3a4d63",
|
||
headerBg:"rgba(5,8,13,0.93)",
|
||
},
|
||
amber: {
|
||
bg:"#0a0800", bgCard:"#110f00", bgHover:"#1a1500",
|
||
border:"#2a2000", borderBright:"#3d3000",
|
||
green:"#ffb300", greenMuted:"rgba(255,179,0,0.08)",
|
||
amber:"#ff8c00", amberMuted:"rgba(255,140,0,0.08)",
|
||
purple:"#ffcc44", purpleMuted:"rgba(255,204,68,0.08)",
|
||
blue:"#ffd966", blueMuted:"rgba(255,217,102,0.08)",
|
||
red:"#ff4400", redMuted:"rgba(255,68,0,0.08)",
|
||
t1:"#ffe066", t2:"#a8862e", t3:"#5c4810",
|
||
headerBg:"rgba(10,8,0,0.95)",
|
||
},
|
||
slate: {
|
||
bg:"#0e1117", bgCard:"#151b24", bgHover:"#1c2533",
|
||
border:"#1e2d3d", borderBright:"#2a3f57",
|
||
green:"#38bdf8", greenMuted:"rgba(56,189,248,0.08)",
|
||
amber:"#7dd3fc", amberMuted:"rgba(125,211,252,0.08)",
|
||
purple:"#a5b4fc", purpleMuted:"rgba(165,180,252,0.08)",
|
||
blue:"#38bdf8", blueMuted:"rgba(56,189,248,0.08)",
|
||
red:"#f87171", redMuted:"rgba(248,113,113,0.08)",
|
||
t1:"#e2eaf4", t2:"#6b8cad", t3:"#34495f",
|
||
headerBg:"rgba(14,17,23,0.95)",
|
||
},
|
||
light: {
|
||
bg:"#f4f6f9", bgCard:"#ffffff", bgHover:"#edf0f5",
|
||
border:"#dde3ec", borderBright:"#c8d2e0",
|
||
green:"#059669", greenMuted:"rgba(5,150,105,0.08)",
|
||
amber:"#d97706", amberMuted:"rgba(217,119,6,0.08)",
|
||
purple:"#7c3aed", purpleMuted:"rgba(124,58,237,0.08)",
|
||
blue:"#0284c7", blueMuted:"rgba(2,132,199,0.08)",
|
||
red:"#dc2626", redMuted:"rgba(220,38,38,0.08)",
|
||
t1:"#0f172a", t2:"#475569", t3:"#94a3b8",
|
||
headerBg:"rgba(244,246,249,0.97)",
|
||
},
|
||
};
|
||
|
||
const THEME_META = [
|
||
{ id:"cypherpunk", label:"Cypherpunk", dot:"#00e87a" },
|
||
{ id:"amber", label:"Ámbar", dot:"#ffb300" },
|
||
{ id:"slate", label:"Slate", dot:"#38bdf8" },
|
||
{ id:"light", label:"Claro", dot:"#059669" },
|
||
];
|
||
|
||
let C = THEMES.cypherpunk;
|
||
const setTheme = id => { C = THEMES[id] || THEMES.cypherpunk; };
|
||
|
||
const MOCK_FEES = { fastestFee:42, halfHourFee:28, hourFee:18, economyFee:8, minimumFee:3 };
|
||
const MOCK_MEMPOOL = { count:14821, vsize:28400000, total_fee:310000000 };
|
||
const MOCK_HEIGHT = 847293;
|
||
const MOCK_DIFF = { difficultyChange:3.42, timeAvg:596000, remainingBlocks:1204 };
|
||
const MOCK_BLOCKS = Array.from({length:10}, (_,i) => ({
|
||
id: `00000000000000000${i}a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0`,
|
||
height: 847293 - i, timestamp: Math.floor(Date.now()/1000) - i*600,
|
||
tx_count: 1500 + i*200, size: 1400000 - i*50000, weight: 3900000,
|
||
extras: { totalFees: 2000000 - i*100000, medianFee: 35 - i*2, pool: { name: ["Foundry USA","AntPool","F2Pool","ViaBTC","Binance Pool"][i%5] } }
|
||
}));
|
||
const MOCK_MEMPOOLBLOCKS = [
|
||
{nTx:2841, blockVSize:998000, feeRange:[38,45]},
|
||
{nTx:2210, blockVSize:985000, feeRange:[25,38]},
|
||
{nTx:1890, blockVSize:972000, feeRange:[16,25]},
|
||
{nTx:1420, blockVSize:910000, feeRange:[8,16]},
|
||
];
|
||
const MOCK_TX = {
|
||
txid: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
|
||
fee: 4200, feerate: 16.8, size: 374, weight: 996,
|
||
status: { confirmed:true, block_height:840811, block_time: Math.floor(Date.now()/1000) - 3891200 },
|
||
vin: [
|
||
{ sequence: 0xffffffff, txid:"d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5", vout:0, prevout: { value:5000000, scriptpubkey_address:"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", scriptpubkey_type:"v0_p2wpkh" } },
|
||
{ sequence: 0xffffffff, txid:"f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1", vout:1, prevout: { value:3000000, scriptpubkey_address:"bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", scriptpubkey_type:"v0_p2wpkh" } },
|
||
],
|
||
vout: [
|
||
{ value:7000000, scriptpubkey_address:"bc1qm34lsc65zpw79lxes69zkqmk6ee3ewf0j77s3h", scriptpubkey_type:"v0_p2wpkh" },
|
||
{ value:995800, scriptpubkey_address:"bc1qhkfq3zahaqkkzx5mjnamwjsfpq2jk7z09z4832", scriptpubkey_type:"v0_p2wpkh" },
|
||
],
|
||
};
|
||
const MOCK_ADDRESS = {
|
||
address: "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
|
||
chain_stats: { funded_txo_sum:284710000, spent_txo_sum:269887000, tx_count:47 },
|
||
mempool_stats: { funded_txo_sum:0, spent_txo_sum:0 },
|
||
utxos: [
|
||
{ txid:"abc1def2abc1def2abc1def2abc1def2abc1def2abc1def2abc1def2abc1def2", vout:0, value:10000000, status:{confirmed:true, block_height:846089} },
|
||
{ txid:"123a456b123a456b123a456b123a456b123a456b123a456b123a456b123a456b", vout:1, value:4000000, status:{confirmed:true, block_height:846911} },
|
||
],
|
||
txs: Array.from({length:6}, (_,i) => ({
|
||
txid: `mock${i}txid${i}mock${i}txid${i}mock${i}txid${i}mock${i}txid${i}`,
|
||
vout: [{ value: 1000000 + i*500000 }],
|
||
status: { confirmed: i < 5, block_height: 847200 - i*100, block_time: Math.floor(Date.now()/1000) - i*86400 },
|
||
})),
|
||
};
|
||
|
||
// ── Entity Index — direcciones de entidades conocidas ────────────
|
||
// Embebido, sin consultas externas. Lookup O(1).
|
||
// OFAC: lista de sanciones (penaliza). Mining: pools (informativo).
|
||
// Fuentes: OFAC SDN list, mempool.space coinbase tags, bitcoin-data.
|
||
const ENTITY_INDEX = new Map([
|
||
// ─── OFAC — entidades sancionadas (crítico) ───
|
||
["3K35dyL85fR9ht7UgzPfd1gLRRXQtNTqE3",{name:"Blender.io",cat:"ofac"}],
|
||
["3Q5dGfLKkWqWSwYtbMUyc8xGjN5LrRviK4",{name:"Blender.io",cat:"ofac"}],
|
||
["3EPqGUw2q89pwPZ1UF8FJspE2AyojSTjdu",{name:"Blender.io",cat:"ofac"}],
|
||
["3LhnVMcBq4gsR7aDaRr9XmUo17CuYBV4FN",{name:"Blender.io",cat:"ofac"}],
|
||
["3F6bbvS1krsc1qR8FsbTDfYQyvkMm3QvmR",{name:"Blender.io",cat:"ofac"}],
|
||
["3JHMz3mTna1gVCZSPp8NgRFiY7phkv5mA8",{name:"Blender.io",cat:"ofac"}],
|
||
["32DaxSzUhLBHY2WGSWQYiBSHnRsfQZrrRp",{name:"Blender.io",cat:"ofac"}],
|
||
["3MTRvM5QrYZHKo8gh5qKcrPK3RLjxcDCZE",{name:"Blender.io",cat:"ofac"}],
|
||
["34pFGsSYbWEritXncW9unZtQQE9dKSvKku",{name:"Blender.io",cat:"ofac"}],
|
||
["38ncxqt932N9CcfNfYuHGZgCyR85hDkWBW",{name:"Blender.io",cat:"ofac"}],
|
||
["3MD3riFB6U8PykypF6qkvSj8R2SGdUDPn3",{name:"Blender.io",cat:"ofac"}],
|
||
["3JUwAS7seL3fh5hxWh9fu3HCiEzjuQLTfg",{name:"Blender.io",cat:"ofac"}],
|
||
["3EUjqe9UpmyXCFd6jeu69hoTzndMRfxw9M",{name:"Blender.io",cat:"ofac"}],
|
||
["3QEjBiPzw6WZUL4MYMmMU6DY1Y25aVbpQu",{name:"Blender.io",cat:"ofac"}],
|
||
["3N3YSDvp4cbhEgNGabQxTN39kEzJmwG8Ah",{name:"Blender.io",cat:"ofac"}],
|
||
["3J19qffPT6mxQUcV6k5yVURGZtdhpdGr4y",{name:"Blender.io",cat:"ofac"}],
|
||
["33KKjn4exdBJQkTtdWxqpdVsWxrw3LareG",{name:"Blender.io",cat:"ofac"}],
|
||
["3GSXNXzyCDoQ1Rhsc7F1jjjFe7DGcHHdcM",{name:"Blender.io",cat:"ofac"}],
|
||
["3QJyT8nThEQakbfqgX86YjCK1Sp9hfNCUW",{name:"Blender.io",cat:"ofac"}],
|
||
["35hh9dg3wSvUJz9vFk1FsezLE5Fx3Hudk2",{name:"Blender.io",cat:"ofac"}],
|
||
["3NDzzVxiLBUs1WPvVGRfCYDTAD2Ua2PvW4",{name:"Blender.io",cat:"ofac"}],
|
||
["3DCCgmyKozcZkFBzYb1A2x8abZCpAUTPPk",{name:"Blender.io",cat:"ofac"}],
|
||
["3MvQ4gThF4mmuo49p4dBNchcmFHBRZnYfx",{name:"Blender.io",cat:"ofac"}],
|
||
["3FBgeJdhiBe22UoSpp51Vd8dPHVa2A4wZX",{name:"Blender.io",cat:"ofac"}],
|
||
["3HQDRyzwm82MFmLWtmyikDM9JQEtVT6vAp",{name:"Blender.io",cat:"ofac"}],
|
||
["31t4nEpcwyQJT1VuXdAoQZTT5givRDPsNP",{name:"Blender.io",cat:"ofac"}],
|
||
["39AALn7eTjdPzLb99hHhD6F7J8QWB3R2Rd",{name:"Blender.io",cat:"ofac"}],
|
||
["3LDbNuDkKmLae5r3a5icPA5CQg2Y8F7ogW",{name:"Blender.io",cat:"ofac"}],
|
||
["3JLyyLbwciWAC6re87D7mRknXakR4YbnUd",{name:"Blender.io",cat:"ofac"}],
|
||
["3ANWhUnHujdwbw2jEuGSRH6bvFsD9BqEy9",{name:"Blender.io",cat:"ofac"}],
|
||
["32fbAZMTaQxNd2fAue1PgsiPgWfcsHBQQt",{name:"Blender.io",cat:"ofac"}],
|
||
["3HupEUfKmMhvhXqf8TMoPAyqDcRC1kpe65",{name:"Blender.io",cat:"ofac"}],
|
||
["34kEYgpijvCmjvahRXXQEnBH76UGJVx2wg",{name:"Blender.io",cat:"ofac"}],
|
||
["3GYbbYkvqvjF5oYhaKCgQYCvcVE1JENk6J",{name:"Blender.io",cat:"ofac"}],
|
||
["3BazbaTP8ELJUEfPBV9z5HXEdgBziV9p7W",{name:"Blender.io",cat:"ofac"}],
|
||
["3GMfGEDYMTq9G8dEHet1zLtUFJwYwSNa3Y",{name:"Blender.io",cat:"ofac"}],
|
||
["38LjCapRrJEW7w2zwbyS15P9D9UGPjWS44",{name:"Blender.io",cat:"ofac"}],
|
||
["36XqYWGvUQwBrYLRVuegN4pJJJSPWL1WEu",{name:"Blender.io",cat:"ofac"}],
|
||
["37g6WgqedzZx6nx51tYgssNG8Hnknyj5nL",{name:"Blender.io",cat:"ofac"}],
|
||
["3QAdoc1rDCt8dii1GVPJXvvK6CEJLzCRZw",{name:"Blender.io",cat:"ofac"}],
|
||
["32PsiT8itBrEF84ebdaF82yBUEcz5Wc6uY",{name:"Blender.io",cat:"ofac"}],
|
||
["3B4G1M8eF3cThbeMwhEWkKzczw9QoNTGak",{name:"Blender.io",cat:"ofac"}],
|
||
["34ETiHfQWEYFCCaXmEeQWVmhFH5vz2JMvd",{name:"Blender.io",cat:"ofac"}],
|
||
["3PyzSbFj3hbQQjTzDzyLSgvFVDjB7yw4Cj",{name:"Blender.io",cat:"ofac"}],
|
||
["15PggTG7YhJKiE6B16vkKzA1YDTZipXEX4",{name:"Blender.io",cat:"ofac"}],
|
||
["bc1qq7p0es3dv5hcynjjf40f2xjjr6qp5py47d2f6n847vduuq9gvnyq7y9ecd",{name:"Sinbad",cat:"ofac"}],
|
||
["1JHdQHkBZiim1cb4hyUh2PbzEbbg6z2TrF",{name:"Sinbad",cat:"ofac"}],
|
||
["3Lpoy53K625zVeE47ZasiG5jGkAxJ27kh1",{name:"Garantex",cat:"ofac"}],
|
||
["12HQDsicffSBaYdJ6BhnE22sfjTESmmzKx",{name:"Suex",cat:"ofac"}],
|
||
["1L4ncif9hh9TnUveqWq77HfWWt6CJWtrnb",{name:"Suex",cat:"ofac"}],
|
||
["13mnk8SvDGqsQTHbiGiHBXqtaQCUKfcsnP",{name:"Suex",cat:"ofac"}],
|
||
["1Edue8XZCWNoDBNZgnQkCCivDyr9GEo4x6",{name:"Suex",cat:"ofac"}],
|
||
["1ECeZBxCVJ8Wm2JSN3Cyc6rge2gnvD3W5K",{name:"Suex",cat:"ofac"}],
|
||
["1J9oGoAiHeRfeMZeUnJ9W7RpV55CdKtgYE",{name:"Suex",cat:"ofac"}],
|
||
["1295rkVyNfFpqZpXvKGhDqwhP1jZcNNDMV",{name:"Suex",cat:"ofac"}],
|
||
["1LiNmTUPSJEd92ZgVJjAV3RT9BzUjvUCkx",{name:"Suex",cat:"ofac"}],
|
||
["1LrxsRd7zNuxPJcL5rttnoeJFy1y4AffYY",{name:"Suex",cat:"ofac"}],
|
||
["1KUUJPkyDhamZXgpsyXqNGc3x1QPXtdhgz",{name:"Suex",cat:"ofac"}],
|
||
["1CF46Rfbp97absrs7zb7dFfZS6qBXUm9EP",{name:"Suex",cat:"ofac"}],
|
||
["1Df883c96LVauVsx9FEgnsourD8DELwCUQ",{name:"Suex",cat:"ofac"}],
|
||
["bc1qdt3gml5z5n50y5hm04u2yjdphefkm0fl2zdj68",{name:"Suex",cat:"ofac"}],
|
||
["1B64QRxfaa35MVkf7sDjuGUYAP5izQt7Qi",{name:"Suex",cat:"ofac"}],
|
||
["3E7YbpXuhh3CWFks1jmvWoV8y5DvsfzE6n",{name:"Chatex",cat:"ofac"}],
|
||
["3NRJ8aXdUiZdHaiFX9ePX3DhGHzcEi14Fq",{name:"Chatex",cat:"ofac"}],
|
||
["3K7PMJyMNVnxqsfpmK9r9nJDtzDw9wNwNV",{name:"Chatex",cat:"ofac"}],
|
||
["3H3rh85qPaGLy2w6618yZNaH7i8asHv46B",{name:"Chatex",cat:"ofac"}],
|
||
["3MTrJTFhYK9v1C6pjHtuweZSopfZa4b1wb",{name:"Chatex",cat:"ofac"}],
|
||
["347QFbejDBdMZFTxpmn6evvvqyXiqZTCd7",{name:"Chatex",cat:"ofac"}],
|
||
["33xWfziVZesgo83U5izdNCBVTnrtBpSwK7",{name:"Chatex",cat:"ofac"}],
|
||
["32wdqwX3zCEX3DhAVEcKwXCEGdzgBnx1R9",{name:"Chatex",cat:"ofac"}],
|
||
["3N9YcPBDky9UsMx1RTk33tL4jDkZfSnsPk",{name:"Chatex",cat:"ofac"}],
|
||
["bc1q90zrdysy4flyacw7hsury3ajs9yzwtwp6guqpypx94w0d3p58hysvz6pde",{name:"Chatex",cat:"ofac"}],
|
||
["bc1qw7vfgv3r5vnehafl0y95sclg3uqsj87wxs9ad628yjjcq33cwessr6ndyw",{name:"Chatex",cat:"ofac"}],
|
||
["bc1q86tl9255vg5wldamfymaaz36uqxzm30gs7fhkljvzdlt9t38s3lqgdwdfq",{name:"Chatex",cat:"ofac"}],
|
||
["3M7CGBPUJwXXSroWuZ6H5jiprdKCyf7V5M",{name:"Chatex",cat:"ofac"}],
|
||
["34kWCKF2wCbe6uinit2uL4ND6d8yxsuxKM",{name:"Chatex",cat:"ofac"}],
|
||
["bc1qe95l438kzjcvnsm3kn8n5augf9gpctdlhsq7f7hpnkyvlr7rc7cqupapf7",{name:"Chatex",cat:"ofac"}],
|
||
["32VgTk8kGvBsqkHhkvtNooGdtqZm46jTVo",{name:"Chatex",cat:"ofac"}],
|
||
["3NPognMSbzyA2JYW2fpkVKWyBMi2XTq2Zt",{name:"Chatex",cat:"ofac"}],
|
||
["3MzLtBQ4Lz9J6w4Qu55TktgxFKZwxYWrP6",{name:"Chatex",cat:"ofac"}],
|
||
["36YGN5dGzqrxMomTHdkT6cYVMnWBw8S7hD",{name:"Chatex",cat:"ofac"}],
|
||
["bc1q4rzdtlt0uslyw86cp29sctl6ct29g9a95cuup7pn5md9ddj7xgmqpp5m73",{name:"Chatex",cat:"ofac"}],
|
||
["39KQvziHwUe2vddbpfC5WkQEV72qbQhxuh",{name:"Chatex",cat:"ofac"}],
|
||
["3Qw9Fn19gCnga9LfHfpM99aGzuqxBNjR2i",{name:"Chatex",cat:"ofac"}],
|
||
["1EfMVkxQQuZfBdocpJu6RUsCJvenQWbQyE",{name:"Lazarus Group",cat:"ofac"}],
|
||
["17UVSMegvrzfobKC82dHXpZLtLcqzW9stF",{name:"Lazarus Group",cat:"ofac"}],
|
||
["39eboeqYNFe2VoLC3mUGx4dh6GNhLB3D2q",{name:"Lazarus Group",cat:"ofac"}],
|
||
["39fhoB2DohisGBbHvvfmkdPdShT75CNHdX",{name:"Lazarus Group",cat:"ofac"}],
|
||
["3E6rY4dSCDW6y2bzJNwrjvTtdmMQjB6yeh",{name:"Lazarus Group",cat:"ofac"}],
|
||
["3EeR8FbcPbkcGj77D6ttneJxmsr3Nu7KGV",{name:"Lazarus Group",cat:"ofac"}],
|
||
["3HQRveQzPifZorZLDXHernc5zjoZax8U9f",{name:"Lazarus Group",cat:"ofac"}],
|
||
["3JXKQ81JzBqVbB8VHdV9Jtd7auWokkdPgY",{name:"Lazarus Group",cat:"ofac"}],
|
||
["3KHfXU24Bt3YD5Ef4J7uNp2buCuhrxfGen",{name:"Lazarus Group",cat:"ofac"}],
|
||
["3LbDu1rUXHNyiz4i8eb3KwkSSBMf7C583D",{name:"Lazarus Group",cat:"ofac"}],
|
||
["3MN8nYo1tt5hLxMwMbxDkXWd7Xu522hb9P",{name:"Lazarus Group",cat:"ofac"}],
|
||
["3N6WeZ6i34taX8Ditser6LKWBcXmt2XXL4",{name:"Lazarus Group",cat:"ofac"}],
|
||
["134r8iHv69xdT6p5qVKTsHrcUEuBVZAYak",{name:"Lazarus Group",cat:"ofac"}],
|
||
["15YK647qtoZQDzNrvY6HJL6QwXduLHfT28",{name:"Lazarus Group",cat:"ofac"}],
|
||
["1PfwHNxUnkpfkK9MKjMqzR3Xq3KCtq9u17",{name:"Lazarus Group",cat:"ofac"}],
|
||
["14kqryJUxM3a7aEi117KX9hoLUw592WsMR",{name:"Lazarus Group",cat:"ofac"}],
|
||
["1F2Gdug9ib9NQMhKMGGJczzMk5SuENoqrp",{name:"Lazarus Group",cat:"ofac"}],
|
||
["3F2sZ4jbhvDKQdGbHYPC6ZxFXEau2m5Lqj",{name:"Lazarus Group",cat:"ofac"}],
|
||
["1AXUTu9y3H8w4wYx4BjyFWgRhZKDhmcMrn",{name:"Lazarus Group",cat:"ofac"}],
|
||
["1Hn9ErTCPRP6j5UDBeuXPGuq5RtRjFJxJQ",{name:"Lazarus Group",cat:"ofac"}],
|
||
// ─── Mining pools (informativo) ───
|
||
["1KFHE7w8BhaENAswwryaoccDb6qcT6DbYY",{name:"F2Pool",cat:"mining"}],
|
||
["bc1qf274x7penhcd8hsv3jcmwa5xxzjl2a6pa9pxwm",{name:"F2Pool",cat:"mining"}],
|
||
["bc1q7wedv4zdu5smt3shljpu5mgns48jn299mukymc",{name:"F2Pool",cat:"mining"}],
|
||
["1KGG9kvV5zXiqyQAMfY32sGt9eFLMmgpgX",{name:"F2Pool",cat:"mining"}],
|
||
["12KKDt4Mj7N5UAkQMN7LtPZMayenXHa8KL",{name:"Foundry USA",cat:"mining"}],
|
||
["1FFxkVijzvUPUeHgkFjBk2Qw8j3wQY2cDw",{name:"Foundry USA",cat:"mining"}],
|
||
["bc1qxhmdufsvnuaaaer4ynz88fspdsxq2h9e9cetdj",{name:"Foundry USA",cat:"mining"}],
|
||
["bc1p8k4v4xuz55dv49svzjg43qjxq2whur7ync9tm0xgl5t4wjl9ca9snxgmlt",{name:"Foundry USA",cat:"mining"}],
|
||
["bc1qwzrryqr3ja8w7hnja2spmkgfdcgvqwp5swz4af4ngsjecfz0w0pqud7k38",{name:"Foundry USA",cat:"mining"}],
|
||
["1PuJjnF476W3zXfVYmJfGnouzFDAXakkL4",{name:"ViaBTC",cat:"mining"}],
|
||
["12dRugNcdxK39288NjcDV4GX7rMsKCGn6B",{name:"Antpool",cat:"mining"}],
|
||
["15kiNKfDWsq7UsPg87UwxA8rVvWAjzRkYS",{name:"Antpool",cat:"mining"}],
|
||
["16MdTdqmXusauybtXTmFEW4GNFPPgGxQYE",{name:"Antpool",cat:"mining"}],
|
||
["16kUc5B48qnASbxeZTisCqTNx6G3DPXuKn",{name:"Antpool",cat:"mining"}],
|
||
["17gVZssumiJqYMCHozHKXGyaAvyu6NCX6V",{name:"Antpool",cat:"mining"}],
|
||
["1AJQ3jXhUF8WiisEcuVd8Xmfq4QJ7n1SdL",{name:"Antpool",cat:"mining"}],
|
||
["1BWW3pg5jb6rxebrNeo9TATarwJ1rthnoe",{name:"Antpool",cat:"mining"}],
|
||
["1CyB8GJNEsNVXtPutB36nrDY3fMXBTzXSX",{name:"Antpool",cat:"mining"}],
|
||
["1D4UZG4qo8bF1MuZHSEyBHRZaxT8inatXS",{name:"Antpool",cat:"mining"}],
|
||
["1DDXyKUT6q3H9e5QXm2Gv6BNNWgztFG55g",{name:"Antpool",cat:"mining"}],
|
||
["1Dek9ArRHb9tyWb9gaaX8SWmkfi5V7U5Y6",{name:"Antpool",cat:"mining"}],
|
||
["1DyR7HPQWjM6Zrnk7SzHVY2GEpXRGNNH9o",{name:"Antpool",cat:"mining"}],
|
||
["1FdJkPdpXtK3t5utZHJAop3saLZWfPfgak",{name:"Antpool",cat:"mining"}],
|
||
["1GRcX882sdBYCAWyG99iF2oz7j3nYzXhLM",{name:"Antpool",cat:"mining"}],
|
||
["1Gp7iCzDGMZiV55Kt8uKsux6VyoHe1aJaN",{name:"Antpool",cat:"mining"}],
|
||
["1H3u6R813MHGYhmGW6v86EYYriawRtACYD",{name:"Antpool",cat:"mining"}],
|
||
["1JBVrhSSDrZrRmm4RnoWouqgGGqJMvWHi8",{name:"Antpool",cat:"mining"}],
|
||
["1JwUDWVSbAY5NeCBJhxQk1E8AfETfZuPj4",{name:"Antpool",cat:"mining"}],
|
||
["1K8PNogxBZ6ts532DZnzxdbjgzJLjLdXqz",{name:"Antpool",cat:"mining"}],
|
||
["1LTGvTjDxiy5S9YcKEE9Lb7xSpZcPSqinw",{name:"Antpool",cat:"mining"}],
|
||
["1NS4gbx1G2D5rc9PnvVsPys12nKxGiQg72",{name:"Antpool",cat:"mining"}],
|
||
["1Nh7uHdvY6fNwtQtM1G5EZAFPLC33B59rB",{name:"Antpool",cat:"mining"}],
|
||
["1Sjj2cPC3rTWcSTEYDeu2f3BavLosog4T",{name:"Antpool",cat:"mining"}],
|
||
["1jLVpwtNMfXWaHY4eiLDmGuBxokYLgv1X",{name:"Antpool",cat:"mining"}],
|
||
["39C7fxSzEACPjM78Z7xdPxhf7mKxJwvfMJ",{name:"Antpool",cat:"mining"}],
|
||
["3FaYYQF6wCMUB9NCeRe4tUp1zZx8qqM7H1",{name:"Antpool",cat:"mining"}],
|
||
["15MdAHnkxt9TMC2Rj595hsg8Hnv693pPBB",{name:"MARA Pool",cat:"mining"}],
|
||
["1A32KFEX7JNPmU1PVjrtiXRrTQcesT3Nf1",{name:"MARA Pool",cat:"mining"}],
|
||
["3D72db1KMCnj7FL7MBsmxTw81z2bVu4UN5",{name:"MARA Pool",cat:"mining"}],
|
||
["3LC8dDKyBsrWPfzhXyt7aAyjXxGYkfDdHu",{name:"MARA Pool",cat:"mining"}],
|
||
["1AqTMY7kmHZxBuLUR5wJjPFUvqGs23sesr",{name:"Braiins Pool",cat:"mining"}],
|
||
["1CK6KHY6MHgYvmRQ4PAafKYDrg1ejbH1cE",{name:"Braiins Pool",cat:"mining"}],
|
||
["1MkCDCzHpBsYQivp8MxjY5AkTGG1f2baoe",{name:"Luxor",cat:"mining"}],
|
||
["39bitUyBcUu3y3hRTtYprKbTp712t4ZWqK",{name:"Luxor",cat:"mining"}],
|
||
["32BfKjhByDSxx3BM5vUkQ3NQq9csZR6nt6",{name:"Luxor",cat:"mining"}],
|
||
["37dvwZZoT3D7RXpTCpN2yKzMmNs2i2Fd1n",{name:"Ocean Mining",cat:"mining"}],
|
||
["bc1qvfzssz36y4gxcg9gh234rzem9k0vrdlx4kq5sg",{name:"NiceHash",cat:"mining"}],
|
||
["14yfxkcpHnju97pecpM7fjuTkVdtbkcfE6",{name:"Bitfury",cat:"mining"}],
|
||
["1AcAj9p6zJn4xLXdvmdiuPCtY7YkBPTAJo",{name:"Bitfury",cat:"mining"}],
|
||
["1FeDtFhARLxjKUPPkQqEBL78tisenc9znS",{name:"Bitfury",cat:"mining"}],
|
||
["1Nd99aNgYWpKkqcqSMgWtdtVDadewAS5F7",{name:"Bitfury",cat:"mining"}],
|
||
["13JmJMxfsmtiscVhJHHAHLRDqkeJLwDCNQ",{name:"BTCC",cat:"exchange",src:"cluster"}],
|
||
["14KBQmbsGhhsRNcYgRnj78arkzsDBDUoeH",{name:"BTCC",cat:"exchange",src:"cluster"}],
|
||
["14iykVoJbnqdqSXwL1dxd5jhrEmStjMAcm",{name:"BTCC",cat:"exchange",src:"cluster"}],
|
||
["15GZfB6BKqkzEHmvnireuD2gakuVe5rzC3",{name:"BTCC",cat:"exchange",src:"cluster"}],
|
||
["15GufGoLdDmcwHnQQKnzPabTomz4ZCZGQR",{name:"BTCC",cat:"exchange",src:"cluster"}],
|
||
["17TaKE9ST9pkZh1b6svJPiRmrK4B3FuwCy",{name:"BTCC",cat:"exchange",src:"cluster"}],
|
||
["19f4bD2FxF2NkmxJg2WsN33JmEG9pCGS5C",{name:"BTCC",cat:"exchange",src:"cluster"}],
|
||
["1E55hhTcPFzWy7jeVpLVGnvWcVryn5mtqr",{name:"BTCC",cat:"exchange",src:"cluster"}],
|
||
["1Ghy3746Ri1LeC1BXQXKxmKuT4wWPndVpH",{name:"BTCC",cat:"exchange",src:"cluster"}],
|
||
["1GtR2gL9v1vsdH4pCmPUQsqcdAbUM1NP4V",{name:"BTCC",cat:"exchange",src:"cluster"}],
|
||
["1H3T7B2DbGjheZHd4eLQVn7Sp7G6fsVjfx",{name:"BTCC",cat:"exchange",src:"cluster"}],
|
||
["1HEstph5Emkucm5nNJo5X4utj5Y9286DLE",{name:"BTCC",cat:"exchange",src:"cluster"}],
|
||
["1KBk4d4KbFyGv5bMYwNVprDFAghcBUJfn2",{name:"BTCC",cat:"exchange",src:"cluster"}],
|
||
["1KHUbrASx6ust5cns1FTmsxbwvq4rmvt1B",{name:"BTCC",cat:"exchange",src:"cluster"}],
|
||
["1LDmXvCDvV2zJfULw2T55NuVJuwvTHEHWs",{name:"BTCC",cat:"exchange",src:"cluster"}],
|
||
["1LP51sotiufxyWvmZrDKYDCsphNt6TjkUp",{name:"BTCC",cat:"exchange",src:"cluster"}],
|
||
["1LQXi8FfrsduvrwuSBD3REMjZDfxXjtZWu",{name:"BTCC",cat:"exchange",src:"cluster"}],
|
||
["1Lf5WjV1WBhK7zG7SYPDAdLM5LygU4ZYWa",{name:"BTCC",cat:"exchange",src:"cluster"}],
|
||
["1MR61zxeYSq5Rz7KfzvKJBuPLL5m8hrL5e",{name:"BTCC",cat:"exchange",src:"cluster"}],
|
||
["1NibLyFvkASQWfrHhdsBGaweUh6YJPeK8f",{name:"BTCC",cat:"exchange",src:"cluster"}],
|
||
["13EzhtK1UAJW3uBhmwcAkBN7uqEpVnAzDv",{name:"BitPay",cat:"exchange",src:"cluster"}],
|
||
["15FqFTPm3amgv2gX4ASyo5JLu9yDfvtAZz",{name:"BitPay",cat:"exchange",src:"cluster"}],
|
||
["16FprC8UUAkJReuNRqus9rvuBxMR4HWp22",{name:"BitPay",cat:"exchange",src:"cluster"}],
|
||
["17FXdktcyiyHjNF8WqTUU46qLTahfRqwTm",{name:"BitPay",cat:"exchange",src:"cluster"}],
|
||
["17qdd64x6LJT8iEN1P1BpSxasFYgYuGPJo",{name:"BitPay",cat:"exchange",src:"cluster"}],
|
||
["18mKyjyUjouG3iaoVCH4zyHEoyan6HEvWj",{name:"BitPay",cat:"exchange",src:"cluster"}],
|
||
["1BAwXXhXuEUk2n6AJMcfdi81X4mmbtx84e",{name:"BitPay",cat:"exchange",src:"cluster"}],
|
||
["1CEAcGsMrcpjxtFiKT9JKwVY628LJFcypQ",{name:"BitPay",cat:"exchange",src:"cluster"}],
|
||
["1Cnc6HdErHyxmMXn8i8k9RWQDVCoM52BNv",{name:"BitPay",cat:"exchange",src:"cluster"}],
|
||
["1D87ViVe4xXh4XNQzQPBigGKL4rTxi5hDm",{name:"BitPay",cat:"exchange",src:"cluster"}],
|
||
["1EATV1BvmSikvMnWow8LoqPbynSDmxdcZs",{name:"BitPay",cat:"exchange",src:"cluster"}],
|
||
["1EikfuSy19oV3B8sViYNvf2EDWfLXnCtGv",{name:"BitPay",cat:"exchange",src:"cluster"}],
|
||
["1FKJY6nbm9fvRoZaAkEGtR7CQp9STjvgDj",{name:"BitPay",cat:"exchange",src:"cluster"}],
|
||
["1JhBYAt3vNi3P4Jh9TDENoyDKHEVQihUmD",{name:"BitPay",cat:"exchange",src:"cluster"}],
|
||
["1Jwf6PaRo5vNdEyxjkhXqD8fMoe16Xy581",{name:"BitPay",cat:"exchange",src:"cluster"}],
|
||
["1LfAVVQV7Xt2QWgVewyyUygQ7NAVcaXZ16",{name:"BitPay",cat:"exchange",src:"cluster"}],
|
||
["1M7rYKbkthr5smABbt2SoXzJLFoTi9xj1D",{name:"BitPay",cat:"exchange",src:"cluster"}],
|
||
["1QBkeTETDomsJh5jMRbLjuy1yYey7XAug9",{name:"BitPay",cat:"exchange",src:"cluster"}],
|
||
["1QFKFV2iHXYnieHKh5bRGmKrtXHrFTKgDV",{name:"BitPay",cat:"exchange",src:"cluster"}],
|
||
["1pVmdnqXFfyq5xv2hzwGm2X8JYS6TgobH",{name:"BitPay",cat:"exchange",src:"cluster"}],
|
||
["12j8KeDLDTcmLH9L1uP7RmYqNLBk5w6gYL",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["13asmtt9g8FMmSSLHkMsuG6KXCv86kQ8hY",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["13rCNPkLp27VtdfLdbZnbSxm9NG8xw3Dc1",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["13zUF3a39eZ7BkRf3w3KTzVzTGwK8em1R3",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["143iJ6gqQ9MMbXWRqKVNXhxXm2a8yUmQqd",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["154A7yBEmbeayLoQ557hWeYcc8SSpbd5Cw",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["15SRuTm77maTfCx4ndyVuwEHgqUeK6kkZt",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["15VZsRthXqBj7knfZ2hqR7PQy1F6SqipGS",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["17qZbpiC91nmpEqbJHLyoyao21fduNmZ4c",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["187V1TN74QCFycyKkjx4s8CnTwTXnGpztJ",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["18BcFawpyHMkjDTWVLqsxqr9S1CRXo4EHM",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["18yYnkuG7Dj9akTAXYo8JkpYYUgBcbercY",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["19NWk5ouaXNW1AiYvXZJyksvn7nbrcRiXz",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["19hjC2jG8K3oAt8xqm6P2m6zb84EwhkVTK",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["1A591tkdns5SZjsujg8Fg4VkDvY5Hp8juu",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["1AWzC6FaqV95dWQHffyB9vabJAH4G9PQvN",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["1BAcYxHahsqxfrRtVYacsW6nkPvx7Rwxge",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["1BivsmQxepwTTp96ysBiT1UF7GyHu35jGp",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["1Caw4AMmX2ACztRxuP4K5dWybMSqkyQQfk",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["1FamuE3oRM6vT3rGYbYKtrTQFZF8gukBSf",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["1GNjdhN4JPhA2RfDiCEBAE9fKzE4MJZSFy",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["1HzoVgU9No93WwbNgwXHeMdD5NGjioCt7K",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["1JJCmLaMe6MZX47h25cG5cGxPTYZ8XFm5Y",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["1JbC68vpWNyXNtDGxWtqP518R5voqeTrLA",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["1JjDF8ht3VN5B5scDiWzVvmkfLeMVhTiX2",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["1K6jkrNa6FuEfXFz2Sf3pVMQRxykDEX2Ev",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["1KNVVNAfWCwijmi1X8oRuxEhNUP2QzQgCF",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["1KgQUQjqwddMdQWK87oR9KjN7Hm2t1w3vx",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["1Koy2zA93VgZT2sr2v13tLZ6HDWWcePECj",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["1MG3fJ2GFqJn4Hj4DzbZY9EMbQrxahxYWZ",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["1Nk2UNED12CFTPvzETunF4tdjiqS5HTsWv",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["1PEG84KWeSDcDBExhgo5ngEQNybohu3PDz",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["1PQYBt8VATUZiMGQw9rEVNLLJUpZGVykjw",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["1PfKVhtfKtsJC6914PP8a2HTjEKEJRbq5F",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["1PfZSv4bXEponpy99kXskKyVg7EdxQFcUG",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["1bz9Vp1vVhpH36ujjCULer5syar8AWt31",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["32Hgbz12HzPLes2L9wQNnu6epXMgDxeMvL",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["32nDpW3JxJ2hDHtFN3G9ZKneCJDqqecRSK",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["337pZwtgmm2PnAXjDpsp6nJ6PqFEuyceNV",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["339TKGwDLDdwvrR7xcYsM5GkY1HpWeGNPf",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["33HuMKdvcmzkcneV1USTcZVJqpkB2VBCd9",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["33hfnAXusAqAECmup7vz4xcduYzL4sadKK",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["33inC9M8CFH9eUhZModeNrEukSP61nWFKp",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["33jY477XPfh5JMkKhiq796yhHb97SrpFJB",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["34B9qezStgpdrcou1gSB9BVbuM1s9gHjtj",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["34QqziuZ8FZFUbhNa6MEHVwAcHLnCTXgTa",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["34eyDsZTjNJDG94Lc15DAUiEGALJbZQco3",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["34ryCaXdHBXAYNoWx9uarGe2BRv9aHKDAf",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["34yqz4xeg2rU97XZco1pxoUL58hSiWzc5e",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["36mbcJVoPwqDEdKNNAvefB6jcEGX8KYEW7",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["37g4L1sDNahPL7vQKG17YCXBinMt7QcmqL",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["37nxRAXixoS9KXqEFuhiCKYCQB89Gv2DDV",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["38wtfzWoxkr2quwSAMm18Xifygz2hWKTUn",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["39HyDsA97QjviwWHFstGkb8U9p4kZT3fZ1",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["39LAyy77En7ST9ygAXYSd2YQH7kYNWHTnq",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["39kUREkPTxfdBpJz7FGZzwEXqNZHuT2Gtx",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["39kmRMqS5BAwQii4AggoxMDJ6riwcXAqVp",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3A7mKFvogic4CP17tyy3JB9ac28Jg292pt",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3AnALWk4uDK2PJcykaZTnuENUA3jWQ3kzi",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3B372xLfWD3j3BmwTud6LdTySGSTFJ71oe",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3BZztLpXkwq7Y7tHgBuTaF3uicZQibkGiF",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3CJtkTcEaCND6qHdAwdpAFufgWcQ1AC3kE",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3CQLVeVPNvYJegEhDF79MQcWCVMA1qbZ9K",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3CeKGe2zU8PcwR5LkffQ2KVJ3DBjvxy9Um",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3CtY6YAuo4c4jvW8GAL9VgqR8wHj2ehHkB",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3D5xvErYoZwgJez3ZH6pwyKyhqxaYgDahC",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3DQ1SCNVCXf5SdXhm3WiassYLhDybfyv6S",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3DdzHGif9XUbFBFBzyngmZv84VJTdBMpnn",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3F5EBhK9aDsKdrR9iCASsNPKTEhFzrx6MD",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3FkybPjv9WJCTAc3SXR79uiAy5nDcJUB4A",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3GgyrGSjxyJM6ZtwPEnMuGWdT9pJ7tCXJE",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3H2WdfJV3J3wdhT91q68bR2QSvcuYnFygL",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3HCzPmzmqU3ZMQeP43E4uj2Rw9vSRybNCH",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3HZsgGApFEigC6b9Lj9fRX5ztJhAhyoUxR",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3HrgqQ2H54woTHC8XuGKMA7pWb1yvQZFdn",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3J3kQ6w2tA1ZFpduBKcQwLg1x4qYW4Dt5r",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3J83empZHbLGYEPX7kZyoj3siTcWVivCJQ",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3JDNp3k53E6rUYE5KM6MbS5ut9eX3zJjkE",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3JbNs6fDkBbxWWz7rzEETZq5ua26J1ycn1",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3KLSVQgg5t5n5LWJ51yMemmbzVq9bcYKB2",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3KPHQTi8Rj7YTpSip2Ex4sWdPygAs6Qmpz",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3KhAks2JFoNMeb4E7dtx1c3vqDoUEic4wb",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3KmE8f6jw4wy44xU2BPoptgf8veT4pjftv",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3KpakYqkqS6xybiwFKQVXJ5pFR717hWqqZ",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3LNmUzJik2n9o49iacagLWZ6cqfyF7t7Zi",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3LUvYcJ8AEU1N6LbfFvw1psaV94U5VhS3Q",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3Lpe5ueCKu8sWYKsDhSUdqTuF1q5oBMHAV",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3MANMYQYwpQtuRditSdhM5tgLapJQumPbZ",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3MKiiR7W7JmKU9xB1dGHZr3oTKQGzyeiEv",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3MT6Snjpxc8EvVA9Ub9NUKCw1tzGXTfhqx",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3MsZViNbeHY3NMuDGcYFSfGqDaK2UwMLdW",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3N8vFbudt8Q4ooHDPU48sDtiucsbsFpciK",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3PPHmDGLmBHKv4tWbaQKUKUfQxrCJYLnur",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3PVDFfPUy4zuANQB2sHyC36iqWhV272QdK",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3PZZGoyqZYXnDVLvwEx9pDGDB8BjUMDUdo",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3Pv16w9CF4YqixcchRZYWjd7n96XupLJAB",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3PzC6GL9h3Z7VSovxXhnWhEhDA2F3oU2Td",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3QC6KAnChVCbbV1c7sFpx5K3AZ25qzHiLz",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["3QFMq68VtDG7XX1TJqSB2poQV76So5pEiu",{name:"Bitcoin.de",cat:"exchange",src:"cluster"}],
|
||
["1129A9dFqy4ABDsGQ8RGusbMWYBoZc4myc",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["122LtqxDXZuQ1fU34Gn83kr6UAHUCQP1Jz",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["126pCFmyyidpiwSEVeBfjuUWjunSexeEPV",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["12JnUK69nFQYwDpdVo26GUhNUQ2FdscbK8b",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["12JsfJ8Y6qoZvfsqu2E4nzAhNqkA2Pe4Ya",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["12d4QwCwKj2zwCVcpwwhdkrbEHjfkDtmtH",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["12mKcMszHTdYU3ya5yxBLQhp1HfgDkqbec",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["12rzdGG3HaMFsE7MkYpEX5YvB9E7qUqk1N",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["13BYmugkKw3UHgRxeKQPajurpLRjhpEE1X",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["13FPbyzf2Y5oJqUpDHfsQt8QMukPQx55c6",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["13VT9amaRZJ247qXxo3kr54CrXgdNwheiz",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["143grU7zjhQk32MFkg3i3TaUni4EUCAg4K",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["14FZML8FmmVr43YjAvVSBPdhwuur2J5new",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["14TLV7Ms7efVLUrYexYNFCZ361CnqNhWTP",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["15dMQYQqAfhNj3cUPXMuiqVF5G1oEWKmJc",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["15fYjcCadt5aZyiFveaSrCVgdyFZouyXU6",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["15ykyQD6M1vQk9PyMXJd4nsJHzEK7RHLkY",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["15zetdHgZA22s4XAVVBrrJ5YTSsTUjhuMp",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["165ERUpEs9ktjHx8ymqQ7zg8mjT8wurWVj",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["16WP5xbvVk9A5wZZaEQFBqz2Cb4ME2puoK",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["16iu8bQpmCm8aYxPEkWbaCcTrRmDC9uS5d",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["16ydh3znvBGpBmQpyTT2Kfmakz5P7W6pQN",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["17dan7s9vaBDotRXt5XfXUEvBnr6ofi8ph",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["17gZVXZ4g4dEDtiDo7Heg3mWevLcCxe61u",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["187Wtdn1NmTwq2bZMpLJ85cphGsRFNZjst",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["18FJAzqBftbXTMsaK1JYwcAifSsmsv8RCx",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["197jMxWmQgzVq3wAo3ybcwn591kDP1dZfi",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["19RvJ2MBsnWg1p6XUYi6xwVDHb5wjr85o5",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["19SEwaY9fyRL9tbyBoKXBE1p4xXFLCAFXx",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["19VNw8EQWKTmN7u1X15hqyWHrymrCCVWdK",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1AWoHw4kYqDdiBLjauL5fe74WCPWoeka9z",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1AZmTMCeUbXRvFqGpQdkDzTXoFSWugrMDS",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1AdgqFm49hPFWtCtGgd8WTpukBuhiMdiLy",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1AisCybQXq1BDhdCpNf1zfbuyi8FBJUrxT",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1AkkiPRVrYMoKBvGumQ4KA3VAeDUpAkQTT",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1AoGjCpgRNmtFF9AoJw7pyQ45V6mQNmWfC",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1As2MR7L3YVRXgg474dercw2SSFxesXMbc",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1B1CwPGfd9rETmsk9AawSr865tajqTdrt6",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1B8MuMxt3QoGU2Ngg8HXtyy9Qrkwc9Dnou",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1Bsw3B5xUrgfp3LHk9WMTSbZVLKAYcFETP",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1BycLiTsxX2SwdpHGjnbQFoNU5oySDo7nJ",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1C4nqSBnoc6KxqCgXgLqW6txCcwjwcYBpj",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1Cc5Te1MZ4dVqUKygmRwFX9GtFuQJfXWu8",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1ChSkAhsT2zCM4dnMPKVPqZvmy8CEvitu9",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1CkN6W3eFqb4j4C4xRpLcZ6Q2PTdJjRbHU",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1CmMKk4zsWk5yZReg4Ln4wPKcnFYTuBw3T",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1CtSgJSQAUHdK9VR9zv6rP2zWCD319dgu8",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1D2jvZSUvVnQbbaHvxBqyAm4cNHA1pRoaV",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1DLsHNBq6AqDR8tqkErLGaXwaoi39n3Q21",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1DU6pv3mextRtE1PdbC3YWdC2PjcESBa2o",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1DUvE32zPF73D7vAyba5yW7xxJ7u7kznoS",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1Dd52EnXoPmYiubDEBv9SDaihsuu4E8gYK",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1Dh4tZjFzneQqfYryAFcunYzbyMZEQbHZo",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1E5KQmxf71mPLrCNkuxqbgWkRxriL5oX9j",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1EF85HtkoqJLvuruuSpUt8FyoLV6kRDRkz",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1EFWMicwwC3ZyuZJSvkKrofqmwz63HXHvq",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1EgehKpUCCpZ8XkVdAA7w6WmayuVkYZLo7",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1FC1BgZbgkpqetdByyBR6GKuFi6eWYSASn",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1FQBqWvxwmkmTataSAAa9RpYMV5g9DcMph",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1FRuPupcdy3NBiBKW2VGkapJuwuf2GcaTQ",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1FVEASn821E6LVNFPQ3X3rXEvtgqmBQ7Zw",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1FcLSyBcvSpxRsATGCQxPvWS1uX6RMXUi5",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1FiYFw39bGwiYZsseMnqz25iX2sBCCfcj5",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1FqgPXZumCXKcn4dgLKnYNn4akTZuXZ911",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1G4wYZrdtqrBM2ZDvXfsPQXoxstPTceHqY",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1GR87bLeiUpQvyviq8fMf1nHUbF3BQVxeK",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1Gfab2KK5BLPv2wERkfGAm7nTP83Hvyd6m",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1GgspBiakYDHHVMFZeUKqdajTxgFYo2x4e",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1Hbm3EXdSSS5wWpYjHMefzU14avDeQEFaK",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1HiSmbVAtKMvm15dCW52JSEvM1Q8STEjTM",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1HzCmSh4iVkbme93P1e8HKvEaaGrqqvuPt",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1J6snu5ua6zhwgVKWBWTVCi4PaAkaBKxqf",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1JAtUdkwcNVcvZQhf3F3ksTQaJXS1gLBBj",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1JD5U7pmABH9dyWzwjaqPj4SfSv3Xn4d9S",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1JPkpYAuA8JQuM3iKXihGjWVrULbtedDZU",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1JTQgn6jTxFPu1G3GNREtWwXoNDhE4rd9n",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1JdjFEeVyXFFw8n3JoUwDk45BgqmTVom1s",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1JrZWWWPHh8aW6MGPBzwGP71Qk1XW16iKf",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1JyLeGv4Qg9mimJcZFVrEWxJgpBwjQcWyH",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1K4C3vvnsoZSV711UWmzQgVUyL1h9WRzt2",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1KUWA92H6RsLCT7XAWsbL7mLrEBZQnxn44",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1Kksk8XpDgP7V4GYZc3qxpo1W7ZhPyXkep",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1KukjLJtJkruuv93pYADN3nkTqyjMys3E1",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1LqaMeudj3zsvvpt57vpY7DxLUsKgtwnk3",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1M3GUnBJbabtmmSy6UFdwq62MQKThurWb1",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1M6AP7SwFGnJF7AfYzvrt9xA2GXPcdjQmK",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1MAL6Gve3QEvCcDtP2tDLYDnrGjWENU9bF",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1MRFkubNb4syprcGb8DSEoLKGnXoVJP2jr",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1MYQXUMCQC87iAE4jAhFc1NUBk2mjDA1qs",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1MbkPuxSrQeTfs2ruumGJpmGJuJriUkyR4",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1MeSGqTstY5fY7CMssPi7GR4ZuboC2QKPK",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1NBodLa7B5BREwDvAwznVn7tTauRAS7VYu",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1NpatveqWPsPxFXQhrkQTA13uyGbmDapf1",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1P1afaY63jp3HkXKafGEvycJLdjkmuetMu",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1P6ZpfSTdSCMFd8YM6TcZfehoEw1gkjZES",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1PeJQUDcCZorAPQys78Gw6nj25iqkUGPpj",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1PeqdU1NyH6coorqvehkQtRxUzDQpddD2r",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1Ps2P1w9x2tsGkUcVEpqgKBesRJEiSS5pe",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1QAZYxrS1e6zP1dnUZv2s92Jf6WJ2c6915",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["1cZkNYmu7QLtpk8ReZnMbWbURU53jfPiP",{name:"Bitfinex",cat:"exchange",src:"cluster"}],
|
||
["bc1qazcm763858nkj2dj986etajv6wquslv8uxwczt",{name:"Bitfinex",cat:"exchange",src:"forensic"}],
|
||
["11527fVg2u2PHknTgUc1nNiYHyc9uThX7B",{name:"Bitso",cat:"exchange",src:"cluster"}],
|
||
["12L8CumUjj2VizphNeiMj14qLPqMzwYYi4",{name:"Bitso",cat:"exchange",src:"cluster"}],
|
||
["13evnY9iWZtrW7uq3twyzoo5iLXYtRbduP",{name:"Bitso",cat:"exchange",src:"cluster"}],
|
||
["151X29mMsWs8H9Js2pBj4hkAvwMaFF4wR4",{name:"Bitso",cat:"exchange",src:"cluster"}],
|
||
["171AB3bHvMCcdnPN8gQicdYKCgUXgu7rW5",{name:"Bitso",cat:"exchange",src:"cluster"}],
|
||
["17kgXTvjtdWEJTr5N25yFR4fmkStUYVXms",{name:"Bitso",cat:"exchange",src:"cluster"}],
|
||
["17qs9JKh9VtGQfQLoeWQYKZ4XuzVEq63R",{name:"Bitso",cat:"exchange",src:"cluster"}],
|
||
["17yk5J4ZVwHLzUmZu7wzMiCw3NZUZm7JCr",{name:"Bitso",cat:"exchange",src:"cluster"}],
|
||
["19GQYDJEK9inGs8hom6j7oJEzsNBqe98Ex",{name:"Bitso",cat:"exchange",src:"cluster"}],
|
||
["19zuufBYS2aekBZrK6Nh23PYovENNmGBpE",{name:"Bitso",cat:"exchange",src:"cluster"}],
|
||
["1AJAPBRWcduABiCEkvwhhWKo8cv5YbwQ1g",{name:"Bitso",cat:"exchange",src:"cluster"}],
|
||
["1CdLuS6huJQXPR8ev42dTi2WcNR3ps5QNq",{name:"Bitso",cat:"exchange",src:"cluster"}],
|
||
["1DDwrxz5MC98Pfrjp4PcQiErx7wz6FJnRJ",{name:"Bitso",cat:"exchange",src:"cluster"}],
|
||
["1DHb1MvC35K2zBnKcGKXPUtczazKCQHSx9",{name:"Bitso",cat:"exchange",src:"cluster"}],
|
||
["1Di9zmk2bE3tbb65gJzfxFD9EN8a4yRRE7",{name:"Bitso",cat:"exchange",src:"cluster"}],
|
||
["1EuHY7gPSqjoaSsudvow8FTiofVf1VtQhk",{name:"Bitso",cat:"exchange",src:"cluster"}],
|
||
["1FzUe4Pe37rD8dFWLKhbihGLGHJi1d1odv",{name:"Bitso",cat:"exchange",src:"cluster"}],
|
||
["1HMNvptGnucR8D7NBKqRXKBvX42zwjq32C",{name:"Bitso",cat:"exchange",src:"cluster"}],
|
||
["1LVgg3kTSpK3BUypbzDijvKy8a4Yu4UJWJ",{name:"Bitso",cat:"exchange",src:"cluster"}],
|
||
["1M2qdVNDuSGaajgktQXB3mroVLiPBgG1rc",{name:"Bitso",cat:"exchange",src:"cluster"}],
|
||
["12CByhtcmy3ckq4bs6B4BMjn8VNPbowenM",{name:"BleuTrade",cat:"exchange",src:"cluster"}],
|
||
["13bLpP83nLEHZxJfdJaMUapzVWznzyx3gR",{name:"BleuTrade",cat:"exchange",src:"cluster"}],
|
||
["17fFbwNdhJMJY3NVmjUEY6T8Z5awF7i3UQ",{name:"BleuTrade",cat:"exchange",src:"cluster"}],
|
||
["181tyRdHuPsSvXeTdjyRddBvhuZvURW4vy",{name:"BleuTrade",cat:"exchange",src:"cluster"}],
|
||
["18ieTfWsJqw78VHjJWamn4wt8Thb6uT2SX",{name:"BleuTrade",cat:"exchange",src:"cluster"}],
|
||
["1Mc6yWMqBDrbzdUgVoDX1D4ybJRUrWq8U",{name:"BleuTrade",cat:"exchange",src:"cluster"}],
|
||
["1Q9EVkxRhLjeQRW1rfRNueaYvzKDY3WJm",{name:"BleuTrade",cat:"exchange",src:"cluster"}],
|
||
["3FAEtHyBERwWUhijnaDjukcESm1kiWrQTL",{name:"BleuTrade",cat:"exchange",src:"cluster"}],
|
||
["3FPiAL3ZGaaW2SXfTvMTcrCjRMN1FN4pqa",{name:"BleuTrade",cat:"exchange",src:"cluster"}],
|
||
["3PUN3gNGVA4A5WArcACRZpmUDwornNeQrL",{name:"BleuTrade",cat:"exchange",src:"cluster"}],
|
||
["123xjrf3knrqCEWvdTfs8EZxKqDNafgWci",{name:"CaVirtEx",cat:"exchange",src:"cluster"}],
|
||
["13BR1SXbsqChZWeoHngNNvevtkMJ7nzsjy",{name:"CaVirtEx",cat:"exchange",src:"cluster"}],
|
||
["14MkjiiZ4SnN8bL1cJuRViAACyNsM6aUDz",{name:"CaVirtEx",cat:"exchange",src:"cluster"}],
|
||
["1ADjBN92r4stb8wsqNhr4Rbt4gi4P8TdNZ",{name:"CaVirtEx",cat:"exchange",src:"cluster"}],
|
||
["1AdVABFi77GTdPL6NBmT6LJSedwxXucB5N",{name:"CaVirtEx",cat:"exchange",src:"cluster"}],
|
||
["1KLq9XRKfFZV8iM2iCgSFQ7o9uTibXy1Yq",{name:"CaVirtEx",cat:"exchange",src:"cluster"}],
|
||
["1LLMSCG3yycYPKm5q13zBFpEGUm51Pcx1E",{name:"CaVirtEx",cat:"exchange",src:"cluster"}],
|
||
["1MQiyWm4tycqkBBATfnSPxsPvxPwmYUGcW",{name:"CaVirtEx",cat:"exchange",src:"cluster"}],
|
||
["1NjAPxynACKu2hz1eXWyeAd2nJbCYko9Gx",{name:"CaVirtEx",cat:"exchange",src:"cluster"}],
|
||
["1PjTMEST2xDzpn2PGAuThtzN1uXSBRimC2",{name:"CaVirtEx",cat:"exchange",src:"cluster"}],
|
||
["113SZQ4N7VL9okbWfTjNxMUoH24Ec95SzL",{name:"CoinJar",cat:"exchange",src:"cluster"}],
|
||
["133HUSC75QkwGWwMojhyfUf6sC84tNvfqm",{name:"CoinJar",cat:"exchange",src:"cluster"}],
|
||
["13674Yivhni7hctD1K7rqSVzMsvWLSKnj7",{name:"CoinJar",cat:"exchange",src:"cluster"}],
|
||
["14EK19jiBZP14AfDSgK6or6jxNw1WDDQSN",{name:"CoinJar",cat:"exchange",src:"cluster"}],
|
||
["14G3W1iMrWMtXMpc89czkEZBf8zuif57N9",{name:"CoinJar",cat:"exchange",src:"cluster"}],
|
||
["14fxUCq57j3yuBqUAscFN3bkXAKW93kFQA",{name:"CoinJar",cat:"exchange",src:"cluster"}],
|
||
["17Pw6Yq8FrouswmfYhWPWSPywjcYRmftHe",{name:"CoinJar",cat:"exchange",src:"cluster"}],
|
||
["1Ar71DrzLxDwbTSWCMyAYPY7rNV3bpsDAD",{name:"CoinJar",cat:"exchange",src:"cluster"}],
|
||
["1BewmHiMfYTFrwEuTEUqYjLRj6tam2w3e6",{name:"CoinJar",cat:"exchange",src:"cluster"}],
|
||
["1E5iM4SRuQraWaetjUpyRVkTQRZ8pUZxhg",{name:"CoinJar",cat:"exchange",src:"cluster"}],
|
||
["1EueKiYnkoPzdAdXNtPcGWa4w9TMMt7zZ5",{name:"CoinJar",cat:"exchange",src:"cluster"}],
|
||
["1GCSwX6ZSfGczSGhYyk6yo7ExAURp4CQvG",{name:"CoinJar",cat:"exchange",src:"cluster"}],
|
||
["1H8ateCTAubbTYwzDuTKsbXkLkgwu5tssq",{name:"CoinJar",cat:"exchange",src:"cluster"}],
|
||
["1McDpMvyztoPG7r154zjFaKQ26yGb8D9fH",{name:"CoinJar",cat:"exchange",src:"cluster"}],
|
||
["1NdgTJvVctXD9bwtEh1TrbLmRESod34frg",{name:"CoinJar",cat:"exchange",src:"cluster"}],
|
||
["1PC1XxBV3UYR3ymRXW12wKkk3u6Xm6ryV6",{name:"CoinJar",cat:"exchange",src:"cluster"}],
|
||
["1PDA7r2MuAbHAAq3zQwXxeLPCvBaB6mSY3",{name:"CoinJar",cat:"exchange",src:"cluster"}],
|
||
["1Q9YxVRVq8EWFUvDrZq8xoJUiaxCrKHLsB",{name:"CoinJar",cat:"exchange",src:"cluster"}],
|
||
["1QDVeUgAu2pokY9W2bodkFqDmbeT8mQ7Hk",{name:"CoinJar",cat:"exchange",src:"cluster"}],
|
||
["1exUP93whNTaLbMvre9uBLuTvee26MbMa",{name:"CoinJar",cat:"exchange",src:"cluster"}],
|
||
["124mkM5aVUZpdMazWVXX56DQd46hKzvhHV",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
|
||
["12YJ9uaWS4nWzBxRKSMSkY2T9CFvd8gN4M",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
|
||
["12qJQvP56NvjTPDj92bFt9bLYf9hvFXajt",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
|
||
["138cr34fNKubV19ezkXjWBhvwzv13ix4yv",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
|
||
["14LSJVAgNf8JMbvqXZ3DuknufkkAQ9Yg21",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
|
||
["14T7Xp29FhYMdNQYo2vT2RyVamLxdWYWK2",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
|
||
["15S5kQvhpGvcCbjK7j44JQUY84nJbNRysF",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
|
||
["15srHazeix36eme7nK2QKioeEV7xo8fCUV",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
|
||
["16rHJdsLwSVv3RKqeKupPo62b2w5dXbPv5",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
|
||
["17qxN7FEv8hXcwVbijEZSkm2owMqSYsAnC",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
|
||
["17roc8nRFzJ2KgvkjTAfAW4ouRYxryncBX",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
|
||
["18j1tSmbyojKTpSQp1o13w8DtVwheJfoMQ",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
|
||
["18jLrPyicWe4FVDEkWv2mUXgkBvZ84chx6",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
|
||
["19Tv2pVLTEfxaBb92iAS8ErMHkq7Mp1Ejy",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
|
||
["1DpeL4a14BwArkPcUzxPYk4bhP5pUTKuvM",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
|
||
["1EzK7T326C3eU3pi4MWFDzUptg143vV4K5",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
|
||
["1EzzqmUxGdmTsAoo5kupfE8KtiMqm9v6Cb",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
|
||
["1HLvc8yvFDDU6YKtWj7uSWV8tDd9Qw6KeD",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
|
||
["1MFZHu5KnVAdKjeNJ6JUJdk59YzYfUVsMr",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
|
||
["1MT6dBP3r9XeHMQYASRD3vNNpiLoHfHmgQ",{name:"Cryptsy",cat:"exchange",src:"cluster"}],
|
||
["16jce8CbnyF2dYkBzrXKEdeG2wGMwShkPq",{name:"Korbit",cat:"exchange",src:"cluster"}],
|
||
["16qaBxqomiNWFon94Ymd8NJ5zFGGFVGncZ",{name:"Korbit",cat:"exchange",src:"cluster"}],
|
||
["181acE6XdV4JToqMFRNmmKDqtir84Cmaxz",{name:"Korbit",cat:"exchange",src:"cluster"}],
|
||
["18rWxfA3Qv6uFKwKexGtskPxsC6BZFPKhb",{name:"Korbit",cat:"exchange",src:"cluster"}],
|
||
["19Ls2qFMEztRVgSYyFFtFReEbhGUsuHHdX",{name:"Korbit",cat:"exchange",src:"cluster"}],
|
||
["19iGtbDzXSASmcyJFbdgCiFikZMRpVaMWp",{name:"Korbit",cat:"exchange",src:"cluster"}],
|
||
["1AvGPjBB3PcdhLYwy7twKFrPwpwJ1iv9pF",{name:"Korbit",cat:"exchange",src:"cluster"}],
|
||
["1BNSSSwGATcVHAdmSXviYYoeHnps9PhGL",{name:"Korbit",cat:"exchange",src:"cluster"}],
|
||
["1BcuvsuTQ3torbthN4vYeQPhfdPY8KM2cY",{name:"Korbit",cat:"exchange",src:"cluster"}],
|
||
["1BsFwcxmUKpSNF3o4ZwSRPF4h3Z2s8vefB",{name:"Korbit",cat:"exchange",src:"cluster"}],
|
||
["1CsTzASjqs8f63pzcw5f9LJoi2i1HLwKjd",{name:"Korbit",cat:"exchange",src:"cluster"}],
|
||
["1En5ErLPzF9RMeP8z8hjna3SMjGbBXRnL",{name:"Korbit",cat:"exchange",src:"cluster"}],
|
||
["1GoxkdmiZzFKwndzGihHcW823uoxqc7NAh",{name:"Korbit",cat:"exchange",src:"cluster"}],
|
||
["1GrCCd4mSvxLqKFzs8Pbny9qBqL65LFKJp",{name:"Korbit",cat:"exchange",src:"cluster"}],
|
||
["1Gxd9c2VuLcQjee1tubhHSJShrG6TbyUcY",{name:"Korbit",cat:"exchange",src:"cluster"}],
|
||
["1JeyZBDbJTz5d1rfSkGqywzwX2AWySUByW",{name:"Korbit",cat:"exchange",src:"cluster"}],
|
||
["1KHFeyp2Sb4xXg1rjnNRi4c8yhb3XDSTSC",{name:"Korbit",cat:"exchange",src:"cluster"}],
|
||
["1KkraRR2rqMz3R9683EjYTASFbz5tmdySv",{name:"Korbit",cat:"exchange",src:"cluster"}],
|
||
["1Nh6sF7Bgxbeh6cEt8R8hRR4vuLWdcmeoB",{name:"Korbit",cat:"exchange",src:"cluster"}],
|
||
["1QJ13PRLkWBF4s1XGKUMr9AbmtnzuBH7QX",{name:"Korbit",cat:"exchange",src:"cluster"}],
|
||
["12FACbewf5Fy9nmeaLQtm6Ugo5WS8g2Hay",{name:"KuCoin",cat:"exchange",src:"forensic"}],
|
||
["1TYyommJW3uhjhcnHhUSuTQFqSBAxBDPV",{name:"KuCoin",cat:"exchange",src:"forensic"}],
|
||
["12Guz9u47GMsnqgLxo5yC5qbtbzAX7Hqxa",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["12NnaNga66HzQp2sBPRm3kkJvAeazJCKE9",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["12fVyemsxhrsnrF9VCJbRzPtMcVanJmP4i",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["13D41vrEarK4mpmZvwUoTpo4mcjrYEU33M",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["13KvKbpJ8VDZy9x39HmdnUcRDkw6FU451d",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["13T9y8u8prqvagFypK7DpnEcG7QHgkuPKX",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["13XEC8VBNvuYMyNx5wTtpwCGEL6DW9MQCv",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["13oTXxKtwoS3GjLXysTCy4EYq1pFuoLjhb",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["13va7xTm4HQ3NMQ7GVUPFfrdffUqy39Qsc",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["13xr66DhtxtcSVDdYRUoAFf4TQojaFLJjN",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["13yPMA67HaMa2TVboTdodp1BYw3BEMEdNs",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["14Ezos8TABLoJhzGoYxrrcM5QYCobbFwiG",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["15KZFKQLRxJ4AcMTnYns2r12HAVPs3nbyH",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["15XtoasEnkPnfdwKDoNRYKN84yhRXkt2Tj",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["15uctjhfCDhbjTTPZvUteum4DrHb8vcuVV",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["15x9a1paayi8izcPsCN6NJERjfTUZscjKb",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["16ZFK6PRAtL7hhMAyP6FBXrMzS9WWodord",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["16dhND4CK52imjrvnzwboWMFnU7rnSPcCa",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["17WSNnmmiJzk2DRQYi8hksnNj2JPzp2yFg",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["17tiCS48mCPjkzzdubqkWdDwwtJ9i5soPo",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["181a8dhM3MGTzLKBHASx3Pv6mcyNbkHNYM",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["18qfweby4AxnUHkbAJSfx2W92raKw3T9Y",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1942mhPQBodHHLUvQtz5Uk82RDHyzAqLNm",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["196p4UsNjZwq5wygMAswVhboyyfpjyrhq2",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["197oJMMiKkwa2SqejrbPHSNi3V4Yr9ehU5",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["19Bvv3YbhfndQN7814pSMcm41754tWNxNs",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["19NsF6oQMdFLNREHA49o3TY13TKQk7HaEM",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["19Ucf3o2QZi4zQToF2EHa7A5N1ua3gXpq3",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["19vtVvVSbLzKTsrndadsgJcyq3RveWPG5E",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["19wHwMxumg4bQULF5mpr7jFbiozD9RUMRN",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["19wtRYkewmxsQJp4ntgfihtfmf3LUc2DqD",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1AANfZpPg3JDB8QxXJvUjaGuRUnC7hR3ML",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1AX2WGCkGEkCWzP3hBNwBhDQ6bHY3EGNH6",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1ApQfqYSypGUtDiizjEk1ZeKXnQ6E9LK7",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1B3EuSu7mTgLXeLbZ6SmU5cmr2rMLjtf6C",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1BAVHJkJMjnzHMPTgRQP5wpJyWDYVzPMdi",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1BDDrZSXhsZVPhV2JyjpugTTNgcNe3Db3c",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1BXZifc9csT8pxWH5D3TY4wxbQLir64cgo",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1BcRfca3QkPADPnizVJPUZXaaEjVTCucaQ",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1BejMo6TbAM1WeMDxPLmTmx1hdrKtZhYEe",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1BfzPRSPTd775Vt9KbtHpqXbxJFEd5fQkz",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1Bo565wEcMjuMab9mVYRfy41hv2vF3is2B",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1BoY1JjPWyy9PuFcQM3KFf9DazGxZZBuqA",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1BwRgLi1e5Yv3fpL9VZmzr27wMeey7Pc2o",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1C23yq1MkiEPTtK36v36FpySrn5yaPYNPd",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1C4T9u55LhE15ikNoN6XKwmfiqLWaBN6Pw",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1CtuQxUcMFzdsMQf6et4fjr9vcRtaaYd62",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1DAe6wXTzwzmTUNBJsnsN55KELvWoF49K2",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1DYBKHzEbw3TkbFvjZ8TerHh6R5ZuzUtxQ",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1E6SFFemL3bZYA9GuKEPhQ8mk4JSFcYPFf",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1EJ1QtVdLuoJyJxi2b7YEfup7fryuxvkGF",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1Ei8PNkWU135hqinpqCRP9ML5njHGAz8Hd",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1Eq6aeU65NgmuxZ88NPw4pMW3R8omFzsjv",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1Eq7JKdb1sx6aSLMSS4oC1SjcGmE327Jca",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1F3rKLPp75MprgtvMt9jxQELQUKfJMj2iU",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1FB1i2T72o4fxA395ZqtiMyqjanMKcdjM9",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1FcyiRqAQqporVRJ6u3ktZTNXHtcUqfqv5",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1FmFqnP5uQvkSPcRm1VY7Gsjp95wRYknBR",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1GC3JQNm6viNQESfCXoFWaXkMj4rG8FPHX",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1GHxHr2Vn7BYwzwFQY3yeuGCYDo4vnStAU",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1GZfbm1F9yPc6VfnN36JD4yHgA7s2VXc82",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1GcTvkZuxdgJgYrUVFQmNh56snzhixyUaS",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1GkYvZQmTrSFBohvvPtU1m4oLumzeC9ee7",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1GuYn3UacyeMbPxTyGem11mosNPcw6PWfW",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1HB4Z7Gk6yj3jd5vHmbeQ4h4REKdYcUhUX",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1HQLxtACiyR61ecg8DQNEL2TjVhExsAtbG",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1HRTMdx6QvMaL1BEkgYyCsvgpkbqg2xxy3",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1Hc1BRCjEyjJUYi19e3ZAn85utgpKTEbFz",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1JARMeEWXEBReVRvC13syVm12qb8L6t1Lf",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1JNgdL8WJkDnqkxKoqiJPXWGt7yaqoqaUW",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1JRN8SH1LuWnY8fgKdPupMKFgRxRtL4MTJ",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1JWd94gmBFnBoq5Fe56Ynk9ioJ5HkCac4P",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1JZKDNnv6FS3sVg57tW7XwhUthtaMYjTRJ",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1Ja8xD3Zqps9KrTGZK9GQTmc15UfpKs6Dc",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1JdXuWHRrVPTRkesF7mwBBGoHiBfvH2aiZ",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1JjUZRP3WH3opLsu9WgPDZcHLHmLbVXsoc",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1Jttrcn1nT36TWq5eWTnB94j8uwxEoPSs7",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1Ke79m6KCAt9iKHi7zzqEL6PWhmjpqWZJH",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1KeRJKd46RcZFBnqbPnxJcdo7bFUzTqoEP",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1KoNGKbDWLiA7a5YjzKi7YisqWiCZvDkMo",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1L55msvNmn3tW5WCbyzZ7CnXDhRHzgkLYq",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1LHrPzjMhCxgn49A9EDbkQEie5oqzZf68f",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1LMiov4uiHAB6zLqAX5ShQt4bpi3wBzqmM",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1LN5yqUanEeZqW8uraxc6iPQyKS8NHCodM",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1M6T2HkVfKRr6GCAseXjzUA2Z4DxN3E4T7",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1MDoQuG8umWPMBYfk5huzCTEdgT1Zx1AaJ",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1MV1w1bfTm2MStWuwfHcu3DfLczcUyypXc",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1MsuyRk9NfPFuL3aJUQziKScMkZgkLLHno",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1N4JM9J62zoiHtsyBGvfPt2t8VYSQwZfnb",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1N6TZMW3tb6Ht9mpmB8w3mcdUtzhPFQVGM",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1NUTCdeMuDDDPHYgVjtAnVCoimCp2xFSpF",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1NZ8qjspVPcPjHe2Y649QXWfFXFfo8jDt4",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1NhyQRhCavnGjgNrWBSbEjyDkjNh7tzESo",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1NjncXQibyR5Ch5Nb9NvdEZiXGTut5pVsf",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1Npr57NAZ1BL96t5fawtHJsw6JJzTY9EiN",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1NsEpn3kcpRTPB1owtMnG2VG1kHVRMyCRw",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1PRbfZ4bnnHNxvaXpPCqd2chANpmXe4Wti",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1PkVARXSgjKKUcjBaYB8dcSMULejnrR2ds",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1PkfxMRkk1Xvyx7Ca4Rg1DvradL7kQ9c3c",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["1PmorchZH8xxKBtN8Ftyio2uiRQXtNU7iD",{name:"OKCoin",cat:"exchange",src:"cluster"}],
|
||
["16rF2zwSJ9goQ9fZfYoti5LsUqqegb5RnA",{name:"OKX",cat:"exchange",src:"richlist"}],
|
||
["1CY7fykRLWXeSbKB885Kr4KjQxmDdvW923",{name:"OKX",cat:"exchange",src:"richlist"}],
|
||
["1DcT5Wij5tfb3oVViF8mA8p4WrG98ahZPT",{name:"OKX",cat:"exchange",src:"richlist"}],
|
||
["1LnoZawVFFQihU8d8ntxLMpYheZUfyeVAK",{name:"OKX",cat:"exchange",src:"richlist"}],
|
||
["39wVd42giU95ca39sEPkbPTpWygvsBDuA5",{name:"OKX",cat:"exchange",src:"richlist"}],
|
||
["3E5EPMGRL5PC6YDCLcHLVu9ayC3DysMpau",{name:"OKX",cat:"exchange",src:"richlist"}],
|
||
["3FM9vDYsN2iuMPKWjAcqgyahdwdrUxhbJ3",{name:"OKX",cat:"exchange",src:"richlist"}],
|
||
["3MgEAFWu1HKSnZ5ZsC8qf61ZW18xrP5pgd",{name:"OKX",cat:"exchange",src:"richlist"}],
|
||
["1367BSnpQZ8JagnDvSURQdooUdZApsEYgL",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["17LCpUaEaMYpRfYjHGaPcgn1tp7bsErR3d",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["1A15zRXCNcrqw1j5vZV9EG1wnsg4TBD615",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["1A8jWLjUF87oANgDre2ES6bpuARipm4q1W",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["1KQjhxZx6nAzfQ4txKrPcwkT21QBvAuMwz",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["1L4KCg6Q7gVRPwK2SgtdVrYbuTFRvWos2J",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["1Nu2K2ZWdGYWaTyTAbXLgBNWGRcyN3pY7T",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["1PQjqn9kJDTsMXQbcDYoVyQjDmKP5yGpqD",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["1Q2j3K9XTcuLtW77ujdD17qQ3ZVo1aJUHk",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["1Q3coeqkKSir24Sc5yHcWA7vWGYKi2WjLy",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["1ZoHQyPGHQdSLx99eyY6ahfsyUzEXecTt",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["1mLDuepK2WRSGHbnrdyd8mfYCRRGbqggX",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["31pwBGMkFk49vdUizoetEmVNzRVNdRMsx8",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["32CCzqFbnVGwy3f1edgotoX43QUHLDRAM8",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["32GuLFS3fiZ2WMPwpZKizoxPcbjjFZCeVh",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["32JARmUYMSxGLsEXRsY29diuuUmArfpApJ",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["32QSsYmqJejtRo8hQnAEy2cmPYhLVygKKe",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["33vBrEMZzAJixeTLPhx9JLB4Yj3wWANq9j",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3458VUxz8rqDHTbhftwt6fCPTRy2yHR3jN",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["34LJNiftNWZ6J1eZ5uRNjWxcxYTRnrbwV3",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["354BfFxLizqEPsSs1hu2opy2gnoR3PRqJH",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["354mTDg5ewFxfSAwNST1viYpt4rgp3qWcp",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["355KcSF5ABCfPu6kS278WtdiShaQTom1TS",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["35BqU1LtPYJUn9P1skm4CbWGYYxwXDXDax",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["36WVLkdtAUNc3kHV8wHmkhm3R5F9hvRf9t",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["36YNuumHfeNEihLwzemfgEMPX171fUSvCs",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["36uec6cXpKPx6akZ2nxsNyThdFLXvF8ERa",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["36zHGPpHLcXd34UWmUy4vBDPJ2GwMuNwoh",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["371q3Gq3GPEWtjL8HaknWJuwuDocJAPdAC",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["37PnurPK2cYouaD8n4XnxzkEaX8DDPSFuN",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["37VdUdEAQJAbxQ1fzRb7ojphQ6M29JbTsC",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["37fu9rmxXzoPv15bTvkmzd3vGA793VXpE5",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["37hgFVUVTNg3HxRtRVtdG6npGuLQn1afDb",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["37npTQHVEqCB1d5eZ2sUt4LPCEHdA7gnMi",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["37sHGDicfW4PdYJsnUrxidT4sxX8X75wqZ",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["38Ef6hftZ4YfAQ6DU7RP6eASsmfc32DdJo",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["38hP7pnCJjYxbsAV14xY5sL7LLDzWxtEKC",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["39hRBNmJ4MLbxNMbv7xoUXequoHWLyynyr",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3AMVL2Ht1pz1SqhtoAhNkbhJ1JRLcbWhbq",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3AVJUPtHAbdwdiKA4MaY1uLqcru13aho8S",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3AjdPhtQbucj6zSUDwCRvkcnGLBonGdQ9o",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3B7FQwyX5jS1TZQNf77PpKKQ47eJTSpTer",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3BJMMZ4NVpEEtK5ekTcnK6oBK2Q2K9gn3L",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3BQrkCaCEfdy6JoBTUrdWfGwUcZAqDbAYQ",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3BS9wPNzbhtjbfQvqNt7Zn4BXPhXevywiL",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3BYVBLAP1yZdihGHqWUagjoitgkMfXLzE4",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3BssZgYUGwnFDhY8EyFBtQoiyfn6VDGWnc",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3BtmwbfVHF2piUGwJxFHymKJpfd1ZNWqRu",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3Bu1F7dUCmezVrTgnaCN6Q6vyXS4DV37cC",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3C9zZxd5GX4tSrHZMSrBFTzeQHF9zuWMcf",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3CNGGCSuc6KFESsphMYbPdR6dNqGXGnYv2",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3D3gQz98dYq4auarRkxFoJZYQevwgcwRUQ",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3D5UfA6TJ68owWkhGnvfwkNdEDxaKaNS6r",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3DDeaeSF4ukxs2y6wsppc9zZst9JEdy1r2",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3DgLSrAXeY7N8AJ2RKjXqNTpebvSjzLzUQ",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3DqrDxbXLY8KuQyoLtqWw9GHY9Kj9X1piV",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3DvFp1inWiDPeS4NUfnXJgjuCESa5YUvof",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3DvRGkcoUFLWDJLtJugAR5DFLBfKN2eQJz",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3EDTFJo38AV4N6zM8QpS7yPHNw6LW3Qfou",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3EYyfuqH7zMPUJYymbNsLjYBG2pxGjgjDe",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3EiS4CoWnZ3uNXSnGmBBBe6BWveDp94FMK",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3Er2qGdoQF1C79uJAco8mmGb5g9n6UABxi",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3ErANKbsaAxbC7LSW4LqYXKM1sfQhEfzjc",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3EyV7SdKWiXT81bZEdVqM1xrq7e6me9pY8",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3FCYnrE5cEV5zgEtNu9cefK1foihTHt8dK",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3FDNB63njLW9UmJNYghENzwmAADpJ9g4er",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3FXTykUWe6apKuptKBxxLLLYfUgPy7jgF6",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3FgkBvucMBUM29JgLZxRFKM6i3Sm3qeuSe",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3GiZi7g9ywnrbBhojFuxUTanJFXCtLbzAs",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3GksksGsvr8JweZaZoK8mu6RgeiK3CZK2G",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3Gv53ro1QrSxRo2Bj3RdLcKk5ZLzGS64zr",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3HPF1x3g34oeiakfmrUb8Ej6mbWSj8CpZc",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3HQJxVMhEQd5vzngtzwGRccniNqBMeHQrd",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3HxnXPhArTodRDbUrV2BxfHza24ERzxGjc",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3K4dySg4x1Vup4YtSyaPynKvTBP2zWU66y",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3KD2BzXJoxwni7DtfycHSSHT9T8KGJdUfD",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3KqzFurDsi8Y5eCMLUesAr1z7PgNKX2GqD",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3KsGBGFmyQi8k7cKZeqLg7Nxtabb7kEkxK",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3KzEtNM5gmStHyFG4nxomv9HyLjzPnBh5R",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3LDft6PB3qvhqWG3JeCxPBR8bmCLau2Juw",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3LDgjfPzyhyDeFXWwEfMF4Esbjf7qe2ACq",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3LKb9K4qR7suZL5PQj2bBtaQeERpqv9sa5",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3LRrNUHUd7fURdnG1wVNtsPzd7w8v2qM8f",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3LgiJD4BzCaFBsw7hATTZWBByD9cQSQZxQ",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3MDy74qRm26WoZCde7BT6brnM74jvo5ga5",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3MYg111R7ScsUtMMR13FsVsXyP4hshVsd1",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3MzM6wZKNvV7Q5EH4r4tdVc3LVz57CU6GG",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3NMD6WsSXbuxneAvjDx2PQffP4L4XntHe7",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3NgF4YGX3nh1AK36Ewwjfh3mWy3vNbZ3k6",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3NrvF3oR6sJvwzBSyxzjuVqoh4T4EQ2oAV",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3Ns4RTpU8cjQbinxA9LqzjD1u2sgcBCNFm",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3PDYQWnHNxGwcti3AtTtgCjB42B59zYJeT",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3PE5HxqTaeovir34G2xxbHH9RdPLV3gArt",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3Q2Jk5YXqTYqmV4VcHcaHmKBvf6vzBzTse",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3Q7yzzqsxtHW2hsugkpxUD72xnpYoUVrF2",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3Q9TSBxRMRgruzmEA711gqz4MPFeZGmnCY",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3QHNZFQBnQDCzUDx7D1N9WdtSNRQht6Dqy",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3QJh4ZnSPBMgRFcADbxQoaDMByDAYRU8mF",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3QQY2jDUjRFCXSRnWydPLH5GRfniqmmbbj",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["3QuBuAYS5ferkySb1JE3VWM4ncWVpWfcyn",{name:"Xapo Bank",cat:"exchange",src:"cluster"}],
|
||
["134SMxxas6b5Ng1h9nr3cS8P8K4Pk5hKzt",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
|
||
["13qBs6J5HMx2atwtX9dCKyeKHJ7p8SCVCv",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
|
||
["13sUoM8NzksNcqvTgEhdSqxgKatfQJ3SwP",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
|
||
["14p92hXmwY9ZoHrYRTZzhDWn5gJmskaVgj",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
|
||
["161GstYqTw8vNDmvJU1t1o2nKKzJdZzwfa",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
|
||
["19Wn2EcdZRpnUZd7oVs11PH4xMoEcnHfvu",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
|
||
["1ANRDDtmMRzUDYMXS64gdp6jwkNH14CEeZ",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
|
||
["1B9EuqZHut27bhs8buANR48t1Hnk8J7JUW",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
|
||
["1DCfmNNf5XmsjCHd9vHrcaDpcz8KeNTfJw",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
|
||
["1ESEG1pyd9uSwFvgCLwPyMKMT5mxuXyJUd",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
|
||
["1FijphUT1ahUfna6DFSgA26NJyEECAt289",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
|
||
["1GBSXv1e2sNbeQo5BwF4BipWF5DqsumBs5",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
|
||
["1HSvJSwo2AVJPzhArnZdY51Fs3ntoLR2Vh",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
|
||
["1HTPzb9rVRh1m5oZkM6eRENfRCFU3PnoRw",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
|
||
["1HuNzKAAwnV8Gp2RYFxjrDPoegPiuh6gCM",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
|
||
["1JrcSPyvJ87rR4WRn2CUHvmNt86tCe3zdv",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
|
||
["1KEQVbjCaCeMUenT1LkxgiGjhcfHjpSsQT",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
|
||
["1KKPhEQVz8e6EBk69LJu4jzczeqr6szLjY",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
|
||
["1MHWB6gukQCHqbwC5bH5oYJDkc535ppjvo",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
|
||
["1NxZJ9kxguQZQCnqzeDeEGP67VL5S8hG8A",{name:"CoinSpot",cat:"exchange",src:"cluster"}],
|
||
["13RbmLNJ2WpY1RhR6eMqM5kSmzPWJfeQmw",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
|
||
["142zj8SxAwHGwug4qCsAEGppiv6GCb1UeA",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
|
||
["183w3twnpsgN7fNv3wn6LRotJoiUykeEiM",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
|
||
["1DJiUcUW4yJYLFXkbsQPWsCM8xpA81NBSr",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
|
||
["1EwZ4s5BNKBydhEJ6NjwiFD3pqUEL2jrct",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
|
||
["1FYKSZWHV5yKeuc6jNEFv9YkmN1Nzw2Mqb",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
|
||
["1GQykdcGxvyQ7ZhpCTSNzbyLuDkExSS3Vq",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
|
||
["1HAYB6Sa6nJkB1bgGcAxVHVMnWLuGx5egi",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
|
||
["1JNKY7k93gRPxZqCrd4cAqp6oZDuvQTfPw",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
|
||
["1LE4JTHqzg7ryS6xzpKWW3TqABooGmkBYi",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
|
||
["1Nx5iKXFdKGXN8yJ9P4kzvgLRVenCayvYc",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
|
||
["1PETUYP2npdqpDxsEQwhvtDBc9kn7H8GLT",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
|
||
["1nhYMfNHSAsCXxD3KnVeAgtC4Y5Sww2hn",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
|
||
["32DzaZ8wTFZAMvcwa4DPSWRRsz7jb6CASd",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
|
||
["32idg4g9VNA9BV6cTqeDKCSRDhnCwxcUfP",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
|
||
["34E6etAYfr3SsHxDTaUFU9112CRtgzd11X",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
|
||
["35qUCmr3N8gzcP6T8DJb5TRFTsX3GLYdiC",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
|
||
["3AzxPu3bw8ZXEqNB9xkHMKkTFq8Ckmpn64",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
|
||
["3HbBH2i7sHsi687Z8pS1S137mVbjgMFpQ3",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
|
||
["3Nxa9vDXRg4NQFaToUREsLrp5sd4JamhJK",{name:"Mercado Bitcoin",cat:"exchange",src:"cluster"}],
|
||
]);
|
||
|
||
// ── Wallet fingerprinting — señales combinadas ──────────────────────
|
||
function detectWallets(tx, isBip69Shared) {
|
||
const results = [];
|
||
|
||
const locktime = tx.locktime || 0;
|
||
const allInputs = tx.vin || [];
|
||
const allOutputs = tx.vout || [];
|
||
const inTypes = allInputs.map(v=>v.prevout?.scriptpubkey_type).filter(Boolean);
|
||
|
||
// Usar BIP69 precalculado si está disponible, si no calcularlo aquí
|
||
const isBip69 = isBip69Shared !== undefined ? isBip69Shared : (() => {
|
||
const bi = allInputs.length < 2 || allInputs.every((v,i,a) => {
|
||
if (i===0) return true;
|
||
const pidA = a[i-1].txid||"", pidB = v.txid||"";
|
||
return pidA !== pidB ? pidA < pidB : (a[i-1].vout||0) <= (v.vout||0);
|
||
});
|
||
const bo = allOutputs.length < 2 || allOutputs.every((v,i,a) => {
|
||
if (i===0) return true;
|
||
if (a[i-1].value !== v.value) return a[i-1].value < v.value;
|
||
return (a[i-1].scriptpubkey||"") <= (v.scriptpubkey||"");
|
||
});
|
||
return bi && bo;
|
||
})();
|
||
|
||
// RBF explícito (sequence = 0xFFFFFFFD)
|
||
const hasExplicitRbf = allInputs.some(v=>v.sequence===0xfffffffd);
|
||
// RBF opt-in (sequence < 0xFFFFFFFE)
|
||
const hasRbfOptin = allInputs.some(v=>typeof v.sequence==="number" && v.sequence < 0xfffffffe);
|
||
// Locktime en altura de bloque reciente (> 500000 = bloque, no timestamp)
|
||
const locktimeIsBlock = locktime > 500000 && locktime < 2000000;
|
||
// Todos inputs P2WPKH
|
||
const allP2wpkh = inTypes.length>0 && inTypes.every(t=>t==="v0_p2wpkh"||t==="p2wpkh");
|
||
// Todos inputs P2TR
|
||
const allP2tr = inTypes.length>0 && inTypes.every(t=>t==="v1_p2tr"||t==="p2tr");
|
||
|
||
// ─── Bitcoin Core ──────────────────────────────────────────────
|
||
// locktime=altura actual + RBF (activo por defecto desde v0.12)
|
||
{
|
||
let score = 0, sigs = [];
|
||
if (locktimeIsBlock) { score+=3; sigs.push("locktime=bloque actual"); }
|
||
if (hasExplicitRbf) { score+=3; sigs.push("RBF sequence 0xFFFFFFFD"); }
|
||
if (hasRbfOptin && !hasExplicitRbf) { score+=2; sigs.push("RBF opt-in"); }
|
||
if (isBip69) { score+=1; sigs.push("BIP69 ordering"); }
|
||
if (score >= 4) results.push({ name:"Bitcoin Core", score, confidence: score>=5?"PROBABLE":"POSIBLE", signals:sigs });
|
||
}
|
||
|
||
// ─── Sparrow ───────────────────────────────────────────────────
|
||
// BIP69 + locktime=bloque + RBF + inputs modernos uniformes
|
||
{
|
||
let score = 0, sigs = [];
|
||
if (isBip69) { score+=3; sigs.push("BIP69 ordering"); }
|
||
if (locktimeIsBlock) { score+=2; sigs.push("locktime=bloque actual"); }
|
||
if (hasRbfOptin) { score+=1; sigs.push("RBF opt-in"); }
|
||
if (allP2wpkh || allP2tr) { score+=2; sigs.push("inputs tipo uniforme moderno"); }
|
||
if (score >= 6) results.push({ name:"Sparrow", score, confidence: score>=7?"PROBABLE":"POSIBLE", signals:sigs });
|
||
}
|
||
|
||
// ─── Electrum ──────────────────────────────────────────────────
|
||
// Guardia: Electrum usa P2WPKH por defecto. Si no hay inputs P2WPKH,
|
||
// no tiene sentido puntuar Electrum (evita falsos positivos en P2TR/P2PKH).
|
||
if (allP2wpkh) {
|
||
let score = 2, sigs = ["inputs P2WPKH uniformes"]; // incluido por la guardia
|
||
if (locktime === 0) { score+=1; sigs.push("locktime=0"); }
|
||
if (!hasRbfOptin) { score+=1; sigs.push("sin RBF"); }
|
||
if (allInputs.every(v=>v.sequence===0xffffffff)) { score+=2; sigs.push("sequence=0xFFFFFFFF"); }
|
||
const allOutSameTypeAsIn = allOutputs.length > 0 &&
|
||
allOutputs.every(o => o.scriptpubkey_type === inTypes[0]);
|
||
if (allOutSameTypeAsIn) { score+=1; sigs.push("outputs del mismo tipo que inputs"); }
|
||
if (allOutputs.length === 2) { score+=1; sigs.push("2 outputs"); }
|
||
if (score >= 5) results.push({ name:"Electrum", score, confidence: score>=6?"PROBABLE":"POSIBLE", signals:sigs });
|
||
}
|
||
|
||
// ─── BlueWallet / mobile ───────────────────────────────────────
|
||
// Corregido: eliminado doble-conteo de "1 input P2WPKH" (antes sumaba 4 pts solo).
|
||
// Umbral subido a 6 para no dispararse con cualquier tx simple genérica.
|
||
{
|
||
let score = 0, sigs = [];
|
||
if (allInputs.length === 1) { score+=2; sigs.push("1 solo input"); }
|
||
if (allInputs.length === 2) { score+=1; sigs.push("2 inputs"); }
|
||
if (allP2wpkh) { score+=1; sigs.push("inputs P2WPKH"); }
|
||
if (allOutputs.length === 2) { score+=1; sigs.push("2 outputs"); }
|
||
if (locktime === 0) { score+=1; sigs.push("locktime=0"); }
|
||
if (!hasRbfOptin) { score+=1; sigs.push("sin RBF"); }
|
||
if (allInputs.every(v=>v.sequence===0xffffffff)) { score+=1; sigs.push("sequence=0xFFFFFFFF"); }
|
||
if (score >= 6) results.push({ name:"BlueWallet/Mobile", score, confidence:"POSIBLE", signals:sigs });
|
||
}
|
||
|
||
// ─── Taproot nativo ────────────────────────────────────────────
|
||
// Fallback cuando hay inputs P2TR y Sparrow no se ha detectado ya.
|
||
// "Taproot nativo" no es un wallet concreto — es el tipo de script.
|
||
if (allP2tr && allInputs.length > 0 && !results.find(r=>r.name==="Sparrow")) {
|
||
results.push({
|
||
name:"Wallet Taproot nativo",
|
||
score: 3,
|
||
confidence:"POSIBLE",
|
||
signals:["todos los inputs son P2TR — compatible con Sparrow, Zeus, Phoenix, Bitkey u otros wallets modernos con Taproot"]
|
||
});
|
||
}
|
||
|
||
// ─── Post-proceso: resolver ambigüedades por señales solapadas ──
|
||
// Cuando un wallet más específico ya explica las señales con PROBABLE,
|
||
// el wallet genérico que comparte esas mismas señales como POSIBLE es ruido.
|
||
|
||
// Sparrow PROBABLE → Bitcoin Core (cualquier confianza) es redundante:
|
||
// las señales de Core (locktime=bloque + RBF) son subconjunto estricto de Sparrow.
|
||
// Sparrow añade BIP69 que Core no requiere — si Sparrow encaja mejor, Core sobra.
|
||
const sparrowProbable = results.find(r => r.name === "Sparrow" && r.confidence === "PROBABLE");
|
||
if (sparrowProbable) {
|
||
const idx = results.findIndex(r => r.name === "Bitcoin Core");
|
||
if (idx !== -1) results.splice(idx, 1);
|
||
}
|
||
|
||
// Electrum PROBABLE → BlueWallet POSIBLE es redundante
|
||
// (las señales de BlueWallet son un subconjunto de las de Electrum)
|
||
const electrumProbable = results.find(r => r.name === "Electrum" && r.confidence === "PROBABLE");
|
||
if (electrumProbable) {
|
||
const idx = results.findIndex(r => r.name === "BlueWallet/Mobile");
|
||
if (idx !== -1) results.splice(idx, 1);
|
||
}
|
||
|
||
return results;
|
||
}
|
||
|
||
function analyzeTx(tx) {
|
||
const checks = [];
|
||
const weights = {
|
||
input_reuse: 25, input_type_mixing: 14, output_type_mismatch: 8,
|
||
round_numbers: 10, rbf: 5, peeling: 8,
|
||
unnecessary_input: 12, dust: 8, change_detection: 10,
|
||
wallet_fingerprint: 6, batch_payment: 45, entity_ofac: 20, op_return: 30, legacy_type: 15,
|
||
};
|
||
let deductions = 0;
|
||
|
||
const totalIn = tx.vin.reduce((s,v)=>s+(v.prevout?v.prevout.value:0),0);
|
||
const totalOut = tx.vout.reduce((s,v)=>s+v.value,0);
|
||
const fee = tx.fee || (totalIn > totalOut ? totalIn - totalOut : 0);
|
||
const outputValues = tx.vout.map(v=>v.value);
|
||
// Outputs gastables: excluimos OP_RETURN (no son monedas, son datos).
|
||
// Whirlpool puede llevar un OP_RETURN además de las salidas mezcladas,
|
||
// así que la detección de CoinJoin se hace sobre los gastables.
|
||
const isOpReturn = (v) => v.scriptpubkey_type === "op_return" || v.scriptpubkey?.startsWith("6a");
|
||
const spendableOuts = tx.vout.filter(v => !isOpReturn(v));
|
||
const spendableValues = spendableOuts.map(v => v.value);
|
||
|
||
// ── BIP69 calculado una vez — compartido con detectWallets ────────
|
||
const isBip69Inputs = tx.vin.length < 2 || tx.vin.every((v,i,a) => {
|
||
if (i===0) return true;
|
||
const pidA = a[i-1].txid||"", pidB = v.txid||"";
|
||
return pidA !== pidB ? pidA < pidB : (a[i-1].vout||0) <= (v.vout||0);
|
||
});
|
||
const isBip69Outputs = tx.vout.length < 2 || tx.vout.every((v,i,a) => {
|
||
if (i===0) return true;
|
||
if (a[i-1].value !== v.value) return a[i-1].value < v.value;
|
||
return (a[i-1].scriptpubkey||"") <= (v.scriptpubkey||"");
|
||
});
|
||
const isBip69 = isBip69Inputs && isBip69Outputs;
|
||
|
||
// ── Fee estimador dinámico por tipo de input ──────────────────────
|
||
const estimateFeeForInput = (type) => {
|
||
if (!type) return 300;
|
||
if (type.includes("p2tr") || type.includes("v1")) return 58; // Taproot key-path
|
||
if (type.includes("p2wpkh") || type.includes("v0_p2wpkh")) return 68; // SegWit native
|
||
if (type.includes("p2wsh") || type.includes("v0_p2wsh")) return 120; // SegWit script
|
||
if (type.includes("p2sh")) return 180; // P2SH wrapped
|
||
return 300; // Legacy P2PKH / desconocido
|
||
};
|
||
|
||
// ── CoinJoin — detección multi-variante ───────────────────────────
|
||
// Trabajamos sobre outputs gastables (sin OP_RETURN).
|
||
const valueCounts = spendableValues.reduce((m,v)=>{m.set(v,(m.get(v)||0)+1);return m;}, new Map());
|
||
const maxEqual = valueCounts.size > 0 ? Math.max(...valueCounts.values()) : 0;
|
||
const whirlpoolDenoms = [100000, 1000000, 5000000, 50000000];
|
||
// Whirlpool: las salidas mezcladas son todas de la misma denominación fija.
|
||
// Whirlpool descuenta una comisión, así que se tolera un margen estrecho (~2%)
|
||
// HACIA la denominación — no un cajón de salidas pequeñas arbitrarias.
|
||
const WP_TOL = 0.02;
|
||
const whirlpoolDenom = whirlpoolDenoms.find(d => {
|
||
const atDenom = spendableValues.filter(v => Math.abs(v - d) <= d * WP_TOL).length;
|
||
// Al menos 5 salidas en la denominación Y mayoría clara de los gastables
|
||
return atDenom >= 5 && atDenom >= Math.ceil(spendableOuts.length * 0.6);
|
||
});
|
||
const isWhirlpool = tx.vin.length >= 5 && spendableOuts.length >= 5 && !!whirlpoolDenom;
|
||
// CoinJoin genérico: ≥50% outputs gastables de igual valor (sin denominación Whirlpool reconocida).
|
||
// Filtro de denominación mínima: el valor repetido debe ser ≥10.000 sats.
|
||
// Mezclar polvo (p.ej. 500 sats) no tiene sentido económico — no es un CoinJoin real.
|
||
const CJ_MIN_DENOM = 10000;
|
||
const equalValue = [...valueCounts.entries()].find(([v,c]) => c === maxEqual)?.[0] ?? 0;
|
||
const isGenericCJ = tx.vin.length >= 5 && spendableOuts.length >= 5
|
||
&& maxEqual >= Math.floor(spendableOuts.length * 0.5)
|
||
&& equalValue >= CJ_MIN_DENOM;
|
||
// CoinJoin por estructura de mezcla (WabiSabi / JoinMarket outputs variables).
|
||
// WabiSabi no usa denominaciones fijas — sus outputs son de valor variable,
|
||
// así que isGenericCJ no lo captura. Pero sí tiene firma estructural clara:
|
||
// muchos inputs Y muchos outputs, ratio cercano a 1 (no es batch ni consolidación),
|
||
// y sin un output dominante que concentre el valor (no hay "cambio" obvio).
|
||
const CJ_STRUCT_MIN = 10;
|
||
const inputOutputRatio = spendableOuts.length > 0 ? tx.vin.length / spendableOuts.length : 999;
|
||
const maxOutValue = spendableOuts.length > 0 ? Math.max(...spendableValues) : 0;
|
||
const totalOutValue = spendableValues.reduce((s,v) => s+v, 0);
|
||
const dominantOutputShare = totalOutValue > 0 ? maxOutValue / totalOutValue : 1;
|
||
const isStructuralCJ = !isWhirlpool && !isGenericCJ
|
||
&& tx.vin.length >= CJ_STRUCT_MIN && spendableOuts.length >= CJ_STRUCT_MIN
|
||
&& inputOutputRatio >= 0.5 && inputOutputRatio <= 2.0
|
||
&& dominantOutputShare < 0.3;
|
||
const likelyCJ = isWhirlpool || isGenericCJ || isStructuralCJ;
|
||
const cjVariant = isWhirlpool ? "Whirlpool" : isStructuralCJ ? "CoinJoin (mezcla estructural)" : "CoinJoin genérico";
|
||
|
||
// ── PayJoin — señal débil informativa ────────────────────────────
|
||
// Si hay exactamente 1 input "extra" que no era necesario y no es CoinJoin
|
||
// y el número de outputs es menor que inputs, puede ser PayJoin
|
||
const likelyPayJoin = !likelyCJ && tx.vin.length >= 2 && tx.vout.length < tx.vin.length
|
||
&& tx.vout.length >= 1;
|
||
|
||
// ── 1. Reutilización de direcciones en inputs ─────────────────────
|
||
const inputAddrs = tx.vin.map(v=>v.prevout?.scriptpubkey_address).filter(Boolean);
|
||
const uniqueInputAddrs = new Set(inputAddrs);
|
||
const hasInputReuse = uniqueInputAddrs.size < inputAddrs.length;
|
||
checks.push({
|
||
id:"input_reuse", label:"Reutilización de direcciones en inputs", certainty:"CERTEZA", pass:!hasInputReuse,
|
||
actionability: "evitable",
|
||
detail: hasInputReuse
|
||
? `${inputAddrs.length - uniqueInputAddrs.size} dirección(es) repetida(s) en inputs. Revela que esos UTXOs pertenecen al mismo propietario con certeza.`
|
||
: "Ningún input repite dirección dentro de esta transacción. (No se ha comprobado si estas direcciones se reutilizan en otras transacciones de la cadena.)",
|
||
didactic: "Reutilizar una dirección Bitcoin es el error de privacidad más grave. Cualquiera puede ver que todos esos UTXOs pertenecen al mismo propietario y construir un historial completo de sus transacciones.",
|
||
penalty: weights.input_reuse,
|
||
});
|
||
if (hasInputReuse) deductions += weights.input_reuse;
|
||
|
||
// ── 2. Mezcla de tipos en inputs ──────────────────────────────────
|
||
const inTypes = [...new Set(tx.vin.map(v=>v.prevout?.scriptpubkey_type).filter(Boolean))];
|
||
const outTypes = [...new Set(tx.vout.map(v=>v.scriptpubkey_type).filter(Boolean))];
|
||
const hasInputTypeMixing = inTypes.length > 1;
|
||
checks.push({
|
||
id:"input_type_mixing", label:"Mezcla de tipos de script en inputs", certainty:"CERTEZA",
|
||
pass: likelyCJ ? true : !hasInputTypeMixing,
|
||
informational: likelyCJ && hasInputTypeMixing,
|
||
actionability: likelyCJ ? null : "evitable",
|
||
detail: hasInputTypeMixing
|
||
? (likelyCJ
|
||
? `Los inputs mezclan tipos (${inTypes.join(", ")}). En un CoinJoin es normal: cada participante aporta sus propios UTXOs, que pueden ser de tipos distintos.`
|
||
: `Hecho (certeza): los inputs son de tipos distintos (${inTypes.join(", ")}). Interpretación (probable): suele indicar consolidación de UTXOs de fuentes o épocas distintas — aunque un mismo wallet moderno también puede tener UTXOs de varios tipos.`)
|
||
: `Inputs uniformes: ${inTypes[0] || "—"}.`,
|
||
didactic: "Que los inputs mezclen tipos de script es un hecho observable. Lo que se infiere de ello —que el emisor consolidaba UTXOs de wallets o épocas distintas— es probable pero no seguro: un wallet moderno puede acumular UTXOs de varios tipos con el tiempo. La señal es reveladora para el análisis, pero conviene leerla como indicio, no como prueba.",
|
||
penalty: likelyCJ ? 0 : weights.input_type_mixing,
|
||
});
|
||
// En CoinJoin la mezcla de tipos de script es esperada (participantes distintos) — no penaliza.
|
||
if (hasInputTypeMixing && !likelyCJ) deductions += weights.input_type_mixing;
|
||
|
||
// ── 3. Mismatch tipo input/output ────────────────────────────────
|
||
const dominantInType = inTypes[0];
|
||
let hasOutputMismatch = false;
|
||
let mismatchDetail = "";
|
||
if (dominantInType && tx.vout.length === 2) {
|
||
const matchCount = tx.vout.filter(v=>v.scriptpubkey_type===dominantInType).length;
|
||
if (matchCount === 1) {
|
||
hasOutputMismatch = true;
|
||
const changeOut = tx.vout.find(v=>v.scriptpubkey_type===dominantInType);
|
||
const paymentOut = tx.vout.find(v=>v.scriptpubkey_type!==dominantInType);
|
||
mismatchDetail = `Output de tipo ${paymentOut?.scriptpubkey_type} es probablemente el pago externo; el de tipo ${changeOut?.scriptpubkey_type} es el cambio del emisor.`;
|
||
}
|
||
}
|
||
checks.push({
|
||
id:"output_type_mismatch", label:"Tipo de output revela el cambio", certainty:"PROBABLE", pass:!hasOutputMismatch,
|
||
actionability: "no_corregible",
|
||
detail: hasOutputMismatch ? mismatchDetail : "Los outputs son del mismo tipo que los inputs — no se puede distinguir cuál es el cambio por tipo de script.",
|
||
didactic: "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, positive: likelyCJ, informational: !likelyCJ,
|
||
});
|
||
// Sin penalización por no usar CoinJoin — es una técnica avanzada, no un error
|
||
|
||
// ── 5. Output de valor redondo ────────────────────────────────────
|
||
const roundOutputs = tx.vout.filter(v =>
|
||
v.value % 1000000 === 0 || v.value % 100000 === 0 || v.value % 10000000 === 0
|
||
);
|
||
const hasRound = roundOutputs.length > 0 && tx.vout.length === 2 && !likelyCJ;
|
||
checks.push({
|
||
id:"round_numbers", label:"Output de valor redondo", certainty:"PROBABLE", pass:!hasRound,
|
||
actionability: "evitable",
|
||
detail: hasRound
|
||
? `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: likelyCJ ? true : !hasUnnecessaryInput,
|
||
informational: likelyCJ,
|
||
actionability: likelyCJ ? null : "evitable",
|
||
detail: likelyCJ
|
||
? `En un CoinJoin todos los inputs son necesarios por diseño del protocolo — cada participante aporta los suyos. Esta heurística no aplica.`
|
||
: (hasUnnecessaryInput ? unnecessaryDetail : "No se detectan inputs claramente innecesarios para cubrir el pago."),
|
||
didactic: "Si una transacción 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: likelyCJ ? 0 : weights.unnecessary_input,
|
||
});
|
||
// En CoinJoin múltiples inputs son la estructura del protocolo — no penaliza.
|
||
if (hasUnnecessaryInput && !likelyCJ) deductions += weights.unnecessary_input;
|
||
|
||
// ── 8. Patrón de pago simple (posible eslabón de peeling chain) ────
|
||
// OJO: una sola tx 1-in/2-out NO es una peeling chain — es el pago más
|
||
// común de Bitcoin. Una peeling chain real es una CADENA de estas txs
|
||
// enlazadas (el cambio de una es el input de la siguiente), y detectarla
|
||
// requiere mirar las txs anterior/siguiente (futuro: vía rastro de procedencia).
|
||
// Por ahora: informativo, sin penalización, para no dar un falso positivo.
|
||
const isPeeling = tx.vin.length === 1 && tx.vout.length === 2 && !likelyCJ;
|
||
checks.push({
|
||
id:"peeling", label:"Patrón de pago simple (peeling)", certainty:"informativo", pass:!isPeeling,
|
||
actionability: "evitable",
|
||
detail: isPeeling
|
||
? "1 input y 2 outputs: el patrón de pago más común (un pago + un cambio). Por sí solo no es un problema. Solo se convierte en peeling chain si se encadena con otras transacciones iguales — eso no puede confirmarse mirando una transacción aislada."
|
||
: "La estructura no sigue el patrón de pago simple 1→2.",
|
||
didactic: "Una peeling chain es una serie de transacciones 1-input/2-outputs donde el cambio de una se convierte en el input de la siguiente. Cada eslabón es fácil de trazar. Pero una sola transacción 1→2 es simplemente un pago normal — no una cadena. Confirmar una cadena requiere seguir el rastro hacia adelante y atrás.",
|
||
penalty: 0, informational: true,
|
||
});
|
||
// Sin penalización: una tx aislada 1→2 no es trazabilidad encadenada.
|
||
|
||
// ── 9. Dust outputs ───────────────────────────────────────────────
|
||
// El umbral de dust no es fijo: depende del tipo de salida porque cuesta
|
||
// más o menos gastarla. Valores de referencia de Bitcoin Core.
|
||
const dustThreshold = (type) => {
|
||
if (!type) return 546;
|
||
if (type.includes("p2tr") || type.includes("v1")) return 330; // Taproot
|
||
if (type.includes("p2wpkh") || type.includes("v0_p2wpkh")) return 294; // SegWit nativo
|
||
if (type.includes("p2wsh") || type.includes("v0_p2wsh")) return 330; // SegWit script
|
||
return 546; // Legacy P2PKH / P2SH / desconocido
|
||
};
|
||
const dustOutputs = tx.vout.filter(v => v.value > 0 && v.value < dustThreshold(v.scriptpubkey_type));
|
||
// Dusting de privacidad: salidas pequeñas pero por ENCIMA del umbral técnico
|
||
// (un atacante manda 555, 888... gastables a propósito para que no sean
|
||
// "dust técnico" y el receptor las tenga en el wallet). No es un hecho como
|
||
// el dust técnico, sino una sospecha -> certeza POSIBLE.
|
||
const DUSTING_CEIL = 1000;
|
||
const dustingOutputs = tx.vout.filter(v => {
|
||
const th = dustThreshold(v.scriptpubkey_type);
|
||
return v.value >= th && v.value < DUSTING_CEIL;
|
||
});
|
||
const hasDust = dustOutputs.length > 0;
|
||
const hasDusting = !hasDust && dustingOutputs.length > 0;
|
||
if (hasDust) {
|
||
checks.push({
|
||
id:"dust", label:"Outputs de dust detectados", certainty:"CERTEZA", pass:false,
|
||
actionability: "no_corregible",
|
||
detail: `${dustOutputs.length} output(s) por debajo del umbral de dust (${dustOutputs.map(d=>d.value+"sat").join(", ")}). Hecho: hay salidas por debajo del mínimo económico de la red. Interpretación: podría ser un ataque de dusting, pero también un cambio diminuto de tu propio wallet o una salida de protocolo (Lightning, inscripciones). No se puede distinguir solo con esta transacción. Consecuencia: si esa salida se gasta junto a otros UTXOs, queda vinculada con ellos — que es justo lo que persigue un ataque de dusting.`,
|
||
didactic: "Un ataque de dust envía cantidades mínimas a múltiples direcciones. Cuando el receptor gasta ese dust combinándolo con otros UTXOs, revela qué UTXOs pertenecen al mismo wallet. Es una técnica de surveillance — pero no toda salida diminuta es un ataque: también las hay legítimas (cambios pequeños, salidas de protocolo). La marca se materializa solo si el dust se gasta junto a otras monedas.",
|
||
penalty: weights.dust,
|
||
});
|
||
deductions += weights.dust;
|
||
} else if (hasDusting) {
|
||
checks.push({
|
||
id:"dust", label:"Salida muy pequeña (posible dusting)", certainty:"POSIBLE", pass:false,
|
||
actionability: "no_corregible",
|
||
detail: `${dustingOutputs.length} output(s) de cantidad muy pequeña (${dustingOutputs.map(d=>d.value+"sat").join(", ")}), por encima del mínimo técnico pero inusualmente bajos. Hecho: son salidas diminutas pero gastables. Interpretación: compatible con un dusting de privacidad (cantidades pequeñas enviadas a propósito por encima del umbral de dust para que el receptor las conserve), aunque también con un pago o cambio pequeño legítimo. No se puede confirmar solo con esta transacción. Consecuencia: si esa salida se gasta junto a otros UTXOs, queda vinculada con ellos.`,
|
||
didactic: "El umbral de dust técnico (≈546 sats) marca lo que la red considera antieconómico de gastar. Pero un dusting de privacidad suele usar cantidades algo mayores —gastables a propósito— para que el receptor las mantenga en su wallet y, al gastarlas, revele qué UTXOs son suyos. Por eso una salida pequeña por encima del umbral técnico merece atención, aunque no sea concluyente.",
|
||
penalty: weights.dust,
|
||
});
|
||
deductions += weights.dust;
|
||
} else {
|
||
checks.push({
|
||
id:"dust", label:"Outputs de dust detectados", certainty:"CERTEZA", pass:true,
|
||
actionability: "no_corregible",
|
||
detail: "Sin outputs de dust ni salidas inusualmente pequeñas (según el umbral de cada tipo de salida).",
|
||
didactic: "Un ataque de dust envía cantidades mínimas a múltiples direcciones. Cuando el receptor gasta ese dust combinándolo con otros UTXOs, revela qué UTXOs pertenecen al mismo wallet. Es una técnica de surveillance.",
|
||
penalty: weights.dust, informational: true,
|
||
});
|
||
}
|
||
|
||
// ── 10. Change detection — señales combinadas ─────────────────────
|
||
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"]
|
||
});
|
||
} else if (isStructuralCJ) {
|
||
detectedWallets.unshift({
|
||
name: "Wasabi o JoinMarket (CoinJoin)", confidence: "POSIBLE",
|
||
signals: ["estructura de mezcla — muchos inputs/outputs, sin output dominante"]
|
||
});
|
||
}
|
||
checks.push({
|
||
id:"wallet_fingerprint", label:"Fingerprinting de wallet",
|
||
certainty: detectedWallets[0]?.confidence||"POSIBLE",
|
||
pass: likelyCJ ? true : detectedWallets.length===0,
|
||
informational: likelyCJ && detectedWallets.length > 0,
|
||
actionability: likelyCJ ? null : "wallet",
|
||
detail: detectedWallets.length > 0
|
||
? (likelyCJ
|
||
? `En un CoinJoin identificar el software es informativo, no un problema. ${detectedWallets.map(w=>`${w.name} (${w.confidence}): ${w.signals.join(", ")}`).join(" · ")}`
|
||
: detectedWallets.map(w=>`${w.name} (${w.confidence}): ${w.signals.join(", ")}`).join(" · "))
|
||
: "No se detecta un patrón de wallet específico con suficiente certeza.",
|
||
didactic: "El objetivo no es ser invisible sino ser indistinguible de millones de usuarios del mismo wallet. Un fingerprint de Bitcoin Core lo comparten millones de transacciones — no revela nada útil. Un fingerprint de Exodus o un wallet minoritario pertenece a un conjunto mucho menor y es más revelador. En un CoinJoin, el tipo de mezcla ya sugiere el software usado.",
|
||
penalty: likelyCJ ? 0 : weights.wallet_fingerprint,
|
||
});
|
||
// En CoinJoin no penaliza (identificar el wallet es esperado, no un fallo).
|
||
// Fuera de CoinJoin, solo penaliza si se detectó un wallet estructural real.
|
||
if (!likelyCJ && detectedWallets.length > 0) deductions += weights.wallet_fingerprint;
|
||
|
||
// ── 13. PayJoin — informativo ─────────────────────────────────────
|
||
checks.push({
|
||
id:"payjoin", label:"Posible PayJoin", certainty:"POSIBLE", pass:true,
|
||
actionability: "evitable",
|
||
detail: likelyPayJoin
|
||
? `Estructura compatible con PayJoin: ${tx.vin.length} inputs, ${tx.vout.length} outputs. Si es un PayJoin, el CIOH (Common Input Ownership Heuristic) no aplica y los inputs pueden pertenecer a distintos propietarios.`
|
||
: "No se detecta estructura compatible con PayJoin.",
|
||
didactic: "PayJoin (P2EP) es una técnica donde el receptor añade un input a la transacción del pagador. Rompe la heurística CIOH porque los inputs ya no pertenecen todos al mismo propietario. Es difícil de detectar precisamente porque parece una transacción normal.",
|
||
penalty: 0, informational: true,
|
||
});
|
||
|
||
// ── 14. Batch payment ─────────────────────────────────────────────
|
||
// Un batch real (exchange/servicio pagando a muchos) tiene muchos outputs
|
||
// casi todos con valores distintos. No exigimos que TODOS sean distintos
|
||
// (dos destinatarios pueden recibir la misma cantidad), sino la mayoría.
|
||
const uniqueOutValues = new Set(outputValues);
|
||
const distinctRatio = tx.vout.length > 0 ? uniqueOutValues.size / tx.vout.length : 0;
|
||
const isBatch = tx.vout.length >= 5 && distinctRatio >= 0.8 && uniqueOutValues.size >= 5 && !likelyCJ;
|
||
checks.push({
|
||
id:"batch_payment", label:"Pago por lotes (batch payment)", certainty:"PROBABLE", pass:!isBatch,
|
||
actionability: "no_corregible",
|
||
detail: isBatch
|
||
? `${tx.vout.length} outputs, casi todos con valores distintos: compatible con un exchange o servicio que paga a muchos destinatarios en una sola transacción. Compartes los mismos inputs —y por tanto el mismo origen— con los otros destinatarios del lote, lo que permite a un observador agruparos.`
|
||
: "No se detecta patrón de batch payment.",
|
||
didactic: "Exchanges y custodios agrupan múltiples pagos en una transacción para ahorrar comisiones. Recibir de un batch reduce tu privacidad: los demás destinatarios comparten origen contigo, así que un observador puede correlacionar todos los pagos del lote. Y si el origen es un exchange con KYC, ese servicio conoce la identidad asociada. Por eso baja la valoración aunque tú no controles cómo te pagaron.",
|
||
penalty: isBatch ? weights.batch_payment : 0,
|
||
});
|
||
if (isBatch) deductions += weights.batch_payment;
|
||
|
||
// ── 15. OP_RETURN ─────────────────────────────────────────────────
|
||
// Detecta outputs OP_RETURN (nulldata): presencia y tamaño total en bytes.
|
||
// No muestra el contenido — lo relevante para privacidad es que hay datos
|
||
// arbitrarios, no qué dicen. Ver v1.2.1 del changelog.
|
||
const opReturnOuts = tx.vout.filter(v => isOpReturn(v));
|
||
const hasOpReturn = opReturnOuts.length > 0;
|
||
const opReturnBytes = opReturnOuts.reduce((s, v) => {
|
||
// scriptpubkey: "6a" + longitud (1 byte) + datos. Datos = (hex.length/2) - 2
|
||
const hex = v.scriptpubkey || "";
|
||
return s + Math.max(0, hex.length / 2 - 2);
|
||
}, 0);
|
||
checks.push({
|
||
id:"op_return", label:"Datos OP_RETURN", certainty:"CERTEZA", pass:!hasOpReturn,
|
||
actionability: "no_corregible",
|
||
detail: hasOpReturn
|
||
? `${opReturnOuts.length} output${opReturnOuts.length > 1 ? "s" : ""} OP_RETURN con ${opReturnBytes} bytes de datos arbitrarios. Revela que esta transacción usa un protocolo o servicio que escribe datos en la cadena (Stamps, Ordinals, OpenTimestamps, Omni, etc.). Se informa la presencia y el tamaño, no el contenido.`
|
||
: "No se detectan outputs OP_RETURN.",
|
||
didactic: "Un output OP_RETURN contiene datos arbitrarios que cualquiera puede leer. Su presencia revela el uso de un protocolo concreto y puede vincular esta transacción con actividad on-chain identificable. El contenido no se muestra — para la privacidad lo relevante es que existe, no qué dice.",
|
||
penalty: hasOpReturn ? weights.op_return : 0,
|
||
});
|
||
if (hasOpReturn) deductions += weights.op_return;
|
||
|
||
// ── 16. Timing analysis — informativo ────────────────────────────
|
||
let timingDetail = "No hay timestamp disponible (transacción pendiente o sin confirmar).";
|
||
if (tx.status?.block_time) {
|
||
const date = new Date(tx.status.block_time * 1000);
|
||
const hour = date.getUTCHours();
|
||
const day = date.getUTCDay();
|
||
const dayNames = ["domingo","lunes","martes","miércoles","jueves","viernes","sábado"];
|
||
const zone = hour >= 6 && hour <= 10 ? "mañana temprana (Europa/África)" :
|
||
hour >= 14 && hour <= 18 ? "tarde europea / mañana americana" :
|
||
hour >= 22 || hour <= 2 ? "madrugada Europa / tarde América" : "horario sin patrón claro";
|
||
timingDetail = `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. Tipo legacy (P2PKH / P2SH) ───────────────────────────────
|
||
// Inputs en formato legacy (P2PKH = 1..., P2SH = 3...) revelan UTXOs antiguos
|
||
// o wallets que no han migrado a SegWit/Taproot. Mayor huella on-chain y
|
||
// menor anonimato en el conjunto de usuarios modernos.
|
||
const legacyTypes = ["p2pkh", "p2sh"];
|
||
const allInputScriptTypes = [...new Set(tx.vin.map(v=>v.prevout?.scriptpubkey_type).filter(Boolean))];
|
||
const hasLegacyInputs = allInputScriptTypes.some(t => legacyTypes.includes(t));
|
||
const legacyInputTypes = allInputScriptTypes.filter(t => legacyTypes.includes(t));
|
||
checks.push({
|
||
id:"legacy_type", label:"Tipo de script legacy (P2PKH/P2SH)", certainty:"CERTEZA",
|
||
pass: !hasLegacyInputs,
|
||
actionability: "wallet",
|
||
detail: hasLegacyInputs
|
||
? `Los inputs usan tipo legacy (${legacyInputTypes.join(", ")}). Las direcciones legacy (1... para P2PKH, 3... para P2SH) tienen mayor huella on-chain que SegWit (bc1q...) o Taproot (bc1p...) y forman un conjunto de usuarios cada vez más pequeño, lo que reduce el anonimato por conjunto.`
|
||
: "Los inputs usan SegWit o Taproot — no se detecta tipo legacy.",
|
||
didactic: "P2PKH y P2SH son los formatos originales de Bitcoin. La mayoría de wallets modernos usan SegWit (bc1q) o Taproot (bc1p), que son más eficientes y tienen menor huella. Usar legacy no es un error activo, pero sí una característica del UTXO observable por cualquiera.",
|
||
penalty: hasLegacyInputs ? weights.legacy_type : 0,
|
||
});
|
||
if (hasLegacyInputs) deductions += weights.legacy_type;
|
||
|
||
// ── 17. Detección de entidades conocidas ──────────────────────────
|
||
// Cruza direcciones de inputs y outputs contra el ENTITY_INDEX embebido.
|
||
const allTxAddrs = [
|
||
...tx.vin.map(v => ({ addr: v.prevout?.scriptpubkey_address, side: "input" })),
|
||
...tx.vout.map(v => ({ addr: v.scriptpubkey_address, side: "output" })),
|
||
].filter(x => x.addr);
|
||
const ofacHits = [];
|
||
const miningHits = [];
|
||
const exchHits = [];
|
||
for (const { addr, side } of allTxAddrs) {
|
||
const hit = ENTITY_INDEX.get(addr);
|
||
if (!hit) continue;
|
||
if (hit.cat === "ofac") ofacHits.push({ ...hit, addr, side });
|
||
if (hit.cat === "mining") miningHits.push({ ...hit, addr, side });
|
||
if (hit.cat === "exchange") exchHits.push({ ...hit, addr, side });
|
||
}
|
||
|
||
// OFAC — 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,
|
||
});
|
||
}
|
||
|
||
// Exchange — informativo. El texto es HONESTO sobre la fiabilidad:
|
||
// las direcciones por clustering son inferidas (no confirmadas por el
|
||
// exchange); las de rich list / forense son etiquetas públicas conocidas.
|
||
if (exchHits.length > 0) {
|
||
const names = [...new Set(exchHits.map(h => h.name))].join(", ");
|
||
const sides = [...new Set(exchHits.map(h => h.side))];
|
||
const todasConfiables = exchHits.every(h => h.src==="richlist" || h.src==="forensic");
|
||
const algunaCluster = exchHits.some(h => h.src==="cluster");
|
||
const label = todasConfiables
|
||
? `Coincide con dirección conocida de exchange: ${names}`
|
||
: `Posible coincidencia con exchange (agrupación): ${names}`;
|
||
const detail = todasConfiables
|
||
? `Una o más direcciones (${sides.join(" y ")}) coinciden con direcciones públicas conocidas de ${names}. Recibir o enviar a un exchange con KYC vincula esta actividad con tu identidad verificada ante ese servicio.`
|
||
: `Una o más direcciones (${sides.join(" y ")}) coinciden con ${names} según datos de agrupación de direcciones (clustering). Importante: esto NO está confirmado por el exchange — es una inferencia estadística que asocia direcciones por cómo se mueven los fondos. Puede haber falsos positivos. Tómalo como una pista, no como un hecho.`;
|
||
checks.push({
|
||
id:"entity_exchange", label, certainty: todasConfiables ? "PROBABLE" : "POSIBLE", pass:true,
|
||
actionability: "no_corregible",
|
||
detail,
|
||
didactic: "Los exchanges con KYC conocen la identidad de sus usuarios. Si una transacción toca una dirección de exchange, ese servicio puede vincular los fondos con una persona. Las direcciones de exchange se conocen de dos formas: las que el propio exchange publica (fiables) y las que se infieren agrupando direcciones por su comportamiento (clustering, no confirmado). Txoko distingue ambas para que sepas cuánto fiarte del dato.",
|
||
penalty: 0, informational: true,
|
||
});
|
||
}
|
||
|
||
// ── Score compuesto ───────────────────────────────────────────────
|
||
const maxPossible = Object.values(weights).reduce((a,b)=>a+b,0);
|
||
const rawScore = Math.max(0, Math.min(100, Math.round(100 - (deductions / maxPossible) * 100)));
|
||
// OP_RETURN es incompatible con privacidad aceptable — fuerza banda BAJA como máximo.
|
||
const score = hasOpReturn ? Math.min(rawScore, 44) : rawScore;
|
||
|
||
return {
|
||
score, checks,
|
||
band: score>=75?"ALTA":score>=45?"MEDIA":"BAJA",
|
||
summary: score>=75?"Privacidad aceptable":score>=45?"Privacidad mejorable":"Privacidad baja",
|
||
summaryColor: score>=75?C.green:score>=45?C.amber:C.red,
|
||
wallets: detectedWallets,
|
||
};
|
||
}
|
||
|
||
function analyzeAddress(addr) {
|
||
const checks = []; let score = 100;
|
||
const txCount = addr.chain_stats ? addr.chain_stats.tx_count : 0;
|
||
const utxoCount = addr.utxos ? addr.utxos.length : 0;
|
||
const reused = txCount > 1;
|
||
const penalty = txCount > 10 ? 40 : txCount > 3 ? 25 : 15;
|
||
checks.push({ id:"addr_reuse", label:"Reutilización de dirección", certainty:"CERTEZA", pass:!reused, detail: reused ? `Usada en ${txCount} transacciones. Permite vincular pagos y construir un perfil.` : "Usada una sola vez. Correcto.", penalty });
|
||
if (reused) score -= penalty;
|
||
const address = addr.address || "";
|
||
const isTaproot = address.startsWith("bc1p");
|
||
const isNative = address.startsWith("bc1q");
|
||
const isLegacy = address.startsWith("1");
|
||
const scriptScore = isTaproot ? 0 : isNative ? 5 : isLegacy ? 20 : 15;
|
||
checks.push({ id:"script_type", label:"Tipo de script", certainty:"CERTEZA", pass:isTaproot||isNative, detail: isTaproot ? "Taproot (bc1p). Mejor tipo disponible." : isNative ? "Native SegWit (bc1q). Buen tipo." : isLegacy ? "Legacy P2PKH. Tipo antiguo, menor privacidad." : "P2SH. Tipo intermedio.", penalty:scriptScore });
|
||
score -= scriptScore;
|
||
checks.push({ id:"utxo_count", label:"Fragmentación de UTXOs", certainty:"POSIBLE", pass:utxoCount<=5, detail: utxoCount > 5 ? `${utxoCount} UTXOs. Un número alto facilita el análisis de actividad.` : `${utxoCount} UTXOs. Número razonable.`, penalty:10 });
|
||
if (utxoCount > 5) score -= 10;
|
||
const funded = addr.chain_stats ? addr.chain_stats.funded_txo_sum : 0;
|
||
checks.push({ id:"volume", label:"Volumen de actividad", certainty:"CERTEZA", pass:true, detail:`Total recibido: ${(funded/1e8).toFixed(4)} BTC en ${txCount} transacciones.`, penalty:0, informational:true });
|
||
score = Math.max(0, Math.min(100, score));
|
||
return { score, checks, band: score>=75?"ALTA":score>=45?"MEDIA":"BAJA", summary: score >= 75 ? "Privacidad aceptable" : score >= 45 ? "Privacidad mejorable" : "Privacidad baja", summaryColor: score >= 75 ? C.green : score >= 45 ? C.amber : C.red, wallets: [] };
|
||
}
|
||
|
||
// ── fetch con timeout — protege el nodo y evita el spinner infinito ──
|
||
// Si Fulcrum/Mempool está ocupado (reindexando, mempool llena), la petición
|
||
// se cancela pasados timeoutMs en lugar de quedarse colgada para siempre.
|
||
const FETCH_TIMEOUT_MS = 8000;
|
||
async function fetchWithTimeout(url, timeoutMs) {
|
||
const ms = timeoutMs || FETCH_TIMEOUT_MS;
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(() => controller.abort(), ms);
|
||
try {
|
||
return await fetch(url, { signal: controller.signal });
|
||
} catch (e) {
|
||
if (e.name === "AbortError") {
|
||
throw new Error("el nodo tardó demasiado en responder (timeout)");
|
||
}
|
||
throw e;
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
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}}>
|
||
{tx
|
||
? (score>=75?"La transacción presenta buenas propiedades de privacidad.":score>=45?"La transacción es funcional, pero tiene características que reducen su privacidad.":"Esta transacción tiene características que facilitan su rastreo.")
|
||
: (score>=75?"La dirección presenta buenas propiedades de privacidad.":score>=45?"La dirección es funcional, pero tiene características que reducen su privacidad.":"Esta dirección tiene características que facilitan su rastreo.")}
|
||
</div>
|
||
{wallets&&wallets.length>0&&(
|
||
<div style={{marginTop:6,fontSize:"0.62rem",color:C.t2,fontFamily:"monospace"}}>
|
||
Wallet posible: <span style={{color:C.amber}}>{wallets.map(w=>w.name||w).join(", ")}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Card>
|
||
|
||
{/* Informe narrativo — colapsable */}
|
||
<div style={{marginBottom:10}}>
|
||
<button onClick={()=>setShowReport(!showReport)} style={{
|
||
width:"100%",display:"flex",alignItems:"center",justifyContent:"space-between",
|
||
padding:"10px 14px",background:C.bgCard,border:`1px solid ${showReport?C.purple+"60":C.border}`,
|
||
borderRadius:6,cursor:"pointer",color:C.purple,fontFamily:"monospace",fontSize:"0.68rem",fontWeight:700,
|
||
letterSpacing:"0.1em",textTransform:"uppercase",
|
||
}}>
|
||
<span>⌖ Informe de privacidad</span>
|
||
<span style={{fontSize:"0.7rem"}}>{showReport?"▲":"▼"}</span>
|
||
</button>
|
||
{showReport&&(
|
||
<div style={{background:C.bgCard,border:`1px solid ${C.purple}30`,borderTop:"none",borderRadius:"0 0 6px 6px",padding:"14px 16px"}}>
|
||
{report.map((section,i) => (
|
||
<div key={i} style={{marginBottom: i<report.length-1?16:0}}>
|
||
<div style={{fontSize:"0.6rem",color:C.purple,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.15em",marginBottom:6,fontWeight:700}}>
|
||
{section.title}
|
||
</div>
|
||
{section.text&&(
|
||
<div style={{fontSize:"0.7rem",color:C.t1,lineHeight:1.8}}>{section.text}</div>
|
||
)}
|
||
{section.recs&&(
|
||
<div style={{display:"flex",flexDirection:"column",gap:8}}>
|
||
{section.recs.map((rec,j)=>(
|
||
<div key={j} style={{padding:"10px 12px",background:C.bg,borderRadius:6,border:`1px solid ${C.border}`}}>
|
||
<div style={{display:"flex",alignItems:"center",gap:8,marginBottom:5}}>
|
||
<span style={{fontSize:"0.72rem",color:C.green,fontFamily:"monospace",fontWeight:700}}>{j+1}.</span>
|
||
<span style={{fontSize:"0.7rem",color:C.t1,fontWeight:600,flex:1}}>{rec.action}</span>
|
||
<span style={{fontSize:"0.55rem",fontFamily:"monospace",padding:"2px 6px",borderRadius:3,flexShrink:0,
|
||
background: rec.tag==="wallet"?C.purpleMuted:C.amberMuted,
|
||
color: rec.tag==="wallet"?C.purple:C.amber,
|
||
border: `1px solid ${rec.tag==="wallet"?C.purple:C.amber}30`,
|
||
}}>
|
||
{rec.tag==="wallet"?"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.positive&&!check.pass)?C.green:check.pass?C.green:C.red,flexShrink:0,fontWeight:700}}>
|
||
{check.informational?"·":(check.positive&&!check.pass)?"✓":check.pass?"✓":"✗"}
|
||
</span>
|
||
<span style={{flex:1,fontSize:"0.73rem",color:C.t1,fontFamily:"monospace"}}>{check.label}</span>
|
||
{!check.pass&&!check.positive&&check.actionability&&(
|
||
<span style={{fontSize:"0.55rem",fontFamily:"monospace",padding:"2px 6px",borderRadius:3,flexShrink:0,
|
||
background: check.actionability==="no_corregible"?C.redMuted:check.actionability==="wallet"?C.purpleMuted:C.amberMuted,
|
||
color: check.actionability==="no_corregible"?C.red:check.actionability==="wallet"?C.purple:C.amber,
|
||
border: `1px solid ${check.actionability==="no_corregible"?C.red:check.actionability==="wallet"?C.purple:C.amber}30`,
|
||
}}>
|
||
{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} analysis={analysis}/>}
|
||
{result&&result.type==="block"&&<BlockResult block={result.data}/>}
|
||
<UTXOMap base={base}/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TxVerifier({base}) {
|
||
const {get}=useApi(base);
|
||
const [txid,setTxid]=useState("");
|
||
const [result,setResult]=useState(null);
|
||
const [loading,setLoading]=useState(false);
|
||
const [err,setErr]=useState(null);
|
||
|
||
const verify=async()=>{
|
||
const q=txid.trim();
|
||
if(!q||!/^[0-9a-f]{64}$/i.test(q)){setErr("Introduce un txid válido (64 caracteres hex)");return;}
|
||
setLoading(true);setResult(null);setErr(null);
|
||
try{
|
||
const tx=await get(`/api/tx/${q}`,null);
|
||
if(!tx)throw new Error("No encontrada");
|
||
const height=await get("/api/blocks/tip/height",MOCK_HEIGHT);
|
||
const confs=tx.status?.confirmed?(height-tx.status.block_height+1):0;
|
||
setResult({tx,confs,height});
|
||
}catch(e){setErr(`No encontrada en el nodo: ${e.message}`);}
|
||
setLoading(false);
|
||
};
|
||
|
||
const statusColor = result
|
||
? result.confs===0 ? C.amber : result.confs<6 ? C.blue : C.green
|
||
: C.t2;
|
||
const statusLabel = result
|
||
? result.confs===0 ? "EN MEMPOOL" : result.confs<6 ? `${result.confs} CONFIRMACIONES` : `${fmt.num(result.confs)} CONFIRMACIONES ✓`
|
||
: "";
|
||
|
||
return (
|
||
<Card style={{marginBottom:4}}>
|
||
<div style={{fontSize:"0.62rem",color:C.green,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.2em",marginBottom:12,display:"flex",alignItems:"center",gap:8}}>
|
||
<span>✓</span> Verificar Transacción Propia
|
||
<div style={{flex:1,height:1,background:`linear-gradient(to right,${C.green}40,transparent)`}}/>
|
||
</div>
|
||
<div style={{display:"flex",gap:8,marginBottom:err||result?"10px":"0"}}>
|
||
<input value={txid} onChange={e=>setTxid(e.target.value)} onKeyDown={e=>e.key==="Enter"&&verify()}
|
||
placeholder="Pega aquí tu txid para verificar"
|
||
style={{flex:1,background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"9px 12px",color:C.t1,fontFamily:"monospace",fontSize:"0.75rem",outline:"none"}}
|
||
onFocus={e=>e.target.style.borderColor=C.green} onBlur={e=>e.target.style.borderColor=C.borderBright}
|
||
/>
|
||
<button onClick={verify} style={{padding:"9px 16px",background:C.greenMuted,border:`1px solid ${C.green}40`,borderRadius:6,color:C.green,fontFamily:"monospace",fontSize:"0.72rem",cursor:"pointer",fontWeight:700}}>
|
||
{loading?"···":"VERIFICAR"}
|
||
</button>
|
||
</div>
|
||
{err&&<div style={{padding:"8px 12px",background:C.redMuted,border:`1px solid ${C.red}30`,borderRadius:6,color:C.red,fontFamily:"monospace",fontSize:"0.72rem"}}>{err}</div>}
|
||
{result&&(
|
||
<div style={{padding:"12px 14px",background:C.bg,borderRadius:6,border:`1px solid ${statusColor}40`}}>
|
||
<div style={{display:"flex",alignItems:"center",justifyContent:"space-between",marginBottom:10}}>
|
||
<span style={{fontSize:"0.68rem",fontFamily:"monospace",color:C.t2,wordBreak:"break-all"}}>{fmt.hash(result.tx.txid)}</span>
|
||
<Badge color={statusColor}>{statusLabel}</Badge>
|
||
</div>
|
||
<div style={{display:"grid",gridTemplateColumns:"repeat(auto-fill,minmax(120px,1fr))",gap:10}}>
|
||
<Tag label="Estado" value={result.confs===0?"Pendiente":"Confirmada"} color={statusColor}/>
|
||
<Tag label="Bloque" value={result.tx.status?.block_height?`#${fmt.num(result.tx.status.block_height)}`:"mempool"} color={C.purple}/>
|
||
<Tag label="Fee pagado" value={result.tx.fee?`${fmt.num(result.tx.fee)} sat`:"-"} color={C.amber}/>
|
||
<Tag label="Tasa fee" value={result.tx.fee&&result.tx.weight?`${(result.tx.fee/Math.ceil(result.tx.weight/4)).toFixed(1)} sat/vB`:result.tx.feerate?`${Number(result.tx.feerate).toFixed(1)} sat/vB`:"-"} color={C.amber}/>
|
||
<Tag label="Confirmado" value={result.tx.status?.block_time?fmt.date(result.tx.status.block_time):"-"} color={C.t1}/>
|
||
<Tag label="vSize" value={result.tx.weight?`${Math.ceil(result.tx.weight/4)} vB`:"-"} color={C.t1}/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function UTXOMap({base}) {
|
||
const {get}=useApi(base);
|
||
const [addr,setAddr]=useState("");
|
||
const [utxos,setUtxos]=useState(null);
|
||
const [txCache,setTxCache]=useState({});
|
||
const [loading,setLoading]=useState(false);
|
||
const [err,setErr]=useState(null);
|
||
const MAX_UTXOS = 25; // límite para no estresar el nodo
|
||
|
||
const MOCK_UTXOS=[
|
||
{txid:"abc1def2abc1def2abc1def2abc1def2abc1def2abc1def2abc1def2abc1def2",vout:0,value:10000000,status:{confirmed:true,block_height:846089,block_time:1713000000}},
|
||
{txid:"123a456b123a456b123a456b123a456b123a456b123a456b123a456b123a456b",vout:1,value:4000000,status:{confirmed:true,block_height:846911,block_time:1713200000}},
|
||
{txid:"789c012d789c012d789c012d789c012d789c012d789c012d789c012d789c012d",vout:0,value:823000,status:{confirmed:false}},
|
||
{txid:"aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd",vout:2,value:50000000,status:{confirmed:true,block_height:840000,block_time:1710000000}},
|
||
{txid:"11223344112233441122334411223344112233441122334411223344aabbcc11",vout:0,value:1500000,status:{confirmed:true,block_height:950000,block_time:1716000000}},
|
||
];
|
||
|
||
// Calcular score de privacidad por UTXO
|
||
const getUtxoPrivacy = (utxo, originTx) => {
|
||
const issues = [];
|
||
let score = 100;
|
||
|
||
// Sin confirmar
|
||
if (!utxo.status?.confirmed) {
|
||
issues.push({label:"Sin confirmar", color:C.amber, weight:10});
|
||
score -= 10;
|
||
}
|
||
|
||
// Dust — umbral conservador (546): un UTXO futuro podría gastarse como cualquier tipo
|
||
if (utxo.value < 546) {
|
||
issues.push({label:"Dust — trátalo con cuidado (posible linking)", color:C.red, weight:30});
|
||
score -= 30;
|
||
}
|
||
|
||
// Alto valor — más visible on-chain
|
||
if (utxo.value > 10000000) {
|
||
issues.push({label:"Alto valor — destacado on-chain", color:C.blue, weight:5});
|
||
score -= 5;
|
||
}
|
||
|
||
if (originTx) {
|
||
// Origen: batch payment (exchange/custodio)
|
||
const outCount = originTx.vout?.length || 0;
|
||
const inCount = originTx.vin?.length || 0;
|
||
if (outCount >= 5) {
|
||
issues.push({label:"Origen: batch payment (exchange?)", color:C.amber, weight:15});
|
||
score -= 15;
|
||
}
|
||
// Origen: consolidación de muchos inputs
|
||
if (inCount >= 5) {
|
||
issues.push({label:"Origen: consolidación de inputs", color:C.amber, weight:10});
|
||
score -= 10;
|
||
}
|
||
// Origen: CoinJoin
|
||
const vals = originTx.vout?.map(v=>v.value)||[];
|
||
const maxEq = vals.length>0 ? Math.max(...Object.values(vals.reduce((m,v)=>{m[v]=(m[v]||0)+1;return m;},{}))) : 0;
|
||
if (inCount>=5 && outCount>=5 && maxEq>=Math.floor(outCount*0.5)) {
|
||
issues.push({label:"Origen: posible CoinJoin ✓", color:C.green, weight:-20});
|
||
score += 20; // positivo
|
||
}
|
||
// RBF en tx de origen
|
||
const hasRbf = originTx.vin?.some(v=>typeof v.sequence==="number"&&v.sequence<0xfffffffe);
|
||
if (hasRbf) {
|
||
issues.push({label:"Tx origen con RBF", color:C.amber, weight:5});
|
||
score -= 5;
|
||
}
|
||
}
|
||
|
||
score = Math.max(0, Math.min(100, score));
|
||
const color = score >= 70 ? C.green : score >= 40 ? C.amber : C.red;
|
||
return { score, issues, color };
|
||
};
|
||
|
||
// Detectar patrones temporales del conjunto de UTXOs
|
||
const getTemporalPattern = (utxoList) => {
|
||
const times = utxoList
|
||
.filter(u=>u.status?.block_time)
|
||
.map(u=>new Date(u.status.block_time*1000).getUTCHours());
|
||
if (times.length < 3) return null;
|
||
const avg = times.reduce((a,b)=>a+b,0)/times.length;
|
||
const zone = avg>=6&&avg<=10?"mañana europea (6-10 UTC)":
|
||
avg>=14&&avg<=18?"tarde europea / mañana americana (14-18 UTC)":
|
||
avg>=22||avg<=2?"madrugada Europa / tarde América":null;
|
||
return zone ? `Patrón horario detectado: la mayoría de transacciones ocurren en ${zone}.` : null;
|
||
};
|
||
|
||
const fetchUtxos=async()=>{
|
||
const q=addr.trim();
|
||
if(!q){setErr("Introduce una dirección Bitcoin");return;}
|
||
setLoading(true);setUtxos(null);setErr(null);setTxCache({});
|
||
try{
|
||
if(!base){
|
||
setTimeout(()=>{setUtxos(MOCK_UTXOS);setLoading(false);},400); return;
|
||
}
|
||
|
||
// Obtener UTXOs
|
||
let data=null;
|
||
try{
|
||
const res=await fetchWithTimeout(`${base}/api/address/${q}/utxo`);
|
||
if(res.ok){ const json=await res.json(); if(Array.isArray(json)&&json.length>0) data=json; }
|
||
}catch{}
|
||
|
||
if(!data){
|
||
const txRes=await fetchWithTimeout(`${base}/api/v1/address/${q}/txs`);
|
||
if(!txRes.ok) throw new Error(`HTTP ${txRes.status}`);
|
||
const txs=await txRes.json();
|
||
const spentTxids=new Set();
|
||
txs.forEach(tx=>tx.vin.forEach(v=>{ if(v.txid) spentTxids.add(`${v.txid}:${v.vout}`); }));
|
||
const outputs=[];
|
||
txs.forEach(tx=>{
|
||
tx.vout.forEach((out,i)=>{
|
||
if(out.scriptpubkey_address===q&&!spentTxids.has(`${tx.txid}:${i}`)){
|
||
outputs.push({txid:tx.txid,vout:i,value:out.value,status:tx.status});
|
||
}
|
||
});
|
||
});
|
||
data=outputs;
|
||
}
|
||
|
||
if(!data||data.length===0){
|
||
setErr("No se encontraron UTXOs para esta dirección — puede que todos estén gastados");
|
||
setLoading(false);return;
|
||
}
|
||
|
||
const sorted=[...data].sort((a,b)=>b.value-a.value);
|
||
const limited=sorted.slice(0,MAX_UTXOS);
|
||
if(sorted.length>MAX_UTXOS) setErr(`Mostrando ${MAX_UTXOS} de ${sorted.length} UTXOs — se muestran los de mayor valor.`);
|
||
|
||
// Cargar txs de origen para contexto (máx 10, las más grandes)
|
||
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>
|
||
);
|
||
}
|
||
|
||
// ── RASTRO DE PROCEDENCIA ──────────────────────────────────────────────
|
||
// Sigue una transacción hacia atrás, eslabón a eslabón, BAJO DEMANDA.
|
||
// Paso 2: RECURSIVO — cada eslabón puede a su vez seguir sus propios inputs,
|
||
// encadenando saltos hacia atrás. La recursión NO dispara consultas solas:
|
||
// solo se expande cuando el usuario pulsa. Caché compartida en memoria.
|
||
function bandColorRastro(b){ return b==="ALTA"?C.green : b==="MEDIA"?C.amber : b==="BAJA"?C.red : C.t2; }
|
||
function abrevTxid(t){ return t ? `${t.slice(0,10)}…${t.slice(-6)}` : "—"; }
|
||
|
||
// Marcas baratas de una tx (lookup local ENTITY_INDEX + estructura)
|
||
function marcasDeTx(txData, ana){
|
||
const marcas=[];
|
||
const addrs=[
|
||
...(txData.vin||[]).map(v=>v.prevout?.scriptpubkey_address),
|
||
...(txData.vout||[]).map(o=>o.scriptpubkey_address),
|
||
].filter(Boolean);
|
||
let ofac=false, mining=false, exch=null;
|
||
for(const a of addrs){
|
||
const hit=ENTITY_INDEX.get(a);
|
||
if(hit?.cat==="ofac") ofac=true;
|
||
if(hit?.cat==="mining") mining=true;
|
||
if(hit?.cat==="exchange" && !exch) exch={name:hit.name, src:hit.src};
|
||
}
|
||
const esCoinbase=(txData.vin||[]).some(v=>v.is_coinbase||v.coinbase)||(txData.vin||[]).length===0;
|
||
// CoinJoin real: el check con id "coinjoin" dio pass:true (5+ in, 5+ out
|
||
// con valores iguales). NO buscar la palabra en el texto: el check existe
|
||
// siempre, incluso cuando dice que NO es CoinJoin -> daría falso positivo.
|
||
const esCoinJoin = (ana?.checks||[]).find(c=>c.id==="coinjoin")?.pass === true;
|
||
if(esCoinJoin) marcas.push({txt:"🔀 CoinJoin",color:C.blue});
|
||
if(ofac) marcas.push({txt:"⚠️ OFAC",color:C.red});
|
||
if(mining) marcas.push({txt:"⛏️ minería",color:C.green});
|
||
if(esCoinbase) marcas.push({txt:"⛏️ coinbase (origen)",color:C.green});
|
||
// Exchange: el texto es HONESTO sobre la fiabilidad de la fuente.
|
||
// cluster = agrupación de direcciones (inferido, no confirmado por el exchange).
|
||
// richlist/forensic = etiqueta pública conocida.
|
||
if(exch){
|
||
const confiable = exch.src==="richlist" || exch.src==="forensic";
|
||
const txt = confiable
|
||
? `🏦 ${exch.name}`
|
||
: `🏦 posible ${exch.name}`;
|
||
marcas.push({txt, color:C.amber, exchSrc:exch.src});
|
||
}
|
||
return {marcas, esCoinbase};
|
||
}
|
||
|
||
// Eslabón recursivo: muestra una tx de origen y permite seguir SUS inputs.
|
||
// depth limita la profundidad visual; cache y getTx se comparten desde arriba.
|
||
function Eslabon({txData, fluyo, vout, depth, getTx, base}) {
|
||
const [hijos,setHijos]=useState({}); // {vinIndex: {loading, child:{txData,fluyo,vout}, err}}
|
||
const ana = useMemo(()=>analyzeTx(txData),[txData]);
|
||
const {marcas, esCoinbase} = useMemo(()=>marcasDeTx(txData,ana),[txData,ana]);
|
||
const mempoolUrl = base ? `${base}/tx/${txData.txid}` : `https://mempool.space/tx/${txData.txid}`;
|
||
const inputs = txData.vin || [];
|
||
const [hover,setHover]=useState(false);
|
||
|
||
// Detalle para el tooltip: lo que un noderunner querría ver de un eslabón
|
||
const detalle = useMemo(()=>{
|
||
const nIn = (txData.vin||[]).length;
|
||
const nOut = (txData.vout||[]).length;
|
||
const totalOut = (txData.vout||[]).reduce((s,o)=>s+(o.value||0),0);
|
||
const totalIn = (txData.vin||[]).reduce((s,v)=>s+(v.prevout?.value||0),0);
|
||
const fee = txData.fee || (totalIn>totalOut ? totalIn-totalOut : null);
|
||
const vsize = txData.weight ? Math.ceil(txData.weight/4) : null;
|
||
const feeRate = (fee && vsize) ? (fee/vsize) : (txData.feerate ? Number(txData.feerate) : null);
|
||
return {
|
||
nIn, nOut,
|
||
btc: (fluyo/1e8).toFixed(8),
|
||
bloque: txData.status?.confirmed ? txData.status.block_height : null,
|
||
fee, feeRate,
|
||
};
|
||
},[txData,fluyo]);
|
||
|
||
const seguirInput = async (i) => {
|
||
const vin=inputs[i];
|
||
const origenTxid=vin?.txid;
|
||
if(!origenTxid){ setHijos(s=>({...s,[i]:{err:"Este input no indica su transacción de origen."}})); return; }
|
||
setHijos(s=>({...s,[i]:{loading:true}}));
|
||
try{
|
||
const child=await getTx(origenTxid, depth+1);
|
||
if(!child) throw new Error("No se pudo traer la transacción de origen");
|
||
setHijos(s=>({...s,[i]:{child:{txData:child, fluyo:vin.prevout?.value||0, vout:vin.vout}}}));
|
||
}catch(e){ setHijos(s=>({...s,[i]:{err:e.message}})); }
|
||
};
|
||
|
||
// ¿Este eslabón merece atención? (tiene marca, o banda no es ALTA)
|
||
// Lo relevante se destaca; lo limpio se silencia.
|
||
const relevante = marcas.length>0 || (ana?.band && ana.band!=="ALTA");
|
||
const acento = marcas.length>0 ? marcas[0].color : bandColorRastro(ana?.band);
|
||
// Barra visual de cantidad: proporción respecto al flujo de entrada (vout origen).
|
||
// Da una idea de tamaño sin leer la cifra. Si no hay referencia, escala suave.
|
||
const pctBarra = (() => {
|
||
const ref = fluyo || 1;
|
||
const outTotal = (txData.vout||[]).reduce((s,o)=>s+(o.value||0),0) || ref;
|
||
const p = Math.min(100, Math.round((fluyo/outTotal)*100));
|
||
return isFinite(p) && p>0 ? p : 5;
|
||
})();
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
position:"relative",
|
||
padding:"10px 12px",
|
||
background: relevante ? `${acento}0d` : C.bg,
|
||
borderRadius:6,
|
||
border:`1px solid ${relevante ? acento+"55" : C.border}`,
|
||
marginTop:8,
|
||
opacity: relevante ? 1 : 0.78,
|
||
}}>
|
||
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",gap:8,flexWrap:"wrap",marginBottom:6}}>
|
||
<div style={{display:"flex",alignItems:"center",gap:8,position:"relative"}}>
|
||
<a href={mempoolUrl} target="_blank" rel="noopener noreferrer"
|
||
style={{fontSize:"0.64rem",fontFamily:"monospace",color:C.blue,textDecoration:"none",wordBreak:"break-all"}}>
|
||
{abrevTxid(txData.txid)} ↗
|
||
</a>
|
||
<button
|
||
onMouseEnter={()=>setHover(true)}
|
||
onMouseLeave={()=>setHover(false)}
|
||
onClick={()=>setHover(h=>!h)} title="Ver detalle"
|
||
style={{padding:"0 6px",background:hover?C.borderBright:"transparent",border:`1px solid ${C.borderBright}`,borderRadius:4,color:C.t2,fontFamily:"monospace",fontSize:"0.58rem",cursor:"pointer",lineHeight:"1.5"}}>
|
||
{detalle.nIn}→{detalle.nOut} ⓘ
|
||
</button>
|
||
|
||
{/* Tooltip de detalle: aparece al pasar el ratón o tocar la pastilla i→o */}
|
||
{hover&&(
|
||
<div style={{position:"absolute",zIndex:50,bottom:"100%",left:0,marginBottom:6,minWidth:250,padding:"12px 14px",background:"#0a0e14",border:`1px solid ${acento}`,borderRadius:6,boxShadow:"0 10px 36px rgba(0,0,0,0.92)"}}>
|
||
<div style={{fontSize:"0.64rem",fontFamily:"monospace",lineHeight:2}}>
|
||
<div><span style={{color:C.t1}}>Estructura </span><span style={{color:"#fff",fontWeight:700}}>{detalle.nIn} in → {detalle.nOut} out</span></div>
|
||
<div><span style={{color:C.t1}}>Fluyó </span><span style={{color:C.amber,fontWeight:700}}>{fmt.num(fluyo)} sat</span><span style={{color:C.t1}}> · </span><span style={{color:"#fff",fontWeight:700}}>{detalle.btc} BTC</span></div>
|
||
{detalle.bloque!=null
|
||
? <div><span style={{color:C.t1}}>Bloque </span><span style={{color:C.blue,fontWeight:700}}>#{fmt.num(detalle.bloque)}</span></div>
|
||
: <div><span style={{color:C.t1}}>Estado </span><span style={{color:C.amber,fontWeight:700}}>sin confirmar (mempool)</span></div>}
|
||
{detalle.fee!=null&&<div><span style={{color:C.t1}}>Fee </span><span style={{color:"#fff",fontWeight:700}}>{fmt.num(detalle.fee)} sat</span>{detalle.feeRate&&<span style={{color:C.t1}}> · {detalle.feeRate.toFixed(1)} sat/vB</span>}</div>}
|
||
{marcas.length>0&&<div style={{marginTop:4}}>{marcas.map((m,j)=>(<span key={j} style={{color:m.color,fontWeight:700,marginRight:8}}>{m.txt}</span>))}</div>}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{ana?.band&&(
|
||
<span style={{fontSize:"0.58rem",fontFamily:"monospace",fontWeight:relevante?700:400,color:bandColorRastro(ana.band),border:`1px solid ${bandColorRastro(ana.band)}${relevante?"":"40"}`,borderRadius:4,padding:"1px 7px"}}>{ana.band}</span>
|
||
)}
|
||
</div>
|
||
{/* Barra visual de cantidad: el ojo capta proporción sin leer cifras */}
|
||
<div style={{display:"flex",alignItems:"center",gap:8,marginBottom:6}}>
|
||
<div style={{flex:1,height:4,background:C.border,borderRadius:2,overflow:"hidden"}}>
|
||
<div style={{width:`${pctBarra}%`,height:"100%",background:relevante?acento:C.t3,borderRadius:2}}/>
|
||
</div>
|
||
<span style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace",whiteSpace:"nowrap"}}>
|
||
<span style={{color:C.t1}}>{fmt.num(fluyo)} sat</span>{txData.status?.block_time&&` · ${fmt.date(txData.status.block_time)}`}
|
||
</span>
|
||
</div>
|
||
{marcas.length>0&&(
|
||
<div style={{display:"flex",gap:6,flexWrap:"wrap",marginBottom:6}}>
|
||
{marcas.map((m,j)=>(<span key={j} style={{fontSize:"0.58rem",fontFamily:"monospace",fontWeight:700,color:m.color,background:`${m.color}1a`,border:`1px solid ${m.color}55`,borderRadius:4,padding:"1px 7px"}}>{m.txt}</span>))}
|
||
</div>
|
||
)}
|
||
{esCoinbase&&(
|
||
<div style={{fontSize:"0.58rem",color:C.green,marginTop:2}}>⛏️ Origen: estas monedas se crearon aquí (recompensa de minado).</div>
|
||
)}
|
||
|
||
{/* Seguir más atrás desde este eslabón (recursivo, bajo demanda) */}
|
||
{!esCoinbase&&depth<8&&(
|
||
<div style={{marginTop:8,paddingLeft:10,borderLeft:`2px solid ${C.border}`}}>
|
||
{inputs.map((vin,i)=>{
|
||
const h=hijos[i]||{};
|
||
return (
|
||
<div key={i} style={{marginBottom:6}}>
|
||
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",gap:8,flexWrap:"wrap"}}>
|
||
<span style={{fontSize:"0.6rem",fontFamily:"monospace",color:C.t2}}>↑ origen del input #{i+1} · {fmt.num(vin.prevout?.value||0)} sat</span>
|
||
{!h.child&&!h.loading&&(
|
||
<button onClick={()=>seguirInput(i)}
|
||
style={{padding:"3px 9px",background:C.purpleMuted,border:`1px solid ${C.purple}40`,borderRadius:5,color:C.purple,fontFamily:"monospace",fontSize:"0.56rem",cursor:"pointer"}}>
|
||
↓ seguir
|
||
</button>
|
||
)}
|
||
{h.loading&&<span style={{fontSize:"0.56rem",color:C.t2,fontFamily:"monospace"}}>consultando…</span>}
|
||
</div>
|
||
{h.err&&<div style={{fontSize:"0.56rem",color:C.amber,fontFamily:"monospace",marginTop:4}}>{h.err}</div>}
|
||
{h.child&&<Eslabon txData={h.child.txData} fluyo={h.child.fluyo} vout={h.child.vout} depth={depth+1} getTx={getTx} base={base}/>}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
{!esCoinbase&&depth>=8&&(
|
||
<div style={{fontSize:"0.56rem",color:C.t3,marginTop:6}}>Límite de profundidad alcanzado. Abre el txid en Mempool para seguir manualmente.</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RastroProcedencia({tx, base}) {
|
||
const {get}=useApi(base);
|
||
const cacheRef = useRef({}); // txid -> {txData, depth}, compartida
|
||
const [raiz,setRaiz]=useState({}); // {vinIndex: {loading, child, err}}
|
||
const [explorados,setExplorados]=useState(0); // contador: sube al explorar, refresca resumen
|
||
|
||
// Trae una tx (de caché o del nodo) y registra a qué profundidad se exploró.
|
||
// Compartida por todos los eslabones.
|
||
const getTx = useCallback(async (txid, depth)=>{
|
||
const cached = cacheRef.current[txid];
|
||
if(cached){
|
||
// Si ya estaba pero a más profundidad, nos quedamos con la menor (más cercana)
|
||
if(depth!=null && depth < cached.depth){ cached.depth = depth; setExplorados(n=>n+1); }
|
||
return cached.txData;
|
||
}
|
||
let data;
|
||
if(!base){ data={...MOCK_TX, txid}; }
|
||
else { data=await get(`/api/tx/${txid}`, null); }
|
||
if(data){ cacheRef.current[txid]={txData:data, depth:depth??1}; setExplorados(n=>n+1); }
|
||
return data;
|
||
},[base,get]);
|
||
|
||
// Resumen de distancia a entidades: recorre lo YA explorado en la caché
|
||
// y busca la marca más cercana de cada tipo. Honesto: solo sabe de lo
|
||
// que se ha mirado, no afirma sobre ramas sin explorar.
|
||
const resumenDistancia = useMemo(()=>{
|
||
void explorados; // dependencia: recalcular cuando se explora algo nuevo
|
||
const minDist = {}; // cat -> profundidad mínima
|
||
for(const txid in cacheRef.current){
|
||
const {txData, depth} = cacheRef.current[txid];
|
||
const ana = analyzeTx(txData);
|
||
const {marcas} = marcasDeTx(txData, ana);
|
||
for(const m of marcas){
|
||
let cat=null;
|
||
if(m.txt.includes("OFAC")) cat="⚠️ OFAC";
|
||
else if(m.txt.includes("minería")||m.txt.includes("coinbase")) cat="⛏️ minería";
|
||
else if(m.txt.includes("CoinJoin")) cat="🔀 CoinJoin";
|
||
else if(m.txt.includes("🏦")) cat="🏦 exchange";
|
||
if(cat && (minDist[cat]==null || depth<minDist[cat])) minDist[cat]=depth;
|
||
}
|
||
}
|
||
return Object.entries(minDist).map(([cat,d])=>({cat,dist:d}));
|
||
},[explorados]);
|
||
|
||
const inputs = tx.vin || [];
|
||
|
||
const seguir = async (i) => {
|
||
const vin=inputs[i];
|
||
const origenTxid=vin?.txid;
|
||
if(!origenTxid){ setRaiz(s=>({...s,[i]:{err:"Este input no indica su transacción de origen."}})); return; }
|
||
setRaiz(s=>({...s,[i]:{loading:true}}));
|
||
try{
|
||
const child=await getTx(origenTxid, 1);
|
||
if(!child) throw new Error("No se pudo traer la transacción de origen");
|
||
setRaiz(s=>({...s,[i]:{child:{txData:child, fluyo:vin.prevout?.value||0, vout:vin.vout}}}));
|
||
}catch(e){ setRaiz(s=>({...s,[i]:{err:e.message}})); }
|
||
};
|
||
|
||
const limpiar = () => { cacheRef.current={}; setRaiz({}); setExplorados(0); };
|
||
|
||
return (
|
||
<Card>
|
||
<div style={{display:"flex",alignItems:"center",gap:8,marginBottom:10}}>
|
||
<span style={{fontSize:"0.62rem",color:C.purple,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em"}}>Rastro de procedencia</span>
|
||
<div style={{flex:1,height:1,background:`linear-gradient(to right,${C.purple}40,transparent)`}}/>
|
||
{Object.keys(raiz).length>0&&(
|
||
<button onClick={limpiar} title="Vaciar el rastro y la caché de esta sesión"
|
||
style={{padding:"3px 9px",background:C.bg,border:`1px solid ${C.border}`,borderRadius:5,color:C.t2,fontFamily:"monospace",fontSize:"0.58rem",cursor:"pointer"}}>
|
||
🗑 limpiar
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div style={{fontSize:"0.62rem",color:C.t2,marginBottom:12,lineHeight:1.5}}>
|
||
Sigue cada input hacia atrás, un salto a la vez. Cada consulta la pides tú, para no sobrecargar el nodo. Desde cada eslabón puedes seguir bajando.
|
||
</div>
|
||
|
||
{resumenDistancia.length>0 ? (
|
||
<div style={{marginBottom:12,padding:"10px 12px",background:`${C.amber}0d`,borderRadius:6,border:`1px solid ${C.amber}55`}}>
|
||
<div style={{fontSize:"0.66rem",color:C.amber,fontFamily:"monospace",fontWeight:700,marginBottom:6}}>
|
||
Atención: el rastro toca {resumenDistancia.map(r=>r.cat.replace(/^.. /,"")).join(", ")}
|
||
</div>
|
||
<div style={{display:"flex",gap:8,flexWrap:"wrap"}}>
|
||
{resumenDistancia.map((r,j)=>(
|
||
<span key={j} style={{fontSize:"0.6rem",fontFamily:"monospace",color:C.t1,border:`1px solid ${C.border}`,borderRadius:4,padding:"2px 8px"}}>
|
||
{r.cat} · a {r.dist} {r.dist===1?"salto":"saltos"}
|
||
</span>
|
||
))}
|
||
</div>
|
||
<div style={{fontSize:"0.55rem",color:C.t3,marginTop:6,lineHeight:1.4}}>
|
||
Solo cuenta las ramas que has seguido. Puede haber más sin explorar.
|
||
</div>
|
||
</div>
|
||
) : explorados>0 ? (
|
||
<div style={{marginBottom:12,padding:"10px 12px",background:`${C.green}0d`,borderRadius:6,border:`1px solid ${C.green}40`}}>
|
||
<div style={{fontSize:"0.66rem",color:C.green,fontFamily:"monospace",fontWeight:700}}>
|
||
Camino limpio
|
||
</div>
|
||
<div style={{fontSize:"0.55rem",color:C.t3,marginTop:4,lineHeight:1.4}}>
|
||
De lo explorado, nada toca entidades marcadas. Puede haber más ramas sin seguir.
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{/* ¿La propia tx analizada es coinbase? Entonces es el origen: no hay
|
||
nada hacia atrás, estas monedas se crearon aquí. */}
|
||
{((tx.vin||[]).some(v=>v.is_coinbase||v.coinbase) || (tx.vin||[]).length===0 || (tx.vin||[]).every(v=>!v.txid)) ? (
|
||
<div style={{padding:"12px 14px",background:C.bg,borderRadius:6,border:`1px solid ${C.green}30`}}>
|
||
<div style={{fontSize:"0.64rem",color:C.green,fontFamily:"monospace",marginBottom:4}}>⛏️ Origen del rastro: transacción coinbase</div>
|
||
<div style={{fontSize:"0.6rem",color:C.t2,lineHeight:1.5}}>
|
||
Estas monedas se crearon aquí, como recompensa de minado. No vienen de ninguna transacción anterior — es el principio de su historia en la cadena.
|
||
</div>
|
||
</div>
|
||
) : inputs.map((vin,i)=>{
|
||
const est=raiz[i]||{};
|
||
return (
|
||
<div key={i} style={{marginBottom:10,paddingLeft:10,borderLeft:`2px solid ${C.border}`}}>
|
||
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",gap:8,flexWrap:"wrap"}}>
|
||
<div style={{fontSize:"0.65rem",fontFamily:"monospace",color:C.t1}}>
|
||
Input #{i+1}<span style={{color:C.t2,marginLeft:8}}>{fmt.num(vin.prevout?.value||0)} sat</span>
|
||
</div>
|
||
{!est.child&&!est.loading&&(
|
||
<button onClick={()=>seguir(i)}
|
||
style={{padding:"4px 10px",background:C.purpleMuted,border:`1px solid ${C.purple}40`,borderRadius:5,color:C.purple,fontFamily:"monospace",fontSize:"0.6rem",cursor:"pointer"}}>
|
||
↓ seguir rastro
|
||
</button>
|
||
)}
|
||
{est.loading&&<span style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace"}}>consultando…</span>}
|
||
</div>
|
||
{est.err&&<div style={{marginTop:6,fontSize:"0.6rem",color:C.amber,fontFamily:"monospace"}}>{est.err}</div>}
|
||
{est.child&&<Eslabon txData={est.child.txData} fluyo={est.child.fluyo} vout={est.child.vout} depth={1} getTx={getTx} base={base}/>}
|
||
</div>
|
||
);
|
||
})}
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
// ── AUDITORÍA ──────────────────────────────────────────────────────────
|
||
function Auditoria({base, initialQuery}) {
|
||
const {get}=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}/>
|
||
<RastroProcedencia tx={tx} base={base}/>
|
||
<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, analysis}) {
|
||
const c=addr.chain_stats||{funded_txo_sum:0,spent_txo_sum:0,tx_count:0};
|
||
const m=addr.mempool_stats||{funded_txo_sum:0,spent_txo_sum:0};
|
||
const balance=c.funded_txo_sum-c.spent_txo_sum;
|
||
const unconf=(m.funded_txo_sum||0)-(m.spent_txo_sum||0);
|
||
return (
|
||
<div style={{display:"flex",flexDirection:"column",gap:10}}>
|
||
<Card glow={C.green}>
|
||
<div style={{marginBottom:12}}><div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",marginBottom:4}}>DIRECCIÓN</div><div style={{fontSize:"0.72rem",fontFamily:"monospace",color:C.green,wordBreak:"break-all"}}>{addr.address}</div></div>
|
||
<div style={{display:"grid",gridTemplateColumns:"repeat(auto-fill,minmax(130px,1fr))",gap:12,paddingTop:12,borderTop:`1px solid ${C.border}`}}>
|
||
<Tag label="Balance" value={fmt.btc(balance)} color={C.green}/>
|
||
<Tag label="No confirmado" value={unconf!==0?fmt.btc(unconf):"0"} color={unconf>0?C.amber:C.t2}/>
|
||
<Tag label="Total recibido" value={fmt.btc(c.funded_txo_sum)} color={C.blue}/>
|
||
<Tag label="Total enviado" value={fmt.btc(c.spent_txo_sum)} color={C.red}/>
|
||
<Tag label="Transacciones" value={fmt.num(c.tx_count)} color={C.t1}/>
|
||
<Tag label="UTXOs" value={fmt.num(addr.utxos?addr.utxos.length:0)} color={C.amber}/>
|
||
</div>
|
||
</Card>
|
||
{analysis&&<PrivacyLab analysis={analysis}/>}
|
||
{addr.utxos&&addr.utxos.length>0&&<Card><div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:10}}>UTXOs</div>{addr.utxos.slice(0,6).map((u,i)=><div key={i} style={{display:"flex",justifyContent:"space-between",alignItems:"center",padding:"6px 0",borderBottom:i<Math.min(addr.utxos.length,6)-1?`1px solid ${C.border}`:"none"}}><span style={{fontSize:"0.68rem",fontFamily:"monospace",color:C.blue}}>{fmt.hash(u.txid)}:{u.vout}</span><div style={{display:"flex",gap:6}}><Badge color={C.green}>{fmt.btc(u.value)}</Badge><Badge color={u.status&&u.status.confirmed?C.t2:C.amber}>{u.status&&u.status.confirmed?`#${fmt.num(u.status.block_height)}`:"mempool"}</Badge></div></div>)}</Card>}
|
||
{addr.txs&&addr.txs.length>0&&<Card><div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:10}}>Transacciones Recientes</div>{addr.txs.slice(0,8).map((tx,i)=>{const out=tx.vout?tx.vout.reduce((s,v)=>s+v.value,0):0;return<div key={i} style={{display:"flex",justifyContent:"space-between",alignItems:"center",padding:"7px 0",borderBottom:i<Math.min(addr.txs.length,8)-1?`1px solid ${C.border}`:"none"}}><div><div style={{fontSize:"0.68rem",fontFamily:"monospace",color:C.blue}}>{fmt.hash(tx.txid)}</div><div style={{fontSize:"0.6rem",color:C.t2,marginTop:2}}>{tx.status&&tx.status.block_time?fmt.date(tx.status.block_time):"pendiente"}</div></div><div style={{display:"flex",gap:6}}><Badge color={tx.status&&tx.status.confirmed?C.green:C.amber}>{tx.status&&tx.status.confirmed?"conf":"pend"}</Badge><Badge color={C.t2}>{fmt.btc(out)}</Badge></div></div>;})}</Card>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function BlockResult({block}) {
|
||
return <Card glow={C.purple}><div style={{marginBottom:12}}><div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",marginBottom:4}}>BLOQUE</div><div style={{fontSize:"0.88rem",color:C.purple,fontFamily:"monospace",fontWeight:700}}>#{fmt.num(block.height)}</div><div style={{fontSize:"0.68rem",fontFamily:"monospace",color:C.t2,wordBreak:"break-all",marginTop:4}}>{block.id}</div></div><div style={{display:"grid",gridTemplateColumns:"repeat(auto-fill,minmax(130px,1fr))",gap:12,paddingTop:12,borderTop:`1px solid ${C.border}`}}><Tag label="Transacciones" value={fmt.num(block.tx_count)} color={C.blue}/><Tag label="Tamaño" value={fmt.mb(block.size)} color={C.t1}/><Tag label="Fees totales" value={block.extras&&block.extras.totalFees!=null?fmt.sbtc(block.extras.totalFees):"-"} color={C.amber}/><Tag label="Minero" value={block.extras&&block.extras.pool?block.extras.pool.name:"-"} color={C.green}/><Tag label="Fecha" value={fmt.date(block.timestamp)} color={C.t1}/></div></Card>;
|
||
}
|
||
|
||
function ConfigModal({config,onSave,onClose}) {
|
||
const [url,setUrl]=useState(config.url||"");
|
||
const [name,setName]=useState(config.name||"");
|
||
const [remember,setRemember]=useState(()=>!!localStorage.getItem("txoko-config"));
|
||
const [testing,setTesting]=useState(false);
|
||
const [testResult,setTestResult]=useState(null);
|
||
const testConn=async()=>{
|
||
setTesting(true);setTestResult(null);
|
||
try{ const res=await fetchWithTimeout(`${url.replace(/\/$/,"")}/api/v1/fees/recommended`); if(res.ok)setTestResult({ok:true,msg:"Conexión exitosa ✓"}); else setTestResult({ok:false,msg:`Error HTTP ${res.status}`}); }
|
||
catch(e){ const msg = e.name==="AbortError" ? "Sin respuesta (timeout) — ¿es correcta la URL y está el nodo accesible?" : `No se pudo conectar: ${e.message}`; setTestResult({ok:false,msg}); }
|
||
setTesting(false);
|
||
};
|
||
const handleSave=()=>{
|
||
const cfg={url:url.replace(/\/$/,""),name:name||"Mi nodo"};
|
||
if(remember){ localStorage.setItem("txoko-config",JSON.stringify(cfg)); }
|
||
else { localStorage.removeItem("txoko-config"); }
|
||
onSave(cfg);
|
||
};
|
||
return (
|
||
<div style={{position:"fixed",inset:0,background:"rgba(5,8,13,0.95)",display:"flex",alignItems:"center",justifyContent:"center",zIndex:100,padding:16}}>
|
||
<Card style={{maxWidth:440,width:"100%"}}>
|
||
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:20}}>
|
||
<span style={{fontFamily:"monospace",color:C.green,fontSize:"0.82rem",fontWeight:700}}>CONFIGURACIÓN</span>
|
||
<button onClick={onClose} style={{background:"none",border:"none",color:C.t2,cursor:"pointer",fontSize:"1rem",fontFamily:"monospace"}}>✕</button>
|
||
</div>
|
||
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",marginBottom:6}}>Nombre del nodo</div>
|
||
<input value={name} onChange={e=>setName(e.target.value)} placeholder="Mi nodo"
|
||
style={{width:"100%",background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"10px 12px",color:C.t1,fontFamily:"monospace",fontSize:"0.8rem",outline:"none",boxSizing:"border-box",marginBottom:14}}/>
|
||
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",marginBottom:6}}>URL de tu Mempool</div>
|
||
<input value={url} onChange={e=>setUrl(e.target.value)} placeholder="http://100.x.x.x:4080"
|
||
style={{width:"100%",background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"10px 12px",color:C.t1,fontFamily:"monospace",fontSize:"0.8rem",outline:"none",boxSizing:"border-box",marginBottom:8}}/>
|
||
<div style={{fontSize:"0.65rem",color:C.t2,marginBottom:12,lineHeight:1.6}}>IP Tailscale del mini PC + puerto 4080 · Ej: http://100.64.0.5:4080</div>
|
||
{/* Checkbox recordar */}
|
||
<label style={{display:"flex",alignItems:"flex-start",gap:10,marginBottom:16,cursor:"pointer"}}>
|
||
<input type="checkbox" checked={remember} onChange={e=>setRemember(e.target.checked)}
|
||
style={{marginTop:2,accentColor:C.amber,cursor:"pointer",flexShrink:0}}/>
|
||
<div>
|
||
<div style={{fontSize:"0.72rem",color:remember?C.amber:C.t2,fontFamily:"monospace",fontWeight:remember?700:400}}>
|
||
Recordar configuración
|
||
</div>
|
||
<div style={{fontSize:"0.62rem",color:C.t3,marginTop:3,lineHeight:1.5}}>
|
||
{remember
|
||
? "La URL y nombre se guardarán en este dispositivo. No se almacena ningún dato del nodo, solo la dirección."
|
||
: "Sin marcar, deberás introducir la URL cada vez que abras el dashboard."}
|
||
</div>
|
||
</div>
|
||
</label>
|
||
{testResult&&<div style={{padding:"8px 12px",borderRadius:6,fontFamily:"monospace",fontSize:"0.72rem",marginBottom:14,background:testResult.ok?C.greenMuted:C.redMuted,color:testResult.ok?C.green:C.red}}>{testResult.msg}</div>}
|
||
<div style={{display:"flex",gap:8}}>
|
||
<button onClick={testConn} disabled={!url||testing} style={{flex:1,padding:10,background:C.blueMuted,border:`1px solid ${C.blue}40`,borderRadius:6,color:C.blue,fontFamily:"monospace",fontSize:"0.75rem",cursor:"pointer",fontWeight:700}}>{testing?"PROBANDO···":"PROBAR"}</button>
|
||
<button onClick={handleSave} disabled={!url} style={{flex:1,padding:10,background:C.greenMuted,border:`1px solid ${C.green}40`,borderRadius:6,color:C.green,fontFamily:"monospace",fontSize:"0.75rem",cursor:"pointer",fontWeight:700}}>GUARDAR</button>
|
||
<button onClick={onClose} style={{padding:"10px 14px",background:"none",border:`1px solid ${C.border}`,borderRadius:6,color:C.t2,fontFamily:"monospace",fontSize:"0.75rem",cursor:"pointer"}}>✕</button>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── TOOLS ──────────────────────────────────────────────────────────────
|
||
function Tools({base}) {
|
||
const [activeTool, setActiveTool] = useState("converter");
|
||
|
||
const tools = [
|
||
{id:"converter", label:"Conversor", icon:"⇄"},
|
||
{id:"validator", label:"Validar dirección", icon:"✓"},
|
||
{id:"op_return", label:"OP_RETURN", icon:"🔍"},
|
||
{id:"psbt", label:"PSBT", icon:"🔍"},
|
||
{id:"rawtx", label:"Tx Raw", icon:"⬡"},
|
||
{id:"verifysig", label:"Verificar firma", icon:"🔐"},
|
||
];
|
||
|
||
return (
|
||
<div style={{display:"flex",flexDirection:"column",gap:12}}>
|
||
<SectionTitle accent={C.blue} icon="⚙">TOOLS</SectionTitle>
|
||
{/* Tool selector */}
|
||
<div style={{display:"flex",gap:6,flexWrap:"wrap"}}>
|
||
{tools.map(t=>(
|
||
<button key={t.id} onClick={()=>setActiveTool(t.id)} style={{
|
||
padding:"7px 14px", borderRadius:6, border:`1px solid ${activeTool===t.id?C.blue+"60":C.border}`,
|
||
background: activeTool===t.id ? C.blueMuted : C.bgCard,
|
||
color: activeTool===t.id ? C.blue : C.t2,
|
||
fontFamily:"monospace", fontSize:"0.7rem", cursor:"pointer",
|
||
display:"flex", alignItems:"center", gap:5,
|
||
}}>
|
||
<span>{t.icon}</span>{t.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{activeTool==="converter" && <ToolConverter base={base}/>}
|
||
{activeTool==="validator" && <ToolValidator/>}
|
||
{activeTool==="op_return" && <ToolOpReturn base={base}/>}
|
||
{activeTool==="psbt" && <ToolPsbt/>}
|
||
{activeTool==="rawtx" && <ToolRawTx/>}
|
||
{activeTool==="verifysig" && <ToolVerifySig/>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Conversor sat/BTC/EUR/USD ──────────────────────────────────────────
|
||
function ToolConverter({base}) {
|
||
const [sat, setSat] = useState("");
|
||
const [price, setPrice] = useState(null);
|
||
const {get} = useApi(base);
|
||
|
||
useEffect(()=>{
|
||
get("/api/v1/prices", null).then(d=>{
|
||
if(d&&d.USD) setPrice(d);
|
||
}).catch(()=>{});
|
||
},[base]);
|
||
|
||
const sats = parseInt(sat.replace(/[^0-9]/g,""))||0;
|
||
const btc = sats/1e8;
|
||
const usd = price ? (btc * price.USD).toFixed(2) : null;
|
||
const eur = price ? (btc * price.EUR).toFixed(2) : null;
|
||
|
||
const handleBtc = v => { const n=parseFloat(v)||0; setSat(Math.round(n*1e8).toString()); };
|
||
const handleUsd = v => { if(!price) return; const n=parseFloat(v)||0; setSat(Math.round((n/price.USD)*1e8).toString()); };
|
||
|
||
return (
|
||
<Card>
|
||
<div style={{fontSize:"0.62rem",color:C.blue,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:14}}>Conversor sat / BTC / EUR / USD</div>
|
||
<div style={{display:"flex",flexDirection:"column",gap:10}}>
|
||
{[
|
||
{label:"Satoshis", value:sats?fmt.num(sats):"", placeholder:"100000", onChange:e=>setSat(e.target.value.replace(/[^0-9]/g,"")), color:C.amber},
|
||
{label:"BTC", value:sats?(btc.toFixed(8)):"", placeholder:"0.00100000", onChange:e=>handleBtc(e.target.value), color:C.green},
|
||
{label:"USD", value:usd&&sats?usd:"", placeholder:price?"precio real":"sin precio", onChange:e=>handleUsd(e.target.value), color:C.blue, disabled:!price},
|
||
{label:"EUR", value:eur&&sats?eur:"", placeholder:price?"precio real":"sin precio", color:C.purple, disabled:true},
|
||
].map(f=>(
|
||
<div key={f.label} style={{display:"flex",alignItems:"center",gap:10}}>
|
||
<span style={{fontSize:"0.7rem",color:C.t2,fontFamily:"monospace",width:60,textAlign:"right",flexShrink:0}}>{f.label}</span>
|
||
<input value={f.value} onChange={f.onChange||(()=>{})} placeholder={f.placeholder}
|
||
disabled={f.disabled}
|
||
style={{flex:1,background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"9px 12px",color:f.color,fontFamily:"monospace",fontSize:"0.85rem",fontWeight:700,outline:"none",opacity:f.disabled?0.5:1}}
|
||
onFocus={e=>!f.disabled&&(e.target.style.borderColor=f.color)}
|
||
onBlur={e=>e.target.style.borderColor=C.borderBright}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{price&&<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",marginTop:10}}>Precio BTC: ${fmt.num(price.USD)} USD · €{fmt.num(price.EUR)} EUR · desde tu nodo</div>}
|
||
{!price&&base&&<div style={{fontSize:"0.62rem",color:C.amber,fontFamily:"monospace",marginTop:10}}>Sin precio de mercado — activa FIAT_PRICE en tu Mempool para USD/EUR</div>}
|
||
{!base&&<div style={{fontSize:"0.62rem",color:C.amber,fontFamily:"monospace",marginTop:10}}>Conecta tu nodo para ver precios en tiempo real</div>}
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
// ── Validador de dirección ─────────────────────────────────────────────
|
||
function ToolValidator() {
|
||
const [addr, setAddr] = useState("");
|
||
const [result, setResult] = useState(null);
|
||
|
||
const validate = () => {
|
||
const a = addr.trim();
|
||
if (!a) return;
|
||
let type=null, network=null, valid=false, details=[];
|
||
|
||
if (/^1[1-9A-HJ-NP-Za-km-z]{25,34}$/.test(a)) {
|
||
type="P2PKH (Legacy)"; network="mainnet"; valid=true;
|
||
details=["Formato Legacy — el más antiguo","Fees más altos que SegWit","Menor privacidad que tipos modernos"];
|
||
} else if (/^3[1-9A-HJ-NP-Za-km-z]{25,34}$/.test(a)) {
|
||
type="P2SH"; network="mainnet"; valid=true;
|
||
details=["Puede ser multisig o wrapped SegWit","Fees intermedios","Formato ampliamente compatible"];
|
||
} else if (/^bc1q[0-9a-z]{38,59}$/.test(a)) {
|
||
type="P2WPKH (Native SegWit)"; network="mainnet"; valid=true;
|
||
details=["SegWit nativo — fees reducidos","Buena privacidad","Formato recomendado para pagos"];
|
||
} else if (/^bc1p[0-9a-z]{58}$/.test(a)) {
|
||
type="P2TR (Taproot)"; network="mainnet"; valid=true;
|
||
details=["Taproot — máxima privacidad","Scripts complejos indistinguibles de pagos simples","El tipo más moderno y privado"];
|
||
} else if (/^tb1[0-9a-z]{38,}$/.test(a)) {
|
||
type="Testnet SegWit"; network="testnet"; valid=true;
|
||
details=["Dirección de testnet","No usar en mainnet"];
|
||
} else if (/^m[1-9A-HJ-NP-Za-km-z]{25,34}$/.test(a)||/^n[1-9A-HJ-NP-Za-km-z]{25,34}$/.test(a)) {
|
||
type="P2PKH Testnet"; network="testnet"; valid=true;
|
||
details=["Dirección legacy de testnet"];
|
||
} else {
|
||
valid=false;
|
||
details=["Formato no reconocido","Verifica que hayas copiado la dirección completa"];
|
||
}
|
||
|
||
setResult({addr:a, type, network, valid, details});
|
||
};
|
||
|
||
return (
|
||
<Card>
|
||
<div style={{fontSize:"0.62rem",color:C.blue,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:14}}>Validador de Dirección</div>
|
||
<div style={{display:"flex",gap:8,marginBottom:12}}>
|
||
<input value={addr} onChange={e=>setAddr(e.target.value)} onKeyDown={e=>e.key==="Enter"&&validate()}
|
||
placeholder="Pega una dirección Bitcoin para validar"
|
||
style={{flex:1,background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"9px 12px",color:C.t1,fontFamily:"monospace",fontSize:"0.75rem",outline:"none"}}
|
||
onFocus={e=>e.target.style.borderColor=C.blue} onBlur={e=>e.target.style.borderColor=C.borderBright}
|
||
/>
|
||
<button onClick={validate} style={{padding:"9px 16px",background:C.blueMuted,border:`1px solid ${C.blue}40`,borderRadius:6,color:C.blue,fontFamily:"monospace",fontSize:"0.72rem",cursor:"pointer",fontWeight:700}}>VALIDAR</button>
|
||
</div>
|
||
{result&&(
|
||
<div style={{padding:"12px 14px",background:C.bg,borderRadius:6,border:`1px solid ${result.valid?C.green:C.red}40`}}>
|
||
<div style={{display:"flex",alignItems:"center",gap:8,marginBottom:10}}>
|
||
<span style={{fontSize:"1rem",color:result.valid?C.green:C.red}}>{result.valid?"✓":"✗"}</span>
|
||
<span style={{fontSize:"0.8rem",color:result.valid?C.green:C.red,fontFamily:"monospace",fontWeight:700}}>{result.valid?"Dirección válida":"Dirección inválida"}</span>
|
||
{result.network&&<Badge color={result.network==="mainnet"?C.green:C.amber}>{result.network}</Badge>}
|
||
</div>
|
||
{result.type&&<div style={{fontSize:"0.75rem",color:C.blue,fontFamily:"monospace",marginBottom:10}}>{result.type}</div>}
|
||
<div style={{display:"flex",flexDirection:"column",gap:4}}>
|
||
{result.details.map((d,i)=>(
|
||
<div key={i} style={{display:"flex",alignItems:"center",gap:6}}>
|
||
<span style={{color:C.t2,fontSize:"0.65rem"}}>·</span>
|
||
<span style={{fontSize:"0.68rem",color:C.t2}}>{d}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
// ── Detector OP_RETURN ─────────────────────────────────────────────────
|
||
// Indica SI hay datos arbitrarios y su tamaño, pero NO decodifica ni
|
||
// muestra el contenido. Para auditar privacidad lo relevante es QUE HAYA
|
||
// un OP_RETURN (revela uso de protocolo/servicio), no qué dice. Además,
|
||
// evita ser un visor de contenido arbitrario de la cadena.
|
||
function ToolOpReturn({base}) {
|
||
const {get} = useApi(base);
|
||
const [txid, setTxid] = useState("");
|
||
const [result, setResult] = useState(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [err, setErr] = useState(null);
|
||
|
||
const decode = async () => {
|
||
const q = txid.trim();
|
||
if (!q) return;
|
||
setLoading(true); setResult(null); setErr(null);
|
||
try {
|
||
const tx = await get(`/api/tx/${q}`, null);
|
||
if (!tx) throw new Error("Transacción no encontrada");
|
||
const opReturns = tx.vout.filter(o=>o.scriptpubkey_type==="op_return"||o.scriptpubkey?.startsWith("6a"));
|
||
if (opReturns.length===0) { setErr("Esta transacción no contiene outputs OP_RETURN"); setLoading(false); return; }
|
||
// Solo medimos el tamaño en bytes. NO decodificamos el contenido.
|
||
const detected = opReturns.map(o=>{
|
||
const hex = o.scriptpubkey?.slice(4)||"";
|
||
const bytes = Math.floor(hex.length/2);
|
||
return { bytes };
|
||
});
|
||
setResult({ txid:tx.txid, count:opReturns.length, outputs:detected });
|
||
} catch(e) { setErr(e.message); }
|
||
setLoading(false);
|
||
};
|
||
|
||
return (
|
||
<Card>
|
||
<div style={{fontSize:"0.62rem",color:C.blue,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:8}}>Detector OP_RETURN</div>
|
||
<div style={{fontSize:"0.65rem",color:C.t2,marginBottom:12}}>Indica si una transacción contiene datos arbitrarios (OP_RETURN) y su tamaño.</div>
|
||
<div style={{display:"flex",gap:8,marginBottom:12}}>
|
||
<input value={txid} onChange={e=>setTxid(e.target.value)} onKeyDown={e=>e.key==="Enter"&&decode()}
|
||
placeholder="txid de una transacción"
|
||
style={{flex:1,background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"9px 12px",color:C.t1,fontFamily:"monospace",fontSize:"0.75rem",outline:"none"}}
|
||
onFocus={e=>e.target.style.borderColor=C.blue} onBlur={e=>e.target.style.borderColor=C.borderBright}
|
||
/>
|
||
<button onClick={decode} style={{padding:"9px 16px",background:C.blueMuted,border:`1px solid ${C.blue}40`,borderRadius:6,color:C.blue,fontFamily:"monospace",fontSize:"0.72rem",cursor:"pointer",fontWeight:700}}>{loading?"···":"DETECTAR"}</button>
|
||
</div>
|
||
{err&&<div style={{padding:"8px 12px",background:C.redMuted,border:`1px solid ${C.red}30`,borderRadius:6,color:C.red,fontFamily:"monospace",fontSize:"0.72rem"}}>{err}</div>}
|
||
{result&&(
|
||
<div style={{padding:"12px 14px",background:C.bg,borderRadius:6,border:`1px solid ${C.border}`,marginTop:8}}>
|
||
<div style={{fontSize:"0.8rem",color:C.amber,fontFamily:"monospace",marginBottom:8}}>
|
||
Contiene {result.count} output{result.count>1?"s":""} OP_RETURN con datos arbitrarios.
|
||
</div>
|
||
{result.outputs.map((o,i)=>(
|
||
<div key={i} style={{fontSize:"0.65rem",color:C.t2,fontFamily:"monospace",marginTop:4}}>
|
||
OP_RETURN #{i+1}: {o.bytes} bytes de datos
|
||
</div>
|
||
))}
|
||
<div style={{fontSize:"0.6rem",color:C.t3,marginTop:10,lineHeight:1.5}}>
|
||
Se indica la presencia y el tamaño, no el contenido. Un OP_RETURN puede revelar qué protocolo o servicio se usó — ese es el dato relevante para tu privacidad.
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
// ── 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 (0–100) 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.t2,letterSpacing:"0.05em"}}>· your node, your rules</span>
|
||
</footer>
|
||
{showConfig&&<ConfigModal config={config} onSave={cfg=>{setConfig(cfg);setShowConfig(false);}} onClose={()=>setShowConfig(false)}/>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
|
||
</script>
|
||
</body>
|
||
</html>
|