// Analizador de transacciones completo (analyzeTx) // // Extrae el analizador entero del dashboard.html y lo ejecuta contra // transacciones construidas a mano. Comprueba tres cosas: // // · Coherencia del score: que el denominador y las deducciones midan lo // mismo, es decir que ninguna transacción pueda puntuar negativo antes // del clamp. Ese desajuste existía (deducciones hasta 213 contra un // denominador de 196) y lo tapaba un Math.max(0, …). // · Que la guardia de CoinJoin desactive las heurísticas que no aplican. // · Los dos checks nuevos: polvo gastado junto a otras monedas, y // consolidación pura. // // Uso: node tests/test6.js const fs = require("fs"); const path = require("path"); const htmlPath = path.join(__dirname, "..", "dashboard.html"); const lines = fs.readFileSync(htmlPath, "utf8").split("\n"); const start = lines.findIndex(l => l.includes("function detectWallets")); const end = lines.findIndex((l, i) => i > start && l.includes("function analyzeAddress")); if (start === -1 || end === -1) { console.error("No se localiza el analizador en dashboard.html"); process.exit(1); } // El analizador vive dentro del dashboard y usa la paleta y el índice de // entidades. Aquí se sustituyen por lo mínimo: colores de mentira y un índice // vacío, para que lo que se pruebe sean las heurísticas y no los datos. const preludio = ` const C = { green:"g", amber:"a", red:"r", t2:"t", blue:"b" }; const ENTITY_INDEX = new Map(); const OFAC_SET = new Set(); `; const src = lines.slice(start, end).join("\n"); const analyzeTx = new Function(preludio + src + "\nreturn analyzeTx;")(); const IN = (v,t,a) => ({ prevout:{ value:v, scriptpubkey_type:t, scriptpubkey_address:a }, txid:"aa", vout:0, sequence:0xffffffff }); const OUT = (v,t,a) => ({ value:v, scriptpubkey_type:t, scriptpubkey_address:a, scriptpubkey:"0014"+"11".repeat(20) }); const TX = (vin, vout, extra={}) => ({ txid:"ff".repeat(32), vin, vout, weight:800, fee:2000, locktime:0, status:{confirmed:true}, ...extra }); let pass = 0, fail = 0; function ok(nombre, cond, extra) { console.log(` ${cond ? "✓" : "✗"} ${nombre}`); if (!cond && extra) console.log(` ${extra}`); cond ? pass++ : fail++; } const check = (r, id) => r.checks.find(c => c.id === id); console.log("=== Coherencia de la nota ===\n"); // El peor caso imaginable: todo lo que puede restar, restando a la vez. // Muchas entradas de direcciones distintas y tipos mezclados, salidas legacy, // polvo entre las entradas, OP_RETURN, y un lote de destinatarios. const vinPeor = []; for (let i = 0; i < 25; i++) { vinPeor.push(IN(i === 0 ? 600 : 5000000, i % 2 ? "p2pkh" : "v0_p2wpkh", "addr" + i)); } vinPeor.push(IN(5000000, "p2pkh", "addr0")); // reutilización const voutPeor = []; for (let i = 0; i < 8; i++) voutPeor.push(OUT(1000000 + i * 7777, "p2pkh", "out" + i)); voutPeor.push({ value:0, scriptpubkey_type:"op_return", scriptpubkey:"6a24" + "ab".repeat(36), scriptpubkey_address:null }); const peor = analyzeTx(TX(vinPeor, voutPeor)); ok("la peor transacción posible no puntúa por debajo de 0", peor.score >= 0, `score = ${peor.score}`); ok("la peor transacción posible cae en banda BAJA", peor.band === "BAJA", `banda = ${peor.band}`); ok("la exposición heredada se reporta aparte y no toca la nota", peor.exposicion && peor.exposicion.items.length > 0, `items = ${JSON.stringify(peor.exposicion?.items?.map(i=>i.id))}`); // Suma de penalizaciones declaradas frente al máximo teórico: si el // denominador fuera menor que la suma de lo que puede restar, existiría una // transacción con score negativo antes del clamp. const sumaPenalizaciones = peor.checks .filter(c => !c.informational && c.pass === false) .reduce((s, c) => s + (c.penalty || 0), 0); ok("las deducciones reales no superan el 100% de la escala", 100 - peor.score <= 100, `deducciones equivalentes = ${100 - peor.score}`); console.log(` (penalizaciones declaradas en esta tx: ${sumaPenalizaciones})`); // Una transacción limpia: una entrada, dos salidas del mismo tipo, sin // redondos, sin polvo, sin OP_RETURN. const limpia = analyzeTx(TX( [IN(5000000, "v1_p2tr", "bc1pA")], [OUT(1234567, "v1_p2tr", "bc1pB"), OUT(3763000, "v1_p2tr", "bc1pC")] )); ok("una transacción limpia alcanza banda ALTA", limpia.band === "ALTA", `score = ${limpia.score}, banda = ${limpia.band}`); console.log("\n=== La nota mide solo lo que dependía de ti ===\n"); // Un cobro impecable desde un exchange que agrupa pagos. El destinatario no // eligió nada de esto: hasta la v1.17 le costaba 23 puntos de nota y no había // forma de mejorarla. const vinBatch = [IN(500000000, "v0_p2wpkh", "bc1qEXCHANGE")]; const voutBatch = []; for (let i = 0; i < 9; i++) voutBatch.push(OUT(1000000 + i * 31337, "v0_p2wpkh", "bc1qDEST" + i)); const batch = analyzeTx(TX(vinBatch, voutBatch)); ok("recibir de un lote se detecta", check(batch, "batch_payment")?.pass === false); ok("...pero no baja la nota: aparece como exposición heredada", batch.exposicion.items.some(i => i.id === "batch_payment"), `items = ${JSON.stringify(batch.exposicion.items.map(i=>i.id))}`); ok("...y la nota sigue siendo ALTA, porque quien cobra no hizo nada mal", batch.band === "ALTA", `score = ${batch.score}, banda = ${batch.band}`); // OP_RETURN ya no fuerza banda BAJA por decreto. const conOpReturn = analyzeTx(TX( [IN(5000000, "v1_p2tr", "bc1pA")], [OUT(4990000, "v1_p2tr", "bc1pB"), { value:0, scriptpubkey_type:"op_return", scriptpubkey:"6a0a"+"ab".repeat(10), scriptpubkey_address:null }] )); ok("OP_RETURN se detecta", check(conOpReturn, "op_return")?.pass === false); ok("...pero ya no fuerza banda BAJA por decreto", conOpReturn.band !== "BAJA", `banda = ${conOpReturn.band}`); // Un fallo grave impide ALTA aunque el número dé de sobra. const cambioVisible = analyzeTx(TX( [IN(100000000, "v0_p2wpkh", "bc1qA")], [OUT(100000, "v0_p2wpkh", "bc1qPAGO"), OUT(99895000, "v0_p2wpkh", "bc1qA")] )); ok("un fallo grave impide la banda ALTA aunque el número dé", cambioVisible.band !== "ALTA", `score = ${cambioVisible.score}, banda = ${cambioVisible.band}`); console.log("\n=== Polvo gastado junto a otras monedas (check nuevo) ===\n"); const conPolvo = analyzeTx(TX( [IN(600, "v0_p2wpkh", "bc1qPOLVO"), IN(5000000, "v0_p2wpkh", "bc1qMIA")], [OUT(4990000, "v0_p2wpkh", "bc1qDESTINO")] )); ok("detecta el polvo gastado junto a una moneda normal", check(conPolvo, "dust_spent")?.pass === false); const soloPolvo = analyzeTx(TX( [IN(600, "v0_p2wpkh", "bc1qA"), IN(700, "v0_p2wpkh", "bc1qB")], [OUT(900, "v0_p2wpkh", "bc1qC")] )); ok("no avisa si solo se gasta polvo (no revela vinculación nueva)", soloPolvo.checks.find(c => c.id === "dust_spent")?.pass === true); const sinPolvo = analyzeTx(TX( [IN(5000000, "v0_p2wpkh", "bc1qA"), IN(3000000, "v0_p2wpkh", "bc1qB")], [OUT(7990000, "v0_p2wpkh", "bc1qC")] )); ok("no avisa cuando ninguna entrada es polvo", sinPolvo.checks.find(c => c.id === "dust_spent")?.pass === true); console.log("\n=== Consolidación pura (hueco cerrado) ===\n"); const vinConsol = []; for (let i = 0; i < 20; i++) vinConsol.push(IN(5000000, "v0_p2wpkh", "bc1qA" + i)); const consol = analyzeTx(TX(vinConsol, [OUT(99900000, "v0_p2wpkh", "bc1qDESTINO")])); const cu = check(consol, "unnecessary_input"); ok("una consolidación de 20 entradas a 1 salida ya no pasa desapercibida", cu?.pass === false, `pass = ${cu?.pass}`); ok("se etiqueta como consolidación, con certeza y no como probable", cu?.label === "Consolidación de UTXOs" && cu?.certainty === "CERTEZA", `label = ${cu?.label}, certeza = ${cu?.certainty}`); ok("no se cobra dos veces: la penalización la lleva input_linkage", cu?.penalty === 0 && check(consol, "input_linkage")?.pass === false); console.log("\n=== La comisión como huella (check nuevo) ===\n"); // Comisión absoluta redonda: nadie llega a 10.000 sats clavados con un // estimador, que calcula tarifa por tamaño y devuelve números feos. const feeRedonda = analyzeTx(TX( [IN(5000000, "v1_p2tr", "bc1pA")], [OUT(4990000, "v1_p2tr", "bc1pB")], { fee: 10000, weight: 600 } )); ok("detecta una comisión absoluta redonda", check(feeRedonda, "fee_fingerprint")?.pass === true && /10000 sats exactos/.test(check(feeRedonda, "fee_fingerprint")?.detail || ""), check(feeRedonda, "fee_fingerprint")?.detail?.slice(0, 90)); // Tarifa entera: 20,00 sat/vB sale de teclear "20" en la casilla. const feeEntera = analyzeTx(TX( [IN(5000000, "v1_p2tr", "bc1pA")], [OUT(4996000, "v1_p2tr", "bc1pB")], { fee: 3000, weight: 600 } // vsize 150 -> 20,00 sat/vB )); ok("detecta una tarifa prácticamente entera", /prácticamente un número entero|sats exactos/.test(check(feeEntera, "fee_fingerprint")?.detail || ""), check(feeEntera, "fee_fingerprint")?.detail?.slice(0, 90)); // Un estimador automático deja números feos. const feeEstimada = analyzeTx(TX( [IN(5000000, "v1_p2tr", "bc1pA")], [OUT(4996873, "v1_p2tr", "bc1pB")], { fee: 3127, weight: 601 } )); ok("no ve huella donde hay un número feo de estimador", /sin forma de cifra elegida a mano/.test(check(feeEstimada, "fee_fingerprint")?.detail || ""), check(feeEstimada, "fee_fingerprint")?.detail?.slice(0, 90)); ok("la huella de comisión no penaliza (misma decisión que con RBF)", check(feeRedonda, "fee_fingerprint")?.penalty === 0 && check(feeRedonda, "fee_fingerprint")?.informational === true); console.log("\n=== Guardia de CoinJoin ===\n"); // Whirlpool: 5 entradas, 5 salidas de la misma denominación. const vinCJ = [], voutCJ = []; for (let i = 0; i < 6; i++) { vinCJ.push(IN(1100000, "v0_p2wpkh", "bc1qIN" + i)); voutCJ.push(OUT(1000000, "v0_p2wpkh", "bc1qOUT" + i)); } const cj = analyzeTx(TX(vinCJ, voutCJ)); ok("se reconoce como CoinJoin", check(cj, "coinjoin")?.pass === true); for (const id of ["input_linkage", "unnecessary_input", "input_type_mixing", "round_numbers", "dust_spent"]) { const c = check(cj, id); ok(`la guardia neutraliza ${id}`, c ? c.pass === true : true, `pass = ${c?.pass}`); } console.log(`\n${pass} correctos, ${fail} fallos`); process.exit(fail === 0 ? 0 : 1);