feat: peritaje forense — heurística de perfil de dirección

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.
This commit is contained in:
2026-07-16 12:11:53 +02:00
parent de3ab5cc7e
commit f5cffa3f8a
+68
View File
@@ -3649,6 +3649,74 @@
return {marcas, esCoinbase}; 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. // 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}) {