diff --git a/.gitignore b/.gitignore index 55bd8bb..6194a55 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ COHERENCIA.md GIT.md REPASO.md +HEURISTICAS.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c3c31a..6a12790 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,46 @@ y el versionado sigue [Versionado Semántico](https://semver.org/lang/es/): - **MENOR** — características nuevas que no rompen lo anterior - **PARCHE** — arreglos de errores +--- +## [1.15.0] — 2026-08-08 +### Corregido +- **La detección del cambio contaba una misma señal dos veces.** La "señal A" + (solo una salida comparte tipo de script con las entradas) y la "señal E" + (el tipo de output difiere del de las entradas) eran la misma condición + escrita dos veces —el mismo `filter`, la misma comparación, el mismo `=== 1`— + y cada una incrementaba el contador. Como el umbral para declarar el cambio + identificable es de dos señales, **ese único hecho bastaba por sí solo**. + - Consecuencia práctica: pagar desde una dirección bech32 a una taproot —de + lo más común que hay— salía marcado como "cambio identificable" con certeza + PROBABLE, sin ninguna otra evidencia. + - Y el informe mostraba las dos señales juntas, que se contradicen entre sí: + "tipo de script idéntico a inputs" y "tipo de output distinto al de inputs". + El mismo hecho descrito con palabras opuestas. + - Ahora es una sola señal. Esa transacción pasa a una señal y a **no + identificable**, que es lo correcto. Cuando queda una sola señal el check + lo dice en vez de callarse: existe, no basta, y conviene que se sepa que + otro analista menos escrupuloso la daría por buena ella sola. +- **Se podía señalar el pago como si fuera el cambio.** Cuando ninguna señal + fuerte apuntaba a un output concreto, el índice caía en `indexOf(smaller)`: + se asumía que el cambio es siempre la salida menor. Es falso en el caso más + común de todos —pagar poco desde una moneda grande—, donde el cambio es la + salida *mayor*. + - Comprobado con un pago de 0,001 BTC desde 1 BTC: la app decía en el detalle + "el output redondo es el pago" y acto seguido señalaba ese mismo output + como cambio. La información para acertar estaba delante y se descartaba. + - Esto no se quedaba en el analizador: el motor de peritaje usa ese índice + para detectar cadenas de peeling y para redactar el informe. Un cambio mal + identificado significa presentar como rastro del actor **la dirección del + destinatario del pago**, una persona ajena. + - Ahora cada señal apunta a un output o reconoce que no puede. El orden de + resolución es: reutilización de dirección de entrada → tipo de script → + la salida NO redonda → posición. Si ninguna apunta, el índice queda vacío + y se dice que hay señales pero no cuál — preferible a señalar mal. + +### Añadido +- `tests/test5.js` — seis casos de detección de cambio con el pago y el cambio + conocidos de antemano, incluidos los dos fallos anteriores como regresión. + --- ## [1.14.0] — 2026-08-08 ### Añadido diff --git a/dashboard.html b/dashboard.html index 158e627..c53e507 100644 --- a/dashboard.html +++ b/dashboard.html @@ -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=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, }); diff --git a/tests/test5.js b/tests/test5.js new file mode 100644 index 0000000..e8bc263 --- /dev/null +++ b/tests/test5.js @@ -0,0 +1,89 @@ +// Detección del output de cambio (guessChangeOutput) +// +// Extrae la función del dashboard.html y la ejecuta contra transacciones +// construidas a mano, donde sabemos de antemano cuál es el pago y cuál el +// cambio. Cubre los dos fallos corregidos en la v1.15.0: +// +// 1. Una misma condición se contaba como dos señales distintas, así que +// un pago corriente entre tipos de dirección distintos ya bastaba para +// declarar el cambio "identificable" con certeza PROBABLE. +// 2. Cuando ninguna señal fuerte apuntaba a un output, el índice caía en +// "el más pequeño". En un pago pequeño desde una moneda grande, el más +// pequeño es el PAGO: la app señalaba la dirección del destinatario +// como si fuera el cambio del emisor. +// +// Uso: node tests/test5.js + +const fs = require("fs"); +const path = require("path"); + +const htmlPath = path.join(__dirname, "..", "dashboard.html"); +const lines = fs.readFileSync(htmlPath, "utf8").split("\n"); +const start = lines.findIndex(l => l.includes("function guessChangeOutput")); +if (start === -1) { console.error("No se encuentra guessChangeOutput en dashboard.html"); process.exit(1); } +const end = lines.findIndex((l, i) => i > start && l.includes("function analyzeTx")); +const src = lines.slice(start, end).join("\n"); +const guessChangeOutput = new Function(src + "\nreturn guessChangeOutput;")(); + +const IN = (v,t,a) => ({ prevout:{ value:v, scriptpubkey_type:t, scriptpubkey_address:a }, txid:"aa", vout:0 }); +const OUT = (v,t,a) => ({ value:v, scriptpubkey_type:t, scriptpubkey_address:a, scriptpubkey:"00" }); + +let pass = 0, fail = 0; +function T(nombre, tx, esperado, esCoinJoin) { + const r = guessChangeOutput(tx, !!esCoinJoin); + const real = r.index == null ? null : tx.vout[r.index].scriptpubkey_address; + const ok = real === esperado; + console.log(` ${ok ? "✓" : "✗"} ${nombre}`); + if (!ok) { + console.log(` señaló como cambio: ${real === null ? "ninguno" : real}`); + console.log(` esperado: ${esperado === null ? "ninguno" : esperado}`); + r.details.forEach(d => console.log(` · ${d}`)); + fail++; + } else pass++; +} + +console.log("=== Detección del output de cambio ===\n"); + +// Regresión del fallo 1. Pagar desde bech32 a una dirección taproot es de lo +// más común que hay. Que los tipos difieran es UNA señal, no dos: sola no +// basta para señalar nada. +T("pago bech32 -> taproot, importes no redondos: una sola señal, no se afirma", + { vin:[IN(5000000,"v0_p2wpkh","bc1qA")], + vout:[OUT(1234567,"v1_p2tr","bc1pPAGO"), OUT(3765000,"v0_p2wpkh","bc1qCAMBIO")] }, + null); + +// Regresión del fallo 2. La salida redonda es el pago, luego el cambio es la +// otra — aunque la otra sea la grande. +T("pago redondo pequeño desde moneda grande: el cambio es el output GRANDE", + { vin:[IN(100000000,"v0_p2wpkh","bc1qA")], + vout:[OUT(100000,"v0_p2wpkh","bc1qPAGO"), OUT(99895000,"v0_p2wpkh","bc1qCAMBIO")] }, + "bc1qCAMBIO"); + +// La señal más fuerte que existe: el cambio vuelve a una dirección ya gastada. +T("el cambio reutiliza una dirección de las entradas", + { vin:[IN(5000000,"v0_p2wpkh","bc1qA")], + vout:[OUT(1234567,"v1_p2tr","bc1pPAGO"), OUT(3765000,"v0_p2wpkh","bc1qA")] }, + "bc1qA"); + +// Sin ninguna señal no se inventa nada. +T("mismo tipo en ambas salidas, sin redondos ni reuso: no se afirma nada", + { vin:[IN(5000000,"v0_p2wpkh","bc1qA")], + vout:[OUT(1234567,"v0_p2wpkh","bc1qB"), OUT(3765000,"v0_p2wpkh","bc1qC")] }, + null); + +// Caso clásico y correcto desde siempre: pago redondo grande, cambio pequeño. +T("pago redondo grande y cambio pequeño (caso clásico)", + { vin:[IN(11000000,"v0_p2wpkh","bc1qA")], + vout:[OUT(10000000,"v0_p2wpkh","bc1qPAGO"), OUT(985000,"v0_p2wpkh","bc1qCAMBIO")] }, + "bc1qCAMBIO"); + +// En un CoinJoin la heurística no aplica: aunque las señales estructurales +// existan, las entradas son de personas distintas. Esta tx dispararía señales +// si no fuera por la guardia. +T("CoinJoin: la guardia desactiva la heurística aunque haya señales", + { vin:[IN(100000000,"v0_p2wpkh","bc1qA")], + vout:[OUT(100000,"v1_p2tr","bc1pX"), OUT(99895000,"v0_p2wpkh","bc1qY")] }, + null, true); + +console.log(`\n${pass} correctos, ${fail} fallos`); +process.exit(fail === 0 ? 0 : 1);