From 428f67c1324e2cbecfab1c7cca6b3444652b12b8 Mon Sep 17 00:00:00 2001 From: Aitor Date: Sat, 8 Aug 2026 18:42:41 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20dos=20fallos=20en=20la=20detecci=C3=B3n?= =?UTF-8?q?=20del=20output=20de=20cambio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uno: la señal A y la señal E eran la misma condición escrita dos veces, así que un solo hecho llegaba al umbral de dos señales. Un pago corriente de bech32 a taproot salía como cambio identificable con certeza PROBABLE, y el informe mostraba las dos señales contradiciéndose entre sí. Dos: sin señal fuerte, el índice caía en la salida menor. Pagando poco desde una moneda grande el cambio es la mayor, así que la app señalaba el pago. El detalle llegaba a decir 'el output redondo es el pago' y a continuación lo marcaba como cambio. El peritaje usa ese índice, de modo que podía presentar la dirección del destinatario como rastro del actor. Ahora cada señal apunta a un output o admite que no puede, y si ninguna apunta el índice queda vacío. tests/test5.js deja los dos casos como regresión. --- .gitignore | 1 + CHANGELOG.md | 40 ++++++++++++++++ dashboard.html | 127 +++++++++++++++++++++++++++++++++---------------- tests/test5.js | 89 ++++++++++++++++++++++++++++++++++ 4 files changed, 215 insertions(+), 42 deletions(-) create mode 100644 tests/test5.js 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);