diff --git a/dashboard.html b/dashboard.html
index aeab05e..f792bb7 100644
--- a/dashboard.html
+++ b/dashboard.html
@@ -278,6 +278,12 @@
const totalOut = tx.vout.reduce((s,v)=>s+v.value,0);
const fee = tx.fee || (totalIn > totalOut ? totalIn - totalOut : 0);
const outputValues = tx.vout.map(v=>v.value);
+ // Outputs gastables: excluimos OP_RETURN (no son monedas, son datos).
+ // Whirlpool puede llevar un OP_RETURN además de las salidas mezcladas,
+ // así que la detección de CoinJoin se hace sobre los gastables.
+ const isOpReturn = (v) => v.scriptpubkey_type === "op_return" || v.scriptpubkey?.startsWith("6a");
+ const spendableOuts = tx.vout.filter(v => !isOpReturn(v));
+ const spendableValues = spendableOuts.map(v => v.value);
// ── BIP69 calculado una vez — compartido con detectWallets ────────
const isBip69Inputs = tx.vin.length < 2 || tx.vin.every((v,i,a) => {
@@ -303,21 +309,28 @@
};
// ── CoinJoin — detección multi-variante ───────────────────────────
- const valueCounts = outputValues.reduce((m,v)=>{m.set(v,(m.get(v)||0)+1);return m;}, new Map());
- const maxEqual = Math.max(...valueCounts.values());
+ // Trabajamos sobre outputs gastables (sin OP_RETURN).
+ const valueCounts = spendableValues.reduce((m,v)=>{m.set(v,(m.get(v)||0)+1);return m;}, new Map());
+ const maxEqual = valueCounts.size > 0 ? Math.max(...valueCounts.values()) : 0;
const whirlpoolDenoms = [100000, 1000000, 5000000, 50000000];
// Whirlpool: las salidas mezcladas son todas de la misma denominación fija.
// Whirlpool descuenta una comisión, así que se tolera un margen estrecho (~2%)
// HACIA la denominación — no un cajón de salidas pequeñas arbitrarias.
const WP_TOL = 0.02;
const whirlpoolDenom = whirlpoolDenoms.find(d => {
- const atDenom = outputValues.filter(v => Math.abs(v - d) <= d * WP_TOL).length;
- // Al menos 5 salidas en la denominación Y que sean mayoría clara del total
- return atDenom >= 5 && atDenom >= Math.ceil(tx.vout.length * 0.6);
+ const atDenom = spendableValues.filter(v => Math.abs(v - d) <= d * WP_TOL).length;
+ // Al menos 5 salidas en la denominación Y mayoría clara de los gastables
+ return atDenom >= 5 && atDenom >= Math.ceil(spendableOuts.length * 0.6);
});
- const isWhirlpool = tx.vin.length >= 5 && tx.vout.length >= 5 && !!whirlpoolDenom;
- // CoinJoin genérico: ≥50% outputs de igual valor (sin denominación Whirlpool reconocida)
- const isGenericCJ = tx.vin.length >= 5 && tx.vout.length >= 5 && maxEqual >= Math.floor(tx.vout.length * 0.5);
+ const isWhirlpool = tx.vin.length >= 5 && spendableOuts.length >= 5 && !!whirlpoolDenom;
+ // CoinJoin genérico: ≥50% outputs gastables de igual valor (sin denominación Whirlpool reconocida).
+ // Filtro de denominación mínima: el valor repetido debe ser ≥10.000 sats.
+ // Mezclar polvo (p.ej. 500 sats) no tiene sentido económico — no es un CoinJoin real.
+ const CJ_MIN_DENOM = 10000;
+ const equalValue = [...valueCounts.entries()].find(([v,c]) => c === maxEqual)?.[0] ?? 0;
+ const isGenericCJ = tx.vin.length >= 5 && spendableOuts.length >= 5
+ && maxEqual >= Math.floor(spendableOuts.length * 0.5)
+ && equalValue >= CJ_MIN_DENOM;
const likelyCJ = isWhirlpool || isGenericCJ;
const cjVariant = isWhirlpool ? "Whirlpool" : "CoinJoin genérico";
@@ -451,18 +464,23 @@
});
if (hasUnnecessaryInput) deductions += weights.unnecessary_input;
- // ── 8. Patrón de pago simple ──────────────────────────────────────
+ // ── 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
+ // común de Bitcoin. Una peeling chain real es una CADENA de estas txs
+ // enlazadas (el cambio de una es el input de la siguiente), y detectarla
+ // requiere mirar las txs anterior/siguiente (futuro: vía rastro de procedencia).
+ // Por ahora: informativo, sin penalización, para no dar un falso positivo.
const isPeeling = tx.vin.length === 1 && tx.vout.length === 2 && !likelyCJ;
checks.push({
- id:"peeling", label:"Patrón de pago simple (1 input, 2 outputs)", certainty:"POSIBLE", pass:!isPeeling,
+ id:"peeling", label:"Patrón de pago simple (1 input, 2 outputs)", certainty:"informativo", pass:!isPeeling,
actionability: "evitable",
detail: isPeeling
- ? "1 input y 2 outputs. Patrón de pago simple — uno es el pago, otro el cambio. Facilita el rastreo encadenado de fondos."
+ ? "1 input y 2 outputs: el patrón de pago más común (un pago + un cambio). Por sí solo no es un problema. Solo se convierte en peeling chain si se encadena con otras transacciones iguales — eso no puede confirmarse mirando una transacción aislada."
: "La estructura no sigue el patrón de pago simple 1→2.",
- didactic: "Una peeling chain es una serie de transacciones 1-input/2-outputs donde el cambio se convierte en el input de la siguiente. Cada eslabón es fácilmente trazable. Es el patrón más común en pagos individuales.",
- penalty: weights.peeling,
+ didactic: "Una peeling chain es una serie de transacciones 1-input/2-outputs donde el cambio de una se convierte en el input de la siguiente. Cada eslabón es fácil de trazar. Pero una sola transacción 1→2 es simplemente un pago normal — no una cadena. Confirmar una cadena requiere seguir el rastro hacia adelante y atrás.",
+ penalty: 0, informational: true,
});
- if (isPeeling) deductions += weights.peeling;
+ // Sin penalización: una tx aislada 1→2 no es trazabilidad encadenada.
// ── 9. Dust outputs ───────────────────────────────────────────────
// El umbral de dust no es fijo: depende del tipo de salida porque cuesta
@@ -571,16 +589,31 @@
// ── 12. Wallet fingerprinting ─────────────────────────────────────
const detectedWallets = detectWallets(tx, isBip69);
+ // Si la tx es un CoinJoin, el tipo de mezcla revela el wallet con más
+ // fiabilidad que el fingerprinting estructural. Lo anteponemos.
+ if (isWhirlpool) {
+ detectedWallets.unshift({
+ name: "Samourai / Sparrow (Whirlpool)", confidence: "PROBABLE",
+ signals: ["estructura Whirlpool — denominación fija de pool"]
+ });
+ } else if (isGenericCJ) {
+ detectedWallets.unshift({
+ name: "Wasabi o JoinMarket (CoinJoin)", confidence: "POSIBLE",
+ signals: ["estructura CoinJoin genérica — outputs de igual valor"]
+ });
+ }
checks.push({
id:"wallet_fingerprint", label:"Fingerprinting de wallet", certainty: detectedWallets[0]?.confidence||"POSIBLE", pass:detectedWallets.length===0,
actionability: "wallet",
detail: detectedWallets.length > 0
? detectedWallets.map(w=>`${w.name} (${w.confidence}): ${w.signals.join(", ")}`).join(" · ")
: "No se detecta un patrón de wallet específico con suficiente certeza.",
- didactic: "El objetivo no es ser invisible sino ser indistinguible de millones de usuarios del mismo wallet. Un fingerprint de Bitcoin Core lo comparten millones de transacciones — no revela nada útil. Un fingerprint de Exodus o un wallet minoritario pertenece a un conjunto mucho menor y es más revelador.",
- penalty: weights.wallet_fingerprint,
+ didactic: "El objetivo no es ser invisible sino ser indistinguible de millones de usuarios del mismo wallet. Un fingerprint de Bitcoin Core lo comparten millones de transacciones — no revela nada útil. Un fingerprint de Exodus o un wallet minoritario pertenece a un conjunto mucho menor y es más revelador. En un CoinJoin, el tipo de mezcla ya sugiere el software usado.",
+ penalty: likelyCJ ? 0 : weights.wallet_fingerprint,
});
- if (detectedWallets.length > 0) deductions += weights.wallet_fingerprint;
+ // En CoinJoin no penaliza (identificar el wallet es esperado, no un fallo).
+ // Fuera de CoinJoin, solo penaliza si se detectó un wallet estructural real.
+ if (!likelyCJ && detectedWallets.length > 0) deductions += weights.wallet_fingerprint;
// ── 13. PayJoin — informativo ─────────────────────────────────────
checks.push({
@@ -594,16 +627,20 @@
});
// ── 14. Batch payment ─────────────────────────────────────────────
+ // Un batch real (exchange/servicio pagando a muchos) tiene muchos outputs
+ // casi todos con valores distintos. No exigimos que TODOS sean distintos
+ // (dos destinatarios pueden recibir la misma cantidad), sino la mayoría.
const uniqueOutValues = new Set(outputValues);
- const isBatch = tx.vout.length >= 5 && uniqueOutValues.size === tx.vout.length && !likelyCJ;
+ const distinctRatio = tx.vout.length > 0 ? uniqueOutValues.size / tx.vout.length : 0;
+ const isBatch = tx.vout.length >= 5 && distinctRatio >= 0.8 && uniqueOutValues.size >= 5 && !likelyCJ;
checks.push({
- id:"batch_payment", label:"Pago por lotes (batch payment)", certainty:"POSIBLE", pass:!isBatch,
+ id:"batch_payment", label:"Pago por lotes (batch payment)", certainty:"informativo", pass:!isBatch,
actionability: "no_corregible",
detail: isBatch
- ? `${tx.vout.length} outputs con valores todos distintos. Patrón típico de exchange o servicio enviando a múltiples destinatarios a la vez.`
+ ? `${tx.vout.length} outputs, casi todos con valores distintos. Compatible con un exchange o servicio enviando a múltiples destinatarios en una sola transacción. Si eres uno de los receptores, esto no es un fallo tuyo.`
: "No se detecta patrón de batch payment.",
- didactic: "Exchanges y custodios agrupan múltiples pagos en una transacción para ahorrar fees. Si recibes fondos de un batch, otros destinatarios pueden correlacionar sus pagos con los tuyos porque comparten los mismos inputs — y por tanto el mismo origen.",
- penalty: weights.batch_payment, informational: isBatch,
+ didactic: "Exchanges y custodios agrupan múltiples pagos en una transacción para ahorrar fees. Si recibes fondos de un batch, otros destinatarios podrían correlacionar sus pagos con los tuyos porque comparten los mismos inputs — y por tanto el mismo origen. No es algo que puedas controlar como receptor, pero conviene saberlo.",
+ penalty: 0, informational: true,
});
// ── 15. Timing analysis — informativo ────────────────────────────