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.
123 lines
5.7 KiB
JavaScript
123 lines
5.7 KiB
JavaScript
// Detección del output de cambio (guessChangeOutput)
|
|
//
|
|
// Extrae la función del dashboard.html y la ejecuta contra transacciones
|
|
// construidas a mano, donde sabemos de antemano cuál es el pago y cuál el
|
|
// cambio. Cubre los dos fallos corregidos en la v1.15.0:
|
|
//
|
|
// 1. Una misma condición se contaba como dos señales distintas, así que
|
|
// un pago corriente entre tipos de dirección distintos ya bastaba para
|
|
// declarar el cambio "identificable" con certeza PROBABLE.
|
|
// 2. Cuando ninguna señal fuerte apuntaba a un output, el índice caía en
|
|
// "el más pequeño". En un pago pequeño desde una moneda grande, el más
|
|
// pequeño es el PAGO: la app señalaba la dirección del destinatario
|
|
// como si fuera el cambio del emisor.
|
|
//
|
|
// Uso: node tests/test5.js
|
|
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
const htmlPath = path.join(__dirname, "..", "dashboard.html");
|
|
const lines = fs.readFileSync(htmlPath, "utf8").split("\n");
|
|
// Se extrae desde roundness() porque guessChangeOutput depende de ella y de
|
|
// ROUND_MIN. El corte de abajo es analyzeTx, la siguiente función del archivo.
|
|
const start = lines.findIndex(l => l.includes("function roundness"));
|
|
if (start === -1) { console.error("No se encuentra roundness en dashboard.html"); process.exit(1); }
|
|
const end = lines.findIndex((l, i) => i > start && l.includes("function analyzeTx"));
|
|
const src = lines.slice(start, end).join("\n");
|
|
const { guessChangeOutput, roundness } = new Function(src + "\nreturn { guessChangeOutput, roundness };")();
|
|
|
|
const IN = (v,t,a) => ({ prevout:{ value:v, scriptpubkey_type:t, scriptpubkey_address:a }, txid:"aa", vout:0 });
|
|
const OUT = (v,t,a) => ({ value:v, scriptpubkey_type:t, scriptpubkey_address:a, scriptpubkey:"00" });
|
|
|
|
let pass = 0, fail = 0;
|
|
function T(nombre, tx, esperado, esCoinJoin) {
|
|
const r = guessChangeOutput(tx, !!esCoinJoin);
|
|
const real = r.index == null ? null : tx.vout[r.index].scriptpubkey_address;
|
|
const ok = real === esperado;
|
|
console.log(` ${ok ? "✓" : "✗"} ${nombre}`);
|
|
if (!ok) {
|
|
console.log(` señaló como cambio: ${real === null ? "ninguno" : real}`);
|
|
console.log(` esperado: ${esperado === null ? "ninguno" : esperado}`);
|
|
r.details.forEach(d => console.log(` · ${d}`));
|
|
fail++;
|
|
} else pass++;
|
|
}
|
|
|
|
console.log("=== Detección del output de cambio ===\n");
|
|
|
|
// Regresión del fallo 1. Pagar desde bech32 a una dirección taproot es de lo
|
|
// más común que hay. Que los tipos difieran es UNA señal, no dos: sola no
|
|
// basta para señalar nada.
|
|
T("pago bech32 -> taproot, importes no redondos: una sola señal, no se afirma",
|
|
{ vin:[IN(5000000,"v0_p2wpkh","bc1qA")],
|
|
vout:[OUT(1234567,"v1_p2tr","bc1pPAGO"), OUT(3765000,"v0_p2wpkh","bc1qCAMBIO")] },
|
|
null);
|
|
|
|
// Regresión del fallo 2. La salida redonda es el pago, luego el cambio es la
|
|
// otra — aunque la otra sea la grande.
|
|
T("pago redondo pequeño desde moneda grande: el cambio es el output GRANDE",
|
|
{ vin:[IN(100000000,"v0_p2wpkh","bc1qA")],
|
|
vout:[OUT(100000,"v0_p2wpkh","bc1qPAGO"), OUT(99895000,"v0_p2wpkh","bc1qCAMBIO")] },
|
|
"bc1qCAMBIO");
|
|
|
|
// La señal más fuerte que existe: el cambio vuelve a una dirección ya gastada.
|
|
T("el cambio reutiliza una dirección de las entradas",
|
|
{ vin:[IN(5000000,"v0_p2wpkh","bc1qA")],
|
|
vout:[OUT(1234567,"v1_p2tr","bc1pPAGO"), OUT(3765000,"v0_p2wpkh","bc1qA")] },
|
|
"bc1qA");
|
|
|
|
// Sin ninguna señal no se inventa nada.
|
|
T("mismo tipo en ambas salidas, sin redondos ni reuso: no se afirma nada",
|
|
{ vin:[IN(5000000,"v0_p2wpkh","bc1qA")],
|
|
vout:[OUT(1234567,"v0_p2wpkh","bc1qB"), OUT(3765000,"v0_p2wpkh","bc1qC")] },
|
|
null);
|
|
|
|
// Caso clásico y correcto desde siempre: pago redondo grande, cambio pequeño.
|
|
T("pago redondo grande y cambio pequeño (caso clásico)",
|
|
{ vin:[IN(11000000,"v0_p2wpkh","bc1qA")],
|
|
vout:[OUT(10000000,"v0_p2wpkh","bc1qPAGO"), OUT(985000,"v0_p2wpkh","bc1qCAMBIO")] },
|
|
"bc1qCAMBIO");
|
|
|
|
// En un CoinJoin la heurística no aplica: aunque las señales estructurales
|
|
// existan, las entradas son de personas distintas. Esta tx dispararía señales
|
|
// si no fuera por la guardia.
|
|
T("CoinJoin: la guardia desactiva la heurística aunque haya señales",
|
|
{ vin:[IN(100000000,"v0_p2wpkh","bc1qA")],
|
|
vout:[OUT(100000,"v1_p2tr","bc1pX"), OUT(99895000,"v0_p2wpkh","bc1qY")] },
|
|
null, true);
|
|
|
|
// ── Redondez ────────────────────────────────────────────────────────────
|
|
// La versión anterior era `v%1000000===0 || v%100000===0 || v%10000000===0`,
|
|
// donde la primera y la tercera condición sobran (todo múltiplo de un millón
|
|
// lo es de cien mil). Equivalía a "múltiplo de 0,001 BTC" y se le escapaban
|
|
// pagos tan redondos como 10.000 o 50.000 sats.
|
|
console.log("\n=== Redondez de las cifras ===\n");
|
|
function R(sats, minimo) {
|
|
const nivel = roundness(sats);
|
|
const ok = nivel >= minimo;
|
|
console.log(` ${ok ? "✓" : "✗"} ${String(sats).padStart(9)} sat = ${(sats/1e8).toFixed(8)} BTC -> nivel ${nivel} (mínimo esperado ${minimo})`);
|
|
ok ? pass++ : fail++;
|
|
}
|
|
R(100000000, 4); // 1 BTC
|
|
R( 10000000, 4); // 0,1 BTC
|
|
R( 1000000, 3); // 0,01 BTC
|
|
R( 100000, 3); // 0,001 BTC
|
|
R( 250000, 2); // 0,0025 BTC — se escapaba antes
|
|
R( 50000, 2); // 0,0005 BTC — se escapaba antes
|
|
R( 10000, 2); // 0,0001 BTC — se escapaba antes
|
|
|
|
// Y lo que NO debe considerarse redondo.
|
|
function NR(sats) {
|
|
const nivel = roundness(sats);
|
|
const ok = nivel < 2;
|
|
console.log(` ${ok ? "✓" : "✗"} ${String(sats).padStart(9)} sat -> nivel ${nivel} (no debe llegar a 2)`);
|
|
ok ? pass++ : fail++;
|
|
}
|
|
NR(123456);
|
|
NR(99895000);
|
|
NR(1234567);
|
|
|
|
console.log(`\n${pass} correctos, ${fail} fallos`);
|
|
process.exit(fail === 0 ? 0 : 1);
|