diff --git a/dashboard.html b/dashboard.html
index 4b9468e..459972c 100644
--- a/dashboard.html
+++ b/dashboard.html
@@ -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.valuev.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.valuev.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)