A Python Guide to Multisigs and PSBTs

NaiyomaNaiyoma
7 min read

Imagine Alice and Bob are running a business together. They need to authorize joint expenses, so they opt for a 2-of-2 multisig wallet. Valuing flexibility and security, they encounter a challenge, sometimes Alice is offline and unable to sign transactions. Enter, Partially Signed Bitcoin Transactions (PSBTs). The guide below offers a step-by-step walk-through for Alice and Bob to create a 2-of-2 multisig wallet, fund it, and explore PSBTs by creating, signing, and broadcasting a transaction using a Python script.

What is a Partially Signed Bitcoin Transaction (PSBT)?

PSBT is a format that allows for a transaction to have incomplete signatures, this enables other parties to add their signatures independently to the same transaction. Before signing, the signer will have access to the UTXO values and metadata, and once all the signatures are added and other conditions are met the transaction can then be propagated to the network. BIP174

Before diving into the script, let's visualize the overall process that Alice and Bob will follow using the diagram below.

Prerequisites.

  • Ensure you have Python 3 installed.

  • Run a Bitcoind Node.

  • Install bitcoinrpc.

  • Create a Python file for the script.

Inside the Python script, start by importing bitcoinrpc and creating a wallet. Follow this guide to complete the setup and create a wallet.

  1. Create a Multisig.

    Creating a 2/2 multisig requires two public keys.

    Alice and Bob need to generate private keys, which will be used as valid signatures. This means for funds to be spent from the multisig address, both Alice and Bob must provide valid signatures.

    Therefore the locking script would be:

    2 <public_key_1> <public_key_2> 2 checkmultisig

    and the unlocking script will have two signatures for each public key

    <signature_1> <signature_2>

    we'll start by generating a new address for both Alice and Bob using getnewaddress function and then use the address information to get the public keys.

     address_1 = rpc_connection.getnewaddress("Alice")
     address_2 = rpc_connection.getnewaddress("Bob")
     address_1_info = rpc_connection.getaddressinfo(address_1)
     address_2_info = rpc_connection.getaddressinfo(address_2)
     #generate public keys
     pubkey_1 = address_1['pubkey']
     pubkey_2 = address_2['pubkey']
    

    createmultisig requires two parameters, n (which is the number of signatures out of the total number of public keys provided), and a list of the public keys, pubkey_1, and pubkey_2.

    createmultisig <n> < [pubkey_1, pubkey_2]>

     multisig = rpc_connection.createmultisig(2, [pubkey_1, pubkey_2])
     multisig_address = multisig['address']
    

    the sample output for creating a multisig will include:

    • multisig address: `'2NCXPeYuPcvp6sujMzGJDzCPGEr1DHKzZou`

      this is a P2SH address(on regtest) and is what we will use in the next step to fund the wallet.

    • a redeem script: '522103d3d8e5f505bed4bae9652451b5051dd75fb2a58754d5c774f43842730be8fc39210398027163a2f633a7549a6caacf0d38526e9b94824f33a885c836a9bfb4033c4552ae' this is a hexadecimal representation of the spending conditions for the funds locked in the multisig address. It can be broken down into 4 parts:

      a bytes string containing the number of signatures required, the first compressed public key, the second compressed public key, and an opcode that verifies the if signatures from the required number of public keys are valid.

    • a descriptor: 'sh(multi(2,03d3d8e5f505bed4bae9652451b5051dd75fb2a58754d5c774f43842730be8fc39,0398027163a2f633a7549a6caacf0d38526e9b94824f33a885c836a9bfb4033c45)) this is a Pay to Script Hash(P2SH) for a 2-of-2 multisig with public key 1 and public key 2 in order.

  2. Fund a Multisig Address.

    Next Alice wants to lock some funds in the wallet to spend later on. We'll do this using the sendtoaddress function and set the recipient to be the multisig_address.

    sendtoaddress <address> <amount>

     send_transaction = rpc_connection.sendtoaddress(multisig_address, 0.0001, "Track expenses" "Alice")
    
  3. Create a PSBT.

    To create a PSBT one can choose between createpsbt and walletcreatefundedpsbt , Although both functions accept similar parameters the output for each will be different, createpsbt does not access the wallet's utxo, therefore resulting in an empty PSBT being formed [refrence], while walletcreatefundedpsbt will leverage on the wallet's utxo to select the outputs that will fund the transaction, therefore generating a PSBT with inputs and outputs.

    The walletcreatefundedpsbt function requires two parameters:

    • txid : this is the id of the transaction containing the utxo that can be used to fund the psbt (in our example we are using the utxo from the send transaction.)

    • vout: this is the index of the output we want to use. It represents the position of the output within the transaction specified by the txid.

    • data: this param is not required however it's a hexadecimal field that can used to include extra information or metadata into the transaction.

        raw_transaction_details = rpc_connection.getrawtransaction(send_transaction, True)
      
        psbt = rpc_connection.walletcreatefundedpsbt([
            {
                "txid":raw_transaction_details['txid'] ,
                "vout": 0,
            }
        ], [
             {
                "data": "00010203"  
            }
        ])
      

We can now use the decodepsbt to view the details of the created PSBT.

    decode = rpc_connection.decodepsbt(psbt)

sample output:


    {
      "tx": {
        "txid": "18d26c0e8804bdc2a950e9c61897c1a3496a25a479f6df9c4b7fe53aa6168d5a",
        "hash": "18d26c0e8804bdc2a950e9c61897c1a3496a25a479f6df9c4b7fe53aa6168d5a",
        "version": 2,
        "size": 109,
        "vsize": 109,
        "weight": 436,
        "locktime": 0,
        "vin": [
          {
            "txid": "4b9ad6065d504b22a4e64683bfcd732ff4d9077067d010029e9204fe240ddb3c",
            "vout": 0,
            "scriptSig": {
              "asm": "",
              "hex": ""
            },
            "sequence": 4294967295
          }
        ],
        "vout": [
          {
            "value": "0E-8",
            "n": 0,
            "scriptPubKey": {
              "asm": "OP_RETURN 50462976",
              "desc": "raw(6a0400010203)#6scht25q",
              "hex": "6a0400010203",
              "type": "nulldata"
            }
          },
          {
            "value": "0.01066675",
            "n": 1,
            "scriptPubKey": {
              "asm": "1 25c13646a28a9978156cd5115c5a866c7b1c4e84dc4857f33ee8ecec61644405",
              "desc": "addr(bcrt1pyhqnv34z32vhs9tv65g4ck5xd3a3cn5ym3y90ue7arkwcctygszsl6nq4v)#tf8gqjha",
              "hex": "512025c13646a28a9978156cd5115c5a866c7b1c4e84dc4857f33ee8ecec61644405",
              "address": "bcrt1pyhqnv34z32vhs9tv65g4ck5xd3a3cn5ym3y90ue7arkwcctygszsl6nq4v",
              "type": "witness_v1_taproot"
            }
          }
        ]
      },
      "unknown": {},
      "inputs": [
        {
          "witness_utxo": {
            "amount": "0.01066835",
            "scriptPubKey": {
              "asm": "OP_HASH160 97bff8e1180e8cbbe203a5f67a8e80025a20a727 OP_EQUAL",
              "desc": "addr(2N75bziDzTX2TCDHNb8j8B1THP7NEjbZaxA)#3w75gcz7",
              "hex": "a91497bff8e1180e8cbbe203a5f67a8e80025a20a72787",
              "address": "2N75bziDzTX2TCDHNb8j8B1THP7NEjbZaxA",
              "type": "scripthash"
            }

View the complete sample output here

The output will have 4 key elements transaction, inputs, outputs, and fees(optional).

  • Transaction:

This section will include the transition details of the PSBT, that is the txid,hash,version,weight ,vin (the txid of the previous utxo and the and vout (which is the index of the spendable outputs in the previous transaction)

  • Inputs:

This section provides all the necessary information about the inputs being spent in the PSBT, including the transaction id, the amount being spent, locking conditions(scriptpubkey), redeem script, and BIP32 derivation path.

  • Outputs:

Contains the following details: the amount that the PSBT can spend, the index number of that output, and the scriptPubKey.

  • Fees(optional):

The transaction fee for a PSBT is optional because, In multi-party signing scenarios, each participant might have different fee preferences. Allowing separate fee calculations and adjustments helps with flexibility.

The `scriptSig` in the input section typically holds the signatures for a transaction, from the sample output above we can see that it's empty indicating an absence of signatures.

  1. Sign the transaction
    We now have an unsigned PSBT which we can distribute to both Alice and Bob for signing using walletprocesspsbt.

    walletprocesspsbt validates a PSBT by adding all the input utxos to the wallet, checks the wallet's private keys, and then signs the transactions based on the specifications in the scriptpubkey.

     # Alice signs the PSBT
     alice_signed_psbt = rpc_connection.walletprocesspsbt(psbt["psbt"])
     print("Alice's Signed PSBT:", alice_signed_psbt)
    
     # Bob signs the PSBT
     bob_signed_psbt = rpc_connection.walletprocesspsbt(psbt["psbt"])
     print("Bob's Signed PSBT:", bob_signed_psbt)
    

    the output will include two PSBTs for both Alice and Bob with two fields a psbt which is the updated transaction after the process is concluded and complete : true which indicates that the transactions are now fully signed and ready to be combined.

  2. Combine PSBTs

    Before finalizing the process, we need to combine Alice's and Bob's PSBTs using the combinepsbt function. This function takes a list of PSBTs, checks for signed inputs in each PSBT, and adds these inputs to a single transaction.[refrence]
    combinepsbt(psbt1,psbt2)

     combined_psbt = rpc_connection.combinepsbt([alice_signed_psbt["psbt"], bob_signed_psbt["psbt"]])
    

    For our case, since we have a 2-of-2 multisig, the decoded combined PSBT will include a final scriptSig containing the signatures for both Alice and Bob.

     "final_scriptSig": {
         "asm": "0014e60e84576edb945ba92294e6d1a3c97def2087f5",
         "hex": "160014e60e84576edb945ba92294e6d1a3c97def2087f5"
     },
     "final_scriptwitness": [
         "3044022005ece9ae25e67729111dadf21389b30075bcd0900f990edb36631e138d54aca302207590b0ba4975f8026eb227e4ca9bf5d01a74af8c2fd19fddb84dbf32bd3edbf501",
         "03e94a1d7536aa61cdd82498111935e3829f7350b1d48764d929166ed9709517a3"
     ]
    
  3. Finalize and broadcast the transaction.

    After combining PSBTs the final step is to finalize the transaction using, finalizepsbt function, which takes a PSBT, consolidates the input signatures, and produces a fully signed transaction in hexadecimal format. This function will output the finalized PSBT hex string and a 'complete': true status.

    Finally, let's broadcast the transaction using the sendrawtransaction function and the hex from the finalized psbt.

    
       finalize_psbt = rpc_connection.finalizepsbt(combined_psbt)
       send_transaction = rpc_connection.sendrawtransaction(finalize_psbt["hex"])
    

    Checkout the complete script here

    Conclusion

    In summary, we have seen how one can create a multisig and then use it to generate a PSBT.

    PSBTs have many more use cases and have currently been adopted by some Hard wallets such as Coldcard.

    To further understand, consider exploring the additional readings.
    https://bitcoin.stackexchange.com/questions/85624/what-is-the-exact-difference-between-combinepsbt-and-joinpsbts#85636
    https://bitcoinops.org/en/topics/psbt/
    https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki

    https://bitcoin.stackexchange.com/questions/57253/how-do-i-spend-bitcoins-from-multiple-wallets-in-a-single-transaction#89169

1
Subscribe to my newsletter

Read articles from Naiyoma directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Naiyoma
Naiyoma