Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
728ba655ee | ||
|
|
b57c44e5c0 |
@@ -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
|
||||
|
||||
+354
-36
@@ -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,14 +4837,21 @@
|
||||
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++;
|
||||
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 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));
|
||||
@@ -4844,6 +4859,8 @@
|
||||
|
||||
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>}
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user