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.` };
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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