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:
@@ -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
|
||||
- **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
|
||||
### Corregido
|
||||
|
||||
+166
-30
@@ -1515,6 +1515,58 @@
|
||||
// no sostienen. Autocontenida (recalcula sus propias señales desde tx) para
|
||||
// poder llamarse tanto desde analyzeTx como, salto a salto, desde el motor
|
||||
// de rastreo forense, que necesita saber POR CUÁL output concreto seguir.
|
||||
// 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) {
|
||||
if (tx.vout.length !== 2 || likelyCJ) {
|
||||
return { identifiable: false, index: null, signals: 0, details: [], bonus: 0, certainty: null };
|
||||
@@ -1532,9 +1584,7 @@
|
||||
return (a[i-1].scriptpubkey||"") <= (v.scriptpubkey||"");
|
||||
});
|
||||
const isBip69 = isBip69Inputs && isBip69Outputs;
|
||||
const roundOutputs = tx.vout.filter(v =>
|
||||
v.value % 1000000 === 0 || v.value % 100000 === 0 || v.value % 10000000 === 0
|
||||
);
|
||||
const roundOutputs = tx.vout.filter(v => roundness(v.value) >= ROUND_MIN);
|
||||
const inputAddrs = tx.vin.map(v=>v.prevout?.scriptpubkey_address).filter(Boolean);
|
||||
const inputAddrSet = new Set(inputAddrs);
|
||||
const totalIn = tx.vin.reduce((s,v)=>s+(v.prevout?v.prevout.value:0),0);
|
||||
@@ -1638,14 +1688,28 @@
|
||||
|
||||
function analyzeTx(tx) {
|
||||
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 = {
|
||||
input_reuse: 25, input_type_mixing: 14, output_type_mismatch: 8,
|
||||
round_numbers: 10, rbf: 5, peeling: 8,
|
||||
unnecessary_input: 12, dust: 8, change_detection: 10,
|
||||
input_reuse: 25, input_linkage: 30, input_type_mixing: 14,
|
||||
output_type_mismatch: 8, round_numbers: 10,
|
||||
unnecessary_input: 12, dust: 8, dust_spent: 20, change_detection: 10,
|
||||
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
|
||||
// reduce tu privacidad. Ver sus checks para el razonamiento.
|
||||
// Checks informativos: no restan nunca, así que no tienen peso ni entran
|
||||
// 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;
|
||||
|
||||
const totalIn = tx.vin.reduce((s,v)=>s+(v.prevout?v.prevout.value:0),0);
|
||||
@@ -1764,12 +1828,14 @@
|
||||
const nVinculadas = uniqueInputAddrs.size;
|
||||
const hayVinculacion = nVinculadas > 1 && !likelyCJ;
|
||||
// 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
|
||||
: nVinculadas >= 20 ? 30
|
||||
: nVinculadas >= 10 ? 22
|
||||
: nVinculadas >= 5 ? 15
|
||||
: 8;
|
||||
: nVinculadas >= 20 ? weights.input_linkage
|
||||
: nVinculadas >= 10 ? Math.round(weights.input_linkage * 0.73)
|
||||
: nVinculadas >= 5 ? Math.round(weights.input_linkage * 0.5)
|
||||
: Math.round(weights.input_linkage * 0.27);
|
||||
checks.push({
|
||||
id:"input_linkage", label:"Direcciones que quedan vinculadas entre sí", certainty:"CERTEZA",
|
||||
pass: !hayVinculacion,
|
||||
@@ -1840,20 +1906,24 @@
|
||||
// Sin penalización por no usar CoinJoin — es una técnica avanzada, no un error
|
||||
|
||||
// ── 5. Output de valor redondo ────────────────────────────────────
|
||||
const roundOutputs = tx.vout.filter(v =>
|
||||
v.value % 1000000 === 0 || v.value % 100000 === 0 || v.value % 10000000 === 0
|
||||
);
|
||||
// La fuerza de la señal escala con lo redonda que sea la cifra: pagar
|
||||
// 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 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({
|
||||
id:"round_numbers", label:"Output de valor redondo", certainty:"PROBABLE", pass:!hasRound,
|
||||
actionability: "evitable",
|
||||
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.",
|
||||
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.",
|
||||
penalty: weights.round_numbers,
|
||||
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: pesoRedondo,
|
||||
});
|
||||
if (hasRound) deductions += weights.round_numbers;
|
||||
if (hasRound) deductions += pesoRedondo;
|
||||
|
||||
// ── 6. RBF ────────────────────────────────────────────────────────
|
||||
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.`;
|
||||
}
|
||||
}
|
||||
// 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({
|
||||
id:"unnecessary_input", label:"Inputs innecesarios (consolidación revelada)", certainty:"PROBABLE",
|
||||
pass: likelyCJ ? true : !hasUnnecessaryInput,
|
||||
id:"unnecessary_input",
|
||||
label: esConsolidacion ? "Consolidación de UTXOs" : "Inputs innecesarios (consolidación revelada)",
|
||||
certainty: esConsolidacion ? "CERTEZA" : "PROBABLE",
|
||||
pass: likelyCJ ? true : !(hasUnnecessaryInput || esConsolidacion),
|
||||
informational: likelyCJ,
|
||||
actionability: likelyCJ ? null : "evitable",
|
||||
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.`
|
||||
: 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."),
|
||||
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.
|
||||
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) ────
|
||||
// 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) ──
|
||||
const changeGuess = guessChangeOutput(tx, likelyCJ);
|
||||
const changeSignals = changeGuess.signals;
|
||||
@@ -2236,13 +2368,13 @@
|
||||
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)));
|
||||
// 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 {
|
||||
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,
|
||||
band: score>=BANDA_ALTA?"ALTA":score>=BANDA_MEDIA?"MEDIA":"BAJA",
|
||||
summary: score>=BANDA_ALTA?"Privacidad aceptable":score>=BANDA_MEDIA?"Privacidad mejorable":"Privacidad baja",
|
||||
summaryColor: score>=BANDA_ALTA?C.green:score>=BANDA_MEDIA?C.amber:C.red,
|
||||
wallets: detectedWallets,
|
||||
};
|
||||
}
|
||||
@@ -2274,6 +2406,10 @@
|
||||
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 });
|
||||
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: [] };
|
||||
}
|
||||
|
||||
@@ -2706,8 +2842,8 @@
|
||||
{analysis.unused
|
||||
? "Esta dirección no se ha usado todavía. No tiene historial on-chain que analizar."
|
||||
: 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.")
|
||||
: (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 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.")
|
||||
: (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>
|
||||
{wallets&&wallets.length>0&&(
|
||||
<div style={{marginTop:6,fontSize:"0.62rem",color:C.t2,fontFamily:"monospace"}}>
|
||||
|
||||
+36
-4
@@ -1,11 +1,26 @@
|
||||
# Pruebas de la criptografía
|
||||
# Pruebas
|
||||
|
||||
Verifican la derivación watch-only (BIP32, secp256k1, RIPEMD-160, bech32)
|
||||
contra los **vectores oficiales de los estándares**, no contra resultados
|
||||
propios. Si un cambio rompe algo, estas pruebas lo dicen.
|
||||
Dos familias. Las de **criptografía** (test1–test4) verifican la derivación
|
||||
watch-only —BIP32, secp256k1, RIPEMD-160, bech32— contra los **vectores
|
||||
oficiales de los estándares**, no contra resultados propios. Las de
|
||||
**heurísticas** (test5–test6) 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
|
||||
|
||||
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
|
||||
que van desde `const B32 = {` hasta el final de `deriveAddresses`), añadir al
|
||||
principio `const { webcrypto } = require("crypto"); const crypto = webcrypto;`
|
||||
@@ -33,9 +48,26 @@ Después:
|
||||
- **test4** — comprueba que esos cuatro siguen cerrados: checksum rota, xpub
|
||||
truncado, índice endurecido, índice negativo. Y que un `tpub` genera
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
+36
-3
@@ -19,11 +19,13 @@ 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); }
|
||||
// Se extrae desde roundness() porque guessChangeOutput depende de ella y de
|
||||
// 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 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 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")] },
|
||||
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`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
|
||||
+141
@@ -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);
|
||||
Reference in New Issue
Block a user