From f5cffa3f8a43d774bd3240b81f00b2b62c7e31eb Mon Sep 17 00:00:00 2001 From: pikaro Date: Thu, 16 Jul 2026 12:11:53 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20peritaje=20forense=20=E2=80=94=20heur?= =?UTF-8?q?=C3=ADstica=20de=20perfil=20de=20direcci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit addressProfile(addr, addrInfo, addrTxs) clasifica personal vs hot wallet usando chain_stats (residual/tx_count) y detección de barrido automático (recibe y reenvía 1-in/1-out sin cambio, pocos bloques después). No identifica al custodio, solo el patrón de comportamiento; la atribución a un exchange concreto sigue viniendo de ENTITY_INDEX. Primera pieza nueva del módulo Peritaje (spec en TRASPASO.md), sobre la base de los refactors de unionFindCluster y guessChangeOutput. --- dashboard.html | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/dashboard.html b/dashboard.html index 459972c..ca78c4c 100644 --- a/dashboard.html +++ b/dashboard.html @@ -3649,6 +3649,74 @@ return {marcas, esCoinbase}; } + // ── Peritaje forense — heurísticas nuevas ─────────────────────────────── + // Perfil de dirección: distingue wallet personal de hot wallet de exchange/ + // custodio a partir de chain_stats (funded/spent/tx_count, ya disponibles + // en /api/address/{addr}) y del patrón de gasto en addrTxs + // (/api/address/{addr}/txs). No identifica el custodio — eso es el trabajo + // de ENTITY_INDEX/marcasDeTx; esto solo dice "se comporta como" uno. + function addressProfile(addr, addrInfo, addrTxs) { + const stats = addrInfo?.chain_stats || { funded_txo_sum:0, spent_txo_sum:0, tx_count:0 }; + const funded = stats.funded_txo_sum || 0; + const spent = stats.spent_txo_sum || 0; + const txCount = stats.tx_count || 0; + const residual = funded - spent; + const residualRatio = funded > 0 ? residual / funded : 0; + + const signals = []; + let hotScore = 0, personalScore = 0; + + // Señal de flujo: saldo residual casi nulo + volumen y tx_count altos -> + // la dirección funciona como paso de caudal, no como ahorro (típico de + // hot wallet de exchange). Lo contrario -> retención típica de uso personal. + if (funded > 0 && residualRatio < 0.05 && txCount >= 20) { + hotScore += 2; + signals.push(`saldo residual ≈0 (${(residualRatio*100).toFixed(1)}% de lo recibido) con ${txCount} transacciones — patrón de flujo, no de ahorro`); + } else if (funded > 0 && residualRatio > 0.3 && txCount < 10) { + personalScore += 2; + signals.push(`retiene ${(residualRatio*100).toFixed(0)}% de lo recibido en solo ${txCount} transacción(es) — patrón de retención, no de custodio`); + } + + // Barrido automático: recibe y reenvía en una tx 1-entrada/1-salida sin + // cambio, pocos bloques después. "Pocos bloques", nunca minutos: la + // confirmación on-chain resuelve en bloques (~10 min de media), no da + // precisión de reloj. + const spendingTxOf = new Map(); // "txid:vout" -> tx que lo gasta + for (const t of addrTxs) { + for (const vin of (t.vin||[])) { + if (vin.prevout?.scriptpubkey_address === addr && vin.txid != null) { + spendingTxOf.set(`${vin.txid}:${vin.vout}`, t); + } + } + } + let sweepCount = 0; + for (const t of addrTxs) { + (t.vout||[]).forEach((vout, i) => { + if (vout.scriptpubkey_address !== addr) return; + const spendingTx = spendingTxOf.get(`${t.txid}:${i}`); + if (!spendingTx) return; + const oneInOneOut = (spendingTx.vin||[]).length === 1 && (spendingTx.vout||[]).length === 1; + if (!oneInOneOut) return; + const h1 = t.status?.block_height, h2 = spendingTx.status?.block_height; + if (h1 == null || h2 == null) return; + const hopBlocks = h2 - h1; + if (hopBlocks >= 0 && hopBlocks <= 6) sweepCount++; + }); + } + if (sweepCount > 0) { + hotScore += 2; + signals.push(`barrido automático: ${sweepCount} vez(es) reenviado en una tx 1-entrada/1-salida sin cambio, pocos bloques después de recibir`); + } + + let kind, certainty; + if (hotScore >= 3) { kind = "hot_wallet"; certainty = "PROBABLE"; } + else if (hotScore >= 2) { kind = "hot_wallet"; certainty = "POSIBLE"; } + else if (personalScore >= 2) { kind = "personal"; certainty = "POSIBLE"; } + else { kind = "indeterminado"; certainty = null; } + + return { kind, certainty, signals, funded, spent, residual, txCount, sweepCount }; + } + // 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}) {