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.
152 lines
7.0 KiB
JavaScript
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);
|