diff --git a/dashboard.html b/dashboard.html
index 0f78aa9..f8075c8 100644
--- a/dashboard.html
+++ b/dashboard.html
@@ -1085,8 +1085,22 @@
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";
+ // CoinJoin por estructura de mezcla (WabiSabi / JoinMarket outputs variables).
+ // WabiSabi no usa denominaciones fijas — sus outputs son de valor variable,
+ // así que isGenericCJ no lo captura. Pero sí tiene firma estructural clara:
+ // muchos inputs Y muchos outputs, ratio cercano a 1 (no es batch ni consolidación),
+ // y sin un output dominante que concentre el valor (no hay "cambio" obvio).
+ const CJ_STRUCT_MIN = 10;
+ const inputOutputRatio = spendableOuts.length > 0 ? tx.vin.length / spendableOuts.length : 999;
+ const maxOutValue = spendableOuts.length > 0 ? Math.max(...spendableValues) : 0;
+ const totalOutValue = spendableValues.reduce((s,v) => s+v, 0);
+ const dominantOutputShare = totalOutValue > 0 ? maxOutValue / totalOutValue : 1;
+ const isStructuralCJ = !isWhirlpool && !isGenericCJ
+ && tx.vin.length >= CJ_STRUCT_MIN && spendableOuts.length >= CJ_STRUCT_MIN
+ && inputOutputRatio >= 0.5 && inputOutputRatio <= 2.0
+ && dominantOutputShare < 0.3;
+ const likelyCJ = isWhirlpool || isGenericCJ || isStructuralCJ;
+ const cjVariant = isWhirlpool ? "Whirlpool" : isStructuralCJ ? "CoinJoin (mezcla estructural)" : "CoinJoin genérico";
// ── PayJoin — señal débil informativa ────────────────────────────
// Si hay exactamente 1 input "extra" que no era necesario y no es CoinJoin
@@ -1114,15 +1128,20 @@
const outTypes = [...new Set(tx.vout.map(v=>v.scriptpubkey_type).filter(Boolean))];
const hasInputTypeMixing = inTypes.length > 1;
checks.push({
- id:"input_type_mixing", label:"Mezcla de tipos de script en inputs", certainty:"CERTEZA", pass:!hasInputTypeMixing,
- actionability: "evitable",
+ id:"input_type_mixing", label:"Mezcla de tipos de script en inputs", certainty:"CERTEZA",
+ pass: likelyCJ ? true : !hasInputTypeMixing,
+ informational: likelyCJ && hasInputTypeMixing,
+ actionability: likelyCJ ? null : "evitable",
detail: hasInputTypeMixing
- ? `Hecho (certeza): los inputs son de tipos distintos (${inTypes.join(", ")}). Interpretación (probable): suele indicar consolidación de UTXOs de fuentes o épocas distintas — aunque un mismo wallet moderno también puede tener UTXOs de varios tipos.`
+ ? (likelyCJ
+ ? `Los inputs mezclan tipos (${inTypes.join(", ")}). En un CoinJoin es normal: cada participante aporta sus propios UTXOs, que pueden ser de tipos distintos.`
+ : `Hecho (certeza): los inputs son de tipos distintos (${inTypes.join(", ")}). Interpretación (probable): suele indicar consolidación de UTXOs de fuentes o épocas distintas — aunque un mismo wallet moderno también puede tener UTXOs de varios tipos.`)
: `Inputs uniformes: ${inTypes[0] || "—"}.`,
didactic: "Que los inputs mezclen tipos de script es un hecho observable. Lo que se infiere de ello —que el emisor consolidaba UTXOs de wallets o épocas distintas— es probable pero no seguro: un wallet moderno puede acumular UTXOs de varios tipos con el tiempo. La señal es reveladora para el análisis, pero conviene leerla como indicio, no como prueba.",
- penalty: weights.input_type_mixing,
+ penalty: likelyCJ ? 0 : weights.input_type_mixing,
});
- if (hasInputTypeMixing) deductions += weights.input_type_mixing;
+ // En CoinJoin la mezcla de tipos de script es esperada (participantes distintos) — no penaliza.
+ if (hasInputTypeMixing && !likelyCJ) deductions += weights.input_type_mixing;
// ── 3. Mismatch tipo input/output ────────────────────────────────
const dominantInType = inTypes[0];
@@ -1210,13 +1229,18 @@
}
}
checks.push({
- id:"unnecessary_input", label:"Inputs innecesarios (consolidación revelada)", certainty:"PROBABLE", pass:!hasUnnecessaryInput,
- actionability: "evitable",
- detail: hasUnnecessaryInput ? unnecessaryDetail : "No se detectan inputs claramente innecesarios para cubrir el pago.",
+ id:"unnecessary_input", label:"Inputs innecesarios (consolidación revelada)", certainty:"PROBABLE",
+ pass: likelyCJ ? true : !hasUnnecessaryInput,
+ 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."),
didactic: "Si una transacción tiene más inputs de los necesarios para cubrir el pago, el emisor estaba consolidando UTXOs. Esto revela que esos UTXOs pertenecen al mismo propietario y es una de las heurísticas más usadas en chain analysis real.",
- penalty: weights.unnecessary_input,
+ penalty: likelyCJ ? 0 : weights.unnecessary_input,
});
- if (hasUnnecessaryInput) deductions += weights.unnecessary_input;
+ // En CoinJoin múltiples inputs son la estructura del protocolo — no penaliza.
+ if (hasUnnecessaryInput && !likelyCJ) 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
@@ -1382,12 +1406,22 @@
name: "Wasabi o JoinMarket (CoinJoin)", confidence: "POSIBLE",
signals: ["estructura CoinJoin genérica — outputs de igual valor"]
});
+ } else if (isStructuralCJ) {
+ detectedWallets.unshift({
+ name: "Wasabi o JoinMarket (CoinJoin)", confidence: "POSIBLE",
+ signals: ["estructura de mezcla — muchos inputs/outputs, sin output dominante"]
+ });
}
checks.push({
- id:"wallet_fingerprint", label:"Fingerprinting de wallet", certainty: detectedWallets[0]?.confidence||"POSIBLE", pass:detectedWallets.length===0,
- actionability: "wallet",
+ id:"wallet_fingerprint", label:"Fingerprinting de wallet",
+ certainty: detectedWallets[0]?.confidence||"POSIBLE",
+ pass: likelyCJ ? true : detectedWallets.length===0,
+ informational: likelyCJ && detectedWallets.length > 0,
+ actionability: likelyCJ ? null : "wallet",
detail: detectedWallets.length > 0
- ? detectedWallets.map(w=>`${w.name} (${w.confidence}): ${w.signals.join(", ")}`).join(" · ")
+ ? (likelyCJ
+ ? `En un CoinJoin identificar el software es informativo, no un problema. ${detectedWallets.map(w=>`${w.name} (${w.confidence}): ${w.signals.join(", ")}`).join(" · ")}`
+ : 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. En un CoinJoin, el tipo de mezcla ya sugiere el software usado.",
penalty: likelyCJ ? 0 : weights.wallet_fingerprint,