DEV Community

Aturo Phil
Aturo Phil

Posted on

PSBT: Collaborative Bitcoin Transactions

Before BIP 174, coordinating a Bitcoin transaction that required multiple parties, a hardware wallet signing, a multisig cosigner approving, a coinjoin participant contributing, meant custom, incompatible formats. Every wallet implementation had its own way of passing unsigned transactions around.

Partially Signed Bitcoin Transactions (PSBT) standardized this. A PSBT is a data format that carries a Bitcoin transaction through its signing lifecycle, accumulating signatures and metadata from multiple parties until it is complete and ready to broadcast.

Understanding PSBT is essential for anyone building multisig wallets, hardware wallet integrations, coinjoin tools, or any workflow where a transaction requires coordination before broadcast.


The Problem PSBT Solves

Signing a Bitcoin transaction requires:

  1. The unsigned transaction inputs and outputs
  2. For each input: the UTXO being spent (to determine the value and script type)
  3. The signing key(s) for each input
  4. Optionally: derivation paths for HD wallet keys, redeem scripts for P2SH inputs, witness scripts for P2WSH inputs

Before PSBT, a hardware wallet asked to sign a transaction needed all of this information, but there was no standard way to package it. An offline signing device had no way to verify what it was signing without access to the full UTXO set.

PSBT solves this by:

  • Carrying the full UTXO for each input alongside the transaction
  • Carrying redeem scripts, witness scripts, and key derivation paths
  • Allowing partial signatures from multiple signers to accumulate in the same file
  • Defining a clear lifecycle: Creator → Updater → Signer → Combiner → Finalizer → Extractor

The PSBT Format

A PSBT is a binary format with a global section and per-input/per-output sections. Each section is a sequence of key-value records.

Magic bytes: 0x70736274 0xff
Global section:
  key=0x00: unsigned transaction (required)
  key=0x01: xpub entries (optional)
  key=0x03: version (optional, PSBT v2 only)
Per-input sections (one per transaction input):
  key=0x00: non-witness UTXO (full previous transaction)
  key=0x01: witness UTXO (just the output being spent)
  key=0x02: partial signature <pubkey> → <sig>
  key=0x04: redeem script
  key=0x05: witness script
  key=0x06: BIP32 derivation path <pubkey> → <fingerprint + path>
  key=0x07: finalized scriptSig
  key=0x08: finalized scriptWitness
Per-output sections (one per transaction output):
  key=0x02: BIP32 derivation path
  key=0x00: redeem script
  key=0x01: witness script
Enter fullscreen mode Exit fullscreen mode

The key distinction: 0x00 (non-witness UTXO) carries the full previous transaction for legacy inputs. 0x01 (witness UTXO) carries only the specific output being spent for SegWit inputs. Hardware wallets use this to verify the amount they are signing over.

Reading a PSBT in Python:

import base64
import struct
from io import BytesIO

PSBT_MAGIC = b'psbt\xff'

def read_compact_size(stream) -> int:
    first = stream.read(1)[0]
    if first < 0xfd:
        return first
    elif first == 0xfd:
        return struct.unpack('<H', stream.read(2))[0]
    elif first == 0xfe:
        return struct.unpack('<I', stream.read(4))[0]
    else:
        return struct.unpack('<Q', stream.read(8))[0]

def read_key_value(stream) -> tuple[bytes, bytes] | None:
    key_len = read_compact_size(stream)
    if key_len == 0:
        return None  # separator
    key = stream.read(key_len)
    val_len = read_compact_size(stream)
    value = stream.read(val_len)
    return key, value

def parse_psbt(psbt_bytes: bytes) -> dict:
    stream = BytesIO(psbt_bytes)

    # Verify magic
    magic = stream.read(5)
    if magic != PSBT_MAGIC:
        raise ValueError(f"Invalid PSBT magic: {magic.hex()}")

    result = {'global': {}, 'inputs': [], 'outputs': []}

    # Parse global section
    while True:
        kv = read_key_value(stream)
        if kv is None:
            break
        key, value = kv
        key_type = key[0]
        if key_type == 0x00:
            result['global']['unsigned_tx'] = value.hex()
        elif key_type == 0x01:
            result['global'].setdefault('xpubs', {})[key[1:].hex()] = value.hex()

    # Count inputs and outputs from the unsigned tx
    # (simplified — a real parser would fully decode the tx)
    n_inputs = 2   # determined from parsing unsigned_tx
    n_outputs = 2  # determined from parsing unsigned_tx

    # Parse per-input sections
    for _ in range(n_inputs):
        input_data = {}
        while True:
            kv = read_key_value(stream)
            if kv is None:
                break
            key, value = kv
            key_type = key[0]
            if key_type == 0x00:
                input_data['non_witness_utxo'] = value.hex()
            elif key_type == 0x01:
                input_data['witness_utxo'] = value.hex()
            elif key_type == 0x02:
                pubkey = key[1:].hex()
                input_data.setdefault('partial_sigs', {})[pubkey] = value.hex()
            elif key_type == 0x04:
                input_data['redeem_script'] = value.hex()
            elif key_type == 0x05:
                input_data['witness_script'] = value.hex()
            elif key_type == 0x06:
                pubkey = key[1:].hex()
                fingerprint = value[:4].hex()
                path = '/'.join(str(struct.unpack('<I', value[i:i+4])[0] & 0x7FFFFFFF) +
                               ("'" if struct.unpack('<I', value[i:i+4])[0] & 0x80000000 else "")
                               for i in range(4, len(value), 4))
                input_data.setdefault('bip32_derivations', {})[pubkey] = {
                    'fingerprint': fingerprint,
                    'path': f"m/{path}"
                }
        result['inputs'].append(input_data)

    return result
Enter fullscreen mode Exit fullscreen mode

Using the psbt library is more practical:

# python-bitcoinlib includes PSBT support
from bitcoin.psbt import PartiallySignedTransaction
import base64

def inspect_psbt(psbt_b64: str) -> None:
    psbt_bytes = base64.b64decode(psbt_b64)
    psbt = PartiallySignedTransaction.deserialize(psbt_bytes)

    print(f"Version: {psbt.version}")
    print(f"Inputs: {len(psbt.inputs)}")
    print(f"Outputs: {len(psbt.outputs)}")

    for i, inp in enumerate(psbt.inputs):
        print(f"\nInput {i}:")
        if inp.witness_utxo:
            print(f"  Value: {inp.witness_utxo.nValue} sat")
        print(f"  Signatures: {len(inp.partial_sigs)}")
        for pubkey, sig in inp.partial_sigs.items():
            print(f"  Sig from: {pubkey.hex()}")
        if inp.bip32_derivations:
            for pubkey, deriv in inp.bip32_derivations.items():
                print(f"  Key path: {deriv.fingerprint.hex()} {deriv.path}")
Enter fullscreen mode Exit fullscreen mode

The PSBT Lifecycle

1. Creator: Builds the unsigned transaction and initializes the PSBT.

from bitcoin.core import CMutableTransaction, CMutableTxIn, CMutableTxOut, COutPoint
from bitcoin.psbt import PartiallySignedTransaction, PSBTInput, PSBTOutput

def create_psbt(
    utxos: list[dict],     # list of {txid, vout, value, script}
    outputs: list[dict],   # list of {address, value}
) -> str:
    # Build unsigned transaction
    inputs = [
        CMutableTxIn(COutPoint(bytes.fromhex(u['txid'])[::-1], u['vout']))
        for u in utxos
    ]
    tx_outputs = [
        CMutableTxOut(o['value'], address_to_scriptpubkey(o['address']))
        for o in outputs
    ]
    unsigned_tx = CMutableTransaction(inputs, tx_outputs)

    # Initialize PSBT
    psbt = PartiallySignedTransaction(unsigned_tx=unsigned_tx)

    # Add UTXO data for each input (witness_utxo for SegWit)
    for i, utxo in enumerate(utxos):
        psbt.inputs[i].witness_utxo = CMutableTxOut(
            utxo['value'],
            bytes.fromhex(utxo['script'])
        )

    return base64.b64encode(psbt.serialize()).decode()
Enter fullscreen mode Exit fullscreen mode

2. Updater: Adds missing data — redeem scripts, witness scripts, derivation paths.

def update_psbt(psbt_b64: str, wallet_data: dict) -> str:
    psbt = PartiallySignedTransaction.deserialize(base64.b64decode(psbt_b64))

    for i, inp in enumerate(psbt.inputs):
        input_id = f"input_{i}"
        if input_id in wallet_data.get('derivations', {}):
            deriv = wallet_data['derivations'][input_id]
            pubkey = bytes.fromhex(deriv['pubkey'])
            psbt.inputs[i].bip32_derivations[pubkey] = KeyOriginInfo(
                fingerprint=bytes.fromhex(deriv['fingerprint']),
                path=parse_derivation_path(deriv['path'])
            )

        if input_id in wallet_data.get('witness_scripts', {}):
            psbt.inputs[i].witness_script = bytes.fromhex(
                wallet_data['witness_scripts'][input_id]
            )

    return base64.b64encode(psbt.serialize()).decode()
Enter fullscreen mode Exit fullscreen mode

3. Signer: Examines the PSBT and adds a partial signature for inputs it can sign.

import hashlib
from coincurve import PrivateKey

def sign_psbt(psbt_b64: str, private_key_hex: str) -> str:
    psbt = PartiallySignedTransaction.deserialize(base64.b64decode(psbt_b64))
    private_key = PrivateKey(bytes.fromhex(private_key_hex))
    pubkey = private_key.public_key.format(compressed=True)

    for i, inp in enumerate(psbt.inputs):
        # Check if this key is expected to sign this input
        if pubkey not in inp.bip32_derivations:
            continue

        # Compute sighash
        if inp.witness_utxo:
            # SegWit sighash (BIP 143)
            sighash = compute_segwit_sighash(
                psbt.unsigned_tx, i, inp.witness_utxo, inp.witness_script
            )
            sighash_type = 0x01  # SIGHASH_ALL
        else:
            # Legacy sighash
            sighash = compute_legacy_sighash(
                psbt.unsigned_tx, i, inp.redeem_script
            )
            sighash_type = 0x01

        # Sign
        sig = private_key.sign(sighash, hasher=None)
        der_sig = sig + bytes([sighash_type])

        psbt.inputs[i].partial_sigs[pubkey] = der_sig

    return base64.b64encode(psbt.serialize()).decode()
Enter fullscreen mode Exit fullscreen mode

4. Combiner: Merges PSBTs from multiple signers.

def combine_psbts(psbt_list: list[str]) -> str:
    """
    Merge partial signatures from multiple PSBTs.
    All PSBTs must have the same unsigned transaction.
    """
    psbts = [
        PartiallySignedTransaction.deserialize(base64.b64decode(p))
        for p in psbt_list
    ]

    base = psbts[0]

    for psbt in psbts[1:]:
        for i in range(len(base.inputs)):
            # Merge partial signatures
            base.inputs[i].partial_sigs.update(psbt.inputs[i].partial_sigs)
            # Merge any other fields the other PSBT has
            if psbt.inputs[i].witness_script and not base.inputs[i].witness_script:
                base.inputs[i].witness_script = psbt.inputs[i].witness_script

    return base64.b64encode(base.serialize()).decode()
Enter fullscreen mode Exit fullscreen mode

5. Finalizer: Converts partial signatures into the final scriptSig and scriptWitness.

def finalize_psbt(psbt_b64: str) -> str:
    psbt = PartiallySignedTransaction.deserialize(base64.b64decode(psbt_b64))

    for i, inp in enumerate(psbt.inputs):
        if inp.witness_script:
            # P2WSH: build witness from partial sigs + witness script
            sigs = list(inp.partial_sigs.values())
            witness = [b''] + sigs + [inp.witness_script]  # OP_0 + sigs + script
            psbt.inputs[i].final_script_witness = witness
            psbt.inputs[i].final_script_sig = b''
        elif inp.witness_utxo:
            # P2WPKH: build witness from single sig + pubkey
            pubkey = list(inp.partial_sigs.keys())[0]
            sig = inp.partial_sigs[pubkey]
            psbt.inputs[i].final_script_witness = [sig, pubkey]
            psbt.inputs[i].final_script_sig = b''
        elif inp.redeem_script:
            # P2SH: build scriptSig from sigs + redeem script
            sigs = list(inp.partial_sigs.values())
            psbt.inputs[i].final_script_sig = build_p2sh_scriptsig(sigs, inp.redeem_script)

    return base64.b64encode(psbt.serialize()).decode()
Enter fullscreen mode Exit fullscreen mode

6. Extractor: Converts the finalized PSBT into a network-ready transaction.

def extract_transaction(psbt_b64: str) -> str:
    """Extract the final, signable transaction for broadcast."""
    psbt = PartiallySignedTransaction.deserialize(base64.b64decode(psbt_b64))

    tx = psbt.unsigned_tx

    for i, inp in enumerate(psbt.inputs):
        if not inp.final_script_witness and not inp.final_script_sig:
            raise ValueError(f"Input {i} is not finalized")
        if inp.final_script_sig:
            tx.vin[i].scriptSig = inp.final_script_sig
        # witness is set separately in the transaction structure

    return tx.serialize().hex()
Enter fullscreen mode Exit fullscreen mode

Real Workflow: 2-of-3 Multisig with Hardware Wallets

A practical multisig flow where two of three hardware wallets must sign:

import asyncio

async def multisig_payment(
    utxos: list,
    outputs: list,
    hw_wallet_1,
    hw_wallet_2,
    cosigner_server_url: str
) -> str:
    # Step 1: Creator builds PSBT
    psbt = create_psbt(utxos, outputs)
    print(f"Created PSBT: {psbt[:50]}...")

    # Step 2: First hardware wallet signs
    psbt_signed_1 = await hw_wallet_1.sign_psbt(psbt)
    print("Hardware wallet 1 signed")

    # Step 3: Send to cosigner server for second signature
    async with httpx.AsyncClient() as client:
        response = await client.post(
            f"{cosigner_server_url}/sign",
            json={"psbt": psbt_signed_1}
        )
        psbt_signed_2 = response.json()["psbt"]
    print("Cosigner server signed")

    # Step 4: Combine signatures
    combined = combine_psbts([psbt_signed_1, psbt_signed_2])

    # Step 5: Finalize and extract
    finalized = finalize_psbt(combined)
    raw_tx = extract_transaction(finalized)

    print(f"Final transaction: {raw_tx[:50]}...")
    return raw_tx
Enter fullscreen mode Exit fullscreen mode

This pattern where a PSBT is passed between parties as a base64 string, each signing their portion is the standard for multisig coordination in modern wallets.


PSBT v2 (BIP 370)

BIP 174 defined PSBT v1. BIP 370 extends it with PSBT v2, which adds:

  • Independent input/output modification: In v1, the full transaction must be fixed before any signing. In v2, inputs and outputs can be added incrementally, which is required for coinjoin protocols where participants add their own inputs and outputs.
  • Explicit version field: key=0x03 in the global section
  • Per-input fields: PSBT_IN_PREVIOUS_TXID, PSBT_IN_OUTPUT_INDEX, PSBT_IN_SEQUENCE, PSBT_IN_REQUIRED_TIME_LOCKTIME, PSBT_IN_REQUIRED_HEIGHT_LOCKTIME

PSBTv2 is used by coinjoin implementations and by protocols where the transaction is constructed collaboratively (e.g., LSPS1 channel purchase coordination, submarine swap protocols).

def create_psbt_v2(participants: list[dict]) -> str:
    """
    PSBTv2 for coinjoin-style construction.
    Each participant adds their own input and output.
    """
    psbt = PartiallySignedTransactionV2()
    psbt.version = 2

    for participant in participants:
        # Each adds their UTXO as an input
        psbt.add_input(
            txid=participant['txid'],
            vout=participant['vout'],
            witness_utxo=participant['utxo'],
            bip32_derivation=participant['derivation']
        )
        # Each adds their output
        psbt.add_output(
            value=participant['receive_value'],
            script=participant['receive_script']
        )

    return base64.b64encode(psbt.serialize()).decode()
Enter fullscreen mode Exit fullscreen mode

Using PSBT with Bitcoin Core

# Create a PSBT via Bitcoin Core RPC
bitcoin-cli walletcreatefundedpsbt \
  '[{"txid":"...", "vout":0}]' \
  '[{"bc1q...": 0.001}]'

# Inspect a PSBT
bitcoin-cli decodepsbt "cHNidP8B..."

# Have the wallet sign it
bitcoin-cli walletprocesspsbt "cHNidP8B..."

# Finalize and extract
bitcoin-cli finalizepsbt "cHNidP8B..."

# Or combine multiple PSBTs
bitcoin-cli combinepsbt '["cHNidP8B...", "cHNidP8B..."]'
Enter fullscreen mode Exit fullscreen mode

Bitcoin Core's PSBT support is complete and production-tested. If you are building server-side tooling, delegating PSBT operations to Bitcoin Core via RPC is more reliable than implementing the lifecycle yourself.


Where PSBT Is Used Today

Hardware wallets: Every major hardware wallet (Ledger, Trezor, Coldcard, Bitbox) uses PSBT as its signing interface. Coldcard was the first and remains the reference implementation for air-gapped PSBT signing.

Multisig coordinators: Sparrow Wallet, Specter, and Liana all use PSBT as the coordination format for multisig setups.

Lightning channel opens: LND and CLN use PSBT internally for externally funded channel opens, where the funding transaction is signed by an external wallet before being broadcast.

Coinjoin: Joinstr, WabiSabi (Wasabi), and similar protocols use PSBTv2 for collaborative transaction construction.

Miniscript: Miniscript (a structured subset of Bitcoin Script) integrates directly with PSBT, descriptors specify the spending policy, and PSBT carries the partial signatures.


Further Reading

Top comments (0)