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:
+406
-11
@@ -1018,7 +1018,274 @@
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
function analyzeTx(tx) {
|
|
||||||
|
// ── 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 checks = [];
|
||||||
const weights = {
|
const weights = {
|
||||||
input_reuse: 25, input_type_mixing: 14, output_type_mismatch: 8,
|
input_reuse: 25, input_type_mixing: 14, output_type_mismatch: 8,
|
||||||
@@ -1606,6 +1873,13 @@
|
|||||||
const checks = []; let score = 100;
|
const checks = []; let score = 100;
|
||||||
const txCount = addr.chain_stats ? addr.chain_stats.tx_count : 0;
|
const txCount = addr.chain_stats ? addr.chain_stats.tx_count : 0;
|
||||||
const utxoCount = addr.utxos ? addr.utxos.length : 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 reused = txCount > 1;
|
||||||
const penalty = txCount > 10 ? 40 : txCount > 3 ? 25 : 15;
|
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 });
|
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) {
|
function generateReport(analysis, tx) {
|
||||||
const {score, checks} = analysis;
|
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 failed = checks.filter(c => !c.pass && !c.informational);
|
||||||
const critical = failed.filter(c => c.certainty === "CERTEZA");
|
const critical = failed.filter(c => c.certainty === "CERTEZA");
|
||||||
const probable = failed.filter(c => c.certainty === "PROBABLE");
|
const probable = failed.filter(c => c.certainty === "PROBABLE");
|
||||||
@@ -1709,6 +1986,14 @@
|
|||||||
|
|
||||||
const sections = [];
|
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 ─────────────────────────────────────
|
// ── 1. DIAGNÓSTICO PRINCIPAL ─────────────────────────────────────
|
||||||
let diag = "";
|
let diag = "";
|
||||||
if (hasInputReuse && hasChange) {
|
if (hasInputReuse && hasChange) {
|
||||||
@@ -1726,7 +2011,7 @@
|
|||||||
} else if (hasDust) {
|
} 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.";
|
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) {
|
} 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 {
|
} else {
|
||||||
diag = `Se detectan ${failed.length} característica${failed.length>1?"s":""} que reducen la privacidad, ninguna de gravedad crítica por sí sola.`;
|
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) {
|
if (topRecs.length > 0) {
|
||||||
sections.push({ title:"Recomendaciones", recs: topRecs });
|
sections.push({ title:"Recomendaciones", recs: topRecs });
|
||||||
} else {
|
} 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;
|
return sections;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PrivacyLab({analysis, tx, labels}) {
|
function PrivacyLab({analysis, tx, labels, myAddr}) {
|
||||||
const [expanded,setExpanded] = useState(null);
|
const [expanded,setExpanded] = useState(null);
|
||||||
const [showDidactic,setShowDidactic] = useState(null);
|
const [showDidactic,setShowDidactic] = useState(null);
|
||||||
const [showReport,setShowReport] = useState(false);
|
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:"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:"1rem",color:summaryColor,fontFamily:"monospace",fontWeight:700,marginBottom:4}}>{summary}</div>
|
||||||
<div style={{fontSize:"0.65rem",color:C.t2,lineHeight:1.5}}>
|
<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 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.")}
|
: (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>
|
</div>
|
||||||
@@ -3440,6 +3727,39 @@
|
|||||||
const [labels,setLabels]=useState(new Map()); // BIP-329: ref -> label
|
const [labels,setLabels]=useState(new Map()); // BIP-329: ref -> label
|
||||||
const [showLabelModal,setShowLabelModal]=useState(false);
|
const [showLabelModal,setShowLabelModal]=useState(false);
|
||||||
const [labelInfo,setLabelInfo]=useState(null); // {count, file}
|
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 loadLabels = (file) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
@@ -3482,14 +3802,69 @@
|
|||||||
return (
|
return (
|
||||||
<div style={{display:"flex",flexDirection:"column",gap:12}}>
|
<div style={{display:"flex",flexDirection:"column",gap:12}}>
|
||||||
{!base&&<DemoBanner/>}
|
{!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>
|
<SectionTitle accent={C.purple} icon="⌖">AUDITORÍA</SectionTitle>
|
||||||
<button onClick={()=>setShowLabelModal(true)}
|
<div style={{display:"flex",gap:6}}>
|
||||||
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"}}>
|
<button onClick={()=>{setShowWalletPanel(!showWalletPanel);setShowLabelModal(false);}}
|
||||||
{labels.size>0?`✓ ${labels.size} etiquetas`:"Etiquetas ↑"}
|
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"}}>
|
||||||
</button>
|
{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>
|
</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&&(
|
{showLabelModal&&(
|
||||||
<Card glow={C.purple}>
|
<Card glow={C.purple}>
|
||||||
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:10}}>
|
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:10}}>
|
||||||
@@ -3567,8 +3942,28 @@
|
|||||||
<Tag label="Outputs" value={tx.vout?.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):"-"}/>
|
<Tag label="Fecha" value={tx.status?.block_time?fmt.date(tx.status.block_time):"-"}/>
|
||||||
</div>
|
</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>
|
</Card>
|
||||||
<PrivacyLab analysis={analysis} tx={tx} labels={labels}/>
|
<PrivacyLab analysis={analysis} tx={tx} labels={labels} myAddr={myAddr}/>
|
||||||
<RastroProcedencia tx={tx} base={base} labels={labels}/>
|
<RastroProcedencia tx={tx} base={base} labels={labels}/>
|
||||||
<TxVerifier base={base}/>
|
<TxVerifier base={base}/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user