Files
pikaro 7d05dae8cf feat: cierra las cinco heurísticas ausentes del repaso
La comisión como huella: una comisión absoluta redonda o una tarifa entera no
salen de un estimador, salen de alguien que las escribió a mano. Informativo y
sin penalizar, igual que RBF — castigarlo empujaría a elegir comisiones peores
para disimular.

Cambio con más de dos salidas, solo el caso inequívoco: si exactamente una
salida comparte tipo con las entradas no hay nada que adivinar, y con más
salidas la coincidencia por azar es todavía más improbable. POSIBLE, no
PROBABLE, porque es una sola señal.

Autoenvíos en el informe de wallet: solo cabe ahí, porque saber que todas las
salidas son tuyas exige conocer el wallet entero. Avisa solo cuando ataron más
de una dirección — mover una a otra no vincula nada nuevo.
2026-08-08 19:03:08 +02:00

219 lines
10 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=== 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);