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.
This commit is contained in:
2026-08-08 18:42:41 +02:00
parent 643236e47b
commit 0bfe6dca14
4 changed files with 215 additions and 42 deletions
+1
View File
@@ -30,3 +30,4 @@ COHERENCIA.md
GIT.md GIT.md
REPASO.md REPASO.md
HEURISTICAS.md
+40
View File
@@ -10,6 +10,46 @@ y el versionado sigue [Versionado Semántico](https://semver.org/lang/es/):
- **MENOR** — características nuevas que no rompen lo anterior - **MENOR** — características nuevas que no rompen lo anterior
- **PARCHE** — arreglos de errores - **PARCHE** — arreglos de errores
---
## [1.15.0] — 2026-08-08
### Corregido
- **La detección del cambio contaba una misma señal dos veces.** La "señal A"
(solo una salida comparte tipo de script con las entradas) y la "señal E"
(el tipo de output difiere del de las entradas) eran la misma condición
escrita dos veces —el mismo `filter`, la misma comparación, el mismo `=== 1`
y cada una incrementaba el contador. Como el umbral para declarar el cambio
identificable es de dos señales, **ese único hecho bastaba por sí solo**.
- Consecuencia práctica: pagar desde una dirección bech32 a una taproot —de
lo más común que hay— salía marcado como "cambio identificable" con certeza
PROBABLE, sin ninguna otra evidencia.
- Y el informe mostraba las dos señales juntas, que se contradicen entre sí:
"tipo de script idéntico a inputs" y "tipo de output distinto al de inputs".
El mismo hecho descrito con palabras opuestas.
- Ahora es una sola señal. Esa transacción pasa a una señal y a **no
identificable**, que es lo correcto. Cuando queda una sola señal el check
lo dice en vez de callarse: existe, no basta, y conviene que se sepa que
otro analista menos escrupuloso la daría por buena ella sola.
- **Se podía señalar el pago como si fuera el cambio.** Cuando ninguna señal
fuerte apuntaba a un output concreto, el índice caía en `indexOf(smaller)`:
se asumía que el cambio es siempre la salida menor. Es falso en el caso más
común de todos —pagar poco desde una moneda grande—, donde el cambio es la
salida *mayor*.
- Comprobado con un pago de 0,001 BTC desde 1 BTC: la app decía en el detalle
"el output redondo es el pago" y acto seguido señalaba ese mismo output
como cambio. La información para acertar estaba delante y se descartaba.
- Esto no se quedaba en el analizador: el motor de peritaje usa ese índice
para detectar cadenas de peeling y para redactar el informe. Un cambio mal
identificado significa presentar como rastro del actor **la dirección del
destinatario del pago**, una persona ajena.
- Ahora cada señal apunta a un output o reconoce que no puede. El orden de
resolución es: reutilización de dirección de entrada → tipo de script →
la salida NO redonda → posición. Si ninguna apunta, el índice queda vacío
y se dice que hay señales pero no cuál — preferible a señalar mal.
### Añadido
- `tests/test5.js` — seis casos de detección de cambio con el pago y el cambio
conocidos de antemano, incluidos los dos fallos anteriores como regresión.
--- ---
## [1.14.0] — 2026-08-08 ## [1.14.0] — 2026-08-08
### Añadido ### Añadido
+85 -42
View File
@@ -1537,61 +1537,100 @@
); );
const inputAddrs = tx.vin.map(v=>v.prevout?.scriptpubkey_address).filter(Boolean); const inputAddrs = tx.vin.map(v=>v.prevout?.scriptpubkey_address).filter(Boolean);
const inputAddrSet = new Set(inputAddrs); const inputAddrSet = new Set(inputAddrs);
let hasOutputMismatch = false;
if (dominantInType) {
hasOutputMismatch = tx.vout.filter(v=>v.scriptpubkey_type===dominantInType).length === 1;
}
const totalIn = tx.vin.reduce((s,v)=>s+(v.prevout?v.prevout.value:0),0); const totalIn = tx.vin.reduce((s,v)=>s+(v.prevout?v.prevout.value:0),0);
let signals = 0, details = [], bonus = 0; let signals = 0, details = [], bonus = 0;
const larger = tx.vout.reduce((a,b)=>a.value>b.value?a:b); const larger = tx.vout.reduce((a,b)=>a.value>b.value?a:b);
const smaller = tx.vout.reduce((a,b)=>a.value<b.value?a:b); const smaller = tx.vout.reduce((a,b)=>a.value<b.value?a:b);
// Señal A: tipo de script coincide con inputs
let signalA = false; // Cada señal apunta, cuando puede, a QUÉ output cree que es el cambio.
// Antes las señales solo se contaban y el índice se decidía al final por
// separado, lo que permitía que el contador dijera "identificable" y el
// índice señalara el output equivocado. Ahora van juntas.
// ── Señal A: solo una de las dos salidas comparte tipo de script con
// las entradas. Es la señal estructural más fiable en una tx de 2 salidas.
//
// OJO — aquí vivía un fallo: esta misma condición se evaluaba DOS veces,
// una como "señal A: tipo idéntico a inputs" y otra como "señal E: tipo
// distinto a inputs". El mismo hecho, contado dos veces y descrito con
// palabras opuestas. Como el umbral es signals>=2, ese hecho por sí solo
// bastaba para declarar el cambio identificable con certeza PROBABLE.
// Un pago corriente de bech32 a taproot ya disparaba la alarma.
let typeMatchIdx = null;
if (dominantInType) { if (dominantInType) {
const matchCount = tx.vout.filter(v=>v.scriptpubkey_type===dominantInType).length; const matches = tx.vout.filter(v=>v.scriptpubkey_type===dominantInType);
if (matchCount === 1) { signals++; signalA = true; details.push("tipo de script idéntico a inputs"); } if (matches.length === 1) {
} signals++;
// Señal B + C: "pago redondo" y "cambio pequeño" solo cuentan por separado typeMatchIdx = tx.vout.findIndex(v=>v.scriptpubkey_type===dominantInType);
// si apuntan a outputs distintos details.push("solo una de las dos salidas comparte tipo de script con las entradas");
const roundIsLarger = roundOutputs.length === 1 && roundOutputs[0].value === larger.value;
const smallerIsSmall = totalIn > 0 && smaller.value < totalIn * 0.15;
if (roundOutputs.length === 1 && smallerIsSmall && !roundIsLarger) {
signals += 2; details.push("el output redondo es el pago"); details.push("output menor < 15% del total");
} else if (roundOutputs.length === 1) {
signals++; details.push("pago redondo y cambio pequeño (misma señal)");
} else if (smallerIsSmall) {
signals++; details.push("output menor < 15% del total");
}
// Señal D: output reutiliza dirección de input
const outputToKnownAddr = tx.vout.some(v=>v.scriptpubkey_address && inputAddrSet.has(v.scriptpubkey_address));
if (outputToKnownAddr) { signals+=2; details.push("output reutiliza dirección de input — cambio casi seguro"); bonus += 10; }
// Señal E: mismatch tipo input/output
if (hasOutputMismatch) { signals++; details.push("tipo de output distinto al de inputs"); }
// Señal F: posición del cambio — solo si la señal A no se disparó ya
if (!isBip69 && !signalA) {
const lastOut = tx.vout[tx.vout.length - 1];
if (dominantInType && lastOut.scriptpubkey_type === dominantInType) {
signals++; details.push("posición fija del cambio (índice 1, sin BIP69)");
} }
} }
const signalA = typeMatchIdx !== null;
// ── Señal B: hay exactamente un output de cifra redonda.
// La lectura correcta es que el redondo es el PAGO, así que el cambio
// es el OTRO. Antes se contaba la señal y luego el índice caía en el
// output más pequeño, que en un pago pequeño desde una moneda grande es
// justo el pago: la app decía "el output redondo es el pago" y acto
// seguido lo señalaba como cambio.
let roundOtherIdx = null;
if (roundOutputs.length === 1) {
signals++;
const paymentIdx = tx.vout.indexOf(roundOutputs[0]);
roundOtherIdx = paymentIdx === 0 ? 1 : 0;
details.push("una salida es cifra redonda — suele ser el pago, y el cambio el otro");
}
// ── Señal C: una salida es mucho menor que el total gastado.
// Señal DÉBIL y sin dirección: no dice cuál es el cambio, porque el
// cambio es pequeño cuando gastas casi toda la moneda y grande cuando
// pagas poco desde una moneda gorda. Cuenta para el nivel de sospecha,
// no para señalar un output.
const smallerIsSmall = totalIn > 0 && smaller.value < totalIn * 0.15;
if (smallerIsSmall) {
signals++;
details.push("una salida es menos del 15% del total (no indica por sí sola cuál es el cambio)");
}
// ── Señal D: un output vuelve a una dirección de las entradas.
// La más fuerte de todas: el cambio es esa, sin ambigüedad.
const knownAddrIdx = tx.vout.findIndex(v=>v.scriptpubkey_address && inputAddrSet.has(v.scriptpubkey_address));
const outputToKnownAddr = knownAddrIdx !== -1;
if (outputToKnownAddr) {
signals += 2; bonus += 10;
details.push("una salida reutiliza una dirección de las entradas — cambio casi seguro");
}
// ── Señal E: posición fija del cambio, solo si la señal A no dijo ya
// lo mismo por una vía mejor.
let positionIdx = null;
if (!isBip69 && !signalA && dominantInType) {
const lastIdx = tx.vout.length - 1;
if (tx.vout[lastIdx].scriptpubkey_type === dominantInType) {
signals++; positionIdx = lastIdx;
details.push("el cambio ocupa la última posición y no hay orden BIP69");
}
}
// Bonus correlación: ≥3 señales independientes // Bonus correlación: ≥3 señales independientes
if (signals >= 3) bonus += 5; if (signals >= 3) bonus += 5;
const identifiable = signals >= 2; const identifiable = signals >= 2;
const certainty = bonus >= 10 ? "PROBABLE" : signals >= 3 ? "PROBABLE" : signals >= 2 ? "PROBABLE" : "POSIBLE"; const certainty = bonus >= 10 ? "PROBABLE" : signals >= 2 ? "PROBABLE" : "POSIBLE";
// Índice del output de cambio, por orden de fuerza de señal: reutilización // Índice del cambio, por orden de fuerza. Solo señalan las que de verdad
// de dirección de input > tipo idéntico a inputs > el output menor. // apuntan a un output; si ninguna lo hace, el índice queda en null y se
// dice que hay señales pero no cuál — que es preferible a señalar mal.
let index = null; let index = null;
if (identifiable) { if (identifiable) {
if (outputToKnownAddr) { index = outputToKnownAddr ? knownAddrIdx
index = tx.vout.findIndex(v=>v.scriptpubkey_address && inputAddrSet.has(v.scriptpubkey_address)); : signalA ? typeMatchIdx
} else if (signalA) { : roundOtherIdx !== null ? roundOtherIdx
index = tx.vout.findIndex(v=>v.scriptpubkey_type===dominantInType); : positionIdx;
} else { }
index = tx.vout.indexOf(smaller); if (identifiable && index === null) {
} details.push("las señales no coinciden en cuál de las dos salidas es el cambio");
} }
return { identifiable, index, signals, details, bonus, certainty }; return { identifiable, index, signals, details, bonus, certainty };
@@ -1955,10 +1994,14 @@
id:"change_detection", label:"Output de cambio identificable", certainty: changeCertainty, pass:!changeIdentifiable, id:"change_detection", label:"Output de cambio identificable", certainty: changeCertainty, pass:!changeIdentifiable,
actionability: "evitable", actionability: "evitable",
detail: changeIdentifiable detail: changeIdentifiable
? `${changeSignals} señal(es) identifican el cambio: ${changeDetails.join(" · ")}.${correlatedProblem?" (penalización reducida por correlación con otros checks)":""}` ? (changeGuess.index != null
? `Hecho (certeza): ${changeSignals} señales apuntan al output #${changeGuess.index} — ${changeDetails.join(" · ")}. Interpretación (probable): ese es el cambio, la moneda que vuelve a tu propio wallet. Consecuencia: quien lo identifique puede seguir tus gastos posteriores desde ahí.${correlatedProblem?" (penalización reducida por correlación con otros checks)":""}`
: `Hecho (certeza): hay ${changeSignals} señales de que el cambio es identificable — ${changeDetails.join(" · ")}. Interpretación: las señales no coinciden en cuál de las dos salidas es el cambio, así que no se señala ninguna. Consecuencia: la transacción filtra que hay un cambio, aunque esta herramienta no se atreva a decir cuál — otro analista con más contexto sí podría.${correlatedProblem?" (penalización reducida por correlación con otros checks)":""}`)
: tx.vout.length > 2 : tx.vout.length > 2
? `Estructura de ${tx.vout.length} outputs — la identificación del cambio es menos fiable y no se afirma aquí. La herramienta no adivina el cambio en transacciones con más de 2 salidas para no dar una certeza que los datos no sostienen.` ? `Estructura de ${tx.vout.length} outputs — la identificación del cambio es menos fiable y no se afirma aquí. La herramienta no adivina el cambio en transacciones con más de 2 salidas para no dar una certeza que los datos no sostienen.`
: "No hay suficientes señales para identificar el output de cambio.", : changeSignals === 1
? `Hay una señal, insuficiente para afirmar nada: ${changeDetails.join(" · ")}. Hace falta una segunda señal independiente para señalar un output, así que aquí no se señala ninguno. Conviene saber que la señal existe: un analista menos escrupuloso la daría por buena ella sola.`
: "No hay señales que permitan identificar el output de cambio.",
didactic: "Identificar el output de cambio es el objetivo central del chain analysis: quien conoce tu dirección de cambio puede seguir rastreando tus fondos en transacciones futuras. Se detecta combinando tipo de script, valores relativos, posición y reutilización de direcciones.", didactic: "Identificar el output de cambio es el objetivo central del chain analysis: quien conoce tu dirección de cambio puede seguir rastreando tus fondos en transacciones futuras. Se detecta combinando tipo de script, valores relativos, posición y reutilización de direcciones.",
penalty: changeEffectivePenalty, penalty: changeEffectivePenalty,
}); });
+89
View File
@@ -0,0 +1,89 @@
// 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);