feat: watch-only por xpub — derivación BIP32 local, identifica outputs propios (recepción/cambio), selector 20/50/100 direcciones

This commit is contained in:
Aitor
2026-06-04 19:38:15 +02:00
parent 57291a381b
commit 3816b0426e
+402 -7
View File
@@ -1018,6 +1018,273 @@
return results;
}
// ── BIP32 / bech32 — derivación watch-only ────────────────────────────────
// Implementación mínima usando Web Crypto API (nativa del navegador).
// No hay dependencias externas. Todo ocurre en memoria, nada sale del nodo.
// Utilidades de bytes
const B32 = {
// Decodifica base58check → Uint8Array (sin la checksum de 4 bytes)
decodeBase58: (str) => {
const ALPHA = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
let n = 0n;
for (const c of str) {
const idx = ALPHA.indexOf(c);
if (idx < 0) throw new Error("base58 inválido: " + c);
n = n * 58n + BigInt(idx);
}
// Convertir el entero a bytes
let hex = n.toString(16);
if (hex.length % 2) hex = "0" + hex;
const body = hex.length ? hex.match(/.{2}/g).map(b => parseInt(b, 16)) : [];
// Cada '1' inicial en base58 representa un byte 0x00
let leadingZeros = 0;
for (const c of str) { if (c === "1") leadingZeros++; else break; }
const full = new Uint8Array(leadingZeros + body.length);
full.set(body, leadingZeros);
// full = 82 bytes (78 payload + 4 checksum); devolvemos el payload
return full.slice(0, full.length - 4);
},
// SHA256 doble (para checksum base58)
sha256d: async (data) => {
const h1 = await crypto.subtle.digest("SHA-256", data);
return new Uint8Array(await crypto.subtle.digest("SHA-256", h1));
},
// HMAC-SHA512
hmac512: async (key, data) => {
const k = await crypto.subtle.importKey("raw", key, {name:"HMAC",hash:"SHA-512"}, false, ["sign"]);
return new Uint8Array(await crypto.subtle.sign("HMAC", k, data));
},
// Serializar entero 32 bits big-endian
u32be: (n) => {
const b = new Uint8Array(4);
b[0]=(n>>>24)&0xff; b[1]=(n>>>16)&0xff; b[2]=(n>>>8)&0xff; b[3]=n&0xff;
return b;
},
// Concatenar Uint8Arrays
concat: (...arrays) => {
const len = arrays.reduce((s,a) => s + a.length, 0);
const out = new Uint8Array(len);
let off = 0;
for (const a of arrays) { out.set(a, off); off += a.length; }
return out;
},
};
// Aritmética de curva secp256k1 (solo lo necesario: punto + escalar)
const SECP = (() => {
const P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2Fn;
const N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141n;
const Gx = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798n;
const Gy = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8n;
const mod = (a, m=P) => { const r = a % m; return r < 0n ? r + m : r; };
// Inverso modular por el pequeño teorema de Fermat (robusto con BigInt)
const modPow = (b, e, m) => {
let r = 1n; b = mod(b, m);
while (e > 0n) { if (e & 1n) r = mod(r * b, m); e >>= 1n; b = mod(b * b, m); }
return r;
};
const inv = (a, m=P) => modPow(mod(a, m), m - 2n, m);
const addPoints = (P1, P2) => {
if (!P1) return P2; if (!P2) return P1;
const [x1,y1] = P1, [x2,y2] = P2;
if (x1 === x2 && mod(y1 + y2) === 0n) return null; // P + (-P) = infinito
let lam;
if (x1 === x2 && y1 === y2) {
lam = mod(3n * x1 * x1 * inv(2n * y1)); // duplicación
} else {
lam = mod((y2 - y1) * inv(mod(x2 - x1))); // suma de distintos
}
const x3 = mod(lam * lam - x1 - x2);
return [x3, mod(lam * (x1 - x3) - y1)];
};
const mulPoint = (k, pt) => {
let result = null, addend = pt;
while (k > 0n) {
if (k & 1n) result = addPoints(result, addend);
addend = addPoints(addend, addend);
k >>= 1n;
}
return result;
};
// Comprimir punto (33 bytes)
const compress = ([x, y]) => {
const xBytes = new Uint8Array(32);
let xn = x;
for (let i = 31; i >= 0; i--) { xBytes[i] = Number(xn & 0xffn); xn >>= 8n; }
const prefix = new Uint8Array([y & 1n ? 3 : 2]);
return B32.concat(prefix, xBytes);
};
// Descomprimir punto desde clave pública comprimida (33 bytes)
const decompress = (bytes) => {
const prefix = bytes[0];
let x = 0n;
for (let i = 1; i < 33; i++) x = (x << 8n) | BigInt(bytes[i]);
const y2 = mod(x * x * x + 7n);
let y = modPow(y2, (P + 1n) / 4n, P);
if ((y & 1n) !== BigInt(prefix & 1)) y = P - y;
return [x, y];
};
return { P, N, G: [Gx, Gy], addPoints, mulPoint, compress, decompress };
})();
// Derivación BIP32 de clave pública hija (solo clave pública, sin hardened)
const deriveChildPubkey = async (parentPub, parentChain, index) => {
const indexBytes = B32.u32be(index); // index < 0x80000000 (no hardened)
const data = B32.concat(parentPub, indexBytes);
const I = await B32.hmac512(parentChain, data);
const IL = I.slice(0, 32);
const IR = I.slice(32);
// childKey = point(IL) + parentPub
let il = 0n;
for (const b of IL) il = (il << 8n) | BigInt(b);
if (il >= SECP.N) throw new Error("derivación inválida");
const ilPoint = SECP.mulPoint(il, SECP.G);
const parentPoint = SECP.decompress(parentPub);
const childPoint = SECP.addPoints(ilPoint, parentPoint);
if (!childPoint) throw new Error("punto infinito");
return { pub: SECP.compress(childPoint), chain: IR };
};
// Hash160 = RIPEMD160(SHA256(data))
// Implementamos RIPEMD160 mínimo ya que WebCrypto no lo incluye
const ripemd160 = (data) => {
// Implementación RIPEMD-160 completa
const KL = [0x00000000,0x5A827999,0x6ED9EBA1,0x8F1BBCDC,0xA953FD4E];
const KR = [0x50A28BE6,0x5C4DD124,0x6D703EF3,0x7A6D76E9,0x00000000];
const RL = [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,7,4,13,1,10,6,15,3,12,0,9,5,2,14,11,8,3,10,14,4,9,15,8,1,2,7,0,6,13,11,5,12,1,9,11,10,0,8,12,4,13,3,7,15,14,5,6,2,4,0,5,9,7,12,2,10,14,1,3,8,11,6,15,13];
const RR = [5,14,7,0,9,2,11,4,13,6,15,8,1,10,3,12,6,11,3,7,0,13,5,10,14,15,8,12,4,9,1,2,15,5,1,3,7,14,6,9,11,8,12,2,10,0,4,13,8,6,4,1,3,11,15,0,5,12,2,13,9,7,10,14,12,15,10,4,1,5,8,7,6,2,13,14,0,3,9,11];
const SL = [11,14,15,12,5,8,7,9,11,13,14,15,6,7,9,8,7,6,8,13,11,9,7,15,7,12,15,9,11,7,13,12,11,13,6,7,14,9,13,15,14,8,13,6,5,12,7,5,11,12,14,15,14,15,9,8,9,14,5,6,8,6,5,12,9,15,5,11,6,8,13,12,5,12,13,14,11,8,5,6];
const SR = [8,9,9,11,13,15,15,5,7,7,8,11,14,14,12,6,9,13,15,7,12,8,9,11,7,7,12,7,6,15,13,11,9,7,15,11,8,6,6,14,12,13,5,14,13,13,7,5,15,5,8,11,14,14,6,14,6,9,12,9,12,5,15,8,8,5,12,9,12,5,14,6,8,13,6,5,15,13,11,11];
const f = (j,x,y,z) => j<16?(x^y^z):j<32?((x&y)|(~x&z)):j<48?((x|~y)^z):j<64?((x&z)|(y&~z)):(x^(y|~z));
const rol = (x,n) => ((x<<n)|(x>>>(32-n)))>>>0;
// Padding
const msgLen = data.length;
const padLen = ((msgLen + 8) % 64 <= 55) ? 55 - (msgLen % 64) : 119 - (msgLen % 64);
const padded = new Uint8Array(msgLen + padLen + 9);
padded.set(data);
padded[msgLen] = 0x80;
const bitLen = msgLen * 8;
padded[padded.length-8] = bitLen & 0xff;
padded[padded.length-7] = (bitLen>>>8) & 0xff;
padded[padded.length-6] = (bitLen>>>16) & 0xff;
padded[padded.length-5] = (bitLen>>>24) & 0xff;
let [h0,h1,h2,h3,h4] = [0x67452301,0xEFCDAB89,0x98BADCFE,0x10325476,0xC3D2E1F0];
for (let i = 0; i < padded.length; i += 64) {
const X = new Int32Array(16);
for (let j = 0; j < 16; j++) {
X[j] = padded[i+j*4] | (padded[i+j*4+1]<<8) | (padded[i+j*4+2]<<16) | (padded[i+j*4+3]<<24);
}
let [al,bl,cl,dl,el] = [h0,h1,h2,h3,h4];
let [ar,br,cr,dr,er] = [h0,h1,h2,h3,h4];
for (let j = 0; j < 80; j++) {
const jj = Math.floor(j/16);
let T = rol((al + f(j,bl,cl,dl) + X[RL[j]] + KL[jj])>>>0, SL[j]);
T = (T + el)>>>0; al=el; el=dl; dl=rol(cl,10); cl=bl; bl=T;
T = rol((ar + f(79-j,br,cr,dr) + X[RR[j]] + KR[jj])>>>0, SR[j]);
T = (T + er)>>>0; ar=er; er=dr; dr=rol(cr,10); cr=br; br=T;
}
const T = (h1 + cl + dr)>>>0;
h1 = (h2 + dl + er)>>>0; h2 = (h3 + el + ar)>>>0;
h3 = (h4 + al + br)>>>0; h4 = (h0 + bl + cr)>>>0; h0 = T;
}
const out = new Uint8Array(20);
const view = new DataView(out.buffer);
[h0,h1,h2,h3,h4].forEach((h,i) => view.setUint32(i*4, h, true));
return out;
};
const hash160 = async (pubkey) => {
const sha = new Uint8Array(await crypto.subtle.digest("SHA-256", pubkey));
return ripemd160(sha);
};
// Bech32 — generar dirección bc1q desde hash160
const toBech32 = (hrp, data) => {
const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
const polymod = (values) => {
const GEN = [0x3b6a57b2,0x26508e6d,0x1ea119fa,0x3d4233dd,0x2a1462b3];
let chk = 1;
for (const v of values) {
const b = chk >> 25;
chk = ((chk & 0x1ffffff) << 5) ^ v;
for (let i = 0; i < 5; i++) if ((b >> i) & 1) chk ^= GEN[i];
}
return chk;
};
const hrpExpand = (hrp) => {
const ret = [];
for (const c of hrp) ret.push(c.charCodeAt(0) >> 5);
ret.push(0);
for (const c of hrp) ret.push(c.charCodeAt(0) & 31);
return ret;
};
const createChecksum = (hrp, data) => {
const values = [...hrpExpand(hrp), ...data, 0, 0, 0, 0, 0, 0];
const mod = polymod(values) ^ 1;
return Array.from({length:6}, (_,i) => (mod >> (5*(5-i))) & 31);
};
const convertbits = (data, frombits, tobits, pad=true) => {
let acc = 0, bits = 0;
const ret = [];
const maxv = (1 << tobits) - 1;
for (const v of data) {
acc = (acc << frombits) | v;
bits += frombits;
while (bits >= tobits) { bits -= tobits; ret.push((acc >> bits) & maxv); }
}
if (pad && bits > 0) ret.push((acc << (tobits - bits)) & maxv);
return ret;
};
const words = [0, ...convertbits(data, 8, 5)]; // version 0
const checksum = createChecksum(hrp, words);
return hrp + "1" + [...words, ...checksum].map(d => CHARSET[d]).join("");
};
// Derivar N direcciones desde xpub (rama 0=recepción, 1=cambio)
const deriveAddresses = async (xpubStr, count=20) => {
// Decodificar xpub/zpub
const raw = B32.decodeBase58(xpubStr.trim());
// raw[0..3]=version, [4]=depth, [5..8]=fingerprint, [9..12]=childIndex
// [13..44]=chainCode, [45..77]=pubKey
const chainCode = raw.slice(13, 45);
const pubKey = raw.slice(45, 78);
// Parent fingerprint (bytes 5-8): lo que Sparrow muestra junto al keystore
const fingerprint = Array.from(raw.slice(5, 9)).map(b => b.toString(16).padStart(2,"0")).join("");
const hrp = xpubStr.startsWith("tb") || xpubStr.startsWith("u") || xpubStr.startsWith("v") ? "tb" : "bc";
const result = { receive: [], change: [], fingerprint };
for (const [branch, label] of [[0,"receive"],[1,"change"]]) {
// Derivar la clave de rama (m/branch)
const branchKey = await deriveChildPubkey(pubKey, chainCode, branch);
for (let i = 0; i < count; i++) {
const childKey = await deriveChildPubkey(branchKey.pub, branchKey.chain, i);
const h160 = await hash160(childKey.pub);
result[label].push(toBech32(hrp, Array.from(h160)));
}
}
return result;
};
function analyzeTx(tx) {
const checks = [];
const weights = {
@@ -1606,6 +1873,13 @@
const checks = []; let score = 100;
const txCount = addr.chain_stats ? addr.chain_stats.tx_count : 0;
const utxoCount = addr.utxos ? addr.utxos.length : 0;
// Dirección sin usar: no tiene privacidad buena ni mala, simplemente está virgen
if (txCount === 0) {
checks.push({ id:"unused", label:"Dirección sin usar", certainty:"CERTEZA", pass:true, detail:"Esta dirección no tiene historial on-chain. No ha recibido ni enviado fondos.", penalty:0, informational:true });
return { score:null, checks, band:"NUEVA", summary:"Dirección sin usar", summaryColor:C.t2, wallets:[], unused:true };
}
const reused = txCount > 1;
const penalty = txCount > 10 ? 40 : txCount > 3 ? 25 : 15;
checks.push({ id:"addr_reuse", label:"Reutilización de dirección", certainty:"CERTEZA", pass:!reused, detail: reused ? `Usada en ${txCount} transacciones. Permite vincular pagos y construir un perfil.` : "Usada una sola vez. Correcto.", penalty });
@@ -1689,6 +1963,9 @@
function generateReport(analysis, tx) {
const {score, checks} = analysis;
// Detectar si el objeto analizado es una dirección en vez de una transacción
const isAddr = !!(tx && (tx.address || tx.chain_stats));
const noun = isAddr ? "dirección" : "transacción";
const failed = checks.filter(c => !c.pass && !c.informational);
const critical = failed.filter(c => c.certainty === "CERTEZA");
const probable = failed.filter(c => c.certainty === "PROBABLE");
@@ -1709,6 +1986,14 @@
const sections = [];
// Caso especial: dirección sin usar
if (analysis.unused) {
sections.push({ title:"Diagnóstico", text:"Esta dirección no tiene historial on-chain — no ha recibido ni enviado fondos. No hay nada que analizar todavía." });
sections.push({ title:"Qué puede saber un analista", text:"Nada. Una dirección sin usar no revela información hasta que recibe su primer pago." });
sections.push({ title:"Recomendaciones", text:"Cuando recibas fondos en esta dirección, úsala una sola vez para mantener una buena privacidad." });
return sections;
}
// ── 1. DIAGNÓSTICO PRINCIPAL ─────────────────────────────────────
let diag = "";
if (hasInputReuse && hasChange) {
@@ -1726,7 +2011,7 @@
} 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.";
diag = `No se detectan problemas graves de privacidad en esta ${noun} con los datos disponibles.`;
} else {
diag = `Se detectan ${failed.length} característica${failed.length>1?"s":""} que reducen la privacidad, ninguna de gravedad crítica por sí sola.`;
}
@@ -1776,13 +2061,13 @@
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." });
sections.push({ title:"Recomendaciones", text:`No hay acciones correctivas prioritarias para esta ${noun}.` });
}
return sections;
}
function PrivacyLab({analysis, tx, labels}) {
function PrivacyLab({analysis, tx, labels, myAddr}) {
const [expanded,setExpanded] = useState(null);
const [showDidactic,setShowDidactic] = useState(null);
const [showReport,setShowReport] = useState(false);
@@ -1889,7 +2174,9 @@
<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
{analysis.unused
? "Esta dirección no se ha usado todavía. No tiene historial on-chain que analizar."
: tx
? (score>=75?"La transacción presenta buenas propiedades de privacidad.":score>=45?"La transacción es funcional, pero tiene características que reducen su privacidad.":"Esta transacción tiene características que facilitan su rastreo.")
: (score>=75?"La dirección presenta buenas propiedades de privacidad.":score>=45?"La dirección es funcional, pero tiene características que reducen su privacidad.":"Esta dirección tiene características que facilitan su rastreo.")}
</div>
@@ -3440,6 +3727,39 @@
const [labels,setLabels]=useState(new Map()); // BIP-329: ref -> label
const [showLabelModal,setShowLabelModal]=useState(false);
const [labelInfo,setLabelInfo]=useState(null); // {count, file}
const [walletAddrs,setWalletAddrs]=useState(null); // {receive:[], change:[]}
const [xpubInput,setXpubInput]=useState("");
const [xpubCount,setXpubCount]=useState(20);
const [xpubLoading,setXpubLoading]=useState(false);
const [xpubError,setXpubError]=useState(null);
const [showWalletPanel,setShowWalletPanel]=useState(false);
const loadWallet = async () => {
const xpub = xpubInput.trim();
if (!xpub) return;
if (!/^[xyzuvt]pub[1-9A-HJ-NP-Za-km-z]{100,}$/.test(xpub)) {
setXpubError("Introduce un xpub/zpub válido"); return;
}
setXpubLoading(true); setXpubError(null);
try {
const addrs = await deriveAddresses(xpub, xpubCount);
setWalletAddrs(addrs);
setShowWalletPanel(false);
} catch(e) {
setXpubError("Error al derivar direcciones: " + e.message);
}
setXpubLoading(false);
};
const clearWallet = () => { setWalletAddrs(null); setXpubInput(""); setXpubError(null); };
// Determinar si una dirección es mía y de qué rama
const myAddr = (addr) => {
if (!walletAddrs) return null;
if (walletAddrs.receive.includes(addr)) return "recepción";
if (walletAddrs.change.includes(addr)) return "cambio";
return null;
};
const loadLabels = (file) => {
const reader = new FileReader();
@@ -3482,13 +3802,68 @@
return (
<div style={{display:"flex",flexDirection:"column",gap:12}}>
{!base&&<DemoBanner/>}
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center"}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",gap:8}}>
<SectionTitle accent={C.purple} icon="⌖">AUDITORÍA</SectionTitle>
<button onClick={()=>setShowLabelModal(true)}
<div style={{display:"flex",gap:6}}>
<button onClick={()=>{setShowWalletPanel(!showWalletPanel);setShowLabelModal(false);}}
style={{padding:"5px 12px",background:walletAddrs?C.greenMuted:C.bgCard,border:`1px solid ${walletAddrs?C.green:C.border}`,borderRadius:6,color:walletAddrs?C.green:C.t2,fontFamily:"monospace",fontSize:"0.65rem",cursor:"pointer",whiteSpace:"nowrap"}}>
{walletAddrs?"✓ Wallet cargado":"Wallet ↑"}
</button>
<button onClick={()=>{setShowLabelModal(!showLabelModal);setShowWalletPanel(false);}}
style={{padding:"5px 12px",background:labels.size>0?C.greenMuted:C.bgCard,border:`1px solid ${labels.size>0?C.green:C.border}`,borderRadius:6,color:labels.size>0?C.green:C.t2,fontFamily:"monospace",fontSize:"0.65rem",cursor:"pointer",whiteSpace:"nowrap"}}>
{labels.size>0?`✓ ${labels.size} etiquetas`:"Etiquetas ↑"}
</button>
</div>
</div>
{showWalletPanel&&(
<Card glow={C.green}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:10}}>
<div style={{fontSize:"0.65rem",color:C.green,fontFamily:"monospace",fontWeight:700}}>WALLET WATCH-ONLY (xpub)</div>
<button onClick={()=>setShowWalletPanel(false)} style={{background:"none",border:"none",color:C.t2,cursor:"pointer",fontSize:"1rem"}}>×</button>
</div>
<div style={{fontSize:"0.68rem",color:C.t2,marginBottom:12}}>
Pega tu xpub/zpub desde Sparrow: <span style={{fontFamily:"monospace",color:C.t1}}>Settings → Keystore → Master Public Key</span>. Txoko deriva tus direcciones en local — el xpub no sale del navegador.
</div>
{walletAddrs?(
<div>
<div style={{fontSize:"0.65rem",color:C.green,fontFamily:"monospace",marginBottom:8}}>✓ {walletAddrs.receive.length} direcciones de recepción + {walletAddrs.change.length} de cambio derivadas</div>
<div style={{fontSize:"0.62rem",color:C.t2,marginBottom:8}}>Las direcciones de tus txs analizadas se marcan automáticamente como tuyas. Compara estas con Sparrow para verificar:</div>
<div style={{display:"flex",flexDirection:"column",gap:3,marginBottom:10}}>
{walletAddrs.receive.slice(0,3).map((a,i)=>(
<div key={i} style={{fontSize:"0.6rem",fontFamily:"monospace",color:C.t1}}>
<span style={{color:C.t2}}>recep #{i}:</span> {a}
</div>
))}
</div>
<button onClick={clearWallet} style={{padding:"5px 12px",background:"none",border:`1px solid ${C.red}40`,borderRadius:6,color:C.red,fontFamily:"monospace",fontSize:"0.65rem",cursor:"pointer"}}>
Borrar wallet
</button>
</div>
):(
<div style={{display:"flex",gap:8}}>
<input value={xpubInput} onChange={e=>setXpubInput(e.target.value)}
onKeyDown={e=>e.key==="Enter"&&loadWallet()}
placeholder="xpub... o zpub..."
style={{flex:1,background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"8px 12px",color:C.t1,fontFamily:"monospace",fontSize:"0.7rem",outline:"none"}}
/>
<select value={xpubCount} onChange={e=>setXpubCount(Number(e.target.value))}
title="Direcciones a derivar por rama"
style={{background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"8px 8px",color:C.t1,fontFamily:"monospace",fontSize:"0.7rem",outline:"none",cursor:"pointer"}}>
<option value={20}>20</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
<button onClick={loadWallet} disabled={xpubLoading}
style={{padding:"8px 16px",background:C.greenMuted,border:`1px solid ${C.green}40`,borderRadius:6,color:C.green,fontFamily:"monospace",fontSize:"0.72rem",cursor:"pointer",fontWeight:700}}>
{xpubLoading?"···":"Cargar"}
</button>
</div>
)}
{!walletAddrs&&<div style={{marginTop:8,fontSize:"0.6rem",color:C.t2,fontFamily:"monospace"}}>Direcciones a derivar por rama (recepción y cambio). Más direcciones = más cobertura pero más tiempo de cálculo.</div>}
{xpubError&&<div style={{marginTop:8,fontSize:"0.65rem",color:C.red,fontFamily:"monospace"}}>{xpubError}</div>}
</Card>
)}
{showLabelModal&&(
<Card glow={C.purple}>
@@ -3567,8 +3942,28 @@
<Tag label="Outputs" value={tx.vout?.length||0} color={C.t1}/>
<Tag label="Fecha" value={tx.status?.block_time?fmt.date(tx.status.block_time):"-"}/>
</div>
{walletAddrs&&tx.vout&&tx.vout.some(o=>myAddr(o.scriptpubkey_address))&&(
<div style={{marginTop:10,paddingTop:10,borderTop:`1px solid ${C.border}`}}>
<div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",marginBottom:6}}>OUTPUTS DE TU WALLET</div>
<div style={{display:"flex",flexDirection:"column",gap:4}}>
{tx.vout.map((o,i)=>{
const mine = myAddr(o.scriptpubkey_address);
if (!mine) return null;
const lbl = labels?.get(o.scriptpubkey_address);
return (
<div key={i} style={{display:"flex",alignItems:"center",gap:8,padding:"5px 8px",background:mine==="cambio"?C.amberMuted:C.greenMuted,border:`1px solid ${mine==="cambio"?C.amber:C.green}40`,borderRadius:5}}>
<span style={{fontSize:"0.65rem",color:mine==="cambio"?C.amber:C.green,fontFamily:"monospace",fontWeight:600}}>{mine==="cambio"?"↺ cambio":"↓ recepción"}</span>
<span style={{fontSize:"0.68rem",color:C.t1,fontFamily:"monospace"}}>{fmt.sats(o.value)}</span>
{lbl&&<span style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace"}}>· {lbl}</span>}
<span style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace",marginLeft:"auto"}}>{o.scriptpubkey_address?.slice(0,12)}…</span>
</div>
);
})}
</div>
</div>
)}
</Card>
<PrivacyLab analysis={analysis} tx={tx} labels={labels}/>
<PrivacyLab analysis={analysis} tx={tx} labels={labels} myAddr={myAddr}/>
<RastroProcedencia tx={tx} base={base} labels={labels}/>
<TxVerifier base={base}/>
</div>