feat: tope de tx_count como cinturón de seguridad en el peritaje

LARGE_ADDR_TX_COUNT=5000: si el perfil de una dirección de salida
tiene chain_stats.tx_count por encima del umbral, se trata como
custodio presunto (stopReason="exchange", byVolume:true) aunque el
heurístico de perfil no la clasifique "hot_wallet" (p.ej. residual
momentáneamente alto en el momento de la consulta). No cuesta
peticiones extra — chain_stats ya se pide para el perfil de toda
dirección de salida nueva.

Cierra el hueco identificado en la auditoría previa: sin este tope,
una dirección enorme no detectada por el heurístico se encolaría para
el siguiente salto, y findSpendingTx intentaría paginar hasta 200 de
sus transacciones (8 páginas × 8s de timeout, ~64s en el peor caso)
buscando una entre decenas de miles, sin ninguna posibilidad realista
de encontrarla.

El texto del informe distingue este caso: es HECHO (medida de
protección, con el tx_count real citado), no INFERENCIA sobre quién
controla la dirección — a diferencia de una parada por ENTITY_INDEX o
por el heurístico de hot wallet, aquí no se afirma nada sobre la
naturaleza de la dirección.

Verificado en navegador con un caso sintético diseñado para que el
heurístico de perfil NO dispare (residual del 100%, fuera del umbral
<0.05) pero con tx_count=79.828 (el caso real que se va a probar): el
tope se activa correctamente y la dirección enorme recibe EXACTAMENTE
1 petición (el perfil barato) — nunca se llega a paginar su historial.
This commit is contained in:
2026-07-16 13:57:09 +02:00
parent c86134cc24
commit b37b9a125a
+30 -5
View File
@@ -3893,6 +3893,18 @@
const maxHops = (opts && opts.maxHops) || 8; const maxHops = (opts && opts.maxHops) || 8;
const onProgress = opts && opts.onProgress; const onProgress = opts && opts.onProgress;
const BATCH = 5, PAUSE = 120, MAX_NODES = 80; const BATCH = 5, PAUSE = 120, MAX_NODES = 80;
// Cinturón de seguridad independiente del heurístico de perfil: una
// dirección con un volumen de transacciones así de alto es, con
// altísima probabilidad, infraestructura de un servicio (exchange,
// custodio, procesador de pagos) aunque el perfil no llegue a
// clasificarla "hot_wallet" (residual momentáneamente alto, etc.). Sin
// este tope, seguir esa dirección dispararía en findSpendingTx una
// paginación de hasta 200 tx (8 páginas × 8s de timeout) buscando una
// aguja en un pajar de decenas de miles — coste real para el nodo del
// usuario sin ninguna posibilidad realista de encontrar el gasto. No
// cuesta peticiones extra: chain_stats.tx_count ya viene en el perfil
// que se pide de todos modos para cada dirección de salida nueva.
const LARGE_ADDR_TX_COUNT = 5000;
const { get, originTxid, originFingerprint, nodes, edges, unspentTerminals, txCache, enqueuedKeys } = trace; const { get, originTxid, originFingerprint, nodes, edges, unspentTerminals, txCache, enqueuedKeys } = trace;
if (trace.frontier.length === 0) { trace.done = true; return trace; } if (trace.frontier.length === 0) { trace.done = true; return trace; }
@@ -3978,17 +3990,26 @@
const tracedShare = totalInHop > 0 ? amount / totalInHop : 1; const tracedShare = totalInHop > 0 ? amount / totalInHop : 1;
const diluted = inAddrs.length >= 3 && tracedShare < 0.5; const diluted = inAddrs.length >= 3 && tracedShare < 0.5;
// Parada por custodio: entidad conocida (ENTITY_INDEX) o perfil de // Parada por custodio: entidad conocida (ENTITY_INDEX), perfil de
// hot wallet no indexado ("posible custodio no identificado"). // hot wallet no indexado ("posible custodio no identificado"), o
// — cinturón de seguridad — volumen de tx tan alto que seguir esa
// dirección sería, con seguridad práctica, hammer al nodo sin
// esperanza real de encontrar el gasto (ver LARGE_ADDR_TX_COUNT).
let custodyStop = null; let custodyStop = null;
for (const a of outAddrs) { for (const a of outAddrs) {
const hit = ENTITY_INDEX.get(a); const hit = ENTITY_INDEX.get(a);
if (hit?.cat === "exchange") { custodyStop = { addr:a, name:hit.name, known:true }; break; } if (hit?.cat === "exchange") { custodyStop = { addr:a, name:hit.name, known:true, byVolume:false }; break; }
} }
if (!custodyStop) { if (!custodyStop) {
for (const a of outAddrs) { for (const a of outAddrs) {
const prof = addressProfiles[a]; const prof = addressProfiles[a];
if (prof && prof.kind === "hot_wallet") { custodyStop = { addr:a, name:null, known:false }; break; } if (prof && prof.kind === "hot_wallet") { custodyStop = { addr:a, name:null, known:false, byVolume:false }; break; }
}
}
if (!custodyStop) {
for (const a of outAddrs) {
const prof = addressProfiles[a];
if (prof && prof.txCount >= LARGE_ADDR_TX_COUNT) { custodyStop = { addr:a, name:null, known:false, byVolume:true, txCount:prof.txCount }; break; }
} }
} }
@@ -4169,7 +4190,11 @@
} }
} }
for (const n of allNodes) { for (const n of allNodes) {
if (n.stopReason === "exchange" && n.custodyStop) { if (n.stopReason === "exchange" && n.custodyStop && n.custodyStop.byVolume) {
conclusions.push({ level:"HECHO",
text:`El rastro se detiene por precaución en la transacción ${n.txid}: la dirección de destino tiene ${fmt.num(n.custodyStop.txCount)} transacciones registradas, un volumen que hace inviable buscar el gasto concreto sin sobrecargar el nodo. No es una conclusión sobre quién controla esa dirección — solo una medida de protección. Puedes intentar continuar manualmente si lo necesitas.`,
refs:[n.txid, n.custodyStop.addr] });
} else if (n.stopReason === "exchange" && n.custodyStop) {
const label = n.custodyStop.known ? `${n.custodyStop.name} (fuente: índice de entidades)` : "hot wallet no identificada — posible custodio"; const label = n.custodyStop.known ? `${n.custodyStop.name} (fuente: índice de entidades)` : "hot wallet no identificada — posible custodio";
conclusions.push({ level:"INFERENCIA", certainty: n.custodyStop.known ? "PROBABLE" : "POSIBLE", conclusions.push({ level:"INFERENCIA", certainty: n.custodyStop.known ? "PROBABLE" : "POSIBLE",
text:`El rastro se detiene en ${label} en la transacción ${n.txid}.`, refs:[n.txid, n.custodyStop.addr] }); text:`El rastro se detiene en ${label} en la transacción ${n.txid}.`, refs:[n.txid, n.custodyStop.addr] });