diff --git a/dashboard.html b/dashboard.html
index 2a8681b..4b9468e 100644
--- a/dashboard.html
+++ b/dashboard.html
@@ -1288,6 +1288,42 @@
// ── Informe de wallet completo ────────────────────────────────────────
// 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.
+ // 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) {
// 1) Reutilización: direcciones propias que aparecen en más de una tx
const addrUseCount = new Map();
@@ -1307,35 +1343,8 @@
// 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.
- // 2) Clusters de vinculación por CIOH (common-input-ownership):
- // si una tx gasta varias direcciones propias como inputs, un observador
- // 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);
+ // 2) Clusters de vinculación por CIOH — ver unionFindCluster
+ const { clusters, linkReasons } = unionFindCluster(txs, myAddrSet);
// 3) Historial: cada tx con su banda
const history = txs.map(tx => {