feat: peritaje forense — cambio conductual y comparación de huella

behavioralChangeGuess: para cada output, si se gasta rápido (≤6
bloques) o queda quieto (no gastado todavía). combineChangeSignals
cruza esto con la señal estructural de guessChangeOutput — sube la
certeza cuando coinciden, reporta la discrepancia en vez de forzar
una conclusión cuando no.

compareFingerprints compara la huella de 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.
This commit is contained in:
Aitor
2026-07-16 12:13:05 +02:00
parent 2e97a6b822
commit ee817a8c9b
+62
View File
@@ -3717,6 +3717,68 @@
return { kind, certainty, signals, funded, spent, residual, txCount, sweepCount }; 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. // 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. // depth limita la profundidad visual; cache y getTx se comparten desde arriba.
function Eslabon({txData, fluyo, vout, depth, getTx, base}) { function Eslabon({txData, fluyo, vout, depth, getTx, base}) {