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:
@@ -10,6 +10,41 @@ y el versionado sigue [Versionado Semántico](https://semver.org/lang/es/):
|
||||
- **MENOR** — características nuevas que no rompen lo anterior
|
||||
- **PARCHE** — arreglos de errores
|
||||
|
||||
---
|
||||
## [1.18.0] — 2026-08-08
|
||||
### Añadido
|
||||
- **La comisión como huella.** El sat/vB que eliges dice algo del software y de
|
||||
quien lo maneja, y el dato estaba en cada transacción sin que nadie lo
|
||||
mirase. Se detectan dos firmas: una comisión **absoluta** redonda (10.000
|
||||
sats clavados — un estimador nunca da eso, calcula tarifa por tamaño y
|
||||
devuelve números feos) y una **tarifa** prácticamente entera (20,00 sat/vB,
|
||||
que sale de teclear un número redondo en la casilla).
|
||||
- Informativo y sin penalización, por el mismo motivo que se decidió con RBF:
|
||||
la señal es débil, y bajar la nota por ella empujaría a elegir comisiones
|
||||
peores solo para disimular. Mal negocio.
|
||||
- **El cambio ya se identifica en transacciones de más de dos salidas, cuando
|
||||
el caso es inequívoco.** Antes la detección se plantaba en seco por encima de
|
||||
dos salidas. Estaba bien argumentado, pero se tiraba información sólida: si
|
||||
de N salidas **exactamente una** comparte tipo de script con las entradas, no
|
||||
hay nada que adivinar — y con más salidas la coincidencia por azar es aún más
|
||||
improbable que con dos. También cuenta el caso de una salida que vuelve a una
|
||||
dirección ya gastada. 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.
|
||||
- **Aviso de autoenvíos que ataron direcciones**, en el informe de wallet. Es
|
||||
una comprobación que solo cabe ahí: para saber que *todas* las salidas son
|
||||
tuyas hace falta conocer tu wallet entero, cosa que el analizador de una
|
||||
transacción suelta no puede saber.
|
||||
- Detecta las transacciones donde entradas y salidas son todas tuyas, y avisa
|
||||
solo 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.
|
||||
- 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. Se paga una comisión por empeorar la propia
|
||||
privacidad.
|
||||
|
||||
Con esto quedan cerradas las cinco heurísticas que el repaso del 2026-08-08
|
||||
señaló como ausentes.
|
||||
|
||||
---
|
||||
## [1.17.0] — 2026-08-08
|
||||
### Cambiado
|
||||
|
||||
+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}}>
|
||||
|
||||
@@ -87,6 +87,35 @@ T("CoinJoin: la guardia desactiva la heurística aunque haya señales",
|
||||
vout:[OUT(100000,"v1_p2tr","bc1pX"), OUT(99895000,"v0_p2wpkh","bc1qY")] },
|
||||
null, true);
|
||||
|
||||
// ── Más de dos salidas: solo el caso inequívoco ─────────────────────────
|
||||
// Antes la detección se plantaba en seco con más de 2 salidas. Ahora se
|
||||
// pronuncia solo cuando no hay nada que adivinar.
|
||||
console.log("\n=== Cambio con más de dos salidas ===\n");
|
||||
|
||||
T("3 salidas, solo una comparte tipo con las entradas: esa es el cambio",
|
||||
{ vin:[IN(10000000,"v0_p2wpkh","bc1qA")],
|
||||
vout:[OUT(3000000,"v1_p2tr","bc1pX"), OUT(2000000,"p2pkh","1Y"),
|
||||
OUT(4990000,"v0_p2wpkh","bc1qCAMBIO")] },
|
||||
"bc1qCAMBIO");
|
||||
|
||||
T("3 salidas, dos comparten tipo con las entradas: ambiguo, no se afirma",
|
||||
{ vin:[IN(10000000,"v0_p2wpkh","bc1qA")],
|
||||
vout:[OUT(3000000,"v0_p2wpkh","bc1qX"), OUT(2000000,"p2pkh","1Y"),
|
||||
OUT(4990000,"v0_p2wpkh","bc1qZ")] },
|
||||
null);
|
||||
|
||||
T("4 salidas, una vuelve a una dirección de las entradas: señal más fuerte",
|
||||
{ vin:[IN(10000000,"v0_p2wpkh","bc1qA")],
|
||||
vout:[OUT(3000000,"v0_p2wpkh","bc1qX"), OUT(2000000,"v0_p2wpkh","bc1qY"),
|
||||
OUT(1000000,"v0_p2wpkh","bc1qZ"), OUT(3990000,"v0_p2wpkh","bc1qA")] },
|
||||
"bc1qA");
|
||||
|
||||
T("CoinJoin con muchas salidas: la guardia sigue mandando",
|
||||
{ vin:[IN(10000000,"v0_p2wpkh","bc1qA")],
|
||||
vout:[OUT(3000000,"v1_p2tr","bc1pX"), OUT(2000000,"p2pkh","1Y"),
|
||||
OUT(4990000,"v0_p2wpkh","bc1qCAMBIO")] },
|
||||
null, true);
|
||||
|
||||
// ── Redondez ────────────────────────────────────────────────────────────
|
||||
// La versión anterior era `v%1000000===0 || v%100000===0 || v%10000000===0`,
|
||||
// donde la primera y la tercera condición sobran (todo múltiplo de un millón
|
||||
|
||||
@@ -160,6 +160,44 @@ ok("se etiqueta como consolidación, con certeza y no como probable",
|
||||
ok("no se cobra dos veces: la penalización la lleva input_linkage",
|
||||
cu?.penalty === 0 && check(consol, "input_linkage")?.pass === false);
|
||||
|
||||
console.log("\n=== La comisión como huella (check nuevo) ===\n");
|
||||
|
||||
// Comisión absoluta redonda: nadie llega a 10.000 sats clavados con un
|
||||
// estimador, que calcula tarifa por tamaño y devuelve números feos.
|
||||
const feeRedonda = analyzeTx(TX(
|
||||
[IN(5000000, "v1_p2tr", "bc1pA")],
|
||||
[OUT(4990000, "v1_p2tr", "bc1pB")],
|
||||
{ fee: 10000, weight: 600 }
|
||||
));
|
||||
ok("detecta una comisión absoluta redonda",
|
||||
check(feeRedonda, "fee_fingerprint")?.pass === true &&
|
||||
/10000 sats exactos/.test(check(feeRedonda, "fee_fingerprint")?.detail || ""),
|
||||
check(feeRedonda, "fee_fingerprint")?.detail?.slice(0, 90));
|
||||
|
||||
// Tarifa entera: 20,00 sat/vB sale de teclear "20" en la casilla.
|
||||
const feeEntera = analyzeTx(TX(
|
||||
[IN(5000000, "v1_p2tr", "bc1pA")],
|
||||
[OUT(4996000, "v1_p2tr", "bc1pB")],
|
||||
{ fee: 3000, weight: 600 } // vsize 150 -> 20,00 sat/vB
|
||||
));
|
||||
ok("detecta una tarifa prácticamente entera",
|
||||
/prácticamente un número entero|sats exactos/.test(check(feeEntera, "fee_fingerprint")?.detail || ""),
|
||||
check(feeEntera, "fee_fingerprint")?.detail?.slice(0, 90));
|
||||
|
||||
// Un estimador automático deja números feos.
|
||||
const feeEstimada = analyzeTx(TX(
|
||||
[IN(5000000, "v1_p2tr", "bc1pA")],
|
||||
[OUT(4996873, "v1_p2tr", "bc1pB")],
|
||||
{ fee: 3127, weight: 601 }
|
||||
));
|
||||
ok("no ve huella donde hay un número feo de estimador",
|
||||
/sin forma de cifra elegida a mano/.test(check(feeEstimada, "fee_fingerprint")?.detail || ""),
|
||||
check(feeEstimada, "fee_fingerprint")?.detail?.slice(0, 90));
|
||||
|
||||
ok("la huella de comisión no penaliza (misma decisión que con RBF)",
|
||||
check(feeRedonda, "fee_fingerprint")?.penalty === 0 &&
|
||||
check(feeRedonda, "fee_fingerprint")?.informational === true);
|
||||
|
||||
console.log("\n=== Guardia de CoinJoin ===\n");
|
||||
|
||||
// Whirlpool: 5 entradas, 5 salidas de la misma denominación.
|
||||
|
||||
Reference in New Issue
Block a user