fix: podar solo la rama de custodio, no la transacción entera
Cuando un salto se detenía por custodyStop (entidad conocida, hot wallet, o tope de tx_count), se descartaban los DOS outputs de esa transacción, no solo el que disparó la parada — un pago normal junto a un depósito de exchange en la misma tx se perdía igual. Mixer, dilución y el tope global de nodos siguen bloqueando la transacción entera (afectan a todos sus outputs por igual, por construcción); un custodio es propiedad de UNA dirección concreta y ahora se evalúa por dirección: `custodyByAddr` sustituye al `custodyStop` único, y solo esa dirección se excluye del encolado y de la semilla CIOH. El nodo sigue guardando `custodyStop` (primera coincidencia, para el texto del informe) y el nuevo `custodyAddrs` (todas), y el texto de las conclusiones pasa a nombrar la dirección concreta en vez de "la transacción", que ya no es preciso cuando otra rama sigue su curso. Verificado en navegador: los dos casos de regresión ya usados (salto simple, cadena de peeling de 3 saltos) dan resultado idéntico entre el modo automático y el modo por saltos, sin cambios. Caso nuevo dedicado — un salto con un output de custodio (79.828 tx) junto a un output normal — confirma que la rama normal se sigue explorando un salto más, sus fondos aparecen correctamente como localizados, la dirección de custodio recibe una sola petición (nunca se pagina), y el CIOH atribuye la rama continuada sin incluir la del custodio. Caso de dilución confirma que ese stop sigue bloqueando ambos outputs, sin regresión.
This commit is contained in:
+25
-20
@@ -3995,23 +3995,19 @@
|
|||||||
// — cinturón de seguridad — volumen de tx tan alto que seguir esa
|
// — cinturón de seguridad — volumen de tx tan alto que seguir esa
|
||||||
// dirección sería, con seguridad práctica, hammer al nodo sin
|
// dirección sería, con seguridad práctica, hammer al nodo sin
|
||||||
// esperanza real de encontrar el gasto (ver LARGE_ADDR_TX_COUNT).
|
// 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) {
|
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, byVolume:false }; break; }
|
if (hit?.cat === "exchange") { custodyByAddr[a] = { addr:a, name:hit.name, known:true, byVolume:false }; continue; }
|
||||||
}
|
const prof = addressProfiles[a];
|
||||||
if (!custodyStop) {
|
if (prof && prof.kind === "hot_wallet") { custodyByAddr[a] = { addr:a, name:null, known:false, byVolume:false }; continue; }
|
||||||
for (const a of outAddrs) {
|
if (prof && prof.txCount >= LARGE_ADDR_TX_COUNT) { custodyByAddr[a] = { addr:a, name:null, known:false, byVolume:true, txCount:prof.txCount }; }
|
||||||
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; }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const custodyAddrs = outAddrs.filter(a => custodyByAddr[a]);
|
||||||
|
const custodyStop = custodyAddrs.length ? custodyByAddr[custodyAddrs[0]] : null;
|
||||||
|
|
||||||
let stopReason = null;
|
let stopReason = null;
|
||||||
if (likelyCJ) stopReason = "mixer";
|
if (likelyCJ) stopReason = "mixer";
|
||||||
@@ -4034,13 +4030,20 @@
|
|||||||
addressProfiles,
|
addressProfiles,
|
||||||
changeGuess: { structural, behavioral, combined },
|
changeGuess: { structural, behavioral, combined },
|
||||||
changeAddress: structural.index != null ? (spendTx.vout[structural.index]?.scriptpubkey_address ?? null) : null,
|
changeAddress: structural.index != null ? (spendTx.vout[structural.index]?.scriptpubkey_address ?? null) : null,
|
||||||
diluted, tracedShare, custodyStop,
|
diluted, tracedShare, custodyStop, custodyAddrs,
|
||||||
stopReason,
|
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) => {
|
spendTx.vout.forEach((v, i) => {
|
||||||
if (!v.value || v.value <= 0) return;
|
if (!v.value || v.value <= 0) return;
|
||||||
|
if (v.scriptpubkey_address && custodyByAddr[v.scriptpubkey_address]) return;
|
||||||
const key = `${spendTx.txid}:${i}`;
|
const key = `${spendTx.txid}:${i}`;
|
||||||
if (enqueuedKeys.has(key)) return;
|
if (enqueuedKeys.has(key)) return;
|
||||||
enqueuedKeys.add(key);
|
enqueuedKeys.add(key);
|
||||||
@@ -4066,11 +4069,13 @@
|
|||||||
// CIOH: semilla = origen + toda dirección de entrada de cualquier
|
// CIOH: semilla = origen + toda dirección de entrada de cualquier
|
||||||
// salto (por construcción, llegó ahí siguiendo los fondos) + direcciones
|
// salto (por construcción, llegó ahí siguiendo los fondos) + direcciones
|
||||||
// de salida que NO son la puerta de un custodio identificado (esas
|
// 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));
|
const actorAddrSet = new Set([originOut.scriptpubkey_address].filter(Boolean));
|
||||||
for (const n of nodes.values()) {
|
for (const n of nodes.values()) {
|
||||||
for (const a of n.addresses.in) actorAddrSet.add(a);
|
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);
|
const { clusters, linkReasons } = unionFindCluster([...txCache.values()], actorAddrSet);
|
||||||
|
|
||||||
@@ -4192,12 +4197,12 @@
|
|||||||
for (const n of allNodes) {
|
for (const n of allNodes) {
|
||||||
if (n.stopReason === "exchange" && n.custodyStop && n.custodyStop.byVolume) {
|
if (n.stopReason === "exchange" && n.custodyStop && n.custodyStop.byVolume) {
|
||||||
conclusions.push({ level:"HECHO",
|
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] });
|
refs:[n.txid, n.custodyStop.addr] });
|
||||||
} else if (n.stopReason === "exchange" && n.custodyStop) {
|
} 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:`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") {
|
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] });
|
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] });
|
||||||
|
|||||||
Reference in New Issue
Block a user