diff --git a/dashboard.html b/dashboard.html
index cbbf7cb..becac09 100644
--- a/dashboard.html
+++ b/dashboard.html
@@ -1018,7 +1018,274 @@
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<>>(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 = {
input_reuse: 25, input_type_mixing: 14, output_type_mismatch: 8,
@@ -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 @@
NIVEL DE PRIVACIDAD
{summary}
- {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.")}