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:
Aitor
2026-08-08 18:42:41 +02:00
parent 2a1c7254e2
commit 428f67c132
4 changed files with 215 additions and 42 deletions
+85 -42
View File
@@ -1537,61 +1537,100 @@
);
const inputAddrs = tx.vin.map(v=>v.prevout?.scriptpubkey_address).filter(Boolean);
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);
let signals = 0, details = [], bonus = 0;
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);
// 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) {
const matchCount = tx.vout.filter(v=>v.scriptpubkey_type===dominantInType).length;
if (matchCount === 1) { signals++; signalA = true; details.push("tipo de script idéntico a inputs"); }
}
// Señal B + C: "pago redondo" y "cambio pequeño" solo cuentan por separado
// si apuntan a outputs distintos
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 matches = tx.vout.filter(v=>v.scriptpubkey_type===dominantInType);
if (matches.length === 1) {
signals++;
typeMatchIdx = tx.vout.findIndex(v=>v.scriptpubkey_type===dominantInType);
details.push("solo una de las dos salidas comparte tipo de script con las entradas");
}
}
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
if (signals >= 3) bonus += 5;
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
// de dirección de input > tipo idéntico a inputs > el output menor.
// Índice del cambio, por orden de fuerza. Solo señalan las que de verdad
// 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;
if (identifiable) {
if (outputToKnownAddr) {
index = tx.vout.findIndex(v=>v.scriptpubkey_address && inputAddrSet.has(v.scriptpubkey_address));
} else if (signalA) {
index = tx.vout.findIndex(v=>v.scriptpubkey_type===dominantInType);
} else {
index = tx.vout.indexOf(smaller);
}
index = outputToKnownAddr ? knownAddrIdx
: signalA ? typeMatchIdx
: roundOtherIdx !== null ? roundOtherIdx
: positionIdx;
}
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 };
@@ -1955,10 +1994,14 @@
id:"change_detection", label:"Output de cambio identificable", certainty: changeCertainty, pass:!changeIdentifiable,
actionability: "evitable",
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
? `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.",
penalty: changeEffectivePenalty,
});