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:
Aitor
2026-08-08 19:03:08 +02:00
parent 3d490b32d4
commit afebeeaccd
4 changed files with 237 additions and 5 deletions
+135 -5
View File
@@ -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}}>