What you will end up with
A small offline program that opens a Bitcoin transaction container, counts its sections and rejects several broken examples. It uses invented transaction data. There is no wallet connection, signing operation or network request.
The container is a Partially Signed Bitcoin Transaction, or PSBT. Wallets use it to exchange a transaction plan and the information needed to sign it. The PSBT glossary entry covers that purpose. Here the task is narrower: follow the byte boundaries of version 0 and understand what a successful structural inspection actually establishes.
The expected result is one global map, one input map and one output map. A map is a list of named byte values. The example also rejects eight deliberately malformed containers. Neither result establishes that the transaction spends a real output, pays the right person or is safe to sign.
Before you start
Use Python 3 and a text editor in a disposable directory. The procedure below was executed with Python 3.12.14 on October 18, 2026. It uses only the standard library; no package installation, node, wallet, private key or recovery phrase is required.
BIP 174 defines the envelope and PSBT version 0. Its published valid and invalid examples were also used for a separate compatibility check. Bitcoin Core 31.1 was the latest published stable release when checked. Its PSBT decoding source rejects extra data after the container. That source was inspected; no Bitcoin Core binary or RPC was executed.
Use only the synthetic data created by this program. A real PSBT may reveal extended public keys, derivation paths, scripts, previous transactions and payment details. Base64, the text encoding commonly used to transport it, provides no encryption. An offline inspector is not a reason to paste a personal PSBT into a website, chat or public bug report.
The tradeoff you are accepting
An intentionally small parser makes the format easy to follow. It also leaves out most of a wallet's validation. This program checks boundaries and a few version-0 requirements. It does not check signature validity, previous-output authenticity, ownership, fees, change, script execution or the meanings of every registered field.
Use a maintained wallet or node implementation for real transaction workflows. This exercise is for developers learning serialization, not for approving a payment. It prints counts rather than dumping values and never writes a modified PSBT. Unknown records can be counted, but their presence is not a compatibility or safety guarantee.
Steps
- Create a disposable working directory. Keep it separate from wallet backups and any directory containing payment files. The commands below create an ordinary folder and enter it. They do not contact a service.
mkdir psbt-envelope-practice
cd psbt-envelope-practice
- Save the program below as
psbt_envelope.py. Read it before running it. Thefixturefunction creates the practice transaction locally. Its previous-output hash is invented, its output value is zero and its output script isOP_RETURN, which cannot be spent. It is not a transaction to broadcast. The program contains no broadcast function.
#!/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()
- Run the program. This is the complete command, without a wallet filename or a pasted payment request:
python3 psbt_envelope.py
The expected output is:
global_maps=1 global_entries=1 input_maps=1 output_maps=1
unsigned_tx_bytes=61 malformed_rejected=8
-
Follow one record through the reader. The envelope starts with the five bytes
70736274ff. The first four spellpsbt; the last distinguishes the format. After that, a key length precedes the key bytes, and a value length precedes the value bytes. A zero key length ends the current map. A zero byte inside a value does not end it, because the length has already said how many bytes belong to that value. -
Check where the map counts come from. In version 0, global key
00contains the unsigned transaction. The parser reads that transaction's input and output counts, then consumes exactly that many maps. The synthetic maps are empty, but each still needs its terminating byte. Removing the final separator therefore produces an error rather than a successful container with a missing output section. -
Inspect the rejection list. The eight cases cover a wrong header, truncation, a trailing byte, a duplicate global key, a nonminimal length, a filled input script in the unsigned transaction, unsupported version 2 and a missing unsigned transaction. Each is expected to raise
ValueError. If a case is accepted, the program raises a different error and stops. The absence of an exception is checked explicitly rather than being assumed.
Verify it worked
Compare both output lines exactly. The 61-byte transaction length is the serialization generated by this fixture, not a typical Bitcoin transaction size. An input map is also not an input itself: it is the PSBT information associated with an input already described by the unsigned transaction.
The Reader.compact method checks CompactSize, Bitcoin's variable-length unsigned-integer encoding. Small values must use their shortest form. The map reader also rejects duplicate complete keys. Two records may share a type when their key data differ, so rejecting every repeated type would be a different and incorrect rule. The code retains complete key bytes for that comparison.
The separate source-vector check accepted BIP 174's valid “one P2PKH input, outputs are empty” example and reported one input map and two output maps. In that vector's title, “empty” describes the output maps, not an absence of transaction outputs. The source's plain network-transaction example was rejected as not_psbt. These checks exercise a boundary; they do not make this program a complete BIP 174 implementation.
Base64 decoding uses validate=True, so unrelated characters are not silently discarded. The decoded envelope is limited to 100,000 bytes for this exercise. That is a local teaching limit, not a Bitcoin consensus or PSBT standard limit. The program constructs its own small input and does not accept files or command-line payloads.
If something goes wrong
Python reports a syntax error. Check indentation and copy the entire program as plain text. Keep byte-string escapes, such as \xff, unchanged. Do not replace straight code quotes with typographic quotation marks. Run the program from the directory where it was saved.
The program reports only_psbt_v0. Version 2 is a different supported format in the wider ecosystem, defined by BIP 370. It stores transaction details differently. Do not change the version bytes to force a version-2 container through this version-0 exercise; that does not convert it.
A duplicate-key case fails. That rejection is the intended result. Converting records directly into a dictionary without checking for an existing key would silently overwrite one value. This reader checks first. It does not combine competing PSBTs or decide which of two records should survive.
A container parses but lacks signing information. Parsing is not readiness. The synthetic input map deliberately has no previous output, derivation information or signature. The program does not infer missing values or calculate a fee. A signer needs much more evidence, and a parser's success message does not provide it.
The counts match but a field is unfamiliar. Keep that uncertainty. This inspector does not interpret every standard or proprietary type and does not reserialize anything. BIP 174 requires software that reserializes a PSBT to preserve records it does not understand. Building an updater or signer requires that broader contract, plus validation beyond this exercise.
Sources
- BIP 174: Partially Signed Bitcoin Transaction Format, including the specification and published test vectors, checked October 18, 2026.
- BIP 370: PSBT version 2, checked October 18, 2026.
- Bitcoin Core 31.1 release and its PSBT decoding source, checked October 18, 2026.
- Python 3.12 Base64 documentation, for strict decoding behavior.
