refactor: extraer guessChangeOutput de analyzeTx (señales A-F de cambio)

Autocontenida para poder llamarse por salto desde el futuro motor de
rastreo forense, que necesita saber qué output concreto seguir (no solo
si el cambio es identificable). analyzeTx delega en ella sin cambio de
comportamiento — mismas señales, mismos umbrales, mismo texto.
This commit is contained in:
2026-07-16 12:10:09 +02:00
parent 55297ecae6
commit de3ab5cc7e
+96 -49
View File
@@ -1373,6 +1373,95 @@
health: { band: healthBand, color: healthColor, msg: healthMsg } };
}
// Estima qué output es el cambio combinando tipo de script, valores
// relativos, posición y reutilización de direcciones (señales A-F). Solo
// se afirma nada en transacciones de 2 outputs sin CoinJoin — con más
// salidas la herramienta no adivina para no dar una certeza que los datos
// no sostienen. Autocontenida (recalcula sus propias señales desde tx) para
// poder llamarse tanto desde analyzeTx como, salto a salto, desde el motor
// de rastreo forense, que necesita saber POR CUÁL output concreto seguir.
function guessChangeOutput(tx, likelyCJ) {
if (tx.vout.length !== 2 || likelyCJ) {
return { identifiable: false, index: null, signals: 0, details: [], bonus: 0, certainty: null };
}
const inTypes = tx.vin.map(v=>v.prevout?.scriptpubkey_type).filter(Boolean);
const dominantInType = inTypes[0];
const isBip69Inputs = tx.vin.length < 2 || tx.vin.every((v,i,a) => {
if (i===0) return true;
const pidA = a[i-1].txid||"", pidB = v.txid||"";
return pidA !== pidB ? pidA < pidB : (a[i-1].vout||0) <= (v.vout||0);
});
const isBip69Outputs = tx.vout.length < 2 || tx.vout.every((v,i,a) => {
if (i===0) return true;
if (a[i-1].value !== v.value) return a[i-1].value < v.value;
return (a[i-1].scriptpubkey||"") <= (v.scriptpubkey||"");
});
const isBip69 = isBip69Inputs && isBip69Outputs;
const roundOutputs = tx.vout.filter(v =>
v.value % 1000000 === 0 || v.value % 100000 === 0 || v.value % 10000000 === 0
);
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;
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)");
}
}
// 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";
// Í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.
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);
}
}
return { identifiable, index, signals, details, bonus, certainty };
}
function analyzeTx(tx) {
const checks = [];
const weights = {
@@ -1665,55 +1754,13 @@
});
}
// ── 10. Change detection — señales combinadas ─────────────────────
let changeSignals = 0;
let changeDetails = [];
let changeBonus = 0;
if (tx.vout.length === 2 && !likelyCJ) {
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;
if (dominantInType) {
const matchCount = tx.vout.filter(v=>v.scriptpubkey_type===dominantInType).length;
if (matchCount === 1) { changeSignals++; signalA = true; changeDetails.push("tipo de script idéntico a inputs"); }
}
// Señal B + C: "pago redondo" y "cambio pequeño" SOLO son independientes si
// apuntan a outputs distintos. Si el output redondo es el mayor (el pago),
// entonces "el menor es el cambio" es la MISMA observación → cuenta una sola vez.
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) {
// El redondo es el pequeño: dos pistas genuinamente distintas
changeSignals += 2; changeDetails.push("el output redondo es el pago"); changeDetails.push("output menor < 15% del total");
} else if (roundOutputs.length === 1) {
// Pago redondo grande + cambio pequeño = una sola observación
changeSignals++; changeDetails.push("pago redondo y cambio pequeño (misma señal)");
} else if (smallerIsSmall) {
// Solo la pista del tamaño, sin output redondo
changeSignals++; changeDetails.push("output menor < 15% del total");
}
// Señal D: output reutiliza dirección de input
const inputAddrSet = new Set(inputAddrs);
const outputToKnownAddr = tx.vout.some(v=>v.scriptpubkey_address && inputAddrSet.has(v.scriptpubkey_address));
if (outputToKnownAddr) { changeSignals+=2; changeDetails.push("output reutiliza dirección de input — cambio casi seguro"); changeBonus += 10; }
// Señal E: mismatch tipo input/output
if (hasOutputMismatch) { changeSignals++; changeDetails.push("tipo de output distinto al de inputs"); }
// Señal F: posición del cambio — solo aporta si la señal A (tipo) no se disparó ya,
// para no contar el mismo output dos veces (por tipo y por posición).
if (!isBip69 && !signalA) {
const lastOut = tx.vout[tx.vout.length - 1];
if (dominantInType && lastOut.scriptpubkey_type === dominantInType) {
changeSignals++; changeDetails.push("posición fija del cambio (índice 1, sin BIP69)");
}
}
// Bonus correlación: ≥3 señales independientes
if (changeSignals >= 3) changeBonus += 5;
}
const changeIdentifiable = changeSignals >= 2 && tx.vout.length === 2;
const changeCertainty = changeBonus >= 10 ? "PROBABLE" : changeSignals >= 3 ? "PROBABLE" : changeSignals >= 2 ? "PROBABLE" : "POSIBLE";
// ── 10. Change detection — señales combinadas (ver guessChangeOutput) ──
const changeGuess = guessChangeOutput(tx, likelyCJ);
const changeSignals = changeGuess.signals;
const changeDetails = changeGuess.details;
const changeBonus = changeGuess.bonus;
const changeIdentifiable = changeGuess.identifiable;
const changeCertainty = changeGuess.certainty || "POSIBLE";
// Scoring correlacionado: si input_reuse + unnecessary_input + change_detection
// se disparan juntos, limitamos la penalización acumulada (describen el mismo problema)