Files
txoko-dashboard/tests/test5.js
T
pikaro 0bfe6dca14 fix: dos fallos en la detección del output de cambio
Uno: la señal A y la señal E eran la misma condición escrita dos veces, así que
un solo hecho llegaba al umbral de dos señales. Un pago corriente de bech32 a
taproot salía como cambio identificable con certeza PROBABLE, y el informe
mostraba las dos señales contradiciéndose entre sí.

Dos: sin señal fuerte, el índice caía en la salida menor. Pagando poco desde
una moneda grande el cambio es la mayor, así que la app señalaba el pago. El
detalle llegaba a decir 'el output redondo es el pago' y a continuación lo
marcaba como cambio. El peritaje usa ese índice, de modo que podía presentar
la dirección del destinatario como rastro del actor.

Ahora cada señal apunta a un output o admite que no puede, y si ninguna apunta
el índice queda vacío. tests/test5.js deja los dos casos como regresión.
2026-08-08 18:42:41 +02:00

90 lines
4.2 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");
const start = lines.findIndex(l => l.includes("function guessChangeOutput"));
if (start === -1) { console.error("No se encuentra guessChangeOutput 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 = new Function(src + "\nreturn guessChangeOutput;")();
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);
console.log(`\n${pass} correctos, ${fail} fallos`);
process.exit(fail === 0 ? 0 : 1);