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)