fix: endurecer la validación del xpub tras revisar la criptografía

La matemática pasa todos los vectores oficiales (RIPEMD-160, secp256k1, BIP32
vectores 1 y 2, bech32/BIP173) y no se ha tocado. Los fallos estaban en la
validación de la entrada:

- No se comprobaba la checksum del xpub: un carácter mal copiado generaba 200
  direcciones ajenas y el usuario veía su cartera 'sin actividad'. Mismo
  patrón de falso negativo silencioso que el resto de fallos de hoy.
- No se validaba la longitud (78 bytes) ni el formato de la clave pública.
- tpub se trataba como mainnet: la red se detectaba por prefijo de texto
  ('tb'/'u'/'v') y un tpub empieza por 't' pero no por 'tb'. Ahora se detecta
  por bytes de versión, con las diez variantes.
- deriveChildPubkey aceptaba índices endurecidos, imposibles desde una clave
  pública. No alcanzable desde la UI, pero debe defenderse sola.

Se añade tests/ con las cuatro baterías, documentando también qué NO cubren:
no sustituyen una auditoría externa.
This commit is contained in:
2026-07-27 14:59:48 +02:00
parent bb9b92d9b9
commit 0acfb35bf8
7 changed files with 316 additions and 6 deletions
+24
View File
@@ -40,6 +40,30 @@ y el versionado sigue [Versionado Semántico](https://semver.org/lang/es/):
**Qué puedes hacer** —, que aquí cobra sentido literal: todavía estás a
tiempo de cambiar la transacción.
### Corregido
- **Revisión de la criptografía watch-only.** Se verificó la derivación completa
contra los vectores oficiales de los estándares: RIPEMD-160 (los seis del
estándar, incluido el de un millón de caracteres), secp256k1, BIP32 (vectores
1 y 2) y bech32/BIP173. **La matemática es correcta y no se tocó.** Los fallos
estaban en la validación de la entrada:
- **No se comprobaba la suma de verificación del xpub.** Un carácter mal
copiado se aceptaba sin protestar y generaba 200 direcciones ajenas: el
usuario habría visto su cartera "sin actividad" y se habría quedado
tranquilo. Falso negativo silencioso, el mismo patrón que el resto de fallos
de esta jornada. Nuevo `decodeBase58Check`.
- **No se validaba la longitud** del material decodificado (deben ser 78
bytes) ni que la clave pública fuese comprimida (0x02/0x03).
- **`tpub` se trataba como mainnet.** La red se detectaba mirando si el texto
empezaba por "tb", "u" o "v" — y un `tpub`, el formato más común de testnet,
empieza por "t" pero no por "tb". Generaba direcciones `bc1…` a partir de
claves de testnet. Ahora se detecta por bytes de versión, que son
inequívocos, con las diez variantes (x/y/z/Y/Z y t/u/v/U/V).
- **`deriveChildPubkey` aceptaba índices endurecidos**, matemáticamente
imposibles desde una clave pública. No era alcanzable desde la interfaz,
pero una función criptográfica debe defenderse sola.
- Añadida la carpeta `tests/` con las cuatro baterías y su documentación,
incluido qué **no** cubren: no sustituyen una auditoría externa.
---
## [1.11.0] — 2026-07-27
### Añadido
+56 -6
View File
@@ -1055,8 +1055,27 @@
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);
return full; // completo: payload + 4 bytes de checksum
},
// Decodifica y COMPRUEBA la checksum. Es async porque el hash lo hace
// Web Crypto. Sin esta comprobación, un xpub con un solo carácter mal
// copiado se acepta sin protestar y genera direcciones que no son las
// del usuario — que entonces vería su cartera "sin actividad" y se
// quedaría tranquilo. Un falso negativo silencioso, justo lo que este
// proyecto evita en todo lo demás.
decodeBase58Check: async (str) => {
const full = B32.decodeBase58(str);
if (full.length < 5) throw new Error("Cadena demasiado corta para ser una clave extendida válida.");
const payload = full.slice(0, full.length - 4);
const checksum = full.slice(full.length - 4);
const h = await B32.sha256d(payload);
for (let i = 0; i < 4; i++) {
if (h[i] !== checksum[i]) {
throw new Error("La clave no es válida: la suma de verificación no cuadra. Suele ser un carácter mal copiado — revísala y pégala entera.");
}
}
return payload;
},
// SHA256 doble (para checksum base58)
@@ -1153,7 +1172,13 @@
// 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)
// Los índices endurecidos (≥ 2³¹) NO se pueden derivar desde una clave
// pública: hace falta la privada. Hoy solo se llama con 0 y 1, así que
// no es alcanzable, pero una función criptográfica debe defenderse sola.
if (!Number.isInteger(index) || index < 0 || index >= 0x80000000) {
throw new Error("Índice de derivación inválido: desde una clave pública no se pueden derivar índices endurecidos.");
}
const indexBytes = B32.u32be(index);
const data = B32.concat(parentPub, indexBytes);
const I = await B32.hmac512(parentChain, data);
const IL = I.slice(0, 32);
@@ -1272,17 +1297,42 @@
};
// Derivar N direcciones desde xpub (rama 0=recepción, 1=cambio)
// Redes por bytes de versión, no por el prefijo del texto. Mirar las
// primeras letras parece equivalente y no lo es: un `tpub` —el formato más
// habitual de testnet— empieza por "t" pero no por "tb", así que una
// comprobación textual lo confunde con mainnet y genera direcciones bc1…
// a partir de claves de testnet. Los bytes de versión son inequívocos.
const XPUB_VERSIONS = {
"0488b21e": { red:"bc", tipo:"xpub" }, "049d7cb2": { red:"bc", tipo:"ypub" },
"04b24746": { red:"bc", tipo:"zpub" }, "0295b43f": { red:"bc", tipo:"Ypub" },
"02aa7ed3": { red:"bc", tipo:"Zpub" },
"043587cf": { red:"tb", tipo:"tpub" }, "044a5262": { red:"tb", tipo:"upub" },
"045f1cf6": { red:"tb", tipo:"vpub" }, "024289ef": { red:"tb", tipo:"Upub" },
"02575483": { red:"tb", tipo:"Vpub" },
};
const deriveAddresses = async (xpubStr, count=20) => {
// Decodificar xpub/zpub
const raw = B32.decodeBase58(xpubStr.trim());
// decodeBase58Check comprueba la suma de verificación: un carácter mal
// copiado se detecta aquí y no acaba generando direcciones ajenas.
const raw = await B32.decodeBase58Check(xpubStr.trim());
// raw[0..3]=version, [4]=depth, [5..8]=fingerprint, [9..12]=childIndex
// [13..44]=chainCode, [45..77]=pubKey
if (raw.length !== 78) {
throw new Error(`La clave extendida debería ocupar 78 bytes y ocupa ${raw.length}. Parece incompleta o de un formato que no reconozco.`);
}
const version = Array.from(raw.slice(0,4)).map(b=>b.toString(16).padStart(2,"0")).join("");
const info = XPUB_VERSIONS[version];
if (!info) throw new Error(`Formato de clave extendida no reconocido (versión ${version}).`);
const chainCode = raw.slice(13, 45);
const pubKey = raw.slice(45, 78);
if (pubKey[0] !== 0x02 && pubKey[0] !== 0x03) {
throw new Error("La clave pública que contiene no tiene un formato comprimido válido.");
}
// 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 hrp = info.red;
const result = { receive: [], change: [], fingerprint };
for (const [branch, label] of [[0,"receive"],[1,"change"]]) {
+47
View File
@@ -0,0 +1,47 @@
# Pruebas de la criptografía
Verifican la derivación watch-only (BIP32, secp256k1, RIPEMD-160, bech32)
contra los **vectores oficiales de los estándares**, no contra resultados
propios. Si un cambio rompe algo, estas pruebas lo dicen.
## Cómo ejecutarlas
Extraer el bloque criptográfico de `dashboard.html` a `crypto.js` (las líneas
que van desde `const B32 = {` hasta el final de `deriveAddresses`), añadir al
principio `const { webcrypto } = require("crypto"); const crypto = webcrypto;`
y al final la exportación:
module.exports = { B32, SECP, deriveChildPubkey, ripemd160, hash160, toBech32, deriveAddresses };
Después:
node test1.js # RIPEMD-160 y aritmética de curva
node test2.js # BIP32 y bech32 contra vectores oficiales
node test3.js # búsqueda de casos borde (diagnóstico)
node test4.js # regresión de los fallos ya corregidos
## Qué cubren
- **test1** — RIPEMD-160 con los seis vectores del estándar (incluido el de un
millón de caracteres), generador de secp256k1, múltiplos conocidos, y que
comprimir y descomprimir un punto sea reversible.
- **test2** — BIP32: clave pública y chain code de la raíz, y derivación no
endurecida `m/0`, contra los vectores 1 y 2 del propio BIP32. bech32: la
dirección P2WPKH del generador, en mainnet y testnet (BIP173).
- **test3** — sondeo de casos borde. Fue el que encontró los cuatro fallos de
validación corregidos el 2026-07-27.
- **test4** — comprueba que esos cuatro siguen cerrados: checksum rota, xpub
truncado, índice endurecido, índice negativo. Y que un `tpub` genera
direcciones de testnet, no de mainnet.
## Lo que estas pruebas NO cubren
La matemática es correcta, pero eso no es una auditoría. No cubren análisis
formal ni una revisión independiente: quien las escribió conoce la
implementación y comparte sus supuestos, que es justo el sesgo que rompe un
revisor externo. Siguen haciendo falta ojos de fuera antes de difundir el
proyecto ampliamente.
Tampoco aplican aquí los ataques de canal lateral: todo esto maneja **solo
claves públicas**. No hay secreto que filtrar; lo único que importa es que el
resultado sea correcto, y eso es lo que se comprueba.
+44
View File
@@ -0,0 +1,44 @@
// WebCrypto en Node
const { webcrypto } = require('crypto');
global.crypto = webcrypto;
const fs=require("fs");
const { B32, SECP, deriveChildPubkey, ripemd160, hash160, toBech32, deriveAddresses } = require('/tmp/cripto/crypto.js');
const hex = a => Array.from(a).map(b=>b.toString(16).padStart(2,'0')).join('');
let pass=0, fail=0;
const check=(nombre, got, want)=>{
const ok = got===want;
console.log(` ${ok?'✓':'✗'} ${nombre}`);
if(!ok){ console.log(` obtenido: ${got}`); console.log(` esperado: ${want}`); fail++; } else pass++;
};
(async () => {
console.log("=== 1. RIPEMD-160 (vectores del estándar) ===");
const enc = s => new TextEncoder().encode(s);
check('""', hex(ripemd160(enc(""))), "9c1185a5c5e9fc54612808977ee8f548b2258d31");
check('"a"', hex(ripemd160(enc("a"))), "0bdc9d2d256b3ee9daae347be6f4dc835a467ffe");
check('"abc"', hex(ripemd160(enc("abc"))), "8eb208f7e05d987a9b044a8e98c6b087f15a0bfc");
check('"message digest"', hex(ripemd160(enc("message digest"))), "5d0689ef49d2fae572b881b123a85ffa21595f36");
check('abcdefghijklmnopqrstuvwxyz', hex(ripemd160(enc("abcdefghijklmnopqrstuvwxyz"))), "f71c27109c692c1b56bbdceb5b9d2865b3708dbc");
check('1M x "a"', hex(ripemd160(enc("a".repeat(1000000)))), "52783243c1697bdbe16d37f97f68f08325dc1528");
console.log("\n=== 2. secp256k1: G y múltiplos conocidos ===");
const G = SECP.G;
check('G comprimido', hex(SECP.compress(G)), "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798");
check('2G', hex(SECP.compress(SECP.mulPoint(2n, G))), "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5");
check('3G', hex(SECP.compress(SECP.mulPoint(3n, G))), "02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9");
// n-1 * G = -G (mismo x, y opuesta)
const nm1 = SECP.mulPoint(SECP.N - 1n, G);
check('(n-1)G tiene la x de G', nm1[0].toString(16), G[0].toString(16));
check('(n-1)G tiene y opuesta', ((nm1[1] + G[1]) % SECP.P).toString(), "0");
console.log("\n=== 3. compress → decompress (ida y vuelta) ===");
for (const k of [1n, 2n, 7n, 12345n, 0xdeadbeefn]) {
const pt = SECP.mulPoint(k, G);
const c = SECP.compress(pt);
const d = SECP.decompress(c);
check(`k=${k}`, hex(SECP.compress(d)), hex(c));
}
console.log(`\nRESULTADO: ${pass} correctas, ${fail} incorrectas`);
})();
+40
View File
@@ -0,0 +1,40 @@
const { B32, SECP, deriveChildPubkey, ripemd160, hash160, toBech32, deriveAddresses } = require('/tmp/cripto/crypto.js');
const hex = a => Array.from(a).map(b=>b.toString(16).padStart(2,'0')).join('');
let pass=0, fail=0;
const check=(n,g,w)=>{ const ok=g===w; console.log(` ${ok?'✓':'✗'} ${n}`); if(!ok){console.log(` obtenido: ${g}`);console.log(` esperado: ${w}`);fail++;}else pass++; };
(async () => {
// ── BIP32, vectores oficiales del estándar ──
// Vector 1: seed 000102...0e0f
// m/0/1 derivado SOLO con clave pública (derivación no endurecida)
console.log("=== 4. BIP32 — vector 1 oficial, derivación pública ===");
// xpub de m (raíz) del vector 1
const M = "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8";
const raw = B32.decodeBase58(M);
const chain = raw.slice(13,45), pub = raw.slice(45,78);
check("clave pública de m", hex(pub), "0339a36013301597daef41fbe593a02cc513d0b55527ec2df1050e2e8ff49c85c2");
check("chain code de m", hex(chain), "873dff81c02f525623fd1fe5167eac3a55a049de3d314bb42ee227ffed37d508");
// m/0 → xpub esperado del estándar
const m0 = await deriveChildPubkey(pub, chain, 0);
// del vector oficial: xpub de m/0'/1 ... usamos m/0 no endurecido del vector 2
console.log("\n=== 5. BIP32 — vector 2 oficial (m/0, no endurecida) ===");
const M2 = "xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB";
const r2 = B32.decodeBase58(M2);
const c2 = r2.slice(13,45), p2 = r2.slice(45,78);
const d2 = await deriveChildPubkey(p2, c2, 0);
check("m/0 clave pública", hex(d2.pub), "02fc9e5af0ac8d9b3cecfe2a888e2117ba3d089d8585886c9c826b6b22a98d12ea");
check("m/0 chain code", hex(d2.chain), "f0909affaa7ee7abe5dd4e100598d4dc53cd709d5a5c2cac40e7412f232f7c9c");
// m/0/2147483647 no se puede (endurecida). Probamos m/0/1 del vector 2:
const d3 = await deriveChildPubkey(d2.pub, d2.chain, 1);
console.log("\n=== 6. bech32 — vectores oficiales BIP173 ===");
// P2WPKH conocido: pubkey 0279be66... → bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4
const pk = Uint8Array.from(Buffer.from("0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","hex"));
const h = await hash160(pk);
check("hash160 de G", hex(h), "751e76e8199196d454941c45d1b3a323f1433bd6");
check("dirección P2WPKH", toBech32("bc", Array.from(h)), "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4");
check("misma en testnet", toBech32("tb", Array.from(h)), "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx");
console.log(`\nRESULTADO: ${pass} correctas, ${fail} incorrectas`);
})();
+63
View File
@@ -0,0 +1,63 @@
const { B32, SECP, deriveChildPubkey, ripemd160, hash160, toBech32, deriveAddresses } = require('/tmp/cripto/crypto.js');
const hex = a => Array.from(a).map(b=>b.toString(16).padStart(2,'0')).join('');
let hallazgos=[];
(async () => {
console.log("=== 7. ¿Se verifica la checksum del xpub? ===");
// xpub válido con UN carácter cambiado al final (checksum rota)
const bueno = "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8";
const malo = bueno.slice(0,-1) + (bueno.slice(-1)==="8" ? "9" : "8");
try {
const r = B32.decodeBase58(malo);
console.log(" ✗ ACEPTA un xpub con checksum inválida — no la comprueba");
hallazgos.push({sev:"medio", t:"No se verifica la checksum base58 del xpub", d:"decodeBase58 descarta los 4 bytes de checksum sin comprobarlos. Un xpub mal copiado (un carácter cambiado) se acepta y genera direcciones que no son las del usuario."});
} catch(e) { console.log(" ✓ rechaza:", e.message); }
console.log("\n=== 8. ¿Se valida la longitud del xpub? ===");
try {
const r = B32.decodeBase58("xpub661MyMwAqRbcFtXgS5sYJ"); // truncado
console.log(` ✗ ACEPTA un xpub truncado → ${r.length} bytes (deberían ser 78)`);
hallazgos.push({sev:"medio", t:"No se valida la longitud del xpub decodificado", d:"Un xpub truncado produce un array corto; chainCode y pubKey salen vacíos o parciales y la derivación falla de forma confusa o produce basura."});
} catch(e){ console.log(" ✓ rechaza:", e.message); }
console.log("\n=== 9. ¿Se valida que la clave pública esté en la curva? ===");
// Punto que NO está en secp256k1: x=1 no tiene y entera para y²=x³+7 → 8 no es residuo
try {
const falso = Uint8Array.from([2, ...new Array(31).fill(0), 1]); // x=1
const pt = SECP.decompress(falso);
const enCurva = (pt[1]*pt[1] - (pt[0]**3n + 7n)) % SECP.P === 0n;
if (!enCurva) {
console.log(" ✗ decompress DEVUELVE un punto que no está en la curva (no valida)");
hallazgos.push({sev:"medio", t:"decompress no comprueba que el punto esté en la curva", d:"Con una x que no corresponde a ningún punto de secp256k1, devuelve un par (x,y) inválido en vez de fallar. La derivación seguiría y produciría direcciones sin sentido. Solo alcanzable con un xpub manipulado."});
} else console.log(" ✓ el punto resultante sí está en la curva");
} catch(e){ console.log(" ✓ rechaza:", e.message); }
console.log("\n=== 10. Índices endurecidos (no derivables desde xpub) ===");
const raw=B32.decodeBase58(bueno), ch=raw.slice(13,45), pb=raw.slice(45,78);
try {
await deriveChildPubkey(pb, ch, 0x80000000);
console.log(" ✗ ACEPTA un índice endurecido — matemáticamente imposible desde una clave pública");
hallazgos.push({sev:"bajo", t:"No se rechaza el índice endurecido en deriveChildPubkey", d:"Un índice ≥ 0x80000000 no se puede derivar desde una clave pública. Hoy no se llama nunca con esos valores (deriveAddresses usa 0 y 1), así que no es explotable, pero la función no se defiende sola."});
} catch(e){ console.log(" ✓ rechaza:", e.message); }
console.log("\n=== 11. Consistencia: 100 direcciones seguidas ===");
const a = await deriveAddresses(bueno, 100);
const todas = [...a.receive, ...a.change];
const unicas = new Set(todas);
console.log(` direcciones generadas: ${todas.length}, únicas: ${unicas.size}`);
console.log(` todas empiezan por bc1q: ${todas.every(x=>x.startsWith("bc1q"))}`);
console.log(` longitud correcta (42): ${todas.every(x=>x.length===42)}`);
if (unicas.size !== todas.length) hallazgos.push({sev:"alto", t:"Direcciones duplicadas en la derivación", d:"Dos índices distintos producen la misma dirección."});
console.log("\n=== 12. Detección de red (mainnet vs testnet) ===");
for (const [p,esperado] of [["xpub","bc"],["zpub","bc"],["ypub","bc"],["tpub","?"],["vpub","tb"],["upub","tb"]]) {
const hrp = p.startsWith("tb")||p.startsWith("u")||p.startsWith("v") ? "tb":"bc";
const marca = (p==="tpub" && hrp==="bc") ? " ✗" : " ·";
console.log(`${marca} ${p}${hrp}`);
}
hallazgos.push({sev:"medio", t:"tpub (testnet) se trata como mainnet", d:"La detección mira si empieza por 'tb', 'u' o 'v'. Un tpub —el formato más común de testnet— empieza por 't' y NO por 'tb', así que cae en la rama de mainnet y genera direcciones bc1... a partir de claves de testnet."});
console.log("\n\n════ HALLAZGOS ════");
for (const h of hallazgos) console.log(`\n[${h.sev.toUpperCase()}] ${h.t}\n ${h.d}`);
if (!hallazgos.length) console.log("ninguno");
})();
+42
View File
@@ -0,0 +1,42 @@
const { B32, SECP, deriveChildPubkey, deriveAddresses } = require('/tmp/cripto/crypto.js');
let ok=0, ko=0;
const debeFallar = async (nombre, fn) => {
try { await fn(); console.log(`${nombre} — NO falló`); ko++; }
catch(e){ console.log(`${nombre}\n → "${e.message}"`); ok++; }
};
(async () => {
const bueno = "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8";
console.log("=== los cuatro hallazgos, revisados ===\n");
await debeFallar("checksum rota (un carácter cambiado)", () =>
deriveAddresses(bueno.slice(0,-1) + (bueno.slice(-1)==="8"?"9":"8"), 2));
await debeFallar("xpub truncado", () => deriveAddresses("xpub661MyMwAqRbcFtXgS5sYJ", 2));
await debeFallar("índice endurecido", async () => {
const raw = await B32.decodeBase58Check(bueno);
return deriveChildPubkey(raw.slice(45,78), raw.slice(13,45), 0x80000000);
});
await debeFallar("índice negativo", async () => {
const raw = await B32.decodeBase58Check(bueno);
return deriveChildPubkey(raw.slice(45,78), raw.slice(13,45), -1);
});
console.log("\n=== el xpub bueno sigue funcionando ===");
const a = await deriveAddresses(bueno, 3);
console.log(" recepción:", a.receive.join(", "));
console.log(" huella:", a.fingerprint);
const bien = a.receive.every(x=>x.startsWith("bc1q")&&x.length===42);
console.log(` ${bien?'✓':'✗'} formato correcto`); bien?ok++:ko++;
console.log("\n=== testnet: tpub ahora se detecta bien ===");
// vector 1 del BIP32 con los bytes de versión de testnet — dato público, no de nadie
const tp = "tpubD6NzVbkrYhZ4XgiXtGrdW5XDAPFCL9h7we1vwNCpn8tGbBcgfVYjXyhWo4E1xkh56hjod1RhGjxbaTLV3X4FyWuejifB9jusQ46QzG87VKp";
try {
const t = await deriveAddresses(tp, 2);
const esTb = t.receive.every(x=>x.startsWith("tb1q"));
console.log(` ${esTb?'✓':'✗'} genera direcciones de testnet: ${t.receive[0]}`);
esTb?ok++:ko++;
} catch(e){ console.log(" ✗ falló:", e.message); ko++; }
console.log(`\nRESULTADO: ${ok} correctas, ${ko} incorrectas`);
})();