feat: informe de wallet (vinculación CIOH, reutilización, historial); fix: monitor v2 con caché TTL (resuelve CPU alta); mejora presentación de checks
This commit is contained in:
+297
-4
@@ -1285,6 +1285,85 @@
|
||||
return result;
|
||||
};
|
||||
|
||||
// ── Informe de wallet completo ────────────────────────────────────────
|
||||
// Analiza el conjunto de txs del wallet: vinculación (CIOH), reutilización
|
||||
// y un resumen de salud. Funciona sobre datos ya traídos, en memoria.
|
||||
function buildWalletReport(activeAddrs, txs, myAddrSet) {
|
||||
// 1) Reutilización: direcciones propias que aparecen en más de una tx
|
||||
const addrUseCount = new Map();
|
||||
for (const tx of txs) {
|
||||
const touched = new Set();
|
||||
for (const vin of (tx.vin||[])) {
|
||||
const a = vin.prevout?.scriptpubkey_address;
|
||||
if (a && myAddrSet.has(a)) touched.add(a);
|
||||
}
|
||||
for (const vout of (tx.vout||[])) {
|
||||
const a = vout.scriptpubkey_address;
|
||||
if (a && myAddrSet.has(a)) touched.add(a);
|
||||
}
|
||||
for (const a of touched) addrUseCount.set(a, (addrUseCount.get(a)||0) + 1);
|
||||
}
|
||||
const reusedAddrs = [...addrUseCount.entries()].filter(([,c]) => c > 2);
|
||||
// Nota: umbral 2 porque una dirección normal aparece en 2 txs como mínimo
|
||||
// (la que recibe y la que gasta). Más de 2 = reutilización real.
|
||||
|
||||
// 2) Clusters de vinculación por CIOH (common-input-ownership):
|
||||
// si una tx gasta varias direcciones propias como inputs, un observador
|
||||
// asume que pertenecen al mismo dueño. Union-Find sobre direcciones.
|
||||
const parent = new Map();
|
||||
const find = (x) => { while (parent.get(x) !== x) { parent.set(x, parent.get(parent.get(x))); x = parent.get(x); } return x; };
|
||||
const union = (a, b) => { const ra=find(a), rb=find(b); if (ra!==rb) parent.set(ra, rb); };
|
||||
for (const a of myAddrSet) parent.set(a, a);
|
||||
|
||||
const linkReasons = [];
|
||||
for (const tx of txs) {
|
||||
const ownInputs = (tx.vin||[])
|
||||
.map(v => v.prevout?.scriptpubkey_address)
|
||||
.filter(a => a && myAddrSet.has(a));
|
||||
const uniq = [...new Set(ownInputs)];
|
||||
if (uniq.length > 1) {
|
||||
for (let i = 1; i < uniq.length; i++) union(uniq[0], uniq[i]);
|
||||
linkReasons.push({ txid: tx.txid, addrs: uniq, reason: "compartieron inputs en una misma transacción (CIOH)" });
|
||||
}
|
||||
}
|
||||
|
||||
const clusterMap = new Map();
|
||||
const linked = new Set(linkReasons.flatMap(l => l.addrs));
|
||||
for (const a of linked) {
|
||||
const r = find(a);
|
||||
if (!clusterMap.has(r)) clusterMap.set(r, []);
|
||||
clusterMap.get(r).push(a);
|
||||
}
|
||||
const clusters = [...clusterMap.values()].filter(c => c.length > 1)
|
||||
.sort((a,b) => b.length - a.length);
|
||||
|
||||
// 3) Historial: cada tx con su banda
|
||||
const history = txs.map(tx => {
|
||||
let band = "—", score = null;
|
||||
try { const a = analyzeTx(tx); band = a.band; score = a.score; } catch {}
|
||||
return { txid: tx.txid, band, score, time: tx.status?.block_time || 0, confirmed: !!tx.status?.confirmed };
|
||||
}).sort((a,b) => b.time - a.time);
|
||||
|
||||
// 4) Salud general
|
||||
const totalTxs = txs.length;
|
||||
const reuseRatio = activeAddrs.length ? reusedAddrs.length / activeAddrs.length : 0;
|
||||
const biggestCluster = clusters.length ? clusters[0].length : 0;
|
||||
let healthBand, healthColor, healthMsg;
|
||||
if (reusedAddrs.length === 0 && clusters.length === 0) {
|
||||
healthBand = "ALTA"; healthColor = C.green;
|
||||
healthMsg = "No se detecta reutilización de direcciones ni vinculación evidente entre tus monedas.";
|
||||
} else if (reuseRatio < 0.25 && biggestCluster <= 3) {
|
||||
healthBand = "MEDIA"; healthColor = C.amber;
|
||||
healthMsg = "Hay algo de reutilización o vinculación, pero limitada. Revisa los clusters para entender qué monedas están conectadas.";
|
||||
} else {
|
||||
healthBand = "BAJA"; healthColor = C.red;
|
||||
healthMsg = "Tus monedas presentan vinculación significativa o reutilización frecuente. Un observador puede agrupar buena parte de tu actividad.";
|
||||
}
|
||||
|
||||
return { totalTxs, activeCount: activeAddrs.length, reusedAddrs, clusters, linkReasons, history,
|
||||
health: { band: healthBand, color: healthColor, msg: healthMsg } };
|
||||
}
|
||||
|
||||
function analyzeTx(tx) {
|
||||
const checks = [];
|
||||
const weights = {
|
||||
@@ -2255,11 +2334,15 @@
|
||||
</span>
|
||||
)}
|
||||
<CertaintyBadge level={check.certainty}/>
|
||||
{!check.informational&&!check.pass&&check.penalty>0&&<span style={{fontSize:"0.62rem",color:C.red,fontFamily:"monospace",marginLeft:4}}>−{check.penalty}</span>}
|
||||
</div>
|
||||
{expanded===check.id&&(
|
||||
<div style={{padding:"0 14px 12px 14px",borderTop:`1px solid ${C.border}`}}>
|
||||
<div style={{fontSize:"0.7rem",color:C.t2,lineHeight:1.6,paddingTop:10}}>{check.detail}</div>
|
||||
{!check.informational&&!check.pass&&check.penalty>0&&(
|
||||
<div style={{marginTop:6,fontSize:"0.62rem",color:C.t2,fontFamily:"monospace"}}>
|
||||
Peso en la banda: {check.penalty>=40?"alto":check.penalty>=20?"medio":"bajo"}
|
||||
</div>
|
||||
)}
|
||||
{check.didactic&&(
|
||||
<div style={{marginTop:8}}>
|
||||
<button onClick={e=>{e.stopPropagation();setShowDidactic(showDidactic===check.id?null:check.id);}}
|
||||
@@ -3761,6 +3844,66 @@
|
||||
return null;
|
||||
};
|
||||
|
||||
// ── Análisis de wallet completo ──────────────────────────────────────
|
||||
const [walletReport,setWalletReport]=useState(null);
|
||||
const [walletScanning,setWalletScanning]=useState(false);
|
||||
const [walletProgress,setWalletProgress]=useState("");
|
||||
|
||||
// Trae datos del nodo por lotes, con pausa, para no saturar Fulcrum
|
||||
const scanWallet = async () => {
|
||||
if (!walletAddrs || !base) return;
|
||||
setWalletScanning(true); setWalletReport(null);
|
||||
try {
|
||||
const all = [...walletAddrs.receive.map(a=>({addr:a,branch:"recepción"})),
|
||||
...walletAddrs.change.map(a=>({addr:a,branch:"cambio"}))];
|
||||
const BATCH = 5; // direcciones por lote
|
||||
const PAUSE = 120; // ms entre lotes
|
||||
const active = []; // direcciones con actividad
|
||||
|
||||
// Fase 1: estado ligero de cada dirección
|
||||
for (let i = 0; i < all.length; i += BATCH) {
|
||||
const slice = all.slice(i, i + BATCH);
|
||||
setWalletProgress(`Explorando direcciones ${Math.min(i+BATCH,all.length)}/${all.length}`);
|
||||
const infos = await Promise.all(slice.map(s =>
|
||||
get(`/api/address/${s.addr}`, null).catch(()=>null)
|
||||
));
|
||||
infos.forEach((info, j) => {
|
||||
const txc = info?.chain_stats?.tx_count || 0;
|
||||
if (txc > 0) active.push({ ...slice[j], info });
|
||||
});
|
||||
if (i + BATCH < all.length) await new Promise(r => setTimeout(r, PAUSE));
|
||||
}
|
||||
|
||||
// Fase 2: txs de las direcciones con actividad, con paginación
|
||||
// (la API devuelve ~25 txs por página; seguimos con /txs/chain/{last})
|
||||
const txMap = new Map();
|
||||
const myAddrSet = new Set(all.map(a => a.addr));
|
||||
for (let i = 0; i < active.length; i += BATCH) {
|
||||
const slice = active.slice(i, i + BATCH);
|
||||
setWalletProgress(`Trayendo transacciones ${Math.min(i+BATCH,active.length)}/${active.length}`);
|
||||
await Promise.all(slice.map(async s => {
|
||||
let page = await get(`/api/address/${s.addr}/txs`, []).catch(()=>[]);
|
||||
let guard = 0;
|
||||
while (Array.isArray(page) && page.length > 0 && guard < 8) {
|
||||
page.forEach(t => { if (t && t.txid) txMap.set(t.txid, t); });
|
||||
if (page.length < 25) break; // última página
|
||||
const last = page[page.length-1].txid;
|
||||
page = await get(`/api/address/${s.addr}/txs/chain/${last}`, []).catch(()=>[]);
|
||||
guard++;
|
||||
}
|
||||
}));
|
||||
if (i + BATCH < active.length) await new Promise(r => setTimeout(r, PAUSE));
|
||||
}
|
||||
|
||||
setWalletProgress("Analizando vinculación…");
|
||||
const report = buildWalletReport(active, [...txMap.values()], myAddrSet);
|
||||
setWalletReport(report);
|
||||
} catch(e) {
|
||||
setWalletReport({ error: e.message });
|
||||
}
|
||||
setWalletScanning(false); setWalletProgress("");
|
||||
};
|
||||
|
||||
const loadLabels = (file) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
@@ -3836,9 +3979,19 @@
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={clearWallet} style={{padding:"5px 12px",background:"none",border:`1px solid ${C.red}40`,borderRadius:6,color:C.red,fontFamily:"monospace",fontSize:"0.65rem",cursor:"pointer"}}>
|
||||
Borrar wallet
|
||||
</button>
|
||||
<div style={{display:"flex",gap:8,flexWrap:"wrap"}}>
|
||||
<button onClick={scanWallet} disabled={walletScanning||!base}
|
||||
title={!base?"Necesitas conectar tu nodo (CONFIG) para analizar el wallet":""}
|
||||
style={{padding:"6px 14px",background:C.purpleMuted,border:`1px solid ${C.purple}60`,borderRadius:6,color:C.purple,fontFamily:"monospace",fontSize:"0.65rem",cursor:"pointer",fontWeight:700}}>
|
||||
{walletScanning?"···":"⊙ Analizar wallet"}
|
||||
</button>
|
||||
<button onClick={clearWallet} style={{padding:"6px 14px",background:"none",border:`1px solid ${C.red}40`,borderRadius:6,color:C.red,fontFamily:"monospace",fontSize:"0.65rem",cursor:"pointer"}}>
|
||||
Borrar wallet
|
||||
</button>
|
||||
</div>
|
||||
{walletScanning&&walletProgress&&(
|
||||
<div style={{marginTop:8,fontSize:"0.62rem",color:C.t2,fontFamily:"monospace"}}>{walletProgress}</div>
|
||||
)}
|
||||
</div>
|
||||
):(
|
||||
<div style={{display:"flex",gap:8}}>
|
||||
@@ -3865,6 +4018,146 @@
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{walletReport&&!walletReport.error&&(
|
||||
<Card glow={C.purple}>
|
||||
{/* Cabecera del informe con exportación */}
|
||||
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:14}}>
|
||||
<div style={{fontSize:"0.65rem",color:C.purple,fontFamily:"monospace",fontWeight:700}}>INFORME DE WALLET</div>
|
||||
<div style={{display:"flex",gap:6}}>
|
||||
<button onClick={()=>{
|
||||
const {health,...rest}=walletReport;
|
||||
const exportData={...rest,health:{band:health.band,msg:health.msg}};
|
||||
const blob=new Blob([JSON.stringify(exportData,null,2)],{type:"application/json"});
|
||||
const a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download="wallet-report.json";a.click();
|
||||
}} style={{padding:"3px 10px",background:"none",border:`1px solid ${C.border}`,borderRadius:4,color:C.t2,fontFamily:"monospace",fontSize:"0.6rem",cursor:"pointer"}}>↓ JSON</button>
|
||||
<button onClick={()=>{
|
||||
const {health,totalTxs,activeCount,reusedAddrs,clusters,history}=walletReport;
|
||||
const lines=[
|
||||
`# Informe de Wallet — Txoko`,``,
|
||||
`## Salud general: ${health.band}`,``,health.msg,``,
|
||||
`- Transacciones analizadas: ${totalTxs}`,
|
||||
`- Direcciones con actividad: ${activeCount}`,
|
||||
`- Direcciones reutilizadas: ${reusedAddrs.length}`,
|
||||
`- Clusters de vinculación: ${clusters.length}`,``,
|
||||
`## Clusters de vinculación`,``,
|
||||
...(clusters.length===0?[`No se detectan clusters.`]:clusters.flatMap((c,i)=>[
|
||||
`### Cluster ${i+1} — ${c.length} direcciones vinculadas`,
|
||||
...c.map(a=>`- ${a}`),``
|
||||
])),``,
|
||||
`## Historial (${history.length} transacciones)`,``,
|
||||
...history.slice(0,20).map(t=>`- ${t.txid.slice(0,16)}… | ${t.band} | ${t.time?new Date(t.time*1000).toLocaleDateString("es-ES"):"sin confirmar"}`),
|
||||
];
|
||||
const blob=new Blob([lines.join("\n")],{type:"text/markdown"});
|
||||
const a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download="wallet-report.md";a.click();
|
||||
}} style={{padding:"3px 10px",background:"none",border:`1px solid ${C.border}`,borderRadius:4,color:C.t2,fontFamily:"monospace",fontSize:"0.6rem",cursor:"pointer"}}>↓ MD</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bloque 1: Salud general */}
|
||||
<div style={{display:"flex",alignItems:"center",gap:14,padding:"12px 14px",background:C.bgCard,borderRadius:8,marginBottom:14,border:`1px solid ${walletReport.health.color}30`}}>
|
||||
<div style={{width:48,height:48,borderRadius:"50%",border:`3px solid ${walletReport.health.color}`,display:"flex",alignItems:"center",justifyContent:"center",flexShrink:0}}>
|
||||
<span style={{fontSize:"0.6rem",fontFamily:"monospace",fontWeight:700,color:walletReport.health.color}}>{walletReport.health.band}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{fontSize:"0.72rem",color:walletReport.health.color,fontWeight:700,marginBottom:4}}>
|
||||
{walletReport.health.band==="ALTA"?"Wallet bien compartimentado":walletReport.health.band==="MEDIA"?"Vinculación parcial detectada":"Vinculación significativa"}
|
||||
</div>
|
||||
<div style={{fontSize:"0.65rem",color:C.t2,lineHeight:1.5}}>{walletReport.health.msg}</div>
|
||||
<div style={{display:"flex",gap:16,marginTop:8}}>
|
||||
{[
|
||||
{label:"Txs",v:walletReport.totalTxs},
|
||||
{label:"Activas",v:walletReport.activeCount},
|
||||
{label:"Reutilizadas",v:walletReport.reusedAddrs.length,warn:walletReport.reusedAddrs.length>0},
|
||||
{label:"Clusters",v:walletReport.clusters.length,warn:walletReport.clusters.length>0},
|
||||
].map(({label,v,warn})=>(
|
||||
<div key={label}>
|
||||
<div style={{fontSize:"0.55rem",color:C.t2,fontFamily:"monospace"}}>{label}</div>
|
||||
<div style={{fontSize:"0.8rem",fontFamily:"monospace",fontWeight:700,color:warn?C.amber:C.t1}}>{v}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bloque 2: Clusters de vinculación */}
|
||||
{walletReport.clusters.length>0&&(
|
||||
<div style={{marginBottom:14}}>
|
||||
<div style={{fontSize:"0.6rem",color:C.amber,fontFamily:"monospace",fontWeight:700,marginBottom:8,letterSpacing:"0.15em"}}>CLUSTERS DE VINCULACIÓN</div>
|
||||
<div style={{display:"flex",flexDirection:"column",gap:8}}>
|
||||
{walletReport.clusters.map((cluster,i)=>{
|
||||
const reasons=walletReport.linkReasons.filter(r=>r.addrs.some(a=>cluster.includes(a)));
|
||||
const barW=Math.min(100,Math.round((cluster.length/Math.max(...walletReport.clusters.map(c=>c.length)))*100));
|
||||
return (
|
||||
<div key={i} style={{padding:"10px 12px",background:C.bgCard,border:`1px solid ${C.amber}30`,borderRadius:6}}>
|
||||
<div style={{display:"flex",alignItems:"center",gap:10,marginBottom:6}}>
|
||||
<div style={{fontSize:"0.62rem",color:C.amber,fontFamily:"monospace",fontWeight:600}}>Cluster {i+1}</div>
|
||||
<div style={{flex:1,height:4,background:C.bg,borderRadius:2,overflow:"hidden"}}>
|
||||
<div style={{width:`${barW}%`,height:"100%",background:C.amber,borderRadius:2}}/>
|
||||
</div>
|
||||
<div style={{fontSize:"0.62rem",color:C.t2,fontFamily:"monospace"}}>{cluster.length} dirs.</div>
|
||||
</div>
|
||||
{reasons.length>0&&(
|
||||
<div style={{fontSize:"0.6rem",color:C.t2,marginBottom:6,lineHeight:1.4}}>
|
||||
Vinculadas porque {reasons[0].reason}
|
||||
{reasons.length>1&&<span> (+{reasons.length-1} más)</span>}
|
||||
</div>
|
||||
)}
|
||||
<div style={{display:"flex",flexDirection:"column",gap:2}}>
|
||||
{cluster.slice(0,4).map((a,j)=>(
|
||||
<div key={j} style={{fontSize:"0.58rem",color:C.t1,fontFamily:"monospace"}}>
|
||||
{labels?.get(a)&&<span style={{color:C.purple,marginRight:6}}>🏷 {labels.get(a)}</span>}
|
||||
{a.slice(0,14)}…{a.slice(-8)}
|
||||
</div>
|
||||
))}
|
||||
{cluster.length>4&&<div style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace"}}>+{cluster.length-4} más</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{walletReport.clusters.length===0&&(
|
||||
<div style={{marginBottom:14,padding:"10px 12px",background:C.bgCard,border:`1px solid ${C.green}30`,borderRadius:6,fontSize:"0.65rem",color:C.green,fontFamily:"monospace"}}>
|
||||
✓ No se detectan clusters de vinculación entre tus monedas.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bloque 3: Historial */}
|
||||
{walletReport.history.length>0&&(
|
||||
<div>
|
||||
<div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",fontWeight:700,marginBottom:8,letterSpacing:"0.15em"}}>HISTORIAL — {walletReport.history.length} TRANSACCIONES</div>
|
||||
<div style={{display:"flex",flexDirection:"column",gap:4,maxHeight:320,overflowY:"auto"}}>
|
||||
{walletReport.history.map((t,i)=>{
|
||||
const bandColor=t.band==="ALTA"?C.green:t.band==="MEDIA"?C.amber:t.band==="BAJA"?C.red:C.t2;
|
||||
return (
|
||||
<div key={i} onClick={()=>{setQuery(t.txid);setShowWalletPanel(false);setTimeout(()=>analyze(t.txid),50);}}
|
||||
style={{display:"flex",alignItems:"center",gap:10,padding:"7px 10px",background:C.bgCard,borderRadius:5,cursor:"pointer",border:`1px solid ${C.border}`,transition:"border-color 0.15s"}}
|
||||
onMouseEnter={e=>e.currentTarget.style.borderColor=bandColor}
|
||||
onMouseLeave={e=>e.currentTarget.style.borderColor=C.border}>
|
||||
<div style={{width:6,height:6,borderRadius:"50%",background:bandColor,flexShrink:0}}/>
|
||||
<div style={{fontSize:"0.58rem",color:C.t2,fontFamily:"monospace",minWidth:70}}>
|
||||
{t.time?new Date(t.time*1000).toLocaleDateString("es-ES",{day:"2-digit",month:"2-digit",year:"2-digit"}):"pendiente"}
|
||||
</div>
|
||||
<div style={{fontSize:"0.6rem",color:C.t1,fontFamily:"monospace",flex:1,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>
|
||||
{labels?.get(t.txid)&&<span style={{color:C.purple,marginRight:6}}>🏷</span>}
|
||||
{t.txid.slice(0,20)}…
|
||||
</div>
|
||||
<div style={{fontSize:"0.58rem",color:bandColor,fontFamily:"monospace",fontWeight:600,flexShrink:0}}>{t.band||"—"}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
{walletReport?.error&&(
|
||||
<Card glow={C.red}>
|
||||
<div style={{fontSize:"0.65rem",color:C.red,fontFamily:"monospace"}}>Error al analizar el wallet: {walletReport.error}</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{showLabelModal&&(
|
||||
<Card glow={C.purple}>
|
||||
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:10}}>
|
||||
|
||||
Reference in New Issue
Block a user