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:
Aitor
2026-07-16 12:22:28 +02:00
parent ee817a8c9b
commit 6fcffb2096
+248
View File
@@ -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}) {