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

152 lines
7.0 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);
// ── Más de dos salidas: solo el caso inequívoco ─────────────────────────
// Antes la detección se plantaba en seco con más de 2 salidas. Ahora se
// pronuncia solo cuando no hay nada que adivinar.
console.log("\n=== Cambio con más de dos salidas ===\n");
T("3 salidas, solo una comparte tipo con las entradas: esa es el cambio",
{ vin:[IN(10000000,"v0_p2wpkh","bc1qA")],
vout:[OUT(3000000,"v1_p2tr","bc1pX"), OUT(2000000,"p2pkh","1Y"),
OUT(4990000,"v0_p2wpkh","bc1qCAMBIO")] },
"bc1qCAMBIO");
T("3 salidas, dos comparten tipo con las entradas: ambiguo, no se afirma",
{ vin:[IN(10000000,"v0_p2wpkh","bc1qA")],
vout:[OUT(3000000,"v0_p2wpkh","bc1qX"), OUT(2000000,"p2pkh","1Y"),
OUT(4990000,"v0_p2wpkh","bc1qZ")] },
null);
T("4 salidas, una vuelve a una dirección de las entradas: señal más fuerte",
{ vin:[IN(10000000,"v0_p2wpkh","bc1qA")],
vout:[OUT(3000000,"v0_p2wpkh","bc1qX"), OUT(2000000,"v0_p2wpkh","bc1qY"),
OUT(1000000,"v0_p2wpkh","bc1qZ"), OUT(3990000,"v0_p2wpkh","bc1qA")] },
"bc1qA");
T("CoinJoin con muchas salidas: la guardia sigue mandando",
{ vin:[IN(10000000,"v0_p2wpkh","bc1qA")],
vout:[OUT(3000000,"v1_p2tr","bc1pX"), OUT(2000000,"p2pkh","1Y"),
OUT(4990000,"v0_p2wpkh","bc1qCAMBIO")] },
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);