feat: cierra las cinco heurísticas ausentes del repaso
La comisión como huella: una comisión absoluta redonda o una tarifa entera no salen de un estimador, salen de alguien que las escribió a mano. Informativo y sin penalizar, igual que RBF — castigarlo empujaría a elegir comisiones peores para disimular. Cambio con más de dos salidas, solo el caso inequívoco: si exactamente una salida comparte tipo con las entradas no hay nada que adivinar, y con más salidas la coincidencia por azar es todavía más improbable. POSIBLE, no PROBABLE, porque es una sola señal. Autoenvíos en el informe de wallet: solo cabe ahí, porque saber que todas las salidas son tuyas exige conocer el wallet entero. Avisa solo cuando ataron más de una dirección — mover una a otra no vincula nada nuevo.
This commit is contained in:
+135
-5
@@ -1503,8 +1503,41 @@
|
||||
polvoRecibido.sort((a,b) => b.time - a.time);
|
||||
const polvoSinGastar = polvoRecibido.filter(p => !p.gastado);
|
||||
|
||||
// ── Autoenvíos (mover dinero de una dirección tuya a otra tuya) ────
|
||||
//
|
||||
// Esta comprobación solo cabe aquí. El analizador de una transacción
|
||||
// suelta no puede hacerla: para saber que TODAS las salidas son tuyas
|
||||
// hace falta conocer tu wallet entero, que es justo lo que este informe
|
||||
// tiene y aquel no.
|
||||
//
|
||||
// Es un gesto que la gente hace creyendo que limpia el rastro —"paso
|
||||
// esto a otra dirección mía"— y hace exactamente lo contrario: gasta
|
||||
// varias direcciones tuyas a la vez para juntarlas en otra tuya, y deja
|
||||
// las tres atadas en público sin que haya habido ningún pago de por
|
||||
// medio. Se paga una comisión por empeorar la propia privacidad.
|
||||
const autoenvios = [];
|
||||
for (const tx of txs) {
|
||||
const ins = (tx.vin || []).map(v => v.prevout?.scriptpubkey_address).filter(Boolean);
|
||||
const outs = (tx.vout || []).map(v => v.scriptpubkey_address).filter(Boolean);
|
||||
if (ins.length === 0 || outs.length === 0) continue;
|
||||
// Todas las entradas Y todas las salidas dentro del propio wallet.
|
||||
if (!ins.every(a => myAddrSet.has(a)) || !outs.every(a => myAddrSet.has(a))) continue;
|
||||
const dirsEntrada = new Set(ins);
|
||||
autoenvios.push({
|
||||
txid: tx.txid,
|
||||
time: tx.status?.block_time || 0,
|
||||
dirsEntrada: dirsEntrada.size,
|
||||
dirsSalida: new Set(outs).size,
|
||||
// Lo que de verdad importa: cuántas direcciones tuyas quedaron
|
||||
// enlazadas por haber firmado juntas.
|
||||
vinculadas: dirsEntrada.size,
|
||||
});
|
||||
}
|
||||
autoenvios.sort((a,b) => b.time - a.time);
|
||||
const autoenviosDaninos = autoenvios.filter(a => a.vinculadas > 1);
|
||||
|
||||
return { totalTxs, activeCount: activeAddrs.length, reusedAddrs, clusters, linkReasons, history,
|
||||
polvoRecibido, polvoSinGastar,
|
||||
polvoRecibido, polvoSinGastar, autoenvios, autoenviosDaninos,
|
||||
health: { band: healthBand, color: healthColor, msg: healthMsg } };
|
||||
}
|
||||
|
||||
@@ -1566,12 +1599,48 @@
|
||||
// demasiado común para significar nada.
|
||||
const ROUND_MIN = 2;
|
||||
|
||||
const SIN_CAMBIO = { identifiable: false, index: null, signals: 0, details: [], bonus: 0, certainty: null };
|
||||
|
||||
function guessChangeOutput(tx, likelyCJ) {
|
||||
if (tx.vout.length !== 2 || likelyCJ) {
|
||||
return { identifiable: false, index: null, signals: 0, details: [], bonus: 0, certainty: null };
|
||||
}
|
||||
if (likelyCJ || tx.vout.length < 2) return SIN_CAMBIO;
|
||||
|
||||
const inTypes = tx.vin.map(v=>v.prevout?.scriptpubkey_type).filter(Boolean);
|
||||
const dominantInType = inTypes[0];
|
||||
|
||||
// ── Más de dos salidas: solo el caso inequívoco ────────────────────
|
||||
//
|
||||
// Con tres o más salidas la heurística general no vale, y la aplicación
|
||||
// se plantaba en seco. Bien argumentado, pero se estaba tirando
|
||||
// información sólida por prudencia: hay un subcaso donde no hay nada
|
||||
// que adivinar.
|
||||
//
|
||||
// Si de N salidas EXACTAMENTE UNA comparte tipo de script con las
|
||||
// entradas, esa es el cambio. El razonamiento es el mismo que en el caso
|
||||
// de dos salidas: tu monedero devuelve el cambio a una dirección del
|
||||
// mismo formato que las que gasta, mientras que los destinatarios usan
|
||||
// el formato que les da la gana. Con más salidas la señal es incluso más
|
||||
// limpia, porque la coincidencia por azar es más improbable.
|
||||
//
|
||||
// Se marca POSIBLE y no PROBABLE: es una sola señal, y hay monederos que
|
||||
// devuelven el cambio a un tipo distinto del que gastan.
|
||||
if (tx.vout.length > 2) {
|
||||
const reusaIdx = tx.vout.findIndex(v =>
|
||||
v.scriptpubkey_address && tx.vin.some(i => i.prevout?.scriptpubkey_address === v.scriptpubkey_address));
|
||||
if (reusaIdx !== -1) {
|
||||
return { identifiable: true, index: reusaIdx, signals: 2, bonus: 10, certainty: "PROBABLE",
|
||||
details: [`de ${tx.vout.length} salidas, una vuelve a una dirección de las entradas — cambio casi seguro`] };
|
||||
}
|
||||
if (!dominantInType) return SIN_CAMBIO;
|
||||
const coinciden = tx.vout.filter(v => v.scriptpubkey_type === dominantInType);
|
||||
if (coinciden.length !== 1) return SIN_CAMBIO;
|
||||
return {
|
||||
identifiable: true,
|
||||
index: tx.vout.findIndex(v => v.scriptpubkey_type === dominantInType),
|
||||
signals: 1, bonus: 0, certainty: "POSIBLE",
|
||||
details: [`de ${tx.vout.length} salidas, solo una comparte tipo de script (${dominantInType}) con las entradas`],
|
||||
};
|
||||
}
|
||||
|
||||
const isBip69Inputs = tx.vin.length < 2 || tx.vin.every((v,i,a) => {
|
||||
if (i===0) return true;
|
||||
const pidA = a[i-1].txid||"", pidB = v.txid||"";
|
||||
@@ -2140,7 +2209,7 @@
|
||||
? `Hecho (certeza): ${changeSignals} señales apuntan al output #${changeGuess.index} — ${changeDetails.join(" · ")}. Interpretación (probable): ese es el cambio, la moneda que vuelve a tu propio wallet. Consecuencia: quien lo identifique puede seguir tus gastos posteriores desde ahí.${correlatedProblem?" (penalización reducida por correlación con otros checks)":""}`
|
||||
: `Hecho (certeza): hay ${changeSignals} señales de que el cambio es identificable — ${changeDetails.join(" · ")}. Interpretación: las señales no coinciden en cuál de las dos salidas es el cambio, así que no se señala ninguna. Consecuencia: la transacción filtra que hay un cambio, aunque esta herramienta no se atreva a decir cuál — otro analista con más contexto sí podría.${correlatedProblem?" (penalización reducida por correlación con otros checks)":""}`)
|
||||
: tx.vout.length > 2
|
||||
? `Estructura de ${tx.vout.length} outputs — la identificación del cambio es menos fiable y no se afirma aquí. La herramienta no adivina el cambio en transacciones con más de 2 salidas para no dar una certeza que los datos no sostienen.`
|
||||
? `Estructura de ${tx.vout.length} salidas. Con más de dos, esta herramienta solo se pronuncia si el caso es inequívoco —que una sola salida comparta tipo de script con las entradas, o que una vuelva a una dirección ya gastada— y aquí no lo es. Adivinar entre varias salidas daría una certeza que los datos no sostienen.`
|
||||
: changeSignals === 1
|
||||
? `Hay una señal, insuficiente para afirmar nada: ${changeDetails.join(" · ")}. Hace falta una segunda señal independiente para señalar un output, así que aquí no se señala ninguno. Conviene saber que la señal existe: un analista menos escrupuloso la daría por buena ella sola.`
|
||||
: "No hay señales que permitan identificar el output de cambio.",
|
||||
@@ -2275,6 +2344,40 @@
|
||||
penalty: 0, informational: true,
|
||||
});
|
||||
|
||||
// ── 16-bis. La comisión como huella ───────────────────────────────
|
||||
// El sat/vB que eliges dice algo del software y de quien lo maneja, y
|
||||
// el dato estaba en la transacción sin que nadie lo mirase.
|
||||
//
|
||||
// Dos firmas distintas:
|
||||
// · Comisión ABSOLUTA redonda (10.000 sats clavados). Un estimador
|
||||
// nunca da eso: calcula tarifa × tamaño y sale un número feo. Una
|
||||
// cifra redonda significa que alguien la escribió a mano.
|
||||
// · TARIFA casi exacta (20,00 sat/vB). Sale de teclear un número
|
||||
// redondo en la casilla de tarifa. No es exacta del todo porque el
|
||||
// tamaño real difiere del estimado al firmar, de ahí la tolerancia.
|
||||
//
|
||||
// Informativo y sin penalización, por el mismo motivo que RBF: la señal
|
||||
// es débil y castigarla empujaría a elegir comisiones peores para
|
||||
// esconderla. Mal negocio, y esta herramienta no está para eso.
|
||||
const vsizeReal = tx.weight ? Math.ceil(tx.weight / 4) : null;
|
||||
const tarifaReal = (tx.fee && vsizeReal) ? tx.fee / vsizeReal : null;
|
||||
const comisionRedonda = tx.fee ? roundness(tx.fee) >= 2 : false;
|
||||
const tarifaEntera = tarifaReal !== null
|
||||
&& Math.abs(tarifaReal - Math.round(tarifaReal)) < 0.03
|
||||
&& Math.round(tarifaReal) >= 2;
|
||||
const hayHuellaComision = comisionRedonda || tarifaEntera;
|
||||
checks.push({
|
||||
id:"fee_fingerprint", label:"La comisión como huella", certainty:"POSIBLE", pass:true,
|
||||
actionability: "evitable",
|
||||
detail: hayHuellaComision
|
||||
? `Hecho (certeza): ${comisionRedonda ? `la comisión es de ${tx.fee} sats exactos` : `la tarifa sale a ${tarifaReal.toFixed(2)} sat/vB, prácticamente un número entero`}. Interpretación (posible): las cifras redondas no salen de un estimador automático —que calcula tarifa por tamaño y devuelve números feos— sino de alguien que las escribió a mano. Consecuencia: te mete en el grupo, más pequeño, de quien ajusta la comisión manualmente. Por sí sola no identifica a nadie; suma a un perfil junto con el resto de rasgos.`
|
||||
: tarifaReal !== null
|
||||
? `Tarifa de ${tarifaReal.toFixed(2)} sat/vB, sin forma de cifra elegida a mano. Compatible con un estimador automático, que es lo que hace la mayoría.`
|
||||
: "No hay datos de comisión y tamaño para evaluar este rasgo.",
|
||||
didactic: "El fingerprinting no busca una prueba, busca acumular rasgos hasta que el grupo de gente que los comparte es lo bastante pequeño. La comisión es uno de ellos y casi nadie lo piensa: teclear «20 sat/vB» deja una marca distinta a dejar que el monedero calcule. Aquí no penaliza a propósito — la señal es débil y bajar la nota por ella te empujaría a elegir comisiones peores solo para disimular, que es un mal negocio. Lo mismo que se decidió con RBF.",
|
||||
penalty: 0, informational: true,
|
||||
});
|
||||
|
||||
// ── 16. Tipo legacy (P2PKH / P2SH) ───────────────────────────────
|
||||
// Inputs en formato legacy (P2PKH = 1..., P2SH = 3...) revelan UTXOs antiguos
|
||||
// o wallets que no han migrado a SegWit/Taproot. Mayor huella on-chain y
|
||||
@@ -5669,6 +5772,33 @@
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Autoenvíos que ataron direcciones. Solo se muestra cuando
|
||||
hubo daño real (más de una dirección de entrada): mover una
|
||||
dirección a otra no vincula nada nuevo y no merece alarma. */}
|
||||
{walletReport.autoenviosDaninos && walletReport.autoenviosDaninos.length > 0 && (
|
||||
<div style={{padding:"10px 14px",background:C.bgCard,border:`1px solid ${C.amber}40`,borderRadius:8,marginBottom:14}}>
|
||||
<div style={{fontSize:"0.64rem",color:C.amber,fontFamily:"monospace",fontWeight:700,marginBottom:5}}>
|
||||
{walletReport.autoenviosDaninos.length} autoenvío(s) que ataron direcciones tuyas
|
||||
</div>
|
||||
<div style={{fontSize:"0.62rem",color:C.t2,lineHeight:1.6,marginBottom:6}}>
|
||||
Moviste dinero de varias direcciones tuyas a otra dirección tuya. Es un gesto que suele hacerse creyendo que despista, y hace lo contrario: no hubo ningún pago, pero al firmar varias direcciones a la vez quedaron enlazadas en público para siempre. Pagaste una comisión por empeorar tu propia privacidad.
|
||||
</div>
|
||||
<div style={{display:"flex",flexDirection:"column",gap:3}}>
|
||||
{walletReport.autoenviosDaninos.slice(0,6).map(a=>(
|
||||
<div key={a.txid} style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace"}}>
|
||||
<span style={{color:C.amber}}>{a.vinculadas} direcciones atadas</span>
|
||||
{" · "}{a.time ? new Date(a.time*1000).toLocaleDateString("es-ES") : "sin confirmar"}
|
||||
{" · "}<VerEnMempool base={base} tipo="tx" id={a.txid} texto={a.txid.slice(0,12)+"… ↗"}/>
|
||||
</div>
|
||||
))}
|
||||
{walletReport.autoenviosDaninos.length>6&&<div style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace"}}>…y {walletReport.autoenviosDaninos.length-6} más</div>}
|
||||
</div>
|
||||
<div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",marginTop:6,lineHeight:1.5}}>
|
||||
Ya está hecho y no se deshace. Para adelante: si de verdad necesitas mover monedas entre direcciones tuyas, muévelas de una en una, y solo cuando su origen ya estuviera relacionado.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bloque 1: Salud general */}
|
||||
<div style={{display:"flex",alignItems:"center",gap:14,padding:"12px 14px",background:C.bgCard,borderRadius:8,marginBottom:14,border:`1px solid ${walletReport.health.color}30`}}>
|
||||
<div style={{width:48,height:48,borderRadius:"50%",border:`3px solid ${walletReport.health.color}`,display:"flex",alignItems:"center",justifyContent:"center",flexShrink:0}}>
|
||||
|
||||
Reference in New Issue
Block a user