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:
+402
-7
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user