fix: el peritaje ya no presenta un fallo de consulta como rastro completo

Detectado probando contra el nodo real: un 503 del backend se mostraba como
'Rastro completo' y contaba la rama como terminal, porque get() devolvía
mockData ante cualquier error y findSpendingTx remataba con .catch(()=>[]).
Un error de red quedaba así indistinguible de 'output sin gastar' — la
conclusión más accionable de un informe pericial, afirmada sobre una consulta
que nunca respondió.

- getStrict en useApi propaga el fallo; get se mantiene igual para el resto
- el motor registra los fallos en trace.queryErrors y no los cuenta como
  fondos sin gastar
- el informe los lista aparte y abre las conclusiones avisando del alcance
  incompleto (UI, JSON y MD)
- la UI muestra el detalle del fallo en vez del falso rastro completo
This commit is contained in:
Aitor
2026-07-27 12:38:07 +02:00
parent 4d242a6ac6
commit c2fb4f1388
2 changed files with 171 additions and 26 deletions
+28
View File
@@ -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 - **MENOR** — características nuevas que no rompen lo anterior
- **PARCHE** — arreglos de errores - **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 ## [1.10.0] — 2026-07-16
### Añadido ### Añadido
+142 -25
View File
@@ -2063,7 +2063,19 @@
return res.json(); return res.json();
} catch { return mockData; } } catch { return mockData; }
}, [baseUrl]); }, [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 = { const fmt = {
@@ -3785,16 +3797,22 @@
// (verificado contra el nodo real — ver TRASPASO.md), así que no hay forma // (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 // directa de preguntar "¿quién gasta esto?". Mismo patrón de paginación
// que scanWallet: página de 25 + /txs/chain/{last}. // 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; maxPages = maxPages || 8;
let page = await get(`/api/address/${addr}/txs`, []).catch(()=>[]); let page = await getStrict(`/api/address/${addr}/txs`);
let guard = 0; let guard = 0;
while (Array.isArray(page) && page.length > 0 && guard < maxPages) { while (Array.isArray(page) && page.length > 0 && guard < maxPages) {
const found = page.find(t => (t.vin||[]).some(v => v.txid === txid && v.vout === vout)); const found = page.find(t => (t.vin||[]).some(v => v.txid === txid && v.vout === vout));
if (found) return found; if (found) return found;
if (page.length < 25) break; // última página, no hay más que mirar if (page.length < 25) break; // última página, no hay más que mirar
const last = page[page.length-1].txid; 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++; guard++;
} }
return null; return null;
@@ -3865,20 +3883,29 @@
// //
// Crea el estado de un rastreo forense nuevo, sin ejecutar ningún salto // Crea el estado de un rastreo forense nuevo, sin ejecutar ningún salto
// todavía. // todavía.
async function initForensicTrace({ get, originTxid, originVout, amountStolen }) { async function initForensicTrace({ get, getStrict, originTxid, originVout, amountStolen }) {
const originTx = await get(`/api/tx/${originTxid}`, null).catch(()=>null); // 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."); if (!originTx) throw new Error("No se pudo obtener la transacción de origen. Comprueba el txid.");
const originOut = originTx.vout?.[originVout]; const originOut = originTx.vout?.[originVout];
if (!originOut) throw new Error("El vout indicado no existe en la transacción de origen."); if (!originOut) throw new Error("El vout indicado no existe en la transacción de origen.");
const originFingerprint = detectWallets(originTx); const originFingerprint = detectWallets(originTx);
return { return {
get, originTxid, originVout, amountStolen: amountStolen || null, get, getStrict, originTxid, originVout, amountStolen: amountStolen || null,
originTx, originOut, originFingerprint, originTx, originOut, originFingerprint,
txCache: new Map([[originTxid, originTx]]), txCache: new Map([[originTxid, originTx]]),
nodes: new Map(), // txid -> ForensicNode nodes: new Map(), // txid -> ForensicNode
edges: [], // ForensicEdge[] 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 }], frontier: [{ txid: originTxid, vout: originVout, amount: originOut.value, hop: 0, parentTxid: originTxid }],
enqueuedKeys: new Set(), enqueuedKeys: new Set(),
hop: 0, done: false, hop: 0, done: false,
@@ -3912,7 +3939,7 @@
// cuesta peticiones extra: chain_stats.tx_count ya viene en el perfil // 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. // que se pide de todos modos para cada dirección de salida nueva.
const LARGE_ADDR_TX_COUNT = 5000; 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; } if (trace.frontier.length === 0) { trace.done = true; return trace; }
@@ -3938,7 +3965,17 @@
return; 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) { if (!spendTx) {
unspentTerminals.push({ txid, vout, address:addr, amount, note:"UTXO sin gastar." }); unspentTerminals.push({ txid, vout, address:addr, amount, note:"UTXO sin gastar." });
return; return;
@@ -3966,10 +4003,18 @@
const outAddrTxs = {}; const outAddrTxs = {};
const addressProfiles = {}; const addressProfiles = {};
await Promise.all(outAddrs.map(async a => { await Promise.all(outAddrs.map(async a => {
const [info, txs] = await Promise.all([ let info = null, txs = [];
get(`/api/address/${a}`, null).catch(()=>null), try {
get(`/api/address/${a}/txs`, []).catch(()=>[]), [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 || []; outAddrTxs[a] = txs || [];
if (info) addressProfiles[a] = addressProfile(a, info, txs||[]); if (info) addressProfiles[a] = addressProfile(a, info, txs||[]);
})); }));
@@ -4071,7 +4116,7 @@
// partir de un `trace`, completo o parcial — se puede llamar en // partir de un `trace`, completo o parcial — se puede llamar en
// cualquier momento del rastreo a demanda, no solo al terminar. // cualquier momento del rastreo a demanda, no solo al terminar.
function finalizeForensicGraph(trace) { 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 // CIOH: semilla = origen + toda dirección de entrada de cualquier
// salto (por construcción, llegó ahí siguiendo los fondos) + direcciones // salto (por construcción, llegó ahí siguiendo los fondos) + direcciones
@@ -4101,6 +4146,7 @@
}, },
amountStolen: amountStolen || null, amountStolen: amountStolen || null,
nodes, edges, unspentTerminals, clusters, linkReasons, nodes, edges, unspentTerminals, clusters, linkReasons,
queryErrors: queryErrors || [],
peelingChains: detectPeelingChains(nodes, edges), peelingChains: detectPeelingChains(nodes, edges),
}; };
} }
@@ -4110,8 +4156,8 @@
// Se mantiene por compatibilidad — la UI a demanda usa // Se mantiene por compatibilidad — la UI a demanda usa
// initForensicTrace + advanceForensicHop directamente, con el usuario // initForensicTrace + advanceForensicHop directamente, con el usuario
// decidiendo cuándo llamar a cada salto en vez de este bucle. // decidiendo cuándo llamar a cada salto en vez de este bucle.
async function buildForensicGraph({ get, originTxid, originVout, amountStolen, maxHops, onProgress }) { async function buildForensicGraph({ get, getStrict, originTxid, originVout, amountStolen, maxHops, onProgress }) {
const trace = await initForensicTrace({ get, originTxid, originVout, amountStolen }); const trace = await initForensicTrace({ get, getStrict, originTxid, originVout, amountStolen });
while (!trace.done) { while (!trace.done) {
await advanceForensicHop(trace, { maxHops, onProgress }); await advanceForensicHop(trace, { maxHops, onProgress });
} }
@@ -4125,6 +4171,7 @@
// afectado sin corroborar — nunca se mezcla con las otras dos. // afectado sin corroborar — nunca se mezcla con las otras dos.
function buildForensicReport(graph, declaracionText) { function buildForensicReport(graph, declaracionText) {
const { origin, nodes, unspentTerminals, clusters, peelingChains, amountStolen } = graph; 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 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 maxHop = allNodes.reduce((m,n)=>Math.max(m,n.hop), 0);
const addressesTouched = new Set([origin.address].filter(Boolean)); const addressesTouched = new Set([origin.address].filter(Boolean));
@@ -4192,14 +4239,32 @@
} }
// ── 5. Fondos localizados sin gastar ──────────────────────────────── // ── 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 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] })); .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 truncatedBranches = unspentTerminals.filter(u => u.truncated);
const failedBranches = unspentTerminals.filter(u => u.queryFailed);
// ── 6. Conclusiones numeradas ──────────────────────────────────────── // ── 6. Conclusiones numeradas ────────────────────────────────────────
const conclusions = []; 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] }); 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) { 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() }); 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], 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. // Eslabón recursivo: muestra una tx de origen y permite seguir SUS inputs.
@@ -5014,7 +5079,7 @@
// ── PERITAJE ───────────────────────────────────────────────────────── // ── PERITAJE ─────────────────────────────────────────────────────────
function PeritajeForense({base}) { function PeritajeForense({base}) {
const {get} = useApi(base); const {get, getStrict} = useApi(base);
const [txidInput, setTxidInput] = useState(""); const [txidInput, setTxidInput] = useState("");
const [voutInput, setVoutInput] = useState("0"); const [voutInput, setVoutInput] = useState("0");
const [amountInput, setAmountInput] = useState(""); const [amountInput, setAmountInput] = useState("");
@@ -5051,7 +5116,7 @@
setError(null); setBusy(true); setReport(null); setError(null); setBusy(true); setReport(null);
try { try {
const amountStolen = amountInput.trim() ? Math.round(parseFloat(amountInput)*1e8) : null; 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; traceRef.current = trace;
setTick(t => t+1); setTick(t => t+1);
await avanzarSalto(trace); // "iniciar" ya explora el primer salto — un solo clic para ver algo 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, summary: report.summary, declaracion: report.declaracion,
chronology: report.chronology, attributed: report.attributed, chronology: report.chronology, attributed: report.attributed,
unspentFunds: report.unspentFunds, conclusions: report.conclusions, unspentFunds: report.unspentFunds, conclusions: report.conclusions,
failedBranches: report.failedBranches, queryErrors: report.queryErrors,
recommendations: report.recommendations, methodology: report.methodology, recommendations: report.recommendations, methodology: report.methodology,
verificationAppendix: report.verificationAppendix, verificationAppendix: report.verificationAppendix,
}; };
@@ -5091,7 +5157,7 @@
}; };
const exportMD = () => { 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 = [ const lines = [
`# Peritaje forense — Txoko`, ``, `# Peritaje forense — Txoko`, ``,
`## Resumen`, ``, `## Resumen`, ``,
@@ -5113,6 +5179,12 @@
...(unspentFunds.length===0 ? ["Ninguno detectado en las ramas exploradas."] : ...(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})`)), 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`, ``, `## Conclusiones`, ``,
...conclusions.map((c,i) => `${i+1}. [${c.level}${c.certainty?"/"+c.certainty:""}] ${c.text}`), ...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:"Transacciones exploradas", v: trace.nodes.size},
{label:"Ramas pendientes", v: trace.frontier.length}, {label:"Ramas pendientes", v: trace.frontier.length},
{label:"Ramas terminales", v: trace.unspentTerminals.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})=>(
<div key={label}> <div key={label}>
<div style={{fontSize:"0.55rem",color:C.t2,fontFamily:"monospace"}}>{label}</div> <div style={{fontSize:"0.55rem",color:C.t2,fontFamily:"monospace"}}>{label}</div>
<div style={{fontSize:"0.8rem",fontFamily:"monospace",fontWeight:700,color:C.t1}}>{v}</div> <div style={{fontSize:"0.8rem",fontFamily:"monospace",fontWeight:700,color:alert?C.red:C.t1}}>{v}</div>
</div> </div>
))} ))}
</div> </div>
{trace.done?( {/* Un rastreo con consultas fallidas NO está completo: decir
"no quedan ramas" cuando el nodo falló convierte un error en
una conclusión. */}
{trace.done&&(trace.queryErrors||[]).length>0?(
<div style={{padding:"9px 12px",background:C.redMuted,border:`1px solid ${C.red}40`,borderRadius:6,marginBottom:10}}>
<div style={{fontSize:"0.65rem",color:C.red,fontFamily:"monospace",fontWeight:700,marginBottom:4}}>
⚠ Rastro incompleto — {trace.queryErrors.length} consulta(s) al nodo fallaron
</div>
<div style={{fontSize:"0.62rem",color:C.t1,fontFamily:"monospace",lineHeight:1.6}}>
No quedan ramas por explorar, pero algunas no se pudieron comprobar: no se sabe si esos fondos siguen sin gastar o se movieron. Suele ser transitorio (nodo ocupado o dirección con mucho historial) — pulsa "Limpiar" y repite el rastreo.
</div>
<div style={{marginTop:6,display:"flex",flexDirection:"column",gap:2}}>
{trace.queryErrors.slice(0,5).map((q,i)=>(
<div key={i} style={{fontSize:"0.56rem",color:C.t2,fontFamily:"monospace",wordBreak:"break-all"}}>
{q.address} — {q.message} ({q.context})
</div>
))}
{trace.queryErrors.length>5&&<div style={{fontSize:"0.56rem",color:C.t2,fontFamily:"monospace"}}>…y {trace.queryErrors.length-5} más</div>}
</div>
</div>
):trace.done?(
<div style={{fontSize:"0.65rem",color:C.green,fontFamily:"monospace",marginBottom:10}}>✓ Rastro completo — no quedan ramas por explorar.</div> <div style={{fontSize:"0.65rem",color:C.green,fontFamily:"monospace",marginBottom:10}}>✓ Rastro completo — no quedan ramas por explorar.</div>
):( ):(
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",marginBottom:10}}> <div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",marginBottom:10}}>
@@ -5210,7 +5303,8 @@
</button> </button>
)} )}
<button onClick={generarInforme} disabled={busy||trace.nodes.size===0} <button onClick={generarInforme} disabled={busy||trace.nodes.size===0}
style={{padding:"8px 16px",background:C.purpleMuted,border:`1px solid ${C.purple}50`,borderRadius:6,color:C.purple,fontFamily:"monospace",fontSize:"0.68rem",cursor:"pointer",fontWeight:700}}> title={trace.nodes.size===0?"Aún no hay ninguna transacción explorada que incluir en el informe":""}
style={{padding:"8px 16px",background:C.purpleMuted,border:`1px solid ${C.purple}50`,borderRadius:6,color:C.purple,fontFamily:"monospace",fontSize:"0.68rem",cursor:trace.nodes.size===0?"not-allowed":"pointer",fontWeight:700,opacity:trace.nodes.size===0?0.45:1}}>
{trace.done?"Generar informe":"Generar informe con lo explorado hasta ahora"} {trace.done?"Generar informe":"Generar informe con lo explorado hasta ahora"}
</button> </button>
<button onClick={limpiar} disabled={busy} <button onClick={limpiar} disabled={busy}
@@ -5218,6 +5312,11 @@
Limpiar Limpiar
</button> </button>
</div> </div>
{trace.nodes.size===0&&!busy&&(
<div style={{marginTop:8,fontSize:"0.6rem",color:C.t2,fontFamily:"monospace"}}>
El informe se activa en cuanto haya al menos una transacción explorada.
</div>
)}
{busy&&progress&&<div style={{marginTop:8,fontSize:"0.62rem",color:C.t2,fontFamily:"monospace"}}>{progress}</div>} {busy&&progress&&<div style={{marginTop:8,fontSize:"0.62rem",color:C.t2,fontFamily:"monospace"}}>{progress}</div>}
</Card> </Card>
)} )}
@@ -5328,6 +5427,24 @@
)} )}
</div> </div>
{report.failedBranches&&report.failedBranches.length>0&&(
<div style={{marginBottom:14,padding:"10px 12px",background:C.redMuted,border:`1px solid ${C.red}40`,borderRadius:6}}>
<div style={{fontSize:"0.62rem",color:C.red,fontFamily:"monospace",fontWeight:700,marginBottom:6,letterSpacing:"0.1em"}}>
RAMAS NO COMPROBADAS — {report.failedBranches.length}
</div>
<div style={{fontSize:"0.63rem",color:C.t1,fontFamily:"monospace",lineHeight:1.6,marginBottom:8}}>
La consulta al nodo falló en estas ramas. <strong>No son fondos sin gastar</strong> — no se sabe qué pasó con ellas. El alcance de este informe es incompleto hasta repetir el rastreo sobre ellas.
</div>
<div style={{display:"flex",flexDirection:"column",gap:4}}>
{report.failedBranches.map((f,i)=>(
<div key={i} style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace",wordBreak:"break-all"}}>
{f.address} · {fmt.num(f.amount)} sat · tx {f.txid.slice(0,16)}…:{f.vout}
</div>
))}
</div>
</div>
)}
{report.truncatedBranches.length>0&&( {report.truncatedBranches.length>0&&(
<div style={{marginBottom:14,padding:"10px 12px",background:C.amberMuted,border:`1px solid ${C.amber}30`,borderRadius:6}}> <div style={{marginBottom:14,padding:"10px 12px",background:C.amberMuted,border:`1px solid ${C.amber}30`,borderRadius:6}}>
<div style={{fontSize:"0.65rem",color:C.amber,fontFamily:"monospace"}}> <div style={{fontSize:"0.65rem",color:C.amber,fontFamily:"monospace"}}>