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}) {