fix: cuadrar el denominador de la nota y ampliar la redondez; dos checks nuevos

El denominador de la nota incluía dos pesos que nunca restan (rbf, peeling) y
excluía uno que sí (input_linkage, hasta 30), así que las deducciones podían
superarlo en 17 puntos y dar negativo. Corregido en ambas direcciones, con los
cortes de banda recalibrados a 79/54 para que ninguna transacción cambie de
banda por el arreglo.

'Redondo' equivalía a 'múltiplo de 0,001 BTC' por dos condiciones redundantes,
y se le escapaban pagos de 10.000 o 50.000 sats. Ahora se mide por ceros
finales y la señal se gradúa.

Dos checks nuevos. El de polvo avisaba de una vinculación futura y nunca
miraba si estaba ocurriendo delante: ahora la detecta cuando el polvo se gasta
junto a monedas normales. Y la consolidación pura (N entradas, 1 salida) no
disparaba el check de inputs innecesarios; ahora se nombra, sin cobrarla dos
veces porque input_linkage ya la penaliza.

tests/test6.js cubre lo nuevo y la coherencia de la nota.
This commit is contained in:
Aitor
2026-08-08 18:52:40 +02:00
parent 428f67c132
commit 320db70ee5
5 changed files with 438 additions and 38 deletions
+58
View File
@@ -10,6 +10,64 @@ y el versionado sigue [Versionado Semántico](https://semver.org/lang/es/):
- **MENOR** — características nuevas que no rompen lo anterior - **MENOR** — características nuevas que no rompen lo anterior
- **PARCHE** — arreglos de errores - **PARCHE** — arreglos de errores
---
## [1.16.0] — 2026-08-08
### Corregido
- **El denominador de la nota de privacidad no cuadraba con los pesos.** La
nota se calcula como `100 - (deducciones / maxPossible) * 100`, donde
`maxPossible` es la suma del objeto `weights`. Ese objeto tenía dos
desajustes, en direcciones opuestas y ninguno intencionado:
- Incluía `rbf` (5) y `peeling` (8), que son informativos y **nunca restan**.
Trece puntos de denominador imposibles de alcanzar, que inflaban
sistemáticamente todas las notas.
- **No** incluía `input_linkage`, que resta hasta 30 desde una variable
suelta. Las deducciones podían superar al denominador en 17 puntos y dar
una nota negativa, tapada solo por el `Math.max(0, …)`.
- Ahora todo lo que puede restar está en `weights` y nada más lo está. Los
cortes de banda pasan de 75/45 a 79/54, que son los valores que hacen que
una transacción reciba **exactamente la misma banda que antes** con el
denominador corregido. Queda anotado en el código que normalizar por la
suma de los pesos obliga a recalibrar cada vez que se añade un check —
porque cada check nuevo hace parecer menos graves a los anteriores.
- El texto explicativo bajo la nota derivaba la banda por su cuenta con los
cortes antiguos escritos a mano. Ahora usa la banda ya calculada.
- **"Cifra redonda" estaba mal definido.** La condición era
`v % 1000000 === 0 || v % 100000 === 0 || v % 10000000 === 0`, donde la
primera y la tercera sobran: todo múltiplo de un millón lo es de cien mil.
Equivalía a "múltiplo de 0,001 BTC", así que pagos tan redondos como 10.000
o 50.000 sats —de lo más común con las comisiones de hoy— no se veían. La
heurística estaba infrautilizada, no equivocada.
- Ahora se mide por ceros finales y la señal se gradúa: pagar 0,5 BTC
clavados delata más que pagar 0,0123, y la penalización lo refleja.
- El didáctico dice ahora el punto ciego: si pagas una cantidad redonda **en
euros**, en BTC sale un número con todos sus decimales y esta heurística no
ve nada.
### Añadido
- **Nuevo aviso: se gasta polvo junto a otras monedas.** El check de polvo
advertía de que una salida diminuta "quedará vinculada con las demás si se
gasta junto a ellas" — y la aplicación nunca miraba si eso estaba ocurriendo
delante de ella, pese a tener el dato en la mano. Ahora lo mira: si entre las
entradas hay una moneda de tamaño polvo gastada junto a otras de importe
normal, el ataque ya no es una hipótesis, se acaba de consumar, y se dice con
CERTEZA. No aplica en CoinJoin ni cuando solo se gasta polvo, que no revela
vinculación nueva.
- **La consolidación pura ya no pasa desapercibida.** Una transacción de N
entradas a **una sola salida** —el acto que más privacidad destruye de una
vez— no disparaba el check de inputs innecesarios: su condición exige que una
entrada cubra el pago *y sobre*, y en una consolidación la salida vale casi
lo mismo que la suma de entradas. Comprobado con 20 entradas a 1 salida.
Ahora se nombra como lo que es, con certeza. No suma penalización propia a
propósito: la vinculación ya la cobra `input_linkage`, y cobrarla dos veces
sería repetir el error que este mismo repaso acaba de corregir.
- `tests/test6.js` — el analizador completo contra transacciones construidas a
mano: coherencia de la nota, los dos checks nuevos y la guardia de CoinJoin.
- `tests/test5.js` gana la comprobación de los niveles de redondez.
- `tests/README.md` explica ahora las dos familias de prueba y deja escrito que
las heurísticas se comprueban contra transacciones fabricadas, no contra la
cadena real: eso demuestra que la lógica hace lo que dice, pero no cuántas
veces acierta ahí fuera.
--- ---
## [1.15.0] — 2026-08-08 ## [1.15.0] — 2026-08-08
### Corregido ### Corregido
+166 -30
View File
@@ -1515,6 +1515,58 @@
// no sostienen. Autocontenida (recalcula sus propias señales desde tx) para // no sostienen. Autocontenida (recalcula sus propias señales desde tx) para
// poder llamarse tanto desde analyzeTx como, salto a salto, desde el motor // poder llamarse tanto desde analyzeTx como, salto a salto, desde el motor
// de rastreo forense, que necesita saber POR CUÁL output concreto seguir. // de rastreo forense, que necesita saber POR CUÁL output concreto seguir.
// Cortes de banda de la nota de privacidad.
//
// Estaban en 75 y 45 cuando el denominador de la nota era 196. Ese
// denominador ha cambiado a 233 al corregir los pesos y añadir dust_spent,
// así que todas las notas suben ~4 puntos: mantener 75/45 habría relajado
// el listón sin que nadie lo decidiera. Estos valores son los que hacen
// que una transacción reciba hoy exactamente la misma banda que recibía
// antes de tocar nada:
// 75 sobre 196 -> 49 puntos de deducción -> 79 sobre 233
// 45 sobre 196 -> 108 puntos de deducción -> 54 sobre 233
//
// AVISO ESTRUCTURAL: normalizar por la suma de todos los pesos tiene un
// efecto perverso — cada check nuevo agranda el denominador y hace que
// todos los problemas anteriores parezcan menos graves. Por eso hay que
// recalibrar estos dos números cada vez que se añade un check, y por eso
// conviene decidir algún día si la nota debe seguir normalizándose o
// restar puntos directamente sobre 100 (como hace analyzeAddress).
const BANDA_ALTA = 79;
const BANDA_MEDIA = 54;
// ¿Es una cifra "redonda"? Devuelve la fuerza de la señal, no un sí/no.
//
// La versión anterior era `v % 1000000 === 0 || v % 100000 === 0 ||
// v % 10000000 === 0`, donde la primera y la tercera condición sobran:
// todo lo divisible por un millón lo es por cien mil. La expresión entera
// equivalía a "múltiplo de 0,001 BTC", así que pagos tan redondos como
// 10.000 o 50.000 sats —de lo más común con las comisiones de hoy— no se
// veían. La heurística estaba infrautilizada, no equivocada.
//
// Ahora se mide por ceros finales y se gradúa: cuanto más redondo, más
// improbable que sea un cambio (que es lo que sobra de una resta) y más
// probable que sea el pago.
//
// LÍMITE QUE CONVIENE CONOCER: esto no ve el pago redondo en euros. Quien
// paga 50 € manda una cantidad de BTC con todos sus decimales, y para esa
// persona la heurística falla en silencio. Es una de las razones por las
// que pagar en fiat convertido protege más de lo que parece.
function roundness(sats) {
if (!Number.isFinite(sats) || sats <= 0) return 0;
if (sats % 100000000 === 0) return 4; // BTC enteros
if (sats % 10000000 === 0) return 4; // 0,1 BTC
if (sats % 1000000 === 0) return 3; // 0,01 BTC
if (sats % 100000 === 0) return 3; // 0,001 BTC
if (sats % 10000 === 0) return 2; // 0,0001 BTC
if (sats % 1000 === 0) return 1; // 0,00001 BTC
return 0;
}
// Umbral para considerar una salida "redonda" a efectos de heurística.
// 2 = múltiplo de 10.000 sats. Por debajo (múltiplos de 1.000) la señal es
// demasiado común para significar nada.
const ROUND_MIN = 2;
function guessChangeOutput(tx, likelyCJ) { function guessChangeOutput(tx, likelyCJ) {
if (tx.vout.length !== 2 || likelyCJ) { if (tx.vout.length !== 2 || likelyCJ) {
return { identifiable: false, index: null, signals: 0, details: [], bonus: 0, certainty: null }; return { identifiable: false, index: null, signals: 0, details: [], bonus: 0, certainty: null };
@@ -1532,9 +1584,7 @@
return (a[i-1].scriptpubkey||"") <= (v.scriptpubkey||""); return (a[i-1].scriptpubkey||"") <= (v.scriptpubkey||"");
}); });
const isBip69 = isBip69Inputs && isBip69Outputs; const isBip69 = isBip69Inputs && isBip69Outputs;
const roundOutputs = tx.vout.filter(v => const roundOutputs = tx.vout.filter(v => roundness(v.value) >= ROUND_MIN);
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 inputAddrs = tx.vin.map(v=>v.prevout?.scriptpubkey_address).filter(Boolean);
const inputAddrSet = new Set(inputAddrs); const inputAddrSet = new Set(inputAddrs);
const totalIn = tx.vin.reduce((s,v)=>s+(v.prevout?v.prevout.value:0),0); const totalIn = tx.vin.reduce((s,v)=>s+(v.prevout?v.prevout.value:0),0);
@@ -1638,14 +1688,28 @@
function analyzeTx(tx) { function analyzeTx(tx) {
const checks = []; const checks = [];
// Pesos que SÍ restan puntos. La regla es que todo lo que esté aquí
// pueda aparecer en `deductions`, y que todo lo que aparezca en
// `deductions` esté aquí. Antes no se cumplía en ninguna dirección:
// · rbf (5) y peeling (8) vivían aquí pero son informativos y nunca
// restan — trece puntos de denominador imposibles de alcanzar, que
// inflaban todas las notas.
// · input_linkage restaba hasta 30 desde una variable suelta que no
// estaba en este objeto, así que ni siquiera entraba en el
// denominador: las deducciones podían superarlo en 17 puntos y dar
// una nota negativa, tapada solo por el Math.max(0, …).
// Con los dos desajustes corregidos, un peso de 25 sobre un total de
// 213 significa lo que parece.
const weights = { const weights = {
input_reuse: 25, input_type_mixing: 14, output_type_mismatch: 8, input_reuse: 25, input_linkage: 30, input_type_mixing: 14,
round_numbers: 10, rbf: 5, peeling: 8, output_type_mismatch: 8, round_numbers: 10,
unnecessary_input: 12, dust: 8, change_detection: 10, unnecessary_input: 12, dust: 8, dust_spent: 20, change_detection: 10,
wallet_fingerprint: 6, batch_payment: 45, op_return: 30, legacy_type: 15, wallet_fingerprint: 6, batch_payment: 45, op_return: 30, legacy_type: 15,
}; };
// entity_ofac y rbf no aparecen aquí a propósito: ninguno de los dos // Checks informativos: no restan nunca, así que no tienen peso ni entran
// reduce tu privacidad. Ver sus checks para el razonamiento. // en el denominador. entity_ofac y rbf están aquí por decisión razonada
// (ninguno reduce tu privacidad); peeling porque una tx suelta 1-in/2-out
// no es una cadena de peeling. Ver cada check para el razonamiento.
let deductions = 0; let deductions = 0;
const totalIn = tx.vin.reduce((s,v)=>s+(v.prevout?v.prevout.value:0),0); const totalIn = tx.vin.reduce((s,v)=>s+(v.prevout?v.prevout.value:0),0);
@@ -1764,12 +1828,14 @@
const nVinculadas = uniqueInputAddrs.size; const nVinculadas = uniqueInputAddrs.size;
const hayVinculacion = nVinculadas > 1 && !likelyCJ; const hayVinculacion = nVinculadas > 1 && !likelyCJ;
// Escalonado: consolidar dos monedas es rutina; juntar decenas es otra // Escalonado: consolidar dos monedas es rutina; juntar decenas es otra
// cosa. El peso sube con el número de direcciones que quedan atadas. // cosa. El peso sube con el número de direcciones que quedan atadas,
// hasta el máximo declarado en weights.input_linkage — que es también
// lo que aporta al denominador de la nota.
const pesoVinculacion = !hayVinculacion ? 0 const pesoVinculacion = !hayVinculacion ? 0
: nVinculadas >= 20 ? 30 : nVinculadas >= 20 ? weights.input_linkage
: nVinculadas >= 10 ? 22 : nVinculadas >= 10 ? Math.round(weights.input_linkage * 0.73)
: nVinculadas >= 5 ? 15 : nVinculadas >= 5 ? Math.round(weights.input_linkage * 0.5)
: 8; : Math.round(weights.input_linkage * 0.27);
checks.push({ checks.push({
id:"input_linkage", label:"Direcciones que quedan vinculadas entre sí", certainty:"CERTEZA", id:"input_linkage", label:"Direcciones que quedan vinculadas entre sí", certainty:"CERTEZA",
pass: !hayVinculacion, pass: !hayVinculacion,
@@ -1840,20 +1906,24 @@
// Sin penalización por no usar CoinJoin — es una técnica avanzada, no un error // Sin penalización por no usar CoinJoin — es una técnica avanzada, no un error
// ── 5. Output de valor redondo ──────────────────────────────────── // ── 5. Output de valor redondo ────────────────────────────────────
const roundOutputs = tx.vout.filter(v => // La fuerza de la señal escala con lo redonda que sea la cifra: pagar
v.value % 1000000 === 0 || v.value % 100000 === 0 || v.value % 10000000 === 0 // 0,5 BTC clavados delata mucho más que pagar 0,0123 BTC. El peso va
); // del 60% al 100% del máximo según el nivel devuelto por roundness().
const roundOutputs = tx.vout.filter(v => roundness(v.value) >= ROUND_MIN);
const hasRound = roundOutputs.length > 0 && tx.vout.length === 2 && !likelyCJ; const hasRound = roundOutputs.length > 0 && tx.vout.length === 2 && !likelyCJ;
const roundLevel = hasRound ? Math.max(...roundOutputs.map(v => roundness(v.value))) : 0;
const roundNames = { 2: "múltiplo de 0,0001 BTC", 3: "múltiplo de 0,001 BTC", 4: "múltiplo de 0,1 BTC" };
const pesoRedondo = Math.round(weights.round_numbers * (roundLevel >= 4 ? 1 : roundLevel === 3 ? 0.8 : 0.6));
checks.push({ checks.push({
id:"round_numbers", label:"Output de valor redondo", certainty:"PROBABLE", pass:!hasRound, id:"round_numbers", label:"Output de valor redondo", certainty:"PROBABLE", pass:!hasRound,
actionability: "evitable", actionability: "evitable",
detail: hasRound detail: hasRound
? `Hecho (certeza): uno de los dos outputs vale exactamente ${(roundOutputs[0].value/1e8).toFixed(6)} BTC, una cifra redonda. Interpretación (probable): las personas pagan cantidades redondas y el cambio sale de la resta, así que ese suele ser el pago y el otro el cambio. No siempre: una consolidación o un pago calculado al céntimo rompen la regla. Consecuencia: si acierta, queda señalada la dirección de cambio del emisor — y con ella, por dónde seguir sus gastos futuros.` ? `Hecho (certeza): uno de los dos outputs vale exactamente ${(roundOutputs[0].value/1e8).toFixed(8).replace(/0+$/,"0")} BTC — ${roundNames[roundLevel] || "una cifra redonda"}. Interpretación (probable): las personas pagan cantidades redondas y el cambio sale de la resta, así que ese suele ser el pago y el otro el cambio. No siempre: una consolidación o un pago calculado al céntimo rompen la regla. Consecuencia: si acierta, queda señalada la dirección de cambio del emisor — y con ella, por dónde seguir sus gastos futuros.`
: "No se detectan outputs de valor redondo en una estructura de 2 outputs.", : "No se detectan outputs de valor redondo en una estructura de 2 outputs.",
didactic: "Las personas pagan cantidades redondas (0,001, 0,01, 0,1 BTC) y el cambio es lo que sobra, con todos sus decimales. De ahí la regla: en una transacción de dos salidas, la redonda suele ser el pago y la irregular el cambio. Suele, no siempre — hay consolidaciones y pagos calculados al céntimo que la desmienten. Cuando acierta, identifica la dirección de cambio del emisor, que es la puerta para seguir sus gastos posteriores.", didactic: "Las personas pagan cantidades redondas (0,001, 0,01, 0,1 BTC) y el cambio es lo que sobra, con todos sus decimales. De ahí la regla: en una transacción de dos salidas, la redonda suele ser el pago y la irregular el cambio. Cuanto más redonda la cifra, más fuerte la señal. Suele, no siempre — hay consolidaciones y pagos calculados al céntimo que la desmienten. Y hay un punto ciego que conviene conocer: si pagas una cantidad redonda en euros o dólares, en BTC sale un número con todos sus decimales y esta heurística no ve nada.",
penalty: weights.round_numbers, penalty: pesoRedondo,
}); });
if (hasRound) deductions += weights.round_numbers; if (hasRound) deductions += pesoRedondo;
// ── 6. RBF ──────────────────────────────────────────────────────── // ── 6. RBF ────────────────────────────────────────────────────────
const rbfEnabled = tx.vin.some(v=>typeof v.sequence==="number" && v.sequence < 0xfffffffe); const rbfEnabled = tx.vin.some(v=>typeof v.sequence==="number" && v.sequence < 0xfffffffe);
@@ -1893,19 +1963,37 @@
unnecessaryDetail = `Uno de los ${tx.vin.length} inputs habría sido suficiente para cubrir el pago. Los inputs adicionales revelan consolidación de UTXOs y ligan esos fondos al mismo propietario.`; unnecessaryDetail = `Uno de los ${tx.vin.length} inputs habría sido suficiente para cubrir el pago. Los inputs adicionales revelan consolidación de UTXOs y ligan esos fondos al mismo propietario.`;
} }
} }
// Consolidación pura: N entradas y UNA sola salida, sin cambio. Es el
// acto que más privacidad destruye de golpe y hasta ahora pasaba este
// check sin despeinarse: la condición de arriba exige que una entrada
// cubra la mayor salida `y sobre` (val < totalIn * 0.99), y en una
// consolidación la salida vale casi lo mismo que la suma de entradas,
// así que ninguna entrada la cubre sola. Comprobado con 20 entradas a
// 1 salida: no se disparaba.
//
// No suma penalización propia a propósito. La vinculación que provoca
// ya la cobra input_linkage, y cobrarla dos veces sería repetir el error
// que este mismo repaso corrigió en la detección de cambio. Lo que
// faltaba no era castigo, era nombre.
const esConsolidacion = !likelyCJ && tx.vin.length >= 3 && tx.vout.length === 1;
checks.push({ checks.push({
id:"unnecessary_input", label:"Inputs innecesarios (consolidación revelada)", certainty:"PROBABLE", id:"unnecessary_input",
pass: likelyCJ ? true : !hasUnnecessaryInput, label: esConsolidacion ? "Consolidación de UTXOs" : "Inputs innecesarios (consolidación revelada)",
certainty: esConsolidacion ? "CERTEZA" : "PROBABLE",
pass: likelyCJ ? true : !(hasUnnecessaryInput || esConsolidacion),
informational: likelyCJ, informational: likelyCJ,
actionability: likelyCJ ? null : "evitable", actionability: likelyCJ ? null : "evitable",
detail: likelyCJ detail: likelyCJ
? `En un CoinJoin todos los inputs son necesarios por diseño del protocolo — cada participante aporta los suyos. Esta heurística no aplica.` ? `En un CoinJoin todos los inputs son necesarios por diseño del protocolo — cada participante aporta los suyos. Esta heurística no aplica.`
: esConsolidacion
? `Hecho (certeza): ${tx.vin.length} entradas y una sola salida, sin cambio. Esto no es un pago: es una consolidación, juntar monedas sueltas en una. Interpretación (certeza para un observador): las ${tx.vin.length} entradas son del mismo dueño, y aquí no cabe la excepción del CoinJoin porque no hay nada que mezclar. Consecuencia: es el gesto que más historial ata de una sola vez, y suele hacerse cuando las comisiones están baratas — justo cuando menos se piensa en lo que cuesta en privacidad. La penalización la aplica el check de vinculación, que mide cuántas direcciones quedan atadas.`
: (hasUnnecessaryInput ? unnecessaryDetail : "No se detectan inputs claramente innecesarios para cubrir el pago."), : (hasUnnecessaryInput ? unnecessaryDetail : "No se detectan inputs claramente innecesarios para cubrir el pago."),
didactic: "Si una transacción aporta más entradas de las que hacían falta para cubrir el pago, lo habitual es que el emisor estuviera consolidando monedas sueltas. Gastarlas juntas es lo que las delata: cualquiera que mire asume que son del mismo dueño. Esa suposición —la heurística CIOH— es la más usada del análisis de cadena y acierta casi siempre, pero no es una ley: un CoinJoin o un pago colaborativo la desmienten por diseño. Por eso aquí se marca como probable y no como certeza.", didactic: "Si una transacción aporta más entradas de las que hacían falta para cubrir el pago, lo habitual es que el emisor estuviera consolidando monedas sueltas. Gastarlas juntas es lo que las delata: cualquiera que mire asume que son del mismo dueño. Esa suposición —la heurística CIOH— es la más usada del análisis de cadena y acierta casi siempre, pero no es una ley: un CoinJoin o un pago colaborativo la desmienten por diseño. Por eso aquí se marca como probable y no como certeza.",
penalty: likelyCJ ? 0 : weights.unnecessary_input, penalty: (likelyCJ || esConsolidacion) ? 0 : weights.unnecessary_input,
}); });
// En CoinJoin múltiples inputs son la estructura del protocolo — no penaliza. // En CoinJoin múltiples inputs son la estructura del protocolo — no penaliza.
if (hasUnnecessaryInput && !likelyCJ) deductions += weights.unnecessary_input; // En una consolidación tampoco resta aquí: ya lo cobra input_linkage.
if (hasUnnecessaryInput && !likelyCJ && !esConsolidacion) deductions += weights.unnecessary_input;
// ── 8. Patrón de pago simple (posible eslabón de peeling chain) ──── // ── 8. Patrón de pago simple (posible eslabón de peeling chain) ────
// OJO: una sola tx 1-in/2-out NO es una peeling chain — es el pago más // OJO: una sola tx 1-in/2-out NO es una peeling chain — es el pago más
@@ -1975,6 +2063,50 @@
}); });
} }
// ── 9-bis. El dusting materializado ───────────────────────────────
// El check anterior avisa de que una salida diminuta "quedará vinculada
// con las demás SI se gasta junto a ellas". Esa frase describía un
// futuro hipotético y la app nunca miraba si ese futuro estaba
// ocurriendo delante de ella — pese a tener el dato en la mano.
//
// Aquí se mira. Si entre las ENTRADAS de esta transacción hay una moneda
// de tamaño polvo gastada junto a otras mayores, el ataque ya no es una
// hipótesis: se acaba de consumar, y se puede decir con CERTEZA.
//
// Guardias: no aplica en CoinJoin (las entradas son de gente distinta) y
// exige al menos otra entrada que no sea polvo — gastar solo polvo, sin
// nada más, no revela ninguna vinculación nueva.
const entradasPolvo = tx.vin.filter(v => {
const val = v.prevout?.value;
if (typeof val !== "number") return false;
return val < Math.max(DUSTING_CEIL, dustThreshold(v.prevout?.scriptpubkey_type));
});
const entradasNormales = tx.vin.length - entradasPolvo.length;
const polvoGastadoJunto = !likelyCJ && entradasPolvo.length > 0 && entradasNormales > 0;
if (polvoGastadoJunto) {
const dirsPolvo = [...new Set(entradasPolvo.map(v=>v.prevout?.scriptpubkey_address).filter(Boolean))];
checks.push({
id:"dust_spent", label:"Se gasta polvo junto a otras monedas", certainty:"CERTEZA", pass:false,
actionability: "evitable",
detail: `Hecho (certeza): entre las entradas hay ${entradasPolvo.length} moneda(s) de tamaño polvo (${entradasPolvo.map(v=>v.prevout.value+" sat").join(", ")}) gastada(s) junto a otras ${entradasNormales} de importe normal. Interpretación (certeza para un observador): quien envió ese polvo estaba esperando exactamente esto. Al firmar todas las entradas juntas se demuestra que la dirección que recibió el polvo y las demás son del mismo dueño. Consecuencia: si el remitente del polvo era un analista, acaba de confirmar la vinculación que buscaba — y a diferencia del aviso preventivo, esto ya no se puede deshacer.`,
didactic: "Un ataque de dusting no hace daño al llegar: hace daño cuando lo gastas. El atacante manda una cantidad ínfima a tu dirección y espera. Si algún día esa moneda entra en una transacción junto a otras tuyas, la heurística CIOH hace el resto y tus direcciones quedan atadas en público. Por eso el consejo ante el polvo recibido es no tocarlo y congelarlo en el monedero si tu wallet lo permite. Aquí ya no es un consejo preventivo: la transacción que estás mirando lo hizo.",
penalty: weights.dust_spent,
});
deductions += weights.dust_spent;
} else {
checks.push({
id:"dust_spent", label:"Se gasta polvo junto a otras monedas", certainty:"CERTEZA", pass:true,
actionability: "evitable",
detail: likelyCJ
? "En un CoinJoin las entradas pertenecen a participantes distintos, así que gastar monedas pequeñas junto a otras no vincula nada. La heurística no aplica."
: entradasPolvo.length > 0
? "Hay entradas de tamaño polvo, pero no se mezclan con monedas de importe normal, así que no se revela ninguna vinculación nueva."
: "Ninguna de las entradas es de tamaño polvo.",
didactic: "Un ataque de dusting no hace daño al llegar: hace daño cuando lo gastas. Mientras el polvo siga quieto en tu monedero, la vinculación que busca el atacante no se ha producido.",
penalty: weights.dust_spent, informational: true,
});
}
// ── 10. Change detection — señales combinadas (ver guessChangeOutput) ── // ── 10. Change detection — señales combinadas (ver guessChangeOutput) ──
const changeGuess = guessChangeOutput(tx, likelyCJ); const changeGuess = guessChangeOutput(tx, likelyCJ);
const changeSignals = changeGuess.signals; const changeSignals = changeGuess.signals;
@@ -2236,13 +2368,13 @@
const maxPossible = Object.values(weights).reduce((a,b)=>a+b,0); const maxPossible = Object.values(weights).reduce((a,b)=>a+b,0);
const rawScore = Math.max(0, Math.min(100, Math.round(100 - (deductions / maxPossible) * 100))); const rawScore = Math.max(0, Math.min(100, Math.round(100 - (deductions / maxPossible) * 100)));
// OP_RETURN es incompatible con privacidad aceptable — fuerza banda BAJA como máximo. // OP_RETURN es incompatible con privacidad aceptable — fuerza banda BAJA como máximo.
const score = hasOpReturn ? Math.min(rawScore, 44) : rawScore; const score = hasOpReturn ? Math.min(rawScore, BANDA_MEDIA - 1) : rawScore;
return { return {
score, checks, score, checks,
band: score>=75?"ALTA":score>=45?"MEDIA":"BAJA", band: score>=BANDA_ALTA?"ALTA":score>=BANDA_MEDIA?"MEDIA":"BAJA",
summary: score>=75?"Privacidad aceptable":score>=45?"Privacidad mejorable":"Privacidad baja", summary: score>=BANDA_ALTA?"Privacidad aceptable":score>=BANDA_MEDIA?"Privacidad mejorable":"Privacidad baja",
summaryColor: score>=75?C.green:score>=45?C.amber:C.red, summaryColor: score>=BANDA_ALTA?C.green:score>=BANDA_MEDIA?C.amber:C.red,
wallets: detectedWallets, wallets: detectedWallets,
}; };
} }
@@ -2274,6 +2406,10 @@
const funded = addr.chain_stats ? addr.chain_stats.funded_txo_sum : 0; const funded = addr.chain_stats ? addr.chain_stats.funded_txo_sum : 0;
checks.push({ id:"volume", label:"Volumen de actividad", certainty:"CERTEZA", pass:true, detail:`Total recibido: ${(funded/1e8).toFixed(4)} BTC en ${txCount} transacciones.`, penalty:0, informational:true }); checks.push({ id:"volume", label:"Volumen de actividad", certainty:"CERTEZA", pass:true, detail:`Total recibido: ${(funded/1e8).toFixed(4)} BTC en ${txCount} transacciones.`, penalty:0, informational:true });
score = Math.max(0, Math.min(100, score)); score = Math.max(0, Math.min(100, score));
// Cortes propios, a propósito distintos de BANDA_ALTA/BANDA_MEDIA. Esta
// nota se calcula restando puntos directamente sobre 100, sin el
// denominador normalizado del analizador de transacciones, así que 75/45
// aquí ya están en la escala correcta. No unificar sin recalibrar.
return { score, checks, band: score>=75?"ALTA":score>=45?"MEDIA":"BAJA", summary: score >= 75 ? "Privacidad aceptable" : score >= 45 ? "Privacidad mejorable" : "Privacidad baja", summaryColor: score >= 75 ? C.green : score >= 45 ? C.amber : C.red, wallets: [] }; return { score, checks, band: score>=75?"ALTA":score>=45?"MEDIA":"BAJA", summary: score >= 75 ? "Privacidad aceptable" : score >= 45 ? "Privacidad mejorable" : "Privacidad baja", summaryColor: score >= 75 ? C.green : score >= 45 ? C.amber : C.red, wallets: [] };
} }
@@ -2706,8 +2842,8 @@
{analysis.unused {analysis.unused
? "Esta dirección no se ha usado todavía. No tiene historial on-chain que analizar." ? "Esta dirección no se ha usado todavía. No tiene historial on-chain que analizar."
: tx : tx
? (score>=75?"La transacción presenta buenas propiedades de privacidad.":score>=45?"La transacción es funcional, pero tiene características que reducen su privacidad.":"Esta transacción tiene características que facilitan su rastreo.") ? (analysis.band==="ALTA"?"La transacción presenta buenas propiedades de privacidad.":analysis.band==="MEDIA"?"La transacción es funcional, pero tiene características que reducen su privacidad.":"Esta transacción tiene características que facilitan su rastreo.")
: (score>=75?"La dirección presenta buenas propiedades de privacidad.":score>=45?"La dirección es funcional, pero tiene características que reducen su privacidad.":"Esta dirección tiene características que facilitan su rastreo.")} : (analysis.band==="ALTA"?"La dirección presenta buenas propiedades de privacidad.":analysis.band==="MEDIA"?"La dirección es funcional, pero tiene características que reducen su privacidad.":"Esta dirección tiene características que facilitan su rastreo.")}
</div> </div>
{wallets&&wallets.length>0&&( {wallets&&wallets.length>0&&(
<div style={{marginTop:6,fontSize:"0.62rem",color:C.t2,fontFamily:"monospace"}}> <div style={{marginTop:6,fontSize:"0.62rem",color:C.t2,fontFamily:"monospace"}}>
+36 -4
View File
@@ -1,11 +1,26 @@
# Pruebas de la criptografía # Pruebas
Verifican la derivación watch-only (BIP32, secp256k1, RIPEMD-160, bech32) Dos familias. Las de **criptografía** (test1test4) verifican la derivación
contra los **vectores oficiales de los estándares**, no contra resultados watch-only —BIP32, secp256k1, RIPEMD-160, bech32— contra los **vectores
propios. Si un cambio rompe algo, estas pruebas lo dicen. oficiales de los estándares**, no contra resultados propios. Las de
**heurísticas** (test5test6) comprueban el analizador de privacidad contra
transacciones donde la respuesta se conoce de antemano.
Si un cambio rompe algo, estas pruebas lo dicen.
## Cómo ejecutarlas ## Cómo ejecutarlas
Las de heurísticas se ejecutan directamente — se extraen solas del
`dashboard.html`, así que no se desactualizan:
```bash
node tests/test5.js
node tests/test6.js
```
Las de criptografía necesitan una preparación manual (pendiente de
automatizar igual que las otras dos):
Extraer el bloque criptográfico de `dashboard.html` a `crypto.js` (las líneas Extraer el bloque criptográfico de `dashboard.html` a `crypto.js` (las líneas
que van desde `const B32 = {` hasta el final de `deriveAddresses`), añadir al que van desde `const B32 = {` hasta el final de `deriveAddresses`), añadir al
principio `const { webcrypto } = require("crypto"); const crypto = webcrypto;` principio `const { webcrypto } = require("crypto"); const crypto = webcrypto;`
@@ -33,9 +48,26 @@ Después:
- **test4** — comprueba que esos cuatro siguen cerrados: checksum rota, xpub - **test4** — comprueba que esos cuatro siguen cerrados: checksum rota, xpub
truncado, índice endurecido, índice negativo. Y que un `tpub` genera truncado, índice endurecido, índice negativo. Y que un `tpub` genera
direcciones de testnet, no de mainnet. direcciones de testnet, no de mainnet.
- **test5** — detección del output de cambio. Transacciones donde se sabe de
antemano cuál es el pago y cuál el cambio, más los niveles de "redondez" de
una cifra. Nació de dos fallos de la v1.15.0: una señal que se contaba dos
veces y un índice que podía señalar el pago como si fuera el cambio.
- **test6** — el analizador completo. Coherencia de la nota (que ninguna
transacción pueda puntuar negativo antes del clamp), los dos checks nuevos
—polvo gastado junto a otras monedas y consolidación pura— y que la guardia
de CoinJoin desactive las heurísticas que no aplican dentro de una mezcla.
Las cuatro primeras prueban criptografía; las dos últimas, heurísticas. Se
ejecutan igual: `node tests/testN.js`.
## Lo que estas pruebas NO cubren ## Lo que estas pruebas NO cubren
Las heurísticas (test5, test6) se comprueban contra transacciones construidas
a mano, no contra la cadena real. Eso demuestra que la lógica hace lo que dice
—y ha bastado para encontrar fallos reales— pero no dice nada sobre cuántas
veces acierta ahí fuera. Medir eso exigiría un conjunto de transacciones reales
con la respuesta conocida de antemano, que es un trabajo distinto y pendiente.
La matemática es correcta, pero eso no es una auditoría. No cubren análisis La matemática es correcta, pero eso no es una auditoría. No cubren análisis
formal ni una revisión independiente: quien las escribió conoce la formal ni una revisión independiente: quien las escribió conoce la
implementación y comparte sus supuestos, que es justo el sesgo que rompe un implementación y comparte sus supuestos, que es justo el sesgo que rompe un
+36 -3
View File
@@ -19,11 +19,13 @@ const path = require("path");
const htmlPath = path.join(__dirname, "..", "dashboard.html"); const htmlPath = path.join(__dirname, "..", "dashboard.html");
const lines = fs.readFileSync(htmlPath, "utf8").split("\n"); const lines = fs.readFileSync(htmlPath, "utf8").split("\n");
const start = lines.findIndex(l => l.includes("function guessChangeOutput")); // Se extrae desde roundness() porque guessChangeOutput depende de ella y de
if (start === -1) { console.error("No se encuentra guessChangeOutput en dashboard.html"); process.exit(1); } // ROUND_MIN. El corte de abajo es analyzeTx, la siguiente función del archivo.
const start = lines.findIndex(l => l.includes("function roundness"));
if (start === -1) { console.error("No se encuentra roundness en dashboard.html"); process.exit(1); }
const end = lines.findIndex((l, i) => i > start && l.includes("function analyzeTx")); const end = lines.findIndex((l, i) => i > start && l.includes("function analyzeTx"));
const src = lines.slice(start, end).join("\n"); const src = lines.slice(start, end).join("\n");
const guessChangeOutput = new Function(src + "\nreturn guessChangeOutput;")(); const { guessChangeOutput, roundness } = new Function(src + "\nreturn { guessChangeOutput, roundness };")();
const IN = (v,t,a) => ({ prevout:{ value:v, scriptpubkey_type:t, scriptpubkey_address:a }, txid:"aa", vout:0 }); 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" }); const OUT = (v,t,a) => ({ value:v, scriptpubkey_type:t, scriptpubkey_address:a, scriptpubkey:"00" });
@@ -85,5 +87,36 @@ T("CoinJoin: la guardia desactiva la heurística aunque haya señales",
vout:[OUT(100000,"v1_p2tr","bc1pX"), OUT(99895000,"v0_p2wpkh","bc1qY")] }, vout:[OUT(100000,"v1_p2tr","bc1pX"), OUT(99895000,"v0_p2wpkh","bc1qY")] },
null, true); 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
// lo es de cien mil). Equivalía a "múltiplo de 0,001 BTC" y se le escapaban
// pagos tan redondos como 10.000 o 50.000 sats.
console.log("\n=== Redondez de las cifras ===\n");
function R(sats, minimo) {
const nivel = roundness(sats);
const ok = nivel >= minimo;
console.log(` ${ok ? "✓" : "✗"} ${String(sats).padStart(9)} sat = ${(sats/1e8).toFixed(8)} BTC -> nivel ${nivel} (mínimo esperado ${minimo})`);
ok ? pass++ : fail++;
}
R(100000000, 4); // 1 BTC
R( 10000000, 4); // 0,1 BTC
R( 1000000, 3); // 0,01 BTC
R( 100000, 3); // 0,001 BTC
R( 250000, 2); // 0,0025 BTC — se escapaba antes
R( 50000, 2); // 0,0005 BTC — se escapaba antes
R( 10000, 2); // 0,0001 BTC — se escapaba antes
// Y lo que NO debe considerarse redondo.
function NR(sats) {
const nivel = roundness(sats);
const ok = nivel < 2;
console.log(` ${ok ? "✓" : "✗"} ${String(sats).padStart(9)} sat -> nivel ${nivel} (no debe llegar a 2)`);
ok ? pass++ : fail++;
}
NR(123456);
NR(99895000);
NR(1234567);
console.log(`\n${pass} correctos, ${fail} fallos`); console.log(`\n${pass} correctos, ${fail} fallos`);
process.exit(fail === 0 ? 0 : 1); process.exit(fail === 0 ? 0 : 1);
+141
View File
@@ -0,0 +1,141 @@
// Analizador de transacciones completo (analyzeTx)
//
// Extrae el analizador entero del dashboard.html y lo ejecuta contra
// transacciones construidas a mano. Comprueba tres cosas:
//
// · Coherencia del score: que el denominador y las deducciones midan lo
// mismo, es decir que ninguna transacción pueda puntuar negativo antes
// del clamp. Ese desajuste existía (deducciones hasta 213 contra un
// denominador de 196) y lo tapaba un Math.max(0, …).
// · Que la guardia de CoinJoin desactive las heurísticas que no aplican.
// · Los dos checks nuevos: polvo gastado junto a otras monedas, y
// consolidación pura.
//
// Uso: node tests/test6.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 detectWallets"));
const end = lines.findIndex((l, i) => i > start && l.includes("function analyzeAddress"));
if (start === -1 || end === -1) { console.error("No se localiza el analizador en dashboard.html"); process.exit(1); }
// El analizador vive dentro del dashboard y usa la paleta y el índice de
// entidades. Aquí se sustituyen por lo mínimo: colores de mentira y un índice
// vacío, para que lo que se pruebe sean las heurísticas y no los datos.
const preludio = `
const C = { green:"g", amber:"a", red:"r", t2:"t", blue:"b" };
const ENTITY_INDEX = new Map();
const OFAC_SET = new Set();
`;
const src = lines.slice(start, end).join("\n");
const analyzeTx = new Function(preludio + src + "\nreturn analyzeTx;")();
const IN = (v,t,a) => ({ prevout:{ value:v, scriptpubkey_type:t, scriptpubkey_address:a }, txid:"aa", vout:0, sequence:0xffffffff });
const OUT = (v,t,a) => ({ value:v, scriptpubkey_type:t, scriptpubkey_address:a, scriptpubkey:"0014"+"11".repeat(20) });
const TX = (vin, vout, extra={}) => ({ txid:"ff".repeat(32), vin, vout, weight:800, fee:2000, locktime:0, status:{confirmed:true}, ...extra });
let pass = 0, fail = 0;
function ok(nombre, cond, extra) {
console.log(` ${cond ? "✓" : "✗"} ${nombre}`);
if (!cond && extra) console.log(` ${extra}`);
cond ? pass++ : fail++;
}
const check = (r, id) => r.checks.find(c => c.id === id);
console.log("=== Coherencia de la nota ===\n");
// El peor caso imaginable: todo lo que puede restar, restando a la vez.
// Muchas entradas de direcciones distintas y tipos mezclados, salidas legacy,
// polvo entre las entradas, OP_RETURN, y un lote de destinatarios.
const vinPeor = [];
for (let i = 0; i < 25; i++) {
vinPeor.push(IN(i === 0 ? 600 : 5000000, i % 2 ? "p2pkh" : "v0_p2wpkh", "addr" + i));
}
vinPeor.push(IN(5000000, "p2pkh", "addr0")); // reutilización
const voutPeor = [];
for (let i = 0; i < 8; i++) voutPeor.push(OUT(1000000 + i * 7777, "p2pkh", "out" + i));
voutPeor.push({ value:0, scriptpubkey_type:"op_return", scriptpubkey:"6a24" + "ab".repeat(36), scriptpubkey_address:null });
const peor = analyzeTx(TX(vinPeor, voutPeor));
ok("la peor transacción posible no puntúa por debajo de 0",
peor.score >= 0, `score = ${peor.score}`);
ok("la peor transacción posible cae en banda BAJA",
peor.band === "BAJA", `banda = ${peor.band}`);
// Suma de penalizaciones declaradas frente al máximo teórico: si el
// denominador fuera menor que la suma de lo que puede restar, existiría una
// transacción con score negativo antes del clamp.
const sumaPenalizaciones = peor.checks
.filter(c => !c.informational && c.pass === false)
.reduce((s, c) => s + (c.penalty || 0), 0);
ok("las deducciones reales no superan el 100% de la escala",
100 - peor.score <= 100, `deducciones equivalentes = ${100 - peor.score}`);
console.log(` (penalizaciones declaradas en esta tx: ${sumaPenalizaciones})`);
// Una transacción limpia: una entrada, dos salidas del mismo tipo, sin
// redondos, sin polvo, sin OP_RETURN.
const limpia = analyzeTx(TX(
[IN(5000000, "v1_p2tr", "bc1pA")],
[OUT(1234567, "v1_p2tr", "bc1pB"), OUT(3763000, "v1_p2tr", "bc1pC")]
));
ok("una transacción limpia alcanza banda ALTA",
limpia.band === "ALTA", `score = ${limpia.score}, banda = ${limpia.band}`);
console.log("\n=== Polvo gastado junto a otras monedas (check nuevo) ===\n");
const conPolvo = analyzeTx(TX(
[IN(600, "v0_p2wpkh", "bc1qPOLVO"), IN(5000000, "v0_p2wpkh", "bc1qMIA")],
[OUT(4990000, "v0_p2wpkh", "bc1qDESTINO")]
));
ok("detecta el polvo gastado junto a una moneda normal",
check(conPolvo, "dust_spent")?.pass === false);
const soloPolvo = analyzeTx(TX(
[IN(600, "v0_p2wpkh", "bc1qA"), IN(700, "v0_p2wpkh", "bc1qB")],
[OUT(900, "v0_p2wpkh", "bc1qC")]
));
ok("no avisa si solo se gasta polvo (no revela vinculación nueva)",
soloPolvo.checks.find(c => c.id === "dust_spent")?.pass === true);
const sinPolvo = analyzeTx(TX(
[IN(5000000, "v0_p2wpkh", "bc1qA"), IN(3000000, "v0_p2wpkh", "bc1qB")],
[OUT(7990000, "v0_p2wpkh", "bc1qC")]
));
ok("no avisa cuando ninguna entrada es polvo",
sinPolvo.checks.find(c => c.id === "dust_spent")?.pass === true);
console.log("\n=== Consolidación pura (hueco cerrado) ===\n");
const vinConsol = [];
for (let i = 0; i < 20; i++) vinConsol.push(IN(5000000, "v0_p2wpkh", "bc1qA" + i));
const consol = analyzeTx(TX(vinConsol, [OUT(99900000, "v0_p2wpkh", "bc1qDESTINO")]));
const cu = check(consol, "unnecessary_input");
ok("una consolidación de 20 entradas a 1 salida ya no pasa desapercibida",
cu?.pass === false, `pass = ${cu?.pass}`);
ok("se etiqueta como consolidación, con certeza y no como probable",
cu?.label === "Consolidación de UTXOs" && cu?.certainty === "CERTEZA",
`label = ${cu?.label}, certeza = ${cu?.certainty}`);
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=== Guardia de CoinJoin ===\n");
// Whirlpool: 5 entradas, 5 salidas de la misma denominación.
const vinCJ = [], voutCJ = [];
for (let i = 0; i < 6; i++) {
vinCJ.push(IN(1100000, "v0_p2wpkh", "bc1qIN" + i));
voutCJ.push(OUT(1000000, "v0_p2wpkh", "bc1qOUT" + i));
}
const cj = analyzeTx(TX(vinCJ, voutCJ));
ok("se reconoce como CoinJoin", check(cj, "coinjoin")?.pass === true);
for (const id of ["input_linkage", "unnecessary_input", "input_type_mixing", "round_numbers", "dust_spent"]) {
const c = check(cj, id);
ok(`la guardia neutraliza ${id}`, c ? c.pass === true : true,
`pass = ${c?.pass}`);
}
console.log(`\n${pass} correctos, ${fail} fallos`);
process.exit(fail === 0 ? 0 : 1);