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:
@@ -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}) {
|
||||||
|
|||||||
Reference in New Issue
Block a user