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 ────────────────────────────