// 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);