Compare commits
5
Commits
2a1c7254e2
...
d6775f62c5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6775f62c5 | ||
|
|
afebeeaccd | ||
|
|
3d490b32d4 | ||
|
|
320db70ee5 | ||
|
|
428f67c132 |
@@ -30,3 +30,4 @@ COHERENCIA.md
|
||||
|
||||
GIT.md
|
||||
REPASO.md
|
||||
HEURISTICAS.md
|
||||
|
||||
+167
@@ -10,6 +10,173 @@ 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
|
||||
- **La nota de privacidad ya solo mide lo que dependía de ti.** Hasta ahora
|
||||
mezclaba dos cosas distintas en un número: los errores propios (reutilizar
|
||||
direcciones, consolidar, dejar el cambio a la vista) y la exposición que
|
||||
provoca un tercero (que te paguen desde un lote, recibir polvo, que la otra
|
||||
parte use un tipo de dirección distinto). La consecuencia era absurda:
|
||||
**alguien impecable que cobró de un exchange sacaba peor nota que un
|
||||
descuidado que cobró de un particular**, y no había nada que pudiera hacer
|
||||
para mejorarla. Recibir de un lote costaba 23 puntos y estaba marcado, con
|
||||
razón, como "no corregible".
|
||||
- La nota se calcula ahora solo con los checks sobre los que tienes decisión.
|
||||
Denominador 142.
|
||||
- Lo demás pasa a un bloque propio, **«lo que hicieron otros»**, con su
|
||||
propio nivel de exposición (ninguna / moderada / alta). Se informa, se
|
||||
explica, y no baja una nota que no podrías subir.
|
||||
- Una nota que no puedes mejorar no es una evaluación, es un reproche. Y una
|
||||
herramienta que reprocha lo que no elegiste enseña a ignorarla.
|
||||
- **Un fallo grave impide la banda ALTA aunque el número dé de sobra.** Con la
|
||||
nota restringida a lo propio, una transacción con el cambio identificable
|
||||
—una fuga real y concreta— sacaba 93 sobre 100 y la etiqueta "privacidad
|
||||
aceptable". Ahora cualquier check propio que falle con peso ≥10 impide la
|
||||
banda alta. Un promedio bueno no borra un fallo concreto.
|
||||
- **OP_RETURN deja de forzar banda BAJA por decreto.** Sigue penalizando, pero
|
||||
el tope duro condenaba por igual a una inscripción y a un sello de tiempo de
|
||||
OpenTimestamps, que es privacidad neutra. Como además OP_RETURN suele venir
|
||||
del protocolo que usaste y no de tu descuido, vive ahora en la exposición
|
||||
heredada.
|
||||
- Los cortes de banda pasan a 85 y 55: con la nota midiendo solo lo propio, el
|
||||
listón puede ser más exigente porque ya no hay dentro nada que no puedas
|
||||
arreglar.
|
||||
- La exportación en Markdown y en JSON incluyen ambos bloques por separado.
|
||||
|
||||
---
|
||||
## [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
|
||||
- **La detección del cambio contaba una misma señal dos veces.** La "señal A"
|
||||
(solo una salida comparte tipo de script con las entradas) y la "señal E"
|
||||
(el tipo de output difiere del de las entradas) eran la misma condición
|
||||
escrita dos veces —el mismo `filter`, la misma comparación, el mismo `=== 1`—
|
||||
y cada una incrementaba el contador. Como el umbral para declarar el cambio
|
||||
identificable es de dos señales, **ese único hecho bastaba por sí solo**.
|
||||
- Consecuencia práctica: pagar desde una dirección bech32 a una taproot —de
|
||||
lo más común que hay— salía marcado como "cambio identificable" con certeza
|
||||
PROBABLE, sin ninguna otra evidencia.
|
||||
- Y el informe mostraba las dos señales juntas, que se contradicen entre sí:
|
||||
"tipo de script idéntico a inputs" y "tipo de output distinto al de inputs".
|
||||
El mismo hecho descrito con palabras opuestas.
|
||||
- Ahora es una sola señal. Esa transacción pasa a una señal y a **no
|
||||
identificable**, que es lo correcto. Cuando queda una sola señal el check
|
||||
lo dice en vez de callarse: existe, no basta, y conviene que se sepa que
|
||||
otro analista menos escrupuloso la daría por buena ella sola.
|
||||
- **Se podía señalar el pago como si fuera el cambio.** Cuando ninguna señal
|
||||
fuerte apuntaba a un output concreto, el índice caía en `indexOf(smaller)`:
|
||||
se asumía que el cambio es siempre la salida menor. Es falso en el caso más
|
||||
común de todos —pagar poco desde una moneda grande—, donde el cambio es la
|
||||
salida *mayor*.
|
||||
- Comprobado con un pago de 0,001 BTC desde 1 BTC: la app decía en el detalle
|
||||
"el output redondo es el pago" y acto seguido señalaba ese mismo output
|
||||
como cambio. La información para acertar estaba delante y se descartaba.
|
||||
- Esto no se quedaba en el analizador: el motor de peritaje usa ese índice
|
||||
para detectar cadenas de peeling y para redactar el informe. Un cambio mal
|
||||
identificado significa presentar como rastro del actor **la dirección del
|
||||
destinatario del pago**, una persona ajena.
|
||||
- Ahora cada señal apunta a un output o reconoce que no puede. El orden de
|
||||
resolución es: reutilización de dirección de entrada → tipo de script →
|
||||
la salida NO redonda → posición. Si ninguna apunta, el índice queda vacío
|
||||
y se dice que hay señales pero no cuál — preferible a señalar mal.
|
||||
|
||||
### Añadido
|
||||
- `tests/test5.js` — seis casos de detección de cambio con el pago y el cambio
|
||||
conocidos de antemano, incluidos los dos fallos anteriores como regresión.
|
||||
|
||||
---
|
||||
## [1.14.0] — 2026-08-08
|
||||
### Añadido
|
||||
|
||||
@@ -84,8 +84,16 @@ Consecuencia práctica: **el dashboard funciona sin conexión a internet**. Solo
|
||||
## Funcionalidades
|
||||
|
||||
**Auditoría de privacidad**
|
||||
- Análisis de transacción con más de 20 heurísticas ponderadas
|
||||
- Análisis de transacción con más de 25 heurísticas ponderadas
|
||||
- La nota mide **solo lo que dependía de ti**. Lo que te expone por decisión de
|
||||
un tercero (recibir de un lote, recibir polvo) se informa aparte, porque una
|
||||
nota que no puedes mejorar no evalúa nada
|
||||
- Detección del output de cambio por señales combinadas, que se callan cuando
|
||||
no coinciden en lugar de señalar la salida equivocada
|
||||
- Detección de CoinJoin: Whirlpool (denominaciones fijas), WabiSabi (estructura de mezcla), CoinJoin genérico
|
||||
- Aviso cuando el polvo recibido **se gasta** junto a otras monedas: ahí el
|
||||
ataque de dusting deja de ser hipótesis
|
||||
- Consolidación y vinculación CIOH: cuántas direcciones tuyas quedan atadas
|
||||
- Detección de OP_RETURN: presencia y tamaño, sin mostrar contenido
|
||||
- Detección de tipo legacy (P2PKH/P2SH) vs SegWit/Taproot
|
||||
- Wallet fingerprinting: Bitcoin Core, Sparrow, BlueWallet, Taproot nativo y otros
|
||||
|
||||
+469
-98
@@ -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 } };
|
||||
}
|
||||
|
||||
@@ -1515,12 +1548,99 @@
|
||||
// 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.
|
||||
//
|
||||
// Desde la v1.17 la nota mide SOLO lo que dependía de ti, así que el
|
||||
// listón puede ser más exigente: ya no hay nada dentro que no puedas
|
||||
// arreglar. El denominador es 142 (la suma de `weights`).
|
||||
//
|
||||
// El corte de ALTA no basta por sí solo — se combina con la regla de
|
||||
// "fallo grave" en analyzeTx: cualquier check propio que falle con peso
|
||||
// ≥10 impide la banda ALTA aunque el número dé. Sin esa regla, una
|
||||
// transacción con el cambio identificable sacaría 93 y la etiqueta
|
||||
// "privacidad aceptable".
|
||||
//
|
||||
// AVISO ESTRUCTURAL: normalizar por la suma de los pesos tiene un efecto
|
||||
// perverso — cada check nuevo agranda el denominador y hace que los
|
||||
// problemas anteriores parezcan menos graves. Hay que recalibrar estos dos
|
||||
// números cada vez que se añade un check con peso.
|
||||
const BANDA_ALTA = 85;
|
||||
const BANDA_MEDIA = 55;
|
||||
|
||||
// ¿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;
|
||||
|
||||
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||"";
|
||||
@@ -1532,66 +1652,103 @@
|
||||
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);
|
||||
let hasOutputMismatch = false;
|
||||
if (dominantInType) {
|
||||
hasOutputMismatch = tx.vout.filter(v=>v.scriptpubkey_type===dominantInType).length === 1;
|
||||
}
|
||||
const totalIn = tx.vin.reduce((s,v)=>s+(v.prevout?v.prevout.value:0),0);
|
||||
|
||||
let signals = 0, details = [], bonus = 0;
|
||||
const larger = tx.vout.reduce((a,b)=>a.value>b.value?a:b);
|
||||
const smaller = tx.vout.reduce((a,b)=>a.value<b.value?a:b);
|
||||
// Señal A: tipo de script coincide con inputs
|
||||
let signalA = false;
|
||||
|
||||
// Cada señal apunta, cuando puede, a QUÉ output cree que es el cambio.
|
||||
// Antes las señales solo se contaban y el índice se decidía al final por
|
||||
// separado, lo que permitía que el contador dijera "identificable" y el
|
||||
// índice señalara el output equivocado. Ahora van juntas.
|
||||
|
||||
// ── Señal A: solo una de las dos salidas comparte tipo de script con
|
||||
// las entradas. Es la señal estructural más fiable en una tx de 2 salidas.
|
||||
//
|
||||
// OJO — aquí vivía un fallo: esta misma condición se evaluaba DOS veces,
|
||||
// una como "señal A: tipo idéntico a inputs" y otra como "señal E: tipo
|
||||
// distinto a inputs". El mismo hecho, contado dos veces y descrito con
|
||||
// palabras opuestas. Como el umbral es signals>=2, ese hecho por sí solo
|
||||
// bastaba para declarar el cambio identificable con certeza PROBABLE.
|
||||
// Un pago corriente de bech32 a taproot ya disparaba la alarma.
|
||||
let typeMatchIdx = null;
|
||||
if (dominantInType) {
|
||||
const matchCount = tx.vout.filter(v=>v.scriptpubkey_type===dominantInType).length;
|
||||
if (matchCount === 1) { signals++; signalA = true; details.push("tipo de script idéntico a inputs"); }
|
||||
}
|
||||
// Señal B + C: "pago redondo" y "cambio pequeño" solo cuentan por separado
|
||||
// si apuntan a outputs distintos
|
||||
const roundIsLarger = roundOutputs.length === 1 && roundOutputs[0].value === larger.value;
|
||||
const smallerIsSmall = totalIn > 0 && smaller.value < totalIn * 0.15;
|
||||
if (roundOutputs.length === 1 && smallerIsSmall && !roundIsLarger) {
|
||||
signals += 2; details.push("el output redondo es el pago"); details.push("output menor < 15% del total");
|
||||
} else if (roundOutputs.length === 1) {
|
||||
signals++; details.push("pago redondo y cambio pequeño (misma señal)");
|
||||
} else if (smallerIsSmall) {
|
||||
signals++; details.push("output menor < 15% del total");
|
||||
}
|
||||
// Señal D: output reutiliza dirección de input
|
||||
const outputToKnownAddr = tx.vout.some(v=>v.scriptpubkey_address && inputAddrSet.has(v.scriptpubkey_address));
|
||||
if (outputToKnownAddr) { signals+=2; details.push("output reutiliza dirección de input — cambio casi seguro"); bonus += 10; }
|
||||
// Señal E: mismatch tipo input/output
|
||||
if (hasOutputMismatch) { signals++; details.push("tipo de output distinto al de inputs"); }
|
||||
// Señal F: posición del cambio — solo si la señal A no se disparó ya
|
||||
if (!isBip69 && !signalA) {
|
||||
const lastOut = tx.vout[tx.vout.length - 1];
|
||||
if (dominantInType && lastOut.scriptpubkey_type === dominantInType) {
|
||||
signals++; details.push("posición fija del cambio (índice 1, sin BIP69)");
|
||||
const matches = tx.vout.filter(v=>v.scriptpubkey_type===dominantInType);
|
||||
if (matches.length === 1) {
|
||||
signals++;
|
||||
typeMatchIdx = tx.vout.findIndex(v=>v.scriptpubkey_type===dominantInType);
|
||||
details.push("solo una de las dos salidas comparte tipo de script con las entradas");
|
||||
}
|
||||
}
|
||||
const signalA = typeMatchIdx !== null;
|
||||
|
||||
// ── Señal B: hay exactamente un output de cifra redonda.
|
||||
// La lectura correcta es que el redondo es el PAGO, así que el cambio
|
||||
// es el OTRO. Antes se contaba la señal y luego el índice caía en el
|
||||
// output más pequeño, que en un pago pequeño desde una moneda grande es
|
||||
// justo el pago: la app decía "el output redondo es el pago" y acto
|
||||
// seguido lo señalaba como cambio.
|
||||
let roundOtherIdx = null;
|
||||
if (roundOutputs.length === 1) {
|
||||
signals++;
|
||||
const paymentIdx = tx.vout.indexOf(roundOutputs[0]);
|
||||
roundOtherIdx = paymentIdx === 0 ? 1 : 0;
|
||||
details.push("una salida es cifra redonda — suele ser el pago, y el cambio el otro");
|
||||
}
|
||||
|
||||
// ── Señal C: una salida es mucho menor que el total gastado.
|
||||
// Señal DÉBIL y sin dirección: no dice cuál es el cambio, porque el
|
||||
// cambio es pequeño cuando gastas casi toda la moneda y grande cuando
|
||||
// pagas poco desde una moneda gorda. Cuenta para el nivel de sospecha,
|
||||
// no para señalar un output.
|
||||
const smallerIsSmall = totalIn > 0 && smaller.value < totalIn * 0.15;
|
||||
if (smallerIsSmall) {
|
||||
signals++;
|
||||
details.push("una salida es menos del 15% del total (no indica por sí sola cuál es el cambio)");
|
||||
}
|
||||
|
||||
// ── Señal D: un output vuelve a una dirección de las entradas.
|
||||
// La más fuerte de todas: el cambio es esa, sin ambigüedad.
|
||||
const knownAddrIdx = tx.vout.findIndex(v=>v.scriptpubkey_address && inputAddrSet.has(v.scriptpubkey_address));
|
||||
const outputToKnownAddr = knownAddrIdx !== -1;
|
||||
if (outputToKnownAddr) {
|
||||
signals += 2; bonus += 10;
|
||||
details.push("una salida reutiliza una dirección de las entradas — cambio casi seguro");
|
||||
}
|
||||
|
||||
// ── Señal E: posición fija del cambio, solo si la señal A no dijo ya
|
||||
// lo mismo por una vía mejor.
|
||||
let positionIdx = null;
|
||||
if (!isBip69 && !signalA && dominantInType) {
|
||||
const lastIdx = tx.vout.length - 1;
|
||||
if (tx.vout[lastIdx].scriptpubkey_type === dominantInType) {
|
||||
signals++; positionIdx = lastIdx;
|
||||
details.push("el cambio ocupa la última posición y no hay orden BIP69");
|
||||
}
|
||||
}
|
||||
|
||||
// Bonus correlación: ≥3 señales independientes
|
||||
if (signals >= 3) bonus += 5;
|
||||
|
||||
const identifiable = signals >= 2;
|
||||
const certainty = bonus >= 10 ? "PROBABLE" : signals >= 3 ? "PROBABLE" : signals >= 2 ? "PROBABLE" : "POSIBLE";
|
||||
const certainty = bonus >= 10 ? "PROBABLE" : signals >= 2 ? "PROBABLE" : "POSIBLE";
|
||||
|
||||
// Índice del output de cambio, por orden de fuerza de señal: reutilización
|
||||
// de dirección de input > tipo idéntico a inputs > el output menor.
|
||||
// Índice del cambio, por orden de fuerza. Solo señalan las que de verdad
|
||||
// apuntan a un output; si ninguna lo hace, el índice queda en null y se
|
||||
// dice que hay señales pero no cuál — que es preferible a señalar mal.
|
||||
let index = null;
|
||||
if (identifiable) {
|
||||
if (outputToKnownAddr) {
|
||||
index = tx.vout.findIndex(v=>v.scriptpubkey_address && inputAddrSet.has(v.scriptpubkey_address));
|
||||
} else if (signalA) {
|
||||
index = tx.vout.findIndex(v=>v.scriptpubkey_type===dominantInType);
|
||||
} else {
|
||||
index = tx.vout.indexOf(smaller);
|
||||
}
|
||||
index = outputToKnownAddr ? knownAddrIdx
|
||||
: signalA ? typeMatchIdx
|
||||
: roundOtherIdx !== null ? roundOtherIdx
|
||||
: positionIdx;
|
||||
}
|
||||
if (identifiable && index === null) {
|
||||
details.push("las señales no coinciden en cuál de las dos salidas es el cambio");
|
||||
}
|
||||
|
||||
return { identifiable, index, signals, details, bonus, certainty };
|
||||
@@ -1599,15 +1756,40 @@
|
||||
|
||||
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.
|
||||
// Lo que hiciste TÚ. Solo esto entra en la nota, porque solo sobre esto
|
||||
// puedes actuar. Una nota que mezcla tu conducta con la de quien te pagó
|
||||
// deja de significar nada: hasta la v1.16 alguien impecable que cobró de
|
||||
// un exchange sacaba peor nota que un descuidado que cobró de un
|
||||
// particular, y no había forma de que la mejorase.
|
||||
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,
|
||||
wallet_fingerprint: 6, batch_payment: 45, op_return: 30, legacy_type: 15,
|
||||
input_reuse: 25, input_linkage: 30, input_type_mixing: 14,
|
||||
round_numbers: 10, unnecessary_input: 12, dust_spent: 20,
|
||||
change_detection: 10, wallet_fingerprint: 6, 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.
|
||||
let deductions = 0;
|
||||
// Lo que te hicieron OTROS. Se informa aparte, con su propio recuento,
|
||||
// y NO resta de la nota. No es que dé igual —te expone lo mismo— es que
|
||||
// no es una calificación de nada tuyo: es contexto heredado.
|
||||
const heredado = {
|
||||
batch_payment: 45, op_return: 30, dust: 8, output_type_mismatch: 8,
|
||||
};
|
||||
// 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; // resta de la nota
|
||||
let expuesto = 0; // no resta: se informa aparte
|
||||
|
||||
const totalIn = tx.vin.reduce((s,v)=>s+(v.prevout?v.prevout.value:0),0);
|
||||
const totalOut = tx.vout.reduce((s,v)=>s+v.value,0);
|
||||
@@ -1725,12 +1907,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,
|
||||
@@ -1784,9 +1968,9 @@
|
||||
actionability: "no_corregible",
|
||||
detail: hasOutputMismatch ? mismatchDetail : "Los outputs son del mismo tipo que los inputs — no se puede distinguir cuál es el cambio por tipo de script.",
|
||||
didactic: "Tu wallet devuelve el cambio a una dirección del mismo tipo que las que gasta. Así que si pagas a alguien con un formato distinto al tuyo, el cambio se delata solo: es la salida que coincide con tus entradas. La señal es fuerte, pero no infalible — si quien cobra usa tu mismo formato, o si te pagas a ti mismo, deja de distinguirse. Por eso es probable y no certeza.",
|
||||
penalty: weights.output_type_mismatch,
|
||||
penalty: heredado.output_type_mismatch,
|
||||
});
|
||||
if (hasOutputMismatch) deductions += weights.output_type_mismatch;
|
||||
if (hasOutputMismatch) expuesto += heredado.output_type_mismatch;
|
||||
|
||||
// ── 4. CoinJoin — informativo, sin penalización ──────────────────
|
||||
checks.push({
|
||||
@@ -1801,20 +1985,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);
|
||||
@@ -1854,19 +2042,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.`
|
||||
: (hasUnnecessaryInput ? unnecessaryDetail : "No se detectan inputs claramente innecesarios para cubrir el pago."),
|
||||
: 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
|
||||
@@ -1914,25 +2120,69 @@
|
||||
actionability: "no_corregible",
|
||||
detail: `${dustOutputs.length} output(s) por debajo del umbral de dust (${dustOutputs.map(d=>d.value+"sat").join(", ")}). Hecho: hay salidas por debajo del mínimo económico de la red. Interpretación: podría ser un ataque de dusting, pero también un cambio diminuto de tu propio wallet o una salida de protocolo (Lightning, inscripciones). No se puede distinguir solo con esta transacción. Consecuencia: si esa salida se gasta junto a otros UTXOs, queda vinculada con ellos — que es justo lo que persigue un ataque de dusting.`,
|
||||
didactic: "Un ataque de dust envía cantidades mínimas a múltiples direcciones. Cuando el receptor gasta ese dust combinándolo con otros UTXOs, revela qué UTXOs pertenecen al mismo wallet. Es una técnica de surveillance — pero no toda salida diminuta es un ataque: también las hay legítimas (cambios pequeños, salidas de protocolo). La marca se materializa solo si el dust se gasta junto a otras monedas.",
|
||||
penalty: weights.dust,
|
||||
penalty: heredado.dust,
|
||||
});
|
||||
deductions += weights.dust;
|
||||
expuesto += heredado.dust;
|
||||
} else if (hasDusting) {
|
||||
checks.push({
|
||||
id:"dust", label:"Salida muy pequeña (posible dusting)", certainty:"POSIBLE", pass:false,
|
||||
actionability: "no_corregible",
|
||||
detail: `${dustingOutputs.length} output(s) de cantidad muy pequeña (${dustingOutputs.map(d=>d.value+"sat").join(", ")}), por encima del mínimo técnico pero inusualmente bajos. Hecho: son salidas diminutas pero gastables. Interpretación: compatible con un dusting de privacidad (cantidades pequeñas enviadas a propósito por encima del umbral de dust para que el receptor las conserve), aunque también con un pago o cambio pequeño legítimo. No se puede confirmar solo con esta transacción. Consecuencia: si esa salida se gasta junto a otros UTXOs, queda vinculada con ellos.`,
|
||||
didactic: "El umbral de dust técnico (≈546 sats) marca lo que la red considera antieconómico de gastar. Pero un dusting de privacidad suele usar cantidades algo mayores —gastables a propósito— para que el receptor las mantenga en su wallet y, al gastarlas, revele qué UTXOs son suyos. Por eso una salida pequeña por encima del umbral técnico merece atención, aunque no sea concluyente.",
|
||||
penalty: weights.dust,
|
||||
penalty: heredado.dust,
|
||||
});
|
||||
deductions += weights.dust;
|
||||
expuesto += heredado.dust;
|
||||
} else {
|
||||
checks.push({
|
||||
id:"dust", label:"Outputs de dust detectados", certainty:"CERTEZA", pass:true,
|
||||
actionability: "no_corregible",
|
||||
detail: "Sin outputs de dust ni salidas inusualmente pequeñas (según el umbral de cada tipo de salida).",
|
||||
didactic: "Un ataque de dust envía cantidades mínimas a múltiples direcciones. Cuando el receptor gasta ese dust combinándolo con otros UTXOs, revela qué UTXOs pertenecen al mismo wallet. Es una técnica de surveillance.",
|
||||
penalty: weights.dust, informational: true,
|
||||
penalty: heredado.dust, informational: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1955,10 +2205,14 @@
|
||||
id:"change_detection", label:"Output de cambio identificable", certainty: changeCertainty, pass:!changeIdentifiable,
|
||||
actionability: "evitable",
|
||||
detail: changeIdentifiable
|
||||
? `${changeSignals} señal(es) identifican el cambio: ${changeDetails.join(" · ")}.${correlatedProblem?" (penalización reducida por correlación con otros checks)":""}`
|
||||
? (changeGuess.index != null
|
||||
? `Hecho (certeza): ${changeSignals} señales apuntan al output #${changeGuess.index} — ${changeDetails.join(" · ")}. Interpretación (probable): ese es el cambio, la moneda que vuelve a tu propio wallet. Consecuencia: quien lo identifique puede seguir tus gastos posteriores desde ahí.${correlatedProblem?" (penalización reducida por correlación con otros checks)":""}`
|
||||
: `Hecho (certeza): hay ${changeSignals} señales de que el cambio es identificable — ${changeDetails.join(" · ")}. Interpretación: las señales no coinciden en cuál de las dos salidas es el cambio, así que no se señala ninguna. Consecuencia: la transacción filtra que hay un cambio, aunque esta herramienta no se atreva a decir cuál — otro analista con más contexto sí podría.${correlatedProblem?" (penalización reducida por correlación con otros checks)":""}`)
|
||||
: tx.vout.length > 2
|
||||
? `Estructura de ${tx.vout.length} outputs — la identificación del cambio es menos fiable y no se afirma aquí. La herramienta no adivina el cambio en transacciones con más de 2 salidas para no dar una certeza que los datos no sostienen.`
|
||||
: "No hay suficientes señales para identificar el output de cambio.",
|
||||
? `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.",
|
||||
didactic: "Identificar el output de cambio es el objetivo central del chain analysis: quien conoce tu dirección de cambio puede seguir rastreando tus fondos en transacciones futuras. Se detecta combinando tipo de script, valores relativos, posición y reutilización de direcciones.",
|
||||
penalty: changeEffectivePenalty,
|
||||
});
|
||||
@@ -2044,9 +2298,9 @@
|
||||
? `${tx.vout.length} outputs, casi todos con valores distintos: compatible con un exchange o servicio que paga a muchos destinatarios en una sola transacción. Compartes los mismos inputs —y por tanto el mismo origen— con los otros destinatarios del lote, lo que permite a un observador agruparos.`
|
||||
: "No se detecta patrón de batch payment.",
|
||||
didactic: "Exchanges y custodios agrupan múltiples pagos en una transacción para ahorrar comisiones. Recibir de un batch reduce tu privacidad: los demás destinatarios comparten origen contigo, así que un observador puede correlacionar todos los pagos del lote. Y si el origen es un exchange con KYC, ese servicio conoce la identidad asociada. Por eso baja la valoración aunque tú no controles cómo te pagaron.",
|
||||
penalty: isBatch ? weights.batch_payment : 0,
|
||||
penalty: isBatch ? heredado.batch_payment : 0,
|
||||
});
|
||||
if (isBatch) deductions += weights.batch_payment;
|
||||
if (isBatch) expuesto += heredado.batch_payment;
|
||||
|
||||
// ── 15. OP_RETURN ─────────────────────────────────────────────────
|
||||
// Detecta outputs OP_RETURN (nulldata): presencia y tamaño total en bytes.
|
||||
@@ -2066,9 +2320,9 @@
|
||||
? `${opReturnOuts.length} output${opReturnOuts.length > 1 ? "s" : ""} OP_RETURN con ${opReturnBytes} bytes de datos arbitrarios. Revela que esta transacción usa un protocolo o servicio que escribe datos en la cadena (Stamps, Ordinals, OpenTimestamps, Omni, etc.). Se informa la presencia y el tamaño, no el contenido.`
|
||||
: "No se detectan outputs OP_RETURN.",
|
||||
didactic: "Un output OP_RETURN contiene datos arbitrarios que cualquiera puede leer. Su presencia revela el uso de un protocolo concreto y puede vincular esta transacción con actividad on-chain identificable. El contenido no se muestra — para la privacidad lo relevante es que existe, no qué dice.",
|
||||
penalty: hasOpReturn ? weights.op_return : 0,
|
||||
penalty: hasOpReturn ? heredado.op_return : 0,
|
||||
});
|
||||
if (hasOpReturn) deductions += weights.op_return;
|
||||
if (hasOpReturn) expuesto += heredado.op_return;
|
||||
|
||||
// ── 16. Timing analysis — informativo ────────────────────────────
|
||||
let timingDetail = "No hay timestamp disponible (transacción pendiente o sin confirmar).";
|
||||
@@ -2090,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
|
||||
@@ -2189,17 +2477,40 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ── Score compuesto ───────────────────────────────────────────────
|
||||
// ── Nota (solo lo que dependía de ti) ─────────────────────────────
|
||||
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 = Math.max(0, Math.min(100, Math.round(100 - (deductions / maxPossible) * 100)));
|
||||
|
||||
// Un fallo grave impide la banda ALTA aunque el número dé de sobra.
|
||||
// Sin esta regla, una transacción cuyo cambio es identificable —una fuga
|
||||
// real y concreta— saldría con 93 sobre 100 y la etiqueta "privacidad
|
||||
// aceptable", que es exactamente el tipo de afirmación de más que esta
|
||||
// herramienta existe para no hacer. Un promedio alto no borra un fallo
|
||||
// concreto.
|
||||
const falloGrave = checks.some(c =>
|
||||
!c.informational && c.pass === false && (c.penalty || 0) >= 10 && weights[c.id] !== undefined
|
||||
);
|
||||
const band = (score >= BANDA_ALTA && !falloGrave) ? "ALTA"
|
||||
: score >= BANDA_MEDIA ? "MEDIA" : "BAJA";
|
||||
|
||||
// ── Exposición heredada (lo que hicieron otros) ───────────────────
|
||||
// Cuenta aparte. No resta de la nota porque no califica nada tuyo, pero
|
||||
// te expone igual y por eso se informa con su propio nivel.
|
||||
const heredadoMax = Object.values(heredado).reduce((a,b)=>a+b,0);
|
||||
const itemsHeredados = checks.filter(c =>
|
||||
!c.informational && c.pass === false && heredado[c.id] !== undefined
|
||||
).map(c => ({ id: c.id, label: c.label }));
|
||||
const exposicion = {
|
||||
puntos: expuesto,
|
||||
max: heredadoMax,
|
||||
items: itemsHeredados,
|
||||
nivel: expuesto === 0 ? "ninguna" : expuesto >= heredadoMax * 0.5 ? "alta" : "moderada",
|
||||
};
|
||||
|
||||
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,
|
||||
score, checks, band, exposicion,
|
||||
summary: band==="ALTA"?"Privacidad aceptable":band==="MEDIA"?"Privacidad mejorable":"Privacidad baja",
|
||||
summaryColor: band==="ALTA"?C.green:band==="MEDIA"?C.amber:C.red,
|
||||
wallets: detectedWallets,
|
||||
};
|
||||
}
|
||||
@@ -2231,6 +2542,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: [] };
|
||||
}
|
||||
|
||||
@@ -2593,6 +2908,7 @@
|
||||
accionabilidad: c.actionability || null,
|
||||
penalizacion: c.penalty || 0, detalle: c.detail,
|
||||
})),
|
||||
exposicion_heredada: analysis.exposicion || null,
|
||||
};
|
||||
descargar(JSON.stringify(datos, null, 2), nombreBase()+".json", "application/json;charset=utf-8");
|
||||
};
|
||||
@@ -2603,7 +2919,13 @@
|
||||
L.push("");
|
||||
L.push(`**Transacción:** \`${tx?.txid || "—"}\` `);
|
||||
L.push(`**Generado:** ${new Date().toLocaleString("es-ES")} `);
|
||||
L.push(`**Nivel de privacidad:** ${band} (${score}/100) — ${summary}`);
|
||||
L.push(`**Lo que dependía de ti:** ${band} (${score}/100) — ${summary}`);
|
||||
if (analysis.exposicion) {
|
||||
L.push("");
|
||||
L.push(analysis.exposicion.items.length === 0
|
||||
? `**Lo que hicieron otros:** exposición ninguna. Nada en esta transacción te expone por decisión de un tercero.`
|
||||
: `**Lo que hicieron otros:** exposición ${analysis.exposicion.nivel} — ${analysis.exposicion.items.map(i=>i.label).join(", ")}. Te expone igual, pero no lo decidiste tú, así que no resta de la nota.`);
|
||||
}
|
||||
L.push("");
|
||||
if (wallets && wallets.length > 0) {
|
||||
L.push(`**Wallet posible:** ${wallets.map(w=>w.name||w).join(", ")}`);
|
||||
@@ -2657,14 +2979,16 @@
|
||||
<Card glow={summaryColor} style={{display:"flex",alignItems:"center",gap:16,padding:"14px 18px",marginBottom:10}}>
|
||||
<ScoreRing score={score} color={summaryColor} size={72} label={band}/>
|
||||
<div style={{flex:1}}>
|
||||
<div style={{fontSize:"0.7rem",color:C.t2,fontFamily:"monospace",marginBottom:4}}>NIVEL DE PRIVACIDAD</div>
|
||||
<div style={{fontSize:"0.7rem",color:C.t2,fontFamily:"monospace",marginBottom:4}}>
|
||||
{tx ? "LO QUE DEPENDÍA DE TI" : "NIVEL DE PRIVACIDAD"}
|
||||
</div>
|
||||
<div style={{fontSize:"1rem",color:summaryColor,fontFamily:"monospace",fontWeight:700,marginBottom:4}}>{summary}</div>
|
||||
<div style={{fontSize:"0.65rem",color:C.t2,lineHeight:1.5}}>
|
||||
{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"}}>
|
||||
@@ -2672,6 +2996,26 @@
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Exposición heredada — cuenta aparte, a propósito.
|
||||
La nota de arriba solo mide lo que podías haber hecho de otra
|
||||
manera. Esto te expone igual, pero no lo decidiste tú, así
|
||||
que no baja una nota que no podrías subir. */}
|
||||
{analysis.exposicion && (
|
||||
<div style={{marginTop:10,paddingTop:8,borderTop:`1px solid ${C.border}`}}>
|
||||
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",marginBottom:3}}>
|
||||
LO QUE HICIERON OTROS
|
||||
<span style={{marginLeft:6,color: analysis.exposicion.nivel==="ninguna"?C.green:analysis.exposicion.nivel==="alta"?C.red:C.amber}}>
|
||||
exposición {analysis.exposicion.nivel}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{fontSize:"0.62rem",color:C.t2,lineHeight:1.5}}>
|
||||
{analysis.exposicion.items.length === 0
|
||||
? "Nada en esta transacción te expone por decisión de un tercero."
|
||||
: <>Te expone, pero no lo decidiste tú: <span style={{color:C.amber}}>{analysis.exposicion.items.map(i=>i.label).join(" · ")}</span>. No resta de la nota porque no hay nada que pudieras haber hecho distinto — pero conviene saberlo.</>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -4635,7 +4979,7 @@
|
||||
// ── 3. Cronología ────────────────────────────────────────────────────
|
||||
const chronologyRow = (txid, hop, blockTime, blockHeight, amount, analysis, fingerprint, entityMarks) => ({
|
||||
level: "HECHO", txid, hop, blockTime, blockHeight, amount, refs:[txid],
|
||||
band: { level:"INFERENCIA", certainty: analysis?.band ? (analysis.score>=90?"CERTEZA":analysis.score>=45?"PROBABLE":"POSIBLE") : null, value: analysis?.band || "—" },
|
||||
band: { level:"INFERENCIA", certainty: analysis?.band ? (analysis.band==="ALTA"?"CERTEZA":analysis.band==="MEDIA"?"PROBABLE":"POSIBLE") : null, value: analysis?.band || "—" },
|
||||
fingerprint: { level:"INFERENCIA", certainty: fingerprint?.[0]?.confidence || null, value: fingerprint?.[0]?.name || "sin huella clara" },
|
||||
entityMarks: entityMarks || [],
|
||||
});
|
||||
@@ -5428,6 +5772,33 @@
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<div style={{padding:"10px 14px",background:C.bgCard,border:`1px solid ${C.amber}40`,borderRadius:8,marginBottom:14}}>
|
||||
<div style={{fontSize:"0.64rem",color:C.amber,fontFamily:"monospace",fontWeight:700,marginBottom:5}}>
|
||||
{walletReport.autoenviosDaninos.length} autoenvío(s) que ataron direcciones tuyas
|
||||
</div>
|
||||
<div style={{fontSize:"0.62rem",color:C.t2,lineHeight:1.6,marginBottom:6}}>
|
||||
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.
|
||||
</div>
|
||||
<div style={{display:"flex",flexDirection:"column",gap:3}}>
|
||||
{walletReport.autoenviosDaninos.slice(0,6).map(a=>(
|
||||
<div key={a.txid} style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace"}}>
|
||||
<span style={{color:C.amber}}>{a.vinculadas} direcciones atadas</span>
|
||||
{" · "}{a.time ? new Date(a.time*1000).toLocaleDateString("es-ES") : "sin confirmar"}
|
||||
{" · "}<VerEnMempool base={base} tipo="tx" id={a.txid} texto={a.txid.slice(0,12)+"… ↗"}/>
|
||||
</div>
|
||||
))}
|
||||
{walletReport.autoenviosDaninos.length>6&&<div style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace"}}>…y {walletReport.autoenviosDaninos.length-6} más</div>}
|
||||
</div>
|
||||
<div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",marginTop:6,lineHeight:1.5}}>
|
||||
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.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bloque 1: Salud general */}
|
||||
<div style={{display:"flex",alignItems:"center",gap:14,padding:"12px 14px",background:C.bgCard,borderRadius:8,marginBottom:14,border:`1px solid ${walletReport.health.color}30`}}>
|
||||
<div style={{width:48,height:48,borderRadius:"50%",border:`3px solid ${walletReport.health.color}`,display:"flex",alignItems:"center",justifyContent:"center",flexShrink:0}}>
|
||||
|
||||
+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
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
// Detección del output de cambio (guessChangeOutput)
|
||||
//
|
||||
// Extrae la función del dashboard.html y la ejecuta contra transacciones
|
||||
// construidas a mano, donde sabemos de antemano cuál es el pago y cuál el
|
||||
// cambio. Cubre los dos fallos corregidos en la v1.15.0:
|
||||
//
|
||||
// 1. Una misma condición se contaba como dos señales distintas, así que
|
||||
// un pago corriente entre tipos de dirección distintos ya bastaba para
|
||||
// declarar el cambio "identificable" con certeza PROBABLE.
|
||||
// 2. Cuando ninguna señal fuerte apuntaba a un output, el índice caía en
|
||||
// "el más pequeño". En un pago pequeño desde una moneda grande, el más
|
||||
// pequeño es el PAGO: la app señalaba la dirección del destinatario
|
||||
// como si fuera el cambio del emisor.
|
||||
//
|
||||
// Uso: node tests/test5.js
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const htmlPath = path.join(__dirname, "..", "dashboard.html");
|
||||
const lines = fs.readFileSync(htmlPath, "utf8").split("\n");
|
||||
// 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, 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" });
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
function T(nombre, tx, esperado, esCoinJoin) {
|
||||
const r = guessChangeOutput(tx, !!esCoinJoin);
|
||||
const real = r.index == null ? null : tx.vout[r.index].scriptpubkey_address;
|
||||
const ok = real === esperado;
|
||||
console.log(` ${ok ? "✓" : "✗"} ${nombre}`);
|
||||
if (!ok) {
|
||||
console.log(` señaló como cambio: ${real === null ? "ninguno" : real}`);
|
||||
console.log(` esperado: ${esperado === null ? "ninguno" : esperado}`);
|
||||
r.details.forEach(d => console.log(` · ${d}`));
|
||||
fail++;
|
||||
} else pass++;
|
||||
}
|
||||
|
||||
console.log("=== Detección del output de cambio ===\n");
|
||||
|
||||
// Regresión del fallo 1. Pagar desde bech32 a una dirección taproot es de lo
|
||||
// más común que hay. Que los tipos difieran es UNA señal, no dos: sola no
|
||||
// basta para señalar nada.
|
||||
T("pago bech32 -> taproot, importes no redondos: una sola señal, no se afirma",
|
||||
{ vin:[IN(5000000,"v0_p2wpkh","bc1qA")],
|
||||
vout:[OUT(1234567,"v1_p2tr","bc1pPAGO"), OUT(3765000,"v0_p2wpkh","bc1qCAMBIO")] },
|
||||
null);
|
||||
|
||||
// Regresión del fallo 2. La salida redonda es el pago, luego el cambio es la
|
||||
// otra — aunque la otra sea la grande.
|
||||
T("pago redondo pequeño desde moneda grande: el cambio es el output GRANDE",
|
||||
{ vin:[IN(100000000,"v0_p2wpkh","bc1qA")],
|
||||
vout:[OUT(100000,"v0_p2wpkh","bc1qPAGO"), OUT(99895000,"v0_p2wpkh","bc1qCAMBIO")] },
|
||||
"bc1qCAMBIO");
|
||||
|
||||
// La señal más fuerte que existe: el cambio vuelve a una dirección ya gastada.
|
||||
T("el cambio reutiliza una dirección de las entradas",
|
||||
{ vin:[IN(5000000,"v0_p2wpkh","bc1qA")],
|
||||
vout:[OUT(1234567,"v1_p2tr","bc1pPAGO"), OUT(3765000,"v0_p2wpkh","bc1qA")] },
|
||||
"bc1qA");
|
||||
|
||||
// Sin ninguna señal no se inventa nada.
|
||||
T("mismo tipo en ambas salidas, sin redondos ni reuso: no se afirma nada",
|
||||
{ vin:[IN(5000000,"v0_p2wpkh","bc1qA")],
|
||||
vout:[OUT(1234567,"v0_p2wpkh","bc1qB"), OUT(3765000,"v0_p2wpkh","bc1qC")] },
|
||||
null);
|
||||
|
||||
// Caso clásico y correcto desde siempre: pago redondo grande, cambio pequeño.
|
||||
T("pago redondo grande y cambio pequeño (caso clásico)",
|
||||
{ vin:[IN(11000000,"v0_p2wpkh","bc1qA")],
|
||||
vout:[OUT(10000000,"v0_p2wpkh","bc1qPAGO"), OUT(985000,"v0_p2wpkh","bc1qCAMBIO")] },
|
||||
"bc1qCAMBIO");
|
||||
|
||||
// En un CoinJoin la heurística no aplica: aunque las señales estructurales
|
||||
// existan, las entradas son de personas distintas. Esta tx dispararía señales
|
||||
// si no fuera por la guardia.
|
||||
T("CoinJoin: la guardia desactiva la heurística aunque haya señales",
|
||||
{ vin:[IN(100000000,"v0_p2wpkh","bc1qA")],
|
||||
vout:[OUT(100000,"v1_p2tr","bc1pX"), OUT(99895000,"v0_p2wpkh","bc1qY")] },
|
||||
null, true);
|
||||
|
||||
// ── 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
|
||||
// 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);
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
// 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}`);
|
||||
ok("la exposición heredada se reporta aparte y no toca la nota",
|
||||
peor.exposicion && peor.exposicion.items.length > 0,
|
||||
`items = ${JSON.stringify(peor.exposicion?.items?.map(i=>i.id))}`);
|
||||
|
||||
// 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=== La nota mide solo lo que dependía de ti ===\n");
|
||||
|
||||
// Un cobro impecable desde un exchange que agrupa pagos. El destinatario no
|
||||
// eligió nada de esto: hasta la v1.17 le costaba 23 puntos de nota y no había
|
||||
// forma de mejorarla.
|
||||
const vinBatch = [IN(500000000, "v0_p2wpkh", "bc1qEXCHANGE")];
|
||||
const voutBatch = [];
|
||||
for (let i = 0; i < 9; i++) voutBatch.push(OUT(1000000 + i * 31337, "v0_p2wpkh", "bc1qDEST" + i));
|
||||
const batch = analyzeTx(TX(vinBatch, voutBatch));
|
||||
ok("recibir de un lote se detecta",
|
||||
check(batch, "batch_payment")?.pass === false);
|
||||
ok("...pero no baja la nota: aparece como exposición heredada",
|
||||
batch.exposicion.items.some(i => i.id === "batch_payment"),
|
||||
`items = ${JSON.stringify(batch.exposicion.items.map(i=>i.id))}`);
|
||||
ok("...y la nota sigue siendo ALTA, porque quien cobra no hizo nada mal",
|
||||
batch.band === "ALTA", `score = ${batch.score}, banda = ${batch.band}`);
|
||||
|
||||
// OP_RETURN ya no fuerza banda BAJA por decreto.
|
||||
const conOpReturn = analyzeTx(TX(
|
||||
[IN(5000000, "v1_p2tr", "bc1pA")],
|
||||
[OUT(4990000, "v1_p2tr", "bc1pB"),
|
||||
{ value:0, scriptpubkey_type:"op_return", scriptpubkey:"6a0a"+"ab".repeat(10), scriptpubkey_address:null }]
|
||||
));
|
||||
ok("OP_RETURN se detecta", check(conOpReturn, "op_return")?.pass === false);
|
||||
ok("...pero ya no fuerza banda BAJA por decreto",
|
||||
conOpReturn.band !== "BAJA", `banda = ${conOpReturn.band}`);
|
||||
|
||||
// Un fallo grave impide ALTA aunque el número dé de sobra.
|
||||
const cambioVisible = analyzeTx(TX(
|
||||
[IN(100000000, "v0_p2wpkh", "bc1qA")],
|
||||
[OUT(100000, "v0_p2wpkh", "bc1qPAGO"), OUT(99895000, "v0_p2wpkh", "bc1qA")]
|
||||
));
|
||||
ok("un fallo grave impide la banda ALTA aunque el número dé",
|
||||
cambioVisible.band !== "ALTA",
|
||||
`score = ${cambioVisible.score}, banda = ${cambioVisible.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=== 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.
|
||||
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