diff --git a/dashboard.html b/dashboard.html index ca78c4c..f4f4d46 100644 --- a/dashboard.html +++ b/dashboard.html @@ -3717,6 +3717,68 @@ return { kind, certainty, signals, funded, spent, residual, txCount, sweepCount }; } + // Cambio conductual: para cada output de una tx, ¿se gasta rápido (pocos + // bloques después) o queda quieto (no gastado todavía)? spendInfo ya viene + // resuelto por el motor de rastreo desde /api/address/{addr}/txs — esta + // función no hace peticiones, solo interpreta. + // Umbral de "rápido" = 6 bloques, igual que el barrido automático de + // addressProfile, por coherencia dentro del módulo. + function behavioralChangeGuess(tx, spendInfo) { + const outputs = (tx.vout||[]).map((v,i) => ({ index:i, ...(spendInfo.get(i)||{spent:false,blocksLater:null}) })); + const quiet = outputs.filter(o => !o.spent).map(o=>o.index); + const fast = outputs.filter(o => o.spent && o.blocksLater != null && o.blocksLater <= 6).map(o=>o.index); + return { outputs, quiet, fast }; + } + + // Cruza la señal estructural (guessChangeOutput) con la conductual: cuando + // coinciden —el output señalado como cambio queda quieto mientras el otro + // se mueve rápido— la certeza sube un nivel. Cuando discrepan, se reporta + // la discrepancia en vez de forzar una conclusión. "Queda quieto" solo + // significa "no gastado todavía", nunca una prueba por sí sola. + function combineChangeSignals(structural, behavioral) { + if (!structural.identifiable) { + return { index: structural.index, certainty: null, agreement: null, + note: "Sin señal estructural suficiente para cruzar con el comportamiento." }; + } + const idx = structural.index; + const otherIdx = idx === 0 ? 1 : 0; + const idxQuiet = behavioral.quiet.includes(idx); + const idxFast = behavioral.fast.includes(idx); + const otherQuiet = behavioral.quiet.includes(otherIdx); + const otherFast = behavioral.fast.includes(otherIdx); + + if (idxQuiet && otherFast) { + return { index: idx, certainty: "PROBABLE", agreement: "coincide", + note: `El output señalado como cambio (#${idx}) sigue sin gastar mientras el otro (#${otherIdx}) se movió pocos bloques después de esta tx — coincide con lo esperado de un cambio.` }; + } + if (idxFast && otherQuiet) { + return { index: idx, certainty: structural.certainty, agreement: "discrepa", + note: `El output señalado estructuralmente como cambio (#${idx}) se movió rápido mientras el otro (#${otherIdx}) sigue quieto — discrepa con la señal estructural. No se fuerza una conclusión: se deja constancia de la discrepancia.` }; + } + return { index: idx, certainty: structural.certainty, agreement: "sin_dato", + note: "Sin datos de gasto suficientes (ambos gastados o ambos quietos) para cruzar con la señal estructural." }; + } + + // Compara la huella de software (detectWallets) entre dos saltos + // consecutivos del rastro. Un cambio de huella es INFERENCIA/POSIBLE de + // "cambio de actor o entrada en infraestructura de un servicio" — nunca + // CERTEZA, porque una misma persona puede cambiar de wallet sin cambiar + // de dueño de los fondos. + function compareFingerprints(prevFp, nextFp) { + const prevTop = prevFp?.[0]?.name || null; + const nextTop = nextFp?.[0]?.name || null; + if (!prevTop || !nextTop) { + return { changed: null, certainty: null, + note: "Huella insuficiente en uno de los dos saltos para comparar." }; + } + if (prevTop === nextTop) { + return { changed: false, certainty: null, + note: `Misma huella de software en ambos saltos (${prevTop}) — compatible con el mismo actor.` }; + } + return { changed: true, certainty: "POSIBLE", + note: `La huella cambia de ${prevTop} a ${nextTop} entre saltos — posible cambio de actor o entrada en infraestructura de un servicio (custodio, exchange). No es una prueba por sí sola.` }; + } + // Eslabón recursivo: muestra una tx de origen y permite seguir SUS inputs. // depth limita la profundidad visual; cache y getTx se comparten desde arriba. function Eslabon({txData, fluyo, vout, depth, getTx, base}) {