diff --git a/CHANGELOG.md b/CHANGELOG.md index ae7313c..b99f4ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,34 @@ y el versionado sigue [Versionado Semántico](https://semver.org/lang/es/): - **MENOR** — características nuevas que no rompen lo anterior - **PARCHE** — arreglos de errores +--- +## [1.10.1] — 2026-07-27 +### Corregido +- **Peritaje forense: un fallo de consulta ya no se presenta como "rastro + completo".** Detectado probando contra el nodo real: cuando el backend + devolvía un 503 o agotaba el timeout, la app lo mostraba como + *"✓ Rastro completo — no quedan ramas por explorar"* y contaba esa rama como + terminal. La causa eran dos capturas silenciosas encadenadas — `get` devolvía + `mockData` ante cualquier error y `findSpendingTx` remataba con + `.catch(()=>[])` —, así que `[]` por error era indistinguible de `[]` porque + el output sigue sin gastar. En un informe pericial eso convertía una consulta + fallida en la conclusión más accionable del documento: *fondos localizados + sin gastar*. + - Nuevo `getStrict` en `useApi`: propaga el fallo en vez de disfrazarlo. + `get` no cambia, así que el resto del dashboard mantiene su comportamiento. + - El motor (`findSpendingTx`, `advanceForensicHop`, `initForensicTrace`) + distingue "no se pudo consultar" de "no hay gasto" y registra los fallos en + `trace.queryErrors`. + - El informe excluye las ramas con consulta fallida de "fondos localizados + sin gastar", las lista en su propia sección y abre las conclusiones + avisando de que el rastreo está incompleto. Incluido en los export JSON y MD. + - La UI muestra contador de consultas fallidas y sustituye el falso + "rastro completo" por un aviso con el detalle de qué falló. +- Peritaje: el error al obtener la transacción de origen ya no culpa siempre al + txid — distingue un txid inexistente de un fallo del nodo. +- Peritaje: el botón "Generar informe" explica por qué está deshabilitado en + vez de quedarse inerte sin dar motivo. + --- ## [1.10.0] — 2026-07-16 ### Añadido diff --git a/dashboard.html b/dashboard.html index 90c16b5..8b13948 100644 --- a/dashboard.html +++ b/dashboard.html @@ -2063,7 +2063,19 @@ return res.json(); } catch { return mockData; } }, [baseUrl]); - return { get }; + // Variante estricta: propaga el fallo en vez de disfrazarlo de dato + // vacío. `get` devuelve mockData ante cualquier error, lo que en las + // vistas de exploración es aceptable (se ve el modo demo), pero en el + // peritaje haría indistinguible "el nodo no respondió" de "aquí no hay + // nada" — y eso convierte un fallo de consulta en una afirmación + // pericial falsa. Quien use getStrict debe capturar y decir qué pasó. + const getStrict = useCallback(async (path) => { + if (!baseUrl) throw new Error("no hay nodo configurado"); + const res = await fetchWithTimeout(`${baseUrl}${path}`); + if (!res.ok) throw new Error(`el nodo respondió HTTP ${res.status}`); + return res.json(); + }, [baseUrl]); + return { get, getStrict }; } const fmt = { @@ -3785,16 +3797,22 @@ // (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: página de 25 + /txs/chain/{last}. - async function findSpendingTx(get, addr, txid, vout, maxPages) { + // Usa la variante ESTRICTA de fetch a propósito: si el nodo falla, la + // excepción sube al motor, que registra esa rama como "no se pudo + // comprobar". Tragar el error aquí (devolver []) haría que un 503 o un + // timeout fuese indistinguible de "este output sigue sin gastar" — la + // conclusión más accionable del informe, afirmada sobre una consulta que + // nunca llegó a responder. + async function findSpendingTx(getStrict, addr, txid, vout, maxPages) { maxPages = maxPages || 8; - let page = await get(`/api/address/${addr}/txs`, []).catch(()=>[]); + let page = await getStrict(`/api/address/${addr}/txs`); 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(()=>[]); + page = await getStrict(`/api/address/${addr}/txs/chain/${last}`); guard++; } return null; @@ -3865,20 +3883,29 @@ // // Crea el estado de un rastreo forense nuevo, sin ejecutar ningún salto // todavía. - async function initForensicTrace({ get, originTxid, originVout, amountStolen }) { - const originTx = await get(`/api/tx/${originTxid}`, null).catch(()=>null); + async function initForensicTrace({ get, getStrict, originTxid, originVout, amountStolen }) { + // Distinguir "txid inexistente" de "el nodo no respondió": culpar al + // txid cuando el fallo es del nodo manda al usuario a buscar un error + // que no existe. + let originTx; + try { + originTx = await getStrict(`/api/tx/${originTxid}`); + } catch (e) { + throw new Error(`No se pudo consultar la transacción de origen: ${e.message}. No es (necesariamente) un problema del txid — la petición al nodo falló.`); + } 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."); const originFingerprint = detectWallets(originTx); return { - get, originTxid, originVout, amountStolen: amountStolen || null, + get, getStrict, originTxid, originVout, amountStolen: amountStolen || null, originTx, originOut, originFingerprint, txCache: new Map([[originTxid, originTx]]), nodes: new Map(), // txid -> ForensicNode edges: [], // ForensicEdge[] - unspentTerminals: [], // ramas terminales: UTXO sin gastar, sin dirección, o truncadas + unspentTerminals: [], // ramas terminales: UTXO sin gastar, sin dirección, truncadas o con consulta fallida + queryErrors: [], // consultas al nodo que fallaron — NUNCA se leen como "no hay nada" frontier: [{ txid: originTxid, vout: originVout, amount: originOut.value, hop: 0, parentTxid: originTxid }], enqueuedKeys: new Set(), hop: 0, done: false, @@ -3912,7 +3939,7 @@ // cuesta peticiones extra: chain_stats.tx_count ya viene en el perfil // que se pide de todos modos para cada dirección de salida nueva. const LARGE_ADDR_TX_COUNT = 5000; - const { get, originTxid, originFingerprint, nodes, edges, unspentTerminals, txCache, enqueuedKeys } = trace; + const { get, getStrict, originTxid, originFingerprint, nodes, edges, unspentTerminals, txCache, enqueuedKeys, queryErrors } = trace; if (trace.frontier.length === 0) { trace.done = true; return trace; } @@ -3938,7 +3965,17 @@ return; } - const spendTx = await findSpendingTx(get, addr, txid, vout); + // "No se pudo consultar" y "no hay gasto" son cosas distintas y se + // registran distinto. Solo la segunda es una conclusión. + let spendTx; + try { + spendTx = await findSpendingTx(getStrict, addr, txid, vout); + } catch (e) { + queryErrors.push({ txid, vout, address:addr, message:e.message, context:"búsqueda del gasto" }); + unspentTerminals.push({ txid, vout, address:addr, amount, queryFailed:true, + note:`No se pudo comprobar si este output fue gastado: ${e.message}. La consulta al nodo falló — no es una conclusión sobre el estado de los fondos.` }); + return; + } if (!spendTx) { unspentTerminals.push({ txid, vout, address:addr, amount, note:"UTXO sin gastar." }); return; @@ -3966,10 +4003,18 @@ 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(()=>[]), - ]); + let info = null, txs = []; + try { + [info, txs] = await Promise.all([ + getStrict(`/api/address/${a}`), + getStrict(`/api/address/${a}/txs`), + ]); + } catch (e) { + // Sin perfil no hay señal conductual NI cinturón de volumen + // para esta dirección: queda anotado para que el informe no + // presente su ausencia como si se hubiera comprobado. + queryErrors.push({ txid:spendTx.txid, address:a, message:e.message, context:"perfil de dirección" }); + } outAddrTxs[a] = txs || []; if (info) addressProfiles[a] = addressProfile(a, info, txs||[]); })); @@ -4071,7 +4116,7 @@ // partir de un `trace`, completo o parcial — se puede llamar en // cualquier momento del rastreo a demanda, no solo al terminar. function finalizeForensicGraph(trace) { - const { originTxid, originVout, originTx, originOut, originFingerprint, amountStolen, nodes, edges, unspentTerminals, txCache } = trace; + const { originTxid, originVout, originTx, originOut, originFingerprint, amountStolen, nodes, edges, unspentTerminals, txCache, queryErrors } = trace; // CIOH: semilla = origen + toda dirección de entrada de cualquier // salto (por construcción, llegó ahí siguiendo los fondos) + direcciones @@ -4101,6 +4146,7 @@ }, amountStolen: amountStolen || null, nodes, edges, unspentTerminals, clusters, linkReasons, + queryErrors: queryErrors || [], peelingChains: detectPeelingChains(nodes, edges), }; } @@ -4110,8 +4156,8 @@ // Se mantiene por compatibilidad — la UI a demanda usa // initForensicTrace + advanceForensicHop directamente, con el usuario // decidiendo cuándo llamar a cada salto en vez de este bucle. - async function buildForensicGraph({ get, originTxid, originVout, amountStolen, maxHops, onProgress }) { - const trace = await initForensicTrace({ get, originTxid, originVout, amountStolen }); + async function buildForensicGraph({ get, getStrict, originTxid, originVout, amountStolen, maxHops, onProgress }) { + const trace = await initForensicTrace({ get, getStrict, originTxid, originVout, amountStolen }); while (!trace.done) { await advanceForensicHop(trace, { maxHops, onProgress }); } @@ -4125,6 +4171,7 @@ // afectado sin corroborar — nunca se mezcla con las otras dos. function buildForensicReport(graph, declaracionText) { const { origin, nodes, unspentTerminals, clusters, peelingChains, amountStolen } = graph; + const queryErrors = graph.queryErrors || []; const allNodes = [...nodes.values()].sort((a,b)=> a.hop - b.hop || (a.blockTime||0) - (b.blockTime||0)); const maxHop = allNodes.reduce((m,n)=>Math.max(m,n.hop), 0); const addressesTouched = new Set([origin.address].filter(Boolean)); @@ -4192,14 +4239,32 @@ } // ── 5. Fondos localizados sin gastar ──────────────────────────────── + // Solo entra aquí lo COMPROBADO sin gastar. Las ramas con consulta + // fallida (queryFailed) se quedan fuera a propósito: afirmar que unos + // fondos siguen sin gastar porque el nodo devolvió un 503 sería la peor + // clase de error en un informe pericial. const unspentFunds = unspentTerminals - .filter(u => u.address && !u.truncated && u.note === "UTXO sin gastar.") + .filter(u => u.address && !u.truncated && !u.queryFailed && u.note === "UTXO sin gastar.") .map(u => ({ level:"HECHO", txid:u.txid, vout:u.vout, address:u.address, amount:u.amount, refs:[u.txid, u.address] })); const truncatedBranches = unspentTerminals.filter(u => u.truncated); + const failedBranches = unspentTerminals.filter(u => u.queryFailed); // ── 6. Conclusiones numeradas ──────────────────────────────────────── const conclusions = []; conclusions.push({ level:"HECHO", text:`${origin.amount} sats rastreados desde ${origin.txid}:${origin.vout}, a través de ${allNodes.length} transacción(es) en ${maxHop} salto(s).`, refs:[origin.txid] }); + // Va lo primero, antes que cualquier conclusión: si parte del rastreo no + // se pudo consultar, el lector debe saberlo antes de leer nada más. + if (failedBranches.length > 0) { + conclusions.push({ level:"HECHO", + text:`ATENCIÓN — rastreo incompleto por fallos de consulta: ${failedBranches.length} rama(s) quedaron sin comprobar porque el nodo no respondió (${[...new Set(failedBranches.map(f=>f.address))].join(", ")}). NO se sabe si esos fondos siguen sin gastar o se movieron: la consulta falló, no se comprobó nada. Repite el rastreo sobre esas ramas antes de dar por bueno el alcance de este informe.`, + refs: failedBranches.map(f=>f.txid) }); + } + if (queryErrors.some(q => q.context === "perfil de dirección")) { + const affected = [...new Set(queryErrors.filter(q=>q.context==="perfil de dirección").map(q=>q.address))]; + conclusions.push({ level:"HECHO", + text:`No se pudo obtener el perfil de ${affected.length} dirección(es) (${affected.join(", ")}). Para esas direcciones no hay señal conductual ni comprobación de volumen, así que su ausencia en las conclusiones no significa que se hayan descartado — no se pudieron examinar.`, + refs: affected }); + } if (clusters.length > 0) { conclusions.push({ level:"INFERENCIA", certainty:"PROBABLE", text:`Se detectaron ${clusters.length} cluster(es) de direcciones vinculadas por CIOH — con alta probabilidad pertenecen al mismo actor.`, refs: clusters.flat() }); } @@ -4261,7 +4326,7 @@ addresses: [...addressesTouched], }; - return { summary, declaracion, chronology, attributed, unspentFunds, truncatedBranches, conclusions, recommendations, methodology, verificationAppendix }; + return { summary, declaracion, chronology, attributed, unspentFunds, truncatedBranches, failedBranches, queryErrors, conclusions, recommendations, methodology, verificationAppendix }; } // Eslabón recursivo: muestra una tx de origen y permite seguir SUS inputs. @@ -5014,7 +5079,7 @@ // ── PERITAJE ───────────────────────────────────────────────────────── function PeritajeForense({base}) { - const {get} = useApi(base); + const {get, getStrict} = useApi(base); const [txidInput, setTxidInput] = useState(""); const [voutInput, setVoutInput] = useState("0"); const [amountInput, setAmountInput] = useState(""); @@ -5051,7 +5116,7 @@ setError(null); setBusy(true); setReport(null); try { const amountStolen = amountInput.trim() ? Math.round(parseFloat(amountInput)*1e8) : null; - const trace = await initForensicTrace({ get, originTxid: txid, originVout: vout, amountStolen }); + const trace = await initForensicTrace({ get, getStrict, originTxid: txid, originVout: vout, amountStolen }); traceRef.current = trace; setTick(t => t+1); await avanzarSalto(trace); // "iniciar" ya explora el primer salto — un solo clic para ver algo @@ -5083,6 +5148,7 @@ summary: report.summary, declaracion: report.declaracion, chronology: report.chronology, attributed: report.attributed, unspentFunds: report.unspentFunds, conclusions: report.conclusions, + failedBranches: report.failedBranches, queryErrors: report.queryErrors, recommendations: report.recommendations, methodology: report.methodology, verificationAppendix: report.verificationAppendix, }; @@ -5091,7 +5157,7 @@ }; const exportMD = () => { - const { summary, declaracion:decl, chronology, attributed, unspentFunds, conclusions, recommendations, methodology, verificationAppendix } = report; + const { summary, declaracion:decl, chronology, attributed, unspentFunds, failedBranches, conclusions, recommendations, methodology, verificationAppendix } = report; const lines = [ `# Peritaje forense — Txoko`, ``, `## Resumen`, ``, @@ -5113,6 +5179,12 @@ ...(unspentFunds.length===0 ? ["Ninguno detectado en las ramas exploradas."] : unspentFunds.map(u => `- [HECHO] ${u.address} — ${u.amount} sats (tx ${u.txid.slice(0,16)}…, vout ${u.vout})`)), ``, + ...((failedBranches && failedBranches.length>0) ? [ + `## Ramas NO comprobadas (fallo de consulta)`, ``, + `Estas ramas no pudieron consultarse. **No** son fondos sin gastar: simplemente no se sabe qué pasó con ellas. El alcance del informe es incompleto hasta que se repita el rastreo sobre ellas.`, ``, + ...failedBranches.map(f => `- [HECHO] ${f.address} — consulta fallida en tx ${f.txid.slice(0,16)}…, vout ${f.vout} (${f.amount} sats en juego)`), + ``, + ] : []), `## Conclusiones`, ``, ...conclusions.map((c,i) => `${i+1}. [${c.level}${c.certainty?"/"+c.certainty:""}] ${c.text}`), ``, @@ -5188,14 +5260,35 @@ {label:"Transacciones exploradas", v: trace.nodes.size}, {label:"Ramas pendientes", v: trace.frontier.length}, {label:"Ramas terminales", v: trace.unspentTerminals.length}, - ].map(({label,v})=>( + {label:"Consultas fallidas", v: (trace.queryErrors||[]).length, alert:(trace.queryErrors||[]).length>0}, + ].map(({label,v,alert})=>(