From dca3758c6832cfef5add702a59e475d178b90667 Mon Sep 17 00:00:00 2001 From: Aitor Date: Thu, 4 Jun 2026 10:21:38 +0200 Subject: [PATCH] fix: CoinJoin estructural (WabiSabi), checks neutros en mezcla --- dashboard.html | 64 ++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 49 insertions(+), 15 deletions(-) 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,