From de3ab5cc7e6b52e711f65b5805b9c83d4f0465c7 Mon Sep 17 00:00:00 2001 From: pikaro Date: Thu, 16 Jul 2026 12:10:09 +0200 Subject: [PATCH] =?UTF-8?q?refactor:=20extraer=20guessChangeOutput=20de=20?= =?UTF-8?q?analyzeTx=20(se=C3=B1ales=20A-F=20de=20cambio)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- dashboard.html | 145 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 96 insertions(+), 49 deletions(-) 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)