El denominador de la nota incluía dos pesos que nunca restan (rbf, peeling) y excluía uno que sí (input_linkage, hasta 30), así que las deducciones podían superarlo en 17 puntos y dar negativo. Corregido en ambas direcciones, con los cortes de banda recalibrados a 79/54 para que ninguna transacción cambie de banda por el arreglo. 'Redondo' equivalía a 'múltiplo de 0,001 BTC' por dos condiciones redundantes, y se le escapaban pagos de 10.000 o 50.000 sats. Ahora se mide por ceros finales y la señal se gradúa. Dos checks nuevos. El de polvo avisaba de una vinculación futura y nunca miraba si estaba ocurriendo delante: ahora la detecta cuando el polvo se gasta junto a monedas normales. Y la consolidación pura (N entradas, 1 salida) no disparaba el check de inputs innecesarios; ahora se nombra, sin cobrarla dos veces porque input_linkage ya la penaliza. tests/test6.js cubre lo nuevo y la coherencia de la nota.
142 lines
6.4 KiB
JavaScript
142 lines
6.4 KiB
JavaScript
// 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}`);
|
|
|
|
// 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=== 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=== 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);
|