refactor: extraer unionFindCluster de buildWalletReport a utilidad compartida

Prepara la reutilización del union-find CIOH en el módulo de peritaje
forense (semilla = direcciones del actor rastreado en vez de mis
direcciones). Sin cambio de comportamiento en el informe de wallet.
This commit is contained in:
Aitor
2026-07-16 12:06:25 +02:00
parent a6fe438b06
commit e89fea499c
+38 -29
View File
@@ -1288,6 +1288,42 @@
// ── Informe de wallet completo ──────────────────────────────────────── // ── Informe de wallet completo ────────────────────────────────────────
// Analiza el conjunto de txs del wallet: vinculación (CIOH), reutilización // Analiza el conjunto de txs del wallet: vinculación (CIOH), reutilización
// y un resumen de salud. Funciona sobre datos ya traídos, en memoria. // y un resumen de salud. Funciona sobre datos ya traídos, en memoria.
// Clusters de vinculación por CIOH (common-input-ownership): si una tx gasta
// varias direcciones semilla como inputs, un observador asume que pertenecen
// al mismo dueño. Union-Find sobre direcciones. Compartida entre el informe
// de wallet (semilla = mis direcciones) y el peritaje forense (semilla =
// direcciones atribuidas al actor rastreado).
function unionFindCluster(txs, seedAddrSet) {
const parent = new Map();
const find = (x) => { while (parent.get(x) !== x) { parent.set(x, parent.get(parent.get(x))); x = parent.get(x); } return x; };
const union = (a, b) => { const ra=find(a), rb=find(b); if (ra!==rb) parent.set(ra, rb); };
for (const a of seedAddrSet) parent.set(a, a);
const linkReasons = [];
for (const tx of txs) {
const ownInputs = (tx.vin||[])
.map(v => v.prevout?.scriptpubkey_address)
.filter(a => a && seedAddrSet.has(a));
const uniq = [...new Set(ownInputs)];
if (uniq.length > 1) {
for (let i = 1; i < uniq.length; i++) union(uniq[0], uniq[i]);
linkReasons.push({ txid: tx.txid, addrs: uniq, reason: "compartieron inputs en una misma transacción (CIOH)" });
}
}
const clusterMap = new Map();
const linked = new Set(linkReasons.flatMap(l => l.addrs));
for (const a of linked) {
const r = find(a);
if (!clusterMap.has(r)) clusterMap.set(r, []);
clusterMap.get(r).push(a);
}
const clusters = [...clusterMap.values()].filter(c => c.length > 1)
.sort((a,b) => b.length - a.length);
return { clusters, linkReasons };
}
function buildWalletReport(activeAddrs, txs, myAddrSet) { function buildWalletReport(activeAddrs, txs, myAddrSet) {
// 1) Reutilización: direcciones propias que aparecen en más de una tx // 1) Reutilización: direcciones propias que aparecen en más de una tx
const addrUseCount = new Map(); const addrUseCount = new Map();
@@ -1307,35 +1343,8 @@
// Nota: umbral 2 porque una dirección normal aparece en 2 txs como mínimo // Nota: umbral 2 porque una dirección normal aparece en 2 txs como mínimo
// (la que recibe y la que gasta). Más de 2 = reutilización real. // (la que recibe y la que gasta). Más de 2 = reutilización real.
// 2) Clusters de vinculación por CIOH (common-input-ownership): // 2) Clusters de vinculación por CIOH — ver unionFindCluster
// si una tx gasta varias direcciones propias como inputs, un observador const { clusters, linkReasons } = unionFindCluster(txs, myAddrSet);
// asume que pertenecen al mismo dueño. Union-Find sobre direcciones.
const parent = new Map();
const find = (x) => { while (parent.get(x) !== x) { parent.set(x, parent.get(parent.get(x))); x = parent.get(x); } return x; };
const union = (a, b) => { const ra=find(a), rb=find(b); if (ra!==rb) parent.set(ra, rb); };
for (const a of myAddrSet) parent.set(a, a);
const linkReasons = [];
for (const tx of txs) {
const ownInputs = (tx.vin||[])
.map(v => v.prevout?.scriptpubkey_address)
.filter(a => a && myAddrSet.has(a));
const uniq = [...new Set(ownInputs)];
if (uniq.length > 1) {
for (let i = 1; i < uniq.length; i++) union(uniq[0], uniq[i]);
linkReasons.push({ txid: tx.txid, addrs: uniq, reason: "compartieron inputs en una misma transacción (CIOH)" });
}
}
const clusterMap = new Map();
const linked = new Set(linkReasons.flatMap(l => l.addrs));
for (const a of linked) {
const r = find(a);
if (!clusterMap.has(r)) clusterMap.set(r, []);
clusterMap.get(r).push(a);
}
const clusters = [...clusterMap.values()].filter(c => c.length > 1)
.sort((a,b) => b.length - a.length);
// 3) Historial: cada tx con su banda // 3) Historial: cada tx con su banda
const history = txs.map(tx => { const history = txs.map(tx => {