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}) {