feat: check de vinculación CIOH; fix: saldos falsos y fingerprint por ausencia
Tres cosas encontradas analizando transacciones reales del incidente Coldcard: - Faltaba avisar de que gastar N direcciones juntas las vincula para siempre. El analizador medía la privacidad desde el lado de quien construye la transacción, no desde el de los dueños de las monedas gastadas. - El explorador mostraba 'Balance: 0 BTC' en direcciones con mucho historial: Mempool sobre Fulcrum no devuelve los importes y la app los daba por buenos. Ahora dice 'no disponible' y explica por qué — tener 492 transacciones y cero recibido es imposible en la cadena. - El fingerprint decía 'Electrum' por AUSENCIA de rasgos (locktime 0, sin RBF), cuando Electrum moderno hace lo contrario. Ahora exige señales positivas y existe 'sin rasgos distintivos', que no penaliza.
This commit is contained in:
+110
-13
@@ -967,18 +967,47 @@
|
||||
// ─── Electrum ──────────────────────────────────────────────────
|
||||
// Guardia: Electrum usa P2WPKH por defecto. Si no hay inputs P2WPKH,
|
||||
// no tiene sentido puntuar Electrum (evita falsos positivos en P2TR/P2PKH).
|
||||
// Antes se puntuaba Electrum por AUSENCIA de rasgos: locktime=0, sin
|
||||
// RBF, sequence por defecto. Eso es exactamente al revés — Electrum
|
||||
// moderno (4.x, desde 2020) pone locktime a la altura actual para
|
||||
// protegerse del fee sniping y señaliza RBF por defecto. Lo que aquellas
|
||||
// señales describen no es un wallet concreto, sino software que no
|
||||
// configura nada: un script hecho a medida.
|
||||
//
|
||||
// El fallo salió analizando la transacción de un robo real: la app la
|
||||
// atribuía a "Electrum (PROBABLE)" cuando sus rasgos eran justo los
|
||||
// contrarios a los de Electrum. Un silencio leído como una afirmación.
|
||||
if (allP2wpkh) {
|
||||
let score = 2, sigs = ["inputs P2WPKH uniformes"]; // incluido por la guardia
|
||||
if (locktime === 0) { score+=1; sigs.push("locktime=0"); }
|
||||
if (!hasRbfOptin) { score+=1; sigs.push("sin RBF"); }
|
||||
if (allInputs.every(v=>v.sequence===0xffffffff)) { score+=2; sigs.push("sequence=0xFFFFFFFF"); }
|
||||
let score = 0, sigs = [];
|
||||
if (locktimeIsBlock) { score+=3; sigs.push("locktime=altura actual (anti-fee-sniping)"); }
|
||||
if (hasRbfOptin) { score+=2; sigs.push("RBF señalizado"); }
|
||||
const allOutSameTypeAsIn = allOutputs.length > 0 &&
|
||||
allOutputs.every(o => o.scriptpubkey_type === inTypes[0]);
|
||||
if (allOutSameTypeAsIn) { score+=1; sigs.push("outputs del mismo tipo que inputs"); }
|
||||
if (allOutputs.length === 2) { score+=1; sigs.push("2 outputs"); }
|
||||
if (score >= 2) sigs.unshift("inputs P2WPKH uniformes");
|
||||
// Hace falta al menos una señal POSITIVA fuerte, no solo ausencias.
|
||||
if (score >= 5) results.push({ name:"Electrum", score, confidence: score>=6?"PROBABLE":"POSIBLE", signals:sigs });
|
||||
}
|
||||
|
||||
// ─── Sin rasgos distintivos ────────────────────────────────────
|
||||
// Una transacción sin locktime, sin RBF y con sequence al máximo no
|
||||
// lleva ninguna de las marcas que dejan los monederos actuales. Decirlo
|
||||
// es más honesto que forzar un nombre: es lo que produce una librería
|
||||
// en crudo o un script propio, y también algún wallet antiguo.
|
||||
{
|
||||
const sinLocktime = locktime === 0;
|
||||
const sinRbf = !hasRbfOptin;
|
||||
const seqPorDefecto = allInputs.length > 0 && allInputs.every(v=>v.sequence===0xffffffff);
|
||||
if (sinLocktime && sinRbf && seqPorDefecto && results.length === 0) {
|
||||
results.push({
|
||||
name: "sin rasgos distintivos", score: 3, confidence: "POSIBLE",
|
||||
signals: ["locktime=0", "sin RBF", "sequence=0xFFFFFFFF"],
|
||||
note: "No lleva las marcas que dejan los monederos habituales. Compatible con una librería usada en crudo, un script propio o software antiguo — pero no identifica a ninguno en concreto.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BlueWallet / mobile ───────────────────────────────────────
|
||||
// Corregido: eliminado doble-conteo de "1 input P2WPKH" (antes sumaba 4 pts solo).
|
||||
// Umbral subido a 6 para no dispararse con cualquier tx simple genérica.
|
||||
@@ -1631,6 +1660,48 @@
|
||||
});
|
||||
if (hasInputReuse) deductions += weights.input_reuse;
|
||||
|
||||
// ── 1b. Vinculación de direcciones al gastarlas juntas (CIOH) ──────
|
||||
// Faltaba, y es el hueco más grande que tenía el analizador: se medía
|
||||
// la privacidad desde el lado de quien CONSTRUYE la transacción (¿se ve
|
||||
// mi cambio?, ¿pago cifras redondas?) y no desde el lado de los dueños
|
||||
// de las monedas gastadas, que es donde ocurre el daño irreversible.
|
||||
//
|
||||
// Gastar N direcciones distintas en una misma transacción las enlaza
|
||||
// para siempre a ojos de cualquiera. Es la heurística CIOH, la más
|
||||
// fiable del análisis de cadena, y aquí no se aplicaba a la propia
|
||||
// transacción que se está mirando — solo al conjunto del wallet.
|
||||
//
|
||||
// Se detectó analizando una consolidación real de 491 direcciones: la
|
||||
// app la calificaba de privacidad ALTA porque el cambio no se
|
||||
// distinguía, sin mencionar que 491 direcciones acababan de quedar
|
||||
// vinculadas en público.
|
||||
//
|
||||
// No aplica en CoinJoin: ahí las entradas son de personas distintas y
|
||||
// esa es justamente la excepción clásica a CIOH.
|
||||
const nVinculadas = uniqueInputAddrs.size;
|
||||
const hayVinculacion = nVinculadas > 1 && !likelyCJ;
|
||||
// Escalonado: consolidar dos monedas es rutina; juntar decenas es otra
|
||||
// cosa. El peso sube con el número de direcciones que quedan atadas.
|
||||
const pesoVinculacion = !hayVinculacion ? 0
|
||||
: nVinculadas >= 20 ? 30
|
||||
: nVinculadas >= 10 ? 22
|
||||
: nVinculadas >= 5 ? 15
|
||||
: 8;
|
||||
checks.push({
|
||||
id:"input_linkage", label:"Direcciones que quedan vinculadas entre sí", certainty:"CERTEZA",
|
||||
pass: !hayVinculacion,
|
||||
informational: likelyCJ && nVinculadas > 1,
|
||||
actionability: "evitable",
|
||||
detail: !hayVinculacion
|
||||
? (likelyCJ && nVinculadas > 1
|
||||
? `Se gastan ${nVinculadas} direcciones distintas, pero en un CoinJoin las entradas son de participantes diferentes: la vinculación por CIOH no aplica aquí. De hecho, romperla es el objetivo de la mezcla.`
|
||||
: "Solo se gasta una dirección, así que esta transacción no enlaza direcciones entre sí.")
|
||||
: `Hecho (certeza): esta transacción gasta ${nVinculadas} direcciones distintas a la vez. Interpretación (certeza para un observador): quien mira la cadena asume que las ${nVinculadas} pertenecen al mismo dueño, porque hizo falta la clave de todas para firmar. Consecuencia: quedan enlazadas de forma permanente y pública. Si alguna de ellas se asocia alguna vez a una identidad, arrastra a las demás — y esto no se puede deshacer.`,
|
||||
didactic: "Es la heurística CIOH (common input ownership), la más fiable que existe en el análisis de cadena y la primera que aplica cualquiera que observe. Cada vez que gastas varias monedas juntas, le dices al mundo que son de la misma persona. Consolidar cuando las comisiones están baratas es tentador y sale caro en privacidad: media docena de consolidaciones descuidadas bastan para reconstruir un wallet entero. La forma de evitarlo es gastar monedas de una en una cuando su origen no debe relacionarse — que es justo lo contrario de lo que invita a hacer una comisión baja.",
|
||||
penalty: pesoVinculacion,
|
||||
});
|
||||
if (hayVinculacion) deductions += pesoVinculacion;
|
||||
|
||||
// ── 2. Mezcla de tipos en inputs ──────────────────────────────────
|
||||
const inTypes = [...new Set(tx.vin.map(v=>v.prevout?.scriptpubkey_type).filter(Boolean))];
|
||||
const outTypes = [...new Set(tx.vout.map(v=>v.scriptpubkey_type).filter(Boolean))];
|
||||
@@ -1884,20 +1955,25 @@
|
||||
checks.push({
|
||||
id:"wallet_fingerprint", label:"Fingerprinting de wallet",
|
||||
certainty: detectedWallets[0]?.confidence||"POSIBLE",
|
||||
pass: likelyCJ ? true : detectedWallets.length===0,
|
||||
informational: likelyCJ && detectedWallets.length > 0,
|
||||
pass: likelyCJ ? true : !detectedWallets.some(w => w.name !== "sin rasgos distintivos"),
|
||||
informational: (likelyCJ && detectedWallets.length > 0) ||
|
||||
(detectedWallets.length > 0 && !detectedWallets.some(w => w.name !== "sin rasgos distintivos")),
|
||||
actionability: likelyCJ ? null : "wallet",
|
||||
detail: detectedWallets.length > 0
|
||||
? (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(" · "))
|
||||
: detectedWallets.map(w=>`${w.name} (${w.confidence}): ${w.signals.join(", ")}${w.note?` — ${w.note}`:""}`).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,
|
||||
});
|
||||
// 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;
|
||||
// Fuera de CoinJoin, solo penaliza si se identificó un software concreto:
|
||||
// "sin rasgos distintivos" es lo contrario de una huella — significa que
|
||||
// la transacción no se puede atribuir a ningún monedero, que si acaso
|
||||
// juega a favor. Penalizarlo sería cobrar por no dejar rastro.
|
||||
const huellaReal = detectedWallets.filter(w => w.name !== "sin rasgos distintivos");
|
||||
if (!likelyCJ && huellaReal.length > 0) deductions += weights.wallet_fingerprint;
|
||||
|
||||
// ── 13. PayJoin — informativo ─────────────────────────────────────
|
||||
checks.push({
|
||||
@@ -3813,6 +3889,13 @@
|
||||
const funded = stats.funded_txo_sum || 0;
|
||||
const spent = stats.spent_txo_sum || 0;
|
||||
const txCount = stats.tx_count || 0;
|
||||
// El backend de Mempool sobre Fulcrum devuelve los importes a cero en
|
||||
// direcciones con mucho historial, aunque el contador de transacciones
|
||||
// sí venga bien. Una dirección con transacciones pero sin nada recibido
|
||||
// es imposible en la cadena: si se da, los datos están incompletos.
|
||||
// Sin esta comprobación, el perfil concluye "no es hot wallet" cuando en
|
||||
// realidad no ha podido mirarlo — otro silencio leído como respuesta.
|
||||
const statsIncompletas = txCount > 0 && (stats.funded_txo_count || 0) === 0;
|
||||
const residual = funded - spent;
|
||||
const residualRatio = funded > 0 ? residual / funded : 0;
|
||||
|
||||
@@ -3822,7 +3905,9 @@
|
||||
// Señal de flujo: saldo residual casi nulo + volumen y tx_count altos ->
|
||||
// la dirección funciona como paso de caudal, no como ahorro (típico de
|
||||
// hot wallet de exchange). Lo contrario -> retención típica de uso personal.
|
||||
if (funded > 0 && residualRatio < 0.05 && txCount >= 20) {
|
||||
if (statsIncompletas) {
|
||||
signals.push(`el nodo no devolvió los importes de esta dirección (${txCount} transacciones registradas, 0 recibido — imposible en la cadena). La señal de flujo no se ha podido evaluar: no es que se haya descartado, es que no se ha podido mirar`);
|
||||
} else if (funded > 0 && residualRatio < 0.05 && txCount >= 20) {
|
||||
hotScore += 2;
|
||||
signals.push(`saldo residual ≈0 (${(residualRatio*100).toFixed(1)}% de lo recibido) con ${txCount} transacciones — patrón de flujo, no de ahorro`);
|
||||
} else if (funded > 0 && residualRatio > 0.3 && txCount < 10) {
|
||||
@@ -5797,18 +5882,30 @@
|
||||
const m=addr.mempool_stats||{funded_txo_sum:0,spent_txo_sum:0};
|
||||
const balance=c.funded_txo_sum-c.spent_txo_sum;
|
||||
const unconf=(m.funded_txo_sum||0)-(m.spent_txo_sum||0);
|
||||
// Con Fulcrum por debajo, el backend devuelve los importes a cero en
|
||||
// direcciones con mucho historial, aunque el contador de transacciones
|
||||
// venga bien. Tener transacciones y no haber recibido nada es imposible
|
||||
// en la cadena, así que cuando se da, los importes no son reales: son un
|
||||
// hueco. Mostrar "Balance 0" tal cual sería mentir con seguridad.
|
||||
const importesIncompletos = c.tx_count > 0 && (c.funded_txo_count||0) === 0;
|
||||
const noDisponible = "no disponible";
|
||||
return (
|
||||
<div style={{display:"flex",flexDirection:"column",gap:10}}>
|
||||
<Card glow={C.green}>
|
||||
<div style={{marginBottom:12}}><div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",marginBottom:4}}>DIRECCIÓN</div><div style={{fontSize:"0.72rem",fontFamily:"monospace",color:C.green,wordBreak:"break-all"}}>{addr.address}</div></div>
|
||||
<div style={{display:"grid",gridTemplateColumns:"repeat(auto-fill,minmax(130px,1fr))",gap:12,paddingTop:12,borderTop:`1px solid ${C.border}`}}>
|
||||
<Tag label="Balance" value={fmt.btc(balance)} color={C.green}/>
|
||||
<Tag label="Balance" value={importesIncompletos?noDisponible:fmt.btc(balance)} color={importesIncompletos?C.t3:C.green}/>
|
||||
<Tag label="No confirmado" value={unconf!==0?fmt.btc(unconf):"0"} color={unconf>0?C.amber:C.t2}/>
|
||||
<Tag label="Total recibido" value={fmt.btc(c.funded_txo_sum)} color={C.blue}/>
|
||||
<Tag label="Total enviado" value={fmt.btc(c.spent_txo_sum)} color={C.red}/>
|
||||
<Tag label="Total recibido" value={importesIncompletos?noDisponible:fmt.btc(c.funded_txo_sum)} color={importesIncompletos?C.t3:C.blue}/>
|
||||
<Tag label="Total enviado" value={importesIncompletos?noDisponible:fmt.btc(c.spent_txo_sum)} color={importesIncompletos?C.t3:C.red}/>
|
||||
<Tag label="Transacciones" value={fmt.num(c.tx_count)} color={C.t1}/>
|
||||
<Tag label="UTXOs" value={fmt.num(addr.utxos?addr.utxos.length:0)} color={C.amber}/>
|
||||
</div>
|
||||
{importesIncompletos&&(
|
||||
<div style={{marginTop:12,padding:"9px 12px",background:C.amberMuted,border:`1px solid ${C.amber}40`,borderRadius:6,fontSize:"0.63rem",color:C.t1,fontFamily:"monospace",lineHeight:1.6}}>
|
||||
<strong style={{color:C.amber}}>Importes no disponibles.</strong> El nodo informa de {fmt.num(c.tx_count)} transacciones pero devuelve cero recibido, y eso no puede ocurrir en la cadena. Es una limitación conocida del backend de Mempool sobre Fulcrum con direcciones de mucho historial. Los importes se ocultan en vez de mostrarse a cero, porque un cero aquí sería un dato falso. El número de transacciones y la lista de abajo sí son correctos.
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
{analysis&&<PrivacyLab analysis={analysis}/>}
|
||||
{addr.utxos&&addr.utxos.length>0&&<Card><div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:10}}>UTXOs</div>{addr.utxos.slice(0,6).map((u,i)=><div key={i} style={{display:"flex",justifyContent:"space-between",alignItems:"center",padding:"6px 0",borderBottom:i<Math.min(addr.utxos.length,6)-1?`1px solid ${C.border}`:"none"}}><span style={{fontSize:"0.68rem",fontFamily:"monospace",color:C.blue}}>{fmt.hash(u.txid)}:{u.vout}</span><div style={{display:"flex",gap:6}}><Badge color={C.green}>{fmt.btc(u.value)}</Badge><Badge color={u.status&&u.status.confirmed?C.t2:C.amber}>{u.status&&u.status.confirmed?`#${fmt.num(u.status.block_height)}`:"mempool"}</Badge></div></div>)}</Card>}
|
||||
|
||||
Reference in New Issue
Block a user