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.
This commit is contained in:
Aitor
2026-07-27 14:44:26 +02:00
parent b57c44e5c0
commit 728ba655ee
2 changed files with 321 additions and 23 deletions
+30
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
+291 -23
View File
@@ -6038,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>}
<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={{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",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>