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:
2026-08-08 19:03:08 +02:00
parent b957fb626d
commit 7d05dae8cf
4 changed files with 237 additions and 5 deletions
+29
View File
@@ -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
+38
View File
@@ -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.