feat: mejoras fase 1 - peeling, batch, JoinMarket, Whirlpool OP_RETURN, wallet inferido

This commit is contained in:
Aitor
2026-05-30 19:40:49 +02:00
parent c2b7c37c1e
commit 8f5bb0d2ba
+59 -22
View File
@@ -278,6 +278,12 @@
const totalOut = tx.vout.reduce((s,v)=>s+v.value,0); const totalOut = tx.vout.reduce((s,v)=>s+v.value,0);
const fee = tx.fee || (totalIn > totalOut ? totalIn - totalOut : 0); const fee = tx.fee || (totalIn > totalOut ? totalIn - totalOut : 0);
const outputValues = tx.vout.map(v=>v.value); 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 ──────── // ── BIP69 calculado una vez — compartido con detectWallets ────────
const isBip69Inputs = tx.vin.length < 2 || tx.vin.every((v,i,a) => { const isBip69Inputs = tx.vin.length < 2 || tx.vin.every((v,i,a) => {
@@ -303,21 +309,28 @@
}; };
// ── CoinJoin — detección multi-variante ─────────────────────────── // ── CoinJoin — detección multi-variante ───────────────────────────
const valueCounts = outputValues.reduce((m,v)=>{m.set(v,(m.get(v)||0)+1);return m;}, new Map()); // Trabajamos sobre outputs gastables (sin OP_RETURN).
const maxEqual = Math.max(...valueCounts.values()); 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]; const whirlpoolDenoms = [100000, 1000000, 5000000, 50000000];
// Whirlpool: las salidas mezcladas son todas de la misma denominación fija. // 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%) // 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. // HACIA la denominación — no un cajón de salidas pequeñas arbitrarias.
const WP_TOL = 0.02; const WP_TOL = 0.02;
const whirlpoolDenom = whirlpoolDenoms.find(d => { const whirlpoolDenom = whirlpoolDenoms.find(d => {
const atDenom = outputValues.filter(v => Math.abs(v - d) <= d * WP_TOL).length; const atDenom = spendableValues.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 // Al menos 5 salidas en la denominación Y mayoría clara de los gastables
return atDenom >= 5 && atDenom >= Math.ceil(tx.vout.length * 0.6); return atDenom >= 5 && atDenom >= Math.ceil(spendableOuts.length * 0.6);
}); });
const isWhirlpool = tx.vin.length >= 5 && tx.vout.length >= 5 && !!whirlpoolDenom; const isWhirlpool = tx.vin.length >= 5 && spendableOuts.length >= 5 && !!whirlpoolDenom;
// CoinJoin genérico: ≥50% outputs de igual valor (sin denominación Whirlpool reconocida) // CoinJoin genérico: ≥50% outputs gastables 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); // 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 likelyCJ = isWhirlpool || isGenericCJ;
const cjVariant = isWhirlpool ? "Whirlpool" : "CoinJoin genérico"; const cjVariant = isWhirlpool ? "Whirlpool" : "CoinJoin genérico";
@@ -451,18 +464,23 @@
}); });
if (hasUnnecessaryInput) deductions += weights.unnecessary_input; 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; const isPeeling = tx.vin.length === 1 && tx.vout.length === 2 && !likelyCJ;
checks.push({ 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", actionability: "evitable",
detail: isPeeling 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.", : "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.", 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: weights.peeling, penalty: 0, informational: true,
}); });
if (isPeeling) deductions += weights.peeling; // Sin penalización: una tx aislada 1→2 no es trazabilidad encadenada.
// ── 9. Dust outputs ─────────────────────────────────────────────── // ── 9. Dust outputs ───────────────────────────────────────────────
// El umbral de dust no es fijo: depende del tipo de salida porque cuesta // El umbral de dust no es fijo: depende del tipo de salida porque cuesta
@@ -571,16 +589,31 @@
// ── 12. Wallet fingerprinting ───────────────────────────────────── // ── 12. Wallet fingerprinting ─────────────────────────────────────
const detectedWallets = detectWallets(tx, isBip69); 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({ checks.push({
id:"wallet_fingerprint", label:"Fingerprinting de wallet", certainty: detectedWallets[0]?.confidence||"POSIBLE", pass:detectedWallets.length===0, id:"wallet_fingerprint", label:"Fingerprinting de wallet", certainty: detectedWallets[0]?.confidence||"POSIBLE", pass:detectedWallets.length===0,
actionability: "wallet", actionability: "wallet",
detail: detectedWallets.length > 0 detail: detectedWallets.length > 0
? 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.", : "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.", 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: weights.wallet_fingerprint, 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 ───────────────────────────────────── // ── 13. PayJoin — informativo ─────────────────────────────────────
checks.push({ checks.push({
@@ -594,16 +627,20 @@
}); });
// ── 14. Batch payment ───────────────────────────────────────────── // ── 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 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({ 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", actionability: "no_corregible",
detail: isBatch 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.", : "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.", 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: weights.batch_payment, informational: isBatch, penalty: 0, informational: true,
}); });
// ── 15. Timing analysis — informativo ──────────────────────────── // ── 15. Timing analysis — informativo ────────────────────────────