From afebeeaccd375b145521ec38be160914bc3e2f78 Mon Sep 17 00:00:00 2001 From: Aitor Date: Sat, 8 Aug 2026 19:03:08 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20cierra=20las=20cinco=20heur=C3=ADsticas?= =?UTF-8?q?=20ausentes=20del=20repaso?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La comisión como huella: una comisión absoluta redonda o una tarifa entera no salen de un estimador, salen de alguien que las escribió a mano. Informativo y sin penalizar, igual que RBF — castigarlo empujaría a elegir comisiones peores para disimular. Cambio con más de dos salidas, solo el caso inequívoco: si exactamente una salida comparte tipo con las entradas no hay nada que adivinar, y con más salidas la coincidencia por azar es todavía más improbable. POSIBLE, no PROBABLE, porque es una sola señal. Autoenvíos en el informe de wallet: solo cabe ahí, porque saber que todas las salidas son tuyas exige conocer el wallet entero. Avisa solo cuando ataron más de una dirección — mover una a otra no vincula nada nuevo. --- CHANGELOG.md | 35 +++++++++++++ dashboard.html | 140 +++++++++++++++++++++++++++++++++++++++++++++++-- tests/test5.js | 29 ++++++++++ tests/test6.js | 38 ++++++++++++++ 4 files changed, 237 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 005f297..c49fd2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,41 @@ 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.18.0] — 2026-08-08 +### Añadido +- **La comisión como huella.** El sat/vB que eliges dice algo del software y de + quien lo maneja, y el dato estaba en cada transacción sin que nadie lo + mirase. Se detectan dos firmas: una comisión **absoluta** redonda (10.000 + sats clavados — un estimador nunca da eso, calcula tarifa por tamaño y + devuelve números feos) y una **tarifa** prácticamente entera (20,00 sat/vB, + que sale de teclear un número redondo en la casilla). + - Informativo y sin penalización, por el mismo motivo que se decidió con RBF: + la señal es débil, y bajar la nota por ella empujaría a elegir comisiones + peores solo para disimular. Mal negocio. +- **El cambio ya se identifica en transacciones de más de dos salidas, cuando + el caso es inequívoco.** Antes la detección se plantaba en seco por encima de + dos salidas. Estaba bien argumentado, pero se tiraba información sólida: si + de N salidas **exactamente una** comparte tipo de script con las entradas, no + hay nada que adivinar — y con más salidas la coincidencia por azar es aún más + improbable que con dos. También cuenta el caso de una salida que vuelve a una + dirección ya gastada. Se marca POSIBLE y no PROBABLE: es una sola señal, y + hay monederos que devuelven el cambio a un tipo distinto del que gastan. +- **Aviso de autoenvíos que ataron direcciones**, en el informe de wallet. Es + una comprobación que solo cabe ahí: para saber que *todas* las salidas son + tuyas hace falta conocer tu wallet entero, cosa que el analizador de una + transacción suelta no puede saber. + - Detecta las transacciones donde entradas y salidas son todas tuyas, y avisa + solo cuando hubo daño real: más de una dirección de entrada. Mover una + dirección a otra no vincula nada nuevo y no merece alarma. + - Es un gesto que suele hacerse creyendo que despista y hace lo contrario: no + hubo ningún pago, pero al firmar varias direcciones a la vez quedaron + enlazadas en público. Se paga una comisión por empeorar la propia + privacidad. + +Con esto quedan cerradas las cinco heurísticas que el repaso del 2026-08-08 +señaló como ausentes. + --- ## [1.17.0] — 2026-08-08 ### Cambiado diff --git a/dashboard.html b/dashboard.html index 688c558..69e9213 100644 --- a/dashboard.html +++ b/dashboard.html @@ -1503,8 +1503,41 @@ polvoRecibido.sort((a,b) => b.time - a.time); const polvoSinGastar = polvoRecibido.filter(p => !p.gastado); + // ── Autoenvíos (mover dinero de una dirección tuya a otra tuya) ──── + // + // Esta comprobación solo cabe aquí. El analizador de una transacción + // suelta no puede hacerla: para saber que TODAS las salidas son tuyas + // hace falta conocer tu wallet entero, que es justo lo que este informe + // tiene y aquel no. + // + // Es un gesto que la gente hace creyendo que limpia el rastro —"paso + // esto a otra dirección mía"— y hace exactamente lo contrario: gasta + // varias direcciones tuyas a la vez para juntarlas en otra tuya, y deja + // las tres atadas en público sin que haya habido ningún pago de por + // medio. Se paga una comisión por empeorar la propia privacidad. + const autoenvios = []; + for (const tx of txs) { + const ins = (tx.vin || []).map(v => v.prevout?.scriptpubkey_address).filter(Boolean); + const outs = (tx.vout || []).map(v => v.scriptpubkey_address).filter(Boolean); + if (ins.length === 0 || outs.length === 0) continue; + // Todas las entradas Y todas las salidas dentro del propio wallet. + if (!ins.every(a => myAddrSet.has(a)) || !outs.every(a => myAddrSet.has(a))) continue; + const dirsEntrada = new Set(ins); + autoenvios.push({ + txid: tx.txid, + time: tx.status?.block_time || 0, + dirsEntrada: dirsEntrada.size, + dirsSalida: new Set(outs).size, + // Lo que de verdad importa: cuántas direcciones tuyas quedaron + // enlazadas por haber firmado juntas. + vinculadas: dirsEntrada.size, + }); + } + autoenvios.sort((a,b) => b.time - a.time); + const autoenviosDaninos = autoenvios.filter(a => a.vinculadas > 1); + return { totalTxs, activeCount: activeAddrs.length, reusedAddrs, clusters, linkReasons, history, - polvoRecibido, polvoSinGastar, + polvoRecibido, polvoSinGastar, autoenvios, autoenviosDaninos, health: { band: healthBand, color: healthColor, msg: healthMsg } }; } @@ -1566,12 +1599,48 @@ // demasiado común para significar nada. const ROUND_MIN = 2; + const SIN_CAMBIO = { identifiable: false, index: null, signals: 0, details: [], bonus: 0, certainty: null }; + function guessChangeOutput(tx, likelyCJ) { - if (tx.vout.length !== 2 || likelyCJ) { - return { identifiable: false, index: null, signals: 0, details: [], bonus: 0, certainty: null }; - } + if (likelyCJ || tx.vout.length < 2) return SIN_CAMBIO; + const inTypes = tx.vin.map(v=>v.prevout?.scriptpubkey_type).filter(Boolean); const dominantInType = inTypes[0]; + + // ── Más de dos salidas: solo el caso inequívoco ──────────────────── + // + // Con tres o más salidas la heurística general no vale, y la aplicación + // se plantaba en seco. Bien argumentado, pero se estaba tirando + // información sólida por prudencia: hay un subcaso donde no hay nada + // que adivinar. + // + // Si de N salidas EXACTAMENTE UNA comparte tipo de script con las + // entradas, esa es el cambio. El razonamiento es el mismo que en el caso + // de dos salidas: tu monedero devuelve el cambio a una dirección del + // mismo formato que las que gasta, mientras que los destinatarios usan + // el formato que les da la gana. Con más salidas la señal es incluso más + // limpia, porque la coincidencia por azar es más improbable. + // + // Se marca POSIBLE y no PROBABLE: es una sola señal, y hay monederos que + // devuelven el cambio a un tipo distinto del que gastan. + if (tx.vout.length > 2) { + const reusaIdx = tx.vout.findIndex(v => + v.scriptpubkey_address && tx.vin.some(i => i.prevout?.scriptpubkey_address === v.scriptpubkey_address)); + if (reusaIdx !== -1) { + return { identifiable: true, index: reusaIdx, signals: 2, bonus: 10, certainty: "PROBABLE", + details: [`de ${tx.vout.length} salidas, una vuelve a una dirección de las entradas — cambio casi seguro`] }; + } + if (!dominantInType) return SIN_CAMBIO; + const coinciden = tx.vout.filter(v => v.scriptpubkey_type === dominantInType); + if (coinciden.length !== 1) return SIN_CAMBIO; + return { + identifiable: true, + index: tx.vout.findIndex(v => v.scriptpubkey_type === dominantInType), + signals: 1, bonus: 0, certainty: "POSIBLE", + details: [`de ${tx.vout.length} salidas, solo una comparte tipo de script (${dominantInType}) con las entradas`], + }; + } + 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||""; @@ -2140,7 +2209,7 @@ ? `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.` + ? `Estructura de ${tx.vout.length} salidas. Con más de dos, esta herramienta solo se pronuncia si el caso es inequívoco —que una sola salida comparta tipo de script con las entradas, o que una vuelva a una dirección ya gastada— y aquí no lo es. Adivinar entre varias salidas daría una certeza que los datos no sostienen.` : 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.", @@ -2275,6 +2344,40 @@ penalty: 0, informational: true, }); + // ── 16-bis. La comisión como huella ─────────────────────────────── + // El sat/vB que eliges dice algo del software y de quien lo maneja, y + // el dato estaba en la transacción sin que nadie lo mirase. + // + // Dos firmas distintas: + // · Comisión ABSOLUTA redonda (10.000 sats clavados). Un estimador + // nunca da eso: calcula tarifa × tamaño y sale un número feo. Una + // cifra redonda significa que alguien la escribió a mano. + // · TARIFA casi exacta (20,00 sat/vB). Sale de teclear un número + // redondo en la casilla de tarifa. No es exacta del todo porque el + // tamaño real difiere del estimado al firmar, de ahí la tolerancia. + // + // Informativo y sin penalización, por el mismo motivo que RBF: la señal + // es débil y castigarla empujaría a elegir comisiones peores para + // esconderla. Mal negocio, y esta herramienta no está para eso. + const vsizeReal = tx.weight ? Math.ceil(tx.weight / 4) : null; + const tarifaReal = (tx.fee && vsizeReal) ? tx.fee / vsizeReal : null; + const comisionRedonda = tx.fee ? roundness(tx.fee) >= 2 : false; + const tarifaEntera = tarifaReal !== null + && Math.abs(tarifaReal - Math.round(tarifaReal)) < 0.03 + && Math.round(tarifaReal) >= 2; + const hayHuellaComision = comisionRedonda || tarifaEntera; + checks.push({ + id:"fee_fingerprint", label:"La comisión como huella", certainty:"POSIBLE", pass:true, + actionability: "evitable", + detail: hayHuellaComision + ? `Hecho (certeza): ${comisionRedonda ? `la comisión es de ${tx.fee} sats exactos` : `la tarifa sale a ${tarifaReal.toFixed(2)} sat/vB, prácticamente un número entero`}. Interpretación (posible): las cifras redondas no salen de un estimador automático —que calcula tarifa por tamaño y devuelve números feos— sino de alguien que las escribió a mano. Consecuencia: te mete en el grupo, más pequeño, de quien ajusta la comisión manualmente. Por sí sola no identifica a nadie; suma a un perfil junto con el resto de rasgos.` + : tarifaReal !== null + ? `Tarifa de ${tarifaReal.toFixed(2)} sat/vB, sin forma de cifra elegida a mano. Compatible con un estimador automático, que es lo que hace la mayoría.` + : "No hay datos de comisión y tamaño para evaluar este rasgo.", + didactic: "El fingerprinting no busca una prueba, busca acumular rasgos hasta que el grupo de gente que los comparte es lo bastante pequeño. La comisión es uno de ellos y casi nadie lo piensa: teclear «20 sat/vB» deja una marca distinta a dejar que el monedero calcule. Aquí no penaliza a propósito — la señal es débil y bajar la nota por ella te empujaría a elegir comisiones peores solo para disimular, que es un mal negocio. Lo mismo que se decidió con RBF.", + penalty: 0, informational: true, + }); + // ── 16. Tipo legacy (P2PKH / P2SH) ─────────────────────────────── // Inputs en formato legacy (P2PKH = 1..., P2SH = 3...) revelan UTXOs antiguos // o wallets que no han migrado a SegWit/Taproot. Mayor huella on-chain y @@ -5669,6 +5772,33 @@ )} + {/* Autoenvíos que ataron direcciones. Solo se muestra cuando + hubo daño real (más de una dirección de entrada): mover una + dirección a otra no vincula nada nuevo y no merece alarma. */} + {walletReport.autoenviosDaninos && walletReport.autoenviosDaninos.length > 0 && ( +
+
+ {walletReport.autoenviosDaninos.length} autoenvío(s) que ataron direcciones tuyas +
+
+ Moviste dinero de varias direcciones tuyas a otra dirección tuya. Es un gesto que suele hacerse creyendo que despista, y hace lo contrario: no hubo ningún pago, pero al firmar varias direcciones a la vez quedaron enlazadas en público para siempre. Pagaste una comisión por empeorar tu propia privacidad. +
+
+ {walletReport.autoenviosDaninos.slice(0,6).map(a=>( +
+ {a.vinculadas} direcciones atadas + {" · "}{a.time ? new Date(a.time*1000).toLocaleDateString("es-ES") : "sin confirmar"} + {" · "} +
+ ))} + {walletReport.autoenviosDaninos.length>6&&
…y {walletReport.autoenviosDaninos.length-6} más
} +
+
+ Ya está hecho y no se deshace. Para adelante: si de verdad necesitas mover monedas entre direcciones tuyas, muévelas de una en una, y solo cuando su origen ya estuviera relacionado. +
+
+ )} + {/* Bloque 1: Salud general */}
diff --git a/tests/test5.js b/tests/test5.js index 466d06c..1d6f8b5 100644 --- a/tests/test5.js +++ b/tests/test5.js @@ -87,6 +87,35 @@ T("CoinJoin: la guardia desactiva la heurística aunque haya señales", vout:[OUT(100000,"v1_p2tr","bc1pX"), OUT(99895000,"v0_p2wpkh","bc1qY")] }, null, true); +// ── Más de dos salidas: solo el caso inequívoco ───────────────────────── +// Antes la detección se plantaba en seco con más de 2 salidas. Ahora se +// pronuncia solo cuando no hay nada que adivinar. +console.log("\n=== Cambio con más de dos salidas ===\n"); + +T("3 salidas, solo una comparte tipo con las entradas: esa es el cambio", + { vin:[IN(10000000,"v0_p2wpkh","bc1qA")], + vout:[OUT(3000000,"v1_p2tr","bc1pX"), OUT(2000000,"p2pkh","1Y"), + OUT(4990000,"v0_p2wpkh","bc1qCAMBIO")] }, + "bc1qCAMBIO"); + +T("3 salidas, dos comparten tipo con las entradas: ambiguo, no se afirma", + { vin:[IN(10000000,"v0_p2wpkh","bc1qA")], + vout:[OUT(3000000,"v0_p2wpkh","bc1qX"), OUT(2000000,"p2pkh","1Y"), + OUT(4990000,"v0_p2wpkh","bc1qZ")] }, + null); + +T("4 salidas, una vuelve a una dirección de las entradas: señal más fuerte", + { vin:[IN(10000000,"v0_p2wpkh","bc1qA")], + vout:[OUT(3000000,"v0_p2wpkh","bc1qX"), OUT(2000000,"v0_p2wpkh","bc1qY"), + OUT(1000000,"v0_p2wpkh","bc1qZ"), OUT(3990000,"v0_p2wpkh","bc1qA")] }, + "bc1qA"); + +T("CoinJoin con muchas salidas: la guardia sigue mandando", + { vin:[IN(10000000,"v0_p2wpkh","bc1qA")], + vout:[OUT(3000000,"v1_p2tr","bc1pX"), OUT(2000000,"p2pkh","1Y"), + OUT(4990000,"v0_p2wpkh","bc1qCAMBIO")] }, + null, true); + // ── Redondez ──────────────────────────────────────────────────────────── // La versión anterior era `v%1000000===0 || v%100000===0 || v%10000000===0`, // donde la primera y la tercera condición sobran (todo múltiplo de un millón diff --git a/tests/test6.js b/tests/test6.js index 6b332a1..881bba1 100644 --- a/tests/test6.js +++ b/tests/test6.js @@ -160,6 +160,44 @@ ok("se etiqueta como consolidación, con certeza y no como probable", ok("no se cobra dos veces: la penalización la lleva input_linkage", cu?.penalty === 0 && check(consol, "input_linkage")?.pass === false); +console.log("\n=== La comisión como huella (check nuevo) ===\n"); + +// Comisión absoluta redonda: nadie llega a 10.000 sats clavados con un +// estimador, que calcula tarifa por tamaño y devuelve números feos. +const feeRedonda = analyzeTx(TX( + [IN(5000000, "v1_p2tr", "bc1pA")], + [OUT(4990000, "v1_p2tr", "bc1pB")], + { fee: 10000, weight: 600 } +)); +ok("detecta una comisión absoluta redonda", + check(feeRedonda, "fee_fingerprint")?.pass === true && + /10000 sats exactos/.test(check(feeRedonda, "fee_fingerprint")?.detail || ""), + check(feeRedonda, "fee_fingerprint")?.detail?.slice(0, 90)); + +// Tarifa entera: 20,00 sat/vB sale de teclear "20" en la casilla. +const feeEntera = analyzeTx(TX( + [IN(5000000, "v1_p2tr", "bc1pA")], + [OUT(4996000, "v1_p2tr", "bc1pB")], + { fee: 3000, weight: 600 } // vsize 150 -> 20,00 sat/vB +)); +ok("detecta una tarifa prácticamente entera", + /prácticamente un número entero|sats exactos/.test(check(feeEntera, "fee_fingerprint")?.detail || ""), + check(feeEntera, "fee_fingerprint")?.detail?.slice(0, 90)); + +// Un estimador automático deja números feos. +const feeEstimada = analyzeTx(TX( + [IN(5000000, "v1_p2tr", "bc1pA")], + [OUT(4996873, "v1_p2tr", "bc1pB")], + { fee: 3127, weight: 601 } +)); +ok("no ve huella donde hay un número feo de estimador", + /sin forma de cifra elegida a mano/.test(check(feeEstimada, "fee_fingerprint")?.detail || ""), + check(feeEstimada, "fee_fingerprint")?.detail?.slice(0, 90)); + +ok("la huella de comisión no penaliza (misma decisión que con RBF)", + check(feeRedonda, "fee_fingerprint")?.penalty === 0 && + check(feeRedonda, "fee_fingerprint")?.informational === true); + console.log("\n=== Guardia de CoinJoin ===\n"); // Whirlpool: 5 entradas, 5 salidas de la misma denominación.