fix: cuadrar el denominador de la nota y ampliar la redondez; dos checks nuevos
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.
This commit is contained in:
+141
@@ -0,0 +1,141 @@
|
||||
// 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);
|
||||
Reference in New Issue
Block a user