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};
|
||||
}
|
||||
|
||||
// ── 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}) {
|
||||
|
||||
Reference in New Issue
Block a user