Files
txoko-dashboard/tests/test6.js
T
pikaro b957fb626d feat: la nota mide solo lo que dependía de ti
Antes mezclaba dos cosas en un número: tus errores y la exposición que provoca
un tercero. Alguien impecable que cobró de un exchange sacaba peor nota que un
descuidado que cobró de un particular, y no tenía forma de mejorarla. Una nota
que no puedes subir no es una evaluación, es un reproche.

La nota se queda con los checks donde hay decisión tuya. El resto pasa a un
bloque propio, 'lo que hicieron otros', con su nivel de exposición.

Con la nota restringida a lo propio aparecía otro problema: una transacción con
el cambio identificable sacaba 93 y la etiqueta 'privacidad aceptable'. Ahora
cualquier fallo propio de peso >=10 impide la banda alta.

Y OP_RETURN deja de forzar banda BAJA por decreto: condenaba igual a una
inscripción y a un sello de OpenTimestamps.
2026-08-08 18:57:23 +02:00

181 lines
8.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}`);
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=== 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);