feat: mejoras fase 1 - peeling, batch, JoinMarket, Whirlpool OP_RETURN, wallet inferido
This commit is contained in:
+59
-22
@@ -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 ────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user