El resultado
Un pequeño programa sin conexión que abre un contenedor de transacciones de Bitcoin, cuenta sus secciones y rechaza varios ejemplos incorrectos. Usa datos de transacción inventados. No conecta ninguna cartera, firma ni realiza peticiones de red.
El contenedor es una transacción de Bitcoin parcialmente firmada, o PSBT. Las carteras lo usan para intercambiar un plan de transacción y los datos necesarios para firmarlo. La entrada del glosario sobre PSBT explica su propósito. Aquí la tarea es más limitada: seguir los límites de bytes de la versión 0 y comprender qué demuestra una inspección estructural correcta.
El resultado esperado es un mapa global, un mapa de entrada y un mapa de salida. Un mapa es una lista de valores binarios identificados por claves. El ejemplo también rechaza ocho contenedores malformados deliberadamente. Ningún resultado demuestra que la transacción gaste una salida real, pague a la persona correcta o sea segura para firmar.
Antes de empezar
Se necesita Python 3 y un editor de texto en una carpeta desechable. El procedimiento siguiente se ejecutó con Python 3.12.14 el 18 de octubre de 2026. Usa solo la biblioteca estándar; no requiere instalar paquetes, tener un nodo, cartera, clave privada ni frase de recuperación.
BIP 174 define el contenedor y la versión 0 de PSBT. Sus ejemplos publicados válidos e inválidos también se usaron en una comprobación separada de compatibilidad. Bitcoin Core 31.1 era la última versión estable publicada al comprobarlo. Su código de decodificación de PSBT rechaza datos sobrantes después del contenedor. Se inspeccionó ese código; no se ejecutó ningún binario ni RPC de Bitcoin Core.
Solo deben usarse los datos sintéticos que crea este programa. Un PSBT real puede revelar claves públicas extendidas, rutas de derivación, scripts, transacciones anteriores y detalles del pago. Base64, la codificación de texto habitual para transportarlo, no cifra nada. Un inspector sin conexión no justifica pegar un PSBT personal en una web, chat o informe público de errores.
La contrapartida
Un analizador deliberadamente pequeño permite seguir el formato con facilidad. También deja fuera la mayor parte de la validación de una cartera. Este programa comprueba límites y algunos requisitos de la versión 0. No verifica firmas, autenticidad de salidas anteriores, propiedad, comisiones, cambio, ejecución de scripts ni el significado de cada campo registrado.
Los flujos reales de transacciones requieren una implementación mantenida de cartera o nodo. El ejercicio sirve para aprender serialización, no para autorizar un pago. Imprime cantidades en lugar de volcar valores y nunca escribe un PSBT modificado. Los registros desconocidos pueden contarse, pero su presencia no garantiza compatibilidad ni seguridad.
Pasos
- Crear una carpeta de trabajo desechable. Mantenerla separada de copias de seguridad de carteras y de cualquier carpeta con archivos de pagos. Estos comandos crean una carpeta ordinaria y entran en ella. No contactan con ningún servicio.
mkdir psbt-envelope-practice
cd psbt-envelope-practice
- Guardar el programa siguiente como
psbt_envelope.py. Leerlo antes de ejecutarlo. La funciónfixturecrea localmente la transacción de práctica. El hash de la salida anterior es inventado, el valor de la salida es cero y su script esOP_RETURN, que no puede gastarse. No es una transacción para transmitir. El programa no contiene ninguna función de transmisión.
#!/usr/bin/env python3
import base64
MAGIC = b"psbt\xff"
class Reader:
def __init__(self, data):
self.data, self.pos = data, 0
def take(self, size):
if size < 0 or self.pos + size > len(self.data):
raise ValueError("truncated")
result = self.data[self.pos:self.pos + size]
self.pos += size
return result
def compact(self):
tag = self.take(1)[0]
if tag < 253:
return tag
size = {253: 2, 254: 4, 255: 8}[tag]
value = int.from_bytes(self.take(size), "little")
if value < {253: 253, 254: 65536, 255: 4294967296}[tag]:
raise ValueError("nonminimal_compactsize")
return value
def done(self):
if self.pos != len(self.data):
raise ValueError("trailing_bytes")
def read_map(reader):
entries = {}
while True:
size = reader.compact()
if size == 0:
return entries
key = reader.take(size)
Reader(key).compact()
if key in entries:
raise ValueError("duplicate_key")
entries[key] = reader.take(reader.compact())
def transaction_counts(raw):
reader = Reader(raw)
reader.take(4)
inputs = reader.compact()
if inputs == 0:
raise ValueError("unsupported_zero_input_or_witness")
for _ in range(inputs):
reader.take(36)
if reader.compact() != 0:
raise ValueError("unsigned_tx_has_scriptsig")
reader.take(4)
outputs = reader.compact()
for _ in range(outputs):
reader.take(8)
reader.take(reader.compact())
reader.take(4)
reader.done()
return inputs, outputs
def inspect(raw):
if len(raw) > 100000:
raise ValueError("exercise_size_limit")
reader = Reader(raw)
if reader.take(5) != MAGIC:
raise ValueError("not_psbt")
global_map = read_map(reader)
for key in global_map:
kind = Reader(key).compact()
if kind in (0, 251) and len(key) != 1:
raise ValueError("global_key_has_data")
version = global_map.get(b"\xfb", b"\0\0\0\0")
if len(version) != 4 or int.from_bytes(version, "little") != 0:
raise ValueError("only_psbt_v0")
if b"\0" not in global_map:
raise ValueError("missing_unsigned_tx")
transaction = global_map[b"\0"]
inputs, outputs = transaction_counts(transaction)
input_maps = [read_map(reader) for _ in range(inputs)]
output_maps = [read_map(reader) for _ in range(outputs)]
reader.done()
return len(global_map), len(input_maps), len(output_maps), len(transaction)
def fixture(script=b""):
transaction = (bytes.fromhex("0200000001") + b"\x11" * 32
+ b"\0" * 4 + bytes([len(script)]) + script
+ bytes.fromhex("ffffffff01") + b"\0" * 8
+ bytes.fromhex("016a00000000"))
pair = b"\x01\x00" + bytes([len(transaction)]) + transaction
return MAGIC + pair + b"\0\0\0", pair
def main():
raw, pair = fixture()
encoded = base64.b64encode(raw).decode("ascii")
decoded = base64.b64decode(encoded, validate=True)
result = inspect(decoded)
if result != (1, 1, 1, 61):
raise RuntimeError("unexpected_summary")
invalid = [b"xxxxx" + raw[5:], raw[:-1], raw + b"\0",
MAGIC + pair + pair + b"\0\0\0",
MAGIC + b"\xfd\x01\x00" + raw[6:],
fixture(b"\x51")[0],
MAGIC + pair + bytes.fromhex("01fb0402000000") + b"\0\0\0",
MAGIC + b"\0\0\0"]
for item in invalid:
try:
inspect(item)
except ValueError:
continue
raise RuntimeError("malformed_input_accepted")
print("global_maps=1 global_entries=1 input_maps=1 output_maps=1")
print("unsigned_tx_bytes=61 malformed_rejected=8")
if __name__ == "__main__":
main()
- Ejecutar el programa. Este es el comando completo, sin nombres de archivos de carteras ni solicitudes de pago pegadas:
python3 psbt_envelope.py
El resultado esperado es:
global_maps=1 global_entries=1 input_maps=1 output_maps=1
unsigned_tx_bytes=61 malformed_rejected=8
-
Seguir un registro a través del lector. El contenedor comienza con los cinco bytes
70736274ff. Los cuatro primeros formanpsbt; el último distingue el formato. Después, una longitud precede a los bytes de la clave y otra a los bytes del valor. Una longitud de clave igual a cero termina el mapa actual. Un byte cero dentro de un valor no lo termina, porque la longitud ya indica cuántos bytes pertenecen a ese valor. -
Comprobar de dónde salen las cantidades de mapas. En la versión 0, la clave global
00contiene la transacción sin firmar. El analizador lee sus cantidades de entradas y salidas y consume exactamente ese número de mapas. Los mapas sintéticos están vacíos, pero cada uno necesita su byte terminador. Quitar el separador final provoca un error, en lugar de aceptar un contenedor sin su sección de salida. -
Inspeccionar la lista de rechazos. Los ocho casos cubren cabecera incorrecta, truncamiento, byte sobrante, clave global duplicada, longitud no mínima, script de entrada no vacío en la transacción sin firmar, versión 2 no admitida y ausencia de transacción sin firmar. Cada uno debe provocar
ValueError. Si se acepta un caso, el programa provoca otro error y se detiene. La ausencia de una excepción se comprueba expresamente.
Comprobar el resultado
Comparar exactamente las dos líneas de salida. Los 61 bytes son la serialización generada por este ejemplo, no un tamaño habitual de transacción de Bitcoin. Un mapa de entrada tampoco es una entrada: contiene la información PSBT asociada a una entrada ya descrita por la transacción sin firmar.
El método Reader.compact comprueba CompactSize, la codificación de enteros sin signo y longitud variable de Bitcoin. Los valores pequeños deben usar su forma más corta. El lector de mapas también rechaza claves completas duplicadas. Dos registros pueden compartir un tipo si difieren sus datos de clave; rechazar cualquier repetición de tipo sería una regla distinta e incorrecta. El código conserva los bytes completos de las claves para compararlas.
La comprobación separada de vectores aceptó el ejemplo válido de BIP 174 «una entrada P2PKH, las salidas están vacías» e informó de un mapa de entrada y dos mapas de salida. En ese título, «vacías» describe los mapas de salida, no la ausencia de salidas en la transacción. El ejemplo de una transacción de red ordinaria se rechazó como not_psbt. Estas comprobaciones ejercitan un límite; no convierten el programa en una implementación completa de BIP 174.
La decodificación Base64 usa validate=True, por lo que no descarta silenciosamente caracteres ajenos a esa codificación. El contenedor decodificado tiene un límite de 100.000 bytes en este ejercicio. Es un límite didáctico local, no una regla de consenso ni del estándar PSBT. El programa construye su propio ejemplo pequeño y no acepta archivos ni datos por argumentos.
Si algo sale mal
Python informa de un error de sintaxis. Comprobar la sangría y copiar el programa completo como texto plano. Mantener los escapes de las cadenas binarias, como \xff, sin cambios. No sustituir las comillas del código por comillas tipográficas. Ejecutar el programa desde la carpeta donde se guardó.
El programa informa de only_psbt_v0. La versión 2 es otro formato admitido en el ecosistema, definido por BIP 370. Almacena los detalles de la transacción de otra manera. Cambiar los bytes de versión para forzar un contenedor de versión 2 a pasar este ejercicio de versión 0 no lo convierte.
Falla un caso de clave duplicada. Ese rechazo es el resultado buscado. Convertir los registros directamente en un diccionario sin comprobar si la clave existe sobrescribiría silenciosamente un valor. Este lector comprueba antes. No combina PSBT en conflicto ni decide qué registro debería conservarse.
El contenedor se interpreta, pero faltan datos para firmar. Interpretar no equivale a estar listo. El mapa de entrada sintético no incluye salida anterior, derivación ni firma. El programa no deduce valores ausentes ni calcula una comisión. Un firmante necesita muchas más pruebas, y el mensaje de éxito de un analizador no las aporta.
Las cantidades coinciden, pero aparece un campo desconocido. Esa incertidumbre debe mantenerse. Este inspector no interpreta todos los tipos estándar o propietarios ni vuelve a serializar nada. BIP 174 exige que el software que vuelva a serializar un PSBT conserve los registros que no comprende. Crear un actualizador o firmante requiere ese contrato más amplio y validación adicional.
Fuentes
- BIP 174: formato de transacciones de Bitcoin parcialmente firmadas, con la especificación y los vectores publicados, consultado el 18 de octubre de 2026.
- BIP 370: PSBT versión 2, consultado el 18 de octubre de 2026.
- Versión Bitcoin Core 31.1 y su código de decodificación de PSBT, consultados el 18 de octubre de 2026.
- Documentación de Base64 en Python 3.12, sobre el comportamiento de la decodificación estricta.
