feat: peritaje forense — motor de rastreo multi-salto (buildForensicGraph)
Rastreo hacia adelante desde {txid,vout} siguiendo TODOS los outputs de
cada salto (no solo el presunto cambio) porque los fondos pueden
repartirse en varias ramas; cada rama se detiene de forma
independiente. Condiciones de parada: CoinJoin real, dilución (3+
direcciones de entrada no relacionadas), entidad conocida o perfil de
hot wallet no indexado, UTXO sin gastar, límite de saltos.
findSpendingTx implementa el fallback ya verificado en la sesión
anterior (TRASPASO.md): este backend Mempool no expone /outspend, así
que se recorre /api/address/{addr}/txs buscando la tx cuyo vin
referencia el txid:vout de origen — mismo patrón de paginación y
throttling por lotes que scanWallet.
detectPeelingChains cierra el hueco que el check `peeling` de
analyzeTx ya señalaba (confirmar una cadena requiere mirar hacia
adelante): reconstruye tramos de nodos 1-in/2-out conectados por la
señal de cambio combinada, sube a CERTEZA con 3+ saltos y huella de
wallet estable.
MAX_NODES=80 como red de seguridad aparte de maxHops, para no hammer
el nodo del usuario si un salto desemboca en una tx con muchos
outputs. Verificado: balance de sintaxis del bloque Babel y
node --check sobre el fragmento JS puro del motor.
This commit is contained in:
+248
@@ -3779,6 +3779,254 @@
|
|||||||
note: `La huella cambia de ${prevTop} a ${nextTop} entre saltos — posible cambio de actor o entrada en infraestructura de un servicio (custodio, exchange). No es una prueba por sí sola.` };
|
note: `La huella cambia de ${prevTop} a ${nextTop} entre saltos — posible cambio de actor o entrada en infraestructura de un servicio (custodio, exchange). No es una prueba por sí sola.` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Busca la tx que gasta un output concreto (txid:vout) recorriendo el
|
||||||
|
// historial de la dirección que lo recibió. Fallback obligado: este
|
||||||
|
// backend Mempool self-hosted no implementa /outspend ni /outspends
|
||||||
|
// (verificado contra el nodo real — ver TRASPASO.md), así que no hay forma
|
||||||
|
// directa de preguntar "¿quién gasta esto?". Mismo patrón de paginación
|
||||||
|
// que scanWallet (~línea 3929): página de 25 + /txs/chain/{last}.
|
||||||
|
async function findSpendingTx(get, addr, txid, vout, maxPages) {
|
||||||
|
maxPages = maxPages || 8;
|
||||||
|
let page = await get(`/api/address/${addr}/txs`, []).catch(()=>[]);
|
||||||
|
let guard = 0;
|
||||||
|
while (Array.isArray(page) && page.length > 0 && guard < maxPages) {
|
||||||
|
const found = page.find(t => (t.vin||[]).some(v => v.txid === txid && v.vout === vout));
|
||||||
|
if (found) return found;
|
||||||
|
if (page.length < 25) break; // última página, no hay más que mirar
|
||||||
|
const last = page[page.length-1].txid;
|
||||||
|
page = await get(`/api/address/${addr}/txs/chain/${last}`, []).catch(()=>[]);
|
||||||
|
guard++;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruye tramos de peeling chain confirmada: cadenas de nodos 1-in/
|
||||||
|
// 2-out donde el output de cambio (señal combinada estructural+conductual)
|
||||||
|
// de cada uno alimenta exactamente al siguiente. El check `peeling` de
|
||||||
|
// analyzeTx (informativo, un solo salto) ya dice explícitamente que
|
||||||
|
// confirmar una cadena requiere mirar hacia adelante — esto es lo que
|
||||||
|
// cierra ese hueco, sobre el grafo ya construido.
|
||||||
|
function detectPeelingChains(nodes, edges) {
|
||||||
|
const childVia = (node) => {
|
||||||
|
const idx = node.changeGuess?.combined?.index;
|
||||||
|
if (idx == null) return null;
|
||||||
|
const e = edges.find(e => e.fromTxid === node.txid && e.fromVout === idx);
|
||||||
|
return e ? e.toTxid : null;
|
||||||
|
};
|
||||||
|
const isPeelHop = (n) => n && n.vinCount === 1 && n.voutCount === 2;
|
||||||
|
const nonStart = new Set();
|
||||||
|
for (const n of nodes.values()) {
|
||||||
|
if (!isPeelHop(n)) continue;
|
||||||
|
const childTxid = childVia(n);
|
||||||
|
const child = childTxid && nodes.get(childTxid);
|
||||||
|
if (isPeelHop(child)) nonStart.add(child.txid);
|
||||||
|
}
|
||||||
|
const chains = [];
|
||||||
|
for (const n of nodes.values()) {
|
||||||
|
if (!isPeelHop(n) || nonStart.has(n.txid)) continue;
|
||||||
|
const segment = [n];
|
||||||
|
let fingerprintStable = true;
|
||||||
|
let cur = n;
|
||||||
|
while (true) {
|
||||||
|
const childTxid = childVia(cur);
|
||||||
|
const child = childTxid && nodes.get(childTxid);
|
||||||
|
if (!isPeelHop(child)) break;
|
||||||
|
if (child.fingerprintComparison?.changed === true) fingerprintStable = false;
|
||||||
|
segment.push(child);
|
||||||
|
cur = child;
|
||||||
|
}
|
||||||
|
if (segment.length >= 2) {
|
||||||
|
chains.push({
|
||||||
|
txids: segment.map(s=>s.txid),
|
||||||
|
length: segment.length,
|
||||||
|
fingerprintStable,
|
||||||
|
certainty: segment.length >= 3 && fingerprintStable ? "CERTEZA" : "PROBABLE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chains;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Motor de rastreo forense — hacia adelante, salto a salto, desde
|
||||||
|
// {txid,vout}. Sigue TODOS los outputs de cada salto (no solo "el
|
||||||
|
// cambio"): un actor puede repartir los fondos en más de una rama, y cada
|
||||||
|
// rama se detiene de forma independiente (spec: "cualquiera detiene esa
|
||||||
|
// rama, no todo el rastreo"). Qué output es "cambio" se calcula por señal
|
||||||
|
// estructural+conductual y sirve para el informe (atribución, peeling
|
||||||
|
// chain), no para podar ramas.
|
||||||
|
//
|
||||||
|
// Throttling: mismo patrón que scanWallet — lotes con pausa, no ráfaga.
|
||||||
|
// MAX_NODES es una red de seguridad aparte de maxHops: protege el nodo del
|
||||||
|
// usuario si un salto desemboca en una tx con muchísimos outputs (p.ej.
|
||||||
|
// consolidación de un servicio de pagos).
|
||||||
|
async function buildForensicGraph({ get, originTxid, originVout, amountStolen, maxHops, onProgress }) {
|
||||||
|
maxHops = maxHops || 8;
|
||||||
|
const BATCH = 5, PAUSE = 120, MAX_NODES = 80;
|
||||||
|
|
||||||
|
const nodes = new Map(); // txid -> ForensicNode
|
||||||
|
const edges = []; // ForensicEdge[]
|
||||||
|
const unspentTerminals = []; // ramas terminales: UTXO sin gastar, sin dirección, o truncadas
|
||||||
|
const txCache = new Map(); // txid -> tx cruda (para el CIOH final)
|
||||||
|
|
||||||
|
const originTx = await get(`/api/tx/${originTxid}`, null).catch(()=>null);
|
||||||
|
if (!originTx) throw new Error("No se pudo obtener la transacción de origen. Comprueba el txid.");
|
||||||
|
const originOut = originTx.vout?.[originVout];
|
||||||
|
if (!originOut) throw new Error("El vout indicado no existe en la transacción de origen.");
|
||||||
|
txCache.set(originTxid, originTx);
|
||||||
|
const originFingerprint = detectWallets(originTx);
|
||||||
|
|
||||||
|
let frontier = [{ txid: originTxid, vout: originVout, amount: originOut.value, hop: 0, parentTxid: originTxid }];
|
||||||
|
const enqueuedKeys = new Set();
|
||||||
|
|
||||||
|
while (frontier.length > 0) {
|
||||||
|
const batch = frontier.splice(0, BATCH);
|
||||||
|
if (onProgress) onProgress(`Explorando ${nodes.size} tx del rastro (${frontier.length + batch.length} rama(s) en cola)…`);
|
||||||
|
|
||||||
|
await Promise.all(batch.map(async (item) => {
|
||||||
|
const { txid, vout, amount, hop, parentTxid } = item;
|
||||||
|
const parentTx = txCache.get(txid);
|
||||||
|
const out = parentTx?.vout?.[vout];
|
||||||
|
const addr = out?.scriptpubkey_address;
|
||||||
|
|
||||||
|
if (!addr) {
|
||||||
|
unspentTerminals.push({ txid, vout, address:null, amount, note:"Sin dirección estándar (OP_RETURN u otro script) — no rastreable." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hop >= maxHops) {
|
||||||
|
unspentTerminals.push({ txid, vout, address:addr, amount, note:"Límite de saltos alcanzado.", truncated:true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const spendTx = await findSpendingTx(get, addr, txid, vout);
|
||||||
|
if (!spendTx) {
|
||||||
|
unspentTerminals.push({ txid, vout, address:addr, amount, note:"UTXO sin gastar." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
edges.push({
|
||||||
|
fromTxid: txid, fromVout: vout, toTxid: spendTx.txid,
|
||||||
|
toVin: (spendTx.vin||[]).findIndex(v=>v.txid===txid && v.vout===vout), amount,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (nodes.has(spendTx.txid)) return; // ya construido por otra rama — la arista basta (CIOH implícito)
|
||||||
|
txCache.set(spendTx.txid, spendTx);
|
||||||
|
|
||||||
|
const analysis = analyzeTx(spendTx);
|
||||||
|
const likelyCJ = analysis.checks.find(c=>c.id==="coinjoin")?.pass === true;
|
||||||
|
const { marcas: entityMarks } = marcasDeTx(spendTx, analysis);
|
||||||
|
const fingerprint = detectWallets(spendTx);
|
||||||
|
const structural = guessChangeOutput(spendTx, likelyCJ);
|
||||||
|
|
||||||
|
const inAddrs = [...new Set((spendTx.vin||[]).map(v=>v.prevout?.scriptpubkey_address).filter(Boolean))];
|
||||||
|
const outAddrs = [...new Set((spendTx.vout||[]).map(v=>v.scriptpubkey_address).filter(Boolean))];
|
||||||
|
|
||||||
|
// Perfil + historial de las direcciones de salida — reutilizamos ese
|
||||||
|
// historial para la señal conductual (spendInfo) sin pedirlo dos veces.
|
||||||
|
const outAddrTxs = {};
|
||||||
|
const addressProfiles = {};
|
||||||
|
await Promise.all(outAddrs.map(async a => {
|
||||||
|
const [info, txs] = await Promise.all([
|
||||||
|
get(`/api/address/${a}`, null).catch(()=>null),
|
||||||
|
get(`/api/address/${a}/txs`, []).catch(()=>[]),
|
||||||
|
]);
|
||||||
|
outAddrTxs[a] = txs || [];
|
||||||
|
if (info) addressProfiles[a] = addressProfile(a, info, txs||[]);
|
||||||
|
}));
|
||||||
|
|
||||||
|
const spendInfo = new Map();
|
||||||
|
(spendTx.vout||[]).forEach((v, i) => {
|
||||||
|
const a = v.scriptpubkey_address;
|
||||||
|
if (!a) return;
|
||||||
|
const txs = outAddrTxs[a] || [];
|
||||||
|
const spender = txs.find(t => (t.vin||[]).some(vin => vin.txid===spendTx.txid && vin.vout===i));
|
||||||
|
if (spender && spender.status?.block_height != null && spendTx.status?.block_height != null) {
|
||||||
|
spendInfo.set(i, { spent:true, blocksLater: spender.status.block_height - spendTx.status.block_height });
|
||||||
|
} else if (spender) {
|
||||||
|
spendInfo.set(i, { spent:true, blocksLater:null });
|
||||||
|
} else {
|
||||||
|
spendInfo.set(i, { spent:false, blocksLater:null });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const behavioral = likelyCJ ? null : behavioralChangeGuess(spendTx, spendInfo);
|
||||||
|
const combined = behavioral ? combineChangeSignals(structural, behavioral) : null;
|
||||||
|
|
||||||
|
// Dilución: 3+ direcciones de entrada y el monto rastreado deja de
|
||||||
|
// ser una fracción identificable del total del salto.
|
||||||
|
const totalInHop = (spendTx.vin||[]).reduce((s,v)=>s+(v.prevout?.value||0),0);
|
||||||
|
const tracedShare = totalInHop > 0 ? amount / totalInHop : 1;
|
||||||
|
const diluted = inAddrs.length >= 3 && tracedShare < 0.5;
|
||||||
|
|
||||||
|
// Parada por custodio: entidad conocida (ENTITY_INDEX) o perfil de
|
||||||
|
// hot wallet no indexado ("posible custodio no identificado").
|
||||||
|
let custodyStop = null;
|
||||||
|
for (const a of outAddrs) {
|
||||||
|
const hit = ENTITY_INDEX.get(a);
|
||||||
|
if (hit?.cat === "exchange") { custodyStop = { addr:a, name:hit.name, known:true }; break; }
|
||||||
|
}
|
||||||
|
if (!custodyStop) {
|
||||||
|
for (const a of outAddrs) {
|
||||||
|
const prof = addressProfiles[a];
|
||||||
|
if (prof && prof.kind === "hot_wallet") { custodyStop = { addr:a, name:null, known:false }; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let stopReason = null;
|
||||||
|
if (likelyCJ) stopReason = "mixer";
|
||||||
|
else if (diluted) stopReason = "dilution";
|
||||||
|
else if (custodyStop) stopReason = "exchange";
|
||||||
|
else if (nodes.size + 1 >= MAX_NODES) stopReason = "maxHops";
|
||||||
|
|
||||||
|
const parentNode = parentTxid === originTxid ? { fingerprint: originFingerprint } : nodes.get(parentTxid);
|
||||||
|
const fingerprintComparison = parentNode ? compareFingerprints(parentNode.fingerprint, fingerprint) : null;
|
||||||
|
|
||||||
|
nodes.set(spendTx.txid, {
|
||||||
|
txid: spendTx.txid, hop: hop+1,
|
||||||
|
blockTime: spendTx.status?.block_time ?? null,
|
||||||
|
blockHeight: spendTx.status?.confirmed ? (spendTx.status.block_height ?? null) : null,
|
||||||
|
confirmed: !!spendTx.status?.confirmed,
|
||||||
|
vinCount: (spendTx.vin||[]).length, voutCount: (spendTx.vout||[]).length,
|
||||||
|
addresses: { in: inAddrs, out: outAddrs },
|
||||||
|
amountTraced: amount,
|
||||||
|
analysis, fingerprint, fingerprintComparison, entityMarks,
|
||||||
|
addressProfiles,
|
||||||
|
changeGuess: { structural, behavioral, combined },
|
||||||
|
diluted, tracedShare, custodyStop,
|
||||||
|
stopReason,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!stopReason) {
|
||||||
|
spendTx.vout.forEach((v, i) => {
|
||||||
|
if (!v.value || v.value <= 0) return;
|
||||||
|
const key = `${spendTx.txid}:${i}`;
|
||||||
|
if (enqueuedKeys.has(key)) return;
|
||||||
|
enqueuedKeys.add(key);
|
||||||
|
frontier.push({ txid: spendTx.txid, vout:i, amount:v.value, hop:hop+1, parentTxid: txid });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (frontier.length > 0) await new Promise(r => setTimeout(r, PAUSE));
|
||||||
|
}
|
||||||
|
|
||||||
|
// CIOH final: semilla = origen + toda dirección de entrada de cualquier
|
||||||
|
// salto (por construcción, llegó ahí siguiendo los fondos) + direcciones
|
||||||
|
// de salida que NO son la puerta de un custodio identificado (esas
|
||||||
|
// pertenecen al servicio, no al mismo actor rastreado).
|
||||||
|
const actorAddrSet = new Set([originOut.scriptpubkey_address].filter(Boolean));
|
||||||
|
for (const n of nodes.values()) {
|
||||||
|
for (const a of n.addresses.in) actorAddrSet.add(a);
|
||||||
|
if (!n.custodyStop) for (const a of n.addresses.out) actorAddrSet.add(a);
|
||||||
|
}
|
||||||
|
const { clusters, linkReasons } = unionFindCluster([...txCache.values()], actorAddrSet);
|
||||||
|
|
||||||
|
return {
|
||||||
|
origin: { txid: originTxid, vout: originVout, address: originOut.scriptpubkey_address, amount: originOut.value, fingerprint: originFingerprint },
|
||||||
|
amountStolen: amountStolen || null,
|
||||||
|
nodes, edges, unspentTerminals, clusters, linkReasons,
|
||||||
|
peelingChains: detectPeelingChains(nodes, edges),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// 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