diff --git a/dashboard.html b/dashboard.html index 9446ecf..d3690e0 100644 --- a/dashboard.html +++ b/dashboard.html @@ -3995,23 +3995,19 @@ // — 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; + // Se evalúa POR DIRECCIÓN: un pago normal junto a un depósito de + // exchange en la misma tx no se descarta solo porque su vecino + // sea un custodio — cada output es su propia rama. + const custodyByAddr = {}; for (const a of outAddrs) { const hit = ENTITY_INDEX.get(a); - if (hit?.cat === "exchange") { custodyStop = { addr:a, name:hit.name, known:true, byVolume:false }; break; } - } - if (!custodyStop) { - for (const a of outAddrs) { - const prof = addressProfiles[a]; - 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; } - } + if (hit?.cat === "exchange") { custodyByAddr[a] = { addr:a, name:hit.name, known:true, byVolume:false }; continue; } + const prof = addressProfiles[a]; + if (prof && prof.kind === "hot_wallet") { custodyByAddr[a] = { addr:a, name:null, known:false, byVolume:false }; continue; } + if (prof && prof.txCount >= LARGE_ADDR_TX_COUNT) { custodyByAddr[a] = { addr:a, name:null, known:false, byVolume:true, txCount:prof.txCount }; } } + const custodyAddrs = outAddrs.filter(a => custodyByAddr[a]); + const custodyStop = custodyAddrs.length ? custodyByAddr[custodyAddrs[0]] : null; let stopReason = null; if (likelyCJ) stopReason = "mixer"; @@ -4034,13 +4030,20 @@ addressProfiles, changeGuess: { structural, behavioral, combined }, changeAddress: structural.index != null ? (spendTx.vout[structural.index]?.scriptpubkey_address ?? null) : null, - diluted, tracedShare, custodyStop, + diluted, tracedShare, custodyStop, custodyAddrs, stopReason, }); - if (!stopReason) { + // Mixer/dilución/tope de nodos afectan a TODOS los outputs por + // igual (se originan en el lado de entrada de la tx, o son un + // límite global) — ahí sí se corta la transacción entera. Un + // custodio, en cambio, es propiedad de UNA dirección concreta: + // solo esa rama se corta, las demás siguen su curso normal. + const blockAllOutputs = stopReason === "mixer" || stopReason === "dilution" || stopReason === "maxHops"; + if (!blockAllOutputs) { spendTx.vout.forEach((v, i) => { if (!v.value || v.value <= 0) return; + if (v.scriptpubkey_address && custodyByAddr[v.scriptpubkey_address]) return; const key = `${spendTx.txid}:${i}`; if (enqueuedKeys.has(key)) return; enqueuedKeys.add(key); @@ -4066,11 +4069,13 @@ // CIOH: semilla = origen + toda dirección de entrada de cualquier // salto (por construcción, llegó ahí siguiendo los fondos) + direcciones // de salida que NO son la puerta de un custodio identificado (esas - // pertenecen al servicio, no al mismo actor rastreado). + // pertenecen al servicio, no al mismo actor rastreado). Exclusión por + // dirección, no por nodo entero — un pago normal junto a un depósito + // de exchange en la misma tx sí cuenta como del actor. const actorAddrSet = new Set([originOut.scriptpubkey_address].filter(Boolean)); for (const n of nodes.values()) { for (const a of n.addresses.in) actorAddrSet.add(a); - if (!n.custodyStop) for (const a of n.addresses.out) actorAddrSet.add(a); + for (const a of n.addresses.out) { if (!n.custodyAddrs.includes(a)) actorAddrSet.add(a); } } const { clusters, linkReasons } = unionFindCluster([...txCache.values()], actorAddrSet); @@ -4192,12 +4197,12 @@ for (const n of allNodes) { 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.`, + text:`La rama hacia ${n.custodyStop.addr} (tx ${n.txid}) se detiene por precaución: esa dirección 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 la controla — solo una medida de protección. Otras ramas de la misma transacción, si las hay, siguen su curso normal.`, 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"; 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:`La rama hacia ${n.custodyStop.addr} (tx ${n.txid}) se detiene en ${label}. Otras ramas de la misma transacción, si las hay, siguen su curso normal.`, refs:[n.txid, n.custodyStop.addr] }); } if (n.stopReason === "mixer") { conclusions.push({ level:"INFERENCIA", certainty:"CERTEZA", text:`El rastro se rompe en un CoinJoin real en ${n.txid} — no se puede atribuir con honestidad más allá de este punto.`, refs:[n.txid] });