Compare commits

..
2 Commits
Author SHA1 Message Date
Aitor 728ba655ee feat: auditoría de PSBT — revisar una transacción antes de firmarla
Primera pieza de la app que llega a tiempo: todo lo demás es diagnóstico de lo
que ya pasó. Sustituye al validador anterior, que solo comprobaba que el
archivo empezara por 'psbt'.

Parser BIP174 completo escrito desde cero, validado contra los cinco vectores
inválidos del estándar (los rechaza los cinco con mensajes en castellano) y
contra PSBTs reales de Sparrow.

Todo offline: la PSBT ya trae los importes y scripts de sus entradas, así que
no hace falta el nodo — funciona con él sincronizando y sin que se entere.

El aviso más valioso es el de los xpubs incrustados: las PSBT llevan dentro las
claves maestras de la cartera y están hechas para compartirse, así que quien
reciba el archivo puede ver todas las direcciones, el saldo y el historial.
No he encontrado ningún wallet que avise de esto.
2026-07-27 14:44:26 +02:00
Aitor b57c44e5c0 fix: el informe de wallet avisa cuando el escaneo quedó incompleto
Mismo patrón que el fallo del peritaje, en la pieza central del proyecto.
scanWallet usaba .catch(()=>[]) en sus tres consultas, así que una dirección
que el nodo no pudo servir era indistinguible de una sin actividad: sus txs no
se traían, no contaban para reutilización ni clusters, y el informe daba su
valoración sin mencionar que faltaban datos.

El sesgo iba siempre al optimismo — menos actividad vista, mejor nota. Ahora
se registran los fallos y se avisa antes de la banda de salud, en la UI y en
el export MD.
2026-07-27 14:06:08 +02:00
2 changed files with 398 additions and 36 deletions
+44
View File
@@ -10,6 +10,36 @@ 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.12.0] — 2026-07-27
### Añadido
- **Auditoría de PSBT: revisar una transacción ANTES de firmarla.** Hasta ahora
toda la app era diagnóstico *a posteriori* — te contaba con detalle lo que ya
había pasado y no podías cambiar. Esta es la primera pieza que llega a tiempo.
Sustituye al antiguo "validador", que solo comprobaba que el archivo empezara
por `psbt` y decía su tamaño.
- **Parser completo de BIP174 escrito desde cero**, sin librerías, como el
resto del proyecto. Validado contra los cinco vectores inválidos que publica
el propio estándar: los rechaza los cinco, cada uno con un mensaje que
explica en castellano qué está mal (truncada, sin salidas, ya firmada,
sin transacción interna, con claves repetidas).
- **Todo el análisis es offline.** Una PSBT bien formada ya lleva dentro los
importes y scripts de sus entradas, así que no hace falta consultar el nodo:
funciona con el nodo sincronizando, y el nodo ni se entera.
- Acepta base64, hexadecimal o abrir el archivo `.psbt` directamente.
- **Aviso crítico sobre xpubs incrustados.** Las PSBT suelen llevar dentro las
claves públicas maestras de la cartera, y están hechas para compartirse — se
mandan por correo o chat para que el resto firme. Quien reciba el archivo
puede derivar todas las direcciones, presentes y futuras, y ver el saldo y
el historial completos. No puede gastar, pero lo ve todo. Ningún wallet
avisa de esto.
- Detecta además: cambio identificable por tipo de dirección, importes
redondos que delatan cuál es el pago, envíos a la propia cartera, carteras
multifirma (con su M-de-N) y PSBTs incompletas sin los importes de entrada.
- Cada aviso mantiene el formato del resto de la app — Hecho, Consecuencia y
**Qué puedes hacer** —, que aquí cobra sentido literal: todavía estás a
tiempo de cambiar la transacción.
---
## [1.11.0] — 2026-07-27
### Añadido
@@ -103,6 +133,20 @@ y el versionado sigue [Versionado Semántico](https://semver.org/lang/es/):
`mixer` del conjunto de direcciones del actor y del union-find, y la
atribución por huella salta esos nodos.
- Verificado con el mismo caso: de 10 direcciones atribuidas a 0.
- **El informe de wallet ya no da un aprobado optimista cuando faltan datos.**
Mismo patrón que el fallo del peritaje, en la pieza central del proyecto:
`scanWallet` usaba `.catch(()=>[])` en las tres consultas del escaneo, así que
una dirección que el nodo no pudo servir quedaba indistinguible de una
dirección sin actividad. Sus transacciones no se traían, no contaban para la
reutilización, no entraban en el union-find y no aparecían en el historial —
y el informe daba su valoración de salud sin mencionar que le faltaban datos.
El sesgo iba siempre hacia el optimismo: menos actividad vista es mejor nota.
- Las tres consultas usan ahora `getStrict` y registran los fallos en
`report.scanErrors`, con la dirección, la rama y en qué fase ocurrió.
- Aviso en rojo **antes** de la banda de salud (una valoración leída sin saber
que faltan datos es peor que ninguna valoración), y bloque equivalente al
principio del export Markdown.
- **Tampoco atribuye al actor las direcciones de un custodio.** Misma raíz que
el fallo anterior, descubierta probando la poda por rama con un depósito real
en Bitfinex: el cluster CIOH ya excluía las direcciones de custodio, pero la
+349 -31
View File
@@ -4748,7 +4748,7 @@
// ── AUDITORÍA ──────────────────────────────────────────────────────────
function Auditoria({base, initialQuery}) {
const {get}=useApi(base);
const {get, getStrict}=useApi(base);
const [query,setQuery]=useState(initialQuery||"");
const [tx,setTx]=useState(null);
const [analysis,setAnalysis]=useState(null);
@@ -4806,14 +4806,22 @@
const BATCH = 5; // direcciones por lote
const PAUSE = 120; // ms entre lotes
const active = []; // direcciones con actividad
// Consultas que el nodo no pudo servir. Se registran porque su
// ausencia SESGA el informe hacia el optimismo: una dirección que no
// se pudo leer parece una dirección sin actividad, y entonces su
// reutilización, sus vinculaciones y sus transacciones desaparecen
// del análisis. Decir "tu wallet está limpia" cuando en realidad
// faltan datos es peor que no decir nada.
const scanErrors = [];
// 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)
));
const infos = await Promise.all(slice.map(async s => {
try { return await getStrict(`/api/address/${s.addr}`); }
catch (e) { scanErrors.push({ addr:s.addr, branch:s.branch, message:e.message, phase:"estado de la dirección" }); return null; }
}));
infos.forEach((info, j) => {
const txc = info?.chain_stats?.tx_count || 0;
if (txc > 0) active.push({ ...slice[j], info });
@@ -4829,21 +4837,30 @@
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(()=>[]);
try {
let page = await getStrict(`/api/address/${s.addr}/txs`);
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(()=>[]);
page = await getStrict(`/api/address/${s.addr}/txs/chain/${last}`);
guard++;
}
} catch (e) {
// Perder el historial de una dirección con actividad conocida
// es lo más grave: sabemos que tiene transacciones y no hemos
// podido verlas. El informe queda incompleto por ahí.
scanErrors.push({ addr:s.addr, branch:s.branch, message:e.message, phase:"historial de transacciones", txCount:s.info?.chain_stats?.tx_count ?? null });
}
}));
if (i + BATCH < active.length) await new Promise(r => setTimeout(r, PAUSE));
}
setWalletProgress("Analizando vinculación…");
const report = buildWalletReport(active, [...txMap.values()], myAddrSet);
report.scanErrors = scanErrors;
report.scanComplete = scanErrors.length === 0;
setWalletReport(report);
} catch(e) {
setWalletReport({ error: e.message });
@@ -4978,9 +4995,16 @@
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 {health,totalTxs,activeCount,reusedAddrs,clusters,history,scanErrors}=walletReport;
const lines=[
`# Informe de Wallet — Txoko`,``,
...((scanErrors&&scanErrors.length>0)?[
`> **AVISO — informe incompleto.** ${scanErrors.length} consulta(s) al nodo fallaron durante el escaneo.`,
`> Las direcciones no leídas se comportan como si no tuvieran actividad, así que la valoración`,
`> de abajo es **optimista**: puede faltar reutilización, vinculaciones o transacciones enteras.`,
`> Repite el escaneo antes de darla por buena.`,``,
...scanErrors.map(e=>`> - ${e.addr} (${e.branch}) — ${e.message} [${e.phase}]`),``,
]:[]),
`## Salud general: ${health.band}`,``,health.msg,``,
`- Transacciones analizadas: ${totalTxs}`,
`- Direcciones con actividad: ${activeCount}`,
@@ -5000,6 +5024,32 @@
</div>
</div>
{/* Aviso de escaneo incompleto — va ANTES de la banda a
propósito: una valoración de salud leída sin saber que
faltan datos es peor que no tener valoración. El sesgo
siempre va hacia el optimismo, porque una dirección no
leída parece una dirección sin actividad. */}
{walletReport.scanErrors&&walletReport.scanErrors.length>0&&(
<div style={{padding:"10px 14px",background:C.redMuted,border:`1px solid ${C.red}40`,borderRadius:8,marginBottom:14}}>
<div style={{fontSize:"0.66rem",color:C.red,fontFamily:"monospace",fontWeight:700,marginBottom:6}}>
⚠ Informe incompleto — {walletReport.scanErrors.length} consulta(s) al nodo fallaron
</div>
<div style={{fontSize:"0.64rem",color:C.t1,lineHeight:1.6,marginBottom:8}}>
Las direcciones que no se pudieron leer se comportan aquí como si no tuvieran actividad. Eso hace que la valoración de abajo sea <strong>optimista</strong>: puede faltar reutilización, vinculaciones o transacciones enteras que sí existen. No la des por buena sin repetir el escaneo.
</div>
<div style={{display:"flex",flexDirection:"column",gap:2}}>
{walletReport.scanErrors.slice(0,6).map((e,i)=>(
<div key={i} style={{fontSize:"0.56rem",color:C.t2,fontFamily:"monospace",wordBreak:"break-all"}}>
{e.addr} ({e.branch}) — {e.message} · {e.phase}
</div>
))}
{walletReport.scanErrors.length>6&&(
<div style={{fontSize:"0.56rem",color:C.t2,fontFamily:"monospace"}}>…y {walletReport.scanErrors.length-6} más</div>
)}
</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}}>
@@ -5988,51 +6038,319 @@
);
}
// ── Decodificador PSBT ─────────────────────────────────────────────────
// ── Parser PSBT (BIP174) ───────────────────────────────────────────────
// Escrito desde cero, sin librerías, como el resto del proyecto. Validado
// contra los cinco vectores inválidos del propio BIP174 (los rechaza los
// cinco) y contra PSBTs reales de Sparrow.
//
// Todo el análisis es OFFLINE: una PSBT bien formada ya lleva dentro los
// importes y scripts de sus entradas, así que no hace falta consultar el
// nodo. Eso significa que puedes auditar una transacción antes de firmarla
// aunque el nodo esté sincronizando — y que el nodo no se entera.
function parsePsbt(bytes) {
let p = 0;
const need = (n) => { if (p + n > bytes.length) throw new Error("PSBT truncada: el archivo se acaba antes de tiempo."); };
const u8 = () => { need(1); return bytes[p++]; };
const u32 = () => { need(4); const v = bytes[p]|(bytes[p+1]<<8)|(bytes[p+2]<<16)|(bytes[p+3]<<24); p+=4; return v>>>0; };
const u64 = () => { need(8); let v=0n; for(let i=7;i>=0;i--) v=(v<<8n)|BigInt(bytes[p+i]); p+=8; return Number(v); };
const take = (n) => { need(n); return bytes.slice(p, p+=n); };
const varint = () => { const v=u8(); if(v<0xfd) return v; if(v===0xfd){need(2);const r=bytes[p]|(bytes[p+1]<<8);p+=2;return r;} if(v===0xfe) return u32(); return u64(); };
if (bytes.length < 5 || bytes[0]!==0x70||bytes[1]!==0x73||bytes[2]!==0x62||bytes[3]!==0x74||bytes[4]!==0xff)
throw new Error("Esto no es una PSBT: le falta la cabecera que las identifica. ¿Has pegado una transacción normal en vez de una a medio firmar?");
p = 5;
// Cada sección es una lista de pares clave/valor que termina en un 0x00.
// El estándar PROHÍBE claves repetidas: si aparecen, el archivo está
// corrupto o manipulado, y conviene no seguir leyéndolo.
const readMap = (donde) => {
const out = []; const vistas = new Set();
for (;;) {
if (p >= bytes.length) throw new Error(`PSBT truncada: la sección de ${donde} no termina.`);
const klen = varint();
if (klen === 0) return out;
const key = take(klen);
const kh = Array.from(key).map(b=>b.toString(16).padStart(2,"0")).join("");
if (vistas.has(kh)) throw new Error(`PSBT inválida: hay una clave repetida en ${donde}. El estándar no lo permite.`);
vistas.add(kh);
const vlen = varint();
out.push({ type: key[0], key: key.slice(1), value: take(vlen) });
}
};
const global = readMap("la cabecera");
const utxRec = global.find(e => e.type === 0x00);
if (!utxRec) throw new Error("PSBT inválida: no contiene la transacción sin firmar.");
const tx = utxRec.value; let q = 0;
const tneed=(n)=>{ if(q+n>tx.length) throw new Error("La transacción que hay dentro está truncada."); };
const tu32=()=>{ tneed(4); const v=tx[q]|(tx[q+1]<<8)|(tx[q+2]<<16)|(tx[q+3]<<24); q+=4; return v>>>0; };
const tu64=()=>{ tneed(8); let v=0n; for(let i=7;i>=0;i--) v=(v<<8n)|BigInt(tx[q+i]); q+=8; return Number(v); };
const tvar=()=>{ const v=tx[q++]; if(v<0xfd) return v; if(v===0xfd){const r=tx[q]|(tx[q+1]<<8);q+=2;return r;} if(v===0xfe) return tu32(); return tu64(); };
const ttake=(n)=>{ tneed(n); return tx.slice(q, q+=n); };
const hx=(a)=>Array.from(a).map(b=>b.toString(16).padStart(2,"0")).join("");
const version = tu32();
const nIn = tvar();
if (nIn === 0) throw new Error("PSBT inválida: la transacción no tiene ninguna entrada.");
const vin = [];
for (let i=0;i<nIn;i++) {
const txid = hx(Array.from(ttake(32)).reverse());
const vout = tu32();
const slen = tvar();
ttake(slen);
// Una PSBT contiene, por definición, una transacción SIN firmar. Si
// trae firmas dentro, o no es una PSBT o alguien la ha manipulado.
if (slen > 0) throw new Error("PSBT inválida: la transacción ya lleva firmas incrustadas donde no debería.");
vin.push({ txid, vout, sequence: tu32() });
}
const nOut = tvar();
if (nOut === 0) throw new Error("PSBT inválida: la transacción no tiene ninguna salida.");
const vout = [];
for (let i=0;i<nOut;i++) { const value=tu64(); const slen=tvar(); vout.push({ value, script: ttake(slen) }); }
const locktime = tu32();
const inputs = []; for (let i=0;i<nIn;i++) inputs.push(readMap(`la entrada ${i+1}`));
const outputs = []; for (let i=0;i<nOut;i++) outputs.push(readMap(`la salida ${i+1}`));
if (p !== bytes.length) throw new Error("PSBT inválida: sobran bytes al final del archivo.");
return { version, locktime, vin, vout, global, inputs, outputs, size: bytes.length };
}
const PSBT_TIPOS = { v0_p2wpkh:"SegWit (bc1q)", v0_p2wsh:"SegWit multifirma (bc1q)", v1_p2tr:"Taproot (bc1p)", p2pkh:"Legacy (1…)", p2sh:"Script legacy (3…)", op_return:"OP_RETURN", desconocido:"desconocido" };
function psbtScriptType(s) {
if (!s || !s.length) return "desconocido";
if (s.length===22 && s[0]===0x00 && s[1]===0x14) return "v0_p2wpkh";
if (s.length===34 && s[0]===0x00 && s[1]===0x20) return "v0_p2wsh";
if (s.length===34 && s[0]===0x51 && s[1]===0x20) return "v1_p2tr";
if (s.length===25 && s[0]===0x76 && s[1]===0xa9) return "p2pkh";
if (s.length===23 && s[0]===0xa9 && s[1]===0x14) return "p2sh";
if (s[0]===0x6a) return "op_return";
return "desconocido";
}
// Auditoría de privacidad ANTES de firmar. La diferencia con el resto de
// la app: aquí todavía estás a tiempo de cambiar la transacción.
function analyzePsbt(psbt) {
const avisos = [];
const hx = a => Array.from(a).map(b=>b.toString(16).padStart(2,"0")).join("");
const xpubs = psbt.global.filter(e => e.type === 0x01);
if (xpubs.length > 0) {
avisos.push({
nivel: "critico", id: "xpub_expuesto",
titulo: xpubs.length === 1 ? "El archivo lleva dentro una clave pública maestra" : `El archivo lleva dentro ${xpubs.length} claves públicas maestras`,
hecho: `Hecho (certeza): además de lo necesario para firmar, la PSBT incluye ${xpubs.length} xpub de nivel cuenta.`,
consecuencia: "Quien reciba este archivo puede derivar TODAS las direcciones de la cartera, las que ya has usado y las que usarás, y ver su saldo e historial completos. No puede gastar nada, pero lo ve todo. Y las PSBT están hechas para compartirse: acaban en un correo o un chat para que el resto firme.",
quePuedesHacer: "Compártela solo con los cosignatarios y por un canal cifrado. Si los demás firmantes ya tienen cargada la configuración de la cartera, no necesitan estos xpubs: Sparrow permite excluirlos al exportar.",
});
}
const inVals = psbt.inputs.map(m => {
const w = m.find(e => e.type === 0x01);
if (w) { let v=0n; for(let i=7;i>=0;i--) v=(v<<8n)|BigInt(w.value[i]); return Number(v); }
return null;
});
const conocidos = inVals.every(v => v !== null);
const totalIn = conocidos ? inVals.reduce((a,b)=>a+b,0) : null;
const totalOut = psbt.vout.reduce((a,o)=>a+o.value,0);
const fee = totalIn !== null ? totalIn - totalOut : null;
const salidasPropias = psbt.outputs.map(m => m.some(e=>e.type===0x02));
const todasPropias = salidasPropias.length > 0 && salidasPropias.every(Boolean);
if (todasPropias && psbt.vout.length === 1) {
avisos.push({
nivel: "info", id: "autoenvio",
titulo: "Todo el importe vuelve a tu propia cartera",
hecho: "Hecho (certeza): la única salida lleva rutas de derivación de tu cartera, así que no estás pagando a nadie.",
consecuencia: "Es un movimiento interno: consolidar monedas, acelerar una transacción atascada o cambiar de dirección. No revela un pago, pero deja un patrón reconocible en la cadena — una entrada, una salida, sin cambio.",
quePuedesHacer: null,
});
}
const tiposIn = new Set(psbt.inputs.map(m => {
const w = m.find(e=>e.type===0x01);
if (!w) return null;
const l = w.value[8];
return psbtScriptType(w.value.slice(9, 9+l));
}).filter(Boolean));
const tiposOut = psbt.vout.map(o => psbtScriptType(o.script));
if (psbt.vout.length === 2 && tiposIn.size === 1 && tiposOut[0] !== tiposOut[1]) {
const t = [...tiposIn][0];
if (tiposOut.filter(x => x === t).length === 1) {
avisos.push({
nivel: "aviso", id: "cambio_por_tipo",
titulo: "Tu cambio se distingue por el tipo de dirección",
hecho: `Hecho (certeza): gastas monedas de tipo ${PSBT_TIPOS[t]} y solo una de las dos salidas usa ese mismo tipo.`,
consecuencia: "Cualquiera que mire la transacción deduce cuál de las dos salidas es tu cambio — y por ahí puede seguir tus gastos posteriores.",
quePuedesHacer: "Cuando puedas elegir, gasta monedas del mismo tipo que la dirección a la que pagas. No siempre depende de ti: manda el formato que use quien cobra.",
});
}
}
if (psbt.vout.length === 2) {
const redondos = psbt.vout.map((o,i)=>({i, v:o.value})).filter(o => o.v % 100000 === 0);
if (redondos.length === 1) {
avisos.push({
nivel: "aviso", id: "valor_redondo",
titulo: "Una de las salidas tiene un importe redondo",
hecho: `Hecho (certeza): la salida ${redondos[0].i+1} vale exactamente ${(redondos[0].v/1e8).toFixed(8)} BTC.`,
consecuencia: "Las personas pagan cifras redondas y el cambio es lo que sobra, con todos sus decimales. Refuerza la deducción de cuál salida es el pago y cuál el cambio.",
quePuedesHacer: "Si el importe lo decides tú, añadir unos satoshis sueltos rompe la señal sin coste real.",
});
}
}
const conWitnessScript = psbt.inputs.find(m => m.some(e=>e.type===0x05));
if (conWitnessScript) {
const ws = conWitnessScript.find(e=>e.type===0x05).value;
const m = ws[0]>=0x51&&ws[0]<=0x60 ? ws[0]-0x50 : null;
const n = ws[ws.length-2]>=0x51&&ws[ws.length-2]<=0x60 ? ws[ws.length-2]-0x50 : null;
avisos.push({
nivel: "info", id: "multisig",
titulo: (m&&n) ? `Cartera multifirma de ${m} de ${n}` : "Cartera multifirma",
hecho: `Hecho (certeza): las entradas se gastan con un script de firma múltiple${(m&&n)?`, de tipo ${m} de ${n}`:""}.`,
consecuencia: "Al gastar, ese script queda escrito en la cadena para siempre. El multisig es minoritario, así que te coloca en un grupo pequeño y reconocible: alguien que guarda cantidades con la custodia repartida.",
quePuedesHacer: "Es el precio de esta forma de custodia y con este esquema no se puede evitar. Taproot permite gastos multifirma que por fuera parecen pagos corrientes, pero implica cambiar de configuración.",
});
}
if (!conocidos) {
avisos.push({
nivel: "aviso", id: "sin_importes",
titulo: "No se puede calcular la comisión",
hecho: "Hecho (certeza): alguna entrada no incluye el importe de la moneda que gasta.",
consecuencia: "Sin ese dato no se sabe cuánto se paga de comisión. Una PSBT completa debería traerlo; que falte suele indicar que la generó una herramienta incompleta.",
quePuedesHacer: "Ábrela en el wallet que la creó y vuelve a exportarla.",
});
}
return {
resumen: {
entradas: psbt.vin.length, salidas: psbt.vout.length,
totalIn, totalOut, fee, locktime: psbt.locktime,
rbf: psbt.vin.some(i => i.sequence < 0xfffffffe),
tiposEntrada: [...tiposIn].map(t=>PSBT_TIPOS[t]),
tiposSalida: tiposOut.map(t=>PSBT_TIPOS[t]),
xpubs: xpubs.length, multisig: !!conWitnessScript,
firmasPresentes: psbt.inputs.reduce((a,m)=>a+m.filter(e=>e.type===0x02).length,0),
size: psbt.size,
},
avisos,
};
}
// ── Auditoría de PSBT ──────────────────────────────────────────────────
function ToolPsbt() {
const [input, setInput] = useState("");
const [result, setResult] = useState(null);
const [err, setErr] = useState(null);
// Acepta base64 (lo habitual al copiar) o hexadecimal.
const decode = () => {
const raw = input.trim();
const raw = input.trim().replace(/\s/g,"");
if (!raw) return;
setErr(null); setResult(null);
try {
// Basic PSBT validation — starts with magic bytes "psbt" in base64
const bytes = atob(raw.replace(/\s/g,""));
if (!bytes.startsWith("psbt")) throw new Error("No es una PSBT válida — debe empezar por 'psbt'");
// Extract basic info from magic bytes
setResult({
valid: true,
size: bytes.length,
note: "PSBT válida. Para un análisis completo necesitas un wallet como Sparrow o Coldcard. Esta herramienta verifica el formato.",
});
let bytes;
if (/^[0-9a-fA-F]+$/.test(raw) && raw.length % 2 === 0) {
bytes = new Uint8Array(raw.match(/../g).map(h=>parseInt(h,16)));
} else {
const bin = atob(raw);
bytes = new Uint8Array(bin.length);
for (let i=0;i<bin.length;i++) bytes[i] = bin.charCodeAt(i);
}
const psbt = parsePsbt(bytes);
setResult(analyzePsbt(psbt));
} catch(e) {
setErr(`Error al decodificar: ${e.message}`);
setErr(e.message);
}
};
const cargarArchivo = (file) => {
if (!file) return;
const r = new FileReader();
r.onload = (e) => {
const bytes = new Uint8Array(e.target.result);
setErr(null); setResult(null);
try { setResult(analyzePsbt(parsePsbt(bytes))); }
catch(err2) { setErr(err2.message); }
};
r.readAsArrayBuffer(file);
};
const colorNivel = (n) => n==="critico"?C.red : n==="aviso"?C.amber : C.blue;
const etiquetaNivel = (n) => n==="critico"?"CRÍTICO" : n==="aviso"?"AVISO" : "INFORMATIVO";
const R = result?.resumen;
return (
<Card>
<div style={{fontSize:"0.62rem",color:C.blue,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:8}}>Validador PSBT</div>
<div style={{fontSize:"0.65rem",color:C.t2,marginBottom:12}}>Pega una PSBT en base64 para verificar que es válida antes de firmarla. Nunca se envía a ningún servidor — todo es local.</div>
<div style={{fontSize:"0.62rem",color:C.blue,fontFamily:"monospace",textTransform:"uppercase",letterSpacing:"0.1em",marginBottom:8}}>Auditoría de PSBT</div>
<div style={{fontSize:"0.65rem",color:C.t2,marginBottom:12,lineHeight:1.6}}>
Revisa una transacción <strong style={{color:C.t1}}>antes de firmarla</strong>, cuando todavía puedes cambiarla. Todo se analiza en tu navegador: el archivo no se envía a ningún sitio, ni siquiera a tu nodo.
</div>
<textarea value={input} onChange={e=>setInput(e.target.value)}
placeholder="cHNidP8BAH..."
placeholder="Pega aquí la PSBT en base64 (cHNidP8BAH…) o en hexadecimal"
rows={4}
style={{width:"100%",background:C.bg,border:`1px solid ${C.borderBright}`,borderRadius:6,padding:"10px 12px",color:C.t1,fontFamily:"monospace",fontSize:"0.72rem",outline:"none",resize:"vertical",boxSizing:"border-box",marginBottom:8}}
onFocus={e=>e.target.style.borderColor=C.blue} onBlur={e=>e.target.style.borderColor=C.borderBright}
/>
<button onClick={decode} style={{padding:"9px 16px",background:C.blueMuted,border:`1px solid ${C.blue}40`,borderRadius:6,color:C.blue,fontFamily:"monospace",fontSize:"0.72rem",cursor:"pointer",fontWeight:700,marginBottom:10}}>VERIFICAR</button>
{err&&<div style={{padding:"8px 12px",background:C.redMuted,border:`1px solid ${C.red}30`,borderRadius:6,color:C.red,fontFamily:"monospace",fontSize:"0.72rem"}}>{err}</div>}
{result&&(
<div style={{padding:"12px 14px",background:C.bg,borderRadius:6,border:`1px solid ${C.green}40`}}>
<div style={{display:"flex",alignItems:"center",gap:8,marginBottom:8}}>
<span style={{color:C.green,fontSize:"1rem"}}></span>
<span style={{color:C.green,fontFamily:"monospace",fontSize:"0.78rem",fontWeight:700}}>PSBT válida</span>
<Badge color={C.t2}>{result.size} bytes</Badge>
<div style={{display:"flex",gap:8,alignItems:"center",flexWrap:"wrap",marginBottom:10}}>
<button onClick={decode} style={{padding:"9px 16px",background:C.blueMuted,border:`1px solid ${C.blue}40`,borderRadius:6,color:C.blue,fontFamily:"monospace",fontSize:"0.72rem",cursor:"pointer",fontWeight:700}}>ANALIZAR</button>
<label style={{padding:"9px 16px",background:"none",border:`1px solid ${C.border}`,borderRadius:6,color:C.t2,fontFamily:"monospace",fontSize:"0.7rem",cursor:"pointer"}}>
…o abrir archivo .psbt
<input type="file" accept=".psbt,.txn,application/octet-stream" style={{display:"none"}} onChange={e=>cargarArchivo(e.target.files[0])}/>
</label>
</div>
{err&&<div style={{padding:"10px 12px",background:C.redMuted,border:`1px solid ${C.red}40`,borderRadius:6,color:C.red,fontFamily:"monospace",fontSize:"0.7rem",lineHeight:1.6}}>{err}</div>}
{result&&(
<div style={{display:"flex",flexDirection:"column",gap:12}}>
{/* Resumen de la transacción */}
<div style={{padding:"12px 14px",background:C.bg,borderRadius:6,border:`1px solid ${C.border}`}}>
<div style={{fontSize:"0.6rem",color:C.t2,fontFamily:"monospace",fontWeight:700,letterSpacing:"0.12em",marginBottom:10}}>QUÉ HACE ESTA TRANSACCIÓN</div>
<div style={{display:"flex",gap:18,flexWrap:"wrap"}}>
<Tag label="Entradas" value={R.entradas}/>
<Tag label="Salidas" value={R.salidas}/>
{R.totalIn!=null&&<Tag label="Gasta" value={fmt.btc(R.totalIn)}/>}
<Tag label="Envía" value={fmt.btc(R.totalOut)}/>
{R.fee!=null&&<Tag label="Comisión" value={`${fmt.num(R.fee)} sat`} color={C.amber}/>}
<Tag label="Firmas ya puestas" value={R.firmasPresentes}/>
</div>
<div style={{marginTop:10,fontSize:"0.62rem",color:C.t2,fontFamily:"monospace",lineHeight:1.7}}>
Gasta monedas de tipo {R.tiposEntrada.join(", ")||"desconocido"} · envía a {R.tiposSalida.join(", ")}<br/>
{R.rbf ? "Reemplazable (RBF): podrás subir la comisión si se atasca." : "No reemplazable: si se atasca, no podrás acelerarla con RBF."}
{R.locktime>0 && ` · locktime ${R.locktime}: no puede minarse antes de ese bloque (protección anti-fee-sniping, buena señal).`}
</div>
</div>
{/* Avisos */}
{result.avisos.length===0 ? (
<div style={{padding:"12px 14px",background:C.greenMuted,border:`1px solid ${C.green}40`,borderRadius:6,fontSize:"0.68rem",color:C.green,fontFamily:"monospace"}}>
✓ No se detectan problemas de privacidad en la estructura de esta transacción.
</div>
) : result.avisos.map((a,i)=>(
<div key={i} style={{padding:"12px 14px",background:C.bgCard,borderRadius:6,border:`1px solid ${colorNivel(a.nivel)}40`}}>
<div style={{display:"flex",alignItems:"center",gap:8,marginBottom:8,flexWrap:"wrap"}}>
<span style={{fontSize:"0.55rem",fontFamily:"monospace",fontWeight:700,color:colorNivel(a.nivel),border:`1px solid ${colorNivel(a.nivel)}50`,borderRadius:3,padding:"2px 6px"}}>{etiquetaNivel(a.nivel)}</span>
<span style={{fontSize:"0.72rem",color:C.t1,fontWeight:600}}>{a.titulo}</span>
</div>
<div style={{fontSize:"0.65rem",color:C.t2,lineHeight:1.7,marginBottom:6}}>{a.hecho}</div>
<div style={{fontSize:"0.65rem",color:C.t1,lineHeight:1.7}}>{a.consecuencia}</div>
{a.quePuedesHacer&&(
<div style={{marginTop:8,paddingTop:8,borderTop:`1px solid ${C.border}`,fontSize:"0.64rem",color:C.green,lineHeight:1.7}}>
<strong>Qué puedes hacer:</strong> {a.quePuedesHacer}
</div>
)}
</div>
))}
<div style={{fontSize:"0.58rem",color:C.t3,fontFamily:"monospace",lineHeight:1.6}}>
{R.size} bytes analizados · sin consultar el nodo · esta herramienta no firma ni modifica nada
</div>
<div style={{fontSize:"0.68rem",color:C.t2,lineHeight:1.6}}>{result.note}</div>
</div>
)}
</Card>