diff --git a/dashboard.html b/dashboard.html
index f4f4d46..7b90cd8 100644
--- a/dashboard.html
+++ b/dashboard.html
@@ -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}) {